mirror of
https://github.com/sipeed/NanoKVM-USB.git
synced 2026-09-11 05:59:57 -05:00
@@ -106,7 +106,7 @@ const App = () => {
|
||||
|
||||
<video
|
||||
id="video"
|
||||
className={clsx('block select-none', mouseStyle)}
|
||||
className={clsx('block min-h-[480px] min-w-[640px] select-none', mouseStyle)}
|
||||
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'scale-down' }}
|
||||
autoPlay
|
||||
playsInline
|
||||
|
||||
@@ -1,40 +1,22 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Modal, Select } from 'antd';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { Modal } from 'antd';
|
||||
import { 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';
|
||||
import { serialStateAtom, videoStateAtom } from '@/jotai/device.ts';
|
||||
|
||||
type MediaDevice = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
import { SerialPort } from './serial-port';
|
||||
import { Video } from './video';
|
||||
|
||||
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 videoState = useAtomValue(videoStateAtom);
|
||||
const serialState = useAtomValue(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') {
|
||||
@@ -46,102 +28,11 @@ export const DeviceModal = () => {
|
||||
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>
|
||||
)}
|
||||
<Video setErrMsg={setErrMsg} />
|
||||
<SerialPort setErrMsg={setErrMsg} />
|
||||
|
||||
{errMsg && <span className="text-xs text-red-500">{errMsg}</span>}
|
||||
</div>
|
||||
|
||||
59
browser/src/components/device-modal/serial-port.tsx
Normal file
59
browser/src/components/device-modal/serial-port.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { serialStateAtom } from '@/jotai/device.ts';
|
||||
import { device } from '@/libs/device';
|
||||
|
||||
type SerialPortProps = {
|
||||
setErrMsg: (msg: string) => void;
|
||||
};
|
||||
|
||||
export const SerialPort = ({ setErrMsg }: SerialPortProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [serialState, setSerialState] = useAtom(serialStateAtom);
|
||||
|
||||
useEffect(() => {
|
||||
checkSerialPort();
|
||||
}, []);
|
||||
|
||||
function checkSerialPort() {
|
||||
const isWebSerialSupported = 'serial' in navigator;
|
||||
const state = isWebSerialSupported ? 'disconnected' : 'notSupported';
|
||||
setSerialState(state);
|
||||
}
|
||||
|
||||
async function selectSerialPort() {
|
||||
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 (
|
||||
<>
|
||||
{serialState !== 'notSupported' && (
|
||||
<Button
|
||||
type="primary"
|
||||
className="w-[250px]"
|
||||
loading={serialState === 'connecting'}
|
||||
onClick={selectSerialPort}
|
||||
>
|
||||
{t('modal.selectSerial')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
109
browser/src/components/device-modal/video.tsx
Normal file
109
browser/src/components/device-modal/video.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { resolutionAtom, videoDeviceIdAtom, videoStateAtom } from '@/jotai/device.ts';
|
||||
import { camera } from '@/libs/camera';
|
||||
import * as storage from '@/libs/storage';
|
||||
import type { MediaDevice } from '@/types';
|
||||
|
||||
type VideoProps = {
|
||||
setErrMsg: (msg: string) => void;
|
||||
};
|
||||
|
||||
export const Video = ({ setErrMsg }: VideoProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const [videoDeviceId, setVideoDeviceId] = useAtom(videoDeviceIdAtom);
|
||||
const [videoState, setVideoState] = useAtom(videoStateAtom);
|
||||
|
||||
const [devices, setDevices] = useState<MediaDevice[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getDevices();
|
||||
}, []);
|
||||
|
||||
async function getDevices() {
|
||||
try {
|
||||
const allDevices = await navigator.mediaDevices.enumerateDevices();
|
||||
const videoDevices = allDevices.filter((device) => device.kind === 'videoinput');
|
||||
const audioDevices = allDevices.filter((device) => device.kind === 'audioinput');
|
||||
|
||||
const mediaDevices = videoDevices.map((videoDevice) => {
|
||||
const device: MediaDevice = {
|
||||
videoId: videoDevice.deviceId,
|
||||
videoName: videoDevice.label
|
||||
};
|
||||
|
||||
if (videoDevice.groupId) {
|
||||
const matchedAudioDevice = audioDevices.find(
|
||||
(audioDevice) => audioDevice.groupId === videoDevice.groupId
|
||||
);
|
||||
if (matchedAudioDevice) {
|
||||
device.audioId = matchedAudioDevice.deviceId;
|
||||
device.audioName = matchedAudioDevice.label;
|
||||
}
|
||||
}
|
||||
|
||||
return device;
|
||||
});
|
||||
|
||||
setDevices(mediaDevices);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
setErrMsg(t('camera.failed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function selectVideo(videoId: string) {
|
||||
if (videoState === 'connecting') return;
|
||||
|
||||
if (!videoId) {
|
||||
setVideoDeviceId('');
|
||||
return;
|
||||
}
|
||||
|
||||
const device = devices.find((d) => d.videoId === videoId);
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
|
||||
setVideoState('connecting');
|
||||
setErrMsg('');
|
||||
|
||||
try {
|
||||
await camera.open(videoId, resolution.width, resolution.height, device.audioId);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
setErrMsg(t('camera.failed'));
|
||||
}
|
||||
|
||||
const video = document.getElementById('video') as HTMLVideoElement;
|
||||
if (!video) return;
|
||||
|
||||
video.srcObject = camera.getStream();
|
||||
|
||||
setVideoState('connected');
|
||||
setVideoDeviceId(videoId);
|
||||
storage.setVideoDevice(videoId);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={videoDeviceId || undefined}
|
||||
style={{ width: 250 }}
|
||||
options={devices}
|
||||
fieldNames={{
|
||||
value: 'videoId',
|
||||
label: 'videoName'
|
||||
}}
|
||||
allowClear={true}
|
||||
loading={videoState === 'connecting'}
|
||||
placeholder={t('modal.selectVideo')}
|
||||
onChange={selectVideo}
|
||||
onClick={getDevices}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { resolutionAtom, videoDeviceIdAtom } from '@/jotai/device.ts';
|
||||
import { camera } from '@/libs/camera';
|
||||
import * as storage from '@/libs/storage';
|
||||
import type { MediaDevice } from '@/types';
|
||||
|
||||
export const Device = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -15,30 +16,57 @@ export const Device = () => {
|
||||
const resolution = useAtomValue(resolutionAtom);
|
||||
const [videoDeviceId, setVideoDeviceId] = useAtom(videoDeviceIdAtom);
|
||||
|
||||
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
const [devices, setDevices] = useState<MediaDevice[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
navigator.mediaDevices.enumerateDevices().then((deviceInfo) => {
|
||||
const videoDevices = deviceInfo.filter((device) => device.kind === 'videoinput');
|
||||
setDevices(videoDevices);
|
||||
});
|
||||
getDevices();
|
||||
}, []);
|
||||
|
||||
async function selectDevice(deviceId: string) {
|
||||
async function getDevices() {
|
||||
try {
|
||||
const allDevices = await navigator.mediaDevices.enumerateDevices();
|
||||
const videoDevices = allDevices.filter((device) => device.kind === 'videoinput');
|
||||
const audioDevices = allDevices.filter((device) => device.kind === 'audioinput');
|
||||
|
||||
const mediaDevices = videoDevices.map((videoDevice) => {
|
||||
const device: MediaDevice = {
|
||||
videoId: videoDevice.deviceId,
|
||||
videoName: videoDevice.label
|
||||
};
|
||||
|
||||
if (videoDevice.groupId) {
|
||||
const matchedAudioDevice = audioDevices.find(
|
||||
(audioDevice) => audioDevice.groupId === videoDevice.groupId
|
||||
);
|
||||
if (matchedAudioDevice) {
|
||||
device.audioId = matchedAudioDevice.deviceId;
|
||||
device.audioName = matchedAudioDevice.label;
|
||||
}
|
||||
}
|
||||
|
||||
return device;
|
||||
});
|
||||
|
||||
setDevices(mediaDevices);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectDevice(device: MediaDevice) {
|
||||
if (isLoading) return;
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const success = await camera.open(deviceId, resolution.width, resolution.height);
|
||||
if (!success) return;
|
||||
await camera.open(device.videoId, resolution.width, resolution.height, device.audioId);
|
||||
|
||||
const video = document.getElementById('video') as HTMLVideoElement;
|
||||
if (!video) return;
|
||||
video.srcObject = camera.getStream();
|
||||
|
||||
setVideoDeviceId(deviceId);
|
||||
storage.setVideoDevice(deviceId);
|
||||
setVideoDeviceId(device.videoId);
|
||||
storage.setVideoDevice(device.videoId);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -46,16 +74,16 @@ export const Device = () => {
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{devices.map((device: MediaDeviceInfo) => (
|
||||
{devices.map((device) => (
|
||||
<div
|
||||
key={device.deviceId}
|
||||
key={device.videoId}
|
||||
className={clsx(
|
||||
'cursor-pointer rounded px-2 py-1.5 hover:bg-neutral-700/60',
|
||||
device.deviceId === videoDeviceId ? 'text-blue-500' : 'text-white'
|
||||
device.videoId === videoDeviceId ? 'text-blue-500' : 'text-white'
|
||||
)}
|
||||
onClick={() => selectDevice(device.deviceId)}
|
||||
onClick={() => selectDevice(device)}
|
||||
>
|
||||
{device.label}
|
||||
{device.videoName}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -64,8 +64,12 @@ export const Resolution = () => {
|
||||
}
|
||||
|
||||
async function updateResolution(w: number, h: number) {
|
||||
const success = await camera.open('', w, h);
|
||||
if (!success) return;
|
||||
try {
|
||||
await camera.updateResolution(w, h);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
return;
|
||||
}
|
||||
|
||||
const video = document.getElementById('video') as HTMLVideoElement;
|
||||
if (!video) return;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const languages = [
|
||||
{ key: 'en', name: 'English' },
|
||||
{ key: 'ru', name: 'Русский' },
|
||||
{ key: 'zh', name: '中文' }
|
||||
];
|
||||
|
||||
|
||||
65
browser/src/i18n/locales/ru.ts
Normal file
65
browser/src/i18n/locales/ru.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
const ru = {
|
||||
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 ru;
|
||||
@@ -2,35 +2,30 @@ class Camera {
|
||||
id: string = '';
|
||||
width: number = 1920;
|
||||
height: number = 1080;
|
||||
audioId: string = '';
|
||||
stream: MediaStream | null = null;
|
||||
|
||||
public async open(id?: string, width?: number, height?: number): Promise<boolean> {
|
||||
public async open(id: string, width: number, height: number, audioId?: string) {
|
||||
if (!id && !this.id) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.close();
|
||||
this.close();
|
||||
|
||||
const constraints = {
|
||||
video: {
|
||||
deviceId: { exact: id || this.id },
|
||||
width: { ideal: width || this.width },
|
||||
height: { ideal: height || this.height }
|
||||
},
|
||||
audio: true
|
||||
};
|
||||
const constraints = {
|
||||
video: { deviceId: { exact: id }, width: { ideal: width }, height: { ideal: height } },
|
||||
audio: audioId ? { deviceId: { exact: audioId } } : false
|
||||
};
|
||||
|
||||
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
if (id) this.id = id;
|
||||
if (width) this.width = width;
|
||||
if (height) this.height = height;
|
||||
this.id = id;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
if (audioId) this.audioId = audioId;
|
||||
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
return false;
|
||||
}
|
||||
public async updateResolution(width: number, height: number) {
|
||||
return this.open(this.id, width, height, this.audioId);
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
|
||||
@@ -2,3 +2,10 @@ export type Resolution = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type MediaDevice = {
|
||||
videoId: string;
|
||||
videoName: string;
|
||||
audioId?: string;
|
||||
audioName?: string;
|
||||
};
|
||||
|
||||
@@ -2,8 +2,17 @@
|
||||
|
||||
This is the NanoKVM-USB desktop version project.
|
||||
|
||||
|
||||
## Development
|
||||
|
||||
Linux build tool chain:
|
||||
|
||||
```shell
|
||||
sudo apt update
|
||||
sudo apt install -y build-essential python3 libudev-dev
|
||||
echo "python=/usr/bin/python3.10" >> ~/.npmrc # This should match you pyton version.
|
||||
```
|
||||
|
||||
```shell
|
||||
cd desktop
|
||||
pnpm install
|
||||
@@ -22,3 +31,10 @@ pnpm build:mac
|
||||
# For Linux
|
||||
pnpm build:linux
|
||||
```
|
||||
|
||||
# For Linux run and install
|
||||
```shell
|
||||
dpkg -i dist/nanokvm-usb_1.0.0_amd64.deb
|
||||
sudo chown root:root /opt/NanoKVM-USB/chrome-sandbox
|
||||
sudo chmod 4755 /opt/NanoKVM-USB/chrome-sandbox
|
||||
```
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
appId: com.sipeed.usbkvm
|
||||
productName: NanoKVM-USB
|
||||
|
||||
directories:
|
||||
buildResources: build
|
||||
|
||||
files:
|
||||
- '!**/.vscode/*'
|
||||
- '!src/*'
|
||||
@@ -9,30 +11,39 @@ files:
|
||||
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
|
||||
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
|
||||
win:
|
||||
executableName: NanoKVM-USB
|
||||
|
||||
nsis:
|
||||
onClick: false
|
||||
oneClick: false # show installer wizard
|
||||
perMachine: true # install for all users
|
||||
allowToChangeInstallationDirectory: true
|
||||
allowElevation: true # request elevation when needed
|
||||
artifactName: ${productName}-${version}-setup.${ext}
|
||||
shortcutName: ${productName}
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
createDesktopShortcut: true
|
||||
createStartMenuShortcut: true
|
||||
|
||||
mac:
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
extendInfo:
|
||||
- NSCameraUsageDescription: Application requests access to the device's camera.
|
||||
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
|
||||
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
|
||||
NSCameraUsageDescription: Application requests access to the device's camera.
|
||||
NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||
NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
|
||||
NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
|
||||
hardenedRuntime: true
|
||||
gatekeeperAssess: false
|
||||
notarize: false
|
||||
|
||||
dmg:
|
||||
artifactName: ${productName}-${version}.${ext}
|
||||
sign: true
|
||||
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
@@ -40,13 +51,18 @@ linux:
|
||||
- deb
|
||||
maintainer: sipeed.com
|
||||
category: Utility
|
||||
|
||||
appImage:
|
||||
artifactName: ${productName}-${version}.${ext}
|
||||
|
||||
npmRebuild: false
|
||||
|
||||
afterSign: "./notarize.js"
|
||||
|
||||
publish:
|
||||
provider: github
|
||||
owner: sipeed
|
||||
repo: NanoKVM-USB
|
||||
|
||||
electronDownload:
|
||||
mirror: https://npmmirror.com/mirrors/electron/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { join } from 'path'
|
||||
import { electronApp, is, optimizer } from '@electron-toolkit/utils'
|
||||
import { app, BrowserWindow, shell } from 'electron'
|
||||
import { app, BrowserWindow, shell, session } from 'electron'
|
||||
import log from 'electron-log/main'
|
||||
|
||||
import icon from '../../resources/icon.png?asset'
|
||||
@@ -26,6 +26,7 @@ function createWindow(): void {
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow.show()
|
||||
mainWindow.maximize()
|
||||
//mainWindow.webContents.openDevTools()
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
@@ -43,6 +44,14 @@ function createWindow(): void {
|
||||
app.whenReady().then(() => {
|
||||
electronApp.setAppUserModelId('com.sipeed.usbkvm')
|
||||
|
||||
session.defaultSession.setPermissionRequestHandler((_, permission, callback) => {
|
||||
if (permission === 'media') {
|
||||
callback(true)
|
||||
} else {
|
||||
callback(false)
|
||||
}
|
||||
})
|
||||
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
|
||||
@@ -114,7 +114,7 @@ const App = (): ReactElement => {
|
||||
|
||||
<video
|
||||
id="video"
|
||||
className={clsx('block select-none', mouseStyle)}
|
||||
className={clsx('block min-h-[480px] min-w-[640px] select-none', mouseStyle)}
|
||||
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'scale-down' }}
|
||||
autoPlay
|
||||
playsInline
|
||||
|
||||
@@ -5,12 +5,8 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { resolutionAtom, videoDeviceIdAtom, videoStateAtom } from '@renderer/jotai/device'
|
||||
import { camera } from '@renderer/libs/camera'
|
||||
import { getVideoDevice, setVideoDevice } from '@renderer/libs/storage'
|
||||
|
||||
type MediaDevice = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
import * as storage from '@renderer/libs/storage'
|
||||
import type { MediaDevice } from '@renderer/types'
|
||||
|
||||
type VideoProps = {
|
||||
setMsg: (msg: string) => void
|
||||
@@ -23,43 +19,73 @@ export const Video = ({ setMsg }: VideoProps): ReactElement => {
|
||||
const [videoState, setVideoState] = useAtom(videoStateAtom)
|
||||
const [videoDeviceId, setVideoDeviceId] = useAtom(videoDeviceIdAtom)
|
||||
|
||||
const [videoDevices, setVideoDevices] = useState<MediaDevice[]>([])
|
||||
const [devices, setDevices] = useState<MediaDevice[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
getVideoDevices(true)
|
||||
getDevices(true)
|
||||
}, [])
|
||||
|
||||
async function getVideoDevices(autoOpen: boolean): Promise<void> {
|
||||
const allDevices = await navigator.mediaDevices.enumerateDevices()
|
||||
async function getDevices(autoOpen: boolean): Promise<void> {
|
||||
try {
|
||||
const allDevices = await navigator.mediaDevices.enumerateDevices()
|
||||
const videoDevices = allDevices.filter((device) => device.kind === 'videoinput')
|
||||
const audioDevices = allDevices.filter((device) => device.kind === 'audioinput')
|
||||
|
||||
const devices = allDevices
|
||||
.filter((device) => device.kind === 'videoinput')
|
||||
.map((device) => ({ value: device.deviceId, label: device.label }))
|
||||
const mediaDevices = videoDevices.map((videoDevice) => {
|
||||
const device: MediaDevice = {
|
||||
videoId: videoDevice.deviceId,
|
||||
videoName: videoDevice.label
|
||||
}
|
||||
|
||||
setVideoDevices(devices)
|
||||
if (videoDevice.groupId) {
|
||||
const matchedAudioDevice = audioDevices.find(
|
||||
(audioDevice) => audioDevice.groupId === videoDevice.groupId
|
||||
)
|
||||
if (matchedAudioDevice) {
|
||||
device.audioId = matchedAudioDevice.deviceId
|
||||
device.audioName = matchedAudioDevice.label
|
||||
}
|
||||
}
|
||||
|
||||
if (autoOpen) {
|
||||
const deviceId = getVideoDevice()
|
||||
if (deviceId && devices.some((device) => device.value === deviceId)) {
|
||||
await selectVideo(deviceId)
|
||||
return device
|
||||
})
|
||||
|
||||
setDevices(mediaDevices)
|
||||
|
||||
if (autoOpen) {
|
||||
const videoId = storage.getVideoDevice()
|
||||
if (!videoId) return
|
||||
const device = mediaDevices.find((d) => d.videoId === videoId)
|
||||
if (!device) return
|
||||
await openCamera(device.videoId, device.audioId)
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
setMsg(t('camera.failed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function selectVideo(deviceId: string): Promise<void> {
|
||||
if (!deviceId) {
|
||||
async function selectDevice(videoId: string): Promise<void> {
|
||||
if (!videoId) {
|
||||
setVideoDeviceId('')
|
||||
return
|
||||
}
|
||||
|
||||
if (videoState === 'connecting') return
|
||||
|
||||
setVideoState('connecting')
|
||||
setMsg('')
|
||||
|
||||
const device = devices.find((d) => d.videoId === videoId)
|
||||
if (!device) {
|
||||
return
|
||||
}
|
||||
|
||||
await openCamera(device.videoId, device.audioId)
|
||||
}
|
||||
|
||||
async function openCamera(videoId: string, audioId?: string): Promise<void> {
|
||||
try {
|
||||
const success = await camera.open(deviceId, resolution.width, resolution.height)
|
||||
if (!success) return
|
||||
await camera.open(videoId, resolution.width, resolution.height, audioId)
|
||||
|
||||
const video = document.getElementById('video') as HTMLVideoElement
|
||||
if (!video) return
|
||||
@@ -67,8 +93,8 @@ export const Video = ({ setMsg }: VideoProps): ReactElement => {
|
||||
video.srcObject = camera.getStream()
|
||||
|
||||
setVideoState('connected')
|
||||
setVideoDeviceId(deviceId)
|
||||
setVideoDevice(deviceId)
|
||||
setVideoDeviceId(videoId)
|
||||
storage.setVideoDevice(videoId)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : t('camera.failed')
|
||||
setMsg(msg)
|
||||
@@ -79,12 +105,16 @@ export const Video = ({ setMsg }: VideoProps): ReactElement => {
|
||||
<Select
|
||||
value={videoDeviceId || undefined}
|
||||
style={{ width: 280 }}
|
||||
options={videoDevices}
|
||||
options={devices}
|
||||
fieldNames={{
|
||||
value: 'videoId',
|
||||
label: 'videoName'
|
||||
}}
|
||||
allowClear={true}
|
||||
loading={videoState === 'connecting'}
|
||||
placeholder={t('modal.selectVideo')}
|
||||
onChange={selectVideo}
|
||||
onClick={() => getVideoDevices(false)}
|
||||
onChange={selectDevice}
|
||||
onClick={() => getDevices(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,36 +8,73 @@ import { useTranslation } from 'react-i18next'
|
||||
import { resolutionAtom, videoDeviceIdAtom } from '@renderer/jotai/device'
|
||||
import { camera } from '@renderer/libs/camera'
|
||||
import * as storage from '@renderer/libs/storage'
|
||||
import type { MediaDevice } from '@renderer/types'
|
||||
|
||||
export const Device = (): ReactElement => {
|
||||
const { t } = useTranslation()
|
||||
const resolution = useAtomValue(resolutionAtom)
|
||||
const [videoDeviceId, setVideoDeviceId] = useAtom(videoDeviceIdAtom)
|
||||
|
||||
const [devices, setDevices] = useState<MediaDeviceInfo[]>([])
|
||||
const [devices, setDevices] = useState<MediaDevice[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
navigator.mediaDevices.enumerateDevices().then((deviceInfo) => {
|
||||
const videoDevices = deviceInfo.filter((device) => device.kind === 'videoinput')
|
||||
setDevices(videoDevices)
|
||||
})
|
||||
getDevices()
|
||||
}, [])
|
||||
|
||||
async function selectDevice(deviceId: string): Promise<void> {
|
||||
async function getDevices(): Promise<void> {
|
||||
try {
|
||||
await navigator.mediaDevices.getUserMedia({ video: true })
|
||||
|
||||
const allDevices = await navigator.mediaDevices.enumerateDevices()
|
||||
const videoDevices = allDevices.filter((device) => device.kind === 'videoinput')
|
||||
const audioDevices = allDevices.filter((device) => device.kind === 'audioinput')
|
||||
|
||||
const mediaDevices = videoDevices.map((videoDevice) => {
|
||||
const device: MediaDevice = {
|
||||
videoId: videoDevice.deviceId,
|
||||
videoName: videoDevice.label
|
||||
}
|
||||
|
||||
if (videoDevice.groupId) {
|
||||
const matchedAudioDevice = audioDevices.find(
|
||||
(audioDevice) => audioDevice.groupId === videoDevice.groupId
|
||||
)
|
||||
if (matchedAudioDevice) {
|
||||
device.audioId = matchedAudioDevice.deviceId
|
||||
device.audioName = matchedAudioDevice.label
|
||||
}
|
||||
}
|
||||
|
||||
return device
|
||||
})
|
||||
|
||||
setDevices(mediaDevices)
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function selectDevice(device: MediaDevice): Promise<void> {
|
||||
if (isLoading) return
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const success = await camera.open(deviceId, resolution.width, resolution.height)
|
||||
if (!success) return
|
||||
await camera.open(device.videoId, resolution.width, resolution.height, device.audioId)
|
||||
|
||||
const video = document.getElementById('video') as HTMLVideoElement
|
||||
if (!video) return
|
||||
video.srcObject = camera.getStream()
|
||||
|
||||
setVideoDeviceId(deviceId)
|
||||
storage.setVideoDevice(deviceId)
|
||||
// Start playback explicitly
|
||||
try {
|
||||
await video.play()
|
||||
} catch (err) {
|
||||
console.error('video.play() failed:', err)
|
||||
}
|
||||
|
||||
setVideoDeviceId(device.videoId)
|
||||
storage.setVideoDevice(device.videoId)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -45,16 +82,16 @@ export const Device = (): ReactElement => {
|
||||
|
||||
const content = (
|
||||
<div className="max-h-[350px] overflow-y-auto">
|
||||
{devices.map((device: MediaDeviceInfo) => (
|
||||
{devices.map((device) => (
|
||||
<div
|
||||
key={device.deviceId}
|
||||
key={device.videoId}
|
||||
className={clsx(
|
||||
'cursor-pointer rounded px-2 py-1.5 hover:bg-neutral-700/60',
|
||||
device.deviceId === videoDeviceId ? 'text-blue-500' : 'text-white'
|
||||
device.videoId === videoDeviceId ? 'text-blue-500' : 'text-white'
|
||||
)}
|
||||
onClick={() => selectDevice(device.deviceId)}
|
||||
onClick={() => selectDevice(device)}
|
||||
>
|
||||
{device.label}
|
||||
{device.videoName}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -65,8 +65,12 @@ export const Resolution = (): ReactElement => {
|
||||
}
|
||||
|
||||
async function updateResolution(w: number, h: number): Promise<void> {
|
||||
const success = await camera.open('', w, h)
|
||||
if (!success) return
|
||||
try {
|
||||
await camera.updateResolution(w, h)
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
return
|
||||
}
|
||||
|
||||
const video = document.getElementById('video') as HTMLVideoElement
|
||||
if (!video) return
|
||||
|
||||
@@ -2,35 +2,30 @@ class Camera {
|
||||
id: string = ''
|
||||
width: number = 1920
|
||||
height: number = 1080
|
||||
audioId: string = ''
|
||||
stream: MediaStream | null = null
|
||||
|
||||
public async open(id?: string, width?: number, height?: number): Promise<boolean> {
|
||||
public async open(id: string, width: number, height: number, audioId?: string): Promise<void> {
|
||||
if (!id && !this.id) {
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.close()
|
||||
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
|
||||
const constraints = {
|
||||
video: { deviceId: { exact: id }, width: { ideal: width }, height: { ideal: height } },
|
||||
audio: audioId ? { deviceId: { exact: audioId } } : false
|
||||
}
|
||||
|
||||
this.id = id
|
||||
this.width = width
|
||||
this.height = height
|
||||
if (audioId) this.audioId = audioId
|
||||
this.stream = await navigator.mediaDevices.getUserMedia(constraints)
|
||||
}
|
||||
|
||||
public async updateResolution(width: number, height: number): Promise<void> {
|
||||
return this.open(this.id, width, height, this.audioId)
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
|
||||
@@ -3,6 +3,13 @@ export type Resolution = {
|
||||
height: number
|
||||
}
|
||||
|
||||
export type MediaDevice = {
|
||||
videoId: string
|
||||
videoName: string
|
||||
audioId?: string
|
||||
audioName?: string
|
||||
}
|
||||
|
||||
export type Mouse = {
|
||||
left: boolean
|
||||
right: boolean
|
||||
|
||||
Reference in New Issue
Block a user