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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 133 | 133 | 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 | 142 | groups := strings.Join(usr.Groups(), ",") | |
| 137 | 143 | if _, err := h.chrExec.ExecuteInChroot(ctx, cmd.MountPoint, "useradd", "-m", "-G", groups, "-s", usr.Shell(), usr.Username()); err != nil { | |
| 138 | 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 | 206 | 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 | 252 | // migrateIWDProfiles reads *.psk files from the live ISO's /var/lib/iwd and | |
| 204 | 253 | // writes equivalent NetworkManager keyfile profiles into the installed system. | |
| 205 | 254 | // 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, err | 133 | 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, nil | 206 | 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 and | 252 | // 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 ( | |||
| 9 | 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 | 97 | func TestConfigureSystemHandler_Handle_Success(t *testing.T) { | |
| 13 | 98 | ctrl := gomock.NewController(t) | |
| 14 | 99 | 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() | ||