- commit
- 9ed422188d81541280efae4565815703c55bf20a
- parent
- fe400ddb2d4153c31c3aa49df7ed2291602ff81b
- author
- Dami <dami.etoile@gmail.com>
| M | crates/ | 201 | ++++++++++++++++++++++++++++++- |
| M | crates/ | 36 | +++++- |
| M | portal/ | 4 | +- |
| M | portal/ | 15 | ++ |
| M | portal/ | 123 | +++++++++++++++++-- |
| M | portal/ | 121 | +++++++++++++++--- |
| M | portal/ | 30 | ++--- |
@@ -396,15 +396,22 @@ enum MappedKey {
396396 Comma,397397 Dot,398398 Slash,399+ NonUsBackslash,400+ Ro,401+ Yen,399402 Enter,400403 Tab,401404 Space,402405 Backspace,403406 Escape,404407 LeftShift,408+ RightShift,405409 LeftCtrl,410+ RightCtrl,406411 LeftAlt,412+ RightAlt,407413 LeftMeta,414+ RightMeta,408415 Up,409416 Down,410417 Left,@@ -413,10 +420,24 @@ enum MappedKey {
413420 End,414421 PageUp,415422 PageDown,423+ Insert,416424 Delete,425+ PrintScreen,426+ F1,427+ F2,428+ F3,429+ F4,430+ F5,431+ F6,432+ F7,433+ F8,434+ F9,435+ F10,436+ F11,437+ F12,417438 }418439 419-440+420441 const ALL_MAPPED_KEYS: &[MappedKey] = &[421442 MappedKey::A,422443 MappedKey::B,@@ -465,15 +486,22 @@ const ALL_MAPPED_KEYS: &[MappedKey] = &[
465486 MappedKey::Comma,466487 MappedKey::Dot,467488 MappedKey::Slash,489+ MappedKey::NonUsBackslash,490+ MappedKey::Ro,491+ MappedKey::Yen,468492 MappedKey::Enter,469493 MappedKey::Tab,470494 MappedKey::Space,471495 MappedKey::Backspace,472496 MappedKey::Escape,473497 MappedKey::LeftShift,498+ MappedKey::RightShift,474499 MappedKey::LeftCtrl,500+ MappedKey::RightCtrl,475501 MappedKey::LeftAlt,502+ MappedKey::RightAlt,476503 MappedKey::LeftMeta,504+ MappedKey::RightMeta,477505 MappedKey::Up,478506 MappedKey::Down,479507 MappedKey::Left,@@ -482,21 +510,97 @@ const ALL_MAPPED_KEYS: &[MappedKey] = &[
482510 MappedKey::End,483511 MappedKey::PageUp,484512 MappedKey::PageDown,513+ MappedKey::Insert,485514 MappedKey::Delete,515+ MappedKey::PrintScreen,516+ MappedKey::F1,517+ MappedKey::F2,518+ MappedKey::F3,519+ MappedKey::F4,520+ MappedKey::F5,521+ MappedKey::F6,522+ MappedKey::F7,523+ MappedKey::F8,524+ MappedKey::F9,525+ MappedKey::F10,526+ MappedKey::F11,527+ MappedKey::F12,486528 ];487529 488530 489531 fn map_key(value: &str) -> Option<MappedKey> {490532 match value {533+ "KeyA" => Some(MappedKey::A),534+ "KeyB" => Some(MappedKey::B),535+ "KeyC" => Some(MappedKey::C),536+ "KeyD" => Some(MappedKey::D),537+ "KeyE" => Some(MappedKey::E),538+ "KeyF" => Some(MappedKey::F),539+ "KeyG" => Some(MappedKey::G),540+ "KeyH" => Some(MappedKey::H),541+ "KeyI" => Some(MappedKey::I),542+ "KeyJ" => Some(MappedKey::J),543+ "KeyK" => Some(MappedKey::K),544+ "KeyL" => Some(MappedKey::L),545+ "KeyM" => Some(MappedKey::M),546+ "KeyN" => Some(MappedKey::N),547+ "KeyO" => Some(MappedKey::O),548+ "KeyP" => Some(MappedKey::P),549+ "KeyQ" => Some(MappedKey::Q),550+ "KeyR" => Some(MappedKey::R),551+ "KeyS" => Some(MappedKey::S),552+ "KeyT" => Some(MappedKey::T),553+ "KeyU" => Some(MappedKey::U),554+ "KeyV" => Some(MappedKey::V),555+ "KeyW" => Some(MappedKey::W),556+ "KeyX" => Some(MappedKey::X),557+ "KeyY" => Some(MappedKey::Y),558+ "KeyZ" => Some(MappedKey::Z),559+ "Digit0" => Some(MappedKey::Num0),560+ "Digit1" => Some(MappedKey::Num1),561+ "Digit2" => Some(MappedKey::Num2),562+ "Digit3" => Some(MappedKey::Num3),563+ "Digit4" => Some(MappedKey::Num4),564+ "Digit5" => Some(MappedKey::Num5),565+ "Digit6" => Some(MappedKey::Num6),566+ "Digit7" => Some(MappedKey::Num7),567+ "Digit8" => Some(MappedKey::Num8),568+ "Digit9" => Some(MappedKey::Num9),569+ "Minus" => Some(MappedKey::Minus),570+ "Equal" => Some(MappedKey::Equal),571+ "BracketLeft" => Some(MappedKey::LeftBrace),572+ "BracketRight" => Some(MappedKey::RightBrace),573+ "Backslash" => Some(MappedKey::Backslash),574+ "IntlBackslash" => Some(MappedKey::NonUsBackslash),575+ "IntlRo" => Some(MappedKey::Ro),576+ "IntlYen" => Some(MappedKey::Yen),577+ "Semicolon" => Some(MappedKey::Semicolon),578+ "Quote" => Some(MappedKey::Apostrophe),579+ "Backquote" => Some(MappedKey::Grave),580+ "Comma" => Some(MappedKey::Comma),581+ "Period" => Some(MappedKey::Dot),582+ "Slash" => Some(MappedKey::Slash),491583 "Enter" => Some(MappedKey::Enter),492584 "Tab" => Some(MappedKey::Tab),493585 "Space" => Some(MappedKey::Space),494586 "Backspace" => Some(MappedKey::Backspace),495587 "Escape" => Some(MappedKey::Escape),588+ "ShiftLeft" => Some(MappedKey::LeftShift),589+ "ShiftRight" => Some(MappedKey::RightShift),590+ "ControlLeft" => Some(MappedKey::LeftCtrl),591+ "ControlRight" => Some(MappedKey::RightCtrl),592+ "AltLeft" => Some(MappedKey::LeftAlt),593+ "AltRight" => Some(MappedKey::RightAlt),594+ "MetaLeft" => Some(MappedKey::LeftMeta),595+ "MetaRight" => Some(MappedKey::RightMeta),496596 "Shift" => Some(MappedKey::LeftShift),497597 "Control" => Some(MappedKey::LeftCtrl),498598 "Alt" => Some(MappedKey::LeftAlt),499599 "Meta" => Some(MappedKey::LeftMeta),600+ "ArrowUp" => Some(MappedKey::Up),601+ "ArrowDown" => Some(MappedKey::Down),602+ "ArrowLeft" => Some(MappedKey::Left),603+ "ArrowRight" => Some(MappedKey::Right),500604 "Up" => Some(MappedKey::Up),501605 "Down" => Some(MappedKey::Down),502606 "Left" => Some(MappedKey::Left),@@ -505,7 +609,21 @@ fn map_key(value: &str) -> Option<MappedKey> {
505609 "End" => Some(MappedKey::End),506610 "PageUp" => Some(MappedKey::PageUp),507611 "PageDown" => Some(MappedKey::PageDown),612+ "Insert" => Some(MappedKey::Insert),508613 "Delete" => Some(MappedKey::Delete),614+ "PrintScreen" => Some(MappedKey::PrintScreen),615+ "F1" => Some(MappedKey::F1),616+ "F2" => Some(MappedKey::F2),617+ "F3" => Some(MappedKey::F3),618+ "F4" => Some(MappedKey::F4),619+ "F5" => Some(MappedKey::F5),620+ "F6" => Some(MappedKey::F6),621+ "F7" => Some(MappedKey::F7),622+ "F8" => Some(MappedKey::F8),623+ "F9" => Some(MappedKey::F9),624+ "F10" => Some(MappedKey::F10),625+ "F11" => Some(MappedKey::F11),626+ "F12" => Some(MappedKey::F12),509627 _ => map_printable_us_key(value),510628 }511629 }@@ -1339,7 +1457,7 @@ mod linux {
13391457 .into_raw()13401458 }13411459 1342- fn linux_key(key: MappedKey) -> Key {1460+ pub(super) fn linux_key(key: MappedKey) -> Key {13431461 match key {13441462 MappedKey::A => Key::A,13451463 MappedKey::B => Key::B,@@ -1388,15 +1506,22 @@ mod linux {
13881506 MappedKey::Comma => Key::Comma,13891507 MappedKey::Dot => Key::Dot,13901508 MappedKey::Slash => Key::Slash,1509+ MappedKey::NonUsBackslash => Key::NonUsBackslashAndPipe,1510+ MappedKey::Ro => Key::Ro,1511+ MappedKey::Yen => Key::Yen,13911512 MappedKey::Enter => Key::Enter,13921513 MappedKey::Tab => Key::Tab,13931514 MappedKey::Space => Key::Space,13941515 MappedKey::Backspace => Key::Backspace,13951516 MappedKey::Escape => Key::Esc,13961517 MappedKey::LeftShift => Key::LeftShift,1518+ MappedKey::RightShift => Key::RightShift,13971519 MappedKey::LeftCtrl => Key::LeftCtrl,1520+ MappedKey::RightCtrl => Key::RightCtrl,13981521 MappedKey::LeftAlt => Key::LeftAlt,1522+ MappedKey::RightAlt => Key::RightAlt,13991523 MappedKey::LeftMeta => Key::LeftMeta,1524+ MappedKey::RightMeta => Key::RightMeta,14001525 MappedKey::Up => Key::Up,14011526 MappedKey::Down => Key::Down,14021527 MappedKey::Left => Key::Left,@@ -1405,7 +1530,21 @@ mod linux {
14051530 MappedKey::End => Key::End,14061531 MappedKey::PageUp => Key::PageUp,14071532 MappedKey::PageDown => Key::PageDown,1533+ MappedKey::Insert => Key::Insert,14081534 MappedKey::Delete => Key::Delete,1535+ MappedKey::PrintScreen => Key::Sysrq,1536+ MappedKey::F1 => Key::F1,1537+ MappedKey::F2 => Key::F2,1538+ MappedKey::F3 => Key::F3,1539+ MappedKey::F4 => Key::F4,1540+ MappedKey::F5 => Key::F5,1541+ MappedKey::F6 => Key::F6,1542+ MappedKey::F7 => Key::F7,1543+ MappedKey::F8 => Key::F8,1544+ MappedKey::F9 => Key::F9,1545+ MappedKey::F10 => Key::F10,1546+ MappedKey::F11 => Key::F11,1547+ MappedKey::F12 => Key::F12,14091548 }14101549 }14111550 }@@ -1601,9 +1740,67 @@ mod tests {
16011740 assert_eq!(map_key("Control"), Some(MappedKey::LeftCtrl));16021741 assert_eq!(map_key("PageDown"), Some(MappedKey::PageDown));16031742 assert_eq!(map_key("F13"), None);1743+ assert_eq!(map_key("CapsLock"), None);1744+ assert_eq!(map_key("Numpad1"), None);16041745 assert_eq!(map_key(""), None);16051746 }16061747 1748+ 1749+ fn maps_bounded_browser_physical_keys_for_games_and_layouts() {1750+ for (value, expected) in [1751+ ("KeyQ", MappedKey::Q),1752+ ("Digit2", MappedKey::Num2),1753+ ("BracketLeft", MappedKey::LeftBrace),1754+ ("IntlBackslash", MappedKey::NonUsBackslash),1755+ ("IntlRo", MappedKey::Ro),1756+ ("IntlYen", MappedKey::Yen),1757+ ("ShiftLeft", MappedKey::LeftShift),1758+ ("ShiftRight", MappedKey::RightShift),1759+ ("ControlLeft", MappedKey::LeftCtrl),1760+ ("ControlRight", MappedKey::RightCtrl),1761+ ("AltLeft", MappedKey::LeftAlt),1762+ ("AltRight", MappedKey::RightAlt),1763+ ("MetaLeft", MappedKey::LeftMeta),1764+ ("MetaRight", MappedKey::RightMeta),1765+ ("ArrowUp", MappedKey::Up),1766+ ("Insert", MappedKey::Insert),1767+ ("PrintScreen", MappedKey::PrintScreen),1768+ ("F1", MappedKey::F1),1769+ ("F12", MappedKey::F12),1770+ ] {1771+ assert_eq!(map_key(value), Some(expected), "{value}");1772+ assert!(ALL_MAPPED_KEYS.contains(&expected), "{value}");1773+ }1774+ let unique = ALL_MAPPED_KEYS1775+ .iter()1776+ .copied()1777+ .collect::<std::collections::BTreeSet<_>>();1778+ assert_eq!(unique.len(), ALL_MAPPED_KEYS.len());1779+ }1780+1781+ 1782+ 1783+ fn extended_physical_keys_translate_to_exact_linux_evdev_codes() {1784+ for (mapped, expected) in [1785+ (MappedKey::F1, input_linux::Key::F1),1786+ (MappedKey::F12, input_linux::Key::F12),1787+ (MappedKey::Insert, input_linux::Key::Insert),1788+ (MappedKey::PrintScreen, input_linux::Key::Sysrq),1789+ (MappedKey::RightShift, input_linux::Key::RightShift),1790+ (MappedKey::RightCtrl, input_linux::Key::RightCtrl),1791+ (MappedKey::RightAlt, input_linux::Key::RightAlt),1792+ (MappedKey::RightMeta, input_linux::Key::RightMeta),1793+ (1794+ MappedKey::NonUsBackslash,1795+ input_linux::Key::NonUsBackslashAndPipe,1796+ ),1797+ (MappedKey::Ro, input_linux::Key::Ro),1798+ (MappedKey::Yen, input_linux::Key::Yen),1799+ ] {1800+ assert_eq!(linux::linux_key(mapped), expected);1801+ }1802+ }1803+16071804 16081805 fn maps_us_printable_keys_to_physical_keys() {16091806 assert_eq!(map_key("a"), Some(MappedKey::A));@@ -4,7 +4,7 @@ use tokio::io::{AsyncRead, AsyncWrite};
44 use crate::framing::{read_json, write_json};55 use crate::{MAX_INPUT_MESSAGE_LEN, ProtocolError, Result};66 7-const MAX_KEY_LEN: usize = 64;7+const MAX_KEY_IDENTIFIER_UTF8_BYTES: usize = 64;88 const MAX_TEXT_LEN: usize = 1_024;99 1010 /// Symmetric per-message relative pointer range. Large accumulated movement@@ -102,6 +102,8 @@ pub enum InputEvent {
102102 MouseUp { b: u8 },103103 104104 MouseScroll { dx: i32, dy: i32 },105+ /// Bounded physical key identifier. New clients send the supported106+ /// `KeyboardEvent.code` subset; legacy logical key names remain decodable.105107 106108 KeyDown { k: String },107109 @@ -204,7 +206,7 @@ impl InputEvent {
204206 })205207 }206208 Self::KeyDown { k } | Self::KeyUp { k } | Self::KeyClick { k }207- if k.is_empty() || k.len() > MAX_KEY_LEN =>209+ if k.is_empty() || k.len() > MAX_KEY_IDENTIFIER_UTF8_BYTES =>208210 {209211 Err(ProtocolError::InvalidMessage {210212 message_type: "input event",@@ -321,6 +323,22 @@ mod tests {
321323 assert_eq!(read_input_event(&mut receiver).await.unwrap(), None);322324 }323325 326+ 327+ async fn physical_keyboard_down_up_and_click_tokens_round_trip() {328+ for event in [329+ InputEvent::KeyDown { k: "F1".into() },330+ InputEvent::KeyUp {331+ k: "PrintScreen".into(),332+ },333+ InputEvent::KeyClick { k: "KeyQ".into() },334+ ] {335+ let (mut sender, mut receiver) = duplex(256);336+ write_input_event(&mut sender, &event).await.unwrap();337+ sender.shutdown().await.unwrap();338+ assert_eq!(read_input_event(&mut receiver).await.unwrap(), Some(event));339+ }340+ }341+324342 325343 async fn full_gamepad_snapshot_round_trips() {326344 let event = InputEvent::Gamepad {@@ -477,6 +495,20 @@ mod tests {
477495 );478496 assert!(InputEvent::MouseDown { b: 9 }.validate().is_err());479497 assert!(InputEvent::KeyUp { k: String::new() }.validate().is_err());498+ assert!(499+ InputEvent::KeyDown {500+ k: "é".repeat(MAX_KEY_IDENTIFIER_UTF8_BYTES / 2)501+ }502+ .validate()503+ .is_ok()504+ );505+ assert!(506+ InputEvent::KeyUp {507+ k: "é".repeat((MAX_KEY_IDENTIFIER_UTF8_BYTES / 2) + 1)508+ }509+ .validate()510+ .is_err()511+ );480512 assert!(481513 InputEvent::Text {482514 s: "x".repeat(MAX_TEXT_LEN + 1)@@ -299,7 +299,9 @@ test('Portal delegates queue ownership while retaining DOM input wiring', () =>
299299 );300300 assert.match(main, /window\.addEventListener\('mousedown', handleMouseDown/);301301 assert.match(main, /inputRuntime\.trackMouseButton\(btn\)/);302- assert.match(main, /inputRuntime\.trackKey\(keyId, k\)/);302+ assert.match(main, /keyboardInputForEvent\(e, connectionState\.inputCapabilities\)/);303+ assert.match(main, /inputRuntime\.trackKey\(keyId, input\.key\)/);304+ assert.doesNotMatch(main, /const printableText =/);303305 assert.doesNotMatch(main, /const reliableInputQueue =/);304306 assert.doesNotMatch(main, /function sendInput\(/);305307 assert.doesNotMatch(main, /new HeldInputState\(/);@@ -230,6 +230,21 @@ test('ordinary releases retain the original forwarded key value', () => {
230230 assert.equal(state.takeKeyRelease('Digit1'), null);231231 });232232 233+test('extended physical keys suppress repeats and retain exact neutralization tokens', () => {234+ const state = new HeldInputState();235+ assert.equal(state.trackKey('F12', 'F12'), 'tracked');236+ assert.equal(state.trackKey('F12', 'F12'), 'repeat');237+ assert.equal(state.trackKey('IntlBackslash', 'IntlBackslash'), 'tracked');238+ assert.equal(state.trackKey('AltRight', 'AltRight'), 'tracked');239+ assert.deepEqual(state.releaseEvents(), [240+ { t: 'ku', k: 'F12' },241+ { t: 'ku', k: 'IntlBackslash' },242+ { t: 'ku', k: 'AltRight' },243+ ]);244+ assert.deepEqual(state.takeKeyRelease('F12'), { t: 'ku', k: 'F12' });245+ assert.equal(state.takeKeyRelease('F12'), null);246+});247+233248 test('relative motion coalesces small displacement into one bounded event', () => {234249 const motion = new RelativePointerAccumulator();235250 assert.equal(motion.add(2.4, -3.6), true);@@ -1,14 +1,113 @@
1+// This bounded subset of W3C KeyboardEvent.code values is the Portal/Sigil2+// physical-key contract. Codes identify key position rather than the active3+// client layout, which is what games, Steam, and Linux evdev consume.4+const PHYSICAL_KEY_CODES = new Set([5+ ...Array.from({ length: 26 }, (_, index) => `Key${String.fromCharCode(65 + index)}`),6+ ...Array.from({ length: 10 }, (_, index) => `Digit${index}`),7+ 'Minus',8+ 'Equal',9+ 'BracketLeft',10+ 'BracketRight',11+ 'Backslash',12+ 'IntlBackslash',13+ 'IntlRo',14+ 'IntlYen',15+ 'Semicolon',16+ 'Quote',17+ 'Backquote',18+ 'Comma',19+ 'Period',20+ 'Slash',21+ 'Enter',22+ 'Tab',23+ 'Space',24+ 'Backspace',25+ 'Escape',26+ 'ShiftLeft',27+ 'ShiftRight',28+ 'ControlLeft',29+ 'ControlRight',30+ 'AltLeft',31+ 'AltRight',32+ 'MetaLeft',33+ 'MetaRight',34+ 'ArrowUp',35+ 'ArrowDown',36+ 'ArrowLeft',37+ 'ArrowRight',38+ 'Home',39+ 'End',40+ 'PageUp',41+ 'PageDown',42+ 'Insert',43+ 'Delete',44+ 'PrintScreen',45+ ...Array.from({ length: 12 }, (_, index) => `F${index + 1}`),46+]);47+48+const LEGACY_NAMED_KEYS = Object.freeze({49+ ArrowUp: 'Up',50+ ArrowDown: 'Down',51+ ArrowLeft: 'Left',52+ ArrowRight: 'Right',53+ ' ': 'Space',54+ Delete: 'Delete',55+ Backspace: 'Backspace',56+ Enter: 'Enter',57+ Tab: 'Tab',58+ Escape: 'Escape',59+ Shift: 'Shift',60+ Control: 'Control',61+ Alt: 'Alt',62+ Meta: 'Meta',63+ Home: 'Home',64+ End: 'End',65+ PageUp: 'PageUp',66+ PageDown: 'PageDown',67+ Insert: 'Insert',68+ PrintScreen: 'PrintScreen',69+});70+71+function isAsciiPrintable(value) {72+ return value.length === 173+ && value.codePointAt(0) >= 0x2074+ && value.codePointAt(0) <= 0x7e;75+}76+77+function textValue(event) {78+ if (event.ctrlKey || event.altKey || event.metaKey || event.isComposing) return null;79+ if (typeof event.key !== 'string') return null;80+ const codePoints = Array.from(event.key);81+ if (codePoints.length !== 1) return null;82+ const codePoint = codePoints[0].codePointAt(0);83+ if (codePoint <= 0x1f84+ || (codePoint >= 0x7f && codePoint <= 0x9f)85+ || (codePoint >= 0xd800 && codePoint <= 0xdfff)) return null;86+ return event.key;87+}88+189 export function mapKey(event) {2- const key = event.key;3- const map = {4- 'ArrowUp': 'Up', 'ArrowDown': 'Down', 'ArrowLeft': 'Left', 'ArrowRight': 'Right',5- ' ': 'Space', 'Delete': 'Delete', 'Backspace': 'Backspace',6- 'Enter': 'Enter', 'Tab': 'Tab', 'Escape': 'Escape',7- 'Shift': 'Shift', 'Control': 'Control', 'Alt': 'Alt', 'Meta': 'Meta',8- 'Home': 'Home', 'End': 'End', 'PageUp': 'PageUp', 'PageDown': 'PageDown',9- };10- const asciiPrintable = key.length === 111- && key.codePointAt(0) >= 0x2012- && key.codePointAt(0) <= 0x7e;13- return map[key] || (asciiPrintable ? key : null);90+ if (!event || typeof event !== 'object') return null;91+ if (typeof event.code === 'string' && event.code.length > 0) {92+ return PHYSICAL_KEY_CODES.has(event.code) ? event.code : null;93+ }94+ if (typeof event.key !== 'string') return null;95+ if (/^F(?:[1-9]|1[0-2])$/.test(event.key)) return event.key;96+ return LEGACY_NAMED_KEYS[event.key]97+ ?? (isAsciiPrintable(event.key) ? event.key : null);98+}99+100+// Resolve each keydown to exactly one protocol class. Physical keyboard input101+// wins whenever it is available; text is a fallback for virtual keyboards or102+// unsupported physical codes and is never emitted alongside a key transition.103+export function keyboardInputForEvent(event, capabilities = {}) {104+ if (capabilities.keyboard === true) {105+ const key = mapKey(event);106+ if (key !== null) return { type: 'key', key };107+ }108+ if (capabilities.text === true) {109+ const text = textValue(event);110+ if (text !== null) return { type: 'text', text };111+ }112+ return null;14113 }@@ -1,38 +1,115 @@
11 import assert from 'node:assert/strict';22 import test from 'node:test';33 4-import { mapKey } from './keyboard-map.mjs';4+import {5+ keyboardInputForEvent,6+ mapKey,7+} from './keyboard-map.mjs';58 6-test('maps the current named keyboard controls exactly', () => {9+test('forwards the bounded physical code contract including extended keys', () => {10+ for (const code of [11+ 'KeyA',12+ 'Digit0',13+ 'Minus',14+ 'BracketLeft',15+ 'IntlBackslash',16+ 'IntlRo',17+ 'IntlYen',18+ 'ShiftLeft',19+ 'ShiftRight',20+ 'ControlLeft',21+ 'ControlRight',22+ 'AltLeft',23+ 'AltRight',24+ 'MetaLeft',25+ 'MetaRight',26+ 'ArrowUp',27+ 'Insert',28+ 'PrintScreen',29+ ...Array.from({ length: 12 }, (_, index) => `F${index + 1}`),30+ ]) {31+ assert.equal(mapKey({ code, key: 'layout-dependent' }), code, code);32+ }33+});34+35+test('physical codes preserve non-ASCII and alternate-layout key positions', () => {36+ assert.equal(mapKey({ code: 'KeyE', key: 'é' }), 'KeyE');37+ assert.equal(mapKey({ code: 'Digit2', key: 'é' }), 'Digit2');38+ assert.equal(mapKey({ code: 'KeyQ', key: 'a' }), 'KeyQ');39+ assert.equal(mapKey({ code: 'AltRight', key: 'AltGraph' }), 'AltRight');40+});41+42+test('legacy missing-code events retain the compatible named and ASCII map', () => {743 for (const [key, expected] of [844 ['ArrowUp', 'Up'],9- ['ArrowDown', 'Down'],10- ['ArrowLeft', 'Left'],11- ['ArrowRight', 'Right'],1245 [' ', 'Space'],13- ['Delete', 'Delete'],14- ['Backspace', 'Backspace'],15- ['Enter', 'Enter'],16- ['Tab', 'Tab'],17- ['Escape', 'Escape'],18- ['Shift', 'Shift'],1946 ['Control', 'Control'],20- ['Alt', 'Alt'],21- ['Meta', 'Meta'],22- ['Home', 'Home'],23- ['End', 'End'],24- ['PageUp', 'PageUp'],2547 ['PageDown', 'PageDown'],48+ ['Insert', 'Insert'],49+ ['PrintScreen', 'PrintScreen'],50+ ['F1', 'F1'],51+ ['F12', 'F12'],52+ ['!', '!'],53+ ['a', 'a'],2654 ]) {27- assert.equal(mapKey({ key }), expected, key);55+ assert.equal(mapKey({ code: '', key }), expected, key);2856 }2957 });3058 31-test('preserves only single ASCII-printable keys', () => {32- for (const key of ['!', 'a', 'A', '0', '~']) {33- assert.equal(mapKey({ key }), key, key);59+test('rejects unsupported physical and logical keys without guessing', () => {60+ for (const event of [61+ { code: 'F13', key: 'F13' },62+ { code: 'CapsLock', key: 'CapsLock' },63+ { code: 'Numpad1', key: '1' },64+ { code: 'AudioVolumeUp', key: 'AudioVolumeUp' },65+ { code: '', key: 'é' },66+ { code: '', key: '😀' },67+ { code: '', key: '' },68+ null,69+ ]) {70+ assert.equal(mapKey(event), null);3471 }35- for (const key of ['F1', 'F12', 'é', '😀', 'CapsLock', 'Insert', '']) {36- assert.equal(mapKey({ key }), null, key);72+});73+74+test('chooses one physical key transition before any text fallback', () => {75+ const both = { keyboard: true, text: true };76+ assert.deepEqual(77+ keyboardInputForEvent({ code: 'KeyE', key: 'é' }, both),78+ { type: 'key', key: 'KeyE' },79+ );80+ assert.deepEqual(81+ keyboardInputForEvent({ code: 'F5', key: 'F5' }, both),82+ { type: 'key', key: 'F5' },83+ );84+ assert.deepEqual(85+ keyboardInputForEvent({ code: 'Unknown', key: 'é' }, both),86+ { type: 'text', text: 'é' },87+ );88+});89+90+test('text-only fallback accepts one Unicode scalar and rejects modified or composing input', () => {91+ const textOnly = { keyboard: false, text: true };92+ assert.deepEqual(93+ keyboardInputForEvent({ code: '', key: 'é' }, textOnly),94+ { type: 'text', text: 'é' },95+ );96+ assert.deepEqual(97+ keyboardInputForEvent({ code: '', key: '😀' }, textOnly),98+ { type: 'text', text: '😀' },99+ );100+ for (const event of [101+ { code: '', key: 'a', ctrlKey: true },102+ { code: '', key: 'a', altKey: true },103+ { code: '', key: 'a', metaKey: true },104+ { code: '', key: 'a', isComposing: true },105+ { code: '', key: 'Enter' },106+ { code: '', key: '\u007f' },107+ { code: '', key: '\ud800' },108+ ]) {109+ assert.equal(keyboardInputForEvent(event, textOnly), null);37110 }111+ assert.equal(112+ keyboardInputForEvent({ code: 'KeyA', key: 'a' }, {}),113+ null,114+ );38115 });@@ -40,7 +40,7 @@ import {
4040 normalizeInvitationSummary,4141 shortPeerFingerprint,4242 } from './enrollment.mjs';43-import { mapKey } from './keyboard-map.mjs';43+import { keyboardInputForEvent } from './keyboard-map.mjs';4444 4545 let enrollmentReady = false;4646 let pendingInvitationSummary = null;@@ -977,25 +977,21 @@ window.addEventListener('keydown', (e) => {
977977 controlRuntime.exit();978978 return;979979 }980- const printableText = e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey;981- if (printableText && connectionState.inputCapabilities.text) {982- e.preventDefault();983- inputRuntime.send({ t: 'tx', s: e.key });980+ const input = keyboardInputForEvent(e, connectionState.inputCapabilities);981+ if (input === null) return;982+ e.preventDefault();983+ if (input.type === 'text') {984+ inputRuntime.send({ t: 'tx', s: input.text });984985 return;985986 }986- if (!connectionState.inputCapabilities.keyboard) return;987- const k = mapKey(e);988- if (k) {989- e.preventDefault();990- const keyId = e.code || e.key;991- const tracking = inputRuntime.trackKey(keyId, k);992- if (tracking === 'repeat') return;993- if (tracking === 'full') {994- console.error('held key limit reached; refusing additional key transition');995- return;996- }997- if (!inputRuntime.send({ t: 'kd', k })) inputRuntime.takeKeyRelease(keyId);987+ const keyId = e.code || e.key;988+ const tracking = inputRuntime.trackKey(keyId, input.key);989+ if (tracking === 'repeat') return;990+ if (tracking === 'full') {991+ console.error('held key limit reached; refusing additional key transition');992+ return;998993 }994+ if (!inputRuntime.send({ t: 'kd', k: input.key })) inputRuntime.takeKeyRelease(keyId);999995 });1000996 1001997 window.addEventListener('keyup', (e) => {