v0.9.0 636c44c

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 (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {
49	dataDir, err := config.GetDataDir()
50	if err != nil {
51		return nil, err
52	}
53	stateDir, err := config.GetStateDir()
54	if err != nil {
55		return nil, err
56	}
57
58	webkitData := filepath.Join(dataDir, "webkit")
59	webkitCache := filepath.Join(stateDir, "webkit-cache")
60	if err := os.MkdirAll(webkitData, constants.DirPerm); err != nil {
61		return nil, err
62	}
63	if err := os.MkdirAll(webkitCache, constants.DirPerm); err != nil {
64		return nil, err
65	}
66
67	cfg := &webkit.Config{
68		Assets:                app.assets,
69		InitialURL:            "dumb://homepage",
70		ZoomDefault:           1.0,
71		EnableDeveloperExtras: true,
72		DataDir:               webkitData,
73		CacheDir:              webkitCache,
74		APIToken:              app.config.APISecurity.Token,
75		DefaultSansFont:       app.config.Appearance.SansFont,
76		DefaultSerifFont:      app.config.Appearance.SerifFont,
77		DefaultMonospaceFont:  app.config.Appearance.MonospaceFont,
78		DefaultFontSize:       app.config.Appearance.DefaultFontSize,
79		Rendering:             webkit.RenderingConfig{Mode: string(app.config.RenderingMode)},
80		UseDomZoom:            app.config.UseDomZoom,
81		VideoAcceleration: webkit.VideoAccelerationConfig{
82			EnableVAAPI:      app.config.VideoAcceleration.EnableVAAPI,
83			AutoDetectGPU:    app.config.VideoAcceleration.AutoDetectGPU,
84			VAAPIDriverName:  app.config.VideoAcceleration.VAAPIDriverName,
85			EnableAllDrivers: app.config.VideoAcceleration.EnableAllDrivers,
86			LegacyVAAPI:      app.config.VideoAcceleration.LegacyVAAPI,
87		},
88		Memory: buildMemoryConfig(app.config.WebkitMemory),
89		CodecPreferences: webkit.CodecPreferencesConfig{
90			PreferredCodecs:           strings.Split(app.config.CodecPreferences.PreferredCodecs, ","),
91			BlockedCodecs:             environment.BuildBlockedCodecsList(app.config.CodecPreferences),
92			ForceAV1:                  app.config.CodecPreferences.ForceAV1,
93			CustomUserAgent:           app.config.CodecPreferences.CustomUserAgent,
94			DisableTwitchCodecControl: app.config.CodecPreferences.DisableTwitchCodecControl,
95		},
96	}
97
98	return cfg, nil
99}
100
101func (app *BrowserApp) buildPane(view *webkit.WebView) (*BrowserPane, error) {
102	pane := &BrowserPane{
103		webView: view,
104	}
105
106	pane.clipboardController = control.NewClipboardController(view)
107	pane.zoomController = control.NewZoomController(app.browserService, view)
108	pane.navigationController = control.NewNavigationController(
109		app.parserService,
110		app.browserService,
111		view,
112		pane.zoomController,
113	)
114	pane.messageHandler = messaging.NewHandler(app.parserService, app.browserService)
115	pane.messageHandler.SetWebView(view)
116	pane.messageHandler.SetNavigationController(pane.navigationController)
117	pane.shortcutHandler = NewShortcutHandler(view, pane.clipboardController, app.config, app)
118
119	pane.zoomController.RegisterHandlers()
120	pane.shortcutHandler.RegisterShortcuts()
121
122	return pane, nil
123}
124
125func (app *BrowserApp) createPaneForView(view *webkit.WebView) (*BrowserPane, error) {
126	pane, err := app.buildPane(view)
127	if err != nil {
128		return nil, err
129	}
130
131	// Generate unique pane ID
132	pane.SetID(fmt.Sprintf("pane-%d-%p", time.Now().Unix(), view))
133	pane.initializeGUITracking()
134
135	// Inject minimal bootstrap script for pane initialization
136	bootstrapScript := fmt.Sprintf(`
137		window.__dumber_pane = {
138			id: '%s',
139			created: %d,
140			active: false
141		};
142
143		// GUI manager will be loaded on-demand
144		window.__dumber_gui_ready = true;
145
146		console.log('[pane] Initialized pane %s');
147	`, pane.ID(), time.Now().UnixMilli(), pane.ID())
148
149	if err := view.InjectScript(bootstrapScript); err != nil {
150		log.Printf("[pane-%s] Failed to inject bootstrap: %v", pane.ID(), err)
151	}
152
153	app.attachPaneHandlers(pane)
154	return pane, nil
155}
156
157func (app *BrowserApp) attachPaneHandlers(pane *BrowserPane) {
158	if pane == nil || pane.webView == nil {
159		return
160	}
161
162	pane.webView.RegisterTitleChangedHandler(func(title string) {
163		if title == "" {
164			return
165		}
166		url := pane.webView.GetCurrentURL()
167		if url == "" {
168			return
169		}
170		go func(url, title string) {
171			ctx := context.Background()
172			if err := app.browserService.UpdatePageTitle(ctx, url, title); err != nil {
173				log.Printf("Warning: failed to update page title: %v", err)
174			}
175		}(url, title)
176	})
177
178	pane.webView.RegisterScriptMessageHandler(func(payload string) {
179		if app.workspace != nil {
180			app.workspace.focusByView(pane.webView)
181		}
182		pane.messageHandler.Handle(payload)
183	})
184
185	if pane.messageHandler != nil && pane.navigationController != nil {
186		pane.messageHandler.SetNavigationController(pane.navigationController)
187	}
188
189	pane.webView.RegisterPopupHandler(func(uri string) *webkit.WebView {
190		if app.workspace != nil {
191			return app.workspace.HandlePopup(pane.webView, uri)
192		}
193
194		// No workspace manager - just navigate in current pane
195		if uri != "" && pane.navigationController != nil {
196			go func() {
197				if err := pane.navigationController.NavigateToURL(uri); err != nil {
198					log.Printf("[webview] failed to navigate to URL %s: %v", uri, err)
199				}
200			}()
201		}
202		return nil
203	})
204}
205
206// createWebView creates and configures the WebView
207func (app *BrowserApp) createWebView() error {
208	log.Printf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable())
209
210	cfg, err := app.buildWebkitConfig()
211	if err != nil {
212		return err
213	}
214
215	// Main window needs a top-level window
216	cfg.CreateWindow = true
217
218	view, err := webkit.NewWebView(cfg)
219
220	if err != nil {
221		return err
222	}
223
224	pane, err := app.createPaneForView(view)
225	if err != nil {
226		return err
227	}
228
229	app.webView = view
230	app.zoomController = pane.zoomController
231	app.navigationController = pane.navigationController
232	app.clipboardController = pane.clipboardController
233	app.messageHandler = pane.messageHandler
234	app.shortcutHandler = pane.shortcutHandler
235	app.panes = []*BrowserPane{pane}
236	app.activePane = pane
237
238	if app.workspace == nil {
239		app.workspace = NewWorkspaceManager(app, pane)
240	}
241
242	// Initialize window-level global shortcuts AFTER workspace is set up
243	if window := view.Window(); window != nil {
244		app.windowShortcutHandler = NewWindowShortcutHandler(window, app)
245		if app.windowShortcutHandler != nil {
246			log.Printf("Window-level global shortcuts initialized")
247		} else {
248			log.Printf("Warning: failed to initialize window-level shortcuts")
249		}
250	}
251
252	app.setupWebViewIntegration()
253
254	// Apply initial zoom and show window
255	app.zoomController.ApplyInitialZoom()
256
257	log.Printf("Showing WebView window…")
258	if err := view.Show(); err != nil {
259		log.Printf("Warning: failed to show WebView: %v", err)
260	} else if !webkit.IsNativeAvailable() {
261		log.Printf("Notice: running without webkit_cgo tag — no native window will be displayed.")
262	}
263
264	return nil
265}
266
267// setupWebViewIntegration connects the WebView to browser services
268func (app *BrowserApp) setupWebViewIntegration() {
269	app.browserService.AttachWebView(app.webView)
270
271	// GUI bundle is loaded via WebKit user scripts in enableUserContentManager
272	// No need to load separately in browser service
273
274	// Use native window as title updater
275	if win := app.webView.Window(); win != nil {
276		app.browserService.SetWindowTitleUpdater(win)
277	}
278}
279
280// setupContentBlocking initializes the content blocking system with proper timing
281func (app *BrowserApp) setupContentBlocking() error {
282	log.Printf("Initializing content blocking system...")
283
284	// Enable WebKit debug logging if requested
285	webkit.SetupWebKitDebugLogging(app.config)
286
287	defer func() {
288		if r := recover(); r != nil {
289			log.Printf("Content blocking setup panic recovered: %v", r)
290		}
291	}()
292
293	// Setup filter system
294	filterManager, err := filtering.SetupFilterSystem()
295	if err != nil {
296		log.Printf("Warning: Failed to setup filter system: %v", err)
297		return nil // Don't fail browser startup, continue without filters
298	}
299
300	// Store reference to filter manager
301	app.filterManager = filterManager
302
303	// Set up callback to re-apply network filters when they become ready
304	// This callback will be called from the async filter loading process
305	filterManager.SetFiltersReadyCallback(func() {
306		// Add small delay to avoid race conditions with WebView initialization
307		go func() {
308			time.Sleep(300 * time.Millisecond)
309			if app.webView != nil {
310				if err := app.webView.UpdateContentFilters(filterManager); err != nil {
311					log.Printf("Failed to apply filters after loading: %v", err)
312				} else {
313					log.Printf("Successfully applied filters after async loading")
314				}
315			}
316		}()
317	})
318
319	// Start async filter loading early (before WebView content blocking initialization)
320	// This allows filters to be ready when the WebView needs them
321	go func() {
322		if err := filtering.InitializeFiltersAsync(filterManager); err != nil {
323			log.Printf("Warning: failed to initialize filters asynchronously: %v", err)
324		}
325	}()
326
327	// Initialize content blocking in WebView with delay
328	// Wait for WebView to be fully loaded before setting up content blocking
329	go func() {
330		// Wait extra time for the first navigation to avoid preconnect interference
331		log.Printf("Waiting for WebView to complete initial load before enabling content blocking...")
332		time.Sleep(3000 * time.Millisecond) // 3 seconds for first load stability
333
334		if err := app.webView.InitializeContentBlocking(filterManager); err != nil {
335			log.Printf("Warning: Failed to initialize content blocking: %v", err)
336			// Continue without content blocking rather than failing
337		}
338
339		// Register navigation handler for domain-specific filtering and GUI injection
340		// Only register after content blocking is initialized
341		app.webView.RegisterURIChangedHandler(func(uri string) {
342			// Add small delay to avoid conflicts with page load
343			go func() {
344				time.Sleep(200 * time.Millisecond) // Slightly longer delay for stability
345
346				// Apply content filtering if available
347				if app.filterManager != nil {
348					app.webView.OnNavigate(uri, app.filterManager)
349				}
350
351				// Note: GUI bundle (controls and toast) is now injected as User Script
352				// in WebKit's enableUserContentManager, so it persists across all navigations
353			}()
354		})
355	}()
356
357	log.Printf("Content blocking system initialization started")
358	return nil
359}

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