From 05bf61152bbf0a54235aa62e759170dc0f0538cf Mon Sep 17 00:00:00 2001 From: Marc Brooks Date: Tue, 26 Aug 2025 09:55:08 -0500 Subject: [PATCH 01/13] feature/Faster loading (#746) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feature/Faster loading This refactors all the hot-path components for an already-setup JetKVM so that we only lazy-load the components off the main path. This greatly reduces the initial .JS size at initial page load from a single file of dist/assets/index-D4LZBdmN.js 1,969.46 kB │ gzip: 570.08 kB To these files, of which the hot-path only loads the 963.29 kB index for a savings of just over a megabyte (180kb savings in gzip). dist/assets/login-DA9KVVX1.js 0.64 kB │ gzip: 0.40 kB dist/assets/signup-Bb_VCzY1.js 0.67 kB │ gzip: 0.40 kB dist/assets/devices._id.settings.macros.add-DpBnq5E0.js 0.82 kB │ gzip: 0.55 kB dist/assets/devices._id.settings.appearance-VHd5B2H2.js 0.91 kB │ gzip: 0.52 kB dist/assets/devices._id.settings.general.reboot-DsRBP5Dd.js 1.01 kB │ gzip: 0.52 kB dist/assets/UpdateInProgressStatusCard-DJCdJo-z.js 1.05 kB │ gzip: 0.54 kB dist/assets/devices._id.other-session-BpXjEP6K.js 1.09 kB │ gzip: 0.56 kB dist/assets/devices.already-adopted-BC1xoKrN.js 1.16 kB │ gzip: 0.57 kB dist/assets/Checkbox-DGO277w5.js 1.24 kB │ gzip: 0.64 kB dist/assets/devices._id.settings.keyboard-Cno0kaUr.js 1.59 kB │ gzip: 0.81 kB dist/assets/devices._id.settings.general._index-CNW0Pj5B.js 1.71 kB │ gzip: 0.76 kB dist/assets/devices._id.settings.macros.edit-BYQGw2CJ.js 1.92 kB │ gzip: 1.00 kB dist/assets/ConfirmDialog-lzerZkf7.js 2.77 kB │ gzip: 1.13 kB dist/assets/AuthLayout-H4vGP3TU.js 2.96 kB │ gzip: 1.41 kB dist/assets/AutoHeight-B-TU1fRg.js 4.07 kB │ gzip: 1.63 kB dist/assets/devices._id.settings.video-O3qJWstQ.js 5.68 kB │ gzip: 2.17 kB dist/assets/devices._id.settings.advanced-Drd_iPzw.js 5.98 kB │ gzip: 2.08 kB dist/assets/devices._id.settings.macros-D3unB0uf.js 6.05 kB │ gzip: 2.13 kB dist/assets/devices._id.settings.access.local-auth-BltQI66N.js 6.17 kB │ gzip: 1.54 kB dist/assets/devices._id.settings.mouse-CAwDHqxl.js 10.02 kB │ gzip: 3.59 kB dist/assets/devices._id.settings.general.update-jkzXML1U.js 10.22 kB │ gzip: 2.67 kB dist/assets/devices._id.settings.hardware-B7v3lfwA.js 10.41 kB │ gzip: 3.03 kB dist/assets/devices._id.settings.network-CJYfzFt2.js 25.23 kB │ gzip: 7.21 kB dist/assets/devices._id.mount-4AT1reig.js 43.92 kB │ gzip: 19.81 kB dist/assets/MacroForm-BQpdQgFn.js 49.75 kB │ gzip: 16.25 kB dist/assets/connectionStats-NM-PZeH3.js 400.14 kB │ gzip: 110.33 kB dist/assets/Terminal-Dgo3sfr-.js 425.05 kB │ gzip: 109.49 kB dist/assets/index-w6H2Mz3f.js 963.29 kB │ gzip: 294.20 kB * Remove feral async declarations on things that have no await --- ui/src/components/UsbDeviceSetting.tsx | 2 +- ui/src/components/UsbInfoSetting.tsx | 2 +- ui/src/components/WebRTCVideo.tsx | 2 +- ui/src/main.tsx | 78 +++++++++---------- ui/src/routes/devices.$id.mount.tsx | 4 +- ui/src/routes/devices.$id.settings._index.tsx | 8 +- .../devices.$id.settings.access._index.tsx | 4 +- .../devices.$id.settings.general.update.tsx | 4 +- ui/src/routes/devices.$id.settings.mouse.tsx | 6 +- ui/src/routes/devices.$id.tsx | 36 ++++----- 10 files changed, 75 insertions(+), 71 deletions(-) diff --git a/ui/src/components/UsbDeviceSetting.tsx b/ui/src/components/UsbDeviceSetting.tsx index 8b68f164..2663674c 100644 --- a/ui/src/components/UsbDeviceSetting.tsx +++ b/ui/src/components/UsbDeviceSetting.tsx @@ -127,7 +127,7 @@ export function UsbDeviceSetting() { ); const handlePresetChange = useCallback( - async (e: React.ChangeEvent) => { + (e: React.ChangeEvent) => { const newPreset = e.target.value; setSelectedPreset(newPreset); diff --git a/ui/src/components/UsbInfoSetting.tsx b/ui/src/components/UsbInfoSetting.tsx index 1d6a4550..cc837f4a 100644 --- a/ui/src/components/UsbInfoSetting.tsx +++ b/ui/src/components/UsbInfoSetting.tsx @@ -137,7 +137,7 @@ export function UsbInfoSetting() { ); useEffect(() => { - send("getDeviceID", {}, async (resp: JsonRpcResponse) => { + send("getDeviceID", {}, (resp: JsonRpcResponse) => { if ("error" in resp) { return notifications.error( `Failed to get device ID: ${resp.error.data || "Unknown error"}`, diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index b24ced1b..21d4aca5 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -411,7 +411,7 @@ export default function WebRTCVideo() { ); const keyDownHandler = useCallback( - async (e: KeyboardEvent) => { + (e: KeyboardEvent) => { e.preventDefault(); const prev = useHidStore.getState(); let code = e.code; diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 6fb57389..53746580 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -1,3 +1,4 @@ +import { lazy } from "react"; import ReactDOM from "react-dom/client"; import "./index.css"; import { @@ -9,46 +10,45 @@ import { } from "react-router-dom"; import { ExclamationTriangleIcon } from "@heroicons/react/16/solid"; +import { CLOUD_API, DEVICE_API } from "@/ui.config"; +import api from "@/api"; +import Root from "@/root"; +import Card from "@components/Card"; import EmptyCard from "@components/EmptyCard"; import NotFoundPage from "@components/NotFoundPage"; +import DeviceRoute, { LocalDevice } from "@routes/devices.$id"; +import WelcomeRoute, { DeviceStatus } from "@routes/welcome-local"; +import LoginLocalRoute from "@routes/login-local"; +import WelcomeLocalModeRoute from "@routes/welcome-local.mode"; +import WelcomeLocalPasswordRoute from "@routes/welcome-local.password"; +import AdoptRoute from "@routes/adopt"; +import SetupRoute from "@routes/devices.$id.setup"; import DevicesIdDeregister from "@routes/devices.$id.deregister"; import DeviceIdRename from "@routes/devices.$id.rename"; -import AdoptRoute from "@routes/adopt"; -import SignupRoute from "@routes/signup"; -import LoginRoute from "@routes/login"; -import SetupRoute from "@routes/devices.$id.setup"; import DevicesRoute from "@routes/devices"; -import DeviceRoute, { LocalDevice } from "@routes/devices.$id"; -import Card from "@components/Card"; -import DevicesAlreadyAdopted from "@routes/devices.already-adopted"; - -import Root from "./root"; -import Notifications from "./notifications"; -import LoginLocalRoute from "./routes/login-local"; -import WelcomeLocalModeRoute from "./routes/welcome-local.mode"; -import WelcomeRoute, { DeviceStatus } from "./routes/welcome-local"; -import WelcomeLocalPasswordRoute from "./routes/welcome-local.password"; -import { CLOUD_API, DEVICE_API } from "./ui.config"; -import OtherSessionRoute from "./routes/devices.$id.other-session"; -import MountRoute from "./routes/devices.$id.mount"; -import * as SettingsRoute from "./routes/devices.$id.settings"; -import SettingsMouseRoute from "./routes/devices.$id.settings.mouse"; -import SettingsKeyboardRoute from "./routes/devices.$id.settings.keyboard"; -import api from "./api"; -import * as SettingsIndexRoute from "./routes/devices.$id.settings._index"; -import SettingsAdvancedRoute from "./routes/devices.$id.settings.advanced"; -import SettingsAccessIndexRoute from "./routes/devices.$id.settings.access._index"; -import SettingsHardwareRoute from "./routes/devices.$id.settings.hardware"; -import SettingsVideoRoute from "./routes/devices.$id.settings.video"; -import SettingsAppearanceRoute from "./routes/devices.$id.settings.appearance"; -import * as SettingsGeneralIndexRoute from "./routes/devices.$id.settings.general._index"; -import SettingsGeneralRebootRoute from "./routes/devices.$id.settings.general.reboot"; -import SettingsGeneralUpdateRoute from "./routes/devices.$id.settings.general.update"; -import SettingsNetworkRoute from "./routes/devices.$id.settings.network"; -import SecurityAccessLocalAuthRoute from "./routes/devices.$id.settings.access.local-auth"; -import SettingsMacrosRoute from "./routes/devices.$id.settings.macros"; -import SettingsMacrosAddRoute from "./routes/devices.$id.settings.macros.add"; -import SettingsMacrosEditRoute from "./routes/devices.$id.settings.macros.edit"; +import SettingsIndexRoute from "@routes/devices.$id.settings._index"; +import SettingsAccessIndexRoute from "@routes/devices.$id.settings.access._index"; +const Notifications = lazy(() => import("@/notifications")); +const SignupRoute = lazy(() => import("@routes/signup")); +const LoginRoute = lazy(() => import("@routes/login")); +const DevicesAlreadyAdopted = lazy(() => import("@routes/devices.already-adopted")); +const OtherSessionRoute = lazy(() => import("@routes/devices.$id.other-session")); +const MountRoute = lazy(() => import("./routes/devices.$id.mount")); +const SettingsRoute = lazy(() => import("@routes/devices.$id.settings")); +const SettingsMouseRoute = lazy(() => import("@routes/devices.$id.settings.mouse")); +const SettingsKeyboardRoute = lazy(() => import("@routes/devices.$id.settings.keyboard")); +const SettingsAdvancedRoute = lazy(() => import("@routes/devices.$id.settings.advanced")); +const SettingsHardwareRoute = lazy(() => import("@routes/devices.$id.settings.hardware")); +const SettingsVideoRoute = lazy(() => import("@routes/devices.$id.settings.video")); +const SettingsAppearanceRoute = lazy(() => import("@routes/devices.$id.settings.appearance")); +const SettingsGeneralIndexRoute = lazy(() => import("@routes/devices.$id.settings.general._index")); +const SettingsGeneralRebootRoute = lazy(() => import("@routes/devices.$id.settings.general.reboot")); +const SettingsGeneralUpdateRoute = lazy(() => import("@routes/devices.$id.settings.general.update")); +const SettingsNetworkRoute = lazy(() => import("@routes/devices.$id.settings.network")); +const SecurityAccessLocalAuthRoute = lazy(() => import("@routes/devices.$id.settings.access.local-auth")); +const SettingsMacrosRoute = lazy(() => import("@routes/devices.$id.settings.macros")); +const SettingsMacrosAddRoute = lazy(() => import("@routes/devices.$id.settings.macros.add")); +const SettingsMacrosEditRoute = lazy(() => import("@routes/devices.$id.settings.macros.edit")); export const isOnDevice = import.meta.env.MODE === "device"; export const isInCloud = !isOnDevice; @@ -128,7 +128,7 @@ if (isOnDevice) { }, { path: "settings", - element: , + element: , children: [ { index: true, @@ -139,7 +139,7 @@ if (isOnDevice) { children: [ { index: true, - element: , + element: , }, { path: "reboot", @@ -265,7 +265,7 @@ if (isOnDevice) { }, { path: "settings", - element: , + element: , children: [ { index: true, @@ -276,7 +276,7 @@ if (isOnDevice) { children: [ { index: true, - element: , + element: , }, { path: "update", diff --git a/ui/src/routes/devices.$id.mount.tsx b/ui/src/routes/devices.$id.mount.tsx index c0179206..295429f1 100644 --- a/ui/src/routes/devices.$id.mount.tsx +++ b/ui/src/routes/devices.$id.mount.tsx @@ -89,7 +89,7 @@ export function Dialog({ onClose }: { onClose: () => void }) { console.log(`Mounting ${url} as ${mode}`); setMountInProgress(true); - send("mountWithHTTP", { url, mode }, async (resp: JsonRpcResponse) => { + send("mountWithHTTP", { url, mode }, (resp: JsonRpcResponse) => { if ("error" in resp) triggerError(resp.error.message); clearMountMediaState(); @@ -108,7 +108,7 @@ export function Dialog({ onClose }: { onClose: () => void }) { console.log(`Mounting ${fileName} as ${mode}`); setMountInProgress(true); - send("mountWithStorage", { filename: fileName, mode }, async (resp: JsonRpcResponse) => { + send("mountWithStorage", { filename: fileName, mode }, (resp: JsonRpcResponse) => { if ("error" in resp) triggerError(resp.error.message); clearMountMediaState(); diff --git a/ui/src/routes/devices.$id.settings._index.tsx b/ui/src/routes/devices.$id.settings._index.tsx index 603efeca..4fb35eda 100644 --- a/ui/src/routes/devices.$id.settings._index.tsx +++ b/ui/src/routes/devices.$id.settings._index.tsx @@ -2,6 +2,12 @@ import { LoaderFunctionArgs, redirect } from "react-router-dom"; import { getDeviceUiPath } from "../hooks/useAppNavigation"; -export function loader({ params }: LoaderFunctionArgs) { +const loader = ({ params }: LoaderFunctionArgs) => { return redirect(getDeviceUiPath("/settings/general", params.id)); } + +export default function SettingIndexRoute() { + return (<>); +} + +SettingIndexRoute.loader = loader; \ No newline at end of file diff --git a/ui/src/routes/devices.$id.settings.access._index.tsx b/ui/src/routes/devices.$id.settings.access._index.tsx index b3e0c9a0..52c6fc6b 100644 --- a/ui/src/routes/devices.$id.settings.access._index.tsx +++ b/ui/src/routes/devices.$id.settings.access._index.tsx @@ -87,7 +87,7 @@ export default function SettingsAccessIndexRoute() { }); }, [send]); - const deregisterDevice = async () => { + const deregisterDevice = () => { send("deregisterDevice", {}, (resp: JsonRpcResponse) => { if ("error" in resp) { notifications.error( @@ -198,7 +198,7 @@ export default function SettingsAccessIndexRoute() { getCloudState(); getTLSState(); - send("getDeviceID", {}, async (resp: JsonRpcResponse) => { + send("getDeviceID", {}, (resp: JsonRpcResponse) => { if ("error" in resp) return console.error(resp.error); setDeviceId(resp.result as string); }); diff --git a/ui/src/routes/devices.$id.settings.general.update.tsx b/ui/src/routes/devices.$id.settings.general.update.tsx index 16b33786..a641cbcc 100644 --- a/ui/src/routes/devices.$id.settings.general.update.tsx +++ b/ui/src/routes/devices.$id.settings.general.update.tsx @@ -62,7 +62,7 @@ export function Dialog({ const { modalView, setModalView, otaState } = useUpdateStore(); const onFinishedLoading = useCallback( - async (versionInfo: SystemVersionInfo) => { + (versionInfo: SystemVersionInfo) => { const hasUpdate = versionInfo?.systemUpdateAvailable || versionInfo?.appUpdateAvailable; @@ -141,7 +141,7 @@ function LoadingState({ const getVersionInfo = useCallback(() => { return new Promise((resolve, reject) => { - send("getUpdateStatus", {}, async (resp: JsonRpcResponse) => { + send("getUpdateStatus", {}, (resp: JsonRpcResponse) => { if ("error" in resp) { notifications.error(`Failed to check for updates: ${resp.error}`); reject(new Error("Failed to check for updates")); diff --git a/ui/src/routes/devices.$id.settings.mouse.tsx b/ui/src/routes/devices.$id.settings.mouse.tsx index 42d6fae6..ab1aec60 100644 --- a/ui/src/routes/devices.$id.settings.mouse.tsx +++ b/ui/src/routes/devices.$id.settings.mouse.tsx @@ -121,7 +121,7 @@ export default function SettingsMouseRoute() { const saveJigglerConfig = useCallback( (jigglerConfig: JigglerConfig) => { // We assume the jiggler should be set to enabled if the config is being updated - send("setJigglerState", { enabled: true }, async (resp: JsonRpcResponse) => { + send("setJigglerState", { enabled: true }, (resp: JsonRpcResponse) => { if ("error" in resp) { return notifications.error( `Failed to set jiggler state: ${resp.error.data || "Unknown error"}`, @@ -129,7 +129,7 @@ export default function SettingsMouseRoute() { } }); - send("setJigglerConfig", { jigglerConfig }, async (resp: JsonRpcResponse) => { + send("setJigglerConfig", { jigglerConfig }, (resp: JsonRpcResponse) => { if ("error" in resp) { const errorMsg = resp.error.data || "Unknown error"; @@ -163,7 +163,7 @@ export default function SettingsMouseRoute() { // We don't need to update the device jiggler state when the option is "disabled" if (option === "disabled") { - send("setJigglerState", { enabled: false }, async (resp: JsonRpcResponse) => { + send("setJigglerState", { enabled: false }, (resp: JsonRpcResponse) => { if ("error" in resp) { return notifications.error( `Failed to set jiggler state: ${resp.error.data || "Unknown error"}`, diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index a4ecf3d8..1017eb43 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { LoaderFunctionArgs, Outlet, @@ -16,7 +16,11 @@ import { FocusTrap } from "focus-trap-react"; import { motion, AnimatePresence } from "framer-motion"; import useWebSocket from "react-use-websocket"; +import { CLOUD_API, DEVICE_API } from "@/ui.config"; +import api from "@/api"; +import { checkAuth, isInCloud, isOnDevice } from "@/main"; import { cx } from "@/cva.config"; +import notifications from "@/notifications"; import { HidState, KeyboardLedState, @@ -34,27 +38,21 @@ import { VideoState, } from "@/hooks/stores"; import WebRTCVideo from "@components/WebRTCVideo"; -import { checkAuth, isInCloud, isOnDevice } from "@/main"; import DashboardNavbar from "@components/Header"; -import ConnectionStatsSidebar from "@/components/sidebar/connectionStats"; +const ConnectionStatsSidebar = lazy(() => import('@/components/sidebar/connectionStats')); +const Terminal = lazy(() => import('@components/Terminal')); +const UpdateInProgressStatusCard = lazy(() => import("@/components/UpdateInProgressStatusCard")); +import Modal from "@/components/Modal"; import { JsonRpcRequest, JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; -import Terminal from "@components/Terminal"; -import { CLOUD_API, DEVICE_API } from "@/ui.config"; - -import UpdateInProgressStatusCard from "../components/UpdateInProgressStatusCard"; -import api from "../api"; -import Modal from "../components/Modal"; -import { useDeviceUiNavigation } from "../hooks/useAppNavigation"; import { ConnectionFailedOverlay, LoadingConnectionOverlay, PeerConnectionDisconnectedOverlay, -} from "../components/VideoOverlay"; -import { FeatureFlagProvider } from "../providers/FeatureFlagProvider"; -import notifications from "../notifications"; - -import { DeviceStatus } from "./welcome-local"; -import { SystemVersionInfo } from "./devices.$id.settings.general.update"; +} from "@/components/VideoOverlay"; +import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; +import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider"; +import { DeviceStatus } from "@routes/welcome-local"; +import { SystemVersionInfo } from "@routes/devices.$id.settings.general.update"; interface LocalLoaderResp { authMode: "password" | "noPassword" | null; @@ -114,7 +112,7 @@ const cloudLoader = async (params: Params): Promise => return { user, iceConfig, deviceName: device.name || device.id }; }; -const loader = async ({ params }: LoaderFunctionArgs) => { +const loader = ({ params }: LoaderFunctionArgs) => { return import.meta.env.MODE === "device" ? deviceLoader() : cloudLoader(params); }; @@ -452,7 +450,7 @@ export default function KvmIdRoute() { } }; - pc.onicecandidate = async ({ candidate }) => { + pc.onicecandidate = ({ candidate }) => { if (!candidate) return; if (candidate.candidate === "") return; sendWebRTCSignal("new-ice-candidate", candidate); @@ -735,7 +733,7 @@ export default function KvmIdRoute() { useEffect(() => { if (appVersion) return; - send("getUpdateStatus", {}, async (resp: JsonRpcResponse) => { + send("getUpdateStatus", {}, (resp: JsonRpcResponse) => { if ("error" in resp) { notifications.error(`Failed to get device version: ${resp.error}`); return From 3ec243255b08bc47a02e75ff12a10144f3ec2924 Mon Sep 17 00:00:00 2001 From: Marc Brooks Date: Tue, 26 Aug 2025 10:09:35 -0500 Subject: [PATCH 02/13] Add ability to track modifier state on the device (#725) Remove LED sync source option and add keypress reporting while still working with devices that haven't been upgraded We return the modifiers as the valid bitmask so that the VirtualKeyboard and InfoBar can represent the correct keys as down. This is important when we have strokes like Left-Control + Right-Control + Keypad-1 (used in switching KVMs and such). Fix handling of modifier keys in client and also removed the extraneous resetKeyboardState. Manage state to eliminate rerenders by judicious use of useMemo. Centralized keyboard layout and localized display maps Move keyboardOptions to useKeyboardLayouts Added translations for display maps. Add documentation on the legacy support. Return the KeysDownState from keyboardReport Clear out the hidErrorRollOver once sent to reset the keyboard to nothing down. Handles the returned KeysDownState from keyboardReport Now passes all logic through handleKeyPress. If we get a state back from a keyboardReport, use it and also enable keypressReport because we now know it's an upgraded device. Added exposition on isoCode management Fix de-DE chars to reflect German E2 keyboard. https://kbdlayout.info/kbdgre2/overview+virtualkeys Ran go modernize Morphs Interface{} to any Ranges over SplitSeq and FieldSeq for iterating splits Used min for end calculation remote_mount.Read Used range 16 in wol.createMagicPacket DID NOT apply the Omitempty cleanup. Strong typed in the typescript realm. Cleanup react state management to enable upgrading Zustand --- config.go | 2 +- display.go | 20 +- internal/confparser/confparser.go | 20 +- internal/confparser/utils.go | 2 +- internal/logging/logger.go | 6 +- internal/logging/pion.go | 10 +- internal/logging/utils.go | 2 +- internal/network/hostname.go | 2 +- internal/network/utils.go | 2 +- internal/udhcpc/parser.go | 4 +- internal/udhcpc/udhcpc.go | 2 +- internal/usbgadget/hid_keyboard.go | 204 +++- internal/usbgadget/hid_mouse_absolute.go | 14 +- internal/usbgadget/hid_mouse_relative.go | 10 +- internal/usbgadget/usbgadget.go | 13 +- internal/usbgadget/utils.go | 28 +- jsonrpc.go | 120 ++- log.go | 2 +- native.go | 22 +- remote_mount.go | 5 +- ui/eslint.config.cjs | 4 + ui/package-lock.json | 985 +++++++++--------- ui/package.json | 24 +- ui/src/components/ActionBar.tsx | 33 +- ui/src/components/Header.tsx | 2 +- ui/src/components/InfoBar.tsx | 92 +- ui/src/components/MacroForm.tsx | 9 +- ui/src/components/MacroStepCard.tsx | 35 +- ui/src/components/Terminal.tsx | 19 +- ui/src/components/USBStateStatus.tsx | 6 +- ui/src/components/UsbInfoSetting.tsx | 2 +- ui/src/components/VirtualKeyboard.tsx | 231 ++-- ui/src/components/WebRTCVideo.tsx | 351 +++---- .../components/extensions/SerialConsole.tsx | 2 +- ui/src/components/popovers/MountPopover.tsx | 2 +- ui/src/components/popovers/PasteModal.tsx | 45 +- .../components/popovers/WakeOnLan/Index.tsx | 6 +- ui/src/components/sidebar/connectionStats.tsx | 33 +- ui/src/hooks/stores.ts | 326 +++--- ui/src/hooks/useJsonRpc.ts | 11 +- ui/src/hooks/useKeyboard.ts | 204 +++- ui/src/hooks/useKeyboardLayout.ts | 35 + ui/src/index.css | 5 + ui/src/keyboardLayouts.ts | 27 +- ui/src/keyboardLayouts/cs_CZ.ts | 33 +- ui/src/keyboardLayouts/de_CH.ts | 33 +- ui/src/keyboardLayouts/de_DE.ts | 295 +++++- ui/src/keyboardLayouts/en_UK.ts | 11 +- ui/src/keyboardLayouts/en_US.ts | 220 +++- ui/src/keyboardLayouts/es_ES.ts | 23 +- ui/src/keyboardLayouts/fr_BE.ts | 23 +- ui/src/keyboardLayouts/fr_CH.ts | 19 +- ui/src/keyboardLayouts/fr_FR.ts | 17 +- ui/src/keyboardLayouts/it_IT.ts | 11 +- ui/src/keyboardLayouts/nb_NO.ts | 23 +- ui/src/keyboardLayouts/sv_SE.ts | 23 +- ui/src/keyboardMappings.ts | 274 +++-- .../devices.$id.settings.access._index.tsx | 4 +- .../routes/devices.$id.settings.advanced.tsx | 2 +- .../devices.$id.settings.general.update.tsx | 4 +- .../routes/devices.$id.settings.hardware.tsx | 5 +- .../routes/devices.$id.settings.keyboard.tsx | 76 +- ui/src/routes/devices.$id.settings.macros.tsx | 11 +- ui/src/routes/devices.$id.settings.mouse.tsx | 17 +- .../routes/devices.$id.settings.network.tsx | 17 +- ui/src/routes/devices.$id.settings.tsx | 18 +- ui/src/routes/devices.$id.settings.video.tsx | 11 +- ui/src/routes/devices.$id.tsx | 196 ++-- usb.go | 24 +- webrtc.go | 8 +- wol.go | 2 +- 71 files changed, 2574 insertions(+), 1805 deletions(-) create mode 100644 ui/src/hooks/useKeyboardLayout.ts diff --git a/config.go b/config.go index 537f85fe..1fa56a75 100644 --- a/config.go +++ b/config.go @@ -114,7 +114,7 @@ var defaultConfig = &Config{ ActiveExtension: "", KeyboardMacros: []KeyboardMacro{}, DisplayRotation: "270", - KeyboardLayout: "en_US", + KeyboardLayout: "en-US", DisplayMaxBrightness: 64, DisplayDimAfterSec: 120, // 2 minutes DisplayOffAfterSec: 1800, // 30 minutes diff --git a/display.go b/display.go index 274bb8bf..aab19fbc 100644 --- a/display.go +++ b/display.go @@ -30,7 +30,7 @@ const ( // do not call this function directly, use switchToScreenIfDifferent instead // this function is not thread safe func switchToScreen(screen string) { - _, err := CallCtrlAction("lv_scr_load", map[string]interface{}{"obj": screen}) + _, err := CallCtrlAction("lv_scr_load", map[string]any{"obj": screen}) if err != nil { displayLogger.Warn().Err(err).Str("screen", screen).Msg("failed to switch to screen") return @@ -39,15 +39,15 @@ func switchToScreen(screen string) { } func lvObjSetState(objName string, state string) (*CtrlResponse, error) { - return CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": objName, "state": state}) + return CallCtrlAction("lv_obj_set_state", map[string]any{"obj": objName, "state": state}) } func lvObjAddFlag(objName string, flag string) (*CtrlResponse, error) { - return CallCtrlAction("lv_obj_add_flag", map[string]interface{}{"obj": objName, "flag": flag}) + return CallCtrlAction("lv_obj_add_flag", map[string]any{"obj": objName, "flag": flag}) } func lvObjClearFlag(objName string, flag string) (*CtrlResponse, error) { - return CallCtrlAction("lv_obj_clear_flag", map[string]interface{}{"obj": objName, "flag": flag}) + return CallCtrlAction("lv_obj_clear_flag", map[string]any{"obj": objName, "flag": flag}) } func lvObjHide(objName string) (*CtrlResponse, error) { @@ -59,27 +59,27 @@ func lvObjShow(objName string) (*CtrlResponse, error) { } func lvObjSetOpacity(objName string, opacity int) (*CtrlResponse, error) { // nolint:unused - return CallCtrlAction("lv_obj_set_style_opa_layered", map[string]interface{}{"obj": objName, "opa": opacity}) + return CallCtrlAction("lv_obj_set_style_opa_layered", map[string]any{"obj": objName, "opa": opacity}) } func lvObjFadeIn(objName string, duration uint32) (*CtrlResponse, error) { - return CallCtrlAction("lv_obj_fade_in", map[string]interface{}{"obj": objName, "time": duration}) + return CallCtrlAction("lv_obj_fade_in", map[string]any{"obj": objName, "time": duration}) } func lvObjFadeOut(objName string, duration uint32) (*CtrlResponse, error) { - return CallCtrlAction("lv_obj_fade_out", map[string]interface{}{"obj": objName, "time": duration}) + return CallCtrlAction("lv_obj_fade_out", map[string]any{"obj": objName, "time": duration}) } func lvLabelSetText(objName string, text string) (*CtrlResponse, error) { - return CallCtrlAction("lv_label_set_text", map[string]interface{}{"obj": objName, "text": text}) + return CallCtrlAction("lv_label_set_text", map[string]any{"obj": objName, "text": text}) } func lvImgSetSrc(objName string, src string) (*CtrlResponse, error) { - return CallCtrlAction("lv_img_set_src", map[string]interface{}{"obj": objName, "src": src}) + return CallCtrlAction("lv_img_set_src", map[string]any{"obj": objName, "src": src}) } func lvDispSetRotation(rotation string) (*CtrlResponse, error) { - return CallCtrlAction("lv_disp_set_rotation", map[string]interface{}{"rotation": rotation}) + return CallCtrlAction("lv_disp_set_rotation", map[string]any{"rotation": rotation}) } func updateLabelIfChanged(objName string, newText string) { diff --git a/internal/confparser/confparser.go b/internal/confparser/confparser.go index 5ccd1cbe..aaa39686 100644 --- a/internal/confparser/confparser.go +++ b/internal/confparser/confparser.go @@ -16,22 +16,22 @@ import ( type FieldConfig struct { Name string Required bool - RequiredIf map[string]interface{} + RequiredIf map[string]any OneOf []string ValidateTypes []string - Defaults interface{} + Defaults any IsEmpty bool - CurrentValue interface{} + CurrentValue any TypeString string Delegated bool shouldUpdateValue bool } -func SetDefaultsAndValidate(config interface{}) error { +func SetDefaultsAndValidate(config any) error { return setDefaultsAndValidate(config, true) } -func setDefaultsAndValidate(config interface{}, isRoot bool) error { +func setDefaultsAndValidate(config any, isRoot bool) error { // first we need to check if the config is a pointer if reflect.TypeOf(config).Kind() != reflect.Ptr { return fmt.Errorf("config is not a pointer") @@ -55,7 +55,7 @@ func setDefaultsAndValidate(config interface{}, isRoot bool) error { Name: field.Name, OneOf: splitString(field.Tag.Get("one_of")), ValidateTypes: splitString(field.Tag.Get("validate_type")), - RequiredIf: make(map[string]interface{}), + RequiredIf: make(map[string]any), CurrentValue: fieldValue.Interface(), IsEmpty: false, TypeString: fieldType, @@ -142,8 +142,8 @@ func setDefaultsAndValidate(config interface{}, isRoot bool) error { // now check if the field has required_if requiredIf := field.Tag.Get("required_if") if requiredIf != "" { - requiredIfParts := strings.Split(requiredIf, ",") - for _, part := range requiredIfParts { + requiredIfParts := strings.SplitSeq(requiredIf, ",") + for part := range requiredIfParts { partVal := strings.SplitN(part, "=", 2) if len(partVal) != 2 { return fmt.Errorf("invalid required_if for field `%s`: %s", field.Name, requiredIf) @@ -168,7 +168,7 @@ func setDefaultsAndValidate(config interface{}, isRoot bool) error { return nil } -func validateFields(config interface{}, fields map[string]FieldConfig) error { +func validateFields(config any, fields map[string]FieldConfig) error { // now we can start to validate the fields for _, fieldConfig := range fields { if err := fieldConfig.validate(fields); err != nil { @@ -215,7 +215,7 @@ func (f *FieldConfig) validate(fields map[string]FieldConfig) error { return nil } -func (f *FieldConfig) populate(config interface{}) { +func (f *FieldConfig) populate(config any) { // update the field if it's not empty if !f.shouldUpdateValue { return diff --git a/internal/confparser/utils.go b/internal/confparser/utils.go index a46871e9..36ee28b1 100644 --- a/internal/confparser/utils.go +++ b/internal/confparser/utils.go @@ -16,7 +16,7 @@ func splitString(s string) []string { return strings.Split(s, ",") } -func toString(v interface{}) (string, error) { +func toString(v any) (string, error) { switch v := v.(type) { case string: return v, nil diff --git a/internal/logging/logger.go b/internal/logging/logger.go index 39156ecc..3a8274c5 100644 --- a/internal/logging/logger.go +++ b/internal/logging/logger.go @@ -50,7 +50,7 @@ var ( TimeFormat: time.RFC3339, PartsOrder: []string{"time", "level", "scope", "component", "message"}, FieldsExclude: []string{"scope", "component"}, - FormatPartValueByName: func(value interface{}, name string) string { + FormatPartValueByName: func(value any, name string) string { val := fmt.Sprintf("%s", value) if name == "component" { if value == nil { @@ -121,8 +121,8 @@ func (l *Logger) updateLogLevel() { continue } - scopes := strings.Split(strings.ToLower(env), ",") - for _, scope := range scopes { + scopes := strings.SplitSeq(strings.ToLower(env), ",") + for scope := range scopes { l.scopeLevels[scope] = level } } diff --git a/internal/logging/pion.go b/internal/logging/pion.go index 453b8bc9..2676caf2 100644 --- a/internal/logging/pion.go +++ b/internal/logging/pion.go @@ -13,32 +13,32 @@ type pionLogger struct { func (c pionLogger) Trace(msg string) { c.logger.Trace().Msg(msg) } -func (c pionLogger) Tracef(format string, args ...interface{}) { +func (c pionLogger) Tracef(format string, args ...any) { c.logger.Trace().Msgf(format, args...) } func (c pionLogger) Debug(msg string) { c.logger.Debug().Msg(msg) } -func (c pionLogger) Debugf(format string, args ...interface{}) { +func (c pionLogger) Debugf(format string, args ...any) { c.logger.Debug().Msgf(format, args...) } func (c pionLogger) Info(msg string) { c.logger.Info().Msg(msg) } -func (c pionLogger) Infof(format string, args ...interface{}) { +func (c pionLogger) Infof(format string, args ...any) { c.logger.Info().Msgf(format, args...) } func (c pionLogger) Warn(msg string) { c.logger.Warn().Msg(msg) } -func (c pionLogger) Warnf(format string, args ...interface{}) { +func (c pionLogger) Warnf(format string, args ...any) { c.logger.Warn().Msgf(format, args...) } func (c pionLogger) Error(msg string) { c.logger.Error().Msg(msg) } -func (c pionLogger) Errorf(format string, args ...interface{}) { +func (c pionLogger) Errorf(format string, args ...any) { c.logger.Error().Msgf(format, args...) } diff --git a/internal/logging/utils.go b/internal/logging/utils.go index e622d964..73ae37a8 100644 --- a/internal/logging/utils.go +++ b/internal/logging/utils.go @@ -13,7 +13,7 @@ func GetDefaultLogger() *zerolog.Logger { return &defaultLogger } -func ErrorfL(l *zerolog.Logger, format string, err error, args ...interface{}) error { +func ErrorfL(l *zerolog.Logger, format string, err error, args ...any) error { // TODO: move rootLogger to logging package if l == nil { l = &defaultLogger diff --git a/internal/network/hostname.go b/internal/network/hostname.go index d75255c8..09d39969 100644 --- a/internal/network/hostname.go +++ b/internal/network/hostname.go @@ -42,7 +42,7 @@ func updateEtcHosts(hostname string, fqdn string) error { hostLine := fmt.Sprintf("127.0.1.1\t%s %s", hostname, fqdn) hostLineExists := false - for _, line := range strings.Split(string(lines), "\n") { + for line := range strings.SplitSeq(string(lines), "\n") { if strings.HasPrefix(line, "127.0.1.1") { hostLineExists = true line = hostLine diff --git a/internal/network/utils.go b/internal/network/utils.go index 6d643326..797fd72f 100644 --- a/internal/network/utils.go +++ b/internal/network/utils.go @@ -13,7 +13,7 @@ func lifetimeToTime(lifetime int) *time.Time { return &t } -func IsSame(a, b interface{}) bool { +func IsSame(a, b any) bool { aJSON, err := json.Marshal(a) if err != nil { return false diff --git a/internal/udhcpc/parser.go b/internal/udhcpc/parser.go index 66c3ba2a..d75857c9 100644 --- a/internal/udhcpc/parser.go +++ b/internal/udhcpc/parser.go @@ -101,7 +101,7 @@ func (l *Lease) SetLeaseExpiry() (time.Time, error) { func UnmarshalDHCPCLease(lease *Lease, str string) error { // parse the lease file as a map data := make(map[string]string) - for _, line := range strings.Split(str, "\n") { + for line := range strings.SplitSeq(str, "\n") { line = strings.TrimSpace(line) // skip empty lines and comments if line == "" || strings.HasPrefix(line, "#") { @@ -165,7 +165,7 @@ func UnmarshalDHCPCLease(lease *Lease, str string) error { field.Set(reflect.ValueOf(ip)) case []net.IP: val := make([]net.IP, 0) - for _, ipStr := range strings.Fields(value) { + for ipStr := range strings.FieldsSeq(value) { ip := net.ParseIP(ipStr) if ip == nil { continue diff --git a/internal/udhcpc/udhcpc.go b/internal/udhcpc/udhcpc.go index 128ea66b..7b4d6e4d 100644 --- a/internal/udhcpc/udhcpc.go +++ b/internal/udhcpc/udhcpc.go @@ -52,7 +52,7 @@ func NewDHCPClient(options *DHCPClientOptions) *DHCPClient { } func (c *DHCPClient) getWatchPaths() []string { - watchPaths := make(map[string]interface{}) + watchPaths := make(map[string]any) watchPaths[filepath.Dir(c.leaseFile)] = nil if c.pidFile != "" { diff --git a/internal/usbgadget/hid_keyboard.go b/internal/usbgadget/hid_keyboard.go index 6ad3b6a5..f4fbaa6e 100644 --- a/internal/usbgadget/hid_keyboard.go +++ b/internal/usbgadget/hid_keyboard.go @@ -1,10 +1,10 @@ package usbgadget import ( + "bytes" "context" "fmt" "os" - "reflect" "time" ) @@ -61,6 +61,8 @@ var keyboardReportDesc = []byte{ const ( hidReadBufferSize = 8 + hidKeyBufferSize = 6 + hidErrorRollOver = 0x01 // https://www.usb.org/sites/default/files/documents/hid1_11.pdf // https://www.usb.org/sites/default/files/hut1_2.pdf KeyboardLedMaskNumLock = 1 << 0 @@ -68,7 +70,9 @@ const ( KeyboardLedMaskScrollLock = 1 << 2 KeyboardLedMaskCompose = 1 << 3 KeyboardLedMaskKana = 1 << 4 - ValidKeyboardLedMasks = KeyboardLedMaskNumLock | KeyboardLedMaskCapsLock | KeyboardLedMaskScrollLock | KeyboardLedMaskCompose | KeyboardLedMaskKana + // power on/off LED is 5 + KeyboardLedMaskShift = 1 << 6 + ValidKeyboardLedMasks = KeyboardLedMaskNumLock | KeyboardLedMaskCapsLock | KeyboardLedMaskScrollLock | KeyboardLedMaskCompose | KeyboardLedMaskKana | KeyboardLedMaskShift ) // Synchronization between LED states and CAPS LOCK, NUM LOCK, SCROLL LOCK, @@ -81,6 +85,7 @@ type KeyboardState struct { ScrollLock bool `json:"scroll_lock"` Compose bool `json:"compose"` Kana bool `json:"kana"` + Shift bool `json:"shift"` // This is not part of the main USB HID spec } func getKeyboardState(b byte) KeyboardState { @@ -91,27 +96,27 @@ func getKeyboardState(b byte) KeyboardState { ScrollLock: b&KeyboardLedMaskScrollLock != 0, Compose: b&KeyboardLedMaskCompose != 0, Kana: b&KeyboardLedMaskKana != 0, + Shift: b&KeyboardLedMaskShift != 0, } } -func (u *UsbGadget) updateKeyboardState(b byte) { +func (u *UsbGadget) updateKeyboardState(state byte) { u.keyboardStateLock.Lock() defer u.keyboardStateLock.Unlock() - if b&^ValidKeyboardLedMasks != 0 { - u.log.Trace().Uint8("b", b).Msg("contains invalid bits, ignoring") + if state&^ValidKeyboardLedMasks != 0 { + u.log.Warn().Uint8("state", state).Msg("ignoring invalid bits") return } - newState := getKeyboardState(b) - if reflect.DeepEqual(u.keyboardState, newState) { + if u.keyboardState == state { return } - u.log.Info().Interface("old", u.keyboardState).Interface("new", newState).Msg("keyboardState updated") - u.keyboardState = newState + u.log.Trace().Uint8("old", u.keyboardState).Uint8("new", state).Msg("keyboardState updated") + u.keyboardState = state if u.onKeyboardStateChange != nil { - (*u.onKeyboardStateChange)(newState) + (*u.onKeyboardStateChange)(getKeyboardState(state)) } } @@ -123,7 +128,35 @@ func (u *UsbGadget) GetKeyboardState() KeyboardState { u.keyboardStateLock.Lock() defer u.keyboardStateLock.Unlock() - return u.keyboardState + return getKeyboardState(u.keyboardState) +} + +func (u *UsbGadget) GetKeysDownState() KeysDownState { + u.keyboardStateLock.Lock() + defer u.keyboardStateLock.Unlock() + + return u.keysDownState +} + +func (u *UsbGadget) updateKeyDownState(state KeysDownState) { + u.keyboardStateLock.Lock() + defer u.keyboardStateLock.Unlock() + + if u.keysDownState.Modifier == state.Modifier && + bytes.Equal(u.keysDownState.Keys, state.Keys) { + return // No change in key down state + } + + u.log.Trace().Interface("old", u.keysDownState).Interface("new", state).Msg("keysDownState updated") + u.keysDownState = state + + if u.onKeysDownChange != nil { + (*u.onKeysDownChange)(state) + } +} + +func (u *UsbGadget) SetOnKeysDownChange(f func(state KeysDownState)) { + u.onKeysDownChange = &f } func (u *UsbGadget) listenKeyboardEvents() { @@ -142,7 +175,7 @@ func (u *UsbGadget) listenKeyboardEvents() { l.Info().Msg("context done") return default: - l.Trace().Msg("reading from keyboard") + l.Trace().Msg("reading from keyboard for LED state changes") if u.keyboardHidFile == nil { u.logWithSuppression("keyboardHidFileNil", 100, &l, nil, "keyboardHidFile is nil") // show the error every 100 times to avoid spamming the logs @@ -159,7 +192,7 @@ func (u *UsbGadget) listenKeyboardEvents() { } u.resetLogSuppressionCounter("keyboardHidFileRead") - l.Trace().Int("n", n).Bytes("buf", buf).Msg("got data from keyboard") + l.Trace().Int("n", n).Uints8("buf", buf).Msg("got data from keyboard") if n != 1 { l.Trace().Int("n", n).Msg("expected 1 byte, got") continue @@ -195,12 +228,12 @@ func (u *UsbGadget) OpenKeyboardHidFile() error { return u.openKeyboardHidFile() } -func (u *UsbGadget) keyboardWriteHidFile(data []byte) error { +func (u *UsbGadget) keyboardWriteHidFile(modifier byte, keys []byte) error { if err := u.openKeyboardHidFile(); err != nil { return err } - _, err := u.keyboardHidFile.Write(data) + _, err := u.keyboardHidFile.Write(append([]byte{modifier, 0x00}, keys[:hidKeyBufferSize]...)) if err != nil { u.logWithSuppression("keyboardWriteHidFile", 100, u.log, err, "failed to write to hidg0") u.keyboardHidFile.Close() @@ -211,22 +244,145 @@ func (u *UsbGadget) keyboardWriteHidFile(data []byte) error { return nil } -func (u *UsbGadget) KeyboardReport(modifier uint8, keys []uint8) error { +func (u *UsbGadget) UpdateKeysDown(modifier byte, keys []byte) KeysDownState { + // if we just reported an error roll over, we should clear the keys + if keys[0] == hidErrorRollOver { + for i := range keys { + keys[i] = 0 + } + } + + downState := KeysDownState{ + Modifier: modifier, + Keys: []byte(keys[:]), + } + u.updateKeyDownState(downState) + return downState +} + +func (u *UsbGadget) KeyboardReport(modifier byte, keys []byte) (KeysDownState, error) { u.keyboardLock.Lock() defer u.keyboardLock.Unlock() + defer u.resetUserInputTime() - if len(keys) > 6 { - keys = keys[:6] + if len(keys) > hidKeyBufferSize { + keys = keys[:hidKeyBufferSize] } - if len(keys) < 6 { - keys = append(keys, make([]uint8, 6-len(keys))...) + if len(keys) < hidKeyBufferSize { + keys = append(keys, make([]byte, hidKeyBufferSize-len(keys))...) } - err := u.keyboardWriteHidFile([]byte{modifier, 0, keys[0], keys[1], keys[2], keys[3], keys[4], keys[5]}) + err := u.keyboardWriteHidFile(modifier, keys) if err != nil { - return err + u.log.Warn().Uint8("modifier", modifier).Uints8("keys", keys).Msg("Could not write keyboard report to hidg0") } - u.resetUserInputTime() - return nil + return u.UpdateKeysDown(modifier, keys), err +} + +const ( + // https://www.usb.org/sites/default/files/documents/hut1_2.pdf + // Dynamic Flags (DV) + LeftControl = 0xE0 + LeftShift = 0xE1 + LeftAlt = 0xE2 + LeftSuper = 0xE3 // Left GUI (e.g. Windows key, Apple Command key) + RightControl = 0xE4 + RightShift = 0xE5 + RightAlt = 0xE6 + RightSuper = 0xE7 // Right GUI (e.g. Windows key, Apple Command key) +) + +const ( + // https://www.usb.org/sites/default/files/documents/hid1_11.pdf Appendix C + ModifierMaskLeftControl = 0x01 + ModifierMaskRightControl = 0x10 + ModifierMaskLeftShift = 0x02 + ModifierMaskRightShift = 0x20 + ModifierMaskLeftAlt = 0x04 + ModifierMaskRightAlt = 0x40 + ModifierMaskLeftSuper = 0x08 + ModifierMaskRightSuper = 0x80 +) + +// KeyCodeToMaskMap is a slice of KeyCodeMask for quick lookup +var KeyCodeToMaskMap = map[byte]byte{ + LeftControl: ModifierMaskLeftControl, + LeftShift: ModifierMaskLeftShift, + LeftAlt: ModifierMaskLeftAlt, + LeftSuper: ModifierMaskLeftSuper, + RightControl: ModifierMaskRightControl, + RightShift: ModifierMaskRightShift, + RightAlt: ModifierMaskRightAlt, + RightSuper: ModifierMaskRightSuper, +} + +func (u *UsbGadget) KeypressReport(key byte, press bool) (KeysDownState, error) { + u.keyboardLock.Lock() + defer u.keyboardLock.Unlock() + defer u.resetUserInputTime() + + // IMPORTANT: This code parallels the logic in the kernel's hid-gadget driver + // for handling key presses and releases. It ensures that the USB gadget + // behaves similarly to a real USB HID keyboard. This logic is paralleled + // in the client/browser-side code in useKeyboard.ts so make sure to keep + // them in sync. + var state = u.keysDownState + modifier := state.Modifier + keys := append([]byte(nil), state.Keys...) + + if mask, exists := KeyCodeToMaskMap[key]; exists { + // If the key is a modifier key, we update the keyboardModifier state + // by setting or clearing the corresponding bit in the modifier byte. + // This allows us to track the state of dynamic modifier keys like + // Shift, Control, Alt, and Super. + if press { + modifier |= mask + } else { + modifier &^= mask + } + } else { + // handle other keys that are not modifier keys by placing or removing them + // from the key buffer since the buffer tracks currently pressed keys + overrun := true + for i := range hidKeyBufferSize { + // If we find the key in the buffer the buffer, we either remove it (if press is false) + // or do nothing (if down is true) because the buffer tracks currently pressed keys + // and if we find a zero byte, we can place the key there (if press is true) + if keys[i] == key || keys[i] == 0 { + if press { + keys[i] = key // overwrites the zero byte or the same key if already pressed + } else { + // we are releasing the key, remove it from the buffer + if keys[i] != 0 { + copy(keys[i:], keys[i+1:]) + keys[hidKeyBufferSize-1] = 0 // Clear the last byte + } + } + overrun = false // We found a slot for the key + break + } + } + + // If we reach here it means we didn't find an empty slot or the key in the buffer + if overrun { + if press { + u.log.Error().Uint8("key", key).Msg("keyboard buffer overflow, key not added") + // Fill all key slots with ErrorRollOver (0x01) to indicate overflow + for i := range keys { + keys[i] = hidErrorRollOver + } + } else { + // If we are releasing a key, and we didn't find it in a slot, who cares? + u.log.Warn().Uint8("key", key).Msg("key not found in buffer, nothing to release") + } + } + } + + err := u.keyboardWriteHidFile(modifier, keys) + if err != nil { + u.log.Warn().Uint8("modifier", modifier).Uints8("keys", keys).Msg("Could not write keypress report to hidg0") + } + + return u.UpdateKeysDown(modifier, keys), err } diff --git a/internal/usbgadget/hid_mouse_absolute.go b/internal/usbgadget/hid_mouse_absolute.go index 2718f207..c083b606 100644 --- a/internal/usbgadget/hid_mouse_absolute.go +++ b/internal/usbgadget/hid_mouse_absolute.go @@ -85,17 +85,17 @@ func (u *UsbGadget) absMouseWriteHidFile(data []byte) error { return nil } -func (u *UsbGadget) AbsMouseReport(x, y int, buttons uint8) error { +func (u *UsbGadget) AbsMouseReport(x int, y int, buttons uint8) error { u.absMouseLock.Lock() defer u.absMouseLock.Unlock() err := u.absMouseWriteHidFile([]byte{ - 1, // Report ID 1 - buttons, // Buttons - uint8(x), // X Low Byte - uint8(x >> 8), // X High Byte - uint8(y), // Y Low Byte - uint8(y >> 8), // Y High Byte + 1, // Report ID 1 + buttons, // Buttons + byte(x), // X Low Byte + byte(x >> 8), // X High Byte + byte(y), // Y Low Byte + byte(y >> 8), // Y High Byte }) if err != nil { return err diff --git a/internal/usbgadget/hid_mouse_relative.go b/internal/usbgadget/hid_mouse_relative.go index 786f265e..70cb72c5 100644 --- a/internal/usbgadget/hid_mouse_relative.go +++ b/internal/usbgadget/hid_mouse_relative.go @@ -75,15 +75,15 @@ func (u *UsbGadget) relMouseWriteHidFile(data []byte) error { return nil } -func (u *UsbGadget) RelMouseReport(mx, my int8, buttons uint8) error { +func (u *UsbGadget) RelMouseReport(mx int8, my int8, buttons uint8) error { u.relMouseLock.Lock() defer u.relMouseLock.Unlock() err := u.relMouseWriteHidFile([]byte{ - buttons, // Buttons - uint8(mx), // X - uint8(my), // Y - 0, // Wheel + buttons, // Buttons + byte(mx), // X + byte(my), // Y + 0, // Wheel }) if err != nil { return err diff --git a/internal/usbgadget/usbgadget.go b/internal/usbgadget/usbgadget.go index cb70655e..3a01a447 100644 --- a/internal/usbgadget/usbgadget.go +++ b/internal/usbgadget/usbgadget.go @@ -41,6 +41,11 @@ var defaultUsbGadgetDevices = Devices{ MassStorage: true, } +type KeysDownState struct { + Modifier byte `json:"modifier"` + Keys ByteSlice `json:"keys"` +} + // UsbGadget is a struct that represents a USB gadget. type UsbGadget struct { name string @@ -60,7 +65,9 @@ type UsbGadget struct { relMouseHidFile *os.File relMouseLock sync.Mutex - keyboardState KeyboardState + keyboardState byte // keyboard latched state (NumLock, CapsLock, ScrollLock, Compose, Kana) + keysDownState KeysDownState // keyboard dynamic state (modifier keys and pressed keys) + keyboardStateLock sync.Mutex keyboardStateCtx context.Context keyboardStateCancel context.CancelFunc @@ -77,6 +84,7 @@ type UsbGadget struct { txLock sync.Mutex onKeyboardStateChange *func(state KeyboardState) + onKeysDownChange *func(state KeysDownState) log *zerolog.Logger @@ -122,7 +130,8 @@ func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDev txLock: sync.Mutex{}, keyboardStateCtx: keyboardCtx, keyboardStateCancel: keyboardCancel, - keyboardState: KeyboardState{}, + keyboardState: 0, + keysDownState: KeysDownState{Modifier: 0, Keys: []byte{0, 0, 0, 0, 0, 0}}, // must be initialized to hidKeyBufferSize (6) zero bytes enabledDevices: *enabledDevices, lastUserInput: time.Now(), log: logger, diff --git a/internal/usbgadget/utils.go b/internal/usbgadget/utils.go index 8654924a..05fcd3ad 100644 --- a/internal/usbgadget/utils.go +++ b/internal/usbgadget/utils.go @@ -2,6 +2,7 @@ package usbgadget import ( "bytes" + "encoding/json" "fmt" "path/filepath" "strconv" @@ -10,6 +11,31 @@ import ( "github.com/rs/zerolog" ) +type ByteSlice []byte + +func (s ByteSlice) MarshalJSON() ([]byte, error) { + vals := make([]int, len(s)) + for i, v := range s { + vals[i] = int(v) + } + return json.Marshal(vals) +} + +func (s *ByteSlice) UnmarshalJSON(data []byte) error { + var vals []int + if err := json.Unmarshal(data, &vals); err != nil { + return err + } + *s = make([]byte, len(vals)) + for i, v := range vals { + if v < 0 || v > 255 { + return fmt.Errorf("value %d out of byte range", v) + } + (*s)[i] = byte(v) + } + return nil +} + func joinPath(basePath string, paths []string) string { pathArr := append([]string{basePath}, paths...) return filepath.Join(pathArr...) @@ -81,7 +107,7 @@ func compareFileContent(oldContent []byte, newContent []byte, looserMatch bool) return false } -func (u *UsbGadget) logWithSuppression(counterName string, every int, logger *zerolog.Logger, err error, msg string, args ...interface{}) { +func (u *UsbGadget) logWithSuppression(counterName string, every int, logger *zerolog.Logger, err error, msg string, args ...any) { u.logSuppressionLock.Lock() defer u.logSuppressionLock.Unlock() diff --git a/jsonrpc.go b/jsonrpc.go index 23766a58..6f9c670e 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -13,29 +13,30 @@ import ( "time" "github.com/pion/webrtc/v4" + "github.com/rs/zerolog" "go.bug.st/serial" "github.com/jetkvm/kvm/internal/usbgadget" ) type JSONRPCRequest struct { - JSONRPC string `json:"jsonrpc"` - Method string `json:"method"` - Params map[string]interface{} `json:"params,omitempty"` - ID interface{} `json:"id,omitempty"` + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params map[string]any `json:"params,omitempty"` + ID any `json:"id,omitempty"` } type JSONRPCResponse struct { - JSONRPC string `json:"jsonrpc"` - Result interface{} `json:"result,omitempty"` - Error interface{} `json:"error,omitempty"` - ID interface{} `json:"id"` + JSONRPC string `json:"jsonrpc"` + Result any `json:"result,omitempty"` + Error any `json:"error,omitempty"` + ID any `json:"id"` } type JSONRPCEvent struct { - JSONRPC string `json:"jsonrpc"` - Method string `json:"method"` - Params interface{} `json:"params,omitempty"` + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params any `json:"params,omitempty"` } type DisplayRotationSettings struct { @@ -61,7 +62,7 @@ func writeJSONRPCResponse(response JSONRPCResponse, session *Session) { } } -func writeJSONRPCEvent(event string, params interface{}, session *Session) { +func writeJSONRPCEvent(event string, params any, session *Session) { request := JSONRPCEvent{ JSONRPC: "2.0", Method: event, @@ -102,7 +103,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) { errorResponse := JSONRPCResponse{ JSONRPC: "2.0", - Error: map[string]interface{}{ + Error: map[string]any{ "code": -32700, "message": "Parse error", }, @@ -123,7 +124,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) { if !ok { errorResponse := JSONRPCResponse{ JSONRPC: "2.0", - Error: map[string]interface{}{ + Error: map[string]any{ "code": -32601, "message": "Method not found", }, @@ -133,13 +134,12 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) { return } - scopedLogger.Trace().Msg("Calling RPC handler") - result, err := callRPCHandler(handler, request.Params) + result, err := callRPCHandler(scopedLogger, handler, request.Params) if err != nil { scopedLogger.Error().Err(err).Msg("Error calling RPC handler") errorResponse := JSONRPCResponse{ JSONRPC: "2.0", - Error: map[string]interface{}{ + Error: map[string]any{ "code": -32603, "message": "Internal error", "data": err.Error(), @@ -200,7 +200,7 @@ func rpcGetStreamQualityFactor() (float64, error) { func rpcSetStreamQualityFactor(factor float64) error { logger.Info().Float64("factor", factor).Msg("Setting stream quality factor") - var _, err = CallCtrlAction("set_video_quality_factor", map[string]interface{}{"quality_factor": factor}) + var _, err = CallCtrlAction("set_video_quality_factor", map[string]any{"quality_factor": factor}) if err != nil { return err } @@ -240,7 +240,7 @@ func rpcSetEDID(edid string) error { } else { logger.Info().Str("edid", edid).Msg("Setting EDID") } - _, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": edid}) + _, err := CallCtrlAction("set_edid", map[string]any{"edid": edid}) if err != nil { return err } @@ -467,12 +467,12 @@ func rpcSetTLSState(state TLSState) error { } type RPCHandler struct { - Func interface{} + Func any Params []string } // call the handler but recover from a panic to ensure our RPC thread doesn't collapse on malformed calls -func callRPCHandler(handler RPCHandler, params map[string]interface{}) (result interface{}, err error) { +func callRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string]any) (result any, err error) { // Use defer to recover from a panic defer func() { if r := recover(); r != nil { @@ -486,11 +486,11 @@ func callRPCHandler(handler RPCHandler, params map[string]interface{}) (result i }() // Call the handler - result, err = riskyCallRPCHandler(handler, params) - return result, err + result, err = riskyCallRPCHandler(logger, handler, params) + return result, err // do not combine these two lines into one, as it breaks the above defer function's setting of err } -func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (interface{}, error) { +func riskyCallRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string]any) (any, error) { handlerValue := reflect.ValueOf(handler.Func) handlerType := handlerValue.Type() @@ -499,20 +499,24 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int } numParams := handlerType.NumIn() - args := make([]reflect.Value, numParams) - // Get the parameter names from the RPCHandler - paramNames := handler.Params + paramNames := handler.Params // Get the parameter names from the RPCHandler if len(paramNames) != numParams { - return nil, errors.New("mismatch between handler parameters and defined parameter names") + err := fmt.Errorf("mismatch between handler parameters (%d) and defined parameter names (%d)", numParams, len(paramNames)) + logger.Error().Strs("paramNames", paramNames).Err(err).Msg("Cannot call RPC handler") + return nil, err } - for i := 0; i < numParams; i++ { + args := make([]reflect.Value, numParams) + + for i := range numParams { paramType := handlerType.In(i) paramName := paramNames[i] paramValue, ok := params[paramName] if !ok { - return nil, errors.New("missing parameter: " + paramName) + err := fmt.Errorf("missing parameter: %s", paramName) + logger.Error().Err(err).Msg("Cannot marshal arguments for RPC handler") + return nil, err } convertedValue := reflect.ValueOf(paramValue) @@ -529,7 +533,7 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int if elemValue.Kind() == reflect.Float64 && paramType.Elem().Kind() == reflect.Uint8 { intValue := int(elemValue.Float()) if intValue < 0 || intValue > 255 { - return nil, fmt.Errorf("value out of range for uint8: %v", intValue) + return nil, fmt.Errorf("value out of range for uint8: %v for parameter %s", intValue, paramName) } newSlice.Index(j).SetUint(uint64(intValue)) } else { @@ -545,12 +549,12 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int } else if paramType.Kind() == reflect.Struct && convertedValue.Kind() == reflect.Map { jsonData, err := json.Marshal(convertedValue.Interface()) if err != nil { - return nil, fmt.Errorf("failed to marshal map to JSON: %v", err) + return nil, fmt.Errorf("failed to marshal map to JSON: %v for parameter %s", err, paramName) } newStruct := reflect.New(paramType).Interface() if err := json.Unmarshal(jsonData, newStruct); err != nil { - return nil, fmt.Errorf("failed to unmarshal JSON into struct: %v", err) + return nil, fmt.Errorf("failed to unmarshal JSON into struct: %v for parameter %s", err, paramName) } args[i] = reflect.ValueOf(newStruct).Elem() } else { @@ -561,6 +565,7 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int } } + logger.Trace().Msg("Calling RPC handler") results := handlerValue.Call(args) if len(results) == 0 { @@ -568,23 +573,32 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int } if len(results) == 1 { - if results[0].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) { - if !results[0].IsNil() { - return nil, results[0].Interface().(error) + if ok, err := asError(results[0]); ok { + return nil, err + } + return results[0].Interface(), nil + } + + if len(results) == 2 { + if ok, err := asError(results[1]); ok { + if err != nil { + return nil, err } - return nil, nil } return results[0].Interface(), nil } - if len(results) == 2 && results[1].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) { - if !results[1].IsNil() { - return nil, results[1].Interface().(error) - } - return results[0].Interface(), nil - } + return nil, fmt.Errorf("too many return values from handler: %d", len(results)) +} - return nil, errors.New("unexpected return values from handler") +func asError(value reflect.Value) (bool, error) { + if value.Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) { + if value.IsNil() { + return true, nil + } + return true, value.Interface().(error) + } + return false, nil } func rpcSetMassStorageMode(mode string) (string, error) { @@ -923,7 +937,7 @@ func rpcSetKeyboardLayout(layout string) error { return nil } -func getKeyboardMacros() (interface{}, error) { +func getKeyboardMacros() (any, error) { macros := make([]KeyboardMacro, len(config.KeyboardMacros)) copy(macros, config.KeyboardMacros) @@ -931,10 +945,10 @@ func getKeyboardMacros() (interface{}, error) { } type KeyboardMacrosParams struct { - Macros []interface{} `json:"macros"` + Macros []any `json:"macros"` } -func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) { +func setKeyboardMacros(params KeyboardMacrosParams) (any, error) { if params.Macros == nil { return nil, fmt.Errorf("missing or invalid macros parameter") } @@ -942,7 +956,7 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) { newMacros := make([]KeyboardMacro, 0, len(params.Macros)) for i, item := range params.Macros { - macroMap, ok := item.(map[string]interface{}) + macroMap, ok := item.(map[string]any) if !ok { return nil, fmt.Errorf("invalid macro at index %d", i) } @@ -960,16 +974,16 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) { } steps := []KeyboardMacroStep{} - if stepsArray, ok := macroMap["steps"].([]interface{}); ok { + if stepsArray, ok := macroMap["steps"].([]any); ok { for _, stepItem := range stepsArray { - stepMap, ok := stepItem.(map[string]interface{}) + stepMap, ok := stepItem.(map[string]any) if !ok { continue } step := KeyboardMacroStep{} - if keysArray, ok := stepMap["keys"].([]interface{}); ok { + if keysArray, ok := stepMap["keys"].([]any); ok { for _, k := range keysArray { if keyStr, ok := k.(string); ok { step.Keys = append(step.Keys, keyStr) @@ -977,7 +991,7 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) { } } - if modsArray, ok := stepMap["modifiers"].([]interface{}); ok { + if modsArray, ok := stepMap["modifiers"].([]any); ok { for _, m := range modsArray { if modStr, ok := m.(string); ok { step.Modifiers = append(step.Modifiers, modStr) @@ -1047,6 +1061,8 @@ var rpcHandlers = map[string]RPCHandler{ "renewDHCPLease": {Func: rpcRenewDHCPLease}, "keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}}, "getKeyboardLedState": {Func: rpcGetKeyboardLedState}, + "keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}}, + "getKeyDownState": {Func: rpcGetKeysDownState}, "absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}}, "relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}}, "wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}}, diff --git a/log.go b/log.go index b353a2c4..1a091b15 100644 --- a/log.go +++ b/log.go @@ -5,7 +5,7 @@ import ( "github.com/rs/zerolog" ) -func ErrorfL(l *zerolog.Logger, format string, err error, args ...interface{}) error { +func ErrorfL(l *zerolog.Logger, format string, err error, args ...any) error { return logging.ErrorfL(l, format, err, args...) } diff --git a/native.go b/native.go index 98072062..67f423a0 100644 --- a/native.go +++ b/native.go @@ -21,18 +21,18 @@ import ( var ctrlSocketConn net.Conn type CtrlAction struct { - Action string `json:"action"` - Seq int32 `json:"seq,omitempty"` - Params map[string]interface{} `json:"params,omitempty"` + Action string `json:"action"` + Seq int32 `json:"seq,omitempty"` + Params map[string]any `json:"params,omitempty"` } type CtrlResponse struct { - Seq int32 `json:"seq,omitempty"` - Error string `json:"error,omitempty"` - Errno int32 `json:"errno,omitempty"` - Result map[string]interface{} `json:"result,omitempty"` - Event string `json:"event,omitempty"` - Data json.RawMessage `json:"data,omitempty"` + Seq int32 `json:"seq,omitempty"` + Error string `json:"error,omitempty"` + Errno int32 `json:"errno,omitempty"` + Result map[string]any `json:"result,omitempty"` + Event string `json:"event,omitempty"` + Data json.RawMessage `json:"data,omitempty"` } type EventHandler func(event CtrlResponse) @@ -48,7 +48,7 @@ var ( nativeCmdLock = &sync.Mutex{} ) -func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse, error) { +func CallCtrlAction(action string, params map[string]any) (*CtrlResponse, error) { lock.Lock() defer lock.Unlock() ctrlAction := CtrlAction{ @@ -429,7 +429,7 @@ func ensureBinaryUpdated(destPath string) error { func restoreHdmiEdid() { if config.EdidString != "" { nativeLogger.Info().Str("edid", config.EdidString).Msg("Restoring HDMI EDID") - _, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": config.EdidString}) + _, err := CallCtrlAction("set_edid", map[string]any{"edid": config.EdidString}) if err != nil { nativeLogger.Warn().Err(err).Msg("Failed to restore HDMI EDID") } diff --git a/remote_mount.go b/remote_mount.go index befffcbc..32a0fd25 100644 --- a/remote_mount.go +++ b/remote_mount.go @@ -27,10 +27,7 @@ func (w *WebRTCDiskReader) Read(ctx context.Context, offset int64, size int64) ( } mountedImageSize := currentVirtualMediaState.Size virtualMediaStateMutex.RUnlock() - end := offset + size - if end > mountedImageSize { - end = mountedImageSize - } + end := min(offset+size, mountedImageSize) req := DiskReadRequest{ Start: uint64(offset), End: uint64(end), diff --git a/ui/eslint.config.cjs b/ui/eslint.config.cjs index a6c0c1fb..6e972586 100644 --- a/ui/eslint.config.cjs +++ b/ui/eslint.config.cjs @@ -66,6 +66,10 @@ module.exports = defineConfig([{ groups: ["builtin", "external", "internal", "parent", "sibling"], "newlines-between": "always", }], + + "@typescript-eslint/no-unused-vars": ["warn", { + "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" + }], }, settings: { diff --git a/ui/package-lock.json b/ui/package-lock.json index 72a48499..51c16424 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "kvm-ui", - "version": "2025.08.07.001", + "version": "2025.08.25.2300", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kvm-ui", - "version": "2025.08.07.001", + "version": "2025.08.25.2300", "dependencies": { "@headlessui/react": "^2.2.7", "@headlessui/tailwindcss": "^0.2.2", @@ -28,10 +28,10 @@ "react": "^19.1.1", "react-animate-height": "^3.2.3", "react-dom": "^19.1.1", - "react-hot-toast": "^2.5.2", + "react-hot-toast": "^2.6.0", "react-icons": "^5.5.0", "react-router-dom": "^6.22.3", - "react-simple-keyboard": "^3.8.106", + "react-simple-keyboard": "^3.8.115", "react-use-websocket": "^4.13.0", "react-xtermjs": "^1.0.10", "recharts": "^2.15.3", @@ -41,22 +41,22 @@ "zustand": "^4.5.2" }, "devDependencies": { - "@eslint/compat": "^1.3.1", + "@eslint/compat": "^1.3.2", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "^9.32.0", + "@eslint/js": "^9.34.0", "@tailwindcss/forms": "^0.5.10", - "@tailwindcss/postcss": "^4.1.11", + "@tailwindcss/postcss": "^4.1.12", "@tailwindcss/typography": "^0.5.16", - "@tailwindcss/vite": "^4.1.11", - "@types/react": "^19.1.9", + "@tailwindcss/vite": "^4.1.12", + "@types/react": "^19.1.11", "@types/react-dom": "^19.1.7", "@types/semver": "^7.7.0", "@types/validator": "^13.15.2", - "@typescript-eslint/eslint-plugin": "^8.39.0", - "@typescript-eslint/parser": "^8.39.0", + "@typescript-eslint/eslint-plugin": "^8.41.0", + "@typescript-eslint/parser": "^8.41.0", "@vitejs/plugin-react-swc": "^3.10.2", "autoprefixer": "^10.4.21", - "eslint": "^9.32.0", + "eslint": "^9.34.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", "eslint-plugin-react": "^7.37.5", @@ -66,7 +66,7 @@ "postcss": "^8.5.6", "prettier": "^3.6.2", "prettier-plugin-tailwindcss": "^0.6.14", - "tailwindcss": "^4.1.11", + "tailwindcss": "^4.1.12", "typescript": "^5.9.2", "vite": "^6.3.5", "vite-tsconfig-paths": "^5.1.4" @@ -88,33 +88,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@babel/runtime": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.2.tgz", - "integrity": "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", + "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", - "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", + "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", "cpu": [ "ppc64" ], @@ -128,9 +114,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", - "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", + "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", "cpu": [ "arm" ], @@ -144,9 +130,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", - "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", + "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", "cpu": [ "arm64" ], @@ -160,9 +146,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", - "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", + "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", "cpu": [ "x64" ], @@ -176,9 +162,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", - "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", + "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", "cpu": [ "arm64" ], @@ -192,9 +178,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", - "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", + "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", "cpu": [ "x64" ], @@ -208,9 +194,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", - "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", + "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", "cpu": [ "arm64" ], @@ -224,9 +210,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", - "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", + "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", "cpu": [ "x64" ], @@ -240,9 +226,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", - "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", + "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", "cpu": [ "arm" ], @@ -256,9 +242,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", - "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", + "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", "cpu": [ "arm64" ], @@ -272,9 +258,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", - "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", + "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", "cpu": [ "ia32" ], @@ -288,9 +274,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", - "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", + "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", "cpu": [ "loong64" ], @@ -304,9 +290,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", - "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", + "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", "cpu": [ "mips64el" ], @@ -320,9 +306,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", - "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", + "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", "cpu": [ "ppc64" ], @@ -336,9 +322,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", - "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", + "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", "cpu": [ "riscv64" ], @@ -352,9 +338,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", - "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", + "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", "cpu": [ "s390x" ], @@ -368,9 +354,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", - "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", + "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", "cpu": [ "x64" ], @@ -384,9 +370,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", - "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", + "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", "cpu": [ "arm64" ], @@ -400,9 +386,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", - "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", + "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", "cpu": [ "x64" ], @@ -416,9 +402,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", - "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", + "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", "cpu": [ "arm64" ], @@ -432,9 +418,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", - "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", + "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", "cpu": [ "x64" ], @@ -448,9 +434,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", - "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", + "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", "cpu": [ "arm64" ], @@ -464,9 +450,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", - "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", + "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", "cpu": [ "x64" ], @@ -480,9 +466,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", - "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", + "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", "cpu": [ "arm64" ], @@ -496,9 +482,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", - "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", + "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", "cpu": [ "ia32" ], @@ -512,9 +498,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", - "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", + "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", "cpu": [ "x64" ], @@ -555,9 +541,9 @@ } }, "node_modules/@eslint/compat": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.3.1.tgz", - "integrity": "sha512-k8MHony59I5EPic6EQTCNOuPoVBnoYXkP+20xvwFjN7t0qI3ImyvyBgg+hIVPwC8JaxVjjUZld+cLfBLFDLucg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.3.2.tgz", + "integrity": "sha512-jRNwzTbd6p2Rw4sZ1CgWRS8YMtqG15YyZf7zvb6gY2rB2u6n+2Z+ELW0GtL0fQgyl0pr4Y/BzBfng/BdsereRA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -587,18 +573,18 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", - "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", - "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" @@ -643,9 +629,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.32.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.32.0.tgz", - "integrity": "sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==", + "version": "9.34.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.34.0.tgz", + "integrity": "sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -664,12 +650,12 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", - "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.1", + "@eslint/core": "^0.15.2", "levn": "^0.4.1" }, "engines": { @@ -686,9 +672,9 @@ } }, "node_modules/@floating-ui/dom": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.3.tgz", - "integrity": "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", "license": "MIT", "dependencies": { "@floating-ui/core": "^1.7.3", @@ -711,12 +697,12 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.5.tgz", - "integrity": "sha512-HDO/1/1oH9fjj4eLgegrlH3dklZpHtUYYFiVwMUwfGvk9jWDRWqkklA2/NFScknrcNSspbV868WjXORvreDX+Q==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.3" + "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", @@ -845,9 +831,9 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", - "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { @@ -855,6 +841,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -866,16 +863,16 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.29", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", - "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -922,14 +919,14 @@ } }, "node_modules/@react-aria/focus": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.0.tgz", - "integrity": "sha512-7NEGtTPsBy52EZ/ToVKCu0HSelE3kq9qeis+2eEq90XSuJOMaDHUQrA7RC2Y89tlEwQB31bud/kKRi9Qme1dkA==", + "version": "3.21.1", + "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.1.tgz", + "integrity": "sha512-hmH1IhHlcQ2lSIxmki1biWzMbGgnhdxJUM0MFfzc71Rv6YAzhlx4kX3GYn4VNcjCeb6cdPv4RZ5vunV4kgMZYQ==", "license": "Apache-2.0", "dependencies": { - "@react-aria/interactions": "^3.25.4", - "@react-aria/utils": "^3.30.0", - "@react-types/shared": "^3.31.0", + "@react-aria/interactions": "^3.25.5", + "@react-aria/utils": "^3.30.1", + "@react-types/shared": "^3.32.0", "@swc/helpers": "^0.5.0", "clsx": "^2.0.0" }, @@ -939,15 +936,15 @@ } }, "node_modules/@react-aria/interactions": { - "version": "3.25.4", - "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.4.tgz", - "integrity": "sha512-HBQMxgUPHrW8V63u9uGgBymkMfj6vdWbB0GgUJY49K9mBKMsypcHeWkWM6+bF7kxRO728/IK8bWDV6whDbqjHg==", + "version": "3.25.5", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.5.tgz", + "integrity": "sha512-EweYHOEvMwef/wsiEqV73KurX/OqnmbzKQa2fLxdULbec5+yDj6wVGaRHIzM4NiijIDe+bldEl5DG05CAKOAHA==", "license": "Apache-2.0", "dependencies": { "@react-aria/ssr": "^3.9.10", - "@react-aria/utils": "^3.30.0", + "@react-aria/utils": "^3.30.1", "@react-stately/flags": "^3.1.2", - "@react-types/shared": "^3.31.0", + "@react-types/shared": "^3.32.0", "@swc/helpers": "^0.5.0" }, "peerDependencies": { @@ -971,15 +968,15 @@ } }, "node_modules/@react-aria/utils": { - "version": "3.30.0", - "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.30.0.tgz", - "integrity": "sha512-ydA6y5G1+gbem3Va2nczj/0G0W7/jUVo/cbN10WA5IizzWIwMP5qhFr7macgbKfHMkZ+YZC3oXnt2NNre5odKw==", + "version": "3.30.1", + "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.30.1.tgz", + "integrity": "sha512-zETcbDd6Vf9GbLndO6RiWJadIZsBU2MMm23rBACXLmpRztkrIqPEb2RVdlLaq1+GklDx0Ii6PfveVjx+8S5U6A==", "license": "Apache-2.0", "dependencies": { "@react-aria/ssr": "^3.9.10", "@react-stately/flags": "^3.1.2", "@react-stately/utils": "^3.10.8", - "@react-types/shared": "^3.31.0", + "@react-types/shared": "^3.32.0", "@swc/helpers": "^0.5.0", "clsx": "^2.0.0" }, @@ -1010,9 +1007,9 @@ } }, "node_modules/@react-types/shared": { - "version": "3.31.0", - "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz", - "integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==", + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.32.0.tgz", + "integrity": "sha512-t+cligIJsZYFMSPFMvsJMjzlzde06tZMOIOFa1OV5Z0BcMowrb2g4mB57j/9nP28iJIRYn10xCniQts+qadrqQ==", "license": "Apache-2.0", "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" @@ -1035,9 +1032,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.2.tgz", - "integrity": "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.48.1.tgz", + "integrity": "sha512-rGmb8qoG/zdmKoYELCBwu7vt+9HxZ7Koos3pD0+sH5fR3u3Wb/jGcpnqxcnWsPEKDUyzeLSqksN8LJtgXjqBYw==", "cpu": [ "arm" ], @@ -1048,9 +1045,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.2.tgz", - "integrity": "sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.48.1.tgz", + "integrity": "sha512-4e9WtTxrk3gu1DFE+imNJr4WsL13nWbD/Y6wQcyku5qadlKHY3OQ3LJ/INrrjngv2BJIHnIzbqMk1GTAC2P8yQ==", "cpu": [ "arm64" ], @@ -1061,9 +1058,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.2.tgz", - "integrity": "sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.48.1.tgz", + "integrity": "sha512-+XjmyChHfc4TSs6WUQGmVf7Hkg8ferMAE2aNYYWjiLzAS/T62uOsdfnqv+GHRjq7rKRnYh4mwWb4Hz7h/alp8A==", "cpu": [ "arm64" ], @@ -1074,9 +1071,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.2.tgz", - "integrity": "sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.48.1.tgz", + "integrity": "sha512-upGEY7Ftw8M6BAJyGwnwMw91rSqXTcOKZnnveKrVWsMTF8/k5mleKSuh7D4v4IV1pLxKAk3Tbs0Lo9qYmii5mQ==", "cpu": [ "x64" ], @@ -1087,9 +1084,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.2.tgz", - "integrity": "sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.48.1.tgz", + "integrity": "sha512-P9ViWakdoynYFUOZhqq97vBrhuvRLAbN/p2tAVJvhLb8SvN7rbBnJQcBu8e/rQts42pXGLVhfsAP0k9KXWa3nQ==", "cpu": [ "arm64" ], @@ -1100,9 +1097,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.2.tgz", - "integrity": "sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.48.1.tgz", + "integrity": "sha512-VLKIwIpnBya5/saccM8JshpbxfyJt0Dsli0PjXozHwbSVaHTvWXJH1bbCwPXxnMzU4zVEfgD1HpW3VQHomi2AQ==", "cpu": [ "x64" ], @@ -1113,9 +1110,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.2.tgz", - "integrity": "sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.48.1.tgz", + "integrity": "sha512-3zEuZsXfKaw8n/yF7t8N6NNdhyFw3s8xJTqjbTDXlipwrEHo4GtIKcMJr5Ed29leLpB9AugtAQpAHW0jvtKKaQ==", "cpu": [ "arm" ], @@ -1126,9 +1123,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.2.tgz", - "integrity": "sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.48.1.tgz", + "integrity": "sha512-leo9tOIlKrcBmmEypzunV/2w946JeLbTdDlwEZ7OnnsUyelZ72NMnT4B2vsikSgwQifjnJUbdXzuW4ToN1wV+Q==", "cpu": [ "arm" ], @@ -1139,9 +1136,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.2.tgz", - "integrity": "sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.48.1.tgz", + "integrity": "sha512-Vy/WS4z4jEyvnJm+CnPfExIv5sSKqZrUr98h03hpAMbE2aI0aD2wvK6GiSe8Gx2wGp3eD81cYDpLLBqNb2ydwQ==", "cpu": [ "arm64" ], @@ -1152,9 +1149,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.2.tgz", - "integrity": "sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.48.1.tgz", + "integrity": "sha512-x5Kzn7XTwIssU9UYqWDB9VpLpfHYuXw5c6bJr4Mzv9kIv242vmJHbI5PJJEnmBYitUIfoMCODDhR7KoZLot2VQ==", "cpu": [ "arm64" ], @@ -1165,9 +1162,9 @@ ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.2.tgz", - "integrity": "sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.48.1.tgz", + "integrity": "sha512-yzCaBbwkkWt/EcgJOKDUdUpMHjhiZT/eDktOPWvSRpqrVE04p0Nd6EGV4/g7MARXXeOqstflqsKuXVM3H9wOIQ==", "cpu": [ "loong64" ], @@ -1178,9 +1175,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.2.tgz", - "integrity": "sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.48.1.tgz", + "integrity": "sha512-UK0WzWUjMAJccHIeOpPhPcKBqax7QFg47hwZTp6kiMhQHeOYJeaMwzeRZe1q5IiTKsaLnHu9s6toSYVUlZ2QtQ==", "cpu": [ "ppc64" ], @@ -1191,9 +1188,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.2.tgz", - "integrity": "sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.48.1.tgz", + "integrity": "sha512-3NADEIlt+aCdCbWVZ7D3tBjBX1lHpXxcvrLt/kdXTiBrOds8APTdtk2yRL2GgmnSVeX4YS1JIf0imFujg78vpw==", "cpu": [ "riscv64" ], @@ -1204,9 +1201,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.2.tgz", - "integrity": "sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.48.1.tgz", + "integrity": "sha512-euuwm/QTXAMOcyiFCcrx0/S2jGvFlKJ2Iro8rsmYL53dlblp3LkUQVFzEidHhvIPPvcIsxDhl2wkBE+I6YVGzA==", "cpu": [ "riscv64" ], @@ -1217,9 +1214,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.2.tgz", - "integrity": "sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.48.1.tgz", + "integrity": "sha512-w8mULUjmPdWLJgmTYJx/W6Qhln1a+yqvgwmGXcQl2vFBkWsKGUBRbtLRuKJUln8Uaimf07zgJNxOhHOvjSQmBQ==", "cpu": [ "s390x" ], @@ -1230,9 +1227,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.2.tgz", - "integrity": "sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.48.1.tgz", + "integrity": "sha512-90taWXCWxTbClWuMZD0DKYohY1EovA+W5iytpE89oUPmT5O1HFdf8cuuVIylE6vCbrGdIGv85lVRzTcpTRZ+kA==", "cpu": [ "x64" ], @@ -1243,9 +1240,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.2.tgz", - "integrity": "sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.48.1.tgz", + "integrity": "sha512-2Gu29SkFh1FfTRuN1GR1afMuND2GKzlORQUP3mNMJbqdndOg7gNsa81JnORctazHRokiDzQ5+MLE5XYmZW5VWg==", "cpu": [ "x64" ], @@ -1256,9 +1253,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.2.tgz", - "integrity": "sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.48.1.tgz", + "integrity": "sha512-6kQFR1WuAO50bxkIlAVeIYsz3RUx+xymwhTo9j94dJ+kmHe9ly7muH23sdfWduD0BA8pD9/yhonUvAjxGh34jQ==", "cpu": [ "arm64" ], @@ -1269,9 +1266,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.2.tgz", - "integrity": "sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.48.1.tgz", + "integrity": "sha512-RUyZZ/mga88lMI3RlXFs4WQ7n3VyU07sPXmMG7/C1NOi8qisUg57Y7LRarqoGoAiopmGmChUhSwfpvQ3H5iGSQ==", "cpu": [ "ia32" ], @@ -1282,9 +1279,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.2.tgz", - "integrity": "sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.48.1.tgz", + "integrity": "sha512-8a/caCUN4vkTChxkaIJcMtwIVcBhi4X2PQRoT+yCK3qRYaZ7cURrmJFL5Ux9H9RaMIXj9RuihckdmkBX3zZsgg==", "cpu": [ "x64" ], @@ -1301,15 +1298,15 @@ "license": "MIT" }, "node_modules/@swc/core": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.3.tgz", - "integrity": "sha512-ZaDETVWnm6FE0fc+c2UE8MHYVS3Fe91o5vkmGfgwGXFbxYvAjKSqxM/j4cRc9T7VZNSJjriXq58XkfCp3Y6f+w==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz", + "integrity": "sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.23" + "@swc/types": "^0.1.24" }, "engines": { "node": ">=10" @@ -1319,16 +1316,16 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.13.3", - "@swc/core-darwin-x64": "1.13.3", - "@swc/core-linux-arm-gnueabihf": "1.13.3", - "@swc/core-linux-arm64-gnu": "1.13.3", - "@swc/core-linux-arm64-musl": "1.13.3", - "@swc/core-linux-x64-gnu": "1.13.3", - "@swc/core-linux-x64-musl": "1.13.3", - "@swc/core-win32-arm64-msvc": "1.13.3", - "@swc/core-win32-ia32-msvc": "1.13.3", - "@swc/core-win32-x64-msvc": "1.13.3" + "@swc/core-darwin-arm64": "1.13.5", + "@swc/core-darwin-x64": "1.13.5", + "@swc/core-linux-arm-gnueabihf": "1.13.5", + "@swc/core-linux-arm64-gnu": "1.13.5", + "@swc/core-linux-arm64-musl": "1.13.5", + "@swc/core-linux-x64-gnu": "1.13.5", + "@swc/core-linux-x64-musl": "1.13.5", + "@swc/core-win32-arm64-msvc": "1.13.5", + "@swc/core-win32-ia32-msvc": "1.13.5", + "@swc/core-win32-x64-msvc": "1.13.5" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" @@ -1340,9 +1337,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.13.3.tgz", - "integrity": "sha512-ux0Ws4pSpBTqbDS9GlVP354MekB1DwYlbxXU3VhnDr4GBcCOimpocx62x7cFJkSpEBF8bmX8+/TTCGKh4PbyXw==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.13.5.tgz", + "integrity": "sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ==", "cpu": [ "arm64" ], @@ -1357,9 +1354,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.13.3.tgz", - "integrity": "sha512-p0X6yhxmNUOMZrbeZ3ZNsPige8lSlSe1llllXvpCLkKKxN/k5vZt1sULoq6Nj4eQ7KeHQVm81/+AwKZyf/e0TA==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.13.5.tgz", + "integrity": "sha512-ILd38Fg/w23vHb0yVjlWvQBoE37ZJTdlLHa8LRCFDdX4WKfnVBiblsCU9ar4QTMNdeTBEX9iUF4IrbNWhaF1Ng==", "cpu": [ "x64" ], @@ -1374,9 +1371,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.13.3.tgz", - "integrity": "sha512-OmDoiexL2fVWvQTCtoh0xHMyEkZweQAlh4dRyvl8ugqIPEVARSYtaj55TBMUJIP44mSUOJ5tytjzhn2KFxFcBA==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.13.5.tgz", + "integrity": "sha512-Q6eS3Pt8GLkXxqz9TAw+AUk9HpVJt8Uzm54MvPsqp2yuGmY0/sNaPPNVqctCX9fu/Nu8eaWUen0si6iEiCsazQ==", "cpu": [ "arm" ], @@ -1391,9 +1388,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.13.3.tgz", - "integrity": "sha512-STfKku3QfnuUj6k3g9ld4vwhtgCGYIFQmsGPPgT9MK/dI3Lwnpe5Gs5t1inoUIoGNP8sIOLlBB4HV4MmBjQuhw==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.13.5.tgz", + "integrity": "sha512-aNDfeN+9af+y+M2MYfxCzCy/VDq7Z5YIbMqRI739o8Ganz6ST+27kjQFd8Y/57JN/hcnUEa9xqdS3XY7WaVtSw==", "cpu": [ "arm64" ], @@ -1408,9 +1405,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.13.3.tgz", - "integrity": "sha512-bc+CXYlFc1t8pv9yZJGus372ldzOVscBl7encUBlU1m/Sig0+NDJLz6cXXRcFyl6ABNOApWeR4Yl7iUWx6C8og==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.13.5.tgz", + "integrity": "sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ==", "cpu": [ "arm64" ], @@ -1425,9 +1422,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.13.3.tgz", - "integrity": "sha512-dFXoa0TEhohrKcxn/54YKs1iwNeW6tUkHJgXW33H381SvjKFUV53WR231jh1sWVJETjA3vsAwxKwR23s7UCmUA==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.13.5.tgz", + "integrity": "sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA==", "cpu": [ "x64" ], @@ -1442,9 +1439,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.13.3.tgz", - "integrity": "sha512-ieyjisLB+ldexiE/yD8uomaZuZIbTc8tjquYln9Quh5ykOBY7LpJJYBWvWtm1g3pHv6AXlBI8Jay7Fffb6aLfA==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.13.5.tgz", + "integrity": "sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q==", "cpu": [ "x64" ], @@ -1459,9 +1456,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.13.3.tgz", - "integrity": "sha512-elTQpnaX5vESSbhCEgcwXjpMsnUbqqHfEpB7ewpkAsLzKEXZaK67ihSRYAuAx6ewRQTo7DS5iTT6X5aQD3MzMw==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.13.5.tgz", + "integrity": "sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw==", "cpu": [ "arm64" ], @@ -1476,9 +1473,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.13.3.tgz", - "integrity": "sha512-nvehQVEOdI1BleJpuUgPLrclJ0TzbEMc+MarXDmmiRFwEUGqj+pnfkTSb7RZyS1puU74IXdK/YhTirHurtbI9w==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.13.5.tgz", + "integrity": "sha512-C5Yi/xIikrFUzZcyGj9L3RpKljFvKiDMtyDzPKzlsDrKIw2EYY+bF88gB6oGY5RGmv4DAX8dbnpRAqgFD0FMEw==", "cpu": [ "ia32" ], @@ -1493,9 +1490,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.13.3.tgz", - "integrity": "sha512-A+JSKGkRbPLVV2Kwx8TaDAV0yXIXm/gc8m98hSkVDGlPBBmydgzNdWy3X7HTUBM7IDk7YlWE7w2+RUGjdgpTmg==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.13.5.tgz", + "integrity": "sha512-YrKdMVxbYmlfybCSbRtrilc6UA8GF5aPmGKBdPvjrarvsmf4i7ZHGCEnLtfOMd3Lwbs2WUZq3WdMbozYeLU93Q==", "cpu": [ "x64" ], @@ -1549,25 +1546,25 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz", - "integrity": "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.12.tgz", + "integrity": "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.3.0", - "enhanced-resolve": "^5.18.1", - "jiti": "^2.4.2", + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.5.1", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.11" + "tailwindcss": "4.1.12" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.11.tgz", - "integrity": "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.12.tgz", + "integrity": "sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1579,24 +1576,24 @@ "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.11", - "@tailwindcss/oxide-darwin-arm64": "4.1.11", - "@tailwindcss/oxide-darwin-x64": "4.1.11", - "@tailwindcss/oxide-freebsd-x64": "4.1.11", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.11", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.11", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.11", - "@tailwindcss/oxide-linux-x64-musl": "4.1.11", - "@tailwindcss/oxide-wasm32-wasi": "4.1.11", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.11", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.11" + "@tailwindcss/oxide-android-arm64": "4.1.12", + "@tailwindcss/oxide-darwin-arm64": "4.1.12", + "@tailwindcss/oxide-darwin-x64": "4.1.12", + "@tailwindcss/oxide-freebsd-x64": "4.1.12", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.12", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.12", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.12", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.12", + "@tailwindcss/oxide-linux-x64-musl": "4.1.12", + "@tailwindcss/oxide-wasm32-wasi": "4.1.12", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.12", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.12" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.11.tgz", - "integrity": "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.12.tgz", + "integrity": "sha512-oNY5pq+1gc4T6QVTsZKwZaGpBb2N1H1fsc1GD4o7yinFySqIuRZ2E4NvGasWc6PhYJwGK2+5YT1f9Tp80zUQZQ==", "cpu": [ "arm64" ], @@ -1611,9 +1608,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.11.tgz", - "integrity": "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.12.tgz", + "integrity": "sha512-cq1qmq2HEtDV9HvZlTtrj671mCdGB93bVY6J29mwCyaMYCP/JaUBXxrQQQm7Qn33AXXASPUb2HFZlWiiHWFytw==", "cpu": [ "arm64" ], @@ -1628,9 +1625,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.11.tgz", - "integrity": "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.12.tgz", + "integrity": "sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg==", "cpu": [ "x64" ], @@ -1645,9 +1642,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.11.tgz", - "integrity": "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.12.tgz", + "integrity": "sha512-JOH/f7j6+nYXIrHobRYCtoArJdMJh5zy5lr0FV0Qu47MID/vqJAY3r/OElPzx1C/wdT1uS7cPq+xdYYelny1ww==", "cpu": [ "x64" ], @@ -1662,9 +1659,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.11.tgz", - "integrity": "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.12.tgz", + "integrity": "sha512-v4Ghvi9AU1SYgGr3/j38PD8PEe6bRfTnNSUE3YCMIRrrNigCFtHZ2TCm8142X8fcSqHBZBceDx+JlFJEfNg5zQ==", "cpu": [ "arm" ], @@ -1679,9 +1676,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.11.tgz", - "integrity": "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.12.tgz", + "integrity": "sha512-YP5s1LmetL9UsvVAKusHSyPlzSRqYyRB0f+Kl/xcYQSPLEw/BvGfxzbH+ihUciePDjiXwHh+p+qbSP3SlJw+6g==", "cpu": [ "arm64" ], @@ -1696,9 +1693,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.11.tgz", - "integrity": "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.12.tgz", + "integrity": "sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA==", "cpu": [ "arm64" ], @@ -1713,9 +1710,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.11.tgz", - "integrity": "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.12.tgz", + "integrity": "sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q==", "cpu": [ "x64" ], @@ -1730,9 +1727,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.11.tgz", - "integrity": "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.12.tgz", + "integrity": "sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A==", "cpu": [ "x64" ], @@ -1747,9 +1744,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.11.tgz", - "integrity": "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.12.tgz", + "integrity": "sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1765,11 +1762,11 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@emnapi/wasi-threads": "^1.0.2", - "@napi-rs/wasm-runtime": "^0.2.11", - "@tybys/wasm-util": "^0.9.0", + "@emnapi/core": "^1.4.5", + "@emnapi/runtime": "^1.4.5", + "@emnapi/wasi-threads": "^1.0.4", + "@napi-rs/wasm-runtime": "^0.2.12", + "@tybys/wasm-util": "^0.10.0", "tslib": "^2.8.0" }, "engines": { @@ -1777,9 +1774,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz", - "integrity": "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz", + "integrity": "sha512-iGLyD/cVP724+FGtMWslhcFyg4xyYyM+5F4hGvKA7eifPkXHRAUDFaimu53fpNg9X8dfP75pXx/zFt/jlNF+lg==", "cpu": [ "arm64" ], @@ -1794,9 +1791,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.11.tgz", - "integrity": "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.12.tgz", + "integrity": "sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA==", "cpu": [ "x64" ], @@ -1811,17 +1808,17 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.11.tgz", - "integrity": "sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.12.tgz", + "integrity": "sha512-5PpLYhCAwf9SJEeIsSmCDLgyVfdBhdBpzX1OJ87anT9IVR0Z9pjM0FNixCAUAHGnMBGB8K99SwAheXrT0Kh6QQ==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.11", - "@tailwindcss/oxide": "4.1.11", + "@tailwindcss/node": "4.1.12", + "@tailwindcss/oxide": "4.1.12", "postcss": "^8.4.41", - "tailwindcss": "4.1.11" + "tailwindcss": "4.1.12" } }, "node_modules/@tailwindcss/typography": { @@ -1841,15 +1838,15 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.11.tgz", - "integrity": "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.12.tgz", + "integrity": "sha512-4pt0AMFDx7gzIrAOIYgYP0KCBuKWqyW8ayrdiLEjoJTT4pKTjrzG/e4uzWtTLDziC+66R9wbUqZBccJalSE5vQ==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.1.11", - "@tailwindcss/oxide": "4.1.11", - "tailwindcss": "4.1.11" + "@tailwindcss/node": "4.1.12", + "@tailwindcss/oxide": "4.1.12", + "tailwindcss": "4.1.12" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" @@ -1964,9 +1961,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.1.9", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz", - "integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==", + "version": "19.1.11", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.11.tgz", + "integrity": "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==", "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -1996,17 +1993,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.39.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.0.tgz", - "integrity": "sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw==", + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.41.0.tgz", + "integrity": "sha512-8fz6oa6wEKZrhXWro/S3n2eRJqlRcIa6SlDh59FXJ5Wp5XRZ8B9ixpJDcjadHq47hMx0u+HW6SNa6LjJQ6NLtw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.39.0", - "@typescript-eslint/type-utils": "8.39.0", - "@typescript-eslint/utils": "8.39.0", - "@typescript-eslint/visitor-keys": "8.39.0", + "@typescript-eslint/scope-manager": "8.41.0", + "@typescript-eslint/type-utils": "8.41.0", + "@typescript-eslint/utils": "8.41.0", + "@typescript-eslint/visitor-keys": "8.41.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -2020,7 +2017,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.39.0", + "@typescript-eslint/parser": "^8.41.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -2036,16 +2033,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.39.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.39.0.tgz", - "integrity": "sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg==", + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.41.0.tgz", + "integrity": "sha512-gTtSdWX9xiMPA/7MV9STjJOOYtWwIJIYxkQxnSV1U3xcE+mnJSH3f6zI0RYP+ew66WSlZ5ed+h0VCxsvdC1jJg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.39.0", - "@typescript-eslint/types": "8.39.0", - "@typescript-eslint/typescript-estree": "8.39.0", - "@typescript-eslint/visitor-keys": "8.39.0", + "@typescript-eslint/scope-manager": "8.41.0", + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/typescript-estree": "8.41.0", + "@typescript-eslint/visitor-keys": "8.41.0", "debug": "^4.3.4" }, "engines": { @@ -2061,14 +2058,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.39.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.39.0.tgz", - "integrity": "sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew==", + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.41.0.tgz", + "integrity": "sha512-b8V9SdGBQzQdjJ/IO3eDifGpDBJfvrNTp2QD9P2BeqWTGrRibgfgIlBSw6z3b6R7dPzg752tOs4u/7yCLxksSQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.39.0", - "@typescript-eslint/types": "^8.39.0", + "@typescript-eslint/tsconfig-utils": "^8.41.0", + "@typescript-eslint/types": "^8.41.0", "debug": "^4.3.4" }, "engines": { @@ -2083,14 +2080,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.39.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.39.0.tgz", - "integrity": "sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A==", + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.41.0.tgz", + "integrity": "sha512-n6m05bXn/Cd6DZDGyrpXrELCPVaTnLdPToyhBoFkLIMznRUQUEQdSp96s/pcWSQdqOhrgR1mzJ+yItK7T+WPMQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.39.0", - "@typescript-eslint/visitor-keys": "8.39.0" + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/visitor-keys": "8.41.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2101,9 +2098,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.39.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.0.tgz", - "integrity": "sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ==", + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.41.0.tgz", + "integrity": "sha512-TDhxYFPUYRFxFhuU5hTIJk+auzM/wKvWgoNYOPcOf6i4ReYlOoYN8q1dV5kOTjNQNJgzWN3TUUQMtlLOcUgdUw==", "dev": true, "license": "MIT", "engines": { @@ -2118,15 +2115,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.39.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.39.0.tgz", - "integrity": "sha512-6B3z0c1DXVT2vYA9+z9axjtc09rqKUPRmijD5m9iv8iQpHBRYRMBcgxSiKTZKm6FwWw1/cI4v6em35OsKCiN5Q==", + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.41.0.tgz", + "integrity": "sha512-63qt1h91vg3KsjVVonFJWjgSK7pZHSQFKH6uwqxAH9bBrsyRhO6ONoKyXxyVBzG1lJnFAJcKAcxLS54N1ee1OQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.39.0", - "@typescript-eslint/typescript-estree": "8.39.0", - "@typescript-eslint/utils": "8.39.0", + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/typescript-estree": "8.41.0", + "@typescript-eslint/utils": "8.41.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -2143,9 +2140,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.39.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.0.tgz", - "integrity": "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg==", + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.41.0.tgz", + "integrity": "sha512-9EwxsWdVqh42afLbHP90n2VdHaWU/oWgbH2P0CfcNfdKL7CuKpwMQGjwev56vWu9cSKU7FWSu6r9zck6CVfnag==", "dev": true, "license": "MIT", "engines": { @@ -2157,16 +2154,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.39.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.0.tgz", - "integrity": "sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw==", + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.41.0.tgz", + "integrity": "sha512-D43UwUYJmGhuwHfY7MtNKRZMmfd8+p/eNSfFe6tH5mbVDto+VQCayeAt35rOx3Cs6wxD16DQtIKw/YXxt5E0UQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.39.0", - "@typescript-eslint/tsconfig-utils": "8.39.0", - "@typescript-eslint/types": "8.39.0", - "@typescript-eslint/visitor-keys": "8.39.0", + "@typescript-eslint/project-service": "8.41.0", + "@typescript-eslint/tsconfig-utils": "8.41.0", + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/visitor-keys": "8.41.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -2212,16 +2209,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.39.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.39.0.tgz", - "integrity": "sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ==", + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.41.0.tgz", + "integrity": "sha512-udbCVstxZ5jiPIXrdH+BZWnPatjlYwJuJkDA4Tbo3WyYLh8NvB+h/bKeSZHDOFKfphsZYJQqaFtLeXEqurQn1A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.39.0", - "@typescript-eslint/types": "8.39.0", - "@typescript-eslint/typescript-estree": "8.39.0" + "@typescript-eslint/scope-manager": "8.41.0", + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/typescript-estree": "8.41.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2236,13 +2233,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.39.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.0.tgz", - "integrity": "sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA==", + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.41.0.tgz", + "integrity": "sha512-+GeGMebMCy0elMNg67LRNoVnUFPIm37iu5CmHESVx56/9Jsfdpsvbv605DQ81Pi/x11IdKUsS5nzgTYbCQU9fg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.39.0", + "@typescript-eslint/types": "8.41.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -2650,9 +2647,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", - "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "version": "4.25.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.3.tgz", + "integrity": "sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ==", "dev": true, "funding": [ { @@ -2670,8 +2667,8 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001726", - "electron-to-chromium": "^1.5.173", + "caniuse-lite": "^1.0.30001735", + "electron-to-chromium": "^1.5.204", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, @@ -2739,9 +2736,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001731", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001731.tgz", - "integrity": "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==", + "version": "1.0.30001737", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001737.tgz", + "integrity": "sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw==", "dev": true, "funding": [ { @@ -3159,9 +3156,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.198", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.198.tgz", - "integrity": "sha512-G5COfnp3w+ydVu80yprgWSfmfQaYRh9DOxfhAxstLyetKaLyl55QrNjx8C38Pc/C+RaDmb1M0Lk8wPEMQ+bGgQ==", + "version": "1.5.209", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.209.tgz", + "integrity": "sha512-Xoz0uMrim9ZETCQt8UgM5FxQF9+imA7PBpokoGcZloA1uw2LeHzTlip5cb5KOAsXZLjh/moN2vReN3ZjJmjI9A==", "dev": true, "license": "ISC" }, @@ -3350,9 +3347,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", - "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", + "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -3362,32 +3359,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.8", - "@esbuild/android-arm": "0.25.8", - "@esbuild/android-arm64": "0.25.8", - "@esbuild/android-x64": "0.25.8", - "@esbuild/darwin-arm64": "0.25.8", - "@esbuild/darwin-x64": "0.25.8", - "@esbuild/freebsd-arm64": "0.25.8", - "@esbuild/freebsd-x64": "0.25.8", - "@esbuild/linux-arm": "0.25.8", - "@esbuild/linux-arm64": "0.25.8", - "@esbuild/linux-ia32": "0.25.8", - "@esbuild/linux-loong64": "0.25.8", - "@esbuild/linux-mips64el": "0.25.8", - "@esbuild/linux-ppc64": "0.25.8", - "@esbuild/linux-riscv64": "0.25.8", - "@esbuild/linux-s390x": "0.25.8", - "@esbuild/linux-x64": "0.25.8", - "@esbuild/netbsd-arm64": "0.25.8", - "@esbuild/netbsd-x64": "0.25.8", - "@esbuild/openbsd-arm64": "0.25.8", - "@esbuild/openbsd-x64": "0.25.8", - "@esbuild/openharmony-arm64": "0.25.8", - "@esbuild/sunos-x64": "0.25.8", - "@esbuild/win32-arm64": "0.25.8", - "@esbuild/win32-ia32": "0.25.8", - "@esbuild/win32-x64": "0.25.8" + "@esbuild/aix-ppc64": "0.25.9", + "@esbuild/android-arm": "0.25.9", + "@esbuild/android-arm64": "0.25.9", + "@esbuild/android-x64": "0.25.9", + "@esbuild/darwin-arm64": "0.25.9", + "@esbuild/darwin-x64": "0.25.9", + "@esbuild/freebsd-arm64": "0.25.9", + "@esbuild/freebsd-x64": "0.25.9", + "@esbuild/linux-arm": "0.25.9", + "@esbuild/linux-arm64": "0.25.9", + "@esbuild/linux-ia32": "0.25.9", + "@esbuild/linux-loong64": "0.25.9", + "@esbuild/linux-mips64el": "0.25.9", + "@esbuild/linux-ppc64": "0.25.9", + "@esbuild/linux-riscv64": "0.25.9", + "@esbuild/linux-s390x": "0.25.9", + "@esbuild/linux-x64": "0.25.9", + "@esbuild/netbsd-arm64": "0.25.9", + "@esbuild/netbsd-x64": "0.25.9", + "@esbuild/openbsd-arm64": "0.25.9", + "@esbuild/openbsd-x64": "0.25.9", + "@esbuild/openharmony-arm64": "0.25.9", + "@esbuild/sunos-x64": "0.25.9", + "@esbuild/win32-arm64": "0.25.9", + "@esbuild/win32-ia32": "0.25.9", + "@esbuild/win32-x64": "0.25.9" } }, "node_modules/escalade": { @@ -3413,19 +3410,19 @@ } }, "node_modules/eslint": { - "version": "9.32.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.32.0.tgz", - "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", + "version": "9.34.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.34.0.tgz", + "integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==", "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.0", - "@eslint/core": "^0.15.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.32.0", - "@eslint/plugin-kit": "^0.3.4", + "@eslint/js": "9.34.0", + "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -4747,9 +4744,9 @@ } }, "node_modules/js-base64": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.7.tgz", - "integrity": "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==", + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", "license": "BSD-3-Clause" }, "node_modules/js-tokens": { @@ -5133,13 +5130,13 @@ } }, "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "version": "0.30.18", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.18.tgz", + "integrity": "sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/math-intrinsics": { @@ -5787,9 +5784,9 @@ } }, "node_modules/react-hot-toast": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.5.2.tgz", - "integrity": "sha512-Tun3BbCxzmXXM7C+NI4qiv6lT0uwGh4oAfeJyNOjYUejTsm35mK9iCaYLGv8cBz9L5YxZLx/2ii7zsIwPtPUdw==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", "license": "MIT", "dependencies": { "csstype": "^3.1.3", @@ -5851,9 +5848,9 @@ } }, "node_modules/react-simple-keyboard": { - "version": "3.8.106", - "resolved": "https://registry.npmjs.org/react-simple-keyboard/-/react-simple-keyboard-3.8.106.tgz", - "integrity": "sha512-ItCHCdhVCzn9huhenuyuHQMOGsl3UMLu5xAO1bkjj4AAgVoktFC1DQ4HWkOS6BGPvUJejFM3Q5hVM8Bl2oX9pA==", + "version": "3.8.115", + "resolved": "https://registry.npmjs.org/react-simple-keyboard/-/react-simple-keyboard-3.8.115.tgz", + "integrity": "sha512-tHN2J0Vpi/+lJaQFZMUBCZdZCRPCEMNklEXR4mt7M/s76vpzWMrwkZjxDRbmK++KUy0jIfbZ04v5kORgaWNEMQ==", "license": "MIT", "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", @@ -6027,9 +6024,9 @@ } }, "node_modules/rollup": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz", - "integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.48.1.tgz", + "integrity": "sha512-jVG20NvbhTYDkGAty2/Yh7HK6/q3DGSRH4o8ALKGArmMuaauM9kLfoMZ+WliPwA5+JHr2lTn3g557FxBV87ifg==", "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -6042,26 +6039,26 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.46.2", - "@rollup/rollup-android-arm64": "4.46.2", - "@rollup/rollup-darwin-arm64": "4.46.2", - "@rollup/rollup-darwin-x64": "4.46.2", - "@rollup/rollup-freebsd-arm64": "4.46.2", - "@rollup/rollup-freebsd-x64": "4.46.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.46.2", - "@rollup/rollup-linux-arm-musleabihf": "4.46.2", - "@rollup/rollup-linux-arm64-gnu": "4.46.2", - "@rollup/rollup-linux-arm64-musl": "4.46.2", - "@rollup/rollup-linux-loongarch64-gnu": "4.46.2", - "@rollup/rollup-linux-ppc64-gnu": "4.46.2", - "@rollup/rollup-linux-riscv64-gnu": "4.46.2", - "@rollup/rollup-linux-riscv64-musl": "4.46.2", - "@rollup/rollup-linux-s390x-gnu": "4.46.2", - "@rollup/rollup-linux-x64-gnu": "4.46.2", - "@rollup/rollup-linux-x64-musl": "4.46.2", - "@rollup/rollup-win32-arm64-msvc": "4.46.2", - "@rollup/rollup-win32-ia32-msvc": "4.46.2", - "@rollup/rollup-win32-x64-msvc": "4.46.2", + "@rollup/rollup-android-arm-eabi": "4.48.1", + "@rollup/rollup-android-arm64": "4.48.1", + "@rollup/rollup-darwin-arm64": "4.48.1", + "@rollup/rollup-darwin-x64": "4.48.1", + "@rollup/rollup-freebsd-arm64": "4.48.1", + "@rollup/rollup-freebsd-x64": "4.48.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.48.1", + "@rollup/rollup-linux-arm-musleabihf": "4.48.1", + "@rollup/rollup-linux-arm64-gnu": "4.48.1", + "@rollup/rollup-linux-arm64-musl": "4.48.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.48.1", + "@rollup/rollup-linux-ppc64-gnu": "4.48.1", + "@rollup/rollup-linux-riscv64-gnu": "4.48.1", + "@rollup/rollup-linux-riscv64-musl": "4.48.1", + "@rollup/rollup-linux-s390x-gnu": "4.48.1", + "@rollup/rollup-linux-x64-gnu": "4.48.1", + "@rollup/rollup-linux-x64-musl": "4.48.1", + "@rollup/rollup-win32-arm64-msvc": "4.48.1", + "@rollup/rollup-win32-ia32-msvc": "4.48.1", + "@rollup/rollup-win32-x64-msvc": "4.48.1", "fsevents": "~2.3.2" } }, @@ -6478,19 +6475,23 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", - "integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz", + "integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==", "license": "MIT" }, "node_modules/tapable": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", - "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", + "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", "dev": true, "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/tar": { @@ -6534,10 +6535,13 @@ } }, "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -6939,10 +6943,13 @@ } }, "node_modules/vite/node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, diff --git a/ui/package.json b/ui/package.json index 9f0c298e..2ceaf304 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,7 +1,7 @@ { "name": "kvm-ui", "private": true, - "version": "2025.08.07.001", + "version": "2025.08.25.2300", "type": "module", "engines": { "node": "22.15.0" @@ -39,10 +39,10 @@ "react": "^19.1.1", "react-animate-height": "^3.2.3", "react-dom": "^19.1.1", - "react-hot-toast": "^2.5.2", + "react-hot-toast": "^2.6.0", "react-icons": "^5.5.0", "react-router-dom": "^6.22.3", - "react-simple-keyboard": "^3.8.106", + "react-simple-keyboard": "^3.8.115", "react-use-websocket": "^4.13.0", "react-xtermjs": "^1.0.10", "recharts": "^2.15.3", @@ -52,22 +52,22 @@ "zustand": "^4.5.2" }, "devDependencies": { - "@eslint/compat": "^1.3.1", + "@eslint/compat": "^1.3.2", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "^9.32.0", + "@eslint/js": "^9.34.0", "@tailwindcss/forms": "^0.5.10", - "@tailwindcss/postcss": "^4.1.11", + "@tailwindcss/postcss": "^4.1.12", "@tailwindcss/typography": "^0.5.16", - "@tailwindcss/vite": "^4.1.11", - "@types/react": "^19.1.9", + "@tailwindcss/vite": "^4.1.12", + "@types/react": "^19.1.11", "@types/react-dom": "^19.1.7", "@types/semver": "^7.7.0", "@types/validator": "^13.15.2", - "@typescript-eslint/eslint-plugin": "^8.39.0", - "@typescript-eslint/parser": "^8.39.0", + "@typescript-eslint/eslint-plugin": "^8.41.0", + "@typescript-eslint/parser": "^8.41.0", "@vitejs/plugin-react-swc": "^3.10.2", "autoprefixer": "^10.4.21", - "eslint": "^9.32.0", + "eslint": "^9.34.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", "eslint-plugin-react": "^7.37.5", @@ -77,7 +77,7 @@ "postcss": "^8.5.6", "prettier": "^3.6.2", "prettier-plugin-tailwindcss": "^0.6.14", - "tailwindcss": "^4.1.11", + "tailwindcss": "^4.1.12", "typescript": "^5.9.2", "vite": "^6.3.5", "vite-tsconfig-paths": "^5.1.4" diff --git a/ui/src/components/ActionBar.tsx b/ui/src/components/ActionBar.tsx index 801cc7a7..4f79d7ed 100644 --- a/ui/src/components/ActionBar.tsx +++ b/ui/src/components/ActionBar.tsx @@ -26,17 +26,13 @@ export default function Actionbar({ requestFullscreen: () => Promise; }) { const { navigateTo } = useDeviceUiNavigation(); - const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled); + const { isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = useHidStore(); + const { setDisableVideoFocusTrap, terminalType, setTerminalType, toggleSidebarView } = useUiStore(); - const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled); - const toggleSidebarView = useUiStore(state => state.toggleSidebarView); - const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap); - const terminalType = useUiStore(state => state.terminalType); - const setTerminalType = useUiStore(state => state.setTerminalType); const remoteVirtualMediaState = useMountMediaStore( state => state.remoteVirtualMediaState, ); - const developerMode = useSettingsStore(state => state.developerMode); + const { developerMode } = useSettingsStore(); // This is the only way to get a reliable state change for the popover // at time of writing this there is no mount, or unmount event for the popover @@ -47,13 +43,13 @@ export default function Actionbar({ isOpen.current = open; if (!open) { setTimeout(() => { - setDisableFocusTrap(false); - console.log("Popover is closing. Returning focus trap to video"); + setDisableVideoFocusTrap(false); + console.debug("Popover is closing. Returning focus trap to video"); }, 0); } } }, - [setDisableFocusTrap], + [setDisableVideoFocusTrap], ); return ( @@ -81,7 +77,7 @@ export default function Actionbar({ text="Paste text" LeadingIcon={MdOutlineContentPasteGo} onClick={() => { - setDisableFocusTrap(true); + setDisableVideoFocusTrap(true); }} /> @@ -123,7 +119,7 @@ export default function Actionbar({ ); }} onClick={() => { - setDisableFocusTrap(true); + setDisableVideoFocusTrap(true); }} /> @@ -154,7 +150,7 @@ export default function Actionbar({ theme="light" text="Wake on LAN" onClick={() => { - setDisableFocusTrap(true); + setDisableVideoFocusTrap(true); }} LeadingIcon={({ className }) => ( setVirtualKeyboard(!virtualKeyboard)} + onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)} /> @@ -218,7 +214,7 @@ export default function Actionbar({ text="Extension" LeadingIcon={LuCable} onClick={() => { - setDisableFocusTrap(true); + setDisableVideoFocusTrap(true); }} /> @@ -243,7 +239,7 @@ export default function Actionbar({ theme="light" text="Virtual Keyboard" LeadingIcon={FaKeyboard} - onClick={() => setVirtualKeyboard(!virtualKeyboard)} + onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)} />
@@ -268,7 +264,10 @@ export default function Actionbar({ theme="light" text="Settings" LeadingIcon={LuSettings} - onClick={() => navigateTo("/settings")} + onClick={() => { + setDisableVideoFocusTrap(true); + navigateTo("/settings") + }} />
diff --git a/ui/src/components/Header.tsx b/ui/src/components/Header.tsx index 543634ab..4bb7a976 100644 --- a/ui/src/components/Header.tsx +++ b/ui/src/components/Header.tsx @@ -48,7 +48,7 @@ export default function DashboardNavbar({ navigate("/"); }, [navigate, setUser]); - const usbState = useHidStore(state => state.usbState); + const { usbState } = useHidStore(); // for testing //userEmail = "user@example.org"; diff --git a/ui/src/components/InfoBar.tsx b/ui/src/components/InfoBar.tsx index 7ce67a4a..29f159d6 100644 --- a/ui/src/components/InfoBar.tsx +++ b/ui/src/components/InfoBar.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { cx } from "@/cva.config"; import { @@ -7,65 +7,68 @@ import { useRTCStore, useSettingsStore, useVideoStore, + VideoState } from "@/hooks/stores"; import { keys, modifiers } from "@/keyboardMappings"; export default function InfoBar() { - const activeKeys = useHidStore(state => state.activeKeys); - const activeModifiers = useHidStore(state => state.activeModifiers); - const mouseX = useMouseStore(state => state.mouseX); - const mouseY = useMouseStore(state => state.mouseY); - const mouseMove = useMouseStore(state => state.mouseMove); + const { keysDownState } = useHidStore(); + const { mouseX, mouseY, mouseMove } = useMouseStore(); const videoClientSize = useVideoStore( - state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`, + (state: VideoState) => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`, ); const videoSize = useVideoStore( - state => `${Math.round(state.width)}x${Math.round(state.height)}`, + (state: VideoState) => `${Math.round(state.width)}x${Math.round(state.height)}`, ); - const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); - - const settings = useSettingsStore(); - const showPressedKeys = useSettingsStore(state => state.showPressedKeys); + const { rpcDataChannel } = useRTCStore(); + const { debugMode, mouseMode, showPressedKeys } = useSettingsStore(); useEffect(() => { if (!rpcDataChannel) return; rpcDataChannel.onclose = () => console.log("rpcDataChannel has closed"); - rpcDataChannel.onerror = e => - console.log(`Error on DataChannel '${rpcDataChannel.label}': ${e}`); + rpcDataChannel.onerror = (e: Event) => + console.error(`Error on DataChannel '${rpcDataChannel.label}': ${e}`); }, [rpcDataChannel]); - const keyboardLedState = useHidStore(state => state.keyboardLedState); - const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable); - const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync); + const { keyboardLedState, usbState } = useHidStore(); + const { isTurnServerInUse } = useRTCStore(); + const { hdmiState } = useVideoStore(); - const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse); + const displayKeys = useMemo(() => { + if (!showPressedKeys) + return ""; - const usbState = useHidStore(state => state.usbState); - const hdmiState = useVideoStore(state => state.hdmiState); + const activeModifierMask = keysDownState.modifier || 0; + const keysDown = keysDownState.keys || []; + const modifierNames = Object.entries(modifiers).filter(([_, mask]) => (activeModifierMask & mask) !== 0).map(([name, _]) => name); + const keyNames = Object.entries(keys).filter(([_, value]) => keysDown.includes(value)).map(([name, _]) => name); + + return [...modifierNames,...keyNames].join(", "); + }, [keysDownState, showPressedKeys]); return (
- {settings.debugMode ? ( + {debugMode ? (
Resolution:{" "} {videoSize}
) : null} - {settings.debugMode ? ( + {debugMode ? (
Video Size: {videoClientSize}
) : null} - {(settings.debugMode && settings.mouseMode == "absolute") ? ( + {(debugMode && mouseMode == "absolute") ? (
Pointer: @@ -74,7 +77,7 @@ export default function InfoBar() {
) : null} - {(settings.debugMode && settings.mouseMode == "relative") ? ( + {(debugMode && mouseMode == "relative") ? (
Last Move: @@ -85,13 +88,13 @@ export default function InfoBar() {
) : null} - {settings.debugMode && ( + {debugMode && (
USB State: {usbState}
)} - {settings.debugMode && ( + {debugMode && (
HDMI State: {hdmiState} @@ -102,14 +105,7 @@ export default function InfoBar() {
Keys:

- {[ - ...activeKeys.map( - x => Object.entries(keys).filter(y => y[1] === x)[0][0], - ), - activeModifiers.map( - x => Object.entries(modifiers).filter(y => y[1] === x)[0][0], - ), - ].join(", ")} + {displayKeys}

)} @@ -122,23 +118,10 @@ export default function InfoBar() {
)} - {keyboardLedStateSyncAvailable ? ( -
- {keyboardLedSync === "browser" ? "Browser" : "Host"} -
- ) : null}
Scroll Lock
- {keyboardLedState?.compose ? ( + {keyboardLedState.compose ? (
Compose
) : null} - {keyboardLedState?.kana ? ( + {keyboardLedState.kana ? (
Kana
) : null} + {keyboardLedState.shift ? ( +
+ Shift +
+ ) : null}
diff --git a/ui/src/components/MacroForm.tsx b/ui/src/components/MacroForm.tsx index f74c4ae5..1aafe9c9 100644 --- a/ui/src/components/MacroForm.tsx +++ b/ui/src/components/MacroForm.tsx @@ -1,17 +1,18 @@ import { useState } from "react"; import { LuPlus } from "react-icons/lu"; -import { KeySequence } from "@/hooks/stores"; import { Button } from "@/components/Button"; -import { InputFieldWithLabel, FieldError } from "@/components/InputField"; +import FieldLabel from "@/components/FieldLabel"; import Fieldset from "@/components/Fieldset"; +import { InputFieldWithLabel, FieldError } from "@/components/InputField"; import { MacroStepCard } from "@/components/MacroStepCard"; import { DEFAULT_DELAY, MAX_STEPS_PER_MACRO, MAX_KEYS_PER_STEP, } from "@/constants/macros"; -import FieldLabel from "@/components/FieldLabel"; +import { KeySequence } from "@/hooks/stores"; +import useKeyboardLayout from "@/hooks/useKeyboardLayout"; interface ValidationErrors { name?: string; @@ -44,6 +45,7 @@ export function MacroForm({ const [keyQueries, setKeyQueries] = useState>({}); const [errors, setErrors] = useState({}); const [errorMessage, setErrorMessage] = useState(null); + const { selectedKeyboard } = useKeyboardLayout(); const showTemporaryError = (message: string) => { setErrorMessage(message); @@ -234,6 +236,7 @@ export function MacroForm({ } onDelayChange={delay => handleDelayChange(stepIndex, delay)} isLastStep={stepIndex === (macro.steps?.length || 0) - 1} + keyboard={selectedKeyboard} /> ))}
diff --git a/ui/src/components/MacroStepCard.tsx b/ui/src/components/MacroStepCard.tsx index 8642c28c..cf22468b 100644 --- a/ui/src/components/MacroStepCard.tsx +++ b/ui/src/components/MacroStepCard.tsx @@ -1,23 +1,18 @@ +import { useMemo } from "react"; import { LuArrowUp, LuArrowDown, LuX, LuTrash2 } from "react-icons/lu"; import { Button } from "@/components/Button"; import { Combobox } from "@/components/Combobox"; import { SelectMenuBasic } from "@/components/SelectMenuBasic"; import Card from "@/components/Card"; -import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings"; -import { MAX_KEYS_PER_STEP, DEFAULT_DELAY } from "@/constants/macros"; import FieldLabel from "@/components/FieldLabel"; +import { MAX_KEYS_PER_STEP, DEFAULT_DELAY } from "@/constants/macros"; +import { KeyboardLayout } from "@/keyboardLayouts"; +import { keys, modifiers } from "@/keyboardMappings"; // Filter out modifier keys since they're handled in the modifiers section const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta']; -const keyOptions = Object.keys(keys) - .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix))) - .map(key => ({ - value: key, - label: keyDisplayMap[key] || key, - })); - const modifierOptions = Object.keys(modifiers).map(modifier => ({ value: modifier, label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"), @@ -67,6 +62,7 @@ interface MacroStepCardProps { onModifierChange: (modifiers: string[]) => void; onDelayChange: (delay: number) => void; isLastStep: boolean; + keyboard: KeyboardLayout } const ensureArray = (arr: T[] | null | undefined): T[] => { @@ -84,9 +80,22 @@ export function MacroStepCard({ keyQuery, onModifierChange, onDelayChange, - isLastStep + isLastStep, + keyboard }: MacroStepCardProps) { - const getFilteredKeys = () => { + const { keyDisplayMap } = keyboard; + + const keyOptions = useMemo(() => + Object.keys(keys) + .filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix))) + .map(key => ({ + value: key, + label: keyDisplayMap[key] || key, + })), + [keyDisplayMap] + ); + + const filteredKeys = useMemo(() => { const selectedKeys = ensureArray(step.keys); const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value)); @@ -95,7 +104,7 @@ export function MacroStepCard({ } else { return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase())); } - }; + }, [keyOptions, keyQuery, step.keys]); return ( @@ -204,7 +213,7 @@ export function MacroStepCard({ }} displayValue={() => keyQuery} onInputChange={onKeyQueryChange} - options={getFilteredKeys} + options={() => filteredKeys} disabledMessage="Max keys reached" size="SM" immediate diff --git a/ui/src/components/Terminal.tsx b/ui/src/components/Terminal.tsx index f5d662d4..ba3e667c 100644 --- a/ui/src/components/Terminal.tsx +++ b/ui/src/components/Terminal.tsx @@ -1,6 +1,6 @@ import "react-simple-keyboard/build/css/index.css"; import { ChevronDownIcon } from "@heroicons/react/16/solid"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { useXTerm } from "react-xtermjs"; import { FitAddon } from "@xterm/addon-fit"; import { WebLinksAddon } from "@xterm/addon-web-links"; @@ -65,21 +65,22 @@ function Terminal({ readonly dataChannel: RTCDataChannel; readonly type: AvailableTerminalTypes; }) { - const enableTerminal = useUiStore(state => state.terminalType == type); - const setTerminalType = useUiStore(state => state.setTerminalType); - const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap); - + const { terminalType, setTerminalType, setDisableVideoFocusTrap } = useUiStore(); const { instance, ref } = useXTerm({ options: TERMINAL_CONFIG }); + const isTerminalTypeEnabled = useMemo(() => { + return terminalType == type; + }, [terminalType, type]); + useEffect(() => { setTimeout(() => { - setDisableVideoFocusTrap(enableTerminal); + setDisableVideoFocusTrap(isTerminalTypeEnabled); }, 500); return () => { setDisableVideoFocusTrap(false); }; - }, [enableTerminal, setDisableVideoFocusTrap]); + }, [setDisableVideoFocusTrap, isTerminalTypeEnabled]); const readyState = dataChannel.readyState; useEffect(() => { @@ -175,9 +176,9 @@ function Terminal({ ], { "pointer-events-none translate-y-[500px] opacity-100 transition duration-300": - !enableTerminal, + !isTerminalTypeEnabled, "pointer-events-auto -translate-y-[0px] opacity-100 transition duration-300": - enableTerminal, + isTerminalTypeEnabled, }, )} > diff --git a/ui/src/components/USBStateStatus.tsx b/ui/src/components/USBStateStatus.tsx index f0b2cb2f..9321a19c 100644 --- a/ui/src/components/USBStateStatus.tsx +++ b/ui/src/components/USBStateStatus.tsx @@ -4,9 +4,7 @@ import { cx } from "@/cva.config"; import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png"; import LoadingSpinner from "@components/LoadingSpinner"; import StatusCard from "@components/StatusCards"; -import { HidState } from "@/hooks/stores"; - -type USBStates = HidState["usbState"]; +import { USBStates } from "@/hooks/stores"; type StatusProps = Record< USBStates, @@ -67,7 +65,7 @@ export default function USBStateStatus({ }; const props = StatusCardProps[state]; if (!props) { - console.log("Unsupported USB state: ", state); + console.warn("Unsupported USB state: ", state); return; } diff --git a/ui/src/components/UsbInfoSetting.tsx b/ui/src/components/UsbInfoSetting.tsx index cc837f4a..e37bce0b 100644 --- a/ui/src/components/UsbInfoSetting.tsx +++ b/ui/src/components/UsbInfoSetting.tsx @@ -101,8 +101,8 @@ export function UsbInfoSetting() { `Failed to load USB Config: ${resp.error.data || "Unknown error"}`, ); } else { - console.log("syncUsbConfigProduct#getUsbConfig result:", resp.result); const usbConfigState = resp.result as UsbConfigState; + console.log("syncUsbConfigProduct#getUsbConfig result:", usbConfigState); const product = usbConfigs.map(u => u.value).includes(usbConfigState.product) ? usbConfigState.product : "custom"; diff --git a/ui/src/components/VirtualKeyboard.tsx b/ui/src/components/VirtualKeyboard.tsx index 4ff04a94..ce1bd83f 100644 --- a/ui/src/components/VirtualKeyboard.tsx +++ b/ui/src/components/VirtualKeyboard.tsx @@ -1,4 +1,3 @@ -import { useShallow } from "zustand/react/shallow"; import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { AnimatePresence, motion } from "framer-motion"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -13,9 +12,10 @@ import "react-simple-keyboard/build/css/index.css"; import AttachIconRaw from "@/assets/attach-icon.svg"; import DetachIconRaw from "@/assets/detach-icon.svg"; import { cx } from "@/cva.config"; -import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores"; +import { useHidStore, useUiStore } from "@/hooks/stores"; import useKeyboard from "@/hooks/useKeyboard"; -import { keyDisplayMap, keys, modifiers } from "@/keyboardMappings"; +import useKeyboardLayout from "@/hooks/useKeyboardLayout"; +import { keys, modifiers, latchingKeys, decodeModifiers } from "@/keyboardMappings"; export const DetachIcon = ({ className }: { className?: string }) => { return Detach Icon; @@ -26,34 +26,47 @@ const AttachIcon = ({ className }: { className?: string }) => { }; function KeyboardWrapper() { - const [layoutName, setLayoutName] = useState("default"); - const keyboardRef = useRef(null); - const showAttachedVirtualKeyboard = useUiStore( - state => state.isAttachedVirtualKeyboardVisible, - ); - const setShowAttachedVirtualKeyboard = useUiStore( - state => state.setAttachedVirtualKeyboardVisibility, - ); - - const { sendKeyboardEvent, resetKeyboardState } = useKeyboard(); + const { isAttachedVirtualKeyboardVisible, setAttachedVirtualKeyboardVisibility } = useUiStore(); + const { keysDownState, /* keyboardLedState,*/ isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = useHidStore(); + const { handleKeyPress, executeMacro } = useKeyboard(); + const { selectedKeyboard } = useKeyboardLayout(); const [isDragging, setIsDragging] = useState(false); const [position, setPosition] = useState({ x: 0, y: 0 }); const [newPosition, setNewPosition] = useState({ x: 0, y: 0 }); - const isCapsLockActive = useHidStore(useShallow(state => state.keyboardLedState?.caps_lock)); + const keyDisplayMap = useMemo(() => { + return selectedKeyboard.keyDisplayMap; + }, [selectedKeyboard]); - // HID related states - const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable); - const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync); - const isKeyboardLedManagedByHost = useMemo(() => - keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable, - [keyboardLedSync, keyboardLedStateSyncAvailable], - ); + const virtualKeyboard = useMemo(() => { + return selectedKeyboard.virtualKeyboard; + }, [selectedKeyboard]); - const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive); + //const isCapsLockActive = useMemo(() => { + // return (keyboardLedState.caps_lock); + //}, [keyboardLedState]); + const { isShiftActive, /*isControlActive, isAltActive, isMetaActive, isAltGrActive*/ } = useMemo(() => { + return decodeModifiers(keysDownState.modifier); + }, [keysDownState]); + + const mainLayoutName = useMemo(() => { + const layoutName = isShiftActive ? "shift": "default"; + return layoutName; + }, [isShiftActive]); + + const keyNamesForDownKeys = useMemo(() => { + const activeModifierMask = keysDownState.modifier || 0; + const modifierNames = Object.entries(modifiers).filter(([_, mask]) => (activeModifierMask & mask) !== 0).map(([name, _]) => name); + + const keysDown = keysDownState.keys || []; + const keyNames = Object.entries(keys).filter(([_, value]) => keysDown.includes(value)).map(([name, _]) => name); + + return [...modifierNames,...keyNames, ' ']; // we have to have at least one space to avoid keyboard whining + }, [keysDownState]); + const startDrag = useCallback((e: MouseEvent | TouchEvent) => { if (!keyboardRef.current) return; if (e instanceof TouchEvent && e.touches.length > 1) return; @@ -123,94 +136,69 @@ function KeyboardWrapper() { }; }, [endDrag, onDrag, startDrag]); + const onKeyUp = useCallback( + async (_: string, e: MouseEvent | undefined) => { + e?.preventDefault(); + e?.stopPropagation(); + }, + [] + ); + const onKeyDown = useCallback( - (key: string) => { - const isKeyShift = key === "{shift}" || key === "ShiftLeft" || key === "ShiftRight"; - const isKeyCaps = key === "CapsLock"; - const cleanKey = key.replace(/[()]/g, ""); - const keyHasShiftModifier = key.includes("("); - - // Handle toggle of layout for shift or caps lock - const toggleLayout = () => { - setLayoutName(prevLayout => (prevLayout === "default" ? "shift" : "default")); - }; + async (key: string, e: MouseEvent | undefined) => { + e?.preventDefault(); + e?.stopPropagation(); + // handle the fake key-macros we have defined for common combinations if (key === "CtrlAltDelete") { - sendKeyboardEvent( - [keys["Delete"]], - [modifiers["ControlLeft"], modifiers["AltLeft"]], - ); - setTimeout(resetKeyboardState, 100); + await executeMacro([ { keys: ["Delete"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 } ]); return; } if (key === "AltMetaEscape") { - sendKeyboardEvent( - [keys["Escape"]], - [modifiers["MetaLeft"], modifiers["AltLeft"]], - ); - - setTimeout(resetKeyboardState, 100); + await executeMacro([ { keys: ["Escape"], modifiers: ["AltLeft", "MetaLeft"], delay: 100 } ]); return; } if (key === "CtrlAltBackspace") { - sendKeyboardEvent( - [keys["Backspace"]], - [modifiers["ControlLeft"], modifiers["AltLeft"]], - ); - - setTimeout(resetKeyboardState, 100); + await executeMacro([ { keys: ["Backspace"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 } ]); return; } - if (isKeyShift || isKeyCaps) { - toggleLayout(); - - if (isCapsLockActive) { - if (!isKeyboardLedManagedByHost) { - setIsCapsLockActive(false); - } - sendKeyboardEvent([keys["CapsLock"]], []); - return; - } + // if they press any of the latching keys, we send a keypress down event and the release it automatically (on timer) + if (latchingKeys.includes(key)) { + console.debug(`Latching key pressed: ${key} sending down and delayed up pair`); + handleKeyPress(keys[key], true) + setTimeout(() => handleKeyPress(keys[key], false), 100); + return; } - // Handle caps lock state change - if (isKeyCaps && !isKeyboardLedManagedByHost) { - setIsCapsLockActive(!isCapsLockActive); + // if they press any of the dynamic keys, we send a keypress down event but we don't release it until they click it again + if (Object.keys(modifiers).includes(key)) { + const currentlyDown = keyNamesForDownKeys.includes(key); + console.debug(`Dynamic key pressed: ${key} was currently down: ${currentlyDown}, toggling state`); + handleKeyPress(keys[key], !currentlyDown) + return; } - // Collect new active keys and modifiers - const newKeys = keys[cleanKey] ? [keys[cleanKey]] : []; - const newModifiers = - keyHasShiftModifier && !isCapsLockActive ? [modifiers["ShiftLeft"]] : []; - - // Update current keys and modifiers - sendKeyboardEvent(newKeys, newModifiers); - - // If shift was used as a modifier and caps lock is not active, revert to default layout - if (keyHasShiftModifier && !isCapsLockActive) { - setLayoutName("default"); - } - - setTimeout(resetKeyboardState, 100); + // otherwise, just treat it as a down+up pair + const cleanKey = key.replace(/[()]/g, ""); + console.debug(`Regular key pressed: ${cleanKey} sending down and up pair`); + handleKeyPress(keys[cleanKey], true); + setTimeout(() => handleKeyPress(keys[cleanKey], false), 50); }, - [isCapsLockActive, isKeyboardLedManagedByHost, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive], + [executeMacro, handleKeyPress, keyNamesForDownKeys], ); - const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled); - const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled); - return (
- {virtualKeyboard && ( + {isVirtualKeyboardEnabled && (
- {showAttachedVirtualKeyboard ? ( + {isAttachedVirtualKeyboardVisible ? (
@@ -266,7 +254,7 @@ function KeyboardWrapper() { theme="light" text="Hide" LeadingIcon={ChevronDownIcon} - onClick={() => setVirtualKeyboard(false)} + onClick={() => setVirtualKeyboardEnabled(false)} />
@@ -275,66 +263,61 @@ function KeyboardWrapper() {
+ { /* TODO add optional number pad */ }
diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index 21d4aca5..9e2f0f24 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -9,9 +9,8 @@ import notifications from "@/notifications"; import useKeyboard from "@/hooks/useKeyboard"; import { useJsonRpc } from "@/hooks/useJsonRpc"; import { cx } from "@/cva.config"; -import { keys, modifiers } from "@/keyboardMappings"; +import { keys } from "@/keyboardMappings"; import { - useHidStore, useMouseStore, useRTCStore, useSettingsStore, @@ -28,15 +27,14 @@ import { export default function WebRTCVideo() { // Video and stream related refs and states const videoElm = useRef(null); - const mediaStream = useRTCStore(state => state.mediaStream); + const { mediaStream, peerConnectionState } = useRTCStore(); const [isPlaying, setIsPlaying] = useState(false); - const peerConnectionState = useRTCStore(state => state.peerConnectionState); const [isPointerLockActive, setIsPointerLockActive] = useState(false); + const [isKeyboardLockActive, setIsKeyboardLockActive] = useState(false); // Store hooks const settings = useSettingsStore(); - const { sendKeyboardEvent, resetKeyboardState } = useKeyboard(); - const setMousePosition = useMouseStore(state => state.setMousePosition); - const setMouseMove = useMouseStore(state => state.setMouseMove); + const { handleKeyPress, resetKeyboardState } = useKeyboard(); + const { setMousePosition, setMouseMove } = useMouseStore(); const { setClientSize: setVideoClientSize, setSize: setVideoSize, @@ -44,49 +42,39 @@ export default function WebRTCVideo() { height: videoHeight, clientWidth: videoClientWidth, clientHeight: videoClientHeight, + hdmiState, } = useVideoStore(); // Video enhancement settings - const videoSaturation = useSettingsStore(state => state.videoSaturation); - const videoBrightness = useSettingsStore(state => state.videoBrightness); - const videoContrast = useSettingsStore(state => state.videoContrast); - - // HID related states - const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable); - const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync); - const isKeyboardLedManagedByHost = useMemo(() => - keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable, - [keyboardLedSync, keyboardLedStateSyncAvailable], - ); - - const setIsNumLockActive = useHidStore(state => state.setIsNumLockActive); - const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive); - const setIsScrollLockActive = useHidStore(state => state.setIsScrollLockActive); + const { videoSaturation, videoBrightness, videoContrast } = useSettingsStore(); // RTC related states - const peerConnection = useRTCStore(state => state.peerConnection); + const { peerConnection } = useRTCStore(); // HDMI and UI states - const hdmiState = useVideoStore(state => state.hdmiState); const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState); const isVideoLoading = !isPlaying; + // Mouse wheel states const [blockWheelEvent, setBlockWheelEvent] = useState(false); // Misc states and hooks const { send } = useJsonRpc(); // Video-related + const handleResize = useCallback( + ( { width, height }: { width: number | undefined; height: number | undefined }) => { + if (!videoElm.current) return; + // Do something with width and height, e.g.: + setVideoClientSize(width || 0, height || 0); + setVideoSize(videoElm.current.videoWidth, videoElm.current.videoHeight); + }, + [setVideoClientSize, setVideoSize] + ); + useResizeObserver({ ref: videoElm as React.RefObject, - onResize: ({ width, height }) => { - // This is actually client size, not videoSize - if (width && height) { - if (!videoElm.current) return; - setVideoClientSize(width, height); - setVideoSize(videoElm.current.videoWidth, videoElm.current.videoHeight); - } - }, + onResize: handleResize, }); const updateVideoSizeStore = useCallback( @@ -107,15 +95,15 @@ export default function WebRTCVideo() { function updateVideoSizeOnMount() { if (videoElm.current) updateVideoSizeStore(videoElm.current); }, - [setVideoClientSize, updateVideoSizeStore, setVideoSize], + [updateVideoSizeStore], ); // Pointer lock and keyboard lock related const isPointerLockPossible = window.location.protocol === "https:" || window.location.hostname === "localhost"; const isFullscreenEnabled = document.fullscreenEnabled; - + const checkNavigatorPermissions = useCallback(async (permissionName: string) => { - if (!navigator.permissions || !navigator.permissions.query) { + if (!navigator || !navigator.permissions || !navigator.permissions.query) { return false; // if can't query permissions, assume NOT granted } @@ -149,29 +137,31 @@ export default function WebRTCVideo() { if (videoElm.current === null) return; const isKeyboardLockGranted = await checkNavigatorPermissions("keyboard-lock"); - - if (isKeyboardLockGranted && "keyboard" in navigator) { + + if (isKeyboardLockGranted && navigator && "keyboard" in navigator) { try { // @ts-expect-error - keyboard lock is not supported in all browsers - await navigator.keyboard.lock(); + await navigator.keyboard.lock(); + setIsKeyboardLockActive(true); } catch { // ignore errors } } - }, [checkNavigatorPermissions]); + }, [checkNavigatorPermissions, setIsKeyboardLockActive]); const releaseKeyboardLock = useCallback(async () => { if (videoElm.current === null || document.fullscreenElement !== videoElm.current) return; - if ("keyboard" in navigator) { - try { - // @ts-expect-error - keyboard unlock is not supported in all browsers - await navigator.keyboard.unlock(); - } catch { - // ignore errors - } + if (navigator && "keyboard" in navigator) { + try { + // @ts-expect-error - keyboard unlock is not supported in all browsers + await navigator.keyboard.unlock(); + } catch { + // ignore errors + } + setIsKeyboardLockActive(false); } - }, []); + }, [setIsKeyboardLockActive]); useEffect(() => { if (!isPointerLockPossible || !videoElm.current) return; @@ -197,7 +187,7 @@ export default function WebRTCVideo() { }, [isPointerLockPossible]); const requestFullscreen = useCallback(async () => { - if (!isFullscreenEnabled || !videoElm.current) return; + if (!isFullscreenEnabled || !videoElm.current) return; // per https://wicg.github.io/keyboard-lock/#system-key-press-handler // If keyboard lock is activated after fullscreen is already in effect, then the user my @@ -344,153 +334,58 @@ export default function WebRTCVideo() { sendAbsMouseMovement(0, 0, 0); }, [sendAbsMouseMovement]); - // Keyboard-related - const handleModifierKeys = useCallback( - (e: KeyboardEvent, activeModifiers: number[]) => { - const { shiftKey, ctrlKey, altKey, metaKey } = e; - - const filteredModifiers = activeModifiers.filter(Boolean); - - // Example: activeModifiers = [0x01, 0x02, 0x04, 0x08] - // Assuming 0x01 = ControlLeft, 0x02 = ShiftLeft, 0x04 = AltLeft, 0x08 = MetaLeft - return ( - filteredModifiers - // Shift: Keep if Shift is pressed or if the key isn't a Shift key - // Example: If shiftKey is true, keep all modifiers - // If shiftKey is false, filter out 0x02 (ShiftLeft) and 0x20 (ShiftRight) - .filter( - modifier => - shiftKey || - (modifier !== modifiers["ShiftLeft"] && - modifier !== modifiers["ShiftRight"]), - ) - // Ctrl: Keep if Ctrl is pressed or if the key isn't a Ctrl key - // Example: If ctrlKey is true, keep all modifiers - // If ctrlKey is false, filter out 0x01 (ControlLeft) and 0x10 (ControlRight) - .filter( - modifier => - ctrlKey || - (modifier !== modifiers["ControlLeft"] && - modifier !== modifiers["ControlRight"]), - ) - // Alt: Keep if Alt is pressed or if the key isn't an Alt key - // Example: If altKey is true, keep all modifiers - // If altKey is false, filter out 0x04 (AltLeft) - // - // But intentionally do not filter out 0x40 (AltRight) to accomodate - // Alt Gr (Alt Graph) as a modifier. Oddly, Alt Gr does not declare - // itself to be an altKey. For example, the KeyboardEvent for - // Alt Gr + 2 has the following structure: - // - altKey: false - // - code: "Digit2" - // - type: [ "keydown" | "keyup" ] - // - // For context, filteredModifiers aims to keep track which modifiers - // are being pressed on the physical keyboard at any point in time. - // There is logic in the keyUpHandler and keyDownHandler to add and - // remove 0x40 (AltRight) from the list of new modifiers. - // - // But relying on the two handlers alone to track the state of the - // modifier bears the risk that the key up event for Alt Gr could - // get lost while the browser window is temporarily out of focus, - // which means the Alt Gr key state would then be "stuck". At this - // point, we would need to rely on the user to press Alt Gr again - // to properly release the state of that modifier. - .filter(modifier => altKey || modifier !== modifiers["AltLeft"]) - // Meta: Keep if Meta is pressed or if the key isn't a Meta key - // Example: If metaKey is true, keep all modifiers - // If metaKey is false, filter out 0x08 (MetaLeft) and 0x80 (MetaRight) - .filter( - modifier => - metaKey || - (modifier !== modifiers["MetaLeft"] && modifier !== modifiers["MetaRight"]), - ) - ); - }, - [], - ); - const keyDownHandler = useCallback( (e: KeyboardEvent) => { e.preventDefault(); - const prev = useHidStore.getState(); - let code = e.code; - const key = e.key; + const code = getAdjustedKeyCode(e); + const hidKey = keys[code]; - if (!isKeyboardLedManagedByHost) { - setIsNumLockActive(e.getModifierState("NumLock")); - setIsCapsLockActive(e.getModifierState("CapsLock")); - setIsScrollLockActive(e.getModifierState("ScrollLock")); + if (hidKey === undefined) { + console.warn(`Key down not mapped: ${code}`); + return; } - if (code == "IntlBackslash" && ["`", "~"].includes(key)) { - code = "Backquote"; - } else if (code == "Backquote" && ["§", "±"].includes(key)) { - code = "IntlBackslash"; - } - - // Add the key to the active keys - const newKeys = [...prev.activeKeys, keys[code]].filter(Boolean); - - // Add the modifier to the active modifiers - const newModifiers = handleModifierKeys(e, [ - ...prev.activeModifiers, - modifiers[code], - ]); - // When pressing the meta key + another key, the key will never trigger a keyup // 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 - if (e.metaKey) { + if (e.metaKey && hidKey < 0xE0) { setTimeout(() => { - const prev = useHidStore.getState(); - sendKeyboardEvent([], newModifiers || prev.activeModifiers); + console.debug(`Forcing the meta key release of associated key: ${hidKey}`); + handleKeyPress(hidKey, false); }, 10); } - - sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]); + console.debug(`Key down: ${hidKey}`); + handleKeyPress(hidKey, true); + + if (!isKeyboardLockActive && hidKey === keys.MetaLeft) { + // If the left meta key was just pressed and we're not keyboard locked + // we'll never see the keyup event because the browser is going to lose + // focus so set a deferred keyup after a short delay + setTimeout(() => { + console.debug(`Forcing the left meta key release`); + handleKeyPress(hidKey, false); + }, 100); + } }, - [ - handleModifierKeys, - sendKeyboardEvent, - isKeyboardLedManagedByHost, - setIsNumLockActive, - setIsCapsLockActive, - setIsScrollLockActive, - ], + [handleKeyPress, isKeyboardLockActive], ); const keyUpHandler = useCallback( - (e: KeyboardEvent) => { + async (e: KeyboardEvent) => { e.preventDefault(); - const prev = useHidStore.getState(); + const code = getAdjustedKeyCode(e); + const hidKey = keys[code]; - if (!isKeyboardLedManagedByHost) { - setIsNumLockActive(e.getModifierState("NumLock")); - setIsCapsLockActive(e.getModifierState("CapsLock")); - setIsScrollLockActive(e.getModifierState("ScrollLock")); + if (hidKey === undefined) { + console.warn(`Key up not mapped: ${code}`); + return; } - // Filtering out the key that was just released (keys[e.code]) - const newKeys = prev.activeKeys.filter(k => k !== keys[e.code]).filter(Boolean); - - // Filter out the modifier that was just released - const newModifiers = handleModifierKeys( - e, - prev.activeModifiers.filter(k => k !== modifiers[e.code]), - ); - - sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]); + console.debug(`Key up: ${hidKey}`); + handleKeyPress(hidKey, false); }, - [ - handleModifierKeys, - sendKeyboardEvent, - isKeyboardLedManagedByHost, - setIsNumLockActive, - setIsCapsLockActive, - setIsScrollLockActive, - ], + [handleKeyPress], ); const videoKeyUpHandler = useCallback((e: KeyboardEvent) => { @@ -501,7 +396,7 @@ export default function WebRTCVideo() { // Fix only works in chrome based browsers. if (e.code === "Space") { if (videoElm.current.paused) { - console.log("Force playing video"); + console.debug("Force playing video"); videoElm.current.play(); } } @@ -544,13 +439,7 @@ export default function WebRTCVideo() { // We set the as early as possible addStreamToVideoElm(mediaStream); }, - [ - setVideoClientSize, - mediaStream, - updateVideoSizeStore, - peerConnection, - addStreamToVideoElm, - ], + [addStreamToVideoElm, mediaStream], ); // Setup Keyboard Events @@ -606,7 +495,7 @@ export default function WebRTCVideo() { videoElmRefValue.addEventListener("mousemove", isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler, { signal }); videoElmRefValue.addEventListener("pointerdown", isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler, { signal }); - videoElmRefValue.addEventListener("pointerup", isRelativeMouseMode ? relMouseMoveHandler :absMouseMoveHandler, { signal }); + videoElmRefValue.addEventListener("pointerup", isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler, { signal }); videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { signal, passive: true, @@ -663,10 +552,22 @@ export default function WebRTCVideo() { return isDefault ? {} // No filter if all settings are default (1.0) : { - filter: `saturate(${videoSaturation}) brightness(${videoBrightness}) contrast(${videoContrast})`, - }; + filter: `saturate(${videoSaturation}) brightness(${videoBrightness}) contrast(${videoContrast})`, + }; }, [videoSaturation, videoBrightness, videoContrast]); + function getAdjustedKeyCode(e: KeyboardEvent) { + const key = e.key; + let code = e.code; + + if (code == "IntlBackslash" && ["`", "~"].includes(key)) { + code = "Backquote"; + } else if (code == "Backquote" && ["§", "±"].includes(key)) { + code = "IntlBackslash"; + } + return code; + } + return (
@@ -699,48 +600,48 @@ export default function WebRTCVideo() {
-
diff --git a/ui/src/components/extensions/SerialConsole.tsx b/ui/src/components/extensions/SerialConsole.tsx index 3304fca0..e36365ff 100644 --- a/ui/src/components/extensions/SerialConsole.tsx +++ b/ui/src/components/extensions/SerialConsole.tsx @@ -49,7 +49,7 @@ export function SerialConsole() { setSettings(newSettings); }); }; - const setTerminalType = useUiStore(state => state.setTerminalType); + const { setTerminalType } = useUiStore(); return (
diff --git a/ui/src/components/popovers/MountPopover.tsx b/ui/src/components/popovers/MountPopover.tsx index 537d9887..1ed57d1c 100644 --- a/ui/src/components/popovers/MountPopover.tsx +++ b/ui/src/components/popovers/MountPopover.tsx @@ -21,7 +21,7 @@ import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import notifications from "@/notifications"; const MountPopopover = forwardRef((_props, ref) => { - const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats); + const { diskDataChannelStats } = useRTCStore(); const { send } = useJsonRpc(); const { remoteVirtualMediaState, setModalView, setRemoteVirtualMediaState } = useMountMediaStore(); diff --git a/ui/src/components/popovers/PasteModal.tsx b/ui/src/components/popovers/PasteModal.tsx index 7e45c072..077759b7 100644 --- a/ui/src/components/popovers/PasteModal.tsx +++ b/ui/src/components/popovers/PasteModal.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { LuCornerDownLeft } from "react-icons/lu"; import { ExclamationCircleIcon } from "@heroicons/react/16/solid"; import { useClose } from "@headlessui/react"; @@ -10,7 +10,8 @@ import { SettingsPageHeader } from "@components/SettingsPageheader"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { useHidStore, useRTCStore, useUiStore, useSettingsStore } from "@/hooks/stores"; import { keys, modifiers } from "@/keyboardMappings"; -import { KeyStroke, KeyboardLayout, selectedKeyboard } from "@/keyboardLayouts"; +import { KeyStroke } from "@/keyboardLayouts"; +import useKeyboardLayout from "@/hooks/useKeyboardLayout"; import notifications from "@/notifications"; const hidKeyboardPayload = (modifier: number, keys: number[]) => { @@ -18,33 +19,24 @@ const hidKeyboardPayload = (modifier: number, keys: number[]) => { }; const modifierCode = (shift?: boolean, altRight?: boolean) => { - return (shift ? modifiers["ShiftLeft"] : 0) - | (altRight ? modifiers["AltRight"] : 0) + return (shift ? modifiers.ShiftLeft : 0) + | (altRight ? modifiers.AltRight : 0) } const noModifier = 0 export default function PasteModal() { const TextAreaRef = useRef(null); - const setPasteMode = useHidStore(state => state.setPasteModeEnabled); - const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap); + const { setPasteModeEnabled } = useHidStore(); + const { setDisableVideoFocusTrap } = useUiStore(); const { send } = useJsonRpc(); - const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); + const { rpcDataChannel } = useRTCStore(); const [invalidChars, setInvalidChars] = useState([]); const close = useClose(); - const keyboardLayout = useSettingsStore(state => state.keyboardLayout); - const setKeyboardLayout = useSettingsStore( - state => state.setKeyboardLayout, - ); - - // this ensures we always get the original en_US if it hasn't been set yet - const safeKeyboardLayout = useMemo(() => { - if (keyboardLayout && keyboardLayout.length > 0) - return keyboardLayout; - return "en_US"; - }, [keyboardLayout]); + const { setKeyboardLayout } = useSettingsStore(); + const { selectedKeyboard } = useKeyboardLayout(); useEffect(() => { send("getKeyboardLayout", {}, (resp: JsonRpcResponse) => { @@ -54,24 +46,23 @@ export default function PasteModal() { }, [send, setKeyboardLayout]); const onCancelPasteMode = useCallback(() => { - setPasteMode(false); + setPasteModeEnabled(false); setDisableVideoFocusTrap(false); setInvalidChars([]); - }, [setDisableVideoFocusTrap, setPasteMode]); + }, [setDisableVideoFocusTrap, setPasteModeEnabled]); const onConfirmPaste = useCallback(async () => { - setPasteMode(false); + setPasteModeEnabled(false); setDisableVideoFocusTrap(false); if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return; - const keyboard: KeyboardLayout = selectedKeyboard(safeKeyboardLayout); - if (!keyboard) return; + if (!selectedKeyboard) return; const text = TextAreaRef.current.value; try { for (const char of text) { - const keyprops = keyboard.chars[char]; + const keyprops = selectedKeyboard.chars[char]; if (!keyprops) continue; const { key, shift, altRight, deadKey, accentKey } = keyprops; @@ -111,7 +102,7 @@ export default function PasteModal() { ); }); } - }, [rpcDataChannel?.readyState, safeKeyboardLayout, send, setDisableVideoFocusTrap, setPasteMode]); + }, [selectedKeyboard, rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteModeEnabled]); useEffect(() => { if (TextAreaRef.current) { @@ -161,7 +152,7 @@ export default function PasteModal() { // @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments [...new Intl.Segmenter().segment(value)] .map(x => x.segment) - .filter(char => !selectedKeyboard(safeKeyboardLayout).chars[char]), + .filter(char => !selectedKeyboard.chars[char]), ), ]; @@ -182,7 +173,7 @@ export default function PasteModal() {

- Sending text using keyboard layout: {selectedKeyboard(safeKeyboardLayout).name} + Sending text using keyboard layout: {selectedKeyboard.isoCode}-{selectedKeyboard.name}

diff --git a/ui/src/components/popovers/WakeOnLan/Index.tsx b/ui/src/components/popovers/WakeOnLan/Index.tsx index 59ca071a..6ebf3c79 100644 --- a/ui/src/components/popovers/WakeOnLan/Index.tsx +++ b/ui/src/components/popovers/WakeOnLan/Index.tsx @@ -14,10 +14,8 @@ import AddDeviceForm from "./AddDeviceForm"; export default function WakeOnLanModal() { const [storedDevices, setStoredDevices] = useState([]); const [showAddForm, setShowAddForm] = useState(false); - const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap); - - const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); - + const { setDisableVideoFocusTrap } = useUiStore(); + const { rpcDataChannel } = useRTCStore(); const { send } = useJsonRpc(); const close = useClose(); const [errorMessage, setErrorMessage] = useState(null); diff --git a/ui/src/components/sidebar/connectionStats.tsx b/ui/src/components/sidebar/connectionStats.tsx index 404deb14..3faf81ba 100644 --- a/ui/src/components/sidebar/connectionStats.tsx +++ b/ui/src/components/sidebar/connectionStats.tsx @@ -37,10 +37,18 @@ function createChartArray( } export default function ConnectionStatsSidebar() { - const inboundRtpStats = useRTCStore(state => state.inboundRtpStats); - - const candidatePairStats = useRTCStore(state => state.candidatePairStats); - const setSidebarView = useUiStore(state => state.setSidebarView); + const { sidebarView, setSidebarView } = useUiStore(); + const { + mediaStream, + peerConnection, + inboundRtpStats, + appendInboundRtpStats, + candidatePairStats, + appendCandidatePairStats, + appendLocalCandidateStats, + appendRemoteCandidateStats, + appendDiskDataChannelStats, + } = useRTCStore(); function isMetricSupported( stream: Map, @@ -49,20 +57,6 @@ export default function ConnectionStatsSidebar() { return Array.from(stream).some(([, stat]) => stat[metric] !== undefined); } - const appendInboundRtpStats = useRTCStore(state => state.appendInboundRtpStats); - const appendIceCandidatePair = useRTCStore(state => state.appendCandidatePairStats); - const appendDiskDataChannelStats = useRTCStore( - state => state.appendDiskDataChannelStats, - ); - const appendLocalCandidateStats = useRTCStore(state => state.appendLocalCandidateStats); - const appendRemoteCandidateStats = useRTCStore( - state => state.appendRemoteCandidateStats, - ); - - const peerConnection = useRTCStore(state => state.peerConnection); - const mediaStream = useRTCStore(state => state.mediaStream); - const sidebarView = useUiStore(state => state.sidebarView); - useInterval(function collectWebRTCStats() { (async () => { if (!mediaStream) return; @@ -80,8 +74,7 @@ export default function ConnectionStatsSidebar() { successfulLocalCandidateId = report.localCandidateId; successfulRemoteCandidateId = report.remoteCandidateId; } - - appendIceCandidatePair(report); + appendCandidatePairStats(report); } else if (report.type === "local-candidate") { // We only want to append the local candidate stats that were used in nominated candidate pair if (successfulLocalCandidateId === report.id) { diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index a41f2d71..f0718255 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -47,12 +47,12 @@ export interface User { picture?: string; } -interface UserState { +export interface UserState { user: User | null; setUser: (user: User | null) => void; } -interface UIState { +export interface UIState { sidebarView: AvailableSidebarViews | null; setSidebarView: (view: AvailableSidebarViews | null) => void; @@ -68,21 +68,21 @@ interface UIState { setAttachedVirtualKeyboardVisibility: (enabled: boolean) => void; terminalType: AvailableTerminalTypes; - setTerminalType: (enabled: UIState["terminalType"]) => void; + setTerminalType: (type: UIState["terminalType"]) => void; } export const useUiStore = create(set => ({ terminalType: "none", - setTerminalType: type => set({ terminalType: type }), + setTerminalType: (type: UIState["terminalType"]) => set({ terminalType: type }), sidebarView: null, - setSidebarView: view => set({ sidebarView: view }), + setSidebarView: (view: AvailableSidebarViews | null) => set({ sidebarView: view }), disableVideoFocusTrap: false, - setDisableVideoFocusTrap: enabled => set({ disableVideoFocusTrap: enabled }), + setDisableVideoFocusTrap: (enabled: boolean) => set({ disableVideoFocusTrap: enabled }), isWakeOnLanModalVisible: false, - setWakeOnLanModalVisibility: enabled => set({ isWakeOnLanModalVisible: enabled }), + setWakeOnLanModalVisibility: (enabled: boolean) => set({ isWakeOnLanModalVisible: enabled }), toggleSidebarView: view => set(state => { @@ -94,11 +94,11 @@ export const useUiStore = create(set => ({ }), isAttachedVirtualKeyboardVisible: true, - setAttachedVirtualKeyboardVisibility: enabled => + setAttachedVirtualKeyboardVisibility: (enabled: boolean) => set({ isAttachedVirtualKeyboardVisible: enabled }), })); -interface RTCState { +export interface RTCState { peerConnection: RTCPeerConnection | null; setPeerConnection: (pc: RTCState["peerConnection"]) => void; @@ -118,18 +118,18 @@ interface RTCState { setMediaStream: (stream: MediaStream) => void; videoStreamStats: RTCInboundRtpStreamStats | null; - appendVideoStreamStats: (state: RTCInboundRtpStreamStats) => void; + appendVideoStreamStats: (stats: RTCInboundRtpStreamStats) => void; videoStreamStatsHistory: Map; isTurnServerInUse: boolean; setTurnServerInUse: (inUse: boolean) => void; inboundRtpStats: Map; - appendInboundRtpStats: (state: RTCInboundRtpStreamStats) => void; + appendInboundRtpStats: (stats: RTCInboundRtpStreamStats) => void; clearInboundRtpStats: () => void; candidatePairStats: Map; - appendCandidatePairStats: (pair: RTCIceCandidatePairStats) => void; + appendCandidatePairStats: (stats: RTCIceCandidatePairStats) => void; clearCandidatePairStats: () => void; // Remote ICE candidates stat type doesn't exist as of today @@ -141,7 +141,7 @@ interface RTCState { // Disk data channel stats type doesn't exist as of today diskDataChannelStats: Map; - appendDiskDataChannelStats: (stat: RTCDataChannelStats) => void; + appendDiskDataChannelStats: (stats: RTCDataChannelStats) => void; terminalChannel: RTCDataChannel | null; setTerminalChannel: (channel: RTCDataChannel) => void; @@ -149,78 +149,78 @@ interface RTCState { export const useRTCStore = create(set => ({ peerConnection: null, - setPeerConnection: pc => set({ peerConnection: pc }), + setPeerConnection: (pc: RTCState["peerConnection"]) => set({ peerConnection: pc }), rpcDataChannel: null, - setRpcDataChannel: channel => set({ rpcDataChannel: channel }), + setRpcDataChannel: (channel: RTCDataChannel) => set({ rpcDataChannel: channel }), transceiver: null, - setTransceiver: transceiver => set({ transceiver }), + setTransceiver: (transceiver: RTCRtpTransceiver) => set({ transceiver }), peerConnectionState: null, - setPeerConnectionState: state => set({ peerConnectionState: state }), + setPeerConnectionState: (state: RTCPeerConnectionState) => set({ peerConnectionState: state }), diskChannel: null, - setDiskChannel: channel => set({ diskChannel: channel }), + setDiskChannel: (channel: RTCDataChannel) => set({ diskChannel: channel }), mediaStream: null, - setMediaStream: stream => set({ mediaStream: stream }), + setMediaStream: (stream: MediaStream) => set({ mediaStream: stream }), videoStreamStats: null, - appendVideoStreamStats: stats => set({ videoStreamStats: stats }), + appendVideoStreamStats: (stats: RTCInboundRtpStreamStats) => set({ videoStreamStats: stats }), videoStreamStatsHistory: new Map(), isTurnServerInUse: false, - setTurnServerInUse: inUse => set({ isTurnServerInUse: inUse }), + setTurnServerInUse: (inUse: boolean) => set({ isTurnServerInUse: inUse }), inboundRtpStats: new Map(), - appendInboundRtpStats: newStat => { + appendInboundRtpStats: (stats: RTCInboundRtpStreamStats) => { set(prevState => ({ - inboundRtpStats: appendStatToMap(newStat, prevState.inboundRtpStats), + inboundRtpStats: appendStatToMap(stats, prevState.inboundRtpStats), })); }, clearInboundRtpStats: () => set({ inboundRtpStats: new Map() }), candidatePairStats: new Map(), - appendCandidatePairStats: newStat => { + appendCandidatePairStats: (stats: RTCIceCandidatePairStats) => { set(prevState => ({ - candidatePairStats: appendStatToMap(newStat, prevState.candidatePairStats), + candidatePairStats: appendStatToMap(stats, prevState.candidatePairStats), })); }, clearCandidatePairStats: () => set({ candidatePairStats: new Map() }), localCandidateStats: new Map(), - appendLocalCandidateStats: newStat => { + appendLocalCandidateStats: (stats: RTCIceCandidateStats) => { set(prevState => ({ - localCandidateStats: appendStatToMap(newStat, prevState.localCandidateStats), + localCandidateStats: appendStatToMap(stats, prevState.localCandidateStats), })); }, remoteCandidateStats: new Map(), - appendRemoteCandidateStats: newStat => { + appendRemoteCandidateStats: (stats: RTCIceCandidateStats) => { set(prevState => ({ - remoteCandidateStats: appendStatToMap(newStat, prevState.remoteCandidateStats), + remoteCandidateStats: appendStatToMap(stats, prevState.remoteCandidateStats), })); }, diskDataChannelStats: new Map(), - appendDiskDataChannelStats: newStat => { + appendDiskDataChannelStats: (stats: RTCDataChannelStats) => { set(prevState => ({ - diskDataChannelStats: appendStatToMap(newStat, prevState.diskDataChannelStats), + diskDataChannelStats: appendStatToMap(stats, prevState.diskDataChannelStats), })); }, // Add these new properties to the store implementation terminalChannel: null, - setTerminalChannel: channel => set({ terminalChannel: channel }), + setTerminalChannel: (channel: RTCDataChannel) => set({ terminalChannel: channel }), })); -interface MouseMove { +export interface MouseMove { x: number; y: number; buttons: number; } -interface MouseState { +export interface MouseState { mouseX: number; mouseY: number; mouseMove?: MouseMove; @@ -232,9 +232,17 @@ export const useMouseStore = create(set => ({ mouseX: 0, mouseY: 0, setMouseMove: (move?: MouseMove) => set({ mouseMove: move }), - setMousePosition: (x, y) => set({ mouseX: x, mouseY: y }), + setMousePosition: (x: number, y: number) => set({ mouseX: x, mouseY: y }), })); +export type HdmiStates = "ready" | "no_signal" | "no_lock" | "out_of_range" | "connecting"; +export type HdmiErrorStates = Extract + +export interface HdmiState { + ready: boolean; + error?: HdmiErrorStates; +} + export interface VideoState { width: number; height: number; @@ -242,19 +250,13 @@ export interface VideoState { clientHeight: number; setClientSize: (width: number, height: number) => void; setSize: (width: number, height: number) => void; - hdmiState: "ready" | "no_signal" | "no_lock" | "out_of_range" | "connecting"; + hdmiState: HdmiStates; setHdmiState: (state: { ready: boolean; - error?: Extract; + error?: HdmiErrorStates; }) => void; } -export interface BacklightSettings { - max_brightness: number; - dim_after: number; - off_after: number; -} - export const useVideoStore = create(set => ({ width: 0, height: 0, @@ -263,13 +265,13 @@ export const useVideoStore = create(set => ({ clientHeight: 0, // The video element's client size - setClientSize: (clientWidth, clientHeight) => set({ clientWidth, clientHeight }), + setClientSize: (clientWidth: number, clientHeight: number) => set({ clientWidth, clientHeight }), // Resolution - setSize: (width, height) => set({ width, height }), + setSize: (width: number, height: number) => set({ width, height }), hdmiState: "connecting", - setHdmiState: state => { + setHdmiState: (state: HdmiState) => { if (!state) return; const { ready, error } = state; @@ -283,9 +285,13 @@ export const useVideoStore = create(set => ({ }, })); -export type KeyboardLedSync = "auto" | "browser" | "host"; +export interface BacklightSettings { + max_brightness: number; + dim_after: number; + off_after: number; +} -interface SettingsState { +export interface SettingsState { isCursorHidden: boolean; setCursorVisibility: (enabled: boolean) => void; @@ -308,9 +314,6 @@ interface SettingsState { keyboardLayout: string; setKeyboardLayout: (layout: string) => void; - keyboardLedSync: KeyboardLedSync; - setKeyboardLedSync: (sync: KeyboardLedSync) => void; - scrollThrottling: number; setScrollThrottling: (value: number) => void; @@ -330,17 +333,17 @@ export const useSettingsStore = create( persist( set => ({ isCursorHidden: false, - setCursorVisibility: enabled => set({ isCursorHidden: enabled }), + setCursorVisibility: (enabled: boolean) => set({ isCursorHidden: enabled }), mouseMode: "absolute", - setMouseMode: mode => set({ mouseMode: mode }), + setMouseMode: (mode: string) => set({ mouseMode: mode }), debugMode: import.meta.env.DEV, - setDebugMode: enabled => set({ debugMode: enabled }), + setDebugMode: (enabled: boolean) => set({ debugMode: enabled }), // Add developer mode with default value developerMode: false, - setDeveloperMode: enabled => set({ developerMode: enabled }), + setDeveloperMode: (enabled: boolean) => set({ developerMode: enabled }), displayRotation: "270", setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }), @@ -354,24 +357,21 @@ export const useSettingsStore = create( set({ backlightSettings: settings }), keyboardLayout: "en-US", - setKeyboardLayout: layout => set({ keyboardLayout: layout }), - - keyboardLedSync: "auto", - setKeyboardLedSync: sync => set({ keyboardLedSync: sync }), + setKeyboardLayout: (layout: string) => set({ keyboardLayout: layout }), scrollThrottling: 0, - setScrollThrottling: value => set({ scrollThrottling: value }), + setScrollThrottling: (value: number) => set({ scrollThrottling: value }), showPressedKeys: true, - setShowPressedKeys: show => set({ showPressedKeys: show }), + setShowPressedKeys: (show: boolean) => set({ showPressedKeys: show }), // Video enhancement settings with default values (1.0 = normal) videoSaturation: 1.0, - setVideoSaturation: value => set({ videoSaturation: value }), + setVideoSaturation: (value: number) => set({ videoSaturation: value }), videoBrightness: 1.0, - setVideoBrightness: value => set({ videoBrightness: value }), + setVideoBrightness: (value: number) => set({ videoBrightness: value }), videoContrast: 1.0, - setVideoContrast: value => set({ videoContrast: value }), + setVideoContrast: (value: number) => set({ videoContrast: value }), }), { name: "settings", @@ -411,23 +411,23 @@ export interface MountMediaState { export const useMountMediaStore = create(set => ({ localFile: null, - setLocalFile: file => set({ localFile: file }), + setLocalFile: (file: MountMediaState["localFile"]) => set({ localFile: file }), remoteVirtualMediaState: null, - setRemoteVirtualMediaState: state => set({ remoteVirtualMediaState: state }), + setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => set({ remoteVirtualMediaState: state }), modalView: "mode", - setModalView: view => set({ modalView: view }), + setModalView: (view: MountMediaState["modalView"]) => set({ modalView: view }), isMountMediaDialogOpen: false, - setIsMountMediaDialogOpen: isOpen => set({ isMountMediaDialogOpen: isOpen }), + setIsMountMediaDialogOpen: (isOpen: MountMediaState["isMountMediaDialogOpen"]) => set({ isMountMediaDialogOpen: isOpen }), uploadedFiles: [], - addUploadedFile: file => + addUploadedFile: (file: { name: string; size: string; uploadedAt: string }) => set(state => ({ uploadedFiles: [...state.uploadedFiles, file] })), errorMessage: null, - setErrorMessage: message => set({ errorMessage: message }), + setErrorMessage: (message: string | null) => set({ errorMessage: message }), })); export interface KeyboardLedState { @@ -436,41 +436,33 @@ export interface KeyboardLedState { scroll_lock: boolean; compose: boolean; kana: boolean; + shift: boolean; // Optional, as not all keyboards have a shift LED }; -const defaultKeyboardLedState: KeyboardLedState = { - num_lock: false, - caps_lock: false, - scroll_lock: false, - compose: false, - kana: false, -}; + +export const hidKeyBufferSize = 6; +export const hidErrorRollOver = 0x01; + +export interface KeysDownState { + modifier: number; + keys: number[]; +} + +export type USBStates = + | "configured" + | "attached" + | "not attached" + | "suspended" + | "addressed"; export interface HidState { - activeKeys: number[]; - activeModifiers: number[]; - - updateActiveKeysAndModifiers: (keysAndModifiers: { - keys: number[]; - modifiers: number[]; - }) => void; - - altGrArmed: boolean; - setAltGrArmed: (armed: boolean) => void; - - altGrTimer: number | null; // _altGrCtrlTime - setAltGrTimer: (timeout: number | null) => void; - - altGrCtrlTime: number; // _altGrCtrlTime - setAltGrCtrlTime: (time: number) => void; - - keyboardLedState?: KeyboardLedState; + keyboardLedState: KeyboardLedState; setKeyboardLedState: (state: KeyboardLedState) => void; - setIsNumLockActive: (active: boolean) => void; - setIsCapsLockActive: (active: boolean) => void; - setIsScrollLockActive: (active: boolean) => void; - keyboardLedStateSyncAvailable: boolean; - setKeyboardLedStateSyncAvailable: (available: boolean) => void; + keysDownState: KeysDownState; + setKeysDownState: (state: KeysDownState) => void; + + keyPressReportApiAvailable: boolean; + setkeyPressReportApiAvailable: (available: boolean) => void; isVirtualKeyboardEnabled: boolean; setVirtualKeyboardEnabled: (enabled: boolean) => void; @@ -478,55 +470,29 @@ export interface HidState { isPasteModeEnabled: boolean; setPasteModeEnabled: (enabled: boolean) => void; - usbState: "configured" | "attached" | "not attached" | "suspended" | "addressed"; - setUsbState: (state: HidState["usbState"]) => void; + usbState: USBStates; + setUsbState: (state: USBStates) => void; } -export const useHidStore = create((set, get) => ({ - activeKeys: [], - activeModifiers: [], - updateActiveKeysAndModifiers: ({ keys, modifiers }) => { - return set({ activeKeys: keys, activeModifiers: modifiers }); - }, +export const useHidStore = create(set => ({ + keyboardLedState: {} as KeyboardLedState, + setKeyboardLedState: (ledState: KeyboardLedState): void => set({ keyboardLedState: ledState }), - altGrArmed: false, - setAltGrArmed: armed => set({ altGrArmed: armed }), + keysDownState: { modifier: 0, keys: [0,0,0,0,0,0] } as KeysDownState, + setKeysDownState: (state: KeysDownState): void => set({ keysDownState: state }), - altGrTimer: 0, - setAltGrTimer: timeout => set({ altGrTimer: timeout }), - - altGrCtrlTime: 0, - setAltGrCtrlTime: time => set({ altGrCtrlTime: time }), - - setKeyboardLedState: ledState => set({ keyboardLedState: ledState }), - setIsNumLockActive: active => { - const keyboardLedState = { ...(get().keyboardLedState || defaultKeyboardLedState) }; - keyboardLedState.num_lock = active; - set({ keyboardLedState }); - }, - setIsCapsLockActive: active => { - const keyboardLedState = { ...(get().keyboardLedState || defaultKeyboardLedState) }; - keyboardLedState.caps_lock = active; - set({ keyboardLedState }); - }, - setIsScrollLockActive: active => { - const keyboardLedState = { ...(get().keyboardLedState || defaultKeyboardLedState) }; - keyboardLedState.scroll_lock = active; - set({ keyboardLedState }); - }, - - keyboardLedStateSyncAvailable: false, - setKeyboardLedStateSyncAvailable: available => set({ keyboardLedStateSyncAvailable: available }), + keyPressReportApiAvailable: true, + setkeyPressReportApiAvailable: (available: boolean) => set({ keyPressReportApiAvailable: available }), isVirtualKeyboardEnabled: false, - setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }), + setVirtualKeyboardEnabled: (enabled: boolean): void => set({ isVirtualKeyboardEnabled: enabled }), isPasteModeEnabled: false, - setPasteModeEnabled: enabled => set({ isPasteModeEnabled: enabled }), + setPasteModeEnabled: (enabled: boolean): void => set({ isPasteModeEnabled: enabled }), // Add these new properties for USB state usbState: "not attached", - setUsbState: state => set({ usbState: state }), + setUsbState: (state: USBStates) => set({ usbState: state }), })); export const useUserStore = create(set => ({ @@ -534,11 +500,15 @@ export const useUserStore = create(set => ({ setUser: user => set({ user }), })); -export interface UpdateState { - isUpdatePending: boolean; - setIsUpdatePending: (isPending: boolean) => void; - updateDialogHasBeenMinimized: boolean; - otaState: { +export type UpdateModalViews = + | "loading" + | "updating" + | "upToDate" + | "updateAvailable" + | "updateCompleted" + | "error"; + +export interface OtaState { updating: boolean; error: string | null; @@ -567,24 +537,24 @@ export interface UpdateState { systemUpdateProgress: number; systemUpdatedAt: string | null; - }; - setOtaState: (state: UpdateState["otaState"]) => void; +}; + +export interface UpdateState { + isUpdatePending: boolean; + setIsUpdatePending: (isPending: boolean) => void; + updateDialogHasBeenMinimized: boolean; + otaState: OtaState; + setOtaState: (state: OtaState) => void; setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void; - modalView: - | "loading" - | "updating" - | "upToDate" - | "updateAvailable" - | "updateCompleted" - | "error"; - setModalView: (view: UpdateState["modalView"]) => void; + modalView: UpdateModalViews + setModalView: (view: UpdateModalViews) => void; setUpdateErrorMessage: (errorMessage: string) => void; updateErrorMessage: string | null; } export const useUpdateStore = create(set => ({ isUpdatePending: false, - setIsUpdatePending: isPending => set({ isUpdatePending: isPending }), + setIsUpdatePending: (isPending: boolean) => set({ isUpdatePending: isPending }), setOtaState: state => set({ otaState: state }), otaState: { @@ -608,18 +578,22 @@ export const useUpdateStore = create(set => ({ }, updateDialogHasBeenMinimized: false, - setUpdateDialogHasBeenMinimized: hasBeenMinimized => + setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => set({ updateDialogHasBeenMinimized: hasBeenMinimized }), modalView: "loading", - setModalView: view => set({ modalView: view }), + setModalView: (view: UpdateModalViews) => set({ modalView: view }), updateErrorMessage: null, - setUpdateErrorMessage: errorMessage => set({ updateErrorMessage: errorMessage }), + setUpdateErrorMessage: (errorMessage: string) => set({ updateErrorMessage: errorMessage }), })); -interface UsbConfigModalState { - modalView: "updateUsbConfig" | "updateUsbConfigSuccess"; +export type UsbConfigModalViews = + | "updateUsbConfig" + | "updateUsbConfigSuccess"; + +export interface UsbConfigModalState { + modalView: UsbConfigModalViews ; errorMessage: string | null; - setModalView: (view: UsbConfigModalState["modalView"]) => void; + setModalView: (view: UsbConfigModalViews) => void; setErrorMessage: (message: string | null) => void; } @@ -634,24 +608,26 @@ export interface UsbConfigState { export const useUsbConfigModalStore = create(set => ({ modalView: "updateUsbConfig", errorMessage: null, - setModalView: view => set({ modalView: view }), - setErrorMessage: message => set({ errorMessage: message }), + setModalView: (view: UsbConfigModalViews) => set({ modalView: view }), + setErrorMessage: (message: string | null) => set({ errorMessage: message }), })); -interface LocalAuthModalState { - modalView: - | "createPassword" - | "deletePassword" - | "updatePassword" - | "creationSuccess" - | "deleteSuccess" - | "updateSuccess"; - setModalView: (view: LocalAuthModalState["modalView"]) => void; +export type LocalAuthModalViews = + | "createPassword" + | "deletePassword" + | "updatePassword" + | "creationSuccess" + | "deleteSuccess" + | "updateSuccess"; + +export interface LocalAuthModalState { + modalView:LocalAuthModalViews; + setModalView: (view:LocalAuthModalViews) => void; } export const useLocalAuthModalStore = create(set => ({ modalView: "createPassword", - setModalView: view => set({ modalView: view }), + setModalView: (view: LocalAuthModalViews) => set({ modalView: view }), })); export interface DeviceState { @@ -666,8 +642,8 @@ export const useDeviceStore = create(set => ({ appVersion: null, systemVersion: null, - setAppVersion: version => set({ appVersion: version }), - setSystemVersion: version => set({ systemVersion: version }), + setAppVersion: (version: string) => set({ appVersion: version }), + setSystemVersion: (version: string) => set({ systemVersion: version }), })); export interface DhcpLease { @@ -913,7 +889,7 @@ export const useMacrosStore = create((set, get) => ({ sendFn( "setKeyboardMacros", { params: { macros: macrosWithSortOrder } }, - response => { + (response: JsonRpcResponse) => { resolve(response); }, ); diff --git a/ui/src/hooks/useJsonRpc.ts b/ui/src/hooks/useJsonRpc.ts index fdb144df..b4fcc8ef 100644 --- a/ui/src/hooks/useJsonRpc.ts +++ b/ui/src/hooks/useJsonRpc.ts @@ -33,10 +33,10 @@ const callbackStore = new Map void>( let requestCounter = 0; export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) { - const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); + const { rpcDataChannel } = useRTCStore(); const send = useCallback( - (method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => { + async (method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => { if (rpcDataChannel?.readyState !== "open") return; requestCounter++; const payload = { jsonrpc: "2.0", method, params, id: requestCounter }; @@ -45,7 +45,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) { rpcDataChannel.send(JSON.stringify(payload)); }, - [rpcDataChannel], + [rpcDataChannel] ); useEffect(() => { @@ -61,7 +61,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) { return; } - if ("error" in payload) console.error(payload.error); + if ("error" in payload) console.error("RPC error", payload); if (!payload.id) return; const callback = callbackStore.get(payload.id); @@ -76,7 +76,8 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) { return () => { rpcDataChannel.removeEventListener("message", messageHandler); }; - }, [rpcDataChannel, onRequest]); + }, + [rpcDataChannel, onRequest]); return { send }; } diff --git a/ui/src/hooks/useKeyboard.ts b/ui/src/hooks/useKeyboard.ts index f08b44c6..5f587b08 100644 --- a/ui/src/hooks/useKeyboard.ts +++ b/ui/src/hooks/useKeyboard.ts @@ -1,42 +1,117 @@ import { useCallback } from "react"; -import { useHidStore, useRTCStore } from "@/hooks/stores"; -import { useJsonRpc } from "@/hooks/useJsonRpc"; -import { keys, modifiers } from "@/keyboardMappings"; +import { KeysDownState, useHidStore, useRTCStore, hidKeyBufferSize, hidErrorRollOver } from "@/hooks/stores"; +import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; +import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings"; export default function useKeyboard() { const { send } = useJsonRpc(); + const { rpcDataChannel } = useRTCStore(); + const { keysDownState, setKeysDownState } = useHidStore(); - const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); - const updateActiveKeysAndModifiers = useHidStore( - state => state.updateActiveKeysAndModifiers, - ); + // INTRODUCTION: The earlier version of the JetKVM device shipped with all keyboard state + // being tracked on the browser/client-side. When adding the keyPressReport API to the + // device-side code, we have to still support the situation where the browser/client-side code + // is running on the cloud against a device that has not been updated yet and thus does not + // support the keyPressReport API. In that case, we need to handle the key presses locally + // and send the full state to the device, so it can behave like a real USB HID keyboard. + // This flag indicates whether the keyPressReport API is available on the device which is + // dynamically set when the device responds to the first key press event or reports its + // keysDownState when queried since the keyPressReport was introduced together with the + // getKeysDownState API. + const { keyPressReportApiAvailable, setkeyPressReportApiAvailable} = useHidStore(); + // sendKeyboardEvent is used to send the full keyboard state to the device for macro handling + // and resetting keyboard state. It sends the keys currently pressed and the modifier state. + // The device will respond with the keysDownState if it supports the keyPressReport API + // or just accept the state if it does not support (returning no result) const sendKeyboardEvent = useCallback( - (keys: number[], modifiers: number[]) => { + async (state: KeysDownState) => { if (rpcDataChannel?.readyState !== "open") return; - const accModifier = modifiers.reduce((acc, val) => acc + val, 0); - send("keyboardReport", { keys, modifier: accModifier }); + console.debug(`Send keyboardReport keys: ${state.keys}, modifier: ${state.modifier}`); + send("keyboardReport", { keys: state.keys, modifier: state.modifier }, (resp: JsonRpcResponse) => { + if ("error" in resp) { + console.error(`Failed to send keyboard report ${state}`, resp.error); + } else { + // If the device supports keyPressReport API, it will (also) return the keysDownState when we send + // the keyboardReport + const keysDownState = resp.result as KeysDownState; - // We do this for the info bar to display the currently pressed keys for the user - updateActiveKeysAndModifiers({ keys: keys, modifiers: modifiers }); + if (keysDownState) { + setKeysDownState(keysDownState); // treat the response as the canonical state + setkeyPressReportApiAvailable(true); // if they returned a keysDownState, we ALSO know they also support keyPressReport + } else { + // older devices versions do not return the keyDownState + // so we just pretend they accepted what we sent + setKeysDownState(state); + setkeyPressReportApiAvailable(false); // we ALSO know they do not support keyPressReport + } + } + }); }, - [rpcDataChannel?.readyState, send, updateActiveKeysAndModifiers], + [rpcDataChannel?.readyState, send, setKeysDownState, setkeyPressReportApiAvailable], ); - const resetKeyboardState = useCallback(() => { - sendKeyboardEvent([], []); - }, [sendKeyboardEvent]); + // sendKeypressEvent is used to send a single key press/release event to the device. + // It sends the key and whether it is pressed or released. + // Older device version will not understand this request and will respond with + // an error with code -32601, which means that the RPC method name was not recognized. + // In that case we will switch to local key handling and update the keysDownState + // in client/browser-side code using simulateDeviceSideKeyHandlingForLegacyDevices. + const sendKeypressEvent = useCallback( + async (key: number, press: boolean) => { + if (rpcDataChannel?.readyState !== "open") return; + console.debug(`Send keypressEvent key: ${key}, press: ${press}`); + send("keypressReport", { key, press }, (resp: JsonRpcResponse) => { + if ("error" in resp) { + // -32601 means the method is not supported because the device is running an older version + if (resp.error.code === -32601) { + console.error("Legacy device does not support keypressReport API, switching to local key down state handling", resp.error); + setkeyPressReportApiAvailable(false); + } else { + console.error(`Failed to send key ${key} press: ${press}`, resp.error); + } + } else { + const keysDownState = resp.result as KeysDownState; + + if (keysDownState) { + setKeysDownState(keysDownState); + // we don't need to set keyPressReportApiAvailable here, because it's already true or we never landed here + } + } + }); + }, + [rpcDataChannel?.readyState, send, setkeyPressReportApiAvailable, setKeysDownState], + ); + + // resetKeyboardState is used to reset the keyboard state to no keys pressed and no modifiers. + // This is useful for macros and when the browser loses focus to ensure that the keyboard state + // is clean. + const resetKeyboardState = useCallback( + async () => { + // Reset the keys buffer to zeros and the modifier state to zero + keysDownState.keys.length = hidKeyBufferSize; + keysDownState.keys.fill(0); + keysDownState.modifier = 0; + sendKeyboardEvent(keysDownState); + }, [keysDownState, sendKeyboardEvent]); + + // executeMacro is used to execute a macro consisting of multiple steps. + // Each step can have multiple keys, multiple modifiers and a delay. + // The keys and modifiers are pressed together and held for the delay duration. + // After the delay, the keys and modifiers are released and the next step is executed. + // If a step has no keys or modifiers, it is treated as a delay-only step. + // A small pause is added between steps to ensure that the device can process the events. const executeMacro = async (steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[]) => { for (const [index, step] of steps.entries()) { - const keyValues = step.keys?.map(key => keys[key]).filter(Boolean) || []; - const modifierValues = step.modifiers?.map(mod => modifiers[mod]).filter(Boolean) || []; + const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean); + const modifierMask: number = (step.modifiers || []).map(mod => modifiers[mod]).reduce((acc, val) => acc + val, 0); // If the step has keys and/or modifiers, press them and hold for the delay - if (keyValues.length > 0 || modifierValues.length > 0) { - sendKeyboardEvent(keyValues, modifierValues); + if (keyValues.length > 0 || modifierMask > 0) { + sendKeyboardEvent({ keys: keyValues, modifier: modifierMask }); await new Promise(resolve => setTimeout(resolve, step.delay || 50)); resetKeyboardState(); @@ -52,5 +127,92 @@ export default function useKeyboard() { } }; - return { sendKeyboardEvent, resetKeyboardState, executeMacro }; + // handleKeyPress is used to handle a key press or release event. + // This function handle both key press and key release events. + // It checks if the keyPressReport API is available and sends the key press event. + // If the keyPressReport API is not available, it simulates the device-side key + // handling for legacy devices and updates the keysDownState accordingly. + // It then sends the full keyboard state to the device. + const handleKeyPress = useCallback( + async (key: number, press: boolean) => { + if (rpcDataChannel?.readyState !== "open") return; + if ((key || 0) === 0) return; // ignore zero key presses (they are bad mappings) + + if (keyPressReportApiAvailable) { + // if the keyPress api is available, we can just send the key press event + sendKeypressEvent(key, press); + } else { + // if the keyPress api is not available, we need to handle the key locally + const downState = simulateDeviceSideKeyHandlingForLegacyDevices(keysDownState, key, press); + sendKeyboardEvent(downState); // then we send the full state + + // if we just sent ErrorRollOver, reset to empty state + if (downState.keys[0] === hidErrorRollOver) { + resetKeyboardState(); + } + } + }, + [keyPressReportApiAvailable, keysDownState, resetKeyboardState, rpcDataChannel?.readyState, sendKeyboardEvent, sendKeypressEvent], + ); + + // IMPORTANT: See the keyPressReportApiAvailable comment above for the reason this exists + function simulateDeviceSideKeyHandlingForLegacyDevices(state: KeysDownState, key: number, press: boolean): KeysDownState { + // IMPORTANT: This code parallels the logic in the kernel's hid-gadget driver + // for handling key presses and releases. It ensures that the USB gadget + // behaves similarly to a real USB HID keyboard. This logic is paralleled + // in the device-side code in hid_keyboard.go so make sure to keep them in sync. + let modifiers = state.modifier; + const keys = state.keys; + const modifierMask = hidKeyToModifierMask[key] || 0; + + if (modifierMask !== 0) { + // If the key is a modifier key, we update the keyboardModifier state + // by setting or clearing the corresponding bit in the modifier byte. + // This allows us to track the state of dynamic modifier keys like + // Shift, Control, Alt, and Super. + if (press) { + modifiers |= modifierMask; + } else { + modifiers &= ~modifierMask; + } + } else { + // handle other keys that are not modifier keys by placing or removing them + // from the key buffer since the buffer tracks currently pressed keys + let overrun = true; + for (let i = 0; i < hidKeyBufferSize; i++) { + // If we find the key in the buffer the buffer, we either remove it (if press is false) + // or do nothing (if down is true) because the buffer tracks currently pressed keys + // and if we find a zero byte, we can place the key there (if press is true) + if (keys[i] === key || keys[i] === 0) { + if (press) { + keys[i] = key // overwrites the zero byte or the same key if already pressed + } else { + // we are releasing the key, remove it from the buffer + if (keys[i] !== 0) { + keys.splice(i, 1); + keys.push(0); // add a zero at the end + } + } + overrun = false; // We found a slot for the key + break; + } + } + + // If we reach here it means we didn't find an empty slot or the key in the buffer + if (overrun) { + if (press) { + console.warn(`keyboard buffer overflow current keys ${keys}, key: ${key} not added`); + // Fill all key slots with ErrorRollOver (0x01) to indicate overflow + keys.length = hidKeyBufferSize; + keys.fill(hidErrorRollOver); + } else { + // If we are releasing a key, and we didn't find it in a slot, who cares? + console.debug(`key ${key} not found in buffer, nothing to release`) + } + } + } + return { modifier: modifiers, keys }; + } + + return { handleKeyPress, resetKeyboardState, executeMacro }; } diff --git a/ui/src/hooks/useKeyboardLayout.ts b/ui/src/hooks/useKeyboardLayout.ts new file mode 100644 index 00000000..c1d0557c --- /dev/null +++ b/ui/src/hooks/useKeyboardLayout.ts @@ -0,0 +1,35 @@ +import { useMemo } from "react"; + +import { useSettingsStore } from "@/hooks/stores"; +import { keyboards } from "@/keyboardLayouts"; + +export default function useKeyboardLayout() { + const { keyboardLayout } = useSettingsStore(); + + const keyboardOptions = useMemo(() => { + return keyboards.map((keyboard) => { + return { label: keyboard.name, value: keyboard.isoCode } + }); + }, []); + + const isoCode = useMemo(() => { + // If we don't have a specific layout, default to "en-US" because that was the original layout + // developed so it is a good fallback. Additionally, we replace "en_US" with "en-US" because + // the original server-side code used "en_US" as the default value, but that's not the correct + // ISO code for English/United State. To ensure we remain backward compatible with devices that + // have not had their Keyboard Layout selected by the user, we want to treat "en_US" as if it was + // "en-US" to match the ISO standard codes now used in the keyboardLayouts. + console.debug("Current keyboard layout from store:", keyboardLayout); + if (keyboardLayout && keyboardLayout.length > 0) + return keyboardLayout.replace("en_US", "en-US"); + return "en-US"; + }, [keyboardLayout]); + + const selectedKeyboard = useMemo(() => { + // fallback to original behaviour of en-US if no isoCode given or matching layout not found + return keyboards.find(keyboard => keyboard.isoCode === isoCode) + ?? keyboards.find(keyboard => keyboard.isoCode === "en-US")!; + }, [isoCode]); + + return { keyboardOptions, isoCode, selectedKeyboard }; +} \ No newline at end of file diff --git a/ui/src/index.css b/ui/src/index.css index 44acd2ad..db03b427 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -315,6 +315,11 @@ video::-webkit-media-controls { @apply inline-flex h-auto! w-auto! grow-0 py-1 text-xs; } +.hg-theme-default .hg-row .down-key { + background: rgb(28, 28, 28); + @apply text-white! font-bold!; +} + .hg-theme-default .hg-row .hg-button-container, .hg-theme-default .hg-row .hg-button:not(:last-child) { @apply mr-[2px]! md:mr-[5px]!; diff --git a/ui/src/keyboardLayouts.ts b/ui/src/keyboardLayouts.ts index 4ae3ad94..4ae8970d 100644 --- a/ui/src/keyboardLayouts.ts +++ b/ui/src/keyboardLayouts.ts @@ -1,9 +1,20 @@ export interface KeyStroke { modifier: number; keys: number[]; } export interface KeyInfo { key: string | number; shift?: boolean, altRight?: boolean } export interface KeyCombo extends KeyInfo { deadKey?: boolean, accentKey?: KeyInfo } -export interface KeyboardLayout { isoCode: string, name: string, chars: Record } +export interface KeyboardLayout { + isoCode: string; + name: string; + chars: Record; + modifierDisplayMap: Record; + keyDisplayMap: Record; + virtualKeyboard: { + main: { default: string[], shift: string[] }, + control?: { default: string[], shift?: string[] }, + arrows?: { default: string[] } + }; +} -// to add a new layout, create a file like the above and add it to the list +// To add a new layout, create a file like the above and add it to the list import { cs_CZ } from "@/keyboardLayouts/cs_CZ" import { de_CH } from "@/keyboardLayouts/de_CH" import { de_DE } from "@/keyboardLayouts/de_DE" @@ -18,15 +29,3 @@ import { nb_NO } from "@/keyboardLayouts/nb_NO" import { sv_SE } from "@/keyboardLayouts/sv_SE" export const keyboards: KeyboardLayout[] = [ cs_CZ, de_CH, de_DE, en_UK, en_US, es_ES, fr_BE, fr_CH, fr_FR, it_IT, nb_NO, sv_SE ]; - -export const selectedKeyboard = (isoCode: string): KeyboardLayout => { - // fallback to original behaviour of en-US if no isoCode given - return keyboards.find(keyboard => keyboard.isoCode == isoCode) - ?? keyboards.find(keyboard => keyboard.isoCode == "en-US")!; -}; - -export const keyboardOptions = () => { - return keyboards.map((keyboard) => { - return { label: keyboard.name, value: keyboard.isoCode } - }); -} diff --git a/ui/src/keyboardLayouts/cs_CZ.ts b/ui/src/keyboardLayouts/cs_CZ.ts index e4f8822d..c02be702 100644 --- a/ui/src/keyboardLayouts/cs_CZ.ts +++ b/ui/src/keyboardLayouts/cs_CZ.ts @@ -1,17 +1,20 @@ import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" -const name = "Čeština"; +import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard -const keyTrema = { key: "Backslash" } // tréma (umlaut), two dots placed above a vowel -const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter -const keyHat = { key: "Digit3", shift: true, altRight: true } // accent circonflexe (accent hat), mark ^ placed above the letter -const keyCaron = { key: "Equal", shift: true } // caron or haček (inverted hat), mark ˇ placed above the letter -const keyGrave = { key: "Digit7", shift: true, altRight: true } // accent grave, mark ` placed above the letter -const keyTilde = { key: "Digit1", shift: true, altRight: true } // tilde, mark ~ placed above the letter -const keyRing = { key: "Backquote", shift: true } // kroužek (little ring), mark ° placed above the letter -const keyOverdot = { key: "Digit8", shift: true, altRight: true } // overdot (dot above), mark ˙ placed above the letter -const keyHook = { key: "Digit6", shift: true, altRight: true } // ogonoek (little hook), mark ˛ placed beneath a letter -const keyCedille = { key: "Equal", shift: true, altRight: true } // accent cedille (cedilla), mark ¸ placed beneath a letter +const name = "Čeština"; +const isoCode = "cs-CZ"; + +const keyTrema: KeyCombo = { key: "Backslash" } // tréma (umlaut), two dots placed above a vowel +const keyAcute: KeyCombo = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter +const keyHat: KeyCombo = { key: "Digit3", shift: true, altRight: true } // accent circonflexe (accent hat), mark ^ placed above the letter +const keyCaron: KeyCombo = { key: "Equal", shift: true } // caron or haček (inverted hat), mark ˇ placed above the letter +const keyGrave: KeyCombo = { key: "Digit7", shift: true, altRight: true } // accent grave, mark ` placed above the letter +const keyTilde: KeyCombo = { key: "Digit1", shift: true, altRight: true } // tilde, mark ~ placed above the letter +const keyRing: KeyCombo = { key: "Backquote", shift: true } // kroužek (little ring), mark ° placed above the letter +const keyOverdot: KeyCombo = { key: "Digit8", shift: true, altRight: true } // overdot (dot above), mark ˙ placed above the letter +const keyHook: KeyCombo = { key: "Digit6", shift: true, altRight: true } // ogonoek (little hook), mark ˛ placed beneath a letter +const keyCedille: KeyCombo = { key: "Equal", shift: true, altRight: true } // accent cedille (cedilla), mark ¸ placed beneath a letter const chars = { A: { key: "KeyA", shift: true }, @@ -244,7 +247,11 @@ const chars = { } as Record; export const cs_CZ: KeyboardLayout = { - isoCode: "cs-CZ", + isoCode: isoCode, name: name, - chars: chars + chars: chars, + // TODO need to localize these maps and layouts + keyDisplayMap: en_US.keyDisplayMap, + modifierDisplayMap: en_US.modifierDisplayMap, + virtualKeyboard: en_US.virtualKeyboard }; \ No newline at end of file diff --git a/ui/src/keyboardLayouts/de_CH.ts b/ui/src/keyboardLayouts/de_CH.ts index 4743bcf2..87764097 100644 --- a/ui/src/keyboardLayouts/de_CH.ts +++ b/ui/src/keyboardLayouts/de_CH.ts @@ -1,12 +1,15 @@ import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" -const name = "Schwiizerdütsch"; +import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard -const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel -const keyAcute = { key: "Minus", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter -const keyHat = { key: "Equal" } // accent circonflexe (accent hat), mark ^ placed above the letter -const keyGrave = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter -const keyTilde = { key: "Equal", altRight: true } // tilde, mark ~ placed above the letter +const name = "Schwiizerdütsch"; +const isoCode = "de-CH"; + +const keyTrema: KeyCombo = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel +const keyAcute: KeyCombo = { key: "Minus", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter +const keyHat: KeyCombo = { key: "Equal" } // accent circonflexe (accent hat), mark ^ placed above the letter +const keyGrave: KeyCombo = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter +const keyTilde: KeyCombo = { key: "Equal", altRight: true } // tilde, mark ~ placed above the letter const chars = { A: { key: "KeyA", shift: true }, @@ -164,8 +167,22 @@ const chars = { Tab: { key: "Tab" }, } as Record; +const keyDisplayMap = { + ...en_US.keyDisplayMap, + BracketLeft: "è", + "(BracketLeft)": "ü", + Semicolon: "é", + "(Semicolon)": "ö", + Quote: "à", + "(Quote)": "ä", +} as Record; + export const de_CH: KeyboardLayout = { - isoCode: "de-CH", + isoCode: isoCode, name: name, - chars: chars + chars: chars, + keyDisplayMap: keyDisplayMap, + // TODO need to localize these maps and layouts + modifierDisplayMap: en_US.modifierDisplayMap, + virtualKeyboard: en_US.virtualKeyboard }; \ No newline at end of file diff --git a/ui/src/keyboardLayouts/de_DE.ts b/ui/src/keyboardLayouts/de_DE.ts index 89b7eed2..d05d542e 100644 --- a/ui/src/keyboardLayouts/de_DE.ts +++ b/ui/src/keyboardLayouts/de_DE.ts @@ -1,113 +1,146 @@ import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" -const name = "Deutsch"; +import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard -const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter -const keyHat = { key: "Backquote" } // accent circonflexe (accent hat), mark ^ placed above the letter -const keyGrave = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter +const name = "Deutsch"; +const isoCode = "de-DE"; + +const keyAcute: KeyCombo = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter +const keyHat: KeyCombo = { key: "Backquote" } // accent circonflexe (accent hat), mark ^ placed above the letter +const keyGrave: KeyCombo = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter const chars = { + a: { key: "KeyA" }, + "á": { key: "KeyA", accentKey: keyAcute }, + "â": { key: "KeyA", accentKey: keyHat }, + "à": { key: "KeyA", accentKey: keyGrave }, A: { key: "KeyA", shift: true }, "Á": { key: "KeyA", shift: true, accentKey: keyAcute }, "Â": { key: "KeyA", shift: true, accentKey: keyHat }, "À": { key: "KeyA", shift: true, accentKey: keyGrave }, + "☺": { key: "KeyA", altRight: true }, // white smiling face ☺ + b: { key: "KeyB" }, B: { key: "KeyB", shift: true }, + "‹": { key: "KeyB", altRight: true }, // single left-pointing angle quotation mark, ‹ + c: { key: "KeyC" }, C: { key: "KeyC", shift: true }, + "\u202f": { key: "KeyC", altRight: true }, // narrow no-break space + d: { key: "KeyD" }, D: { key: "KeyD", shift: true }, + "′": { key: "KeyD", altRight: true }, // prime, mark ′ placed above the letter + e: { key: "KeyE" }, + "é": { key: "KeyE", accentKey: keyAcute }, + "ê": { key: "KeyE", accentKey: keyHat }, + "è": { key: "KeyE", accentKey: keyGrave }, + "€": { key: "KeyE", altRight: true }, E: { key: "KeyE", shift: true }, "É": { key: "KeyE", shift: true, accentKey: keyAcute }, "Ê": { key: "KeyE", shift: true, accentKey: keyHat }, "È": { key: "KeyE", shift: true, accentKey: keyGrave }, - F: { key: "KeyF", shift: true }, - G: { key: "KeyG", shift: true }, - H: { key: "KeyH", shift: true }, - I: { key: "KeyI", shift: true }, - "Í": { key: "KeyI", shift: true, accentKey: keyAcute }, - "Î": { key: "KeyI", shift: true, accentKey: keyHat }, - "Ì": { key: "KeyI", shift: true, accentKey: keyGrave }, - J: { key: "KeyJ", shift: true }, - K: { key: "KeyK", shift: true }, - L: { key: "KeyL", shift: true }, - M: { key: "KeyM", shift: true }, - N: { key: "KeyN", shift: true }, - O: { key: "KeyO", shift: true }, - "Ó": { key: "KeyO", shift: true, accentKey: keyAcute }, - "Ô": { key: "KeyO", shift: true, accentKey: keyHat }, - "Ò": { key: "KeyO", shift: true, accentKey: keyGrave }, - P: { key: "KeyP", shift: true }, - Q: { key: "KeyQ", shift: true }, - R: { key: "KeyR", shift: true }, - S: { key: "KeyS", shift: true }, - T: { key: "KeyT", shift: true }, - U: { key: "KeyU", shift: true }, - "Ú": { key: "KeyU", shift: true, accentKey: keyAcute }, - "Û": { key: "KeyU", shift: true, accentKey: keyHat }, - "Ù": { key: "KeyU", shift: true, accentKey: keyGrave }, - V: { key: "KeyV", shift: true }, - W: { key: "KeyW", shift: true }, - X: { key: "KeyX", shift: true }, - Y: { key: "KeyZ", shift: true }, - Z: { key: "KeyY", shift: true }, - a: { key: "KeyA" }, - "á": { key: "KeyA", accentKey: keyAcute }, - "â": { key: "KeyA", accentKey: keyHat }, - "à": { key: "KeyA", accentKey: keyGrave}, - b: { key: "KeyB" }, - c: { key: "KeyC" }, - d: { key: "KeyD" }, - e: { key: "KeyE" }, - "é": { key: "KeyE", accentKey: keyAcute}, - "ê": { key: "KeyE", accentKey: keyHat }, - "è": { key: "KeyE", accentKey: keyGrave }, - "€": { key: "KeyE", altRight: true }, f: { key: "KeyF" }, + F: { key: "KeyF", shift: true }, + "˟": { key: "KeyF", deadKey: true, altRight: true }, // modifier letter cross accent, ˟ + G: { key: "KeyG", shift: true }, g: { key: "KeyG" }, + "ẞ": { key: "KeyG", altRight: true }, // capital sharp S, ẞ h: { key: "KeyH" }, + H: { key: "KeyH", shift: true }, + "ˍ": { key: "KeyH", deadKey: true, altRight: true }, // modifier letter low macron, ˍ i: { key: "KeyI" }, "í": { key: "KeyI", accentKey: keyAcute }, "î": { key: "KeyI", accentKey: keyHat }, "ì": { key: "KeyI", accentKey: keyGrave }, + I: { key: "KeyI", shift: true }, + "Í": { key: "KeyI", shift: true, accentKey: keyAcute }, + "Î": { key: "KeyI", shift: true, accentKey: keyHat }, + "Ì": { key: "KeyI", shift: true, accentKey: keyGrave }, + "˜": { key: "KeyI", deadKey: true, altRight: true }, // tilde accent, mark ˜ placed above the letter j: { key: "KeyJ" }, + J: { key: "KeyJ", shift: true }, + "¸": { key: "KeyJ", deadKey: true, altRight: true }, // cedilla accent, mark ¸ placed below the letter k: { key: "KeyK" }, + K: { key: "KeyK", shift: true }, l: { key: "KeyL" }, + L: { key: "KeyL", shift: true }, + "ˏ": { key: "KeyL", deadKey: true, altRight: true }, // modifier letter reversed comma, ˏ m: { key: "KeyM" }, + M: { key: "KeyM", shift: true }, "µ": { key: "KeyM", altRight: true }, n: { key: "KeyN" }, + N: { key: "KeyN", shift: true }, + "–": { key: "KeyN", altRight: true }, // en dash, – o: { key: "KeyO" }, "ó": { key: "KeyO", accentKey: keyAcute }, "ô": { key: "KeyO", accentKey: keyHat }, "ò": { key: "KeyO", accentKey: keyGrave }, + O: { key: "KeyO", shift: true }, + "Ó": { key: "KeyO", shift: true, accentKey: keyAcute }, + "Ô": { key: "KeyO", shift: true, accentKey: keyHat }, + "Ò": { key: "KeyO", shift: true, accentKey: keyGrave }, + "˚": { key: "KeyO", deadKey: true, altRight: true }, // ring above, ˚ p: { key: "KeyP" }, + P: { key: "KeyP", shift: true }, + "ˀ": { key: "KeyP", deadKey: true, altRight: true }, // modifier letter apostrophe, ʾ q: { key: "KeyQ" }, + Q: { key: "KeyQ", shift: true }, "@": { key: "KeyQ", altRight: true }, + R: { key: "KeyR", shift: true }, r: { key: "KeyR" }, + "˝": { key: "KeyR", deadKey: true, altRight: true }, // double acute accent, mark ˝ placed above the letter + S: { key: "KeyS", shift: true }, s: { key: "KeyS" }, + "″": { key: "KeyS", altRight: true }, // double prime, mark ″ placed above the letter + T: { key: "KeyT", shift: true }, t: { key: "KeyT" }, + "ˇ": { key: "KeyT", deadKey: true, altRight: true }, // caron/hacek accent, mark ˇ placed above the letter u: { key: "KeyU" }, "ú": { key: "KeyU", accentKey: keyAcute }, "û": { key: "KeyU", accentKey: keyHat }, "ù": { key: "KeyU", accentKey: keyGrave }, + U: { key: "KeyU", shift: true }, + "Ú": { key: "KeyU", shift: true, accentKey: keyAcute }, + "Û": { key: "KeyU", shift: true, accentKey: keyHat }, + "Ù": { key: "KeyU", shift: true, accentKey: keyGrave }, + "˘": { key: "KeyU", deadKey: true, altRight: true }, // breve accent, ˘ placed above the letter v: { key: "KeyV" }, + V: { key: "KeyV", shift: true }, + "«": { key: "KeyV", altRight: true }, // left-pointing double angle quotation mark, « w: { key: "KeyW" }, + W: { key: "KeyW", shift: true }, + "¯": { key: "KeyW", deadKey: true, altRight: true }, // macron accent, mark ¯ placed above the letter x: { key: "KeyX" }, + X: { key: "KeyX", shift: true }, + "»": { key: "KeyX", altRight: true }, + // cross key between shift and y (aka OEM 102 key) y: { key: "KeyZ" }, + Y: { key: "KeyZ", shift: true }, + "›": { key: "KeyZ", altRight: true }, // single right-pointing angle quotation mark, › z: { key: "KeyY" }, + Z: { key: "KeyY", shift: true }, + "¨": { key: "KeyY", deadKey: true, altRight: true }, // diaeresis accent, mark ¨ placed above the letter "°": { key: "Backquote", shift: true }, "^": { key: "Backquote", deadKey: true }, + "|": { key: "Backquote", altRight: true }, 1: { key: "Digit1" }, "!": { key: "Digit1", shift: true }, + "’": { key: "Digit1", altRight: true }, // single quote, mark ’ placed above the letter 2: { key: "Digit2" }, "\"": { key: "Digit2", shift: true }, "²": { key: "Digit2", altRight: true }, + "<": { key: "Digit2", altRight: true }, // non-US < and > 3: { key: "Digit3" }, "§": { key: "Digit3", shift: true }, "³": { key: "Digit3", altRight: true }, + ">": { key: "Digit3", altRight: true }, // non-US < and > 4: { key: "Digit4" }, "$": { key: "Digit4", shift: true }, + "—": { key: "Digit4", altRight: true }, // em dash, — 5: { key: "Digit5" }, "%": { key: "Digit5", shift: true }, + "¡": { key: "Digit5", altRight: true }, // inverted exclamation mark, ¡ 6: { key: "Digit6" }, "&": { key: "Digit6", shift: true }, + "¿": { key: "Digit6", altRight: true }, // inverted question mark, ¿ 7: { key: "Digit7" }, "/": { key: "Digit7", shift: true }, "{": { key: "Digit7", altRight: true }, @@ -123,36 +156,192 @@ const chars = { "ß": { key: "Minus" }, "?": { key: "Minus", shift: true }, "\\": { key: "Minus", altRight: true }, - "´": { key: "Equal", deadKey: true }, - "`": { key: "Equal", shift: true, deadKey: true }, + "´": { key: "Equal", deadKey: true }, // accent acute, mark ´ placed above the letter + "`": { key: "Equal", shift: true, deadKey: true }, // accent grave, mark ` placed above the letter + "˙": { key: "Equal", control: true, altRight: true, deadKey: true }, // acute accent, mark ˙ placed above the letter "ü": { key: "BracketLeft" }, "Ü": { key: "BracketLeft", shift: true }, + Escape: { key: "BracketLeft", control: true }, + "ʼ": { key: "BracketLeft", altRight: true }, // modifier letter apostrophe, ʼ "+": { key: "BracketRight" }, "*": { key: "BracketRight", shift: true }, + Control: { key: "BracketRight", control: true }, "~": { key: "BracketRight", altRight: true }, "ö": { key: "Semicolon" }, "Ö": { key: "Semicolon", shift: true }, + "ˌ": { key: "Semicolon", deadkey: true, altRight: true }, // modifier letter low vertical line, ˌ "ä": { key: "Quote" }, "Ä": { key: "Quote", shift: true }, + "˗": { key: "Quote", deadKey: true, altRight: true }, // modifier letter minus sign, ˗ "#": { key: "Backslash" }, "'": { key: "Backslash", shift: true }, + "−": { key: "Backslash", altRight: true }, // minus sign, − ",": { key: "Comma" }, ";": { key: "Comma", shift: true }, + "\u2011": { key: "Comma", altRight: true }, // non-breaking hyphen, ‑ ".": { key: "Period" }, ":": { key: "Period", shift: true }, + "·": { key: "Period", altRight: true }, // middle dot, · "-": { key: "Slash" }, "_": { key: "Slash", shift: true }, - "<": { key: "IntlBackslash" }, - ">": { key: "IntlBackslash", shift: true }, - "|": { key: "IntlBackslash", altRight: true }, + "\u00ad": { key: "Slash", altRight: true }, // soft hyphen, ­ " ": { key: "Space" }, "\n": { key: "Enter" }, Enter: { key: "Enter" }, Tab: { key: "Tab" }, } as Record; +export const keyDisplayMap: Record = { + ...en_US.keyDisplayMap, + // now override the English keyDisplayMap with German specific keys + + // Combination keys + CtrlAltDelete: "Strg + Alt + Entf", + CtrlAltBackspace: "Strg + Alt + ←", + + // German action keys + AltLeft: "Alt", + AltRight: "AltGr", + Backspace: "Rücktaste", + "(Backspace)": "Rücktaste", + CapsLock: "Feststelltaste", + Clear: "Entf", + ControlLeft: "Strg", + ControlRight: "Strg", + Delete: "Entf", + End: "Ende", + Enter: "Eingabe", + Escape: "Esc", + Home: "Pos1", + Insert: "Einfg", + Menu: "Menü", + MetaLeft: "Meta", + MetaRight: "Meta", + PageDown: "Bild ↓", + PageUp: "Bild ↑", + ShiftLeft: "Umschalt", + ShiftRight: "Umschalt", + + // German umlauts and ß + BracketLeft: "ü", + "(BracketLeft)": "Ü", + Semicolon: "ö", + "(Semicolon)": "Ö", + Quote: "ä", + "(Quote)": "Ä", + Minus: "ß", + "(Minus)": "?", + Equal: "´", + "(Equal)": "`", + Backslash: "#", + "(Backslash)": "'", + + // Shifted Numbers + "(Digit2)": "\"", + "(Digit3)": "§", + "(Digit6)": "&", + "(Digit7)": "/", + "(Digit8)": "(", + "(Digit9)": ")", + "(Digit0)": "=", + + // Additional German symbols + Backquote: "^", + "(Backquote)": "°", + Comma: ",", + "(Comma)": ";", + Period: ".", + "(Period)": ":", + Slash: "-", + "(Slash)": "_", + + // Numpad + NumpadDecimal: "Num ,", + NumpadEnter: "Num Eingabe", + NumpadInsert: "Einfg", + NumpadDelete: "Entf", + + // Modals + PrintScreen: "Druck", + ScrollLock: "Rollen", + "(Pause)": "Unterbr", +} + +export const modifierDisplayMap: Record = { + ShiftLeft: "Umschalt (links)", + ShiftRight: "Umschalt (rechts)", + ControlLeft: "Strg (links)", + ControlRight: "Strg (rechts)", + AltLeft: "Alt", + AltRight: "AltGr", + MetaLeft: "Meta (links)", + MetaRight: "Meta (rechts)", + AltGr: "AltGr", +} as Record; + +export const virtualKeyboard = { + main: { + default: [ + "CtrlAltDelete AltMetaEscape CtrlAltBackspace", + "Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12", + "Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace", + "Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight", + "CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Backslash Enter", + "ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight", + "ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight", + ], + shift: [ + "CtrlAltDelete AltMetaEscape CtrlAltBackspace", + "Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12", + "(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)", + "Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)", + "CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter", + "ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight", + "ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight", + ] + }, + control: { + default: [ + "PrintScreen ScrollLock Pause", + "Insert Home PageUp", + "Delete End PageDown" + ], + shift: [ + "(PrintScreen) ScrollLock (Pause)", + "Insert Home PageUp", + "Delete End PageDown" + ], + }, + + arrows: { + default: [ + " ArrowUp ", + "ArrowLeft ArrowDown ArrowRight"], + }, + + numpad: { + numlocked: [ + "NumLock NumpadDivide NumpadMultiply NumpadSubtract", + "Numpad7 Numpad8 Numpad9 NumpadAdd", + "Numpad4 Numpad5 Numpad6", + "Numpad1 Numpad2 Numpad3 NumpadEnter", + "Numpad0 NumpadDecimal", + ], + default: [ + "NumLock NumpadDivide NumpadMultiply NumpadSubtract", + "Home ArrowUp PageUp NumpadAdd", + "ArrowLeft Clear ArrowRight", + "End ArrowDown PageDown NumpadEnter", + "NumpadInsert NumpadDelete", + ], + } +} + export const de_DE: KeyboardLayout = { - isoCode: "de-DE", + isoCode: isoCode, name: name, - chars: chars + chars: chars, + keyDisplayMap: keyDisplayMap, + modifierDisplayMap: modifierDisplayMap, + virtualKeyboard: virtualKeyboard }; \ No newline at end of file diff --git a/ui/src/keyboardLayouts/en_UK.ts b/ui/src/keyboardLayouts/en_UK.ts index a5ef7791..5341f0f0 100644 --- a/ui/src/keyboardLayouts/en_UK.ts +++ b/ui/src/keyboardLayouts/en_UK.ts @@ -1,6 +1,9 @@ import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" +import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard + const name = "English (UK)"; +const isoCode = "en-UK"; const chars = { A: { key: "KeyA", shift: true }, @@ -107,7 +110,11 @@ const chars = { } as Record export const en_UK: KeyboardLayout = { - isoCode: "en-UK", + isoCode: isoCode, name: name, - chars: chars + chars: chars, + // TODO need to localize these maps and layouts + keyDisplayMap: en_US.keyDisplayMap, + modifierDisplayMap: en_US.modifierDisplayMap, + virtualKeyboard: en_US.virtualKeyboard }; \ No newline at end of file diff --git a/ui/src/keyboardLayouts/en_US.ts b/ui/src/keyboardLayouts/en_US.ts index cd7aaf6d..872d3569 100644 --- a/ui/src/keyboardLayouts/en_US.ts +++ b/ui/src/keyboardLayouts/en_US.ts @@ -1,8 +1,18 @@ import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" const name = "English (US)"; +const isoCode = "en-US"; -const chars = { +// dead keys for "international" 101 keyboards TODO +/* +const keyAcute = { key: "Quote", control: true, menu: true, mark: "´" } // acute accent +const keyCedilla = { key: ".", shift: true, alt: true, mark: "¸" } // cedilla accent +const keyComma = { key: "BracketRight", shift: true, altRight: true, mark: "," } // comma accent +const keyDiaeresis = { key: "Quote", shift: true, control: true, menu: true, mark: "¨" } // diaeresis accent +const keyDegree = { key: "Semicolon", shift: true, control: true, menu: true, mark: "°" } // degree accent +*/ + +export const chars = { A: { key: "KeyA", shift: true }, B: { key: "KeyB", shift: true }, C: { key: "KeyC", shift: true }, @@ -89,31 +99,213 @@ const chars = { ">": { key: "Period", shift: true }, ";": { key: "Semicolon" }, ":": { key: "Semicolon", shift: true }, + "¶": { key: "Semicolon", altRight: true }, // pilcrow sign "[": { key: "BracketLeft" }, "{": { key: "BracketLeft", shift: true }, + "«": { key: "BracketLeft", altRight: true }, // double left quote sign "]": { key: "BracketRight" }, "}": { key: "BracketRight", shift: true }, + "»": { key: "BracketRight", altRight: true }, // double right quote sign "\\": { key: "Backslash" }, "|": { key: "Backslash", shift: true }, + "¬": { key: "Backslash", altRight: true }, // not sign "`": { key: "Backquote" }, "~": { key: "Backquote", shift: true }, "§": { key: "IntlBackslash" }, "±": { key: "IntlBackslash", shift: true }, - " ": { key: "Space", shift: false }, - "\n": { key: "Enter", shift: false }, - Enter: { key: "Enter", shift: false }, - Tab: { key: "Tab", shift: false }, - PrintScreen: { key: "Prt Sc", shift: false }, + " ": { key: "Space" }, + "\n": { key: "Enter" }, + Enter: { key: "Enter" }, + Escape: { key: "Escape" }, + Tab: { key: "Tab" }, + PrintScreen: { key: "Prt Sc" }, SystemRequest: { key: "Prt Sc", shift: true }, - ScrollLock: { key: "ScrollLock", shift: false}, - Pause: { key: "Pause", shift: false }, + ScrollLock: { key: "ScrollLock" }, + Pause: { key: "Pause" }, Break: { key: "Pause", shift: true }, - Insert: { key: "Insert", shift: false }, - Delete: { key: "Delete", shift: false }, + Insert: { key: "Insert" }, + Delete: { key: "Delete" }, } as Record +export const modifierDisplayMap: Record = { + ControlLeft: "Left Ctrl", + ControlRight: "Right Ctrl", + ShiftLeft: "Left Shift", + ShiftRight: "Right Shift", + AltLeft: "Left Alt", + AltRight: "Right Alt", + MetaLeft: "Left Meta", + MetaRight: "Right Meta", + AltGr: "AltGr", +} as Record; + +export const keyDisplayMap: Record = { + CtrlAltDelete: "Ctrl + Alt + Delete", + AltMetaEscape: "Alt + Meta + Escape", + CtrlAltBackspace: "Ctrl + Alt + Backspace", + AltGr: "AltGr", + AltLeft: "Alt", + AltRight: "Alt", + ArrowDown: "↓", + ArrowLeft: "←", + ArrowRight: "→", + ArrowUp: "↑", + Backspace: "Backspace", + "(Backspace)": "Backspace", + CapsLock: "Caps Lock", + Clear: "Clear", + ControlLeft: "Ctrl", + ControlRight: "Ctrl", + Delete: "Delete", + End: "End", + Enter: "Enter", + Escape: "Esc", + Home: "Home", + Insert: "Insert", + Menu: "Menu", + MetaLeft: "Meta", + MetaRight: "Meta", + PageDown: "PgDn", + PageUp: "PgUp", + ShiftLeft: "Shift", + ShiftRight: "Shift", + Space: " ", + Tab: "Tab", + + // Letters + KeyA: "a", KeyB: "b", KeyC: "c", KeyD: "d", KeyE: "e", + KeyF: "f", KeyG: "g", KeyH: "h", KeyI: "i", KeyJ: "j", + KeyK: "k", KeyL: "l", KeyM: "m", KeyN: "n", KeyO: "o", + KeyP: "p", KeyQ: "q", KeyR: "r", KeyS: "s", KeyT: "t", + KeyU: "u", KeyV: "v", KeyW: "w", KeyX: "x", KeyY: "y", + KeyZ: "z", + + // Capital letters + "(KeyA)": "A", "(KeyB)": "B", "(KeyC)": "C", "(KeyD)": "D", "(KeyE)": "E", + "(KeyF)": "F", "(KeyG)": "G", "(KeyH)": "H", "(KeyI)": "I", "(KeyJ)": "J", + "(KeyK)": "K", "(KeyL)": "L", "(KeyM)": "M", "(KeyN)": "N", "(KeyO)": "O", + "(KeyP)": "P", "(KeyQ)": "Q", "(KeyR)": "R", "(KeyS)": "S", "(KeyT)": "T", + "(KeyU)": "U", "(KeyV)": "V", "(KeyW)": "W", "(KeyX)": "X", "(KeyY)": "Y", + "(KeyZ)": "Z", + + // Numbers + Digit1: "1", Digit2: "2", Digit3: "3", Digit4: "4", Digit5: "5", + Digit6: "6", Digit7: "7", Digit8: "8", Digit9: "9", Digit0: "0", + + // Shifted Numbers + "(Digit1)": "!", "(Digit2)": "@", "(Digit3)": "#", "(Digit4)": "$", "(Digit5)": "%", + "(Digit6)": "^", "(Digit7)": "&", "(Digit8)": "*", "(Digit9)": "(", "(Digit0)": ")", + + // Symbols + Minus: "-", + "(Minus)": "_", + Equal: "=", + "(Equal)": "+", + BracketLeft: "[", + "(BracketLeft)": "{", + BracketRight: "]", + "(BracketRight)": "}", + Backslash: "\\", + "(Backslash)": "|", + Semicolon: ";", + "(Semicolon)": ":", + Quote: "'", + "(Quote)": "\"", + Comma: ",", + "(Comma)": "<", + Period: ".", + "(Period)": ">", + Slash: "/", + "(Slash)": "?", + Backquote: "`", + "(Backquote)": "~", + IntlBackslash: "\\", + + // Function keys + F1: "F1", F2: "F2", F3: "F3", F4: "F4", + F5: "F5", F6: "F6", F7: "F7", F8: "F8", + F9: "F9", F10: "F10", F11: "F11", F12: "F12", + + // Numpad + Numpad0: "Num 0", Numpad1: "Num 1", Numpad2: "Num 2", + Numpad3: "Num 3", Numpad4: "Num 4", Numpad5: "Num 5", + Numpad6: "Num 6", Numpad7: "Num 7", Numpad8: "Num 8", + Numpad9: "Num 9", NumpadAdd: "Num +", NumpadSubtract: "Num -", + NumpadMultiply: "Num *", NumpadDivide: "Num /", NumpadDecimal: "Num .", + NumpadEqual: "Num =", NumpadEnter: "Num Enter", NumpadInsert: "Ins", + NumpadDelete: "Del", NumLock: "Num Lock", + + // Modals + PrintScreen: "Prt Sc", ScrollLock: "Scr Lk", Pause: "Pause", + "(PrintScreen)": "Sys Rq", "(Pause)": "Break", + SystemRequest: "Sys Rq", Break: "Break" +}; + +export const virtualKeyboard = { + main: { + default: [ + "CtrlAltDelete AltMetaEscape CtrlAltBackspace", + "Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12", + "Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace", + "Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash", + "CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter", + "ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight", + "ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight", + ], + shift: [ + "CtrlAltDelete AltMetaEscape CtrlAltBackspace", + "Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12", + "(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)", + "Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)", + "CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter", + "ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight", + "ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight", + ] + }, + control: { + default: [ + "PrintScreen ScrollLock Pause", + "Insert Home PageUp", + "Delete End PageDown" + ], + shift: [ + "(PrintScreen) ScrollLock (Pause)", + "Insert Home PageUp", + "Delete End PageDown" + ], + }, + + arrows: { + default: [ + "ArrowUp", + "ArrowLeft ArrowDown ArrowRight"], + }, + + numpad: { + numlocked: [ + "NumLock NumpadDivide NumpadMultiply NumpadSubtract", + "Numpad7 Numpad8 Numpad9 NumpadAdd", + "Numpad4 Numpad5 Numpad6", + "Numpad1 Numpad2 Numpad3 NumpadEnter", + "Numpad0 NumpadDecimal", + ], + default: [ + "NumLock NumpadDivide NumpadMultiply NumpadSubtract", + "Home ArrowUp PageUp NumpadAdd", + "ArrowLeft Clear ArrowRight", + "End ArrowDown PageDown NumpadEnter", + "NumpadInsert NumpadDelete", + ], + } +} + export const en_US: KeyboardLayout = { - isoCode: "en-US", - name: name, - chars: chars -}; \ No newline at end of file + isoCode, + name, + chars, + keyDisplayMap, + modifierDisplayMap, + virtualKeyboard +}; + + diff --git a/ui/src/keyboardLayouts/es_ES.ts b/ui/src/keyboardLayouts/es_ES.ts index 9eb1d6a0..ab7762b0 100644 --- a/ui/src/keyboardLayouts/es_ES.ts +++ b/ui/src/keyboardLayouts/es_ES.ts @@ -1,12 +1,15 @@ import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" -const name = "Español"; +import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard -const keyTrema = { key: "Quote", shift: true } // tréma (umlaut), two dots placed above a vowel -const keyAcute = { key: "Quote" } // accent aigu (acute accent), mark ´ placed above the letter -const keyHat = { key: "BracketRight", shift: true } // accent circonflexe (accent hat), mark ^ placed above the letter -const keyGrave = { key: "BracketRight" } // accent grave, mark ` placed above the letter -const keyTilde = { key: "Key4", altRight: true } // tilde, mark ~ placed above the letter +const name = "Español"; +const isoCode = "es-ES"; + +const keyTrema: KeyCombo = { key: "Quote", shift: true } // tréma (umlaut), two dots placed above a vowel +const keyAcute: KeyCombo = { key: "Quote" } // accent aigu (acute accent), mark ´ placed above the letter +const keyHat: KeyCombo = { key: "BracketRight", shift: true } // accent circonflexe (accent hat), mark ^ placed above the letter +const keyGrave: KeyCombo = { key: "BracketRight" } // accent grave, mark ` placed above the letter +const keyTilde: KeyCombo = { key: "Key4", altRight: true } // tilde, mark ~ placed above the letter const chars = { A: { key: "KeyA", shift: true }, @@ -168,7 +171,11 @@ const chars = { } as Record; export const es_ES: KeyboardLayout = { - isoCode: "es-ES", + isoCode: isoCode, name: name, - chars: chars + chars: chars, + // TODO need to localize these maps and layouts + keyDisplayMap: en_US.keyDisplayMap, + modifierDisplayMap: en_US.modifierDisplayMap, + virtualKeyboard: en_US.virtualKeyboard }; \ No newline at end of file diff --git a/ui/src/keyboardLayouts/fr_BE.ts b/ui/src/keyboardLayouts/fr_BE.ts index bd417e0d..fb5a79ba 100644 --- a/ui/src/keyboardLayouts/fr_BE.ts +++ b/ui/src/keyboardLayouts/fr_BE.ts @@ -1,12 +1,15 @@ import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" -const name = "Belgisch Nederlands"; +import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard -const keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel -const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter -const keyAcute = { key: "Semicolon", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter -const keyGrave = { key: "Quote", shift: true } // accent grave, mark ` placed above the letter -const keyTilde = { key: "Slash", altRight: true } // tilde, mark ~ placed above the letter +const name = "Belgisch Nederlands"; +const isoCode = "nl-BE"; + +const keyTrema: KeyCombo = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel +const keyHat: KeyCombo = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter +const keyAcute: KeyCombo = { key: "Semicolon", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter +const keyGrave: KeyCombo = { key: "Quote", shift: true } // accent grave, mark ` placed above the letter +const keyTilde: KeyCombo = { key: "Slash", altRight: true } // tilde, mark ~ placed above the letter const chars = { A: { key: "KeyQ", shift: true }, @@ -167,7 +170,11 @@ const chars = { } as Record; export const fr_BE: KeyboardLayout = { - isoCode: "fr-BE", + isoCode: isoCode, name: name, - chars: chars + chars: chars, + // TODO need to localize these maps and layouts + keyDisplayMap: en_US.keyDisplayMap, + modifierDisplayMap: en_US.modifierDisplayMap, + virtualKeyboard: en_US.virtualKeyboard }; \ No newline at end of file diff --git a/ui/src/keyboardLayouts/fr_CH.ts b/ui/src/keyboardLayouts/fr_CH.ts index 0ba8cb4c..d0a70f35 100644 --- a/ui/src/keyboardLayouts/fr_CH.ts +++ b/ui/src/keyboardLayouts/fr_CH.ts @@ -3,6 +3,7 @@ import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" import { de_CH } from "./de_CH" const name = "Français de Suisse"; +const isoCode = "fr-CH"; const chars = { ...de_CH.chars, @@ -14,8 +15,22 @@ const chars = { "ä": { key: "Quote", shift: true }, } as Record; +const keyDisplayMap = { + ...de_CH.keyDisplayMap, + "BracketLeft": "è", + "BracketLeftShift": "ü", + "Semicolon": "é", + "SemicolonShift": "ö", + "Quote": "à", + "QuoteShift": "ä", +} as Record; + export const fr_CH: KeyboardLayout = { - isoCode: "fr-CH", + isoCode: isoCode, name: name, - chars: chars + chars: chars, + keyDisplayMap: keyDisplayMap, + // TODO need to localize these maps and layouts + modifierDisplayMap: de_CH.modifierDisplayMap, + virtualKeyboard: de_CH.virtualKeyboard }; diff --git a/ui/src/keyboardLayouts/fr_FR.ts b/ui/src/keyboardLayouts/fr_FR.ts index 29d51040..2ac5e74a 100644 --- a/ui/src/keyboardLayouts/fr_FR.ts +++ b/ui/src/keyboardLayouts/fr_FR.ts @@ -1,9 +1,12 @@ import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" -const name = "Français"; +import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard -const keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel -const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter +const name = "Français"; +const isoCode = "fr-FR"; + +const keyTrema: KeyCombo = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel +const keyHat: KeyCombo = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter const chars = { A: { key: "KeyQ", shift: true }, @@ -139,7 +142,11 @@ const chars = { } as Record; export const fr_FR: KeyboardLayout = { - isoCode: "fr-FR", + isoCode: isoCode, name: name, - chars: chars + chars: chars, + // TODO need to localize these maps and layouts + keyDisplayMap: en_US.keyDisplayMap, + modifierDisplayMap: en_US.modifierDisplayMap, + virtualKeyboard: en_US.virtualKeyboard }; \ No newline at end of file diff --git a/ui/src/keyboardLayouts/it_IT.ts b/ui/src/keyboardLayouts/it_IT.ts index 0ff6e244..160b0fc7 100644 --- a/ui/src/keyboardLayouts/it_IT.ts +++ b/ui/src/keyboardLayouts/it_IT.ts @@ -1,6 +1,9 @@ import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" +import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard + const name = "Italiano"; +const isoCode = "it-IT"; const chars = { A: { key: "KeyA", shift: true }, @@ -113,7 +116,11 @@ const chars = { } as Record; export const it_IT: KeyboardLayout = { - isoCode: "it-IT", + isoCode: isoCode, name: name, - chars: chars + chars: chars, + // TODO need to localize these maps and layouts + keyDisplayMap: en_US.keyDisplayMap, + modifierDisplayMap: en_US.modifierDisplayMap, + virtualKeyboard: en_US.virtualKeyboard }; \ No newline at end of file diff --git a/ui/src/keyboardLayouts/nb_NO.ts b/ui/src/keyboardLayouts/nb_NO.ts index 4dae9c8f..25043d95 100644 --- a/ui/src/keyboardLayouts/nb_NO.ts +++ b/ui/src/keyboardLayouts/nb_NO.ts @@ -1,12 +1,15 @@ import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" -const name = "Norsk bokmål"; +import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard -const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel -const keyAcute = { key: "Equal", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter -const keyHat = { key: "BracketRight", shift: true } // accent circonflexe (accent hat), mark ^ placed above the letter -const keyGrave = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter -const keyTilde = { key: "BracketRight", altRight: true } // tilde, mark ~ placed above the letter +const name = "Norsk bokmål"; +const isoCode = "nb-NO"; + +const keyTrema: KeyCombo = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel +const keyAcute: KeyCombo = { key: "Equal", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter +const keyHat: KeyCombo = { key: "BracketRight", shift: true } // accent circonflexe (accent hat), mark ^ placed above the letter +const keyGrave: KeyCombo = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter +const keyTilde: KeyCombo = { key: "BracketRight", altRight: true } // tilde, mark ~ placed above the letter const chars = { A: { key: "KeyA", shift: true }, @@ -167,7 +170,11 @@ const chars = { } as Record; export const nb_NO: KeyboardLayout = { - isoCode: "nb-NO", + isoCode: isoCode, name: name, - chars: chars + chars: chars, + // TODO need to localize these maps and layouts + keyDisplayMap: en_US.keyDisplayMap, + modifierDisplayMap: en_US.modifierDisplayMap, + virtualKeyboard: en_US.virtualKeyboard }; \ No newline at end of file diff --git a/ui/src/keyboardLayouts/sv_SE.ts b/ui/src/keyboardLayouts/sv_SE.ts index fbde3d09..388ddf9f 100644 --- a/ui/src/keyboardLayouts/sv_SE.ts +++ b/ui/src/keyboardLayouts/sv_SE.ts @@ -1,12 +1,15 @@ import { KeyboardLayout, KeyCombo } from "../keyboardLayouts" -const name = "Svenska"; +import { en_US } from "./en_US" // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard -const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel -const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter -const keyHat = { key: "BracketRight", shift: true } // accent circonflexe (accent hat), mark ^ placed above the letter -const keyGrave = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter -const keyTilde = { key: "BracketRight", altRight: true } // tilde, mark ~ placed above the letter +const name = "Svenska"; +const isoCode = "sv-SE"; + +const keyTrema: KeyCombo = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel +const keyAcute: KeyCombo = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter +const keyHat: KeyCombo = { key: "BracketRight", shift: true } // accent circonflexe (accent hat), mark ^ placed above the letter +const keyGrave: KeyCombo = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter +const keyTilde: KeyCombo = { key: "BracketRight", altRight: true } // tilde, mark ~ placed above the letter const chars = { A: { key: "KeyA", shift: true }, @@ -164,7 +167,11 @@ const chars = { } as Record; export const sv_SE: KeyboardLayout = { - isoCode: "sv-SE", + isoCode: isoCode, name: name, - chars: chars + chars: chars, + // TODO need to localize these maps and layouts + keyDisplayMap: en_US.keyDisplayMap, + modifierDisplayMap: en_US.modifierDisplayMap, + virtualKeyboard: en_US.virtualKeyboard }; \ No newline at end of file diff --git a/ui/src/keyboardMappings.ts b/ui/src/keyboardMappings.ts index bb24fbb2..14b0c606 100644 --- a/ui/src/keyboardMappings.ts +++ b/ui/src/keyboardMappings.ts @@ -1,20 +1,39 @@ // Key codes and modifiers correspond to definitions in the // [Linux USB HID gadget driver](https://www.kernel.org/doc/Documentation/usb/gadget_hid.txt) -// [Section 10. Keyboard/Keypad Page 0x07](https://usb.org/sites/default/files/hut1_21.pdf) +// [Universal Serial Bus HID Usage Tables: Section 10](https://www.usb.org/sites/default/files/documents/hut1_12v2.pdf) +// These are all the key codes (not scan codes) that an 85/101/102 keyboard might have on it export const keys = { + Again: 0x79, + AlternateErase: 0x9d, + AltGr: 0xe6, // aka AltRight + AltLeft: 0xe2, + AltRight: 0xe6, + Application: 0x65, ArrowDown: 0x51, ArrowLeft: 0x50, ArrowRight: 0x4f, ArrowUp: 0x52, + Attention: 0x9a, Backquote: 0x35, // aka Grave Backslash: 0x31, Backspace: 0x2a, BracketLeft: 0x2f, // aka LeftBrace BracketRight: 0x30, // aka RightBrace + Cancel: 0x9b, CapsLock: 0x39, + Clear: 0x9c, + ClearAgain: 0xa2, Comma: 0x36, - Compose: 0x65, - ContextMenu: 0, + Compose: 0xe3, + ContextMenu: 0x65, + ControlLeft: 0xe0, + ControlRight: 0xe4, + Copy: 0x7c, + CrSel: 0xa3, + CurrencySubunit: 0xb5, + CurrencyUnit: 0xb4, + Cut: 0x7b, + DecimalSeparator: 0xb3, Delete: 0x4c, Digit0: 0x27, Digit1: 0x1e, @@ -30,6 +49,8 @@ export const keys = { Enter: 0x28, Equal: 0x2e, Escape: 0x29, + Execute: 0x74, + ExSel: 0xa4, F1: 0x3a, F2: 0x3b, F3: 0x3c, @@ -42,6 +63,7 @@ export const keys = { F10: 0x43, F11: 0x44, F12: 0x45, + F13: 0x68, F14: 0x69, F15: 0x6a, F16: 0x6b, @@ -53,9 +75,21 @@ export const keys = { F22: 0x71, F23: 0x72, F24: 0x73, - Home: 0x4a, + Find: 0x7e, + Grave: 0x35, HashTilde: 0x32, // non-US # and ~ + Help: 0x75, + Home: 0x4a, Insert: 0x49, + International1: 0x87, + International2: 0x88, + International3: 0x89, + International4: 0x8a, + International5: 0x8b, + International6: 0x8c, + International7: 0x8d, + International8: 0x8e, + International9: 0x8f, IntlBackslash: 0x64, // non-US \ and | KeyA: 0x04, KeyB: 0x05, @@ -83,11 +117,27 @@ export const keys = { KeyX: 0x1b, KeyY: 0x1c, KeyZ: 0x1d, - KeypadExclamation: 0xcf, + LockingCapsLock: 0x82, + LockingNumLock: 0x83, + LockingScrollLock: 0x84, + Lang1: 0x90, // Hangul/English toggle on Korean keyboards + Lang2: 0x91, // Hanja conversion on Korean keyboards + Lang3: 0x92, // Katakana on Japanese keyboards + Lang4: 0x93, // Hiragana on Japanese keyboards + Lang5: 0x94, // Zenkaku/Hankaku toggle on Japanese keyboards + Lang6: 0x95, + Lang7: 0x96, + Lang8: 0x97, + Lang9: 0x98, + Menu: 0x76, + MetaLeft: 0xe3, + MetaRight: 0xe7, Minus: 0x2d, - None: 0x00, + Mute: 0x7f, NumLock: 0x53, // and Clear Numpad0: 0x62, // and Insert + Numpad00: 0xb0, + Numpad000: 0xb1, Numpad1: 0x59, // and End Numpad2: 0x5a, // and Down Arrow Numpad3: 0x5b, // and Page Down @@ -98,30 +148,111 @@ export const keys = { Numpad8: 0x60, // and Up Arrow Numpad9: 0x61, // and Page Up NumpadAdd: 0x57, + NumpadAnd: 0xc7, + NumpadAt: 0xce, + NumpadBackspace: 0xbb, + NumpadBinary: 0xda, + NumpadCircumflex: 0xc3, + NumpadClear: 0xd8, + NumpadClearEntry: 0xd9, + NumpadColon: 0xcb, NumpadComma: 0x85, NumpadDecimal: 0x63, + NumpadDecimalBase: 0xdc, + NumpadDelete: 0x63, NumpadDivide: 0x54, + NumpadDownArrow: 0x5a, + NumpadEnd: 0x59, NumpadEnter: 0x58, NumpadEqual: 0x67, + NumpadExclamation: 0xcf, + NumpadGreaterThan: 0xc6, + NumpadHexadecimal: 0xdd, + NumpadHome: 0x5f, + NumpadKeyA: 0xbc, + NumpadKeyB: 0xbd, + NumpadKeyC: 0xbe, + NumpadKeyD: 0xbf, + NumpadKeyE: 0xc0, + NumpadKeyF: 0xc1, + NumpadLeftArrow: 0x5c, + NumpadLeftBrace: 0xb8, NumpadLeftParen: 0xb6, + NumpadLessThan: 0xc5, + NumpadLogicalAnd: 0xc8, + NumpadLogicalOr: 0xca, + NumpadMemoryAdd: 0xd3, + NumpadMemoryClear: 0xd2, + NumpadMemoryDivide: 0xd6, + NumpadMemoryMultiply: 0xd5, + NumpadMemoryRecall: 0xd1, + NumpadMemoryStore: 0xd0, + NumpadMemorySubtract: 0xd4, NumpadMultiply: 0x55, + NumpadOctal: 0xdb, + NumpadOctathorpe: 0xcc, + NumpadOr: 0xc9, + NumpadPageDown: 0x5b, + NumpadPageUp: 0x61, + NumpadPercent: 0xc4, + NumpadPlusMinus: 0xd7, + NumpadRightArrow: 0x5e, + NumpadRightBrace: 0xb9, NumpadRightParen: 0xb7, + NumpadSpace: 0xcd, NumpadSubtract: 0x56, + NumpadTab: 0xba, + NumpadUpArrow: 0x60, + NumpadXOR: 0xc2, + Octothorpe: 0x32, // non-US # and ~ + Operation: 0xa1, + Out: 0xa0, PageDown: 0x4e, PageUp: 0x4b, - Period: 0x37, - PrintScreen: 0x46, + Paste: 0x7d, Pause: 0x48, + Period: 0x37, Power: 0x66, + PrintScreen: 0x46, + Prior: 0x9d, Quote: 0x34, // aka Single Quote or Apostrophe + Return: 0x9e, ScrollLock: 0x47, + Select: 0x77, Semicolon: 0x33, + Separator: 0x9f, + ShiftLeft: 0xe1, + ShiftRight: 0xe5, Slash: 0x38, Space: 0x2c, + Stop: 0x78, SystemRequest: 0x9a, Tab: 0x2b, + ThousandsSeparator: 0xb2, + Tilde: 0x35, + Undo: 0x7a, + VolumeDown: 0x81, + VolumeUp: 0x80, } as Record; +export const deadKeys = { + Acute: 0x00b4, + Breve: 0x02d8, + Caron: 0x02c7, + Cedilla: 0x00b8, + Circumflex: 0x005e, // or 0x02c6? + Comma: 0x002c, + Dot: 0x00b7, + DoubleAcute: 0x02dd, + Grave: 0x0060, + Kreis: 0x00b0, + Ogonek: 0x02db, + Ring: 0x02da, + Slash: 0x02f8, + Tilde: 0x007e, + Umlaut: 0x00a8, +} as Record + export const modifiers = { ControlLeft: 0x01, ControlRight: 0x10, @@ -131,113 +262,28 @@ export const modifiers = { AltRight: 0x40, MetaLeft: 0x08, MetaRight: 0x80, + AltGr: 0x40, } as Record; -export const modifierDisplayMap: Record = { - ControlLeft: "Left Ctrl", - ControlRight: "Right Ctrl", - ShiftLeft: "Left Shift", - ShiftRight: "Right Shift", - AltLeft: "Left Alt", - AltRight: "Right Alt", - MetaLeft: "Left Meta", - MetaRight: "Right Meta", -} as Record; +export const hidKeyToModifierMask = { + 0xe0: modifiers.ControlLeft, + 0xe1: modifiers.ShiftLeft, + 0xe2: modifiers.AltLeft, + 0xe3: modifiers.MetaLeft, + 0xe4: modifiers.ControlRight, + 0xe5: modifiers.ShiftRight, + 0xe6: modifiers.AltRight, // can also be AltGr + 0xe7: modifiers.MetaRight, +} as Record; -export const keyDisplayMap: Record = { - CtrlAltDelete: "Ctrl + Alt + Delete", - AltMetaEscape: "Alt + Meta + Escape", - CtrlAltBackspace: "Ctrl + Alt + Backspace", - Escape: "esc", - Tab: "tab", - Backspace: "backspace", - "(Backspace)": "backspace", - Enter: "enter", - CapsLock: "caps lock", - ShiftLeft: "shift", - ShiftRight: "shift", - ControlLeft: "ctrl", - AltLeft: "alt", - AltRight: "alt", - MetaLeft: "meta", - MetaRight: "meta", - Space: " ", - Insert: "insert", - Home: "home", - PageUp: "page up", - Delete: "delete", - End: "end", - PageDown: "page down", - ArrowLeft: "←", - ArrowRight: "→", - ArrowUp: "↑", - ArrowDown: "↓", - - // Letters - KeyA: "a", KeyB: "b", KeyC: "c", KeyD: "d", KeyE: "e", - KeyF: "f", KeyG: "g", KeyH: "h", KeyI: "i", KeyJ: "j", - KeyK: "k", KeyL: "l", KeyM: "m", KeyN: "n", KeyO: "o", - KeyP: "p", KeyQ: "q", KeyR: "r", KeyS: "s", KeyT: "t", - KeyU: "u", KeyV: "v", KeyW: "w", KeyX: "x", KeyY: "y", - KeyZ: "z", +export const latchingKeys = ["CapsLock", "ScrollLock", "NumLock", "Meta", "Compose", "Kana"]; - // Capital letters - "(KeyA)": "A", "(KeyB)": "B", "(KeyC)": "C", "(KeyD)": "D", "(KeyE)": "E", - "(KeyF)": "F", "(KeyG)": "G", "(KeyH)": "H", "(KeyI)": "I", "(KeyJ)": "J", - "(KeyK)": "K", "(KeyL)": "L", "(KeyM)": "M", "(KeyN)": "N", "(KeyO)": "O", - "(KeyP)": "P", "(KeyQ)": "Q", "(KeyR)": "R", "(KeyS)": "S", "(KeyT)": "T", - "(KeyU)": "U", "(KeyV)": "V", "(KeyW)": "W", "(KeyX)": "X", "(KeyY)": "Y", - "(KeyZ)": "Z", - - // Numbers - Digit1: "1", Digit2: "2", Digit3: "3", Digit4: "4", Digit5: "5", - Digit6: "6", Digit7: "7", Digit8: "8", Digit9: "9", Digit0: "0", - - // Shifted Numbers - "(Digit1)": "!", "(Digit2)": "@", "(Digit3)": "#", "(Digit4)": "$", "(Digit5)": "%", - "(Digit6)": "^", "(Digit7)": "&", "(Digit8)": "*", "(Digit9)": "(", "(Digit0)": ")", - - // Symbols - Minus: "-", - "(Minus)": "_", - Equal: "=", - "(Equal)": "+", - BracketLeft: "[", - "(BracketLeft)": "{", - BracketRight: "]", - "(BracketRight)": "}", - Backslash: "\\", - "(Backslash)": "|", - Semicolon: ";", - "(Semicolon)": ":", - Quote: "'", - "(Quote)": "\"", - Comma: ",", - "(Comma)": "<", - Period: ".", - "(Period)": ">", - Slash: "/", - "(Slash)": "?", - Backquote: "`", - "(Backquote)": "~", - IntlBackslash: "\\", - - // Function keys - F1: "F1", F2: "F2", F3: "F3", F4: "F4", - F5: "F5", F6: "F6", F7: "F7", F8: "F8", - F9: "F9", F10: "F10", F11: "F11", F12: "F12", - - // Numpad - Numpad0: "Num 0", Numpad1: "Num 1", Numpad2: "Num 2", - Numpad3: "Num 3", Numpad4: "Num 4", Numpad5: "Num 5", - Numpad6: "Num 6", Numpad7: "Num 7", Numpad8: "Num 8", - Numpad9: "Num 9", NumpadAdd: "Num +", NumpadSubtract: "Num -", - NumpadMultiply: "Num *", NumpadDivide: "Num /", NumpadDecimal: "Num .", - NumpadEqual: "Num =", NumpadEnter: "Num Enter", - NumLock: "Num Lock", - - // Modals - PrintScreen: "prt sc", ScrollLock: "scr lk", Pause: "pause", - "(PrintScreen)": "sys rq", "(Pause)": "break", - SystemRequest: "sys rq", Break: "break" -}; +export function decodeModifiers(modifier: number) { + return { + isShiftActive: (modifier & (modifiers.ShiftLeft | modifiers.ShiftRight)) !== 0, + isControlActive: (modifier & (modifiers.ControlLeft | modifiers.ControlRight)) !== 0, + isAltActive: (modifier & (modifiers.AltLeft | modifiers.AltRight)) !== 0, + isMetaActive: (modifier & (modifiers.MetaLeft | modifiers.MetaRight)) !== 0, + isAltGrActive: (modifier & modifiers.AltGr) !== 0, + }; +} \ No newline at end of file diff --git a/ui/src/routes/devices.$id.settings.access._index.tsx b/ui/src/routes/devices.$id.settings.access._index.tsx index 52c6fc6b..31b7bb79 100644 --- a/ui/src/routes/devices.$id.settings.access._index.tsx +++ b/ui/src/routes/devices.$id.settings.access._index.tsx @@ -166,9 +166,7 @@ export default function SettingsAccessIndexRoute() { notifications.success("TLS settings updated successfully"); }); - }, - [send], - ); + }, [send]); // Handle TLS mode change const handleTlsModeChange = (value: string) => { diff --git a/ui/src/routes/devices.$id.settings.advanced.tsx b/ui/src/routes/devices.$id.settings.advanced.tsx index cfa05ad5..c7d01369 100644 --- a/ui/src/routes/devices.$id.settings.advanced.tsx +++ b/ui/src/routes/devices.$id.settings.advanced.tsx @@ -18,7 +18,7 @@ export default function SettingsAdvancedRoute() { const { send } = useJsonRpc(); const [sshKey, setSSHKey] = useState(""); - const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode); + const { setDeveloperMode } = useSettingsStore(); const [devChannel, setDevChannel] = useState(false); const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false); const [showLoopbackWarning, setShowLoopbackWarning] = useState(false); diff --git a/ui/src/routes/devices.$id.settings.general.update.tsx b/ui/src/routes/devices.$id.settings.general.update.tsx index a641cbcc..b719d7e6 100644 --- a/ui/src/routes/devices.$id.settings.general.update.tsx +++ b/ui/src/routes/devices.$id.settings.general.update.tsx @@ -134,11 +134,9 @@ function LoadingState({ }) { const [progressWidth, setProgressWidth] = useState("0%"); const abortControllerRef = useRef(null); + const { setAppVersion, setSystemVersion } = useDeviceStore(); const { send } = useJsonRpc(); - const setAppVersion = useDeviceStore(state => state.setAppVersion); - const setSystemVersion = useDeviceStore(state => state.setSystemVersion); - const getVersionInfo = useCallback(() => { return new Promise((resolve, reject) => { send("getUpdateStatus", {}, (resp: JsonRpcResponse) => { diff --git a/ui/src/routes/devices.$id.settings.hardware.tsx b/ui/src/routes/devices.$id.settings.hardware.tsx index ea27b19c..11c11100 100644 --- a/ui/src/routes/devices.$id.settings.hardware.tsx +++ b/ui/src/routes/devices.$id.settings.hardware.tsx @@ -14,8 +14,7 @@ import { FeatureFlag } from "../components/FeatureFlag"; export default function SettingsHardwareRoute() { const { send } = useJsonRpc(); const settings = useSettingsStore(); - - const setDisplayRotation = useSettingsStore(state => state.setDisplayRotation); + const { setDisplayRotation } = useSettingsStore(); const handleDisplayRotationChange = (rotation: string) => { setDisplayRotation(rotation); @@ -34,7 +33,7 @@ export default function SettingsHardwareRoute() { }); }; - const setBacklightSettings = useSettingsStore(state => state.setBacklightSettings); + const { setBacklightSettings } = useSettingsStore(); const handleBacklightSettingsChange = (settings: BacklightSettings) => { // If the user has set the display to dim after it turns off, set the dim_after diff --git a/ui/src/routes/devices.$id.settings.keyboard.tsx b/ui/src/routes/devices.$id.settings.keyboard.tsx index 907b3450..abd72bf7 100644 --- a/ui/src/routes/devices.$id.settings.keyboard.tsx +++ b/ui/src/routes/devices.$id.settings.keyboard.tsx @@ -1,64 +1,44 @@ -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect } from "react"; -import { KeyboardLedSync, useSettingsStore } from "@/hooks/stores"; +import { useSettingsStore } from "@/hooks/stores"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; -import notifications from "@/notifications"; +import useKeyboardLayout from "@/hooks/useKeyboardLayout"; import { SettingsPageHeader } from "@components/SettingsPageheader"; -import { keyboardOptions } from "@/keyboardLayouts"; import { Checkbox } from "@/components/Checkbox"; - -import { SelectMenuBasic } from "../components/SelectMenuBasic"; +import { SelectMenuBasic } from "@/components/SelectMenuBasic"; +import notifications from "@/notifications"; import { SettingsItem } from "./devices.$id.settings"; export default function SettingsKeyboardRoute() { - const keyboardLayout = useSettingsStore(state => state.keyboardLayout); - const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync); - const showPressedKeys = useSettingsStore(state => state.showPressedKeys); - const setKeyboardLayout = useSettingsStore( - state => state.setKeyboardLayout, - ); - const setKeyboardLedSync = useSettingsStore( - state => state.setKeyboardLedSync, - ); - const setShowPressedKeys = useSettingsStore( - state => state.setShowPressedKeys, - ); - - // this ensures we always get the original en_US if it hasn't been set yet - const safeKeyboardLayout = useMemo(() => { - if (keyboardLayout && keyboardLayout.length > 0) - return keyboardLayout; - return "en_US"; - }, [keyboardLayout]); - - const layoutOptions = keyboardOptions(); - const ledSyncOptions = [ - { value: "auto", label: "Automatic" }, - { value: "browser", label: "Browser Only" }, - { value: "host", label: "Host Only" }, - ]; + const { setKeyboardLayout } = useSettingsStore(); + const { showPressedKeys, setShowPressedKeys } = useSettingsStore(); + const { selectedKeyboard, keyboardOptions } = useKeyboardLayout(); const { send } = useJsonRpc(); useEffect(() => { send("getKeyboardLayout", {}, (resp: JsonRpcResponse) => { if ("error" in resp) return; - setKeyboardLayout(resp.result as string); + const isoCode = resp.result as string; + console.log("Fetched keyboard layout from backend:", isoCode); + if (isoCode && isoCode.length > 0) { + setKeyboardLayout(isoCode); + } }); }, [send, setKeyboardLayout]); const onKeyboardLayoutChange = useCallback( (e: React.ChangeEvent) => { - const layout = e.target.value; - send("setKeyboardLayout", { layout }, (resp: JsonRpcResponse) => { + const isoCode = e.target.value; + send("setKeyboardLayout", { layout: isoCode }, resp => { if ("error" in resp) { notifications.error( `Failed to set keyboard layout: ${resp.error.data || "Unknown error"}`, ); } - notifications.success("Keyboard layout set successfully"); - setKeyboardLayout(layout); + notifications.success("Keyboard layout set successfully to " + isoCode); + setKeyboardLayout(isoCode); }); }, [send, setKeyboardLayout], @@ -72,7 +52,6 @@ export default function SettingsKeyboardRoute() { />
- { /* this menu item could be renamed to plain "Keyboard layout" in the future, when also the virtual keyboard layout mappings are being implemented */ }

@@ -91,23 +70,6 @@ export default function SettingsKeyboardRoute() {

-
- { /* this menu item could be renamed to plain "Keyboard layout" in the future, when also the virtual keyboard layout mappings are being implemented */ } - - setKeyboardLedSync(e.target.value as KeyboardLedSync)} - options={ledSyncOptions} - /> - -
-
{ return macros.map((macro, index) => ({ @@ -35,6 +35,7 @@ export default function SettingsMacrosRoute() { const [actionLoadingId, setActionLoadingId] = useState(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [macroToDelete, setMacroToDelete] = useState(null); + const { selectedKeyboard } = useKeyboardLayout(); const isMaxMacrosReached = useMemo( () => macros.length >= MAX_TOTAL_MACROS, @@ -185,7 +186,7 @@ export default function SettingsMacrosRoute() { step.modifiers.map((modifier, idx) => ( - {modifierDisplayMap[modifier] || modifier} + {selectedKeyboard.modifierDisplayMap[modifier] || modifier} {idx < step.modifiers.length - 1 && ( @@ -210,7 +211,7 @@ export default function SettingsMacrosRoute() { step.keys.map((key, idx) => ( - {keyDisplayMap[key] || key} + {selectedKeyboard.keyDisplayMap[key] || key} {idx < step.keys.length - 1 && ( @@ -297,8 +298,10 @@ export default function SettingsMacrosRoute() { actionLoadingId, handleDeleteMacro, handleMoveMacro, + selectedKeyboard.modifierDisplayMap, + selectedKeyboard.keyDisplayMap, handleDuplicateMacro, - navigate, + navigate ], ); diff --git a/ui/src/routes/devices.$id.settings.mouse.tsx b/ui/src/routes/devices.$id.settings.mouse.tsx index ab1aec60..f2b169d9 100644 --- a/ui/src/routes/devices.$id.settings.mouse.tsx +++ b/ui/src/routes/devices.$id.settings.mouse.tsx @@ -64,14 +64,11 @@ const jigglerOptions = [ type JigglerValues = (typeof jigglerOptions)[number]["value"] | "custom"; export default function SettingsMouseRoute() { - const hideCursor = useSettingsStore(state => state.isCursorHidden); - const setHideCursor = useSettingsStore(state => state.setCursorVisibility); - - const mouseMode = useSettingsStore(state => state.mouseMode); - const setMouseMode = useSettingsStore(state => state.setMouseMode); - - const scrollThrottling = useSettingsStore(state => state.scrollThrottling); - const setScrollThrottling = useSettingsStore(state => state.setScrollThrottling); + const { + isCursorHidden, setCursorVisibility, + mouseMode, setMouseMode, + scrollThrottling, setScrollThrottling + } = useSettingsStore(); const [selectedJigglerOption, setSelectedJigglerOption] = useState(null); @@ -196,8 +193,8 @@ export default function SettingsMouseRoute() { description="Hide the cursor when sending mouse movements" > setHideCursor(e.target.checked)} + checked={isCursorHidden} + onChange={e => setCursorVisibility(e.target.checked)} /> diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index 77de42c2..d87eb2be 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -106,11 +106,12 @@ export default function SettingsNetworkRoute() { setNetworkSettingsLoaded(false); send("getNetworkSettings", {}, (resp: JsonRpcResponse) => { if ("error" in resp) return; - console.log(resp.result); - setNetworkSettings(resp.result as NetworkSettings); + const networkSettings = resp.result as NetworkSettings; + console.debug("Network settings: ", networkSettings); + setNetworkSettings(networkSettings); if (!firstNetworkSettings.current) { - firstNetworkSettings.current = resp.result as NetworkSettings; + firstNetworkSettings.current = networkSettings; } setNetworkSettingsLoaded(true); }); @@ -119,8 +120,9 @@ export default function SettingsNetworkRoute() { const getNetworkState = useCallback(() => { send("getNetworkState", {}, (resp: JsonRpcResponse) => { if ("error" in resp) return; - console.log(resp.result); - setNetworkState(resp.result as NetworkState); + const networkState = resp.result as NetworkState; + console.debug("Network state:", networkState); + setNetworkState(networkState); }); }, [send, setNetworkState]); @@ -136,9 +138,10 @@ export default function SettingsNetworkRoute() { setNetworkSettingsLoaded(true); return; } + const networkSettings = resp.result as NetworkSettings; // We need to update the firstNetworkSettings ref to the new settings so we can use it to determine if the settings have changed - firstNetworkSettings.current = resp.result as NetworkSettings; - setNetworkSettings(resp.result as NetworkSettings); + firstNetworkSettings.current = networkSettings; + setNetworkSettings(networkSettings); getNetworkState(); setNetworkSettingsLoaded(true); notifications.success("Network settings saved"); diff --git a/ui/src/routes/devices.$id.settings.tsx b/ui/src/routes/devices.$id.settings.tsx index 5075ab5e..5a617782 100644 --- a/ui/src/routes/devices.$id.settings.tsx +++ b/ui/src/routes/devices.$id.settings.tsx @@ -17,19 +17,16 @@ import { useResizeObserver } from "usehooks-ts"; import Card from "@/components/Card"; import { LinkButton } from "@/components/Button"; +import { FeatureFlag } from "@/components/FeatureFlag"; import LoadingSpinner from "@/components/LoadingSpinner"; import { useUiStore } from "@/hooks/stores"; -import useKeyboard from "@/hooks/useKeyboard"; -import { FeatureFlag } from "../components/FeatureFlag"; import { cx } from "../cva.config"; - /* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */ export default function SettingsRoute() { const location = useLocation(); - const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap); - const { sendKeyboardEvent } = useKeyboard(); + const { setDisableVideoFocusTrap } = useUiStore(); const scrollContainerRef = useRef(null); const [showLeftGradient, setShowLeftGradient] = useState(false); const [showRightGradient, setShowRightGradient] = useState(false); @@ -65,21 +62,14 @@ export default function SettingsRoute() { }, [width]); useEffect(() => { - // disable focus trap setTimeout(() => { - // Reset keyboard state. Incase the user is pressing a key while enabling the sidebar - sendKeyboardEvent([], []); setDisableVideoFocusTrap(true); - // For some reason, the focus trap is not disabled immediately - // so we need to blur the active element - (document.activeElement as HTMLElement)?.blur(); - console.log("Just disabled focus trap"); - }, 300); + }, 500); return () => { setDisableVideoFocusTrap(false); }; - }, [sendKeyboardEvent, setDisableVideoFocusTrap]); + }, [setDisableVideoFocusTrap]); return (
diff --git a/ui/src/routes/devices.$id.settings.video.tsx b/ui/src/routes/devices.$id.settings.video.tsx index 8b46b7f9..4a7e3713 100644 --- a/ui/src/routes/devices.$id.settings.video.tsx +++ b/ui/src/routes/devices.$id.settings.video.tsx @@ -47,12 +47,11 @@ export default function SettingsVideoRoute() { const [edid, setEdid] = useState(null); // Video enhancement settings from store - const videoSaturation = useSettingsStore(state => state.videoSaturation); - const setVideoSaturation = useSettingsStore(state => state.setVideoSaturation); - const videoBrightness = useSettingsStore(state => state.videoBrightness); - const setVideoBrightness = useSettingsStore(state => state.setVideoBrightness); - const videoContrast = useSettingsStore(state => state.videoContrast); - const setVideoContrast = useSettingsStore(state => state.setVideoContrast); + const { + videoSaturation, setVideoSaturation, + videoBrightness, setVideoBrightness, + videoContrast, setVideoContrast + } = useSettingsStore(); useEffect(() => { send("getStreamQualityFactor", {}, (resp: JsonRpcResponse) => { diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index 1017eb43..b746e13b 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -22,10 +22,11 @@ import { checkAuth, isInCloud, isOnDevice } from "@/main"; import { cx } from "@/cva.config"; import notifications from "@/notifications"; import { - HidState, KeyboardLedState, + KeysDownState, NetworkState, - UpdateState, + OtaState, + USBStates, useDeviceStore, useHidStore, useMountMediaStore, @@ -125,22 +126,22 @@ export default function KvmIdRoute() { const authMode = "authMode" in loaderResp ? loaderResp.authMode : null; const params = useParams() as { id: string }; - const sidebarView = useUiStore(state => state.sidebarView); - const [queryParams, setQueryParams] = useSearchParams(); + const { sidebarView, setSidebarView, disableVideoFocusTrap } = useUiStore(); + const [ queryParams, setQueryParams ] = useSearchParams(); + + const { + peerConnection, setPeerConnection, + peerConnectionState, setPeerConnectionState, + diskChannel, setDiskChannel, + setMediaStream, + setRpcDataChannel, + isTurnServerInUse, setTurnServerInUse, + rpcDataChannel, + setTransceiver + } = useRTCStore(); - const setIsTurnServerInUse = useRTCStore(state => state.setTurnServerInUse); - const peerConnection = useRTCStore(state => state.peerConnection); - const setPeerConnectionState = useRTCStore(state => state.setPeerConnectionState); - const peerConnectionState = useRTCStore(state => state.peerConnectionState); - const setMediaMediaStream = useRTCStore(state => state.setMediaStream); - const setPeerConnection = useRTCStore(state => state.setPeerConnection); - const setDiskChannel = useRTCStore(state => state.setDiskChannel); - const setRpcDataChannel = useRTCStore(state => state.setRpcDataChannel); - const setTransceiver = useRTCStore(state => state.setTransceiver); const location = useLocation(); - const isLegacySignalingEnabled = useRef(false); - const [connectionFailed, setConnectionFailed] = useState(false); const navigate = useNavigate(); @@ -209,7 +210,7 @@ export default function KvmIdRoute() { clearInterval(checkInterval); setLoadingMessage("Connection established"); } else if (attempts >= 10) { - console.log( + console.warn( "[setRemoteSessionDescription] Failed to establish connection after 10 attempts", { connectionState: pc.connectionState, @@ -245,27 +246,27 @@ export default function KvmIdRoute() { reconnectAttempts: 15, reconnectInterval: 1000, onReconnectStop: () => { - console.log("Reconnect stopped"); + console.debug("Reconnect stopped"); cleanupAndStopReconnecting(); }, shouldReconnect(event) { - console.log("[Websocket] shouldReconnect", event); + console.debug("[Websocket] shouldReconnect", event); // TODO: Why true? return true; }, onClose(event) { - console.log("[Websocket] onClose", event); + console.debug("[Websocket] onClose", event); // We don't want to close everything down, we wait for the reconnect to stop instead }, onError(event) { - console.log("[Websocket] onError", event); + console.error("[Websocket] onError", event); // We don't want to close everything down, we wait for the reconnect to stop instead }, onOpen() { - console.log("[Websocket] onOpen"); + console.debug("[Websocket] onOpen"); }, onMessage: message => { @@ -287,8 +288,8 @@ export default function KvmIdRoute() { const parsedMessage = JSON.parse(message.data); if (parsedMessage.type === "device-metadata") { const { deviceVersion } = parsedMessage.data; - console.log("[Websocket] Received device-metadata message"); - console.log("[Websocket] Device version", deviceVersion); + console.debug("[Websocket] Received device-metadata message"); + console.debug("[Websocket] Device version", deviceVersion); // If the device version is not set, we can assume the device is using the legacy signaling if (!deviceVersion) { console.log("[Websocket] Device is using legacy signaling"); @@ -306,7 +307,7 @@ export default function KvmIdRoute() { if (!peerConnection) return; if (parsedMessage.type === "answer") { - console.log("[Websocket] Received answer"); + console.debug("[Websocket] Received answer"); const readyForOffer = // If we're making an offer, we don't want to accept an answer !makingOffer && @@ -320,7 +321,7 @@ export default function KvmIdRoute() { // Set so we don't accept an answer while we're setting the remote description isSettingRemoteAnswerPending.current = parsedMessage.type === "answer"; - console.log( + console.debug( "[Websocket] Setting remote answer pending", isSettingRemoteAnswerPending.current, ); @@ -336,7 +337,7 @@ export default function KvmIdRoute() { // Reset the remote answer pending flag isSettingRemoteAnswerPending.current = false; } else if (parsedMessage.type === "new-ice-candidate") { - console.log("[Websocket] Received new-ice-candidate"); + console.debug("[Websocket] Received new-ice-candidate"); const candidate = parsedMessage.data; peerConnection.addIceCandidate(candidate); } @@ -382,7 +383,7 @@ export default function KvmIdRoute() { return; } - console.log("Successfully got Remote Session Description. Setting."); + console.debug("Successfully got Remote Session Description. Setting."); setLoadingMessage("Setting remote session description..."); const decodedSd = atob(json.sd); @@ -393,13 +394,13 @@ export default function KvmIdRoute() { ); const setupPeerConnection = useCallback(async () => { - console.log("[setupPeerConnection] Setting up peer connection"); + console.debug("[setupPeerConnection] Setting up peer connection"); setConnectionFailed(false); setLoadingMessage("Connecting to device..."); let pc: RTCPeerConnection; try { - console.log("[setupPeerConnection] Creating peer connection"); + console.debug("[setupPeerConnection] Creating peer connection"); setLoadingMessage("Creating peer connection..."); pc = new RTCPeerConnection({ // We only use STUN or TURN servers if we're in the cloud @@ -409,7 +410,7 @@ export default function KvmIdRoute() { }); setPeerConnectionState(pc.connectionState); - console.log("[setupPeerConnection] Peer connection created", pc); + console.debug("[setupPeerConnection] Peer connection created", pc); setLoadingMessage("Setting up connection to device..."); } catch (e) { console.error(`[setupPeerConnection] Error creating peer connection: ${e}`); @@ -421,13 +422,13 @@ export default function KvmIdRoute() { // Set up event listeners and data channels pc.onconnectionstatechange = () => { - console.log("[setupPeerConnection] Connection state changed", pc.connectionState); + console.debug("[setupPeerConnection] Connection state changed", pc.connectionState); setPeerConnectionState(pc.connectionState); }; pc.onnegotiationneeded = async () => { try { - console.log("[setupPeerConnection] Creating offer"); + console.debug("[setupPeerConnection] Creating offer"); makingOffer.current = true; const offer = await pc.createOffer(); @@ -437,7 +438,7 @@ export default function KvmIdRoute() { if (isNewSignalingEnabled) { sendWebRTCSignal("offer", { sd: sd }); } else { - console.log("Legacy signanling. Waiting for ICE Gathering to complete..."); + console.log("Legacy signaling. Waiting for ICE Gathering to complete..."); } } catch (e) { console.error( @@ -459,7 +460,7 @@ export default function KvmIdRoute() { pc.onicegatheringstatechange = event => { const pc = event.currentTarget as RTCPeerConnection; if (pc.iceGatheringState === "complete") { - console.log("ICE Gathering completed"); + console.debug("ICE Gathering completed"); setLoadingMessage("ICE Gathering completed"); if (isLegacySignalingEnabled.current) { @@ -467,13 +468,13 @@ export default function KvmIdRoute() { legacyHTTPSignaling(pc); } } else if (pc.iceGatheringState === "gathering") { - console.log("ICE Gathering Started"); + console.debug("ICE Gathering Started"); setLoadingMessage("Gathering ICE candidates..."); } }; pc.ontrack = function (event) { - setMediaMediaStream(event.streams[0]); + setMediaStream(event.streams[0]); }; setTransceiver(pc.addTransceiver("video", { direction: "recvonly" })); @@ -495,7 +496,7 @@ export default function KvmIdRoute() { legacyHTTPSignaling, sendWebRTCSignal, setDiskChannel, - setMediaMediaStream, + setMediaStream, setPeerConnection, setPeerConnectionState, setRpcDataChannel, @@ -504,15 +505,13 @@ export default function KvmIdRoute() { useEffect(() => { if (peerConnectionState === "failed") { - console.log("Connection failed, closing peer connection"); + console.warn("Connection failed, closing peer connection"); cleanupAndStopReconnecting(); } }, [peerConnectionState, cleanupAndStopReconnecting]); // Cleanup effect - const clearInboundRtpStats = useRTCStore(state => state.clearInboundRtpStats); - const clearCandidatePairStats = useRTCStore(state => state.clearCandidatePairStats); - const setSidebarView = useUiStore(state => state.setSidebarView); + const { clearInboundRtpStats, clearCandidatePairStats } = useRTCStore(); useEffect(() => { return () => { @@ -543,11 +542,10 @@ export default function KvmIdRoute() { if (!lastRemoteStat?.length) return; const remoteCandidateIsUsingTurn = lastRemoteStat[1].candidateType === "relay"; // [0] is the timestamp, which we don't care about here - setIsTurnServerInUse(localCandidateIsUsingTurn || remoteCandidateIsUsingTurn); - }, [peerConnectionState, setIsTurnServerInUse]); + setTurnServerInUse(localCandidateIsUsingTurn || remoteCandidateIsUsingTurn); + }, [peerConnectionState, setTurnServerInUse]); // TURN server usage reporting - const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse); const lastBytesReceived = useRef(0); const lastBytesSent = useRef(0); @@ -580,15 +578,13 @@ export default function KvmIdRoute() { }); }, 10000); - const setNetworkState = useNetworkStateStore(state => state.setNetworkState); - - const setUsbState = useHidStore(state => state.setUsbState); - const setHdmiState = useVideoStore(state => state.setHdmiState); - - const keyboardLedState = useHidStore(state => state.keyboardLedState); - const setKeyboardLedState = useHidStore(state => state.setKeyboardLedState); - - const setKeyboardLedStateSyncAvailable = useHidStore(state => state.setKeyboardLedStateSyncAvailable); + const { setNetworkState} = useNetworkStateStore(); + const { setHdmiState } = useVideoStore(); + const { + keyboardLedState, setKeyboardLedState, + keysDownState, setKeysDownState, setUsbState, + setkeyPressReportApiAvailable + } = useHidStore(); const [hasUpdated, setHasUpdated] = useState(false); const { navigateTo } = useDeviceUiNavigation(); @@ -599,27 +595,38 @@ export default function KvmIdRoute() { } if (resp.method === "usbState") { - setUsbState(resp.params as unknown as HidState["usbState"]); + const usbState = resp.params as unknown as USBStates; + console.debug("Setting USB state", usbState); + setUsbState(usbState); } if (resp.method === "videoInputState") { - setHdmiState(resp.params as Parameters[0]); + const hdmiState = resp.params as Parameters[0]; + console.debug("Setting HDMI state", hdmiState); + setHdmiState(hdmiState); } if (resp.method === "networkState") { - console.log("Setting network state", resp.params); + console.debug("Setting network state", resp.params); setNetworkState(resp.params as NetworkState); } if (resp.method === "keyboardLedState") { const ledState = resp.params as KeyboardLedState; - console.log("Setting keyboard led state", ledState); + console.debug("Setting keyboard led state", ledState); setKeyboardLedState(ledState); - setKeyboardLedStateSyncAvailable(true); + } + + if (resp.method === "keysDownState") { + const downState = resp.params as KeysDownState; + console.debug("Setting key down state:", downState); + setKeysDownState(downState); + setkeyPressReportApiAvailable(true); // if they returned a keyDownState, we know they also support keyPressReport } if (resp.method === "otaState") { - const otaState = resp.params as UpdateState["otaState"]; + const otaState = resp.params as OtaState; + console.debug("Setting OTA state", otaState); setOtaState(otaState); if (otaState.updating === true) { @@ -643,39 +650,67 @@ export default function KvmIdRoute() { } } - const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); const { send } = useJsonRpc(onJsonRpcRequest); useEffect(() => { if (rpcDataChannel?.readyState !== "open") return; + console.log("Requesting video state"); send("getVideoState", {}, (resp: JsonRpcResponse) => { if ("error" in resp) return; - setHdmiState(resp.result as Parameters[0]); + const hdmiState = resp.result as Parameters[0]; + console.debug("Setting HDMI state", hdmiState); + setHdmiState(hdmiState); }); }, [rpcDataChannel?.readyState, send, setHdmiState]); + const [needLedState, setNeedLedState] = useState(true); + // request keyboard led state from the device useEffect(() => { if (rpcDataChannel?.readyState !== "open") return; - if (keyboardLedState !== undefined) return; + if (!needLedState) return; console.log("Requesting keyboard led state"); send("getKeyboardLedState", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) { + console.error("Failed to get keyboard led state", resp.error); + return; + } else { + const ledState = resp.result as KeyboardLedState; + console.debug("Keyboard led state: ", ledState); + setKeyboardLedState(ledState); + } + setNeedLedState(false); + }); + }, [rpcDataChannel?.readyState, send, setKeyboardLedState, keyboardLedState, needLedState]); + + const [needKeyDownState, setNeedKeyDownState] = useState(true); + + // request keyboard key down state from the device + useEffect(() => { + if (rpcDataChannel?.readyState !== "open") return; + if (!needKeyDownState) return; + console.log("Requesting keys down state"); + + send("getKeyDownState", {}, (resp: JsonRpcResponse) => { if ("error" in resp) { // -32601 means the method is not supported if (resp.error.code === -32601) { - setKeyboardLedStateSyncAvailable(false); - console.error("Failed to get keyboard led state, disabling sync", resp.error); + // if we don't support key down state, we know key press is also not available + console.warn("Failed to get key down state, switching to old-school", resp.error); + setkeyPressReportApiAvailable(false); } else { - console.error("Failed to get keyboard led state", resp.error); + console.error("Failed to get key down state", resp.error); } - return; + } else { + const downState = resp.result as KeysDownState; + console.debug("Keyboard key down state", downState); + setKeysDownState(downState); + setkeyPressReportApiAvailable(true); // if they returned a keyDownState, we know they also support keyPressReport } - console.log("Keyboard led state", resp.result); - setKeyboardLedState(resp.result as KeyboardLedState); - setKeyboardLedStateSyncAvailable(true); + setNeedKeyDownState(false); }); - }, [rpcDataChannel?.readyState, send, setKeyboardLedState, setKeyboardLedStateSyncAvailable, keyboardLedState]); + }, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, setkeyPressReportApiAvailable, setKeysDownState]); // When the update is successful, we need to refresh the client javascript and show a success modal useEffect(() => { @@ -684,14 +719,13 @@ export default function KvmIdRoute() { } }, [navigate, navigateTo, queryParams, setModalView, setQueryParams]); - const diskChannel = useRTCStore(state => state.diskChannel)!; - const file = useMountMediaStore(state => state.localFile)!; + const { localFile } = useMountMediaStore(); useEffect(() => { - if (!diskChannel || !file) return; + if (!diskChannel || !localFile) return; diskChannel.onmessage = async e => { - console.log("Received", e.data); + console.debug("Received", e.data); const data = JSON.parse(e.data); - const blob = file.slice(data.start, data.end); + const blob = localFile.slice(data.start, data.end); const buf = await blob.arrayBuffer(); const header = new ArrayBuffer(16); const headerView = new DataView(header); @@ -702,11 +736,9 @@ export default function KvmIdRoute() { fullData.set(new Uint8Array(buf), header.byteLength); diskChannel.send(fullData); }; - }, [diskChannel, file]); + }, [diskChannel, localFile]); // System update - const disableVideoFocusTrap = useUiStore(state => state.disableVideoFocusTrap); - const [kvmTerminal, setKvmTerminal] = useState(null); const [serialConsole, setSerialConsole] = useState(null); @@ -726,9 +758,7 @@ export default function KvmIdRoute() { if (location.pathname !== "/other-session") navigateTo("/"); }, [navigateTo, location.pathname]); - const appVersion = useDeviceStore(state => state.appVersion); - const setAppVersion = useDeviceStore(state => state.setAppVersion); - const setSystemVersion = useDeviceStore(state => state.setSystemVersion); + const { appVersion, setAppVersion, setSystemVersion} = useDeviceStore(); useEffect(() => { if (appVersion) return; @@ -736,7 +766,7 @@ export default function KvmIdRoute() { send("getUpdateStatus", {}, (resp: JsonRpcResponse) => { if ("error" in resp) { notifications.error(`Failed to get device version: ${resp.error}`); - return + return } const result = resp.result as SystemVersionInfo; @@ -873,7 +903,7 @@ interface SidebarContainerProps { } function SidebarContainer(props: SidebarContainerProps) { - const { sidebarView }= props; + const { sidebarView } = props; return (
Date: Wed, 27 Aug 2025 10:16:17 +0200 Subject: [PATCH 03/13] enhancement: add new EDID for DELL iDRAC (#693) --- ui/src/routes/devices.$id.settings.video.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ui/src/routes/devices.$id.settings.video.tsx b/ui/src/routes/devices.$id.settings.video.tsx index 4a7e3713..e5072d69 100644 --- a/ui/src/routes/devices.$id.settings.video.tsx +++ b/ui/src/routes/devices.$id.settings.video.tsx @@ -32,6 +32,11 @@ const edids = [ "00FFFFFFFFFFFF0010AC132045393639201E0103803C22782ACD25A3574B9F270D5054A54B00714F8180A9C0D1C00101010101010101023A801871382D40582C450056502100001E000000FF00335335475132330A2020202020000000FC0044454C4C204432373231480A20000000FD00384C1E5311000A202020202020018102031AB14F90050403020716010611121513141F65030C001000023A801871382D40582C450056502100001E011D8018711C1620582C250056502100009E011D007251D01E206E28550056502100001E8C0AD08A20E02D10103E960056502100001800000000000000000000000000000000000000000000000000000000004F", label: "DELL D2721H, 1920x1080", }, + { + value: + "00ffffffffffff0010ac0100020000000111010380221bff0a00000000000000000000adce0781800101010101010101010101010101000000ff0030303030303030303030303030000000ff0030303030303030303030303030000000fd00384c1f530b000a000000000000000000fc0044454c4c2049445241430a2020000a", + label: "DELL IDRAC EDID, 1280x1024", + }, ]; const streamQualityOptions = [ @@ -140,7 +145,7 @@ export default function SettingsVideoRoute() { title="Video Enhancement" description="Adjust color settings to make the video output more vibrant and colorful" /> - +
Date: Thu, 28 Aug 2025 05:01:04 -0500 Subject: [PATCH 04/13] Add application icon for Safari and saved-bookmarks (#749) --- ui/index.html | 11 +++++++++-- ui/public/apple-touch-icon.png | Bin 0 -> 1820 bytes ui/public/favicon-96x96.png | Bin 0 -> 972 bytes ui/public/favicon.ico | Bin 0 -> 15086 bytes ui/public/favicon.png | Bin 2752 -> 1254 bytes ui/public/favicon.svg | 1 + ui/public/jetkvm.svg | 1 + ui/public/site.webmanifest | 21 +++++++++++++++++++++ ui/public/web-app-manifest-192x192.png | Bin 0 -> 1915 bytes ui/public/web-app-manifest-512x512.png | Bin 0 -> 8047 bytes 10 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 ui/public/apple-touch-icon.png create mode 100644 ui/public/favicon-96x96.png create mode 100644 ui/public/favicon.ico create mode 100644 ui/public/favicon.svg create mode 100644 ui/public/jetkvm.svg create mode 100644 ui/public/site.webmanifest create mode 100644 ui/public/web-app-manifest-192x192.png create mode 100644 ui/public/web-app-manifest-512x512.png diff --git a/ui/index.html b/ui/index.html index af9bdfb4..0ce91234 100644 --- a/ui/index.html +++ b/ui/index.html @@ -1,7 +1,7 @@ - + JetKVM - + + + + + + + +