Compare commits

...

10 Commits

Author SHA1 Message Date
Aveline 66fbda864a
chore: reset usb after updating config (#482)
* fix(usbgadget): do not panic if a change isn't found

* chore(usbgadget): rebind usb after updating config
2025-05-20 01:29:16 +02:00
Aveline a0f6d01465
fix(usbgadget): do not panic if a change isn't found (#481)
* fix(usbgadget): do not panic if a change isn't found

* chore(usbgadget): rebind usb after updating config
2025-05-20 00:34:32 +02:00
Siyuan Miao b4dd4961fc fix: jetkvm_app path is now bin/jetkvm_app 2025-05-19 23:59:10 +02:00
Aveline eeb103adf9
fix: configFS might not be mounted if the directory exists (#479) 2025-05-19 23:59:02 +02:00
Siyuan Miao 8cf6b40dc3 build: set up golang (smoketest) 2025-05-19 23:25:30 +02:00
Siyuan Miao c6b05d4abe build: add device-tests 2025-05-19 23:23:38 +02:00
Siyuan Miao 51814dcc5e fix: add missing files for building unit tests 2025-05-19 23:05:12 +02:00
Aveline 5ba08de566
fix: unit test not returning error when test fails
* fix: unit test not returning error when test fails

* chore: add unit test to smoketest.yml

* fix: make linter happy
2025-05-19 22:51:11 +02:00
Adam Shiervani 3f320e50f7
refactor: remove scroll sensitivity functionality and clean up related code (#477)
- Removed scroll sensitivity state and associated functions from jsonrpc.go and WebRTCVideo component.
- Cleaned up device settings store by eliminating unused scroll sensitivity logic.
- Updated mouse settings route to reflect the removal of scroll sensitivity feature.
- Simplified mouse wheel event handling in WebRTCVideo component.
2025-05-19 22:44:53 +02:00
Aveline 7a9fb7cbb1
chore(usbgadget): update usbgadget config only when needed (#474) 2025-05-19 21:48:43 +02:00
23 changed files with 1683 additions and 1240 deletions

View File

@ -35,6 +35,9 @@ jobs:
- name: Run tests
run: |
go test ./... -json > testreport.json
- name: Make test cases
run: |
make build_dev_test
- name: Golang Test Report
uses: becheran/go-testreport@v0.3.2
with:
@ -43,4 +46,6 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: jetkvm-app
path: bin/jetkvm_app
path: |
bin/jetkvm_app
device-tests.tar.gz

View File

@ -69,12 +69,54 @@ jobs:
CI_USER: ${{ vars.JETKVM_CI_USER }}
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
CI_SSH_PRIVATE: ${{ secrets.JETKVM_CI_SSH_PRIVATE }}
- name: Run tests
run: |
set -e
echo "+ Copying device-tests.tar.gz to remote host"
ssh jkci "cat > /tmp/device-tests.tar.gz" < device-tests.tar.gz
echo "+ Running go tests"
ssh jkci ash << 'EOF'
set -e
TMP_DIR=$(mktemp -d)
cd ${TMP_DIR}
tar zxf /tmp/device-tests.tar.gz
./gotestsum --format=testdox \
--jsonfile=/tmp/device-tests.json \
--post-run-command 'sh -c "echo $TESTS_FAILED > /tmp/device-tests.failed"' \
--raw-command -- ./run_all_tests -json
GOTESTSUM_EXIT_CODE=$?
if [ $GOTESTSUM_EXIT_CODE -ne 0 ]; then
echo "❌ Tests failed (exit code: $GOTESTSUM_EXIT_CODE)"
rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz
exit 1
fi
TESTS_FAILED=$(cat /tmp/device-tests.failed)
if [ "$TESTS_FAILED" -ne 0 ]; then
echo "❌ Tests failed $TESTS_FAILED tests failed"
rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz
exit 1
fi
echo "✅ Tests passed"
rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz
EOF
ssh jkci "cat /tmp/device-tests.json" > device-tests.json
- name: Set up Golang
uses: actions/setup-go@v4
with:
go-version: "1.24.0"
- name: Golang Test Report
uses: becheran/go-testreport@v0.3.2
with:
input: "device-tests.json"
- name: Deploy application
run: |
set -e
# Copy the binary to the remote host
echo "+ Copying the application to the remote host"
cat jetkvm_app | gzip | ssh jkci "cat > /userdata/jetkvm/jetkvm_app.update.gz"
cat bin/jetkvm_app | gzip | ssh jkci "cat > /userdata/jetkvm/jetkvm_app.update.gz"
# Deploy and run the application on the remote host
echo "+ Deploying the application on the remote host"
ssh jkci ash <<EOF
@ -108,15 +150,25 @@ jobs:
run: |
echo "+ Checking the status of the device"
curl -v http://$CI_HOST/device/status && echo
echo "+ Waiting for 10 seconds to allow all services to start"
sleep 10
echo "+ Waiting for 15 seconds to allow all services to start"
sleep 15
echo "+ Collecting logs"
ssh jkci "cat /userdata/jetkvm/last.log" > last.log
cat last.log
local_log_tar=$(mktemp)
ssh jkci ash > $local_log_tar <<'EOF'
log_path=$(mktemp -d)
dmesg > $log_path/dmesg.log
cp /userdata/jetkvm/last.log $log_path/last.log
tar -czf - -C $log_path .
EOF
tar -xf $local_log_tar
cat dmesg.log last.log
env:
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
- name: Upload logs
uses: actions/upload-artifact@v4
with:
name: device-logs
path: last.log
path: |
last.log
dmesg.log
device-tests.json

View File

@ -15,6 +15,9 @@ GO_LDFLAGS := \
-X $(PROMETHEUS_TAG).Revision=$(REVISION) \
-X $(KVM_PKG_NAME).builtTimestamp=$(BUILDTS)
GO_CMD := GOOS=linux GOARCH=arm GOARM=7 go
BIN_DIR := $(shell pwd)/bin
TEST_DIRS := $(shell find . -name "*_test.go" -type f -exec dirname {} \; | sort -u)
hash_resource:
@ -22,31 +25,35 @@ hash_resource:
build_dev: hash_resource
@echo "Building..."
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" -o bin/jetkvm_app cmd/main.go
$(GO_CMD) build -ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" -o $(BIN_DIR)/jetkvm_app cmd/main.go
build_test2json:
GOOS=linux GOARCH=arm GOARM=7 go build -o bin/test2json cmd/test2json
$(GO_CMD) build -o $(BIN_DIR)/test2json cmd/test2json
build_dev_test: build_test2json
build_gotestsum:
@echo "Building gotestsum..."
$(GO_CMD) install gotest.tools/gotestsum@latest
cp $(shell $(GO_CMD) env GOPATH)/bin/linux_arm/gotestsum $(BIN_DIR)/gotestsum
build_dev_test: build_test2json build_gotestsum
# collect all directories that contain tests
@echo "Building tests for devices ..."
@rm -rf bin/tests && mkdir -p bin/tests
@rm -rf $(BIN_DIR)/tests && mkdir -p $(BIN_DIR)/tests
@cat resource/dev_test.sh > bin/tests/run_all_tests
@cat resource/dev_test.sh > $(BIN_DIR)/tests/run_all_tests
@for test in $(TEST_DIRS); do \
test_pkg_name=$$(echo $$test | sed 's/^.\///g'); \
test_pkg_full_name=$(KVM_PKG_NAME)/$$(echo $$test | sed 's/^.\///g'); \
test_filename=$$(echo $$test_pkg_name | sed 's/\//__/g')_test; \
GOOS=linux GOARCH=arm GOARM=7 \
go test -v \
$(GO_CMD) test -v \
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
-c -o bin/tests/$$test_filename $$test; \
echo "runTest ./$$test_filename $$test_pkg_full_name" >> bin/tests/run_all_tests; \
-c -o $(BIN_DIR)/tests/$$test_filename $$test; \
echo "runTest ./$$test_filename $$test_pkg_full_name" >> $(BIN_DIR)/tests/run_all_tests; \
done; \
chmod +x bin/tests/run_all_tests; \
cp bin/test2json bin/tests/; \
chmod +x bin/tests/test2json; \
tar czfv device-tests.tar.gz -C bin/tests .
chmod +x $(BIN_DIR)/tests/run_all_tests; \
cp $(BIN_DIR)/test2json $(BIN_DIR)/tests/ && chmod +x $(BIN_DIR)/tests/test2json; \
cp $(BIN_DIR)/gotestsum $(BIN_DIR)/tests/ && chmod +x $(BIN_DIR)/tests/gotestsum; \
tar czfv device-tests.tar.gz -C $(BIN_DIR)/tests .
frontend:
cd ui && npm ci && npm run build:device
@ -59,7 +66,7 @@ dev_release: frontend build_dev
build_release: frontend hash_resource
@echo "Building release..."
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" -o bin/jetkvm_app cmd/main.go
$(GO_CMD) build -ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" -o bin/jetkvm_app cmd/main.go
release:
@if rclone lsf r2://jetkvm-update/app/$(VERSION)/ | grep -q "jetkvm_app"; then \

View File

@ -26,6 +26,7 @@ show_help() {
echo "Optional:"
echo " -u, --user <remote_user> Remote username (default: root)"
echo " --run-go-tests Run go tests"
echo " --run-go-tests-only Run go tests and exit"
echo " --skip-ui-build Skip frontend/UI build"
echo " --help Display this help message"
echo
@ -41,7 +42,8 @@ SKIP_UI_BUILD=false
RESET_USB_HID_DEVICE=false
LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}"
RUN_GO_TESTS=false
RUN_GO_TESTS_JSON=false
RUN_GO_TESTS_ONLY=false
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
@ -65,8 +67,9 @@ while [[ $# -gt 0 ]]; do
RUN_GO_TESTS=true
shift
;;
--run-go-tests-json)
RUN_GO_TESTS_JSON=true
--run-go-tests-only)
RUN_GO_TESTS_ONLY=true
RUN_GO_TESTS=true
shift
;;
--help)
@ -81,10 +84,6 @@ while [[ $# -gt 0 ]]; do
esac
done
if [ "$RUN_GO_TESTS_JSON" = true ]; then
RUN_GO_TESTS=true
fi
# Verify required parameters
if [ -z "$REMOTE_HOST" ]; then
msg_err "Error: Remote IP is a required parameter"
@ -98,29 +97,51 @@ if [ "$SKIP_UI_BUILD" = false ]; then
make frontend
fi
msg_info "▶ Building go binary"
make build_dev
if [ "$RUN_GO_TESTS" = true ]; then
msg_info "▶ Building go tests"
make build_dev_test
msg_info "▶ Copying device-tests.tar.gz to remote host"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/device-tests.tar.gz" < device-tests.tar.gz
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > /tmp/device-tests.tar.gz" < device-tests.tar.gz
msg_info "▶ Running go tests"
TEST_ARGS=""
if [ "$RUN_GO_TESTS_JSON" = true ]; then
TEST_ARGS="-json"
fi
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << 'EOF'
set -e
cd ${REMOTE_PATH}
tar zxvf device-tests.tar.gz
./run_all_tests $TEST_ARGS
EOF
TMP_DIR=$(mktemp -d)
cd ${TMP_DIR}
tar zxf /tmp/device-tests.tar.gz
./gotestsum --format=testdox \
--jsonfile=/tmp/device-tests.json \
--post-run-command 'sh -c "echo $TESTS_FAILED > /tmp/device-tests.failed"' \
--raw-command -- ./run_all_tests -json
GOTESTSUM_EXIT_CODE=$?
if [ $GOTESTSUM_EXIT_CODE -ne 0 ]; then
echo "❌ Tests failed (exit code: $GOTESTSUM_EXIT_CODE)"
rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz
exit 1
fi
TESTS_FAILED=$(cat /tmp/device-tests.failed)
if [ "$TESTS_FAILED" -ne 0 ]; then
echo "❌ Tests failed $TESTS_FAILED tests failed"
rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz
exit 1
fi
echo "✅ Tests passed"
rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz
EOF
if [ "$RUN_GO_TESTS_ONLY" = true ]; then
msg_info "▶ Go tests completed"
exit 0
fi
fi
msg_info "▶ Building go binary"
make build_dev
# Kill any existing instances of the application
ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
@ -128,6 +149,8 @@ ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < bin/jetkvm_app
if [ "$RESET_USB_HID_DEVICE" = true ]; then
msg_info "▶ Resetting USB HID device"
msg_warn "The option has been deprecated and will be removed in a future version, as JetKVM will now reset USB gadget configuration when needed"
# Remove the old USB gadget configuration
ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC"

23
go.mod
View File

@ -1,6 +1,8 @@
module github.com/jetkvm/kvm
go 1.23.0
go 1.23.4
toolchain go1.24.3
require (
github.com/Masterminds/semver/v3 v3.3.0
@ -12,21 +14,25 @@ require (
github.com/gin-contrib/logger v1.2.5
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/guregu/null/v6 v6.0.0
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf
github.com/hanwen/go-fuse/v2 v2.5.1
github.com/hashicorp/go-envparse v0.1.0
github.com/pion/logging v0.2.2
github.com/pion/mdns/v2 v2.0.7
github.com/pion/webrtc/v4 v4.0.0
github.com/pojntfx/go-nbd v0.3.2
github.com/prometheus/client_golang v1.21.0
github.com/prometheus/common v0.62.0
github.com/prometheus/procfs v0.15.1
github.com/psanford/httpreadat v0.1.0
github.com/rs/zerolog v1.34.0
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f
github.com/stretchr/testify v1.10.0
github.com/vishvananda/netlink v1.3.0
go.bug.st/serial v1.6.2
golang.org/x/crypto v0.36.0
golang.org/x/net v0.38.0
golang.org/x/crypto v0.38.0
golang.org/x/net v0.40.0
golang.org/x/sys v0.33.0
)
replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b
@ -38,6 +44,7 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/creack/goselect v0.1.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
@ -45,7 +52,7 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/guregu/null/v6 v6.0.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
@ -70,16 +77,16 @@ require (
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/turn/v4 v4.0.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/arch v0.15.0 // indirect
golang.org/x/oauth2 v0.24.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/text v0.25.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

28
go.sum
View File

@ -51,8 +51,8 @@ github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAu
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.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=
@ -62,8 +62,6 @@ github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf h1:JO6ISZIvEUitto
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
github.com/hanwen/go-fuse/v2 v2.5.1 h1:OQBE8zVemSocRxA4OaFJbjJ5hlpCmIWbGr7r0M4uoQQ=
github.com/hanwen/go-fuse/v2 v2.5.1/go.mod h1:xKwi1cF7nXAOBCXujD5ie0ZKsxc8GGSA1rlMJc+8IJs=
github.com/hashicorp/go-envparse v0.1.0 h1:bE++6bhIsNCPLvgDZkYqo3nA+/PFI51pkrHdmPSDFPY=
github.com/hashicorp/go-envparse v0.1.0/go.mod h1:OHheN1GoygLlAkTlXLXvAdnXdZxy8JUweQ1rAXx1xnc=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
@ -149,11 +147,13 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/psanford/httpreadat v0.1.0 h1:VleW1HS2zO7/4c7c7zNl33fO6oYACSagjJIyMIwZLUE=
github.com/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f h1:VgoRCP1efSCEZIcF2THLQ46+pIBzzgNiaUBe9wEDwYU=
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f/go.mod h1:pzro7BGorij2WgrjEammtrkbo3+xldxo+KaGLGUiD+Q=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@ -181,10 +181,10 @@ go.bug.st/serial v1.6.2 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8=
go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE=
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -194,10 +194,10 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@ -0,0 +1,436 @@
package usbgadget
import (
"bytes"
"fmt"
"os"
"path/filepath"
"reflect"
"time"
"github.com/prometheus/procfs"
"github.com/sourcegraph/tf-dag/dag"
)
// it's a minimalistic implementation of ansible's file module with some modifications
// to make it more suitable for our use case
// https://docs.ansible.com/ansible/latest/modules/file_module.html
// we use this to check if the files in the gadget config are in the expected state
// and to update them if they are not in the expected state
type FileState uint8
type ChangeState uint8
type FileChangeResolvedAction uint8
type ApplyFunc func(c *ChangeSet, changes []*FileChange) error
const (
FileStateUnknown FileState = iota
FileStateAbsent
FileStateDirectory
FileStateFile
FileStateFileContentMatch
FileStateFileWrite // update file content without checking
FileStateMounted
FileStateMountedConfigFS
FileStateSymlink
FileStateSymlinkInOrderConfigFS // configfs is a shithole, so we need to check if the symlinks are created in the correct order
FileStateSymlinkNotInOrderConfigFS
FileStateTouch
)
var FileStateString = map[FileState]string{
FileStateUnknown: "UNKNOWN",
FileStateAbsent: "ABSENT",
FileStateDirectory: "DIRECTORY",
FileStateFile: "FILE",
FileStateFileContentMatch: "FILE_CONTENT_MATCH",
FileStateFileWrite: "FILE_WRITE",
FileStateMounted: "MOUNTED",
FileStateMountedConfigFS: "CONFIGFS_MOUNTED",
FileStateSymlink: "SYMLINK",
FileStateSymlinkInOrderConfigFS: "SYMLINK_IN_ORDER_CONFIGFS",
FileStateTouch: "TOUCH",
}
const (
ChangeStateUnknown ChangeState = iota
ChangeStateRequired
ChangeStateNotChanged
ChangeStateChanged
ChangeStateError
)
const (
FileChangeResolvedActionUnknown FileChangeResolvedAction = iota
FileChangeResolvedActionDoNothing
FileChangeResolvedActionRemove
FileChangeResolvedActionCreateFile
FileChangeResolvedActionWriteFile
FileChangeResolvedActionUpdateFile
FileChangeResolvedActionAppendFile
FileChangeResolvedActionCreateSymlink
FileChangeResolvedActionRecreateSymlink
FileChangeResolvedActionCreateDirectoryAndSymlinks
FileChangeResolvedActionReorderSymlinks
FileChangeResolvedActionCreateDirectory
FileChangeResolvedActionRemoveDirectory
FileChangeResolvedActionTouch
FileChangeResolvedActionMountConfigFS
)
var FileChangeResolvedActionString = map[FileChangeResolvedAction]string{
FileChangeResolvedActionUnknown: "UNKNOWN",
FileChangeResolvedActionDoNothing: "DO_NOTHING",
FileChangeResolvedActionRemove: "REMOVE",
FileChangeResolvedActionCreateFile: "FILE_CREATE",
FileChangeResolvedActionWriteFile: "FILE_WRITE",
FileChangeResolvedActionUpdateFile: "FILE_UPDATE",
FileChangeResolvedActionAppendFile: "FILE_APPEND",
FileChangeResolvedActionCreateSymlink: "SYMLINK_CREATE",
FileChangeResolvedActionRecreateSymlink: "SYMLINK_RECREATE",
FileChangeResolvedActionCreateDirectoryAndSymlinks: "DIR_CREATE_AND_SYMLINKS",
FileChangeResolvedActionReorderSymlinks: "SYMLINK_REORDER",
FileChangeResolvedActionCreateDirectory: "DIR_CREATE",
FileChangeResolvedActionRemoveDirectory: "DIR_REMOVE",
FileChangeResolvedActionTouch: "TOUCH",
FileChangeResolvedActionMountConfigFS: "CONFIGFS_MOUNT",
}
type ChangeSet struct {
Changes []FileChange
}
type RequestedFileChange struct {
Component string
Key string
Path string // will be used as Key if Key is empty
ParamSymlinks []symlink
ExpectedState FileState
ExpectedContent []byte
DependsOn []string
BeforeChange []string // if the file is going to be changed, apply the change first
Description string
IgnoreErrors bool
When string // only apply the change if when meets the condition
}
type FileChange struct {
RequestedFileChange
ActualState FileState
ActualContent []byte
resolvedDeps []string
checked bool
changed ChangeState
action FileChangeResolvedAction
}
func (f *RequestedFileChange) String() string {
var s string
switch f.ExpectedState {
case FileStateDirectory:
s = fmt.Sprintf("dir: %s", f.Path)
case FileStateFile:
s = fmt.Sprintf("file: %s", f.Path)
case FileStateSymlink:
s = fmt.Sprintf("symlink: %s -> %s", f.Path, f.ExpectedContent)
case FileStateSymlinkInOrderConfigFS:
s = fmt.Sprintf("symlink_in_order_configfs: %s -> %s", f.Path, f.ExpectedContent)
case FileStateSymlinkNotInOrderConfigFS:
s = fmt.Sprintf("symlink_not_in_order_configfs: %s -> %s", f.Path, f.ExpectedContent)
case FileStateAbsent:
s = fmt.Sprintf("absent: %s", f.Path)
case FileStateFileContentMatch:
s = fmt.Sprintf("file: %s with content [%s]", f.Path, f.ExpectedContent)
case FileStateFileWrite:
s = fmt.Sprintf("write: %s with content [%s]", f.Path, f.ExpectedContent)
case FileStateMountedConfigFS:
s = fmt.Sprintf("configfs: %s", f.Path)
case FileStateTouch:
s = fmt.Sprintf("touch: %s", f.Path)
case FileStateUnknown:
s = fmt.Sprintf("unknown change for %s", f.Path)
default:
s = fmt.Sprintf("unknown expected state %d for %s", f.ExpectedState, f.Path)
}
if len(f.Description) > 0 {
s += fmt.Sprintf(" (%s)", f.Description)
}
return s
}
func (f *RequestedFileChange) IsSame(other *RequestedFileChange) bool {
return f.Path == other.Path &&
f.ExpectedState == other.ExpectedState &&
reflect.DeepEqual(f.ExpectedContent, other.ExpectedContent) &&
reflect.DeepEqual(f.DependsOn, other.DependsOn) &&
f.IgnoreErrors == other.IgnoreErrors
}
func (fc *FileChange) checkIfDirIsMountPoint() error {
// check if the file is a mount point
mounts, err := procfs.GetMounts()
if err != nil {
return fmt.Errorf("failed to get mounts")
}
for _, mount := range mounts {
if mount.MountPoint == fc.Path {
fc.ActualState = FileStateMounted
fc.ActualContent = []byte(mount.Source)
if mount.FSType == "configfs" {
fc.ActualState = FileStateMountedConfigFS
}
return nil
}
}
return nil
}
// GetActualState returns the actual state of the file at the given path.
func (fc *FileChange) getActualState() error {
l := defaultLogger.With().Str("path", fc.Path).Logger()
fi, err := os.Lstat(fc.Path)
if err != nil {
if os.IsNotExist(err) {
fc.ActualState = FileStateAbsent
} else {
l.Warn().Err(err).Msg("failed to stat file")
fc.ActualState = FileStateUnknown
}
return nil
}
// check if the file is a symlink
if fi.Mode()&os.ModeSymlink == os.ModeSymlink {
fc.ActualState = FileStateSymlink
// get the target of the symlink
target, err := os.Readlink(fc.Path)
if err != nil {
l.Warn().Err(err).Msg("failed to read symlink")
return fmt.Errorf("failed to read symlink")
}
// check if the target is a relative path
if !filepath.IsAbs(target) {
// make it absolute
target, err = filepath.Abs(filepath.Join(filepath.Dir(fc.Path), target))
if err != nil {
l.Warn().Err(err).Msg("failed to make symlink target absolute")
return fmt.Errorf("failed to make symlink target absolute")
}
}
fc.ActualContent = []byte(target)
return nil
}
if fi.IsDir() {
fc.ActualState = FileStateDirectory
switch fc.ExpectedState {
case FileStateMountedConfigFS:
err := fc.checkIfDirIsMountPoint()
if err != nil {
l.Warn().Err(err).Msg("failed to check if dir is mount point")
return err
}
case FileStateSymlinkInOrderConfigFS:
state, err := checkIfSymlinksInOrder(fc, &l)
if err != nil {
l.Warn().Err(err).Msg("failed to check if symlinks are in order")
return err
}
fc.ActualState = state
}
return nil
}
if fi.Mode()&os.ModeDevice == os.ModeDevice {
l.Info().Msg("file is a device")
return nil
}
// check if the file is a regular file
if fi.Mode().IsRegular() {
fc.ActualState = FileStateFile
// get the content of the file
content, err := os.ReadFile(fc.Path)
if err != nil {
l.Warn().Err(err).Msg("failed to read file")
return fmt.Errorf("failed to read file")
}
fc.ActualContent = content
return nil
}
l.Warn().Interface("file_info", fi.Mode()).Bool("is_dir", fi.IsDir()).Msg("unknown file type")
return fmt.Errorf("unknown file type")
}
func (fc *FileChange) ResetActionResolution() {
fc.checked = false
fc.action = FileChangeResolvedActionUnknown
fc.changed = ChangeStateUnknown
}
func (fc *FileChange) Action() FileChangeResolvedAction {
if !fc.checked {
fc.action = fc.getFileChangeResolvedAction()
fc.checked = true
}
return fc.action
}
func (fc *FileChange) getFileChangeResolvedAction() FileChangeResolvedAction {
l := defaultLogger.With().Str("path", fc.Path).Logger()
// some actions are not needed to be checked
switch fc.ExpectedState {
case FileStateFileWrite:
return FileChangeResolvedActionWriteFile
case FileStateTouch:
return FileChangeResolvedActionTouch
}
// get the actual state of the file
err := fc.getActualState()
if err != nil {
return FileChangeResolvedActionDoNothing
}
baseName := filepath.Base(fc.Path)
switch fc.ExpectedState {
case FileStateDirectory:
// if the file is already a directory, do nothing
if fc.ActualState == FileStateDirectory {
return FileChangeResolvedActionDoNothing
}
return FileChangeResolvedActionCreateDirectory
case FileStateFile:
// if the file is already a file, do nothing
if fc.ActualState == FileStateFile {
return FileChangeResolvedActionDoNothing
}
return FileChangeResolvedActionCreateFile
case FileStateFileContentMatch:
// if the file is already a file with the expected content, do nothing
if fc.ActualState == FileStateFile {
looserMatch := baseName == "inquiry_string"
if compareFileContent(fc.ActualContent, fc.ExpectedContent, looserMatch) {
return FileChangeResolvedActionDoNothing
}
// TODO: move this to somewhere else
// this is a workaround for the fact that the file is not updated if it has no content
if baseName == "file" &&
bytes.Equal(fc.ActualContent, []byte{}) &&
bytes.Equal(fc.ExpectedContent, []byte{0x0a}) {
return FileChangeResolvedActionDoNothing
}
return FileChangeResolvedActionUpdateFile
}
return FileChangeResolvedActionCreateFile
case FileStateSymlink:
// if the file is already a symlink, check if the target is the same
if fc.ActualState == FileStateSymlink {
if reflect.DeepEqual(fc.ActualContent, fc.ExpectedContent) {
return FileChangeResolvedActionDoNothing
}
return FileChangeResolvedActionRecreateSymlink
}
return FileChangeResolvedActionCreateSymlink
case FileStateSymlinkInOrderConfigFS:
// if the file is already a symlink, check if the target is the same
if fc.ActualState == FileStateSymlinkInOrderConfigFS {
return FileChangeResolvedActionDoNothing
}
return FileChangeResolvedActionReorderSymlinks
case FileStateAbsent:
if fc.ActualState == FileStateAbsent {
return FileChangeResolvedActionDoNothing
}
return FileChangeResolvedActionRemove
case FileStateMountedConfigFS:
if fc.ActualState == FileStateMountedConfigFS {
return FileChangeResolvedActionDoNothing
}
return FileChangeResolvedActionMountConfigFS
default:
l.Warn().Interface("file_change", FileStateString[fc.ExpectedState]).Msg("unknown expected state")
return FileChangeResolvedActionDoNothing
}
}
func (c *ChangeSet) AddFileChangeStruct(r RequestedFileChange) {
fc := FileChange{
RequestedFileChange: r,
}
c.Changes = append(c.Changes, fc)
}
func (c *ChangeSet) AddFileChange(component string, path string, expectedState FileState, expectedContent []byte, dependsOn []string, description string) {
c.AddFileChangeStruct(RequestedFileChange{
Component: component,
Path: path,
ExpectedState: expectedState,
ExpectedContent: expectedContent,
DependsOn: dependsOn,
Description: description,
})
}
func (c *ChangeSet) ApplyChanges() error {
r := ChangeSetResolver{
changeset: c,
g: &dag.AcyclicGraph{},
l: defaultLogger,
}
return r.Apply()
}
func (c *ChangeSet) applyChange(change *FileChange) error {
switch change.Action() {
case FileChangeResolvedActionWriteFile:
return os.WriteFile(change.Path, change.ExpectedContent, 0644)
case FileChangeResolvedActionUpdateFile:
return os.WriteFile(change.Path, change.ExpectedContent, 0644)
case FileChangeResolvedActionCreateFile:
return os.WriteFile(change.Path, change.ExpectedContent, 0644)
case FileChangeResolvedActionCreateSymlink:
return os.Symlink(string(change.ExpectedContent), change.Path)
case FileChangeResolvedActionRecreateSymlink:
if err := os.Remove(change.Path); err != nil {
return fmt.Errorf("failed to remove symlink: %w", err)
}
return os.Symlink(string(change.ExpectedContent), change.Path)
case FileChangeResolvedActionReorderSymlinks:
return recreateSymlinks(change, nil)
case FileChangeResolvedActionCreateDirectory:
return os.MkdirAll(change.Path, 0755)
case FileChangeResolvedActionRemove:
return os.Remove(change.Path)
case FileChangeResolvedActionRemoveDirectory:
return os.RemoveAll(change.Path)
case FileChangeResolvedActionTouch:
return os.Chtimes(change.Path, time.Now(), time.Now())
case FileChangeResolvedActionMountConfigFS:
return mountConfigFS(change.Path)
case FileChangeResolvedActionDoNothing:
return nil
default:
return fmt.Errorf("unknown action: %d", change.Action())
}
}
func (c *ChangeSet) Apply() error {
return c.ApplyChanges()
}

View File

@ -0,0 +1,115 @@
//go:build arm && linux
package usbgadget
import (
"os"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
var (
usbConfig = &Config{
VendorId: "0x1d6b", //The Linux Foundation
ProductId: "0x0104", //Multifunction Composite Gadget
SerialNumber: "",
Manufacturer: "JetKVM",
Product: "USB Emulation Device",
strictMode: true,
}
usbDevices = &Devices{
AbsoluteMouse: true,
RelativeMouse: true,
Keyboard: true,
MassStorage: true,
}
usbGadgetName = "jetkvm"
usbGadget *UsbGadget
)
var oldAbsoluteMouseCombinedReportDesc = []byte{
0x05, 0x01, // Usage Page (Generic Desktop Ctrls)
0x09, 0x02, // Usage (Mouse)
0xA1, 0x01, // Collection (Application)
// Report ID 1: Absolute Mouse Movement
0x85, 0x01, // Report ID (1)
0x09, 0x01, // Usage (Pointer)
0xA1, 0x00, // Collection (Physical)
0x05, 0x09, // Usage Page (Button)
0x19, 0x01, // Usage Minimum (0x01)
0x29, 0x03, // Usage Maximum (0x03)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x03, // Report Count (3)
0x81, 0x02, // Input (Data, Var, Abs)
0x95, 0x01, // Report Count (1)
0x75, 0x05, // Report Size (5)
0x81, 0x03, // Input (Cnst, Var, Abs)
0x05, 0x01, // Usage Page (Generic Desktop Ctrls)
0x09, 0x30, // Usage (X)
0x09, 0x31, // Usage (Y)
0x16, 0x00, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x7F, // Logical Maximum (32767)
0x36, 0x00, 0x00, // Physical Minimum (0)
0x46, 0xFF, 0x7F, // Physical Maximum (32767)
0x75, 0x10, // Report Size (16)
0x95, 0x02, // Report Count (2)
0x81, 0x02, // Input (Data, Var, Abs)
0xC0, // End Collection
// Report ID 2: Relative Wheel Movement
0x85, 0x02, // Report ID (2)
0x09, 0x38, // Usage (Wheel)
0x15, 0x81, // Logical Minimum (-127)
0x25, 0x7F, // Logical Maximum (127)
0x75, 0x08, // Report Size (8)
0x95, 0x01, // Report Count (1)
0x81, 0x06, // Input (Data, Var, Rel)
0xC0, // End Collection
}
func TestUsbGadgetInit(t *testing.T) {
assert := assert.New(t)
usbGadget = NewUsbGadget(usbGadgetName, usbDevices, usbConfig, nil)
assert.NotNil(usbGadget)
}
func TestUsbGadgetStrictModeInitFail(t *testing.T) {
usbConfig.strictMode = true
u := NewUsbGadget("test", usbDevices, usbConfig, nil)
assert.Nil(t, u, "should be nil")
}
func TestUsbGadgetUDCNotBoundAfterReportDescrChanged(t *testing.T) {
assert := assert.New(t)
usbGadget = NewUsbGadget(usbGadgetName, usbDevices, usbConfig, nil)
assert.NotNil(usbGadget)
// release the usb gadget and create a new one
usbGadget = nil
altGadgetConfig := defaultGadgetConfig
oldAbsoluteMouseConfig := altGadgetConfig["absolute_mouse"]
oldAbsoluteMouseConfig.reportDesc = oldAbsoluteMouseCombinedReportDesc
altGadgetConfig["absolute_mouse"] = oldAbsoluteMouseConfig
usbGadget = newUsbGadget(usbGadgetName, altGadgetConfig, usbDevices, usbConfig, nil)
assert.NotNil(usbGadget)
udcs := getUdcs()
assert.Equal(1, len(udcs), "should be only one UDC")
// check if the UDC is bound
udc := udcs[0]
assert.NotNil(udc, "UDC should exist")
udcStr, err := os.ReadFile("/sys/kernel/config/usb_gadget/jetkvm/UDC")
assert.Nil(err, "usb_gadget/UDC should exist")
assert.Equal(strings.TrimSpace(udc), strings.TrimSpace(string(udcStr)), "UDC should be the same")
}

View File

@ -0,0 +1,192 @@
package usbgadget
import (
"fmt"
"github.com/rs/zerolog"
"github.com/sourcegraph/tf-dag/dag"
)
type ChangeSetResolver struct {
changeset *ChangeSet
l *zerolog.Logger
g *dag.AcyclicGraph
changesMap map[string]*FileChange
conditionalChangesMap map[string]*FileChange
orderedChanges []dag.Vertex
resolvedChanges []*FileChange
additionalResolveRequired bool
}
func (c *ChangeSetResolver) toOrderedChanges() error {
for key, change := range c.changesMap {
v := c.g.Add(key)
for _, dependsOn := range change.DependsOn {
c.g.Connect(dag.BasicEdge(dependsOn, v))
}
for _, dependsOn := range change.resolvedDeps {
c.g.Connect(dag.BasicEdge(dependsOn, v))
}
}
cycles := c.g.Cycles()
if len(cycles) > 0 {
return fmt.Errorf("cycles detected: %v", cycles)
}
orderedChanges := c.g.TopologicalOrder()
c.orderedChanges = orderedChanges
return nil
}
func (c *ChangeSetResolver) doResolveChanges(initial bool) error {
resolvedChanges := make([]*FileChange, 0)
for _, key := range c.orderedChanges {
change := c.changesMap[key.(string)]
if change == nil {
c.l.Error().Str("key", key.(string)).Msg("fileChange not found")
continue
}
if !initial {
change.ResetActionResolution()
}
resolvedAction := change.Action()
resolvedChanges = append(resolvedChanges, change)
// no need to check the triggers if there's no change
if resolvedAction == FileChangeResolvedActionDoNothing {
continue
}
if !initial {
continue
}
if change.BeforeChange != nil {
change.resolvedDeps = append(change.resolvedDeps, change.BeforeChange...)
c.additionalResolveRequired = true
// add the dependencies to the changes map
for _, dep := range change.BeforeChange {
depChange, ok := c.conditionalChangesMap[dep]
if !ok {
return fmt.Errorf("dependency %s not found", dep)
}
c.changesMap[dep] = depChange
}
}
}
c.resolvedChanges = resolvedChanges
return nil
}
func (c *ChangeSetResolver) resolveChanges(initial bool) error {
// get the ordered changes
err := c.toOrderedChanges()
if err != nil {
return err
}
// resolve the changes
err = c.doResolveChanges(initial)
if err != nil {
return err
}
for _, change := range c.resolvedChanges {
c.l.Trace().Str("change", change.String()).Msg("resolved change")
}
if !c.additionalResolveRequired || !initial {
return nil
}
return c.resolveChanges(false)
}
func (c *ChangeSetResolver) applyChanges() error {
for _, change := range c.resolvedChanges {
change.ResetActionResolution()
action := change.Action()
actionStr := FileChangeResolvedActionString[action]
l := c.l.Info()
if action == FileChangeResolvedActionDoNothing {
l = c.l.Trace()
}
l.Str("action", actionStr).Str("change", change.String()).Msg("applying change")
err := c.changeset.applyChange(change)
if err != nil {
if change.IgnoreErrors {
c.l.Warn().Str("change", change.String()).Err(err).Msg("ignoring error")
} else {
return err
}
}
}
return nil
}
func (c *ChangeSetResolver) GetChanges() ([]*FileChange, error) {
localChanges := c.changeset.Changes
changesMap := make(map[string]*FileChange)
conditionalChangesMap := make(map[string]*FileChange)
// build the map of the changes
for _, change := range localChanges {
key := change.Key
if key == "" {
key = change.Path
}
// remove it from the map first
if change.When != "" {
conditionalChangesMap[key] = &change
continue
}
if _, ok := changesMap[key]; ok {
if changesMap[key].IsSame(&change.RequestedFileChange) {
continue
}
return nil, fmt.Errorf(
"duplicate change: %s, current: %s, requested: %s",
key,
changesMap[key].String(),
change.String(),
)
}
changesMap[key] = &change
}
c.changesMap = changesMap
c.conditionalChangesMap = conditionalChangesMap
err := c.resolveChanges(true)
if err != nil {
return nil, err
}
return c.resolvedChanges, nil
}
func (c *ChangeSetResolver) Apply() error {
if _, err := c.GetChanges(); err != nil {
return err
}
return c.applyChanges()
}

View File

@ -0,0 +1,136 @@
package usbgadget
import (
"fmt"
"os"
"path"
"path/filepath"
"reflect"
"github.com/rs/zerolog"
)
type symlink struct {
Path string
Target string
}
func compareSymlinks(expected []symlink, actual []symlink) bool {
if len(expected) != len(actual) {
return false
}
return reflect.DeepEqual(expected, actual)
}
func checkIfSymlinksInOrder(fc *FileChange, logger *zerolog.Logger) (FileState, error) {
if logger == nil {
logger = defaultLogger
}
l := logger.With().Str("path", fc.Path).Logger()
if len(fc.ParamSymlinks) == 0 {
return FileStateUnknown, fmt.Errorf("no symlinks to check")
}
fi, err := os.Lstat(fc.Path)
if err != nil {
if os.IsNotExist(err) {
return FileStateAbsent, nil
} else {
l.Warn().Err(err).Msg("failed to stat file")
return FileStateUnknown, fmt.Errorf("failed to stat file")
}
}
if !fi.IsDir() {
return FileStateUnknown, fmt.Errorf("file is not a directory")
}
files, err := os.ReadDir(fc.Path)
symlinks := make([]symlink, 0)
if err != nil {
return FileStateUnknown, fmt.Errorf("failed to read directory")
}
for _, file := range files {
if file.Type()&os.ModeSymlink != os.ModeSymlink {
continue
}
path := filepath.Join(fc.Path, file.Name())
target, err := os.Readlink(path)
if err != nil {
return FileStateUnknown, fmt.Errorf("failed to read symlink")
}
if !filepath.IsAbs(target) {
target = filepath.Join(fc.Path, target)
newTarget, err := filepath.Abs(target)
if err != nil {
return FileStateUnknown, fmt.Errorf("failed to get absolute path")
}
target = newTarget
}
symlinks = append(symlinks, symlink{
Path: path,
Target: target,
})
}
// compare the symlinks with the expected symlinks
if compareSymlinks(fc.ParamSymlinks, symlinks) {
return FileStateSymlinkInOrderConfigFS, nil
}
l.Trace().Interface("expected", fc.ParamSymlinks).Interface("actual", symlinks).Msg("symlinks are not in order")
return FileStateSymlinkNotInOrderConfigFS, nil
}
func recreateSymlinks(fc *FileChange, logger *zerolog.Logger) error {
if logger == nil {
logger = defaultLogger
}
// remove all symlinks
files, err := os.ReadDir(fc.Path)
if err != nil {
return fmt.Errorf("failed to read directory")
}
l := logger.With().Str("path", fc.Path).Logger()
l.Info().Msg("recreate symlinks")
for _, file := range files {
if file.Type()&os.ModeSymlink != os.ModeSymlink {
continue
}
l.Info().Str("name", file.Name()).Msg("remove symlink")
err := os.Remove(path.Join(fc.Path, file.Name()))
if err != nil {
return fmt.Errorf("failed to remove symlink")
}
}
l.Info().Interface("param-symlinks", fc.ParamSymlinks).Msg("create symlinks")
// create the symlinks
for _, symlink := range fc.ParamSymlinks {
l.Info().Str("name", symlink.Path).Str("target", symlink.Target).Msg("create symlink")
path := symlink.Path
if !filepath.IsAbs(path) {
path = filepath.Join(fc.Path, path)
}
err := os.Symlink(symlink.Target, path)
if err != nil {
l.Warn().Err(err).Msg("failed to create symlink")
return fmt.Errorf("failed to create symlink")
}
}
return nil
}

View File

@ -2,11 +2,7 @@ package usbgadget
import (
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"sort"
)
type gadgetConfigItem struct {
@ -160,20 +156,10 @@ func (u *UsbGadget) OverrideGadgetConfig(itemKey string, itemAttr string, value
return nil, true
}
func mountConfigFS() error {
_, err := os.Stat(gadgetPath)
// TODO: check if it's mounted properly
if err == nil {
return nil
}
if os.IsNotExist(err) {
err = exec.Command("mount", "-t", "configfs", "none", configFSPath).Run()
if err != nil {
return fmt.Errorf("failed to mount configfs: %w", err)
}
} else {
return fmt.Errorf("unable to access usb gadget path: %w", err)
func mountConfigFS(path string) error {
err := exec.Command("mount", "-t", "configfs", "none", path).Run()
if err != nil {
return fmt.Errorf("failed to mount configfs: %w", err)
}
return nil
}
@ -186,26 +172,14 @@ func (u *UsbGadget) Init() error {
udcs := getUdcs()
if len(udcs) < 1 {
u.log.Error().Msg("no udc found, skipping USB stack init")
return nil
return u.logWarn("no udc found, skipping USB stack init", nil)
}
u.udc = udcs[0]
_, err := os.Stat(u.kvmGadgetPath)
if err == nil {
u.log.Info().Msg("usb gadget already exists")
}
if err := mountConfigFS(); err != nil {
u.log.Error().Err(err).Msg("failed to mount configfs, usb stack might not function properly")
}
if err := os.MkdirAll(u.configC1Path, 0755); err != nil {
u.log.Error().Err(err).Msg("failed to create config path")
}
if err := u.writeGadgetConfig(); err != nil {
u.log.Error().Err(err).Msg("failed to start gadget")
err := u.configureUsbGadget(false)
if err != nil {
return u.logError("unable to initialize USB stack", err)
}
return nil
@ -217,143 +191,22 @@ func (u *UsbGadget) UpdateGadgetConfig() error {
u.loadGadgetConfig()
if err := u.writeGadgetConfig(); err != nil {
u.log.Error().Err(err).Msg("failed to update gadget")
err := u.configureUsbGadget(true)
if err != nil {
return u.logError("unable to update gadget config", err)
}
return nil
}
func (u *UsbGadget) getOrderedConfigItems() orderedGadgetConfigItems {
items := make([]gadgetConfigItemWithKey, 0)
for key, item := range u.configMap {
items = append(items, gadgetConfigItemWithKey{key, item})
}
sort.Slice(items, func(i, j int) bool {
return items[i].item.order < items[j].item.order
func (u *UsbGadget) configureUsbGadget(resetUsb bool) error {
return u.WithTransaction(func() error {
u.tx.MountConfigFS()
u.tx.CreateConfigPath()
u.tx.WriteGadgetConfig()
if resetUsb {
u.tx.RebindUsb(true)
}
return nil
})
return items
}
func (u *UsbGadget) writeGadgetConfig() error {
// create kvm gadget path
err := os.MkdirAll(u.kvmGadgetPath, 0755)
if err != nil {
return err
}
u.log.Trace().Msg("writing gadget config")
for _, val := range u.getOrderedConfigItems() {
key := val.key
item := val.item
// check if the item is enabled in the config
if !u.isGadgetConfigItemEnabled(key) {
u.log.Trace().Str("key", key).Msg("disabling gadget config")
err = u.disableGadgetItemConfig(item)
if err != nil {
return err
}
continue
}
u.log.Trace().Str("key", key).Msg("writing gadget config")
err = u.writeGadgetItemConfig(item)
if err != nil {
return err
}
}
if err = u.writeUDC(); err != nil {
u.log.Error().Err(err).Msg("failed to write UDC")
return err
}
if err = u.rebindUsb(true); err != nil {
u.log.Info().Err(err).Msg("failed to rebind usb")
}
return nil
}
func (u *UsbGadget) disableGadgetItemConfig(item gadgetConfigItem) error {
// remove symlink if exists
if item.configPath == nil {
return nil
}
configPath := joinPath(u.configC1Path, item.configPath)
if _, err := os.Lstat(configPath); os.IsNotExist(err) {
u.log.Trace().Str("path", configPath).Msg("symlink does not exist")
return nil
}
if err := os.Remove(configPath); err != nil {
return fmt.Errorf("failed to remove symlink %s: %w", item.configPath, err)
}
return nil
}
func (u *UsbGadget) writeGadgetItemConfig(item gadgetConfigItem) error {
// create directory for the item
gadgetItemPath := joinPath(u.kvmGadgetPath, item.path)
err := os.MkdirAll(gadgetItemPath, 0755)
if err != nil {
return fmt.Errorf("failed to create path %s: %w", gadgetItemPath, err)
}
if len(item.attrs) > 0 {
// write attributes for the item
err = u.writeGadgetAttrs(gadgetItemPath, item.attrs)
if err != nil {
return fmt.Errorf("failed to write attributes for %s: %w", gadgetItemPath, err)
}
}
// write report descriptor if available
if item.reportDesc != nil {
err = u.writeIfDifferent(path.Join(gadgetItemPath, "report_desc"), item.reportDesc, 0644)
if err != nil {
return err
}
}
// create config directory if configAttrs are set
if len(item.configAttrs) > 0 {
configItemPath := joinPath(u.configC1Path, item.configPath)
err = os.MkdirAll(configItemPath, 0755)
if err != nil {
return fmt.Errorf("failed to create path %s: %w", configItemPath, err)
}
err = u.writeGadgetAttrs(configItemPath, item.configAttrs)
if err != nil {
return fmt.Errorf("failed to write config attributes for %s: %w", configItemPath, err)
}
}
// create symlink if configPath is set
if item.configPath != nil && item.configAttrs == nil {
configPath := joinPath(u.configC1Path, item.configPath)
u.log.Trace().Str("source", configPath).Str("target", gadgetItemPath).Msg("creating symlink")
if err := ensureSymlink(configPath, gadgetItemPath); err != nil {
return err
}
}
return nil
}
func (u *UsbGadget) writeGadgetAttrs(basePath string, attrs gadgetAttributes) error {
for key, val := range attrs {
filePath := filepath.Join(basePath, key)
err := u.writeIfDifferent(filePath, []byte(val), 0644)
if err != nil {
return fmt.Errorf("failed to write to %s: %w", filePath, err)
}
}
return nil
}

View File

@ -0,0 +1,349 @@
package usbgadget
import (
"fmt"
"path"
"path/filepath"
"sort"
"github.com/rs/zerolog"
)
// no os package should occur in this file
type UsbGadgetTransaction struct {
c *ChangeSet
// below are the fields that are needed to be set by the caller
log *zerolog.Logger
udc string
dwc3Path string
kvmGadgetPath string
configC1Path string
orderedConfigItems orderedGadgetConfigItems
isGadgetConfigItemEnabled func(key string) bool
reorderSymlinkChanges *RequestedFileChange
}
func (u *UsbGadget) newUsbGadgetTransaction(lock bool) error {
if lock {
u.txLock.Lock()
defer u.txLock.Unlock()
}
if u.tx != nil {
return fmt.Errorf("transaction already exists")
}
tx := &UsbGadgetTransaction{
c: &ChangeSet{},
log: u.log,
udc: u.udc,
dwc3Path: dwc3Path,
kvmGadgetPath: u.kvmGadgetPath,
configC1Path: u.configC1Path,
orderedConfigItems: u.getOrderedConfigItems(),
isGadgetConfigItemEnabled: u.isGadgetConfigItemEnabled,
}
u.tx = tx
return nil
}
func (u *UsbGadget) WithTransaction(fn func() error) error {
u.txLock.Lock()
defer u.txLock.Unlock()
err := u.newUsbGadgetTransaction(false)
if err != nil {
u.log.Error().Err(err).Msg("failed to create transaction")
return err
}
if err := fn(); err != nil {
u.log.Error().Err(err).Msg("transaction failed")
return err
}
result := u.tx.Commit()
u.tx = nil
return result
}
func (tx *UsbGadgetTransaction) addFileChange(component string, change RequestedFileChange) string {
change.Component = component
tx.c.AddFileChangeStruct(change)
key := change.Key
if key == "" {
key = change.Path
}
return key
}
func (tx *UsbGadgetTransaction) mkdirAll(component string, path string, description string, deps []string) string {
return tx.addFileChange(component, RequestedFileChange{
Path: path,
ExpectedState: FileStateDirectory,
Description: description,
DependsOn: deps,
})
}
func (tx *UsbGadgetTransaction) removeFile(component string, path string, description string) string {
return tx.addFileChange(component, RequestedFileChange{
Path: path,
ExpectedState: FileStateAbsent,
Description: description,
})
}
func (tx *UsbGadgetTransaction) Commit() error {
tx.addFileChange("gadget-finalize", *tx.reorderSymlinkChanges)
err := tx.c.Apply()
if err != nil {
tx.log.Error().Err(err).Msg("failed to update usbgadget configuration")
return err
}
tx.log.Info().Msg("usbgadget configuration updated")
return nil
}
func (u *UsbGadget) getOrderedConfigItems() orderedGadgetConfigItems {
items := make([]gadgetConfigItemWithKey, 0)
for key, item := range u.configMap {
items = append(items, gadgetConfigItemWithKey{key, item})
}
sort.Slice(items, func(i, j int) bool {
return items[i].item.order < items[j].item.order
})
return items
}
func (tx *UsbGadgetTransaction) MountConfigFS() {
tx.addFileChange("gadget", RequestedFileChange{
Path: configFSPath,
ExpectedState: FileStateMountedConfigFS,
Description: "mount configfs",
})
}
func (tx *UsbGadgetTransaction) CreateConfigPath() {
tx.mkdirAll(
"gadget",
tx.configC1Path,
"create config path",
[]string{configFSPath},
)
}
func (tx *UsbGadgetTransaction) WriteGadgetConfig() {
// create kvm gadget path
tx.mkdirAll(
"gadget",
tx.kvmGadgetPath,
"create kvm gadget path",
[]string{tx.configC1Path},
)
deps := make([]string, 0)
deps = append(deps, tx.kvmGadgetPath)
for _, val := range tx.orderedConfigItems {
key := val.key
item := val.item
// check if the item is enabled in the config
if !tx.isGadgetConfigItemEnabled(key) {
tx.DisableGadgetItemConfig(item)
continue
}
deps = tx.writeGadgetItemConfig(item, deps)
}
tx.WriteUDC()
}
func (tx *UsbGadgetTransaction) getDisableKeys() []string {
disableKeys := make([]string, 0)
for _, item := range tx.orderedConfigItems {
if !tx.isGadgetConfigItemEnabled(item.key) {
continue
}
if item.item.configPath == nil || item.item.configAttrs != nil {
continue
}
disableKeys = append(disableKeys, fmt.Sprintf("disable-%s", item.item.device))
}
return disableKeys
}
func (tx *UsbGadgetTransaction) DisableGadgetItemConfig(item gadgetConfigItem) {
// remove symlink if exists
if item.configPath == nil {
return
}
configPath := joinPath(tx.configC1Path, item.configPath)
_ = tx.removeFile("gadget", configPath, "remove symlink: disable gadget config")
}
func (tx *UsbGadgetTransaction) writeGadgetItemConfig(item gadgetConfigItem, deps []string) []string {
component := item.device
// create directory for the item
files := make([]string, 0)
files = append(files, deps...)
gadgetItemPath := joinPath(tx.kvmGadgetPath, item.path)
if gadgetItemPath != tx.kvmGadgetPath {
gadgetItemDir := tx.mkdirAll(component, gadgetItemPath, "create gadget item directory", files)
files = append(files, gadgetItemDir)
}
beforeChange := make([]string, 0)
disableGadgetItemKey := fmt.Sprintf("disable-%s", item.device)
if item.configPath != nil && item.configAttrs == nil {
beforeChange = append(beforeChange, tx.getDisableKeys()...)
}
if len(item.attrs) > 0 {
// write attributes for the item
files = append(files, tx.writeGadgetAttrs(
gadgetItemPath,
item.attrs,
component,
beforeChange,
)...)
}
// write report descriptor if available
reportDescPath := path.Join(gadgetItemPath, "report_desc")
if item.reportDesc != nil {
tx.addFileChange(component, RequestedFileChange{
Path: reportDescPath,
ExpectedState: FileStateFileContentMatch,
ExpectedContent: item.reportDesc,
Description: "write report descriptor",
BeforeChange: beforeChange,
DependsOn: files,
})
} else {
tx.addFileChange(component, RequestedFileChange{
Path: reportDescPath,
ExpectedState: FileStateAbsent,
Description: "remove report descriptor",
BeforeChange: beforeChange,
DependsOn: files,
})
}
files = append(files, reportDescPath)
// create config directory if configAttrs are set
if len(item.configAttrs) > 0 {
configItemPath := joinPath(tx.configC1Path, item.configPath)
if configItemPath != tx.configC1Path {
configItemDir := tx.mkdirAll(component, configItemPath, "create config item directory", files)
files = append(files, configItemDir)
}
files = append(files, tx.writeGadgetAttrs(
configItemPath,
item.configAttrs,
component,
beforeChange,
)...)
}
// create symlink if configPath is set
if item.configPath != nil && item.configAttrs == nil {
configPath := joinPath(tx.configC1Path, item.configPath)
// the change will be only applied by `beforeChange`
tx.addFileChange(component, RequestedFileChange{
Key: disableGadgetItemKey,
Path: configPath,
ExpectedState: FileStateAbsent,
When: "beforeChange", // TODO: make it more flexible
Description: "remove symlink",
})
tx.addReorderSymlinkChange(configPath, gadgetItemPath, files)
}
return files
}
func (tx *UsbGadgetTransaction) writeGadgetAttrs(basePath string, attrs gadgetAttributes, component string, beforeChange []string) (files []string) {
files = make([]string, 0)
for key, val := range attrs {
filePath := filepath.Join(basePath, key)
tx.addFileChange(component, RequestedFileChange{
Path: filePath,
ExpectedState: FileStateFileContentMatch,
ExpectedContent: []byte(val),
Description: "write gadget attribute",
DependsOn: []string{basePath},
BeforeChange: beforeChange,
})
files = append(files, filePath)
}
return files
}
func (tx *UsbGadgetTransaction) addReorderSymlinkChange(path string, target string, deps []string) {
tx.log.Trace().Str("path", path).Str("target", target).Msg("add reorder symlink change")
if tx.reorderSymlinkChanges == nil {
tx.reorderSymlinkChanges = &RequestedFileChange{
Component: "gadget-finalize",
Key: "reorder-symlinks",
Path: tx.configC1Path,
ExpectedState: FileStateSymlinkInOrderConfigFS,
Description: "order symlinks",
ParamSymlinks: []symlink{},
}
}
tx.reorderSymlinkChanges.DependsOn = append(tx.reorderSymlinkChanges.DependsOn, deps...)
tx.reorderSymlinkChanges.ParamSymlinks = append(tx.reorderSymlinkChanges.ParamSymlinks, symlink{
Path: path,
Target: target,
})
}
func (tx *UsbGadgetTransaction) WriteUDC() {
// bound the gadget to a UDC (USB Device Controller)
path := path.Join(tx.kvmGadgetPath, "UDC")
tx.addFileChange("udc", RequestedFileChange{
Key: "udc",
Path: path,
ExpectedState: FileStateFileContentMatch,
ExpectedContent: []byte(tx.udc),
DependsOn: []string{"reorder-symlinks"},
Description: "write UDC",
})
}
func (tx *UsbGadgetTransaction) RebindUsb(ignoreUnbindError bool) {
// remove the gadget from the UDC
tx.addFileChange("udc", RequestedFileChange{
Path: path.Join(tx.dwc3Path, "unbind"),
ExpectedState: FileStateFileWrite,
ExpectedContent: []byte(tx.udc),
Description: "unbind UDC",
DependsOn: []string{"udc"},
IgnoreErrors: ignoreUnbindError,
})
// bind the gadget to the UDC
tx.addFileChange("udc", RequestedFileChange{
Path: path.Join(tx.dwc3Path, "bind"),
ExpectedState: FileStateFileWrite,
ExpectedContent: []byte(tx.udc),
Description: "bind UDC",
DependsOn: []string{path.Join(tx.dwc3Path, "unbind")},
})
}

27
internal/usbgadget/log.go Normal file
View File

@ -0,0 +1,27 @@
package usbgadget
import (
"errors"
)
func (u *UsbGadget) logWarn(msg string, err error) error {
if err == nil {
err = errors.New(msg)
}
if u.strictMode {
return err
}
u.log.Warn().Err(err).Msg(msg)
return nil
}
func (u *UsbGadget) logError(msg string, err error) error {
if err == nil {
err = errors.New(msg)
}
if u.strictMode {
return err
}
u.log.Error().Err(err).Msg(msg)
return nil
}

View File

@ -50,18 +50,6 @@ func (u *UsbGadget) RebindUsb(ignoreUnbindError bool) error {
return u.rebindUsb(ignoreUnbindError)
}
func (u *UsbGadget) writeUDC() error {
path := path.Join(u.kvmGadgetPath, "UDC")
u.log.Trace().Str("udc", u.udc).Str("path", path).Msg("writing UDC")
err := u.writeIfDifferent(path, []byte(u.udc), 0644)
if err != nil {
return fmt.Errorf("failed to write UDC: %w", err)
}
return nil
}
// GetUsbState returns the current state of the USB gadget
func (u *UsbGadget) GetUsbState() (state string) {
stateFile := path.Join("/sys/class/udc", u.udc, "state")

View File

@ -8,6 +8,7 @@ import (
"sync"
"time"
"github.com/jetkvm/kvm/internal/logging"
"github.com/rs/zerolog"
)
@ -28,7 +29,8 @@ type Config struct {
Manufacturer string `json:"manufacturer"`
Product string `json:"product"`
isEmpty bool
strictMode bool // when it's enabled, all warnings will be converted to errors
isEmpty bool
}
var defaultUsbGadgetDevices = Devices{
@ -59,22 +61,31 @@ type UsbGadget struct {
enabledDevices Devices
strictMode bool // only intended for testing for now
absMouseAccumulatedWheelY float64
lastUserInput time.Time
tx *UsbGadgetTransaction
txLock sync.Mutex
log *zerolog.Logger
}
const configFSPath = "/sys/kernel/config"
const gadgetPath = "/sys/kernel/config/usb_gadget"
var defaultLogger = zerolog.New(os.Stdout).Level(zerolog.InfoLevel)
var defaultLogger = logging.GetSubsystemLogger("usbgadget")
// NewUsbGadget creates a new UsbGadget.
func NewUsbGadget(name string, enabledDevices *Devices, config *Config, logger *zerolog.Logger) *UsbGadget {
return newUsbGadget(name, defaultGadgetConfig, enabledDevices, config, logger)
}
func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDevices *Devices, config *Config, logger *zerolog.Logger) *UsbGadget {
if logger == nil {
logger = &defaultLogger
logger = defaultLogger
}
if enabledDevices == nil {
@ -89,16 +100,19 @@ func NewUsbGadget(name string, enabledDevices *Devices, config *Config, logger *
name: name,
kvmGadgetPath: path.Join(gadgetPath, name),
configC1Path: path.Join(gadgetPath, name, "configs/c.1"),
configMap: defaultGadgetConfig,
configMap: configMap,
customConfig: *config,
configLock: sync.Mutex{},
keyboardLock: sync.Mutex{},
absMouseLock: sync.Mutex{},
relMouseLock: sync.Mutex{},
txLock: sync.Mutex{},
enabledDevices: *enabledDevices,
lastUserInput: time.Now(),
log: logger,
strictMode: config.strictMode,
absMouseAccumulatedWheelY: 0,
}
if err := g.Init(); err != nil {

View File

@ -3,8 +3,9 @@ package usbgadget
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
func joinPath(basePath string, paths []string) string {
@ -12,44 +13,68 @@ func joinPath(basePath string, paths []string) string {
return filepath.Join(pathArr...)
}
func ensureSymlink(linkPath string, target string) error {
if _, err := os.Lstat(linkPath); err == nil {
currentTarget, err := os.Readlink(linkPath)
if err != nil || currentTarget != target {
err = os.Remove(linkPath)
if err != nil {
return fmt.Errorf("failed to remove existing symlink %s: %w", linkPath, err)
}
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("failed to check if symlink exists: %w", err)
func hexToDecimal(hex string) (int64, error) {
decimal, err := strconv.ParseInt(hex, 16, 64)
if err != nil {
return 0, err
}
if err := os.Symlink(target, linkPath); err != nil {
return fmt.Errorf("failed to create symlink from %s to %s: %w", linkPath, target, err)
}
return nil
return decimal, nil
}
func (u *UsbGadget) writeIfDifferent(filePath string, content []byte, permMode os.FileMode) error {
if _, err := os.Stat(filePath); err == nil {
oldContent, err := os.ReadFile(filePath)
if err == nil {
if bytes.Equal(oldContent, content) {
u.log.Trace().Str("path", filePath).Msg("skipping writing to as it already has the correct content")
return nil
}
func decimalToOctal(decimal int64) string {
return fmt.Sprintf("%04o", decimal)
}
if len(oldContent) == len(content)+1 &&
bytes.Equal(oldContent[:len(content)], content) &&
oldContent[len(content)] == 10 {
u.log.Trace().Str("path", filePath).Msg("skipping writing to as it already has the correct content")
return nil
}
func hexToOctal(hex string) (string, error) {
hex = strings.ToLower(hex)
hex = strings.Replace(hex, "0x", "", 1) //remove 0x or 0X
u.log.Trace().Str("path", filePath).Bytes("old", oldContent).Bytes("new", content).Msg("writing to as it has different content")
decimal, err := hexToDecimal(hex)
if err != nil {
return "", err
}
// Convert the decimal integer to an octal string.
octal := decimalToOctal(decimal)
return octal, nil
}
func compareFileContent(oldContent []byte, newContent []byte, looserMatch bool) bool {
if bytes.Equal(oldContent, newContent) {
return true
}
if len(oldContent) == len(newContent)+1 &&
bytes.Equal(oldContent[:len(newContent)], newContent) &&
oldContent[len(newContent)] == 10 {
return true
}
if len(newContent) == 4 {
if len(oldContent) < 6 || len(oldContent) > 7 {
return false
}
if len(oldContent) == 7 && oldContent[6] == 0x0a {
oldContent = oldContent[:6]
}
oldOctalValue, err := hexToOctal(string(oldContent))
if err != nil {
return false
}
if oldOctalValue == string(newContent) {
return true
}
}
return os.WriteFile(filePath, content, permMode)
if looserMatch {
oldContentStr := strings.TrimSpace(string(oldContent))
newContentStr := strings.TrimSpace(string(newContent))
return oldContentStr == newContentStr
}
return false
}

View File

@ -877,17 +877,6 @@ func rpcSetCloudUrl(apiUrl string, appUrl string) error {
return nil
}
var currentScrollSensitivity string = "default"
func rpcGetScrollSensitivity() (string, error) {
return currentScrollSensitivity, nil
}
func rpcSetScrollSensitivity(sensitivity string) error {
currentScrollSensitivity = sensitivity
return nil
}
func getKeyboardMacros() (interface{}, error) {
macros := make([]KeyboardMacro, len(config.KeyboardMacros))
copy(macros, config.KeyboardMacros)
@ -1053,8 +1042,6 @@ var rpcHandlers = map[string]RPCHandler{
"setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}},
"setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}},
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
"getScrollSensitivity": {Func: rpcGetScrollSensitivity},
"setScrollSensitivity": {Func: rpcSetScrollSensitivity, Params: []string{"sensitivity"}},
"getKeyboardMacros": {Func: getKeyboardMacros},
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
}

View File

@ -29,8 +29,6 @@ runTest() {
function exit_with_code() {
if [ $EXIT_CODE -ne 0 ]; then
printf "\e[0;31m❌ Test failed\e[0m\n"
else
printf "\e[0;32m✅ All tests passed\e[0m\n"
fi
exit $EXIT_CODE

891
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useResizeObserver } from "usehooks-ts";
import {
useDeviceSettingsStore,
useHidStore,
useMouseStore,
useRTCStore,
@ -61,7 +60,6 @@ export default function WebRTCVideo() {
useHidStore();
// Misc states and hooks
const [blockWheelEvent, setBlockWheelEvent] = useState(false);
const disableVideoFocusTrap = useUiStore(state => state.disableVideoFocusTrap);
const [send] = useJsonRpc();
@ -248,17 +246,8 @@ export default function WebRTCVideo() {
],
);
const trackpadSensitivity = useDeviceSettingsStore(state => state.trackpadSensitivity);
const mouseSensitivity = useDeviceSettingsStore(state => state.mouseSensitivity);
const clampMin = useDeviceSettingsStore(state => state.clampMin);
const clampMax = useDeviceSettingsStore(state => state.clampMax);
const blockDelay = useDeviceSettingsStore(state => state.blockDelay);
const trackpadThreshold = useDeviceSettingsStore(state => state.trackpadThreshold);
const mouseWheelHandler = useCallback(
(e: WheelEvent) => {
if (blockWheelEvent) return;
// Determine if the wheel event is an accel scroll value
const isAccel = Math.abs(e.deltaY) >= 100;
@ -266,7 +255,7 @@ export default function WebRTCVideo() {
const accelScrollValue = e.deltaY / 100;
// Calculate the no accel scroll value
const noAccelScrollValue = e.deltaY > 0 ? 1 : (e.deltaY < 0 ? -1 : 0);
const noAccelScrollValue = e.deltaY > 0 ? 1 : e.deltaY < 0 ? -1 : 0;
// Get scroll value
const scrollValue = isAccel ? accelScrollValue : noAccelScrollValue;
@ -277,22 +266,9 @@ export default function WebRTCVideo() {
// Invert the clamped scroll value to match expected behavior
const invertedScrollValue = -clampedScrollValue;
send("wheelReport", { wheelY : invertedScrollValue });
// Apply blocking delay
setBlockWheelEvent(true);
setTimeout(() => setBlockWheelEvent(false), blockDelay);
send("wheelReport", { wheelY: invertedScrollValue });
},
[
blockDelay,
blockWheelEvent,
clampMax,
clampMin,
mouseSensitivity,
send,
trackpadSensitivity,
trackpadThreshold,
],
[send],
);
const resetMousePosition = useCallback(() => {
@ -351,11 +327,7 @@ export default function WebRTCVideo() {
// which means the Alt Gr key state would then be "stuck". At this
// point, we would need to rely on the user to press Alt Gr again
// to properly release the state of that modifier.
.filter(
modifier =>
altKey ||
(modifier !== modifiers["AltLeft"]),
)
.filter(modifier => altKey || modifier !== modifiers["AltLeft"])
// Meta: Keep if Meta is pressed or if the key isn't a Meta key
// Example: If metaKey is true, keep all modifiers
// If metaKey is false, filter out 0x08 (MetaLeft) and 0x80 (MetaRight)
@ -716,7 +688,7 @@ export default function WebRTCVideo() {
disablePictureInPicture
controlsList="nofullscreen"
className={cx(
"z-30 max-h-full min-h-[384px] min-w-[512px] max-w-full bg-black/50 object-contain transition-all duration-1000",
"z-30 max-h-full min-h-[384px] max-w-full min-w-[512px] bg-black/50 object-contain transition-all duration-1000",
{
"cursor-none": settings.isCursorHidden,
"opacity-0":
@ -732,7 +704,7 @@ export default function WebRTCVideo() {
{peerConnection?.connectionState == "connected" && (
<div
style={{ animationDuration: "500ms" }}
className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center"
className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center"
>
<div className="relative h-full w-full rounded-md">
<LoadingVideoOverlay show={isVideoLoading} />

View File

@ -321,8 +321,7 @@ export const useSettingsStore = create(
setDeveloperMode: enabled => set({ developerMode: enabled }),
displayRotation: "270",
setDisplayRotation: (rotation: string) =>
set({ displayRotation: rotation }),
setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }),
backlightSettings: {
max_brightness: 100,
@ -350,67 +349,6 @@ export interface DeviceSettingsState {
setScrollSensitivity: (sensitivity: DeviceSettingsState["scrollSensitivity"]) => void;
}
export const useDeviceSettingsStore = create<DeviceSettingsState>(set => ({
trackpadSensitivity: 3.0,
mouseSensitivity: 5.0,
clampMin: -8,
clampMax: 8,
blockDelay: 25,
trackpadThreshold: 10,
scrollSensitivity: "default",
setScrollSensitivity: sensitivity => {
const wheelSettings: Record<
DeviceSettingsState["scrollSensitivity"],
{
trackpadSensitivity: DeviceSettingsState["trackpadSensitivity"];
mouseSensitivity: DeviceSettingsState["mouseSensitivity"];
clampMin: DeviceSettingsState["clampMin"];
clampMax: DeviceSettingsState["clampMax"];
blockDelay: DeviceSettingsState["blockDelay"];
trackpadThreshold: DeviceSettingsState["trackpadThreshold"];
}
> = {
low: {
trackpadSensitivity: 2.0,
mouseSensitivity: 3.0,
clampMin: -6,
clampMax: 6,
blockDelay: 30,
trackpadThreshold: 10,
},
default: {
trackpadSensitivity: 3.0,
mouseSensitivity: 5.0,
clampMin: -8,
clampMax: 8,
blockDelay: 25,
trackpadThreshold: 10,
},
high: {
trackpadSensitivity: 4.0,
mouseSensitivity: 6.0,
clampMin: -9,
clampMax: 9,
blockDelay: 20,
trackpadThreshold: 10,
},
};
const settings = wheelSettings[sensitivity];
return set({
trackpadSensitivity: settings.trackpadSensitivity,
trackpadThreshold: settings.trackpadThreshold,
mouseSensitivity: settings.mouseSensitivity,
clampMin: settings.clampMin,
clampMax: settings.clampMax,
blockDelay: settings.blockDelay,
scrollSensitivity: sensitivity,
});
},
}));
export interface RemoteVirtualMediaState {
source: "WebRTC" | "HTTP" | "Storage" | null;
mode: "CDROM" | "Disk" | null;

View File

@ -1,23 +1,20 @@
import { CheckCircleIcon } from "@heroicons/react/16/solid";
import { useCallback, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import MouseIcon from "@/assets/mouse-icon.svg";
import PointingFinger from "@/assets/pointing-finger.svg";
import { GridCard } from "@/components/Card";
import { Checkbox } from "@/components/Checkbox";
import { useDeviceSettingsStore, useSettingsStore } from "@/hooks/stores";
import { useSettingsStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { FeatureFlag } from "../components/FeatureFlag";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { useFeatureFlag } from "../hooks/useFeatureFlag";
import { cx } from "../cva.config";
import { SettingsItem } from "./devices.$id.settings";
type ScrollSensitivity = "low" | "default" | "high";
export default function SettingsKeyboardMouseRoute() {
const hideCursor = useSettingsStore(state => state.isCursorHidden);
const setHideCursor = useSettingsStore(state => state.setCursorVisibility);
@ -25,11 +22,6 @@ export default function SettingsKeyboardMouseRoute() {
const mouseMode = useSettingsStore(state => state.mouseMode);
const setMouseMode = useSettingsStore(state => state.setMouseMode);
const scrollSensitivity = useDeviceSettingsStore(state => state.scrollSensitivity);
const setScrollSensitivity = useDeviceSettingsStore(
state => state.setScrollSensitivity,
);
const { isEnabled: isScrollSensitivityEnabled } = useFeatureFlag("0.3.8");
const [jiggler, setJiggler] = useState(false);
@ -41,14 +33,7 @@ export default function SettingsKeyboardMouseRoute() {
if ("error" in resp) return;
setJiggler(resp.result as boolean);
});
if (isScrollSensitivityEnabled) {
send("getScrollSensitivity", {}, resp => {
if ("error" in resp) return;
setScrollSensitivity(resp.result as ScrollSensitivity);
});
}
}, [isScrollSensitivityEnabled, send, setScrollSensitivity]);
}, [isScrollSensitivityEnabled, send]);
const handleJigglerChange = (enabled: boolean) => {
send("setJigglerState", { enabled }, resp => {
@ -62,22 +47,6 @@ export default function SettingsKeyboardMouseRoute() {
});
};
const onScrollSensitivityChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const sensitivity = e.target.value as ScrollSensitivity;
send("setScrollSensitivity", { sensitivity }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set scroll sensitivity: ${resp.error.data || "Unknown error"}`,
);
}
notifications.success("Scroll sensitivity set successfully");
setScrollSensitivity(sensitivity);
});
},
[send, setScrollSensitivity],
);
return (
<div className="space-y-4">
<SettingsPageHeader
@ -96,28 +65,6 @@ export default function SettingsKeyboardMouseRoute() {
/>
</SettingsItem>
<FeatureFlag minAppVersion="0.3.8" name="Scroll Sensitivity">
<SettingsItem
title="Scroll Sensitivity"
description="Adjust the scroll sensitivity"
>
<SelectMenuBasic
size="SM"
label=""
fullWidth
value={scrollSensitivity}
onChange={onScrollSensitivityChange}
options={
[
{ label: "Low", value: "low" },
{ label: "Default", value: "default" },
{ label: "High", value: "high" },
] as { label: string; value: ScrollSensitivity }[]
}
/>
</SettingsItem>
</FeatureFlag>
<SettingsItem
title="Jiggler"
description="Simulate movement of a computer mouse. Prevents sleep mode, standby mode or the screensaver from activating"
@ -131,17 +78,19 @@ export default function SettingsKeyboardMouseRoute() {
<SettingsItem title="Modes" description="Choose the mouse input mode" />
<div className="flex items-center gap-4">
<button
className="block group grow"
onClick={() => { setMouseMode("absolute"); }}
className="group block grow"
onClick={() => {
setMouseMode("absolute");
}}
>
<GridCard>
<div className="flex items-center px-4 py-3 group gap-x-4">
<div className="group flex w-full items-center gap-x-4 px-4 py-3">
<img
className="w-6 shrink-0 dark:invert"
src={PointingFinger}
alt="Finger touching a screen"
/>
<div className="flex items-center justify-between grow">
<div className="flex grow items-center justify-between">
<div className="text-left">
<h3 className="text-sm font-semibold text-black dark:text-white">
Absolute
@ -150,32 +99,44 @@ export default function SettingsKeyboardMouseRoute() {
Most convenient
</p>
</div>
{mouseMode === "absolute" && (
<CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" />
)}
<CheckCircleIcon
className={cx(
"h-4 w-4 text-blue-700 opacity-0 transition dark:text-blue-500",
{ "opacity-100": mouseMode === "absolute" },
)}
/>
</div>
</div>
</GridCard>
</button>
<button
className="block group grow"
onClick={() => { setMouseMode("relative"); }}
className="group block grow"
onClick={() => {
setMouseMode("relative");
}}
>
<GridCard>
<div className="flex items-center px-4 py-3 gap-x-4">
<img className="w-6 shrink-0 dark:invert" src={MouseIcon} alt="Mouse icon" />
<div className="flex items-center justify-between grow">
<div className="flex w-full items-center gap-x-4 px-4 py-3">
<img
className="w-6 shrink-0 dark:invert"
src={MouseIcon}
alt="Mouse icon"
/>
<div className="flex grow items-center justify-between">
<div className="text-left">
<h3 className="text-sm font-semibold text-black dark:text-white">
Relative
</h3>
<p className="text-xs leading-none text-slate-800 dark:text-slate-300">
Most Compatible (Beta)
Most Compatible
</p>
</div>
{mouseMode === "relative" && (
<CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" />
)}
<CheckCircleIcon
className={cx(
"h-4 w-4 text-blue-700 opacity-0 transition dark:text-blue-500",
{ "opacity-100": mouseMode === "relative" },
)}
/>
</div>
</div>
</GridCard>

View File

@ -18,11 +18,9 @@ import useWebSocket from "react-use-websocket";
import { cx } from "@/cva.config";
import {
DeviceSettingsState,
HidState,
NetworkState,
UpdateState,
useDeviceSettingsStore,
useDeviceStore,
useHidStore,
useMountMediaStore,
@ -714,21 +712,6 @@ export default function KvmIdRoute() {
});
}, [appVersion, send, setAppVersion, setSystemVersion]);
const setScrollSensitivity = useDeviceSettingsStore(
state => state.setScrollSensitivity,
);
// Initialize device settings
useEffect(
function initializeDeviceSettings() {
send("getScrollSensitivity", {}, resp => {
if ("error" in resp) return;
setScrollSensitivity(resp.result as DeviceSettingsState["scrollSensitivity"]);
});
},
[send, setScrollSensitivity],
);
const ConnectionStatusElement = useMemo(() => {
const hasConnectionFailed =
connectionFailed || ["failed", "closed"].includes(peerConnectionState || "");