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}