diff --git a/ui/package-lock.json b/ui/package-lock.json index dd34a03..7b30741 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -32,6 +32,7 @@ "react-simple-keyboard": "^3.7.112", "react-xtermjs": "^1.0.9", "recharts": "^2.15.0", + "semver": "^7.7.1", "tailwind-merge": "^2.5.5", "usehooks-ts": "^3.1.0", "validator": "^13.12.0", @@ -4184,18 +4185,6 @@ "loose-envify": "cli.js" } }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5347,13 +5336,9 @@ } }, "node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "bin": { "semver": "bin/semver.js" }, @@ -6328,12 +6313,6 @@ "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==", "deprecated": "This package is now deprecated. Move to @xterm/xterm instead." }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/yaml": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.1.tgz", diff --git a/ui/package.json b/ui/package.json index 5be3e2c..42889b4 100644 --- a/ui/package.json +++ b/ui/package.json @@ -41,6 +41,7 @@ "react-simple-keyboard": "^3.7.112", "react-xtermjs": "^1.0.9", "recharts": "^2.15.0", + "semver": "^7.7.1", "tailwind-merge": "^2.5.5", "usehooks-ts": "^3.1.0", "validator": "^13.12.0", diff --git a/ui/src/components/FeatureFlag.tsx b/ui/src/components/FeatureFlag.tsx new file mode 100644 index 0000000..985cec6 --- /dev/null +++ b/ui/src/components/FeatureFlag.tsx @@ -0,0 +1,27 @@ +import { useEffect } from "react"; +import { useFeatureFlag } from "../hooks/useFeatureFlag"; + +export function FeatureFlag({ + minAppVersion, + name = "unnamed", + fallback = null, + children, +}: { + minAppVersion: string; + name?: string; + fallback?: React.ReactNode; + children: React.ReactNode; +}) { + const { isEnabled, appVersion } = useFeatureFlag(minAppVersion); + + useEffect(() => { + if (!appVersion) return; + console.log( + `Feature '${name}' ${isEnabled ? "ENABLED" : "DISABLED"}: ` + + `Current version: ${appVersion}, ` + + `Required min version: ${minAppVersion || "N/A"}`, + ); + }, [isEnabled, name, minAppVersion, appVersion]); + + return isEnabled ? children : fallback; +} diff --git a/ui/src/components/UsbConfigSetting.tsx b/ui/src/components/UsbConfigSetting.tsx new file mode 100644 index 0000000..1e8ab03 --- /dev/null +++ b/ui/src/components/UsbConfigSetting.tsx @@ -0,0 +1,177 @@ +import { useMemo } from "react"; + +import { useCallback } from "react"; + +import { useEffect, useState } from "react"; +import { UsbConfigState } from "../hooks/stores"; +import { useJsonRpc } from "../hooks/useJsonRpc"; +import notifications from "../notifications"; +import { SettingsItem } from "../routes/devices.$id.settings"; +import { SelectMenuBasic } from "./SelectMenuBasic"; +import USBConfigDialog from "./USBConfigDialog"; + +const generatedSerialNumber = [generateNumber(1, 9), generateHex(7, 7), 0, 1].join("&"); + +function generateNumber(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1) + min); +} + +function generateHex(min: number, max: number) { + const len = generateNumber(min, max); + const n = (Math.random() * 0xfffff * 1000000).toString(16); + return n.slice(0, len); +} + +export interface USBConfig { + vendor_id: string; + product_id: string; + serial_number: string; + manufacturer: string; + product: string; +} + +const usbConfigs = [ + { + label: "JetKVM Default", + value: "USB Emulation Device", + }, + { + label: "Logitech Universal Adapter", + value: "Logitech USB Input Device", + }, + { + label: "Microsoft Wireless MultiMedia Keyboard", + value: "Wireless MultiMedia Keyboard", + }, + { + label: "Dell Multimedia Pro Keyboard", + value: "Multimedia Pro Keyboard", + }, +]; + +type UsbConfigMap = Record; + +export function UsbConfigSetting() { + const [send] = useJsonRpc(); + + const [usbConfigProduct, setUsbConfigProduct] = useState(""); + const [deviceId, setDeviceId] = useState(""); + const usbConfigData: UsbConfigMap = useMemo( + () => ({ + "USB Emulation Device": { + vendor_id: "0x1d6b", + product_id: "0x0104", + serial_number: deviceId, + manufacturer: "JetKVM", + product: "USB Emulation Device", + }, + "Logitech USB Input Device": { + vendor_id: "0x046d", + product_id: "0xc52b", + serial_number: generatedSerialNumber, + manufacturer: "Logitech (x64)", + product: "Logitech USB Input Device", + }, + "Wireless MultiMedia Keyboard": { + vendor_id: "0x045e", + product_id: "0x005f", + serial_number: generatedSerialNumber, + manufacturer: "Microsoft", + product: "Wireless MultiMedia Keyboard", + }, + "Multimedia Pro Keyboard": { + vendor_id: "0x413c", + product_id: "0x2011", + serial_number: generatedSerialNumber, + manufacturer: "Dell Inc.", + product: "Multimedia Pro Keyboard", + }, + }), + [deviceId], + ); + + const syncUsbConfigProduct = useCallback(() => { + send("getUsbConfig", {}, resp => { + if ("error" in resp) { + console.error("Failed to load USB Config:", resp.error); + notifications.error( + `Failed to load USB Config: ${resp.error.data || "Unknown error"}`, + ); + } else { + console.log("syncUsbConfigProduct#getUsbConfig result:", resp.result); + const usbConfigState = resp.result as UsbConfigState; + const product = usbConfigs.map(u => u.value).includes(usbConfigState.product) + ? usbConfigState.product + : "custom"; + setUsbConfigProduct(product); + } + }); + }, [send]); + + const handleUsbConfigChange = useCallback( + (usbConfig: USBConfig) => { + send("setUsbConfig", { usbConfig }, resp => { + if ("error" in resp) { + notifications.error( + `Failed to set usb config: ${resp.error.data || "Unknown error"}`, + ); + return; + } + // setUsbConfigProduct(usbConfig.product); + notifications.success( + `USB Config set to ${usbConfig.manufacturer} ${usbConfig.product}`, + ); + syncUsbConfigProduct(); + }); + }, + [send, syncUsbConfigProduct], + ); + + useEffect(() => { + send("getDeviceID", {}, async resp => { + if ("error" in resp) { + return notifications.error( + `Failed to get device ID: ${resp.error.data || "Unknown error"}`, + ); + } + setDeviceId(resp.result as string); + }); + + syncUsbConfigProduct(); + }, [send, syncUsbConfigProduct]); + + return ( + <> +
+ + + { + if (e.target.value === "custom") { + setUsbConfigProduct(e.target.value); + } else { + const usbConfig = usbConfigData[e.target.value]; + handleUsbConfigChange(usbConfig); + } + }} + options={[...usbConfigs, { value: "custom", label: "Custom" }]} + /> + + {usbConfigProduct === "custom" && ( + handleUsbConfigChange(usbConfig)} + onRestoreToDefault={() => + handleUsbConfigChange(usbConfigData[usbConfigs[0].value]) + } + /> + )} + + ); +} diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index ba6a4eb..3dfc96c 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -553,3 +553,19 @@ export const useLocalAuthModalStore = create(set => ({ modalView: "createPassword", setModalView: view => set({ modalView: view }), })); + +export interface DeviceState { + appVersion: string | null; + systemVersion: string | null; + + setAppVersion: (version: string) => void; + setSystemVersion: (version: string) => void; +} + +export const useDeviceStore = create(set => ({ + appVersion: null, + systemVersion: null, + + setAppVersion: version => set({ appVersion: version }), + setSystemVersion: version => set({ systemVersion: version }), +})); diff --git a/ui/src/hooks/useFeatureFlag.ts b/ui/src/hooks/useFeatureFlag.ts new file mode 100644 index 0000000..9a7fcd8 --- /dev/null +++ b/ui/src/hooks/useFeatureFlag.ts @@ -0,0 +1,15 @@ +import { useContext } from "react"; +import { FeatureFlagContext } from "../providers/FeatureFlagProvider"; + +export const useFeatureFlag = (minAppVersion: string) => { + const context = useContext(FeatureFlagContext); + + if (!context) { + throw new Error("useFeatureFlag must be used within a FeatureFlagProvider"); + } + + const { isFeatureEnabled, appVersion } = context; + + const isEnabled = isFeatureEnabled(minAppVersion); + return { isEnabled, appVersion }; +}; diff --git a/ui/src/providers/FeatureFlagProvider.tsx b/ui/src/providers/FeatureFlagProvider.tsx new file mode 100644 index 0000000..93d75bb --- /dev/null +++ b/ui/src/providers/FeatureFlagProvider.tsx @@ -0,0 +1,42 @@ +import { createContext } from "react"; +import semver from "semver"; + +interface FeatureFlagContextType { + appVersion: string | null; + isFeatureEnabled: (minVersion: string) => boolean; +} + +// Create the context +export const FeatureFlagContext = createContext({ + appVersion: null, + isFeatureEnabled: () => false, +}); + +// Provider component that fetches version and provides context +export const FeatureFlagProvider = ({ + children, + appVersion, +}: { + children: React.ReactNode; + appVersion: string | null; +}) => { + const isFeatureEnabled = (minAppVersion: string) => { + // If no version is set, feature is disabled. + // The feature flag component can deside what to display as a fallback - either omit the component or like a "please upgrade to enable". + if (!appVersion) return false; + + // Extract the base versions without prerelease identifier + const baseCurrentVersion = semver.coerce(appVersion)?.version; + const baseMinVersion = semver.coerce(minAppVersion)?.version; + + if (!baseCurrentVersion || !baseMinVersion) return false; + + return semver.gte(baseCurrentVersion, baseMinVersion); + }; + + const value = { appVersion, isFeatureEnabled }; + + return ( + {children} + ); +}; diff --git a/ui/src/routes/devices.$id.settings.general._index.tsx b/ui/src/routes/devices.$id.settings.general._index.tsx index f4d258d..b5701f9 100644 --- a/ui/src/routes/devices.$id.settings.general._index.tsx +++ b/ui/src/routes/devices.$id.settings.general._index.tsx @@ -1,43 +1,32 @@ import { SettingsPageHeader } from "../components/SettingsPageheader"; import { SettingsItem } from "./devices.$id.settings"; -import { useCallback, useState } from "react"; +import { useState } from "react"; import { useEffect } from "react"; -import { SystemVersionInfo } from "./devices.$id.settings.general.update"; import { useJsonRpc } from "@/hooks/useJsonRpc"; import { Button } from "../components/Button"; import notifications from "../notifications"; import Checkbox from "../components/Checkbox"; import { useDeviceUiNavigation } from "../hooks/useAppNavigation"; +import { useDeviceStore } from "../hooks/stores"; export default function SettingsGeneralRoute() { const [send] = useJsonRpc(); const { navigateTo } = useDeviceUiNavigation(); - const [autoUpdate, setAutoUpdate] = useState(true); - const [currentVersions, setCurrentVersions] = useState<{ - appVersion: string; - systemVersion: string; - } | null>(null); - const getCurrentVersions = useCallback(() => { - send("getUpdateStatus", {}, resp => { - if ("error" in resp) return; - const result = resp.result as SystemVersionInfo; - setCurrentVersions({ - appVersion: result.local.appVersion, - systemVersion: result.local.systemVersion, - }); - }); - }, [send]); + const currentVersions = useDeviceStore(state => { + const { appVersion, systemVersion } = state; + if (!appVersion || !systemVersion) return null; + return { appVersion, systemVersion }; + }); useEffect(() => { - getCurrentVersions(); send("getAutoUpdateState", {}, resp => { if ("error" in resp) return; setAutoUpdate(resp.result as boolean); }); - }, [getCurrentVersions, send]); + }, [send]); const handleAutoUpdateChange = (enabled: boolean) => { send("setAutoUpdateState", { enabled }, resp => { diff --git a/ui/src/routes/devices.$id.settings.general.update.tsx b/ui/src/routes/devices.$id.settings.general.update.tsx index 648efc7..f2b52d6 100644 --- a/ui/src/routes/devices.$id.settings.general.update.tsx +++ b/ui/src/routes/devices.$id.settings.general.update.tsx @@ -3,7 +3,7 @@ import Card from "@/components/Card"; import { useCallback, useEffect, useRef, useState } from "react"; import { useJsonRpc } from "@/hooks/useJsonRpc"; import { Button } from "@components/Button"; -import { UpdateState, useUpdateStore } from "@/hooks/stores"; +import { UpdateState, useDeviceStore, useUpdateStore } from "@/hooks/stores"; import notifications from "@/notifications"; import { CheckCircleIcon } from "@heroicons/react/20/solid"; import LoadingSpinner from "@/components/LoadingSpinner"; @@ -134,6 +134,9 @@ function LoadingState({ const abortControllerRef = useRef(null); 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", {}, async resp => { @@ -142,11 +145,13 @@ function LoadingState({ reject(new Error("Failed to check for updates")); } else { const result = resp.result as SystemVersionInfo; + setAppVersion(result.local.appVersion); + setSystemVersion(result.local.systemVersion); resolve(result); } }); }); - }, [send]); + }, [send, setAppVersion, setSystemVersion]); const progressBarRef = useRef(null); useEffect(() => { diff --git a/ui/src/routes/devices.$id.settings.hardware.tsx b/ui/src/routes/devices.$id.settings.hardware.tsx index 5e306a0..5fb744b 100644 --- a/ui/src/routes/devices.$id.settings.hardware.tsx +++ b/ui/src/routes/devices.$id.settings.hardware.tsx @@ -1,97 +1,20 @@ import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsItem } from "@routes/devices.$id.settings"; -import { BacklightSettings, UsbConfigState, useSettingsStore } from "@/hooks/stores"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { BacklightSettings, useSettingsStore } from "@/hooks/stores"; +import { useEffect } from "react"; import { useJsonRpc } from "@/hooks/useJsonRpc"; import notifications from "../notifications"; import { SelectMenuBasic } from "@components/SelectMenuBasic"; -import USBConfigDialog from "@components/USBConfigDialog"; - -const generatedSerialNumber = [generateNumber(1, 9), generateHex(7, 7), 0, 1].join("&"); - -function generateNumber(min: number, max: number) { - return Math.floor(Math.random() * (max - min + 1) + min); -} - -function generateHex(min: number, max: number) { - const len = generateNumber(min, max); - const n = (Math.random() * 0xfffff * 1000000).toString(16); - return n.slice(0, len); -} - -export interface USBConfig { - vendor_id: string; - product_id: string; - serial_number: string; - manufacturer: string; - product: string; -} - -const usbConfigs = [ - { - label: "JetKVM Default", - value: "USB Emulation Device", - }, - { - label: "Logitech Universal Adapter", - value: "Logitech USB Input Device", - }, - { - label: "Microsoft Wireless MultiMedia Keyboard", - value: "Wireless MultiMedia Keyboard", - }, - { - label: "Dell Multimedia Pro Keyboard", - value: "Multimedia Pro Keyboard", - }, -]; - -type UsbConfigMap = Record; +import { UsbConfigSetting } from "../components/UsbConfigSetting"; +import { FeatureFlag } from "../components/FeatureFlag"; export default function SettingsHardwareRoute() { const [send] = useJsonRpc(); const settings = useSettingsStore(); - const [usbConfigProduct, setUsbConfigProduct] = useState(""); - const [deviceId, setDeviceId] = useState(""); - const setBacklightSettings = useSettingsStore(state => state.setBacklightSettings); - const usbConfigData: UsbConfigMap = useMemo( - () => ({ - "USB Emulation Device": { - vendor_id: "0x1d6b", - product_id: "0x0104", - serial_number: deviceId, - manufacturer: "JetKVM", - product: "USB Emulation Device", - }, - "Logitech USB Input Device": { - vendor_id: "0x046d", - product_id: "0xc52b", - serial_number: generatedSerialNumber, - manufacturer: "Logitech (x64)", - product: "Logitech USB Input Device", - }, - "Wireless MultiMedia Keyboard": { - vendor_id: "0x045e", - product_id: "0x005f", - serial_number: generatedSerialNumber, - manufacturer: "Microsoft", - product: "Wireless MultiMedia Keyboard", - }, - "Multimedia Pro Keyboard": { - vendor_id: "0x413c", - product_id: "0x2011", - serial_number: generatedSerialNumber, - manufacturer: "Dell Inc.", - product: "Multimedia Pro Keyboard", - }, - }), - [deviceId], - ); - const handleBacklightSettingsChange = (settings: BacklightSettings) => { // If the user has set the display to dim after it turns off, set the dim_after // value to never. @@ -114,42 +37,6 @@ export default function SettingsHardwareRoute() { notifications.success("Backlight settings updated successfully"); }); }; - const syncUsbConfigProduct = useCallback(() => { - send("getUsbConfig", {}, resp => { - if ("error" in resp) { - console.error("Failed to load USB Config:", resp.error); - notifications.error( - `Failed to load USB Config: ${resp.error.data || "Unknown error"}`, - ); - } else { - console.log("syncUsbConfigProduct#getUsbConfig result:", resp.result); - const usbConfigState = resp.result as UsbConfigState; - const product = usbConfigs.map(u => u.value).includes(usbConfigState.product) - ? usbConfigState.product - : "custom"; - setUsbConfigProduct(product); - } - }); - }, [send]); - - const handleUsbConfigChange = useCallback( - (usbConfig: USBConfig) => { - send("setUsbConfig", { usbConfig }, resp => { - if ("error" in resp) { - notifications.error( - `Failed to set usb config: ${resp.error.data || "Unknown error"}`, - ); - return; - } - // setUsbConfigProduct(usbConfig.product); - notifications.success( - `USB Config set to ${usbConfig.manufacturer} ${usbConfig.product}`, - ); - syncUsbConfigProduct(); - }); - }, - [send, syncUsbConfigProduct], - ); useEffect(() => { send("getBacklightSettings", {}, resp => { @@ -161,18 +48,7 @@ export default function SettingsHardwareRoute() { const result = resp.result as BacklightSettings; setBacklightSettings(result); }); - - send("getDeviceID", {}, async resp => { - if ("error" in resp) { - return notifications.error( - `Failed to get device ID: ${resp.error.data || "Unknown error"}`, - ); - } - setDeviceId(resp.result as string); - }); - - syncUsbConfigProduct(); - }, [send, setBacklightSettings, syncUsbConfigProduct]); + }, [send, setBacklightSettings]); return (
@@ -253,36 +129,9 @@ export default function SettingsHardwareRoute() {

-
- - - { - if (e.target.value === "custom") { - setUsbConfigProduct(e.target.value); - } else { - const usbConfig = usbConfigData[e.target.value]; - handleUsbConfigChange(usbConfig); - } - }} - options={[...usbConfigs, { value: "custom", label: "Custom" }]} - /> - - {usbConfigProduct === "custom" && ( - handleUsbConfigChange(usbConfig)} - onRestoreToDefault={() => - handleUsbConfigChange(usbConfigData[usbConfigs[0].value]) - } - /> - )} + + +
); } diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index a68ddb2..5576d75 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -3,6 +3,7 @@ import { cx } from "@/cva.config"; import { HidState, UpdateState, + useDeviceStore, useHidStore, useMountMediaStore, User, @@ -39,6 +40,9 @@ import { CLOUD_API, DEVICE_API } from "@/ui.config"; import Modal from "../components/Modal"; import { motion, AnimatePresence } from "motion/react"; import { useDeviceUiNavigation } from "../hooks/useAppNavigation"; +import { FeatureFlagProvider } from "../providers/FeatureFlagProvider"; +import { SystemVersionInfo } from "./devices.$id.settings.general.update"; +import notifications from "../notifications"; interface LocalLoaderResp { authMode: "password" | "noPassword" | null; @@ -442,8 +446,26 @@ 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); + + useEffect(() => { + if (appVersion) return; + + send("getUpdateStatus", {}, async resp => { + if ("error" in resp) { + notifications.error("Failed to get device version"); + } else { + const result = resp.result as SystemVersionInfo; + setAppVersion(result.local.appVersion); + setSystemVersion(result.local.systemVersion); + } + }); + }, [appVersion, send, setAppVersion, setSystemVersion]); + return ( - <> + {!outlet && otaState.updating && ( )} - + ); }