mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
279
server/service/hid/leds.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
168
server/service/hid/leds_test.go
Normal file
168
server/service/hid/leds_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
94
server/service/ws/keyboard_led_status.go
Normal file
94
server/service/ws/keyboard_led_status.go
Normal 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))
|
||||||
|
}
|
||||||
79
server/service/ws/keyboard_led_status_test.go
Normal file
79
server/service/ws/keyboard_led_status_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 l’estat de Bloq Num, Bloq Maj i Bloq Despl de l’ordinador 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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
68
web/src/pages/desktop/keyboard-led-status/index.tsx
Normal file
68
web/src/pages/desktop/keyboard-led-status/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
web/src/pages/desktop/keyboard-led-status/model.ts
Normal file
69
web/src/pages/desktop/keyboard-led-status/model.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user