mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-11-10 18:09:19 -06:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4af32fc703 | ||
|
|
a611e5ef56 | ||
|
|
94051c3037 | ||
|
|
d5cd706078 | ||
|
|
7460ebfd07 | ||
|
|
e3877308a0 | ||
|
|
7c28fb8124 | ||
|
|
b18873364f | ||
|
|
016a7d8a51 | ||
|
|
585f1bc2b5 |
16
CHANGELOG.md
16
CHANGELOG.md
@@ -1,3 +1,19 @@
|
||||
## 2.3.4 (2026-01-26)
|
||||
|
||||
### Features
|
||||
|
||||
* Introduced `Leader Key` functionality to bypass local browser shortcut interception
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fixed an issue where the Right Shift key was not being recognized on Windows system
|
||||
* Fixed a bug where the menu bar would fail to close correctly
|
||||
|
||||
### Localization
|
||||
|
||||
* Updated Traditional Chinese translations (thanks to [@j796160836](https://github.com/j796160836))
|
||||
* Updated Korean translations (thanks to [@kmw0410](https://github.com/kmw0410))
|
||||
|
||||
## 2.3.3 (2026-01-16)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -29,3 +29,11 @@ type AddShortcutReq struct {
|
||||
type DeleteShortcutReq struct {
|
||||
ID string `validate:"required"`
|
||||
}
|
||||
|
||||
type SetLeaderKeyReq struct {
|
||||
Key string `validate:"omitempty"`
|
||||
}
|
||||
|
||||
type GetLeaderKeyRsp struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ func hidRouter(r *gin.Engine) {
|
||||
api.POST("/hid/shortcut", service.AddShortcut) // add shortcut
|
||||
api.DELETE("/hid/shortcut", service.DeleteShortcut) // delete shortcut
|
||||
|
||||
api.GET("/hid/shortcut/leader-key", service.GetLeaderKey) // set shortcut leader key
|
||||
api.POST("/hid/shortcut/leader-key", service.SetLeaderKey) // set shortcut leader key
|
||||
|
||||
api.GET("/hid/mode", service.GetHidMode) // get hid mode
|
||||
api.POST("/hid/mode", service.SetHidMode) // set hid mode
|
||||
api.POST("/hid/reset", service.ResetHid) // reset hid
|
||||
|
||||
63
server/service/hid/leader_key.go
Normal file
63
server/service/hid/leader_key.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package hid
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"NanoKVM-Server/proto"
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
LeaderKeyFile = "/etc/kvm/leader-key"
|
||||
)
|
||||
|
||||
func (s *Service) SetLeaderKey(c *gin.Context) {
|
||||
var req proto.SetLeaderKeyReq
|
||||
var rsp proto.Response
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid arguments")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Key == "" {
|
||||
err := os.Remove(LeaderKeyFile)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
rsp.ErrRsp(c, -2, "reset failed")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
err := os.WriteFile(LeaderKeyFile, []byte(req.Key), 0o644)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -3, "write failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("set leader key: %s", req.Key)
|
||||
}
|
||||
|
||||
func (s *Service) GetLeaderKey(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
data, err := os.ReadFile(LeaderKeyFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
rsp.OkRspWithData(c, &proto.GetLeaderKeyRsp{
|
||||
Key: "",
|
||||
})
|
||||
return
|
||||
}
|
||||
rsp.ErrRsp(c, -1, "read leader key failed")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetLeaderKeyRsp{
|
||||
Key: strings.Replace(string(data), "\n", "", -1),
|
||||
})
|
||||
|
||||
log.Debugf("get leader key successful")
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
package vm
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/config"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"NanoKVM-Server/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
@@ -19,6 +20,7 @@ var imageVersionMap = map[string]string{
|
||||
"2024-11-13-09-59-9c961a.img": "v1.3.0",
|
||||
"2025-02-17-19-08-3649fe.img": "v1.4.0",
|
||||
"2025-04-17-14-21-98d17d.img": "v1.4.1",
|
||||
"2026-01-05-1_4_1.img": "v1.4.2",
|
||||
}
|
||||
|
||||
func (s *Service) GetInfo(c *gin.Context) {
|
||||
|
||||
@@ -43,3 +43,13 @@ export function deleteShortcut(id: string) {
|
||||
};
|
||||
return http.delete('/api/hid/shortcut', data);
|
||||
}
|
||||
|
||||
// get shortcut leader key
|
||||
export function getLeaderKey() {
|
||||
return http.get('/api/hid/shortcut/leader-key');
|
||||
}
|
||||
|
||||
// set shortcut leader key
|
||||
export function setLeaderKey(key: string) {
|
||||
return http.post('/api/hid/shortcut/leader-key', { key });
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ export interface MenuVisibilityState {
|
||||
isMenuExpanded: boolean;
|
||||
isMenuHidden: boolean;
|
||||
isMenuMoved: boolean;
|
||||
isInvisible: boolean;
|
||||
handleHovered: (hovered: boolean) => void;
|
||||
handleMoved: () => void;
|
||||
setIsMenuExpanded: (expanded: boolean) => void;
|
||||
@@ -76,18 +75,12 @@ export function useMenuVisibility(): MenuVisibilityState {
|
||||
|
||||
// Handle display mode changes
|
||||
useEffect(() => {
|
||||
if (menuDisplayMode === 'off') {
|
||||
setIsMenuExpanded(false);
|
||||
setIsMenuHidden(false);
|
||||
stopCountdown();
|
||||
} else if (menuDisplayMode === 'always') {
|
||||
setIsMenuExpanded(true);
|
||||
setIsMenuHidden(false);
|
||||
stopCountdown();
|
||||
} else if (menuDisplayMode === 'auto') {
|
||||
setIsMenuExpanded(true);
|
||||
setIsMenuHidden(false);
|
||||
setIsMenuHidden(false);
|
||||
|
||||
if (menuDisplayMode === 'auto') {
|
||||
startCountdown();
|
||||
} else {
|
||||
stopCountdown();
|
||||
}
|
||||
}, [menuDisplayMode, startCountdown, stopCountdown]);
|
||||
|
||||
@@ -114,13 +107,10 @@ export function useMenuVisibility(): MenuVisibilityState {
|
||||
}
|
||||
}, [isMenuMoved]);
|
||||
|
||||
const isInvisible = menuDisplayMode === 'off' && !isMenuExpanded;
|
||||
|
||||
return {
|
||||
isMenuExpanded,
|
||||
isMenuHidden,
|
||||
isMenuMoved,
|
||||
isInvisible,
|
||||
isInitialized,
|
||||
handleHovered,
|
||||
handleMoved,
|
||||
|
||||
@@ -92,6 +92,34 @@ const en = {
|
||||
captureTips:
|
||||
'Capturing system-level keys (such as the Windows key) requires full-screen permission.',
|
||||
enterFullScreen: 'Toggle full-screen mode.'
|
||||
},
|
||||
leaderKey: {
|
||||
title: 'Leader Key',
|
||||
desc: 'Bypass browser restrictions and send system shortcuts directly to the remote host.',
|
||||
howToUse: 'How to Use',
|
||||
simultaneous: {
|
||||
title: 'Simultaneous Mode',
|
||||
desc1: 'Press and hold the Leader Key, then press the shortcut.',
|
||||
desc2: 'Intuitive, but may conflict with system shortcuts.'
|
||||
},
|
||||
sequential: {
|
||||
title: 'Sequential Mode',
|
||||
desc1:
|
||||
'Press the Leader Key → press the shortcut in sequence → press the Leader Key again.',
|
||||
desc2: 'Requires more steps, but completely avoids system conflicts.'
|
||||
},
|
||||
enable: 'Enable Leader Key',
|
||||
tip: 'When assigned as a Leader Key, this key functions exclusively as a shortcut trigger and loses its default behavior.',
|
||||
placeholder: 'Please press the Leader Key',
|
||||
shiftRight: 'Right Shift',
|
||||
ctrlRight: 'Right Ctrl',
|
||||
metaRight: 'Right Win',
|
||||
submit: 'Submit',
|
||||
recorder: {
|
||||
rec: 'REC',
|
||||
activate: 'Activate keys',
|
||||
input: 'Please press the shortcut...'
|
||||
}
|
||||
}
|
||||
},
|
||||
mouse: {
|
||||
|
||||
@@ -218,10 +218,18 @@ const ko = {
|
||||
display: '표시',
|
||||
language: '언어',
|
||||
languageDesc: '인터페이스 언어를 선택하세요',
|
||||
menuBar: '메뉴 바',
|
||||
menuBarDesc: '메뉴 바에 아이콘을 표시',
|
||||
webTitle: '웹 제목',
|
||||
webTitleDesc: '웹 페이지 제목 사용자 지정'
|
||||
webTitleDesc: '웹 페이지 제목 사용자 지정',
|
||||
menuBar: {
|
||||
title: '메뉴 바',
|
||||
mode: '표시 모드',
|
||||
modeDesc: '메뉴 바를 화면에 표시합니다',
|
||||
modeOff: '꺼짐',
|
||||
modeAuto: '자동 숨기기',
|
||||
modeAlways: '항상 보이기',
|
||||
icons: '하위 메뉴 아이콘',
|
||||
iconsDesc: '메뉴 바에 하위 메뉴 아이콘을 표시합니다'
|
||||
}
|
||||
},
|
||||
device: {
|
||||
title: '장치',
|
||||
|
||||
@@ -81,6 +81,33 @@ const zh = {
|
||||
save: '保存',
|
||||
captureTips: '捕获系统级按键(如 Windows 键)需要全屏权限。',
|
||||
enterFullScreen: '切换全屏模式。'
|
||||
},
|
||||
leaderKey: {
|
||||
title: '引导键',
|
||||
desc: '绕过浏览器限制,向远程主机发送被系统拦截的快捷键。',
|
||||
howToUse: '使用方法',
|
||||
simultaneous: {
|
||||
title: '组合按键模式',
|
||||
desc1: '按住引导键不放,同时按下目标快捷键。',
|
||||
desc2: '操作直观,但少数快捷键可能因系统占用而无法生效。'
|
||||
},
|
||||
sequential: {
|
||||
title: '序列输入模式',
|
||||
desc1: '点击【引导键】开始 → 依次点击【快捷键】→ 点击【引导键】结束。',
|
||||
desc2: '步骤较多,但能完美避开系统键位冲突。'
|
||||
},
|
||||
enable: '启用引导键',
|
||||
tip: '设为引导键后,该按键将仅用于触发快捷键,不再作为普通按键使用。',
|
||||
placeholder: '请按下引导键',
|
||||
shiftRight: '右 Shift',
|
||||
ctrlRight: '右 Ctrl',
|
||||
metaRight: '右 Win',
|
||||
submit: '确定',
|
||||
recorder: {
|
||||
rec: '录制中',
|
||||
activate: '激活按键',
|
||||
input: '请按下快捷键...'
|
||||
}
|
||||
}
|
||||
},
|
||||
mouse: {
|
||||
|
||||
@@ -14,7 +14,7 @@ const zh_tw = {
|
||||
placeholderPassword2: '請再次輸入密碼',
|
||||
noEmptyUsername: '使用者名稱不能為空',
|
||||
noEmptyPassword: '密碼不能為空',
|
||||
noAccount: '取得使用者資訊失敗,請重新整理網頁或重設密碼',
|
||||
noAccount: '找不到使用者,請重新整理網頁或重設密碼',
|
||||
invalidUser: '使用者名稱或密碼錯誤',
|
||||
error: '非預期性錯誤',
|
||||
changePassword: '更改密碼',
|
||||
@@ -33,26 +33,28 @@ const zh_tw = {
|
||||
reset4: 'SSH 預設帳號:',
|
||||
change1: '請注意,此操作將同時更新下列密碼:',
|
||||
change2: '網頁登入密碼',
|
||||
change3: 'root user密碼 (SSH登入密碼)',
|
||||
change3: 'root user 密碼 (SSH登入密碼)',
|
||||
change4: '如果您忘記密碼,需要長按 NanoKVM 上的 BOOT 按鍵來重設密碼。'
|
||||
}
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: '設定 NanoKVM WiFi',
|
||||
description: '設定 NanoKVM Wi-Fi',
|
||||
success: '請檢查 NanoKVM 的網路狀態,並存取新的 IP 位址。',
|
||||
failed: '操作失敗,請重試。',
|
||||
invalidMode: '目前模式不支援設定網路。請先前往裝置啟用 Wi-Fi 配置模式。',
|
||||
confirmBtn: '確定',
|
||||
finishBtn: '完成'
|
||||
},
|
||||
screen: {
|
||||
scale: '缩放',
|
||||
title: '螢幕',
|
||||
video: '編碼格式',
|
||||
videoDirectTips: '本模式需先啟用HTTPS,請前往「設定 -> 設備」中開啟',
|
||||
videoDirectTips: '本模式需先啟用 HTTPS,請前往「設定 -> 設備」中開啟',
|
||||
resolution: '解析度',
|
||||
auto: '自動',
|
||||
autoTips:
|
||||
'在特定解析度下可能會出現畫面撕裂或滑鼠偏移的情況。考慮調整遠端主機的解析度或停用自動模式。',
|
||||
'在某些特定解析度下可能會出現畫面撕裂或滑鼠偏移的情況。請調整遠端主機的解析度或停用自動模式。',
|
||||
fps: '更新頻率',
|
||||
customizeFps: '自定義',
|
||||
quality: '品質',
|
||||
@@ -61,17 +63,26 @@ const zh_tw = {
|
||||
qualityMedium: '中',
|
||||
qualityLow: '低',
|
||||
frameDetect: '影格檢測',
|
||||
frameDetectTip: '計算影格之間的差異。當遠端主機畫面未偵測到任何變更時,停止傳輸視訊串流。',
|
||||
frameDetectTip: '計算影格之間的差異。當遠端主機畫面未偵測到任何變更時,停止視訊傳輸串流。',
|
||||
resetHdmi: '重置 HDMI'
|
||||
},
|
||||
keyboard: {
|
||||
title: 'Keyboard',
|
||||
title: '鍵盤',
|
||||
paste: '貼上',
|
||||
tips: '僅支援標準鍵盤字母和符號',
|
||||
tips: '僅支援標準鍵盤的字母和符號',
|
||||
placeholder: '請輸入內容',
|
||||
submit: '送出',
|
||||
virtual: '虛擬鍵盤',
|
||||
ctrlaltdel: 'Ctrl+Alt+Del'
|
||||
ctrlaltdel: 'Ctrl+Alt+Del',
|
||||
shortcut: {
|
||||
title: '快捷鍵',
|
||||
custom: '自定義',
|
||||
capture: '點選此處錄製快捷鍵',
|
||||
clear: '清空',
|
||||
save: '儲存',
|
||||
captureTips: '錄製系統級按鍵(如 Windows 鍵)需要全螢幕的權限。',
|
||||
enterFullScreen: '切換全螢幕模式。'
|
||||
}
|
||||
},
|
||||
mouse: {
|
||||
title: '滑鼠',
|
||||
@@ -85,53 +96,63 @@ const zh_tw = {
|
||||
mode: '滑鼠模式',
|
||||
absolute: '絕對模式',
|
||||
relative: '相對模式',
|
||||
direction: '滾輪方向',
|
||||
scrollUp: '向上',
|
||||
scrollDown: '向下',
|
||||
speed: '滾輪速度',
|
||||
fast: '快',
|
||||
slow: '慢',
|
||||
requestPointer: '使用相對模式。請按一下桌面以取得滑鼠游標。',
|
||||
requestPointer: '正在使用滑鼠相對模式。請按一下桌面以取得滑鼠游標。',
|
||||
resetHid: '重設 HID',
|
||||
hidOnly: {
|
||||
title: 'HID-Only 模式',
|
||||
desc: "如果您的滑鼠和鍵盤停止回應,且重設 HID 無效,可能是 NanoKVM 與裝置間的相容性問題。請嘗試啟用 HID-Only 模式以獲得更好的相容性。",
|
||||
tip1: '啟用 HID-Only 模式將會卸載虛擬隨身碟和虛擬網卡',
|
||||
tip2: '在 HID-Only 模式下,映像掛載功能將被停用',
|
||||
desc: "如果您的滑鼠和鍵盤沒有反應,且重設 HID 無效,可能是 NanoKVM 與您的裝置間有相容性問題。請嘗試啟用 HID-Only 模式以獲得更好的相容性。",
|
||||
tip1: '啟用 HID-Only 模式將會停用虛擬隨身碟和虛擬網卡的功能',
|
||||
tip2: '在 HID-Only 模式下,映像檔掛載功能將被停用',
|
||||
tip3: 'NanoKVM 將在切換模式後自動重新啟動',
|
||||
enable: '啟用 HID-Only 模式',
|
||||
disable: '停用 HID-Only 模式'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: '映像',
|
||||
title: '映像檔',
|
||||
loading: '載入中...',
|
||||
empty: '未找到任何內容',
|
||||
cdrom: '以CD-ROM模式掛載',
|
||||
mountMode: '掛載模式',
|
||||
mountFailed: '掛載失敗',
|
||||
mountDesc: '在某些系統中,在掛載映像之前需要中斷遠端主機上的虛擬磁碟。',
|
||||
refresh: 'Refresh the image list',
|
||||
mountDesc: '在某些系統中,需要在遠端主機中彈出虛擬硬碟後再掛載映像檔。',
|
||||
unmountFailed: '解除安裝失敗',
|
||||
unmountDesc: '在某些系統中,需要在遠端主機中手動彈出後再解除安裝映像。',
|
||||
refresh: '重新整理映像檔列表',
|
||||
attention: '注意',
|
||||
deleteConfirm: '確定要刪除該映像檔嗎?',
|
||||
okBtn: '確定',
|
||||
cancelBtn: '取消',
|
||||
tips: {
|
||||
title: '如何上傳',
|
||||
usb1: '透過 USB 將 NanoKVM 連接到您的電腦。',
|
||||
usb2: '確保已安裝虛擬磁碟(設定 - 虛擬磁碟)。',
|
||||
usb3: '開啟電腦上的虛擬磁碟,將映像檔案複製到虛擬磁碟的根目錄下。',
|
||||
usb3: '開啟電腦上的虛擬磁碟,將映象檔案複製到虛擬磁碟的根目錄下。',
|
||||
scp1: '確保 NanoKVM 和您的電腦位於同一區域網路。',
|
||||
scp2: '開啟電腦上的終端機,使用 SCP 指令將映像檔案上傳到 NanoKVM 的 /data 目錄下。',
|
||||
scp3: '範例:scp your-image-path root@your-nanokvm-ip:/data',
|
||||
tfCard: 'microSD 卡',
|
||||
tf1: '此方法適用於 Linux 系統',
|
||||
tf2: '從 NanoKVM 取得 microSD 卡(FULL 版本請先拆開外殼)。',
|
||||
tf3: '將 microSD 卡插入讀卡機並連接至電腦。',
|
||||
tf4: '將映像檔案複製到 microSD 卡的 /data 目錄下。',
|
||||
tf5: '將 microSD 卡重新插入 NanoKVM。'
|
||||
tf2: '從 NanoKVM 拔出 microSD 卡(FULL 版本請先拆開外殼)。',
|
||||
tf3: '使用 USB 讀卡機將 microSD 卡連接至電腦。',
|
||||
tf4: '將映像檔複製到 microSD 卡的 /data 目錄下。',
|
||||
tf5: '將 microSD 卡重新插回 NanoKVM。'
|
||||
}
|
||||
},
|
||||
script: {
|
||||
title: '腳本',
|
||||
title: '指令碼',
|
||||
upload: '上傳',
|
||||
run: '執行',
|
||||
runBackground: '背景執行',
|
||||
runFailed: '執行失敗',
|
||||
attention: '注意',
|
||||
delDesc: '確定刪除該檔案?',
|
||||
delDesc: '確定要刪除該檔案嗎?',
|
||||
confirm: '確定',
|
||||
cancel: '取消',
|
||||
delete: '刪除',
|
||||
@@ -164,22 +185,22 @@ const zh_tw = {
|
||||
ok: '確定'
|
||||
},
|
||||
download: {
|
||||
title: '下载映像',
|
||||
input: '請輸入映像檔的下載URL',
|
||||
title: '下载映像檔',
|
||||
input: '請輸入映像檔的下載 URL',
|
||||
ok: '確定',
|
||||
disabled: '/data 為唯讀目錄,無法下載映像檔'
|
||||
},
|
||||
power: {
|
||||
title: '電源',
|
||||
showConfirm: 'Confirmation',
|
||||
showConfirmTip: 'Power operations require an extra confirmation',
|
||||
title: '電源控制',
|
||||
showConfirm: '顯示確認框',
|
||||
showConfirmTip: '電源操作需要二次確認',
|
||||
reset: '重新啟動',
|
||||
power: '電源',
|
||||
powerShort: '電源 (短按)',
|
||||
powerLong: '電源 (長按)',
|
||||
resetConfirm: '執行重新啟動按鍵操作?',
|
||||
powerConfirm: '執行電源按鍵操作?',
|
||||
okBtn: '是',
|
||||
resetConfirm: '確認執行重新啟動的按鍵操作嗎?',
|
||||
powerConfirm: '確認執行電源的按鍵操作嗎?',
|
||||
okBtn: '確認',
|
||||
cancelBtn: '取消'
|
||||
},
|
||||
settings: {
|
||||
@@ -191,8 +212,8 @@ const zh_tw = {
|
||||
mdns: 'mDNS',
|
||||
application: '應用程式版本',
|
||||
applicationTip: 'NanoKVM 網頁程式版本',
|
||||
image: '映像版本',
|
||||
imageTip: 'NanoKVM 系统镜像版本',
|
||||
image: '韌體版本',
|
||||
imageTip: 'NanoKVM 系统韌體版本',
|
||||
deviceKey: '設備序號',
|
||||
community: '社群',
|
||||
hostname: '主機名稱',
|
||||
@@ -207,10 +228,20 @@ const zh_tw = {
|
||||
title: '外觀',
|
||||
display: '顯示',
|
||||
language: '語言',
|
||||
menuBar: '選單列',
|
||||
languageDesc: '選擇介面語言',
|
||||
menuBarDesc: '是否在選單列中顯示圖案',
|
||||
webTitle: '網頁標題',
|
||||
webTitleDesc: '自訂網頁標題'
|
||||
webTitleDesc: '自訂網頁標題',
|
||||
menuBar: {
|
||||
title: '選單列',
|
||||
mode: '顯示方式',
|
||||
modeDesc: '選單欄在螢幕上的顯示方式',
|
||||
modeOff: '關閉',
|
||||
modeAuto: '自動隱藏',
|
||||
modeAlways: '始終顯示',
|
||||
icons: '選單圖示',
|
||||
iconsDesc: '是否在選單欄中顯示子選單圖示'
|
||||
}
|
||||
},
|
||||
device: {
|
||||
title: '設備',
|
||||
@@ -218,57 +249,80 @@ const zh_tw = {
|
||||
title: 'OLED',
|
||||
description: '設定 OLED 螢幕自動睡眠時間',
|
||||
0: '永不',
|
||||
15: '15秒',
|
||||
30: '30秒',
|
||||
60: '1分鐘',
|
||||
180: '3分鐘',
|
||||
300: '5分鐘',
|
||||
600: '10分鐘',
|
||||
1800: '30分鐘',
|
||||
3600: '1小時'
|
||||
15: '15 秒',
|
||||
30: '30 秒',
|
||||
60: '1 分鐘',
|
||||
180: '3 分鐘',
|
||||
300: '5 分鐘',
|
||||
600: '10 分鐘',
|
||||
1800: '30 分鐘',
|
||||
3600: '1 小時'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: '設定 Wi-Fi',
|
||||
setBtn: '設定'
|
||||
setBtn: '設定',
|
||||
apMode: 'AP 模式已啟用,該模式下僅支援掃描 QRCode 配網',
|
||||
connect: '連線 Wi-Fi',
|
||||
connectDesc1: '請輸入網路名稱和密碼',
|
||||
connectDesc2: '請輸入密碼以連線此網路',
|
||||
disconnect: '是否要中斷該網路連線?',
|
||||
failed: '連線失敗,請重試',
|
||||
ssid: 'SSID 名稱',
|
||||
password: '密碼',
|
||||
joinBtn: '加入',
|
||||
confirmBtn: '確定',
|
||||
cancelBtn: '取消'
|
||||
},
|
||||
ssh: {
|
||||
description: '啟用SSH',
|
||||
description: '啟用 SSH 伺服器',
|
||||
tip: '啟用前請務必設定強密碼(帳號 - 更改密碼)'
|
||||
},
|
||||
tls: {
|
||||
description: '啟用 HTTPS',
|
||||
tip: '啟用 HTTPS 可以提高安全性,但可能會增加延遲,特別是使用 MJPEG 編碼格式時。'
|
||||
description: '啟用 HTTPS 協議',
|
||||
tip: '啟用 HTTPS 可以提高安全性,但可能會增加傳輸延遲,特別是使用 MJPEG 格式傳輸時。'
|
||||
},
|
||||
advanced: '進階設定',
|
||||
swap: {
|
||||
title: 'Swap',
|
||||
disable: '停用',
|
||||
description: '設定Swap檔大小',
|
||||
description: '設定 Swap 檔大小',
|
||||
tip: "啟用此功能可能會減少SD卡的使用壽命!"
|
||||
},
|
||||
mouseJiggler: {
|
||||
title: 'Mouse Jiggler',
|
||||
title: '滑鼠抖動模式 (Mouse Jiggler)',
|
||||
description: '避免遠端主機進入休眠狀態',
|
||||
disable: '停用',
|
||||
absolute: '絕對模式',
|
||||
relative: '相對模式'
|
||||
},
|
||||
mdns: {
|
||||
description: '啟用mDNS服務',
|
||||
description: '啟用 mDNS 發現服務',
|
||||
tip: "若無需求,建議關閉此功能"
|
||||
},
|
||||
hdmi: {
|
||||
description: '啟用 HDMI/螢幕 輸出'
|
||||
},
|
||||
autostart: {
|
||||
title: '啟動時指令碼設定',
|
||||
description: '管理能夠在 NanoKVM 啟動時自動執行的相關指令碼',
|
||||
new: '建立新指令碼',
|
||||
deleteConfirm: '確定要刪除該檔案嗎?',
|
||||
yes: '是',
|
||||
no: '否',
|
||||
scriptName: 'Script 名稱',
|
||||
scriptContent: 'Script 內容',
|
||||
settings: '設定'
|
||||
},
|
||||
hidOnly: 'HID-Only 模式',
|
||||
hidOnlyDesc: '該模式下不再打開虛擬隨身碟,僅保留基礎的 鍵盤/滑鼠 的控制功能。',
|
||||
disk: '虛擬隨身碟',
|
||||
diskDesc: '在遠端主機上連接虛擬隨身碟',
|
||||
network: '虛擬網卡',
|
||||
networkDesc: '在遠端主機上新增虛擬網卡',
|
||||
reboot: '重新啟動',
|
||||
rebootDesc: '你確定要重新啟動 NanoKVM?',
|
||||
okBtn: '是',
|
||||
rebootDesc: '您確定要重新啟動 NanoKVM?',
|
||||
okBtn: '確定',
|
||||
cancelBtn: '取消'
|
||||
},
|
||||
tailscale: {
|
||||
@@ -278,6 +332,10 @@ const zh_tw = {
|
||||
tip: '當記憶體使用量超過限制時,會更積極的進行垃圾回收來嘗試釋放記憶體。若使用 Tailscale 建議設定為 50MB,於重啟 Tailscale 後生效。',
|
||||
disable: '關閉'
|
||||
},
|
||||
swap: {
|
||||
title: 'Swap',
|
||||
tip: '如果啟用記憶體最佳化後依然存在問題,可以嘗試開啟 Swap。啟用後會將交換檔案設定為 256MB,可以在「設定 - 裝置」中修改該選項。'
|
||||
},
|
||||
restart: '確定要重啟 Tailscale 嗎?',
|
||||
stop: '確定要停止 Tailscale 嗎?',
|
||||
stopDesc: '此操作將會登出帳號,並停止開機自動啟動。',
|
||||
@@ -290,9 +348,11 @@ const zh_tw = {
|
||||
download: '下載',
|
||||
package: '安裝包',
|
||||
unzip: '並解壓縮它',
|
||||
upTailscale: '將 Tailscale 上傳到 NanoKVM 目錄 /usr/bin/',
|
||||
upTailscaled: '將 Tailscale 上傳到 NanoKVM 目錄 /usr/sbin/',
|
||||
upTailscale: '將 Tailscale 上傳到 NanoKVM 的 /usr/bin/ 資料夾',
|
||||
upTailscaled: '將 Tailscale 上傳到 NanoKVM 的 /usr/sbin/ 資料夾',
|
||||
refresh: '重新整理頁面',
|
||||
notRunning: 'Tailscale 尚未執行',
|
||||
run: '啟動',
|
||||
notLogin: '設備尚未綁定。請登入並將該裝置綁定到您的帳戶。',
|
||||
urlPeriod: '此網址有效期限為 10 分鐘',
|
||||
login: '登入',
|
||||
@@ -302,24 +362,33 @@ const zh_tw = {
|
||||
deviceIP: '裝置 IP',
|
||||
account: '帳號',
|
||||
logout: '登出',
|
||||
logoutDesc: '確認登出?',
|
||||
logoutDesc: '確認要登出嗎?',
|
||||
uninstall: '移除 Tailscale',
|
||||
uninstallDesc: '確定要解除安裝 Tailscale 嗎?',
|
||||
reboot: '重啟',
|
||||
rebootDesc: '您確定要重啟 NanoKVM 嗎?',
|
||||
okBtn: '確認',
|
||||
cancelBtn: '取消'
|
||||
},
|
||||
update: {
|
||||
title: '檢查更新',
|
||||
queryFailed: '取得版本失敗',
|
||||
queryFailed: '取得版本號失敗',
|
||||
updateFailed: '更新失敗。請重試。',
|
||||
isLatest: '您已經擁有最新版本。',
|
||||
available: '有可用更新。確定要更新嗎?',
|
||||
updating: '正在更新。請稍等...',
|
||||
isLatest: '您已經是最新版本。',
|
||||
available: '發現有可用更新。您確定要更新嗎?',
|
||||
updating: '更新中,請稍候...',
|
||||
confirm: '確定',
|
||||
cancel: '取消',
|
||||
preview: '預覽更新',
|
||||
previewDesc: '搶先體驗新功能和改進',
|
||||
previewTip:
|
||||
'請注意,預覽版本可能包含錯誤或功能不完整!'
|
||||
previewDesc: '預覽版本,搶先體驗新功能和改進',
|
||||
previewTip: '請注意,預覽版本可能包含一些不穩定因素或未完善的功能!',
|
||||
offline: {
|
||||
title: '離線更新',
|
||||
desc: '透過本地安裝包進行更新',
|
||||
upload: '上傳',
|
||||
invalidName: '檔名格式錯誤,請前往 GitHub 釋出頁下載安裝包。',
|
||||
updateFailed: '更新失敗,請重試'
|
||||
}
|
||||
},
|
||||
account: {
|
||||
title: '帳號',
|
||||
@@ -327,9 +396,9 @@ const zh_tw = {
|
||||
password: '密碼',
|
||||
updateBtn: '修改',
|
||||
logoutBtn: '登出',
|
||||
logoutDesc: '你確定要登出嗎?',
|
||||
okBtn: '是',
|
||||
cancelBtn: '取消'
|
||||
logoutDesc: '您確定要登出嗎?',
|
||||
confirm: '確定',
|
||||
cancel: '取消'
|
||||
}
|
||||
},
|
||||
error: {
|
||||
|
||||
@@ -5,3 +5,6 @@ export const isKeyboardEnableAtom = atom(true);
|
||||
|
||||
// is the virtual keyboard opened
|
||||
export const isKeyboardOpenAtom = atom(false);
|
||||
|
||||
// leader key code for bypassing browser shortcuts (empty string means disabled)
|
||||
export const leaderKeyAtom = atom('');
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
export type System = 'Windows' | 'macOS' | 'Linux' | 'Unknown';
|
||||
|
||||
export interface BrowserInfo {
|
||||
os: string;
|
||||
os: System;
|
||||
isChrome: boolean;
|
||||
}
|
||||
|
||||
export function getOperatingSystem(): string {
|
||||
export function getOperatingSystem(): System {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
const platformSource =
|
||||
if ('userAgentData' in navigator) {
|
||||
// @ts-expect-error check userAgentData.platform
|
||||
'userAgentData' in navigator ? navigator.userAgentData.platform : navigator.platform;
|
||||
|
||||
if (!platformSource) {
|
||||
return 'Unknown';
|
||||
const platform = navigator.userAgentData?.platform?.toLowerCase();
|
||||
if (platform) {
|
||||
if (platform === 'windows') return 'Windows';
|
||||
if (platform === 'macos') return 'macOS';
|
||||
if (platform === 'linux' || platform === 'android') return 'Linux';
|
||||
}
|
||||
}
|
||||
|
||||
const platform = platformSource.toLowerCase();
|
||||
// Fallback to User Agent
|
||||
const userAgent = navigator.userAgent;
|
||||
|
||||
if (platform.startsWith('win')) return 'Windows';
|
||||
if (platform.startsWith('mac')) return 'macOS';
|
||||
if (platform.startsWith('linux')) return 'Linux';
|
||||
if (/Win/i.test(userAgent)) return 'Windows';
|
||||
if (/Mac|iPhone|iPod|iPad/i.test(userAgent)) return 'macOS';
|
||||
if (/Linux|Android/i.test(userAgent)) return 'Linux';
|
||||
|
||||
return platformSource;
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
export function isChromeBrowser(): boolean {
|
||||
|
||||
@@ -7,31 +7,38 @@ import { isModifier } from '@/lib/keymap';
|
||||
import { client, MessageEvent } from '@/lib/websocket.ts';
|
||||
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||
|
||||
interface AltGrState {
|
||||
active: boolean;
|
||||
ctrlLeftTimestamp: number;
|
||||
}
|
||||
|
||||
const ALTGR_THRESHOLD_MS = 10;
|
||||
import { Recorder } from './recorder.tsx';
|
||||
import { useAltGr } from './useAltGr.ts';
|
||||
import { useLeaderKey } from './useLeaderKey.ts';
|
||||
import { normalizeKeyCode } from './utils.ts';
|
||||
|
||||
export const Keyboard = () => {
|
||||
const os = getOperatingSystem();
|
||||
|
||||
const isKeyboardEnabled = useAtomValue(isKeyboardEnableAtom);
|
||||
|
||||
const keyboardRef = useRef(new KeyboardReport());
|
||||
const pressedKeys = useRef(new Set<string>());
|
||||
const altGrState = useRef<AltGrState | null>(null);
|
||||
const isComposing = useRef(false);
|
||||
|
||||
// Send key event helper
|
||||
const sendKeyEvent = (type: 'keydown' | 'keyup', code: string) => {
|
||||
const kb = keyboardRef.current;
|
||||
const report = type === 'keydown' ? kb.keyDown(code) : kb.keyUp(code);
|
||||
sendReport(report);
|
||||
};
|
||||
|
||||
// Init leader key handler
|
||||
const leaderKey = useLeaderKey(pressedKeys, sendKeyEvent);
|
||||
const leaderKeyRef = useRef(leaderKey);
|
||||
leaderKeyRef.current = leaderKey;
|
||||
|
||||
// Init AltGr key handler
|
||||
const altGr = useAltGr(os, pressedKeys, sendKeyEvent);
|
||||
const altGrRef = useRef(altGr);
|
||||
altGrRef.current = altGr;
|
||||
|
||||
useEffect(() => {
|
||||
if (getOperatingSystem() === 'Windows' && !altGrState.current) {
|
||||
altGrState.current = { active: false, ctrlLeftTimestamp: 0 };
|
||||
}
|
||||
|
||||
if (!isKeyboardEnabled) {
|
||||
releaseKeys();
|
||||
return;
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener('keyup', handleKeyUp);
|
||||
document.addEventListener('compositionstart', handleCompositionStart);
|
||||
@@ -39,111 +46,94 @@ export const Keyboard = () => {
|
||||
window.addEventListener('blur', handleBlur);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
// Key down event
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (!isKeyboardEnabled) return;
|
||||
|
||||
// Skip during IME composition
|
||||
if (isComposing.current || event.isComposing) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const code = event.code;
|
||||
if (pressedKeys.current.has(code)) return;
|
||||
const code = normalizeKeyCode(event, os);
|
||||
if (!code || pressedKeys.current.has(code)) return;
|
||||
|
||||
// When AltGr is pressed, browsers send ControlLeft followed immediately by AltRight
|
||||
if (altGrState.current) {
|
||||
if (code === 'ControlLeft') {
|
||||
altGrState.current.ctrlLeftTimestamp = event.timeStamp;
|
||||
} else if (code === 'AltRight') {
|
||||
const timeDiff = event.timeStamp - altGrState.current.ctrlLeftTimestamp;
|
||||
if (timeDiff < ALTGR_THRESHOLD_MS && pressedKeys.current.has('ControlLeft')) {
|
||||
pressedKeys.current.delete('ControlLeft');
|
||||
handleKeyEvent({ type: 'keyup', code: 'ControlLeft' });
|
||||
altGrState.current.active = true;
|
||||
}
|
||||
}
|
||||
// Handle leader key
|
||||
const leaderHandled = leaderKeyRef.current.handleKeyDown(code);
|
||||
if (leaderHandled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle AltGr key
|
||||
altGrRef.current.handleKeyDown(code, event.timeStamp);
|
||||
|
||||
pressedKeys.current.add(code);
|
||||
handleKeyEvent({ type: 'keydown', code });
|
||||
sendKeyEvent('keydown', code);
|
||||
}
|
||||
|
||||
// Key up event
|
||||
function handleKeyUp(event: KeyboardEvent) {
|
||||
if (!isKeyboardEnabled) return;
|
||||
|
||||
if (isComposing.current || event.isComposing) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const code = event.code;
|
||||
const code = normalizeKeyCode(event, os);
|
||||
if (!code) return;
|
||||
|
||||
// Handle AltGr state for Windows
|
||||
if (altGrState.current?.active) {
|
||||
if (code === 'ControlLeft') return;
|
||||
|
||||
if (code === 'AltRight') {
|
||||
altGrState.current.active = false;
|
||||
}
|
||||
// Handle leader key release
|
||||
const leaderHandled = leaderKeyRef.current.handleKeyUp(code);
|
||||
if (leaderHandled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Compatible with macOS's command key combinations
|
||||
if (code === 'MetaLeft' || code === 'MetaRight') {
|
||||
const keysToRelease: string[] = [];
|
||||
pressedKeys.current.forEach((pressedCode) => {
|
||||
if (!isModifier(pressedCode)) {
|
||||
keysToRelease.push(pressedCode);
|
||||
}
|
||||
});
|
||||
// Handle AltGr key release
|
||||
const altGrHandled = altGrRef.current.handleKeyUp(code);
|
||||
if (altGrHandled) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const key of keysToRelease) {
|
||||
handleKeyEvent({ type: 'keyup', code: key });
|
||||
pressedKeys.current.delete(key);
|
||||
}
|
||||
// Release Meta combinations (macOS compatibility)
|
||||
if (code === 'MetaLeft' || code === 'MetaRight') {
|
||||
releaseMetaCombinations();
|
||||
}
|
||||
|
||||
pressedKeys.current.delete(code);
|
||||
handleKeyEvent({ type: 'keyup', code });
|
||||
sendKeyEvent('keyup', code);
|
||||
}
|
||||
|
||||
// Composition start event
|
||||
function handleCompositionStart() {
|
||||
isComposing.current = true;
|
||||
}
|
||||
|
||||
// Composition end event
|
||||
function handleCompositionEnd() {
|
||||
isComposing.current = false;
|
||||
}
|
||||
|
||||
// Release all keys when window loses focus
|
||||
function handleBlur() {
|
||||
releaseKeys();
|
||||
}
|
||||
|
||||
// Release all keys before window closes
|
||||
function handleVisibilityChange() {
|
||||
if (document.hidden) {
|
||||
releaseKeys();
|
||||
}
|
||||
}
|
||||
|
||||
// Release all keys
|
||||
function releaseMetaCombinations() {
|
||||
const keysToRelease = Array.from(pressedKeys.current).filter((code) => !isModifier(code));
|
||||
for (const key of keysToRelease) {
|
||||
sendKeyEvent('keyup', key);
|
||||
pressedKeys.current.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function releaseKeys() {
|
||||
pressedKeys.current.forEach((code) => {
|
||||
handleKeyEvent({ type: 'keyup', code });
|
||||
sendKeyEvent('keyup', code);
|
||||
});
|
||||
|
||||
pressedKeys.current.clear();
|
||||
|
||||
// Reset AltGr state
|
||||
if (altGrState.current) {
|
||||
altGrState.current.active = false;
|
||||
altGrState.current.ctrlLeftTimestamp = 0;
|
||||
}
|
||||
leaderKeyRef.current.reset();
|
||||
altGrRef.current.reset();
|
||||
|
||||
const report = keyboardRef.current.reset();
|
||||
sendReport(report);
|
||||
@@ -161,18 +151,14 @@ export const Keyboard = () => {
|
||||
};
|
||||
}, [isKeyboardEnabled]);
|
||||
|
||||
// Keyboard handler
|
||||
function handleKeyEvent(event: { type: 'keydown' | 'keyup'; code: string }) {
|
||||
const kb = keyboardRef.current;
|
||||
const report = event.type === 'keydown' ? kb.keyDown(event.code) : kb.keyUp(event.code);
|
||||
sendReport(report);
|
||||
}
|
||||
|
||||
// Send keyboard report
|
||||
function sendReport(report: Uint8Array) {
|
||||
const data = new Uint8Array([MessageEvent.Keyboard, ...report]);
|
||||
client.send(data);
|
||||
}
|
||||
|
||||
return <></>;
|
||||
return (
|
||||
<>
|
||||
<Recorder recordMode={leaderKey.recordMode} recordedKeys={leaderKey.recordedKeys} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
61
web/src/pages/desktop/keyboard/recorder.tsx
Normal file
61
web/src/pages/desktop/keyboard/recorder.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface RecorderProps {
|
||||
recordMode: boolean;
|
||||
recordedKeys: string[];
|
||||
}
|
||||
|
||||
export const Recorder = ({ recordMode, recordedKeys }: RecorderProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!recordMode) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in slide-in-from-top-4 pointer-events-none fixed right-5 top-5 z-[9999] flex min-w-[150px] flex-col gap-2 rounded-xl border border-white/10 bg-black/60 p-4 shadow-2xl backdrop-blur-md transition-all duration-200">
|
||||
{recordedKeys.length === 0 ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex size-2.5">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-amber-400 opacity-75"></span>
|
||||
<span className="relative inline-flex size-2.5 rounded-full bg-amber-500"></span>
|
||||
</div>
|
||||
<span className="text-xs font-semibold tracking-wider text-white/90">
|
||||
{t('keyboard.leaderKey.recorder.rec')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<span className="text-sm italic text-white/50">
|
||||
{t('keyboard.leaderKey.recorder.input')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex size-2.5">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-green-400 opacity-75"></span>
|
||||
<span className="relative inline-flex size-2.5 rounded-full bg-green-500"></span>
|
||||
</div>
|
||||
<span className="text-xs text-white/90">
|
||||
{t('keyboard.leaderKey.recorder.activate')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{recordedKeys.map((key) => (
|
||||
<span
|
||||
key={key}
|
||||
className="flex items-center justify-center rounded border-b-2 border-white/20 bg-white/10 px-2 py-0.5 font-mono text-sm font-medium text-white shadow-sm"
|
||||
>
|
||||
{key}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
77
web/src/pages/desktop/keyboard/useAltGr.ts
Normal file
77
web/src/pages/desktop/keyboard/useAltGr.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
const ALTGR_THRESHOLD_MS = 10;
|
||||
|
||||
interface AltGrState {
|
||||
active: boolean;
|
||||
ctrlLeftTimestamp: number;
|
||||
}
|
||||
|
||||
export interface AltGrHandlers {
|
||||
handleKeyDown: (code: string, timestamp: number) => void;
|
||||
handleKeyUp: (code: string) => boolean;
|
||||
reset: () => void;
|
||||
isActive: () => boolean;
|
||||
}
|
||||
|
||||
export function useAltGr(
|
||||
os: string,
|
||||
pressedKeys: React.MutableRefObject<Set<string>>,
|
||||
sendKeyEvent: (type: 'keydown' | 'keyup', code: string) => void
|
||||
): AltGrHandlers {
|
||||
const state = useRef<AltGrState | null>(null);
|
||||
|
||||
if (os === 'Windows' && !state.current) {
|
||||
state.current = { active: false, ctrlLeftTimestamp: 0 };
|
||||
}
|
||||
|
||||
const handleKeyDown = (code: string, timestamp: number): void => {
|
||||
if (!state.current) return;
|
||||
|
||||
// When AltGr is pressed, browsers send ControlLeft followed immediately by AltRight
|
||||
if (code === 'ControlLeft') {
|
||||
state.current.ctrlLeftTimestamp = timestamp;
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 'AltRight') {
|
||||
const timeDiff = timestamp - state.current.ctrlLeftTimestamp;
|
||||
if (timeDiff < ALTGR_THRESHOLD_MS && pressedKeys.current.has('ControlLeft')) {
|
||||
pressedKeys.current.delete('ControlLeft');
|
||||
sendKeyEvent('keyup', 'ControlLeft');
|
||||
state.current.active = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (code: string): boolean => {
|
||||
if (!state.current?.active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (code === 'ControlLeft') {
|
||||
pressedKeys.current.delete('ControlLeft');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (code === 'AltRight') {
|
||||
pressedKeys.current.delete('ControlLeft');
|
||||
state.current.active = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
if (state.current) {
|
||||
state.current.active = false;
|
||||
state.current.ctrlLeftTimestamp = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const isActive = () => state.current?.active ?? false;
|
||||
|
||||
return { handleKeyDown, handleKeyUp, reset, isActive };
|
||||
}
|
||||
132
web/src/pages/desktop/keyboard/useLeaderKey.ts
Normal file
132
web/src/pages/desktop/keyboard/useLeaderKey.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
|
||||
import * as api from '@/api/hid.ts';
|
||||
import { isModifier } from '@/lib/keymap';
|
||||
import { leaderKeyAtom } from '@/jotai/keyboard.ts';
|
||||
|
||||
interface LeaderKeyState {
|
||||
code: string;
|
||||
startTime: number;
|
||||
recordMode: boolean;
|
||||
recordModifiers: string[];
|
||||
}
|
||||
|
||||
interface LeaderKeyHandlers {
|
||||
handleKeyDown: (code: string) => boolean;
|
||||
handleKeyUp: (code: string) => boolean;
|
||||
reset: () => void;
|
||||
recordMode: boolean;
|
||||
recordedKeys: string[];
|
||||
}
|
||||
|
||||
const RECORD_MODE_THRESHOLD_MS = 350;
|
||||
|
||||
export function useLeaderKey(
|
||||
pressedKeys: React.MutableRefObject<Set<string>>,
|
||||
sendKeyEvent: (type: 'keydown' | 'keyup', code: string) => void
|
||||
): LeaderKeyHandlers {
|
||||
const [leaderKeyCode, setLeaderKeyCode] = useAtom(leaderKeyAtom);
|
||||
|
||||
const [recordMode, setRecordMode] = useState(false);
|
||||
const [recordedKeys, setRecordedKeys] = useState<string[]>([]);
|
||||
|
||||
const leaderState = useRef<LeaderKeyState>({
|
||||
code: '',
|
||||
startTime: 0,
|
||||
recordMode: false,
|
||||
recordModifiers: []
|
||||
});
|
||||
|
||||
// Get leader key
|
||||
useEffect(() => {
|
||||
api.getLeaderKey().then((rsp: any) => {
|
||||
if (rsp.code === 0 && rsp.data?.key !== undefined) {
|
||||
setLeaderKeyCode(rsp.data.key);
|
||||
}
|
||||
});
|
||||
}, [setLeaderKeyCode]);
|
||||
|
||||
// Update leader key
|
||||
useEffect(() => {
|
||||
leaderState.current.code = leaderKeyCode;
|
||||
}, [leaderKeyCode]);
|
||||
|
||||
const handleKeyDown = (code: string): boolean => {
|
||||
const leader = leaderState.current;
|
||||
|
||||
// Leader key pressed
|
||||
if (leader.code && code === leader.code) {
|
||||
if (!leader.recordMode) {
|
||||
// Enter record mode
|
||||
leader.startTime = Date.now();
|
||||
leader.recordMode = true;
|
||||
setRecordMode(true);
|
||||
}
|
||||
|
||||
pressedKeys.current.add(code);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Record mode
|
||||
if (leader.recordMode) {
|
||||
if (isModifier(code)) {
|
||||
// Modifier: send keydown immediately and track it
|
||||
if (!leader.recordModifiers.includes(code)) {
|
||||
leader.recordModifiers.push(code);
|
||||
setRecordedKeys([...leaderState.current.recordModifiers]);
|
||||
sendKeyEvent('keydown', code);
|
||||
}
|
||||
} else {
|
||||
// Non-modifier: send keydown + keyup immediately
|
||||
sendKeyEvent('keydown', code);
|
||||
sendKeyEvent('keyup', code);
|
||||
}
|
||||
|
||||
pressedKeys.current.add(code);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleKeyUp = (code: string): boolean => {
|
||||
const leader = leaderState.current;
|
||||
|
||||
// Leader key released
|
||||
if (leader.code && code === leader.code) {
|
||||
if (leader.recordMode && Date.now() - leader.startTime > RECORD_MODE_THRESHOLD_MS) {
|
||||
// Exit record mode
|
||||
reset();
|
||||
}
|
||||
|
||||
pressedKeys.current.delete(code);
|
||||
return true;
|
||||
}
|
||||
|
||||
// In record mode, handle keyup
|
||||
if (leader.recordMode && code !== leader.code) {
|
||||
pressedKeys.current.delete(code);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
const leader = leaderState.current;
|
||||
|
||||
for (const key of leader.recordModifiers) {
|
||||
sendKeyEvent('keyup', key);
|
||||
}
|
||||
|
||||
leader.startTime = 0;
|
||||
leader.recordMode = false;
|
||||
leader.recordModifiers = [];
|
||||
|
||||
setRecordMode(false);
|
||||
setRecordedKeys([]);
|
||||
};
|
||||
|
||||
return { handleKeyDown, handleKeyUp, reset, recordMode, recordedKeys };
|
||||
}
|
||||
26
web/src/pages/desktop/keyboard/utils.ts
Normal file
26
web/src/pages/desktop/keyboard/utils.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export function normalizeKeyCode(event: KeyboardEvent, os?: string): string {
|
||||
if (event.code) {
|
||||
return event.code;
|
||||
}
|
||||
|
||||
// Fallback: use event.key + event.location to determine the key
|
||||
// event.location: 1 = left, 2 = right, 0 = standard (non-positional)
|
||||
if (event.key === 'Shift') {
|
||||
if (event.location === 0 && os === 'Windows') {
|
||||
return 'ShiftRight';
|
||||
}
|
||||
return event.location === 2 ? 'ShiftRight' : 'ShiftLeft';
|
||||
}
|
||||
|
||||
if (event.key === 'Control') {
|
||||
return event.location === 2 ? 'ControlRight' : 'ControlLeft';
|
||||
}
|
||||
if (event.key === 'Alt') {
|
||||
return event.location === 2 ? 'AltRight' : 'AltLeft';
|
||||
}
|
||||
if (event.key === 'Meta') {
|
||||
return event.location === 2 ? 'MetaRight' : 'MetaLeft';
|
||||
}
|
||||
|
||||
return event.code;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { useAtomValue } from 'jotai';
|
||||
import { GripVerticalIcon } from 'lucide-react';
|
||||
import Draggable, { DraggableData, DraggableEvent } from 'react-draggable';
|
||||
|
||||
import { menuDisabledItemsAtom, menuDisplayModeAtom } from '@/jotai/settings.ts';
|
||||
import { menuDisabledItemsAtom } from '@/jotai/settings.ts';
|
||||
import { useMenuBounds } from '@/hooks/useMenuBounds.ts';
|
||||
import { useMenuVisibility } from '@/hooks/useMenuVisibility.ts';
|
||||
|
||||
@@ -25,14 +25,12 @@ import { Wol } from './wol';
|
||||
export const Menu = () => {
|
||||
const nodeRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const menuDisplayMode = useAtomValue(menuDisplayModeAtom);
|
||||
const menuDisabledItems = useAtomValue(menuDisabledItemsAtom);
|
||||
|
||||
const {
|
||||
isInitialized,
|
||||
isMenuExpanded,
|
||||
isMenuHidden,
|
||||
isInvisible,
|
||||
handleHovered,
|
||||
handleMoved,
|
||||
setIsMenuExpanded
|
||||
@@ -61,8 +59,7 @@ export const Menu = () => {
|
||||
ref={nodeRef}
|
||||
className={clsx(
|
||||
'fixed left-1/2 top-[10px] z-[1000] -translate-x-1/2 transition-opacity duration-300',
|
||||
isInitialized ? 'opacity-100' : 'opacity-0',
|
||||
isInvisible && 'invisible'
|
||||
isInitialized ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
onMouseEnter={() => handleHovered(true)}
|
||||
onMouseLeave={() => handleHovered(false)}
|
||||
@@ -118,7 +115,7 @@ export const Menu = () => {
|
||||
</div>
|
||||
|
||||
{/* Menubar expand button */}
|
||||
{!isMenuExpanded && menuDisplayMode !== 'off' && <Expand toggleMenu={setIsMenuExpanded} />}
|
||||
{!isMenuExpanded && <Expand toggleMenu={setIsMenuExpanded} />}
|
||||
</div>
|
||||
</Draggable>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Divider } from 'antd';
|
||||
import { KeyboardIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { MenuItem } from '@/components/menu-item.tsx';
|
||||
|
||||
import { LeaderKey } from './leader-key.tsx';
|
||||
import { Paste } from './paste.tsx';
|
||||
import { Shortcuts } from './shortcuts';
|
||||
import { VirtualKeyboard } from './virtual-keyboard.tsx';
|
||||
@@ -10,17 +12,19 @@ import { VirtualKeyboard } from './virtual-keyboard.tsx';
|
||||
export const Keyboard = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const content = (
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Paste />
|
||||
<VirtualKeyboard />
|
||||
<Shortcuts />
|
||||
|
||||
<Divider style={{ margin: '5px 0', opacity: 0.5 }} />
|
||||
|
||||
<LeaderKey />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
title={t('keyboard.title')}
|
||||
icon={<KeyboardIcon size={18} />}
|
||||
content={
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Paste />
|
||||
<VirtualKeyboard />
|
||||
<Shortcuts />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<MenuItem title={t('keyboard.title')} icon={<KeyboardIcon size={18} />} content={content} />
|
||||
);
|
||||
};
|
||||
|
||||
205
web/src/pages/desktop/menu/keyboard/leader-key.tsx
Normal file
205
web/src/pages/desktop/menu/keyboard/leader-key.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Input, Modal, Switch } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { useAtom, useSetAtom } from 'jotai';
|
||||
import {
|
||||
ArrowBigUpIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronUpIcon,
|
||||
CommandIcon,
|
||||
KeyboardIcon,
|
||||
KeyIcon
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/hid.ts';
|
||||
import { isKeyboardEnableAtom, leaderKeyAtom } from '@/jotai/keyboard.ts';
|
||||
|
||||
export const LeaderKey = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom);
|
||||
const [leaderKey, setLeaderKey] = useAtom(leaderKeyAtom);
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isLeaderKeyEnable, setIsLeaderKeyEnable] = useState(false);
|
||||
const [tempLeaderKey, setTempLeaderKey] = useState('');
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isDocCollapsed, setIsDocCollapsed] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const optionalKeys = [
|
||||
{
|
||||
value: 'ShiftRight',
|
||||
label: t('keyboard.leaderKey.shiftRight'),
|
||||
icon: <ArrowBigUpIcon size={16} />
|
||||
},
|
||||
{
|
||||
value: 'ControlRight',
|
||||
label: t('keyboard.leaderKey.ctrlRight'),
|
||||
icon: <ChevronUpIcon size={16} />
|
||||
},
|
||||
{
|
||||
value: 'MetaRight',
|
||||
label: t('keyboard.leaderKey.metaRight'),
|
||||
icon: <CommandIcon size={16} />
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFocused) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
setTempLeaderKey(event.code);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isFocused]);
|
||||
|
||||
function openModal() {
|
||||
setIsKeyboardEnable(false);
|
||||
setIsLeaderKeyEnable(!!leaderKey);
|
||||
setTempLeaderKey(leaderKey);
|
||||
setIsDocCollapsed(true);
|
||||
setIsModalOpen(true);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
setIsKeyboardEnable(true);
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (isLoading) return;
|
||||
|
||||
const key = isLeaderKeyEnable ? tempLeaderKey : '';
|
||||
if (key === leaderKey) {
|
||||
closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
api
|
||||
.setLeaderKey(key)
|
||||
.then((rsp) => {
|
||||
if (rsp.code === 0) {
|
||||
setLeaderKey(key);
|
||||
closeModal();
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex cursor-pointer select-none items-center space-x-2 rounded py-1 pl-2 pr-5 hover:bg-neutral-700/70"
|
||||
onClick={openModal}
|
||||
>
|
||||
<KeyIcon size={18} />
|
||||
<span>{t('keyboard.leaderKey.title')}</span>
|
||||
</div>
|
||||
|
||||
<Modal width={510} keyboard={false} footer={null} open={isModalOpen} onCancel={closeModal}>
|
||||
<div className="my-3 flex flex-col space-y-3">
|
||||
{/* title */}
|
||||
<div className="flex flex-col space-y-1">
|
||||
<span className="text-lg"> {t('keyboard.leaderKey.title')}</span>
|
||||
<span className="text-xs text-neutral-500">{t('keyboard.leaderKey.desc')}</span>
|
||||
</div>
|
||||
|
||||
{/* Document */}
|
||||
<div className="rounded-lg bg-neutral-800 py-3">
|
||||
<div
|
||||
className="flex cursor-pointer items-center justify-between px-5"
|
||||
onClick={() => setIsDocCollapsed((c) => !c)}
|
||||
>
|
||||
<span>{t('keyboard.leaderKey.howToUse')}</span>
|
||||
<div
|
||||
className={clsx(
|
||||
'transition-300 size-[18px] text-neutral-500 transition',
|
||||
isDocCollapsed ? 'rotate-0' : 'rotate-90'
|
||||
)}
|
||||
>
|
||||
<ChevronRightIcon size={18} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isDocCollapsed && (
|
||||
<ul className="list-outside space-y-3">
|
||||
{['simultaneous', 'sequential'].map((key) => (
|
||||
<li>
|
||||
<div className="flex flex-col space-y-2 pr-1">
|
||||
<span className="">{t(`keyboard.leaderKey.${key}.title`)}</span>
|
||||
<span className="text-sm text-neutral-400">
|
||||
{t(`keyboard.leaderKey.${key}.desc1`)}
|
||||
</span>
|
||||
<span className="text-sm text-neutral-400">
|
||||
{t(`keyboard.leaderKey.${key}.desc2`)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Switch */}
|
||||
<div className="flex items-center justify-between rounded-lg bg-neutral-800 px-5 py-3">
|
||||
<span className="select-none">{t('keyboard.leaderKey.enable')}</span>
|
||||
<Switch value={isLeaderKeyEnable} onChange={setIsLeaderKeyEnable} />
|
||||
</div>
|
||||
|
||||
{/* Keyboard recorder */}
|
||||
{isLeaderKeyEnable && (
|
||||
<div className="flex flex-col space-y-5 rounded-lg bg-neutral-800 px-5 py-5">
|
||||
<span className="text-sm text-neutral-400">{t('keyboard.leaderKey.tip')}</span>
|
||||
<Input
|
||||
value={tempLeaderKey}
|
||||
className="flex-1"
|
||||
placeholder={t('keyboard.leaderKey.placeholder')}
|
||||
prefix={<KeyboardIcon size={16} className="text-neutral-500" />}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-center space-x-3">
|
||||
{optionalKeys.map((key) => (
|
||||
<div
|
||||
key={key.value}
|
||||
className={clsx(
|
||||
'flex w-28 cursor-pointer items-center justify-center space-x-1 rounded py-1 shadow-lg shadow-neutral-800 outline',
|
||||
key.value === tempLeaderKey
|
||||
? 'text-neutral-300 outline-blue-500'
|
||||
: 'text-neutral-500 outline-neutral-700 hover:shadow-xl hover:outline-neutral-600'
|
||||
)}
|
||||
onClick={() => setTempLeaderKey((k) => (k === key.value ? '' : key.value))}
|
||||
>
|
||||
{key.icon}
|
||||
<span>{key.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex w-full justify-center pt-5">
|
||||
<Button className="w-24" type="primary" loading={isLoading} onClick={submit}>
|
||||
{t('keyboard.leaderKey.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -13,8 +13,6 @@ type UpdateProps = {
|
||||
setIsLocked: (isClosable: boolean) => void;
|
||||
};
|
||||
|
||||
// type Status = '' | 'loading' | 'updating' | 'outdated' | 'latest' | 'failed';
|
||||
|
||||
export const Update = ({ setIsLocked }: UpdateProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -85,7 +83,7 @@ export const Update = ({ setIsLocked }: UpdateProps) => {
|
||||
<div className="text-base">{t('settings.update.title')}</div>
|
||||
<Divider className="opacity-50" />
|
||||
|
||||
<Preview />
|
||||
<Preview checkForUpdates={checkForUpdates} />
|
||||
<Offline
|
||||
status={status}
|
||||
setStatus={setStatus}
|
||||
|
||||
@@ -5,7 +5,11 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as api from '@/api/application.ts';
|
||||
|
||||
export const Preview = () => {
|
||||
interface PreviewProps {
|
||||
checkForUpdates: () => void;
|
||||
}
|
||||
|
||||
export const Preview = ({ checkForUpdates }: PreviewProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -48,6 +52,7 @@ export const Preview = () => {
|
||||
}
|
||||
|
||||
setIsEnabled(enable);
|
||||
checkForUpdates();
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
|
||||
Reference in New Issue
Block a user