From eb20fb41aae33fd6d79b99b2930c6edffb7a99f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=82=86=E6=9C=88?= <2835601846@qq.com> Date: Tue, 4 Aug 2026 09:28:14 +0800 Subject: [PATCH] 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 --- server/proto/hid.go | 8 + server/router/hid.go | 1 + server/service/hid/hid.go | 72 ++++- server/service/hid/leds.go | 279 ++++++++++++++++++ server/service/hid/leds_test.go | 168 +++++++++++ server/service/hid/status.go | 17 ++ server/service/ws/client.go | 111 ++++++- server/service/ws/keyboard_led_status.go | 94 ++++++ server/service/ws/keyboard_led_status_test.go | 79 +++++ server/service/ws/service.go | 1 + server/service/ws/types.go | 27 +- web/src/api/hid.ts | 5 + web/src/hooks/useMenuVisibility.ts | 10 +- web/src/i18n/locales/ca.ts | 16 + web/src/i18n/locales/cz.ts | 16 + web/src/i18n/locales/da.ts | 16 + web/src/i18n/locales/de.ts | 16 + web/src/i18n/locales/en.ts | 15 + web/src/i18n/locales/es.ts | 16 + web/src/i18n/locales/fr.ts | 16 + web/src/i18n/locales/hu.ts | 16 + web/src/i18n/locales/id.ts | 16 + web/src/i18n/locales/it.ts | 16 + web/src/i18n/locales/ja.ts | 16 + web/src/i18n/locales/ko.ts | 15 + web/src/i18n/locales/nb.ts | 16 + web/src/i18n/locales/nl.ts | 16 + web/src/i18n/locales/pl.ts | 16 + web/src/i18n/locales/pt_br.ts | 16 + web/src/i18n/locales/ru.ts | 16 + web/src/i18n/locales/se.ts | 16 + web/src/i18n/locales/th.ts | 16 + web/src/i18n/locales/tr.ts | 16 + web/src/i18n/locales/uk.ts | 16 + web/src/i18n/locales/vi.ts | 16 + web/src/i18n/locales/zh.ts | 15 + web/src/i18n/locales/zh_tw.ts | 15 + web/src/jotai/settings.ts | 3 + web/src/lib/localstorage.ts | 10 + .../desktop/keyboard-led-status/index.tsx | 68 +++++ .../desktop/keyboard-led-status/model.ts | 69 +++++ .../use-keyboard-led-status.ts | 57 ++++ web/src/pages/desktop/menu/index.tsx | 16 +- .../menu/settings/appearance/index.tsx | 2 + .../appearance/keyboard-led-status.tsx | 31 ++ 45 files changed, 1483 insertions(+), 25 deletions(-) create mode 100644 server/service/hid/leds.go create mode 100644 server/service/hid/leds_test.go create mode 100644 server/service/ws/keyboard_led_status.go create mode 100644 server/service/ws/keyboard_led_status_test.go create mode 100644 web/src/pages/desktop/keyboard-led-status/index.tsx create mode 100644 web/src/pages/desktop/keyboard-led-status/model.ts create mode 100644 web/src/pages/desktop/keyboard-led-status/use-keyboard-led-status.ts create mode 100644 web/src/pages/desktop/menu/settings/appearance/keyboard-led-status.tsx diff --git a/server/proto/hid.go b/server/proto/hid.go index ec167cb..5a04eed 100644 --- a/server/proto/hid.go +++ b/server/proto/hid.go @@ -4,6 +4,14 @@ type GetHidModeRsp struct { 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 { Mode string `validate:"required"` // normal or hid-only } diff --git a/server/router/hid.go b/server/router/hid.go index 665a57d..ac4e897 100644 --- a/server/router/hid.go +++ b/server/router/hid.go @@ -30,6 +30,7 @@ func hidRouter(r *gin.Engine) { api.GET("/hid/mode", service.GetHidMode) // get hid mode api.POST("/hid/mode", service.SetHidMode) // set hid mode api.POST("/hid/reset", service.ResetHid) // reset hid + api.GET("/hid/leds", service.GetKeyboardLedStatus) localAPI.POST("/usb/recover", service.RecoverUSB) } diff --git a/server/service/hid/hid.go b/server/service/hid/hid.go index 5b5fd27..192719e 100644 --- a/server/service/hid/hid.go +++ b/server/service/hid/hid.go @@ -13,11 +13,17 @@ import ( ) type Hid struct { - g0 *os.File - g1 *os.File - g2 *os.File - kbMutex sync.Mutex - mouseMutex sync.Mutex + g0 *os.File + g0Reader *os.File + g1 *os.File + g2 *os.File + ledReaderNotifyReader *os.File + ledReaderNotifyWriter *os.File + ledReaderNotifyReadFD int + ledReaderNotifyWriteFD int + kbMutex sync.Mutex + mouseMutex sync.Mutex + ledReaderStartOnce sync.Once } 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...) } @@ -162,6 +177,8 @@ func openNoLockWithRetry(open func() error, timeout, delay time.Duration) error } func (h *Hid) CloseNoLock() { + h.closeKeyboardLedReaderNoLock() + for _, device := range h.devices() { 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 // nonblocking descriptor. EAGAIN means the host is not accepting HID reports // 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 { 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() 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 device.path == HID0 { + h.closeKeyboardLedReaderNoLock() + } h.closeDeviceNoLock(device) switch { case errors.Is(err, os.ErrClosed): diff --git a/server/service/hid/leds.go b/server/service/hid/leds.go new file mode 100644 index 0000000..6305ee5 --- /dev/null +++ b/server/service/hid/leds.go @@ -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 + } + } +} diff --git a/server/service/hid/leds_test.go b/server/service/hid/leds_test.go new file mode 100644 index 0000000..6e12895 --- /dev/null +++ b/server/service/hid/leds_test.go @@ -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) + } +} diff --git a/server/service/hid/status.go b/server/service/hid/status.go index c373286..e31a12c 100644 --- a/server/service/hid/status.go +++ b/server/service/hid/status.go @@ -47,6 +47,23 @@ func (s *Service) GetHidMode(c *gin.Context) { 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) { var req proto.SetHidModeReq var rsp proto.Response diff --git a/server/service/ws/client.go b/server/service/ws/client.go index 97f0ce2..d213d06 100644 --- a/server/service/ws/client.go +++ b/server/service/ws/client.go @@ -29,13 +29,15 @@ const ( func NewClient(ws *websocket.Conn) *Client { client := &Client{ - ws: ws, - hid: hid.GetHid(), - manual: inputcontrol.NewManualSession(controlmode.GetManager(), inputcontrol.GetCoordinator()), - keyboard: make(chan hid.QueuedReport, 200), - mouse: make(chan hid.QueuedReport, 200), - heartbeatTimeout: clientHeartbeatTimeout, - lastHeartbeat: time.Time{}, + ws: ws, + hid: hid.GetHid(), + manual: inputcontrol.NewManualSession(controlmode.GetManager(), inputcontrol.GetCoordinator()), + keyboard: make(chan hid.QueuedReport, 200), + mouse: make(chan hid.QueuedReport, 200), + heartbeatTimeout: clientHeartbeatTimeout, + keyboardLedNotify: make(chan struct{}, 1), + keyboardLedDone: make(chan struct{}), + lastHeartbeat: time.Time{}, } client.hid.Open() @@ -44,6 +46,8 @@ func NewClient(ws *websocket.Conn) *Client { } func (c *Client) Start() { + c.startKeyboardLedStatusWorker() + c.workers.Add(2) go func() { defer c.workers.Done() @@ -194,6 +198,7 @@ func (c *Client) Close() { if c.ws != nil { _ = c.ws.Close() } + c.stopKeyboardLedStatusWorker() if c.keyboard != nil { close(c.keyboard) @@ -203,12 +208,104 @@ func (c *Client) Close() { } }) c.workers.Wait() + c.keyboardLedWorkers.Wait() if c.manual != nil { c.manual.Close() } 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 { if !sendQueue(queue, report) { report.Complete(false) diff --git a/server/service/ws/keyboard_led_status.go b/server/service/ws/keyboard_led_status.go new file mode 100644 index 0000000..42cb029 --- /dev/null +++ b/server/service/ws/keyboard_led_status.go @@ -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)) +} diff --git a/server/service/ws/keyboard_led_status_test.go b/server/service/ws/keyboard_led_status_test.go new file mode 100644 index 0000000..d573c2f --- /dev/null +++ b/server/service/ws/keyboard_led_status_test.go @@ -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") + } +} diff --git a/server/service/ws/service.go b/server/service/ws/service.go index 659efd8..6b1fa8d 100644 --- a/server/service/ws/service.go +++ b/server/service/ws/service.go @@ -39,6 +39,7 @@ func (s *Service) Connect(c *gin.Context) { sendCaptureStatusSnapshot(client) sendH264ModeStatusSnapshot(client) + sendKeyboardLedStatusSnapshot(client) client.Start() } diff --git a/server/service/ws/types.go b/server/service/ws/types.go index fa79295..3d3ceb5 100644 --- a/server/service/ws/types.go +++ b/server/service/ws/types.go @@ -16,16 +16,23 @@ type Manager struct { } type Client struct { - ws *websocket.Conn - hid *hid.Hid - manual *inputcontrol.ManualSession - keyboard chan hid.QueuedReport - mouse chan hid.QueuedReport - heartbeatTimeout time.Duration - lastHeartbeat time.Time - mutex sync.Mutex - closeOnce sync.Once - workers sync.WaitGroup + ws *websocket.Conn + hid *hid.Hid + manual *inputcontrol.ManualSession + keyboard chan hid.QueuedReport + mouse chan hid.QueuedReport + heartbeatTimeout time.Duration + lastHeartbeat time.Time + mutex sync.Mutex + keyboardLedMutex sync.Mutex + 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 { diff --git a/web/src/api/hid.ts b/web/src/api/hid.ts index 58f1caf..a300731 100644 --- a/web/src/api/hid.ts +++ b/web/src/api/hid.ts @@ -15,6 +15,11 @@ export function getHidMode() { return http.get('/api/hid/mode'); } +// get remote keyboard lock LED status +export function getKeyboardLedStatus() { + return http.get('/api/hid/leds'); +} + // set hid mode export function setHidMode(mode: string) { const data = { diff --git a/web/src/hooks/useMenuVisibility.ts b/web/src/hooks/useMenuVisibility.ts index c117b9d..e781797 100644 --- a/web/src/hooks/useMenuVisibility.ts +++ b/web/src/hooks/useMenuVisibility.ts @@ -1,8 +1,13 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; -import { getMenuDisabledItems, getMenuDisplayMode } from '@/lib/localstorage.ts'; import { + getKeyboardLedStatusVisible, + getMenuDisabledItems, + getMenuDisplayMode +} from '@/lib/localstorage.ts'; +import { + keyboardLedStatusVisibleAtom, menuDisabledItemsAtom, menuDisplayModeAtom, submenuOpenCountAtom @@ -23,6 +28,7 @@ export interface MenuVisibilityState { export function useMenuVisibility(): MenuVisibilityState { const [menuDisplayMode, setMenuDisplayMode] = useAtom(menuDisplayModeAtom); const setMenuDisabledItems = useSetAtom(menuDisabledItemsAtom); + const setKeyboardLedStatusVisible = useSetAtom(keyboardLedStatusVisibleAtom); const submenuOpenCount = useAtomValue(submenuOpenCountAtom); const [isMenuExpanded, setIsMenuExpanded] = useState(true); @@ -62,6 +68,8 @@ export function useMenuVisibility(): MenuVisibilityState { const items = getMenuDisabledItems(); setMenuDisabledItems(items); + setKeyboardLedStatusVisible(getKeyboardLedStatusVisible()); + if (displayMode === 'off') { setIsMenuExpanded(false); } diff --git a/web/src/i18n/locales/ca.ts b/web/src/i18n/locales/ca.ts index 237bbae..54d046b 100644 --- a/web/src/i18n/locales/ca.ts +++ b/web/src/i18n/locales/ca.ts @@ -332,10 +332,26 @@ const ca = { modeOff: 'Apagat', modeAuto: 'Ocultació automàtica', modeAlways: 'Sempre visible', + keyboardLedStatus: 'Indicadors de bloqueig del teclat', + keyboardLedStatusDesc: + 'Mostra l’estat de Bloq Num, Bloq Maj i Bloq Despl de l’ordinador remot', icons: 'Icones del submenú', 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: { title: 'Dispositiu', oled: { diff --git a/web/src/i18n/locales/cz.ts b/web/src/i18n/locales/cz.ts index 9aa8af8..6e43002 100644 --- a/web/src/i18n/locales/cz.ts +++ b/web/src/i18n/locales/cz.ts @@ -335,10 +335,26 @@ const cz = { modeOff: 'Vypnuto', modeAuto: 'Automatické skrytí', 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', 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: { title: 'Zařízení', oled: { diff --git a/web/src/i18n/locales/da.ts b/web/src/i18n/locales/da.ts index 4a49b12..49e4f14 100644 --- a/web/src/i18n/locales/da.ts +++ b/web/src/i18n/locales/da.ts @@ -332,10 +332,26 @@ const da = { modeOff: 'Fra', modeAuto: 'Skjul automatisk', modeAlways: 'Altid synlig', + keyboardLedStatus: 'Tastaturlåseindikatorer', + keyboardLedStatusDesc: + 'Vis Num Lock-, Caps Lock- og Scroll Lock-status for fjerncomputeren', icons: 'Undermenuikoner', 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: { title: 'Enhed', oled: { diff --git a/web/src/i18n/locales/de.ts b/web/src/i18n/locales/de.ts index a3b5ef2..ee93c3c 100644 --- a/web/src/i18n/locales/de.ts +++ b/web/src/i18n/locales/de.ts @@ -338,10 +338,26 @@ const de = { modeOff: 'Aus', modeAuto: 'Automatisch ausblenden', modeAlways: 'Immer sichtbar', + keyboardLedStatus: 'Tastensperren-Anzeigen', + keyboardLedStatusDesc: + 'Num-Lock-, Feststell- und Rollen-Status des Remote-Computers anzeigen', icons: 'Untermenüsymbole', 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: { title: 'Gerät', oled: { diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index d4d2d4d..4d0a835 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -331,10 +331,25 @@ const en = { modeOff: 'Off', modeAuto: 'Auto hide', modeAlways: 'Always visible', + keyboardLedStatus: 'Keyboard lock indicators', + keyboardLedStatusDesc: 'Display remote Num Lock, Caps Lock, and Scroll Lock status', icons: 'Submenu Icons', 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: { title: 'Device', oled: { diff --git a/web/src/i18n/locales/es.ts b/web/src/i18n/locales/es.ts index bc11fa8..93afbbd 100644 --- a/web/src/i18n/locales/es.ts +++ b/web/src/i18n/locales/es.ts @@ -335,10 +335,26 @@ const es = { modeOff: 'Apagado', modeAuto: 'Ocultar automáticamente', 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ú', 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: { title: 'Dispositivo', oled: { diff --git a/web/src/i18n/locales/fr.ts b/web/src/i18n/locales/fr.ts index af1f883..3598ba1 100644 --- a/web/src/i18n/locales/fr.ts +++ b/web/src/i18n/locales/fr.ts @@ -337,10 +337,26 @@ const fr = { modeOff: 'Désactivé', modeAuto: 'Masquer automatiquement', 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', 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: { title: 'Appareil', oled: { diff --git a/web/src/i18n/locales/hu.ts b/web/src/i18n/locales/hu.ts index 054e851..e0b51ba 100644 --- a/web/src/i18n/locales/hu.ts +++ b/web/src/i18n/locales/hu.ts @@ -336,10 +336,26 @@ const hu = { modeOff: 'Ki', modeAuto: 'Automatikus elrejtés', 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', 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: { title: 'Eszköz', oled: { diff --git a/web/src/i18n/locales/id.ts b/web/src/i18n/locales/id.ts index 4318225..d8cd105 100644 --- a/web/src/i18n/locales/id.ts +++ b/web/src/i18n/locales/id.ts @@ -334,10 +334,26 @@ const id = { modeOff: 'Mati', modeAuto: 'Sembunyikan otomatis', modeAlways: 'Selalu terlihat', + keyboardLedStatus: 'Indikator kunci keyboard', + keyboardLedStatusDesc: + 'Tampilkan status Num Lock, Caps Lock, dan Scroll Lock komputer jarak jauh', icons: 'Ikon Submenu', 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: { title: 'Perangkat', oled: { diff --git a/web/src/i18n/locales/it.ts b/web/src/i18n/locales/it.ts index 939f787..67e4956 100644 --- a/web/src/i18n/locales/it.ts +++ b/web/src/i18n/locales/it.ts @@ -336,10 +336,26 @@ const it = { modeOff: 'Spento', modeAuto: 'Nascondi automaticamente', 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', 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: { title: 'Dispositivo', oled: { diff --git a/web/src/i18n/locales/ja.ts b/web/src/i18n/locales/ja.ts index 21b757e..e622d35 100644 --- a/web/src/i18n/locales/ja.ts +++ b/web/src/i18n/locales/ja.ts @@ -335,10 +335,26 @@ const ja = { modeOff: '閉じる', modeAuto: '自動非表示', modeAlways: '常に表示', + keyboardLedStatus: 'キーボードロックの表示', + keyboardLedStatusDesc: + 'リモートコンピューターの Num Lock、Caps Lock、Scroll Lock の状態を表示', icons: 'メニューアイコン', 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: { title: 'デバイス', oled: { diff --git a/web/src/i18n/locales/ko.ts b/web/src/i18n/locales/ko.ts index 565f4e5..03219b8 100644 --- a/web/src/i18n/locales/ko.ts +++ b/web/src/i18n/locales/ko.ts @@ -330,10 +330,25 @@ const ko = { modeOff: '꺼짐', modeAuto: '자동 숨기기', modeAlways: '항상 보이기', + keyboardLedStatus: '키보드 잠금 표시기', + keyboardLedStatusDesc: '원격 컴퓨터의 Num Lock, Caps Lock, Scroll Lock 상태 표시', icons: '하위 메뉴 아이콘', 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: { title: '장치', oled: { diff --git a/web/src/i18n/locales/nb.ts b/web/src/i18n/locales/nb.ts index 110a77e..2bf1de9 100644 --- a/web/src/i18n/locales/nb.ts +++ b/web/src/i18n/locales/nb.ts @@ -333,10 +333,26 @@ const nb = { modeOff: 'Av', modeAuto: 'Skjul automatisk', modeAlways: 'Alltid synlig', + keyboardLedStatus: 'Indikatorer for tastaturlås', + keyboardLedStatusDesc: + 'Vis Num Lock-, Caps Lock- og Scroll Lock-status for den eksterne datamaskinen', icons: 'Undermenyikoner', 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: { title: 'Enhet', oled: { diff --git a/web/src/i18n/locales/nl.ts b/web/src/i18n/locales/nl.ts index a8f6436..a23b659 100644 --- a/web/src/i18n/locales/nl.ts +++ b/web/src/i18n/locales/nl.ts @@ -336,10 +336,26 @@ const nl = { modeOff: 'Uit', modeAuto: 'Automatisch verbergen', modeAlways: 'Altijd zichtbaar', + keyboardLedStatus: 'Toetsvergrendelingsindicatoren', + keyboardLedStatusDesc: + 'Toon de Num Lock-, Caps Lock- en Scroll Lock-status van de externe computer', icons: 'Submenupictogrammen', 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: { title: 'Apparaat', oled: { diff --git a/web/src/i18n/locales/pl.ts b/web/src/i18n/locales/pl.ts index 9ba9871..a489d98 100644 --- a/web/src/i18n/locales/pl.ts +++ b/web/src/i18n/locales/pl.ts @@ -335,10 +335,26 @@ const pl = { modeOff: 'Wyłączone', modeAuto: 'Automatyczne ukrywanie', modeAlways: 'Zawsze widoczny', + keyboardLedStatus: 'Wskaźniki blokad klawiatury', + keyboardLedStatusDesc: + 'Wyświetl stan Num Lock, Caps Lock i Scroll Lock zdalnego komputera', icons: 'Ikony podmenu', 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: { title: 'Urządzenie', oled: { diff --git a/web/src/i18n/locales/pt_br.ts b/web/src/i18n/locales/pt_br.ts index 1af720a..78cdc7c 100644 --- a/web/src/i18n/locales/pt_br.ts +++ b/web/src/i18n/locales/pt_br.ts @@ -334,10 +334,26 @@ const pt_br = { modeOff: 'Desligado', modeAuto: 'Ocultar automaticamente', 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', 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: { title: 'Dispositivo', oled: { diff --git a/web/src/i18n/locales/ru.ts b/web/src/i18n/locales/ru.ts index 8bf1b96..88d5040 100644 --- a/web/src/i18n/locales/ru.ts +++ b/web/src/i18n/locales/ru.ts @@ -335,10 +335,26 @@ const ru = { modeOff: 'Выкл.', modeAuto: 'Автоматическое скрытие', modeAlways: 'Всегда виден', + keyboardLedStatus: 'Индикаторы блокировки клавиатуры', + keyboardLedStatusDesc: + 'Показывать состояние Num Lock, Caps Lock и Scroll Lock удалённого компьютера', icons: 'Значки подменю', 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: { title: 'Устройство', oled: { diff --git a/web/src/i18n/locales/se.ts b/web/src/i18n/locales/se.ts index 7e29b9e..fa1adc1 100644 --- a/web/src/i18n/locales/se.ts +++ b/web/src/i18n/locales/se.ts @@ -331,10 +331,26 @@ const se = { modeOff: 'Av', modeAuto: 'Dölj automatiskt', 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', 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: { title: 'Enhet', oled: { diff --git a/web/src/i18n/locales/th.ts b/web/src/i18n/locales/th.ts index de3d5b6..682b424 100644 --- a/web/src/i18n/locales/th.ts +++ b/web/src/i18n/locales/th.ts @@ -327,10 +327,26 @@ const th = { modeOff: 'ปิด', modeAuto: 'ซ่อนอัตโนมัติ', modeAlways: 'มองเห็นได้เสมอ', + keyboardLedStatus: 'ตัวบ่งชี้ปุ่มล็อกแป้นพิมพ์', + keyboardLedStatusDesc: + 'แสดงสถานะ Num Lock, Caps Lock และ Scroll Lock ของคอมพิวเตอร์ระยะไกล', icons: 'ไอคอนเมนูย่อย', 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: { title: 'อุปกรณ์', oled: { diff --git a/web/src/i18n/locales/tr.ts b/web/src/i18n/locales/tr.ts index d2985ef..d0c8f01 100644 --- a/web/src/i18n/locales/tr.ts +++ b/web/src/i18n/locales/tr.ts @@ -333,10 +333,26 @@ const tr = { modeOff: 'Kapalı', modeAuto: 'Otomatik gizle', 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', 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: { title: 'Cihaz', oled: { diff --git a/web/src/i18n/locales/uk.ts b/web/src/i18n/locales/uk.ts index 7a3ac1a..b638ea3 100644 --- a/web/src/i18n/locales/uk.ts +++ b/web/src/i18n/locales/uk.ts @@ -334,10 +334,26 @@ const uk = { modeOff: 'Вимк', modeAuto: 'Автоматичне приховування', modeAlways: 'Завжди видно', + keyboardLedStatus: 'Індикатори блокування клавіатури', + keyboardLedStatusDesc: + 'Показувати стан Num Lock, Caps Lock і Scroll Lock віддаленого комп’ютера', icons: 'Значки підменю', 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: { title: 'Пристрій', oled: { diff --git a/web/src/i18n/locales/vi.ts b/web/src/i18n/locales/vi.ts index eced068..e988efd 100644 --- a/web/src/i18n/locales/vi.ts +++ b/web/src/i18n/locales/vi.ts @@ -331,10 +331,26 @@ const vi = { modeOff: 'Tắt', modeAuto: 'Tự động ẩn', 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', 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: { title: 'Thiết bị', oled: { diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index fb23103..f51f31e 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -322,10 +322,25 @@ const zh = { modeOff: '关闭', modeAuto: '自动隐藏', modeAlways: '始终显示', + keyboardLedStatus: '键盘锁定状态指示灯', + keyboardLedStatusDesc: '显示远程主机的 Num Lock、Caps Lock 和 Scroll Lock 状态', icons: '菜单图标', iconsDesc: '是否在菜单栏中显示子菜单图标' } }, + keyboardLedStatus: { + groupLabel: '远程键盘锁定状态', + indicatorLabel: '{{label}}:{{state}}', + numLock: '数字锁定', + numLockShort: '数', + capsLock: '大写锁定', + capsLockShort: '大', + scrollLock: '滚动锁定', + scrollLockShort: '滚', + on: '开启', + off: '关闭', + unknown: '未知' + }, device: { title: '设备', oled: { diff --git a/web/src/i18n/locales/zh_tw.ts b/web/src/i18n/locales/zh_tw.ts index 7b10773..ccc3501 100644 --- a/web/src/i18n/locales/zh_tw.ts +++ b/web/src/i18n/locales/zh_tw.ts @@ -322,10 +322,25 @@ const zh_tw = { modeOff: '關閉', modeAuto: '自動隱藏', modeAlways: '始終顯示', + keyboardLedStatus: '鍵盤鎖定狀態指示燈', + keyboardLedStatusDesc: '顯示遠端電腦的 Num Lock、Caps Lock 與 Scroll Lock 狀態', icons: '選單圖示', iconsDesc: '是否在選單欄中顯示子選單圖示' } }, + keyboardLedStatus: { + groupLabel: '遠端鍵盤鎖定狀態', + indicatorLabel: '{{label}}:{{state}}', + numLock: '數字鎖定', + numLockShort: '數', + capsLock: '大寫鎖定', + capsLockShort: '大', + scrollLock: '捲動鎖定', + scrollLockShort: '捲', + on: '開啟', + off: '關閉', + unknown: '未知' + }, device: { title: '設備', oled: { diff --git a/web/src/jotai/settings.ts b/web/src/jotai/settings.ts index e8fac35..e59d9be 100644 --- a/web/src/jotai/settings.ts +++ b/web/src/jotai/settings.ts @@ -11,3 +11,6 @@ export const webTitleAtom = atom(''); // menu display mode: 'off' | 'auto' | 'always' export const menuDisplayModeAtom = atom('auto'); + +// show the remote keyboard lock-status indicator beside the menu bar +export const keyboardLedStatusVisibleAtom = atom(true); diff --git a/web/src/lib/localstorage.ts b/web/src/lib/localstorage.ts index 2ad2e37..030fa83 100644 --- a/web/src/lib/localstorage.ts +++ b/web/src/lib/localstorage.ts @@ -18,6 +18,7 @@ const KEYBOARD_LANGUAGE_KEY = 'nano-kvm-keyboard-language'; const SKIP_MODIFY_PASSWORD_KEY = 'nano-kvm-skip-modify-password'; const MENU_DISABLED_ITEMS_KEY = 'nano-kvm-menu-disabled-items'; const MENU_AUTO_HIDE_KEY = 'nano-kvm-menu-auto-hide'; +const KEYBOARD_LED_STATUS_VISIBLE_KEY = 'nano-kvm-keyboard-led-status-visible'; const POWER_CONFIRM_KEY = 'nano-kvm-power-confirm'; type ItemWithExpiry = { @@ -222,6 +223,15 @@ export function setMenuDisplayMode(mode: string) { 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() { const enabled = localStorage.getItem(POWER_CONFIRM_KEY); return enabled === 'true'; diff --git a/web/src/pages/desktop/keyboard-led-status/index.tsx b/web/src/pages/desktop/keyboard-led-status/index.tsx new file mode 100644 index 0000000..819607d --- /dev/null +++ b/web/src/pages/desktop/keyboard-led-status/index.tsx @@ -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 ( + +
+ + {shortLabel} +
+
+ ); +} + +export function KeyboardLedStatus() { + const status = useKeyboardLedStatus(); + const known = status?.known ?? false; + + return ( +
+ + + +
+ ); +} diff --git a/web/src/pages/desktop/keyboard-led-status/model.ts b/web/src/pages/desktop/keyboard-led-status/model.ts new file mode 100644 index 0000000..0895d48 --- /dev/null +++ b/web/src/pages/desktop/keyboard-led-status/model.ts @@ -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; + 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; +} diff --git a/web/src/pages/desktop/keyboard-led-status/use-keyboard-led-status.ts b/web/src/pages/desktop/keyboard-led-status/use-keyboard-led-status.ts new file mode 100644 index 0000000..55a88b6 --- /dev/null +++ b/web/src/pages/desktop/keyboard-led-status/use-keyboard-led-status.ts @@ -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(null); + const latestStatusRef = useRef(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; +} diff --git a/web/src/pages/desktop/menu/index.tsx b/web/src/pages/desktop/menu/index.tsx index 232156c..3390e1c 100644 --- a/web/src/pages/desktop/menu/index.tsx +++ b/web/src/pages/desktop/menu/index.tsx @@ -5,10 +5,11 @@ import { useAtomValue } from 'jotai'; import { GripVerticalIcon } from 'lucide-react'; 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 { useMenuVisibility } from '@/hooks/useMenuVisibility.ts'; +import { KeyboardLedStatus } from '../keyboard-led-status'; import { DownloadImage } from './download.tsx'; import { Fullscreen } from './fullscreen'; import { Image } from './image'; @@ -27,6 +28,7 @@ export const Menu = () => { const nodeRef = useRef(null); const menuDisabledItems = useAtomValue(menuDisabledItemsAtom); + const isKeyboardLedStatusVisible = useAtomValue(keyboardLedStatusVisibleAtom); const { isInitialized, @@ -75,11 +77,21 @@ export const Menu = () => {
+ {isMenuExpanded && isKeyboardLedStatusVisible && ( +
+ +
+ )}
diff --git a/web/src/pages/desktop/menu/settings/appearance/index.tsx b/web/src/pages/desktop/menu/settings/appearance/index.tsx index ea3baa3..fbdc5ed 100644 --- a/web/src/pages/desktop/menu/settings/appearance/index.tsx +++ b/web/src/pages/desktop/menu/settings/appearance/index.tsx @@ -1,6 +1,7 @@ import { Divider } from 'antd'; import { useTranslation } from 'react-i18next'; +import { KeyboardLedStatusSetting } from './keyboard-led-status.tsx'; import { Language } from './language.tsx'; import { MenuIcons } from './menu-icons.tsx'; import { MenuMode } from './menu-mode.tsx'; @@ -22,6 +23,7 @@ export const Appearance = () => {
{t('settings.appearance.menuBar.title')}
+ ); diff --git a/web/src/pages/desktop/menu/settings/appearance/keyboard-led-status.tsx b/web/src/pages/desktop/menu/settings/appearance/keyboard-led-status.tsx new file mode 100644 index 0000000..e2d0ed2 --- /dev/null +++ b/web/src/pages/desktop/menu/settings/appearance/keyboard-led-status.tsx @@ -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 ( +
+
+ + {t('settings.appearance.menuBar.keyboardLedStatus')} + + + {t('settings.appearance.menuBar.keyboardLedStatusDesc')} + +
+ + +
+ ); +};