v0.14.1 0da30ba

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

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