detached b574bab

fix(app): restore green gates and harden accent input

commit
b574bab6d1fdae2179b0f02ee90dbd112caa5b29
author
brice · · 2026-03-11 06:44 UTC
parent
0523844
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
21 files · +310 -52
M Makefile +1 -4
@@ -1,6 +1,6 @@
11 # Makefile for dumber (Clean Architecture - puregotk)
22
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
44
55 # Load local overrides from .env.local if present (Makefile syntax)
66 ifneq (,$(wildcard .env.local))
@@ -71,9 +71,6 @@ test: ## Run tests
7171 @echo "Running tests..."
7272 go test -v ./...
7373
74-check-docs: ## Verify README/docs match code
75- @go run ./dev/check-docs.go --fail-on-error
76-
7774 test-race: ## Run tests with race detection
7875 @echo "Running tests with race detection..."
7976 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-purego3+.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 detection74 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() {
7373 }
7474
7575 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)
8477
8578 runtime.LockOSThread()
8679 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 support76+ 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 {
407407 }
408408
409409 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
410411 if err := os.WriteFile(shutdownMarkerPath(lockDir, sessionID), content, markerFilePerm); err != nil {
411412 return err
412413 }
@@ -456,6 +457,7 @@ func markAbruptExits(lockDir string, detectedAt time.Time, logger *zerolog.Logge
456457 payload += "ppid=" + ppid + "\n"
457458 }
458459
460+ //nolint:gosec // G703: path is derived from the app lock dir plus an internally generated session id
459461 if err := os.WriteFile(abruptMarkerPath(lockDir, sessionID), []byte(payload), markerFilePerm); err != nil {
460462 return abruptSessions, err
461463 }
@@ -571,7 +573,8 @@ func markerValue(raw []byte, key string) string {
571573
572574 // runSessionCleanupAsync performs stale session cleanup and old session pruning
573575 // 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.
575578 func runSessionCleanupAsync(
576579 startupCtx context.Context,
577580 sessionUC *usecase.ManageSessionUseCase,
@@ -580,14 +583,10 @@ func runSessionCleanupAsync(
580583 lockDir string,
581584 log *zerolog.Logger,
582585 ) {
583- // Silence unused parameter warning - startupCtx is intentionally unused.
584- _ = startupCtx
585-
586586 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)
591590
592591 // End stale active sessions (orphaned from crashed processes)
593592 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 err412 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, err462 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 pruning574 // 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 the588+ // 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 {
602602 tabName = "Tab"
603603 }
604604
605- b.WriteString(fmt.Sprintf(" %s%s %s\n",
605+ fmt.Fprintf(&b, " %s%s %s\n",
606606 treeStyle.Render(branch),
607607 leafStyle.Render(tabIcon),
608608 t.Normal.Render(tabName),
609- ))
609+ )
610610
611611 // Render panes under this tab
612612 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 tab611 // 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 {
5454 valueStyle := lipgloss.NewStyle().Foreground(r.theme.Text)
5555
5656 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))
5858
5959 for _, key := range keys {
60- sb.WriteString(fmt.Sprintf(
60+ fmt.Fprintf(&sb,
6161 " %s %s\n Type: %s | Default: %s\n",
6262 iconStyle.Render(IconCursor),
6363 keyStyle.Render(key.Key),
6464 typeStyle.Render(key.Type),
6565 valueStyle.Render(key.DefaultValue),
66- ))
66+ )
6767 }
6868
6969 return sb.String()
@@ -98,42 +98,42 @@ func (r *ConfigRenderer) RenderChanges(changes []port.KeyChange) string {
9898
9999 // Show renamed keys first (most important for user to understand)
100100 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))
102102 for _, c := range renamed {
103- sb.WriteString(fmt.Sprintf(
103+ fmt.Fprintf(&sb,
104104 " %s %s %s %s\n Value: %s\n",
105105 renameStyle.Render("~"),
106106 valueStyle.Render(c.OldKey),
107107 renameStyle.Render("→"),
108108 keyStyle.Render(c.NewKey),
109109 valueStyle.Render(c.OldValue),
110- ))
110+ )
111111 }
112112 }
113113
114114 // Show new settings
115115 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))
117117 for _, c := range added {
118- sb.WriteString(fmt.Sprintf(
118+ fmt.Fprintf(&sb,
119119 " %s %s\n Default: %s\n",
120120 addStyle.Render("+"),
121121 keyStyle.Render(c.NewKey),
122122 valueStyle.Render(c.NewValue),
123- ))
123+ )
124124 }
125125 }
126126
127127 // Show deprecated settings (will be removed)
128128 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))
130130 for _, c := range removed {
131- sb.WriteString(fmt.Sprintf(
131+ fmt.Fprintf(&sb,
132132 " %s %s %s\n",
133133 removeStyle.Render("-"),
134134 valueStyle.Render(c.OldKey),
135135 removeStyle.Render("(will be removed)"),
136- ))
136+ )
137137 }
138138 }
139139
@@ -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.Builder56 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 settings114 // 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 (
77 "github.com/bnema/dumber/internal/application/port"
88 )
99
10+const noChangesMsg = "No changes detected."
11+
1012 // ConfigDiffFormatter implements port.DiffFormatter for formatting config changes.
1113 type ConfigDiffFormatter struct{}
1214
@@ -18,7 +20,7 @@ func NewDiffFormatter() *ConfigDiffFormatter {
1820 // FormatChangesAsDiff returns changes formatted as a diff for display.
1921 func (*ConfigDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string {
2022 if len(changes) == 0 {
21- return "No changes detected."
23+ return noChangesMsg
2224 }
2325
2426 var sb strings.Builder
@@ -27,12 +29,12 @@ func (*ConfigDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string
2729 for _, change := range changes {
2830 switch change.Type {
2931 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)
3133 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)
3335 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)
3638 }
3739 }
3840
@@ -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.Builder26 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 {
189189 }
190190
191191 for _, dir := range directories {
192+ //nolint:gosec // G703: XDG directories come from the user environment/config and are created intentionally
192193 if err := os.MkdirAll(dir, dirPerm); err != nil {
193194 return err
194195 }
@@ -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 err194 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 {
9595 Str("text", text).
9696 Msg("inserted text into WebView via clipboard paste")
9797
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)
101103 go func() {
102104 time.Sleep(clipboardRestoreDelay)
105+ restoreCtx, cancelRestore := context.WithTimeout(detachedCtx, clipboardRestoreDelay)
106+ defer cancelRestore()
103107 if originalClipboard != "" {
104- if err := t.clipboard.WriteText(context.Background(), originalClipboard); err != nil {
108+ if err := t.clipboard.WriteText(restoreCtx, originalClipboard); err != nil {
105109 log.Debug().Err(err).Msg("failed to restore clipboard")
106110 }
107111 }
@@ -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 delay98+ // Restore original clipboard after a short delay.
99- // This allows the paste operation to complete first99+ // This allows the paste operation to complete first.
100- // Use context.Background() since this cleanup should complete regardless of ctx cancellation100+ // 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 {
162162
163163 // Write to staging location.
164164 stagedPath := a.stagedBinaryPath()
165+ //nolint:gosec // G703: staged path is the app-managed pending-update path under the configured state dir
165166 if err := os.WriteFile(stagedPath, newBinary, applierExecPerm); err != nil {
166167 return fmt.Errorf("failed to write staged binary: %w", err)
167168 }
@@ -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(
363363 if attempt > maxRetryAttempts {
364364 return nil, fmt.Errorf("request failed after retries")
365365 }
366+ //nolint:gosec // G107: callers validate or construct trusted GitHub request URLs before passing them here
366367 resp, err := client.Do(req)
367368 if err != nil {
368369 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)
7474 // RegisterAccentHandlers registers accent key press/release handlers with the router.
7575 // Must be called after the AccentKeyHandler is initialized (i.e., after initAccentPicker).
7676 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+
7784 if err := router.RegisterHandler("accent_key_press", webkit.MessageHandlerFunc(
7885 func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
7986 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() {
195195 }, true);
196196 })();`
197197
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+
198202 // webRTCCompatScript maps legacy Safari-prefixed WebRTC globals to standard names.
199203 // Some pages gate support on window.RTCPeerConnection and report false negatives
200204 // when only webkit-prefixed constructors are present.
@@ -387,12 +391,12 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
387391 log.Debug().Msg("auto-copy selection script injected")
388392 }
389393
390- // 7. Inject accent key detection for all pages (unconditional)
394+ // 7. Inject accent key detection for all pages and all frames (unconditional).
391395 // JS only reports keydown/keyup events; Go handles timing and picker display.
392396 addScript(
393397 webkit.NewUserScript(
394398 accentDetectionScript,
395- webkit.UserContentInjectTopFrameValue,
399+ accentDetectionInjectionMode,
396400 webkit.UserScriptInjectAtDocumentEndValue,
397401 nil, // all pages
398402 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 negatives203 // 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 pages401 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
8585 // Track original URL for cross-domain redirect favicon caching
8686 c.contentCoord.SetNavigationOrigin(activePaneID, url)
8787 // Pre-load cached favicon asynchronously (don't block navigation start)
88- preloadCtx, cancelPreload := context.WithTimeout(ctx, faviconPreloadTimeout)
8988 go func() {
89+ preloadCtx, cancelPreload := context.WithTimeout(ctx, faviconPreloadTimeout)
9090 defer cancelPreload()
9191 c.contentCoord.PreloadCachedFavicon(preloadCtx, activePaneID, url)
9292 }()
@@ -85,8 +85,8 @@ func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error
85 // Track original URL for cross-domain redirect favicon caching85 // 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 {
154154 // AttachTo attaches the keyboard handler to a GTK window.
155155 // The handler runs in the bubble phase so that WebKit's IM pipeline (including
156156 // 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
158158 // text input; unhandled events bubble up to this controller.
159159 func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
160160 log := logging.FromContext(h.ctx)
@@ -275,9 +275,11 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
275275 }
276276
277277 // 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).
281283 func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval uint, modifiers Modifier) bool {
282284 if accentHandler == nil {
283285 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 (including155 // 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 shortcuts156 // dead-key / compose sequences) processes key events first. App-level shortcuts
157-// are still intercepted because WebKit only consumes events it recognises as157+// 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 the278+// 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 false285 return false
M internal/ui/input/keyboard_test.go +4 -0
@@ -81,6 +81,10 @@ func TestIsTextInputKey(t *testing.T) {
8181 {"dead tilde", 0xfe53, true},
8282 {"dead diaeresis", 0xfe57, true},
8383
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+
8488 // Non-text keys (should NOT be treated as text)
8589 {"escape", 0xff1b, false},
8690 {"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 {
3535 func FontCSSVars(fonts FontConfig) string {
3636 var sb strings.Builder
3737 // 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)
4040 return sb.String()
4141 }
4242
@@ -35,8 +35,8 @@ func DefaultFontConfig() FontConfig {
35 func FontCSSVars(fonts FontConfig) string {35 func FontCSSVars(fonts FontConfig) string {
36 var sb strings.Builder36 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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close