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