detached a2309fb

fix(keyboard): write X11 keyboard conf so GUI sessions match console keymap

commit
a2309fbfe969dc69fdf1f132a38b53fca8ac78a6
author
brice · · 2026-02-27 19:38 UTC
parent
05d1c18
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +134 -0
M internal/application/handlers/configure_system_handler.go +49 -0
@@ -133,6 +133,12 @@ func (h *ConfigureSystemHandler) Handle(ctx context.Context, cmd commands.Config
133133 return result, err
134134 }
135135
136+ // Write X11/Wayland keyboard layout so GUI sessions use the same keymap as the console.
137+ if err := h.writeX11KeyboardConf(cmd.MountPoint, sysConfig.Keymap()); err != nil {
138+ // Non-fatal: log and continue. Console keymap is already set via vconsole.conf.
139+ h.logger.Warn("Failed to write X11 keyboard config", "error", err)
140+ }
141+
136142 groups := strings.Join(usr.Groups(), ",")
137143 if _, err := h.chrExec.ExecuteInChroot(ctx, cmd.MountPoint, "useradd", "-m", "-G", groups, "-s", usr.Shell(), usr.Username()); err != nil {
138144 h.logger.Error("Failed to create user", "error", err)
@@ -200,6 +206,49 @@ func (h *ConfigureSystemHandler) Handle(ctx context.Context, cmd commands.Config
200206 return result, nil
201207 }
202208
209+// writeX11KeyboardConf writes /etc/X11/xorg.conf.d/00-keyboard.conf so that
210+// X11 and Wayland sessions use the same keyboard layout as the console.
211+// The XKB layout is derived from the keymap by stripping any variant suffix
212+// (e.g. "de-nodeadkeys" → "de", "fr" → "fr").
213+func (h *ConfigureSystemHandler) writeX11KeyboardConf(mountPoint, keymap string) error {
214+ xkbLayout := xkbLayoutFromKeymap(keymap)
215+ if xkbLayout == "" {
216+ return nil
217+ }
218+
219+ xorgDir := filepath.Join(mountPoint, "etc", "X11", "xorg.conf.d")
220+ if err := h.fs.MkdirAll(xorgDir, 0755); err != nil {
221+ return fmt.Errorf("failed to create xorg.conf.d directory: %w", err)
222+ }
223+
224+ content := fmt.Sprintf(`Section "InputClass"
225+ Identifier "system-keyboard"
226+ MatchIsKeyboard "on"
227+ Option "XkbLayout" "%s"
228+EndSection
229+`, xkbLayout)
230+
231+ confPath := filepath.Join(xorgDir, "00-keyboard.conf")
232+ if err := h.fs.WriteFile(confPath, []byte(content), 0644); err != nil {
233+ return fmt.Errorf("failed to write 00-keyboard.conf: %w", err)
234+ }
235+
236+ return nil
237+}
238+
239+// xkbLayoutFromKeymap extracts the XKB layout from a vconsole keymap name.
240+// Keymap variants use hyphens (e.g. "de-nodeadkeys"), XKB uses the base part.
241+func xkbLayoutFromKeymap(keymap string) string {
242+ if keymap == "" {
243+ return ""
244+ }
245+ // Strip variant suffix: "de-nodeadkeys" → "de", "fr" → "fr"
246+ if idx := strings.Index(keymap, "-"); idx != -1 {
247+ return keymap[:idx]
248+ }
249+ return keymap
250+}
251+
203252 // migrateIWDProfiles reads *.psk files from the live ISO's /var/lib/iwd and
204253 // writes equivalent NetworkManager keyfile profiles into the installed system.
205254 // This ensures the system has WiFi connectivity on first boot without manual setup.
@@ -133,6 +133,12 @@ func (h *ConfigureSystemHandler) Handle(ctx context.Context, cmd commands.Config
133 return result, err133 return result, err
134 }134 }
135 135
136+ // Write X11/Wayland keyboard layout so GUI sessions use the same keymap as the console.
137+ if err := h.writeX11KeyboardConf(cmd.MountPoint, sysConfig.Keymap()); err != nil {
138+ // Non-fatal: log and continue. Console keymap is already set via vconsole.conf.
139+ h.logger.Warn("Failed to write X11 keyboard config", "error", err)
140+ }
141+
136 groups := strings.Join(usr.Groups(), ",")142 groups := strings.Join(usr.Groups(), ",")
137 if _, err := h.chrExec.ExecuteInChroot(ctx, cmd.MountPoint, "useradd", "-m", "-G", groups, "-s", usr.Shell(), usr.Username()); err != nil {143 if _, err := h.chrExec.ExecuteInChroot(ctx, cmd.MountPoint, "useradd", "-m", "-G", groups, "-s", usr.Shell(), usr.Username()); err != nil {
138 h.logger.Error("Failed to create user", "error", err)144 h.logger.Error("Failed to create user", "error", err)
@@ -200,6 +206,49 @@ func (h *ConfigureSystemHandler) Handle(ctx context.Context, cmd commands.Config
200 return result, nil206 return result, nil
201 }207 }
202 208
209+// writeX11KeyboardConf writes /etc/X11/xorg.conf.d/00-keyboard.conf so that
210+// X11 and Wayland sessions use the same keyboard layout as the console.
211+// The XKB layout is derived from the keymap by stripping any variant suffix
212+// (e.g. "de-nodeadkeys" → "de", "fr" → "fr").
213+func (h *ConfigureSystemHandler) writeX11KeyboardConf(mountPoint, keymap string) error {
214+ xkbLayout := xkbLayoutFromKeymap(keymap)
215+ if xkbLayout == "" {
216+ return nil
217+ }
218+
219+ xorgDir := filepath.Join(mountPoint, "etc", "X11", "xorg.conf.d")
220+ if err := h.fs.MkdirAll(xorgDir, 0755); err != nil {
221+ return fmt.Errorf("failed to create xorg.conf.d directory: %w", err)
222+ }
223+
224+ content := fmt.Sprintf(`Section "InputClass"
225+ Identifier "system-keyboard"
226+ MatchIsKeyboard "on"
227+ Option "XkbLayout" "%s"
228+EndSection
229+`, xkbLayout)
230+
231+ confPath := filepath.Join(xorgDir, "00-keyboard.conf")
232+ if err := h.fs.WriteFile(confPath, []byte(content), 0644); err != nil {
233+ return fmt.Errorf("failed to write 00-keyboard.conf: %w", err)
234+ }
235+
236+ return nil
237+}
238+
239+// xkbLayoutFromKeymap extracts the XKB layout from a vconsole keymap name.
240+// Keymap variants use hyphens (e.g. "de-nodeadkeys"), XKB uses the base part.
241+func xkbLayoutFromKeymap(keymap string) string {
242+ if keymap == "" {
243+ return ""
244+ }
245+ // Strip variant suffix: "de-nodeadkeys" → "de", "fr" → "fr"
246+ if idx := strings.Index(keymap, "-"); idx != -1 {
247+ return keymap[:idx]
248+ }
249+ return keymap
250+}
251+
203 // migrateIWDProfiles reads *.psk files from the live ISO's /var/lib/iwd and252 // migrateIWDProfiles reads *.psk files from the live ISO's /var/lib/iwd and
204 // writes equivalent NetworkManager keyfile profiles into the installed system.253 // writes equivalent NetworkManager keyfile profiles into the installed system.
205 // This ensures the system has WiFi connectivity on first boot without manual setup.254 // This ensures the system has WiFi connectivity on first boot without manual setup.
M internal/application/handlers/configure_system_handler_test.go +85 -0
@@ -9,6 +9,91 @@ import (
99 "go.uber.org/mock/gomock"
1010 )
1111
12+func TestXkbLayoutFromKeymap(t *testing.T) {
13+ tests := []struct {
14+ keymap string
15+ expected string
16+ }{
17+ {"us", "us"},
18+ {"fr", "fr"},
19+ {"de", "de"},
20+ {"de-nodeadkeys", "de"},
21+ {"fr-latin9", "fr"},
22+ {"", ""},
23+ }
24+ for _, tt := range tests {
25+ got := xkbLayoutFromKeymap(tt.keymap)
26+ if got != tt.expected {
27+ t.Errorf("xkbLayoutFromKeymap(%q) = %q, want %q", tt.keymap, got, tt.expected)
28+ }
29+ }
30+}
31+
32+func TestConfigureSystemHandler_Handle_FrenchKeymap(t *testing.T) {
33+ ctrl := gomock.NewController(t)
34+ defer ctrl.Finish()
35+
36+ mockFS := mocks.NewMockFileSystem(ctrl)
37+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
38+ mockLogger := mocks.NewMockLogger(ctrl)
39+
40+ mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
41+ mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
42+ mockLogger.EXPECT().LogPath().Return("/var/log/archup-install.log").AnyTimes()
43+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
44+ mockChrExec.EXPECT().ExecuteInChrootWithStdin(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
45+ mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
46+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
47+
48+ // Expect 00-keyboard.conf to be written with "fr" layout
49+ x11ConfPath := "/mnt/etc/X11/xorg.conf.d/00-keyboard.conf"
50+ mockFS.EXPECT().WriteFile(gomock.Eq(x11ConfPath), gomock.Any(), gomock.Any()).DoAndReturn(
51+ func(path string, content []byte, perm interface{}) error {
52+ if !containsString(string(content), `Option "XkbLayout" "fr"`) {
53+ t.Errorf("expected XkbLayout fr in 00-keyboard.conf, got: %s", string(content))
54+ }
55+ return nil
56+ },
57+ )
58+ // All other WriteFile calls
59+ mockFS.EXPECT().WriteFile(gomock.Not(gomock.Eq(x11ConfPath)), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
60+
61+ handler := NewConfigureSystemHandler(mockFS, mockChrExec, mockLogger)
62+
63+ cmd := commands.ConfigureSystemCommand{
64+ MountPoint: "/mnt",
65+ Hostname: "myarch",
66+ Timezone: "Europe/Paris",
67+ Locale: "fr_FR.UTF-8",
68+ Keymap: "fr",
69+ Username: "testuser",
70+ UserShell: "/bin/bash",
71+ UserPassword: "testpass123",
72+ RootPassword: "rootpass456",
73+ }
74+
75+ result, err := handler.Handle(context.Background(), cmd)
76+ if err != nil {
77+ t.Fatalf("expected no error, got %v", err)
78+ }
79+ if !result.Success {
80+ t.Error("expected success")
81+ }
82+}
83+
84+func containsString(s, substr string) bool {
85+ return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstring(s, substr))
86+}
87+
88+func containsSubstring(s, substr string) bool {
89+ for i := 0; i <= len(s)-len(substr); i++ {
90+ if s[i:i+len(substr)] == substr {
91+ return true
92+ }
93+ }
94+ return false
95+}
96+
1297 func TestConfigureSystemHandler_Handle_Success(t *testing.T) {
1398 ctrl := gomock.NewController(t)
1499 defer ctrl.Finish()
@@ -9,6 +9,91 @@ import (
9 "go.uber.org/mock/gomock"9 "go.uber.org/mock/gomock"
10 )10 )
11 11
12+func TestXkbLayoutFromKeymap(t *testing.T) {
13+ tests := []struct {
14+ keymap string
15+ expected string
16+ }{
17+ {"us", "us"},
18+ {"fr", "fr"},
19+ {"de", "de"},
20+ {"de-nodeadkeys", "de"},
21+ {"fr-latin9", "fr"},
22+ {"", ""},
23+ }
24+ for _, tt := range tests {
25+ got := xkbLayoutFromKeymap(tt.keymap)
26+ if got != tt.expected {
27+ t.Errorf("xkbLayoutFromKeymap(%q) = %q, want %q", tt.keymap, got, tt.expected)
28+ }
29+ }
30+}
31+
32+func TestConfigureSystemHandler_Handle_FrenchKeymap(t *testing.T) {
33+ ctrl := gomock.NewController(t)
34+ defer ctrl.Finish()
35+
36+ mockFS := mocks.NewMockFileSystem(ctrl)
37+ mockChrExec := mocks.NewMockChrootExecutor(ctrl)
38+ mockLogger := mocks.NewMockLogger(ctrl)
39+
40+ mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
41+ mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any()).AnyTimes()
42+ mockLogger.EXPECT().LogPath().Return("/var/log/archup-install.log").AnyTimes()
43+ mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
44+ mockChrExec.EXPECT().ExecuteInChrootWithStdin(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
45+ mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
46+ mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
47+
48+ // Expect 00-keyboard.conf to be written with "fr" layout
49+ x11ConfPath := "/mnt/etc/X11/xorg.conf.d/00-keyboard.conf"
50+ mockFS.EXPECT().WriteFile(gomock.Eq(x11ConfPath), gomock.Any(), gomock.Any()).DoAndReturn(
51+ func(path string, content []byte, perm interface{}) error {
52+ if !containsString(string(content), `Option "XkbLayout" "fr"`) {
53+ t.Errorf("expected XkbLayout fr in 00-keyboard.conf, got: %s", string(content))
54+ }
55+ return nil
56+ },
57+ )
58+ // All other WriteFile calls
59+ mockFS.EXPECT().WriteFile(gomock.Not(gomock.Eq(x11ConfPath)), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
60+
61+ handler := NewConfigureSystemHandler(mockFS, mockChrExec, mockLogger)
62+
63+ cmd := commands.ConfigureSystemCommand{
64+ MountPoint: "/mnt",
65+ Hostname: "myarch",
66+ Timezone: "Europe/Paris",
67+ Locale: "fr_FR.UTF-8",
68+ Keymap: "fr",
69+ Username: "testuser",
70+ UserShell: "/bin/bash",
71+ UserPassword: "testpass123",
72+ RootPassword: "rootpass456",
73+ }
74+
75+ result, err := handler.Handle(context.Background(), cmd)
76+ if err != nil {
77+ t.Fatalf("expected no error, got %v", err)
78+ }
79+ if !result.Success {
80+ t.Error("expected success")
81+ }
82+}
83+
84+func containsString(s, substr string) bool {
85+ return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstring(s, substr))
86+}
87+
88+func containsSubstring(s, substr string) bool {
89+ for i := 0; i <= len(s)-len(substr); i++ {
90+ if s[i:i+len(substr)] == substr {
91+ return true
92+ }
93+ }
94+ return false
95+}
96+
12 func TestConfigureSystemHandler_Handle_Success(t *testing.T) {97 func TestConfigureSystemHandler_Handle_Success(t *testing.T) {
13 ctrl := gomock.NewController(t)98 ctrl := gomock.NewController(t)
14 defer ctrl.Finish()99 defer ctrl.Finish()

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 1

Tags 0

    ↑↓ moveenter openesc close