fix(app): restore green gates and harden accent input
- commit
- b574bab6d1fdae2179b0f02ee90dbd112caa5b29
- author
- brice · · 2026-03-11 06:44 UTC
- parent
- 0523844
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
21 files · +310 -52
M Makefile +1 -4
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Makefile for dumber (Clean Architecture - puregotk) | |
| 2 | 2 | ||
| 3 | - | .PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego | |
| 3 | + | .PHONY: build build-frontend test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego | |
| 4 | 4 | ||
| 5 | 5 | # Load local overrides from .env.local if present (Makefile syntax) | |
| 6 | 6 | ifneq (,$(wildcard .env.local)) | |
| @@ -71,9 +71,6 @@ test: ## Run tests | |||
| 71 | 71 | @echo "Running tests..." | |
| 72 | 72 | go test -v ./... | |
| 73 | 73 | ||
| 74 | - | check-docs: ## Verify README/docs match code | |
| 75 | - | @go run ./dev/check-docs.go --fail-on-error | |
| 76 | - | ||
| 77 | 74 | test-race: ## Run tests with race detection | |
| 78 | 75 | @echo "Running tests with race detection..." | |
| 79 | 76 | go test -race -v ./... | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | # Makefile for dumber (Clean Architecture - puregotk) | 1 | # Makefile for dumber (Clean Architecture - puregotk) | ||
| 2 | 2 | ||||
| 3 | - | .PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego | 3 | + | .PHONY: build build-frontend test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego |
| 4 | 4 | ||||
| 5 | # Load local overrides from .env.local if present (Makefile syntax) | 5 | # Load local overrides from .env.local if present (Makefile syntax) | ||
| 6 | ifneq (,$(wildcard .env.local)) | 6 | ifneq (,$(wildcard .env.local)) | ||
| @@ -71,9 +71,6 @@ test: ## Run tests | |||||
| 71 | @echo "Running tests..." | 71 | @echo "Running tests..." | ||
| 72 | go test -v ./... | 72 | go test -v ./... | ||
| 73 | 73 | ||||
| 74 | - | check-docs: ## Verify README/docs match code | |||
| 75 | - | @go run ./dev/check-docs.go --fail-on-error | |||
| 76 | - | ||||
| 77 | test-race: ## Run tests with race detection | 74 | test-race: ## Run tests with race detection | ||
| 78 | @echo "Running tests with race detection..." | 75 | @echo "Running tests with race detection..." | ||
| 79 | go test -race -v ./... | 76 | go test -race -v ./... | ||
M cmd/dumber/main.go +1 -8
| @@ -73,14 +73,7 @@ func main() { | |||
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | 75 | func runGUI() int { | |
| 76 | - | // Wayland compositors that lack text-input-v3 (e.g. niri) don't support | |
| 77 | - | // IBus/Fcitx input methods, causing dead-key compose to break. Fall back to | |
| 78 | - | // GTK's built-in simple IM context so dead keys work out of the box, but | |
| 79 | - | // only when the user hasn't explicitly chosen an IM module. | |
| 80 | - | if os.Getenv("GTK_IM_MODULE") == "" { | |
| 81 | - | fmt.Fprintf(os.Stderr, "dumber: GTK_IM_MODULE unset, defaulting to gtk-im-context-simple for dead-key support\n") | |
| 82 | - | _ = os.Setenv("GTK_IM_MODULE", "gtk-im-context-simple") | |
| 83 | - | } | |
| 76 | + | bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr) | |
| 84 | 77 | ||
| 85 | 78 | runtime.LockOSThread() | |
| 86 | 79 | component.SetSkeletonVersion(version) | |
| @@ -73,14 +73,7 @@ func main() { | |||||
| 73 | } | 73 | } | ||
| 74 | 74 | ||||
| 75 | func runGUI() int { | 75 | func runGUI() int { | ||
| 76 | - | // Wayland compositors that lack text-input-v3 (e.g. niri) don't support | 76 | + | bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr) |
| 77 | - | // IBus/Fcitx input methods, causing dead-key compose to break. Fall back to | |||
| 78 | - | // GTK's built-in simple IM context so dead keys work out of the box, but | |||
| 79 | - | // only when the user hasn't explicitly chosen an IM module. | |||
| 80 | - | if os.Getenv("GTK_IM_MODULE") == "" { | |||
| 81 | - | fmt.Fprintf(os.Stderr, "dumber: GTK_IM_MODULE unset, defaulting to gtk-im-context-simple for dead-key support\n") | |||
| 82 | - | _ = os.Setenv("GTK_IM_MODULE", "gtk-im-context-simple") | |||
| 83 | - | } | |||
| 84 | 77 | ||||
| 85 | runtime.LockOSThread() | 78 | runtime.LockOSThread() | ||
| 86 | component.SetSkeletonVersion(version) | 79 | component.SetSkeletonVersion(version) | ||
A internal/bootstrap/runtime_env.go +33 -0
| @@ -0,0 +1,33 @@ | |||
| 1 | + | package bootstrap | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "io" | |
| 6 | + | "os" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // ApplyGTKIMModuleFallback sets GTK_IM_MODULE to "gtk-im-context-simple" when: | |
| 10 | + | // - GTK_IM_MODULE is unset, AND | |
| 11 | + | // - the session appears to be Wayland (XDG_SESSION_TYPE == "wayland" OR WAYLAND_DISPLAY != ""). | |
| 12 | + | // | |
| 13 | + | // This ensures dead-key compose works on Wayland compositors that lack text-input-v3 | |
| 14 | + | // (e.g. niri), without overriding an explicit user choice. | |
| 15 | + | // | |
| 16 | + | // The getter and setter parameters allow injection for testing; pass os.Getenv and | |
| 17 | + | // os.Setenv for production use. | |
| 18 | + | func ApplyGTKIMModuleFallback(stderr io.Writer, getter func(string) string, setter func(string, string) error) { | |
| 19 | + | if getter("GTK_IM_MODULE") != "" { | |
| 20 | + | return | |
| 21 | + | } | |
| 22 | + | isWayland := getter("XDG_SESSION_TYPE") == "wayland" || getter("WAYLAND_DISPLAY") != "" | |
| 23 | + | if !isWayland { | |
| 24 | + | return | |
| 25 | + | } | |
| 26 | + | _, _ = fmt.Fprintf(stderr, "dumber: GTK_IM_MODULE unset, defaulting to gtk-im-context-simple for dead-key support\n") | |
| 27 | + | _ = setter("GTK_IM_MODULE", "gtk-im-context-simple") | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // ApplyGTKIMModuleFallbackDefault calls ApplyGTKIMModuleFallback with os.Getenv and os.Setenv. | |
| 31 | + | func ApplyGTKIMModuleFallbackDefault(stderr io.Writer) { | |
| 32 | + | ApplyGTKIMModuleFallback(stderr, os.Getenv, os.Setenv) | |
| 33 | + | } | |
| @@ -0,0 +1,33 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "io" | |||
| 6 | + | "os" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // ApplyGTKIMModuleFallback sets GTK_IM_MODULE to "gtk-im-context-simple" when: | |||
| 10 | + | // - GTK_IM_MODULE is unset, AND | |||
| 11 | + | // - the session appears to be Wayland (XDG_SESSION_TYPE == "wayland" OR WAYLAND_DISPLAY != ""). | |||
| 12 | + | // | |||
| 13 | + | // This ensures dead-key compose works on Wayland compositors that lack text-input-v3 | |||
| 14 | + | // (e.g. niri), without overriding an explicit user choice. | |||
| 15 | + | // | |||
| 16 | + | // The getter and setter parameters allow injection for testing; pass os.Getenv and | |||
| 17 | + | // os.Setenv for production use. | |||
| 18 | + | func ApplyGTKIMModuleFallback(stderr io.Writer, getter func(string) string, setter func(string, string) error) { | |||
| 19 | + | if getter("GTK_IM_MODULE") != "" { | |||
| 20 | + | return | |||
| 21 | + | } | |||
| 22 | + | isWayland := getter("XDG_SESSION_TYPE") == "wayland" || getter("WAYLAND_DISPLAY") != "" | |||
| 23 | + | if !isWayland { | |||
| 24 | + | return | |||
| 25 | + | } | |||
| 26 | + | _, _ = fmt.Fprintf(stderr, "dumber: GTK_IM_MODULE unset, defaulting to gtk-im-context-simple for dead-key support\n") | |||
| 27 | + | _ = setter("GTK_IM_MODULE", "gtk-im-context-simple") | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // ApplyGTKIMModuleFallbackDefault calls ApplyGTKIMModuleFallback with os.Getenv and os.Setenv. | |||
| 31 | + | func ApplyGTKIMModuleFallbackDefault(stderr io.Writer) { | |||
| 32 | + | ApplyGTKIMModuleFallback(stderr, os.Getenv, os.Setenv) | |||
| 33 | + | } | |||
A internal/bootstrap/runtime_env_test.go +82 -0
| @@ -0,0 +1,82 @@ | |||
| 1 | + | package bootstrap | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestApplyGTKIMModuleFallback(t *testing.T) { | |
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | gtkIMModule string | |
| 14 | + | xdgSessionType string | |
| 15 | + | waylandDisplay string | |
| 16 | + | wantEnvSet bool | |
| 17 | + | wantMessageSent bool | |
| 18 | + | }{ | |
| 19 | + | { | |
| 20 | + | name: "GTK_IM_MODULE already set => no override, no message", | |
| 21 | + | gtkIMModule: "ibus", | |
| 22 | + | xdgSessionType: "wayland", | |
| 23 | + | waylandDisplay: "", | |
| 24 | + | wantEnvSet: false, | |
| 25 | + | wantMessageSent: false, | |
| 26 | + | }, | |
| 27 | + | { | |
| 28 | + | name: "non-Wayland with GTK_IM_MODULE unset => no override, no message", | |
| 29 | + | gtkIMModule: "", | |
| 30 | + | xdgSessionType: "x11", | |
| 31 | + | waylandDisplay: "", | |
| 32 | + | wantEnvSet: false, | |
| 33 | + | wantMessageSent: false, | |
| 34 | + | }, | |
| 35 | + | { | |
| 36 | + | name: "Wayland via XDG_SESSION_TYPE with GTK_IM_MODULE unset => override + message", | |
| 37 | + | gtkIMModule: "", | |
| 38 | + | xdgSessionType: "wayland", | |
| 39 | + | waylandDisplay: "", | |
| 40 | + | wantEnvSet: true, | |
| 41 | + | wantMessageSent: true, | |
| 42 | + | }, | |
| 43 | + | { | |
| 44 | + | name: "Wayland via WAYLAND_DISPLAY with GTK_IM_MODULE unset => override + message", | |
| 45 | + | gtkIMModule: "", | |
| 46 | + | xdgSessionType: "", | |
| 47 | + | waylandDisplay: "wayland-0", | |
| 48 | + | wantEnvSet: true, | |
| 49 | + | wantMessageSent: true, | |
| 50 | + | }, | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | for _, tt := range tests { | |
| 54 | + | t.Run(tt.name, func(t *testing.T) { | |
| 55 | + | var stderr bytes.Buffer | |
| 56 | + | env := map[string]string{ | |
| 57 | + | "GTK_IM_MODULE": tt.gtkIMModule, | |
| 58 | + | "XDG_SESSION_TYPE": tt.xdgSessionType, | |
| 59 | + | "WAYLAND_DISPLAY": tt.waylandDisplay, | |
| 60 | + | } | |
| 61 | + | getter := func(key string) string { return env[key] } | |
| 62 | + | var setKey, setValue string | |
| 63 | + | setter := func(key, val string) error { | |
| 64 | + | setKey = key | |
| 65 | + | setValue = val | |
| 66 | + | return nil | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | ApplyGTKIMModuleFallback(&stderr, getter, setter) | |
| 70 | + | ||
| 71 | + | msg := stderr.String() | |
| 72 | + | if tt.wantMessageSent { | |
| 73 | + | assert.Contains(t, msg, "GTK_IM_MODULE unset", "expected stderr message") | |
| 74 | + | assert.Equal(t, "GTK_IM_MODULE", setKey) | |
| 75 | + | assert.Equal(t, "gtk-im-context-simple", setValue) | |
| 76 | + | } else { | |
| 77 | + | assert.Empty(t, msg, "expected no stderr message") | |
| 78 | + | assert.Empty(t, setKey, "expected no env var set") | |
| 79 | + | } | |
| 80 | + | }) | |
| 81 | + | } | |
| 82 | + | } | |
| @@ -0,0 +1,82 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestApplyGTKIMModuleFallback(t *testing.T) { | |||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | gtkIMModule string | |||
| 14 | + | xdgSessionType string | |||
| 15 | + | waylandDisplay string | |||
| 16 | + | wantEnvSet bool | |||
| 17 | + | wantMessageSent bool | |||
| 18 | + | }{ | |||
| 19 | + | { | |||
| 20 | + | name: "GTK_IM_MODULE already set => no override, no message", | |||
| 21 | + | gtkIMModule: "ibus", | |||
| 22 | + | xdgSessionType: "wayland", | |||
| 23 | + | waylandDisplay: "", | |||
| 24 | + | wantEnvSet: false, | |||
| 25 | + | wantMessageSent: false, | |||
| 26 | + | }, | |||
| 27 | + | { | |||
| 28 | + | name: "non-Wayland with GTK_IM_MODULE unset => no override, no message", | |||
| 29 | + | gtkIMModule: "", | |||
| 30 | + | xdgSessionType: "x11", | |||
| 31 | + | waylandDisplay: "", | |||
| 32 | + | wantEnvSet: false, | |||
| 33 | + | wantMessageSent: false, | |||
| 34 | + | }, | |||
| 35 | + | { | |||
| 36 | + | name: "Wayland via XDG_SESSION_TYPE with GTK_IM_MODULE unset => override + message", | |||
| 37 | + | gtkIMModule: "", | |||
| 38 | + | xdgSessionType: "wayland", | |||
| 39 | + | waylandDisplay: "", | |||
| 40 | + | wantEnvSet: true, | |||
| 41 | + | wantMessageSent: true, | |||
| 42 | + | }, | |||
| 43 | + | { | |||
| 44 | + | name: "Wayland via WAYLAND_DISPLAY with GTK_IM_MODULE unset => override + message", | |||
| 45 | + | gtkIMModule: "", | |||
| 46 | + | xdgSessionType: "", | |||
| 47 | + | waylandDisplay: "wayland-0", | |||
| 48 | + | wantEnvSet: true, | |||
| 49 | + | wantMessageSent: true, | |||
| 50 | + | }, | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | for _, tt := range tests { | |||
| 54 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 55 | + | var stderr bytes.Buffer | |||
| 56 | + | env := map[string]string{ | |||
| 57 | + | "GTK_IM_MODULE": tt.gtkIMModule, | |||
| 58 | + | "XDG_SESSION_TYPE": tt.xdgSessionType, | |||
| 59 | + | "WAYLAND_DISPLAY": tt.waylandDisplay, | |||
| 60 | + | } | |||
| 61 | + | getter := func(key string) string { return env[key] } | |||
| 62 | + | var setKey, setValue string | |||
| 63 | + | setter := func(key, val string) error { | |||
| 64 | + | setKey = key | |||
| 65 | + | setValue = val | |||
| 66 | + | return nil | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | ApplyGTKIMModuleFallback(&stderr, getter, setter) | |||
| 70 | + | ||||
| 71 | + | msg := stderr.String() | |||
| 72 | + | if tt.wantMessageSent { | |||
| 73 | + | assert.Contains(t, msg, "GTK_IM_MODULE unset", "expected stderr message") | |||
| 74 | + | assert.Equal(t, "GTK_IM_MODULE", setKey) | |||
| 75 | + | assert.Equal(t, "gtk-im-context-simple", setValue) | |||
| 76 | + | } else { | |||
| 77 | + | assert.Empty(t, msg, "expected no stderr message") | |||
| 78 | + | assert.Empty(t, setKey, "expected no env var set") | |||
| 79 | + | } | |||
| 80 | + | }) | |||
| 81 | + | } | |||
| 82 | + | } | |||
M internal/bootstrap/session.go +7 -8
| @@ -407,6 +407,7 @@ func writeShutdownMarker(lockDir, sessionID string, endedAt time.Time) error { | |||
| 407 | 407 | } | |
| 408 | 408 | ||
| 409 | 409 | content := []byte(endedAt.Format(time.RFC3339Nano) + "\n" + startupLine + pidLine + ppidLine) | |
| 410 | + | //nolint:gosec // G703: path is derived from the app lock dir plus an internally generated session id | |
| 410 | 411 | if err := os.WriteFile(shutdownMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil { | |
| 411 | 412 | return err | |
| 412 | 413 | } | |
| @@ -456,6 +457,7 @@ func markAbruptExits(lockDir string, detectedAt time.Time, logger *zerolog.Logge | |||
| 456 | 457 | payload += "ppid=" + ppid + "\n" | |
| 457 | 458 | } | |
| 458 | 459 | ||
| 460 | + | //nolint:gosec // G703: path is derived from the app lock dir plus an internally generated session id | |
| 459 | 461 | if err := os.WriteFile(abruptMarkerPath(lockDir, sessionID), []byte(payload), markerFilePerm); err != nil { | |
| 460 | 462 | return abruptSessions, err | |
| 461 | 463 | } | |
| @@ -571,7 +573,8 @@ func markerValue(raw []byte, key string) string { | |||
| 571 | 573 | ||
| 572 | 574 | // runSessionCleanupAsync performs stale session cleanup and old session pruning | |
| 573 | 575 | // in a background goroutine. This avoids blocking startup for non-critical tasks. | |
| 574 | - | // Uses context.Background() to ensure cleanup completes even if startup context is canceled. | |
| 576 | + | // Uses context.WithoutCancel(startupCtx) so the background work inherits request | |
| 577 | + | // values and the logger from the startup context while detaching from its cancellation. | |
| 575 | 578 | func runSessionCleanupAsync( | |
| 576 | 579 | startupCtx context.Context, | |
| 577 | 580 | sessionUC *usecase.ManageSessionUseCase, | |
| @@ -580,14 +583,10 @@ func runSessionCleanupAsync( | |||
| 580 | 583 | lockDir string, | |
| 581 | 584 | log *zerolog.Logger, | |
| 582 | 585 | ) { | |
| 583 | - | // Silence unused parameter warning - startupCtx is intentionally unused. | |
| 584 | - | _ = startupCtx | |
| 585 | - | ||
| 586 | 586 | go func() { | |
| 587 | - | // Use a detached background context instead of the startup context: | |
| 588 | - | // session cleanup is critical and must run to completion even if the | |
| 589 | - | // startup context is canceled or times out. | |
| 590 | - | bgCtx := context.Background() | |
| 587 | + | // Detach cancellation from the startup context so cleanup runs to completion | |
| 588 | + | // even if the startup context is canceled, while still carrying its values/logger. | |
| 589 | + | bgCtx := context.WithoutCancel(startupCtx) | |
| 591 | 590 | ||
| 592 | 591 | // End stale active sessions (orphaned from crashed processes) | |
| 593 | 592 | if lockDir != "" { | |
| @@ -407,6 +407,7 @@ func writeShutdownMarker(lockDir, sessionID string, endedAt time.Time) error { | |||||
| 407 | } | 407 | } | ||
| 408 | 408 | ||||
| 409 | content := []byte(endedAt.Format(time.RFC3339Nano) + "\n" + startupLine + pidLine + ppidLine) | 409 | content := []byte(endedAt.Format(time.RFC3339Nano) + "\n" + startupLine + pidLine + ppidLine) | ||
| 410 | + | //nolint:gosec // G703: path is derived from the app lock dir plus an internally generated session id | |||
| 410 | if err := os.WriteFile(shutdownMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil { | 411 | if err := os.WriteFile(shutdownMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil { | ||
| 411 | return err | 412 | return err | ||
| 412 | } | 413 | } | ||
| @@ -456,6 +457,7 @@ func markAbruptExits(lockDir string, detectedAt time.Time, logger *zerolog.Logge | |||||
| 456 | payload += "ppid=" + ppid + "\n" | 457 | payload += "ppid=" + ppid + "\n" | ||
| 457 | } | 458 | } | ||
| 458 | 459 | ||||
| 460 | + | //nolint:gosec // G703: path is derived from the app lock dir plus an internally generated session id | |||
| 459 | if err := os.WriteFile(abruptMarkerPath(lockDir, sessionID), []byte(payload), markerFilePerm); err != nil { | 461 | if err := os.WriteFile(abruptMarkerPath(lockDir, sessionID), []byte(payload), markerFilePerm); err != nil { | ||
| 460 | return abruptSessions, err | 462 | return abruptSessions, err | ||
| 461 | } | 463 | } | ||
| @@ -571,7 +573,8 @@ func markerValue(raw []byte, key string) string { | |||||
| 571 | 573 | ||||
| 572 | // runSessionCleanupAsync performs stale session cleanup and old session pruning | 574 | // runSessionCleanupAsync performs stale session cleanup and old session pruning | ||
| 573 | // in a background goroutine. This avoids blocking startup for non-critical tasks. | 575 | // in a background goroutine. This avoids blocking startup for non-critical tasks. | ||
| 574 | - | // Uses context.Background() to ensure cleanup completes even if startup context is canceled. | 576 | + | // Uses context.WithoutCancel(startupCtx) so the background work inherits request |
| 577 | + | // values and the logger from the startup context while detaching from its cancellation. | |||
| 575 | func runSessionCleanupAsync( | 578 | func runSessionCleanupAsync( | ||
| 576 | startupCtx context.Context, | 579 | startupCtx context.Context, | ||
| 577 | sessionUC *usecase.ManageSessionUseCase, | 580 | sessionUC *usecase.ManageSessionUseCase, | ||
| @@ -580,14 +583,10 @@ func runSessionCleanupAsync( | |||||
| 580 | lockDir string, | 583 | lockDir string, | ||
| 581 | log *zerolog.Logger, | 584 | log *zerolog.Logger, | ||
| 582 | ) { | 585 | ) { | ||
| 583 | - | // Silence unused parameter warning - startupCtx is intentionally unused. | |||
| 584 | - | _ = startupCtx | |||
| 585 | - | ||||
| 586 | go func() { | 586 | go func() { | ||
| 587 | - | // Use a detached background context instead of the startup context: | 587 | + | // Detach cancellation from the startup context so cleanup runs to completion |
| 588 | - | // session cleanup is critical and must run to completion even if the | 588 | + | // even if the startup context is canceled, while still carrying its values/logger. |
| 589 | - | // startup context is canceled or times out. | 589 | + | bgCtx := context.WithoutCancel(startupCtx) |
| 590 | - | bgCtx := context.Background() | |||
| 591 | 590 | ||||
| 592 | // End stale active sessions (orphaned from crashed processes) | 591 | // End stale active sessions (orphaned from crashed processes) | ||
| 593 | if lockDir != "" { | 592 | if lockDir != "" { | ||
M internal/cli/model/sessions.go +2 -2
| @@ -602,11 +602,11 @@ func (m SessionsModel) renderSessionDetails(info entity.SessionInfo) string { | |||
| 602 | 602 | tabName = "Tab" | |
| 603 | 603 | } | |
| 604 | 604 | ||
| 605 | - | b.WriteString(fmt.Sprintf(" %s%s %s\n", | |
| 605 | + | fmt.Fprintf(&b, " %s%s %s\n", | |
| 606 | 606 | treeStyle.Render(branch), | |
| 607 | 607 | leafStyle.Render(tabIcon), | |
| 608 | 608 | t.Normal.Render(tabName), | |
| 609 | - | )) | |
| 609 | + | ) | |
| 610 | 610 | ||
| 611 | 611 | // Render panes under this tab | |
| 612 | 612 | m.renderPaneTree(&b, &tab.Workspace, isLastTab, t, treeStyle, leafStyle) | |
| @@ -602,11 +602,11 @@ func (m SessionsModel) renderSessionDetails(info entity.SessionInfo) string { | |||||
| 602 | tabName = "Tab" | 602 | tabName = "Tab" | ||
| 603 | } | 603 | } | ||
| 604 | 604 | ||||
| 605 | - | b.WriteString(fmt.Sprintf(" %s%s %s\n", | 605 | + | fmt.Fprintf(&b, " %s%s %s\n", |
| 606 | treeStyle.Render(branch), | 606 | treeStyle.Render(branch), | ||
| 607 | leafStyle.Render(tabIcon), | 607 | leafStyle.Render(tabIcon), | ||
| 608 | t.Normal.Render(tabName), | 608 | t.Normal.Render(tabName), | ||
| 609 | - | )) | 609 | + | ) |
| 610 | 610 | ||||
| 611 | // Render panes under this tab | 611 | // Render panes under this tab | ||
| 612 | m.renderPaneTree(&b, &tab.Workspace, isLastTab, t, treeStyle, leafStyle) | 612 | m.renderPaneTree(&b, &tab.Workspace, isLastTab, t, treeStyle, leafStyle) | ||
M internal/cli/styles/config.go +12 -12
| @@ -54,16 +54,16 @@ func (r *ConfigRenderer) RenderMissingKeys(keys []port.KeyInfo) string { | |||
| 54 | 54 | valueStyle := lipgloss.NewStyle().Foreground(r.theme.Text) | |
| 55 | 55 | ||
| 56 | 56 | var sb strings.Builder | |
| 57 | - | sb.WriteString(fmt.Sprintf("\n Missing settings (%d):\n", len(keys))) | |
| 57 | + | fmt.Fprintf(&sb, "\n Missing settings (%d):\n", len(keys)) | |
| 58 | 58 | ||
| 59 | 59 | for _, key := range keys { | |
| 60 | - | sb.WriteString(fmt.Sprintf( | |
| 60 | + | fmt.Fprintf(&sb, | |
| 61 | 61 | " %s %s\n Type: %s | Default: %s\n", | |
| 62 | 62 | iconStyle.Render(IconCursor), | |
| 63 | 63 | keyStyle.Render(key.Key), | |
| 64 | 64 | typeStyle.Render(key.Type), | |
| 65 | 65 | valueStyle.Render(key.DefaultValue), | |
| 66 | - | )) | |
| 66 | + | ) | |
| 67 | 67 | } | |
| 68 | 68 | ||
| 69 | 69 | return sb.String() | |
| @@ -98,42 +98,42 @@ func (r *ConfigRenderer) RenderChanges(changes []port.KeyChange) string { | |||
| 98 | 98 | ||
| 99 | 99 | // Show renamed keys first (most important for user to understand) | |
| 100 | 100 | if len(renamed) > 0 { | |
| 101 | - | sb.WriteString(fmt.Sprintf("\n Renamed settings (%d):\n", len(renamed))) | |
| 101 | + | fmt.Fprintf(&sb, "\n Renamed settings (%d):\n", len(renamed)) | |
| 102 | 102 | for _, c := range renamed { | |
| 103 | - | sb.WriteString(fmt.Sprintf( | |
| 103 | + | fmt.Fprintf(&sb, | |
| 104 | 104 | " %s %s %s %s\n Value: %s\n", | |
| 105 | 105 | renameStyle.Render("~"), | |
| 106 | 106 | valueStyle.Render(c.OldKey), | |
| 107 | 107 | renameStyle.Render("→"), | |
| 108 | 108 | keyStyle.Render(c.NewKey), | |
| 109 | 109 | valueStyle.Render(c.OldValue), | |
| 110 | - | )) | |
| 110 | + | ) | |
| 111 | 111 | } | |
| 112 | 112 | } | |
| 113 | 113 | ||
| 114 | 114 | // Show new settings | |
| 115 | 115 | if len(added) > 0 { | |
| 116 | - | sb.WriteString(fmt.Sprintf("\n New settings (%d):\n", len(added))) | |
| 116 | + | fmt.Fprintf(&sb, "\n New settings (%d):\n", len(added)) | |
| 117 | 117 | for _, c := range added { | |
| 118 | - | sb.WriteString(fmt.Sprintf( | |
| 118 | + | fmt.Fprintf(&sb, | |
| 119 | 119 | " %s %s\n Default: %s\n", | |
| 120 | 120 | addStyle.Render("+"), | |
| 121 | 121 | keyStyle.Render(c.NewKey), | |
| 122 | 122 | valueStyle.Render(c.NewValue), | |
| 123 | - | )) | |
| 123 | + | ) | |
| 124 | 124 | } | |
| 125 | 125 | } | |
| 126 | 126 | ||
| 127 | 127 | // Show deprecated settings (will be removed) | |
| 128 | 128 | if len(removed) > 0 { | |
| 129 | - | sb.WriteString(fmt.Sprintf("\n Deprecated settings (%d):\n", len(removed))) | |
| 129 | + | fmt.Fprintf(&sb, "\n Deprecated settings (%d):\n", len(removed)) | |
| 130 | 130 | for _, c := range removed { | |
| 131 | - | sb.WriteString(fmt.Sprintf( | |
| 131 | + | fmt.Fprintf(&sb, | |
| 132 | 132 | " %s %s %s\n", | |
| 133 | 133 | removeStyle.Render("-"), | |
| 134 | 134 | valueStyle.Render(c.OldKey), | |
| 135 | 135 | removeStyle.Render("(will be removed)"), | |
| 136 | - | )) | |
| 136 | + | ) | |
| 137 | 137 | } | |
| 138 | 138 | } | |
| 139 | 139 | ||
| @@ -54,16 +54,16 @@ func (r *ConfigRenderer) RenderMissingKeys(keys []port.KeyInfo) string { | |||||
| 54 | valueStyle := lipgloss.NewStyle().Foreground(r.theme.Text) | 54 | valueStyle := lipgloss.NewStyle().Foreground(r.theme.Text) | ||
| 55 | 55 | ||||
| 56 | var sb strings.Builder | 56 | var sb strings.Builder | ||
| 57 | - | sb.WriteString(fmt.Sprintf("\n Missing settings (%d):\n", len(keys))) | 57 | + | fmt.Fprintf(&sb, "\n Missing settings (%d):\n", len(keys)) |
| 58 | 58 | ||||
| 59 | for _, key := range keys { | 59 | for _, key := range keys { | ||
| 60 | - | sb.WriteString(fmt.Sprintf( | 60 | + | fmt.Fprintf(&sb, |
| 61 | " %s %s\n Type: %s | Default: %s\n", | 61 | " %s %s\n Type: %s | Default: %s\n", | ||
| 62 | iconStyle.Render(IconCursor), | 62 | iconStyle.Render(IconCursor), | ||
| 63 | keyStyle.Render(key.Key), | 63 | keyStyle.Render(key.Key), | ||
| 64 | typeStyle.Render(key.Type), | 64 | typeStyle.Render(key.Type), | ||
| 65 | valueStyle.Render(key.DefaultValue), | 65 | valueStyle.Render(key.DefaultValue), | ||
| 66 | - | )) | 66 | + | ) |
| 67 | } | 67 | } | ||
| 68 | 68 | ||||
| 69 | return sb.String() | 69 | return sb.String() | ||
| @@ -98,42 +98,42 @@ func (r *ConfigRenderer) RenderChanges(changes []port.KeyChange) string { | |||||
| 98 | 98 | ||||
| 99 | // Show renamed keys first (most important for user to understand) | 99 | // Show renamed keys first (most important for user to understand) | ||
| 100 | if len(renamed) > 0 { | 100 | if len(renamed) > 0 { | ||
| 101 | - | sb.WriteString(fmt.Sprintf("\n Renamed settings (%d):\n", len(renamed))) | 101 | + | fmt.Fprintf(&sb, "\n Renamed settings (%d):\n", len(renamed)) |
| 102 | for _, c := range renamed { | 102 | for _, c := range renamed { | ||
| 103 | - | sb.WriteString(fmt.Sprintf( | 103 | + | fmt.Fprintf(&sb, |
| 104 | " %s %s %s %s\n Value: %s\n", | 104 | " %s %s %s %s\n Value: %s\n", | ||
| 105 | renameStyle.Render("~"), | 105 | renameStyle.Render("~"), | ||
| 106 | valueStyle.Render(c.OldKey), | 106 | valueStyle.Render(c.OldKey), | ||
| 107 | renameStyle.Render("→"), | 107 | renameStyle.Render("→"), | ||
| 108 | keyStyle.Render(c.NewKey), | 108 | keyStyle.Render(c.NewKey), | ||
| 109 | valueStyle.Render(c.OldValue), | 109 | valueStyle.Render(c.OldValue), | ||
| 110 | - | )) | 110 | + | ) |
| 111 | } | 111 | } | ||
| 112 | } | 112 | } | ||
| 113 | 113 | ||||
| 114 | // Show new settings | 114 | // Show new settings | ||
| 115 | if len(added) > 0 { | 115 | if len(added) > 0 { | ||
| 116 | - | sb.WriteString(fmt.Sprintf("\n New settings (%d):\n", len(added))) | 116 | + | fmt.Fprintf(&sb, "\n New settings (%d):\n", len(added)) |
| 117 | for _, c := range added { | 117 | for _, c := range added { | ||
| 118 | - | sb.WriteString(fmt.Sprintf( | 118 | + | fmt.Fprintf(&sb, |
| 119 | " %s %s\n Default: %s\n", | 119 | " %s %s\n Default: %s\n", | ||
| 120 | addStyle.Render("+"), | 120 | addStyle.Render("+"), | ||
| 121 | keyStyle.Render(c.NewKey), | 121 | keyStyle.Render(c.NewKey), | ||
| 122 | valueStyle.Render(c.NewValue), | 122 | valueStyle.Render(c.NewValue), | ||
| 123 | - | )) | 123 | + | ) |
| 124 | } | 124 | } | ||
| 125 | } | 125 | } | ||
| 126 | 126 | ||||
| 127 | // Show deprecated settings (will be removed) | 127 | // Show deprecated settings (will be removed) | ||
| 128 | if len(removed) > 0 { | 128 | if len(removed) > 0 { | ||
| 129 | - | sb.WriteString(fmt.Sprintf("\n Deprecated settings (%d):\n", len(removed))) | 129 | + | fmt.Fprintf(&sb, "\n Deprecated settings (%d):\n", len(removed)) |
| 130 | for _, c := range removed { | 130 | for _, c := range removed { | ||
| 131 | - | sb.WriteString(fmt.Sprintf( | 131 | + | fmt.Fprintf(&sb, |
| 132 | " %s %s %s\n", | 132 | " %s %s %s\n", | ||
| 133 | removeStyle.Render("-"), | 133 | removeStyle.Render("-"), | ||
| 134 | valueStyle.Render(c.OldKey), | 134 | valueStyle.Render(c.OldKey), | ||
| 135 | removeStyle.Render("(will be removed)"), | 135 | removeStyle.Render("(will be removed)"), | ||
| 136 | - | )) | 136 | + | ) |
| 137 | } | 137 | } | ||
| 138 | } | 138 | } | ||
| 139 | 139 | ||||
M internal/infrastructure/config/diff_formatter.go +7 -5
| @@ -7,6 +7,8 @@ import ( | |||
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | + | const noChangesMsg = "No changes detected." | |
| 11 | + | ||
| 10 | 12 | // ConfigDiffFormatter implements port.DiffFormatter for formatting config changes. | |
| 11 | 13 | type ConfigDiffFormatter struct{} | |
| 12 | 14 | ||
| @@ -18,7 +20,7 @@ func NewDiffFormatter() *ConfigDiffFormatter { | |||
| 18 | 20 | // FormatChangesAsDiff returns changes formatted as a diff for display. | |
| 19 | 21 | func (*ConfigDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string { | |
| 20 | 22 | if len(changes) == 0 { | |
| 21 | - | return "No changes detected." | |
| 23 | + | return noChangesMsg | |
| 22 | 24 | } | |
| 23 | 25 | ||
| 24 | 26 | var sb strings.Builder | |
| @@ -27,12 +29,12 @@ func (*ConfigDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string | |||
| 27 | 29 | for _, change := range changes { | |
| 28 | 30 | switch change.Type { | |
| 29 | 31 | case port.KeyChangeAdded: | |
| 30 | - | sb.WriteString(fmt.Sprintf(" + %s = %s\n", change.NewKey, change.NewValue)) | |
| 32 | + | fmt.Fprintf(&sb, " + %s = %s\n", change.NewKey, change.NewValue) | |
| 31 | 33 | case port.KeyChangeRemoved: | |
| 32 | - | sb.WriteString(fmt.Sprintf(" - %s = %s (deprecated)\n", change.OldKey, change.OldValue)) | |
| 34 | + | fmt.Fprintf(&sb, " - %s = %s (deprecated)\n", change.OldKey, change.OldValue) | |
| 33 | 35 | case port.KeyChangeRenamed: | |
| 34 | - | sb.WriteString(fmt.Sprintf(" ~ %s -> %s\n", change.OldKey, change.NewKey)) | |
| 35 | - | sb.WriteString(fmt.Sprintf(" (value: %s)\n", change.OldValue)) | |
| 36 | + | fmt.Fprintf(&sb, " ~ %s -> %s\n", change.OldKey, change.NewKey) | |
| 37 | + | fmt.Fprintf(&sb, " (value: %s)\n", change.OldValue) | |
| 36 | 38 | } | |
| 37 | 39 | } | |
| 38 | 40 | ||
| @@ -7,6 +7,8 @@ import ( | |||||
| 7 | "github.com/bnema/dumber/internal/application/port" | 7 | "github.com/bnema/dumber/internal/application/port" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | + | const noChangesMsg = "No changes detected." | |||
| 11 | + | ||||
| 10 | // ConfigDiffFormatter implements port.DiffFormatter for formatting config changes. | 12 | // ConfigDiffFormatter implements port.DiffFormatter for formatting config changes. | ||
| 11 | type ConfigDiffFormatter struct{} | 13 | type ConfigDiffFormatter struct{} | ||
| 12 | 14 | ||||
| @@ -18,7 +20,7 @@ func NewDiffFormatter() *ConfigDiffFormatter { | |||||
| 18 | // FormatChangesAsDiff returns changes formatted as a diff for display. | 20 | // FormatChangesAsDiff returns changes formatted as a diff for display. | ||
| 19 | func (*ConfigDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string { | 21 | func (*ConfigDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string { | ||
| 20 | if len(changes) == 0 { | 22 | if len(changes) == 0 { | ||
| 21 | - | return "No changes detected." | 23 | + | return noChangesMsg |
| 22 | } | 24 | } | ||
| 23 | 25 | ||||
| 24 | var sb strings.Builder | 26 | var sb strings.Builder | ||
| @@ -27,12 +29,12 @@ func (*ConfigDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string | |||||
| 27 | for _, change := range changes { | 29 | for _, change := range changes { | ||
| 28 | switch change.Type { | 30 | switch change.Type { | ||
| 29 | case port.KeyChangeAdded: | 31 | case port.KeyChangeAdded: | ||
| 30 | - | sb.WriteString(fmt.Sprintf(" + %s = %s\n", change.NewKey, change.NewValue)) | 32 | + | fmt.Fprintf(&sb, " + %s = %s\n", change.NewKey, change.NewValue) |
| 31 | case port.KeyChangeRemoved: | 33 | case port.KeyChangeRemoved: | ||
| 32 | - | sb.WriteString(fmt.Sprintf(" - %s = %s (deprecated)\n", change.OldKey, change.OldValue)) | 34 | + | fmt.Fprintf(&sb, " - %s = %s (deprecated)\n", change.OldKey, change.OldValue) |
| 33 | case port.KeyChangeRenamed: | 35 | case port.KeyChangeRenamed: | ||
| 34 | - | sb.WriteString(fmt.Sprintf(" ~ %s -> %s\n", change.OldKey, change.NewKey)) | 36 | + | fmt.Fprintf(&sb, " ~ %s -> %s\n", change.OldKey, change.NewKey) |
| 35 | - | sb.WriteString(fmt.Sprintf(" (value: %s)\n", change.OldValue)) | 37 | + | fmt.Fprintf(&sb, " (value: %s)\n", change.OldValue) |
| 36 | } | 38 | } | ||
| 37 | } | 39 | } | ||
| 38 | 40 | ||||
A internal/infrastructure/config/diff_formatter_test.go +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestConfigDiffFormatter_FormatChangesAsDiff(t *testing.T) { | |
| 10 | + | f := NewDiffFormatter() | |
| 11 | + | ||
| 12 | + | t.Run("no changes", func(t *testing.T) { | |
| 13 | + | got := f.FormatChangesAsDiff(nil) | |
| 14 | + | want := noChangesMsg | |
| 15 | + | if got != want { | |
| 16 | + | t.Errorf("got %q, want %q", got, want) | |
| 17 | + | } | |
| 18 | + | }) | |
| 19 | + | ||
| 20 | + | t.Run("empty slice", func(t *testing.T) { | |
| 21 | + | got := f.FormatChangesAsDiff([]port.KeyChange{}) | |
| 22 | + | want := noChangesMsg | |
| 23 | + | if got != want { | |
| 24 | + | t.Errorf("got %q, want %q", got, want) | |
| 25 | + | } | |
| 26 | + | }) | |
| 27 | + | ||
| 28 | + | t.Run("added change", func(t *testing.T) { | |
| 29 | + | changes := []port.KeyChange{ | |
| 30 | + | {Type: port.KeyChangeAdded, NewKey: "update.enable", NewValue: "true"}, | |
| 31 | + | } | |
| 32 | + | got := f.FormatChangesAsDiff(changes) | |
| 33 | + | want := "Config migration changes:\n\n + update.enable = true\n" | |
| 34 | + | if got != want { | |
| 35 | + | t.Errorf("got %q, want %q", got, want) | |
| 36 | + | } | |
| 37 | + | }) | |
| 38 | + | ||
| 39 | + | t.Run("removed change", func(t *testing.T) { | |
| 40 | + | changes := []port.KeyChange{ | |
| 41 | + | {Type: port.KeyChangeRemoved, OldKey: "old.key", OldValue: "42"}, | |
| 42 | + | } | |
| 43 | + | got := f.FormatChangesAsDiff(changes) | |
| 44 | + | want := "Config migration changes:\n\n - old.key = 42 (deprecated)\n" | |
| 45 | + | if got != want { | |
| 46 | + | t.Errorf("got %q, want %q", got, want) | |
| 47 | + | } | |
| 48 | + | }) | |
| 49 | + | ||
| 50 | + | t.Run("renamed change", func(t *testing.T) { | |
| 51 | + | changes := []port.KeyChange{ | |
| 52 | + | {Type: port.KeyChangeRenamed, OldKey: "old.name", NewKey: "new.name", OldValue: "hello"}, | |
| 53 | + | } | |
| 54 | + | got := f.FormatChangesAsDiff(changes) | |
| 55 | + | want := "Config migration changes:\n\n ~ old.name -> new.name\n (value: hello)\n" | |
| 56 | + | if got != want { | |
| 57 | + | t.Errorf("got %q, want %q", got, want) | |
| 58 | + | } | |
| 59 | + | }) | |
| 60 | + | ||
| 61 | + | t.Run("all change types combined", func(t *testing.T) { | |
| 62 | + | changes := []port.KeyChange{ | |
| 63 | + | {Type: port.KeyChangeAdded, NewKey: "feature.flag", NewValue: "false"}, | |
| 64 | + | {Type: port.KeyChangeRemoved, OldKey: "legacy.opt", OldValue: "1"}, | |
| 65 | + | {Type: port.KeyChangeRenamed, OldKey: "foo.bar", NewKey: "foo.baz", OldValue: "qux"}, | |
| 66 | + | } | |
| 67 | + | got := f.FormatChangesAsDiff(changes) | |
| 68 | + | want := "Config migration changes:\n\n" + | |
| 69 | + | " + feature.flag = false\n" + | |
| 70 | + | " - legacy.opt = 1 (deprecated)\n" + | |
| 71 | + | " ~ foo.bar -> foo.baz\n" + | |
| 72 | + | " (value: qux)\n" | |
| 73 | + | if got != want { | |
| 74 | + | t.Errorf("got %q, want %q", got, want) | |
| 75 | + | } | |
| 76 | + | }) | |
| 77 | + | } | |
| @@ -0,0 +1,77 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestConfigDiffFormatter_FormatChangesAsDiff(t *testing.T) { | |||
| 10 | + | f := NewDiffFormatter() | |||
| 11 | + | ||||
| 12 | + | t.Run("no changes", func(t *testing.T) { | |||
| 13 | + | got := f.FormatChangesAsDiff(nil) | |||
| 14 | + | want := noChangesMsg | |||
| 15 | + | if got != want { | |||
| 16 | + | t.Errorf("got %q, want %q", got, want) | |||
| 17 | + | } | |||
| 18 | + | }) | |||
| 19 | + | ||||
| 20 | + | t.Run("empty slice", func(t *testing.T) { | |||
| 21 | + | got := f.FormatChangesAsDiff([]port.KeyChange{}) | |||
| 22 | + | want := noChangesMsg | |||
| 23 | + | if got != want { | |||
| 24 | + | t.Errorf("got %q, want %q", got, want) | |||
| 25 | + | } | |||
| 26 | + | }) | |||
| 27 | + | ||||
| 28 | + | t.Run("added change", func(t *testing.T) { | |||
| 29 | + | changes := []port.KeyChange{ | |||
| 30 | + | {Type: port.KeyChangeAdded, NewKey: "update.enable", NewValue: "true"}, | |||
| 31 | + | } | |||
| 32 | + | got := f.FormatChangesAsDiff(changes) | |||
| 33 | + | want := "Config migration changes:\n\n + update.enable = true\n" | |||
| 34 | + | if got != want { | |||
| 35 | + | t.Errorf("got %q, want %q", got, want) | |||
| 36 | + | } | |||
| 37 | + | }) | |||
| 38 | + | ||||
| 39 | + | t.Run("removed change", func(t *testing.T) { | |||
| 40 | + | changes := []port.KeyChange{ | |||
| 41 | + | {Type: port.KeyChangeRemoved, OldKey: "old.key", OldValue: "42"}, | |||
| 42 | + | } | |||
| 43 | + | got := f.FormatChangesAsDiff(changes) | |||
| 44 | + | want := "Config migration changes:\n\n - old.key = 42 (deprecated)\n" | |||
| 45 | + | if got != want { | |||
| 46 | + | t.Errorf("got %q, want %q", got, want) | |||
| 47 | + | } | |||
| 48 | + | }) | |||
| 49 | + | ||||
| 50 | + | t.Run("renamed change", func(t *testing.T) { | |||
| 51 | + | changes := []port.KeyChange{ | |||
| 52 | + | {Type: port.KeyChangeRenamed, OldKey: "old.name", NewKey: "new.name", OldValue: "hello"}, | |||
| 53 | + | } | |||
| 54 | + | got := f.FormatChangesAsDiff(changes) | |||
| 55 | + | want := "Config migration changes:\n\n ~ old.name -> new.name\n (value: hello)\n" | |||
| 56 | + | if got != want { | |||
| 57 | + | t.Errorf("got %q, want %q", got, want) | |||
| 58 | + | } | |||
| 59 | + | }) | |||
| 60 | + | ||||
| 61 | + | t.Run("all change types combined", func(t *testing.T) { | |||
| 62 | + | changes := []port.KeyChange{ | |||
| 63 | + | {Type: port.KeyChangeAdded, NewKey: "feature.flag", NewValue: "false"}, | |||
| 64 | + | {Type: port.KeyChangeRemoved, OldKey: "legacy.opt", OldValue: "1"}, | |||
| 65 | + | {Type: port.KeyChangeRenamed, OldKey: "foo.bar", NewKey: "foo.baz", OldValue: "qux"}, | |||
| 66 | + | } | |||
| 67 | + | got := f.FormatChangesAsDiff(changes) | |||
| 68 | + | want := "Config migration changes:\n\n" + | |||
| 69 | + | " + feature.flag = false\n" + | |||
| 70 | + | " - legacy.opt = 1 (deprecated)\n" + | |||
| 71 | + | " ~ foo.bar -> foo.baz\n" + | |||
| 72 | + | " (value: qux)\n" | |||
| 73 | + | if got != want { | |||
| 74 | + | t.Errorf("got %q, want %q", got, want) | |||
| 75 | + | } | |||
| 76 | + | }) | |||
| 77 | + | } | |||
M internal/infrastructure/config/xdg.go +1 -0
| @@ -189,6 +189,7 @@ func EnsureDirectories() error { | |||
| 189 | 189 | } | |
| 190 | 190 | ||
| 191 | 191 | for _, dir := range directories { | |
| 192 | + | //nolint:gosec // G703: XDG directories come from the user environment/config and are created intentionally | |
| 192 | 193 | if err := os.MkdirAll(dir, dirPerm); err != nil { | |
| 193 | 194 | return err | |
| 194 | 195 | } | |
| @@ -189,6 +189,7 @@ func EnsureDirectories() error { | |||||
| 189 | } | 189 | } | ||
| 190 | 190 | ||||
| 191 | for _, dir := range directories { | 191 | for _, dir := range directories { | ||
| 192 | + | //nolint:gosec // G703: XDG directories come from the user environment/config and are created intentionally | |||
| 192 | if err := os.MkdirAll(dir, dirPerm); err != nil { | 193 | if err := os.MkdirAll(dir, dirPerm); err != nil { | ||
| 193 | return err | 194 | return err | ||
| 194 | } | 195 | } | ||
M internal/infrastructure/textinput/webview_target.go +8 -4
| @@ -95,13 +95,17 @@ func (t *WebViewTarget) InsertText(ctx context.Context, text string) error { | |||
| 95 | 95 | Str("text", text). | |
| 96 | 96 | Msg("inserted text into WebView via clipboard paste") | |
| 97 | 97 | ||
| 98 | - | // Restore original clipboard after a short delay | |
| 99 | - | // This allows the paste operation to complete first | |
| 100 | - | // Use context.Background() since this cleanup should complete regardless of ctx cancellation | |
| 98 | + | // Restore original clipboard after a short delay. | |
| 99 | + | // This allows the paste operation to complete first. | |
| 100 | + | // Derive a detached context so the restore runs even if ctx is canceled, | |
| 101 | + | // but still carries its values/logger. | |
| 102 | + | detachedCtx := context.WithoutCancel(ctx) | |
| 101 | 103 | go func() { | |
| 102 | 104 | time.Sleep(clipboardRestoreDelay) | |
| 105 | + | restoreCtx, cancelRestore := context.WithTimeout(detachedCtx, clipboardRestoreDelay) | |
| 106 | + | defer cancelRestore() | |
| 103 | 107 | if originalClipboard != "" { | |
| 104 | - | if err := t.clipboard.WriteText(context.Background(), originalClipboard); err != nil { | |
| 108 | + | if err := t.clipboard.WriteText(restoreCtx, originalClipboard); err != nil { | |
| 105 | 109 | log.Debug().Err(err).Msg("failed to restore clipboard") | |
| 106 | 110 | } | |
| 107 | 111 | } | |
| @@ -95,13 +95,17 @@ func (t *WebViewTarget) InsertText(ctx context.Context, text string) error { | |||||
| 95 | Str("text", text). | 95 | Str("text", text). | ||
| 96 | Msg("inserted text into WebView via clipboard paste") | 96 | Msg("inserted text into WebView via clipboard paste") | ||
| 97 | 97 | ||||
| 98 | - | // Restore original clipboard after a short delay | 98 | + | // Restore original clipboard after a short delay. |
| 99 | - | // This allows the paste operation to complete first | 99 | + | // This allows the paste operation to complete first. |
| 100 | - | // Use context.Background() since this cleanup should complete regardless of ctx cancellation | 100 | + | // Derive a detached context so the restore runs even if ctx is canceled, |
| 101 | + | // but still carries its values/logger. | |||
| 102 | + | detachedCtx := context.WithoutCancel(ctx) | |||
| 101 | go func() { | 103 | go func() { | ||
| 102 | time.Sleep(clipboardRestoreDelay) | 104 | time.Sleep(clipboardRestoreDelay) | ||
| 105 | + | restoreCtx, cancelRestore := context.WithTimeout(detachedCtx, clipboardRestoreDelay) | |||
| 106 | + | defer cancelRestore() | |||
| 103 | if originalClipboard != "" { | 107 | if originalClipboard != "" { | ||
| 104 | - | if err := t.clipboard.WriteText(context.Background(), originalClipboard); err != nil { | 108 | + | if err := t.clipboard.WriteText(restoreCtx, originalClipboard); err != nil { |
| 105 | log.Debug().Err(err).Msg("failed to restore clipboard") | 109 | log.Debug().Err(err).Msg("failed to restore clipboard") | ||
| 106 | } | 110 | } | ||
| 107 | } | 111 | } | ||
M internal/infrastructure/updater/applier.go +1 -0
| @@ -162,6 +162,7 @@ func (a *Applier) StageUpdate(ctx context.Context, newBinaryPath string) error { | |||
| 162 | 162 | ||
| 163 | 163 | // Write to staging location. | |
| 164 | 164 | stagedPath := a.stagedBinaryPath() | |
| 165 | + | //nolint:gosec // G703: staged path is the app-managed pending-update path under the configured state dir | |
| 165 | 166 | if err := os.WriteFile(stagedPath, newBinary, applierExecPerm); err != nil { | |
| 166 | 167 | return fmt.Errorf("failed to write staged binary: %w", err) | |
| 167 | 168 | } | |
| @@ -162,6 +162,7 @@ func (a *Applier) StageUpdate(ctx context.Context, newBinaryPath string) error { | |||||
| 162 | 162 | ||||
| 163 | // Write to staging location. | 163 | // Write to staging location. | ||
| 164 | stagedPath := a.stagedBinaryPath() | 164 | stagedPath := a.stagedBinaryPath() | ||
| 165 | + | //nolint:gosec // G703: staged path is the app-managed pending-update path under the configured state dir | |||
| 165 | if err := os.WriteFile(stagedPath, newBinary, applierExecPerm); err != nil { | 166 | if err := os.WriteFile(stagedPath, newBinary, applierExecPerm); err != nil { | ||
| 166 | return fmt.Errorf("failed to write staged binary: %w", err) | 167 | return fmt.Errorf("failed to write staged binary: %w", err) | ||
| 167 | } | 168 | } | ||
M internal/infrastructure/updater/github.go +1 -0
| @@ -363,6 +363,7 @@ func doRequestWithRetryHelper( | |||
| 363 | 363 | if attempt > maxRetryAttempts { | |
| 364 | 364 | return nil, fmt.Errorf("request failed after retries") | |
| 365 | 365 | } | |
| 366 | + | //nolint:gosec // G107: callers validate or construct trusted GitHub request URLs before passing them here | |
| 366 | 367 | resp, err := client.Do(req) | |
| 367 | 368 | if err != nil { | |
| 368 | 369 | if !isRetryableRequestError(err) || attempt == maxRetryAttempts { | |
| @@ -363,6 +363,7 @@ func doRequestWithRetryHelper( | |||||
| 363 | if attempt > maxRetryAttempts { | 363 | if attempt > maxRetryAttempts { | ||
| 364 | return nil, fmt.Errorf("request failed after retries") | 364 | return nil, fmt.Errorf("request failed after retries") | ||
| 365 | } | 365 | } | ||
| 366 | + | //nolint:gosec // G107: callers validate or construct trusted GitHub request URLs before passing them here | |||
| 366 | resp, err := client.Do(req) | 367 | resp, err := client.Do(req) | ||
| 367 | if err != nil { | 368 | if err != nil { | ||
| 368 | if !isRetryableRequestError(err) || attempt == maxRetryAttempts { | 369 | if !isRetryableRequestError(err) || attempt == maxRetryAttempts { | ||
M internal/infrastructure/webkit/handlers/register.go +7 -0
| @@ -74,6 +74,13 @@ func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config) | |||
| 74 | 74 | // RegisterAccentHandlers registers accent key press/release handlers with the router. | |
| 75 | 75 | // Must be called after the AccentKeyHandler is initialized (i.e., after initAccentPicker). | |
| 76 | 76 | func RegisterAccentHandlers(ctx context.Context, router *webkit.MessageRouter, handler AccentKeyHandler) error { | |
| 77 | + | if router == nil { | |
| 78 | + | return fmt.Errorf("RegisterAccentHandlers: router must not be nil") | |
| 79 | + | } | |
| 80 | + | if handler == nil { | |
| 81 | + | return fmt.Errorf("RegisterAccentHandlers: handler must not be nil") | |
| 82 | + | } | |
| 83 | + | ||
| 77 | 84 | if err := router.RegisterHandler("accent_key_press", webkit.MessageHandlerFunc( | |
| 78 | 85 | func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 79 | 86 | var p struct { | |
| @@ -74,6 +74,13 @@ func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config) | |||||
| 74 | // RegisterAccentHandlers registers accent key press/release handlers with the router. | 74 | // RegisterAccentHandlers registers accent key press/release handlers with the router. | ||
| 75 | // Must be called after the AccentKeyHandler is initialized (i.e., after initAccentPicker). | 75 | // Must be called after the AccentKeyHandler is initialized (i.e., after initAccentPicker). | ||
| 76 | func RegisterAccentHandlers(ctx context.Context, router *webkit.MessageRouter, handler AccentKeyHandler) error { | 76 | func RegisterAccentHandlers(ctx context.Context, router *webkit.MessageRouter, handler AccentKeyHandler) error { | ||
| 77 | + | if router == nil { | |||
| 78 | + | return fmt.Errorf("RegisterAccentHandlers: router must not be nil") | |||
| 79 | + | } | |||
| 80 | + | if handler == nil { | |||
| 81 | + | return fmt.Errorf("RegisterAccentHandlers: handler must not be nil") | |||
| 82 | + | } | |||
| 83 | + | ||||
| 77 | if err := router.RegisterHandler("accent_key_press", webkit.MessageHandlerFunc( | 84 | if err := router.RegisterHandler("accent_key_press", webkit.MessageHandlerFunc( | ||
| 78 | func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | 85 | func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | ||
| 79 | var p struct { | 86 | var p struct { | ||
A internal/infrastructure/webkit/handlers/register_test.go +32 -0
| @@ -0,0 +1,32 @@ | |||
| 1 | + | package handlers | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestRegisterAccentHandlers_NilRouter(t *testing.T) { | |
| 13 | + | ctx := context.Background() | |
| 14 | + | handler := &stubAccentHandler{} | |
| 15 | + | err := RegisterAccentHandlers(ctx, nil, handler) | |
| 16 | + | require.Error(t, err) | |
| 17 | + | assert.Contains(t, err.Error(), "router") | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func TestRegisterAccentHandlers_NilHandler(t *testing.T) { | |
| 21 | + | ctx := context.Background() | |
| 22 | + | router := webkit.NewMessageRouter(ctx) | |
| 23 | + | err := RegisterAccentHandlers(ctx, router, nil) | |
| 24 | + | require.Error(t, err) | |
| 25 | + | assert.Contains(t, err.Error(), "handler") | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // stubAccentHandler satisfies AccentKeyHandler for tests. | |
| 29 | + | type stubAccentHandler struct{} | |
| 30 | + | ||
| 31 | + | func (*stubAccentHandler) OnKeyPressed(_ context.Context, _ rune, _ bool) bool { return false } | |
| 32 | + | func (*stubAccentHandler) OnKeyReleased(_ context.Context, _ rune) {} | |
| @@ -0,0 +1,32 @@ | |||||
| 1 | + | package handlers | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/infrastructure/webkit" | |||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestRegisterAccentHandlers_NilRouter(t *testing.T) { | |||
| 13 | + | ctx := context.Background() | |||
| 14 | + | handler := &stubAccentHandler{} | |||
| 15 | + | err := RegisterAccentHandlers(ctx, nil, handler) | |||
| 16 | + | require.Error(t, err) | |||
| 17 | + | assert.Contains(t, err.Error(), "router") | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func TestRegisterAccentHandlers_NilHandler(t *testing.T) { | |||
| 21 | + | ctx := context.Background() | |||
| 22 | + | router := webkit.NewMessageRouter(ctx) | |||
| 23 | + | err := RegisterAccentHandlers(ctx, router, nil) | |||
| 24 | + | require.Error(t, err) | |||
| 25 | + | assert.Contains(t, err.Error(), "handler") | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // stubAccentHandler satisfies AccentKeyHandler for tests. | |||
| 29 | + | type stubAccentHandler struct{} | |||
| 30 | + | ||||
| 31 | + | func (*stubAccentHandler) OnKeyPressed(_ context.Context, _ rune, _ bool) bool { return false } | |||
| 32 | + | func (*stubAccentHandler) OnKeyReleased(_ context.Context, _ rune) {} | |||
M internal/infrastructure/webkit/injector.go +6 -2
| @@ -195,6 +195,10 @@ const accentDetectionScript = `(function() { | |||
| 195 | 195 | }, true); | |
| 196 | 196 | })();` | |
| 197 | 197 | ||
| 198 | + | // accentDetectionInjectionMode controls which frames receive the accent detection script. | |
| 199 | + | // Set to AllFrames so the script runs in iframes as well as the top-level document. | |
| 200 | + | const accentDetectionInjectionMode = webkit.UserContentInjectAllFramesValue | |
| 201 | + | ||
| 198 | 202 | // webRTCCompatScript maps legacy Safari-prefixed WebRTC globals to standard names. | |
| 199 | 203 | // Some pages gate support on window.RTCPeerConnection and report false negatives | |
| 200 | 204 | // when only webkit-prefixed constructors are present. | |
| @@ -387,12 +391,12 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 387 | 391 | log.Debug().Msg("auto-copy selection script injected") | |
| 388 | 392 | } | |
| 389 | 393 | ||
| 390 | - | // 7. Inject accent key detection for all pages (unconditional) | |
| 394 | + | // 7. Inject accent key detection for all pages and all frames (unconditional). | |
| 391 | 395 | // JS only reports keydown/keyup events; Go handles timing and picker display. | |
| 392 | 396 | addScript( | |
| 393 | 397 | webkit.NewUserScript( | |
| 394 | 398 | accentDetectionScript, | |
| 395 | - | webkit.UserContentInjectTopFrameValue, | |
| 399 | + | accentDetectionInjectionMode, | |
| 396 | 400 | webkit.UserScriptInjectAtDocumentEndValue, | |
| 397 | 401 | nil, // all pages | |
| 398 | 402 | nil, | |
| @@ -195,6 +195,10 @@ const accentDetectionScript = `(function() { | |||||
| 195 | }, true); | 195 | }, true); | ||
| 196 | })();` | 196 | })();` | ||
| 197 | 197 | ||||
| 198 | + | // accentDetectionInjectionMode controls which frames receive the accent detection script. | |||
| 199 | + | // Set to AllFrames so the script runs in iframes as well as the top-level document. | |||
| 200 | + | const accentDetectionInjectionMode = webkit.UserContentInjectAllFramesValue | |||
| 201 | + | ||||
| 198 | // webRTCCompatScript maps legacy Safari-prefixed WebRTC globals to standard names. | 202 | // webRTCCompatScript maps legacy Safari-prefixed WebRTC globals to standard names. | ||
| 199 | // Some pages gate support on window.RTCPeerConnection and report false negatives | 203 | // Some pages gate support on window.RTCPeerConnection and report false negatives | ||
| 200 | // when only webkit-prefixed constructors are present. | 204 | // when only webkit-prefixed constructors are present. | ||
| @@ -387,12 +391,12 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 387 | log.Debug().Msg("auto-copy selection script injected") | 391 | log.Debug().Msg("auto-copy selection script injected") | ||
| 388 | } | 392 | } | ||
| 389 | 393 | ||||
| 390 | - | // 7. Inject accent key detection for all pages (unconditional) | 394 | + | // 7. Inject accent key detection for all pages and all frames (unconditional). |
| 391 | // JS only reports keydown/keyup events; Go handles timing and picker display. | 395 | // JS only reports keydown/keyup events; Go handles timing and picker display. | ||
| 392 | addScript( | 396 | addScript( | ||
| 393 | webkit.NewUserScript( | 397 | webkit.NewUserScript( | ||
| 394 | accentDetectionScript, | 398 | accentDetectionScript, | ||
| 395 | - | webkit.UserContentInjectTopFrameValue, | 399 | + | accentDetectionInjectionMode, |
| 396 | webkit.UserScriptInjectAtDocumentEndValue, | 400 | webkit.UserScriptInjectAtDocumentEndValue, | ||
| 397 | nil, // all pages | 401 | nil, // all pages | ||
| 398 | nil, | 402 | nil, | ||
A internal/infrastructure/webkit/injector_accent_test.go +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/puregotk-webkit/webkit" | |
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // TestAccentDetectionScriptInjectionMode verifies that the accent detection | |
| 11 | + | // script is configured for all-frames injection (not top-frame only). | |
| 12 | + | // This ensures the script runs in iframes as well as the top-level document. | |
| 13 | + | func TestAccentDetectionScriptInjectionMode(t *testing.T) { | |
| 14 | + | assert.Equal(t, | |
| 15 | + | webkit.UserContentInjectAllFramesValue, | |
| 16 | + | accentDetectionInjectionMode, | |
| 17 | + | "accent detection script must inject into all frames, not just the top frame", | |
| 18 | + | ) | |
| 19 | + | } | |
| @@ -0,0 +1,19 @@ | |||||
| 1 | + | package webkit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/puregotk-webkit/webkit" | |||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // TestAccentDetectionScriptInjectionMode verifies that the accent detection | |||
| 11 | + | // script is configured for all-frames injection (not top-frame only). | |||
| 12 | + | // This ensures the script runs in iframes as well as the top-level document. | |||
| 13 | + | func TestAccentDetectionScriptInjectionMode(t *testing.T) { | |||
| 14 | + | assert.Equal(t, | |||
| 15 | + | webkit.UserContentInjectAllFramesValue, | |||
| 16 | + | accentDetectionInjectionMode, | |||
| 17 | + | "accent detection script must inject into all frames, not just the top frame", | |||
| 18 | + | ) | |||
| 19 | + | } | |||
M internal/ui/coordinator/navigation.go +1 -1
| @@ -85,8 +85,8 @@ func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error | |||
| 85 | 85 | // Track original URL for cross-domain redirect favicon caching | |
| 86 | 86 | c.contentCoord.SetNavigationOrigin(activePaneID, url) | |
| 87 | 87 | // Pre-load cached favicon asynchronously (don't block navigation start) | |
| 88 | - | preloadCtx, cancelPreload := context.WithTimeout(ctx, faviconPreloadTimeout) | |
| 89 | 88 | go func() { | |
| 89 | + | preloadCtx, cancelPreload := context.WithTimeout(ctx, faviconPreloadTimeout) | |
| 90 | 90 | defer cancelPreload() | |
| 91 | 91 | c.contentCoord.PreloadCachedFavicon(preloadCtx, activePaneID, url) | |
| 92 | 92 | }() | |
| @@ -85,8 +85,8 @@ func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error | |||||
| 85 | // Track original URL for cross-domain redirect favicon caching | 85 | // Track original URL for cross-domain redirect favicon caching | ||
| 86 | c.contentCoord.SetNavigationOrigin(activePaneID, url) | 86 | c.contentCoord.SetNavigationOrigin(activePaneID, url) | ||
| 87 | // Pre-load cached favicon asynchronously (don't block navigation start) | 87 | // Pre-load cached favicon asynchronously (don't block navigation start) | ||
| 88 | - | preloadCtx, cancelPreload := context.WithTimeout(ctx, faviconPreloadTimeout) | |||
| 89 | go func() { | 88 | go func() { | ||
| 89 | + | preloadCtx, cancelPreload := context.WithTimeout(ctx, faviconPreloadTimeout) | |||
| 90 | defer cancelPreload() | 90 | defer cancelPreload() | ||
| 91 | c.contentCoord.PreloadCachedFavicon(preloadCtx, activePaneID, url) | 91 | c.contentCoord.PreloadCachedFavicon(preloadCtx, activePaneID, url) | ||
| 92 | }() | 92 | }() | ||
M internal/ui/input/keyboard.go +6 -4
| @@ -154,7 +154,7 @@ func (h *KeyboardHandler) Mode() Mode { | |||
| 154 | 154 | // AttachTo attaches the keyboard handler to a GTK window. | |
| 155 | 155 | // The handler runs in the bubble phase so that WebKit's IM pipeline (including | |
| 156 | 156 | // dead-key / compose sequences) processes key events first. App-level shortcuts | |
| 157 | - | // are still intercepted because WebKit only consumes events it recognises as | |
| 157 | + | // are still intercepted because WebKit only consumes events it recognizes as | |
| 158 | 158 | // text input; unhandled events bubble up to this controller. | |
| 159 | 159 | func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) { | |
| 160 | 160 | log := logging.FromContext(h.ctx) | |
| @@ -275,9 +275,11 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||
| 275 | 275 | } | |
| 276 | 276 | ||
| 277 | 277 | // tryAccentDetection starts long-press detection for accent-eligible keys. | |
| 278 | - | // Called for both RoutePassToWidget and RouteAccentDetection routes, so the | |
| 279 | - | // long-press accent picker is available universally (WebView and GTK Entry). | |
| 280 | - | // Returns true if the key should be suppressed. | |
| 278 | + | // It is used only for the RouteAccentDetection route (GTK Entry widgets such as | |
| 279 | + | // the omnibox and find bar). RoutePassToWidget does not call tryAccentDetection; | |
| 280 | + | // WebView long-press accent behavior is handled entirely by the injected JS bridge | |
| 281 | + | // (accentDetectionScript) and the Go-side accent key detection script, not here. | |
| 282 | + | // Returns true if the key should be suppressed (i.e. RouteAccentDetection consumed it). | |
| 281 | 283 | func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval uint, modifiers Modifier) bool { | |
| 282 | 284 | if accentHandler == nil { | |
| 283 | 285 | return false | |
| @@ -154,7 +154,7 @@ func (h *KeyboardHandler) Mode() Mode { | |||||
| 154 | // AttachTo attaches the keyboard handler to a GTK window. | 154 | // AttachTo attaches the keyboard handler to a GTK window. | ||
| 155 | // The handler runs in the bubble phase so that WebKit's IM pipeline (including | 155 | // The handler runs in the bubble phase so that WebKit's IM pipeline (including | ||
| 156 | // dead-key / compose sequences) processes key events first. App-level shortcuts | 156 | // dead-key / compose sequences) processes key events first. App-level shortcuts | ||
| 157 | - | // are still intercepted because WebKit only consumes events it recognises as | 157 | + | // are still intercepted because WebKit only consumes events it recognizes as |
| 158 | // text input; unhandled events bubble up to this controller. | 158 | // text input; unhandled events bubble up to this controller. | ||
| 159 | func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) { | 159 | func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) { | ||
| 160 | log := logging.FromContext(h.ctx) | 160 | log := logging.FromContext(h.ctx) | ||
| @@ -275,9 +275,11 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||||
| 275 | } | 275 | } | ||
| 276 | 276 | ||||
| 277 | // tryAccentDetection starts long-press detection for accent-eligible keys. | 277 | // tryAccentDetection starts long-press detection for accent-eligible keys. | ||
| 278 | - | // Called for both RoutePassToWidget and RouteAccentDetection routes, so the | 278 | + | // It is used only for the RouteAccentDetection route (GTK Entry widgets such as |
| 279 | - | // long-press accent picker is available universally (WebView and GTK Entry). | 279 | + | // the omnibox and find bar). RoutePassToWidget does not call tryAccentDetection; |
| 280 | - | // Returns true if the key should be suppressed. | 280 | + | // WebView long-press accent behavior is handled entirely by the injected JS bridge |
| 281 | + | // (accentDetectionScript) and the Go-side accent key detection script, not here. | |||
| 282 | + | // Returns true if the key should be suppressed (i.e. RouteAccentDetection consumed it). | |||
| 281 | func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval uint, modifiers Modifier) bool { | 283 | func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval uint, modifiers Modifier) bool { | ||
| 282 | if accentHandler == nil { | 284 | if accentHandler == nil { | ||
| 283 | return false | 285 | return false | ||
M internal/ui/input/keyboard_test.go +4 -0
| @@ -81,6 +81,10 @@ func TestIsTextInputKey(t *testing.T) { | |||
| 81 | 81 | {"dead tilde", 0xfe53, true}, | |
| 82 | 82 | {"dead diaeresis", 0xfe57, true}, | |
| 83 | 83 | ||
| 84 | + | // Dead key upper-bound boundary cases | |
| 85 | + | {"dead_greek (0xfe8c) - highest dead key", 0xfe8c, true}, | |
| 86 | + | {"0xfe8d - one past highest dead key", 0xfe8d, false}, | |
| 87 | + | ||
| 84 | 88 | // Non-text keys (should NOT be treated as text) | |
| 85 | 89 | {"escape", 0xff1b, false}, | |
| 86 | 90 | {"return", 0xff0d, false}, | |
| @@ -81,6 +81,10 @@ func TestIsTextInputKey(t *testing.T) { | |||||
| 81 | {"dead tilde", 0xfe53, true}, | 81 | {"dead tilde", 0xfe53, true}, | ||
| 82 | {"dead diaeresis", 0xfe57, true}, | 82 | {"dead diaeresis", 0xfe57, true}, | ||
| 83 | 83 | ||||
| 84 | + | // Dead key upper-bound boundary cases | |||
| 85 | + | {"dead_greek (0xfe8c) - highest dead key", 0xfe8c, true}, | |||
| 86 | + | {"0xfe8d - one past highest dead key", 0xfe8d, false}, | |||
| 87 | + | ||||
| 84 | // Non-text keys (should NOT be treated as text) | 88 | // Non-text keys (should NOT be treated as text) | ||
| 85 | {"escape", 0xff1b, false}, | 89 | {"escape", 0xff1b, false}, | ||
| 86 | {"return", 0xff0d, false}, | 90 | {"return", 0xff0d, false}, | ||
M internal/ui/theme/css.go +2 -2
| @@ -35,8 +35,8 @@ func DefaultFontConfig() FontConfig { | |||
| 35 | 35 | func FontCSSVars(fonts FontConfig) string { | |
| 36 | 36 | var sb strings.Builder | |
| 37 | 37 | // Quote the configured family; include generic fallback. | |
| 38 | - | sb.WriteString(fmt.Sprintf(" --font-sans: %q, sans-serif;\n", fonts.SansFont)) | |
| 39 | - | sb.WriteString(fmt.Sprintf(" --font-mono: %q, monospace;\n", fonts.MonospaceFont)) | |
| 38 | + | fmt.Fprintf(&sb, " --font-sans: %q, sans-serif;\n", fonts.SansFont) | |
| 39 | + | fmt.Fprintf(&sb, " --font-mono: %q, monospace;\n", fonts.MonospaceFont) | |
| 40 | 40 | return sb.String() | |
| 41 | 41 | } | |
| 42 | 42 | ||
| @@ -35,8 +35,8 @@ func DefaultFontConfig() FontConfig { | |||||
| 35 | func FontCSSVars(fonts FontConfig) string { | 35 | func FontCSSVars(fonts FontConfig) string { | ||
| 36 | var sb strings.Builder | 36 | var sb strings.Builder | ||
| 37 | // Quote the configured family; include generic fallback. | 37 | // Quote the configured family; include generic fallback. | ||
| 38 | - | sb.WriteString(fmt.Sprintf(" --font-sans: %q, sans-serif;\n", fonts.SansFont)) | 38 | + | fmt.Fprintf(&sb, " --font-sans: %q, sans-serif;\n", fonts.SansFont) |
| 39 | - | sb.WriteString(fmt.Sprintf(" --font-mono: %q, monospace;\n", fonts.MonospaceFont)) | 39 | + | fmt.Fprintf(&sb, " --font-mono: %q, monospace;\n", fonts.MonospaceFont) |
| 40 | return sb.String() | 40 | return sb.String() | ||
| 41 | } | 41 | } | ||
| 42 | 42 | ||||