Fix CEF clipboard flow and dev runtime isolation
- commit
- 21f7418d8817efe2b5b96f3f1ca7dbc402486fd6
- author
- brice · · 2026-04-18 07:37 UTC
- parent
- 5214440
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
16 files · +688 -44
M internal/application/usecase/clipboard_text_orchestrator.go +11 -0
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "unicode/utf8" | |
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/dumber/internal/application/port" | |
| 12 | + | "github.com/bnema/dumber/internal/logging" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 14 | 15 | const explicitClipboardDedupWindow = 250 * time.Millisecond | |
| @@ -125,6 +126,10 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate( | |||
| 125 | 126 | if uc.toast != nil { | |
| 126 | 127 | uc.toast(textLen) | |
| 127 | 128 | } | |
| 129 | + | logging.FromContext(ctx).Debug(). | |
| 130 | + | Int("text_len", textLen). | |
| 131 | + | Str("source_engine", string(input.SourceEngine)). | |
| 132 | + | Msg("clipboard selection copied") | |
| 128 | 133 | return nil | |
| 129 | 134 | } | |
| 130 | 135 | ||
| @@ -172,6 +177,12 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy( | |||
| 172 | 177 | if uc.toast != nil { | |
| 173 | 178 | uc.toast(textLen) | |
| 174 | 179 | } | |
| 180 | + | logging.FromContext(ctx).Debug(). | |
| 181 | + | Int("text_len", textLen). | |
| 182 | + | Str("source_engine", string(input.SourceEngine)). | |
| 183 | + | Str("action", input.Action). | |
| 184 | + | Bool("native_handled", input.NativeHandled). | |
| 185 | + | Msg("clipboard explicit copied") | |
| 175 | 186 | return nil | |
| 176 | 187 | } | |
| 177 | 188 | ||
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "unicode/utf8" | 9 | "unicode/utf8" | ||
| 10 | 10 | ||||
| 11 | "github.com/bnema/dumber/internal/application/port" | 11 | "github.com/bnema/dumber/internal/application/port" | ||
| 12 | + | "github.com/bnema/dumber/internal/logging" | |||
| 12 | ) | 13 | ) | ||
| 13 | 14 | ||||
| 14 | const explicitClipboardDedupWindow = 250 * time.Millisecond | 15 | const explicitClipboardDedupWindow = 250 * time.Millisecond | ||
| @@ -125,6 +126,10 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate( | |||||
| 125 | if uc.toast != nil { | 126 | if uc.toast != nil { | ||
| 126 | uc.toast(textLen) | 127 | uc.toast(textLen) | ||
| 127 | } | 128 | } | ||
| 129 | + | logging.FromContext(ctx).Debug(). | |||
| 130 | + | Int("text_len", textLen). | |||
| 131 | + | Str("source_engine", string(input.SourceEngine)). | |||
| 132 | + | Msg("clipboard selection copied") | |||
| 128 | return nil | 133 | return nil | ||
| 129 | } | 134 | } | ||
| 130 | 135 | ||||
| @@ -172,6 +177,12 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy( | |||||
| 172 | if uc.toast != nil { | 177 | if uc.toast != nil { | ||
| 173 | uc.toast(textLen) | 178 | uc.toast(textLen) | ||
| 174 | } | 179 | } | ||
| 180 | + | logging.FromContext(ctx).Debug(). | |||
| 181 | + | Int("text_len", textLen). | |||
| 182 | + | Str("source_engine", string(input.SourceEngine)). | |||
| 183 | + | Str("action", input.Action). | |||
| 184 | + | Bool("native_handled", input.NativeHandled). | |||
| 185 | + | Msg("clipboard explicit copied") | |||
| 175 | return nil | 186 | return nil | ||
| 176 | } | 187 | } | ||
| 177 | 188 | ||||
M internal/infrastructure/cef/context_menu.go +5 -2
| @@ -176,7 +176,7 @@ func dispatchContextMenuSelection( | |||
| 176 | 176 | return | |
| 177 | 177 | } | |
| 178 | 178 | log.Debug().Str("action", string(item.Action)).Str("label", item.Label).Msg("cef: context menu item selected") | |
| 179 | - | if item.Action == port.MenuActionCopySelection { | |
| 179 | + | if item.Action == port.MenuActionCopySelection && menuContext.SelectionText == "" { | |
| 180 | 180 | if cmdID, ok := commandIDByAction[item.Action]; ok { | |
| 181 | 181 | log.Debug().Str("action", string(item.Action)).Int32("command_id", cmdID).Msg("cef: continuing native context menu command") | |
| 182 | 182 | callback.Cont(cmdID, 0) | |
| @@ -212,6 +212,8 @@ func dispatchContextMenuSelection( | |||
| 212 | 212 | ||
| 213 | 213 | func contextMenuCopiedText(action port.MenuAction, menuContext port.MenuContext) string { | |
| 214 | 214 | switch action { | |
| 215 | + | case port.MenuActionCopySelection: | |
| 216 | + | return menuContext.SelectionText | |
| 215 | 217 | case port.MenuActionCopyLink: | |
| 216 | 218 | return menuContext.LinkURI | |
| 217 | 219 | default: | |
| @@ -227,7 +229,8 @@ func shouldExecuteDirectCEFAction(action port.MenuAction) bool { | |||
| 227 | 229 | port.MenuActionOpenLinkNewTab, | |
| 228 | 230 | port.MenuActionCopyLink, | |
| 229 | 231 | port.MenuActionCopyImage, | |
| 230 | - | port.MenuActionInspectElement: | |
| 232 | + | port.MenuActionInspectElement, | |
| 233 | + | port.MenuActionCopySelection: | |
| 231 | 234 | return true | |
| 232 | 235 | default: | |
| 233 | 236 | return false | |
| @@ -176,7 +176,7 @@ func dispatchContextMenuSelection( | |||||
| 176 | return | 176 | return | ||
| 177 | } | 177 | } | ||
| 178 | log.Debug().Str("action", string(item.Action)).Str("label", item.Label).Msg("cef: context menu item selected") | 178 | log.Debug().Str("action", string(item.Action)).Str("label", item.Label).Msg("cef: context menu item selected") | ||
| 179 | - | if item.Action == port.MenuActionCopySelection { | 179 | + | if item.Action == port.MenuActionCopySelection && menuContext.SelectionText == "" { |
| 180 | if cmdID, ok := commandIDByAction[item.Action]; ok { | 180 | if cmdID, ok := commandIDByAction[item.Action]; ok { | ||
| 181 | log.Debug().Str("action", string(item.Action)).Int32("command_id", cmdID).Msg("cef: continuing native context menu command") | 181 | log.Debug().Str("action", string(item.Action)).Int32("command_id", cmdID).Msg("cef: continuing native context menu command") | ||
| 182 | callback.Cont(cmdID, 0) | 182 | callback.Cont(cmdID, 0) | ||
| @@ -212,6 +212,8 @@ func dispatchContextMenuSelection( | |||||
| 212 | 212 | ||||
| 213 | func contextMenuCopiedText(action port.MenuAction, menuContext port.MenuContext) string { | 213 | func contextMenuCopiedText(action port.MenuAction, menuContext port.MenuContext) string { | ||
| 214 | switch action { | 214 | switch action { | ||
| 215 | + | case port.MenuActionCopySelection: | |||
| 216 | + | return menuContext.SelectionText | |||
| 215 | case port.MenuActionCopyLink: | 217 | case port.MenuActionCopyLink: | ||
| 216 | return menuContext.LinkURI | 218 | return menuContext.LinkURI | ||
| 217 | default: | 219 | default: | ||
| @@ -227,7 +229,8 @@ func shouldExecuteDirectCEFAction(action port.MenuAction) bool { | |||||
| 227 | port.MenuActionOpenLinkNewTab, | 229 | port.MenuActionOpenLinkNewTab, | ||
| 228 | port.MenuActionCopyLink, | 230 | port.MenuActionCopyLink, | ||
| 229 | port.MenuActionCopyImage, | 231 | port.MenuActionCopyImage, | ||
| 230 | - | port.MenuActionInspectElement: | 232 | + | port.MenuActionInspectElement, |
| 233 | + | port.MenuActionCopySelection: | |||
| 231 | return true | 234 | return true | ||
| 232 | default: | 235 | default: | ||
| 233 | return false | 236 | return false | ||
M internal/infrastructure/cef/context_menu_adapter_test.go +26 -3
| @@ -171,10 +171,11 @@ func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *t | |||
| 171 | 171 | require.Equal(t, 1, callback.cancelCalls) | |
| 172 | 172 | } | |
| 173 | 173 | ||
| 174 | - | func TestContextMenuSelectionUsesNativeCommandForCopySelection(t *testing.T) { | |
| 174 | + | func TestContextMenuSelectionExecutesCopySelectionDirectlyWhenSelectionTextAvailable(t *testing.T) { | |
| 175 | 175 | callback := &stubRunContextMenuCallback{} | |
| 176 | 176 | executor := &stubContextMenuExecutor{} | |
| 177 | 177 | var copiedLens []int | |
| 178 | + | menuContext := port.MenuContext{SelectionText: "selected text", HasSelection: true} | |
| 178 | 179 | ||
| 179 | 180 | dispatchContextMenuSelection( | |
| 180 | 181 | context.Background(), | |
| @@ -183,14 +184,36 @@ func TestContextMenuSelectionUsesNativeCommandForCopySelection(t *testing.T) { | |||
| 183 | 184 | func(text string) { copiedLens = append(copiedLens, len(text)) }, | |
| 184 | 185 | map[port.MenuAction]int32{port.MenuActionCopySelection: 333}, | |
| 185 | 186 | port.MenuItem{Action: port.MenuActionCopySelection, Label: "Copy"}, | |
| 186 | - | port.MenuContext{SelectionText: "selected text", HasSelection: true}, | |
| 187 | + | menuContext, | |
| 188 | + | ) | |
| 189 | + | ||
| 190 | + | require.Equal(t, 1, executor.executeCalls) | |
| 191 | + | require.Equal(t, port.MenuActionCopySelection, executor.action) | |
| 192 | + | require.Equal(t, menuContext, executor.menuContext) | |
| 193 | + | require.Zero(t, callback.contCalls) | |
| 194 | + | require.Zero(t, callback.commandID) | |
| 195 | + | require.Equal(t, 1, callback.cancelCalls) | |
| 196 | + | require.Equal(t, []int{len("selected text")}, copiedLens) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func TestContextMenuSelectionFallsBackToNativeCopySelectionWhenTextUnavailable(t *testing.T) { | |
| 200 | + | callback := &stubRunContextMenuCallback{} | |
| 201 | + | executor := &stubContextMenuExecutor{} | |
| 202 | + | ||
| 203 | + | dispatchContextMenuSelection( | |
| 204 | + | context.Background(), | |
| 205 | + | executor, | |
| 206 | + | callback, | |
| 207 | + | nil, | |
| 208 | + | map[port.MenuAction]int32{port.MenuActionCopySelection: 333}, | |
| 209 | + | port.MenuItem{Action: port.MenuActionCopySelection, Label: "Copy"}, | |
| 210 | + | port.MenuContext{HasSelection: true}, | |
| 187 | 211 | ) | |
| 188 | 212 | ||
| 189 | 213 | require.Equal(t, 1, callback.contCalls) | |
| 190 | 214 | require.Zero(t, callback.cancelCalls) | |
| 191 | 215 | require.Equal(t, int32(333), callback.commandID) | |
| 192 | 216 | require.Zero(t, executor.executeCalls) | |
| 193 | - | require.Empty(t, copiedLens) | |
| 194 | 217 | } | |
| 195 | 218 | ||
| 196 | 219 | func TestContextMenuAnchorPositionScalesCEFCoordinates(t *testing.T) { | |
| @@ -171,10 +171,11 @@ func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *t | |||||
| 171 | require.Equal(t, 1, callback.cancelCalls) | 171 | require.Equal(t, 1, callback.cancelCalls) | ||
| 172 | } | 172 | } | ||
| 173 | 173 | ||||
| 174 | - | func TestContextMenuSelectionUsesNativeCommandForCopySelection(t *testing.T) { | 174 | + | func TestContextMenuSelectionExecutesCopySelectionDirectlyWhenSelectionTextAvailable(t *testing.T) { |
| 175 | callback := &stubRunContextMenuCallback{} | 175 | callback := &stubRunContextMenuCallback{} | ||
| 176 | executor := &stubContextMenuExecutor{} | 176 | executor := &stubContextMenuExecutor{} | ||
| 177 | var copiedLens []int | 177 | var copiedLens []int | ||
| 178 | + | menuContext := port.MenuContext{SelectionText: "selected text", HasSelection: true} | |||
| 178 | 179 | ||||
| 179 | dispatchContextMenuSelection( | 180 | dispatchContextMenuSelection( | ||
| 180 | context.Background(), | 181 | context.Background(), | ||
| @@ -183,14 +184,36 @@ func TestContextMenuSelectionUsesNativeCommandForCopySelection(t *testing.T) { | |||||
| 183 | func(text string) { copiedLens = append(copiedLens, len(text)) }, | 184 | func(text string) { copiedLens = append(copiedLens, len(text)) }, | ||
| 184 | map[port.MenuAction]int32{port.MenuActionCopySelection: 333}, | 185 | map[port.MenuAction]int32{port.MenuActionCopySelection: 333}, | ||
| 185 | port.MenuItem{Action: port.MenuActionCopySelection, Label: "Copy"}, | 186 | port.MenuItem{Action: port.MenuActionCopySelection, Label: "Copy"}, | ||
| 186 | - | port.MenuContext{SelectionText: "selected text", HasSelection: true}, | 187 | + | menuContext, |
| 188 | + | ) | |||
| 189 | + | ||||
| 190 | + | require.Equal(t, 1, executor.executeCalls) | |||
| 191 | + | require.Equal(t, port.MenuActionCopySelection, executor.action) | |||
| 192 | + | require.Equal(t, menuContext, executor.menuContext) | |||
| 193 | + | require.Zero(t, callback.contCalls) | |||
| 194 | + | require.Zero(t, callback.commandID) | |||
| 195 | + | require.Equal(t, 1, callback.cancelCalls) | |||
| 196 | + | require.Equal(t, []int{len("selected text")}, copiedLens) | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | func TestContextMenuSelectionFallsBackToNativeCopySelectionWhenTextUnavailable(t *testing.T) { | |||
| 200 | + | callback := &stubRunContextMenuCallback{} | |||
| 201 | + | executor := &stubContextMenuExecutor{} | |||
| 202 | + | ||||
| 203 | + | dispatchContextMenuSelection( | |||
| 204 | + | context.Background(), | |||
| 205 | + | executor, | |||
| 206 | + | callback, | |||
| 207 | + | nil, | |||
| 208 | + | map[port.MenuAction]int32{port.MenuActionCopySelection: 333}, | |||
| 209 | + | port.MenuItem{Action: port.MenuActionCopySelection, Label: "Copy"}, | |||
| 210 | + | port.MenuContext{HasSelection: true}, | |||
| 187 | ) | 211 | ) | ||
| 188 | 212 | ||||
| 189 | require.Equal(t, 1, callback.contCalls) | 213 | require.Equal(t, 1, callback.contCalls) | ||
| 190 | require.Zero(t, callback.cancelCalls) | 214 | require.Zero(t, callback.cancelCalls) | ||
| 191 | require.Equal(t, int32(333), callback.commandID) | 215 | require.Equal(t, int32(333), callback.commandID) | ||
| 192 | require.Zero(t, executor.executeCalls) | 216 | require.Zero(t, executor.executeCalls) | ||
| 193 | - | require.Empty(t, copiedLens) | |||
| 194 | } | 217 | } | ||
| 195 | 218 | ||||
| 196 | func TestContextMenuAnchorPositionScalesCEFCoordinates(t *testing.T) { | 219 | func TestContextMenuAnchorPositionScalesCEFCoordinates(t *testing.T) { | ||
M internal/infrastructure/cef/engine.go +4 -5
| @@ -298,11 +298,10 @@ func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action | |||
| 298 | 298 | } | |
| 299 | 299 | textLen := utf8.RuneCountInString(text) | |
| 300 | 300 | if err := e.clipboardTextOrchestrator.HandleExplicitCopy(e.currentContext(), port.ExplicitClipboardInput{ | |
| 301 | - | Text: text, | |
| 302 | - | Action: action, | |
| 303 | - | SourceEngine: port.ClipboardSourceCEF, | |
| 304 | - | ViewID: viewID, | |
| 305 | - | NativeHandled: true, | |
| 301 | + | Text: text, | |
| 302 | + | Action: action, | |
| 303 | + | SourceEngine: port.ClipboardSourceCEF, | |
| 304 | + | ViewID: viewID, | |
| 306 | 305 | }); err != nil { | |
| 307 | 306 | logging.FromContext(e.currentContext()).Debug(). | |
| 308 | 307 | Err(err). | |
| @@ -298,11 +298,10 @@ func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action | |||||
| 298 | } | 298 | } | ||
| 299 | textLen := utf8.RuneCountInString(text) | 299 | textLen := utf8.RuneCountInString(text) | ||
| 300 | if err := e.clipboardTextOrchestrator.HandleExplicitCopy(e.currentContext(), port.ExplicitClipboardInput{ | 300 | if err := e.clipboardTextOrchestrator.HandleExplicitCopy(e.currentContext(), port.ExplicitClipboardInput{ | ||
| 301 | - | Text: text, | 301 | + | Text: text, |
| 302 | - | Action: action, | 302 | + | Action: action, |
| 303 | - | SourceEngine: port.ClipboardSourceCEF, | 303 | + | SourceEngine: port.ClipboardSourceCEF, |
| 304 | - | ViewID: viewID, | 304 | + | ViewID: viewID, |
| 305 | - | NativeHandled: true, | |||
| 306 | }); err != nil { | 305 | }); err != nil { | ||
| 307 | logging.FromContext(e.currentContext()).Debug(). | 306 | logging.FromContext(e.currentContext()).Debug(). | ||
| 308 | Err(err). | 307 | Err(err). | ||
M internal/infrastructure/cef/engine_runtime_test.go +1 -1
| @@ -85,7 +85,7 @@ func TestEngineExplicitClipboardCopy_ForwardsViewID(t *testing.T) { | |||
| 85 | 85 | ||
| 86 | 86 | eng.handleExplicitClipboardBridgeText(77, "copy", "copied") | |
| 87 | 87 | ||
| 88 | - | require.Equal(t, port.ExplicitClipboardInput{Text: "copied", SourceEngine: port.ClipboardSourceCEF, ViewID: 77, Action: "copy", NativeHandled: true}, orchestrator.explicit) | |
| 88 | + | require.Equal(t, port.ExplicitClipboardInput{Text: "copied", SourceEngine: port.ClipboardSourceCEF, ViewID: 77, Action: "copy"}, orchestrator.explicit) | |
| 89 | 89 | } | |
| 90 | 90 | ||
| 91 | 91 | type stubClipboardTextOrchestrator struct{} | |
| @@ -85,7 +85,7 @@ func TestEngineExplicitClipboardCopy_ForwardsViewID(t *testing.T) { | |||||
| 85 | 85 | ||||
| 86 | eng.handleExplicitClipboardBridgeText(77, "copy", "copied") | 86 | eng.handleExplicitClipboardBridgeText(77, "copy", "copied") | ||
| 87 | 87 | ||||
| 88 | - | require.Equal(t, port.ExplicitClipboardInput{Text: "copied", SourceEngine: port.ClipboardSourceCEF, ViewID: 77, Action: "copy", NativeHandled: true}, orchestrator.explicit) | 88 | + | require.Equal(t, port.ExplicitClipboardInput{Text: "copied", SourceEngine: port.ClipboardSourceCEF, ViewID: 77, Action: "copy"}, orchestrator.explicit) |
| 89 | } | 89 | } | ||
| 90 | 90 | ||||
| 91 | type stubClipboardTextOrchestrator struct{} | 91 | type stubClipboardTextOrchestrator struct{} | ||
M internal/infrastructure/cef/factory.go +12 -1
| @@ -103,6 +103,12 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||
| 103 | 103 | wv.findCtrl = newFindController() | |
| 104 | 104 | ||
| 105 | 105 | input := newInputBridge(ctx, f.scale) | |
| 106 | + | input.selectionText = wv.selectedTextSnapshot | |
| 107 | + | input.explicitCopyText = func(action, text string) { | |
| 108 | + | if f.engine != nil { | |
| 109 | + | f.engine.handleExplicitClipboardBridgeText(wv.id, action, text) | |
| 110 | + | } | |
| 111 | + | } | |
| 106 | 112 | input.attachTo(pipeline.glArea) | |
| 107 | 113 | wv.input = input | |
| 108 | 114 | ||
| @@ -172,14 +178,19 @@ func (f *WebViewFactory) configureInitialBrowserCreation( | |||
| 172 | 178 | log.Debug().Msg("cef: skipping browser creation for destroyed webview") | |
| 173 | 179 | return | |
| 174 | 180 | } | |
| 181 | + | initialURL := wv.pendingNavigationURI() | |
| 182 | + | if initialURL == "" { | |
| 183 | + | initialURL = "about:blank" | |
| 184 | + | } | |
| 175 | 185 | result := purecef.BrowserHostCreateBrowser( | |
| 176 | 186 | pc.windowInfo, | |
| 177 | 187 | pc.client, | |
| 178 | - | "about:blank", | |
| 188 | + | initialURL, | |
| 179 | 189 | pc.settings, | |
| 180 | 190 | nil, // extraInfo | |
| 181 | 191 | nil, // requestContext | |
| 182 | 192 | ) | |
| 193 | + | log.Debug().Str("initial_url", initialURL).Msg("cef: BrowserHostCreateBrowser initial URL") | |
| 183 | 194 | if f.engine != nil { | |
| 184 | 195 | f.engine.recordBrowserCreateRequest(w, h, result) | |
| 185 | 196 | } | |
| @@ -103,6 +103,12 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||||
| 103 | wv.findCtrl = newFindController() | 103 | wv.findCtrl = newFindController() | ||
| 104 | 104 | ||||
| 105 | input := newInputBridge(ctx, f.scale) | 105 | input := newInputBridge(ctx, f.scale) | ||
| 106 | + | input.selectionText = wv.selectedTextSnapshot | |||
| 107 | + | input.explicitCopyText = func(action, text string) { | |||
| 108 | + | if f.engine != nil { | |||
| 109 | + | f.engine.handleExplicitClipboardBridgeText(wv.id, action, text) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 106 | input.attachTo(pipeline.glArea) | 112 | input.attachTo(pipeline.glArea) | ||
| 107 | wv.input = input | 113 | wv.input = input | ||
| 108 | 114 | ||||
| @@ -172,14 +178,19 @@ func (f *WebViewFactory) configureInitialBrowserCreation( | |||||
| 172 | log.Debug().Msg("cef: skipping browser creation for destroyed webview") | 178 | log.Debug().Msg("cef: skipping browser creation for destroyed webview") | ||
| 173 | return | 179 | return | ||
| 174 | } | 180 | } | ||
| 181 | + | initialURL := wv.pendingNavigationURI() | |||
| 182 | + | if initialURL == "" { | |||
| 183 | + | initialURL = "about:blank" | |||
| 184 | + | } | |||
| 175 | result := purecef.BrowserHostCreateBrowser( | 185 | result := purecef.BrowserHostCreateBrowser( | ||
| 176 | pc.windowInfo, | 186 | pc.windowInfo, | ||
| 177 | pc.client, | 187 | pc.client, | ||
| 178 | - | "about:blank", | 188 | + | initialURL, |
| 179 | pc.settings, | 189 | pc.settings, | ||
| 180 | nil, // extraInfo | 190 | nil, // extraInfo | ||
| 181 | nil, // requestContext | 191 | nil, // requestContext | ||
| 182 | ) | 192 | ) | ||
| 193 | + | log.Debug().Str("initial_url", initialURL).Msg("cef: BrowserHostCreateBrowser initial URL") | |||
| 183 | if f.engine != nil { | 194 | if f.engine != nil { | ||
| 184 | f.engine.recordBrowserCreateRequest(w, h, result) | 195 | f.engine.recordBrowserCreateRequest(w, h, result) | ||
| 185 | } | 196 | } | ||
M internal/infrastructure/cef/input_bridge.go +48 -1
| @@ -57,6 +57,14 @@ type inputBridge struct { | |||
| 57 | 57 | // ImeCommitText. Set once during attachTo, read-only afterwards. | |
| 58 | 58 | clipboard *gdk.Clipboard | |
| 59 | 59 | ||
| 60 | + | // selectionText returns the browser's latest known text selection so Ctrl+C | |
| 61 | + | // / Ctrl+X can route the selection through the shared explicit-copy path. | |
| 62 | + | selectionText func() string | |
| 63 | + | ||
| 64 | + | // explicitCopyText routes explicit copy/cut text through the shared | |
| 65 | + | // clipboard orchestration path. | |
| 66 | + | explicitCopyText func(action, text string) | |
| 67 | + | ||
| 60 | 68 | // IMContext for dead key / compose sequence support. Held as a field | |
| 61 | 69 | // to prevent garbage collection while the controller is alive. | |
| 62 | 70 | imContext *gtk.IMContextSimple | |
| @@ -223,6 +231,8 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) { | |||
| 223 | 231 | keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool { | |
| 224 | 232 | mods := uint(state) | |
| 225 | 233 | ||
| 234 | + | ib.maybeMirrorClipboardShortcut(keyval, mods) | |
| 235 | + | ||
| 226 | 236 | // Intercept Ctrl+V / Ctrl+Shift+V: CEF OSR cannot access the | |
| 227 | 237 | // Wayland/X11 clipboard, so we read from GDK and inject via | |
| 228 | 238 | // ImeCommitText instead of letting CEF handle the paste. | |
| @@ -371,6 +381,39 @@ func (ib *inputBridge) hasGTKFocus() bool { | |||
| 371 | 381 | return ib.gtkFocused.Load() | |
| 372 | 382 | } | |
| 373 | 383 | ||
| 384 | + | func (ib *inputBridge) maybeMirrorClipboardShortcut(keyval, mods uint) { | |
| 385 | + | action, ok := clipboardShortcutAction(keyval, mods) | |
| 386 | + | if !ok || ib == nil || ib.selectionText == nil || ib.explicitCopyText == nil { | |
| 387 | + | return | |
| 388 | + | } | |
| 389 | + | text := ib.selectionText() | |
| 390 | + | if text == "" { | |
| 391 | + | return | |
| 392 | + | } | |
| 393 | + | logging.FromContext(ib.ctx).Debug(). | |
| 394 | + | Str("action", action). | |
| 395 | + | Int("text_len", len(text)). | |
| 396 | + | Msg("cef: clipboard shortcut routed through explicit copy handler") | |
| 397 | + | ib.explicitCopyText(action, text) | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | func clipboardShortcutAction(keyval, mods uint) (string, bool) { | |
| 401 | + | if mods&uint(gdk.ControlMaskValue) == 0 { | |
| 402 | + | return "", false | |
| 403 | + | } | |
| 404 | + | if mods&uint(gdk.ShiftMaskValue) != 0 || mods&uint(gdk.AltMaskValue) != 0 { | |
| 405 | + | return "", false | |
| 406 | + | } | |
| 407 | + | switch keyval { | |
| 408 | + | case gdkKeyLowercaseC, gdkKeyUppercaseC: | |
| 409 | + | return "copy", true | |
| 410 | + | case gdkKeyLowercaseX, gdkKeyUppercaseX: | |
| 411 | + | return "cut", true | |
| 412 | + | default: | |
| 413 | + | return "", false | |
| 414 | + | } | |
| 415 | + | } | |
| 416 | + | ||
| 374 | 417 | func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) { | |
| 375 | 418 | ib.mu.Lock() | |
| 376 | 419 | host := ib.host | |
| @@ -591,9 +634,13 @@ const ( | |||
| 591 | 634 | gdkKeyPageUp = 0xff55 | |
| 592 | 635 | gdkKeyPageDown = 0xff56 | |
| 593 | 636 | ||
| 594 | - | // Paste shortcut keyvals. | |
| 637 | + | // Clipboard shortcut keyvals. | |
| 638 | + | gdkKeyLowercaseC = 0x063 | |
| 639 | + | gdkKeyUppercaseC = 0x043 | |
| 595 | 640 | gdkKeyLowercaseV = 0x076 | |
| 596 | 641 | gdkKeyUppercaseV = 0x056 | |
| 642 | + | gdkKeyLowercaseX = 0x078 | |
| 643 | + | gdkKeyUppercaseX = 0x058 | |
| 597 | 644 | ) | |
| 598 | 645 | ||
| 599 | 646 | // keyvalToChar converts a GDK keyval to a UTF-16 character for CEF CHAR events. | |
| @@ -57,6 +57,14 @@ type inputBridge struct { | |||||
| 57 | // ImeCommitText. Set once during attachTo, read-only afterwards. | 57 | // ImeCommitText. Set once during attachTo, read-only afterwards. | ||
| 58 | clipboard *gdk.Clipboard | 58 | clipboard *gdk.Clipboard | ||
| 59 | 59 | ||||
| 60 | + | // selectionText returns the browser's latest known text selection so Ctrl+C | |||
| 61 | + | // / Ctrl+X can route the selection through the shared explicit-copy path. | |||
| 62 | + | selectionText func() string | |||
| 63 | + | ||||
| 64 | + | // explicitCopyText routes explicit copy/cut text through the shared | |||
| 65 | + | // clipboard orchestration path. | |||
| 66 | + | explicitCopyText func(action, text string) | |||
| 67 | + | ||||
| 60 | // IMContext for dead key / compose sequence support. Held as a field | 68 | // IMContext for dead key / compose sequence support. Held as a field | ||
| 61 | // to prevent garbage collection while the controller is alive. | 69 | // to prevent garbage collection while the controller is alive. | ||
| 62 | imContext *gtk.IMContextSimple | 70 | imContext *gtk.IMContextSimple | ||
| @@ -223,6 +231,8 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) { | |||||
| 223 | keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool { | 231 | keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool { | ||
| 224 | mods := uint(state) | 232 | mods := uint(state) | ||
| 225 | 233 | ||||
| 234 | + | ib.maybeMirrorClipboardShortcut(keyval, mods) | |||
| 235 | + | ||||
| 226 | // Intercept Ctrl+V / Ctrl+Shift+V: CEF OSR cannot access the | 236 | // Intercept Ctrl+V / Ctrl+Shift+V: CEF OSR cannot access the | ||
| 227 | // Wayland/X11 clipboard, so we read from GDK and inject via | 237 | // Wayland/X11 clipboard, so we read from GDK and inject via | ||
| 228 | // ImeCommitText instead of letting CEF handle the paste. | 238 | // ImeCommitText instead of letting CEF handle the paste. | ||
| @@ -371,6 +381,39 @@ func (ib *inputBridge) hasGTKFocus() bool { | |||||
| 371 | return ib.gtkFocused.Load() | 381 | return ib.gtkFocused.Load() | ||
| 372 | } | 382 | } | ||
| 373 | 383 | ||||
| 384 | + | func (ib *inputBridge) maybeMirrorClipboardShortcut(keyval, mods uint) { | |||
| 385 | + | action, ok := clipboardShortcutAction(keyval, mods) | |||
| 386 | + | if !ok || ib == nil || ib.selectionText == nil || ib.explicitCopyText == nil { | |||
| 387 | + | return | |||
| 388 | + | } | |||
| 389 | + | text := ib.selectionText() | |||
| 390 | + | if text == "" { | |||
| 391 | + | return | |||
| 392 | + | } | |||
| 393 | + | logging.FromContext(ib.ctx).Debug(). | |||
| 394 | + | Str("action", action). | |||
| 395 | + | Int("text_len", len(text)). | |||
| 396 | + | Msg("cef: clipboard shortcut routed through explicit copy handler") | |||
| 397 | + | ib.explicitCopyText(action, text) | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | func clipboardShortcutAction(keyval, mods uint) (string, bool) { | |||
| 401 | + | if mods&uint(gdk.ControlMaskValue) == 0 { | |||
| 402 | + | return "", false | |||
| 403 | + | } | |||
| 404 | + | if mods&uint(gdk.ShiftMaskValue) != 0 || mods&uint(gdk.AltMaskValue) != 0 { | |||
| 405 | + | return "", false | |||
| 406 | + | } | |||
| 407 | + | switch keyval { | |||
| 408 | + | case gdkKeyLowercaseC, gdkKeyUppercaseC: | |||
| 409 | + | return "copy", true | |||
| 410 | + | case gdkKeyLowercaseX, gdkKeyUppercaseX: | |||
| 411 | + | return "cut", true | |||
| 412 | + | default: | |||
| 413 | + | return "", false | |||
| 414 | + | } | |||
| 415 | + | } | |||
| 416 | + | ||||
| 374 | func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) { | 417 | func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) { | ||
| 375 | ib.mu.Lock() | 418 | ib.mu.Lock() | ||
| 376 | host := ib.host | 419 | host := ib.host | ||
| @@ -591,9 +634,13 @@ const ( | |||||
| 591 | gdkKeyPageUp = 0xff55 | 634 | gdkKeyPageUp = 0xff55 | ||
| 592 | gdkKeyPageDown = 0xff56 | 635 | gdkKeyPageDown = 0xff56 | ||
| 593 | 636 | ||||
| 594 | - | // Paste shortcut keyvals. | 637 | + | // Clipboard shortcut keyvals. |
| 638 | + | gdkKeyLowercaseC = 0x063 | |||
| 639 | + | gdkKeyUppercaseC = 0x043 | |||
| 595 | gdkKeyLowercaseV = 0x076 | 640 | gdkKeyLowercaseV = 0x076 | ||
| 596 | gdkKeyUppercaseV = 0x056 | 641 | gdkKeyUppercaseV = 0x056 | ||
| 642 | + | gdkKeyLowercaseX = 0x078 | |||
| 643 | + | gdkKeyUppercaseX = 0x058 | |||
| 597 | ) | 644 | ) | ||
| 598 | 645 | ||||
| 599 | // keyvalToChar converts a GDK keyval to a UTF-16 character for CEF CHAR events. | 646 | // keyvalToChar converts a GDK keyval to a UTF-16 character for CEF CHAR events. | ||
M internal/infrastructure/cef/input_bridge_test.go +44 -0
| @@ -1,9 +1,11 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | purecef "github.com/bnema/purego-cef/cef" | |
| 8 | + | "github.com/bnema/puregotk/v4/gdk" | |
| 7 | 9 | "github.com/stretchr/testify/require" | |
| 8 | 10 | ) | |
| 9 | 11 | ||
| @@ -39,3 +41,45 @@ func TestSyncWindowlessBrowserFocus_ReassertsVisibleFocusedAndInvalidates(t *tes | |||
| 39 | 41 | require.Equal(t, []int32{1}, host.focusValues) | |
| 40 | 42 | require.Equal(t, []purecef.PaintElementType{purecef.PaintElementTypePetView}, host.invalidations) | |
| 41 | 43 | } | |
| 44 | + | ||
| 45 | + | func TestClipboardShortcutAction_RecognizesCopyAndCut(t *testing.T) { | |
| 46 | + | action, ok := clipboardShortcutAction(gdkKeyLowercaseC, uint(gdk.ControlMaskValue)) | |
| 47 | + | require.True(t, ok) | |
| 48 | + | require.Equal(t, "copy", action) | |
| 49 | + | ||
| 50 | + | action, ok = clipboardShortcutAction(gdkKeyUppercaseX, uint(gdk.ControlMaskValue)) | |
| 51 | + | require.True(t, ok) | |
| 52 | + | require.Equal(t, "cut", action) | |
| 53 | + | ||
| 54 | + | _, ok = clipboardShortcutAction(gdkKeyLowercaseC, uint(gdk.ControlMaskValue|gdk.ShiftMaskValue)) | |
| 55 | + | require.False(t, ok) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestInputBridgeMaybeMirrorClipboardShortcut_RoutesSelectionThroughExplicitHandler(t *testing.T) { | |
| 59 | + | ib := newInputBridge(context.Background(), 1) | |
| 60 | + | ib.selectionText = func() string { return "selected text" } | |
| 61 | + | routed := struct { | |
| 62 | + | action string | |
| 63 | + | text string | |
| 64 | + | }{} | |
| 65 | + | ib.explicitCopyText = func(action, text string) { | |
| 66 | + | routed.action = action | |
| 67 | + | routed.text = text | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | ib.maybeMirrorClipboardShortcut(gdkKeyLowercaseC, uint(gdk.ControlMaskValue)) | |
| 71 | + | ||
| 72 | + | require.Equal(t, "copy", routed.action) | |
| 73 | + | require.Equal(t, "selected text", routed.text) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestInputBridgeMaybeMirrorClipboardShortcut_IgnoresEmptySelection(t *testing.T) { | |
| 77 | + | ib := newInputBridge(context.Background(), 1) | |
| 78 | + | ib.selectionText = func() string { return "" } | |
| 79 | + | called := false | |
| 80 | + | ib.explicitCopyText = func(string, string) { called = true } | |
| 81 | + | ||
| 82 | + | ib.maybeMirrorClipboardShortcut(gdkKeyLowercaseX, uint(gdk.ControlMaskValue)) | |
| 83 | + | ||
| 84 | + | require.False(t, called) | |
| 85 | + | } | |
| @@ -1,9 +1,11 @@ | |||||
| 1 | package cef | 1 | package cef | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | 6 | ||||
| 6 | purecef "github.com/bnema/purego-cef/cef" | 7 | purecef "github.com/bnema/purego-cef/cef" | ||
| 8 | + | "github.com/bnema/puregotk/v4/gdk" | |||
| 7 | "github.com/stretchr/testify/require" | 9 | "github.com/stretchr/testify/require" | ||
| 8 | ) | 10 | ) | ||
| 9 | 11 | ||||
| @@ -39,3 +41,45 @@ func TestSyncWindowlessBrowserFocus_ReassertsVisibleFocusedAndInvalidates(t *tes | |||||
| 39 | require.Equal(t, []int32{1}, host.focusValues) | 41 | require.Equal(t, []int32{1}, host.focusValues) | ||
| 40 | require.Equal(t, []purecef.PaintElementType{purecef.PaintElementTypePetView}, host.invalidations) | 42 | require.Equal(t, []purecef.PaintElementType{purecef.PaintElementTypePetView}, host.invalidations) | ||
| 41 | } | 43 | } | ||
| 44 | + | ||||
| 45 | + | func TestClipboardShortcutAction_RecognizesCopyAndCut(t *testing.T) { | |||
| 46 | + | action, ok := clipboardShortcutAction(gdkKeyLowercaseC, uint(gdk.ControlMaskValue)) | |||
| 47 | + | require.True(t, ok) | |||
| 48 | + | require.Equal(t, "copy", action) | |||
| 49 | + | ||||
| 50 | + | action, ok = clipboardShortcutAction(gdkKeyUppercaseX, uint(gdk.ControlMaskValue)) | |||
| 51 | + | require.True(t, ok) | |||
| 52 | + | require.Equal(t, "cut", action) | |||
| 53 | + | ||||
| 54 | + | _, ok = clipboardShortcutAction(gdkKeyLowercaseC, uint(gdk.ControlMaskValue|gdk.ShiftMaskValue)) | |||
| 55 | + | require.False(t, ok) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestInputBridgeMaybeMirrorClipboardShortcut_RoutesSelectionThroughExplicitHandler(t *testing.T) { | |||
| 59 | + | ib := newInputBridge(context.Background(), 1) | |||
| 60 | + | ib.selectionText = func() string { return "selected text" } | |||
| 61 | + | routed := struct { | |||
| 62 | + | action string | |||
| 63 | + | text string | |||
| 64 | + | }{} | |||
| 65 | + | ib.explicitCopyText = func(action, text string) { | |||
| 66 | + | routed.action = action | |||
| 67 | + | routed.text = text | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | ib.maybeMirrorClipboardShortcut(gdkKeyLowercaseC, uint(gdk.ControlMaskValue)) | |||
| 71 | + | ||||
| 72 | + | require.Equal(t, "copy", routed.action) | |||
| 73 | + | require.Equal(t, "selected text", routed.text) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestInputBridgeMaybeMirrorClipboardShortcut_IgnoresEmptySelection(t *testing.T) { | |||
| 77 | + | ib := newInputBridge(context.Background(), 1) | |||
| 78 | + | ib.selectionText = func() string { return "" } | |||
| 79 | + | called := false | |||
| 80 | + | ib.explicitCopyText = func(string, string) { called = true } | |||
| 81 | + | ||||
| 82 | + | ib.maybeMirrorClipboardShortcut(gdkKeyLowercaseX, uint(gdk.ControlMaskValue)) | |||
| 83 | + | ||||
| 84 | + | require.False(t, called) | |||
| 85 | + | } | |||
M internal/infrastructure/cef/render_pipeline.go +55 -0
| @@ -114,6 +114,27 @@ type renderPipeline struct { | |||
| 114 | 114 | onResizeCB func(width, height int32) | |
| 115 | 115 | } | |
| 116 | 116 | ||
| 117 | + | type renderPipelineSnapshot struct { | |
| 118 | + | Width int32 | |
| 119 | + | Height int32 | |
| 120 | + | Scale int32 | |
| 121 | + | GLReady bool | |
| 122 | + | NeedsUpload bool | |
| 123 | + | SizeChanged bool | |
| 124 | + | ForceFullUpload bool | |
| 125 | + | ViewRectSeq uint64 | |
| 126 | + | ScreenInfoSeq uint64 | |
| 127 | + | PaintSeq uint64 | |
| 128 | + | ResizeSeq uint64 | |
| 129 | + | LastQueuedPaintSeq uint64 | |
| 130 | + | GLRenderSeq uint64 | |
| 131 | + | PaintCount uint64 | |
| 132 | + | QueueRenderCount uint64 | |
| 133 | + | RenderCount uint64 | |
| 134 | + | UploadCount uint64 | |
| 135 | + | FullUploadCount uint64 | |
| 136 | + | } | |
| 137 | + | ||
| 117 | 138 | // Shader sources for the fullscreen textured quad. | |
| 118 | 139 | const vertexShaderSource = "" + | |
| 119 | 140 | "#version 330 core\n" + | |
| @@ -806,6 +827,40 @@ func (rp *renderPipeline) maybeLogDiagnostics() { | |||
| 806 | 827 | } | |
| 807 | 828 | ||
| 808 | 829 | // buildShaderProgram compiles and links the vertex+fragment shaders. | |
| 830 | + | func (rp *renderPipeline) diagnosticSnapshot() renderPipelineSnapshot { | |
| 831 | + | if rp == nil { | |
| 832 | + | return renderPipelineSnapshot{} | |
| 833 | + | } | |
| 834 | + | ||
| 835 | + | rp.diagMu.Lock() | |
| 836 | + | snap := renderPipelineSnapshot{ | |
| 837 | + | Width: rp.widthAtomic.Load(), | |
| 838 | + | Height: rp.heightAtomic.Load(), | |
| 839 | + | ViewRectSeq: rp.viewRectSeq.Load(), | |
| 840 | + | ScreenInfoSeq: rp.screenInfoSeq.Load(), | |
| 841 | + | PaintSeq: rp.paintSeq.Load(), | |
| 842 | + | ResizeSeq: rp.resizeSeq.Load(), | |
| 843 | + | LastQueuedPaintSeq: rp.lastQueuedPaintSeq.Load(), | |
| 844 | + | GLRenderSeq: rp.glRenderSeq.Load(), | |
| 845 | + | PaintCount: rp.paintCount, | |
| 846 | + | QueueRenderCount: rp.queueRenderCount, | |
| 847 | + | RenderCount: rp.glRenderCount, | |
| 848 | + | UploadCount: rp.uploadCount, | |
| 849 | + | FullUploadCount: rp.fullUploadCount, | |
| 850 | + | } | |
| 851 | + | rp.diagMu.Unlock() | |
| 852 | + | ||
| 853 | + | rp.mu.Lock() | |
| 854 | + | snap.Scale = rp.scale | |
| 855 | + | snap.GLReady = rp.glReady | |
| 856 | + | snap.NeedsUpload = rp.needsUpload | |
| 857 | + | snap.SizeChanged = rp.sizeChanged | |
| 858 | + | snap.ForceFullUpload = rp.forceFullUpload | |
| 859 | + | rp.mu.Unlock() | |
| 860 | + | ||
| 861 | + | return snap | |
| 862 | + | } | |
| 863 | + | ||
| 809 | 864 | func (rp *renderPipeline) buildShaderProgram() uint32 { | |
| 810 | 865 | gl := rp.gl | |
| 811 | 866 | ||
| @@ -114,6 +114,27 @@ type renderPipeline struct { | |||||
| 114 | onResizeCB func(width, height int32) | 114 | onResizeCB func(width, height int32) | ||
| 115 | } | 115 | } | ||
| 116 | 116 | ||||
| 117 | + | type renderPipelineSnapshot struct { | |||
| 118 | + | Width int32 | |||
| 119 | + | Height int32 | |||
| 120 | + | Scale int32 | |||
| 121 | + | GLReady bool | |||
| 122 | + | NeedsUpload bool | |||
| 123 | + | SizeChanged bool | |||
| 124 | + | ForceFullUpload bool | |||
| 125 | + | ViewRectSeq uint64 | |||
| 126 | + | ScreenInfoSeq uint64 | |||
| 127 | + | PaintSeq uint64 | |||
| 128 | + | ResizeSeq uint64 | |||
| 129 | + | LastQueuedPaintSeq uint64 | |||
| 130 | + | GLRenderSeq uint64 | |||
| 131 | + | PaintCount uint64 | |||
| 132 | + | QueueRenderCount uint64 | |||
| 133 | + | RenderCount uint64 | |||
| 134 | + | UploadCount uint64 | |||
| 135 | + | FullUploadCount uint64 | |||
| 136 | + | } | |||
| 137 | + | ||||
| 117 | // Shader sources for the fullscreen textured quad. | 138 | // Shader sources for the fullscreen textured quad. | ||
| 118 | const vertexShaderSource = "" + | 139 | const vertexShaderSource = "" + | ||
| 119 | "#version 330 core\n" + | 140 | "#version 330 core\n" + | ||
| @@ -806,6 +827,40 @@ func (rp *renderPipeline) maybeLogDiagnostics() { | |||||
| 806 | } | 827 | } | ||
| 807 | 828 | ||||
| 808 | // buildShaderProgram compiles and links the vertex+fragment shaders. | 829 | // buildShaderProgram compiles and links the vertex+fragment shaders. | ||
| 830 | + | func (rp *renderPipeline) diagnosticSnapshot() renderPipelineSnapshot { | |||
| 831 | + | if rp == nil { | |||
| 832 | + | return renderPipelineSnapshot{} | |||
| 833 | + | } | |||
| 834 | + | ||||
| 835 | + | rp.diagMu.Lock() | |||
| 836 | + | snap := renderPipelineSnapshot{ | |||
| 837 | + | Width: rp.widthAtomic.Load(), | |||
| 838 | + | Height: rp.heightAtomic.Load(), | |||
| 839 | + | ViewRectSeq: rp.viewRectSeq.Load(), | |||
| 840 | + | ScreenInfoSeq: rp.screenInfoSeq.Load(), | |||
| 841 | + | PaintSeq: rp.paintSeq.Load(), | |||
| 842 | + | ResizeSeq: rp.resizeSeq.Load(), | |||
| 843 | + | LastQueuedPaintSeq: rp.lastQueuedPaintSeq.Load(), | |||
| 844 | + | GLRenderSeq: rp.glRenderSeq.Load(), | |||
| 845 | + | PaintCount: rp.paintCount, | |||
| 846 | + | QueueRenderCount: rp.queueRenderCount, | |||
| 847 | + | RenderCount: rp.glRenderCount, | |||
| 848 | + | UploadCount: rp.uploadCount, | |||
| 849 | + | FullUploadCount: rp.fullUploadCount, | |||
| 850 | + | } | |||
| 851 | + | rp.diagMu.Unlock() | |||
| 852 | + | ||||
| 853 | + | rp.mu.Lock() | |||
| 854 | + | snap.Scale = rp.scale | |||
| 855 | + | snap.GLReady = rp.glReady | |||
| 856 | + | snap.NeedsUpload = rp.needsUpload | |||
| 857 | + | snap.SizeChanged = rp.sizeChanged | |||
| 858 | + | snap.ForceFullUpload = rp.forceFullUpload | |||
| 859 | + | rp.mu.Unlock() | |||
| 860 | + | ||||
| 861 | + | return snap | |||
| 862 | + | } | |||
| 863 | + | ||||
| 809 | func (rp *renderPipeline) buildShaderProgram() uint32 { | 864 | func (rp *renderPipeline) buildShaderProgram() uint32 { | ||
| 810 | gl := rp.gl | 865 | gl := rp.gl | ||
| 811 | 866 | ||||
M internal/infrastructure/cef/webview.go +257 -9
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "math" | |
| 9 | + | "strings" | |
| 9 | 10 | "sync" | |
| 10 | 11 | "sync/atomic" | |
| 11 | 12 | "time" | |
| @@ -31,7 +32,26 @@ var errDestroyed = errors.New("cef: webview is destroyed") | |||
| 31 | 32 | // errNoBrowser is returned when the browser has not been created yet. | |
| 32 | 33 | var errNoBrowser = errors.New("cef: browser not yet created") | |
| 33 | 34 | ||
| 34 | - | const clipboardSelectionDebounceDelay = 300 * time.Millisecond | |
| 35 | + | const ( | |
| 36 | + | clipboardSelectionDebounceDelay = 300 * time.Millisecond | |
| 37 | + | pendingNavigationRetryDelay = 25 * time.Millisecond | |
| 38 | + | pendingNavigationMaxRetries = 20 | |
| 39 | + | ) | |
| 40 | + | ||
| 41 | + | var cefLoadWatchdogDelays = []time.Duration{ | |
| 42 | + | 250 * time.Millisecond, | |
| 43 | + | 1 * time.Second, | |
| 44 | + | 2 * time.Second, | |
| 45 | + | 5 * time.Second, | |
| 46 | + | 15 * time.Second, | |
| 47 | + | 30 * time.Second, | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | var ( | |
| 51 | + | cefNewTask = purecef.NewTask | |
| 52 | + | cefPostTask = purecef.PostTask | |
| 53 | + | cefPostDelayedTask = purecef.PostDelayedTask | |
| 54 | + | ) | |
| 35 | 55 | ||
| 36 | 56 | // WebView implements port.WebView using a CEF off-screen browser rendered | |
| 37 | 57 | // through a renderPipeline and driven by an inputBridge. | |
| @@ -82,6 +102,13 @@ type WebView struct { | |||
| 82 | 102 | // Last known hover URI for middle-click → new tab. | |
| 83 | 103 | lastHoverURI string | |
| 84 | 104 | ||
| 105 | + | // Load diagnostics state (mutex-protected). | |
| 106 | + | loadDiagSeq uint64 | |
| 107 | + | loadDiagStartedAt time.Time | |
| 108 | + | loadDiagLastProgressAt time.Time | |
| 109 | + | loadDiagLastAddressAt time.Time | |
| 110 | + | loadDiagLastLoadStateAt time.Time | |
| 111 | + | ||
| 85 | 112 | // Atomic state. | |
| 86 | 113 | destroyed atomic.Bool | |
| 87 | 114 | fullscreen atomic.Bool | |
| @@ -134,16 +161,14 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error { | |||
| 134 | 161 | actualURI := toActualInternalURL(uri) | |
| 135 | 162 | wv.mu.Lock() | |
| 136 | 163 | browser := wv.browser | |
| 164 | + | // Always remember the latest requested URI so a browser/main-frame race | |
| 165 | + | // cannot strand startup on about:blank. | |
| 166 | + | wv.pendingURI = actualURI | |
| 167 | + | wv.mu.Unlock() | |
| 137 | 168 | if browser == nil { | |
| 138 | - | // Browser not yet created — queue the URI for OnAfterCreated. | |
| 139 | - | wv.pendingURI = actualURI | |
| 140 | - | wv.mu.Unlock() | |
| 141 | 169 | return nil | |
| 142 | 170 | } | |
| 143 | - | wv.mu.Unlock() | |
| 144 | - | if frame := browser.GetMainFrame(); frame != nil { | |
| 145 | - | frame.LoadURL(actualURI) | |
| 146 | - | } | |
| 171 | + | wv.schedulePendingNavigationReplay(browser, 0) | |
| 147 | 172 | return nil | |
| 148 | 173 | } | |
| 149 | 174 | ||
| @@ -515,11 +540,23 @@ func (wv *WebView) NativeWidget() uintptr { | |||
| 515 | 540 | ||
| 516 | 541 | func (wv *WebView) updateURI(uri string) { | |
| 517 | 542 | uri = toConceptualInternalURL(uri) | |
| 543 | + | now := time.Now() | |
| 518 | 544 | wv.mu.Lock() | |
| 519 | 545 | wv.uri = uri | |
| 546 | + | wv.loadDiagLastAddressAt = now | |
| 520 | 547 | cb := wv.callbacks | |
| 548 | + | pendingMatched := pendingURIEquivalent(wv.pendingURI, uri) | |
| 549 | + | if pendingMatched { | |
| 550 | + | wv.pendingURI = "" | |
| 551 | + | } | |
| 521 | 552 | wv.mu.Unlock() | |
| 522 | 553 | ||
| 554 | + | if pendingMatched && wv.ctx != nil { | |
| 555 | + | logging.FromContext(wv.ctx).Debug(). | |
| 556 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |
| 557 | + | Msg("cef: pending navigation acknowledged by address change") | |
| 558 | + | } | |
| 559 | + | ||
| 523 | 560 | if cb != nil && cb.OnURIChanged != nil { | |
| 524 | 561 | wv.runOnGTK(func() { | |
| 525 | 562 | cb.OnURIChanged(uri) | |
| @@ -541,8 +578,10 @@ func (wv *WebView) updateTitle(title string) { | |||
| 541 | 578 | } | |
| 542 | 579 | ||
| 543 | 580 | func (wv *WebView) updateProgress(progress float64) { | |
| 581 | + | now := time.Now() | |
| 544 | 582 | wv.mu.Lock() | |
| 545 | 583 | wv.progress = progress | |
| 584 | + | wv.loadDiagLastProgressAt = now | |
| 546 | 585 | cb := wv.callbacks | |
| 547 | 586 | wv.mu.Unlock() | |
| 548 | 587 | ||
| @@ -554,11 +593,26 @@ func (wv *WebView) updateProgress(progress float64) { | |||
| 554 | 593 | } | |
| 555 | 594 | ||
| 556 | 595 | func (wv *WebView) updateLoadState(loading, back, fwd bool) { | |
| 596 | + | now := time.Now() | |
| 557 | 597 | wv.mu.Lock() | |
| 598 | + | wasLoading := wv.isLoading | |
| 558 | 599 | wv.isLoading = loading | |
| 559 | 600 | wv.canGoBack = back | |
| 560 | 601 | wv.canGoFwd = fwd | |
| 602 | + | wv.loadDiagLastLoadStateAt = now | |
| 603 | + | var loadDiagSeq uint64 | |
| 604 | + | if loading && !wasLoading { | |
| 605 | + | wv.loadDiagSeq++ | |
| 606 | + | wv.loadDiagStartedAt = now | |
| 607 | + | wv.loadDiagLastProgressAt = time.Time{} | |
| 608 | + | wv.loadDiagLastAddressAt = time.Time{} | |
| 609 | + | loadDiagSeq = wv.loadDiagSeq | |
| 610 | + | } | |
| 561 | 611 | wv.mu.Unlock() | |
| 612 | + | ||
| 613 | + | if loadDiagSeq != 0 { | |
| 614 | + | wv.scheduleLoadWatchdogs(loadDiagSeq) | |
| 615 | + | } | |
| 562 | 616 | } | |
| 563 | 617 | ||
| 564 | 618 | func (wv *WebView) setAudioPlaying(playing bool) { | |
| @@ -587,6 +641,7 @@ func (wv *WebView) setEditableFocus(editable bool) { | |||
| 587 | 641 | return | |
| 588 | 642 | } | |
| 589 | 643 | wv.mu.Lock() | |
| 644 | + | previous := wv.focusedEditable | |
| 590 | 645 | wv.focusedEditable = editable | |
| 591 | 646 | if editable { | |
| 592 | 647 | wv.selectionDebounceSeq++ | |
| @@ -596,9 +651,15 @@ func (wv *WebView) setEditableFocus(editable bool) { | |||
| 596 | 651 | if timer != nil { | |
| 597 | 652 | timer.Stop() | |
| 598 | 653 | } | |
| 654 | + | if previous != editable && wv.ctx != nil { | |
| 655 | + | logging.FromContext(wv.ctx).Debug().Bool("editable", editable).Msg("cef: editable focus changed") | |
| 656 | + | } | |
| 599 | 657 | return | |
| 600 | 658 | } | |
| 601 | 659 | wv.mu.Unlock() | |
| 660 | + | if previous != editable && wv.ctx != nil { | |
| 661 | + | logging.FromContext(wv.ctx).Debug().Bool("editable", editable).Msg("cef: editable focus changed") | |
| 662 | + | } | |
| 602 | 663 | } | |
| 603 | 664 | ||
| 604 | 665 | func (wv *WebView) cancelSelectionDebounce() { | |
| @@ -620,10 +681,19 @@ func (wv *WebView) scheduleSelectionUpdate(text string) { | |||
| 620 | 681 | return | |
| 621 | 682 | } | |
| 622 | 683 | wv.mu.Lock() | |
| 623 | - | if wv.destroyed.Load() || wv.focusedEditable || wv.engine == nil { | |
| 684 | + | if wv.destroyed.Load() || wv.engine == nil { | |
| 624 | 685 | wv.mu.Unlock() | |
| 625 | 686 | return | |
| 626 | 687 | } | |
| 688 | + | if wv.focusedEditable { | |
| 689 | + | wv.mu.Unlock() | |
| 690 | + | if wv.ctx != nil { | |
| 691 | + | logging.FromContext(wv.ctx).Debug(). | |
| 692 | + | Int("text_len", len(text)). | |
| 693 | + | Msg("cef: selection auto-copy skipped while editable focused") | |
| 694 | + | } | |
| 695 | + | return | |
| 696 | + | } | |
| 627 | 697 | wv.selectionDebounceSeq++ | |
| 628 | 698 | seq := wv.selectionDebounceSeq | |
| 629 | 699 | if timer := wv.selectionDebounceTimer; timer != nil { | |
| @@ -648,6 +718,11 @@ func (wv *WebView) flushSelectionUpdate(seq uint64, text string) { | |||
| 648 | 718 | viewID := wv.id | |
| 649 | 719 | wv.mu.Unlock() | |
| 650 | 720 | if engine != nil { | |
| 721 | + | if wv.ctx != nil { | |
| 722 | + | logging.FromContext(wv.ctx).Debug(). | |
| 723 | + | Int("text_len", len(text)). | |
| 724 | + | Msg("cef: debounced selection update flushed") | |
| 725 | + | } | |
| 651 | 726 | engine.handleClipboardSelectionUpdate(viewID, text) | |
| 652 | 727 | } | |
| 653 | 728 | } | |
| @@ -658,6 +733,179 @@ func (wv *WebView) selectedTextSnapshot() string { | |||
| 658 | 733 | return wv.selectedText | |
| 659 | 734 | } | |
| 660 | 735 | ||
| 736 | + | func (wv *WebView) scheduleLoadWatchdogs(loadSeq uint64) { | |
| 737 | + | for _, delay := range cefLoadWatchdogDelays { | |
| 738 | + | d := delay | |
| 739 | + | time.AfterFunc(d, func() { | |
| 740 | + | wv.logLoadWatchdog(loadSeq, d) | |
| 741 | + | }) | |
| 742 | + | } | |
| 743 | + | } | |
| 744 | + | ||
| 745 | + | func (wv *WebView) logLoadWatchdog(loadSeq uint64, delay time.Duration) { | |
| 746 | + | if wv == nil || wv.destroyed.Load() || wv.ctx == nil { | |
| 747 | + | return | |
| 748 | + | } | |
| 749 | + | ||
| 750 | + | wv.mu.RLock() | |
| 751 | + | currentSeq := wv.loadDiagSeq | |
| 752 | + | loading := wv.isLoading | |
| 753 | + | uri := wv.uri | |
| 754 | + | title := wv.title | |
| 755 | + | progress := wv.progress | |
| 756 | + | pendingURI := wv.pendingURI | |
| 757 | + | startedAt := wv.loadDiagStartedAt | |
| 758 | + | lastProgressAt := wv.loadDiagLastProgressAt | |
| 759 | + | lastAddressAt := wv.loadDiagLastAddressAt | |
| 760 | + | lastLoadStateAt := wv.loadDiagLastLoadStateAt | |
| 761 | + | wv.mu.RUnlock() | |
| 762 | + | ||
| 763 | + | if loadSeq != currentSeq || !loading { | |
| 764 | + | return | |
| 765 | + | } | |
| 766 | + | ||
| 767 | + | snap := renderPipelineSnapshot{} | |
| 768 | + | if wv.pipeline != nil { | |
| 769 | + | snap = wv.pipeline.diagnosticSnapshot() | |
| 770 | + | } | |
| 771 | + | ||
| 772 | + | now := time.Now() | |
| 773 | + | logging.FromContext(wv.ctx).Debug(). | |
| 774 | + | Uint64("load_seq", loadSeq). | |
| 775 | + | Int64("watch_delay_ms", delay.Milliseconds()). | |
| 776 | + | Int64("since_start_ms", sinceTimestampMs(startedAt, now)). | |
| 777 | + | Int64("since_progress_ms", sinceTimestampMs(lastProgressAt, now)). | |
| 778 | + | Int64("since_address_ms", sinceTimestampMs(lastAddressAt, now)). | |
| 779 | + | Int64("since_load_state_ms", sinceTimestampMs(lastLoadStateAt, now)). | |
| 780 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |
| 781 | + | Str("title", title). | |
| 782 | + | Float64("progress", progress). | |
| 783 | + | Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)). | |
| 784 | + | Int32("surface_width", snap.Width). | |
| 785 | + | Int32("surface_height", snap.Height). | |
| 786 | + | Int32("surface_scale", snap.Scale). | |
| 787 | + | Bool("gl_ready", snap.GLReady). | |
| 788 | + | Bool("needs_upload", snap.NeedsUpload). | |
| 789 | + | Bool("size_changed", snap.SizeChanged). | |
| 790 | + | Bool("force_full_upload", snap.ForceFullUpload). | |
| 791 | + | Uint64("view_rect_seq", snap.ViewRectSeq). | |
| 792 | + | Uint64("screen_info_seq", snap.ScreenInfoSeq). | |
| 793 | + | Uint64("paint_seq", snap.PaintSeq). | |
| 794 | + | Uint64("resize_seq", snap.ResizeSeq). | |
| 795 | + | Uint64("last_queued_paint_seq", snap.LastQueuedPaintSeq). | |
| 796 | + | Uint64("gl_render_seq", snap.GLRenderSeq). | |
| 797 | + | Uint64("paint_total", snap.PaintCount). | |
| 798 | + | Uint64("queue_total", snap.QueueRenderCount). | |
| 799 | + | Uint64("render_total", snap.RenderCount). | |
| 800 | + | Uint64("upload_total", snap.UploadCount). | |
| 801 | + | Uint64("full_upload_total", snap.FullUploadCount). | |
| 802 | + | Msg("cef: loading watchdog") | |
| 803 | + | } | |
| 804 | + | ||
| 805 | + | func (wv *WebView) pendingNavigationURI() string { | |
| 806 | + | if wv == nil { | |
| 807 | + | return "" | |
| 808 | + | } | |
| 809 | + | wv.mu.RLock() | |
| 810 | + | defer wv.mu.RUnlock() | |
| 811 | + | return wv.pendingURI | |
| 812 | + | } | |
| 813 | + | ||
| 814 | + | func (wv *WebView) schedulePendingNavigationReplay(browser purecef.Browser, attempt int) { | |
| 815 | + | if wv == nil || browser == nil || wv.destroyed.Load() { | |
| 816 | + | return | |
| 817 | + | } | |
| 818 | + | task := cefNewTask(cefTaskFunc(func() { | |
| 819 | + | wv.replayPendingNavigation(browser, attempt) | |
| 820 | + | })) | |
| 821 | + | if task == nil { | |
| 822 | + | return | |
| 823 | + | } | |
| 824 | + | var result int32 | |
| 825 | + | if attempt <= 0 { | |
| 826 | + | result = cefPostTask(purecef.ThreadIDTidUi, task) | |
| 827 | + | } else { | |
| 828 | + | result = cefPostDelayedTask(purecef.ThreadIDTidUi, task, int64(pendingNavigationRetryDelay/time.Millisecond)) | |
| 829 | + | } | |
| 830 | + | if result != 1 && wv.ctx != nil { | |
| 831 | + | logging.FromContext(wv.ctx).Warn(). | |
| 832 | + | Int("attempt", attempt). | |
| 833 | + | Int32("result", result). | |
| 834 | + | Msg("cef: failed to schedule pending navigation replay") | |
| 835 | + | } | |
| 836 | + | } | |
| 837 | + | ||
| 838 | + | func (wv *WebView) replayPendingNavigation(browser purecef.Browser, attempt int) { | |
| 839 | + | if wv == nil || browser == nil || wv.destroyed.Load() { | |
| 840 | + | return | |
| 841 | + | } | |
| 842 | + | wv.mu.RLock() | |
| 843 | + | uri := wv.pendingURI | |
| 844 | + | wv.mu.RUnlock() | |
| 845 | + | if uri == "" { | |
| 846 | + | return | |
| 847 | + | } | |
| 848 | + | frame := browser.GetMainFrame() | |
| 849 | + | if frame == nil { | |
| 850 | + | if attempt >= pendingNavigationMaxRetries { | |
| 851 | + | if wv.ctx != nil { | |
| 852 | + | logging.FromContext(wv.ctx).Warn(). | |
| 853 | + | Int("attempt", attempt). | |
| 854 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |
| 855 | + | Msg("cef: pending navigation replay exhausted without main frame") | |
| 856 | + | } | |
| 857 | + | return | |
| 858 | + | } | |
| 859 | + | if wv.ctx != nil { | |
| 860 | + | logging.FromContext(wv.ctx).Debug(). | |
| 861 | + | Int("attempt", attempt). | |
| 862 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |
| 863 | + | Msg("cef: pending navigation replay waiting for main frame") | |
| 864 | + | } | |
| 865 | + | wv.schedulePendingNavigationReplay(browser, attempt+1) | |
| 866 | + | return | |
| 867 | + | } | |
| 868 | + | currentURL := "" | |
| 869 | + | if frame != nil { | |
| 870 | + | currentURL = frame.GetURL() | |
| 871 | + | } | |
| 872 | + | if pendingURIEquivalent(currentURL, uri) { | |
| 873 | + | wv.mu.Lock() | |
| 874 | + | if pendingURIEquivalent(wv.pendingURI, uri) { | |
| 875 | + | wv.pendingURI = "" | |
| 876 | + | } | |
| 877 | + | wv.mu.Unlock() | |
| 878 | + | if wv.ctx != nil { | |
| 879 | + | logging.FromContext(wv.ctx).Debug(). | |
| 880 | + | Int("attempt", attempt). | |
| 881 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |
| 882 | + | Msg("cef: pending navigation already active") | |
| 883 | + | } | |
| 884 | + | return | |
| 885 | + | } | |
| 886 | + | frame.LoadURL(uri) | |
| 887 | + | if wv.ctx != nil { | |
| 888 | + | logging.FromContext(wv.ctx).Debug(). | |
| 889 | + | Int("attempt", attempt). | |
| 890 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |
| 891 | + | Msg("cef: replayed pending navigation") | |
| 892 | + | } | |
| 893 | + | } | |
| 894 | + | ||
| 895 | + | func pendingURIEquivalent(a, b string) bool { | |
| 896 | + | if a == "" || b == "" { | |
| 897 | + | return false | |
| 898 | + | } | |
| 899 | + | return toConceptualInternalURL(strings.TrimSpace(a)) == toConceptualInternalURL(strings.TrimSpace(b)) | |
| 900 | + | } | |
| 901 | + | ||
| 902 | + | func sinceTimestampMs(ts, now time.Time) int64 { | |
| 903 | + | if ts.IsZero() { | |
| 904 | + | return -1 | |
| 905 | + | } | |
| 906 | + | return now.Sub(ts).Milliseconds() | |
| 907 | + | } | |
| 908 | + | ||
| 661 | 909 | func (wv *WebView) updateHoverURI(uri string) { | |
| 662 | 910 | wv.mu.Lock() | |
| 663 | 911 | wv.lastHoverURI = uri | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "math" | 8 | "math" | ||
| 9 | + | "strings" | |||
| 9 | "sync" | 10 | "sync" | ||
| 10 | "sync/atomic" | 11 | "sync/atomic" | ||
| 11 | "time" | 12 | "time" | ||
| @@ -31,7 +32,26 @@ var errDestroyed = errors.New("cef: webview is destroyed") | |||||
| 31 | // errNoBrowser is returned when the browser has not been created yet. | 32 | // errNoBrowser is returned when the browser has not been created yet. | ||
| 32 | var errNoBrowser = errors.New("cef: browser not yet created") | 33 | var errNoBrowser = errors.New("cef: browser not yet created") | ||
| 33 | 34 | ||||
| 34 | - | const clipboardSelectionDebounceDelay = 300 * time.Millisecond | 35 | + | const ( |
| 36 | + | clipboardSelectionDebounceDelay = 300 * time.Millisecond | |||
| 37 | + | pendingNavigationRetryDelay = 25 * time.Millisecond | |||
| 38 | + | pendingNavigationMaxRetries = 20 | |||
| 39 | + | ) | |||
| 40 | + | ||||
| 41 | + | var cefLoadWatchdogDelays = []time.Duration{ | |||
| 42 | + | 250 * time.Millisecond, | |||
| 43 | + | 1 * time.Second, | |||
| 44 | + | 2 * time.Second, | |||
| 45 | + | 5 * time.Second, | |||
| 46 | + | 15 * time.Second, | |||
| 47 | + | 30 * time.Second, | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | var ( | |||
| 51 | + | cefNewTask = purecef.NewTask | |||
| 52 | + | cefPostTask = purecef.PostTask | |||
| 53 | + | cefPostDelayedTask = purecef.PostDelayedTask | |||
| 54 | + | ) | |||
| 35 | 55 | ||||
| 36 | // WebView implements port.WebView using a CEF off-screen browser rendered | 56 | // WebView implements port.WebView using a CEF off-screen browser rendered | ||
| 37 | // through a renderPipeline and driven by an inputBridge. | 57 | // through a renderPipeline and driven by an inputBridge. | ||
| @@ -82,6 +102,13 @@ type WebView struct { | |||||
| 82 | // Last known hover URI for middle-click → new tab. | 102 | // Last known hover URI for middle-click → new tab. | ||
| 83 | lastHoverURI string | 103 | lastHoverURI string | ||
| 84 | 104 | ||||
| 105 | + | // Load diagnostics state (mutex-protected). | |||
| 106 | + | loadDiagSeq uint64 | |||
| 107 | + | loadDiagStartedAt time.Time | |||
| 108 | + | loadDiagLastProgressAt time.Time | |||
| 109 | + | loadDiagLastAddressAt time.Time | |||
| 110 | + | loadDiagLastLoadStateAt time.Time | |||
| 111 | + | ||||
| 85 | // Atomic state. | 112 | // Atomic state. | ||
| 86 | destroyed atomic.Bool | 113 | destroyed atomic.Bool | ||
| 87 | fullscreen atomic.Bool | 114 | fullscreen atomic.Bool | ||
| @@ -134,16 +161,14 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error { | |||||
| 134 | actualURI := toActualInternalURL(uri) | 161 | actualURI := toActualInternalURL(uri) | ||
| 135 | wv.mu.Lock() | 162 | wv.mu.Lock() | ||
| 136 | browser := wv.browser | 163 | browser := wv.browser | ||
| 164 | + | // Always remember the latest requested URI so a browser/main-frame race | |||
| 165 | + | // cannot strand startup on about:blank. | |||
| 166 | + | wv.pendingURI = actualURI | |||
| 167 | + | wv.mu.Unlock() | |||
| 137 | if browser == nil { | 168 | if browser == nil { | ||
| 138 | - | // Browser not yet created — queue the URI for OnAfterCreated. | |||
| 139 | - | wv.pendingURI = actualURI | |||
| 140 | - | wv.mu.Unlock() | |||
| 141 | return nil | 169 | return nil | ||
| 142 | } | 170 | } | ||
| 143 | - | wv.mu.Unlock() | 171 | + | wv.schedulePendingNavigationReplay(browser, 0) |
| 144 | - | if frame := browser.GetMainFrame(); frame != nil { | |||
| 145 | - | frame.LoadURL(actualURI) | |||
| 146 | - | } | |||
| 147 | return nil | 172 | return nil | ||
| 148 | } | 173 | } | ||
| 149 | 174 | ||||
| @@ -515,11 +540,23 @@ func (wv *WebView) NativeWidget() uintptr { | |||||
| 515 | 540 | ||||
| 516 | func (wv *WebView) updateURI(uri string) { | 541 | func (wv *WebView) updateURI(uri string) { | ||
| 517 | uri = toConceptualInternalURL(uri) | 542 | uri = toConceptualInternalURL(uri) | ||
| 543 | + | now := time.Now() | |||
| 518 | wv.mu.Lock() | 544 | wv.mu.Lock() | ||
| 519 | wv.uri = uri | 545 | wv.uri = uri | ||
| 546 | + | wv.loadDiagLastAddressAt = now | |||
| 520 | cb := wv.callbacks | 547 | cb := wv.callbacks | ||
| 548 | + | pendingMatched := pendingURIEquivalent(wv.pendingURI, uri) | |||
| 549 | + | if pendingMatched { | |||
| 550 | + | wv.pendingURI = "" | |||
| 551 | + | } | |||
| 521 | wv.mu.Unlock() | 552 | wv.mu.Unlock() | ||
| 522 | 553 | ||||
| 554 | + | if pendingMatched && wv.ctx != nil { | |||
| 555 | + | logging.FromContext(wv.ctx).Debug(). | |||
| 556 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |||
| 557 | + | Msg("cef: pending navigation acknowledged by address change") | |||
| 558 | + | } | |||
| 559 | + | ||||
| 523 | if cb != nil && cb.OnURIChanged != nil { | 560 | if cb != nil && cb.OnURIChanged != nil { | ||
| 524 | wv.runOnGTK(func() { | 561 | wv.runOnGTK(func() { | ||
| 525 | cb.OnURIChanged(uri) | 562 | cb.OnURIChanged(uri) | ||
| @@ -541,8 +578,10 @@ func (wv *WebView) updateTitle(title string) { | |||||
| 541 | } | 578 | } | ||
| 542 | 579 | ||||
| 543 | func (wv *WebView) updateProgress(progress float64) { | 580 | func (wv *WebView) updateProgress(progress float64) { | ||
| 581 | + | now := time.Now() | |||
| 544 | wv.mu.Lock() | 582 | wv.mu.Lock() | ||
| 545 | wv.progress = progress | 583 | wv.progress = progress | ||
| 584 | + | wv.loadDiagLastProgressAt = now | |||
| 546 | cb := wv.callbacks | 585 | cb := wv.callbacks | ||
| 547 | wv.mu.Unlock() | 586 | wv.mu.Unlock() | ||
| 548 | 587 | ||||
| @@ -554,11 +593,26 @@ func (wv *WebView) updateProgress(progress float64) { | |||||
| 554 | } | 593 | } | ||
| 555 | 594 | ||||
| 556 | func (wv *WebView) updateLoadState(loading, back, fwd bool) { | 595 | func (wv *WebView) updateLoadState(loading, back, fwd bool) { | ||
| 596 | + | now := time.Now() | |||
| 557 | wv.mu.Lock() | 597 | wv.mu.Lock() | ||
| 598 | + | wasLoading := wv.isLoading | |||
| 558 | wv.isLoading = loading | 599 | wv.isLoading = loading | ||
| 559 | wv.canGoBack = back | 600 | wv.canGoBack = back | ||
| 560 | wv.canGoFwd = fwd | 601 | wv.canGoFwd = fwd | ||
| 602 | + | wv.loadDiagLastLoadStateAt = now | |||
| 603 | + | var loadDiagSeq uint64 | |||
| 604 | + | if loading && !wasLoading { | |||
| 605 | + | wv.loadDiagSeq++ | |||
| 606 | + | wv.loadDiagStartedAt = now | |||
| 607 | + | wv.loadDiagLastProgressAt = time.Time{} | |||
| 608 | + | wv.loadDiagLastAddressAt = time.Time{} | |||
| 609 | + | loadDiagSeq = wv.loadDiagSeq | |||
| 610 | + | } | |||
| 561 | wv.mu.Unlock() | 611 | wv.mu.Unlock() | ||
| 612 | + | ||||
| 613 | + | if loadDiagSeq != 0 { | |||
| 614 | + | wv.scheduleLoadWatchdogs(loadDiagSeq) | |||
| 615 | + | } | |||
| 562 | } | 616 | } | ||
| 563 | 617 | ||||
| 564 | func (wv *WebView) setAudioPlaying(playing bool) { | 618 | func (wv *WebView) setAudioPlaying(playing bool) { | ||
| @@ -587,6 +641,7 @@ func (wv *WebView) setEditableFocus(editable bool) { | |||||
| 587 | return | 641 | return | ||
| 588 | } | 642 | } | ||
| 589 | wv.mu.Lock() | 643 | wv.mu.Lock() | ||
| 644 | + | previous := wv.focusedEditable | |||
| 590 | wv.focusedEditable = editable | 645 | wv.focusedEditable = editable | ||
| 591 | if editable { | 646 | if editable { | ||
| 592 | wv.selectionDebounceSeq++ | 647 | wv.selectionDebounceSeq++ | ||
| @@ -596,9 +651,15 @@ func (wv *WebView) setEditableFocus(editable bool) { | |||||
| 596 | if timer != nil { | 651 | if timer != nil { | ||
| 597 | timer.Stop() | 652 | timer.Stop() | ||
| 598 | } | 653 | } | ||
| 654 | + | if previous != editable && wv.ctx != nil { | |||
| 655 | + | logging.FromContext(wv.ctx).Debug().Bool("editable", editable).Msg("cef: editable focus changed") | |||
| 656 | + | } | |||
| 599 | return | 657 | return | ||
| 600 | } | 658 | } | ||
| 601 | wv.mu.Unlock() | 659 | wv.mu.Unlock() | ||
| 660 | + | if previous != editable && wv.ctx != nil { | |||
| 661 | + | logging.FromContext(wv.ctx).Debug().Bool("editable", editable).Msg("cef: editable focus changed") | |||
| 662 | + | } | |||
| 602 | } | 663 | } | ||
| 603 | 664 | ||||
| 604 | func (wv *WebView) cancelSelectionDebounce() { | 665 | func (wv *WebView) cancelSelectionDebounce() { | ||
| @@ -620,10 +681,19 @@ func (wv *WebView) scheduleSelectionUpdate(text string) { | |||||
| 620 | return | 681 | return | ||
| 621 | } | 682 | } | ||
| 622 | wv.mu.Lock() | 683 | wv.mu.Lock() | ||
| 623 | - | if wv.destroyed.Load() || wv.focusedEditable || wv.engine == nil { | 684 | + | if wv.destroyed.Load() || wv.engine == nil { |
| 624 | wv.mu.Unlock() | 685 | wv.mu.Unlock() | ||
| 625 | return | 686 | return | ||
| 626 | } | 687 | } | ||
| 688 | + | if wv.focusedEditable { | |||
| 689 | + | wv.mu.Unlock() | |||
| 690 | + | if wv.ctx != nil { | |||
| 691 | + | logging.FromContext(wv.ctx).Debug(). | |||
| 692 | + | Int("text_len", len(text)). | |||
| 693 | + | Msg("cef: selection auto-copy skipped while editable focused") | |||
| 694 | + | } | |||
| 695 | + | return | |||
| 696 | + | } | |||
| 627 | wv.selectionDebounceSeq++ | 697 | wv.selectionDebounceSeq++ | ||
| 628 | seq := wv.selectionDebounceSeq | 698 | seq := wv.selectionDebounceSeq | ||
| 629 | if timer := wv.selectionDebounceTimer; timer != nil { | 699 | if timer := wv.selectionDebounceTimer; timer != nil { | ||
| @@ -648,6 +718,11 @@ func (wv *WebView) flushSelectionUpdate(seq uint64, text string) { | |||||
| 648 | viewID := wv.id | 718 | viewID := wv.id | ||
| 649 | wv.mu.Unlock() | 719 | wv.mu.Unlock() | ||
| 650 | if engine != nil { | 720 | if engine != nil { | ||
| 721 | + | if wv.ctx != nil { | |||
| 722 | + | logging.FromContext(wv.ctx).Debug(). | |||
| 723 | + | Int("text_len", len(text)). | |||
| 724 | + | Msg("cef: debounced selection update flushed") | |||
| 725 | + | } | |||
| 651 | engine.handleClipboardSelectionUpdate(viewID, text) | 726 | engine.handleClipboardSelectionUpdate(viewID, text) | ||
| 652 | } | 727 | } | ||
| 653 | } | 728 | } | ||
| @@ -658,6 +733,179 @@ func (wv *WebView) selectedTextSnapshot() string { | |||||
| 658 | return wv.selectedText | 733 | return wv.selectedText | ||
| 659 | } | 734 | } | ||
| 660 | 735 | ||||
| 736 | + | func (wv *WebView) scheduleLoadWatchdogs(loadSeq uint64) { | |||
| 737 | + | for _, delay := range cefLoadWatchdogDelays { | |||
| 738 | + | d := delay | |||
| 739 | + | time.AfterFunc(d, func() { | |||
| 740 | + | wv.logLoadWatchdog(loadSeq, d) | |||
| 741 | + | }) | |||
| 742 | + | } | |||
| 743 | + | } | |||
| 744 | + | ||||
| 745 | + | func (wv *WebView) logLoadWatchdog(loadSeq uint64, delay time.Duration) { | |||
| 746 | + | if wv == nil || wv.destroyed.Load() || wv.ctx == nil { | |||
| 747 | + | return | |||
| 748 | + | } | |||
| 749 | + | ||||
| 750 | + | wv.mu.RLock() | |||
| 751 | + | currentSeq := wv.loadDiagSeq | |||
| 752 | + | loading := wv.isLoading | |||
| 753 | + | uri := wv.uri | |||
| 754 | + | title := wv.title | |||
| 755 | + | progress := wv.progress | |||
| 756 | + | pendingURI := wv.pendingURI | |||
| 757 | + | startedAt := wv.loadDiagStartedAt | |||
| 758 | + | lastProgressAt := wv.loadDiagLastProgressAt | |||
| 759 | + | lastAddressAt := wv.loadDiagLastAddressAt | |||
| 760 | + | lastLoadStateAt := wv.loadDiagLastLoadStateAt | |||
| 761 | + | wv.mu.RUnlock() | |||
| 762 | + | ||||
| 763 | + | if loadSeq != currentSeq || !loading { | |||
| 764 | + | return | |||
| 765 | + | } | |||
| 766 | + | ||||
| 767 | + | snap := renderPipelineSnapshot{} | |||
| 768 | + | if wv.pipeline != nil { | |||
| 769 | + | snap = wv.pipeline.diagnosticSnapshot() | |||
| 770 | + | } | |||
| 771 | + | ||||
| 772 | + | now := time.Now() | |||
| 773 | + | logging.FromContext(wv.ctx).Debug(). | |||
| 774 | + | Uint64("load_seq", loadSeq). | |||
| 775 | + | Int64("watch_delay_ms", delay.Milliseconds()). | |||
| 776 | + | Int64("since_start_ms", sinceTimestampMs(startedAt, now)). | |||
| 777 | + | Int64("since_progress_ms", sinceTimestampMs(lastProgressAt, now)). | |||
| 778 | + | Int64("since_address_ms", sinceTimestampMs(lastAddressAt, now)). | |||
| 779 | + | Int64("since_load_state_ms", sinceTimestampMs(lastLoadStateAt, now)). | |||
| 780 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |||
| 781 | + | Str("title", title). | |||
| 782 | + | Float64("progress", progress). | |||
| 783 | + | Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)). | |||
| 784 | + | Int32("surface_width", snap.Width). | |||
| 785 | + | Int32("surface_height", snap.Height). | |||
| 786 | + | Int32("surface_scale", snap.Scale). | |||
| 787 | + | Bool("gl_ready", snap.GLReady). | |||
| 788 | + | Bool("needs_upload", snap.NeedsUpload). | |||
| 789 | + | Bool("size_changed", snap.SizeChanged). | |||
| 790 | + | Bool("force_full_upload", snap.ForceFullUpload). | |||
| 791 | + | Uint64("view_rect_seq", snap.ViewRectSeq). | |||
| 792 | + | Uint64("screen_info_seq", snap.ScreenInfoSeq). | |||
| 793 | + | Uint64("paint_seq", snap.PaintSeq). | |||
| 794 | + | Uint64("resize_seq", snap.ResizeSeq). | |||
| 795 | + | Uint64("last_queued_paint_seq", snap.LastQueuedPaintSeq). | |||
| 796 | + | Uint64("gl_render_seq", snap.GLRenderSeq). | |||
| 797 | + | Uint64("paint_total", snap.PaintCount). | |||
| 798 | + | Uint64("queue_total", snap.QueueRenderCount). | |||
| 799 | + | Uint64("render_total", snap.RenderCount). | |||
| 800 | + | Uint64("upload_total", snap.UploadCount). | |||
| 801 | + | Uint64("full_upload_total", snap.FullUploadCount). | |||
| 802 | + | Msg("cef: loading watchdog") | |||
| 803 | + | } | |||
| 804 | + | ||||
| 805 | + | func (wv *WebView) pendingNavigationURI() string { | |||
| 806 | + | if wv == nil { | |||
| 807 | + | return "" | |||
| 808 | + | } | |||
| 809 | + | wv.mu.RLock() | |||
| 810 | + | defer wv.mu.RUnlock() | |||
| 811 | + | return wv.pendingURI | |||
| 812 | + | } | |||
| 813 | + | ||||
| 814 | + | func (wv *WebView) schedulePendingNavigationReplay(browser purecef.Browser, attempt int) { | |||
| 815 | + | if wv == nil || browser == nil || wv.destroyed.Load() { | |||
| 816 | + | return | |||
| 817 | + | } | |||
| 818 | + | task := cefNewTask(cefTaskFunc(func() { | |||
| 819 | + | wv.replayPendingNavigation(browser, attempt) | |||
| 820 | + | })) | |||
| 821 | + | if task == nil { | |||
| 822 | + | return | |||
| 823 | + | } | |||
| 824 | + | var result int32 | |||
| 825 | + | if attempt <= 0 { | |||
| 826 | + | result = cefPostTask(purecef.ThreadIDTidUi, task) | |||
| 827 | + | } else { | |||
| 828 | + | result = cefPostDelayedTask(purecef.ThreadIDTidUi, task, int64(pendingNavigationRetryDelay/time.Millisecond)) | |||
| 829 | + | } | |||
| 830 | + | if result != 1 && wv.ctx != nil { | |||
| 831 | + | logging.FromContext(wv.ctx).Warn(). | |||
| 832 | + | Int("attempt", attempt). | |||
| 833 | + | Int32("result", result). | |||
| 834 | + | Msg("cef: failed to schedule pending navigation replay") | |||
| 835 | + | } | |||
| 836 | + | } | |||
| 837 | + | ||||
| 838 | + | func (wv *WebView) replayPendingNavigation(browser purecef.Browser, attempt int) { | |||
| 839 | + | if wv == nil || browser == nil || wv.destroyed.Load() { | |||
| 840 | + | return | |||
| 841 | + | } | |||
| 842 | + | wv.mu.RLock() | |||
| 843 | + | uri := wv.pendingURI | |||
| 844 | + | wv.mu.RUnlock() | |||
| 845 | + | if uri == "" { | |||
| 846 | + | return | |||
| 847 | + | } | |||
| 848 | + | frame := browser.GetMainFrame() | |||
| 849 | + | if frame == nil { | |||
| 850 | + | if attempt >= pendingNavigationMaxRetries { | |||
| 851 | + | if wv.ctx != nil { | |||
| 852 | + | logging.FromContext(wv.ctx).Warn(). | |||
| 853 | + | Int("attempt", attempt). | |||
| 854 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |||
| 855 | + | Msg("cef: pending navigation replay exhausted without main frame") | |||
| 856 | + | } | |||
| 857 | + | return | |||
| 858 | + | } | |||
| 859 | + | if wv.ctx != nil { | |||
| 860 | + | logging.FromContext(wv.ctx).Debug(). | |||
| 861 | + | Int("attempt", attempt). | |||
| 862 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |||
| 863 | + | Msg("cef: pending navigation replay waiting for main frame") | |||
| 864 | + | } | |||
| 865 | + | wv.schedulePendingNavigationReplay(browser, attempt+1) | |||
| 866 | + | return | |||
| 867 | + | } | |||
| 868 | + | currentURL := "" | |||
| 869 | + | if frame != nil { | |||
| 870 | + | currentURL = frame.GetURL() | |||
| 871 | + | } | |||
| 872 | + | if pendingURIEquivalent(currentURL, uri) { | |||
| 873 | + | wv.mu.Lock() | |||
| 874 | + | if pendingURIEquivalent(wv.pendingURI, uri) { | |||
| 875 | + | wv.pendingURI = "" | |||
| 876 | + | } | |||
| 877 | + | wv.mu.Unlock() | |||
| 878 | + | if wv.ctx != nil { | |||
| 879 | + | logging.FromContext(wv.ctx).Debug(). | |||
| 880 | + | Int("attempt", attempt). | |||
| 881 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |||
| 882 | + | Msg("cef: pending navigation already active") | |||
| 883 | + | } | |||
| 884 | + | return | |||
| 885 | + | } | |||
| 886 | + | frame.LoadURL(uri) | |||
| 887 | + | if wv.ctx != nil { | |||
| 888 | + | logging.FromContext(wv.ctx).Debug(). | |||
| 889 | + | Int("attempt", attempt). | |||
| 890 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |||
| 891 | + | Msg("cef: replayed pending navigation") | |||
| 892 | + | } | |||
| 893 | + | } | |||
| 894 | + | ||||
| 895 | + | func pendingURIEquivalent(a, b string) bool { | |||
| 896 | + | if a == "" || b == "" { | |||
| 897 | + | return false | |||
| 898 | + | } | |||
| 899 | + | return toConceptualInternalURL(strings.TrimSpace(a)) == toConceptualInternalURL(strings.TrimSpace(b)) | |||
| 900 | + | } | |||
| 901 | + | ||||
| 902 | + | func sinceTimestampMs(ts, now time.Time) int64 { | |||
| 903 | + | if ts.IsZero() { | |||
| 904 | + | return -1 | |||
| 905 | + | } | |||
| 906 | + | return now.Sub(ts).Milliseconds() | |||
| 907 | + | } | |||
| 908 | + | ||||
| 661 | func (wv *WebView) updateHoverURI(uri string) { | 909 | func (wv *WebView) updateHoverURI(uri string) { | ||
| 662 | wv.mu.Lock() | 910 | wv.mu.Lock() | ||
| 663 | wv.lastHoverURI = uri | 911 | wv.lastHoverURI = uri | ||
M internal/infrastructure/cef/webview_handlers.go +43 -8
| @@ -307,6 +307,7 @@ func (h *handlerSet) shouldAcceptMainViewPaint( | |||
| 307 | 307 | if sizeMatchesCurrentView { | |
| 308 | 308 | _, _, shouldLog := h.wv.pipeline.markFirstPaintAfterResize() | |
| 309 | 309 | if shouldLog { | |
| 310 | + | logging.Trace().Mark("cef_first_content_paint") | |
| 310 | 311 | logging.FromContext(h.wv.ctx).Debug(). | |
| 311 | 312 | Uint64("webview_id", uint64(h.wv.id)). | |
| 312 | 313 | Uint64("paint_seq", paintSeq). | |
| @@ -403,6 +404,11 @@ func (h *handlerSet) OnVirtualKeyboardRequested(_ purecef.Browser, _ purecef.Tex | |||
| 403 | 404 | // OnAddressChange updates the cached URI when the main frame navigates. | |
| 404 | 405 | func (h *handlerSet) OnAddressChange(_ purecef.Browser, frame purecef.Frame, url string) { | |
| 405 | 406 | if frame != nil && frame.IsMain() { | |
| 407 | + | if h.wv != nil && h.wv.ctx != nil { | |
| 408 | + | logging.FromContext(h.wv.ctx).Debug(). | |
| 409 | + | Str("url", logging.TruncateURL(url, logging.PermissionLogURLMaxLen)). | |
| 410 | + | Msg("cef: OnAddressChange") | |
| 411 | + | } | |
| 406 | 412 | h.wv.updateURI(url) | |
| 407 | 413 | } | |
| 408 | 414 | } | |
| @@ -486,6 +492,11 @@ func (h *handlerSet) OnAutoResize(_ purecef.Browser, _ *purecef.Size) int32 { re | |||
| 486 | 492 | ||
| 487 | 493 | // OnLoadingProgressChange updates the cached progress value. | |
| 488 | 494 | func (h *handlerSet) OnLoadingProgressChange(_ purecef.Browser, progress float64) { | |
| 495 | + | if h.wv != nil && h.wv.ctx != nil { | |
| 496 | + | logging.FromContext(h.wv.ctx).Trace(). | |
| 497 | + | Float64("progress", progress). | |
| 498 | + | Msg("cef: OnLoadingProgressChange") | |
| 499 | + | } | |
| 489 | 500 | h.wv.updateProgress(progress) | |
| 490 | 501 | } | |
| 491 | 502 | ||
| @@ -516,6 +527,21 @@ func (h *handlerSet) GetRootWindowScreenRect(_ purecef.Browser, _ *purecef.Rect) | |||
| 516 | 527 | func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangoback, cangoforward int32) { | |
| 517 | 528 | loading := isloading != 0 | |
| 518 | 529 | h.wv.updateLoadState(loading, cangoback != 0, cangoforward != 0) | |
| 530 | + | if h.wv != nil && h.wv.ctx != nil { | |
| 531 | + | h.wv.mu.RLock() | |
| 532 | + | uri := h.wv.uri | |
| 533 | + | progress := h.wv.progress | |
| 534 | + | pendingURI := h.wv.pendingURI | |
| 535 | + | h.wv.mu.RUnlock() | |
| 536 | + | logging.FromContext(h.wv.ctx).Debug(). | |
| 537 | + | Bool("loading", loading). | |
| 538 | + | Bool("can_go_back", cangoback != 0). | |
| 539 | + | Bool("can_go_forward", cangoforward != 0). | |
| 540 | + | Float64("progress", progress). | |
| 541 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |
| 542 | + | Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)). | |
| 543 | + | Msg("cef: OnLoadingStateChange") | |
| 544 | + | } | |
| 519 | 545 | ||
| 520 | 546 | if !loading && h.wv.input != nil && h.wv.input.hasGTKFocus() { | |
| 521 | 547 | h.wv.mu.RLock() | |
| @@ -547,6 +573,11 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec | |||
| 547 | 573 | if frame == nil || !frame.IsMain() { | |
| 548 | 574 | return | |
| 549 | 575 | } | |
| 576 | + | if h.wv != nil && h.wv.ctx != nil { | |
| 577 | + | logging.FromContext(h.wv.ctx).Debug(). | |
| 578 | + | Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)). | |
| 579 | + | Msg("cef: OnLoadStart") | |
| 580 | + | } | |
| 550 | 581 | h.wv.updateURI(frame.GetURL()) | |
| 551 | 582 | h.wv.mu.RLock() | |
| 552 | 583 | cb := h.wv.callbacks | |
| @@ -572,6 +603,18 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu | |||
| 572 | 603 | // Successful load — reset the consecutive crash counter. | |
| 573 | 604 | h.wv.crashCount.Store(0) | |
| 574 | 605 | ||
| 606 | + | // If a queued startup navigation is still pending after about:blank finished, | |
| 607 | + | // replay it now that the initial main-frame load completed. | |
| 608 | + | frameURL := frame.GetURL() | |
| 609 | + | if pendingURI := h.wv.pendingNavigationURI(); pendingURI != "" && !pendingURIEquivalent(frameURL, pendingURI) { | |
| 610 | + | if strings.EqualFold(strings.TrimSpace(frameURL), "about:blank") { | |
| 611 | + | log.Debug(). | |
| 612 | + | Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)). | |
| 613 | + | Msg("cef: replaying pending navigation after about:blank load end") | |
| 614 | + | h.wv.schedulePendingNavigationReplay(h.wv.browser, 0) | |
| 615 | + | } | |
| 616 | + | } | |
| 617 | + | ||
| 575 | 618 | // Inject scripts and styles after page load. | |
| 576 | 619 | // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread, | |
| 577 | 620 | // and JavaScript injection requires the main thread. | |
| @@ -660,8 +703,6 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) { | |||
| 660 | 703 | h.wv.mu.Lock() | |
| 661 | 704 | h.wv.browser = browser | |
| 662 | 705 | h.wv.host = host | |
| 663 | - | uri := h.wv.pendingURI | |
| 664 | - | h.wv.pendingURI = "" | |
| 665 | 706 | h.wv.input.setHost(host) | |
| 666 | 707 | if h.wv.findCtrl != nil { | |
| 667 | 708 | h.wv.findCtrl.setHost(host) | |
| @@ -677,12 +718,6 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) { | |||
| 677 | 718 | syncWindowlessBrowserFocus(host) | |
| 678 | 719 | } | |
| 679 | 720 | ||
| 680 | - | // Replay any navigation that was requested before the browser existed. | |
| 681 | - | if uri != "" { | |
| 682 | - | if frame := browser.GetMainFrame(); frame != nil { | |
| 683 | - | frame.LoadURL(uri) | |
| 684 | - | } | |
| 685 | - | } | |
| 686 | 721 | h.wv.mu.Unlock() | |
| 687 | 722 | ||
| 688 | 723 | h.wv.scheduleStartBeginFrameLoop() | |
| @@ -307,6 +307,7 @@ func (h *handlerSet) shouldAcceptMainViewPaint( | |||||
| 307 | if sizeMatchesCurrentView { | 307 | if sizeMatchesCurrentView { | ||
| 308 | _, _, shouldLog := h.wv.pipeline.markFirstPaintAfterResize() | 308 | _, _, shouldLog := h.wv.pipeline.markFirstPaintAfterResize() | ||
| 309 | if shouldLog { | 309 | if shouldLog { | ||
| 310 | + | logging.Trace().Mark("cef_first_content_paint") | |||
| 310 | logging.FromContext(h.wv.ctx).Debug(). | 311 | logging.FromContext(h.wv.ctx).Debug(). | ||
| 311 | Uint64("webview_id", uint64(h.wv.id)). | 312 | Uint64("webview_id", uint64(h.wv.id)). | ||
| 312 | Uint64("paint_seq", paintSeq). | 313 | Uint64("paint_seq", paintSeq). | ||
| @@ -403,6 +404,11 @@ func (h *handlerSet) OnVirtualKeyboardRequested(_ purecef.Browser, _ purecef.Tex | |||||
| 403 | // OnAddressChange updates the cached URI when the main frame navigates. | 404 | // OnAddressChange updates the cached URI when the main frame navigates. | ||
| 404 | func (h *handlerSet) OnAddressChange(_ purecef.Browser, frame purecef.Frame, url string) { | 405 | func (h *handlerSet) OnAddressChange(_ purecef.Browser, frame purecef.Frame, url string) { | ||
| 405 | if frame != nil && frame.IsMain() { | 406 | if frame != nil && frame.IsMain() { | ||
| 407 | + | if h.wv != nil && h.wv.ctx != nil { | |||
| 408 | + | logging.FromContext(h.wv.ctx).Debug(). | |||
| 409 | + | Str("url", logging.TruncateURL(url, logging.PermissionLogURLMaxLen)). | |||
| 410 | + | Msg("cef: OnAddressChange") | |||
| 411 | + | } | |||
| 406 | h.wv.updateURI(url) | 412 | h.wv.updateURI(url) | ||
| 407 | } | 413 | } | ||
| 408 | } | 414 | } | ||
| @@ -486,6 +492,11 @@ func (h *handlerSet) OnAutoResize(_ purecef.Browser, _ *purecef.Size) int32 { re | |||||
| 486 | 492 | ||||
| 487 | // OnLoadingProgressChange updates the cached progress value. | 493 | // OnLoadingProgressChange updates the cached progress value. | ||
| 488 | func (h *handlerSet) OnLoadingProgressChange(_ purecef.Browser, progress float64) { | 494 | func (h *handlerSet) OnLoadingProgressChange(_ purecef.Browser, progress float64) { | ||
| 495 | + | if h.wv != nil && h.wv.ctx != nil { | |||
| 496 | + | logging.FromContext(h.wv.ctx).Trace(). | |||
| 497 | + | Float64("progress", progress). | |||
| 498 | + | Msg("cef: OnLoadingProgressChange") | |||
| 499 | + | } | |||
| 489 | h.wv.updateProgress(progress) | 500 | h.wv.updateProgress(progress) | ||
| 490 | } | 501 | } | ||
| 491 | 502 | ||||
| @@ -516,6 +527,21 @@ func (h *handlerSet) GetRootWindowScreenRect(_ purecef.Browser, _ *purecef.Rect) | |||||
| 516 | func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangoback, cangoforward int32) { | 527 | func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangoback, cangoforward int32) { | ||
| 517 | loading := isloading != 0 | 528 | loading := isloading != 0 | ||
| 518 | h.wv.updateLoadState(loading, cangoback != 0, cangoforward != 0) | 529 | h.wv.updateLoadState(loading, cangoback != 0, cangoforward != 0) | ||
| 530 | + | if h.wv != nil && h.wv.ctx != nil { | |||
| 531 | + | h.wv.mu.RLock() | |||
| 532 | + | uri := h.wv.uri | |||
| 533 | + | progress := h.wv.progress | |||
| 534 | + | pendingURI := h.wv.pendingURI | |||
| 535 | + | h.wv.mu.RUnlock() | |||
| 536 | + | logging.FromContext(h.wv.ctx).Debug(). | |||
| 537 | + | Bool("loading", loading). | |||
| 538 | + | Bool("can_go_back", cangoback != 0). | |||
| 539 | + | Bool("can_go_forward", cangoforward != 0). | |||
| 540 | + | Float64("progress", progress). | |||
| 541 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |||
| 542 | + | Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)). | |||
| 543 | + | Msg("cef: OnLoadingStateChange") | |||
| 544 | + | } | |||
| 519 | 545 | ||||
| 520 | if !loading && h.wv.input != nil && h.wv.input.hasGTKFocus() { | 546 | if !loading && h.wv.input != nil && h.wv.input.hasGTKFocus() { | ||
| 521 | h.wv.mu.RLock() | 547 | h.wv.mu.RLock() | ||
| @@ -547,6 +573,11 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec | |||||
| 547 | if frame == nil || !frame.IsMain() { | 573 | if frame == nil || !frame.IsMain() { | ||
| 548 | return | 574 | return | ||
| 549 | } | 575 | } | ||
| 576 | + | if h.wv != nil && h.wv.ctx != nil { | |||
| 577 | + | logging.FromContext(h.wv.ctx).Debug(). | |||
| 578 | + | Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)). | |||
| 579 | + | Msg("cef: OnLoadStart") | |||
| 580 | + | } | |||
| 550 | h.wv.updateURI(frame.GetURL()) | 581 | h.wv.updateURI(frame.GetURL()) | ||
| 551 | h.wv.mu.RLock() | 582 | h.wv.mu.RLock() | ||
| 552 | cb := h.wv.callbacks | 583 | cb := h.wv.callbacks | ||
| @@ -572,6 +603,18 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu | |||||
| 572 | // Successful load — reset the consecutive crash counter. | 603 | // Successful load — reset the consecutive crash counter. | ||
| 573 | h.wv.crashCount.Store(0) | 604 | h.wv.crashCount.Store(0) | ||
| 574 | 605 | ||||
| 606 | + | // If a queued startup navigation is still pending after about:blank finished, | |||
| 607 | + | // replay it now that the initial main-frame load completed. | |||
| 608 | + | frameURL := frame.GetURL() | |||
| 609 | + | if pendingURI := h.wv.pendingNavigationURI(); pendingURI != "" && !pendingURIEquivalent(frameURL, pendingURI) { | |||
| 610 | + | if strings.EqualFold(strings.TrimSpace(frameURL), "about:blank") { | |||
| 611 | + | log.Debug(). | |||
| 612 | + | Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)). | |||
| 613 | + | Msg("cef: replaying pending navigation after about:blank load end") | |||
| 614 | + | h.wv.schedulePendingNavigationReplay(h.wv.browser, 0) | |||
| 615 | + | } | |||
| 616 | + | } | |||
| 617 | + | ||||
| 575 | // Inject scripts and styles after page load. | 618 | // Inject scripts and styles after page load. | ||
| 576 | // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread, | 619 | // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread, | ||
| 577 | // and JavaScript injection requires the main thread. | 620 | // and JavaScript injection requires the main thread. | ||
| @@ -660,8 +703,6 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) { | |||||
| 660 | h.wv.mu.Lock() | 703 | h.wv.mu.Lock() | ||
| 661 | h.wv.browser = browser | 704 | h.wv.browser = browser | ||
| 662 | h.wv.host = host | 705 | h.wv.host = host | ||
| 663 | - | uri := h.wv.pendingURI | |||
| 664 | - | h.wv.pendingURI = "" | |||
| 665 | h.wv.input.setHost(host) | 706 | h.wv.input.setHost(host) | ||
| 666 | if h.wv.findCtrl != nil { | 707 | if h.wv.findCtrl != nil { | ||
| 667 | h.wv.findCtrl.setHost(host) | 708 | h.wv.findCtrl.setHost(host) | ||
| @@ -677,12 +718,6 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) { | |||||
| 677 | syncWindowlessBrowserFocus(host) | 718 | syncWindowlessBrowserFocus(host) | ||
| 678 | } | 719 | } | ||
| 679 | 720 | ||||
| 680 | - | // Replay any navigation that was requested before the browser existed. | |||
| 681 | - | if uri != "" { | |||
| 682 | - | if frame := browser.GetMainFrame(); frame != nil { | |||
| 683 | - | frame.LoadURL(uri) | |||
| 684 | - | } | |||
| 685 | - | } | |||
| 686 | h.wv.mu.Unlock() | 721 | h.wv.mu.Unlock() | ||
| 687 | 722 | ||||
| 688 | h.wv.scheduleStartBeginFrameLoop() | 723 | h.wv.scheduleStartBeginFrameLoop() | ||
A internal/infrastructure/cef/webview_navigation_test.go +93 -0
| @@ -0,0 +1,93 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 9 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestWebViewReplayPendingNavigation_LoadsQueuedURIWhenMainFrameAvailable(t *testing.T) { | |
| 14 | + | browser := cefmocks.NewMockBrowser(t) | |
| 15 | + | frame := cefmocks.NewMockFrame(t) | |
| 16 | + | frame.EXPECT().GetURL().Return("").Once() | |
| 17 | + | frame.EXPECT().LoadURL("https://github.com/bnema").Once() | |
| 18 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |
| 19 | + | ||
| 20 | + | wv := &WebView{ctx: context.Background(), pendingURI: "https://github.com/bnema"} | |
| 21 | + | wv.replayPendingNavigation(browser, 0) | |
| 22 | + | ||
| 23 | + | wv.mu.RLock() | |
| 24 | + | defer wv.mu.RUnlock() | |
| 25 | + | require.Equal(t, "https://github.com/bnema", wv.pendingURI) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testing.T) { | |
| 29 | + | browser := cefmocks.NewMockBrowser(t) | |
| 30 | + | browser.EXPECT().GetMainFrame().Return((purecef.Frame)(nil)).Once() | |
| 31 | + | ||
| 32 | + | oldTask := cefNewTask | |
| 33 | + | oldDelayed := cefPostDelayedTask | |
| 34 | + | defer func() { | |
| 35 | + | cefNewTask = oldTask | |
| 36 | + | cefPostDelayedTask = oldDelayed | |
| 37 | + | }() | |
| 38 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |
| 39 | + | ||
| 40 | + | scheduled := false | |
| 41 | + | cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 { | |
| 42 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |
| 43 | + | require.NotNil(t, task) | |
| 44 | + | require.Equal(t, int64(pendingNavigationRetryDelay/time.Millisecond), delayMs) | |
| 45 | + | scheduled = true | |
| 46 | + | return 1 | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | wv := &WebView{ctx: context.Background(), pendingURI: "https://github.com/bnema"} | |
| 50 | + | wv.replayPendingNavigation(browser, 0) | |
| 51 | + | ||
| 52 | + | require.True(t, scheduled) | |
| 53 | + | wv.mu.RLock() | |
| 54 | + | defer wv.mu.RUnlock() | |
| 55 | + | require.Equal(t, "https://github.com/bnema", wv.pendingURI) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestWebViewLoadURI_QueuesPendingNavigationReplayForExistingBrowser(t *testing.T) { | |
| 59 | + | browser := cefmocks.NewMockBrowser(t) | |
| 60 | + | frame := cefmocks.NewMockFrame(t) | |
| 61 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |
| 62 | + | frame.EXPECT().GetURL().Return("").Once() | |
| 63 | + | frame.EXPECT().LoadURL("github.com/bnema").Once() | |
| 64 | + | ||
| 65 | + | oldTask := cefNewTask | |
| 66 | + | oldPost := cefPostTask | |
| 67 | + | defer func() { | |
| 68 | + | cefNewTask = oldTask | |
| 69 | + | cefPostTask = oldPost | |
| 70 | + | }() | |
| 71 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |
| 72 | + | cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 { | |
| 73 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |
| 74 | + | require.NotNil(t, task) | |
| 75 | + | task.Execute() | |
| 76 | + | return 1 | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | wv := &WebView{ctx: context.Background(), browser: browser} | |
| 80 | + | require.NoError(t, wv.LoadURI(context.Background(), "github.com/bnema")) | |
| 81 | + | ||
| 82 | + | wv.mu.RLock() | |
| 83 | + | defer wv.mu.RUnlock() | |
| 84 | + | require.Equal(t, "github.com/bnema", wv.pendingURI) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestWebViewUpdateURI_ClearsMatchingPendingNavigation(t *testing.T) { | |
| 88 | + | wv := &WebView{ctx: context.Background(), pendingURI: "https://github.com/bnema"} | |
| 89 | + | ||
| 90 | + | wv.updateURI("https://github.com/bnema") | |
| 91 | + | ||
| 92 | + | require.Empty(t, wv.pendingNavigationURI()) | |
| 93 | + | } | |
| @@ -0,0 +1,93 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | purecef "github.com/bnema/purego-cef/cef" | |||
| 9 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestWebViewReplayPendingNavigation_LoadsQueuedURIWhenMainFrameAvailable(t *testing.T) { | |||
| 14 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 15 | + | frame := cefmocks.NewMockFrame(t) | |||
| 16 | + | frame.EXPECT().GetURL().Return("").Once() | |||
| 17 | + | frame.EXPECT().LoadURL("https://github.com/bnema").Once() | |||
| 18 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |||
| 19 | + | ||||
| 20 | + | wv := &WebView{ctx: context.Background(), pendingURI: "https://github.com/bnema"} | |||
| 21 | + | wv.replayPendingNavigation(browser, 0) | |||
| 22 | + | ||||
| 23 | + | wv.mu.RLock() | |||
| 24 | + | defer wv.mu.RUnlock() | |||
| 25 | + | require.Equal(t, "https://github.com/bnema", wv.pendingURI) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testing.T) { | |||
| 29 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 30 | + | browser.EXPECT().GetMainFrame().Return((purecef.Frame)(nil)).Once() | |||
| 31 | + | ||||
| 32 | + | oldTask := cefNewTask | |||
| 33 | + | oldDelayed := cefPostDelayedTask | |||
| 34 | + | defer func() { | |||
| 35 | + | cefNewTask = oldTask | |||
| 36 | + | cefPostDelayedTask = oldDelayed | |||
| 37 | + | }() | |||
| 38 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |||
| 39 | + | ||||
| 40 | + | scheduled := false | |||
| 41 | + | cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 { | |||
| 42 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |||
| 43 | + | require.NotNil(t, task) | |||
| 44 | + | require.Equal(t, int64(pendingNavigationRetryDelay/time.Millisecond), delayMs) | |||
| 45 | + | scheduled = true | |||
| 46 | + | return 1 | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | wv := &WebView{ctx: context.Background(), pendingURI: "https://github.com/bnema"} | |||
| 50 | + | wv.replayPendingNavigation(browser, 0) | |||
| 51 | + | ||||
| 52 | + | require.True(t, scheduled) | |||
| 53 | + | wv.mu.RLock() | |||
| 54 | + | defer wv.mu.RUnlock() | |||
| 55 | + | require.Equal(t, "https://github.com/bnema", wv.pendingURI) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestWebViewLoadURI_QueuesPendingNavigationReplayForExistingBrowser(t *testing.T) { | |||
| 59 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 60 | + | frame := cefmocks.NewMockFrame(t) | |||
| 61 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |||
| 62 | + | frame.EXPECT().GetURL().Return("").Once() | |||
| 63 | + | frame.EXPECT().LoadURL("github.com/bnema").Once() | |||
| 64 | + | ||||
| 65 | + | oldTask := cefNewTask | |||
| 66 | + | oldPost := cefPostTask | |||
| 67 | + | defer func() { | |||
| 68 | + | cefNewTask = oldTask | |||
| 69 | + | cefPostTask = oldPost | |||
| 70 | + | }() | |||
| 71 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |||
| 72 | + | cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 { | |||
| 73 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |||
| 74 | + | require.NotNil(t, task) | |||
| 75 | + | task.Execute() | |||
| 76 | + | return 1 | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | wv := &WebView{ctx: context.Background(), browser: browser} | |||
| 80 | + | require.NoError(t, wv.LoadURI(context.Background(), "github.com/bnema")) | |||
| 81 | + | ||||
| 82 | + | wv.mu.RLock() | |||
| 83 | + | defer wv.mu.RUnlock() | |||
| 84 | + | require.Equal(t, "github.com/bnema", wv.pendingURI) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestWebViewUpdateURI_ClearsMatchingPendingNavigation(t *testing.T) { | |||
| 88 | + | wv := &WebView{ctx: context.Background(), pendingURI: "https://github.com/bnema"} | |||
| 89 | + | ||||
| 90 | + | wv.updateURI("https://github.com/bnema") | |||
| 91 | + | ||||
| 92 | + | require.Empty(t, wv.pendingNavigationURI()) | |||
| 93 | + | } | |||
M internal/infrastructure/xdg/adapter.go +7 -2
| @@ -29,8 +29,13 @@ func (a *Adapter) StateDir() (string, error) { | |||
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | 31 | func (a *Adapter) RuntimeDir() (string, error) { | |
| 32 | - | if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" { | |
| 33 | - | return dir, nil | |
| 32 | + | // In dev mode, keep runtime IPC isolated inside the .dev sandbox even when | |
| 33 | + | // XDG_RUNTIME_DIR is set. This prevents browser-launch relay collisions | |
| 34 | + | // between the installed app and a repo-local ENV=dev instance. | |
| 35 | + | if os.Getenv("ENV") != "dev" { | |
| 36 | + | if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" { | |
| 37 | + | return dir, nil | |
| 38 | + | } | |
| 34 | 39 | } | |
| 35 | 40 | ||
| 36 | 41 | stateDir, err := a.StateDir() | |
| @@ -29,8 +29,13 @@ func (a *Adapter) StateDir() (string, error) { | |||||
| 29 | } | 29 | } | ||
| 30 | 30 | ||||
| 31 | func (a *Adapter) RuntimeDir() (string, error) { | 31 | func (a *Adapter) RuntimeDir() (string, error) { | ||
| 32 | - | if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" { | 32 | + | // In dev mode, keep runtime IPC isolated inside the .dev sandbox even when |
| 33 | - | return dir, nil | 33 | + | // XDG_RUNTIME_DIR is set. This prevents browser-launch relay collisions |
| 34 | + | // between the installed app and a repo-local ENV=dev instance. | |||
| 35 | + | if os.Getenv("ENV") != "dev" { | |||
| 36 | + | if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" { | |||
| 37 | + | return dir, nil | |||
| 38 | + | } | |||
| 34 | } | 39 | } | ||
| 35 | 40 | ||||
| 36 | stateDir, err := a.StateDir() | 41 | stateDir, err := a.StateDir() | ||
M internal/infrastructure/xdg/adapter_test.go +21 -1
| @@ -39,7 +39,8 @@ func TestAdapter_DownloadDir(t *testing.T) { | |||
| 39 | 39 | func TestAdapter_RuntimeDir(t *testing.T) { | |
| 40 | 40 | adapter := New() | |
| 41 | 41 | ||
| 42 | - | t.Run("returns XDG_RUNTIME_DIR when set", func(t *testing.T) { | |
| 42 | + | t.Run("returns XDG_RUNTIME_DIR when set outside dev", func(t *testing.T) { | |
| 43 | + | t.Setenv("ENV", "") | |
| 43 | 44 | t.Setenv("XDG_RUNTIME_DIR", "/custom/runtime") | |
| 44 | 45 | ||
| 45 | 46 | dir, err := adapter.RuntimeDir() | |
| @@ -49,6 +50,7 @@ func TestAdapter_RuntimeDir(t *testing.T) { | |||
| 49 | 50 | }) | |
| 50 | 51 | ||
| 51 | 52 | t.Run("falls back to state runtime dir when XDG_RUNTIME_DIR not set", func(t *testing.T) { | |
| 53 | + | t.Setenv("ENV", "") | |
| 52 | 54 | t.Setenv("XDG_RUNTIME_DIR", "") | |
| 53 | 55 | t.Setenv("XDG_STATE_HOME", "/tmp/dumber-state") | |
| 54 | 56 | ||
| @@ -57,6 +59,24 @@ func TestAdapter_RuntimeDir(t *testing.T) { | |||
| 57 | 59 | require.NoError(t, err) | |
| 58 | 60 | assert.Equal(t, "/tmp/dumber-state/dumber/runtime", dir) | |
| 59 | 61 | }) | |
| 62 | + | ||
| 63 | + | t.Run("uses sandbox runtime dir in dev even when XDG_RUNTIME_DIR is set", func(t *testing.T) { | |
| 64 | + | wd := t.TempDir() | |
| 65 | + | oldWD, err := os.Getwd() | |
| 66 | + | require.NoError(t, err) | |
| 67 | + | require.NoError(t, os.Chdir(wd)) | |
| 68 | + | defer func() { | |
| 69 | + | require.NoError(t, os.Chdir(oldWD)) | |
| 70 | + | }() | |
| 71 | + | ||
| 72 | + | t.Setenv("ENV", "dev") | |
| 73 | + | t.Setenv("XDG_RUNTIME_DIR", "/shared/runtime") | |
| 74 | + | ||
| 75 | + | dir, err := adapter.RuntimeDir() | |
| 76 | + | ||
| 77 | + | require.NoError(t, err) | |
| 78 | + | assert.Equal(t, filepath.Join(wd, ".dev", "dumber", "runtime"), dir) | |
| 79 | + | }) | |
| 60 | 80 | } | |
| 61 | 81 | ||
| 62 | 82 | func TestNew(t *testing.T) { | |
| @@ -39,7 +39,8 @@ func TestAdapter_DownloadDir(t *testing.T) { | |||||
| 39 | func TestAdapter_RuntimeDir(t *testing.T) { | 39 | func TestAdapter_RuntimeDir(t *testing.T) { | ||
| 40 | adapter := New() | 40 | adapter := New() | ||
| 41 | 41 | ||||
| 42 | - | t.Run("returns XDG_RUNTIME_DIR when set", func(t *testing.T) { | 42 | + | t.Run("returns XDG_RUNTIME_DIR when set outside dev", func(t *testing.T) { |
| 43 | + | t.Setenv("ENV", "") | |||
| 43 | t.Setenv("XDG_RUNTIME_DIR", "/custom/runtime") | 44 | t.Setenv("XDG_RUNTIME_DIR", "/custom/runtime") | ||
| 44 | 45 | ||||
| 45 | dir, err := adapter.RuntimeDir() | 46 | dir, err := adapter.RuntimeDir() | ||
| @@ -49,6 +50,7 @@ func TestAdapter_RuntimeDir(t *testing.T) { | |||||
| 49 | }) | 50 | }) | ||
| 50 | 51 | ||||
| 51 | t.Run("falls back to state runtime dir when XDG_RUNTIME_DIR not set", func(t *testing.T) { | 52 | t.Run("falls back to state runtime dir when XDG_RUNTIME_DIR not set", func(t *testing.T) { | ||
| 53 | + | t.Setenv("ENV", "") | |||
| 52 | t.Setenv("XDG_RUNTIME_DIR", "") | 54 | t.Setenv("XDG_RUNTIME_DIR", "") | ||
| 53 | t.Setenv("XDG_STATE_HOME", "/tmp/dumber-state") | 55 | t.Setenv("XDG_STATE_HOME", "/tmp/dumber-state") | ||
| 54 | 56 | ||||
| @@ -57,6 +59,24 @@ func TestAdapter_RuntimeDir(t *testing.T) { | |||||
| 57 | require.NoError(t, err) | 59 | require.NoError(t, err) | ||
| 58 | assert.Equal(t, "/tmp/dumber-state/dumber/runtime", dir) | 60 | assert.Equal(t, "/tmp/dumber-state/dumber/runtime", dir) | ||
| 59 | }) | 61 | }) | ||
| 62 | + | ||||
| 63 | + | t.Run("uses sandbox runtime dir in dev even when XDG_RUNTIME_DIR is set", func(t *testing.T) { | |||
| 64 | + | wd := t.TempDir() | |||
| 65 | + | oldWD, err := os.Getwd() | |||
| 66 | + | require.NoError(t, err) | |||
| 67 | + | require.NoError(t, os.Chdir(wd)) | |||
| 68 | + | defer func() { | |||
| 69 | + | require.NoError(t, os.Chdir(oldWD)) | |||
| 70 | + | }() | |||
| 71 | + | ||||
| 72 | + | t.Setenv("ENV", "dev") | |||
| 73 | + | t.Setenv("XDG_RUNTIME_DIR", "/shared/runtime") | |||
| 74 | + | ||||
| 75 | + | dir, err := adapter.RuntimeDir() | |||
| 76 | + | ||||
| 77 | + | require.NoError(t, err) | |||
| 78 | + | assert.Equal(t, filepath.Join(wd, ".dev", "dumber", "runtime"), dir) | |||
| 79 | + | }) | |||
| 60 | } | 80 | } | ||
| 61 | 81 | ||||
| 62 | func TestNew(t *testing.T) { | 82 | func TestNew(t *testing.T) { | ||
M internal/ui/component/pane_view.go +2 -2
| @@ -390,7 +390,7 @@ func (pv *PaneView) SetLoadProgress(progress float64) { | |||
| 390 | 390 | ctx = context.Background() | |
| 391 | 391 | } | |
| 392 | 392 | logging.FromContext(ctx). | |
| 393 | - | Debug(). | |
| 393 | + | Trace(). | |
| 394 | 394 | Str("pane_id", string(pv.paneID)). | |
| 395 | 395 | Float64("progress", progress). | |
| 396 | 396 | Msg("setting load progress") | |
| @@ -419,7 +419,7 @@ func (pv *PaneView) SetLoading(loading bool) { | |||
| 419 | 419 | if loading { | |
| 420 | 420 | pb.Show() | |
| 421 | 421 | } else { | |
| 422 | - | pb.Hide() | |
| 422 | + | pb.HideWithReason("loading_false") | |
| 423 | 423 | } | |
| 424 | 424 | } | |
| 425 | 425 | ||
| @@ -390,7 +390,7 @@ func (pv *PaneView) SetLoadProgress(progress float64) { | |||||
| 390 | ctx = context.Background() | 390 | ctx = context.Background() | ||
| 391 | } | 391 | } | ||
| 392 | logging.FromContext(ctx). | 392 | logging.FromContext(ctx). | ||
| 393 | - | Debug(). | 393 | + | Trace(). |
| 394 | Str("pane_id", string(pv.paneID)). | 394 | Str("pane_id", string(pv.paneID)). | ||
| 395 | Float64("progress", progress). | 395 | Float64("progress", progress). | ||
| 396 | Msg("setting load progress") | 396 | Msg("setting load progress") | ||
| @@ -419,7 +419,7 @@ func (pv *PaneView) SetLoading(loading bool) { | |||||
| 419 | if loading { | 419 | if loading { | ||
| 420 | pb.Show() | 420 | pb.Show() | ||
| 421 | } else { | 421 | } else { | ||
| 422 | - | pb.Hide() | 422 | + | pb.HideWithReason("loading_false") |
| 423 | } | 423 | } | ||
| 424 | } | 424 | } | ||
| 425 | 425 | ||||
M internal/ui/component/progress_bar.go +59 -9
| @@ -4,6 +4,7 @@ package component | |||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | 6 | "sync" | |
| 7 | + | "time" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 9 | 10 | "github.com/bnema/puregotk/v4/glib" | |
| @@ -29,11 +30,15 @@ type ProgressBar struct { | |||
| 29 | 30 | ctx context.Context | |
| 30 | 31 | progressBar layout.ProgressBarWidget | |
| 31 | 32 | ||
| 32 | - | visible bool | |
| 33 | - | currentValue float64 // Current displayed value | |
| 34 | - | targetValue float64 // Target value to animate towards | |
| 35 | - | animationTimer uint // Timer source ID for animation | |
| 36 | - | timeoutTimer uint // Timer source ID for auto-hide timeout | |
| 33 | + | visible bool | |
| 34 | + | currentValue float64 // Current displayed value | |
| 35 | + | targetValue float64 // Target value to animate towards | |
| 36 | + | animationTimer uint // Timer source ID for animation | |
| 37 | + | timeoutTimer uint // Timer source ID for auto-hide timeout | |
| 38 | + | lastShowAt time.Time | |
| 39 | + | lastProgressAt time.Time | |
| 40 | + | lastHideReason string | |
| 41 | + | progressEventSeq uint64 | |
| 37 | 42 | ||
| 38 | 43 | mu sync.Mutex | |
| 39 | 44 | } | |
| @@ -85,12 +90,17 @@ func (pb *ProgressBar) SetProgress(progress float64) { | |||
| 85 | 90 | if ctx == nil { | |
| 86 | 91 | ctx = context.Background() | |
| 87 | 92 | } | |
| 93 | + | now := time.Now() | |
| 88 | 94 | logging.FromContext(ctx). | |
| 89 | - | Debug(). | |
| 95 | + | Trace(). | |
| 90 | 96 | Float64("incoming_progress", incomingProgress). | |
| 91 | 97 | Float64("current_value", pb.currentValue). | |
| 92 | 98 | Float64("target_value", pb.targetValue). | |
| 93 | 99 | Bool("visible", pb.visible). | |
| 100 | + | Bool("animation_running", pb.animationTimer != 0). | |
| 101 | + | Bool("timeout_running", pb.timeoutTimer != 0). | |
| 102 | + | Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)). | |
| 103 | + | Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)). | |
| 94 | 104 | Msg("setting progress") | |
| 95 | 105 | ||
| 96 | 106 | // Clamp progress to valid range | |
| @@ -101,6 +111,8 @@ func (pb *ProgressBar) SetProgress(progress float64) { | |||
| 101 | 111 | } | |
| 102 | 112 | ||
| 103 | 113 | pb.targetValue = progress | |
| 114 | + | pb.lastProgressAt = now | |
| 115 | + | pb.progressEventSeq++ | |
| 104 | 116 | ||
| 105 | 117 | // For large jumps (>0.3) or completion, update immediately | |
| 106 | 118 | diff := progress - pb.currentValue | |
| @@ -146,6 +158,12 @@ func (pb *ProgressBar) startAnimation() { | |||
| 146 | 158 | }) | |
| 147 | 159 | ||
| 148 | 160 | pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0) | |
| 161 | + | logging.FromContext(pb.ctx). | |
| 162 | + | Trace(). | |
| 163 | + | Uint64("progress_event_seq", pb.progressEventSeq). | |
| 164 | + | Float64("current_value", pb.currentValue). | |
| 165 | + | Float64("target_value", pb.targetValue). | |
| 166 | + | Msg("progress bar animation started") | |
| 149 | 167 | } | |
| 150 | 168 | ||
| 151 | 169 | // initialProgressFraction is the fraction set when the progress bar first | |
| @@ -172,6 +190,8 @@ func (pb *ProgressBar) Show() { | |||
| 172 | 190 | ||
| 173 | 191 | if !pb.visible { | |
| 174 | 192 | pb.visible = true | |
| 193 | + | pb.lastShowAt = time.Now() | |
| 194 | + | pb.lastHideReason = "" | |
| 175 | 195 | // Set an initial non-zero fraction so the bar is visually noticeable | |
| 176 | 196 | // immediately. Without this, the bar is technically visible but | |
| 177 | 197 | // renders as empty (0% fill) until the first progress callback, | |
| @@ -207,11 +227,20 @@ func (pb *ProgressBar) resetTimeout() { | |||
| 207 | 227 | pb.mu.Lock() | |
| 208 | 228 | defer pb.mu.Unlock() | |
| 209 | 229 | ||
| 230 | + | now := time.Now() | |
| 231 | + | logging.FromContext(pb.ctx). | |
| 232 | + | Debug(). | |
| 233 | + | Float64("current_value", pb.currentValue). | |
| 234 | + | Float64("target_value", pb.targetValue). | |
| 235 | + | Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)). | |
| 236 | + | Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)). | |
| 237 | + | Msg("progress bar timeout fired") | |
| 238 | + | ||
| 210 | 239 | // Clear timer ID (timer is being removed) | |
| 211 | 240 | pb.timeoutTimer = 0 | |
| 212 | 241 | ||
| 213 | 242 | // Hide the progress bar inline to avoid race condition | |
| 214 | - | pb.hideInternal() | |
| 243 | + | pb.hideInternal("timeout") | |
| 215 | 244 | return false // Don't repeat | |
| 216 | 245 | }) | |
| 217 | 246 | ||
| @@ -220,26 +249,40 @@ func (pb *ProgressBar) resetTimeout() { | |||
| 220 | 249 | ||
| 221 | 250 | // Hide makes the progress bar invisible and resets state. | |
| 222 | 251 | func (pb *ProgressBar) Hide() { | |
| 252 | + | pb.HideWithReason("manual") | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | // HideWithReason makes the progress bar invisible and records why it was hidden. | |
| 256 | + | func (pb *ProgressBar) HideWithReason(reason string) { | |
| 223 | 257 | pb.mu.Lock() | |
| 224 | 258 | defer pb.mu.Unlock() | |
| 225 | 259 | ||
| 226 | - | pb.hideInternal() | |
| 260 | + | pb.hideInternal(reason) | |
| 227 | 261 | } | |
| 228 | 262 | ||
| 229 | 263 | // hideInternal performs the actual hide operation. | |
| 230 | 264 | // Must be called with lock held. | |
| 231 | - | func (pb *ProgressBar) hideInternal() { | |
| 265 | + | func (pb *ProgressBar) hideInternal(reason string) { | |
| 232 | 266 | ctx := pb.ctx | |
| 233 | 267 | if ctx == nil { | |
| 234 | 268 | ctx = context.Background() | |
| 235 | 269 | } | |
| 270 | + | now := time.Now() | |
| 236 | 271 | logging.FromContext(ctx). | |
| 237 | 272 | Debug(). | |
| 238 | 273 | Bool("visible", pb.visible). | |
| 274 | + | Str("reason", reason). | |
| 275 | + | Float64("current_value", pb.currentValue). | |
| 276 | + | Float64("target_value", pb.targetValue). | |
| 277 | + | Bool("animation_running", pb.animationTimer != 0). | |
| 278 | + | Bool("timeout_running", pb.timeoutTimer != 0). | |
| 279 | + | Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)). | |
| 280 | + | Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)). | |
| 239 | 281 | Msg("progress bar hide before") | |
| 240 | 282 | ||
| 241 | 283 | if pb.visible { | |
| 242 | 284 | pb.visible = false | |
| 285 | + | pb.lastHideReason = reason | |
| 243 | 286 | pb.progressBar.SetVisible(false) | |
| 244 | 287 | ||
| 245 | 288 | // Stop any running animation | |
| @@ -261,6 +304,13 @@ func (pb *ProgressBar) hideInternal() { | |||
| 261 | 304 | } | |
| 262 | 305 | } | |
| 263 | 306 | ||
| 307 | + | func sinceTimeMs(start, now time.Time) int64 { | |
| 308 | + | if start.IsZero() { | |
| 309 | + | return -1 | |
| 310 | + | } | |
| 311 | + | return now.Sub(start).Milliseconds() | |
| 312 | + | } | |
| 313 | + | ||
| 264 | 314 | // IsVisible returns whether the progress bar is currently visible. | |
| 265 | 315 | func (pb *ProgressBar) IsVisible() bool { | |
| 266 | 316 | pb.mu.Lock() | |
| @@ -4,6 +4,7 @@ package component | |||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "sync" | 6 | "sync" | ||
| 7 | + | "time" | |||
| 7 | 8 | ||||
| 8 | "github.com/bnema/dumber/internal/logging" | 9 | "github.com/bnema/dumber/internal/logging" | ||
| 9 | "github.com/bnema/puregotk/v4/glib" | 10 | "github.com/bnema/puregotk/v4/glib" | ||
| @@ -29,11 +30,15 @@ type ProgressBar struct { | |||||
| 29 | ctx context.Context | 30 | ctx context.Context | ||
| 30 | progressBar layout.ProgressBarWidget | 31 | progressBar layout.ProgressBarWidget | ||
| 31 | 32 | ||||
| 32 | - | visible bool | 33 | + | visible bool |
| 33 | - | currentValue float64 // Current displayed value | 34 | + | currentValue float64 // Current displayed value |
| 34 | - | targetValue float64 // Target value to animate towards | 35 | + | targetValue float64 // Target value to animate towards |
| 35 | - | animationTimer uint // Timer source ID for animation | 36 | + | animationTimer uint // Timer source ID for animation |
| 36 | - | timeoutTimer uint // Timer source ID for auto-hide timeout | 37 | + | timeoutTimer uint // Timer source ID for auto-hide timeout |
| 38 | + | lastShowAt time.Time | |||
| 39 | + | lastProgressAt time.Time | |||
| 40 | + | lastHideReason string | |||
| 41 | + | progressEventSeq uint64 | |||
| 37 | 42 | ||||
| 38 | mu sync.Mutex | 43 | mu sync.Mutex | ||
| 39 | } | 44 | } | ||
| @@ -85,12 +90,17 @@ func (pb *ProgressBar) SetProgress(progress float64) { | |||||
| 85 | if ctx == nil { | 90 | if ctx == nil { | ||
| 86 | ctx = context.Background() | 91 | ctx = context.Background() | ||
| 87 | } | 92 | } | ||
| 93 | + | now := time.Now() | |||
| 88 | logging.FromContext(ctx). | 94 | logging.FromContext(ctx). | ||
| 89 | - | Debug(). | 95 | + | Trace(). |
| 90 | Float64("incoming_progress", incomingProgress). | 96 | Float64("incoming_progress", incomingProgress). | ||
| 91 | Float64("current_value", pb.currentValue). | 97 | Float64("current_value", pb.currentValue). | ||
| 92 | Float64("target_value", pb.targetValue). | 98 | Float64("target_value", pb.targetValue). | ||
| 93 | Bool("visible", pb.visible). | 99 | Bool("visible", pb.visible). | ||
| 100 | + | Bool("animation_running", pb.animationTimer != 0). | |||
| 101 | + | Bool("timeout_running", pb.timeoutTimer != 0). | |||
| 102 | + | Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)). | |||
| 103 | + | Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)). | |||
| 94 | Msg("setting progress") | 104 | Msg("setting progress") | ||
| 95 | 105 | ||||
| 96 | // Clamp progress to valid range | 106 | // Clamp progress to valid range | ||
| @@ -101,6 +111,8 @@ func (pb *ProgressBar) SetProgress(progress float64) { | |||||
| 101 | } | 111 | } | ||
| 102 | 112 | ||||
| 103 | pb.targetValue = progress | 113 | pb.targetValue = progress | ||
| 114 | + | pb.lastProgressAt = now | |||
| 115 | + | pb.progressEventSeq++ | |||
| 104 | 116 | ||||
| 105 | // For large jumps (>0.3) or completion, update immediately | 117 | // For large jumps (>0.3) or completion, update immediately | ||
| 106 | diff := progress - pb.currentValue | 118 | diff := progress - pb.currentValue | ||
| @@ -146,6 +158,12 @@ func (pb *ProgressBar) startAnimation() { | |||||
| 146 | }) | 158 | }) | ||
| 147 | 159 | ||||
| 148 | pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0) | 160 | pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0) | ||
| 161 | + | logging.FromContext(pb.ctx). | |||
| 162 | + | Trace(). | |||
| 163 | + | Uint64("progress_event_seq", pb.progressEventSeq). | |||
| 164 | + | Float64("current_value", pb.currentValue). | |||
| 165 | + | Float64("target_value", pb.targetValue). | |||
| 166 | + | Msg("progress bar animation started") | |||
| 149 | } | 167 | } | ||
| 150 | 168 | ||||
| 151 | // initialProgressFraction is the fraction set when the progress bar first | 169 | // initialProgressFraction is the fraction set when the progress bar first | ||
| @@ -172,6 +190,8 @@ func (pb *ProgressBar) Show() { | |||||
| 172 | 190 | ||||
| 173 | if !pb.visible { | 191 | if !pb.visible { | ||
| 174 | pb.visible = true | 192 | pb.visible = true | ||
| 193 | + | pb.lastShowAt = time.Now() | |||
| 194 | + | pb.lastHideReason = "" | |||
| 175 | // Set an initial non-zero fraction so the bar is visually noticeable | 195 | // Set an initial non-zero fraction so the bar is visually noticeable | ||
| 176 | // immediately. Without this, the bar is technically visible but | 196 | // immediately. Without this, the bar is technically visible but | ||
| 177 | // renders as empty (0% fill) until the first progress callback, | 197 | // renders as empty (0% fill) until the first progress callback, | ||
| @@ -207,11 +227,20 @@ func (pb *ProgressBar) resetTimeout() { | |||||
| 207 | pb.mu.Lock() | 227 | pb.mu.Lock() | ||
| 208 | defer pb.mu.Unlock() | 228 | defer pb.mu.Unlock() | ||
| 209 | 229 | ||||
| 230 | + | now := time.Now() | |||
| 231 | + | logging.FromContext(pb.ctx). | |||
| 232 | + | Debug(). | |||
| 233 | + | Float64("current_value", pb.currentValue). | |||
| 234 | + | Float64("target_value", pb.targetValue). | |||
| 235 | + | Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)). | |||
| 236 | + | Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)). | |||
| 237 | + | Msg("progress bar timeout fired") | |||
| 238 | + | ||||
| 210 | // Clear timer ID (timer is being removed) | 239 | // Clear timer ID (timer is being removed) | ||
| 211 | pb.timeoutTimer = 0 | 240 | pb.timeoutTimer = 0 | ||
| 212 | 241 | ||||
| 213 | // Hide the progress bar inline to avoid race condition | 242 | // Hide the progress bar inline to avoid race condition | ||
| 214 | - | pb.hideInternal() | 243 | + | pb.hideInternal("timeout") |
| 215 | return false // Don't repeat | 244 | return false // Don't repeat | ||
| 216 | }) | 245 | }) | ||
| 217 | 246 | ||||
| @@ -220,26 +249,40 @@ func (pb *ProgressBar) resetTimeout() { | |||||
| 220 | 249 | ||||
| 221 | // Hide makes the progress bar invisible and resets state. | 250 | // Hide makes the progress bar invisible and resets state. | ||
| 222 | func (pb *ProgressBar) Hide() { | 251 | func (pb *ProgressBar) Hide() { | ||
| 252 | + | pb.HideWithReason("manual") | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | // HideWithReason makes the progress bar invisible and records why it was hidden. | |||
| 256 | + | func (pb *ProgressBar) HideWithReason(reason string) { | |||
| 223 | pb.mu.Lock() | 257 | pb.mu.Lock() | ||
| 224 | defer pb.mu.Unlock() | 258 | defer pb.mu.Unlock() | ||
| 225 | 259 | ||||
| 226 | - | pb.hideInternal() | 260 | + | pb.hideInternal(reason) |
| 227 | } | 261 | } | ||
| 228 | 262 | ||||
| 229 | // hideInternal performs the actual hide operation. | 263 | // hideInternal performs the actual hide operation. | ||
| 230 | // Must be called with lock held. | 264 | // Must be called with lock held. | ||
| 231 | - | func (pb *ProgressBar) hideInternal() { | 265 | + | func (pb *ProgressBar) hideInternal(reason string) { |
| 232 | ctx := pb.ctx | 266 | ctx := pb.ctx | ||
| 233 | if ctx == nil { | 267 | if ctx == nil { | ||
| 234 | ctx = context.Background() | 268 | ctx = context.Background() | ||
| 235 | } | 269 | } | ||
| 270 | + | now := time.Now() | |||
| 236 | logging.FromContext(ctx). | 271 | logging.FromContext(ctx). | ||
| 237 | Debug(). | 272 | Debug(). | ||
| 238 | Bool("visible", pb.visible). | 273 | Bool("visible", pb.visible). | ||
| 274 | + | Str("reason", reason). | |||
| 275 | + | Float64("current_value", pb.currentValue). | |||
| 276 | + | Float64("target_value", pb.targetValue). | |||
| 277 | + | Bool("animation_running", pb.animationTimer != 0). | |||
| 278 | + | Bool("timeout_running", pb.timeoutTimer != 0). | |||
| 279 | + | Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)). | |||
| 280 | + | Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)). | |||
| 239 | Msg("progress bar hide before") | 281 | Msg("progress bar hide before") | ||
| 240 | 282 | ||||
| 241 | if pb.visible { | 283 | if pb.visible { | ||
| 242 | pb.visible = false | 284 | pb.visible = false | ||
| 285 | + | pb.lastHideReason = reason | |||
| 243 | pb.progressBar.SetVisible(false) | 286 | pb.progressBar.SetVisible(false) | ||
| 244 | 287 | ||||
| 245 | // Stop any running animation | 288 | // Stop any running animation | ||
| @@ -261,6 +304,13 @@ func (pb *ProgressBar) hideInternal() { | |||||
| 261 | } | 304 | } | ||
| 262 | } | 305 | } | ||
| 263 | 306 | ||||
| 307 | + | func sinceTimeMs(start, now time.Time) int64 { | |||
| 308 | + | if start.IsZero() { | |||
| 309 | + | return -1 | |||
| 310 | + | } | |||
| 311 | + | return now.Sub(start).Milliseconds() | |||
| 312 | + | } | |||
| 313 | + | ||||
| 264 | // IsVisible returns whether the progress bar is currently visible. | 314 | // IsVisible returns whether the progress bar is currently visible. | ||
| 265 | func (pb *ProgressBar) IsVisible() bool { | 315 | func (pb *ProgressBar) IsVisible() bool { | ||
| 266 | pb.mu.Lock() | 316 | pb.mu.Lock() | ||