mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-11-10 01:49:00 -06:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e1251ad6a | ||
|
|
afec4efb34 | ||
|
|
11980b9122 | ||
|
|
2a43fb5413 | ||
|
|
4af32fc703 | ||
|
|
a611e5ef56 | ||
|
|
94051c3037 | ||
|
|
d5cd706078 | ||
|
|
7460ebfd07 | ||
|
|
e3877308a0 | ||
|
|
7c28fb8124 | ||
|
|
b18873364f | ||
|
|
016a7d8a51 | ||
|
|
585f1bc2b5 | ||
|
|
4fcc4c3747 | ||
|
|
0d2c52adc3 | ||
|
|
af256e7f92 | ||
|
|
84830421ee | ||
|
|
9b278cdb0e | ||
|
|
9b40a5cf86 | ||
|
|
b8d980be33 | ||
|
|
195037af87 | ||
|
|
c98000069a | ||
|
|
2bd277b57d | ||
|
|
4852ed813b | ||
|
|
fb08df9e4e | ||
|
|
64f3b49e21 |
8
.github/workflows/issue-tracker.yml
vendored
8
.github/workflows/issue-tracker.yml
vendored
@@ -2,7 +2,7 @@ name: Issue Tracker
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, reopened]
|
||||
types: [opened, reopened, closed]
|
||||
|
||||
jobs:
|
||||
track-issue:
|
||||
@@ -28,8 +28,10 @@ jobs:
|
||||
--arg state "${{ github.event.issue.state }}" \
|
||||
--arg created_at "${{ github.event.issue.created_at }}" \
|
||||
--arg updated_at "${{ github.event.issue.updated_at }}" \
|
||||
--arg closed_at "${{ github.event.issue.closed_at }}" \
|
||||
--arg url "${{ github.event.issue.html_url }}" \
|
||||
--arg user_login "${{ github.event.issue.user.login }}" \
|
||||
--arg repository "${{ github.repository }}" \
|
||||
--argjson labels "$RAW_LABELS" \
|
||||
--argjson assignees "$RAW_ASSIGNEES" \
|
||||
'{
|
||||
@@ -42,11 +44,13 @@ jobs:
|
||||
state: $state,
|
||||
created_at: $created_at,
|
||||
updated_at: $updated_at,
|
||||
closed_at: $closed_at,
|
||||
url: $url,
|
||||
user: { login: $user_login },
|
||||
labels: $labels,
|
||||
assignees: $assignees
|
||||
}
|
||||
},
|
||||
repository: { name: $repository },
|
||||
}')
|
||||
|
||||
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$API_URL" \
|
||||
|
||||
54
CHANGELOG.md
54
CHANGELOG.md
@@ -1,4 +1,56 @@
|
||||
## 2.3.2 (2025-01-08)
|
||||
## 2.3.5 (2026-02-28)
|
||||
|
||||
### Features
|
||||
|
||||
* Implemented login brute-force protection with lockout mechanism
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Improved Tailscale error handling in UI components and refined backend state mapping
|
||||
|
||||
### Chores
|
||||
|
||||
* Updated server and web dependencies
|
||||
|
||||
## 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
|
||||
|
||||
* Added support for setting the display mode of the menu bar
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fixed a keyboard issue where IME composition events were not handled correctly
|
||||
* Resolved an issue where the `AltGr` key was not recognized on the Windows system
|
||||
* Fixed a bug where `Command` key combinations were not fully released on the macOS system
|
||||
* Fixed input issues with the `IntlBackslash` key on the German virtual keyboard
|
||||
|
||||
### Localization
|
||||
|
||||
* Updated Korean translations (thanks to [@kmw0410](https://github.com/kmw0410))
|
||||
|
||||
### Chores
|
||||
|
||||
* Bump react-router-dom from 6.27.0 to 6.30.3
|
||||
* Updated eslint
|
||||
|
||||
## 2.3.2 (2026-01-08)
|
||||
|
||||
### Features
|
||||
|
||||
|
||||
@@ -26,41 +26,39 @@ server
|
||||
The configuration file path is `/etc/kvm/server.yaml`.
|
||||
|
||||
```yaml
|
||||
proto: http
|
||||
# Network Settings
|
||||
proto: http # Access protocol. Can be changed to `https` only when certificates are configured. Default is `http`
|
||||
port:
|
||||
http: 80
|
||||
https: 443
|
||||
http: 80 # The listening port for the HTTP service. Default is `80`
|
||||
https: 443 # The listening port for the HTTPS service (effective when HTTPS is enabled). Default is `443`
|
||||
cert:
|
||||
crt: server.crt
|
||||
key: server.key
|
||||
crt: server.crt # The path to the public key certificate for HTTPS
|
||||
key: server.key # The path to the private key file for HTTPS
|
||||
|
||||
# Log level (debug/info/warn/error)
|
||||
# Note: Use 'info' or 'error' in production, 'debug' only for development
|
||||
|
||||
# Logging Configuration
|
||||
logger:
|
||||
level: info
|
||||
file: stdout
|
||||
level: info # Global log output level. Evaluated options from highest to lowest detail: `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`. Default is `info`
|
||||
file: stdout # Log output destination. `stdout` outputs to the standard console. A file path directs log output to that file. Default is `stdout`
|
||||
|
||||
# Authentication setting (enable/disable)
|
||||
# Note: Only disable authentication in development environment
|
||||
authentication: enable
|
||||
|
||||
# Authentication & Security
|
||||
authentication: enable # Whether to enable identity verification for HTTP API and Web endpoints. Options are `enable` or `disable`. Default is `enable`. Highly recommended to leave this enabled for internet-facing devices!
|
||||
jwt:
|
||||
# JWT secret key. If left empty, a random 64-byte key will be generated automatically.
|
||||
secretKey: ""
|
||||
# JWT token expiration time in seconds. Default: 2678400 (31 days)
|
||||
refreshTokenDuration: 2678400
|
||||
# Invalidate all JWT tokens when the user logs out. Default: true
|
||||
revokeTokensOnLogout: true
|
||||
secretKey: "" # The secret key used to sign and verify JWT Tokens. If left empty, a random key will be generated automatically on startup
|
||||
refreshTokenDuration: 2678400 # The token refresh duration threshold in seconds before forcing a re-login. Default is `2678400` (~31 days)
|
||||
revokeTokensOnLogout: true # Whether to invalidate all existing tokens upon logout by rotating the SecretKey. Default is `true`
|
||||
security:
|
||||
loginLockoutDuration: 0, # The duration (in seconds) to ban an IP from attempting to log in again after reaching the failure limit. If set to `0` or left empty, brute-force protection is disabled. Default is `0`
|
||||
loginMaxFailures: 5, # The maximum number of continuous failed login attempts allowed per IP before triggering protection. Default is `5`
|
||||
|
||||
# Address for custom STUN server
|
||||
# Note: You can disable the STUN service by setting it to 'disable' (e.g., in a LAN environment)
|
||||
stun: stun.l.google.com:19302
|
||||
|
||||
# Address and authentication for custom TURN server
|
||||
# WebRTC Traversal Settings
|
||||
stun: stun.l.google.com:19302 # The default STUN server address used for NAT hole-punching to establish P2P streams
|
||||
turn:
|
||||
turnAddr: example_addr
|
||||
turnUser: example_user
|
||||
turnCred: example_cred
|
||||
turnAddr: example_addr # The relay (TURN) server address (format `ip:port`) used as a fallback when P2P connection fails. Leave empty to disable TURN relay
|
||||
turnUser: example_user # The username required for authorization to the TURN server
|
||||
turnCred: example_cred # The credential/password required for authorization to the TURN server
|
||||
```
|
||||
|
||||
## Compile & Deploy
|
||||
|
||||
@@ -24,40 +24,39 @@ server
|
||||
配置文件路径为 `/etc/kvm/server.yaml`。
|
||||
|
||||
```yaml
|
||||
proto: http
|
||||
# 网络设置
|
||||
proto: http # 访问协议,默认为 `http`,仅当配置了证书时支持改为 `https`
|
||||
port:
|
||||
http: 80
|
||||
https: 443
|
||||
http: 80 # HTTP 服务的监听端口,默认为 `80`
|
||||
https: 443 # HTTPS 服务的监听端口(启用 https 协议时生效),默认为 `443`
|
||||
cert:
|
||||
crt: server.crt
|
||||
key: server.key
|
||||
crt: server.crt # HTTPS 服务使用的公钥证书路径
|
||||
key: server.key # HTTPS 服务使用的私钥文件路径
|
||||
|
||||
# 日志级别(debug/info/warn/error)
|
||||
# 注意:在生产环境中使用 info 或 error。debug 模式仅在开发环境中使用。
|
||||
|
||||
# 日志配置
|
||||
logger:
|
||||
level: info
|
||||
file: stdout
|
||||
level: info # 全局日志打印级别,从高到底可选 `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`。默认为 `info`
|
||||
file: stdout # 日志输出目标位置。若填写 `stdout` 则输出在控制台。配置为文件路径则会输出到对应的文件。默认为 `stdout`
|
||||
|
||||
# 鉴权设置(enable/disable)
|
||||
# 注意:生产环境中请勿使用 disable。
|
||||
authentication: enable
|
||||
|
||||
# 认证与安全
|
||||
authentication: enable # 是否开启 HTTP 接口与网页的身份校验。可选 `enable` (开启) 或 `disable` (禁用)。默认为 `enable`。强烈建议公开在互联网的机器开启此项!
|
||||
jwt:
|
||||
# jwt 密钥。设置为空则使用随机生成的64位密钥
|
||||
secretKey: ""
|
||||
# jwt token 过期时间(单位:秒),默认为2678400(31天)
|
||||
refreshTokenDuration: 2678400
|
||||
# 在帐号登出时是否使所有 jwt token 失效。默认为 true
|
||||
revokeTokensOnLogout: true
|
||||
secretKey: "" # 用于签发和验证 JWT Token 的密钥。如果不填,服务启动时将自动随机生成
|
||||
refreshTokenDuration: 2678400 # 登录超时的刷新周期(单位:秒)。默认为 `2678400`(约31天)
|
||||
revokeTokensOnLogout: true # 退出登录时是否废除所有现存的 Token。启用此项可以在注销时轮换 SecretKey,强迫所有终端重新登录。默认为 `true`
|
||||
security:
|
||||
loginLockoutDuration: 0, # 达到失败上限后,禁止该 IP 再次尝试登录的持续时间(单位:秒)。如果设为 `0` 或不填,则代表不开启防暴力破解功能。默认为 `0`
|
||||
loginMaxFailures: 5, # 允许触发保护前,单个 IP 连续登录失败的最大次数。默认为 `5`
|
||||
|
||||
# 自定义 STUN 服务器的地址
|
||||
# 注意:可以设置为“disable”来禁用 STUN 服务(例如在局域网环境中使用时)
|
||||
stun: stun.l.google.com:19302
|
||||
|
||||
# WebRTC 内网穿透
|
||||
stun: stun.l.google.com:19302 # 默认使用的 STUN 服务器地址,用于打洞获取公网 IP 建立 P2P 流
|
||||
turn:
|
||||
turnAddr: example_addr
|
||||
turnUser: example_user
|
||||
turnCred: example_cred
|
||||
turnAddr: example_addr # 当 P2P 直连失败时,作为备用的中继(TURN)服务器地址(格式如 `ip:port`)。留空表示不使用 TURN 中继
|
||||
turnUser: example_user # TURN 服务器授权连接时使用的用户名
|
||||
turnCred: example_cred # TURN 服务器授权连接时使用的凭据/密码
|
||||
```
|
||||
|
||||
## 编译部署
|
||||
|
||||
@@ -26,6 +26,10 @@ var defaultConfig = &Config{
|
||||
TurnCred: "",
|
||||
},
|
||||
Authentication: "enable",
|
||||
Security: Security{
|
||||
LoginLockoutDuration: 0,
|
||||
LoginMaxFailures: 5,
|
||||
},
|
||||
}
|
||||
|
||||
func checkDefaultValue() {
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
package config
|
||||
|
||||
type Config struct {
|
||||
Proto string `yaml:"proto"`
|
||||
Port Port `yaml:"port"`
|
||||
Cert Cert `yaml:"cert"`
|
||||
Logger Logger `yaml:"logger"`
|
||||
Authentication string `yaml:"authentication"`
|
||||
JWT JWT `yaml:"jwt"`
|
||||
Stun string `yaml:"stun"`
|
||||
Turn Turn `yaml:"turn"`
|
||||
Proto string `yaml:"proto"`
|
||||
Port Port `yaml:"port"`
|
||||
Cert Cert `yaml:"cert"`
|
||||
Logger Logger `yaml:"logger"`
|
||||
Authentication string `yaml:"authentication"`
|
||||
JWT JWT `yaml:"jwt"`
|
||||
Stun string `yaml:"stun"`
|
||||
Turn Turn `yaml:"turn"`
|
||||
Security Security `yaml:"security"`
|
||||
|
||||
Hardware Hardware `yaml:"-"`
|
||||
}
|
||||
@@ -40,6 +41,11 @@ type Turn struct {
|
||||
TurnCred string `yaml:"turnCred"`
|
||||
}
|
||||
|
||||
type Security struct {
|
||||
LoginLockoutDuration int `yaml:"loginLockoutDuration"`
|
||||
LoginMaxFailures int `yaml:"loginMaxFailures"`
|
||||
}
|
||||
|
||||
type Hardware struct {
|
||||
Version HWVersion `yaml:"-"`
|
||||
GPIOReset string `yaml:"-"`
|
||||
|
||||
@@ -8,9 +8,10 @@ require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-playground/validator/v10 v10.20.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/mervick/aes-everywhere/go/aes256 v0.0.0-20240803013625-6759956693c0
|
||||
github.com/pion/dtls/v3 v3.0.3
|
||||
github.com/pion/dtls/v3 v3.1.2
|
||||
github.com/pion/rtp v1.8.18
|
||||
github.com/pion/webrtc/v4 v4.0.1
|
||||
github.com/rs/cors/wrapper/gin v0.0.0-20240830163046-1084d89a1692
|
||||
@@ -32,7 +33,6 @@ require (
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
@@ -46,7 +46,7 @@ require (
|
||||
github.com/pion/datachannel v1.5.9 // indirect
|
||||
github.com/pion/ice/v4 v4.0.2 // indirect
|
||||
github.com/pion/interceptor v0.1.39 // indirect
|
||||
github.com/pion/logging v0.2.3 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/rtcp v1.2.15 // indirect
|
||||
@@ -55,6 +55,7 @@ require (
|
||||
github.com/pion/srtp/v3 v3.0.4 // indirect
|
||||
github.com/pion/stun/v3 v3.0.0 // indirect
|
||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||
github.com/pion/turn/v4 v4.0.0 // indirect
|
||||
github.com/rs/cors v1.11.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
@@ -66,7 +67,7 @@ require (
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/wlynxg/anet v0.0.3 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
|
||||
@@ -74,14 +74,14 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pion/datachannel v1.5.9 h1:LpIWAOYPyDrXtU+BW7X0Yt/vGtYxtXQ8ql7dFfYUVZA=
|
||||
github.com/pion/datachannel v1.5.9/go.mod h1:kDUuk4CU4Uxp82NH4LQZbISULkX/HtzKa4P7ldf9izE=
|
||||
github.com/pion/dtls/v3 v3.0.3 h1:j5ajZbQwff7Z8k3pE3S+rQ4STvKvXUdKsi/07ka+OWM=
|
||||
github.com/pion/dtls/v3 v3.0.3/go.mod h1:weOTUyIV4z0bQaVzKe8kpaP17+us3yAuiQsEAG1STMU=
|
||||
github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
|
||||
github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
|
||||
github.com/pion/ice/v4 v4.0.2 h1:1JhBRX8iQLi0+TfcavTjPjI6GO41MFn4CeTBX+Y9h5s=
|
||||
github.com/pion/ice/v4 v4.0.2/go.mod h1:DCdqyzgtsDNYN6/3U8044j3U7qsJ9KFJC92VnOWHvXg=
|
||||
github.com/pion/interceptor v0.1.39 h1:Y6k0bN9Y3Lg/Wb21JBWp480tohtns8ybJ037AGr9UuA=
|
||||
github.com/pion/interceptor v0.1.39/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
|
||||
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
|
||||
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
|
||||
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
@@ -100,6 +100,8 @@ github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
|
||||
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
|
||||
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
||||
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
||||
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
||||
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
||||
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
|
||||
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
|
||||
github.com/pion/webrtc/v4 v4.0.1 h1:6Unwc6JzoTsjxetcAIoWH81RUM4K5dBc1BbJGcF9WVE=
|
||||
@@ -140,8 +142,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
@@ -150,8 +152,8 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/unrolled/secure v1.15.0 h1:q7x+pdp8jAHnbzxu6UheP8fRlG/rwYTb8TPuQ3rn9Og=
|
||||
github.com/unrolled/secure v1.15.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
|
||||
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
|
||||
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||
|
||||
@@ -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
|
||||
|
||||
147
server/service/auth/brute_force.go
Normal file
147
server/service/auth/brute_force.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type loginAttempt struct {
|
||||
failures int
|
||||
lastFailed time.Time
|
||||
lockoutEnd time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
maxLoginAttemptsRecords = 3000
|
||||
cleanupInterval = 6 * time.Hour
|
||||
)
|
||||
|
||||
var (
|
||||
loginAttempts = make(map[string]*loginAttempt)
|
||||
loginMutex sync.Mutex
|
||||
cleanupOnce sync.Once
|
||||
)
|
||||
|
||||
// startCleanupRoutine starts a background routine to clean up memory
|
||||
func startCleanupRoutine() {
|
||||
conf := config.GetInstance()
|
||||
if conf.Security.LoginLockoutDuration <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(cleanupInterval)
|
||||
for range ticker.C {
|
||||
loginMutex.Lock()
|
||||
now := time.Now()
|
||||
for ip, attempt := range loginAttempts {
|
||||
// Cleanup rules: if it has been locked and the lockout time has passed,
|
||||
// or (although not locked) it has been 30 minutes since the last failure,
|
||||
// remove this record
|
||||
if (!attempt.lockoutEnd.IsZero() && now.After(attempt.lockoutEnd)) ||
|
||||
(attempt.lockoutEnd.IsZero() && now.Sub(attempt.lastFailed) > 30*time.Minute) {
|
||||
delete(loginAttempts, ip)
|
||||
}
|
||||
}
|
||||
loginMutex.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// GetClientIP gets a reliable real IP
|
||||
func GetClientIP(c *gin.Context) string {
|
||||
ip := c.RemoteIP()
|
||||
if ip == "" {
|
||||
ip = c.ClientIP()
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
// CheckLoginAttempt checks if a login attempt is allowed based on brute-force protection rules.
|
||||
// Returning true means the IP/System is locked out, and an error string and error code are returned.
|
||||
func CheckLoginAttempt(clientIP string) (bool, int, string) {
|
||||
conf := config.GetInstance()
|
||||
if conf.Security.LoginLockoutDuration <= 0 {
|
||||
return false, 0, ""
|
||||
}
|
||||
|
||||
cleanupOnce.Do(startCleanupRoutine)
|
||||
|
||||
loginMutex.Lock()
|
||||
defer loginMutex.Unlock()
|
||||
|
||||
if attempt, exists := loginAttempts[clientIP]; exists {
|
||||
if time.Now().Before(attempt.lockoutEnd) {
|
||||
log.Debugf("login blocked for IP %s: account locked due to too many failed attempts (until %s)", clientIP, attempt.lockoutEnd)
|
||||
return true, -5, "Account locked due to too many failed attempts, please try again later"
|
||||
}
|
||||
|
||||
// If lockout has elapsed, then we reset the failures and lockoutEnd.
|
||||
if !attempt.lockoutEnd.IsZero() {
|
||||
attempt.failures = 0
|
||||
attempt.lockoutEnd = time.Time{}
|
||||
}
|
||||
}
|
||||
|
||||
return false, 0, ""
|
||||
}
|
||||
|
||||
// RecordLoginFailure records a failed login attempt for the given IP address.
|
||||
func RecordLoginFailure(clientIP string) (bool, int, string) {
|
||||
conf := config.GetInstance()
|
||||
if conf.Security.LoginLockoutDuration <= 0 {
|
||||
return false, 0, ""
|
||||
}
|
||||
|
||||
cleanupOnce.Do(startCleanupRoutine)
|
||||
|
||||
loginMutex.Lock()
|
||||
defer loginMutex.Unlock()
|
||||
|
||||
attempt, exists := loginAttempts[clientIP]
|
||||
if !exists {
|
||||
// When the record pool is full, clear the records instead of global lockout to prevent DDoS
|
||||
if len(loginAttempts) >= maxLoginAttemptsRecords {
|
||||
log.Warn("Login attempt records reached maximum limit, clearing records to prevent memory overflow")
|
||||
loginAttempts = make(map[string]*loginAttempt)
|
||||
}
|
||||
attempt = &loginAttempt{}
|
||||
loginAttempts[clientIP] = attempt
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
// Failure time window: if it has been a long time since the last failure
|
||||
// (e.g., beyond the lockoutDuration window), reset the failure count
|
||||
if !attempt.lastFailed.IsZero() && now.Sub(attempt.lastFailed) > time.Duration(conf.Security.LoginLockoutDuration)*time.Second {
|
||||
attempt.failures = 0
|
||||
}
|
||||
|
||||
attempt.failures++
|
||||
attempt.lastFailed = now
|
||||
|
||||
// Reach the failure limit, lock out
|
||||
if attempt.failures >= conf.Security.LoginMaxFailures {
|
||||
attempt.lockoutEnd = now.Add(time.Duration(conf.Security.LoginLockoutDuration) * time.Second)
|
||||
log.Debugf("login failures reached threshold for IP %s, locking out until %s", clientIP, attempt.lockoutEnd)
|
||||
}
|
||||
|
||||
return false, 0, ""
|
||||
}
|
||||
|
||||
// ClearLoginAttempt clears the failed login attempt record for an IP upon successful login.
|
||||
func ClearLoginAttempt(clientIP string) {
|
||||
conf := config.GetInstance()
|
||||
if conf.Security.LoginLockoutDuration <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
loginMutex.Lock()
|
||||
defer loginMutex.Unlock()
|
||||
|
||||
delete(loginAttempts, clientIP)
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/config"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/proto"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -23,6 +24,13 @@ func (s *Service) Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
clientIP := GetClientIP(c)
|
||||
if locked, code, msg := CheckLoginAttempt(clientIP); locked {
|
||||
time.Sleep(3 * time.Second)
|
||||
rsp.ErrRsp(c, code, msg)
|
||||
return
|
||||
}
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
time.Sleep(3 * time.Second)
|
||||
rsp.ErrRsp(c, -1, "invalid parameters")
|
||||
@@ -31,10 +39,18 @@ func (s *Service) Login(c *gin.Context) {
|
||||
|
||||
if ok := CompareAccount(req.Username, req.Password); !ok {
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
if locked, code, msg := RecordLoginFailure(clientIP); locked {
|
||||
rsp.ErrRsp(c, code, msg)
|
||||
return
|
||||
}
|
||||
|
||||
rsp.ErrRsp(c, -2, "invalid username or password")
|
||||
return
|
||||
}
|
||||
|
||||
ClearLoginAttempt(clientIP)
|
||||
|
||||
token, err := middleware.GenerateJWT(req.Username)
|
||||
if err != nil {
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
@@ -20,11 +20,13 @@ const (
|
||||
)
|
||||
|
||||
var StateMap = map[string]proto.TailscaleState{
|
||||
"NoState": proto.TailscaleNotRunning,
|
||||
"Starting": proto.TailscaleNotRunning,
|
||||
"NeedsLogin": proto.TailscaleNotLogin,
|
||||
"Running": proto.TailscaleRunning,
|
||||
"Stopped": proto.TailscaleStopped,
|
||||
"NoState": proto.TailscaleNotRunning,
|
||||
"Starting": proto.TailscaleNotRunning,
|
||||
"NeedsLogin": proto.TailscaleNotLogin,
|
||||
"NeedsMachineAuth": proto.TailscaleNotLogin,
|
||||
"InUseOtherUser": proto.TailscaleNotLogin,
|
||||
"Running": proto.TailscaleRunning,
|
||||
"Stopped": proto.TailscaleStopped,
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'prettier',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
32
web/eslint.config.js
Normal file
32
web/eslint.config.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import eslintConfigPrettier from 'eslint-config-prettier'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'public/mockServiceWorker.js'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
eslintConfigPrettier
|
||||
)
|
||||
@@ -7,7 +7,8 @@
|
||||
"dev": "vite",
|
||||
"mocked": "vite --mode mocked",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"lint": "eslint . --report-unused-disable-directives",
|
||||
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -16,51 +17,54 @@
|
||||
"@xterm/addon-attach": "^0.11.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"antd": "^5.29.1",
|
||||
"axios": "1.12.0",
|
||||
"antd": "^5.29.3",
|
||||
"axios": "1.13.6",
|
||||
"clsx": "^2.1.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"i18next": "^23.16.4",
|
||||
"jotai": "^2.10.1",
|
||||
"i18next": "^23.16.8",
|
||||
"jotai": "^2.18.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-draggable": "^4.4.6",
|
||||
"react-draggable": "^4.5.0",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-i18next": "^14.1.3",
|
||||
"react-responsive": "^10.0.0",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"react-simple-keyboard": "^3.8.19",
|
||||
"semver": "^7.6.3",
|
||||
"react-responsive": "^10.0.1",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-simple-keyboard": "^3.8.164",
|
||||
"semver": "^7.7.4",
|
||||
"vaul": "^0.9.9",
|
||||
"websocket": "^1.0.35",
|
||||
"yocto-queue": "^1.2.1"
|
||||
"yocto-queue": "^1.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
|
||||
"@eslint/js": "^9.39.3",
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/react": "^18.3.28",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/websocket": "^1.0.10",
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.0",
|
||||
"@typescript-eslint/parser": "^8.48.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||
"@typescript-eslint/parser": "^8.56.1",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"msw": "^2.6.0",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.9",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"typescript": "^5.6.3",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-config-prettier": "^9.1.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"globals": "^17.3.0",
|
||||
"msw": "^2.12.10",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "7.1.11",
|
||||
"vite-tsconfig-paths": "^4.3.2"
|
||||
},
|
||||
|
||||
3607
web/pnpm-lock.yaml
generated
3607
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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 });
|
||||
}
|
||||
|
||||
55
web/src/hooks/useMenuBounds.ts
Normal file
55
web/src/hooks/useMenuBounds.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { RefObject, useCallback, useEffect, useState } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
import { menuDisabledItemsAtom } from '@/jotai/settings.ts';
|
||||
|
||||
export interface MenuBounds {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export function useMenuBounds(
|
||||
nodeRef: RefObject<HTMLDivElement | null>,
|
||||
isMenuExpanded: boolean
|
||||
): MenuBounds {
|
||||
const menuDisabledItems = useAtomValue(menuDisabledItemsAtom);
|
||||
const [menuBounds, setMenuBounds] = useState<MenuBounds>({
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0
|
||||
});
|
||||
|
||||
const handleResize = useCallback(() => {
|
||||
if (!nodeRef.current) return;
|
||||
|
||||
const elementRect = nodeRef.current.getBoundingClientRect();
|
||||
const width = (window.innerWidth - elementRect.width) / 2;
|
||||
|
||||
setMenuBounds({
|
||||
left: -width,
|
||||
top: -10,
|
||||
right: width,
|
||||
bottom: window.innerHeight - elementRect.height - 10
|
||||
});
|
||||
}, [nodeRef]);
|
||||
|
||||
// Update bounds on mount and resize
|
||||
useEffect(() => {
|
||||
handleResize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [handleResize]);
|
||||
|
||||
// Update bounds when menu items or expansion state change
|
||||
useEffect(() => {
|
||||
handleResize();
|
||||
}, [menuDisabledItems, isMenuExpanded, handleResize]);
|
||||
|
||||
return menuBounds;
|
||||
}
|
||||
119
web/src/hooks/useMenuVisibility.ts
Normal file
119
web/src/hooks/useMenuVisibility.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
|
||||
import { getMenuDisabledItems, getMenuDisplayMode } from '@/lib/localstorage.ts';
|
||||
import {
|
||||
menuDisabledItemsAtom,
|
||||
menuDisplayModeAtom,
|
||||
submenuOpenCountAtom
|
||||
} from '@/jotai/settings.ts';
|
||||
|
||||
const HIDE_TIMEOUT = 5000;
|
||||
|
||||
export interface MenuVisibilityState {
|
||||
isInitialized: boolean;
|
||||
isMenuExpanded: boolean;
|
||||
isMenuHidden: boolean;
|
||||
isMenuMoved: boolean;
|
||||
handleHovered: (hovered: boolean) => void;
|
||||
handleMoved: () => void;
|
||||
setIsMenuExpanded: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
export function useMenuVisibility(): MenuVisibilityState {
|
||||
const [menuDisplayMode, setMenuDisplayMode] = useAtom(menuDisplayModeAtom);
|
||||
const setMenuDisabledItems = useSetAtom(menuDisabledItemsAtom);
|
||||
const submenuOpenCount = useAtomValue(submenuOpenCountAtom);
|
||||
|
||||
const [isMenuExpanded, setIsMenuExpanded] = useState(true);
|
||||
const [isMenuMoved, setIsMenuMoved] = useState(false);
|
||||
const [isMenuHovered, setIsMenuHovered] = useState(false);
|
||||
const [isMenuHidden, setIsMenuHidden] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const startCountdown = useCallback(() => {
|
||||
if (menuDisplayMode !== 'auto' || submenuOpenCount > 0 || !isMenuExpanded || isMenuMoved) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
setIsMenuHidden(true);
|
||||
}, HIDE_TIMEOUT);
|
||||
}, [menuDisplayMode, submenuOpenCount, isMenuExpanded, isMenuMoved]);
|
||||
|
||||
const stopCountdown = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initialize menu settings
|
||||
useEffect(() => {
|
||||
const displayMode = getMenuDisplayMode();
|
||||
setMenuDisplayMode(displayMode);
|
||||
|
||||
const items = getMenuDisabledItems();
|
||||
setMenuDisabledItems(items);
|
||||
|
||||
if (displayMode === 'off') {
|
||||
setIsMenuExpanded(false);
|
||||
}
|
||||
|
||||
setIsInitialized(true);
|
||||
|
||||
return () => {
|
||||
stopCountdown();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle display mode changes
|
||||
useEffect(() => {
|
||||
setIsMenuHidden(false);
|
||||
|
||||
if (menuDisplayMode === 'auto') {
|
||||
startCountdown();
|
||||
} else {
|
||||
stopCountdown();
|
||||
}
|
||||
}, [menuDisplayMode, startCountdown, stopCountdown]);
|
||||
|
||||
// Handle hover and submenu state
|
||||
useEffect(() => {
|
||||
if (submenuOpenCount === 0 && !isMenuHovered) {
|
||||
startCountdown();
|
||||
} else {
|
||||
stopCountdown();
|
||||
}
|
||||
}, [submenuOpenCount, isMenuHovered, startCountdown, stopCountdown]);
|
||||
|
||||
// Handle hover state
|
||||
const handleHovered = useCallback((hovered: boolean) => {
|
||||
setIsMenuHovered(hovered);
|
||||
if (hovered) {
|
||||
setIsMenuHidden(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMoved = useCallback(() => {
|
||||
if (!isMenuMoved) {
|
||||
setIsMenuMoved(true);
|
||||
}
|
||||
}, [isMenuMoved]);
|
||||
|
||||
return {
|
||||
isMenuExpanded,
|
||||
isMenuHidden,
|
||||
isMenuMoved,
|
||||
isInitialized,
|
||||
handleHovered,
|
||||
handleMoved,
|
||||
setIsMenuExpanded
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,8 @@ const en = {
|
||||
noEmptyPassword: 'Password required',
|
||||
noAccount: 'Failed to get user information, please refresh web page or reset password',
|
||||
invalidUser: 'Invalid username or password',
|
||||
locked: 'Too many logins, please try again later',
|
||||
globalLocked: 'System under protection, please try again later',
|
||||
error: 'Unexpected error',
|
||||
changePassword: 'Change Password',
|
||||
changePasswordDesc: 'For the security of your device, please change the password!',
|
||||
@@ -92,6 +94,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: {
|
||||
@@ -243,10 +273,18 @@ const en = {
|
||||
display: 'Display',
|
||||
language: 'Language',
|
||||
languageDesc: 'Select the language for the interface',
|
||||
menuBar: 'Menu Bar',
|
||||
menuBarDesc: 'Display icons in the menu bar',
|
||||
webTitle: 'Web Title',
|
||||
webTitleDesc: 'Customize the web page title'
|
||||
webTitleDesc: 'Customize the web page title',
|
||||
menuBar: {
|
||||
title: 'Menu Bar',
|
||||
mode: 'Display Mode',
|
||||
modeDesc: 'Display menu bar on the screen',
|
||||
modeOff: 'Off',
|
||||
modeAuto: 'Auto hide',
|
||||
modeAlways: 'Always visible',
|
||||
icons: 'Submenu Icons',
|
||||
iconsDesc: 'Display submenu icons in the menu bar'
|
||||
}
|
||||
},
|
||||
device: {
|
||||
title: 'Device',
|
||||
|
||||
@@ -217,10 +217,19 @@ const ko = {
|
||||
title: '디자인',
|
||||
display: '표시',
|
||||
language: '언어',
|
||||
menuBar: '메뉴 바',
|
||||
menuBarDesc: '메뉴 바에 아이콘을 표시',
|
||||
languageDesc: '인터페이스 언어를 선택하세요',
|
||||
webTitle: '웹 제목',
|
||||
webTitleDesc: '웹 페이지 제목 사용자 지정'
|
||||
webTitleDesc: '웹 페이지 제목 사용자 지정',
|
||||
menuBar: {
|
||||
title: '메뉴 바',
|
||||
mode: '표시 모드',
|
||||
modeDesc: '메뉴 바를 화면에 표시합니다',
|
||||
modeOff: '꺼짐',
|
||||
modeAuto: '자동 숨기기',
|
||||
modeAlways: '항상 보이기',
|
||||
icons: '하위 메뉴 아이콘',
|
||||
iconsDesc: '메뉴 바에 하위 메뉴 아이콘을 표시합니다'
|
||||
}
|
||||
},
|
||||
device: {
|
||||
title: '장치',
|
||||
@@ -336,7 +345,14 @@ const ko = {
|
||||
cancel: '취소',
|
||||
preview: '미리보기 업데이트',
|
||||
previewDesc: '새로운 기능과 개선 사항에 미리 접근하세요',
|
||||
previewTip: '미리보기 버전에는 버그나 완성되지 않은 기능이 포함될 수 있으니 주의하세요!'
|
||||
previewTip: '미리보기 버전에는 버그나 완성되지 않은 기능이 포함될 수 있으니 주의하세요!',
|
||||
offline: {
|
||||
title: '오프라인 업데이트',
|
||||
desc: '로컬 설치 패키지를 통한 업데이트',
|
||||
upload: '업로드',
|
||||
invalidName: '유효하지 않은 파일 이름 형식입니다. GitHub 릴리즈에서 다운로드하세요.',
|
||||
updateFailed: '업데이트에 실패했습니다. 재시도하세요.'
|
||||
}
|
||||
},
|
||||
account: {
|
||||
title: '계정',
|
||||
|
||||
@@ -94,7 +94,7 @@ const tr = {
|
||||
resetHid: 'İnsan Arayüz Cihazı\'nı sıfırlayın',
|
||||
hidOnly: {
|
||||
title: 'Sadece İnsan Arayüz Cihazı modu',
|
||||
desc: "Eğer fare ve klavye çalışmayı bırakırsa ve İnsan Arayüz Cihazı\'nı sıfırlamak sorunu çözmezse bunun sebebi cihazınız ve NanoKVM arasındaki bir uyumluluk sorunu olabilir. Uyumluluğu arttırmak için Sadece İnsan Arayüz Cihazı modunu kullanın.",
|
||||
desc: "Eğer fare ve klavye çalışmayı bırakırsa ve İnsan Arayüz Cihazı'nı sıfırlamak sorunu çözmezse bunun sebebi cihazınız ve NanoKVM arasındaki bir uyumluluk sorunu olabilir. Uyumluluğu arttırmak için Sadece İnsan Arayüz Cihazı modunu kullanın.",
|
||||
tip1: 'Sadece İnsan Arayüz Cihazı modunu aktifleştirmek, sanal U-disk’i ve sanal ağı devre dışı bırakacaktır.',
|
||||
tip2: 'Sadece İnsan Arayüz Cihazı modunda disk imajı bağlama özelliği devre dışı bırakılır',
|
||||
tip3: 'NanoKVM mod değişiminden sonra kendiliğinden yeniden başlatılacaktır',
|
||||
|
||||
@@ -16,6 +16,8 @@ const zh = {
|
||||
noEmptyPassword: '密码不能为空',
|
||||
noAccount: '获取用户信息失败,请刷新重试或重置密码',
|
||||
invalidUser: '用户名或密码错误',
|
||||
locked: '登录太频繁,请稍后再试',
|
||||
globalLocked: '系统防爆破保护中,请稍后再试',
|
||||
error: '未知错误',
|
||||
changePassword: '修改密码',
|
||||
changePasswordDesc: '为了您的设备安全,请修改密码!',
|
||||
@@ -81,6 +83,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: {
|
||||
@@ -212,10 +241,18 @@ const zh = {
|
||||
display: '显示',
|
||||
language: '语言',
|
||||
languageDesc: '选择界面语言',
|
||||
menuBar: '菜单栏',
|
||||
menuBarDesc: '是否在菜单栏中显示图标',
|
||||
webTitle: '网页标题',
|
||||
webTitleDesc: '自定义网站页面标题'
|
||||
webTitleDesc: '自定义网站页面标题',
|
||||
menuBar: {
|
||||
title: '菜单栏',
|
||||
mode: '显示方式',
|
||||
modeDesc: '菜单栏在屏幕上的显示方式',
|
||||
modeOff: '关闭',
|
||||
modeAuto: '自动隐藏',
|
||||
modeAlways: '始终显示',
|
||||
icons: '菜单图标',
|
||||
iconsDesc: '是否在菜单栏中显示子菜单图标'
|
||||
}
|
||||
},
|
||||
device: {
|
||||
title: '设备',
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -8,3 +8,6 @@ export const submenuOpenCountAtom = atom(0);
|
||||
|
||||
// web title
|
||||
export const webTitleAtom = atom('');
|
||||
|
||||
// menu display mode: 'off' | 'auto' | 'always'
|
||||
export const menuDisplayModeAtom = atom<string>('auto');
|
||||
|
||||
61
web/src/lib/browser.ts
Normal file
61
web/src/lib/browser.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export type System = 'Windows' | 'macOS' | 'Linux' | 'Unknown';
|
||||
|
||||
export interface BrowserInfo {
|
||||
os: System;
|
||||
isChrome: boolean;
|
||||
}
|
||||
|
||||
export function getOperatingSystem(): System {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
if ('userAgentData' in navigator) {
|
||||
// @ts-expect-error check userAgentData.platform
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to User Agent
|
||||
const userAgent = navigator.userAgent;
|
||||
|
||||
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 'Unknown';
|
||||
}
|
||||
|
||||
export function isChromeBrowser(): boolean {
|
||||
if (typeof window === 'undefined' || !window.navigator) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('userAgentData' in navigator) {
|
||||
// @ts-expect-error userAgentData.brands
|
||||
return navigator.userAgentData?.brands?.some(
|
||||
(brand: any) => brand.brand === 'Google Chrome' || brand.brand === 'Chromium'
|
||||
);
|
||||
}
|
||||
|
||||
const userAgent = navigator.userAgent;
|
||||
const vendor = navigator.vendor;
|
||||
|
||||
return (
|
||||
/Chrome|Chromium/.test(userAgent) &&
|
||||
/Google Inc/.test(vendor) &&
|
||||
!/Edg/.test(userAgent) &&
|
||||
!/OPR/.test(userAgent)
|
||||
);
|
||||
}
|
||||
|
||||
export function getBrowserInfo(): BrowserInfo {
|
||||
return {
|
||||
os: getOperatingSystem(),
|
||||
isChrome: isChromeBrowser()
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const KEYBOARD_SYSTEM_KEY = 'nano-kvm-keyboard-system';
|
||||
const KEYBOARD_LANGUAGE_KEY = 'nano-kvm-keyboard-language';
|
||||
const SKIP_MODIFY_PASSWORD_KEY = 'nano-kvm-skip-modify-password';
|
||||
const MENU_DISABLED_ITEMS_KEY = 'nano-kvm-menu-disabled-items';
|
||||
const MENU_AUTO_HIDE_KEY = 'nano-kvm-menu-auto-hide';
|
||||
const POWER_CONFIRM_KEY = 'nano-kvm-power-confirm';
|
||||
|
||||
type ItemWithExpiry = {
|
||||
@@ -212,6 +213,15 @@ export function getMenuDisabledItems(): string[] {
|
||||
return value ? JSON.parse(value) : [];
|
||||
}
|
||||
|
||||
export function getMenuDisplayMode(): string {
|
||||
const value = localStorage.getItem(MENU_AUTO_HIDE_KEY);
|
||||
return value || 'auto';
|
||||
}
|
||||
|
||||
export function setMenuDisplayMode(mode: string) {
|
||||
localStorage.setItem(MENU_AUTO_HIDE_KEY, mode);
|
||||
}
|
||||
|
||||
export function getPowerConfirm() {
|
||||
const enabled = localStorage.getItem(POWER_CONFIRM_KEY);
|
||||
return enabled === 'true';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, ReactElement } from 'react';
|
||||
import { ReactElement, useEffect, useState } from 'react';
|
||||
import { LockOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { Button, Form, Input } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -41,7 +41,12 @@ export const Login = (): ReactElement => {
|
||||
.login(username, password)
|
||||
.then((rsp: any) => {
|
||||
if (rsp.code !== 0) {
|
||||
setMsg(rsp.code === -2 ? t('auth.invalidUser') : t('auth.error'));
|
||||
let errorMsg = t('auth.error');
|
||||
if (rsp.code === -2) errorMsg = t('auth.invalidUser');
|
||||
else if (rsp.code === -5) errorMsg = t('auth.locked');
|
||||
else if (rsp.code === -4) errorMsg = t('auth.globalLocked');
|
||||
|
||||
setMsg(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -80,7 +85,8 @@ export const Login = (): ReactElement => {
|
||||
setTimeout(() => {
|
||||
(evt.target as HTMLImageElement).classList.remove('animate-spin');
|
||||
}, 1000);
|
||||
}} />
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Form.Item
|
||||
name="username"
|
||||
@@ -100,7 +106,7 @@ export const Login = (): ReactElement => {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div className="text-red-500">{msg}</div>
|
||||
<div className="pb-1 text-red-500">{msg}</div>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" className="w-full" loading={isLoading}>
|
||||
|
||||
@@ -1,75 +1,140 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
import { getOperatingSystem } from '@/lib/browser.ts';
|
||||
import { KeyboardReport } from '@/lib/keyboard.ts';
|
||||
import { isModifier } from '@/lib/keymap';
|
||||
import { client, MessageEvent } from '@/lib/websocket.ts';
|
||||
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
|
||||
|
||||
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 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 (!isKeyboardEnabled) {
|
||||
releaseKeys();
|
||||
return;
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener('keyup', handleKeyUp);
|
||||
document.addEventListener('compositionstart', handleCompositionStart);
|
||||
document.addEventListener('compositionend', handleCompositionEnd);
|
||||
window.addEventListener('blur', handleBlur);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
// Key down event
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (!isKeyboardEnabled) return;
|
||||
if (isComposing.current || event.isComposing) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const code = event.code;
|
||||
if (pressedKeys.current.has(code)) {
|
||||
const code = normalizeKeyCode(event, os);
|
||||
if (!code || pressedKeys.current.has(code)) return;
|
||||
|
||||
// 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 leader key release
|
||||
const leaderHandled = leaderKeyRef.current.handleKeyUp(code);
|
||||
if (leaderHandled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle AltGr key release
|
||||
const altGrHandled = altGrRef.current.handleKeyUp(code);
|
||||
if (altGrHandled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Release Meta combinations (macOS compatibility)
|
||||
if (code === 'MetaLeft' || code === 'MetaRight') {
|
||||
releaseMetaCombinations();
|
||||
}
|
||||
|
||||
pressedKeys.current.delete(code);
|
||||
handleKeyEvent({ type: 'keyup', code });
|
||||
sendKeyEvent('keyup', code);
|
||||
}
|
||||
|
||||
function handleCompositionStart() {
|
||||
isComposing.current = true;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
leaderKeyRef.current.reset();
|
||||
altGrRef.current.reset();
|
||||
|
||||
const report = keyboardRef.current.reset();
|
||||
sendReport(report);
|
||||
}
|
||||
@@ -77,23 +142,23 @@ export const Keyboard = () => {
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener('keyup', handleKeyUp);
|
||||
document.removeEventListener('compositionstart', handleCompositionStart);
|
||||
document.removeEventListener('compositionend', handleCompositionEnd);
|
||||
window.removeEventListener('blur', handleBlur);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
releaseKeys();
|
||||
};
|
||||
}, [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;
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { Divider } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { GripVerticalIcon } from 'lucide-react';
|
||||
import Draggable, { DraggableData, DraggableEvent } from 'react-draggable';
|
||||
|
||||
import { getMenuDisabledItems } from '@/lib/localstorage.ts';
|
||||
import { menuDisabledItemsAtom, submenuOpenCountAtom } from '@/jotai/settings.ts';
|
||||
import { menuDisabledItemsAtom } from '@/jotai/settings.ts';
|
||||
import { useMenuBounds } from '@/hooks/useMenuBounds.ts';
|
||||
import { useMenuVisibility } from '@/hooks/useMenuVisibility.ts';
|
||||
|
||||
import { DownloadImage } from './download.tsx';
|
||||
import { Fullscreen } from './fullscreen';
|
||||
@@ -21,99 +22,25 @@ import { Settings } from './settings';
|
||||
import { Terminal } from './terminal';
|
||||
import { Wol } from './wol';
|
||||
|
||||
const HIDE_TIMEOUT = 8000;
|
||||
|
||||
export const Menu = () => {
|
||||
const [menuDisabledItems, setMenuDisabledItems] = useAtom(menuDisabledItemsAtom);
|
||||
const submenuOpenCount = useAtomValue(submenuOpenCountAtom);
|
||||
|
||||
const [isMenuExpanded, setIsMenuExpanded] = useState(true);
|
||||
const [isMenuMoved, setIsMenuMoved] = useState(false);
|
||||
const [isMenuHovered, setIsMenuHovered] = useState(false);
|
||||
const [isMenuHidden, setIsMenuHidden] = useState(false);
|
||||
const [menuBounds, setMenuBounds] = useState({ left: 0, right: 0, top: 0, bottom: 0 });
|
||||
|
||||
const nodeRef = useRef<HTMLDivElement | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// init menu
|
||||
useEffect(() => {
|
||||
const items = getMenuDisabledItems();
|
||||
setMenuDisabledItems(items);
|
||||
const menuDisabledItems = useAtomValue(menuDisabledItemsAtom);
|
||||
|
||||
startCountdown();
|
||||
handleResize();
|
||||
const {
|
||||
isInitialized,
|
||||
isMenuExpanded,
|
||||
isMenuHidden,
|
||||
handleHovered,
|
||||
handleMoved,
|
||||
setIsMenuExpanded
|
||||
} = useMenuVisibility();
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
const menuBounds = useMenuBounds(nodeRef, isMenuExpanded);
|
||||
|
||||
return () => {
|
||||
stopCountdown();
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// handle menu bounds
|
||||
useEffect(() => {
|
||||
handleResize();
|
||||
}, [menuDisabledItems, isMenuExpanded]);
|
||||
|
||||
// handle hide timer
|
||||
useEffect(() => {
|
||||
if (submenuOpenCount === 0 && !isMenuHovered) {
|
||||
startCountdown();
|
||||
return;
|
||||
}
|
||||
|
||||
stopCountdown();
|
||||
}, [submenuOpenCount, isMenuHovered]);
|
||||
|
||||
function handleResize() {
|
||||
if (!nodeRef.current) return;
|
||||
|
||||
const elementRect = nodeRef.current.getBoundingClientRect();
|
||||
const width = (window.innerWidth - elementRect.width) / 2;
|
||||
|
||||
setMenuBounds({
|
||||
left: -width,
|
||||
top: -10,
|
||||
right: width,
|
||||
bottom: window.innerHeight - elementRect.height - 10
|
||||
});
|
||||
}
|
||||
|
||||
function startCountdown() {
|
||||
if (submenuOpenCount > 0 || !isMenuExpanded || isMenuMoved) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
setIsMenuHidden(true);
|
||||
}, HIDE_TIMEOUT);
|
||||
}
|
||||
|
||||
function stopCountdown() {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMoved(_e: DraggableEvent, data: DraggableData) {
|
||||
function onDragStop(_e: DraggableEvent, data: DraggableData) {
|
||||
if (data.x === 0 && data.y === 0) return;
|
||||
if (isMenuMoved) return;
|
||||
|
||||
setIsMenuMoved(true);
|
||||
}
|
||||
|
||||
function handleHovered(hovered: boolean) {
|
||||
setIsMenuHovered(hovered);
|
||||
if (hovered) {
|
||||
setIsMenuHidden(false);
|
||||
}
|
||||
handleMoved();
|
||||
}
|
||||
|
||||
function isEnabled(item: string) {
|
||||
@@ -126,11 +53,14 @@ export const Menu = () => {
|
||||
bounds={menuBounds}
|
||||
handle="strong"
|
||||
positionOffset={{ x: '-50%', y: '0%' }}
|
||||
onStop={handleMoved}
|
||||
onStop={onDragStop}
|
||||
>
|
||||
<div
|
||||
ref={nodeRef}
|
||||
className="fixed left-1/2 top-[10px] z-[1000] -translate-x-1/2"
|
||||
className={clsx(
|
||||
'fixed left-1/2 top-[10px] z-[1000] -translate-x-1/2 transition-opacity duration-300',
|
||||
isInitialized ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
onMouseEnter={() => handleHovered(true)}
|
||||
onMouseLeave={() => handleHovered(false)}
|
||||
onBlur={() => handleHovered(false)}
|
||||
@@ -144,7 +74,6 @@ export const Menu = () => {
|
||||
<div className="sticky top-[10px] flex w-full justify-center">
|
||||
<div
|
||||
className={clsx(
|
||||
'h-[36px] items-center rounded bg-neutral-800/80 transition-all duration-300',
|
||||
'h-[36px] items-center rounded bg-neutral-800/80 pl-1 pr-2 transition-all duration-300',
|
||||
isMenuExpanded ? 'flex' : 'hidden',
|
||||
isMenuHidden ? '-translate-y-[110%] opacity-80' : 'translate-y-0 opacity-100'
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,8 @@ import { Divider } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Language } from './language.tsx';
|
||||
import { MenuBar } from './menu-bar.tsx';
|
||||
import { MenuIcons } from './menu-icons.tsx';
|
||||
import { MenuMode } from './menu-mode.tsx';
|
||||
import { WebTitle } from './web-title.tsx';
|
||||
|
||||
export const Appearance = () => {
|
||||
@@ -19,7 +20,9 @@ export const Appearance = () => {
|
||||
|
||||
<Divider className="opacity-50" style={{ margin: '32px 0' }} />
|
||||
|
||||
<MenuBar />
|
||||
<div className="text-neutral-400">{t('settings.appearance.menuBar.title')}</div>
|
||||
<MenuMode />
|
||||
<MenuIcons />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import * as ls from '@/lib/localstorage.ts';
|
||||
import { menuDisabledItemsAtom } from '@/jotai/settings.ts';
|
||||
|
||||
export const MenuBar = () => {
|
||||
export const MenuIcons = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [menuDisabledItems, setMenuDisabledItems] = useAtom(menuDisabledItemsAtom);
|
||||
@@ -43,13 +43,15 @@ export const MenuBar = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-8 flex flex-col space-y-5">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-neutral-400">{t('settings.appearance.menuBar')}</span>
|
||||
<span className="text-xs text-neutral-500">{t('settings.appearance.menuBarDesc')}</span>
|
||||
<span className="text-neutral-400">{t('settings.appearance.menuBar.icons')}</span>
|
||||
<span className="text-xs text-neutral-500">
|
||||
{t('settings.appearance.menuBar.iconsDesc')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-col space-y-5">
|
||||
<div className="mt-5 flex flex-col space-y-5">
|
||||
{items.map((item) => (
|
||||
<div key={item.key} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 text-neutral-400">
|
||||
@@ -66,6 +68,6 @@ export const MenuBar = () => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
38
web/src/pages/desktop/menu/settings/appearance/menu-mode.tsx
Normal file
38
web/src/pages/desktop/menu/settings/appearance/menu-mode.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Segmented } from 'antd';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as storage from '@/lib/localstorage.ts';
|
||||
import { menuDisplayModeAtom } from '@/jotai/settings.ts';
|
||||
|
||||
export const MenuMode = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [menuDisplayMode, setMenuDisplayMode] = useAtom(menuDisplayModeAtom);
|
||||
|
||||
const options = [
|
||||
// { value: 'off', label: t('settings.appearance.menuBar.modeOff') },
|
||||
{ value: 'auto', label: t('settings.appearance.menuBar.modeAuto') },
|
||||
{ value: 'always', label: t('settings.appearance.menuBar.modeAlways') }
|
||||
];
|
||||
|
||||
function handleChange(mode: string) {
|
||||
if (mode === menuDisplayMode) return;
|
||||
|
||||
setMenuDisplayMode(mode);
|
||||
storage.setMenuDisplayMode(mode);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-5 flex w-full items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-neutral-400">{t('settings.appearance.menuBar.mode')}</span>
|
||||
<span className="text-xs text-neutral-500">
|
||||
{t('settings.appearance.menuBar.modeDesc')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Segmented value={menuDisplayMode} options={options} onChange={handleChange} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -55,6 +55,9 @@ export const Device = ({ status, onLogout }: DeviceProps) => {
|
||||
|
||||
onLogout();
|
||||
})
|
||||
.catch((err) => {
|
||||
setErrMsg(err?.message || 'Failed to logout');
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLogging(false);
|
||||
});
|
||||
|
||||
@@ -41,6 +41,9 @@ export const Tailscale = ({ setIsLocked }: TailscaleProps) => {
|
||||
|
||||
setStatus(rsp.data);
|
||||
})
|
||||
.catch((err) => {
|
||||
setErrMsg(err?.message || 'Failed to get status');
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
@@ -29,9 +29,12 @@ export const Install = ({ setIsLocked, onSuccess }: InstallProps) => {
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
setState('');
|
||||
})
|
||||
.catch(() => {
|
||||
setState('failed');
|
||||
})
|
||||
.finally(() => {
|
||||
setState('');
|
||||
setIsLocked(false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ export const Login = ({ onSuccess }: LoginProps) => {
|
||||
window.open(url, '_blank');
|
||||
setTimeout(() => setLoginUrl(''), 10 * 60 * 1000);
|
||||
})
|
||||
.catch((err) => {
|
||||
setErrMsg(err?.message || 'Failed to login');
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
@@ -29,6 +29,9 @@ export const Run = ({ onSuccess }: RunProps) => {
|
||||
|
||||
onSuccess();
|
||||
})
|
||||
.catch((err) => {
|
||||
setErrMsg(err?.message || 'Failed to run tailscale');
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
@@ -45,6 +45,7 @@ export const Uninstall = ({ onSuccess }: UninstallProps) => {
|
||||
<Modal
|
||||
title={title}
|
||||
open={isModalOpen}
|
||||
centered={true}
|
||||
okType="danger"
|
||||
okText={t('settings.tailscale.okBtn')}
|
||||
cancelText={t('settings.tailscale.cancelBtn')}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -137,7 +137,6 @@ export const VirtualKeyboard = () => {
|
||||
// Tausch
|
||||
if (base === 'KeyZ') return getKeycode('KeyY');
|
||||
if (base === 'KeyY') return getKeycode('KeyZ');
|
||||
if (base === 'IntlBackslash') return getKeycode('IntlBackslash_qwertz');
|
||||
// all other labels use their own code
|
||||
return getKeycode(base);
|
||||
}
|
||||
|
||||
@@ -79,11 +79,11 @@ export const keyboardOptions = {
|
||||
|
||||
KeyY_qwertz: 'Y',
|
||||
KeyZ_qwertz: 'Z',
|
||||
|
||||
|
||||
KeyQ_qwertz: 'Q<br/>@',
|
||||
KeyE_qwertz: 'E<br/>€',
|
||||
BracketRight_qwertz: '+~*',
|
||||
Backslash_qwertz: '#<br/>\'',
|
||||
Backslash_qwertz: "#<br/>'",
|
||||
IntlBackslash_qwertz: '<|>',
|
||||
Comma_qwertz: ',<br/>;',
|
||||
Period_qwertz: '.<br/>:',
|
||||
@@ -91,11 +91,11 @@ export const keyboardOptions = {
|
||||
'{controlleft_qwertz}': 'STRG',
|
||||
'{altright_qwertz}': 'ALT GR',
|
||||
'{controlright_qwertz}': 'STRG',
|
||||
|
||||
|
||||
BracketLeft_qwertz: 'Ü',
|
||||
Semicolon_qwertz: 'Ö',
|
||||
Quote_qwertz: 'Ä',
|
||||
|
||||
|
||||
'{tab}': 'Tab',
|
||||
KeyQ: 'Q',
|
||||
KeyW: 'W',
|
||||
@@ -336,7 +336,7 @@ export const modifierKeys = [
|
||||
'{altright}',
|
||||
'{metaright}',
|
||||
'{winright}',
|
||||
|
||||
|
||||
// DE-spezifische Modifier-Tokens (für qwertz-Layout)
|
||||
'{controlleft_qwertz}',
|
||||
'{controlright_qwertz}',
|
||||
|
||||
Reference in New Issue
Block a user