Merge pull request #219 from bnema/feature/fresh-browser-windows
feat: add fresh browser window relay and refresh purego stack
71 files · +4579 -617
M .mockery.yaml +2 -0
| @@ -74,6 +74,8 @@ packages: | |||
| 74 | 74 | RuntimeVersionProbe: {} | |
| 75 | 75 | ScriptRefresher: {} | |
| 76 | 76 | SnapshotService: {} | |
| 77 | + | BrowserWindowOpener: {} | |
| 78 | + | BrowserLaunchRelay: {} | |
| 77 | 79 | ImageDataResolver: {} | |
| 78 | 80 | ResolvedImageSaver: {} | |
| 79 | 81 | MenuActionDelegator: {} | |
M cmd/dumber/main.go +57 -10
| @@ -53,6 +53,9 @@ var initialURL string | |||
| 53 | 53 | // restoreSessionID holds the session ID to restore on startup. | |
| 54 | 54 | var restoreSessionID string | |
| 55 | 55 | ||
| 56 | + | // browserLaunchRelay is shared for early browse handoff and in-app browser launches. | |
| 57 | + | var browserLaunchRelay = desktop.NewBrowserLaunchRelay(xdg.New()) | |
| 58 | + | ||
| 56 | 59 | type launchMode string | |
| 57 | 60 | ||
| 58 | 61 | const ( | |
| @@ -86,6 +89,14 @@ func launchModeFromArgs(args []string) (launchMode, string) { | |||
| 86 | 89 | return launchModeCLI, "" | |
| 87 | 90 | } | |
| 88 | 91 | ||
| 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 | + | ||
| 89 | 100 | func main() { | |
| 90 | 101 | enableCrashForensics() | |
| 91 | 102 | ||
| @@ -103,6 +114,17 @@ func main() { | |||
| 103 | 114 | mode, browseURL := launchModeFromArgs(os.Args) | |
| 104 | 115 | // Run GUI mode for browse command | |
| 105 | 116 | 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 | + | ||
| 106 | 128 | initialURL = browseURL | |
| 107 | 129 | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | |
| 108 | 130 | os.Args = os.Args[:1] | |
| @@ -187,6 +209,13 @@ func runGUI() int { | |||
| 187 | 209 | log.Error().Err(err).Msg("failed to create application") | |
| 188 | 210 | return 1 | |
| 189 | 211 | } | |
| 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 | + | } | |
| 190 | 219 | timer.Mark("ui_deps") | |
| 191 | 220 | timer.Log(ctx) | |
| 192 | 221 | ||
| @@ -212,9 +241,15 @@ func runStandaloneOmnibox() int { | |||
| 212 | 241 | ||
| 213 | 242 | preInitializeAdwaitaForCEF(cfg, initResult, ui.EnsureAdwaitaInitialized) | |
| 214 | 243 | ||
| 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() | |
| 216 | 251 | 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") | |
| 218 | 253 | return 1 | |
| 219 | 254 | } | |
| 220 | 255 | if dbCleanup != nil { | |
| @@ -228,7 +263,7 @@ func runStandaloneOmnibox() int { | |||
| 228 | 263 | initResult.ThemeManager, | |
| 229 | 264 | initResult.ColorResolver, | |
| 230 | 265 | initResult.AdwaitaDetector, | |
| 231 | - | engine, | |
| 266 | + | nil, | |
| 232 | 267 | repos, | |
| 233 | 268 | useCases, | |
| 234 | 269 | nil, | |
| @@ -239,7 +274,7 @@ func runStandaloneOmnibox() int { | |||
| 239 | 274 | logging.FromContext(ctx).Error().Err(err).Msg("failed to initialize standalone omnibox UI dependencies") | |
| 240 | 275 | return 1 | |
| 241 | 276 | } | |
| 242 | - | runtimeCfg := ui.NewStandaloneOmniboxRuntime(ctx, uiDeps, engine.FaviconDatabase()) | |
| 277 | + | runtimeCfg := ui.NewStandaloneOmniboxRuntime(ctx, uiDeps, nil) | |
| 243 | 278 | ||
| 244 | 279 | return ui.RunStandaloneOmnibox(ctx, runtimeCfg) | |
| 245 | 280 | } | |
| @@ -419,12 +454,20 @@ func initStackAndRepos( | |||
| 419 | 454 | return nil, nil, nil, err | |
| 420 | 455 | } | |
| 421 | 456 | ||
| 422 | - | lazyDB, err := bootstrap.CreateLazyDatabase() | |
| 457 | + | repos, dbCleanup, err := initStandaloneOmniboxRepos() | |
| 423 | 458 | if err != nil { | |
| 424 | 459 | return nil, nil, nil, err | |
| 425 | 460 | } | |
| 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 | + | } | |
| 426 | 469 | dbCleanup := func() { _ = lazyDB.Close() } | |
| 427 | - | return engine, createLazyRepositories(lazyDB), dbCleanup, nil | |
| 470 | + | return createLazyRepositories(lazyDB), dbCleanup, nil | |
| 428 | 471 | } | |
| 429 | 472 | ||
| 430 | 473 | func configureDeferredInit( | |
| @@ -695,6 +738,7 @@ func buildUIDependencies( | |||
| 695 | 738 | } | |
| 696 | 739 | ||
| 697 | 740 | focusProvider := textinput.NewFocusProvider() | |
| 741 | + | browserLauncher := desktop.NewBrowserLauncher(browserLaunchRelay) | |
| 698 | 742 | ||
| 699 | 743 | uiDeps := &ui.Dependencies{ | |
| 700 | 744 | Ctx: ctx, | |
| @@ -738,7 +782,7 @@ func buildUIDependencies( | |||
| 738 | 782 | }, | |
| 739 | 783 | CheckUpdateUC: uc.checkUpdate, | |
| 740 | 784 | ApplyUpdateUC: uc.applyUpdate, | |
| 741 | - | SessionSpawner: desktop.NewSessionSpawner(ctx), | |
| 785 | + | SessionSpawner: bootstrap.NewSessionSpawner(ctx, cfg), | |
| 742 | 786 | FileSystem: filesystem.New(), | |
| 743 | 787 | AccentFocusProvider: focusProvider, | |
| 744 | 788 | NewGTKEntryTarget: func(entry *gtk.SearchEntry) port.TextInputTarget { | |
| @@ -759,9 +803,12 @@ func buildUIDependencies( | |||
| 759 | 803 | return config.GetManager().Watch() | |
| 760 | 804 | }, | |
| 761 | 805 | 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, | |
| 765 | 812 | } | |
| 766 | 813 | ||
| 767 | 814 | return uiDeps, nil | |
M cmd/dumber/main_test.go +45 -0
| @@ -1,9 +1,11 @@ | |||
| 1 | 1 | package main | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "errors" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port/mocks" | |
| 7 | 9 | "github.com/bnema/dumber/internal/bootstrap" | |
| 8 | 10 | "github.com/bnema/dumber/internal/infrastructure/colorscheme" | |
| 9 | 11 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| @@ -131,3 +133,46 @@ func TestPreInitializeAdwaitaForCEF_SkipsNonCEF(t *testing.T) { | |||
| 131 | 133 | t.Fatal("expected adwaita detector to remain unavailable") | |
| 132 | 134 | } | |
| 133 | 135 | } | |
| 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 | |||
| 3 | 3 | go 1.26.1 | |
| 4 | 4 | ||
| 5 | 5 | 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 | |
| 12 | 12 | github.com/charmbracelet/bubbles v1.0.0 | |
| 13 | 13 | github.com/charmbracelet/bubbletea v1.3.10 | |
| 14 | 14 | github.com/charmbracelet/lipgloss v1.1.0 | |
| 15 | - | github.com/ebitengine/purego v0.10.0 | |
| 16 | 15 | github.com/fsnotify/fsnotify v1.9.0 | |
| 17 | 16 | github.com/godbus/dbus/v5 v5.2.2 | |
| 18 | 17 | github.com/google/uuid v1.6.0 | |
| @@ -70,5 +69,3 @@ require ( | |||
| 70 | 69 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect | |
| 71 | 70 | modernc.org/sqlite v1.47.0 // indirect | |
| 72 | 71 | ) | |
| 73 | - | ||
| 74 | - | replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-20260413043705-7850e384cde9 | |
M go.sum +14 -16 generated file: collapsed
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 ( | |||
| 8 | 8 | "context" | |
| 9 | 9 | "database/sql" | |
| 10 | 10 | "encoding/json" | |
| 11 | + | "io" | |
| 11 | 12 | "time" | |
| 12 | 13 | ||
| 13 | 14 | "github.com/bnema/dumber/internal/application/port" | |
| @@ -172,6 +173,251 @@ func (_c *MockAccentPickerUI_Show_Call) RunAndReturn(run func(accents []rune, se | |||
| 172 | 173 | return _c | |
| 173 | 174 | } | |
| 174 | 175 | ||
| 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 | + | ||
| 175 | 421 | // 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. | |
| 176 | 422 | // The first argument is typically a *testing.T value. | |
| 177 | 423 | func NewMockClipboard(t interface { | |
| @@ -14689,6 +14935,59 @@ func (_c *MockXDGPaths_ManDir_Call) RunAndReturn(run func() (string, error)) *Mo | |||
| 14689 | 14935 | return _c | |
| 14690 | 14936 | } | |
| 14691 | 14937 | ||
| 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 | + | ||
| 14692 | 14991 | // StateDir provides a mock function for the type MockXDGPaths | |
| 14693 | 14992 | func (_mock *MockXDGPaths) StateDir() (string, error) { | |
| 14694 | 14993 | ret := _mock.Called() | |
M internal/application/port/session_state.go +12 -0
| @@ -21,6 +21,18 @@ type SessionSpawner interface { | |||
| 21 | 21 | SpawnWithSession(sessionID entity.SessionID) error | |
| 22 | 22 | } | |
| 23 | 23 | ||
| 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 | + | ||
| 24 | 36 | // SnapshotService manages debounced session-state snapshots. | |
| 25 | 37 | type SnapshotService interface { | |
| 26 | 38 | // Start begins the snapshot service with the given context. | |
M internal/application/port/xdg.go +1 -0
| @@ -5,6 +5,7 @@ type XDGPaths interface { | |||
| 5 | 5 | ConfigDir() (string, error) | |
| 6 | 6 | DataDir() (string, error) | |
| 7 | 7 | StateDir() (string, error) | |
| 8 | + | RuntimeDir() (string, error) | |
| 8 | 9 | CacheDir() (string, error) | |
| 9 | 10 | ||
| 10 | 11 | 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 ( | |||
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/dumber/internal/application/port" | |
| 12 | 12 | "github.com/bnema/dumber/internal/application/usecase" | |
| 13 | + | "github.com/bnema/dumber/internal/bootstrap" | |
| 13 | 14 | "github.com/bnema/dumber/internal/cli/styles" | |
| 14 | 15 | "github.com/bnema/dumber/internal/domain/build" | |
| 15 | 16 | "github.com/bnema/dumber/internal/domain/repository" | |
| @@ -37,6 +38,7 @@ type App struct { | |||
| 37 | 38 | ||
| 38 | 39 | // Services | |
| 39 | 40 | FaviconService *favicon.Service | |
| 41 | + | SessionSpawner port.SessionSpawner | |
| 40 | 42 | ||
| 41 | 43 | // Context with logger | |
| 42 | 44 | ctx context.Context | |
| @@ -131,6 +133,7 @@ func NewApp() (*App, error) { | |||
| 131 | 133 | RestoreUC: restoreUC, | |
| 132 | 134 | DeleteSessionUC: deleteSessionUC, | |
| 133 | 135 | FaviconService: faviconService, | |
| 136 | + | SessionSpawner: bootstrap.NewSessionSpawner(ctx, cfg), | |
| 134 | 137 | ctx: ctx, | |
| 135 | 138 | logCleanup: logCleanup, | |
| 136 | 139 | }, nil | |
M internal/cli/cmd/sessions.go +7 -3
| @@ -13,7 +13,6 @@ import ( | |||
| 13 | 13 | "github.com/bnema/dumber/internal/cli/model" | |
| 14 | 14 | "github.com/bnema/dumber/internal/cli/styles" | |
| 15 | 15 | "github.com/bnema/dumber/internal/domain/entity" | |
| 16 | - | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 17 | 16 | ) | |
| 18 | 17 | ||
| 19 | 18 | const defaultSessionsLimit = 20 | |
| @@ -60,6 +59,7 @@ func runSessions(_ *cobra.Command, _ []string) error { | |||
| 60 | 59 | ListSessionsUC: app.ListSessionsUC, | |
| 61 | 60 | RestoreUC: app.RestoreUC, | |
| 62 | 61 | DeleteSessionUC: app.DeleteSessionUC, | |
| 62 | + | SessionSpawner: app.SessionSpawner, | |
| 63 | 63 | CurrentSession: currentSessionID, | |
| 64 | 64 | MaxExitedSessions: app.Config.Session.MaxExitedSessions, | |
| 65 | 65 | }) | |
| @@ -181,8 +181,12 @@ func runSessionsRestore(_ *cobra.Command, args []string) error { | |||
| 181 | 181 | } | |
| 182 | 182 | ||
| 183 | 183 | // 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 { | |
| 186 | 190 | wrappedErr := fmt.Errorf("spawn browser: %w", err) | |
| 187 | 191 | fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr)) | |
| 188 | 192 | return wrapPrintedError(wrappedErr) | |
M internal/cli/model/sessions.go +8 -3
| @@ -11,10 +11,10 @@ import ( | |||
| 11 | 11 | tea "github.com/charmbracelet/bubbletea" | |
| 12 | 12 | "github.com/charmbracelet/lipgloss" | |
| 13 | 13 | ||
| 14 | + | "github.com/bnema/dumber/internal/application/port" | |
| 14 | 15 | "github.com/bnema/dumber/internal/application/usecase" | |
| 15 | 16 | "github.com/bnema/dumber/internal/cli/styles" | |
| 16 | 17 | "github.com/bnema/dumber/internal/domain/entity" | |
| 17 | - | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 18 | 18 | "github.com/bnema/dumber/internal/logging" | |
| 19 | 19 | ) | |
| 20 | 20 | ||
| @@ -43,6 +43,7 @@ type SessionsModel struct { | |||
| 43 | 43 | listSessionsUC *usecase.ListSessionsUseCase | |
| 44 | 44 | restoreUC *usecase.RestoreSessionUseCase | |
| 45 | 45 | deleteSessionUC *usecase.DeleteSessionUseCase | |
| 46 | + | sessionSpawner port.SessionSpawner | |
| 46 | 47 | theme *styles.Theme | |
| 47 | 48 | } | |
| 48 | 49 | ||
| @@ -114,6 +115,7 @@ type SessionsModelConfig struct { | |||
| 114 | 115 | ListSessionsUC *usecase.ListSessionsUseCase | |
| 115 | 116 | RestoreUC *usecase.RestoreSessionUseCase | |
| 116 | 117 | DeleteSessionUC *usecase.DeleteSessionUseCase | |
| 118 | + | SessionSpawner port.SessionSpawner | |
| 117 | 119 | CurrentSession entity.SessionID | |
| 118 | 120 | MaxExitedSessions int | |
| 119 | 121 | } | |
| @@ -137,6 +139,7 @@ func NewSessionsModel(ctx context.Context, theme *styles.Theme, cfg SessionsMode | |||
| 137 | 139 | listSessionsUC: cfg.ListSessionsUC, | |
| 138 | 140 | restoreUC: cfg.RestoreUC, | |
| 139 | 141 | deleteSessionUC: cfg.DeleteSessionUC, | |
| 142 | + | sessionSpawner: cfg.SessionSpawner, | |
| 140 | 143 | currentSession: cfg.CurrentSession, | |
| 141 | 144 | theme: theme, | |
| 142 | 145 | } | |
| @@ -336,8 +339,10 @@ func (m SessionsModel) restoreSession(sessionID entity.SessionID) tea.Cmd { | |||
| 336 | 339 | } | |
| 337 | 340 | ||
| 338 | 341 | // 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 { | |
| 341 | 346 | return sessionRestoredMsg{sessionID: sessionID, err: err} | |
| 342 | 347 | } | |
| 343 | 348 | ||
M internal/infrastructure/cef/app.go +89 -3
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "strings" | |
| 5 | + | ||
| 4 | 6 | purecef "github.com/bnema/purego-cef/cef" | |
| 5 | 7 | ||
| 6 | 8 | "github.com/bnema/dumber/internal/logging" | |
| @@ -125,9 +127,93 @@ func (h *dumberBPH) OnBeforeChildProcessLaunch(commandLine purecef.CommandLine) | |||
| 125 | 127 | } | |
| 126 | 128 | h.engine.recordChildProcessLaunch(processType, useAngle, ozonePlatform, commandLineString) | |
| 127 | 129 | } | |
| 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 } | |
| 131 | 217 | ||
| 132 | 218 | // OnScheduleMessagePumpWork is a no-op — multi-threaded message loop drives | |
| 133 | 219 | // its own pump. Required by the BrowserProcessHandler interface. | |
M internal/infrastructure/cef/app_test.go +102 -0
| @@ -2,6 +2,7 @@ package cef | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | + | "unsafe" | |
| 5 | 6 | ||
| 6 | 7 | purecef "github.com/bnema/purego-cef/cef" | |
| 7 | 8 | ) | |
| @@ -19,6 +20,32 @@ func (s *schemeRegistrarStub) AddCustomScheme(schemeName string, options int32) | |||
| 19 | 20 | return 1 | |
| 20 | 21 | } | |
| 21 | 22 | ||
| 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 | + | ||
| 22 | 49 | func TestRegisterDumbScheme(t *testing.T) { | |
| 23 | 50 | t.Parallel() | |
| 24 | 51 | ||
| @@ -41,3 +68,78 @@ func TestRegisterDumbScheme(t *testing.T) { | |||
| 41 | 68 | t.Fatalf("options = %d, want %d", stub.options, wantOptions) | |
| 42 | 69 | } | |
| 43 | 70 | } | |
| 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 ( | |||
| 14 | 14 | ||
| 15 | 15 | // Compile-time interface check. | |
| 16 | 16 | var _ port.Engine = (*Engine)(nil) | |
| 17 | + | var _ port.AlreadyRunningAppRelaunchHandlerSetter = (*Engine)(nil) | |
| 17 | 18 | ||
| 18 | 19 | // Engine implements port.Engine for the CEF browser backend. | |
| 19 | 20 | // It manages the CEF lifecycle and provides access to all engine subsystems. | |
| @@ -27,15 +28,17 @@ type Engine struct { | |||
| 27 | 28 | schemeHandler *dumbSchemeHandler | |
| 28 | 29 | contentInj *contentInjector | |
| 29 | 30 | ||
| 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) | |
| 39 | 42 | ||
| 40 | 43 | // activeWebViews tracks all live webviews for CSS broadcast. | |
| 41 | 44 | activeWebViews sync.Map // map[port.WebViewID]*WebView | |
| @@ -114,6 +117,20 @@ func (e *Engine) SetColorResolver(resolver port.ColorSchemeResolver) { | |||
| 114 | 117 | } | |
| 115 | 118 | } | |
| 116 | 119 | ||
| 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 | + | ||
| 117 | 134 | // registerWebView adds a webview to the active tracking map. | |
| 118 | 135 | func (e *Engine) registerWebView(wv *WebView) { | |
| 119 | 136 | e.activeWebViews.Store(wv.id, wv) | |
M internal/infrastructure/cef/engine_init.go +10 -0
| @@ -22,6 +22,7 @@ import ( | |||
| 22 | 22 | ) | |
| 23 | 23 | ||
| 24 | 24 | const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE" | |
| 25 | + | const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH" | |
| 25 | 26 | ||
| 26 | 27 | // NewEngine initializes the CEF runtime and returns a ready-to-use Engine. | |
| 27 | 28 | func NewEngine( | |
| @@ -89,6 +90,10 @@ func NewEngine( | |||
| 89 | 90 | } | |
| 90 | 91 | ||
| 91 | 92 | func resolvedStateRoot(opts port.EngineOptions) string { | |
| 93 | + | if root := os.Getenv(CEFRootCachePathEnvVar); root != "" { | |
| 94 | + | return root | |
| 95 | + | } | |
| 96 | + | ||
| 92 | 97 | switch { | |
| 93 | 98 | case opts.DataDir != "": | |
| 94 | 99 | return opts.DataDir | |
| @@ -159,6 +164,11 @@ func defaultCEFUserDataDir() string { | |||
| 159 | 164 | return filepath.Join(dirs.DataHome, "cef_user_data") | |
| 160 | 165 | } | |
| 161 | 166 | ||
| 167 | + | // DefaultCEFUserDataDir returns the default CEF state root. | |
| 168 | + | func DefaultCEFUserDataDir() string { | |
| 169 | + | return defaultCEFUserDataDir() | |
| 170 | + | } | |
| 171 | + | ||
| 162 | 172 | // initializeCEF calls cef_initialize with the App to register custom schemes | |
| 163 | 173 | // and browser-process callbacks (OnBeforeCommandLineProcessing, etc.). | |
| 164 | 174 | 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) { | |||
| 22 | 22 | require.Equal(t, "/tmp/cache", root) | |
| 23 | 23 | } | |
| 24 | 24 | ||
| 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 | + | ||
| 25 | 35 | func TestPrepareCEFSettings_UsesResolvedStateRoot(t *testing.T) { | |
| 26 | 36 | logger := zerolog.Nop() | |
| 27 | 37 | settings, err := prepareCEFSettings(port.EngineOptions{CacheDir: "/tmp/cache"}, RuntimeConfig{}, &logger) | |
M internal/infrastructure/cef/gl_loader.go +1 -1
| @@ -4,7 +4,7 @@ import ( | |||
| 4 | 4 | "fmt" | |
| 5 | 5 | "unsafe" | |
| 6 | 6 | ||
| 7 | - | "github.com/ebitengine/purego" | |
| 7 | + | "github.com/bnema/purego" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | // 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 | |||
| 163 | 163 | rp.glArea.SetHasDepthBuffer(false) | |
| 164 | 164 | rp.glArea.SetHasStencilBuffer(false) | |
| 165 | 165 | ||
| 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) | |
| 168 | 169 | return rp.onGLRender() | |
| 169 | 170 | } | |
| 170 | 171 | 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 { | |||
| 381 | 381 | ||
| 382 | 382 | // SessionSpawner implements port.SessionSpawner by launching a new dumber process. | |
| 383 | 383 | type SessionSpawner struct { | |
| 384 | - | ctx context.Context | |
| 384 | + | ctx context.Context | |
| 385 | + | spawnEnv port.SessionSpawnEnvironment | |
| 385 | 386 | } | |
| 386 | 387 | ||
| 387 | 388 | // 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} | |
| 390 | 391 | } | |
| 391 | 392 | ||
| 392 | 393 | // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration. | |
| @@ -406,7 +407,18 @@ func LaunchExternalURL(uri string) { | |||
| 406 | 407 | ||
| 407 | 408 | // LaunchBrowserURL opens a URL in a new dumber browser window. | |
| 408 | 409 | 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() | |
| 410 | 422 | if err != nil { | |
| 411 | 423 | fmt.Fprintf(os.Stderr, "failed to resolve dumber executable for URL %q: %v\n", uri, err) | |
| 412 | 424 | return | |
| @@ -414,7 +426,7 @@ func LaunchBrowserURL(uri string) { | |||
| 414 | 426 | ||
| 415 | 427 | cmd := exec.Command(execPath, "browse", uri) | |
| 416 | 428 | cmd.Env = sanitizedChildEnv(os.Environ()) | |
| 417 | - | if err := startDetachedProcess(cmd); err != nil { | |
| 429 | + | if err := spawnDetachedProcess(cmd); err != nil { | |
| 418 | 430 | fmt.Fprintf(os.Stderr, "failed to launch dumber browse for URL %q: %v\n", uri, err) | |
| 419 | 431 | } | |
| 420 | 432 | } | |
| @@ -469,6 +481,33 @@ func sanitizedChildEnv(env []string) []string { | |||
| 469 | 481 | return cleaned | |
| 470 | 482 | } | |
| 471 | 483 | ||
| 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 | + | ||
| 472 | 511 | // SpawnWithSession starts a new dumber instance to restore a session. | |
| 473 | 512 | func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error { | |
| 474 | 513 | log := logging.FromContext(s.ctx) | |
| @@ -481,8 +520,14 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error { | |||
| 481 | 520 | // Start dumber browse with session ID in environment | |
| 482 | 521 | cmd := exec.Command(execPath, "browse") | |
| 483 | 522 | ||
| 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 | |
| 486 | 531 | ||
| 487 | 532 | // Detach from current process group so the new process survives. | |
| 488 | 533 | cmd.Stdin = nil | |
M internal/infrastructure/desktop/adapter_test.go +120 -1
| @@ -1,6 +1,41 @@ | |||
| 1 | 1 | package desktop | |
| 2 | 2 | ||
| 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 | + | } | |
| 4 | 39 | ||
| 5 | 40 | func TestSanitizedChildEnv_RemovesLayerShellPreloadOnly(t *testing.T) { | |
| 6 | 41 | env := sanitizedChildEnv([]string{ | |
| @@ -32,3 +67,87 @@ func TestSanitizedChildEnv_PreservesColonSeparatedEntries(t *testing.T) { | |||
| 32 | 67 | t.Fatalf("expected unrelated colon-separated entries to remain, got %#v", env) | |
| 33 | 68 | } | |
| 34 | 69 | } | |
| 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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "time" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 7 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | // FilterStore abstracts the WebKit UserContentFilterStore operations. | |
M internal/infrastructure/filtering/manager.go +1 -1
| @@ -14,7 +14,7 @@ import ( | |||
| 14 | 14 | ||
| 15 | 15 | "github.com/bnema/dumber/internal/application/port" | |
| 16 | 16 | "github.com/bnema/dumber/internal/logging" | |
| 17 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 17 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 18 | 18 | ) | |
| 19 | 19 | ||
| 20 | 20 | // Compile-time check that Manager satisfies port.FilterManager. | |
M internal/infrastructure/filtering/manager_test.go +1 -1
| @@ -13,7 +13,7 @@ import ( | |||
| 13 | 13 | "github.com/bnema/dumber/internal/infrastructure/filtering" | |
| 14 | 14 | "github.com/bnema/dumber/internal/infrastructure/filtering/mocks" | |
| 15 | 15 | "github.com/bnema/dumber/internal/logging" | |
| 16 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 16 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 17 | 17 | "github.com/stretchr/testify/assert" | |
| 18 | 18 | "github.com/stretchr/testify/mock" | |
| 19 | 19 | "github.com/stretchr/testify/require" | |
M internal/infrastructure/filtering/mocks/mocks.go +1 -1
| @@ -9,7 +9,7 @@ import ( | |||
| 9 | 9 | "time" | |
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/dumber/internal/infrastructure/filtering" | |
| 12 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 12 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 13 | 13 | mock "github.com/stretchr/testify/mock" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
M internal/infrastructure/filtering/store.go +1 -1
| @@ -6,8 +6,8 @@ import ( | |||
| 6 | 6 | "sync" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/dumber/internal/logging" | |
| 9 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 10 | 9 | "github.com/bnema/puregotk/v4/gio" | |
| 10 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | // Store wraps webkit.UserContentFilterStore with Go-friendly async operations. | |
M internal/infrastructure/textinput/webview_target.go +1 -1
| @@ -6,7 +6,7 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 8 | "github.com/bnema/dumber/internal/logging" | |
| 9 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 9 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | // deleteBeforeCursorJS is the JavaScript template to delete n characters before cursor. | |
M internal/infrastructure/transcoder/pipeline.go +1 -1
| @@ -13,7 +13,7 @@ import ( | |||
| 13 | 13 | "net" | |
| 14 | 14 | "time" | |
| 15 | 15 | ||
| 16 | - | "github.com/ebitengine/purego" | |
| 16 | + | "github.com/bnema/purego" | |
| 17 | 17 | "github.com/rs/zerolog" | |
| 18 | 18 | ||
| 19 | 19 | "github.com/bnema/purego-ffmpeg/ffmpeg" | |
M internal/infrastructure/webkit/context.go +2 -2
| @@ -7,12 +7,12 @@ import ( | |||
| 7 | 7 | "sync" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 10 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 10 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 11 | 11 | "github.com/rs/zerolog" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | // 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. | |
| 16 | 16 | type FaviconDatabase = webkit.FaviconDatabase | |
| 17 | 17 | ||
| 18 | 18 | // WebKitContext manages the shared WebContext and persistent NetworkSession. | |
M internal/infrastructure/webkit/context_menu.go +1 -1
| @@ -17,8 +17,8 @@ import ( | |||
| 17 | 17 | "github.com/bnema/dumber/internal/application/port" | |
| 18 | 18 | "github.com/bnema/dumber/internal/domain/entity" | |
| 19 | 19 | "github.com/bnema/dumber/internal/logging" | |
| 20 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 21 | 20 | "github.com/bnema/puregotk/v4/gtk" | |
| 21 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 22 | 22 | ) | |
| 23 | 23 | ||
| 24 | 24 | // contextMenuResolver implements port.ImageDataResolver by fetching image bytes | |
M internal/infrastructure/webkit/download_handler.go +1 -1
| @@ -9,8 +9,8 @@ import ( | |||
| 9 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| 10 | 10 | downloadutil "github.com/bnema/dumber/internal/domain/download" | |
| 11 | 11 | "github.com/bnema/dumber/internal/logging" | |
| 12 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 13 | 12 | "github.com/bnema/puregotk/v4/glib" | |
| 13 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | const ( | |
M internal/infrastructure/webkit/gobject_type_helpers.go +1 -1
| @@ -5,9 +5,9 @@ import ( | |||
| 5 | 5 | "sync" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/logging" | |
| 8 | + | "github.com/bnema/purego" | |
| 8 | 9 | "github.com/bnema/puregotk/pkg/core" | |
| 9 | 10 | gtypes "github.com/bnema/puregotk/v4/gobject/types" | |
| 10 | - | "github.com/ebitengine/purego" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | var ( | |
M internal/infrastructure/webkit/injector.go +1 -1
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | 11 | "github.com/bnema/dumber/internal/infrastructure/webutil" | |
| 12 | 12 | "github.com/bnema/dumber/internal/logging" | |
| 13 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 13 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | const ( | |
M internal/infrastructure/webkit/injector_accent_test.go +1 -1
| @@ -3,7 +3,7 @@ package webkit | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 6 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 7 | 7 | "github.com/stretchr/testify/assert" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
M internal/infrastructure/webkit/memory_pressure.go +1 -1
| @@ -5,7 +5,7 @@ import ( | |||
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/dumber/internal/application/port" | |
| 7 | 7 | "github.com/bnema/dumber/internal/logging" | |
| 8 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 8 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 11 | // MemoryPressureApplier implements port.MemoryPressureApplier for WebKitGTK. | |
M internal/infrastructure/webkit/messaging.go +2 -2
| @@ -9,8 +9,8 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/dumber/internal/application/port" | |
| 11 | 11 | "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" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | // Message is the JS -> Go message envelope sent via postMessage. | |
M internal/infrastructure/webkit/pool.go +1 -1
| @@ -10,8 +10,8 @@ import ( | |||
| 10 | 10 | "time" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/dumber/internal/logging" | |
| 13 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 14 | 13 | "github.com/bnema/puregotk/v4/glib" | |
| 14 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | 17 | // 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 ( | |||
| 17 | 17 | "github.com/bnema/dumber/internal/infrastructure/env" | |
| 18 | 18 | "github.com/bnema/dumber/internal/infrastructure/webutil" | |
| 19 | 19 | "github.com/bnema/dumber/internal/logging" | |
| 20 | - | "github.com/bnema/puregotk-webkit/soup" | |
| 21 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 22 | 20 | "github.com/bnema/puregotk/v4/gio" | |
| 21 | + | "github.com/bnema/puregotk/v4/soup" | |
| 22 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 23 | 23 | "github.com/rs/zerolog" | |
| 24 | 24 | ) | |
| 25 | 25 | ||
M internal/infrastructure/webkit/settings.go +1 -1
| @@ -6,7 +6,7 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 8 | 8 | "github.com/bnema/dumber/internal/logging" | |
| 9 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 9 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 10 | 10 | "github.com/rs/zerolog" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
M internal/infrastructure/webkit/webview.go +1 -1
| @@ -15,13 +15,13 @@ import ( | |||
| 15 | 15 | urlutil "github.com/bnema/dumber/internal/domain/url" | |
| 16 | 16 | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 17 | 17 | "github.com/bnema/dumber/internal/logging" | |
| 18 | - | "github.com/bnema/puregotk-webkit/webkit" | |
| 19 | 18 | "github.com/bnema/puregotk/v4/gdk" | |
| 20 | 19 | "github.com/bnema/puregotk/v4/gio" | |
| 21 | 20 | "github.com/bnema/puregotk/v4/glib" | |
| 22 | 21 | "github.com/bnema/puregotk/v4/gobject" | |
| 23 | 22 | gtypes "github.com/bnema/puregotk/v4/gobject/types" | |
| 24 | 23 | "github.com/bnema/puregotk/v4/gtk" | |
| 24 | + | "github.com/bnema/puregotk/v4/webkit" | |
| 25 | 25 | "github.com/rs/zerolog" | |
| 26 | 26 | ) | |
| 27 | 27 | ||
M internal/infrastructure/webkit/webview_termination_test.go +1 -1
| @@ -3,7 +3,7 @@ package webkit | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | - | webkitlib "github.com/bnema/puregotk-webkit/webkit" | |
| 6 | + | webkitlib "github.com/bnema/puregotk/v4/webkit" | |
| 7 | 7 | "github.com/stretchr/testify/assert" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
M internal/infrastructure/xdg/adapter.go +12 -0
| @@ -28,6 +28,18 @@ func (a *Adapter) StateDir() (string, error) { | |||
| 28 | 28 | return config.GetStateDir() | |
| 29 | 29 | } | |
| 30 | 30 | ||
| 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 | + | ||
| 31 | 43 | func (a *Adapter) CacheDir() (string, error) { | |
| 32 | 44 | dirs, err := config.GetXDGDirs() | |
| 33 | 45 | if err != nil { | |
M internal/infrastructure/xdg/adapter_test.go +25 -22
| @@ -13,17 +13,7 @@ func TestAdapter_DownloadDir(t *testing.T) { | |||
| 13 | 13 | adapter := New() | |
| 14 | 14 | ||
| 15 | 15 | 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") | |
| 27 | 17 | ||
| 28 | 18 | dir, err := adapter.DownloadDir() | |
| 29 | 19 | ||
| @@ -32,17 +22,7 @@ func TestAdapter_DownloadDir(t *testing.T) { | |||
| 32 | 22 | }) | |
| 33 | 23 | ||
| 34 | 24 | 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", "") | |
| 46 | 26 | ||
| 47 | 27 | dir, err := adapter.DownloadDir() | |
| 48 | 28 | ||
| @@ -56,6 +36,29 @@ func TestAdapter_DownloadDir(t *testing.T) { | |||
| 56 | 36 | }) | |
| 57 | 37 | } | |
| 58 | 38 | ||
| 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 | + | ||
| 59 | 62 | func TestNew(t *testing.T) { | |
| 60 | 63 | adapter := New() | |
| 61 | 64 | ||
M internal/ui/app.go +796 -436
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | + | "io" | |
| 7 | 8 | "os" | |
| 8 | 9 | "path/filepath" | |
| 9 | 10 | "strings" | |
| @@ -65,6 +66,12 @@ type App struct { | |||
| 65 | 66 | gtkApp *gtk.Application | |
| 66 | 67 | mainWindow *window.MainWindow | |
| 67 | 68 | ||
| 69 | + | browserWindows map[string]*browserWindow | |
| 70 | + | lastFocusedWindowID string | |
| 71 | + | browserWindowFactory func(context.Context, string) (*browserWindow, error) | |
| 72 | + | tabCreationWindow *browserWindow | |
| 73 | + | dispatchOnMainThread func(func()) | |
| 74 | + | ||
| 68 | 75 | // State | |
| 69 | 76 | tabs *entity.TabList | |
| 70 | 77 | tabsUC *usecase.ManageTabsUseCase | |
| @@ -79,6 +86,7 @@ type App struct { | |||
| 79 | 86 | // Pane management (used by coordinators) | |
| 80 | 87 | panesUC *usecase.ManagePanesUseCase | |
| 81 | 88 | workspaceViews map[entity.TabID]*component.WorkspaceView | |
| 89 | + | windowForTab map[entity.TabID]*browserWindow | |
| 82 | 90 | widgetFactory layout.WidgetFactory | |
| 83 | 91 | stackedPaneMgr *component.StackedPaneManager | |
| 84 | 92 | ||
| @@ -86,9 +94,8 @@ type App struct { | |||
| 86 | 94 | keyboardHandler *input.KeyboardHandler | |
| 87 | 95 | globalShortcutHandler *input.GlobalShortcutHandler | |
| 88 | 96 | ||
| 89 | - | // Focus and border management | |
| 90 | - | focusMgr *focus.Manager | |
| 91 | - | borderMgr *focus.BorderManager | |
| 97 | + | // Focus tracking stays app-global. | |
| 98 | + | focusMgr *focus.Manager | |
| 92 | 99 | ||
| 93 | 100 | resizeModeBorderTarget layout.Widget | |
| 94 | 101 | ||
| @@ -106,18 +113,6 @@ type App struct { | |||
| 106 | 113 | // Web content (managed by content.Coordinator) | |
| 107 | 114 | faviconAdapter *adapter.FaviconAdapter | |
| 108 | 115 | ||
| 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 | |
| 121 | 116 | snapshotService port.SnapshotService | |
| 122 | 117 | ||
| 123 | 118 | // Update management | |
| @@ -126,13 +121,13 @@ type App struct { | |||
| 126 | 121 | // ID generator for tabs/panes | |
| 127 | 122 | idCounter uint64 | |
| 128 | 123 | idMu sync.Mutex | |
| 124 | + | windowIDCounter uint64 | |
| 125 | + | windowIDMu sync.Mutex | |
| 129 | 126 | firstWebViewShownOnce sync.Once | |
| 130 | 127 | ||
| 131 | 128 | movePaneToTabUC *usecase.MovePaneToTabUseCase | |
| 132 | 129 | ||
| 133 | 130 | // Accent picker for dead keys support | |
| 134 | - | accentPicker *component.AccentPicker | |
| 135 | - | insertAccentUC *usecase.InsertAccentUseCase | |
| 136 | 131 | accentFocusProvider port.FocusedInputProvider | |
| 137 | 132 | ||
| 138 | 133 | // Deferred initialization - runs after first load_started to avoid blocking initial navigation | |
| @@ -140,7 +135,9 @@ type App struct { | |||
| 140 | 135 | deferredInitFn func() | |
| 141 | 136 | ||
| 142 | 137 | // lifecycle | |
| 143 | - | cancel context.CancelCauseFunc | |
| 138 | + | cancel context.CancelCauseFunc | |
| 139 | + | browserLaunchRelayOnce sync.Once | |
| 140 | + | browserLaunchRelayCloser io.Closer | |
| 144 | 141 | } | |
| 145 | 142 | ||
| 146 | 143 | type floatingWorkspaceSession struct { | |
| @@ -172,14 +169,17 @@ func New(deps *Dependencies) (*App, error) { | |||
| 172 | 169 | ctx, cancel := context.WithCancelCause(deps.Ctx) | |
| 173 | 170 | ||
| 174 | 171 | 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, | |
| 183 | 183 | } | |
| 184 | 184 | // Register message handlers through the engine. | |
| 185 | 185 | if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{ | |
| @@ -194,12 +194,10 @@ func New(deps *Dependencies) (*App, error) { | |||
| 194 | 194 | }}, | |
| 195 | 195 | OnClipboardCopied: func(textLen int) { | |
| 196 | 196 | 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 | + | ) | |
| 203 | 201 | return false | |
| 204 | 202 | }) | |
| 205 | 203 | glib.IdleAdd(&cb, 0) | |
| @@ -244,6 +242,7 @@ func (a *App) Run(ctx context.Context, args []string) int { | |||
| 244 | 242 | } | |
| 245 | 243 | defer a.gtkApp.Unref() | |
| 246 | 244 | logging.Trace().Mark("gtk_app_created") | |
| 245 | + | a.dispatchOnMainThread = a.runOnMainThread | |
| 247 | 246 | ||
| 248 | 247 | // Connect activate signal | |
| 249 | 248 | activateCb := func(_ gio.Application) { | |
| @@ -272,18 +271,16 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 272 | 271 | // Configure pool background color early (prevents white flash), but avoid | |
| 273 | 272 | // synchronous prewarming that delays the first navigation on cold start. | |
| 274 | 273 | a.setupPoolBackgroundColor(ctx) | |
| 274 | + | a.initLayoutInfrastructure() | |
| 275 | 275 | ||
| 276 | - | if err := a.createMainWindow(ctx); err != nil { | |
| 276 | + | if err := a.OpenFreshWindow(ctx, a.initialWindowURL()); err != nil { | |
| 277 | 277 | log.Error().Err(err).Msg("failed to create main window") | |
| 278 | 278 | return | |
| 279 | 279 | } | |
| 280 | 280 | logging.Trace().Mark("window_created") | |
| 281 | 281 | ||
| 282 | - | a.initLayoutInfrastructure() | |
| 283 | - | a.initAppToasterOverlay() | |
| 284 | 282 | a.installCrashReportNotifier(ctx) | |
| 285 | - | a.initFocusAndBorderOverlay() | |
| 286 | - | a.initAccentPicker(ctx) | |
| 283 | + | a.initFocusManager() | |
| 287 | 284 | a.registerAccentHandlers(ctx) | |
| 288 | 285 | a.initDownloadHandler(ctx) | |
| 289 | 286 | ||
| @@ -293,8 +290,7 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 293 | 290 | a.initKeyboardHandler(ctx) | |
| 294 | 291 | a.initOmniboxConfig(ctx) | |
| 295 | 292 | a.initFindBarConfig(ctx) | |
| 296 | - | a.initSessionManager(ctx) | |
| 297 | - | a.initTabPicker(ctx) | |
| 293 | + | a.wireSessionManagerShortcut() | |
| 298 | 294 | a.initSnapshotService(ctx) | |
| 299 | 295 | a.initUpdateCoordinator(ctx) | |
| 300 | 296 | a.createInitialTab(ctx) | |
| @@ -380,19 +376,38 @@ func (a *App) prewarmWebViewPoolAsync(ctx context.Context) { | |||
| 380 | 376 | pool.PrewarmAsync(ctx, 0) | |
| 381 | 377 | } | |
| 382 | 378 | ||
| 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 | + | ||
| 384 | 390 | log := logging.FromContext(ctx) | |
| 385 | 391 | mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config.Workspace.TabBarPosition) | |
| 386 | 392 | 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 | |
| 388 | 403 | } | |
| 389 | - | a.mainWindow = mainWindow | |
| 390 | 404 | ||
| 391 | 405 | 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) | |
| 393 | 408 | return false | |
| 394 | 409 | } | |
| 395 | - | a.mainWindow.Window().ConnectCloseRequest(&closeRequestCb) | |
| 410 | + | mainWindow.Window().ConnectCloseRequest(&closeRequestCb) | |
| 396 | 411 | ||
| 397 | 412 | // Create permission popup and dialog presenter | |
| 398 | 413 | if a.deps != nil && a.deps.PermissionUC != nil { | |
| @@ -404,10 +419,9 @@ func (a *App) createMainWindow(ctx context.Context) error { | |||
| 404 | 419 | if permPopup != nil { | |
| 405 | 420 | // Add popup to the main window's content overlay | |
| 406 | 421 | if w := permPopup.Widget(); w != nil { | |
| 407 | - | a.mainWindow.AddOverlay(w) | |
| 422 | + | mainWindow.AddOverlay(w) | |
| 408 | 423 | } | |
| 409 | - | permDialog := dialog.NewPermissionDialog(permPopup) | |
| 410 | - | a.deps.PermissionUC.SetDialogPresenter(permDialog) | |
| 424 | + | browserWindow.permissionDialog = dialog.NewPermissionDialog(permPopup) | |
| 411 | 425 | } | |
| 412 | 426 | } | |
| 413 | 427 | ||
| @@ -415,155 +429,417 @@ func (a *App) createMainWindow(ctx context.Context) error { | |||
| 415 | 429 | indicator := component.NewWebRTCPermissionIndicator() | |
| 416 | 430 | if indicator != nil { | |
| 417 | 431 | if w := indicator.Widget(); w != nil { | |
| 418 | - | a.mainWindow.AddOverlay(w) | |
| 419 | - | a.webrtcIndicator = indicator | |
| 432 | + | mainWindow.AddOverlay(w) | |
| 420 | 433 | } | |
| 434 | + | browserWindow.webrtcIndicator = indicator | |
| 421 | 435 | } | |
| 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) | |
| 422 | 444 | ||
| 423 | 445 | // Apply GTK CSS styling from theme manager. | |
| 424 | 446 | if a.deps == nil || a.deps.Theme == nil { | |
| 425 | - | return nil | |
| 447 | + | return browserWindow, nil | |
| 426 | 448 | } | |
| 427 | - | if display := a.mainWindow.Window().GetDisplay(); display != nil { | |
| 449 | + | if display := mainWindow.Window().GetDisplay(); display != nil { | |
| 428 | 450 | a.deps.Theme.ApplyToDisplay(ctx, display) | |
| 429 | 451 | } | |
| 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 | + | } | |
| 430 | 470 | return nil | |
| 431 | 471 | } | |
| 432 | 472 | ||
| 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 | + | } | |
| 436 | 484 | ||
| 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 | |
| 439 | 498 | } | |
| 440 | 499 | ||
| 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 { | |
| 443 | 515 | return | |
| 444 | 516 | } | |
| 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 | + | } | |
| 445 | 546 | ||
| 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 { | |
| 450 | 550 | return | |
| 451 | 551 | } | |
| 452 | - | gtkWidget := toasterWidget.GtkWidget() | |
| 453 | - | if gtkWidget == nil { | |
| 552 | + | if entering { | |
| 553 | + | bw.mainWindow.TabBar().SetVisible(false) | |
| 454 | 554 | return | |
| 455 | 555 | } | |
| 456 | - | a.mainWindow.AddOverlay(gtkWidget) | |
| 556 | + | a.updateBrowserWindowTabBarVisibility(bw) | |
| 557 | + | } | |
| 457 | 558 | ||
| 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 { | |
| 462 | 561 | return | |
| 463 | 562 | } | |
| 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) | |
| 466 | 565 | return | |
| 467 | 566 | } | |
| 468 | - | a.mainWindow.AddOverlay(modeGtkWidget) | |
| 567 | + | shouldShow := bw.mainWindow.TabBar().Count() > 1 | |
| 568 | + | bw.mainWindow.TabBar().SetVisible(shouldShow) | |
| 469 | 569 | } | |
| 470 | 570 | ||
| 471 | - | func (a *App) installCrashReportNotifier(ctx context.Context) { | |
| 472 | - | if a.deps == nil { | |
| 571 | + | func (a *App) activateBrowserWindow(bw *browserWindow) { | |
| 572 | + | if bw == nil { | |
| 473 | 573 | return | |
| 474 | 574 | } | |
| 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) | |
| 478 | 586 | } | |
| 587 | + | a.updateWindowTitleFromActivePane(bw.activeTabID) | |
| 479 | 588 | } | |
| 480 | 589 | ||
| 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 { | |
| 490 | 592 | return | |
| 491 | 593 | } | |
| 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 { | |
| 494 | 599 | return | |
| 495 | 600 | } | |
| 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 { | |
| 498 | 608 | return | |
| 499 | 609 | } | |
| 500 | - | a.mainWindow.AddOverlay(gtkWidget) | |
| 610 | + | a.ensureWindowForTabMap() | |
| 611 | + | a.windowForTab[tabID] = bw | |
| 612 | + | if bw.activeTabID == "" { | |
| 613 | + | bw.activeTabID = tabID | |
| 614 | + | } | |
| 501 | 615 | } | |
| 502 | 616 | ||
| 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 | + | } | |
| 505 | 626 | ||
| 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 { | |
| 508 | 629 | return | |
| 509 | 630 | } | |
| 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 | + | } | |
| 510 | 640 | ||
| 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 { | |
| 515 | 643 | return | |
| 516 | 644 | } | |
| 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) | |
| 517 | 655 | ||
| 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() | |
| 524 | 693 | } | |
| 694 | + | return createErr | |
| 525 | 695 | } | |
| 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 | + | } | |
| 526 | 723 | ||
| 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 | + | } | |
| 529 | 774 | ||
| 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() | |
| 542 | 785 | ||
| 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 | + | }) | |
| 544 | 816 | } | |
| 545 | 817 | ||
| 546 | 818 | // 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. | |
| 548 | 820 | func (a *App) registerAccentHandlers(ctx context.Context) { | |
| 549 | 821 | log := logging.FromContext(ctx) | |
| 550 | 822 | ||
| 551 | - | if a.insertAccentUC == nil { | |
| 552 | - | log.Debug().Msg("insertAccentUC not available, skipping accent handler registration") | |
| 553 | - | return | |
| 554 | - | } | |
| 555 | 823 | if a.engine == nil { | |
| 556 | 824 | log.Debug().Msg("engine not available, skipping accent handler registration") | |
| 557 | 825 | return | |
| 558 | 826 | } | |
| 559 | 827 | ||
| 560 | - | if err := a.engine.RegisterAccentHandlers(ctx, a.insertAccentUC); err != nil { | |
| 828 | + | if err := a.engine.RegisterAccentHandlers(ctx, a); err != nil { | |
| 561 | 829 | log.Error().Err(err).Msg("failed to register accent handlers") | |
| 562 | 830 | } else { | |
| 563 | 831 | log.Info().Msg("registered accent key handlers") | |
| 564 | 832 | } | |
| 565 | 833 | } | |
| 566 | 834 | ||
| 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 | + | ||
| 567 | 843 | func (a *App) initDownloadHandler(ctx context.Context) { | |
| 568 | 844 | log := logging.FromContext(ctx) | |
| 569 | 845 | ||
| @@ -617,17 +893,13 @@ type downloadEventAdapter struct { | |||
| 617 | 893 | func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) { | |
| 618 | 894 | // Must schedule on GTK main thread. | |
| 619 | 895 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 620 | - | if d.app.appToaster == nil { | |
| 621 | - | return false | |
| 622 | - | } | |
| 623 | - | ||
| 624 | 896 | switch event.Type { | |
| 625 | 897 | 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) | |
| 627 | 899 | 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) | |
| 629 | 901 | 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) | |
| 631 | 903 | } | |
| 632 | 904 | return false | |
| 633 | 905 | }) | |
| @@ -647,57 +919,51 @@ func (a *autoCopyConfigFn) IsAutoCopyEnabled() bool { | |||
| 647 | 919 | } | |
| 648 | 920 | ||
| 649 | 921 | 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) { | |
| 650 | 930 | log := logging.FromContext(ctx) | |
| 651 | 931 | ||
| 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 { | |
| 653 | 936 | return | |
| 654 | 937 | } | |
| 655 | 938 | ||
| 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) | |
| 659 | 942 | if action == input.ActionClosePane { | |
| 660 | - | if a.closeAndReleaseActiveFloatingPane(ctx) { | |
| 943 | + | if a.closeAndReleaseActiveFloatingPane(actionCtx) { | |
| 661 | 944 | return nil | |
| 662 | 945 | } | |
| 663 | 946 | } | |
| 664 | - | return a.kbDispatcher.Dispatch(ctx, action) | |
| 947 | + | return a.kbDispatcher.Dispatch(actionCtx, action) | |
| 665 | 948 | }) | |
| 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) | |
| 668 | 952 | }) | |
| 669 | - | a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) { | |
| 953 | + | bw.keyboardHandler.SetOnModeChange(func(from, to input.Mode) { | |
| 954 | + | a.activateBrowserWindow(bw) | |
| 670 | 955 | a.handleModeChange(ctx, from, to) | |
| 671 | 956 | }) | |
| 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() { | |
| 682 | 959 | return input.RoutePassToWidget | |
| 683 | 960 | } | |
| 684 | - | if a.tabPicker != nil && a.tabPicker.IsVisible() { | |
| 961 | + | if bw.tabPicker != nil && bw.tabPicker.IsVisible() { | |
| 685 | 962 | return input.RoutePassToWidget | |
| 686 | 963 | } | |
| 687 | 964 | ||
| 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. | |
| 696 | 965 | if a.accentFocusProvider != nil { | |
| 697 | 966 | 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) | |
| 701 | 967 | if kc.Modifiers&input.ModAlt != 0 { | |
| 702 | 968 | return input.RouteHandleShortcuts | |
| 703 | 969 | } | |
| @@ -705,8 +971,6 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||
| 705 | 971 | } | |
| 706 | 972 | } | |
| 707 | 973 | ||
| 708 | - | // WebView focused: text/dead keys pass through for native IM compose, | |
| 709 | - | // shortcut-modified keys (Ctrl+, Alt+) are handled by shortcut system | |
| 710 | 974 | if input.IsShortcutModified(kc.Modifiers) { | |
| 711 | 975 | return input.RouteHandleShortcuts | |
| 712 | 976 | } | |
| @@ -714,37 +978,48 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||
| 714 | 978 | return input.RoutePassToWidget | |
| 715 | 979 | } | |
| 716 | 980 | ||
| 717 | - | // Non-text, non-shortcut keys (F-keys, arrows, etc.): shortcut system | |
| 718 | 981 | return input.RouteHandleShortcuts | |
| 719 | 982 | }) | |
| 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()) | |
| 725 | 985 | ||
| 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( | |
| 730 | 987 | ctx, | |
| 731 | - | a.mainWindow.Window(), | |
| 988 | + | bw.mainWindow.Window(), | |
| 732 | 989 | &a.deps.Config.Workspace, | |
| 733 | 990 | &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) | |
| 737 | 995 | }, | |
| 738 | 996 | ) | |
| 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 | |
| 741 | 1007 | } | |
| 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 | + | }) | |
| 742 | 1016 | } | |
| 743 | 1017 | ||
| 744 | 1018 | // handleAccentKeyPress handles accent key press events for GTK entry widgets | |
| 745 | 1019 | // (omnibox and find bar). Returns true if the key was consumed. | |
| 746 | 1020 | 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 { | |
| 748 | 1023 | return false | |
| 749 | 1024 | } | |
| 750 | 1025 | if state&(gdk.ControlMaskValue|gdk.AltMaskValue) != 0 { | |
| @@ -752,7 +1027,7 @@ func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.M | |||
| 752 | 1027 | } | |
| 753 | 1028 | shiftHeld := state&gdk.ShiftMaskValue != 0 | |
| 754 | 1029 | if char := input.KeyvalToRune(keyval); char != 0 { | |
| 755 | - | return a.insertAccentUC.OnKeyPressed(ctx, char, shiftHeld) | |
| 1030 | + | return handler.OnKeyPressed(ctx, char, shiftHeld) | |
| 756 | 1031 | } | |
| 757 | 1032 | return false | |
| 758 | 1033 | } | |
| @@ -760,12 +1035,45 @@ func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.M | |||
| 760 | 1035 | // handleAccentKeyRelease handles accent key release events for GTK entry widgets | |
| 761 | 1036 | // (omnibox and find bar). | |
| 762 | 1037 | func (a *App) handleAccentKeyRelease(ctx context.Context, keyval uint) { | |
| 763 | - | if a.insertAccentUC == nil { | |
| 1038 | + | handler := a.activeAccentHandler() | |
| 1039 | + | if handler == nil { | |
| 764 | 1040 | return | |
| 765 | 1041 | } | |
| 766 | 1042 | 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 | |
| 768 | 1059 | } | |
| 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) | |
| 769 | 1077 | } | |
| 770 | 1078 | ||
| 771 | 1079 | type omniboxCallbacks struct { | |
| @@ -931,89 +1239,18 @@ func (a *App) initFindBarConfig(ctx context.Context) { | |||
| 931 | 1239 | } | |
| 932 | 1240 | } | |
| 933 | 1241 | ||
| 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 | - | ||
| 1007 | 1242 | // ToggleSessionManager shows or hides the session manager. | |
| 1008 | 1243 | func (a *App) ToggleSessionManager(ctx context.Context) { | |
| 1009 | - | if a.sessionManager == nil { | |
| 1244 | + | bw := a.lastFocusedBrowserWindow() | |
| 1245 | + | if bw == nil || bw.sessionManager == nil { | |
| 1010 | 1246 | return | |
| 1011 | 1247 | } | |
| 1012 | - | a.sessionManager.Toggle(ctx) | |
| 1248 | + | bw.sessionManager.Toggle(ctx) | |
| 1013 | 1249 | } | |
| 1014 | 1250 | ||
| 1015 | 1251 | 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 { | |
| 1017 | 1254 | return | |
| 1018 | 1255 | } | |
| 1019 | 1256 | wsView := a.activeWorkspaceView() | |
| @@ -1034,24 +1271,24 @@ func (a *App) attachTabPickerToActivePane() { | |||
| 1034 | 1271 | return | |
| 1035 | 1272 | } | |
| 1036 | 1273 | ||
| 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 { | |
| 1040 | 1277 | return | |
| 1041 | 1278 | } | |
| 1042 | 1279 | } | |
| 1043 | 1280 | ||
| 1044 | 1281 | // If currently attached to a different pane overlay, detach. | |
| 1045 | - | if a.tabPickerPaneID != "" && a.tabPickerPaneID != activePaneID { | |
| 1282 | + | if bw.tabPickerPaneID != "" && bw.tabPickerPaneID != activePaneID { | |
| 1046 | 1283 | for _, view := range a.workspaceViews { | |
| 1047 | 1284 | if view == nil { | |
| 1048 | 1285 | continue | |
| 1049 | 1286 | } | |
| 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) | |
| 1053 | 1290 | } else if parent != nil { | |
| 1054 | - | a.tabPickerWidget.Unparent() | |
| 1291 | + | bw.tabPickerWidget.Unparent() | |
| 1055 | 1292 | } | |
| 1056 | 1293 | break | |
| 1057 | 1294 | } | |
| @@ -1059,20 +1296,21 @@ func (a *App) attachTabPickerToActivePane() { | |||
| 1059 | 1296 | } | |
| 1060 | 1297 | ||
| 1061 | 1298 | // 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() | |
| 1064 | 1301 | } | |
| 1065 | 1302 | ||
| 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 | |
| 1069 | 1306 | } | |
| 1070 | 1307 | ||
| 1071 | 1308 | func (a *App) HandleMovePaneToTab(ctx context.Context) error { | |
| 1072 | 1309 | if a.movePaneToTabUC == nil { | |
| 1073 | 1310 | return nil | |
| 1074 | 1311 | } | |
| 1075 | - | if a.tabPicker == nil { | |
| 1312 | + | bw := a.lastFocusedBrowserWindow() | |
| 1313 | + | if bw == nil || bw.tabPicker == nil { | |
| 1076 | 1314 | return nil | |
| 1077 | 1315 | } | |
| 1078 | 1316 | ||
| @@ -1104,7 +1342,7 @@ func (a *App) HandleMovePaneToTab(ctx context.Context) error { | |||
| 1104 | 1342 | items = append(items, component.TabPickerItem{IsNew: true, Index: -1}) | |
| 1105 | 1343 | ||
| 1106 | 1344 | a.attachTabPickerToActivePane() | |
| 1107 | - | a.tabPicker.Show(ctx, items) | |
| 1345 | + | bw.tabPicker.Show(ctx, items) | |
| 1108 | 1346 | return nil | |
| 1109 | 1347 | } | |
| 1110 | 1348 | ||
| @@ -1135,6 +1373,17 @@ func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID) | |||
| 1135 | 1373 | return nil | |
| 1136 | 1374 | } | |
| 1137 | 1375 | ||
| 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 | + | ||
| 1138 | 1387 | out, err := a.movePaneToTabUC.Execute(*in) | |
| 1139 | 1388 | if err != nil { | |
| 1140 | 1389 | return err | |
| @@ -1142,14 +1391,24 @@ func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID) | |||
| 1142 | 1391 | if out == nil || out.TargetTab == nil { | |
| 1143 | 1392 | return nil | |
| 1144 | 1393 | } | |
| 1394 | + | if out.NewTabCreated && targetWindow != nil { | |
| 1395 | + | a.setBrowserWindowForTab(out.TargetTab.ID, targetWindow) | |
| 1396 | + | } | |
| 1145 | 1397 | ||
| 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) | |
| 1148 | 1400 | a.updateTabBarVisibility(ctx) | |
| 1149 | 1401 | a.MarkDirty() | |
| 1150 | 1402 | return nil | |
| 1151 | 1403 | } | |
| 1152 | 1404 | ||
| 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 | + | ||
| 1153 | 1412 | func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePaneToTabInput, *entity.Tab) { | |
| 1154 | 1413 | if a.movePaneToTabUC == nil { | |
| 1155 | 1414 | return nil, nil | |
| @@ -1172,7 +1431,13 @@ func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePa | |||
| 1172 | 1431 | return in, activeTab | |
| 1173 | 1432 | } | |
| 1174 | 1433 | ||
| 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 | + | ) { | |
| 1176 | 1441 | if out == nil || out.TargetTab == nil { | |
| 1177 | 1442 | return | |
| 1178 | 1443 | } | |
| @@ -1182,10 +1447,10 @@ func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToT | |||
| 1182 | 1447 | } | |
| 1183 | 1448 | ||
| 1184 | 1449 | if out.SourceTabClosed { | |
| 1185 | - | a.removeSourceTabUI(sourceTabID) | |
| 1450 | + | a.removeSourceTabUI(sourceTabID, sourceWindow) | |
| 1186 | 1451 | } | |
| 1187 | 1452 | if out.NewTabCreated { | |
| 1188 | - | a.ensureTargetTabUI(ctx, out.TargetTab) | |
| 1453 | + | a.ensureTargetTabUI(ctx, out.TargetTab, targetWindow) | |
| 1189 | 1454 | } | |
| 1190 | 1455 | ||
| 1191 | 1456 | if !out.SourceTabClosed { | |
| @@ -1194,20 +1459,20 @@ func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToT | |||
| 1194 | 1459 | a.rebuildAndAttachWorkspace(ctx, out.TargetTab.ID, out.TargetTab) | |
| 1195 | 1460 | } | |
| 1196 | 1461 | ||
| 1197 | - | func (a *App) removeSourceTabUI(sourceTabID entity.TabID) { | |
| 1462 | + | func (a *App) removeSourceTabUI(sourceTabID entity.TabID, sourceWindow *browserWindow) { | |
| 1198 | 1463 | a.releaseFloatingSessionsForTab(context.Background(), sourceTabID) | |
| 1199 | 1464 | 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) | |
| 1202 | 1467 | } | |
| 1203 | 1468 | } | |
| 1204 | 1469 | ||
| 1205 | - | func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab) { | |
| 1470 | + | func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab, targetWindow *browserWindow) { | |
| 1206 | 1471 | if tab == nil { | |
| 1207 | 1472 | return | |
| 1208 | 1473 | } | |
| 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) | |
| 1211 | 1476 | } | |
| 1212 | 1477 | a.createWorkspaceViewWithoutAttach(ctx, tab) | |
| 1213 | 1478 | } | |
| @@ -1255,12 +1520,19 @@ func (a *App) reattachFloatingSessions(tabID entity.TabID, wsView *component.Wor | |||
| 1255 | 1520 | } | |
| 1256 | 1521 | } | |
| 1257 | 1522 | ||
| 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 | + | ) { | |
| 1259 | 1528 | if out == nil || out.TargetTab == nil { | |
| 1260 | 1529 | return | |
| 1261 | 1530 | } | |
| 1262 | 1531 | ||
| 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 | + | } | |
| 1264 | 1536 | if out.SourceTabClosed { | |
| 1265 | 1537 | switchToTarget = true | |
| 1266 | 1538 | } | |
| @@ -1269,8 +1541,14 @@ func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.Mo | |||
| 1269 | 1541 | } | |
| 1270 | 1542 | ||
| 1271 | 1543 | 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) | |
| 1274 | 1552 | } | |
| 1275 | 1553 | a.switchWorkspaceView(ctx, out.TargetTab.ID) | |
| 1276 | 1554 | } | |
| @@ -1281,42 +1559,6 @@ func (a *App) updateTabBarVisibility(ctx context.Context) { | |||
| 1281 | 1559 | } | |
| 1282 | 1560 | } | |
| 1283 | 1561 | ||
| 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 | - | ||
| 1320 | 1562 | func (a *App) initSnapshotService(ctx context.Context) { | |
| 1321 | 1563 | log := logging.FromContext(ctx) | |
| 1322 | 1564 | ||
| @@ -1356,7 +1598,9 @@ func (a *App) initUpdateCoordinator(ctx context.Context) { | |||
| 1356 | 1598 | a.updateCoord = coordinator.NewUpdateCoordinator( | |
| 1357 | 1599 | a.deps.CheckUpdateUC, | |
| 1358 | 1600 | a.deps.ApplyUpdateUC, | |
| 1359 | - | a.appToaster, | |
| 1601 | + | func(ctx context.Context, msg string, level component.ToastLevel) { | |
| 1602 | + | a.showToastOnLastFocusedBrowserWindow(ctx, msg, level) | |
| 1603 | + | }, | |
| 1360 | 1604 | a.deps.Config.Update.EnableOnStartup, | |
| 1361 | 1605 | a.deps.Config.Update.AutoDownload, | |
| 1362 | 1606 | ) | |
| @@ -1395,29 +1639,59 @@ func (a *App) createInitialTab(ctx context.Context) { | |||
| 1395 | 1639 | if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil { | |
| 1396 | 1640 | log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab") | |
| 1397 | 1641 | // 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) | |
| 1401 | 1643 | } else { | |
| 1402 | 1644 | log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored") | |
| 1403 | 1645 | // 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) | |
| 1407 | 1647 | return | |
| 1408 | 1648 | } | |
| 1409 | 1649 | } | |
| 1410 | 1650 | ||
| 1411 | 1651 | // 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 { | |
| 1417 | 1653 | log.Error().Err(err).Msg("failed to create initial tab") | |
| 1418 | 1654 | } | |
| 1419 | 1655 | } | |
| 1420 | 1656 | ||
| 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 | + | ||
| 1421 | 1695 | func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error { | |
| 1422 | 1696 | log := logging.FromContext(ctx) | |
| 1423 | 1697 | ||
| @@ -1443,6 +1717,8 @@ func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) er | |||
| 1443 | 1717 | return fmt.Errorf("failed to build tab list from snapshot") | |
| 1444 | 1718 | } | |
| 1445 | 1719 | ||
| 1720 | + | a.clearSessionRestoreUIState(ctx) | |
| 1721 | + | ||
| 1446 | 1722 | // Replace tabs in-place to preserve references held by TabCoordinator | |
| 1447 | 1723 | a.tabs.ReplaceFrom(restoredTabs) | |
| 1448 | 1724 | ||
| @@ -1451,6 +1727,10 @@ func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) er | |||
| 1451 | 1727 | tabBar := a.mainWindow.TabBar() | |
| 1452 | 1728 | for _, tab := range a.tabs.Tabs { | |
| 1453 | 1729 | 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 | + | } | |
| 1454 | 1734 | if tabBar != nil { | |
| 1455 | 1735 | tabBar.AddTab(tab) | |
| 1456 | 1736 | } | |
| @@ -1480,6 +1760,7 @@ func (a *App) finalizeActivation(ctx context.Context) { | |||
| 1480 | 1760 | if a.mainWindow != nil { | |
| 1481 | 1761 | a.mainWindow.Show() | |
| 1482 | 1762 | } | |
| 1763 | + | a.startBrowserLaunchRelayListener(ctx) | |
| 1483 | 1764 | log.Info().Msg("main window displayed") | |
| 1484 | 1765 | ||
| 1485 | 1766 | if a.deps != nil && len(a.deps.StartupCrashReports) > 0 { | |
| @@ -1505,13 +1786,11 @@ func (a *App) showCrashReportToast(ctx context.Context, paths []string) { | |||
| 1505 | 1786 | log.Warn().Int("count", len(paths)).Msg("unexpected-close reports available") | |
| 1506 | 1787 | ||
| 1507 | 1788 | 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 | + | ) | |
| 1515 | 1794 | return false | |
| 1516 | 1795 | }) | |
| 1517 | 1796 | glib.IdleAdd(&cb, 0) | |
| @@ -1563,6 +1842,9 @@ func (a *App) onShutdown(ctx context.Context) { | |||
| 1563 | 1842 | } | |
| 1564 | 1843 | } | |
| 1565 | 1844 | ||
| 1845 | + | // Stop accepting relaunches before teardown starts. | |
| 1846 | + | a.closeBrowserLaunchRelayListener() | |
| 1847 | + | ||
| 1566 | 1848 | // Cancel context to signal all goroutines | |
| 1567 | 1849 | a.cancel(errors.New("application shutdown")) | |
| 1568 | 1850 | ||
| @@ -1666,6 +1948,10 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1666 | 1948 | a.createWorkspaceView(ctx, tab) | |
| 1667 | 1949 | }) | |
| 1668 | 1950 | 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 | + | } | |
| 1669 | 1955 | a.switchWorkspaceView(ctx, tab.ID) | |
| 1670 | 1956 | }) | |
| 1671 | 1957 | a.tabCoord.SetOnQuit(a.Quit) | |
| @@ -1675,27 +1961,13 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1675 | 1961 | a.attachPopupToTab(ctx, tabID, pane, wv) | |
| 1676 | 1962 | }) | |
| 1677 | 1963 | ||
| 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) | |
| 1685 | 1966 | } | |
| 1686 | 1967 | ||
| 1687 | 1968 | // 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) | |
| 1699 | 1971 | }) | |
| 1700 | 1972 | ||
| 1701 | 1973 | // 3. Workspace Coordinator | |
| @@ -1768,8 +2040,8 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1768 | 2040 | }) | |
| 1769 | 2041 | ||
| 1770 | 2042 | // 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) | |
| 1773 | 2045 | }) | |
| 1774 | 2046 | ||
| 1775 | 2047 | // Wire pane URI updates for session snapshots (searches all tabs) | |
| @@ -1814,8 +2086,12 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1814 | 2086 | a.deps.ZoomUC, | |
| 1815 | 2087 | a.deps.CopyURLUC, | |
| 1816 | 2088 | a.deps.Config.Workspace.NewPaneURL, | |
| 2089 | + | a.contentCoord.ActivePaneID, | |
| 1817 | 2090 | ) | |
| 1818 | 2091 | a.wireKeyboardActions() | |
| 2092 | + | for _, bw := range a.browserWindows { | |
| 2093 | + | a.initBrowserWindowInput(ctx, bw) | |
| 2094 | + | } | |
| 1819 | 2095 | ||
| 1820 | 2096 | log.Debug().Msg("coordinators initialized") | |
| 1821 | 2097 | } | |
| @@ -1838,10 +2114,16 @@ func (a *App) wireKeyboardActions() { | |||
| 1838 | 2114 | a.CloseFindBar(ctx) | |
| 1839 | 2115 | return nil | |
| 1840 | 2116 | }) | |
| 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 | + | } | |
| 1842 | 2121 | return a.HandleMovePaneToTab(ctx) | |
| 1843 | 2122 | }) | |
| 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 | + | } | |
| 1845 | 2127 | return a.HandleMovePaneToNextTab(ctx) | |
| 1846 | 2128 | }) | |
| 1847 | 2129 | a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error { | |
| @@ -1858,33 +2140,73 @@ func (a *App) wireKeyboardActions() { | |||
| 1858 | 2140 | } | |
| 1859 | 2141 | ||
| 1860 | 2142 | func (a *App) wireWebRTCPermissionIndicator() { | |
| 1861 | - | if a.webrtcIndicator == nil || a.contentCoord == nil { | |
| 2143 | + | if a.contentCoord == nil { | |
| 1862 | 2144 | return | |
| 1863 | 2145 | } | |
| 1864 | 2146 | ctx := context.Background() | |
| 1865 | 2147 | if a.deps != nil && a.deps.Ctx != nil { | |
| 1866 | 2148 | ctx = a.deps.Ctx | |
| 1867 | 2149 | } | |
| 1868 | - | log := logging.FromContext(ctx) | |
| 1869 | 2150 | ||
| 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) | |
| 1872 | 2165 | ||
| 1873 | 2166 | switch state { | |
| 1874 | 2167 | case content.PermissionActivityRequesting: | |
| 1875 | - | a.webrtcIndicator.MarkRequesting(permTypes) | |
| 2168 | + | bw.webrtcIndicator.MarkRequesting(permTypes) | |
| 1876 | 2169 | case content.PermissionActivityAllowed: | |
| 1877 | - | a.webrtcIndicator.MarkAllowed(permTypes) | |
| 2170 | + | bw.webrtcIndicator.MarkAllowed(permTypes) | |
| 1878 | 2171 | case content.PermissionActivityBlocked: | |
| 1879 | - | a.webrtcIndicator.MarkBlocked(permTypes) | |
| 2172 | + | bw.webrtcIndicator.MarkBlocked(permTypes) | |
| 1880 | 2173 | } | |
| 1881 | 2174 | ||
| 1882 | 2175 | for _, permType := range permTypes { | |
| 1883 | - | a.syncWebRTCPermissionLockState(ctx, origin, permType) | |
| 2176 | + | a.syncWebRTCPermissionLockState(ctx, bw.webrtcIndicator, origin, permType) | |
| 1884 | 2177 | } | |
| 1885 | 2178 | }) | |
| 1886 | 2179 | ||
| 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) { | |
| 1888 | 2210 | if a.deps == nil || a.deps.PermissionUC == nil || origin == "" { | |
| 1889 | 2211 | return | |
| 1890 | 2212 | } | |
| @@ -1909,27 +2231,17 @@ func (a *App) wireWebRTCPermissionIndicator() { | |||
| 1909 | 2231 | } | |
| 1910 | 2232 | } | |
| 1911 | 2233 | ||
| 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) | |
| 1928 | 2235 | }) | |
| 1929 | 2236 | } | |
| 1930 | 2237 | ||
| 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 == "" { | |
| 1933 | 2245 | return | |
| 1934 | 2246 | } | |
| 1935 | 2247 | ||
| @@ -1944,11 +2256,40 @@ func (a *App) syncWebRTCPermissionLockState(ctx context.Context, origin string, | |||
| 1944 | 2256 | } | |
| 1945 | 2257 | ||
| 1946 | 2258 | if record == nil { | |
| 1947 | - | a.webrtcIndicator.SetStoredDecision(permType, entity.PermissionPrompt, false) | |
| 2259 | + | indicator.SetStoredDecision(permType, entity.PermissionPrompt, false) | |
| 1948 | 2260 | return | |
| 1949 | 2261 | } | |
| 1950 | 2262 | ||
| 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 | |
| 1952 | 2293 | } | |
| 1953 | 2294 | ||
| 1954 | 2295 | // generateID generates a unique ID for tabs and panes. | |
| @@ -1990,8 +2331,11 @@ func RunWithArgs(ctx context.Context, deps *Dependencies) int { | |||
| 1990 | 2331 | ||
| 1991 | 2332 | // updateWindowTitle updates the window title with the given page title. | |
| 1992 | 2333 | // 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 { | |
| 1995 | 2339 | return | |
| 1996 | 2340 | } | |
| 1997 | 2341 | ||
| @@ -1999,18 +2343,27 @@ func (a *App) updateWindowTitle(pageTitle string) { | |||
| 1999 | 2343 | if pageTitle != "" { | |
| 2000 | 2344 | title = pageTitle + " - Dumber" | |
| 2001 | 2345 | } | |
| 2002 | - | a.mainWindow.SetTitle(title) | |
| 2346 | + | target.mainWindow.SetTitle(title) | |
| 2003 | 2347 | } | |
| 2004 | 2348 | ||
| 2005 | 2349 | // 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 | + | } | |
| 2008 | 2361 | if ws == nil || a.contentCoord == nil { | |
| 2009 | - | a.updateWindowTitle("") | |
| 2362 | + | a.updateWindowTitle("", a.browserWindowForTab(tabID)) | |
| 2010 | 2363 | return | |
| 2011 | 2364 | } | |
| 2012 | 2365 | title := a.contentCoord.GetTitle(ws.ActivePaneID) | |
| 2013 | - | a.updateWindowTitle(title) | |
| 2366 | + | a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, "")) | |
| 2014 | 2367 | } | |
| 2015 | 2368 | ||
| 2016 | 2369 | // handleModeChange is called when the input mode changes. | |
| @@ -2027,8 +2380,8 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) { | |||
| 2027 | 2380 | ||
| 2028 | 2381 | // Update global border overlay visibility based on mode. | |
| 2029 | 2382 | // 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) | |
| 2032 | 2385 | } | |
| 2033 | 2386 | ||
| 2034 | 2387 | // Show/hide mode indicator toaster based on config. | |
| @@ -2037,25 +2390,26 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) { | |||
| 2037 | 2390 | ||
| 2038 | 2391 | // updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config. | |
| 2039 | 2392 | 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 { | |
| 2041 | 2395 | return | |
| 2042 | 2396 | } | |
| 2043 | 2397 | ||
| 2044 | 2398 | // Check if mode indicator toaster is enabled in config. | |
| 2045 | 2399 | if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.Styling.ModeIndicatorToasterEnabled { | |
| 2046 | - | a.modeToaster.Hide() | |
| 2400 | + | bw.modeToaster.Hide() | |
| 2047 | 2401 | return | |
| 2048 | 2402 | } | |
| 2049 | 2403 | ||
| 2050 | 2404 | if mode == input.ModeNormal { | |
| 2051 | - | a.modeToaster.Hide() | |
| 2405 | + | bw.modeToaster.Hide() | |
| 2052 | 2406 | return | |
| 2053 | 2407 | } | |
| 2054 | 2408 | ||
| 2055 | 2409 | // Show persistent toaster at bottom-left with mode display name. | |
| 2056 | 2410 | // Mode class is applied atomically with Show() to avoid visual flicker. | |
| 2057 | 2411 | modeClass := getModeToastClass(mode) | |
| 2058 | - | a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo, | |
| 2412 | + | bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo, | |
| 2059 | 2413 | component.WithDuration(0), // Persistent until mode exits. | |
| 2060 | 2414 | component.WithPosition(component.ToastPositionBottomLeft), | |
| 2061 | 2415 | component.WithModeClass(modeClass), | |
| @@ -2123,14 +2477,19 @@ func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) { | |||
| 2123 | 2477 | func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) { | |
| 2124 | 2478 | a.createWorkspaceViewWithoutAttach(ctx, tab) | |
| 2125 | 2479 | ||
| 2480 | + | target := a.browserWindowForTab(tab.ID) | |
| 2481 | + | if target != nil { | |
| 2482 | + | a.setBrowserWindowForTab(tab.ID, target) | |
| 2483 | + | } | |
| 2484 | + | ||
| 2126 | 2485 | // Attach to content area | |
| 2127 | 2486 | wsView := a.workspaceViews[tab.ID] | |
| 2128 | - | if wsView != nil && a.mainWindow != nil { | |
| 2487 | + | if wsView != nil && target != nil && target.mainWindow != nil { | |
| 2129 | 2488 | widget := wsView.Widget() | |
| 2130 | 2489 | if widget != nil { | |
| 2131 | 2490 | gtkWidget := widget.GtkWidget() | |
| 2132 | 2491 | if gtkWidget != nil { | |
| 2133 | - | a.mainWindow.SetContent(gtkWidget) | |
| 2492 | + | target.mainWindow.SetContent(gtkWidget) | |
| 2134 | 2493 | } | |
| 2135 | 2494 | } | |
| 2136 | 2495 | } | |
| @@ -2203,6 +2562,9 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity. | |||
| 2203 | 2562 | ||
| 2204 | 2563 | // activeWorkspace returns the workspace of the active tab. | |
| 2205 | 2564 | func (a *App) activeWorkspace() *entity.Workspace { | |
| 2565 | + | if a.tabs == nil { | |
| 2566 | + | return nil | |
| 2567 | + | } | |
| 2206 | 2568 | activeTab := a.tabs.ActiveTab() | |
| 2207 | 2569 | if activeTab == nil { | |
| 2208 | 2570 | return nil | |
| @@ -2309,12 +2671,15 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) { | |||
| 2309 | 2671 | } | |
| 2310 | 2672 | ||
| 2311 | 2673 | // 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 { | |
| 2313 | 2678 | a.mainWindow.SetContent(gtkWidget) | |
| 2314 | 2679 | } | |
| 2315 | 2680 | ||
| 2316 | 2681 | // Update window title with the new active pane's title | |
| 2317 | - | a.updateWindowTitleFromActivePane() | |
| 2682 | + | a.updateWindowTitleFromActivePane(tabID) | |
| 2318 | 2683 | a.syncFloatingFocus() | |
| 2319 | 2684 | ||
| 2320 | 2685 | log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched") | |
| @@ -2495,8 +2860,8 @@ func (a *App) installFloatingOverlayPositioning(tabID entity.TabID, workspaceOve | |||
| 2495 | 2860 | return | |
| 2496 | 2861 | } | |
| 2497 | 2862 | ||
| 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) | |
| 2500 | 2865 | if widget != nil { | |
| 2501 | 2866 | widgetPtr = widget.GoPointer() | |
| 2502 | 2867 | } | |
| @@ -2756,9 +3121,7 @@ func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspac | |||
| 2756 | 3121 | if session.omnibox == nil { | |
| 2757 | 3122 | cfg := a.omniboxCfg | |
| 2758 | 3123 | 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) | |
| 2762 | 3125 | } | |
| 2763 | 3126 | ||
| 2764 | 3127 | omnibox := component.NewOmnibox(ctx, cfg) | |
| @@ -3184,13 +3547,16 @@ func (a *App) initConfigWatcher(ctx context.Context) { | |||
| 3184 | 3547 | a.deps.OnConfigChange(func() { | |
| 3185 | 3548 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 3186 | 3549 | 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 | + | } | |
| 3194 | 3560 | } | |
| 3195 | 3561 | return false | |
| 3196 | 3562 | }) | |
| @@ -3279,21 +3645,15 @@ func (a *App) showFilterStatus(ctx context.Context, status port.FilterStatus) { | |||
| 3279 | 3645 | ||
| 3280 | 3646 | switch status.State { | |
| 3281 | 3647 | 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) | |
| 3285 | 3649 | case port.FilterStateActive: | |
| 3286 | 3650 | // Apply filters to existing webviews that were created before filters loaded | |
| 3287 | 3651 | if a.contentCoord != nil && a.deps.FilterManager != nil { | |
| 3288 | 3652 | a.contentCoord.ApplyFiltersToAll(ctx) | |
| 3289 | 3653 | log.Debug().Msg("applied filters to all existing webviews") | |
| 3290 | 3654 | } | |
| 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) | |
| 3294 | 3656 | 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) | |
| 3298 | 3658 | } | |
| 3299 | 3659 | } | |
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 { | |||
| 728 | 728 | o.listBox.AddCssClass("omnibox-listbox") | |
| 729 | 729 | o.listBox.SetSelectionMode(gtk.SelectionSingleValue) | |
| 730 | 730 | ||
| 731 | - | rowSelectedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) { | |
| 731 | + | rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) { | |
| 732 | 732 | o.restoreEntryToRealInput() | |
| 733 | + | row := gtk.ListBoxRowNewFromInternalPtr(rowPtr) | |
| 733 | 734 | if row == nil { | |
| 734 | 735 | o.mu.Lock() | |
| 735 | 736 | o.selectedIndex = -1 | |
| @@ -745,7 +746,8 @@ func (o *Omnibox) initList() error { | |||
| 745 | 746 | o.listBox.ConnectRowSelected(&rowSelectedCb) | |
| 746 | 747 | ||
| 747 | 748 | // 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) | |
| 749 | 751 | if row == nil { | |
| 750 | 752 | return | |
| 751 | 753 | } | |
| @@ -753,12 +755,17 @@ func (o *Omnibox) initList() error { | |||
| 753 | 755 | o.mu.RLock() | |
| 754 | 756 | mode := o.viewMode | |
| 755 | 757 | bangMode := o.bangMode | |
| 758 | + | bangSuggestions := o.bangSuggestions | |
| 756 | 759 | suggestions := o.suggestions | |
| 757 | 760 | favorites := o.favorites | |
| 758 | 761 | o.mu.RUnlock() | |
| 759 | 762 | ||
| 760 | 763 | // Don't navigate in bang mode - activating a bang should fill it in | |
| 761 | 764 | if bangMode { | |
| 765 | + | if bangText, ok := bangSuggestionTextAt(idx, bangSuggestions); ok && o.entry != nil { | |
| 766 | + | o.entry.SetText(bangText) | |
| 767 | + | o.entry.SetPosition(-1) | |
| 768 | + | } | |
| 762 | 769 | return | |
| 763 | 770 | } | |
| 764 | 771 | ||
| @@ -1456,6 +1463,13 @@ func selectedTargetURL(mode ViewMode, idx, maxVisible int, suggestions []Suggest | |||
| 1456 | 1463 | return resolveTargetURLForSelection(mode, idx, maxVisible, suggestions, favorites), true | |
| 1457 | 1464 | } | |
| 1458 | 1465 | ||
| 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 | + | ||
| 1459 | 1473 | func visibleResultCount(total, maxVisible int) int { | |
| 1460 | 1474 | if total <= 0 { | |
| 1461 | 1475 | return 0 | |
| @@ -2291,8 +2305,8 @@ func (o *Omnibox) navigateToSelected() { | |||
| 2291 | 2305 | } | |
| 2292 | 2306 | ||
| 2293 | 2307 | // 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) | |
| 2296 | 2310 | o.entry.SetPosition(-1) | |
| 2297 | 2311 | return | |
| 2298 | 2312 | } | |
M internal/ui/component/omnibox_navigation_state_test.go +11 -0
| @@ -199,6 +199,17 @@ func TestSelectedTargetURL(t *testing.T) { | |||
| 199 | 199 | } | |
| 200 | 200 | } | |
| 201 | 201 | ||
| 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 | + | ||
| 202 | 213 | func TestVisibleGhostSuggestion(t *testing.T) { | |
| 203 | 214 | suggestions := []Suggestion{ | |
| 204 | 215 | {URL: "https://github.com/bnema/dumber"}, | |
M internal/ui/component/session_manager.go +2 -1
| @@ -314,7 +314,8 @@ func (sm *SessionManager) initList() error { | |||
| 314 | 314 | sm.listBox.SetSelectionMode(gtk.SelectionSingleValue) | |
| 315 | 315 | ||
| 316 | 316 | // 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) | |
| 318 | 319 | if row == nil { | |
| 319 | 320 | return | |
| 320 | 321 | } | |
M internal/ui/component/tab_button.go +3 -0
| @@ -85,6 +85,9 @@ func (tb *TabButton) SetActive(active bool) { | |||
| 85 | 85 | return | |
| 86 | 86 | } | |
| 87 | 87 | tb.isActive = active | |
| 88 | + | if tb.button == nil { | |
| 89 | + | return | |
| 90 | + | } | |
| 88 | 91 | ||
| 89 | 92 | if active { | |
| 90 | 93 | tb.button.AddCssClass("tab-button-active") | |
M internal/ui/component/tab_picker.go +2 -1
| @@ -232,7 +232,8 @@ func (tp *TabPicker) createList() error { | |||
| 232 | 232 | tp.listBox.AddCssClass("tab-picker-list") | |
| 233 | 233 | tp.listBox.SetSelectionMode(gtk.SelectionSingleValue) | |
| 234 | 234 | ||
| 235 | - | rowSelectedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) { | |
| 235 | + | rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) { | |
| 236 | + | row := gtk.ListBoxRowNewFromInternalPtr(rowPtr) | |
| 236 | 237 | if row == nil { | |
| 237 | 238 | return | |
| 238 | 239 | } | |
M internal/ui/config_migration.go +2 -2
| @@ -32,8 +32,8 @@ func (a *App) checkConfigMigration(ctx context.Context) { | |||
| 32 | 32 | Msg("config migration available") | |
| 33 | 33 | ||
| 34 | 34 | // 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 { | |
| 36 | 36 | 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) | |
| 38 | 38 | } | |
| 39 | 39 | } | |
M internal/ui/coordinator/content/callbacks.go +5 -4
| @@ -118,7 +118,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P | |||
| 118 | 118 | } | |
| 119 | 119 | }, | |
| 120 | 120 | 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) | |
| 122 | 122 | }, | |
| 123 | 123 | } | |
| 124 | 124 | ||
| @@ -146,7 +146,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P | |||
| 146 | 146 | } | |
| 147 | 147 | } | |
| 148 | 148 | if c.onFullscreenChanged != nil { | |
| 149 | - | c.onFullscreenChanged(true) | |
| 149 | + | c.onFullscreenChanged(paneID, true) | |
| 150 | 150 | } | |
| 151 | 151 | return false // Allow fullscreen | |
| 152 | 152 | } | |
| @@ -158,7 +158,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P | |||
| 158 | 158 | } | |
| 159 | 159 | } | |
| 160 | 160 | if c.onFullscreenChanged != nil { | |
| 161 | - | c.onFullscreenChanged(false) | |
| 161 | + | c.onFullscreenChanged(paneID, false) | |
| 162 | 162 | } | |
| 163 | 163 | return false // Allow leaving fullscreen | |
| 164 | 164 | } | |
| @@ -189,6 +189,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P | |||
| 189 | 189 | // It delegates to the permission use case which handles auto-allow, stored permissions, and dialogs. | |
| 190 | 190 | func (c *Coordinator) handlePermissionRequest( | |
| 191 | 191 | ctx context.Context, | |
| 192 | + | paneID entity.PaneID, | |
| 192 | 193 | origin string, | |
| 193 | 194 | permTypes []string, | |
| 194 | 195 | metadata map[string]string, | |
| @@ -226,7 +227,7 @@ func (c *Coordinator) handlePermissionRequest( | |||
| 226 | 227 | if c.onPermissionActivity == nil || len(trackedTypes) == 0 { | |
| 227 | 228 | return | |
| 228 | 229 | } | |
| 229 | - | c.onPermissionActivity(origin, trackedTypes, state) | |
| 230 | + | c.onPermissionActivity(paneID, origin, trackedTypes, state) | |
| 230 | 231 | } | |
| 231 | 232 | ||
| 232 | 233 | notifyActivity(PermissionActivityRequesting) | |
M internal/ui/coordinator/content/coordinator.go +8 -8
| @@ -56,13 +56,13 @@ type Coordinator struct { | |||
| 56 | 56 | onPaneURIUpdated func(paneID entity.PaneID, url string) | |
| 57 | 57 | ||
| 58 | 58 | // Callback when active pane title changes (for window title updates) | |
| 59 | - | onWindowTitleChanged func(title string) | |
| 59 | + | onWindowTitleChanged func(paneID entity.PaneID, title string) | |
| 60 | 60 | ||
| 61 | 61 | // 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) | |
| 63 | 63 | ||
| 64 | 64 | // Callback when the active pane commits a navigation (new page loading). | |
| 65 | - | onActiveNavigationCommitted func(uri string) | |
| 65 | + | onActiveNavigationCommitted func(paneID entity.PaneID, uri string) | |
| 66 | 66 | ||
| 67 | 67 | // Callback when the WebView becomes visible (first real commit) | |
| 68 | 68 | onWebViewShown func(paneID entity.PaneID) | |
| @@ -103,7 +103,7 @@ type Coordinator struct { | |||
| 103 | 103 | idleInhibitor port.IdleInhibitor | |
| 104 | 104 | ||
| 105 | 105 | // Callback when fullscreen state changes (for hiding/showing tab bar) | |
| 106 | - | onFullscreenChanged func(entering bool) | |
| 106 | + | onFullscreenChanged func(paneID entity.PaneID, entering bool) | |
| 107 | 107 | ||
| 108 | 108 | // Callback when WebView gains focus (for accent picker text input targeting) | |
| 109 | 109 | onWebViewFocused func(paneID entity.PaneID, wv port.WebView) | |
| @@ -187,13 +187,13 @@ func (c *Coordinator) SetOnHistoryRecord(fn func(ctx context.Context, paneID ent | |||
| 187 | 187 | ||
| 188 | 188 | // SetOnPermissionActivity sets a callback for WebRTC permission activity changes. | |
| 189 | 189 | 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), | |
| 191 | 191 | ) { | |
| 192 | 192 | c.onPermissionActivity = fn | |
| 193 | 193 | } | |
| 194 | 194 | ||
| 195 | 195 | // 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)) { | |
| 197 | 197 | c.onActiveNavigationCommitted = fn | |
| 198 | 198 | } | |
| 199 | 199 | ||
| @@ -203,7 +203,7 @@ func (c *Coordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url stri | |||
| 203 | 203 | } | |
| 204 | 204 | ||
| 205 | 205 | // 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)) { | |
| 207 | 207 | c.onWindowTitleChanged = fn | |
| 208 | 208 | } | |
| 209 | 209 | ||
| @@ -223,7 +223,7 @@ func (c *Coordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) { | |||
| 223 | 223 | } | |
| 224 | 224 | ||
| 225 | 225 | // 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)) { | |
| 227 | 227 | c.onFullscreenChanged = fn | |
| 228 | 228 | } | |
| 229 | 229 | ||
M internal/ui/coordinator/content/navigation.go +2 -2
| @@ -123,13 +123,13 @@ func (c *Coordinator) notifyActiveNavigation(paneID entity.PaneID, uri string) { | |||
| 123 | 123 | c.activePaneOverrideMu.RUnlock() | |
| 124 | 124 | if override != "" { | |
| 125 | 125 | if override == paneID { | |
| 126 | - | c.onActiveNavigationCommitted(uri) | |
| 126 | + | c.onActiveNavigationCommitted(paneID, uri) | |
| 127 | 127 | } | |
| 128 | 128 | return | |
| 129 | 129 | } | |
| 130 | 130 | ws, _ := c.getActiveWS() | |
| 131 | 131 | if ws != nil && ws.ActivePaneID == paneID { | |
| 132 | - | c.onActiveNavigationCommitted(uri) | |
| 132 | + | c.onActiveNavigationCommitted(paneID, uri) | |
| 133 | 133 | } | |
| 134 | 134 | } | |
| 135 | 135 | ||
M internal/ui/coordinator/content/title.go +19 -25
| @@ -29,30 +29,22 @@ func (c *Coordinator) onTitleChanged(ctx context.Context, paneID entity.PaneID, | |||
| 29 | 29 | c.paneTitles[paneID] = title | |
| 30 | 30 | c.titleMu.Unlock() | |
| 31 | 31 | ||
| 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 | + | } | |
| 46 | 40 | } | |
| 47 | - | } | |
| 48 | 41 | ||
| 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 | + | } | |
| 56 | 48 | } | |
| 57 | 49 | } | |
| 58 | 50 | } | |
| @@ -67,9 +59,11 @@ func (c *Coordinator) onTitleChanged(ctx context.Context, paneID entity.PaneID, | |||
| 67 | 59 | } | |
| 68 | 60 | } | |
| 69 | 61 | ||
| 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 | + | } | |
| 73 | 67 | } | |
| 74 | 68 | ||
| 75 | 69 | log.Debug(). | |
M internal/ui/coordinator/content/title_test.go +57 -0
| @@ -8,8 +8,65 @@ import ( | |||
| 8 | 8 | "github.com/stretchr/testify/assert" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | "github.com/bnema/dumber/internal/ui/component" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| 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 | + | ||
| 13 | 70 | // TestGetTitle tests the GetTitle method for title state retrieval. | |
| 14 | 71 | ||
| 15 | 72 | func TestGetTitle_ReturnsEmptyForUnknownPane(t *testing.T) { | |
M internal/ui/coordinator/tab.go +6 -1
| @@ -69,6 +69,11 @@ func (c *TabCoordinator) SetOnStateChanged(fn func()) { | |||
| 69 | 69 | c.onStateChanged = fn | |
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | + | // SetMainWindow updates the window targeted by tab UI operations. | |
| 73 | + | func (c *TabCoordinator) SetMainWindow(mainWindow *window.MainWindow) { | |
| 74 | + | c.mainWindow = mainWindow | |
| 75 | + | } | |
| 76 | + | ||
| 72 | 77 | // notifyStateChanged triggers the state changed callback if set. | |
| 73 | 78 | func (c *TabCoordinator) notifyStateChanged() { | |
| 74 | 79 | if c.onStateChanged != nil { | |
| @@ -352,7 +357,7 @@ func (c *TabCoordinator) UpdateBarVisibility(ctx context.Context) { | |||
| 352 | 357 | return | |
| 353 | 358 | } | |
| 354 | 359 | ||
| 355 | - | tabCount := c.tabs.Count() | |
| 360 | + | tabCount := c.mainWindow.TabBar().Count() | |
| 356 | 361 | shouldShow := tabCount > 1 | |
| 357 | 362 | ||
| 358 | 363 | 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 ( | |||
| 17 | 17 | type UpdateCoordinator struct { | |
| 18 | 18 | checkUC *usecase.CheckUpdateUseCase | |
| 19 | 19 | applyUC *usecase.ApplyUpdateUseCase | |
| 20 | - | toaster *component.Toaster | |
| 20 | + | toastFn func(ctx context.Context, msg string, level component.ToastLevel) | |
| 21 | 21 | enableOnStartup bool | |
| 22 | 22 | autoDownload bool | |
| 23 | 23 | mu sync.RWMutex // Protects status and lastInfo | |
| @@ -31,14 +31,14 @@ type UpdateCoordinator struct { | |||
| 31 | 31 | func NewUpdateCoordinator( | |
| 32 | 32 | checkUC *usecase.CheckUpdateUseCase, | |
| 33 | 33 | applyUC *usecase.ApplyUpdateUseCase, | |
| 34 | - | toaster *component.Toaster, | |
| 34 | + | showToast func(ctx context.Context, msg string, level component.ToastLevel), | |
| 35 | 35 | enableOnStartup bool, | |
| 36 | 36 | autoDownload bool, | |
| 37 | 37 | ) *UpdateCoordinator { | |
| 38 | 38 | return &UpdateCoordinator{ | |
| 39 | 39 | checkUC: checkUC, | |
| 40 | 40 | applyUC: applyUC, | |
| 41 | - | toaster: toaster, | |
| 41 | + | toastFn: showToast, | |
| 42 | 42 | enableOnStartup: enableOnStartup, | |
| 43 | 43 | autoDownload: autoDownload, | |
| 44 | 44 | status: entity.UpdateStatusUnknown, | |
| @@ -104,7 +104,9 @@ func (c *UpdateCoordinator) showUpdateNotification(ctx context.Context, result * | |||
| 104 | 104 | } | |
| 105 | 105 | ||
| 106 | 106 | 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 | + | } | |
| 108 | 110 | return false | |
| 109 | 111 | }) | |
| 110 | 112 | glib.IdleAdd(&cb, 0) | |
| @@ -145,7 +147,9 @@ func (c *UpdateCoordinator) downloadAsync(ctx context.Context, downloadURL strin | |||
| 145 | 147 | ||
| 146 | 148 | func (c *UpdateCoordinator) showToast(ctx context.Context, msg string, level component.ToastLevel) { | |
| 147 | 149 | 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 | + | } | |
| 149 | 153 | return false | |
| 150 | 154 | }) | |
| 151 | 155 | glib.IdleAdd(&cb, 0) | |
M internal/ui/deps.go +2 -0
| @@ -104,6 +104,8 @@ type Dependencies struct { | |||
| 104 | 104 | // LaunchBrowserURL opens a URL in a new dumber browser window. | |
| 105 | 105 | // Optional: if nil, standalone omnibox web navigations are logged only. | |
| 106 | 106 | LaunchBrowserURL func(uri string) | |
| 107 | + | // BrowserLaunchRelay listens for in-process browser launch requests. | |
| 108 | + | BrowserLaunchRelay port.BrowserLaunchRelay | |
| 107 | 109 | } | |
| 108 | 110 | ||
| 109 | 111 | // Validate checks that all required dependencies are set. | |
M internal/ui/dispatcher/keyboard.go +18 -15
| @@ -28,9 +28,10 @@ type KeyboardDispatcher struct { | |||
| 28 | 28 | onFindNext func(ctx context.Context) error | |
| 29 | 29 | onFindPrev func(ctx context.Context) error | |
| 30 | 30 | 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 | |
| 34 | 35 | onToggleFloating func(ctx context.Context) error | |
| 35 | 36 | onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error | |
| 36 | 37 | } | |
| @@ -44,17 +45,19 @@ func NewKeyboardDispatcher( | |||
| 44 | 45 | zoomUC *usecase.ManageZoomUseCase, | |
| 45 | 46 | copyURLUC *usecase.CopyURLUseCase, | |
| 46 | 47 | newPaneURL string, | |
| 48 | + | activePaneID func(context.Context) entity.PaneID, | |
| 47 | 49 | ) *KeyboardDispatcher { | |
| 48 | 50 | log := logging.FromContext(ctx) | |
| 49 | 51 | log.Debug().Msg("creating keyboard dispatcher") | |
| 50 | 52 | ||
| 51 | 53 | 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, | |
| 58 | 61 | } | |
| 59 | 62 | dispatcher.initActionHandlers() | |
| 60 | 63 | return dispatcher | |
| @@ -86,15 +89,15 @@ func (d *KeyboardDispatcher) SetOnFindClose(fn func(ctx context.Context) error) | |||
| 86 | 89 | } | |
| 87 | 90 | ||
| 88 | 91 | // 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) { | |
| 90 | 93 | d.onSessionOpen = fn | |
| 91 | 94 | } | |
| 92 | 95 | ||
| 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) { | |
| 94 | 97 | d.onMovePaneToTab = fn | |
| 95 | 98 | } | |
| 96 | 99 | ||
| 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) { | |
| 98 | 101 | d.onMovePaneToNext = fn | |
| 99 | 102 | } | |
| 100 | 103 | ||
| @@ -289,7 +292,7 @@ func (d *KeyboardDispatcher) handleFindClose(ctx context.Context) error { | |||
| 289 | 292 | ||
| 290 | 293 | func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error { | |
| 291 | 294 | if d.onSessionOpen != nil { | |
| 292 | - | return d.onSessionOpen(ctx) | |
| 295 | + | return d.onSessionOpen(ctx, d.activePaneID(ctx)) | |
| 293 | 296 | } | |
| 294 | 297 | logging.FromContext(ctx).Debug().Msg("session open action (no handler)") | |
| 295 | 298 | return nil | |
| @@ -297,7 +300,7 @@ func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error { | |||
| 297 | 300 | ||
| 298 | 301 | func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error { | |
| 299 | 302 | if d.onMovePaneToTab != nil { | |
| 300 | - | return d.onMovePaneToTab(ctx) | |
| 303 | + | return d.onMovePaneToTab(ctx, d.activePaneID(ctx)) | |
| 301 | 304 | } | |
| 302 | 305 | logging.FromContext(ctx).Debug().Msg("move pane to tab action (no handler)") | |
| 303 | 306 | return nil | |
| @@ -305,7 +308,7 @@ func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error { | |||
| 305 | 308 | ||
| 306 | 309 | func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error { | |
| 307 | 310 | if d.onMovePaneToNext != nil { | |
| 308 | - | return d.onMovePaneToNext(ctx) | |
| 311 | + | return d.onMovePaneToNext(ctx, d.activePaneID(ctx)) | |
| 309 | 312 | } | |
| 310 | 313 | logging.FromContext(ctx).Debug().Msg("move pane to next tab action (no handler)") | |
| 311 | 314 | return nil | |
M internal/ui/dispatcher/keyboard_test.go +46 -3
| @@ -35,7 +35,7 @@ func TestKeyboardDispatcher_AltNumberCreatesTabsUntilRequestedIndexExists(t *tes | |||
| 35 | 35 | _, err := tabCoord.Create(ctx, "https://initial.example") | |
| 36 | 36 | require.NoError(t, err) | |
| 37 | 37 | ||
| 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 "" }) | |
| 39 | 39 | ||
| 40 | 40 | err = d.Dispatch(ctx, input.ActionSwitchTabIndex3) | |
| 41 | 41 | require.NoError(t, err) | |
| @@ -76,7 +76,7 @@ func TestKeyboardDispatcher_AltNumberSwitchesToExistingTabWithoutNewPaneURL(t *t | |||
| 76 | 76 | _, err = tabCoord.Create(ctx, "https://second.example") | |
| 77 | 77 | require.NoError(t, err) | |
| 78 | 78 | ||
| 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 "" }) | |
| 80 | 80 | ||
| 81 | 81 | err = d.Dispatch(ctx, input.ActionSwitchTabIndex2) | |
| 82 | 82 | require.NoError(t, err) | |
| @@ -106,10 +106,53 @@ func TestKeyboardDispatcher_AltNumberErrorsWhenMissingTabRequiresCreationWithout | |||
| 106 | 106 | _, err := tabCoord.Create(ctx, "https://first.example") | |
| 107 | 107 | require.NoError(t, err) | |
| 108 | 108 | ||
| 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 "" }) | |
| 110 | 110 | ||
| 111 | 111 | err = d.Dispatch(ctx, input.ActionSwitchTabIndex2) | |
| 112 | 112 | require.Error(t, err) | |
| 113 | 113 | require.Len(t, tabs.Tabs, 1) | |
| 114 | 114 | assert.Equal(t, tabs.TabAt(0).ID, tabs.ActiveTabID) | |
| 115 | 115 | } | |
| 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 ( | |||
| 7 | 7 | urlutil "github.com/bnema/dumber/internal/domain/url" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | - | func TestHandleStandaloneOmniboxNavigation_UsesBrowserLauncherForHTTP(t *testing.T) { | |
| 10 | + | func TestHandleStandaloneOmniboxNavigation_UsesFreshBrowserWindowForHTTP(t *testing.T) { | |
| 11 | 11 | browserURL := "" | |
| 12 | 12 | externalURL := "" | |
| 13 | 13 | handleStandaloneOmniboxNavigation(&Dependencies{ | |
M internal/ui/window/main_window.go +18 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/logging" | |
| 8 | 8 | "github.com/bnema/dumber/internal/ui/component" | |
| 9 | + | "github.com/bnema/puregotk/v4/gobject" | |
| 9 | 10 | "github.com/bnema/puregotk/v4/gtk" | |
| 10 | 11 | "github.com/rs/zerolog" | |
| 11 | 12 | ) | |
| @@ -118,11 +119,17 @@ func (mw *MainWindow) assembleLayout() { | |||
| 118 | 119 | ||
| 119 | 120 | // Show presents the window. | |
| 120 | 121 | func (mw *MainWindow) Show() { | |
| 122 | + | if mw == nil || mw.window == nil { | |
| 123 | + | return | |
| 124 | + | } | |
| 121 | 125 | mw.window.Present() | |
| 122 | 126 | } | |
| 123 | 127 | ||
| 124 | 128 | // Close closes the window. | |
| 125 | 129 | func (mw *MainWindow) Close() { | |
| 130 | + | if mw == nil || mw.window == nil { | |
| 131 | + | return | |
| 132 | + | } | |
| 126 | 133 | mw.window.Close() | |
| 127 | 134 | } | |
| 128 | 135 | ||
| @@ -155,6 +162,17 @@ func (mw *MainWindow) Window() *gtk.ApplicationWindow { | |||
| 155 | 162 | return mw.window | |
| 156 | 163 | } | |
| 157 | 164 | ||
| 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 | + | ||
| 158 | 176 | // SetTitle sets the window title. | |
| 159 | 177 | func (mw *MainWindow) SetTitle(title string) { | |
| 160 | 178 | if mw.window == nil { | |