v0.15.0 9006957

browser.go RawHistoryFolder

1package browser
2
3import (
4	"context"
5	"database/sql"
6	"embed"
7	"fmt"
8	"os"
9	"os/signal"
10	"runtime"
11	"syscall"
12	"time"
13
14	"github.com/bnema/dumber/internal/app/api"
15	"github.com/bnema/dumber/internal/app/control"
16	"github.com/bnema/dumber/internal/app/environment"
17	"github.com/bnema/dumber/internal/app/messaging"
18	"github.com/bnema/dumber/internal/config"
19	"github.com/bnema/dumber/internal/db"
20	"github.com/bnema/dumber/internal/filtering"
21	"github.com/bnema/dumber/internal/logging"
22	"github.com/bnema/dumber/internal/services"
23	"github.com/bnema/dumber/pkg/webkit"
24	"golang.org/x/sync/errgroup"
25)
26
27// BrowserApp represents the main browser application
28type BrowserApp struct {
29	version   string
30	commit    string
31	buildDate string
32	assets    embed.FS
33
34	// Core components
35	config   *config.Config
36	database *sql.DB
37	queries  *db.Queries
38
39	// Services
40	parserService  *services.ParserService
41	browserService *services.BrowserService
42	faviconService *services.FaviconService
43
44	// WebView and controllers
45	webView              *webkit.WebView
46	zoomController       *control.ZoomController
47	navigationController *control.NavigationController
48	clipboardController  *control.ClipboardController
49
50	// Tab and workspace management
51	tabManager *TabManager
52
53	// Convenience accessors (delegate to active tab's workspace)
54	// These maintain compatibility with existing code
55	panes      []*BrowserPane
56	activePane *BrowserPane
57	workspace  *WorkspaceManager
58
59	// Content filtering
60	filterManager          *filtering.FilterManager
61	contentBlockingService *filtering.ContentBlockingService
62
63	// Handlers
64	schemeHandler         *api.SchemeHandler
65	messageHandler        *messaging.Handler
66	shortcutHandler       *ShortcutHandler
67	windowShortcutHandler WindowShortcutHandlerInterface
68}
69
70// Run starts the browser application
71func Run(assets embed.FS, version, commit, buildDate string) {
72	startupStart := time.Now()
73	logging.Info(fmt.Sprintf("Starting GUI mode (webkit_cgo=%v)", webkit.IsNativeAvailable()))
74
75	app := &BrowserApp{
76		version:   version,
77		commit:    commit,
78		buildDate: buildDate,
79		assets:    assets,
80	}
81
82	if err := app.Initialize(); err != nil {
83		logging.Error(fmt.Sprintf("Failed to initialize browser: %v", err))
84		if webkit.IsNativeAvailable() {
85			runtime.UnlockOSThread()
86		}
87		os.Exit(1)
88	}
89
90	startupElapsed := time.Since(startupStart)
91	logging.Info(fmt.Sprintf("[startup] Application initialized in %v", startupElapsed))
92	if startupElapsed > 500*time.Millisecond {
93		logging.Warn(fmt.Sprintf("[startup] WARNING: Startup took %v (target: <500ms)", startupElapsed))
94	}
95
96	app.Run()
97}
98
99// Initialize sets up all browser components
100func (app *BrowserApp) Initialize() error {
101	// Setup media pipeline hardening
102	environment.SetupMediaPipeline()
103
104	// Initialize configuration
105	if err := config.Init(); err != nil {
106		return err
107	}
108	app.config = config.Get()
109
110	// Apply environment configurations
111	environment.ApplyCodecConfiguration(app.config.CodecPreferences)
112
113	// Setup crash handler
114	logging.SetupCrashHandler()
115
116	// Setup output capture if configured
117	if err := app.setupOutputCapture(); err != nil {
118		logging.Warn(fmt.Sprintf("Warning: failed to setup output capture: %v", err))
119	}
120
121	// Initialize WebKit log capture if configured
122	if app.config.Logging.CaptureCOutput {
123		if err := webkit.InitWebKitLogCapture(); err != nil {
124			logging.Warn(fmt.Sprintf("Warning: failed to initialize WebKit log capture: %v", err))
125		} else {
126			defer webkit.StopWebKitLogCapture()
127			webkit.StartWebKitOutputCapture()
128		}
129	}
130
131	logging.Info(fmt.Sprintf("Config initialized"))
132
133	// Detect keyboard layout
134	environment.DetectAndSetKeyboardLocale()
135
136	// Initialize database (migrations run automatically in InitDB)
137	database, err := db.InitDB(app.config.Database.Path)
138	if err != nil {
139		return err
140	}
141	app.database = database
142	app.queries = db.New(database)
143	logging.Info(fmt.Sprintf("Database opened at %s", app.config.Database.Path))
144
145	// Cleanup expired certificate validations
146	if err := webkit.CleanupExpiredCertificateValidations(); err != nil {
147		logging.Warn(fmt.Sprintf("Warning: failed to cleanup expired certificate validations: %v", err))
148	}
149
150	// Initialize services
151	app.parserService = services.NewParserService(app.config, app.queries)
152	app.browserService = services.NewBrowserService(app.config, app.queries)
153
154	// Load all caches in parallel for fast startup (target: <100ms)
155	if err := app.loadCachesParallel(context.Background()); err != nil {
156		logging.Warn(fmt.Sprintf("Warning: failed to load caches: %v", err))
157		// Non-fatal - caches will fall back to defaults or DB queries
158	}
159
160	// Initialize handlers
161	app.schemeHandler = api.NewSchemeHandler(app.assets, app.parserService, app.browserService)
162	app.messageHandler = messaging.NewHandler(app.parserService, app.browserService)
163
164	return nil
165}
166
167// loadCachesParallel loads all caches in parallel for fast startup.
168// Uses errgroup to coordinate parallel loads and capture any errors.
169// Target: <100ms for all caches combined.
170func (app *BrowserApp) loadCachesParallel(ctx context.Context) error {
171	startTime := time.Now()
172
173	g, ctx := errgroup.WithContext(ctx)
174
175	// Load zoom cache in parallel
176	g.Go(func() error {
177		if err := app.browserService.LoadZoomCacheFromDB(ctx); err != nil {
178			logging.Error(fmt.Sprintf("[cache] Failed to load zoom cache: %v", err))
179			return err
180		}
181		return nil
182	})
183
184	// Load certificate validation cache in parallel
185	g.Go(func() error {
186		if err := app.browserService.LoadCertCacheFromDB(ctx); err != nil {
187			logging.Error(fmt.Sprintf("[cache] Failed to load cert cache: %v", err))
188			return err
189		}
190		return nil
191	})
192
193	// Load favorites cache in parallel
194	g.Go(func() error {
195		if err := app.browserService.LoadFavoritesCacheFromDB(ctx); err != nil {
196			logging.Error(fmt.Sprintf("[cache] Failed to load favorites cache: %v", err))
197			return err
198		}
199		return nil
200	})
201
202	// Load fuzzy search cache in parallel for instant dmenu access
203	g.Go(func() error {
204		if err := app.browserService.LoadFuzzyCacheFromDB(ctx); err != nil {
205			logging.Error(fmt.Sprintf("[cache] Failed to load fuzzy cache: %v", err))
206			return err
207		}
208		return nil
209	})
210
211	// Wait for all cache loads to complete
212	if err := g.Wait(); err != nil {
213		return fmt.Errorf("cache loading failed: %w", err)
214	}
215
216	elapsed := time.Since(startTime)
217	logging.Info(fmt.Sprintf("[cache] All caches loaded in %v", elapsed))
218
219	// Warn if startup is slower than target
220	if elapsed > 100*time.Millisecond {
221		logging.Warn(fmt.Sprintf("[cache] Warning: Cache loading took %v (target: <100ms)", elapsed))
222	}
223
224	return nil
225}
226
227// Run executes the main browser loop
228func (app *BrowserApp) Run() {
229	defer app.cleanup()
230	defer logging.SetupPanicRecovery()
231
232	// Set config on scheme handler
233	app.schemeHandler.SetConfig(app.config)
234
235	// Register custom scheme resolver for "dumb://" URIs (will be applied after WebView creation)
236	webkit.RegisterURIScheme("dumb", app.schemeHandler.Handle)
237
238	// Initialize content blocking BEFORE creating WebViews so the service is ready
239	// to register WebViews as they are created (tabs, workspaces, popups)
240	if err := app.setupContentBlocking(); err != nil {
241		logging.Warn(fmt.Sprintf("Warning: failed to setup content blocking: %v", err))
242		// Continue without content blocking
243	}
244
245	// Create and setup WebView (this also creates TabManager which creates more WebViews)
246	if err := app.createWebView(); err != nil {
247		logging.Warn(fmt.Sprintf("Warning: failed to create WebView: %v", err))
248		return
249	}
250
251	// Apply URI scheme handlers after WebView creation
252	if err := webkit.ApplyURISchemeHandlers(app.webView.GetWebView()); err != nil {
253		logging.Warn(fmt.Sprintf("Warning: failed to register URI scheme handlers: %v", err))
254	}
255
256	// Handle browse command if present (must use active tab's navigation controller)
257	if app.tabManager != nil {
258		activeTab := app.tabManager.GetActiveTab()
259		if activeTab != nil && activeTab.workspace != nil {
260			activePane := activeTab.workspace.GetActivePane()
261			if activePane != nil && activePane.navigationController != nil {
262				activePane.navigationController.HandleBrowseCommand()
263			}
264		}
265	} else {
266		// Fallback to legacy behavior if no tab manager
267		app.navigationController.HandleBrowseCommand()
268	}
269
270	// Setup signal handling
271	app.setupSignalHandling()
272
273	// Run main loop
274	app.runMainLoop()
275}
276
277// cleanup handles cleanup on shutdown
278func (app *BrowserApp) cleanup() {
279	logging.Info(fmt.Sprintf("Starting browser cleanup..."))
280
281	// Cleanup window shortcuts first
282	if app.windowShortcutHandler != nil {
283		logging.Info(fmt.Sprintf("Cleaning up window shortcuts"))
284		app.windowShortcutHandler.Cleanup()
285		app.windowShortcutHandler = nil
286	}
287
288	// Cleanup tab manager (which cleans up all tabs and their workspaces)
289	if app.tabManager != nil {
290		logging.Info(fmt.Sprintf("Cleaning up tab manager"))
291		app.tabManager.Cleanup()
292		app.tabManager = nil
293	}
294
295	// Flush all caches to ensure pending writes complete before database closes
296	if app.browserService != nil {
297		logging.Info(fmt.Sprintf("Flushing all caches..."))
298		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
299		defer cancel()
300		if err := app.browserService.FlushAllCaches(ctx); err != nil {
301			logging.Warn(fmt.Sprintf("Warning: failed to flush caches: %v", err))
302		}
303	}
304
305	// Close database with WAL checkpoint
306	if app.database != nil {
307		logging.Info(fmt.Sprintf("Performing WAL checkpoint and closing database..."))
308
309		// Run WAL checkpoint to commit all pending writes and truncate WAL file
310		if _, err := app.database.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
311			logging.Warn(fmt.Sprintf("Warning: WAL checkpoint failed: %v", err))
312		} else {
313			logging.Info(fmt.Sprintf("WAL checkpoint completed successfully"))
314		}
315
316		// Close database connection
317		if closeErr := app.database.Close(); closeErr != nil {
318			logging.Warn(fmt.Sprintf("Warning: failed to close database: %v", closeErr))
319		} else {
320			logging.Info(fmt.Sprintf("Database closed successfully"))
321		}
322	}
323
324	logging.Info(fmt.Sprintf("Browser cleanup completed"))
325}
326
327// setupOutputCapture initializes stdout/stderr capture if configured
328func (app *BrowserApp) setupOutputCapture() error {
329	if app.config.Logging.CaptureStdout || app.config.Logging.CaptureStderr {
330		// Capturing stdout/stderr works for non-GTK builds, but in native GTK mode
331		// it interferes with WebKit's own pipe management and crashes immediately.
332		if webkit.IsNativeAvailable() {
333			logging.Warn(fmt.Sprintf("Warning: stdout/stderr capture is not supported in native GTK mode; skipping"))
334			return nil
335		}
336
337		logging.Warn(fmt.Sprintf("Warning: stdout/stderr capture is experimental and may interfere with normal operations"))
338		if logger := logging.GetLogger(); logger != nil {
339			outputCapture := logging.NewOutputCapture(logger)
340			if err := outputCapture.Start(); err != nil {
341				return err
342			}
343			// Note: defer outputCapture.Stop() should be handled by the caller
344		}
345	}
346	return nil
347}
348
349// setupSignalHandling configures graceful shutdown on signals
350func (app *BrowserApp) setupSignalHandling() {
351	sigChan := make(chan os.Signal, 1)
352	signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
353
354	// Handle signals in a goroutine to quit the main loop
355	go func() {
356		sig := <-sigChan
357		logging.Info(fmt.Sprintf("Received signal %v - shutting down gracefully", sig))
358		webkit.QuitMainLoop()
359	}()
360}
361
362// runMainLoop starts the appropriate main loop based on WebKit availability
363func (app *BrowserApp) runMainLoop() {
364	if webkit.IsNativeAvailable() {
365		logging.Info(fmt.Sprintf("Entering GTK main loop…"))
366		webkit.RunMainLoop()
367		logging.Info(fmt.Sprintf("GTK main loop exited"))
368
369		// Flush pending history writes immediately after main loop exit
370		// This ensures database operations complete while GTK is still in a valid state
371		// MUST happen before cleanup() deferred call, which happens after this function returns
372		if app.browserService != nil {
373			logging.Info(fmt.Sprintf("Flushing pending history writes..."))
374			ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
375			defer cancel()
376			if err := app.browserService.FlushHistoryQueue(ctx); err != nil {
377				logging.Warn(fmt.Sprintf("Warning: history queue flush incomplete: %v", err))
378			} else {
379				logging.Info(fmt.Sprintf("History queue flushed successfully"))
380			}
381		}
382	} else {
383		logging.Info(fmt.Sprintf("Not entering GUI loop (non-CGO build)"))
384		// In non-CGO mode, just wait for signals
385		sigChan := make(chan os.Signal, 1)
386		signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
387		sig := <-sigChan
388		logging.Info(fmt.Sprintf("Received signal %v - exiting", sig))
389	}
390}

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