feat: show remote keyboard lock status (#838)

* feat: show remote keyboard lock status

* fix(keyboard-led-status): eliminate polling and improve delivery

* fix(keyboard-led-status): simplify indicator labels
This commit is contained in:
肆月
2026-08-04 09:28:14 +08:00
committed by GitHub
parent 21e834b4c8
commit eb20fb41aa
45 changed files with 1483 additions and 25 deletions

View File

@@ -4,6 +4,14 @@ type GetHidModeRsp struct {
Mode string `json:"mode"` // normal or hid-only Mode string `json:"mode"` // normal or hid-only
} }
type GetKeyboardLedStatusRsp struct {
NumLock bool `json:"numLock"`
CapsLock bool `json:"capsLock"`
ScrollLock bool `json:"scrollLock"`
Known bool `json:"known"`
UpdatedAt string `json:"updatedAt"`
}
type SetHidModeReq struct { type SetHidModeReq struct {
Mode string `validate:"required"` // normal or hid-only Mode string `validate:"required"` // normal or hid-only
} }

View File

@@ -30,6 +30,7 @@ func hidRouter(r *gin.Engine) {
api.GET("/hid/mode", service.GetHidMode) // get hid mode api.GET("/hid/mode", service.GetHidMode) // get hid mode
api.POST("/hid/mode", service.SetHidMode) // set hid mode api.POST("/hid/mode", service.SetHidMode) // set hid mode
api.POST("/hid/reset", service.ResetHid) // reset hid api.POST("/hid/reset", service.ResetHid) // reset hid
api.GET("/hid/leds", service.GetKeyboardLedStatus)
localAPI.POST("/usb/recover", service.RecoverUSB) localAPI.POST("/usb/recover", service.RecoverUSB)
} }

View File

@@ -13,11 +13,17 @@ import (
) )
type Hid struct { type Hid struct {
g0 *os.File g0 *os.File
g1 *os.File g0Reader *os.File
g2 *os.File g1 *os.File
kbMutex sync.Mutex g2 *os.File
mouseMutex sync.Mutex ledReaderNotifyReader *os.File
ledReaderNotifyWriter *os.File
ledReaderNotifyReadFD int
ledReaderNotifyWriteFD int
kbMutex sync.Mutex
mouseMutex sync.Mutex
ledReaderStartOnce sync.Once
} }
const ( const (
@@ -124,6 +130,15 @@ func (h *Hid) OpenNoLock() error {
} }
} }
if h.g0 != nil {
if err := h.openKeyboardLedReaderNoLock(); err != nil {
log.Errorf("open keyboard LED reader failed: %s", err)
errs = append(errs, err)
} else {
h.startKeyboardLedReader()
}
}
return errors.Join(errs...) return errors.Join(errs...)
} }
@@ -162,6 +177,8 @@ func openNoLockWithRetry(open func() error, timeout, delay time.Duration) error
} }
func (h *Hid) CloseNoLock() { func (h *Hid) CloseNoLock() {
h.closeKeyboardLedReaderNoLock()
for _, device := range h.devices() { for _, device := range h.devices() {
h.closeDeviceNoLock(device) h.closeDeviceNoLock(device)
} }
@@ -193,6 +210,41 @@ func (h *Hid) closeDeviceNoLock(device hidDevice) {
} }
} }
func (h *Hid) openKeyboardLedReaderNoLock() error {
if h.g0Reader != nil {
return nil
}
if err := h.ensureKeyboardLedReaderNotifierNoLock(); err != nil {
return err
}
// Keep this descriptor blocking. The LED reader waits for either an output
// report or a lifecycle notification, so an idle host does not cause a
// periodic EAGAIN retry loop.
file, err := os.OpenFile(HID0, os.O_RDONLY, 0o666)
if err != nil {
return fmt.Errorf("%s: %w", HID0, err)
}
h.g0Reader = file
h.notifyKeyboardLedReaderNoLock()
return nil
}
func (h *Hid) closeKeyboardLedReaderNoLock() {
if h.g0Reader == nil {
return
}
file := h.g0Reader
h.g0Reader = nil
h.notifyKeyboardLedReaderNoLock()
if err := file.Close(); err != nil {
log.Debugf("close keyboard LED reader failed: %s", err)
}
}
// writeWithTimeout bounds how long callers hold HID locks when writing to a // writeWithTimeout bounds how long callers hold HID locks when writing to a
// nonblocking descriptor. EAGAIN means the host is not accepting HID reports // nonblocking descriptor. EAGAIN means the host is not accepting HID reports
// yet, so retry until the caller's deadline expires. // yet, so retry until the caller's deadline expires.
@@ -302,6 +354,13 @@ func (h *Hid) writeHID(device hidDevice, data []byte) error {
if err := h.openDeviceNoLock(device); err != nil { if err := h.openDeviceNoLock(device); err != nil {
return err return err
} }
if device.path == HID0 {
if err := h.openKeyboardLedReaderNoLock(); err != nil {
log.Debugf("open keyboard LED reader failed: %s", err)
} else {
h.startKeyboardLedReader()
}
}
file := device.get() file := device.get()
if file == nil { if file == nil {
@@ -314,6 +373,9 @@ func (h *Hid) writeHID(device hidDevice, data []byte) error {
} }
if err := writeWithTimeout(file, data, hidWriteTimeout); err != nil { if err := writeWithTimeout(file, data, hidWriteTimeout); err != nil {
if device.path == HID0 {
h.closeKeyboardLedReaderNoLock()
}
h.closeDeviceNoLock(device) h.closeDeviceNoLock(device)
switch { switch {
case errors.Is(err, os.ErrClosed): case errors.Is(err, os.ErrClosed):

279
server/service/hid/leds.go Normal file
View File

@@ -0,0 +1,279 @@
package hid
import (
"errors"
"fmt"
"os"
"sync"
"syscall"
"time"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/unix"
)
const (
KeyboardLedStatusEvent = "hid-led-status"
)
// KeyboardLedStatus is the lock-key LED state last reported by the remote host
// through the keyboard HID output report.
type KeyboardLedStatus struct {
NumLock bool `json:"numLock"`
CapsLock bool `json:"capsLock"`
ScrollLock bool `json:"scrollLock"`
Known bool `json:"known"`
UpdatedAt time.Time `json:"updatedAt"`
}
type keyboardLedStatusStore struct {
mutex sync.RWMutex
status KeyboardLedStatus
subscribers map[int]func(KeyboardLedStatus)
nextID int
now func() time.Time
}
var keyboardLeds = newKeyboardLedStatusStore(time.Now)
func newKeyboardLedStatusStore(now func() time.Time) *keyboardLedStatusStore {
return &keyboardLedStatusStore{
subscribers: make(map[int]func(KeyboardLedStatus)),
now: now,
}
}
// GetKeyboardLedStatus returns a snapshot. Known is false until the host has
// sent at least one keyboard HID output report.
func GetKeyboardLedStatus() KeyboardLedStatus {
return keyboardLeds.Get()
}
// SubscribeKeyboardLedStatus subscribes to state changes and returns a
// function that removes the subscription.
func SubscribeKeyboardLedStatus(subscriber func(KeyboardLedStatus)) func() {
return keyboardLeds.Subscribe(subscriber)
}
func (s *keyboardLedStatusStore) Get() KeyboardLedStatus {
s.mutex.RLock()
defer s.mutex.RUnlock()
return s.status
}
func (s *keyboardLedStatusStore) Subscribe(subscriber func(KeyboardLedStatus)) func() {
s.mutex.Lock()
id := s.nextID
s.nextID++
s.subscribers[id] = subscriber
s.mutex.Unlock()
return func() {
s.mutex.Lock()
delete(s.subscribers, id)
s.mutex.Unlock()
}
}
func (s *keyboardLedStatusStore) Update(report byte) {
next := keyboardLedStatusFromReport(report, s.now())
s.mutex.Lock()
previous := s.status
s.status = next
if sameKeyboardLedState(previous, next) {
s.mutex.Unlock()
return
}
subscribers := make([]func(KeyboardLedStatus), 0, len(s.subscribers))
for _, subscriber := range s.subscribers {
subscribers = append(subscribers, subscriber)
}
s.mutex.Unlock()
for _, subscriber := range subscribers {
subscriber(next)
}
}
func keyboardLedStatusFromReport(report byte, updatedAt time.Time) KeyboardLedStatus {
return KeyboardLedStatus{
NumLock: report&0x01 != 0,
CapsLock: report&0x02 != 0,
ScrollLock: report&0x04 != 0,
Known: true,
UpdatedAt: updatedAt,
}
}
func sameKeyboardLedState(a, b KeyboardLedStatus) bool {
return a.NumLock == b.NumLock &&
a.CapsLock == b.CapsLock &&
a.ScrollLock == b.ScrollLock &&
a.Known == b.Known
}
func (h *Hid) startKeyboardLedReader() {
h.ledReaderStartOnce.Do(func() {
go h.readKeyboardLeds()
})
}
func (h *Hid) readKeyboardLeds() {
buf := make([]byte, 64)
for {
file, notifierFD := h.keyboardLedReaderHandles()
if file == nil {
if err := waitForKeyboardLedReaderChange(notifierFD); err != nil {
log.Debugf("wait for keyboard LED reader failed: %s", err)
}
continue
}
changed, err := waitForKeyboardLedReportOrChange(file, notifierFD)
if err != nil {
if !errors.Is(err, os.ErrClosed) {
log.Debugf("wait for keyboard LED report failed: %s", err)
}
h.reopenKeyboardLedReader(file)
continue
}
if changed {
continue
}
n, err := file.Read(buf)
if n > 0 {
// Both keyboard gadget descriptors in kvmapp/system/init.d declare
// boot-keyboard reports without a Report ID (no 0x85 item). Their LED
// output report is one byte: bits 0-4 are the LED bitmap and bits 5-7
// are padding. report_length is 8 because it also covers input reports.
keyboardLeds.Update(buf[0])
}
if err == nil && n > 0 {
continue
}
if err != nil && !errors.Is(err, os.ErrClosed) {
log.Debugf("read keyboard LED report failed: %s", err)
}
h.reopenKeyboardLedReader(file)
}
}
func (h *Hid) keyboardLedReaderHandles() (*os.File, int) {
h.kbMutex.Lock()
defer h.kbMutex.Unlock()
if h.ledReaderNotifyReader == nil {
return h.g0Reader, -1
}
return h.g0Reader, h.ledReaderNotifyReadFD
}
func (h *Hid) reopenKeyboardLedReader(file *os.File) {
h.kbMutex.Lock()
defer h.kbMutex.Unlock()
// A reset may have installed a new reader while the old descriptor was
// reporting an error. Never let that stale reader close the replacement.
if h.g0Reader != file {
return
}
h.closeKeyboardLedReaderNoLock()
if h.g0 == nil {
return
}
if err := h.openKeyboardLedReaderNoLock(); err != nil {
log.Debugf("reopen keyboard LED reader failed: %s", err)
}
}
func (h *Hid) ensureKeyboardLedReaderNotifierNoLock() error {
if h.ledReaderNotifyReader != nil {
return nil
}
reader, writer, err := os.Pipe()
if err != nil {
return fmt.Errorf("create keyboard LED reader notifier: %w", err)
}
readerFD := int(reader.Fd())
writerFD := int(writer.Fd())
if err := unix.SetNonblock(readerFD, true); err != nil {
_ = reader.Close()
_ = writer.Close()
return fmt.Errorf("set keyboard LED reader notifier read end nonblocking: %w", err)
}
if err := unix.SetNonblock(writerFD, true); err != nil {
_ = reader.Close()
_ = writer.Close()
return fmt.Errorf("set keyboard LED reader notifier write end nonblocking: %w", err)
}
h.ledReaderNotifyReader = reader
h.ledReaderNotifyWriter = writer
h.ledReaderNotifyReadFD = readerFD
h.ledReaderNotifyWriteFD = writerFD
return nil
}
func (h *Hid) notifyKeyboardLedReaderNoLock() {
if h.ledReaderNotifyWriter == nil {
return
}
_, err := unix.Write(h.ledReaderNotifyWriteFD, []byte{1})
if err != nil && !errors.Is(err, syscall.EAGAIN) && !errors.Is(err, syscall.EWOULDBLOCK) {
log.Debugf("notify keyboard LED reader failed: %s", err)
}
}
func waitForKeyboardLedReaderChange(notifierFD int) error {
if notifierFD < 0 {
return fmt.Errorf("keyboard LED reader notifier is nil")
}
_, err := unix.Poll([]unix.PollFd{{Fd: int32(notifierFD), Events: unix.POLLIN}}, -1)
if err != nil {
return err
}
drainKeyboardLedReaderNotifier(notifierFD)
return nil
}
// waitForKeyboardLedReportOrChange blocks until a HID output report arrives or
// the reader is replaced/closed. Its bool result is true for the latter.
func waitForKeyboardLedReportOrChange(file *os.File, notifierFD int) (bool, error) {
if file == nil || notifierFD < 0 {
return false, fmt.Errorf("keyboard LED reader or notifier is nil")
}
fds := []unix.PollFd{
{Fd: int32(file.Fd()), Events: unix.POLLIN},
{Fd: int32(notifierFD), Events: unix.POLLIN},
}
_, err := unix.Poll(fds, -1)
if err != nil {
return false, err
}
if fds[1].Revents&unix.POLLIN != 0 {
drainKeyboardLedReaderNotifier(notifierFD)
return true, nil
}
if fds[0].Revents&(unix.POLLIN|unix.POLLERR|unix.POLLHUP|unix.POLLNVAL) != 0 {
return false, nil
}
return false, fmt.Errorf("keyboard LED poll returned without an event")
}
func drainKeyboardLedReaderNotifier(notifierFD int) {
buf := make([]byte, 64)
for {
_, err := unix.Read(notifierFD, buf)
if err != nil {
if !errors.Is(err, syscall.EAGAIN) && !errors.Is(err, syscall.EWOULDBLOCK) {
log.Debugf("drain keyboard LED reader notifier failed: %s", err)
}
return
}
}
}

View File

@@ -0,0 +1,168 @@
package hid
import (
"os"
"testing"
"time"
)
func TestKeyboardLedStatusFromReport(t *testing.T) {
updatedAt := time.Date(2026, time.July, 24, 12, 0, 0, 0, time.UTC)
status := keyboardLedStatusFromReport(0x17, updatedAt)
if !status.NumLock || !status.CapsLock || !status.ScrollLock || !status.Known {
t.Fatalf("unexpected parsed status: %+v", status)
}
if !status.UpdatedAt.Equal(updatedAt) {
t.Fatalf("updatedAt = %s, want %s", status.UpdatedAt, updatedAt)
}
}
func TestKeyboardLedStatusStoreOnlyNotifiesStateChanges(t *testing.T) {
now := time.Date(2026, time.July, 24, 12, 0, 0, 0, time.UTC)
store := newKeyboardLedStatusStore(func() time.Time { return now })
notifications := make(chan KeyboardLedStatus, 2)
store.Subscribe(func(status KeyboardLedStatus) {
notifications <- status
})
store.Update(0x01)
store.Update(0x01)
now = now.Add(time.Second)
store.Update(0x03)
first := <-notifications
second := <-notifications
if !first.NumLock || first.CapsLock || !second.NumLock || !second.CapsLock {
t.Fatalf("unexpected notifications: first=%+v second=%+v", first, second)
}
select {
case status := <-notifications:
t.Fatalf("unexpected duplicate notification: %+v", status)
default:
}
if status := store.Get(); !status.UpdatedAt.Equal(now) {
t.Fatalf("latest updatedAt = %s, want %s", status.UpdatedAt, now)
}
}
func TestWaitForKeyboardLedReaderChangeWaitsForNotification(t *testing.T) {
h := &Hid{}
h.kbMutex.Lock()
if err := h.ensureKeyboardLedReaderNotifierNoLock(); err != nil {
h.kbMutex.Unlock()
t.Fatal(err)
}
notifierFD := h.ledReaderNotifyReadFD
h.kbMutex.Unlock()
defer h.ledReaderNotifyReader.Close()
defer h.ledReaderNotifyWriter.Close()
done := make(chan error, 1)
go func() { done <- waitForKeyboardLedReaderChange(notifierFD) }()
select {
case err := <-done:
t.Fatalf("wait returned before a notification: %v", err)
case <-time.After(25 * time.Millisecond):
}
h.kbMutex.Lock()
h.notifyKeyboardLedReaderNoLock()
h.kbMutex.Unlock()
select {
case err := <-done:
if err != nil {
t.Fatalf("wait returned error: %v", err)
}
case <-time.After(time.Second):
t.Fatal("wait did not wake after a notification")
}
}
func TestWaitForKeyboardLedReportOrChangeReturnsForReport(t *testing.T) {
reader, writer, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer reader.Close()
defer writer.Close()
h := &Hid{}
h.kbMutex.Lock()
if err := h.ensureKeyboardLedReaderNotifierNoLock(); err != nil {
h.kbMutex.Unlock()
t.Fatal(err)
}
notifierFD := h.ledReaderNotifyReadFD
h.kbMutex.Unlock()
defer h.ledReaderNotifyReader.Close()
defer h.ledReaderNotifyWriter.Close()
done := make(chan struct {
changed bool
err error
}, 1)
go func() {
changed, err := waitForKeyboardLedReportOrChange(reader, notifierFD)
done <- struct {
changed bool
err error
}{changed, err}
}()
if _, err := writer.Write([]byte{0x03}); err != nil {
t.Fatal(err)
}
select {
case result := <-done:
if result.err != nil || result.changed {
t.Fatalf("result = changed=%t, err=%v; want report", result.changed, result.err)
}
case <-time.After(time.Second):
t.Fatal("wait did not wake for report")
}
}
func TestReopenKeyboardLedReaderDoesNotCloseReplacement(t *testing.T) {
oldReader, oldWriter, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer oldReader.Close()
defer oldWriter.Close()
replacement, replacementWriter, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer replacement.Close()
defer replacementWriter.Close()
h := &Hid{g0Reader: oldReader}
h.kbMutex.Lock()
if err := h.ensureKeyboardLedReaderNotifierNoLock(); err != nil {
h.kbMutex.Unlock()
t.Fatal(err)
}
h.g0Reader = replacement // Simulate reset/reopen replacing the reader.
h.kbMutex.Unlock()
defer h.ledReaderNotifyReader.Close()
defer h.ledReaderNotifyWriter.Close()
h.reopenKeyboardLedReader(oldReader)
current, _ := h.keyboardLedReaderHandles()
if current != replacement {
t.Fatal("stale reader close replaced the active reader")
}
if _, err := replacementWriter.Write([]byte{0x01}); err != nil {
t.Fatalf("replacement reader was closed: %v", err)
}
buf := make([]byte, 1)
if _, err := replacement.Read(buf); err != nil {
t.Fatalf("replacement reader was closed: %v", err)
}
}

View File

@@ -47,6 +47,23 @@ func (s *Service) GetHidMode(c *gin.Context) {
log.Debugf("get hid mode: %s", mode) log.Debugf("get hid mode: %s", mode)
} }
func (s *Service) GetKeyboardLedStatus(c *gin.Context) {
var rsp proto.Response
status := GetKeyboardLedStatus()
updatedAt := ""
if !status.UpdatedAt.IsZero() {
updatedAt = status.UpdatedAt.UTC().Format(time.RFC3339Nano)
}
rsp.OkRspWithData(c, &proto.GetKeyboardLedStatusRsp{
NumLock: status.NumLock,
CapsLock: status.CapsLock,
ScrollLock: status.ScrollLock,
Known: status.Known,
UpdatedAt: updatedAt,
})
}
func (s *Service) SetHidMode(c *gin.Context) { func (s *Service) SetHidMode(c *gin.Context) {
var req proto.SetHidModeReq var req proto.SetHidModeReq
var rsp proto.Response var rsp proto.Response

View File

@@ -29,13 +29,15 @@ const (
func NewClient(ws *websocket.Conn) *Client { func NewClient(ws *websocket.Conn) *Client {
client := &Client{ client := &Client{
ws: ws, ws: ws,
hid: hid.GetHid(), hid: hid.GetHid(),
manual: inputcontrol.NewManualSession(controlmode.GetManager(), inputcontrol.GetCoordinator()), manual: inputcontrol.NewManualSession(controlmode.GetManager(), inputcontrol.GetCoordinator()),
keyboard: make(chan hid.QueuedReport, 200), keyboard: make(chan hid.QueuedReport, 200),
mouse: make(chan hid.QueuedReport, 200), mouse: make(chan hid.QueuedReport, 200),
heartbeatTimeout: clientHeartbeatTimeout, heartbeatTimeout: clientHeartbeatTimeout,
lastHeartbeat: time.Time{}, keyboardLedNotify: make(chan struct{}, 1),
keyboardLedDone: make(chan struct{}),
lastHeartbeat: time.Time{},
} }
client.hid.Open() client.hid.Open()
@@ -44,6 +46,8 @@ func NewClient(ws *websocket.Conn) *Client {
} }
func (c *Client) Start() { func (c *Client) Start() {
c.startKeyboardLedStatusWorker()
c.workers.Add(2) c.workers.Add(2)
go func() { go func() {
defer c.workers.Done() defer c.workers.Done()
@@ -194,6 +198,7 @@ func (c *Client) Close() {
if c.ws != nil { if c.ws != nil {
_ = c.ws.Close() _ = c.ws.Close()
} }
c.stopKeyboardLedStatusWorker()
if c.keyboard != nil { if c.keyboard != nil {
close(c.keyboard) close(c.keyboard)
@@ -203,12 +208,104 @@ func (c *Client) Close() {
} }
}) })
c.workers.Wait() c.workers.Wait()
c.keyboardLedWorkers.Wait()
if c.manual != nil { if c.manual != nil {
c.manual.Close() c.manual.Close()
} }
log.Debug("websocket disconnected") log.Debug("websocket disconnected")
} }
// enqueueKeyboardLedStatus records only the newest status for this client. It
// never waits for a WebSocket write, so a client which stops reading cannot
// delay LED updates for the other connected clients.
func (c *Client) enqueueKeyboardLedStatus(status hid.KeyboardLedStatus) {
c.keyboardLedMutex.Lock()
if c.keyboardLedClosed || c.keyboardLedNotify == nil {
c.keyboardLedMutex.Unlock()
return
}
c.keyboardLedStatus = &status
notify := c.keyboardLedNotify
c.keyboardLedMutex.Unlock()
select {
case notify <- struct{}{}:
default:
}
}
func (c *Client) startKeyboardLedStatusWorker() {
c.keyboardLedMutex.Lock()
if c.keyboardLedClosed {
c.keyboardLedMutex.Unlock()
return
}
if c.keyboardLedNotify == nil {
c.keyboardLedNotify = make(chan struct{}, 1)
}
if c.keyboardLedDone == nil {
c.keyboardLedDone = make(chan struct{})
}
c.keyboardLedMutex.Unlock()
c.keyboardLedOnce.Do(func() {
c.keyboardLedWorkers.Add(1)
go func() {
defer c.keyboardLedWorkers.Done()
c.runKeyboardLedStatusWorker()
}()
})
}
func (c *Client) stopKeyboardLedStatusWorker() {
c.keyboardLedMutex.Lock()
defer c.keyboardLedMutex.Unlock()
if c.keyboardLedClosed {
return
}
c.keyboardLedClosed = true
if c.keyboardLedDone != nil {
close(c.keyboardLedDone)
}
}
func (c *Client) runKeyboardLedStatusWorker() {
for {
c.keyboardLedMutex.Lock()
notify := c.keyboardLedNotify
done := c.keyboardLedDone
c.keyboardLedMutex.Unlock()
select {
case <-done:
return
case <-notify:
}
for {
status, ok := c.takeKeyboardLedStatus()
if !ok {
break
}
if err := sendKeyboardLedStatus(c, status); err != nil {
log.Errorf("failed to send keyboard LED status: %s", err)
}
}
}
}
func (c *Client) takeKeyboardLedStatus() (hid.KeyboardLedStatus, bool) {
c.keyboardLedMutex.Lock()
defer c.keyboardLedMutex.Unlock()
if c.keyboardLedStatus == nil {
return hid.KeyboardLedStatus{}, false
}
status := *c.keyboardLedStatus
c.keyboardLedStatus = nil
return status, true
}
func writeQueue(queue chan hid.QueuedReport, report hid.QueuedReport) bool { func writeQueue(queue chan hid.QueuedReport, report hid.QueuedReport) bool {
if !sendQueue(queue, report) { if !sendQueue(queue, report) {
report.Complete(false) report.Complete(false)

View File

@@ -0,0 +1,94 @@
package ws
import (
"encoding/json"
"sync"
"NanoKVM-Server/service/hid"
log "github.com/sirupsen/logrus"
)
var keyboardLedBroadcaster = newKeyboardLedStatusBroadcaster(broadcastKeyboardLedStatus)
func init() {
go keyboardLedBroadcaster.Run()
hid.SubscribeKeyboardLedStatus(func(status hid.KeyboardLedStatus) {
keyboardLedBroadcaster.Enqueue(status)
})
}
type keyboardLedStatusBroadcaster struct {
mutex sync.Mutex
pending *hid.KeyboardLedStatus
notify chan struct{}
broadcast func(hid.KeyboardLedStatus)
}
func newKeyboardLedStatusBroadcaster(
broadcast func(hid.KeyboardLedStatus),
) *keyboardLedStatusBroadcaster {
return &keyboardLedStatusBroadcaster{
notify: make(chan struct{}, 1),
broadcast: broadcast,
}
}
func (b *keyboardLedStatusBroadcaster) Enqueue(status hid.KeyboardLedStatus) {
b.mutex.Lock()
b.pending = &status
b.mutex.Unlock()
select {
case b.notify <- struct{}{}:
default:
}
}
func (b *keyboardLedStatusBroadcaster) Run() {
for range b.notify {
for {
status, ok := b.takePending()
if !ok {
break
}
b.broadcast(status)
}
}
}
func (b *keyboardLedStatusBroadcaster) takePending() (hid.KeyboardLedStatus, bool) {
b.mutex.Lock()
defer b.mutex.Unlock()
if b.pending == nil {
return hid.KeyboardLedStatus{}, false
}
status := *b.pending
b.pending = nil
return status, true
}
func sendKeyboardLedStatusSnapshot(client *Client) {
if err := sendKeyboardLedStatus(client, hid.GetKeyboardLedStatus()); err != nil {
log.Errorf("failed to send keyboard LED status snapshot: %s", err)
}
}
func broadcastKeyboardLedStatus(status hid.KeyboardLedStatus) {
for _, client := range GetManager().GetClients() {
client.enqueueKeyboardLedStatus(status)
}
}
func sendKeyboardLedStatus(client *Client, status hid.KeyboardLedStatus) error {
payload, err := json.Marshal(status)
if err != nil {
return err
}
return client.Write(hid.KeyboardLedStatusEvent, string(payload))
}

View File

@@ -0,0 +1,79 @@
package ws
import (
"testing"
"time"
"NanoKVM-Server/service/hid"
)
func TestKeyboardLedStatusQueueKeepsLatestStatus(t *testing.T) {
client := &Client{
keyboardLedNotify: make(chan struct{}, 1),
keyboardLedDone: make(chan struct{}),
}
first := hid.KeyboardLedStatus{NumLock: true}
latest := hid.KeyboardLedStatus{CapsLock: true}
client.enqueueKeyboardLedStatus(first)
client.enqueueKeyboardLedStatus(latest)
status, ok := client.takeKeyboardLedStatus()
if !ok {
t.Fatal("queued LED status was not available")
}
if status != latest {
t.Fatalf("queued status = %#v, want latest %#v", status, latest)
}
if _, ok := client.takeKeyboardLedStatus(); ok {
t.Fatal("queue retained more than one LED status")
}
}
func TestKeyboardLedStatusQueueDoesNotBlockWhenWorkerIsBusy(t *testing.T) {
client := &Client{
keyboardLedNotify: make(chan struct{}, 1),
keyboardLedDone: make(chan struct{}),
}
// A pending notification models a worker currently busy writing the prior
// status to a slow websocket client.
client.keyboardLedNotify <- struct{}{}
finished := make(chan struct{})
go func() {
client.enqueueKeyboardLedStatus(hid.KeyboardLedStatus{CapsLock: true})
close(finished)
}()
select {
case <-finished:
case <-time.After(time.Second):
t.Fatal("enqueue blocked while the keyboard LED worker was busy")
}
}
func TestKeyboardLedStatusWorkerStopsOnClientClose(t *testing.T) {
client := &Client{
keyboardLedNotify: make(chan struct{}, 1),
keyboardLedDone: make(chan struct{}),
}
client.startKeyboardLedStatusWorker()
client.stopKeyboardLedStatusWorker()
done := make(chan struct{})
go func() {
client.workers.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("keyboard LED worker did not stop after client close")
}
client.enqueueKeyboardLedStatus(hid.KeyboardLedStatus{ScrollLock: true})
if _, ok := client.takeKeyboardLedStatus(); ok {
t.Fatal("closed client accepted a keyboard LED status")
}
}

View File

@@ -39,6 +39,7 @@ func (s *Service) Connect(c *gin.Context) {
sendCaptureStatusSnapshot(client) sendCaptureStatusSnapshot(client)
sendH264ModeStatusSnapshot(client) sendH264ModeStatusSnapshot(client)
sendKeyboardLedStatusSnapshot(client)
client.Start() client.Start()
} }

View File

@@ -16,16 +16,23 @@ type Manager struct {
} }
type Client struct { type Client struct {
ws *websocket.Conn ws *websocket.Conn
hid *hid.Hid hid *hid.Hid
manual *inputcontrol.ManualSession manual *inputcontrol.ManualSession
keyboard chan hid.QueuedReport keyboard chan hid.QueuedReport
mouse chan hid.QueuedReport mouse chan hid.QueuedReport
heartbeatTimeout time.Duration heartbeatTimeout time.Duration
lastHeartbeat time.Time lastHeartbeat time.Time
mutex sync.Mutex mutex sync.Mutex
closeOnce sync.Once keyboardLedMutex sync.Mutex
workers sync.WaitGroup keyboardLedStatus *hid.KeyboardLedStatus
keyboardLedNotify chan struct{}
keyboardLedDone chan struct{}
keyboardLedClosed bool
keyboardLedOnce sync.Once
keyboardLedWorkers sync.WaitGroup
closeOnce sync.Once
workers sync.WaitGroup
} }
type Message struct { type Message struct {

View File

@@ -15,6 +15,11 @@ export function getHidMode() {
return http.get('/api/hid/mode'); return http.get('/api/hid/mode');
} }
// get remote keyboard lock LED status
export function getKeyboardLedStatus() {
return http.get('/api/hid/leds');
}
// set hid mode // set hid mode
export function setHidMode(mode: string) { export function setHidMode(mode: string) {
const data = { const data = {

View File

@@ -1,8 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { getMenuDisabledItems, getMenuDisplayMode } from '@/lib/localstorage.ts';
import { import {
getKeyboardLedStatusVisible,
getMenuDisabledItems,
getMenuDisplayMode
} from '@/lib/localstorage.ts';
import {
keyboardLedStatusVisibleAtom,
menuDisabledItemsAtom, menuDisabledItemsAtom,
menuDisplayModeAtom, menuDisplayModeAtom,
submenuOpenCountAtom submenuOpenCountAtom
@@ -23,6 +28,7 @@ export interface MenuVisibilityState {
export function useMenuVisibility(): MenuVisibilityState { export function useMenuVisibility(): MenuVisibilityState {
const [menuDisplayMode, setMenuDisplayMode] = useAtom(menuDisplayModeAtom); const [menuDisplayMode, setMenuDisplayMode] = useAtom(menuDisplayModeAtom);
const setMenuDisabledItems = useSetAtom(menuDisabledItemsAtom); const setMenuDisabledItems = useSetAtom(menuDisabledItemsAtom);
const setKeyboardLedStatusVisible = useSetAtom(keyboardLedStatusVisibleAtom);
const submenuOpenCount = useAtomValue(submenuOpenCountAtom); const submenuOpenCount = useAtomValue(submenuOpenCountAtom);
const [isMenuExpanded, setIsMenuExpanded] = useState(true); const [isMenuExpanded, setIsMenuExpanded] = useState(true);
@@ -62,6 +68,8 @@ export function useMenuVisibility(): MenuVisibilityState {
const items = getMenuDisabledItems(); const items = getMenuDisabledItems();
setMenuDisabledItems(items); setMenuDisabledItems(items);
setKeyboardLedStatusVisible(getKeyboardLedStatusVisible());
if (displayMode === 'off') { if (displayMode === 'off') {
setIsMenuExpanded(false); setIsMenuExpanded(false);
} }

View File

@@ -332,10 +332,26 @@ const ca = {
modeOff: 'Apagat', modeOff: 'Apagat',
modeAuto: 'Ocultació automàtica', modeAuto: 'Ocultació automàtica',
modeAlways: 'Sempre visible', modeAlways: 'Sempre visible',
keyboardLedStatus: 'Indicadors de bloqueig del teclat',
keyboardLedStatusDesc:
'Mostra lestat de Bloq Num, Bloq Maj i Bloq Despl de lordinador remot',
icons: 'Icones del submenú', icons: 'Icones del submenú',
iconsDesc: 'Mostra les icones del submenú a la barra de menús' iconsDesc: 'Mostra les icones del submenú a la barra de menús'
} }
}, },
keyboardLedStatus: {
groupLabel: 'Estat de bloqueig del teclat remot',
indicatorLabel: '{{label}}: {{state}}',
numLock: 'Bloq Num',
numLockShort: 'Num',
capsLock: 'Bloq Maj',
capsLockShort: 'Maj',
scrollLock: 'Bloq Despl',
scrollLockShort: 'Despl',
on: 'Activat',
off: 'Desactivat',
unknown: 'Desconegut'
},
device: { device: {
title: 'Dispositiu', title: 'Dispositiu',
oled: { oled: {

View File

@@ -335,10 +335,26 @@ const cz = {
modeOff: 'Vypnuto', modeOff: 'Vypnuto',
modeAuto: 'Automatické skrytí', modeAuto: 'Automatické skrytí',
modeAlways: 'Vždy viditelné', modeAlways: 'Vždy viditelné',
keyboardLedStatus: 'Indikátory zámku klávesnice',
keyboardLedStatusDesc:
'Zobrazit stav Num Lock, Caps Lock a Scroll Lock vzdáleného počítače',
icons: 'Ikony podnabídky', icons: 'Ikony podnabídky',
iconsDesc: 'Zobrazení ikon podnabídky na liště nabídek' iconsDesc: 'Zobrazení ikon podnabídky na liště nabídek'
} }
}, },
keyboardLedStatus: {
groupLabel: 'Stav zámků vzdálené klávesnice',
indicatorLabel: '{{label}}: {{state}}',
numLock: 'Num Lock',
numLockShort: 'Num',
capsLock: 'Caps Lock',
capsLockShort: 'Caps',
scrollLock: 'Scroll Lock',
scrollLockShort: 'Scr',
on: 'Zapnuto',
off: 'Vypnuto',
unknown: 'Neznámé'
},
device: { device: {
title: 'Zařízení', title: 'Zařízení',
oled: { oled: {

View File

@@ -332,10 +332,26 @@ const da = {
modeOff: 'Fra', modeOff: 'Fra',
modeAuto: 'Skjul automatisk', modeAuto: 'Skjul automatisk',
modeAlways: 'Altid synlig', modeAlways: 'Altid synlig',
keyboardLedStatus: 'Tastaturlåseindikatorer',
keyboardLedStatusDesc:
'Vis Num Lock-, Caps Lock- og Scroll Lock-status for fjerncomputeren',
icons: 'Undermenuikoner', icons: 'Undermenuikoner',
iconsDesc: 'Vis undermenuikoner i menulinjen' iconsDesc: 'Vis undermenuikoner i menulinjen'
} }
}, },
keyboardLedStatus: {
groupLabel: 'Status for låse på fjernkeyboard',
indicatorLabel: '{{label}}: {{state}}',
numLock: 'Num Lock',
numLockShort: 'Num',
capsLock: 'Caps Lock',
capsLockShort: 'Caps',
scrollLock: 'Scroll Lock',
scrollLockShort: 'Scr',
on: 'Til',
off: 'Fra',
unknown: 'Ukendt'
},
device: { device: {
title: 'Enhed', title: 'Enhed',
oled: { oled: {

View File

@@ -338,10 +338,26 @@ const de = {
modeOff: 'Aus', modeOff: 'Aus',
modeAuto: 'Automatisch ausblenden', modeAuto: 'Automatisch ausblenden',
modeAlways: 'Immer sichtbar', modeAlways: 'Immer sichtbar',
keyboardLedStatus: 'Tastensperren-Anzeigen',
keyboardLedStatusDesc:
'Num-Lock-, Feststell- und Rollen-Status des Remote-Computers anzeigen',
icons: 'Untermenüsymbole', icons: 'Untermenüsymbole',
iconsDesc: 'Untermenüsymbole in der Menüleiste anzeigen' iconsDesc: 'Untermenüsymbole in der Menüleiste anzeigen'
} }
}, },
keyboardLedStatus: {
groupLabel: 'Tastensperren-Status der Remote-Tastatur',
indicatorLabel: '{{label}}: {{state}}',
numLock: 'Num-Taste',
numLockShort: 'Num',
capsLock: 'Feststelltaste',
capsLockShort: 'Fest',
scrollLock: 'Rollen-Taste',
scrollLockShort: 'Roll',
on: 'Ein',
off: 'Aus',
unknown: 'Unbekannt'
},
device: { device: {
title: 'Gerät', title: 'Gerät',
oled: { oled: {

View File

@@ -331,10 +331,25 @@ const en = {
modeOff: 'Off', modeOff: 'Off',
modeAuto: 'Auto hide', modeAuto: 'Auto hide',
modeAlways: 'Always visible', modeAlways: 'Always visible',
keyboardLedStatus: 'Keyboard lock indicators',
keyboardLedStatusDesc: 'Display remote Num Lock, Caps Lock, and Scroll Lock status',
icons: 'Submenu Icons', icons: 'Submenu Icons',
iconsDesc: 'Display submenu icons in the menu bar' iconsDesc: 'Display submenu icons in the menu bar'
} }
}, },
keyboardLedStatus: {
groupLabel: 'Remote keyboard lock status',
indicatorLabel: '{{label}}: {{state}}',
numLock: 'Num Lock',
numLockShort: 'Num',
capsLock: 'Caps Lock',
capsLockShort: 'Caps',
scrollLock: 'Scroll Lock',
scrollLockShort: 'Scr',
on: 'On',
off: 'Off',
unknown: 'Unknown'
},
device: { device: {
title: 'Device', title: 'Device',
oled: { oled: {

View File

@@ -335,10 +335,26 @@ const es = {
modeOff: 'Apagado', modeOff: 'Apagado',
modeAuto: 'Ocultar automáticamente', modeAuto: 'Ocultar automáticamente',
modeAlways: 'Siempre visible', modeAlways: 'Siempre visible',
keyboardLedStatus: 'Indicadores de bloqueo del teclado',
keyboardLedStatusDesc:
'Mostrar el estado de Bloq Num, Bloq Mayús y Bloq Despl del equipo remoto',
icons: 'Iconos del submenú', icons: 'Iconos del submenú',
iconsDesc: 'Mostrar iconos de submenú en la barra de menú' iconsDesc: 'Mostrar iconos de submenú en la barra de menú'
} }
}, },
keyboardLedStatus: {
groupLabel: 'Estado de bloqueos del teclado remoto',
indicatorLabel: '{{label}}: {{state}}',
numLock: 'Bloq Num',
numLockShort: 'Num',
capsLock: 'Bloq Mayús',
capsLockShort: 'May',
scrollLock: 'Bloq Despl',
scrollLockShort: 'Despl',
on: 'Activado',
off: 'Desactivado',
unknown: 'Desconocido'
},
device: { device: {
title: 'Dispositivo', title: 'Dispositivo',
oled: { oled: {

View File

@@ -337,10 +337,26 @@ const fr = {
modeOff: 'Désactivé', modeOff: 'Désactivé',
modeAuto: 'Masquer automatiquement', modeAuto: 'Masquer automatiquement',
modeAlways: 'Toujours visible', modeAlways: 'Toujours visible',
keyboardLedStatus: 'Indicateurs de verrouillage du clavier',
keyboardLedStatusDesc:
'Afficher létat de Verr Num, Verr Maj et Arrêt défil du poste distant',
icons: 'Icônes du sous-menu', icons: 'Icônes du sous-menu',
iconsDesc: 'Afficher les icônes des sous-menus dans la barre de menus' iconsDesc: 'Afficher les icônes des sous-menus dans la barre de menus'
} }
}, },
keyboardLedStatus: {
groupLabel: 'État des verrouillages du clavier distant',
indicatorLabel: '{{label}} : {{state}}',
numLock: 'Verr Num',
numLockShort: 'Num',
capsLock: 'Verr Maj',
capsLockShort: 'Maj',
scrollLock: 'Arrêt défil',
scrollLockShort: 'Défil',
on: 'Activé',
off: 'Désactivé',
unknown: 'Inconnu'
},
device: { device: {
title: 'Appareil', title: 'Appareil',
oled: { oled: {

View File

@@ -336,10 +336,26 @@ const hu = {
modeOff: 'Ki', modeOff: 'Ki',
modeAuto: 'Automatikus elrejtés', modeAuto: 'Automatikus elrejtés',
modeAlways: 'Mindig látható', modeAlways: 'Mindig látható',
keyboardLedStatus: 'Billentyűzár-jelzők',
keyboardLedStatusDesc:
'A távoli számítógép Num Lock, Caps Lock és Scroll Lock állapotának megjelenítése',
icons: 'Almenü ikonok', icons: 'Almenü ikonok',
iconsDesc: 'Almenüikonok megjelenítése a menüsorban' iconsDesc: 'Almenüikonok megjelenítése a menüsorban'
} }
}, },
keyboardLedStatus: {
groupLabel: 'Távoli billentyűzárak állapota',
indicatorLabel: '{{label}}: {{state}}',
numLock: 'Num Lock',
numLockShort: 'Num',
capsLock: 'Caps Lock',
capsLockShort: 'Caps',
scrollLock: 'Scroll Lock',
scrollLockShort: 'Scr',
on: 'Be',
off: 'Ki',
unknown: 'Ismeretlen'
},
device: { device: {
title: 'Eszköz', title: 'Eszköz',
oled: { oled: {

View File

@@ -334,10 +334,26 @@ const id = {
modeOff: 'Mati', modeOff: 'Mati',
modeAuto: 'Sembunyikan otomatis', modeAuto: 'Sembunyikan otomatis',
modeAlways: 'Selalu terlihat', modeAlways: 'Selalu terlihat',
keyboardLedStatus: 'Indikator kunci keyboard',
keyboardLedStatusDesc:
'Tampilkan status Num Lock, Caps Lock, dan Scroll Lock komputer jarak jauh',
icons: 'Ikon Submenu', icons: 'Ikon Submenu',
iconsDesc: 'Menampilkan ikon submenu di bilah menu' iconsDesc: 'Menampilkan ikon submenu di bilah menu'
} }
}, },
keyboardLedStatus: {
groupLabel: 'Status kunci keyboard jarak jauh',
indicatorLabel: '{{label}}: {{state}}',
numLock: 'Num Lock',
numLockShort: 'Num',
capsLock: 'Caps Lock',
capsLockShort: 'Caps',
scrollLock: 'Scroll Lock',
scrollLockShort: 'Scr',
on: 'Aktif',
off: 'Nonaktif',
unknown: 'Tidak diketahui'
},
device: { device: {
title: 'Perangkat', title: 'Perangkat',
oled: { oled: {

View File

@@ -336,10 +336,26 @@ const it = {
modeOff: 'Spento', modeOff: 'Spento',
modeAuto: 'Nascondi automaticamente', modeAuto: 'Nascondi automaticamente',
modeAlways: 'Sempre visibile', modeAlways: 'Sempre visibile',
keyboardLedStatus: 'Indicatori di blocco della tastiera',
keyboardLedStatusDesc:
'Mostra lo stato di Bloc Num, Bloc Maiusc e Bloc Scorr del computer remoto',
icons: 'Icone dei sottomenu', icons: 'Icone dei sottomenu',
iconsDesc: 'Visualizza le icone dei sottomenu nella barra dei menu' iconsDesc: 'Visualizza le icone dei sottomenu nella barra dei menu'
} }
}, },
keyboardLedStatus: {
groupLabel: 'Stato dei blocchi della tastiera remota',
indicatorLabel: '{{label}}: {{state}}',
numLock: 'Bloc Num',
numLockShort: 'Num',
capsLock: 'Bloc Maiusc',
capsLockShort: 'Mai',
scrollLock: 'Bloc Scorr',
scrollLockShort: 'Scorr',
on: 'Attivo',
off: 'Disattivo',
unknown: 'Sconosciuto'
},
device: { device: {
title: 'Dispositivo', title: 'Dispositivo',
oled: { oled: {

View File

@@ -335,10 +335,26 @@ const ja = {
modeOff: '閉じる', modeOff: '閉じる',
modeAuto: '自動非表示', modeAuto: '自動非表示',
modeAlways: '常に表示', modeAlways: '常に表示',
keyboardLedStatus: 'キーボードロックの表示',
keyboardLedStatusDesc:
'リモートコンピューターの Num Lock、Caps Lock、Scroll Lock の状態を表示',
icons: 'メニューアイコン', icons: 'メニューアイコン',
iconsDesc: 'メニューバーでのサブメニューアイコンの表示' iconsDesc: 'メニューバーでのサブメニューアイコンの表示'
} }
}, },
keyboardLedStatus: {
groupLabel: 'リモートキーボードのロック状態',
indicatorLabel: '{{label}}{{state}}',
numLock: 'Num Lock',
numLockShort: 'Num',
capsLock: 'Caps Lock',
capsLockShort: 'Caps',
scrollLock: 'Scroll Lock',
scrollLockShort: 'Scr',
on: 'オン',
off: 'オフ',
unknown: '不明'
},
device: { device: {
title: 'デバイス', title: 'デバイス',
oled: { oled: {

View File

@@ -330,10 +330,25 @@ const ko = {
modeOff: '꺼짐', modeOff: '꺼짐',
modeAuto: '자동 숨기기', modeAuto: '자동 숨기기',
modeAlways: '항상 보이기', modeAlways: '항상 보이기',
keyboardLedStatus: '키보드 잠금 표시기',
keyboardLedStatusDesc: '원격 컴퓨터의 Num Lock, Caps Lock, Scroll Lock 상태 표시',
icons: '하위 메뉴 아이콘', icons: '하위 메뉴 아이콘',
iconsDesc: '메뉴 바에 하위 메뉴 아이콘을 표시합니다' iconsDesc: '메뉴 바에 하위 메뉴 아이콘을 표시합니다'
} }
}, },
keyboardLedStatus: {
groupLabel: '원격 키보드 잠금 상태',
indicatorLabel: '{{label}}: {{state}}',
numLock: 'Num Lock',
numLockShort: 'Num',
capsLock: 'Caps Lock',
capsLockShort: 'Caps',
scrollLock: 'Scroll Lock',
scrollLockShort: 'Scr',
on: '켜짐',
off: '꺼짐',
unknown: '알 수 없음'
},
device: { device: {
title: '장치', title: '장치',
oled: { oled: {

View File

@@ -333,10 +333,26 @@ const nb = {
modeOff: 'Av', modeOff: 'Av',
modeAuto: 'Skjul automatisk', modeAuto: 'Skjul automatisk',
modeAlways: 'Alltid synlig', modeAlways: 'Alltid synlig',
keyboardLedStatus: 'Indikatorer for tastaturlås',
keyboardLedStatusDesc:
'Vis Num Lock-, Caps Lock- og Scroll Lock-status for den eksterne datamaskinen',
icons: 'Undermenyikoner', icons: 'Undermenyikoner',
iconsDesc: 'Vis undermenyikoner i menylinjen' iconsDesc: 'Vis undermenyikoner i menylinjen'
} }
}, },
keyboardLedStatus: {
groupLabel: 'Status for låser på eksternt tastatur',
indicatorLabel: '{{label}}: {{state}}',
numLock: 'Num Lock',
numLockShort: 'Num',
capsLock: 'Caps Lock',
capsLockShort: 'Caps',
scrollLock: 'Scroll Lock',
scrollLockShort: 'Scr',
on: 'På',
off: 'Av',
unknown: 'Ukjent'
},
device: { device: {
title: 'Enhet', title: 'Enhet',
oled: { oled: {

View File

@@ -336,10 +336,26 @@ const nl = {
modeOff: 'Uit', modeOff: 'Uit',
modeAuto: 'Automatisch verbergen', modeAuto: 'Automatisch verbergen',
modeAlways: 'Altijd zichtbaar', modeAlways: 'Altijd zichtbaar',
keyboardLedStatus: 'Toetsvergrendelingsindicatoren',
keyboardLedStatusDesc:
'Toon de Num Lock-, Caps Lock- en Scroll Lock-status van de externe computer',
icons: 'Submenupictogrammen', icons: 'Submenupictogrammen',
iconsDesc: 'Submenupictogrammen weergeven in de menubalk' iconsDesc: 'Submenupictogrammen weergeven in de menubalk'
} }
}, },
keyboardLedStatus: {
groupLabel: 'Toetsvergrendelingsstatus van extern toetsenbord',
indicatorLabel: '{{label}}: {{state}}',
numLock: 'Num Lock',
numLockShort: 'Num',
capsLock: 'Caps Lock',
capsLockShort: 'Caps',
scrollLock: 'Scroll Lock',
scrollLockShort: 'Scr',
on: 'Aan',
off: 'Uit',
unknown: 'Onbekend'
},
device: { device: {
title: 'Apparaat', title: 'Apparaat',
oled: { oled: {

View File

@@ -335,10 +335,26 @@ const pl = {
modeOff: 'Wyłączone', modeOff: 'Wyłączone',
modeAuto: 'Automatyczne ukrywanie', modeAuto: 'Automatyczne ukrywanie',
modeAlways: 'Zawsze widoczny', modeAlways: 'Zawsze widoczny',
keyboardLedStatus: 'Wskaźniki blokad klawiatury',
keyboardLedStatusDesc:
'Wyświetl stan Num Lock, Caps Lock i Scroll Lock zdalnego komputera',
icons: 'Ikony podmenu', icons: 'Ikony podmenu',
iconsDesc: 'Wyświetla ikony podmenu na pasku menu' iconsDesc: 'Wyświetla ikony podmenu na pasku menu'
} }
}, },
keyboardLedStatus: {
groupLabel: 'Stan blokad zdalnej klawiatury',
indicatorLabel: '{{label}}: {{state}}',
numLock: 'Num Lock',
numLockShort: 'Num',
capsLock: 'Caps Lock',
capsLockShort: 'Caps',
scrollLock: 'Scroll Lock',
scrollLockShort: 'Scr',
on: 'Włączone',
off: 'Wyłączone',
unknown: 'Nieznany'
},
device: { device: {
title: 'Urządzenie', title: 'Urządzenie',
oled: { oled: {

View File

@@ -334,10 +334,26 @@ const pt_br = {
modeOff: 'Desligado', modeOff: 'Desligado',
modeAuto: 'Ocultar automaticamente', modeAuto: 'Ocultar automaticamente',
modeAlways: 'Sempre visível', modeAlways: 'Sempre visível',
keyboardLedStatus: 'Indicadores de bloqueio do teclado',
keyboardLedStatusDesc:
'Exibir o estado de Num Lock, Caps Lock e Scroll Lock do computador remoto',
icons: 'Ícones do submenu', icons: 'Ícones do submenu',
iconsDesc: 'Exibir ícones de submenus na barra de menu' iconsDesc: 'Exibir ícones de submenus na barra de menu'
} }
}, },
keyboardLedStatus: {
groupLabel: 'Estado dos bloqueios do teclado remoto',
indicatorLabel: '{{label}}: {{state}}',
numLock: 'Num Lock',
numLockShort: 'Num',
capsLock: 'Caps Lock',
capsLockShort: 'Caps',
scrollLock: 'Scroll Lock',
scrollLockShort: 'Scr',
on: 'Ativado',
off: 'Desativado',
unknown: 'Desconhecido'
},
device: { device: {
title: 'Dispositivo', title: 'Dispositivo',
oled: { oled: {

View File

@@ -335,10 +335,26 @@ const ru = {
modeOff: 'Выкл.', modeOff: 'Выкл.',
modeAuto: 'Автоматическое скрытие', modeAuto: 'Автоматическое скрытие',
modeAlways: 'Всегда виден', modeAlways: 'Всегда виден',
keyboardLedStatus: 'Индикаторы блокировки клавиатуры',
keyboardLedStatusDesc:
'Показывать состояние Num Lock, Caps Lock и Scroll Lock удалённого компьютера',
icons: 'Значки подменю', icons: 'Значки подменю',
iconsDesc: 'Отображение значков подменю в строке меню' iconsDesc: 'Отображение значков подменю в строке меню'
} }
}, },
keyboardLedStatus: {
groupLabel: 'Состояние блокировок удалённой клавиатуры',
indicatorLabel: '{{label}}: {{state}}',
numLock: 'Num Lock',
numLockShort: 'Num',
capsLock: 'Caps Lock',
capsLockShort: 'Caps',
scrollLock: 'Scroll Lock',
scrollLockShort: 'Scr',
on: 'Вкл.',
off: 'Выкл.',
unknown: 'Неизвестно'
},
device: { device: {
title: 'Устройство', title: 'Устройство',
oled: { oled: {

View File

@@ -331,10 +331,26 @@ const se = {
modeOff: 'Av', modeOff: 'Av',
modeAuto: 'Dölj automatiskt', modeAuto: 'Dölj automatiskt',
modeAlways: 'Alltid synlig', modeAlways: 'Alltid synlig',
keyboardLedStatus: 'Indikatorer för tangentbordslås',
keyboardLedStatusDesc:
'Visa Num Lock-, Caps Lock- och Scroll Lock-status för fjärrdatorn',
icons: 'Undermenyikoner', icons: 'Undermenyikoner',
iconsDesc: 'Visa undermenyikoner i menyraden' iconsDesc: 'Visa undermenyikoner i menyraden'
} }
}, },
keyboardLedStatus: {
groupLabel: 'Status för lås på fjärrtangentbord',
indicatorLabel: '{{label}}: {{state}}',
numLock: 'Num Lock',
numLockShort: 'Num',
capsLock: 'Caps Lock',
capsLockShort: 'Caps',
scrollLock: 'Scroll Lock',
scrollLockShort: 'Scr',
on: 'På',
off: 'Av',
unknown: 'Okänd'
},
device: { device: {
title: 'Enhet', title: 'Enhet',
oled: { oled: {

View File

@@ -327,10 +327,26 @@ const th = {
modeOff: 'ปิด', modeOff: 'ปิด',
modeAuto: 'ซ่อนอัตโนมัติ', modeAuto: 'ซ่อนอัตโนมัติ',
modeAlways: 'มองเห็นได้เสมอ', modeAlways: 'มองเห็นได้เสมอ',
keyboardLedStatus: 'ตัวบ่งชี้ปุ่มล็อกแป้นพิมพ์',
keyboardLedStatusDesc:
'แสดงสถานะ Num Lock, Caps Lock และ Scroll Lock ของคอมพิวเตอร์ระยะไกล',
icons: 'ไอคอนเมนูย่อย', icons: 'ไอคอนเมนูย่อย',
iconsDesc: 'แสดงไอคอนเมนูย่อยในแถบเมนู' iconsDesc: 'แสดงไอคอนเมนูย่อยในแถบเมนู'
} }
}, },
keyboardLedStatus: {
groupLabel: 'สถานะปุ่มล็อกแป้นพิมพ์ระยะไกล',
indicatorLabel: '{{label}}: {{state}}',
numLock: 'Num Lock',
numLockShort: 'Num',
capsLock: 'Caps Lock',
capsLockShort: 'Caps',
scrollLock: 'Scroll Lock',
scrollLockShort: 'Scr',
on: 'เปิด',
off: 'ปิด',
unknown: 'ไม่ทราบ'
},
device: { device: {
title: 'อุปกรณ์', title: 'อุปกรณ์',
oled: { oled: {

View File

@@ -333,10 +333,26 @@ const tr = {
modeOff: 'Kapalı', modeOff: 'Kapalı',
modeAuto: 'Otomatik gizle', modeAuto: 'Otomatik gizle',
modeAlways: 'Her zaman görünür', modeAlways: 'Her zaman görünür',
keyboardLedStatus: 'Klavye kilidi göstergeleri',
keyboardLedStatusDesc:
'Uzak bilgisayarın Num Lock, Caps Lock ve Scroll Lock durumunu göster',
icons: 'Alt Menü Simgeleri', icons: 'Alt Menü Simgeleri',
iconsDesc: 'Menü çubuğunda alt menü simgelerini görüntüle' iconsDesc: 'Menü çubuğunda alt menü simgelerini görüntüle'
} }
}, },
keyboardLedStatus: {
groupLabel: 'Uzak klavye kilidi durumu',
indicatorLabel: '{{label}}: {{state}}',
numLock: 'Num Lock',
numLockShort: 'Num',
capsLock: 'Caps Lock',
capsLockShort: 'Caps',
scrollLock: 'Scroll Lock',
scrollLockShort: 'Scr',
on: 'Açık',
off: 'Kapalı',
unknown: 'Bilinmiyor'
},
device: { device: {
title: 'Cihaz', title: 'Cihaz',
oled: { oled: {

View File

@@ -334,10 +334,26 @@ const uk = {
modeOff: 'Вимк', modeOff: 'Вимк',
modeAuto: 'Автоматичне приховування', modeAuto: 'Автоматичне приховування',
modeAlways: 'Завжди видно', modeAlways: 'Завжди видно',
keyboardLedStatus: 'Індикатори блокування клавіатури',
keyboardLedStatusDesc:
'Показувати стан Num Lock, Caps Lock і Scroll Lock віддаленого комп’ютера',
icons: 'Значки підменю', icons: 'Значки підменю',
iconsDesc: 'Відображення значків підменю на панелі меню' iconsDesc: 'Відображення значків підменю на панелі меню'
} }
}, },
keyboardLedStatus: {
groupLabel: 'Стан блокувань віддаленої клавіатури',
indicatorLabel: '{{label}}: {{state}}',
numLock: 'Num Lock',
numLockShort: 'Num',
capsLock: 'Caps Lock',
capsLockShort: 'Caps',
scrollLock: 'Scroll Lock',
scrollLockShort: 'Scr',
on: 'Увімкнено',
off: 'Вимкнено',
unknown: 'Невідомо'
},
device: { device: {
title: 'Пристрій', title: 'Пристрій',
oled: { oled: {

View File

@@ -331,10 +331,26 @@ const vi = {
modeOff: 'Tắt', modeOff: 'Tắt',
modeAuto: 'Tự động ẩn', modeAuto: 'Tự động ẩn',
modeAlways: 'Luôn hiển thị', modeAlways: 'Luôn hiển thị',
keyboardLedStatus: 'Chỉ báo khóa bàn phím',
keyboardLedStatusDesc:
'Hiển thị trạng thái Num Lock, Caps Lock và Scroll Lock của máy tính từ xa',
icons: 'Biểu tượng menu con', icons: 'Biểu tượng menu con',
iconsDesc: 'Hiển thị biểu tượng menu con trên thanh menu' iconsDesc: 'Hiển thị biểu tượng menu con trên thanh menu'
} }
}, },
keyboardLedStatus: {
groupLabel: 'Trạng thái khóa bàn phím từ xa',
indicatorLabel: '{{label}}: {{state}}',
numLock: 'Num Lock',
numLockShort: 'Num',
capsLock: 'Caps Lock',
capsLockShort: 'Caps',
scrollLock: 'Scroll Lock',
scrollLockShort: 'Scr',
on: 'Bật',
off: 'Tắt',
unknown: 'Không rõ'
},
device: { device: {
title: 'Thiết bị', title: 'Thiết bị',
oled: { oled: {

View File

@@ -322,10 +322,25 @@ const zh = {
modeOff: '关闭', modeOff: '关闭',
modeAuto: '自动隐藏', modeAuto: '自动隐藏',
modeAlways: '始终显示', modeAlways: '始终显示',
keyboardLedStatus: '键盘锁定状态指示灯',
keyboardLedStatusDesc: '显示远程主机的 Num Lock、Caps Lock 和 Scroll Lock 状态',
icons: '菜单图标', icons: '菜单图标',
iconsDesc: '是否在菜单栏中显示子菜单图标' iconsDesc: '是否在菜单栏中显示子菜单图标'
} }
}, },
keyboardLedStatus: {
groupLabel: '远程键盘锁定状态',
indicatorLabel: '{{label}}{{state}}',
numLock: '数字锁定',
numLockShort: '数',
capsLock: '大写锁定',
capsLockShort: '大',
scrollLock: '滚动锁定',
scrollLockShort: '滚',
on: '开启',
off: '关闭',
unknown: '未知'
},
device: { device: {
title: '设备', title: '设备',
oled: { oled: {

View File

@@ -322,10 +322,25 @@ const zh_tw = {
modeOff: '關閉', modeOff: '關閉',
modeAuto: '自動隱藏', modeAuto: '自動隱藏',
modeAlways: '始終顯示', modeAlways: '始終顯示',
keyboardLedStatus: '鍵盤鎖定狀態指示燈',
keyboardLedStatusDesc: '顯示遠端電腦的 Num Lock、Caps Lock 與 Scroll Lock 狀態',
icons: '選單圖示', icons: '選單圖示',
iconsDesc: '是否在選單欄中顯示子選單圖示' iconsDesc: '是否在選單欄中顯示子選單圖示'
} }
}, },
keyboardLedStatus: {
groupLabel: '遠端鍵盤鎖定狀態',
indicatorLabel: '{{label}}{{state}}',
numLock: '數字鎖定',
numLockShort: '數',
capsLock: '大寫鎖定',
capsLockShort: '大',
scrollLock: '捲動鎖定',
scrollLockShort: '捲',
on: '開啟',
off: '關閉',
unknown: '未知'
},
device: { device: {
title: '設備', title: '設備',
oled: { oled: {

View File

@@ -11,3 +11,6 @@ export const webTitleAtom = atom('');
// menu display mode: 'off' | 'auto' | 'always' // menu display mode: 'off' | 'auto' | 'always'
export const menuDisplayModeAtom = atom<string>('auto'); export const menuDisplayModeAtom = atom<string>('auto');
// show the remote keyboard lock-status indicator beside the menu bar
export const keyboardLedStatusVisibleAtom = atom(true);

View File

@@ -18,6 +18,7 @@ const KEYBOARD_LANGUAGE_KEY = 'nano-kvm-keyboard-language';
const SKIP_MODIFY_PASSWORD_KEY = 'nano-kvm-skip-modify-password'; const SKIP_MODIFY_PASSWORD_KEY = 'nano-kvm-skip-modify-password';
const MENU_DISABLED_ITEMS_KEY = 'nano-kvm-menu-disabled-items'; const MENU_DISABLED_ITEMS_KEY = 'nano-kvm-menu-disabled-items';
const MENU_AUTO_HIDE_KEY = 'nano-kvm-menu-auto-hide'; const MENU_AUTO_HIDE_KEY = 'nano-kvm-menu-auto-hide';
const KEYBOARD_LED_STATUS_VISIBLE_KEY = 'nano-kvm-keyboard-led-status-visible';
const POWER_CONFIRM_KEY = 'nano-kvm-power-confirm'; const POWER_CONFIRM_KEY = 'nano-kvm-power-confirm';
type ItemWithExpiry = { type ItemWithExpiry = {
@@ -222,6 +223,15 @@ export function setMenuDisplayMode(mode: string) {
localStorage.setItem(MENU_AUTO_HIDE_KEY, mode); localStorage.setItem(MENU_AUTO_HIDE_KEY, mode);
} }
export function getKeyboardLedStatusVisible(): boolean {
const value = localStorage.getItem(KEYBOARD_LED_STATUS_VISIBLE_KEY);
return value !== 'false';
}
export function setKeyboardLedStatusVisible(visible: boolean) {
localStorage.setItem(KEYBOARD_LED_STATUS_VISIBLE_KEY, String(visible));
}
export function getPowerConfirm() { export function getPowerConfirm() {
const enabled = localStorage.getItem(POWER_CONFIRM_KEY); const enabled = localStorage.getItem(POWER_CONFIRM_KEY);
return enabled === 'true'; return enabled === 'true';

View File

@@ -0,0 +1,68 @@
import { Tooltip } from 'antd';
import clsx from 'clsx';
import { useKeyboardLedStatus } from './use-keyboard-led-status';
const LOCK_INDICATORS = {
numLock: { label: 'Num Lock', shortLabel: 'Num' },
capsLock: { label: 'Caps Lock', shortLabel: 'Caps' },
scrollLock: { label: 'Scroll Lock', shortLabel: 'Scr' }
} as const;
type LockIndicatorProps = {
labelKey: 'numLock' | 'capsLock' | 'scrollLock';
active: boolean;
known: boolean;
};
function LockIndicator({ labelKey, active, known }: LockIndicatorProps) {
const { label, shortLabel } = LOCK_INDICATORS[labelKey];
const state = known ? (active ? 'On' : 'Off') : 'Unknown';
const indicatorLabel = `${label}: ${state}`;
return (
<Tooltip
title={indicatorLabel}
placement="bottom"
mouseEnterDelay={0.6}
>
<div
className="flex h-[8px] items-center gap-1 px-1 text-[8px] font-medium leading-[8px] text-neutral-400"
aria-label={indicatorLabel}
role="img"
>
<span
aria-hidden="true"
className={clsx(
'flex h-2 w-2 items-center justify-center rounded-full text-[7px] leading-none',
known
? active
? 'bg-emerald-400'
: 'bg-neutral-600'
: 'border border-dashed border-neutral-500 text-neutral-300'
)}
>
{!known && '?'}
</span>
<span className="hidden sm:inline">{shortLabel}</span>
</div>
</Tooltip>
);
}
export function KeyboardLedStatus() {
const status = useKeyboardLedStatus();
const known = status?.known ?? false;
return (
<div
className="flex h-full w-[40px] flex-col items-start justify-center rounded bg-neutral-800/80"
aria-label="Keyboard lock status"
role="group"
>
<LockIndicator labelKey="numLock" active={status?.numLock ?? false} known={known} />
<LockIndicator labelKey="capsLock" active={status?.capsLock ?? false} known={known} />
<LockIndicator labelKey="scrollLock" active={status?.scrollLock ?? false} known={known} />
</div>
);
}

View File

@@ -0,0 +1,69 @@
export const KEYBOARD_LED_STATUS_EVENT = 'hid-led-status';
export type KeyboardLedStatus = {
numLock: boolean;
capsLock: boolean;
scrollLock: boolean;
known: boolean;
updatedAt: string;
};
export function parseKeyboardLedStatus(value: unknown): KeyboardLedStatus | null {
if (!value || typeof value !== 'object') {
return null;
}
const status = value as Partial<KeyboardLedStatus>;
if (
typeof status.numLock !== 'boolean' ||
typeof status.capsLock !== 'boolean' ||
typeof status.scrollLock !== 'boolean' ||
typeof status.known !== 'boolean'
) {
return null;
}
return {
numLock: status.numLock,
capsLock: status.capsLock,
scrollLock: status.scrollLock,
known: status.known,
updatedAt: typeof status.updatedAt === 'string' ? status.updatedAt : ''
};
}
export function parseKeyboardLedStatusMessage(message: {
data: unknown;
}): KeyboardLedStatus | null {
if (typeof message.data !== 'string') {
return null;
}
try {
const envelope = JSON.parse(message.data) as { data?: unknown };
if (typeof envelope.data !== 'string') {
return null;
}
return parseKeyboardLedStatus(JSON.parse(envelope.data));
} catch (error) {
console.log(error);
return null;
}
}
export function shouldAcceptKeyboardLedStatus(
current: KeyboardLedStatus | null,
next: KeyboardLedStatus
) {
if (!current) {
return true;
}
return getTimestamp(next) >= getTimestamp(current);
}
function getTimestamp(status: KeyboardLedStatus) {
const timestamp = Date.parse(status.updatedAt);
return Number.isFinite(timestamp) ? timestamp : 0;
}

View File

@@ -0,0 +1,57 @@
import { useEffect, useRef, useState } from 'react';
import { getKeyboardLedStatus } from '@/api/hid.ts';
import { client } from '@/lib/websocket.ts';
import {
KEYBOARD_LED_STATUS_EVENT,
KeyboardLedStatus,
parseKeyboardLedStatus,
parseKeyboardLedStatusMessage,
shouldAcceptKeyboardLedStatus
} from './model';
export function useKeyboardLedStatus() {
const [status, setStatus] = useState<KeyboardLedStatus | null>(null);
const latestStatusRef = useRef<KeyboardLedStatus | null>(null);
useEffect(() => {
let disposed = false;
function update(next: KeyboardLedStatus) {
if (!shouldAcceptKeyboardLedStatus(latestStatusRef.current, next)) {
return;
}
latestStatusRef.current = next;
setStatus(next);
}
const unsubscribe = client.on(KEYBOARD_LED_STATUS_EVENT, (message) => {
const next = parseKeyboardLedStatusMessage(message);
if (next) {
update(next);
}
});
getKeyboardLedStatus()
.then((rsp) => {
if (rsp.code !== 0) {
return;
}
const next = parseKeyboardLedStatus(rsp.data);
if (!disposed && next) {
update(next);
}
})
.catch(() => undefined);
return () => {
disposed = true;
unsubscribe();
};
}, []);
return status;
}

View File

@@ -5,10 +5,11 @@ import { useAtomValue } from 'jotai';
import { GripVerticalIcon } from 'lucide-react'; import { GripVerticalIcon } from 'lucide-react';
import Draggable, { DraggableData, DraggableEvent } from 'react-draggable'; import Draggable, { DraggableData, DraggableEvent } from 'react-draggable';
import { menuDisabledItemsAtom } from '@/jotai/settings.ts'; import { keyboardLedStatusVisibleAtom, menuDisabledItemsAtom } from '@/jotai/settings.ts';
import { useMenuBounds } from '@/hooks/useMenuBounds.ts'; import { useMenuBounds } from '@/hooks/useMenuBounds.ts';
import { useMenuVisibility } from '@/hooks/useMenuVisibility.ts'; import { useMenuVisibility } from '@/hooks/useMenuVisibility.ts';
import { KeyboardLedStatus } from '../keyboard-led-status';
import { DownloadImage } from './download.tsx'; import { DownloadImage } from './download.tsx';
import { Fullscreen } from './fullscreen'; import { Fullscreen } from './fullscreen';
import { Image } from './image'; import { Image } from './image';
@@ -27,6 +28,7 @@ export const Menu = () => {
const nodeRef = useRef<HTMLDivElement | null>(null); const nodeRef = useRef<HTMLDivElement | null>(null);
const menuDisabledItems = useAtomValue(menuDisabledItemsAtom); const menuDisabledItems = useAtomValue(menuDisabledItemsAtom);
const isKeyboardLedStatusVisible = useAtomValue(keyboardLedStatusVisibleAtom);
const { const {
isInitialized, isInitialized,
@@ -75,11 +77,21 @@ export const Menu = () => {
<div className="sticky top-[10px] flex w-full justify-center"> <div className="sticky top-[10px] flex w-full justify-center">
<div <div
className={clsx( className={clsx(
'h-[36px] items-center rounded bg-neutral-800/80 pl-1 pr-2 transition-all duration-300', 'relative h-[36px] items-center rounded bg-neutral-800/80 pl-1 pr-2 transition-all duration-300',
isMenuExpanded ? 'flex' : 'hidden', isMenuExpanded ? 'flex' : 'hidden',
isMenuHidden ? '-translate-y-[110%] opacity-80' : 'translate-y-0 opacity-100' isMenuHidden ? '-translate-y-[110%] opacity-80' : 'translate-y-0 opacity-100'
)} )}
> >
{isMenuExpanded && isKeyboardLedStatusVisible && (
<div
className={clsx(
'absolute inset-y-0 right-full mr-1 transition-all duration-300',
isMenuHidden ? 'pointer-events-none opacity-0' : 'opacity-100'
)}
>
<KeyboardLedStatus />
</div>
)}
<strong> <strong>
<div className="flex h-[30px] cursor-move select-none items-center justify-center pl-1 text-neutral-500"> <div className="flex h-[30px] cursor-move select-none items-center justify-center pl-1 text-neutral-500">
<GripVerticalIcon size={18} /> <GripVerticalIcon size={18} />

View File

@@ -1,6 +1,7 @@
import { Divider } from 'antd'; import { Divider } from 'antd';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { KeyboardLedStatusSetting } from './keyboard-led-status.tsx';
import { Language } from './language.tsx'; import { Language } from './language.tsx';
import { MenuIcons } from './menu-icons.tsx'; import { MenuIcons } from './menu-icons.tsx';
import { MenuMode } from './menu-mode.tsx'; import { MenuMode } from './menu-mode.tsx';
@@ -22,6 +23,7 @@ export const Appearance = () => {
<div className="text-neutral-400">{t('settings.appearance.menuBar.title')}</div> <div className="text-neutral-400">{t('settings.appearance.menuBar.title')}</div>
<MenuMode /> <MenuMode />
<KeyboardLedStatusSetting />
<MenuIcons /> <MenuIcons />
</> </>
); );

View File

@@ -0,0 +1,31 @@
import { Switch } from 'antd';
import { useAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
import * as storage from '@/lib/localstorage.ts';
import { keyboardLedStatusVisibleAtom } from '@/jotai/settings.ts';
export const KeyboardLedStatusSetting = () => {
const { t } = useTranslation();
const [visible, setVisible] = useAtom(keyboardLedStatusVisibleAtom);
function update(nextVisible: boolean) {
setVisible(nextVisible);
storage.setKeyboardLedStatusVisible(nextVisible);
}
return (
<div className="mt-5 flex w-full items-center justify-between">
<div className="flex flex-col">
<span className="text-neutral-400">
{t('settings.appearance.menuBar.keyboardLedStatus')}
</span>
<span className="text-xs text-neutral-500">
{t('settings.appearance.menuBar.keyboardLedStatusDesc')}
</span>
</div>
<Switch checked={visible} onChange={update} />
</div>
);
};