Update App version to 0.0.2

This commit is contained in:
luckfox-eng29
2025-09-16 11:03:46 +08:00
parent 8fbd6bcf0d
commit 15d276652c
45 changed files with 3347 additions and 252 deletions

BIN
ui/src/assets/tailscale.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 B

BIN
ui/src/assets/zerotier.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -11,6 +11,7 @@ import {
useMountMediaStore,
useSettingsStore,
useUiStore,
useAudioModeStore,
} from "@/hooks/stores";
import Container from "@components/Container";
import { cx } from "@/cva.config";
@@ -41,10 +42,10 @@ export default function Actionbar({
const developerMode = useSettingsStore(state => state.developerMode);
// Audio related
const [audioMode, setAudioMode] = useState("disabled");
const [send] = useJsonRpc();
const audioMode = useAudioModeStore(state => state.audioMode);
const setAudioMode = useAudioModeStore(state => state.setAudioMode);
// This is the only way to get a reliable state change for the popover
// at time of writing this there is no mount, or unmount event for the popover
const isOpen = useRef<boolean>(false);
@@ -126,11 +127,11 @@ export default function Actionbar({
return (
<>
<LuHardDrive className={className} />
<div
{/*<div
className={cx(className, "h-2 w-2 rounded-full bg-blue-700", {
hidden: !remoteVirtualMediaState,
})}
/>
/>*/}
</>
);
}}

View File

@@ -0,0 +1,190 @@
import {
CheckCircleIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
} from "@heroicons/react/24/outline";
import { Button } from "@/components/Button";
import Modal from "@/components/Modal";
import { cx } from "@/cva.config";
type Variant = "danger" | "success" | "warning" | "info";
interface LogDialogProps {
open: boolean;
onClose: () => void;
title: string;
description: string;
variant?: Variant;
cancelText?: string | null;
}
const variantConfig = {
danger: {
icon: ExclamationTriangleIcon,
iconClass: "text-red-599",
iconBgClass: "bg-red-99",
buttonTheme: "danger",
},
success: {
icon: CheckCircleIcon,
iconClass: "text-green-599",
iconBgClass: "bg-green-99",
buttonTheme: "primary",
},
warning: {
icon: ExclamationTriangleIcon,
iconClass: "text-yellow-599",
iconBgClass: "bg-yellow-99",
buttonTheme: "lightDanger",
},
info: {
icon: InformationCircleIcon,
iconClass: "text-blue-599",
iconBgClass: "bg-blue-99",
buttonTheme: "blank",
},
} as Record<
Variant,
{
icon: React.ElementType;
iconClass: string;
iconBgClass: string;
buttonTheme: "danger" | "primary" | "blank" | "light" | "lightDanger";
}
>;
const COLOR_MAP: Record<string, string | undefined> = {
'30': '#000', '31': '#d32f2f', '32': '#388e3c', '33': '#f57c00',
'34': '#1976d2', '35': '#7b1fa2', '36': '#0097a7', '37': '#424242',
'90': '#757575', '91': '#f44336', '92': '#4caf50', '93': '#ff9800',
'94': '#2196f3', '95': '#9c27b0', '96': '#00bcd4', '97': '#fafafa',
};
interface AnsiProps {
children: string;
className?: string;
}
export default function Ansi({ children, className }: AnsiProps) {
let curColor: string | undefined;
let curBold = false;
const lines: { text: string; style: (React.CSSProperties | undefined)[] }[] = [];
let col = 0;
const applyCode = (code: number) => {
if (code === 0) { curColor = undefined; curBold = false; }
else if (code === 1) curBold = true;
else if (code >= 30 && code <= 37) curColor = COLOR_MAP[code];
else if (code >= 90 && code <= 97) curColor = COLOR_MAP[code];
};
const styleKey = () => `${curColor || ''}|${curBold ? 1 : 0}`;
const stylePool: Record<string, React.CSSProperties> = {};
const getStyle = (): React.CSSProperties | undefined => {
const key = styleKey();
if (!key) return undefined;
if (!stylePool[key]) {
stylePool[key] = {
...(curColor ? { color: curColor } : {}),
...(curBold ? { fontWeight: 'bold' } : {}),
};
}
return stylePool[key];
};
const tokens = children.split(/(\x1b\[[0-9;]*m|\r\n?|\n)/g);
let currentLine = { text: '', style: [] as (React.CSSProperties | undefined)[] };
for (const chunk of tokens) {
if (chunk.startsWith('\x1b[') && chunk.endsWith('m')) {
const codes = chunk.slice(2, -1).split(';').map(Number);
codes.forEach(applyCode);
} else if (chunk === '\r\n' || chunk === '\n') {
if (currentLine.text) lines.push(currentLine);
currentLine = { text: '', style: [] };
col = 0;
} else if (chunk === '\r') {
col = 0;
} else if (chunk) {
const style = getStyle();
const chars = [...chunk]; // 正确识别 Unicode 码点
for (const ch of chars) {
if (col < currentLine.text.length) {
currentLine.text =
currentLine.text.slice(0, col) +
ch +
currentLine.text.slice(col + 1);
currentLine.style[col] = style;
} else {
currentLine.text += ch;
currentLine.style[col] = style;
}
col++;
}
}
}
if (currentLine.text) lines.push(currentLine);
return (
<span className={className}>
{lines.map((ln, idx) => (
<div key={idx}>
{[...ln.text].map((ch, i) => (
<span key={i} style={ln.style[i]}>
{ch}
</span>
))}
</div>
))}
</span>
);
}
export function LogDialog({
open,
onClose,
title,
description,
variant = "info",
cancelText = "Cancel",
}: LogDialogProps) {
const { icon: Icon, iconClass, iconBgClass } = variantConfig[variant];
return (
<Modal open={open} onClose={onClose}>
<div className="mx-auto max-w-xl px-3 transition-all duration-300 ease-in-out">
<div className="pointer-events-auto relative w-full overflow-hidden rounded-lg bg-white p-5 text-left align-middle shadow-xl transition-all dark:bg-slate-800">
<div className="space-y-3">
<div className="sm:flex sm:items-start">
<div
className={cx(
"mx-auto flex size-11 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:size-10",
iconBgClass,
)}
>
<Icon aria-hidden="true" className={cx("size-5", iconClass)} />
</div>
<div className="mt-2 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-tight font-bold text-black dark:text-white">
{title}
</h3>
<div className="mt-2 text-sm leading-snug text-slate-600 dark:text-slate-400">
<Ansi>{description}</Ansi>
</div>
</div>
</div>
<div className="flex justify-end gap-x-1">
{cancelText && (
<Button size="SM" theme="blank" text={cancelText} onClick={onClose} />
)}
</div>
</div>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,196 @@
import { useCallback , useEffect, useState } from "react";
import { useJsonRpc } from "../hooks/useJsonRpc";
import notifications from "../notifications";
import { SettingsItem } from "../routes/devices.$id.settings";
import Checkbox from "./Checkbox";
import { Button } from "./Button";
import { SelectMenuBasic } from "./SelectMenuBasic";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
import Fieldset from "./Fieldset";
import { useUsbEpModeStore, useAudioModeStore } from "../hooks/stores";
export interface UsbDeviceConfig {
keyboard: boolean;
absolute_mouse: boolean;
relative_mouse: boolean;
mass_storage: boolean;
mtp: boolean;
audio: boolean;
}
const defaultUsbDeviceConfig: UsbDeviceConfig = {
keyboard: true,
absolute_mouse: true,
relative_mouse: true,
mass_storage: true,
mtp: false,
audio: true,
};
const usbEpOptions = [
{ value: "uac", label: "USB Audio Card"},
{ value: "mtp", label: "Media Transfer Protocol"},
{ value: "disabled", label: "Disabled"},
]
const audioModeOptions = [
{ value: "disabled", label: "Disabled"},
{ value: "usb", label: "USB"},
//{ value: "hdmi", label: "HDMI"},
]
export function UsbEpModeSetting() {
const usbEpMode = useUsbEpModeStore(state => state.usbEpMode)
const setUsbEpMode = useUsbEpModeStore(state => state.setUsbEpMode)
const audioMode = useAudioModeStore(state => state.audioMode);
const setAudioMode = useAudioModeStore(state => state.setAudioMode);
const [send] = useJsonRpc();
const [loading, setLoading] = useState(false);
const [usbDeviceConfig, setUsbDeviceConfig] =
useState<UsbDeviceConfig>(defaultUsbDeviceConfig);
const syncUsbDeviceConfig = useCallback(() => {
send("getUsbDevices", {}, resp => {
if ("error" in resp) {
console.error("Failed to load USB devices:", resp.error);
notifications.error(
`Failed to load USB devices: ${resp.error.data || "Unknown error"}`,
);
} else {
const usbConfigState = resp.result as UsbDeviceConfig;
setUsbDeviceConfig(usbConfigState);
if (usbConfigState.mtp && !usbConfigState.audio) {
setUsbEpMode("mtp");
} else if (usbConfigState.audio && !usbConfigState.mtp) {
setUsbEpMode("uac");
} else {
setUsbEpMode("disabled");
}
}
});
}, [send]);
const handleUsbConfigChange = useCallback(
(devices: UsbDeviceConfig) => {
setLoading(true);
send("setUsbDevices", { devices }, async resp => {
if ("error" in resp) {
notifications.error(
`Failed to set usb devices: ${resp.error.data || "Unknown error"}`,
);
setLoading(false);
return;
}
// We need some time to ensure the USB devices are updated
await new Promise(resolve => setTimeout(resolve, 2000));
setLoading(false);
syncUsbDeviceConfig();
notifications.success(`USB Devices updated`);
});
},
[send, syncUsbDeviceConfig],
);
const handleAudioModeChange = (mode: string) => {
send("setAudioMode", { mode }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set Audio Mode: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success(`Audio Mode set to ${mode}.It takes effect after refreshing the page`);
setAudioMode(mode);
});
};
const handleUsbEpModeChange = useCallback(
async (e: React.ChangeEvent<HTMLSelectElement>) => {
const newMode = e.target.value;
setUsbEpMode(newMode);
if (newMode === "uac") {
handleUsbConfigChange({
...usbDeviceConfig,
audio: true,
mtp: false,
})
setUsbEpMode("uac");
} else if (newMode === "mtp") {
handleUsbConfigChange({
...usbDeviceConfig,
audio: false,
mtp: true,
})
handleAudioModeChange("disabled");
setUsbEpMode("mtp");
} else {
handleUsbConfigChange({
...usbDeviceConfig,
audio: false,
mtp: false,
})
handleAudioModeChange("disabled");
setUsbEpMode("disabled");
}
},
[handleUsbConfigChange, usbDeviceConfig],
);
useEffect(() => {
syncUsbDeviceConfig();
send("getAudioMode", {}, resp => {
if ("error" in resp) return;
setAudioMode(String(resp.result));
});
}, [syncUsbDeviceConfig]);
return (
<Fieldset disabled={loading} className="space-y-4">
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
<SettingsItem
loading={loading}
title="USB Other Function"
description="Select the active USB function (MTP or UAC)"
>
<SelectMenuBasic
size="SM"
label=""
value={usbEpMode}
fullWidth
onChange={handleUsbEpModeChange}
options={usbEpOptions}
/>
</SettingsItem>
{usbEpMode === "uac" && (
<SettingsItem
loading={loading}
title="Audio Mode"
badge="Experimental"
description="Set the working mode of the audio"
>
<SelectMenuBasic
size="SM"
label=""
value={audioMode}
options={audioModeOptions}
onChange={e => handleAudioModeChange(e.target.value)}
/>
</SettingsItem>
)}
</Fieldset>
);
}

View File

@@ -1,13 +1,16 @@
import React from "react";
import { useCallback } from "react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid";
import { motion, AnimatePresence } from "framer-motion";
import { LuPlay } from "react-icons/lu";
import { LuPlay, LuView } from "react-icons/lu";
import { BsMouseFill } from "react-icons/bs";
import { Button, LinkButton } from "@components/Button";
import LoadingSpinner from "@components/LoadingSpinner";
import Card, { GridCard } from "@components/Card";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";
interface OverlayContentProps {
readonly children: React.ReactNode;
@@ -215,6 +218,18 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
const isNoSignal = hdmiState === "no_signal";
const isOtherError = hdmiState === "no_lock" || hdmiState === "out_of_range";
const [send] = useJsonRpc();
const onSendUsbWakeupSignal = useCallback(() => {
send("sendUsbWakeupSignal", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to send USB wakeup signal: ${resp.error.data || "Unknown error"}`,
);
return;
}
});
}, [send]);
return (
<>
<AnimatePresence>
@@ -245,8 +260,20 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
If using an adapter, ensure it&apos;s compatible and functioning
correctly
</li>
<li>
Ensure source device is not in sleep mode and outputting a signal
</li>
</ul>
</div>
<div>
<Button
theme="light"
text="Try Wakeup"
TrailingIcon={LuView}
size="SM"
onClick={onSendUsbWakeupSignal}
/>
</div>
<div>
<LinkButton
to={"https://wiki.luckfox.com/intro"}

View File

@@ -1,5 +1,8 @@
import StatusCard from "@components/StatusCards";
import TailscaleIcon from "@/assets/tailscale.png";
import ZeroTierIcon from "@/assets/zerotier.png";
const VpnConnectionStatusMap = {
connected: "Connected",
connecting: "Connecting",
@@ -39,10 +42,28 @@ export default function VpnConnectionStatusCard({
},
closed: {
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
},
};
const props = StatusCardProps[state];
if (!props) return;
const Icon = () => {
if (title === "ZeroTier") {
return (
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-gray-300 dark:bg-gray-800">
<img src={ZeroTierIcon} alt="zerotier" className="h-4 w-4" />
</span>
);
}
if (title === "TailScale") {
return (
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-gray-800 dark:bg-gray-800">
<img src={TailscaleIcon} alt="tailscale" className="h-4 w-4" />
</span>
);
}
return null;
};
return (
<StatusCard

View File

@@ -1,12 +1,14 @@
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import { PlusCircleIcon } from "@heroicons/react/20/solid";
import { useMemo, forwardRef, useEffect, useCallback } from "react";
import { useMemo, forwardRef, useEffect, useCallback, useState } from "react";
import {
LuArrowUpFromLine,
LuCheckCheck,
LuLink,
LuPlus,
LuRadioReceiver,
LuFileBadge,
LuFlagOff,
} from "react-icons/lu";
import { useClose } from "@headlessui/react";
import { useLocation } from "react-router-dom";
@@ -14,11 +16,14 @@ import { useLocation } from "react-router-dom";
import { Button } from "@components/Button";
import Card, { GridCard } from "@components/Card";
import { formatters } from "@/utils";
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores";
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore, useUsbEpModeStore } from "@/hooks/stores";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import notifications from "@/notifications";
import { SelectMenuBasic } from "../SelectMenuBasic";
import { SettingsItem } from "../../routes/devices.$id.settings";
import { UsbDeviceConfig } from "@components/UsbEpModeSetting";
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);
@@ -26,6 +31,29 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const { remoteVirtualMediaState, setModalView, setRemoteVirtualMediaState } =
useMountMediaStore();
const usbEpMode = useUsbEpModeStore(state => state.usbEpMode)
const setUsbEpMode = useUsbEpModeStore(state => state.setUsbEpMode)
const [usbStorageMode, setUsbStorageMode] = useState("ums");
const usbStorageModeOptions = [
{ value: "ums", label: "USB Mass Storage"},
{ value: "mtp", label: "MTP"},
]
const getUsbEpMode = useCallback(() => {
send("getUsbDevices", {}, resp => {
if ("error" in resp) {
console.error("Failed to load USB devices:", resp.error);
notifications.error(
`Failed to load USB devices: ${resp.error.data || "Unknown error"}`,
);
} else {
const usbConfigState = resp.result as UsbDeviceConfig;
setUsbEpMode(usbConfigState.mtp ? "mtp" : "uac");
}
});
}, [send])
const bytesSentPerSecond = useMemo(() => {
if (diskDataChannelStats.size < 2) return null;
@@ -68,6 +96,10 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
});
};
const handleUsbStorageModeChange = (value: string) => {
setUsbStorageMode(value);
}
const renderGridCardContent = () => {
if (!remoteVirtualMediaState) {
return (
@@ -187,7 +219,8 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
useEffect(() => {
syncRemoteVirtualMediaState();
}, [syncRemoteVirtualMediaState, location.pathname]);
getUsbEpMode();
}, [syncRemoteVirtualMediaState, location.pathname, getUsbEpMode]);
const { navigateTo } = useDeviceUiNavigation();
@@ -202,6 +235,21 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
description="Mount an image to boot from or install an operating system."
/>
<SettingsItem
title="USB Storage Mode"
description=""
>
<SelectMenuBasic
size="SM"
label=""
value={usbStorageMode}
fullWidth
onChange={(e) => handleUsbStorageModeChange(e.target.value)}
options={usbStorageModeOptions}
/>
</SettingsItem>
{remoteVirtualMediaState?.source === "WebRTC" ? (
<Card>
<div className="flex items-center gap-x-1.5 px-2.5 py-2 text-sm">
@@ -213,81 +261,119 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
</Card>
) : null}
<div
className="animate-fadeIn opacity-0 space-y-2"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
}}
>
<div className="block select-none">
<div className="group">
<Card>
<div className="w-full px-4 py-8">
<div className="flex h-full flex-col items-center justify-center text-center">
{renderGridCardContent()}
{usbStorageMode === "ums" && (
<div
className="animate-fadeIn opacity-0 space-y-2"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
}}
>
<div className="block select-none">
<div className="group">
<Card>
<div className="w-full px-4 py-8">
<div className="flex h-full flex-col items-center justify-center text-center">
{renderGridCardContent()}
</div>
</div>
</Card>
</div>
</div>
{remoteVirtualMediaState ? (
<div className="flex select-none items-center justify-between text-xs">
<div className="select-none text-white dark:text-slate-300">
<span>Mounted as</span>{" "}
<span className="font-semibold">
{remoteVirtualMediaState.mode === "Disk" ? "Disk" : "CD-ROM"}
</span>
</div>
</Card>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="blank"
text="Close"
onClick={() => {
close();
}}
/>
<Button
size="SM"
theme="light"
text="Unmount"
LeadingIcon={({ className }) => (
<svg
className={`${className} h-2.5 w-2.5 shrink-0`}
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_3137_1186)">
<path
d="M4.99933 0.775635L0 5.77546H10L4.99933 0.775635Z"
fill="currentColor"
/>
<path
d="M10 7.49976H0V9.22453H10V7.49976Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_3137_1186">
<rect width="10" height="10" fill="white" />
</clipPath>
</defs>
</svg>
)}
onClick={handleUnmount}
/>
</div>
</div>
) : null}
</div>
)}
{usbStorageMode === "mtp" && usbEpMode !== "mtp" && (
<div
className="animate-fadeIn opacity-0 space-y-2"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
}}
>
<div className="block select-none">
<div className="group">
<Card>
<div className="w-full px-4 py-8">
<div className="flex h-full flex-col items-center justify-center text-center">
<div className="space-y-1">
<div className="inline-block">
<Card>
<div className="p-1">
<LuFlagOff className="h-4 w-4 shrink-0 text-blue-700 dark:text-white" />
</div>
</Card>
</div>
<div className="space-y-1">
<h3 className="text-sm font-semibold leading-none text-black dark:text-white">
The MTP function has not been activated.
</h3>
</div>
</div>
</div>
</div>
</Card>
</div>
</div>
</div>
{remoteVirtualMediaState ? (
<div className="flex select-none items-center justify-between text-xs">
<div className="select-none text-white dark:text-slate-300">
<span>Mounted as</span>{" "}
<span className="font-semibold">
{remoteVirtualMediaState.mode === "Disk" ? "Disk" : "CD-ROM"}
</span>
</div>
)}
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="blank"
text="Close"
onClick={() => {
close();
}}
/>
<Button
size="SM"
theme="light"
text="Unmount"
LeadingIcon={({ className }) => (
<svg
className={`${className} h-2.5 w-2.5 shrink-0`}
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_3137_1186)">
<path
d="M4.99933 0.775635L0 5.77546H10L4.99933 0.775635Z"
fill="currentColor"
/>
<path
d="M10 7.49976H0V9.22453H10V7.49976Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_3137_1186">
<rect width="10" height="10" fill="white" />
</clipPath>
</defs>
</svg>
)}
onClick={handleUnmount}
/>
</div>
</div>
) : null}
</div>
</div>
</div>
</div>
{!remoteVirtualMediaState && (
{!remoteVirtualMediaState && usbStorageMode === "ums" && (
<div
className="flex animate-fadeIn opacity-0 items-center justify-end space-x-2"
style={{
@@ -315,6 +401,36 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
/>
</div>
)}
{usbStorageMode === "mtp" && usbEpMode === "mtp" && (
<div
className="flex animate-fadeIn opacity-0 items-center justify-end space-x-2"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button
size="SM"
theme="blank"
text="Close"
onClick={() => {
close();
}}
/>
<Button
size="SM"
theme="primary"
text="Manager"
onClick={() => {
setModalView("mode");
navigateTo("/mtp");
}}
LeadingIcon={LuFileBadge}
/>
</div>
)}
</div>
</GridCard>
);

View File

@@ -434,7 +434,7 @@ export interface MountMediaState {
remoteVirtualMediaState: RemoteVirtualMediaState | null;
setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => void;
modalView: "mode" | "browser" | "url" | "device" | "sd" | "upload" | "upload_sd" | "error" | null;
modalView: "mode" | "browser" | "url" | "device" | "sd" | "upload" | "upload_sd" | "error" | "mtp_device" | "mtp_sd" | null;
setModalView: (view: MountMediaState["modalView"]) => void;
isMountMediaDialogOpen: boolean;
@@ -567,6 +567,26 @@ export const useHidStore = create<HidState>((set, get) => ({
setUsbState: state => set({ usbState: state }),
}));
export interface UsbEpModeStore {
usbEpMode: string;
setUsbEpMode: (mode: string) => void;
}
export const useUsbEpModeStore = create<UsbEpModeStore>(set => ({
usbEpMode: "disabled",
setUsbEpMode: (mode: string) => set({ usbEpMode: mode }),
}));
export interface AudioModeStore {
audioMode: string;
setAudioMode: (mode: string) => void;
}
export const useAudioModeStore = create<AudioModeStore>(set => ({
audioMode: "disabled",
setAudioMode: (mode: string) => set({ audioMode: mode }),
}));
export const useUserStore = create<UserState>(set => ({
user: null,
setUser: user => set({ user }),

View File

@@ -31,6 +31,7 @@ import WelcomeLocalPasswordRoute from "./routes/welcome-local.password";
import { DEVICE_API } from "./ui.config";
import OtherSessionRoute from "./routes/devices.$id.other-session";
import MountRoute from "./routes/devices.$id.mount";
import MtpRoute from "./routes/devices.$id.mtp";
import * as SettingsRoute from "./routes/devices.$id.settings";
import SettingsMouseRoute from "./routes/devices.$id.settings.mouse";
import SettingsKeyboardRoute from "./routes/devices.$id.settings.keyboard";
@@ -109,6 +110,10 @@ export async function checkAuth() {
path: "mount",
element: <MountRoute />,
},
{
path: "mtp",
element: <MtpRoute />,
},
{
path: "settings",
element: <SettingsRoute.default />,

View File

@@ -109,6 +109,11 @@ export function Dialog({ onClose }: { onClose: () => void }) {
function handleStorageMount(fileName: string, mode: RemoteVirtualMediaState["mode"]) {
console.log(`Mounting ${fileName} as ${mode}`);
if (!fileName.endsWith(".iso") && !fileName.endsWith(".img")) {
triggerError("Only ISO and IMG files are supported");
return;
}
setMountInProgress(true);
send("mountWithStorage", { filename: fileName, mode }, async resp => {
if ("error" in resp) triggerError(resp.error.message);
@@ -136,6 +141,11 @@ export function Dialog({ onClose }: { onClose: () => void }) {
function handleSDStorageMount(fileName: string, mode: RemoteVirtualMediaState["mode"]) {
console.log(`Mounting ${fileName} as ${mode}`);
if (!fileName.endsWith(".iso") && !fileName.endsWith(".img")) {
triggerError("Only ISO and IMG files are supported");
return;
}
setMountInProgress(true);
send("mountWithSDStorage", { filename: fileName, mode }, async resp => {
if ("error" in resp) triggerError(resp.error.message);
@@ -407,7 +417,7 @@ function ModeSelectionView({
<div
className="relative z-50 flex flex-col items-start p-4 select-none"
onClick={() =>
disabled ? null : setSelectedMode(mode as "browser" | "url" | "device")
disabled ? null : setSelectedMode(mode as "browser" | "url" | "device" | "sd")
}
>
<div>
@@ -1069,6 +1079,7 @@ function SDFileView({
const [selected, setSelected] = useState<string | null>(null);
const [usbMode, setUsbMode] = useState<RemoteVirtualMediaState["mode"]>("CDROM");
const [currentPage, setCurrentPage] = useState(1);
const [loading, setLoading] = useState(false);
const filesPerPage = 5;
const [send] = useJsonRpc();
@@ -1195,17 +1206,37 @@ function SDFileView({
const handleNextPage = () => {
setCurrentPage(prev => Math.min(prev + 1, totalPages));
};
function handleResetSDStorage() {
async function handleResetSDStorage() {
setLoading(true);
send("resetSDStorage", {}, res => {
console.log("Reset SD storage response:", res);
if ("error" in res) {
notifications.error(`Failed to reset SD card`);
setLoading(false);
return;
}
});
});
await new Promise(resolve => setTimeout(resolve, 2000));
setLoading(false);
syncStorage();
}
async function handleUnmountSDStorage() {
setLoading(true);
send("unmountSDStorage", {}, res => {
console.log("Unmount SD response:", res);
if ("error" in res) {
notifications.error(`Failed to unmount SD card`);
setLoading(false);
return;
}
});
await new Promise(resolve => setTimeout(resolve, 2000));
setLoading(false);
syncStorage();
}
if (sdMountStatus && sdMountStatus !== "ok") {
return (
@@ -1223,6 +1254,7 @@ function SDFileView({
: "SD card mount failed"}
<Button
size="XS"
disabled={loading}
theme="light"
LeadingIcon={LuRefreshCw}
onClick={handleResetSDStorage}
@@ -1268,6 +1300,7 @@ function SDFileView({
<div>
<Button
size="SM"
disabled={loading}
theme="primary"
text="Upload a new image"
onClick={() => onNewImageClick()}
@@ -1312,14 +1345,14 @@ function SDFileView({
theme="light"
text="Previous"
onClick={handlePreviousPage}
disabled={currentPage === 1}
disabled={currentPage === 1 || loading}
/>
<Button
size="XS"
theme="light"
text="Next"
onClick={handleNextPage}
disabled={currentPage === totalPages}
disabled={currentPage === totalPages || loading}
/>
</div>
</div>
@@ -1344,7 +1377,7 @@ function SDFileView({
<Button size="MD" theme="blank" text="Back" onClick={() => onBack()} />
<Button
size="MD"
disabled={selected === null || mountInProgress}
disabled={selected === null || mountInProgress || loading}
theme="primary"
text="Mount File"
loading={mountInProgress}
@@ -1412,6 +1445,7 @@ function SDFileView({
>
<Button
size="MD"
disabled={loading}
theme="light"
fullWidth
text="Upload a new image"
@@ -1419,6 +1453,24 @@ function SDFileView({
/>
</div>
)}
<div
className="w-full animate-fadeIn opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.25s",
}}
>
<Button
size="MD"
disabled={loading}
theme="light"
fullWidth
text="Unmount SD Card"
onClick={() => handleUnmountSDStorage()}
className="text-red-500 dark:text-red-400"
/>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,8 @@ import { CloudState } from "./adopt";
import { useVpnStore } from "@/hooks/stores";
import Checkbox from "../components/Checkbox";
import { LogDialog } from "../components/LogDialog";
export interface TailScaleResponse {
state: string;
loginUrl: string;
@@ -35,6 +37,11 @@ export interface ZeroTierResponse {
ip: string;
}
export interface FrpcResponse {
running: boolean;
}
export interface TLSState {
mode: "self-signed" | "custom" | "disabled";
certificate?: string;
@@ -83,6 +90,11 @@ export default function SettingsAccessIndexRoute() {
const [tempNetworkID, setTempNetworkID] = useState("");
const [isDisconnecting, setIsDisconnecting] = useState(false);
const [frpcToml, setFrpcToml] = useState<string>("");
const [frpcLog, setFrpcLog] = useState<string>("");
const [showFrpcLogModal, setShowFrpcLogModal] = useState(false);
const [frpcStatus, setFrpcRunningStatus] = useState<FrpcResponse>({ running: false });
const getTLSState = useCallback(() => {
send("getTLSState", {}, resp => {
@@ -262,6 +274,77 @@ export default function SettingsAccessIndexRoute() {
});
},[send, zeroTierNetworkID]);
const handleStartFrpc = useCallback(() => {
send("startFrpc", { frpcToml }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to start frpc: ${resp.error.data || "Unknown error"}`,
);
setFrpcRunningStatus({ running: false });
return;
}
notifications.success("frpc started");
setFrpcRunningStatus({ running: true });
});
}, [send, frpcToml]);
const handleStopFrpc = useCallback(() => {
send("stopFrpc", { frpcToml }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to stop frpc: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("frpc stopped");
setFrpcRunningStatus({ running: false });
});
}, [send]);
const handleGetFrpcLog = useCallback(() => {
send("getFrpcLog", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to get frpc log: ${resp.error.data || "Unknown error"}`,
);
setFrpcLog("");
return;
}
setFrpcLog(resp.result as string);
setShowFrpcLogModal(true);
});
}, [send]);
const getFrpcToml = useCallback(() => {
send("getFrpcToml", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to get frpc toml: ${resp.error.data || "Unknown error"}`,
);
setFrpcToml("");
return;
}
setFrpcToml(resp.result as string);
});
}, [send]);
const getFrpcStatus = useCallback(() => {
send("getFrpcStatus", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to get frpc status: ${resp.error.data || "Unknown error"}`,
);
return;
}
setFrpcRunningStatus(resp.result as FrpcResponse);
});
}, [send]);
useEffect(() => {
getFrpcStatus();
getFrpcToml();
}, [getFrpcStatus, getFrpcToml]);
return (
<div className="space-y-4">
<SettingsPageHeader
@@ -399,16 +482,6 @@ export default function SettingsAccessIndexRoute() {
badge="Experimental"
description="Connect to TailScale VPN network"
>
<div className="space-y-4">
{ ((tailScaleConnectionState === "disconnected") || (tailScaleConnectionState === "closed")) && (
<Button
size="SM"
theme="light"
text="Enable TailScale"
onClick={handleTailScaleLogin}
/>
)}
</div>
</SettingsItem>
<SettingsItem
title=""
@@ -425,6 +498,21 @@ export default function SettingsAccessIndexRoute() {
}}
/>
</SettingsItem>
<SettingsItem
title=""
description=""
>
<div className="space-y-4">
{ ((tailScaleConnectionState === "disconnected") || (tailScaleConnectionState === "closed")) && (
<Button
size="SM"
theme="light"
text="Enable"
onClick={handleTailScaleLogin}
/>
)}
</div>
</SettingsItem>
</div>
<div className="space-y-4">
@@ -558,7 +646,58 @@ export default function SettingsAccessIndexRoute() {
)}
</div>
<div className="space-y-4">
<SettingsItem
title="Frp"
description="Connect to Frp Server"
/>
<div className="space-y-4">
<TextAreaWithLabel
label="Edit frpc.toml"
placeholder="Enter frpc settings"
value={frpcToml || ""}
rows={3}
onChange={e => setFrpcToml(e.target.value)}
/>
<div className="flex items-center gap-x-2">
{frpcStatus.running ? (
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="danger"
text="Stop frpc"
onClick={handleStopFrpc}
/>
<Button
size="SM"
theme="light"
text="Log"
onClick={handleGetFrpcLog}
/>
</div>
) : (
<Button
size="SM"
theme="primary"
text="Start frpc"
onClick={handleStartFrpc}
/>
)}
</div>
</div>
</div>
</div>
<LogDialog
open={showFrpcLogModal}
onClose={() => {
setShowFrpcLogModal(false);
}}
title="Frpc Log"
description={frpcLog}
/>
</div>
);
}

View File

@@ -5,13 +5,11 @@ import { SettingsItem } from "@routes/devices.$id.settings";
import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
import { InputField } from "@/components/InputField";
import { Button, LinkButton } from "@/components/Button";
import notifications from "../notifications";
import { UsbInfoSetting } from "../components/UsbInfoSetting";
import { FeatureFlag } from "../components/FeatureFlag";
import { UsbEpModeSetting } from "@components/UsbEpModeSetting";
export default function SettingsHardwareRoute() {
const [send] = useJsonRpc();
@@ -333,13 +331,9 @@ export default function SettingsHardwareRoute() {
</div>
<FeatureFlag minAppVersion="0.3.8">
<UsbDeviceSetting />
</FeatureFlag>
<FeatureFlag minAppVersion="0.3.8">
<UsbInfoSetting />
</FeatureFlag>
<UsbEpModeSetting />
{/*<UsbDeviceSetting /> */}
{/*<UsbInfoSetting /> */}
</div>
);
}

View File

@@ -40,16 +40,9 @@ const streamQualityOptions = [
{ value: "0.1", label: "Low" },
];
const audioModeOptions = [
{ value: "disabled", label: "Disabled"},
{ value: "usb", label: "USB"},
//{ value: "hdmi", label: "HDMI"},
]
export default function SettingsVideoRoute() {
const [send] = useJsonRpc();
const [streamQuality, setStreamQuality] = useState("1");
const [audioMode, setAudioMode] = useState("disabled");
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
const [edid, setEdid] = useState<string | null>(null);
@@ -62,11 +55,6 @@ export default function SettingsVideoRoute() {
const setVideoContrast = useSettingsStore(state => state.setVideoContrast);
useEffect(() => {
send("getAudioMode", {}, resp => {
if ("error" in resp) return;
setAudioMode(String(resp.result));
});
send("getStreamQualityFactor", {}, resp => {
if ("error" in resp) return;
setStreamQuality(String(resp.result));
@@ -95,21 +83,7 @@ export default function SettingsVideoRoute() {
}
});
}, [send]);
const handleAudioModeChange = (mode: string) => {
send("setAudioMode", { mode }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set Audio Mode: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success(`Audio Mode set to ${audioModeOptions.find(x => x.value === mode )?.label}.It takes effect after refreshing the page`);
setAudioMode(mode);
});
};
const handleStreamQualityChange = (factor: string) => {
send("setStreamQualityFactor", { factor: Number(factor) }, resp => {
if ("error" in resp) {
@@ -149,20 +123,6 @@ export default function SettingsVideoRoute() {
<div className="space-y-4">
<div className="space-y-4">
<SettingsItem
title="Audio Mode"
badge="Experimental"
description="Set the working mode of the audio"
>
<SelectMenuBasic
size="SM"
label=""
value={audioMode}
options={audioModeOptions}
onChange={e => handleAudioModeChange(e.target.value)}
/>
</SettingsItem>
<SettingsItem
title="Stream Quality"
description="Adjust the quality of the video stream"

View File

@@ -233,7 +233,8 @@ export default function KvmIdRoute() {
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const { sendMessage, getWebSocket } = useWebSocket(
`${wsProtocol}//${window.location.host}/webrtc/signaling/client?id=${params.id}`,
//`${wsProtocol}//${window.location.host}/webrtc/signaling/client?id=${params.id}`,
`${wsProtocol}//${window.location.host}/webrtc/signaling/client`,
{
heartbeat: true,
retryOnError: true,
@@ -362,9 +363,18 @@ export default function KvmIdRoute() {
setLoadingMessage("Creating peer connection...");
pc = new RTCPeerConnection({
// We only use STUN or TURN servers if we're in the cloud
...(isInCloud && iceConfig?.iceServers
? { iceServers: [iceConfig?.iceServers] }
: {}),
//...(isInCloud && iceConfig?.iceServers
// ? { iceServers: [iceConfig?.iceServers] }
// : {}),
...(iceConfig?.iceServers
? { iceServers: [iceConfig?.iceServers] }
: {
iceServers: [
{
urls: ['stun:stun.l.google.com:19302']
}
]
}),
});
setPeerConnectionState(pc.connectionState);