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}