mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-11-10 09:59:15 -06:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
590db5b08c | ||
|
|
10a512ec96 | ||
|
|
08093dddad | ||
|
|
86a85a61b7 | ||
|
|
cb3a2c562e | ||
|
|
67fe57f189 | ||
|
|
44caee21ab | ||
|
|
5b2a1ff788 | ||
|
|
dc5ba29bcf | ||
|
|
89c14310c7 | ||
|
|
3322a3c07f | ||
|
|
9e1251ad6a | ||
|
|
afec4efb34 | ||
|
|
11980b9122 | ||
|
|
2a43fb5413 | ||
|
|
57887f3f69 | ||
|
|
2b8f640957 |
29
CHANGELOG.md
29
CHANGELOG.md
@@ -1,3 +1,32 @@
|
||||
## 2.3.6 (2026-03-12)
|
||||
|
||||
### Features
|
||||
|
||||
* Implemented AP password authentication for the WiFi configuration page
|
||||
* Added Korean and Japanese virtual keyboard layouts (thanks to [@klim4-bot](https://github.com/klim4-bot))
|
||||
* Enabled support for 640x480 resolution (thanks to [@Voranto](https://github.com/Voranto))
|
||||
* Added encryption parameters for OLED distribution network
|
||||
* Added custom logo function and logo generation tools
|
||||
* Added update mechanism for tailscale startup script
|
||||
|
||||
### Localization
|
||||
|
||||
* Updated Japanese translation (thanks to [@tkmsst](https://github.com/tkmsst))
|
||||
|
||||
## 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
|
||||
|
||||
@@ -32,6 +32,18 @@ case "$1" in
|
||||
iptables -C OUTPUT -o eth0 -p tcp --sport 8000 -m state --state ESTABLISHED -j DROP 2>/dev/null || \
|
||||
iptables -A OUTPUT -o eth0 -p tcp --sport 8000 -m state --state ESTABLISHED -j DROP
|
||||
|
||||
# Logo selection
|
||||
if [ -f /boot/logo.ico ]; then
|
||||
if [ ! -f /kvmapp/server/web/original.ico ]; then
|
||||
mv /kvmapp/server/web/sipeed.ico /kvmapp/server/web/original.ico
|
||||
cp /boot/logo.ico /kvmapp/server/web/sipeed.ico
|
||||
fi
|
||||
else
|
||||
if [ -f /kvmapp/server/web/original.ico ]; then
|
||||
mv /kvmapp/server/web/original.ico /kvmapp/server/web/sipeed.ico
|
||||
fi
|
||||
fi
|
||||
|
||||
# Start services
|
||||
cp -r /kvmapp/kvm_system /tmp/
|
||||
/tmp/kvm_system/kvm_system &
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -10,7 +10,8 @@ import (
|
||||
func networkRouter(r *gin.Engine) {
|
||||
service := network.NewService()
|
||||
|
||||
r.POST("/api/network/wifi", service.ConnectWifiNoAuth) // connect Wi-Fi without auth (only available in ap mode)
|
||||
r.POST("/api/network/wifi", service.ConnectWifiNoAuth) // connect Wi-Fi without auth (only available in ap mode)
|
||||
r.POST("/api/network/wifi/verify", service.VerifyApLogin) // verify ap login
|
||||
|
||||
api := r.Group("/api").Use(middleware.CheckToken())
|
||||
|
||||
|
||||
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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -21,6 +22,7 @@ const (
|
||||
WiFiConnect = "/kvmapp/kvm/wifi_try_connect"
|
||||
WiFiStateFile = "/kvmapp/kvm/wifi_state"
|
||||
WiFiScript = "/etc/init.d/S30wifi"
|
||||
WiFiApPassFile = "/kvmapp/kvm/ap.pass"
|
||||
)
|
||||
|
||||
func (s *Service) GetWifi(c *gin.Context) {
|
||||
@@ -62,6 +64,15 @@ func (s *Service) ConnectWifiNoAuth(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify AP Password
|
||||
apKey := c.GetHeader("X-AP-Key")
|
||||
expectedPass := getApPassword()
|
||||
if apKey == "" || expectedPass == "" || subtle.ConstantTimeCompare([]byte(apKey), []byte(expectedPass)) != 1 {
|
||||
time.Sleep(2 * time.Second)
|
||||
rsp.ErrRsp(c, -4, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
time.Sleep(1 * time.Second)
|
||||
rsp.ErrRsp(c, -2, "invalid parameters")
|
||||
@@ -79,6 +90,26 @@ func (s *Service) ConnectWifiNoAuth(c *gin.Context) {
|
||||
log.Debugf("set wifi ap mode successfully")
|
||||
}
|
||||
|
||||
func (s *Service) VerifyApLogin(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
if !isSupported() || !isAPMode() {
|
||||
time.Sleep(2 * time.Second)
|
||||
rsp.ErrRsp(c, -1, "invalid mode")
|
||||
return
|
||||
}
|
||||
|
||||
apKey := c.GetHeader("X-AP-Key")
|
||||
expectedPass := getApPassword()
|
||||
if apKey == "" || expectedPass == "" || subtle.ConstantTimeCompare([]byte(apKey), []byte(expectedPass)) != 1 {
|
||||
time.Sleep(2 * time.Second)
|
||||
rsp.ErrRsp(c, -4, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
}
|
||||
|
||||
func (s *Service) ConnectWifi(c *gin.Context) {
|
||||
var req proto.ConnectWifiReq
|
||||
var rsp proto.Response
|
||||
@@ -111,7 +142,6 @@ func (s *Service) ConnectWifi(c *gin.Context) {
|
||||
}
|
||||
|
||||
rsp.ErrRsp(c, -3, "failed to get wifi status")
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Service) DisconnectWifi(c *gin.Context) {
|
||||
@@ -182,3 +212,12 @@ func getWiFiSsid() string {
|
||||
|
||||
return strings.ReplaceAll(string(ssidByte), "\n", "")
|
||||
}
|
||||
|
||||
func getApPassword() string {
|
||||
passByte, err := os.ReadFile(WiFiApPassFile)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.ReplaceAll(string(passByte), "\n", "")
|
||||
}
|
||||
|
||||
@@ -164,6 +164,7 @@ uint16_t hdmi_res_list[][2] = {
|
||||
{1152, 864},
|
||||
{1024, 768},
|
||||
{800, 600},
|
||||
{640, 480},
|
||||
};
|
||||
|
||||
uint16_t hdmi_unsupported_res_list[][2] = {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -356,8 +356,8 @@ void OLED_ShowStringTurn(uint8_t x, uint8_t y, char *chr, uint8_t sizey);
|
||||
void OLED_ShowStringtoend(uint8_t x, uint8_t y, char *chr, uint8_t sizey, uint8_t end);
|
||||
void OLED_ShowString_AlignRight(uint8_t x_end, uint8_t y, char *chr, uint8_t size);
|
||||
void OLED_ROW(uint8_t _EN);
|
||||
void OLED_ShowKVMLogo();
|
||||
void OLED_ShowLogo();
|
||||
void OLED_ShowSipeedLogo();
|
||||
void OLED_Showline();
|
||||
void OLED_Showline_1();
|
||||
void OLED_ShowIMG(uint8_t x,uint8_t y,char *chr,uint8_t width,uint8_t height);
|
||||
|
||||
@@ -13,8 +13,8 @@ void kvm_init_cube_ui(void)
|
||||
|
||||
OLED_Clear();
|
||||
// OLED_Revolve();
|
||||
OLED_ShowKVMLogo();
|
||||
OLED_ShowLogo();
|
||||
OLED_ShowSipeedLogo();
|
||||
OLED_ShowKVMState(HDMI_STATE, 0);
|
||||
OLED_ShowKVMState(HID_STATE, 0);
|
||||
OLED_ShowKVMState(ETH_STATE, 0);
|
||||
@@ -37,7 +37,7 @@ void kvm_init_pcie_ui(void)
|
||||
{
|
||||
OLED_Revolve();
|
||||
OLED_Showline_1();
|
||||
OLED_ShowSipeedLogo();
|
||||
OLED_ShowLogo();
|
||||
OLED_ShowKVMState(HDMI_STATE, 0);
|
||||
OLED_ShowKVMState(HID_STATE, 0);
|
||||
OLED_ShowKVMState(ETH_STATE, 0);
|
||||
@@ -412,8 +412,15 @@ void show_wifi_config_ip(void)
|
||||
{
|
||||
OLED_Clear();
|
||||
get_ip_addr(WiFi_IP);
|
||||
OLED_ShowString(0, 1, "Config IP:", 8);
|
||||
OLED_ShowString_AlignRight(63, 2, (char*)kvm_sys_state.wifi_addr, 4);
|
||||
OLED_ShowString(1, 0, "Config URL", 8);
|
||||
OLED_ShowString_AlignRight(63, 1, "----------------", 4);
|
||||
// OLED_ShowString_AlignRight(63, 2, (char*)kvm_sys_state.wifi_addr, 4);
|
||||
static char wifi_addr_with_path[30];
|
||||
static char wifi_addr_with_key[30];
|
||||
sprintf(wifi_addr_with_path, "%s/#/", kvm_sys_state.wifi_addr);
|
||||
sprintf(wifi_addr_with_key, "WIFI?P=%s", kvm_sys_state.wifi_ap_pass);
|
||||
OLED_ShowString_AlignRight(63, 2, wifi_addr_with_path, 4);
|
||||
OLED_ShowString_AlignRight(63, 3, wifi_addr_with_key, 4);
|
||||
}
|
||||
|
||||
void show_wifi_config_QR(void)
|
||||
@@ -421,8 +428,7 @@ void show_wifi_config_QR(void)
|
||||
static char cmd[70];
|
||||
OLED_Clear();
|
||||
get_ip_addr(WiFi_IP);
|
||||
printf("http://%s/#/wifi\n", kvm_sys_state.wifi_addr);
|
||||
sprintf(cmd, "http://%s/#/wifi", kvm_sys_state.wifi_addr);
|
||||
sprintf(cmd, "http://%s/#/WIFI?P=%s", kvm_sys_state.wifi_addr, kvm_sys_state.wifi_ap_pass);
|
||||
qrencode(cmd);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ void Production_testing_patch(void)
|
||||
void new_app_init(void)
|
||||
{
|
||||
// Update the necessary scripts
|
||||
system("rm -f /boot/logo.jpeg");
|
||||
system("cp -f /kvmapp/system/update-nanokvm.py /etc/kvm/");
|
||||
system("rm -f /etc/init.d/S02udisk");
|
||||
system("cp -f /kvmapp/system/init.d/S00kmod /etc/init.d/");
|
||||
@@ -66,6 +67,11 @@ void new_app_init(void)
|
||||
} else {
|
||||
system("rm -f /etc/init.d/S30wifi");
|
||||
}
|
||||
|
||||
// if exit /etc/init.d/S98tailscaled then cp -f /kvmapp/system/init.d/S98tailscaled /etc/init.d/
|
||||
if(access("/etc/init.d/S98tailscaled", F_OK) == 0){
|
||||
system("cp -f /kvmapp/system/init.d/S98tailscaled /etc/init.d/");
|
||||
}
|
||||
|
||||
// rmmod soph_saradc
|
||||
system("rmmod soph_saradc");
|
||||
|
||||
605
tools/logo_generator/logo_generator.py
Normal file
605
tools/logo_generator/logo_generator.py
Normal file
@@ -0,0 +1,605 @@
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
import sys
|
||||
import os
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Header, Footer, Button, Input, Label, Static
|
||||
from textual.containers import Container, Horizontal, Vertical, Center, Middle
|
||||
from textual.binding import Binding
|
||||
from textual import events
|
||||
from textual.message import Message
|
||||
|
||||
|
||||
# Language dictionary for i18n support
|
||||
LANGUAGES = {
|
||||
'zh': {
|
||||
'app_title': '🖼️ [ NanoKVM Logo 生成器 ]',
|
||||
'app_title_editor': '✏️ NanoKVM Logo 编辑器',
|
||||
'contrast_minus': '对比度-',
|
||||
'contrast_plus': '对比度+',
|
||||
'current_contrast': '当前对比度:{}',
|
||||
'click_hint': '💡 点击图片以修改像素',
|
||||
'btn_invert': '🔃 反转',
|
||||
'btn_reset': '🔄 重置',
|
||||
'btn_export': '💾 导出',
|
||||
'btn_quit': '❌ 退出',
|
||||
'help_text_with_image': '快捷键:↑↓ 调整±10 | ←→ 调整±1 | I 反转 | R 重置 | D 导出 | Q 退出',
|
||||
'help_text_editor': '快捷键:I 反转 | R 清空 | D 导出 | Q 退出',
|
||||
'status_blank_canvas': '空白画布 - 点击像素开始绘制',
|
||||
'status_loaded': '已加载:{}',
|
||||
'status_load_failed': '无法加载图片:{}',
|
||||
'status_contrast': '对比度:{}',
|
||||
'status_inverted': '已反转所有像素',
|
||||
'status_reset_image': '已重置为原始图片',
|
||||
'status_reset_canvas': '已清空画布',
|
||||
'status_export_error': '错误:没有可导出的数据',
|
||||
'status_exported': '✅ 已导出 logo 文件',
|
||||
'status_pixel_toggled': '切换像素 ({}, {})',
|
||||
'lang_select_title': '选择语言 / Select Language',
|
||||
'btn_chinese': '中文 (Chinese)',
|
||||
'btn_english': 'English',
|
||||
'binding_quit': '退出',
|
||||
'binding_export': '导出',
|
||||
'binding_reset': '重置',
|
||||
'binding_invert': '反转',
|
||||
'binding_threshold_up': '阈值 +10',
|
||||
'binding_threshold_down': '阈值 -10',
|
||||
'binding_threshold_up_small': '阈值 +1',
|
||||
'binding_threshold_down_small': '阈值 -1',
|
||||
},
|
||||
'en': {
|
||||
'app_title': '🖼️ [ NanoKVM Logo Generator ]',
|
||||
'app_title_editor': '✏️ NanoKVM Logo Editor',
|
||||
'contrast_minus': 'Contrast-',
|
||||
'contrast_plus': 'Contrast+',
|
||||
'current_contrast': 'Contrast: {}',
|
||||
'click_hint': '💡 Click image to toggle pixels',
|
||||
'btn_invert': '🔃 Invert',
|
||||
'btn_reset': '🔄 Reset',
|
||||
'btn_export': '💾 Export',
|
||||
'btn_quit': '❌ Quit',
|
||||
'help_text_with_image': 'Shortcuts: ↑↓ ±10 | ←→ ±1 | I Invert | R Reset | D Export | Q Quit',
|
||||
'help_text_editor': 'Shortcuts: I Invert | R Clear | D Export | Q Quit',
|
||||
'status_blank_canvas': 'Blank canvas - Click pixels to draw',
|
||||
'status_loaded': 'Loaded: {}',
|
||||
'status_load_failed': 'Failed to load image: {}',
|
||||
'status_contrast': 'Contrast: {}',
|
||||
'status_inverted': 'All pixels inverted',
|
||||
'status_reset_image': 'Reset to original image',
|
||||
'status_reset_canvas': 'Canvas cleared',
|
||||
'status_export_error': 'Error: No data to export',
|
||||
'status_exported': '✅ Exported logo file',
|
||||
'status_pixel_toggled': 'Toggled pixel ({}, {})',
|
||||
'lang_select_title': '选择语言 / Select Language',
|
||||
'btn_chinese': '中文 (Chinese)',
|
||||
'btn_english': 'English',
|
||||
'binding_quit': 'Quit',
|
||||
'binding_export': 'Export',
|
||||
'binding_reset': 'Reset',
|
||||
'binding_invert': 'Invert',
|
||||
'binding_threshold_up': 'Threshold +10',
|
||||
'binding_threshold_down': 'Threshold -10',
|
||||
'binding_threshold_up_small': 'Threshold +1',
|
||||
'binding_threshold_down_small': 'Threshold -1',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def reverse_byte(byte):
|
||||
"""Reverse the bits in a byte"""
|
||||
result = 0
|
||||
for i in range(8):
|
||||
if (byte >> i) & 1:
|
||||
result |= (1 << (7 - i))
|
||||
return result
|
||||
|
||||
|
||||
def process_image(image_path, threshold=128):
|
||||
"""
|
||||
Process image to 16x16 binary data
|
||||
|
||||
Args:
|
||||
image_path: Path to the image file
|
||||
threshold: Binarization threshold (0-255)
|
||||
|
||||
Returns:
|
||||
16x16 numpy array of binary values
|
||||
"""
|
||||
if not os.path.exists(image_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
img = Image.open(image_path).convert('L')
|
||||
except Exception as e:
|
||||
print(f"Error: Failed to open image - {e}")
|
||||
return None
|
||||
|
||||
# If not square, crop the center region
|
||||
if img.width != img.height:
|
||||
size = min(img.width, img.height)
|
||||
left = (img.width - size) // 2
|
||||
top = (img.height - size) // 2
|
||||
img = img.crop((left, top, left + size, top + size))
|
||||
|
||||
# Resize to 16x16 and binarize
|
||||
img = img.resize((16, 16), Image.Resampling.LANCZOS)
|
||||
binary = (np.array(img) < threshold).astype(np.uint8)
|
||||
|
||||
return binary
|
||||
|
||||
|
||||
def create_blank_image():
|
||||
"""Create a blank 16x16 image (all black)"""
|
||||
return np.zeros((16, 16), dtype=np.uint8)
|
||||
|
||||
|
||||
def binary_to_bytes(binary):
|
||||
"""Convert 16x16 binary data to 32 bytes"""
|
||||
data = []
|
||||
|
||||
for x in range(16):
|
||||
for page in range(2):
|
||||
byte = 0
|
||||
start_row = page * 8
|
||||
for bit in range(8):
|
||||
row = start_row + bit
|
||||
if row < 16 and binary[row][x] == 1:
|
||||
byte |= (1 << (7 - bit))
|
||||
data.append(reverse_byte(byte))
|
||||
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def binary_to_image(binary_data, size=64):
|
||||
"""
|
||||
Convert 16x16 binary data to 64x64 RGBA image
|
||||
|
||||
Args:
|
||||
binary_data: 16x16 numpy array of binary values
|
||||
size: Size to scale the image to (default 64)
|
||||
|
||||
Returns:
|
||||
PIL Image in RGBA mode
|
||||
"""
|
||||
# Create 16x16 RGBA image from binary data
|
||||
img_16 = Image.new('RGBA', (16, 16), (0, 0, 0, 255)) # Black for 1
|
||||
pixels = img_16.load()
|
||||
|
||||
for y in range(16):
|
||||
for x in range(16):
|
||||
if binary_data[y][x] == 1:
|
||||
pixels[x, y] = (255, 255, 255, 255) # White
|
||||
else:
|
||||
pixels[x, y] = (0, 0, 0, 255) # Black
|
||||
|
||||
# Scale to 64x64 using NEAREST for pixel-perfect scaling
|
||||
img_64 = img_16.resize((size, size), Image.Resampling.NEAREST)
|
||||
return img_64
|
||||
|
||||
|
||||
def image_to_ico(image_path=None, binary_data=None, output_path="logo.ico", size=64):
|
||||
"""
|
||||
Convert image or binary data to 64x64 32-bit ICO file
|
||||
|
||||
Args:
|
||||
image_path: Path to the source image (optional if binary_data is provided)
|
||||
binary_data: 16x16 numpy array of binary values (optional if image_path is provided)
|
||||
output_path: Output ICO file path
|
||||
size: Size to scale the icon to (default 64)
|
||||
"""
|
||||
try:
|
||||
if image_path and os.path.exists(image_path):
|
||||
img = Image.open(image_path).convert('RGBA')
|
||||
|
||||
# If not square, crop the center region
|
||||
if img.width != img.height:
|
||||
min_size = min(img.width, img.height)
|
||||
left = (img.width - min_size) // 2
|
||||
top = (img.height - min_size) // 2
|
||||
img = img.crop((left, top, left + min_size, top + min_size))
|
||||
|
||||
# Resize to 64x64 using LANCZOS for high quality
|
||||
img_64 = img.resize((size, size), Image.Resampling.LANCZOS)
|
||||
elif binary_data is not None:
|
||||
# Convert binary data to image and scale
|
||||
img_64 = binary_to_image(binary_data, size)
|
||||
else:
|
||||
print("Error: No image source provided")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: Failed to create ICO - {e}")
|
||||
return False
|
||||
|
||||
# Save as ICO with 32-bit color depth
|
||||
img_64.save(output_path, format='ICO', sizes=[(size, size)])
|
||||
return True
|
||||
|
||||
|
||||
class PixelGrid(Static):
|
||||
"""16x16 pixel grid component"""
|
||||
|
||||
class PixelClick(Message):
|
||||
def __init__(self, x: int, y: int):
|
||||
super().__init__()
|
||||
self.x = x
|
||||
self.y = y
|
||||
|
||||
DEFAULT_CSS = """
|
||||
PixelGrid {
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
background: $surface;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, binary_data=None, id=None):
|
||||
super().__init__(id=id)
|
||||
self.binary_data = binary_data if binary_data is not None else np.zeros((16, 16), dtype=np.uint8)
|
||||
self.can_focus = True
|
||||
|
||||
def update_data(self, binary_data):
|
||||
self.binary_data = binary_data
|
||||
self.refresh()
|
||||
|
||||
def render(self):
|
||||
"""Render the pixel grid"""
|
||||
lines = []
|
||||
# Top border
|
||||
lines.append("┌" + "─" * 32 + "┐")
|
||||
|
||||
for y in range(16):
|
||||
line = "│"
|
||||
for x in range(16):
|
||||
if self.binary_data[y][x] == 1:
|
||||
line += "██"
|
||||
else:
|
||||
line += " "
|
||||
line += "│"
|
||||
lines.append(line)
|
||||
|
||||
# Bottom border
|
||||
lines.append("└" + "─" * 32 + "┘")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def on_click(self, event: events.Click):
|
||||
"""Handle click event"""
|
||||
# Get coordinates relative to the component
|
||||
# Use event.offset to get coordinates relative to the component
|
||||
try:
|
||||
local_x = event.offset_x
|
||||
local_y = event.offset_y
|
||||
except AttributeError:
|
||||
# If offset attribute doesn't exist, use event coordinates
|
||||
local_x = event.x if hasattr(event, 'x') else 0
|
||||
local_y = event.y if hasattr(event, 'y') else 0
|
||||
|
||||
# Render format:
|
||||
# Row 0: ┌────────────────────────────────┐ (top border)
|
||||
# Rows 1-16: │██ ██ ... │ (pixel rows, 16 pixels per row, 2 chars per pixel)
|
||||
# Row 17: └────────────────────────────────┘ (bottom border)
|
||||
# Column positions: 0 is left border │, 1-32 is pixel area (2 chars per pixel), 33 is right border │
|
||||
|
||||
# Check if within pixel grid area (y from 1 to 16, since row 0 is top border)
|
||||
if local_y >= 1 and local_y <= 16:
|
||||
grid_y = local_y - 1
|
||||
# Check if x is within valid range (1 to 32, since there are borders on both sides)
|
||||
if local_x >= 1 and local_x <= 32:
|
||||
# Each pixel takes 2 chars, (1,2) is column 0, (3,4) is column 1, etc.
|
||||
grid_x = (local_x - 1) // 2
|
||||
if 0 <= grid_x < 16 and 0 <= grid_y < 16:
|
||||
self.post_message(self.PixelClick(grid_x, grid_y))
|
||||
|
||||
|
||||
class LanguageSelectApp(App):
|
||||
"""Language selection screen"""
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
#lang-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
#lang-title {
|
||||
text-align: center;
|
||||
text-style: bold;
|
||||
padding: 2 0;
|
||||
}
|
||||
|
||||
#lang-buttons {
|
||||
width: auto;
|
||||
height: auto;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
Button {
|
||||
margin: 1 2;
|
||||
min-width: 20;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.selected_language = 'zh' # Default to Chinese
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Container(id="lang-container"):
|
||||
yield Label("选择语言 / Select Language", id="lang-title")
|
||||
with Horizontal(id="lang-buttons"):
|
||||
yield Button("中文 (Chinese)", id="btn-chinese", variant="primary")
|
||||
yield Button("English", id="btn-english", variant="success")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed):
|
||||
if event.button.id == "btn-chinese":
|
||||
self.selected_language = 'zh'
|
||||
self.exit('zh')
|
||||
elif event.button.id == "btn-english":
|
||||
self.selected_language = 'en'
|
||||
self.exit('en')
|
||||
|
||||
|
||||
class LogoApp(App):
|
||||
"""Logo conversion TUI application"""
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
#main-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
#title {
|
||||
text-align: center;
|
||||
text-style: bold;
|
||||
padding: 1 0;
|
||||
}
|
||||
|
||||
#threshold-container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 1 2;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
#threshold-value {
|
||||
text-align: center;
|
||||
padding: 0 1;
|
||||
width: 20;
|
||||
}
|
||||
|
||||
#button-container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
content-align: center middle;
|
||||
padding: 1 0;
|
||||
}
|
||||
|
||||
Button {
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
#status {
|
||||
text-align: center;
|
||||
padding: 1 0;
|
||||
text-style: italic;
|
||||
}
|
||||
|
||||
#help-text {
|
||||
text-align: center;
|
||||
padding: 1 0;
|
||||
color: $text-muted;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("q", "quit", "Quit"),
|
||||
Binding("d", "export", "Export"),
|
||||
Binding("r", "reset", "Reset"),
|
||||
Binding("i", "invert", "Invert"),
|
||||
Binding("up", "threshold_up", "Threshold +10"),
|
||||
Binding("down", "threshold_down", "Threshold -10"),
|
||||
Binding("left", "threshold_down_small", "Threshold -1"),
|
||||
Binding("right", "threshold_up_small", "Threshold +1"),
|
||||
]
|
||||
|
||||
def __init__(self, image_path=None, language='zh'):
|
||||
super().__init__()
|
||||
self.image_path = image_path
|
||||
self.language = language
|
||||
self.lang = LANGUAGES[language]
|
||||
self.original_binary = None
|
||||
self.current_binary = None
|
||||
self.threshold = 128
|
||||
self.has_image = image_path is not None
|
||||
|
||||
# Update bindings with selected language
|
||||
self.BINDINGS = [
|
||||
Binding("q", "quit", self.lang['binding_quit']),
|
||||
Binding("d", "export", self.lang['binding_export']),
|
||||
Binding("r", "reset", self.lang['binding_reset']),
|
||||
Binding("i", "invert", self.lang['binding_invert']),
|
||||
Binding("up", "threshold_up", self.lang['binding_threshold_up']),
|
||||
Binding("down", "threshold_down", self.lang['binding_threshold_down']),
|
||||
Binding("left", "threshold_down_small", self.lang['binding_threshold_down_small']),
|
||||
Binding("right", "threshold_up_small", self.lang['binding_threshold_up_small']),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
with Container(id="main-container"):
|
||||
if self.has_image:
|
||||
yield Label(self.lang['app_title'], id="title")
|
||||
# Contrast control area: [Contrast-] Current value [Contrast+]
|
||||
with Horizontal(id="threshold-container"):
|
||||
yield Button(self.lang['contrast_minus'], id="threshold-minus-btn", variant="primary")
|
||||
yield Label(self.lang['current_contrast'].format(self.threshold), id="threshold-value-label")
|
||||
yield Button(self.lang['contrast_plus'], id="threshold-plus-btn", variant="primary")
|
||||
else:
|
||||
yield Label(self.lang['app_title_editor'], id="title")
|
||||
yield PixelGrid(id="pixel-grid")
|
||||
yield Label(self.lang['click_hint'], id="click-hint")
|
||||
# Action button area: Invert | Reset | Export | Quit
|
||||
with Horizontal(id="button-container"):
|
||||
yield Button(self.lang['btn_invert'], id="invert-btn", variant="default")
|
||||
yield Button(self.lang['btn_reset'], id="reset-btn", variant="warning")
|
||||
yield Button(self.lang['btn_export'], id="export-btn", variant="success")
|
||||
yield Button(self.lang['btn_quit'], id="quit-btn", variant="error")
|
||||
yield Label("", id="status")
|
||||
if self.has_image:
|
||||
yield Label(self.lang['help_text_with_image'], id="help-text")
|
||||
else:
|
||||
yield Label(self.lang['help_text_editor'], id="help-text")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self):
|
||||
"""When app starts"""
|
||||
self.pixel_grid = self.query_one("#pixel-grid", PixelGrid)
|
||||
self.threshold_value_label = self.query_one("#threshold-value-label", Label) if self.has_image else None
|
||||
self.status = self.query_one("#status", Label)
|
||||
|
||||
if self.has_image:
|
||||
self.load_image(self.image_path)
|
||||
else:
|
||||
self.current_binary = create_blank_image()
|
||||
self.pixel_grid.update_data(self.current_binary)
|
||||
self.status.update(self.lang['status_blank_canvas'])
|
||||
|
||||
def load_image(self, path):
|
||||
"""Load image"""
|
||||
self.original_binary = process_image(path, self.threshold)
|
||||
if self.original_binary is not None:
|
||||
self.current_binary = self.original_binary.copy()
|
||||
self.pixel_grid.update_data(self.current_binary)
|
||||
self.status.update(self.lang['status_loaded'].format(path))
|
||||
else:
|
||||
self.status.update(self.lang['status_load_failed'].format(path))
|
||||
|
||||
def update_threshold(self, new_value: int):
|
||||
"""Update threshold"""
|
||||
new_value = max(0, min(255, new_value))
|
||||
if new_value != self.threshold:
|
||||
self.threshold = new_value
|
||||
if self.threshold_value_label:
|
||||
self.threshold_value_label.update(self.lang['current_contrast'].format(self.threshold))
|
||||
if self.original_binary is not None:
|
||||
self.current_binary = process_image(self.image_path, self.threshold)
|
||||
self.pixel_grid.update_data(self.current_binary)
|
||||
self.status.update(self.lang['status_contrast'].format(self.threshold))
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed):
|
||||
"""Button press event"""
|
||||
button_id = event.button.id
|
||||
|
||||
if button_id == "invert-btn":
|
||||
self.action_invert()
|
||||
elif button_id == "reset-btn":
|
||||
self.action_reset()
|
||||
elif button_id == "export-btn":
|
||||
self.action_export()
|
||||
elif button_id == "quit-btn":
|
||||
self.action_quit()
|
||||
elif button_id == "threshold-plus-btn":
|
||||
self.action_threshold_up()
|
||||
elif button_id == "threshold-minus-btn":
|
||||
self.action_threshold_down()
|
||||
|
||||
def on_pixel_grid_pixel_click(self, event: PixelGrid.PixelClick):
|
||||
"""Pixel click event"""
|
||||
if self.current_binary is not None:
|
||||
# Toggle pixel value
|
||||
self.current_binary[event.y][event.x] = 1 - self.current_binary[event.y][event.x]
|
||||
self.pixel_grid.update_data(self.current_binary)
|
||||
self.status.update(self.lang['status_pixel_toggled'].format(event.x, event.y))
|
||||
|
||||
def action_invert(self):
|
||||
"""Invert all pixels"""
|
||||
if self.current_binary is not None:
|
||||
self.current_binary = 1 - self.current_binary
|
||||
self.pixel_grid.update_data(self.current_binary)
|
||||
self.status.update(self.lang['status_inverted'])
|
||||
|
||||
def action_reset(self):
|
||||
"""Reset to original image or clear"""
|
||||
if self.has_image and self.original_binary is not None:
|
||||
self.current_binary = self.original_binary.copy()
|
||||
self.pixel_grid.update_data(self.current_binary)
|
||||
self.status.update(self.lang['status_reset_image'])
|
||||
else:
|
||||
self.current_binary = create_blank_image()
|
||||
self.pixel_grid.update_data(self.current_binary)
|
||||
self.status.update(self.lang['status_reset_canvas'])
|
||||
|
||||
def action_export(self):
|
||||
"""Export logo.bin and logo.ico"""
|
||||
if self.current_binary is None:
|
||||
self.status.update(self.lang['status_export_error'])
|
||||
return
|
||||
|
||||
data = binary_to_bytes(self.current_binary)
|
||||
|
||||
with open("logo.bin", "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
# Export 64x64 32-bit ICO file
|
||||
if self.has_image and self.image_path:
|
||||
image_to_ico(self.image_path, "logo.ico", size=64)
|
||||
else:
|
||||
# Export from current canvas (16x16 -> 64x64)
|
||||
image_to_ico(binary_data=self.current_binary, output_path="logo.ico", size=64)
|
||||
|
||||
self.status.update(self.lang['status_exported'])
|
||||
|
||||
def action_threshold_up(self):
|
||||
"""Increase threshold by 10"""
|
||||
if self.has_image:
|
||||
self.update_threshold(self.threshold + 10)
|
||||
|
||||
def action_threshold_down(self):
|
||||
"""Decrease threshold by 10"""
|
||||
if self.has_image:
|
||||
self.update_threshold(self.threshold - 10)
|
||||
|
||||
def action_threshold_up_small(self):
|
||||
"""Increase threshold by 1"""
|
||||
if self.has_image:
|
||||
self.update_threshold(self.threshold + 1)
|
||||
|
||||
def action_threshold_down_small(self):
|
||||
"""Decrease threshold by 1"""
|
||||
if self.has_image:
|
||||
self.update_threshold(self.threshold - 1)
|
||||
|
||||
def action_quit(self):
|
||||
self.exit()
|
||||
|
||||
|
||||
def main():
|
||||
image_path = None
|
||||
|
||||
# First, show language selection screen
|
||||
lang_app = LanguageSelectApp()
|
||||
selected_language = lang_app.run()
|
||||
|
||||
if len(sys.argv) >= 2:
|
||||
image_path = sys.argv[1]
|
||||
if not os.path.exists(image_path):
|
||||
print(f"Error: File not found - {image_path}")
|
||||
return
|
||||
|
||||
app = LogoApp(image_path, language=selected_language)
|
||||
app.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
39
tools/logo_generator/readme.md
Normal file
39
tools/logo_generator/readme.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# NanoKVM Logo Generator
|
||||
|
||||
**Compatible Hardware:** NanoKVM-Cube, NanoKVM-PCIe
|
||||
|
||||
🛠️ **Tool Description:**
|
||||
This is a logo generator for the NanoKVM OLED display and Web. You can use it to create a `logo.bin` and `logo.ico` file and place them in the `/boot` directory of your NanoKVM system.
|
||||
|
||||
📋 **Instructions:**
|
||||
|
||||
1. **Install required libraries:**
|
||||
```bash
|
||||
pip install Pillow numpy textual
|
||||
```
|
||||
|
||||
2. **Prepare your logo image:**
|
||||
Make sure it's as square as possible for best results 🖼️
|
||||
|
||||
3. **Run the script:**
|
||||
```bash
|
||||
python logo_generator.py path/to/your_logo.png
|
||||
```
|
||||
|
||||
4. **Choose your language:**
|
||||
Select either English or Chinese
|
||||
|
||||
5. **Adjust the contrast:**
|
||||
Fine-tune the contrast based on the preview below until you're happy with how it looks ✨
|
||||
|
||||
6. **Fine-tune pixel by pixel:**
|
||||
Click on individual pixels to adjust details. If you prefer an inverted display, just click the invert button 🔄
|
||||
|
||||
7. **Export and deploy:**
|
||||
Export the `logo.bin` & `logo.ico`, then copy it to the `/boot` directory of your NanoKVM system. You can do this by:
|
||||
* Enabling SSH and using SCP to copy the file 💻
|
||||
* Or directly placing it in the boot partition of your TF card 💾
|
||||
|
||||
8. **Reboot and enjoy:**
|
||||
Make sure your NanoKVM application version is greater than 2.3.6 ✅
|
||||
Reboot your NanoKVM, and you should see your custom logo on the OLED display! 🎉
|
||||
@@ -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
@@ -31,12 +31,29 @@ export function getWiFi() {
|
||||
}
|
||||
|
||||
// connect wifi without auth (only available in wifi configuration mode)
|
||||
export function connectWifiNoAuth(ssid: string, password: string) {
|
||||
export function connectWifiNoAuth(ssid: string, password: string, apPassword?: string) {
|
||||
const data = {
|
||||
ssid,
|
||||
password
|
||||
};
|
||||
return http.post('/api/network/wifi', data);
|
||||
return http.post('/api/network/wifi', data, {
|
||||
headers: {
|
||||
'X-AP-Key': apPassword || ''
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// verify ap login
|
||||
export function verifyApLogin(apPassword: string) {
|
||||
return http.post(
|
||||
'/api/network/wifi/verify',
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
'X-AP-Key': apPassword || ''
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// connect wifi
|
||||
|
||||
@@ -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!',
|
||||
@@ -46,7 +48,14 @@ const en = {
|
||||
invalidMode:
|
||||
'The current mode does not support network setup. Please go to your device and enable Wi-Fi configuration mode.',
|
||||
confirmBtn: 'Ok',
|
||||
finishBtn: 'Finished'
|
||||
finishBtn: 'Finished',
|
||||
ap: {
|
||||
authTitle: 'Authentication Required',
|
||||
authDescription: 'Please enter the AP password to continue',
|
||||
authFailed: 'Invalid AP password',
|
||||
passPlaceholder: 'AP password',
|
||||
verifyBtn: 'Verify',
|
||||
}
|
||||
},
|
||||
screen: {
|
||||
scale: 'Scale',
|
||||
|
||||
@@ -11,16 +11,14 @@ const ja = {
|
||||
login: 'ログイン',
|
||||
placeholderUsername: 'ユーザー名を入力してください',
|
||||
placeholderPassword: 'パスワードを入力してください',
|
||||
placeholderPassword2: '再度パスワードを入力してください',
|
||||
placeholderPassword2: 'パスワードをもう一度入力してください',
|
||||
noEmptyUsername: 'ユーザー名は空にできません',
|
||||
noEmptyPassword: 'パスワードは空にできません',
|
||||
noAccount:
|
||||
'ユーザー情報の取得に失敗しました。ウェブページをリフレッシュするか、パスワードをリセットしてください。',
|
||||
invalidUser: '無効なユーザー名またはパスワード',
|
||||
error: '予期しないエラー',
|
||||
noAccount: 'ユーザー情報の取得に失敗しました。ページを更新してもう一度お試しいただくか、パスワードをリセットしてください。',
|
||||
invalidUser: 'ユーザー名またはパスワードが正しくありません',
|
||||
error: '不明なエラー',
|
||||
changePassword: 'パスワード変更',
|
||||
changePasswordDesc:
|
||||
'デバイスのセキュリティのために、ウェブログインのパスワードを変更してください。',
|
||||
changePasswordDesc: 'デバイスのセキュリティのために、パスワードを変更してください!',
|
||||
differentPassword: 'パスワードが一致しません',
|
||||
illegalUsername: 'ユーザー名に不正な文字が含まれています',
|
||||
illegalPassword: 'パスワードに不正な文字が含まれています',
|
||||
@@ -29,86 +27,147 @@ const ja = {
|
||||
cancel: 'キャンセル',
|
||||
loginButtonText: 'ログイン',
|
||||
tips: {
|
||||
reset1:
|
||||
'To reset the passwords, pressing and holding the BOOT button on the NanoKVM for 10 seconds.',
|
||||
reset2: 'For detailed steps, please consult this document:',
|
||||
reset3: 'Web default account:',
|
||||
reset4: 'SSH default account:',
|
||||
change1: 'Please note that this action will change the following passwords:',
|
||||
change2: 'Web login password',
|
||||
change3: 'System root password (SSH login password)',
|
||||
change4: 'To reset the passwords, press and hold the BOOT button on the NanoKVM.'
|
||||
reset1: 'パスワードをリセットするには、NanoKVM の BOOT ボタンを 10 秒間押し続けます。',
|
||||
reset2: '詳細な手順については、次のドキュメントを参照してください:',
|
||||
reset3: 'ウェブデフォルトアカウント:',
|
||||
reset4: 'SSH デフォルトアカウント:',
|
||||
change1: 'この操作により、以下のパスワードも更新されることに注意してください:',
|
||||
change2: 'ウェブログインパスワード',
|
||||
change3: 'システム root パスワード(SSH ログインパスワード)',
|
||||
change4: 'パスワードを忘れた場合は、NanoKVM の BOOT ボタンを長押ししてパスワードをリセットする必要があります。'
|
||||
}
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'Configure Wi-Fi for NanoKVM',
|
||||
success: 'Please check the network status of NanoKVM and visit the new IP address.',
|
||||
failed: 'Operation failed, please try again.',
|
||||
confirmBtn: 'Ok',
|
||||
finishBtn: 'Finished'
|
||||
description: 'NanoKVM の Wi-Fi を設定する',
|
||||
success: 'NanoKVM のネットワークステータスを確認するにはデバイスにアクセスしてください。',
|
||||
failed: '操作に失敗しました。もう一度お試しください。',
|
||||
invalidMode: '現在のモードではネットワーク設定はサポートされていません。デバイスで Wi-Fi 設定モードを有効にしてください。',
|
||||
confirmBtn: 'OK',
|
||||
finishBtn: '完了'
|
||||
},
|
||||
screen: {
|
||||
scale: '倍率',
|
||||
title: '画面',
|
||||
video: 'ビデオモード',
|
||||
videoDirectTips: 'このモードを使用するには「設定 - デバイス」で HTTPS を有効にしてください',
|
||||
resolution: '解像度',
|
||||
auto: '自動',
|
||||
autoTips:
|
||||
'特定の解像度で画面のティアリングやマウスのオフセットが発生する可能性があります。リモートホストの解像度を調整するか、自動モードを無効にすることを検討してください。',
|
||||
fps: 'FPS',
|
||||
'特定の解像度で画面のちらつきやマウスカーソルのずれが発生する場合があります。リモートホストの解像度を調整するか、自動モードを無効にしてください。',
|
||||
fps: 'フレームレート',
|
||||
customizeFps: 'カスタマイズ',
|
||||
quality: '品質',
|
||||
quality: '画質',
|
||||
qualityLossless: 'ロスレス',
|
||||
qualityHigh: '高い',
|
||||
qualityMedium: '中くらい',
|
||||
qualityLow: '低い',
|
||||
frameDetect: 'フレーム検出',
|
||||
frameDetectTip:
|
||||
'フレーム間の差を計算します。リモートホストの画面で変更が検出されない場合、ビデオストリームの送信を停止します。',
|
||||
resetHdmi: 'Reset HDMI'
|
||||
qualityHigh: '高',
|
||||
qualityMedium: '中',
|
||||
qualityLow: '低',
|
||||
frameDetect: 'フレーム差分検出',
|
||||
frameDetectTip: 'フレーム間の差異を計算し、リモートホストの画面が変更されない場合はビデオストリームの送信を停止します',
|
||||
resetHdmi: 'HDMI をリセット'
|
||||
},
|
||||
keyboard: {
|
||||
title: 'キーボード',
|
||||
paste: '貼り付け',
|
||||
tips: '標準的なキーボードの文字と記号のみがサポートされています',
|
||||
tips: '標準的なキーボードの文字と記号のみをサポートしています',
|
||||
placeholder: '入力してください',
|
||||
submit: '送信',
|
||||
virtual: 'キーボード',
|
||||
ctrlaltdel: 'Ctrl+Alt+Del'
|
||||
virtual: '仮想キーボード',
|
||||
shortcut: {
|
||||
title: 'ショートカット',
|
||||
custom: 'カスタム',
|
||||
capture: 'ショートカットをキャプチャするにはここをクリックしてください',
|
||||
clear: 'クリア',
|
||||
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: 'REC',
|
||||
activate: 'キーを有効にする',
|
||||
input: 'ショートカットキーを押してください...'
|
||||
}
|
||||
}
|
||||
},
|
||||
mouse: {
|
||||
default: 'デフォルトカーソル',
|
||||
title: 'マウス',
|
||||
cursor: 'ポインター形状',
|
||||
default: 'デフォルトポインター',
|
||||
pointer: 'ポインターカーソル',
|
||||
cell: 'セルカーソル',
|
||||
cell: 'セルポインター',
|
||||
text: 'テキストカーソル',
|
||||
grab: 'つかむカーソル',
|
||||
hide: 'カーソルを非表示',
|
||||
grab: 'つかむポインター',
|
||||
hide: 'ポインターを非表示',
|
||||
mode: 'マウスモード',
|
||||
absolute: '絶対モード',
|
||||
relative: '相対モード',
|
||||
requestPointer:
|
||||
'相対モードを使用中です。デスクトップをクリックしてマウスポインタを取得してください。',
|
||||
resetHid: 'HIDをリセット'
|
||||
direction: 'ホイール方向',
|
||||
scrollUp: '上',
|
||||
scrollDown: '下',
|
||||
speed: 'ホイール速度',
|
||||
fast: '速い',
|
||||
slow: '遅い',
|
||||
requestPointer: '相対モードを使用中です。マウスポインターを取得するには、デスクトップをクリックしてください。',
|
||||
resetHid: 'HID をリセット',
|
||||
hidOnly: {
|
||||
title: 'HID-Only モード',
|
||||
desc: '使用中にマウスとキーボードが反応しなくなり、HID をリセットしても効果がない場合は、NanoKVM とデバイス間の互換性に問題がある可能性があります。互換性を向上させるために、HID-Only モードを有効にすることをお勧めします。',
|
||||
tip1: 'このモードを有効にすると仮想 USB ドライブと仮想ネットワークアダプターが無効になります。',
|
||||
tip2: 'このモードではイメージマウント機能は使用できません。',
|
||||
tip3: 'モードを切り替えると、NanoKVM は自動的に再起動します。',
|
||||
enable: 'HID-Only モードを有効化',
|
||||
disable: 'HID-Only モードを無効化'
|
||||
}
|
||||
},
|
||||
image: {
|
||||
title: 'イメージ',
|
||||
loading: '読み込み中...',
|
||||
empty: '見つかりませんでした',
|
||||
loading: '読み込み中',
|
||||
empty: 'イメージファイルがありません',
|
||||
mountMode: 'マウントモード',
|
||||
mountFailed: 'マウントに失敗しました',
|
||||
mountDesc:
|
||||
'一部のシステムでは、イメージをマウントする前にリモートホストで仮想ディスクをアンマウントする必要があります。',
|
||||
mountDesc: '一部のシステムでは、イメージをマウントする前にリモートホストで仮想ディスクをアンマウントする必要があります。',
|
||||
unmountFailed: 'アンマウントに失敗しました',
|
||||
unmountDesc: '一部のシステムでは、イメージをアンマウントする前にリモートホストから手動で取り出す必要があります。',
|
||||
refresh: 'イメージリストを更新',
|
||||
attention: '注意',
|
||||
deleteConfirm: 'このイメージを削除してもよろしいですか?',
|
||||
okBtn: 'はい',
|
||||
cancelBtn: 'いいえ',
|
||||
tips: {
|
||||
title: 'アップロード方法',
|
||||
usb1: 'NanoKVMをUSB経由でコンピュータに接続します。',
|
||||
usb2: '仮想ディスクがマウントされていることを確認します(設定 - 仮想ディスク)。',
|
||||
usb1: 'NanoKVM を USB 経由でコンピュータに接続します;',
|
||||
usb2: '仮想ディスクがマウントされていることを確認します(設定 - 仮想ディスク);',
|
||||
usb3: 'コンピュータ上で仮想ディスクを開き、イメージファイルを仮想ディスクのルートディレクトリにコピーします。',
|
||||
scp1: 'NanoKVMとコンピュータが同じローカルネットワークに接続されていることを確認します。',
|
||||
scp2: 'コンピュータのターミナルを開き、SCPコマンドを使用してイメージファイルをNanoKVMの/dataディレクトリにアップロードします。',
|
||||
scp3: '例: scp あなたのイメージパス root@あなたのナノKVMのIP:/data',
|
||||
tfCard: 'TFカード',
|
||||
tf1: 'この方法はLinuxシステムでサポートされています',
|
||||
tf2: 'NanoKVMからTFカードを取り出します(フルバージョンの場合、まずケースを分解してください)。',
|
||||
tf3: 'TFカードをカードリーダーに挿入し、コンピュータに接続します。',
|
||||
tf4: 'TFカードの/dataディレクトリにイメージファイルをコピーします。',
|
||||
tf5: 'TFカードをNanoKVMに挿入します。'
|
||||
scp1: 'NanoKVM とコンピュータが同じローカルエリアネットワークに接続されていることを確認します;',
|
||||
scp2: 'コンピュータでターミナルを開き、SCP コマンドを使用してイメージファイルを NanoKVM の /data ディレクトリにアップロードします。',
|
||||
scp3: '例:scp your-image-path root@your-nanokvm-ip:/data',
|
||||
tfCard: 'TF カード',
|
||||
tf1: 'この方法は Linux システムでサポートされています',
|
||||
tf2: 'NanoKVM から TF カードを取り出します(フルバージョンでは、まずケースを分解してください);',
|
||||
tf3: 'TF カードをカードリーダーに挿入してコンピュータに接続します;',
|
||||
tf4: 'コンピューターから TF カードの /data ディレクトリにイメージファイルをコピーします;',
|
||||
tf5: 'TF カードを NanoKVM に挿入します。'
|
||||
}
|
||||
},
|
||||
script: {
|
||||
@@ -126,7 +185,7 @@ const ja = {
|
||||
},
|
||||
terminal: {
|
||||
title: 'ターミナル',
|
||||
nanokvm: 'NanoKVMターミナル',
|
||||
nanokvm: 'NanoKVM ターミナル',
|
||||
serial: 'シリアルポートターミナル',
|
||||
serialPort: 'シリアルポート',
|
||||
serialPortPlaceholder: 'シリアルポートを入力してください',
|
||||
@@ -137,115 +196,225 @@ const ja = {
|
||||
title: 'Wake-on-LAN',
|
||||
sending: 'コマンドを送信中...',
|
||||
sent: 'コマンドを送信しました',
|
||||
input: 'MACを入力してください',
|
||||
input: 'MAC アドレスを入力してください',
|
||||
ok: 'OK'
|
||||
},
|
||||
download: {
|
||||
title: 'イメージダウンローダー',
|
||||
input: 'イメージのダウンロードアドレスを入力してください',
|
||||
ok: 'OK',
|
||||
disabled: '/data パーティションは読み取り専用であり、イメージのダウンロードには使用できません'
|
||||
},
|
||||
power: {
|
||||
title: '電源',
|
||||
showConfirm: '確認メッセージ',
|
||||
showConfirmTip: '電源操作の確認メッセージを表示する',
|
||||
reset: 'リセット',
|
||||
power: '電源',
|
||||
powerShort: '電源(短いクリック)',
|
||||
powerLong: '電源(長いクリック)'
|
||||
powerShort: '電源(クリック)',
|
||||
powerLong: '電源(長押し)',
|
||||
resetConfirm: '再起動を実行しますか?',
|
||||
powerConfirm: '電源操作を実行しますか?',
|
||||
okBtn: 'はい',
|
||||
cancelBtn: 'いいえ'
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
title: '設定',
|
||||
about: {
|
||||
title: 'NanoKVMについて',
|
||||
title: 'NanoKVM について',
|
||||
information: '情報',
|
||||
ip: 'IP',
|
||||
mdns: 'mDNS',
|
||||
application: 'アプリケーションバージョン',
|
||||
applicationTip: 'NanoKVM web application version',
|
||||
applicationTip: 'NanoKVM ウェブアプリケーションバージョン',
|
||||
image: 'イメージバージョン',
|
||||
imageTip: 'NanoKVM system image version',
|
||||
firmware: 'ファームウェアバージョン',
|
||||
imageTip: 'NanoKVM システムイメージバージョン',
|
||||
deviceKey: 'デバイスキー',
|
||||
community: 'コミュニティ'
|
||||
community: 'コミュニティ',
|
||||
hostname: 'ホスト名',
|
||||
hostnameUpdated: 'ホスト名は正常に変更され、再起動後に有効になります'
|
||||
},
|
||||
appearance: {
|
||||
title: 'Appearance',
|
||||
display: 'Display',
|
||||
language: 'Language',
|
||||
menuBar: 'Menu Bar',
|
||||
menuBarDesc: 'Display icons in the menu bar'
|
||||
title: '外観',
|
||||
display: '表示',
|
||||
language: '言語',
|
||||
languageDesc: 'インターフェース言語の選択',
|
||||
webTitle: 'ウェブページタイトル',
|
||||
webTitleDesc: 'ウェブページタイトルのカスタマイズ',
|
||||
menuBar: {
|
||||
title: 'メニューバー',
|
||||
mode: '表示モード',
|
||||
modeDesc: 'メニューバーの画面表示方法',
|
||||
modeOff: '閉じる',
|
||||
modeAuto: '自動非表示',
|
||||
modeAlways: '常に表示',
|
||||
icons: 'メニューアイコン',
|
||||
iconsDesc: 'メニューバーでのサブメニューアイコンの表示'
|
||||
}
|
||||
},
|
||||
device: {
|
||||
title: 'Device',
|
||||
title: 'デバイス',
|
||||
oled: {
|
||||
title: 'OLED',
|
||||
description: 'OLED screen automatically sleep',
|
||||
0: 'Never',
|
||||
15: '15 sec',
|
||||
30: '30 sec',
|
||||
60: '1 min',
|
||||
180: '3 min',
|
||||
300: '5 min',
|
||||
600: '10 min',
|
||||
1800: '30 min',
|
||||
3600: '1 hour'
|
||||
description: 'OLED 画面の自動スリープ時間',
|
||||
0: '無効',
|
||||
15: '15秒',
|
||||
30: '30秒',
|
||||
60: '1分',
|
||||
180: '3分',
|
||||
300: '5分',
|
||||
600: '10分',
|
||||
1800: '30分',
|
||||
3600: '1時間'
|
||||
},
|
||||
wifi: {
|
||||
title: 'Wi-Fi',
|
||||
description: 'Configure Wi-Fi',
|
||||
setBtn: 'Config'
|
||||
description: 'Wi-Fi 設定',
|
||||
apMode: 'AP モードが有効になりました。QR コードをスキャンして Wi-Fi に接続してください。',
|
||||
connect: 'Wi-Fi に接続',
|
||||
connectDesc1: 'SSID とパスワードを入力してください',
|
||||
connectDesc2: 'このネットワークに接続するためのパスワードを入力してください',
|
||||
disconnect: 'このネットワーク接続を切断しますか?',
|
||||
failed: '接続に失敗しました。もう一度お試しください。',
|
||||
ssid: 'SSID',
|
||||
password: 'パスワード',
|
||||
joinBtn: '接続',
|
||||
confirmBtn: 'OK',
|
||||
cancelBtn: 'キャンセル'
|
||||
},
|
||||
disk: 'Virtual Disk',
|
||||
diskDesc: 'Mount virtual U-disk on the remote host',
|
||||
network: 'Virtual Network',
|
||||
networkDesc: 'Mount virtual network card on the remote host'
|
||||
ssh: {
|
||||
description: 'SSH リモートアクセスを有効にする',
|
||||
tip: '使用する前に必ず強力なパスワードを設定してください(アカウント - パスワードの変更)'
|
||||
},
|
||||
tls: {
|
||||
description: 'HTTPS プロトコルを有効にする',
|
||||
tip: '注意:HTTPS を使用すると、特に MJPEG ビデオモードで遅延が増加する可能性があります。'
|
||||
},
|
||||
advanced: '詳細設定',
|
||||
swap: {
|
||||
disable: '無効',
|
||||
description: 'スワップファイルのサイズを設定する',
|
||||
tip: 'この機能を有効にすると、SD カードの寿命が短くなる可能性があります!'
|
||||
},
|
||||
mouseJiggler: {
|
||||
title: 'マウスジグラー',
|
||||
description: 'リモートホストの休止を防ぐ',
|
||||
disable: '閉じる',
|
||||
absolute: '絶対モード',
|
||||
relative: '相対モード'
|
||||
},
|
||||
mdns: {
|
||||
description: 'mDNS 検出サービスを有効にする',
|
||||
tip: 'この機能を使用していない場合は、オフにすることをお勧めします'
|
||||
},
|
||||
hdmi: {
|
||||
description: 'HDMI/モニター 出力機能を有効にする'
|
||||
},
|
||||
autostart: {
|
||||
title: '自動起動スクリプト設定',
|
||||
description: 'NanoKVM の起動時に自動的に実行されるスクリプトファイルを管理します',
|
||||
new: '新しいスクリプトを作成する',
|
||||
deleteConfirm: 'このファイルを削除してもよろしいですか?',
|
||||
yes: 'はい',
|
||||
no: 'いいえ',
|
||||
scriptName: '自動起動スクリプト名',
|
||||
scriptContent: '自動起動スクリプト内容',
|
||||
settings: '設定'
|
||||
},
|
||||
hidOnly: 'HID-Only モード',
|
||||
hidOnlyDesc: 'このモードでは仮想デバイスはマウントされなくなり、基本的な HID 制御機能のみが保持されます。',
|
||||
disk: '仮想ディスク',
|
||||
diskDesc: 'リモートホストに仮想 USB ドライブをマウントする',
|
||||
network: '仮想ネットワークカード',
|
||||
networkDesc: 'リモートホストに仮想ネットワークカードをマウントする',
|
||||
okBtn: 'はい',
|
||||
cancelBtn: 'いいえ'
|
||||
},
|
||||
tailscale: {
|
||||
title: 'Tailscale',
|
||||
memory: {
|
||||
title: 'Memory optimization',
|
||||
tip: "When memory usage exceeds the limit, garbage collection is performed more aggressively to attempt to free up memory. it's recommended to set to 50MB if using Tailscale. A Tailscale restart is required for the change to take effect.",
|
||||
disable: 'Disable'
|
||||
title: 'メモリ最適化',
|
||||
tip: "メモリ使用量が上限を超えると、メモリ解放のためにより積極的にガベージコレクションが実行されます。Tailscale を使用する場合は 50MB に設定することをお勧めします。この設定を有効にするには Tailscale を再起動する必要があります。",
|
||||
},
|
||||
restart: 'Are you sure to restart Tailscale?',
|
||||
stop: 'Are you sure to stop Tailscale?',
|
||||
stopDesc: 'Log out Tailscale and disable its automatic startup on boot.',
|
||||
swap: {
|
||||
title: 'スワップメモリ',
|
||||
tip: 'メモリ最適化を有効にしても問題が解決しない場合は、スワップメモリを有効にしてみてください。有効にするとスワップファイルが 256MB に設定されます。このサイズは「設定 - デバイス」で変更できます。'
|
||||
},
|
||||
restart: 'Tailscale を再起動しますか?',
|
||||
stop: 'Tailscale を停止しますか?',
|
||||
stopDesc: 'Tailscale からログアウトし、起動時の自動実行を無効にします。',
|
||||
loading: '読み込み中...',
|
||||
notInstall: 'Tailscaleが見つかりません!インストールしてください。',
|
||||
notInstall: 'Tailscale が見つかりません。インストールしてください。',
|
||||
install: 'インストール',
|
||||
installing: 'インストール中',
|
||||
failed: 'インストールに失敗しました',
|
||||
retry: 'ページをリフレッシュして再試行してください。または手動でインストールしてください',
|
||||
download: 'インストールパッケージをダウンロードして',
|
||||
package: '解凍してください',
|
||||
upTailscale: 'tailscaleをNanoKVMのディレクトリ/usr/bin/にアップロードしてください',
|
||||
upTailscaled: 'tailscaledをNanoKVMのディレクトリ/usr/sbin/にアップロードしてください',
|
||||
refresh: '現在のページをリフレッシュします',
|
||||
notLogin:
|
||||
'デバイスはまだバインドされていません。ログインしてこのデバイスをアカウントにバインドしてください。',
|
||||
urlPeriod: 'このURLは10分間有効です',
|
||||
retry: 'ページを更新してもう一度お試しいただくか、手動でインストールしてください',
|
||||
download: 'ダウンロードして',
|
||||
package: 'インストールパッケージを',
|
||||
unzip: '解凍してください',
|
||||
upTailscale: 'tailscale ファイルを NanoKVM の /usr/bin ディレクトリにアップロードします',
|
||||
upTailscaled: 'tailscaled ファイルを NanoKVM の /usr/sbin ディレクトリにアップロードします',
|
||||
refresh: 'ページを更新します',
|
||||
notRunning: 'Tailscale はまだ実行されていません。起動操作を実行してください',
|
||||
run: '起動',
|
||||
notLogin: 'このデバイスはまだバインドされていません。ログインしてデバイスをアカウントにバインドしてください。',
|
||||
urlPeriod: 'この URL は 10 分間有効です',
|
||||
login: 'ログイン',
|
||||
loginSuccess: 'ログイン成功',
|
||||
enable: 'Tailscaleを有効化',
|
||||
enable: 'Tailscale を有効化',
|
||||
deviceName: 'デバイス名',
|
||||
deviceIP: 'デバイスIP',
|
||||
deviceIP: 'デバイスアドレス',
|
||||
account: 'アカウント',
|
||||
logout: 'ログアウト',
|
||||
logout2: 'ログアウトしてもよろしいですか?',
|
||||
okBtn: 'Yes',
|
||||
cancelBtn: 'No'
|
||||
logoutDesc: 'ログアウトしてもよろしいですか?',
|
||||
uninstall: 'Tailscale をアンインストール',
|
||||
uninstallDesc: 'Tailscale をアンインストールしてもよろしいですか?',
|
||||
reboot: '再起動',
|
||||
rebootDesc: 'NanoKVM を再起動してもよろしいですか?',
|
||||
okBtn: 'はい',
|
||||
cancelBtn: 'いいえ'
|
||||
},
|
||||
update: {
|
||||
title: 'アップデートの確認',
|
||||
queryFailed: 'バージョンの取得に失敗しました',
|
||||
updateFailed: '更新に失敗しました。再試行してください。',
|
||||
isLatest: '最新のバージョンを既に持っています。',
|
||||
available: 'アップデートが利用可能です。更新してもよろしいですか?',
|
||||
updating: '更新を開始しました。お待ちください...',
|
||||
confirm: '確認',
|
||||
cancel: 'キャンセル'
|
||||
title: 'アップデート',
|
||||
queryFailed: 'バージョン番号の取得に失敗しました',
|
||||
updateFailed: 'アップデートに失敗しました。もう一度お試しください。',
|
||||
isLatest: 'すでに最新バージョンです。',
|
||||
available: '新しいバージョンが利用可能です。アップデートしてもよろしいですか?',
|
||||
updating: 'アップデート中、お待ちください...',
|
||||
confirm: 'はい',
|
||||
cancel: 'いいえ',
|
||||
preview: 'プレビューアップデート',
|
||||
previewDesc: '新機能や改善をいち早く体験する',
|
||||
previewTip: 'プレビューアップデートには不安定な部分や不完全な機能が含まれる場合があります!',
|
||||
offline: {
|
||||
title: 'オフラインアップデート',
|
||||
desc: 'ローカルインストールパッケージでアップデートする',
|
||||
upload: 'アップロード',
|
||||
invalidName: 'ファイル名の形式が正しくありません。GitHub リリースページにアクセスしてインストールパッケージをダウンロードしてください。',
|
||||
updateFailed: 'アップデートに失敗しました。もう一度お試しください。'
|
||||
}
|
||||
},
|
||||
account: {
|
||||
title: 'Account',
|
||||
webAccount: 'Web Account Name',
|
||||
password: 'Password',
|
||||
updateBtn: 'Update',
|
||||
logoutBtn: 'Logout'
|
||||
title: 'アカウント',
|
||||
webAccount: 'ウェブアカウント名',
|
||||
password: 'パスワード',
|
||||
updateBtn: '変更',
|
||||
logoutBtn: 'ログアウト',
|
||||
logoutDesc: 'ログアウトしてもよろしいですか?',
|
||||
confirm: 'はい',
|
||||
cancel: 'いいえ'
|
||||
}
|
||||
},
|
||||
error: {
|
||||
title: 'エラーが発生しました',
|
||||
refresh: '更新'
|
||||
},
|
||||
fullscreen: {
|
||||
toggle: '全画面表示切り替え'
|
||||
},
|
||||
menu: {
|
||||
collapse: 'メニューを折りたたむ',
|
||||
expand: 'メニューを展開する'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,6 +16,8 @@ const zh = {
|
||||
noEmptyPassword: '密码不能为空',
|
||||
noAccount: '获取用户信息失败,请刷新重试或重置密码',
|
||||
invalidUser: '用户名或密码错误',
|
||||
locked: '登录太频繁,请稍后再试',
|
||||
globalLocked: '系统防爆破保护中,请稍后再试',
|
||||
error: '未知错误',
|
||||
changePassword: '修改密码',
|
||||
changePasswordDesc: '为了您的设备安全,请修改密码!',
|
||||
@@ -44,7 +46,14 @@ const zh = {
|
||||
failed: '操作失败,请重试。',
|
||||
invalidMode: '当前模式不支持配置网络。请先前往设备启用 Wi-Fi 配置模式。',
|
||||
confirmBtn: '确定',
|
||||
finishBtn: '完成'
|
||||
finishBtn: '完成',
|
||||
ap: {
|
||||
authTitle: '身份验证',
|
||||
authDescription: '请输入 AP 密码以继续',
|
||||
authFailed: '密码错误',
|
||||
passPlaceholder: 'AP 密码',
|
||||
verifyBtn: '验证',
|
||||
}
|
||||
},
|
||||
screen: {
|
||||
scale: '缩放',
|
||||
|
||||
@@ -58,11 +58,12 @@ class Http {
|
||||
});
|
||||
}
|
||||
|
||||
public post(url: string, data?: any): Promise<Response> {
|
||||
public post(url: string, data?: any, config?: AxiosRequestConfig): Promise<Response> {
|
||||
return this.instance.request({
|
||||
method: 'post',
|
||||
url,
|
||||
data
|
||||
data,
|
||||
...config
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -46,8 +46,10 @@ export const VirtualKeyboard = () => {
|
||||
const languages = [
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'French' },
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'ru', label: 'Russian' }
|
||||
{ value: 'de', label: 'German' },
|
||||
{ value: 'ru', label: 'Russian' },
|
||||
{ value: 'ko', label: 'Korean' },
|
||||
{ value: 'ja', label: 'Japanese' }
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
@@ -67,7 +69,9 @@ export const VirtualKeyboard = () => {
|
||||
['en', 'default'],
|
||||
['ru', 'rus'],
|
||||
['de', 'qwertz'],
|
||||
['fr', 'azerty']
|
||||
['fr', 'azerty'],
|
||||
['ko', 'ko'],
|
||||
['ja', 'ja']
|
||||
]);
|
||||
|
||||
if (keyboardLanguage === 'en' && keyboardSystem === 'mac') {
|
||||
@@ -141,6 +145,16 @@ export const VirtualKeyboard = () => {
|
||||
return getKeycode(base);
|
||||
}
|
||||
|
||||
if (keyboardLanguage === 'ko' && key.endsWith('_ko')) {
|
||||
const base = key.replace('_ko', '');
|
||||
return getKeycode(base);
|
||||
}
|
||||
|
||||
if (keyboardLanguage === 'ja' && key.endsWith('_ja')) {
|
||||
const base = key.replace('_ja', '');
|
||||
return getKeycode(base);
|
||||
}
|
||||
|
||||
const specialKey = specialKeyMap.get(key);
|
||||
if (specialKey) {
|
||||
return getKeycode(specialKey);
|
||||
|
||||
@@ -44,6 +44,22 @@ export const keyboardOptions = {
|
||||
'{capslock} KeyQ_azerty KeyS_azerty KeyD_azerty KeyF_azerty KeyG_azerty KeyH_azerty KeyJ_azerty KeyK_azerty KeyL_azerty Semicolon_azerty Quote_azerty {enter}',
|
||||
'{shiftleft} KeyW_azerty KeyX_azerty KeyC_azerty KeyV_azerty KeyB_azerty KeyN_azerty KeyM_azerty Comma_azerty Period_azerty Slash_azerty {shiftright}',
|
||||
'{controlleft} {winleft} {altleft} {space} {altright} {winright} {menu} {controlright}'
|
||||
],
|
||||
ko: [
|
||||
'{escape} F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
|
||||
'Backquote_ko Digit1_ko Digit2_ko Digit3_ko Digit4_ko Digit5_ko Digit6_ko Digit7_ko Digit8_ko Digit9_ko Digit0_ko Minus_ko Equal_ko {backspace}',
|
||||
'{tab} KeyQ_ko KeyW_ko KeyE_ko KeyR_ko KeyT_ko KeyY_ko KeyU_ko KeyI_ko KeyO_ko KeyP_ko BracketLeft_ko BracketRight_ko Backslash_ko',
|
||||
'{capslock} KeyA_ko KeyS_ko KeyD_ko KeyF_ko KeyG_ko KeyH_ko KeyJ_ko KeyK_ko KeyL_ko Semicolon_ko Quote_ko {enter}',
|
||||
'{shiftleft} KeyZ_ko KeyX_ko KeyC_ko KeyV_ko KeyB_ko KeyN_ko KeyM_ko Comma_ko Period_ko Slash_ko {shiftright}',
|
||||
'{controlleft} {winleft} {altleft} {space} {altright} {winright} {menu} {controlright}'
|
||||
],
|
||||
ja: [
|
||||
'{escape} F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
|
||||
'Backquote_ja Digit1_ja Digit2_ja Digit3_ja Digit4_ja Digit5_ja Digit6_ja Digit7_ja Digit8_ja Digit9_ja Digit0_ja Minus_ja Equal_ja {backspace}',
|
||||
'{tab} KeyQ_ja KeyW_ja KeyE_ja KeyR_ja KeyT_ja KeyY_ja KeyU_ja KeyI_ja KeyO_ja KeyP_ja BracketLeft_ja BracketRight_ja Backslash_ja',
|
||||
'{capslock} KeyA_ja KeyS_ja KeyD_ja KeyF_ja KeyG_ja KeyH_ja KeyJ_ja KeyK_ja KeyL_ja Semicolon_ja Quote_ja {enter}',
|
||||
'{shiftleft} KeyZ_ja KeyX_ja KeyC_ja KeyV_ja KeyB_ja KeyN_ja KeyM_ja Comma_ja Period_ja Slash_ja {shiftright}',
|
||||
'{controlleft} {winleft} {altleft} {space} {altright} {winright} {menu} {controlright}'
|
||||
]
|
||||
},
|
||||
display: {
|
||||
@@ -186,6 +202,110 @@ export const keyboardOptions = {
|
||||
RusPeriod: 'Ю',
|
||||
RusSlash: ',<br/>.',
|
||||
|
||||
// Korean (2-set) display
|
||||
Backquote_ko: '`',
|
||||
Digit1_ko: '1',
|
||||
Digit2_ko: '2',
|
||||
Digit3_ko: '3',
|
||||
Digit4_ko: '4',
|
||||
Digit5_ko: '5',
|
||||
Digit6_ko: '6',
|
||||
Digit7_ko: '7',
|
||||
Digit8_ko: '8',
|
||||
Digit9_ko: '9',
|
||||
Digit0_ko: '0',
|
||||
Minus_ko: '-',
|
||||
Equal_ko: '=',
|
||||
|
||||
KeyQ_ko: 'ㅂ',
|
||||
KeyW_ko: 'ㅈ',
|
||||
KeyE_ko: 'ㄷ',
|
||||
KeyR_ko: 'ㄱ',
|
||||
KeyT_ko: 'ㅅ',
|
||||
KeyY_ko: 'ㅛ',
|
||||
KeyU_ko: 'ㅕ',
|
||||
KeyI_ko: 'ㅑ',
|
||||
KeyO_ko: 'ㅐ',
|
||||
KeyP_ko: 'ㅔ',
|
||||
BracketLeft_ko: '[',
|
||||
BracketRight_ko: ']',
|
||||
Backslash_ko: '\\',
|
||||
|
||||
KeyA_ko: 'ㅁ',
|
||||
KeyS_ko: 'ㄴ',
|
||||
KeyD_ko: 'ㅇ',
|
||||
KeyF_ko: 'ㄹ',
|
||||
KeyG_ko: 'ㅎ',
|
||||
KeyH_ko: 'ㅗ',
|
||||
KeyJ_ko: 'ㅓ',
|
||||
KeyK_ko: 'ㅏ',
|
||||
KeyL_ko: 'ㅣ',
|
||||
Semicolon_ko: ';',
|
||||
Quote_ko: "'",
|
||||
|
||||
KeyZ_ko: 'ㅋ',
|
||||
KeyX_ko: 'ㅌ',
|
||||
KeyC_ko: 'ㅊ',
|
||||
KeyV_ko: 'ㅍ',
|
||||
KeyB_ko: 'ㅠ',
|
||||
KeyN_ko: 'ㅜ',
|
||||
KeyM_ko: 'ㅡ',
|
||||
Comma_ko: ',',
|
||||
Period_ko: '.',
|
||||
Slash_ko: '/',
|
||||
|
||||
// Japanese display (representative hiragana per key)
|
||||
Backquote_ja: '`',
|
||||
Digit1_ja: '1',
|
||||
Digit2_ja: '2',
|
||||
Digit3_ja: '3',
|
||||
Digit4_ja: '4',
|
||||
Digit5_ja: '5',
|
||||
Digit6_ja: '6',
|
||||
Digit7_ja: '7',
|
||||
Digit8_ja: '8',
|
||||
Digit9_ja: '9',
|
||||
Digit0_ja: '0',
|
||||
Minus_ja: '-',
|
||||
Equal_ja: '=',
|
||||
|
||||
KeyQ_ja: 'た',
|
||||
KeyW_ja: 'て',
|
||||
KeyE_ja: 'い',
|
||||
KeyR_ja: 'す',
|
||||
KeyT_ja: 'か',
|
||||
KeyY_ja: 'ん',
|
||||
KeyU_ja: 'な',
|
||||
KeyI_ja: 'に',
|
||||
KeyO_ja: 'ら',
|
||||
KeyP_ja: 'せ',
|
||||
BracketLeft_ja: '[',
|
||||
BracketRight_ja: ']',
|
||||
Backslash_ja: '\\',
|
||||
|
||||
KeyA_ja: 'ち',
|
||||
KeyS_ja: 'と',
|
||||
KeyD_ja: 'し',
|
||||
KeyF_ja: 'は',
|
||||
KeyG_ja: 'き',
|
||||
KeyH_ja: 'く',
|
||||
KeyJ_ja: 'ま',
|
||||
KeyK_ja: 'の',
|
||||
KeyL_ja: 'り',
|
||||
Semicolon_ja: ';',
|
||||
Quote_ja: "'",
|
||||
|
||||
KeyZ_ja: 'つ',
|
||||
KeyX_ja: 'さ',
|
||||
KeyC_ja: 'そ',
|
||||
KeyV_ja: 'ひ',
|
||||
KeyB_ja: 'こ',
|
||||
KeyN_ja: 'み',
|
||||
KeyM_ja: 'も',
|
||||
Comma_ja: ',',
|
||||
Period_ja: '.',
|
||||
Slash_ja: '/',
|
||||
|
||||
// AZERTY specific display keys
|
||||
// Row 1
|
||||
Backquote_azerty: '<<br/>>',
|
||||
|
||||
@@ -1,17 +1,56 @@
|
||||
import { useState } from 'react';
|
||||
import { CheckOutlined, LockOutlined, WifiOutlined } from '@ant-design/icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CheckOutlined, KeyOutlined, LockOutlined, WifiOutlined } from '@ant-design/icons';
|
||||
import { Button, Form, Input } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import * as api from '@/api/network.ts';
|
||||
import { Head } from '@/components/head.tsx';
|
||||
|
||||
type State = '' | 'loading' | 'success' | 'failed' | 'denied';
|
||||
type VerifyState = '' | 'failed' | 'denied';
|
||||
|
||||
export const Wifi = () => {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [state, setState] = useState<State>('');
|
||||
const [apPassword, setApPassword] = useState<string>('');
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
||||
const [verifying, setVerifying] = useState<boolean>(false);
|
||||
const [verifyState, setVerifyState] = useState<VerifyState>('');
|
||||
|
||||
useEffect(() => {
|
||||
const pass = searchParams.get('p') || searchParams.get('P');
|
||||
if (pass) {
|
||||
verifyPassword(pass);
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function verifyPassword(password: string) {
|
||||
if (verifying) return;
|
||||
setVerifying(true);
|
||||
setVerifyState('');
|
||||
|
||||
try {
|
||||
const rsp = await api.verifyApLogin(password);
|
||||
if (rsp?.code === 0) {
|
||||
setApPassword(password);
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
setVerifyState(rsp?.code === -1 ? 'denied' : 'failed');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setVerifyState('failed');
|
||||
}
|
||||
setVerifying(false);
|
||||
}
|
||||
|
||||
async function onVerifyFinish(values: any) {
|
||||
if (!values.apPassword) return;
|
||||
await verifyPassword(values.apPassword);
|
||||
}
|
||||
|
||||
async function connect(values: any) {
|
||||
if (!values.ssid || !values.password) return;
|
||||
@@ -20,13 +59,14 @@ export const Wifi = () => {
|
||||
setState('loading');
|
||||
|
||||
try {
|
||||
const rsp = await api.connectWifiNoAuth(values.ssid, values.password);
|
||||
const rsp = await api.connectWifiNoAuth(values.ssid, values.password, apPassword);
|
||||
|
||||
switch (rsp?.code) {
|
||||
case 0:
|
||||
setState('success');
|
||||
return;
|
||||
case -1:
|
||||
case -4:
|
||||
setState('denied');
|
||||
return;
|
||||
case -2:
|
||||
@@ -41,6 +81,48 @@ export const Wifi = () => {
|
||||
setState('success');
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<>
|
||||
<Head title={t('head.wifi')} />
|
||||
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center">
|
||||
<Form
|
||||
style={{ minWidth: 300, maxWidth: 500 }}
|
||||
initialValues={{ remember: true }}
|
||||
onFinish={onVerifyFinish}
|
||||
>
|
||||
<div className="flex flex-col space-y-1 pb-5">
|
||||
<span className="text-center text-2xl font-semibold text-red-500">
|
||||
{t('wifi.ap.authTitle')}
|
||||
</span>
|
||||
<span className="text-center text-neutral-400">{t('wifi.ap.authDescription')}</span>
|
||||
</div>
|
||||
|
||||
<Form.Item name="apPassword">
|
||||
<Input.Password prefix={<KeyOutlined />} placeholder={t('wifi.ap.passPlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button className="w-full" htmlType="submit" type="primary" loading={verifying}>
|
||||
{t('wifi.ap.verifyBtn')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div className="flex max-w-[500px] justify-center px-5 pt-3 md:px-10">
|
||||
{verifyState === 'failed' && (
|
||||
<span className="text-sm text-red-500">{t('wifi.ap.authFailed')}</span>
|
||||
)}
|
||||
{verifyState === 'denied' && (
|
||||
<span className="text-sm text-red-500">{t('wifi.invalidMode')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title={t('head.wifi')} />
|
||||
|
||||
@@ -44,6 +44,7 @@ export const router = createHashRouter([
|
||||
},
|
||||
{
|
||||
path: '/wifi',
|
||||
caseSensitive: false,
|
||||
lazy: async () => {
|
||||
const { Wifi } = await import('./pages/wifi');
|
||||
return { Component: Wifi };
|
||||
|
||||
Reference in New Issue
Block a user