detached cc0df8f

fix(wayland): widen virtual keymap keycode range to avoid an Xwayland heap overflow

commit
cc0df8fc664565f3d90330ec048395ceb6192b64
author
brice · · 2026-09-26 14:52 UTC
parent
3338d18
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +290 -0
A internal/adapters/wayland/keycodes.go +212 -0
@@ -0,0 +1,212 @@
1+package wayland
2+
3+import (
4+ "strconv"
5+ "strings"
6+)
7+
8+// minKeycodeMax is the smallest keycode maximum sent to clients.
9+// Xwayland (xkb/xkbUtils.c _XkbCopyClientMap, still in 24.1) clears
10+// MAP_LENGTH-(max+1) entries starting at that same index of its 256-entry
11+// key maps: with a maximum below 127 it writes past the end of the heap
12+// block and aborts on the next realloc. Dictation tools (wtype-like)
13+// send keymaps holding only the keys they type (e.g. 8..26). Keycodes up
14+// to 255 are always valid, so the range is widened without changing any
15+// key.
16+const minKeycodeMax = 255
17+
18+// widenKeycodes raises the keycode maximum of a keymap's xkb_keycodes
19+// section to minKeycodeMax. An explicit maximum below it is rewritten; a
20+// section without one (its highest keycode is the maximum) gets one,
21+// unless it includes other files or already has a keycode above it. It
22+// returns the keymap and the former maximum (the highest keycode when
23+// none was set), 0 when unchanged. Keywords are case-insensitive;
24+// comments, strings and key names are skipped.
25+func widenKeycodes(text string) (string, int) {
26+ open, end := keycodesSection(text)
27+ if open < 0 {
28+ return text, 0
29+ }
30+ type span struct{ from, to int }
31+ var low []span
32+ maxSet, include := false, false
33+ from, highest := 0, 0
34+ lastName := false // the previous token was a <name>: "= N" is a keycode
35+ for i := open + 1; i < end; {
36+ if j := skipNoise(text, i); j != i {
37+ lastName = text[i] == '<'
38+ i = j
39+ continue
40+ }
41+ c := text[i]
42+ switch {
43+ case isIdentStart(c):
44+ j := i
45+ for j < end && isIdent(text[j]) {
46+ j++
47+ }
48+ word := text[i:j]
49+ lastName = false
50+ i = j
51+ switch {
52+ case strings.EqualFold(word, "include"), strings.EqualFold(word, "augment"), strings.EqualFold(word, "override"), strings.EqualFold(word, "replace"):
53+ include = true
54+ case strings.EqualFold(word, "maximum"):
55+ k := skipBlank(text, j, end)
56+ if k >= end || text[k] != '=' {
57+ continue
58+ }
59+ k = skipBlank(text, k+1, end)
60+ d := k
61+ for d < end && text[d] >= '0' && text[d] <= '9' {
62+ d++
63+ }
64+ n, err := strconv.Atoi(text[k:d])
65+ if err != nil {
66+ continue
67+ }
68+ maxSet = true
69+ if n < minKeycodeMax {
70+ low = append(low, span{k, d})
71+ from = n
72+ }
73+ i = d
74+ }
75+ case c == '=' && lastName:
76+ k := skipBlank(text, i+1, end)
77+ d := k
78+ for d < end && text[d] >= '0' && text[d] <= '9' {
79+ d++
80+ }
81+ if n, err := strconv.Atoi(text[k:d]); err == nil {
82+ highest = max(highest, n)
83+ }
84+ lastName = false
85+ i = max(d, i+1)
86+ default:
87+ if c != ' ' && c != '\t' && c != '\n' && c != '\r' {
88+ lastName = false
89+ }
90+ i++
91+ }
92+ }
93+ if !maxSet {
94+ if include || highest >= minKeycodeMax || highest == 0 {
95+ return text, 0
96+ }
97+ return text[:open+1] + " maximum = " + strconv.Itoa(minKeycodeMax) + ";" + text[open+1:], highest
98+ }
99+ if len(low) == 0 {
100+ return text, 0
101+ }
102+ var b strings.Builder
103+ last := 0
104+ for _, sp := range low {
105+ b.WriteString(text[last:sp.from])
106+ b.WriteString(strconv.Itoa(minKeycodeMax))
107+ last = sp.to
108+ }
109+ b.WriteString(text[last:])
110+ return b.String(), from
111+}
112+
113+// keycodesSection returns the indexes of the braces of the first
114+// xkb_keycodes section, -1 without one.
115+func keycodesSection(text string) (open, end int) {
116+ for i := 0; i < len(text); {
117+ if j := skipNoise(text, i); j != i {
118+ i = j
119+ continue
120+ }
121+ if !isIdentStart(text[i]) {
122+ i++
123+ continue
124+ }
125+ j := i
126+ for j < len(text) && isIdent(text[j]) {
127+ j++
128+ }
129+ if !strings.EqualFold(text[i:j], "xkb_keycodes") {
130+ i = j
131+ continue
132+ }
133+ // The section's name may be a string; its body is the next brace.
134+ for i = j; i < len(text) && text[i] != '{'; {
135+ if k := skipNoise(text, i); k != i {
136+ i = k
137+ continue
138+ }
139+ if text[i] == ';' {
140+ return -1, -1 // a declaration without a body
141+ }
142+ i++
143+ }
144+ if i >= len(text) {
145+ return -1, -1
146+ }
147+ open, depth := i, 0
148+ for i < len(text) {
149+ if k := skipNoise(text, i); k != i {
150+ i = k
151+ continue
152+ }
153+ switch text[i] {
154+ case '{':
155+ depth++
156+ case '}':
157+ if depth--; depth == 0 {
158+ return open, i
159+ }
160+ }
161+ i++
162+ }
163+ return -1, -1
164+ }
165+ return -1, -1
166+}
167+
168+// skipNoise returns the index after a comment (// or # to the end of the
169+// line), a string or a <key name> starting at i, else i.
170+func skipNoise(text string, i int) int {
171+ switch {
172+ case text[i] == '#', strings.HasPrefix(text[i:], "//"):
173+ if n := strings.IndexByte(text[i:], '\n'); n >= 0 {
174+ return i + n + 1
175+ }
176+ return len(text)
177+ case text[i] == '"':
178+ for j := i + 1; j < len(text); j++ {
179+ switch text[j] {
180+ case '\\':
181+ j++
182+ case '"':
183+ return j + 1
184+ }
185+ }
186+ return len(text)
187+ case text[i] == '<':
188+ if n := strings.IndexByte(text[i:], '>'); n >= 0 {
189+ return i + n + 1
190+ }
191+ return len(text)
192+ }
193+ return i
194+}
195+
196+// skipBlank skips spaces and comments from i, up to end.
197+func skipBlank(text string, i, end int) int {
198+ for i < end {
199+ switch {
200+ case text[i] == ' ' || text[i] == '\t' || text[i] == '\n' || text[i] == '\r':
201+ i++
202+ case text[i] == '#' || strings.HasPrefix(text[i:], "//"):
203+ i = skipNoise(text, i)
204+ default:
205+ return i
206+ }
207+ }
208+ return i
209+}
210+
211+func isIdentStart(c byte) bool { return c == '_' || c|0x20 >= 'a' && c|0x20 <= 'z' }
212+func isIdent(c byte) bool { return isIdentStart(c) || c >= '0' && c <= '9' }
@@ -0,0 +1,212 @@
1+package wayland
2+
3+import (
4+ "strconv"
5+ "strings"
6+)
7+
8+// minKeycodeMax is the smallest keycode maximum sent to clients.
9+// Xwayland (xkb/xkbUtils.c _XkbCopyClientMap, still in 24.1) clears
10+// MAP_LENGTH-(max+1) entries starting at that same index of its 256-entry
11+// key maps: with a maximum below 127 it writes past the end of the heap
12+// block and aborts on the next realloc. Dictation tools (wtype-like)
13+// send keymaps holding only the keys they type (e.g. 8..26). Keycodes up
14+// to 255 are always valid, so the range is widened without changing any
15+// key.
16+const minKeycodeMax = 255
17+
18+// widenKeycodes raises the keycode maximum of a keymap's xkb_keycodes
19+// section to minKeycodeMax. An explicit maximum below it is rewritten; a
20+// section without one (its highest keycode is the maximum) gets one,
21+// unless it includes other files or already has a keycode above it. It
22+// returns the keymap and the former maximum (the highest keycode when
23+// none was set), 0 when unchanged. Keywords are case-insensitive;
24+// comments, strings and key names are skipped.
25+func widenKeycodes(text string) (string, int) {
26+ open, end := keycodesSection(text)
27+ if open < 0 {
28+ return text, 0
29+ }
30+ type span struct{ from, to int }
31+ var low []span
32+ maxSet, include := false, false
33+ from, highest := 0, 0
34+ lastName := false // the previous token was a <name>: "= N" is a keycode
35+ for i := open + 1; i < end; {
36+ if j := skipNoise(text, i); j != i {
37+ lastName = text[i] == '<'
38+ i = j
39+ continue
40+ }
41+ c := text[i]
42+ switch {
43+ case isIdentStart(c):
44+ j := i
45+ for j < end && isIdent(text[j]) {
46+ j++
47+ }
48+ word := text[i:j]
49+ lastName = false
50+ i = j
51+ switch {
52+ case strings.EqualFold(word, "include"), strings.EqualFold(word, "augment"), strings.EqualFold(word, "override"), strings.EqualFold(word, "replace"):
53+ include = true
54+ case strings.EqualFold(word, "maximum"):
55+ k := skipBlank(text, j, end)
56+ if k >= end || text[k] != '=' {
57+ continue
58+ }
59+ k = skipBlank(text, k+1, end)
60+ d := k
61+ for d < end && text[d] >= '0' && text[d] <= '9' {
62+ d++
63+ }
64+ n, err := strconv.Atoi(text[k:d])
65+ if err != nil {
66+ continue
67+ }
68+ maxSet = true
69+ if n < minKeycodeMax {
70+ low = append(low, span{k, d})
71+ from = n
72+ }
73+ i = d
74+ }
75+ case c == '=' && lastName:
76+ k := skipBlank(text, i+1, end)
77+ d := k
78+ for d < end && text[d] >= '0' && text[d] <= '9' {
79+ d++
80+ }
81+ if n, err := strconv.Atoi(text[k:d]); err == nil {
82+ highest = max(highest, n)
83+ }
84+ lastName = false
85+ i = max(d, i+1)
86+ default:
87+ if c != ' ' && c != '\t' && c != '\n' && c != '\r' {
88+ lastName = false
89+ }
90+ i++
91+ }
92+ }
93+ if !maxSet {
94+ if include || highest >= minKeycodeMax || highest == 0 {
95+ return text, 0
96+ }
97+ return text[:open+1] + " maximum = " + strconv.Itoa(minKeycodeMax) + ";" + text[open+1:], highest
98+ }
99+ if len(low) == 0 {
100+ return text, 0
101+ }
102+ var b strings.Builder
103+ last := 0
104+ for _, sp := range low {
105+ b.WriteString(text[last:sp.from])
106+ b.WriteString(strconv.Itoa(minKeycodeMax))
107+ last = sp.to
108+ }
109+ b.WriteString(text[last:])
110+ return b.String(), from
111+}
112+
113+// keycodesSection returns the indexes of the braces of the first
114+// xkb_keycodes section, -1 without one.
115+func keycodesSection(text string) (open, end int) {
116+ for i := 0; i < len(text); {
117+ if j := skipNoise(text, i); j != i {
118+ i = j
119+ continue
120+ }
121+ if !isIdentStart(text[i]) {
122+ i++
123+ continue
124+ }
125+ j := i
126+ for j < len(text) && isIdent(text[j]) {
127+ j++
128+ }
129+ if !strings.EqualFold(text[i:j], "xkb_keycodes") {
130+ i = j
131+ continue
132+ }
133+ // The section's name may be a string; its body is the next brace.
134+ for i = j; i < len(text) && text[i] != '{'; {
135+ if k := skipNoise(text, i); k != i {
136+ i = k
137+ continue
138+ }
139+ if text[i] == ';' {
140+ return -1, -1 // a declaration without a body
141+ }
142+ i++
143+ }
144+ if i >= len(text) {
145+ return -1, -1
146+ }
147+ open, depth := i, 0
148+ for i < len(text) {
149+ if k := skipNoise(text, i); k != i {
150+ i = k
151+ continue
152+ }
153+ switch text[i] {
154+ case '{':
155+ depth++
156+ case '}':
157+ if depth--; depth == 0 {
158+ return open, i
159+ }
160+ }
161+ i++
162+ }
163+ return -1, -1
164+ }
165+ return -1, -1
166+}
167+
168+// skipNoise returns the index after a comment (// or # to the end of the
169+// line), a string or a <key name> starting at i, else i.
170+func skipNoise(text string, i int) int {
171+ switch {
172+ case text[i] == '#', strings.HasPrefix(text[i:], "//"):
173+ if n := strings.IndexByte(text[i:], '\n'); n >= 0 {
174+ return i + n + 1
175+ }
176+ return len(text)
177+ case text[i] == '"':
178+ for j := i + 1; j < len(text); j++ {
179+ switch text[j] {
180+ case '\\':
181+ j++
182+ case '"':
183+ return j + 1
184+ }
185+ }
186+ return len(text)
187+ case text[i] == '<':
188+ if n := strings.IndexByte(text[i:], '>'); n >= 0 {
189+ return i + n + 1
190+ }
191+ return len(text)
192+ }
193+ return i
194+}
195+
196+// skipBlank skips spaces and comments from i, up to end.
197+func skipBlank(text string, i, end int) int {
198+ for i < end {
199+ switch {
200+ case text[i] == ' ' || text[i] == '\t' || text[i] == '\n' || text[i] == '\r':
201+ i++
202+ case text[i] == '#' || strings.HasPrefix(text[i:], "//"):
203+ i = skipNoise(text, i)
204+ default:
205+ return i
206+ }
207+ }
208+ return i
209+}
210+
211+func isIdentStart(c byte) bool { return c == '_' || c|0x20 >= 'a' && c|0x20 <= 'z' }
212+func isIdent(c byte) bool { return isIdentStart(c) || c >= '0' && c <= '9' }
M internal/adapters/wayland/virtualkeyboard.go +4 -0
@@ -58,6 +58,10 @@ func (k *virtualKeyboard) Keymap(_ *virtualkeyboard.ZwpVirtualKeyboardV1, format
5858 k.server.log.Debug().Msg("virtual keymap rejected")
5959 return
6060 }
61+ if wide, from := widenKeycodes(text); from != 0 {
62+ k.server.log.Debug().Int("maximum", from).Int("widened", minKeycodeMax).Msg("virtual keymap keycode range widened")
63+ text = wide
64+ }
6165 copyFD, copySize, err := keymapFile(text)
6266 if err != nil {
6367 k.server.log.Warn().Err(err).Msg("virtual keymap copy failed")
@@ -58,6 +58,10 @@ func (k *virtualKeyboard) Keymap(_ *virtualkeyboard.ZwpVirtualKeyboardV1, format
58 k.server.log.Debug().Msg("virtual keymap rejected")58 k.server.log.Debug().Msg("virtual keymap rejected")
59 return59 return
60 }60 }
61+ if wide, from := widenKeycodes(text); from != 0 {
62+ k.server.log.Debug().Int("maximum", from).Int("widened", minKeycodeMax).Msg("virtual keymap keycode range widened")
63+ text = wide
64+ }
61 copyFD, copySize, err := keymapFile(text)65 copyFD, copySize, err := keymapFile(text)
62 if err != nil {66 if err != nil {
63 k.server.log.Warn().Err(err).Msg("virtual keymap copy failed")67 k.server.log.Warn().Err(err).Msg("virtual keymap copy failed")
M internal/adapters/wayland/virtualkeyboard_test.go +74 -0
@@ -258,3 +258,77 @@ func TestVirtualKeyboardOtherKeymapSent(t *testing.T) {
258258 t.Fatalf("keymaps %d, want virtual, updated virtual, seat", len(proxy.keymaps))
259259 }
260260 }
261+
262+// smallKeymap is a dictation tool keymap: only the keys it types.
263+const smallKeymap = `xkb_keymap {
264+xkb_keycodes "(unnamed)" {
265+minimum = 8;
266+maximum = 26;
267+<K1> = 9;
268+};
269+xkb_types "(unnamed)" { include "complete" };
270+xkb_compatibility "(unnamed)" { include "complete" };
271+xkb_symbols "(unnamed)" {
272+key <K1> {[D]};
273+};
274+};
275+`
276+
277+func TestWidenKeycodes(t *testing.T) {
278+ const tail = "\nxkb_symbols { key <K1> {[D]}; };\n};\n"
279+ cases := []struct {
280+ name, in, want string
281+ from int
282+ }{
283+ {"small maximum", smallKeymap, strings.Replace(smallKeymap, "maximum = 26;", "maximum = 255;", 1), 26},
284+ {"maximum 127..254", "xkb_keymap {\nxkb_keycodes { minimum = 8; maximum = 200; <K1> = 9; };" + tail,
285+ "xkb_keymap {\nxkb_keycodes { minimum = 8; maximum = 255; <K1> = 9; };" + tail, 200},
286+ {"no maximum", "xkb_keymap {\nxkb_keycodes \"x\" { minimum = 8; <K1> = 9; <K2> = 25; };" + tail,
287+ "xkb_keymap {\nxkb_keycodes \"x\" { maximum = 255; minimum = 8; <K1> = 9; <K2> = 25; };" + tail, 25},
288+ {"mixed case", "XKB_Keymap {\nXkb_KeyCodes { MINIMUM = 8; Maximum = 26; <K1> = 9; };" + tail,
289+ "XKB_Keymap {\nXkb_KeyCodes { MINIMUM = 8; Maximum = 255; <K1> = 9; };" + tail, 26},
290+ {"brace in comment", "xkb_keymap {\nxkb_keycodes {\n// ends here }\n# and } here\nminimum = 8;\nmaximum = 26;\n<K1> = 9;\n};" + tail,
291+ "xkb_keymap {\nxkb_keycodes {\n// ends here }\n# and } here\nminimum = 8;\nmaximum = 255;\n<K1> = 9;\n};" + tail, 26},
292+ {"commented maximum", "xkb_keymap {\nxkb_keycodes {\n// maximum = 26;\nminimum = 8;\n<K1> = 9;\n};" + tail,
293+ "xkb_keymap {\nxkb_keycodes { maximum = 255;\n// maximum = 26;\nminimum = 8;\n<K1> = 9;\n};" + tail, 9},
294+ {"maximum after comment", "xkb_keymap {\nxkb_keycodes {\nminimum = 8;\nmaximum = // why\n 26;\n<K1> = 9;\n};" + tail,
295+ "xkb_keymap {\nxkb_keycodes {\nminimum = 8;\nmaximum = // why\n 255;\n<K1> = 9;\n};" + tail, 26},
296+ }
297+ for _, c := range cases {
298+ got, from := widenKeycodes(c.in)
299+ if got != c.want || from != c.from {
300+ t.Errorf("%s: from %d want %d\n%s", c.name, from, c.from, got)
301+ }
302+ }
303+ for name, keep := range map[string]string{
304+ "seat keymap": keymapText(t),
305+ "maximum 255": "xkb_keymap { xkb_keycodes { maximum = 255; <K1> = 9; }; };",
306+ "maximum above 255": "xkb_keymap { xkb_keycodes { minimum = 8; maximum = 708; <K1> = 9; }; };",
307+ "no keycodes": "xkb_keymap { xkb_symbols { }; };",
308+ "includes": "xkb_keymap { xkb_keycodes { include \"evdev+aliases(qwerty)\" }; };",
309+ "maximum in string": "xkb_keymap { xkb_keycodes \"maximum = 26;\" { minimum = 8; <K1> = 300; }; };",
310+ } {
311+ if got, from := widenKeycodes(keep); from != 0 || got != keep {
312+ t.Errorf("%s changed:\n%s", name, got)
313+ }
314+ }
315+}
316+
317+// Xwayland overflows its key maps on a keymap whose maximum keycode is
318+// small: clients get the virtual keymap with the full keycode range.
319+func TestVirtualKeymapWidenedForClients(t *testing.T) {
320+ s, events, commands, dir := keyboardServer(t)
321+ target, proxy := focusedTarget(t, s, events, commands, dir)
322+ typer, kb := virtualTyper(t, s, dir)
323+ sendVirtualKeymap(t, typer, kb, smallKeymap)
324+ requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestKey, uint32(1), uint32(1), uint32(1))
325+ roundtrip(t, typer, target)
326+ deadline := time.Now().Add(2 * time.Second)
327+ for len(proxy.keymaps) < 1 && time.Now().Before(deadline) {
328+ roundtrip(t, target)
329+ time.Sleep(5 * time.Millisecond)
330+ }
331+ if n := len(proxy.keymaps); n == 0 || !strings.Contains(proxy.keymaps[n-1], "maximum = 255;") || strings.Contains(proxy.keymaps[n-1], "maximum = 26;") {
332+ t.Fatalf("keymaps %q", proxy.keymaps)
333+ }
334+}
@@ -258,3 +258,77 @@ func TestVirtualKeyboardOtherKeymapSent(t *testing.T) {
258 t.Fatalf("keymaps %d, want virtual, updated virtual, seat", len(proxy.keymaps))258 t.Fatalf("keymaps %d, want virtual, updated virtual, seat", len(proxy.keymaps))
259 }259 }
260 }260 }
261+
262+// smallKeymap is a dictation tool keymap: only the keys it types.
263+const smallKeymap = `xkb_keymap {
264+xkb_keycodes "(unnamed)" {
265+minimum = 8;
266+maximum = 26;
267+<K1> = 9;
268+};
269+xkb_types "(unnamed)" { include "complete" };
270+xkb_compatibility "(unnamed)" { include "complete" };
271+xkb_symbols "(unnamed)" {
272+key <K1> {[D]};
273+};
274+};
275+`
276+
277+func TestWidenKeycodes(t *testing.T) {
278+ const tail = "\nxkb_symbols { key <K1> {[D]}; };\n};\n"
279+ cases := []struct {
280+ name, in, want string
281+ from int
282+ }{
283+ {"small maximum", smallKeymap, strings.Replace(smallKeymap, "maximum = 26;", "maximum = 255;", 1), 26},
284+ {"maximum 127..254", "xkb_keymap {\nxkb_keycodes { minimum = 8; maximum = 200; <K1> = 9; };" + tail,
285+ "xkb_keymap {\nxkb_keycodes { minimum = 8; maximum = 255; <K1> = 9; };" + tail, 200},
286+ {"no maximum", "xkb_keymap {\nxkb_keycodes \"x\" { minimum = 8; <K1> = 9; <K2> = 25; };" + tail,
287+ "xkb_keymap {\nxkb_keycodes \"x\" { maximum = 255; minimum = 8; <K1> = 9; <K2> = 25; };" + tail, 25},
288+ {"mixed case", "XKB_Keymap {\nXkb_KeyCodes { MINIMUM = 8; Maximum = 26; <K1> = 9; };" + tail,
289+ "XKB_Keymap {\nXkb_KeyCodes { MINIMUM = 8; Maximum = 255; <K1> = 9; };" + tail, 26},
290+ {"brace in comment", "xkb_keymap {\nxkb_keycodes {\n// ends here }\n# and } here\nminimum = 8;\nmaximum = 26;\n<K1> = 9;\n};" + tail,
291+ "xkb_keymap {\nxkb_keycodes {\n// ends here }\n# and } here\nminimum = 8;\nmaximum = 255;\n<K1> = 9;\n};" + tail, 26},
292+ {"commented maximum", "xkb_keymap {\nxkb_keycodes {\n// maximum = 26;\nminimum = 8;\n<K1> = 9;\n};" + tail,
293+ "xkb_keymap {\nxkb_keycodes { maximum = 255;\n// maximum = 26;\nminimum = 8;\n<K1> = 9;\n};" + tail, 9},
294+ {"maximum after comment", "xkb_keymap {\nxkb_keycodes {\nminimum = 8;\nmaximum = // why\n 26;\n<K1> = 9;\n};" + tail,
295+ "xkb_keymap {\nxkb_keycodes {\nminimum = 8;\nmaximum = // why\n 255;\n<K1> = 9;\n};" + tail, 26},
296+ }
297+ for _, c := range cases {
298+ got, from := widenKeycodes(c.in)
299+ if got != c.want || from != c.from {
300+ t.Errorf("%s: from %d want %d\n%s", c.name, from, c.from, got)
301+ }
302+ }
303+ for name, keep := range map[string]string{
304+ "seat keymap": keymapText(t),
305+ "maximum 255": "xkb_keymap { xkb_keycodes { maximum = 255; <K1> = 9; }; };",
306+ "maximum above 255": "xkb_keymap { xkb_keycodes { minimum = 8; maximum = 708; <K1> = 9; }; };",
307+ "no keycodes": "xkb_keymap { xkb_symbols { }; };",
308+ "includes": "xkb_keymap { xkb_keycodes { include \"evdev+aliases(qwerty)\" }; };",
309+ "maximum in string": "xkb_keymap { xkb_keycodes \"maximum = 26;\" { minimum = 8; <K1> = 300; }; };",
310+ } {
311+ if got, from := widenKeycodes(keep); from != 0 || got != keep {
312+ t.Errorf("%s changed:\n%s", name, got)
313+ }
314+ }
315+}
316+
317+// Xwayland overflows its key maps on a keymap whose maximum keycode is
318+// small: clients get the virtual keymap with the full keycode range.
319+func TestVirtualKeymapWidenedForClients(t *testing.T) {
320+ s, events, commands, dir := keyboardServer(t)
321+ target, proxy := focusedTarget(t, s, events, commands, dir)
322+ typer, kb := virtualTyper(t, s, dir)
323+ sendVirtualKeymap(t, typer, kb, smallKeymap)
324+ requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestKey, uint32(1), uint32(1), uint32(1))
325+ roundtrip(t, typer, target)
326+ deadline := time.Now().Add(2 * time.Second)
327+ for len(proxy.keymaps) < 1 && time.Now().Before(deadline) {
328+ roundtrip(t, target)
329+ time.Sleep(5 * time.Millisecond)
330+ }
331+ if n := len(proxy.keymaps); n == 0 || !strings.Contains(proxy.keymaps[n-1], "maximum = 255;") || strings.Contains(proxy.keymaps[n-1], "maximum = 26;") {
332+ t.Fatalf("keymaps %q", proxy.keymaps)
333+ }
334+}

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 25

Tags 10

↑↓ moveenter openesc close