release the browser version code

This commit is contained in:
wj-xiao
2025-04-02 17:29:49 +08:00
parent 69bd6d7577
commit 36f87c7d14
64 changed files with 8366 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

9
browser/.editorconfig Normal file
View File

@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

20
browser/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,20 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'prettier',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

27
browser/.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
vite.config.js

1
browser/.prettierignore Normal file
View File

@@ -0,0 +1 @@
*.hbs

27
browser/.prettierrc.yaml Normal file
View File

@@ -0,0 +1,27 @@
singleQuote: true
trailingComma: none
printWidth: 100
tabWidth: 2
bracketSpacing: true
importOrder:
- ^(react/(.*)$)|^(react$)
- ^(next/(.*)$)|^(next$)
- <THIRD_PARTY_MODULES>
- ''
- ^types$
- ^@/(.*)$
- ''
- '^[./]'
importOrderSeparation: false
importOrderSortSpecifiers: true
importOrderBuiltinModulesToTop: true
importOrderParserPlugins:
- typescript
- jsx
- tsx
- decorators-legacy
importOrderMergeDuplicateImports: true
importOrderCombineTypeAndValueImports: true
plugins:
- '@ianvs/prettier-plugin-sort-imports'
- prettier-plugin-tailwindcss

0
browser/README.md Normal file
View File

13
browser/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/sipeed.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NanoKVM-USB</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

50
browser/package.json Normal file
View File

@@ -0,0 +1,50 @@
{
"name": "nanokvm-usb",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"antd": "^5.22.2",
"clsx": "^2.1.1",
"i18next": "^24.0.5",
"jotai": "^2.10.3",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.1.4",
"react-responsive": "^10.0.0",
"react-simple-keyboard": "^3.8.28",
"vaul": "^1.1.1",
"yocto-queue": "^1.1.1"
},
"devDependencies": {
"@eslint/js": "^9.15.0",
"@ianvs/prettier-plugin-sort-imports": "^4.4.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@typescript-eslint/eslint-plugin": "^8.16.0",
"@typescript-eslint/parser": "^8.16.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.15.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14",
"globals": "^15.12.0",
"postcss": "^8.4.49",
"prettier": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.6.9",
"tailwindcss": "^3.4.15",
"typescript": "~5.6.2",
"typescript-eslint": "^8.15.0",
"vite": "^6.0.1",
"vite-tsconfig-paths": "^5.1.3"
}
}

5196
browser/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

BIN
browser/public/sipeed.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

120
browser/src/App.tsx Normal file
View File

@@ -0,0 +1,120 @@
import { useEffect, useState } from 'react';
import { Alert, Result, Spin } from 'antd';
import clsx from 'clsx';
import { useAtomValue, useSetAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
import { useMediaQuery } from 'react-responsive';
import { DeviceModal } from '@/components/device-modal';
import { Keyboard } from '@/components/keyboard';
import { Menu } from '@/components/menu';
import { Mouse } from '@/components/mouse';
import { VirtualKeyboard } from '@/components/virtual-keyboard';
import { resolutionAtom, serialStateAtom, videoStateAtom } from '@/jotai/device.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { mouseStyleAtom } from '@/jotai/mouse.ts';
import { camera } from '@/libs/camera';
import { device } from '@/libs/device';
import * as storage from '@/libs/storage';
import type { Resolution } from '@/types.ts';
const App = () => {
const { t } = useTranslation();
const isBigScreen = useMediaQuery({ minWidth: 850 });
const mouseStyle = useAtomValue(mouseStyleAtom);
const videoState = useAtomValue(videoStateAtom);
const serialState = useAtomValue(serialStateAtom);
const isKeyboardEnable = useAtomValue(isKeyboardEnableAtom);
const setResolution = useSetAtom(resolutionAtom);
const [isLoading, setIsLoading] = useState(true);
const [isCameraAvailable, setIsCameraAvailable] = useState(false);
useEffect(() => {
const resolution = storage.getVideoResolution();
if (resolution) {
setResolution(resolution);
}
requestMediaPermissions(resolution);
return () => {
camera.close();
device.serialPort.close();
};
}, []);
async function requestMediaPermissions(resolution?: Resolution) {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: resolution?.width || 1920 },
height: { ideal: resolution?.height || 1080 }
},
audio: true
});
stream.getTracks().forEach((track) => track.stop());
setIsCameraAvailable(true);
} catch (err: any) {
console.log('failed to request media permissions: ', err);
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
setIsCameraAvailable(false);
} else {
setIsCameraAvailable(true);
}
}
setIsLoading(false);
}
if (isLoading) {
return <Spin size="large" spinning={isLoading} tip={t('camera.tip')} fullscreen />;
}
if (!isCameraAvailable) {
return (
<Result
status="info"
title={t('camera.denied')}
extra={[<h2 className="text-xl text-white">{t('camera.authorize')}</h2>]}
/>
);
}
return (
<>
<DeviceModal />
{videoState === 'connected' && (
<>
<Menu />
{serialState === 'notSupported' && (
<Alert message={t('serial.notSupported')} type="warning" banner closable />
)}
{serialState === 'connected' && (
<>
<Mouse />
{isKeyboardEnable && <Keyboard />}
</>
)}
</>
)}
<video
id="video"
className={clsx('block select-none', mouseStyle)}
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'scale-down' }}
autoPlay
playsInline
/>
<VirtualKeyboard isBigScreen={isBigScreen} />
</>
);
};
export default App;

View File

@@ -0,0 +1,14 @@
@layer tailwind-base, antd;
@layer tailwind-base {
@tailwind base;
}
@tailwind components;
@tailwind utilities;
html, body {
padding: 0;
margin: 0;
background: #000;
}

View File

@@ -0,0 +1,105 @@
.keyboardContainer {
display: flex;
background-color: #e5e5e5;
justify-content: center;
margin: 0 auto;
border-radius: 0 5px;
}
.simple-keyboard.hg-theme-default {
display: inline-block;
}
.simple-keyboard-main.simple-keyboard {
width: 640px;
min-width: 640px;
background: none;
}
.simple-keyboard-main.simple-keyboard .hg-button {
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}
.simple-keyboard-main.simple-keyboard .hg-row:first-child {
margin-bottom: 10px;
}
.simple-keyboard-arrows.simple-keyboard {
align-self: flex-end;
background: none;
}
.simple-keyboard .hg-button.selectedButton {
background: rgba(5, 25, 70, 0.53);
color: white;
}
.simple-keyboard .hg-button.emptySpace {
pointer-events: none;
background: none;
border: none;
box-shadow: none;
}
.simple-keyboard-arrows .hg-row {
justify-content: center;
}
.simple-keyboard-arrows .hg-button {
width: 50px;
flex-grow: 0;
justify-content: center;
display: flex;
align-items: center;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}
.controlArrows {
display: flex;
align-items: center;
justify-content: space-between;
flex-flow: column;
}
.simple-keyboard-control.simple-keyboard {
background: none;
}
.simple-keyboard-control.simple-keyboard .hg-row:first-child {
margin-bottom: 10px;
}
.simple-keyboard-control .hg-button {
width: 50px;
flex-grow: 0;
justify-content: center;
display: flex;
align-items: center;
font-size: 14px;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}
.hg-button.hg-functionBtn.hg-button-space {
width: 250px;
}
.hg-layout-mac .hg-button.hg-functionBtn.hg-button-space {
width: 350px;
}
.simple-keyboard .hg-highlight {
background: rgb(37 99 235);
border-bottom: 1px solid #2563eb;
box-shadow: 0 1px 3px 0 rgb(37 99 235 / 0.1), 0 1px 2px -1px rgb(37 99 235 / 0.1);
color: white;
}
.simple-keyboard .hg-button.hg-double{
text-align: center;
font-size: 14px;
line-height: 16px;
}
.keyboard-header {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}

View File

@@ -0,0 +1,150 @@
import { useEffect, useState } from 'react';
import { Button, Modal, Select } from 'antd';
import { useAtom, useAtomValue } from 'jotai';
import { useTranslation } from 'react-i18next';
import {
resolutionAtom,
serialStateAtom,
videoDeviceIdAtom,
videoStateAtom
} from '@/jotai/device.ts';
import { camera } from '@/libs/camera';
import { device } from '@/libs/device';
import * as storage from '@/libs/storage';
type MediaDevice = {
value: string;
label: string;
};
export const DeviceModal = () => {
const { t } = useTranslation();
const resolution = useAtomValue(resolutionAtom);
const [videoDeviceId, setVideoDeviceId] = useAtom(videoDeviceIdAtom);
const [videoState, setVideoState] = useAtom(videoStateAtom);
const [serialState, setSerialState] = useAtom(serialStateAtom);
const [isOpen, setIsOpen] = useState(false);
const [devices, setDevices] = useState<MediaDevice[]>([]);
const [errMsg, setErrMsg] = useState('');
useEffect(() => {
getVideoDevices(false);
checkSerial();
}, []);
useEffect(() => {
if (videoState === 'connected') {
if (serialState === 'notSupported' || serialState === 'connected') {
setIsOpen(false);
return;
}
}
setIsOpen(true);
}, [videoState, serialState]);
// get video input devices
async function getVideoDevices(autoOpen: boolean) {
const allDevices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = allDevices.filter((device) => device.kind === 'videoinput');
setDevices(videoDevices.map((device) => ({ value: device.deviceId, label: device.label })));
if (autoOpen) {
const deviceId = storage.getVideoDevice();
if (deviceId && videoDevices.some((device) => device.deviceId === deviceId)) {
await selectVideo(deviceId);
}
}
}
// select video input device
async function selectVideo(deviceId: string) {
if (videoState === 'connecting') return;
if (!deviceId) {
setVideoDeviceId('');
return;
}
setVideoState('connecting');
setErrMsg('');
try {
const success = await camera.open(deviceId, resolution.width, resolution.height);
if (!success) return;
const video = document.getElementById('video') as HTMLVideoElement;
if (!video) return;
video.srcObject = camera.getStream();
setVideoState('connected');
setVideoDeviceId(deviceId);
storage.setVideoDevice(deviceId);
} catch (err) {
console.log(err);
setSerialState('disconnected');
setErrMsg(t('camera.failed'));
}
}
// check the web serial api
function checkSerial() {
const isWebSerialSupported = 'serial' in navigator;
const state = isWebSerialSupported ? 'disconnected' : 'notSupported';
setSerialState(state);
}
// select serial port
async function selectSerial() {
if (serialState === 'connecting') return;
setSerialState('connecting');
setErrMsg('');
try {
const port = await navigator.serial.requestPort();
await device.serialPort.init(port);
setSerialState('connected');
} catch (err) {
console.log(err);
setSerialState('disconnected');
setErrMsg(t('serial.failed'));
}
}
return (
<Modal open={isOpen} title={t('modal.title')} footer={null} closable={false} destroyOnClose>
<div className="flex flex-col items-center justify-center space-y-5 py-10">
<Select
value={videoDeviceId || undefined}
style={{ width: 250 }}
options={devices}
allowClear={true}
loading={videoState === 'connecting'}
placeholder={t('modal.selectVideo')}
onChange={selectVideo}
onClick={() => getVideoDevices(false)}
/>
{serialState !== 'notSupported' && (
<Button
type="primary"
className="w-[250px]"
loading={serialState === 'connecting'}
onClick={selectSerial}
>
{t('modal.selectSerial')}
</Button>
)}
{errMsg && <span className="text-xs text-red-500">{errMsg}</span>}
</div>
</Modal>
);
};

View File

@@ -0,0 +1,94 @@
import { useEffect, useRef } from 'react';
import { device } from '@/libs/device';
import { Modifiers } from '@/libs/device/keyboard.ts';
import { KeyboardCodes } from '@/libs/keyboard';
export const Keyboard = () => {
const controlKeys = new Set(['Control', 'Shift', 'Alt', 'Meta']);
const lastKeyRef = useRef<KeyboardEvent>();
const pressedKeysRef = useRef<Set<string>>(new Set());
// listen keyboard events
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, []);
// press button
async function handleKeyDown(event: KeyboardEvent) {
event.preventDefault();
event.stopPropagation();
lastKeyRef.current = event;
if (controlKeys.has(event.key)) {
pressedKeysRef.current.add(event.code);
return;
}
await sendKeyDown(event);
}
// release button
async function handleKeyUp(event: KeyboardEvent) {
event.preventDefault();
event.stopPropagation();
if (controlKeys.has(event.key) && lastKeyRef.current?.code === event.code) {
await sendKeyDown(lastKeyRef.current);
lastKeyRef.current = undefined;
pressedKeysRef.current.clear();
}
await sendKeyUp();
}
async function sendKeyDown(event: KeyboardEvent) {
const code = KeyboardCodes.get(event.code);
if (!code) return;
const ctrl = getCtrl(event);
const keys = [0x00, 0x00, code, 0x00, 0x00, 0x00];
await device.sendKeyboardData(ctrl, keys);
}
async function sendKeyUp() {
const modifiers = new Modifiers();
const keys = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
await device.sendKeyboardData(modifiers, keys);
}
function getCtrl(event: KeyboardEvent) {
const modifiers = new Modifiers();
if (event.ctrlKey) {
modifiers.leftCtrl = pressedKeysRef.current.has('ControlLeft');
modifiers.rightCtrl = pressedKeysRef.current.has('ControlRight');
}
if (event.shiftKey) {
modifiers.leftShift = pressedKeysRef.current.has('ShiftLeft');
modifiers.rightShift = pressedKeysRef.current.has('ShiftRight');
}
if (event.altKey) {
modifiers.leftAlt = pressedKeysRef.current.has('AltLeft');
modifiers.rightAlt = pressedKeysRef.current.has('AltRight');
}
if (event.metaKey) {
modifiers.leftWindows = pressedKeysRef.current.has('MetaLeft');
modifiers.rightWindows = pressedKeysRef.current.has('MetaRight');
}
return modifiers;
}
return <></>;
};

View File

@@ -0,0 +1,38 @@
import { useEffect, useState } from 'react';
import { MaximizeIcon, MinimizeIcon } from 'lucide-react';
export const Fullscreen = () => {
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
function onFullscreenChange() {
setIsFullscreen(!!document.fullscreenElement);
}
onFullscreenChange();
document.addEventListener('fullscreenchange', onFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', onFullscreenChange);
};
}, []);
function handleFullscreen() {
if (!document.fullscreenElement) {
const element = document.documentElement;
element.requestFullscreen().then();
} else {
document.exitFullscreen().then();
}
}
return (
<div
className="flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded text-white hover:bg-neutral-700/70"
onClick={handleFullscreen}
>
{isFullscreen ? <MinimizeIcon size={18} /> : <MaximizeIcon size={18} />}
</div>
);
};

View File

@@ -0,0 +1,79 @@
import { useEffect, useState } from 'react';
import { Divider } from 'antd';
import clsx from 'clsx';
import { useAtomValue } from 'jotai';
import { MenuIcon, XIcon } from 'lucide-react';
import { serialStateAtom } from '@/jotai/device.ts';
import * as storage from '@/libs/storage';
import { Fullscreen } from './fullscreen';
import { Keyboard } from './keyboard';
import { Mouse } from './mouse';
import { SerialPort } from './serial-port';
import { Settings } from './settings';
import { Video } from './video';
export const Menu = () => {
const serialState = useAtomValue(serialStateAtom);
const [isMenuOpen, setIsMenuOpen] = useState(false);
useEffect(() => {
const isOpen = storage.getIsMenuOpen();
setIsMenuOpen(isOpen);
}, []);
function toggleMenu() {
const isOpen = !isMenuOpen;
setIsMenuOpen(isOpen);
storage.setIsMenuOpen(isOpen);
}
return (
<div className="fixed left-1/2 top-[10px] z-[1000] -translate-x-1/2">
<div className="sticky top-[10px]">
<div
className={clsx(
'h-[34px] items-center justify-between space-x-1.5 rounded bg-neutral-800/70 px-3',
isMenuOpen ? 'flex' : 'hidden'
)}
>
<Video />
{serialState === 'connected' && (
<>
<SerialPort />
<Divider type="vertical" />
<Keyboard />
<Mouse />
</>
)}
<Divider type="vertical" />
<Settings />
<Fullscreen />
<div
className="flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded text-white hover:bg-neutral-700/70"
onClick={toggleMenu}
>
<XIcon size={18} />
</div>
</div>
{!isMenuOpen && (
<div
className="flex h-[30px] w-[35px] cursor-pointer items-center justify-center rounded bg-neutral-800/50 text-white/50 hover:bg-neutral-800 hover:text-white"
onClick={toggleMenu}
>
<MenuIcon size={18} />
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,32 @@
import { useState } from 'react';
import { Popover } from 'antd';
import { KeyboardIcon } from 'lucide-react';
import { Paste } from './paste.tsx';
import { VirtualKeyboard } from './virtual-keyboard.tsx';
export const Keyboard = () => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const content = (
<>
<Paste />
<VirtualKeyboard />
</>
);
return (
<Popover
content={content}
placement="bottomLeft"
trigger="click"
arrow={false}
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
>
<div className="flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded text-white hover:bg-neutral-700/70">
<KeyboardIcon size={18} />
</div>
</Popover>
);
};

View File

@@ -0,0 +1,57 @@
import { useState } from 'react';
import { ClipboardIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { device } from '@/libs/device';
import { Modifiers } from '@/libs/device/keyboard.ts';
import { CharCodes, ShiftChars } from '@/libs/keyboard';
export const Paste = () => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
async function paste() {
if (isLoading) return;
setIsLoading(true);
try {
const text = await navigator.clipboard.readText();
if (!text) return;
for (const char of text) {
const ascii = char.charCodeAt(0);
const code = CharCodes.get(ascii);
if (!code) continue;
const modifiers = new Modifiers();
if ((ascii >= 65 && ascii <= 90) || ShiftChars.has(ascii)) {
modifiers.leftShift = true;
}
await send(modifiers, code);
}
} catch (e) {
console.log(e);
} finally {
setIsLoading(false);
}
}
async function send(modifiers: Modifiers, code: number) {
const keys = [0x00, 0x00, code, 0x00, 0x00, 0x00];
await device.sendKeyboardData(modifiers, keys);
await device.sendKeyboardData(new Modifiers(), [0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
}
return (
<div
className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-3 text-neutral-300 hover:bg-neutral-700/50"
onClick={paste}
>
<ClipboardIcon size={18} />
<span>{t('keyboard.paste')}</span>
</div>
);
};

View File

@@ -0,0 +1,20 @@
import { useSetAtom } from 'jotai';
import { KeyboardIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { isKeyboardOpenAtom } from '@/jotai/keyboard.ts';
export const VirtualKeyboard = () => {
const { t } = useTranslation();
const setIsKeyboardOpen = useSetAtom(isKeyboardOpenAtom);
return (
<div
className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-3 text-neutral-300 hover:bg-neutral-700/50"
onClick={() => setIsKeyboardOpen(true)}
>
<KeyboardIcon size={18} />
<span>{t('keyboard.virtualKeyboard')}</span>
</div>
);
};

View File

@@ -0,0 +1,58 @@
import { ReactElement } from 'react';
import { Popover } from 'antd';
import clsx from 'clsx';
import { useAtom } from 'jotai';
import { ArrowDownUpIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { scrollDirectionAtom } from '@/jotai/mouse.ts';
import * as storage from '@/libs/storage';
export const Direction = (): ReactElement => {
const { t } = useTranslation();
const [scrollDirection, setScrollDirection] = useAtom(scrollDirectionAtom);
const directions = [
{ name: t('mouse.scrollUp'), value: '1' },
{ name: t('mouse.scrollDown'), value: '-1' }
];
function update(direction: string): void {
setScrollDirection(Number(direction));
storage.setMouseScrollDirection(direction);
}
return (
<Popover
content={
<>
{directions.map((direction) => (
<div
key={direction.value}
className={clsx(
'my-1 flex cursor-pointer items-center space-x-1 rounded py-1 pl-2 pr-5 hover:bg-neutral-700/50',
direction.value === scrollDirection.toString()
? 'text-blue-500'
: 'text-neutral-300'
)}
onClick={() => update(direction.value)}
>
{direction.name}
</div>
))}
</>
}
placement="rightTop"
arrow={true}
trigger="hover"
>
<div className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-3 text-neutral-300 hover:bg-neutral-700/50">
<div className="flex h-[14px] w-[20px] items-end">
<ArrowDownUpIcon size={14} />
</div>
<span>{t('mouse.direction')}</span>
</div>
</Popover>
);
};

View File

@@ -0,0 +1,59 @@
import { useEffect, useState } from 'react';
import { Popover } from 'antd';
import { useAtom, useSetAtom } from 'jotai';
import { MouseIcon } from 'lucide-react';
import { mouseModeAtom, mouseStyleAtom, scrollDirectionAtom } from '@/jotai/mouse.ts';
import * as storage from '@/libs/storage';
import { Direction } from './direction.tsx';
import { Mode } from './mode.tsx';
import { Style } from './style.tsx';
export const Mouse = () => {
const [mouseStyle, setMouseStyle] = useAtom(mouseStyleAtom);
const [mouseMode, setMouseMode] = useAtom(mouseModeAtom);
const setScrollDirection = useSetAtom(scrollDirectionAtom);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
useEffect(() => {
const style = storage.getMouseStyle();
if (style && style !== mouseStyle) {
setMouseStyle(style);
}
const mode = storage.getMouseMode();
if (mode && mode !== mouseMode) {
setMouseMode(mode);
}
const direction = storage.getMouseScrollDirection();
if (direction && Number(direction)) {
setScrollDirection(Number(direction));
}
}, []);
const content = (
<>
<Style />
<Mode />
<Direction />
</>
);
return (
<Popover
content={content}
placement="bottomLeft"
trigger="click"
arrow={false}
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
>
<div className="flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded text-white hover:bg-neutral-700/70">
<MouseIcon size={18} />
</div>
</Popover>
);
};

View File

@@ -0,0 +1,54 @@
import { Popover } from 'antd';
import clsx from 'clsx';
import { useAtom } from 'jotai';
import { SquareMousePointerIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { mouseModeAtom } from '@/jotai/mouse.ts';
import * as storage from '@/libs/storage';
export const Mode = () => {
const { t } = useTranslation();
const [mouseMode, setMouseMode] = useAtom(mouseModeAtom);
const mouseModes = [
{ name: t('mouse.absolute'), value: 'absolute' },
{ name: t('mouse.relative'), value: 'relative' }
];
function update(mode: string) {
setMouseMode(mode);
storage.setMouseMode(mode);
}
return (
<Popover
content={
<>
{mouseModes.map((mode) => (
<div
key={mode.value}
className={clsx(
'my-1 flex cursor-pointer items-center space-x-1 rounded py-1 pl-2 pr-5 hover:bg-neutral-700/50',
mode.value === mouseMode ? 'text-blue-500' : 'text-neutral-300'
)}
onClick={() => update(mode.value)}
>
{mode.name}
</div>
))}
</>
}
placement="rightTop"
arrow={true}
trigger="hover"
>
<div className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-3 text-neutral-300 hover:bg-neutral-700/50">
<div className="flex h-[14px] w-[20px] items-end">
<SquareMousePointerIcon size={14} />
</div>
<span>{t('mouse.mode')}</span>
</div>
</Popover>
);
};

View File

@@ -0,0 +1,57 @@
import { Popover } from 'antd';
import clsx from 'clsx';
import { useAtom } from 'jotai';
import { EyeOffIcon, HandIcon, MousePointerIcon, PlusIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { mouseStyleAtom } from '@/jotai/mouse.ts';
import * as storage from '@/libs/storage';
export const Style = () => {
const { t } = useTranslation();
const [mouseStyle, setMouseStyle] = useAtom(mouseStyleAtom);
function updateStyle(style: string) {
setMouseStyle(style);
storage.setMouseStyle(style);
}
const mouseStyles = [
{
name: t('mouse.cursor.pointer'),
icon: <MousePointerIcon size={14} />,
value: 'cursor-default'
},
{ name: t('mouse.cursor.grab'), icon: <HandIcon size={14} />, value: 'cursor-grab' },
{ name: t('mouse.cursor.cell'), icon: <PlusIcon size={14} />, value: 'cursor-cell' },
{ name: t('mouse.cursor.hide'), icon: <EyeOffIcon size={14} />, value: 'cursor-none' }
];
const content = (
<>
{mouseStyles.map((style) => (
<div
key={style.value}
className={clsx(
'flex cursor-pointer select-none items-center space-x-1 rounded py-1.5 pl-3 pr-5 hover:bg-neutral-700/50',
style.value === mouseStyle ? 'text-blue-500' : 'text-neutral-300'
)}
onClick={() => updateStyle(style.value)}
>
<div className="flex h-[14px] w-[20px] items-end">{style.icon}</div>
<span>{style.name}</span>
</div>
))}
</>
);
return (
<Popover content={content} placement="rightTop">
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
<MousePointerIcon size={18} />
<span className="select-none text-sm">{t('mouse.cursor.title')}</span>
</div>
</Popover>
);
};

View File

@@ -0,0 +1,28 @@
import { useState } from 'react';
import { CpuIcon, Loader2Icon } from 'lucide-react';
import { device } from '@/libs/device';
export const SerialPort = () => {
const [isLoading, setIsLoading] = useState(false);
async function selectSerial() {
if (isLoading) return;
setIsLoading(true);
try {
const port = await navigator.serial.requestPort();
await device.serialPort.init(port);
} finally {
setIsLoading(false);
}
}
return (
<div className="flex items-center justify-center text-neutral-300" onClick={selectSerial}>
<div className="flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded text-white hover:bg-neutral-700/70">
{isLoading ? <Loader2Icon className="animate-spin" size={18} /> : <CpuIcon size={18} />}
</div>
</div>
);
};

View File

@@ -0,0 +1,43 @@
import { Popover } from 'antd';
import { BookIcon, DownloadIcon, SettingsIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Language } from './language.tsx';
export const Settings = () => {
const { t } = useTranslation();
function openPage(url: string) {
window.open(url, '_blank');
}
const content = (
<div className="flex flex-col space-y-1">
<Language />
<div
className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700"
onClick={() => openPage('https://wiki.sipeed.com/nanokvmusb')}
>
<BookIcon size={18} />
<span>{t('settings.document')}</span>
</div>
<div
className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700"
onClick={() => openPage('https://github.com/sipeed/NanoKVM-USB/releases')}
>
<DownloadIcon size={18} />
<span>{t('settings.download')}</span>
</div>
</div>
);
return (
<Popover content={content} placement="bottomLeft" trigger="click" arrow={false}>
<div className="flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded text-white hover:bg-neutral-700/70">
<SettingsIcon size={18} />
</div>
</Popover>
);
};

View File

@@ -0,0 +1,44 @@
import { Popover } from 'antd';
import clsx from 'clsx';
import { LanguagesIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import languages from '@/i18n/languages.ts';
import { setLanguage } from '@/libs/storage';
export const Language = () => {
const { t, i18n } = useTranslation();
function changeLanguage(lng: string) {
if (i18n.language === lng) return;
i18n.changeLanguage(lng);
setLanguage(lng);
}
const content = (
<>
{languages.map((lng) => (
<div
key={lng.key}
className={clsx(
'flex cursor-pointer select-none items-center space-x-1 rounded px-5 py-1',
i18n.language === lng.key ? 'text-blue-500' : 'text-white hover:bg-neutral-700'
)}
onClick={() => changeLanguage(lng.key)}
>
{lng.name}
</div>
))}
</>
);
return (
<Popover content={content} placement="rightTop">
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
<LanguagesIcon size={18} />
<span>{t('settings.language')}</span>
</div>
</Popover>
);
};

View File

@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react';
import { Popover } from 'antd';
import clsx from 'clsx';
import { useAtom, useAtomValue } from 'jotai';
import { VideoIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { resolutionAtom, videoDeviceIdAtom } from '@/jotai/device.ts';
import { camera } from '@/libs/camera';
import * as storage from '@/libs/storage';
export const Device = () => {
const { t } = useTranslation();
const resolution = useAtomValue(resolutionAtom);
const [videoDeviceId, setVideoDeviceId] = useAtom(videoDeviceIdAtom);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
navigator.mediaDevices.enumerateDevices().then((deviceInfo) => {
const videoDevices = deviceInfo.filter((device) => device.kind === 'videoinput');
setDevices(videoDevices);
});
}, []);
async function selectDevice(deviceId: string) {
if (isLoading) return;
setIsLoading(true);
try {
const success = await camera.open(deviceId, resolution.width, resolution.height);
if (!success) return;
const video = document.getElementById('video') as HTMLVideoElement;
if (!video) return;
video.srcObject = camera.getStream();
setVideoDeviceId(deviceId);
storage.setVideoDevice(deviceId);
} finally {
setIsLoading(false);
}
}
const content = (
<>
{devices.map((device: MediaDeviceInfo) => (
<div
key={device.deviceId}
className={clsx(
'cursor-pointer rounded px-2 py-1.5 hover:bg-neutral-700/60',
device.deviceId === videoDeviceId ? 'text-blue-500' : 'text-white'
)}
onClick={() => selectDevice(device.deviceId)}
>
{device.label}
</div>
))}
</>
);
return (
<Popover content={content} placement="rightTop">
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
<VideoIcon size={18} />
<span className="select-none text-sm">{t('video.device')}</span>
</div>
</Popover>
);
};

View File

@@ -0,0 +1,22 @@
import { Popover } from 'antd';
import { MonitorIcon } from 'lucide-react';
import { Device } from './device.tsx';
import { Resolution } from './resolution.tsx';
export const Video = () => {
const content = (
<div className="flex flex-col space-y-1">
<Resolution />
<Device />
</div>
);
return (
<Popover content={content} placement="bottomLeft" trigger="click" arrow={false}>
<div className="flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded text-white hover:bg-neutral-700/70">
<MonitorIcon size={18} />
</div>
</Popover>
);
};

View File

@@ -0,0 +1,196 @@
import { useEffect, useState } from 'react';
import { Button, Divider, InputNumber, Modal, Popover } from 'antd';
import clsx from 'clsx';
import { useAtom, useSetAtom } from 'jotai';
import { RatioIcon, Trash2Icon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { resolutionAtom } from '@/jotai/device.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { camera } from '@/libs/camera';
import * as storage from '@/libs/storage';
import type { Resolution as VideoResolution } from '@/types';
export const Resolution = () => {
const { t } = useTranslation();
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
const [resolution, setResolution] = useAtom(resolutionAtom);
const [isOpen, setIsOpen] = useState(false);
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
const [customResolutions, setCustomResolutions] = useState<VideoResolution[]>([]);
const resolutions: VideoResolution[] = [
{ width: 2560, height: 1440 },
{ width: 1920, height: 1080 },
{ width: 1280, height: 720 },
{ width: 800, height: 600 },
{ width: 640, height: 480 }
];
useEffect(() => {
const resolutions = storage.getCustomResolutions();
if (resolutions) {
setCustomResolutions(resolutions);
}
}, []);
useEffect(() => {
setIsKeyboardEnable(!isOpen);
}, [isOpen]);
function showModal() {
setWidth(0);
setHeight(0);
setIsOpen(true);
}
function submit() {
if (!width || !height || (width === resolution.width && height === resolution.height)) {
setIsOpen(false);
return;
}
let isExist = resolutions.some((r) => r.width === width && r.height === height);
if (isExist) return;
isExist = customResolutions.some((r) => r.width === width && r.height === height);
if (isExist) return;
setCustomResolutions([...customResolutions, { width, height }]);
storage.setCustomResolution(width, height);
updateResolution(width, height);
}
async function updateResolution(w: number, h: number) {
const success = await camera.open('', w, h);
if (!success) return;
const video = document.getElementById('video') as HTMLVideoElement;
if (!video) return;
video.srcObject = camera.getStream();
setResolution({ width: w, height: h });
storage.setVideoResolution(w, h);
setIsOpen(false);
}
function removeCustomResolution(e: any) {
e.stopPropagation();
const isExist = customResolutions.some(
(r) => r.width === resolution.width && r.height === resolution.height
);
if (isExist) {
updateResolution(1920, 1080);
}
setCustomResolutions([]);
storage.removeCustomResolutions();
}
const content = (
<>
{/* resolution list */}
{resolutions.map((res) => (
<div
key={res.width}
className={clsx(
'flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 hover:bg-neutral-700/60',
resolution.width === res.width && resolution.height === res.height
? 'text-blue-500'
: 'text-white'
)}
onClick={() => updateResolution(res.width, res.height)}
>
<span className="flex w-[32px]">{res.width}</span>
<span>x</span>
<span className="w-[32px]">{res.height}</span>
</div>
))}
<Divider style={{ margin: '5px 0 5px 0' }} />
{/* custom resolution */}
<div
className="flex cursor-pointer select-none items-center justify-between space-x-3 rounded px-3 py-1.5 text-sm hover:bg-neutral-700/60"
onClick={showModal}
>
<span>{t('video.customResolution')}</span>
{customResolutions.length > 0 && (
<span className="hover:text-red-500" onClick={removeCustomResolution}>
<Trash2Icon size={16} />
</span>
)}
</div>
{customResolutions.map((res) => (
<div
key={res.width}
className={clsx(
'flex cursor-pointer select-none items-center space-x-1 rounded px-3 py-1.5 hover:bg-neutral-700/60',
resolution.width === res.width && resolution.height === res.height
? 'text-blue-500'
: 'text-white'
)}
onClick={() => updateResolution(res.width, res.height)}
>
<span className="flex w-[32px]">{res.width}</span>
<span>x</span>
<span className="w-[32px]">{res.height}</span>
</div>
))}
</>
);
return (
<>
<Popover content={content} placement="rightTop">
<div className="flex h-[30px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700">
<RatioIcon size={18} />
<span className="select-none text-sm">{t('video.resolution')}</span>
</div>
</Popover>
<Modal
open={isOpen}
title={t('video.custom.title')}
footer={null}
closable={false}
destroyOnClose
>
<div className="flex flex-col items-center justify-center space-y-5 py-10">
<div className="flex items-center space-x-5">
<span className="text-sm">{t('video.custom.width')}</span>
<InputNumber
min={1}
controls={false}
defaultValue={resolution.width}
onChange={(value) => setWidth(value || 0)}
/>
</div>
<div className="flex items-center space-x-5">
<span className="text-sm">{t('video.custom.height')}</span>
<InputNumber
min={1}
controls={false}
defaultValue={resolution.height}
onChange={(value) => setHeight(value || 0)}
/>
</div>
<div className="flex space-x-5">
<Button type="primary" className="w-20" onClick={submit}>
{t('video.custom.confirm')}
</Button>
<Button type="default" className="w-20" onClick={() => setIsOpen(false)}>
{t('video.custom.cancel')}
</Button>
</div>
</div>
</Modal>
</>
);
};

View File

@@ -0,0 +1,112 @@
import { useEffect, useRef } from 'react';
import { useAtomValue } from 'jotai';
import { resolutionAtom } from '@/jotai/device.ts';
import { scrollDirectionAtom } from '@/jotai/mouse.ts';
import { device } from '@/libs/device';
import { Key } from '@/libs/device/mouse.ts';
export const Absolute = () => {
const resolution = useAtomValue(resolutionAtom);
const scrollDirection = useAtomValue(scrollDirectionAtom);
const keyRef = useRef<Key>(new Key());
// listen mouse events
useEffect(() => {
const canvas = document.getElementById('video');
if (!canvas) return;
canvas.addEventListener('mousedown', handleMouseDown);
canvas.addEventListener('mouseup', handleMouseUp);
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('wheel', handleWheel);
canvas.addEventListener('click', disableEvent);
canvas.addEventListener('contextmenu', disableEvent);
// press button
async function handleMouseDown(event: any) {
disableEvent(event);
switch (event.button) {
case 0:
keyRef.current.left = true;
break;
case 1:
keyRef.current.mid = true;
break;
case 2:
keyRef.current.right = true;
break;
default:
console.log(`unknown button ${event.button}`);
return;
}
await send(event);
}
// release button
async function handleMouseUp(event: any) {
disableEvent(event);
switch (event.button) {
case 0:
keyRef.current.left = false;
break;
case 1:
keyRef.current.mid = false;
break;
case 2:
keyRef.current.right = false;
break;
default:
console.log(`unknown button ${event.button}`);
return;
}
await send(event);
}
// mouse move
async function handleMouseMove(event: any) {
disableEvent(event);
await send(event);
}
// mouse scroll
async function handleWheel(event: any) {
disableEvent(event);
const delta = Math.floor(event.deltaY);
if (delta === 0) return;
await send(event, delta > 0 ? -1 * scrollDirection : scrollDirection);
}
async function send(event: MouseEvent, scroll: number = 0) {
const rect = canvas!.getBoundingClientRect();
const x = Math.abs(event.clientX - rect.left);
const y = Math.abs(event.clientY - rect.top);
await device.sendMouseAbsoluteData(keyRef.current, rect.width, rect.height, x, y, scroll);
}
return () => {
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('mousedown', handleMouseDown);
canvas.removeEventListener('mouseup', handleMouseUp);
canvas.removeEventListener('wheel', handleWheel);
canvas.removeEventListener('click', disableEvent);
canvas.removeEventListener('contextmenu', disableEvent);
};
}, [resolution, scrollDirection]);
// disable default events
function disableEvent(event: any) {
event.preventDefault();
event.stopPropagation();
}
return <></>;
};

View File

@@ -0,0 +1,12 @@
import { useAtomValue } from 'jotai';
import { mouseModeAtom } from '@/jotai/mouse.ts';
import { Absolute } from './absolute.tsx';
import { Relative } from './relative.tsx';
export const Mouse = () => {
const mouseMode = useAtomValue(mouseModeAtom);
return <>{mouseMode === 'relative' ? <Relative /> : <Absolute />}</>;
};

View File

@@ -0,0 +1,145 @@
import { useEffect, useRef } from 'react';
import { message } from 'antd';
import { useAtomValue } from 'jotai';
import { useTranslation } from 'react-i18next';
import { resolutionAtom } from '@/jotai/device.ts';
import { scrollDirectionAtom } from '@/jotai/mouse.ts';
import { device } from '@/libs/device';
import { Key } from '@/libs/device/mouse.ts';
export const Relative = () => {
const { t } = useTranslation();
const [messageApi, contextHolder] = message.useMessage();
const resolution = useAtomValue(resolutionAtom);
const scrollDirection = useAtomValue(scrollDirectionAtom);
const isLockedRef = useRef(false);
const keyRef = useRef<Key>(new Key());
useEffect(() => {
messageApi.open({
key: 'relative',
type: 'info',
content: t('mouse.requestPointer'),
duration: 3,
style: {
marginTop: '40vh'
}
});
}, []);
// listen mouse events
useEffect(() => {
const canvas = document.getElementById('video');
if (!canvas) return;
document.addEventListener('pointerlockchange', handlePointerLockChange);
canvas.addEventListener('click', handleClick);
canvas.addEventListener('mousedown', handleMouseDown);
canvas.addEventListener('mouseup', handleMouseUp);
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('wheel', handleWheel);
canvas.addEventListener('contextmenu', disableEvent);
function handleClick(event: any) {
disableEvent(event);
if (!isLockedRef.current) {
canvas!.requestPointerLock();
}
}
function handlePointerLockChange() {
isLockedRef.current = document.pointerLockElement === canvas;
}
// press button
async function handleMouseDown(event: any) {
disableEvent(event);
switch (event.button) {
case 0:
keyRef.current.left = true;
break;
case 1:
keyRef.current.mid = true;
break;
case 2:
keyRef.current.right = true;
break;
default:
console.log(`unknown button ${event.button}`);
return;
}
await send(0, 0, 0);
}
// release button
async function handleMouseUp(event: any) {
disableEvent(event);
switch (event.button) {
case 0:
keyRef.current.left = false;
break;
case 1:
keyRef.current.mid = false;
break;
case 2:
keyRef.current.right = false;
break;
default:
console.log(`unknown button ${event.button}`);
return;
}
await send(0, 0, 0);
}
// mouse move
async function handleMouseMove(event: any) {
disableEvent(event);
const x = event.movementX || event.mozMovementX || event.webkitMovementX || 0;
const y = event.movementY || event.mozMovementY || event.webkitMovementY || 0;
if (x === 0 && y === 0) return;
await send(Math.abs(x) < 10 ? x * 2 : x, Math.abs(y) < 10 ? y * 2 : y, 0);
}
// mouse scroll
async function handleWheel(event: any) {
disableEvent(event);
const delta = Math.floor(event.deltaY);
if (delta === 0) return;
await send(0, 0, delta > 0 ? -1 * scrollDirection : scrollDirection);
}
return () => {
document.removeEventListener('pointerlockchange', handlePointerLockChange);
canvas.removeEventListener('click', handleClick);
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('mousedown', handleMouseDown);
canvas.removeEventListener('mouseup', handleMouseUp);
canvas.removeEventListener('wheel', handleWheel);
canvas.removeEventListener('contextmenu', disableEvent);
};
}, [resolution, scrollDirection]);
async function send(x: number, y: number, scroll: number) {
await device.sendMouseRelativeData(keyRef.current, x, y, scroll);
}
// disable default events
function disableEvent(event: any) {
event.preventDefault();
event.stopPropagation();
}
return <>{contextHolder}</>;
};

View File

@@ -0,0 +1,182 @@
import { useRef, useState } from 'react';
import clsx from 'clsx';
import { useAtom } from 'jotai';
import { XIcon } from 'lucide-react';
import Keyboard, { KeyboardButtonTheme } from 'react-simple-keyboard';
import { Drawer } from 'vaul';
import 'react-simple-keyboard/build/css/index.css';
import '@/assets/keyboard.css';
import { isKeyboardOpenAtom } from '@/jotai/keyboard.ts';
import { device } from '@/libs/device';
import { Modifiers } from '@/libs/device/keyboard.ts';
import { KeyboardCodes } from '@/libs/keyboard';
import {
doubleKeys,
keyboardArrowsOptions,
keyboardControlPadOptions,
keyboardOptions,
modifierKeys,
specialKeyMap
} from './keys.ts';
type KeyboardProps = {
isBigScreen: boolean;
};
export const VirtualKeyboard = ({ isBigScreen }: KeyboardProps) => {
const [isKeyboardOpen, setIsKeyboardOpen] = useAtom(isKeyboardOpenAtom);
const [activeModifierKeys, setActiveModifierKeys] = useState<string[]>([]);
const keyboardRef = useRef<any>(null);
async function onKeyPress(key: string) {
if (modifierKeys.includes(key)) {
if (activeModifierKeys.includes(key)) {
await sendKeydown(key);
await sendKeyup();
} else {
setActiveModifierKeys([...activeModifierKeys, key]);
}
return;
}
await sendKeydown(key);
}
async function onKeyReleased(key: string) {
if (modifierKeys.includes(key)) {
return;
}
await sendKeyup();
}
async function sendKeydown(key: string) {
const specialKey = specialKeyMap.get(key);
const code = KeyboardCodes.get(specialKey ? specialKey : key);
if (!code) {
console.log('unknown code: ', key);
return;
}
const ctrl = getCtrl();
const keys = [0x00, 0x00, code, 0x00, 0x00, 0x00];
await device.sendKeyboardData(ctrl, keys);
}
async function sendKeyup() {
const ctrl = new Modifiers();
const keys = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
await device.sendKeyboardData(ctrl, keys);
setActiveModifierKeys([]);
}
function getCtrl() {
const modifiers = new Modifiers();
activeModifierKeys.forEach((modifierKey) => {
const specialKey = specialKeyMap.get(modifierKey)!;
switch (specialKey) {
case 'ControlLeft':
modifiers.leftCtrl = true;
break;
case 'ControlRight':
modifiers.rightCtrl = true;
break;
case 'ShiftLeft':
modifiers.leftShift = true;
break;
case 'ShiftRight':
modifiers.rightShift = true;
break;
case 'AltLeft':
modifiers.leftAlt = true;
break;
case 'AltRight':
modifiers.rightAlt = true;
break;
case 'MetaLeft':
modifiers.leftWindows = true;
break;
case 'MetaRight':
modifiers.rightWindows = true;
break;
default:
break;
}
});
return modifiers;
}
function getButtonTheme(): KeyboardButtonTheme[] {
const theme = [{ class: 'hg-double', buttons: doubleKeys.join(' ') }];
if (activeModifierKeys.length > 0) {
const buttons = activeModifierKeys.join(' ');
theme.push({ class: 'hg-highlight', buttons });
}
return theme;
}
return (
<Drawer.Root open={isKeyboardOpen} onOpenChange={setIsKeyboardOpen} modal={false}>
<Drawer.Portal>
<Drawer.Content
className={clsx(
'fixed bottom-0 left-0 right-0 z-[999] mx-auto overflow-hidden rounded bg-white outline-none',
isBigScreen ? 'w-[820px]' : 'w-[650px]'
)}
>
{/* header */}
<div className="flex justify-end px-3 py-1">
<div
className="flex h-[20px] w-[20px] cursor-pointer items-center justify-center rounded text-neutral-600 hover:bg-neutral-300 hover:text-white"
onClick={() => setIsKeyboardOpen(false)}
>
<XIcon size={18} />
</div>
</div>
<div className="h-px flex-shrink-0 border-b bg-neutral-300" />
<div data-vaul-no-drag className="keyboardContainer w-full">
{/* main keyboard */}
<Keyboard
buttonTheme={getButtonTheme()}
keyboardRef={(r: any) => (keyboardRef.current = r)}
onKeyPress={onKeyPress}
onKeyReleased={onKeyReleased}
layoutName="default"
{...keyboardOptions}
/>
{/* control keyboard */}
{isBigScreen && (
<div className="controlArrows">
<Keyboard
onKeyPress={onKeyPress}
onKeyReleased={onKeyReleased}
{...keyboardControlPadOptions}
/>
<Keyboard
onKeyPress={onKeyPress}
onKeyReleased={onKeyReleased}
{...keyboardArrowsOptions}
/>
</div>
)}
</div>
</Drawer.Content>
<Drawer.Overlay />
</Drawer.Portal>
</Drawer.Root>
);
};

View File

@@ -0,0 +1,199 @@
// main keys
export const keyboardOptions = {
theme: 'simple-keyboard hg-theme-default',
baseClass: 'simple-keyboard-main',
layout: {
default: [
'{escape} F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
'Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal {backspace}',
'{tab} KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash',
'{capslock} KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote {enter}',
'{shiftleft} KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash {shiftright}',
'{controlleft} {winleft} {altleft} {space} {altright} {winright} {menu} {controlright}'
],
mac: [
'{escape} F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
'Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal {backspace}',
'{tab} KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash',
'{capslock} KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote {enter}',
'{shiftleft} KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash {shiftright}',
'{controlleft} {altleft} {metaleft} {space} {metaright} {altright}'
]
},
display: {
'{escape}': 'Esc',
Backquote: '~<br/>`',
Digit1: '!<br/>1',
Digit2: '@<br/>2',
Digit3: '#<br/>3',
Digit4: '$<br/>4',
Digit5: '%<br/>5',
Digit6: '^<br/>6',
Digit7: '&<br/>7',
Digit8: '*<br/>8',
Digit9: '(<br/>9',
Digit0: ')<br/>0',
Minus: '_<br/>-',
Equal: '+<br/>=',
'{backspace}': 'Backspace',
'{tab}': 'Tab',
KeyQ: 'Q',
KeyW: 'W',
KeyE: 'E',
KeyR: 'R',
KeyT: 'T',
KeyY: 'Y',
KeyU: 'U',
KeyI: 'I',
KeyO: 'O',
KeyP: 'P',
BracketLeft: '{<br/>[',
BracketRight: '}<br/>]',
Backslash: '|<br>\\',
'{capslock}': 'Caps',
KeyA: 'A',
KeyS: 'S',
KeyD: 'D',
KeyF: 'F',
KeyG: 'G',
KeyH: 'H',
KeyJ: 'J',
KeyK: 'K',
KeyL: 'L',
Semicolon: ':<br/>;',
Quote: '"<br/>\'',
'{enter}': 'Enter',
'{shiftleft}': 'Shift',
KeyZ: 'Z',
KeyX: 'X',
KeyC: 'C',
KeyV: 'V',
KeyB: 'B',
KeyN: 'N',
KeyM: 'M',
Comma: '<<br/>,',
Period: '><br/>.',
Slash: '?<br/>/',
'{shiftright}': 'Shift',
'{controlleft}': 'Ctrl',
'{altleft}': 'Alt',
'{metaleft}': 'Cmd',
'{winleft}': 'Win',
'{space}': 'Space',
'{metaright}': 'Cmd',
'{winright}': 'Win',
'{altright}': 'Alt',
'{menu}': 'Menu',
'{controlright}': 'Ctrl'
}
};
// control keys
export const keyboardControlPadOptions = {
theme: 'simple-keyboard hg-theme-default',
baseClass: 'simple-keyboard-control',
layout: {
default: [
'{prtscr} {scrolllock} {pause}',
'{insert} {home} {pageup}',
'{delete} {end} {pagedown}'
]
},
display: {
'{prtscr}': 'PrtScr',
'{scrolllock}': 'Lock',
'{pause}': 'Pause',
'{insert}': 'Ins',
'{home}': 'Home',
'{pageup}': 'PgUp',
'{delete}': 'Del',
'{end}': 'End',
'{pagedown}': 'PgDn'
}
};
// arrow keys
export const keyboardArrowsOptions = {
theme: 'simple-keyboard hg-theme-default',
baseClass: 'simple-keyboard-arrows',
layout: {
default: ['{arrowup}', '{arrowleft} {arrowdown} {arrowright}']
}
};
// keys require special mapping
export const specialKeyMap = new Map([
['{escape}', 'Escape'],
['{backspace}', 'Backspace'],
['{tab}', 'Tab'],
['{capslock}', 'CapsLock'],
['{enter}', 'Enter'],
['{shiftleft}', 'ShiftLeft'],
['{shiftright}', 'ShiftRight'],
['{controlleft}', 'ControlLeft'],
['{controlright}', 'ControlRight'],
['{altleft}', 'AltLeft'],
['{metaleft}', 'MetaLeft'],
['{winleft}', 'WinLeft'],
['{space}', 'Space'],
['{metaright}', 'MetaRight'],
['{winright}', 'WinRight'],
['{altright}', 'AltRight'],
['{prtscr}', 'PrintScreen'],
['{scrolllock}', 'ScrollLock'],
['{pause}', 'Pause'],
['{insert}', 'Insert'],
['{home}', 'Home'],
['{pageup}', 'PageUp'],
['{delete}', 'Delete'],
['{end}', 'End'],
['{pagedown}', 'PageDown'],
['{arrowright}', 'ArrowRight'],
['{arrowleft}', 'ArrowLeft'],
['{arrowdown}', 'ArrowDown'],
['{arrowup}', 'ArrowUp']
]);
// modifier keys
export const modifierKeys = [
'{shiftleft}',
'{controlleft}',
'{altleft}',
'{metaleft}',
'{winleft}',
'{shiftright}',
'{controlright}',
'{altright}',
'{metaright}',
'{winright}'
];
// double line display buttons
export const doubleKeys = [
'Backquote',
'Digit1',
'Digit2',
'Digit3',
'Digit4',
'Digit5',
'Digit6',
'Digit7',
'Digit8',
'Digit9',
'Digit0',
'Minus',
'Equal',
'BracketLeft',
'BracketRight',
'Backslash',
'Semicolon',
'Quote',
'Comma',
'Period',
'Slash'
];

53
browser/src/i18n/index.ts Normal file
View File

@@ -0,0 +1,53 @@
import i18n from 'i18next';
import type { Resource } from 'i18next';
import { initReactI18next } from 'react-i18next';
import { getLanguage } from '@/libs/storage';
function getResources(): Resource {
const resources: Resource = {};
const modules: Record<string, Resource> = import.meta.glob('./locales/*.ts', { eager: true });
for (const path in modules) {
const moduleName = path.split('/').pop()?.replace('.ts', '');
if (moduleName) {
resources[moduleName] = modules[path].default;
}
}
return resources;
}
function getCurrentLanguage(): string {
const languages = Object.keys(resources);
const cookieLng = getLanguage();
if (cookieLng && languages.includes(cookieLng)) {
return cookieLng;
}
const navigatorLng = navigator.language.split('-')[0];
if (languages.includes(navigatorLng)) {
return navigatorLng;
}
return 'en';
}
const resources = getResources();
const lng = getCurrentLanguage();
i18n
.use(initReactI18next)
.init({
resources,
lng,
fallbackLng: 'en',
interpolation: {
escapeValue: false
}
})
.then();
export default i18n;

View File

@@ -0,0 +1,8 @@
const languages = [
{ key: 'en', name: 'English' },
{ key: 'zh', name: '中文' }
];
languages.sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }));
export default languages;

View File

@@ -0,0 +1,65 @@
const en = {
translation: {
serial: {
notSupported:
'Serial not supported. Please use the desktop Chrome browser to enable mouse and keyboard.',
failed: 'Failed to connect serial. Please try again'
},
camera: {
tip: 'Waiting for authorization...',
denied: 'Permission Denied',
authorize:
'Remote desktop requires camera permission. Please authorize camera in browser settings.',
failed: 'Failed to connect camera. Please try again.'
},
modal: {
title: 'Select USB Device',
selectVideo: 'Please select a video input device',
selectSerial: 'Select serial device'
},
menu: {
serial: 'Serial',
keyboard: 'Keyboard',
mouse: 'Mouse'
},
video: {
resolution: 'Resolution',
customResolution: 'Custom',
device: 'Device',
custom: {
title: 'Custom Resolution',
width: 'Width',
height: 'Height',
confirm: 'Ok',
cancel: 'Cancel'
}
},
keyboard: {
paste: 'Paste',
virtualKeyboard: 'Keyboard'
},
mouse: {
cursor: {
title: 'Cursor',
pointer: 'Pointer',
grab: 'Grab',
cell: 'Cell',
hide: 'Hide'
},
mode: 'Mouse mode',
absolute: 'Absolute mode',
relative: 'Relative mode',
direction: 'Wheel direction',
scrollUp: 'Scroll up',
scrollDown: 'Scroll down',
requestPointer: 'Using relative mode. Please click desktop to get mouse pointer.'
},
settings: {
language: 'Language',
document: 'Document',
download: 'Download'
}
}
};
export default en;

View File

@@ -0,0 +1,63 @@
const zh = {
translation: {
serial: {
notSupported: '当前浏览器不支持串口,无法使用键鼠。请使用桌面版 Chrome 浏览器。',
failed: '串口连接失败,请重试。'
},
camera: {
tip: '等待授权...',
denied: '权限不足',
authorize: '远程桌面需要获取摄像头权限,请在浏览器设置中允许使用摄像头。',
failed: '摄像头连接失败,请重试。'
},
modal: {
title: '选择 USB 设备',
selectVideo: '请选择视频输入设备',
selectSerial: '选择串口设备'
},
menu: {
serial: '串口',
keyboard: '键盘',
mouse: '鼠标'
},
video: {
resolution: '分辨率',
customResolution: '自定义',
device: '设备',
custom: {
title: '自定义分辨率',
width: '宽度',
height: '高度',
confirm: '确定',
cancel: '取消'
}
},
keyboard: {
paste: '粘贴',
virtualKeyboard: '虚拟键盘'
},
mouse: {
cursor: {
title: '鼠标指针',
pointer: '箭头',
grab: '抓取',
cell: '单元',
hide: '隐藏'
},
mode: '鼠标模式',
absolute: '绝对模式',
relative: '相对模式',
direction: '滚轮方向',
scrollUp: '向上',
scrollDown: '向下',
requestPointer: '正在使用鼠标相对模式,请点击桌面获取鼠标指针。'
},
settings: {
language: '语言',
document: '文档',
download: '下载'
}
}
};
export default zh;

View File

@@ -0,0 +1,16 @@
import { atom } from 'jotai';
import type { Resolution } from '@/types.ts';
type VideoState = 'disconnected' | 'connecting' | 'connected';
type SerialState = 'notSupported' | 'disconnected' | 'connecting' | 'connected';
export const resolutionAtom = atom<Resolution>({
width: 1920,
height: 1080
});
export const videoDeviceIdAtom = atom('');
export const videoStateAtom = atom<VideoState>('disconnected');
export const serialStateAtom = atom<SerialState>('disconnected');

View File

@@ -0,0 +1,5 @@
import { atom } from 'jotai';
export const isKeyboardEnableAtom = atom(true);
export const isKeyboardOpenAtom = atom(false);

View File

@@ -0,0 +1,10 @@
// mouse cursor style
import { atom } from 'jotai';
export const mouseStyleAtom = atom('cursor-default');
// mouse mode: absolute or relative
export const mouseModeAtom = atom('absolute');
// mouse scroll direction: 1 or -1
export const scrollDirectionAtom = atom(1);

View File

@@ -0,0 +1,52 @@
class Camera {
id: string = '';
width: number = 1920;
height: number = 1080;
stream: MediaStream | null = null;
public async open(id?: string, width?: number, height?: number): Promise<boolean> {
if (!id && !this.id) {
return false;
}
try {
this.close();
const constraints = {
video: {
deviceId: { exact: id || this.id },
width: { ideal: width || this.width },
height: { ideal: height || this.height }
},
audio: true
};
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
if (id) this.id = id;
if (width) this.width = width;
if (height) this.height = height;
return true;
} catch (err) {
console.log(err);
return false;
}
}
public close(): void {
if (this.stream) {
this.stream.getTracks().forEach((track) => track.stop());
this.stream = null;
}
}
public getStream(): MediaStream | null {
return this.stream;
}
public isOpen(): boolean {
return this.stream !== null;
}
}
export const camera = new Camera();

View File

@@ -0,0 +1,66 @@
import { Modifiers } from './keyboard.ts';
import { Key as MouseKey, Mode as MouseMode } from './mouse.ts';
import { CmdEvent, CmdPacket } from './proto.ts';
import { SerialPort } from './serial-port.ts';
import { intToLittleEndianList } from './utils.ts';
export class Device {
addr: number;
serialPort: SerialPort;
constructor() {
this.addr = 0x00;
this.serialPort = new SerialPort();
}
async sendKeyboardData(modifiers: Modifiers, keys: number[]) {
if (keys.length !== 6) {
throw new Error('keyboard keys length must be 6');
}
const data = [modifiers.encode(), 0x00, ...keys];
const cmdData = new CmdPacket(this.addr, CmdEvent.SEND_KB_GENERAL_DATA, data).encode();
await this.serialPort.write(cmdData);
}
async sendMouseAbsoluteData(
key: MouseKey,
width: number,
height: number,
x: number,
y: number,
scroll: number
) {
const xAbs = width === 0 ? 0 : Math.floor((x * 4096) / width);
const yAbs = width === 0 ? 0 : Math.floor((y * 4096) / height);
const data = [
MouseMode.ABSOLUTE,
key.encode(),
...intToLittleEndianList(xAbs),
...intToLittleEndianList(yAbs),
scroll
];
const cmdData = new CmdPacket(this.addr, CmdEvent.SEND_MS_ABS_DATA, data).encode();
await this.serialPort.write(cmdData);
}
async sendMouseRelativeData(msKey: MouseKey, x: number, y: number, scroll: number) {
function intToByte(value: number): number {
if (value < -128 || value > 127) {
throw new Error('value must be in range -128 to 127 for a signed byte');
}
return (value + 256) % 256;
}
const xByte = intToByte(x);
const yByte = intToByte(y);
const data = [MouseMode.RELATIVE, msKey.encode(), xByte, yByte, scroll];
const cmdData = new CmdPacket(this.addr, CmdEvent.SEND_MS_REL_DATA, data).encode();
await this.serialPort.write(cmdData);
}
}
export const device = new Device();

View File

@@ -0,0 +1,25 @@
import { setBit } from './utils.ts';
export class Modifiers {
public rightWindows: boolean = false;
public rightAlt: boolean = false;
public rightShift: boolean = false;
public rightCtrl: boolean = false;
public leftWindows: boolean = false;
public leftAlt: boolean = false;
public leftShift: boolean = false;
public leftCtrl: boolean = false;
public encode(): number {
let b = 0x00;
b = setBit(b, 0, this.leftCtrl);
b = setBit(b, 1, this.leftShift);
b = setBit(b, 2, this.leftAlt);
b = setBit(b, 3, this.leftWindows);
b = setBit(b, 4, this.rightCtrl);
b = setBit(b, 5, this.rightShift);
b = setBit(b, 6, this.rightAlt);
b = setBit(b, 7, this.rightWindows);
return b;
}
}

View File

@@ -0,0 +1,26 @@
import { setBit } from './utils.ts';
export enum Mode {
RELATIVE = 0x01,
ABSOLUTE = 0x02
}
export class Key {
left: boolean;
right: boolean;
mid: boolean;
constructor(left: boolean = false, right: boolean = false, mid: boolean = false) {
this.left = left;
this.right = right;
this.mid = mid;
}
public encode(): number {
let b = 0x00;
b = setBit(b, 0, this.left);
b = setBit(b, 1, this.right);
b = setBit(b, 2, this.mid);
return b;
}
}

View File

@@ -0,0 +1,108 @@
export enum CmdEvent {
GET_INFO = 0x01,
SEND_KB_GENERAL_DATA = 0x02,
SEND_KB_MEDIA_DATA = 0x03,
SEND_MS_ABS_DATA = 0x04,
SEND_MS_REL_DATA = 0x05,
SEND_MY_HID_DATA = 0x06,
READ_MY_HID_DATA = 0x87,
GET_PARA_CFG = 0x08,
SET_PARA_CFG = 0x09,
GET_USB_STRING = 0x0a,
SET_USB_STRING = 0x0b,
SET_DEFAULT_CFG = 0x0c,
RESET = 0x0f
}
export class CmdPacket {
readonly HEAD1: number = 0x57;
readonly HEAD2: number = 0xab;
ADDR: number = 0x00;
CMD: number = 0x00;
LEN: number = 0x00;
DATA: number[] = [];
SUM: number = 0x00;
constructor(addr: number = 0x00, cmd: number = 0x00, data: number[] = []) {
if (addr < 0 || cmd < 0) {
this.decode(data);
return;
}
this.save(addr, cmd, data);
}
encode(): number[] {
return [this.HEAD1, this.HEAD2, this.ADDR, this.CMD, this.LEN, ...this.DATA, this.SUM];
}
public decode(data: number[]): number {
const headerIndex = this.findHead(data);
if (headerIndex < 0) {
console.log('cannot find HEAD');
return -1;
}
if (data.length - headerIndex < 6) {
console.log('len error1');
return -1;
}
const addr = data[headerIndex + 2];
const cmd = data[headerIndex + 3];
const dataLen = data[headerIndex + 4];
if (data.length < headerIndex + 3 + dataLen + 1) {
console.log('len error2');
return -1;
}
let sum: number;
try {
sum = data[headerIndex + 5 + dataLen];
} catch {
console.log('len error3');
return -1;
}
let s = 0;
for (let i = headerIndex; i < headerIndex + 4 + dataLen; i++) {
s += data[i];
}
if ((s & 0xff) !== sum) {
// console.log(`sum error, sum${sum}, s${s & 0xff}`);
return -1;
}
this.ADDR = addr;
this.CMD = cmd;
this.LEN = dataLen;
this.DATA = data.slice(headerIndex + 5, headerIndex + 5 + this.LEN);
this.SUM = sum;
return 0;
}
private findHead(lst: number[]): number {
const subsequence = [this.HEAD1, this.HEAD2];
const subseqLen = subsequence.length;
for (let i = 0; i <= lst.length - subseqLen; i++) {
if (lst.slice(i, i + subseqLen).every((val, index) => val === subsequence[index])) {
return i;
}
}
return -1;
}
private save(addr: number, cmd: number, data: number[]): void {
this.ADDR = addr;
this.CMD = cmd;
this.DATA = data;
this.LEN = data.length;
this.SUM = this.HEAD1 + this.HEAD2 + this.ADDR + this.CMD + this.LEN;
for (const i of this.DATA) {
this.SUM += i;
}
this.SUM &= 0xff;
}
}

View File

@@ -0,0 +1,80 @@
export class SerialPort {
instance: any;
reader: ReadableStreamDefaultReader | null;
writer: WritableStreamDefaultWriter | null;
readonly TIMEOUT = 500; // 500ms
constructor() {
this.instance = null;
this.reader = null;
this.writer = null;
}
async init(port: any, baudRate: number = 57600) {
try {
if (this.instance) {
await this.close();
}
this.instance = port;
await this.instance.open({baudRate});
this.reader = this.instance.readable!.getReader();
this.writer = this.instance.writable!.getWriter();
} catch (err) {
console.error('Error opening serial port:', err);
throw err;
}
}
async write(data: number[]) {
if (!this.writer) {
throw new Error('Serial port not initialized');
}
const uint8Array = new Uint8Array(data);
await this.writer.write(uint8Array);
}
async read(minSize: number, sleep: number = 0): Promise<number[]> {
if (!this.reader) {
throw new Error('Serial port not initialized');
}
const result: number[] = [];
const startTime = Date.now();
while (result.length < minSize) {
if (Date.now() - startTime > this.TIMEOUT) {
return [];
}
const {value, done} = await this.reader.read();
if (done) {
break;
}
const data = Array.from(value) as number[];
result.push(...data);
}
if (sleep > 0) {
await new Promise((resolve) => setTimeout(resolve, sleep));
}
return result;
}
async close(): Promise<void> {
if (this.reader) {
await this.reader.cancel();
this.reader.releaseLock();
}
if (this.writer) {
await this.writer.close();
this.writer.releaseLock();
}
if (this.instance) {
await this.instance.close();
}
}
}

View File

@@ -0,0 +1,19 @@
export function getBit(number: number, bitPosition: number): number {
return (number >> bitPosition) & 1;
}
export function setBit(number: number, bitPosition: number, value: boolean): number {
if (value) {
return number | (1 << bitPosition);
} else {
return number & ~(1 << bitPosition);
}
}
export function intToLittleEndianList(number: number): number[] {
const byteList: number[] = [];
for (let i = 0; i < 2; i++) {
byteList.push((number >> (i * 8)) & 0xff);
}
return byteList;
}

View File

@@ -0,0 +1,107 @@
export const CharCodes: Map<number, number> = new Map([
[48, 0x27], // 0
[49, 0x1e], // 1
[50, 0x1f], // 2
[51, 0x20], // 3
[52, 0x21], // 4
[53, 0x22], // 5
[54, 0x23], // 6
[55, 0x24], // 7
[56, 0x25], // 8
[57, 0x26], // 9
[65, 0x04], // A
[66, 0x05], // B
[67, 0x06], // C
[68, 0x07], // D
[69, 0x08], // E
[70, 0x09], // F
[71, 0x0a], // G
[72, 0x0b], // H
[73, 0x0c], // I
[74, 0x0d], // J
[75, 0x0e], // K
[76, 0x0f], // L
[77, 0x10], // M
[78, 0x11], // N
[79, 0x12], // O
[80, 0x13], // P
[81, 0x14], // Q
[82, 0x15], // R
[83, 0x16], // S
[84, 0x17], // T
[85, 0x18], // U
[86, 0x19], // V
[87, 0x1a], // W
[88, 0x1b], // X
[89, 0x1c], // Y
[90, 0x1d], // Z
[97, 0x04], // a
[98, 0x05], // b
[99, 0x06], // c
[100, 0x07], // d
[101, 0x08], // e
[102, 0x09], // f
[103, 0x0a], // g
[104, 0x0b], // h
[105, 0x0c], // i
[106, 0x0d], // j
[107, 0x0e], // k
[108, 0x0f], // l
[109, 0x10], // m
[110, 0x11], // n
[111, 0x12], // o
[112, 0x13], // p
[113, 0x14], // q
[114, 0x15], // r
[115, 0x16], // s
[116, 0x17], // t
[117, 0x18], // u
[118, 0x19], // v
[119, 0x1a], // w
[120, 0x1b], // x
[121, 0x1c], // y
[122, 0x1d], // z
[32, 0x2c], // Space
[33, 0x1e], // !
[34, 0x34], // "
[35, 0x20], // #
[36, 0x21], // $
[37, 0x22], // %
[38, 0x24], // &
[39, 0x34], // '
[40, 0x26], // (
[41, 0x27], // )
[42, 0x25], // *
[43, 0x2e], // +
[44, 0x36], // ,
[45, 0x2d], // -
[46, 0x37], // .
[47, 0x38], // /
[9, 43], // Tab
[10, 40], // Enter
[58, 51], // :
[59, 51], // ;
[60, 54], // <
[61, 46], // =
[62, 55], // >
[63, 56], // ?
[64, 31], // @
[91, 47], // [
[92, 49], // \
[93, 48], // ]
[94, 35], // ^
[95, 45], // _
[96, 53], // `
[123, 47], // {
[124, 49], // |
[125, 48], // }
[126, 53] // ~
]);
export const ShiftChars: Set<number> = new Set([
33, 64, 35, 36, 37, 94, 38, 42, 40, 41, 95, 43, 123, 124, 125, 58, 34, 126, 60, 62, 63
]);

View File

@@ -0,0 +1,2 @@
export * from './keyboardCodes.ts';
export * from './charCodes.ts';

View File

@@ -0,0 +1,121 @@
export const KeyboardCodes: Map<string, number> = new Map([
['KeyA', 4],
['KeyB', 5],
['KeyC', 6],
['KeyD', 7],
['KeyE', 8],
['KeyF', 9],
['KeyG', 10],
['KeyH', 11],
['KeyI', 12],
['KeyJ', 13],
['KeyK', 14],
['KeyL', 15],
['KeyM', 16],
['KeyN', 17],
['KeyO', 18],
['KeyP', 19],
['KeyQ', 20],
['KeyR', 21],
['KeyS', 22],
['KeyT', 23],
['KeyU', 24],
['KeyV', 25],
['KeyW', 26],
['KeyX', 27],
['KeyY', 28],
['KeyZ', 29],
['Digit1', 30],
['Digit2', 31],
['Digit3', 32],
['Digit4', 33],
['Digit5', 34],
['Digit6', 35],
['Digit7', 36],
['Digit8', 37],
['Digit9', 38],
['Digit0', 39],
['Enter', 40],
['Escape', 41],
['Backspace', 42],
['Tab', 43],
['Space', 44],
['Minus', 45],
['Equal', 46],
['BracketLeft', 47],
['BracketRight', 48],
['Backslash', 49],
['IntlBackslash', 49],
['Semicolon', 51],
['Quote', 52],
['Backquote', 53],
['KeyTilde', 53],
['Comma', 54],
['Period', 55],
['KeyDot', 55],
['Slash', 56],
['CapsLock', 57],
['F1', 58],
['F2', 59],
['F3', 60],
['F4', 61],
['F5', 62],
['F6', 63],
['F7', 64],
['F8', 65],
['F9', 66],
['F10', 67],
['F11', 68],
['F12', 69],
['F13', 70],
['PrintScreen', 70],
['ScrollLock', 71],
['Pause', 72],
['Insert', 73],
['Home', 74],
['PageUp', 75],
['Delete', 76],
['End', 77],
['PageDown', 78],
['ArrowRight', 79],
['ArrowLeft', 80],
['ArrowDown', 81],
['ArrowUp', 82],
['NumLock', 83],
['NumpadDivide', 84],
['NumpadMultiply', 85],
['NumpadSubtract', 86],
['NumpadAdd', 87],
['NumpadEnter', 88],
['Numpad1', 89],
['Numpad2', 90],
['Numpad3', 91],
['Numpad4', 92],
['Numpad5', 93],
['Numpad6', 94],
['Numpad7', 95],
['Numpad8', 96],
['Numpad9', 97],
['Numpad0', 98],
['NumpadDecimal', 99],
['KeyKpDot', 99],
['Menu', 118],
['ControlLeft', 224],
['ShiftLeft', 225],
['AltLeft', 226],
['MetaLeft', 227],
['WinLeft', 227],
['ControlRight', 228],
['ShiftRight', 229],
['AltRight', 230],
['MetaRight', 231],
['WinRight', 231]
]);

View File

@@ -0,0 +1,92 @@
import type { Resolution } from '@/types';
const LANGUAGE_KEY = 'nanokvm-usb-language';
const VIDEO_DEVICE_ID_KEY = 'nanokvm-usb-video-device-id';
const VIDEO_RESOLUTION_KEY = 'nanokvm-usb-video-resolution';
const CUSTOM_RESOLUTION_KEY = 'nanokvm-usb-custom-resolution';
const IS_MENU_OPEN_KEY = 'nanokvm-is-menu-open';
const MOUSE_STYLE_KEY = 'nanokvm-usb-mouse-style';
const MOUSE_MODE_KEY = 'nanokvm-usb-mouse-mode';
const MOUSE_SCROLL_DIRECTION_KEY = 'nanokvm-usb-mouse-scroll-direction';
export function getLanguage() {
return localStorage.getItem(LANGUAGE_KEY);
}
export function setLanguage(language: string) {
localStorage.setItem(LANGUAGE_KEY, language);
}
export function setVideoDevice(id: string) {
localStorage.setItem(VIDEO_DEVICE_ID_KEY, id);
}
export function getVideoDevice() {
return localStorage.getItem(VIDEO_DEVICE_ID_KEY);
}
export function setVideoResolution(width: number, height: number) {
localStorage.setItem(VIDEO_RESOLUTION_KEY, window.JSON.stringify({ width, height }));
}
export function getVideoResolution() {
const resolution = localStorage.getItem(VIDEO_RESOLUTION_KEY);
if (!resolution) return;
return window.JSON.parse(resolution) as Resolution;
}
export function getCustomResolutions() {
const resolution = localStorage.getItem(CUSTOM_RESOLUTION_KEY);
if (!resolution) return;
return window.JSON.parse(resolution) as Resolution[];
}
export function setCustomResolution(width: number, height: number) {
const resolutions = getCustomResolutions();
if (resolutions?.some((r) => r.width === width && r.height === height)) {
return;
}
const data = resolutions ? [...resolutions, { width, height }] : [{ width, height }];
localStorage.setItem(CUSTOM_RESOLUTION_KEY, window.JSON.stringify(data));
}
export function removeCustomResolutions() {
localStorage.removeItem(CUSTOM_RESOLUTION_KEY);
}
export function getIsMenuOpen(): boolean {
const state = localStorage.getItem(IS_MENU_OPEN_KEY);
if (!state) {
return true;
}
return state === 'true';
}
export function setIsMenuOpen(isOpen: boolean) {
localStorage.setItem(IS_MENU_OPEN_KEY, isOpen ? 'true' : 'false');
}
export function getMouseStyle() {
return localStorage.getItem(MOUSE_STYLE_KEY);
}
export function setMouseStyle(mouse: string) {
localStorage.setItem(MOUSE_STYLE_KEY, mouse);
}
export function getMouseMode() {
return localStorage.getItem(MOUSE_MODE_KEY);
}
export function setMouseMode(mouse: string) {
localStorage.setItem(MOUSE_MODE_KEY, mouse);
}
export function getMouseScrollDirection(): string | null {
return localStorage.getItem(MOUSE_SCROLL_DIRECTION_KEY);
}
export function setMouseScrollDirection(mouse: string): void {
localStorage.setItem(MOUSE_SCROLL_DIRECTION_KEY, mouse);
}

18
browser/src/main.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { StrictMode } from 'react';
import { ConfigProvider, theme } from 'antd';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import './i18n';
import './assets/index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ConfigProvider theme={{ algorithm: theme.darkAlgorithm }}>
<div className="flex h-screen w-screen flex-col items-center justify-center overflow-hidden">
<App />
</div>
</ConfigProvider>
</StrictMode>
);

4
browser/src/types.ts Normal file
View File

@@ -0,0 +1,4 @@
export type Resolution = {
width: number;
height: number;
};

4
browser/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
/// <reference types="vite/client" />
interface Navigator {
serial?: Serial;
}

View File

@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {}
},
plugins: []
};

30
browser/tsconfig.app.json Normal file
View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

7
browser/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

2
browser/vite.config.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;

13
browser/vite.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import viteTsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [react(), viteTsconfigPaths()],
server: {
port: 3001
},
build: {
chunkSizeWarningLimit: 1024
}
});