Browse Source

Added multi device support

TC 4 days ago
parent
commit
eddda0eb9e

+ 1 - 0
dezukvmd/config/uuid.cfg

@@ -0,0 +1 @@
+5fb88e70-a502-4b4d-af85-5a8859aff3b0

+ 2 - 2
dezukvmd/configure.go

@@ -4,12 +4,12 @@ import (
 	"log"
 	"time"
 
-	"imuslab.com/dezukvm/dezukvmd/mod/remdeshid"
+	"imuslab.com/dezukvm/dezukvmd/mod/kvmhid"
 )
 
 func SetupHIDCommunication(config *UsbKvmConfig) error {
 	// Initiate the HID controller
-	usbKVM = remdeshid.NewHIDController(&remdeshid.Config{
+	usbKVM = kvmhid.NewHIDController(&kvmhid.Config{
 		PortName:          config.USBKVMDevicePath,
 		BaudRate:          config.USBKVMBaudrate,
 		ScrollSensitivity: 0x01, // Set mouse scroll sensitivity

BIN
dezukvmd/debug.sh


+ 3 - 1
dezukvmd/go.mod

@@ -3,12 +3,14 @@ module imuslab.com/dezukvm/dezukvmd
 go 1.23.4
 
 require (
+	github.com/google/uuid v1.6.0
+	github.com/gorilla/csrf v1.7.3
 	github.com/gorilla/websocket v1.5.3
 	github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
 	github.com/vladimirvivien/go4vl v0.0.5
 )
 
 require (
-	github.com/pion/opus v0.0.0-20250618074346-646586bb17bf // indirect
+	github.com/gorilla/securecookie v1.1.2 // indirect
 	golang.org/x/sys v0.31.0 // indirect
 )

+ 8 - 2
dezukvmd/go.sum

@@ -1,7 +1,13 @@
+github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
+github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0=
+github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
+github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
+github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
 github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
 github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/pion/opus v0.0.0-20250618074346-646586bb17bf h1:aVhn89Yu8oTedA+E/Ge3LHk1FvFBThogBP/a0WkhGeI=
-github.com/pion/opus v0.0.0-20250618074346-646586bb17bf/go.mod h1:MF0ECGlX1vw71XHaPvRqZoeFED6QTwvFL71vbsd29yY=
 github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU=
 github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
 github.com/vladimirvivien/go4vl v0.0.5 h1:jHuo/CZOAzYGzrSMOc7anOMNDr03uWH5c1B5kQ+Chnc=

+ 122 - 1
dezukvmd/ipkvm.go

@@ -1,5 +1,126 @@
 package main
 
+import (
+	"fmt"
+	"log"
+	"net/http"
+	"os"
+	"os/signal"
+	"path/filepath"
+	"strings"
+	"syscall"
+
+	"github.com/gorilla/csrf"
+	"imuslab.com/dezukvm/dezukvmd/mod/dezukvm"
+)
+
+var (
+	dezukvmManager     *dezukvm.DezukVM
+	listeningServerMux *http.ServeMux
+)
+
 func init_ipkvm_mode() error {
-	return nil
+	listeningServerMux = http.NewServeMux()
+	//Create a new DezukVM manager
+	dezukvmManager = dezukvm.NewKvmHostInstance(&dezukvm.RuntimeOptions{
+		EnableLog: true,
+	})
+
+	// Experimental
+	connectedUsbKvms, err := dezukvm.ScanConnectedUsbKvmDevices()
+	if err != nil {
+		return err
+	}
+
+	for _, dev := range connectedUsbKvms {
+		err := dezukvmManager.AddUsbKvmDevice(dev)
+		if err != nil {
+			return err
+		}
+	}
+
+	err = dezukvmManager.StartAllUsbKvmDevices()
+	if err != nil {
+		return err
+	}
+	// ~Experimental
+
+	// Handle program exit to close the HID controller
+	c := make(chan os.Signal, 1)
+	signal.Notify(c, os.Interrupt, syscall.SIGTERM)
+	go func() {
+		<-c
+		log.Println("Shutting down DezuKVM...")
+
+		if dezukvmManager != nil {
+			dezukvmManager.Close()
+		}
+		log.Println("Shutdown complete.")
+		os.Exit(0)
+	}()
+
+	// Middleware to inject CSRF token into HTML files served from www
+	listeningServerMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		// Only inject for .html files
+		path := r.URL.Path
+		if path == "/" {
+			path = "/index.html"
+		}
+		if strings.HasSuffix(path, ".html") {
+			// Read the HTML file from disk
+			targetFilePath := filepath.Join("www", filepath.Clean(path))
+			content, err := os.ReadFile(targetFilePath)
+			if err != nil {
+				http.NotFound(w, r)
+				return
+			}
+			htmlContent := string(content)
+			// Replace CSRF token placeholder
+			htmlContent = strings.ReplaceAll(htmlContent, "{{.csrfToken}}", csrf.Token(r))
+			w.Header().Set("Content-Type", "text/html")
+			w.Write([]byte(htmlContent))
+			return
+		}
+		// Fallback to static file server for non-HTML files
+		http.FileServer(http.Dir("www")).ServeHTTP(w, r)
+	})
+
+	// Register DezukVM related APIs
+	register_ipkvm_apis(listeningServerMux)
+
+	csrfMiddleware := csrf.Protect(
+		[]byte(nodeUUID),
+		csrf.CookieName("dezukvm-csrf"),
+		csrf.Secure(false),
+		csrf.Path("/"),
+		csrf.SameSite(csrf.SameSiteLaxMode),
+	)
+	err = http.ListenAndServe(":9000", csrfMiddleware(listeningServerMux))
+	return err
+}
+
+func register_ipkvm_apis(mux *http.ServeMux) {
+	mux.HandleFunc("/api/v1/stream/{uuid}/video", func(w http.ResponseWriter, r *http.Request) {
+		instanceUUID := r.PathValue("uuid")
+		fmt.Println("Requested video stream for instance UUID:", instanceUUID)
+		dezukvmManager.HandleVideoStreams(w, r, instanceUUID)
+	})
+
+	mux.HandleFunc("/api/v1/stream/{uuid}/audio", func(w http.ResponseWriter, r *http.Request) {
+		instanceUUID := r.PathValue("uuid")
+		dezukvmManager.HandleAudioStreams(w, r, instanceUUID)
+	})
+
+	mux.HandleFunc("/api/v1/hid/{uuid}/events", func(w http.ResponseWriter, r *http.Request) {
+		instanceUUID := r.PathValue("uuid")
+		dezukvmManager.HandleHIDEvents(w, r, instanceUUID)
+	})
+
+	mux.HandleFunc("/api/v1/instances", func(w http.ResponseWriter, r *http.Request) {
+		if r.Method == http.MethodGet {
+			dezukvmManager.HandleListInstances(w, r)
+		} else {
+			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		}
+	})
 }

+ 21 - 1
dezukvmd/main.go

@@ -7,17 +7,21 @@ import (
 	"log"
 	"net/http"
 	"os"
+
+	"github.com/google/uuid"
 )
 
 const (
 	defaultDevMode   = true
 	configPath       = "./config"
 	usbKvmConfigPath = configPath + "/usbkvm.json"
+	uuidFile         = configPath + "/uuid.cfg"
 )
 
 var (
+	nodeUUID   = "00000000-0000-0000-0000-000000000000"
 	developent = flag.Bool("dev", defaultDevMode, "Enable development mode with local static files")
-	mode       = flag.String("mode", "usbkvm", "Mode of operation: usbkvm, ipkvm or debug")
+	mode       = flag.String("mode", "ipkvm", "Mode of operation: usbkvm, ipkvm or debug")
 	tool       = flag.String("tool", "", "Run debug tool, must be used with -mode=debug")
 )
 
@@ -50,6 +54,22 @@ func init() {
 func main() {
 	flag.Parse()
 
+	//Generate the node uuid if not set
+
+	if _, err := os.Stat(uuidFile); os.IsNotExist(err) {
+		newUUID := uuid.NewString()
+		err = os.WriteFile(uuidFile, []byte(newUUID), 0644)
+		if err != nil {
+			log.Fatal("Failed to write UUID to file:", err)
+		}
+	}
+
+	uuidBytes, err := os.ReadFile(uuidFile)
+	if err != nil {
+		log.Fatal("Failed to read UUID from file:", err)
+	}
+	nodeUUID = string(uuidBytes)
+
 	switch *mode {
 	case "cfgchip":
 		//Load config file or create default one

+ 133 - 0
dezukvmd/mod/dezukvm/dezukvm.go

@@ -0,0 +1,133 @@
+package dezukvm
+
+import (
+	"errors"
+
+	"imuslab.com/dezukvm/dezukvmd/mod/usbcapture"
+)
+
+// NewKvmHostInstance creates a new instance of DezukVM, which can manage multiple USB KVM devices.
+func NewKvmHostInstance(option *RuntimeOptions) *DezukVM {
+	return &DezukVM{
+		UsbKvmInstance: []*UsbKvmDeviceInstance{},
+		occupiedUUIDs:  make(map[string]bool),
+		option:         option,
+	}
+}
+
+// AddUsbKvmDevice adds a new USB KVM device instance to the DezukVM manager.
+func (d *DezukVM) AddUsbKvmDevice(config *UsbKvmDeviceOption) error {
+	//Build the capture config from the device option
+	// Audio config
+	if config.AudioCaptureDevicePath == "" {
+		return errors.New("audio capture device path is not specified")
+	}
+	defaultAudioConfig := usbcapture.GetDefaultAudioConfig()
+	if config.CaptureAudioSampleRate == 0 {
+		config.CaptureAudioSampleRate = defaultAudioConfig.SampleRate
+	}
+	if config.CaptureAudioChannels == 0 {
+		config.CaptureAudioChannels = defaultAudioConfig.Channels
+	}
+	if config.CaptureAudioBytesPerSample == 0 {
+		config.CaptureAudioBytesPerSample = defaultAudioConfig.BytesPerSample
+	}
+	if config.CaptureAudioFrameSize == 0 {
+		config.CaptureAudioFrameSize = defaultAudioConfig.FrameSize
+	}
+
+	//Remap the audio config
+	audioCaptureCfg := &usbcapture.AudioConfig{
+		SampleRate:     config.CaptureAudioSampleRate,
+		Channels:       config.CaptureAudioChannels,
+		BytesPerSample: config.CaptureAudioBytesPerSample,
+		FrameSize:      config.CaptureAudioFrameSize,
+	}
+
+	//Setup video capture configs
+	if config.VideoCaptureDevicePath == "" {
+		return errors.New("video capture device path is not specified")
+	}
+	if config.CaptureVideoResolutionWidth == 0 {
+		config.CaptureVideoResolutionWidth = 1920
+	}
+	if config.CaptureeVideoResolutionHeight == 0 {
+		config.CaptureeVideoResolutionHeight = 1080
+	}
+	if config.CaptureeVideoFPS == 0 {
+		config.CaptureeVideoFPS = 25
+	}
+
+	// capture config
+	captureCfg := &usbcapture.Config{
+		VideoDeviceName: config.VideoCaptureDevicePath,
+		AudioDeviceName: config.AudioCaptureDevicePath,
+		AudioConfig:     audioCaptureCfg,
+	}
+
+	// video resolution config
+	videoResolutionConfig := &usbcapture.CaptureResolution{
+		Width:  config.CaptureVideoResolutionWidth,
+		Height: config.CaptureeVideoResolutionHeight,
+		FPS:    config.CaptureeVideoFPS,
+	}
+
+	instance := &UsbKvmDeviceInstance{
+		Config: config,
+
+		captureConfig:         captureCfg,
+		videoResoltuionConfig: videoResolutionConfig,
+
+		uuid:             "", // Will be set when starting the instance
+		usbKVMController: nil,
+		auxMCUController: nil,
+		usbCaptureDevice: nil,
+		parent:           d,
+	}
+	d.UsbKvmInstance = append(d.UsbKvmInstance, instance)
+	return nil
+}
+
+// RemoveUsbKvmDevice removes a USB KVM device instance by its UUID.
+func (d *DezukVM) RemoveUsbKvmDevice(uuid string) error {
+	for i, dev := range d.UsbKvmInstance {
+		if dev.UUID() == uuid {
+			d.UsbKvmInstance = append(d.UsbKvmInstance[:i], d.UsbKvmInstance[i+1:]...)
+			return nil
+		}
+	}
+	return errors.New("target USB KVM device not found")
+}
+
+func (d *DezukVM) StartAllUsbKvmDevices() error {
+	for _, instance := range d.UsbKvmInstance {
+		err := instance.Start()
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (d *DezukVM) StopAllUsbKvmDevices() error {
+	for _, instance := range d.UsbKvmInstance {
+		err := instance.Stop()
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (d *DezukVM) GetInstanceByUUID(uuid string) (*UsbKvmDeviceInstance, error) {
+	for _, instance := range d.UsbKvmInstance {
+		if instance.UUID() == uuid {
+			return instance, nil
+		}
+	}
+	return nil, errors.New("instance with specified UUID not found")
+}
+
+func (d *DezukVM) Close() error {
+	return d.StopAllUsbKvmDevices()
+}

+ 55 - 0
dezukvmd/mod/dezukvm/handlers.go

@@ -0,0 +1,55 @@
+package dezukvm
+
+import (
+	"encoding/json"
+	"net/http"
+)
+
+func (d *DezukVM) HandleVideoStreams(w http.ResponseWriter, r *http.Request, instanceUuid string) {
+	targetInstance, err := d.GetInstanceByUUID(instanceUuid)
+	if err != nil {
+		http.Error(w, "Instance with specified UUID not found", http.StatusNotFound)
+		return
+	}
+	targetInstance.usbCaptureDevice.ServeVideoStream(w, r)
+}
+
+func (d *DezukVM) HandleAudioStreams(w http.ResponseWriter, r *http.Request, instanceUuid string) {
+	targetInstance, err := d.GetInstanceByUUID(instanceUuid)
+	if err != nil {
+		http.Error(w, "Instance with specified UUID not found", http.StatusNotFound)
+		return
+	}
+	targetInstance.usbCaptureDevice.AudioStreamingHandler(w, r)
+}
+
+func (d *DezukVM) HandleHIDEvents(w http.ResponseWriter, r *http.Request, instanceUuid string) {
+	targetInstance, err := d.GetInstanceByUUID(instanceUuid)
+	if err != nil {
+		http.Error(w, "Instance with specified UUID not found", http.StatusNotFound)
+		return
+	}
+	targetInstance.usbKVMController.HIDWebSocketHandler(w, r)
+}
+
+func (d *DezukVM) HandleListInstances(w http.ResponseWriter, r *http.Request) {
+	instances := []map[string]interface{}{}
+	for _, instance := range d.UsbKvmInstance {
+		instances = append(instances, map[string]interface{}{
+			"uuid":                    instance.UUID(),
+			"video_capture_dev":       instance.Config.VideoCaptureDevicePath,
+			"audio_capture_dev":       instance.Config.AudioCaptureDevicePath,
+			"video_resolution_width":  instance.Config.CaptureVideoResolutionWidth,
+			"video_resolution_height": instance.Config.CaptureeVideoResolutionHeight,
+			"video_framerate":         instance.Config.CaptureeVideoFPS,
+			"audio_sample_rate":       instance.Config.CaptureAudioSampleRate,
+			"audio_channels":          instance.Config.CaptureAudioChannels,
+			"stream_info":             instance.usbCaptureDevice.GetStreamInfo(),
+			"usb_kvm_device":          instance.Config.USBKVMDevicePath,
+			"aux_mcu_device":          instance.Config.AuxMCUDevicePath,
+			"usb_mass_storage_side":   instance.auxMCUController.GetUSBMassStorageSide(),
+		})
+	}
+	w.Header().Set("Content-Type", "application/json")
+	json.NewEncoder(w).Encode(instances)
+}

+ 40 - 4
dezukvmd/kvmscan.go → dezukvmd/mod/dezukvm/kvmscan.go

@@ -1,4 +1,4 @@
-package main
+package dezukvm
 
 import (
 	"errors"
@@ -8,7 +8,8 @@ import (
 	"regexp"
 	"strings"
 
-	"imuslab.com/dezukvm/dezukvmd/mod/remdesaux"
+	"imuslab.com/dezukvm/dezukvmd/mod/kvmaux"
+	"imuslab.com/dezukvm/dezukvmd/mod/usbcapture"
 )
 
 /*
@@ -35,6 +36,41 @@ type UsbKvmDevice struct {
 	AlsaDevicePaths    []string // e.g. /dev/snd/pcmC1D0c, etc.
 }
 
+// ScanConnectedUsbKvmDevices scans and lists all connected USB KVM devices in the system.
+func ScanConnectedUsbKvmDevices() ([]*UsbKvmDeviceOption, error) {
+	possibleKvmDeviceGroup, err := DiscoverUsbKvmSubtree()
+	if err != nil {
+		return nil, err
+	}
+
+	if len(possibleKvmDeviceGroup) == 0 {
+		return nil, errors.New("no USB KVM devices found")
+	}
+
+	result := []*UsbKvmDeviceOption{}
+	for _, dev := range possibleKvmDeviceGroup {
+		option := &UsbKvmDeviceOption{
+			USBKVMDevicePath:       dev.USBKVMDevicePath,
+			AuxMCUDevicePath:       dev.AuxMCUDevicePath,
+			VideoCaptureDevicePath: "",
+			AudioCaptureDevicePath: "",
+		}
+		for _, videoPath := range dev.CaptureDevicePaths {
+			isCaptureCard := usbcapture.IsCaptureCardVideoInterface(videoPath)
+			if isCaptureCard {
+				option.VideoCaptureDevicePath = videoPath
+			}
+		}
+
+		// In theory one capture card shd only got 1 alsa audio device file
+		if len(dev.AlsaDevicePaths) > 0 {
+			option.AudioCaptureDevicePath = dev.AlsaDevicePaths[0] // Use the first audio device by default
+		}
+		result = append(result, option)
+	}
+	return result, nil
+}
+
 // populateUsbKvmUUID tries to get the UUID from the AuxMCU device
 func populateUsbKvmUUID(dev *UsbKvmDevice) error {
 	if dev.AuxMCUDevicePath == "" {
@@ -42,7 +78,7 @@ func populateUsbKvmUUID(dev *UsbKvmDevice) error {
 	}
 
 	// The standard baudrate for AuxMCU is 115200
-	aux, err := remdesaux.NewAuxOutbandController(dev.AuxMCUDevicePath, 115200)
+	aux, err := kvmaux.NewAuxOutbandController(dev.AuxMCUDevicePath, 115200)
 	if err != nil {
 		return err
 	}
@@ -57,7 +93,7 @@ func populateUsbKvmUUID(dev *UsbKvmDevice) error {
 	return nil
 }
 
-func discoverUsbKvmSubtree() ([]*UsbKvmDevice, error) {
+func DiscoverUsbKvmSubtree() ([]*UsbKvmDevice, error) {
 	// Scan all /dev/tty*, /dev/video*, /dev/snd/pcmC* devices
 	getMatchingDevs := func(pattern string) ([]string, error) {
 		files, err := filepath.Glob(pattern)

+ 54 - 0
dezukvmd/mod/dezukvm/typedef.go

@@ -0,0 +1,54 @@
+package dezukvm
+
+import (
+	"imuslab.com/dezukvm/dezukvmd/mod/kvmaux"
+	"imuslab.com/dezukvm/dezukvmd/mod/kvmhid"
+	"imuslab.com/dezukvm/dezukvmd/mod/usbcapture"
+)
+
+type UsbKvmDeviceOption struct {
+	/* Device Paths */
+	USBKVMDevicePath       string `json:"usb_kvm_device_path"`       // Path to the USB KVM HID device (e.g., /dev/ttyUSB0)
+	AuxMCUDevicePath       string `json:"aux_mcu_device_path"`       // Path to the auxiliary MCU device (e.g., /dev/ttyACM0)
+	VideoCaptureDevicePath string `json:"video_capture_device_path"` // Path to the video capture device (e.g., /dev/video0)
+	AudioCaptureDevicePath string `json:"audio_capture_device_path"` // Path to the audio capture device (e.g., /dev/snd/pcmC1D0c)
+
+	/* Capture Settings */
+	CaptureVideoResolutionWidth   int `json:"capture_video_resolution_width"`  // Video capture resolution width in pixels, e.g., 1920
+	CaptureeVideoResolutionHeight int `json:"capture_video_resolution_height"` // Video capture resolution height in pixels, e.g., 1080
+	CaptureeVideoFPS              int `json:"capture_video_resolution_fps"`    // Video capture frames per second, e.g., 25
+	CaptureAudioSampleRate        int `json:"capture_audio_sample_rate"`       // Audio capture sample rate in Hz, e.g., 48000
+	CaptureAudioChannels          int `json:"capture_audio_channels"`          // Number of audio channels, e.g., 2 for stereo
+	CaptureAudioBytesPerSample    int `json:"capture_audio_bytes_per_sample"`  // Bytes per audio sample, e.g., 2 for 16-bit audio
+	CaptureAudioFrameSize         int `json:"capture_audio_frame_size"`        // Size of each audio frame in bytes, e.g., 1920
+
+	/* Communication Settings */
+	USBKVMBaudrate int `json:"usb_kvm_baudrate"` // Baudrate for USB KVM HID communication, e.g., 115200
+	AuxMCUBaudrate int `json:"aux_mcu_baudrate"` // Baudrate for auxiliary MCU communication, e.g., 115200
+}
+
+type UsbKvmDeviceInstance struct {
+	Config *UsbKvmDeviceOption
+
+	/* Processed Configs */
+	captureConfig         *usbcapture.Config
+	videoResoltuionConfig *usbcapture.CaptureResolution
+
+	/* Internals */
+	uuid             string // Session UUID obtained from AuxMCU
+	usbKVMController *kvmhid.Controller
+	auxMCUController *kvmaux.AuxMcu
+	usbCaptureDevice *usbcapture.Instance
+	parent           *DezukVM
+}
+
+type RuntimeOptions struct {
+	EnableLog bool `json:"enable_log"` // Enable or disable logging
+}
+type DezukVM struct {
+	UsbKvmInstance []*UsbKvmDeviceInstance
+
+	/* Internals */
+	occupiedUUIDs map[string]bool // Track occupied UUIDs to prevent duplicate connections
+	option        *RuntimeOptions // Runtime options
+}

+ 105 - 0
dezukvmd/mod/dezukvm/usbkvm.go

@@ -0,0 +1,105 @@
+package dezukvm
+
+import (
+	"errors"
+
+	"github.com/google/uuid"
+	"imuslab.com/dezukvm/dezukvmd/mod/kvmaux"
+	"imuslab.com/dezukvm/dezukvmd/mod/kvmhid"
+	"imuslab.com/dezukvm/dezukvmd/mod/usbcapture"
+)
+
+func (i *UsbKvmDeviceInstance) UUID() string {
+	return i.uuid
+}
+
+func (i *UsbKvmDeviceInstance) Start() error {
+	if i.Config.USBKVMDevicePath == "" {
+		return errors.New("USB KVM device path is not specified")
+	}
+	if i.Config.USBKVMBaudrate == 0 {
+		//Use default baudrate if not specified
+		i.Config.USBKVMBaudrate = 115200
+	}
+
+	/* --------- Start HID Controller --------- */
+	usbKVM := kvmhid.NewHIDController(&kvmhid.Config{
+		PortName:          i.Config.USBKVMDevicePath,
+		BaudRate:          i.Config.USBKVMBaudrate,
+		ScrollSensitivity: 0x01, // Set mouse scroll sensitivity
+	})
+
+	//Start the HID controller
+	err := usbKVM.Connect()
+	if err != nil {
+		return err
+	}
+
+	i.usbKVMController = usbKVM
+
+	/* --------- Start AuxMCU Controller --------- */
+	//Check if AuxMCU is configured, if so, start the connection
+	if i.Config.AuxMCUDevicePath != "" {
+		if i.Config.AuxMCUBaudrate == 0 {
+			//Use default baudrate if not specified
+			i.Config.AuxMCUBaudrate = 115200
+		}
+
+		auxMCU, err := kvmaux.NewAuxOutbandController(i.Config.AuxMCUDevicePath, i.Config.AuxMCUBaudrate)
+		if err != nil {
+			return err
+		}
+		i.auxMCUController = auxMCU
+
+		//Try to get the UUID from the AuxMCU
+		uuid, err := auxMCU.GetUUID()
+		if err != nil {
+			return err
+		}
+		i.uuid = uuid
+
+	} else {
+		// Randomly generate a UUIDv4 if AuxMCU is not present
+		uuid, err := uuid.NewRandom()
+		if err != nil {
+			return err
+		}
+		i.uuid = uuid.String()
+	}
+
+	/* --------- Start USB Capture Device --------- */
+	usbCaptureDevice, err := usbcapture.NewInstance(i.captureConfig)
+	if err != nil {
+		return err
+	}
+
+	err = usbCaptureDevice.StartVideoCapture(i.videoResoltuionConfig)
+	if err != nil {
+		usbCaptureDevice.Close()
+		return err
+	}
+
+	i.usbCaptureDevice = usbCaptureDevice
+	return nil
+}
+
+func (i *UsbKvmDeviceInstance) Stop() error {
+	if i.usbKVMController != nil {
+		i.usbKVMController.Close()
+		i.usbKVMController = nil
+	}
+	if i.auxMCUController != nil {
+		i.auxMCUController.Close()
+		i.auxMCUController = nil
+	}
+	if i.usbCaptureDevice != nil {
+		i.usbCaptureDevice.Close()
+		i.usbCaptureDevice = nil
+	}
+	return nil
+}
+
+// Remove removes the USB KVM device instance from its parent DezukVM manager.
+func (i *UsbKvmDeviceInstance) Remove() error {
+	return i.parent.RemoveUsbKvmDevice(i.UUID())
+}

+ 1 - 1
dezukvmd/mod/remdesaux/handlers.go → dezukvmd/mod/kvmaux/handlers.go

@@ -1,4 +1,4 @@
-package remdesaux
+package kvmaux
 
 import (
 	"encoding/json"

+ 1 - 1
dezukvmd/mod/remdesaux/remdesaux.go → dezukvmd/mod/kvmaux/remdesaux.go

@@ -1,4 +1,4 @@
-package remdesaux
+package kvmaux
 
 /*
 	RemdesAux - Auxiliary MCU Control for RemdeskVM

+ 1 - 1
dezukvmd/mod/remdeshid/ch9329.go → dezukvmd/mod/kvmhid/ch9329.go

@@ -1,4 +1,4 @@
-package remdeshid
+package kvmhid
 
 import (
 	"errors"

+ 1 - 1
dezukvmd/mod/remdeshid/handler.go → dezukvmd/mod/kvmhid/handler.go

@@ -1,4 +1,4 @@
-package remdeshid
+package kvmhid
 
 import (
 	"encoding/json"

+ 1 - 1
dezukvmd/mod/remdeshid/keyboard.go → dezukvmd/mod/kvmhid/keyboard.go

@@ -1,4 +1,4 @@
-package remdeshid
+package kvmhid
 
 import "errors"
 

+ 32 - 17
dezukvmd/mod/remdeshid/remdeshid.go → dezukvmd/mod/kvmhid/kvmhid.go

@@ -1,4 +1,4 @@
-package remdeshid
+package kvmhid
 
 import (
 	"fmt"
@@ -25,6 +25,7 @@ func NewHIDController(config *Config) *Controller {
 		hidState:          defaultHidState,
 		writeQueue:        make(chan []byte, 32),
 		incomingDataQueue: make(chan []byte, 1024),
+		readCloseChan:     make(chan bool),
 	}
 }
 
@@ -32,10 +33,11 @@ func NewHIDController(config *Config) *Controller {
 func (c *Controller) Connect() error {
 	// Open the serial port
 	config := &serial.Config{
-		Name:   c.Config.PortName,
-		Baud:   c.Config.BaudRate,
-		Size:   8,
-		Parity: serial.ParityNone,
+		Name:        c.Config.PortName,
+		Baud:        c.Config.BaudRate,
+		Size:        8,
+		Parity:      serial.ParityNone,
+		ReadTimeout: time.Millisecond * 500,
 	}
 
 	port, err := serial.OpenPort(config)
@@ -44,21 +46,21 @@ func (c *Controller) Connect() error {
 	}
 
 	c.serialPort = port
-	//Start reading from the serial port
+	// Start reading from the serial port
 	go func() {
 		buf := make([]byte, 1024)
 		for {
-			n, err := port.Read(buf)
-			if err != nil {
-				log.Println(err.Error())
+			select {
+			case <-c.readCloseChan:
 				return
-			}
-			if n > 0 {
-				c.incomingDataQueue <- buf[:n]
-				//fmt.Print("Received bytes: ")
-				//for i := 0; i < n; i++ {
-				//	fmt.Printf("0x%02X ", buf[i])
-				//}
+			default:
+				n, err := port.Read(buf)
+				if err != nil {
+					continue
+				}
+				if n > 0 {
+					c.incomingDataQueue <- buf[:n]
+				}
 			}
 		}
 	}()
@@ -170,7 +172,20 @@ func (c *Controller) WaitForReply(cmdByte byte) ([]byte, error) {
 }
 
 func (c *Controller) Close() {
+	c.serialRunning = false
+	c.readCloseChan <- true
 	if c.serialPort != nil {
-		c.serialPort.Close()
+		done := make(chan struct{})
+		go func() {
+			c.serialPort.Close()
+			close(done)
+		}()
+		select {
+		case <-done:
+			// Closed successfully
+		case <-time.After(3 * time.Second):
+			log.Println("serial port close timeout")
+		}
 	}
+
 }

+ 1 - 1
dezukvmd/mod/remdeshid/mouse.go → dezukvmd/mod/kvmhid/mouse.go

@@ -1,4 +1,4 @@
-package remdeshid
+package kvmhid
 
 import (
 	"bytes"

+ 2 - 1
dezukvmd/mod/remdeshid/typedef.go → dezukvmd/mod/kvmhid/typedef.go

@@ -1,4 +1,4 @@
-package remdeshid
+package kvmhid
 
 import (
 	"github.com/tarm/serial"
@@ -49,6 +49,7 @@ type Controller struct {
 	writeQueue          chan []byte
 	incomingDataQueue   chan []byte // Queue for incoming data
 	lastCursorEventTime int64
+	readCloseChan       chan bool
 }
 
 type HIDCommand struct {

+ 1 - 1
dezukvmd/mod/usbcapture/usbcapture.go

@@ -19,7 +19,7 @@ func NewInstance(config *Config) (*Instance, error) {
 	}
 
 	//Check if the device file actualy points to a video device
-	isValidDevice, err := checkVideoCaptureDevice(config.VideoDeviceName)
+	isValidDevice, err := CheckVideoCaptureDevice(config.VideoDeviceName)
 	if err != nil {
 		return nil, fmt.Errorf("failed to check video device: %w", err)
 	}

+ 20 - 4
dezukvmd/mod/usbcapture/video_device.go

@@ -71,7 +71,7 @@ func (i *Instance) StartVideoCapture(openWithResolution *CaptureResolution) erro
 	format := "mjpeg"
 
 	//Check if the video device is a capture device
-	isCaptureDev, err := checkVideoCaptureDevice(devName)
+	isCaptureDev, err := CheckVideoCaptureDevice(devName)
 	if err != nil {
 		return fmt.Errorf("failed to check video device: %w", err)
 	}
@@ -232,8 +232,24 @@ func (i *Instance) StopCapture() error {
 	return nil
 }
 
+// IsCaptureCardVideoInterface checks if the given video device is a capture card with multiple input interfaces
+func IsCaptureCardVideoInterface(device string) bool {
+	if ok, _ := CheckVideoCaptureDevice(device); !ok {
+		return false
+	}
+	formats, err := GetV4L2FormatInfo(device)
+	if err != nil {
+		return false
+	}
+	count := 0
+	for _, f := range formats {
+		count += len(f.Sizes)
+	}
+	return count > 1
+}
+
 // CheckVideoCaptureDevice checks if the given video device is a video capture device
-func checkVideoCaptureDevice(device string) (bool, error) {
+func CheckVideoCaptureDevice(device string) (bool, error) {
 	// Run v4l2-ctl to get device capabilities
 	cmd := exec.Command("v4l2-ctl", "--device", device, "--all")
 	output, err := cmd.CombinedOutput()
@@ -254,7 +270,7 @@ func GetDefaultVideoDevice() (string, error) {
 	// List all /dev/video* devices and return the first one that is a video capture device
 	for i := 0; i < 10; i++ {
 		device := fmt.Sprintf("/dev/video%d", i)
-		isCapture, err := checkVideoCaptureDevice(device)
+		isCapture, err := CheckVideoCaptureDevice(device)
 		if err != nil {
 			continue
 		}
@@ -294,7 +310,7 @@ func deviceSupportResolution(devicePath string, resolution *CaptureResolution) (
 // PrintV4L2FormatInfo prints the supported formats, resolutions, and frame rates of the given video device
 func PrintV4L2FormatInfo(devicePath string) {
 	// Check if the device is a video capture device
-	isCapture, err := checkVideoCaptureDevice(devicePath)
+	isCapture, err := CheckVideoCaptureDevice(devicePath)
 	if err != nil {
 		fmt.Printf("Error checking device: %v\n", err)
 		return

BIN
dezukvmd/resources/cursor_overlay.png


BIN
dezukvmd/resources/cursor_overlay.psd


+ 3 - 2
dezukvmd/tools.go

@@ -6,6 +6,7 @@ import (
 	"log"
 	"os/exec"
 
+	"imuslab.com/dezukvm/dezukvmd/mod/dezukvm"
 	"imuslab.com/dezukvm/dezukvmd/mod/usbcapture"
 )
 
@@ -17,7 +18,7 @@ func handle_debug_tool() error {
 			return err
 		}
 	case "list-usbkvm-json":
-		result, err := discoverUsbKvmSubtree()
+		result, err := dezukvm.DiscoverUsbKvmSubtree()
 		if err != nil {
 			return err
 		}
@@ -58,7 +59,7 @@ func run_dependency_precheck() error {
 
 // list_usb_kvm_devcies lists all discovered USB KVM devices and their associated sub-devices
 func list_usb_kvm_devcies() error {
-	result, err := discoverUsbKvmSubtree()
+	result, err := dezukvm.DiscoverUsbKvmSubtree()
 	if err != nil {
 		return err
 	}

+ 6 - 6
dezukvmd/usbkvm.go

@@ -17,8 +17,8 @@ import (
 	"os/signal"
 	"syscall"
 
-	"imuslab.com/dezukvm/dezukvmd/mod/remdesaux"
-	"imuslab.com/dezukvm/dezukvmd/mod/remdeshid"
+	"imuslab.com/dezukvm/dezukvmd/mod/kvmaux"
+	"imuslab.com/dezukvm/dezukvmd/mod/kvmhid"
 	"imuslab.com/dezukvm/dezukvmd/mod/usbcapture"
 )
 
@@ -37,8 +37,8 @@ type UsbKvmConfig struct {
 
 var (
 	/* Internal variables for USB-KVM mode only */
-	usbKVM              *remdeshid.Controller
-	auxMCU              *remdesaux.AuxMcu
+	usbKVM              *kvmhid.Controller
+	auxMCU              *kvmaux.AuxMcu
 	usbCaptureDevice    *usbcapture.Instance
 	defaultUsbKvmConfig = &UsbKvmConfig{
 		ListeningAddress:        ":9000",
@@ -91,7 +91,7 @@ func loadUsbKvmConfig() (*UsbKvmConfig, error) {
 func startUsbKvmMode(config *UsbKvmConfig) error {
 	log.Println("Starting in USB KVM mode...")
 	// Initiate the HID controller
-	usbKVM = remdeshid.NewHIDController(&remdeshid.Config{
+	usbKVM = kvmhid.NewHIDController(&kvmhid.Config{
 		PortName:          config.USBKVMDevicePath,
 		BaudRate:          config.USBKVMBaudrate,
 		ScrollSensitivity: 0x01, // Set mouse scroll sensitivity
@@ -104,7 +104,7 @@ func startUsbKvmMode(config *UsbKvmConfig) error {
 	}
 
 	//Start auxiliary MCU connections
-	auxMCU, err = remdesaux.NewAuxOutbandController(config.AuxMCUDevicePath, config.AuxMCUBaudrate)
+	auxMCU, err = kvmaux.NewAuxOutbandController(config.AuxMCUDevicePath, config.AuxMCUBaudrate)
 	if err != nil {
 		return err
 	}

+ 0 - 0
dezukvmd/www/forgot-password.html


BIN
dezukvmd/www/img/cursor_overlay.png


+ 3 - 2
dezukvmd/www/index.html

@@ -5,6 +5,7 @@
         <title>DezuKVM | Dashboard</title>
         <meta name="csrf_token" content="">
         <meta name="viewport" content="width=device-width, initial-scale=1.0">
+        <meta name="dezukvm.csrf.token" content="{{.csrfToken}}">
         <link rel="icon" type="image/png" href="/favicon.png">
         <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
         <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/fomantic-ui/2.9.4/semantic.min.css" integrity="sha512-ySrYzxj+EI1e9xj/kRYqeDL5l1wW0IWY8pzHNTIZ+vc1D3Z14UDNPbwup4yOUmlRemYjgUXsUZ/xvCQU2ThEAw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
@@ -130,8 +131,8 @@
                     </div>
                 </div>
                 <div class="menu-bottom">
-                    <div class="item"><i class="cog icon"></i> Settings</div>
-                    <div class="item"><i class="sign-out icon"></i> Logout</div>
+                    <div class="item"><i class="cog icon"></i></div>
+                    <div class="item"><i class="sign out alternate icon"></i></div>
                 </div>
             </nav>
             <main class="content">

+ 28 - 10
dezukvmd/www/kvmevt.js → dezukvmd/www/js/kvmevt.js

@@ -10,12 +10,33 @@ const cursorCaptureElementId = "remoteCapture";
 let socket;
 let protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
 let port = window.location.port ? window.location.port : (protocol === 'wss' ? 443 : 80);
-let socketURL = `${protocol}://${window.location.hostname}:${port}/hid`;
+let hidSocketURL = `${protocol}://${window.location.hostname}:${port}/api/v1/hid/{uuid}/events`;
+let audioSocketURL = `${protocol}://${window.location.hostname}:${port}/api/v1/stream/{uuid}/audio`;
 let mouseMoveAbsolute = true; // Set to true for absolute mouse coordinates, false for relative
 let mouseIsOutside = false; //Mouse is outside capture element
 let audioFrontendStarted = false; //Audio frontend has been started
+let kvmDeviceUUID = ""; //UUID of the device being controlled
 
 
+if (window.location.hash.length > 1){
+    kvmDeviceUUID = window.location.hash.substring(1);
+    hidSocketURL = hidSocketURL.replace("{uuid}", kvmDeviceUUID);
+    audioSocketURL = audioSocketURL.replace("{uuid}", kvmDeviceUUID);
+}
+
+$(document).ready(function() {
+    setStreamingSource(kvmDeviceUUID);
+    //Start HID WebSocket
+    startHidWebSocket();
+});
+
+/* Stream endpoint */
+function setStreamingSource(deviceUUID) {
+    let videoStreamURL = `/api/v1/stream/${deviceUUID}/video`
+    let videoElement = document.getElementById("remoteCapture");
+    videoElement.src = videoStreamURL;
+}
+
 /* Mouse events */
 function handleMouseMove(event) {
     const hidCommand = {
@@ -216,14 +237,14 @@ function handleKeyUp(event) {
     }
 }
 
-/* Start and Stop events */
-function startWebSocket(){
+/* Start and Stop HID events */
+function startHidWebSocket(){
     if (socket){
         //Already started
         console.warn("Invalid usage: HID Transport Websocket already started!");
         return;
     }
-    const socketUrl = socketURL;
+    const socketUrl = hidSocketURL;
     socket = new WebSocket(socketUrl);
 
     socket.addEventListener('open', function(event) {
@@ -270,11 +291,8 @@ function startAudioWebSocket(quality="standard") {
         console.warn("Audio WebSocket already started");
         return;
     }
-    let protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
-    let port = window.location.port ? window.location.port : (protocol === 'wss' ? 443 : 80);
-    let audioSocketURL = `${protocol}://${window.location.hostname}:${port}/audio?quality=${quality}`;
-
-    audioSocket = new WebSocket(audioSocketURL);
+    const socketURL = `${audioSocketURL}?quality=${quality}`;
+    audioSocket = new WebSocket(socketURL);
     audioSocket.binaryType = 'arraybuffer';
 
     audioSocket.onopen = function() {
@@ -390,7 +408,7 @@ function stopAudioWebSocket() {
     }
 }
 
-startWebSocket();
+
 
 window.addEventListener('beforeunload', function() {
     stopAudioWebSocket();

+ 32 - 3
dezukvmd/www/ui.js → dezukvmd/www/js/viewport.js

@@ -1,6 +1,5 @@
 /*
-    ui.js
-    
+    viewport.js
 */
 
 function cjax(object){
@@ -20,6 +19,8 @@ function cjax(object){
 // Add cjax as a jQuery method
 $.cjax = cjax;
 
+
+
 function switchMassStorageToRemote(){
     $.cjax({
         url: '/aux/switchusbremote',
@@ -69,4 +70,32 @@ function toggleFullScreen(){
             document.msExitFullscreen();
         }
     }
-}
+}
+
+
+function measureMJPEGfps(imgId, callback, intervalMs = 1000) {
+    let frameCount = 0;
+    let lastSrc = '';
+    const img = document.getElementById(imgId);
+
+    if (!img) {
+        console.error('Image element not found');
+        return;
+    }
+
+    // Listen for src changes (for MJPEG, the src stays the same, but the image data updates)
+    img.addEventListener('load', () => {
+        frameCount++;
+    });
+
+    // Periodically report FPS
+    setInterval(() => {
+        callback(frameCount);
+        frameCount = 0;
+    }, intervalMs);
+}
+
+// Example usage:
+measureMJPEGfps('remoteCapture', function(fps) {
+    document.title = `${fps} fps| DezuKVM`;
+});

+ 6 - 5
dezukvmd/www/login.html

@@ -3,9 +3,12 @@
 <head>
     <meta charset="UTF-8">
     <title>DezuKVM | Login</title>
-    <meta name="csrf_token" content="">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <link rel="icon" type="image/png" href="/favicon.png">
+    <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
+    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/fomantic-ui/2.9.4/semantic.min.css" integrity="sha512-ySrYzxj+EI1e9xj/kRYqeDL5l1wW0IWY8pzHNTIZ+vc1D3Z14UDNPbwup4yOUmlRemYjgUXsUZ/xvCQU2ThEAw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/fomantic-ui/2.9.4/semantic.min.js" integrity="sha512-Y/wIVu+S+XJsDL7I+nL50kAVFLMqSdvuLqF2vMoRqiMkmvcqFjEpEgeu6Rx8tpZXKp77J8OUpMKy0m3jLYhbbw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
+        
     <style>
         html, body {
             height: 100%;
@@ -82,9 +85,7 @@
             display: none;
         }
     </style>
-    <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
-    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/fomantic-ui/2.9.4/semantic.min.css" integrity="sha512-ySrYzxj+EI1e9xj/kRYqeDL5l1wW0IWY8pzHNTIZ+vc1D3Z14UDNPbwup4yOUmlRemYjgUXsUZ/xvCQU2ThEAw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
-    <script src="https://cdnjs.cloudflare.com/ajax/libs/fomantic-ui/2.9.4/semantic.min.js" integrity="sha512-Y/wIVu+S+XJsDL7I+nL50kAVFLMqSdvuLqF2vMoRqiMkmvcqFjEpEgeu6Rx8tpZXKp77J8OUpMKy0m3jLYhbbw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
+   
 </head>
 <body>
     <div class="login-container ui basic segment">
@@ -102,7 +103,7 @@
         <div style="width:100%; margin-top: 1rem; display: flex; justify-content: space-between; font-size: 0.95em;">
             <div>
             <div class="ui breadcrumb">
-                <a href="/forgot-password" class="section">Forgot Password?</a>
+                <a href="/forgot-password.html" class="section">Forgot Password?</a>
                 <div class="divider"> / </div>
                 <a href="https://dezukvm.com" target="_blank" rel="noopener" class="section">DezuKVM</a>
             </div>

+ 1 - 1
dezukvmd/www/main.css → dezukvmd/www/viewport.css

@@ -19,7 +19,7 @@ body {
     display: block;
     margin: auto;
     object-fit: contain;
-    cursor: url('img/cursor_overlay.png') 10 10, pointer;
+    cursor: url('img/cursor_overlay.png') 0 0, pointer;
 }
 
 #menu {

+ 7 - 13
dezukvmd/www/viewport.html

@@ -5,27 +5,21 @@
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <meta name="description" content="dezuKVM Management Interface">
     <meta name="author" content="imuslab">
-    <meta name="csrf-token" content="">
-    <title>Connected | dezuKVM</title>
-    
-    <!-- OpenGraph Metadata -->
-    <meta property="og:title" content="dezuKVM Management Interface">
-    <meta property="og:description" content="A web-based management interface for dezuKVM">
-    <meta property="og:type" content="website">
-    <meta property="og:url" content="https://kvm.aroz.org">
-    <meta property="og:image" content="https://kvm.aroz.org/og.jpg">
+    <meta name="dezukvm.csrf.token" content="{{.csrfToken}}">
+    <title>Connecting | DezuKVM</title>
     <script src="js/jquery-3.7.1.min.js"></script>
-    <link rel="stylesheet" href="main.css">
+    <link rel="stylesheet" href="viewport.css">
+    <link rel="icon" type="image/png" href="/favicon.png">
 </head>
 <body>
-    <img id="remoteCapture" src="/stream" oncontextmenu="return false;"></img>
+    <img id="remoteCapture" src="" oncontextmenu="return false;"></img>
     <div id="menu">
         <button id="btnFullScreen" onclick="toggleFullScreen()">Fullscreen</button>
         <button id="btnCtrlAltDel" onclick="sendCtrlAltDel()">Ctrl+Alt+Del</button>
         <button id="btnFullScreen" onclick="switchMassStorageToKvm()">Switch Storage to KVM</button>
         <button id="btnFullScreen" onclick="switchMassStorageToRemote()">Switch Storage to Remote</button>
     </div>
-    <script src="ui.js"></script>
-    <script src="kvmevt.js"></script>
+    <script src="js/viewport.js"></script>
+    <script src="js/kvmevt.js"></script>
 </body>
 </html>

+ 0 - 0
designs/ref/Screenshot 2023-04-19 at 00-38-33 #6.2 Photo & Design.png → ref/Screenshot 2023-04-19 at 00-38-33 #6.2 Photo & Design.png


+ 0 - 0
designs/ref/Screenshot 2023-04-19 at 00-38-43 #6.2 Photo & Design.png → ref/Screenshot 2023-04-19 at 00-38-43 #6.2 Photo & Design.png


+ 0 - 0
designs/ref/leaf-line.svg → ref/leaf-line.svg