v0.13.0 976176a

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

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