mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-09-11 00:22:56 -05:00
feat(web): add configurable mouse input regions
Add device-level input-region APIs for reading, validating, and atomically persisting absolute mouse calibration settings, including automatic, manual, and disabled modes. Introduce a shared screen viewport that keeps the rendered media geometry and configured input region aligned across MJPEG, direct H.264, and WebRTC streams. Add automatic black-border detection and a manual selection overlay with dragging, resizing, magnified preview, keyboard locking, and cancellation support. Provide original-resolution presets so manual calibration can be recalculated for common aspect ratios, expose the controls from the desktop mouse menu, close menus before entering selection mode, and preserve active file transfers when the download menu closes. Add localized control-region labels and messages for all supported locales, and update direct-frame rendering so media dimensions are reported after the canvas has been drawn.
This commit is contained in:
@@ -152,3 +152,41 @@ type GetWebTitleRsp struct {
|
||||
type SetTlsReq struct {
|
||||
Enabled bool `validate:"omitempty"`
|
||||
}
|
||||
|
||||
type InputRegion struct {
|
||||
Mode string `json:"mode"`
|
||||
FrameWidth int `json:"frameWidth"`
|
||||
FrameHeight int `json:"frameHeight"`
|
||||
Left int `json:"left"`
|
||||
Top int `json:"top"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Resolutions []OriginalResolution `json:"resolutions,omitempty"`
|
||||
SelectedResolution string `json:"selectedResolution"`
|
||||
}
|
||||
|
||||
type OriginalResolution struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
type SetInputRegionReq struct {
|
||||
Mode string `json:"mode"`
|
||||
FrameWidth *int `json:"frameWidth,omitempty"`
|
||||
FrameHeight *int `json:"frameHeight,omitempty"`
|
||||
Left *int `json:"left,omitempty"`
|
||||
Top *int `json:"top,omitempty"`
|
||||
Width *int `json:"width,omitempty"`
|
||||
Height *int `json:"height,omitempty"`
|
||||
Resolutions *[]OriginalResolution `json:"resolutions,omitempty"`
|
||||
SelectedResolution *string `json:"selectedResolution,omitempty"`
|
||||
}
|
||||
|
||||
type GetInputRegionRsp struct {
|
||||
InputRegion
|
||||
}
|
||||
|
||||
type GetInputResolutionRsp struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ func vmRouter(r *gin.Engine) {
|
||||
api.GET("/vm/gpio", service.GetGpio) // get gpio
|
||||
api.POST("/vm/screen", service.SetScreen) // update screen
|
||||
|
||||
api.GET("/vm/input-region", service.GetInputRegion)
|
||||
api.POST("/vm/input-region", service.SetInputRegion)
|
||||
api.GET("/vm/input-resolution", service.GetInputResolution)
|
||||
|
||||
admin.GET("/vm/terminal", service.Terminal) // web terminal
|
||||
|
||||
admin.GET("/vm/script", service.GetScripts) // get script
|
||||
|
||||
283
server/service/vm/input_region.go
Normal file
283
server/service/vm/input_region.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package vm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"NanoKVM-Server/proto"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var (
|
||||
inputRegionFile = "/etc/kvm/input-region.json"
|
||||
inputRegionMu sync.RWMutex
|
||||
)
|
||||
|
||||
func (s *Service) GetInputResolution(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
width, widthErr := readPositiveInt("/kvmapp/kvm/width")
|
||||
height, heightErr := readPositiveInt("/kvmapp/kvm/height")
|
||||
if widthErr != nil || heightErr != nil {
|
||||
rsp.ErrRsp(c, -1, "failed to read input resolution")
|
||||
return
|
||||
}
|
||||
rsp.OkRspWithData(c, &proto.GetInputResolutionRsp{Width: width, Height: height})
|
||||
}
|
||||
|
||||
func readPositiveInt(path string) (int, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
value, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
if err != nil || value <= 0 {
|
||||
return 0, errors.New("invalid positive integer")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetInputRegion(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
inputRegionMu.RLock()
|
||||
region, err := readInputRegion(inputRegionFile)
|
||||
inputRegionMu.RUnlock()
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
rsp.OkRspWithData(c, &proto.GetInputRegionRsp{InputRegion: proto.InputRegion{Mode: "off"}})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, "failed to read input region")
|
||||
return
|
||||
}
|
||||
if region == nil {
|
||||
rsp.OkRspWithData(c, &proto.GetInputRegionRsp{InputRegion: proto.InputRegion{Mode: "off"}})
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetInputRegionRsp{InputRegion: *region})
|
||||
}
|
||||
|
||||
func (s *Service) SetInputRegion(c *gin.Context) {
|
||||
var req proto.SetInputRegionReq
|
||||
var rsp proto.Response
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid arguments")
|
||||
return
|
||||
}
|
||||
|
||||
inputRegionMu.Lock()
|
||||
defer inputRegionMu.Unlock()
|
||||
|
||||
if req.Mode == "off" || req.Mode == "auto" ||
|
||||
(req.Mode == "manual" && req.FrameWidth == nil && req.FrameHeight == nil &&
|
||||
req.Left == nil && req.Top == nil && req.Width == nil && req.Height == nil) {
|
||||
region, err := readInputRegion(inputRegionFile)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
rsp.ErrRsp(c, -2, "failed to read input region")
|
||||
return
|
||||
}
|
||||
if region == nil {
|
||||
region = &proto.InputRegion{}
|
||||
}
|
||||
region.Mode = req.Mode
|
||||
if req.Resolutions != nil {
|
||||
if err := validateOriginalResolutions(*req.Resolutions); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid original resolutions")
|
||||
return
|
||||
}
|
||||
region.Resolutions = *req.Resolutions
|
||||
}
|
||||
if req.SelectedResolution != nil {
|
||||
region.SelectedResolution = *req.SelectedResolution
|
||||
}
|
||||
if err := validateSelectedResolution(region.SelectedResolution, region.Resolutions); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid selected resolution")
|
||||
return
|
||||
}
|
||||
if err := writeInputRegion(inputRegionFile, *region); err != nil {
|
||||
rsp.ErrRsp(c, -2, "failed to save input region mode")
|
||||
return
|
||||
}
|
||||
rsp.OkRsp(c)
|
||||
return
|
||||
}
|
||||
if req.Mode != "manual" {
|
||||
rsp.ErrRsp(c, -1, "invalid input region mode")
|
||||
return
|
||||
}
|
||||
region, err := inputRegionFromRequest(req)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid input region")
|
||||
return
|
||||
}
|
||||
previous, readErr := readInputRegion(inputRegionFile)
|
||||
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
|
||||
rsp.ErrRsp(c, -2, "failed to read input region")
|
||||
return
|
||||
}
|
||||
if previous != nil {
|
||||
region.Resolutions = previous.Resolutions
|
||||
region.SelectedResolution = previous.SelectedResolution
|
||||
}
|
||||
if req.Resolutions != nil {
|
||||
if err := validateOriginalResolutions(*req.Resolutions); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid original resolutions")
|
||||
return
|
||||
}
|
||||
region.Resolutions = *req.Resolutions
|
||||
}
|
||||
if req.SelectedResolution != nil {
|
||||
region.SelectedResolution = *req.SelectedResolution
|
||||
}
|
||||
if err := validateSelectedResolution(region.SelectedResolution, region.Resolutions); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid selected resolution")
|
||||
return
|
||||
}
|
||||
if err := writeInputRegion(inputRegionFile, region); err != nil {
|
||||
rsp.ErrRsp(c, -2, "failed to save input region")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
}
|
||||
|
||||
func validateOriginalResolutions(resolutions []proto.OriginalResolution) error {
|
||||
seen := make(map[proto.OriginalResolution]struct{}, len(resolutions))
|
||||
for _, resolution := range resolutions {
|
||||
if resolution.Width <= 0 || resolution.Height <= 0 {
|
||||
return errors.New("resolution dimensions must be positive")
|
||||
}
|
||||
if _, ok := seen[resolution]; ok {
|
||||
return errors.New("duplicate resolution")
|
||||
}
|
||||
seen[resolution] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSelectedResolution(selected string, resolutions []proto.OriginalResolution) error {
|
||||
if selected == "" {
|
||||
return nil
|
||||
}
|
||||
for _, resolution := range resolutions {
|
||||
if selected == resolutionKey(resolution) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("selected resolution not found")
|
||||
}
|
||||
|
||||
func resolutionKey(resolution proto.OriginalResolution) string {
|
||||
return fmt.Sprintf("%dx%d", resolution.Width, resolution.Height)
|
||||
}
|
||||
|
||||
func inputRegionFromRequest(req proto.SetInputRegionReq) (proto.InputRegion, error) {
|
||||
if req.FrameWidth == nil || req.FrameHeight == nil || req.Left == nil ||
|
||||
req.Top == nil || req.Width == nil || req.Height == nil {
|
||||
return proto.InputRegion{}, errors.New("input region must be complete")
|
||||
}
|
||||
|
||||
region := proto.InputRegion{
|
||||
Mode: "manual",
|
||||
FrameWidth: *req.FrameWidth,
|
||||
FrameHeight: *req.FrameHeight,
|
||||
Left: *req.Left,
|
||||
Top: *req.Top,
|
||||
Width: *req.Width,
|
||||
Height: *req.Height,
|
||||
}
|
||||
return region, validateInputRegion(region)
|
||||
}
|
||||
|
||||
func validateInputRegion(region proto.InputRegion) error {
|
||||
if region.Mode == "off" || region.Mode == "auto" {
|
||||
return nil
|
||||
}
|
||||
if region.Mode == "manual" && region.FrameWidth == 0 && region.FrameHeight == 0 {
|
||||
return nil
|
||||
}
|
||||
if region.Mode != "manual" {
|
||||
return errors.New("invalid input region mode")
|
||||
}
|
||||
if region.FrameWidth <= 0 || region.FrameHeight <= 0 {
|
||||
return errors.New("frame dimensions must be positive")
|
||||
}
|
||||
if region.Left < 0 || region.Top < 0 || region.Width <= 0 || region.Height <= 0 {
|
||||
return errors.New("region dimensions must be positive and offsets non-negative")
|
||||
}
|
||||
if region.Left > region.FrameWidth-region.Width || region.Top > region.FrameHeight-region.Height {
|
||||
return errors.New("region must be inside frame")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readInputRegion(path string) (*proto.InputRegion, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var region *proto.InputRegion
|
||||
if err := json.Unmarshal(data, ®ion); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
if region == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if region.Mode == "" {
|
||||
region.Mode = "manual"
|
||||
}
|
||||
if err := validateInputRegion(*region); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return region, nil
|
||||
}
|
||||
|
||||
func writeInputRegion(path string, region proto.InputRegion) error {
|
||||
data, err := json.Marshal(region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(dir, ".input-region-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Chmod(0o644); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpPath, path)
|
||||
}
|
||||
|
||||
func removeInputRegion(path string) error {
|
||||
err := os.Remove(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user