mirror of
https://github.com/sipeed/NanoKVM-USB.git
synced 2026-09-12 06:29:59 -05:00
release the desktop version code
This commit is contained in:
9
desktop/.editorconfig
Normal file
9
desktop/.editorconfig
Normal 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
9
desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
|
||||
.idea
|
||||
.vscode
|
||||
3
desktop/.npmrc
Normal file
3
desktop/.npmrc
Normal 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
6
desktop/.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
||||
out
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
LICENSE.md
|
||||
tsconfig.json
|
||||
tsconfig.*.json
|
||||
26
desktop/.prettierrc.yaml
Normal file
26
desktop/.prettierrc.yaml
Normal 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
|
||||
18
desktop/build/entitlements.mac.plist
Normal file
18
desktop/build/entitlements.mac.plist
Normal 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
BIN
desktop/build/icon.icns
Normal file
Binary file not shown.
BIN
desktop/build/icon.ico
Normal file
BIN
desktop/build/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
desktop/build/icon.png
Normal file
BIN
desktop/build/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
desktop/build/icons/512x512.png
Normal file
BIN
desktop/build/icons/512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
4
desktop/dev-app-update.yml
Normal file
4
desktop/dev-app-update.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
provider: github
|
||||
owner: sipeed
|
||||
repo: NanoKVM-USB
|
||||
updaterCacheDirName: nanokvm-usb-updater
|
||||
52
desktop/electron-builder.yml
Normal file
52
desktop/electron-builder.yml
Normal 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/
|
||||
22
desktop/electron.vite.config.ts
Normal file
22
desktop/electron.vite.config.ts
Normal 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
31
desktop/eslint.config.mjs
Normal 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
18
desktop/notarize.js
Normal 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
71
desktop/package.json
Normal 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
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
BIN
desktop/resources/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
21
desktop/src/common/ipc-events.ts
Normal file
21
desktop/src/common/ipc-events.ts
Normal 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'
|
||||
}
|
||||
58
desktop/src/main/device/index.ts
Normal file
58
desktop/src/main/device/index.ts
Normal 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()
|
||||
134
desktop/src/main/device/proto.ts
Normal file
134
desktop/src/main/device/proto.ts
Normal 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
|
||||
}
|
||||
}
|
||||
81
desktop/src/main/device/serial-port.ts
Normal file
81
desktop/src/main/device/serial-port.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
desktop/src/main/device/utils.ts
Normal file
18
desktop/src/main/device/utils.ts
Normal 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
|
||||
}
|
||||
42
desktop/src/main/events/app.ts
Normal file
42
desktop/src/main/events/app.ts
Normal 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
|
||||
}
|
||||
}
|
||||
3
desktop/src/main/events/index.ts
Normal file
3
desktop/src/main/events/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './app'
|
||||
export * from './serial-port'
|
||||
export * from './updater'
|
||||
89
desktop/src/main/events/serial-port.ts
Normal file
89
desktop/src/main/events/serial-port.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
50
desktop/src/main/events/updater.ts
Normal file
50
desktop/src/main/events/updater.ts
Normal 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
66
desktop/src/main/index.ts
Normal 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
8
desktop/src/preload/index.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI
|
||||
api: unknown
|
||||
}
|
||||
}
|
||||
22
desktop/src/preload/index.ts
Normal file
22
desktop/src/preload/index.ts
Normal 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
|
||||
}
|
||||
16
desktop/src/renderer/index.html
Normal file
16
desktop/src/renderer/index.html
Normal 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>
|
||||
128
desktop/src/renderer/src/App.tsx
Normal file
128
desktop/src/renderer/src/App.tsx
Normal 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
|
||||
BIN
desktop/src/renderer/src/assets/images/icon.png
Normal file
BIN
desktop/src/renderer/src/assets/images/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
5
desktop/src/renderer/src/assets/styles/base.css
Normal file
5
desktop/src/renderer/src/assets/styles/base.css
Normal file
@@ -0,0 +1,5 @@
|
||||
html, body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: #000;
|
||||
}
|
||||
105
desktop/src/renderer/src/assets/styles/keyboard.css
Normal file
105
desktop/src/renderer/src/assets/styles/keyboard.css
Normal 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";
|
||||
}
|
||||
9
desktop/src/renderer/src/assets/styles/main.css
Normal file
9
desktop/src/renderer/src/assets/styles/main.css
Normal file
@@ -0,0 +1,9 @@
|
||||
@import "./base.css";
|
||||
|
||||
@layer tailwind-base, antd;
|
||||
|
||||
@layer tailwind-base {
|
||||
@tailwind base;
|
||||
}
|
||||
|
||||
@import "tailwindcss";
|
||||
26
desktop/src/renderer/src/components/device-modal/index.tsx
Normal file
26
desktop/src/renderer/src/components/device-modal/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
90
desktop/src/renderer/src/components/device-modal/video.tsx
Normal file
90
desktop/src/renderer/src/components/device-modal/video.tsx
Normal 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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
82
desktop/src/renderer/src/components/keyboard/index.tsx
Normal file
82
desktop/src/renderer/src/components/keyboard/index.tsx
Normal 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 <></>
|
||||
}
|
||||
63
desktop/src/renderer/src/components/menu/index.tsx
Normal file
63
desktop/src/renderer/src/components/menu/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
desktop/src/renderer/src/components/menu/keyboard/index.tsx
Normal file
32
desktop/src/renderer/src/components/menu/keyboard/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
desktop/src/renderer/src/components/menu/keyboard/paste.tsx
Normal file
51
desktop/src/renderer/src/components/menu/keyboard/paste.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
44
desktop/src/renderer/src/components/menu/language/index.tsx
Normal file
44
desktop/src/renderer/src/components/menu/language/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
desktop/src/renderer/src/components/menu/mouse/direction.tsx
Normal file
58
desktop/src/renderer/src/components/menu/mouse/direction.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
desktop/src/renderer/src/components/menu/mouse/index.tsx
Normal file
59
desktop/src/renderer/src/components/menu/mouse/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
desktop/src/renderer/src/components/menu/mouse/mode.tsx
Normal file
56
desktop/src/renderer/src/components/menu/mouse/mode.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
desktop/src/renderer/src/components/menu/mouse/style.tsx
Normal file
57
desktop/src/renderer/src/components/menu/mouse/style.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
79
desktop/src/renderer/src/components/menu/settings/about.tsx
Normal file
79
desktop/src/renderer/src/components/menu/settings/about.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
108
desktop/src/renderer/src/components/menu/settings/index.tsx
Normal file
108
desktop/src/renderer/src/components/menu/settings/index.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
136
desktop/src/renderer/src/components/menu/settings/update.tsx
Normal file
136
desktop/src/renderer/src/components/menu/settings/update.tsx
Normal 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')} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
71
desktop/src/renderer/src/components/menu/video/device.tsx
Normal file
71
desktop/src/renderer/src/components/menu/video/device.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
23
desktop/src/renderer/src/components/menu/video/index.tsx
Normal file
23
desktop/src/renderer/src/components/menu/video/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
195
desktop/src/renderer/src/components/menu/video/resolution.tsx
Normal file
195
desktop/src/renderer/src/components/menu/video/resolution.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
127
desktop/src/renderer/src/components/mouse/absolute.tsx
Normal file
127
desktop/src/renderer/src/components/mouse/absolute.tsx
Normal 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 <></>
|
||||
}
|
||||
13
desktop/src/renderer/src/components/mouse/index.tsx
Normal file
13
desktop/src/renderer/src/components/mouse/index.tsx
Normal 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 />}</>
|
||||
}
|
||||
148
desktop/src/renderer/src/components/mouse/relative.tsx
Normal file
148
desktop/src/renderer/src/components/mouse/relative.tsx
Normal 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}</>
|
||||
}
|
||||
158
desktop/src/renderer/src/components/virtual-keyboard/index.tsx
Normal file
158
desktop/src/renderer/src/components/virtual-keyboard/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
199
desktop/src/renderer/src/components/virtual-keyboard/keys.ts
Normal file
199
desktop/src/renderer/src/components/virtual-keyboard/keys.ts
Normal 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
1
desktop/src/renderer/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
53
desktop/src/renderer/src/i18n/index.ts
Normal file
53
desktop/src/renderer/src/i18n/index.ts
Normal 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
|
||||
8
desktop/src/renderer/src/i18n/languages.ts
Normal file
8
desktop/src/renderer/src/i18n/languages.ts
Normal 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
|
||||
79
desktop/src/renderer/src/i18n/locales/en.ts
Normal file
79
desktop/src/renderer/src/i18n/locales/en.ts
Normal 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
|
||||
77
desktop/src/renderer/src/i18n/locales/zh.ts
Normal file
77
desktop/src/renderer/src/i18n/locales/zh.ts
Normal 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
|
||||
17
desktop/src/renderer/src/jotai/device.ts
Normal file
17
desktop/src/renderer/src/jotai/device.ts
Normal 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')
|
||||
5
desktop/src/renderer/src/jotai/keyboard.ts
Normal file
5
desktop/src/renderer/src/jotai/keyboard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { atom } from 'jotai'
|
||||
|
||||
export const isKeyboardEnableAtom = atom(true)
|
||||
|
||||
export const isKeyboardOpenAtom = atom(false)
|
||||
10
desktop/src/renderer/src/jotai/mouse.ts
Normal file
10
desktop/src/renderer/src/jotai/mouse.ts
Normal 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)
|
||||
52
desktop/src/renderer/src/libs/camera/index.ts
Normal file
52
desktop/src/renderer/src/libs/camera/index.ts
Normal 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()
|
||||
107
desktop/src/renderer/src/libs/keyboard/charCodes.ts
Normal file
107
desktop/src/renderer/src/libs/keyboard/charCodes.ts
Normal 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
|
||||
])
|
||||
2
desktop/src/renderer/src/libs/keyboard/index.ts
Normal file
2
desktop/src/renderer/src/libs/keyboard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './keyboardCodes'
|
||||
export * from './charCodes'
|
||||
121
desktop/src/renderer/src/libs/keyboard/keyboardCodes.ts
Normal file
121
desktop/src/renderer/src/libs/keyboard/keyboardCodes.ts
Normal 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]
|
||||
])
|
||||
31
desktop/src/renderer/src/libs/storage/expiry.ts
Normal file
31
desktop/src/renderer/src/libs/storage/expiry.ts
Normal 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
|
||||
}
|
||||
117
desktop/src/renderer/src/libs/storage/index.ts
Normal file
117
desktop/src/renderer/src/libs/storage/index.ts
Normal 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)
|
||||
}
|
||||
18
desktop/src/renderer/src/main.tsx
Normal file
18
desktop/src/renderer/src/main.tsx
Normal 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>
|
||||
)
|
||||
10
desktop/src/renderer/src/types.ts
Normal file
10
desktop/src/renderer/src/types.ts
Normal 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
4
desktop/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
|
||||
}
|
||||
17
desktop/tsconfig.node.json
Normal file
17
desktop/tsconfig.node.json
Normal 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
23
desktop/tsconfig.web.json
Normal 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/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user