v0.14.0 76d1892

browser.go RawHistoryFolder

1package browser
2
3import (
4	"context"
5	"database/sql"
6	"embed"
7	"fmt"
8	"log"
9	"os"
10	"os/signal"
11	"runtime"
12	"syscall"
13	"time"
14
15	"github.com/bnema/dumber/internal/app/api"
16	"github.com/bnema/dumber/internal/app/control"
17	"github.com/bnema/dumber/internal/app/environment"
18	"github.com/bnema/dumber/internal/app/messaging"
19	"github.com/bnema/dumber/internal/config"
20	"github.com/bnema/dumber/internal/db"
21	"github.com/bnema/dumber/internal/filtering"
22	"github.com/bnema/dumber/internal/logging"
23	"github.com/bnema/dumber/internal/services"
24	"github.com/bnema/dumber/pkg/webkit"
25	"golang.org/x/sync/errgroup"
26)
27
28// BrowserApp represents the main browser application
29type BrowserApp struct {
30	version   string
31	commit    string
32	buildDate string
33	assets    embed.FS
34
35	// Core components
36	config   *config.Config
37	database *sql.DB
38	queries  *db.Queries
39
40	// Services
41	parserService  *services.ParserService
42	browserService *services.BrowserService
43	faviconService *services.FaviconService
44
45	// WebView and controllers
46	webView              *webkit.WebView
47	zoomController       *control.ZoomController
48	navigationController *control.NavigationController
49	clipboardController  *control.ClipboardController
50
51	// Tab and workspace management
52	tabManager *TabManager
53
54	// Convenience accessors (delegate to active tab's workspace)
55	// These maintain compatibility with existing code
56	panes      []*BrowserPane
57	activePane *BrowserPane
58	workspace  *WorkspaceManager
59
60	// Content filtering
61	filterManager *filtering.FilterManager
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	log.Printf("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		log.Printf("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	log.Printf("[startup] Application initialized in %v", startupElapsed)
92	if startupElapsed > 500*time.Millisecond {
93		log.Printf("[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		log.Printf("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			log.Printf("Warning: failed to initialize WebKit log capture: %v", err)
125		} else {
126			defer webkit.StopWebKitLogCapture()
127			webkit.StartWebKitOutputCapture()
128		}
129	}
130
131	log.Printf("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	log.Printf("Database opened at %s", app.config.Database.Path)
144
145	// Cleanup expired certificate validations
146	if err := webkit.CleanupExpiredCertificateValidations(); err != nil {
147		log.Printf("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		log.Printf("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			log.Printf("[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			log.Printf("[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			log.Printf("[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			log.Printf("[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	log.Printf("[cache] All caches loaded in %v", elapsed)
218
219	// Warn if startup is slower than target
220	if elapsed > 100*time.Millisecond {
221		log.Printf("[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	// Create and setup WebView
239	if err := app.createWebView(); err != nil {
240		log.Printf("Warning: failed to create WebView: %v", err)
241		return
242	}
243
244	// Apply URI scheme handlers after WebView creation
245	if err := webkit.ApplyURISchemeHandlers(app.webView.GetWebView()); err != nil {
246		log.Printf("Warning: failed to register URI scheme handlers: %v", err)
247	}
248
249	// Initialize content blocking
250	if err := app.setupContentBlocking(); err != nil {
251		log.Printf("Warning: failed to setup content blocking: %v", err)
252		// Continue without content blocking
253	}
254
255	// Handle browse command if present (must use active tab's navigation controller)
256	if app.tabManager != nil {
257		activeTab := app.tabManager.GetActiveTab()
258		if activeTab != nil && activeTab.workspace != nil {
259			activePane := activeTab.workspace.GetActivePane()
260			if activePane != nil && activePane.navigationController != nil {
261				activePane.navigationController.HandleBrowseCommand()
262			}
263		}
264	} else {
265		// Fallback to legacy behavior if no tab manager
266		app.navigationController.HandleBrowseCommand()
267	}
268
269	// Setup signal handling
270	app.setupSignalHandling()
271
272	// Run main loop
273	app.runMainLoop()
274}
275
276// cleanup handles cleanup on shutdown
277func (app *BrowserApp) cleanup() {
278	log.Printf("Starting browser cleanup...")
279
280	// Cleanup window shortcuts first
281	if app.windowShortcutHandler != nil {
282		log.Printf("Cleaning up window shortcuts")
283		app.windowShortcutHandler.Cleanup()
284		app.windowShortcutHandler = nil
285	}
286
287	// Cleanup tab manager (which cleans up all tabs and their workspaces)
288	if app.tabManager != nil {
289		log.Printf("Cleaning up tab manager")
290		app.tabManager.Cleanup()
291		app.tabManager = nil
292	}
293
294	// Flush all caches to ensure pending writes complete before database closes
295	if app.browserService != nil {
296		log.Printf("Flushing all caches...")
297		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
298		defer cancel()
299		if err := app.browserService.FlushAllCaches(ctx); err != nil {
300			log.Printf("Warning: failed to flush caches: %v", err)
301		}
302	}
303
304	// Close database with WAL checkpoint
305	if app.database != nil {
306		log.Printf("Performing WAL checkpoint and closing database...")
307
308		// Run WAL checkpoint to commit all pending writes and truncate WAL file
309		if _, err := app.database.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
310			log.Printf("Warning: WAL checkpoint failed: %v", err)
311		} else {
312			log.Printf("WAL checkpoint completed successfully")
313		}
314
315		// Close database connection
316		if closeErr := app.database.Close(); closeErr != nil {
317			log.Printf("Warning: failed to close database: %v", closeErr)
318		} else {
319			log.Printf("Database closed successfully")
320		}
321	}
322
323	log.Printf("Browser cleanup completed")
324}
325
326// setupOutputCapture initializes stdout/stderr capture if configured
327func (app *BrowserApp) setupOutputCapture() error {
328	if app.config.Logging.CaptureStdout || app.config.Logging.CaptureStderr {
329		// Capturing stdout/stderr works for non-GTK builds, but in native GTK mode
330		// it interferes with WebKit's own pipe management and crashes immediately.
331		if webkit.IsNativeAvailable() {
332			log.Printf("Warning: stdout/stderr capture is not supported in native GTK mode; skipping")
333			return nil
334		}
335
336		log.Printf("Warning: stdout/stderr capture is experimental and may interfere with normal operations")
337		if logger := logging.GetLogger(); logger != nil {
338			outputCapture := logging.NewOutputCapture(logger)
339			if err := outputCapture.Start(); err != nil {
340				return err
341			}
342			// Note: defer outputCapture.Stop() should be handled by the caller
343		}
344	}
345	return nil
346}
347
348// setupSignalHandling configures graceful shutdown on signals
349func (app *BrowserApp) setupSignalHandling() {
350	sigChan := make(chan os.Signal, 1)
351	signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
352
353	// Handle signals in a goroutine to quit the main loop
354	go func() {
355		sig := <-sigChan
356		log.Printf("Received signal %v - shutting down gracefully", sig)
357		webkit.QuitMainLoop()
358	}()
359}
360
361// runMainLoop starts the appropriate main loop based on WebKit availability
362func (app *BrowserApp) runMainLoop() {
363	if webkit.IsNativeAvailable() {
364		log.Printf("Entering GTK main loop…")
365		webkit.RunMainLoop()
366		log.Printf("GTK main loop exited")
367
368		// Flush pending history writes immediately after main loop exit
369		// This ensures database operations complete while GTK is still in a valid state
370		// MUST happen before cleanup() deferred call, which happens after this function returns
371		if app.browserService != nil {
372			log.Printf("Flushing pending history writes...")
373			ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
374			defer cancel()
375			if err := app.browserService.FlushHistoryQueue(ctx); err != nil {
376				log.Printf("Warning: history queue flush incomplete: %v", err)
377			} else {
378				log.Printf("History queue flushed successfully")
379			}
380		}
381	} else {
382		log.Printf("Not entering GUI loop (non-CGO build)")
383		// In non-CGO mode, just wait for signals
384		sigChan := make(chan os.Signal, 1)
385		signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
386		sig := <-sigChan
387		log.Printf("Received signal %v - exiting", sig)
388	}
389}

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