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 | RuntimeVersionProbe: {} | 74 | RuntimeVersionProbe: {} | ||
| 75 | ScriptRefresher: {} | 75 | ScriptRefresher: {} | ||
| 76 | SnapshotService: {} | 76 | SnapshotService: {} | ||
| 77 | + | BrowserWindowOpener: {} | |||
| 78 | + | BrowserLaunchRelay: {} | |||
| 77 | ImageDataResolver: {} | 79 | ImageDataResolver: {} | ||
| 78 | ResolvedImageSaver: {} | 80 | ResolvedImageSaver: {} | ||
| 79 | MenuActionDelegator: {} | 81 | MenuActionDelegator: {} | ||
M cmd/dumber/main.go +57 -10
| @@ -53,6 +53,9 @@ var initialURL string | |||||
| 53 | // restoreSessionID holds the session ID to restore on startup. | 53 | // restoreSessionID holds the session ID to restore on startup. | ||
| 54 | var restoreSessionID string | 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 | type launchMode string | 59 | type launchMode string | ||
| 57 | 60 | ||||
| 58 | const ( | 61 | const ( | ||
| @@ -86,6 +89,14 @@ func launchModeFromArgs(args []string) (launchMode, string) { | |||||
| 86 | return launchModeCLI, "" | 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 | func main() { | 100 | func main() { | ||
| 90 | enableCrashForensics() | 101 | enableCrashForensics() | ||
| 91 | 102 | ||||
| @@ -103,6 +114,17 @@ func main() { | |||||
| 103 | mode, browseURL := launchModeFromArgs(os.Args) | 114 | mode, browseURL := launchModeFromArgs(os.Args) | ||
| 104 | // Run GUI mode for browse command | 115 | // Run GUI mode for browse command | ||
| 105 | if mode == launchModeBrowse { | 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 | initialURL = browseURL | 128 | initialURL = browseURL | ||
| 107 | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | 129 | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | ||
| 108 | os.Args = os.Args[:1] | 130 | os.Args = os.Args[:1] | ||
| @@ -187,6 +209,13 @@ func runGUI() int { | |||||
| 187 | log.Error().Err(err).Msg("failed to create application") | 209 | log.Error().Err(err).Msg("failed to create application") | ||
| 188 | return 1 | 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 | timer.Mark("ui_deps") | 219 | timer.Mark("ui_deps") | ||
| 191 | timer.Log(ctx) | 220 | timer.Log(ctx) | ||
| 192 | 221 | ||||
| @@ -212,9 +241,15 @@ func runStandaloneOmnibox() int { | |||||
| 212 | 241 | ||||
| 213 | preInitializeAdwaitaForCEF(cfg, initResult, ui.EnsureAdwaitaInitialized) | 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 | if err != nil { | 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 | return 1 | 253 | return 1 | ||
| 219 | } | 254 | } | ||
| 220 | if dbCleanup != nil { | 255 | if dbCleanup != nil { | ||
| @@ -228,7 +263,7 @@ func runStandaloneOmnibox() int { | |||||
| 228 | initResult.ThemeManager, | 263 | initResult.ThemeManager, | ||
| 229 | initResult.ColorResolver, | 264 | initResult.ColorResolver, | ||
| 230 | initResult.AdwaitaDetector, | 265 | initResult.AdwaitaDetector, | ||
| 231 | - | engine, | 266 | + | nil, |
| 232 | repos, | 267 | repos, | ||
| 233 | useCases, | 268 | useCases, | ||
| 234 | nil, | 269 | nil, | ||
| @@ -239,7 +274,7 @@ func runStandaloneOmnibox() int { | |||||
| 239 | logging.FromContext(ctx).Error().Err(err).Msg("failed to initialize standalone omnibox UI dependencies") | 274 | logging.FromContext(ctx).Error().Err(err).Msg("failed to initialize standalone omnibox UI dependencies") | ||
| 240 | return 1 | 275 | return 1 | ||
| 241 | } | 276 | } | ||
| 242 | - | runtimeCfg := ui.NewStandaloneOmniboxRuntime(ctx, uiDeps, engine.FaviconDatabase()) | 277 | + | runtimeCfg := ui.NewStandaloneOmniboxRuntime(ctx, uiDeps, nil) |
| 243 | 278 | ||||
| 244 | return ui.RunStandaloneOmnibox(ctx, runtimeCfg) | 279 | return ui.RunStandaloneOmnibox(ctx, runtimeCfg) | ||
| 245 | } | 280 | } | ||
| @@ -419,12 +454,20 @@ func initStackAndRepos( | |||||
| 419 | return nil, nil, nil, err | 454 | return nil, nil, nil, err | ||
| 420 | } | 455 | } | ||
| 421 | 456 | ||||
| 422 | - | lazyDB, err := bootstrap.CreateLazyDatabase() | 457 | + | repos, dbCleanup, err := initStandaloneOmniboxRepos() |
| 423 | if err != nil { | 458 | if err != nil { | ||
| 424 | return nil, nil, nil, err | 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 | dbCleanup := func() { _ = lazyDB.Close() } | 469 | dbCleanup := func() { _ = lazyDB.Close() } | ||
| 427 | - | return engine, createLazyRepositories(lazyDB), dbCleanup, nil | 470 | + | return createLazyRepositories(lazyDB), dbCleanup, nil |
| 428 | } | 471 | } | ||
| 429 | 472 | ||||
| 430 | func configureDeferredInit( | 473 | func configureDeferredInit( | ||
| @@ -695,6 +738,7 @@ func buildUIDependencies( | |||||
| 695 | } | 738 | } | ||
| 696 | 739 | ||||
| 697 | focusProvider := textinput.NewFocusProvider() | 740 | focusProvider := textinput.NewFocusProvider() | ||
| 741 | + | browserLauncher := desktop.NewBrowserLauncher(browserLaunchRelay) | |||
| 698 | 742 | ||||
| 699 | uiDeps := &ui.Dependencies{ | 743 | uiDeps := &ui.Dependencies{ | ||
| 700 | Ctx: ctx, | 744 | Ctx: ctx, | ||
| @@ -738,7 +782,7 @@ func buildUIDependencies( | |||||
| 738 | }, | 782 | }, | ||
| 739 | CheckUpdateUC: uc.checkUpdate, | 783 | CheckUpdateUC: uc.checkUpdate, | ||
| 740 | ApplyUpdateUC: uc.applyUpdate, | 784 | ApplyUpdateUC: uc.applyUpdate, | ||
| 741 | - | SessionSpawner: desktop.NewSessionSpawner(ctx), | 785 | + | SessionSpawner: bootstrap.NewSessionSpawner(ctx, cfg), |
| 742 | FileSystem: filesystem.New(), | 786 | FileSystem: filesystem.New(), | ||
| 743 | AccentFocusProvider: focusProvider, | 787 | AccentFocusProvider: focusProvider, | ||
| 744 | NewGTKEntryTarget: func(entry *gtk.SearchEntry) port.TextInputTarget { | 788 | NewGTKEntryTarget: func(entry *gtk.SearchEntry) port.TextInputTarget { | ||
| @@ -759,9 +803,12 @@ func buildUIDependencies( | |||||
| 759 | return config.GetManager().Watch() | 803 | return config.GetManager().Watch() | ||
| 760 | }, | 804 | }, | ||
| 761 | LaunchExternalURL: desktop.LaunchExternalURL, | 805 | LaunchExternalURL: desktop.LaunchExternalURL, | ||
| 762 | - | LaunchBrowserURL: desktop.LaunchBrowserURL, | 806 | + | LaunchBrowserURL: func(uri string) { |
| 763 | - | ConfigMigrator: config.NewMigrator(), | 807 | + | browserLauncher.LaunchURL(ctx, uri) |
| 764 | - | HandlerDeps: *handlerDeps, | 808 | + | }, |
| 809 | + | BrowserLaunchRelay: browserLaunchRelay, | |||
| 810 | + | ConfigMigrator: config.NewMigrator(), | |||
| 811 | + | HandlerDeps: *handlerDeps, | |||
| 765 | } | 812 | } | ||
| 766 | 813 | ||||
| 767 | return uiDeps, nil | 814 | return uiDeps, nil | ||
M cmd/dumber/main_test.go +45 -0
| @@ -1,9 +1,11 @@ | |||||
| 1 | package main | 1 | package main | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "errors" | 5 | "errors" | ||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 7 | "github.com/bnema/dumber/internal/bootstrap" | 9 | "github.com/bnema/dumber/internal/bootstrap" | ||
| 8 | "github.com/bnema/dumber/internal/infrastructure/colorscheme" | 10 | "github.com/bnema/dumber/internal/infrastructure/colorscheme" | ||
| 9 | "github.com/bnema/dumber/internal/infrastructure/config" | 11 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| @@ -131,3 +133,46 @@ func TestPreInitializeAdwaitaForCEF_SkipsNonCEF(t *testing.T) { | |||||
| 131 | t.Fatal("expected adwaita detector to remain unavailable") | 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 | go 1.26.1 | 3 | go 1.26.1 | ||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | - | github.com/bnema/purego-cef v0.8.1 | 6 | + | github.com/bnema/purego v0.11.0-bnema.2 |
| 7 | - | github.com/bnema/purego-ffmpeg v0.3.0 | 7 | + | github.com/bnema/purego-cef v0.8.2 |
| 8 | - | github.com/bnema/purego-pipewire v0.1.1 | 8 | + | github.com/bnema/purego-ffmpeg v0.3.1 |
| 9 | - | github.com/bnema/purego-sqlite v0.1.1 | 9 | + | github.com/bnema/purego-pipewire v0.1.2 |
| 10 | - | github.com/bnema/puregotk v0.4.0 | 10 | + | github.com/bnema/purego-sqlite v0.1.2 |
| 11 | - | github.com/bnema/puregotk-webkit v0.0.16 | 11 | + | github.com/bnema/puregotk v0.5.1 |
| 12 | github.com/charmbracelet/bubbles v1.0.0 | 12 | github.com/charmbracelet/bubbles v1.0.0 | ||
| 13 | github.com/charmbracelet/bubbletea v1.3.10 | 13 | github.com/charmbracelet/bubbletea v1.3.10 | ||
| 14 | github.com/charmbracelet/lipgloss v1.1.0 | 14 | github.com/charmbracelet/lipgloss v1.1.0 | ||
| 15 | - | github.com/ebitengine/purego v0.10.0 | |||
| 16 | github.com/fsnotify/fsnotify v1.9.0 | 15 | github.com/fsnotify/fsnotify v1.9.0 | ||
| 17 | github.com/godbus/dbus/v5 v5.2.2 | 16 | github.com/godbus/dbus/v5 v5.2.2 | ||
| 18 | github.com/google/uuid v1.6.0 | 17 | github.com/google/uuid v1.6.0 | ||
| @@ -70,5 +69,3 @@ require ( | |||||
| 70 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect | 69 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect | ||
| 71 | modernc.org/sqlite v1.47.0 // indirect | 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 | "context" | 8 | "context" | ||
| 9 | "database/sql" | 9 | "database/sql" | ||
| 10 | "encoding/json" | 10 | "encoding/json" | ||
| 11 | + | "io" | |||
| 11 | "time" | 12 | "time" | ||
| 12 | 13 | ||||
| 13 | "github.com/bnema/dumber/internal/application/port" | 14 | "github.com/bnema/dumber/internal/application/port" | ||
| @@ -172,6 +173,251 @@ func (_c *MockAccentPickerUI_Show_Call) RunAndReturn(run func(accents []rune, se | |||||
| 172 | return _c | 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 | // 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. | 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 | // The first argument is typically a *testing.T value. | 422 | // The first argument is typically a *testing.T value. | ||
| 177 | func NewMockClipboard(t interface { | 423 | func NewMockClipboard(t interface { | ||
| @@ -14689,6 +14935,59 @@ func (_c *MockXDGPaths_ManDir_Call) RunAndReturn(run func() (string, error)) *Mo | |||||
| 14689 | return _c | 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 | // StateDir provides a mock function for the type MockXDGPaths | 14991 | // StateDir provides a mock function for the type MockXDGPaths | ||
| 14693 | func (_mock *MockXDGPaths) StateDir() (string, error) { | 14992 | func (_mock *MockXDGPaths) StateDir() (string, error) { | ||
| 14694 | ret := _mock.Called() | 14993 | ret := _mock.Called() | ||
M internal/application/port/session_state.go +12 -0
| @@ -21,6 +21,18 @@ type SessionSpawner interface { | |||||
| 21 | SpawnWithSession(sessionID entity.SessionID) error | 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 | // SnapshotService manages debounced session-state snapshots. | 36 | // SnapshotService manages debounced session-state snapshots. | ||
| 25 | type SnapshotService interface { | 37 | type SnapshotService interface { | ||
| 26 | // Start begins the snapshot service with the given context. | 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 | ConfigDir() (string, error) | 5 | ConfigDir() (string, error) | ||
| 6 | DataDir() (string, error) | 6 | DataDir() (string, error) | ||
| 7 | StateDir() (string, error) | 7 | StateDir() (string, error) | ||
| 8 | + | RuntimeDir() (string, error) | |||
| 8 | CacheDir() (string, error) | 9 | CacheDir() (string, error) | ||
| 9 | 10 | ||||
| 10 | FilterJSONDir() (string, error) | 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 | "github.com/bnema/dumber/internal/application/port" | 11 | "github.com/bnema/dumber/internal/application/port" | ||
| 12 | "github.com/bnema/dumber/internal/application/usecase" | 12 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 13 | + | "github.com/bnema/dumber/internal/bootstrap" | |||
| 13 | "github.com/bnema/dumber/internal/cli/styles" | 14 | "github.com/bnema/dumber/internal/cli/styles" | ||
| 14 | "github.com/bnema/dumber/internal/domain/build" | 15 | "github.com/bnema/dumber/internal/domain/build" | ||
| 15 | "github.com/bnema/dumber/internal/domain/repository" | 16 | "github.com/bnema/dumber/internal/domain/repository" | ||
| @@ -37,6 +38,7 @@ type App struct { | |||||
| 37 | 38 | ||||
| 38 | // Services | 39 | // Services | ||
| 39 | FaviconService *favicon.Service | 40 | FaviconService *favicon.Service | ||
| 41 | + | SessionSpawner port.SessionSpawner | |||
| 40 | 42 | ||||
| 41 | // Context with logger | 43 | // Context with logger | ||
| 42 | ctx context.Context | 44 | ctx context.Context | ||
| @@ -131,6 +133,7 @@ func NewApp() (*App, error) { | |||||
| 131 | RestoreUC: restoreUC, | 133 | RestoreUC: restoreUC, | ||
| 132 | DeleteSessionUC: deleteSessionUC, | 134 | DeleteSessionUC: deleteSessionUC, | ||
| 133 | FaviconService: faviconService, | 135 | FaviconService: faviconService, | ||
| 136 | + | SessionSpawner: bootstrap.NewSessionSpawner(ctx, cfg), | |||
| 134 | ctx: ctx, | 137 | ctx: ctx, | ||
| 135 | logCleanup: logCleanup, | 138 | logCleanup: logCleanup, | ||
| 136 | }, nil | 139 | }, nil | ||
M internal/cli/cmd/sessions.go +7 -3
| @@ -13,7 +13,6 @@ import ( | |||||
| 13 | "github.com/bnema/dumber/internal/cli/model" | 13 | "github.com/bnema/dumber/internal/cli/model" | ||
| 14 | "github.com/bnema/dumber/internal/cli/styles" | 14 | "github.com/bnema/dumber/internal/cli/styles" | ||
| 15 | "github.com/bnema/dumber/internal/domain/entity" | 15 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 16 | - | "github.com/bnema/dumber/internal/infrastructure/desktop" | |||
| 17 | ) | 16 | ) | ||
| 18 | 17 | ||||
| 19 | const defaultSessionsLimit = 20 | 18 | const defaultSessionsLimit = 20 | ||
| @@ -60,6 +59,7 @@ func runSessions(_ *cobra.Command, _ []string) error { | |||||
| 60 | ListSessionsUC: app.ListSessionsUC, | 59 | ListSessionsUC: app.ListSessionsUC, | ||
| 61 | RestoreUC: app.RestoreUC, | 60 | RestoreUC: app.RestoreUC, | ||
| 62 | DeleteSessionUC: app.DeleteSessionUC, | 61 | DeleteSessionUC: app.DeleteSessionUC, | ||
| 62 | + | SessionSpawner: app.SessionSpawner, | |||
| 63 | CurrentSession: currentSessionID, | 63 | CurrentSession: currentSessionID, | ||
| 64 | MaxExitedSessions: app.Config.Session.MaxExitedSessions, | 64 | MaxExitedSessions: app.Config.Session.MaxExitedSessions, | ||
| 65 | }) | 65 | }) | ||
| @@ -181,8 +181,12 @@ func runSessionsRestore(_ *cobra.Command, args []string) error { | |||||
| 181 | } | 181 | } | ||
| 182 | 182 | ||||
| 183 | // Spawn a new dumber instance with the session | 183 | // Spawn a new dumber instance with the session | ||
| 184 | - | spawner := desktop.NewSessionSpawner(app.Ctx()) | 184 | + | if app.SessionSpawner == nil { |
| 185 | - | if err := spawner.SpawnWithSession(sessionID); err != 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 | wrappedErr := fmt.Errorf("spawn browser: %w", err) | 190 | wrappedErr := fmt.Errorf("spawn browser: %w", err) | ||
| 187 | fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr)) | 191 | fmt.Fprintln(os.Stderr, renderer.RenderError(wrappedErr)) | ||
| 188 | return wrapPrintedError(wrappedErr) | 192 | return wrapPrintedError(wrappedErr) | ||
M internal/cli/model/sessions.go +8 -3
| @@ -11,10 +11,10 @@ import ( | |||||
| 11 | tea "github.com/charmbracelet/bubbletea" | 11 | tea "github.com/charmbracelet/bubbletea" | ||
| 12 | "github.com/charmbracelet/lipgloss" | 12 | "github.com/charmbracelet/lipgloss" | ||
| 13 | 13 | ||||
| 14 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 14 | "github.com/bnema/dumber/internal/application/usecase" | 15 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 15 | "github.com/bnema/dumber/internal/cli/styles" | 16 | "github.com/bnema/dumber/internal/cli/styles" | ||
| 16 | "github.com/bnema/dumber/internal/domain/entity" | 17 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 17 | - | "github.com/bnema/dumber/internal/infrastructure/desktop" | |||
| 18 | "github.com/bnema/dumber/internal/logging" | 18 | "github.com/bnema/dumber/internal/logging" | ||
| 19 | ) | 19 | ) | ||
| 20 | 20 | ||||
| @@ -43,6 +43,7 @@ type SessionsModel struct { | |||||
| 43 | listSessionsUC *usecase.ListSessionsUseCase | 43 | listSessionsUC *usecase.ListSessionsUseCase | ||
| 44 | restoreUC *usecase.RestoreSessionUseCase | 44 | restoreUC *usecase.RestoreSessionUseCase | ||
| 45 | deleteSessionUC *usecase.DeleteSessionUseCase | 45 | deleteSessionUC *usecase.DeleteSessionUseCase | ||
| 46 | + | sessionSpawner port.SessionSpawner | |||
| 46 | theme *styles.Theme | 47 | theme *styles.Theme | ||
| 47 | } | 48 | } | ||
| 48 | 49 | ||||
| @@ -114,6 +115,7 @@ type SessionsModelConfig struct { | |||||
| 114 | ListSessionsUC *usecase.ListSessionsUseCase | 115 | ListSessionsUC *usecase.ListSessionsUseCase | ||
| 115 | RestoreUC *usecase.RestoreSessionUseCase | 116 | RestoreUC *usecase.RestoreSessionUseCase | ||
| 116 | DeleteSessionUC *usecase.DeleteSessionUseCase | 117 | DeleteSessionUC *usecase.DeleteSessionUseCase | ||
| 118 | + | SessionSpawner port.SessionSpawner | |||
| 117 | CurrentSession entity.SessionID | 119 | CurrentSession entity.SessionID | ||
| 118 | MaxExitedSessions int | 120 | MaxExitedSessions int | ||
| 119 | } | 121 | } | ||
| @@ -137,6 +139,7 @@ func NewSessionsModel(ctx context.Context, theme *styles.Theme, cfg SessionsMode | |||||
| 137 | listSessionsUC: cfg.ListSessionsUC, | 139 | listSessionsUC: cfg.ListSessionsUC, | ||
| 138 | restoreUC: cfg.RestoreUC, | 140 | restoreUC: cfg.RestoreUC, | ||
| 139 | deleteSessionUC: cfg.DeleteSessionUC, | 141 | deleteSessionUC: cfg.DeleteSessionUC, | ||
| 142 | + | sessionSpawner: cfg.SessionSpawner, | |||
| 140 | currentSession: cfg.CurrentSession, | 143 | currentSession: cfg.CurrentSession, | ||
| 141 | theme: theme, | 144 | theme: theme, | ||
| 142 | } | 145 | } | ||
| @@ -336,8 +339,10 @@ func (m SessionsModel) restoreSession(sessionID entity.SessionID) tea.Cmd { | |||||
| 336 | } | 339 | } | ||
| 337 | 340 | ||||
| 338 | // Spawn a new dumber instance | 341 | // Spawn a new dumber instance | ||
| 339 | - | spawner := desktop.NewSessionSpawner(m.ctx) | 342 | + | if m.sessionSpawner == nil { |
| 340 | - | if err := spawner.SpawnWithSession(sessionID); err != nil { | 343 | + | return sessionRestoredMsg{sessionID: sessionID, err: fmt.Errorf("session spawner not available")} |
| 344 | + | } | |||
| 345 | + | if err := m.sessionSpawner.SpawnWithSession(sessionID); err != nil { | |||
| 341 | return sessionRestoredMsg{sessionID: sessionID, err: err} | 346 | return sessionRestoredMsg{sessionID: sessionID, err: err} | ||
| 342 | } | 347 | } | ||
| 343 | 348 | ||||
M internal/infrastructure/cef/app.go +89 -3
| @@ -1,6 +1,8 @@ | |||||
| 1 | package cef | 1 | package cef | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "strings" | |||
| 5 | + | ||||
| 4 | purecef "github.com/bnema/purego-cef/cef" | 6 | purecef "github.com/bnema/purego-cef/cef" | ||
| 5 | 7 | ||||
| 6 | "github.com/bnema/dumber/internal/logging" | 8 | "github.com/bnema/dumber/internal/logging" | ||
| @@ -125,9 +127,93 @@ func (h *dumberBPH) OnBeforeChildProcessLaunch(commandLine purecef.CommandLine) | |||||
| 125 | } | 127 | } | ||
| 126 | h.engine.recordChildProcessLaunch(processType, useAngle, ozonePlatform, commandLineString) | 128 | h.engine.recordChildProcessLaunch(processType, useAngle, ozonePlatform, commandLineString) | ||
| 127 | } | 129 | } | ||
| 128 | - | func (h *dumberBPH) OnAlreadyRunningAppRelaunch(_ purecef.CommandLine, _ string) int32 { return 0 } | 130 | + | |
| 129 | - | func (h *dumberBPH) GetDefaultClient() purecef.Client { return nil } | 131 | + | func parseRelaunchCommandLineArgs(commandLine string) []string { |
| 130 | - | func (h *dumberBPH) GetDefaultRequestContextHandler() purecef.RequestContextHandler { return nil } | 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 | // OnScheduleMessagePumpWork is a no-op — multi-threaded message loop drives | 218 | // OnScheduleMessagePumpWork is a no-op — multi-threaded message loop drives | ||
| 133 | // its own pump. Required by the BrowserProcessHandler interface. | 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 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | + | "unsafe" | |||
| 5 | 6 | ||||
| 6 | purecef "github.com/bnema/purego-cef/cef" | 7 | purecef "github.com/bnema/purego-cef/cef" | ||
| 7 | ) | 8 | ) | ||
| @@ -19,6 +20,32 @@ func (s *schemeRegistrarStub) AddCustomScheme(schemeName string, options int32) | |||||
| 19 | return 1 | 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 | func TestRegisterDumbScheme(t *testing.T) { | 49 | func TestRegisterDumbScheme(t *testing.T) { | ||
| 23 | t.Parallel() | 50 | t.Parallel() | ||
| 24 | 51 | ||||
| @@ -41,3 +68,78 @@ func TestRegisterDumbScheme(t *testing.T) { | |||||
| 41 | t.Fatalf("options = %d, want %d", stub.options, wantOptions) | 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 | // Compile-time interface check. | 15 | // Compile-time interface check. | ||
| 16 | var _ port.Engine = (*Engine)(nil) | 16 | var _ port.Engine = (*Engine)(nil) | ||
| 17 | + | var _ port.AlreadyRunningAppRelaunchHandlerSetter = (*Engine)(nil) | |||
| 17 | 18 | ||||
| 18 | // Engine implements port.Engine for the CEF browser backend. | 19 | // Engine implements port.Engine for the CEF browser backend. | ||
| 19 | // It manages the CEF lifecycle and provides access to all engine subsystems. | 20 | // It manages the CEF lifecycle and provides access to all engine subsystems. | ||
| @@ -27,15 +28,17 @@ type Engine struct { | |||||
| 27 | schemeHandler *dumbSchemeHandler | 28 | schemeHandler *dumbSchemeHandler | ||
| 28 | contentInj *contentInjector | 29 | contentInj *contentInjector | ||
| 29 | 30 | ||||
| 30 | - | registerHandlers HandlerRegistrar | 31 | + | registerHandlers HandlerRegistrar |
| 31 | - | registerAccentHandlers AccentHandlerRegistrar | 32 | + | registerAccentHandlers AccentHandlerRegistrar |
| 32 | - | currentConfigPayload func() ([]byte, error) | 33 | + | currentConfigPayload func() ([]byte, error) |
| 33 | - | defaultConfigPayload func() ([]byte, error) | 34 | + | defaultConfigPayload func() ([]byte, error) |
| 34 | - | ctxMenuBuilder port.ContextMenuBuilder | 35 | + | ctxMenuBuilder port.ContextMenuBuilder |
| 35 | - | ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory | 36 | + | ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory |
| 36 | - | clipboard port.Clipboard | 37 | + | clipboard port.Clipboard |
| 37 | - | resolver port.ImageDataResolver | 38 | + | resolver port.ImageDataResolver |
| 38 | - | mediaClassifier MediaClassifier | 39 | + | mediaClassifier MediaClassifier |
| 40 | + | alreadyRunningAppRelaunchMu sync.RWMutex | |||
| 41 | + | alreadyRunningAppRelaunchHandler func(string) | |||
| 39 | 42 | ||||
| 40 | // activeWebViews tracks all live webviews for CSS broadcast. | 43 | // activeWebViews tracks all live webviews for CSS broadcast. | ||
| 41 | activeWebViews sync.Map // map[port.WebViewID]*WebView | 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 | // registerWebView adds a webview to the active tracking map. | 134 | // registerWebView adds a webview to the active tracking map. | ||
| 118 | func (e *Engine) registerWebView(wv *WebView) { | 135 | func (e *Engine) registerWebView(wv *WebView) { | ||
| 119 | e.activeWebViews.Store(wv.id, wv) | 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 | const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE" | 24 | const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE" | ||
| 25 | + | const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH" | |||
| 25 | 26 | ||||
| 26 | // NewEngine initializes the CEF runtime and returns a ready-to-use Engine. | 27 | // NewEngine initializes the CEF runtime and returns a ready-to-use Engine. | ||
| 27 | func NewEngine( | 28 | func NewEngine( | ||
| @@ -89,6 +90,10 @@ func NewEngine( | |||||
| 89 | } | 90 | } | ||
| 90 | 91 | ||||
| 91 | func resolvedStateRoot(opts port.EngineOptions) string { | 92 | func resolvedStateRoot(opts port.EngineOptions) string { | ||
| 93 | + | if root := os.Getenv(CEFRootCachePathEnvVar); root != "" { | |||
| 94 | + | return root | |||
| 95 | + | } | |||
| 96 | + | ||||
| 92 | switch { | 97 | switch { | ||
| 93 | case opts.DataDir != "": | 98 | case opts.DataDir != "": | ||
| 94 | return opts.DataDir | 99 | return opts.DataDir | ||
| @@ -159,6 +164,11 @@ func defaultCEFUserDataDir() string { | |||||
| 159 | return filepath.Join(dirs.DataHome, "cef_user_data") | 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 | // initializeCEF calls cef_initialize with the App to register custom schemes | 172 | // initializeCEF calls cef_initialize with the App to register custom schemes | ||
| 163 | // and browser-process callbacks (OnBeforeCommandLineProcessing, etc.). | 173 | // and browser-process callbacks (OnBeforeCommandLineProcessing, etc.). | ||
| 164 | func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logger) error { | 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 | require.Equal(t, "/tmp/cache", root) | 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 | func TestPrepareCEFSettings_UsesResolvedStateRoot(t *testing.T) { | 35 | func TestPrepareCEFSettings_UsesResolvedStateRoot(t *testing.T) { | ||
| 26 | logger := zerolog.Nop() | 36 | logger := zerolog.Nop() | ||
| 27 | settings, err := prepareCEFSettings(port.EngineOptions{CacheDir: "/tmp/cache"}, RuntimeConfig{}, &logger) | 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 | "fmt" | 4 | "fmt" | ||
| 5 | "unsafe" | 5 | "unsafe" | ||
| 6 | 6 | ||||
| 7 | - | "github.com/ebitengine/purego" | 7 | + | "github.com/bnema/purego" |
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | // OpenGL constants for PBO-based texture rendering. | 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 | rp.glArea.SetHasDepthBuffer(false) | 163 | rp.glArea.SetHasDepthBuffer(false) | ||
| 164 | rp.glArea.SetHasStencilBuffer(false) | 164 | rp.glArea.SetHasStencilBuffer(false) | ||
| 165 | 165 | ||||
| 166 | - | // Wire signals. puregotk takes *func(...) for signal callbacks. | 166 | + | // Wire signals. puregotk currently exposes the render context as a raw pointer. |
| 167 | - | renderCb := func(_ gtk.GLArea, _ *gdk.GLContext) bool { | 167 | + | renderCb := func(_ gtk.GLArea, contextPtr uintptr) bool { |
| 168 | + | _ = gdk.GLContextNewFromInternalPtr(contextPtr) | |||
| 168 | return rp.onGLRender() | 169 | return rp.onGLRender() | ||
| 169 | } | 170 | } | ||
| 170 | rp.glArea.ConnectRender(&renderCb) | 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 | // SessionSpawner implements port.SessionSpawner by launching a new dumber process. | 382 | // SessionSpawner implements port.SessionSpawner by launching a new dumber process. | ||
| 383 | type SessionSpawner struct { | 383 | type SessionSpawner struct { | ||
| 384 | - | ctx context.Context | 384 | + | ctx context.Context |
| 385 | + | spawnEnv port.SessionSpawnEnvironment | |||
| 385 | } | 386 | } | ||
| 386 | 387 | ||||
| 387 | // NewSessionSpawner creates a new session spawner. | 388 | // NewSessionSpawner creates a new session spawner. | ||
| 388 | - | func NewSessionSpawner(ctx context.Context) *SessionSpawner { | 389 | + | func NewSessionSpawner(ctx context.Context, spawnEnv port.SessionSpawnEnvironment) *SessionSpawner { |
| 389 | - | return &SessionSpawner{ctx: ctx} | 390 | + | return &SessionSpawner{ctx: ctx, spawnEnv: spawnEnv} |
| 390 | } | 391 | } | ||
| 391 | 392 | ||||
| 392 | // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration. | 393 | // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration. | ||
| @@ -406,7 +407,18 @@ func LaunchExternalURL(uri string) { | |||||
| 406 | 407 | ||||
| 407 | // LaunchBrowserURL opens a URL in a new dumber browser window. | 408 | // LaunchBrowserURL opens a URL in a new dumber browser window. | ||
| 408 | func LaunchBrowserURL(uri string) { | 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 | if err != nil { | 422 | if err != nil { | ||
| 411 | fmt.Fprintf(os.Stderr, "failed to resolve dumber executable for URL %q: %v\n", uri, err) | 423 | fmt.Fprintf(os.Stderr, "failed to resolve dumber executable for URL %q: %v\n", uri, err) | ||
| 412 | return | 424 | return | ||
| @@ -414,7 +426,7 @@ func LaunchBrowserURL(uri string) { | |||||
| 414 | 426 | ||||
| 415 | cmd := exec.Command(execPath, "browse", uri) | 427 | cmd := exec.Command(execPath, "browse", uri) | ||
| 416 | cmd.Env = sanitizedChildEnv(os.Environ()) | 428 | cmd.Env = sanitizedChildEnv(os.Environ()) | ||
| 417 | - | if err := startDetachedProcess(cmd); err != nil { | 429 | + | if err := spawnDetachedProcess(cmd); err != nil { |
| 418 | fmt.Fprintf(os.Stderr, "failed to launch dumber browse for URL %q: %v\n", uri, err) | 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 | return cleaned | 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 | // SpawnWithSession starts a new dumber instance to restore a session. | 511 | // SpawnWithSession starts a new dumber instance to restore a session. | ||
| 473 | func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error { | 512 | func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error { | ||
| 474 | log := logging.FromContext(s.ctx) | 513 | log := logging.FromContext(s.ctx) | ||
| @@ -481,8 +520,14 @@ func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error { | |||||
| 481 | // Start dumber browse with session ID in environment | 520 | // Start dumber browse with session ID in environment | ||
| 482 | cmd := exec.Command(execPath, "browse") | 521 | cmd := exec.Command(execPath, "browse") | ||
| 483 | 522 | ||||
| 484 | - | // Inherit environment and add restore session ID | 523 | + | sanitizedEnv := withoutEnvKeys(sanitizedChildEnv(os.Environ()), RestoreSessionEnvVar) |
| 485 | - | cmd.Env = append(sanitizedChildEnv(os.Environ()), RestoreSessionEnvVar+"="+string(sessionID)) | 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 | // Detach from current process group so the new process survives. | 532 | // Detach from current process group so the new process survives. | ||
| 488 | cmd.Stdin = nil | 533 | cmd.Stdin = nil | ||
M internal/infrastructure/desktop/adapter_test.go +120 -1
| @@ -1,6 +1,41 @@ | |||||
| 1 | package desktop | 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 | func TestSanitizedChildEnv_RemovesLayerShellPreloadOnly(t *testing.T) { | 40 | func TestSanitizedChildEnv_RemovesLayerShellPreloadOnly(t *testing.T) { | ||
| 6 | env := sanitizedChildEnv([]string{ | 41 | env := sanitizedChildEnv([]string{ | ||
| @@ -32,3 +67,87 @@ func TestSanitizedChildEnv_PreservesColonSeparatedEntries(t *testing.T) { | |||||
| 32 | t.Fatalf("expected unrelated colon-separated entries to remain, got %#v", env) | 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 | "context" | 4 | "context" | ||
| 5 | "time" | 5 | "time" | ||
| 6 | 6 | ||||
| 7 | - | "github.com/bnema/puregotk-webkit/webkit" | 7 | + | "github.com/bnema/puregotk/v4/webkit" |
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | // FilterStore abstracts the WebKit UserContentFilterStore operations. | 10 | // FilterStore abstracts the WebKit UserContentFilterStore operations. | ||
M internal/infrastructure/filtering/manager.go +1 -1
| @@ -14,7 +14,7 @@ import ( | |||||
| 14 | 14 | ||||
| 15 | "github.com/bnema/dumber/internal/application/port" | 15 | "github.com/bnema/dumber/internal/application/port" | ||
| 16 | "github.com/bnema/dumber/internal/logging" | 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 | // Compile-time check that Manager satisfies port.FilterManager. | 20 | // Compile-time check that Manager satisfies port.FilterManager. | ||
M internal/infrastructure/filtering/manager_test.go +1 -1
| @@ -13,7 +13,7 @@ import ( | |||||
| 13 | "github.com/bnema/dumber/internal/infrastructure/filtering" | 13 | "github.com/bnema/dumber/internal/infrastructure/filtering" | ||
| 14 | "github.com/bnema/dumber/internal/infrastructure/filtering/mocks" | 14 | "github.com/bnema/dumber/internal/infrastructure/filtering/mocks" | ||
| 15 | "github.com/bnema/dumber/internal/logging" | 15 | "github.com/bnema/dumber/internal/logging" | ||
| 16 | - | "github.com/bnema/puregotk-webkit/webkit" | 16 | + | "github.com/bnema/puregotk/v4/webkit" |
| 17 | "github.com/stretchr/testify/assert" | 17 | "github.com/stretchr/testify/assert" | ||
| 18 | "github.com/stretchr/testify/mock" | 18 | "github.com/stretchr/testify/mock" | ||
| 19 | "github.com/stretchr/testify/require" | 19 | "github.com/stretchr/testify/require" | ||
M internal/infrastructure/filtering/mocks/mocks.go +1 -1
| @@ -9,7 +9,7 @@ import ( | |||||
| 9 | "time" | 9 | "time" | ||
| 10 | 10 | ||||
| 11 | "github.com/bnema/dumber/internal/infrastructure/filtering" | 11 | "github.com/bnema/dumber/internal/infrastructure/filtering" | ||
| 12 | - | "github.com/bnema/puregotk-webkit/webkit" | 12 | + | "github.com/bnema/puregotk/v4/webkit" |
| 13 | mock "github.com/stretchr/testify/mock" | 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 | "sync" | 6 | "sync" | ||
| 7 | 7 | ||||
| 8 | "github.com/bnema/dumber/internal/logging" | 8 | "github.com/bnema/dumber/internal/logging" | ||
| 9 | - | "github.com/bnema/puregotk-webkit/webkit" | |||
| 10 | "github.com/bnema/puregotk/v4/gio" | 9 | "github.com/bnema/puregotk/v4/gio" | ||
| 10 | + | "github.com/bnema/puregotk/v4/webkit" | |||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | // Store wraps webkit.UserContentFilterStore with Go-friendly async operations. | 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 | "github.com/bnema/dumber/internal/application/port" | 7 | "github.com/bnema/dumber/internal/application/port" | ||
| 8 | "github.com/bnema/dumber/internal/logging" | 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 | // deleteBeforeCursorJS is the JavaScript template to delete n characters before cursor. | 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 | "net" | 13 | "net" | ||
| 14 | "time" | 14 | "time" | ||
| 15 | 15 | ||||
| 16 | - | "github.com/ebitengine/purego" | 16 | + | "github.com/bnema/purego" |
| 17 | "github.com/rs/zerolog" | 17 | "github.com/rs/zerolog" | ||
| 18 | 18 | ||||
| 19 | "github.com/bnema/purego-ffmpeg/ffmpeg" | 19 | "github.com/bnema/purego-ffmpeg/ffmpeg" | ||
M internal/infrastructure/webkit/context.go +2 -2
| @@ -7,12 +7,12 @@ import ( | |||||
| 7 | "sync" | 7 | "sync" | ||
| 8 | 8 | ||||
| 9 | "github.com/bnema/dumber/internal/logging" | 9 | "github.com/bnema/dumber/internal/logging" | ||
| 10 | - | "github.com/bnema/puregotk-webkit/webkit" | 10 | + | "github.com/bnema/puregotk/v4/webkit" |
| 11 | "github.com/rs/zerolog" | 11 | "github.com/rs/zerolog" | ||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | // FaviconDatabase is a type alias for webkit.FaviconDatabase. | 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 | type FaviconDatabase = webkit.FaviconDatabase | 16 | type FaviconDatabase = webkit.FaviconDatabase | ||
| 17 | 17 | ||||
| 18 | // WebKitContext manages the shared WebContext and persistent NetworkSession. | 18 | // WebKitContext manages the shared WebContext and persistent NetworkSession. | ||
M internal/infrastructure/webkit/context_menu.go +1 -1
| @@ -17,8 +17,8 @@ import ( | |||||
| 17 | "github.com/bnema/dumber/internal/application/port" | 17 | "github.com/bnema/dumber/internal/application/port" | ||
| 18 | "github.com/bnema/dumber/internal/domain/entity" | 18 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 19 | "github.com/bnema/dumber/internal/logging" | 19 | "github.com/bnema/dumber/internal/logging" | ||
| 20 | - | "github.com/bnema/puregotk-webkit/webkit" | |||
| 21 | "github.com/bnema/puregotk/v4/gtk" | 20 | "github.com/bnema/puregotk/v4/gtk" | ||
| 21 | + | "github.com/bnema/puregotk/v4/webkit" | |||
| 22 | ) | 22 | ) | ||
| 23 | 23 | ||||
| 24 | // contextMenuResolver implements port.ImageDataResolver by fetching image bytes | 24 | // contextMenuResolver implements port.ImageDataResolver by fetching image bytes | ||
M internal/infrastructure/webkit/download_handler.go +1 -1
| @@ -9,8 +9,8 @@ import ( | |||||
| 9 | "github.com/bnema/dumber/internal/application/port" | 9 | "github.com/bnema/dumber/internal/application/port" | ||
| 10 | downloadutil "github.com/bnema/dumber/internal/domain/download" | 10 | downloadutil "github.com/bnema/dumber/internal/domain/download" | ||
| 11 | "github.com/bnema/dumber/internal/logging" | 11 | "github.com/bnema/dumber/internal/logging" | ||
| 12 | - | "github.com/bnema/puregotk-webkit/webkit" | |||
| 13 | "github.com/bnema/puregotk/v4/glib" | 12 | "github.com/bnema/puregotk/v4/glib" | ||
| 13 | + | "github.com/bnema/puregotk/v4/webkit" | |||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | const ( | 16 | const ( | ||
M internal/infrastructure/webkit/gobject_type_helpers.go +1 -1
| @@ -5,9 +5,9 @@ import ( | |||||
| 5 | "sync" | 5 | "sync" | ||
| 6 | 6 | ||||
| 7 | "github.com/bnema/dumber/internal/logging" | 7 | "github.com/bnema/dumber/internal/logging" | ||
| 8 | + | "github.com/bnema/purego" | |||
| 8 | "github.com/bnema/puregotk/pkg/core" | 9 | "github.com/bnema/puregotk/pkg/core" | ||
| 9 | gtypes "github.com/bnema/puregotk/v4/gobject/types" | 10 | gtypes "github.com/bnema/puregotk/v4/gobject/types" | ||
| 10 | - | "github.com/ebitengine/purego" | |||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | var ( | 13 | var ( | ||
M internal/infrastructure/webkit/injector.go +1 -1
| @@ -10,7 +10,7 @@ import ( | |||||
| 10 | "github.com/bnema/dumber/internal/domain/entity" | 10 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 11 | "github.com/bnema/dumber/internal/infrastructure/webutil" | 11 | "github.com/bnema/dumber/internal/infrastructure/webutil" | ||
| 12 | "github.com/bnema/dumber/internal/logging" | 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 | const ( | 16 | const ( | ||
M internal/infrastructure/webkit/injector_accent_test.go +1 -1
| @@ -3,7 +3,7 @@ package webkit | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | - | "github.com/bnema/puregotk-webkit/webkit" | 6 | + | "github.com/bnema/puregotk/v4/webkit" |
| 7 | "github.com/stretchr/testify/assert" | 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 | "github.com/bnema/dumber/internal/application/port" | 6 | "github.com/bnema/dumber/internal/application/port" | ||
| 7 | "github.com/bnema/dumber/internal/logging" | 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 | // MemoryPressureApplier implements port.MemoryPressureApplier for WebKitGTK. | 11 | // MemoryPressureApplier implements port.MemoryPressureApplier for WebKitGTK. | ||
M internal/infrastructure/webkit/messaging.go +2 -2
| @@ -9,8 +9,8 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | "github.com/bnema/dumber/internal/application/port" | 10 | "github.com/bnema/dumber/internal/application/port" | ||
| 11 | "github.com/bnema/dumber/internal/logging" | 11 | "github.com/bnema/dumber/internal/logging" | ||
| 12 | - | "github.com/bnema/puregotk-webkit/javascriptcore" | 12 | + | "github.com/bnema/puregotk/v4/javascriptcore" |
| 13 | - | "github.com/bnema/puregotk-webkit/webkit" | 13 | + | "github.com/bnema/puregotk/v4/webkit" |
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | // Message is the JS -> Go message envelope sent via postMessage. | 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 | "time" | 10 | "time" | ||
| 11 | 11 | ||||
| 12 | "github.com/bnema/dumber/internal/logging" | 12 | "github.com/bnema/dumber/internal/logging" | ||
| 13 | - | "github.com/bnema/puregotk-webkit/webkit" | |||
| 14 | "github.com/bnema/puregotk/v4/glib" | 13 | "github.com/bnema/puregotk/v4/glib" | ||
| 14 | + | "github.com/bnema/puregotk/v4/webkit" | |||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | // ErrPoolClosed is returned when operations are attempted on a closed pool. | 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 | "github.com/bnema/dumber/internal/infrastructure/env" | 17 | "github.com/bnema/dumber/internal/infrastructure/env" | ||
| 18 | "github.com/bnema/dumber/internal/infrastructure/webutil" | 18 | "github.com/bnema/dumber/internal/infrastructure/webutil" | ||
| 19 | "github.com/bnema/dumber/internal/logging" | 19 | "github.com/bnema/dumber/internal/logging" | ||
| 20 | - | "github.com/bnema/puregotk-webkit/soup" | |||
| 21 | - | "github.com/bnema/puregotk-webkit/webkit" | |||
| 22 | "github.com/bnema/puregotk/v4/gio" | 20 | "github.com/bnema/puregotk/v4/gio" | ||
| 21 | + | "github.com/bnema/puregotk/v4/soup" | |||
| 22 | + | "github.com/bnema/puregotk/v4/webkit" | |||
| 23 | "github.com/rs/zerolog" | 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 | "github.com/bnema/dumber/internal/infrastructure/config" | 7 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 8 | "github.com/bnema/dumber/internal/logging" | 8 | "github.com/bnema/dumber/internal/logging" | ||
| 9 | - | "github.com/bnema/puregotk-webkit/webkit" | 9 | + | "github.com/bnema/puregotk/v4/webkit" |
| 10 | "github.com/rs/zerolog" | 10 | "github.com/rs/zerolog" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
M internal/infrastructure/webkit/webview.go +1 -1
| @@ -15,13 +15,13 @@ import ( | |||||
| 15 | urlutil "github.com/bnema/dumber/internal/domain/url" | 15 | urlutil "github.com/bnema/dumber/internal/domain/url" | ||
| 16 | "github.com/bnema/dumber/internal/infrastructure/desktop" | 16 | "github.com/bnema/dumber/internal/infrastructure/desktop" | ||
| 17 | "github.com/bnema/dumber/internal/logging" | 17 | "github.com/bnema/dumber/internal/logging" | ||
| 18 | - | "github.com/bnema/puregotk-webkit/webkit" | |||
| 19 | "github.com/bnema/puregotk/v4/gdk" | 18 | "github.com/bnema/puregotk/v4/gdk" | ||
| 20 | "github.com/bnema/puregotk/v4/gio" | 19 | "github.com/bnema/puregotk/v4/gio" | ||
| 21 | "github.com/bnema/puregotk/v4/glib" | 20 | "github.com/bnema/puregotk/v4/glib" | ||
| 22 | "github.com/bnema/puregotk/v4/gobject" | 21 | "github.com/bnema/puregotk/v4/gobject" | ||
| 23 | gtypes "github.com/bnema/puregotk/v4/gobject/types" | 22 | gtypes "github.com/bnema/puregotk/v4/gobject/types" | ||
| 24 | "github.com/bnema/puregotk/v4/gtk" | 23 | "github.com/bnema/puregotk/v4/gtk" | ||
| 24 | + | "github.com/bnema/puregotk/v4/webkit" | |||
| 25 | "github.com/rs/zerolog" | 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 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | - | webkitlib "github.com/bnema/puregotk-webkit/webkit" | 6 | + | webkitlib "github.com/bnema/puregotk/v4/webkit" |
| 7 | "github.com/stretchr/testify/assert" | 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 | return config.GetStateDir() | 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 | func (a *Adapter) CacheDir() (string, error) { | 43 | func (a *Adapter) CacheDir() (string, error) { | ||
| 32 | dirs, err := config.GetXDGDirs() | 44 | dirs, err := config.GetXDGDirs() | ||
| 33 | if err != nil { | 45 | if err != nil { | ||
M internal/infrastructure/xdg/adapter_test.go +25 -22
| @@ -13,17 +13,7 @@ func TestAdapter_DownloadDir(t *testing.T) { | |||||
| 13 | adapter := New() | 13 | adapter := New() | ||
| 14 | 14 | ||||
| 15 | t.Run("returns XDG_DOWNLOAD_DIR when set", func(t *testing.T) { | 15 | t.Run("returns XDG_DOWNLOAD_DIR when set", func(t *testing.T) { | ||
| 16 | - | // Save and restore original env. | 16 | + | t.Setenv("XDG_DOWNLOAD_DIR", "/custom/downloads") |
| 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") | |||
| 27 | 17 | ||||
| 28 | dir, err := adapter.DownloadDir() | 18 | dir, err := adapter.DownloadDir() | ||
| 29 | 19 | ||||
| @@ -32,17 +22,7 @@ func TestAdapter_DownloadDir(t *testing.T) { | |||||
| 32 | }) | 22 | }) | ||
| 33 | 23 | ||||
| 34 | t.Run("falls back to ~/Downloads when XDG_DOWNLOAD_DIR not set", func(t *testing.T) { | 24 | t.Run("falls back to ~/Downloads when XDG_DOWNLOAD_DIR not set", func(t *testing.T) { | ||
| 35 | - | // Save and restore original env. | 25 | + | t.Setenv("XDG_DOWNLOAD_DIR", "") |
| 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") | |||
| 46 | 26 | ||||
| 47 | dir, err := adapter.DownloadDir() | 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 | func TestNew(t *testing.T) { | 62 | func TestNew(t *testing.T) { | ||
| 60 | adapter := New() | 63 | adapter := New() | ||
| 61 | 64 | ||||
M internal/ui/app.go +796 -436
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | + | "io" | |||
| 7 | "os" | 8 | "os" | ||
| 8 | "path/filepath" | 9 | "path/filepath" | ||
| 9 | "strings" | 10 | "strings" | ||
| @@ -65,6 +66,12 @@ type App struct { | |||||
| 65 | gtkApp *gtk.Application | 66 | gtkApp *gtk.Application | ||
| 66 | mainWindow *window.MainWindow | 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 | // State | 75 | // State | ||
| 69 | tabs *entity.TabList | 76 | tabs *entity.TabList | ||
| 70 | tabsUC *usecase.ManageTabsUseCase | 77 | tabsUC *usecase.ManageTabsUseCase | ||
| @@ -79,6 +86,7 @@ type App struct { | |||||
| 79 | // Pane management (used by coordinators) | 86 | // Pane management (used by coordinators) | ||
| 80 | panesUC *usecase.ManagePanesUseCase | 87 | panesUC *usecase.ManagePanesUseCase | ||
| 81 | workspaceViews map[entity.TabID]*component.WorkspaceView | 88 | workspaceViews map[entity.TabID]*component.WorkspaceView | ||
| 89 | + | windowForTab map[entity.TabID]*browserWindow | |||
| 82 | widgetFactory layout.WidgetFactory | 90 | widgetFactory layout.WidgetFactory | ||
| 83 | stackedPaneMgr *component.StackedPaneManager | 91 | stackedPaneMgr *component.StackedPaneManager | ||
| 84 | 92 | ||||
| @@ -86,9 +94,8 @@ type App struct { | |||||
| 86 | keyboardHandler *input.KeyboardHandler | 94 | keyboardHandler *input.KeyboardHandler | ||
| 87 | globalShortcutHandler *input.GlobalShortcutHandler | 95 | globalShortcutHandler *input.GlobalShortcutHandler | ||
| 88 | 96 | ||||
| 89 | - | // Focus and border management | 97 | + | // Focus tracking stays app-global. |
| 90 | - | focusMgr *focus.Manager | 98 | + | focusMgr *focus.Manager |
| 91 | - | borderMgr *focus.BorderManager | |||
| 92 | 99 | ||||
| 93 | resizeModeBorderTarget layout.Widget | 100 | resizeModeBorderTarget layout.Widget | ||
| 94 | 101 | ||||
| @@ -106,18 +113,6 @@ type App struct { | |||||
| 106 | // Web content (managed by content.Coordinator) | 113 | // Web content (managed by content.Coordinator) | ||
| 107 | faviconAdapter *adapter.FaviconAdapter | 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 | snapshotService port.SnapshotService | 116 | snapshotService port.SnapshotService | ||
| 122 | 117 | ||||
| 123 | // Update management | 118 | // Update management | ||
| @@ -126,13 +121,13 @@ type App struct { | |||||
| 126 | // ID generator for tabs/panes | 121 | // ID generator for tabs/panes | ||
| 127 | idCounter uint64 | 122 | idCounter uint64 | ||
| 128 | idMu sync.Mutex | 123 | idMu sync.Mutex | ||
| 124 | + | windowIDCounter uint64 | |||
| 125 | + | windowIDMu sync.Mutex | |||
| 129 | firstWebViewShownOnce sync.Once | 126 | firstWebViewShownOnce sync.Once | ||
| 130 | 127 | ||||
| 131 | movePaneToTabUC *usecase.MovePaneToTabUseCase | 128 | movePaneToTabUC *usecase.MovePaneToTabUseCase | ||
| 132 | 129 | ||||
| 133 | // Accent picker for dead keys support | 130 | // Accent picker for dead keys support | ||
| 134 | - | accentPicker *component.AccentPicker | |||
| 135 | - | insertAccentUC *usecase.InsertAccentUseCase | |||
| 136 | accentFocusProvider port.FocusedInputProvider | 131 | accentFocusProvider port.FocusedInputProvider | ||
| 137 | 132 | ||||
| 138 | // Deferred initialization - runs after first load_started to avoid blocking initial navigation | 133 | // Deferred initialization - runs after first load_started to avoid blocking initial navigation | ||
| @@ -140,7 +135,9 @@ type App struct { | |||||
| 140 | deferredInitFn func() | 135 | deferredInitFn func() | ||
| 141 | 136 | ||||
| 142 | // lifecycle | 137 | // lifecycle | ||
| 143 | - | cancel context.CancelCauseFunc | 138 | + | cancel context.CancelCauseFunc |
| 139 | + | browserLaunchRelayOnce sync.Once | |||
| 140 | + | browserLaunchRelayCloser io.Closer | |||
| 144 | } | 141 | } | ||
| 145 | 142 | ||||
| 146 | type floatingWorkspaceSession struct { | 143 | type floatingWorkspaceSession struct { | ||
| @@ -172,14 +169,17 @@ func New(deps *Dependencies) (*App, error) { | |||||
| 172 | ctx, cancel := context.WithCancelCause(deps.Ctx) | 169 | ctx, cancel := context.WithCancelCause(deps.Ctx) | ||
| 173 | 170 | ||||
| 174 | app := &App{ | 171 | app := &App{ | ||
| 175 | - | deps: deps, | 172 | + | deps: deps, |
| 176 | - | tabs: entity.NewTabList(), | 173 | + | tabs: entity.NewTabList(), |
| 177 | - | tabsUC: deps.TabsUC, | 174 | + | tabsUC: deps.TabsUC, |
| 178 | - | panesUC: deps.PanesUC, | 175 | + | panesUC: deps.PanesUC, |
| 179 | - | workspaceViews: make(map[entity.TabID]*component.WorkspaceView), | 176 | + | workspaceViews: make(map[entity.TabID]*component.WorkspaceView), |
| 180 | - | floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession), | 177 | + | windowForTab: make(map[entity.TabID]*browserWindow), |
| 181 | - | engine: deps.Engine, | 178 | + | floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession), |
| 182 | - | cancel: cancel, | 179 | + | browserWindows: make(map[string]*browserWindow), |
| 180 | + | dispatchOnMainThread: func(fn func()) { fn() }, | |||
| 181 | + | engine: deps.Engine, | |||
| 182 | + | cancel: cancel, | |||
| 183 | } | 183 | } | ||
| 184 | // Register message handlers through the engine. | 184 | // Register message handlers through the engine. | ||
| 185 | if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{ | 185 | if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{ | ||
| @@ -194,12 +194,10 @@ func New(deps *Dependencies) (*App, error) { | |||||
| 194 | }}, | 194 | }}, | ||
| 195 | OnClipboardCopied: func(textLen int) { | 195 | OnClipboardCopied: func(textLen int) { | ||
| 196 | cb := glib.SourceFunc(func(_ uintptr) bool { | 196 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| 197 | - | if app.appToaster != nil { | 197 | + | app.showToastOnLastFocusedBrowserWindow(ctx, "Copied to clipboard", component.ToastInfo, |
| 198 | - | app.appToaster.Show(ctx, "Copied to clipboard", component.ToastInfo, | 198 | + | component.WithDuration(component.ToastBriefDurationMs), |
| 199 | - | component.WithDuration(component.ToastBriefDurationMs), | 199 | + | component.WithPosition(component.ToastPositionBottomRight), |
| 200 | - | component.WithPosition(component.ToastPositionBottomRight), | 200 | + | ) |
| 201 | - | ) | |||
| 202 | - | } | |||
| 203 | return false | 201 | return false | ||
| 204 | }) | 202 | }) | ||
| 205 | glib.IdleAdd(&cb, 0) | 203 | glib.IdleAdd(&cb, 0) | ||
| @@ -244,6 +242,7 @@ func (a *App) Run(ctx context.Context, args []string) int { | |||||
| 244 | } | 242 | } | ||
| 245 | defer a.gtkApp.Unref() | 243 | defer a.gtkApp.Unref() | ||
| 246 | logging.Trace().Mark("gtk_app_created") | 244 | logging.Trace().Mark("gtk_app_created") | ||
| 245 | + | a.dispatchOnMainThread = a.runOnMainThread | |||
| 247 | 246 | ||||
| 248 | // Connect activate signal | 247 | // Connect activate signal | ||
| 249 | activateCb := func(_ gio.Application) { | 248 | activateCb := func(_ gio.Application) { | ||
| @@ -272,18 +271,16 @@ func (a *App) onActivate(ctx context.Context) { | |||||
| 272 | // Configure pool background color early (prevents white flash), but avoid | 271 | // Configure pool background color early (prevents white flash), but avoid | ||
| 273 | // synchronous prewarming that delays the first navigation on cold start. | 272 | // synchronous prewarming that delays the first navigation on cold start. | ||
| 274 | a.setupPoolBackgroundColor(ctx) | 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 | log.Error().Err(err).Msg("failed to create main window") | 277 | log.Error().Err(err).Msg("failed to create main window") | ||
| 278 | return | 278 | return | ||
| 279 | } | 279 | } | ||
| 280 | logging.Trace().Mark("window_created") | 280 | logging.Trace().Mark("window_created") | ||
| 281 | 281 | ||||
| 282 | - | a.initLayoutInfrastructure() | |||
| 283 | - | a.initAppToasterOverlay() | |||
| 284 | a.installCrashReportNotifier(ctx) | 282 | a.installCrashReportNotifier(ctx) | ||
| 285 | - | a.initFocusAndBorderOverlay() | 283 | + | a.initFocusManager() |
| 286 | - | a.initAccentPicker(ctx) | |||
| 287 | a.registerAccentHandlers(ctx) | 284 | a.registerAccentHandlers(ctx) | ||
| 288 | a.initDownloadHandler(ctx) | 285 | a.initDownloadHandler(ctx) | ||
| 289 | 286 | ||||
| @@ -293,8 +290,7 @@ func (a *App) onActivate(ctx context.Context) { | |||||
| 293 | a.initKeyboardHandler(ctx) | 290 | a.initKeyboardHandler(ctx) | ||
| 294 | a.initOmniboxConfig(ctx) | 291 | a.initOmniboxConfig(ctx) | ||
| 295 | a.initFindBarConfig(ctx) | 292 | a.initFindBarConfig(ctx) | ||
| 296 | - | a.initSessionManager(ctx) | 293 | + | a.wireSessionManagerShortcut() |
| 297 | - | a.initTabPicker(ctx) | |||
| 298 | a.initSnapshotService(ctx) | 294 | a.initSnapshotService(ctx) | ||
| 299 | a.initUpdateCoordinator(ctx) | 295 | a.initUpdateCoordinator(ctx) | ||
| 300 | a.createInitialTab(ctx) | 296 | a.createInitialTab(ctx) | ||
| @@ -380,19 +376,38 @@ func (a *App) prewarmWebViewPoolAsync(ctx context.Context) { | |||||
| 380 | pool.PrewarmAsync(ctx, 0) | 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 | log := logging.FromContext(ctx) | 390 | log := logging.FromContext(ctx) | ||
| 385 | mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config.Workspace.TabBarPosition) | 391 | mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config.Workspace.TabBarPosition) | ||
| 386 | if err != nil { | 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 | closeRequestCb := func(_ gtk.Window) bool { | 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 | return false | 408 | return false | ||
| 394 | } | 409 | } | ||
| 395 | - | a.mainWindow.Window().ConnectCloseRequest(&closeRequestCb) | 410 | + | mainWindow.Window().ConnectCloseRequest(&closeRequestCb) |
| 396 | 411 | ||||
| 397 | // Create permission popup and dialog presenter | 412 | // Create permission popup and dialog presenter | ||
| 398 | if a.deps != nil && a.deps.PermissionUC != nil { | 413 | if a.deps != nil && a.deps.PermissionUC != nil { | ||
| @@ -404,10 +419,9 @@ func (a *App) createMainWindow(ctx context.Context) error { | |||||
| 404 | if permPopup != nil { | 419 | if permPopup != nil { | ||
| 405 | // Add popup to the main window's content overlay | 420 | // Add popup to the main window's content overlay | ||
| 406 | if w := permPopup.Widget(); w != nil { | 421 | if w := permPopup.Widget(); w != nil { | ||
| 407 | - | a.mainWindow.AddOverlay(w) | 422 | + | mainWindow.AddOverlay(w) |
| 408 | } | 423 | } | ||
| 409 | - | permDialog := dialog.NewPermissionDialog(permPopup) | 424 | + | browserWindow.permissionDialog = dialog.NewPermissionDialog(permPopup) |
| 410 | - | a.deps.PermissionUC.SetDialogPresenter(permDialog) | |||
| 411 | } | 425 | } | ||
| 412 | } | 426 | } | ||
| 413 | 427 | ||||
| @@ -415,155 +429,417 @@ func (a *App) createMainWindow(ctx context.Context) error { | |||||
| 415 | indicator := component.NewWebRTCPermissionIndicator() | 429 | indicator := component.NewWebRTCPermissionIndicator() | ||
| 416 | if indicator != nil { | 430 | if indicator != nil { | ||
| 417 | if w := indicator.Widget(); w != nil { | 431 | if w := indicator.Widget(); w != nil { | ||
| 418 | - | a.mainWindow.AddOverlay(w) | 432 | + | mainWindow.AddOverlay(w) |
| 419 | - | a.webrtcIndicator = indicator | |||
| 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 | // Apply GTK CSS styling from theme manager. | 445 | // Apply GTK CSS styling from theme manager. | ||
| 424 | if a.deps == nil || a.deps.Theme == nil { | 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 | a.deps.Theme.ApplyToDisplay(ctx, display) | 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 | return nil | 470 | return nil | ||
| 431 | } | 471 | } | ||
| 432 | 472 | ||||
| 433 | - | func (a *App) initLayoutInfrastructure() { | 473 | + | func (a *App) browserWindowForTab(tabID entity.TabID) *browserWindow { |
| 434 | - | // Initialize widget factory for pane layout. | 474 | + | if a.windowForTab != nil { |
| 435 | - | a.widgetFactory = layout.NewGtkWidgetFactory() | 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. | 485 | + | func (a *App) browserWindowForPane(paneID entity.PaneID) *browserWindow { |
| 438 | - | a.stackedPaneMgr = component.NewStackedPaneManager(a.widgetFactory) | 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() { | 500 | + | func (a *App) lastFocusedBrowserWindow() *browserWindow { |
| 442 | - | if a.mainWindow == nil { | 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 | return | 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. | 547 | + | func (a *App) handlePaneFullscreenChanged(paneID entity.PaneID, entering bool) { |
| 447 | - | a.appToaster = component.NewToaster(a.widgetFactory) | 548 | + | bw := a.browserWindowForPane(paneID) |
| 448 | - | toasterWidget := a.appToaster.Widget() | 549 | + | if bw == nil || bw.mainWindow == nil || bw.mainWindow.TabBar() == nil { |
| 449 | - | if toasterWidget == nil { | |||
| 450 | return | 550 | return | ||
| 451 | } | 551 | } | ||
| 452 | - | gtkWidget := toasterWidget.GtkWidget() | 552 | + | if entering { |
| 453 | - | if gtkWidget == nil { | 553 | + | bw.mainWindow.TabBar().SetVisible(false) |
| 454 | return | 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. | 559 | + | func (a *App) updateBrowserWindowTabBarVisibility(bw *browserWindow) { |
| 459 | - | a.modeToaster = component.NewToaster(a.widgetFactory) | 560 | + | if bw == nil || bw.mainWindow == nil || bw.mainWindow.TabBar() == nil { |
| 460 | - | modeToasterWidget := a.modeToaster.Widget() | |||
| 461 | - | if modeToasterWidget == nil { | |||
| 462 | return | 561 | return | ||
| 463 | } | 562 | } | ||
| 464 | - | modeGtkWidget := modeToasterWidget.GtkWidget() | 563 | + | if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.HideTabBarWhenSingleTab { |
| 465 | - | if modeGtkWidget == nil { | 564 | + | bw.mainWindow.TabBar().SetVisible(true) |
| 466 | return | 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) { | 571 | + | func (a *App) activateBrowserWindow(bw *browserWindow) { |
| 472 | - | if a.deps == nil { | 572 | + | if bw == nil { |
| 473 | return | 573 | return | ||
| 474 | } | 574 | } | ||
| 475 | - | 575 | + | a.lastFocusedWindowID = bw.id | |
| 476 | - | a.deps.OnCrashReportsDetected = func(paths []string) { | 576 | + | if bw.mainWindow != nil { |
| 477 | - | a.showCrashReportToast(ctx, paths) | 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() { | 590 | + | func (a *App) handleBrowserWindowActivationChanged(bw *browserWindow, active bool) { |
| 482 | - | // Initialize focus manager for geometric navigation. | 591 | + | if !active || bw == nil { |
| 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 { | |||
| 490 | return | 592 | return | ||
| 491 | } | 593 | } | ||
| 492 | - | borderWidget := a.borderMgr.Widget() | 594 | + | a.activateBrowserWindow(bw) |
| 493 | - | if borderWidget == nil { | 595 | + | } |
| 596 | + | ||||
| 597 | + | func (a *App) wireBrowserWindowActivationTracking(bw *browserWindow) { | |||
| 598 | + | if bw == nil || bw.mainWindow == nil { | |||
| 494 | return | 599 | return | ||
| 495 | } | 600 | } | ||
| 496 | - | gtkWidget := borderWidget.GtkWidget() | 601 | + | bw.mainWindow.ConnectActiveNotify(func(active bool) { |
| 497 | - | if gtkWidget == nil { | 602 | + | a.handleBrowserWindowActivationChanged(bw, active) |
| 603 | + | }) | |||
| 604 | + | } | |||
| 605 | + | ||||
| 606 | + | func (a *App) setBrowserWindowForTab(tabID entity.TabID, bw *browserWindow) { | |||
| 607 | + | if bw == nil { | |||
| 498 | return | 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) { | 617 | + | func (a *App) tabBarForBrowserWindow(bw *browserWindow) *component.TabBar { |
| 504 | - | log := logging.FromContext(ctx) | 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 { | 627 | + | func (a *App) startBrowserLaunchRelayListener(ctx context.Context) { |
| 507 | - | log.Debug().Msg("widget factory or main window not available, skipping accent picker") | 628 | + | if a == nil || a.deps == nil || a.deps.BrowserLaunchRelay == nil { |
| 508 | return | 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 | 641 | + | func (a *App) closeBrowserLaunchRelayListener() { |
| 512 | - | a.accentPicker = component.NewAccentPicker(a.widgetFactory) | 642 | + | if a.browserLaunchRelayCloser == nil { |
| 513 | - | if a.accentPicker == nil { | |||
| 514 | - | log.Warn().Msg("failed to create accent picker") | |||
| 515 | return | 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 | 656 | + | if a.tabs == nil || a.tabs.Count() == 0 { |
| 519 | - | pickerWidget := a.accentPicker.Widget() | 657 | + | a.activateBrowserWindow(created) |
| 520 | - | if pickerWidget != nil { | 658 | + | return nil |
| 521 | - | gtkWidget := pickerWidget.GtkWidget() | 659 | + | } |
| 522 | - | if gtkWidget != nil { | 660 | + | |
| 523 | - | a.mainWindow.AddOverlay(gtkWidget) | 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) | 724 | + | func (a *App) openFreshWindowWithTabsUC(ctx context.Context, url string, created *browserWindow) error { |
| 528 | - | a.accentFocusProvider = a.deps.AccentFocusProvider | 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 | 775 | + | func (a *App) initialWindowURL() string { |
| 531 | - | a.insertAccentUC = usecase.NewInsertAccentUseCase( | 776 | + | if a.deps != nil && a.deps.InitialURL != "" { |
| 532 | - | a.accentFocusProvider, | 777 | + | return a.deps.InitialURL |
| 533 | - | a.accentPicker, | 778 | + | } |
| 534 | - | func(fn func()) { | 779 | + | return "dumb://home" |
| 535 | - | cb := glib.SourceFunc(func(_ uintptr) bool { | 780 | + | } |
| 536 | - | fn() | 781 | + | |
| 537 | - | return false // Don't repeat | 782 | + | func (a *App) initLayoutInfrastructure() { |
| 538 | - | }) | 783 | + | // Initialize widget factory for pane layout. |
| 539 | - | glib.IdleAdd(&cb, 0) | 784 | + | a.widgetFactory = layout.NewGtkWidgetFactory() |
| 540 | - | }, | |||
| 541 | - | ) | |||
| 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 | // registerAccentHandlers registers the accent key press/release message handlers | 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 | func (a *App) registerAccentHandlers(ctx context.Context) { | 820 | func (a *App) registerAccentHandlers(ctx context.Context) { | ||
| 549 | log := logging.FromContext(ctx) | 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 | if a.engine == nil { | 823 | if a.engine == nil { | ||
| 556 | log.Debug().Msg("engine not available, skipping accent handler registration") | 824 | log.Debug().Msg("engine not available, skipping accent handler registration") | ||
| 557 | return | 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 | log.Error().Err(err).Msg("failed to register accent handlers") | 829 | log.Error().Err(err).Msg("failed to register accent handlers") | ||
| 562 | } else { | 830 | } else { | ||
| 563 | log.Info().Msg("registered accent key handlers") | 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 | func (a *App) initDownloadHandler(ctx context.Context) { | 843 | func (a *App) initDownloadHandler(ctx context.Context) { | ||
| 568 | log := logging.FromContext(ctx) | 844 | log := logging.FromContext(ctx) | ||
| 569 | 845 | ||||
| @@ -617,17 +893,13 @@ type downloadEventAdapter struct { | |||||
| 617 | func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) { | 893 | func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) { | ||
| 618 | // Must schedule on GTK main thread. | 894 | // Must schedule on GTK main thread. | ||
| 619 | cb := glib.SourceFunc(func(_ uintptr) bool { | 895 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| 620 | - | if d.app.appToaster == nil { | |||
| 621 | - | return false | |||
| 622 | - | } | |||
| 623 | - | ||||
| 624 | switch event.Type { | 896 | switch event.Type { | ||
| 625 | case port.DownloadEventStarted: | 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 | case port.DownloadEventFinished: | 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 | case port.DownloadEventFailed: | 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 | return false | 904 | return false | ||
| 633 | }) | 905 | }) | ||
| @@ -647,57 +919,51 @@ func (a *autoCopyConfigFn) IsAutoCopyEnabled() bool { | |||||
| 647 | } | 919 | } | ||
| 648 | 920 | ||||
| 649 | func (a *App) initKeyboardHandler(ctx context.Context) { | 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 | log := logging.FromContext(ctx) | 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 | return | 936 | return | ||
| 654 | } | 937 | } | ||
| 655 | 938 | ||||
| 656 | - | // Create keyboard handler and wire to dispatcher. | 939 | + | bw.keyboardHandler = input.NewKeyboardHandler(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session) |
| 657 | - | a.keyboardHandler = input.NewKeyboardHandler(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session) | 940 | + | bw.keyboardHandler.SetOnAction(func(actionCtx context.Context, action input.Action) error { |
| 658 | - | a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error { | 941 | + | a.activateBrowserWindow(bw) |
| 659 | if action == input.ActionClosePane { | 942 | if action == input.ActionClosePane { | ||
| 660 | - | if a.closeAndReleaseActiveFloatingPane(ctx) { | 943 | + | if a.closeAndReleaseActiveFloatingPane(actionCtx) { |
| 661 | return nil | 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 { | 949 | + | bw.keyboardHandler.SetOnEscape(func(escapeCtx context.Context) bool { |
| 667 | - | return a.handleGlobalEscape(ctx) | 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 | a.handleModeChange(ctx, from, to) | 955 | a.handleModeChange(ctx, from, to) | ||
| 671 | }) | 956 | }) | ||
| 672 | - | // Wire key routing policy. | 957 | + | bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute { |
| 673 | - | // Determines how each key event should be handled based on UI state: | 958 | + | if bw.sessionManager != nil && bw.sessionManager.IsVisible() { |
| 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() { | |||
| 682 | return input.RoutePassToWidget | 959 | return input.RoutePassToWidget | ||
| 683 | } | 960 | } | ||
| 684 | - | if a.tabPicker != nil && a.tabPicker.IsVisible() { | 961 | + | if bw.tabPicker != nil && bw.tabPicker.IsVisible() { |
| 685 | return input.RoutePassToWidget | 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 | if a.accentFocusProvider != nil { | 965 | if a.accentFocusProvider != nil { | ||
| 697 | if _, ok := a.accentFocusProvider.GetFocusedInput().(port.EntryInputTarget); ok { | 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 | if kc.Modifiers&input.ModAlt != 0 { | 967 | if kc.Modifiers&input.ModAlt != 0 { | ||
| 702 | return input.RouteHandleShortcuts | 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 | if input.IsShortcutModified(kc.Modifiers) { | 974 | if input.IsShortcutModified(kc.Modifiers) { | ||
| 711 | return input.RouteHandleShortcuts | 975 | return input.RouteHandleShortcuts | ||
| 712 | } | 976 | } | ||
| @@ -714,37 +978,48 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||||
| 714 | return input.RoutePassToWidget | 978 | return input.RoutePassToWidget | ||
| 715 | } | 979 | } | ||
| 716 | 980 | ||||
| 717 | - | // Non-text, non-shortcut keys (F-keys, arrows, etc.): shortcut system | |||
| 718 | return input.RouteHandleShortcuts | 981 | return input.RouteHandleShortcuts | ||
| 719 | }) | 982 | }) | ||
| 720 | - | // Wire accent handler for dead keys support | 983 | + | bw.keyboardHandler.SetAccentHandler(a) |
| 721 | - | if a.insertAccentUC != nil { | 984 | + | bw.keyboardHandler.AttachTo(bw.mainWindow.Window()) |
| 722 | - | a.keyboardHandler.SetAccentHandler(a.insertAccentUC) | |||
| 723 | - | } | |||
| 724 | - | a.keyboardHandler.AttachTo(a.mainWindow.Window()) | |||
| 725 | 985 | ||||
| 726 | - | // Create global shortcut handler for Alt+1-9 tab switching. | 986 | + | bw.globalShortcutHandler = input.NewGlobalShortcutHandler( |
| 727 | - | // This uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL | |||
| 728 | - | // to intercept shortcuts even when WebView has focus. | |||
| 729 | - | a.globalShortcutHandler = input.NewGlobalShortcutHandler( | |||
| 730 | ctx, | 987 | ctx, | ||
| 731 | - | a.mainWindow.Window(), | 988 | + | bw.mainWindow.Window(), |
| 732 | &a.deps.Config.Workspace, | 989 | &a.deps.Config.Workspace, | ||
| 733 | &a.deps.Config.Session, | 990 | &a.deps.Config.Session, | ||
| 734 | - | a.keyboardHandler, | 991 | + | bw.keyboardHandler, |
| 735 | - | func(ctx context.Context, action input.Action) error { | 992 | + | func(actionCtx context.Context, action input.Action) error { |
| 736 | - | return a.kbDispatcher.Dispatch(ctx, action) | 993 | + | a.activateBrowserWindow(bw) |
| 994 | + | return a.kbDispatcher.Dispatch(actionCtx, action) | |||
| 737 | }, | 995 | }, | ||
| 738 | ) | 996 | ) | ||
| 739 | - | if a.globalShortcutHandler == nil { | 997 | + | if bw.globalShortcutHandler == nil { |
| 740 | - | log.Warn().Msg("global shortcut handler creation failed, shortcuts may not work when WebView has focus") | 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 | // handleAccentKeyPress handles accent key press events for GTK entry widgets | 1018 | // handleAccentKeyPress handles accent key press events for GTK entry widgets | ||
| 745 | // (omnibox and find bar). Returns true if the key was consumed. | 1019 | // (omnibox and find bar). Returns true if the key was consumed. | ||
| 746 | func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.ModifierType) bool { | 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 | return false | 1023 | return false | ||
| 749 | } | 1024 | } | ||
| 750 | if state&(gdk.ControlMaskValue|gdk.AltMaskValue) != 0 { | 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 | shiftHeld := state&gdk.ShiftMaskValue != 0 | 1028 | shiftHeld := state&gdk.ShiftMaskValue != 0 | ||
| 754 | if char := input.KeyvalToRune(keyval); char != 0 { | 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 | return false | 1032 | return false | ||
| 758 | } | 1033 | } | ||
| @@ -760,12 +1035,45 @@ func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.M | |||||
| 760 | // handleAccentKeyRelease handles accent key release events for GTK entry widgets | 1035 | // handleAccentKeyRelease handles accent key release events for GTK entry widgets | ||
| 761 | // (omnibox and find bar). | 1036 | // (omnibox and find bar). | ||
| 762 | func (a *App) handleAccentKeyRelease(ctx context.Context, keyval uint) { | 1037 | func (a *App) handleAccentKeyRelease(ctx context.Context, keyval uint) { | ||
| 763 | - | if a.insertAccentUC == nil { | 1038 | + | handler := a.activeAccentHandler() |
| 1039 | + | if handler == nil { | |||
| 764 | return | 1040 | return | ||
| 765 | } | 1041 | } | ||
| 766 | if char := input.KeyvalToRune(keyval); char != 0 { | 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 | type omniboxCallbacks struct { | 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 | // ToggleSessionManager shows or hides the session manager. | 1242 | // ToggleSessionManager shows or hides the session manager. | ||
| 1008 | func (a *App) ToggleSessionManager(ctx context.Context) { | 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 | return | 1246 | return | ||
| 1011 | } | 1247 | } | ||
| 1012 | - | a.sessionManager.Toggle(ctx) | 1248 | + | bw.sessionManager.Toggle(ctx) |
| 1013 | } | 1249 | } | ||
| 1014 | 1250 | ||||
| 1015 | func (a *App) attachTabPickerToActivePane() { | 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 | return | 1254 | return | ||
| 1018 | } | 1255 | } | ||
| 1019 | wsView := a.activeWorkspaceView() | 1256 | wsView := a.activeWorkspaceView() | ||
| @@ -1034,24 +1271,24 @@ func (a *App) attachTabPickerToActivePane() { | |||||
| 1034 | return | 1271 | return | ||
| 1035 | } | 1272 | } | ||
| 1036 | 1273 | ||||
| 1037 | - | if a.tabPickerWidget == nil { | 1274 | + | if bw.tabPickerWidget == nil { |
| 1038 | - | a.tabPickerWidget = a.tabPicker.WidgetAsLayout(a.widgetFactory) | 1275 | + | bw.tabPickerWidget = bw.tabPicker.WidgetAsLayout(a.widgetFactory) |
| 1039 | - | if a.tabPickerWidget == nil { | 1276 | + | if bw.tabPickerWidget == nil { |
| 1040 | return | 1277 | return | ||
| 1041 | } | 1278 | } | ||
| 1042 | } | 1279 | } | ||
| 1043 | 1280 | ||||
| 1044 | // If currently attached to a different pane overlay, detach. | 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 | for _, view := range a.workspaceViews { | 1283 | for _, view := range a.workspaceViews { | ||
| 1047 | if view == nil { | 1284 | if view == nil { | ||
| 1048 | continue | 1285 | continue | ||
| 1049 | } | 1286 | } | ||
| 1050 | - | if oldPV := view.GetPaneView(a.tabPickerPaneID); oldPV != nil { | 1287 | + | if oldPV := view.GetPaneView(bw.tabPickerPaneID); oldPV != nil { |
| 1051 | - | if parent := a.tabPickerWidget.GetParent(); parent == oldPV.Overlay() { | 1288 | + | if parent := bw.tabPickerWidget.GetParent(); parent == oldPV.Overlay() { |
| 1052 | - | oldPV.RemoveOverlayWidget(a.tabPickerWidget) | 1289 | + | oldPV.RemoveOverlayWidget(bw.tabPickerWidget) |
| 1053 | } else if parent != nil { | 1290 | } else if parent != nil { | ||
| 1054 | - | a.tabPickerWidget.Unparent() | 1291 | + | bw.tabPickerWidget.Unparent() |
| 1055 | } | 1292 | } | ||
| 1056 | break | 1293 | break | ||
| 1057 | } | 1294 | } | ||
| @@ -1059,20 +1296,21 @@ func (a *App) attachTabPickerToActivePane() { | |||||
| 1059 | } | 1296 | } | ||
| 1060 | 1297 | ||||
| 1061 | // Ensure the widget can be reparented. | 1298 | // Ensure the widget can be reparented. | ||
| 1062 | - | if parent := a.tabPickerWidget.GetParent(); parent != nil { | 1299 | + | if parent := bw.tabPickerWidget.GetParent(); parent != nil { |
| 1063 | - | a.tabPickerWidget.Unparent() | 1300 | + | bw.tabPickerWidget.Unparent() |
| 1064 | } | 1301 | } | ||
| 1065 | 1302 | ||||
| 1066 | - | a.tabPicker.SetParentOverlay(pv.Overlay()) | 1303 | + | bw.tabPicker.SetParentOverlay(pv.Overlay()) |
| 1067 | - | pv.AddOverlayWidget(a.tabPickerWidget) | 1304 | + | pv.AddOverlayWidget(bw.tabPickerWidget) |
| 1068 | - | a.tabPickerPaneID = activePaneID | 1305 | + | bw.tabPickerPaneID = activePaneID |
| 1069 | } | 1306 | } | ||
| 1070 | 1307 | ||||
| 1071 | func (a *App) HandleMovePaneToTab(ctx context.Context) error { | 1308 | func (a *App) HandleMovePaneToTab(ctx context.Context) error { | ||
| 1072 | if a.movePaneToTabUC == nil { | 1309 | if a.movePaneToTabUC == nil { | ||
| 1073 | return nil | 1310 | return nil | ||
| 1074 | } | 1311 | } | ||
| 1075 | - | if a.tabPicker == nil { | 1312 | + | bw := a.lastFocusedBrowserWindow() |
| 1313 | + | if bw == nil || bw.tabPicker == nil { | |||
| 1076 | return nil | 1314 | return nil | ||
| 1077 | } | 1315 | } | ||
| 1078 | 1316 | ||||
| @@ -1104,7 +1342,7 @@ func (a *App) HandleMovePaneToTab(ctx context.Context) error { | |||||
| 1104 | items = append(items, component.TabPickerItem{IsNew: true, Index: -1}) | 1342 | items = append(items, component.TabPickerItem{IsNew: true, Index: -1}) | ||
| 1105 | 1343 | ||||
| 1106 | a.attachTabPickerToActivePane() | 1344 | a.attachTabPickerToActivePane() | ||
| 1107 | - | a.tabPicker.Show(ctx, items) | 1345 | + | bw.tabPicker.Show(ctx, items) |
| 1108 | return nil | 1346 | return nil | ||
| 1109 | } | 1347 | } | ||
| 1110 | 1348 | ||||
| @@ -1135,6 +1373,17 @@ func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID) | |||||
| 1135 | return nil | 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 | out, err := a.movePaneToTabUC.Execute(*in) | 1387 | out, err := a.movePaneToTabUC.Execute(*in) | ||
| 1139 | if err != nil { | 1388 | if err != nil { | ||
| 1140 | return err | 1389 | return err | ||
| @@ -1142,14 +1391,24 @@ func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID) | |||||
| 1142 | if out == nil || out.TargetTab == nil { | 1391 | if out == nil || out.TargetTab == nil { | ||
| 1143 | return nil | 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) | 1398 | + | a.applyMovePaneToTabUI(ctx, out, sourceTab, sourceWindow, targetWindow) |
| 1147 | - | a.switchToTargetTabIfConfigured(ctx, out) | 1399 | + | a.switchToTargetTabIfConfigured(ctx, out, targetWindow) |
| 1148 | a.updateTabBarVisibility(ctx) | 1400 | a.updateTabBarVisibility(ctx) | ||
| 1149 | a.MarkDirty() | 1401 | a.MarkDirty() | ||
| 1150 | return nil | 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 | func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePaneToTabInput, *entity.Tab) { | 1412 | func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePaneToTabInput, *entity.Tab) { | ||
| 1154 | if a.movePaneToTabUC == nil { | 1413 | if a.movePaneToTabUC == nil { | ||
| 1155 | return nil, nil | 1414 | return nil, nil | ||
| @@ -1172,7 +1431,13 @@ func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePa | |||||
| 1172 | return in, activeTab | 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 | if out == nil || out.TargetTab == nil { | 1441 | if out == nil || out.TargetTab == nil { | ||
| 1177 | return | 1442 | return | ||
| 1178 | } | 1443 | } | ||
| @@ -1182,10 +1447,10 @@ func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToT | |||||
| 1182 | } | 1447 | } | ||
| 1183 | 1448 | ||||
| 1184 | if out.SourceTabClosed { | 1449 | if out.SourceTabClosed { | ||
| 1185 | - | a.removeSourceTabUI(sourceTabID) | 1450 | + | a.removeSourceTabUI(sourceTabID, sourceWindow) |
| 1186 | } | 1451 | } | ||
| 1187 | if out.NewTabCreated { | 1452 | if out.NewTabCreated { | ||
| 1188 | - | a.ensureTargetTabUI(ctx, out.TargetTab) | 1453 | + | a.ensureTargetTabUI(ctx, out.TargetTab, targetWindow) |
| 1189 | } | 1454 | } | ||
| 1190 | 1455 | ||||
| 1191 | if !out.SourceTabClosed { | 1456 | if !out.SourceTabClosed { | ||
| @@ -1194,20 +1459,20 @@ func (a *App) applyMovePaneToTabUI(ctx context.Context, out *usecase.MovePaneToT | |||||
| 1194 | a.rebuildAndAttachWorkspace(ctx, out.TargetTab.ID, out.TargetTab) | 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 | a.releaseFloatingSessionsForTab(context.Background(), sourceTabID) | 1463 | a.releaseFloatingSessionsForTab(context.Background(), sourceTabID) | ||
| 1199 | delete(a.workspaceViews, sourceTabID) | 1464 | delete(a.workspaceViews, sourceTabID) | ||
| 1200 | - | if a.mainWindow != nil && a.mainWindow.TabBar() != nil { | 1465 | + | if tabBar := a.tabBarForBrowserWindow(sourceWindow); tabBar != nil { |
| 1201 | - | a.mainWindow.TabBar().RemoveTab(sourceTabID) | 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 | if tab == nil { | 1471 | if tab == nil { | ||
| 1207 | return | 1472 | return | ||
| 1208 | } | 1473 | } | ||
| 1209 | - | if a.mainWindow != nil && a.mainWindow.TabBar() != nil { | 1474 | + | if tabBar := a.tabBarForBrowserWindow(targetWindow); tabBar != nil { |
| 1210 | - | a.mainWindow.TabBar().AddTab(tab) | 1475 | + | tabBar.AddTab(tab) |
| 1211 | } | 1476 | } | ||
| 1212 | a.createWorkspaceViewWithoutAttach(ctx, tab) | 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 | if out == nil || out.TargetTab == nil { | 1528 | if out == nil || out.TargetTab == nil { | ||
| 1260 | return | 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 | if out.SourceTabClosed { | 1536 | if out.SourceTabClosed { | ||
| 1265 | switchToTarget = true | 1537 | switchToTarget = true | ||
| 1266 | } | 1538 | } | ||
| @@ -1269,8 +1541,14 @@ func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.Mo | |||||
| 1269 | } | 1541 | } | ||
| 1270 | 1542 | ||||
| 1271 | a.tabs.SetActive(out.TargetTab.ID) | 1543 | a.tabs.SetActive(out.TargetTab.ID) | ||
| 1272 | - | if a.mainWindow != nil && a.mainWindow.TabBar() != nil { | 1544 | + | if targetWindow != nil { |
| 1273 | - | a.mainWindow.TabBar().SetActive(out.TargetTab.ID) | 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 | a.switchWorkspaceView(ctx, out.TargetTab.ID) | 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 | func (a *App) initSnapshotService(ctx context.Context) { | 1562 | func (a *App) initSnapshotService(ctx context.Context) { | ||
| 1321 | log := logging.FromContext(ctx) | 1563 | log := logging.FromContext(ctx) | ||
| 1322 | 1564 | ||||
| @@ -1356,7 +1598,9 @@ func (a *App) initUpdateCoordinator(ctx context.Context) { | |||||
| 1356 | a.updateCoord = coordinator.NewUpdateCoordinator( | 1598 | a.updateCoord = coordinator.NewUpdateCoordinator( | ||
| 1357 | a.deps.CheckUpdateUC, | 1599 | a.deps.CheckUpdateUC, | ||
| 1358 | a.deps.ApplyUpdateUC, | 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 | a.deps.Config.Update.EnableOnStartup, | 1604 | a.deps.Config.Update.EnableOnStartup, | ||
| 1361 | a.deps.Config.Update.AutoDownload, | 1605 | a.deps.Config.Update.AutoDownload, | ||
| 1362 | ) | 1606 | ) | ||
| @@ -1395,29 +1639,59 @@ func (a *App) createInitialTab(ctx context.Context) { | |||||
| 1395 | if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil { | 1639 | if err := a.restoreSession(ctx, entity.SessionID(a.deps.RestoreSessionID)); err != nil { | ||
| 1396 | log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab") | 1640 | log.Error().Err(err).Str("session_id", a.deps.RestoreSessionID).Msg("failed to restore session, creating default tab") | ||
| 1397 | // Show error toast and fall through to create default tab | 1641 | // Show error toast and fall through to create default tab | ||
| 1398 | - | if a.appToaster != nil { | 1642 | + | a.showToastOnLastFocusedBrowserWindow(ctx, "Session restore failed", component.ToastWarning) |
| 1399 | - | a.appToaster.Show(ctx, "Session restore failed", component.ToastWarning) | |||
| 1400 | - | } | |||
| 1401 | } else { | 1643 | } else { | ||
| 1402 | log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored") | 1644 | log.Info().Str("session_id", a.deps.RestoreSessionID).Msg("session restored") | ||
| 1403 | // Show success toast for session restoration | 1645 | // Show success toast for session restoration | ||
| 1404 | - | if a.appToaster != nil { | 1646 | + | a.showToastOnLastFocusedBrowserWindow(ctx, "Session restored", component.ToastSuccess) |
| 1405 | - | a.appToaster.Show(ctx, "Session restored", component.ToastSuccess) | |||
| 1406 | - | } | |||
| 1407 | return | 1647 | return | ||
| 1408 | } | 1648 | } | ||
| 1409 | } | 1649 | } | ||
| 1410 | 1650 | ||||
| 1411 | // Create an initial tab using coordinator. | 1651 | // Create an initial tab using coordinator. | ||
| 1412 | - | initialURL := "dumb://home" | 1652 | + | if _, err := a.tabCoord.Create(ctx, a.initialWindowURL()); err != nil { |
| 1413 | - | if a.deps != nil && a.deps.InitialURL != "" { | |||
| 1414 | - | initialURL = a.deps.InitialURL | |||
| 1415 | - | } | |||
| 1416 | - | if _, err := a.tabCoord.Create(ctx, initialURL); err != nil { | |||
| 1417 | log.Error().Err(err).Msg("failed to create initial tab") | 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 | func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error { | 1695 | func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error { | ||
| 1422 | log := logging.FromContext(ctx) | 1696 | log := logging.FromContext(ctx) | ||
| 1423 | 1697 | ||||
| @@ -1443,6 +1717,8 @@ func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) er | |||||
| 1443 | return fmt.Errorf("failed to build tab list from snapshot") | 1717 | return fmt.Errorf("failed to build tab list from snapshot") | ||
| 1444 | } | 1718 | } | ||
| 1445 | 1719 | ||||
| 1720 | + | a.clearSessionRestoreUIState(ctx) | |||
| 1721 | + | ||||
| 1446 | // Replace tabs in-place to preserve references held by TabCoordinator | 1722 | // Replace tabs in-place to preserve references held by TabCoordinator | ||
| 1447 | a.tabs.ReplaceFrom(restoredTabs) | 1723 | a.tabs.ReplaceFrom(restoredTabs) | ||
| 1448 | 1724 | ||||
| @@ -1451,6 +1727,10 @@ func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) er | |||||
| 1451 | tabBar := a.mainWindow.TabBar() | 1727 | tabBar := a.mainWindow.TabBar() | ||
| 1452 | for _, tab := range a.tabs.Tabs { | 1728 | for _, tab := range a.tabs.Tabs { | ||
| 1453 | a.createWorkspaceViewWithoutAttach(ctx, tab) | 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 | if tabBar != nil { | 1734 | if tabBar != nil { | ||
| 1455 | tabBar.AddTab(tab) | 1735 | tabBar.AddTab(tab) | ||
| 1456 | } | 1736 | } | ||
| @@ -1480,6 +1760,7 @@ func (a *App) finalizeActivation(ctx context.Context) { | |||||
| 1480 | if a.mainWindow != nil { | 1760 | if a.mainWindow != nil { | ||
| 1481 | a.mainWindow.Show() | 1761 | a.mainWindow.Show() | ||
| 1482 | } | 1762 | } | ||
| 1763 | + | a.startBrowserLaunchRelayListener(ctx) | |||
| 1483 | log.Info().Msg("main window displayed") | 1764 | log.Info().Msg("main window displayed") | ||
| 1484 | 1765 | ||||
| 1485 | if a.deps != nil && len(a.deps.StartupCrashReports) > 0 { | 1766 | if a.deps != nil && len(a.deps.StartupCrashReports) > 0 { | ||
| @@ -1505,13 +1786,11 @@ func (a *App) showCrashReportToast(ctx context.Context, paths []string) { | |||||
| 1505 | log.Warn().Int("count", len(paths)).Msg("unexpected-close reports available") | 1786 | log.Warn().Int("count", len(paths)).Msg("unexpected-close reports available") | ||
| 1506 | 1787 | ||||
| 1507 | cb := glib.SourceFunc(func(_ uintptr) bool { | 1788 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| 1508 | - | if a.appToaster != nil { | 1789 | + | msg := fmt.Sprintf("Detected %d unexpected close report(s). Run: dumber crashes issue latest", len(paths)) |
| 1509 | - | msg := fmt.Sprintf("Detected %d unexpected close report(s). Run: dumber crashes issue latest", len(paths)) | 1790 | + | a.showToastOnLastFocusedBrowserWindow(ctx, msg, component.ToastWarning, |
| 1510 | - | a.appToaster.Show(ctx, msg, component.ToastWarning, | 1791 | + | component.WithDuration(crashReportToastDurationMs), |
| 1511 | - | component.WithDuration(crashReportToastDurationMs), | 1792 | + | component.WithPosition(component.ToastPositionBottomRight), |
| 1512 | - | component.WithPosition(component.ToastPositionBottomRight), | 1793 | + | ) |
| 1513 | - | ) | |||
| 1514 | - | } | |||
| 1515 | return false | 1794 | return false | ||
| 1516 | }) | 1795 | }) | ||
| 1517 | glib.IdleAdd(&cb, 0) | 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 | // Cancel context to signal all goroutines | 1848 | // Cancel context to signal all goroutines | ||
| 1567 | a.cancel(errors.New("application shutdown")) | 1849 | a.cancel(errors.New("application shutdown")) | ||
| 1568 | 1850 | ||||
| @@ -1666,6 +1948,10 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 1666 | a.createWorkspaceView(ctx, tab) | 1948 | a.createWorkspaceView(ctx, tab) | ||
| 1667 | }) | 1949 | }) | ||
| 1668 | a.tabCoord.SetOnTabSwitched(func(ctx context.Context, tab *entity.Tab) { | 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 | a.switchWorkspaceView(ctx, tab.ID) | 1955 | a.switchWorkspaceView(ctx, tab.ID) | ||
| 1670 | }) | 1956 | }) | ||
| 1671 | a.tabCoord.SetOnQuit(a.Quit) | 1957 | a.tabCoord.SetOnQuit(a.Quit) | ||
| @@ -1675,27 +1961,13 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 1675 | a.attachPopupToTab(ctx, tabID, pane, wv) | 1961 | a.attachPopupToTab(ctx, tabID, pane, wv) | ||
| 1676 | }) | 1962 | }) | ||
| 1677 | 1963 | ||||
| 1678 | - | // Wire tab bar click handling to coordinator | 1964 | + | for _, bw := range a.browserWindows { |
| 1679 | - | if a.mainWindow != nil && a.mainWindow.TabBar() != nil { | 1965 | + | a.wireBrowserWindowTabBar(ctx, bw) |
| 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 | - | }) | |||
| 1685 | } | 1966 | } | ||
| 1686 | 1967 | ||||
| 1687 | // Set fullscreen callback to hide/show tab bar (after tabCoord is initialized) | 1968 | // Set fullscreen callback to hide/show tab bar (after tabCoord is initialized) | ||
| 1688 | - | a.contentCoord.SetOnFullscreenChanged(func(entering bool) { | 1969 | + | a.contentCoord.SetOnFullscreenChanged(func(paneID entity.PaneID, entering bool) { |
| 1689 | - | if a.mainWindow == nil || a.mainWindow.TabBar() == nil { | 1970 | + | a.handlePaneFullscreenChanged(paneID, entering) |
| 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 | - | } | |||
| 1699 | }) | 1971 | }) | ||
| 1700 | 1972 | ||||
| 1701 | // 3. Workspace Coordinator | 1973 | // 3. Workspace Coordinator | ||
| @@ -1768,8 +2040,8 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 1768 | }) | 2040 | }) | ||
| 1769 | 2041 | ||||
| 1770 | // Wire window title updates when active pane's title changes | 2042 | // Wire window title updates when active pane's title changes | ||
| 1771 | - | a.contentCoord.SetOnWindowTitleChanged(func(title string) { | 2043 | + | a.contentCoord.SetOnWindowTitleChanged(func(paneID entity.PaneID, title string) { |
| 1772 | - | a.updateWindowTitle(title) | 2044 | + | a.handlePaneWindowTitleChanged(paneID, title) |
| 1773 | }) | 2045 | }) | ||
| 1774 | 2046 | ||||
| 1775 | // Wire pane URI updates for session snapshots (searches all tabs) | 2047 | // Wire pane URI updates for session snapshots (searches all tabs) | ||
| @@ -1814,8 +2086,12 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 1814 | a.deps.ZoomUC, | 2086 | a.deps.ZoomUC, | ||
| 1815 | a.deps.CopyURLUC, | 2087 | a.deps.CopyURLUC, | ||
| 1816 | a.deps.Config.Workspace.NewPaneURL, | 2088 | a.deps.Config.Workspace.NewPaneURL, | ||
| 2089 | + | a.contentCoord.ActivePaneID, | |||
| 1817 | ) | 2090 | ) | ||
| 1818 | a.wireKeyboardActions() | 2091 | a.wireKeyboardActions() | ||
| 2092 | + | for _, bw := range a.browserWindows { | |||
| 2093 | + | a.initBrowserWindowInput(ctx, bw) | |||
| 2094 | + | } | |||
| 1819 | 2095 | ||||
| 1820 | log.Debug().Msg("coordinators initialized") | 2096 | log.Debug().Msg("coordinators initialized") | ||
| 1821 | } | 2097 | } | ||
| @@ -1838,10 +2114,16 @@ func (a *App) wireKeyboardActions() { | |||||
| 1838 | a.CloseFindBar(ctx) | 2114 | a.CloseFindBar(ctx) | ||
| 1839 | return nil | 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 | return a.HandleMovePaneToTab(ctx) | 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 | return a.HandleMovePaneToNextTab(ctx) | 2127 | return a.HandleMovePaneToNextTab(ctx) | ||
| 1846 | }) | 2128 | }) | ||
| 1847 | a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error { | 2129 | a.kbDispatcher.SetOnToggleFloatingPane(func(ctx context.Context) error { | ||
| @@ -1858,33 +2140,73 @@ func (a *App) wireKeyboardActions() { | |||||
| 1858 | } | 2140 | } | ||
| 1859 | 2141 | ||||
| 1860 | func (a *App) wireWebRTCPermissionIndicator() { | 2142 | func (a *App) wireWebRTCPermissionIndicator() { | ||
| 1861 | - | if a.webrtcIndicator == nil || a.contentCoord == nil { | 2143 | + | if a.contentCoord == nil { |
| 1862 | return | 2144 | return | ||
| 1863 | } | 2145 | } | ||
| 1864 | ctx := context.Background() | 2146 | ctx := context.Background() | ||
| 1865 | if a.deps != nil && a.deps.Ctx != nil { | 2147 | if a.deps != nil && a.deps.Ctx != nil { | ||
| 1866 | ctx = a.deps.Ctx | 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) { | 2151 | + | a.contentCoord.SetOnPermissionActivity(func( |
| 1871 | - | a.webrtcIndicator.SetOrigin(origin) | 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 | switch state { | 2166 | switch state { | ||
| 1874 | case content.PermissionActivityRequesting: | 2167 | case content.PermissionActivityRequesting: | ||
| 1875 | - | a.webrtcIndicator.MarkRequesting(permTypes) | 2168 | + | bw.webrtcIndicator.MarkRequesting(permTypes) |
| 1876 | case content.PermissionActivityAllowed: | 2169 | case content.PermissionActivityAllowed: | ||
| 1877 | - | a.webrtcIndicator.MarkAllowed(permTypes) | 2170 | + | bw.webrtcIndicator.MarkAllowed(permTypes) |
| 1878 | case content.PermissionActivityBlocked: | 2171 | case content.PermissionActivityBlocked: | ||
| 1879 | - | a.webrtcIndicator.MarkBlocked(permTypes) | 2172 | + | bw.webrtcIndicator.MarkBlocked(permTypes) |
| 1880 | } | 2173 | } | ||
| 1881 | 2174 | ||||
| 1882 | for _, permType := range permTypes { | 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 | if a.deps == nil || a.deps.PermissionUC == nil || origin == "" { | 2210 | if a.deps == nil || a.deps.PermissionUC == nil || origin == "" { | ||
| 1889 | return | 2211 | return | ||
| 1890 | } | 2212 | } | ||
| @@ -1909,27 +2231,17 @@ func (a *App) wireWebRTCPermissionIndicator() { | |||||
| 1909 | } | 2231 | } | ||
| 1910 | } | 2232 | } | ||
| 1911 | 2233 | ||||
| 1912 | - | a.syncWebRTCPermissionLockState(ctx, origin, permType) | 2234 | + | a.syncWebRTCPermissionLockState(ctx, bw.webrtcIndicator, 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 | - | } | |||
| 1928 | }) | 2235 | }) | ||
| 1929 | } | 2236 | } | ||
| 1930 | 2237 | ||||
| 1931 | - | func (a *App) syncWebRTCPermissionLockState(ctx context.Context, origin string, permType entity.PermissionType) { | 2238 | + | func (a *App) syncWebRTCPermissionLockState( |
| 1932 | - | if a.webrtcIndicator == nil || a.deps == nil || a.deps.PermissionUC == nil || origin == "" { | 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 | return | 2245 | return | ||
| 1934 | } | 2246 | } | ||
| 1935 | 2247 | ||||
| @@ -1944,11 +2256,40 @@ func (a *App) syncWebRTCPermissionLockState(ctx context.Context, origin string, | |||||
| 1944 | } | 2256 | } | ||
| 1945 | 2257 | ||||
| 1946 | if record == nil { | 2258 | if record == nil { | ||
| 1947 | - | a.webrtcIndicator.SetStoredDecision(permType, entity.PermissionPrompt, false) | 2259 | + | indicator.SetStoredDecision(permType, entity.PermissionPrompt, false) |
| 1948 | return | 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 | // generateID generates a unique ID for tabs and panes. | 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 | // updateWindowTitle updates the window title with the given page title. | 2332 | // updateWindowTitle updates the window title with the given page title. | ||
| 1992 | // Format: "<Page Title> - Dumber" or just "Dumber" if title is empty. | 2333 | // Format: "<Page Title> - Dumber" or just "Dumber" if title is empty. | ||
| 1993 | - | func (a *App) updateWindowTitle(pageTitle string) { | 2334 | + | func (a *App) updateWindowTitle(pageTitle string, target *browserWindow) { |
| 1994 | - | if a.mainWindow == nil { | 2335 | + | if target == nil { |
| 2336 | + | target = a.browserWindowForMainWindow(a.mainWindow) | |||
| 2337 | + | } | |||
| 2338 | + | if target == nil || target.mainWindow == nil { | |||
| 1995 | return | 2339 | return | ||
| 1996 | } | 2340 | } | ||
| 1997 | 2341 | ||||
| @@ -1999,18 +2343,27 @@ func (a *App) updateWindowTitle(pageTitle string) { | |||||
| 1999 | if pageTitle != "" { | 2343 | if pageTitle != "" { | ||
| 2000 | title = pageTitle + " - Dumber" | 2344 | title = pageTitle + " - Dumber" | ||
| 2001 | } | 2345 | } | ||
| 2002 | - | a.mainWindow.SetTitle(title) | 2346 | + | target.mainWindow.SetTitle(title) |
| 2003 | } | 2347 | } | ||
| 2004 | 2348 | ||||
| 2005 | // updateWindowTitleFromActivePane updates the window title based on the current active pane. | 2349 | // updateWindowTitleFromActivePane updates the window title based on the current active pane. | ||
| 2006 | - | func (a *App) updateWindowTitleFromActivePane() { | 2350 | + | func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) { |
| 2007 | - | ws := a.activeWorkspace() | 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 | if ws == nil || a.contentCoord == nil { | 2361 | if ws == nil || a.contentCoord == nil { | ||
| 2009 | - | a.updateWindowTitle("") | 2362 | + | a.updateWindowTitle("", a.browserWindowForTab(tabID)) |
| 2010 | return | 2363 | return | ||
| 2011 | } | 2364 | } | ||
| 2012 | title := a.contentCoord.GetTitle(ws.ActivePaneID) | 2365 | title := a.contentCoord.GetTitle(ws.ActivePaneID) | ||
| 2013 | - | a.updateWindowTitle(title) | 2366 | + | a.updateWindowTitle(title, a.ownerOrLastFocusedBrowserWindow(tabID, "")) |
| 2014 | } | 2367 | } | ||
| 2015 | 2368 | ||||
| 2016 | // handleModeChange is called when the input mode changes. | 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 | // Update global border overlay visibility based on mode. | 2381 | // Update global border overlay visibility based on mode. | ||
| 2029 | // Note: resize mode border is handled per-pane (stack container), not via global overlay. | 2382 | // Note: resize mode border is handled per-pane (stack container), not via global overlay. | ||
| 2030 | - | if a.borderMgr != nil { | 2383 | + | if bw := a.lastFocusedBrowserWindow(); bw != nil && bw.borderMgr != nil { |
| 2031 | - | a.borderMgr.OnModeChange(ctx, from, to) | 2384 | + | bw.borderMgr.OnModeChange(ctx, from, to) |
| 2032 | } | 2385 | } | ||
| 2033 | 2386 | ||||
| 2034 | // Show/hide mode indicator toaster based on config. | 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 | // updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config. | 2391 | // updateModeIndicatorToaster shows or hides the mode indicator toaster based on mode and config. | ||
| 2039 | func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) { | 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 | return | 2395 | return | ||
| 2042 | } | 2396 | } | ||
| 2043 | 2397 | ||||
| 2044 | // Check if mode indicator toaster is enabled in config. | 2398 | // Check if mode indicator toaster is enabled in config. | ||
| 2045 | if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.Styling.ModeIndicatorToasterEnabled { | 2399 | if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.Styling.ModeIndicatorToasterEnabled { | ||
| 2046 | - | a.modeToaster.Hide() | 2400 | + | bw.modeToaster.Hide() |
| 2047 | return | 2401 | return | ||
| 2048 | } | 2402 | } | ||
| 2049 | 2403 | ||||
| 2050 | if mode == input.ModeNormal { | 2404 | if mode == input.ModeNormal { | ||
| 2051 | - | a.modeToaster.Hide() | 2405 | + | bw.modeToaster.Hide() |
| 2052 | return | 2406 | return | ||
| 2053 | } | 2407 | } | ||
| 2054 | 2408 | ||||
| 2055 | // Show persistent toaster at bottom-left with mode display name. | 2409 | // Show persistent toaster at bottom-left with mode display name. | ||
| 2056 | // Mode class is applied atomically with Show() to avoid visual flicker. | 2410 | // Mode class is applied atomically with Show() to avoid visual flicker. | ||
| 2057 | modeClass := getModeToastClass(mode) | 2411 | modeClass := getModeToastClass(mode) | ||
| 2058 | - | a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo, | 2412 | + | bw.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo, |
| 2059 | component.WithDuration(0), // Persistent until mode exits. | 2413 | component.WithDuration(0), // Persistent until mode exits. | ||
| 2060 | component.WithPosition(component.ToastPositionBottomLeft), | 2414 | component.WithPosition(component.ToastPositionBottomLeft), | ||
| 2061 | component.WithModeClass(modeClass), | 2415 | component.WithModeClass(modeClass), | ||
| @@ -2123,14 +2477,19 @@ func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) { | |||||
| 2123 | func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) { | 2477 | func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) { | ||
| 2124 | a.createWorkspaceViewWithoutAttach(ctx, tab) | 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 | // Attach to content area | 2485 | // Attach to content area | ||
| 2127 | wsView := a.workspaceViews[tab.ID] | 2486 | wsView := a.workspaceViews[tab.ID] | ||
| 2128 | - | if wsView != nil && a.mainWindow != nil { | 2487 | + | if wsView != nil && target != nil && target.mainWindow != nil { |
| 2129 | widget := wsView.Widget() | 2488 | widget := wsView.Widget() | ||
| 2130 | if widget != nil { | 2489 | if widget != nil { | ||
| 2131 | gtkWidget := widget.GtkWidget() | 2490 | gtkWidget := widget.GtkWidget() | ||
| 2132 | if gtkWidget != nil { | 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 | // activeWorkspace returns the workspace of the active tab. | 2563 | // activeWorkspace returns the workspace of the active tab. | ||
| 2205 | func (a *App) activeWorkspace() *entity.Workspace { | 2564 | func (a *App) activeWorkspace() *entity.Workspace { | ||
| 2565 | + | if a.tabs == nil { | |||
| 2566 | + | return nil | |||
| 2567 | + | } | |||
| 2206 | activeTab := a.tabs.ActiveTab() | 2568 | activeTab := a.tabs.ActiveTab() | ||
| 2207 | if activeTab == nil { | 2569 | if activeTab == nil { | ||
| 2208 | return nil | 2570 | return nil | ||
| @@ -2309,12 +2671,15 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) { | |||||
| 2309 | } | 2671 | } | ||
| 2310 | 2672 | ||||
| 2311 | // Swap content (MainWindow.SetContent now properly removes old content) | 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 | a.mainWindow.SetContent(gtkWidget) | 2678 | a.mainWindow.SetContent(gtkWidget) | ||
| 2314 | } | 2679 | } | ||
| 2315 | 2680 | ||||
| 2316 | // Update window title with the new active pane's title | 2681 | // Update window title with the new active pane's title | ||
| 2317 | - | a.updateWindowTitleFromActivePane() | 2682 | + | a.updateWindowTitleFromActivePane(tabID) |
| 2318 | a.syncFloatingFocus() | 2683 | a.syncFloatingFocus() | ||
| 2319 | 2684 | ||||
| 2320 | log.Debug().Str("tab_id", string(tabID)).Msg("workspace view switched") | 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 | return | 2860 | return | ||
| 2496 | } | 2861 | } | ||
| 2497 | 2862 | ||||
| 2498 | - | cb := func(_ gtk.Overlay, widget *gtk.Widget, allocationPtr *uintptr) bool { | 2863 | + | cb := func(_ gtk.Overlay, widgetPtr uintptr, allocationPtr *uintptr) bool { |
| 2499 | - | var widgetPtr uintptr | 2864 | + | widget := gtk.WidgetNewFromInternalPtr(widgetPtr) |
| 2500 | if widget != nil { | 2865 | if widget != nil { | ||
| 2501 | widgetPtr = widget.GoPointer() | 2866 | widgetPtr = widget.GoPointer() | ||
| 2502 | } | 2867 | } | ||
| @@ -2756,9 +3121,7 @@ func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspac | |||||
| 2756 | if session.omnibox == nil { | 3121 | if session.omnibox == nil { | ||
| 2757 | cfg := a.omniboxCfg | 3122 | cfg := a.omniboxCfg | ||
| 2758 | cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) { | 3123 | cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) { | ||
| 2759 | - | if a.appToaster != nil { | 3124 | + | a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level) |
| 2760 | - | a.appToaster.Show(toastCtx, message, level) | |||
| 2761 | - | } | |||
| 2762 | } | 3125 | } | ||
| 2763 | 3126 | ||||
| 2764 | omnibox := component.NewOmnibox(ctx, cfg) | 3127 | omnibox := component.NewOmnibox(ctx, cfg) | ||
| @@ -3184,13 +3547,16 @@ func (a *App) initConfigWatcher(ctx context.Context) { | |||||
| 3184 | a.deps.OnConfigChange(func() { | 3547 | a.deps.OnConfigChange(func() { | ||
| 3185 | cb := glib.SourceFunc(func(_ uintptr) bool { | 3548 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| 3186 | a.applyAppearanceConfig(ctx) | 3549 | a.applyAppearanceConfig(ctx) | ||
| 3187 | - | // Reload keyboard shortcuts | 3550 | + | for _, bw := range a.browserWindows { |
| 3188 | - | if a.keyboardHandler != nil { | 3551 | + | if bw == nil { |
| 3189 | - | a.keyboardHandler.ReloadShortcuts(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session) | 3552 | + | continue |
| 3190 | - | } | 3553 | + | } |
| 3191 | - | // Reload global shortcuts (removes stale, re-registers from config) | 3554 | + | if bw.keyboardHandler != nil { |
| 3192 | - | if a.globalShortcutHandler != nil { | 3555 | + | bw.keyboardHandler.ReloadShortcuts(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session) |
| 3193 | - | a.globalShortcutHandler.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 | return false | 3561 | return false | ||
| 3196 | }) | 3562 | }) | ||
| @@ -3279,21 +3645,15 @@ func (a *App) showFilterStatus(ctx context.Context, status port.FilterStatus) { | |||||
| 3279 | 3645 | ||||
| 3280 | switch status.State { | 3646 | switch status.State { | ||
| 3281 | case port.FilterStateLoading: | 3647 | case port.FilterStateLoading: | ||
| 3282 | - | if a.appToaster != nil { | 3648 | + | a.showToastOnLastFocusedBrowserWindow(ctx, status.Message, component.ToastInfo) |
| 3283 | - | a.appToaster.Show(ctx, status.Message, component.ToastInfo) | |||
| 3284 | - | } | |||
| 3285 | case port.FilterStateActive: | 3649 | case port.FilterStateActive: | ||
| 3286 | // Apply filters to existing webviews that were created before filters loaded | 3650 | // Apply filters to existing webviews that were created before filters loaded | ||
| 3287 | if a.contentCoord != nil && a.deps.FilterManager != nil { | 3651 | if a.contentCoord != nil && a.deps.FilterManager != nil { | ||
| 3288 | a.contentCoord.ApplyFiltersToAll(ctx) | 3652 | a.contentCoord.ApplyFiltersToAll(ctx) | ||
| 3289 | log.Debug().Msg("applied filters to all existing webviews") | 3653 | log.Debug().Msg("applied filters to all existing webviews") | ||
| 3290 | } | 3654 | } | ||
| 3291 | - | if a.appToaster != nil { | 3655 | + | a.showToastOnLastFocusedBrowserWindow(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo) |
| 3292 | - | a.appToaster.Show(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo) | |||
| 3293 | - | } | |||
| 3294 | case port.FilterStateError: | 3656 | case port.FilterStateError: | ||
| 3295 | - | if a.appToaster != nil { | 3657 | + | a.showToastOnLastFocusedBrowserWindow(ctx, "Filter load failed: "+status.Message, component.ToastError) |
| 3296 | - | a.appToaster.Show(ctx, "Filter load failed: "+status.Message, component.ToastError) | |||
| 3297 | - | } | |||
| 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 | o.listBox.AddCssClass("omnibox-listbox") | 728 | o.listBox.AddCssClass("omnibox-listbox") | ||
| 729 | o.listBox.SetSelectionMode(gtk.SelectionSingleValue) | 729 | o.listBox.SetSelectionMode(gtk.SelectionSingleValue) | ||
| 730 | 730 | ||||
| 731 | - | rowSelectedCb := func(_ gtk.ListBox, row *gtk.ListBoxRow) { | 731 | + | rowSelectedCb := func(_ gtk.ListBox, rowPtr uintptr) { |
| 732 | o.restoreEntryToRealInput() | 732 | o.restoreEntryToRealInput() | ||
| 733 | + | row := gtk.ListBoxRowNewFromInternalPtr(rowPtr) | |||
| 733 | if row == nil { | 734 | if row == nil { | ||
| 734 | o.mu.Lock() | 735 | o.mu.Lock() | ||
| 735 | o.selectedIndex = -1 | 736 | o.selectedIndex = -1 | ||
| @@ -745,7 +746,8 @@ func (o *Omnibox) initList() error { | |||||
| 745 | o.listBox.ConnectRowSelected(&rowSelectedCb) | 746 | o.listBox.ConnectRowSelected(&rowSelectedCb) | ||
| 746 | 747 | ||||
| 747 | // Handle row activation (click or Enter) - navigate directly to the URL | 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 | if row == nil { | 751 | if row == nil { | ||
| 750 | return | 752 | return | ||
| 751 | } | 753 | } | ||
| @@ -753,12 +755,17 @@ func (o *Omnibox) initList() error { | |||||
| 753 | o.mu.RLock() | 755 | o.mu.RLock() | ||
| 754 | mode := o.viewMode | 756 | mode := o.viewMode | ||
| 755 | bangMode := o.bangMode | 757 | bangMode := o.bangMode | ||
| 758 | + | bangSuggestions := o.bangSuggestions | |||
| 756 | suggestions := o.suggestions | 759 | suggestions := o.suggestions | ||
| 757 | favorites := o.favorites | 760 | favorites := o.favorites | ||
| 758 | o.mu.RUnlock() | 761 | o.mu.RUnlock() | ||
| 759 | 762 | ||||
| 760 | // Don't navigate in bang mode - activating a bang should fill it in | 763 | // Don't navigate in bang mode - activating a bang should fill it in | ||
| 761 | if bangMode { | 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 | return | 769 | return | ||
| 763 | } | 770 | } | ||
| 764 | 771 | ||||
| @@ -1456,6 +1463,13 @@ func selectedTargetURL(mode ViewMode, idx, maxVisible int, suggestions []Suggest | |||||
| 1456 | return resolveTargetURLForSelection(mode, idx, maxVisible, suggestions, favorites), true | 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 | func visibleResultCount(total, maxVisible int) int { | 1473 | func visibleResultCount(total, maxVisible int) int { | ||
| 1460 | if total <= 0 { | 1474 | if total <= 0 { | ||
| 1461 | return 0 | 1475 | return 0 | ||
| @@ -2291,8 +2305,8 @@ func (o *Omnibox) navigateToSelected() { | |||||
| 2291 | } | 2305 | } | ||
| 2292 | 2306 | ||||
| 2293 | // Otherwise, Enter autocompletes the selected bang. | 2307 | // Otherwise, Enter autocompletes the selected bang. | ||
| 2294 | - | if idx >= 0 && idx < len(bangSuggestions) { | 2308 | + | if bangText, ok := bangSuggestionTextAt(idx, bangSuggestions); ok { |
| 2295 | - | o.entry.SetText("!" + bangSuggestions[idx].Key + " ") | 2309 | + | o.entry.SetText(bangText) |
| 2296 | o.entry.SetPosition(-1) | 2310 | o.entry.SetPosition(-1) | ||
| 2297 | return | 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 | func TestVisibleGhostSuggestion(t *testing.T) { | 213 | func TestVisibleGhostSuggestion(t *testing.T) { | ||
| 203 | suggestions := []Suggestion{ | 214 | suggestions := []Suggestion{ | ||
| 204 | {URL: "https://github.com/bnema/dumber"}, | 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 | sm.listBox.SetSelectionMode(gtk.SelectionSingleValue) | 314 | sm.listBox.SetSelectionMode(gtk.SelectionSingleValue) | ||
| 315 | 315 | ||||
| 316 | // Connect row selection - map list row index to session index | 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 | if row == nil { | 319 | if row == nil { | ||
| 319 | return | 320 | return | ||
| 320 | } | 321 | } | ||
M internal/ui/component/tab_button.go +3 -0
| @@ -85,6 +85,9 @@ func (tb *TabButton) SetActive(active bool) { | |||||
| 85 | return | 85 | return | ||
| 86 | } | 86 | } | ||
| 87 | tb.isActive = active | 87 | tb.isActive = active | ||
| 88 | + | if tb.button == nil { | |||
| 89 | + | return | |||
| 90 | + | } | |||
| 88 | 91 | ||||
| 89 | if active { | 92 | if active { | ||
| 90 | tb.button.AddCssClass("tab-button-active") | 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 | tp.listBox.AddCssClass("tab-picker-list") | 232 | tp.listBox.AddCssClass("tab-picker-list") | ||
| 233 | tp.listBox.SetSelectionMode(gtk.SelectionSingleValue) | 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 | if row == nil { | 237 | if row == nil { | ||
| 237 | return | 238 | return | ||
| 238 | } | 239 | } | ||
M internal/ui/config_migration.go +2 -2
| @@ -32,8 +32,8 @@ func (a *App) checkConfigMigration(ctx context.Context) { | |||||
| 32 | Msg("config migration available") | 32 | Msg("config migration available") | ||
| 33 | 33 | ||||
| 34 | // Show toast notification if enabled | 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 | msg := fmt.Sprintf("Config has %d new settings. Run 'dumber config migrate'", len(result.MissingKeys)) | 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 | OnPermissionRequest: func(origin string, permTypes []string, metadata map[string]string, allow, deny func()) bool { | 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 | if c.onFullscreenChanged != nil { | 148 | if c.onFullscreenChanged != nil { | ||
| 149 | - | c.onFullscreenChanged(true) | 149 | + | c.onFullscreenChanged(paneID, true) |
| 150 | } | 150 | } | ||
| 151 | return false // Allow fullscreen | 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 | if c.onFullscreenChanged != nil { | 160 | if c.onFullscreenChanged != nil { | ||
| 161 | - | c.onFullscreenChanged(false) | 161 | + | c.onFullscreenChanged(paneID, false) |
| 162 | } | 162 | } | ||
| 163 | return false // Allow leaving fullscreen | 163 | return false // Allow leaving fullscreen | ||
| 164 | } | 164 | } | ||
| @@ -189,6 +189,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P | |||||
| 189 | // It delegates to the permission use case which handles auto-allow, stored permissions, and dialogs. | 189 | // It delegates to the permission use case which handles auto-allow, stored permissions, and dialogs. | ||
| 190 | func (c *Coordinator) handlePermissionRequest( | 190 | func (c *Coordinator) handlePermissionRequest( | ||
| 191 | ctx context.Context, | 191 | ctx context.Context, | ||
| 192 | + | paneID entity.PaneID, | |||
| 192 | origin string, | 193 | origin string, | ||
| 193 | permTypes []string, | 194 | permTypes []string, | ||
| 194 | metadata map[string]string, | 195 | metadata map[string]string, | ||
| @@ -226,7 +227,7 @@ func (c *Coordinator) handlePermissionRequest( | |||||
| 226 | if c.onPermissionActivity == nil || len(trackedTypes) == 0 { | 227 | if c.onPermissionActivity == nil || len(trackedTypes) == 0 { | ||
| 227 | return | 228 | return | ||
| 228 | } | 229 | } | ||
| 229 | - | c.onPermissionActivity(origin, trackedTypes, state) | 230 | + | c.onPermissionActivity(paneID, origin, trackedTypes, state) |
| 230 | } | 231 | } | ||
| 231 | 232 | ||||
| 232 | notifyActivity(PermissionActivityRequesting) | 233 | notifyActivity(PermissionActivityRequesting) | ||
M internal/ui/coordinator/content/coordinator.go +8 -8
| @@ -56,13 +56,13 @@ type Coordinator struct { | |||||
| 56 | onPaneURIUpdated func(paneID entity.PaneID, url string) | 56 | onPaneURIUpdated func(paneID entity.PaneID, url string) | ||
| 57 | 57 | ||||
| 58 | // Callback when active pane title changes (for window title updates) | 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 | // Callback when media permission activity changes (requesting/allowed/blocked). | 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 | // Callback when the active pane commits a navigation (new page loading). | 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 | // Callback when the WebView becomes visible (first real commit) | 67 | // Callback when the WebView becomes visible (first real commit) | ||
| 68 | onWebViewShown func(paneID entity.PaneID) | 68 | onWebViewShown func(paneID entity.PaneID) | ||
| @@ -103,7 +103,7 @@ type Coordinator struct { | |||||
| 103 | idleInhibitor port.IdleInhibitor | 103 | idleInhibitor port.IdleInhibitor | ||
| 104 | 104 | ||||
| 105 | // Callback when fullscreen state changes (for hiding/showing tab bar) | 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 | // Callback when WebView gains focus (for accent picker text input targeting) | 108 | // Callback when WebView gains focus (for accent picker text input targeting) | ||
| 109 | onWebViewFocused func(paneID entity.PaneID, wv port.WebView) | 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 | // SetOnPermissionActivity sets a callback for WebRTC permission activity changes. | 188 | // SetOnPermissionActivity sets a callback for WebRTC permission activity changes. | ||
| 189 | func (c *Coordinator) SetOnPermissionActivity( | 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 | c.onPermissionActivity = fn | 192 | c.onPermissionActivity = fn | ||
| 193 | } | 193 | } | ||
| 194 | 194 | ||||
| 195 | // SetOnActiveNavigationCommitted sets a callback fired when the active pane commits a navigation. | 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 | c.onActiveNavigationCommitted = fn | 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 | // SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates). | 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 | c.onWindowTitleChanged = fn | 207 | c.onWindowTitleChanged = fn | ||
| 208 | } | 208 | } | ||
| 209 | 209 | ||||
| @@ -223,7 +223,7 @@ func (c *Coordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) { | |||||
| 223 | } | 223 | } | ||
| 224 | 224 | ||||
| 225 | // SetOnFullscreenChanged sets the callback for fullscreen state changes. | 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 | c.onFullscreenChanged = fn | 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 | c.activePaneOverrideMu.RUnlock() | 123 | c.activePaneOverrideMu.RUnlock() | ||
| 124 | if override != "" { | 124 | if override != "" { | ||
| 125 | if override == paneID { | 125 | if override == paneID { | ||
| 126 | - | c.onActiveNavigationCommitted(uri) | 126 | + | c.onActiveNavigationCommitted(paneID, uri) |
| 127 | } | 127 | } | ||
| 128 | return | 128 | return | ||
| 129 | } | 129 | } | ||
| 130 | ws, _ := c.getActiveWS() | 130 | ws, _ := c.getActiveWS() | ||
| 131 | if ws != nil && ws.ActivePaneID == paneID { | 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 | c.paneTitles[paneID] = title | 29 | c.paneTitles[paneID] = title | ||
| 30 | c.titleMu.Unlock() | 30 | c.titleMu.Unlock() | ||
| 31 | 31 | ||||
| 32 | - | // Update domain model and check if this is the active pane | 32 | + | // Update domain model and stacked title if available. |
| 33 | - | isActivePaneTitle := false | 33 | + | if c.getActiveWS != nil { |
| 34 | - | if c.getActiveWS == nil { | 34 | + | ws, wsView := c.getActiveWS() |
| 35 | - | return | 35 | + | if ws != nil { |
| 36 | - | } | 36 | + | paneNode := ws.FindPane(paneID) |
| 37 | - | ws, wsView := c.getActiveWS() | 37 | + | if paneNode != nil && paneNode.Pane != nil { |
| 38 | - | if ws != nil { | 38 | + | paneNode.Pane.Title = title |
| 39 | - | paneNode := ws.FindPane(paneID) | 39 | + | } |
| 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 | |||
| 46 | } | 40 | } | ||
| 47 | - | } | |||
| 48 | 41 | ||||
| 49 | - | // Update StackedView title bar if this pane is in a stack | 42 | + | if wsView != nil { |
| 50 | - | if wsView != nil { | 43 | + | tr := wsView.TreeRenderer() |
| 51 | - | tr := wsView.TreeRenderer() | 44 | + | if tr != nil { |
| 52 | - | if tr != nil { | 45 | + | if stackedView := tr.GetStackedViewForPane(string(paneID)); stackedView != nil { |
| 53 | - | stackedView := tr.GetStackedViewForPane(string(paneID)) | 46 | + | c.updateStackedPaneTitle(ctx, stackedView, paneID, title) |
| 54 | - | if stackedView != nil { | 47 | + | } |
| 55 | - | c.updateStackedPaneTitle(ctx, stackedView, paneID, title) | |||
| 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 | 62 | + | // Notify window title updates only for the active pane. |
| 71 | - | if isActivePaneTitle && c.onWindowTitleChanged != nil { | 63 | + | if c.onWindowTitleChanged != nil && c.getActiveWS != nil { |
| 72 | - | c.onWindowTitleChanged(title) | 64 | + | if ws, _ := c.getActiveWS(); ws != nil && ws.ActivePaneID == paneID { |
| 65 | + | c.onWindowTitleChanged(paneID, title) | |||
| 66 | + | } | |||
| 73 | } | 67 | } | ||
| 74 | 68 | ||||
| 75 | log.Debug(). | 69 | log.Debug(). | ||
M internal/ui/coordinator/content/title_test.go +57 -0
| @@ -8,8 +8,65 @@ import ( | |||||
| 8 | "github.com/stretchr/testify/assert" | 8 | "github.com/stretchr/testify/assert" | ||
| 9 | 9 | ||||
| 10 | "github.com/bnema/dumber/internal/domain/entity" | 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 | // TestGetTitle tests the GetTitle method for title state retrieval. | 70 | // TestGetTitle tests the GetTitle method for title state retrieval. | ||
| 14 | 71 | ||||
| 15 | func TestGetTitle_ReturnsEmptyForUnknownPane(t *testing.T) { | 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 | c.onStateChanged = fn | 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 | // notifyStateChanged triggers the state changed callback if set. | 77 | // notifyStateChanged triggers the state changed callback if set. | ||
| 73 | func (c *TabCoordinator) notifyStateChanged() { | 78 | func (c *TabCoordinator) notifyStateChanged() { | ||
| 74 | if c.onStateChanged != nil { | 79 | if c.onStateChanged != nil { | ||
| @@ -352,7 +357,7 @@ func (c *TabCoordinator) UpdateBarVisibility(ctx context.Context) { | |||||
| 352 | return | 357 | return | ||
| 353 | } | 358 | } | ||
| 354 | 359 | ||||
| 355 | - | tabCount := c.tabs.Count() | 360 | + | tabCount := c.mainWindow.TabBar().Count() |
| 356 | shouldShow := tabCount > 1 | 361 | shouldShow := tabCount > 1 | ||
| 357 | 362 | ||||
| 358 | log.Debug().Int("tab_count", tabCount).Bool("should_show", shouldShow).Msg("setting tab bar visibility") | 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 | type UpdateCoordinator struct { | 17 | type UpdateCoordinator struct { | ||
| 18 | checkUC *usecase.CheckUpdateUseCase | 18 | checkUC *usecase.CheckUpdateUseCase | ||
| 19 | applyUC *usecase.ApplyUpdateUseCase | 19 | applyUC *usecase.ApplyUpdateUseCase | ||
| 20 | - | toaster *component.Toaster | 20 | + | toastFn func(ctx context.Context, msg string, level component.ToastLevel) |
| 21 | enableOnStartup bool | 21 | enableOnStartup bool | ||
| 22 | autoDownload bool | 22 | autoDownload bool | ||
| 23 | mu sync.RWMutex // Protects status and lastInfo | 23 | mu sync.RWMutex // Protects status and lastInfo | ||
| @@ -31,14 +31,14 @@ type UpdateCoordinator struct { | |||||
| 31 | func NewUpdateCoordinator( | 31 | func NewUpdateCoordinator( | ||
| 32 | checkUC *usecase.CheckUpdateUseCase, | 32 | checkUC *usecase.CheckUpdateUseCase, | ||
| 33 | applyUC *usecase.ApplyUpdateUseCase, | 33 | applyUC *usecase.ApplyUpdateUseCase, | ||
| 34 | - | toaster *component.Toaster, | 34 | + | showToast func(ctx context.Context, msg string, level component.ToastLevel), |
| 35 | enableOnStartup bool, | 35 | enableOnStartup bool, | ||
| 36 | autoDownload bool, | 36 | autoDownload bool, | ||
| 37 | ) *UpdateCoordinator { | 37 | ) *UpdateCoordinator { | ||
| 38 | return &UpdateCoordinator{ | 38 | return &UpdateCoordinator{ | ||
| 39 | checkUC: checkUC, | 39 | checkUC: checkUC, | ||
| 40 | applyUC: applyUC, | 40 | applyUC: applyUC, | ||
| 41 | - | toaster: toaster, | 41 | + | toastFn: showToast, |
| 42 | enableOnStartup: enableOnStartup, | 42 | enableOnStartup: enableOnStartup, | ||
| 43 | autoDownload: autoDownload, | 43 | autoDownload: autoDownload, | ||
| 44 | status: entity.UpdateStatusUnknown, | 44 | status: entity.UpdateStatusUnknown, | ||
| @@ -104,7 +104,9 @@ func (c *UpdateCoordinator) showUpdateNotification(ctx context.Context, result * | |||||
| 104 | } | 104 | } | ||
| 105 | 105 | ||||
| 106 | cb := glib.SourceFunc(func(_ uintptr) bool { | 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 | return false | 110 | return false | ||
| 109 | }) | 111 | }) | ||
| 110 | glib.IdleAdd(&cb, 0) | 112 | glib.IdleAdd(&cb, 0) | ||
| @@ -145,7 +147,9 @@ func (c *UpdateCoordinator) downloadAsync(ctx context.Context, downloadURL strin | |||||
| 145 | 147 | ||||
| 146 | func (c *UpdateCoordinator) showToast(ctx context.Context, msg string, level component.ToastLevel) { | 148 | func (c *UpdateCoordinator) showToast(ctx context.Context, msg string, level component.ToastLevel) { | ||
| 147 | cb := glib.SourceFunc(func(_ uintptr) bool { | 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 | return false | 153 | return false | ||
| 150 | }) | 154 | }) | ||
| 151 | glib.IdleAdd(&cb, 0) | 155 | glib.IdleAdd(&cb, 0) | ||
M internal/ui/deps.go +2 -0
| @@ -104,6 +104,8 @@ type Dependencies struct { | |||||
| 104 | // LaunchBrowserURL opens a URL in a new dumber browser window. | 104 | // LaunchBrowserURL opens a URL in a new dumber browser window. | ||
| 105 | // Optional: if nil, standalone omnibox web navigations are logged only. | 105 | // Optional: if nil, standalone omnibox web navigations are logged only. | ||
| 106 | LaunchBrowserURL func(uri string) | 106 | LaunchBrowserURL func(uri string) | ||
| 107 | + | // BrowserLaunchRelay listens for in-process browser launch requests. | |||
| 108 | + | BrowserLaunchRelay port.BrowserLaunchRelay | |||
| 107 | } | 109 | } | ||
| 108 | 110 | ||||
| 109 | // Validate checks that all required dependencies are set. | 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 | onFindNext func(ctx context.Context) error | 28 | onFindNext func(ctx context.Context) error | ||
| 29 | onFindPrev func(ctx context.Context) error | 29 | onFindPrev func(ctx context.Context) error | ||
| 30 | onFindClose func(ctx context.Context) error | 30 | onFindClose func(ctx context.Context) error | ||
| 31 | - | onSessionOpen func(ctx context.Context) error | 31 | + | activePaneID func(ctx context.Context) entity.PaneID |
| 32 | - | onMovePaneToTab func(ctx context.Context) error | 32 | + | onSessionOpen func(ctx context.Context, paneID entity.PaneID) error |
| 33 | - | onMovePaneToNext func(ctx context.Context) error | 33 | + | onMovePaneToTab func(ctx context.Context, paneID entity.PaneID) error |
| 34 | + | onMovePaneToNext func(ctx context.Context, paneID entity.PaneID) error | |||
| 34 | onToggleFloating func(ctx context.Context) error | 35 | onToggleFloating func(ctx context.Context) error | ||
| 35 | onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error | 36 | onOpenFloating func(ctx context.Context, target input.FloatingProfileTarget) error | ||
| 36 | } | 37 | } | ||
| @@ -44,17 +45,19 @@ func NewKeyboardDispatcher( | |||||
| 44 | zoomUC *usecase.ManageZoomUseCase, | 45 | zoomUC *usecase.ManageZoomUseCase, | ||
| 45 | copyURLUC *usecase.CopyURLUseCase, | 46 | copyURLUC *usecase.CopyURLUseCase, | ||
| 46 | newPaneURL string, | 47 | newPaneURL string, | ||
| 48 | + | activePaneID func(context.Context) entity.PaneID, | |||
| 47 | ) *KeyboardDispatcher { | 49 | ) *KeyboardDispatcher { | ||
| 48 | log := logging.FromContext(ctx) | 50 | log := logging.FromContext(ctx) | ||
| 49 | log.Debug().Msg("creating keyboard dispatcher") | 51 | log.Debug().Msg("creating keyboard dispatcher") | ||
| 50 | 52 | ||||
| 51 | dispatcher := &KeyboardDispatcher{ | 53 | dispatcher := &KeyboardDispatcher{ | ||
| 52 | - | tabCoord: tabCoord, | 54 | + | tabCoord: tabCoord, |
| 53 | - | wsCoord: wsCoord, | 55 | + | wsCoord: wsCoord, |
| 54 | - | navCoord: navCoord, | 56 | + | navCoord: navCoord, |
| 55 | - | zoomUC: zoomUC, | 57 | + | zoomUC: zoomUC, |
| 56 | - | copyURLUC: copyURLUC, | 58 | + | copyURLUC: copyURLUC, |
| 57 | - | newPaneURL: newPaneURL, | 59 | + | newPaneURL: newPaneURL, |
| 60 | + | activePaneID: activePaneID, | |||
| 58 | } | 61 | } | ||
| 59 | dispatcher.initActionHandlers() | 62 | dispatcher.initActionHandlers() | ||
| 60 | return dispatcher | 63 | return dispatcher | ||
| @@ -86,15 +89,15 @@ func (d *KeyboardDispatcher) SetOnFindClose(fn func(ctx context.Context) error) | |||||
| 86 | } | 89 | } | ||
| 87 | 90 | ||||
| 88 | // SetOnSessionOpen sets the callback for opening the session manager. | 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 | d.onSessionOpen = fn | 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 | d.onMovePaneToTab = fn | 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 | d.onMovePaneToNext = fn | 101 | d.onMovePaneToNext = fn | ||
| 99 | } | 102 | } | ||
| 100 | 103 | ||||
| @@ -289,7 +292,7 @@ func (d *KeyboardDispatcher) handleFindClose(ctx context.Context) error { | |||||
| 289 | 292 | ||||
| 290 | func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error { | 293 | func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error { | ||
| 291 | if d.onSessionOpen != nil { | 294 | if d.onSessionOpen != nil { | ||
| 292 | - | return d.onSessionOpen(ctx) | 295 | + | return d.onSessionOpen(ctx, d.activePaneID(ctx)) |
| 293 | } | 296 | } | ||
| 294 | logging.FromContext(ctx).Debug().Msg("session open action (no handler)") | 297 | logging.FromContext(ctx).Debug().Msg("session open action (no handler)") | ||
| 295 | return nil | 298 | return nil | ||
| @@ -297,7 +300,7 @@ func (d *KeyboardDispatcher) handleSessionOpen(ctx context.Context) error { | |||||
| 297 | 300 | ||||
| 298 | func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error { | 301 | func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error { | ||
| 299 | if d.onMovePaneToTab != nil { | 302 | if d.onMovePaneToTab != nil { | ||
| 300 | - | return d.onMovePaneToTab(ctx) | 303 | + | return d.onMovePaneToTab(ctx, d.activePaneID(ctx)) |
| 301 | } | 304 | } | ||
| 302 | logging.FromContext(ctx).Debug().Msg("move pane to tab action (no handler)") | 305 | logging.FromContext(ctx).Debug().Msg("move pane to tab action (no handler)") | ||
| 303 | return nil | 306 | return nil | ||
| @@ -305,7 +308,7 @@ func (d *KeyboardDispatcher) handleMovePaneToTab(ctx context.Context) error { | |||||
| 305 | 308 | ||||
| 306 | func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error { | 309 | func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error { | ||
| 307 | if d.onMovePaneToNext != nil { | 310 | if d.onMovePaneToNext != nil { | ||
| 308 | - | return d.onMovePaneToNext(ctx) | 311 | + | return d.onMovePaneToNext(ctx, d.activePaneID(ctx)) |
| 309 | } | 312 | } | ||
| 310 | logging.FromContext(ctx).Debug().Msg("move pane to next tab action (no handler)") | 313 | logging.FromContext(ctx).Debug().Msg("move pane to next tab action (no handler)") | ||
| 311 | return nil | 314 | return nil | ||
M internal/ui/dispatcher/keyboard_test.go +46 -3
| @@ -35,7 +35,7 @@ func TestKeyboardDispatcher_AltNumberCreatesTabsUntilRequestedIndexExists(t *tes | |||||
| 35 | _, err := tabCoord.Create(ctx, "https://initial.example") | 35 | _, err := tabCoord.Create(ctx, "https://initial.example") | ||
| 36 | require.NoError(t, err) | 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 | err = d.Dispatch(ctx, input.ActionSwitchTabIndex3) | 40 | err = d.Dispatch(ctx, input.ActionSwitchTabIndex3) | ||
| 41 | require.NoError(t, err) | 41 | require.NoError(t, err) | ||
| @@ -76,7 +76,7 @@ func TestKeyboardDispatcher_AltNumberSwitchesToExistingTabWithoutNewPaneURL(t *t | |||||
| 76 | _, err = tabCoord.Create(ctx, "https://second.example") | 76 | _, err = tabCoord.Create(ctx, "https://second.example") | ||
| 77 | require.NoError(t, err) | 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 | err = d.Dispatch(ctx, input.ActionSwitchTabIndex2) | 81 | err = d.Dispatch(ctx, input.ActionSwitchTabIndex2) | ||
| 82 | require.NoError(t, err) | 82 | require.NoError(t, err) | ||
| @@ -106,10 +106,53 @@ func TestKeyboardDispatcher_AltNumberErrorsWhenMissingTabRequiresCreationWithout | |||||
| 106 | _, err := tabCoord.Create(ctx, "https://first.example") | 106 | _, err := tabCoord.Create(ctx, "https://first.example") | ||
| 107 | require.NoError(t, err) | 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 | err = d.Dispatch(ctx, input.ActionSwitchTabIndex2) | 111 | err = d.Dispatch(ctx, input.ActionSwitchTabIndex2) | ||
| 112 | require.Error(t, err) | 112 | require.Error(t, err) | ||
| 113 | require.Len(t, tabs.Tabs, 1) | 113 | require.Len(t, tabs.Tabs, 1) | ||
| 114 | assert.Equal(t, tabs.TabAt(0).ID, tabs.ActiveTabID) | 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 | urlutil "github.com/bnema/dumber/internal/domain/url" | 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 | browserURL := "" | 11 | browserURL := "" | ||
| 12 | externalURL := "" | 12 | externalURL := "" | ||
| 13 | handleStandaloneOmniboxNavigation(&Dependencies{ | 13 | handleStandaloneOmniboxNavigation(&Dependencies{ | ||
M internal/ui/window/main_window.go +18 -0
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | 6 | ||||
| 7 | "github.com/bnema/dumber/internal/logging" | 7 | "github.com/bnema/dumber/internal/logging" | ||
| 8 | "github.com/bnema/dumber/internal/ui/component" | 8 | "github.com/bnema/dumber/internal/ui/component" | ||
| 9 | + | "github.com/bnema/puregotk/v4/gobject" | |||
| 9 | "github.com/bnema/puregotk/v4/gtk" | 10 | "github.com/bnema/puregotk/v4/gtk" | ||
| 10 | "github.com/rs/zerolog" | 11 | "github.com/rs/zerolog" | ||
| 11 | ) | 12 | ) | ||
| @@ -118,11 +119,17 @@ func (mw *MainWindow) assembleLayout() { | |||||
| 118 | 119 | ||||
| 119 | // Show presents the window. | 120 | // Show presents the window. | ||
| 120 | func (mw *MainWindow) Show() { | 121 | func (mw *MainWindow) Show() { | ||
| 122 | + | if mw == nil || mw.window == nil { | |||
| 123 | + | return | |||
| 124 | + | } | |||
| 121 | mw.window.Present() | 125 | mw.window.Present() | ||
| 122 | } | 126 | } | ||
| 123 | 127 | ||||
| 124 | // Close closes the window. | 128 | // Close closes the window. | ||
| 125 | func (mw *MainWindow) Close() { | 129 | func (mw *MainWindow) Close() { | ||
| 130 | + | if mw == nil || mw.window == nil { | |||
| 131 | + | return | |||
| 132 | + | } | |||
| 126 | mw.window.Close() | 133 | mw.window.Close() | ||
| 127 | } | 134 | } | ||
| 128 | 135 | ||||
| @@ -155,6 +162,17 @@ func (mw *MainWindow) Window() *gtk.ApplicationWindow { | |||||
| 155 | return mw.window | 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 | // SetTitle sets the window title. | 176 | // SetTitle sets the window title. | ||
| 159 | func (mw *MainWindow) SetTitle(title string) { | 177 | func (mw *MainWindow) SetTitle(title string) { | ||
| 160 | if mw.window == nil { | 178 | if mw.window == nil { | ||