detached 2dc133d

Merge pull request #219 from bnema/feature/fresh-browser-windows

feat: add fresh browser window relay and refresh purego stack
commit
2dc133deea5121665534268a981015020fd1af8e
author
bnema · · 2026-04-16 04:16 UTC
committer
GitHub · · 2026-04-16 04:16 UTC
merge of
a1aa08a + eb16ef1 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
71 files · +4579 -617
M .mockery.yaml +2 -0
@@ -74,6 +74,8 @@ packages:
7474 RuntimeVersionProbe: {}
7575 ScriptRefresher: {}
7676 SnapshotService: {}
77+ BrowserWindowOpener: {}
78+ BrowserLaunchRelay: {}
7779 ImageDataResolver: {}
7880 ResolvedImageSaver: {}
7981 MenuActionDelegator: {}
M cmd/dumber/main.go +57 -10
@@ -53,6 +53,9 @@ var initialURL string
5353 // restoreSessionID holds the session ID to restore on startup.
5454 var restoreSessionID string
5555
56+// browserLaunchRelay is shared for early browse handoff and in-app browser launches.
57+var browserLaunchRelay = desktop.NewBrowserLaunchRelay(xdg.New())
58+
5659 type launchMode string
5760
5861 const (
@@ -86,6 +89,14 @@ func launchModeFromArgs(args []string) (launchMode, string) {
8689 return launchModeCLI, ""
8790 }
8891
92+func tryForwardBrowseURLToRunningInstance(ctx context.Context, relay port.BrowserLaunchRelay, browseURL string) (bool, error) {
93+ if relay == nil || browseURL == "" {
94+ return false, nil
95+ }
96+
97+ return relay.DeliverOpenFreshWindow(ctx, browseURL)
98+}
99+
89100 func main() {
90101 enableCrashForensics()
91102
@@ -103,6 +114,17 @@ func main() {
103114 mode, browseURL := launchModeFromArgs(os.Args)
104115 // Run GUI mode for browse command
105116 if mode == launchModeBrowse {
117+ if forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), browserLaunchRelay, browseURL); err != nil {
118+ fmt.Fprintf(
119+ os.Stderr,
120+ "warning: failed to forward browse URL %q to a running instance, falling back to a new process: %v\n",
121+ browseURL,
122+ err,
123+ )
124+ } else if forwarded {
125+ os.Exit(0)
126+ }
127+
106128 initialURL = browseURL
107129 restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")
108130 os.Args = os.Args[:1]
@@ -187,6 +209,13 @@ func runGUI() int {
187209 log.Error().Err(err).Msg("failed to create application")
188210 return 1
189211 }
212+ if relaunchSetter, ok := engine.(port.AlreadyRunningAppRelaunchHandlerSetter); ok {
213+ relaunchSetter.SetAlreadyRunningAppRelaunchHandler(func(url string) {
214+ if err := app.OpenFreshWindow(ctx, url); err != nil {
215+ log.Warn().Err(err).Str("url", url).Msg("failed to open relaunch browser window")
216+ }
217+ })
218+ }
190219 timer.Mark("ui_deps")
191220 timer.Log(ctx)
192221
@@ -212,9 +241,15 @@ func runStandaloneOmnibox() int {
212241
213242 preInitializeAdwaitaForCEF(cfg, initResult, ui.EnsureAdwaitaInitialized)
214243
215- engine, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, false)
244+ // Standalone omnibox intentionally skips browser-engine initialization.
245+ // When the main browser is already running under CEF, spinning up another CEF
246+ // engine for the omnibox process triggers Chromium's already-running-app
247+ // relaunch path and can turn the `omnibox` subcommand into a bogus browser
248+ // URL (`omnibox/`). The standalone omnibox only needs repos/use cases/theme;
249+ // actual browser navigation is handed off via LaunchBrowserURL.
250+ repos, dbCleanup, err := initStandaloneOmniboxRepos()
216251 if err != nil {
217- logging.FromContext(ctx).Error().Err(err).Msg("failed to initialize standalone omnibox runtime")
252+ logging.FromContext(ctx).Error().Err(err).Msg("failed to initialize standalone omnibox repositories")
218253 return 1
219254 }
220255 if dbCleanup != nil {
@@ -228,7 +263,7 @@ func runStandaloneOmnibox() int {
228263 initResult.ThemeManager,
229264 initResult.ColorResolver,
230265 initResult.AdwaitaDetector,
231- engine,
266+ nil,
232267 repos,
233268 useCases,
234269 nil,
@@ -239,7 +274,7 @@ func runStandaloneOmnibox() int {
239274 logging.FromContext(ctx).Error().Err(err).Msg("failed to initialize standalone omnibox UI dependencies")
240275 return 1
241276 }
242- runtimeCfg := ui.NewStandaloneOmniboxRuntime(ctx, uiDeps, engine.FaviconDatabase())
277+ runtimeCfg := ui.NewStandaloneOmniboxRuntime(ctx, uiDeps, nil)
243278
244279 return ui.RunStandaloneOmnibox(ctx, runtimeCfg)
245280 }
@@ -419,12 +454,20 @@ func initStackAndRepos(
419454 return nil, nil, nil, err
420455 }
421456
422- lazyDB, err := bootstrap.CreateLazyDatabase()
457+ repos, dbCleanup, err := initStandaloneOmniboxRepos()
423458 if err != nil {
424459 return nil, nil, nil, err
425460 }
461+ return engine, repos, dbCleanup, nil
462+}
463+
464+func initStandaloneOmniboxRepos() (*repositories, func(), error) {
465+ lazyDB, err := bootstrap.CreateLazyDatabase()
466+ if err != nil {
467+ return nil, nil, err
468+ }
426469 dbCleanup := func() { _ = lazyDB.Close() }
427- return engine, createLazyRepositories(lazyDB), dbCleanup, nil
470+ return createLazyRepositories(lazyDB), dbCleanup, nil
428471 }
429472
430473 func configureDeferredInit(
@@ -695,6 +738,7 @@ func buildUIDependencies(
695738 }
696739
697740 focusProvider := textinput.NewFocusProvider()
741+ browserLauncher := desktop.NewBrowserLauncher(browserLaunchRelay)
698742
699743 uiDeps := &ui.Dependencies{
700744 Ctx: ctx,
@@ -738,7 +782,7 @@ func buildUIDependencies(
738782 },
739783 CheckUpdateUC: uc.checkUpdate,
740784 ApplyUpdateUC: uc.applyUpdate,
741- SessionSpawner: desktop.NewSessionSpawner(ctx),
785+ SessionSpawner: bootstrap.NewSessionSpawner(ctx, cfg),
742786 FileSystem: filesystem.New(),
743787 AccentFocusProvider: focusProvider,
744788 NewGTKEntryTarget: func(entry *gtk.SearchEntry) port.TextInputTarget {
@@ -759,9 +803,12 @@ func buildUIDependencies(
759803 return config.GetManager().Watch()
760804 },
761805 LaunchExternalURL: desktop.LaunchExternalURL,
762- LaunchBrowserURL: desktop.LaunchBrowserURL,
763- ConfigMigrator: config.NewMigrator(),
764- HandlerDeps: *handlerDeps,
806+ LaunchBrowserURL: func(uri string) {
807+ browserLauncher.LaunchURL(ctx, uri)
808+ },
809+ BrowserLaunchRelay: browserLaunchRelay,
810+ ConfigMigrator: config.NewMigrator(),
811+ HandlerDeps: *handlerDeps,
765812 }
766813
767814 return uiDeps, nil
M cmd/dumber/main_test.go +45 -0
@@ -1,9 +1,11 @@
11 package main
22
33 import (
4+ "context"
45 "errors"
56 "testing"
67
8+ "github.com/bnema/dumber/internal/application/port/mocks"
79 "github.com/bnema/dumber/internal/bootstrap"
810 "github.com/bnema/dumber/internal/infrastructure/colorscheme"
911 "github.com/bnema/dumber/internal/infrastructure/config"
@@ -131,3 +133,46 @@ func TestPreInitializeAdwaitaForCEF_SkipsNonCEF(t *testing.T) {
131133 t.Fatal("expected adwaita detector to remain unavailable")
132134 }
133135 }
136+
137+func TestTryForwardBrowseURLToRunningInstance_ReturnsTrueOnRelayHit(t *testing.T) {
138+ relay := mocks.NewMockBrowserLaunchRelay(t)
139+ relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, nil)
140+
141+ forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")
142+
143+ if err != nil {
144+ t.Fatalf("expected no error, got %v", err)
145+ }
146+ if !forwarded {
147+ t.Fatal("expected browse URL to be forwarded")
148+ }
149+}
150+
151+func TestTryForwardBrowseURLToRunningInstance_ReturnsFalseOnRelayMiss(t *testing.T) {
152+ relay := mocks.NewMockBrowserLaunchRelay(t)
153+ relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, nil)
154+
155+ forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")
156+
157+ if err != nil {
158+ t.Fatalf("expected no error, got %v", err)
159+ }
160+ if forwarded {
161+ t.Fatal("expected browse URL to remain unforwarded")
162+ }
163+}
164+
165+func TestTryForwardBrowseURLToRunningInstance_PropagatesError(t *testing.T) {
166+ relay := mocks.NewMockBrowserLaunchRelay(t)
167+ wantErr := errors.New("relay error")
168+ relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, wantErr)
169+
170+ forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), relay, "https://example.com")
171+
172+ if !errors.Is(err, wantErr) {
173+ t.Fatalf("expected error %v, got %v", wantErr, err)
174+ }
175+ if forwarded {
176+ t.Fatal("expected forwarded to be false on error")
177+ }
178+}
M go.mod +6 -9
@@ -3,16 +3,15 @@ module github.com/bnema/dumber
33 go 1.26.1
44
55 require (
6- github.com/bnema/purego-cef v0.8.1
7- github.com/bnema/purego-ffmpeg v0.3.0
8- github.com/bnema/purego-pipewire v0.1.1
9- github.com/bnema/purego-sqlite v0.1.1
10- github.com/bnema/puregotk v0.4.0
11- github.com/bnema/puregotk-webkit v0.0.16
6+ github.com/bnema/purego v0.11.0-bnema.2
7+ github.com/bnema/purego-cef v0.8.2
8+ github.com/bnema/purego-ffmpeg v0.3.1
9+ github.com/bnema/purego-pipewire v0.1.2
10+ github.com/bnema/purego-sqlite v0.1.2
11+ github.com/bnema/puregotk v0.5.1
1212 github.com/charmbracelet/bubbles v1.0.0
1313 github.com/charmbracelet/bubbletea v1.3.10
1414 github.com/charmbracelet/lipgloss v1.1.0
15- github.com/ebitengine/purego v0.10.0
1615 github.com/fsnotify/fsnotify v1.9.0
1716 github.com/godbus/dbus/v5 v5.2.2
1817 github.com/google/uuid v1.6.0
@@ -70,5 +69,3 @@ require (
7069 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
7170 modernc.org/sqlite v1.47.0 // indirect
7271 )
73-
74-replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-20260413043705-7850e384cde9
M go.sum +14 -16
@@ -4,20 +4,18 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
44 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
55 github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
66 github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
7-github.com/bnema/purego v0.0.0-20260413043705-7850e384cde9 h1:uY4oWlNAXh+5KArWcF7bjHaDwNoT9H3cr2zPa1vBvyI=
8-github.com/bnema/purego v0.0.0-20260413043705-7850e384cde9/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
9-github.com/bnema/purego-cef v0.8.1 h1:BtzCcKSuCZ/dS/LROi2eildY9zaZ8Axagy5dNzj6hV4=
10-github.com/bnema/purego-cef v0.8.1/go.mod h1:8XatJEof+SzApTWd8MSar1Vdkkivln/WH7yH++rcf5o=
11-github.com/bnema/purego-ffmpeg v0.3.0 h1:GRwW5RS2flUBvgZbluJkM5dKF0gvyUszMb5gDbvofzk=
12-github.com/bnema/purego-ffmpeg v0.3.0/go.mod h1:AU9ZBsUMq7G//DcP1u1qcLFmJw7HegS+wpk3QFycKac=
13-github.com/bnema/purego-pipewire v0.1.1 h1:1AflvkG08caOLJH9Y7h7Y7YM/Ttdi6nZ0wwgIg1/n5w=
14-github.com/bnema/purego-pipewire v0.1.1/go.mod h1:m+Ej80mItox2TRasF6m8L3fPXQaCgYHHY80LrC2kJLk=
15-github.com/bnema/purego-sqlite v0.1.1 h1:lH/F6/xxaI+LwWNHv3zof83bDJWQnib9OlNnBo/JnII=
16-github.com/bnema/purego-sqlite v0.1.1/go.mod h1:nRVrkBt8dFOlSGGOOFM/ynlk34TOU1RlOvH4T9KM91s=
17-github.com/bnema/puregotk v0.4.0 h1:YAcBC2ML6tvFy46EHtCj0b8sw1l9OrrOq7GIpz+0oTg=
18-github.com/bnema/puregotk v0.4.0/go.mod h1:Sl24sbCl02M/HLCNUgaYVCckkYcGOi8yxMuGuQVWsL8=
19-github.com/bnema/puregotk-webkit v0.0.16 h1:fYYtRLK3N4YgYmPkHpGZT6W+Mxu4EPu5I25H8nUv9oY=
20-github.com/bnema/puregotk-webkit v0.0.16/go.mod h1:5qlQ44TexU9UUyIR7PlEHmQZl3yARAWhqG+XWO5uv74=
7+github.com/bnema/purego v0.11.0-bnema.2 h1:7pzcdOJ8bGtLrCz6V582oyleP6css1ICQoGgj+/Ve4o=
8+github.com/bnema/purego v0.11.0-bnema.2/go.mod h1:5BnOIUj+0fvk/vwv45iottFoGfkDT7cnLXnpM2s6Hng=
9+github.com/bnema/purego-cef v0.8.2 h1:MOyKiR0wwq2koI8We77rx+Fw/u/LsSU/XqvckGJKVYc=
10+github.com/bnema/purego-cef v0.8.2/go.mod h1:AIW4lrXr4f2vesO4WnMDN49yAyfivYHV4x6hxpu5SmM=
11+github.com/bnema/purego-ffmpeg v0.3.1 h1:BWqRQvRXH3Lu7atem71Ai//fOYdNwuFofVF7uz/0XF0=
12+github.com/bnema/purego-ffmpeg v0.3.1/go.mod h1:0/PCk5gYU0Dl1taBgiJd8ttR7A3SFSJXeNJBljeqFGc=
13+github.com/bnema/purego-pipewire v0.1.2 h1:U057iOlpQatwlis6nfrZQJVrT3aGDFdagRLUz+ll7Ik=
14+github.com/bnema/purego-pipewire v0.1.2/go.mod h1:kchW7Bkfjc+NkX2IZw3LnlYfQxk6jxhl32Id88u04y8=
15+github.com/bnema/purego-sqlite v0.1.2 h1:dhqfe3KfNRBh7tlrwxL7bf4FPZk2VCajdBESMbrvmoE=
16+github.com/bnema/purego-sqlite v0.1.2/go.mod h1:fCvocfke59pjLrm5+y7aCQ3D5ZkiZbygLLJHkoezmRI=
17+github.com/bnema/puregotk v0.5.1 h1:Qgk12PHu1P5bsVD3tZ0io2a9wn3+dN60qZvKnkXASY0=
18+github.com/bnema/puregotk v0.5.1/go.mod h1:k9obstQtCopDsU4677xV9xJcMXMLbESRD5re7NAi7BM=
2119 github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
2220 github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
2321 github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@@ -59,8 +57,8 @@ github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlnd
5957 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
6058 github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
6159 github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
62-github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
63-github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
60+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
61+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
6462 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
6563 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
6664 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
A internal/application/port/browser_launch.go +25 -0
@@ -0,0 +1,25 @@
1+package port
2+
3+import (
4+ "context"
5+ "io"
6+)
7+
8+// BrowserWindowOpener opens a fresh browser window.
9+type BrowserWindowOpener interface {
10+ OpenFreshWindow(ctx context.Context, url string) error
11+}
12+
13+// BrowserLaunchRelay delivers fresh-window launch requests.
14+type BrowserLaunchRelay interface {
15+ // DeliverOpenFreshWindow attempts to deliver a request to open a fresh window.
16+ // The bool reports whether the relay accepted the request; false means the
17+ // caller should treat it as undelivered and try another path.
18+ DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error)
19+ Listen(ctx context.Context, opener BrowserWindowOpener) (io.Closer, error)
20+}
21+
22+// AlreadyRunningAppRelaunchHandlerSetter configures a handler for CEF relaunches.
23+type AlreadyRunningAppRelaunchHandlerSetter interface {
24+ SetAlreadyRunningAppRelaunchHandler(func(string))
25+}
M internal/application/port/mocks/mocks.go +299 -0
@@ -8,6 +8,7 @@ import (
88 "context"
99 "database/sql"
1010 "encoding/json"
11+ "io"
1112 "time"
1213
1314 "github.com/bnema/dumber/internal/application/port"
@@ -172,6 +173,251 @@ func (_c *MockAccentPickerUI_Show_Call) RunAndReturn(run func(accents []rune, se
172173 return _c
173174 }
174175
176+// NewMockBrowserWindowOpener creates a new instance of MockBrowserWindowOpener. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
177+// The first argument is typically a *testing.T value.
178+func NewMockBrowserWindowOpener(t interface {
179+ mock.TestingT
180+ Cleanup(func())
181+}) *MockBrowserWindowOpener {
182+ mock := &MockBrowserWindowOpener{}
183+ mock.Mock.Test(t)
184+
185+ t.Cleanup(func() { mock.AssertExpectations(t) })
186+
187+ return mock
188+}
189+
190+// MockBrowserWindowOpener is an autogenerated mock type for the BrowserWindowOpener type
191+type MockBrowserWindowOpener struct {
192+ mock.Mock
193+}
194+
195+type MockBrowserWindowOpener_Expecter struct {
196+ mock *mock.Mock
197+}
198+
199+func (_m *MockBrowserWindowOpener) EXPECT() *MockBrowserWindowOpener_Expecter {
200+ return &MockBrowserWindowOpener_Expecter{mock: &_m.Mock}
201+}
202+
203+// OpenFreshWindow provides a mock function for the type MockBrowserWindowOpener
204+func (_mock *MockBrowserWindowOpener) OpenFreshWindow(ctx context.Context, url string) error {
205+ ret := _mock.Called(ctx, url)
206+
207+ if len(ret) == 0 {
208+ panic("no return value specified for OpenFreshWindow")
209+ }
210+
211+ var r0 error
212+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
213+ r0 = returnFunc(ctx, url)
214+ } else {
215+ r0 = ret.Error(0)
216+ }
217+ return r0
218+}
219+
220+// MockBrowserWindowOpener_OpenFreshWindow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenFreshWindow'
221+type MockBrowserWindowOpener_OpenFreshWindow_Call struct {
222+ *mock.Call
223+}
224+
225+// OpenFreshWindow is a helper method to define mock.On call
226+// - ctx context.Context
227+// - url string
228+func (_e *MockBrowserWindowOpener_Expecter) OpenFreshWindow(ctx interface{}, url interface{}) *MockBrowserWindowOpener_OpenFreshWindow_Call {
229+ return &MockBrowserWindowOpener_OpenFreshWindow_Call{Call: _e.mock.On("OpenFreshWindow", ctx, url)}
230+}
231+
232+func (_c *MockBrowserWindowOpener_OpenFreshWindow_Call) Run(run func(ctx context.Context, url string)) *MockBrowserWindowOpener_OpenFreshWindow_Call {
233+ _c.Call.Run(func(args mock.Arguments) {
234+ var arg0 context.Context
235+ if args[0] != nil {
236+ arg0 = args[0].(context.Context)
237+ }
238+ var arg1 string
239+ if args[1] != nil {
240+ arg1 = args[1].(string)
241+ }
242+ run(
243+ arg0,
244+ arg1,
245+ )
246+ })
247+ return _c
248+}
249+
250+func (_c *MockBrowserWindowOpener_OpenFreshWindow_Call) Return(err error) *MockBrowserWindowOpener_OpenFreshWindow_Call {
251+ _c.Call.Return(err)
252+ return _c
253+}
254+
255+func (_c *MockBrowserWindowOpener_OpenFreshWindow_Call) RunAndReturn(run func(ctx context.Context, url string) error) *MockBrowserWindowOpener_OpenFreshWindow_Call {
256+ _c.Call.Return(run)
257+ return _c
258+}
259+
260+// NewMockBrowserLaunchRelay creates a new instance of MockBrowserLaunchRelay. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
261+// The first argument is typically a *testing.T value.
262+func NewMockBrowserLaunchRelay(t interface {
263+ mock.TestingT
264+ Cleanup(func())
265+}) *MockBrowserLaunchRelay {
266+ mock := &MockBrowserLaunchRelay{}
267+ mock.Mock.Test(t)
268+
269+ t.Cleanup(func() { mock.AssertExpectations(t) })
270+
271+ return mock
272+}
273+
274+// MockBrowserLaunchRelay is an autogenerated mock type for the BrowserLaunchRelay type
275+type MockBrowserLaunchRelay struct {
276+ mock.Mock
277+}
278+
279+type MockBrowserLaunchRelay_Expecter struct {
280+ mock *mock.Mock
281+}
282+
283+func (_m *MockBrowserLaunchRelay) EXPECT() *MockBrowserLaunchRelay_Expecter {
284+ return &MockBrowserLaunchRelay_Expecter{mock: &_m.Mock}
285+}
286+
287+// DeliverOpenFreshWindow provides a mock function for the type MockBrowserLaunchRelay
288+func (_mock *MockBrowserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) {
289+ ret := _mock.Called(ctx, url)
290+
291+ if len(ret) == 0 {
292+ panic("no return value specified for DeliverOpenFreshWindow")
293+ }
294+
295+ var r0 bool
296+ var r1 error
297+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (bool, error)); ok {
298+ return returnFunc(ctx, url)
299+ }
300+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok {
301+ r0 = returnFunc(ctx, url)
302+ } else {
303+ r0 = ret.Get(0).(bool)
304+ }
305+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
306+ r1 = returnFunc(ctx, url)
307+ } else {
308+ r1 = ret.Error(1)
309+ }
310+ return r0, r1
311+}
312+
313+// MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeliverOpenFreshWindow'
314+type MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call struct {
315+ *mock.Call
316+}
317+
318+// DeliverOpenFreshWindow is a helper method to define mock.On call
319+// - ctx context.Context
320+// - url string
321+func (_e *MockBrowserLaunchRelay_Expecter) DeliverOpenFreshWindow(ctx interface{}, url interface{}) *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call {
322+ return &MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call{Call: _e.mock.On("DeliverOpenFreshWindow", ctx, url)}
323+}
324+
325+func (_c *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call) Run(run func(ctx context.Context, url string)) *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call {
326+ _c.Call.Run(func(args mock.Arguments) {
327+ var arg0 context.Context
328+ if args[0] != nil {
329+ arg0 = args[0].(context.Context)
330+ }
331+ var arg1 string
332+ if args[1] != nil {
333+ arg1 = args[1].(string)
334+ }
335+ run(
336+ arg0,
337+ arg1,
338+ )
339+ })
340+ return _c
341+}
342+
343+func (_c *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call) Return(b bool, err error) *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call {
344+ _c.Call.Return(b, err)
345+ return _c
346+}
347+
348+func (_c *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call) RunAndReturn(run func(ctx context.Context, url string) (bool, error)) *MockBrowserLaunchRelay_DeliverOpenFreshWindow_Call {
349+ _c.Call.Return(run)
350+ return _c
351+}
352+
353+// Listen provides a mock function for the type MockBrowserLaunchRelay
354+func (_mock *MockBrowserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWindowOpener) (io.Closer, error) {
355+ ret := _mock.Called(ctx, opener)
356+
357+ if len(ret) == 0 {
358+ panic("no return value specified for Listen")
359+ }
360+
361+ var r0 io.Closer
362+ var r1 error
363+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.BrowserWindowOpener) (io.Closer, error)); ok {
364+ return returnFunc(ctx, opener)
365+ }
366+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.BrowserWindowOpener) io.Closer); ok {
367+ r0 = returnFunc(ctx, opener)
368+ } else {
369+ if ret.Get(0) != nil {
370+ r0 = ret.Get(0).(io.Closer)
371+ }
372+ }
373+ if returnFunc, ok := ret.Get(1).(func(context.Context, port.BrowserWindowOpener) error); ok {
374+ r1 = returnFunc(ctx, opener)
375+ } else {
376+ r1 = ret.Error(1)
377+ }
378+ return r0, r1
379+}
380+
381+// MockBrowserLaunchRelay_Listen_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Listen'
382+type MockBrowserLaunchRelay_Listen_Call struct {
383+ *mock.Call
384+}
385+
386+// Listen is a helper method to define mock.On call
387+// - ctx context.Context
388+// - opener port.BrowserWindowOpener
389+func (_e *MockBrowserLaunchRelay_Expecter) Listen(ctx interface{}, opener interface{}) *MockBrowserLaunchRelay_Listen_Call {
390+ return &MockBrowserLaunchRelay_Listen_Call{Call: _e.mock.On("Listen", ctx, opener)}
391+}
392+
393+func (_c *MockBrowserLaunchRelay_Listen_Call) Run(run func(ctx context.Context, opener port.BrowserWindowOpener)) *MockBrowserLaunchRelay_Listen_Call {
394+ _c.Call.Run(func(args mock.Arguments) {
395+ var arg0 context.Context
396+ if args[0] != nil {
397+ arg0 = args[0].(context.Context)
398+ }
399+ var arg1 port.BrowserWindowOpener
400+ if args[1] != nil {
401+ arg1 = args[1].(port.BrowserWindowOpener)
402+ }
403+ run(
404+ arg0,
405+ arg1,
406+ )
407+ })
408+ return _c
409+}
410+
411+func (_c *MockBrowserLaunchRelay_Listen_Call) Return(closer io.Closer, err error) *MockBrowserLaunchRelay_Listen_Call {
412+ _c.Call.Return(closer, err)
413+ return _c
414+}
415+
416+func (_c *MockBrowserLaunchRelay_Listen_Call) RunAndReturn(run func(ctx context.Context, opener port.BrowserWindowOpener) (io.Closer, error)) *MockBrowserLaunchRelay_Listen_Call {
417+ _c.Call.Return(run)
418+ return _c
419+}
420+
175421 // NewMockClipboard creates a new instance of MockClipboard. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
176422 // The first argument is typically a *testing.T value.
177423 func NewMockClipboard(t interface {
@@ -14689,6 +14935,59 @@ func (_c *MockXDGPaths_ManDir_Call) RunAndReturn(run func() (string, error)) *Mo
1468914935 return _c
1469014936 }
1469114937
14938+// RuntimeDir provides a mock function for the type MockXDGPaths
14939+func (_mock *MockXDGPaths) RuntimeDir() (string, error) {
14940+ ret := _mock.Called()
14941+
14942+ if len(ret) == 0 {
14943+ panic("no return value specified for RuntimeDir")
14944+ }
14945+
14946+ var r0 string
14947+ var r1 error
14948+ if returnFunc, ok := ret.Get(0).(func() (string, error)); ok {
14949+ return returnFunc()
14950+ }
14951+ if returnFunc, ok := ret.Get(0).(func() string); ok {
14952+ r0 = returnFunc()
14953+ } else {
14954+ r0 = ret.Get(0).(string)
14955+ }
14956+ if returnFunc, ok := ret.Get(1).(func() error); ok {
14957+ r1 = returnFunc()
14958+ } else {
14959+ r1 = ret.Error(1)
14960+ }
14961+ return r0, r1
14962+}
14963+
14964+// MockXDGPaths_RuntimeDir_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RuntimeDir'
14965+type MockXDGPaths_RuntimeDir_Call struct {
14966+ *mock.Call
14967+}
14968+
14969+// RuntimeDir is a helper method to define mock.On call
14970+func (_e *MockXDGPaths_Expecter) RuntimeDir() *MockXDGPaths_RuntimeDir_Call {
14971+ return &MockXDGPaths_RuntimeDir_Call{Call: _e.mock.On("RuntimeDir")}
14972+}
14973+
14974+func (_c *MockXDGPaths_RuntimeDir_Call) Run(run func()) *MockXDGPaths_RuntimeDir_Call {
14975+ _c.Call.Run(func(args mock.Arguments) {
14976+ run()
14977+ })
14978+ return _c
14979+}
14980+
14981+func (_c *MockXDGPaths_RuntimeDir_Call) Return(s string, err error) *MockXDGPaths_RuntimeDir_Call {
14982+ _c.Call.Return(s, err)
14983+ return _c
14984+}
14985+
14986+func (_c *MockXDGPaths_RuntimeDir_Call) RunAndReturn(run func() (string, error)) *MockXDGPaths_RuntimeDir_Call {
14987+ _c.Call.Return(run)
14988+ return _c
14989+}
14990+
1469214991 // StateDir provides a mock function for the type MockXDGPaths
1469314992 func (_mock *MockXDGPaths) StateDir() (string, error) {
1469414993 ret := _mock.Called()
M internal/application/port/session_state.go +12 -0
@@ -21,6 +21,18 @@ type SessionSpawner interface {
2121 SpawnWithSession(sessionID entity.SessionID) error
2222 }
2323
24+// SessionSpawnEnvironment provides engine-specific environment overrides for
25+// spawned restore-session processes. Implementations are optional: engines that
26+// do not need extra launch environment can leave this nil.
27+type SessionSpawnEnvironment interface {
28+ // RootCacheEnvVar returns the environment variable used to override the
29+ // engine's root cache/data directory.
30+ RootCacheEnvVar() string
31+ // SessionRootCachePath returns the engine-specific root cache/data path for
32+ // the provided restored session.
33+ SessionRootCachePath(sessionID entity.SessionID) string
34+}
35+
2436 // SnapshotService manages debounced session-state snapshots.
2537 type SnapshotService interface {
2638 // Start begins the snapshot service with the given context.
M internal/application/port/xdg.go +1 -0
@@ -5,6 +5,7 @@ type XDGPaths interface {
55 ConfigDir() (string, error)
66 DataDir() (string, error)
77 StateDir() (string, error)
8+ RuntimeDir() (string, error)
89 CacheDir() (string, error)
910
1011 FilterJSONDir() (string, error)
A internal/bootstrap/session_spawner.go +20 -0
@@ -0,0 +1,20 @@
1+package bootstrap
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/infrastructure/cef"
8+ "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/infrastructure/desktop"
10+)
11+
12+// NewSessionSpawner wires the session spawner with any engine-specific launch
13+// environment required by the current browser backend.
14+func NewSessionSpawner(ctx context.Context, cfg *config.Config) port.SessionSpawner {
15+ var spawnEnv port.SessionSpawnEnvironment
16+ if cfg != nil && cfg.Engine.ResolveEngineType() == config.EngineTypeCEF {
17+ spawnEnv = cef.SessionSpawnEnvironment{}
18+ }
19+ return desktop.NewSessionSpawner(ctx, spawnEnv)
20+}
M internal/cli/app.go +3 -0
@@ -10,6 +10,7 @@ import (
1010
1111 "github.com/bnema/dumber/internal/application/port"
1212 "github.com/bnema/dumber/internal/application/usecase"
13+ "github.com/bnema/dumber/internal/bootstrap"
1314 "github.com/bnema/dumber/internal/cli/styles"
1415 "github.com/bnema/dumber/internal/domain/build"
1516 "github.com/bnema/dumber/internal/domain/repository"
@@ -37,6 +38,7 @@ type App struct {
3738
3839 // Services
3940 FaviconService *favicon.Service
41+ SessionSpawner port.SessionSpawner
4042
4143 // Context with logger
4244 ctx context.Context
@@ -131,6 +133,7 @@ func NewApp() (*App, error) {
131133 RestoreUC: restoreUC,
132134 DeleteSessionUC: deleteSessionUC,
133135 FaviconService: faviconService,
136+ SessionSpawner: bootstrap.NewSessionSpawner(ctx, cfg),
134137 ctx: ctx,
135138 logCleanup: logCleanup,
136139 }, nil
M internal/cli/cmd/sessions.go +7 -3
@@ -13,7 +13,6 @@ import (
1313 "github.com/bnema/dumber/internal/cli/model"
1414 "github.com/bnema/dumber/internal/cli/styles"
1515 "github.com/bnema/dumber/internal/domain/entity"
16- "github.com/bnema/dumber/internal/infrastructure/desktop"
1716 )
1817
1918 const defaultSessionsLimit = 20
@@ -60,6 +59,7 @@ func runSessions(_ *cobra.Command, _ []string) error {
6059 ListSessionsUC: app.ListSessionsUC,
6160 RestoreUC: app.RestoreUC,
6261 DeleteSessionUC: app.DeleteSessionUC,
62+ SessionSpawner: app.SessionSpawner,
6363 CurrentSession: currentSessionID,
6464 MaxExitedSessions: app.Config.Session.MaxExitedSessions,
6565 })
@@ -181,8 +181,12 @@ func runSessionsRestore(_ *cobra.Command, args []string) error {
181181 }
182182
183183 // Spawn a new dumber instance with the session
184- spawner := desktop.NewSessionSpawner(app.Ctx())
185- if err := spawner.SpawnWithSession(sessionID); err != nil {
184+ if app.SessionSpawner == nil {
185+ err := fmt.Errorf("session spawner not available")
186+ fmt.Fprintln(os.Stderr, renderer.RenderError(err))
187+ return wrapPrintedError(err)
188+ }
189+ if err := app.SessionSpawner.SpawnWithSession(sessionID); err != nil {
186190 wrappedErr := fmt.Errorf("spawn browser: %w", err)
187191 fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr))
188192 return wrapPrintedError(wrappedErr)
M internal/cli/model/sessions.go +8 -3
@@ -11,10 +11,10 @@ import (
1111 tea "github.com/charmbracelet/bubbletea"
1212 "github.com/charmbracelet/lipgloss"
1313
14+ "github.com/bnema/dumber/internal/application/port"
1415 "github.com/bnema/dumber/internal/application/usecase"
1516 "github.com/bnema/dumber/internal/cli/styles"
1617 "github.com/bnema/dumber/internal/domain/entity"
17- "github.com/bnema/dumber/internal/infrastructure/desktop"
1818 "github.com/bnema/dumber/internal/logging"
1919 )
2020
@@ -43,6 +43,7 @@ type SessionsModel struct {
4343 listSessionsUC *usecase.ListSessionsUseCase
4444 restoreUC *usecase.RestoreSessionUseCase
4545 deleteSessionUC *usecase.DeleteSessionUseCase
46+ sessionSpawner port.SessionSpawner
4647 theme *styles.Theme
4748 }
4849
@@ -114,6 +115,7 @@ type SessionsModelConfig struct {
114115 ListSessionsUC *usecase.ListSessionsUseCase
115116 RestoreUC *usecase.RestoreSessionUseCase
116117 DeleteSessionUC *usecase.DeleteSessionUseCase
118+ SessionSpawner port.SessionSpawner
117119 CurrentSession entity.SessionID
118120 MaxExitedSessions int
119121 }
@@ -137,6 +139,7 @@ func NewSessionsModel(ctx context.Context, theme *styles.Theme, cfg SessionsMode
137139 listSessionsUC: cfg.ListSessionsUC,
138140 restoreUC: cfg.RestoreUC,
139141 deleteSessionUC: cfg.DeleteSessionUC,
142+ sessionSpawner: cfg.SessionSpawner,
140143 currentSession: cfg.CurrentSession,
141144 theme: theme,
142145 }
@@ -336,8 +339,10 @@ func (m SessionsModel) restoreSession(sessionID entity.SessionID) tea.Cmd {
336339 }
337340
338341 // Spawn a new dumber instance
339- spawner := desktop.NewSessionSpawner(m.ctx)
340- if err := spawner.SpawnWithSession(sessionID); err != nil {
342+ if m.sessionSpawner == nil {
343+ return sessionRestoredMsg{sessionID: sessionID, err: fmt.Errorf("session spawner not available")}
344+ }
345+ if err := m.sessionSpawner.SpawnWithSession(sessionID); err != nil {
341346 return sessionRestoredMsg{sessionID: sessionID, err: err}
342347 }
343348
M internal/infrastructure/cef/app.go +89 -3
@@ -1,6 +1,8 @@
11 package cef
22
33 import (
4+ "strings"
5+
46 purecef "github.com/bnema/purego-cef/cef"
57
68 "github.com/bnema/dumber/internal/logging"
@@ -125,9 +127,93 @@ func (h *dumberBPH) OnBeforeChildProcessLaunch(commandLine purecef.CommandLine)
125127 }
126128 h.engine.recordChildProcessLaunch(processType, useAngle, ozonePlatform, commandLineString)
127129 }
128-func (h *dumberBPH) OnAlreadyRunningAppRelaunch(_ purecef.CommandLine, _ string) int32 { return 0 }
129-func (h *dumberBPH) GetDefaultClient() purecef.Client { return nil }
130-func (h *dumberBPH) GetDefaultRequestContextHandler() purecef.RequestContextHandler { return nil }
130+
131+func parseRelaunchCommandLineArgs(commandLine string) []string {
132+ args := make([]string, 0, 4)
133+ var current strings.Builder
134+ var quote rune
135+ escaped := false
136+ flush := func() {
137+ if current.Len() == 0 {
138+ return
139+ }
140+ args = append(args, current.String())
141+ current.Reset()
142+ }
143+
144+ for _, r := range commandLine {
145+ switch {
146+ case escaped:
147+ current.WriteRune(r)
148+ escaped = false
149+ case r == '\\':
150+ escaped = true
151+ case quote != 0:
152+ if r == quote {
153+ quote = 0
154+ continue
155+ }
156+ current.WriteRune(r)
157+ case r == '"' || r == '\'':
158+ quote = r
159+ case r == ' ' || r == '\t' || r == '\n' || r == '\r':
160+ flush()
161+ default:
162+ current.WriteRune(r)
163+ }
164+ }
165+
166+ if escaped {
167+ current.WriteRune('\\')
168+ }
169+ flush()
170+
171+ return args
172+}
173+
174+func parseBrowseRelaunchCommandLine(commandLine purecef.CommandLine) (string, bool) {
175+ if commandLine == nil {
176+ return "", false
177+ }
178+
179+ args := parseRelaunchCommandLineArgs(commandLine.GetCommandLineString())
180+ if len(args) < 3 || args[1] != "browse" || args[2] == "" {
181+ return "", false
182+ }
183+ return args[2], true
184+}
185+
186+func isBrowseRelaunchCommandLine(commandLine purecef.CommandLine) bool {
187+ if commandLine == nil {
188+ return false
189+ }
190+
191+ args := parseRelaunchCommandLineArgs(commandLine.GetCommandLineString())
192+ return len(args) >= 2 && args[1] == "browse"
193+}
194+
195+func parseBrowseURLFromRelaunchCommandLine(commandLine purecef.CommandLine) string {
196+ browseURL, _ := parseBrowseRelaunchCommandLine(commandLine)
197+ return browseURL
198+}
199+
200+func (h *dumberBPH) OnAlreadyRunningAppRelaunch(commandLine purecef.CommandLine, _ string) int32 {
201+ if browseURL, ok := parseBrowseRelaunchCommandLine(commandLine); ok {
202+ if h != nil && h.engine != nil {
203+ if handler := h.engine.alreadyRunningAppRelaunchCallback(); handler != nil {
204+ handler(browseURL)
205+ }
206+ }
207+ return 1
208+ }
209+ if isBrowseRelaunchCommandLine(commandLine) {
210+ return 1
211+ }
212+
213+ return 0
214+}
215+func (h *dumberBPH) GetDefaultClient() purecef.Client { return nil }
216+func (h *dumberBPH) GetDefaultRequestContextHandler() purecef.RequestContextHandler { return nil }
131217
132218 // OnScheduleMessagePumpWork is a no-op — multi-threaded message loop drives
133219 // its own pump. Required by the BrowserProcessHandler interface.
M internal/infrastructure/cef/app_test.go +102 -0
@@ -2,6 +2,7 @@ package cef
22
33 import (
44 "testing"
5+ "unsafe"
56
67 purecef "github.com/bnema/purego-cef/cef"
78 )
@@ -19,6 +20,32 @@ func (s *schemeRegistrarStub) AddCustomScheme(schemeName string, options int32)
1920 return 1
2021 }
2122
23+type relaunchCommandLineStub struct {
24+ commandLineString string
25+}
26+
27+func (c relaunchCommandLineStub) IsValid() bool { return true }
28+func (c relaunchCommandLineStub) IsReadOnly() bool { return true }
29+func (c relaunchCommandLineStub) Copy() purecef.CommandLine { return c }
30+func (c relaunchCommandLineStub) InitFromArgv(int32, unsafe.Pointer) {}
31+func (c relaunchCommandLineStub) InitFromString(string) {}
32+func (c relaunchCommandLineStub) Reset() {}
33+func (c relaunchCommandLineStub) GetArgv(uintptr) {}
34+func (c relaunchCommandLineStub) GetCommandLineString() string { return c.commandLineString }
35+func (c relaunchCommandLineStub) GetProgram() string { return "dumber" }
36+func (c relaunchCommandLineStub) SetProgram(string) {}
37+func (c relaunchCommandLineStub) HasSwitches() bool { return false }
38+func (c relaunchCommandLineStub) HasSwitch(string) bool { return false }
39+func (c relaunchCommandLineStub) GetSwitchValue(string) string { return "" }
40+func (c relaunchCommandLineStub) GetSwitches(uintptr) {}
41+func (c relaunchCommandLineStub) AppendSwitch(string) {}
42+func (c relaunchCommandLineStub) AppendSwitchWithValue(string, string) {}
43+func (c relaunchCommandLineStub) HasArguments() bool { return true }
44+func (c relaunchCommandLineStub) GetArguments(uintptr) {}
45+func (c relaunchCommandLineStub) AppendArgument(string) {}
46+func (c relaunchCommandLineStub) PrependWrapper(string) {}
47+func (c relaunchCommandLineStub) RemoveSwitch(string) {}
48+
2249 func TestRegisterDumbScheme(t *testing.T) {
2350 t.Parallel()
2451
@@ -41,3 +68,78 @@ func TestRegisterDumbScheme(t *testing.T) {
4168 t.Fatalf("options = %d, want %d", stub.options, wantOptions)
4269 }
4370 }
71+
72+func TestParseBrowseURLFromRelaunchCommandLine(t *testing.T) {
73+ t.Parallel()
74+
75+ got := parseBrowseURLFromRelaunchCommandLine(relaunchCommandLineStub{
76+ commandLineString: "dumber browse https://example.com",
77+ })
78+
79+ if got != "https://example.com" {
80+ t.Fatalf("parsed browse url = %q, want %q", got, "https://example.com")
81+ }
82+}
83+
84+func TestDumberBPH_OnAlreadyRunningAppRelaunch_ForwardsBrowseURLAndReturns1(t *testing.T) {
85+ t.Parallel()
86+
87+ gotURL := ""
88+ eng := &Engine{}
89+ eng.SetAlreadyRunningAppRelaunchHandler(func(url string) {
90+ gotURL = url
91+ })
92+
93+ ret := (&dumberBPH{engine: eng}).OnAlreadyRunningAppRelaunch(relaunchCommandLineStub{
94+ commandLineString: "dumber browse https://example.com",
95+ }, "")
96+
97+ if ret != 1 {
98+ t.Fatalf("OnAlreadyRunningAppRelaunch returned %d, want 1", ret)
99+ }
100+ if gotURL != "https://example.com" {
101+ t.Fatalf("forwarded browse url = %q, want %q", gotURL, "https://example.com")
102+ }
103+}
104+
105+func TestDumberBPH_OnAlreadyRunningAppRelaunch_DoesNotForwardEmptyBrowseURLAndReturns1(t *testing.T) {
106+ t.Parallel()
107+
108+ gotURL := "sentinel"
109+ eng := &Engine{}
110+ eng.SetAlreadyRunningAppRelaunchHandler(func(url string) {
111+ gotURL = url
112+ })
113+
114+ ret := (&dumberBPH{engine: eng}).OnAlreadyRunningAppRelaunch(relaunchCommandLineStub{
115+ commandLineString: "dumber browse",
116+ }, "")
117+
118+ if ret != 1 {
119+ t.Fatalf("OnAlreadyRunningAppRelaunch returned %d, want 1", ret)
120+ }
121+ if gotURL != "sentinel" {
122+ t.Fatalf("forwarded browse url = %q, want %q", gotURL, "sentinel")
123+ }
124+}
125+
126+func TestDumberBPH_OnAlreadyRunningAppRelaunch_DoesNotForwardNonBrowse(t *testing.T) {
127+ t.Parallel()
128+
129+ called := false
130+ eng := &Engine{}
131+ eng.SetAlreadyRunningAppRelaunchHandler(func(string) {
132+ called = true
133+ })
134+
135+ ret := (&dumberBPH{engine: eng}).OnAlreadyRunningAppRelaunch(relaunchCommandLineStub{
136+ commandLineString: "dumber",
137+ }, "")
138+
139+ if ret != 0 {
140+ t.Fatalf("OnAlreadyRunningAppRelaunch returned %d, want 0", ret)
141+ }
142+ if called {
143+ t.Fatal("handler should not be called for non-browse relaunch")
144+ }
145+}
M internal/infrastructure/cef/engine.go +26 -9
@@ -14,6 +14,7 @@ import (
1414
1515 // Compile-time interface check.
1616 var _ port.Engine = (*Engine)(nil)
17+var _ port.AlreadyRunningAppRelaunchHandlerSetter = (*Engine)(nil)
1718
1819 // Engine implements port.Engine for the CEF browser backend.
1920 // It manages the CEF lifecycle and provides access to all engine subsystems.
@@ -27,15 +28,17 @@ type Engine struct {
2728 schemeHandler *dumbSchemeHandler
2829 contentInj *contentInjector
2930
30- registerHandlers HandlerRegistrar
31- registerAccentHandlers AccentHandlerRegistrar
32- currentConfigPayload func() ([]byte, error)
33- defaultConfigPayload func() ([]byte, error)
34- ctxMenuBuilder port.ContextMenuBuilder
35- ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory
36- clipboard port.Clipboard
37- resolver port.ImageDataResolver
38- mediaClassifier MediaClassifier
31+ registerHandlers HandlerRegistrar
32+ registerAccentHandlers AccentHandlerRegistrar
33+ currentConfigPayload func() ([]byte, error)
34+ defaultConfigPayload func() ([]byte, error)
35+ ctxMenuBuilder port.ContextMenuBuilder
36+ ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory
37+ clipboard port.Clipboard
38+ resolver port.ImageDataResolver
39+ mediaClassifier MediaClassifier
40+ alreadyRunningAppRelaunchMu sync.RWMutex
41+ alreadyRunningAppRelaunchHandler func(string)
3942
4043 // activeWebViews tracks all live webviews for CSS broadcast.
4144 activeWebViews sync.Map // map[port.WebViewID]*WebView
@@ -114,6 +117,20 @@ func (e *Engine) SetColorResolver(resolver port.ColorSchemeResolver) {
114117 }
115118 }
116119
120+// SetAlreadyRunningAppRelaunchHandler configures the callback invoked when CEF
121+// relaunches an already-running app instance.
122+func (e *Engine) SetAlreadyRunningAppRelaunchHandler(handler func(string)) {
123+ e.alreadyRunningAppRelaunchMu.Lock()
124+ defer e.alreadyRunningAppRelaunchMu.Unlock()
125+ e.alreadyRunningAppRelaunchHandler = handler
126+}
127+
128+func (e *Engine) alreadyRunningAppRelaunchCallback() func(string) {
129+ e.alreadyRunningAppRelaunchMu.RLock()
130+ defer e.alreadyRunningAppRelaunchMu.RUnlock()
131+ return e.alreadyRunningAppRelaunchHandler
132+}
133+
117134 // registerWebView adds a webview to the active tracking map.
118135 func (e *Engine) registerWebView(wv *WebView) {
119136 e.activeWebViews.Store(wv.id, wv)
M internal/infrastructure/cef/engine_init.go +10 -0
@@ -22,6 +22,7 @@ import (
2222 )
2323
2424 const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE"
25+const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH"
2526
2627 // NewEngine initializes the CEF runtime and returns a ready-to-use Engine.
2728 func NewEngine(
@@ -89,6 +90,10 @@ func NewEngine(
8990 }
9091
9192 func resolvedStateRoot(opts port.EngineOptions) string {
93+ if root := os.Getenv(CEFRootCachePathEnvVar); root != "" {
94+ return root
95+ }
96+
9297 switch {
9398 case opts.DataDir != "":
9499 return opts.DataDir
@@ -159,6 +164,11 @@ func defaultCEFUserDataDir() string {
159164 return filepath.Join(dirs.DataHome, "cef_user_data")
160165 }
161166
167+// DefaultCEFUserDataDir returns the default CEF state root.
168+func DefaultCEFUserDataDir() string {
169+ return defaultCEFUserDataDir()
170+}
171+
162172 // initializeCEF calls cef_initialize with the App to register custom schemes
163173 // and browser-process callbacks (OnBeforeCommandLineProcessing, etc.).
164174 func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logger) error {
M internal/infrastructure/cef/engine_init_test.go +10 -0
@@ -22,6 +22,16 @@ func TestResolvedStateRoot_FallsBackToCacheDir(t *testing.T) {
2222 require.Equal(t, "/tmp/cache", root)
2323 }
2424
25+func TestResolvedStateRoot_UsesEnvOverride(t *testing.T) {
26+ t.Setenv(CEFRootCachePathEnvVar, "/tmp/override")
27+
28+ root := resolvedStateRoot(port.EngineOptions{})
29+ require.Equal(t, "/tmp/override", root)
30+
31+ root = resolvedStateRoot(port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"})
32+ require.Equal(t, "/tmp/override", root)
33+}
34+
2535 func TestPrepareCEFSettings_UsesResolvedStateRoot(t *testing.T) {
2636 logger := zerolog.Nop()
2737 settings, err := prepareCEFSettings(port.EngineOptions{CacheDir: "/tmp/cache"}, RuntimeConfig{}, &logger)
M internal/infrastructure/cef/gl_loader.go +1 -1
@@ -4,7 +4,7 @@ import (
44 "fmt"
55 "unsafe"
66
7- "github.com/ebitengine/purego"
7+ "github.com/bnema/purego"
88 )
99
1010 // OpenGL constants for PBO-based texture rendering.
M internal/infrastructure/cef/render_pipeline.go +3 -2
@@ -163,8 +163,9 @@ func newRenderPipeline(ctx context.Context, gl *glLoader, scale int32, webviewID
163163 rp.glArea.SetHasDepthBuffer(false)
164164 rp.glArea.SetHasStencilBuffer(false)
165165
166- // Wire signals. puregotk takes *func(...) for signal callbacks.
167- renderCb := func(_ gtk.GLArea, _ *gdk.GLContext) bool {
166+ // Wire signals. puregotk currently exposes the render context as a raw pointer.
167+ renderCb := func(_ gtk.GLArea, contextPtr uintptr) bool {
168+ _ = gdk.GLContextNewFromInternalPtr(contextPtr)
168169 return rp.onGLRender()
169170 }
170171 rp.glArea.ConnectRender(&renderCb)
A internal/infrastructure/cef/session_spawn_environment.go +22 -0
@@ -0,0 +1,22 @@
1+package cef
2+
3+import (
4+ "path/filepath"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+// SessionSpawnEnvironment exposes the engine-specific launch environment needed
11+// when restoring a session into a freshly spawned browser process.
12+type SessionSpawnEnvironment struct{}
13+
14+func (SessionSpawnEnvironment) RootCacheEnvVar() string {
15+ return CEFRootCachePathEnvVar
16+}
17+
18+func (SessionSpawnEnvironment) SessionRootCachePath(sessionID entity.SessionID) string {
19+ return filepath.Join(DefaultCEFUserDataDir(), "sessions", string(sessionID))
20+}
21+
22+var _ port.SessionSpawnEnvironment = SessionSpawnEnvironment{}
M internal/infrastructure/desktop/adapter.go +52 -7
@@ -381,12 +381,13 @@ func (a *Adapter) UnsetAsDefaultBrowser(ctx context.Context) error {
381381
382382 // SessionSpawner implements port.SessionSpawner by launching a new dumber process.
383383 type SessionSpawner struct {
384- ctx context.Context
384+ ctx context.Context
385+ spawnEnv port.SessionSpawnEnvironment
385386 }
386387
387388 // NewSessionSpawner creates a new session spawner.
388-func NewSessionSpawner(ctx context.Context) *SessionSpawner {
389- return &SessionSpawner{ctx: ctx}
389+func NewSessionSpawner(ctx context.Context, spawnEnv port.SessionSpawnEnvironment) *SessionSpawner {
390+ return &SessionSpawner{ctx: ctx, spawnEnv: spawnEnv}
390391 }
391392
392393 // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration.
@@ -406,7 +407,18 @@ func LaunchExternalURL(uri string) {
406407
407408 // LaunchBrowserURL opens a URL in a new dumber browser window.
408409 func LaunchBrowserURL(uri string) {
409- execPath, err := getExecutablePath()
410+ launchBrowserBrowseURL(uri, getExecutablePath, startDetachedProcess)
411+}
412+
413+func launchBrowserBrowseURL(uri string, resolveExecutablePath func() (string, error), spawnDetachedProcess func(*exec.Cmd) error) {
414+ if resolveExecutablePath == nil {
415+ resolveExecutablePath = getExecutablePath
416+ }
417+ if spawnDetachedProcess == nil {
418+ spawnDetachedProcess = startDetachedProcess
419+ }
420+
421+ execPath, err := resolveExecutablePath()
410422 if err != nil {
411423 fmt.Fprintf(os.Stderr, "failed to resolve dumber executable for URL %q: %v\n", uri, err)
412424 return
@@ -414,7 +426,7 @@ func LaunchBrowserURL(uri string) {
414426
415427 cmd := exec.Command(execPath, "browse", uri)
416428 cmd.Env = sanitizedChildEnv(os.Environ())
417- if err := startDetachedProcess(cmd); err != nil {
429+ if err := spawnDetachedProcess(cmd); err != nil {
418430 fmt.Fprintf(os.Stderr, "failed to launch dumber browse for URL %q: %v\n", uri, err)
419431 }
420432 }
@@ -469,6 +481,33 @@ func sanitizedChildEnv(env []string) []string {
469481 return cleaned
470482 }
471483
484+func withoutEnvKeys(env []string, keys ...string) []string {
485+ if len(keys) == 0 {
486+ return append([]string(nil), env...)
487+ }
488+
489+ blocked := make(map[string]struct{}, len(keys))
490+ for _, key := range keys {
491+ if key == "" {
492+ continue
493+ }
494+ blocked[key] = struct{}{}
495+ }
496+
497+ filtered := make([]string, 0, len(env))
498+ for _, entry := range env {
499+ key, _, ok := strings.Cut(entry, "=")
500+ if ok {
501+ if _, drop := blocked[key]; drop {
502+ continue
503+ }
504+ }
505+ filtered = append(filtered, entry)
506+ }
507+
508+ return filtered
509+}
510+
472511 // SpawnWithSession starts a new dumber instance to restore a session.
473512 func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
474513 log := logging.FromContext(s.ctx)
@@ -481,8 +520,14 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
481520 // Start dumber browse with session ID in environment
482521 cmd := exec.Command(execPath, "browse")
483522
484- // Inherit environment and add restore session ID
485- cmd.Env = append(sanitizedChildEnv(os.Environ()), RestoreSessionEnvVar+"="+string(sessionID))
523+ sanitizedEnv := withoutEnvKeys(sanitizedChildEnv(os.Environ()), RestoreSessionEnvVar)
524+ overrides := []string{RestoreSessionEnvVar + "=" + string(sessionID)}
525+ if s.spawnEnv != nil {
526+ sanitizedEnv = withoutEnvKeys(sanitizedEnv, s.spawnEnv.RootCacheEnvVar())
527+ overrides = append(overrides, s.spawnEnv.RootCacheEnvVar()+"="+s.spawnEnv.SessionRootCachePath(sessionID))
528+ }
529+ sanitizedEnv = append(sanitizedEnv, overrides...)
530+ cmd.Env = sanitizedEnv
486531
487532 // Detach from current process group so the new process survives.
488533 cmd.Stdin = nil
M internal/infrastructure/desktop/adapter_test.go +120 -1
@@ -1,6 +1,41 @@
11 package desktop
22
3-import "testing"
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/domain/entity"
11+)
12+
13+const testCaptureEnvFile = "DUMBER_TEST_CAPTURE_ENV_FILE"
14+
15+const testRootCacheEnvVar = "DUMBER_TEST_ROOT_CACHE_PATH"
16+
17+type testSessionSpawnEnvironment struct {
18+ rootDir string
19+}
20+
21+func (testSessionSpawnEnvironment) RootCacheEnvVar() string {
22+ return testRootCacheEnvVar
23+}
24+
25+func (e testSessionSpawnEnvironment) SessionRootCachePath(sessionID entity.SessionID) string {
26+ return filepath.Join(e.rootDir, "sessions", string(sessionID))
27+}
28+
29+func TestMain(m *testing.M) {
30+ if envFile := os.Getenv(testCaptureEnvFile); envFile != "" && len(os.Args) >= 2 && os.Args[1] == "browse" {
31+ if err := os.WriteFile(envFile, []byte(strings.Join(os.Environ(), "\n")), 0o644); err != nil {
32+ os.Exit(2)
33+ }
34+ os.Exit(0)
35+ }
36+
37+ os.Exit(m.Run())
38+}
439
540 func TestSanitizedChildEnv_RemovesLayerShellPreloadOnly(t *testing.T) {
641 env := sanitizedChildEnv([]string{
@@ -32,3 +67,87 @@ func TestSanitizedChildEnv_PreservesColonSeparatedEntries(t *testing.T) {
3267 t.Fatalf("expected unrelated colon-separated entries to remain, got %#v", env)
3368 }
3469 }
70+
71+func TestWithoutEnvKeys_RemovesExplicitOverrides(t *testing.T) {
72+ env := withoutEnvKeys([]string{
73+ "PATH=/usr/bin",
74+ RestoreSessionEnvVar + "=old-session",
75+ testRootCacheEnvVar + "=/tmp/old-root",
76+ }, RestoreSessionEnvVar, testRootCacheEnvVar)
77+
78+ if len(env) != 1 || env[0] != "PATH=/usr/bin" {
79+ t.Fatalf("expected override keys to be removed, got %#v", env)
80+ }
81+}
82+
83+func TestSessionSpawner_SpawnWithSession_PassesRestoreSessionAndEngineOverride(t *testing.T) {
84+ rootDir := filepath.Join(t.TempDir(), "cef-root")
85+ spawner := NewSessionSpawner(t.Context(), testSessionSpawnEnvironment{rootDir: rootDir})
86+ childEnvFile := filepath.Join(t.TempDir(), "child-env.txt")
87+ t.Setenv(testCaptureEnvFile, childEnvFile)
88+ t.Setenv("XDG_DATA_HOME", filepath.Join(t.TempDir(), "data"))
89+ t.Setenv("ENV", "")
90+
91+ sessionID := entity.SessionID("session-123")
92+ requireNoError(t, spawner.SpawnWithSession(sessionID))
93+
94+ env := readEnvFile(t, childEnvFile)
95+ wantRoot := filepath.Join(rootDir, "sessions", string(sessionID))
96+ if got := env[RestoreSessionEnvVar]; got != string(sessionID) {
97+ t.Fatalf("restore session env = %q, want %q", got, string(sessionID))
98+ }
99+ if got := env[testRootCacheEnvVar]; got != wantRoot {
100+ t.Fatalf("engine state root env = %q, want %q", got, wantRoot)
101+ }
102+}
103+
104+func readEnvFile(t *testing.T, path string) map[string]string {
105+ t.Helper()
106+
107+ deadline := time.Now().Add(2 * time.Second)
108+ for time.Now().Before(deadline) {
109+ data, err := os.ReadFile(path)
110+ if err == nil {
111+ env := make(map[string]string)
112+ for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
113+ if line == "" {
114+ continue
115+ }
116+ key, value, ok := strings.Cut(line, "=")
117+ if ok {
118+ env[key] = value
119+ }
120+ }
121+ return env
122+ }
123+ time.Sleep(10 * time.Millisecond)
124+ }
125+
126+ t.Fatalf("timed out waiting for child env file %q", path)
127+ return nil
128+}
129+
130+func TestSessionSpawner_SpawnWithSession_OmitsEngineOverrideWhenNotConfigured(t *testing.T) {
131+ spawner := NewSessionSpawner(t.Context(), nil)
132+ childEnvFile := filepath.Join(t.TempDir(), "child-env.txt")
133+ t.Setenv(testCaptureEnvFile, childEnvFile)
134+ t.Setenv("ENV", "")
135+
136+ sessionID := entity.SessionID("session-plain")
137+ requireNoError(t, spawner.SpawnWithSession(sessionID))
138+
139+ env := readEnvFile(t, childEnvFile)
140+ if got := env[RestoreSessionEnvVar]; got != string(sessionID) {
141+ t.Fatalf("restore session env = %q, want %q", got, string(sessionID))
142+ }
143+ if got := env[testRootCacheEnvVar]; got != "" {
144+ t.Fatalf("engine state root env = %q, want empty", got)
145+ }
146+}
147+
148+func requireNoError(t *testing.T, err error) {
149+ t.Helper()
150+ if err != nil {
151+ t.Fatal(err)
152+ }
153+}
A internal/infrastructure/desktop/browser_launch_relay.go +261 -0
@@ -0,0 +1,261 @@
1+package desktop
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "net"
10+ "os"
11+ "path/filepath"
12+ "sync"
13+ "syscall"
14+ "time"
15+
16+ "github.com/bnema/dumber/internal/application/port"
17+ "github.com/bnema/dumber/internal/logging"
18+)
19+
20+const browserLaunchSocketName = "browser-launch.sock"
21+
22+const browserLaunchIOTimeout = 50 * time.Millisecond
23+
24+const browserLaunchDirPerm = 0o700
25+
26+type browserLaunchRelay struct {
27+ xdg port.XDGPaths
28+}
29+
30+type browserLaunchRequest struct {
31+ URL string `json:"url"`
32+}
33+
34+type browserLaunchResponse struct {
35+ Error string `json:"error,omitempty"`
36+}
37+
38+type browserLaunchRelayListener struct {
39+ listener *net.UnixListener
40+ socketPath string
41+ once sync.Once
42+ err error
43+}
44+
45+func NewBrowserLaunchRelay(xdg port.XDGPaths) port.BrowserLaunchRelay {
46+ return &browserLaunchRelay{xdg: xdg}
47+}
48+
49+func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) {
50+ socketPath, err := r.socketPath()
51+ if err != nil {
52+ return false, err
53+ }
54+
55+ conn, err := (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
56+ if err != nil {
57+ if isMissingRelayListener(err) {
58+ return false, nil
59+ }
60+ return false, err
61+ }
62+ defer func() { _ = conn.Close() }()
63+
64+ if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil {
65+ return false, err
66+ }
67+ if err := json.NewEncoder(conn).Encode(browserLaunchRequest{URL: url}); err != nil {
68+ return false, err
69+ }
70+
71+ if err := setBrowserLaunchConnDeadline(ctx, conn); err != nil {
72+ return false, err
73+ }
74+
75+ var response browserLaunchResponse
76+ decoder := json.NewDecoder(conn)
77+ for {
78+ if decodeErr := decoder.Decode(&response); decodeErr != nil {
79+ if isBrowserLaunchReadTimeout(decodeErr) {
80+ if ctxErr := ctx.Err(); ctxErr != nil {
81+ return false, ctxErr
82+ }
83+ if _, ok := ctx.Deadline(); !ok {
84+ return true, nil
85+ }
86+ if deadlineErr := setBrowserLaunchConnDeadline(ctx, conn); deadlineErr != nil {
87+ return false, deadlineErr
88+ }
89+ continue
90+ }
91+ return false, decodeErr
92+ }
93+ break
94+ }
95+ if response.Error != "" {
96+ return true, errors.New(response.Error)
97+ }
98+
99+ return true, nil
100+}
101+
102+func isMissingRelayListener(err error) bool {
103+ return os.IsNotExist(err) || errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ENOENT)
104+}
105+
106+func isBrowserLaunchReadTimeout(err error) bool {
107+ var netErr net.Error
108+ return errors.As(err, &netErr) && netErr.Timeout()
109+}
110+
111+func browserLaunchSocketHasLiveListener(socketPath string) (bool, error) {
112+ conn, err := net.DialTimeout("unix", socketPath, browserLaunchIOTimeout)
113+ if err == nil {
114+ _ = conn.Close()
115+ return true, nil
116+ }
117+
118+ if isMissingRelayListener(err) {
119+ return false, nil
120+ }
121+
122+ return false, fmt.Errorf("check browser launch socket: %w", err)
123+}
124+
125+func setBrowserLaunchConnDeadline(ctx context.Context, conn net.Conn) error {
126+ deadline := time.Now().Add(browserLaunchIOTimeout)
127+ if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
128+ deadline = ctxDeadline
129+ }
130+
131+ return conn.SetDeadline(deadline)
132+}
133+
134+func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWindowOpener) (io.Closer, error) {
135+ socketPath, err := r.socketPath()
136+ if err != nil {
137+ return nil, err
138+ }
139+
140+ if mkdirErr := os.MkdirAll(filepath.Dir(socketPath), browserLaunchDirPerm); mkdirErr != nil {
141+ return nil, fmt.Errorf("create browser launch dir: %w", mkdirErr)
142+ }
143+ listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
144+ if err != nil {
145+ if !errors.Is(err, syscall.EADDRINUSE) {
146+ return nil, fmt.Errorf("listen browser launch socket: %w", err)
147+ }
148+
149+ live, liveErr := browserLaunchSocketHasLiveListener(socketPath)
150+ if liveErr != nil {
151+ return nil, liveErr
152+ }
153+ if live {
154+ return nil, errors.New("browser launch relay already running")
155+ }
156+
157+ if removeErr := os.Remove(socketPath); removeErr != nil && !os.IsNotExist(removeErr) {
158+ return nil, fmt.Errorf("remove stale browser launch socket: %w", removeErr)
159+ }
160+
161+ listener, err = net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
162+ if err != nil {
163+ return nil, fmt.Errorf("listen browser launch socket: %w", err)
164+ }
165+ }
166+
167+ relayListener := &browserLaunchRelayListener{listener: listener, socketPath: socketPath}
168+ go relayListener.serve(ctx, opener)
169+
170+ return relayListener, nil
171+}
172+
173+func (r *browserLaunchRelay) socketPath() (string, error) {
174+ if r == nil || r.xdg == nil {
175+ return "", errors.New("browser launch relay missing XDG paths")
176+ }
177+
178+ runtimeDir, err := r.xdg.RuntimeDir()
179+ if err != nil {
180+ return "", fmt.Errorf("get runtime dir: %w", err)
181+ }
182+ if runtimeDir != "" {
183+ return filepath.Join(runtimeDir, browserLaunchSocketName), nil
184+ }
185+
186+ stateDir, err := r.xdg.StateDir()
187+ if err != nil {
188+ return "", fmt.Errorf("get state dir: %w", err)
189+ }
190+ if stateDir == "" {
191+ return "", errors.New("browser launch relay needs runtime or state dir")
192+ }
193+
194+ return filepath.Join(stateDir, "runtime", browserLaunchSocketName), nil
195+}
196+
197+func (l *browserLaunchRelayListener) Close() error {
198+ l.once.Do(func() {
199+ if l.listener != nil {
200+ l.err = l.listener.Close()
201+ }
202+ _ = os.Remove(l.socketPath)
203+ })
204+
205+ return l.err
206+}
207+
208+func (l *browserLaunchRelayListener) serve(ctx context.Context, opener port.BrowserWindowOpener) {
209+ defer func() { _ = l.Close() }()
210+
211+ for {
212+ if err := l.listener.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
213+ return
214+ }
215+ conn, err := l.listener.AcceptUnix()
216+ if err != nil {
217+ if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
218+ return
219+ }
220+ var netErr net.Error
221+ if errors.As(err, &netErr) && netErr.Timeout() {
222+ if ctx.Err() != nil {
223+ return
224+ }
225+ continue
226+ }
227+ continue
228+ }
229+
230+ go l.handleConnection(ctx, conn, opener)
231+ }
232+}
233+
234+func (*browserLaunchRelayListener) handleConnection(ctx context.Context, conn *net.UnixConn, opener port.BrowserWindowOpener) {
235+ defer func() { _ = conn.Close() }()
236+ if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
237+ return
238+ }
239+
240+ var request browserLaunchRequest
241+ if err := json.NewDecoder(conn).Decode(&request); err != nil {
242+ return
243+ }
244+
245+ if err := conn.SetDeadline(time.Now().Add(browserLaunchIOTimeout)); err != nil {
246+ return
247+ }
248+ if err := json.NewEncoder(conn).Encode(browserLaunchResponse{}); err != nil {
249+ log := logging.FromContext(ctx)
250+ log.Warn().Err(err).Str("url", request.URL).Msg("failed to encode browser launch response")
251+ return
252+ }
253+
254+ go func() {
255+ if err := opener.OpenFreshWindow(ctx, request.URL); err != nil {
256+ logging.FromContext(ctx).Warn().Err(err).Str("url", request.URL).Msg("failed to open fresh browser window")
257+ }
258+ }()
259+}
260+
261+var _ port.BrowserLaunchRelay = (*browserLaunchRelay)(nil)
A internal/infrastructure/desktop/browser_launch_relay_test.go +448 -0
@@ -0,0 +1,448 @@
1+package desktop
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+type testXDGPaths struct {
18+ runtimeDir string
19+ stateDir string
20+}
21+
22+var _ port.XDGPaths = testXDGPaths{}
23+
24+func (testXDGPaths) ConfigDir() (string, error) { return "", nil }
25+func (testXDGPaths) DataDir() (string, error) { return "", nil }
26+func (x testXDGPaths) StateDir() (string, error) { return x.stateDir, nil }
27+func (x testXDGPaths) RuntimeDir() (string, error) { return x.runtimeDir, nil }
28+func (testXDGPaths) CacheDir() (string, error) { return "", nil }
29+func (testXDGPaths) FilterJSONDir() (string, error) { return "", nil }
30+func (testXDGPaths) FilterStoreDir() (string, error) { return "", nil }
31+func (testXDGPaths) FilterCacheDir() (string, error) { return "", nil }
32+func (testXDGPaths) ManDir() (string, error) { return "", nil }
33+func (testXDGPaths) DownloadDir() (string, error) { return "", nil }
34+
35+type browserWindowOpenerFunc func(context.Context, string) error
36+
37+func (f browserWindowOpenerFunc) OpenFreshWindow(ctx context.Context, url string) error {
38+ return f(ctx, url)
39+}
40+
41+func waitForSocket(t *testing.T, path string) {
42+ t.Helper()
43+
44+ require.Eventually(t, func() bool {
45+ _, err := os.Stat(path)
46+ return err == nil
47+ }, time.Second, 10*time.Millisecond)
48+}
49+
50+func waitForSocketGone(t *testing.T, path string) {
51+ t.Helper()
52+
53+ require.Eventually(t, func() bool {
54+ _, err := os.Stat(path)
55+ return os.IsNotExist(err)
56+ }, time.Second, 10*time.Millisecond)
57+}
58+
59+func shortTempDir(t *testing.T) string {
60+ t.Helper()
61+
62+ dir, err := os.MkdirTemp("", "dbr-")
63+ require.NoError(t, err)
64+ t.Cleanup(func() {
65+ _ = os.RemoveAll(dir)
66+ })
67+
68+ return dir
69+}
70+
71+func TestBrowserLaunchRelay_DeliverOpenFreshWindow_MissingListenerReturnsFalseNil(t *testing.T) {
72+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: shortTempDir(t)})
73+
74+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com")
75+
76+ require.NoError(t, err)
77+ assert.False(t, delivered)
78+}
79+
80+func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) {
81+ runtimeDir := filepath.Join(shortTempDir(t), "runtime")
82+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
83+
84+ received := make(chan string, 1)
85+ ctx, cancel := context.WithCancel(context.Background())
86+ defer cancel()
87+
88+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
89+ received <- url
90+ return nil
91+ }))
92+ require.NoError(t, err)
93+ defer closer.Close()
94+
95+ waitForSocket(t, filepath.Join(runtimeDir, "browser-launch.sock"))
96+
97+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/new")
98+
99+ require.NoError(t, err)
100+ assert.True(t, delivered)
101+
102+ select {
103+ case got := <-received:
104+ assert.Equal(t, "https://example.com/new", got)
105+ case <-time.After(time.Second):
106+ t.Fatal("expected opener to receive the URL")
107+ }
108+}
109+
110+func TestBrowserLaunchRelay_UsesStateFallbackWhenRuntimeDirMissing(t *testing.T) {
111+ stateDir := shortTempDir(t)
112+ relay := NewBrowserLaunchRelay(testXDGPaths{stateDir: stateDir})
113+
114+ received := make(chan string, 1)
115+ ctx, cancel := context.WithCancel(context.Background())
116+ defer cancel()
117+
118+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
119+ received <- url
120+ return nil
121+ }))
122+ require.NoError(t, err)
123+ defer closer.Close()
124+
125+ socketPath := filepath.Join(stateDir, "runtime", "browser-launch.sock")
126+ waitForSocket(t, socketPath)
127+
128+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/fallback")
129+
130+ require.NoError(t, err)
131+ assert.True(t, delivered)
132+
133+ select {
134+ case got := <-received:
135+ assert.Equal(t, "https://example.com/fallback", got)
136+ case <-time.After(time.Second):
137+ t.Fatal("expected opener to receive the URL")
138+ }
139+}
140+
141+func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t *testing.T) {
142+ runtimeDir := filepath.Join(shortTempDir(t), "runtime")
143+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
144+
145+ firstReceived := make(chan string, 1)
146+ ctx, cancel := context.WithCancel(context.Background())
147+ defer cancel()
148+
149+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
150+ firstReceived <- url
151+ return nil
152+ }))
153+ require.NoError(t, err)
154+ defer closer.Close()
155+
156+ waitForSocket(t, filepath.Join(runtimeDir, "browser-launch.sock"))
157+
158+ _, err = relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
159+ require.Error(t, err)
160+
161+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/live")
162+ require.NoError(t, err)
163+ require.True(t, delivered)
164+
165+ select {
166+ case got := <-firstReceived:
167+ assert.Equal(t, "https://example.com/live", got)
168+ case <-time.After(time.Second):
169+ t.Fatal("expected the original listener to remain active")
170+ }
171+}
172+
173+func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) {
174+ runtimeDir := filepath.Join(shortTempDir(t), "runtime")
175+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
176+
177+ socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
178+ require.NoError(t, os.MkdirAll(runtimeDir, 0o700))
179+ require.NoError(t, os.WriteFile(socketPath, []byte("stale"), 0o600))
180+
181+ received := make(chan string, 1)
182+ ctx, cancel := context.WithCancel(context.Background())
183+ defer cancel()
184+
185+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
186+ received <- url
187+ return nil
188+ }))
189+ require.NoError(t, err)
190+ defer closer.Close()
191+
192+ waitForSocket(t, socketPath)
193+
194+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/stale")
195+ require.NoError(t, err)
196+ require.True(t, delivered)
197+
198+ select {
199+ case got := <-received:
200+ assert.Equal(t, "https://example.com/stale", got)
201+ case <-time.After(time.Second):
202+ t.Fatal("expected stale socket path to be rebound")
203+ }
204+}
205+
206+func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) {
207+ runtimeDir := filepath.Join(shortTempDir(t), "runtime")
208+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
209+
210+ received := make(chan string, 1)
211+ ctx, cancel := context.WithCancel(context.Background())
212+ defer cancel()
213+
214+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
215+ received <- url
216+ return nil
217+ }))
218+ require.NoError(t, err)
219+ defer closer.Close()
220+
221+ socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
222+ waitForSocket(t, socketPath)
223+
224+ conn, err := net.Dial("unix", socketPath)
225+ require.NoError(t, err)
226+
227+ _, err = conn.Write([]byte("not-json"))
228+ require.NoError(t, err)
229+ require.NoError(t, conn.Close())
230+
231+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/still-works")
232+
233+ require.NoError(t, err)
234+ assert.True(t, delivered)
235+
236+ select {
237+ case got := <-received:
238+ assert.Equal(t, "https://example.com/still-works", got)
239+ case <-time.After(time.Second):
240+ t.Fatal("expected opener to receive the URL after malformed payload")
241+ }
242+}
243+
244+func TestBrowserLaunchRelay_CloseRemovesSocketPath(t *testing.T) {
245+ runtimeDir := filepath.Join(shortTempDir(t), "runtime")
246+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
247+
248+ ctx, cancel := context.WithCancel(context.Background())
249+ defer cancel()
250+
251+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
252+ require.NoError(t, err)
253+
254+ socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
255+ waitForSocket(t, socketPath)
256+
257+ require.NoError(t, closer.Close())
258+ waitForSocketGone(t, socketPath)
259+}
260+
261+func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) {
262+ runtimeDir := filepath.Join(shortTempDir(t), "runtime")
263+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
264+
265+ ctx, cancel := context.WithCancel(context.Background())
266+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
267+ require.NoError(t, err)
268+
269+ socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
270+ waitForSocket(t, socketPath)
271+
272+ cancel()
273+ waitForSocketGone(t, socketPath)
274+ require.NoError(t, closer.Close())
275+
276+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/after-cancel")
277+ require.NoError(t, err)
278+ assert.False(t, delivered)
279+}
280+
281+func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t *testing.T) {
282+ runtimeDir := shortTempDir(t)
283+ socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
284+ require.NoError(t, os.MkdirAll(runtimeDir, 0o700))
285+
286+ listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
287+ require.NoError(t, err)
288+ defer func() {
289+ _ = listener.Close()
290+ _ = os.Remove(socketPath)
291+ }()
292+
293+ ready := make(chan struct{})
294+ go func() {
295+ close(ready)
296+ conn, acceptErr := listener.AcceptUnix()
297+ if acceptErr != nil {
298+ return
299+ }
300+ defer conn.Close()
301+ <-time.After(time.Second)
302+ }()
303+ <-ready
304+
305+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
306+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
307+ defer cancel()
308+
309+ delivered, err := relay.DeliverOpenFreshWindow(ctx, "https://example.com/slow")
310+ require.Error(t, err)
311+ require.ErrorIs(t, err, context.DeadlineExceeded)
312+ assert.False(t, delivered)
313+}
314+
315+func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsTrueOnNoDeadlineTimeout(t *testing.T) {
316+ runtimeDir := shortTempDir(t)
317+ socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
318+ require.NoError(t, os.MkdirAll(runtimeDir, 0o700))
319+
320+ listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
321+ require.NoError(t, err)
322+ defer func() {
323+ _ = listener.Close()
324+ _ = os.Remove(socketPath)
325+ }()
326+
327+ ready := make(chan struct{})
328+ go func() {
329+ close(ready)
330+ conn, acceptErr := listener.AcceptUnix()
331+ if acceptErr != nil {
332+ return
333+ }
334+ defer conn.Close()
335+ <-time.After(time.Second)
336+ }()
337+ <-ready
338+
339+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
340+
341+ started := make(chan struct{})
342+
343+ type deliverResult struct {
344+ delivered bool
345+ err error
346+ }
347+ result := make(chan deliverResult, 1)
348+ go func() {
349+ close(started)
350+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/slow")
351+ result <- deliverResult{delivered: delivered, err: err}
352+ }()
353+ <-started
354+
355+ select {
356+ case got := <-result:
357+ assert.True(t, got.delivered)
358+ require.NoError(t, got.err)
359+ case <-time.After(300 * time.Millisecond):
360+ t.Fatal("DeliverOpenFreshWindow blocked without a caller deadline")
361+ }
362+}
363+
364+func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) {
365+ runtimeDir := filepath.Join(shortTempDir(t), "runtime")
366+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
367+
368+ started := make(chan struct{})
369+ release := make(chan struct{})
370+ ctx, cancel := context.WithCancel(context.Background())
371+ defer cancel()
372+
373+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, _ string) error {
374+ close(started)
375+ <-release
376+ return nil
377+ }))
378+ require.NoError(t, err)
379+ defer closer.Close()
380+
381+ waitForSocket(t, filepath.Join(runtimeDir, "browser-launch.sock"))
382+
383+ conn, err := net.Dial("unix", filepath.Join(runtimeDir, "browser-launch.sock"))
384+ require.NoError(t, err)
385+ defer conn.Close()
386+ require.NoError(t, conn.SetDeadline(time.Now().Add(300*time.Millisecond)))
387+ require.NoError(t, json.NewEncoder(conn).Encode(browserLaunchRequest{URL: "https://example.com/slow-but-healthy"}))
388+
389+ var response browserLaunchResponse
390+ require.NoError(t, json.NewDecoder(conn).Decode(&response))
391+ assert.Empty(t, response.Error)
392+
393+ select {
394+ case <-started:
395+ case <-time.After(time.Second):
396+ t.Fatal("expected opener to be invoked after acknowledgement")
397+ }
398+ close(release)
399+}
400+
401+func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) {
402+ runtimeDir := filepath.Join(shortTempDir(t), "runtime")
403+ relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
404+
405+ received := make(chan string, 1)
406+ ctx, cancel := context.WithCancel(context.Background())
407+ defer cancel()
408+
409+ closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
410+ received <- url
411+ return nil
412+ }))
413+ require.NoError(t, err)
414+ defer closer.Close()
415+
416+ socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
417+ waitForSocket(t, socketPath)
418+
419+ conn, err := net.Dial("unix", socketPath)
420+ require.NoError(t, err)
421+ defer conn.Close()
422+ time.Sleep(20 * time.Millisecond)
423+
424+ type deliverResult struct {
425+ delivered bool
426+ err error
427+ }
428+ result := make(chan deliverResult, 1)
429+ go func() {
430+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/recovered")
431+ result <- deliverResult{delivered: delivered, err: err}
432+ }()
433+
434+ select {
435+ case got := <-result:
436+ require.NoError(t, got.err)
437+ require.True(t, got.delivered)
438+ case <-time.After(300 * time.Millisecond):
439+ t.Fatal("silent client stalled the relay listener")
440+ }
441+
442+ select {
443+ case got := <-received:
444+ assert.Equal(t, "https://example.com/recovered", got)
445+ case <-time.After(time.Second):
446+ t.Fatal("expected opener to receive the URL")
447+ }
448+}
A internal/infrastructure/desktop/browser_launcher.go +45 -0
@@ -0,0 +1,45 @@
1+package desktop
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "os/exec"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+)
11+
12+// BrowserLauncher opens URLs in a relay-first dumber browser instance.
13+type BrowserLauncher struct {
14+ relay port.BrowserLaunchRelay
15+ resolveExecutablePath func() (string, error)
16+ startDetachedProcess func(*exec.Cmd) error
17+}
18+
19+// NewBrowserLauncher creates a browser launcher that prefers relay handoff.
20+func NewBrowserLauncher(relay port.BrowserLaunchRelay) *BrowserLauncher {
21+ return &BrowserLauncher{
22+ relay: relay,
23+ resolveExecutablePath: getExecutablePath,
24+ startDetachedProcess: startDetachedProcess,
25+ }
26+}
27+
28+// LaunchURL forwards the URL to a running instance when possible, otherwise spawns a new one.
29+func (l *BrowserLauncher) LaunchURL(ctx context.Context, url string) {
30+ if l == nil {
31+ return
32+ }
33+
34+ if l.relay != nil {
35+ delivered, err := l.relay.DeliverOpenFreshWindow(ctx, url)
36+ if err != nil {
37+ fmt.Fprintf(os.Stderr, "failed to forward dumber browser URL %q: %v\n", url, err)
38+ }
39+ if delivered {
40+ return
41+ }
42+ }
43+
44+ launchBrowserBrowseURL(url, l.resolveExecutablePath, l.startDetachedProcess)
45+}
A internal/infrastructure/desktop/browser_launcher_test.go +87 -0
@@ -0,0 +1,87 @@
1+package desktop
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "os"
8+ "os/exec"
9+ "testing"
10+
11+ "github.com/bnema/dumber/internal/application/port/mocks"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestBrowserLauncher_LaunchURL_ReturnsWithoutSpawningWhenRelayDelivers(t *testing.T) {
17+ relay := mocks.NewMockBrowserLaunchRelay(t)
18+ relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, nil)
19+
20+ launcher := NewBrowserLauncher(relay)
21+ spawned := false
22+ launcher.resolveExecutablePath = func() (string, error) {
23+ t.Fatal("expected executable path resolution to be skipped")
24+ return "", nil
25+ }
26+ launcher.startDetachedProcess = func(_ *exec.Cmd) error {
27+ spawned = true
28+ return nil
29+ }
30+
31+ launcher.LaunchURL(context.Background(), "https://example.com")
32+
33+ assert.False(t, spawned)
34+}
35+
36+func TestBrowserLauncher_LaunchURL_FallsBackToSpawnWhenRelayMisses(t *testing.T) {
37+ relay := mocks.NewMockBrowserLaunchRelay(t)
38+ relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(false, nil)
39+
40+ launcher := NewBrowserLauncher(relay)
41+ launcher.resolveExecutablePath = func() (string, error) {
42+ return "/usr/bin/dumber", nil
43+ }
44+
45+ var gotCmd *exec.Cmd
46+ launcher.startDetachedProcess = func(cmd *exec.Cmd) error {
47+ gotCmd = cmd
48+ return nil
49+ }
50+
51+ launcher.LaunchURL(context.Background(), "https://example.com")
52+
53+ require.NotNil(t, gotCmd)
54+ assert.Equal(t, "/usr/bin/dumber", gotCmd.Path)
55+ assert.Equal(t, []string{"/usr/bin/dumber", "browse", "https://example.com"}, gotCmd.Args)
56+}
57+
58+func TestBrowserLauncher_LaunchURL_ReportsRelayErrorWhenDelivered(t *testing.T) {
59+ relay := mocks.NewMockBrowserLaunchRelay(t)
60+ wantErr := errors.New("relay exploded")
61+ relay.EXPECT().DeliverOpenFreshWindow(context.Background(), "https://example.com").Return(true, wantErr)
62+
63+ launcher := NewBrowserLauncher(relay)
64+ launcher.resolveExecutablePath = func() (string, error) {
65+ t.Fatal("expected executable path resolution to be skipped")
66+ return "", nil
67+ }
68+ launcher.startDetachedProcess = func(_ *exec.Cmd) error {
69+ t.Fatal("expected launch to stop after relay delivery")
70+ return nil
71+ }
72+
73+ oldStderr := os.Stderr
74+ r, w, err := os.Pipe()
75+ require.NoError(t, err)
76+ os.Stderr = w
77+ t.Cleanup(func() {
78+ os.Stderr = oldStderr
79+ })
80+
81+ launcher.LaunchURL(context.Background(), "https://example.com")
82+
83+ require.NoError(t, w.Close())
84+ output, err := io.ReadAll(r)
85+ require.NoError(t, err)
86+ assert.Contains(t, string(output), wantErr.Error())
87+}
M internal/infrastructure/filtering/interfaces.go +1 -1
@@ -4,7 +4,7 @@ import (
44 "context"
55 "time"
66
7- "github.com/bnema/puregotk-webkit/webkit"
7+ "github.com/bnema/puregotk/v4/webkit"
88 )
99
1010 // FilterStore abstracts the WebKit UserContentFilterStore operations.
M internal/infrastructure/filtering/manager.go +1 -1
@@ -14,7 +14,7 @@ import (
1414
1515 "github.com/bnema/dumber/internal/application/port"
1616 "github.com/bnema/dumber/internal/logging"
17- "github.com/bnema/puregotk-webkit/webkit"
17+ "github.com/bnema/puregotk/v4/webkit"
1818 )
1919
2020 // Compile-time check that Manager satisfies port.FilterManager.
M internal/infrastructure/filtering/manager_test.go +1 -1
@@ -13,7 +13,7 @@ import (
1313 "github.com/bnema/dumber/internal/infrastructure/filtering"
1414 "github.com/bnema/dumber/internal/infrastructure/filtering/mocks"
1515 "github.com/bnema/dumber/internal/logging"
16- "github.com/bnema/puregotk-webkit/webkit"
16+ "github.com/bnema/puregotk/v4/webkit"
1717 "github.com/stretchr/testify/assert"
1818 "github.com/stretchr/testify/mock"
1919 "github.com/stretchr/testify/require"
M internal/infrastructure/filtering/mocks/mocks.go +1 -1
@@ -9,7 +9,7 @@ import (
99 "time"
1010
1111 "github.com/bnema/dumber/internal/infrastructure/filtering"
12- "github.com/bnema/puregotk-webkit/webkit"
12+ "github.com/bnema/puregotk/v4/webkit"
1313 mock "github.com/stretchr/testify/mock"
1414 )
1515
M internal/infrastructure/filtering/store.go +1 -1
@@ -6,8 +6,8 @@ import (
66 "sync"
77
88 "github.com/bnema/dumber/internal/logging"
9- "github.com/bnema/puregotk-webkit/webkit"
109 "github.com/bnema/puregotk/v4/gio"
10+ "github.com/bnema/puregotk/v4/webkit"
1111 )
1212
1313 // Store wraps webkit.UserContentFilterStore with Go-friendly async operations.
M internal/infrastructure/textinput/webview_target.go +1 -1
@@ -6,7 +6,7 @@ import (
66
77 "github.com/bnema/dumber/internal/application/port"
88 "github.com/bnema/dumber/internal/logging"
9- "github.com/bnema/puregotk-webkit/webkit"
9+ "github.com/bnema/puregotk/v4/webkit"
1010 )
1111
1212 // deleteBeforeCursorJS is the JavaScript template to delete n characters before cursor.
M internal/infrastructure/transcoder/pipeline.go +1 -1
@@ -13,7 +13,7 @@ import (
1313 "net"
1414 "time"
1515
16- "github.com/ebitengine/purego"
16+ "github.com/bnema/purego"
1717 "github.com/rs/zerolog"
1818
1919 "github.com/bnema/purego-ffmpeg/ffmpeg"
M internal/infrastructure/webkit/context.go +2 -2
@@ -7,12 +7,12 @@ import (
77 "sync"
88
99 "github.com/bnema/dumber/internal/logging"
10- "github.com/bnema/puregotk-webkit/webkit"
10+ "github.com/bnema/puregotk/v4/webkit"
1111 "github.com/rs/zerolog"
1212 )
1313
1414 // FaviconDatabase is a type alias for webkit.FaviconDatabase.
15-// Re-exported for use by UI layer without direct puregotk-webkit import.
15+// Re-exported for use by the UI layer without a direct WebKit binding import.
1616 type FaviconDatabase = webkit.FaviconDatabase
1717
1818 // WebKitContext manages the shared WebContext and persistent NetworkSession.
M internal/infrastructure/webkit/context_menu.go +1 -1
@@ -17,8 +17,8 @@ import (
1717 "github.com/bnema/dumber/internal/application/port"
1818 "github.com/bnema/dumber/internal/domain/entity"
1919 "github.com/bnema/dumber/internal/logging"
20- "github.com/bnema/puregotk-webkit/webkit"
2120 "github.com/bnema/puregotk/v4/gtk"
21+ "github.com/bnema/puregotk/v4/webkit"
2222 )
2323
2424 // contextMenuResolver implements port.ImageDataResolver by fetching image bytes
M internal/infrastructure/webkit/download_handler.go +1 -1
@@ -9,8 +9,8 @@ import (
99 "github.com/bnema/dumber/internal/application/port"
1010 downloadutil "github.com/bnema/dumber/internal/domain/download"
1111 "github.com/bnema/dumber/internal/logging"
12- "github.com/bnema/puregotk-webkit/webkit"
1312 "github.com/bnema/puregotk/v4/glib"
13+ "github.com/bnema/puregotk/v4/webkit"
1414 )
1515
1616 const (
M internal/infrastructure/webkit/gobject_type_helpers.go +1 -1
@@ -5,9 +5,9 @@ import (
55 "sync"
66
77 "github.com/bnema/dumber/internal/logging"
8+ "github.com/bnema/purego"
89 "github.com/bnema/puregotk/pkg/core"
910 gtypes "github.com/bnema/puregotk/v4/gobject/types"
10- "github.com/ebitengine/purego"
1111 )
1212
1313 var (
M internal/infrastructure/webkit/injector.go +1 -1
@@ -10,7 +10,7 @@ import (
1010 "github.com/bnema/dumber/internal/domain/entity"
1111 "github.com/bnema/dumber/internal/infrastructure/webutil"
1212 "github.com/bnema/dumber/internal/logging"
13- "github.com/bnema/puregotk-webkit/webkit"
13+ "github.com/bnema/puregotk/v4/webkit"
1414 )
1515
1616 const (
M internal/infrastructure/webkit/injector_accent_test.go +1 -1
@@ -3,7 +3,7 @@ package webkit
33 import (
44 "testing"
55
6- "github.com/bnema/puregotk-webkit/webkit"
6+ "github.com/bnema/puregotk/v4/webkit"
77 "github.com/stretchr/testify/assert"
88 )
99
M internal/infrastructure/webkit/memory_pressure.go +1 -1
@@ -5,7 +5,7 @@ import (
55
66 "github.com/bnema/dumber/internal/application/port"
77 "github.com/bnema/dumber/internal/logging"
8- "github.com/bnema/puregotk-webkit/webkit"
8+ "github.com/bnema/puregotk/v4/webkit"
99 )
1010
1111 // MemoryPressureApplier implements port.MemoryPressureApplier for WebKitGTK.
M internal/infrastructure/webkit/messaging.go +2 -2
@@ -9,8 +9,8 @@ import (
99
1010 "github.com/bnema/dumber/internal/application/port"
1111 "github.com/bnema/dumber/internal/logging"
12- "github.com/bnema/puregotk-webkit/javascriptcore"
13- "github.com/bnema/puregotk-webkit/webkit"
12+ "github.com/bnema/puregotk/v4/javascriptcore"
13+ "github.com/bnema/puregotk/v4/webkit"
1414 )
1515
1616 // Message is the JS -> Go message envelope sent via postMessage.
M internal/infrastructure/webkit/pool.go +1 -1
@@ -10,8 +10,8 @@ import (
1010 "time"
1111
1212 "github.com/bnema/dumber/internal/logging"
13- "github.com/bnema/puregotk-webkit/webkit"
1413 "github.com/bnema/puregotk/v4/glib"
14+ "github.com/bnema/puregotk/v4/webkit"
1515 )
1616
1717 // ErrPoolClosed is returned when operations are attempted on a closed pool.
M internal/infrastructure/webkit/scheme_handler.go +2 -2
@@ -17,9 +17,9 @@ import (
1717 "github.com/bnema/dumber/internal/infrastructure/env"
1818 "github.com/bnema/dumber/internal/infrastructure/webutil"
1919 "github.com/bnema/dumber/internal/logging"
20- "github.com/bnema/puregotk-webkit/soup"
21- "github.com/bnema/puregotk-webkit/webkit"
2220 "github.com/bnema/puregotk/v4/gio"
21+ "github.com/bnema/puregotk/v4/soup"
22+ "github.com/bnema/puregotk/v4/webkit"
2323 "github.com/rs/zerolog"
2424 )
2525
M internal/infrastructure/webkit/settings.go +1 -1
@@ -6,7 +6,7 @@ import (
66
77 "github.com/bnema/dumber/internal/infrastructure/config"
88 "github.com/bnema/dumber/internal/logging"
9- "github.com/bnema/puregotk-webkit/webkit"
9+ "github.com/bnema/puregotk/v4/webkit"
1010 "github.com/rs/zerolog"
1111 )
1212
M internal/infrastructure/webkit/webview.go +1 -1
@@ -15,13 +15,13 @@ import (
1515 urlutil "github.com/bnema/dumber/internal/domain/url"
1616 "github.com/bnema/dumber/internal/infrastructure/desktop"
1717 "github.com/bnema/dumber/internal/logging"
18- "github.com/bnema/puregotk-webkit/webkit"
1918 "github.com/bnema/puregotk/v4/gdk"
2019 "github.com/bnema/puregotk/v4/gio"
2120 "github.com/bnema/puregotk/v4/glib"
2221 "github.com/bnema/puregotk/v4/gobject"
2322 gtypes "github.com/bnema/puregotk/v4/gobject/types"
2423 "github.com/bnema/puregotk/v4/gtk"
24+ "github.com/bnema/puregotk/v4/webkit"
2525 "github.com/rs/zerolog"
2626 )
2727
M internal/infrastructure/webkit/webview_termination_test.go +1 -1
@@ -3,7 +3,7 @@ package webkit
33 import (
44 "testing"
55
6- webkitlib "github.com/bnema/puregotk-webkit/webkit"
6+ webkitlib "github.com/bnema/puregotk/v4/webkit"
77 "github.com/stretchr/testify/assert"
88 )
99
M internal/infrastructure/xdg/adapter.go +12 -0
@@ -28,6 +28,18 @@ func (a *Adapter) StateDir() (string, error) {
2828 return config.GetStateDir()
2929 }
3030
31+func (a *Adapter) RuntimeDir() (string, error) {
32+ if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
33+ return dir, nil
34+ }
35+
36+ stateDir, err := a.StateDir()
37+ if err != nil {
38+ return "", err
39+ }
40+ return filepath.Join(stateDir, "runtime"), nil
41+}
42+
3143 func (a *Adapter) CacheDir() (string, error) {
3244 dirs, err := config.GetXDGDirs()
3345 if err != nil {
M internal/infrastructure/xdg/adapter_test.go +25 -22
@@ -13,17 +13,7 @@ func TestAdapter_DownloadDir(t *testing.T) {
1313 adapter := New()
1414
1515 t.Run("returns XDG_DOWNLOAD_DIR when set", func(t *testing.T) {
16- // Save and restore original env.
17- original := os.Getenv("XDG_DOWNLOAD_DIR")
18- defer func() {
19- if original == "" {
20- os.Unsetenv("XDG_DOWNLOAD_DIR")
21- } else {
22- os.Setenv("XDG_DOWNLOAD_DIR", original)
23- }
24- }()
25-
26- os.Setenv("XDG_DOWNLOAD_DIR", "/custom/downloads")
16+ t.Setenv("XDG_DOWNLOAD_DIR", "/custom/downloads")
2717
2818 dir, err := adapter.DownloadDir()
2919
@@ -32,17 +22,7 @@ func TestAdapter_DownloadDir(t *testing.T) {
3222 })
3323
3424 t.Run("falls back to ~/Downloads when XDG_DOWNLOAD_DIR not set", func(t *testing.T) {
35- // Save and restore original env.
36- original := os.Getenv("XDG_DOWNLOAD_DIR")
37- defer func() {
38- if original == "" {
39- os.Unsetenv("XDG_DOWNLOAD_DIR")
40- } else {
41- os.Setenv("XDG_DOWNLOAD_DIR", original)
42- }
43- }()
44-
45- os.Unsetenv("XDG_DOWNLOAD_DIR")
25+ t.Setenv("XDG_DOWNLOAD_DIR", "")
4626
4727 dir, err := adapter.DownloadDir()
4828
@@ -56,6 +36,29 @@ func TestAdapter_DownloadDir(t *testing.T) {
5636 })
5737 }
5838
39+func TestAdapter_RuntimeDir(t *testing.T) {
40+ adapter := New()
41+
42+ t.Run("returns XDG_RUNTIME_DIR when set", func(t *testing.T) {
43+ t.Setenv("XDG_RUNTIME_DIR", "/custom/runtime")
44+
45+ dir, err := adapter.RuntimeDir()
46+
47+ require.NoError(t, err)
48+ assert.Equal(t, "/custom/runtime", dir)
49+ })
50+
51+ t.Run("falls back to state runtime dir when XDG_RUNTIME_DIR not set", func(t *testing.T) {
52+ t.Setenv("XDG_RUNTIME_DIR", "")
53+ t.Setenv("XDG_STATE_HOME", "/tmp/dumber-state")
54+
55+ dir, err := adapter.RuntimeDir()
56+
57+ require.NoError(t, err)
58+ assert.Equal(t, "/tmp/dumber-state/dumber/runtime", dir)
59+ })
60+}
61+
5962 func TestNew(t *testing.T) {
6063 adapter := New()
6164
M internal/ui/app.go +796 -436
@@ -4,6 +4,7 @@ import (
44 "context"
55 "errors"
66 "fmt"
7+ "io"
78 "os"
89 "path/filepath"
910 "strings"
@@ -65,6 +66,12 @@ type App struct {
6566 gtkApp *gtk.Application
6667 mainWindow *window.MainWindow
6768
69+ browserWindows map[string]*browserWindow
70+ lastFocusedWindowID string
71+ browserWindowFactory func(context.Context, string) (*browserWindow, error)
72+ tabCreationWindow *browserWindow
73+ dispatchOnMainThread func(func())
74+
6875 // State
6976 tabs *entity.TabList
7077 tabsUC *usecase.ManageTabsUseCase
@@ -79,6 +86,7 @@ type App struct {
7986 // Pane management (used by coordinators)
8087 panesUC *usecase.ManagePanesUseCase
8188 workspaceViews map[entity.TabID]*component.WorkspaceView
89+ windowForTab map[entity.TabID]*browserWindow
8290 widgetFactory layout.WidgetFactory
8391 stackedPaneMgr *component.StackedPaneManager
8492
@@ -86,9 +94,8 @@ type App struct {
8694 keyboardHandler *input.KeyboardHandler
8795 globalShortcutHandler *input.GlobalShortcutHandler
8896
89- // Focus and border management
90- focusMgr *focus.Manager
91- borderMgr *focus.BorderManager
97+ // Focus tracking stays app-global.
98+ focusMgr *focus.Manager
9299
93100 resizeModeBorderTarget layout.Widget
94101
@@ -106,18 +113,6 @@ type App struct {
106113 // Web content (managed by content.Coordinator)
107114 faviconAdapter *adapter.FaviconAdapter
108115
109- // App-level toaster for system notifications (filter status, etc.)
110- appToaster *component.Toaster
111- // Mode indicator toaster for modal mode notifications (pane, tab, session, resize)
112- modeToaster *component.Toaster
113- // Top-right WebRTC permission activity indicator
114- webrtcIndicator *component.WebRTCPermissionIndicator
115-
116- // Session management
117- sessionManager *component.SessionManager
118- tabPicker *component.TabPicker
119- tabPickerWidget layout.Widget
120- tabPickerPaneID entity.PaneID
121116 snapshotService port.SnapshotService
122117
123118 // Update management
@@ -126,13 +121,13 @@ type App struct {
126121 // ID generator for tabs/panes
127122 idCounter uint64
128123 idMu sync.Mutex
124+ windowIDCounter uint64
125+ windowIDMu sync.Mutex
129126 firstWebViewShownOnce sync.Once
130127
131128 movePaneToTabUC *usecase.MovePaneToTabUseCase
132129
133130 // Accent picker for dead keys support
134- accentPicker *component.AccentPicker
135- insertAccentUC *usecase.InsertAccentUseCase
136131 accentFocusProvider port.FocusedInputProvider
137132
138133 // Deferred initialization - runs after first load_started to avoid blocking initial navigation
@@ -140,7 +135,9 @@ type App struct {
140135 deferredInitFn func()
141136
142137 // lifecycle
143- cancel context.CancelCauseFunc
138+ cancel context.CancelCauseFunc
139+ browserLaunchRelayOnce sync.Once
140+ browserLaunchRelayCloser io.Closer
144141 }
145142
146143 type floatingWorkspaceSession struct {
@@ -172,14 +169,17 @@ func New(deps *Dependencies) (*App, error) {
172169 ctx, cancel := context.WithCancelCause(deps.Ctx)
173170
174171 app := &App{
175- deps: deps,
176- tabs: entity.NewTabList(),
177- tabsUC: deps.TabsUC,
178- panesUC: deps.PanesUC,
179- workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
180- floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession),
181- engine: deps.Engine,
182- cancel: cancel,
172+ deps: deps,
173+ tabs: entity.NewTabList(),
174+ tabsUC: deps.TabsUC,
175+ panesUC: deps.PanesUC,
176+ workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
177+ windowForTab: make(map[entity.TabID]*browserWindow),
178+ floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession),
179+ browserWindows: make(map[string]*browserWindow),
180+ dispatchOnMainThread: func(fn func()) { fn() },
181+ engine: deps.Engine,
182+ cancel: cancel,
183183 }
184184 // Register message handlers through the engine.
185185 if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{
@@ -194,12 +194,10 @@ func New(deps *Dependencies) (*App, error) {
194194 }},
195195 OnClipboardCopied: func(textLen int) {
196196 cb := glib.SourceFunc(func(_ uintptr) bool {
197- if app.appToaster != nil {
198- app.appToaster.Show(ctx, "Copied to clipboard", component.ToastInfo,
199- component.WithDuration(component.ToastBriefDurationMs),
200- component.WithPosition(component.ToastPositionBottomRight),
201- )
202- }
197+ app.showToastOnLastFocusedBrowserWindow(ctx, "Copied to clipboard", component.ToastInfo,
198+ component.WithDuration(component.ToastBriefDurationMs),
199+ component.WithPosition(component.ToastPositionBottomRight),
200+ )
203201 return false
204202 })
205203 glib.IdleAdd(&cb, 0)
@@ -244,6 +242,7 @@ func (a *App) Run(ctx context.Context, args []string) int {
244242 }
245243 defer a.gtkApp.Unref()
246244 logging.Trace().Mark("gtk_app_created")
245+ a.dispatchOnMainThread = a.runOnMainThread
247246
248247 // Connect activate signal
249248 activateCb := func(_ gio.Application) {
@@ -272,18 +271,16 @@ func (a *App) onActivate(ctx context.Context) {
272271 // Configure pool background color early (prevents white flash), but avoid
273272 // synchronous prewarming that delays the first navigation on cold start.
274273 a.setupPoolBackgroundColor(ctx)
274+ a.initLayoutInfrastructure()
275275
276- if err := a.createMainWindow(ctx); err != nil {
276+ if err := a.OpenFreshWindow(ctx, a.initialWindowURL()); err != nil {
277277 log.Error().Err(err).Msg("failed to create main window")
278278 return
279279 }
280280 logging.Trace().Mark("window_created")
281281
282- a.initLayoutInfrastructure()
283- a.initAppToasterOverlay()
284282 a.installCrashReportNotifier(ctx)
285- a.initFocusAndBorderOverlay()
286- a.initAccentPicker(ctx)
283+ a.initFocusManager()
287284 a.registerAccentHandlers(ctx)
288285 a.initDownloadHandler(ctx)
289286
@@ -293,8 +290,7 @@ func (a *App) onActivate(ctx context.Context) {
293290 a.initKeyboardHandler(ctx)
294291 a.initOmniboxConfig(ctx)
295292 a.initFindBarConfig(ctx)
296- a.initSessionManager(ctx)
297- a.initTabPicker(ctx)
293+ a.wireSessionManagerShortcut()
298294 a.initSnapshotService(ctx)
299295 a.initUpdateCoordinator(ctx)
300296 a.createInitialTab(ctx)
@@ -380,19 +376,38 @@ func (a *App) prewarmWebViewPoolAsync(ctx context.Context) {
380376 pool.PrewarmAsync(ctx, 0)
381377 }
382378
383-func (a *App) createMainWindow(ctx context.Context) error {
379+func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*browserWindow, error) {
380+ if a.browserWindowFactory != nil {
381+ created, err := a.browserWindowFactory(ctx, initialURL)
382+ if err != nil {
383+ return nil, err
384+ }
385+ a.wireBrowserWindowActivationTracking(created)
386+ created.initChrome(ctx, a)
387+ return created, nil
388+ }
389+
384390 log := logging.FromContext(ctx)
385391 mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config.Workspace.TabBarPosition)
386392 if err != nil {
387- return err
393+ return nil, err
394+ }
395+ browserWindow := &browserWindow{
396+ id: a.generateWindowID(),
397+ initialURL: initialURL,
398+ mainWindow: mainWindow,
399+ }
400+ a.wireBrowserWindowActivationTracking(browserWindow)
401+ if a.mainWindow == nil {
402+ a.mainWindow = mainWindow
388403 }
389- a.mainWindow = mainWindow
390404
391405 closeRequestCb := func(_ gtk.Window) bool {
392- log.Info().Msg("main window close requested")
406+ log.Info().Msg("browser window close requested")
407+ a.removeBrowserWindow(browserWindow.id)
393408 return false
394409 }
395- a.mainWindow.Window().ConnectCloseRequest(&closeRequestCb)
410+ mainWindow.Window().ConnectCloseRequest(&closeRequestCb)
396411
397412 // Create permission popup and dialog presenter
398413 if a.deps != nil && a.deps.PermissionUC != nil {
@@ -404,10 +419,9 @@ func (a *App) createMainWindow(ctx context.Context) error {
404419 if permPopup != nil {
405420 // Add popup to the main window's content overlay
406421 if w := permPopup.Widget(); w != nil {
407- a.mainWindow.AddOverlay(w)
422+ mainWindow.AddOverlay(w)
408423 }
409- permDialog := dialog.NewPermissionDialog(permPopup)
410- a.deps.PermissionUC.SetDialogPresenter(permDialog)
424+ browserWindow.permissionDialog = dialog.NewPermissionDialog(permPopup)
411425 }
412426 }
413427
@@ -415,155 +429,417 @@ func (a *App) createMainWindow(ctx context.Context) error {
415429 indicator := component.NewWebRTCPermissionIndicator()
416430 if indicator != nil {
417431 if w := indicator.Widget(); w != nil {
418- a.mainWindow.AddOverlay(w)
419- a.webrtcIndicator = indicator
432+ mainWindow.AddOverlay(w)
420433 }
434+ browserWindow.webrtcIndicator = indicator
421435 }
436+ a.wireBrowserWindowPermissionIndicator(browserWindow)
437+ if a.kbDispatcher != nil {
438+ a.initBrowserWindowInput(ctx, browserWindow)
439+ }
440+ if a.tabCoord != nil {
441+ a.wireBrowserWindowTabBar(ctx, browserWindow)
442+ }
443+ browserWindow.initChrome(ctx, a)
422444
423445 // Apply GTK CSS styling from theme manager.
424446 if a.deps == nil || a.deps.Theme == nil {
425- return nil
447+ return browserWindow, nil
426448 }
427- if display := a.mainWindow.Window().GetDisplay(); display != nil {
449+ if display := mainWindow.Window().GetDisplay(); display != nil {
428450 a.deps.Theme.ApplyToDisplay(ctx, display)
429451 }
452+ return browserWindow, nil
453+}
454+
455+func (a *App) ensureWindowForTabMap() {
456+ if a.windowForTab == nil {
457+ a.windowForTab = make(map[entity.TabID]*browserWindow)
458+ }
459+}
460+
461+func (a *App) browserWindowForMainWindow(mainWindow *window.MainWindow) *browserWindow {
462+ if mainWindow == nil {
463+ return nil
464+ }
465+ for _, bw := range a.browserWindows {
466+ if bw != nil && bw.mainWindow == mainWindow {
467+ return bw
468+ }
469+ }
430470 return nil
431471 }
432472
433-func (a *App) initLayoutInfrastructure() {
434- // Initialize widget factory for pane layout.
435- a.widgetFactory = layout.NewGtkWidgetFactory()
473+func (a *App) browserWindowForTab(tabID entity.TabID) *browserWindow {
474+ if a.windowForTab != nil {
475+ if bw := a.windowForTab[tabID]; bw != nil {
476+ return bw
477+ }
478+ }
479+ if a.tabCreationWindow != nil {
480+ return a.tabCreationWindow
481+ }
482+ return a.browserWindowForMainWindow(a.mainWindow)
483+}
436484
437- // Initialize stacked pane manager for incremental widget operations.
438- a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory)
485+func (a *App) browserWindowForPane(paneID entity.PaneID) *browserWindow {
486+ if paneID == "" || a.tabs == nil {
487+ return nil
488+ }
489+ for _, tab := range a.tabs.Tabs {
490+ if tab == nil || tab.Workspace == nil {
491+ continue
492+ }
493+ if tab.Workspace.FindPane(paneID) != nil {
494+ return a.browserWindowForTab(tab.ID)
495+ }
496+ }
497+ return nil
439498 }
440499
441-func (a *App) initAppToasterOverlay() {
442- if a.mainWindow == nil {
500+func (a *App) lastFocusedBrowserWindow() *browserWindow {
501+ if a.lastFocusedWindowID == "" || a.browserWindows == nil {
502+ return nil
503+ }
504+ return a.browserWindows[a.lastFocusedWindowID]
505+}
506+
507+func (a *App) showToastOnBrowserWindow(
508+ ctx context.Context,
509+ bw *browserWindow,
510+ message string,
511+ level component.ToastLevel,
512+ opts ...component.ToastOption,
513+) {
514+ if bw == nil || bw.appToaster == nil {
443515 return
444516 }
517+ bw.appToaster.Show(ctx, message, level, opts...)
518+}
519+
520+func (a *App) showToastOnLastFocusedBrowserWindow(
521+ ctx context.Context,
522+ message string,
523+ level component.ToastLevel,
524+ opts ...component.ToastOption,
525+) {
526+ a.showToastOnBrowserWindow(ctx, a.lastFocusedBrowserWindow(), message, level, opts...)
527+}
528+
529+func (a *App) ownerOrLastFocusedBrowserWindow(tabID entity.TabID, paneID entity.PaneID) *browserWindow {
530+ if paneID != "" {
531+ if bw := a.browserWindowForPane(paneID); bw != nil {
532+ return bw
533+ }
534+ }
535+ if tabID != "" {
536+ if bw := a.browserWindowForTab(tabID); bw != nil {
537+ return bw
538+ }
539+ }
540+ return a.lastFocusedBrowserWindow()
541+}
542+
543+func (a *App) handlePaneWindowTitleChanged(paneID entity.PaneID, title string) {
544+ a.updateWindowTitle(title, a.browserWindowForPane(paneID))
545+}
445546
446- // Create app-level toaster for system notifications.
447- a.appToaster = component.NewToaster(a.widgetFactory)
448- toasterWidget := a.appToaster.Widget()
449- if toasterWidget == nil {
547+func (a *App) handlePaneFullscreenChanged(paneID entity.PaneID, entering bool) {
548+ bw := a.browserWindowForPane(paneID)
549+ if bw == nil || bw.mainWindow == nil || bw.mainWindow.TabBar() == nil {
450550 return
451551 }
452- gtkWidget := toasterWidget.GtkWidget()
453- if gtkWidget == nil {
552+ if entering {
553+ bw.mainWindow.TabBar().SetVisible(false)
454554 return
455555 }
456- a.mainWindow.AddOverlay(gtkWidget)
556+ a.updateBrowserWindowTabBarVisibility(bw)
557+}
457558
458- // Create mode indicator toaster for modal mode notifications.
459- a.modeToaster = component.NewToaster(a.widgetFactory)
460- modeToasterWidget := a.modeToaster.Widget()
461- if modeToasterWidget == nil {
559+func (a *App) updateBrowserWindowTabBarVisibility(bw *browserWindow) {
560+ if bw == nil || bw.mainWindow == nil || bw.mainWindow.TabBar() == nil {
462561 return
463562 }
464- modeGtkWidget := modeToasterWidget.GtkWidget()
465- if modeGtkWidget == nil {
563+ if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.HideTabBarWhenSingleTab {
564+ bw.mainWindow.TabBar().SetVisible(true)
466565 return
467566 }
468- a.mainWindow.AddOverlay(modeGtkWidget)
567+ shouldShow := bw.mainWindow.TabBar().Count() > 1
568+ bw.mainWindow.TabBar().SetVisible(shouldShow)
469569 }
470570
471-func (a *App) installCrashReportNotifier(ctx context.Context) {
472- if a.deps == nil {
571+func (a *App) activateBrowserWindow(bw *browserWindow) {
572+ if bw == nil {
473573 return
474574 }
475-
476- a.deps.OnCrashReportsDetected = func(paths []string) {
477- a.showCrashReportToast(ctx, paths)
575+ a.lastFocusedWindowID = bw.id
576+ if bw.mainWindow != nil {
577+ a.mainWindow = bw.mainWindow
578+ }
579+ a.keyboardHandler = bw.keyboardHandler
580+ a.globalShortcutHandler = bw.globalShortcutHandler
581+ if a.tabCoord != nil && bw.mainWindow != nil {
582+ a.tabCoord.SetMainWindow(bw.mainWindow)
583+ }
584+ if a.tabs != nil && bw.activeTabID != "" && a.tabs.Find(bw.activeTabID) != nil {
585+ a.tabs.SetActive(bw.activeTabID)
478586 }
587+ a.updateWindowTitleFromActivePane(bw.activeTabID)
479588 }
480589
481-func (a *App) initFocusAndBorderOverlay() {
482- // Initialize focus manager for geometric navigation.
483- a.focusMgr = focus.NewManager(a.panesUC)
484-
485- // Initialize border manager for mode indicators.
486- a.borderMgr = focus.NewBorderManager(a.widgetFactory)
487-
488- // Attach border overlay to main window (visible for all tabs).
489- if a.borderMgr == nil || a.mainWindow == nil {
590+func (a *App) handleBrowserWindowActivationChanged(bw *browserWindow, active bool) {
591+ if !active || bw == nil {
490592 return
491593 }
492- borderWidget := a.borderMgr.Widget()
493- if borderWidget == nil {
594+ a.activateBrowserWindow(bw)
595+}
596+
597+func (a *App) wireBrowserWindowActivationTracking(bw *browserWindow) {
598+ if bw == nil || bw.mainWindow == nil {
494599 return
495600 }
496- gtkWidget := borderWidget.GtkWidget()
497- if gtkWidget == nil {
601+ bw.mainWindow.ConnectActiveNotify(func(active bool) {
602+ a.handleBrowserWindowActivationChanged(bw, active)
603+ })
604+}
605+
606+func (a *App) setBrowserWindowForTab(tabID entity.TabID, bw *browserWindow) {
607+ if bw == nil {
498608 return
499609 }
500- a.mainWindow.AddOverlay(gtkWidget)
610+ a.ensureWindowForTabMap()
611+ a.windowForTab[tabID] = bw
612+ if bw.activeTabID == "" {
613+ bw.activeTabID = tabID
614+ }
501615 }
502616
503-func (a *App) initAccentPicker(ctx context.Context) {
504- log := logging.FromContext(ctx)
617+func (a *App) tabBarForBrowserWindow(bw *browserWindow) *component.TabBar {
618+ if bw != nil && bw.mainWindow != nil {
619+ return bw.mainWindow.TabBar()
620+ }
621+ if a.mainWindow != nil {
622+ return a.mainWindow.TabBar()
623+ }
624+ return nil
625+}
505626
506- if a.widgetFactory == nil || a.mainWindow == nil {
507- log.Debug().Msg("widget factory or main window not available, skipping accent picker")
627+func (a *App) startBrowserLaunchRelayListener(ctx context.Context) {
628+ if a == nil || a.deps == nil || a.deps.BrowserLaunchRelay == nil {
508629 return
509630 }
631+ a.browserLaunchRelayOnce.Do(func() {
632+ closer, err := a.deps.BrowserLaunchRelay.Listen(ctx, a)
633+ if err != nil {
634+ logging.FromContext(ctx).Warn().Err(err).Msg("failed to start browser launch relay listener")
635+ return
636+ }
637+ a.browserLaunchRelayCloser = closer
638+ })
639+}
510640
511- // Create accent picker component
512- a.accentPicker = component.NewAccentPicker(a.widgetFactory)
513- if a.accentPicker == nil {
514- log.Warn().Msg("failed to create accent picker")
641+func (a *App) closeBrowserLaunchRelayListener() {
642+ if a.browserLaunchRelayCloser == nil {
515643 return
516644 }
645+ _ = a.browserLaunchRelayCloser.Close()
646+ a.browserLaunchRelayCloser = nil
647+}
648+
649+func (a *App) openFreshWindow(ctx context.Context, url string) error {
650+ created, err := a.createBrowserWindow(ctx, url)
651+ if err != nil {
652+ return err
653+ }
654+ a.registerBrowserWindow(created)
517655
518- // Add to main window overlay
519- pickerWidget := a.accentPicker.Widget()
520- if pickerWidget != nil {
521- gtkWidget := pickerWidget.GtkWidget()
522- if gtkWidget != nil {
523- a.mainWindow.AddOverlay(gtkWidget)
656+ if a.tabs == nil || a.tabs.Count() == 0 {
657+ a.activateBrowserWindow(created)
658+ return nil
659+ }
660+
661+ var openErr error
662+ if a.tabCoord != nil {
663+ openErr = a.openFreshWindowWithTabCoord(ctx, url, created)
664+ } else if a.tabsUC == nil {
665+ a.activateBrowserWindow(created)
666+ return nil
667+ } else {
668+ openErr = a.openFreshWindowWithTabsUC(ctx, url, created)
669+ }
670+ if openErr != nil {
671+ return openErr
672+ }
673+ a.activateBrowserWindow(created)
674+ return nil
675+}
676+
677+func (a *App) openFreshWindowWithTabCoord(ctx context.Context, url string, created *browserWindow) error {
678+ previousMainWindow := a.mainWindow
679+ if created.mainWindow != nil {
680+ a.tabCoord.SetMainWindow(created.mainWindow)
681+ }
682+ a.tabCreationWindow = created
683+ defer func() {
684+ a.tabCreationWindow = nil
685+ a.tabCoord.SetMainWindow(previousMainWindow)
686+ }()
687+
688+ createdTab, createErr := a.tabCoord.Create(ctx, url)
689+ if createErr != nil {
690+ a.removeBrowserWindow(created.id)
691+ if created.mainWindow != nil {
692+ created.mainWindow.Destroy()
524693 }
694+ return createErr
525695 }
696+ if a.workspaceViews[createdTab.ID] == nil {
697+ if a.tabs != nil {
698+ a.tabs.Remove(createdTab.ID)
699+ }
700+ tabBar := a.mainWindow.TabBar()
701+ if created.mainWindow != nil && a.tabCoord != nil {
702+ if targetTabBar := a.tabCoord.GetTabBar(); targetTabBar != nil {
703+ tabBar = targetTabBar
704+ }
705+ }
706+ if tabBar != nil {
707+ tabBar.RemoveTab(createdTab.ID)
708+ tabBar.SetActive(a.tabs.ActiveTabID)
709+ }
710+ a.removeBrowserWindow(created.id)
711+ if created.mainWindow != nil {
712+ created.mainWindow.Destroy()
713+ }
714+ return fmt.Errorf("workspace view not created for tab %s", createdTab.ID)
715+ }
716+ a.setBrowserWindowForTab(createdTab.ID, created)
717+ created.activeTabID = createdTab.ID
718+ if created.mainWindow != nil {
719+ created.mainWindow.Show()
720+ }
721+ return nil
722+}
526723
527- // Use focus provider from deps (tracks which input has focus)
528- a.accentFocusProvider = a.deps.AccentFocusProvider
724+func (a *App) openFreshWindowWithTabsUC(ctx context.Context, url string, created *browserWindow) error {
725+ output, err := a.tabsUC.Create(ctx, usecase.CreateTabInput{
726+ TabList: a.tabs,
727+ InitialURL: url,
728+ })
729+ if err != nil {
730+ a.removeBrowserWindow(created.id)
731+ if created.mainWindow != nil {
732+ created.mainWindow.Destroy()
733+ }
734+ return err
735+ }
736+ a.setBrowserWindowForTab(output.Tab.ID, created)
737+ created.activeTabID = output.Tab.ID
738+ if a.widgetFactory != nil {
739+ a.tabCreationWindow = created
740+ defer func() {
741+ a.tabCreationWindow = nil
742+ }()
743+ a.createWorkspaceView(ctx, output.Tab)
744+ a.switchWorkspaceView(ctx, output.Tab.ID)
745+ }
746+ if a.workspaceViews[output.Tab.ID] == nil {
747+ if a.tabs != nil {
748+ a.tabs.Remove(output.Tab.ID)
749+ }
750+ var tabBar *component.TabBar
751+ if a.mainWindow != nil {
752+ tabBar = a.mainWindow.TabBar()
753+ }
754+ if created.mainWindow != nil && created.mainWindow.TabBar() != nil {
755+ tabBar = created.mainWindow.TabBar()
756+ }
757+ if tabBar != nil {
758+ tabBar.RemoveTab(output.Tab.ID)
759+ if a.tabs != nil {
760+ tabBar.SetActive(a.tabs.ActiveTabID)
761+ }
762+ }
763+ a.removeBrowserWindow(created.id)
764+ if created.mainWindow != nil {
765+ created.mainWindow.Destroy()
766+ }
767+ return fmt.Errorf("workspace view not created for tab %s", output.Tab.ID)
768+ }
769+ if created.mainWindow != nil {
770+ created.mainWindow.Show()
771+ }
772+ return nil
773+}
529774
530- // Create the use case with glib.IdleAdd for thread-safe GTK calls
531- a.insertAccentUC = usecase.NewInsertAccentUseCase(
532- a.accentFocusProvider,
533- a.accentPicker,
534- func(fn func()) {
535- cb := glib.SourceFunc(func(_ uintptr) bool {
536- fn()
537- return false // Don't repeat
538- })
539- glib.IdleAdd(&cb, 0)
540- },
541- )
775+func (a *App) initialWindowURL() string {
776+ if a.deps != nil && a.deps.InitialURL != "" {
777+ return a.deps.InitialURL
778+ }
779+ return "dumb://home"
780+}
781+
782+func (a *App) initLayoutInfrastructure() {
783+ // Initialize widget factory for pane layout.
784+ a.widgetFactory = layout.NewGtkWidgetFactory()
542785
543- log.Debug().Msg("accent picker initialized")
786+ // Initialize stacked pane manager for incremental widget operations.
787+ a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory)
788+}
789+
790+func (a *App) installCrashReportNotifier(ctx context.Context) {
791+ if a.deps == nil {
792+ return
793+ }
794+
795+ a.deps.OnCrashReportsDetected = func(paths []string) {
796+ a.showCrashReportToast(ctx, paths)
797+ }
798+}
799+
800+func (a *App) initFocusManager() {
801+ a.focusMgr = focus.NewManager(a.panesUC)
802+}
803+
804+func (a *App) wireSessionManagerShortcut() {
805+ if a.kbDispatcher == nil {
806+ return
807+ }
808+ a.kbDispatcher.SetOnSessionOpen(func(ctx context.Context, paneID entity.PaneID) error {
809+ bw := a.ownerOrLastFocusedBrowserWindow("", paneID)
810+ if bw == nil || bw.sessionManager == nil {
811+ return nil
812+ }
813+ bw.sessionManager.Toggle(ctx)
814+ return nil
815+ })
544816 }
545817
546818 // registerAccentHandlers registers the accent key press/release message handlers
547-// with the router. Must be called after initAccentPicker so insertAccentUC is non-nil.
819+// with the router.
548820 func (a *App) registerAccentHandlers(ctx context.Context) {
549821 log := logging.FromContext(ctx)
550822
551- if a.insertAccentUC == nil {
552- log.Debug().Msg("insertAccentUC not available, skipping accent handler registration")
553- return
554- }
555823 if a.engine == nil {
556824 log.Debug().Msg("engine not available, skipping accent handler registration")
557825 return
558826 }
559827
560- if err := a.engine.RegisterAccentHandlers(ctx, a.insertAccentUC); err != nil {
828+ if err := a.engine.RegisterAccentHandlers(ctx, a); err != nil {
561829 log.Error().Err(err).Msg("failed to register accent handlers")
562830 } else {
563831 log.Info().Msg("registered accent key handlers")
564832 }
565833 }
566834
835+func (a *App) activeAccentHandler() input.AccentHandler {
836+ bw := a.lastFocusedBrowserWindow()
837+ if bw == nil {
838+ return nil
839+ }
840+ return bw.insertAccentUC
841+}
842+
567843 func (a *App) initDownloadHandler(ctx context.Context) {
568844 log := logging.FromContext(ctx)
569845
@@ -617,17 +893,13 @@ type downloadEventAdapter struct {
617893 func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) {
618894 // Must schedule on GTK main thread.
619895 cb := glib.SourceFunc(func(_ uintptr) bool {
620- if d.app.appToaster == nil {
621- return false
622- }
623-
624896 switch event.Type {
625897 case port.DownloadEventStarted:
626- d.app.appToaster.Show(ctx, "Download started: "+event.Filename, component.ToastInfo)
898+ d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download started: "+event.Filename, component.ToastInfo)
627899 case port.DownloadEventFinished:
628- d.app.appToaster.Show(ctx, "Download complete: "+event.Filename, component.ToastSuccess)
900+ d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download complete: "+event.Filename, component.ToastSuccess)
629901 case port.DownloadEventFailed:
630- d.app.appToaster.Show(ctx, "Download failed: "+event.Filename, component.ToastError)
902+ d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download failed: "+event.Filename, component.ToastError)
631903 }
632904 return false
633905 })
@@ -647,57 +919,51 @@ func (a *autoCopyConfigFn) IsAutoCopyEnabled() bool {
647919 }
648920
649921 func (a *App) initKeyboardHandler(ctx context.Context) {
922+ bw := a.browserWindowForMainWindow(a.mainWindow)
923+ if bw == nil {
924+ return
925+ }
926+ a.initBrowserWindowInput(ctx, bw)
927+}
928+
929+func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
650930 log := logging.FromContext(ctx)
651931
652- if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil {
932+ if bw == nil || bw.mainWindow == nil || a.deps == nil || a.deps.Config == nil || a.kbDispatcher == nil {
933+ return
934+ }
935+ if bw.keyboardHandler != nil || bw.globalShortcutHandler != nil {
653936 return
654937 }
655938
656- // Create keyboard handler and wire to dispatcher.
657- a.keyboardHandler = input.NewKeyboardHandler(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session)
658- a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {
939+ bw.keyboardHandler = input.NewKeyboardHandler(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session)
940+ bw.keyboardHandler.SetOnAction(func(actionCtx context.Context, action input.Action) error {
941+ a.activateBrowserWindow(bw)
659942 if action == input.ActionClosePane {
660- if a.closeAndReleaseActiveFloatingPane(ctx) {
943+ if a.closeAndReleaseActiveFloatingPane(actionCtx) {
661944 return nil
662945 }
663946 }
664- return a.kbDispatcher.Dispatch(ctx, action)
947+ return a.kbDispatcher.Dispatch(actionCtx, action)
665948 })
666- a.keyboardHandler.SetOnEscape(func(ctx context.Context) bool {
667- return a.handleGlobalEscape(ctx)
949+ bw.keyboardHandler.SetOnEscape(func(escapeCtx context.Context) bool {
950+ a.activateBrowserWindow(bw)
951+ return a.handleGlobalEscape(escapeCtx)
668952 })
669- a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
953+ bw.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
954+ a.activateBrowserWindow(bw)
670955 a.handleModeChange(ctx, from, to)
671956 })
672- // Wire key routing policy.
673- // Determines how each key event should be handled based on UI state:
674- // - Overlays visible (session manager, tab picker): pass all keys to widget
675- // - Omnibox visible: accent detection for text keys, shortcuts for modified keys
676- // - WebView focused: pass text/dead keys to WebKit IM for native compose,
677- // intercept shortcut-modified keys (Ctrl+, Alt+)
678- // - Default: handle through shortcut system
679- a.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute {
680- // Overlays take full keyboard control
681- if a.sessionManager != nil && a.sessionManager.IsVisible() {
957+ bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute {
958+ if bw.sessionManager != nil && bw.sessionManager.IsVisible() {
682959 return input.RoutePassToWidget
683960 }
684- if a.tabPicker != nil && a.tabPicker.IsVisible() {
961+ if bw.tabPicker != nil && bw.tabPicker.IsVisible() {
685962 return input.RoutePassToWidget
686963 }
687964
688- wsView := a.activeWorkspaceView()
689- if wsView == nil {
690- return input.RouteHandleShortcuts
691- }
692-
693- // Check actual focus rather than visibility: find bar can remain
694- // visible after focus returns to the WebView, so visibility alone
695- // would misroute keys.
696965 if a.accentFocusProvider != nil {
697966 if _, ok := a.accentFocusProvider.GetFocusedInput().(port.EntryInputTarget); ok {
698- // GTK Entry focused (omnibox or find bar): Alt-modified keys go to
699- // shortcuts (pane navigation), other keys pass through to the widget's
700- // own capture-phase key controller (which handles accent detection)
701967 if kc.Modifiers&input.ModAlt != 0 {
702968 return input.RouteHandleShortcuts
703969 }
@@ -705,8 +971,6 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
705971 }
706972 }
707973
708- // WebView focused: text/dead keys pass through for native IM compose,
709- // shortcut-modified keys (Ctrl+, Alt+) are handled by shortcut system
710974 if input.IsShortcutModified(kc.Modifiers) {
711975 return input.RouteHandleShortcuts
712976 }
@@ -714,37 +978,48 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
714978 return input.RoutePassToWidget
715979 }
716980
717- // Non-text, non-shortcut keys (F-keys, arrows, etc.): shortcut system
718981 return input.RouteHandleShortcuts
719982 })
720- // Wire accent handler for dead keys support
721- if a.insertAccentUC != nil {
722- a.keyboardHandler.SetAccentHandler(a.insertAccentUC)
723- }
724- a.keyboardHandler.AttachTo(a.mainWindow.Window())
983+ bw.keyboardHandler.SetAccentHandler(a)
984+ bw.keyboardHandler.AttachTo(bw.mainWindow.Window())
725985
726- // Create global shortcut handler for Alt+1-9 tab switching.
727- // This uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
728- // to intercept shortcuts even when WebView has focus.
729- a.globalShortcutHandler = input.NewGlobalShortcutHandler(
986+ bw.globalShortcutHandler = input.NewGlobalShortcutHandler(
730987 ctx,
731- a.mainWindow.Window(),
988+ bw.mainWindow.Window(),
732989 &a.deps.Config.Workspace,
733990 &a.deps.Config.Session,
734- a.keyboardHandler,
735- func(ctx context.Context, action input.Action) error {
736- return a.kbDispatcher.Dispatch(ctx, action)
991+ bw.keyboardHandler,
992+ func(actionCtx context.Context, action input.Action) error {
993+ a.activateBrowserWindow(bw)
994+ return a.kbDispatcher.Dispatch(actionCtx, action)
737995 },
738996 )
739- if a.globalShortcutHandler == nil {
740- log.Warn().Msg("global shortcut handler creation failed, shortcuts may not work when WebView has focus")
997+ if bw.globalShortcutHandler == nil {
998+ log.Warn().Str("window_id", bw.id).Msg("global shortcut handler creation failed, shortcuts may not work when WebView has focus")
999+ }
1000+
1001+ a.activateBrowserWindow(bw)
1002+}
1003+
1004+func (a *App) wireBrowserWindowTabBar(ctx context.Context, bw *browserWindow) {
1005+ if bw == nil || bw.mainWindow == nil || bw.mainWindow.TabBar() == nil || a.tabCoord == nil {
1006+ return
7411007 }
1008+ bw.mainWindow.TabBar().SetOnSwitch(func(tabID entity.TabID) {
1009+ a.activateBrowserWindow(bw)
1010+ if err := a.tabCoord.Switch(ctx, tabID); err != nil {
1011+ logging.FromContext(ctx).Error().Err(err).Str("tab_id", string(tabID)).Str("window_id", bw.id).Msg("tab switch failed")
1012+ return
1013+ }
1014+ bw.activeTabID = tabID
1015+ })
7421016 }
7431017
7441018 // handleAccentKeyPress handles accent key press events for GTK entry widgets
7451019 // (omnibox and find bar). Returns true if the key was consumed.
7461020 func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.ModifierType) bool {
747- if a.insertAccentUC == nil {
1021+ handler := a.activeAccentHandler()
1022+ if handler == nil {
7481023 return false
7491024 }
7501025 if state&(gdk.ControlMaskValue|gdk.AltMaskValue) != 0 {
@@ -752,7 +1027,7 @@ func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.M
7521027 }
7531028 shiftHeld := state&gdk.ShiftMaskValue != 0
7541029 if char := input.KeyvalToRune(keyval); char != 0 {
755- return a.insertAccentUC.OnKeyPressed(ctx, char, shiftHeld)
1030+ return handler.OnKeyPressed(ctx, char, shiftHeld)
7561031 }
7571032 return false
7581033 }
@@ -760,12 +1035,45 @@ func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.M
7601035 // handleAccentKeyRelease handles accent key release events for GTK entry widgets
7611036 // (omnibox and find bar).
7621037 func (a *App) handleAccentKeyRelease(ctx context.Context, keyval uint) {
763- if a.insertAccentUC == nil {
1038+ handler := a.activeAccentHandler()
1039+ if handler == nil {
7641040 return
7651041 }
7661042 if char := input.KeyvalToRune(keyval); char != 0 {
767- a.insertAccentUC.OnKeyReleased(ctx, char)
1043+ handler.OnKeyReleased(ctx, char)
1044+ }
1045+}
1046+
1047+func (a *App) OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool {
1048+ handler := a.activeAccentHandler()
1049+ if handler == nil {
1050+ return false
1051+ }
1052+ return handler.OnKeyPressed(ctx, char, shiftHeld)
1053+}
1054+
1055+func (a *App) OnKeyReleased(ctx context.Context, char rune) {
1056+ handler := a.activeAccentHandler()
1057+ if handler == nil {
1058+ return
7681059 }
1060+ handler.OnKeyReleased(ctx, char)
1061+}
1062+
1063+func (a *App) IsPickerVisible() bool {
1064+ handler := a.activeAccentHandler()
1065+ if handler == nil {
1066+ return false
1067+ }
1068+ return handler.IsPickerVisible()
1069+}
1070+
1071+func (a *App) Cancel(ctx context.Context) {
1072+ handler := a.activeAccentHandler()
1073+ if handler == nil {
1074+ return
1075+ }
1076+ handler.Cancel(ctx)
7691077 }
7701078
7711079 type omniboxCallbacks struct {
@@ -931,89 +1239,18 @@ func (a *App) initFindBarConfig(ctx context.Context) {
9311239 }
9321240 }
9331241
934-func (a *App) initSessionManager(ctx context.Context) {
935- log := logging.FromContext(ctx)
936-
937- if a.deps == nil {
938- log.Debug().Msg("deps not available, skipping session manager")
939- return
940- }
941-
942- // Session manager can work without a repo - it just shows an empty list
943- var listSessionsUC *usecase.ListSessionsUseCase
944- var deleteSessionUC *usecase.DeleteSessionUseCase
945- if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil {
946- listSessionsUC = usecase.NewListSessionsUseCase(
947- a.deps.SessionRepo,
948- a.deps.SessionStateRepo,
949- )
950- deleteSessionUC = usecase.NewDeleteSessionUseCase(
951- a.deps.SessionStateRepo,
952- a.deps.SessionRepo,
953- )
954- }
955-
956- // Use injected session spawner for restoration
957- spawner := a.deps.SessionSpawner
958-
959- // Create session manager component
960- a.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{
961- ListSessionsUC: listSessionsUC,
962- DeleteSessionUC: deleteSessionUC,
963- CurrentSession: a.deps.CurrentSessionID,
964- UIScale: a.deps.Config.DefaultUIScale,
965- OnClose: func() {
966- log.Debug().Msg("session manager closed")
967- },
968- OnOpen: func(sessionID entity.SessionID) {
969- log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested")
970- if spawner == nil {
971- log.Warn().Str("session_id", string(sessionID)).Msg("session spawner not available")
972- return
973- }
974- if err := spawner.SpawnWithSession(sessionID); err != nil {
975- log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session")
976- }
977- },
978- OnToast: func(ctx context.Context, message string, level component.ToastLevel) {
979- if a.appToaster != nil {
980- a.appToaster.Show(ctx, message, level)
981- }
982- },
983- })
984-
985- if a.sessionManager == nil {
986- log.Warn().Msg("failed to create session manager")
987- return
988- }
989-
990- // Add session manager to main window overlay
991- if a.mainWindow != nil {
992- widget := a.sessionManager.Widget()
993- if widget != nil {
994- a.mainWindow.AddOverlay(widget)
995- }
996- }
997-
998- // Wire session manager action to keyboard dispatcher
999- a.kbDispatcher.SetOnSessionOpen(func(ctx context.Context) error {
1000- a.ToggleSessionManager(ctx)
1001- return nil
1002- })
1003-
1004- log.Debug().Msg("session manager initialized")
1005-}
1006-
10071242 // ToggleSessionManager shows or hides the session manager.
10081243 func (a *App) ToggleSessionManager(ctx context.Context) {
1009- if a.sessionManager == nil {
1244+ bw := a.lastFocusedBrowserWindow()
1245+ if bw == nil || bw.sessionManager == nil {
10101246 return
10111247 }
1012- a.sessionManager.Toggle(ctx)
1248+ bw.sessionManager.Toggle(ctx)
10131249 }
10141250
10151251 func (a *App) attachTabPickerToActivePane() {
1016- if a.tabPicker == nil || a.widgetFactory == nil {
1252+ bw := a.lastFocusedBrowserWindow()
1253+ if bw == nil || bw.tabPicker == nil || a.widgetFactory == nil {
10171254 return
10181255 }
10191256 wsView := a.activeWorkspaceView()
@@ -1034,24 +1271,24 @@ func (a *App) attachTabPickerToActivePane() {
10341271 return
10351272 }
10361273
1037- if a.tabPickerWidget == nil {
1038- a.tabPickerWidget = a.tabPicker.WidgetAsLayout(a.widgetFactory)
1039- if a.tabPickerWidget == nil {
1274+ if bw.tabPickerWidget == nil {
1275+ bw.tabPickerWidget = bw.tabPicker.WidgetAsLayout(a.widgetFactory)
1276+ if bw.tabPickerWidget == nil {
10401277 return
10411278 }
10421279 }
10431280
10441281 // If currently attached to a different pane overlay, detach.
1045- if a.tabPickerPaneID != "" && a.tabPickerPaneID != activePaneID {
1282+ if bw.tabPickerPaneID != "" && bw.tabPickerPaneID != activePaneID {
10461283 for _, view := range a.workspaceViews {
10471284 if view == nil {
10481285 continue
10491286 }
1050- if oldPV := view.GetPaneView(a.tabPickerPaneID); oldPV != nil {
1051- if parent := a.tabPickerWidget.GetParent(); parent == oldPV.Overlay() {
1052- oldPV.RemoveOverlayWidget(a.tabPickerWidget)
1287+ if oldPV := view.GetPaneView(bw.tabPickerPaneID); oldPV != nil {
1288+ if parent := bw.tabPickerWidget.GetParent(); parent == oldPV.Overlay() {
1289+ oldPV.RemoveOverlayWidget(bw.tabPickerWidget)
10531290 } else if parent != nil {
1054- a.tabPickerWidget.Unparent()
1291+ bw.tabPickerWidget.Unparent()
10551292 }
10561293 break
10571294 }
@@ -1059,20 +1296,21 @@ func (a *App) attachTabPickerToActivePane() {
10591296 }
10601297
10611298 // Ensure the widget can be reparented.
1062- if parent := a.tabPickerWidget.GetParent(); parent != nil {
1063- a.tabPickerWidget.Unparent()
1299+ if parent := bw.tabPickerWidget.GetParent(); parent != nil {
1300+ bw.tabPickerWidget.Unparent()
10641301 }
10651302
1066- a.tabPicker.SetParentOverlay(pv.Overlay())
1067- pv.AddOverlayWidget(a.tabPickerWidget)
1068- a.tabPickerPaneID = activePaneID
1303+ bw.tabPicker.SetParentOverlay(pv.Overlay())
1304+ pv.AddOverlayWidget(bw.tabPickerWidget)
1305+ bw.tabPickerPaneID = activePaneID
10691306 }
10701307
10711308 func (a *App) HandleMovePaneToTab(ctx context.Context) error {
10721309 if a.movePaneToTabUC == nil {
10731310 return nil
10741311 }
1075- if a.tabPicker == nil {
1312+ bw := a.lastFocusedBrowserWindow()
1313+ if bw == nil || bw.tabPicker == nil {
10761314 return nil
10771315 }
10781316
@@ -1104,7 +1342,7 @@ func (a *App) HandleMovePaneToTab(ctx context.Context) error {
11041342 items = append(items, component.TabPickerItem{IsNew: true, Index: -1})
11051343
11061344 a.attachTabPickerToActivePane()
1107- a.tabPicker.Show(ctx, items)
1345+ bw.tabPicker.Show(ctx, items)
11081346 return nil
11091347 }
11101348
@@ -1135,6 +1373,17 @@ func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID)
11351373 return nil
11361374 }
11371375
1376+ sourceWindow := a.browserWindowForTab(sourceTab.ID)
1377+ if sourceWindow == nil {
1378+ sourceWindow = a.browserWindowForMainWindow(a.mainWindow)
1379+ }
1380+ targetWindow := sourceWindow
1381+ if targetTabID != "" {
1382+ if owner := a.browserWindowForTab(targetTabID); owner != nil {
1383+ targetWindow = owner
1384+ }
1385+ }
1386+
11381387 out, err := a.movePaneToTabUC.Execute(*in)
11391388 if err != nil {
11401389 return err
@@ -1142,14 +1391,24 @@ func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID)
11421391 if out == nil || out.TargetTab == nil {
11431392 return nil
11441393 }
1394+ if out.NewTabCreated && targetWindow != nil {
1395+ a.setBrowserWindowForTab(out.TargetTab.ID, targetWindow)
1396+ }
11451397
1146- a.applyMovePaneToTabUI(ctx, out, sourceTab)
1147- a.switchToTargetTabIfConfigured(ctx, out)
1398+ a.applyMovePaneToTabUI(ctx, out, sourceTab, sourceWindow, targetWindow)
1399+ a.switchToTargetTabIfConfigured(ctx, out, targetWindow)
11481400 a.updateTabBarVisibility(ctx)
11491401 a.MarkDirty()
11501402 return nil
11511403 }
11521404
1405+func (a *App) moveActivePaneToTabFromBrowserWindow(ctx context.Context, bw *browserWindow, targetTabID entity.TabID) error {
1406+ if bw != nil {
1407+ a.activateBrowserWindow(bw)
1408+ }
1409+ return a.MoveActivePaneToTab(ctx, targetTabID)
1410+}
1411+
11531412 func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePaneToTabInput, *entity.Tab) {
11541413 if a.movePaneToTabUC == nil {
11551414 return nil, nil
@@ -1172,7 +1431,13 @@ func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePa
11721431 return in, activeTab
11731432 }
11741433
1175-func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToTabOutput, sourceTab *entity.Tab) {
1434+func (a *App) applyMovePaneToTabUI(
1435+ ctx context.Context,
1436+ out *usecase.MovePaneToTabOutput,
1437+ sourceTab *entity.Tab,
1438+ sourceWindow *browserWindow,
1439+ targetWindow *browserWindow,
1440+) {
11761441 if out == nil || out.TargetTab == nil {
11771442 return
11781443 }
@@ -1182,10 +1447,10 @@ func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToT
11821447 }
11831448
11841449 if out.SourceTabClosed {
1185- a.removeSourceTabUI(sourceTabID)
1450+ a.removeSourceTabUI(sourceTabID, sourceWindow)
11861451 }
11871452 if out.NewTabCreated {
1188- a.ensureTargetTabUI(ctx, out.TargetTab)
1453+ a.ensureTargetTabUI(ctx, out.TargetTab, targetWindow)
11891454 }
11901455
11911456 if !out.SourceTabClosed {
@@ -1194,20 +1459,20 @@ func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToT
11941459 a.rebuildAndAttachWorkspace(ctx, out.TargetTab.ID, out.TargetTab)
11951460 }
11961461
1197-func (a *App) removeSourceTabUI(sourceTabID entity.TabID) {
1462+func (a *App) removeSourceTabUI(sourceTabID entity.TabID, sourceWindow *browserWindow) {
11981463 a.releaseFloatingSessionsForTab(context.Background(), sourceTabID)
11991464 delete(a.workspaceViews, sourceTabID)
1200- if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
1201- a.mainWindow.TabBar().RemoveTab(sourceTabID)
1465+ if tabBar := a.tabBarForBrowserWindow(sourceWindow); tabBar != nil {
1466+ tabBar.RemoveTab(sourceTabID)
12021467 }
12031468 }
12041469
1205-func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab) {
1470+func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab, targetWindow *browserWindow) {
12061471 if tab == nil {
12071472 return
12081473 }
1209- if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
1210- a.mainWindow.TabBar().AddTab(tab)
1474+ if tabBar := a.tabBarForBrowserWindow(targetWindow); tabBar != nil {
1475+ tabBar.AddTab(tab)
12111476 }
12121477 a.createWorkspaceViewWithoutAttach(ctx, tab)
12131478 }
@@ -1255,12 +1520,19 @@ func (a *App) reattachFloatingSessions(tabID entity.TabID, wsView *component.Wor
12551520 }
12561521 }
12571522
1258-func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.MovePaneToTabOutput) {
1523+func (a *App) switchToTargetTabIfConfigured(
1524+ ctx context.Context,
1525+ out *usecase.MovePaneToTabOutput,
1526+ targetWindow *browserWindow,
1527+) {
12591528 if out == nil || out.TargetTab == nil {
12601529 return
12611530 }
12621531
1263- switchToTarget := a.deps.Config.Workspace.SwitchToTabOnMove
1532+ switchToTarget := false
1533+ if a.deps != nil && a.deps.Config != nil {
1534+ switchToTarget = a.deps.Config.Workspace.SwitchToTabOnMove
1535+ }
12641536 if out.SourceTabClosed {
12651537 switchToTarget = true
12661538 }
@@ -1269,8 +1541,14 @@ func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.Mo
12691541 }
12701542
12711543 a.tabs.SetActive(out.TargetTab.ID)
1272- if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
1273- a.mainWindow.TabBar().SetActive(out.TargetTab.ID)
1544+ if targetWindow != nil {
1545+ targetWindow.activeTabID = out.TargetTab.ID
1546+ }
1547+ if tabBar := a.tabBarForBrowserWindow(targetWindow); tabBar != nil {
1548+ tabBar.SetActive(out.TargetTab.ID)
1549+ }
1550+ if targetWindow != nil {
1551+ a.activateBrowserWindow(targetWindow)
12741552 }
12751553 a.switchWorkspaceView(ctx, out.TargetTab.ID)
12761554 }
@@ -1281,42 +1559,6 @@ func (a *App) updateTabBarVisibility(ctx context.Context) {
12811559 }
12821560 }
12831561
1284-func (a *App) initTabPicker(ctx context.Context) {
1285- log := logging.FromContext(ctx)
1286-
1287- if a.deps == nil || a.deps.Config == nil {
1288- log.Debug().Msg("deps/config not available, skipping tab picker")
1289- return
1290- }
1291-
1292- a.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{
1293- UIScale: a.deps.Config.DefaultUIScale,
1294- OnClose: func() {
1295- log.Debug().Msg("tab picker closed")
1296- },
1297- OnSelect: func(item component.TabPickerItem) {
1298- cb := glib.SourceFunc(func(_ uintptr) bool {
1299- var targetID entity.TabID
1300- if !item.IsNew {
1301- targetID = item.TabID
1302- }
1303- if err := a.MoveActivePaneToTab(ctx, targetID); err != nil {
1304- log.Warn().Err(err).Msg("move pane to tab failed")
1305- }
1306- return false
1307- })
1308- glib.IdleAdd(&cb, 0)
1309- },
1310- })
1311-
1312- if a.tabPicker == nil {
1313- log.Warn().Msg("failed to create tab picker")
1314- return
1315- }
1316-
1317- log.Debug().Msg("tab picker initialized")
1318-}
1319-
13201562 func (a *App) initSnapshotService(ctx context.Context) {
13211563 log := logging.FromContext(ctx)
13221564
@@ -1356,7 +1598,9 @@ func (a *App) initUpdateCoordinator(ctx context.Context) {
13561598 a.updateCoord = coordinator.NewUpdateCoordinator(
13571599 a.deps.CheckUpdateUC,
13581600 a.deps.ApplyUpdateUC,
1359- a.appToaster,
1601+ func(ctx context.Context, msg string, level component.ToastLevel) {
1602+ a.showToastOnLastFocusedBrowserWindow(ctx, msg, level)
1603+ },
13601604 a.deps.Config.Update.EnableOnStartup,
13611605 a.deps.Config.Update.AutoDownload,
13621606 )
@@ -1395,29 +1639,59 @@ func (a *App) createInitialTab(ctx context.Context) {
13951639 if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil {
13961640 log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab")
13971641 // Show error toast and fall through to create default tab
1398- if a.appToaster != nil {
1399- a.appToaster.Show(ctx, "Session restore failed", component.ToastWarning)
1400- }
1642+ a.showToastOnLastFocusedBrowserWindow(ctx, "Session restore failed", component.ToastWarning)
14011643 } else {
14021644 log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored")
14031645 // Show success toast for session restoration
1404- if a.appToaster != nil {
1405- a.appToaster.Show(ctx, "Session restored", component.ToastSuccess)
1406- }
1646+ a.showToastOnLastFocusedBrowserWindow(ctx, "Session restored", component.ToastSuccess)
14071647 return
14081648 }
14091649 }
14101650
14111651 // Create an initial tab using coordinator.
1412- initialURL := "dumb://home"
1413- if a.deps != nil && a.deps.InitialURL != "" {
1414- initialURL = a.deps.InitialURL
1415- }
1416- if _, err := a.tabCoord.Create(ctx, initialURL); err != nil {
1652+ if _, err := a.tabCoord.Create(ctx, a.initialWindowURL()); err != nil {
14171653 log.Error().Err(err).Msg("failed to create initial tab")
14181654 }
14191655 }
14201656
1657+func (a *App) clearSessionRestoreUIState(ctx context.Context) {
1658+ if a == nil {
1659+ return
1660+ }
1661+
1662+ tabIDs := make(map[entity.TabID]struct{})
1663+ if a.tabs != nil {
1664+ for _, tab := range a.tabs.Tabs {
1665+ if tab == nil {
1666+ continue
1667+ }
1668+ tabIDs[tab.ID] = struct{}{}
1669+ }
1670+ }
1671+ for tabID := range a.workspaceViews {
1672+ tabIDs[tabID] = struct{}{}
1673+ }
1674+ for tabID := range a.windowForTab {
1675+ tabIDs[tabID] = struct{}{}
1676+ }
1677+
1678+ var tabBar *component.TabBar
1679+ if a.mainWindow != nil {
1680+ tabBar = a.mainWindow.TabBar()
1681+ }
1682+ for tabID := range tabIDs {
1683+ if tabBar != nil {
1684+ tabBar.RemoveTab(tabID)
1685+ }
1686+ a.releaseFloatingSessionsForTab(ctx, tabID)
1687+ delete(a.workspaceViews, tabID)
1688+ delete(a.windowForTab, tabID)
1689+ }
1690+ if a.mainWindow != nil {
1691+ a.mainWindow.SetContent(nil)
1692+ }
1693+}
1694+
14211695 func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error {
14221696 log := logging.FromContext(ctx)
14231697
@@ -1443,6 +1717,8 @@ func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) er
14431717 return fmt.Errorf("failed to build tab list from snapshot")
14441718 }
14451719
1720+ a.clearSessionRestoreUIState(ctx)
1721+
14461722 // Replace tabs in-place to preserve references held by TabCoordinator
14471723 a.tabs.ReplaceFrom(restoredTabs)
14481724
@@ -1451,6 +1727,10 @@ func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) er
14511727 tabBar := a.mainWindow.TabBar()
14521728 for _, tab := range a.tabs.Tabs {
14531729 a.createWorkspaceViewWithoutAttach(ctx, tab)
1730+ wsView := a.workspaceViews[tab.ID]
1731+ if a.wsCoord != nil && wsView != nil {
1732+ a.wsCoord.SetupStackedPaneCallbacks(ctx, tab.Workspace, wsView)
1733+ }
14541734 if tabBar != nil {
14551735 tabBar.AddTab(tab)
14561736 }
@@ -1480,6 +1760,7 @@ func (a *App) finalizeActivation(ctx context.Context) {
14801760 if a.mainWindow != nil {
14811761 a.mainWindow.Show()
14821762 }
1763+ a.startBrowserLaunchRelayListener(ctx)
14831764 log.Info().Msg("main window displayed")
14841765
14851766 if a.deps != nil && len(a.deps.StartupCrashReports) > 0 {
@@ -1505,13 +1786,11 @@ func (a *App) showCrashReportToast(ctx context.Context, paths []string) {
15051786 log.Warn().Int("count", len(paths)).Msg("unexpected-close reports available")
15061787
15071788 cb := glib.SourceFunc(func(_ uintptr) bool {
1508- if a.appToaster != nil {
1509- msg := fmt.Sprintf("Detected %d unexpected close report(s). Run: dumber crashes issue latest", len(paths))
1510- a.appToaster.Show(ctx, msg, component.ToastWarning,
1511- component.WithDuration(crashReportToastDurationMs),
1512- component.WithPosition(component.ToastPositionBottomRight),
1513- )
1514- }
1789+ msg := fmt.Sprintf("Detected %d unexpected close report(s). Run: dumber crashes issue latest", len(paths))
1790+ a.showToastOnLastFocusedBrowserWindow(ctx, msg, component.ToastWarning,
1791+ component.WithDuration(crashReportToastDurationMs),
1792+ component.WithPosition(component.ToastPositionBottomRight),
1793+ )
15151794 return false
15161795 })
15171796 glib.IdleAdd(&cb, 0)
@@ -1563,6 +1842,9 @@ func (a *App) onShutdown(ctx context.Context) {
15631842 }
15641843 }
15651844
1845+ // Stop accepting relaunches before teardown starts.
1846+ a.closeBrowserLaunchRelayListener()
1847+
15661848 // Cancel context to signal all goroutines
15671849 a.cancel(errors.New("application shutdown"))
15681850
@@ -1666,6 +1948,10 @@ func (a *App) initCoordinators(ctx context.Context) {
16661948 a.createWorkspaceView(ctx, tab)
16671949 })
16681950 a.tabCoord.SetOnTabSwitched(func(ctx context.Context, tab *entity.Tab) {
1951+ if bw := a.browserWindowForTab(tab.ID); bw != nil {
1952+ bw.activeTabID = tab.ID
1953+ a.activateBrowserWindow(bw)
1954+ }
16691955 a.switchWorkspaceView(ctx, tab.ID)
16701956 })
16711957 a.tabCoord.SetOnQuit(a.Quit)
@@ -1675,27 +1961,13 @@ func (a *App) initCoordinators(ctx context.Context) {
16751961 a.attachPopupToTab(ctx, tabID, pane, wv)
16761962 })
16771963
1678- // Wire tab bar click handling to coordinator
1679- if a.mainWindow != nil && a.mainWindow.TabBar() != nil {
1680- a.mainWindow.TabBar().SetOnSwitch(func(tabID entity.TabID) {
1681- if err := a.tabCoord.Switch(ctx, tabID); err != nil {
1682- log.Error().Err(err).Str("tab_id", string(tabID)).Msg("tab switch failed")
1683- }
1684- })
1964+ for _, bw := range a.browserWindows {
1965+ a.wireBrowserWindowTabBar(ctx, bw)
16851966 }
16861967
16871968 // Set fullscreen callback to hide/show tab bar (after tabCoord is initialized)
1688- a.contentCoord.SetOnFullscreenChanged(func(entering bool) {
1689- if a.mainWindow == nil || a.mainWindow.TabBar() == nil {
1690- return
1691- }
1692- if entering {
1693- // Hide tab bar when entering fullscreen video
1694- a.mainWindow.TabBar().SetVisible(false)
1695- } else {
1696- // Restore tab bar visibility based on normal logic
1697- a.tabCoord.UpdateBarVisibility(ctx)
1698- }
1969+ a.contentCoord.SetOnFullscreenChanged(func(paneID entity.PaneID, entering bool) {
1970+ a.handlePaneFullscreenChanged(paneID, entering)
16991971 })
17001972
17011973 // 3. Workspace Coordinator
@@ -1768,8 +2040,8 @@ func (a *App) initCoordinators(ctx context.Context) {
17682040 })
17692041
17702042 // Wire window title updates when active pane's title changes
1771- a.contentCoord.SetOnWindowTitleChanged(func(title string) {
1772- a.updateWindowTitle(title)
2043+ a.contentCoord.SetOnWindowTitleChanged(func(paneID entity.PaneID, title string) {
2044+ a.handlePaneWindowTitleChanged(paneID, title)
17732045 })
17742046
17752047 // Wire pane URI updates for session snapshots (searches all tabs)
@@ -1814,8 +2086,12 @@ func (a *App) initCoordinators(ctx context.Context) {
18142086 a.deps.ZoomUC,
18152087 a.deps.CopyURLUC,
18162088 a.deps.Config.Workspace.NewPaneURL,
2089+ a.contentCoord.ActivePaneID,
18172090 )
18182091 a.wireKeyboardActions()
2092+ for _, bw := range a.browserWindows {
2093+ a.initBrowserWindowInput(ctx, bw)
2094+ }
18192095
18202096 log.Debug().Msg("coordinators initialized")
18212097 }
@@ -1838,10 +2114,16 @@ func (a *App) wireKeyboardActions() {
18382114 a.CloseFindBar(ctx)
18392115 return nil
18402116 })
1841- a.kbDispatcher.SetOnMovePaneToTab(func(ctx context.Context) error {
2117+ a.kbDispatcher.SetOnMovePaneToTab(func(ctx context.Context, paneID entity.PaneID) error {
2118+ if bw := a.ownerOrLastFocusedBrowserWindow("", paneID); bw != nil {
2119+ a.activateBrowserWindow(bw)
2120+ }
18422121 return a.HandleMovePaneToTab(ctx)
18432122 })
1844- a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context) error {
2123+ a.kbDispatcher.SetOnMovePaneToNextTab(func(ctx context.Context, paneID entity.PaneID) error {
2124+ if bw := a.ownerOrLastFocusedBrowserWindow("", paneID); bw != nil {
2125+ a.activateBrowserWindow(bw)
2126+ }
18452127 return a.HandleMovePaneToNextTab(ctx)
18462128 })
18472129 a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error {
@@ -1858,33 +2140,73 @@ func (a *App) wireKeyboardActions() {
18582140 }
18592141
18602142 func (a *App) wireWebRTCPermissionIndicator() {
1861- if a.webrtcIndicator == nil || a.contentCoord == nil {
2143+ if a.contentCoord == nil {
18622144 return
18632145 }
18642146 ctx := context.Background()
18652147 if a.deps != nil && a.deps.Ctx != nil {
18662148 ctx = a.deps.Ctx
18672149 }
1868- log := logging.FromContext(ctx)
18692150
1870- a.contentCoord.SetOnPermissionActivity(func(origin string, permTypes []entity.PermissionType, state content.PermissionActivityState) {
1871- a.webrtcIndicator.SetOrigin(origin)
2151+ a.contentCoord.SetOnPermissionActivity(func(
2152+ paneID entity.PaneID,
2153+ origin string,
2154+ permTypes []entity.PermissionType,
2155+ state content.PermissionActivityState,
2156+ ) {
2157+ bw := a.browserWindowForPane(paneID)
2158+ if bw == nil || bw.webrtcIndicator == nil {
2159+ return
2160+ }
2161+ if state == content.PermissionActivityRequesting && a.deps != nil && a.deps.PermissionUC != nil && bw.permissionDialog != nil {
2162+ a.deps.PermissionUC.SetDialogPresenter(bw.permissionDialog)
2163+ }
2164+ bw.webrtcIndicator.SetOrigin(origin)
18722165
18732166 switch state {
18742167 case content.PermissionActivityRequesting:
1875- a.webrtcIndicator.MarkRequesting(permTypes)
2168+ bw.webrtcIndicator.MarkRequesting(permTypes)
18762169 case content.PermissionActivityAllowed:
1877- a.webrtcIndicator.MarkAllowed(permTypes)
2170+ bw.webrtcIndicator.MarkAllowed(permTypes)
18782171 case content.PermissionActivityBlocked:
1879- a.webrtcIndicator.MarkBlocked(permTypes)
2172+ bw.webrtcIndicator.MarkBlocked(permTypes)
18802173 }
18812174
18822175 for _, permType := range permTypes {
1883- a.syncWebRTCPermissionLockState(ctx, origin, permType)
2176+ a.syncWebRTCPermissionLockState(ctx, bw.webrtcIndicator, origin, permType)
18842177 }
18852178 })
18862179
1887- a.webrtcIndicator.SetOnToggleLock(func(origin string, permType entity.PermissionType, state string, hasStored bool) {
2180+ // Reset the owning window's indicator when that pane navigates away.
2181+ a.contentCoord.SetOnActiveNavigationCommitted(func(paneID entity.PaneID, uri string) {
2182+ bw := a.browserWindowForPane(paneID)
2183+ if bw == nil || bw.webrtcIndicator == nil {
2184+ return
2185+ }
2186+ newOrigin, err := urlutil.ExtractOrigin(uri)
2187+ if err != nil {
2188+ bw.webrtcIndicator.Reset()
2189+ return
2190+ }
2191+
2192+ currentOrigin := bw.webrtcIndicator.Origin()
2193+ if currentOrigin != "" && currentOrigin != newOrigin {
2194+ bw.webrtcIndicator.Reset()
2195+ }
2196+ })
2197+}
2198+
2199+func (a *App) wireBrowserWindowPermissionIndicator(bw *browserWindow) {
2200+ if bw == nil || bw.webrtcIndicator == nil {
2201+ return
2202+ }
2203+ ctx := context.Background()
2204+ if a.deps != nil && a.deps.Ctx != nil {
2205+ ctx = a.deps.Ctx
2206+ }
2207+ log := logging.FromContext(ctx)
2208+
2209+ bw.webrtcIndicator.SetOnToggleLock(func(origin string, permType entity.PermissionType, state string, hasStored bool) {
18882210 if a.deps == nil || a.deps.PermissionUC == nil || origin == "" {
18892211 return
18902212 }
@@ -1909,27 +2231,17 @@ func (a *App) wireWebRTCPermissionIndicator() {
19092231 }
19102232 }
19112233
1912- a.syncWebRTCPermissionLockState(ctx, origin, permType)
1913- })
1914-
1915- // Reset indicator when the active pane navigates away from the current origin.
1916- a.contentCoord.SetOnActiveNavigationCommitted(func(uri string) {
1917- newOrigin, err := urlutil.ExtractOrigin(uri)
1918- if err != nil {
1919- // Internal pages (dumb://, about:) — clear the indicator.
1920- a.webrtcIndicator.Reset()
1921- return
1922- }
1923-
1924- currentOrigin := a.webrtcIndicator.Origin()
1925- if currentOrigin != "" && currentOrigin != newOrigin {
1926- a.webrtcIndicator.Reset()
1927- }
2234+ a.syncWebRTCPermissionLockState(ctx, bw.webrtcIndicator, origin, permType)
19282235 })
19292236 }
19302237
1931-func (a *App) syncWebRTCPermissionLockState(ctx context.Context, origin string, permType entity.PermissionType) {
1932- if a.webrtcIndicator == nil || a.deps == nil || a.deps.PermissionUC == nil || origin == "" {
2238+func (a *App) syncWebRTCPermissionLockState(
2239+ ctx context.Context,
2240+ indicator *component.WebRTCPermissionIndicator,
2241+ origin string,
2242+ permType entity.PermissionType,
2243+) {
2244+ if indicator == nil || a.deps == nil || a.deps.PermissionUC == nil || origin == "" {
19332245 return
19342246 }
19352247
@@ -1944,11 +2256,40 @@ func (a *App) syncWebRTCPermissionLockState(ctx context.Context, origin string,
19442256 }
19452257
19462258 if record == nil {
1947- a.webrtcIndicator.SetStoredDecision(permType, entity.PermissionPrompt, false)
2259+ indicator.SetStoredDecision(permType, entity.PermissionPrompt, false)
19482260 return
19492261 }
19502262
1951- a.webrtcIndicator.SetStoredDecision(permType, record.Decision, true)
2263+ indicator.SetStoredDecision(permType, record.Decision, true)
2264+}
2265+
2266+// generateWindowID generates a unique ID for top-level browser windows.
2267+func (a *App) generateWindowID() string {
2268+ a.windowIDMu.Lock()
2269+ defer a.windowIDMu.Unlock()
2270+ a.windowIDCounter++
2271+ return fmt.Sprintf("w%d", a.windowIDCounter)
2272+}
2273+
2274+// runOnMainThread executes fn inline when already on the GTK main context;
2275+// otherwise it dispatches fn to the GTK main loop and waits for completion.
2276+func (a *App) runOnMainThread(fn func()) {
2277+ if fn == nil {
2278+ return
2279+ }
2280+ glibCtx := glib.MainContextDefault()
2281+ if glibCtx != nil && glibCtx.IsOwner() {
2282+ fn()
2283+ return
2284+ }
2285+
2286+ done := make(chan struct{})
2287+ cb := glib.SourceOnceFunc(func(_ uintptr) {
2288+ fn()
2289+ close(done)
2290+ })
2291+ glib.IdleAddOnce(&cb, 0)
2292+ <-done
19522293 }
19532294
19542295 // generateID generates a unique ID for tabs and panes.
@@ -1990,8 +2331,11 @@ func RunWithArgs(ctx context.Context, deps *Dependencies) int {
19902331
19912332 // updateWindowTitle updates the window title with the given page title.
19922333 // Format: "<Page Title> - Dumber" or just "Dumber" if title is empty.
1993-func (a *App) updateWindowTitle(pageTitle string) {
1994- if a.mainWindow == nil {
2334+func (a *App) updateWindowTitle(pageTitle string, target *browserWindow) {
2335+ if target == nil {
2336+ target = a.browserWindowForMainWindow(a.mainWindow)
2337+ }
2338+ if target == nil || target.mainWindow == nil {
19952339 return
19962340 }
19972341
@@ -1999,18 +2343,27 @@ func (a *App) updateWindowTitle(pageTitle string) {
19992343 if pageTitle != "" {
20002344 title = pageTitle + " - Dumber"
20012345 }
2002- a.mainWindow.SetTitle(title)
2346+ target.mainWindow.SetTitle(title)
20032347 }
20042348
20052349 // updateWindowTitleFromActivePane updates the window title based on the current active pane.
2006-func (a *App) updateWindowTitleFromActivePane() {
2007- ws := a.activeWorkspace()
2350+func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) {
2351+ var ws *entity.Workspace
2352+ if a.tabs != nil {
2353+ if tab := a.tabs.Find(tabID); tab != nil {
2354+ ws = tab.Workspace
2355+ } else {
2356+ ws = a.activeWorkspace()
2357+ }
2358+ } else {
2359+ ws = a.activeWorkspace()
2360+ }
20082361 if ws == nil || a.contentCoord == nil {
2009- a.updateWindowTitle("")
2362+ a.updateWindowTitle("", a.browserWindowForTab(tabID))
20102363 return
20112364 }
20122365 title := a.contentCoord.GetTitle(ws.ActivePaneID)
2013- a.updateWindowTitle(title)
2366+ a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, ""))
20142367 }
20152368
20162369 // handleModeChange is called when the input mode changes.
@@ -2027,8 +2380,8 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
20272380
20282381 // Update global border overlay visibility based on mode.
20292382 // Note: resize mode border is handled per-pane (stack container), not via global overlay.
2030- if a.borderMgr != nil {
2031- a.borderMgr.OnModeChange(ctx, from, to)
2383+ if bw := a.lastFocusedBrowserWindow(); bw != nil && bw.borderMgr != nil {
2384+ bw.borderMgr.OnModeChange(ctx, from, to)
20322385 }
20332386
20342387 // Show/hide mode indicator toaster based on config.
@@ -2037,25 +2390,26 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
20372390
20382391 // updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config.
20392392 func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) {
2040- if a.modeToaster == nil {
2393+ bw := a.lastFocusedBrowserWindow()
2394+ if bw == nil || bw.modeToaster == nil {
20412395 return
20422396 }
20432397
20442398 // Check if mode indicator toaster is enabled in config.
20452399 if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.Styling.ModeIndicatorToasterEnabled {
2046- a.modeToaster.Hide()
2400+ bw.modeToaster.Hide()
20472401 return
20482402 }
20492403
20502404 if mode == input.ModeNormal {
2051- a.modeToaster.Hide()
2405+ bw.modeToaster.Hide()
20522406 return
20532407 }
20542408
20552409 // Show persistent toaster at bottom-left with mode display name.
20562410 // Mode class is applied atomically with Show() to avoid visual flicker.
20572411 modeClass := getModeToastClass(mode)
2058- a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
2412+ bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo,
20592413 component.WithDuration(0), // Persistent until mode exits.
20602414 component.WithPosition(component.ToastPositionBottomLeft),
20612415 component.WithModeClass(modeClass),
@@ -2123,14 +2477,19 @@ func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) {
21232477 func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
21242478 a.createWorkspaceViewWithoutAttach(ctx, tab)
21252479
2480+ target := a.browserWindowForTab(tab.ID)
2481+ if target != nil {
2482+ a.setBrowserWindowForTab(tab.ID, target)
2483+ }
2484+
21262485 // Attach to content area
21272486 wsView := a.workspaceViews[tab.ID]
2128- if wsView != nil && a.mainWindow != nil {
2487+ if wsView != nil && target != nil && target.mainWindow != nil {
21292488 widget := wsView.Widget()
21302489 if widget != nil {
21312490 gtkWidget := widget.GtkWidget()
21322491 if gtkWidget != nil {
2133- a.mainWindow.SetContent(gtkWidget)
2492+ target.mainWindow.SetContent(gtkWidget)
21342493 }
21352494 }
21362495 }
@@ -2203,6 +2562,9 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
22032562
22042563 // activeWorkspace returns the workspace of the active tab.
22052564 func (a *App) activeWorkspace() *entity.Workspace {
2565+ if a.tabs == nil {
2566+ return nil
2567+ }
22062568 activeTab := a.tabs.ActiveTab()
22072569 if activeTab == nil {
22082570 return nil
@@ -2309,12 +2671,15 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
23092671 }
23102672
23112673 // Swap content (MainWindow.SetContent now properly removes old content)
2312- if a.mainWindow != nil {
2674+ if target := a.browserWindowForTab(tabID); target != nil && target.mainWindow != nil {
2675+ target.activeTabID = tabID
2676+ target.mainWindow.SetContent(gtkWidget)
2677+ } else if a.mainWindow != nil {
23132678 a.mainWindow.SetContent(gtkWidget)
23142679 }
23152680
23162681 // Update window title with the new active pane's title
2317- a.updateWindowTitleFromActivePane()
2682+ a.updateWindowTitleFromActivePane(tabID)
23182683 a.syncFloatingFocus()
23192684
23202685 log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched")
@@ -2495,8 +2860,8 @@ func (a *App) installFloatingOverlayPositioning(tabID entity.TabID, workspaceOve
24952860 return
24962861 }
24972862
2498- cb := func(_ gtk.Overlay, widget *gtk.Widget, allocationPtr *uintptr) bool {
2499- var widgetPtr uintptr
2863+ cb := func(_ gtk.Overlay, widgetPtr uintptr, allocationPtr *uintptr) bool {
2864+ widget := gtk.WidgetNewFromInternalPtr(widgetPtr)
25002865 if widget != nil {
25012866 widgetPtr = widget.GoPointer()
25022867 }
@@ -2756,9 +3121,7 @@ func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspac
27563121 if session.omnibox == nil {
27573122 cfg := a.omniboxCfg
27583123 cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) {
2759- if a.appToaster != nil {
2760- a.appToaster.Show(toastCtx, message, level)
2761- }
3124+ a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level)
27623125 }
27633126
27643127 omnibox := component.NewOmnibox(ctx, cfg)
@@ -3184,13 +3547,16 @@ func (a *App) initConfigWatcher(ctx context.Context) {
31843547 a.deps.OnConfigChange(func() {
31853548 cb := glib.SourceFunc(func(_ uintptr) bool {
31863549 a.applyAppearanceConfig(ctx)
3187- // Reload keyboard shortcuts
3188- if a.keyboardHandler != nil {
3189- a.keyboardHandler.ReloadShortcuts(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session)
3190- }
3191- // Reload global shortcuts (removes stale, re-registers from config)
3192- if a.globalShortcutHandler != nil {
3193- a.globalShortcutHandler.ReloadShortcuts(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session)
3550+ for _, bw := range a.browserWindows {
3551+ if bw == nil {
3552+ continue
3553+ }
3554+ if bw.keyboardHandler != nil {
3555+ bw.keyboardHandler.ReloadShortcuts(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session)
3556+ }
3557+ if bw.globalShortcutHandler != nil {
3558+ bw.globalShortcutHandler.ReloadShortcuts(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session)
3559+ }
31943560 }
31953561 return false
31963562 })
@@ -3279,21 +3645,15 @@ func (a *App) showFilterStatus(ctx context.Context, status port.FilterStatus) {
32793645
32803646 switch status.State {
32813647 case port.FilterStateLoading:
3282- if a.appToaster != nil {
3283- a.appToaster.Show(ctx, status.Message, component.ToastInfo)
3284- }
3648+ a.showToastOnLastFocusedBrowserWindow(ctx, status.Message, component.ToastInfo)
32853649 case port.FilterStateActive:
32863650 // Apply filters to existing webviews that were created before filters loaded
32873651 if a.contentCoord != nil && a.deps.FilterManager != nil {
32883652 a.contentCoord.ApplyFiltersToAll(ctx)
32893653 log.Debug().Msg("applied filters to all existing webviews")
32903654 }
3291- if a.appToaster != nil {
3292- a.appToaster.Show(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo)
3293- }
3655+ a.showToastOnLastFocusedBrowserWindow(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo)
32943656 case port.FilterStateError:
3295- if a.appToaster != nil {
3296- a.appToaster.Show(ctx, "Filter load failed: "+status.Message, component.ToastError)
3297- }
3657+ a.showToastOnLastFocusedBrowserWindow(ctx, "Filter load failed: "+status.Message, component.ToastError)
32983658 }
32993659 }
A internal/ui/app_browser_launch_test.go +1087 -0
@@ -0,0 +1,1087 @@
1+package ui
2+
3+import (
4+ "context"
5+ "io"
6+ "os"
7+ "path/filepath"
8+ "reflect"
9+ "strings"
10+ "testing"
11+ "unsafe"
12+
13+ "github.com/bnema/dumber/internal/application/port"
14+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
15+ "github.com/bnema/dumber/internal/application/usecase"
16+ "github.com/bnema/dumber/internal/domain/entity"
17+ "github.com/bnema/dumber/internal/domain/repository"
18+ "github.com/bnema/dumber/internal/infrastructure/config"
19+ "github.com/bnema/dumber/internal/ui/component"
20+ "github.com/bnema/dumber/internal/ui/coordinator"
21+ contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
22+ "github.com/bnema/dumber/internal/ui/dispatcher"
23+ "github.com/bnema/dumber/internal/ui/input"
24+ "github.com/bnema/dumber/internal/ui/layout"
25+ layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
26+ "github.com/bnema/dumber/internal/ui/window"
27+ "github.com/bnema/puregotk/v4/gdk"
28+ "github.com/bnema/puregotk/v4/gio"
29+ "github.com/bnema/puregotk/v4/gtk"
30+)
31+
32+type testBrowserLaunchRelay struct {
33+ listenCalls int
34+ closer *testCloser
35+}
36+
37+func (r *testBrowserLaunchRelay) DeliverOpenFreshWindow(context.Context, string) (bool, error) {
38+ return false, nil
39+}
40+
41+func (r *testBrowserLaunchRelay) Listen(_ context.Context, opener port.BrowserWindowOpener) (io.Closer, error) {
42+ r.listenCalls++
43+ _ = opener
44+ return r.closer, nil
45+}
46+
47+type testCloser struct {
48+ closed bool
49+}
50+
51+func (c *testCloser) Close() error {
52+ c.closed = true
53+ return nil
54+}
55+
56+type fakeSessionStateRepo struct {
57+ state *entity.SessionState
58+}
59+
60+var _ repository.SessionStateRepository = (*fakeSessionStateRepo)(nil)
61+
62+func (r *fakeSessionStateRepo) SaveSnapshot(context.Context, *entity.SessionState) error { return nil }
63+
64+func (r *fakeSessionStateRepo) GetSnapshot(context.Context, entity.SessionID) (*entity.SessionState, error) {
65+ return r.state, nil
66+}
67+
68+func (r *fakeSessionStateRepo) DeleteSnapshot(context.Context, entity.SessionID) error { return nil }
69+
70+func (r *fakeSessionStateRepo) GetAllSnapshots(context.Context) ([]*entity.SessionState, error) {
71+ return nil, nil
72+}
73+
74+func (r *fakeSessionStateRepo) GetTotalSnapshotsSize(context.Context) (int64, error) { return 0, nil }
75+
76+func testHasUsableGTKDisplay() bool {
77+ if waylandDisplay := os.Getenv("WAYLAND_DISPLAY"); waylandDisplay != "" {
78+ candidates := []string{waylandDisplay}
79+ if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" && !filepath.IsAbs(waylandDisplay) {
80+ candidates = append([]string{filepath.Join(runtimeDir, waylandDisplay)}, candidates...)
81+ }
82+ for _, candidate := range candidates {
83+ if _, err := os.Stat(candidate); err == nil {
84+ return true
85+ }
86+ }
87+ }
88+
89+ if display := os.Getenv("DISPLAY"); display != "" {
90+ if strings.HasPrefix(display, ":") {
91+ displayNum := strings.TrimPrefix(display, ":")
92+ displayNum = strings.SplitN(displayNum, ".", 2)[0]
93+ if displayNum == "" {
94+ return false
95+ }
96+ x11SocketCandidates := []string{filepath.Join(os.TempDir(), ".X11-unix", "X"+displayNum)}
97+ if fallback := "/tmp/.X11-unix/X" + displayNum; fallback != x11SocketCandidates[0] {
98+ x11SocketCandidates = append(x11SocketCandidates, fallback)
99+ }
100+ for _, candidate := range x11SocketCandidates {
101+ if _, err := os.Stat(candidate); err == nil {
102+ return true
103+ }
104+ }
105+ return false
106+ }
107+ return true
108+ }
109+
110+ return false
111+}
112+
113+func requireGTKDisplayApp(t *testing.T) *gtk.Application {
114+ t.Helper()
115+ if !testHasUsableGTKDisplay() {
116+ t.Skip("GTK display not available")
117+ }
118+ EnsureAdwaitaInitialized()
119+ if gdk.DisplayGetDefault() == nil {
120+ t.Skip("GTK display not available")
121+ }
122+ appID := AppID
123+ gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue)
124+ if gtkApp == nil {
125+ t.Fatal("gtk application creation failed")
126+ }
127+ return gtkApp
128+}
129+
130+// setDependencyField uses reflection to reach unexported dependency fields for tests.
131+// Keep this test-only so production visibility stays narrow.
132+func setDependencyField(t *testing.T, deps *Dependencies, field string, value any) {
133+ t.Helper()
134+
135+ rv := reflect.ValueOf(deps).Elem()
136+ fv := rv.FieldByName(field)
137+ if !fv.IsValid() {
138+ t.Fatalf("Dependencies missing %s field", field)
139+ }
140+ fv.Set(reflect.ValueOf(value))
141+}
142+
143+// windowForTabCount uses reflection to inspect unexported app state in tests.
144+// Keep this test-only so production visibility stays narrow.
145+func windowForTabCount(t *testing.T, app *App) int {
146+ t.Helper()
147+
148+ rv := reflect.ValueOf(app).Elem()
149+ fv := rv.FieldByName("windowForTab")
150+ if !fv.IsValid() {
151+ t.Fatalf("App missing windowForTab field")
152+ }
153+ return fv.Len()
154+}
155+
156+// tabCoordinatorMainWindowPtr uses reflection to inspect unexported coordinator state in tests.
157+// Keep this test-only so production visibility stays narrow.
158+func tabCoordinatorMainWindowPtr(t *testing.T, tc *coordinator.TabCoordinator) uintptr {
159+ t.Helper()
160+
161+ rv := reflect.ValueOf(tc).Elem()
162+ fv := rv.FieldByName("mainWindow")
163+ if !fv.IsValid() {
164+ t.Fatalf("TabCoordinator missing mainWindow field")
165+ }
166+ return fv.Pointer()
167+}
168+
169+func setWindowTabBar(t *testing.T, mw *window.MainWindow, tabBar *component.TabBar) {
170+ t.Helper()
171+
172+ rv := reflect.ValueOf(mw).Elem()
173+ fv := rv.FieldByName("tabBar")
174+ if !fv.IsValid() {
175+ t.Fatalf("MainWindow missing tabBar field")
176+ }
177+ reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().Set(reflect.ValueOf(tabBar))
178+}
179+
180+func newTestTabBarShell(t *testing.T, tabIDs ...entity.TabID) *component.TabBar {
181+ t.Helper()
182+
183+ tabBar := &component.TabBar{}
184+ buttons := make(map[entity.TabID]*component.TabButton, len(tabIDs))
185+ for _, tabID := range tabIDs {
186+ buttons[tabID] = &component.TabButton{}
187+ }
188+
189+ rv := reflect.ValueOf(tabBar).Elem()
190+ fv := rv.FieldByName("buttons")
191+ if !fv.IsValid() {
192+ t.Fatalf("TabBar missing buttons field")
193+ }
194+ reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().Set(reflect.ValueOf(buttons))
195+
196+ return tabBar
197+}
198+
199+func windowTabBarActiveID(t *testing.T, mw *window.MainWindow) entity.TabID {
200+ t.Helper()
201+ if mw == nil || mw.TabBar() == nil {
202+ return ""
203+ }
204+ return mw.TabBar().ActiveTabID()
205+}
206+
207+func windowTitle(t *testing.T, mw *window.MainWindow) string {
208+ t.Helper()
209+ if mw == nil || mw.Window() == nil {
210+ return ""
211+ }
212+ return mw.Window().GetTitle()
213+}
214+
215+func windowTabBarVisible(t *testing.T, mw *window.MainWindow) bool {
216+ t.Helper()
217+ if mw == nil || mw.TabBar() == nil || mw.TabBar().Box() == nil {
218+ return false
219+ }
220+ return mw.TabBar().Box().GetVisible()
221+}
222+
223+func stackedViewOnActivateIsNil(t *testing.T, sv *layout.StackedView) bool {
224+ t.Helper()
225+ if sv == nil {
226+ return true
227+ }
228+
229+ rv := reflect.ValueOf(sv).Elem()
230+ fv := rv.FieldByName("onActivate")
231+ if !fv.IsValid() {
232+ t.Fatalf("StackedView missing onActivate field")
233+ }
234+ return reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().IsNil()
235+}
236+
237+func newTestShellToaster(t *testing.T) (*component.Toaster, *layoutmocks.MockBoxWidget, *layoutmocks.MockLabelWidget) {
238+ t.Helper()
239+
240+ factory := layoutmocks.NewMockWidgetFactory(t)
241+ box := layoutmocks.NewMockBoxWidget(t)
242+ label := layoutmocks.NewMockLabelWidget(t)
243+
244+ factory.EXPECT().NewBox(layout.OrientationHorizontal, 0).Return(box).Once()
245+ factory.EXPECT().NewLabel("").Return(label).Once()
246+ box.EXPECT().AddCssClass("toast").Once()
247+ box.EXPECT().AddCssClass("toast-info").Once()
248+ box.EXPECT().SetHalign(gtk.AlignStartValue).Twice()
249+ box.EXPECT().SetValign(gtk.AlignStartValue).Twice()
250+ box.EXPECT().SetHexpand(false).Once()
251+ box.EXPECT().SetVexpand(false).Once()
252+ box.EXPECT().SetCanTarget(false).Once()
253+ box.EXPECT().SetCanFocus(false).Once()
254+ box.EXPECT().SetVisible(false).Once()
255+ box.EXPECT().Append(label).Once()
256+ label.EXPECT().SetCanTarget(false).Once()
257+ label.EXPECT().SetCanFocus(false).Once()
258+
259+ return component.NewToaster(factory), box, label
260+}
261+
262+func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) {
263+ ctx := context.Background()
264+ toaster, box, label := newTestShellToaster(t)
265+ box.EXPECT().SetVisible(true).Once()
266+ label.EXPECT().SetText("Ad blocker loading").Once()
267+
268+ bw := &browserWindow{id: "window-1", appToaster: toaster}
269+ app := &App{
270+ browserWindows: map[string]*browserWindow{bw.id: bw},
271+ lastFocusedWindowID: bw.id,
272+ }
273+
274+ app.showFilterStatus(ctx, port.FilterStatus{State: port.FilterStateLoading, Message: "Ad blocker loading"})
275+}
276+
277+func TestApp_CheckConfigMigrationUsesLastFocusedBrowserWindowToaster(t *testing.T) {
278+ ctx := context.Background()
279+ toaster, box, label := newTestShellToaster(t)
280+ box.EXPECT().SetVisible(true).Once()
281+ label.EXPECT().SetText("Config has 1 new settings. Run 'dumber config migrate'").Once()
282+
283+ migrator := portmocks.NewMockConfigMigrator(t)
284+ migrator.EXPECT().CheckMigration().Return(&port.MigrationResult{MissingKeys: []string{"update.notify_on_new_settings"}}, nil).Once()
285+
286+ bw := &browserWindow{id: "window-1", appToaster: toaster}
287+ app := &App{
288+ deps: &Dependencies{
289+ Config: &config.Config{
290+ Update: config.UpdateConfig{NotifyOnNewSettings: true},
291+ },
292+ ConfigMigrator: migrator,
293+ },
294+ browserWindows: map[string]*browserWindow{bw.id: bw},
295+ lastFocusedWindowID: bw.id,
296+ }
297+
298+ app.checkConfigMigration(ctx)
299+}
300+
301+func TestApp_FinalizeActivationStartsBrowserLaunchRelayOnceAndClosesOnShutdown(t *testing.T) {
302+ relay := &testBrowserLaunchRelay{closer: &testCloser{}}
303+ deps := &Dependencies{}
304+ setDependencyField(t, deps, "BrowserLaunchRelay", relay)
305+
306+ app := &App{deps: deps, cancel: func(error) {}}
307+
308+ app.finalizeActivation(context.Background())
309+ app.finalizeActivation(context.Background())
310+
311+ if relay.listenCalls != 1 {
312+ t.Fatalf("Listen calls = %d, want 1", relay.listenCalls)
313+ }
314+
315+ app.onShutdown(context.Background())
316+
317+ if !relay.closer.closed {
318+ t.Fatalf("relay listener closer was not closed")
319+ }
320+}
321+
322+func TestApp_OpenFreshWindowRecordsTabOwnership(t *testing.T) {
323+ created := &browserWindow{id: "window-1"}
324+ app := &App{
325+ tabs: entity.NewTabList(),
326+ tabsUC: usecase.NewManageTabsUseCase(func() string { return "id-1" }),
327+ browserWindows: make(map[string]*browserWindow),
328+ workspaceViews: map[entity.TabID]*component.WorkspaceView{entity.TabID("id-1"): &component.WorkspaceView{}},
329+ browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
330+ return created, nil
331+ },
332+ }
333+ app.tabs.Add(entity.NewTab(entity.TabID("existing-tab"), entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane"))))
334+
335+ if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
336+ t.Fatalf("OpenFreshWindow returned error: %v", err)
337+ }
338+ if got := windowForTabCount(t, app); got != 1 {
339+ t.Fatalf("windowForTab length = %d, want 1", got)
340+ }
341+}
342+
343+func TestApp_RestoreSessionClearsStaleUIStateBeforeApplyingRestoredTabs(t *testing.T) {
344+ gtkApp := requireGTKDisplayApp(t)
345+ defer gtkApp.Unref()
346+
347+ mainWindow, err := window.New(context.Background(), gtkApp, "top")
348+ if err != nil {
349+ t.Fatalf("window creation failed: %v", err)
350+ }
351+ defer mainWindow.Destroy()
352+
353+ staleTab := entity.NewTab(entity.TabID("stale-tab"), entity.WorkspaceID("stale-workspace"), entity.NewPane(entity.PaneID("stale-pane")))
354+ staleWindow := &browserWindow{id: "window-stale", mainWindow: mainWindow}
355+ staleSessionKey := floatingSessionKey{tabID: staleTab.ID, sessionID: "profile-stale"}
356+ restoredSessionID := entity.SessionID("session-restore")
357+ restoredTabs := entity.NewTabList()
358+ restoredTabs.Add(entity.NewTab(entity.TabID("restored-tab"), entity.WorkspaceID("restored-workspace"), entity.NewPane(entity.PaneID("restored-pane"))))
359+
360+ app := &App{
361+ deps: &Dependencies{
362+ Config: &config.Config{},
363+ SessionStateRepo: &fakeSessionStateRepo{state: entity.SnapshotFromTabList(restoredSessionID, restoredTabs)},
364+ },
365+ mainWindow: mainWindow,
366+ widgetFactory: layout.NewGtkWidgetFactory(),
367+ tabs: entity.NewTabList(),
368+ browserWindows: map[string]*browserWindow{staleWindow.id: staleWindow},
369+ workspaceViews: map[entity.TabID]*component.WorkspaceView{staleTab.ID: &component.WorkspaceView{}},
370+ windowForTab: map[entity.TabID]*browserWindow{staleTab.ID: staleWindow},
371+ floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
372+ staleSessionKey: {},
373+ },
374+ }
375+ app.tabs.Add(staleTab)
376+ mainWindow.TabBar().AddTab(staleTab)
377+
378+ if err := app.restoreSession(context.Background(), restoredSessionID); err != nil {
379+ t.Fatalf("restoreSession returned error: %v", err)
380+ }
381+
382+ if got := app.tabs.Count(); got != 1 {
383+ t.Fatalf("tabs.Count() = %d, want 1", got)
384+ }
385+ if got := mainWindow.TabBar().Count(); got != 1 {
386+ t.Fatalf("tab bar count = %d, want 1", got)
387+ }
388+ if _, ok := app.workspaceViews[staleTab.ID]; ok {
389+ t.Fatalf("stale workspace view was not removed")
390+ }
391+ if _, ok := app.windowForTab[staleTab.ID]; ok {
392+ t.Fatalf("stale windowForTab entry was not removed")
393+ }
394+ if _, ok := app.floatingSessions[staleSessionKey]; ok {
395+ t.Fatalf("stale floating session was not removed")
396+ }
397+}
398+
399+func TestApp_RestoreSessionWiresStackedPaneTitleBarCallbacks(t *testing.T) {
400+ gtkApp := requireGTKDisplayApp(t)
401+ defer gtkApp.Unref()
402+
403+ mainWindow, err := window.New(context.Background(), gtkApp, "top")
404+ if err != nil {
405+ t.Fatalf("window creation failed: %v", err)
406+ }
407+ defer mainWindow.Destroy()
408+
409+ stackedSessionID := entity.SessionID("session-stacked-restore")
410+ stackedTabs := entity.NewTabList()
411+ stackedTabs.Add(entity.NewTab(entity.TabID("restored-tab"), entity.WorkspaceID("restored-workspace"), entity.NewPane(entity.PaneID("restored-pane-1"))))
412+ stackedTabs.Tabs[0].Workspace = &entity.Workspace{
413+ ID: entity.WorkspaceID("restored-workspace"),
414+ Root: &entity.PaneNode{
415+ ID: "restored-stack-root",
416+ IsStacked: true,
417+ Children: []*entity.PaneNode{
418+ {ID: "restored-child-1", Pane: entity.NewPane(entity.PaneID("restored-pane-1"))},
419+ {ID: "restored-child-2", Pane: entity.NewPane(entity.PaneID("restored-pane-2"))},
420+ },
421+ ActiveStackIndex: 0,
422+ },
423+ ActivePaneID: entity.PaneID("restored-pane-1"),
424+ }
425+
426+ app := &App{
427+ deps: &Dependencies{
428+ Config: &config.Config{},
429+ SessionStateRepo: &fakeSessionStateRepo{state: entity.SnapshotFromTabList(stackedSessionID, stackedTabs)},
430+ },
431+ mainWindow: mainWindow,
432+ widgetFactory: layout.NewGtkWidgetFactory(),
433+ contentCoord: &contentcoord.Coordinator{},
434+ wsCoord: coordinator.NewWorkspaceCoordinator(context.Background(), coordinator.WorkspaceCoordinatorConfig{ContentCoord: &contentcoord.Coordinator{}}),
435+ tabs: entity.NewTabList(),
436+ browserWindows: map[string]*browserWindow{},
437+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
438+ windowForTab: map[entity.TabID]*browserWindow{},
439+ }
440+
441+ if err := app.restoreSession(context.Background(), stackedSessionID); err != nil {
442+ t.Fatalf("restoreSession returned error: %v", err)
443+ }
444+
445+ restoredTab := app.tabs.ActiveTab()
446+ if restoredTab == nil {
447+ t.Fatal("restored active tab missing")
448+ }
449+ wv := app.workspaceViews[restoredTab.ID]
450+ if wv == nil {
451+ t.Fatal("restored workspace view missing")
452+ }
453+ tr := wv.TreeRenderer()
454+ if tr == nil {
455+ t.Fatal("restored tree renderer missing")
456+ }
457+ if restoredTab.Workspace == nil || restoredTab.Workspace.Root == nil || len(restoredTab.Workspace.Root.Children) == 0 {
458+ t.Fatal("restored stacked workspace missing children")
459+ }
460+ stackedView := tr.GetStackedViewForPane(string(restoredTab.Workspace.Root.Children[0].Pane.ID))
461+ if stackedView == nil {
462+ t.Fatal("restored stacked view missing")
463+ }
464+ if stackedViewOnActivateIsNil(t, stackedView) {
465+ t.Fatal("restored stacked view onActivate callback is nil")
466+ }
467+}
468+
469+func TestApp_RemoveBrowserWindowRebindsPromotedTabCoordinatorWindow(t *testing.T) {
470+ oldWindow := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
471+ newWindow := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
472+ tc := coordinator.NewTabCoordinator(context.Background(), coordinator.TabCoordinatorConfig{
473+ MainWindow: oldWindow.mainWindow,
474+ })
475+ app := &App{
476+ mainWindow: oldWindow.mainWindow,
477+ browserWindows: map[string]*browserWindow{oldWindow.id: oldWindow, newWindow.id: newWindow},
478+ tabCoord: tc,
479+ }
480+
481+ app.removeBrowserWindow(oldWindow.id)
482+
483+ if app.mainWindow != newWindow.mainWindow {
484+ t.Fatalf("mainWindow = %p, want %p", app.mainWindow, newWindow.mainWindow)
485+ }
486+ if got := tabCoordinatorMainWindowPtr(t, tc); got != reflect.ValueOf(newWindow.mainWindow).Pointer() {
487+ t.Fatalf("tab coordinator mainWindow = %x, want %x", got, reflect.ValueOf(newWindow.mainWindow).Pointer())
488+ }
489+}
490+
491+func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) {
492+ created := &browserWindow{id: "window-1"}
493+ originalWindow := &window.MainWindow{}
494+ tabBar := &component.TabBar{}
495+ setWindowTabBar(t, originalWindow, tabBar)
496+ existingTabID := entity.TabID("existing-tab")
497+ staleTabID := entity.TabID("stale-tab")
498+ tabBar.SetActive(staleTabID)
499+ app := &App{
500+ tabs: entity.NewTabList(),
501+ tabsUC: usecase.NewManageTabsUseCase(func() string { return "id-1" }),
502+ browserWindows: make(map[string]*browserWindow),
503+ browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
504+ return created, nil
505+ },
506+ mainWindow: originalWindow,
507+ }
508+ app.tabs.Add(entity.NewTab(existingTabID, entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane"))))
509+ app.tabCoord = coordinator.NewTabCoordinator(context.Background(), coordinator.TabCoordinatorConfig{
510+ TabsUC: app.tabsUC,
511+ Tabs: app.tabs,
512+ MainWindow: &window.MainWindow{},
513+ })
514+
515+ if err := app.OpenFreshWindow(context.Background(), "https://example.com/fail"); err == nil {
516+ t.Fatalf("OpenFreshWindow = nil error, want failure")
517+ }
518+ if got := len(app.browserWindows); got != 0 {
519+ t.Fatalf("browserWindows length = %d, want 0", got)
520+ }
521+ if got := windowForTabCount(t, app); got != 0 {
522+ t.Fatalf("windowForTab length = %d, want 0", got)
523+ }
524+ if got := windowTabBarActiveID(t, originalWindow); got != existingTabID {
525+ t.Fatalf("tab bar active tab = %q, want %q", got, existingTabID)
526+ }
527+}
528+
529+func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) {
530+ existingTabID := entity.TabID("existing-tab")
531+ createdTabID := entity.TabID("id-1")
532+ oldWindow := &window.MainWindow{}
533+ newWindow := &window.MainWindow{}
534+ setWindowTabBar(t, oldWindow, newTestTabBarShell(t))
535+ setWindowTabBar(t, newWindow, newTestTabBarShell(t, createdTabID))
536+ tabs := entity.NewTabList()
537+ tabsUC := usecase.NewManageTabsUseCase(func() string { return string(createdTabID) })
538+
539+ app := &App{
540+ tabs: tabs,
541+ tabsUC: tabsUC,
542+ browserWindows: make(map[string]*browserWindow),
543+ browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
544+ return &browserWindow{id: "window-1", mainWindow: newWindow}, nil
545+ },
546+ mainWindow: oldWindow,
547+ tabCoord: coordinator.NewTabCoordinator(context.Background(), coordinator.TabCoordinatorConfig{
548+ TabsUC: tabsUC,
549+ Tabs: tabs,
550+ MainWindow: oldWindow,
551+ HideTabBarWhenSingleTab: true,
552+ }),
553+ workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
554+ }
555+ app.tabs.Add(entity.NewTab(existingTabID, entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane"))))
556+ app.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
557+ app.workspaceViews[tab.ID] = &component.WorkspaceView{}
558+ })
559+
560+ if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
561+ t.Fatalf("OpenFreshWindow returned error: %v", err)
562+ }
563+
564+ gotCreatedTabID := app.tabs.ActiveTabID
565+ if gotCreatedTabID == "" {
566+ t.Fatalf("created tab id = %q, want non-empty", gotCreatedTabID)
567+ }
568+ if got := windowTabBarActiveID(t, newWindow); got != gotCreatedTabID {
569+ t.Fatalf("new window tab bar active tab = %q, want %q", got, gotCreatedTabID)
570+ }
571+ if got := windowTabBarActiveID(t, oldWindow); got != "" {
572+ t.Fatalf("old window tab bar active tab = %q, want empty", got)
573+ }
574+ if got := windowTabBarVisible(t, newWindow); got {
575+ t.Fatalf("new window tab bar visible = %v, want false", got)
576+ }
577+}
578+
579+func TestApp_ActivateBrowserWindowSwitchesActiveWorkspace(t *testing.T) {
580+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
581+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
582+ tabs := entity.NewTabList()
583+ tabs.Add(tab1)
584+ tabs.Add(tab2)
585+ ws1 := &component.WorkspaceView{}
586+ ws2 := &component.WorkspaceView{}
587+ first := &browserWindow{id: "window-1", activeTabID: tab1.ID, mainWindow: &window.MainWindow{}}
588+ second := &browserWindow{id: "window-2", activeTabID: tab2.ID, mainWindow: &window.MainWindow{}}
589+ kh := &input.KeyboardHandler{}
590+ gs := &input.GlobalShortcutHandler{}
591+ second.keyboardHandler = kh
592+ second.globalShortcutHandler = gs
593+ app := &App{
594+ tabs: tabs,
595+ mainWindow: first.mainWindow,
596+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
597+ windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
598+ workspaceViews: map[entity.TabID]*component.WorkspaceView{tab1.ID: ws1, tab2.ID: ws2},
599+ }
600+
601+ app.activateBrowserWindow(second)
602+
603+ if app.mainWindow != second.mainWindow {
604+ t.Fatalf("mainWindow = %p, want %p", app.mainWindow, second.mainWindow)
605+ }
606+ if app.tabs.ActiveTabID != tab2.ID {
607+ t.Fatalf("active tab = %q, want %q", app.tabs.ActiveTabID, tab2.ID)
608+ }
609+ if app.activeWorkspaceView() != ws2 {
610+ t.Fatalf("active workspace view = %p, want %p", app.activeWorkspaceView(), ws2)
611+ }
612+ if app.keyboardHandler != kh {
613+ t.Fatalf("keyboardHandler = %p, want %p", app.keyboardHandler, kh)
614+ }
615+ if app.globalShortcutHandler != gs {
616+ t.Fatalf("globalShortcutHandler = %p, want %p", app.globalShortcutHandler, gs)
617+ }
618+}
619+
620+func TestApp_BrowserWindowForPaneFindsOwningWindow(t *testing.T) {
621+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
622+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
623+ tabs := entity.NewTabList()
624+ tabs.Add(tab1)
625+ tabs.Add(tab2)
626+ first := &browserWindow{id: "window-1"}
627+ second := &browserWindow{id: "window-2"}
628+ app := &App{
629+ tabs: tabs,
630+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
631+ windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
632+ }
633+
634+ got := app.browserWindowForPane(entity.PaneID("pane-2"))
635+
636+ if got != second {
637+ t.Fatalf("browserWindowForPane = %p, want %p", got, second)
638+ }
639+}
640+
641+func TestApp_OwnerOrLastFocusedBrowserWindowPrefersPaneOwner(t *testing.T) {
642+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
643+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
644+ tabs := entity.NewTabList()
645+ tabs.Add(tab1)
646+ tabs.Add(tab2)
647+ first := &browserWindow{id: "window-1"}
648+ second := &browserWindow{id: "window-2"}
649+ app := &App{
650+ tabs: tabs,
651+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
652+ windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
653+ lastFocusedWindowID: first.id,
654+ }
655+
656+ got := app.ownerOrLastFocusedBrowserWindow("", entity.PaneID("pane-2"))
657+
658+ if got != second {
659+ t.Fatalf("ownerOrLastFocusedBrowserWindow = %p, want %p", got, second)
660+ }
661+}
662+
663+func TestApp_HandlePaneWindowTitleChangedTargetsOwningWindow(t *testing.T) {
664+ gtkApp := requireGTKDisplayApp(t)
665+ defer gtkApp.Unref()
666+
667+ firstMainWindow, err := window.New(context.Background(), gtkApp, "top")
668+ if err != nil {
669+ t.Fatalf("first window creation failed: %v", err)
670+ }
671+ defer firstMainWindow.Destroy()
672+ secondMainWindow, err := window.New(context.Background(), gtkApp, "top")
673+ if err != nil {
674+ t.Fatalf("second window creation failed: %v", err)
675+ }
676+ defer secondMainWindow.Destroy()
677+
678+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
679+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
680+ tabs := entity.NewTabList()
681+ tabs.Add(tab1)
682+ tabs.Add(tab2)
683+ first := &browserWindow{id: "window-1", mainWindow: firstMainWindow}
684+ second := &browserWindow{id: "window-2", mainWindow: secondMainWindow}
685+ app := &App{
686+ tabs: tabs,
687+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
688+ windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
689+ lastFocusedWindowID: first.id,
690+ }
691+
692+ app.handlePaneWindowTitleChanged(entity.PaneID("pane-2"), "Pane Two")
693+
694+ if got := windowTitle(t, firstMainWindow); got != "Dumber" {
695+ t.Fatalf("first window title = %q, want %q", got, "Dumber")
696+ }
697+ if got := windowTitle(t, secondMainWindow); got != "Pane Two - Dumber" {
698+ t.Fatalf("second window title = %q, want %q", got, "Pane Two - Dumber")
699+ }
700+}
701+
702+func TestApp_ActivateBrowserWindowResyncsTitleFromBackgroundPane(t *testing.T) {
703+ gtkApp := requireGTKDisplayApp(t)
704+ defer gtkApp.Unref()
705+
706+ firstMainWindow, err := window.New(context.Background(), gtkApp, "top")
707+ if err != nil {
708+ t.Fatalf("first window creation failed: %v", err)
709+ }
710+ defer firstMainWindow.Destroy()
711+ secondMainWindow, err := window.New(context.Background(), gtkApp, "top")
712+ if err != nil {
713+ t.Fatalf("second window creation failed: %v", err)
714+ }
715+ defer secondMainWindow.Destroy()
716+
717+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
718+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
719+ tabs := entity.NewTabList()
720+ tabs.Add(tab1)
721+ tabs.Add(tab2)
722+ first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, activeTabID: tab1.ID}
723+ second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, activeTabID: tab2.ID}
724+ contentCoord := &contentcoord.Coordinator{}
725+ contentTitles := reflect.ValueOf(contentCoord).Elem().FieldByName("paneTitles")
726+ reflect.NewAt(contentTitles.Type(), unsafe.Pointer(contentTitles.UnsafeAddr())).Elem().Set(reflect.ValueOf(map[entity.PaneID]string{tab2.Workspace.ActivePaneID: "Pane Two"}))
727+ app := &App{
728+ tabs: tabs,
729+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
730+ windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
731+ contentCoord: contentCoord,
732+ }
733+
734+ app.handlePaneWindowTitleChanged(tab2.Workspace.ActivePaneID, "Pane Two")
735+ secondMainWindow.SetTitle("Dumber")
736+
737+ app.activateBrowserWindow(second)
738+
739+ if got := windowTitle(t, secondMainWindow); got != "Pane Two - Dumber" {
740+ t.Fatalf("second window title = %q, want %q", got, "Pane Two - Dumber")
741+ }
742+ if got := windowTitle(t, firstMainWindow); got != "Dumber" {
743+ t.Fatalf("first window title = %q, want %q", got, "Dumber")
744+ }
745+}
746+
747+func TestApp_HandlePaneFullscreenChangedTargetsOwningWindow(t *testing.T) {
748+ gtkApp := requireGTKDisplayApp(t)
749+ defer gtkApp.Unref()
750+
751+ firstMainWindow, err := window.New(context.Background(), gtkApp, "top")
752+ if err != nil {
753+ t.Fatalf("first window creation failed: %v", err)
754+ }
755+ defer firstMainWindow.Destroy()
756+ secondMainWindow, err := window.New(context.Background(), gtkApp, "top")
757+ if err != nil {
758+ t.Fatalf("second window creation failed: %v", err)
759+ }
760+ defer secondMainWindow.Destroy()
761+
762+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
763+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
764+ tabs := entity.NewTabList()
765+ tabs.Add(tab1)
766+ tabs.Add(tab2)
767+ secondMainWindow.TabBar().AddTab(tab1)
768+ secondMainWindow.TabBar().AddTab(tab2)
769+ first := &browserWindow{id: "window-1", mainWindow: firstMainWindow}
770+ second := &browserWindow{id: "window-2", mainWindow: secondMainWindow}
771+ app := &App{
772+ tabs: tabs,
773+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
774+ windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
775+ lastFocusedWindowID: first.id,
776+ }
777+
778+ app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), true)
779+
780+ if windowTabBarVisible(t, firstMainWindow) != true {
781+ t.Fatalf("first window tab bar visibility changed unexpectedly")
782+ }
783+ if got := windowTabBarVisible(t, secondMainWindow); got {
784+ t.Fatalf("second window tab bar visible = %v, want false", got)
785+ }
786+
787+ app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), false)
788+
789+ if got := windowTabBarVisible(t, secondMainWindow); !got {
790+ t.Fatalf("second window tab bar visible = %v, want true", got)
791+ }
792+}
793+
794+func TestApp_HandlePaneFullscreenChangedIgnoresUnresolvedPane(t *testing.T) {
795+ gtkApp := requireGTKDisplayApp(t)
796+ defer gtkApp.Unref()
797+
798+ firstMainWindow, err := window.New(context.Background(), gtkApp, "top")
799+ if err != nil {
800+ t.Fatalf("first window creation failed: %v", err)
801+ }
802+ defer firstMainWindow.Destroy()
803+ secondMainWindow, err := window.New(context.Background(), gtkApp, "top")
804+ if err != nil {
805+ t.Fatalf("second window creation failed: %v", err)
806+ }
807+ defer secondMainWindow.Destroy()
808+ firstMainWindow.TabBar().SetVisible(true)
809+ secondMainWindow.TabBar().SetVisible(true)
810+ first := &browserWindow{id: "window-1", mainWindow: firstMainWindow}
811+ second := &browserWindow{id: "window-2", mainWindow: secondMainWindow}
812+ app := &App{
813+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
814+ lastFocusedWindowID: first.id,
815+ }
816+
817+ app.handlePaneFullscreenChanged(entity.PaneID("missing-pane"), true)
818+
819+ if got := windowTabBarVisible(t, firstMainWindow); !got {
820+ t.Fatalf("first window tab bar visible = %v, want true", got)
821+ }
822+ if got := windowTabBarVisible(t, secondMainWindow); !got {
823+ t.Fatalf("second window tab bar visible = %v, want true", got)
824+ }
825+}
826+
827+func TestApp_HandleAccentKeyPressDelegatesToOwningWindow(t *testing.T) {
828+ first := newTestAccentUseCase(t, true)
829+ second := newTestAccentUseCase(t, false)
830+ firstWindow := &browserWindow{id: "window-1", insertAccentUC: first}
831+ secondWindow := &browserWindow{id: "window-2", insertAccentUC: second}
832+ app := &App{
833+ browserWindows: map[string]*browserWindow{firstWindow.id: firstWindow, secondWindow.id: secondWindow},
834+ lastFocusedWindowID: secondWindow.id,
835+ }
836+
837+ if got := app.handleAccentKeyPress(context.Background(), uint('e'), 0); got {
838+ t.Fatalf("handleAccentKeyPress returned %v, want false from active shell handler", got)
839+ }
840+ if got := first.IsPickerVisible(); !got {
841+ t.Fatalf("first shell accent handler should remain visible")
842+ }
843+ if got := second.IsPickerVisible(); got {
844+ t.Fatalf("second shell accent handler should remain hidden")
845+ }
846+}
847+
848+func TestApp_MoveActivePaneToTabFromBrowserWindowAnchorsOwningWindow(t *testing.T) {
849+ tabs := entity.NewTabList()
850+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
851+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
852+ tabs.Add(tab1)
853+ tabs.Add(tab2)
854+ first := &browserWindow{id: "window-1", activeTabID: tab1.ID}
855+ second := &browserWindow{id: "window-2", activeTabID: tab2.ID}
856+ app := &App{
857+ tabs: tabs,
858+ movePaneToTabUC: usecase.NewMovePaneToTabUseCase(func() string { return "new-tab" }),
859+ deps: &Dependencies{Config: &config.Config{}},
860+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
861+ lastFocusedWindowID: second.id,
862+ }
863+
864+ if err := app.moveActivePaneToTabFromBrowserWindow(context.Background(), first, ""); err != nil {
865+ t.Fatalf("moveActivePaneToTabFromBrowserWindow returned error: %v", err)
866+ }
867+
868+ if tabs.Find(tab1.ID) != nil {
869+ t.Fatalf("tab-1 should have been moved from owning window")
870+ }
871+ if tabs.Find(tab2.ID) == nil {
872+ t.Fatalf("tab-2 should remain when owning window is preserved")
873+ }
874+}
875+
876+func TestApp_MoveActivePaneToTabFromBrowserWindowActivatesTargetOwnerForCrossWindowTab(t *testing.T) {
877+ tabs := entity.NewTabList()
878+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
879+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
880+ tab3 := entity.NewTab(entity.TabID("tab-3"), entity.WorkspaceID("workspace-3"), entity.NewPane(entity.PaneID("pane-3")))
881+ tabs.Add(tab1)
882+ tabs.Add(tab2)
883+ tabs.Add(tab3)
884+ tabs.SetActive(tab1.ID)
885+ first := &browserWindow{id: "window-1", activeTabID: tab1.ID}
886+ second := &browserWindow{id: "window-2", activeTabID: tab2.ID}
887+ app := &App{
888+ tabs: tabs,
889+ movePaneToTabUC: usecase.NewMovePaneToTabUseCase(func() string { return "generated" }),
890+ deps: &Dependencies{Config: &config.Config{}},
891+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
892+ windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second, tab3.ID: second},
893+ lastFocusedWindowID: first.id,
894+ }
895+
896+ if err := app.moveActivePaneToTabFromBrowserWindow(context.Background(), first, tab3.ID); err != nil {
897+ t.Fatalf("moveActivePaneToTabFromBrowserWindow returned error: %v", err)
898+ }
899+
900+ if tabs.Find(tab1.ID) != nil {
901+ t.Fatalf("tab-1 should be removed after its active pane moves away")
902+ }
903+ if tabs.Find(tab2.ID) == nil {
904+ t.Fatalf("tab-2 should remain in the target owner window")
905+ }
906+ if tabs.Find(tab3.ID) == nil {
907+ t.Fatalf("tab-3 should remain as the move target")
908+ }
909+ if app.tabs.ActiveTabID != tab3.ID {
910+ t.Fatalf("active tab = %q, want %q", app.tabs.ActiveTabID, tab3.ID)
911+ }
912+ if app.lastFocusedWindowID != second.id {
913+ t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, second.id)
914+ }
915+}
916+
917+func TestApp_InitKeyboardHandlerDoesNotReattachExistingWindowInput(t *testing.T) {
918+ gtkApp := requireGTKDisplayApp(t)
919+ defer gtkApp.Unref()
920+
921+ mainWindow, err := window.New(context.Background(), gtkApp, "top")
922+ if err != nil {
923+ t.Fatalf("window creation failed: %v", err)
924+ }
925+ defer mainWindow.Destroy()
926+
927+ sentinelKeyboardHandler := &input.KeyboardHandler{}
928+ sentinelShortcutHandler := &input.GlobalShortcutHandler{}
929+ bw := &browserWindow{
930+ id: "window-1",
931+ mainWindow: mainWindow,
932+ keyboardHandler: sentinelKeyboardHandler,
933+ globalShortcutHandler: sentinelShortcutHandler,
934+ }
935+ app := &App{
936+ deps: &Dependencies{Config: &config.Config{}},
937+ mainWindow: mainWindow,
938+ browserWindows: map[string]*browserWindow{bw.id: bw},
939+ kbDispatcher: dispatcher.NewKeyboardDispatcher(context.Background(), nil, nil, nil, nil, nil, "", func(context.Context) entity.PaneID { return "" }),
940+ }
941+
942+ app.initKeyboardHandler(context.Background())
943+
944+ if bw.keyboardHandler != sentinelKeyboardHandler {
945+ t.Fatalf("keyboardHandler was reattached")
946+ }
947+ if bw.globalShortcutHandler != sentinelShortcutHandler {
948+ t.Fatalf("globalShortcutHandler was reattached")
949+ }
950+}
951+
952+func newTestAccentUseCase(t *testing.T, pickerVisible bool) *usecase.InsertAccentUseCase {
953+ t.Helper()
954+ uc := usecase.NewInsertAccentUseCase(&testFocusedInputProvider{}, nil, nil)
955+ if uc == nil {
956+ t.Fatal("failed to create accent use case")
957+ }
958+ setAccentUseCaseBoolField(t, uc, "pickerVisible", pickerVisible)
959+ return uc
960+}
961+
962+func setAccentUseCaseBoolField(t *testing.T, uc *usecase.InsertAccentUseCase, field string, value bool) {
963+ t.Helper()
964+ rv := reflect.ValueOf(uc).Elem()
965+ fv := rv.FieldByName(field)
966+ if !fv.IsValid() {
967+ t.Fatalf("InsertAccentUseCase missing %s field", field)
968+ }
969+ reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().SetBool(value)
970+}
971+
972+type testFocusedInputProvider struct{}
973+
974+func (*testFocusedInputProvider) GetFocusedInput() port.TextInputTarget { return nil }
975+func (*testFocusedInputProvider) SetFocusedInput(port.TextInputTarget) {}
976+
977+func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(t *testing.T) {
978+ gtkApp := requireGTKDisplayApp(t)
979+ defer gtkApp.Unref()
980+
981+ mainWindow := &window.MainWindow{}
982+ tabBar := component.NewTabBar()
983+ if tabBar == nil {
984+ t.Fatal("tab bar creation failed")
985+ }
986+ tabBar.SetVisible(false)
987+ setWindowTabBar(t, mainWindow, tabBar)
988+ bw := &browserWindow{id: "window-1", mainWindow: mainWindow}
989+ app := &App{
990+ deps: &Dependencies{Config: &config.Config{}},
991+ }
992+ app.deps.Config.Workspace.HideTabBarWhenSingleTab = false
993+
994+ app.updateBrowserWindowTabBarVisibility(bw)
995+
996+ if got := windowTabBarVisible(t, mainWindow); !got {
997+ t.Fatalf("tab bar visible = %v, want true", got)
998+ }
999+}
1000+
1001+func TestApp_RemoveBrowserWindowPromotesDeterministicFallbackWithMainWindow(t *testing.T) {
1002+ for i := 0; i < 20; i++ {
1003+ removed := &browserWindow{id: "window-z", mainWindow: &window.MainWindow{}}
1004+ nilWindow := &browserWindow{id: "window-a"}
1005+ firstValid := &browserWindow{id: "window-b", mainWindow: &window.MainWindow{}}
1006+ secondValid := &browserWindow{id: "window-c", mainWindow: &window.MainWindow{}}
1007+ app := &App{
1008+ mainWindow: removed.mainWindow,
1009+ browserWindows: map[string]*browserWindow{
1010+ removed.id: removed,
1011+ nilWindow.id: nilWindow,
1012+ firstValid.id: firstValid,
1013+ secondValid.id: secondValid,
1014+ },
1015+ lastFocusedWindowID: removed.id,
1016+ }
1017+
1018+ app.removeBrowserWindow(removed.id)
1019+
1020+ if app.mainWindow != firstValid.mainWindow {
1021+ t.Fatalf("mainWindow = %p, want %p", app.mainWindow, firstValid.mainWindow)
1022+ }
1023+ if app.lastFocusedWindowID != firstValid.id {
1024+ t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, firstValid.id)
1025+ }
1026+ }
1027+}
1028+
1029+func TestApp_BrowserWindowActivationHookUpdatesLastFocusedWindowID(t *testing.T) {
1030+ first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
1031+ second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
1032+ app := &App{
1033+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1034+ lastFocusedWindowID: first.id,
1035+ }
1036+
1037+ app.handleBrowserWindowActivationChanged(second, true)
1038+
1039+ if app.lastFocusedWindowID != second.id {
1040+ t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, second.id)
1041+ }
1042+}
1043+
1044+func TestApp_ActivateBrowserWindowTracksLastFocusedWindow(t *testing.T) {
1045+ first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
1046+ second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
1047+ app := &App{
1048+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1049+ }
1050+
1051+ app.activateBrowserWindow(second)
1052+
1053+ if app.lastFocusedWindowID != second.id {
1054+ t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, second.id)
1055+ }
1056+}
1057+
1058+func TestApp_LastFocusedBrowserWindowFallsBackWhenOwnerMissing(t *testing.T) {
1059+ first := &browserWindow{id: "window-1"}
1060+ second := &browserWindow{id: "window-2"}
1061+ app := &App{
1062+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1063+ lastFocusedWindowID: second.id,
1064+ }
1065+
1066+ got := app.lastFocusedBrowserWindow()
1067+
1068+ if got != second {
1069+ t.Fatalf("lastFocusedBrowserWindow = %p, want %p", got, second)
1070+ }
1071+}
1072+
1073+func TestApp_RemoveBrowserWindowClearsLastFocusedFallback(t *testing.T) {
1074+ first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
1075+ second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
1076+ app := &App{
1077+ mainWindow: second.mainWindow,
1078+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1079+ lastFocusedWindowID: second.id,
1080+ }
1081+
1082+ app.removeBrowserWindow(second.id)
1083+
1084+ if app.lastFocusedWindowID != first.id {
1085+ t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, first.id)
1086+ }
1087+}
A internal/ui/browser_window.go +321 -0
@@ -0,0 +1,321 @@
1+package ui
2+
3+import (
4+ "context"
5+ "sort"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/bnema/dumber/internal/ui/component"
12+ "github.com/bnema/dumber/internal/ui/focus"
13+ "github.com/bnema/dumber/internal/ui/input"
14+ "github.com/bnema/dumber/internal/ui/layout"
15+ "github.com/bnema/dumber/internal/ui/window"
16+ "github.com/bnema/puregotk/v4/glib"
17+)
18+
19+type browserWindow struct {
20+ id string
21+ initialURL string
22+ activeTabID entity.TabID
23+ mainWindow *window.MainWindow
24+ appToaster *component.Toaster
25+ modeToaster *component.Toaster
26+ borderMgr *focus.BorderManager
27+ sessionManager *component.SessionManager
28+ tabPicker *component.TabPicker
29+ tabPickerWidget layout.Widget
30+ tabPickerPaneID entity.PaneID
31+ insertAccentUC *usecase.InsertAccentUseCase
32+ accentPicker *component.AccentPicker
33+ keyboardHandler *input.KeyboardHandler
34+ globalShortcutHandler *input.GlobalShortcutHandler
35+ permissionDialog port.PermissionDialogPresenter
36+ webrtcIndicator *component.WebRTCPermissionIndicator
37+}
38+
39+func (bw *browserWindow) clearShellState() {
40+ if bw == nil {
41+ return
42+ }
43+ bw.appToaster = nil
44+ bw.modeToaster = nil
45+ bw.borderMgr = nil
46+ bw.sessionManager = nil
47+ bw.tabPicker = nil
48+ bw.tabPickerWidget = nil
49+ bw.tabPickerPaneID = ""
50+ bw.insertAccentUC = nil
51+ bw.accentPicker = nil
52+ bw.keyboardHandler = nil
53+ bw.globalShortcutHandler = nil
54+ bw.permissionDialog = nil
55+ bw.webrtcIndicator = nil
56+}
57+
58+func (bw *browserWindow) initChrome(ctx context.Context, a *App) {
59+ if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
60+ return
61+ }
62+
63+ bw.initToasterOverlay(a)
64+ bw.initBorderOverlay(a)
65+ bw.initAccentPicker(ctx, a)
66+ bw.initSessionManager(ctx, a)
67+ bw.initTabPicker(ctx, a)
68+}
69+
70+func (bw *browserWindow) initToasterOverlay(a *App) {
71+ if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
72+ return
73+ }
74+
75+ bw.appToaster = component.NewToaster(a.widgetFactory)
76+ if bw.appToaster != nil {
77+ if widget := bw.appToaster.Widget(); widget != nil {
78+ if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
79+ bw.mainWindow.AddOverlay(gtkWidget)
80+ }
81+ }
82+ }
83+
84+ bw.modeToaster = component.NewToaster(a.widgetFactory)
85+ if bw.modeToaster != nil {
86+ if widget := bw.modeToaster.Widget(); widget != nil {
87+ if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
88+ bw.mainWindow.AddOverlay(gtkWidget)
89+ }
90+ }
91+ }
92+}
93+
94+func (bw *browserWindow) initBorderOverlay(a *App) {
95+ if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil {
96+ return
97+ }
98+
99+ bw.borderMgr = focus.NewBorderManager(a.widgetFactory)
100+ if bw.borderMgr == nil {
101+ return
102+ }
103+ if widget := bw.borderMgr.Widget(); widget != nil {
104+ if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
105+ bw.mainWindow.AddOverlay(gtkWidget)
106+ }
107+ }
108+}
109+
110+func (bw *browserWindow) initAccentPicker(ctx context.Context, a *App) {
111+ log := logging.FromContext(ctx)
112+ if bw == nil || a == nil || a.widgetFactory == nil || bw.mainWindow == nil || a.deps == nil {
113+ log.Debug().Msg("widget factory, deps, or main window not available, skipping accent picker")
114+ return
115+ }
116+
117+ bw.accentPicker = component.NewAccentPicker(a.widgetFactory)
118+ if bw.accentPicker == nil {
119+ log.Warn().Msg("failed to create accent picker")
120+ return
121+ }
122+ if widget := bw.accentPicker.Widget(); widget != nil {
123+ if gtkWidget := widget.GtkWidget(); gtkWidget != nil {
124+ bw.mainWindow.AddOverlay(gtkWidget)
125+ }
126+ }
127+
128+ a.accentFocusProvider = a.deps.AccentFocusProvider
129+ bw.insertAccentUC = usecase.NewInsertAccentUseCase(
130+ a.accentFocusProvider,
131+ bw.accentPicker,
132+ func(fn func()) {
133+ cb := glib.SourceFunc(func(_ uintptr) bool {
134+ fn()
135+ return false
136+ })
137+ glib.IdleAdd(&cb, 0)
138+ },
139+ )
140+
141+ log.Debug().Msg("accent picker initialized")
142+}
143+
144+func (bw *browserWindow) initSessionManager(ctx context.Context, a *App) {
145+ log := logging.FromContext(ctx)
146+ if bw == nil || a == nil || a.deps == nil || a.deps.Config == nil || bw.mainWindow == nil {
147+ log.Debug().Msg("deps or main window not available, skipping session manager")
148+ return
149+ }
150+
151+ var listSessionsUC *usecase.ListSessionsUseCase
152+ var deleteSessionUC *usecase.DeleteSessionUseCase
153+ if a.deps.SessionRepo != nil && a.deps.SessionStateRepo != nil {
154+ listSessionsUC = usecase.NewListSessionsUseCase(
155+ a.deps.SessionRepo,
156+ a.deps.SessionStateRepo,
157+ )
158+ deleteSessionUC = usecase.NewDeleteSessionUseCase(
159+ a.deps.SessionStateRepo,
160+ a.deps.SessionRepo,
161+ )
162+ }
163+
164+ bw.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{
165+ ListSessionsUC: listSessionsUC,
166+ DeleteSessionUC: deleteSessionUC,
167+ CurrentSession: a.deps.CurrentSessionID,
168+ UIScale: a.deps.Config.DefaultUIScale,
169+ OnClose: func() {
170+ log.Debug().Msg("session manager closed")
171+ },
172+ OnOpen: func(sessionID entity.SessionID) {
173+ log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested")
174+ if spawner := a.deps.SessionSpawner; spawner == nil {
175+ log.Warn().Str("session_id", string(sessionID)).Msg("session spawner not available")
176+ return
177+ } else if err := spawner.SpawnWithSession(sessionID); err != nil {
178+ log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session")
179+ }
180+ },
181+ OnToast: func(ctx context.Context, message string, level component.ToastLevel) {
182+ if bw.appToaster != nil {
183+ bw.appToaster.Show(ctx, message, level)
184+ }
185+ },
186+ })
187+
188+ if bw.sessionManager == nil {
189+ log.Warn().Msg("failed to create session manager")
190+ return
191+ }
192+ if widget := bw.sessionManager.Widget(); widget != nil {
193+ bw.mainWindow.AddOverlay(widget)
194+ }
195+ log.Debug().Msg("session manager initialized")
196+}
197+
198+func (bw *browserWindow) initTabPicker(ctx context.Context, a *App) {
199+ log := logging.FromContext(ctx)
200+ if bw == nil || a == nil || a.deps == nil || a.deps.Config == nil {
201+ log.Debug().Msg("deps/config not available, skipping tab picker")
202+ return
203+ }
204+
205+ bw.tabPicker = component.NewTabPicker(ctx, component.TabPickerConfig{
206+ UIScale: a.deps.Config.DefaultUIScale,
207+ OnClose: func() {
208+ log.Debug().Msg("tab picker closed")
209+ },
210+ OnSelect: func(item component.TabPickerItem) {
211+ cb := glib.SourceFunc(func(_ uintptr) bool {
212+ var targetID entity.TabID
213+ if !item.IsNew {
214+ targetID = item.TabID
215+ }
216+ if err := a.moveActivePaneToTabFromBrowserWindow(ctx, bw, targetID); err != nil {
217+ log.Warn().Err(err).Msg("move pane to tab failed")
218+ }
219+ return false
220+ })
221+ glib.IdleAdd(&cb, 0)
222+ },
223+ })
224+
225+ if bw.tabPicker == nil {
226+ log.Warn().Msg("failed to create tab picker")
227+ return
228+ }
229+
230+ log.Debug().Msg("tab picker initialized")
231+}
232+
233+func (a *App) registerBrowserWindow(bw *browserWindow) {
234+ if bw == nil {
235+ return
236+ }
237+ if a.browserWindows == nil {
238+ a.browserWindows = make(map[string]*browserWindow)
239+ }
240+ a.browserWindows[bw.id] = bw
241+}
242+
243+func (a *App) removeBrowserWindow(id string) {
244+ if id == "" || a.browserWindows == nil {
245+ return
246+ }
247+ removed := a.browserWindows[id]
248+ wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow
249+ ownedTabIDs := make([]entity.TabID, 0)
250+ for tabID, bw := range a.windowForTab {
251+ if bw != nil && bw.id == id {
252+ ownedTabIDs = append(ownedTabIDs, tabID)
253+ }
254+ }
255+ for _, tabID := range ownedTabIDs {
256+ a.releaseFloatingSessionsForTab(context.Background(), tabID)
257+ delete(a.workspaceViews, tabID)
258+ delete(a.windowForTab, tabID)
259+ if a.tabs != nil && a.tabs.Find(tabID) != nil {
260+ a.tabs.Remove(tabID)
261+ }
262+ }
263+ if removed != nil {
264+ removed.clearShellState()
265+ }
266+ delete(a.browserWindows, id)
267+ fallback := a.deterministicBrowserWindowFallback()
268+ if a.lastFocusedWindowID == id {
269+ if fallback != nil {
270+ a.lastFocusedWindowID = fallback.id
271+ } else {
272+ a.lastFocusedWindowID = ""
273+ }
274+ }
275+ if wasMainWindow {
276+ a.clearResizeModeBorder()
277+ if fallback != nil {
278+ a.activateBrowserWindow(fallback)
279+ return
280+ }
281+ a.lastFocusedWindowID = ""
282+ a.mainWindow = nil
283+ a.keyboardHandler = nil
284+ a.globalShortcutHandler = nil
285+ if a.tabCoord != nil {
286+ a.tabCoord.SetMainWindow(nil)
287+ }
288+ }
289+}
290+
291+func (a *App) deterministicBrowserWindowFallback() *browserWindow {
292+ if a.browserWindows == nil {
293+ return nil
294+ }
295+ ids := make([]string, 0, len(a.browserWindows))
296+ for id, bw := range a.browserWindows {
297+ if id == "" || bw == nil || bw.mainWindow == nil {
298+ continue
299+ }
300+ ids = append(ids, id)
301+ }
302+ sort.Strings(ids)
303+ if len(ids) == 0 {
304+ return nil
305+ }
306+ return a.browserWindows[ids[0]]
307+}
308+
309+func (a *App) OpenFreshWindow(ctx context.Context, url string) error {
310+ dispatch := a.dispatchOnMainThread
311+ if dispatch == nil {
312+ dispatch = func(fn func()) { fn() }
313+ }
314+
315+ var openErr error
316+ dispatch(func() {
317+ openErr = a.openFreshWindow(ctx, url)
318+ })
319+
320+ return openErr
321+}
A internal/ui/browser_window_test.go +311 -0
@@ -0,0 +1,311 @@
1+package ui
2+
3+import (
4+ "context"
5+ "errors"
6+ "reflect"
7+ "testing"
8+ "unsafe"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/dumber/internal/ui/component"
13+ "github.com/bnema/dumber/internal/ui/focus"
14+ "github.com/bnema/dumber/internal/ui/layout"
15+ "github.com/bnema/dumber/internal/ui/window"
16+ "github.com/bnema/puregotk/v4/gtk"
17+)
18+
19+func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) {
20+ mainWindow := &window.MainWindow{}
21+ removed := &browserWindow{id: "window-1", mainWindow: mainWindow}
22+ remaining := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
23+ app := &App{
24+ mainWindow: mainWindow,
25+ browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
26+ lastFocusedWindowID: remaining.id,
27+ }
28+
29+ setShellField(t, removed, "appToaster", &component.Toaster{})
30+ setShellField(t, removed, "modeToaster", &component.Toaster{})
31+ setShellField(t, removed, "borderMgr", &focus.BorderManager{})
32+ setShellField(t, removed, "sessionManager", &component.SessionManager{})
33+ setShellField(t, removed, "tabPicker", &component.TabPicker{})
34+ setShellField(t, removed, "tabPickerWidget", (*testLayoutWidget)(nil))
35+ setShellField(t, removed, "tabPickerPaneID", entity.PaneID("pane-1"))
36+ setShellField(t, removed, "insertAccentUC", newTestAccentUseCase(t, false))
37+ setShellField(t, removed, "accentPicker", &component.AccentPicker{})
38+ setShellField(t, removed, "permissionDialog", (*testPermissionDialogPresenter)(nil))
39+ setShellField(t, removed, "webrtcIndicator", &component.WebRTCPermissionIndicator{})
40+
41+ app.removeBrowserWindow(removed.id)
42+
43+ for _, name := range []string{
44+ "appToaster",
45+ "modeToaster",
46+ "borderMgr",
47+ "sessionManager",
48+ "tabPicker",
49+ "tabPickerWidget",
50+ "tabPickerPaneID",
51+ "insertAccentUC",
52+ "accentPicker",
53+ "keyboardHandler",
54+ "globalShortcutHandler",
55+ "permissionDialog",
56+ "webrtcIndicator",
57+ } {
58+ if !fieldIsZero(t, removed, name) {
59+ t.Fatalf("browserWindow.%s was not cleared", name)
60+ }
61+ }
62+}
63+
64+func TestBrowserWindow_RemoveBrowserWindowCleansOwnedTabState(t *testing.T) {
65+ ownedTab := entity.NewTab(entity.TabID("tab-owned"), entity.WorkspaceID("workspace-owned"), entity.NewPane(entity.PaneID("pane-owned")))
66+ otherTab := entity.NewTab(entity.TabID("tab-other"), entity.WorkspaceID("workspace-other"), entity.NewPane(entity.PaneID("pane-other")))
67+ removedMainWindow := &window.MainWindow{}
68+ remainingMainWindow := &window.MainWindow{}
69+ removed := &browserWindow{id: "window-1", mainWindow: removedMainWindow}
70+ remaining := &browserWindow{id: "window-2", mainWindow: remainingMainWindow}
71+ ownedSessionKey := floatingSessionKey{tabID: ownedTab.ID, sessionID: "profile:owned"}
72+ otherSessionKey := floatingSessionKey{tabID: otherTab.ID, sessionID: "profile:other"}
73+ app := &App{
74+ mainWindow: removedMainWindow,
75+ browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
76+ tabs: entity.NewTabList(),
77+ workspaceViews: map[entity.TabID]*component.WorkspaceView{ownedTab.ID: &component.WorkspaceView{}, otherTab.ID: &component.WorkspaceView{}},
78+ windowForTab: map[entity.TabID]*browserWindow{ownedTab.ID: removed, otherTab.ID: remaining},
79+ floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
80+ ownedSessionKey: {},
81+ otherSessionKey: {},
82+ },
83+ }
84+ app.tabs.Add(ownedTab)
85+ app.tabs.Add(otherTab)
86+
87+ app.removeBrowserWindow(removed.id)
88+
89+ if app.workspaceViews[ownedTab.ID] != nil {
90+ t.Fatalf("owned workspace view was not removed")
91+ }
92+ if app.windowForTab[ownedTab.ID] != nil {
93+ t.Fatalf("owned window mapping was not removed")
94+ }
95+ if app.tabs.Find(ownedTab.ID) != nil {
96+ t.Fatalf("owned tab was not removed")
97+ }
98+ if app.workspaceViews[otherTab.ID] == nil {
99+ t.Fatalf("other workspace view should remain")
100+ }
101+ if app.windowForTab[otherTab.ID] != remaining {
102+ t.Fatalf("other tab should remain mapped to remaining window")
103+ }
104+ if app.tabs.Find(otherTab.ID) == nil {
105+ t.Fatalf("other tab should remain")
106+ }
107+ if _, ok := app.floatingSessions[ownedSessionKey]; ok {
108+ t.Fatalf("owned floating session was not released")
109+ }
110+ if _, ok := app.floatingSessions[otherSessionKey]; !ok {
111+ t.Fatalf("other floating session should remain")
112+ }
113+}
114+
115+func TestBrowserWindow_RegisterAndRemoveTrackCollection(t *testing.T) {
116+ app := &App{browserWindows: make(map[string]*browserWindow)}
117+ first := &browserWindow{id: "window-1"}
118+ second := &browserWindow{id: "window-2"}
119+
120+ app.registerBrowserWindow(first)
121+ app.registerBrowserWindow(second)
122+
123+ if got := len(app.browserWindows); got != 2 {
124+ t.Fatalf("browserWindows length = %d, want 2", got)
125+ }
126+ if _, ok := app.browserWindows[first.id]; !ok {
127+ t.Fatalf("first browser window was not registered")
128+ }
129+ if _, ok := app.browserWindows[second.id]; !ok {
130+ t.Fatalf("second browser window was not registered")
131+ }
132+
133+ app.removeBrowserWindow(first.id)
134+
135+ if _, ok := app.browserWindows[first.id]; ok {
136+ t.Fatalf("first browser window was not removed")
137+ }
138+ if _, ok := app.browserWindows[second.id]; !ok {
139+ t.Fatalf("second browser window should remain registered")
140+ }
141+}
142+
143+func TestBrowserWindow_RemovePromotesRemainingMainWindow(t *testing.T) {
144+ mainWindow := &window.MainWindow{}
145+ otherWindow := &window.MainWindow{}
146+ first := &browserWindow{id: "window-1", mainWindow: mainWindow}
147+ second := &browserWindow{id: "window-2", mainWindow: otherWindow}
148+ app := &App{
149+ mainWindow: mainWindow,
150+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
151+ }
152+
153+ app.removeBrowserWindow(first.id)
154+
155+ if app.mainWindow != otherWindow {
156+ t.Fatalf("mainWindow = %p, want promoted window %p", app.mainWindow, otherWindow)
157+ }
158+ if len(app.browserWindows) != 1 {
159+ t.Fatalf("browserWindows length = %d, want 1", len(app.browserWindows))
160+ }
161+ if _, ok := app.browserWindows[second.id]; !ok {
162+ t.Fatalf("remaining browser window was not kept")
163+ }
164+}
165+
166+func TestBrowserWindow_RemoveLastClearsMainWindow(t *testing.T) {
167+ mainWindow := &window.MainWindow{}
168+ first := &browserWindow{id: "window-1", mainWindow: mainWindow}
169+ app := &App{
170+ mainWindow: mainWindow,
171+ browserWindows: map[string]*browserWindow{first.id: first},
172+ }
173+
174+ app.removeBrowserWindow(first.id)
175+
176+ if app.mainWindow != nil {
177+ t.Fatalf("mainWindow = %p, want nil", app.mainWindow)
178+ }
179+ if len(app.browserWindows) != 0 {
180+ t.Fatalf("browserWindows length = %d, want 0", len(app.browserWindows))
181+ }
182+}
183+
184+func TestBrowserWindow_RemovePromotedWindowClearsResizeModeBorderTarget(t *testing.T) {
185+ mainWindow := &window.MainWindow{}
186+ otherWindow := &window.MainWindow{}
187+ removed := &browserWindow{id: "window-1", mainWindow: mainWindow}
188+ remaining := &browserWindow{id: "window-2", mainWindow: otherWindow}
189+ resizeTarget := &testLayoutWidget{}
190+ app := &App{
191+ mainWindow: mainWindow,
192+ browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
193+ resizeModeBorderTarget: resizeTarget,
194+ }
195+
196+ app.removeBrowserWindow(removed.id)
197+
198+ if app.resizeModeBorderTarget != nil {
199+ t.Fatalf("resizeModeBorderTarget = %p, want nil", app.resizeModeBorderTarget)
200+ }
201+}
202+
203+func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) {
204+ app := &App{browserWindows: make(map[string]*browserWindow)}
205+
206+ dispatched := false
207+ createdURL := ""
208+ app.dispatchOnMainThread = func(fn func()) {
209+ dispatched = true
210+ fn()
211+ }
212+ app.browserWindowFactory = func(ctx context.Context, url string) (*browserWindow, error) {
213+ createdURL = url
214+ return &browserWindow{id: "window-1", initialURL: url}, nil
215+ }
216+
217+ if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
218+ t.Fatalf("OpenFreshWindow returned error: %v", err)
219+ }
220+ if !dispatched {
221+ t.Fatalf("OpenFreshWindow did not use the main-thread dispatcher")
222+ }
223+ if createdURL != "https://example.com" {
224+ t.Fatalf("browser window factory URL = %q, want %q", createdURL, "https://example.com")
225+ }
226+ if got := len(app.browserWindows); got != 1 {
227+ t.Fatalf("browserWindows length = %d, want 1", got)
228+ }
229+ if got := app.browserWindows["window-1"]; got == nil {
230+ t.Fatalf("browser window was not tracked")
231+ }
232+ if app.browserWindows["window-1"].initialURL != "https://example.com" {
233+ t.Fatalf("browser window initialURL = %q, want %q", app.browserWindows["window-1"].initialURL, "https://example.com")
234+ }
235+}
236+
237+func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) {
238+ app := &App{browserWindows: make(map[string]*browserWindow)}
239+ app.dispatchOnMainThread = func(fn func()) { fn() }
240+ wantErr := errors.New("factory error")
241+ app.browserWindowFactory = func(context.Context, string) (*browserWindow, error) {
242+ return nil, wantErr
243+ }
244+
245+ err := app.OpenFreshWindow(context.Background(), "https://example.com")
246+
247+ if !errors.Is(err, wantErr) {
248+ t.Fatalf("expected error %v, got %v", wantErr, err)
249+ }
250+ if got := len(app.browserWindows); got != 0 {
251+ t.Fatalf("browserWindows length = %d, want 0", got)
252+ }
253+}
254+
255+func setShellField(t *testing.T, bw *browserWindow, name string, value any) {
256+ t.Helper()
257+ rv := reflect.ValueOf(bw).Elem()
258+ field := rv.FieldByName(name)
259+ if !field.IsValid() {
260+ t.Fatalf("browserWindow missing field %s", name)
261+ }
262+ reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Set(reflect.ValueOf(value))
263+}
264+
265+func fieldIsZero(t *testing.T, bw *browserWindow, name string) bool {
266+ t.Helper()
267+ rv := reflect.ValueOf(bw).Elem()
268+ field := rv.FieldByName(name)
269+ if !field.IsValid() {
270+ t.Fatalf("browserWindow missing field %s", name)
271+ }
272+ return field.IsZero()
273+}
274+
275+type testLayoutWidget struct{}
276+
277+func (*testLayoutWidget) Show() {}
278+func (*testLayoutWidget) Hide() {}
279+func (*testLayoutWidget) SetVisible(bool) {}
280+func (*testLayoutWidget) IsVisible() bool { return false }
281+func (*testLayoutWidget) SetOpacity(float64) {}
282+func (*testLayoutWidget) GrabFocus() bool { return false }
283+func (*testLayoutWidget) HasFocus() bool { return false }
284+func (*testLayoutWidget) SetCanFocus(bool) {}
285+func (*testLayoutWidget) SetFocusable(bool) {}
286+func (*testLayoutWidget) SetFocusOnClick(bool) {}
287+func (*testLayoutWidget) SetCanTarget(bool) {}
288+func (*testLayoutWidget) SetHexpand(bool) {}
289+func (*testLayoutWidget) SetVexpand(bool) {}
290+func (*testLayoutWidget) GetHexpand() bool { return false }
291+func (*testLayoutWidget) GetVexpand() bool { return false }
292+func (*testLayoutWidget) SetHalign(gtk.Align) {}
293+func (*testLayoutWidget) SetValign(gtk.Align) {}
294+func (*testLayoutWidget) SetSizeRequest(int, int) {}
295+func (*testLayoutWidget) GetAllocatedWidth() int { return 0 }
296+func (*testLayoutWidget) GetAllocatedHeight() int { return 0 }
297+func (*testLayoutWidget) ComputePoint(layout.Widget) (float64, float64, bool) {
298+ return 0, 0, false
299+}
300+func (*testLayoutWidget) AddCssClass(string) {}
301+func (*testLayoutWidget) RemoveCssClass(string) {}
302+func (*testLayoutWidget) HasCssClass(string) bool { return false }
303+func (*testLayoutWidget) Unparent() {}
304+func (*testLayoutWidget) GetParent() layout.Widget { return nil }
305+func (*testLayoutWidget) GtkWidget() *gtk.Widget { return nil }
306+func (*testLayoutWidget) AddController(*gtk.EventController) {}
307+
308+type testPermissionDialogPresenter struct{}
309+
310+func (*testPermissionDialogPresenter) ShowPermissionDialog(context.Context, string, []entity.PermissionType, entity.PermissionMetadata, func(port.PermissionDialogResult)) {
311+}
M internal/ui/component/omnibox.go +18 -4
@@ -728,8 +728,9 @@ func (o *Omnibox) initList() error {
728728 o.listBox.AddCssClass("omnibox-listbox")
729729 o.listBox.SetSelectionMode(gtk.SelectionSingleValue)
730730
731- rowSelectedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) {
731+ rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
732732 o.restoreEntryToRealInput()
733+ row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
733734 if row == nil {
734735 o.mu.Lock()
735736 o.selectedIndex = -1
@@ -745,7 +746,8 @@ func (o *Omnibox) initList() error {
745746 o.listBox.ConnectRowSelected(&rowSelectedCb)
746747
747748 // Handle row activation (click or Enter) - navigate directly to the URL
748- rowActivatedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) {
749+ rowActivatedCb := func(_ gtk.ListBox, rowPtr uintptr) {
750+ row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
749751 if row == nil {
750752 return
751753 }
@@ -753,12 +755,17 @@ func (o *Omnibox) initList() error {
753755 o.mu.RLock()
754756 mode := o.viewMode
755757 bangMode := o.bangMode
758+ bangSuggestions := o.bangSuggestions
756759 suggestions := o.suggestions
757760 favorites := o.favorites
758761 o.mu.RUnlock()
759762
760763 // Don't navigate in bang mode - activating a bang should fill it in
761764 if bangMode {
765+ if bangText, ok := bangSuggestionTextAt(idx, bangSuggestions); ok && o.entry != nil {
766+ o.entry.SetText(bangText)
767+ o.entry.SetPosition(-1)
768+ }
762769 return
763770 }
764771
@@ -1456,6 +1463,13 @@ func selectedTargetURL(mode ViewMode, idx, maxVisible int, suggestions []Suggest
14561463 return resolveTargetURLForSelection(mode, idx, maxVisible, suggestions, favorites), true
14571464 }
14581465
1466+func bangSuggestionTextAt(idx int, bangSuggestions []BangSuggestion) (string, bool) {
1467+ if idx < 0 || idx >= len(bangSuggestions) {
1468+ return "", false
1469+ }
1470+ return "!" + bangSuggestions[idx].Key + " ", true
1471+}
1472+
14591473 func visibleResultCount(total, maxVisible int) int {
14601474 if total <= 0 {
14611475 return 0
@@ -2291,8 +2305,8 @@ func (o *Omnibox) navigateToSelected() {
22912305 }
22922306
22932307 // Otherwise, Enter autocompletes the selected bang.
2294- if idx >= 0 && idx < len(bangSuggestions) {
2295- o.entry.SetText("!" + bangSuggestions[idx].Key + " ")
2308+ if bangText, ok := bangSuggestionTextAt(idx, bangSuggestions); ok {
2309+ o.entry.SetText(bangText)
22962310 o.entry.SetPosition(-1)
22972311 return
22982312 }
M internal/ui/component/omnibox_navigation_state_test.go +11 -0
@@ -199,6 +199,17 @@ func TestSelectedTargetURL(t *testing.T) {
199199 }
200200 }
201201
202+func TestBangSuggestionTextAt(t *testing.T) {
203+ bangSuggestions := []BangSuggestion{{Key: "g"}, {Key: "gh"}}
204+
205+ if got, ok := bangSuggestionTextAt(1, bangSuggestions); !ok || got != "!gh " {
206+ t.Fatalf("bangSuggestionTextAt(1) = (%q, %v), want (%q, %v)", got, ok, "!gh ", true)
207+ }
208+ if got, ok := bangSuggestionTextAt(99, bangSuggestions); ok || got != "" {
209+ t.Fatalf("bangSuggestionTextAt(out of range) = (%q, %v), want (%q, %v)", got, ok, "", false)
210+ }
211+}
212+
202213 func TestVisibleGhostSuggestion(t *testing.T) {
203214 suggestions := []Suggestion{
204215 {URL: "https://github.com/bnema/dumber"},
M internal/ui/component/session_manager.go +2 -1
@@ -314,7 +314,8 @@ func (sm *SessionManager) initList() error {
314314 sm.listBox.SetSelectionMode(gtk.SelectionSingleValue)
315315
316316 // Connect row selection - map list row index to session index
317- rowSelectedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) {
317+ rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
318+ row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
318319 if row == nil {
319320 return
320321 }
M internal/ui/component/tab_button.go +3 -0
@@ -85,6 +85,9 @@ func (tb *TabButton) SetActive(active bool) {
8585 return
8686 }
8787 tb.isActive = active
88+ if tb.button == nil {
89+ return
90+ }
8891
8992 if active {
9093 tb.button.AddCssClass("tab-button-active")
M internal/ui/component/tab_picker.go +2 -1
@@ -232,7 +232,8 @@ func (tp *TabPicker) createList() error {
232232 tp.listBox.AddCssClass("tab-picker-list")
233233 tp.listBox.SetSelectionMode(gtk.SelectionSingleValue)
234234
235- rowSelectedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) {
235+ rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) {
236+ row := gtk.ListBoxRowNewFromInternalPtr(rowPtr)
236237 if row == nil {
237238 return
238239 }
M internal/ui/config_migration.go +2 -2
@@ -32,8 +32,8 @@ func (a *App) checkConfigMigration(ctx context.Context) {
3232 Msg("config migration available")
3333
3434 // Show toast notification if enabled
35- if a.deps.Config != nil && a.deps.Config.Update.NotifyOnNewSettings && a.appToaster != nil {
35+ if a.deps.Config != nil && a.deps.Config.Update.NotifyOnNewSettings {
3636 msg := fmt.Sprintf("Config has %d new settings. Run 'dumber config migrate'", len(result.MissingKeys))
37- a.appToaster.Show(ctx, msg, component.ToastInfo)
37+ a.showToastOnLastFocusedBrowserWindow(ctx, msg, component.ToastInfo)
3838 }
3939 }
M internal/ui/coordinator/content/callbacks.go +5 -4
@@ -118,7 +118,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P
118118 }
119119 },
120120 OnPermissionRequest: func(origin string, permTypes []string, metadata map[string]string, allow, deny func()) bool {
121- return c.handlePermissionRequest(ctx, origin, permTypes, metadata, allow, deny)
121+ return c.handlePermissionRequest(ctx, paneID, origin, permTypes, metadata, allow, deny)
122122 },
123123 }
124124
@@ -146,7 +146,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P
146146 }
147147 }
148148 if c.onFullscreenChanged != nil {
149- c.onFullscreenChanged(true)
149+ c.onFullscreenChanged(paneID, true)
150150 }
151151 return false // Allow fullscreen
152152 }
@@ -158,7 +158,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P
158158 }
159159 }
160160 if c.onFullscreenChanged != nil {
161- c.onFullscreenChanged(false)
161+ c.onFullscreenChanged(paneID, false)
162162 }
163163 return false // Allow leaving fullscreen
164164 }
@@ -189,6 +189,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P
189189 // It delegates to the permission use case which handles auto-allow, stored permissions, and dialogs.
190190 func (c *Coordinator) handlePermissionRequest(
191191 ctx context.Context,
192+ paneID entity.PaneID,
192193 origin string,
193194 permTypes []string,
194195 metadata map[string]string,
@@ -226,7 +227,7 @@ func (c *Coordinator) handlePermissionRequest(
226227 if c.onPermissionActivity == nil || len(trackedTypes) == 0 {
227228 return
228229 }
229- c.onPermissionActivity(origin, trackedTypes, state)
230+ c.onPermissionActivity(paneID, origin, trackedTypes, state)
230231 }
231232
232233 notifyActivity(PermissionActivityRequesting)
M internal/ui/coordinator/content/coordinator.go +8 -8
@@ -56,13 +56,13 @@ type Coordinator struct {
5656 onPaneURIUpdated func(paneID entity.PaneID, url string)
5757
5858 // Callback when active pane title changes (for window title updates)
59- onWindowTitleChanged func(title string)
59+ onWindowTitleChanged func(paneID entity.PaneID, title string)
6060
6161 // Callback when media permission activity changes (requesting/allowed/blocked).
62- onPermissionActivity func(origin string, permTypes []entity.PermissionType, state PermissionActivityState)
62+ onPermissionActivity func(paneID entity.PaneID, origin string, permTypes []entity.PermissionType, state PermissionActivityState)
6363
6464 // Callback when the active pane commits a navigation (new page loading).
65- onActiveNavigationCommitted func(uri string)
65+ onActiveNavigationCommitted func(paneID entity.PaneID, uri string)
6666
6767 // Callback when the WebView becomes visible (first real commit)
6868 onWebViewShown func(paneID entity.PaneID)
@@ -103,7 +103,7 @@ type Coordinator struct {
103103 idleInhibitor port.IdleInhibitor
104104
105105 // Callback when fullscreen state changes (for hiding/showing tab bar)
106- onFullscreenChanged func(entering bool)
106+ onFullscreenChanged func(paneID entity.PaneID, entering bool)
107107
108108 // Callback when WebView gains focus (for accent picker text input targeting)
109109 onWebViewFocused func(paneID entity.PaneID, wv port.WebView)
@@ -187,13 +187,13 @@ func (c *Coordinator) SetOnHistoryRecord(fn func(ctx context.Context, paneID ent
187187
188188 // SetOnPermissionActivity sets a callback for WebRTC permission activity changes.
189189 func (c *Coordinator) SetOnPermissionActivity(
190- fn func(origin string, permTypes []entity.PermissionType, state PermissionActivityState),
190+ fn func(paneID entity.PaneID, origin string, permTypes []entity.PermissionType, state PermissionActivityState),
191191 ) {
192192 c.onPermissionActivity = fn
193193 }
194194
195195 // SetOnActiveNavigationCommitted sets a callback fired when the active pane commits a navigation.
196-func (c *Coordinator) SetOnActiveNavigationCommitted(fn func(uri string)) {
196+func (c *Coordinator) SetOnActiveNavigationCommitted(fn func(paneID entity.PaneID, uri string)) {
197197 c.onActiveNavigationCommitted = fn
198198 }
199199
@@ -203,7 +203,7 @@ func (c *Coordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url stri
203203 }
204204
205205 // SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates).
206-func (c *Coordinator) SetOnWindowTitleChanged(fn func(title string)) {
206+func (c *Coordinator) SetOnWindowTitleChanged(fn func(paneID entity.PaneID, title string)) {
207207 c.onWindowTitleChanged = fn
208208 }
209209
@@ -223,7 +223,7 @@ func (c *Coordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) {
223223 }
224224
225225 // SetOnFullscreenChanged sets the callback for fullscreen state changes.
226-func (c *Coordinator) SetOnFullscreenChanged(fn func(entering bool)) {
226+func (c *Coordinator) SetOnFullscreenChanged(fn func(paneID entity.PaneID, entering bool)) {
227227 c.onFullscreenChanged = fn
228228 }
229229
M internal/ui/coordinator/content/navigation.go +2 -2
@@ -123,13 +123,13 @@ func (c *Coordinator) notifyActiveNavigation(paneID entity.PaneID, uri string) {
123123 c.activePaneOverrideMu.RUnlock()
124124 if override != "" {
125125 if override == paneID {
126- c.onActiveNavigationCommitted(uri)
126+ c.onActiveNavigationCommitted(paneID, uri)
127127 }
128128 return
129129 }
130130 ws, _ := c.getActiveWS()
131131 if ws != nil && ws.ActivePaneID == paneID {
132- c.onActiveNavigationCommitted(uri)
132+ c.onActiveNavigationCommitted(paneID, uri)
133133 }
134134 }
135135
M internal/ui/coordinator/content/title.go +19 -25
@@ -29,30 +29,22 @@ func (c *Coordinator) onTitleChanged(ctx context.Context, paneID entity.PaneID,
2929 c.paneTitles[paneID] = title
3030 c.titleMu.Unlock()
3131
32- // Update domain model and check if this is the active pane
33- isActivePaneTitle := false
34- if c.getActiveWS == nil {
35- return
36- }
37- ws, wsView := c.getActiveWS()
38- if ws != nil {
39- paneNode := ws.FindPane(paneID)
40- if paneNode != nil && paneNode.Pane != nil {
41- paneNode.Pane.Title = title
42- }
43- // Check if this pane is the active one
44- if ws.ActivePaneID == paneID {
45- isActivePaneTitle = true
32+ // Update domain model and stacked title if available.
33+ if c.getActiveWS != nil {
34+ ws, wsView := c.getActiveWS()
35+ if ws != nil {
36+ paneNode := ws.FindPane(paneID)
37+ if paneNode != nil && paneNode.Pane != nil {
38+ paneNode.Pane.Title = title
39+ }
4640 }
47- }
4841
49- // Update StackedView title bar if this pane is in a stack
50- if wsView != nil {
51- tr := wsView.TreeRenderer()
52- if tr != nil {
53- stackedView := tr.GetStackedViewForPane(string(paneID))
54- if stackedView != nil {
55- c.updateStackedPaneTitle(ctx, stackedView, paneID, title)
42+ if wsView != nil {
43+ tr := wsView.TreeRenderer()
44+ if tr != nil {
45+ if stackedView := tr.GetStackedViewForPane(string(paneID)); stackedView != nil {
46+ c.updateStackedPaneTitle(ctx, stackedView, paneID, title)
47+ }
5648 }
5749 }
5850 }
@@ -67,9 +59,11 @@ func (c *Coordinator) onTitleChanged(ctx context.Context, paneID entity.PaneID,
6759 }
6860 }
6961
70- // Notify window title update if this is the active pane
71- if isActivePaneTitle && c.onWindowTitleChanged != nil {
72- c.onWindowTitleChanged(title)
62+ // Notify window title updates only for the active pane.
63+ if c.onWindowTitleChanged != nil && c.getActiveWS != nil {
64+ if ws, _ := c.getActiveWS(); ws != nil && ws.ActivePaneID == paneID {
65+ c.onWindowTitleChanged(paneID, title)
66+ }
7367 }
7468
7569 log.Debug().
M internal/ui/coordinator/content/title_test.go +57 -0
@@ -8,8 +8,65 @@ import (
88 "github.com/stretchr/testify/assert"
99
1010 "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/ui/component"
1112 )
1213
14+func TestOnTitleChanged_DoesNotEmitWindowCallbackForBackgroundPane(t *testing.T) {
15+ t.Parallel()
16+
17+ pane1 := entity.PaneID("pane-1")
18+ pane2 := entity.PaneID("pane-2")
19+ root := &entity.PaneNode{ID: "root"}
20+ leaf1 := &entity.PaneNode{ID: string(pane1), Pane: entity.NewPane(pane1), Parent: root}
21+ leaf2 := &entity.PaneNode{ID: string(pane2), Pane: entity.NewPane(pane2), Parent: root}
22+ root.Children = []*entity.PaneNode{leaf1, leaf2}
23+
24+ ws := &entity.Workspace{Root: root, ActivePaneID: pane1}
25+ var gotPaneID entity.PaneID
26+ var gotTitle string
27+ c := &Coordinator{
28+ paneTitles: make(map[entity.PaneID]string),
29+ getActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { return ws, nil },
30+ onWindowTitleChanged: func(paneID entity.PaneID, title string) {
31+ gotPaneID = paneID
32+ gotTitle = title
33+ },
34+ }
35+
36+ c.onTitleChanged(context.Background(), pane2, "Pane Two")
37+
38+ assert.Empty(t, gotPaneID)
39+ assert.Empty(t, gotTitle)
40+ assert.Equal(t, "Pane Two", c.GetTitle(pane2))
41+}
42+
43+func TestOnTitleChanged_EmitsWindowCallbackForActivePane(t *testing.T) {
44+ t.Parallel()
45+
46+ pane := entity.PaneID("pane-1")
47+ root := &entity.PaneNode{ID: "root"}
48+ leaf := &entity.PaneNode{ID: string(pane), Pane: entity.NewPane(pane), Parent: root}
49+ root.Children = []*entity.PaneNode{leaf}
50+
51+ ws := &entity.Workspace{Root: root, ActivePaneID: pane}
52+ var gotPaneID entity.PaneID
53+ var gotTitle string
54+ c := &Coordinator{
55+ paneTitles: make(map[entity.PaneID]string),
56+ getActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { return ws, nil },
57+ onWindowTitleChanged: func(paneID entity.PaneID, title string) {
58+ gotPaneID = paneID
59+ gotTitle = title
60+ },
61+ }
62+
63+ c.onTitleChanged(context.Background(), pane, "Pane One")
64+
65+ assert.Equal(t, pane, gotPaneID)
66+ assert.Equal(t, "Pane One", gotTitle)
67+ assert.Equal(t, "Pane One", c.GetTitle(pane))
68+}
69+
1370 // TestGetTitle tests the GetTitle method for title state retrieval.
1471
1572 func TestGetTitle_ReturnsEmptyForUnknownPane(t *testing.T) {
M internal/ui/coordinator/tab.go +6 -1
@@ -69,6 +69,11 @@ func (c *TabCoordinator) SetOnStateChanged(fn func()) {
6969 c.onStateChanged = fn
7070 }
7171
72+// SetMainWindow updates the window targeted by tab UI operations.
73+func (c *TabCoordinator) SetMainWindow(mainWindow *window.MainWindow) {
74+ c.mainWindow = mainWindow
75+}
76+
7277 // notifyStateChanged triggers the state changed callback if set.
7378 func (c *TabCoordinator) notifyStateChanged() {
7479 if c.onStateChanged != nil {
@@ -352,7 +357,7 @@ func (c *TabCoordinator) UpdateBarVisibility(ctx context.Context) {
352357 return
353358 }
354359
355- tabCount := c.tabs.Count()
360+ tabCount := c.mainWindow.TabBar().Count()
356361 shouldShow := tabCount > 1
357362
358363 log.Debug().Int("tab_count", tabCount).Bool("should_show", shouldShow).Msg("setting tab bar visibility")
M internal/ui/coordinator/update.go +9 -5
@@ -17,7 +17,7 @@ import (
1717 type UpdateCoordinator struct {
1818 checkUC *usecase.CheckUpdateUseCase
1919 applyUC *usecase.ApplyUpdateUseCase
20- toaster *component.Toaster
20+ toastFn func(ctx context.Context, msg string, level component.ToastLevel)
2121 enableOnStartup bool
2222 autoDownload bool
2323 mu sync.RWMutex // Protects status and lastInfo
@@ -31,14 +31,14 @@ type UpdateCoordinator struct {
3131 func NewUpdateCoordinator(
3232 checkUC *usecase.CheckUpdateUseCase,
3333 applyUC *usecase.ApplyUpdateUseCase,
34- toaster *component.Toaster,
34+ showToast func(ctx context.Context, msg string, level component.ToastLevel),
3535 enableOnStartup bool,
3636 autoDownload bool,
3737 ) *UpdateCoordinator {
3838 return &UpdateCoordinator{
3939 checkUC: checkUC,
4040 applyUC: applyUC,
41- toaster: toaster,
41+ toastFn: showToast,
4242 enableOnStartup: enableOnStartup,
4343 autoDownload: autoDownload,
4444 status: entity.UpdateStatusUnknown,
@@ -104,7 +104,9 @@ func (c *UpdateCoordinator) showUpdateNotification(ctx context.Context, result *
104104 }
105105
106106 cb := glib.SourceFunc(func(_ uintptr) bool {
107- c.toaster.Show(ctx, msg, component.ToastInfo)
107+ if c.toastFn != nil {
108+ c.toastFn(ctx, msg, component.ToastInfo)
109+ }
108110 return false
109111 })
110112 glib.IdleAdd(&cb, 0)
@@ -145,7 +147,9 @@ func (c *UpdateCoordinator) downloadAsync(ctx context.Context, downloadURL strin
145147
146148 func (c *UpdateCoordinator) showToast(ctx context.Context, msg string, level component.ToastLevel) {
147149 cb := glib.SourceFunc(func(_ uintptr) bool {
148- c.toaster.Show(ctx, msg, level)
150+ if c.toastFn != nil {
151+ c.toastFn(ctx, msg, level)
152+ }
149153 return false
150154 })
151155 glib.IdleAdd(&cb, 0)
M internal/ui/deps.go +2 -0
@@ -104,6 +104,8 @@ type Dependencies struct {
104104 // LaunchBrowserURL opens a URL in a new dumber browser window.
105105 // Optional: if nil, standalone omnibox web navigations are logged only.
106106 LaunchBrowserURL func(uri string)
107+ // BrowserLaunchRelay listens for in-process browser launch requests.
108+ BrowserLaunchRelay port.BrowserLaunchRelay
107109 }
108110
109111 // Validate checks that all required dependencies are set.
M internal/ui/dispatcher/keyboard.go +18 -15
@@ -28,9 +28,10 @@ type KeyboardDispatcher struct {
2828 onFindNext func(ctx context.Context) error
2929 onFindPrev func(ctx context.Context) error
3030 onFindClose func(ctx context.Context) error
31- onSessionOpen func(ctx context.Context) error
32- onMovePaneToTab func(ctx context.Context) error
33- onMovePaneToNext func(ctx context.Context) error
31+ activePaneID func(ctx context.Context) entity.PaneID
32+ onSessionOpen func(ctx context.Context, paneID entity.PaneID) error
33+ onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error
34+ onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error
3435 onToggleFloating func(ctx context.Context) error
3536 onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error
3637 }
@@ -44,17 +45,19 @@ func NewKeyboardDispatcher(
4445 zoomUC *usecase.ManageZoomUseCase,
4546 copyURLUC *usecase.CopyURLUseCase,
4647 newPaneURL string,
48+ activePaneID func(context.Context) entity.PaneID,
4749 ) *KeyboardDispatcher {
4850 log := logging.FromContext(ctx)
4951 log.Debug().Msg("creating keyboard dispatcher")
5052
5153 dispatcher := &KeyboardDispatcher{
52- tabCoord: tabCoord,
53- wsCoord: wsCoord,
54- navCoord: navCoord,
55- zoomUC: zoomUC,
56- copyURLUC: copyURLUC,
57- newPaneURL: newPaneURL,
54+ tabCoord: tabCoord,
55+ wsCoord: wsCoord,
56+ navCoord: navCoord,
57+ zoomUC: zoomUC,
58+ copyURLUC: copyURLUC,
59+ newPaneURL: newPaneURL,
60+ activePaneID: activePaneID,
5861 }
5962 dispatcher.initActionHandlers()
6063 return dispatcher
@@ -86,15 +89,15 @@ func (d *KeyboardDispatcher) SetOnFindClose(fn func(ctx context.Context) error)
8689 }
8790
8891 // SetOnSessionOpen sets the callback for opening the session manager.
89-func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context) error) {
92+func (d *KeyboardDispatcher) SetOnSessionOpen(fn func(ctx context.Context, paneID entity.PaneID) error) {
9093 d.onSessionOpen = fn
9194 }
9295
93-func (d *KeyboardDispatcher) SetOnMovePaneToTab(fn func(ctx context.Context) error) {
96+func (d *KeyboardDispatcher) SetOnMovePaneToTab(fn func(ctx context.Context, paneID entity.PaneID) error) {
9497 d.onMovePaneToTab = fn
9598 }
9699
97-func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context) error) {
100+func (d *KeyboardDispatcher) SetOnMovePaneToNextTab(fn func(ctx context.Context, paneID entity.PaneID) error) {
98101 d.onMovePaneToNext = fn
99102 }
100103
@@ -289,7 +292,7 @@ func (d *KeyboardDispatcher) handleFindClose(ctx context.Context) error {
289292
290293 func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error {
291294 if d.onSessionOpen != nil {
292- return d.onSessionOpen(ctx)
295+ return d.onSessionOpen(ctx, d.activePaneID(ctx))
293296 }
294297 logging.FromContext(ctx).Debug().Msg("session open action (no handler)")
295298 return nil
@@ -297,7 +300,7 @@ func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error {
297300
298301 func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error {
299302 if d.onMovePaneToTab != nil {
300- return d.onMovePaneToTab(ctx)
303+ return d.onMovePaneToTab(ctx, d.activePaneID(ctx))
301304 }
302305 logging.FromContext(ctx).Debug().Msg("move pane to tab action (no handler)")
303306 return nil
@@ -305,7 +308,7 @@ func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error {
305308
306309 func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error {
307310 if d.onMovePaneToNext != nil {
308- return d.onMovePaneToNext(ctx)
311+ return d.onMovePaneToNext(ctx, d.activePaneID(ctx))
309312 }
310313 logging.FromContext(ctx).Debug().Msg("move pane to next tab action (no handler)")
311314 return nil
M internal/ui/dispatcher/keyboard_test.go +46 -3
@@ -35,7 +35,7 @@ func TestKeyboardDispatcher_AltNumberCreatesTabsUntilRequestedIndexExists(t *tes
3535 _, err := tabCoord.Create(ctx, "https://initial.example")
3636 require.NoError(t, err)
3737
38- d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "https://new.example")
38+ d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "https://new.example", func(context.Context) entity.PaneID { return "" })
3939
4040 err = d.Dispatch(ctx, input.ActionSwitchTabIndex3)
4141 require.NoError(t, err)
@@ -76,7 +76,7 @@ func TestKeyboardDispatcher_AltNumberSwitchesToExistingTabWithoutNewPaneURL(t *t
7676 _, err = tabCoord.Create(ctx, "https://second.example")
7777 require.NoError(t, err)
7878
79- d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "")
79+ d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID { return "" })
8080
8181 err = d.Dispatch(ctx, input.ActionSwitchTabIndex2)
8282 require.NoError(t, err)
@@ -106,10 +106,53 @@ func TestKeyboardDispatcher_AltNumberErrorsWhenMissingTabRequiresCreationWithout
106106 _, err := tabCoord.Create(ctx, "https://first.example")
107107 require.NoError(t, err)
108108
109- d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "")
109+ d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID { return "" })
110110
111111 err = d.Dispatch(ctx, input.ActionSwitchTabIndex2)
112112 require.Error(t, err)
113113 require.Len(t, tabs.Tabs, 1)
114114 assert.Equal(t, tabs.TabAt(0).ID, tabs.ActiveTabID)
115115 }
116+
117+func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) {
118+ ctx := context.Background()
119+ activePaneID := entity.PaneID("pane-1")
120+ d := NewKeyboardDispatcher(ctx, &coordinator.TabCoordinator{}, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID {
121+ return activePaneID
122+ })
123+
124+ tests := []struct {
125+ name string
126+ set func(func(context.Context, entity.PaneID) error)
127+ invoke func(context.Context) error
128+ }{
129+ {
130+ name: "session open",
131+ set: d.SetOnSessionOpen,
132+ invoke: d.handleSessionOpen,
133+ },
134+ {
135+ name: "move pane to tab",
136+ set: d.SetOnMovePaneToTab,
137+ invoke: d.handleMovePaneToTab,
138+ },
139+ {
140+ name: "move pane to next tab",
141+ set: d.SetOnMovePaneToNextTab,
142+ invoke: d.handleMovePaneToNextTab,
143+ },
144+ }
145+
146+ for _, tc := range tests {
147+ t.Run(tc.name, func(t *testing.T) {
148+ var gotPaneID entity.PaneID
149+ tc.set(func(_ context.Context, paneID entity.PaneID) error {
150+ gotPaneID = paneID
151+ return nil
152+ })
153+
154+ require.NoError(t, tc.invoke(ctx))
155+ assert.Equal(t, activePaneID, gotPaneID)
156+ })
157+ }
158+}
M internal/ui/standalone_runtime_test.go +1 -1
@@ -7,7 +7,7 @@ import (
77 urlutil "github.com/bnema/dumber/internal/domain/url"
88 )
99
10-func TestHandleStandaloneOmniboxNavigation_UsesBrowserLauncherForHTTP(t *testing.T) {
10+func TestHandleStandaloneOmniboxNavigation_UsesFreshBrowserWindowForHTTP(t *testing.T) {
1111 browserURL := ""
1212 externalURL := ""
1313 handleStandaloneOmniboxNavigation(&Dependencies{
M internal/ui/window/main_window.go +18 -0
@@ -6,6 +6,7 @@ import (
66
77 "github.com/bnema/dumber/internal/logging"
88 "github.com/bnema/dumber/internal/ui/component"
9+ "github.com/bnema/puregotk/v4/gobject"
910 "github.com/bnema/puregotk/v4/gtk"
1011 "github.com/rs/zerolog"
1112 )
@@ -118,11 +119,17 @@ func (mw *MainWindow) assembleLayout() {
118119
119120 // Show presents the window.
120121 func (mw *MainWindow) Show() {
122+ if mw == nil || mw.window == nil {
123+ return
124+ }
121125 mw.window.Present()
122126 }
123127
124128 // Close closes the window.
125129 func (mw *MainWindow) Close() {
130+ if mw == nil || mw.window == nil {
131+ return
132+ }
126133 mw.window.Close()
127134 }
128135
@@ -155,6 +162,17 @@ func (mw *MainWindow) Window() *gtk.ApplicationWindow {
155162 return mw.window
156163 }
157164
165+// ConnectActiveNotify wires activation state changes for the top-level window.
166+func (mw *MainWindow) ConnectActiveNotify(callback func(active bool)) uint {
167+ if mw == nil || mw.window == nil || callback == nil {
168+ return 0
169+ }
170+ cb := func(_ gobject.Object, _ *gobject.ParamSpec) {
171+ callback(mw.window.IsActive())
172+ }
173+ return mw.window.ConnectNotifyWithDetail("is-active", &cb)
174+}
175+
158176 // SetTitle sets the window title.
159177 func (mw *MainWindow) SetTitle(title string) {
160178 if mw.window == nil {

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