Merge branch 'main' into feature/add-pt-BR

This commit is contained in:
wenjie
2025-10-30 10:31:10 +08:00
committed by GitHub
44 changed files with 935 additions and 58 deletions

View File

@@ -11,7 +11,12 @@ 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 {
resolutionAtom,
serialPortStateAtom,
videoScaleAtom,
videoStateAtom
} from '@renderer/jotai/device'
import { isKeyboardEnableAtom } from '@renderer/jotai/keyboard'
import { mouseStyleAtom } from '@renderer/jotai/mouse'
import { camera } from '@renderer/libs/camera'
@@ -24,6 +29,7 @@ const App = (): ReactElement => {
const { t } = useTranslation()
const isBigScreen = useMediaQuery({ minWidth: 850 })
const videoScale = useAtomValue(videoScaleAtom)
const videoState = useAtomValue(videoStateAtom)
const serialPortState = useAtomValue(serialPortStateAtom)
const mouseStyle = useAtomValue(mouseStyleAtom)
@@ -115,7 +121,13 @@ const App = (): ReactElement => {
<video
id="video"
className={clsx('block min-h-[480px] min-w-[640px] select-none', mouseStyle)}
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'scale-down' }}
style={{
transform: `scale(${videoScale})`,
transformOrigin: 'center',
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'scale-down'
}}
autoPlay
playsInline
/>

View File

@@ -4,14 +4,17 @@ import { useAtom, useSetAtom } from 'jotai'
import { MouseIcon } from 'lucide-react'
import {
mouseJigglerModeAtom,
mouseModeAtom,
mouseStyleAtom,
scrollDirectionAtom,
scrollIntervalAtom
} from '@renderer/jotai/mouse'
import { mouseJiggler } from '@renderer/libs/mouse-jiggler'
import * as storage from '@renderer/libs/storage'
import { Direction } from './direction'
import { Jiggler } from './jiggler'
import { Mode } from './mode'
import { Speed } from './speed'
import { Style } from './style'
@@ -21,6 +24,7 @@ export const Mouse = (): ReactElement => {
const setMouseMode = useSetAtom(mouseModeAtom)
const setScrollDirection = useSetAtom(scrollDirectionAtom)
const setScrollInterval = useSetAtom(scrollIntervalAtom)
const setMouseJigglerMode = useSetAtom(mouseJigglerModeAtom)
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
@@ -44,6 +48,9 @@ export const Mouse = (): ReactElement => {
if (interval) {
setScrollInterval(interval)
}
const jiggler = storage.getMouseJigglerMode()
mouseJiggler.setMode(jiggler)
setMouseJigglerMode(jiggler)
}, [])
const content = (
@@ -52,6 +59,7 @@ export const Mouse = (): ReactElement => {
<Mode />
<Direction />
<Speed />
<Jiggler />
</div>
)

View File

@@ -0,0 +1,56 @@
import { ReactElement, useEffect } from 'react'
import { Popover } from 'antd'
import clsx from 'clsx'
import { useAtom } from 'jotai'
import { MousePointerIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { mouseJigglerModeAtom } from '@renderer/jotai/mouse'
import { mouseJiggler } from '@renderer/libs/mouse-jiggler'
import * as storage from '@renderer/libs/storage'
export const Jiggler = (): ReactElement => {
const { t } = useTranslation()
const [jigglerMode, setJigglerMode] = useAtom(mouseJigglerModeAtom)
const mouseJigglerModes: { name: string; value: 'enable' | 'disable' }[] = [
{ name: t('mouse.jiggler.enable'), value: 'enable' },
{ name: t('mouse.jiggler.disable'), value: 'disable' }
]
function update(mode: 'enable' | 'disable'): void {
storage.setMouseJigglerMode(mode)
setJigglerMode(mode)
}
useEffect(() => {
mouseJiggler.setMode(jigglerMode)
}, [jigglerMode])
const content = (
<>
{mouseJigglerModes.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/50',
mode.value === jigglerMode ? 'text-blue-500' : 'text-neutral-300'
)}
onClick={() => update(mode.value)}
>
{mode.name}
</div>
))}
</>
)
return (
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [13, 0] }}>
<div className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-3 text-neutral-300 hover:bg-neutral-700/50">
<div className="flex h-[14px] w-[20px] items-end">
<MousePointerIcon size={16} />
</div>
<span>{t('mouse.jiggler.title')}</span>
</div>
</Popover>
)
}

View File

@@ -4,11 +4,13 @@ import { MonitorIcon } from 'lucide-react'
import { Device } from './device'
import { Resolution } from './resolution'
import { Scale } from './scale'
export const Video = (): ReactElement => {
const content = (
<div className="flex flex-col space-y-1">
<Resolution />
<Scale />
<Device />
</div>
)

View File

@@ -0,0 +1,58 @@
import { ReactElement, useEffect } from 'react'
import { Popover, Slider } from 'antd'
import { useAtom } from 'jotai'
import { ScalingIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { videoScaleAtom } from '@renderer/jotai/device'
import * as storage from '@renderer/libs/storage'
export const Scale = (): ReactElement => {
const { t } = useTranslation()
const [videoScale, setVideoScale] = useAtom(videoScaleAtom)
useEffect(() => {
const scale = storage.getVideoScale()
if (scale) {
setVideoScale(scale)
}
}, [setVideoScale])
async function updateScale(scale: number): Promise<void> {
setVideoScale(scale)
storage.setVideoScale(scale)
}
const content = (
<div className="h-[150px] w-[60px] py-3">
<Slider
vertical
marks={{
0.5: <span>x0.5</span>,
1: <span>x1.0</span>,
1.5: <span>x1.5</span>,
2: <span>x2.0</span>
}}
range={false}
included={false}
min={0.5}
max={2}
step={0.1}
defaultValue={videoScale}
onChange={updateScale}
/>
</div>
)
return (
<Popover content={content} placement="rightTop" arrow={false} align={{ offset: [13, 0] }}>
<div className="flex h-[30px] cursor-pointer items-center space-x-1 rounded px-3 text-neutral-300 hover:bg-neutral-700/50">
<div className="flex h-[14px] w-[20px] items-end">
<ScalingIcon size={16} />
</div>
<span>{t('video.scale')}</span>
</div>
</Popover>
)
}

View File

@@ -4,6 +4,7 @@ import { useAtomValue } from 'jotai'
import { IpcEvents } from '@common/ipc-events'
import { resolutionAtom } from '@renderer/jotai/device'
import { scrollDirectionAtom, scrollIntervalAtom } from '@renderer/jotai/mouse'
import { mouseJiggler } from '@renderer/libs/mouse-jiggler'
import type { Mouse as MouseKey } from '@renderer/types'
export const Absolute = (): ReactElement => {
@@ -77,6 +78,8 @@ export const Absolute = (): ReactElement => {
async function handleMouseMove(event: MouseEvent): Promise<void> {
disableEvent(event)
await send(event)
mouseJiggler.moveEventCallback()
}
// mouse scroll

View File

@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'
import { IpcEvents } from '@common/ipc-events'
import { resolutionAtom } from '@renderer/jotai/device'
import { scrollDirectionAtom, scrollIntervalAtom } from '@renderer/jotai/mouse'
import { mouseJiggler } from '@renderer/libs/mouse-jiggler'
import type { Mouse as MouseKey } from '@renderer/types'
export const Relative = (): ReactElement => {
@@ -110,6 +111,8 @@ export const Relative = (): ReactElement => {
if (x === 0 && y === 0) return
await send(Math.abs(x) < 10 ? x * 2 : x, Math.abs(y) < 10 ? y * 2 : y, 0)
mouseJiggler.moveEventCallback()
}
async function handleWheel(event: WheelEvent): Promise<void> {

View File

@@ -2,12 +2,14 @@ const languages = [
{ key: 'en', name: 'English' },
{ key: 'ru', name: 'Русский' },
{ key: 'zh', name: '中文' },
{ key: 'zh-TW', name: '繁體中文' }, // 新增繁體中文
{ key: 'de', name: 'Deutsch' },
{ key: 'nl', name: 'Nederlands' },
{ key: 'be', name: 'België' },
{ key: 'pt_BR', name: 'Português (Brasil)' },
{ key: 'ko', name: '한국어' },
{ key: 'pt_BR', name: 'Português (Brasil)' }
]
languages.sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }))
languages.sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }));
export default languages
export default languages;

View File

@@ -19,6 +19,7 @@ const be = {
},
video: {
resolution: 'Resolutie',
scale: 'Schaal',
customResolution: 'Aangepast',
device: 'Toestel',
custom: {
@@ -76,5 +77,5 @@ const be = {
}
}
}
export default be
export default be

View File

@@ -19,6 +19,7 @@ const de = {
},
video: {
resolution: 'Auflösung',
scale: 'Skalierung',
customResolution: 'Benutzerdefiniert',
device: 'Gerät',
custom: {
@@ -48,6 +49,9 @@ const de = {
direction: 'Scrollrichtung',
scrollUp: 'Hochscrollen',
scrollDown: 'Runterscrollen',
speed: 'Scrollgeschwindigkeit',
fast: 'Schnell',
slow: 'Langsam',
requestPointer: 'Benutze relativen Modus. Bitte auf den Desktop klicken, um den Mauszeiger anzuzeigen.'
},
settings: {
@@ -76,5 +80,5 @@ const de = {
}
}
}
export default de
export default de

View File

@@ -19,6 +19,7 @@ const en = {
},
video: {
resolution: 'Resolution',
scale: 'Scale',
customResolution: 'Custom',
device: 'Device',
custom: {
@@ -51,7 +52,12 @@ const en = {
speed: 'Wheel speed',
fast: 'Fast',
slow: 'Slow',
requestPointer: 'Using relative mode. Please click desktop to get mouse pointer.'
requestPointer: 'Using relative mode. Please click desktop to get mouse pointer.',
jiggler: {
title: 'Mouse Jiggler',
enable: 'Enable',
disable: 'Disable'
}
},
settings: {
title: 'Settings',

View File

@@ -0,0 +1,84 @@
const ko = {
translation: {
camera: {
tip: '권한을 기다리는 중...',
denied: '권한이 거부되었습니다.',
authorize:
'Target PC 연결에 카메라 권한이 필요합니다. 설정에서 카메라 권한을 허용해 주세요.',
failed: '카메라 연결에 실패했습니다. 다시 시도해 주세요.'
},
modal: {
title: 'USB 장치 선택',
selectVideo: '비디오 입력 장치를 선택해 주세요.',
selectSerial: '시리얼 장치를 선택해 주세요.'
},
menu: {
serial: '시리얼',
keyboard: '키보드',
mouse: '마우스'
},
video: {
resolution: '해상도',
scale: '배율',
customResolution: '사용자 정의',
device: '장치',
custom: {
title: '사용자 정의 해상도',
width: '가로',
height: '세로',
confirm: '확인',
cancel: '취소'
}
},
keyboard: {
paste: '붙여넣기',
virtualKeyboard: '가상 키보드',
ctrlAltDel: 'Ctrl + Alt + Delete'
},
mouse: {
cursor: {
title: '커서 모양',
pointer: '포인터',
grab: '손',
cell: '플러스',
hide: '숨기기'
},
mode: '마우스 모드',
absolute: '절대 모드',
relative: '상대 모드',
direction: '휠 방향',
scrollUp: '위로 스크롤',
scrollDown: '아래로 스크롤',
speed: '휠 속도',
fast: '빠르게',
slow: '느리게',
requestPointer: '상대 모드를 사용 중입니다. 마우스 포인터를 가져오려면 스크린을 클릭하세요.'
},
settings: {
title: '설정',
appearance: {
title: '화면 설정',
language: '언어',
menu: '메뉴 바',
menuTips: '시작 시 메뉴 바 열기'
},
update: {
title: '업데이트 확인',
latest: '최신 버전을 사용 중입니다.',
outdated: '업데이트가 가능합니다. 지금 업데이트하시겠습니까?',
downloading: '다운로드 중...',
installing: '설치 중...',
failed: '업데이트에 실패했습니다. 다시 시도해 주세요.',
confirm: '확인',
cancel: '취소'
},
about: {
title: '정보',
version: '버전',
community: '커뮤니티'
}
}
}
}
export default ko

View File

@@ -19,6 +19,7 @@ const nl = {
},
video: {
resolution: 'Resolutie',
scale: 'Schaal',
customResolution: 'Aangepast',
device: 'Apparaat',
custom: {
@@ -76,5 +77,5 @@ const nl = {
}
}
}
export default nl
export default nl

View File

@@ -19,6 +19,7 @@ const ru = {
},
video: {
resolution: 'Разрешение',
scale: 'Масштаб',
customResolution: 'Пользовательское',
device: 'Видеоустройство',
custom: {
@@ -31,7 +32,8 @@ const ru = {
},
keyboard: {
paste: 'Вставить текст',
virtualKeyboard: 'Виртуальная клавиатура'
virtualKeyboard: 'Виртуальная клавиатура',
ctrlAltDel: 'Ctrl + Alt + Delete'
},
mouse: {
cursor: {
@@ -47,6 +49,9 @@ const ru = {
direction: 'Направление прокрутки',
scrollUp: 'Обычное',
scrollDown: 'Инвертированное',
speed: 'Скорость прокрутки',
fast: 'Быстро',
slow: 'Медленно',
requestPointer:
'Используется относительное позиционирование мыши. Чтобы захватить курсор, щелкните по видео на экране'
},

View File

@@ -0,0 +1,67 @@
const zh-TW = {
translation: {
serial: {
notSupported: '當前瀏覽器不支援序列埠,無法使用鍵鼠。請使用桌面版 Chrome 瀏覽器。',
failed: '序列埠連線失敗,請重試。'
},
camera: {
tip: '等待授權中...',
denied: '權限不足',
authorize: '遠端桌面需要取得攝影機權限,請在瀏覽器設定中允許使用攝影機。',
failed: '攝影機連線失敗,請重試。'
},
modal: {
title: '選擇 USB 裝置',
selectVideo: '請選擇視訊輸入裝置',
selectSerial: '選擇序列埠裝置'
},
menu: {
serial: '序列埠',
keyboard: '鍵盤',
mouse: '滑鼠'
},
video: {
resolution: '解析度',
customResolution: '自訂',
device: '裝置',
custom: {
title: '自訂解析度',
width: '寬度',
height: '高度',
confirm: '確定',
cancel: '取消'
}
},
keyboard: {
paste: '貼上',
virtualKeyboard: '虛擬鍵盤',
ctrlAltDel: 'Ctrl + Alt + Delete'
},
mouse: {
cursor: {
title: '滑鼠指標',
pointer: '箭頭',
grab: '抓取',
cell: '格線',
hide: '隱藏'
},
mode: '滑鼠模式',
absolute: '絕對模式',
relative: '相對模式',
direction: '滾輪方向',
scrollUp: '向上',
scrollDown: '向下',
speed: '滾輪速度',
fast: '快',
slow: '慢',
requestPointer: '正在使用滑鼠相對模式,請點擊桌面取得滑鼠指標。'
},
settings: {
language: '語言',
document: '文件',
download: '下載'
}
}
};
export default zh-TW;

View File

@@ -18,6 +18,7 @@ const zh = {
},
video: {
resolution: '分辨率',
scale: '缩放',
customResolution: '自定义',
device: '设备',
custom: {
@@ -50,7 +51,12 @@ const zh = {
speed: '滚轮速度',
fast: '快',
slow: '慢',
requestPointer: '正在使用鼠标相对模式,请点击桌面获取鼠标指针。'
requestPointer: '正在使用鼠标相对模式,请点击桌面获取鼠标指针。',
jiggler: {
title: '空闲晃动',
enable: '启用',
disable: '禁用'
}
},
settings: {
title: '设置',

View File

@@ -10,6 +10,8 @@ export const resolutionAtom = atom<Resolution>({
height: 1080
})
export const videoScaleAtom = atom<number>(1.0)
export const videoDeviceIdAtom = atom('')
export const videoStateAtom = atom<VideoState>('disconnected')

View File

@@ -10,4 +10,7 @@ export const mouseModeAtom = atom('absolute')
export const scrollDirectionAtom = atom(1)
// mouse scroll interval (unit: ms)
export const scrollIntervalAtom = atom(0);
export const scrollIntervalAtom = atom(0)
// mouse jiggler mode: enable or disable
export const mouseJigglerModeAtom = atom<'enable' | 'disable'>('disable')

View File

@@ -0,0 +1,50 @@
import { IpcEvents } from '@common/ipc-events'
import type { Mouse as MouseKey } from '@renderer/types'
const MOUSE_JIGGLER_INTERVAL = 15_000
const EMPTY_KEY: MouseKey = { left: false, right: false, mid: false }
class MouseJiggler {
private lastMoveTime: number
private timer: NodeJS.Timeout | null
private mode: 'enable' | 'disable'
constructor() {
this.lastMoveTime = Date.now()
this.timer = null
this.mode = 'disable'
}
// enable or disable mouse jiggler
setMode(mode: 'enable' | 'disable'): void {
this.mode = mode
if (mode === 'disable' && this.timer !== null) {
clearInterval(this.timer)
this.timer = null
} else if (mode === 'enable' && this.timer === null) {
this.timer = setInterval(() => {
this.timeoutCallback()
}, MOUSE_JIGGLER_INTERVAL / 5)
}
}
// addEventListener to canvas on 'mousemove' event
moveEventCallback(): void {
if (this.mode === 'enable') {
this.lastMoveTime = Date.now()
}
}
timeoutCallback(): void {
if (Date.now() - this.lastMoveTime > MOUSE_JIGGLER_INTERVAL) {
this.lastMoveTime = Date.now() - 1_000
this.sendJiggle()
}
}
async sendJiggle(): Promise<void> {
await window.electron.ipcRenderer.invoke(IpcEvents.SEND_MOUSE_RELATIVE, EMPTY_KEY, 10, 10, 0)
await window.electron.ipcRenderer.invoke(IpcEvents.SEND_MOUSE_RELATIVE, EMPTY_KEY, -10, -10, 0)
}
}
export const mouseJiggler = new MouseJiggler()

View File

@@ -5,6 +5,7 @@ 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 VIDEO_SCALE_KEY = 'nanokvm-usb-video-scale'
const CUSTOM_RESOLUTION_KEY = 'nanokvm-usb-custom-resolution'
const SERIAL_PORT_KEY = 'nanokvm-serial-port'
const IS_MENU_OPEN_KEY = 'nanokvm-is-menu-open'
@@ -13,6 +14,7 @@ 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'
const MOUSE_SCROLL_INTERVAL_KEY = 'nanokvm-usb-mouse-scroll-interval'
const MOUSE_JIGGLER_MODE_KEY = 'nanokvm-usb-mouse-jiggler-mode'
export function getLanguage(): string | null {
return localStorage.getItem(LANGUAGE_KEY)
@@ -63,6 +65,18 @@ export function removeCustomResolutions(): void {
localStorage.removeItem(CUSTOM_RESOLUTION_KEY)
}
export function getVideoScale(): number | null {
const scale = localStorage.getItem(VIDEO_SCALE_KEY)
if (scale && Number(scale)) {
return Number(scale)
}
return null
}
export function setVideoScale(scale: number): void {
localStorage.setItem(VIDEO_SCALE_KEY, String(scale))
}
export function getSerialPort(): string | null {
return localStorage.getItem(SERIAL_PORT_KEY)
}
@@ -132,3 +146,12 @@ export function setSkipUpdate(skip: boolean): void {
const expiry = 3 * 24 * 60 * 60 * 1000
setWithExpiry(SKIP_UPDATE_KEY, String(skip), expiry)
}
export function getMouseJigglerMode(): 'enable' | 'disable' {
const jiggler = localStorage.getItem(MOUSE_JIGGLER_MODE_KEY)
return jiggler && jiggler === 'enable' ? 'enable' : 'disable'
}
export function setMouseJigglerMode(jiggler: 'enable' | 'disable'): void {
localStorage.setItem(MOUSE_JIGGLER_MODE_KEY, jiggler)
}