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}