release the desktop version code

This commit is contained in:
wj-xiao
2025-04-03 14:05:47 +08:00
parent 5299d76392
commit df89990b47
81 changed files with 11880 additions and 0 deletions

9
desktop/.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

9
desktop/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules
dist
out
.DS_Store
.eslintcache
*.log*
.idea
.vscode

3
desktop/.npmrc Normal file
View File

@@ -0,0 +1,3 @@
electron_mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
shamefully-hoist=true

6
desktop/.prettierignore Normal file
View File

@@ -0,0 +1,6 @@
out
dist
pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json

26
desktop/.prettierrc.yaml Normal file
View File

@@ -0,0 +1,26 @@
singleQuote: true
semi: false
printWidth: 100
trailingComma: none
importOrder:
- ^(react/(.*)$)|^(react$)
- ^(next/(.*)$)|^(next$)
- <THIRD_PARTY_MODULES>
- ''
- ^@common/(.*)$
- ^@renderer/(.*)$
- ''
- '^[./]'
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

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.microphone</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>

BIN
desktop/build/icon.icns Normal file

Binary file not shown.

BIN
desktop/build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
desktop/build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,4 @@
provider: github
owner: sipeed
repo: NanoKVM-USB
updaterCacheDirName: nanokvm-usb-updater

View File

@@ -0,0 +1,52 @@
appId: com.sipeed.usbkvm
productName: NanoKVM-USB
directories:
buildResources: build
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.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
allowToChangeInstallationDirectory: true
artifactName: ${productName}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
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.
hardenedRuntime: true
gatekeeperAssess: false
notarize: false
dmg:
artifactName: ${productName}-${version}.${ext}
sign: true
linux:
target:
- AppImage
- snap
- 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/

View File

@@ -0,0 +1,22 @@
import { resolve } from 'path'
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()]
},
preload: {
plugins: [externalizeDepsPlugin()]
},
renderer: {
resolve: {
alias: {
'@common': resolve('src/common'),
'@renderer': resolve('src/renderer/src')
}
},
plugins: [react(), tailwindcss()]
}
})

31
desktop/eslint.config.mjs Normal file
View File

@@ -0,0 +1,31 @@
import tseslint from '@electron-toolkit/eslint-config-ts'
import eslintConfigPrettier from '@electron-toolkit/eslint-config-prettier'
import eslintPluginReact from 'eslint-plugin-react'
import eslintPluginReactHooks from 'eslint-plugin-react-hooks'
import eslintPluginReactRefresh from 'eslint-plugin-react-refresh'
export default tseslint.config(
{ ignores: ['**/node_modules', '**/dist', '**/out'] },
tseslint.configs.recommended,
eslintPluginReact.configs.flat.recommended,
eslintPluginReact.configs.flat['jsx-runtime'],
{
settings: {
react: {
version: 'detect'
}
}
},
{
files: ['**/*.{ts,tsx}'],
plugins: {
'react-hooks': eslintPluginReactHooks,
'react-refresh': eslintPluginReactRefresh
},
rules: {
...eslintPluginReactHooks.configs.recommended.rules,
...eslintPluginReactRefresh.configs.vite.rules
}
},
eslintConfigPrettier
)

18
desktop/notarize.js Normal file
View File

@@ -0,0 +1,18 @@
const { notarize } = require('@electron/notarize')
exports.default = async function notarizing(context) {
const { electronPlatformName, appOutDir } = context
if (electronPlatformName !== 'darwin') {
return
}
const appName = context.packager.appInfo.productFilename
return await notarize({
appBundleId: 'com.sipeed.usbkvm',
appPath: `${appOutDir}/${appName}.app`,
teamId: process.env.APPLE_TEAM_ID,
appleId: process.env.APPLE_ID,
appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD
})
}

71
desktop/package.json Normal file
View File

@@ -0,0 +1,71 @@
{
"name": "nanokvm-usb",
"version": "1.0.0",
"description": "NanoKVM-USB Desktop",
"main": "./out/main/index.js",
"author": "sipeed.com",
"homepage": "https://github.com/sipeed/NanoKVM-USB",
"scripts": {
"format": "prettier --write .",
"lint": "eslint --cache .",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux"
},
"dependencies": {
"@ant-design/icons": "^5.6.1",
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0",
"@tailwindcss/vite": "^4.0.6",
"antd": "^5.24.1",
"clsx": "^2.1.1",
"electron-log": "^5.3.1",
"electron-updater": "^6.3.9",
"i18next": "^24.2.2",
"jotai": "^2.12.1",
"lucide-react": "^0.476.0",
"react-i18next": "^15.4.1",
"react-responsive": "^10.0.0",
"react-simple-keyboard": "^3.8.49",
"serialport": "^13.0.0",
"tailwindcss": "^4.0.6",
"vaul": "^1.1.2"
},
"devDependencies": {
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/tsconfig": "^1.0.1",
"@electron/notarize": "^2.5.0",
"@ianvs/prettier-plugin-sort-imports": "^4.4.1",
"@types/node": "^22.13.4",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"electron": "^34.2.0",
"electron-builder": "^25.1.8",
"electron-vite": "^3.0.0",
"eslint": "^9.20.1",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"prettier": "^3.5.1",
"prettier-plugin-tailwindcss": "^0.6.11",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"typescript": "^5.7.3",
"vite": "^6.1.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"electron"
]
}
}

7878
desktop/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
desktop/resources/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,21 @@
export enum IpcEvents {
GET_APP_VERSION = 'get-app-version',
OPEN_EXTERNAL_RUL = 'open-external-url',
REQUEST_MEDIA_PERMISSIONS = 'request-media-permissions',
GET_SERIAL_PORTS = 'get-serial-ports',
OPEN_SERIAL_PORT = 'open-serial-port',
OPEN_SERIAL_PORT_RSP = 'open-serial-port-rsp',
CLOSE_SERIAL_PORT = 'close-serial-port',
SEND_KEYBOARD = 'send-keyboard',
SEND_MOUSE_RELATIVE = 'send-mouse-relative',
SEND_MOUSE_ABSOLUTE = 'send-mouse-absolute',
UPDATE_AVAILABLE = 'update-available',
UPDATE_NOT_AVAILABLE = 'update-not-available',
UPDATE_ERROR = 'update-error',
DOWNLOAD_PROGRESS = 'download-progress',
UPDATE_DOWNLOADED = 'update-downloaded',
CHECK_FOR_UPDATES = 'check-for-updates',
DOWNLOAD_UPDATE = 'download-update'
}

View File

@@ -0,0 +1,58 @@
import { CmdEvent, CmdPacket, InfoPacket } from './proto'
import { SerialPort } from './serial-port'
import { intToByte, intToLittleEndianList } from './utils'
export class Device {
addr: number
serialPort: SerialPort
constructor() {
this.addr = 0x00
this.serialPort = new SerialPort()
}
async getInfo(): Promise<InfoPacket> {
const data = new CmdPacket(this.addr, CmdEvent.GET_INFO).encode()
await this.serialPort.write(data)
const rsp = await this.serialPort.read(14)
const rspPacket = new CmdPacket(-1, -1, rsp)
return new InfoPacket(rspPacket.DATA)
}
async sendKeyboardData(modifier: number, key: number): Promise<void> {
const data = [modifier, 0x00, 0x00, 0x00, key, 0x00, 0x00, 0x00]
const cmdData = new CmdPacket(this.addr, CmdEvent.SEND_KB_GENERAL_DATA, data).encode()
await this.serialPort.write(cmdData)
}
async sendMouseRelativeData(key: number, x: number, y: number, scroll: number): Promise<void> {
const xByte = intToByte(x)
const yByte = intToByte(y)
const data = [0x01, key, xByte, yByte, scroll]
const cmdData = new CmdPacket(this.addr, CmdEvent.SEND_MS_REL_DATA, data).encode()
await this.serialPort.write(cmdData)
}
async sendMouseAbsoluteData(
key: number,
width: number,
height: number,
x: number,
y: number,
scroll: number
): Promise<void> {
const xAbs = width === 0 ? 0 : Math.floor((x * 4096) / width)
const xLittle = intToLittleEndianList(xAbs)
const yAbs = width === 0 ? 0 : Math.floor((y * 4096) / height)
const yLittle = intToLittleEndianList(yAbs)
const data = [0x02, key, ...xLittle, ...yLittle, scroll]
const cmdData = new CmdPacket(this.addr, CmdEvent.SEND_MS_ABS_DATA, data).encode()
await this.serialPort.write(cmdData)
}
}
export const device = new Device()

View File

@@ -0,0 +1,134 @@
import { getBit } from './utils'
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
}
}
export class InfoPacket {
CHIP_VERSION: string = 'V0.0'
IS_CONNECTED: boolean = false
NUM_LOCK: boolean = false
CAPS_LOCK: boolean = false
SCROLL_LOCK: boolean = false
constructor(data: number[]) {
if (data[0] < 0x30) {
throw new Error('version error')
}
const versionE = data[0] - 0x30
const version = 1.0 + versionE / 10
this.CHIP_VERSION = `V${version.toFixed(1)}`
this.IS_CONNECTED = data[1] !== 0
this.NUM_LOCK = getBit(data[2], 0) === 1
this.CAPS_LOCK = getBit(data[2], 1) === 1
this.SCROLL_LOCK = getBit(data[2], 2) === 1
}
}

View File

@@ -0,0 +1,81 @@
import { SerialPort as SP } from 'serialport'
export class SerialPort {
port: SP | null
readonly TIMEOUT = 500 // 500ms
constructor() {
this.port = null
}
async init(
path: string,
baudRate: number = 57600,
onOpen: (err: Error | null) => void
): Promise<void> {
try {
if (this.port?.isOpen) {
await this.close()
}
this.port = new SP({ path, baudRate }, (err) => {
if (err) {
console.error('Error opening port: ', err.message)
}
onOpen(err)
})
} catch (err) {
console.error('Error opening serial port:', err)
throw err
}
}
async write(data: number[]): Promise<void> {
if (!this.port?.isOpen) {
// throw new Error('Serial port not initialized')
return
}
const uint8Array = new Uint8Array(data)
this.port.write(uint8Array)
}
async read(minSize: number, sleep: number = 0): Promise<number[]> {
if (!this.port?.isOpen) {
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.port.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.port?.isOpen) {
try {
this.port.close()
} catch (error) {
console.error('close-serial-port error', error)
}
}
}
}

View File

@@ -0,0 +1,18 @@
export function getBit(number: number, bitPosition: number): number {
return (number >> bitPosition) & 1
}
export 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
}
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,42 @@
import { app, ipcMain, shell, systemPreferences } from 'electron'
import type { IpcMainEvent, OpenExternalOptions } from 'electron'
import { IpcEvents } from '../../common/ipc-events'
export function registerApp(): void {
ipcMain.handle(IpcEvents.GET_APP_VERSION, getAppVersion)
ipcMain.on(IpcEvents.OPEN_EXTERNAL_RUL, openExternalUrl)
ipcMain.handle(IpcEvents.REQUEST_MEDIA_PERMISSIONS, requestMediaPermissions)
}
function getAppVersion(): string {
return app.getVersion()
}
function openExternalUrl(_: IpcMainEvent, url: string, options?: OpenExternalOptions): void {
shell.openExternal(url, options).catch(console.error)
}
async function requestMediaPermissions(): Promise<{
camera: boolean
microphone: boolean
}> {
const camera = await grant('camera')
const microphone = await grant('microphone')
return { camera, microphone }
}
async function grant(media: 'camera' | 'microphone'): Promise<boolean> {
try {
const status = systemPreferences.getMediaAccessStatus(media)
if (status === 'granted') {
return true
}
return await systemPreferences.askForMediaAccess(media)
} catch (error) {
console.error('Error request permission:', error)
return false
}
}

View File

@@ -0,0 +1,3 @@
export * from './app'
export * from './serial-port'
export * from './updater'

View File

@@ -0,0 +1,89 @@
import { ipcMain, IpcMainInvokeEvent } from 'electron'
import { SerialPort } from 'serialport'
import { IpcEvents } from '../../common/ipc-events'
import { device } from '../device'
export function registerSerialPort(): void {
ipcMain.handle(IpcEvents.GET_SERIAL_PORTS, getSerialPorts)
ipcMain.handle(IpcEvents.OPEN_SERIAL_PORT, openSerialPort)
ipcMain.handle(IpcEvents.CLOSE_SERIAL_PORT, closeSerialPort)
ipcMain.handle(IpcEvents.SEND_KEYBOARD, sendKeyboard)
ipcMain.handle(IpcEvents.SEND_MOUSE_ABSOLUTE, sendMouseAbsolute)
ipcMain.handle(IpcEvents.SEND_MOUSE_RELATIVE, sendMouseRelative)
}
async function getSerialPorts(): Promise<string[]> {
try {
const ports = await SerialPort.list()
return ports.map((port) => port.path)
} catch (error) {
console.error('Error listing serial ports:', error)
return []
}
}
async function openSerialPort(
e: IpcMainInvokeEvent,
path: string,
baudRate = 57600
): Promise<boolean> {
try {
await device.serialPort.init(path, baudRate, (err) => {
const msg = err ? err.message : ''
e.sender.send(IpcEvents.OPEN_SERIAL_PORT_RSP, msg)
})
return true
} catch (error) {
console.error('Error opening serial port:', error)
return false
}
}
async function closeSerialPort(): Promise<boolean> {
try {
await device.serialPort.close()
return true
} catch (error) {
console.error('Error closing serial port:', error)
return false
}
}
async function sendKeyboard(_: IpcMainInvokeEvent, modifier: number, key: number): Promise<void> {
try {
await device.sendKeyboardData(modifier, key)
} catch (error) {
console.error('Error sending keyboard data:', error)
}
}
async function sendMouseRelative(
_: IpcMainInvokeEvent,
key: number,
x: number,
y: number,
scroll: number
): Promise<void> {
try {
await device.sendMouseRelativeData(key, x, y, scroll)
} catch (error) {
console.error('Error sending mouse relative data:', error)
}
}
async function sendMouseAbsolute(
_: IpcMainInvokeEvent,
key: number,
width: number,
height: number,
x: number,
y: number,
scroll: number
): Promise<void> {
try {
await device.sendMouseAbsoluteData(key, width, height, x, y, scroll)
} catch (error) {
console.error('Error sending mouse absolute data:', error)
}
}

View File

@@ -0,0 +1,50 @@
import { BrowserWindow, ipcMain } from 'electron'
import { autoUpdater, UpdateInfo } from 'electron-updater'
import { IpcEvents } from '../../common/ipc-events'
autoUpdater.autoDownload = false
autoUpdater.forceDevUpdateConfig = true
export function registerUpdater(win: BrowserWindow): void {
autoUpdater.on('update-available', (info) => {
win.webContents.send(IpcEvents.UPDATE_AVAILABLE, info)
})
autoUpdater.on('update-not-available', () => {
win.webContents.send(IpcEvents.UPDATE_NOT_AVAILABLE)
})
autoUpdater.on('error', (err) => {
console.error(err)
win.webContents.send(IpcEvents.UPDATE_ERROR)
})
autoUpdater.on('download-progress', (progressObj) => {
const percent = Math.ceil(progressObj.percent)
win.webContents.send(IpcEvents.DOWNLOAD_PROGRESS, percent)
})
autoUpdater.on('update-downloaded', () => {
win.webContents.send(IpcEvents.UPDATE_DOWNLOADED)
setImmediate(() => autoUpdater.quitAndInstall())
})
ipcMain.handle(IpcEvents.CHECK_FOR_UPDATES, async (): Promise<UpdateInfo | undefined> => {
try {
const result = await autoUpdater.checkForUpdates()
return result?.updateInfo
} catch (e) {
console.error(e)
return
}
})
ipcMain.on(IpcEvents.DOWNLOAD_UPDATE, () => {
try {
autoUpdater.downloadUpdate()
} catch (e) {
console.error(e)
}
})
}

66
desktop/src/main/index.ts Normal file
View File

@@ -0,0 +1,66 @@
import { join } from 'path'
import { electronApp, is, optimizer } from '@electron-toolkit/utils'
import { app, BrowserWindow, shell } from 'electron'
import log from 'electron-log/main'
import icon from '../../resources/icon.png?asset'
import * as events from './events'
console.error = log.error
let mainWindow: BrowserWindow
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 800,
height: 600,
show: false,
autoHideMenuBar: true,
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false
}
})
mainWindow.on('ready-to-show', () => {
mainWindow.show()
mainWindow.maximize()
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.sipeed.usbkvm')
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
events.registerApp()
events.registerSerialPort()
createWindow()
events.registerUpdater(mainWindow)
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})

8
desktop/src/preload/index.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
import { ElectronAPI } from '@electron-toolkit/preload'
declare global {
interface Window {
electron: ElectronAPI
api: unknown
}
}

View File

@@ -0,0 +1,22 @@
import { contextBridge } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
// Custom APIs for renderer
const api = {}
// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
// just add to the DOM global.
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
} catch (error) {
console.error(error)
}
} else {
// @ts-ignore (define in dts)
window.electron = electronAPI
// @ts-ignore (define in dts)
window.api = api
}

View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>NanoKVM-USB</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,128 @@
import { ReactElement, useEffect, useState } from 'react'
import { Result, Spin } from 'antd'
import clsx from 'clsx'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive'
import { IpcEvents } from '@common/ipc-events'
import { DeviceModal } from '@renderer/components/device-modal'
import { Keyboard } from '@renderer/components/keyboard'
import { Menu } from '@renderer/components/menu'
import { Mouse } from '@renderer/components/mouse'
import { VirtualKeyboard } from '@renderer/components/virtual-keyboard'
import { resolutionAtom, serialPortStateAtom, videoStateAtom } from '@renderer/jotai/device'
import { isKeyboardEnableAtom } from '@renderer/jotai/keyboard'
import { mouseStyleAtom } from '@renderer/jotai/mouse'
import { camera } from '@renderer/libs/camera'
import { getVideoResolution } from '@renderer/libs/storage'
import type { Resolution } from '@renderer/types'
type State = 'loading' | 'success' | 'failed'
const App = (): ReactElement => {
const { t } = useTranslation()
const isBigScreen = useMediaQuery({ minWidth: 850 })
const videoState = useAtomValue(videoStateAtom)
const serialPortState = useAtomValue(serialPortStateAtom)
const mouseStyle = useAtomValue(mouseStyleAtom)
const isKeyboardEnable = useAtomValue(isKeyboardEnableAtom)
const setResolution = useSetAtom(resolutionAtom)
const [state, setState] = useState<State>('loading')
const [isConnected, setIsConnected] = useState(false)
useEffect(() => {
const resolution = getVideoResolution()
if (resolution) {
setResolution(resolution)
}
requestMediaPermissions(resolution)
return (): void => {
camera.close()
window.electron.ipcRenderer.invoke(IpcEvents.CLOSE_SERIAL_PORT)
}
}, [])
useEffect(() => {
setIsConnected(videoState === 'connected' && serialPortState === 'connected')
}, [videoState, serialPortState])
async function requestMediaPermissions(resolution?: Resolution): Promise<void> {
try {
if (window.electron.process.platform === 'darwin') {
const res = await window.electron.ipcRenderer.invoke(IpcEvents.REQUEST_MEDIA_PERMISSIONS)
if (!res.camera) {
setState('failed')
return
}
} else {
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())
}
setState('success')
} catch (err) {
console.log('failed to request media permissions: ', err)
if (err instanceof Error && ['NotAllowedError', 'PermissionDeniedError'].includes(err.name)) {
setState('failed')
} else {
setState('success')
}
}
}
if (state === 'loading') {
return <Spin size="large" spinning={true} tip={t('camera.tip')} fullscreen />
}
if (state === 'failed') {
return (
<Result
status="info"
title={t('camera.denied')}
extra={[
<h2 key="desc" className="text-xl text-white">
{t('camera.authorize')}
</h2>
]}
/>
)
}
return (
<>
{isConnected ? (
<>
<Menu />
<Mouse />
{isKeyboardEnable && <Keyboard />}
</>
) : (
<DeviceModal />
)}
<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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,5 @@
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,9 @@
@import "./base.css";
@layer tailwind-base, antd;
@layer tailwind-base {
@tailwind base;
}
@import "tailwindcss";

View File

@@ -0,0 +1,26 @@
import { ReactElement, useState } from 'react'
import { Modal } from 'antd'
import { useTranslation } from 'react-i18next'
import { SerialPort } from './serial-port'
import { Video } from './video'
export const DeviceModal = (): ReactElement => {
const { t } = useTranslation()
const [errMsg, setErrMsg] = useState('')
return (
<Modal open={true} title={t('modal.title')} footer={null} closable={false} destroyOnClose>
<div className="flex flex-col items-center justify-center space-y-3 py-10">
<Video setMsg={setErrMsg} />
<div />
<SerialPort setMsg={setErrMsg} />
<div />
{errMsg && <span className="text-xs text-red-500">{errMsg}</span>}
</div>
</Modal>
)
}

View File

@@ -0,0 +1,88 @@
import { ReactElement, useEffect, useState } from 'react'
import { Select } from 'antd'
import { useAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { IpcEvents } from '@common/ipc-events'
import { serialPortAtom, serialPortStateAtom } from '@renderer/jotai/device'
import * as storage from '@renderer/libs/storage'
type Option = {
value: string
label: string
}
type SerialPortProps = {
setMsg: (msg: string) => void
}
export const SerialPort = ({ setMsg }: SerialPortProps): ReactElement => {
const { t } = useTranslation()
const [serialPort, setSerialPort] = useAtom(serialPortAtom)
const [serialPortState, setSerialPortState] = useAtom(serialPortStateAtom)
const [options, setOptions] = useState<Option[]>([])
const [isFailed, setIsFailed] = useState(false)
useEffect(() => {
getSerialPorts(true)
const rmListener = window.electron.ipcRenderer.on(IpcEvents.OPEN_SERIAL_PORT_RSP, (_, err) => {
if (err === '') {
setSerialPortState('connected')
} else {
setIsFailed(true)
setSerialPort('')
setSerialPortState('disconnected')
storage.setSerialPort('')
setMsg(err)
}
})
return (): void => {
rmListener()
}
}, [])
async function getSerialPorts(autoOpen: boolean): Promise<void> {
const serialPorts = await window.electron.ipcRenderer.invoke(IpcEvents.GET_SERIAL_PORTS)
setOptions(serialPorts.map((sp: string) => ({ value: sp, label: sp })))
if (autoOpen) {
const port = storage.getSerialPort()
if (port && serialPorts.includes(port)) {
await selectSerialPort(port)
}
}
}
async function selectSerialPort(port: string): Promise<void> {
if (serialPortState === 'connecting') return
setSerialPortState('connecting')
setIsFailed(false)
setMsg('')
const success = await window.electron.ipcRenderer.invoke(IpcEvents.OPEN_SERIAL_PORT, port)
if (success) {
setSerialPort(port)
storage.setSerialPort(port)
} else {
setSerialPortState('disconnected')
}
}
return (
<Select
value={serialPort || undefined}
style={{ width: 280 }}
options={options}
loading={serialPortState === 'connecting'}
status={isFailed ? 'error' : undefined}
placeholder={t('modal.selectSerial')}
onChange={selectSerialPort}
onClick={() => getSerialPorts(false)}
/>
)
}

View File

@@ -0,0 +1,90 @@
import { ReactElement, useEffect, useState } from 'react'
import { Select } from 'antd'
import { useAtom, useAtomValue } from 'jotai'
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
}
type VideoProps = {
setMsg: (msg: string) => void
}
export const Video = ({ setMsg }: VideoProps): ReactElement => {
const { t } = useTranslation()
const resolution = useAtomValue(resolutionAtom)
const [videoState, setVideoState] = useAtom(videoStateAtom)
const [videoDeviceId, setVideoDeviceId] = useAtom(videoDeviceIdAtom)
const [videoDevices, setVideoDevices] = useState<MediaDevice[]>([])
useEffect(() => {
getVideoDevices(true)
}, [])
async function getVideoDevices(autoOpen: boolean): Promise<void> {
const allDevices = await navigator.mediaDevices.enumerateDevices()
const devices = allDevices
.filter((device) => device.kind === 'videoinput')
.map((device) => ({ value: device.deviceId, label: device.label }))
setVideoDevices(devices)
if (autoOpen) {
const deviceId = getVideoDevice()
if (deviceId && devices.some((device) => device.value === deviceId)) {
await selectVideo(deviceId)
}
}
}
async function selectVideo(deviceId: string): Promise<void> {
if (!deviceId) {
setVideoDeviceId('')
return
}
if (videoState === 'connecting') return
setVideoState('connecting')
setMsg('')
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)
setVideoDevice(deviceId)
} catch (err) {
const msg = err instanceof Error ? err.message : t('camera.failed')
setMsg(msg)
}
}
return (
<Select
value={videoDeviceId || undefined}
style={{ width: 280 }}
options={videoDevices}
allowClear={true}
loading={videoState === 'connecting'}
placeholder={t('modal.selectVideo')}
onChange={selectVideo}
onClick={() => getVideoDevices(false)}
/>
)
}

View File

@@ -0,0 +1,82 @@
import { ReactElement, useEffect, useRef } from 'react'
import { IpcEvents } from '@common/ipc-events'
import { KeyboardCodes } from '@renderer/libs/keyboard'
export const Keyboard = (): ReactElement => {
const modifierKeys = 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)
// press button
async function handleKeyDown(event: KeyboardEvent): Promise<void> {
event.preventDefault()
event.stopPropagation()
lastKeyRef.current = event
if (modifierKeys.has(event.key)) {
pressedKeysRef.current.add(event.code)
return
}
await sendKeyDown(event)
}
// release button
async function handleKeyUp(event: KeyboardEvent): Promise<void> {
event.preventDefault()
event.stopPropagation()
if (modifierKeys.has(event.key) && lastKeyRef.current?.code === event.code) {
await sendKeyDown(lastKeyRef.current)
lastKeyRef.current = undefined
pressedKeysRef.current.clear()
}
await send(0, 0x00)
}
return (): void => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
}
}, [])
async function sendKeyDown(event: KeyboardEvent): Promise<void> {
const code = KeyboardCodes.get(event.code)
if (!code) return
const modifier = getModifier(event)
await send(modifier, code)
}
async function send(modifier: number, key: number): Promise<void> {
await window.electron.ipcRenderer.invoke(IpcEvents.SEND_KEYBOARD, modifier, key)
}
function getModifier(e: KeyboardEvent): number {
const pressedKeys = [
e.ctrlKey && pressedKeysRef.current.has('ControlLeft'),
e.shiftKey && pressedKeysRef.current.has('ShiftLeft'),
e.altKey && pressedKeysRef.current.has('AltLeft'),
e.metaKey && pressedKeysRef.current.has('MetaLeft'),
e.ctrlKey && pressedKeysRef.current.has('ControlRight'),
e.shiftKey && pressedKeysRef.current.has('ShiftRight'),
e.altKey && pressedKeysRef.current.has('AltRight'),
e.metaKey && pressedKeysRef.current.has('MetaRight')
]
return pressedKeys.reduce((acc, isPressed, bit) => (isPressed ? acc | (1 << bit) : acc), 0)
}
return <></>
}

View File

@@ -0,0 +1,63 @@
import { ReactElement, useEffect, useState } from 'react'
import { Divider } from 'antd'
import clsx from 'clsx'
import { MenuIcon, XIcon } from 'lucide-react'
import * as storage from '@renderer/libs/storage'
import { Keyboard } from './keyboard'
import { Mouse } from './mouse'
import { SerialPort } from './serial-port'
import { Settings } from './settings'
import { Video } from './video'
export const Menu = (): ReactElement => {
const [isMenuOpen, setIsMenuOpen] = useState(false)
useEffect(() => {
const isOpen = storage.getIsMenuOpen()
setIsMenuOpen(isOpen)
}, [])
function toggleMenu(): void {
setIsMenuOpen(!isMenuOpen)
}
return (
<div className="fixed top-[10px] left-1/2 z-[1000] -translate-x-1/2">
<div className="sticky top-[10px]">
<div
className={clsx(
'h-[34px] items-center justify-between rounded bg-neutral-800/70 px-2',
isMenuOpen ? 'flex' : 'hidden'
)}
>
<Video />
<SerialPort />
<Divider type="vertical" className="px-[2px]" />
<Mouse />
<Keyboard />
<Divider type="vertical" className="px-[2px]" />
<Settings />
<div
className="flex h-[28px] cursor-pointer items-center justify-center rounded px-2 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/70 hover:bg-neutral-800 hover:text-white"
onClick={toggleMenu}
>
<MenuIcon size={18} />
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { ReactElement, useState } from 'react'
import { Popover } from 'antd'
import { KeyboardIcon } from 'lucide-react'
import { Paste } from './paste'
import { VirtualKeyboard } from './virtual-keyboard'
export const Keyboard = (): ReactElement => {
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] cursor-pointer items-center justify-center rounded px-2 text-white hover:bg-neutral-700/70">
<KeyboardIcon size={18} />
</div>
</Popover>
)
}

View File

@@ -0,0 +1,51 @@
import { ReactElement, useState } from 'react'
import { ClipboardIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { IpcEvents } from '@common/ipc-events'
import { CharCodes, ShiftChars } from '@renderer/libs/keyboard'
export const Paste = (): ReactElement => {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(false)
async function paste(): Promise<void> {
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 modifier = (ascii >= 65 && ascii <= 90) || ShiftChars.has(ascii) ? 2 : 0
await send(modifier, code)
await send(0, 0)
}
} catch (e) {
console.log(e)
} finally {
setIsLoading(false)
}
}
async function send(modifier: number, key: number): Promise<void> {
await window.electron.ipcRenderer.invoke(IpcEvents.SEND_KEYBOARD, modifier, key)
}
return (
<div
className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-3 text-neutral-300 hover:bg-neutral-700/60"
onClick={paste}
>
<ClipboardIcon size={18} />
<span>{t('keyboard.paste')}</span>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { ReactElement } from 'react'
import { useSetAtom } from 'jotai'
import { KeyboardIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { isKeyboardOpenAtom } from '@renderer/jotai/keyboard'
export const VirtualKeyboard = (): ReactElement => {
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/60"
onClick={() => setIsKeyboardOpen(true)}
>
<KeyboardIcon size={18} />
<span>{t('keyboard.virtualKeyboard')}</span>
</div>
)
}

View File

@@ -0,0 +1,44 @@
import { ReactElement } from 'react'
import { Popover } from 'antd'
import clsx from 'clsx'
import { LanguagesIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import languages from '@renderer/i18n/languages'
import { setLanguage } from '@renderer/libs/storage'
export const Language = (): ReactElement => {
const { i18n } = useTranslation()
function changeLanguage(lng: string): void {
if (i18n.language === lng) return
i18n.changeLanguage(lng)
setLanguage(lng)
}
const content = (
<>
{languages.map((lng) => (
<div
key={lng.key}
className={clsx(
'flex cursor-pointer items-center space-x-1 rounded px-5 py-1 select-none',
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="bottomLeft" trigger="click" arrow>
<div className="flex h-[28px] cursor-pointer items-center justify-center rounded px-2 text-white hover:bg-neutral-600/60">
<LanguagesIcon size={18} />
</div>
</Popover>
)
}

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 '@renderer/jotai/mouse'
import * as storage from '@renderer/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 pr-5 pl-2 hover:bg-neutral-700/60',
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/60">
<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 { ReactElement, useEffect, useState } from 'react'
import { Popover } from 'antd'
import { useAtom, useSetAtom } from 'jotai'
import { MouseIcon } from 'lucide-react'
import { mouseModeAtom, mouseStyleAtom, scrollDirectionAtom } from '@renderer/jotai/mouse'
import * as storage from '@renderer/libs/storage'
import { Direction } from './direction'
import { Mode } from './mode'
import { Style } from './style'
export const Mouse = (): ReactElement => {
const [mouseStyle, setMouseStyle] = useAtom(mouseStyleAtom)
const setMouseMode = useSetAtom(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) {
setMouseMode(mode)
}
const direction = storage.getMouseScrollDirection()
if (direction && Number(direction)) {
setScrollDirection(Number(direction))
}
}, [])
const content = (
<div className="flex flex-col space-y-1">
<Style />
<Mode />
<Direction />
</div>
)
return (
<Popover
content={content}
placement="bottomLeft"
trigger="click"
arrow={false}
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
>
<div className="flex h-[28px] cursor-pointer items-center justify-center rounded px-2 text-white hover:bg-neutral-700/70">
<MouseIcon size={18} />
</div>
</Popover>
)
}

View File

@@ -0,0 +1,56 @@
import { ReactElement } from 'react'
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 '@renderer/jotai/mouse'
import * as storage from '@renderer/libs/storage'
export const Mode = (): ReactElement => {
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): void {
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 pr-5 pl-2 hover:bg-neutral-700/60',
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/60">
<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 { ReactElement } from 'react'
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 '@renderer/jotai/mouse'
import * as storage from '@renderer/libs/storage'
export const Style = (): ReactElement => {
const { t } = useTranslation()
const [mouseStyle, setMouseStyle] = useAtom(mouseStyleAtom)
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' }
]
function updateStyle(style: string): void {
setMouseStyle(style)
storage.setMouseStyle(style)
}
const content = (
<>
{mouseStyles.map((style) => (
<div
key={style.value}
className={clsx(
'flex cursor-pointer items-center space-x-1 rounded py-1.5 pr-5 pl-3 select-none hover:bg-neutral-700/60',
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="text-sm select-none">{t('mouse.cursor.title')}</span>
</div>
</Popover>
)
}

View File

@@ -0,0 +1,72 @@
import { ReactElement, useEffect, useState } from 'react'
import { Popover } from 'antd'
import clsx from 'clsx'
import { useAtom } from 'jotai'
import { CpuIcon, LoaderCircleIcon, RadioIcon } from 'lucide-react'
import { IpcEvents } from '@common/ipc-events'
import { serialPortAtom } from '@renderer/jotai/device'
export const SerialPort = (): ReactElement => {
const [serialPort, setSerialPort] = useAtom(serialPortAtom)
const [connectingPort, setConnectingPort] = useState('')
const [serialPorts, setSerialPorts] = useState<string[]>([])
useEffect(() => {
getSerialPorts()
const rmListener = window.electron.ipcRenderer.on(IpcEvents.OPEN_SERIAL_PORT_RSP, () => {
setConnectingPort('')
})
return (): void => {
rmListener()
}
}, [])
async function getSerialPorts(): Promise<void> {
const ports = await window.electron.ipcRenderer.invoke(IpcEvents.GET_SERIAL_PORTS)
setSerialPorts(ports)
}
async function openSerialPort(port: string): Promise<void> {
if (connectingPort) return
setConnectingPort(port)
const success = await window.electron.ipcRenderer.invoke(IpcEvents.OPEN_SERIAL_PORT, port)
if (success) {
setSerialPort(port)
}
}
const content = (
<div className="max-h-[350px] overflow-y-auto">
{serialPorts.map((port: string) => (
<div
key={port}
className={clsx(
'flex cursor-pointer items-center space-x-2 rounded px-3 py-2 hover:bg-neutral-700/60',
port === serialPort ? 'text-blue-500' : 'text-white'
)}
onClick={() => openSerialPort(port)}
>
{port === connectingPort ? (
<LoaderCircleIcon className="animate-spin" size={16} />
) : (
<RadioIcon size={16} />
)}
<span>{port}</span>
</div>
))}
</div>
)
return (
<Popover content={content} placement="bottomLeft" trigger="click" arrow={false}>
<div className="flex h-[28px] cursor-pointer items-center justify-center rounded px-2 text-white hover:bg-neutral-700/70">
<CpuIcon size={18} />
</div>
</Popover>
)
}

View File

@@ -0,0 +1,79 @@
import { ReactElement, useEffect, useState } from 'react'
import { GithubOutlined, XOutlined } from '@ant-design/icons'
import { Divider } from 'antd'
import { BookOpenIcon, MessageSquareIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { IpcEvents } from '@common/ipc-events'
import icon from '@renderer/assets/images/icon.png'
export const About = (): ReactElement => {
const { t } = useTranslation()
const [version, setVersion] = useState('')
const communities = [
{
name: 'Document',
icon: <BookOpenIcon size={24} />,
url: 'https://wiki.sipeed.com/nanokvmusb'
},
{
name: 'GitHub',
icon: <GithubOutlined style={{ fontSize: '20px' }} width={24} height={24} />,
url: 'https://github.com/sipeed/NanoKVM-USB'
},
{
name: 'X',
icon: <XOutlined style={{ fontSize: '20px' }} width={24} height={24} />,
url: 'https://twitter.com/SipeedIO'
},
{
name: 'Discussion',
icon: <MessageSquareIcon size={24} />,
url: 'https://maixhub.com/discussion/nanokvm'
}
]
useEffect(() => {
window.electron.ipcRenderer.invoke(IpcEvents.GET_APP_VERSION).then((ver) => {
setVersion(ver)
})
}, [])
function open(url: string): void {
window.electron.ipcRenderer.send(IpcEvents.OPEN_EXTERNAL_RUL, url)
}
return (
<>
<div className="text-base font-bold">{t('settings.about.title')}</div>
<Divider />
<div className="pb-5 text-neutral-400">{t('settings.about.version')}</div>
<div className="flex items-center space-x-5 pt-1 select-none">
<img src={icon} className="pointer-events-none h-[64px] w-[64px]" alt="maix" />
<div className="flex flex-col space-y-1">
<span className="text-settings-active-foreground text-sm font-bold">NanoKVM-USB</span>
<span className="text-settings-foreground text-sm">{version}</span>
</div>
</div>
<Divider />
<div className="pb-5 text-neutral-400">{t('settings.about.community')}</div>
<div className="my-3 flex space-x-5">
{communities.map((community) => (
<div
key={community.name}
className="flex h-20 w-20 cursor-pointer flex-col items-center justify-center space-y-2 rounded-lg text-neutral-300 outline outline-1 outline-neutral-700 hover:bg-neutral-800 hover:text-white focus:bg-neutral-800"
onClick={() => open(community.url)}
>
{community.icon}
<span className="text-xs">{community.name}</span>
</div>
))}
</div>
</>
)
}

View File

@@ -0,0 +1,70 @@
import { ReactElement, useEffect, useState } from 'react'
import { Divider, Select, Switch } from 'antd'
import { LanguagesIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import languages from '@renderer/i18n/languages'
import { setLanguage } from '@renderer/libs/storage'
import * as storage from '@renderer/libs/storage'
export const Appearance = (): ReactElement => {
const { t, i18n } = useTranslation()
const [isMenuOpen, setIsMenuOpen] = useState(false)
const options = languages.map((language) => ({
value: language.key,
label: language.name
}))
useEffect(() => {
const isOpen = storage.getIsMenuOpen()
setIsMenuOpen(isOpen)
}, [])
function changeLanguage(value: string): void {
if (i18n.language === value) return
i18n.changeLanguage(value)
setLanguage(value)
}
function toggleMenu(): void {
const isOpen = !isMenuOpen
setIsMenuOpen(isOpen)
storage.setIsMenuOpen(isOpen)
}
return (
<>
<div className="text-base font-bold">{t('settings.appearance.title')}</div>
<Divider />
{/* language */}
<div className="flex items-center justify-between pt-3">
<div className="flex items-center space-x-1">
<LanguagesIcon size={16} />
<span>{t('settings.appearance.language')}</span>
</div>
<Select
defaultValue={i18n.language}
style={{ width: 180 }}
options={options}
onSelect={changeLanguage}
/>
</div>
{/* menu bar */}
<div className="flex items-center justify-between pt-6">
<div className="flex flex-col">
<span>{t('settings.appearance.menu')}</span>
<span className="text-xs text-neutral-500">{t('settings.appearance.menuTips')}</span>
</div>
<Switch value={isMenuOpen} onChange={toggleMenu} />
</div>
</>
)
}

View File

@@ -0,0 +1,108 @@
import { ReactElement, useEffect, useState } from 'react'
import { Badge, Modal } from 'antd'
import clsx from 'clsx'
import { BadgeInfoIcon, CircleArrowUpIcon, PaletteIcon, SettingsIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import * as storage from '@renderer/libs/storage'
import { About } from './about'
import { Appearance } from './appearance'
import { Update } from './update'
export const Settings = (): ReactElement => {
const { t } = useTranslation()
const [isModalOpen, setIsModalOpen] = useState(false)
const [currentTab, setCurrentTab] = useState('appearance')
const [isUpdateAvailable, setIsUpdateAvailable] = useState(false)
useEffect(() => {
const skip = storage.getSkipUpdate()
if (skip) return
window.electron.ipcRenderer.invoke('check-for-updates').then((info) => {
if (info?.version) {
setIsUpdateAvailable(true)
}
})
}, [])
const tabs = [
{ id: 'appearance', icon: <PaletteIcon size={16} />, component: <Appearance /> },
{ id: 'update', icon: <CircleArrowUpIcon size={16} />, component: <Update /> },
{ id: 'about', icon: <BadgeInfoIcon size={16} />, component: <About /> }
]
function changeTab(tab: string): void {
setCurrentTab(tab)
if (isUpdateAvailable && tab === 'update') {
setIsUpdateAvailable(false)
storage.setSkipUpdate(true)
}
}
function closeModal(): void {
setIsModalOpen(false)
setCurrentTab('appearance')
}
return (
<>
<div
className="flex h-[28px] cursor-pointer items-center justify-center rounded px-2 text-white hover:bg-neutral-700/70"
onClick={() => setIsModalOpen(true)}
>
<SettingsIcon size={18} />
</div>
<Modal
open={isModalOpen}
width={820}
footer={null}
destroyOnClose={true}
styles={{ content: { padding: 0 } }}
onCancel={closeModal}
>
<div className="flex min-h-[500px] rounded-lg outline outline-1 outline-neutral-700">
<div className="flex flex-col space-y-1 rounded-l-lg bg-neutral-800 px-2 py-5 sm:w-1/5 md:w-1/4">
<div className="hidden px-3 text-lg font-bold sm:block">{t('settings.title')}</div>
<div className="pt-3" />
{tabs.map((tab) => (
<div
key={tab.id}
className={clsx(
'flex cursor-pointer items-center space-x-2 rounded-lg p-2 select-none sm:px-3',
currentTab === tab.id ? 'bg-neutral-700/70' : 'hover:bg-neutral-700'
)}
onClick={() => changeTab(tab.id)}
>
<div className="h-[16px] w-[16px]">{tab.icon}</div>
{isUpdateAvailable && tab.id === 'update' ? (
<Badge dot color="blue" offset={[6, 3]}>
<span className="hidden truncate text-sm sm:block">
{t(`settings.${tab.id}.title`)}
</span>
</Badge>
) : (
<span className="hidden truncate text-sm sm:block">
{t(`settings.${tab.id}.title`)}
</span>
)}
</div>
))}
</div>
<div className="flex max-h-[700px] w-full flex-col items-center overflow-y-auto rounded-r-lg bg-neutral-900 px-3 sm:w-4/5 md:w-3/4">
<div className="w-full max-w-[500px] py-10">
<>{tabs.find((tab) => tab.id === currentTab)?.component}</>
</div>
</div>
</div>
</Modal>
</>
)
}

View File

@@ -0,0 +1,136 @@
import { ReactElement, useEffect, useState } from 'react'
import { LoadingOutlined, RocketOutlined, SmileOutlined } from '@ant-design/icons'
import { Button, Divider, Progress, Result, Spin } from 'antd'
import { useTranslation } from 'react-i18next'
import { IpcEvents } from '@common/ipc-events'
type Status = 'loading' | 'latest' | 'outdated' | 'downloading' | 'installing' | 'error'
export const Update = (): ReactElement => {
const { t } = useTranslation()
const [status, setStatus] = useState<Status>('loading')
const [currentVersion, setCurrentVersion] = useState('')
const [latestVersion, setLatestVersion] = useState('')
const [progress, setProgress] = useState(0)
useEffect(() => {
getVersion()
const rmUpdateAvailable = window.electron.ipcRenderer.on(
IpcEvents.UPDATE_AVAILABLE,
(_, info) => {
if (info?.version) {
setStatus('outdated')
setLatestVersion(info.version)
}
}
)
const rmUpdateNotAvailable = window.electron.ipcRenderer.on(
IpcEvents.UPDATE_NOT_AVAILABLE,
() => {
setStatus('latest')
}
)
const rmDownloadProgress = window.electron.ipcRenderer.on(
IpcEvents.DOWNLOAD_PROGRESS,
(_, percent) => {
setProgress(percent)
if (percent >= 100) {
setStatus('installing')
}
}
)
const rmUpdateDownloaded = window.electron.ipcRenderer.on(IpcEvents.UPDATE_DOWNLOADED, () => {
setStatus('installing')
})
const rmUpdateError = window.electron.ipcRenderer.on(IpcEvents.UPDATE_ERROR, () => {
setStatus('error')
})
return (): void => {
rmUpdateAvailable()
rmUpdateNotAvailable()
rmDownloadProgress()
rmUpdateDownloaded()
rmUpdateError()
}
}, [])
function getVersion(): void {
window.electron.ipcRenderer.invoke(IpcEvents.GET_APP_VERSION).then((version) => {
setCurrentVersion(version)
})
window.electron.ipcRenderer.invoke(IpcEvents.CHECK_FOR_UPDATES)
}
function update(): void {
window.electron.ipcRenderer.send(IpcEvents.DOWNLOAD_UPDATE)
setProgress(0)
setStatus('downloading')
}
return (
<>
<div className="text-base font-bold">{t('settings.update.title')}</div>
<Divider />
{status === 'loading' && (
<div className="flex justify-center pt-24">
<Spin indicator={<LoadingOutlined spin />} size="large" />
</div>
)}
{status === 'latest' && (
<Result
status="success"
icon={<SmileOutlined />}
title={currentVersion}
subTitle={t('settings.update.latest')}
/>
)}
{status === 'outdated' && (
<Result
status="warning"
icon={<RocketOutlined />}
title={`${currentVersion} -> ${latestVersion}`}
subTitle={t('settings.update.outdated')}
extra={[
<Button key="update" type="primary" onClick={update}>
{t('settings.update.confirm')}
</Button>
]}
/>
)}
{status === 'downloading' && (
<div className="flex flex-col items-center justify-center space-y-10 pt-24">
<Progress
percent={progress}
percentPosition={{ align: 'end', type: 'inner' }}
size={[450, 20]}
/>
<div />
<span className="text-blue-500/70">{t('settings.update.downloading')}</span>
</div>
)}
{status === 'installing' && (
<div className="flex flex-col items-center justify-center space-y-10 pt-24">
<Spin size="large" />
<div />
<span className="text-blue-500/70">{t('settings.update.installing')}</span>
</div>
)}
{status === 'error' && <Result subTitle={t('settings.update.failed')} />}
</>
)
}

View File

@@ -0,0 +1,71 @@
import { ReactElement, 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 '@renderer/jotai/device'
import { camera } from '@renderer/libs/camera'
import * as storage from '@renderer/libs/storage'
export const Device = (): ReactElement => {
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): Promise<void> {
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 = (
<div className="max-h-[350px] overflow-y-auto">
{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>
))}
</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="text-sm select-none">{t('video.device')}</span>
</div>
</Popover>
)
}

View File

@@ -0,0 +1,23 @@
import { ReactElement } from 'react'
import { Popover } from 'antd'
import { MonitorIcon } from 'lucide-react'
import { Device } from './device'
import { Resolution } from './resolution'
export const Video = (): ReactElement => {
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] cursor-pointer items-center justify-center rounded px-2 text-white hover:bg-neutral-700/70">
<MonitorIcon size={18} />
</div>
</Popover>
)
}

View File

@@ -0,0 +1,195 @@
import React, { ReactElement, 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 '@renderer/jotai/device'
import { isKeyboardEnableAtom } from '@renderer/jotai/keyboard'
import { camera } from '@renderer/libs/camera'
import * as storage from '@renderer/libs/storage'
import type { Resolution as VideoResolution } from '@renderer/types'
export const Resolution = (): ReactElement => {
const { t } = useTranslation()
const [resolution, setResolution] = useAtom(resolutionAtom)
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom)
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(): void {
setWidth(0)
setHeight(0)
setIsOpen(true)
}
function submit(): void {
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): Promise<void> {
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: React.MouseEvent<HTMLSpanElement, MouseEvent>): void {
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 = (
<>
{resolutions.map((res) => (
<div
key={res.width}
className={clsx(
'flex cursor-pointer items-center space-x-1.5 rounded px-3 py-1.5 select-none 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="w-[32px]">{res.width}</span>
<span>x</span>
<span className="w-[32px]">{res.height}</span>
</div>
))}
<Divider style={{ margin: '5px 0 5px 0' }} />
<div
className="flex cursor-pointer items-center justify-between space-x-3 rounded px-3 py-1.5 text-sm select-none 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 items-center space-x-1 rounded px-3 py-1.5 select-none 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="text-sm select-none">{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,127 @@
import { ReactElement, useEffect, useRef } from 'react'
import { useAtomValue } from 'jotai'
import { IpcEvents } from '@common/ipc-events'
import { resolutionAtom } from '@renderer/jotai/device'
import { scrollDirectionAtom } from '@renderer/jotai/mouse'
import type { Mouse as MouseKey } from '@renderer/types'
export const Absolute = (): ReactElement => {
const resolution = useAtomValue(resolutionAtom)
const scrollDirection = useAtomValue(scrollDirectionAtom)
const keyRef = useRef<MouseKey>({
left: false,
right: false,
mid: false
})
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: MouseEvent): Promise<void> {
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: MouseEvent): Promise<void> {
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: MouseEvent): Promise<void> {
disableEvent(event)
await send(event)
}
// mouse scroll
async function handleWheel(event: WheelEvent): Promise<void> {
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): Promise<void> {
const key =
(keyRef.current.left ? 1 : 0) |
(keyRef.current.right ? 2 : 0) |
(keyRef.current.mid ? 4 : 0)
const rect = canvas!.getBoundingClientRect()
const x = Math.abs(event.clientX - rect.left)
const y = Math.abs(event.clientY - rect.top)
await window.electron.ipcRenderer.invoke(
IpcEvents.SEND_MOUSE_ABSOLUTE,
key,
rect.width,
rect.height,
x,
y,
scroll
)
}
return (): void => {
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])
function disableEvent(event: MouseEvent): void {
event.preventDefault()
event.stopPropagation()
}
return <></>
}

View File

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

View File

@@ -0,0 +1,148 @@
import { ReactElement, useEffect, useRef } from 'react'
import { message } from 'antd'
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import { IpcEvents } from '@common/ipc-events'
import { resolutionAtom } from '@renderer/jotai/device'
import { scrollDirectionAtom } from '@renderer/jotai/mouse'
import type { Mouse as MouseKey } from '@renderer/types'
export const Relative = (): ReactElement => {
const { t } = useTranslation()
const [messageApi, contextHolder] = message.useMessage()
const resolution = useAtomValue(resolutionAtom)
const scrollDirection = useAtomValue(scrollDirectionAtom)
const isLockedRef = useRef(false)
const keyRef = useRef<MouseKey>({
left: false,
right: false,
mid: false
})
useEffect(() => {
messageApi.open({
key: 'relative',
type: 'info',
content: t('mouse.requestPointer'),
duration: 3,
style: {
marginTop: '40vh'
}
})
}, [])
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 handlePointerLockChange(): void {
isLockedRef.current = document.pointerLockElement === canvas
}
function handleClick(event: MouseEvent): void {
disableEvent(event)
if (!isLockedRef.current) {
canvas!.requestPointerLock()
}
}
async function handleMouseDown(event: MouseEvent): Promise<void> {
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)
}
async function handleMouseUp(event: MouseEvent): Promise<void> {
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)
}
async function handleMouseMove(event: MouseEvent): Promise<void> {
disableEvent(event)
const x = event.movementX || 0
const y = event.movementY || 0
if (x === 0 && y === 0) return
await send(Math.abs(x) < 10 ? x * 2 : x, Math.abs(y) < 10 ? y * 2 : y, 0)
}
async function handleWheel(event: WheelEvent): Promise<void> {
disableEvent(event)
const delta = Math.floor(event.deltaY)
if (delta === 0) return
await send(0, 0, delta > 0 ? -1 * scrollDirection : scrollDirection)
}
async function send(x: number, y: number, scroll: number): Promise<void> {
const key =
(keyRef.current.left ? 1 : 0) |
(keyRef.current.right ? 2 : 0) |
(keyRef.current.mid ? 4 : 0)
await window.electron.ipcRenderer.invoke(IpcEvents.SEND_MOUSE_RELATIVE, key, x, y, scroll)
}
return (): void => {
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])
function disableEvent(event: MouseEvent): void {
event.preventDefault()
event.stopPropagation()
}
return <>{contextHolder}</>
}

View File

@@ -0,0 +1,158 @@
import { ReactElement, 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 '@renderer/assets/styles/keyboard.css'
import { IpcEvents } from '@common/ipc-events'
import { isKeyboardOpenAtom } from '@renderer/jotai/keyboard'
import { KeyboardCodes } from '@renderer/libs/keyboard'
import {
doubleKeys,
keyboardArrowsOptions,
keyboardControlPadOptions,
keyboardOptions,
modifierKeys,
specialKeyMap
} from './keys'
type KeyboardProps = {
isBigScreen: boolean
}
export const VirtualKeyboard = ({ isBigScreen }: KeyboardProps): ReactElement => {
const [isKeyboardOpen, setIsKeyboardOpen] = useAtom(isKeyboardOpenAtom)
const [activeModifierKeys, setActiveModifierKeys] = useState<string[]>([])
const keyboardRef = useRef(null)
async function onKeyPress(key: string): Promise<void> {
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): Promise<void> {
if (modifierKeys.includes(key)) {
return
}
await sendKeyup()
}
async function sendKeydown(key: string): Promise<void> {
const specialKey = specialKeyMap.get(key)
const code = KeyboardCodes.get(specialKey ? specialKey : key)
if (!code) {
console.log('unknown code: ', key)
return
}
const modifier = getModifier()
await send(modifier, code)
}
async function sendKeyup(): Promise<void> {
await send(0, 0)
setActiveModifierKeys([])
}
async function send(modifier: number, key: number): Promise<void> {
await window.electron.ipcRenderer.invoke(IpcEvents.SEND_KEYBOARD, modifier, key)
}
function getModifier(): number {
const pressedKeys = [
activeModifierKeys.includes('{controlleft}'),
activeModifierKeys.includes('{shiftleft}'),
activeModifierKeys.includes('{altleft}'),
activeModifierKeys.includes('{metaleft}') || activeModifierKeys.includes('{winleft}'),
activeModifierKeys.includes('{controlright}'),
activeModifierKeys.includes('{shiftright}'),
activeModifierKeys.includes('{altright}'),
activeModifierKeys.includes('{metaright}') || activeModifierKeys.includes('{winright}')
]
return pressedKeys.reduce((acc, isPressed, bit) => (isPressed ? acc | (1 << bit) : acc), 0)
}
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 right-0 bottom-0 left-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 data-vaul-no-drag className="keyboardContainer w-full">
{/* main keyboard */}
<Keyboard
buttonTheme={getButtonTheme()}
keyboardRef={(r) => (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'
]

1
desktop/src/renderer/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,53 @@
import i18n from 'i18next'
import type { Resource } from 'i18next'
import { initReactI18next } from 'react-i18next'
import { getLanguage } from '@renderer/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,79 @@
const en = {
translation: {
camera: {
tip: 'Waiting for authorization...',
denied: 'Authorization failed',
authorize:
'Remote desktop requires camera permission. Please grant camera permission in the settings.',
failed: 'Failed to connect camera. Please try again.'
},
modal: {
title: 'Select USB Device',
selectVideo: 'Please select a video input device',
selectSerial: 'Please 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: {
title: 'Settings',
appearance: {
title: 'Appearance',
language: 'Language',
menu: 'Menu Bar',
menuTips: 'Open menu bar when launch'
},
update: {
title: 'Check for Updates',
latest: 'You already have the latest version.',
outdated: 'An update is available. Are you sure you want to update now?',
downloading: 'Downloading...',
installing: 'Installing...',
failed: 'Update failed. Please retry.',
confirm: 'Confirm',
cancel: 'Cancel'
},
about: {
title: 'About',
version: 'Version',
community: 'Community'
}
}
}
}
export default en

View File

@@ -0,0 +1,77 @@
const zh = {
translation: {
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: {
title: '设置',
appearance: {
title: '外观',
language: '语言',
menu: '菜单栏',
menuTips: '启动时是否打开菜单栏'
},
update: {
title: '更新',
latest: '已经是最新版本。',
outdated: '有新的可用版本,确定要更新吗?',
downloading: '下载中...',
installing: '安装中...',
failed: '更新失败,请重试。',
confirm: '确认'
},
about: {
title: '关于',
version: '版本',
community: '社区'
}
}
}
}
export default zh

View File

@@ -0,0 +1,17 @@
import { atom } from 'jotai'
import { Resolution } from '@renderer/types'
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 serialPortAtom = atom('')
export const serialPortStateAtom = 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,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'
export * from './charCodes'

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,31 @@
type ItemWithExpiry = {
value: string
expiry: number
}
// set the value with expiration time (unit: milliseconds)
export function setWithExpiry(key: string, value: string, ttl: number): void {
const now = new Date()
const item: ItemWithExpiry = {
value: value,
expiry: now.getTime() + ttl
}
localStorage.setItem(key, JSON.stringify(item))
}
// get the value with expiration time
export function getWithExpiry(key: string): string | null {
const itemStr = localStorage.getItem(key)
if (!itemStr) return null
const item: ItemWithExpiry = JSON.parse(itemStr)
const now = new Date()
if (now.getTime() > item.expiry) {
localStorage.removeItem(key)
return null
}
return item.value
}

View File

@@ -0,0 +1,117 @@
import type { Resolution } from '@renderer/types'
import { getWithExpiry, setWithExpiry } from './expiry'
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 SERIAL_PORT_KEY = 'nanokvm-serial-port'
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'
const SKIP_UPDATE_KEY = 'nano-kvm-check-update'
export function getLanguage(): string | null {
return localStorage.getItem(LANGUAGE_KEY)
}
export function setLanguage(language: string): void {
localStorage.setItem(LANGUAGE_KEY, language)
}
export function getVideoDevice(): string | null {
return localStorage.getItem(VIDEO_DEVICE_ID_KEY)
}
export function setVideoDevice(id: string): void {
localStorage.setItem(VIDEO_DEVICE_ID_KEY, id)
}
export function getVideoResolution(): Resolution | undefined {
const resolution = localStorage.getItem(VIDEO_RESOLUTION_KEY)
if (!resolution) {
return
}
return window.JSON.parse(resolution) as Resolution
}
export function setVideoResolution(width: number, height: number): void {
localStorage.setItem(VIDEO_RESOLUTION_KEY, window.JSON.stringify({ width, height }))
}
export function getCustomResolutions(): Resolution[] | undefined {
const resolution = localStorage.getItem(CUSTOM_RESOLUTION_KEY)
if (!resolution) return
return window.JSON.parse(resolution) as Resolution[]
}
export function setCustomResolution(width: number, height: number): void {
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(): void {
localStorage.removeItem(CUSTOM_RESOLUTION_KEY)
}
export function getSerialPort(): string | null {
return localStorage.getItem(SERIAL_PORT_KEY)
}
export function setSerialPort(port: string): void {
localStorage.setItem(SERIAL_PORT_KEY, port)
}
export function getIsMenuOpen(): boolean {
const state = localStorage.getItem(IS_MENU_OPEN_KEY)
if (!state) {
return true
}
return state === 'true'
}
export function setIsMenuOpen(isOpen: boolean): void {
localStorage.setItem(IS_MENU_OPEN_KEY, isOpen ? 'true' : 'false')
}
export function getMouseStyle(): string | null {
return localStorage.getItem(MOUSE_STYLE_KEY)
}
export function setMouseStyle(mouse: string): void {
localStorage.setItem(MOUSE_STYLE_KEY, mouse)
}
export function getMouseMode(): string | null {
return localStorage.getItem(MOUSE_MODE_KEY)
}
export function setMouseMode(mouse: string): void {
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)
}
export function getSkipUpdate(): boolean {
const skip = getWithExpiry(SKIP_UPDATE_KEY)
return skip ? Boolean(skip) : false
}
export function setSkipUpdate(skip: boolean): void {
const expiry = 3 * 24 * 60 * 60 * 1000
setWithExpiry(SKIP_UPDATE_KEY, String(skip), expiry)
}

View File

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

View File

@@ -0,0 +1,10 @@
export type Resolution = {
width: number
height: number
}
export type Mouse = {
left: boolean
right: boolean
mid: boolean
}

4
desktop/tsconfig.json Normal file
View File

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

View File

@@ -0,0 +1,17 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": [
"electron.vite.config.*",
"src/common/**/*",
"src/main/**/*",
"src/preload/**/*"
],
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "bundler",
"types": [
"electron-vite/node"
],
}
}

23
desktop/tsconfig.web.json Normal file
View File

@@ -0,0 +1,23 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
"include": [
"src/common/**/*",
"src/renderer/src/env.d.ts",
"src/renderer/src/**/*",
"src/renderer/src/**/*.tsx",
"src/preload/*.d.ts"
],
"compilerOptions": {
"composite": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@common/*": [
"src/common/*"
],
"@renderer/*": [
"src/renderer/src/*"
]
}
}
}