mirror of
https://github.com/luckfox-eng29/kvm.git
synced 2026-01-18 03:28:19 +01:00
Update App version to 0.0.4
Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
This commit is contained in:
@@ -90,7 +90,7 @@ export default function DashboardNavbar({
|
||||
<div className="inline-block shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<a
|
||||
href="https://wiki.luckfox.com/Luckfox-Pico/Download"
|
||||
href="https://wiki.luckfox.com/intro/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4"
|
||||
|
||||
46
ui/src/components/Tabs.tsx
Normal file
46
ui/src/components/Tabs.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useState } from "react";
|
||||
|
||||
export interface Tab {
|
||||
id: string;
|
||||
label: string;
|
||||
content: React.ReactNode;
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
tabs: Tab[];
|
||||
defaultTab?: string;
|
||||
}
|
||||
|
||||
export function Tabs({ tabs, defaultTab }: TabsProps) {
|
||||
const [activeTab, setActiveTab] = useState(defaultTab || tabs[0]?.id);
|
||||
|
||||
const activeTabContent = tabs.find(tab => tab.id === activeTab)?.content;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Tab buttons */}
|
||||
<div className="flex">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`
|
||||
px-6 py-2.5 text-sm font-medium transition-colors
|
||||
${
|
||||
activeTab === tab.id
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div>{activeTabContent}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -263,6 +263,9 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
|
||||
<li>
|
||||
{$at("If using an adapter, ensure it's compatible and functioning correctly")}
|
||||
</li>
|
||||
<li>
|
||||
{$at("Certain motherboards do not support simultaneous multi-display output")}
|
||||
</li>
|
||||
<li>
|
||||
{$at("Ensure source device is not in sleep mode and outputting a signal")}
|
||||
</li>
|
||||
|
||||
@@ -44,6 +44,30 @@ function KeyboardWrapper() {
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [newPosition, setNewPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
// State for locked modifier keys
|
||||
const [lockedModifiers, setLockedModifiers] = useState({
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
});
|
||||
|
||||
// Toggle for modifier key behavior: true = lock mode, false = direct trigger mode
|
||||
const [modifierLockMode, setModifierLockMode] = useState(true);
|
||||
|
||||
// Clear locked modifiers when switching to direct mode
|
||||
useEffect(() => {
|
||||
if (!modifierLockMode) {
|
||||
setLockedModifiers({
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
});
|
||||
setLayoutName("default");
|
||||
}
|
||||
}, [modifierLockMode]);
|
||||
|
||||
const isCapsLockActive = useHidStore(useShallow(state => state.keyboardLedState?.caps_lock));
|
||||
|
||||
// HID related states
|
||||
@@ -132,11 +156,62 @@ function KeyboardWrapper() {
|
||||
const cleanKey = key.replace(/[()]/g, "");
|
||||
const keyHasShiftModifier = key.includes("(");
|
||||
|
||||
// Check if this is a modifier key press
|
||||
const isModifierKey = key === "ControlLeft" || key === "AltLeft" || key === "MetaLeft" ||
|
||||
key === "AltRight" || key === "MetaRight" || isKeyShift;
|
||||
|
||||
// Handle toggle of layout for shift or caps lock
|
||||
const toggleLayout = () => {
|
||||
setLayoutName(prevLayout => (prevLayout === "default" ? "shift" : "default"));
|
||||
};
|
||||
|
||||
// Handle modifier key press
|
||||
if (key === "ControlLeft") {
|
||||
if (modifierLockMode) {
|
||||
// Lock mode: toggle lock state
|
||||
setLockedModifiers(prev => ({ ...prev, ctrl: !prev.ctrl }));
|
||||
} else {
|
||||
// Direct trigger mode: send key press and release immediately
|
||||
sendKeyboardEvent([], [modifiers["ControlLeft"]]);
|
||||
setTimeout(resetKeyboardState, 100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key === "AltLeft" || key === "AltRight") {
|
||||
if (modifierLockMode) {
|
||||
setLockedModifiers(prev => ({ ...prev, alt: !prev.alt }));
|
||||
} else {
|
||||
sendKeyboardEvent([], [modifiers[key]]);
|
||||
setTimeout(resetKeyboardState, 100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key === "MetaLeft" || key === "MetaRight") {
|
||||
if (modifierLockMode) {
|
||||
setLockedModifiers(prev => ({ ...prev, meta: !prev.meta }));
|
||||
} else {
|
||||
sendKeyboardEvent([], [modifiers[key]]);
|
||||
setTimeout(resetKeyboardState, 100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isKeyShift) {
|
||||
if (modifierLockMode) {
|
||||
setLockedModifiers(prev => ({ ...prev, shift: !prev.shift }));
|
||||
if (lockedModifiers.shift) {
|
||||
// If unlocking shift, return to default layout
|
||||
setLayoutName("default");
|
||||
} else {
|
||||
// If locking shift, switch to shift layout
|
||||
toggleLayout();
|
||||
}
|
||||
} else {
|
||||
sendKeyboardEvent([], [modifiers["ShiftLeft"]]);
|
||||
setTimeout(resetKeyboardState, 100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "CtrlAltDelete") {
|
||||
sendKeyboardEvent(
|
||||
[keys["Delete"]],
|
||||
@@ -166,7 +241,7 @@ function KeyboardWrapper() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isKeyShift || isKeyCaps) {
|
||||
if (isKeyCaps) {
|
||||
toggleLayout();
|
||||
|
||||
if (isCapsLockActive) {
|
||||
@@ -185,25 +260,61 @@ function KeyboardWrapper() {
|
||||
|
||||
// Collect new active keys and modifiers
|
||||
const newKeys = keys[cleanKey] ? [keys[cleanKey]] : [];
|
||||
const newModifiers =
|
||||
keyHasShiftModifier && !isCapsLockActive ? [modifiers["ShiftLeft"]] : [];
|
||||
const newModifiers: number[] = [];
|
||||
|
||||
// Add locked modifiers
|
||||
if (lockedModifiers.ctrl) {
|
||||
newModifiers.push(modifiers["ControlLeft"]);
|
||||
}
|
||||
if (lockedModifiers.alt) {
|
||||
newModifiers.push(modifiers["AltLeft"]);
|
||||
}
|
||||
if (lockedModifiers.meta) {
|
||||
newModifiers.push(modifiers["MetaLeft"]);
|
||||
}
|
||||
if (lockedModifiers.shift && !isCapsLockActive) {
|
||||
newModifiers.push(modifiers["ShiftLeft"]);
|
||||
}
|
||||
|
||||
// Add shift modifier for keys with parentheses (if not caps lock and shift not locked)
|
||||
if (keyHasShiftModifier && !isCapsLockActive && !lockedModifiers.shift) {
|
||||
newModifiers.push(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) {
|
||||
// If shift was used as a modifier and caps lock is not active and shift is not locked, revert to default layout
|
||||
if (keyHasShiftModifier && !isCapsLockActive && !lockedModifiers.shift) {
|
||||
setLayoutName("default");
|
||||
}
|
||||
|
||||
// Auto-unlock modifiers after regular key press (not for combination keys)
|
||||
if (!isModifierKey && newKeys.length > 0) {
|
||||
setLockedModifiers({
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
});
|
||||
setLayoutName("default");
|
||||
}
|
||||
|
||||
setTimeout(resetKeyboardState, 100);
|
||||
},
|
||||
[isCapsLockActive, isKeyboardLedManagedByHost, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive],
|
||||
[isCapsLockActive, isKeyboardLedManagedByHost, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive, lockedModifiers, modifierLockMode],
|
||||
);
|
||||
|
||||
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
|
||||
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
|
||||
|
||||
const modifierLockButtons = [
|
||||
lockedModifiers.ctrl ? "ControlLeft" : "",
|
||||
lockedModifiers.alt ? "AltLeft AltRight" : "",
|
||||
lockedModifiers.meta ? "MetaLeft MetaRight" : "",
|
||||
lockedModifiers.shift ? "ShiftLeft ShiftRight" : "",
|
||||
].filter(Boolean).join(" ").trim();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="transition-all duration-500 ease-in-out"
|
||||
@@ -259,9 +370,11 @@ function KeyboardWrapper() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
|
||||
{$at("Virtual Keyboard")}
|
||||
</h2>
|
||||
<div className="flex flex-col items-center gap-y-1">
|
||||
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
|
||||
{$at("Virtual Keyboard")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="absolute right-2">
|
||||
<Button
|
||||
size="XS"
|
||||
@@ -274,21 +387,64 @@ function KeyboardWrapper() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{/* First row with Lock Mode and combination keys */}
|
||||
<div className="flex items-center bg-blue-50/80 md:flex-row dark:bg-slate-700 gap-x-2 px-2 py-1">
|
||||
{/* Lock Mode toggle - positioned before Ctrl+Alt+Delete */}
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="text-[10px] text-slate-600 dark:text-slate-400 whitespace-nowrap">
|
||||
{$at("Lock Mode")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setModifierLockMode(!modifierLockMode)}
|
||||
className={cx(
|
||||
"relative inline-flex h-4 w-8 items-center rounded-full transition-colors",
|
||||
modifierLockMode ? "bg-blue-500" : "bg-slate-300 dark:bg-slate-600"
|
||||
)}
|
||||
title={modifierLockMode ? $at("Click to switch to direct trigger mode") : $at("Click to switch to lock mode")}
|
||||
>
|
||||
<span
|
||||
className={cx(
|
||||
"inline-block h-3 w-3 transform rounded-full bg-white transition-transform",
|
||||
modifierLockMode ? "translate-x-4" : "translate-x-1"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/* Combination keys */}
|
||||
<div className="flex items-center gap-x-1">
|
||||
<button
|
||||
className="hg-button combination-key inline-flex h-auto w-auto grow-0 py-1 px-2 text-xs border border-b border-slate-800/25 border-b-slate-800/25 shadow-xs dark:bg-slate-800 dark:text-white"
|
||||
onClick={() => onKeyDown("CtrlAltDelete")}
|
||||
>
|
||||
Ctrl + Alt + Delete
|
||||
</button>
|
||||
<button
|
||||
className="hg-button combination-key inline-flex h-auto w-auto grow-0 py-1 px-2 text-xs border border-b border-slate-800/25 border-b-slate-800/25 shadow-xs dark:bg-slate-800 dark:text-white"
|
||||
onClick={() => onKeyDown("AltMetaEscape")}
|
||||
>
|
||||
Alt + Meta + Escape
|
||||
</button>
|
||||
<button
|
||||
className="hg-button combination-key inline-flex h-auto w-auto grow-0 py-1 px-2 text-xs border border-b border-slate-800/25 border-b-slate-800/25 shadow-xs dark:bg-slate-800 dark:text-white"
|
||||
onClick={() => onKeyDown("CtrlAltBackspace")}
|
||||
>
|
||||
Ctrl + Alt + Backspace
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col bg-blue-50/80 md:flex-row dark:bg-slate-700">
|
||||
<Keyboard
|
||||
baseClass="simple-keyboard-main"
|
||||
layoutName={layoutName}
|
||||
onKeyPress={onKeyDown}
|
||||
buttonTheme={[
|
||||
{
|
||||
class: "combination-key",
|
||||
buttons: "CtrlAltDelete AltMetaEscape CtrlAltBackspace",
|
||||
},
|
||||
]}
|
||||
buttonTheme={
|
||||
modifierLockMode && modifierLockButtons
|
||||
? [{ class: "modifier-locked", buttons: modifierLockButtons }]
|
||||
: []
|
||||
}
|
||||
display={keyDisplayMap}
|
||||
layout={{
|
||||
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",
|
||||
@@ -297,7 +453,6 @@ function KeyboardWrapper() {
|
||||
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
|
||||
],
|
||||
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)",
|
||||
|
||||
@@ -10,6 +10,7 @@ import useKeyboard from "@/hooks/useKeyboard";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { cx } from "@/cva.config";
|
||||
import { keys, modifiers } from "@/keyboardMappings";
|
||||
import { chars } from "@/keyboardLayouts";
|
||||
import {
|
||||
useHidStore,
|
||||
useMouseStore,
|
||||
@@ -30,15 +31,25 @@ export default function WebRTCVideo() {
|
||||
// Video and stream related refs and states
|
||||
const videoElm = useRef<HTMLVideoElement>(null);
|
||||
const audioElm = useRef<HTMLAudioElement>(null);
|
||||
const pasteCaptureRef = useRef<HTMLTextAreaElement>(null);
|
||||
const mediaStream = useRTCStore(state => state.mediaStream);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
|
||||
const [isPointerLockActive, setIsPointerLockActive] = useState(false);
|
||||
const [mobileScale, setMobileScale] = useState(1);
|
||||
const [mobileTx, setMobileTx] = useState(0);
|
||||
const [mobileTy, setMobileTy] = useState(0);
|
||||
const activeTouchPointers = useRef<Map<number, { x: number; y: number }>>(new Map());
|
||||
const initialPinchDistance = useRef<number | null>(null);
|
||||
const initialPinchScale = useRef<number>(1);
|
||||
const lastPanPoint = useRef<{ x: number; y: number } | null>(null);
|
||||
const lastTapAt = useRef<number>(0);
|
||||
// Store hooks
|
||||
const settings = useSettingsStore();
|
||||
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
|
||||
const setMousePosition = useMouseStore(state => state.setMousePosition);
|
||||
const setMouseMove = useMouseStore(state => state.setMouseMove);
|
||||
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||
const {
|
||||
setClientSize: setVideoClientSize,
|
||||
setSize: setVideoSize,
|
||||
@@ -78,6 +89,66 @@ export default function WebRTCVideo() {
|
||||
// Misc states and hooks
|
||||
const [send] = useJsonRpc();
|
||||
|
||||
const overrideCtrlV = useSettingsStore(state => state.overrideCtrlV);
|
||||
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
|
||||
const safeKeyboardLayout = useMemo(() => {
|
||||
if (keyboardLayout && keyboardLayout.length > 0) return keyboardLayout;
|
||||
return "en_US";
|
||||
}, [keyboardLayout]);
|
||||
|
||||
const sendTextViaHID = useCallback(async (t: string) => {
|
||||
for (const ch of t) {
|
||||
const mapping = chars[safeKeyboardLayout][ch];
|
||||
if (!mapping || !mapping.key) continue;
|
||||
const { key, shift, altRight, deadKey, accentKey } = mapping;
|
||||
const keyz = [keys[key]];
|
||||
const modz = [(shift ? modifiers["ShiftLeft"] : 0) | (altRight ? modifiers["AltRight"] : 0)];
|
||||
if (deadKey) {
|
||||
keyz.push(keys["Space"]);
|
||||
modz.push(0);
|
||||
}
|
||||
if (accentKey) {
|
||||
keyz.unshift(keys[accentKey.key as keyof typeof keys]);
|
||||
modz.unshift(((accentKey.shift ? modifiers["ShiftLeft"] : 0) | (accentKey.altRight ? modifiers["AltRight"] : 0)));
|
||||
}
|
||||
for (const [index, kei] of keyz.entries()) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
send("keyboardReport", { keys: [kei], modifier: modz[index] }, params => {
|
||||
if ("error" in params) return reject(params.error as unknown as Error);
|
||||
send("keyboardReport", { keys: [], modifier: 0 }, params => {
|
||||
if ("error" in params) return reject(params.error as unknown as Error);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [send, safeKeyboardLayout]);
|
||||
|
||||
const handleGlobalPaste = useCallback(async (e: ClipboardEvent) => {
|
||||
if (!overrideCtrlV) return;
|
||||
e.preventDefault();
|
||||
const txt = e.clipboardData?.getData("text") || "";
|
||||
if (!txt) return;
|
||||
const invalid = [
|
||||
...new Set(
|
||||
// @ts-expect-error
|
||||
[...new Intl.Segmenter().segment(txt)].map(x => x.segment).filter(ch => !chars[safeKeyboardLayout][ch]),
|
||||
),
|
||||
];
|
||||
if (invalid.length > 0) {
|
||||
notifications.error(`Invalid characters: ${invalid.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
if (isReinitializingGadget) return;
|
||||
try {
|
||||
await sendTextViaHID(txt);
|
||||
notifications.success(`Pasted: "${txt}"`);
|
||||
} catch {
|
||||
notifications.error("Failed to paste text");
|
||||
}
|
||||
}, [overrideCtrlV, safeKeyboardLayout, isReinitializingGadget, sendTextViaHID]);
|
||||
|
||||
// Video-related
|
||||
useResizeObserver({
|
||||
ref: videoElm as React.RefObject<HTMLElement>,
|
||||
@@ -223,7 +294,7 @@ export default function WebRTCVideo() {
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("fullscreenchange ", handleFullscreenChange);
|
||||
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
||||
}, [releaseKeyboardLock]);
|
||||
|
||||
// Mouse-related
|
||||
@@ -232,16 +303,24 @@ export default function WebRTCVideo() {
|
||||
const sendRelMouseMovement = useCallback(
|
||||
(x: number, y: number, buttons: number) => {
|
||||
if (settings.mouseMode !== "relative") return;
|
||||
// Don't send mouse events while reinitializing gadget
|
||||
if (isReinitializingGadget) return;
|
||||
// if we ignore the event, double-click will not work
|
||||
// if (x === 0 && y === 0 && buttons === 0) return;
|
||||
send("relMouseReport", { dx: calcDelta(x), dy: calcDelta(y), buttons });
|
||||
setMouseMove({ x, y, buttons });
|
||||
},
|
||||
[send, setMouseMove, settings.mouseMode],
|
||||
[send, setMouseMove, settings.mouseMode, isReinitializingGadget],
|
||||
);
|
||||
|
||||
const relMouseMoveHandler = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
const pt = (e as unknown as PointerEvent).pointerType as unknown as string;
|
||||
if (pt === "touch") {
|
||||
const touchCount = activeTouchPointers.current.size;
|
||||
if (touchCount >= 2) return;
|
||||
if (mobileScale > 1 && lastPanPoint.current) return;
|
||||
}
|
||||
if (settings.mouseMode !== "relative") return;
|
||||
if (isPointerLockActive === false && isPointerLockPossible) return;
|
||||
|
||||
@@ -255,15 +334,25 @@ export default function WebRTCVideo() {
|
||||
const sendAbsMouseMovement = useCallback(
|
||||
(x: number, y: number, buttons: number) => {
|
||||
if (settings.mouseMode !== "absolute") return;
|
||||
// Don't send mouse events while reinitializing gadget
|
||||
if (isReinitializingGadget) return;
|
||||
send("absMouseReport", { x, y, buttons });
|
||||
// We set that for the debug info bar
|
||||
setMousePosition(x, y);
|
||||
},
|
||||
[send, setMousePosition, settings.mouseMode],
|
||||
[send, setMousePosition, settings.mouseMode, isReinitializingGadget],
|
||||
);
|
||||
|
||||
const absMouseMoveHandler = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
const pt = (e as unknown as PointerEvent).pointerType as unknown as string;
|
||||
if (pt === "touch") {
|
||||
const touchCount = activeTouchPointers.current.size;
|
||||
if (touchCount >= 2) return;
|
||||
if (mobileScale > 1) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!videoClientWidth || !videoClientHeight) return;
|
||||
if (settings.mouseMode !== "absolute") return;
|
||||
|
||||
@@ -287,9 +376,13 @@ export default function WebRTCVideo() {
|
||||
offsetY = (videoClientHeight - effectiveHeight) / 2;
|
||||
}
|
||||
|
||||
// Determine input point (reverse transform for touch when zoomed)
|
||||
const inputOffsetX = pt === "touch" ? Math.max(0, Math.min(videoClientWidth, (e.offsetX - mobileTx) / mobileScale)) : e.offsetX;
|
||||
const inputOffsetY = pt === "touch" ? Math.max(0, Math.min(videoClientHeight, (e.offsetY - mobileTy) / mobileScale)) : e.offsetY;
|
||||
|
||||
// Clamp mouse position within the effective video boundaries
|
||||
const clampedX = Math.min(Math.max(offsetX, e.offsetX), offsetX + effectiveWidth);
|
||||
const clampedY = Math.min(Math.max(offsetY, e.offsetY), offsetY + effectiveHeight);
|
||||
const clampedX = Math.min(Math.max(offsetX, inputOffsetX), offsetX + effectiveWidth);
|
||||
const clampedY = Math.min(Math.max(offsetY, inputOffsetY), offsetY + effectiveHeight);
|
||||
|
||||
// Map clamped mouse position to the video stream's coordinate system
|
||||
const relativeX = (clampedX - offsetX) / effectiveWidth;
|
||||
@@ -303,11 +396,13 @@ export default function WebRTCVideo() {
|
||||
const { buttons } = e;
|
||||
sendAbsMouseMovement(x, y, buttons);
|
||||
},
|
||||
[settings.mouseMode, videoClientWidth, videoClientHeight, videoWidth, videoHeight, sendAbsMouseMovement],
|
||||
[settings.mouseMode, videoClientWidth, videoClientHeight, videoWidth, videoHeight, sendAbsMouseMovement, mobileScale, mobileTx, mobileTy],
|
||||
);
|
||||
|
||||
const mouseWheelHandler = useCallback(
|
||||
(e: WheelEvent) => {
|
||||
// Don't send wheel events while reinitializing gadget
|
||||
if (isReinitializingGadget) return;
|
||||
|
||||
if (settings.scrollThrottling && blockWheelEvent) {
|
||||
return;
|
||||
@@ -339,7 +434,7 @@ export default function WebRTCVideo() {
|
||||
setTimeout(() => setBlockWheelEvent(false), settings.scrollThrottling);
|
||||
}
|
||||
},
|
||||
[send, blockWheelEvent, settings],
|
||||
[send, blockWheelEvent, settings, isReinitializingGadget],
|
||||
);
|
||||
|
||||
const resetMousePosition = useCallback(() => {
|
||||
@@ -414,6 +509,16 @@ export default function WebRTCVideo() {
|
||||
|
||||
const keyDownHandler = useCallback(
|
||||
async (e: KeyboardEvent) => {
|
||||
if (overrideCtrlV && (e.code === "KeyV" || e.key.toLowerCase() === "v") && (e.ctrlKey || e.metaKey)) {
|
||||
console.log("Override Ctrl V");
|
||||
if (isReinitializingGadget) return;
|
||||
if (pasteCaptureRef.current) {
|
||||
pasteCaptureRef.current.value = "";
|
||||
pasteCaptureRef.current.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
const prev = useHidStore.getState();
|
||||
let code = e.code;
|
||||
@@ -456,10 +561,15 @@ export default function WebRTCVideo() {
|
||||
[
|
||||
handleModifierKeys,
|
||||
sendKeyboardEvent,
|
||||
send,
|
||||
isKeyboardLedManagedByHost,
|
||||
setIsNumLockActive,
|
||||
setIsCapsLockActive,
|
||||
setIsScrollLockActive,
|
||||
overrideCtrlV,
|
||||
pasteCaptureRef,
|
||||
safeKeyboardLayout,
|
||||
isReinitializingGadget,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -568,23 +678,20 @@ export default function WebRTCVideo() {
|
||||
);
|
||||
|
||||
// Setup Keyboard Events
|
||||
useEffect(
|
||||
function setupKeyboardEvents() {
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
useEffect(function setupKeyboardEvents() {
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
document.addEventListener("keydown", keyDownHandler, { signal });
|
||||
document.addEventListener("keyup", keyUpHandler, { signal });
|
||||
document.addEventListener("keydown", keyDownHandler, { signal });
|
||||
document.addEventListener("keyup", keyUpHandler, { signal });
|
||||
|
||||
window.addEventListener("blur", resetKeyboardState, { signal });
|
||||
document.addEventListener("visibilitychange", resetKeyboardState, { signal });
|
||||
window.addEventListener("blur", resetKeyboardState, { signal });
|
||||
document.addEventListener("visibilitychange", resetKeyboardState, { signal });
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
},
|
||||
[keyDownHandler, keyUpHandler, resetKeyboardState],
|
||||
);
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, [keyDownHandler, keyUpHandler, resetKeyboardState]);
|
||||
|
||||
// Setup Video Event Listeners
|
||||
useEffect(
|
||||
@@ -608,6 +715,14 @@ export default function WebRTCVideo() {
|
||||
[onVideoPlaying, videoKeyUpHandler],
|
||||
);
|
||||
|
||||
// Setup Global Paste Listener (register after handleGlobalPaste is defined)
|
||||
useEffect(function setupPasteListener() {
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
document.addEventListener("paste", handleGlobalPaste, { signal });
|
||||
return () => abortController.abort();
|
||||
}, [handleGlobalPaste]);
|
||||
|
||||
// Setup Mouse Events
|
||||
useEffect(
|
||||
function setMouseModeEventListeners() {
|
||||
@@ -652,6 +767,90 @@ export default function WebRTCVideo() {
|
||||
);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const zoomLayerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = zoomLayerRef.current;
|
||||
if (!el) return;
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (e.pointerType !== "touch") return;
|
||||
try { el.setPointerCapture(e.pointerId); } catch {}
|
||||
activeTouchPointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (activeTouchPointers.current.size === 1) {
|
||||
const now = Date.now();
|
||||
if (now - lastTapAt.current < 300) {
|
||||
setMobileScale(1);
|
||||
setMobileTx(0);
|
||||
setMobileTy(0);
|
||||
}
|
||||
lastTapAt.current = now;
|
||||
lastPanPoint.current = { x: e.clientX, y: e.clientY };
|
||||
} else if (activeTouchPointers.current.size === 2) {
|
||||
const pts = Array.from(activeTouchPointers.current.values());
|
||||
const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
||||
initialPinchDistance.current = d;
|
||||
initialPinchScale.current = mobileScale;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (e.pointerType !== "touch") return;
|
||||
const prev = activeTouchPointers.current.get(e.pointerId);
|
||||
activeTouchPointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
const pts = Array.from(activeTouchPointers.current.values());
|
||||
if (pts.length === 2 && initialPinchDistance.current) {
|
||||
const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
||||
const factor = d / initialPinchDistance.current;
|
||||
const next = Math.max(1, Math.min(4, initialPinchScale.current * factor));
|
||||
setMobileScale(next);
|
||||
} else if (pts.length === 1 && lastPanPoint.current && prev) {
|
||||
const dx = e.clientX - lastPanPoint.current.x;
|
||||
const dy = e.clientY - lastPanPoint.current.y;
|
||||
lastPanPoint.current = { x: e.clientX, y: e.clientY };
|
||||
setMobileTx(v => v + dx);
|
||||
setMobileTy(v => v + dy);
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
if (e.pointerType !== "touch") return;
|
||||
activeTouchPointers.current.delete(e.pointerId);
|
||||
if (activeTouchPointers.current.size < 2) {
|
||||
initialPinchDistance.current = null;
|
||||
}
|
||||
if (activeTouchPointers.current.size === 0) {
|
||||
lastPanPoint.current = null;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
el.addEventListener("pointerdown", onPointerDown, { signal });
|
||||
el.addEventListener("pointermove", onPointerMove, { signal });
|
||||
el.addEventListener("pointerup", onPointerUp, { signal });
|
||||
el.addEventListener("pointercancel", onPointerUp, { signal });
|
||||
|
||||
return () => abortController.abort();
|
||||
}, [mobileScale]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const cw = container.clientWidth;
|
||||
const ch = container.clientHeight;
|
||||
if (!cw || !ch) return;
|
||||
const maxX = (cw * (mobileScale - 1)) / 2;
|
||||
const maxY = (ch * (mobileScale - 1)) / 2;
|
||||
setMobileTx(x => Math.max(-maxX, Math.min(maxX, x)));
|
||||
setMobileTy(y => Math.max(-maxY, Math.min(maxY, y)));
|
||||
}, [mobileScale]);
|
||||
|
||||
const hasNoAutoPlayPermissions = useMemo(() => {
|
||||
if (peerConnection?.connectionState !== "connected") return false;
|
||||
@@ -710,7 +909,15 @@ export default function WebRTCVideo() {
|
||||
{/* In relative mouse mode and under https, we enable the pointer lock, and to do so we need a bar to show the user to click on the video to enable mouse control */}
|
||||
<PointerLockBar show={showPointerLockBar} />
|
||||
<div className="relative mx-4 my-2 flex items-center justify-center overflow-hidden">
|
||||
<div className="relative flex h-full w-full items-center justify-center">
|
||||
<div
|
||||
ref={zoomLayerRef}
|
||||
className="relative flex h-full w-full items-center justify-center"
|
||||
style={{
|
||||
transform: `translate(${mobileTx}px, ${mobileTy}px) scale(${mobileScale})`,
|
||||
transformOrigin: "center center",
|
||||
touchAction: "none",
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={videoElm}
|
||||
autoPlay={true}
|
||||
@@ -767,6 +974,37 @@ export default function WebRTCVideo() {
|
||||
<div>
|
||||
<InfoBar />
|
||||
</div>
|
||||
<textarea
|
||||
ref={pasteCaptureRef}
|
||||
aria-hidden="true"
|
||||
style={{ position: "fixed", left: -9999, top: -9999, width: 1, height: 1, opacity: 0 }}
|
||||
onPaste={async e => {
|
||||
console.log("Paste event");
|
||||
if (!overrideCtrlV) return;
|
||||
e.preventDefault();
|
||||
const txt = e.clipboardData?.getData("text") || e.currentTarget.value || "";
|
||||
e.currentTarget.blur();
|
||||
if (txt) {
|
||||
const invalid = [
|
||||
...new Set(
|
||||
// @ts-expect-error
|
||||
[...new Intl.Segmenter().segment(txt)].map(x => x.segment).filter(ch => !chars[safeKeyboardLayout][ch]),
|
||||
),
|
||||
];
|
||||
if (invalid.length > 0) {
|
||||
notifications.error(`Invalid characters: ${invalid.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
if (isReinitializingGadget) return;
|
||||
try {
|
||||
await sendTextViaHID(txt);
|
||||
notifications.success(`Pasted: "${txt}"`);
|
||||
} catch {
|
||||
notifications.error("Failed to paste text");
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
173
ui/src/components/extensions/PowerControl.tsx
Normal file
173
ui/src/components/extensions/PowerControl.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { LuHardDrive, LuPower, LuRotateCcw } from "react-icons/lu";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@components/Button";
|
||||
import Card from "@components/Card";
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import notifications from "@/notifications";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||
|
||||
import { useJsonRpc } from "../../hooks/useJsonRpc";
|
||||
|
||||
const LONG_PRESS_DURATION = 3000; // 3 seconds for long press
|
||||
|
||||
interface ATXState {
|
||||
power: boolean;
|
||||
hdd: boolean;
|
||||
}
|
||||
|
||||
export function ATXPowerControl() {
|
||||
const [isPowerPressed, setIsPowerPressed] = useState(false);
|
||||
const [powerPressTimer, setPowerPressTimer] = useState<ReturnType<
|
||||
typeof setTimeout
|
||||
> | null>(null);
|
||||
const [atxState, setAtxState] = useState<ATXState | null>(null);
|
||||
|
||||
const [send] = useJsonRpc(function onRequest(resp) {
|
||||
if (resp.method === "atxState") {
|
||||
setAtxState(resp.params as ATXState);
|
||||
}
|
||||
});
|
||||
|
||||
// Request initial state
|
||||
useEffect(() => {
|
||||
send("getATXState", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to get ATX state: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setAtxState(resp.result as ATXState);
|
||||
});
|
||||
}, [send]);
|
||||
|
||||
const handlePowerPress = (pressed: boolean) => {
|
||||
// Prevent phantom releases
|
||||
if (!pressed && !isPowerPressed) return;
|
||||
|
||||
setIsPowerPressed(pressed);
|
||||
|
||||
// Handle button press
|
||||
if (pressed) {
|
||||
// Start long press timer
|
||||
const timer = setTimeout(() => {
|
||||
// Send long press action
|
||||
console.log("Sending long press ATX power action");
|
||||
send("setATXPowerAction", { action: "power-long" }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
setIsPowerPressed(false);
|
||||
});
|
||||
}, LONG_PRESS_DURATION);
|
||||
|
||||
setPowerPressTimer(timer);
|
||||
}
|
||||
// Handle button release
|
||||
else {
|
||||
// If timer exists, was a short press
|
||||
if (powerPressTimer) {
|
||||
clearTimeout(powerPressTimer);
|
||||
setPowerPressTimer(null);
|
||||
|
||||
// Send short press action
|
||||
console.log("Sending short press ATX power action");
|
||||
send("setATXPowerAction", { action: "power-short" }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (powerPressTimer) {
|
||||
clearTimeout(powerPressTimer);
|
||||
}
|
||||
};
|
||||
}, [powerPressTimer]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SettingsPageHeader
|
||||
title="ATX Power Control"
|
||||
description="Control your ATX power settings"
|
||||
/>
|
||||
|
||||
{atxState === null ? (
|
||||
<Card className="flex h-[120px] items-center justify-center p-3">
|
||||
<LoadingSpinner className="h-6 w-6 text-blue-500 dark:text-blue-400" />
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="h-[120px] animate-fadeIn opacity-0">
|
||||
<div className="space-y-4 p-3">
|
||||
{/* Control Buttons */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
LeadingIcon={LuPower}
|
||||
text="Power"
|
||||
onMouseDown={() => handlePowerPress(true)}
|
||||
onMouseUp={() => handlePowerPress(false)}
|
||||
onMouseLeave={() => handlePowerPress(false)}
|
||||
className={isPowerPressed ? "opacity-75" : ""}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
LeadingIcon={LuRotateCcw}
|
||||
text="Reset"
|
||||
onClick={() => {
|
||||
send("setATXPowerAction", { action: "reset" }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr className="border-slate-700/30 dark:border-slate-600/30" />
|
||||
{/* Status Indicators */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
<LuPower
|
||||
strokeWidth={3}
|
||||
className={`mr-1 inline ${
|
||||
atxState?.power ? "text-green-600" : "text-slate-300"
|
||||
}`}
|
||||
/>
|
||||
Power LED
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
<LuHardDrive
|
||||
strokeWidth={3}
|
||||
className={`mr-1 inline ${
|
||||
atxState?.hdd ? "text-blue-400" : "text-slate-300"
|
||||
}`}
|
||||
/>
|
||||
HDD LED
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { keys, modifiers } from "@/keyboardMappings";
|
||||
import { layouts, chars } from "@/keyboardLayouts";
|
||||
import notifications from "@/notifications";
|
||||
import {useReactAt} from 'i18n-auto-extractor/react'
|
||||
import { Checkbox } from "@/components/Checkbox";
|
||||
|
||||
const hidKeyboardPayload = (keys: number[], modifier: number) => {
|
||||
return { keys, modifier };
|
||||
@@ -28,11 +29,15 @@ export default function PasteModal() {
|
||||
const TextAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const setPasteMode = useHidStore(state => state.setPasteModeEnabled);
|
||||
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||
|
||||
const [send] = useJsonRpc();
|
||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
||||
|
||||
const [invalidChars, setInvalidChars] = useState<string[]>([]);
|
||||
const overrideCtrlV = useSettingsStore(state => state.overrideCtrlV);
|
||||
const setOverrideCtrlV = useSettingsStore(state => state.setOverrideCtrlV);
|
||||
const [pasteBuffer, setPasteBuffer] = useState<string>("");
|
||||
const close = useClose();
|
||||
|
||||
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
|
||||
@@ -59,54 +64,126 @@ export default function PasteModal() {
|
||||
setPasteMode(false);
|
||||
setDisableVideoFocusTrap(false);
|
||||
setInvalidChars([]);
|
||||
// keep override state persistent via settings store; do not reset here
|
||||
}, [setDisableVideoFocusTrap, setPasteMode]);
|
||||
|
||||
const onConfirmPaste = useCallback(async () => {
|
||||
setPasteMode(false);
|
||||
setDisableVideoFocusTrap(false);
|
||||
if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return;
|
||||
// Don't send keyboard events while reinitializing gadget
|
||||
if (isReinitializingGadget) {
|
||||
notifications.error("USB gadget is reinitializing, please wait...");
|
||||
return;
|
||||
}
|
||||
if (!safeKeyboardLayout) return;
|
||||
if (!chars[safeKeyboardLayout]) return;
|
||||
const text = TextAreaRef.current.value;
|
||||
const sendText = async (t: string) => {
|
||||
try {
|
||||
for (const char of t) {
|
||||
const mapping = chars[safeKeyboardLayout][char];
|
||||
if (!mapping || !mapping.key) continue;
|
||||
const { key, shift, altRight, deadKey, accentKey } = mapping;
|
||||
|
||||
try {
|
||||
for (const char of text) {
|
||||
const { key, shift, altRight, deadKey, accentKey } = chars[safeKeyboardLayout][char]
|
||||
if (!key) continue;
|
||||
const keyz = [keys[key]];
|
||||
const modz = [modifierCode(shift, altRight)];
|
||||
|
||||
const keyz = [ keys[key] ];
|
||||
const modz = [ modifierCode(shift, altRight) ];
|
||||
|
||||
if (deadKey) {
|
||||
if (deadKey) {
|
||||
keyz.push(keys["Space"]);
|
||||
modz.push(noModifier);
|
||||
}
|
||||
if (accentKey) {
|
||||
keyz.unshift(keys[accentKey.key])
|
||||
modz.unshift(modifierCode(accentKey.shift, accentKey.altRight))
|
||||
}
|
||||
}
|
||||
if (accentKey) {
|
||||
keyz.unshift(keys[accentKey.key]);
|
||||
modz.unshift(modifierCode(accentKey.shift, accentKey.altRight));
|
||||
}
|
||||
|
||||
for (const [index, kei] of keyz.entries()) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
send(
|
||||
"keyboardReport",
|
||||
hidKeyboardPayload([kei], modz[index]),
|
||||
params => {
|
||||
if ("error" in params) return reject(params.error);
|
||||
send("keyboardReport", hidKeyboardPayload([], 0), params => {
|
||||
for (const [index, kei] of keyz.entries()) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
send(
|
||||
"keyboardReport",
|
||||
hidKeyboardPayload([kei], modz[index]),
|
||||
params => {
|
||||
if ("error" in params) return reject(params.error);
|
||||
resolve();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
send("keyboardReport", hidKeyboardPayload([], 0), params => {
|
||||
if ("error" in params) return reject(params.error);
|
||||
resolve();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
notifications.success(`Pasted: "${t}"`);
|
||||
} catch (error) {
|
||||
notifications.error("Failed to paste text");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
notifications.error("Failed to paste text");
|
||||
}
|
||||
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, safeKeyboardLayout]);
|
||||
};
|
||||
|
||||
await sendText(text);
|
||||
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, safeKeyboardLayout, isReinitializingGadget]);
|
||||
|
||||
const readClipboardToBufferAndSend = useCallback(async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
setPasteBuffer(text);
|
||||
const segInvalid = [
|
||||
...new Set(
|
||||
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
|
||||
[...new Intl.Segmenter().segment(text)]
|
||||
.map(x => x.segment)
|
||||
.filter(char => !chars[safeKeyboardLayout][char]),
|
||||
),
|
||||
];
|
||||
setInvalidChars(segInvalid);
|
||||
if (segInvalid.length === 0) {
|
||||
if (rpcDataChannel?.readyState !== "open" || isReinitializingGadget) return;
|
||||
const sendText = async (t: string) => {
|
||||
try {
|
||||
for (const char of t) {
|
||||
const mapping = chars[safeKeyboardLayout][char];
|
||||
if (!mapping || !mapping.key) continue;
|
||||
const { key, shift, altRight, deadKey, accentKey } = mapping;
|
||||
|
||||
const keyz = [keys[key]];
|
||||
const modz = [modifierCode(shift, altRight)];
|
||||
|
||||
if (deadKey) {
|
||||
keyz.push(keys["Space"]);
|
||||
modz.push(noModifier);
|
||||
}
|
||||
if (accentKey) {
|
||||
keyz.unshift(keys[accentKey.key]);
|
||||
modz.unshift(modifierCode(accentKey.shift, accentKey.altRight));
|
||||
}
|
||||
|
||||
for (const [index, kei] of keyz.entries()) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
send(
|
||||
"keyboardReport",
|
||||
hidKeyboardPayload([kei], modz[index]),
|
||||
params => {
|
||||
if ("error" in params) return reject(params.error);
|
||||
send("keyboardReport", hidKeyboardPayload([], 0), params => {
|
||||
if ("error" in params) return reject(params.error);
|
||||
resolve();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
notifications.success(`Pasted: "${t}"`);
|
||||
} catch (error) {
|
||||
notifications.error("Failed to paste text");
|
||||
}
|
||||
};
|
||||
await sendText(text);
|
||||
} else {
|
||||
notifications.error(`Invalid characters: ${segInvalid.join(", ")}`);
|
||||
}
|
||||
} catch {}
|
||||
}, [safeKeyboardLayout, rpcDataChannel?.readyState, isReinitializingGadget, send]);
|
||||
|
||||
useEffect(() => {
|
||||
if (TextAreaRef.current) {
|
||||
@@ -125,6 +202,18 @@ export default function PasteModal() {
|
||||
description={$at("Paste text from your client to the remote host")}
|
||||
/>
|
||||
|
||||
<div className="flex items-center">
|
||||
<label className="flex items-center gap-x-2 text-sm">
|
||||
<Checkbox
|
||||
checked={overrideCtrlV}
|
||||
onChange={e => setOverrideCtrlV(e.target.checked)}
|
||||
/>
|
||||
<span className="text-slate-700 dark:text-slate-300">
|
||||
{$at("Use Ctrl+V to paste clipboard to remote")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="animate-fadeIn opacity-0 space-y-2"
|
||||
style={{
|
||||
@@ -133,44 +222,122 @@ export default function PasteModal() {
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="w-full" onKeyUp={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}>
|
||||
<TextAreaWithLabel
|
||||
ref={TextAreaRef}
|
||||
label={$at("Paste from host")}
|
||||
rows={4}
|
||||
onKeyUp={e => e.stopPropagation()}
|
||||
onKeyDown={e => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
onConfirmPaste();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancelPasteMode();
|
||||
<div
|
||||
className="w-full"
|
||||
onKeyUp={e => e.stopPropagation()}
|
||||
onKeyDown={e => {
|
||||
e.stopPropagation();
|
||||
if (overrideCtrlV && (e.key.toLowerCase() === "v" || e.code === "KeyV") && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
readClipboardToBufferAndSend();
|
||||
}
|
||||
}}
|
||||
onPaste={e => {
|
||||
if (overrideCtrlV) {
|
||||
e.preventDefault();
|
||||
const txt = e.clipboardData?.getData("text") || "";
|
||||
if (txt) {
|
||||
setPasteBuffer(txt);
|
||||
const segInvalid = [
|
||||
...new Set(
|
||||
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
|
||||
[...new Intl.Segmenter().segment(txt)]
|
||||
.map(x => x.segment)
|
||||
.filter(char => !chars[safeKeyboardLayout][char]),
|
||||
),
|
||||
];
|
||||
setInvalidChars(segInvalid);
|
||||
if (segInvalid.length === 0) {
|
||||
if (rpcDataChannel?.readyState === "open" && !isReinitializingGadget) {
|
||||
const sendText = async (t: string) => {
|
||||
try {
|
||||
for (const char of t) {
|
||||
const mapping = chars[safeKeyboardLayout][char];
|
||||
if (!mapping || !mapping.key) continue;
|
||||
const { key, shift, altRight, deadKey, accentKey } = mapping;
|
||||
const keyz = [keys[key]];
|
||||
const modz = [modifierCode(shift, altRight)];
|
||||
if (deadKey) {
|
||||
keyz.push(keys["Space"]);
|
||||
modz.push(noModifier);
|
||||
}
|
||||
if (accentKey) {
|
||||
keyz.unshift(keys[accentKey.key]);
|
||||
modz.unshift(modifierCode(accentKey.shift, accentKey.altRight));
|
||||
}
|
||||
for (const [index, kei] of keyz.entries()) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
send(
|
||||
"keyboardReport",
|
||||
hidKeyboardPayload([kei], modz[index]),
|
||||
params => {
|
||||
if ("error" in params) return reject(params.error);
|
||||
send("keyboardReport", hidKeyboardPayload([], 0), params => {
|
||||
if ("error" in params) return reject(params.error);
|
||||
resolve();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
notifications.success(`Pasted: "${t}"`);
|
||||
} catch (error) {
|
||||
notifications.error("Failed to paste text");
|
||||
}
|
||||
};
|
||||
sendText(txt);
|
||||
}
|
||||
} else {
|
||||
notifications.error(`Invalid characters: ${segInvalid.join(", ")}`);
|
||||
}
|
||||
} else {
|
||||
readClipboardToBufferAndSend();
|
||||
}
|
||||
}}
|
||||
onChange={e => {
|
||||
const value = e.target.value;
|
||||
const invalidChars = [
|
||||
...new Set(
|
||||
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
|
||||
[...new Intl.Segmenter().segment(value)]
|
||||
.map(x => x.segment)
|
||||
.filter(char => !chars[safeKeyboardLayout][char]),
|
||||
),
|
||||
];
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!overrideCtrlV && (
|
||||
<>
|
||||
<TextAreaWithLabel
|
||||
ref={TextAreaRef}
|
||||
label={$at("Paste from host")}
|
||||
rows={4}
|
||||
onKeyUp={e => e.stopPropagation()}
|
||||
onKeyDown={e => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
onConfirmPaste();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancelPasteMode();
|
||||
}
|
||||
}}
|
||||
onChange={e => {
|
||||
const value = e.target.value;
|
||||
const invalidChars = [
|
||||
...new Set(
|
||||
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
|
||||
[...new Intl.Segmenter().segment(value)]
|
||||
.map(x => x.segment)
|
||||
.filter(char => !chars[safeKeyboardLayout][char]),
|
||||
),
|
||||
];
|
||||
|
||||
setInvalidChars(invalidChars);
|
||||
}}
|
||||
/>
|
||||
setInvalidChars(invalidChars);
|
||||
}}
|
||||
/>
|
||||
|
||||
{invalidChars.length > 0 && (
|
||||
<div className="mt-2 flex items-center gap-x-2">
|
||||
<ExclamationCircleIcon className="h-4 w-4 text-red-500 dark:text-red-400" />
|
||||
<span className="text-xs text-red-500 dark:text-red-400">
|
||||
{$at("The following characters will not be pasted:")} {invalidChars.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
{invalidChars.length > 0 && (
|
||||
<div className="mt-2 flex items-center gap-x-2">
|
||||
<ExclamationCircleIcon className="h-4 w-4 text-red-500 dark:text-red-400" />
|
||||
<span className="text-xs text-red-500 dark:text-red-400">
|
||||
{$at("The following characters will not be pasted:")} {invalidChars.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,13 +366,15 @@ export default function PasteModal() {
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text={$at("Confirm paste")}
|
||||
onClick={onConfirmPaste}
|
||||
LeadingIcon={LuCornerDownLeft}
|
||||
/>
|
||||
{!overrideCtrlV && (
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text={$at("Confirm paste")}
|
||||
onClick={onConfirmPaste}
|
||||
LeadingIcon={LuCornerDownLeft}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
|
||||
@@ -346,6 +346,9 @@ interface SettingsState {
|
||||
showPressedKeys: boolean;
|
||||
setShowPressedKeys: (show: boolean) => void;
|
||||
|
||||
overrideCtrlV: boolean;
|
||||
setOverrideCtrlV: (enabled: boolean) => void;
|
||||
|
||||
// Video enhancement settings
|
||||
videoSaturation: number;
|
||||
setVideoSaturation: (value: number) => void;
|
||||
@@ -409,6 +412,9 @@ export const useSettingsStore = create(
|
||||
showPressedKeys: true,
|
||||
setShowPressedKeys: show => set({ showPressedKeys: show }),
|
||||
|
||||
overrideCtrlV: false,
|
||||
setOverrideCtrlV: enabled => set({ overrideCtrlV: enabled }),
|
||||
|
||||
// Video enhancement settings with default values (1.0 = normal)
|
||||
videoSaturation: 1.0,
|
||||
setVideoSaturation: value => set({ videoSaturation: value }),
|
||||
@@ -524,6 +530,9 @@ export interface HidState {
|
||||
|
||||
usbState: "configured" | "attached" | "not attached" | "suspended" | "addressed" | "default";
|
||||
setUsbState: (state: HidState["usbState"]) => void;
|
||||
|
||||
isReinitializingGadget: boolean;
|
||||
setIsReinitializingGadget: (reinitializing: boolean) => void;
|
||||
}
|
||||
|
||||
export const useHidStore = create<HidState>((set, get) => ({
|
||||
@@ -571,6 +580,9 @@ export const useHidStore = create<HidState>((set, get) => ({
|
||||
// Add these new properties for USB state
|
||||
usbState: "not attached",
|
||||
setUsbState: state => set({ usbState: state }),
|
||||
|
||||
isReinitializingGadget: false,
|
||||
setIsReinitializingGadget: reinitializing => set({ isReinitializingGadget: reinitializing }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -808,15 +820,25 @@ export type TimeSyncMode =
|
||||
| "custom"
|
||||
| "unknown";
|
||||
|
||||
export interface IPv4StaticConfig {
|
||||
address?: string;
|
||||
netmask?: string;
|
||||
gateway?: string;
|
||||
dns?: string[];
|
||||
}
|
||||
|
||||
export interface NetworkSettings {
|
||||
hostname: string;
|
||||
domain: string;
|
||||
ipv4_mode: IPv4Mode;
|
||||
ipv4_request_address?: string;
|
||||
ipv4_static?: IPv4StaticConfig;
|
||||
ipv6_mode: IPv6Mode;
|
||||
lldp_mode: LLDPMode;
|
||||
lldp_tx_tlvs: string[];
|
||||
mdns_mode: mDNSMode;
|
||||
time_sync_mode: TimeSyncMode;
|
||||
pending_reboot?: boolean;
|
||||
}
|
||||
|
||||
export const useNetworkStateStore = create<NetworkState>((set, get) => ({
|
||||
|
||||
@@ -57,7 +57,13 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
|
||||
// The "API" can also "request" data from the client
|
||||
// If the payload has a method, it's a request
|
||||
if ("method" in payload) {
|
||||
if (onRequest) onRequest(payload);
|
||||
if ((payload as JsonRpcRequest).method === "refreshPage") {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
currentUrl.searchParams.set("networkChanged", "true");
|
||||
window.location.href = currentUrl.toString();
|
||||
return;
|
||||
}
|
||||
if (onRequest) onRequest(payload as JsonRpcRequest);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import notifications from "@/notifications";
|
||||
|
||||
import { useHidStore, useRTCStore } from "@/hooks/stores";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
@@ -11,18 +12,31 @@ export default function useKeyboard() {
|
||||
const updateActiveKeysAndModifiers = useHidStore(
|
||||
state => state.updateActiveKeysAndModifiers,
|
||||
);
|
||||
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||
const usbState = useHidStore(state => state.usbState);
|
||||
|
||||
const sendKeyboardEvent = useCallback(
|
||||
(keys: number[], modifiers: number[]) => {
|
||||
if (rpcDataChannel?.readyState !== "open") return;
|
||||
// Don't send keyboard events while reinitializing gadget
|
||||
if (isReinitializingGadget) return;
|
||||
if (usbState !== "configured") return;
|
||||
|
||||
const accModifier = modifiers.reduce((acc, val) => acc + val, 0);
|
||||
|
||||
send("keyboardReport", { keys, modifier: accModifier });
|
||||
send("keyboardReport", { keys, modifier: accModifier }, resp => {
|
||||
if ("error" in resp) {
|
||||
const msg = (resp.error.data as string) || resp.error.message || "";
|
||||
if (msg.includes("cannot send after transport endpoint shutdown") && usbState === "configured") {
|
||||
notifications.error("Please check if the cable and connection are stable.", { duration: 5000 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// We do this for the info bar to display the currently pressed keys for the user
|
||||
updateActiveKeysAndModifiers({ keys: keys, modifiers: modifiers });
|
||||
},
|
||||
[rpcDataChannel?.readyState, send, updateActiveKeysAndModifiers],
|
||||
[rpcDataChannel?.readyState, send, updateActiveKeysAndModifiers, isReinitializingGadget, usbState],
|
||||
);
|
||||
|
||||
const resetKeyboardState = useCallback(() => {
|
||||
|
||||
@@ -315,6 +315,16 @@ video::-webkit-media-controls {
|
||||
@apply inline-flex h-auto! w-auto! grow-0 py-1 text-xs;
|
||||
}
|
||||
|
||||
.hg-theme-default .hg-button.modifier-locked {
|
||||
@apply bg-blue-500! text-white! border-blue-600!;
|
||||
box-shadow: 0 0 10px rgba(59, 130, 246, 0.5) !important;
|
||||
}
|
||||
|
||||
.dark .hg-theme-default .hg-button.modifier-locked {
|
||||
@apply bg-blue-600! text-white! border-blue-700!;
|
||||
box-shadow: 0 0 10px rgba(59, 130, 246, 0.7) !important;
|
||||
}
|
||||
|
||||
.hg-theme-default .hg-row .hg-button-container,
|
||||
.hg-theme-default .hg-row .hg-button:not(:last-child) {
|
||||
@apply mr-[2px]! md:mr-[5px]!;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"1281764038": "Unmount",
|
||||
"2188692750": "Send a Signal to wake up the device connected via USB.",
|
||||
"2191159446": "Save Static IPv4 Settings?",
|
||||
"2316865986": "Name (Optional)",
|
||||
"3899981764": "You can only add a maximum of",
|
||||
"514d8a494f": "Terminal",
|
||||
"ef4f580086": "Paste Text",
|
||||
@@ -77,6 +79,7 @@
|
||||
"a90c4aa86f": "Ensure the HDMI cable securely connected at both ends",
|
||||
"6fd1357371": "Ensure source device is powered on and outputting a signal",
|
||||
"3ea74f7e5e": "If using an adapter, ensure it's compatible and functioning correctly",
|
||||
"db130e0e66": "Certain motherboards do not support simultaneous multi-display output",
|
||||
"54b1e31a57": "Ensure source device is not in sleep mode and outputting a signal",
|
||||
"693a670a35": "Try Wakeup",
|
||||
"d59048f21f": "Learn more",
|
||||
@@ -87,6 +90,9 @@
|
||||
"8fc3022cab": "Click on the video to enable mouse control",
|
||||
"f200763a0d": "Detach",
|
||||
"7193518e6f": "Attach",
|
||||
"1c6a47f19f": "Lock Mode",
|
||||
"b2759484fd": "Click to switch to direct trigger mode",
|
||||
"80461c2be2": "Click to switch to lock mode",
|
||||
"703703879d": "IO Control",
|
||||
"4ae517d1d2": "Configure your io control settings",
|
||||
"258f49887e": "Up",
|
||||
@@ -113,6 +119,7 @@
|
||||
"ae94be3cd5": "Manager",
|
||||
"5fe0273ec6": "Paste text",
|
||||
"2a5a0639a5": "Paste text from your client to the remote host",
|
||||
"2b2f7a6d7c": "Use Ctrl+V to paste clipboard to remote",
|
||||
"6d161c8084": "Paste from host",
|
||||
"604c45fbf2": "The following characters will not be pasted:",
|
||||
"a7eb9efa0b": "Sending text using keyboard layout:",
|
||||
@@ -217,33 +224,53 @@
|
||||
"3d0de21428": "Update your device access password",
|
||||
"f8508f576c": "Remote",
|
||||
"a8bb6f5f9f": "Manage the mode of Remote access to the device",
|
||||
"e4abe63a8b": "Connect to TailScale VPN network",
|
||||
"0b86461350": "TailScale use xEdge server",
|
||||
"a7d199ad4f": "Login URL:",
|
||||
"3bef87ee46": "Wait to obtain the Login URL",
|
||||
"a3060e541f": "Quitting...",
|
||||
"0baef6200d": "Network IP",
|
||||
"2faec1f9f8": "Enable",
|
||||
"761c43fec5": "Connect to ZeroTier VPN network",
|
||||
"3cc3bd7438": "Connecting...",
|
||||
"5028274560": "Network ID",
|
||||
"0baef6200d": "Network IP",
|
||||
"a2c4bef9fa": "Connect fail, please retry",
|
||||
"6327b4e59f": "Retry",
|
||||
"37cfbb7f44": "Enter ZeroTier Network ID",
|
||||
"718f8fd90c": "Join in",
|
||||
"18c514b621": "Connect to EasyTier server",
|
||||
"8fc22b3dac": "Network Node",
|
||||
"a3e117fed1": "Network Name",
|
||||
"5f1ffd341a": "Network Secret",
|
||||
"8fc22b3dac": "Network Node",
|
||||
"11a755d598": "Stop",
|
||||
"ce0be71e33": "Log",
|
||||
"3119fca100": "Node Info",
|
||||
"3b92996a28": "Enter EasyTier Network Name",
|
||||
"a4c4c07b3d": "Enter EasyTier Network Secret",
|
||||
"7a1920d611": "Default",
|
||||
"63e0339544": "Enter EasyTier Network Node",
|
||||
"3b92996a28": "Enter EasyTier Network Name",
|
||||
"a4c4c07b3d": "Enter EasyTier Network Secret",
|
||||
"a6122a65ea": "Start",
|
||||
"03eb0dbd4f": "Connect to Frp server",
|
||||
"31a631e8bc": "Config Mode",
|
||||
"c2a012144d": "Config File",
|
||||
"3225a10b07": "Parameters",
|
||||
"fa535ffb25": "Config",
|
||||
"ce545e8797": "Using config file",
|
||||
"459a6f79ad": "Token",
|
||||
"2e8f11ede1": "Device ID",
|
||||
"49ee308734": "Name",
|
||||
"a4d911beb0": "Server Address",
|
||||
"4059b0251f": "Info",
|
||||
"493fbda223": "Edit vnt.ini",
|
||||
"1c1aacac07": "Enter vnt-cli configuration",
|
||||
"788982fb30": "Token (Required)",
|
||||
"7e921a758c": "Enter Vnt Token",
|
||||
"1644a3594c": "Device ID (Optional)",
|
||||
"a6ad3901c6": "Enter Device ID",
|
||||
"2fadcf358b": "Enter Device Name",
|
||||
"0be4dc6ce1": "Server Address (Optional)",
|
||||
"723e86b659": "Enter Server Address",
|
||||
"2a766ad220": "Encryption Algorithm",
|
||||
"b24203b84f": "Password(Optional)",
|
||||
"c412ab8687": "Enter Vnt Password",
|
||||
"af0d799cbf": "Cloudflare Tunnel Token",
|
||||
"c420b0d8f0": "Enter Cloudflare Tunnel Token",
|
||||
"1bbabcdef3": "Edit frpc.toml",
|
||||
"9c088a303a": "Enter frpc configuration",
|
||||
"867cee98fd": "Passwords do not match",
|
||||
@@ -291,9 +318,18 @@
|
||||
"5c43d74dbd": "Control the USB emulation state",
|
||||
"f6c8ddbadf": "Disable USB Emulation",
|
||||
"020b92cfbb": "Enable USB Emulation",
|
||||
"9f55f64b0f": "USB Gadget Reinitialize",
|
||||
"40dc677a89": "Reinitialize USB gadget configuration",
|
||||
"02d2f33ec9": "Reinitialize USB Gadget",
|
||||
"f5ddf02991": "Reboot System",
|
||||
"1dbbf194af": "Restart the device system",
|
||||
"1de72c4fc6": "Reboot",
|
||||
"f43c0398a4": "Reset Configuration",
|
||||
"0031dbef48": "Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.",
|
||||
"0d784092e8": "Reset Config",
|
||||
"a776e925bf": "Reboot System?",
|
||||
"1f070051ff": "Are you sure you want to reboot the system?",
|
||||
"f1a79f466e": "The device will restart and you will be disconnected from the web interface.",
|
||||
"a1c58e9422": "Appearance",
|
||||
"d414b664a7": "Customize the look and feel of your KVM interface",
|
||||
"d721757161": "Theme",
|
||||
@@ -391,12 +427,21 @@
|
||||
"d4dccb8ca2": "Save settings",
|
||||
"8750a898cb": "IPv4 Mode",
|
||||
"72c2543791": "Configure IPv4 mode",
|
||||
"168d88811a": "Effective Upon Reboot",
|
||||
"4805e7f806": "Request Address",
|
||||
"536f68587c": "Netmask",
|
||||
"926dec9494": "Gateway",
|
||||
"94c252be0e": "DHCP Information",
|
||||
"902f16cd13": "No DHCP lease information available",
|
||||
"6a802c3684": "IPv6 Mode",
|
||||
"d29b71c737": "Configure the IPv6 mode",
|
||||
"d323009843": "No IPv6 addresses configured",
|
||||
"892eec6b1a": "This will request your DHCP server to assign a new IP address. Your device may lose network connectivity during the process.",
|
||||
"676228894e": "Changes will take effect after a restart.",
|
||||
"70d9be9b13": "Confirm",
|
||||
"50cfb85440": "Save Request Address?",
|
||||
"a30194c638": "This will save the requested IPv4 address. Changes take effect after a restart.",
|
||||
"cc172c234b": "Change IPv4 Mode?",
|
||||
"a344a29861": "IPv4 mode changes will take effect after a restart.",
|
||||
"697b29c12e": "Back to KVM",
|
||||
"34e2d1989a": "Video",
|
||||
"2c50ab9cb6": "Configure display settings and EDID for optimal compatibility",
|
||||
@@ -411,6 +456,8 @@
|
||||
"c63ecd19a0": "Contrast",
|
||||
"5a31e20e6d": "Contrast level",
|
||||
"4418069f82": "Reset to Default",
|
||||
"839b2e1447": "Force EDID Output",
|
||||
"66e3bd652e": "Force EDID output even when no display is connected",
|
||||
"25793bfcbb": "Adjust the EDID settings for the display",
|
||||
"34d026e0a9": "Custom EDID",
|
||||
"b0258b2bdb": "EDID details video mode compatibility. Default settings works in most cases, but unique UEFI/BIOS might need adjustments.",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"1281764038": "卸载",
|
||||
"2188692750": "发送信号以唤醒通过 USB 连接的设备。",
|
||||
"2191159446": "保存静态 IPv4 设置?",
|
||||
"2316865986": "名称(可选)",
|
||||
"3899981764": "你最多只能添加",
|
||||
"514d8a494f": "终端",
|
||||
"ef4f580086": "粘贴文本",
|
||||
@@ -77,6 +79,7 @@
|
||||
"a90c4aa86f": "确保 HDMI 线缆两端连接牢固",
|
||||
"6fd1357371": "确保源设备已开机并输出信号",
|
||||
"3ea74f7e5e": "如果使用适配器,确保其兼容且工作正常",
|
||||
"db130e0e66": "部分主板不支持同时输出多路视频信号",
|
||||
"54b1e31a57": "确保源设备未处于睡眠模式且正在输出信号",
|
||||
"693a670a35": "尝试唤醒",
|
||||
"d59048f21f": "了解更多",
|
||||
@@ -87,6 +90,9 @@
|
||||
"8fc3022cab": "点击视频以启用鼠标控制",
|
||||
"f200763a0d": "分离",
|
||||
"7193518e6f": "固定",
|
||||
"1c6a47f19f": "锁定模式",
|
||||
"b2759484fd": "点击切换到直接触发模式",
|
||||
"80461c2be2": "点击切换到锁定模式",
|
||||
"703703879d": "IO 控制",
|
||||
"4ae517d1d2": "配置您的 IO 输出电平状态",
|
||||
"258f49887e": "高电平",
|
||||
@@ -113,6 +119,7 @@
|
||||
"ae94be3cd5": "管理",
|
||||
"5fe0273ec6": "粘贴文本",
|
||||
"2a5a0639a5": "将文本从客户端粘贴到远程主机",
|
||||
"2b2f7a6d7c": "按下 Ctrl+V 直接将本地剪贴板内容发送到远端主机",
|
||||
"6d161c8084": "从主机粘贴",
|
||||
"604c45fbf2": "以下字符将不会被粘贴:",
|
||||
"a7eb9efa0b": "使用键盘布局发送文本:",
|
||||
@@ -217,33 +224,53 @@
|
||||
"3d0de21428": "更新设备访问密码",
|
||||
"f8508f576c": "远程",
|
||||
"a8bb6f5f9f": "管理远程访问设备的方式",
|
||||
"e4abe63a8b": "连接到 TailScale VPN 网络",
|
||||
"0b86461350": "TailScale 使用 xEdge 服务器",
|
||||
"a7d199ad4f": "登录网址:",
|
||||
"3bef87ee46": "等待获取登录网址",
|
||||
"a3060e541f": "退出中...",
|
||||
"0baef6200d": "网络 IP",
|
||||
"2faec1f9f8": "启用",
|
||||
"761c43fec5": "连接到 ZeroTier VPN 网络",
|
||||
"3cc3bd7438": "连接中...",
|
||||
"5028274560": "网络 ID",
|
||||
"0baef6200d": "网络 IP",
|
||||
"a2c4bef9fa": "连接失败,请重试",
|
||||
"6327b4e59f": "重试",
|
||||
"37cfbb7f44": "输入 ZeroTier 网络 ID",
|
||||
"718f8fd90c": "加入",
|
||||
"18c514b621": "连接到 EasyTier 服务器",
|
||||
"8fc22b3dac": "网络节点",
|
||||
"a3e117fed1": "网络名称",
|
||||
"5f1ffd341a": "网络密钥",
|
||||
"8fc22b3dac": "网络节点",
|
||||
"11a755d598": "停止",
|
||||
"ce0be71e33": "日志",
|
||||
"3119fca100": "节点信息",
|
||||
"3b92996a28": "输入 EasyTier 网络名称",
|
||||
"a4c4c07b3d": "输入 EasyTier 网络密钥",
|
||||
"7a1920d611": "默认",
|
||||
"63e0339544": "输入 EasyTier 网络节点",
|
||||
"3b92996a28": "输入 EasyTier 网络名称",
|
||||
"a4c4c07b3d": "输入 EasyTier 网络密钥",
|
||||
"a6122a65ea": "启动",
|
||||
"03eb0dbd4f": "连接到 Frp 服务器",
|
||||
"31a631e8bc": "配置模式",
|
||||
"c2a012144d": "配置文件",
|
||||
"3225a10b07": "参数",
|
||||
"fa535ffb25": "配置",
|
||||
"ce545e8797": "使用配置文件",
|
||||
"459a6f79ad": "令牌",
|
||||
"2e8f11ede1": "设备 ID",
|
||||
"49ee308734": "名称",
|
||||
"a4d911beb0": "服务器地址",
|
||||
"4059b0251f": "信息",
|
||||
"493fbda223": "编辑 vnt.ini",
|
||||
"1c1aacac07": "输入 vnt-cli 配置",
|
||||
"788982fb30": "令牌(必需)",
|
||||
"7e921a758c": "输入 Vnt 令牌",
|
||||
"1644a3594c": "设备 ID(可选)",
|
||||
"a6ad3901c6": "输入设备 ID",
|
||||
"2fadcf358b": "输入设备名称",
|
||||
"0be4dc6ce1": "服务器地址(可选)",
|
||||
"723e86b659": "输入服务器地址",
|
||||
"2a766ad220": "加密算法",
|
||||
"b24203b84f": "密码(可选)",
|
||||
"c412ab8687": "输入 Vnt 密码",
|
||||
"af0d799cbf": "Cloudflare 通道令牌",
|
||||
"c420b0d8f0": "输入 Cloudflare 通道令牌",
|
||||
"1bbabcdef3": "编辑 frpc.toml",
|
||||
"9c088a303a": "输入 frpc 配置",
|
||||
"867cee98fd": "密码不一致",
|
||||
@@ -291,9 +318,18 @@
|
||||
"5c43d74dbd": "控制 USB 复用状态",
|
||||
"f6c8ddbadf": "禁用 USB 复用",
|
||||
"020b92cfbb": "启用 USB 复用",
|
||||
"9f55f64b0f": "USB 设备重新初始化",
|
||||
"40dc677a89": "重新初始化 USB 设备配置",
|
||||
"02d2f33ec9": "重新初始化 USB 设备",
|
||||
"f5ddf02991": "重启系统",
|
||||
"1dbbf194af": "重启设备系统",
|
||||
"1de72c4fc6": "重启",
|
||||
"f43c0398a4": "重置配置",
|
||||
"0031dbef48": "重置配置,这将使你退出登录。部分配置重启后生效。",
|
||||
"0d784092e8": "重置配置",
|
||||
"a776e925bf": "重启系统?",
|
||||
"1f070051ff": "你确定重启系统吗?",
|
||||
"f1a79f466e": "设备将重启,你将从 Web 界面断开连接。",
|
||||
"a1c58e9422": "外观",
|
||||
"d414b664a7": "自定义 KVM 界面的外观",
|
||||
"d721757161": "主题",
|
||||
@@ -391,12 +427,21 @@
|
||||
"d4dccb8ca2": "保存设置",
|
||||
"8750a898cb": "IPv4 模式",
|
||||
"72c2543791": "配置 IPv4 模式",
|
||||
"168d88811a": "重启后生效",
|
||||
"4805e7f806": "申请地址",
|
||||
"536f68587c": "网络掩码",
|
||||
"926dec9494": "网关",
|
||||
"94c252be0e": "DHCP 信息",
|
||||
"902f16cd13": "无 DHCP 租约信息",
|
||||
"6a802c3684": "IPv6 模式",
|
||||
"d29b71c737": "配置 IPv6 模式",
|
||||
"d323009843": "未配置 IPv6 地址",
|
||||
"892eec6b1a": "这将请求 DHCP 服务器分配新的 IP 地址。在此过程中,您的设备可能会失去网络连接。",
|
||||
"676228894e": "更改将在重启后生效。",
|
||||
"70d9be9b13": "确认",
|
||||
"50cfb85440": "保存请求的 IPv4 地址?",
|
||||
"a30194c638": "这将保存请求的 IPv4 地址。更改将在重启后生效。",
|
||||
"cc172c234b": "更改 IPv4 模式?",
|
||||
"a344a29861": "IPv4 模式更改将在重启后生效。",
|
||||
"697b29c12e": "返回 KVM",
|
||||
"34e2d1989a": "视频",
|
||||
"2c50ab9cb6": "配置视频显示和 EDID",
|
||||
@@ -411,6 +456,8 @@
|
||||
"c63ecd19a0": "对比度",
|
||||
"5a31e20e6d": "对比度级别",
|
||||
"4418069f82": "恢复默认",
|
||||
"839b2e1447": "强制 EDID 输出",
|
||||
"66e3bd652e": "强制 EDID 输出,即使没有连接显示器",
|
||||
"25793bfcbb": "调整显示器的 EDID 设置",
|
||||
"34d026e0a9": "自定义 EDID",
|
||||
"b0258b2bdb": "EDID 详细信息视频模式兼容性。默认设置在大多数情况下有效,但某些独特的 UEFI/BIOS 可能需要调整。",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ import Checkbox from "../components/Checkbox";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { SettingsPageHeader } from "../components/SettingsPageheader";
|
||||
import { TextAreaWithLabel } from "../components/TextArea";
|
||||
import { useSettingsStore } from "../hooks/stores";
|
||||
import { useSettingsStore, useHidStore } from "../hooks/stores";
|
||||
import { useJsonRpc } from "../hooks/useJsonRpc";
|
||||
import { isOnDevice } from "../main";
|
||||
import notifications from "../notifications";
|
||||
@@ -24,9 +24,12 @@ export default function SettingsAdvancedRoute() {
|
||||
const [devChannel, setDevChannel] = useState(false);
|
||||
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
|
||||
const [showLoopbackWarning, setShowLoopbackWarning] = useState(false);
|
||||
const [showRebootConfirm, setShowRebootConfirm] = useState(false);
|
||||
const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false);
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const isReinitializingGadget = useHidStore(state => state.isReinitializingGadget);
|
||||
const setIsReinitializingGadget = useHidStore(state => state.setIsReinitializingGadget);
|
||||
|
||||
useEffect(() => {
|
||||
send("getSSHKeyState", {}, resp => {
|
||||
@@ -209,6 +212,47 @@ export default function SettingsAdvancedRoute() {
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem
|
||||
title={$at("USB Gadget Reinitialize")}
|
||||
description={$at("Reinitialize USB gadget configuration")}
|
||||
>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text={$at("Reinitialize USB Gadget")}
|
||||
disabled={isReinitializingGadget}
|
||||
loading={isReinitializingGadget}
|
||||
onClick={() => {
|
||||
if (isReinitializingGadget) return;
|
||||
setIsReinitializingGadget(true);
|
||||
send("reinitializeUsbGadget", {}, resp => {
|
||||
setIsReinitializingGadget(false);
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to reinitialize USB gadget: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
notifications.success("USB gadget reinitialized successfully");
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem
|
||||
title={$at("Reboot System")}
|
||||
description={$at("Restart the device system")}
|
||||
>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="danger"
|
||||
text={$at("Reboot")}
|
||||
onClick={() => {
|
||||
setShowRebootConfirm(true);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem
|
||||
title={$at("Reset Configuration")}
|
||||
description={$at("Reset configuration to default. This will log you out.Some configuration changes will take effect after restart system.")}
|
||||
@@ -250,6 +294,39 @@ export default function SettingsAdvancedRoute() {
|
||||
confirmText="I Understand, Enable Anyway"
|
||||
onConfirm={confirmLoopbackModeEnable}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showRebootConfirm}
|
||||
onClose={() => {
|
||||
setShowRebootConfirm(false);
|
||||
}}
|
||||
title={$at("Reboot System?")}
|
||||
description={
|
||||
<>
|
||||
<p>
|
||||
{$at("Are you sure you want to reboot the system?")}
|
||||
</p>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400 mt-2">
|
||||
{$at("The device will restart and you will be disconnected from the web interface.")}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
variant="warning"
|
||||
cancelText={$at("Cancel")}
|
||||
confirmText={$at("Reboot")}
|
||||
onConfirm={() => {
|
||||
setShowRebootConfirm(false);
|
||||
send("reboot", { force: false }, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to reboot: ${resp.error.data || "Unknown error"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
notifications.success("System rebooting...");
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { LuEthernetPort } from "react-icons/lu";
|
||||
|
||||
import {
|
||||
IPv4Mode,
|
||||
IPv4StaticConfig,
|
||||
IPv6Mode,
|
||||
LLDPMode,
|
||||
mDNSMode,
|
||||
@@ -84,11 +86,25 @@ export default function SettingsNetworkRoute() {
|
||||
|
||||
// We use this to determine whether the settings have changed
|
||||
const firstNetworkSettings = useRef<NetworkSettings | undefined>(undefined);
|
||||
// We use this to indicate whether saved settings differ from initial (effective) settings
|
||||
const initialNetworkSettings = useRef<NetworkSettings | undefined>(undefined);
|
||||
|
||||
const [networkSettingsLoaded, setNetworkSettingsLoaded] = useState(false);
|
||||
|
||||
const [customDomain, setCustomDomain] = useState<string>("");
|
||||
const [selectedDomainOption, setSelectedDomainOption] = useState<string>("local");
|
||||
const { id } = useParams();
|
||||
const baselineKey = id ? `network_baseline_${id}` : "network_baseline";
|
||||
const baselineResetKey = id ? `network_baseline_reset_${id}` : "network_baseline_reset";
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.get("networkChanged") === "true") {
|
||||
localStorage.setItem(baselineResetKey, "1");
|
||||
url.searchParams.delete("networkChanged");
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
}
|
||||
}, [baselineResetKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (networkSettings.domain && networkSettingsLoaded) {
|
||||
@@ -109,10 +125,33 @@ export default function SettingsNetworkRoute() {
|
||||
if ("error" in resp) return;
|
||||
console.log(resp.result);
|
||||
setNetworkSettings(resp.result as NetworkSettings);
|
||||
|
||||
if (!firstNetworkSettings.current) {
|
||||
firstNetworkSettings.current = resp.result as NetworkSettings;
|
||||
}
|
||||
const resetFlag = localStorage.getItem(baselineResetKey);
|
||||
const stored = localStorage.getItem(baselineKey);
|
||||
if (resetFlag) {
|
||||
initialNetworkSettings.current = resp.result as NetworkSettings;
|
||||
localStorage.setItem(baselineKey, JSON.stringify(resp.result));
|
||||
localStorage.removeItem(baselineResetKey);
|
||||
} else if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as NetworkSettings;
|
||||
const server = resp.result as NetworkSettings;
|
||||
if (JSON.stringify(parsed) !== JSON.stringify(server)) {
|
||||
initialNetworkSettings.current = server;
|
||||
localStorage.setItem(baselineKey, JSON.stringify(server));
|
||||
} else {
|
||||
initialNetworkSettings.current = parsed;
|
||||
}
|
||||
} catch {
|
||||
initialNetworkSettings.current = resp.result as NetworkSettings;
|
||||
localStorage.setItem(baselineKey, JSON.stringify(resp.result));
|
||||
}
|
||||
} else {
|
||||
initialNetworkSettings.current = resp.result as NetworkSettings;
|
||||
localStorage.setItem(baselineKey, JSON.stringify(resp.result));
|
||||
}
|
||||
setNetworkSettingsLoaded(true);
|
||||
});
|
||||
}, [send]);
|
||||
@@ -164,7 +203,41 @@ export default function SettingsNetworkRoute() {
|
||||
}, [getNetworkState, getNetworkSettings]);
|
||||
|
||||
const handleIpv4ModeChange = (value: IPv4Mode | string) => {
|
||||
setNetworkSettings({ ...networkSettings, ipv4_mode: value as IPv4Mode });
|
||||
const newMode = value as IPv4Mode;
|
||||
const updatedSettings: NetworkSettings = { ...networkSettings, ipv4_mode: newMode };
|
||||
|
||||
// Initialize static config if switching to static mode
|
||||
if (newMode === "static" && !updatedSettings.ipv4_static) {
|
||||
updatedSettings.ipv4_static = {
|
||||
address: "",
|
||||
netmask: "",
|
||||
gateway: "",
|
||||
dns: [],
|
||||
};
|
||||
}
|
||||
|
||||
setNetworkSettings(updatedSettings);
|
||||
};
|
||||
|
||||
const handleIpv4RequestAddressChange = (value: string) => {
|
||||
setNetworkSettings({ ...networkSettings, ipv4_request_address: value });
|
||||
};
|
||||
|
||||
const handleIpv4StaticChange = (field: keyof IPv4StaticConfig, value: string | string[]) => {
|
||||
const staticConfig = networkSettings.ipv4_static || {
|
||||
address: "",
|
||||
netmask: "",
|
||||
gateway: "",
|
||||
dns: [],
|
||||
};
|
||||
|
||||
setNetworkSettings({
|
||||
...networkSettings,
|
||||
ipv4_static: {
|
||||
...staticConfig,
|
||||
[field]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleIpv6ModeChange = (value: IPv6Mode | string) => {
|
||||
@@ -212,6 +285,55 @@ export default function SettingsNetworkRoute() {
|
||||
);
|
||||
|
||||
const [showRenewLeaseConfirm, setShowRenewLeaseConfirm] = useState(false);
|
||||
const [applyingRequestAddr, setApplyingRequestAddr] = useState(false);
|
||||
const [showRequestAddrConfirm, setShowRequestAddrConfirm] = useState(false);
|
||||
const [showApplyStaticConfirm, setShowApplyStaticConfirm] = useState(false);
|
||||
const [showIpv4RestartConfirm, setShowIpv4RestartConfirm] = useState(false);
|
||||
const [pendingIpv4Mode, setPendingIpv4Mode] = useState<IPv4Mode | null>(null);
|
||||
const [ipv4StaticDnsText, setIpv4StaticDnsText] = useState("");
|
||||
|
||||
const isIPv4StaticEqual = (a?: IPv4StaticConfig, b?: IPv4StaticConfig) => {
|
||||
const na = a || { address: "", netmask: "", gateway: "", dns: [] };
|
||||
const nb = b || { address: "", netmask: "", gateway: "", dns: [] };
|
||||
const adns = (na.dns || []).map(x => x.trim()).filter(x => x.length > 0).sort();
|
||||
const bdns = (nb.dns || []).map(x => x.trim()).filter(x => x.length > 0).sort();
|
||||
if ((na.address || "").trim() !== (nb.address || "").trim()) return false;
|
||||
if ((na.netmask || "").trim() !== (nb.netmask || "").trim()) return false;
|
||||
if ((na.gateway || "").trim() !== (nb.gateway || "").trim()) return false;
|
||||
if (adns.length !== bdns.length) return false;
|
||||
for (let i = 0; i < adns.length; i++) {
|
||||
if (adns[i] !== bdns[i]) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleApplyRequestAddress = useCallback(() => {
|
||||
const requested = (networkSettings.ipv4_request_address || "").trim();
|
||||
if (!requested) {
|
||||
notifications.error("Please enter a valid Request Address");
|
||||
return;
|
||||
}
|
||||
if (networkSettings.ipv4_mode !== "dhcp") {
|
||||
notifications.error("Request Address is only available in DHCP mode");
|
||||
return;
|
||||
}
|
||||
setApplyingRequestAddr(true);
|
||||
send("setNetworkSettings", { settings: networkSettings }, resp => {
|
||||
if ("error" in resp) {
|
||||
setApplyingRequestAddr(false);
|
||||
return notifications.error(
|
||||
"Failed to save Request Address: " + (resp.error.data ? resp.error.data : resp.error.message),
|
||||
);
|
||||
}
|
||||
setApplyingRequestAddr(false);
|
||||
notifications.success("Request Address saved. Changes will take effect after restart.");
|
||||
});
|
||||
}, [networkSettings, send]);
|
||||
|
||||
useEffect(() => {
|
||||
const dns = (networkSettings.ipv4_static?.dns || []).join(", ");
|
||||
setIpv4StaticDnsText(dns);
|
||||
}, [networkSettings.ipv4_static?.dns]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -337,7 +459,10 @@ export default function SettingsNetworkRoute() {
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
disabled={firstNetworkSettings.current === networkSettings}
|
||||
disabled={
|
||||
firstNetworkSettings.current === networkSettings ||
|
||||
(networkSettings.ipv4_mode === "static" && firstNetworkSettings.current?.ipv4_mode !== "static")
|
||||
}
|
||||
text={$at("Save settings")}
|
||||
onClick={() => setNetworkSettingsRemote(networkSettings)}
|
||||
/>
|
||||
@@ -346,46 +471,135 @@ export default function SettingsNetworkRoute() {
|
||||
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
|
||||
|
||||
<div className="space-y-4">
|
||||
<SettingsItem title={$at("IPv4 Mode")} description={$at("Configure IPv4 mode")}>
|
||||
<SettingsItem
|
||||
title={$at("IPv4 Mode")}
|
||||
description={$at("Configure IPv4 mode")}
|
||||
// badge={networkSettings.pending_reboot ? $at("Effective Upon Reboot") : undefined}
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
value={networkSettings.ipv4_mode}
|
||||
onChange={e => handleIpv4ModeChange(e.target.value)}
|
||||
onChange={e => {
|
||||
const next = e.target.value as IPv4Mode;
|
||||
setPendingIpv4Mode(next);
|
||||
setShowIpv4RestartConfirm(true);
|
||||
}}
|
||||
options={filterUnknown([
|
||||
{ value: "dhcp", label: "DHCP" },
|
||||
// { value: "static", label: "Static" },
|
||||
{ value: "static", label: "Static" },
|
||||
])}
|
||||
/>
|
||||
</SettingsItem>
|
||||
<AutoHeight>
|
||||
{!networkSettingsLoaded && !networkState?.dhcp_lease ? (
|
||||
|
||||
{networkSettings.ipv4_mode === "dhcp" && (
|
||||
<div className="flex items-end gap-x-2">
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
type="text"
|
||||
label={$at("Request Address")}
|
||||
placeholder="192.168.1.100"
|
||||
value={networkSettings.ipv4_request_address || ""}
|
||||
onChange={e => {
|
||||
handleIpv4RequestAddressChange(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text={$at("Save")}
|
||||
disabled={applyingRequestAddr || !networkSettings.ipv4_request_address}
|
||||
onClick={() => setShowRequestAddrConfirm(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{networkSettings.ipv4_mode === "static" && (
|
||||
<AutoHeight>
|
||||
<GridCard>
|
||||
<div className="p-4">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-base font-bold text-slate-900 dark:text-white">
|
||||
{$at("DHCP Lease Information")}
|
||||
</h3>
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
|
||||
<div className="h-4 w-1/2 rounded bg-slate-200 dark:bg-slate-700" />
|
||||
<div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
|
||||
<div className="p-4 mt-1 space-y-4 border-l border-slate-800/10 pl-4 dark:border-slate-300/20 items-end gap-x-2">
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
type="text"
|
||||
label={$at("IP Address")}
|
||||
placeholder="192.168.1.100"
|
||||
value={networkSettings.ipv4_static?.address || ""}
|
||||
onChange={e => {
|
||||
handleIpv4StaticChange("address", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
type="text"
|
||||
label={$at("Netmask")}
|
||||
placeholder="255.255.255.0"
|
||||
value={networkSettings.ipv4_static?.netmask || ""}
|
||||
onChange={e => {
|
||||
handleIpv4StaticChange("netmask", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
type="text"
|
||||
label={$at("Gateway")}
|
||||
placeholder="192.168.1.1"
|
||||
value={networkSettings.ipv4_static?.gateway || ""}
|
||||
onChange={e => {
|
||||
handleIpv4StaticChange("gateway", e.target.value);
|
||||
}}
|
||||
/>
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
type="text"
|
||||
label={$at("DNS Servers")}
|
||||
placeholder="8.8.8.8,8.8.4.4"
|
||||
value={ipv4StaticDnsText}
|
||||
onChange={e => setIpv4StaticDnsText(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text={$at("Save")}
|
||||
onClick={() => setShowApplyStaticConfirm(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
</AutoHeight>
|
||||
)}
|
||||
|
||||
{networkSettings.ipv4_mode === "dhcp" && (
|
||||
<AutoHeight>
|
||||
{!networkSettingsLoaded && !networkState?.dhcp_lease ? (
|
||||
<GridCard>
|
||||
<div className="p-4">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-base font-bold text-slate-900 dark:text-white">
|
||||
{$at("DHCP Lease Information")}
|
||||
</h3>
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
|
||||
<div className="h-4 w-1/2 rounded bg-slate-200 dark:bg-slate-700" />
|
||||
<div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
) : networkState?.dhcp_lease && networkState.dhcp_lease.ip ? (
|
||||
<DhcpLeaseCard
|
||||
networkState={networkState}
|
||||
setShowRenewLeaseConfirm={setShowRenewLeaseConfirm}
|
||||
/>
|
||||
) : (
|
||||
<EmptyCard
|
||||
IconElm={LuEthernetPort}
|
||||
headline={$at("DHCP Information")}
|
||||
description={$at("No DHCP lease information available")}
|
||||
/>
|
||||
)}
|
||||
</AutoHeight>
|
||||
</GridCard>
|
||||
) : networkState?.dhcp_lease && networkState.dhcp_lease.ip ? (
|
||||
<DhcpLeaseCard
|
||||
networkState={networkState}
|
||||
setShowRenewLeaseConfirm={setShowRenewLeaseConfirm}
|
||||
/>
|
||||
) : (
|
||||
<EmptyCard
|
||||
IconElm={LuEthernetPort}
|
||||
headline={$at("DHCP Information")}
|
||||
description={$at("No DHCP lease information available")}
|
||||
/>
|
||||
)}
|
||||
</AutoHeight>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<SettingsItem title={$at("IPv6 Mode")} description={$at("Configure the IPv6 mode")}>
|
||||
@@ -453,7 +667,7 @@ export default function SettingsNetworkRoute() {
|
||||
open={showRenewLeaseConfirm}
|
||||
onClose={() => setShowRenewLeaseConfirm(false)}
|
||||
title={$at("Renew DHCP Lease")}
|
||||
description={$at("This will request your DHCP server to assign a new IP address. Your device may lose network connectivity during the process.")}
|
||||
description={$at("Changes will take effect after a restart.")}
|
||||
variant="danger"
|
||||
confirmText={$at("Renew DHCP Lease")}
|
||||
cancelText={$at("Cancel")}
|
||||
@@ -462,6 +676,64 @@ export default function SettingsNetworkRoute() {
|
||||
setShowRenewLeaseConfirm(false);
|
||||
}}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={showApplyStaticConfirm}
|
||||
onClose={() => setShowApplyStaticConfirm(false)}
|
||||
title={$at("Save Static IPv4 Settings?")}
|
||||
description={$at("Changes will take effect after a restart.")}
|
||||
variant="warning"
|
||||
confirmText={$at("Confirm")}
|
||||
cancelText={$at("Cancel")}
|
||||
onConfirm={() => {
|
||||
setShowApplyStaticConfirm(false);
|
||||
const dnsArray = ipv4StaticDnsText
|
||||
.split(",")
|
||||
.map(d => d.trim())
|
||||
.filter(d => d.length > 0);
|
||||
const updatedSettings: NetworkSettings = {
|
||||
...networkSettings,
|
||||
ipv4_static: {
|
||||
...(networkSettings.ipv4_static || { address: "", netmask: "", gateway: "", dns: [] }),
|
||||
dns: dnsArray,
|
||||
},
|
||||
};
|
||||
setNetworkSettingsRemote(updatedSettings);
|
||||
}}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={showRequestAddrConfirm}
|
||||
onClose={() => setShowRequestAddrConfirm(false)}
|
||||
title={$at("Save Request Address?")}
|
||||
description={$at("This will save the requested IPv4 address. Changes take effect after a restart.")}
|
||||
variant="warning"
|
||||
confirmText={$at("Save")}
|
||||
cancelText={$at("Cancel")}
|
||||
onConfirm={() => {
|
||||
setShowRequestAddrConfirm(false);
|
||||
handleApplyRequestAddress();
|
||||
}}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={showIpv4RestartConfirm}
|
||||
onClose={() => setShowIpv4RestartConfirm(false)}
|
||||
title={$at("Change IPv4 Mode?")}
|
||||
description={$at("IPv4 mode changes will take effect after a restart.")}
|
||||
variant="warning"
|
||||
confirmText={$at("Confirm")}
|
||||
cancelText={$at("Cancel")}
|
||||
onConfirm={() => {
|
||||
setShowIpv4RestartConfirm(false);
|
||||
if (pendingIpv4Mode) {
|
||||
const updatedSettings: NetworkSettings = { ...networkSettings, ipv4_mode: pendingIpv4Mode };
|
||||
if (pendingIpv4Mode === "static" && !updatedSettings.ipv4_static) {
|
||||
updatedSettings.ipv4_static = { address: "", netmask: "", gateway: "", dns: [] };
|
||||
}
|
||||
setNetworkSettings(updatedSettings);
|
||||
setNetworkSettingsRemote(updatedSettings);
|
||||
setPendingIpv4Mode(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSettingsStore } from "@/hooks/stores";
|
||||
|
||||
import notifications from "../notifications";
|
||||
import { SelectMenuBasic } from "../components/SelectMenuBasic";
|
||||
import Checkbox from "../components/Checkbox";
|
||||
|
||||
import { SettingsItem } from "./devices.$id.settings";
|
||||
import {useReactAt} from 'i18n-auto-extractor/react'
|
||||
@@ -48,6 +49,7 @@ export default function SettingsVideoRoute() {
|
||||
const [streamQuality, setStreamQuality] = useState("1");
|
||||
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
|
||||
const [edid, setEdid] = useState<string | null>(null);
|
||||
const [forceHpd, setForceHpd] = useState(false);
|
||||
|
||||
// Video enhancement settings from store
|
||||
const videoSaturation = useSettingsStore(state => state.videoSaturation);
|
||||
@@ -85,7 +87,31 @@ export default function SettingsVideoRoute() {
|
||||
setCustomEdidValue(receivedEdid);
|
||||
}
|
||||
});
|
||||
|
||||
send("getForceHpd", {}, resp => {
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to get force EDID output: ${resp.error.data || "Unknown error"}`);
|
||||
setForceHpd(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setForceHpd(resp.result as boolean);
|
||||
});
|
||||
|
||||
}, [send]);
|
||||
|
||||
const handleForceHpdChange = (checked: boolean) => {
|
||||
send("setForceHpd", { forceHpd: checked }, resp => { // 修复参数名称为forceHpd
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to set force EDID output: ${resp.error.data || "Unknown error"}`);
|
||||
setForceHpd(!checked);
|
||||
return;
|
||||
}
|
||||
|
||||
notifications.success(`Force EDID output ${checked ? "enabled" : "disabled"}`);
|
||||
setForceHpd(checked);
|
||||
});
|
||||
};
|
||||
|
||||
const handleStreamQualityChange = (factor: string) => {
|
||||
send("setStreamQualityFactor", { factor: Number(factor) }, resp => {
|
||||
@@ -205,6 +231,17 @@ export default function SettingsVideoRoute() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EDID Force Output Setting */}
|
||||
<SettingsItem
|
||||
title={$at("Force EDID Output")}
|
||||
description={$at("Force EDID output even when no display is connected")}
|
||||
>
|
||||
<Checkbox
|
||||
checked={forceHpd}
|
||||
onChange={e => handleForceHpdChange(e.target.checked)}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem
|
||||
title="EDID"
|
||||
description={$at("Adjust the EDID settings for the display")}
|
||||
|
||||
@@ -566,12 +566,13 @@ export default function KvmIdRoute() {
|
||||
const setNetworkState = useNetworkStateStore(state => state.setNetworkState);
|
||||
|
||||
const setUsbState = useHidStore(state => state.setUsbState);
|
||||
const usbState = useHidStore(state => state.usbState);
|
||||
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 setIsReinitializingGadget = useHidStore(state => state.setIsReinitializingGadget);
|
||||
|
||||
const [hasUpdated, setHasUpdated] = useState(false);
|
||||
const { navigateTo } = useDeviceUiNavigation();
|
||||
@@ -601,6 +602,42 @@ export default function KvmIdRoute() {
|
||||
setKeyboardLedStateSyncAvailable(true);
|
||||
}
|
||||
|
||||
if (resp.method === "hidDeviceMissing") {
|
||||
const params = resp.params as { device: string; error: string };
|
||||
console.error("HID device missing:", params);
|
||||
|
||||
send("getUsbEmulationState", {}, stateResp => {
|
||||
if ("error" in stateResp) return;
|
||||
const emuEnabled = stateResp.result as boolean;
|
||||
if (!emuEnabled || usbState !== "configured") {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsReinitializingGadget(true);
|
||||
|
||||
notifications.error(
|
||||
`USB HID device (${params.device}) is missing. Reinitializing USB gadget...`,
|
||||
{ duration: 5000 }
|
||||
);
|
||||
|
||||
send("reinitializeUsbGadgetSoft", {}, (resp) => {
|
||||
setIsReinitializingGadget(false);
|
||||
|
||||
if ("error" in resp) {
|
||||
notifications.error(
|
||||
`Failed to reinitialize USB gadget (soft): ${resp.error.message}`,
|
||||
{ duration: 5000 }
|
||||
);
|
||||
} else {
|
||||
notifications.success(
|
||||
"USB gadget soft reinitialized successfully",
|
||||
{ duration: 3000 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (resp.method === "otaState") {
|
||||
const otaState = resp.params as UpdateState["otaState"];
|
||||
setOtaState(otaState);
|
||||
@@ -624,6 +661,12 @@ export default function KvmIdRoute() {
|
||||
window.location.href = currentUrl.toString();
|
||||
}
|
||||
}
|
||||
|
||||
if (resp.method === "refreshPage") {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
currentUrl.searchParams.set("networkChanged", "true");
|
||||
window.location.href = currentUrl.toString();
|
||||
}
|
||||
}
|
||||
|
||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
||||
|
||||
Reference in New Issue
Block a user