Added German (T1) mappings, UK mappings, updated UK apple mappings. Added functionality to disable keyboard mapping.

This commit is contained in:
William Johnstone 2025-02-25 00:44:17 +00:00
parent 8732a6aff8
commit 40b1c70be0
No known key found for this signature in database
GPG Key ID: 89703D0D4B3BB0FE
11 changed files with 279 additions and 78 deletions

View File

@ -12,25 +12,27 @@ type WakeOnLanDevice struct {
}
type Config struct {
CloudURL string `json:"cloud_url"`
CloudToken string `json:"cloud_token"`
GoogleIdentity string `json:"google_identity"`
JigglerEnabled bool `json:"jiggler_enabled"`
AutoUpdateEnabled bool `json:"auto_update_enabled"`
KeyboardLayout string `json:"keyboard_layout"`
IncludePreRelease bool `json:"include_pre_release"`
HashedPassword string `json:"hashed_password"`
LocalAuthToken string `json:"local_auth_token"`
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
CloudURL string `json:"cloud_url"`
CloudToken string `json:"cloud_token"`
GoogleIdentity string `json:"google_identity"`
JigglerEnabled bool `json:"jiggler_enabled"`
AutoUpdateEnabled bool `json:"auto_update_enabled"`
KeyboardLayout string `json:"keyboard_layout"`
KeyboardMappingEnabled bool `json:"keyboard_mapping_enabled"`
IncludePreRelease bool `json:"include_pre_release"`
HashedPassword string `json:"hashed_password"`
LocalAuthToken string `json:"local_auth_token"`
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
}
const configPath = "/userdata/kvm_config.json"
var defaultConfig = &Config{
CloudURL: "https://api.jetkvm.com",
AutoUpdateEnabled: true, // Set a default value
KeyboardLayout: "us",
CloudURL: "https://api.jetkvm.com",
AutoUpdateEnabled: true, // Set a default value
KeyboardLayout: "us",
KeyboardMappingEnabled: false,
}
var config *Config

View File

@ -143,6 +143,18 @@ func rpcSetKeyboardLayout(KeyboardLayout string) (string, error) {
return KeyboardLayout, nil
}
func rpcGetKeyboardMappingState() (bool, error) {
return config.KeyboardMappingEnabled, nil
}
func rpcSetKeyboardMappingState(enabled bool) (bool, error) {
config.KeyboardMappingEnabled = enabled
if err := SaveConfig(); err != nil {
return config.KeyboardMappingEnabled, fmt.Errorf("failed to save config: %w", err)
}
return enabled, nil
}
var streamFactor = 1.0
func rpcGetStreamQualityFactor() (float64, error) {
@ -521,51 +533,53 @@ func rpcResetConfig() error {
// TODO: replace this crap with code generator
var rpcHandlers = map[string]RPCHandler{
"ping": {Func: rpcPing},
"getDeviceID": {Func: rpcGetDeviceID},
"deregisterDevice": {Func: rpcDeregisterDevice},
"getCloudState": {Func: rpcGetCloudState},
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
"getVideoState": {Func: rpcGetVideoState},
"getUSBState": {Func: rpcGetUSBState},
"unmountImage": {Func: rpcUnmountImage},
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
"getJigglerState": {Func: rpcGetJigglerState},
"sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}},
"getKeyboardLayout": {Func: rpcGetKeyboardLayout},
"setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"kbLayout"}},
"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor},
"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}},
"getAutoUpdateState": {Func: rpcGetAutoUpdateState},
"setAutoUpdateState": {Func: rpcSetAutoUpdateState, Params: []string{"enabled"}},
"getEDID": {Func: rpcGetEDID},
"setEDID": {Func: rpcSetEDID, Params: []string{"edid"}},
"getDevChannelState": {Func: rpcGetDevChannelState},
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
"getUpdateStatus": {Func: rpcGetUpdateStatus},
"tryUpdate": {Func: rpcTryUpdate},
"getDevModeState": {Func: rpcGetDevModeState},
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
"getSSHKeyState": {Func: rpcGetSSHKeyState},
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
"getMassStorageMode": {Func: rpcGetMassStorageMode},
"isUpdatePending": {Func: rpcIsUpdatePending},
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
"getVirtualMediaState": {Func: rpcGetVirtualMediaState},
"getStorageSpace": {Func: rpcGetStorageSpace},
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
"mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
"listStorageFiles": {Func: rpcListStorageFiles},
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},
"startStorageFileUpload": {Func: rpcStartStorageFileUpload, Params: []string{"filename", "size"}},
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
"resetConfig": {Func: rpcResetConfig},
"ping": {Func: rpcPing},
"getDeviceID": {Func: rpcGetDeviceID},
"deregisterDevice": {Func: rpcDeregisterDevice},
"getCloudState": {Func: rpcGetCloudState},
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
"getVideoState": {Func: rpcGetVideoState},
"getUSBState": {Func: rpcGetUSBState},
"unmountImage": {Func: rpcUnmountImage},
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
"getJigglerState": {Func: rpcGetJigglerState},
"sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}},
"getKeyboardLayout": {Func: rpcGetKeyboardLayout},
"setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"kbLayout"}},
"setKeyboardMappingState": {Func: rpcSetKeyboardMappingState, Params: []string{"enabled"}},
"getKeyboardMappingState": {Func: rpcGetKeyboardMappingState},
"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor},
"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}},
"getAutoUpdateState": {Func: rpcGetAutoUpdateState},
"setAutoUpdateState": {Func: rpcSetAutoUpdateState, Params: []string{"enabled"}},
"getEDID": {Func: rpcGetEDID},
"setEDID": {Func: rpcSetEDID, Params: []string{"edid"}},
"getDevChannelState": {Func: rpcGetDevChannelState},
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
"getUpdateStatus": {Func: rpcGetUpdateStatus},
"tryUpdate": {Func: rpcTryUpdate},
"getDevModeState": {Func: rpcGetDevModeState},
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
"getSSHKeyState": {Func: rpcGetSSHKeyState},
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
"getMassStorageMode": {Func: rpcGetMassStorageMode},
"isUpdatePending": {Func: rpcIsUpdatePending},
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
"getVirtualMediaState": {Func: rpcGetVirtualMediaState},
"getStorageSpace": {Func: rpcGetStorageSpace},
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
"mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
"listStorageFiles": {Func: rpcListStorageFiles},
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},
"startStorageFileUpload": {Func: rpcStartStorageFileUpload, Params: []string{"filename", "size"}},
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
"resetConfig": {Func: rpcResetConfig},
}

View File

@ -24,6 +24,7 @@ type SelectMenuProps = Pick<
const sizes = {
XS: "h-[24.5px] pl-3 pr-8 text-xs",
SM: "h-[32px] pl-3 pr-8 text-[13px]",
SM_Wide: "h-[32px] pl-3 pr-8 mr-5 text-[13px]",
MD: "h-[40px] pl-4 pr-10 text-sm",
LG: "h-[48px] pl-4 pr-10 px-5 text-base",
};

View File

@ -17,6 +17,10 @@ import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { ConnectionErrorOverlay, HDMIErrorOverlay, LoadingOverlay } from "./VideoOverlay";
// TODO Implement keyboard lock API to resolve #127
// https://developer.chrome.com/docs/capabilities/web-apis/keyboard-lock
// An appropriate error message will need to be displayed in order to alert users to browser compatibility issues.
export default function WebRTCVideo() {
const [keys, setKeys] = useState(useKeyboardMappingsStore.keys);
const [chars, setChars] = useState(useKeyboardMappingsStore.chars);
@ -155,8 +159,6 @@ export default function WebRTCVideo() {
// Invert the scroll value to match expected behavior
const invertedScroll = -roundedScroll;
// TODO remove debug logs
console.log("wheelReport", { wheelY: invertedScroll });
send("wheelReport", { wheelY: invertedScroll });
// TODO this is making scrolling feel slow and sluggish, also throwing a violation in chrome
@ -179,7 +181,7 @@ export default function WebRTCVideo() {
// TODO remove debug logging
console.log(shiftKey + " " +ctrlKey + " " +altKey + " " +metaKey + " " +mappedKeyModifers.shift + " "+mappedKeyModifers.altLeft + " "+mappedKeyModifers.altRight + " ")
const filteredModifiers = activeModifiers.filter(Boolean);3
const filteredModifiers = activeModifiers.filter(Boolean);
// Example: activeModifiers = [0x01, 0x02, 0x04, 0x08]
// Assuming 0x01 = ControlLeft, 0x02 = ShiftLeft, 0x04 = AltLeft, 0x08 = MetaLeft
return (
@ -210,8 +212,13 @@ export default function WebRTCVideo() {
modifier =>
altKey ||
mappedKeyModifers.altLeft ||
(modifier !== modifiers["AltLeft"]),
)
.filter(
modifier =>
altKey ||
mappedKeyModifers.altRight ||
(modifier !== modifiers["AltLeft"] && modifier !== modifiers["AltRight"]),
(modifier !== modifiers["AltRight"])
)
// Meta: Keep if Meta is pressed or if the key isn't a Meta key
// Example: If metaKey is true, keep all modifiers
@ -230,8 +237,9 @@ export default function WebRTCVideo() {
async (e: KeyboardEvent) => {
e.preventDefault();
const prev = useHidStore.getState();
let code = e.code;
const localisedKey = e.key;
const code = e.code;
console.log("MAPPING ENABLED: " + settings.keyboardMappingEnabled)
var localisedKey = settings.keyboardMappingEnabled ? e.key : code;
console.log(e);
console.log("Localised Key: " + localisedKey);
@ -282,12 +290,12 @@ export default function WebRTCVideo() {
// event, so we need to clear the keys after a short delay
// https://bugs.chromium.org/p/chromium/issues/detail?id=28089
// https://bugzilla.mozilla.org/show_bug.cgi?id=1299553
// TODO add this to the activekey state
// TODO set this to remove from activekeystate as well
if (e.metaKey) {
setTimeout(() => {
const prev = useHidStore.getState();
sendKeyboardEvent([], newModifiers || prev.activeModifiers);
activeKeyState.current.delete("MetaLeft");
activeKeyState.current.delete("MetaRight");
}, 10);
}
@ -302,6 +310,7 @@ export default function WebRTCVideo() {
chars,
keys,
modifiers,
settings,
],
);

View File

@ -79,7 +79,7 @@ export default function PasteModal() {
} catch (error) {
notifications.error("Failed to paste text");
}
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode]);
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, chars, keys, modifiers]);
useEffect(() => {
if (TextAreaRef.current) {
@ -144,6 +144,9 @@ export default function PasteModal() {
The following characters won&apos;t be pasted as the current keyboard layout does not contain a valid mapping:{" "}
{invalidChars.join(", ")}
</span>
<span className="text-xs text-red-500 dark:text-red-400">
Tip: You can set your desired keyboard layout in settings, and remember to enable keyboard mapping.
</span>
</div>
)}
</div>

View File

@ -79,6 +79,7 @@ export default function SettingsSidebar() {
const settings = useSettingsStore();
const [send] = useJsonRpc();
const [keyboardLayout, setKeyboardLayout] = useState("us");
const [kbMappingEnabled, setKeyboardMapping] = useState(false);
const [streamQuality, setStreamQuality] = useState("1");
const [autoUpdate, setAutoUpdate] = useState(true);
const [devChannel, setDevChannel] = useState(false);
@ -161,6 +162,20 @@ export default function SettingsSidebar() {
});
};
const handleKeyboardMappingChange = (enabled: boolean) => {
send("setKeyboardMappingState", { enabled }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set keyboard maping state state: ${resp.error.data || "Unknown error"}`,
);
return;
}
settings.setkeyboardMappingEnabled(enabled);
useKeyboardMappingsStore.setMappingsState(enabled);
setKeyboardMapping(enabled);
});
};
const handleStreamQualityChange = (factor: string) => {
send("setStreamQualityFactor", { factor: Number(factor) }, resp => {
if ("error" in resp) {
@ -295,6 +310,13 @@ export default function SettingsSidebar() {
useKeyboardMappingsStore.setLayout(String(resp.result))
});
send("getKeyboardMappingState", {}, resp => {
if ("error" in resp) return;
setKeyboardMapping(resp.result as boolean);
settings.setkeyboardMappingEnabled(resp.result as boolean);
useKeyboardMappingsStore.setMappingsState(resp.result as boolean);
});
send("getStreamQualityFactor", {}, resp => {
if ("error" in resp) return;
setStreamQuality(String(resp.result));
@ -536,20 +558,32 @@ export default function SettingsSidebar() {
description="Customize keyboard behaviour"
/>
<div className="space-y-4">
<SettingsItem
title="Enable Keyboard Mapping"
description="Enables mapping of keys from your native layout to the layout of the target device"
>
<Checkbox
checked={kbMappingEnabled}
onChange={e => {
handleKeyboardMappingChange(e.target.checked);
}}
/>
</SettingsItem>
<SettingsItem
title="Keyboard Layout"
description="Set keyboard layout (this should match the target machine)"
>
<SelectMenuBasic
size="SM"
size="SM_Wide"
label=""
// TODO figure out how to make this selector wider like the EDID one?
//fullWidthƒ
// TODO figure out how to make this selector wider like the EDID one?, (done but not sure if in desired way.)
//fullWidth
value={keyboardLayout}
options={[
{ value: "uk", label: "GB" },
{ value: "uk_apple", label: "GB Apple" },
{ value: "us", label: "US" },
{ value: "uk", label: "UK" },
{ value: "uk_apple", label: "UK (Apple)" },
{ value: "de_t1", label: "German (T1)" },
]}
onChange={e => handleKeyboardLayoutChange(e.target.value)}
/>

View File

@ -265,6 +265,9 @@ interface SettingsState {
mouseMode: string;
setMouseMode: (mode: string) => void;
keyboardMappingEnabled: boolean;
setkeyboardMappingEnabled: (enabled: boolean) => void;
debugMode: boolean;
setDebugMode: (enabled: boolean) => void;
@ -276,6 +279,9 @@ interface SettingsState {
export const useSettingsStore = create(
persist<SettingsState>(
set => ({
keyboardMappingEnabled: false,
setkeyboardMappingEnabled: enabled => set({keyboardMappingEnabled: enabled}),
isCursorHidden: false,
setCursorVisibility: enabled => set({ isCursorHidden: enabled }),
@ -535,18 +541,42 @@ export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({
class KeyboardMappingsStore {
private _layout: string = 'us';
private _subscribers: (() => void)[] = [];
private _mappingsEnabled: boolean = false;
public keys = getKeyboardMappings(this._layout).keys;
public chars = getKeyboardMappings(this._layout).chars;
public modifiers = getKeyboardMappings(this._layout).modifiers;
private mappedKeys = getKeyboardMappings(this._layout).keys;
private mappedChars = getKeyboardMappings(this._layout).chars;
private mappedModifiers = getKeyboardMappings(this._layout).modifiers;
setLayout(newLayout: string) {
if (this._layout === newLayout) return;
this._layout = newLayout;
const updatedMappings = getKeyboardMappings(newLayout);
this.keys = updatedMappings.keys;
this.chars = updatedMappings.chars;
this.modifiers = updatedMappings.modifiers;
this.mappedKeys = updatedMappings.keys;
this.mappedChars = updatedMappings.chars;
this.mappedModifiers = updatedMappings.modifiers;
if (this._mappingsEnabled) {
this.keys = this.mappedKeys;
this.chars = this.mappedChars;
this.modifiers = this.mappedModifiers;
this._notifySubscribers();
}
}
setMappingsState(enabled: boolean) {
this._mappingsEnabled = enabled;
if (this._mappingsEnabled) {
this.keys = this.mappedKeys;
this.chars = this.mappedChars;
this.modifiers = this.mappedModifiers;
} else {
this.keys = getKeyboardMappings('us').keys;
this.chars = getKeyboardMappings('us').chars;
this.modifiers = getKeyboardMappings('us').modifiers;
}
this._notifySubscribers();
}

View File

@ -1,5 +1,7 @@
import {keysUKApple, charsUKApple, modifiersUKApple } from './layouts/uk_apple';
import {keysUK, charsUK, modifiersUK } from './layouts/uk';
import {keysUS, charsUS, modifiersUS } from './layouts/us';
import { keysDE_T1, charsDE_T1, modifiersDE_T1 } from './layouts/de_t1';
export function getKeyboardMappings(layout: string) {
switch (layout) {
@ -9,6 +11,18 @@ export function getKeyboardMappings(layout: string) {
chars: charsUKApple,
modifiers: modifiersUKApple,
};
case "uk":
return {
keys: keysUK,
chars: charsUK,
modifiers: modifiersUK,
};
case "de_t1":
return {
keys: keysDE_T1,
chars: charsDE_T1,
modifiers: modifiersDE_T1,
};
case "us":
default:
return {

View File

@ -0,0 +1,69 @@
import { charsUS, keysUS, modifiersUS } from "./us";
export const keysDE_T1 = {
...keysUS,
} as Record<string, number>;
export const charsDE_T1 = {
...charsUS,
"y": { key: "KeyZ", shift: false },
"Y": { key: "KeyZ", shift: true },
"z": { key: "KeyY", shift: false },
"Z": { key: "KeyY", shift: true },
"ä": { key: "Quote", shift: false },
"Ä": { key: "Quote", shift: true },
"ö": { key: "Semicolon", shift: false },
"Ö": { key: "Semicolon", shift: true },
"ü": { key: "BracketLeft", shift: false },
"Ü": { key: "BracketLeft", shift: true },
"ß": { key: "Minus", shift: false },
"?": { key: "Minus", shift: true },
"§": { key: "Digit3", shift: true },
"°": { key: "Backquote", shift: true },
"@": { key: "KeyQ", shift: false, altRight: true },
"\"": { key: "Digit2", shift: true },
"#": { key: "Backslash", shift: false },
"'": { key: "Backslash", shift: true },
".": { key: "Period", shift: false },
":": { key: "Period", shift: true },
",": { key: "Comma", shift: false },
";": { key: "Comma", shift: true },
"-": { key: "Slash", shift: false },
"_": { key: "Slash", shift: true },
"*": { key: "BracketRight", shift: true },
"+": { key: "BracketRight", shift: false },
"=": { key: "Digit0", shift: true },
"~": { key: "BracketRight", shift: false, altRight: true },
"{": { key: "Digit7", shift: false, altRight: true },
"}": { key: "Digit0", shift: false, altRight: true },
"[": { key: "Digit8", shift: false, altRight: true },
"]": { key: "Digit9", shift: false, altRight: true },
"\\": { key: "Minus", shift: false, altRight: true },
"|": { key: "IntlBackslash", shift: true, altRight: true },
"<": { key: "IntlBackslash", shift: false },
">": { key: "IntlBackslash", shift: true },
"^": {key: "Backquote", shift: false},
"€": { key: "KeyE", shift: false, altRight: true },
"²": {key: "Digit2", shift: false, altRight: true },
"³": {key: "Digit3", shift: false, altRight: true },
"μ": {key: "KeyM", shift: false, altRight: true },
} as Record<string, { key: string; shift: boolean; altLeft?: boolean; altRight?: boolean }>;
export const modifiersDE_T1 = {
...modifiersUS,
} as Record<string, number>;

View File

@ -0,0 +1,24 @@
import { charsUS, keysUS, modifiersUS } from "./us";
export const keysUK = {
...keysUS,
} as Record<string, number>;
export const charsUK = {
...charsUS,
"`": { key: "Backquote", shift: false },
"~": { key: "Backslash", shift: true },
"\\": { key: "IntlBacklash", shift: false },
"|": { key: "IntlBacklash", shift: true },
"#": { key: "Backslash", shift: false },
"£": { key: "Digit3", shift: true },
"@": { key: "Quote", shift: true },
"\"": { key: "Digit2", shift: true },
"¬": { key: "Backquote", shift: true },
"¦": { key: "Backquote", shift: false, altRight: true },
"€": { key: "Digit4", shift: false, altRight: true },
} as Record<string, { key: string; shift: boolean; altLeft?: boolean; altRight?: boolean; }>;
export const modifiersUK = {
...modifiersUS,
} as Record<string, number>;

View File

@ -16,6 +16,7 @@ export const charsUKApple = {
"£": { key: "Digit3", shift: true },
"@": { key: "Digit2", shift: true },
"\"": { key: "Quote", shift: true },
"¬": { key: "KeyL", shift: false, altLeft: true},
} as Record<string, { key: string; shift: boolean; altLeft?: boolean; altRight?: boolean; }>;
// Modifiers are typically the same between UK and US layouts