v0.12.1 1453d21

webview.go RawHistoryFolder

1package browser
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"log"
8	"os"
9	"path/filepath"
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/messaging"
15	"github.com/bnema/dumber/internal/config"
16	"github.com/bnema/dumber/internal/filtering"
17	"github.com/bnema/dumber/internal/services"
18	"github.com/bnema/dumber/pkg/webkit"
19)
20
21// Note: Memory management and color palettes are now handled at the application level
22// The gotk4 webkit.Config only contains basic WebKit settings
23
24func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {
25	// Ensure data directories exist for WebKit
26	dataDir, err := config.GetDataDir()
27	if err != nil {
28		return nil, err
29	}
30	stateDir, err := config.GetStateDir()
31	if err != nil {
32		return nil, err
33	}
34
35	webkitData := filepath.Join(dataDir, "webkit")
36	webkitCache := filepath.Join(stateDir, "webkit-cache")
37	if err := os.MkdirAll(webkitData, constants.DirPerm); err != nil {
38		return nil, err
39	}
40	if err := os.MkdirAll(webkitCache, constants.DirPerm); err != nil {
41		return nil, err
42	}
43
44	// Build WebKit configuration with data directories for persistence
45	cfg := &webkit.Config{
46		UserAgent:            buildUserAgent(app.config),
47		EnableJavaScript:     true,
48		EnableWebGL:          true,
49		EnableMediaStream:    true,
50		HardwareAcceleration: true,
51		DefaultFontSize:      app.config.Appearance.DefaultFontSize,
52		MinimumFontSize:      8,
53		DataDir:              webkitData,
54		CacheDir:             webkitCache,
55		AppearanceConfigJSON: app.buildAppearanceConfigJSON(),
56		CreateWindow:         true, // Default to creating a window for standalone WebViews
57	}
58
59	return cfg, nil
60}
61
62// buildUserAgent constructs the user agent string from config
63func buildUserAgent(cfg *config.Config) string {
64	if cfg.CodecPreferences.CustomUserAgent != "" {
65		return cfg.CodecPreferences.CustomUserAgent
66	}
67	// Use default WebKit user agent
68	return "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
69}
70
71// buildAppearanceConfigJSON builds the palette configuration as JSON
72// The GUI expects window.__dumber_palette with just the palette data
73func (app *BrowserApp) buildAppearanceConfigJSON() string {
74	if app.config == nil {
75		return ""
76	}
77
78	// Build palette config in the format main-world.ts expects
79	// It looks for window.__dumber_palette = { "light": {...}, "dark": {...} }
80	paletteConfig := map[string]interface{}{
81		"light": map[string]string{
82			"background":      app.config.Appearance.LightPalette.Background,
83			"surface":         app.config.Appearance.LightPalette.Surface,
84			"surface_variant": app.config.Appearance.LightPalette.SurfaceVariant,
85			"text":            app.config.Appearance.LightPalette.Text,
86			"muted":           app.config.Appearance.LightPalette.Muted,
87			"accent":          app.config.Appearance.LightPalette.Accent,
88			"border":          app.config.Appearance.LightPalette.Border,
89		},
90		"dark": map[string]string{
91			"background":      app.config.Appearance.DarkPalette.Background,
92			"surface":         app.config.Appearance.DarkPalette.Surface,
93			"surface_variant": app.config.Appearance.DarkPalette.SurfaceVariant,
94			"text":            app.config.Appearance.DarkPalette.Text,
95			"muted":           app.config.Appearance.DarkPalette.Muted,
96			"accent":          app.config.Appearance.DarkPalette.Accent,
97			"border":          app.config.Appearance.DarkPalette.Border,
98		},
99	}
100
101	payload, err := json.Marshal(paletteConfig)
102	if err != nil {
103		log.Printf("[webview] Failed to marshal palette config: %v", err)
104		return ""
105	}
106
107	return string(payload)
108}
109
110func (app *BrowserApp) buildPane(view *webkit.WebView) (*BrowserPane, error) {
111	pane := &BrowserPane{
112		webView: view,
113	}
114
115	pane.clipboardController = control.NewClipboardController(view)
116	pane.zoomController = control.NewZoomController(app.browserService, view)
117	pane.navigationController = control.NewNavigationController(
118		app.parserService,
119		app.browserService,
120		view,
121		pane.zoomController,
122	)
123	pane.messageHandler = messaging.NewHandler(app.parserService, app.browserService)
124	pane.messageHandler.SetWebView(view)
125	pane.messageHandler.SetNavigationController(pane.navigationController)
126	pane.shortcutHandler = NewShortcutHandler(view, pane.clipboardController, app.config, app)
127
128	pane.zoomController.RegisterHandlers()
129	pane.shortcutHandler.RegisterShortcuts()
130
131	// Register navigation handler for workspace focus events if workspace exists
132	if app.workspace != nil {
133		app.workspace.RegisterNavigationHandler(view)
134	}
135
136	return pane, nil
137}
138
139func (app *BrowserApp) createPaneForView(view *webkit.WebView) (*BrowserPane, error) {
140	pane, err := app.buildPane(view)
141	if err != nil {
142		return nil, err
143	}
144
145	// Generate unique pane ID
146	pane.SetID(fmt.Sprintf("pane-%d-%p", time.Now().Unix(), view))
147	pane.initializeGUITracking()
148
149	// GUI manager will be loaded on-demand when needed
150	log.Printf("[webview] Created pane for webview: %d", view.ID())
151
152	app.attachPaneHandlers(pane)
153	return pane, nil
154}
155
156func (app *BrowserApp) attachPaneHandlers(pane *BrowserPane) {
157	if pane == nil || pane.webView == nil {
158		return
159	}
160
161	pane.webView.RegisterTitleChangedHandler(func(title string) {
162		if title == "" {
163			return
164		}
165		url := pane.webView.GetCurrentURL()
166		if url == "" {
167			return
168		}
169
170		// Update database with new title
171		go func(url, title string) {
172			ctx := context.Background()
173			if err := app.browserService.UpdatePageTitle(ctx, url, title); err != nil {
174				log.Printf("Warning: failed to update page title: %v", err)
175			}
176		}(url, title)
177
178		// Update stacked pane title bar if this pane is in a stack
179		if app.workspace != nil {
180			app.workspace.UpdateTitleBar(pane.webView, title)
181		}
182	})
183
184	pane.webView.RegisterScriptMessageHandler(func(payload string) {
185		if app.workspace != nil && shouldFocusForScriptMessage(payload) {
186			app.workspace.focusByView(pane.webView)
187		}
188		pane.messageHandler.Handle(payload)
189	})
190
191	if pane.messageHandler != nil && pane.navigationController != nil {
192		pane.messageHandler.SetNavigationController(pane.navigationController)
193	}
194
195	// Setup WebKit-native popup lifecycle using create/ready-to-show/close signals
196	if app.workspace != nil {
197		node := app.workspace.GetNodeForWebView(pane.webView)
198		if node != nil {
199			app.workspace.setupPopupHandling(pane.webView, node)
200			log.Printf("[webview] Setup native popup handling for WebView ID: %d", pane.webView.ID())
201		}
202	}
203}
204
205// shouldFocusForScriptMessage filters script messages and only allows focus handoff
206// for explicit user-driven actions (navigation, history commands, popup lifecycle).
207// Background bootstrap messages (palette sync, workspace bridge chatter, etc.) are
208// ignored so they don't steal focus from the user's active pane.
209func shouldFocusForScriptMessage(payload string) bool {
210	var msg messaging.Message
211	if err := json.Unmarshal([]byte(payload), &msg); err != nil {
212		return true // keep legacy behaviour if parsing fails
213	}
214
215	switch msg.Type {
216	case "navigate", "query", "wails", "history_recent", "history_stats", "history_search", "history_delete", "close-popup":
217		return true
218	default:
219		return false
220	}
221}
222
223// createWebView creates and configures the WebView
224func (app *BrowserApp) createWebView() error {
225	log.Printf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable())
226
227	cfg, err := app.buildWebkitConfig()
228	if err != nil {
229		return err
230	}
231
232	// Note: Window creation is now handled automatically by webkit.NewWebView
233	view, err := webkit.NewWebView(cfg)
234
235	if err != nil {
236		return err
237	}
238
239	pane, err := app.createPaneForView(view)
240	if err != nil {
241		return err
242	}
243
244	app.webView = view
245	app.zoomController = pane.zoomController
246	app.navigationController = pane.navigationController
247	app.clipboardController = pane.clipboardController
248	app.messageHandler = pane.messageHandler
249	app.shortcutHandler = pane.shortcutHandler
250	app.panes = []*BrowserPane{pane}
251	app.activePane = pane
252
253	if app.workspace == nil {
254		app.workspace = NewWorkspaceManager(app, pane)
255		// CRITICAL: Register navigation handler for the root webview
256		// This is needed because buildPane() couldn't register it when workspace was nil
257		app.workspace.RegisterNavigationHandler(view)
258
259		// CRITICAL: Setup popup handling for the root webview
260		// This must be done after workspace is created so WebKit's create signal is connected
261		node := app.workspace.GetNodeForWebView(view)
262		if node != nil {
263			app.workspace.setupPopupHandling(view, node)
264			log.Printf("[webview] Setup native popup handling for root WebView ID: %d", view.ID())
265		} else {
266			log.Printf("[webview] WARNING: Could not find node for root WebView ID: %d", view.ID())
267		}
268	}
269
270	// Initialize window-level global shortcuts AFTER workspace is set up
271	if window := view.Window(); window != nil {
272		app.windowShortcutHandler = NewWindowShortcutHandler(window, app)
273		if app.windowShortcutHandler != nil {
274			log.Printf("Window-level global shortcuts initialized")
275		} else {
276			log.Printf("Warning: failed to initialize window-level shortcuts")
277		}
278	}
279
280	app.setupWebViewIntegration()
281
282	// Initialize FaviconService after WebView is created
283	if err := app.initializeFaviconService(); err != nil {
284		log.Printf("Warning: failed to initialize favicon service: %v", err)
285		// Continue without favicon service - not critical
286	}
287
288	// Apply initial zoom and show window
289	app.zoomController.ApplyInitialZoom()
290
291	log.Printf("Showing WebView window…")
292	if err := view.Show(); err != nil {
293		log.Printf("Warning: failed to show WebView: %v", err)
294	} else if !webkit.IsNativeAvailable() {
295		log.Printf("Notice: running without webkit_cgo tag — no native window will be displayed.")
296	}
297
298	return nil
299}
300
301// setupWebViewIntegration connects the WebView to browser services
302func (app *BrowserApp) setupWebViewIntegration() {
303	app.browserService.AttachWebView(app.webView)
304
305	// GUI bundle is loaded via WebKit user scripts in SetupUserContentManager
306	// Appearance config is also injected at document-start via UserContentManager
307
308	// Use native window as title updater
309	if win := app.webView.Window(); win != nil {
310		app.browserService.SetWindowTitleUpdater(win)
311	}
312}
313
314// initializeFaviconService creates and initializes the FaviconService
315func (app *BrowserApp) initializeFaviconService() error {
316	// Get the FaviconDatabase from the WebView
317	faviconDB := app.webView.GetFaviconDatabase()
318	if faviconDB == nil {
319		return fmt.Errorf("favicon database not available")
320	}
321
322	// Get the WebKit data directory
323	dataDir, err := config.GetDataDir()
324	if err != nil {
325		return fmt.Errorf("failed to get data directory: %w", err)
326	}
327	webkitData := filepath.Join(dataDir, "webkit")
328
329	// Create FaviconService
330	faviconService, err := services.NewFaviconService(faviconDB, app.queries, webkitData)
331	if err != nil {
332		return fmt.Errorf("failed to create favicon service: %w", err)
333	}
334
335	app.faviconService = faviconService
336	log.Printf("[favicon] FaviconService initialized successfully")
337	return nil
338}
339
340// setupContentBlocking initializes the content blocking system with proper timing
341func (app *BrowserApp) setupContentBlocking() error {
342	log.Printf("Initializing content blocking system...")
343
344	// Enable WebKit debug logging if requested
345	webkit.SetupWebKitDebugLogging()
346
347	defer func() {
348		if r := recover(); r != nil {
349			log.Printf("Content blocking setup panic recovered: %v", r)
350		}
351	}()
352
353	// Setup filter system
354	filterManager, err := filtering.SetupFilterSystem()
355	if err != nil {
356		log.Printf("Warning: Failed to setup filter system: %v", err)
357		return nil // Don't fail browser startup, continue without filters
358	}
359
360	// Store reference to filter manager
361	app.filterManager = filterManager
362
363	// Set up callback to apply network filters when they become ready
364	// This callback will be called from the async filter loading process
365	filterManager.SetFiltersReadyCallback(func() {
366		log.Printf("[filtering] Filters ready, applying to WebView...")
367
368		// Get the WebKit JSON rules from filter manager
369		filterJSON, err := filterManager.GetNetworkFilters()
370		if err != nil {
371			log.Printf("[filtering] Failed to get network filters: %v", err)
372			return
373		}
374
375		if len(filterJSON) == 0 {
376			log.Printf("[filtering] No network filters to apply")
377			return
378		}
379
380		log.Printf("[filtering] Got %d bytes of WebKit JSON rules", len(filterJSON))
381
382		// Apply filters to the WebView
383		// This must be done on the main thread since it touches GTK/WebKit
384		if app.webView != nil {
385			app.webView.RunOnMainThread(func() {
386				if err := app.webView.InitializeContentBlocking(filterJSON); err != nil {
387					log.Printf("[filtering] Failed to apply content filters: %v", err)
388				} else {
389					log.Printf("[filtering] ✅ Content blocking enabled successfully")
390				}
391			})
392		}
393	})
394
395	// Start async filter loading
396	// This allows filters to compile in the background while the browser starts
397	go func() {
398		if err := filtering.InitializeFiltersAsync(filterManager); err != nil {
399			log.Printf("Warning: failed to initialize filters asynchronously: %v", err)
400		}
401	}()
402
403	log.Printf("Content blocking system initialization started")
404	return nil
405}

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