diff --git a/Makefile b/Makefile index 591d64b45..e91d74daf 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ DOCKER_RUN_BASE := docker run -e UID=$(UID) -e GID=$(GID) -v $(PWD):/home/build/ GO_BUILD_CMD := cd /home/build/NanoKVM/server && go mod tidy && CGO_ENABLED=1 GOOS=linux GOARCH=riscv64 CC=riscv64-unknown-linux-musl-gcc CGO_CFLAGS="-mcpu=c906fdv -march=rv64imafdcv0p7xthead -mcmodel=medany -mabi=lp64d" go build SUPPORT_BUILD_CMD := . ./home/build/MaixCDK/bin/activate && cd /home/build/NanoKVM/support/sg2002 && ./build kvm_system && ./build kvm_system add_to_kvmapp -.PHONY: help check-root builder-image rebuild-image check-image shell app support all clean +.PHONY: help check-root builder-image rebuild-image check-image shell app support all test test-server test-usb-scripts test-web check clean # Default target all: app support @@ -31,6 +31,11 @@ help: @echo " app - Build Go application server" @echo " support - Build hardware support libraries" @echo " all - Build both app and support (default)" + @echo " test - Run server and USB init script tests" + @echo " test-server - Run Go server tests" + @echo " test-usb-scripts - Run USB init script tests" + @echo " test-web - Run web TypeScript production build" + @echo " check - Run all tests and web build" @echo " clean - Clean build artifacts" @echo "" @echo "Prerequisites:" @@ -83,6 +88,22 @@ support: check-root builder-image @echo "Building support..." @$(DOCKER_RUN_BASE) -it $(IMAGE_NAME) /bin/bash -c '$(SUPPORT_BUILD_CMD)' +test: test-usb-scripts test-server + +test-server: + @echo "Running server tests..." + @cd server && go test ./... + +test-usb-scripts: + @echo "Running USB init script tests..." + @sh tests/usb-init-scripts-test.sh + +test-web: + @echo "Running web build..." + @cd web && pnpm build + +check: test test-web + # Clean build artifacts clean: @echo "Cleaning build artifacts..." @@ -94,4 +115,4 @@ clean: rm -rf support/sg2002/build; \ echo "Removed support/sg2002/build"; \ fi - @echo "Clean completed." \ No newline at end of file + @echo "Clean completed." diff --git a/kvmapp/system/init.d/S03usb-common b/kvmapp/system/init.d/S03usb-common new file mode 100644 index 000000000..b40212dcd --- /dev/null +++ b/kvmapp/system/init.d/S03usb-common @@ -0,0 +1,221 @@ +#!/bin/sh +# shellcheck disable=SC2034 + +USB_BOOT_DIR="${USB_BOOT_DIR:-/boot}" +USB_GADGET_ROOT="${USB_GADGET_ROOT:-/sys/kernel/config/usb_gadget}" +USB_GADGET_NAME="${USB_GADGET_NAME:-g0}" +USB_UDC_CLASS="${USB_UDC_CLASS:-/sys/class/udc}" +USB_OTG_ROLE="${USB_OTG_ROLE:-/proc/cviusb/otg_role}" +USB_PHY_DEVICE="${USB_PHY_DEVICE:-4340000.usb}" +USB_DWC2_BIND="${USB_DWC2_BIND:-/sys/bus/platform/drivers/dwc2/bind}" +USB_DWC2_UNBIND="${USB_DWC2_UNBIND:-/sys/bus/platform/drivers/dwc2/unbind}" +PATH="/sbin:/usr/sbin:/bin:/usr/bin${PATH:+:${PATH}}" +export PATH + +USB_DEFAULT_VENDOR_ID='0x3346' +USB_DEFAULT_PRODUCT_ID='0x1009' +USB_SERIAL_NUMBER='0123456789ABCDEF' +USB_MANUFACTURER='sipeed' +USB_PRODUCT='NanoKVM' +USB_CONFIGURATION='NanoKVM' + +USB_NORMAL_BCD_USB='0x0200' +USB_NORMAL_BCD_DEVICE='0x0510' +USB_NORMAL_CONFIG_ATTRIBUTES='0xE0' +USB_NORMAL_MAX_POWER='120' +USB_HID_ONLY_BCD_USB='0x0110' +USB_HID_ONLY_BCD_DEVICE='0x0623' +USB_HID_ONLY_CONFIG_ATTRIBUTES='0xA0' +USB_HID_ONLY_MAX_POWER='200' + +USB_HID_KEYBOARD_FUNC='hid.GS0' +USB_HID_RELATIVE_MOUSE_FUNC='hid.GS1' +USB_HID_ABSOLUTE_MOUSE_FUNC='hid.GS2' +USB_HID_BOOT_SUBCLASS='1' +USB_HID_NON_BOOT_SUBCLASS='0' +USB_HID_KEYBOARD_PROTOCOL='1' +USB_HID_MOUSE_PROTOCOL='2' +USB_HID_KEYBOARD_REPORT_LENGTH='8' +USB_HID_RELATIVE_MOUSE_REPORT_LENGTH='4' +USB_HID_ABSOLUTE_MOUSE_REPORT_LENGTH='6' + +USB_HID_REPORT_NORMAL_KEYBOARD='normal-keyboard' +USB_HID_REPORT_HID_ONLY_KEYBOARD='hid-only-keyboard' +USB_HID_REPORT_RELATIVE_MOUSE='relative-mouse' +USB_HID_REPORT_NORMAL_ABSOLUTE_MOUSE='normal-absolute-mouse' +USB_HID_REPORT_HID_ONLY_ABSOLUTE_MOUSE='hid-only-absolute-mouse' + +remove_existing_gadget(){ + gadget="${USB_GADGET_ROOT}/${USB_GADGET_NAME}" + [ -d "${gadget}" ] || return 0 + + echo '' > "${gadget}/UDC" 2>/dev/null || true + if [ -d "${gadget}/configs/c.1" ] + then + find "${gadget}/configs/c.1" -mindepth 1 -maxdepth 1 -type l -exec rm {} \; + rmdir "${gadget}/configs/c.1/strings/0x409" 2>/dev/null || true + rmdir "${gadget}/configs/c.1/strings" 2>/dev/null || true + rmdir "${gadget}/configs/c.1" 2>/dev/null || true + fi + + if [ -d "${gadget}/functions" ] + then + for func in "${gadget}/functions"/* + do + [ -d "${func}" ] || continue + rmdir "${func}/lun.0" 2>/dev/null || true + rmdir "${func}" 2>/dev/null || true + done + fi + + rmdir "${gadget}/strings/0x409" 2>/dev/null || true + rmdir "${gadget}/strings" 2>/dev/null || true + rmdir "${gadget}/configs" 2>/dev/null || true + rmdir "${gadget}/functions" 2>/dev/null || true + rmdir "${gadget}" 2>/dev/null || true +} + +write_usb_strings(){ + mkdir strings/0x409 + printf '%s' "${USB_SERIAL_NUMBER}" > strings/0x409/serialnumber + printf '%s' "${USB_MANUFACTURER}" > strings/0x409/manufacturer + printf '%s' "${USB_PRODUCT}" > strings/0x409/product +} + +write_usb_config_string(){ + mkdir -p configs/c.1/strings/0x409 + printf '%s' "${USB_CONFIGURATION}" > configs/c.1/strings/0x409/configuration +} + +write_boot_or_default(){ + if [ -e "${USB_BOOT_DIR}/$2" ] + then + cat "${USB_BOOT_DIR}/$2" > "$1" + else + echo "$3" > "$1" + fi +} + +prepare_usb_gadget(){ + echo "usb mode: device" + cd "${USB_GADGET_ROOT}" || exit 1 + remove_existing_gadget + mkdir "${USB_GADGET_NAME}" + cd "${USB_GADGET_NAME}" || exit 1 + + echo "$1" > bcdUSB + echo "$2" > bcdDevice + write_boot_or_default idVendor usb.vid "${USB_DEFAULT_VENDOR_ID}" + write_boot_or_default idProduct usb.pid "${USB_DEFAULT_PRODUCT_ID}" + write_usb_strings + + mkdir configs/c.1 + echo "$3" > configs/c.1/bmAttributes + echo "$4" > configs/c.1/MaxPower + write_usb_config_string +} + +set_hid_wakeup(){ + if [ ! -e "${USB_BOOT_DIR}/usb.notwakeup" ] && [ -e "functions/$1/wakeup_on_write" ] + then + echo 1 > "functions/$1/wakeup_on_write" + fi +} + +add_hid_function(){ + func="$1" + subclass="$2" + protocol="$3" + report_length="$4" + report_desc="$5" + + mkdir "functions/${func}" + echo "${subclass}" > "functions/${func}/subclass" + set_hid_wakeup "${func}" + echo "${protocol}" > "functions/${func}/protocol" + echo "${report_length}" > "functions/${func}/report_length" + write_hid_report_desc "${report_desc}" > "functions/${func}/report_desc" + ln -s "functions/${func}" configs/c.1 +} + +write_hid_report_desc(){ + case "$1" in + "${USB_HID_REPORT_NORMAL_KEYBOARD}") + printf '%b' '\005\001\011\006\241\001\005\007\031\340\051\347\025\000\045\001\165\001\225\010\201\002\225\001\165\010\201\003\225\005\165\001\005\010\031\001\051\005\221\002\225\001\165\003\221\003\225\006\165\010\025\000\045\347\005\007\031\000\051\347\201\000\300' + ;; + "${USB_HID_REPORT_HID_ONLY_KEYBOARD}") + printf '%b' '\005\001\011\006\241\001\005\007\031\340\051\347\025\000\045\001\165\001\225\010\201\002\225\001\165\010\201\003\225\005\165\001\005\010\031\001\051\005\221\002\225\001\165\003\221\003\225\006\165\010\025\000\045\145\005\007\031\000\051\145\201\000\300' + ;; + "${USB_HID_REPORT_RELATIVE_MOUSE}") + printf '%b' '\005\001\011\002\241\001\011\001\241\000\005\011\031\001\051\003\025\000\045\001\225\003\165\001\201\002\225\001\165\005\201\003\005\001\011\060\011\061\011\070\025\201\045\177\165\010\225\003\201\006\300\300' + ;; + "${USB_HID_REPORT_NORMAL_ABSOLUTE_MOUSE}") + printf '%b' '\005\001\011\002\241\001\011\001\241\000\005\011\031\001\051\005\025\000\045\001\225\005\165\001\201\002\225\001\165\003\201\001\005\001\011\060\011\061\025\000\046\377\177\065\000\106\377\177\165\020\225\002\201\002\005\001\011\070\025\201\045\177\065\000\105\000\165\010\225\001\201\006\300\300' + ;; + "${USB_HID_REPORT_HID_ONLY_ABSOLUTE_MOUSE}") + printf '%b' '\005\001\011\002\241\001\011\001\241\000\005\011\031\001\051\003\025\000\045\001\225\003\165\001\201\002\225\001\165\005\201\001\005\001\011\060\011\061\025\000\046\377\177\065\000\106\377\177\165\020\225\002\201\002\005\001\011\070\025\201\045\177\065\000\105\000\165\010\225\001\201\006\300\300' + ;; + *) echo "unknown HID report descriptor: $1" >&2; return 1 ;; + esac +} + +add_hid_functions(){ + keyboard_report="$1" + absolute_mouse_report="$2" + add_hid_function "${USB_HID_KEYBOARD_FUNC}" "${USB_HID_BOOT_SUBCLASS}" "${USB_HID_KEYBOARD_PROTOCOL}" "${USB_HID_KEYBOARD_REPORT_LENGTH}" "${keyboard_report}" + add_hid_function "${USB_HID_RELATIVE_MOUSE_FUNC}" "${USB_HID_BOOT_SUBCLASS}" "${USB_HID_MOUSE_PROTOCOL}" "${USB_HID_RELATIVE_MOUSE_REPORT_LENGTH}" "${USB_HID_REPORT_RELATIVE_MOUSE}" + add_hid_function "${USB_HID_ABSOLUTE_MOUSE_FUNC}" "${USB_HID_NON_BOOT_SUBCLASS}" "${USB_HID_MOUSE_PROTOCOL}" "${USB_HID_ABSOLUTE_MOUSE_REPORT_LENGTH}" "${absolute_mouse_report}" +} + +first_udc(){ find "${USB_UDC_CLASS}" -mindepth 1 -maxdepth 1 -exec basename {} \; | sort | sed -n '1p'; } + +bind_first_udc(){ + udc=$(first_udc) + [ -n "${udc}" ] || return 1 + printf '%s' "${udc}" > UDC + echo device > "${USB_OTG_ROLE}" +} + +unbind_udc(){ + echo '' > "${USB_GADGET_ROOT}/${USB_GADGET_NAME}/UDC" +} + +start_usb_host(){ + unbind_udc + echo host > "${USB_OTG_ROLE}" +} + +restart_udc(){ + echo '' > "${USB_GADGET_ROOT}/${USB_GADGET_NAME}/UDC" + sleep 1 + udc=$(first_udc) + [ -n "${udc}" ] || return 1 + printf '%s' "${udc}" > "${USB_GADGET_ROOT}/${USB_GADGET_NAME}/UDC" +} + +restart_usb_dev(){ + restart_udc + echo "USB Restart OK!" +} + +restart_usb_phy(){ + command -v start_usb_dev >/dev/null || { + echo "start_usb_dev is not defined" >&2 + return 1 + } + echo "${USB_PHY_DEVICE}" > "${USB_DWC2_UNBIND}" + sleep 1 + echo "${USB_PHY_DEVICE}" > "${USB_DWC2_BIND}" + start_usb_host + start_usb_dev +} + +run_usb_action(){ + case "$1" in + start) start_usb_dev ;; + restart) restart_usb_dev ;; + stop) start_usb_host ;; + stop_start) start_usb_host; start_usb_dev ;; + restart_phy) restart_usb_phy ;; + esac +} diff --git a/kvmapp/system/init.d/S03usbdev b/kvmapp/system/init.d/S03usbdev index df5e24862..c73a145cf 100755 --- a/kvmapp/system/init.d/S03usbdev +++ b/kvmapp/system/init.d/S03usbdev @@ -1,159 +1,63 @@ #!/bin/sh # kvmhwd Rev2.1 -start_usb_dev(){ - . /etc/profile - echo "usb mode: device" - cd /sys/kernel/config/usb_gadget +# shellcheck source=/kvmapp/system/init.d/S03usb-common +. "${USB_COMMON:-/kvmapp/system/init.d/S03usb-common}" + +USB_NCM_FUNC='ncm.usb0' +USB_RNDIS_FUNC='rndis.usb0' +USB_MASS_STORAGE_FUNC='mass_storage.disk0' +USB_DATA_DISK_FUNC='mass_storage.disk1' +USB_MASS_STORAGE_INQUIRY='NanoKVM USB Mass Storage0520' +USB_DATA_DISK_INQUIRY='NanoKVM USB Data Disk0520' +USB_DATA_DISK_BACKING='/dev/mmcblk0p3' - mkdir g0 - cd g0 +start_usb_dev(){ + prepare_usb_gadget "${USB_NORMAL_BCD_USB}" "${USB_NORMAL_BCD_DEVICE}" "${USB_NORMAL_CONFIG_ATTRIBUTES}" "${USB_NORMAL_MAX_POWER}" - # echo 0x3346 > idVendor - # echo 0x1009 > idProduct - if [ -e /boot/usb.vid ] + if [ -e "${USB_BOOT_DIR}/usb.ncm" ] then - cat /boot/usb.vid > idVendor - else - echo 0x3346 > idVendor - fi - if [ -e /boot/usb.pid ] + mkdir "functions/${USB_NCM_FUNC}" + ln -s "functions/${USB_NCM_FUNC}" configs/c.1/ + elif [ -e "${USB_BOOT_DIR}/usb.rndis0" ] then - cat /boot/usb.pid > idProduct - else - echo 0x1009 > idProduct + mkdir "functions/${USB_RNDIS_FUNC}" + ln -s "functions/${USB_RNDIS_FUNC}" configs/c.1/ fi - mkdir strings/0x409 - echo '0123456789ABCDEF' > strings/0x409/serialnumber - echo 'sipeed' > strings/0x409/manufacturer - echo 'NanoKVM' > strings/0x409/product - - mkdir configs/c.1 - echo 0xE0 > configs/c.1/bmAttributes - echo 120 > configs/c.1/MaxPower - mkdir configs/c.1/strings/0x409 - echo "NanoKVM" > configs/c.1/strings/0x409/configuration - if [ -e /boot/usb.ncm ] + if [ ! -e "${USB_BOOT_DIR}/disable_hid" ] then - mkdir functions/ncm.usb0 - ln -s functions/ncm.usb0 configs/c.1/ - else - if [ -e /boot/usb.rndis0 ] - then - mkdir functions/rndis.usb0 - ln -s functions/rndis.usb0 configs/c.1/ - fi + add_hid_functions "${USB_HID_REPORT_NORMAL_KEYBOARD}" "${USB_HID_REPORT_NORMAL_ABSOLUTE_MOUSE}" fi - if [ ! -e /boot/disable_hid ] + if [ -e "${USB_BOOT_DIR}/usb.media0" ] then - # keyboard - mkdir functions/hid.GS0 - if [ -e /boot/BIOS ] + mkdir "functions/${USB_MASS_STORAGE_FUNC}" + ln -s "functions/${USB_MASS_STORAGE_FUNC}" configs/c.1/ + echo 1 > "functions/${USB_MASS_STORAGE_FUNC}/lun.0/removable" + if [ -e "${USB_BOOT_DIR}/usb.media0.ro" ] then - echo 1 > functions/hid.GS0/subclass + echo 1 > "functions/${USB_MASS_STORAGE_FUNC}/lun.0/ro" + echo 0 > "functions/${USB_MASS_STORAGE_FUNC}/lun.0/cdrom" fi - if [ ! -e /boot/usb.notwakeup ] + printf '%s' "${USB_MASS_STORAGE_INQUIRY}" > "functions/${USB_MASS_STORAGE_FUNC}/lun.0/inquiry_string" + disk=$(cat "${USB_BOOT_DIR}/usb.media0") + if [ -n "${disk}" ] then - echo 1 > functions/hid.GS0/wakeup_on_write + cat "${USB_BOOT_DIR}/usb.media0" > "functions/${USB_MASS_STORAGE_FUNC}/lun.0/file" fi - echo 1 > functions/hid.GS0/protocol - echo 8 > functions/hid.GS0/report_length - echo -ne \\x05\\x01\\x09\\x06\\xa1\\x01\\x05\\x07\\x19\\xe0\\x29\\xe7\\x15\\x00\\x25\\x01\\x75\\x01\\x95\\x08\\x81\\x02\\x95\\x01\\x75\\x08\\x81\\x03\\x95\\x05\\x75\\x01\\x05\\x08\\x19\\x01\\x29\\x05\\x91\\x02\\x95\\x01\\x75\\x03\\x91\\x03\\x95\\x06\\x75\\x08\\x15\\x00\\x25\\xE7\\x05\\x07\\x19\\x00\\x29\\xE7\\x81\\x00\\xc0 > functions/hid.GS0/report_desc - ln -s functions/hid.GS0 configs/c.1 - - # mouse - mkdir functions/hid.GS1 - if [ -e /boot/BIOS ] - then - echo 1 > functions/hid.GS1/subclass - fi - if [ ! -e /boot/usb.notwakeup ] - then - echo 1 > functions/hid.GS1/wakeup_on_write - fi - echo 2 > functions/hid.GS1/protocol - echo 4 > functions/hid.GS1/report_length - echo -ne \\x5\\x1\\x9\\x2\\xa1\\x1\\x9\\x1\\xa1\\x0\\x5\\x9\\x19\\x1\\x29\\x3\\x15\\x0\\x25\\x1\\x95\\x3\\x75\\x1\\x81\\x2\\x95\\x1\\x75\\x5\\x81\\x3\\x5\\x1\\x9\\x30\\x9\\x31\\x9\\x38\\x15\\x81\\x25\\x7f\\x75\\x8\\x95\\x3\\x81\\x6\\xc0\\xc0 > functions/hid.GS1/report_desc - ln -s functions/hid.GS1 configs/c.1 - - # touchpad - mkdir functions/hid.GS2 - if [ -e /boot/BIOS ] - then - echo 1 > functions/hid.GS2/subclass - fi - if [ ! -e /boot/usb.notwakeup ] - then - echo 1 > functions/hid.GS2/wakeup_on_write - fi - echo 2 > functions/hid.GS2/protocol - echo 6 > functions/hid.GS2/report_length - echo -ne \\x05\\x01\\x09\\x02\\xa1\\x01\\x09\\x01\\xa1\\x00\\x05\\x09\\x19\\x01\\x29\\x05\\x15\\x00\\x25\\x01\\x95\\x05\\x75\\x01\\x81\\x02\\x95\\x01\\x75\\x03\\x81\\x01\\x05\\x01\\x09\\x30\\x09\\x31\\x15\\x00\\x26\\xff\\x7f\\x35\\x00\\x46\\xff\\x7f\\x75\\x10\\x95\\x02\\x81\\x02\\x05\\x01\\x09\\x38\\x15\\x81\\x25\\x7f\\x35\\x00\\x45\\x00\\x75\\x08\\x95\\x01\\x81\\x06\\xc0\\xc0 > functions/hid.GS2/report_desc - ln -s functions/hid.GS2 configs/c.1 fi - if [ -e /boot/usb.disk0 ] + if [ -e "${USB_BOOT_DIR}/usb.disk0" ] then - mkdir functions/mass_storage.disk0 - ln -s functions/mass_storage.disk0 configs/c.1/ - echo 1 > functions/mass_storage.disk0/lun.0/removable - if [ -e /boot/usb.disk0.ro ] - then - echo 1 > functions/mass_storage.disk0/lun.0/ro - echo 0 > functions/mass_storage.disk0/lun.0/cdrom - fi - echo "NanoKVM USB Mass Storage0520" > functions/mass_storage.disk0/lun.0/inquiry_string - disk=$(cat /boot/usb.disk0) - if [ -z "${disk}" ] - then - # if [ ! -e /mnt/usbdisk.img ] - # then - # fallocate -l 8G /mnt/usbdisk.img - # mkfs.vfat /mnt/usbdisk.img - # fi - echo /dev/mmcblk0p3 > functions/mass_storage.disk0/lun.0/file - else - cat /boot/usb.disk0 > functions/mass_storage.disk0/lun.0/file - fi + mkdir "functions/${USB_DATA_DISK_FUNC}" + ln -s "functions/${USB_DATA_DISK_FUNC}" configs/c.1/ + echo 1 > "functions/${USB_DATA_DISK_FUNC}/lun.0/removable" + printf '%s' "${USB_DATA_DISK_INQUIRY}" > "functions/${USB_DATA_DISK_FUNC}/lun.0/inquiry_string" + echo "${USB_DATA_DISK_BACKING}" > "functions/${USB_DATA_DISK_FUNC}/lun.0/file" fi - ls /sys/class/udc/ | cat > UDC - echo device > /proc/cviusb/otg_role -} - -start_usb_host(){ - echo '' > /sys/kernel/config/usb_gadget/g0/UDC - echo host > /proc/cviusb/otg_role -} - -restart_usb_dev(){ - echo > /sys/kernel/config/usb_gadget/g0/UDC - sleep 1 - ls /sys/class/udc/ | cat > /sys/kernel/config/usb_gadget/g0/UDC - echo "USB Restart OK!" + bind_first_udc } -case "$1" in - start) - start_usb_dev - ;; - restart) - restart_usb_dev - ;; - stop) - start_usb_host - ;; - stop_start) - start_usb_host - start_usb_dev - ;; - restart_phy) - echo "4340000.usb" > /sys/bus/platform/drivers/dwc2/unbind - sleep 1 - echo "4340000.usb" > /sys/bus/platform/drivers/dwc2/bind - start_usb_host - start_usb_dev - ;; -esac +run_usb_action "$1" diff --git a/kvmapp/system/init.d/S03usbhid b/kvmapp/system/init.d/S03usbhid index b5f160e8a..8f1179b18 100755 --- a/kvmapp/system/init.d/S03usbhid +++ b/kvmapp/system/init.d/S03usbhid @@ -1,112 +1,13 @@ #!/bin/sh # kvmhwd hid only -start_usb_dev(){ - . /etc/profile - echo "usb mode: device" - cd /sys/kernel/config/usb_gadget - - mkdir g0 - cd g0 - - echo 0x0101 > bcdUSB - echo 0x0623 > bcdDevice - # echo 0x3346 > idVendor - # echo 0x1009 > idProduct - if [ -e /boot/usb.vid ] - then - cat /boot/usb.vid > idVendor - else - echo 0x3346 > idVendor - fi - if [ -e /boot/usb.pid ] - then - cat /boot/usb.pid > idProduct - else - echo 0x1009 > idProduct - fi - mkdir strings/0x409 - # echo '0' > strings/0x409/serialnumber - echo 'sipeed' > strings/0x409/manufacturer - echo 'NanoKVM' > strings/0x409/product - - mkdir configs/c.1 - echo 0xA0 > configs/c.1/bmAttributes - echo 200 > configs/c.1/MaxPower - mkdir configs/c.1/strings/0x409 - echo "NanoKVM" > configs/c.1/strings/0x409/configuration - - # keyboard - mkdir functions/hid.GS0 - echo 1 > functions/hid.GS0/subclass - if [ ! -e /boot/usb.notwakeup ] - then - echo 1 > functions/hid.GS0/wakeup_on_write - fi - echo 1 > functions/hid.GS0/protocol - echo 8 > functions/hid.GS0/report_length - echo -ne \\x05\\x01\\x09\\x06\\xa1\\x01\\x05\\x07\\x19\\xe0\\x29\\xe7\\x15\\x00\\x25\\x01\\x75\\x01\\x95\\x08\\x81\\x02\\x95\\x01\\x75\\x08\\x81\\x03\\x95\\x05\\x75\\x01\\x05\\x08\\x19\\x01\\x29\\x05\\x91\\x02\\x95\\x01\\x75\\x03\\x91\\x03\\x95\\x06\\x75\\x08\\x15\\x00\\x25\\x65\\x05\\x07\\x19\\x00\\x29\\x65\\x81\\x00\\xc0 > functions/hid.GS0/report_desc - ln -s functions/hid.GS0 configs/c.1 - - # mouse - mkdir functions/hid.GS1 - echo 1 > functions/hid.GS1/subclass - if [ ! -e /boot/usb.notwakeup ] - then - echo 1 > functions/hid.GS1/wakeup_on_write - fi - echo 2 > functions/hid.GS1/protocol - echo 4 > functions/hid.GS1/report_length - echo -ne \\x5\\x1\\x9\\x2\\xa1\\x1\\x9\\x1\\xa1\\x0\\x5\\x9\\x19\\x1\\x29\\x3\\x15\\x0\\x25\\x1\\x95\\x3\\x75\\x1\\x81\\x2\\x95\\x1\\x75\\x5\\x81\\x3\\x5\\x1\\x9\\x30\\x9\\x31\\x9\\x38\\x15\\x81\\x25\\x7f\\x75\\x8\\x95\\x3\\x81\\x6\\xc0\\xc0 > functions/hid.GS1/report_desc - ln -s functions/hid.GS1 configs/c.1 +# shellcheck source=/kvmapp/system/init.d/S03usb-common +. "${USB_COMMON:-/kvmapp/system/init.d/S03usb-common}" - # touchpad - mkdir functions/hid.GS2 - echo 1 > functions/hid.GS2/subclass - if [ ! -e /boot/usb.notwakeup ] - then - echo 1 > functions/hid.GS2/wakeup_on_write - fi - echo 2 > functions/hid.GS2/protocol - echo 6 > functions/hid.GS2/report_length - echo -ne \\x05\\x01\\x09\\x02\\xa1\\x01\\x09\\x01\\xa1\\x00\\x05\\x09\\x19\\x01\\x29\\x03\\x15\\x00\\x25\\x01\\x95\\x03\\x75\\x01\\x81\\x02\\x95\\x01\\x75\\x05\\x81\\x01\\x05\\x01\\x09\\x30\\x09\\x31\\x15\\x00\\x26\\xff\\x7f\\x35\\x00\\x46\\xff\\x7f\\x75\\x10\\x95\\x02\\x81\\x02\\x05\\x01\\x09\\x38\\x15\\x81\\x25\\x7f\\x35\\x00\\x45\\x00\\x75\\x08\\x95\\x01\\x81\\x06\\xc0\\xc0 > functions/hid.GS2/report_desc - ln -s functions/hid.GS2 configs/c.1 - - ls /sys/class/udc/ | cat > UDC - echo device > /proc/cviusb/otg_role -} - -start_usb_host(){ - echo '' > /sys/kernel/config/usb_gadget/g0/UDC - echo host > /proc/cviusb/otg_role -} - -restart_usb_dev(){ - echo > /sys/kernel/config/usb_gadget/g0/UDC - sleep 1 - ls /sys/class/udc/ | cat > /sys/kernel/config/usb_gadget/g0/UDC - echo "USB Restart OK!" +start_usb_dev(){ + prepare_usb_gadget "${USB_HID_ONLY_BCD_USB}" "${USB_HID_ONLY_BCD_DEVICE}" "${USB_HID_ONLY_CONFIG_ATTRIBUTES}" "${USB_HID_ONLY_MAX_POWER}" + add_hid_functions "${USB_HID_REPORT_HID_ONLY_KEYBOARD}" "${USB_HID_REPORT_HID_ONLY_ABSOLUTE_MOUSE}" + bind_first_udc } -case "$1" in - start) - start_usb_dev - ;; - restart) - restart_usb_dev - ;; - stop) - start_usb_host - ;; - stop_start) - start_usb_host - start_usb_dev - ;; - restart_phy) - echo "4340000.usb" > /sys/bus/platform/drivers/dwc2/unbind - sleep 1 - echo "4340000.usb" > /sys/bus/platform/drivers/dwc2/bind - start_usb_host - start_usb_dev - ;; -esac +run_usb_action "$1" diff --git a/server/service/hid/hid.go b/server/service/hid/hid.go index 60185ba8c..6efd6ca6c 100644 --- a/server/service/hid/hid.go +++ b/server/service/hid/hid.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "strings" "sync" "syscall" "time" @@ -193,6 +194,24 @@ func (h *Hid) closeDeviceNoLock(device hidDevice) { } } +func (h *Hid) closeDeletedDeviceNoLock(device hidDevice) { + file := device.get() + if file == nil || !hidFileWasDeleted(file) { + return + } + + log.Debugf("close %s because the cached HID handle was deleted", device.path) + h.closeDeviceNoLock(device) +} + +func hidFileWasDeleted(file *os.File) bool { + target, err := os.Readlink(fmt.Sprintf("/proc/self/fd/%d", file.Fd())) + if err != nil { + return false + } + return strings.HasSuffix(target, " (deleted)") +} + // writeWithTimeout bounds how long callers hold HID locks when writing to a // nonblocking descriptor. EAGAIN means the host is not accepting HID reports // yet, so retry until the caller's deadline expires. @@ -230,6 +249,15 @@ func isRetryableWriteError(err error) bool { return errors.Is(err, syscall.EAGAIN) || errors.Is(err, syscall.EWOULDBLOCK) } +func isStaleHIDWriteError(err error) bool { + return errors.Is(err, os.ErrClosed) || + errors.Is(err, syscall.EIO) || + errors.Is(err, syscall.ENODEV) || + errors.Is(err, syscall.ENXIO) || + errors.Is(err, syscall.EPIPE) || + errors.Is(err, syscall.ESHUTDOWN) +} + func (h *Hid) Open() { h.kbMutex.Lock() defer h.kbMutex.Unlock() @@ -272,10 +300,31 @@ func (h *Hid) writeHID(device hidDevice, data []byte) error { device.mu.Lock() defer device.mu.Unlock() + h.closeDeletedDeviceNoLock(device) if err := h.openDeviceNoLock(device); err != nil { return err } + if err := h.writeHIDNoLock(device, data); err != nil { + h.closeDeviceNoLock(device) + if !isStaleHIDWriteError(err) { + return formatHIDWriteError(err) + } + + if reopenErr := h.openDeviceNoLock(device); reopenErr != nil { + return fmt.Errorf("reopen %s after stale HID write: %w", device.path, reopenErr) + } + if retryErr := h.writeHIDNoLock(device, data); retryErr != nil { + h.closeDeviceNoLock(device) + return formatHIDWriteError(retryErr) + } + } + + log.Debugf("write to %s: %v", device.path, data) + return nil +} + +func (h *Hid) writeHIDNoLock(device hidDevice, data []byte) error { file := device.get() if file == nil { return fmt.Errorf("%s: hid handle is nil", device.path) @@ -287,17 +336,19 @@ func (h *Hid) writeHID(device hidDevice, data []byte) error { } if err := writeWithTimeout(file, data, hidWriteTimeout); err != nil { - h.closeDeviceNoLock(device) - switch { - case errors.Is(err, os.ErrClosed): - return fmt.Errorf("hid already closed: %w", err) - case errors.Is(err, os.ErrDeadlineExceeded): - return fmt.Errorf("timeout after %s: %w", hidWriteTimeout, err) - default: - return err - } + return err } - log.Debugf("write to %s: %v", device.path, data) return nil } + +func formatHIDWriteError(err error) error { + switch { + case errors.Is(err, os.ErrClosed): + return fmt.Errorf("hid already closed: %w", err) + case errors.Is(err, os.ErrDeadlineExceeded): + return fmt.Errorf("timeout after %s: %w", hidWriteTimeout, err) + default: + return err + } +} diff --git a/server/service/hid/hid_test.go b/server/service/hid/hid_test.go new file mode 100644 index 000000000..43f396034 --- /dev/null +++ b/server/service/hid/hid_test.go @@ -0,0 +1,56 @@ +package hid + +import ( + "errors" + "os" + "runtime" + "syscall" + "testing" +) + +func TestHidFileWasDeleted(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("deleted fd detection uses /proc/self/fd") + } + + file, err := os.CreateTemp(t.TempDir(), "hidg") + if err != nil { + t.Fatal(err) + } + if hidFileWasDeleted(file) { + t.Fatal("new file reported as deleted") + } + + if err := os.Remove(file.Name()); err != nil { + t.Fatal(err) + } + if !hidFileWasDeleted(file) { + t.Fatal("unlinked open file did not report as deleted") + } +} + +func TestIsStaleHIDWriteError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {name: "closed", err: os.ErrClosed, want: true}, + {name: "io", err: syscall.EIO, want: true}, + {name: "no device", err: syscall.ENODEV, want: true}, + {name: "no such device address", err: syscall.ENXIO, want: true}, + {name: "pipe", err: syscall.EPIPE, want: true}, + {name: "shutdown", err: syscall.ESHUTDOWN, want: true}, + {name: "wrapped", err: errors.Join(syscall.EIO), want: true}, + {name: "again", err: syscall.EAGAIN, want: false}, + {name: "deadline", err: os.ErrDeadlineExceeded, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isStaleHIDWriteError(tt.err); got != tt.want { + t.Fatalf("isStaleHIDWriteError(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} diff --git a/server/service/hid/status.go b/server/service/hid/status.go index 43cfce1bf..f82889c2c 100644 --- a/server/service/hid/status.go +++ b/server/service/hid/status.go @@ -1,9 +1,7 @@ package hid import ( - "NanoKVM-Server/proto" "errors" - "fmt" "io" "os" "os/exec" @@ -12,17 +10,14 @@ import ( "github.com/gin-gonic/gin" log "github.com/sirupsen/logrus" + + "NanoKVM-Server/proto" + "NanoKVM-Server/service/usb" ) const ( ModeNormal = "normal" ModeHidOnly = "hid-only" - ModeFlag = "/sys/kernel/config/usb_gadget/g0/bcdDevice" - - ModeNormalScript = "/kvmapp/system/init.d/S03usbdev" - ModeHidOnlyScript = "/kvmapp/system/init.d/S03usbhid" - - USBDevScript = "/etc/init.d/S03usbdev" ) var modeMap = map[string]string{ @@ -71,9 +66,9 @@ func (s *Service) SetHidMode(c *gin.Context) { h.Unlock() }() - srcScript := ModeNormalScript + srcScript := usb.NormalInitScript if req.Mode == ModeHidOnly { - srcScript = ModeHidOnlyScript + srcScript = usb.HIDOnlyInitScript } if err := copyModeFile(srcScript); err != nil { @@ -115,21 +110,7 @@ func (s *Service) RecoverUSB(c *gin.Context) { } func ResetUSBPHY() error { - h := GetHid() - h.Lock() - h.CloseNoLock() - defer h.Unlock() - - command := fmt.Sprintf("%s restart_phy", USBDevScript) - if err := exec.Command("sh", "-c", command).Run(); err != nil { - return fmt.Errorf("restart usb phy: %w", err) - } - - if err := h.OpenNoLockWithRetry(hidReopenTimeout, hidReopenRetryDelay); err != nil { - return fmt.Errorf("reopen HID devices after usb phy reset: %w", err) - } - - return nil + return usb.RestartPHY(GetHid()) } func copyModeFile(srcScript string) error { @@ -152,7 +133,7 @@ func copyModeFile(srcScript string) error { // create and copy to temporary file tmpFile, err := os.CreateTemp("/etc/init.d/", ".S03usbdev-") if err != nil { - log.Errorf("failed to create temp %s: %s", USBDevScript, err) + log.Errorf("failed to create temp %s: %s", usb.ActiveInitScript, err) return err } tmpPath := tmpFile.Name() @@ -185,19 +166,19 @@ func copyModeFile(srcScript string) error { } // replace the target file with the temporary file - if err := os.Rename(tmpPath, USBDevScript); err != nil { + if err := os.Rename(tmpPath, usb.ActiveInitScript); err != nil { log.Errorf("failed to rename %s: %s", tmpPath, err) return err } - log.Debugf("copy %s to %s successful", srcScript, USBDevScript) + log.Debugf("copy %s to %s successful", srcScript, usb.ActiveInitScript) return nil } func getHidMode() (string, error) { - data, err := os.ReadFile(ModeFlag) + data, err := os.ReadFile(usb.ModeFlag) if err != nil { - log.Errorf("failed to read %s: %s", ModeFlag, err) + log.Errorf("failed to read %s: %s", usb.ModeFlag, err) return "", err } diff --git a/server/service/hid/status_test.go b/server/service/hid/status_test.go new file mode 100644 index 000000000..8f6ba2a43 --- /dev/null +++ b/server/service/hid/status_test.go @@ -0,0 +1,68 @@ +package hid + +import ( + "os" + "path/filepath" + "testing" + + "NanoKVM-Server/service/usb" +) + +func TestGetHidMode(t *testing.T) { + tests := []struct { + name string + flag string + want string + }{ + {name: "normal", flag: "0x0510\n", want: ModeNormal}, + {name: "hid only", flag: "0x0623\n", want: ModeHidOnly}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withModeFlag(t, tt.flag) + + got, err := getHidMode() + if err != nil { + t.Fatal(err) + } + if got != tt.want { + t.Fatalf("getHidMode() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestGetHidModeRejectsUnknownBcdDevice(t *testing.T) { + withModeFlag(t, "0x9999\n") + + if _, err := getHidMode(); err == nil { + t.Fatal("getHidMode succeeded for an unknown bcdDevice") + } +} + +func TestGetHidModeRequiresModeFlag(t *testing.T) { + oldModeFlag := usb.ModeFlag + t.Cleanup(func() { + usb.ModeFlag = oldModeFlag + }) + usb.ModeFlag = filepath.Join(t.TempDir(), "missing-bcdDevice") + + if _, err := getHidMode(); err == nil { + t.Fatal("getHidMode succeeded with a missing bcdDevice") + } +} + +func withModeFlag(t *testing.T, content string) { + t.Helper() + + oldModeFlag := usb.ModeFlag + t.Cleanup(func() { + usb.ModeFlag = oldModeFlag + }) + + usb.ModeFlag = filepath.Join(t.TempDir(), "bcdDevice") + if err := os.WriteFile(usb.ModeFlag, []byte(content), 0o666); err != nil { + t.Fatal(err) + } +} diff --git a/server/service/storage/image.go b/server/service/storage/image.go index 4e0be465a..9b6e1ddc3 100644 --- a/server/service/storage/image.go +++ b/server/service/storage/image.go @@ -1,28 +1,20 @@ package storage import ( - "fmt" "os" - "os/exec" "path/filepath" - "strconv" "strings" - "time" "github.com/gin-gonic/gin" log "github.com/sirupsen/logrus" "NanoKVM-Server/proto" "NanoKVM-Server/service/hid" + "NanoKVM-Server/service/usb" ) const ( imageDirectory = "/data" - imageNone = "/dev/mmcblk0p3" - cdromFlag = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/cdrom" - mountDevice = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/file" - inquiryString = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/inquiry_string" - roFlag = "/sys/kernel/config/usb_gadget/g0/functions/mass_storage.disk0/lun.0/ro" ) func (s *Service) GetImages(c *gin.Context) { @@ -63,86 +55,12 @@ func (s *Service) MountImage(c *gin.Context) { return } - // cdrom and ro flag - // set to 0 when unmount image - // set to 1 when mount image and the CD-ROM is enabled - if req.File == "" || req.Cdrom { - flag := "0" - if req.File != "" && req.Cdrom { - flag = "1" - } - - // unmount - if err := os.WriteFile(mountDevice, []byte("\n"), 0o666); err != nil { - log.Errorf("unmount file failed: %s", err) - rsp.ErrRsp(c, -2, "unmount image failed") - return - } - - // ro flag - if err := os.WriteFile(roFlag, []byte(flag), 0o666); err != nil { - log.Errorf("set ro flag failed: %s", err) - rsp.ErrRsp(c, -2, "set ro flag failed") - return - } - - // cdrom flag - if err := os.WriteFile(cdromFlag, []byte(flag), 0o666); err != nil { - log.Errorf("set cdrom flag failed: %s", err) - rsp.ErrRsp(c, -2, "set cdrom flag failed") - return - } - } - - inquiryVen := "NanoKVM" - inquiryPrd := "USB Mass Storage" - inquiryVer := 0x0520 - if req.Cdrom { - inquiryPrd = "USB CD/DVD-ROM" - } - inquiryData := fmt.Sprintf("%-8s%-16s%04x", inquiryVen, inquiryPrd, inquiryVer) - - if err := os.WriteFile(inquiryString, []byte(inquiryData), 0o666); err != nil { - log.Errorf("set inquiry %s failed: %s", inquiryData, err) - rsp.ErrRsp(c, -2, "set inquiry failed") - return - } - - // mount - image := req.File - if image == "" { - image = imageNone - } - - if err := os.WriteFile(mountDevice, []byte(image), 0o666); err != nil { - log.Errorf("mount file %s failed: %s", image, err) + if err := usb.SetLUNImage(hid.GetHid(), req.File, req.Cdrom); err != nil { + log.Errorf("set image %q cdrom=%t failed: %s", req.File, req.Cdrom, err) rsp.ErrRsp(c, -2, "mount image failed") return } - h := hid.GetHid() - h.Lock() - h.CloseNoLock() - defer func() { - h.OpenNoLock() - h.Unlock() - }() - - // reset usb - commands := []string{ - "echo > /sys/kernel/config/usb_gadget/g0/UDC", - "ls /sys/class/udc/ | cat > /sys/kernel/config/usb_gadget/g0/UDC", - } - - for _, command := range commands { - err := exec.Command("sh", "-c", command).Run() - if err != nil { - rsp.ErrRsp(c, -2, "execute command failed") - return - } - time.Sleep(100 * time.Millisecond) - } - rsp.OkRsp(c) log.Debugf("mount image %s success", req.File) } @@ -150,17 +68,12 @@ func (s *Service) MountImage(c *gin.Context) { func (s *Service) GetMountedImage(c *gin.Context) { var rsp proto.Response - content, err := os.ReadFile(mountDevice) + image, err := usb.MountedImage() if err != nil { rsp.ErrRsp(c, -2, "read failed") return } - image := strings.ReplaceAll(string(content), "\n", "") - if image == imageNone { - image = "" - } - data := &proto.GetMountedImageRsp{ File: image, } @@ -171,21 +84,14 @@ func (s *Service) GetMountedImage(c *gin.Context) { func (s *Service) GetCdRom(c *gin.Context) { var rsp proto.Response - content, err := os.ReadFile(cdromFlag) + cdrom, err := usb.CDROMFlag() if err != nil { rsp.ErrRsp(c, -1, "read failed") return } - flag := strings.ReplaceAll(string(content), "\n", "") - flatInt, err := strconv.ParseInt(flag, 10, 64) - if err != nil { - rsp.ErrRsp(c, -2, "parse failed") - return - } - data := &proto.GetCdRomRsp{ - Cdrom: flatInt, + Cdrom: cdrom, } rsp.OkRspWithData(c, data) diff --git a/server/service/usb/gadget.go b/server/service/usb/gadget.go new file mode 100644 index 000000000..bd2ab2648 --- /dev/null +++ b/server/service/usb/gadget.go @@ -0,0 +1,377 @@ +package usb + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +var ( + GadgetPath = "/sys/kernel/config/usb_gadget/g0" + ConfigPath = GadgetPath + "/configs/c.1" + ModeFlag = GadgetPath + "/bcdDevice" + UDCPath = GadgetPath + "/UDC" + UDCClass = "/sys/class/udc" + OTGRole = "/proc/cviusb/otg_role" + + MassStorageFunction = GadgetPath + "/functions/mass_storage.disk0" + MassStorageLink = ConfigPath + "/mass_storage.disk0" + MassStorageFlag = "/boot/usb.media0" + MassStorageROFlag = "/boot/usb.media0.ro" + LUNPath = MassStorageFunction + "/lun.0" + LUNFile = LUNPath + "/file" + LUNCDROM = LUNPath + "/cdrom" + LUNInquiryString = LUNPath + "/inquiry_string" + LUNRO = LUNPath + "/ro" + LUNForcedEject = LUNPath + "/forced_eject" + + DataDiskFunction = GadgetPath + "/functions/mass_storage.disk1" + DataDiskLink = ConfigPath + "/mass_storage.disk1" + DataDiskFlag = "/boot/usb.disk0" + DataDiskLUNPath = DataDiskFunction + "/lun.0" + DataDiskLUNFile = DataDiskLUNPath + "/file" + + RNDISFunction = GadgetPath + "/functions/rndis.usb0" + RNDISLink = ConfigPath + "/rndis.usb0" + RNDISFlag = "/boot/usb.rndis0" + + NormalInitScript = "/kvmapp/system/init.d/S03usbdev" + HIDOnlyInitScript = "/kvmapp/system/init.d/S03usbhid" + ActiveInitScript = "/etc/init.d/S03usbdev" + + LegacyNoMediaImage = "/dev/mmcblk0p3" +) + +const ( + hidReopenTimeout = 2 * time.Second + hidReopenDelay = 100 * time.Millisecond + usbPHYRestartTimeout = 10 * time.Second +) + +type HIDController interface { + Lock() + Unlock() + CloseNoLock() + OpenNoLockWithRetry(time.Duration, time.Duration) error +} + +func WithDetachedUDC(h HIDController, mutate func() error) error { + h.Lock() + h.CloseNoLock() + defer h.Unlock() + + detachErr := DetachUDC() + mutateErr := mutate() + attachErr := AttachUDC() + openErr := h.OpenNoLockWithRetry(hidReopenTimeout, hidReopenDelay) + + return errors.Join(detachErr, mutateErr, attachErr, openErr) +} + +func Rebind(h HIDController) error { + return WithDetachedUDC(h, func() error { return nil }) +} + +func RestartPHY(h HIDController) error { + h.Lock() + h.CloseNoLock() + defer h.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), usbPHYRestartTimeout) + defer cancel() + + if err := exec.CommandContext(ctx, ActiveInitScript, "restart_phy").Run(); err != nil { + return fmt.Errorf("restart usb phy: %w", err) + } + + if err := h.OpenNoLockWithRetry(hidReopenTimeout, hidReopenDelay); err != nil { + return fmt.Errorf("reopen HID devices after usb phy reset: %w", err) + } + + return nil +} + +func DetachUDC() error { + return ClearString(UDCPath) +} + +func AttachUDC() error { + udc, err := FirstUDC() + if err != nil { + return err + } + + if err := WriteString(UDCPath, udc); err != nil { + return err + } + + if Exists(OTGRole) { + return WriteString(OTGRole, "device") + } + return nil +} + +func FirstUDC() (string, error) { + entries, err := os.ReadDir(UDCClass) + if err != nil { + return "", fmt.Errorf("read UDC list: %w", err) + } + for _, entry := range entries { + name := entry.Name() + if name != "" { + return name, nil + } + } + return "", fmt.Errorf("no UDC found in %s", UDCClass) +} + +func SetVirtualMediaEnabled(h HIDController, enabled bool) error { + return WithDetachedUDC(h, func() error { + if enabled { + if err := EnsureFile(MassStorageFlag); err != nil { + return err + } + return ensureMassStorageLink() + } + + errs := []error{ + RemoveIfExists(MassStorageLink), + RemoveIfExists(MassStorageFlag), + } + if Exists(LUNFile) { + errs = append([]error{DetachLUN()}, errs...) + } + return errors.Join(errs...) + }) +} + +func SetDataDiskEnabled(h HIDController, enabled bool) error { + return WithDetachedUDC(h, func() error { + if enabled { + if err := EnsureFile(DataDiskFlag); err != nil { + return err + } + if err := ensureDataDiskLink(); err != nil { + return err + } + return WriteString(DataDiskLUNFile, LegacyNoMediaImage) + } + + errs := []error{ + RemoveIfExists(DataDiskLink), + RemoveIfExists(DataDiskFlag), + } + if Exists(DataDiskLUNFile) { + errs = append([]error{ClearString(DataDiskLUNFile)}, errs...) + } + return errors.Join(errs...) + }) +} + +func SetRNDISEnabled(h HIDController, enabled bool) error { + return WithDetachedUDC(h, func() error { + if enabled { + if err := EnsureFile(RNDISFlag); err != nil { + return err + } + if err := ensureRNDISFunction(); err != nil { + return err + } + if !Exists(RNDISLink) { + return os.Symlink(RNDISFunction, RNDISLink) + } + return nil + } + + return errors.Join( + RemoveIfExists(RNDISLink), + RemoveIfExists(RNDISFlag), + ) + }) +} + +func SetLUNImage(h HIDController, image string, cdrom bool) error { + image = NormalizeImage(image) + return WithDetachedUDC(h, func() error { + if err := EnsureFile(MassStorageFlag); err != nil { + return err + } + if err := ensureMassStorageLink(); err != nil { + return err + } + + if err := DetachLUN(); err != nil { + return err + } + + flag := "0" + if image != "" && cdrom { + flag = "1" + } + + if err := WriteString(LUNRO, flag); err != nil { + return fmt.Errorf("set read-only flag: %w", err) + } + if err := WriteString(LUNCDROM, flag); err != nil { + return fmt.Errorf("set cdrom flag: %w", err) + } + + inquiryProduct := "USB Mass Storage" + if flag == "1" { + inquiryProduct = "USB CD/DVD-ROM" + } + inquiry := fmt.Sprintf("%-8s%-16s%04x", "NanoKVM", inquiryProduct, 0x0520) + if err := WriteString(LUNInquiryString, inquiry); err != nil { + return fmt.Errorf("set inquiry string: %w", err) + } + + if image == "" { + return nil + } + return WriteString(LUNFile, image) + }) +} + +func DetachLUN() error { + if Exists(LUNForcedEject) { + return errors.Join( + WriteString(LUNForcedEject, "1"), + ClearString(LUNFile), + ) + } + return ClearString(LUNFile) +} + +func MountedImage() (string, error) { + image, err := ReadTrimmed(LUNFile) + if err != nil { + return "", err + } + return NormalizeImage(image), nil +} + +func CDROMFlag() (int64, error) { + flag, err := ReadTrimmed(LUNCDROM) + if err != nil { + return 0, err + } + if flag == "1" { + return 1, nil + } + return 0, nil +} + +func NormalizeImage(image string) string { + image = strings.TrimSpace(image) + if image == LegacyNoMediaImage { + return "" + } + return image +} + +func Exists(path string) bool { + _, err := os.Lstat(path) + return err == nil +} + +func EnsureFile(path string) error { + file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o666) + if err != nil { + return fmt.Errorf("create %s: %w", path, err) + } + return file.Close() +} + +func RemoveIfExists(path string) error { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove %s: %w", path, err) + } + return nil +} + +func ReadTrimmed(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read %s: %w", path, err) + } + return strings.TrimSpace(string(data)), nil +} + +func WriteString(path, value string) error { + if err := os.WriteFile(filepath.Clean(path), []byte(value), 0o666); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + return nil +} + +func ClearString(path string) error { + if err := os.WriteFile(filepath.Clean(path), []byte("\n"), 0o666); err != nil { + return fmt.Errorf("clear %s: %w", path, err) + } + return nil +} + +func ensureMassStorageFunction() error { + if !Exists(MassStorageFunction) { + if err := os.Mkdir(MassStorageFunction, 0o777); err != nil { + return fmt.Errorf("create mass storage function: %w", err) + } + } + if err := WriteString(filepath.Join(LUNPath, "removable"), "1"); err != nil { + return fmt.Errorf("set removable flag: %w", err) + } + if err := WriteString(LUNInquiryString, fmt.Sprintf("%-8s%-16s%04x", "NanoKVM", "USB Mass Storage", 0x0520)); err != nil { + return fmt.Errorf("set inquiry string: %w", err) + } + return nil +} + +func ensureMassStorageLink() error { + if err := ensureMassStorageFunction(); err != nil { + return err + } + if !Exists(MassStorageLink) { + return os.Symlink(MassStorageFunction, MassStorageLink) + } + return nil +} + +func ensureRNDISFunction() error { + if Exists(RNDISFunction) { + return nil + } + if err := os.Mkdir(RNDISFunction, 0o777); err != nil { + return fmt.Errorf("create RNDIS function: %w", err) + } + return nil +} + +func ensureDataDiskFunction() error { + if !Exists(DataDiskFunction) { + if err := os.Mkdir(DataDiskFunction, 0o777); err != nil { + return fmt.Errorf("create data disk function: %w", err) + } + } + if err := WriteString(filepath.Join(DataDiskLUNPath, "removable"), "1"); err != nil { + return fmt.Errorf("set data disk removable flag: %w", err) + } + if err := WriteString(filepath.Join(DataDiskLUNPath, "inquiry_string"), fmt.Sprintf("%-8s%-16s%04x", "NanoKVM", "USB Data Disk", 0x0520)); err != nil { + return fmt.Errorf("set data disk inquiry string: %w", err) + } + return nil +} + +func ensureDataDiskLink() error { + if err := ensureDataDiskFunction(); err != nil { + return err + } + if !Exists(DataDiskLink) { + return os.Symlink(DataDiskFunction, DataDiskLink) + } + return nil +} diff --git a/server/service/usb/gadget_test.go b/server/service/usb/gadget_test.go new file mode 100644 index 000000000..2e7e91d4b --- /dev/null +++ b/server/service/usb/gadget_test.go @@ -0,0 +1,390 @@ +package usb + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +type fakeHID struct { + locks int + closes int + opens int +} + +func (h *fakeHID) Lock() { + h.locks++ +} + +func (h *fakeHID) Unlock() {} + +func (h *fakeHID) CloseNoLock() { + h.closes++ +} + +func (h *fakeHID) OpenNoLockWithRetry(time.Duration, time.Duration) error { + h.opens++ + return nil +} + +func withFakeGadget(t *testing.T) { + t.Helper() + + old := []string{ + GadgetPath, ConfigPath, ModeFlag, UDCPath, UDCClass, OTGRole, + MassStorageFunction, MassStorageLink, MassStorageFlag, MassStorageROFlag, + LUNPath, LUNFile, LUNCDROM, LUNInquiryString, LUNRO, LUNForcedEject, + DataDiskFunction, DataDiskLink, DataDiskFlag, DataDiskLUNPath, + DataDiskLUNFile, RNDISFunction, RNDISLink, RNDISFlag, + } + t.Cleanup(func() { + GadgetPath, ConfigPath, ModeFlag, UDCPath, UDCClass, OTGRole = old[0], old[1], old[2], old[3], old[4], old[5] + MassStorageFunction, MassStorageLink, MassStorageFlag, MassStorageROFlag = old[6], old[7], old[8], old[9] + LUNPath, LUNFile, LUNCDROM, LUNInquiryString, LUNRO, LUNForcedEject = old[10], old[11], old[12], old[13], old[14], old[15] + DataDiskFunction, DataDiskLink, DataDiskFlag, DataDiskLUNPath = old[16], old[17], old[18], old[19] + DataDiskLUNFile, RNDISFunction, RNDISLink, RNDISFlag = old[20], old[21], old[22], old[23] + }) + + root := t.TempDir() + GadgetPath = filepath.Join(root, "g0") + ConfigPath = filepath.Join(GadgetPath, "configs", "c.1") + ModeFlag = filepath.Join(GadgetPath, "bcdDevice") + UDCPath = filepath.Join(GadgetPath, "UDC") + UDCClass = filepath.Join(root, "udc") + OTGRole = filepath.Join(root, "otg_role") + + MassStorageFunction = filepath.Join(GadgetPath, "functions", "mass_storage.disk0") + MassStorageLink = filepath.Join(ConfigPath, "mass_storage.disk0") + MassStorageFlag = filepath.Join(root, "boot", "usb.media0") + MassStorageROFlag = filepath.Join(root, "boot", "usb.media0.ro") + LUNPath = filepath.Join(MassStorageFunction, "lun.0") + LUNFile = filepath.Join(LUNPath, "file") + LUNCDROM = filepath.Join(LUNPath, "cdrom") + LUNInquiryString = filepath.Join(LUNPath, "inquiry_string") + LUNRO = filepath.Join(LUNPath, "ro") + LUNForcedEject = filepath.Join(LUNPath, "forced_eject") + + DataDiskFunction = filepath.Join(GadgetPath, "functions", "mass_storage.disk1") + DataDiskLink = filepath.Join(ConfigPath, "mass_storage.disk1") + DataDiskFlag = filepath.Join(root, "boot", "usb.disk0") + DataDiskLUNPath = filepath.Join(DataDiskFunction, "lun.0") + DataDiskLUNFile = filepath.Join(DataDiskLUNPath, "file") + + RNDISFunction = filepath.Join(GadgetPath, "functions", "rndis.usb0") + RNDISLink = filepath.Join(ConfigPath, "rndis.usb0") + RNDISFlag = filepath.Join(root, "boot", "usb.rndis0") + + for _, dir := range []string{ + ConfigPath, + UDCClass, + filepath.Dir(MassStorageFlag), + LUNPath, + DataDiskLUNPath, + filepath.Dir(RNDISFunction), + } { + if err := os.MkdirAll(dir, 0o777); err != nil { + t.Fatal(err) + } + } + + writeFile(t, filepath.Join(UDCClass, "4340000.usb"), "") + writeFile(t, UDCPath, "4340000.usb") + writeFile(t, OTGRole, "device") + for _, path := range []string{ + LUNFile, LUNCDROM, LUNInquiryString, LUNRO, + filepath.Join(LUNPath, "removable"), + DataDiskLUNFile, + filepath.Join(DataDiskLUNPath, "removable"), + filepath.Join(DataDiskLUNPath, "inquiry_string"), + } { + writeFile(t, path, "") + } +} + +func TestSetLUNImageMountsExplicitCDROM(t *testing.T) { + withFakeGadget(t) + hid := &fakeHID{} + + if err := SetLUNImage(hid, " /data/installer.iso ", true); err != nil { + t.Fatal(err) + } + + assertFile(t, UDCPath, "4340000.usb") + assertFile(t, OTGRole, "device") + assertFile(t, MassStorageFlag, "") + assertSymlink(t, MassStorageLink, MassStorageFunction) + assertFile(t, LUNFile, "/data/installer.iso") + assertFile(t, LUNRO, "1") + assertFile(t, LUNCDROM, "1") + assertContains(t, LUNInquiryString, "USB CD/DVD-ROM") + assertHIDCycle(t, hid) +} + +func TestSetLUNImageUnmountLeavesNoMedia(t *testing.T) { + withFakeGadget(t) + writeFile(t, LUNFile, "/data/installer.iso") + + if err := SetLUNImage(&fakeHID{}, "", false); err != nil { + t.Fatal(err) + } + + image, err := MountedImage() + if err != nil { + t.Fatal(err) + } + if image != "" { + t.Fatalf("mounted image = %q, want empty", image) + } + assertFile(t, LUNFile, "\n") + assertFile(t, LUNRO, "0") + assertFile(t, LUNCDROM, "0") + assertContains(t, LUNInquiryString, "USB Mass Storage") +} + +func TestSetLUNImageUsesForcedEjectWhenAvailable(t *testing.T) { + withFakeGadget(t) + writeFile(t, LUNFile, "/data/installer.iso") + writeFile(t, LUNForcedEject, "") + + if err := SetLUNImage(&fakeHID{}, "", false); err != nil { + t.Fatal(err) + } + + assertFile(t, LUNForcedEject, "1") + assertFile(t, LUNFile, "\n") +} + +func TestLegacyNoMediaImageIsHiddenFromMountedImage(t *testing.T) { + withFakeGadget(t) + writeFile(t, LUNFile, LegacyNoMediaImage) + + image, err := MountedImage() + if err != nil { + t.Fatal(err) + } + if image != "" { + t.Fatalf("mounted image = %q, want empty", image) + } +} + +func TestSetVirtualMediaEnabledSetsFunctionDefaults(t *testing.T) { + withFakeGadget(t) + + if err := SetVirtualMediaEnabled(&fakeHID{}, true); err != nil { + t.Fatal(err) + } + + assertFile(t, filepath.Join(LUNPath, "removable"), "1") + assertContains(t, LUNInquiryString, "USB Mass Storage") + assertSymlink(t, MassStorageLink, MassStorageFunction) +} + +func TestVirtualMediaAndDataDiskAreIndependent(t *testing.T) { + withFakeGadget(t) + hid := &fakeHID{} + + if err := SetVirtualMediaEnabled(hid, true); err != nil { + t.Fatal(err) + } + if err := SetDataDiskEnabled(hid, true); err != nil { + t.Fatal(err) + } + + assertFile(t, MassStorageFlag, "") + assertSymlink(t, MassStorageLink, MassStorageFunction) + assertFile(t, DataDiskFlag, "") + assertSymlink(t, DataDiskLink, DataDiskFunction) + assertFile(t, DataDiskLUNFile, LegacyNoMediaImage) + + if err := SetVirtualMediaEnabled(hid, false); err != nil { + t.Fatal(err) + } + if Exists(MassStorageFlag) { + t.Fatal("media flag still exists after disabling virtual media") + } + if Exists(MassStorageLink) { + t.Fatal("media link still exists after disabling virtual media") + } + assertFile(t, DataDiskFlag, "") + assertSymlink(t, DataDiskLink, DataDiskFunction) + assertFile(t, DataDiskLUNFile, LegacyNoMediaImage) +} + +func TestSetDataDiskEnabledDisablesCleanly(t *testing.T) { + withFakeGadget(t) + + if err := SetDataDiskEnabled(&fakeHID{}, true); err != nil { + t.Fatal(err) + } + if err := SetDataDiskEnabled(&fakeHID{}, false); err != nil { + t.Fatal(err) + } + + assertFile(t, DataDiskLUNFile, "\n") + if Exists(DataDiskFlag) { + t.Fatal("data disk flag still exists after disabling data disk") + } + if Exists(DataDiskLink) { + t.Fatal("data disk link still exists after disabling data disk") + } +} + +func TestCDROMFlagTreatsOnlyOneAsEnabled(t *testing.T) { + withFakeGadget(t) + + writeFile(t, LUNCDROM, "1\n") + flag, err := CDROMFlag() + if err != nil { + t.Fatal(err) + } + if flag != 1 { + t.Fatalf("CDROMFlag = %d, want 1", flag) + } + + writeFile(t, LUNCDROM, "0\n") + flag, err = CDROMFlag() + if err != nil { + t.Fatal(err) + } + if flag != 0 { + t.Fatalf("CDROMFlag = %d, want 0", flag) + } +} + +func TestSetRNDISEnabledTogglesFlagAndLink(t *testing.T) { + withFakeGadget(t) + + if err := SetRNDISEnabled(&fakeHID{}, true); err != nil { + t.Fatal(err) + } + assertFile(t, RNDISFlag, "") + if !Exists(RNDISFunction) { + t.Fatal("RNDIS function was not created") + } + assertSymlink(t, RNDISLink, RNDISFunction) + + if err := SetRNDISEnabled(&fakeHID{}, false); err != nil { + t.Fatal(err) + } + if Exists(RNDISFlag) { + t.Fatal("RNDIS flag still exists after disabling RNDIS") + } + if Exists(RNDISLink) { + t.Fatal("RNDIS link still exists after disabling RNDIS") + } +} + +func TestWithDetachedUDCReattachesAfterMutationError(t *testing.T) { + withFakeGadget(t) + hid := &fakeHID{} + mutateErr := errors.New("boom") + + err := WithDetachedUDC(hid, func() error { + assertFile(t, UDCPath, "\n") + return mutateErr + }) + + if !errors.Is(err, mutateErr) { + t.Fatalf("error = %v, want joined mutation error", err) + } + assertFile(t, UDCPath, "4340000.usb") + assertFile(t, OTGRole, "device") + assertHIDCycle(t, hid) +} + +func TestWithDetachedUDCReportsDetachAttachAndOpenErrors(t *testing.T) { + withFakeGadget(t) + if err := os.Remove(UDCPath); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(UDCClass, "4340000.usb")); err != nil { + t.Fatal(err) + } + hid := &failingHID{} + + err := WithDetachedUDC(hid, func() error { return errors.New("mutation failed") }) + if err == nil { + t.Fatal("WithDetachedUDC succeeded with detach, attach, mutation, and open failures") + } + for _, want := range []string{"mutation failed", "no UDC found", "open failed"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error %q does not contain %q", err, want) + } + } +} + +func TestFirstUDCRequiresADevice(t *testing.T) { + withFakeGadget(t) + if err := os.Remove(filepath.Join(UDCClass, "4340000.usb")); err != nil { + t.Fatal(err) + } + + if _, err := FirstUDC(); err == nil { + t.Fatal("FirstUDC succeeded with no UDC entries") + } +} + +type failingHID struct{} + +func (h *failingHID) Lock() {} + +func (h *failingHID) Unlock() {} + +func (h *failingHID) CloseNoLock() {} + +func (h *failingHID) OpenNoLockWithRetry(time.Duration, time.Duration) error { + return errors.New("open failed") +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o777); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o666); err != nil { + t.Fatal(err) + } +} + +func assertFile(t *testing.T, path, want string) { + t.Helper() + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != want { + t.Fatalf("%s = %q, want %q", path, string(got), want) + } +} + +func assertContains(t *testing.T, path, want string) { + t.Helper() + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), want) { + t.Fatalf("%s = %q, want substring %q", path, string(got), want) + } +} + +func assertSymlink(t *testing.T, path, want string) { + t.Helper() + got, err := os.Readlink(path) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("symlink %s -> %q, want %q", path, got, want) + } +} + +func assertHIDCycle(t *testing.T, hid *fakeHID) { + t.Helper() + if hid.locks != 1 || hid.closes != 1 || hid.opens != 1 { + t.Fatalf("hid cycle locks=%d closes=%d opens=%d, want 1/1/1", hid.locks, hid.closes, hid.opens) + } +} diff --git a/server/service/vm/virtual-device.go b/server/service/vm/virtual-device.go index 2eb40e982..369bed4c3 100644 --- a/server/service/vm/virtual-device.go +++ b/server/service/vm/virtual-device.go @@ -3,56 +3,31 @@ package vm import ( "errors" "os" - "os/exec" "github.com/gin-gonic/gin" log "github.com/sirupsen/logrus" "NanoKVM-Server/proto" "NanoKVM-Server/service/hid" -) - -const ( - virtualNetwork = "/boot/usb.rndis0" - virtualDisk = "/boot/usb.disk0" + "NanoKVM-Server/service/usb" ) var ( - mountNetworkCommands = []string{ - "touch /boot/usb.rndis0", - "/etc/init.d/S03usbdev stop", - "/etc/init.d/S03usbdev start", - } - - unmountNetworkCommands = []string{ - "/etc/init.d/S03usbdev stop", - "rm -rf /sys/kernel/config/usb_gadget/g0/configs/c.1/rndis.usb0", - "rm /boot/usb.rndis0", - "/etc/init.d/S03usbdev start", - } - - mountDiskCommands = []string{ - "touch /boot/usb.disk0", - "/etc/init.d/S03usbdev stop", - "/etc/init.d/S03usbdev start", - } - - unmountDiskCommands = []string{ - "/etc/init.d/S03usbdev stop", - "rm -rf /sys/kernel/config/usb_gadget/g0/configs/c.1/mass_storage.disk0", - "rm /boot/usb.disk0", - "/etc/init.d/S03usbdev start", - } + virtualNetwork = usb.RNDISFlag + virtualMedia = usb.MassStorageFlag + virtualDisk = usb.DataDiskFlag ) func (s *Service) GetVirtualDevice(c *gin.Context) { var rsp proto.Response network, _ := isDeviceExist(virtualNetwork) + media, _ := isDeviceExist(virtualMedia) disk, _ := isDeviceExist(virtualDisk) rsp.OkRspWithData(c, &proto.GetVirtualDeviceRsp{ Network: network, + Media: media, Disk: disk, }) log.Debugf("get virtual device success") @@ -68,46 +43,36 @@ func (s *Service) UpdateVirtualDevice(c *gin.Context) { } var device string - var commands []string + var update func(bool) error switch req.Device { case "network": device = virtualNetwork + update = func(on bool) error { + return usb.SetRNDISEnabled(hid.GetHid(), on) + } - exist, _ := isDeviceExist(device) - if !exist { - commands = mountNetworkCommands - } else { - commands = unmountNetworkCommands + case "media": + device = virtualMedia + update = func(on bool) error { + return usb.SetVirtualMediaEnabled(hid.GetHid(), on) } + case "disk": device = virtualDisk - - exist, _ := isDeviceExist(device) - if !exist { - commands = mountDiskCommands - } else { - commands = unmountDiskCommands + update = func(on bool) error { + return usb.SetDataDiskEnabled(hid.GetHid(), on) } default: rsp.ErrRsp(c, -2, "invalid arguments") return } - h := hid.GetHid() - h.Lock() - h.CloseNoLock() - defer func() { - h.OpenNoLock() - h.Unlock() - }() - - for _, command := range commands { - err := exec.Command("sh", "-c", command).Run() - if err != nil { - rsp.ErrRsp(c, -3, "operation failed") - return - } + exist, _ := isDeviceExist(device) + if err := update(!exist); err != nil { + log.Errorf("update virtual device %s failed: %s", req.Device, err) + rsp.ErrRsp(c, -3, "operation failed") + return } on, _ := isDeviceExist(device) diff --git a/support/sg2002/kvm_system/main/lib/system_state/system_state.cpp b/support/sg2002/kvm_system/main/lib/system_state/system_state.cpp index d98d57671..9851c189e 100644 --- a/support/sg2002/kvm_system/main/lib/system_state/system_state.cpp +++ b/support/sg2002/kvm_system/main/lib/system_state/system_state.cpp @@ -10,6 +10,18 @@ using namespace maix::sys; extern kvm_sys_state_t kvm_sys_state; extern kvm_oled_state_t kvm_oled_state; +static const char *USB_UDC_STATE = "/sys/class/udc/4340000.usb/state"; +static const char *USB_HID0_LINK = "/sys/kernel/config/usb_gadget/g0/configs/c.1/hid.GS0"; +static const char *USB_HID1_LINK = "/sys/kernel/config/usb_gadget/g0/configs/c.1/hid.GS1"; +static const char *USB_HID2_LINK = "/sys/kernel/config/usb_gadget/g0/configs/c.1/hid.GS2"; +static const char *USB_MEDIA_LINK = "/sys/kernel/config/usb_gadget/g0/configs/c.1/mass_storage.disk0"; +static const char *USB_DATA_DISK_LINK = "/sys/kernel/config/usb_gadget/g0/configs/c.1/mass_storage.disk1"; + +static bool path_exists(const char *path) +{ + return access(path, F_OK) == 0; +} + int get_nic_state(const char* interface_name) { int sock; @@ -213,7 +225,13 @@ void kvm_update_usb_state() FILE *fp; int file_size; uint8_t RW_Data[10]; - fp = fopen("/sys/class/udc/4340000.usb/state", "r"); + fp = fopen(USB_UDC_STATE, "r"); + if(fp == NULL) { + kvm_sys_state.usb_state = -1; + kvm_sys_state.hid_state = -1; + kvm_sys_state.udisk_state = -1; + return; + } fseek(fp, 0, SEEK_END); file_size = ftell(fp); fseek(fp, 0, SEEK_SET); @@ -224,10 +242,13 @@ void kvm_update_usb_state() else kvm_sys_state.usb_state = -1; // hid_state & udisk_state (rndis_state单独处理) if(kvm_sys_state.usb_state == 1){ - if(access("/sys/kernel/config/usb_gadget/g0/configs/c.1/hid.GS*", F_OK) == 0) - kvm_sys_state.hid_state = 1; - if(access("/sys/kernel/config/usb_gadget/g0/configs/c.1/mass_storage.disk0", F_OK) == 0) - kvm_sys_state.udisk_state = 1; + kvm_sys_state.hid_state = + path_exists(USB_HID0_LINK) || + path_exists(USB_HID1_LINK) || + path_exists(USB_HID2_LINK); + kvm_sys_state.udisk_state = + path_exists(USB_MEDIA_LINK) || + path_exists(USB_DATA_DISK_LINK); } else { kvm_sys_state.hid_state = 0; kvm_sys_state.udisk_state = 0; @@ -519,4 +540,4 @@ int check_watchdog() return -1; } } else return 0; -} \ No newline at end of file +} diff --git a/tests/usb-init-scripts-test.sh b/tests/usb-init-scripts-test.sh new file mode 100644 index 000000000..ec48c8a5b --- /dev/null +++ b/tests/usb-init-scripts-test.sh @@ -0,0 +1,430 @@ +#!/bin/sh +set -eu + +ROOT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +COMMON_SCRIPT="${ROOT_DIR}/kvmapp/system/init.d/S03usb-common" +NORMAL_SCRIPT="${ROOT_DIR}/kvmapp/system/init.d/S03usbdev" +HID_ONLY_SCRIPT="${ROOT_DIR}/kvmapp/system/init.d/S03usbhid" +TMP_DIRS="" +TEST_COMMON_SCRIPT="" + +CONFIGFS_GADGET_ATTRS="UDC bcdUSB bcdDevice idVendor idProduct" +CONFIGFS_STRING_ATTRS="serialnumber manufacturer product" +CONFIGFS_CONFIG_ATTRS="bmAttributes MaxPower" +CONFIGFS_CONFIG_STRING_ATTRS="configuration" +CONFIGFS_HID_ATTRS="subclass protocol report_length report_desc wakeup_on_write" +CONFIGFS_LUN_ATTRS="removable ro cdrom inquiry_string file" + +cleanup(){ + for dir in ${TMP_DIRS} + do + rm -rf "${dir}" + done +} +trap cleanup EXIT + +fail(){ + echo "FAIL: $*" >&2 + exit 1 +} + +assert_eq(){ + [ "$1" = "$2" ] || fail "$3: got '$1', want '$2'" +} + +assert_no_file(){ + [ ! -e "$1" ] && [ ! -L "$1" ] || fail "unexpected file: $1" +} + +assert_link(){ + path="$1" + want="$2" + [ -e "${path}" ] || [ -L "${path}" ] || fail "missing file: ${path}" + got=$(readlink "${path}") + assert_eq "${got}" "${want}" "symlink ${path}" +} + +assert_hex(){ + assert_eq "$(od -An -tx1 -v "$1" | tr -d ' \n')" "$2" "hex $1" +} + +hex_of(){ + printf '%s' "$1" | od -An -tx1 -v | tr -d ' \n' +} + +assert_text_bytes(){ + assert_hex "$1" "$(hex_of "$2")" +} + +assert_not_contains(){ + path="$1" + pattern="$2" + name="$3" + if grep -F "${pattern}" "${path}" >/dev/null + then + fail "${name}: unexpected '${pattern}' in ${path}" + fi +} + +USB_DEFAULT_UDC="4340000.usb" +USB_PHY_DEVICE="4340000.usb" +USB_SECONDARY_UDC="4350000.usb" +USB_SERIAL_NUMBER="0123456789ABCDEF" +USB_MANUFACTURER="sipeed" +USB_PRODUCT="NanoKVM" +USB_CONFIGURATION="NanoKVM" +USB_NORMAL_BCD_USB="0x0200" +USB_NORMAL_BCD_DEVICE="0x0510" +USB_HID_ONLY_BCD_USB="0x0110" +USB_HID_ONLY_BCD_DEVICE="0x0623" +USB_HID_ONLY_CONFIG_ATTRIBUTES="0xA0" +USB_HID_KEYBOARD_FUNC="hid.GS0" +USB_HID_RELATIVE_MOUSE_FUNC="hid.GS1" +USB_HID_ABSOLUTE_MOUSE_FUNC="hid.GS2" +USB_MASS_STORAGE_FUNC="mass_storage.disk0" +USB_DATA_DISK_FUNC="mass_storage.disk1" +USB_RNDIS_FUNC="rndis.usb0" +USB_DATA_DISK_BACKING="/dev/mmcblk0p3" +USB_TEST_IMAGE="/data/install.iso" + +KEYBOARD_REPORT_DESC="05010906a101050719e029e71500250175019508810295017508810395057501050819012905910295017503910395067508150025e70507190029e78100c0" +HID_ONLY_KEYBOARD_REPORT_DESC="05010906a101050719e029e71500250175019508810295017508810395057501050819012905910295017503910395067508150025650507190029658100c0" +RELATIVE_MOUSE_REPORT_DESC="05010902a1010901a1000509190129031500250195037501810295017505810305010930093109381581257f750895038106c0c0" +ABSOLUTE_MOUSE_REPORT_DESC="05010902a1010901a10005091901290515002501950575018102950175038101050109300931150026ff7f350046ff7f751095028102050109381581257f35004500750895018106c0c0" +HID_ONLY_ABSOLUTE_MOUSE_REPORT_DESC="05010902a1010901a10005091901290315002501950375018102950175058101050109300931150026ff7f350046ff7f751095028102050109381581257f35004500750895018106c0c0" + +setup_fake_configfs_tools(){ + base=$(mktemp -d) + TMP_DIRS="${TMP_DIRS} ${base}" + fake_bin="${base}/bin" + mkdir -p "${fake_bin}" + + real_mkdir=$(command -v mkdir) + real_rmdir=$(command -v rmdir) + + cat > "${fake_bin}/mkdir" < "\${dir}/subclass" + touch_attrs "\${dir}" ${CONFIGFS_HID_ATTRS} + ;; + functions/mass_storage.*/lun.0|*/functions/mass_storage.*/lun.0) + touch_attrs "\${dir}" ${CONFIGFS_LUN_ATTRS} + ;; + functions/mass_storage.*|*/functions/mass_storage.*) + "${real_mkdir}" -p "\${dir}/lun.0" + prepare_fake_configfs_dir "\${dir}/lun.0" + ;; + esac +} +for arg in "\$@" +do + case "\${arg}" in + -*) ;; + *) [ -d "\${arg}" ] && prepare_fake_configfs_dir "\${arg}" ;; + esac +done +EOF + + cat > "${fake_bin}/rmdir" < "${TEST_COMMON_SCRIPT}" +} + +assert_hid_attr(){ + assert_eq "$(cat "$1/functions/$2/$3")" "$4" "$5" +} + +assert_hid_boot_interfaces(){ + g="$1" + label="$2" + assert_hid_attr "${g}" "${USB_HID_KEYBOARD_FUNC}" subclass 1 "${label} keyboard boot subclass" + assert_hid_attr "${g}" "${USB_HID_KEYBOARD_FUNC}" protocol 1 "${label} keyboard protocol" + assert_hid_attr "${g}" "${USB_HID_RELATIVE_MOUSE_FUNC}" subclass 1 "${label} mouse boot subclass" + assert_hid_attr "${g}" "${USB_HID_RELATIVE_MOUSE_FUNC}" protocol 2 "${label} mouse protocol" + assert_hid_attr "${g}" "${USB_HID_ABSOLUTE_MOUSE_FUNC}" subclass 0 "${label} touch non-boot subclass" + assert_hid_attr "${g}" "${USB_HID_ABSOLUTE_MOUSE_FUNC}" protocol 2 "${label} touch protocol" +} + +assert_hid_function(){ + assert_link "$1/configs/c.1/$2" "functions/$2" + assert_hid_attr "$1" "$2" subclass "$4" "$3 subclass" + assert_hid_attr "$1" "$2" protocol "$5" "$3 protocol" + assert_hid_attr "$1" "$2" report_length "$6" "$3 report length" + assert_hex "$1/functions/$2/report_desc" "$7" + assert_hid_attr "$1" "$2" wakeup_on_write "$8" "$3 wake" +} + +assert_hid_functions(){ + assert_hid_function "$1" "${USB_HID_KEYBOARD_FUNC}" "$2 keyboard" 1 1 8 "$3" "$5" + assert_hid_function "$1" "${USB_HID_RELATIVE_MOUSE_FUNC}" "$2 mouse" 1 2 4 "${RELATIVE_MOUSE_REPORT_DESC}" "$5" + assert_hid_function "$1" "${USB_HID_ABSOLUTE_MOUSE_FUNC}" "$2 touch" 0 2 6 "$4" "$5" +} + +assert_no_hid_functions(){ + g="$1" + for func in "${USB_HID_KEYBOARD_FUNC}" "${USB_HID_RELATIVE_MOUSE_FUNC}" "${USB_HID_ABSOLUTE_MOUSE_FUNC}" + do + assert_no_file "${g}/configs/c.1/${func}" + assert_no_file "${g}/functions/${func}" + done +} + +new_env(){ + base=$(mktemp -d) + TMP_DIRS="${TMP_DIRS} ${base}" + mkdir -p "${base}/boot" "${base}/dwc2" "${base}/gadget" "${base}/udc" "${base}/proc/cviusb" + touch "${base}/dwc2/bind" "${base}/dwc2/unbind" + touch "${base}/udc/${USB_DEFAULT_UDC}" + touch "${base}/proc/cviusb/otg_role" + echo "${base}" +} + +run_script_action(){ + script="$1" + action="$2" + base="$3" + USB_COMMON="${TEST_COMMON_SCRIPT}" \ + USB_BOOT_DIR="${base}/boot" \ + USB_GADGET_ROOT="${base}/gadget" \ + USB_UDC_CLASS="${base}/udc" \ + USB_OTG_ROLE="${base}/proc/cviusb/otg_role" \ + USB_PHY_DEVICE="${USB_PHY_DEVICE}" \ + USB_DWC2_BIND="${base}/dwc2/bind" \ + USB_DWC2_UNBIND="${base}/dwc2/unbind" \ + sh "${script}" "${action}" >/dev/null +} + +run_start(){ + script="$1" + base="$2" + run_script_action "${script}" start "${base}" +} + +test_usb_scripts_do_not_source_profile(){ + assert_not_contains "${NORMAL_SCRIPT}" "/etc/profile" "normal init profile" + assert_not_contains "${HID_ONLY_SCRIPT}" "/etc/profile" "hid-only init profile" +} + +test_normal_hid_descriptors(){ + base=$(new_env) + run_start "${NORMAL_SCRIPT}" "${base}" + g="${base}/gadget/g0" + + assert_eq "$(cat "${g}/bcdUSB")" "${USB_NORMAL_BCD_USB}" "normal bcdUSB" + assert_eq "$(cat "${g}/bcdDevice")" "${USB_NORMAL_BCD_DEVICE}" "normal bcdDevice" + assert_text_bytes "${g}/strings/0x409/manufacturer" "${USB_MANUFACTURER}" + assert_text_bytes "${g}/strings/0x409/product" "${USB_PRODUCT}" + assert_text_bytes "${g}/strings/0x409/serialnumber" "${USB_SERIAL_NUMBER}" + assert_text_bytes "${g}/configs/c.1/strings/0x409/configuration" "${USB_CONFIGURATION}" + + assert_hid_functions "${g}" normal "${KEYBOARD_REPORT_DESC}" "${ABSOLUTE_MOUSE_REPORT_DESC}" 1 + assert_eq "$(cat "${g}/UDC")" "${USB_DEFAULT_UDC}" "normal UDC" + assert_eq "$(cat "${base}/proc/cviusb/otg_role")" "device" "normal OTG role" +} + +test_normal_bios_flag_keeps_only_boot_hid_interfaces(){ + base=$(new_env) + touch "${base}/boot/BIOS" + run_start "${NORMAL_SCRIPT}" "${base}" + g="${base}/gadget/g0" + + assert_hid_boot_interfaces "${g}" BIOS +} + +test_normal_disable_hid_removes_hid_functions(){ + base=$(new_env) + touch "${base}/boot/disable_hid" + run_start "${NORMAL_SCRIPT}" "${base}" + g="${base}/gadget/g0" + + assert_no_hid_functions "${g}" +} + +test_normal_media_and_data_disk_split(){ + base=$(new_env) + : > "${base}/boot/usb.media0" + touch "${base}/boot/usb.disk0" + run_start "${NORMAL_SCRIPT}" "${base}" + g="${base}/gadget/g0" + + assert_link "${g}/configs/c.1/${USB_MASS_STORAGE_FUNC}" "functions/${USB_MASS_STORAGE_FUNC}" + assert_link "${g}/configs/c.1/${USB_DATA_DISK_FUNC}" "functions/${USB_DATA_DISK_FUNC}" + assert_eq "$(cat "${g}/functions/${USB_MASS_STORAGE_FUNC}/lun.0/file")" "" "empty media file" + assert_eq "$(cat "${g}/functions/${USB_DATA_DISK_FUNC}/lun.0/file")" "${USB_DATA_DISK_BACKING}" "data disk backing" +} + +test_normal_mounted_image_and_network(){ + base=$(new_env) + printf '%s' "${USB_TEST_IMAGE}" > "${base}/boot/usb.media0" + touch "${base}/boot/usb.media0.ro" + touch "${base}/boot/usb.rndis0" + run_start "${NORMAL_SCRIPT}" "${base}" + g="${base}/gadget/g0" + + assert_link "${g}/configs/c.1/${USB_RNDIS_FUNC}" "functions/${USB_RNDIS_FUNC}" + assert_eq "$(cat "${g}/functions/${USB_MASS_STORAGE_FUNC}/lun.0/file")" "${USB_TEST_IMAGE}" "mounted image" + assert_eq "$(cat "${g}/functions/${USB_MASS_STORAGE_FUNC}/lun.0/ro")" "1" "media ro flag" + assert_eq "$(cat "${g}/functions/${USB_MASS_STORAGE_FUNC}/lun.0/cdrom")" "0" "media cdrom flag" +} + +test_hid_only_descriptors_and_no_wake(){ + base=$(new_env) + touch "${base}/boot/usb.notwakeup" + touch "${base}/boot/usb.media0" + touch "${base}/boot/usb.rndis0" + run_start "${HID_ONLY_SCRIPT}" "${base}" + g="${base}/gadget/g0" + + assert_eq "$(cat "${g}/bcdUSB")" "${USB_HID_ONLY_BCD_USB}" "hid-only bcdUSB" + assert_eq "$(cat "${g}/bcdDevice")" "${USB_HID_ONLY_BCD_DEVICE}" "hid-only bcdDevice" + assert_eq "$(cat "${g}/configs/c.1/bmAttributes")" "${USB_HID_ONLY_CONFIG_ATTRIBUTES}" "hid-only bmAttributes" + assert_text_bytes "${g}/strings/0x409/serialnumber" "${USB_SERIAL_NUMBER}" + assert_text_bytes "${g}/strings/0x409/manufacturer" "${USB_MANUFACTURER}" + assert_text_bytes "${g}/strings/0x409/product" "${USB_PRODUCT}" + assert_text_bytes "${g}/configs/c.1/strings/0x409/configuration" "${USB_CONFIGURATION}" + assert_hid_functions "${g}" hid-only "${HID_ONLY_KEYBOARD_REPORT_DESC}" "${HID_ONLY_ABSOLUTE_MOUSE_REPORT_DESC}" "" + assert_no_file "${g}/configs/c.1/${USB_MASS_STORAGE_FUNC}" + assert_no_file "${g}/configs/c.1/${USB_RNDIS_FUNC}" +} + +test_uses_one_udc(){ + base=$(new_env) + touch "${base}/udc/${USB_SECONDARY_UDC}" + run_start "${NORMAL_SCRIPT}" "${base}" + + assert_eq "$(cat "${base}/gadget/g0/UDC")" "${USB_DEFAULT_UDC}" "first UDC selected" +} + +test_stop_unbinds_and_sets_host_role(){ + base=$(new_env) + run_start "${NORMAL_SCRIPT}" "${base}" + run_script_action "${NORMAL_SCRIPT}" stop "${base}" + + assert_eq "$(cat "${base}/gadget/g0/UDC")" "" "stopped UDC" + assert_eq "$(cat "${base}/proc/cviusb/otg_role")" "host" "stopped OTG role" +} + +test_stop_then_start_rebuilds_gadget(){ + base=$(new_env) + run_start "${NORMAL_SCRIPT}" "${base}" + run_script_action "${NORMAL_SCRIPT}" stop "${base}" + run_script_action "${NORMAL_SCRIPT}" start "${base}" + g="${base}/gadget/g0" + + assert_eq "$(cat "${g}/UDC")" "${USB_DEFAULT_UDC}" "stop-start UDC" + assert_eq "$(cat "${base}/proc/cviusb/otg_role")" "device" "stop-start OTG role" + assert_hid_functions "${g}" stop-start "${KEYBOARD_REPORT_DESC}" "${ABSOLUTE_MOUSE_REPORT_DESC}" 1 +} + +test_restart_phy_rebuilds_gadget(){ + base=$(new_env) + run_start "${NORMAL_SCRIPT}" "${base}" + run_script_action "${NORMAL_SCRIPT}" restart_phy "${base}" + g="${base}/gadget/g0" + + assert_eq "$(cat "${base}/dwc2/unbind")" "${USB_PHY_DEVICE}" "phy unbind" + assert_eq "$(cat "${base}/dwc2/bind")" "${USB_PHY_DEVICE}" "phy bind" + assert_eq "$(cat "${g}/UDC")" "${USB_DEFAULT_UDC}" "restart-phy UDC" + assert_eq "$(cat "${base}/proc/cviusb/otg_role")" "device" "restart-phy OTG role" + assert_hid_functions "${g}" restart-phy "${KEYBOARD_REPORT_DESC}" "${ABSOLUTE_MOUSE_REPORT_DESC}" 1 +} + +test_restart_rebinds_udc(){ + base=$(new_env) + run_start "${NORMAL_SCRIPT}" "${base}" + run_script_action "${NORMAL_SCRIPT}" restart "${base}" + + assert_eq "$(cat "${base}/gadget/g0/UDC")" "${USB_DEFAULT_UDC}" "restarted UDC" +} + +setup_fake_configfs_tools +test_usb_scripts_do_not_source_profile +test_normal_hid_descriptors +test_normal_bios_flag_keeps_only_boot_hid_interfaces +test_normal_disable_hid_removes_hid_functions +test_normal_media_and_data_disk_split +test_normal_mounted_image_and_network +test_hid_only_descriptors_and_no_wake +test_uses_one_udc +test_stop_unbinds_and_sets_host_role +test_stop_then_start_rebuilds_gadget +test_restart_phy_rebuilds_gadget +test_restart_rebinds_udc + +echo "usb init script tests passed" diff --git a/web/src/i18n/locales/ca.ts b/web/src/i18n/locales/ca.ts index 57b1a0729..3cdffcd86 100644 --- a/web/src/i18n/locales/ca.ts +++ b/web/src/i18n/locales/ca.ts @@ -357,8 +357,10 @@ const ca = { }, hidOnly: 'Mode només HID', hidOnlyDesc: "Deixeu d'emular dispositius virtuals, conservant només el control bàsic HID", + media: 'Imatge virtual', + mediaDesc: 'Adjunta un fitxer d\'imatge seleccionat a l\'amfitrió remot', disk: 'Disc virtual', - diskDesc: 'Munta un disc U virtual al dispositiu remot', + diskDesc: 'Exposa l\'emmagatzematge de dades de NanoKVM a l\'amfitrió remot', network: 'Xarxa virtual', networkDesc: 'Munta una targeta de xarxa virtual al dispositiu remot', reboot: 'Reinicia', diff --git a/web/src/i18n/locales/cz.ts b/web/src/i18n/locales/cz.ts index 269ffcaac..dfa159001 100644 --- a/web/src/i18n/locales/cz.ts +++ b/web/src/i18n/locales/cz.ts @@ -360,8 +360,10 @@ const cz = { }, hidOnly: 'HID-Pouze režim', hidOnlyDesc: 'Zastavit emulaci virtuálních zařízení a zachovat pouze základní ovládání HID', + media: 'Virtuální obraz', + mediaDesc: 'Připojit vybraný soubor obrazu ke vzdálenému hostiteli', disk: 'Virtuální disk', - diskDesc: 'Mount virtual U-disk on the remote host', + diskDesc: 'Zpřístupnit datové úložiště NanoKVM vzdálenému hostiteli', network: 'Virtuální síť', networkDesc: 'Připojit virtuální síťovou kartu na vzdáleném hostiteli', reboot: 'Restartujte', diff --git a/web/src/i18n/locales/da.ts b/web/src/i18n/locales/da.ts index fb2a52b50..1624f74c5 100644 --- a/web/src/i18n/locales/da.ts +++ b/web/src/i18n/locales/da.ts @@ -359,8 +359,10 @@ const da = { hidOnly: 'HID-Kun tilstand', hidOnlyDesc: 'Stop med at emulere virtuelle enheder, og behold kun grundlæggende HID kontrol', + media: 'Virtuelt image', + mediaDesc: 'Tilknyt en valgt imagefil til fjernværten', disk: 'Virtuel disk', - diskDesc: 'Mount virtual U-disk on the remote host', + diskDesc: 'Eksponer NanoKVM-datalageret for fjernværten', network: 'Virtuelt netværk', networkDesc: 'Monter det virtuelle netværkskort på den eksterne vært', reboot: 'Genstart', diff --git a/web/src/i18n/locales/de.ts b/web/src/i18n/locales/de.ts index 8cbaf2b29..3a2271159 100644 --- a/web/src/i18n/locales/de.ts +++ b/web/src/i18n/locales/de.ts @@ -364,8 +364,10 @@ const de = { hidOnly: 'HID-Only Mode', hidOnlyDesc: 'Hören Sie auf, virtuelle Geräte zu emulieren, und behalten Sie nur die grundlegende HID-Steuerung bei', + media: 'Virtuelles Image', + mediaDesc: 'Ausgewählte Image-Datei am entfernten Host einbinden', disk: 'Virtuelle Festplatte', - diskDesc: 'Binde das virtuelle U-Laufwerk an den entfernten Host', + diskDesc: 'NanoKVM-Datenspeicher für den entfernten Host bereitstellen', network: 'Virtuelles Netzwerk', networkDesc: 'Binde die virtuelle Netzwerkkarte an den entfernten Host', reboot: 'Neustarten', diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 7148c5a99..a83875bab 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -357,8 +357,10 @@ const en = { }, hidOnly: 'HID-Only Mode', hidOnlyDesc: 'Stop emulating virtual devices, retaining only basic HID control', + media: 'Virtual Image', + mediaDesc: 'Attach a selected image file to the remote host', disk: 'Virtual Disk', - diskDesc: 'Mount SD card on the remote host', + diskDesc: 'Expose NanoKVM data storage to the remote host', network: 'Virtual Network', networkDesc: 'Mount virtual network card on the remote host', reboot: 'Reboot', diff --git a/web/src/i18n/locales/es.ts b/web/src/i18n/locales/es.ts index 8f789dd62..d4b8aff75 100644 --- a/web/src/i18n/locales/es.ts +++ b/web/src/i18n/locales/es.ts @@ -361,8 +361,10 @@ const es = { hidOnly: 'Modo sólo HID', hidOnlyDesc: 'Dejar de emular dispositivos virtuales y conservar solo el control básico HID', + media: 'Imagen virtual', + mediaDesc: 'Adjuntar un archivo de imagen seleccionado al host remoto', disk: 'Disco Virtual', - diskDesc: 'Montar disco virtual en el host remoto', + diskDesc: 'Exponer el almacenamiento de datos de NanoKVM al host remoto', network: 'Red Virtual', networkDesc: 'Montar tarjeta de red virtual en el host remoto', reboot: 'Reiniciar', diff --git a/web/src/i18n/locales/fr.ts b/web/src/i18n/locales/fr.ts index d46989ea2..675820060 100644 --- a/web/src/i18n/locales/fr.ts +++ b/web/src/i18n/locales/fr.ts @@ -363,8 +363,10 @@ const fr = { hidOnly: 'HID-Mode uniquement', hidOnlyDesc: "Arrêtez d'émuler des périphériques virtuels, en ne conservant que le contrôle de base HID", + media: 'Image virtuelle', + mediaDesc: 'Attacher un fichier image sélectionné à l\'hôte distant', disk: 'Disque virtuel', - diskDesc: "Monter le disque virtuel U sur l'hôte distant", + diskDesc: 'Exposer le stockage de données NanoKVM à l\'hôte distant', network: 'Réseau virtuel', networkDesc: "Monter la carte réseau virtuelle sur l'hôte distant", reboot: 'Redémarrer', diff --git a/web/src/i18n/locales/hu.ts b/web/src/i18n/locales/hu.ts index 2d20380ad..f0938b902 100644 --- a/web/src/i18n/locales/hu.ts +++ b/web/src/i18n/locales/hu.ts @@ -362,8 +362,10 @@ const hu = { hidOnly: 'HID-Csak mód', hidOnlyDesc: 'A virtuális eszközök emulálásának leállítása, csak az alapvető HID vezérlés megtartásával', + media: 'Virtuális lemezkép', + mediaDesc: 'Csatlakoztasson egy kiválasztott képfájlt a távoli gazdagéphez', disk: 'Virtuális lemez', - diskDesc: 'Mount virtual U-disk on the remote host', + diskDesc: 'NanoKVM adattároló megosztása a távoli gazdagéppel', network: 'Virtuális hálózat', networkDesc: 'Virtuális hálózati kártya csatlakoztatása a távoli gazdagépen', reboot: 'Újraindítás', diff --git a/web/src/i18n/locales/id.ts b/web/src/i18n/locales/id.ts index 7c37c4a1f..b07217e31 100644 --- a/web/src/i18n/locales/id.ts +++ b/web/src/i18n/locales/id.ts @@ -359,8 +359,10 @@ const id = { }, hidOnly: 'HID-Mode Hanya', hidOnlyDesc: 'Berhenti meniru perangkat virtual, hanya mempertahankan kontrol dasar HID', + media: 'Image virtual', + mediaDesc: 'Lampirkan file image yang dipilih ke host jarak jauh', disk: 'Disk virtual', - diskDesc: 'Mount virtual U-disk on the remote host', + diskDesc: 'Paparkan penyimpanan data NanoKVM ke host jarak jauh', network: 'Jaringan virtual', networkDesc: 'Pasang kartu jaringan virtual pada host jarak jauh', reboot: 'Mulai ulang', diff --git a/web/src/i18n/locales/it.ts b/web/src/i18n/locales/it.ts index 8df8546ff..73d5d4e16 100644 --- a/web/src/i18n/locales/it.ts +++ b/web/src/i18n/locales/it.ts @@ -363,8 +363,10 @@ const it = { hidOnly: 'HID-Solo modalità', hidOnlyDesc: 'Smette di emulare i dispositivi virtuali, mantenendo solo il controllo di base HID', + media: 'Immagine virtuale', + mediaDesc: 'Collega un file immagine selezionato all\'host remoto', disk: 'Disco virtuale', - diskDesc: 'Mount virtual U-disk on the remote host', + diskDesc: 'Espone l\'archivio dati NanoKVM all\'host remoto', network: 'Rete virtuale', networkDesc: 'Monta la scheda di rete virtuale sull’host remoto', reboot: 'Riavvia', diff --git a/web/src/i18n/locales/ja.ts b/web/src/i18n/locales/ja.ts index 91b642aad..4141ae7f3 100644 --- a/web/src/i18n/locales/ja.ts +++ b/web/src/i18n/locales/ja.ts @@ -361,8 +361,10 @@ const ja = { hidOnly: 'HID-Only モード', hidOnlyDesc: 'このモードでは仮想デバイスはマウントされなくなり、基本的な HID 制御機能のみが保持されます。', + media: '仮想イメージ', + mediaDesc: '選択したイメージファイルをリモートホストに接続します', disk: '仮想ディスク', - diskDesc: 'リモートホストに仮想 USB ドライブをマウントする', + diskDesc: 'NanoKVM のデータストレージをリモートホストに公開します', network: '仮想ネットワークカード', networkDesc: 'リモートホストに仮想ネットワークカードをマウントする', reboot: '再起動', diff --git a/web/src/i18n/locales/ko.ts b/web/src/i18n/locales/ko.ts index 035272c1f..b40b94150 100644 --- a/web/src/i18n/locales/ko.ts +++ b/web/src/i18n/locales/ko.ts @@ -355,8 +355,10 @@ const ko = { }, hidOnly: 'HID 전용 모드', hidOnlyDesc: '가상 장치 에뮬레이션을 중지하고 기본 HID 제어만 유지합니다.', + media: '가상 이미지', + mediaDesc: '선택한 이미지 파일을 원격 호스트에 연결합니다.', disk: '가상 디스크', - diskDesc: '원격 호스트에서 가상 USB를 마운트합니다.', + diskDesc: 'NanoKVM 데이터 저장소를 원격 호스트에 노출합니다.', network: '가상 네트워크', networkDesc: '원격 호스트에서 가상 네트워크 카드를 마운트합니다.', reboot: '재부팅', diff --git a/web/src/i18n/locales/nb.ts b/web/src/i18n/locales/nb.ts index 5178a7b79..2b3fbe296 100644 --- a/web/src/i18n/locales/nb.ts +++ b/web/src/i18n/locales/nb.ts @@ -359,8 +359,10 @@ const nb = { }, hidOnly: 'HID-Bare modus', hidOnlyDesc: 'Slutt å emulere virtuelle enheter, behold bare grunnleggende HID-kontroll', + media: 'Virtuelt avtrykk', + mediaDesc: 'Koble en valgt avtrykksfil til den eksterne verten', disk: 'Virtuell disk', - diskDesc: 'Mount virtual U-disk on the remote host', + diskDesc: 'Eksponer NanoKVM-datalagring for den eksterne verten', network: 'Virtuelt nettverk', networkDesc: 'Monter virtuelt nettverkskort på den eksterne verten', reboot: 'Start på nytt', diff --git a/web/src/i18n/locales/nl.ts b/web/src/i18n/locales/nl.ts index b7d0e1f9b..1a1c3a031 100644 --- a/web/src/i18n/locales/nl.ts +++ b/web/src/i18n/locales/nl.ts @@ -363,8 +363,10 @@ const nl = { hidOnly: 'HID-Alleen modus', hidOnlyDesc: 'Stop met het emuleren van virtuele apparaten en behoud alleen de basisbesturing van HID', + media: 'Virtuele image', + mediaDesc: 'Koppel een geselecteerd imagebestand aan de externe host', disk: 'Virtuele schijf', - diskDesc: 'Koppel virtuele U-schijf aan de externe host', + diskDesc: 'Stel NanoKVM-gegevensopslag beschikbaar aan de externe host', network: 'Virtueel Netwerk', networkDesc: 'Koppel virtueel netwerk kaart aan de externe host', reboot: 'Opnieuw opstarten', diff --git a/web/src/i18n/locales/pl.ts b/web/src/i18n/locales/pl.ts index be69332bf..6c47c5f5c 100644 --- a/web/src/i18n/locales/pl.ts +++ b/web/src/i18n/locales/pl.ts @@ -362,8 +362,10 @@ const pl = { hidOnly: 'HID – tylko tryb', hidOnlyDesc: 'Przestań emulować urządzenia wirtualne, zachowując jedynie podstawową kontrolę HID', + media: 'Obraz wirtualny', + mediaDesc: 'Podłącz wybrany plik obrazu do zdalnego hosta', disk: 'Dysk wirtualny', - diskDesc: 'Mount virtual U-disk on the remote host', + diskDesc: 'Udostępnij magazyn danych NanoKVM zdalnemu hostowi', network: 'Sieć wirtualna', networkDesc: 'Zamontuj wirtualną kartę sieciową na zdalnym hoście', reboot: 'Uruchom ponownie', diff --git a/web/src/i18n/locales/pt_br.ts b/web/src/i18n/locales/pt_br.ts index e6fd78b35..f7efca2f2 100644 --- a/web/src/i18n/locales/pt_br.ts +++ b/web/src/i18n/locales/pt_br.ts @@ -360,8 +360,10 @@ const pt_br = { }, hidOnly: 'Modo Somente-HID', hidOnlyDesc: 'Pare de emular dispositivos virtuais, mantendo apenas o controle básico HID', + media: 'Imagem virtual', + mediaDesc: 'Anexar um arquivo de imagem selecionado ao host remoto', disk: 'Disco Virtual', - diskDesc: 'Montar U-disk virtual no host remoto', + diskDesc: 'Expor o armazenamento de dados do NanoKVM ao host remoto', network: 'Rede Virtual', networkDesc: 'Montar placa de rede virtual no host remoto', reboot: 'Reiniciar', diff --git a/web/src/i18n/locales/ru.ts b/web/src/i18n/locales/ru.ts index 9b79faa3c..2d1379858 100644 --- a/web/src/i18n/locales/ru.ts +++ b/web/src/i18n/locales/ru.ts @@ -362,8 +362,10 @@ const ru = { hidOnly: 'Режим только HID', hidOnlyDesc: 'Прекратить эмуляцию виртуальных устройств, сохранив только базовый контроль HID.', + media: 'Виртуальный образ', + mediaDesc: 'Подключить выбранный файл образа к удаленному хосту', disk: 'Виртуальный диск', - diskDesc: 'Смонтировать виртуальный USB диск на удаленном хосте', + diskDesc: 'Предоставить удаленному хосту хранилище данных NanoKVM', network: 'Виртуальная сеть', networkDesc: 'Смонтировать виртуальное сетевое устройство на удаленном хосте', reboot: 'Перезагрузка', diff --git a/web/src/i18n/locales/se.ts b/web/src/i18n/locales/se.ts index 7662d6278..722b80be6 100644 --- a/web/src/i18n/locales/se.ts +++ b/web/src/i18n/locales/se.ts @@ -356,8 +356,10 @@ const se = { }, hidOnly: 'Endast-HID-läge', hidOnlyDesc: 'Sluta emulera virtuella enheter, behåll bara grundläggande HID kontroll', + media: 'Virtuell avbild', + mediaDesc: 'Anslut en vald avbildsfil till fjärrvärden', disk: 'Virtuell disk', - diskDesc: 'Montera virtuell U-disk på fjärrvärden', + diskDesc: 'Exponera NanoKVM-datalagring för fjärrvärden', network: 'Virtuellt nätverk', networkDesc: 'Montera virtuell nätverkskort på fjärrvärden', reboot: 'Starta om', diff --git a/web/src/i18n/locales/th.ts b/web/src/i18n/locales/th.ts index bf2d5a35e..09db582c7 100644 --- a/web/src/i18n/locales/th.ts +++ b/web/src/i18n/locales/th.ts @@ -353,8 +353,10 @@ const th = { }, hidOnly: 'HID-โหมดเท่านั้น', hidOnlyDesc: 'หยุดการจำลองอุปกรณ์เสมือน โดยคงไว้เพียงการควบคุม HID พื้นฐานเท่านั้น', + media: 'อิมเมจจำลอง', + mediaDesc: 'แนบไฟล์อิมเมจที่เลือกกับโฮสต์ระยะไกล', disk: 'ดิสก์จำลอง', - diskDesc: 'เปิดใช้งาน U-disk จำลอง', + diskDesc: 'เปิดเผยพื้นที่เก็บข้อมูล NanoKVM ให้กับโฮสต์ระยะไกล', network: 'เครือข่ายจำลอง', networkDesc: 'เปิดใช้งานอุปกรณ์เครือข่ายจำลอง', reboot: 'รีบูต', diff --git a/web/src/i18n/locales/tr.ts b/web/src/i18n/locales/tr.ts index fcdf3ea33..3439021b9 100644 --- a/web/src/i18n/locales/tr.ts +++ b/web/src/i18n/locales/tr.ts @@ -360,8 +360,10 @@ const tr = { hidOnly: 'Yalnızca HID modu', hidOnlyDesc: 'Yalnızca temel HID kontrolünü koruyarak sanal aygıtları taklit etmeyi bırakın', + media: 'Sanal imaj', + mediaDesc: 'Seçilen imaj dosyasını uzak ana bilgisayara bağla', disk: 'Sanal Disk', - diskDesc: "Sanal U-disk'i uzak ana bilgisayara bağla", + diskDesc: 'NanoKVM veri depolamasını uzak ana bilgisayara sun', network: 'Sanal Ağ', networkDesc: 'Sanal ağ kartını uzak ana bilgisayara bağla', reboot: 'Yeniden Başlat', diff --git a/web/src/i18n/locales/uk.ts b/web/src/i18n/locales/uk.ts index 1187a4d44..daf0676b1 100644 --- a/web/src/i18n/locales/uk.ts +++ b/web/src/i18n/locales/uk.ts @@ -360,8 +360,10 @@ const uk = { }, hidOnly: 'Режим лише HID', hidOnlyDesc: 'Зупиніть емуляцію віртуальних пристроїв, зберігши лише базовий контроль HID', + media: 'Віртуальний образ', + mediaDesc: 'Підключити вибраний файл образу до віддаленого хоста', disk: 'Віртуальний диск', - diskDesc: 'Монтувати віртуальний U-диск на віддаленому хості', + diskDesc: 'Надати віддаленому хосту сховище даних NanoKVM', network: 'Віртуальна мережа', networkDesc: 'Встановити віртуальну мережеву карту на віддаленому хості', reboot: 'Перезавантажити', diff --git a/web/src/i18n/locales/vi.ts b/web/src/i18n/locales/vi.ts index 873531d25..edbb1a55b 100644 --- a/web/src/i18n/locales/vi.ts +++ b/web/src/i18n/locales/vi.ts @@ -357,8 +357,10 @@ const vi = { }, hidOnly: 'HID-Chế độ chỉ', hidOnlyDesc: 'Dừng mô phỏng các thiết bị ảo, chỉ giữ lại điều khiển HID cơ bản', + media: 'Ảnh ảo', + mediaDesc: 'Gắn tệp ảnh đã chọn vào máy chủ từ xa', disk: 'Đĩa ảo', - diskDesc: 'Mount virtual U-disk on the remote host', + diskDesc: 'Hiển thị bộ nhớ dữ liệu NanoKVM cho máy chủ từ xa', network: 'Mạng ảo', networkDesc: 'Gắn card mạng ảo trên máy chủ từ xa', reboot: 'Khởi động lại', diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index f11277be3..50eac654f 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -349,8 +349,10 @@ const zh = { }, hidOnly: 'HID-Only 模式', hidOnlyDesc: '该模式下不再挂载虚拟设备,仅保留基础的 HID 控制功能。', + media: '虚拟镜像', + mediaDesc: '将选中的镜像文件挂载到远程主机', disk: '虚拟U盘', - diskDesc: '在远程主机中挂载虚拟U盘', + diskDesc: '将 NanoKVM 数据存储暴露给远程主机', network: '虚拟网卡', networkDesc: '在远程主机中挂载虚拟网卡', reboot: '重新启动', diff --git a/web/src/i18n/locales/zh_tw.ts b/web/src/i18n/locales/zh_tw.ts index e2aa1bf51..4f81b6111 100644 --- a/web/src/i18n/locales/zh_tw.ts +++ b/web/src/i18n/locales/zh_tw.ts @@ -349,8 +349,10 @@ const zh_tw = { }, hidOnly: 'HID-Only 模式', hidOnlyDesc: '停止模擬虛擬設備,僅保留基礎 HID 控制', + media: '虛擬映像', + mediaDesc: '將選取的映像檔掛載到遠端主機', disk: '虛擬隨身碟', - diskDesc: '在遠端主機上連接虛擬隨身碟', + diskDesc: '將 NanoKVM 資料儲存空間提供給遠端主機', network: '虛擬網卡', networkDesc: '在遠端主機上新增虛擬網卡', reboot: '重新啟動', diff --git a/web/src/pages/desktop/menu/image/index.tsx b/web/src/pages/desktop/menu/image/index.tsx index 21b453b89..7969ab587 100644 --- a/web/src/pages/desktop/menu/image/index.tsx +++ b/web/src/pages/desktop/menu/image/index.tsx @@ -17,24 +17,24 @@ export const Image = () => { const [isModalOpen, setIsModalOpen] = useState(false); const [isMounted, setIsMounted] = useState(false); - const [mode, setMode] = useState('mass-storage'); + const [mode, setMode] = useState('usb-disk'); const modes = [ { - value: 'mass-storage', + value: 'usb-disk', label: (
- Mass Storage + USB disk
) }, { - value: 'cd-rom', + value: 'iso-cdrom', label: (
- CD ROM + ISO CD-ROM
) } @@ -49,7 +49,7 @@ export const Image = () => { api.getCdRom().then((rsp) => { if (rsp.code === 0) { - setMode(rsp.data?.cdrom === 1 ? 'cd-rom' : 'mass-storage'); + setMode(rsp.data?.cdrom === 1 ? 'iso-cdrom' : 'usb-disk'); } }); }, []); @@ -89,7 +89,7 @@ export const Image = () => { - + diff --git a/web/src/pages/desktop/menu/settings/device/virtual-devices.tsx b/web/src/pages/desktop/menu/settings/device/virtual-devices.tsx index c8b727638..d8271836f 100644 --- a/web/src/pages/desktop/menu/settings/device/virtual-devices.tsx +++ b/web/src/pages/desktop/menu/settings/device/virtual-devices.tsx @@ -9,9 +9,10 @@ export const VirtualDevices = () => { const { t } = useTranslation(); const [isHidOnlyMode, setIsHidOnlyMode] = useState(false); + const [isMediaEnabled, setIsMediaEnabled] = useState(false); const [isDiskEnabled, setIsDiskEnabled] = useState(false); const [isNetworkEnabled, setIsNetworkEnabled] = useState(false); - const [loading, setLoading] = useState<'' | 'disk' | 'network'>(''); + const [loading, setLoading] = useState<'' | 'media' | 'disk' | 'network'>(''); useEffect(() => { getHidOnlyMode(); @@ -40,13 +41,14 @@ export const VirtualDevices = () => { } setIsDiskEnabled(rsp.data.disk); + setIsMediaEnabled(rsp.data.media); setIsNetworkEnabled(rsp.data.network); } catch (err) { console.log(err); } } - async function update(device: 'disk' | 'network') { + async function update(device: 'media' | 'disk' | 'network') { if (loading) return; setLoading(device); @@ -80,6 +82,20 @@ export const VirtualDevices = () => { return ( <> + {/* Virtual Image */} +
+
+ {t('settings.device.media')} + {t('settings.device.mediaDesc')} +
+ + update('media')} + /> +
+ {/* Virtual Disk */}