From e1b1a927b8c693b047bafcc75fce09f24391cd00 Mon Sep 17 00:00:00 2001 From: fufesou <13586388+fufesou@users.noreply.github.com> Date: Fri, 30 Jan 2026 17:32:18 +0800 Subject: [PATCH] fix(ios): capsLock, workaround #5871 (#14194) Signed-off-by: fufesou --- flutter/lib/models/input_model.dart | 135 +++++++++++++++++++--------- 1 file changed, 94 insertions(+), 41 deletions(-) diff --git a/flutter/lib/models/input_model.dart b/flutter/lib/models/input_model.dart index 97ef80a55..134b21107 100644 --- a/flutter/lib/models/input_model.dart +++ b/flutter/lib/models/input_model.dart @@ -59,7 +59,8 @@ class CanvasCoords { model.scale = json['scale']; model.scrollX = json['scrollX']; model.scrollY = json['scrollY']; - model.scrollStyle = ScrollStyle.fromJson(json['scrollStyle'], ScrollStyle.scrollauto); + model.scrollStyle = + ScrollStyle.fromJson(json['scrollStyle'], ScrollStyle.scrollauto); model.size = Size(json['size']['w'], json['size']['h']); return model; } @@ -418,6 +419,74 @@ class InputModel { }); } + // https://github.com/flutter/flutter/issues/157241 + // Infer CapsLock state from the character output. + // This is needed because Flutter's HardwareKeyboard.lockModesEnabled may report + // incorrect CapsLock state on iOS. + bool _getIosCapsFromCharacter(KeyEvent e) { + if (!isIOS) return false; + final ch = e.character; + return _getIosCapsFromCharacterImpl( + ch, HardwareKeyboard.instance.isShiftPressed); + } + + // RawKeyEvent version of _getIosCapsFromCharacter. + bool _getIosCapsFromRawCharacter(RawKeyEvent e) { + if (!isIOS) return false; + final ch = e.character; + return _getIosCapsFromCharacterImpl(ch, e.isShiftPressed); + } + + // Shared implementation for inferring CapsLock state from character. + // Uses Unicode-aware case detection to support non-ASCII letters (e.g., ü/Ü, é/É). + // + // Limitations: + // 1. This inference assumes the client and server use the same keyboard layout. + // If layouts differ (e.g., client uses EN, server uses DE), the character output + // may not match expectations. For example, ';' on EN layout maps to 'ö' on DE + // layout, making it impossible to correctly infer CapsLock state from the + // character alone. + // 2. On iOS, CapsLock+Shift produces uppercase letters (unlike desktop where it + // produces lowercase). This method cannot handle that case correctly. + bool _getIosCapsFromCharacterImpl(String? ch, bool shiftPressed) { + if (ch == null || ch.length != 1) return false; + // Use Dart's built-in Unicode-aware case detection + final upper = ch.toUpperCase(); + final lower = ch.toLowerCase(); + final isUpper = upper == ch && lower != ch; + final isLower = lower == ch && upper != ch; + // Skip non-letter characters (e.g., numbers, symbols, CJK characters without case) + if (!isUpper && !isLower) return false; + return isUpper != shiftPressed; + } + + int _buildLockModes(bool iosCapsLock) { + const capslock = 1; + const numlock = 2; + const scrolllock = 3; + int lockModes = 0; + if (isIOS) { + if (iosCapsLock) { + lockModes |= (1 << capslock); + } + // Ignore "NumLock/ScrollLock" on iOS for now. + } else { + if (HardwareKeyboard.instance.lockModesEnabled + .contains(KeyboardLockMode.capsLock)) { + lockModes |= (1 << capslock); + } + if (HardwareKeyboard.instance.lockModesEnabled + .contains(KeyboardLockMode.numLock)) { + lockModes |= (1 << numlock); + } + if (HardwareKeyboard.instance.lockModesEnabled + .contains(KeyboardLockMode.scrollLock)) { + lockModes |= (1 << scrolllock); + } + } + return lockModes; + } + // This function must be called after the peer info is received. // Because `sessionGetKeyboardMode` relies on the peer version. updateKeyboardMode() async { @@ -550,6 +619,11 @@ class InputModel { return KeyEventResult.handled; } + bool iosCapsLock = false; + if (isIOS && e is RawKeyDownEvent) { + iosCapsLock = _getIosCapsFromRawCharacter(e); + } + final key = e.logicalKey; if (e is RawKeyDownEvent) { if (!e.repeat) { @@ -586,7 +660,7 @@ class InputModel { // * Currently mobile does not enable map mode if ((isDesktop || isWebDesktop) && keyboardMode == kKeyMapMode) { - mapKeyboardModeRaw(e); + mapKeyboardModeRaw(e, iosCapsLock); } else { legacyKeyboardModeRaw(e); } @@ -622,6 +696,11 @@ class InputModel { return KeyEventResult.handled; } + bool iosCapsLock = false; + if (isIOS && (e is KeyDownEvent || e is KeyRepeatEvent)) { + iosCapsLock = _getIosCapsFromCharacter(e); + } + if (e is KeyUpEvent) { handleKeyUpEventModifiers(e); } else if (e is KeyDownEvent) { @@ -667,7 +746,8 @@ class InputModel { e.character ?? '', e.physicalKey.usbHidUsage & 0xFFFF, // Show repeat event be converted to "release+press" events? - e is KeyDownEvent || e is KeyRepeatEvent); + e is KeyDownEvent || e is KeyRepeatEvent, + iosCapsLock); } else { legacyKeyboardMode(e); } @@ -676,23 +756,9 @@ class InputModel { } /// Send Key Event - void newKeyboardMode(String character, int usbHid, bool down) { - const capslock = 1; - const numlock = 2; - const scrolllock = 3; - int lockModes = 0; - if (HardwareKeyboard.instance.lockModesEnabled - .contains(KeyboardLockMode.capsLock)) { - lockModes |= (1 << capslock); - } - if (HardwareKeyboard.instance.lockModesEnabled - .contains(KeyboardLockMode.numLock)) { - lockModes |= (1 << numlock); - } - if (HardwareKeyboard.instance.lockModesEnabled - .contains(KeyboardLockMode.scrollLock)) { - lockModes |= (1 << scrolllock); - } + void newKeyboardMode( + String character, int usbHid, bool down, bool iosCapsLock) { + final lockModes = _buildLockModes(iosCapsLock); bind.sessionHandleFlutterKeyEvent( sessionId: sessionId, character: character, @@ -701,7 +767,7 @@ class InputModel { downOrUp: down); } - void mapKeyboardModeRaw(RawKeyEvent e) { + void mapKeyboardModeRaw(RawKeyEvent e, bool iosCapsLock) { int positionCode = -1; int platformCode = -1; bool down; @@ -732,27 +798,14 @@ class InputModel { } else { down = false; } - inputRawKey(e.character ?? '', platformCode, positionCode, down); + inputRawKey( + e.character ?? '', platformCode, positionCode, down, iosCapsLock); } /// Send raw Key Event - void inputRawKey(String name, int platformCode, int positionCode, bool down) { - const capslock = 1; - const numlock = 2; - const scrolllock = 3; - int lockModes = 0; - if (HardwareKeyboard.instance.lockModesEnabled - .contains(KeyboardLockMode.capsLock)) { - lockModes |= (1 << capslock); - } - if (HardwareKeyboard.instance.lockModesEnabled - .contains(KeyboardLockMode.numLock)) { - lockModes |= (1 << numlock); - } - if (HardwareKeyboard.instance.lockModesEnabled - .contains(KeyboardLockMode.scrollLock)) { - lockModes |= (1 << scrolllock); - } + void inputRawKey(String name, int platformCode, int positionCode, bool down, + bool iosCapsLock) { + final lockModes = _buildLockModes(iosCapsLock); bind.sessionHandleFlutterRawKeyEvent( sessionId: sessionId, name: name, @@ -1800,9 +1853,9 @@ class InputModel { // Simulate a key press event. // `usbHidUsage` is the USB HID usage code of the key. Future tapHidKey(int usbHidUsage) async { - newKeyboardMode(kKeyFlutterKey, usbHidUsage, true); + newKeyboardMode(kKeyFlutterKey, usbHidUsage, true, false); await Future.delayed(Duration(milliseconds: 100)); - newKeyboardMode(kKeyFlutterKey, usbHidUsage, false); + newKeyboardMode(kKeyFlutterKey, usbHidUsage, false, false); } Future onMobileVolumeUp() async =>