mirror of
https://github.com/sipeed/NanoKVM.git
synced 2026-11-10 18:09:19 -06:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40bbeab8ae | ||
|
|
eb20fb41aa | ||
|
|
21e834b4c8 | ||
|
|
8773605715 | ||
|
|
b23eb33611 | ||
|
|
ef432da2fe | ||
|
|
96c86f6098 | ||
|
|
a7d7574e70 | ||
|
|
b587b9f912 | ||
|
|
004fd59093 | ||
|
|
774d314ce9 | ||
|
|
e4d23014ba | ||
|
|
dc050d1f19 | ||
|
|
57e1f6a4d6 | ||
|
|
d578c63036 | ||
|
|
6b98f5415f | ||
|
|
64ce12cc0d | ||
|
|
183f8dc983 | ||
|
|
7eee77a33d | ||
|
|
c1986acf65 | ||
|
|
c999a883bd | ||
|
|
70f8503cbb | ||
|
|
5b0a7d2a91 | ||
|
|
4fee758ff0 | ||
|
|
736259baa3 | ||
|
|
83b600bbee | ||
|
|
6071a6f57c | ||
|
|
69f5da6fc7 | ||
|
|
3b2ba7c0c1 | ||
|
|
208998c13a | ||
|
|
3de4a18eb4 |
132
.github/workflows/builder-image.yml
vendored
Normal file
132
.github/workflows/builder-image.yml
vendored
Normal file
@@ -0,0 +1,132 @@
|
||||
name: Builder Image
|
||||
|
||||
# Builds the nanokvm-builder image (riscv64 musl toolchain, Go, and a MaixCDK
|
||||
# checkout patched with support/sg2002/additional) and publishes it to GHCR so
|
||||
# the release workflow does not have to rebuild the SDK on every tag.
|
||||
#
|
||||
# The image is deliberately not rebuilt on every push: it only carries the
|
||||
# toolchain. Release builds re-sync support/sg2002/additional into MaixCDK and
|
||||
# recompile from the checked-out source, so a slightly stale image is harmless.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- docker/**
|
||||
- .github/workflows/builder-image.yml
|
||||
|
||||
concurrency:
|
||||
group: builder-image
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and publish nanokvm-builder
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 240
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Free up disk space
|
||||
run: |
|
||||
echo "Before:"
|
||||
df -h /
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
|
||||
/usr/local/share/boost /usr/local/share/powershell
|
||||
sudo docker image prune -af
|
||||
echo "After:"
|
||||
df -h /
|
||||
|
||||
- name: Resolve image reference
|
||||
id: image
|
||||
env:
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
run: |
|
||||
# GHCR only accepts lowercase repository paths.
|
||||
owner=$(echo "$OWNER" | tr '[:upper:]' '[:lower:]')
|
||||
echo "ref=ghcr.io/$owner/nanokvm-builder" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Logging in before the build means a credential or registry problem fails
|
||||
# in seconds instead of after a full SDK build.
|
||||
- name: Log in to GHCR
|
||||
env:
|
||||
GHCR_USER: ${{ github.actor }}
|
||||
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
ok=0
|
||||
for attempt in 1 2 3; do
|
||||
if printf '%s' "$GHCR_TOKEN" \
|
||||
| docker login ghcr.io -u "$GHCR_USER" --password-stdin; then
|
||||
ok=1
|
||||
break
|
||||
fi
|
||||
echo "login attempt $attempt failed, retrying in 15s"
|
||||
sleep 15
|
||||
done
|
||||
if [ "$ok" -ne 1 ]; then
|
||||
echo "::error::could not log in to ghcr.io after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build image
|
||||
run: |
|
||||
# The Dockerfile chowns MaixCDK to DOCKER_UID/DOCKER_GID and the
|
||||
# entrypoint gosu's to the caller's ids, so bake in the ids the
|
||||
# release job will actually run as. Otherwise the SDK tree belongs to
|
||||
# 1000 and syncing components into it fails with permission denied.
|
||||
docker build \
|
||||
--build-arg DOCKER_UID="$(id -u)" \
|
||||
--build-arg DOCKER_GID="$(id -g)" \
|
||||
--tag "${{ steps.image.outputs.ref }}:latest" \
|
||||
--tag "${{ steps.image.outputs.ref }}:${{ github.sha }}" \
|
||||
--file docker/Dockerfile \
|
||||
.
|
||||
|
||||
- name: Verify toolchain
|
||||
run: |
|
||||
docker run --rm -i "${{ steps.image.outputs.ref }}:latest" go version
|
||||
docker run --rm -i "${{ steps.image.outputs.ref }}:latest" \
|
||||
riscv64-unknown-linux-musl-gcc --version | head -1
|
||||
docker run --rm -i "${{ steps.image.outputs.ref }}:latest" patchelf --version
|
||||
|
||||
- name: Push image
|
||||
env:
|
||||
IMAGE_REF: ${{ steps.image.outputs.ref }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
# ghcr.io occasionally times out; a retry is cheaper than rebuilding.
|
||||
# Push the immutable sha tag first so a failure there cannot leave
|
||||
# :latest — which release.yml pulls — already moved.
|
||||
for tag in "$SHA" latest; do
|
||||
ok=0
|
||||
for attempt in 1 2 3; do
|
||||
if docker push "$IMAGE_REF:$tag"; then
|
||||
ok=1
|
||||
break
|
||||
fi
|
||||
echo "push of $tag attempt $attempt failed, retrying in 15s"
|
||||
sleep 15
|
||||
done
|
||||
if [ "$ok" -ne 1 ]; then
|
||||
echo "::error::could not push $IMAGE_REF:$tag after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
{
|
||||
echo "### Builder image published"
|
||||
echo
|
||||
echo '```'
|
||||
echo "${{ steps.image.outputs.ref }}:latest"
|
||||
echo "${{ steps.image.outputs.ref }}:${{ github.sha }}"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
108
.github/workflows/create-tag.yml
vendored
Normal file
108
.github/workflows/create-tag.yml
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
name: NanoKVM Create Tag
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Numeric version to tag, e.g. 2.5.0
|
||||
required: true
|
||||
type: string
|
||||
commit_sha:
|
||||
description: Full commit SHA on main (empty uses latest main)
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: create-tag-${{ inputs.version }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
create-tag:
|
||||
name: Create annotated tag
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: main
|
||||
|
||||
- name: Validate target
|
||||
id: target
|
||||
env:
|
||||
COMMIT_INPUT: ${{ inputs.commit_sha }}
|
||||
VERSION_INPUT: ${{ inputs.version }}
|
||||
run: |
|
||||
VERSION="$VERSION_INPUT"
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "::error::invalid version '$VERSION', expected MAJOR.MINOR.PATCH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch --force --tags origin main
|
||||
|
||||
if [ -n "$COMMIT_INPUT" ]; then
|
||||
if ! echo "$COMMIT_INPUT" | grep -qE '^[0-9a-fA-F]{40}$'; then
|
||||
echo "::error::commit_sha must be a full 40-character hexadecimal SHA"
|
||||
exit 1
|
||||
fi
|
||||
TARGET_SHA=$(git rev-parse "${COMMIT_INPUT}^{commit}" 2>/dev/null) || {
|
||||
echo "::error::commit '$COMMIT_INPUT' does not exist"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
TARGET_SHA=$(git rev-parse 'origin/main^{commit}')
|
||||
fi
|
||||
|
||||
if ! git merge-base --is-ancestor "$TARGET_SHA" origin/main; then
|
||||
echo "::error::commit '$TARGET_SHA' is not reachable from origin/main"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set +e
|
||||
git ls-remote --exit-code --tags origin "refs/tags/$VERSION" >/dev/null
|
||||
TAG_STATUS=$?
|
||||
set -e
|
||||
case "$TAG_STATUS" in
|
||||
0)
|
||||
echo "::error::tag '$VERSION' already exists"
|
||||
exit 1
|
||||
;;
|
||||
2) ;;
|
||||
*)
|
||||
echo "::error::failed to check whether tag '$VERSION' exists"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "sha=$TARGET_SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create and push tag
|
||||
env:
|
||||
TARGET_SHA: ${{ steps.target.outputs.sha }}
|
||||
VERSION: ${{ steps.target.outputs.version }}
|
||||
run: |
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
git tag -a "$VERSION" "$TARGET_SHA" -m "Release $VERSION"
|
||||
git push origin "refs/tags/$VERSION"
|
||||
./scripts/verify-release-tag.sh "$VERSION" "$TARGET_SHA" >/dev/null
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
TARGET_SHA: ${{ steps.target.outputs.sha }}
|
||||
VERSION: ${{ steps.target.outputs.version }}
|
||||
run: |
|
||||
{
|
||||
echo '### NanoKVM tag created'
|
||||
echo
|
||||
echo "- Tag: \`$VERSION\`"
|
||||
echo "- Commit: \`$TARGET_SHA\`"
|
||||
echo
|
||||
echo 'No package, GitHub Release, or CDN object was created.'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
291
.github/workflows/package.yml
vendored
Normal file
291
.github/workflows/package.yml
vendored
Normal file
@@ -0,0 +1,291 @@
|
||||
name: NanoKVM Package
|
||||
|
||||
# Builds nanokvm_<version>.tar.gz and the latest.json manifest that the
|
||||
# on-device updater consumes (server/service/application/). Pull requests get a
|
||||
# uniquely identified, short-lived test artifact. Release publication is a
|
||||
# separate, explicit workflow that calls this one for an existing tag.
|
||||
#
|
||||
# This workflow does NOT publish to cdn.sipeed.com. Uploading latest.json is what
|
||||
# actually offers the update to every device in the field, so that step stays
|
||||
# manual and deliberate.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- .github/workflows/create-tag.yml
|
||||
- .github/workflows/package.yml
|
||||
- .github/workflows/release.yml
|
||||
- kvmapp/**
|
||||
- scripts/**
|
||||
- server/**
|
||||
- support/**
|
||||
- tools/nanokvm_update_edid/**
|
||||
- web/**
|
||||
- Makefile
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Version to package, e.g. 2.4.4
|
||||
required: true
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: Numeric version to package
|
||||
required: true
|
||||
type: string
|
||||
ref:
|
||||
description: Existing tag or commit to check out
|
||||
required: true
|
||||
type: string
|
||||
outputs:
|
||||
artifact_name:
|
||||
description: Uploaded Actions artifact name
|
||||
value: ${{ jobs.package.outputs.artifact_name }}
|
||||
version:
|
||||
description: Packaged version
|
||||
value: ${{ jobs.package.outputs.version }}
|
||||
|
||||
concurrency:
|
||||
group: package-${{ github.event.pull_request.number || inputs.ref || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: Build package
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
outputs:
|
||||
artifact_name: ${{ steps.version.outputs.artifact_name }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
steps:
|
||||
- name: Checkout event ref
|
||||
if: inputs.ref == ''
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# package.sh derives SOURCE_DATE_EPOCH from the commit date.
|
||||
fetch-depth: 0
|
||||
# Build scripts from a pull request must not inherit checkout's token.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout requested ref
|
||||
if: inputs.ref != ''
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Resolve version
|
||||
id: version
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
BUILD_SHA=$(git rev-parse HEAD)
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then
|
||||
VERSION="0.${PR_NUMBER}.${GITHUB_RUN_NUMBER}"
|
||||
SOURCE_SHA="$HEAD_SHA"
|
||||
elif [ -n "$INPUT_VERSION" ]; then
|
||||
VERSION="$INPUT_VERSION"
|
||||
SOURCE_SHA="$BUILD_SHA"
|
||||
else
|
||||
VERSION="$REF_NAME"
|
||||
SOURCE_SHA="$BUILD_SHA"
|
||||
fi
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "::error::invalid version '$VERSION', expected MAJOR.MINOR.PATCH"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$SOURCE_SHA" ]; then
|
||||
echo "::error::could not resolve source commit"
|
||||
exit 1
|
||||
fi
|
||||
SHORT_SHA=$(printf '%s' "$SOURCE_SHA" | cut -c1-12)
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then
|
||||
ARTIFACT_NAME="nanokvm-pr-${PR_NUMBER}-${SHORT_SHA}-run-${GITHUB_RUN_ID}-attempt-${GITHUB_RUN_ATTEMPT}"
|
||||
else
|
||||
ARTIFACT_NAME="nanokvm-${VERSION}-${SHORT_SHA}-run-${GITHUB_RUN_ID}-attempt-${GITHUB_RUN_ATTEMPT}"
|
||||
fi
|
||||
{
|
||||
echo "artifact_name=$ARTIFACT_NAME"
|
||||
echo "build_sha=$BUILD_SHA"
|
||||
echo "source_sha=$SOURCE_SHA"
|
||||
echo "version=$VERSION"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "Packaging version $VERSION as $ARTIFACT_NAME"
|
||||
|
||||
- name: Free up disk space
|
||||
run: |
|
||||
df -h /
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
|
||||
df -h /
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Set up pnpm
|
||||
run: npm install --global pnpm@11
|
||||
|
||||
- name: Build frontend
|
||||
run: make web
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: github.event_name != 'pull_request'
|
||||
env:
|
||||
GHCR_USER: ${{ github.actor }}
|
||||
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
ok=0
|
||||
for attempt in 1 2 3; do
|
||||
if printf '%s' "$GHCR_TOKEN" \
|
||||
| docker login ghcr.io -u "$GHCR_USER" --password-stdin; then
|
||||
ok=1
|
||||
break
|
||||
fi
|
||||
echo "login attempt $attempt failed, retrying in 15s"
|
||||
sleep 15
|
||||
done
|
||||
if [ "$ok" -ne 1 ]; then
|
||||
echo "::error::could not log in to ghcr.io after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Pull builder image
|
||||
id: image
|
||||
env:
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
run: |
|
||||
# GHCR only accepts lowercase repository paths.
|
||||
# shellcheck disable=SC2153 # OWNER is supplied through the step environment.
|
||||
owner=$(echo "$OWNER" | tr '[:upper:]' '[:lower:]')
|
||||
IMAGE_REPO="ghcr.io/$owner/nanokvm-builder"
|
||||
TAGGED_REF="${IMAGE_REPO}:latest"
|
||||
ok=0
|
||||
for attempt in 1 2 3; do
|
||||
if docker pull "$TAGGED_REF"; then
|
||||
ok=1
|
||||
break
|
||||
fi
|
||||
echo "pull attempt $attempt failed, retrying in 15s"
|
||||
sleep 15
|
||||
done
|
||||
if [ "$ok" -ne 1 ]; then
|
||||
echo "::error::could not pull $TAGGED_REF - run the 'Builder Image' workflow first and ensure PR builds can pull it without credentials"
|
||||
exit 1
|
||||
fi
|
||||
RESOLVED_REF=$(docker image inspect --format='{{index .RepoDigests 0}}' "$TAGGED_REF")
|
||||
case "$RESOLVED_REF" in
|
||||
"$IMAGE_REPO"@sha256:*) ;;
|
||||
*)
|
||||
echo "::error::could not resolve immutable digest for $TAGGED_REF (got '$RESOLVED_REF')"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "Pulled $RESOLVED_REF"
|
||||
echo "ref=$RESOLVED_REF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build riscv64 artifacts
|
||||
run: |
|
||||
make release-build \
|
||||
DOCKER_TTY= \
|
||||
IMAGE_NAME="${{ steps.image.outputs.ref }}"
|
||||
|
||||
- name: Assemble package
|
||||
run: make package VERSION="${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Compare against the published release
|
||||
# Informational only: highlights what changed relative to what devices
|
||||
# are currently running. Never blocks the build.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
./scripts/compare-release.sh \
|
||||
"build/release/nanokvm_${{ steps.version.outputs.version }}.tar.gz"
|
||||
|
||||
- name: Write build provenance
|
||||
env:
|
||||
ARTIFACT_NAME: ${{ steps.version.outputs.artifact_name }}
|
||||
BUILDER_IMAGE: ${{ steps.image.outputs.ref }}
|
||||
BUILD_SHA: ${{ steps.version.outputs.build_sha }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
SOURCE_SHA: ${{ steps.version.outputs.source_sha }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
TARBALL="build/release/nanokvm_${VERSION}.tar.gz"
|
||||
TARBALL_NAME=$(basename "$TARBALL")
|
||||
SHA256_HEX=$(sha256sum "$TARBALL" | cut -d' ' -f1)
|
||||
SHA512_HEX=$(sha512sum "$TARBALL" | cut -d' ' -f1)
|
||||
SHA512_BASE64=$(jq -er '.sha512 | select(type == "string" and length > 0)' \
|
||||
build/release/latest.json)
|
||||
ACTUAL_BASE64=$(openssl dgst -sha512 -binary "$TARBALL" | openssl base64 -A)
|
||||
if [ "$SHA512_BASE64" != "$ACTUAL_BASE64" ]; then
|
||||
echo "::error::latest.json sha512 does not match $TARBALL"
|
||||
exit 1
|
||||
fi
|
||||
printf '%s %s\n' "$SHA256_HEX" "$TARBALL_NAME" > build/release/sha256.txt
|
||||
{
|
||||
echo "artifact=${ARTIFACT_NAME}"
|
||||
echo "version=${VERSION}"
|
||||
echo "event=${GITHUB_EVENT_NAME}"
|
||||
echo "pull_request=${PR_NUMBER}"
|
||||
echo "source_sha=${SOURCE_SHA}"
|
||||
echo "build_sha=${BUILD_SHA}"
|
||||
echo "builder_image=${BUILDER_IMAGE}"
|
||||
echo "run_id=${GITHUB_RUN_ID}"
|
||||
echo "run_attempt=${GITHUB_RUN_ATTEMPT}"
|
||||
echo "run_url=https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "tarball=${TARBALL_NAME}"
|
||||
echo "sha256_hex=${SHA256_HEX}"
|
||||
echo "sha512_hex=${SHA512_HEX}"
|
||||
echo "sha512_base64=${SHA512_BASE64}"
|
||||
} > build/release/BUILD_INFO.txt
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
{
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
echo "> [!WARNING]"
|
||||
echo "> PR TEST ONLY — DO NOT PUBLISH TO CDN"
|
||||
echo
|
||||
fi
|
||||
echo "### nanokvm_${VERSION}.tar.gz"
|
||||
echo
|
||||
echo '```text'
|
||||
cat build/release/BUILD_INFO.txt
|
||||
echo '```'
|
||||
echo
|
||||
echo '```json'
|
||||
cat build/release/latest.json
|
||||
echo '```'
|
||||
echo
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
echo "Upload only the inner tarball through NanoKVM's manual offline update UI."
|
||||
else
|
||||
echo "Publishing to \`cdn.sipeed.com/nanokvm/\` is a separate manual step."
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.version.outputs.artifact_name }}
|
||||
path: |
|
||||
build/release/nanokvm_${{ steps.version.outputs.version }}.tar.gz
|
||||
build/release/latest.json
|
||||
build/release/sha256.txt
|
||||
build/release/BUILD_INFO.txt
|
||||
if-no-files-found: error
|
||||
retention-days: ${{ github.event_name == 'pull_request' && 7 || 90 }}
|
||||
244
.github/workflows/release.yml
vendored
Normal file
244
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,244 @@
|
||||
name: NanoKVM Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Existing numeric tag to release, e.g. 2.5.0
|
||||
required: true
|
||||
type: string
|
||||
release_action:
|
||||
description: Publish a release or promote a tested prerelease
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- publish-prerelease
|
||||
- publish-stable
|
||||
- promote-stable
|
||||
|
||||
# Latest is repository-global, so all Release changes must be serialized.
|
||||
concurrency:
|
||||
group: nanokvm-github-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Resolve release tag
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
source_sha: ${{ steps.tag.outputs.source_sha }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify tag
|
||||
id: tag
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
SOURCE_SHA=$(./scripts/verify-release-tag.sh "$TAG")
|
||||
echo "source_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
package:
|
||||
name: Build tag package
|
||||
needs: validate
|
||||
if: inputs.release_action != 'promote-stable'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
uses: ./.github/workflows/package.yml
|
||||
with:
|
||||
ref: ${{ needs.validate.outputs.source_sha }}
|
||||
version: ${{ inputs.tag }}
|
||||
|
||||
publish:
|
||||
name: Publish GitHub release
|
||||
needs:
|
||||
- validate
|
||||
- package
|
||||
if: inputs.release_action != 'promote-stable'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout release tooling
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download package artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ${{ needs.package.outputs.artifact_name }}
|
||||
path: build/release
|
||||
|
||||
- name: Publish release
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_ACTION: ${{ inputs.release_action }}
|
||||
SOURCE_SHA: ${{ needs.validate.outputs.source_sha }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
./scripts/verify-release-assets.sh build/release "$TAG"
|
||||
|
||||
case "$RELEASE_ACTION" in
|
||||
publish-prerelease) IS_PRERELEASE=true ;;
|
||||
publish-stable) IS_PRERELEASE=false ;;
|
||||
*)
|
||||
echo "::error::invalid publish action '$RELEASE_ACTION'"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
ENDPOINT="repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}"
|
||||
set +e
|
||||
HEADERS=$(gh api --include --silent "$ENDPOINT" 2>&1)
|
||||
API_STATUS=$?
|
||||
set -e
|
||||
HTTP_STATUS=$(printf '%s\n' "$HEADERS" | awk '
|
||||
$1 ~ /^HTTP\// { status = $2 }
|
||||
END { print status }
|
||||
')
|
||||
if [ "$API_STATUS" -eq 0 ] && [ "$HTTP_STATUS" = 200 ]; then
|
||||
echo "::error::release or draft '$TAG' already exists"
|
||||
echo "Publishing is one-shot. Inspect it at https://github.com/${GITHUB_REPOSITORY}/releases."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$API_STATUS" -eq 0 ] || [ "$HTTP_STATUS" != 404 ]; then
|
||||
echo "::error::failed to query release '$TAG' (HTTP status: ${HTTP_STATUS:-unknown})"
|
||||
printf '%s\n' "$HEADERS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARBALL="build/release/nanokvm_${TAG}.tar.gz"
|
||||
SHA256=$(sha256sum "$TARBALL" | cut -d' ' -f1)
|
||||
SHA512=$(jq -er '.sha512' build/release/latest.json)
|
||||
# Backticks below are intentional Markdown, not command substitution.
|
||||
# shellcheck disable=SC2016
|
||||
NOTES=$(printf '%s\n' \
|
||||
"Application package for NanoKVM ${TAG}." \
|
||||
'' \
|
||||
'### Checksums' \
|
||||
'' \
|
||||
'`SHA-256` (hex):' \
|
||||
'' \
|
||||
" ${SHA256}" \
|
||||
'' \
|
||||
'`SHA-512` (base64, as expected by the on-device updater):' \
|
||||
'' \
|
||||
" ${SHA512}" \
|
||||
'' \
|
||||
'To offer this build over OTA, publish it through the separate CDN workflow.')
|
||||
|
||||
STATE_ARGS=(--prerelease="$IS_PRERELEASE")
|
||||
if [ "$IS_PRERELEASE" = true ]; then
|
||||
STATE_ARGS+=(--latest=false)
|
||||
fi
|
||||
|
||||
./scripts/verify-release-tag.sh "$TAG" "$SOURCE_SHA" >/dev/null
|
||||
gh release create "$TAG" \
|
||||
--verify-tag \
|
||||
--title "$TAG" \
|
||||
--notes "$NOTES" \
|
||||
"${STATE_ARGS[@]}" \
|
||||
"$TARBALL" build/release/latest.json build/release/sha256.txt
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
RELEASE_ACTION: ${{ inputs.release_action }}
|
||||
SOURCE_SHA: ${{ needs.validate.outputs.source_sha }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
{
|
||||
echo '### NanoKVM GitHub Release published'
|
||||
echo
|
||||
echo "- Tag: \`$TAG\`"
|
||||
echo "- Commit: \`$SOURCE_SHA\`"
|
||||
echo "- Action: \`$RELEASE_ACTION\`"
|
||||
echo
|
||||
echo 'No CDN object was uploaded.'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
promote:
|
||||
name: Promote verified prerelease
|
||||
needs: validate
|
||||
if: inputs.release_action == 'promote-stable'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout release tooling
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify and promote release
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SOURCE_SHA: ${{ needs.validate.outputs.source_sha }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
RELEASE_JSON=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}")
|
||||
if [ "$(jq -r '.draft' <<< "$RELEASE_JSON")" != false ]; then
|
||||
echo "::error::release '$TAG' is a draft"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(jq -r '.prerelease' <<< "$RELEASE_JSON")" != true ]; then
|
||||
echo "::error::release '$TAG' is not a prerelease"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CURRENT_ASSETS=$(jq -r '.assets[].name' <<< "$RELEASE_JSON" | LC_ALL=C sort)
|
||||
EXPECTED_ASSETS=$(printf '%s\n' \
|
||||
latest.json "nanokvm_${TAG}.tar.gz" sha256.txt | LC_ALL=C sort)
|
||||
if [ "$CURRENT_ASSETS" != "$EXPECTED_ASSETS" ]; then
|
||||
echo "::error::release '$TAG' does not have the exact expected asset set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p build/release
|
||||
gh release download "$TAG" --dir build/release \
|
||||
--pattern "nanokvm_${TAG}.tar.gz" \
|
||||
--pattern latest.json \
|
||||
--pattern sha256.txt
|
||||
./scripts/verify-release-assets.sh build/release "$TAG"
|
||||
|
||||
./scripts/verify-release-tag.sh "$TAG" "$SOURCE_SHA" >/dev/null
|
||||
gh release edit "$TAG" --prerelease=false --latest
|
||||
./scripts/verify-release-tag.sh "$TAG" "$SOURCE_SHA" >/dev/null
|
||||
|
||||
if [ "$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" --jq '.prerelease')" != false ]; then
|
||||
echo "::error::release '$TAG' was not promoted"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq '.tag_name')" != "$TAG" ]; then
|
||||
echo "::error::release '$TAG' was not marked latest"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
SOURCE_SHA: ${{ needs.validate.outputs.source_sha }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
{
|
||||
echo '### NanoKVM prerelease promoted'
|
||||
echo
|
||||
echo "- Tag: \`$TAG\`"
|
||||
echo "- Commit: \`$SOURCE_SHA\`"
|
||||
echo '- Assets: unchanged and checksum-verified'
|
||||
echo
|
||||
echo 'No package was rebuilt and no CDN object was uploaded.'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -29,3 +29,6 @@ support/sg2002/kvm_vision_test/CMakeLists.txt
|
||||
support/sg2002/additional/original
|
||||
kvmapp/server/dl_lib
|
||||
kvmapp/kvm_system/kvm_system
|
||||
|
||||
# Release packages assembled by scripts/package.sh
|
||||
/build/
|
||||
|
||||
10
CHANGELOG.md
10
CHANGELOG.md
@@ -1,3 +1,13 @@
|
||||
## 2.4.3 (2026-06-09)
|
||||
|
||||
### Features
|
||||
|
||||
* Added LT6911D support
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Improved USB network adapter compatibility on Windows
|
||||
|
||||
## 2.4.2 (2026-05-20)
|
||||
|
||||
### Features
|
||||
|
||||
73
Makefile
73
Makefile
@@ -1,19 +1,30 @@
|
||||
# Makefile for NanoKVM Project
|
||||
|
||||
# Configuration
|
||||
IMAGE_NAME := nanokvm-builder
|
||||
UID := $(shell id -u)
|
||||
GID := $(shell id -g)
|
||||
# ?= so a prebuilt image can be used instead, e.g. a GHCR-cached builder in CI.
|
||||
IMAGE_NAME ?= nanokvm-builder-local-$(UID)-$(GID)
|
||||
PWD := $(shell pwd)
|
||||
VERSION ?=
|
||||
|
||||
# Docker run common parameters
|
||||
# Docker run common parameters. Allocating a TTY breaks in environments without
|
||||
# one, so it can be overridden with DOCKER_TTY=
|
||||
DOCKER_TTY ?= -it
|
||||
DOCKER_RUN_BASE := docker run -e UID=$(UID) -e GID=$(GID) -v $(PWD):/home/build/NanoKVM --rm
|
||||
|
||||
# Build the image with the host user's identity so entrypoint does not need
|
||||
# to recursively rewrite the MaixCDK home directory at container startup.
|
||||
DOCKER_BUILD_ARGS := --build-arg DOCKER_UID=$(UID) --build-arg DOCKER_GID=$(GID)
|
||||
|
||||
# Build commands
|
||||
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
|
||||
VISION_BUILD_CMD := . ./home/build/MaixCDK/bin/activate && cd /home/build/NanoKVM/support/sg2002 && ./build kvm_vision && ./build kvm_vision add_to_kvmapp
|
||||
RELEASE_BUILD_CMD := /home/build/NanoKVM/scripts/build-in-container.sh
|
||||
|
||||
.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 vision \
|
||||
web release-build package release all clean
|
||||
|
||||
# Default target
|
||||
all: app support
|
||||
@@ -29,8 +40,13 @@ help:
|
||||
@echo " rebuild-image - Force rebuild Docker image"
|
||||
@echo " shell - Enter interactive builder environment"
|
||||
@echo " app - Build Go application server"
|
||||
@echo " support - Build hardware support libraries"
|
||||
@echo " support - Build kvm_system daemon"
|
||||
@echo " vision - Build video libraries (libkvm.so)"
|
||||
@echo " web - Build the frontend into web/dist"
|
||||
@echo " all - Build both app and support (default)"
|
||||
@echo " release-build - Build every riscv64 release artifact in one pass"
|
||||
@echo " package - Assemble nanokvm_<VERSION>.tar.gz + latest.json"
|
||||
@echo " release - release-build + web + package (needs VERSION=x.y.z)"
|
||||
@echo " clean - Clean build artifacts"
|
||||
@echo ""
|
||||
@echo "Prerequisites:"
|
||||
@@ -58,7 +74,7 @@ check-image: check-root
|
||||
builder-image: check-root
|
||||
@if ! docker image inspect $(IMAGE_NAME) >/dev/null 2>&1; then \
|
||||
echo "Building Docker image..."; \
|
||||
docker build -t $(IMAGE_NAME) -f docker/Dockerfile ./; \
|
||||
docker build $(DOCKER_BUILD_ARGS) -t $(IMAGE_NAME) -f docker/Dockerfile ./; \
|
||||
else \
|
||||
echo "Docker image $(IMAGE_NAME) already exists."; \
|
||||
fi
|
||||
@@ -66,7 +82,7 @@ builder-image: check-root
|
||||
# Force rebuild Docker image
|
||||
rebuild-image: check-root
|
||||
@echo "Force rebuilding Docker image..."
|
||||
@docker build --no-cache -t $(IMAGE_NAME) -f docker/Dockerfile ./
|
||||
@docker build --no-cache $(DOCKER_BUILD_ARGS) -t $(IMAGE_NAME) -f docker/Dockerfile ./
|
||||
|
||||
# Enter interactive shell (equivalent to build.sh with no arguments)
|
||||
shell: check-root builder-image
|
||||
@@ -76,12 +92,43 @@ shell: check-root builder-image
|
||||
# Build Go application
|
||||
app: check-root builder-image
|
||||
@echo "Building app..."
|
||||
@$(DOCKER_RUN_BASE) -it $(IMAGE_NAME) /bin/bash -c '$(GO_BUILD_CMD)'
|
||||
@$(DOCKER_RUN_BASE) $(DOCKER_TTY) $(IMAGE_NAME) /bin/bash -c '$(GO_BUILD_CMD)'
|
||||
|
||||
# Build hardware support libraries
|
||||
support: check-root builder-image
|
||||
@echo "Building support..."
|
||||
@$(DOCKER_RUN_BASE) -it $(IMAGE_NAME) /bin/bash -c '$(SUPPORT_BUILD_CMD)'
|
||||
@$(DOCKER_RUN_BASE) $(DOCKER_TTY) $(IMAGE_NAME) /bin/bash -c '$(SUPPORT_BUILD_CMD)'
|
||||
|
||||
# Build video libraries (libkvm.so) into kvmapp/server/dl_lib
|
||||
vision: check-root builder-image
|
||||
@echo "Building vision..."
|
||||
@$(DOCKER_RUN_BASE) $(DOCKER_TTY) $(IMAGE_NAME) /bin/bash -c '$(VISION_BUILD_CMD)'
|
||||
|
||||
# Build every riscv64 release artifact in one container pass
|
||||
release-build: check-root builder-image
|
||||
@echo "Building release artifacts..."
|
||||
@$(DOCKER_RUN_BASE) $(DOCKER_TTY) $(IMAGE_NAME) /bin/bash -c '$(RELEASE_BUILD_CMD)'
|
||||
|
||||
# Build the frontend (runs on the host; the builder image has no Node)
|
||||
web:
|
||||
@echo "Building web..."
|
||||
@cd web && pnpm install --frozen-lockfile && pnpm build
|
||||
|
||||
# Assemble the release package and its manifest
|
||||
package:
|
||||
@if [ -z "$(VERSION)" ]; then \
|
||||
echo "VERSION is required, e.g. make package VERSION=2.4.4"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@scripts/package.sh "$(VERSION)"
|
||||
|
||||
# Full local release: riscv64 artifacts + frontend + package.
|
||||
# Invoked as sub-makes rather than prerequisites: packaging asserts on what the
|
||||
# earlier steps produce, so the order matters even under make -j.
|
||||
release:
|
||||
@$(MAKE) release-build
|
||||
@$(MAKE) web
|
||||
@$(MAKE) package VERSION="$(VERSION)"
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
@@ -94,4 +141,12 @@ clean:
|
||||
rm -rf support/sg2002/build; \
|
||||
echo "Removed support/sg2002/build"; \
|
||||
fi
|
||||
@echo "Clean completed."
|
||||
@if [ -d build/release ]; then \
|
||||
rm -rf build/release; \
|
||||
echo "Removed build/release"; \
|
||||
fi
|
||||
@if [ -d web/dist ]; then \
|
||||
rm -rf web/dist; \
|
||||
echo "Removed web/dist"; \
|
||||
fi
|
||||
@echo "Clean completed."
|
||||
|
||||
@@ -87,6 +87,7 @@ Start with the guide that matches the part of NanoKVM you want to work on:
|
||||
- **System support modules:** Build and update the low-level hardware support components in [support/sg2002/README.md](support/sg2002/README.md).
|
||||
- **Backend service:** Set up, build, and understand the Go service in [server/README.md](server/README.md).
|
||||
- **Frontend UI:** Develop, lint, and build the React interface in [web/README.md](web/README.md).
|
||||
- **Release packaging:** Assemble the `nanokvm_<version>.tar.gz` update package in [scripts/README.md](scripts/README.md).
|
||||
|
||||
> Backend compilation and runtime validation require the target toolchain or a NanoKVM device. See the module-specific guides above for the latest development workflow.
|
||||
|
||||
|
||||
@@ -3,11 +3,24 @@ FROM ubuntu:24.04 AS base_apt
|
||||
# RUN echo 'Acquire::http::Proxy "http://cache.lan:3142";' > /etc/apt/apt.conf.d/00cacher
|
||||
RUN userdel -r ubuntu
|
||||
RUN apt update \
|
||||
&& apt install wget git cmake build-essential python3 python3-venv python3-pip autoconf automake libtool gosu -y
|
||||
&& apt install wget git cmake build-essential python3 python3-venv python3-pip autoconf automake libtool gosu patchelf -y
|
||||
|
||||
ARG DOCKER_USER=build
|
||||
RUN useradd -m "$DOCKER_USER"
|
||||
USER $DOCKER_USER
|
||||
ARG DOCKER_UID=1000
|
||||
ARG DOCKER_GID=1000
|
||||
# Reuse existing accounts for numeric IDs that are already present in the
|
||||
# base image. All later ownership and user switches use numeric IDs, so a
|
||||
# dedicated build user/group is only needed when the ID does not exist.
|
||||
RUN set -eux; \
|
||||
if ! getent group "$DOCKER_GID" >/dev/null; then \
|
||||
groupadd --gid "$DOCKER_GID" "$DOCKER_USER"; \
|
||||
fi; \
|
||||
if ! getent passwd "$DOCKER_UID" >/dev/null; then \
|
||||
useradd --uid "$DOCKER_UID" --gid "$DOCKER_GID" --no-create-home \
|
||||
--home-dir "/home/$DOCKER_USER" --shell /bin/bash "$DOCKER_USER"; \
|
||||
fi; \
|
||||
install -d -o "$DOCKER_UID" -g "$DOCKER_GID" "/home/$DOCKER_USER"
|
||||
USER $DOCKER_UID:$DOCKER_GID
|
||||
|
||||
FROM base_apt AS host_tools
|
||||
|
||||
@@ -39,6 +52,8 @@ RUN cd /tmp/go_cache \
|
||||
|
||||
FROM base_apt AS sdk
|
||||
|
||||
ENV HOME="/home/$DOCKER_USER"
|
||||
|
||||
RUN cd ~ \
|
||||
&& git clone https://github.com/Sipeed/MaixCDK \
|
||||
&& cd MaixCDK \
|
||||
@@ -46,7 +61,7 @@ RUN cd ~ \
|
||||
&& . ./bin/activate \
|
||||
&& pip install -U -r requirements.txt
|
||||
|
||||
COPY --chown=$DOCKER_USER . /home/$DOCKER_USER/NanoKVM
|
||||
COPY --chown=$DOCKER_UID:$DOCKER_GID . /home/$DOCKER_USER/NanoKVM
|
||||
|
||||
RUN cd ~/MaixCDK \
|
||||
&& . ./bin/activate \
|
||||
@@ -57,7 +72,7 @@ FROM base_apt
|
||||
|
||||
# Install golang
|
||||
COPY --from=golang /usr/local/go /usr/local/go
|
||||
COPY --chown=$DOCKER_USER --from=golang /root/go /home/$DOCKER_USER/go
|
||||
COPY --chown=$DOCKER_UID:$DOCKER_GID --from=golang /root/go /home/$DOCKER_USER/go
|
||||
ENV PATH="$PATH:/usr/local/go/bin"
|
||||
|
||||
# Install host tools
|
||||
@@ -65,7 +80,7 @@ COPY --from=host_tools /usr/local/host-tools /usr/local/host-tools
|
||||
ENV PATH="$PATH:/usr/local/host-tools/gcc/riscv64-linux-musl-x86_64/bin"
|
||||
|
||||
# Install SDK
|
||||
COPY --from=sdk /home/$DOCKER_USER/MaixCDK /home/$DOCKER_USER/MaixCDK
|
||||
COPY --chown=$DOCKER_UID:$DOCKER_GID --from=sdk /home/$DOCKER_USER/MaixCDK /home/$DOCKER_USER/MaixCDK
|
||||
COPY --chmod=+x docker/entrypoint /entrypoint
|
||||
|
||||
RUN echo "Verify build tools" \
|
||||
@@ -76,4 +91,4 @@ USER root
|
||||
RUN chmod 0755 /entrypoint \
|
||||
&& sed "s/\$DOCKER_USER/$DOCKER_USER/g" -i /entrypoint
|
||||
|
||||
ENTRYPOINT ["/entrypoint"]
|
||||
ENTRYPOINT ["/entrypoint"]
|
||||
|
||||
10
docker/entrypoint
Normal file → Executable file
10
docker/entrypoint
Normal file → Executable file
@@ -12,11 +12,9 @@ fi
|
||||
|
||||
if [ "$UID" != 0 ]
|
||||
then
|
||||
usermod -u "$UID" "$DOCKER_USER" 2>/dev/null && {
|
||||
groupmod -g "$GID" "$DOCKER_USER" 2>/dev/null ||
|
||||
usermod -a -G "$GID" "$DOCKER_USER"
|
||||
}
|
||||
set -- gosu "${UID}:${GID}" "${@}"
|
||||
set -- gosu "${UID}:${GID}" env HOME="/home/$DOCKER_USER" "${@}"
|
||||
else
|
||||
export HOME=/root
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
exec "$@"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/bin/sh
|
||||
# kvmhwd Rev2.1
|
||||
# kvmhwd Rev2.2
|
||||
|
||||
start_usb_dev(){
|
||||
. /etc/profile
|
||||
@@ -23,6 +23,11 @@ start_usb_dev(){
|
||||
else
|
||||
echo 0x1009 > idProduct
|
||||
fi
|
||||
|
||||
echo 0xEF > bDeviceClass
|
||||
echo 0x02 > bDeviceSubClass
|
||||
echo 0x01 > bDeviceProtocol
|
||||
|
||||
mkdir strings/0x409
|
||||
echo '0123456789ABCDEF' > strings/0x409/serialnumber
|
||||
echo 'sipeed' > strings/0x409/manufacturer
|
||||
@@ -34,18 +39,48 @@ start_usb_dev(){
|
||||
mkdir configs/c.1/strings/0x409
|
||||
echo "NanoKVM" > configs/c.1/strings/0x409/configuration
|
||||
|
||||
# Deterministic gadget MACs from the chip UID so the host-side RNDIS/NCM
|
||||
# interface keeps a STABLE MAC across re-enumeration/reboot. Without this
|
||||
# the kernel assigns a random dev_addr/host_addr on every bind, so the
|
||||
# attached PC sees a brand-new network adapter (and MAC) each time.
|
||||
usb_uid=$(sha512sum /sys/class/cvi-base/base_uid | head -c 4)
|
||||
if [ -n "$usb_uid" ]
|
||||
then
|
||||
usb_dev_mac="48:da:35:6e:${usb_uid%??}:${usb_uid#??}"
|
||||
usb_host_mac="48:da:35:6d:${usb_uid%??}:${usb_uid#??}"
|
||||
fi
|
||||
|
||||
if [ -e /boot/usb.ncm ]
|
||||
then
|
||||
mkdir functions/ncm.usb0
|
||||
[ -n "$usb_dev_mac" ] && echo "$usb_dev_mac" > functions/ncm.usb0/dev_addr
|
||||
[ -n "$usb_host_mac" ] && echo "$usb_host_mac" > functions/ncm.usb0/host_addr
|
||||
echo WINNCM > functions/ncm.usb0/os_desc/interface.ncm/compatible_id
|
||||
ln -s functions/ncm.usb0 configs/c.1/
|
||||
else
|
||||
if [ -e /boot/usb.rndis0 ]
|
||||
then
|
||||
mkdir functions/rndis.usb0
|
||||
[ -n "$usb_dev_mac" ] && echo "$usb_dev_mac" > functions/rndis.usb0/dev_addr
|
||||
[ -n "$usb_host_mac" ] && echo "$usb_host_mac" > functions/rndis.usb0/host_addr
|
||||
echo e0 > functions/rndis.usb0/class
|
||||
echo 01 > functions/rndis.usb0/subclass
|
||||
echo 03 > functions/rndis.usb0/protocol
|
||||
echo RNDIS > functions/rndis.usb0/os_desc/interface.rndis/compatible_id
|
||||
echo 5162001 > functions/rndis.usb0/os_desc/interface.rndis/sub_compatible_id
|
||||
ln -s functions/rndis.usb0 configs/c.1/
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -e /boot/usb.ncm ] || [ -e /boot/usb.rndis0 ]
|
||||
then
|
||||
# Microsoft OS 2.0 Descriptor
|
||||
echo 1 > os_desc/use
|
||||
echo 0xCD > os_desc/b_vendor_code
|
||||
echo MSFT100 > os_desc/qw_sign
|
||||
ln -s configs/c.1 os_desc
|
||||
fi
|
||||
|
||||
if [ ! -e /boot/disable_hid ]
|
||||
then
|
||||
# keyboard
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/bin/sh
|
||||
# kvmhwd Rev2.4
|
||||
# kvmhwd Rev2.5
|
||||
|
||||
Alpha_OLED_RST_Pin=371
|
||||
Beta_OLED_RST_Pin=502
|
||||
@@ -25,7 +25,7 @@ init_alpha_hw(){
|
||||
devmem 0x03001060 32 0x3 # GPIOA24
|
||||
devmem 0x03001054 32 0x3 # GPIOA25
|
||||
devmem 0x03001058 32 0x3 # GPIOA27
|
||||
|
||||
|
||||
devmem 0x03001068 32 0x6 # GPIOA 18 UART1 RX
|
||||
devmem 0x03001064 32 0x6 # GPIOA 19 UART1 TX
|
||||
devmem 0x03001070 32 0x2 # GPIOA 28 UART2 TX
|
||||
@@ -146,19 +146,7 @@ kvm_hw_detect(){
|
||||
insmod /mnt/system/ko/i2c-algo-bit.ko
|
||||
insmod /mnt/system/ko/i2c-gpio.ko
|
||||
|
||||
kvm_tmp=$(i2cdetect -ry 4 0x2c 0x2c | grep 2c)
|
||||
if [ -n "$kvm_tmp" ]
|
||||
then
|
||||
echo "c" > /etc/kvm/hdmi_version
|
||||
else
|
||||
kvm_tmp1=$(i2cdetect -ry 4 0x2b 0x2b | grep 2b)
|
||||
if [ -n "$kvm_tmp1" ]
|
||||
then
|
||||
echo "ux" > /etc/kvm/hdmi_version
|
||||
else
|
||||
echo "ue" > /etc/kvm/hdmi_version
|
||||
fi
|
||||
fi
|
||||
kvm_hdmi_version_detect
|
||||
|
||||
kvm_tmp=$(i2cdetect -ry 5 0x3c 0x3c | grep 3c)
|
||||
if [ -n "$kvm_tmp" ]
|
||||
@@ -199,20 +187,19 @@ kvm_hw_init(){
|
||||
fi
|
||||
}
|
||||
|
||||
kvm_hdmi_version_detect(){
|
||||
hdmi_tmp=$(i2cdetect -ry 4 0x2c 0x2c | grep 2c)
|
||||
if [ -n "$hdmi_tmp" ]
|
||||
then
|
||||
echo "c" > /etc/kvm/hdmi_version
|
||||
else
|
||||
hdmi_tmp1=$(i2cdetect -ry 4 0x2b 0x2b | grep 2b)
|
||||
if [ -n "$hdmi_tmp1" ]
|
||||
then
|
||||
echo "ux" > /etc/kvm/hdmi_version
|
||||
kvm_hdmi_version_detect()
|
||||
{
|
||||
if i2cdetect -ry 4 0x2c 0x2c | grep -qw "2c"; then
|
||||
echo "c" > /etc/kvm/hdmi_version
|
||||
elif i2cdetect -ry 4 0x2b 0x2b | grep -qw "2b"; then
|
||||
if i2cdetect -ry 4 0x44 0x44 | grep -qw "44"; then
|
||||
echo "ux" > /etc/kvm/hdmi_version
|
||||
else
|
||||
echo "d" > /etc/kvm/hdmi_version
|
||||
fi
|
||||
else
|
||||
echo "ue" > /etc/kvm/hdmi_version
|
||||
echo "ue" > /etc/kvm/hdmi_version
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
|
||||
@@ -21,12 +21,7 @@ resolve_user_home() {
|
||||
printf '%s\n' "/root"
|
||||
}
|
||||
|
||||
setup_runtime_env() {
|
||||
[ -x "$BIN_PATH" ] || {
|
||||
echo "picoclaw binary not found: $BIN_PATH"
|
||||
return 1
|
||||
}
|
||||
|
||||
setup_picoclaw_home() {
|
||||
USER_HOME="$(resolve_user_home)"
|
||||
PICOCLAW_HOME="${PICOCLAW_HOME:-$USER_HOME/.picoclaw}"
|
||||
HOME="$USER_HOME"
|
||||
@@ -37,6 +32,8 @@ setup_runtime_env() {
|
||||
echo "failed to create picoclaw home: $PICOCLAW_HOME"
|
||||
return 1
|
||||
}
|
||||
PICOCLAW_PID_FILE="${PICOCLAW_PID_FILE:-$PICOCLAW_HOME/.picoclaw.pid}"
|
||||
export PICOCLAW_PID_FILE
|
||||
|
||||
cd "$PICOCLAW_HOME" || {
|
||||
echo "failed to enter picoclaw home: $PICOCLAW_HOME"
|
||||
@@ -44,6 +41,75 @@ setup_runtime_env() {
|
||||
}
|
||||
}
|
||||
|
||||
setup_runtime_env() {
|
||||
[ -x "$BIN_PATH" ] || {
|
||||
echo "picoclaw binary not found: $BIN_PATH"
|
||||
return 1
|
||||
}
|
||||
|
||||
setup_picoclaw_home || return 1
|
||||
}
|
||||
|
||||
read_pid_file() {
|
||||
[ -f "$PICOCLAW_PID_FILE" ] || return 1
|
||||
pid="$(sed -n '1p' "$PICOCLAW_PID_FILE" 2>/dev/null | tr -d '[:space:]')"
|
||||
case "$pid" in
|
||||
''|*[!0-9]*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
printf '%s\n' "$pid"
|
||||
}
|
||||
|
||||
is_gateway_pid() {
|
||||
pid="$1"
|
||||
[ -n "$pid" ] || return 1
|
||||
[ -r "/proc/$pid/cmdline" ] || return 1
|
||||
|
||||
cmd_path="$(tr '\000' '\n' <"/proc/$pid/cmdline" 2>/dev/null | sed -n '1p')"
|
||||
subcommand="$(tr '\000' '\n' <"/proc/$pid/cmdline" 2>/dev/null | sed -n '2p')"
|
||||
[ "$(basename "$cmd_path")" = "$BIN_NAME" ] && [ "$subcommand" = "gateway" ] && return 0
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
find_gateway_pid() {
|
||||
for proc_dir in /proc/[0-9]*; do
|
||||
[ -d "$proc_dir" ] || continue
|
||||
pid="${proc_dir#/proc/}"
|
||||
if is_gateway_pid "$pid"; then
|
||||
printf '%s\n' "$pid"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
cleanup_stale_pid_file() {
|
||||
[ -f "$PICOCLAW_PID_FILE" ] || return 0
|
||||
pid="$(read_pid_file 2>/dev/null)" || {
|
||||
rm -f "$PICOCLAW_PID_FILE"
|
||||
return 0
|
||||
}
|
||||
if ! is_gateway_pid "$pid"; then
|
||||
echo "picoclaw stale pid file removed: $PICOCLAW_PID_FILE"
|
||||
rm -f "$PICOCLAW_PID_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
gateway_running_pid() {
|
||||
pid="$(read_pid_file 2>/dev/null)" && is_gateway_pid "$pid" && {
|
||||
printf '%s\n' "$pid"
|
||||
return 0
|
||||
}
|
||||
|
||||
cleanup_stale_pid_file
|
||||
pid="$(find_gateway_pid 2>/dev/null)" || return 1
|
||||
printf '%s\n' "$pid" >"$PICOCLAW_PID_FILE" 2>/dev/null || true
|
||||
printf '%s\n' "$pid"
|
||||
}
|
||||
|
||||
resolve_kvm_control_source() {
|
||||
for candidate in \
|
||||
"/kvmapp/picoclaw/skills/kvm-control" \
|
||||
@@ -107,8 +173,8 @@ sync_agent_profile() {
|
||||
start_service() {
|
||||
setup_runtime_env || return 1
|
||||
|
||||
if pidof "$BIN_NAME" >/dev/null 2>&1; then
|
||||
echo "picoclaw already running"
|
||||
if pid="$(gateway_running_pid)"; then
|
||||
echo "picoclaw gateway already running (PID: $pid)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -118,6 +184,12 @@ start_service() {
|
||||
fi
|
||||
|
||||
"$BIN_PATH" gateway >>"$LOG_FILE" 2>&1 &
|
||||
gateway_pid="$!"
|
||||
printf '%s\n' "$gateway_pid" >"$PICOCLAW_PID_FILE" || {
|
||||
echo "failed to write picoclaw pid file: $PICOCLAW_PID_FILE"
|
||||
return 1
|
||||
}
|
||||
echo "picoclaw gateway started (PID: $gateway_pid)"
|
||||
}
|
||||
|
||||
onboard_service() {
|
||||
@@ -128,7 +200,26 @@ onboard_service() {
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
killall "$BIN_NAME" 2>/dev/null || true
|
||||
setup_picoclaw_home || return 1
|
||||
|
||||
pid="$(gateway_running_pid 2>/dev/null)" || {
|
||||
cleanup_stale_pid_file
|
||||
return 0
|
||||
}
|
||||
|
||||
kill "$pid" 2>/dev/null || true
|
||||
count=0
|
||||
while is_gateway_pid "$pid"; do
|
||||
count=$((count + 1))
|
||||
if [ "$count" -ge 15 ]; then
|
||||
echo "picoclaw gateway did not stop after TERM, killing PID: $pid"
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
rm -f "$PICOCLAW_PID_FILE"
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
|
||||
98
scripts/README.md
Normal file
98
scripts/README.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Release packaging
|
||||
|
||||
These scripts assemble the application update package that NanoKVM devices pull
|
||||
over the air: `nanokvm_<version>.tar.gz` plus its `latest.json` manifest.
|
||||
|
||||
| Script | Purpose |
|
||||
|---|---|
|
||||
| `build-in-container.sh` | Builds every riscv64 artifact (`kvm_system`, `libkvm.so`, `NanoKVM-Server`). Runs inside the `nanokvm-builder` image only. |
|
||||
| `package.sh` | Stages the package tree, creates the tarball, and writes `latest.json`. |
|
||||
| `compare-release.sh` | Diffs a freshly built package against the currently published one. Informational. |
|
||||
| `verify-release-assets.sh` | Checks the three GitHub Release assets before publishing or promotion. |
|
||||
| `verify-release-tag.sh` | Requires an annotated numeric tag whose commit is on `main`. |
|
||||
|
||||
## What the updater expects
|
||||
|
||||
The format is not arbitrary — it is fixed by the on-device updater in
|
||||
`server/service/application/`:
|
||||
|
||||
- **One root directory.** `install.go` untars the package and moves the single
|
||||
top-level directory over `/kvmapp`, so the tarball must contain exactly
|
||||
`nanokvm_<version>/`.
|
||||
- **`name` is the file name.** `version.go` builds the download URL as
|
||||
`<base>/<name>`, where `<base>` is `https://cdn.sipeed.com/nanokvm` (or
|
||||
`.../preview` when `/etc/kvm/preview_updates` exists).
|
||||
- **`sha512` is base64, not hex.** `update.go` compares against
|
||||
`base64(raw sha512 digest)`. A hex digest will fail verification on device.
|
||||
- **`/kvmapp/version`** is what the device reports as its installed version, so
|
||||
it must match the `version` field.
|
||||
|
||||
`size` is parsed into `Latest.Size` but never read anywhere, so nothing on the
|
||||
device depends on its units. Published manifests have carried a kilobyte-ish
|
||||
value; `package.sh` writes the exact byte count instead.
|
||||
|
||||
## Building a release
|
||||
|
||||
The frontend needs Node and pnpm on the host; everything riscv64 needs the
|
||||
builder image (see `docker/Dockerfile`).
|
||||
|
||||
```bash
|
||||
make release VERSION=2.4.4
|
||||
```
|
||||
|
||||
That is equivalent to:
|
||||
|
||||
```bash
|
||||
make release-build # kvm_system + libkvm.so + NanoKVM-Server, in Docker
|
||||
make web # web/dist
|
||||
make package VERSION=2.4.4 # build/release/{nanokvm_2.4.4.tar.gz,latest.json}
|
||||
```
|
||||
|
||||
In CI this runs as the **NanoKVM Package** workflow. Pull requests and manual
|
||||
runs only create Actions artifacts. For a public release, run **NanoKVM Create
|
||||
Tag**, then run **NanoKVM Release** and choose prerelease, stable, or promotion.
|
||||
Publishing attaches three assets:
|
||||
|
||||
Publishing is one-shot: an existing Release or draft is an error, and only a
|
||||
published prerelease can be promoted.
|
||||
|
||||
- `nanokvm_<version>.tar.gz`
|
||||
- `latest.json`
|
||||
- `sha256.txt`
|
||||
|
||||
The Actions artifact also includes `BUILD_INFO.txt` with the source commit,
|
||||
workflow run, immutable builder image digest, and both checksum encodings; that
|
||||
provenance file is not attached to the GitHub release. Uploading the tarball and
|
||||
`latest.json` to the CDN is what actually offers the update to devices in the
|
||||
field, so that step stays manual. `sha256.txt` is only for users verifying a
|
||||
manual download and does not change the on-device `latest.json` contract.
|
||||
|
||||
## Where each file in the package comes from
|
||||
|
||||
| Package path | Source |
|
||||
|---|---|
|
||||
| `version` | the requested version |
|
||||
| `server/NanoKVM-Server` | `server/build.sh` (BoringCrypto, RPATH `$ORIGIN/dl_lib`) |
|
||||
| `server/dl_lib/` | whatever the `kvm_vision` build emits, with any library it does not emit backfilled from the tracked `server/dl_lib/` |
|
||||
| `server/web/` | `web/dist/` |
|
||||
| `kvm_system/kvm_system` | `support/sg2002` `build kvm_system` |
|
||||
| `system/tool/` | prebuilt binaries in `tools/nanokvm_update_edid/` |
|
||||
| `kvm/` | default runtime state (resolution, fps, quality) |
|
||||
| everything else | tracked `kvmapp/` |
|
||||
|
||||
Note that `kvm/` ships default runtime state, and `install.go` replaces
|
||||
`/kvmapp` wholesale, so an update resets those values on the device. That is
|
||||
long-standing behaviour, not something these scripts introduce.
|
||||
|
||||
## The two copies of libkvm.so
|
||||
|
||||
`NanoKVM-Server` is cgo-linked against the **tracked** `server/dl_lib/libkvm.so`
|
||||
(`server/common/kvm_vision.go`: `-L../dl_lib -lkvm`), but the package ships the
|
||||
library the `kvm_vision` build just produced. Those are two different files, and
|
||||
the tracked one can lag well behind — published releases have shipped this
|
||||
mismatch for a long time.
|
||||
|
||||
Because a symbol the binary imports could in principle be absent from the
|
||||
shipped library, and that would only surface as a crash on a real device,
|
||||
`build-in-container.sh` checks the shipped `libkvm.so` still exports every
|
||||
symbol `NanoKVM-Server` actually imports from it, and fails the build otherwise.
|
||||
144
scripts/build-in-container.sh
Executable file
144
scripts/build-in-container.sh
Executable file
@@ -0,0 +1,144 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Build every riscv64 artifact of a release, from inside the nanokvm-builder
|
||||
# image (docker/Dockerfile). Not meant to be run on the host: it needs the
|
||||
# riscv64 musl toolchain and the patched MaixCDK checkout that the image ships.
|
||||
#
|
||||
# Produces:
|
||||
# kvmapp/kvm_system/kvm_system kvm_system daemon
|
||||
# kvmapp/server/dl_lib/ video libraries, including a fresh libkvm.so
|
||||
# server/NanoKVM-Server Go server (BoringCrypto, RPATH $ORIGIN/dl_lib)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# docker/entrypoint sets HOME for the build user. support/sg2002/build resolves
|
||||
# both MaixCDK and the source tree through ~, so a wrong HOME would send the
|
||||
# whole build at /root; say so plainly rather than emitting "directory missing".
|
||||
BUILD_HOME="${BUILD_HOME:-/home/build}"
|
||||
if [ "${HOME:-}" != "$BUILD_HOME" ]; then
|
||||
echo "[ERROR] HOME is '${HOME:-unset}', expected $BUILD_HOME" >&2
|
||||
echo " run this inside the builder image via the Makefile" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MAIXCDK_PATH="$HOME/MaixCDK"
|
||||
NANOKVM_PATH="$HOME/NanoKVM"
|
||||
|
||||
for dir in "$MAIXCDK_PATH" "$NANOKVM_PATH"; do
|
||||
if [ ! -d "$dir" ]; then
|
||||
echo "[ERROR] $dir not found - is this running inside nanokvm-builder?" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Check the toolchain up front. builder-image reuses any existing image, so an
|
||||
# image built before a tooling change silently lacks it - and without this the
|
||||
# SDK build would run for minutes before server/build.sh failed at the end.
|
||||
for tool in go riscv64-unknown-linux-musl-gcc patchelf; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
echo "[ERROR] '$tool' is missing from the builder image" >&2
|
||||
echo " the image is out of date; rebuild it with: make rebuild-image" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# The SDK tree is owned by the ids baked into the image (DOCKER_UID/DOCKER_GID),
|
||||
# while the entrypoint drops to the caller's ids. If they disagree, syncing
|
||||
# components fails partway through a long build; catch it here instead.
|
||||
if [ ! -w "$MAIXCDK_PATH/components" ]; then
|
||||
echo "[ERROR] $MAIXCDK_PATH/components is not writable by $(id -u):$(id -g)" >&2
|
||||
echo " the image was built for different ids; rebuild it with:" >&2
|
||||
echo " make rebuild-image" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
. "$MAIXCDK_PATH/bin/activate"
|
||||
|
||||
echo "::group::support: sync MaixCDK components"
|
||||
cd "$NANOKVM_PATH/support/sg2002"
|
||||
# The image bakes support/sg2002/additional/ into MaixCDK's components at image
|
||||
# build time, and ./build only re-copies them when components/kvm is missing.
|
||||
# Refresh unconditionally so a cached image never yields stale libraries.
|
||||
./build update_lib
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::support: build kvm_system"
|
||||
./build kvm_system
|
||||
./build kvm_system add_to_kvmapp
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::support: build kvm_vision (libkvm.so)"
|
||||
./build kvm_vision
|
||||
./build kvm_vision add_to_kvmapp
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::server: cross-compile NanoKVM-Server"
|
||||
cd "$NANOKVM_PATH/server"
|
||||
./build.sh
|
||||
echo "::endgroup::"
|
||||
|
||||
# support/sg2002/build reports "Build Error!" without a non-zero exit status for
|
||||
# some failure paths, so assert on the artifacts rather than trusting $?.
|
||||
echo "[INFO] verifying artifacts"
|
||||
missing=0
|
||||
for artifact in \
|
||||
"$NANOKVM_PATH/kvmapp/kvm_system/kvm_system" \
|
||||
"$NANOKVM_PATH/kvmapp/server/dl_lib/libkvm.so" \
|
||||
"$NANOKVM_PATH/server/NanoKVM-Server"
|
||||
do
|
||||
if [ -f "$artifact" ]; then
|
||||
echo " ok ${artifact#$NANOKVM_PATH/}"
|
||||
else
|
||||
echo " MISSING ${artifact#$NANOKVM_PATH/}" >&2
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$missing" -ne 0 ]; then
|
||||
echo "[ERROR] build did not produce all expected artifacts" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# NanoKVM-Server is cgo-linked against the tracked server/dl_lib/libkvm.so
|
||||
# (server/common/kvm_vision.go: -L../dl_lib -lkvm), but the package ships the
|
||||
# freshly built one from kvm_vision. Those are different files, so confirm the
|
||||
# shipped library still exports every symbol the binary actually imports from
|
||||
# it. Without this a dropped symbol only shows up as a crash on a real device.
|
||||
echo "[INFO] verifying shipped libkvm.so satisfies NanoKVM-Server"
|
||||
if ! command -v readelf >/dev/null 2>&1; then
|
||||
echo "[WARN] readelf unavailable, skipping ABI check"
|
||||
else
|
||||
exported() {
|
||||
readelf --dyn-syms --wide "$1" 2>/dev/null \
|
||||
| awk '$7 != "UND" && $8 != "" { print $8 }' \
|
||||
| sed 's/@.*//' | LC_ALL=C sort -u
|
||||
}
|
||||
imported() {
|
||||
readelf --dyn-syms --wide "$1" 2>/dev/null \
|
||||
| awk '$7 == "UND" && $8 != "" { print $8 }' \
|
||||
| sed 's/@.*//' | LC_ALL=C sort -u
|
||||
}
|
||||
|
||||
linked_lib="$NANOKVM_PATH/server/dl_lib/libkvm.so"
|
||||
shipped_lib="$NANOKVM_PATH/kvmapp/server/dl_lib/libkvm.so"
|
||||
server_bin="$NANOKVM_PATH/server/NanoKVM-Server"
|
||||
|
||||
# Symbols the binary imports that the link-time library provides, i.e. the
|
||||
# ones libkvm.so is actually responsible for at runtime.
|
||||
needed=$(comm -12 <(imported "$server_bin") <(exported "$linked_lib"))
|
||||
|
||||
if [ -z "$needed" ]; then
|
||||
echo "[WARN] no libkvm symbols resolved; skipping ABI check"
|
||||
else
|
||||
absent=$(comm -23 <(printf '%s\n' "$needed") <(exported "$shipped_lib"))
|
||||
if [ -n "$absent" ]; then
|
||||
echo "[ERROR] shipped libkvm.so is missing symbols NanoKVM-Server needs:" >&2
|
||||
printf ' %s\n' $absent >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " ok $(printf '%s\n' "$needed" | wc -l | tr -d ' ') libkvm symbols resolve"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[DONE] riscv64 artifacts built"
|
||||
103
scripts/compare-release.sh
Executable file
103
scripts/compare-release.sh
Executable file
@@ -0,0 +1,103 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Compare a freshly built package against the currently published release.
|
||||
#
|
||||
# Purely informational: it exits 0 whatever it finds. The point is to make the
|
||||
# delta reviewable before the tarball is published, since the package is what
|
||||
# every device pulls over OTA. Unexpected entries here (a library that vanished,
|
||||
# an init script that changed without a matching commit) are worth a look.
|
||||
#
|
||||
# Usage: scripts/compare-release.sh <new-tarball> [base-url]
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
NEW_TARBALL="${1:-}"
|
||||
BASE_URL="${2:-https://cdn.sipeed.com/nanokvm}"
|
||||
|
||||
if [ -z "$NEW_TARBALL" ] || [ ! -f "$NEW_TARBALL" ]; then
|
||||
echo "Usage: $0 <new-tarball> [base-url]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
echo "[INFO] fetching published manifest from $BASE_URL/latest.json"
|
||||
if ! curl -fsSL "$BASE_URL/latest.json" -o "$WORK/latest.json"; then
|
||||
echo "[WARN] could not fetch latest.json - skipping comparison"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cat "$WORK/latest.json"
|
||||
|
||||
OLD_NAME="$(sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$WORK/latest.json")"
|
||||
if [ -z "$OLD_NAME" ]; then
|
||||
echo "[WARN] no \"name\" in latest.json - skipping comparison"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[INFO] downloading published package $OLD_NAME"
|
||||
if ! curl -fsSL "$BASE_URL/$OLD_NAME" -o "$WORK/$OLD_NAME"; then
|
||||
echo "[WARN] could not download $OLD_NAME - skipping comparison"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$WORK/old" "$WORK/new"
|
||||
if ! tar xzf "$WORK/$OLD_NAME" -C "$WORK/old"; then
|
||||
echo "[WARN] could not extract $OLD_NAME - skipping comparison"
|
||||
exit 0
|
||||
fi
|
||||
if ! tar xzf "$NEW_TARBALL" -C "$WORK/new"; then
|
||||
echo "[WARN] could not extract $(basename "$NEW_TARBALL") - skipping comparison"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Strip the nanokvm_<version>/ prefix so the two trees are comparable.
|
||||
OLD_ROOT="$(find "$WORK/old" -mindepth 1 -maxdepth 1 -type d | head -1)"
|
||||
NEW_ROOT="$(find "$WORK/new" -mindepth 1 -maxdepth 1 -type d | head -1)"
|
||||
|
||||
if [ -z "$OLD_ROOT" ] || [ -z "$NEW_ROOT" ]; then
|
||||
echo "[WARN] could not locate a package root directory - skipping comparison"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
( cd "$OLD_ROOT" && find . -type f | sed 's|^\./||' | LC_ALL=C sort ) > "$WORK/old.list"
|
||||
( cd "$NEW_ROOT" && find . -type f | sed 's|^\./||' | LC_ALL=C sort ) > "$WORK/new.list"
|
||||
|
||||
# An empty inventory would make every section below print "(none)", which reads
|
||||
# identically to "verified, nothing changed". Refuse to imply that.
|
||||
if [ ! -s "$WORK/old.list" ] || [ ! -s "$WORK/new.list" ]; then
|
||||
echo "[WARN] one of the packages produced an empty file list - comparison unreliable, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=============================================================="
|
||||
echo " $(basename "$OLD_ROOT") -> $(basename "$NEW_ROOT")"
|
||||
echo "=============================================================="
|
||||
printf ' files: %s -> %s\n' "$(wc -l < "$WORK/old.list" | tr -d ' ')" \
|
||||
"$(wc -l < "$WORK/new.list" | tr -d ' ')"
|
||||
|
||||
echo
|
||||
echo "--- added ----------------------------------------------------"
|
||||
comm -13 "$WORK/old.list" "$WORK/new.list" | sed 's/^/ + /' || true
|
||||
|
||||
echo
|
||||
echo "--- removed --------------------------------------------------"
|
||||
comm -23 "$WORK/old.list" "$WORK/new.list" | sed 's/^/ - /' || true
|
||||
|
||||
echo
|
||||
echo "--- changed --------------------------------------------------"
|
||||
changed=0
|
||||
while IFS= read -r file; do
|
||||
if ! cmp -s "$OLD_ROOT/$file" "$NEW_ROOT/$file"; then
|
||||
old_size="$(wc -c < "$OLD_ROOT/$file" | tr -d ' ')"
|
||||
new_size="$(wc -c < "$NEW_ROOT/$file" | tr -d ' ')"
|
||||
printf ' ~ %-52s %s -> %s bytes\n' "$file" "$old_size" "$new_size"
|
||||
changed=$((changed + 1))
|
||||
fi
|
||||
done < <(comm -12 "$WORK/old.list" "$WORK/new.list")
|
||||
[ "$changed" -eq 0 ] && echo " (none)"
|
||||
|
||||
echo
|
||||
echo "[INFO] comparison complete"
|
||||
216
scripts/package.sh
Executable file
216
scripts/package.sh
Executable file
@@ -0,0 +1,216 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Assemble a NanoKVM release package: nanokvm_<version>.tar.gz + latest.json.
|
||||
#
|
||||
# The layout and the manifest fields are dictated by the on-device updater:
|
||||
# - server/service/application/version.go parses latest.json and derives the
|
||||
# download URL as "<base>/<name>", so "name" must be the tarball file name.
|
||||
# - server/service/application/update.go verifies the download against
|
||||
# "sha512", which is the *base64* encoding of the raw SHA-512 digest.
|
||||
# - server/service/application/install.go untars the package and moves the
|
||||
# single top-level directory over /kvmapp, so the tarball must contain
|
||||
# exactly one root directory: nanokvm_<version>/.
|
||||
#
|
||||
# Build artifacts are expected to be in place already (see
|
||||
# scripts/build-in-container.sh and the "web" target in the Makefile):
|
||||
# server/NanoKVM-Server riscv64 server binary
|
||||
# kvmapp/kvm_system/kvm_system kvm_system daemon
|
||||
# kvmapp/server/dl_lib/libkvm.so freshly built video library (optional)
|
||||
# web/dist/ built frontend
|
||||
#
|
||||
# Usage: scripts/package.sh <version>
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
VERSION="${1:-}"
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Usage: $0 <version> e.g. $0 2.4.4" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The updater rejects file names outside [a-zA-Z0-9._-] (update_offline.go),
|
||||
# and the version string ends up in the file name, so validate it up front.
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "[ERROR] invalid version '$VERSION', expected MAJOR.MINOR.PATCH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OUT="$ROOT/build/release"
|
||||
STAGE="$OUT/nanokvm_$VERSION"
|
||||
TARBALL="$OUT/nanokvm_$VERSION.tar.gz"
|
||||
MANIFEST="$OUT/latest.json"
|
||||
|
||||
require_file() {
|
||||
if [ ! -f "$1" ]; then
|
||||
echo "[ERROR] missing build artifact: ${1#"$ROOT"/}" >&2
|
||||
echo " $2" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# A host-architecture build is indistinguishable from a cross-compiled one by
|
||||
# name alone, and NanoKVM-Server is gitignored, so nothing else would catch it.
|
||||
# Refuse to package anything that is not riscv64 (ELF e_machine 243).
|
||||
require_riscv64() {
|
||||
local path="$1" magic machine
|
||||
magic=$(od -An -tx1 -N4 "$path" | tr -d ' \n')
|
||||
if [ "$magic" != "7f454c46" ]; then
|
||||
echo "[ERROR] not an ELF object: ${path#"$ROOT"/}" >&2
|
||||
exit 1
|
||||
fi
|
||||
machine=$(od -An -tu1 -j18 -N1 "$path" | tr -d ' \n')
|
||||
if [ "$machine" != "243" ]; then
|
||||
echo "[ERROR] ${path#"$ROOT"/} is not riscv64 (ELF e_machine=$machine)" >&2
|
||||
echo " a host-architecture build must never be packaged" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_file "$ROOT/server/NanoKVM-Server" "run: make app"
|
||||
require_file "$ROOT/kvmapp/kvm_system/kvm_system" "run: make support"
|
||||
require_file "$ROOT/web/dist/index.html" "run: make web"
|
||||
# libkvm.so is the one runtime library that must come from the build: the copy
|
||||
# tracked in server/dl_lib/ exists for cgo link time and lags far behind. If it
|
||||
# were optional here, "make package" on its own would quietly ship the stale one.
|
||||
require_file "$ROOT/kvmapp/server/dl_lib/libkvm.so" "run: make vision"
|
||||
|
||||
require_riscv64 "$ROOT/server/NanoKVM-Server"
|
||||
require_riscv64 "$ROOT/kvmapp/kvm_system/kvm_system"
|
||||
require_riscv64 "$ROOT/kvmapp/server/dl_lib/libkvm.so"
|
||||
|
||||
echo "[INFO] staging nanokvm_$VERSION"
|
||||
# Clear the whole output directory: release.yml uploads build/release/nanokvm_*
|
||||
# by glob, and a leftover tarball from an earlier version would ride along.
|
||||
rm -rf "$OUT"
|
||||
mkdir -p "$STAGE"
|
||||
|
||||
# 1. Everything tracked under kvmapp/ (init scripts, picoclaw bundle, kernel
|
||||
# module, jpg_stream, kvm_stream) plus whatever the support build dropped in
|
||||
# (kvm_system, server/dl_lib). -a carries the exec bits over from git, which
|
||||
# only matters for reading the archive by hand: install.go chmods the whole
|
||||
# tree to 0755 on the device regardless.
|
||||
cp -a "$ROOT/kvmapp/." "$STAGE/"
|
||||
|
||||
# 2. Version marker. The updater reads /kvmapp/version to report the currently
|
||||
# installed version (version.go).
|
||||
printf '%s\n' "$VERSION" > "$STAGE/version"
|
||||
|
||||
# 3. Go server binary.
|
||||
mkdir -p "$STAGE/server"
|
||||
cp -a "$ROOT/server/NanoKVM-Server" "$STAGE/server/NanoKVM-Server"
|
||||
|
||||
# 4. Runtime shared libraries. "build kvm_vision add_to_kvmapp" copies its whole
|
||||
# dist/dl_lib into kvmapp/server/dl_lib, so freshly built libraries are
|
||||
# already staged; the tracked server/dl_lib/ backfills the rest. Never
|
||||
# clobber a fresh library with the tracked (link-time) copy.
|
||||
mkdir -p "$STAGE/server/dl_lib"
|
||||
for lib in "$ROOT"/server/dl_lib/*; do
|
||||
[ -e "$lib" ] || continue
|
||||
name="$(basename "$lib")"
|
||||
if [ ! -e "$STAGE/server/dl_lib/$name" ]; then
|
||||
cp -a "$lib" "$STAGE/server/dl_lib/$name"
|
||||
fi
|
||||
done
|
||||
|
||||
# The SDK is built from an unpinned MaixCDK checkout, so its dist could one
|
||||
# day add or rename a library (e.g. an soname bump leaving both .409 and
|
||||
# .410 behind) and we would ship a library set nobody reviewed. Require the
|
||||
# shipped names to be exactly the tracked set, and fail loudly otherwise.
|
||||
staged_libs=$(cd "$STAGE/server/dl_lib" && ls -1 | LC_ALL=C sort)
|
||||
tracked_libs=$(cd "$ROOT/server/dl_lib" && ls -1 | LC_ALL=C sort)
|
||||
if [ "$staged_libs" != "$tracked_libs" ]; then
|
||||
echo "[ERROR] shipped server/dl_lib does not match the tracked library set" >&2
|
||||
echo " unexpected (in package, not tracked):" >&2
|
||||
comm -23 <(printf '%s\n' "$staged_libs") <(printf '%s\n' "$tracked_libs") \
|
||||
| sed 's/^/ + /' >&2
|
||||
echo " absent (tracked, not in package):" >&2
|
||||
comm -13 <(printf '%s\n' "$staged_libs") <(printf '%s\n' "$tracked_libs") \
|
||||
| sed 's/^/ - /' >&2
|
||||
echo " if this change is intended, update server/dl_lib/ to match." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A library arrives either from the SDK dist (mode 755) or from the tracked
|
||||
# backfill (git stores them 644), so without this the archive would depend on
|
||||
# which path populated each file. 644 is what published releases have always
|
||||
# carried, and install.go chmods the installed tree to 0755 anyway.
|
||||
chmod 644 "$STAGE"/server/dl_lib/*
|
||||
|
||||
# 5. Frontend. router.go serves <dir of executable>/web.
|
||||
rm -rf "$STAGE/server/web"
|
||||
mkdir -p "$STAGE/server/web"
|
||||
cp -a "$ROOT/web/dist/." "$STAGE/server/web/"
|
||||
|
||||
# 6. EDID helper shipped under system/tool/ (prebuilt riscv64 binary in tools/).
|
||||
mkdir -p "$STAGE/system/tool"
|
||||
cp -a "$ROOT/tools/nanokvm_update_edid/nanokvm_update_edid" "$STAGE/system/tool/"
|
||||
cp -a "$ROOT/tools/nanokvm_update_edid/E21_NanoKVM.bin" "$STAGE/system/tool/"
|
||||
|
||||
# 7. Default runtime state read by common.GetScreen() on first boot.
|
||||
mkdir -p "$STAGE/kvm"
|
||||
printf '30\n' > "$STAGE/kvm/fps"
|
||||
printf '0\n' > "$STAGE/kvm/now_fps"
|
||||
printf '60\n' > "$STAGE/kvm/qlty"
|
||||
printf '1920\n' > "$STAGE/kvm/width"
|
||||
printf '1080\n' > "$STAGE/kvm/height"
|
||||
printf '1\n' > "$STAGE/kvm/state"
|
||||
printf 'mjpeg\n' > "$STAGE/kvm/type"
|
||||
printf '0' > "$STAGE/kvm/res"
|
||||
|
||||
# 8. Directories the released package has always carried, kept so the layout
|
||||
# matches previous releases exactly (system_init.cpp probes inside them).
|
||||
mkdir -p "$STAGE/jpg_stream/dl_lib" "$STAGE/kvm_system/dl_lib"
|
||||
|
||||
# --- archive -----------------------------------------------------------------
|
||||
# Normalise owner and timestamps so the same source tree yields the same
|
||||
# tarball, which makes the published sha512 verifiable after the fact.
|
||||
SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git -C "$ROOT" log -1 --format=%ct 2>/dev/null || echo 0)}"
|
||||
|
||||
find "$STAGE" -exec touch -d "@$SOURCE_DATE_EPOCH" {} + 2>/dev/null \
|
||||
|| find "$STAGE" -exec touch -t "$(date -r "$SOURCE_DATE_EPOCH" +%Y%m%d%H%M.%S)" {} + 2>/dev/null \
|
||||
|| echo "[WARN] could not normalise timestamps"
|
||||
|
||||
echo "[INFO] creating $(basename "$TARBALL")"
|
||||
rm -f "$TARBALL"
|
||||
|
||||
# Read the version into a variable first: piping through head can hand tar a
|
||||
# SIGPIPE, and under "set -o pipefail" that would silently select the bsdtar
|
||||
# branch on a GNU system, quietly losing reproducibility.
|
||||
tar_version="$(tar --version 2>/dev/null || true)"
|
||||
|
||||
if [ "${tar_version#*GNU}" != "$tar_version" ]; then
|
||||
tar --format=gnu --sort=name \
|
||||
--owner=0 --group=0 --numeric-owner \
|
||||
--mtime="@$SOURCE_DATE_EPOCH" \
|
||||
-C "$OUT" -cf - "nanokvm_$VERSION" \
|
||||
| gzip -n -9 > "$TARBALL"
|
||||
else
|
||||
# bsdtar (macOS): no --sort/--mtime, so the archive is not byte-reproducible.
|
||||
echo "[WARN] GNU tar not found; archive will not be byte-reproducible"
|
||||
tar --uid 0 --gid 0 --uname '' --gname '' \
|
||||
-C "$OUT" -cf - "nanokvm_$VERSION" \
|
||||
| gzip -n -9 > "$TARBALL"
|
||||
fi
|
||||
|
||||
# --- manifest ----------------------------------------------------------------
|
||||
# update.go compares base64(raw sha512), not the hex digest.
|
||||
SHA512="$(openssl dgst -sha512 -binary "$TARBALL" | openssl base64 -A)"
|
||||
SIZE="$(wc -c < "$TARBALL" | tr -d ' ')"
|
||||
|
||||
cat > "$MANIFEST" <<EOF
|
||||
{
|
||||
"version": "$VERSION",
|
||||
"name": "nanokvm_$VERSION.tar.gz",
|
||||
"sha512": "$SHA512",
|
||||
"size": $SIZE
|
||||
}
|
||||
EOF
|
||||
|
||||
echo
|
||||
echo "[DONE] $TARBALL"
|
||||
echo "[DONE] $MANIFEST"
|
||||
cat "$MANIFEST"
|
||||
118
scripts/verify-release-assets.sh
Executable file
118
scripts/verify-release-assets.sh
Executable file
@@ -0,0 +1,118 @@
|
||||
#!/bin/bash
|
||||
# Verify the three assets that make up a NanoKVM GitHub release.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ASSET_DIR="${1:-}"
|
||||
VERSION="${2:-}"
|
||||
|
||||
if [ -z "$ASSET_DIR" ] || [ -z "$VERSION" ]; then
|
||||
echo "Usage: $0 <asset-directory> <version>" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "[ERROR] invalid version '$VERSION', expected MAJOR.MINOR.PATCH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARBALL_NAME="nanokvm_${VERSION}.tar.gz"
|
||||
TARBALL="$ASSET_DIR/$TARBALL_NAME"
|
||||
MANIFEST="$ASSET_DIR/latest.json"
|
||||
CHECKSUM="$ASSET_DIR/sha256.txt"
|
||||
|
||||
for path in "$TARBALL" "$MANIFEST" "$CHECKSUM"; do
|
||||
if [ ! -f "$path" ]; then
|
||||
echo "[ERROR] missing release asset: $path" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
PACKAGE_ROOT="nanokvm_${VERSION}"
|
||||
ENTRY_LIST=$(mktemp)
|
||||
VERBOSE_LIST=$(mktemp)
|
||||
trap 'rm -f "$ENTRY_LIST" "$VERBOSE_LIST"' EXIT
|
||||
|
||||
if ! tar -tzf "$TARBALL" > "$ENTRY_LIST"; then
|
||||
echo "[ERROR] could not list release tarball" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! tar -tvzf "$TARBALL" > "$VERBOSE_LIST"; then
|
||||
echo "[ERROR] could not inspect release tarball entry types" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ENTRY_COUNT=0
|
||||
while IFS= read -r entry; do
|
||||
ENTRY_COUNT=$((ENTRY_COUNT + 1))
|
||||
case "$entry" in
|
||||
"$PACKAGE_ROOT"|"$PACKAGE_ROOT"/*) ;;
|
||||
*)
|
||||
echo "[ERROR] archive entry is outside $PACKAGE_ROOT/: $entry" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
case "/$entry/" in
|
||||
*/../*|*/./*)
|
||||
echo "[ERROR] archive entry contains an unsafe path component: $entry" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done < "$ENTRY_LIST"
|
||||
if [ "$ENTRY_COUNT" -eq 0 ]; then
|
||||
echo "[ERROR] release tarball is empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while IFS= read -r verbose_entry; do
|
||||
entry_type=${verbose_entry:0:1}
|
||||
case "$entry_type" in
|
||||
-|d) ;;
|
||||
*)
|
||||
echo "[ERROR] archive contains a link or special entry: $verbose_entry" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done < "$VERBOSE_LIST"
|
||||
|
||||
ARCHIVE_VERSION=$(tar -xOzf "$TARBALL" "$PACKAGE_ROOT/version" 2>/dev/null) || {
|
||||
echo "[ERROR] release tarball does not contain $PACKAGE_ROOT/version" >&2
|
||||
exit 1
|
||||
}
|
||||
if [ "$ARCHIVE_VERSION" != "$VERSION" ]; then
|
||||
echo "[ERROR] archive version '$ARCHIVE_VERSION' does not match '$VERSION'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SHA256=$(sha256sum "$TARBALL" | cut -d' ' -f1)
|
||||
if ! printf '%s %s\n' "$SHA256" "$TARBALL_NAME" | cmp -s - "$CHECKSUM"; then
|
||||
echo "[ERROR] sha256.txt does not match $TARBALL_NAME" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MANIFEST_VERSION=$(jq -er '.version | select(type == "string" and length > 0)' "$MANIFEST")
|
||||
MANIFEST_NAME=$(jq -er '.name | select(type == "string" and length > 0)' "$MANIFEST")
|
||||
MANIFEST_SIZE=$(jq -er '.size | select(type == "number" and . >= 0 and floor == .)' "$MANIFEST")
|
||||
MANIFEST_SHA512=$(jq -er '.sha512 | select(type == "string" and length > 0)' "$MANIFEST")
|
||||
|
||||
if [ "$MANIFEST_VERSION" != "$VERSION" ]; then
|
||||
echo "[ERROR] latest.json version '$MANIFEST_VERSION' does not match '$VERSION'" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$MANIFEST_NAME" != "$TARBALL_NAME" ]; then
|
||||
echo "[ERROR] latest.json name '$MANIFEST_NAME' does not match '$TARBALL_NAME'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ACTUAL_SIZE=$(wc -c < "$TARBALL" | tr -d ' ')
|
||||
if [ "$MANIFEST_SIZE" != "$ACTUAL_SIZE" ]; then
|
||||
echo "[ERROR] latest.json size '$MANIFEST_SIZE' does not match '$ACTUAL_SIZE'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ACTUAL_SHA512=$(openssl dgst -sha512 -binary "$TARBALL" | openssl base64 -A)
|
||||
if [ "$MANIFEST_SHA512" != "$ACTUAL_SHA512" ]; then
|
||||
echo "[ERROR] latest.json sha512 does not match $TARBALL_NAME" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[OK] verified NanoKVM release assets for $VERSION"
|
||||
43
scripts/verify-release-tag.sh
Executable file
43
scripts/verify-release-tag.sh
Executable file
@@ -0,0 +1,43 @@
|
||||
#!/bin/bash
|
||||
# Resolve an annotated numeric tag from origin and verify its commit is on main.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
TAG="${1:-}"
|
||||
EXPECTED_SHA="${2:-}"
|
||||
|
||||
if ! echo "$TAG" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "[ERROR] invalid tag '$TAG', expected MAJOR.MINOR.PATCH" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$EXPECTED_SHA" ] && ! echo "$EXPECTED_SHA" | grep -qE '^[0-9a-f]{40}$'; then
|
||||
echo "[ERROR] expected commit must be a full lowercase commit SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG_REF="refs/tags/$TAG"
|
||||
PEELED_REF="${TAG_REF}^{}"
|
||||
REMOTE_REFS=$(git ls-remote --tags origin "$TAG_REF" "$PEELED_REF")
|
||||
TAG_OBJECT=$(printf '%s\n' "$REMOTE_REFS" | awk -v ref="$TAG_REF" '$2 == ref { print $1 }')
|
||||
SOURCE_SHA=$(printf '%s\n' "$REMOTE_REFS" | awk -v ref="$PEELED_REF" '$2 == ref { print $1 }')
|
||||
|
||||
if ! echo "$TAG_OBJECT" | grep -qE '^[0-9a-f]{40}$'; then
|
||||
echo "[ERROR] tag '$TAG' does not exist on origin" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! echo "$SOURCE_SHA" | grep -qE '^[0-9a-f]{40}$'; then
|
||||
echo "[ERROR] tag '$TAG' is not an annotated tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$EXPECTED_SHA" ] && [ "$SOURCE_SHA" != "$EXPECTED_SHA" ]; then
|
||||
echo "[ERROR] tag '$TAG' moved from '$EXPECTED_SHA' to '$SOURCE_SHA'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch --no-tags origin main
|
||||
if ! git merge-base --is-ancestor "$SOURCE_SHA" FETCH_HEAD; then
|
||||
echo "[ERROR] tag '$TAG' points to a commit outside origin/main" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$SOURCE_SHA"
|
||||
@@ -64,7 +64,7 @@ turn:
|
||||
|
||||
## Compile & Deploy
|
||||
|
||||
Note: Use Linux operating system (x86-64). This build process is not compatible with ARM, Windows or macOS.
|
||||
Note: Use Linux operating system (x86-64) with Go 1.25 or newer. This build process is not compatible with ARM, Windows or macOS.
|
||||
|
||||
1. Install the Toolchain
|
||||
1. Download the toolchain from the following link: [Download Link](https://sophon-file.sophon.cn/sophon-prod-s3/drive/23/03/07/16/host-tools.tar.gz).
|
||||
|
||||
@@ -65,7 +65,7 @@ turn:
|
||||
|
||||
## コンパイルとデプロイ
|
||||
|
||||
注意: Linux オペレーティングシステム (x86-64) を使用してください。このビルドプロセスは ARM、Windows、macOS では互換性がありません。
|
||||
注意: Linux オペレーティングシステム (x86-64) と Go 1.25 以降を使用してください。このビルドプロセスは ARM、Windows、macOS では互換性がありません。
|
||||
|
||||
1. ツールチェーンのインストール
|
||||
1. 以下のリンクからツールチェーンをダウンロードします: [ダウンロードリンク](https://sophon-file.sophon.cn/sophon-prod-s3/drive/23/03/07/16/host-tools.tar.gz)。
|
||||
|
||||
@@ -62,7 +62,7 @@ turn:
|
||||
|
||||
## 编译部署
|
||||
|
||||
**注意:请使用 Linux 操作系统(x86-64)。该工具链无法在 ARM、Windows 或 macOS 下使用。**
|
||||
**注意:请使用 Linux 操作系统(x86-64)和 Go 1.25 或更高版本。该工具链无法在 ARM、Windows 或 macOS 下使用。**
|
||||
|
||||
1. 安装工具链
|
||||
1. 下载工具链:[下载地址](https://sophon-file.sophon.cn/sophon-prod-s3/drive/23/03/07/16/host-tools.tar.gz);
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,16 +1,18 @@
|
||||
module NanoKVM-Server
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/gin-gonic/contrib v0.0.0-20240508051311-c1c6bf0061b0
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-playground/validator/v10 v10.20.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/jsonschema-go v0.4.3
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/mervick/aes-everywhere/go/aes256 v0.0.0-20240803013625-6759956693c0
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1
|
||||
github.com/pion/dtls/v3 v3.1.2
|
||||
github.com/pion/rtp v1.8.18
|
||||
github.com/pion/webrtc/v4 v4.0.1
|
||||
@@ -60,6 +62,8 @@ require (
|
||||
github.com/rs/cors v1.11.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/segmentio/asm v1.1.3 // indirect
|
||||
github.com/segmentio/encoding v0.5.4 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
@@ -68,12 +72,14 @@ require (
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
|
||||
@@ -34,11 +34,13 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
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/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -65,6 +67,8 @@ github.com/mervick/aes-everywhere/go/aes256 v0.0.0-20240803013625-6759956693c0 h
|
||||
github.com/mervick/aes-everywhere/go/aes256 v0.0.0-20240803013625-6759956693c0/go.mod h1:Eb5RMoo9kOQra/2uRiUTGP+LfNuM13Vqm7y7P34+KKo=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
|
||||
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -119,6 +123,10 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
@@ -154,6 +162,8 @@ github.com/unrolled/secure v1.15.0 h1:q7x+pdp8jAHnbzxu6UheP8fRlG/rwYTb8TPuQ3rn9O
|
||||
github.com/unrolled/secure v1.15.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||
@@ -167,13 +177,17 @@ golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjs
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"NanoKVM-Server/logger"
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/router"
|
||||
"NanoKVM-Server/service/vm"
|
||||
"NanoKVM-Server/service/vm/jiggler"
|
||||
"NanoKVM-Server/utils"
|
||||
|
||||
@@ -38,12 +39,12 @@ func initialize() {
|
||||
_ = common.GetScreen()
|
||||
|
||||
// init HDMI
|
||||
vision := common.GetKvmVision()
|
||||
vision.SetHDMI(false)
|
||||
vm.DisableHdmiCapture()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
if !utils.IsHdmiDisabled() {
|
||||
vision.SetHDMI(true)
|
||||
vm.EnableHdmiCapture()
|
||||
}
|
||||
vm.SetHdmiViewerCount(0)
|
||||
|
||||
// run mouse jiggler
|
||||
jiggler.GetJiggler().Run()
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package proto
|
||||
|
||||
type DownloadImageReq struct {
|
||||
File string `json:"file" validate:"required"`
|
||||
SHA256Sum string `json:"sha256sum"`
|
||||
}
|
||||
|
||||
type ImageEnabledRsp struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
@@ -4,6 +4,14 @@ type GetHidModeRsp struct {
|
||||
Mode string `json:"mode"` // normal or hid-only
|
||||
}
|
||||
|
||||
type GetKeyboardLedStatusRsp struct {
|
||||
NumLock bool `json:"numLock"`
|
||||
CapsLock bool `json:"capsLock"`
|
||||
ScrollLock bool `json:"scrollLock"`
|
||||
Known bool `json:"known"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type SetHidModeReq struct {
|
||||
Mode string `validate:"required"` // normal or hid-only
|
||||
}
|
||||
|
||||
12
server/proto/mcp.go
Normal file
12
server/proto/mcp.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package proto
|
||||
|
||||
type GetMCPConfigRsp struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"apiKey"`
|
||||
ControlMode string `json:"controlMode"`
|
||||
Transitioning bool `json:"transitioning"`
|
||||
}
|
||||
|
||||
type SetMCPConfigReq struct {
|
||||
Enabled *bool `json:"enabled" form:"enabled" validate:"required"`
|
||||
}
|
||||
@@ -98,7 +98,12 @@ type GetOLEDRsp struct {
|
||||
}
|
||||
|
||||
type GetGetHdmiStateRsp struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Enabled bool `json:"enabled"`
|
||||
IdleTimeout int `json:"idleTimeout"`
|
||||
}
|
||||
|
||||
type SetHdmiIdleTimeoutReq struct {
|
||||
Minutes int `validate:"gte=0,lte=10080"`
|
||||
}
|
||||
|
||||
type GetSSHStateRsp struct {
|
||||
|
||||
159
server/router/control.go
Normal file
159
server/router/control.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
"NanoKVM-Server/service/hid"
|
||||
"NanoKVM-Server/service/inputcontrol"
|
||||
"NanoKVM-Server/service/picoclaw"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type setAIControlModeRequest struct {
|
||||
Mode controlmode.Mode `json:"mode"`
|
||||
}
|
||||
|
||||
func controlRouter(r *gin.Engine, control *controlmode.Manager, picoclawService *picoclaw.Service) {
|
||||
group := r.Group("/api/ai/control").Use(middleware.CheckToken())
|
||||
group.GET("/status", func(c *gin.Context) {
|
||||
status, err := control.Status()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"code": -1, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": status})
|
||||
})
|
||||
|
||||
group.PUT("/mode", func(c *gin.Context) {
|
||||
startedAt := time.Now()
|
||||
var req setAIControlModeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"code": -1, "message": "invalid arguments"})
|
||||
return
|
||||
}
|
||||
if !validAIControlMode(req.Mode) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": -1, "message": "invalid control mode"})
|
||||
return
|
||||
}
|
||||
|
||||
previousStatus, previousErr := control.Status()
|
||||
if previousErr != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"request_mode": string(req.Mode),
|
||||
"elapsed_ms": time.Since(startedAt).Milliseconds(),
|
||||
}).WithError(previousErr).Warn("AI control mode request failed before switch")
|
||||
c.JSON(http.StatusOK, gin.H{"code": -2, "message": previousErr.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
preempt := func() error {
|
||||
inputcontrol.GetCoordinator().CancelMCP()
|
||||
if req.Mode == controlmode.ModeMCP {
|
||||
return picoclawService.PreemptControlLeasesForMCP()
|
||||
}
|
||||
picoclawService.CancelActiveControlOperations()
|
||||
return nil
|
||||
}
|
||||
cleanup := func() error {
|
||||
return hid.ReleaseAllHIDStateBestEffort()
|
||||
}
|
||||
if req.Mode == controlmode.ModeMCP {
|
||||
cleanup = func() error {
|
||||
if err := picoclawService.StopRuntimeForMCP(); err != nil {
|
||||
return err
|
||||
}
|
||||
return hid.ReleaseAllHIDStateBestEffort()
|
||||
}
|
||||
}
|
||||
if err := control.SwitchWithCleanup(req.Mode, preempt, cleanup); err != nil {
|
||||
if req.Mode == controlmode.ModeOff {
|
||||
if status, statusErr := control.Status(); statusErr == nil && status.Mode == controlmode.ModeOff {
|
||||
picoclawService.CancelActiveControlOperations()
|
||||
picoclawService.PreserveRuntimeForChatOnly("control_release_chat_only")
|
||||
closedSessions := 0
|
||||
picoclawService.PublishControlModeChangedFrom(status, "ai_control_mode")
|
||||
log.WithFields(log.Fields{
|
||||
"request_mode": string(req.Mode),
|
||||
"previous_mode": string(previousStatus.Mode),
|
||||
"final_mode": string(status.Mode),
|
||||
"transitioning": status.Transitioning,
|
||||
"closed_sessions": closedSessions,
|
||||
"elapsed_ms": time.Since(startedAt).Milliseconds(),
|
||||
}).WithError(err).Warn("AI control mode request completed with cleanup warning")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": gin.H{
|
||||
"control": status,
|
||||
"runtime": picoclawService.RuntimeStatus(),
|
||||
"released": true,
|
||||
"closed_sessions": closedSessions,
|
||||
"cleanup_warning": err.Error(),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
log.WithFields(log.Fields{
|
||||
"request_mode": string(req.Mode),
|
||||
"previous_mode": string(previousStatus.Mode),
|
||||
"elapsed_ms": time.Since(startedAt).Milliseconds(),
|
||||
}).WithError(err).Warn("AI control mode request failed")
|
||||
c.JSON(http.StatusOK, gin.H{"code": -2, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
status, err := control.Status()
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"request_mode": string(req.Mode),
|
||||
"previous_mode": string(previousStatus.Mode),
|
||||
"elapsed_ms": time.Since(startedAt).Milliseconds(),
|
||||
}).WithError(err).Warn("AI control mode request failed after switch")
|
||||
c.JSON(http.StatusOK, gin.H{"code": -2, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
closedSessions := 0
|
||||
if req.Mode == controlmode.ModeOff {
|
||||
picoclawService.CancelActiveControlOperations()
|
||||
picoclawService.PreserveRuntimeForChatOnly("control_release_chat_only")
|
||||
}
|
||||
picoclawService.PublishControlModeChangedFrom(status, "ai_control_mode")
|
||||
log.WithFields(log.Fields{
|
||||
"request_mode": string(req.Mode),
|
||||
"previous_mode": string(previousStatus.Mode),
|
||||
"final_mode": string(status.Mode),
|
||||
"transitioning": status.Transitioning,
|
||||
"elapsed_ms": time.Since(startedAt).Milliseconds(),
|
||||
}).Info("AI control mode request completed")
|
||||
if req.Mode == controlmode.ModeOff {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": gin.H{
|
||||
"control": status,
|
||||
"runtime": picoclawService.RuntimeStatus(),
|
||||
"released": true,
|
||||
"closed_sessions": closedSessions,
|
||||
"cleanup_warning": "",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": status})
|
||||
})
|
||||
}
|
||||
|
||||
func validAIControlMode(mode controlmode.Mode) bool {
|
||||
switch mode {
|
||||
case controlmode.ModeOff, controlmode.ModeMCP, controlmode.ModePicoclaw:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,9 @@ func downloadRouter(r *gin.Engine) {
|
||||
service := download.NewService()
|
||||
api := r.Group("/api").Use(middleware.CheckToken())
|
||||
|
||||
api.POST("/download/image", service.DownloadImage) // download image
|
||||
api.GET("/download/image/status", service.StatusImage) // download image
|
||||
api.GET("/download/image/enabled", service.ImageEnabled) // download image
|
||||
api.POST("/download/file", service.DownloadImageFile) // download image
|
||||
api.POST("/download/image", service.DownloadImage) // download image
|
||||
api.POST("/download/image/cancel", service.CancelDownloadImage) // cancel image download
|
||||
api.GET("/download/image/status", service.StatusImage) // download image
|
||||
api.GET("/download/image/enabled", service.ImageEnabled) // download image
|
||||
api.POST("/download/file", service.DownloadImageFile) // download image
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ func hidRouter(r *gin.Engine) {
|
||||
api.GET("/hid/mode", service.GetHidMode) // get hid mode
|
||||
api.POST("/hid/mode", service.SetHidMode) // set hid mode
|
||||
api.POST("/hid/reset", service.ResetHid) // reset hid
|
||||
api.GET("/hid/leds", service.GetKeyboardLedStatus)
|
||||
|
||||
localAPI.POST("/usb/recover", service.RecoverUSB)
|
||||
}
|
||||
|
||||
34
server/router/mcp.go
Normal file
34
server/router/mcp.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"NanoKVM-Server/middleware"
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
"NanoKVM-Server/service/hid"
|
||||
mcpservice "NanoKVM-Server/service/mcp"
|
||||
"NanoKVM-Server/service/mcp/capture/kvm"
|
||||
"NanoKVM-Server/service/picoclaw"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func mcpRouter(r *gin.Engine, control *controlmode.Manager, picoclawService *picoclaw.Service) {
|
||||
service := mcpservice.NewServiceWithPreempt(
|
||||
control,
|
||||
picoclawService.PreemptControlLeasesForMCP,
|
||||
picoclawService.StopRuntimeForMCP,
|
||||
hid.ReleaseAllHIDStateBestEffort,
|
||||
func(status controlmode.Status) {
|
||||
picoclawService.PublishControlModeChangedFrom(status, "mcp_config")
|
||||
},
|
||||
)
|
||||
management := r.Group("/api/mcp").Use(middleware.CheckToken())
|
||||
management.GET("/config", service.GetConfig)
|
||||
management.POST("/config", service.SetConfig)
|
||||
management.POST("/key/regenerate", service.RegenerateAPIKey)
|
||||
|
||||
handler := mcpservice.NewMCPHandler(control, kvmcapture.New())
|
||||
handler = http.NewCrossOriginProtection().Handler(handler)
|
||||
r.Any("/api/mcp", mcpservice.APIKeyMiddleware(control), gin.WrapH(handler))
|
||||
}
|
||||
@@ -38,8 +38,7 @@ func PicoclawLoopbackHTTPAllowedPaths() []string {
|
||||
return append([]string(nil), picoclawLoopbackHTTPAllowedPaths...)
|
||||
}
|
||||
|
||||
func picoclawRouter(r *gin.Engine) {
|
||||
service := picoclaw.NewService()
|
||||
func picoclawRouter(r *gin.Engine, service *picoclaw.Service) {
|
||||
frontendAPI := r.Group(picoclawBasePath).Use(middleware.CheckToken())
|
||||
localAPI := r.Group(picoclawBasePath).Use(middleware.CheckLoopbackInternalToken())
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
"NanoKVM-Server/service/picoclaw"
|
||||
|
||||
"github.com/gin-gonic/contrib/static"
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -29,6 +32,9 @@ func web(r *gin.Engine) {
|
||||
}
|
||||
|
||||
func server(r *gin.Engine) {
|
||||
control := controlmode.GetManager()
|
||||
picoclawService := picoclaw.NewService(control)
|
||||
|
||||
authRouter(r)
|
||||
applicationRouter(r)
|
||||
vmRouter(r)
|
||||
@@ -36,7 +42,9 @@ func server(r *gin.Engine) {
|
||||
storageRouter(r)
|
||||
networkRouter(r)
|
||||
hidRouter(r)
|
||||
picoclawRouter(r)
|
||||
controlRouter(r, control, picoclawService)
|
||||
mcpRouter(r, control, picoclawService)
|
||||
picoclawRouter(r, picoclawService)
|
||||
wsRouter(r)
|
||||
downloadRouter(r)
|
||||
extensionsRouter(r)
|
||||
|
||||
@@ -40,6 +40,7 @@ func vmRouter(r *gin.Engine) {
|
||||
api.POST("/vm/hdmi/reset", service.ResetHdmi) // reset hdmi
|
||||
api.POST("/vm/hdmi/enable", service.EnableHdmi) // enable hdmi
|
||||
api.POST("/vm/hdmi/disable", service.DisableHdmi) // disable hdmi
|
||||
api.POST("/vm/hdmi/timeout", service.SetHdmiIdleTimeout)
|
||||
|
||||
api.GET("/vm/ssh", service.GetSSHState) // get SSH state
|
||||
api.POST("/vm/ssh/enable", service.EnableSSH) // enable SSH
|
||||
|
||||
@@ -46,10 +46,6 @@ func offlineUpdate(c *gin.Context) error {
|
||||
_ = os.RemoveAll(CacheDir)
|
||||
}()
|
||||
|
||||
if err := checkDownloadInProgress(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := createSentinelFile(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -75,19 +71,30 @@ func offlineUpdate(c *gin.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkDownloadInProgress() error {
|
||||
if _, err := os.Stat(sentinelPath); err == nil {
|
||||
log.Debug("Download in progress")
|
||||
return fmt.Errorf("download already in progress")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createSentinelFile() error {
|
||||
if err := os.WriteFile(sentinelPath, []byte("downloading"), sentinelPermission); err != nil {
|
||||
file, err := os.OpenFile(
|
||||
sentinelPath,
|
||||
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
|
||||
sentinelPermission,
|
||||
)
|
||||
if err != nil {
|
||||
if os.IsExist(err) {
|
||||
return fmt.Errorf("download already in progress")
|
||||
}
|
||||
log.Errorf("Failed to create sentinel file: %v", err)
|
||||
return fmt.Errorf("failed to create sentinel file: %w", err)
|
||||
}
|
||||
|
||||
if _, err := file.WriteString("downloading"); err != nil {
|
||||
_ = file.Close()
|
||||
_ = os.Remove(sentinelPath)
|
||||
return fmt.Errorf("failed to initialize sentinel file: %w", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
_ = os.Remove(sentinelPath)
|
||||
return fmt.Errorf("failed to close sentinel file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
97
server/service/controlmode/activity.go
Normal file
97
server/service/controlmode/activity.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package controlmode
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrActivityWaitTimeout = errors.New("timed out waiting for active control operations")
|
||||
|
||||
// activityGate allows normal control operations to share the current mode while
|
||||
// giving mode transitions exclusive access. Unlike sync.RWMutex, exclusive
|
||||
// acquisition is bounded so an abandoned client cannot leave the manager stuck
|
||||
// in the transitioning state forever.
|
||||
type activityGate struct {
|
||||
mu sync.Mutex
|
||||
active int
|
||||
exclusive bool
|
||||
changed chan struct{}
|
||||
}
|
||||
|
||||
func (g *activityGate) acquireShared() func() {
|
||||
for {
|
||||
g.mu.Lock()
|
||||
if !g.exclusive {
|
||||
g.active++
|
||||
g.mu.Unlock()
|
||||
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
g.mu.Lock()
|
||||
if g.active > 0 {
|
||||
g.active--
|
||||
}
|
||||
g.signalLocked()
|
||||
g.mu.Unlock()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
changed := g.changedLocked()
|
||||
g.mu.Unlock()
|
||||
<-changed
|
||||
}
|
||||
}
|
||||
|
||||
func (g *activityGate) acquireExclusive(timeout time.Duration) (func(), error) {
|
||||
if timeout <= 0 {
|
||||
return nil, ErrActivityWaitTimeout
|
||||
}
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
g.mu.Lock()
|
||||
if !g.exclusive && g.active == 0 {
|
||||
g.exclusive = true
|
||||
g.mu.Unlock()
|
||||
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
g.mu.Lock()
|
||||
g.exclusive = false
|
||||
g.signalLocked()
|
||||
g.mu.Unlock()
|
||||
})
|
||||
}, nil
|
||||
}
|
||||
|
||||
changed := g.changedLocked()
|
||||
g.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-changed:
|
||||
case <-timer.C:
|
||||
return nil, ErrActivityWaitTimeout
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *activityGate) changedLocked() <-chan struct{} {
|
||||
if g.changed == nil {
|
||||
g.changed = make(chan struct{})
|
||||
}
|
||||
return g.changed
|
||||
}
|
||||
|
||||
func (g *activityGate) signalLocked() {
|
||||
if g.changed == nil {
|
||||
return
|
||||
}
|
||||
close(g.changed)
|
||||
g.changed = make(chan struct{})
|
||||
}
|
||||
555
server/service/controlmode/manager.go
Normal file
555
server/service/controlmode/manager.go
Normal file
@@ -0,0 +1,555 @@
|
||||
package controlmode
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const ModeFile = "/etc/kvm/ai-control.mode"
|
||||
|
||||
const defaultActivityWaitTimeout = 30 * time.Second
|
||||
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
ModeOff Mode = "off"
|
||||
ModeMCP Mode = "mcp"
|
||||
ModePicoclaw Mode = "picoclaw"
|
||||
)
|
||||
|
||||
var ErrModeConflict = errors.New("AI control mode conflict")
|
||||
|
||||
type Status struct {
|
||||
Mode Mode `json:"mode"`
|
||||
Transitioning bool `json:"transitioning"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
ChangedAt time.Time `json:"changed_at,omitempty"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
transitionMu sync.Mutex
|
||||
activity activityGate
|
||||
mu sync.Mutex
|
||||
path string
|
||||
defaultMode Mode
|
||||
activityWaitTimeout time.Duration
|
||||
loaded bool
|
||||
mode Mode
|
||||
modeFileExists bool
|
||||
modeFileSize int64
|
||||
modeFileModTime time.Time
|
||||
transitioning bool
|
||||
lastError string
|
||||
changedAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
defaultManagerOnce sync.Once
|
||||
defaultManager *Manager
|
||||
)
|
||||
|
||||
func GetManager() *Manager {
|
||||
defaultManagerOnce.Do(func() {
|
||||
defaultManager = NewManager(ModeFile, ModePicoclaw)
|
||||
})
|
||||
return defaultManager
|
||||
}
|
||||
|
||||
func NewManager(path string, defaultMode Mode) *Manager {
|
||||
if !validMode(defaultMode) {
|
||||
defaultMode = ModeOff
|
||||
}
|
||||
return &Manager{
|
||||
path: path,
|
||||
defaultMode: defaultMode,
|
||||
activityWaitTimeout: defaultActivityWaitTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Status() (Status, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if err := m.loadLocked(); err != nil {
|
||||
return Status{Mode: ModeOff, Transitioning: m.transitioning, LastError: err.Error(), ChangedAt: m.changedAt}, err
|
||||
}
|
||||
return m.statusLocked(), nil
|
||||
}
|
||||
|
||||
func (m *Manager) Current() Mode {
|
||||
status, err := m.Status()
|
||||
if err != nil {
|
||||
return ModeOff
|
||||
}
|
||||
return status.Mode
|
||||
}
|
||||
|
||||
func (m *Manager) RequireWrite(expected Mode) error {
|
||||
status, err := m.Status()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status.Transitioning || status.Mode != expected {
|
||||
return fmt.Errorf("%w: current=%s expected=%s", ErrModeConflict, status.Mode, expected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Require(expected Mode) error {
|
||||
return m.RequireWrite(expected)
|
||||
}
|
||||
|
||||
func (m *Manager) RequireMode(expected Mode) error {
|
||||
status, err := m.Status()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status.Mode != expected {
|
||||
return fmt.Errorf("%w: current=%s expected=%s", ErrModeConflict, status.Mode, expected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) AcquireWrite(expected Mode) (func(), error) {
|
||||
if err := m.RequireWrite(expected); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
release := m.activity.acquireShared()
|
||||
if err := m.RequireWrite(expected); err != nil {
|
||||
release()
|
||||
return nil, err
|
||||
}
|
||||
return release, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Acquire(expected Mode) (func(), error) {
|
||||
return m.AcquireWrite(expected)
|
||||
}
|
||||
|
||||
// AcquireStable holds the current control mode stable while a non-AI input
|
||||
// operation is in flight. Mode transitions take the exclusive side of the
|
||||
// activity gate, so they wait until the returned release function is called.
|
||||
func (m *Manager) AcquireStable() (Status, func(), error) {
|
||||
status, err := m.Status()
|
||||
if err != nil {
|
||||
return Status{Mode: ModeOff}, nil, err
|
||||
}
|
||||
if status.Transitioning {
|
||||
return status, nil, fmt.Errorf("%w: control mode is transitioning", ErrModeConflict)
|
||||
}
|
||||
|
||||
release := m.activity.acquireShared()
|
||||
status, err = m.Status()
|
||||
if err != nil {
|
||||
release()
|
||||
return Status{Mode: ModeOff}, nil, err
|
||||
}
|
||||
if status.Transitioning {
|
||||
release()
|
||||
return status, nil, fmt.Errorf("%w: control mode is transitioning", ErrModeConflict)
|
||||
}
|
||||
|
||||
return status, release, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Switch(next Mode, preempt func() error) error {
|
||||
return m.SwitchWithCleanup(next, preempt, nil)
|
||||
}
|
||||
|
||||
func (m *Manager) SwitchWithCleanup(next Mode, preempt func() error, cleanup func() error) error {
|
||||
startedAt := time.Now()
|
||||
timings := log.Fields{
|
||||
"to": string(next),
|
||||
}
|
||||
|
||||
m.transitionMu.Lock()
|
||||
defer m.transitionMu.Unlock()
|
||||
|
||||
stageStartedAt := time.Now()
|
||||
status, err := m.Status()
|
||||
timings["load_status_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
if err != nil {
|
||||
logModeSwitchFailure("AI control mode switch failed while loading status", timings, err, startedAt)
|
||||
return err
|
||||
}
|
||||
timings["from"] = string(status.Mode)
|
||||
if !validMode(next) {
|
||||
err := fmt.Errorf("invalid AI control mode %q", next)
|
||||
logModeSwitchFailure("AI control mode switch rejected invalid target", timings, err, startedAt)
|
||||
return err
|
||||
}
|
||||
if status.Mode == next {
|
||||
timings["total_ms"] = elapsedMilliseconds(startedAt)
|
||||
log.WithFields(timings).Debug("AI control mode switch skipped")
|
||||
return nil
|
||||
}
|
||||
|
||||
m.setTransitioning(true)
|
||||
defer m.setTransitioning(false)
|
||||
if preempt != nil {
|
||||
stageStartedAt = time.Now()
|
||||
if err := callModeTransitionHook(preempt); err != nil {
|
||||
timings["preempt_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
m.setLastError(err)
|
||||
logModeSwitchFailure("AI control mode switch preempt failed", timings, err, startedAt)
|
||||
return err
|
||||
}
|
||||
timings["preempt_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
}
|
||||
|
||||
stageStartedAt = time.Now()
|
||||
releaseActivity, err := m.acquireExclusiveActivity()
|
||||
timings["activity_wait_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
if err != nil {
|
||||
m.setLastError(err)
|
||||
logModeSwitchFailure("AI control mode switch activity wait failed", timings, err, startedAt)
|
||||
return err
|
||||
}
|
||||
defer releaseActivity()
|
||||
|
||||
if cleanup != nil {
|
||||
stageStartedAt = time.Now()
|
||||
if err := callModeTransitionHook(cleanup); err != nil {
|
||||
timings["cleanup_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
if rollbackErr := m.save(ModeOff); rollbackErr != nil {
|
||||
err = errors.Join(err, rollbackErr)
|
||||
}
|
||||
m.setLastError(err)
|
||||
logModeSwitchFailure("AI control mode switch cleanup failed", timings, err, startedAt)
|
||||
return err
|
||||
}
|
||||
timings["cleanup_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
}
|
||||
|
||||
stageStartedAt = time.Now()
|
||||
if err := m.save(next); err != nil {
|
||||
timings["save_mode_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
m.setLastError(err)
|
||||
logModeSwitchFailure("AI control mode switch save failed", timings, err, startedAt)
|
||||
return err
|
||||
}
|
||||
timings["save_mode_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
m.setLastError(nil)
|
||||
timings["total_ms"] = elapsedMilliseconds(startedAt)
|
||||
log.WithFields(timings).Info("AI control mode switch completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) SwitchIf(expected Mode, next Mode, preempt func() error) (bool, error) {
|
||||
return m.SwitchIfWithCleanup(expected, next, preempt, nil)
|
||||
}
|
||||
|
||||
func (m *Manager) SwitchIfWithCleanup(expected Mode, next Mode, preempt func() error, cleanup func() error) (bool, error) {
|
||||
startedAt := time.Now()
|
||||
timings := log.Fields{
|
||||
"expected": string(expected),
|
||||
"to": string(next),
|
||||
}
|
||||
|
||||
if !validMode(expected) || !validMode(next) {
|
||||
err := fmt.Errorf("invalid AI control mode transition %q -> %q", expected, next)
|
||||
logModeSwitchFailure("conditional AI control mode switch rejected invalid transition", timings, err, startedAt)
|
||||
return false, err
|
||||
}
|
||||
|
||||
m.transitionMu.Lock()
|
||||
defer m.transitionMu.Unlock()
|
||||
|
||||
stageStartedAt := time.Now()
|
||||
status, err := m.Status()
|
||||
timings["load_status_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
if err != nil {
|
||||
logModeSwitchFailure("conditional AI control mode switch failed while loading status", timings, err, startedAt)
|
||||
return false, err
|
||||
}
|
||||
timings["from"] = string(status.Mode)
|
||||
if status.Mode != expected {
|
||||
timings["matched"] = false
|
||||
timings["total_ms"] = elapsedMilliseconds(startedAt)
|
||||
log.WithFields(timings).Debug("conditional AI control mode switch skipped")
|
||||
return false, nil
|
||||
}
|
||||
if status.Mode == next {
|
||||
timings["matched"] = true
|
||||
timings["total_ms"] = elapsedMilliseconds(startedAt)
|
||||
log.WithFields(timings).Debug("conditional AI control mode switch already satisfied")
|
||||
return true, nil
|
||||
}
|
||||
|
||||
m.setTransitioning(true)
|
||||
defer m.setTransitioning(false)
|
||||
if preempt != nil {
|
||||
stageStartedAt = time.Now()
|
||||
if err := callModeTransitionHook(preempt); err != nil {
|
||||
timings["preempt_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
m.setLastError(err)
|
||||
logModeSwitchFailure("conditional AI control mode switch preempt failed", timings, err, startedAt)
|
||||
return false, err
|
||||
}
|
||||
timings["preempt_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
}
|
||||
|
||||
stageStartedAt = time.Now()
|
||||
releaseActivity, err := m.acquireExclusiveActivity()
|
||||
timings["activity_wait_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
if err != nil {
|
||||
m.setLastError(err)
|
||||
logModeSwitchFailure("conditional AI control mode switch activity wait failed", timings, err, startedAt)
|
||||
return false, err
|
||||
}
|
||||
defer releaseActivity()
|
||||
|
||||
if cleanup != nil {
|
||||
stageStartedAt = time.Now()
|
||||
if err := callModeTransitionHook(cleanup); err != nil {
|
||||
timings["cleanup_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
if rollbackErr := m.save(ModeOff); rollbackErr != nil {
|
||||
err = errors.Join(err, rollbackErr)
|
||||
}
|
||||
m.setLastError(err)
|
||||
logModeSwitchFailure("conditional AI control mode switch cleanup failed", timings, err, startedAt)
|
||||
return false, err
|
||||
}
|
||||
timings["cleanup_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
}
|
||||
|
||||
stageStartedAt = time.Now()
|
||||
if err := m.save(next); err != nil {
|
||||
timings["save_mode_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
m.setLastError(err)
|
||||
logModeSwitchFailure("conditional AI control mode switch save failed", timings, err, startedAt)
|
||||
return false, err
|
||||
}
|
||||
timings["save_mode_ms"] = elapsedMilliseconds(stageStartedAt)
|
||||
m.setLastError(nil)
|
||||
timings["matched"] = true
|
||||
timings["total_ms"] = elapsedMilliseconds(startedAt)
|
||||
log.WithFields(timings).Info("conditional AI control mode switch completed")
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (m *Manager) SwitchToMCP(preempt func() error) error {
|
||||
return m.Switch(ModeMCP, preempt)
|
||||
}
|
||||
|
||||
func (m *Manager) SwitchToMCPWithPreempt(preempt func() error) error {
|
||||
return m.Switch(ModeMCP, preempt)
|
||||
}
|
||||
|
||||
func (m *Manager) SwitchToPicoclaw(preempt func() error) error {
|
||||
return m.Switch(ModePicoclaw, preempt)
|
||||
}
|
||||
|
||||
func (m *Manager) SwitchToPicoclawWithPreempt(preempt func() error) error {
|
||||
return m.Switch(ModePicoclaw, preempt)
|
||||
}
|
||||
|
||||
func (m *Manager) SwitchOff(preempt func() error) error {
|
||||
return m.Switch(ModeOff, preempt)
|
||||
}
|
||||
|
||||
func (m *Manager) SwitchOffIf(expected Mode, preempt func() error) (bool, error) {
|
||||
return m.SwitchIf(expected, ModeOff, preempt)
|
||||
}
|
||||
|
||||
func (m *Manager) SwitchOffIfWithPreempt(expected Mode, preempt func() error) (bool, error) {
|
||||
return m.SwitchIf(expected, ModeOff, preempt)
|
||||
}
|
||||
|
||||
func callModeTransitionHook(hook func() error) (err error) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
err = fmt.Errorf("panic: %v", recovered)
|
||||
}
|
||||
}()
|
||||
return hook()
|
||||
}
|
||||
|
||||
func (m *Manager) setTransitioning(transitioning bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.transitioning = transitioning
|
||||
}
|
||||
|
||||
func (m *Manager) setLastError(err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if err == nil {
|
||||
m.lastError = ""
|
||||
return
|
||||
}
|
||||
m.lastError = err.Error()
|
||||
}
|
||||
|
||||
func (m *Manager) statusLocked() Status {
|
||||
return Status{
|
||||
Mode: m.mode,
|
||||
Transitioning: m.transitioning,
|
||||
LastError: m.lastError,
|
||||
ChangedAt: m.changedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) acquireExclusiveActivity() (func(), error) {
|
||||
timeout := m.activityWaitTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultActivityWaitTimeout
|
||||
}
|
||||
|
||||
release, err := m.activity.acquireExclusive(timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wait for active control operations: %w", err)
|
||||
}
|
||||
return release, nil
|
||||
}
|
||||
|
||||
func (m *Manager) save(mode Mode) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.saveLocked(mode)
|
||||
}
|
||||
|
||||
func (m *Manager) loadLocked() error {
|
||||
if m.loaded && m.transitioning {
|
||||
return nil
|
||||
}
|
||||
|
||||
info, statErr := os.Stat(m.path)
|
||||
if m.loaded && statErr == nil && m.modeFileExists && info.Size() == m.modeFileSize && info.ModTime().Equal(m.modeFileModTime) {
|
||||
return nil
|
||||
}
|
||||
if m.loaded && errors.Is(statErr, os.ErrNotExist) && !m.modeFileExists {
|
||||
return nil
|
||||
}
|
||||
if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
|
||||
return fmt.Errorf("stat AI control mode: %w", statErr)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(m.path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
m.mode = m.defaultMode
|
||||
m.loaded = true
|
||||
m.modeFileExists = false
|
||||
m.modeFileSize = 0
|
||||
m.modeFileModTime = time.Time{}
|
||||
if m.changedAt.IsZero() {
|
||||
m.changedAt = time.Now()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read AI control mode: %w", err)
|
||||
}
|
||||
|
||||
mode := Mode(strings.TrimSpace(string(data)))
|
||||
if !validMode(mode) {
|
||||
m.mode = ModeOff
|
||||
m.loaded = true
|
||||
m.lastError = fmt.Sprintf("invalid AI control mode %q", strings.TrimSpace(string(data)))
|
||||
m.cacheModeFileInfoLocked(info, data)
|
||||
return nil
|
||||
}
|
||||
m.mode = mode
|
||||
m.loaded = true
|
||||
m.lastError = ""
|
||||
m.cacheModeFileInfoLocked(info, data)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) saveLocked(mode Mode) error {
|
||||
if !validMode(mode) {
|
||||
return fmt.Errorf("invalid AI control mode %q", mode)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(m.path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create AI control mode directory: %w", err)
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(dir, ".ai-control.mode.*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary AI control mode: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer func() { _ = os.Remove(tmpPath) }()
|
||||
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("set AI control mode permissions: %w", err)
|
||||
}
|
||||
if _, err := tmp.WriteString(string(mode) + "\n"); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("write AI control mode: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("sync AI control mode: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close AI control mode: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, m.path); err != nil {
|
||||
return fmt.Errorf("replace AI control mode: %w", err)
|
||||
}
|
||||
m.mode = mode
|
||||
m.loaded = true
|
||||
if info, err := os.Stat(m.path); err == nil {
|
||||
m.cacheModeFileInfoLocked(info, []byte(string(mode)+"\n"))
|
||||
} else {
|
||||
m.modeFileExists = true
|
||||
m.modeFileSize = int64(len(string(mode) + "\n"))
|
||||
m.modeFileModTime = time.Now()
|
||||
}
|
||||
m.changedAt = time.Now()
|
||||
|
||||
directory, err := os.Open(dir)
|
||||
if err == nil {
|
||||
if syncErr := directory.Sync(); syncErr != nil {
|
||||
_ = directory.Close()
|
||||
return fmt.Errorf("sync AI control mode directory: %w", syncErr)
|
||||
}
|
||||
_ = directory.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) cacheModeFileInfoLocked(info os.FileInfo, data []byte) {
|
||||
m.modeFileExists = true
|
||||
if info != nil {
|
||||
m.modeFileSize = info.Size()
|
||||
m.modeFileModTime = info.ModTime()
|
||||
if m.changedAt.IsZero() || info.ModTime().After(m.changedAt) {
|
||||
m.changedAt = info.ModTime()
|
||||
}
|
||||
return
|
||||
}
|
||||
m.modeFileSize = int64(len(data))
|
||||
m.modeFileModTime = time.Now()
|
||||
m.changedAt = m.modeFileModTime
|
||||
}
|
||||
|
||||
func validMode(mode Mode) bool {
|
||||
switch mode {
|
||||
case ModeOff, ModeMCP, ModePicoclaw:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func elapsedMilliseconds(startedAt time.Time) int64 {
|
||||
return time.Since(startedAt).Milliseconds()
|
||||
}
|
||||
|
||||
func logModeSwitchFailure(message string, fields log.Fields, err error, startedAt time.Time) {
|
||||
fields["total_ms"] = elapsedMilliseconds(startedAt)
|
||||
log.WithFields(fields).WithError(err).Warn(message)
|
||||
}
|
||||
202
server/service/controlmode/manager_test.go
Normal file
202
server/service/controlmode/manager_test.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package controlmode
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestManagerDefaultsToPicoclaw(t *testing.T) {
|
||||
manager := NewManager(filepath.Join(t.TempDir(), "mode"), ModePicoclaw)
|
||||
if got := manager.Current(); got != ModePicoclaw {
|
||||
t.Fatalf("mode = %q, want %q", got, ModePicoclaw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchPreemptsBeforeWaitingForActiveWrite(t *testing.T) {
|
||||
manager := NewManager(filepath.Join(t.TempDir(), "mode"), ModeMCP)
|
||||
release, err := manager.AcquireWrite(ModeMCP)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
preempted := make(chan struct{})
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- manager.Switch(ModePicoclaw, func() error {
|
||||
close(preempted)
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-preempted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("preempt callback was not called before waiting")
|
||||
}
|
||||
if _, err := manager.AcquireWrite(ModeMCP); err == nil {
|
||||
t.Fatal("new write acquired control during transition")
|
||||
}
|
||||
|
||||
release()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("switch did not resume after active write was released")
|
||||
}
|
||||
if got := manager.Current(); got != ModePicoclaw {
|
||||
t.Fatalf("mode = %q, want picoclaw", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchRunsCleanupAfterActiveWritesDrain(t *testing.T) {
|
||||
manager := NewManager(filepath.Join(t.TempDir(), "mode"), ModePicoclaw)
|
||||
release, err := manager.AcquireWrite(ModePicoclaw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cleanupCalled := make(chan struct{})
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- manager.SwitchWithCleanup(ModeMCP, nil, func() error {
|
||||
close(cleanupCalled)
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-cleanupCalled:
|
||||
t.Fatal("cleanup ran before active write drained")
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
|
||||
release()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("switch did not finish")
|
||||
}
|
||||
select {
|
||||
case <-cleanupCalled:
|
||||
default:
|
||||
t.Fatal("cleanup was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchCleanupFailureFailsClosed(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "mode")
|
||||
manager := NewManager(path, ModePicoclaw)
|
||||
wantErr := errors.New("hid release failed")
|
||||
|
||||
if err := manager.SwitchWithCleanup(ModeMCP, nil, func() error { return wantErr }); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if got := manager.Current(); got != ModeOff {
|
||||
t.Fatalf("mode = %q, want off after cleanup failure", got)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "off\n" {
|
||||
t.Fatalf("mode file = %q, want off", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchIfKeepsDifferentMode(t *testing.T) {
|
||||
manager := NewManager(filepath.Join(t.TempDir(), "mode"), ModePicoclaw)
|
||||
switched, err := manager.SwitchIf(ModeMCP, ModeOff, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if switched || manager.Current() != ModePicoclaw {
|
||||
t.Fatalf("switched=%v mode=%q, want unchanged picoclaw", switched, manager.Current())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchTimeoutDoesNotLeaveManagerTransitioning(t *testing.T) {
|
||||
manager := NewManager(filepath.Join(t.TempDir(), "mode"), ModeMCP)
|
||||
manager.activityWaitTimeout = 20 * time.Millisecond
|
||||
cleanupCalled := false
|
||||
|
||||
status, release, err := manager.AcquireStable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.Mode != ModeMCP {
|
||||
t.Fatalf("mode = %q, want MCP", status.Mode)
|
||||
}
|
||||
|
||||
err = manager.SwitchWithCleanup(ModePicoclaw, nil, func() error {
|
||||
cleanupCalled = true
|
||||
return nil
|
||||
})
|
||||
if !errors.Is(err, ErrActivityWaitTimeout) {
|
||||
t.Fatalf("switch error = %v, want %v", err, ErrActivityWaitTimeout)
|
||||
}
|
||||
if cleanupCalled {
|
||||
t.Fatal("cleanup ran even though exclusive activity lease was not acquired")
|
||||
}
|
||||
status, err = manager.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.Mode != ModeMCP || status.Transitioning {
|
||||
t.Fatalf("status after timeout = %+v, want stable MCP", status)
|
||||
}
|
||||
|
||||
release()
|
||||
if err := manager.Switch(ModePicoclaw, nil); err != nil {
|
||||
t.Fatalf("switch after lease release failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidModeFailsClosed(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "mode")
|
||||
if err := os.WriteFile(path, []byte("invalid\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager := NewManager(path, ModePicoclaw)
|
||||
status, err := manager.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.Mode != ModeOff {
|
||||
t.Fatalf("mode = %q, want off", status.Mode)
|
||||
}
|
||||
if !strings.Contains(status.LastError, "invalid") {
|
||||
t.Fatalf("last_error = %q, want invalid mode detail", status.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusReloadsExternallyModifiedModeFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "mode")
|
||||
if err := os.WriteFile(path, []byte("off\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager := NewManager(path, ModePicoclaw)
|
||||
if got := manager.Current(); got != ModeOff {
|
||||
t.Fatalf("initial mode = %q, want off", got)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte("mcp\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
modTime := time.Now().Add(time.Second)
|
||||
if err := os.Chtimes(path, modTime, modTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := manager.Current(); got != ModeMCP {
|
||||
t.Fatalf("mode after external write = %q, want MCP", got)
|
||||
}
|
||||
}
|
||||
@@ -2,54 +2,165 @@ package download
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/proto"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Service struct{}
|
||||
type downloadStatus string
|
||||
|
||||
var sentinelPath = "/tmp/.download_in_progress"
|
||||
const (
|
||||
transferSentinelPath = "/tmp/.download_in_progress"
|
||||
downloadStatusIdle downloadStatus = "idle"
|
||||
downloadStatusInProgress downloadStatus = "in_progress"
|
||||
downloadStatusSuccess downloadStatus = "success"
|
||||
downloadStatusFailed downloadStatus = "failed"
|
||||
downloadStatusChecksumFailed downloadStatus = "checksum_failed"
|
||||
)
|
||||
|
||||
var (
|
||||
errDownloadInProgress = errors.New("download in progress")
|
||||
errSHA256Mismatch = errors.New("sha256 mismatch")
|
||||
validISOFilename = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
downloadMutex sync.Mutex
|
||||
downloadCancel context.CancelFunc
|
||||
downloadDone chan struct{}
|
||||
downloadStatus downloadStatus
|
||||
downloadFile string
|
||||
downloadPercentage string
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
// Clear sentinel
|
||||
// If we are starting from scratch, we need to remove the sentinel file as any downloads at this point are done or broken
|
||||
_ = os.Remove(sentinelPath)
|
||||
return &Service{}
|
||||
// A running transfer cannot survive a server restart, so any remaining lock
|
||||
// file is stale at this point.
|
||||
_ = os.Remove(transferSentinelPath)
|
||||
return &Service{downloadStatus: downloadStatusIdle}
|
||||
}
|
||||
|
||||
func (s *Service) CancelDownloadImage(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
s.downloadMutex.Lock()
|
||||
cancel := s.downloadCancel
|
||||
done := s.downloadDone
|
||||
s.downloadMutex.Unlock()
|
||||
|
||||
if cancel == nil || done == nil {
|
||||
rsp.ErrRsp(c, -1, "no cancellable download in progress")
|
||||
return
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
rsp.OkRsp(c)
|
||||
case <-time.After(10 * time.Second):
|
||||
rsp.ErrRsp(c, -1, "cancel download timed out")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) beginDownload(file string, cancel context.CancelFunc) (chan struct{}, error) {
|
||||
s.downloadMutex.Lock()
|
||||
defer s.downloadMutex.Unlock()
|
||||
|
||||
if s.downloadDone != nil {
|
||||
return nil, errDownloadInProgress
|
||||
}
|
||||
|
||||
lockFile, err := os.OpenFile(
|
||||
transferSentinelPath,
|
||||
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
|
||||
0o644,
|
||||
)
|
||||
if err != nil {
|
||||
if os.IsExist(err) {
|
||||
return nil, errDownloadInProgress
|
||||
}
|
||||
return nil, fmt.Errorf("acquire transfer lock failed: %w", err)
|
||||
}
|
||||
if err := lockFile.Close(); err != nil {
|
||||
_ = os.Remove(transferSentinelPath)
|
||||
return nil, fmt.Errorf("close transfer lock failed: %w", err)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
s.downloadCancel = cancel
|
||||
s.downloadDone = done
|
||||
s.downloadStatus = downloadStatusInProgress
|
||||
s.downloadFile = file
|
||||
s.downloadPercentage = ""
|
||||
|
||||
return done, nil
|
||||
}
|
||||
|
||||
func (s *Service) setDownloadFile(done chan struct{}, file string) {
|
||||
s.downloadMutex.Lock()
|
||||
defer s.downloadMutex.Unlock()
|
||||
|
||||
if s.downloadDone == done {
|
||||
s.downloadFile = file
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) setDownloadProgress(done chan struct{}, percentage string) {
|
||||
s.downloadMutex.Lock()
|
||||
defer s.downloadMutex.Unlock()
|
||||
|
||||
if s.downloadDone == done {
|
||||
s.downloadPercentage = percentage
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) finishDownload(done chan struct{}, status downloadStatus) {
|
||||
s.downloadMutex.Lock()
|
||||
if s.downloadDone != done {
|
||||
s.downloadMutex.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
s.downloadCancel = nil
|
||||
s.downloadDone = nil
|
||||
s.downloadStatus = status
|
||||
s.downloadFile = ""
|
||||
s.downloadPercentage = ""
|
||||
_ = os.Remove(transferSentinelPath)
|
||||
s.downloadMutex.Unlock()
|
||||
|
||||
close(done)
|
||||
}
|
||||
|
||||
func (s *Service) ImageEnabled(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
// Check if /data mount is RO/RW
|
||||
testFile := "/data/.testfile"
|
||||
file, err := os.Create(testFile)
|
||||
defer file.Close()
|
||||
defer os.Remove(testFile)
|
||||
if err != nil {
|
||||
if os.IsPermission(err) {
|
||||
rsp.OkRspWithData(c, &proto.ImageEnabledRsp{
|
||||
Enabled: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
rsp.OkRspWithData(c, &proto.ImageEnabledRsp{
|
||||
Enabled: false,
|
||||
})
|
||||
rsp.OkRspWithData(c, &proto.ImageEnabledRsp{Enabled: false})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
defer os.Remove(testFile)
|
||||
|
||||
rsp.OkRspWithData(c, &proto.ImageEnabledRsp{
|
||||
Enabled: true,
|
||||
})
|
||||
rsp.OkRspWithData(c, &proto.ImageEnabledRsp{Enabled: true})
|
||||
}
|
||||
|
||||
func isISO9660(path string) (bool, error) {
|
||||
@@ -59,15 +170,13 @@ func isISO9660(path string) (bool, error) {
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// ISO-9660 Magic "CD001" bei Offset 32769
|
||||
_, err = f.Seek(0x8001, io.SeekStart)
|
||||
if err != nil {
|
||||
// ISO-9660 magic "CD001" at offset 32769.
|
||||
if _, err = f.Seek(0x8001, io.SeekStart); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
buf := make([]byte, 5)
|
||||
_, err = io.ReadFull(f, buf)
|
||||
if err != nil {
|
||||
if _, err = io.ReadFull(f, buf); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -77,211 +186,141 @@ func isISO9660(path string) (bool, error) {
|
||||
func (s *Service) StatusImage(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
// Check if the sentinel file exists
|
||||
log.Debug("StatusImage")
|
||||
if _, err := os.Stat(sentinelPath); err == nil {
|
||||
content, err := os.ReadFile(sentinelPath)
|
||||
if err != nil {
|
||||
log.Error("Failed to read sentinel file")
|
||||
rsp.OkRspWithData(c, &proto.StatusImageRsp{
|
||||
Status: "in_progress",
|
||||
File: "",
|
||||
Percentage: "",
|
||||
})
|
||||
return
|
||||
}
|
||||
splitted := strings.Split(string(content), ";")
|
||||
if len(splitted) == 1 {
|
||||
// No percentage, just the URL
|
||||
rsp.OkRspWithData(c, &proto.StatusImageRsp{
|
||||
Status: "in_progress",
|
||||
File: splitted[0],
|
||||
Percentage: "",
|
||||
})
|
||||
} else {
|
||||
// Percentage is available
|
||||
rsp.OkRspWithData(c, &proto.StatusImageRsp{
|
||||
Status: "in_progress",
|
||||
File: splitted[0],
|
||||
Percentage: splitted[1],
|
||||
})
|
||||
}
|
||||
s.downloadMutex.Lock()
|
||||
status := s.downloadStatus
|
||||
file := s.downloadFile
|
||||
percentage := s.downloadPercentage
|
||||
s.downloadMutex.Unlock()
|
||||
|
||||
return
|
||||
}
|
||||
rsp.OkRspWithData(c, &proto.StatusImageRsp{
|
||||
Status: "idle",
|
||||
File: "",
|
||||
Percentage: "",
|
||||
Status: string(status),
|
||||
File: file,
|
||||
Percentage: percentage,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) DownloadImageFile(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
log.Debug("DownloadImage")
|
||||
|
||||
// Set a sentinel file to mark that there is a download in progress
|
||||
// This is to prevent multiple downloads at the same time
|
||||
if _, err := os.Stat(sentinelPath); err == nil {
|
||||
log.Debug("Download in progress")
|
||||
rsp.ErrRsp(c, -1, "download in progress")
|
||||
return
|
||||
}
|
||||
|
||||
// Create the sentinel file
|
||||
err := os.WriteFile(sentinelPath, []byte("start"), 0644)
|
||||
log.Debug("DownloadImageFile")
|
||||
expectedSHA256, err := parseSHA256(c.GetHeader("X-SHA256-Sum"))
|
||||
if err != nil {
|
||||
log.Error("Failed to create sentinel file")
|
||||
rsp.ErrRsp(c, -1, "failed to create sentinel file")
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Multipart Reader direkt nutzen (keine FormFile!)
|
||||
reader, err := c.Request.MultipartReader()
|
||||
if err != nil {
|
||||
done, err := s.beginDownload("", nil)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
}
|
||||
finalStatus := downloadStatusFailed
|
||||
defer func() {
|
||||
s.finishDownload(done, finalStatus)
|
||||
}()
|
||||
|
||||
reader, err := c.Request.MultipartReader()
|
||||
if err != nil {
|
||||
log.Error("invalid multipart data")
|
||||
rsp.ErrRsp(c, -1, "invalid multipart data")
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
var lw *loggingWriter
|
||||
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("failed to read part")
|
||||
rsp.ErrRsp(c, -1, "failed to read part")
|
||||
lw.stopTicker()
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
|
||||
if part.FormName() != "file" {
|
||||
continue
|
||||
}
|
||||
|
||||
filename := part.FileName()
|
||||
if filename == "" {
|
||||
log.Error("no filename")
|
||||
rsp.ErrRsp(c, -1, "no filename")
|
||||
lw.stopTicker()
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
|
||||
filename = filepath.Base(filename)
|
||||
|
||||
if filename != part.FileName() {
|
||||
log.Warn("path detected in filename")
|
||||
rsp.ErrRsp(c, -1, "invalid filename")
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.Contains(filename, "..") {
|
||||
log.Warn("path traversal attempt")
|
||||
rsp.ErrRsp(c, -1, "invalid filename")
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
if part.FormName() != "file" {
|
||||
_ = part.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(strings.ToLower(filename), ".iso") {
|
||||
rsp.ErrRsp(c, -1, "only .iso files allowed")
|
||||
defer os.Remove(sentinelPath)
|
||||
filename := part.FileName()
|
||||
if err := validateISOFilename(filename); err != nil {
|
||||
_ = part.Close()
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
}
|
||||
s.setDownloadFile(done, filename)
|
||||
|
||||
valid := regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
|
||||
if !valid.MatchString(filename) {
|
||||
rsp.ErrRsp(c, -1, "invalid filename")
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(sentinelPath)
|
||||
out, err := os.CreateTemp("/data", ".nanokvm-upload-*")
|
||||
if err != nil {
|
||||
log.Error("Read failed")
|
||||
rsp.ErrRsp(c, -1, "Read failed")
|
||||
lw.stopTicker()
|
||||
defer os.Remove(sentinelPath)
|
||||
_ = part.Close()
|
||||
log.Error("cannot create temporary file")
|
||||
rsp.ErrRsp(c, -1, "cannot create temporary file")
|
||||
return
|
||||
}
|
||||
tempPath := out.Name()
|
||||
defer os.Remove(tempPath)
|
||||
|
||||
outPath := "/data/" + filename
|
||||
out, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
log.Error("cannot create file")
|
||||
rsp.ErrRsp(c, -1, "cannot create file")
|
||||
lw.stopTicker()
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if strings.Contains(string(data), "start") {
|
||||
err = os.WriteFile(sentinelPath, []byte(filename), 0644)
|
||||
if err != nil {
|
||||
log.Error("Failed to create sentinel file")
|
||||
rsp.ErrRsp(c, -1, "failed to create sentinel file")
|
||||
lw.stopTicker()
|
||||
defer os.Remove(outPath)
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
|
||||
lw = &loggingWriter{writer: out, totalSize: c.Request.ContentLength}
|
||||
lw.startTicker()
|
||||
} else {
|
||||
if !strings.Contains(string(data), filename) {
|
||||
log.Error("failed")
|
||||
rsp.ErrRsp(c, -1, "failed")
|
||||
lw.stopTicker()
|
||||
defer os.Remove(outPath)
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Direkt streamen → kein RAM-Bedarf außer kleinem Buffer
|
||||
_, err = io.Copy(lw, part)
|
||||
if err != nil {
|
||||
hasher := sha256.New()
|
||||
lw := newLoggingWriter(io.MultiWriter(out, hasher), c.Request.ContentLength, func(percentage string) {
|
||||
s.setDownloadProgress(done, percentage)
|
||||
})
|
||||
_, copyErr := io.Copy(lw, part)
|
||||
lw.stopTicker()
|
||||
partCloseErr := part.Close()
|
||||
outCloseErr := out.Close()
|
||||
if copyErr != nil || partCloseErr != nil || outCloseErr != nil {
|
||||
log.Error("write failed")
|
||||
rsp.ErrRsp(c, -1, "write failed")
|
||||
lw.stopTicker()
|
||||
defer os.Remove(outPath)
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
|
||||
ok, err := isISO9660(outPath)
|
||||
if err != nil || !ok {
|
||||
rsp.ErrRsp(c, -1, "file is not a valid ISO image")
|
||||
lw.stopTicker()
|
||||
defer os.Remove(outPath)
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
lw.stopTicker()
|
||||
|
||||
rsp.OkRspWithData(c, &proto.StatusImageRsp{
|
||||
Status: "idle",
|
||||
File: "",
|
||||
Percentage: "",
|
||||
})
|
||||
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
if expectedSHA256 != nil && !bytes.Equal(hasher.Sum(nil), expectedSHA256) {
|
||||
finalStatus = downloadStatusChecksumFailed
|
||||
rsp.ErrRsp(c, -1, errSHA256Mismatch.Error())
|
||||
return
|
||||
}
|
||||
|
||||
valid, err := isISO9660(tempPath)
|
||||
if err != nil || !valid {
|
||||
rsp.ErrRsp(c, -1, "file is not a valid ISO image")
|
||||
return
|
||||
}
|
||||
|
||||
outPath := filepath.Join("/data", filename)
|
||||
if err := os.Rename(tempPath, outPath); err != nil {
|
||||
rsp.ErrRsp(c, -1, "cannot install uploaded image")
|
||||
return
|
||||
}
|
||||
|
||||
finalStatus = downloadStatusIdle
|
||||
rsp.OkRspWithData(c, &proto.StatusImageRsp{
|
||||
Status: string(downloadStatusIdle),
|
||||
File: "",
|
||||
Percentage: "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
rsp.ErrRsp(c, -1, "file is required")
|
||||
}
|
||||
|
||||
func validateISOFilename(filename string) error {
|
||||
if filename == "" {
|
||||
return errors.New("no filename")
|
||||
}
|
||||
if filepath.Base(filename) != filename || strings.Contains(filename, "..") {
|
||||
return errors.New("invalid filename")
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(filename), ".iso") {
|
||||
return errors.New("only .iso files allowed")
|
||||
}
|
||||
if !validISOFilename.MatchString(filename) {
|
||||
return errors.New("invalid filename")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) DownloadImage(c *gin.Context) {
|
||||
var req proto.MountImageReq
|
||||
var req proto.DownloadImageReq
|
||||
var rsp proto.Response
|
||||
|
||||
log.Debug("DownloadImage")
|
||||
@@ -291,127 +330,190 @@ func (s *Service) DownloadImage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.File == "" {
|
||||
rsp.ErrRsp(c, -1, "invalid arguments")
|
||||
expectedSHA256, err := parseSHA256(req.SHA256Sum)
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
}
|
||||
// Parse the URI to see if its valid http/s
|
||||
|
||||
u, err := url.Parse(req.File)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.Path == "" {
|
||||
rsp.ErrRsp(c, -1, "invalid url")
|
||||
return
|
||||
}
|
||||
filename := filepath.Base(u.Path)
|
||||
if filename == "." || filename == "/" || filename == "" {
|
||||
rsp.ErrRsp(c, -1, "invalid url")
|
||||
return
|
||||
}
|
||||
|
||||
// Set a sentinel file to mark that there is a download in progress
|
||||
// This is to prevent multiple downloads at the same time
|
||||
if _, err := os.Stat(sentinelPath); err == nil {
|
||||
log.Debug("Download in progress")
|
||||
rsp.ErrRsp(c, -1, "download in progress")
|
||||
return
|
||||
}
|
||||
// Create the sentinel file
|
||||
err = os.WriteFile(sentinelPath, []byte(req.File), 0644)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done, err := s.beginDownload(req.File, cancel)
|
||||
if err != nil {
|
||||
log.Error("Failed to create sentinel file")
|
||||
rsp.ErrRsp(c, -1, "failed to create sentinel file")
|
||||
cancel()
|
||||
rsp.ErrRsp(c, -1, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Check if it actually exists and fail if it doesn't
|
||||
resp, err := http.Head(req.File)
|
||||
if resp.StatusCode != http.StatusOK || err != nil {
|
||||
rsp.ErrRsp(c, resp.StatusCode, "failed when checking the url")
|
||||
log.Error("Failed to check the URL")
|
||||
defer os.Remove(sentinelPath)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Download the image in a goroutine to not block the request
|
||||
go func() {
|
||||
defer os.Remove(sentinelPath)
|
||||
resp, err = http.Get(req.File)
|
||||
if err != nil {
|
||||
log.Error("Failed to download the file")
|
||||
rsp.ErrRsp(c, -1, "failed to download the file")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Create the destination file
|
||||
destPath := filepath.Join("/data", filepath.Base(u.Path))
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
log.Error("Failed to create destination file")
|
||||
rsp.ErrRsp(c, -1, "failed to create destination file")
|
||||
return
|
||||
}
|
||||
defer out.Close()
|
||||
defer cancel()
|
||||
|
||||
lw := &loggingWriter{writer: out, totalSize: resp.ContentLength}
|
||||
lw.startTicker()
|
||||
_, err = io.Copy(lw, resp.Body)
|
||||
if err != nil {
|
||||
log.Error("Failed to save the file")
|
||||
rsp.ErrRsp(c, -1, "failed to save the file")
|
||||
lw.stopTicker()
|
||||
if err := s.downloadRemoteImage(ctx, req.File, expectedSHA256, filename, func(percentage string) {
|
||||
s.setDownloadProgress(done, percentage)
|
||||
}); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
log.Debug("Image download canceled")
|
||||
s.finishDownload(done, downloadStatusIdle)
|
||||
return
|
||||
}
|
||||
|
||||
log.Errorf("Failed to download image: %v", err)
|
||||
status := downloadStatusFailed
|
||||
if errors.Is(err, errSHA256Mismatch) {
|
||||
status = downloadStatusChecksumFailed
|
||||
}
|
||||
s.finishDownload(done, status)
|
||||
return
|
||||
}
|
||||
lw.stopTicker()
|
||||
s.finishDownload(done, downloadStatusSuccess)
|
||||
}()
|
||||
|
||||
rsp.OkRspWithData(c, &proto.StatusImageRsp{
|
||||
Status: "in_progress",
|
||||
Status: string(downloadStatusInProgress),
|
||||
File: req.File,
|
||||
Percentage: "",
|
||||
})
|
||||
}
|
||||
|
||||
func parseSHA256(value string) ([]byte, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
sum, err := hex.DecodeString(value)
|
||||
if err != nil || len(sum) != sha256.Size {
|
||||
return nil, errors.New("invalid sha256sum")
|
||||
}
|
||||
|
||||
return sum, nil
|
||||
}
|
||||
|
||||
func (s *Service) downloadRemoteImage(
|
||||
ctx context.Context,
|
||||
rawURL string,
|
||||
expectedSHA256 []byte,
|
||||
filename string,
|
||||
onProgress func(string),
|
||||
) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create download request failed: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download request returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
tempFile, err := os.CreateTemp("/data", ".nanokvm-download-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary image failed: %w", err)
|
||||
}
|
||||
tempPath := tempFile.Name()
|
||||
defer os.Remove(tempPath)
|
||||
|
||||
hasher := sha256.New()
|
||||
lw := newLoggingWriter(io.MultiWriter(tempFile, hasher), resp.ContentLength, onProgress)
|
||||
_, copyErr := io.Copy(lw, resp.Body)
|
||||
lw.stopTicker()
|
||||
closeErr := tempFile.Close()
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if copyErr != nil {
|
||||
return fmt.Errorf("save downloaded image failed: %w", copyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close downloaded image failed: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSHA256 != nil && !bytes.Equal(hasher.Sum(nil), expectedSHA256) {
|
||||
return errSHA256Mismatch
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
destPath := filepath.Join("/data", filename)
|
||||
if err := os.Rename(tempPath, destPath); err != nil {
|
||||
return fmt.Errorf("install downloaded image failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type loggingWriter struct {
|
||||
writer io.Writer
|
||||
total int64
|
||||
totalSize int64
|
||||
ticker *time.Ticker
|
||||
done chan bool
|
||||
writer io.Writer
|
||||
total atomic.Int64
|
||||
totalSize int64
|
||||
ticker *time.Ticker
|
||||
done chan struct{}
|
||||
stopOnce sync.Once
|
||||
onProgress func(string)
|
||||
}
|
||||
|
||||
func newLoggingWriter(writer io.Writer, totalSize int64, onProgress func(string)) *loggingWriter {
|
||||
lw := &loggingWriter{
|
||||
writer: writer,
|
||||
totalSize: totalSize,
|
||||
onProgress: onProgress,
|
||||
}
|
||||
lw.startTicker()
|
||||
return lw
|
||||
}
|
||||
|
||||
func (lw *loggingWriter) startTicker() {
|
||||
lw.ticker = time.NewTicker(2500 * time.Millisecond)
|
||||
lw.done = make(chan bool)
|
||||
lw.done = make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-lw.done:
|
||||
return
|
||||
case <-lw.ticker.C:
|
||||
lw.updateSentinel()
|
||||
lw.updateProgress()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (lw *loggingWriter) stopTicker() {
|
||||
lw.ticker.Stop()
|
||||
lw.done <- true
|
||||
if lw == nil {
|
||||
return
|
||||
}
|
||||
lw.stopOnce.Do(func() {
|
||||
lw.ticker.Stop()
|
||||
close(lw.done)
|
||||
})
|
||||
}
|
||||
|
||||
func (lw *loggingWriter) updateSentinel() {
|
||||
percentage := float64(lw.total) / float64(lw.totalSize) * 100
|
||||
content, err := os.ReadFile(sentinelPath)
|
||||
if err != nil {
|
||||
log.Error("Failed to read sentinel file")
|
||||
func (lw *loggingWriter) updateProgress() {
|
||||
if lw.totalSize <= 0 || lw.onProgress == nil {
|
||||
return
|
||||
}
|
||||
splitted := strings.Split(string(content), ";")
|
||||
if len(splitted) == 0 {
|
||||
return
|
||||
}
|
||||
err = os.WriteFile(sentinelPath, []byte(fmt.Sprintf("%s;%.2f%%", splitted[0], percentage)), 0644)
|
||||
if err != nil {
|
||||
log.Error("Failed to update sentinel file")
|
||||
}
|
||||
|
||||
percentage := float64(lw.total.Load()) / float64(lw.totalSize) * 100
|
||||
lw.onProgress(fmt.Sprintf("%.2f%%", percentage))
|
||||
}
|
||||
|
||||
func (lw *loggingWriter) Write(p []byte) (int, error) {
|
||||
n, err := lw.writer.Write(p)
|
||||
lw.total += int64(n)
|
||||
lw.total.Add(int64(n))
|
||||
return n, err
|
||||
}
|
||||
|
||||
@@ -13,11 +13,17 @@ import (
|
||||
)
|
||||
|
||||
type Hid struct {
|
||||
g0 *os.File
|
||||
g1 *os.File
|
||||
g2 *os.File
|
||||
kbMutex sync.Mutex
|
||||
mouseMutex sync.Mutex
|
||||
g0 *os.File
|
||||
g0Reader *os.File
|
||||
g1 *os.File
|
||||
g2 *os.File
|
||||
ledReaderNotifyReader *os.File
|
||||
ledReaderNotifyWriter *os.File
|
||||
ledReaderNotifyReadFD int
|
||||
ledReaderNotifyWriteFD int
|
||||
kbMutex sync.Mutex
|
||||
mouseMutex sync.Mutex
|
||||
ledReaderStartOnce sync.Once
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -124,6 +130,15 @@ func (h *Hid) OpenNoLock() error {
|
||||
}
|
||||
}
|
||||
|
||||
if h.g0 != nil {
|
||||
if err := h.openKeyboardLedReaderNoLock(); err != nil {
|
||||
log.Errorf("open keyboard LED reader failed: %s", err)
|
||||
errs = append(errs, err)
|
||||
} else {
|
||||
h.startKeyboardLedReader()
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
@@ -162,6 +177,8 @@ func openNoLockWithRetry(open func() error, timeout, delay time.Duration) error
|
||||
}
|
||||
|
||||
func (h *Hid) CloseNoLock() {
|
||||
h.closeKeyboardLedReaderNoLock()
|
||||
|
||||
for _, device := range h.devices() {
|
||||
h.closeDeviceNoLock(device)
|
||||
}
|
||||
@@ -193,6 +210,41 @@ func (h *Hid) closeDeviceNoLock(device hidDevice) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hid) openKeyboardLedReaderNoLock() error {
|
||||
if h.g0Reader != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := h.ensureKeyboardLedReaderNotifierNoLock(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Keep this descriptor blocking. The LED reader waits for either an output
|
||||
// report or a lifecycle notification, so an idle host does not cause a
|
||||
// periodic EAGAIN retry loop.
|
||||
file, err := os.OpenFile(HID0, os.O_RDONLY, 0o666)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", HID0, err)
|
||||
}
|
||||
|
||||
h.g0Reader = file
|
||||
h.notifyKeyboardLedReaderNoLock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Hid) closeKeyboardLedReaderNoLock() {
|
||||
if h.g0Reader == nil {
|
||||
return
|
||||
}
|
||||
|
||||
file := h.g0Reader
|
||||
h.g0Reader = nil
|
||||
h.notifyKeyboardLedReaderNoLock()
|
||||
if err := file.Close(); err != nil {
|
||||
log.Debugf("close keyboard LED reader failed: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeWithTimeout bounds how long callers hold HID locks when writing to a
|
||||
// nonblocking descriptor. EAGAIN means the host is not accepting HID reports
|
||||
// yet, so retry until the caller's deadline expires.
|
||||
@@ -249,15 +301,42 @@ func (h *Hid) Close() {
|
||||
}
|
||||
|
||||
func (h *Hid) WriteHid0(data []byte) {
|
||||
h.writeHIDReport(h.keyboardDevice(HID0), data)
|
||||
if err := h.WriteKeyboardReport(data); err != nil {
|
||||
log.Errorf("write to %s failed: %s", HID0, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hid) WriteHid1(data []byte) {
|
||||
h.writeHIDReport(h.relativeMouseDevice(HID1), data)
|
||||
if err := h.WriteRelativeMouseReport(data); err != nil {
|
||||
log.Errorf("write to %s failed: %s", HID1, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hid) WriteHid2(data []byte) {
|
||||
h.writeHIDReport(h.absoluteMouseDevice(HID2), data)
|
||||
if err := h.WriteAbsoluteMouseReport(data); err != nil {
|
||||
log.Errorf("write to %s failed: %s", HID2, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hid) WriteKeyboardReport(data []byte) error {
|
||||
if len(data) != 8 {
|
||||
return fmt.Errorf("invalid keyboard report length: %d", len(data))
|
||||
}
|
||||
return h.writeHID(h.keyboardDevice(HID0), data)
|
||||
}
|
||||
|
||||
func (h *Hid) WriteRelativeMouseReport(data []byte) error {
|
||||
if len(data) != 4 {
|
||||
return fmt.Errorf("invalid relative mouse report length: %d", len(data))
|
||||
}
|
||||
return h.writeHID(h.relativeMouseDevice(HID1), data)
|
||||
}
|
||||
|
||||
func (h *Hid) WriteAbsoluteMouseReport(data []byte) error {
|
||||
if len(data) != 6 {
|
||||
return fmt.Errorf("invalid absolute mouse report length: %d", len(data))
|
||||
}
|
||||
return h.writeHID(h.absoluteMouseDevice(HID2), data)
|
||||
}
|
||||
|
||||
func (h *Hid) writeHIDReport(device hidDevice, data []byte) bool {
|
||||
@@ -275,6 +354,13 @@ func (h *Hid) writeHID(device hidDevice, data []byte) error {
|
||||
if err := h.openDeviceNoLock(device); err != nil {
|
||||
return err
|
||||
}
|
||||
if device.path == HID0 {
|
||||
if err := h.openKeyboardLedReaderNoLock(); err != nil {
|
||||
log.Debugf("open keyboard LED reader failed: %s", err)
|
||||
} else {
|
||||
h.startKeyboardLedReader()
|
||||
}
|
||||
}
|
||||
|
||||
file := device.get()
|
||||
if file == nil {
|
||||
@@ -287,6 +373,9 @@ func (h *Hid) writeHID(device hidDevice, data []byte) error {
|
||||
}
|
||||
|
||||
if err := writeWithTimeout(file, data, hidWriteTimeout); err != nil {
|
||||
if device.path == HID0 {
|
||||
h.closeKeyboardLedReaderNoLock()
|
||||
}
|
||||
h.closeDeviceNoLock(device)
|
||||
switch {
|
||||
case errors.Is(err, os.ErrClosed):
|
||||
|
||||
28
server/service/hid/hid_test.go
Normal file
28
server/service/hid/hid_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package hid
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReportLengthValidation(t *testing.T) {
|
||||
h := &Hid{}
|
||||
if err := h.WriteKeyboardReport(make([]byte, 7)); err == nil {
|
||||
t.Fatal("expected keyboard length error")
|
||||
}
|
||||
if err := h.WriteRelativeMouseReport(make([]byte, 5)); err == nil {
|
||||
t.Fatal("expected relative mouse length error")
|
||||
}
|
||||
if err := h.WriteAbsoluteMouseReport(make([]byte, 7)); err == nil {
|
||||
t.Fatal("expected absolute mouse length error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasteDurationLeavesModeSwitchMargin(t *testing.T) {
|
||||
if maxPasteDuration >= 30*time.Second {
|
||||
t.Fatalf("maxPasteDuration = %s, want below 30s mode switch wait budget", maxPasteDuration)
|
||||
}
|
||||
if got := time.Duration(maxPasteContentRunes) * defaultPasteDelay; got > maxPasteDuration {
|
||||
t.Fatalf("max paste content duration = %s, want <= %s", got, maxPasteDuration)
|
||||
}
|
||||
}
|
||||
@@ -5,43 +5,105 @@ import (
|
||||
)
|
||||
|
||||
func (h *Hid) Keyboard(queue <-chan []byte) {
|
||||
h.keyboard(queue, HID0)
|
||||
legacy := make(chan QueuedReport)
|
||||
go func() {
|
||||
defer close(legacy)
|
||||
for report := range queue {
|
||||
legacy <- QueuedReport{Data: report}
|
||||
}
|
||||
}()
|
||||
h.KeyboardReports(legacy)
|
||||
}
|
||||
|
||||
func (h *Hid) keyboard(queue <-chan []byte, path string) {
|
||||
defer h.releaseKeyboard(path)
|
||||
func (h *Hid) KeyboardReports(queue <-chan QueuedReport) {
|
||||
h.keyboardReports(queue, HID0)
|
||||
}
|
||||
|
||||
func (h *Hid) keyboardReports(queue <-chan QueuedReport, path string) {
|
||||
var execute func(func() error) error
|
||||
var resetKeyboard func()
|
||||
keyboardActive := false
|
||||
defer func() {
|
||||
if !keyboardActive {
|
||||
return
|
||||
}
|
||||
if err := runCleanup(execute, func() error {
|
||||
return h.writeHID(h.keyboardDevice(path), keyboardReleaseReport())
|
||||
}); err != nil {
|
||||
log.Errorf("release keyboard on queue close failed: %s", err)
|
||||
return
|
||||
}
|
||||
if resetKeyboard != nil {
|
||||
resetKeyboard()
|
||||
}
|
||||
}()
|
||||
|
||||
for event := range queue {
|
||||
if len(event) != 8 {
|
||||
log.Debugf("invalid keyboard event: %v", event)
|
||||
execute = event.Execute
|
||||
resetKeyboard = event.ResetKeyboard
|
||||
if len(event.Data) != 8 {
|
||||
event.complete(false)
|
||||
log.Debugf("invalid keyboard event: %v", event.Data)
|
||||
continue
|
||||
}
|
||||
|
||||
if !h.writeHIDReport(h.keyboardDevice(path), event) {
|
||||
if err := event.run(func() error {
|
||||
return h.writeHID(h.keyboardDevice(path), event.Data)
|
||||
}); err != nil {
|
||||
log.Errorf("write to %s failed: %s", path, err)
|
||||
if dropped := drainHIDQueue(queue); dropped > 0 {
|
||||
log.Debugf("dropped %d stale keyboard HID reports after write failure", dropped)
|
||||
}
|
||||
h.releaseKeyboard(path)
|
||||
cleanupErr := runCleanup(execute, func() error {
|
||||
return h.writeHID(h.keyboardDevice(path), keyboardReleaseReport())
|
||||
})
|
||||
if cleanupErr != nil {
|
||||
log.Errorf("release keyboard after write failure failed: %s", cleanupErr)
|
||||
keyboardActive = keyboardActive || keyboardReportActive(event.Data)
|
||||
event.complete(false)
|
||||
continue
|
||||
}
|
||||
keyboardActive = false
|
||||
if resetKeyboard != nil {
|
||||
resetKeyboard()
|
||||
}
|
||||
event.complete(false)
|
||||
continue
|
||||
}
|
||||
|
||||
keyboardActive = keyboardReportActive(event.Data)
|
||||
event.complete(true)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hid) releaseKeyboard(path string) {
|
||||
h.writeHIDReport(h.keyboardDevice(path), keyboardReleaseReport())
|
||||
func keyboardReportActive(report []byte) bool {
|
||||
if len(report) != 8 {
|
||||
return false
|
||||
}
|
||||
if report[0] != 0 {
|
||||
return true
|
||||
}
|
||||
for _, key := range report[2:] {
|
||||
if key != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func keyboardReleaseReport() []byte {
|
||||
return []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
}
|
||||
|
||||
func drainHIDQueue(queue <-chan []byte) int {
|
||||
func drainHIDQueue(queue <-chan QueuedReport) int {
|
||||
dropped := 0
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-queue:
|
||||
case report, ok := <-queue:
|
||||
if !ok {
|
||||
return dropped
|
||||
}
|
||||
report.complete(false)
|
||||
dropped++
|
||||
default:
|
||||
return dropped
|
||||
|
||||
279
server/service/hid/leds.go
Normal file
279
server/service/hid/leds.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package hid
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
const (
|
||||
KeyboardLedStatusEvent = "hid-led-status"
|
||||
)
|
||||
|
||||
// KeyboardLedStatus is the lock-key LED state last reported by the remote host
|
||||
// through the keyboard HID output report.
|
||||
type KeyboardLedStatus struct {
|
||||
NumLock bool `json:"numLock"`
|
||||
CapsLock bool `json:"capsLock"`
|
||||
ScrollLock bool `json:"scrollLock"`
|
||||
Known bool `json:"known"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type keyboardLedStatusStore struct {
|
||||
mutex sync.RWMutex
|
||||
status KeyboardLedStatus
|
||||
subscribers map[int]func(KeyboardLedStatus)
|
||||
nextID int
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
var keyboardLeds = newKeyboardLedStatusStore(time.Now)
|
||||
|
||||
func newKeyboardLedStatusStore(now func() time.Time) *keyboardLedStatusStore {
|
||||
return &keyboardLedStatusStore{
|
||||
subscribers: make(map[int]func(KeyboardLedStatus)),
|
||||
now: now,
|
||||
}
|
||||
}
|
||||
|
||||
// GetKeyboardLedStatus returns a snapshot. Known is false until the host has
|
||||
// sent at least one keyboard HID output report.
|
||||
func GetKeyboardLedStatus() KeyboardLedStatus {
|
||||
return keyboardLeds.Get()
|
||||
}
|
||||
|
||||
// SubscribeKeyboardLedStatus subscribes to state changes and returns a
|
||||
// function that removes the subscription.
|
||||
func SubscribeKeyboardLedStatus(subscriber func(KeyboardLedStatus)) func() {
|
||||
return keyboardLeds.Subscribe(subscriber)
|
||||
}
|
||||
|
||||
func (s *keyboardLedStatusStore) Get() KeyboardLedStatus {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
return s.status
|
||||
}
|
||||
|
||||
func (s *keyboardLedStatusStore) Subscribe(subscriber func(KeyboardLedStatus)) func() {
|
||||
s.mutex.Lock()
|
||||
id := s.nextID
|
||||
s.nextID++
|
||||
s.subscribers[id] = subscriber
|
||||
s.mutex.Unlock()
|
||||
|
||||
return func() {
|
||||
s.mutex.Lock()
|
||||
delete(s.subscribers, id)
|
||||
s.mutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *keyboardLedStatusStore) Update(report byte) {
|
||||
next := keyboardLedStatusFromReport(report, s.now())
|
||||
|
||||
s.mutex.Lock()
|
||||
previous := s.status
|
||||
s.status = next
|
||||
if sameKeyboardLedState(previous, next) {
|
||||
s.mutex.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
subscribers := make([]func(KeyboardLedStatus), 0, len(s.subscribers))
|
||||
for _, subscriber := range s.subscribers {
|
||||
subscribers = append(subscribers, subscriber)
|
||||
}
|
||||
s.mutex.Unlock()
|
||||
|
||||
for _, subscriber := range subscribers {
|
||||
subscriber(next)
|
||||
}
|
||||
}
|
||||
|
||||
func keyboardLedStatusFromReport(report byte, updatedAt time.Time) KeyboardLedStatus {
|
||||
return KeyboardLedStatus{
|
||||
NumLock: report&0x01 != 0,
|
||||
CapsLock: report&0x02 != 0,
|
||||
ScrollLock: report&0x04 != 0,
|
||||
Known: true,
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func sameKeyboardLedState(a, b KeyboardLedStatus) bool {
|
||||
return a.NumLock == b.NumLock &&
|
||||
a.CapsLock == b.CapsLock &&
|
||||
a.ScrollLock == b.ScrollLock &&
|
||||
a.Known == b.Known
|
||||
}
|
||||
|
||||
func (h *Hid) startKeyboardLedReader() {
|
||||
h.ledReaderStartOnce.Do(func() {
|
||||
go h.readKeyboardLeds()
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Hid) readKeyboardLeds() {
|
||||
buf := make([]byte, 64)
|
||||
|
||||
for {
|
||||
file, notifierFD := h.keyboardLedReaderHandles()
|
||||
if file == nil {
|
||||
if err := waitForKeyboardLedReaderChange(notifierFD); err != nil {
|
||||
log.Debugf("wait for keyboard LED reader failed: %s", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
changed, err := waitForKeyboardLedReportOrChange(file, notifierFD)
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrClosed) {
|
||||
log.Debugf("wait for keyboard LED report failed: %s", err)
|
||||
}
|
||||
h.reopenKeyboardLedReader(file)
|
||||
continue
|
||||
}
|
||||
if changed {
|
||||
continue
|
||||
}
|
||||
|
||||
n, err := file.Read(buf)
|
||||
if n > 0 {
|
||||
// Both keyboard gadget descriptors in kvmapp/system/init.d declare
|
||||
// boot-keyboard reports without a Report ID (no 0x85 item). Their LED
|
||||
// output report is one byte: bits 0-4 are the LED bitmap and bits 5-7
|
||||
// are padding. report_length is 8 because it also covers input reports.
|
||||
keyboardLeds.Update(buf[0])
|
||||
}
|
||||
|
||||
if err == nil && n > 0 {
|
||||
continue
|
||||
}
|
||||
if err != nil && !errors.Is(err, os.ErrClosed) {
|
||||
log.Debugf("read keyboard LED report failed: %s", err)
|
||||
}
|
||||
h.reopenKeyboardLedReader(file)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hid) keyboardLedReaderHandles() (*os.File, int) {
|
||||
h.kbMutex.Lock()
|
||||
defer h.kbMutex.Unlock()
|
||||
if h.ledReaderNotifyReader == nil {
|
||||
return h.g0Reader, -1
|
||||
}
|
||||
return h.g0Reader, h.ledReaderNotifyReadFD
|
||||
}
|
||||
|
||||
func (h *Hid) reopenKeyboardLedReader(file *os.File) {
|
||||
h.kbMutex.Lock()
|
||||
defer h.kbMutex.Unlock()
|
||||
|
||||
// A reset may have installed a new reader while the old descriptor was
|
||||
// reporting an error. Never let that stale reader close the replacement.
|
||||
if h.g0Reader != file {
|
||||
return
|
||||
}
|
||||
h.closeKeyboardLedReaderNoLock()
|
||||
if h.g0 == nil {
|
||||
return
|
||||
}
|
||||
if err := h.openKeyboardLedReaderNoLock(); err != nil {
|
||||
log.Debugf("reopen keyboard LED reader failed: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hid) ensureKeyboardLedReaderNotifierNoLock() error {
|
||||
if h.ledReaderNotifyReader != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
reader, writer, err := os.Pipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create keyboard LED reader notifier: %w", err)
|
||||
}
|
||||
readerFD := int(reader.Fd())
|
||||
writerFD := int(writer.Fd())
|
||||
if err := unix.SetNonblock(readerFD, true); err != nil {
|
||||
_ = reader.Close()
|
||||
_ = writer.Close()
|
||||
return fmt.Errorf("set keyboard LED reader notifier read end nonblocking: %w", err)
|
||||
}
|
||||
if err := unix.SetNonblock(writerFD, true); err != nil {
|
||||
_ = reader.Close()
|
||||
_ = writer.Close()
|
||||
return fmt.Errorf("set keyboard LED reader notifier write end nonblocking: %w", err)
|
||||
}
|
||||
h.ledReaderNotifyReader = reader
|
||||
h.ledReaderNotifyWriter = writer
|
||||
h.ledReaderNotifyReadFD = readerFD
|
||||
h.ledReaderNotifyWriteFD = writerFD
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Hid) notifyKeyboardLedReaderNoLock() {
|
||||
if h.ledReaderNotifyWriter == nil {
|
||||
return
|
||||
}
|
||||
_, err := unix.Write(h.ledReaderNotifyWriteFD, []byte{1})
|
||||
if err != nil && !errors.Is(err, syscall.EAGAIN) && !errors.Is(err, syscall.EWOULDBLOCK) {
|
||||
log.Debugf("notify keyboard LED reader failed: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForKeyboardLedReaderChange(notifierFD int) error {
|
||||
if notifierFD < 0 {
|
||||
return fmt.Errorf("keyboard LED reader notifier is nil")
|
||||
}
|
||||
_, err := unix.Poll([]unix.PollFd{{Fd: int32(notifierFD), Events: unix.POLLIN}}, -1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
drainKeyboardLedReaderNotifier(notifierFD)
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForKeyboardLedReportOrChange blocks until a HID output report arrives or
|
||||
// the reader is replaced/closed. Its bool result is true for the latter.
|
||||
func waitForKeyboardLedReportOrChange(file *os.File, notifierFD int) (bool, error) {
|
||||
if file == nil || notifierFD < 0 {
|
||||
return false, fmt.Errorf("keyboard LED reader or notifier is nil")
|
||||
}
|
||||
|
||||
fds := []unix.PollFd{
|
||||
{Fd: int32(file.Fd()), Events: unix.POLLIN},
|
||||
{Fd: int32(notifierFD), Events: unix.POLLIN},
|
||||
}
|
||||
_, err := unix.Poll(fds, -1)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if fds[1].Revents&unix.POLLIN != 0 {
|
||||
drainKeyboardLedReaderNotifier(notifierFD)
|
||||
return true, nil
|
||||
}
|
||||
if fds[0].Revents&(unix.POLLIN|unix.POLLERR|unix.POLLHUP|unix.POLLNVAL) != 0 {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("keyboard LED poll returned without an event")
|
||||
}
|
||||
|
||||
func drainKeyboardLedReaderNotifier(notifierFD int) {
|
||||
buf := make([]byte, 64)
|
||||
for {
|
||||
_, err := unix.Read(notifierFD, buf)
|
||||
if err != nil {
|
||||
if !errors.Is(err, syscall.EAGAIN) && !errors.Is(err, syscall.EWOULDBLOCK) {
|
||||
log.Debugf("drain keyboard LED reader notifier failed: %s", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
168
server/service/hid/leds_test.go
Normal file
168
server/service/hid/leds_test.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package hid
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestKeyboardLedStatusFromReport(t *testing.T) {
|
||||
updatedAt := time.Date(2026, time.July, 24, 12, 0, 0, 0, time.UTC)
|
||||
status := keyboardLedStatusFromReport(0x17, updatedAt)
|
||||
|
||||
if !status.NumLock || !status.CapsLock || !status.ScrollLock || !status.Known {
|
||||
t.Fatalf("unexpected parsed status: %+v", status)
|
||||
}
|
||||
if !status.UpdatedAt.Equal(updatedAt) {
|
||||
t.Fatalf("updatedAt = %s, want %s", status.UpdatedAt, updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyboardLedStatusStoreOnlyNotifiesStateChanges(t *testing.T) {
|
||||
now := time.Date(2026, time.July, 24, 12, 0, 0, 0, time.UTC)
|
||||
store := newKeyboardLedStatusStore(func() time.Time { return now })
|
||||
notifications := make(chan KeyboardLedStatus, 2)
|
||||
store.Subscribe(func(status KeyboardLedStatus) {
|
||||
notifications <- status
|
||||
})
|
||||
|
||||
store.Update(0x01)
|
||||
store.Update(0x01)
|
||||
now = now.Add(time.Second)
|
||||
store.Update(0x03)
|
||||
|
||||
first := <-notifications
|
||||
second := <-notifications
|
||||
if !first.NumLock || first.CapsLock || !second.NumLock || !second.CapsLock {
|
||||
t.Fatalf("unexpected notifications: first=%+v second=%+v", first, second)
|
||||
}
|
||||
|
||||
select {
|
||||
case status := <-notifications:
|
||||
t.Fatalf("unexpected duplicate notification: %+v", status)
|
||||
default:
|
||||
}
|
||||
|
||||
if status := store.Get(); !status.UpdatedAt.Equal(now) {
|
||||
t.Fatalf("latest updatedAt = %s, want %s", status.UpdatedAt, now)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForKeyboardLedReaderChangeWaitsForNotification(t *testing.T) {
|
||||
h := &Hid{}
|
||||
h.kbMutex.Lock()
|
||||
if err := h.ensureKeyboardLedReaderNotifierNoLock(); err != nil {
|
||||
h.kbMutex.Unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifierFD := h.ledReaderNotifyReadFD
|
||||
h.kbMutex.Unlock()
|
||||
defer h.ledReaderNotifyReader.Close()
|
||||
defer h.ledReaderNotifyWriter.Close()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- waitForKeyboardLedReaderChange(notifierFD) }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("wait returned before a notification: %v", err)
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
|
||||
h.kbMutex.Lock()
|
||||
h.notifyKeyboardLedReaderNoLock()
|
||||
h.kbMutex.Unlock()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("wait returned error: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("wait did not wake after a notification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForKeyboardLedReportOrChangeReturnsForReport(t *testing.T) {
|
||||
reader, writer, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reader.Close()
|
||||
defer writer.Close()
|
||||
|
||||
h := &Hid{}
|
||||
h.kbMutex.Lock()
|
||||
if err := h.ensureKeyboardLedReaderNotifierNoLock(); err != nil {
|
||||
h.kbMutex.Unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifierFD := h.ledReaderNotifyReadFD
|
||||
h.kbMutex.Unlock()
|
||||
defer h.ledReaderNotifyReader.Close()
|
||||
defer h.ledReaderNotifyWriter.Close()
|
||||
|
||||
done := make(chan struct {
|
||||
changed bool
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
changed, err := waitForKeyboardLedReportOrChange(reader, notifierFD)
|
||||
done <- struct {
|
||||
changed bool
|
||||
err error
|
||||
}{changed, err}
|
||||
}()
|
||||
|
||||
if _, err := writer.Write([]byte{0x03}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case result := <-done:
|
||||
if result.err != nil || result.changed {
|
||||
t.Fatalf("result = changed=%t, err=%v; want report", result.changed, result.err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("wait did not wake for report")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReopenKeyboardLedReaderDoesNotCloseReplacement(t *testing.T) {
|
||||
oldReader, oldWriter, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer oldReader.Close()
|
||||
defer oldWriter.Close()
|
||||
replacement, replacementWriter, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer replacement.Close()
|
||||
defer replacementWriter.Close()
|
||||
|
||||
h := &Hid{g0Reader: oldReader}
|
||||
h.kbMutex.Lock()
|
||||
if err := h.ensureKeyboardLedReaderNotifierNoLock(); err != nil {
|
||||
h.kbMutex.Unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.g0Reader = replacement // Simulate reset/reopen replacing the reader.
|
||||
h.kbMutex.Unlock()
|
||||
defer h.ledReaderNotifyReader.Close()
|
||||
defer h.ledReaderNotifyWriter.Close()
|
||||
|
||||
h.reopenKeyboardLedReader(oldReader)
|
||||
current, _ := h.keyboardLedReaderHandles()
|
||||
if current != replacement {
|
||||
t.Fatal("stale reader close replaced the active reader")
|
||||
}
|
||||
if _, err := replacementWriter.Write([]byte{0x01}); err != nil {
|
||||
t.Fatalf("replacement reader was closed: %v", err)
|
||||
}
|
||||
buf := make([]byte, 1)
|
||||
if _, err := replacement.Read(buf); err != nil {
|
||||
t.Fatalf("replacement reader was closed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -5,59 +5,153 @@ import (
|
||||
)
|
||||
|
||||
func (h *Hid) Mouse(queue <-chan []byte) {
|
||||
h.mouse(queue, HID1, HID2)
|
||||
legacy := make(chan QueuedReport)
|
||||
go func() {
|
||||
defer close(legacy)
|
||||
for report := range queue {
|
||||
legacy <- QueuedReport{Data: report}
|
||||
}
|
||||
}()
|
||||
h.MouseReports(legacy)
|
||||
}
|
||||
|
||||
func (h *Hid) mouse(queue <-chan []byte, relativePath string, absolutePath string) {
|
||||
defer h.releaseRelativeMouse(relativePath)
|
||||
func (h *Hid) MouseReports(queue <-chan QueuedReport) {
|
||||
h.mouseReports(queue, HID1, HID2)
|
||||
}
|
||||
|
||||
func (h *Hid) mouseReports(queue <-chan QueuedReport, relativePath string, absolutePath string) {
|
||||
var execute func(func() error) error
|
||||
var resetRelativeMouse func()
|
||||
var resetAbsoluteMouse func()
|
||||
relativeButtonsActive := false
|
||||
absoluteButtonsActive := false
|
||||
var absoluteReleaseReport []byte
|
||||
absoluteReleaseReport := absoluteMouseReleaseReport(nil)
|
||||
defer func() {
|
||||
if relativeButtonsActive {
|
||||
if err := runCleanup(execute, func() error {
|
||||
return h.writeHID(h.relativeMouseDevice(relativePath), relativeMouseReleaseReport())
|
||||
}); err != nil {
|
||||
log.Errorf("release relative mouse on queue close failed: %s", err)
|
||||
} else if resetRelativeMouse != nil {
|
||||
resetRelativeMouse()
|
||||
}
|
||||
}
|
||||
if absoluteButtonsActive {
|
||||
h.releaseAbsoluteMouse(absolutePath, absoluteReleaseReport)
|
||||
if err := runCleanup(execute, func() error {
|
||||
return h.writeHID(h.absoluteMouseDevice(absolutePath), absoluteReleaseReport)
|
||||
}); err != nil {
|
||||
log.Errorf("release absolute mouse on queue close failed: %s", err)
|
||||
} else if resetAbsoluteMouse != nil {
|
||||
resetAbsoluteMouse()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for event := range queue {
|
||||
switch len(event) {
|
||||
case 4:
|
||||
if !h.writeHIDReport(h.relativeMouseDevice(relativePath), event) {
|
||||
if dropped := drainHIDQueue(queue); dropped > 0 {
|
||||
log.Debugf("dropped %d stale mouse HID reports after relative write failure", dropped)
|
||||
}
|
||||
h.releaseRelativeMouse(relativePath)
|
||||
execute = event.Execute
|
||||
resetRelativeMouse = event.ResetRelativeMouse
|
||||
resetAbsoluteMouse = event.ResetAbsoluteMouse
|
||||
|
||||
cleanupFailure := func(writeErr error) {
|
||||
log.Errorf("mouse HID write failed: %s", writeErr)
|
||||
if dropped := drainHIDQueue(queue); dropped > 0 {
|
||||
log.Debugf("dropped %d stale mouse HID reports after write failure", dropped)
|
||||
}
|
||||
case 6:
|
||||
if !h.writeHIDReport(h.absoluteMouseDevice(absolutePath), event) {
|
||||
if dropped := drainHIDQueue(queue); dropped > 0 {
|
||||
log.Debugf("dropped %d stale mouse HID reports after absolute write failure", dropped)
|
||||
}
|
||||
if absoluteButtonsActive {
|
||||
if h.releaseAbsoluteMouse(absolutePath, absoluteReleaseReport) {
|
||||
absoluteButtonsActive = false
|
||||
|
||||
if len(event.Data) == 4 && event.Data[0] != 0 {
|
||||
relativeButtonsActive = true
|
||||
}
|
||||
if len(event.Data) == 6 && event.Data[0] != 0 {
|
||||
absoluteButtonsActive = true
|
||||
absoluteReleaseReport = absoluteMouseReleaseReport(event.Data)
|
||||
}
|
||||
|
||||
if relativeButtonsActive || len(event.Data) == 4 {
|
||||
if err := runCleanup(execute, func() error {
|
||||
return h.writeHID(h.relativeMouseDevice(relativePath), relativeMouseReleaseReport())
|
||||
}); err != nil {
|
||||
log.Errorf("release relative mouse after write failure failed: %s", err)
|
||||
} else {
|
||||
relativeButtonsActive = false
|
||||
if resetRelativeMouse != nil {
|
||||
resetRelativeMouse()
|
||||
}
|
||||
} else if event[0] != 0 {
|
||||
h.releaseAbsoluteMouse(absolutePath, absoluteMouseReleaseReport(event))
|
||||
}
|
||||
}
|
||||
|
||||
if absoluteButtonsActive || len(event.Data) == 6 {
|
||||
releaseReport := absoluteReleaseReport
|
||||
if len(event.Data) == 6 {
|
||||
releaseReport = absoluteMouseReleaseReport(event.Data)
|
||||
}
|
||||
if err := runCleanup(execute, func() error {
|
||||
return h.writeHID(h.absoluteMouseDevice(absolutePath), releaseReport)
|
||||
}); err != nil {
|
||||
log.Errorf("release absolute mouse after write failure failed: %s", err)
|
||||
} else {
|
||||
absoluteButtonsActive = false
|
||||
if resetAbsoluteMouse != nil {
|
||||
resetAbsoluteMouse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
event.complete(false)
|
||||
}
|
||||
|
||||
switch len(event.Data) {
|
||||
case 4:
|
||||
if absoluteButtonsActive {
|
||||
if err := runCleanup(execute, func() error {
|
||||
return h.writeHID(h.absoluteMouseDevice(absolutePath), absoluteReleaseReport)
|
||||
}); err != nil {
|
||||
cleanupFailure(err)
|
||||
continue
|
||||
}
|
||||
absoluteButtonsActive = false
|
||||
if resetAbsoluteMouse != nil {
|
||||
resetAbsoluteMouse()
|
||||
}
|
||||
}
|
||||
|
||||
if err := event.run(func() error {
|
||||
return h.writeHID(h.relativeMouseDevice(relativePath), event.Data)
|
||||
}); err != nil {
|
||||
cleanupFailure(err)
|
||||
continue
|
||||
}
|
||||
absoluteReleaseReport = absoluteMouseReleaseReport(event)
|
||||
absoluteButtonsActive = event[0] != 0
|
||||
relativeButtonsActive = event.Data[0] != 0
|
||||
event.complete(true)
|
||||
case 6:
|
||||
if relativeButtonsActive {
|
||||
if err := runCleanup(execute, func() error {
|
||||
return h.writeHID(h.relativeMouseDevice(relativePath), relativeMouseReleaseReport())
|
||||
}); err != nil {
|
||||
cleanupFailure(err)
|
||||
continue
|
||||
}
|
||||
relativeButtonsActive = false
|
||||
if resetRelativeMouse != nil {
|
||||
resetRelativeMouse()
|
||||
}
|
||||
}
|
||||
|
||||
if err := event.run(func() error {
|
||||
return h.writeHID(h.absoluteMouseDevice(absolutePath), event.Data)
|
||||
}); err != nil {
|
||||
cleanupFailure(err)
|
||||
continue
|
||||
}
|
||||
absoluteReleaseReport = absoluteMouseReleaseReport(event.Data)
|
||||
absoluteButtonsActive = event.Data[0] != 0
|
||||
event.complete(true)
|
||||
default:
|
||||
log.Debugf("invalid mouse event: %v", event)
|
||||
event.complete(false)
|
||||
log.Debugf("invalid mouse event: %v", event.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hid) releaseRelativeMouse(path string) {
|
||||
h.writeHIDReport(h.relativeMouseDevice(path), relativeMouseReleaseReport())
|
||||
}
|
||||
|
||||
func (h *Hid) releaseAbsoluteMouse(path string, report []byte) bool {
|
||||
return h.writeHIDReport(h.absoluteMouseDevice(path), report)
|
||||
}
|
||||
|
||||
func relativeMouseReleaseReport() []byte {
|
||||
return []byte{0x00, 0x00, 0x00, 0x00}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package hid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/service/inputcontrol"
|
||||
)
|
||||
|
||||
type Char struct {
|
||||
@@ -19,6 +21,12 @@ type PasteReq struct {
|
||||
Langue string `form:"langue"`
|
||||
}
|
||||
|
||||
const (
|
||||
defaultPasteDelay = 30 * time.Millisecond
|
||||
maxPasteDuration = 25 * time.Second
|
||||
maxPasteContentRunes = int(maxPasteDuration / defaultPasteDelay)
|
||||
)
|
||||
|
||||
func LangueSwitch(base map[rune]Char, lang string) map[rune]Char {
|
||||
// if no language is specified → return base map
|
||||
if lang == "" {
|
||||
@@ -86,15 +94,15 @@ func LangueSwitch(base map[rune]Char, lang string) map[rune]Char {
|
||||
case "fr":
|
||||
// French AZERTY layout
|
||||
// Letters: a↔q swap, z↔w swap, m moved to physical ; position
|
||||
m['a'] = Char{0, 20} // a is at physical Q key (HID 20)
|
||||
m['a'] = Char{0, 20} // a is at physical Q key (HID 20)
|
||||
m['A'] = Char{2, 20}
|
||||
m['q'] = Char{0, 4} // q is at physical A key (HID 4)
|
||||
m['q'] = Char{0, 4} // q is at physical A key (HID 4)
|
||||
m['Q'] = Char{2, 4}
|
||||
m['z'] = Char{0, 26} // z is at physical W key (HID 26)
|
||||
m['z'] = Char{0, 26} // z is at physical W key (HID 26)
|
||||
m['Z'] = Char{2, 26}
|
||||
m['w'] = Char{0, 29} // w is at physical Z key (HID 29)
|
||||
m['w'] = Char{0, 29} // w is at physical Z key (HID 29)
|
||||
m['W'] = Char{2, 29}
|
||||
m['m'] = Char{0, 51} // m is at physical ; key (HID 51)
|
||||
m['m'] = Char{0, 51} // m is at physical ; key (HID 51)
|
||||
m['M'] = Char{2, 51}
|
||||
|
||||
// Numbers require Shift on AZERTY
|
||||
@@ -110,53 +118,53 @@ func LangueSwitch(base map[rune]Char, lang string) map[rune]Char {
|
||||
m['0'] = Char{2, 39}
|
||||
|
||||
// Unshifted number row → French/special characters
|
||||
m['&'] = Char{0, 30} // & at physical key 1
|
||||
m['\u00E9'] = Char{0, 31} // é at physical key 2
|
||||
m['"'] = Char{0, 32} // " at physical key 3
|
||||
m['\''] = Char{0, 33} // ' at physical key 4
|
||||
m['('] = Char{0, 34} // ( at physical key 5
|
||||
m['-'] = Char{0, 35} // - at physical key 6
|
||||
m['\u00E8'] = Char{0, 36} // è at physical key 7
|
||||
m['_'] = Char{0, 37} // _ at physical key 8
|
||||
m['\u00E7'] = Char{0, 38} // ç at physical key 9
|
||||
m['\u00E0'] = Char{0, 39} // à at physical key 0
|
||||
m['&'] = Char{0, 30} // & at physical key 1
|
||||
m['\u00E9'] = Char{0, 31} // é at physical key 2
|
||||
m['"'] = Char{0, 32} // " at physical key 3
|
||||
m['\''] = Char{0, 33} // ' at physical key 4
|
||||
m['('] = Char{0, 34} // ( at physical key 5
|
||||
m['-'] = Char{0, 35} // - at physical key 6
|
||||
m['\u00E8'] = Char{0, 36} // è at physical key 7
|
||||
m['_'] = Char{0, 37} // _ at physical key 8
|
||||
m['\u00E7'] = Char{0, 38} // ç at physical key 9
|
||||
m['\u00E0'] = Char{0, 39} // à at physical key 0
|
||||
|
||||
// Physical - key (HID 45) → ) on AZERTY
|
||||
m[')'] = Char{0, 45} // ) at physical - key
|
||||
m['\u00B0'] = Char{2, 45} // ° at shift+physical - key
|
||||
m[')'] = Char{0, 45} // ) at physical - key
|
||||
m['\u00B0'] = Char{2, 45} // ° at shift+physical - key
|
||||
|
||||
// Letter-row bracket/special keys
|
||||
m['^'] = Char{0, 47} // ^ (dead) at physical [ key (HID 47)
|
||||
m['\u00A8'] = Char{2, 47} // ¨ at shift+[
|
||||
m['$'] = Char{0, 48} // $ at physical ] key (HID 48)
|
||||
m['\u00A3'] = Char{2, 48} // £ at shift+]
|
||||
m['*'] = Char{0, 49} // * at physical \ key (HID 49)
|
||||
m['\u00B5'] = Char{2, 49} // µ at shift+\
|
||||
m['\u00F9'] = Char{0, 52} // ù at physical ' key (HID 52)
|
||||
m['%'] = Char{2, 52} // % at shift+'
|
||||
m['^'] = Char{0, 47} // ^ (dead) at physical [ key (HID 47)
|
||||
m['\u00A8'] = Char{2, 47} // ¨ at shift+[
|
||||
m['$'] = Char{0, 48} // $ at physical ] key (HID 48)
|
||||
m['\u00A3'] = Char{2, 48} // £ at shift+]
|
||||
m['*'] = Char{0, 49} // * at physical \ key (HID 49)
|
||||
m['\u00B5'] = Char{2, 49} // µ at shift+\
|
||||
m['\u00F9'] = Char{0, 52} // ù at physical ' key (HID 52)
|
||||
m['%'] = Char{2, 52} // % at shift+'
|
||||
|
||||
// Bottom row remappings
|
||||
m[','] = Char{0, 16} // , at physical M key (HID 16)
|
||||
m['?'] = Char{2, 16} // ? at shift+physical M
|
||||
m[';'] = Char{0, 54} // ; at physical , key (HID 54)
|
||||
m['.'] = Char{2, 54} // . at shift+physical ,
|
||||
m[':'] = Char{0, 55} // : at physical . key (HID 55)
|
||||
m['/'] = Char{2, 55} // / at shift+physical .
|
||||
m['!'] = Char{0, 56} // ! at physical / key (HID 56)
|
||||
m['\u00A7'] = Char{2, 56} // § at shift+physical /
|
||||
m[','] = Char{0, 16} // , at physical M key (HID 16)
|
||||
m['?'] = Char{2, 16} // ? at shift+physical M
|
||||
m[';'] = Char{0, 54} // ; at physical , key (HID 54)
|
||||
m['.'] = Char{2, 54} // . at shift+physical ,
|
||||
m[':'] = Char{0, 55} // : at physical . key (HID 55)
|
||||
m['/'] = Char{2, 55} // / at shift+physical .
|
||||
m['!'] = Char{0, 56} // ! at physical / key (HID 56)
|
||||
m['\u00A7'] = Char{2, 56} // § at shift+physical /
|
||||
|
||||
// AltGr combinations
|
||||
m['~'] = Char{0x40, 31} // AltGr+2
|
||||
m['#'] = Char{0x40, 32} // AltGr+3
|
||||
m['{'] = Char{0x40, 33} // AltGr+4
|
||||
m['['] = Char{0x40, 34} // AltGr+5
|
||||
m['|'] = Char{0x40, 35} // AltGr+6
|
||||
m['`'] = Char{0x40, 36} // AltGr+7
|
||||
m['\\'] = Char{0x40, 37} // AltGr+8
|
||||
m[']'] = Char{0x40, 45} // AltGr+physical -
|
||||
m['}'] = Char{0x40, 46} // AltGr+=
|
||||
m['@'] = Char{0x40, 39} // AltGr+0
|
||||
m['\u20AC'] = Char{0x40, 8} // € AltGr+E
|
||||
m['~'] = Char{0x40, 31} // AltGr+2
|
||||
m['#'] = Char{0x40, 32} // AltGr+3
|
||||
m['{'] = Char{0x40, 33} // AltGr+4
|
||||
m['['] = Char{0x40, 34} // AltGr+5
|
||||
m['|'] = Char{0x40, 35} // AltGr+6
|
||||
m['`'] = Char{0x40, 36} // AltGr+7
|
||||
m['\\'] = Char{0x40, 37} // AltGr+8
|
||||
m[']'] = Char{0x40, 45} // AltGr+physical -
|
||||
m['}'] = Char{0x40, 46} // AltGr+=
|
||||
m['@'] = Char{0x40, 39} // AltGr+0
|
||||
m['\u20AC'] = Char{0x40, 8} // € AltGr+E
|
||||
|
||||
}
|
||||
return m
|
||||
@@ -171,16 +179,44 @@ func (s *Service) Paste(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Content) > 1024 {
|
||||
contentRunes := []rune(req.Content)
|
||||
if len(contentRunes) > maxPasteContentRunes {
|
||||
rsp.ErrRsp(c, -2, "content too long")
|
||||
return
|
||||
}
|
||||
|
||||
charMapLocal := LangueSwitch(charMap, req.Langue)
|
||||
typeableRunes := 0
|
||||
for _, char := range contentRunes {
|
||||
if _, ok := charMapLocal[char]; ok {
|
||||
typeableRunes++
|
||||
}
|
||||
}
|
||||
if time.Duration(typeableRunes)*defaultPasteDelay > maxPasteDuration {
|
||||
rsp.ErrRsp(c, -2, "paste duration exceeds 25s")
|
||||
return
|
||||
}
|
||||
|
||||
keyUp := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
manual := s.newManualSession()
|
||||
defer manual.Close()
|
||||
reservation, err := manual.Reserve(c.Request.Context(), inputcontrol.ManualKeyboard, false, nil)
|
||||
if err != nil {
|
||||
log.Errorf("manual paste failed to acquire HID control: %v", err)
|
||||
rsp.ErrRsp(c, -3, "HID control is busy")
|
||||
return
|
||||
}
|
||||
|
||||
for _, char := range req.Content {
|
||||
writeKeyboardReport := func(report []byte) error {
|
||||
return manual.Execute(func() error {
|
||||
return s.hid.WriteKeyboardReport(report)
|
||||
})
|
||||
}
|
||||
|
||||
for _, char := range contentRunes {
|
||||
if err := context.Cause(c.Request.Context()); err != nil {
|
||||
break
|
||||
}
|
||||
key, ok := charMapLocal[char]
|
||||
if !ok {
|
||||
log.Debugf("unknown key '%c' (rune: %d)", char, char)
|
||||
@@ -188,14 +224,40 @@ func (s *Service) Paste(c *gin.Context) {
|
||||
}
|
||||
|
||||
keyDown := []byte{byte(key.Modifiers), 0x00, byte(key.Code), 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
|
||||
hid.WriteHid0(keyDown)
|
||||
hid.WriteHid0(keyUp)
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
if err = writeKeyboardReport(keyDown); err != nil {
|
||||
break
|
||||
}
|
||||
if err = writeKeyboardReport(keyUp); err != nil {
|
||||
break
|
||||
}
|
||||
if err = sleepPasteContext(c.Request.Context(), defaultPasteDelay); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
err = context.Cause(c.Request.Context())
|
||||
}
|
||||
_ = writeKeyboardReport(keyUp)
|
||||
reservation.Complete(err == nil)
|
||||
if err != nil {
|
||||
log.Errorf("hid paste failed: %v", err)
|
||||
rsp.ErrRsp(c, -3, "HID paste failed")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRsp(c)
|
||||
log.Debugf("hid paste success, total %d characters processed", len(req.Content))
|
||||
log.Debugf("hid paste success, total %d characters processed", len(contentRunes))
|
||||
}
|
||||
|
||||
func sleepPasteContext(ctx context.Context, delay time.Duration) error {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return context.Cause(ctx)
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func copyMap(src map[rune]Char) map[rune]Char {
|
||||
|
||||
30
server/service/hid/queue.go
Normal file
30
server/service/hid/queue.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package hid
|
||||
|
||||
type QueuedReport struct {
|
||||
Data []byte
|
||||
Execute func(func() error) error
|
||||
Complete func(bool)
|
||||
ResetKeyboard func()
|
||||
ResetRelativeMouse func()
|
||||
ResetAbsoluteMouse func()
|
||||
}
|
||||
|
||||
func (r QueuedReport) run(write func() error) error {
|
||||
if r.Execute != nil {
|
||||
return r.Execute(write)
|
||||
}
|
||||
return write()
|
||||
}
|
||||
|
||||
func (r QueuedReport) complete(success bool) {
|
||||
if r.Complete != nil {
|
||||
r.Complete(success)
|
||||
}
|
||||
}
|
||||
|
||||
func runCleanup(execute func(func() error) error, write func() error) error {
|
||||
if execute != nil {
|
||||
return execute(write)
|
||||
}
|
||||
return write()
|
||||
}
|
||||
245
server/service/hid/queue_test.go
Normal file
245
server/service/hid/queue_test.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package hid
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMouseModeSwitchReleasesPreviousDevice(t *testing.T) {
|
||||
relativeReader, relativeWriter, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer relativeReader.Close()
|
||||
absoluteReader, absoluteWriter, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer absoluteReader.Close()
|
||||
|
||||
h := &Hid{g1: relativeWriter, g2: absoluteWriter}
|
||||
queue := make(chan QueuedReport, 2)
|
||||
completed := make(chan bool, 2)
|
||||
absoluteReset := make(chan struct{}, 1)
|
||||
absoluteDown := []byte{1, 0x34, 0x12, 0x78, 0x56, 0}
|
||||
queue <- QueuedReport{
|
||||
Data: absoluteDown,
|
||||
Complete: func(success bool) { completed <- success },
|
||||
ResetAbsoluteMouse: func() {
|
||||
absoluteReset <- struct{}{}
|
||||
},
|
||||
}
|
||||
queue <- QueuedReport{
|
||||
Data: relativeMouseReleaseReport(),
|
||||
Complete: func(success bool) { completed <- success },
|
||||
ResetAbsoluteMouse: func() { absoluteReset <- struct{}{} },
|
||||
}
|
||||
close(queue)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
h.mouseReports(queue, "unused-relative", "unused-absolute")
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("mouse worker did not stop")
|
||||
}
|
||||
|
||||
for range 2 {
|
||||
select {
|
||||
case success := <-completed:
|
||||
if !success {
|
||||
t.Fatal("mouse report failed")
|
||||
}
|
||||
default:
|
||||
t.Fatal("missing mouse completion")
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-absoluteReset:
|
||||
default:
|
||||
t.Fatal("absolute state was not reset during mode switch")
|
||||
}
|
||||
|
||||
absoluteData := make([]byte, 12)
|
||||
if _, err := io.ReadFull(absoluteReader, absoluteData); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(absoluteData[:6], absoluteDown) {
|
||||
t.Fatalf("absolute down report = %v", absoluteData[:6])
|
||||
}
|
||||
wantRelease := absoluteMouseReleaseReport(absoluteDown)
|
||||
if !bytes.Equal(absoluteData[6:], wantRelease) {
|
||||
t.Fatalf("absolute release report = %v, want %v", absoluteData[6:], wantRelease)
|
||||
}
|
||||
relativeData := make([]byte, 4)
|
||||
if _, err := io.ReadFull(relativeReader, relativeData); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(relativeData, relativeMouseReleaseReport()) {
|
||||
t.Fatalf("relative report = %v", relativeData)
|
||||
}
|
||||
h.Close()
|
||||
}
|
||||
|
||||
func TestKeyboardFailureCompletesAfterCleanup(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "hidg0")
|
||||
closedFile, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := closedFile.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
h := &Hid{g0: closedFile}
|
||||
queue := make(chan QueuedReport, 1)
|
||||
cleanupStarted := make(chan struct{})
|
||||
allowCleanup := make(chan struct{})
|
||||
cleanupFinished := make(chan struct{})
|
||||
completed := make(chan bool, 1)
|
||||
executions := 0
|
||||
queue <- QueuedReport{
|
||||
Data: []byte{0, 0, 4, 0, 0, 0, 0, 0},
|
||||
Execute: func(write func() error) error {
|
||||
executions++
|
||||
if executions == 2 {
|
||||
close(cleanupStarted)
|
||||
<-allowCleanup
|
||||
}
|
||||
err := write()
|
||||
if executions == 2 {
|
||||
close(cleanupFinished)
|
||||
}
|
||||
return err
|
||||
},
|
||||
Complete: func(success bool) { completed <- success },
|
||||
}
|
||||
close(queue)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
h.keyboardReports(queue, path)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-cleanupStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("cleanup did not start")
|
||||
}
|
||||
select {
|
||||
case success := <-completed:
|
||||
t.Fatalf("reservation completed before cleanup: success=%v", success)
|
||||
default:
|
||||
}
|
||||
|
||||
close(allowCleanup)
|
||||
select {
|
||||
case <-cleanupFinished:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("cleanup did not finish")
|
||||
}
|
||||
select {
|
||||
case success := <-completed:
|
||||
if success {
|
||||
t.Fatal("failed write completed successfully")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("reservation was not completed")
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("keyboard worker did not stop")
|
||||
}
|
||||
h.Close()
|
||||
}
|
||||
|
||||
func TestKeyboardFailureCompletesFalseWhenCleanupFails(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "hidg0")
|
||||
closedFile, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := closedFile.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
h := &Hid{g0: closedFile}
|
||||
queue := make(chan QueuedReport, 1)
|
||||
completed := make(chan bool, 1)
|
||||
queue <- QueuedReport{
|
||||
Data: []byte{0, 0, 4, 0, 0, 0, 0, 0},
|
||||
Complete: func(success bool) { completed <- success },
|
||||
}
|
||||
close(queue)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
h.keyboardReports(queue, filepath.Join(t.TempDir(), "missing", "hidg0"))
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case success := <-completed:
|
||||
if success {
|
||||
t.Fatal("failed keyboard write completed successfully after cleanup failed")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("reservation was not completed")
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("keyboard worker did not stop")
|
||||
}
|
||||
h.Close()
|
||||
}
|
||||
|
||||
func TestMouseFailureCompletesFalseWhenCleanupFails(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "hidg1")
|
||||
closedFile, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := closedFile.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
h := &Hid{g1: closedFile}
|
||||
queue := make(chan QueuedReport, 1)
|
||||
completed := make(chan bool, 1)
|
||||
queue <- QueuedReport{
|
||||
Data: []byte{1, 0, 0, 0},
|
||||
Complete: func(success bool) { completed <- success },
|
||||
}
|
||||
close(queue)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
h.mouseReports(queue, filepath.Join(t.TempDir(), "missing", "hidg1"), filepath.Join(t.TempDir(), "hidg2"))
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case success := <-completed:
|
||||
if success {
|
||||
t.Fatal("failed mouse write completed successfully after cleanup failed")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("reservation was not completed")
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("mouse worker did not stop")
|
||||
}
|
||||
h.Close()
|
||||
}
|
||||
48
server/service/hid/release.go
Normal file
48
server/service/hid/release.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package hid
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type reportWriter interface {
|
||||
WriteKeyboardReport([]byte) error
|
||||
WriteRelativeMouseReport([]byte) error
|
||||
WriteAbsoluteMouseReport([]byte) error
|
||||
}
|
||||
|
||||
func ReleaseAllHIDState() error {
|
||||
return releaseAllHIDState(GetHid())
|
||||
}
|
||||
|
||||
func ReleaseAllHIDStateBestEffort() error {
|
||||
return releaseAllHIDStateBestEffort(GetHid())
|
||||
}
|
||||
|
||||
func releaseAllHIDStateBestEffort(writer reportWriter) error {
|
||||
if err := releaseAllHIDState(writer); err != nil {
|
||||
log.Warnf("failed to release HID state during control switch: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func releaseAllHIDState(writer reportWriter) error {
|
||||
if writer == nil {
|
||||
return fmt.Errorf("HID writer is unavailable")
|
||||
}
|
||||
|
||||
var errs []error
|
||||
if err := writer.WriteKeyboardReport(keyboardReleaseReport()); err != nil {
|
||||
errs = append(errs, fmt.Errorf("release keyboard: %w", err))
|
||||
}
|
||||
if err := writer.WriteRelativeMouseReport(relativeMouseReleaseReport()); err != nil {
|
||||
errs = append(errs, fmt.Errorf("release relative mouse: %w", err))
|
||||
}
|
||||
if err := writer.WriteAbsoluteMouseReport(absoluteMouseReleaseReport(nil)); err != nil {
|
||||
errs = append(errs, fmt.Errorf("release absolute mouse: %w", err))
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
77
server/service/hid/release_test.go
Normal file
77
server/service/hid/release_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package hid
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type releaseRecordingWriter struct {
|
||||
keyboardErr error
|
||||
relativeErr error
|
||||
absoluteErr error
|
||||
keyboard int
|
||||
relative int
|
||||
absolute int
|
||||
}
|
||||
|
||||
func (w *releaseRecordingWriter) WriteKeyboardReport([]byte) error {
|
||||
w.keyboard++
|
||||
return w.keyboardErr
|
||||
}
|
||||
|
||||
func (w *releaseRecordingWriter) WriteRelativeMouseReport([]byte) error {
|
||||
w.relative++
|
||||
return w.relativeErr
|
||||
}
|
||||
|
||||
func (w *releaseRecordingWriter) WriteAbsoluteMouseReport([]byte) error {
|
||||
w.absolute++
|
||||
return w.absoluteErr
|
||||
}
|
||||
|
||||
func TestReleaseAllHIDStateAttemptsEveryDevice(t *testing.T) {
|
||||
writer := &releaseRecordingWriter{
|
||||
keyboardErr: errors.New("keyboard failed"),
|
||||
relativeErr: errors.New("relative failed"),
|
||||
absoluteErr: errors.New("absolute failed"),
|
||||
}
|
||||
|
||||
err := releaseAllHIDState(writer)
|
||||
if err == nil {
|
||||
t.Fatal("expected joined release error")
|
||||
}
|
||||
if writer.keyboard != 1 || writer.relative != 1 || writer.absolute != 1 {
|
||||
t.Fatalf("release attempts keyboard=%d relative=%d absolute=%d", writer.keyboard, writer.relative, writer.absolute)
|
||||
}
|
||||
for _, message := range []string{"release keyboard", "release relative mouse", "release absolute mouse"} {
|
||||
if !strings.Contains(err.Error(), message) {
|
||||
t.Fatalf("error %q missing %q", err, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseAllHIDStateSuccess(t *testing.T) {
|
||||
writer := &releaseRecordingWriter{}
|
||||
if err := releaseAllHIDState(writer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if writer.keyboard != 1 || writer.relative != 1 || writer.absolute != 1 {
|
||||
t.Fatalf("release attempts keyboard=%d relative=%d absolute=%d", writer.keyboard, writer.relative, writer.absolute)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseAllHIDStateBestEffortSuppressesErrors(t *testing.T) {
|
||||
writer := &releaseRecordingWriter{
|
||||
keyboardErr: errors.New("keyboard failed"),
|
||||
relativeErr: errors.New("relative failed"),
|
||||
absoluteErr: errors.New("absolute failed"),
|
||||
}
|
||||
|
||||
if err := releaseAllHIDStateBestEffort(writer); err != nil {
|
||||
t.Fatalf("best-effort release error = %v, want nil", err)
|
||||
}
|
||||
if writer.keyboard != 1 || writer.relative != 1 || writer.absolute != 1 {
|
||||
t.Fatalf("release attempts keyboard=%d relative=%d absolute=%d", writer.keyboard, writer.relative, writer.absolute)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,24 @@
|
||||
package hid
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
"NanoKVM-Server/service/inputcontrol"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
hid *Hid
|
||||
hid *Hid
|
||||
control *controlmode.Manager
|
||||
coordinator *inputcontrol.Coordinator
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{
|
||||
hid: GetHid(),
|
||||
hid: GetHid(),
|
||||
control: controlmode.GetManager(),
|
||||
coordinator: inputcontrol.GetCoordinator(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) newManualSession() *inputcontrol.ManualSession {
|
||||
return inputcontrol.NewManualSession(s.control, s.coordinator)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package hid
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/proto"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -10,6 +9,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/service/inputcontrol"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
@@ -33,7 +35,7 @@ var modeMap = map[string]string{
|
||||
func (s *Service) GetHidMode(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
mode, err := getHidMode()
|
||||
mode, err := GetMode()
|
||||
if err != nil {
|
||||
rsp.ErrRsp(c, -1, "get HID mode failed")
|
||||
return
|
||||
@@ -45,6 +47,23 @@ func (s *Service) GetHidMode(c *gin.Context) {
|
||||
log.Debugf("get hid mode: %s", mode)
|
||||
}
|
||||
|
||||
func (s *Service) GetKeyboardLedStatus(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
status := GetKeyboardLedStatus()
|
||||
updatedAt := ""
|
||||
if !status.UpdatedAt.IsZero() {
|
||||
updatedAt = status.UpdatedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, &proto.GetKeyboardLedStatusRsp{
|
||||
NumLock: status.NumLock,
|
||||
CapsLock: status.CapsLock,
|
||||
ScrollLock: status.ScrollLock,
|
||||
Known: status.Known,
|
||||
UpdatedAt: updatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) SetHidMode(c *gin.Context) {
|
||||
var req proto.SetHidModeReq
|
||||
var rsp proto.Response
|
||||
@@ -58,7 +77,7 @@ func (s *Service) SetHidMode(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if mode, _ := getHidMode(); req.Mode == mode {
|
||||
if mode, _ := GetMode(); req.Mode == mode {
|
||||
rsp.OkRsp(c)
|
||||
return
|
||||
}
|
||||
@@ -91,7 +110,17 @@ func (s *Service) SetHidMode(c *gin.Context) {
|
||||
func (s *Service) ResetHid(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
|
||||
if err := ResetUSBPHY(); err != nil {
|
||||
manual := s.newManualSession()
|
||||
defer manual.Close()
|
||||
reservation, err := manual.Reserve(c.Request.Context(), inputcontrol.ManualRelativeMouse, false, nil)
|
||||
if err != nil {
|
||||
log.Errorf("failed to acquire manual control for HID reset: %v", err)
|
||||
rsp.ErrRsp(c, -1, "HID control is busy")
|
||||
return
|
||||
}
|
||||
err = manual.Execute(ResetUSBPHY)
|
||||
reservation.Complete(err == nil)
|
||||
if err != nil {
|
||||
log.Errorf("failed to reset hid: %v", err)
|
||||
rsp.ErrRsp(c, -1, "failed to reset hid")
|
||||
return
|
||||
@@ -194,7 +223,7 @@ func copyModeFile(srcScript string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func getHidMode() (string, error) {
|
||||
func GetMode() (string, error) {
|
||||
data, err := os.ReadFile(ModeFlag)
|
||||
if err != nil {
|
||||
log.Errorf("failed to read %s: %s", ModeFlag, err)
|
||||
|
||||
462
server/service/inputcontrol/coordinator.go
Normal file
462
server/service/inputcontrol/coordinator.go
Normal file
@@ -0,0 +1,462 @@
|
||||
package inputcontrol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrMCPBusy = errors.New("MCP remote control is busy")
|
||||
ErrManualControlActive = errors.New("manual control is active")
|
||||
ErrManualInputBlocked = errors.New("manual input is blocked")
|
||||
ErrManualPreempted = errors.New("MCP operation was preempted by manual input")
|
||||
ErrMCPModeChanged = errors.New("MCP control mode changed")
|
||||
)
|
||||
|
||||
const defaultManualCooldown = 2 * time.Second
|
||||
|
||||
type OperationKind uint8
|
||||
|
||||
const (
|
||||
OperationHID OperationKind = iota
|
||||
OperationReadOnly
|
||||
)
|
||||
|
||||
type ManualReportKind uint8
|
||||
|
||||
const (
|
||||
ManualKeyboard ManualReportKind = iota
|
||||
ManualRelativeMouse
|
||||
ManualAbsoluteMouse
|
||||
)
|
||||
|
||||
type activeOperation struct {
|
||||
id uint64
|
||||
kind OperationKind
|
||||
cancel context.CancelCauseFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
type Coordinator struct {
|
||||
mu sync.Mutex
|
||||
manualWriteMu sync.Mutex
|
||||
active *activeOperation
|
||||
nextID uint64
|
||||
manualSessions int
|
||||
manualUntil time.Time
|
||||
manualCooldown time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type ManualSession struct {
|
||||
coordinator *Coordinator
|
||||
control *controlmode.Manager
|
||||
|
||||
mu sync.Mutex
|
||||
active bool
|
||||
closed bool
|
||||
generation uint64
|
||||
pending int
|
||||
keyboardHeld bool
|
||||
relativeMouseHeld bool
|
||||
absoluteMouseHeld bool
|
||||
cooldownOnIdle bool
|
||||
releaseControl func()
|
||||
}
|
||||
|
||||
type ManualReservation struct {
|
||||
once sync.Once
|
||||
session *ManualSession
|
||||
generation uint64
|
||||
kind ManualReportKind
|
||||
held bool
|
||||
startCooldown bool
|
||||
}
|
||||
|
||||
var defaultCoordinator = newCoordinator(defaultManualCooldown, time.Now)
|
||||
|
||||
func newCoordinator(cooldown time.Duration, now func() time.Time) *Coordinator {
|
||||
if cooldown < 0 {
|
||||
cooldown = 0
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Coordinator{manualCooldown: cooldown, now: now}
|
||||
}
|
||||
|
||||
func GetCoordinator() *Coordinator {
|
||||
return defaultCoordinator
|
||||
}
|
||||
|
||||
func NewManualSession(control *controlmode.Manager, coordinator *Coordinator) *ManualSession {
|
||||
if control == nil {
|
||||
control = controlmode.GetManager()
|
||||
}
|
||||
if coordinator == nil {
|
||||
coordinator = GetCoordinator()
|
||||
}
|
||||
return &ManualSession{control: control, coordinator: coordinator}
|
||||
}
|
||||
|
||||
func (c *Coordinator) BeginMCP(parent context.Context, kind OperationKind) (context.Context, func(), error) {
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.active != nil {
|
||||
return nil, nil, ErrMCPBusy
|
||||
}
|
||||
if kind == OperationHID && (c.manualSessions > 0 || c.currentTime().Before(c.manualUntil)) {
|
||||
return nil, nil, ErrManualControlActive
|
||||
}
|
||||
|
||||
c.nextID++
|
||||
id := c.nextID
|
||||
ctx, cancel := context.WithCancelCause(parent)
|
||||
operation := &activeOperation{
|
||||
id: id,
|
||||
kind: kind,
|
||||
cancel: cancel,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
c.active = operation
|
||||
|
||||
var once sync.Once
|
||||
release := func() {
|
||||
once.Do(func() {
|
||||
cancel(context.Canceled)
|
||||
c.mu.Lock()
|
||||
if c.active != nil && c.active.id == id {
|
||||
c.active = nil
|
||||
close(operation.done)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
})
|
||||
}
|
||||
return ctx, release, nil
|
||||
}
|
||||
|
||||
// BeginBackground reserves the HID lane for a best-effort background action,
|
||||
// such as the mouse jiggler. Callers should skip the action when it returns a
|
||||
// busy error rather than delaying manual or MCP input.
|
||||
func (c *Coordinator) BeginBackground(parent context.Context) (context.Context, func(), error) {
|
||||
return c.BeginMCP(parent, OperationHID)
|
||||
}
|
||||
|
||||
func (c *Coordinator) CancelMCP() {
|
||||
c.CancelMCPWithCause(ErrMCPModeChanged)
|
||||
}
|
||||
|
||||
func (c *Coordinator) CancelMCPWithCause(cause error) {
|
||||
if cause == nil {
|
||||
cause = context.Canceled
|
||||
}
|
||||
c.mu.Lock()
|
||||
operation := c.active
|
||||
c.mu.Unlock()
|
||||
if operation != nil {
|
||||
operation.cancel(cause)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Coordinator) beginManual(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.manualSessions++
|
||||
operation := c.active
|
||||
if operation != nil && operation.kind == OperationHID {
|
||||
operation.cancel(ErrManualPreempted)
|
||||
} else {
|
||||
operation = nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if operation == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-operation.done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
c.endManual(false)
|
||||
return context.Cause(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Coordinator) endManual(startCooldown bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.manualSessions > 0 {
|
||||
c.manualSessions--
|
||||
}
|
||||
if startCooldown && c.manualSessions == 0 {
|
||||
c.manualUntil = c.currentTime().Add(c.cooldown())
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Coordinator) currentTime() time.Time {
|
||||
if c.now == nil {
|
||||
return time.Now()
|
||||
}
|
||||
return c.now()
|
||||
}
|
||||
|
||||
func (c *Coordinator) cooldown() time.Duration {
|
||||
if c.manualCooldown <= 0 {
|
||||
return defaultManualCooldown
|
||||
}
|
||||
return c.manualCooldown
|
||||
}
|
||||
|
||||
func (c *Coordinator) executeManual(write func() error) error {
|
||||
if write == nil {
|
||||
return nil
|
||||
}
|
||||
c.manualWriteMu.Lock()
|
||||
defer c.manualWriteMu.Unlock()
|
||||
return write()
|
||||
}
|
||||
|
||||
func (s *ManualSession) Reserve(
|
||||
ctx context.Context,
|
||||
kind ManualReportKind,
|
||||
held bool,
|
||||
allow func(controlmode.Mode) bool,
|
||||
) (*ManualReservation, error) {
|
||||
return s.reserve(ctx, kind, held, true, allow)
|
||||
}
|
||||
|
||||
func (s *ManualSession) ReserveWithCooldown(
|
||||
ctx context.Context,
|
||||
kind ManualReportKind,
|
||||
held bool,
|
||||
startCooldown bool,
|
||||
allow func(controlmode.Mode) bool,
|
||||
) (*ManualReservation, error) {
|
||||
return s.reserve(ctx, kind, held, startCooldown, allow)
|
||||
}
|
||||
|
||||
func (s *ManualSession) reserve(
|
||||
ctx context.Context,
|
||||
kind ManualReportKind,
|
||||
held bool,
|
||||
startCooldown bool,
|
||||
allow func(controlmode.Mode) bool,
|
||||
) (*ManualReservation, error) {
|
||||
if s == nil || s.coordinator == nil || s.control == nil {
|
||||
return nil, fmt.Errorf("manual input coordinator is unavailable")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return nil, fmt.Errorf("manual input session is closed")
|
||||
}
|
||||
if s.active {
|
||||
status, err := s.control.Status()
|
||||
if err != nil {
|
||||
s.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
allowed := !status.Transitioning && (allow == nil || allow(status.Mode))
|
||||
if !allowed && !s.isReleaseReportLocked(kind, held) {
|
||||
s.mu.Unlock()
|
||||
return nil, ErrManualInputBlocked
|
||||
}
|
||||
s.pending++
|
||||
startCooldown = startCooldown || s.isReleaseReportLocked(kind, held)
|
||||
reservation := &ManualReservation{
|
||||
session: s, generation: s.generation, kind: kind, held: held, startCooldown: startCooldown,
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return reservation, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
status, releaseControl, err := s.control.AcquireStable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if allow != nil && !allow(status.Mode) {
|
||||
releaseControl()
|
||||
return nil, ErrManualInputBlocked
|
||||
}
|
||||
if err := s.coordinator.beginManual(ctx); err != nil {
|
||||
releaseControl()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
s.coordinator.endManual(false)
|
||||
releaseControl()
|
||||
return nil, fmt.Errorf("manual input session is closed")
|
||||
}
|
||||
if s.active {
|
||||
// Reserve calls are serialized for a WebSocket client, but keep this path
|
||||
// safe for callers that share a session concurrently.
|
||||
s.pending++
|
||||
startCooldown = startCooldown || s.isReleaseReportLocked(kind, held)
|
||||
reservation := &ManualReservation{
|
||||
session: s, generation: s.generation, kind: kind, held: held, startCooldown: startCooldown,
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.coordinator.endManual(false)
|
||||
releaseControl()
|
||||
return reservation, nil
|
||||
}
|
||||
|
||||
s.active = true
|
||||
s.generation++
|
||||
s.pending = 1
|
||||
s.releaseControl = releaseControl
|
||||
reservation := &ManualReservation{
|
||||
session: s, generation: s.generation, kind: kind, held: held, startCooldown: startCooldown,
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return reservation, nil
|
||||
}
|
||||
|
||||
func (s *ManualSession) isReleaseReportLocked(kind ManualReportKind, held bool) bool {
|
||||
if held {
|
||||
return false
|
||||
}
|
||||
switch kind {
|
||||
case ManualKeyboard:
|
||||
return s.keyboardHeld
|
||||
case ManualRelativeMouse, ManualAbsoluteMouse:
|
||||
return s.relativeMouseHeld || s.absoluteMouseHeld
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ManualReservation) Complete(success bool) {
|
||||
if r == nil || r.session == nil {
|
||||
return
|
||||
}
|
||||
r.once.Do(func() {
|
||||
r.session.complete(r.generation, r.kind, r.held, success, r.startCooldown)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ManualSession) Execute(write func() error) error {
|
||||
if s == nil || s.coordinator == nil {
|
||||
return fmt.Errorf("manual input coordinator is unavailable")
|
||||
}
|
||||
return s.coordinator.executeManual(write)
|
||||
}
|
||||
|
||||
func (s *ManualSession) Reset(kind ManualReportKind) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if !s.active {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
switch kind {
|
||||
case ManualKeyboard:
|
||||
s.keyboardHeld = false
|
||||
case ManualRelativeMouse:
|
||||
s.relativeMouseHeld = false
|
||||
case ManualAbsoluteMouse:
|
||||
s.absoluteMouseHeld = false
|
||||
}
|
||||
releaseControl, end, _ := s.finishIfIdleLocked()
|
||||
s.mu.Unlock()
|
||||
s.finish(releaseControl, end, true)
|
||||
}
|
||||
|
||||
func (s *ManualSession) Close() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.closed = true
|
||||
s.pending = 0
|
||||
s.keyboardHeld = false
|
||||
s.relativeMouseHeld = false
|
||||
s.absoluteMouseHeld = false
|
||||
s.cooldownOnIdle = false
|
||||
releaseControl := s.releaseControl
|
||||
end := s.active
|
||||
s.active = false
|
||||
s.releaseControl = nil
|
||||
s.generation++
|
||||
s.mu.Unlock()
|
||||
s.finish(releaseControl, end, true)
|
||||
}
|
||||
|
||||
func (s *ManualSession) complete(generation uint64, kind ManualReportKind, held bool, success bool, startCooldown bool) {
|
||||
s.mu.Lock()
|
||||
if !s.active || generation != s.generation {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if s.pending > 0 {
|
||||
s.pending--
|
||||
}
|
||||
if startCooldown {
|
||||
s.cooldownOnIdle = true
|
||||
}
|
||||
if success {
|
||||
switch kind {
|
||||
case ManualKeyboard:
|
||||
s.keyboardHeld = held
|
||||
case ManualRelativeMouse:
|
||||
s.relativeMouseHeld = held
|
||||
case ManualAbsoluteMouse:
|
||||
s.absoluteMouseHeld = held
|
||||
}
|
||||
} else {
|
||||
s.keyboardHeld = false
|
||||
s.relativeMouseHeld = false
|
||||
s.absoluteMouseHeld = false
|
||||
}
|
||||
releaseControl, end, startCooldown := s.finishIfIdleLocked()
|
||||
s.mu.Unlock()
|
||||
s.finish(releaseControl, end, startCooldown)
|
||||
}
|
||||
|
||||
func (s *ManualSession) finishIfIdleLocked() (func(), bool, bool) {
|
||||
if !s.active || s.pending > 0 || s.keyboardHeld || s.relativeMouseHeld || s.absoluteMouseHeld {
|
||||
return nil, false, false
|
||||
}
|
||||
releaseControl := s.releaseControl
|
||||
startCooldown := s.cooldownOnIdle
|
||||
s.active = false
|
||||
s.releaseControl = nil
|
||||
s.cooldownOnIdle = false
|
||||
return releaseControl, true, startCooldown
|
||||
}
|
||||
|
||||
func (s *ManualSession) finish(releaseControl func(), end bool, startCooldown bool) {
|
||||
if !end {
|
||||
return
|
||||
}
|
||||
if releaseControl != nil {
|
||||
releaseControl()
|
||||
}
|
||||
s.coordinator.endManual(startCooldown)
|
||||
}
|
||||
474
server/service/inputcontrol/coordinator_test.go
Normal file
474
server/service/inputcontrol/coordinator_test.go
Normal file
@@ -0,0 +1,474 @@
|
||||
package inputcontrol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
)
|
||||
|
||||
func TestCoordinatorRejectsConcurrentMCP(t *testing.T) {
|
||||
coordinator := newCoordinator(defaultManualCooldown, time.Now)
|
||||
ctx, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrMCPBusy) {
|
||||
t.Fatalf("error = %v, want %v", err, ErrMCPBusy)
|
||||
}
|
||||
coordinator.CancelMCP()
|
||||
if err := context.Cause(ctx); !errors.Is(err, ErrMCPModeChanged) {
|
||||
t.Fatalf("context cause = %v, want %v", err, ErrMCPModeChanged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinatorReleaseAllowsNextMCP(t *testing.T) {
|
||||
coordinator := newCoordinator(defaultManualCooldown, time.Now)
|
||||
_, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release()
|
||||
|
||||
_, nextRelease, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nextRelease()
|
||||
}
|
||||
|
||||
func TestManualInputPreemptsMCPWithoutSwitchingMode(t *testing.T) {
|
||||
now := time.Unix(100, 0)
|
||||
coordinator := newCoordinator(2*time.Second, func() time.Time { return now })
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
operationCtx, releaseOperation, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
releaseMode, err := control.Acquire(controlmode.ModeMCP)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
manual := NewManualSession(control, coordinator)
|
||||
type result struct {
|
||||
reservation *ManualReservation
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
reservation, err := manual.Reserve(context.Background(), ManualKeyboard, false, nil)
|
||||
done <- result{reservation: reservation, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-operationCtx.Done():
|
||||
if cause := context.Cause(operationCtx); !errors.Is(cause, ErrManualPreempted) {
|
||||
t.Fatalf("context cause = %v, want %v", cause, ErrManualPreempted)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("manual input did not preempt active MCP operation")
|
||||
}
|
||||
releaseOperation()
|
||||
releaseMode()
|
||||
|
||||
var reservation *ManualReservation
|
||||
select {
|
||||
case got := <-done:
|
||||
if got.err != nil {
|
||||
t.Fatal(got.err)
|
||||
}
|
||||
reservation = got.reservation
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("manual input did not acquire control")
|
||||
}
|
||||
if got := control.Current(); got != controlmode.ModeMCP {
|
||||
t.Fatalf("mode = %q, want MCP to remain enabled", got)
|
||||
}
|
||||
|
||||
reservation.Complete(true)
|
||||
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
|
||||
t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive)
|
||||
}
|
||||
now = now.Add(3 * time.Second)
|
||||
_, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatalf("MCP did not resume after cooldown: %v", err)
|
||||
}
|
||||
release()
|
||||
manual.Close()
|
||||
}
|
||||
|
||||
func TestHeldManualInputBlocksMCPUntilReleaseAndCooldown(t *testing.T) {
|
||||
now := time.Unix(200, 0)
|
||||
coordinator := newCoordinator(time.Second, func() time.Time { return now })
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
manual := NewManualSession(control, coordinator)
|
||||
defer manual.Close()
|
||||
|
||||
down, err := manual.Reserve(context.Background(), ManualKeyboard, true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
down.Complete(true)
|
||||
now = now.Add(10 * time.Second)
|
||||
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
|
||||
t.Fatalf("held input error = %v, want %v", err, ErrManualControlActive)
|
||||
}
|
||||
|
||||
up, err := manual.Reserve(context.Background(), ManualKeyboard, false, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
up.Complete(true)
|
||||
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
|
||||
t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive)
|
||||
}
|
||||
now = now.Add(2 * time.Second)
|
||||
_, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release()
|
||||
}
|
||||
|
||||
func TestFailedHeldManualInputDoesNotRemainHeld(t *testing.T) {
|
||||
now := time.Unix(250, 0)
|
||||
coordinator := newCoordinator(time.Second, func() time.Time { return now })
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
manual := NewManualSession(control, coordinator)
|
||||
defer manual.Close()
|
||||
|
||||
down, err := manual.Reserve(context.Background(), ManualKeyboard, true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
down.Complete(false)
|
||||
|
||||
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
|
||||
t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive)
|
||||
}
|
||||
now = now.Add(2 * time.Second)
|
||||
_, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed held input remained active after cooldown: %v", err)
|
||||
}
|
||||
release()
|
||||
}
|
||||
|
||||
func TestFailedReleaseReportClearsHeldManualInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
kind ManualReportKind
|
||||
}{
|
||||
{name: "keyboard", kind: ManualKeyboard},
|
||||
{name: "relative mouse", kind: ManualRelativeMouse},
|
||||
{name: "absolute mouse", kind: ManualAbsoluteMouse},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
now := time.Unix(260, 0)
|
||||
coordinator := newCoordinator(time.Second, func() time.Time { return now })
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
manual := NewManualSession(control, coordinator)
|
||||
defer manual.Close()
|
||||
|
||||
down, err := manual.Reserve(context.Background(), tc.kind, true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
down.Complete(true)
|
||||
|
||||
up, err := manual.Reserve(context.Background(), tc.kind, false, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
up.Complete(false)
|
||||
|
||||
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
|
||||
t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive)
|
||||
}
|
||||
now = now.Add(2 * time.Second)
|
||||
_, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed release report left held input active: %v", err)
|
||||
}
|
||||
release()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPointerMoveWithoutButtonsDoesNotStartCooldown(t *testing.T) {
|
||||
coordinator := newCoordinator(time.Second, time.Now)
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
manual := NewManualSession(control, coordinator)
|
||||
defer manual.Close()
|
||||
|
||||
move, err := manual.ReserveWithCooldown(context.Background(), ManualAbsoluteMouse, false, false, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
move.Complete(true)
|
||||
|
||||
_, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatalf("pointer move without buttons blocked MCP: %v", err)
|
||||
}
|
||||
release()
|
||||
}
|
||||
|
||||
func TestReleaseReportForcesCooldown(t *testing.T) {
|
||||
now := time.Unix(275, 0)
|
||||
coordinator := newCoordinator(time.Second, func() time.Time { return now })
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
manual := NewManualSession(control, coordinator)
|
||||
defer manual.Close()
|
||||
|
||||
down, err := manual.ReserveWithCooldown(context.Background(), ManualRelativeMouse, true, true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
down.Complete(true)
|
||||
|
||||
up, err := manual.ReserveWithCooldown(context.Background(), ManualRelativeMouse, false, false, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
up.Complete(true)
|
||||
|
||||
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
|
||||
t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive)
|
||||
}
|
||||
now = now.Add(2 * time.Second)
|
||||
_, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release()
|
||||
}
|
||||
|
||||
func TestCooldownAggregatesAcrossPendingReports(t *testing.T) {
|
||||
now := time.Unix(285, 0)
|
||||
coordinator := newCoordinator(time.Second, func() time.Time { return now })
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
manual := NewManualSession(control, coordinator)
|
||||
defer manual.Close()
|
||||
|
||||
move, err := manual.ReserveWithCooldown(context.Background(), ManualAbsoluteMouse, false, false, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wheel, err := manual.ReserveWithCooldown(context.Background(), ManualAbsoluteMouse, false, true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wheel.Complete(true)
|
||||
move.Complete(true)
|
||||
|
||||
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
|
||||
t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive)
|
||||
}
|
||||
now = now.Add(2 * time.Second)
|
||||
_, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release()
|
||||
}
|
||||
|
||||
func TestRelativeAndAbsoluteMouseHeldStateIsIndependent(t *testing.T) {
|
||||
now := time.Unix(300, 0)
|
||||
coordinator := newCoordinator(time.Second, func() time.Time { return now })
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
manual := NewManualSession(control, coordinator)
|
||||
defer manual.Close()
|
||||
|
||||
relativeDown, err := manual.Reserve(context.Background(), ManualRelativeMouse, true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
relativeDown.Complete(true)
|
||||
|
||||
absoluteMove, err := manual.Reserve(context.Background(), ManualAbsoluteMouse, false, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
absoluteMove.Complete(true)
|
||||
|
||||
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
|
||||
t.Fatalf("relative button was cleared by absolute report: %v", err)
|
||||
}
|
||||
|
||||
relativeUp, err := manual.Reserve(context.Background(), ManualRelativeMouse, false, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
relativeUp.Complete(true)
|
||||
|
||||
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
|
||||
t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive)
|
||||
}
|
||||
now = now.Add(2 * time.Second)
|
||||
_, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release()
|
||||
}
|
||||
|
||||
func TestAbsoluteMouseHeldStateSurvivesRelativeReport(t *testing.T) {
|
||||
coordinator := newCoordinator(defaultManualCooldown, time.Now)
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
manual := NewManualSession(control, coordinator)
|
||||
defer manual.Close()
|
||||
|
||||
absoluteDown, err := manual.Reserve(context.Background(), ManualAbsoluteMouse, true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
absoluteDown.Complete(true)
|
||||
|
||||
relativeMove, err := manual.Reserve(context.Background(), ManualRelativeMouse, false, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
relativeMove.Complete(true)
|
||||
|
||||
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
|
||||
t.Fatalf("absolute button was cleared by relative report: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockedActiveSessionStillAllowsReleaseReport(t *testing.T) {
|
||||
coordinator := newCoordinator(defaultManualCooldown, time.Now)
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw)
|
||||
manual := NewManualSession(control, coordinator)
|
||||
defer manual.Close()
|
||||
|
||||
down, err := manual.Reserve(context.Background(), ManualKeyboard, true, func(controlmode.Mode) bool { return true })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
down.Complete(true)
|
||||
|
||||
if _, err := manual.Reserve(context.Background(), ManualKeyboard, true, func(controlmode.Mode) bool { return false }); !errors.Is(err, ErrManualInputBlocked) {
|
||||
t.Fatalf("new held report error = %v, want %v", err, ErrManualInputBlocked)
|
||||
}
|
||||
|
||||
up, err := manual.Reserve(context.Background(), ManualKeyboard, false, func(controlmode.Mode) bool { return false })
|
||||
if err != nil {
|
||||
t.Fatalf("release report was blocked: %v", err)
|
||||
}
|
||||
up.Complete(true)
|
||||
}
|
||||
|
||||
func TestBlockedActiveSessionFailedReleaseClearsHeldReport(t *testing.T) {
|
||||
now := time.Unix(325, 0)
|
||||
coordinator := newCoordinator(time.Second, func() time.Time { return now })
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw)
|
||||
manual := NewManualSession(control, coordinator)
|
||||
defer manual.Close()
|
||||
|
||||
down, err := manual.Reserve(context.Background(), ManualKeyboard, true, func(controlmode.Mode) bool { return true })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
down.Complete(true)
|
||||
|
||||
if _, err := manual.Reserve(context.Background(), ManualKeyboard, true, func(controlmode.Mode) bool { return false }); !errors.Is(err, ErrManualInputBlocked) {
|
||||
t.Fatalf("new held report error = %v, want %v", err, ErrManualInputBlocked)
|
||||
}
|
||||
|
||||
up, err := manual.Reserve(context.Background(), ManualKeyboard, false, func(controlmode.Mode) bool { return false })
|
||||
if err != nil {
|
||||
t.Fatalf("release report was blocked: %v", err)
|
||||
}
|
||||
up.Complete(false)
|
||||
|
||||
if _, _, err := coordinator.BeginMCP(context.Background(), OperationHID); !errors.Is(err, ErrManualControlActive) {
|
||||
t.Fatalf("cooldown error = %v, want %v", err, ErrManualControlActive)
|
||||
}
|
||||
now = now.Add(2 * time.Second)
|
||||
_, release, err := coordinator.BeginMCP(context.Background(), OperationHID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed blocked release report left held input active: %v", err)
|
||||
}
|
||||
release()
|
||||
}
|
||||
|
||||
func TestReadOnlyMCPAllowedDuringManualControl(t *testing.T) {
|
||||
coordinator := newCoordinator(defaultManualCooldown, time.Now)
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
manual := NewManualSession(control, coordinator)
|
||||
defer manual.Close()
|
||||
|
||||
reservation, err := manual.Reserve(context.Background(), ManualRelativeMouse, true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reservation.Complete(true)
|
||||
|
||||
_, release, err := coordinator.BeginMCP(context.Background(), OperationReadOnly)
|
||||
if err != nil {
|
||||
t.Fatalf("read-only MCP operation was blocked by manual control: %v", err)
|
||||
}
|
||||
release()
|
||||
}
|
||||
|
||||
func TestManualWritesAreSerialized(t *testing.T) {
|
||||
coordinator := newCoordinator(defaultManualCooldown, time.Now)
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
first := NewManualSession(control, coordinator)
|
||||
second := NewManualSession(control, coordinator)
|
||||
defer first.Close()
|
||||
defer second.Close()
|
||||
|
||||
firstReservation, err := first.Reserve(context.Background(), ManualKeyboard, false, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondReservation, err := second.Reserve(context.Background(), ManualRelativeMouse, false, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entered := make(chan struct{})
|
||||
releaseFirst := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = first.Execute(func() error {
|
||||
close(entered)
|
||||
<-releaseFirst
|
||||
return nil
|
||||
})
|
||||
firstReservation.Complete(true)
|
||||
}()
|
||||
<-entered
|
||||
|
||||
secondEntered := make(chan struct{})
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = second.Execute(func() error {
|
||||
close(secondEntered)
|
||||
return nil
|
||||
})
|
||||
secondReservation.Complete(true)
|
||||
}()
|
||||
select {
|
||||
case <-secondEntered:
|
||||
t.Fatal("second manual write entered before the first completed")
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
close(releaseFirst)
|
||||
wg.Wait()
|
||||
}
|
||||
51
server/service/mcp/auth.go
Normal file
51
server/service/mcp/auth.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package mcpservice
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func APIKeyMiddleware(control *controlmode.Manager) gin.HandlerFunc {
|
||||
if control == nil {
|
||||
control = controlmode.GetManager()
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := control.Require(controlmode.ModeMCP); err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "MCP service is disabled"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
log.Errorf("failed to load MCP config for authentication: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "MCP auth unavailable"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
token, ok := extractBearer(c.GetHeader("Authorization"))
|
||||
if !ok || cfg.APIKey == "" || subtle.ConstantTimeCompare([]byte(token), []byte(cfg.APIKey)) != 1 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid MCP API key"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func extractBearer(header string) (string, bool) {
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(header, prefix) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(strings.TrimPrefix(header, prefix))
|
||||
return token, token != ""
|
||||
}
|
||||
163
server/service/mcp/capture/capture.go
Normal file
163
server/service/mcp/capture/capture.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package mcpcapture
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image/jpeg"
|
||||
"time"
|
||||
|
||||
mcpservice "NanoKVM-Server/service/mcp"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultQuality = 60
|
||||
minimumQuality = 51
|
||||
maximumQuality = 60
|
||||
maximumJPEGBytes = 1 << 20
|
||||
screenshotRetryDelay = 100 * time.Millisecond
|
||||
maxTimeoutMS = 30_000
|
||||
)
|
||||
|
||||
type VisionReader interface {
|
||||
ReadMjpeg(width uint16, height uint16, quality uint16) ([]byte, int)
|
||||
}
|
||||
|
||||
type ScreenReader func() (width uint16, height uint16)
|
||||
type CaptureLeaseAcquirer func(context.Context) (release func(), claimFresh func() bool, err error)
|
||||
|
||||
type Snapshotter struct {
|
||||
vision VisionReader
|
||||
readScreen ScreenReader
|
||||
captureSlot chan struct{}
|
||||
retryDelay time.Duration
|
||||
acquireLease CaptureLeaseAcquirer
|
||||
}
|
||||
|
||||
func New(vision VisionReader, readScreen ScreenReader) *Snapshotter {
|
||||
return NewWithCaptureLease(vision, readScreen, nil)
|
||||
}
|
||||
|
||||
func NewWithCaptureLease(vision VisionReader, readScreen ScreenReader, acquireLease CaptureLeaseAcquirer) *Snapshotter {
|
||||
return &Snapshotter{
|
||||
vision: vision,
|
||||
readScreen: readScreen,
|
||||
captureSlot: make(chan struct{}, 1),
|
||||
retryDelay: screenshotRetryDelay,
|
||||
acquireLease: acquireLease,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Snapshotter) Capture(ctx context.Context, req mcpservice.SnapshotRequest) (mcpservice.Snapshot, error) {
|
||||
if req.X != 0 || req.Y != 0 || req.W != 0 || req.H != 0 {
|
||||
return mcpservice.Snapshot{}, fmt.Errorf("screenshot cropping is not supported")
|
||||
}
|
||||
if s.vision == nil || s.readScreen == nil {
|
||||
return mcpservice.Snapshot{}, fmt.Errorf("screenshot capture is unavailable")
|
||||
}
|
||||
|
||||
quality := req.Quality
|
||||
if quality == 0 {
|
||||
quality = defaultQuality
|
||||
}
|
||||
quality = clamp(quality, minimumQuality, maximumQuality)
|
||||
|
||||
timeoutMS := 3000
|
||||
if req.TimeoutMS != nil {
|
||||
timeoutMS = clamp(*req.TimeoutMS, 0, maxTimeoutMS)
|
||||
}
|
||||
|
||||
select {
|
||||
case s.captureSlot <- struct{}{}:
|
||||
defer func() { <-s.captureSlot }()
|
||||
case <-ctx.Done():
|
||||
return mcpservice.Snapshot{}, ctx.Err()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return mcpservice.Snapshot{}, err
|
||||
}
|
||||
if s.acquireLease != nil {
|
||||
releaseLease, claimFresh, err := s.acquireLease(ctx)
|
||||
if err != nil {
|
||||
return mcpservice.Snapshot{}, err
|
||||
}
|
||||
if releaseLease != nil {
|
||||
defer releaseLease()
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(time.Duration(timeoutMS) * time.Millisecond)
|
||||
width, height := s.readScreen()
|
||||
return s.capture(ctx, width, height, quality, deadline, timeoutMS, claimFresh)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(time.Duration(timeoutMS) * time.Millisecond)
|
||||
width, height := s.readScreen()
|
||||
return s.capture(ctx, width, height, quality, deadline, timeoutMS, nil)
|
||||
}
|
||||
|
||||
func (s *Snapshotter) capture(ctx context.Context, width uint16, height uint16, quality int, deadline time.Time, timeoutMS int, claimFresh func() bool) (mcpservice.Snapshot, error) {
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return mcpservice.Snapshot{}, err
|
||||
}
|
||||
if timeoutMS > 0 && !time.Now().Before(deadline) {
|
||||
return mcpservice.Snapshot{Message: "screenshot capture timed out"}, nil
|
||||
}
|
||||
|
||||
data, result := s.vision.ReadMjpeg(width, height, uint16(quality))
|
||||
snapshot := mcpservice.Snapshot{
|
||||
RetCode: result,
|
||||
Width: int(width),
|
||||
Height: int(height),
|
||||
JPEG: data,
|
||||
}
|
||||
switch {
|
||||
case result >= 0 && result != 5 && len(data) > 0:
|
||||
if claimFresh != nil && claimFresh() {
|
||||
claimFresh = nil
|
||||
continue
|
||||
}
|
||||
if len(data) > maximumJPEGBytes {
|
||||
snapshot.JPEG = nil
|
||||
snapshot.Message = "captured JPEG exceeds the MCP response size limit"
|
||||
return snapshot, nil
|
||||
}
|
||||
config, err := jpeg.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
snapshot.Message = "captured data is not a valid JPEG"
|
||||
break
|
||||
}
|
||||
snapshot.OK = true
|
||||
snapshot.Width = config.Width
|
||||
snapshot.Height = config.Height
|
||||
return snapshot, nil
|
||||
case result == 5:
|
||||
snapshot.Message = "no HDMI signal or frame unavailable"
|
||||
case result == -3 || result == -4 || result == -5:
|
||||
snapshot.Message = "screenshot capture is temporarily unavailable"
|
||||
case result < 0 || len(data) == 0:
|
||||
snapshot.Message = "failed to capture screenshot"
|
||||
}
|
||||
|
||||
if timeoutMS == 0 || time.Now().Add(s.retryDelay).After(deadline) {
|
||||
return snapshot, nil
|
||||
}
|
||||
timer := time.NewTimer(s.retryDelay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return mcpservice.Snapshot{}, ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clamp(value int, minValue int, maxValue int) int {
|
||||
if value < minValue {
|
||||
return minValue
|
||||
}
|
||||
if value > maxValue {
|
||||
return maxValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
323
server/service/mcp/capture/capture_test.go
Normal file
323
server/service/mcp/capture/capture_test.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package mcpcapture
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mcpservice "NanoKVM-Server/service/mcp"
|
||||
)
|
||||
|
||||
func testJPEG(t *testing.T, width int, height int) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
img.Set(0, 0, color.White)
|
||||
var output bytes.Buffer
|
||||
if err := jpeg.Encode(&output, img, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return output.Bytes()
|
||||
}
|
||||
|
||||
type visionResponse struct {
|
||||
data []byte
|
||||
result int
|
||||
}
|
||||
|
||||
type fakeVision struct {
|
||||
mu sync.Mutex
|
||||
responses []visionResponse
|
||||
calls int
|
||||
quality uint16
|
||||
width uint16
|
||||
height uint16
|
||||
active int
|
||||
maxActive int
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func (v *fakeVision) ReadMjpeg(width uint16, height uint16, quality uint16) ([]byte, int) {
|
||||
v.mu.Lock()
|
||||
v.calls++
|
||||
v.quality = quality
|
||||
v.width = width
|
||||
v.height = height
|
||||
v.active++
|
||||
if v.active > v.maxActive {
|
||||
v.maxActive = v.active
|
||||
}
|
||||
index := v.calls - 1
|
||||
response := visionResponse{result: -1}
|
||||
if index < len(v.responses) {
|
||||
response = v.responses[index]
|
||||
} else if len(v.responses) > 0 {
|
||||
response = v.responses[len(v.responses)-1]
|
||||
}
|
||||
v.mu.Unlock()
|
||||
|
||||
time.Sleep(v.delay)
|
||||
|
||||
v.mu.Lock()
|
||||
v.active--
|
||||
v.mu.Unlock()
|
||||
return response.data, response.result
|
||||
}
|
||||
|
||||
func TestCaptureSuccessAndQualityBounds(t *testing.T) {
|
||||
vision := &fakeVision{responses: []visionResponse{{data: testJPEG(t, 1920, 1080), result: 0}}}
|
||||
snapshotter := New(vision, func() (uint16, uint16) { return 1920, 1080 })
|
||||
|
||||
snapshot, err := snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !snapshot.OK || snapshot.Width != 1920 || snapshot.Height != 1080 || vision.quality != defaultQuality || vision.width != 1920 || vision.height != 1080 {
|
||||
t.Fatalf("snapshot=%+v capture=%dx%d quality=%d", snapshot, vision.width, vision.height, vision.quality)
|
||||
}
|
||||
|
||||
_, err = snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{Quality: 200})
|
||||
if err != nil || vision.quality != maximumQuality {
|
||||
t.Fatalf("quality clamp=%d err=%v", vision.quality, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureRetriesNoSignal(t *testing.T) {
|
||||
vision := &fakeVision{responses: []visionResponse{{result: 5}, {result: -5}, {data: testJPEG(t, 1280, 720), result: 0}}}
|
||||
snapshotter := New(vision, func() (uint16, uint16) { return 1280, 720 })
|
||||
snapshotter.retryDelay = 0
|
||||
|
||||
snapshot, err := snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{})
|
||||
if err != nil || !snapshot.OK || vision.calls != 3 {
|
||||
t.Fatalf("snapshot=%+v calls=%d err=%v", snapshot, vision.calls, err)
|
||||
}
|
||||
|
||||
timeout := 0
|
||||
vision = &fakeVision{responses: []visionResponse{{result: 5}}}
|
||||
snapshotter = New(vision, func() (uint16, uint16) { return 1280, 720 })
|
||||
snapshot, err = snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{TimeoutMS: &timeout})
|
||||
if err != nil || snapshot.RetCode != 5 || vision.calls != 1 {
|
||||
t.Fatalf("timeout snapshot=%+v calls=%d err=%v", snapshot, vision.calls, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureRetriesInitialReadFailure(t *testing.T) {
|
||||
vision := &fakeVision{responses: []visionResponse{
|
||||
{result: -1},
|
||||
{data: testJPEG(t, 1280, 720), result: 0},
|
||||
}}
|
||||
snapshotter := New(vision, func() (uint16, uint16) { return 1280, 720 })
|
||||
snapshotter.retryDelay = 0
|
||||
|
||||
snapshot, err := snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{})
|
||||
if err != nil || !snapshot.OK || vision.calls != 2 {
|
||||
t.Fatalf("snapshot=%+v calls=%d err=%v", snapshot, vision.calls, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureRetriesInvalidJPEG(t *testing.T) {
|
||||
vision := &fakeVision{responses: []visionResponse{
|
||||
{data: []byte("partial jpeg"), result: 0},
|
||||
{data: testJPEG(t, 1280, 720), result: 0},
|
||||
}}
|
||||
snapshotter := New(vision, func() (uint16, uint16) { return 1280, 720 })
|
||||
snapshotter.retryDelay = 0
|
||||
|
||||
snapshot, err := snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{})
|
||||
if err != nil || !snapshot.OK || vision.calls != 2 {
|
||||
t.Fatalf("snapshot=%+v calls=%d err=%v", snapshot, vision.calls, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureRejectsCropAndEmptyData(t *testing.T) {
|
||||
vision := &fakeVision{responses: []visionResponse{{result: 0}}}
|
||||
snapshotter := New(vision, func() (uint16, uint16) { return 800, 600 })
|
||||
if _, err := snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{W: 10}); err == nil {
|
||||
t.Fatal("expected crop error")
|
||||
}
|
||||
if vision.calls != 0 {
|
||||
t.Fatalf("vision called for rejected crop: %d", vision.calls)
|
||||
}
|
||||
|
||||
snapshot, err := snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{})
|
||||
if err != nil || snapshot.OK || snapshot.Message == "" {
|
||||
t.Fatalf("snapshot=%+v err=%v", snapshot, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureSerializesConcurrentCalls(t *testing.T) {
|
||||
vision := &fakeVision{
|
||||
responses: []visionResponse{{data: testJPEG(t, 800, 600), result: 0}},
|
||||
delay: 10 * time.Millisecond,
|
||||
}
|
||||
snapshotter := New(vision, func() (uint16, uint16) { return 800, 600 })
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if vision.maxActive != 1 {
|
||||
t.Fatalf("max concurrent captures = %d", vision.maxActive)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapturePassesThroughAutomaticResolution(t *testing.T) {
|
||||
vision := &fakeVision{responses: []visionResponse{{data: testJPEG(t, 640, 480), result: 0}}}
|
||||
snapshotter := New(vision, func() (uint16, uint16) { return 0, 0 })
|
||||
|
||||
snapshot, err := snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !snapshot.OK || snapshot.Width != 640 || snapshot.Height != 480 || vision.width != 0 || vision.height != 0 {
|
||||
t.Fatalf("snapshot=%+v capture=%dx%d", snapshot, vision.width, vision.height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureDropsClaimedFirstFreshFrame(t *testing.T) {
|
||||
first := testJPEG(t, 320, 240)
|
||||
second := testJPEG(t, 640, 480)
|
||||
vision := &fakeVision{responses: []visionResponse{
|
||||
{data: first, result: 0},
|
||||
{data: second, result: 0},
|
||||
}}
|
||||
claimed := 0
|
||||
snapshotter := NewWithCaptureLease(vision, func() (uint16, uint16) { return 640, 480 }, func(context.Context) (func(), func() bool, error) {
|
||||
return nil, func() bool {
|
||||
claimed++
|
||||
return claimed == 1
|
||||
}, nil
|
||||
})
|
||||
|
||||
snapshot, err := snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{})
|
||||
if err != nil || !snapshot.OK || !bytes.Equal(snapshot.JPEG, second) || vision.calls != 2 || claimed != 1 {
|
||||
t.Fatalf("snapshot=%+v calls=%d claimed=%d err=%v", snapshot, vision.calls, claimed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureRejectsOversizedJPEG(t *testing.T) {
|
||||
data := make([]byte, maximumJPEGBytes+1)
|
||||
vision := &fakeVision{responses: []visionResponse{{data: data, result: 0}}}
|
||||
snapshotter := New(vision, func() (uint16, uint16) { return 960, 540 })
|
||||
|
||||
snapshot, err := snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{})
|
||||
if err != nil || snapshot.OK || len(snapshot.JPEG) != 0 || snapshot.Message == "" {
|
||||
t.Fatalf("snapshot=%+v err=%v", snapshot, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureCancellationWhileWaitingForSlot(t *testing.T) {
|
||||
snapshotter := New(&fakeVision{}, func() (uint16, uint16) { return 0, 0 })
|
||||
snapshotter.captureSlot <- struct{}{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err := snapshotter.Capture(ctx, mcpservice.SnapshotRequest{})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("error=%v, want canceled", err)
|
||||
}
|
||||
<-snapshotter.captureSlot
|
||||
}
|
||||
|
||||
func TestCaptureTimeoutStartsAfterSlotIsAcquired(t *testing.T) {
|
||||
vision := &fakeVision{responses: []visionResponse{{data: testJPEG(t, 320, 240), result: 0}}}
|
||||
snapshotter := New(vision, func() (uint16, uint16) { return 320, 240 })
|
||||
snapshotter.captureSlot <- struct{}{}
|
||||
|
||||
timeout := 1
|
||||
done := make(chan struct {
|
||||
snapshot mcpservice.Snapshot
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
snapshot, err := snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{TimeoutMS: &timeout})
|
||||
done <- struct {
|
||||
snapshot mcpservice.Snapshot
|
||||
err error
|
||||
}{snapshot: snapshot, err: err}
|
||||
}()
|
||||
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
<-snapshotter.captureSlot
|
||||
select {
|
||||
case result := <-done:
|
||||
if result.err != nil || !result.snapshot.OK {
|
||||
t.Fatalf("snapshot=%+v err=%v, want success after queue wait", result.snapshot, result.err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("capture did not complete after slot release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureReleasesLeaseOnSuccessAndCancellation(t *testing.T) {
|
||||
vision := &fakeVision{responses: []visionResponse{{data: testJPEG(t, 320, 240), result: 0}}}
|
||||
acquired := 0
|
||||
released := 0
|
||||
snapshotter := NewWithCaptureLease(vision, func() (uint16, uint16) { return 320, 240 }, func(context.Context) (func(), func() bool, error) {
|
||||
acquired++
|
||||
return func() { released++ }, nil, nil
|
||||
})
|
||||
|
||||
if _, err := snapshotter.Capture(context.Background(), mcpservice.SnapshotRequest{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if acquired != 1 || released != 1 {
|
||||
t.Fatalf("lease acquired=%d released=%d, want 1/1", acquired, released)
|
||||
}
|
||||
|
||||
canceled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := snapshotter.Capture(canceled, mcpservice.SnapshotRequest{}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("error = %v, want canceled", err)
|
||||
}
|
||||
if acquired != 1 || released != 1 {
|
||||
t.Fatalf("lease acquired=%d released=%d after cancellation before acquisition, want 1/1", acquired, released)
|
||||
}
|
||||
}
|
||||
|
||||
type blockingVision struct {
|
||||
started chan struct{}
|
||||
finish chan struct{}
|
||||
}
|
||||
|
||||
func (v *blockingVision) ReadMjpeg(_ uint16, _ uint16, _ uint16) ([]byte, int) {
|
||||
v.started <- struct{}{}
|
||||
<-v.finish
|
||||
return nil, 5
|
||||
}
|
||||
|
||||
func TestCaptureReleasesLeaseAfterAcquisitionCancellation(t *testing.T) {
|
||||
vision := &blockingVision{started: make(chan struct{}, 1), finish: make(chan struct{})}
|
||||
acquired := 0
|
||||
released := 0
|
||||
snapshotter := NewWithCaptureLease(vision, func() (uint16, uint16) { return 320, 240 }, func(context.Context) (func(), func() bool, error) {
|
||||
acquired++
|
||||
return func() { released++ }, nil, nil
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := snapshotter.Capture(ctx, mcpservice.SnapshotRequest{})
|
||||
done <- err
|
||||
}()
|
||||
<-vision.started
|
||||
cancel()
|
||||
close(vision.finish)
|
||||
if err := <-done; !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("error = %v, want canceled", err)
|
||||
}
|
||||
if acquired != 1 || released != 1 {
|
||||
t.Fatalf("lease acquired=%d released=%d after cancellation during capture, want 1/1", acquired, released)
|
||||
}
|
||||
}
|
||||
16
server/service/mcp/capture/kvm/kvm.go
Normal file
16
server/service/mcp/capture/kvm/kvm.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package kvmcapture
|
||||
|
||||
import (
|
||||
"NanoKVM-Server/common"
|
||||
mcpservice "NanoKVM-Server/service/mcp"
|
||||
"NanoKVM-Server/service/mcp/capture"
|
||||
"NanoKVM-Server/service/vm"
|
||||
)
|
||||
|
||||
func New() mcpservice.Snapshotter {
|
||||
return mcpcapture.NewWithCaptureLease(common.GetKvmVision(), func() (uint16, uint16) {
|
||||
screen := common.GetScreen()
|
||||
common.CheckScreen()
|
||||
return screen.Width, screen.Height
|
||||
}, vm.AcquireHdmiCaptureLeaseForRead)
|
||||
}
|
||||
154
server/service/mcp/config.go
Normal file
154
server/service/mcp/config.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package mcpservice
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
ConfigFile = "/etc/kvm/mcp.json"
|
||||
apiKeyPrefix = "nag_mcp_"
|
||||
apiKeyBytes = 32
|
||||
)
|
||||
|
||||
var (
|
||||
configMu sync.Mutex
|
||||
configFilePath = ConfigFile
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
APIKey string `json:"apiKey"`
|
||||
}
|
||||
|
||||
func loadConfig() (Config, error) {
|
||||
configMu.Lock()
|
||||
defer configMu.Unlock()
|
||||
|
||||
return loadConfigFromPath(configFilePath)
|
||||
}
|
||||
|
||||
func updateConfig(update func(Config) (Config, error)) (Config, error) {
|
||||
configMu.Lock()
|
||||
defer configMu.Unlock()
|
||||
|
||||
cfg, err := loadConfigFromPath(configFilePath)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
updated, err := update(cfg)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := saveConfigToPath(configFilePath, updated); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func loadConfigFromPath(path string) (Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return Config{}, nil
|
||||
}
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return Config{}, fmt.Errorf("decode MCP config: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func saveConfigToPath(path string, cfg Config) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create MCP config directory: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode MCP config: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
|
||||
tmp, err := os.CreateTemp(dir, ".mcp.json.*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary MCP config: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer func() { _ = os.Remove(tmpPath) }()
|
||||
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("set temporary MCP config permissions: %w", err)
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("write temporary MCP config: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("sync temporary MCP config: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temporary MCP config: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
return fmt.Errorf("replace MCP config: %w", err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
return fmt.Errorf("set MCP config permissions: %w", err)
|
||||
}
|
||||
|
||||
directory, err := os.Open(dir)
|
||||
if err == nil {
|
||||
if syncErr := directory.Sync(); syncErr != nil {
|
||||
_ = directory.Close()
|
||||
return fmt.Errorf("sync MCP config directory: %w", syncErr)
|
||||
}
|
||||
_ = directory.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureAPIKey(cfg Config) (Config, error) {
|
||||
if cfg.APIKey != "" {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
key, err := generateAPIKey()
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.APIKey = key
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func regenerateAPIKey(cfg Config) (Config, error) {
|
||||
key, err := generateAPIKey()
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.APIKey = key
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func generateAPIKey() (string, error) {
|
||||
raw := make([]byte, apiKeyBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("generate MCP API key: %w", err)
|
||||
}
|
||||
|
||||
return apiKeyPrefix + base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
262
server/service/mcp/config_test.go
Normal file
262
server/service/mcp/config_test.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package mcpservice
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func useTestConfig(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "mcp.json")
|
||||
oldPath := configFilePath
|
||||
configFilePath = path
|
||||
t.Cleanup(func() { configFilePath = oldPath })
|
||||
return path
|
||||
}
|
||||
|
||||
func TestConfigLifecycle(t *testing.T) {
|
||||
path := useTestConfig(t)
|
||||
|
||||
cfg, err := loadConfig()
|
||||
if err != nil || cfg.APIKey != "" {
|
||||
t.Fatalf("unexpected missing config result: cfg=%+v err=%v", cfg, err)
|
||||
}
|
||||
|
||||
cfg, err = updateConfig(func(cfg Config) (Config, error) {
|
||||
cfg, err = ensureAPIKey(cfg)
|
||||
return cfg, err
|
||||
})
|
||||
if err != nil || !strings.HasPrefix(cfg.APIKey, apiKeyPrefix) {
|
||||
t.Fatalf("enable MCP: cfg=%+v err=%v", cfg, err)
|
||||
}
|
||||
firstKey := cfg.APIKey
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat config: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("config permissions = %o, want 600", info.Mode().Perm())
|
||||
}
|
||||
|
||||
cfg, err = updateConfig(ensureAPIKey)
|
||||
if err != nil || cfg.APIKey != firstKey {
|
||||
t.Fatalf("ensure existing key changed it: cfg=%+v err=%v", cfg, err)
|
||||
}
|
||||
cfg, err = updateConfig(regenerateAPIKey)
|
||||
if err != nil || cfg.APIKey == firstKey {
|
||||
t.Fatalf("regenerate did not replace key: cfg=%+v err=%v", cfg, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigRejectsCorruptJSON(t *testing.T) {
|
||||
path := useTestConfig(t)
|
||||
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadConfig(); err == nil {
|
||||
t.Fatal("expected corrupt config error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKeyMiddleware(t *testing.T) {
|
||||
useTestConfig(t)
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeOff)
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/api/mcp", APIKeyMiddleware(control), func(c *gin.Context) { c.Status(http.StatusNoContent) })
|
||||
|
||||
writeConfig := func(cfg Config) {
|
||||
if _, err := updateConfig(func(Config) (Config, error) { return cfg, nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
request := func(header string) *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/mcp", nil)
|
||||
if header != "" {
|
||||
req.Header.Set("Authorization", header)
|
||||
}
|
||||
router.ServeHTTP(recorder, req)
|
||||
return recorder
|
||||
}
|
||||
|
||||
writeConfig(Config{APIKey: "secret"})
|
||||
if got := request("Bearer secret").Code; got != http.StatusForbidden {
|
||||
t.Fatalf("disabled status = %d", got)
|
||||
}
|
||||
if err := control.SwitchToMCP(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, header := range []string{"", "secret", "Basic secret", "Bearer wrong"} {
|
||||
if got := request(header).Code; got != http.StatusUnauthorized {
|
||||
t.Fatalf("header %q status = %d", header, got)
|
||||
}
|
||||
}
|
||||
if got := request("Bearer secret").Code; got != http.StatusNoContent {
|
||||
t.Fatalf("valid key status = %d", got)
|
||||
}
|
||||
writeConfig(Config{APIKey: "rotated"})
|
||||
if got := request("Bearer secret").Code; got != http.StatusUnauthorized {
|
||||
t.Fatalf("old key after rotation status = %d", got)
|
||||
}
|
||||
if got := request("Bearer rotated").Code; got != http.StatusNoContent {
|
||||
t.Fatalf("rotated key status = %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetConfigAPI(t *testing.T) {
|
||||
useTestConfig(t)
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw)
|
||||
preemptLeasesCalled := false
|
||||
stopRuntimeCalled := false
|
||||
releaseCalled := false
|
||||
gin.SetMode(gin.TestMode)
|
||||
service := NewServiceWithPreempt(control, func() error {
|
||||
preemptLeasesCalled = true
|
||||
return nil
|
||||
}, func() error {
|
||||
stopRuntimeCalled = true
|
||||
return nil
|
||||
}, func() error {
|
||||
releaseCalled = true
|
||||
return nil
|
||||
}, nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/mcp/config", strings.NewReader(`{"enabled":true}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
service.SetConfig(c)
|
||||
if got := recorder.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("Cache-Control = %q, want no-store", got)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"apiKey"`
|
||||
ControlMode string `json:"controlMode"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Code != 0 || !response.Data.Enabled || response.Data.APIKey == "" || response.Data.ControlMode != string(controlmode.ModeMCP) || !preemptLeasesCalled || !stopRuntimeCalled || !releaseCalled {
|
||||
t.Fatalf("unexpected response: %+v", response)
|
||||
}
|
||||
|
||||
recorder = httptest.NewRecorder()
|
||||
c, _ = gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/mcp/config", strings.NewReader(`{"enabled":false}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
service.SetConfig(c)
|
||||
if control.Current() != controlmode.ModeOff {
|
||||
t.Fatalf("mode after disabling MCP = %q, want off", control.Current())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetConfigEnablePreemptFailureDoesNotEnableMCP(t *testing.T) {
|
||||
useTestConfig(t)
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw)
|
||||
wantErr := errors.New("picoclaw stop failed")
|
||||
service := NewServiceWithPreempt(control, func() error {
|
||||
return wantErr
|
||||
}, func() error {
|
||||
t.Fatal("runtime stop should not run when soft preempt fails")
|
||||
return nil
|
||||
}, func() error {
|
||||
t.Fatal("release should not run when preempt fails")
|
||||
return nil
|
||||
}, nil)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/mcp/config", strings.NewReader(`{"enabled":true}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
service.SetConfig(c)
|
||||
|
||||
if control.Current() != controlmode.ModePicoclaw {
|
||||
t.Fatalf("mode after failed preempt = %q, want picoclaw", control.Current())
|
||||
}
|
||||
var response struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Code != -2 || !strings.Contains(response.Msg, wantErr.Error()) {
|
||||
t.Fatalf("response = %+v, want preempt failure", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetConfigEnableCleanupFailureFailsClosed(t *testing.T) {
|
||||
useTestConfig(t)
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw)
|
||||
wantErr := errors.New("picoclaw stop failed")
|
||||
preemptCalled := false
|
||||
releaseCalled := false
|
||||
service := NewServiceWithPreempt(control, func() error {
|
||||
preemptCalled = true
|
||||
return nil
|
||||
}, func() error {
|
||||
return wantErr
|
||||
}, func() error {
|
||||
releaseCalled = true
|
||||
return nil
|
||||
}, nil)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/mcp/config", strings.NewReader(`{"enabled":true}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
service.SetConfig(c)
|
||||
|
||||
if control.Current() != controlmode.ModeOff {
|
||||
t.Fatalf("mode after failed destructive cleanup = %q, want off", control.Current())
|
||||
}
|
||||
if !preemptCalled || !releaseCalled {
|
||||
t.Fatalf("preemptCalled=%v releaseCalled=%v, want both true", preemptCalled, releaseCalled)
|
||||
}
|
||||
var response struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Code != -2 || !strings.Contains(response.Msg, wantErr.Error()) {
|
||||
t.Fatalf("response = %+v, want cleanup failure", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetConfigEnableReleaseFailureFailsClosed(t *testing.T) {
|
||||
useTestConfig(t)
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw)
|
||||
service := NewService(control, func() error { return errors.New("release failed") })
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/mcp/config", strings.NewReader(`{"enabled":true}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
service.SetConfig(c)
|
||||
|
||||
if control.Current() != controlmode.ModeOff {
|
||||
t.Fatalf("mode after failed enable = %q, want off", control.Current())
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
if err != nil || cfg.APIKey == "" {
|
||||
t.Fatalf("API key should be persisted before switching: cfg=%+v err=%v", cfg, err)
|
||||
}
|
||||
}
|
||||
122
server/service/mcp/keyboard.go
Normal file
122
server/service/mcp/keyboard.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package mcpservice
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"NanoKVM-Server/service/hid"
|
||||
)
|
||||
|
||||
const maxKeyboardKeys = 6
|
||||
|
||||
var keyUpReport = []byte{0, 0, 0, 0, 0, 0, 0, 0}
|
||||
|
||||
var modifierMap = map[string]byte{
|
||||
"ControlLeft": 1 << 0,
|
||||
"ShiftLeft": 1 << 1,
|
||||
"AltLeft": 1 << 2,
|
||||
"MetaLeft": 1 << 3,
|
||||
"ControlRight": 1 << 4,
|
||||
"ShiftRight": 1 << 5,
|
||||
"AltRight": 1 << 6,
|
||||
"MetaRight": 1 << 7,
|
||||
}
|
||||
|
||||
var keyCodeMap = map[string]byte{
|
||||
"KeyA": 0x04, "KeyB": 0x05, "KeyC": 0x06, "KeyD": 0x07, "KeyE": 0x08,
|
||||
"KeyF": 0x09, "KeyG": 0x0a, "KeyH": 0x0b, "KeyI": 0x0c, "KeyJ": 0x0d,
|
||||
"KeyK": 0x0e, "KeyL": 0x0f, "KeyM": 0x10, "KeyN": 0x11, "KeyO": 0x12,
|
||||
"KeyP": 0x13, "KeyQ": 0x14, "KeyR": 0x15, "KeyS": 0x16, "KeyT": 0x17,
|
||||
"KeyU": 0x18, "KeyV": 0x19, "KeyW": 0x1a, "KeyX": 0x1b, "KeyY": 0x1c,
|
||||
"KeyZ": 0x1d,
|
||||
|
||||
"Digit1": 0x1e, "Digit2": 0x1f, "Digit3": 0x20, "Digit4": 0x21, "Digit5": 0x22,
|
||||
"Digit6": 0x23, "Digit7": 0x24, "Digit8": 0x25, "Digit9": 0x26, "Digit0": 0x27,
|
||||
|
||||
"Enter": 0x28, "Escape": 0x29, "Backspace": 0x2a, "Tab": 0x2b, "Space": 0x2c,
|
||||
"Minus": 0x2d, "Equal": 0x2e, "BracketLeft": 0x2f, "BracketRight": 0x30,
|
||||
"Backslash": 0x31, "IntlHash": 0x32, "Semicolon": 0x33, "Quote": 0x34,
|
||||
"Backquote": 0x35, "Comma": 0x36, "Period": 0x37, "Slash": 0x38,
|
||||
"CapsLock": 0x39,
|
||||
|
||||
"F1": 0x3a, "F2": 0x3b, "F3": 0x3c, "F4": 0x3d, "F5": 0x3e, "F6": 0x3f,
|
||||
"F7": 0x40, "F8": 0x41, "F9": 0x42, "F10": 0x43, "F11": 0x44, "F12": 0x45,
|
||||
|
||||
"PrintScreen": 0x46, "ScrollLock": 0x47, "Pause": 0x48, "Insert": 0x49,
|
||||
"Home": 0x4a, "PageUp": 0x4b, "Delete": 0x4c, "End": 0x4d,
|
||||
"PageDown": 0x4e, "ArrowRight": 0x4f, "ArrowLeft": 0x50,
|
||||
"ArrowDown": 0x51, "ArrowUp": 0x52,
|
||||
|
||||
"NumLock": 0x53, "NumpadDivide": 0x54, "NumpadMultiply": 0x55,
|
||||
"NumpadSubtract": 0x56, "NumpadAdd": 0x57, "NumpadEnter": 0x58,
|
||||
"Numpad1": 0x59, "Numpad2": 0x5a, "Numpad3": 0x5b, "Numpad4": 0x5c,
|
||||
"Numpad5": 0x5d, "Numpad6": 0x5e, "Numpad7": 0x5f, "Numpad8": 0x60,
|
||||
"Numpad9": 0x61, "Numpad0": 0x62, "NumpadDecimal": 0x63,
|
||||
"IntlBackslash": 0x64, "ContextMenu": 0x65,
|
||||
}
|
||||
|
||||
func supportedKeyNames() []any {
|
||||
names := make([]string, 0, len(modifierMap)+len(keyCodeMap))
|
||||
for name := range modifierMap {
|
||||
names = append(names, name)
|
||||
}
|
||||
for name := range keyCodeMap {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
values := make([]any, len(names))
|
||||
for index, name := range names {
|
||||
values[index] = name
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func buildKeyComboReport(keys []string) ([]byte, error) {
|
||||
report := make([]byte, 8)
|
||||
seenKeys := make(map[byte]struct{})
|
||||
keyIndex := 2
|
||||
|
||||
for _, key := range keys {
|
||||
if modifier, ok := modifierMap[key]; ok {
|
||||
report[0] |= modifier
|
||||
continue
|
||||
}
|
||||
|
||||
code, ok := keyCodeMap[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown key: %s", key)
|
||||
}
|
||||
if _, ok := seenKeys[code]; ok {
|
||||
continue
|
||||
}
|
||||
if keyIndex >= 2+maxKeyboardKeys {
|
||||
return nil, fmt.Errorf("too many non-modifier keys")
|
||||
}
|
||||
seenKeys[code] = struct{}{}
|
||||
report[keyIndex] = code
|
||||
keyIndex++
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func buildTypeReports(text string) ([][]byte, []rune) {
|
||||
charMap := hid.GetCharMap("")
|
||||
reports := make([][]byte, 0, len(text)*2)
|
||||
skipped := make([]rune, 0)
|
||||
|
||||
for _, char := range text {
|
||||
key, ok := charMap[char]
|
||||
if !ok {
|
||||
skipped = append(skipped, char)
|
||||
continue
|
||||
}
|
||||
reports = append(reports,
|
||||
[]byte{byte(key.Modifiers), 0, byte(key.Code), 0, 0, 0, 0, 0},
|
||||
append([]byte(nil), keyUpReport...),
|
||||
)
|
||||
}
|
||||
|
||||
return reports, skipped
|
||||
}
|
||||
37
server/service/mcp/keyboard_test.go
Normal file
37
server/service/mcp/keyboard_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package mcpservice
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildKeyComboReport(t *testing.T) {
|
||||
report, err := buildKeyComboReport([]string{"ControlLeft", "AltLeft", "Delete"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report[0] != 0x05 || report[2] != 0x4c {
|
||||
t.Fatalf("unexpected combo report: %v", report)
|
||||
}
|
||||
if _, err := buildKeyComboReport([]string{"Unknown"}); err == nil {
|
||||
t.Fatal("expected unknown key error")
|
||||
}
|
||||
if _, err := buildKeyComboReport([]string{"KeyA", "KeyB", "KeyC", "KeyD", "KeyE", "KeyF", "KeyG"}); err == nil {
|
||||
t.Fatal("expected six-key limit error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTypeReportsReturnsSkippedRunes(t *testing.T) {
|
||||
reports, skipped := buildTypeReports("A界")
|
||||
if len(reports) != 2 || string(skipped) != "界" {
|
||||
t.Fatalf("reports=%d skipped=%q", len(reports), string(skipped))
|
||||
}
|
||||
if reports[0][0] != 2 || reports[0][2] != 4 || string(reports[1]) != string(keyUpReport) {
|
||||
t.Fatalf("unexpected reports: %v", reports)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportedKeyNamesMatchKeyboardDescriptor(t *testing.T) {
|
||||
for name, code := range keyCodeMap {
|
||||
if code > 0x65 {
|
||||
t.Fatalf("key %s uses unsupported descriptor code %#x", name, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
93
server/service/mcp/mouse.go
Normal file
93
server/service/mcp/mouse.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package mcpservice
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
const (
|
||||
maxRelativeMouseReports = 1024
|
||||
maxRelativeMouseDelta = 127 * maxRelativeMouseReports
|
||||
)
|
||||
|
||||
func absoluteCoordinate(value float64) uint16 {
|
||||
value = math.Max(0, math.Min(1, value))
|
||||
return uint16(math.Floor(0x7fff*value)) + 1
|
||||
}
|
||||
|
||||
func buildAbsolutePointerReport(x uint16, y uint16, buttons byte, wheel int) []byte {
|
||||
return []byte{
|
||||
buttons,
|
||||
byte(x), byte(x >> 8),
|
||||
byte(y), byte(y >> 8),
|
||||
byte(int8(clampInt(wheel, -127, 127))),
|
||||
}
|
||||
}
|
||||
|
||||
func buildRelativeMouseReport(deltaX int, deltaY int, buttons byte, wheel int) []byte {
|
||||
return []byte{
|
||||
buttons,
|
||||
byte(int8(clampInt(deltaX, -127, 127))),
|
||||
byte(int8(clampInt(deltaY, -127, 127))),
|
||||
byte(int8(clampInt(wheel, -127, 127))),
|
||||
}
|
||||
}
|
||||
|
||||
func buildRelativeMoveReports(deltaX int, deltaY int) ([][]byte, error) {
|
||||
if relativeReportCount(deltaX, deltaY) > maxRelativeMouseReports {
|
||||
return nil, fmt.Errorf("relative movement exceeds %d reports", maxRelativeMouseReports)
|
||||
}
|
||||
|
||||
reports := make([][]byte, 0)
|
||||
for deltaX != 0 || deltaY != 0 {
|
||||
x := clampInt(deltaX, -127, 127)
|
||||
y := clampInt(deltaY, -127, 127)
|
||||
reports = append(reports, buildRelativeMouseReport(x, y, 0, 0))
|
||||
deltaX -= x
|
||||
deltaY -= y
|
||||
}
|
||||
return reports, nil
|
||||
}
|
||||
|
||||
func mouseButtonBit(button string) (byte, error) {
|
||||
switch button {
|
||||
case "left":
|
||||
return 1 << 0, nil
|
||||
case "right":
|
||||
return 1 << 1, nil
|
||||
case "middle":
|
||||
return 1 << 2, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown mouse button: %s", button)
|
||||
}
|
||||
}
|
||||
|
||||
func clampInt(value int, minValue int, maxValue int) int {
|
||||
if value < minValue {
|
||||
return minValue
|
||||
}
|
||||
if value > maxValue {
|
||||
return maxValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func relativeReportCount(deltaX int, deltaY int) int {
|
||||
xReports := reportsForDelta(deltaX)
|
||||
yReports := reportsForDelta(deltaY)
|
||||
if xReports > yReports {
|
||||
return xReports
|
||||
}
|
||||
return yReports
|
||||
}
|
||||
|
||||
func reportsForDelta(delta int) int {
|
||||
value := int64(delta)
|
||||
if value == 0 {
|
||||
return 0
|
||||
}
|
||||
if value < 0 {
|
||||
return int((-(value + 1))/127 + 1)
|
||||
}
|
||||
return int((value-1)/127 + 1)
|
||||
}
|
||||
53
server/service/mcp/mouse_test.go
Normal file
53
server/service/mcp/mouse_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package mcpservice
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAbsoluteCoordinateUsesNanoKVMRange(t *testing.T) {
|
||||
tests := []struct {
|
||||
value float64
|
||||
want uint16
|
||||
}{
|
||||
{-1, 1},
|
||||
{0, 1},
|
||||
{0.5, 0x4000},
|
||||
{1, 0x8000},
|
||||
{2, 0x8000},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if got := absoluteCoordinate(test.value); got != test.want {
|
||||
t.Fatalf("absoluteCoordinate(%v) = %#x, want %#x", test.value, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbsoluteReportIsSixBytes(t *testing.T) {
|
||||
report := buildAbsolutePointerReport(0x1234, 0x5678, 1, -2)
|
||||
want := []byte{1, 0x34, 0x12, 0x78, 0x56, 0xfe}
|
||||
if string(report) != string(want) {
|
||||
t.Fatalf("report = %v, want %v", report, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeMovementSplitsReports(t *testing.T) {
|
||||
reports, err := buildRelativeMoveReports(300, -300)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(reports) != 3 {
|
||||
t.Fatalf("report count = %d, want 3", len(reports))
|
||||
}
|
||||
if reports[0][1] != byte(int8(127)) || int8(reports[0][2]) != -127 {
|
||||
t.Fatalf("first report = %v", reports[0])
|
||||
}
|
||||
if _, err := buildRelativeMoveReports(127*(maxRelativeMouseReports+1), 0); err == nil {
|
||||
t.Fatal("expected oversized movement error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsupportedAuxiliaryMouseButtons(t *testing.T) {
|
||||
for _, button := range []string{"back", "forward"} {
|
||||
if _, err := mouseButtonBit(button); err == nil {
|
||||
t.Fatalf("button %q should be rejected", button)
|
||||
}
|
||||
}
|
||||
}
|
||||
234
server/service/mcp/remote.go
Normal file
234
server/service/mcp/remote.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package mcpservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/service/hid"
|
||||
)
|
||||
|
||||
type HIDWriter interface {
|
||||
WriteKeyboardReport([]byte) error
|
||||
WriteRelativeMouseReport([]byte) error
|
||||
WriteAbsoluteMouseReport([]byte) error
|
||||
}
|
||||
|
||||
type Remote struct {
|
||||
hid HIDWriter
|
||||
hidMu sync.Mutex
|
||||
}
|
||||
|
||||
func NewRemote() *Remote {
|
||||
return &Remote{hid: hid.GetHid()}
|
||||
}
|
||||
|
||||
func newRemoteWithHID(writer HIDWriter) *Remote {
|
||||
return &Remote{hid: writer}
|
||||
}
|
||||
|
||||
func (r *Remote) TypeText(ctx context.Context, text string, delay time.Duration) ([]rune, error) {
|
||||
reports, skipped := buildTypeReports(text)
|
||||
r.hidMu.Lock()
|
||||
defer r.hidMu.Unlock()
|
||||
|
||||
keyDown := false
|
||||
for index, report := range reports {
|
||||
if err := contextError(ctx); err != nil {
|
||||
if keyDown {
|
||||
_ = r.hid.WriteKeyboardReport(keyUpReport)
|
||||
}
|
||||
return skipped, err
|
||||
}
|
||||
if err := r.hid.WriteKeyboardReport(report); err != nil {
|
||||
_ = r.hid.WriteKeyboardReport(keyUpReport)
|
||||
return skipped, err
|
||||
}
|
||||
keyDown = index%2 == 0
|
||||
if !keyDown && index+1 < len(reports) {
|
||||
if err := sleepContext(ctx, delay); err != nil {
|
||||
return skipped, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return skipped, nil
|
||||
}
|
||||
|
||||
func (r *Remote) PressKeys(ctx context.Context, keys []string, hold time.Duration) error {
|
||||
report, err := buildKeyComboReport(keys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.hidMu.Lock()
|
||||
defer r.hidMu.Unlock()
|
||||
|
||||
if err := contextError(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.hid.WriteKeyboardReport(report); err != nil {
|
||||
_ = r.hid.WriteKeyboardReport(keyUpReport)
|
||||
return err
|
||||
}
|
||||
if err := sleepContext(ctx, hold); err != nil {
|
||||
_ = r.hid.WriteKeyboardReport(keyUpReport)
|
||||
return err
|
||||
}
|
||||
return r.hid.WriteKeyboardReport(keyUpReport)
|
||||
}
|
||||
|
||||
func (r *Remote) MoveAbsolute(ctx context.Context, x float64, y float64) error {
|
||||
if x < 0 || x > 1 || y < 0 || y > 1 {
|
||||
return fmt.Errorf("absolute coordinates must be between 0 and 1")
|
||||
}
|
||||
report := buildAbsolutePointerReport(absoluteCoordinate(x), absoluteCoordinate(y), 0, 0)
|
||||
r.hidMu.Lock()
|
||||
defer r.hidMu.Unlock()
|
||||
if err := contextError(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.hid.WriteAbsoluteMouseReport(report)
|
||||
}
|
||||
|
||||
func (r *Remote) TouchAbsoluteButton(ctx context.Context, x float64, y float64, button string, hold time.Duration) error {
|
||||
if x < 0 || x > 1 || y < 0 || y > 1 {
|
||||
return fmt.Errorf("absolute coordinates must be between 0 and 1")
|
||||
}
|
||||
bit, err := mouseButtonBit(button)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hidX := absoluteCoordinate(x)
|
||||
hidY := absoluteCoordinate(y)
|
||||
release := buildAbsolutePointerReport(hidX, hidY, 0, 0)
|
||||
|
||||
r.hidMu.Lock()
|
||||
defer r.hidMu.Unlock()
|
||||
|
||||
if err := contextError(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.hid.WriteAbsoluteMouseReport(buildAbsolutePointerReport(hidX, hidY, bit, 0)); err != nil {
|
||||
_ = r.hid.WriteAbsoluteMouseReport(release)
|
||||
return err
|
||||
}
|
||||
if err := sleepContext(ctx, hold); err != nil {
|
||||
_ = r.hid.WriteAbsoluteMouseReport(release)
|
||||
return err
|
||||
}
|
||||
return r.hid.WriteAbsoluteMouseReport(release)
|
||||
}
|
||||
|
||||
func (r *Remote) MoveRelative(ctx context.Context, deltaX int, deltaY int) error {
|
||||
reports, err := buildRelativeMoveReports(deltaX, deltaY)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.hidMu.Lock()
|
||||
defer r.hidMu.Unlock()
|
||||
for _, report := range reports {
|
||||
if err := contextError(ctx); err != nil {
|
||||
_ = r.hid.WriteRelativeMouseReport(buildRelativeMouseReport(0, 0, 0, 0))
|
||||
return err
|
||||
}
|
||||
if err := r.hid.WriteRelativeMouseReport(report); err != nil {
|
||||
_ = r.hid.WriteRelativeMouseReport(buildRelativeMouseReport(0, 0, 0, 0))
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Remote) Click(ctx context.Context, button string, clicks int, delay time.Duration) error {
|
||||
bit, err := mouseButtonBit(button)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
clicks = clampInt(clicks, 1, 5)
|
||||
release := buildRelativeMouseReport(0, 0, 0, 0)
|
||||
|
||||
r.hidMu.Lock()
|
||||
defer r.hidMu.Unlock()
|
||||
for i := 0; i < clicks; i++ {
|
||||
if err := contextError(ctx); err != nil {
|
||||
_ = r.hid.WriteRelativeMouseReport(release)
|
||||
return err
|
||||
}
|
||||
if err := r.hid.WriteRelativeMouseReport(buildRelativeMouseReport(0, 0, bit, 0)); err != nil {
|
||||
_ = r.hid.WriteRelativeMouseReport(release)
|
||||
return err
|
||||
}
|
||||
if err := sleepContext(ctx, delay); err != nil {
|
||||
_ = r.hid.WriteRelativeMouseReport(release)
|
||||
return err
|
||||
}
|
||||
if err := r.hid.WriteRelativeMouseReport(release); err != nil {
|
||||
_ = r.hid.WriteRelativeMouseReport(release)
|
||||
return err
|
||||
}
|
||||
if i+1 < clicks {
|
||||
if err := sleepContext(ctx, delay); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Remote) Scroll(ctx context.Context, deltaY int) error {
|
||||
if reportsForDelta(deltaY) > maxRelativeMouseReports {
|
||||
return fmt.Errorf("scroll exceeds %d reports", maxRelativeMouseReports)
|
||||
}
|
||||
|
||||
x := absoluteCoordinate(0.5)
|
||||
y := absoluteCoordinate(0.5)
|
||||
release := buildAbsolutePointerReport(x, y, 0, 0)
|
||||
|
||||
r.hidMu.Lock()
|
||||
defer r.hidMu.Unlock()
|
||||
for deltaY != 0 {
|
||||
if err := contextError(ctx); err != nil {
|
||||
_ = r.hid.WriteAbsoluteMouseReport(release)
|
||||
return err
|
||||
}
|
||||
wheel := clampInt(deltaY, -127, 127)
|
||||
if err := r.hid.WriteAbsoluteMouseReport(buildAbsolutePointerReport(x, y, 0, wheel)); err != nil {
|
||||
_ = r.hid.WriteAbsoluteMouseReport(release)
|
||||
return err
|
||||
}
|
||||
if err := r.hid.WriteAbsoluteMouseReport(release); err != nil {
|
||||
_ = r.hid.WriteAbsoluteMouseReport(release)
|
||||
return err
|
||||
}
|
||||
deltaY -= wheel
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sleepContext(ctx context.Context, delay time.Duration) error {
|
||||
if delay <= 0 {
|
||||
return contextError(ctx)
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return context.Cause(ctx)
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func contextError(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return context.Cause(ctx)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
115
server/service/mcp/remote_test.go
Normal file
115
server/service/mcp/remote_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package mcpservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type recordingHID struct {
|
||||
keyboard [][]byte
|
||||
relative [][]byte
|
||||
absolute [][]byte
|
||||
failAt int
|
||||
writes int
|
||||
onWrite func(int)
|
||||
}
|
||||
|
||||
func (h *recordingHID) record(target *[][]byte, report []byte) error {
|
||||
h.writes++
|
||||
if h.onWrite != nil {
|
||||
h.onWrite(h.writes)
|
||||
}
|
||||
*target = append(*target, append([]byte(nil), report...))
|
||||
if h.failAt > 0 && h.writes == h.failAt {
|
||||
return errors.New("write failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *recordingHID) WriteKeyboardReport(report []byte) error {
|
||||
return h.record(&h.keyboard, report)
|
||||
}
|
||||
|
||||
func (h *recordingHID) WriteRelativeMouseReport(report []byte) error {
|
||||
return h.record(&h.relative, report)
|
||||
}
|
||||
|
||||
func (h *recordingHID) WriteAbsoluteMouseReport(report []byte) error {
|
||||
return h.record(&h.absolute, report)
|
||||
}
|
||||
|
||||
func TestRemotePressKeysAlwaysReleases(t *testing.T) {
|
||||
hid := &recordingHID{}
|
||||
remote := newRemoteWithHID(hid)
|
||||
if err := remote.PressKeys(context.Background(), []string{"ShiftLeft", "KeyA"}, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hid.keyboard) != 2 || string(hid.keyboard[1]) != string(keyUpReport) {
|
||||
t.Fatalf("keyboard reports: %v", hid.keyboard)
|
||||
}
|
||||
|
||||
failing := &recordingHID{failAt: 1}
|
||||
if err := newRemoteWithHID(failing).PressKeys(context.Background(), []string{"KeyA"}, 0); err == nil {
|
||||
t.Fatal("expected write error")
|
||||
}
|
||||
if len(failing.keyboard) != 2 || string(failing.keyboard[1]) != string(keyUpReport) {
|
||||
t.Fatalf("release not attempted after error: %v", failing.keyboard)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteClickReleasesButton(t *testing.T) {
|
||||
hid := &recordingHID{}
|
||||
if err := newRemoteWithHID(hid).Click(context.Background(), "left", 1, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hid.relative) != 2 || hid.relative[0][0] != 1 || hid.relative[1][0] != 0 {
|
||||
t.Fatalf("relative reports: %v", hid.relative)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteScrollUsesAbsoluteReportsAndReleasesWheel(t *testing.T) {
|
||||
hid := &recordingHID{}
|
||||
if err := newRemoteWithHID(hid).Scroll(context.Background(), 130); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(hid.relative) != 0 {
|
||||
t.Fatalf("relative reports = %v, want none", hid.relative)
|
||||
}
|
||||
if len(hid.absolute) != 4 {
|
||||
t.Fatalf("absolute report count = %d, want 4: %v", len(hid.absolute), hid.absolute)
|
||||
}
|
||||
|
||||
x := absoluteCoordinate(0.5)
|
||||
y := absoluteCoordinate(0.5)
|
||||
want := [][]byte{
|
||||
buildAbsolutePointerReport(x, y, 0, 127),
|
||||
buildAbsolutePointerReport(x, y, 0, 0),
|
||||
buildAbsolutePointerReport(x, y, 0, 3),
|
||||
buildAbsolutePointerReport(x, y, 0, 0),
|
||||
}
|
||||
for i := range want {
|
||||
if string(hid.absolute[i]) != string(want[i]) {
|
||||
t.Fatalf("absolute[%d] = %v, want %v", i, hid.absolute[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteTypeTextCancellationReleasesKeyboard(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
hid := &recordingHID{}
|
||||
hid.onWrite = func(write int) {
|
||||
if write == 1 {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
_, err := newRemoteWithHID(hid).TypeText(ctx, "A", 0)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("error = %v, want canceled", err)
|
||||
}
|
||||
if len(hid.keyboard) != 2 || string(hid.keyboard[1]) != string(keyUpReport) {
|
||||
t.Fatalf("keyboard was not released after cancellation: %v", hid.keyboard)
|
||||
}
|
||||
}
|
||||
27
server/service/mcp/screenshot.go
Normal file
27
server/service/mcp/screenshot.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package mcpservice
|
||||
|
||||
import "context"
|
||||
|
||||
const snapshotTimeoutMaxMS = 30_000
|
||||
|
||||
type SnapshotRequest struct {
|
||||
Quality int
|
||||
TimeoutMS *int
|
||||
X int
|
||||
Y int
|
||||
W int
|
||||
H int
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
OK bool
|
||||
RetCode int
|
||||
Message string
|
||||
Width int
|
||||
Height int
|
||||
JPEG []byte
|
||||
}
|
||||
|
||||
type Snapshotter interface {
|
||||
Capture(context.Context, SnapshotRequest) (Snapshot, error)
|
||||
}
|
||||
544
server/service/mcp/server.go
Normal file
544
server/service/mcp/server.go
Normal file
@@ -0,0 +1,544 @@
|
||||
package mcpservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
"NanoKVM-Server/service/inputcontrol"
|
||||
|
||||
"github.com/google/jsonschema-go/jsonschema"
|
||||
protocol "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
const (
|
||||
mcpServerName = "nanokvm-cube-remote-control"
|
||||
typeTextToolName = "cube_type_text"
|
||||
pressKeysToolName = "cube_press_keys"
|
||||
moveMouseToolName = "cube_move_mouse"
|
||||
clickMouseToolName = "cube_click_mouse"
|
||||
scrollMouseToolName = "cube_scroll_mouse"
|
||||
screenshotToolName = "cube_screenshot"
|
||||
defaultTypeDelayMS = 30
|
||||
defaultHoldMS = 50
|
||||
defaultClickDelayMS = 50
|
||||
maxPressKeyItems = maxKeyboardKeys + 8
|
||||
maxTypeTextDuration = 30 * time.Second
|
||||
maxTypeTextRunes = int(maxTypeTextDuration / (defaultTypeDelayMS * time.Millisecond))
|
||||
maxRequestBodyBytes = 1 << 20
|
||||
)
|
||||
|
||||
type TypeTextParams struct {
|
||||
Text string `json:"text" jsonschema:"text to type on the remote host"`
|
||||
DelayMS *int `json:"delayMs,omitempty" jsonschema:"delay between typed characters in milliseconds"`
|
||||
}
|
||||
|
||||
type TypeTextResult struct {
|
||||
Typed int `json:"typed"`
|
||||
Skipped string `json:"skipped,omitempty"`
|
||||
SkippedCount int `json:"skippedCount"`
|
||||
}
|
||||
|
||||
type PressKeysParams struct {
|
||||
Keys []string `json:"keys" jsonschema:"KeyboardEvent.code key names"`
|
||||
HoldMS *int `json:"holdMs,omitempty" jsonschema:"hold time in milliseconds"`
|
||||
}
|
||||
|
||||
type MoveMouseParams struct {
|
||||
Mode string `json:"mode" jsonschema:"absolute or relative"`
|
||||
X *float64 `json:"x,omitempty" jsonschema:"absolute normalized x"`
|
||||
Y *float64 `json:"y,omitempty" jsonschema:"absolute normalized y"`
|
||||
DeltaX int `json:"deltaX,omitempty" jsonschema:"relative x movement"`
|
||||
DeltaY int `json:"deltaY,omitempty" jsonschema:"relative y movement"`
|
||||
}
|
||||
|
||||
type ClickMouseParams struct {
|
||||
Button string `json:"button" jsonschema:"left, right, or middle"`
|
||||
Clicks int `json:"clicks,omitempty" jsonschema:"number of clicks"`
|
||||
Mode string `json:"mode,omitempty" jsonschema:"absolute or relative"`
|
||||
X *float64 `json:"x,omitempty" jsonschema:"absolute normalized x"`
|
||||
Y *float64 `json:"y,omitempty" jsonschema:"absolute normalized y"`
|
||||
}
|
||||
|
||||
type ScrollMouseParams struct {
|
||||
DeltaY int `json:"deltaY" jsonschema:"vertical wheel movement"`
|
||||
}
|
||||
|
||||
type ScreenshotParams struct {
|
||||
Quality int `json:"quality,omitempty"`
|
||||
TimeoutMS *int `json:"timeoutMs,omitempty"`
|
||||
}
|
||||
|
||||
type ScreenshotResult struct {
|
||||
OK bool `json:"ok"`
|
||||
RetCode int `json:"retCode"`
|
||||
Message string `json:"message"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
type ToolOKResult struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
type toolExecutor struct {
|
||||
control *controlmode.Manager
|
||||
coordinator *inputcontrol.Coordinator
|
||||
}
|
||||
|
||||
func NewMCPHandler(control *controlmode.Manager, snapshotter Snapshotter) http.Handler {
|
||||
return newMCPHandler(control, inputcontrol.GetCoordinator(), NewRemote(), snapshotter)
|
||||
}
|
||||
|
||||
func newMCPHandler(control *controlmode.Manager, coordinator *inputcontrol.Coordinator, remote *Remote, snapshotter Snapshotter) http.Handler {
|
||||
executor := &toolExecutor{control: control, coordinator: coordinator}
|
||||
server := protocol.NewServer(&protocol.Implementation{
|
||||
Name: mcpServerName,
|
||||
Version: "v1.0.0",
|
||||
}, nil)
|
||||
registerTools(server, executor, remote, snapshotter)
|
||||
handler := protocol.NewStreamableHTTPHandler(
|
||||
func(*http.Request) *protocol.Server { return server },
|
||||
&protocol.StreamableHTTPOptions{Stateless: true, JSONResponse: true},
|
||||
)
|
||||
return http.MaxBytesHandler(handler, maxRequestBodyBytes)
|
||||
}
|
||||
|
||||
func registerTools(server *protocol.Server, executor *toolExecutor, remote *Remote, snapshotter Snapshotter) {
|
||||
protocol.AddTool(server, &protocol.Tool{Name: typeTextToolName, Description: "Type short text into the remote host", InputSchema: typeTextSchema()}, typeTextHandler(executor, remote))
|
||||
protocol.AddTool(server, &protocol.Tool{Name: pressKeysToolName, Description: "Press one keyboard shortcut on the remote host", InputSchema: pressKeysSchema()}, pressKeysHandler(executor, remote))
|
||||
protocol.AddTool(server, &protocol.Tool{Name: moveMouseToolName, Description: "Move the remote mouse pointer", InputSchema: moveMouseSchema()}, moveMouseHandler(executor, remote))
|
||||
protocol.AddTool(server, &protocol.Tool{Name: clickMouseToolName, Description: "Click a remote mouse button", InputSchema: clickMouseSchema()}, clickMouseHandler(executor, remote))
|
||||
protocol.AddTool(server, &protocol.Tool{Name: scrollMouseToolName, Description: "Scroll the remote mouse wheel", InputSchema: scrollMouseSchema()}, scrollMouseHandler(executor, remote))
|
||||
protocol.AddTool(server, &protocol.Tool{Name: screenshotToolName, Description: "Capture a JPEG screenshot from the remote display", InputSchema: screenshotSchema()}, screenshotHandler(executor, snapshotter))
|
||||
}
|
||||
|
||||
func typeTextHandler(executor *toolExecutor, remote *Remote) protocol.ToolHandlerFor[TypeTextParams, TypeTextResult] {
|
||||
return func(ctx context.Context, _ *protocol.CallToolRequest, input TypeTextParams) (*protocol.CallToolResult, TypeTextResult, error) {
|
||||
textRunes := []rune(input.Text)
|
||||
if len(textRunes) > maxTypeTextRunes {
|
||||
return nil, TypeTextResult{}, fmt.Errorf("text exceeds maximum length of %d runes", maxTypeTextRunes)
|
||||
}
|
||||
delay := time.Duration(resolveDelayMS(input.DelayMS, defaultTypeDelayMS)) * time.Millisecond
|
||||
reports, _ := buildTypeReports(input.Text)
|
||||
typeableRunes := len(reports) / 2
|
||||
if time.Duration(typeableRunes)*delay > maxTypeTextDuration {
|
||||
return nil, TypeTextResult{}, fmt.Errorf("typing duration exceeds %s for %d typeable runes at %s delay", maxTypeTextDuration, typeableRunes, delay)
|
||||
}
|
||||
operationCtx, release, err := executor.begin(ctx, inputcontrol.OperationHID)
|
||||
if err != nil {
|
||||
return nil, TypeTextResult{}, err
|
||||
}
|
||||
defer release()
|
||||
|
||||
skipped, err := remote.TypeText(operationCtx, input.Text, delay)
|
||||
if err != nil {
|
||||
return nil, TypeTextResult{}, err
|
||||
}
|
||||
|
||||
return nil, TypeTextResult{
|
||||
Typed: len(textRunes) - len(skipped),
|
||||
Skipped: string(skipped),
|
||||
SkippedCount: len(skipped),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func pressKeysHandler(executor *toolExecutor, remote *Remote) protocol.ToolHandlerFor[PressKeysParams, ToolOKResult] {
|
||||
return func(ctx context.Context, _ *protocol.CallToolRequest, input PressKeysParams) (*protocol.CallToolResult, ToolOKResult, error) {
|
||||
if len(input.Keys) == 0 {
|
||||
return nil, ToolOKResult{}, fmt.Errorf("keys must not be empty")
|
||||
}
|
||||
operationCtx, release, err := executor.begin(ctx, inputcontrol.OperationHID)
|
||||
if err != nil {
|
||||
return nil, ToolOKResult{}, err
|
||||
}
|
||||
defer release()
|
||||
if err := remote.PressKeys(operationCtx, input.Keys, time.Duration(resolveDelayMS(input.HoldMS, defaultHoldMS))*time.Millisecond); err != nil {
|
||||
return nil, ToolOKResult{}, err
|
||||
}
|
||||
return nil, ToolOKResult{OK: true}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func moveMouseHandler(executor *toolExecutor, remote *Remote) protocol.ToolHandlerFor[MoveMouseParams, ToolOKResult] {
|
||||
return func(ctx context.Context, _ *protocol.CallToolRequest, input MoveMouseParams) (*protocol.CallToolResult, ToolOKResult, error) {
|
||||
mode := normalizedMode(input.Mode, "absolute")
|
||||
if err := validateMoveMouseInput(mode, input); err != nil {
|
||||
return nil, ToolOKResult{}, err
|
||||
}
|
||||
operationCtx, release, err := executor.begin(ctx, inputcontrol.OperationHID)
|
||||
if err != nil {
|
||||
return nil, ToolOKResult{}, err
|
||||
}
|
||||
defer release()
|
||||
switch mode {
|
||||
case "absolute":
|
||||
x, y, err := normalizedCoordinates(input.X, input.Y)
|
||||
if err != nil {
|
||||
return nil, ToolOKResult{}, err
|
||||
}
|
||||
if err := remote.MoveAbsolute(operationCtx, x, y); err != nil {
|
||||
return nil, ToolOKResult{}, err
|
||||
}
|
||||
case "relative":
|
||||
if err := remote.MoveRelative(operationCtx, input.DeltaX, input.DeltaY); err != nil {
|
||||
return nil, ToolOKResult{}, err
|
||||
}
|
||||
default:
|
||||
return nil, ToolOKResult{}, fmt.Errorf("unknown mouse mode: %s", input.Mode)
|
||||
}
|
||||
return nil, ToolOKResult{OK: true}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func clickMouseHandler(executor *toolExecutor, remote *Remote) protocol.ToolHandlerFor[ClickMouseParams, ToolOKResult] {
|
||||
return func(ctx context.Context, _ *protocol.CallToolRequest, input ClickMouseParams) (*protocol.CallToolResult, ToolOKResult, error) {
|
||||
button := strings.ToLower(strings.TrimSpace(input.Button))
|
||||
if button == "" {
|
||||
button = "left"
|
||||
}
|
||||
clicks, err := normalizeClickCount(input.Clicks)
|
||||
if err != nil {
|
||||
return nil, ToolOKResult{}, err
|
||||
}
|
||||
mode := normalizedMode(input.Mode, "relative")
|
||||
if err := validateClickMouseInput(mode, button, input); err != nil {
|
||||
return nil, ToolOKResult{}, err
|
||||
}
|
||||
operationCtx, release, err := executor.begin(ctx, inputcontrol.OperationHID)
|
||||
if err != nil {
|
||||
return nil, ToolOKResult{}, err
|
||||
}
|
||||
defer release()
|
||||
switch mode {
|
||||
case "absolute":
|
||||
x, y, err := normalizedCoordinates(input.X, input.Y)
|
||||
if err != nil {
|
||||
return nil, ToolOKResult{}, err
|
||||
}
|
||||
for i := 0; i < clicks; i++ {
|
||||
if err := remote.TouchAbsoluteButton(operationCtx, x, y, button, defaultClickDelayMS*time.Millisecond); err != nil {
|
||||
return nil, ToolOKResult{}, err
|
||||
}
|
||||
if i+1 < clicks {
|
||||
if err := sleepContext(operationCtx, defaultClickDelayMS*time.Millisecond); err != nil {
|
||||
return nil, ToolOKResult{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
case "relative":
|
||||
if err := remote.Click(operationCtx, button, clicks, defaultClickDelayMS*time.Millisecond); err != nil {
|
||||
return nil, ToolOKResult{}, err
|
||||
}
|
||||
default:
|
||||
return nil, ToolOKResult{}, fmt.Errorf("unknown mouse mode: %s", input.Mode)
|
||||
}
|
||||
|
||||
return nil, ToolOKResult{OK: true}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func scrollMouseHandler(executor *toolExecutor, remote *Remote) protocol.ToolHandlerFor[ScrollMouseParams, ToolOKResult] {
|
||||
return func(ctx context.Context, _ *protocol.CallToolRequest, input ScrollMouseParams) (*protocol.CallToolResult, ToolOKResult, error) {
|
||||
operationCtx, release, err := executor.begin(ctx, inputcontrol.OperationHID)
|
||||
if err != nil {
|
||||
return nil, ToolOKResult{}, err
|
||||
}
|
||||
defer release()
|
||||
if err := remote.Scroll(operationCtx, input.DeltaY); err != nil {
|
||||
return nil, ToolOKResult{}, err
|
||||
}
|
||||
return nil, ToolOKResult{OK: true}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func screenshotHandler(executor *toolExecutor, snapshotter Snapshotter) protocol.ToolHandlerFor[ScreenshotParams, ScreenshotResult] {
|
||||
return func(ctx context.Context, _ *protocol.CallToolRequest, input ScreenshotParams) (*protocol.CallToolResult, ScreenshotResult, error) {
|
||||
if snapshotter == nil {
|
||||
return toolError("screenshot capture is unavailable"), ScreenshotResult{Message: "screenshot capture is unavailable"}, nil
|
||||
}
|
||||
|
||||
snapshot, err := snapshotter.Capture(ctx, SnapshotRequest{
|
||||
Quality: input.Quality, TimeoutMS: input.TimeoutMS,
|
||||
})
|
||||
output := ScreenshotResult{
|
||||
OK: snapshot.OK, RetCode: snapshot.RetCode, Message: snapshot.Message,
|
||||
Width: snapshot.Width, Height: snapshot.Height, Size: len(snapshot.JPEG),
|
||||
}
|
||||
if err != nil {
|
||||
output.Message = err.Error()
|
||||
return toolError(err.Error()), output, nil
|
||||
}
|
||||
if !snapshot.OK || len(snapshot.JPEG) == 0 {
|
||||
message := snapshot.Message
|
||||
if message == "" {
|
||||
message = "screenshot capture failed"
|
||||
}
|
||||
return toolError(message), output, nil
|
||||
}
|
||||
|
||||
return &protocol.CallToolResult{Content: []protocol.Content{
|
||||
&protocol.ImageContent{Data: snapshot.JPEG, MIMEType: "image/jpeg"},
|
||||
}}, output, nil
|
||||
}
|
||||
}
|
||||
|
||||
func resolveDelayMS(value *int, fallback int) int {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
return clampInt(*value, 0, 1000)
|
||||
}
|
||||
|
||||
func normalizeClickCount(value int) (int, error) {
|
||||
if value == 0 {
|
||||
return 1, nil
|
||||
}
|
||||
if value < 1 || value > 5 {
|
||||
return 0, fmt.Errorf("clicks must be between 1 and 5")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func normalizedCoordinates(x *float64, y *float64) (float64, float64, error) {
|
||||
if x == nil || y == nil {
|
||||
return 0, 0, fmt.Errorf("absolute mode requires both x and y")
|
||||
}
|
||||
if *x < 0 || *x > 1 || *y < 0 || *y > 1 {
|
||||
return 0, 0, fmt.Errorf("absolute coordinates must be between 0 and 1")
|
||||
}
|
||||
return *x, *y, nil
|
||||
}
|
||||
|
||||
func validateMoveMouseInput(mode string, input MoveMouseParams) error {
|
||||
switch mode {
|
||||
case "absolute":
|
||||
if input.DeltaX != 0 || input.DeltaY != 0 {
|
||||
return fmt.Errorf("absolute mode does not accept relative deltas")
|
||||
}
|
||||
_, _, err := normalizedCoordinates(input.X, input.Y)
|
||||
return err
|
||||
case "relative":
|
||||
if input.X != nil || input.Y != nil {
|
||||
return fmt.Errorf("relative mode does not accept absolute coordinates")
|
||||
}
|
||||
if input.DeltaX == 0 && input.DeltaY == 0 {
|
||||
return fmt.Errorf("relative mode requires deltaX or deltaY")
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown mouse mode: %s", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func validateClickMouseInput(mode string, button string, input ClickMouseParams) error {
|
||||
switch mode {
|
||||
case "absolute":
|
||||
if button != "left" && button != "right" {
|
||||
return fmt.Errorf("absolute mouse clicks only support left and right buttons")
|
||||
}
|
||||
_, _, err := normalizedCoordinates(input.X, input.Y)
|
||||
return err
|
||||
case "relative":
|
||||
if input.X != nil || input.Y != nil {
|
||||
return fmt.Errorf("relative mouse clicks do not accept absolute coordinates")
|
||||
}
|
||||
if button != "left" && button != "right" && button != "middle" {
|
||||
return fmt.Errorf("relative mouse clicks only support left, right, and middle buttons")
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown mouse mode: %s", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *toolExecutor) begin(ctx context.Context, kind inputcontrol.OperationKind) (context.Context, func(), error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if e == nil {
|
||||
return ctx, func() {}, nil
|
||||
}
|
||||
|
||||
var releaseMode func()
|
||||
if e.control != nil && kind == inputcontrol.OperationHID {
|
||||
var err error
|
||||
releaseMode, err = e.control.AcquireWrite(controlmode.ModeMCP)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("MCP service is disabled: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if e.coordinator == nil {
|
||||
return ctx, func() {
|
||||
if releaseMode != nil {
|
||||
releaseMode()
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
operationCtx, releaseOperation, err := e.coordinator.BeginMCP(ctx, kind)
|
||||
if err != nil {
|
||||
if releaseMode != nil {
|
||||
releaseMode()
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
return operationCtx, func() {
|
||||
releaseOperation()
|
||||
if releaseMode != nil {
|
||||
releaseMode()
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func typeTextSchema() *jsonschema.Schema {
|
||||
return objectSchema(map[string]*jsonschema.Schema{
|
||||
"text": {Type: "string", Description: "Text to type on the remote host", MaxLength: jsonschema.Ptr(maxTypeTextRunes)},
|
||||
"delayMs": integerSchema(fmt.Sprintf("Delay between typed characters in milliseconds; defaults to %d when omitted, and 0 disables the delay", defaultTypeDelayMS), 0, 1000),
|
||||
}, "text")
|
||||
}
|
||||
|
||||
func pressKeysSchema() *jsonschema.Schema {
|
||||
return objectSchema(map[string]*jsonschema.Schema{
|
||||
"keys": {
|
||||
Type: "array",
|
||||
Description: "KeyboardEvent.code key names",
|
||||
Items: &jsonschema.Schema{Type: "string", Enum: supportedKeyNames()},
|
||||
MinItems: jsonschema.Ptr(1),
|
||||
MaxItems: jsonschema.Ptr(maxPressKeyItems),
|
||||
},
|
||||
"holdMs": integerSchema(fmt.Sprintf("Hold time in milliseconds; defaults to %d when omitted, and 0 releases immediately", defaultHoldMS), 0, 1000),
|
||||
}, "keys")
|
||||
}
|
||||
|
||||
func moveMouseSchema() *jsonschema.Schema {
|
||||
mode := &jsonschema.Schema{Type: "string", Enum: []any{"absolute", "relative"}}
|
||||
coordinate := func(description string) *jsonschema.Schema {
|
||||
return numberSchema(description, 0, 1)
|
||||
}
|
||||
schema := objectSchema(map[string]*jsonschema.Schema{
|
||||
"mode": mode,
|
||||
"x": coordinate("Absolute normalized x coordinate"),
|
||||
"y": coordinate("Absolute normalized y coordinate"),
|
||||
"deltaX": integerSchema("Relative x movement", -maxRelativeMouseDelta, maxRelativeMouseDelta),
|
||||
"deltaY": integerSchema("Relative y movement", -maxRelativeMouseDelta, maxRelativeMouseDelta),
|
||||
})
|
||||
schema.OneOf = []*jsonschema.Schema{
|
||||
{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{"mode": {Type: "string", Enum: []any{"absolute"}}},
|
||||
Required: []string{"x", "y"},
|
||||
Not: &jsonschema.Schema{AnyOf: []*jsonschema.Schema{
|
||||
{Type: "object", Required: []string{"deltaX"}},
|
||||
{Type: "object", Required: []string{"deltaY"}},
|
||||
}},
|
||||
},
|
||||
{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{"mode": {Type: "string", Enum: []any{"relative"}}},
|
||||
Required: []string{"mode"},
|
||||
AnyOf: []*jsonschema.Schema{
|
||||
{Type: "object", Required: []string{"deltaX"}},
|
||||
{Type: "object", Required: []string{"deltaY"}},
|
||||
},
|
||||
Not: &jsonschema.Schema{AnyOf: []*jsonschema.Schema{
|
||||
{Type: "object", Required: []string{"x"}},
|
||||
{Type: "object", Required: []string{"y"}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
func clickMouseSchema() *jsonschema.Schema {
|
||||
schema := objectSchema(map[string]*jsonschema.Schema{
|
||||
"button": {Type: "string", Enum: []any{"left", "right", "middle"}},
|
||||
"clicks": integerSchema("Number of clicks", 1, 5),
|
||||
"mode": {Type: "string", Enum: []any{"absolute", "relative"}},
|
||||
"x": numberSchema("Absolute normalized x coordinate", 0, 1),
|
||||
"y": numberSchema("Absolute normalized y coordinate", 0, 1),
|
||||
})
|
||||
schema.OneOf = []*jsonschema.Schema{
|
||||
{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"mode": {Type: "string", Enum: []any{"absolute"}},
|
||||
"button": {Type: "string", Enum: []any{"left", "right"}},
|
||||
},
|
||||
Required: []string{"mode", "x", "y"},
|
||||
},
|
||||
{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{
|
||||
"mode": {Type: "string", Enum: []any{"relative"}},
|
||||
"button": {Type: "string", Enum: []any{"left", "right", "middle"}},
|
||||
},
|
||||
Not: &jsonschema.Schema{AnyOf: []*jsonschema.Schema{
|
||||
{Type: "object", Required: []string{"x"}},
|
||||
{Type: "object", Required: []string{"y"}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
func scrollMouseSchema() *jsonschema.Schema {
|
||||
return objectSchema(map[string]*jsonschema.Schema{
|
||||
"deltaY": integerSchema("Vertical wheel movement", -maxRelativeMouseDelta, maxRelativeMouseDelta),
|
||||
}, "deltaY")
|
||||
}
|
||||
|
||||
func screenshotSchema() *jsonschema.Schema {
|
||||
return objectSchema(map[string]*jsonschema.Schema{
|
||||
"quality": integerSchema("JPEG quality; values are limited to 51-60 on the device", 1, 100),
|
||||
"timeoutMs": integerSchema("Capture timeout in milliseconds", 0, 30000),
|
||||
})
|
||||
}
|
||||
|
||||
func objectSchema(properties map[string]*jsonschema.Schema, required ...string) *jsonschema.Schema {
|
||||
return &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: properties,
|
||||
Required: required,
|
||||
AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}},
|
||||
}
|
||||
}
|
||||
|
||||
func integerSchema(description string, minimum int, maximum int) *jsonschema.Schema {
|
||||
return &jsonschema.Schema{
|
||||
Type: "integer",
|
||||
Description: description,
|
||||
Minimum: jsonschema.Ptr(float64(minimum)),
|
||||
Maximum: jsonschema.Ptr(float64(maximum)),
|
||||
}
|
||||
}
|
||||
|
||||
func numberSchema(description string, minimum float64, maximum float64) *jsonschema.Schema {
|
||||
return &jsonschema.Schema{
|
||||
Type: "number",
|
||||
Description: description,
|
||||
Minimum: jsonschema.Ptr(minimum),
|
||||
Maximum: jsonschema.Ptr(maximum),
|
||||
}
|
||||
}
|
||||
|
||||
func toolError(message string) *protocol.CallToolResult {
|
||||
return &protocol.CallToolResult{
|
||||
Content: []protocol.Content{&protocol.TextContent{Text: message}},
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedMode(mode string, fallback string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(mode))
|
||||
if normalized == "" {
|
||||
return fallback
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
402
server/service/mcp/server_test.go
Normal file
402
server/service/mcp/server_test.go
Normal file
@@ -0,0 +1,402 @@
|
||||
package mcpservice
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
"NanoKVM-Server/service/inputcontrol"
|
||||
)
|
||||
|
||||
type fakeSnapshotter struct {
|
||||
snapshot Snapshot
|
||||
err error
|
||||
}
|
||||
|
||||
func (s fakeSnapshotter) Capture(context.Context, SnapshotRequest) (Snapshot, error) {
|
||||
return s.snapshot, s.err
|
||||
}
|
||||
|
||||
func TestScreenshotHandlerReturnsImage(t *testing.T) {
|
||||
handler := screenshotHandler(nil, fakeSnapshotter{snapshot: Snapshot{
|
||||
OK: true, Width: 800, Height: 600, JPEG: []byte{0xff, 0xd8, 0xff},
|
||||
}})
|
||||
result, output, err := handler(context.Background(), nil, ScreenshotParams{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result == nil || result.IsError || len(result.Content) != 1 || !output.OK || output.Size != 3 {
|
||||
t.Fatalf("result=%+v output=%+v", result, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDelayMSDefaultsOnlyWhenOmitted(t *testing.T) {
|
||||
if got := resolveDelayMS(nil, defaultTypeDelayMS); got != defaultTypeDelayMS {
|
||||
t.Fatalf("omitted delay = %d, want %d", got, defaultTypeDelayMS)
|
||||
}
|
||||
|
||||
zero := 0
|
||||
if got := resolveDelayMS(&zero, defaultTypeDelayMS); got != 0 {
|
||||
t.Fatalf("explicit zero delay = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypeTextSchemaMatchesDurationBudget(t *testing.T) {
|
||||
if maxTypeTextRunes != 1000 {
|
||||
t.Fatalf("maxTypeTextRunes = %d, want 1000", maxTypeTextRunes)
|
||||
}
|
||||
schema := typeTextSchema()
|
||||
maxLength := schema.Properties["text"].MaxLength
|
||||
if maxLength == nil || *maxLength != maxTypeTextRunes {
|
||||
t.Fatalf("schema maxLength = %v, want %d", maxLength, maxTypeTextRunes)
|
||||
}
|
||||
|
||||
tooLong := strings.Repeat("a", maxTypeTextRunes+1)
|
||||
handler := typeTextHandler(nil, nil)
|
||||
if _, _, err := handler(context.Background(), nil, TypeTextParams{Text: tooLong}); err == nil || !strings.Contains(err.Error(), "maximum length") {
|
||||
t.Fatalf("too-long error = %v, want maximum length", err)
|
||||
}
|
||||
|
||||
delayMS := defaultTypeDelayMS + 1
|
||||
maxLengthText := strings.Repeat("a", maxTypeTextRunes)
|
||||
if _, _, err := handler(context.Background(), nil, TypeTextParams{Text: maxLengthText, DelayMS: &delayMS}); err == nil || !strings.Contains(err.Error(), "typing duration exceeds") {
|
||||
t.Fatalf("duration error = %v, want duration budget", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveMouseHandlerDoesNotRateLimitRapidMoves(t *testing.T) {
|
||||
hid := &recordingHID{}
|
||||
handler := moveMouseHandler(nil, newRemoteWithHID(hid))
|
||||
x, y := 0.5, 0.5
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, _, err := handler(context.Background(), nil, MoveMouseParams{Mode: "absolute", X: &x, Y: &y}); err != nil {
|
||||
t.Fatalf("move %d error = %v", i+1, err)
|
||||
}
|
||||
}
|
||||
if len(hid.absolute) != 2 {
|
||||
t.Fatalf("absolute writes = %d, want 2", len(hid.absolute))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPInitializeAndToolsList(t *testing.T) {
|
||||
hid := &recordingHID{}
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
handler := newMCPHandler(control, &inputcontrol.Coordinator{}, newRemoteWithHID(hid), fakeSnapshotter{snapshot: Snapshot{
|
||||
OK: true, Width: 800, Height: 600, JPEG: []byte{0xff, 0xd8, 0xff},
|
||||
}})
|
||||
|
||||
request := func(method string, body string, sessionID string) *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(method, "/api/mcp", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
if sessionID != "" {
|
||||
req.Header.Set("Mcp-Session-Id", sessionID)
|
||||
req.Header.Set("Mcp-Protocol-Version", "2025-03-26")
|
||||
}
|
||||
handler.ServeHTTP(recorder, req)
|
||||
return recorder
|
||||
}
|
||||
|
||||
initialize := request(http.MethodPost, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}`, "")
|
||||
if initialize.Code != http.StatusOK {
|
||||
t.Fatalf("initialize status=%d body=%s", initialize.Code, initialize.Body.String())
|
||||
}
|
||||
if contentType := initialize.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "application/json") {
|
||||
t.Fatalf("initialize content type=%q, want application/json", contentType)
|
||||
}
|
||||
sessionID := initialize.Header().Get("Mcp-Session-Id")
|
||||
if sessionID == "" || !strings.Contains(initialize.Body.String(), "nanokvm-cube-remote-control") {
|
||||
t.Fatalf("session=%q body=%s", sessionID, initialize.Body.String())
|
||||
}
|
||||
|
||||
initialized := request(http.MethodPost, `{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}`, sessionID)
|
||||
if initialized.Code != http.StatusAccepted {
|
||||
t.Fatalf("initialized status=%d body=%s", initialized.Code, initialized.Body.String())
|
||||
}
|
||||
|
||||
tools := request(http.MethodPost, `{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`, sessionID)
|
||||
if tools.Code != http.StatusOK {
|
||||
t.Fatalf("tools/list status=%d body=%s", tools.Code, tools.Body.String())
|
||||
}
|
||||
for _, name := range []string{"cube_type_text", "cube_press_keys", "cube_move_mouse", "cube_click_mouse", "cube_scroll_mouse", "cube_screenshot"} {
|
||||
if !strings.Contains(tools.Body.String(), `"name":"`+name+`"`) {
|
||||
t.Fatalf("tools/list missing %s: %s", name, tools.Body.String())
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"type_text", "press_keys", "move_mouse", "click_mouse", "scroll_mouse", "screenshot"} {
|
||||
if strings.Contains(tools.Body.String(), `"name":"`+name+`"`) {
|
||||
t.Fatalf("tools/list exposes unprefixed tool %s: %s", name, tools.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
calls := []struct {
|
||||
id int
|
||||
name string
|
||||
arguments string
|
||||
}{
|
||||
{3, "cube_type_text", `{"text":"A","delayMs":1}`},
|
||||
{4, "cube_press_keys", `{"keys":["ControlLeft","AltLeft","KeyA","KeyB","KeyC","KeyD","KeyE","KeyF"],"holdMs":1}`},
|
||||
{5, "cube_move_mouse", `{"mode":"absolute","x":0.5,"y":0.5}`},
|
||||
{6, "cube_click_mouse", `{"button":"left","clicks":1}`},
|
||||
{7, "cube_scroll_mouse", `{"deltaY":1}`},
|
||||
{8, "cube_screenshot", `{"quality":75}`},
|
||||
}
|
||||
for _, call := range calls {
|
||||
body := `{"jsonrpc":"2.0","id":` + fmt.Sprint(call.id) + `,"method":"tools/call","params":{"name":"` + call.name + `","arguments":` + call.arguments + `}}`
|
||||
response := request(http.MethodPost, body, sessionID)
|
||||
if response.Code != http.StatusOK || strings.Contains(response.Body.String(), `"isError":true`) {
|
||||
t.Fatalf("tools/call %s status=%d body=%s", call.name, response.Code, response.Body.String())
|
||||
}
|
||||
if call.name == "cube_screenshot" && !strings.Contains(response.Body.String(), "image/jpeg") {
|
||||
t.Fatalf("screenshot response missing image: %s", response.Body.String())
|
||||
}
|
||||
if call.name == "cube_screenshot" && strings.HasPrefix(response.Body.String(), "data:") {
|
||||
t.Fatalf("screenshot unexpectedly used SSE response: %s", response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
invalidCalls := []struct {
|
||||
id int
|
||||
name string
|
||||
arguments string
|
||||
}{
|
||||
{9, "cube_move_mouse", `{"mode":"absolute"}`},
|
||||
{10, "cube_move_mouse", `{"mode":"absolute","x":1.1,"y":0.5}`},
|
||||
{11, "cube_click_mouse", `{"mode":"absolute","button":"left"}`},
|
||||
{12, "cube_screenshot", `{"x":10}`},
|
||||
{13, "cube_move_mouse", `{"mode":"relative","x":0.5,"y":0.5}`},
|
||||
{14, "cube_move_mouse", `{"mode":"relative"}`},
|
||||
{15, "cube_click_mouse", `{"mode":"absolute","button":"middle","x":0.5,"y":0.5}`},
|
||||
{16, "cube_click_mouse", `{"button":"back"}`},
|
||||
{17, "cube_press_keys", `{"keys":["MediaPlayPause"]}`},
|
||||
}
|
||||
for _, call := range invalidCalls {
|
||||
body := `{"jsonrpc":"2.0","id":` + fmt.Sprint(call.id) + `,"method":"tools/call","params":{"name":"` + call.name + `","arguments":` + call.arguments + `}}`
|
||||
response := request(http.MethodPost, body, sessionID)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"isError":true`) {
|
||||
t.Fatalf("invalid tools/call %s status=%d body=%s", call.name, response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
closed := request(http.MethodDelete, "", sessionID)
|
||||
if closed.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete status=%d body=%s", closed.Code, closed.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPHandlerRejectsCrossOriginBrowserRequests(t *testing.T) {
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
handler := http.NewCrossOriginProtection().Handler(newMCPHandler(control, &inputcontrol.Coordinator{}, newRemoteWithHID(&recordingHID{}), nil))
|
||||
|
||||
request := func(origin string) *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "http://nanokvm.local/api/mcp", strings.NewReader(`{}`))
|
||||
req.Header.Set("Origin", origin)
|
||||
handler.ServeHTTP(recorder, req)
|
||||
return recorder
|
||||
}
|
||||
|
||||
if got := request("https://evil.example").Code; got != http.StatusForbidden {
|
||||
t.Fatalf("cross-origin status = %d, want 403", got)
|
||||
}
|
||||
if got := request("http://nanokvm.local").Code; got == http.StatusForbidden {
|
||||
t.Fatalf("same-origin request was rejected with status %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPHandlerRejectsOversizedRequestBody(t *testing.T) {
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
handler := newMCPHandler(control, &inputcontrol.Coordinator{}, newRemoteWithHID(&recordingHID{}), nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/mcp",
|
||||
strings.NewReader(strings.Repeat(" ", maxRequestBodyBytes+1)),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
|
||||
handler.ServeHTTP(recorder, req)
|
||||
if recorder.Code == http.StatusOK {
|
||||
t.Fatalf("oversized request was accepted: status=%d", recorder.Code)
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "failed to read body") {
|
||||
t.Fatalf("unexpected oversized response: status=%d body=%q", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolExecutorRejectsConcurrentControl(t *testing.T) {
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
coordinator := &inputcontrol.Coordinator{}
|
||||
executor := &toolExecutor{control: control, coordinator: coordinator}
|
||||
|
||||
_, release, err := executor.begin(context.Background(), inputcontrol.OperationHID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
if _, _, err := executor.begin(context.Background(), inputcontrol.OperationHID); !errors.Is(err, inputcontrol.ErrMCPBusy) {
|
||||
t.Fatalf("error = %v, want busy", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualControlBlocksHIDButAllowsScreenshot(t *testing.T) {
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
coordinator := &inputcontrol.Coordinator{}
|
||||
manual := inputcontrol.NewManualSession(control, coordinator)
|
||||
defer manual.Close()
|
||||
|
||||
reservation, err := manual.Reserve(context.Background(), inputcontrol.ManualRelativeMouse, true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reservation.Complete(true)
|
||||
|
||||
hid := &recordingHID{}
|
||||
executor := &toolExecutor{control: control, coordinator: coordinator}
|
||||
x, y := 0.5, 0.5
|
||||
_, _, err = moveMouseHandler(executor, newRemoteWithHID(hid))(
|
||||
context.Background(), nil, MoveMouseParams{Mode: "absolute", X: &x, Y: &y},
|
||||
)
|
||||
if !errors.Is(err, inputcontrol.ErrManualControlActive) {
|
||||
t.Fatalf("move error = %v, want manual-control busy", err)
|
||||
}
|
||||
if hid.writes != 0 {
|
||||
t.Fatalf("manual-control rejection wrote %d HID reports", hid.writes)
|
||||
}
|
||||
|
||||
result, output, err := screenshotHandler(executor, fakeSnapshotter{snapshot: Snapshot{
|
||||
OK: true, Width: 800, Height: 600, JPEG: []byte{0xff, 0xd8, 0xff},
|
||||
}})(context.Background(), nil, ScreenshotParams{})
|
||||
if err != nil {
|
||||
t.Fatalf("screenshot was blocked by manual control: %v", err)
|
||||
}
|
||||
if result == nil || !output.OK {
|
||||
t.Fatalf("result=%+v output=%+v", result, output)
|
||||
}
|
||||
|
||||
manual.Reset(inputcontrol.ManualRelativeMouse)
|
||||
if got := control.Current(); got != controlmode.ModeMCP {
|
||||
t.Fatalf("mode = %q, want MCP to remain enabled", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModeSwitchCancelsActiveMCPTool(t *testing.T) {
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
coordinator := &inputcontrol.Coordinator{}
|
||||
hid := &recordingHID{}
|
||||
started := make(chan struct{}, 1)
|
||||
hid.onWrite = func(write int) {
|
||||
if write == 1 {
|
||||
started <- struct{}{}
|
||||
}
|
||||
}
|
||||
handler := typeTextHandler(
|
||||
&toolExecutor{control: control, coordinator: coordinator},
|
||||
newRemoteWithHID(hid),
|
||||
)
|
||||
|
||||
toolDone := make(chan error, 1)
|
||||
go func() {
|
||||
delayMS := 1000
|
||||
_, _, err := handler(context.Background(), nil, TypeTextParams{Text: "AB", DelayMS: &delayMS})
|
||||
toolDone <- err
|
||||
}()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("MCP tool did not start")
|
||||
}
|
||||
|
||||
switched, err := control.SwitchIf(controlmode.ModeMCP, controlmode.ModeOff, func() error {
|
||||
coordinator.CancelMCP()
|
||||
return nil
|
||||
})
|
||||
if err != nil || !switched {
|
||||
t.Fatalf("switched=%v err=%v", switched, err)
|
||||
}
|
||||
select {
|
||||
case err := <-toolDone:
|
||||
if !errors.Is(err, inputcontrol.ErrMCPModeChanged) {
|
||||
t.Fatalf("tool error=%v, want mode-change cancellation", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("canceled MCP tool did not return")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualInputCancelsActiveMCPToolAndKeepsModeEnabled(t *testing.T) {
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP)
|
||||
coordinator := &inputcontrol.Coordinator{}
|
||||
hid := &recordingHID{}
|
||||
started := make(chan struct{}, 1)
|
||||
hid.onWrite = func(write int) {
|
||||
if write == 1 {
|
||||
started <- struct{}{}
|
||||
}
|
||||
}
|
||||
handler := typeTextHandler(
|
||||
&toolExecutor{control: control, coordinator: coordinator},
|
||||
newRemoteWithHID(hid),
|
||||
)
|
||||
|
||||
toolDone := make(chan error, 1)
|
||||
go func() {
|
||||
delayMS := 1000
|
||||
_, _, err := handler(context.Background(), nil, TypeTextParams{Text: "AB", DelayMS: &delayMS})
|
||||
toolDone <- err
|
||||
}()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("MCP tool did not start")
|
||||
}
|
||||
|
||||
manual := inputcontrol.NewManualSession(control, coordinator)
|
||||
defer manual.Close()
|
||||
reservationDone := make(chan *inputcontrol.ManualReservation, 1)
|
||||
reservationErr := make(chan error, 1)
|
||||
go func() {
|
||||
reservation, err := manual.Reserve(context.Background(), inputcontrol.ManualKeyboard, false, nil)
|
||||
if err != nil {
|
||||
reservationErr <- err
|
||||
return
|
||||
}
|
||||
reservationDone <- reservation
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-toolDone:
|
||||
if !errors.Is(err, inputcontrol.ErrManualPreempted) {
|
||||
t.Fatalf("tool error=%v, want manual preemption", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("manual input did not cancel MCP tool")
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-reservationErr:
|
||||
t.Fatal(err)
|
||||
case reservation := <-reservationDone:
|
||||
reservation.Complete(true)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("manual input did not acquire control after MCP cleanup")
|
||||
}
|
||||
if got := control.Current(); got != controlmode.ModeMCP {
|
||||
t.Fatalf("mode = %q, want MCP to remain enabled", got)
|
||||
}
|
||||
if len(hid.keyboard) < 2 || string(hid.keyboard[len(hid.keyboard)-1]) != string(keyUpReport) {
|
||||
t.Fatalf("keyboard was not released before manual takeover: %v", hid.keyboard)
|
||||
}
|
||||
}
|
||||
169
server/service/mcp/service.go
Normal file
169
server/service/mcp/service.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package mcpservice
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"NanoKVM-Server/proto"
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
"NanoKVM-Server/service/hid"
|
||||
"NanoKVM-Server/service/inputcontrol"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
control *controlmode.Manager
|
||||
preemptPicoclawLeases func() error
|
||||
stopPicoclawForMCP func() error
|
||||
releaseHID func() error
|
||||
onControlChange func(controlmode.Status)
|
||||
}
|
||||
|
||||
func NewService(control *controlmode.Manager, releaseHID func() error) *Service {
|
||||
return NewServiceWithPreempt(control, nil, nil, releaseHID, nil)
|
||||
}
|
||||
|
||||
func NewServiceWithPreempt(
|
||||
control *controlmode.Manager,
|
||||
preemptPicoclawLeases func() error,
|
||||
stopPicoclawForMCP func() error,
|
||||
releaseHID func() error,
|
||||
onControlChange func(controlmode.Status),
|
||||
) *Service {
|
||||
if control == nil {
|
||||
control = controlmode.GetManager()
|
||||
}
|
||||
if releaseHID == nil {
|
||||
releaseHID = hid.ReleaseAllHIDState
|
||||
}
|
||||
return &Service{
|
||||
control: control,
|
||||
preemptPicoclawLeases: preemptPicoclawLeases,
|
||||
stopPicoclawForMCP: stopPicoclawForMCP,
|
||||
releaseHID: releaseHID,
|
||||
onControlChange: onControlChange,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) GetConfig(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
c.Header("Cache-Control", "no-store")
|
||||
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
log.Errorf("failed to load MCP config: %v", err)
|
||||
rsp.ErrRsp(c, -1, "get MCP config failed")
|
||||
return
|
||||
}
|
||||
status, err := s.control.Status()
|
||||
if err != nil {
|
||||
log.Errorf("failed to load AI control mode: %v", err)
|
||||
rsp.ErrRsp(c, -1, "get MCP config failed")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, mcpConfigResponse(cfg, status))
|
||||
}
|
||||
|
||||
func (s *Service) SetConfig(c *gin.Context) {
|
||||
var req proto.SetMCPConfigReq
|
||||
var rsp proto.Response
|
||||
c.Header("Cache-Control", "no-store")
|
||||
|
||||
if err := proto.ParseFormRequest(c, &req); err != nil {
|
||||
rsp.ErrRsp(c, -1, "invalid arguments")
|
||||
return
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
var err error
|
||||
cancelMCP := func() error {
|
||||
inputcontrol.GetCoordinator().CancelMCP()
|
||||
return nil
|
||||
}
|
||||
preemptForEnable := func() error {
|
||||
if err := cancelMCP(); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.preemptPicoclawLeases != nil {
|
||||
return s.preemptPicoclawLeases()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
cleanupForEnable := func() error {
|
||||
var errs []error
|
||||
if s.stopPicoclawForMCP != nil {
|
||||
errs = append(errs, s.stopPicoclawForMCP())
|
||||
}
|
||||
if s.releaseHID != nil {
|
||||
errs = append(errs, s.releaseHID())
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
if *req.Enabled {
|
||||
cfg, err = updateConfig(ensureAPIKey)
|
||||
if err == nil {
|
||||
err = s.control.SwitchWithCleanup(
|
||||
controlmode.ModeMCP,
|
||||
preemptForEnable,
|
||||
cleanupForEnable,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
cfg, err = loadConfig()
|
||||
if err == nil {
|
||||
_, err = s.control.SwitchIfWithCleanup(
|
||||
controlmode.ModeMCP,
|
||||
controlmode.ModeOff,
|
||||
cancelMCP,
|
||||
s.releaseHID,
|
||||
)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
log.Errorf("failed to switch MCP control mode: %v", err)
|
||||
rsp.ErrRsp(c, -2, "operation failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
status, err := s.control.Status()
|
||||
if err != nil {
|
||||
log.Errorf("failed to load AI control mode: %v", err)
|
||||
rsp.ErrRsp(c, -2, "operation failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
if s.onControlChange != nil {
|
||||
s.onControlChange(status)
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, mcpConfigResponse(cfg, status))
|
||||
}
|
||||
|
||||
func (s *Service) RegenerateAPIKey(c *gin.Context) {
|
||||
var rsp proto.Response
|
||||
c.Header("Cache-Control", "no-store")
|
||||
|
||||
cfg, err := updateConfig(regenerateAPIKey)
|
||||
if err != nil {
|
||||
log.Errorf("failed to regenerate MCP API key: %v", err)
|
||||
rsp.ErrRsp(c, -1, "operation failed")
|
||||
return
|
||||
}
|
||||
status, err := s.control.Status()
|
||||
if err != nil {
|
||||
log.Errorf("failed to load AI control mode: %v", err)
|
||||
rsp.ErrRsp(c, -1, "operation failed")
|
||||
return
|
||||
}
|
||||
|
||||
rsp.OkRspWithData(c, mcpConfigResponse(cfg, status))
|
||||
}
|
||||
|
||||
func mcpConfigResponse(cfg Config, status controlmode.Status) *proto.GetMCPConfigRsp {
|
||||
return &proto.GetMCPConfigRsp{
|
||||
Enabled: status.Mode == controlmode.ModeMCP && !status.Transitioning,
|
||||
APIKey: cfg.APIKey,
|
||||
ControlMode: string(status.Mode),
|
||||
Transitioning: status.Transitioning,
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package picoclaw
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strings"
|
||||
@@ -20,6 +21,16 @@ const (
|
||||
)
|
||||
|
||||
func (s *Service) Actions(c *gin.Context) {
|
||||
releaseMode, modeErr := s.acquireControlMode()
|
||||
if modeErr != nil {
|
||||
writePicoclawError(c, modeErr)
|
||||
return
|
||||
}
|
||||
defer releaseMode()
|
||||
|
||||
operationCtx, releaseOperation := s.beginControlOperation(c.Request.Context())
|
||||
defer releaseOperation()
|
||||
|
||||
sessionID, sessionErr := s.requireSessionID(c)
|
||||
if sessionErr != nil {
|
||||
writePicoclawError(c, sessionErr)
|
||||
@@ -41,7 +52,7 @@ func (s *Service) Actions(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
result, execErr := s.executeActions(sessionID, actions)
|
||||
result, execErr := s.executeActions(operationCtx, sessionID, actions)
|
||||
if execErr != nil {
|
||||
writePicoclawError(c, execErr)
|
||||
return
|
||||
@@ -74,7 +85,7 @@ func normalizeActions(c *gin.Context) ([]Action, *PicoclawError) {
|
||||
return []Action{action}, nil
|
||||
}
|
||||
|
||||
func (s *Service) executeActions(sessionID string, actions []Action) (result ActionResult, err *PicoclawError) {
|
||||
func (s *Service) executeActions(ctx context.Context, sessionID string, actions []Action) (result ActionResult, err *PicoclawError) {
|
||||
startedAt := time.Now()
|
||||
if len(actions) == 0 {
|
||||
return ActionResult{}, newPicoclawError(CodeInvalidAction, "empty actions")
|
||||
@@ -88,12 +99,16 @@ func (s *Service) executeActions(sessionID string, actions []Action) (result Act
|
||||
|
||||
totalWrites := 0
|
||||
for idx, action := range actions {
|
||||
if contextErr := controlOperationError(ctx); contextErr != nil {
|
||||
contextErr.Index = &idx
|
||||
return ActionResult{}, contextErr
|
||||
}
|
||||
if lockErr := s.lock.Ensure(sessionID); lockErr != nil {
|
||||
lockErr.Index = &idx
|
||||
return ActionResult{}, lockErr
|
||||
}
|
||||
|
||||
writes, execErr := s.executeAction(action)
|
||||
writes, execErr := s.executeAction(ctx, action)
|
||||
if execErr != nil {
|
||||
execErr.Index = &idx
|
||||
return ActionResult{}, execErr
|
||||
@@ -114,7 +129,11 @@ func (s *Service) executeActions(sessionID string, actions []Action) (result Act
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) executeAction(action Action) (int, *PicoclawError) {
|
||||
func (s *Service) executeAction(ctx context.Context, action Action) (int, *PicoclawError) {
|
||||
if err := controlOperationError(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(action.Action)) {
|
||||
case "click":
|
||||
x, y, err := normalizedPoint(action.X, action.Y)
|
||||
@@ -129,7 +148,9 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) {
|
||||
writes := 0
|
||||
writes += s.sendMouseMoveWithButton(x, y, 0x00, 0)
|
||||
writes += s.sendMousePress(x, y, button)
|
||||
time.Sleep(defaultClickHold)
|
||||
if waitErr := waitForControlOperation(ctx, defaultClickHold); waitErr != nil {
|
||||
return writes, waitErr
|
||||
}
|
||||
writes += s.sendMouseRelease(x, y)
|
||||
return writes, nil
|
||||
|
||||
@@ -144,7 +165,12 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) {
|
||||
if action.DurationMs < 0 {
|
||||
return 0, newPicoclawError(CodeInvalidAction, "wait duration must be >= 0")
|
||||
}
|
||||
time.Sleep(time.Duration(action.DurationMs) * time.Millisecond)
|
||||
if action.DurationMs > maxWaitDurationMS {
|
||||
return 0, newPicoclawError(CodeInvalidAction, "wait duration must be <= 30000 milliseconds")
|
||||
}
|
||||
if waitErr := waitForControlOperation(ctx, time.Duration(action.DurationMs)*time.Millisecond); waitErr != nil {
|
||||
return 0, waitErr
|
||||
}
|
||||
return 0, nil
|
||||
|
||||
case "drag":
|
||||
@@ -165,6 +191,9 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) {
|
||||
writes += s.sendMouseMoveWithButton(fromX, fromY, 0x00, 0)
|
||||
writes += s.sendMousePress(fromX, fromY, button)
|
||||
for step := 1; step <= defaultDragSteps; step++ {
|
||||
if contextErr := controlOperationError(ctx); contextErr != nil {
|
||||
return writes, contextErr
|
||||
}
|
||||
ratio := float64(step) / float64(defaultDragSteps)
|
||||
x := fromX + (toX-fromX)*ratio
|
||||
y := fromY + (toY-fromY)*ratio
|
||||
@@ -203,9 +232,14 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) {
|
||||
|
||||
writes := 0
|
||||
for range amount {
|
||||
if contextErr := controlOperationError(ctx); contextErr != nil {
|
||||
return writes, contextErr
|
||||
}
|
||||
writes += s.sendMouseMoveWithButton(x, y, 0x00, wheel)
|
||||
writes += s.sendMouseMoveWithButton(x, y, 0x00, 0)
|
||||
time.Sleep(defaultScrollStep)
|
||||
if waitErr := waitForControlOperation(ctx, defaultScrollStep); waitErr != nil {
|
||||
return writes, waitErr
|
||||
}
|
||||
}
|
||||
return writes, nil
|
||||
|
||||
@@ -216,6 +250,9 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) {
|
||||
charMap := hid.GetCharMap("")
|
||||
writes := 0
|
||||
for _, char := range action.Text {
|
||||
if contextErr := controlOperationError(ctx); contextErr != nil {
|
||||
return writes, contextErr
|
||||
}
|
||||
key, ok := charMap[char]
|
||||
if !ok {
|
||||
return 0, newPicoclawError(CodeInvalidAction, "unsupported character in type action")
|
||||
@@ -223,7 +260,9 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) {
|
||||
|
||||
writes += s.sendKeyboardReport([]byte{byte(key.Modifiers), 0x00, byte(key.Code), 0x00, 0x00, 0x00, 0x00, 0x00})
|
||||
writes += s.sendKeyboardReport([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
|
||||
time.Sleep(defaultKeyDelay)
|
||||
if waitErr := waitForControlOperation(ctx, defaultKeyDelay); waitErr != nil {
|
||||
return writes, waitErr
|
||||
}
|
||||
}
|
||||
return writes, nil
|
||||
|
||||
@@ -234,7 +273,9 @@ func (s *Service) executeAction(action Action) (int, *PicoclawError) {
|
||||
}
|
||||
writes := 0
|
||||
writes += s.sendKeyboardReport(report)
|
||||
time.Sleep(defaultClickHold)
|
||||
if waitErr := waitForControlOperation(ctx, defaultClickHold); waitErr != nil {
|
||||
return writes, waitErr
|
||||
}
|
||||
writes += s.sendKeyboardReport([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
|
||||
return writes, nil
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ func withAgentProfile(status RuntimeStatus) RuntimeStatus {
|
||||
}
|
||||
|
||||
func (s *Service) UpdateAgentProfile(c *gin.Context) {
|
||||
s.ensureDependencies()
|
||||
var req AgentProfileUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid agent profile payload"))
|
||||
@@ -132,16 +133,19 @@ func (s *Service) UpdateAgentProfile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
unlockLifecycle := s.lockRuntimeLifecycle()
|
||||
defer unlockLifecycle()
|
||||
|
||||
if err := applyPicoclawAgentProfile(profile); err != nil {
|
||||
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
_ = s.syncConfigFromPicoclaw()
|
||||
_ = s.ensureRuntimeReady()
|
||||
_ = s.ensureRuntimeReadyForLifecycle()
|
||||
|
||||
writeSuccess(c, gin.H{
|
||||
"profile": profile,
|
||||
"status": withAgentProfile(s.runtime.Get()),
|
||||
"status": withAgentProfile(s.runtimeStatus()),
|
||||
})
|
||||
}
|
||||
|
||||
145
server/service/picoclaw/capture_lease.go
Normal file
145
server/service/picoclaw/capture_lease.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/service/vm"
|
||||
)
|
||||
|
||||
const defaultTaskCaptureLeaseDuration = 2 * time.Minute
|
||||
const maxTaskCaptureLeaseDuration = 30 * time.Minute
|
||||
|
||||
func (s *Service) acquireCaptureLease(ctx context.Context) (func(), func() bool, error) {
|
||||
if s != nil && s.acquireHDMIForRead != nil {
|
||||
return s.acquireHDMIForRead(ctx)
|
||||
}
|
||||
return vm.AcquireHdmiCaptureLeaseForRead(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) activateTaskCaptureLease(sessionID string, taskID string, duration time.Duration) {
|
||||
if s == nil || sessionID == "" {
|
||||
return
|
||||
}
|
||||
if duration <= 0 {
|
||||
duration = defaultTaskCaptureLeaseDuration
|
||||
}
|
||||
if duration > maxTaskCaptureLeaseDuration {
|
||||
duration = maxTaskCaptureLeaseDuration
|
||||
}
|
||||
|
||||
key := taskCaptureLeaseKey(sessionID, taskID)
|
||||
s.captureLeaseMu.Lock()
|
||||
defer s.captureLeaseMu.Unlock()
|
||||
if s.captureLeases == nil {
|
||||
s.captureLeases = make(map[string]func())
|
||||
}
|
||||
if s.captureLeaseTimers == nil {
|
||||
s.captureLeaseTimers = make(map[string]*time.Timer)
|
||||
}
|
||||
|
||||
if s.captureLeases[key] == nil {
|
||||
if s.acquireHDMILease != nil {
|
||||
s.captureLeases[key] = s.acquireHDMILease()
|
||||
} else {
|
||||
s.captureLeases[key] = vm.AcquireHdmiCaptureLease()
|
||||
}
|
||||
}
|
||||
if timer := s.captureLeaseTimers[key]; timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
s.captureLeaseTimers[key] = time.AfterFunc(duration, func() {
|
||||
s.releaseCaptureLease(key)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) updateTaskCaptureLease(source string, sessionID string, data []byte) {
|
||||
if s == nil || sessionID == "" || len(data) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var message struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Payload struct {
|
||||
MaxRuntimeMS int `json:"max_runtime_ms"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &message); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case source == "downstream" && message.Type == "message.send":
|
||||
duration := defaultTaskCaptureLeaseDuration
|
||||
if message.Payload.MaxRuntimeMS > 0 {
|
||||
maxMilliseconds := int(maxTaskCaptureLeaseDuration / time.Millisecond)
|
||||
duration = time.Duration(min(message.Payload.MaxRuntimeMS, maxMilliseconds)) * time.Millisecond
|
||||
}
|
||||
s.activateTaskCaptureLease(sessionID, message.ID, duration)
|
||||
case source == "downstream" && message.Type == "message.cancel":
|
||||
if message.ID == "" {
|
||||
s.releaseCaptureLeasesForSession(sessionID)
|
||||
return
|
||||
}
|
||||
s.releaseTaskCaptureLease(sessionID, message.ID)
|
||||
case source == "upstream" && (message.Type == "typing.stop" || message.Type == "message.create" || message.Type == "message.update" || message.Type == "error"):
|
||||
if message.ID == "" {
|
||||
s.releaseCaptureLeasesForSession(sessionID)
|
||||
return
|
||||
}
|
||||
s.releaseTaskCaptureLease(sessionID, message.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func taskCaptureLeaseKey(sessionID string, taskID string) string {
|
||||
if taskID == "" {
|
||||
taskID = "default"
|
||||
}
|
||||
return "task:" + sessionID + ":" + taskID
|
||||
}
|
||||
|
||||
func (s *Service) releaseTaskCaptureLease(sessionID string, taskID string) {
|
||||
if sessionID == "" {
|
||||
return
|
||||
}
|
||||
s.releaseCaptureLease(taskCaptureLeaseKey(sessionID, taskID))
|
||||
}
|
||||
|
||||
func (s *Service) releaseCaptureLeasesForSession(sessionID string) {
|
||||
if s == nil || sessionID == "" {
|
||||
return
|
||||
}
|
||||
prefix := "task:" + sessionID + ":"
|
||||
s.captureLeaseMu.Lock()
|
||||
keys := make([]string, 0, len(s.captureLeases))
|
||||
for key := range s.captureLeases {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
s.captureLeaseMu.Unlock()
|
||||
for _, key := range keys {
|
||||
s.releaseCaptureLease(key)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) releaseCaptureLease(key string) {
|
||||
if s == nil || key == "" {
|
||||
return
|
||||
}
|
||||
s.captureLeaseMu.Lock()
|
||||
release := s.captureLeases[key]
|
||||
delete(s.captureLeases, key)
|
||||
if timer := s.captureLeaseTimers[key]; timer != nil {
|
||||
timer.Stop()
|
||||
delete(s.captureLeaseTimers, key)
|
||||
}
|
||||
s.captureLeaseMu.Unlock()
|
||||
|
||||
if release != nil {
|
||||
release()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -104,6 +105,53 @@ func (s *Service) syncConfigFromPicoclaw() *PicoclawError {
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncRuntimeConfigMetadataFromPicoclaw refreshes the model metadata exposed
|
||||
// by RuntimeStatus without changing PicoClaw's config or probing its gateway.
|
||||
// This is used while PicoClaw does not own the control mode.
|
||||
func (s *Service) syncRuntimeConfigMetadataFromPicoclaw() *PicoclawError {
|
||||
doc, err := loadPicoclawConfigDocument()
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.ModelConfigured = false
|
||||
status.ModelName = ""
|
||||
status.ConfigError = ""
|
||||
if status.Status == "config_error" {
|
||||
status.Status = "checking"
|
||||
status.LastError = ""
|
||||
}
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.ModelConfigured = false
|
||||
status.ModelName = ""
|
||||
status.Status = "config_error"
|
||||
status.ConfigError = err.Error()
|
||||
status.LastError = err.Error()
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
return newPicoclawError(CodeRuntimeUnavailable, err.Error())
|
||||
}
|
||||
|
||||
modelName := resolvePicoclawTargetModelName(doc.config)
|
||||
modelConfigured := isPicoclawModelConfigured(doc.config, doc.security, modelName)
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.ModelConfigured = modelConfigured
|
||||
status.ModelName = modelName
|
||||
status.ConfigError = ""
|
||||
if status.Status == "config_error" || (modelConfigured && status.Status == "model_not_configured") {
|
||||
status.Status = "checking"
|
||||
status.LastError = ""
|
||||
}
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type picoclawGatewaySettings struct {
|
||||
GatewayURL string
|
||||
Token string
|
||||
|
||||
@@ -3,6 +3,7 @@ package picoclaw
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
@@ -93,19 +94,55 @@ func loadPicoclawConfigDocument() (*picoclawConfigDocument, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
var runPicoclawOnboardForConfig = runPicoclawOnboard
|
||||
|
||||
func loadOrInitializePicoclawConfigDocument() (*picoclawConfigDocument, error) {
|
||||
doc, err := loadPicoclawConfigDocument()
|
||||
if err == nil {
|
||||
return doc, nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, onboardErr := runPicoclawOnboardForConfig(); onboardErr != nil {
|
||||
return nil, fmt.Errorf("failed to initialize PicoClaw config before saving model config: %w", onboardErr)
|
||||
}
|
||||
|
||||
doc, err = loadPicoclawConfigDocument()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load PicoClaw config after initialization: %w", err)
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
func resolvePicoclawConfigPath() (string, error) {
|
||||
home := os.Getenv("PICOCLAW_HOME")
|
||||
if home == "" {
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve PICOCLAW_HOME: %w", err)
|
||||
}
|
||||
home = filepath.Join(currentUser.HomeDir, ".picoclaw")
|
||||
home, err := resolvePicoclawHome()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(home, "config.json"), nil
|
||||
}
|
||||
|
||||
func resolvePicoclawHome() (string, error) {
|
||||
if home := strings.TrimSpace(os.Getenv("PICOCLAW_HOME")); home != "" {
|
||||
return home, nil
|
||||
}
|
||||
|
||||
currentUser, err := user.Current()
|
||||
if err == nil && currentUser.HomeDir != "" {
|
||||
return filepath.Join(currentUser.HomeDir, ".picoclaw"), nil
|
||||
}
|
||||
if home := strings.TrimSpace(os.Getenv("HOME")); home != "" {
|
||||
return filepath.Join(home, ".picoclaw"), nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve PICOCLAW_HOME: %w", err)
|
||||
}
|
||||
return filepath.Join("/root", ".picoclaw"), nil
|
||||
}
|
||||
|
||||
func expandPicoclawPath(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
|
||||
50
server/service/picoclaw/control_events.go
Normal file
50
server/service/picoclaw/control_events.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
)
|
||||
|
||||
func (s *Service) PublishControlModeChanged(status controlmode.Status) {
|
||||
s.PublishControlModeChangedFrom(status, "")
|
||||
}
|
||||
|
||||
func (s *Service) PublishControlModeChangedFrom(status controlmode.Status, source string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.ensureDependencies()
|
||||
|
||||
payload := controlModeChangedPayload(status, source)
|
||||
message := picoGatewayMessage{
|
||||
Type: "control.mode_changed",
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
Payload: payload,
|
||||
}
|
||||
cfg := s.config.Get()
|
||||
for _, session := range GetSessionManager().Snapshot() {
|
||||
if session == nil || session.State != SessionStateActive || session.Downstream == nil {
|
||||
continue
|
||||
}
|
||||
_ = session.writeDownstreamJSON(cfg, message)
|
||||
}
|
||||
}
|
||||
|
||||
func controlModeChangedPayload(status controlmode.Status, source string) map[string]any {
|
||||
payload := map[string]any{
|
||||
"mode": string(status.Mode),
|
||||
"transitioning": status.Transitioning,
|
||||
"can_control": status.Mode == controlmode.ModePicoclaw && !status.Transitioning,
|
||||
}
|
||||
if status.LastError != "" {
|
||||
payload["last_error"] = status.LastError
|
||||
}
|
||||
if !status.ChangedAt.IsZero() {
|
||||
payload["changed_at"] = status.ChangedAt
|
||||
}
|
||||
if source != "" {
|
||||
payload["source"] = source
|
||||
}
|
||||
return payload
|
||||
}
|
||||
50
server/service/picoclaw/control_events_test.go
Normal file
50
server/service/picoclaw/control_events_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
)
|
||||
|
||||
func TestControlModeChangedPayloadIncludesControlMetadata(t *testing.T) {
|
||||
changedAt := time.Now().UTC()
|
||||
payload := controlModeChangedPayload(controlmode.Status{
|
||||
Mode: controlmode.ModeMCP,
|
||||
Transitioning: true,
|
||||
LastError: "switch failed",
|
||||
ChangedAt: changedAt,
|
||||
}, "mcp_config")
|
||||
|
||||
if payload["mode"] != string(controlmode.ModeMCP) {
|
||||
t.Fatalf("mode = %v, want %q", payload["mode"], controlmode.ModeMCP)
|
||||
}
|
||||
if payload["transitioning"] != true {
|
||||
t.Fatalf("transitioning = %v, want true", payload["transitioning"])
|
||||
}
|
||||
if payload["can_control"] != false {
|
||||
t.Fatalf("can_control = %v, want false", payload["can_control"])
|
||||
}
|
||||
if payload["last_error"] != "switch failed" {
|
||||
t.Fatalf("last_error = %v, want switch failed", payload["last_error"])
|
||||
}
|
||||
if payload["changed_at"] != changedAt {
|
||||
t.Fatalf("changed_at = %v, want %v", payload["changed_at"], changedAt)
|
||||
}
|
||||
if payload["source"] != "mcp_config" {
|
||||
t.Fatalf("source = %v, want mcp_config", payload["source"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlModeChangedPayloadAllowsPicoclawControlWhenStable(t *testing.T) {
|
||||
payload := controlModeChangedPayload(controlmode.Status{
|
||||
Mode: controlmode.ModePicoclaw,
|
||||
}, "")
|
||||
|
||||
if payload["can_control"] != true {
|
||||
t.Fatalf("can_control = %v, want true", payload["can_control"])
|
||||
}
|
||||
if _, ok := payload["source"]; ok {
|
||||
t.Fatalf("source = %v, want omitted", payload["source"])
|
||||
}
|
||||
}
|
||||
267
server/service/picoclaw/control_mode.go
Normal file
267
server/service/picoclaw/control_mode.go
Normal file
@@ -0,0 +1,267 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func (s *Service) PreemptForMCP() error {
|
||||
if err := s.PreemptControlLeasesForMCP(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.StopRuntimeForMCP()
|
||||
}
|
||||
|
||||
func (s *Service) PreemptControlLeasesForMCP() error {
|
||||
startedAt := time.Now()
|
||||
if s == nil {
|
||||
return fmt.Errorf("picoclaw service is unavailable")
|
||||
}
|
||||
s.ensureDependencies()
|
||||
|
||||
activeOperations := s.CancelActiveControlOperations()
|
||||
closedSessions := s.ReleaseControlSessions(
|
||||
CloseCodeControlModeSwitched,
|
||||
"external MCP enabled",
|
||||
)
|
||||
log.WithFields(log.Fields{
|
||||
"active_operations": activeOperations,
|
||||
"closed_sessions": closedSessions,
|
||||
"elapsed_ms": time.Since(startedAt).Milliseconds(),
|
||||
}).Info("picoclaw control leases preempted for MCP")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) StopRuntimeForMCP() error {
|
||||
startedAt := time.Now()
|
||||
if s == nil {
|
||||
return fmt.Errorf("picoclaw service is unavailable")
|
||||
}
|
||||
s.ensureDependencies()
|
||||
|
||||
currentStatus := s.runtime.Get()
|
||||
statusRequiresStop :=
|
||||
currentStatus.Ready ||
|
||||
currentStatus.Status == "ready" ||
|
||||
isRuntimeLifecycleStatusPending(currentStatus)
|
||||
running, runningErr := isRuntimeRunning()
|
||||
if runningErr != nil {
|
||||
if !statusRequiresStop {
|
||||
log.WithFields(log.Fields{
|
||||
"status": currentStatus.Status,
|
||||
"ready": currentStatus.Ready,
|
||||
"elapsed_ms": time.Since(startedAt).Milliseconds(),
|
||||
}).WithError(runningErr).Warn("skipping PicoClaw runtime stop for MCP because runtime is not active")
|
||||
s.setRuntimeIntentDesired(false, "mcp_preempt")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("check PicoClaw runtime for MCP: %w", runningErr)
|
||||
}
|
||||
if !running && !statusRequiresStop {
|
||||
s.setRuntimeIntentDesired(false, "mcp_preempt")
|
||||
log.WithFields(log.Fields{
|
||||
"status": currentStatus.Status,
|
||||
"ready": currentStatus.Ready,
|
||||
"elapsed_ms": time.Since(startedAt).Milliseconds(),
|
||||
}).Info("PicoClaw runtime stop skipped for MCP because runtime is not active")
|
||||
return nil
|
||||
}
|
||||
|
||||
unlockLifecycle := s.lockRuntimeLifecycle()
|
||||
defer unlockLifecycle()
|
||||
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Restoring = false
|
||||
status.Status = "stopping"
|
||||
status.LastError = ""
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
if err := s.stopRuntimeAndVerify(false); err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"elapsed_ms": time.Since(startedAt).Milliseconds(),
|
||||
}).WithError(err).Warn("picoclaw stop runtime for MCP failed")
|
||||
return fmt.Errorf("stop PicoClaw runtime for MCP: %w", err)
|
||||
}
|
||||
s.setRuntimeIntentDesired(false, "mcp_preempt")
|
||||
log.WithFields(log.Fields{
|
||||
"elapsed_ms": time.Since(startedAt).Milliseconds(),
|
||||
}).Info("picoclaw runtime stopped for MCP")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) CompleteControlRelease(source string, closeCode int, closeReason string) int {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
s.ensureDependencies()
|
||||
unlockLifecycle := s.lockRuntimeLifecycle()
|
||||
defer unlockLifecycle()
|
||||
|
||||
s.CancelActiveControlOperations()
|
||||
s.setRuntimeIntentDesired(false, source)
|
||||
return s.ReleaseControlSessions(closeCode, closeReason)
|
||||
}
|
||||
|
||||
func (s *Service) PreserveRuntimeForChatOnly(source string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.ensureDependencies()
|
||||
|
||||
status := s.runtime.Get()
|
||||
if !status.Ready && len(GetSessionManager().Snapshot()) == 0 {
|
||||
return
|
||||
}
|
||||
s.setRuntimeIntentDesired(true, source)
|
||||
}
|
||||
|
||||
func (s *Service) RuntimeStatus() RuntimeStatus {
|
||||
if s == nil {
|
||||
return RuntimeStatus{
|
||||
Ready: false,
|
||||
Installed: false,
|
||||
InstallPath: picoclawBinaryPath,
|
||||
Status: "unavailable",
|
||||
}
|
||||
}
|
||||
return s.runtimeStatus()
|
||||
}
|
||||
|
||||
func (s *Service) stopForRuntimeStop() error {
|
||||
s.ensureDependencies()
|
||||
return s.stopRuntimeAndCloseSessions(
|
||||
CloseCodeRuntimeStopped,
|
||||
"PicoClaw runtime stopped",
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) stopRuntimeAndCloseSessions(closeCode int, closeReason string) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.ensureDependencies()
|
||||
startedAt := time.Now()
|
||||
sessions := GetSessionManager().Snapshot()
|
||||
for _, session := range sessions {
|
||||
s.closeGatewaySession(session, closeCode, closeReason)
|
||||
}
|
||||
closeElapsed := time.Since(startedAt)
|
||||
if s.lock != nil {
|
||||
s.lock.Release("")
|
||||
}
|
||||
|
||||
stopStartedAt := time.Now()
|
||||
if err := s.stopRuntimeAndVerify(false); err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"session_count": len(sessions),
|
||||
"close_code": closeCode,
|
||||
"close_sessions_ms": closeElapsed.Milliseconds(),
|
||||
"stop_runtime_ms": time.Since(stopStartedAt).Milliseconds(),
|
||||
"total_ms": time.Since(startedAt).Milliseconds(),
|
||||
}).WithError(err).Warn("picoclaw stop runtime and close sessions failed")
|
||||
return err
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"session_count": len(sessions),
|
||||
"close_code": closeCode,
|
||||
"close_sessions_ms": closeElapsed.Milliseconds(),
|
||||
"stop_runtime_ms": time.Since(stopStartedAt).Milliseconds(),
|
||||
"total_ms": time.Since(startedAt).Milliseconds(),
|
||||
}).Info("picoclaw runtime stopped and gateway sessions closed")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ReleaseControlSessions(closeCode int, closeReason string) int {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
s.ensureDependencies()
|
||||
startedAt := time.Now()
|
||||
sessions := GetSessionManager().Snapshot()
|
||||
for _, session := range sessions {
|
||||
s.closeGatewaySession(session, closeCode, closeReason)
|
||||
}
|
||||
if s.lock != nil {
|
||||
s.lock.Release("")
|
||||
}
|
||||
log.WithFields(log.Fields{
|
||||
"session_count": len(sessions),
|
||||
"close_code": closeCode,
|
||||
"elapsed_ms": time.Since(startedAt).Milliseconds(),
|
||||
}).Info("picoclaw gateway sessions closed for control release")
|
||||
return len(sessions)
|
||||
}
|
||||
|
||||
func (s *Service) stopRuntimeAndVerify(forceStop bool) error {
|
||||
if s == nil {
|
||||
return fmt.Errorf("picoclaw service is unavailable")
|
||||
}
|
||||
s.ensureDependencies()
|
||||
err := stopRuntimeProcessAndVerify(
|
||||
forceStop,
|
||||
isRuntimeRunning,
|
||||
func() error {
|
||||
_, _, stopErr := s.stopRuntime()
|
||||
return stopErr
|
||||
},
|
||||
picoclawStopTimeout,
|
||||
100*time.Millisecond,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Status = "stopped"
|
||||
status.CurrentSession = ""
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func stopRuntimeProcessAndVerify(
|
||||
forceStop bool,
|
||||
isRunning func() (bool, error),
|
||||
stop func() error,
|
||||
timeout time.Duration,
|
||||
pollInterval time.Duration,
|
||||
) error {
|
||||
if !forceStop {
|
||||
running, err := isRunning()
|
||||
if err != nil {
|
||||
return fmt.Errorf("check PicoClaw runtime: %w", err)
|
||||
}
|
||||
if !running {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var stopErr error
|
||||
if err := stop(); err != nil {
|
||||
stopErr = fmt.Errorf("stop PicoClaw runtime: %w", err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
running, err := isRunning()
|
||||
if err != nil {
|
||||
return errors.Join(stopErr, fmt.Errorf("verify PicoClaw stopped: %w", err))
|
||||
}
|
||||
if !running {
|
||||
if stopErr != nil {
|
||||
log.Warnf("PicoClaw stop command returned an error after the runtime stopped: %v", stopErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
return errors.Join(stopErr, fmt.Errorf("PicoClaw runtime is still running"))
|
||||
}
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
}
|
||||
180
server/service/picoclaw/control_operations.go
Normal file
180
server/service/picoclaw/control_operations.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
maxControlOperationDuration = 35 * time.Second
|
||||
maxWaitDurationMS = 30_000
|
||||
)
|
||||
|
||||
var (
|
||||
errControlModeSwitch = errors.New("PicoClaw control operation canceled by control mode switch")
|
||||
errControlTimeout = errors.New("PicoClaw control operation timed out")
|
||||
)
|
||||
|
||||
type controlOperationTracker struct {
|
||||
mu sync.Mutex
|
||||
nextID uint64
|
||||
active map[uint64]context.CancelCauseFunc
|
||||
}
|
||||
|
||||
func newControlOperationTracker() *controlOperationTracker {
|
||||
return &controlOperationTracker{active: make(map[uint64]context.CancelCauseFunc)}
|
||||
}
|
||||
|
||||
func (t *controlOperationTracker) begin(parent context.Context) (context.Context, func()) {
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
if t == nil {
|
||||
return parent, func() {}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancelCause(parent)
|
||||
t.mu.Lock()
|
||||
if t.active == nil {
|
||||
t.active = make(map[uint64]context.CancelCauseFunc)
|
||||
}
|
||||
t.nextID++
|
||||
id := t.nextID
|
||||
t.active[id] = cancel
|
||||
t.mu.Unlock()
|
||||
|
||||
var once sync.Once
|
||||
release := func() {
|
||||
once.Do(func() {
|
||||
cancel(context.Canceled)
|
||||
t.mu.Lock()
|
||||
delete(t.active, id)
|
||||
t.mu.Unlock()
|
||||
})
|
||||
}
|
||||
return ctx, release
|
||||
}
|
||||
|
||||
func (t *controlOperationTracker) cancelAll(cause error) int {
|
||||
if t == nil {
|
||||
return 0
|
||||
}
|
||||
if cause == nil {
|
||||
cause = context.Canceled
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
cancels := make([]context.CancelCauseFunc, 0, len(t.active))
|
||||
for _, cancel := range t.active {
|
||||
cancels = append(cancels, cancel)
|
||||
}
|
||||
t.mu.Unlock()
|
||||
|
||||
for _, cancel := range cancels {
|
||||
cancel(cause)
|
||||
}
|
||||
return len(cancels)
|
||||
}
|
||||
|
||||
func (s *Service) beginControlOperation(parent context.Context) (context.Context, func()) {
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
|
||||
timedCtx, cancelTimeout := context.WithTimeoutCause(
|
||||
parent,
|
||||
maxControlOperationDuration,
|
||||
errControlTimeout,
|
||||
)
|
||||
if s == nil || s.operations == nil {
|
||||
return timedCtx, cancelTimeout
|
||||
}
|
||||
|
||||
operationCtx, releaseOperation := s.operations.begin(timedCtx)
|
||||
var once sync.Once
|
||||
return operationCtx, func() {
|
||||
once.Do(func() {
|
||||
releaseOperation()
|
||||
cancelTimeout()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) beginRuntimeLifecycleOperation(parent context.Context) (context.Context, func()) {
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
if s == nil || s.operations == nil {
|
||||
ctx, cancel := context.WithCancelCause(parent)
|
||||
return ctx, func() { cancel(context.Canceled) }
|
||||
}
|
||||
return s.operations.begin(parent)
|
||||
}
|
||||
|
||||
func (s *Service) CancelActiveControlOperations() int {
|
||||
if s == nil || s.operations == nil {
|
||||
return 0
|
||||
}
|
||||
count := s.operations.cancelAll(errControlModeSwitch)
|
||||
log.WithFields(log.Fields{
|
||||
"active_operations": count,
|
||||
}).Info("picoclaw control operations canceled for control mode switch")
|
||||
return count
|
||||
}
|
||||
|
||||
func runtimeLifecycleOperationError(ctx context.Context) *PicoclawError {
|
||||
if ctx == nil || ctx.Err() == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cause := context.Cause(ctx)
|
||||
switch {
|
||||
case errors.Is(cause, errControlModeSwitch):
|
||||
return newPicoclawError(CodeControlModeConflict, "PicoClaw runtime lifecycle canceled because the control mode is switching")
|
||||
case errors.Is(cause, context.DeadlineExceeded):
|
||||
return newPicoclawError(CodeRuntimeUnavailable, "PicoClaw runtime lifecycle timed out")
|
||||
default:
|
||||
return newPicoclawError(CodeRuntimeUnavailable, "PicoClaw runtime lifecycle was canceled")
|
||||
}
|
||||
}
|
||||
|
||||
func controlOperationError(ctx context.Context) *PicoclawError {
|
||||
if ctx == nil || ctx.Err() == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cause := context.Cause(ctx)
|
||||
switch {
|
||||
case errors.Is(cause, errControlModeSwitch):
|
||||
return newPicoclawError(CodeControlModeConflict, "PicoClaw control operation canceled because the control mode is switching")
|
||||
case errors.Is(cause, errControlTimeout), errors.Is(cause, context.DeadlineExceeded):
|
||||
return newPicoclawError(CodeInvalidAction, "PicoClaw control operation exceeded the 35 second limit")
|
||||
default:
|
||||
return newPicoclawError(CodeInvalidAction, "PicoClaw control operation was canceled")
|
||||
}
|
||||
}
|
||||
|
||||
func waitForControlOperation(ctx context.Context, delay time.Duration) *PicoclawError {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := controlOperationError(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if delay <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return controlOperationError(ctx)
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
143
server/service/picoclaw/control_operations_test.go
Normal file
143
server/service/picoclaw/control_operations_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
)
|
||||
|
||||
func TestControlModeSwitchCancelsActiveWait(t *testing.T) {
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw)
|
||||
service := &Service{
|
||||
control: control,
|
||||
operations: newControlOperationTracker(),
|
||||
}
|
||||
|
||||
operationCtx, releaseOperation := service.beginControlOperation(context.Background())
|
||||
releaseMode, modeErr := service.acquireControlMode()
|
||||
if modeErr != nil {
|
||||
t.Fatal(modeErr)
|
||||
}
|
||||
|
||||
actionStarted := make(chan struct{})
|
||||
actionDone := make(chan *PicoclawError, 1)
|
||||
go func() {
|
||||
close(actionStarted)
|
||||
_, actionErr := service.executeAction(operationCtx, Action{
|
||||
Action: "wait",
|
||||
DurationMs: maxWaitDurationMS,
|
||||
})
|
||||
releaseMode()
|
||||
releaseOperation()
|
||||
actionDone <- actionErr
|
||||
}()
|
||||
<-actionStarted
|
||||
|
||||
switchDone := make(chan error, 1)
|
||||
go func() {
|
||||
switchDone <- control.Switch(controlmode.ModeMCP, func() error {
|
||||
service.CancelActiveControlOperations()
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-switchDone:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("control mode switch did not cancel the active PicoClaw wait")
|
||||
}
|
||||
|
||||
select {
|
||||
case actionErr := <-actionDone:
|
||||
if actionErr == nil || actionErr.Code != CodeControlModeConflict {
|
||||
t.Fatalf("action error = %+v, want %s", actionErr, CodeControlModeConflict)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("canceled PicoClaw action did not return")
|
||||
}
|
||||
|
||||
if got := control.Current(); got != controlmode.ModeMCP {
|
||||
t.Fatalf("mode = %q, want %q", got, controlmode.ModeMCP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlModeSwitchCancelsRuntimeLifecycle(t *testing.T) {
|
||||
control := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw)
|
||||
service := &Service{
|
||||
control: control,
|
||||
operations: newControlOperationTracker(),
|
||||
}
|
||||
|
||||
operationCtx, releaseOperation := service.beginRuntimeLifecycleOperation(context.Background())
|
||||
releaseMode, modeErr := service.acquireControlMode()
|
||||
if modeErr != nil {
|
||||
t.Fatal(modeErr)
|
||||
}
|
||||
|
||||
lifecycleDone := make(chan *PicoclawError, 1)
|
||||
go func() {
|
||||
<-operationCtx.Done()
|
||||
lifecycleErr := runtimeLifecycleOperationError(operationCtx)
|
||||
releaseOperation()
|
||||
releaseMode()
|
||||
lifecycleDone <- lifecycleErr
|
||||
}()
|
||||
|
||||
switchDone := make(chan error, 1)
|
||||
go func() {
|
||||
switchDone <- control.Switch(controlmode.ModeMCP, func() error {
|
||||
service.CancelActiveControlOperations()
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-switchDone:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("control mode switch did not cancel the runtime lifecycle operation")
|
||||
}
|
||||
|
||||
select {
|
||||
case lifecycleErr := <-lifecycleDone:
|
||||
if lifecycleErr == nil || lifecycleErr.Code != CodeControlModeConflict {
|
||||
t.Fatalf("lifecycle error = %+v, want %s", lifecycleErr, CodeControlModeConflict)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("canceled runtime lifecycle operation did not return")
|
||||
}
|
||||
|
||||
if got := control.Current(); got != controlmode.ModeMCP {
|
||||
t.Fatalf("mode = %q, want %q", got, controlmode.ModeMCP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeReadyWaitHonorsLifecycleCancellation(t *testing.T) {
|
||||
service := &Service{}
|
||||
ctx, cancel := context.WithCancelCause(context.Background())
|
||||
cancel(errControlModeSwitch)
|
||||
|
||||
err := service.waitForRuntimeReadyContext(ctx, time.Hour)
|
||||
if err == nil || err.Code != CodeControlModeConflict {
|
||||
t.Fatalf("error = %+v, want %s", err, CodeControlModeConflict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitDurationIsBounded(t *testing.T) {
|
||||
service := &Service{}
|
||||
_, err := service.executeAction(context.Background(), Action{
|
||||
Action: "wait",
|
||||
DurationMs: maxWaitDurationMS + 1,
|
||||
})
|
||||
if err == nil || err.Code != CodeInvalidAction {
|
||||
t.Fatalf("error = %+v, want %s", err, CodeInvalidAction)
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,19 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
CodePicoclawLockHeld = "AI_LOCK_HELD"
|
||||
CodeScreenshotFailed = "SCREENSHOT_FAILED"
|
||||
CodeScreenshotNoSignal = "SCREENSHOT_NO_SIGNAL"
|
||||
CodeHIDWriteFailed = "HID_WRITE_FAILED"
|
||||
CodeInvalidAction = "INVALID_ACTION"
|
||||
CodeRuntimeUnavailable = "RUNTIME_UNAVAILABLE"
|
||||
CodeRuntimeStartFailed = "RUNTIME_START_FAILED"
|
||||
CodeSessionIDMissing = "SESSION_ID_MISSING"
|
||||
CodeSessionIDInvalid = "SESSION_ID_INVALID"
|
||||
CodePicoclawLockHeld = "AI_LOCK_HELD"
|
||||
CodeScreenshotFailed = "SCREENSHOT_FAILED"
|
||||
CodeScreenshotNoSignal = "SCREENSHOT_NO_SIGNAL"
|
||||
CodeHIDWriteFailed = "HID_WRITE_FAILED"
|
||||
CodeInvalidAction = "INVALID_ACTION"
|
||||
CodeRuntimeUnavailable = "RUNTIME_UNAVAILABLE"
|
||||
CodeRuntimeStartFailed = "RUNTIME_START_FAILED"
|
||||
CodeSessionIDMissing = "SESSION_ID_MISSING"
|
||||
CodeSessionIDInvalid = "SESSION_ID_INVALID"
|
||||
CodeControlModeConflict = "AI_MODE_CONFLICT"
|
||||
CodeControlRequired = "CONTROL_REQUIRED"
|
||||
CodeControlOwnedByMCP = "CONTROL_OWNED_BY_MCP"
|
||||
CodeControlTransitioning = "CONTROL_TRANSITIONING"
|
||||
)
|
||||
|
||||
type PicoclawError struct {
|
||||
@@ -47,6 +51,10 @@ func writeSuccess(c *gin.Context, data interface{}) {
|
||||
}
|
||||
|
||||
func writePicoclawError(c *gin.Context, err *PicoclawError) {
|
||||
writePicoclawErrorWithData(c, err, nil)
|
||||
}
|
||||
|
||||
func writePicoclawErrorWithData(c *gin.Context, err *PicoclawError, data interface{}) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
@@ -55,6 +63,9 @@ func writePicoclawError(c *gin.Context, err *PicoclawError) {
|
||||
"code": err.Code,
|
||||
"message": err.Message,
|
||||
}
|
||||
if data != nil {
|
||||
payload["data"] = data
|
||||
}
|
||||
if err.SessionID != "" {
|
||||
payload["session_id"] = err.SessionID
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type gatewayProbeError struct {
|
||||
status string
|
||||
configError string
|
||||
lastError string
|
||||
message string
|
||||
}
|
||||
|
||||
func (s *Service) connectGateway(sessionID string) (*websocket.Conn, *PicoclawError) {
|
||||
cfg := s.config.Get()
|
||||
|
||||
@@ -66,6 +73,69 @@ func (s *Service) connectGateway(sessionID string) (*websocket.Conn, *PicoclawEr
|
||||
return upstream, nil
|
||||
}
|
||||
|
||||
func probePicoclawGateway(cfg Config) *gatewayProbeError {
|
||||
gatewayURL, err := buildGatewayURL(cfg, "runtime-probe")
|
||||
if err != nil {
|
||||
return &gatewayProbeError{
|
||||
status: "config_error",
|
||||
configError: err.Error(),
|
||||
lastError: err.Error(),
|
||||
message: "gateway config is invalid",
|
||||
}
|
||||
}
|
||||
|
||||
header := http.Header{}
|
||||
if cfg.Token != "" {
|
||||
header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.Token))
|
||||
}
|
||||
|
||||
timeout := time.Duration(cfg.ConnectTimeoutMs) * time.Millisecond
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
dialer := websocket.Dialer{
|
||||
HandshakeTimeout: timeout,
|
||||
NetDialContext: (&net.Dialer{
|
||||
Timeout: timeout,
|
||||
}).DialContext,
|
||||
}
|
||||
|
||||
conn, response, err := dialer.Dial(gatewayURL, header)
|
||||
if err == nil {
|
||||
_ = conn.WriteControl(
|
||||
websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "runtime probe complete"),
|
||||
time.Now().Add(time.Second),
|
||||
)
|
||||
_ = conn.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
probeErr := &gatewayProbeError{
|
||||
status: "unavailable",
|
||||
lastError: err.Error(),
|
||||
message: "gateway websocket is unavailable",
|
||||
}
|
||||
if response == nil {
|
||||
return probeErr
|
||||
}
|
||||
|
||||
switch response.StatusCode {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
probeErr.status = "config_error"
|
||||
probeErr.configError = "gateway authentication failed"
|
||||
probeErr.message = "gateway authentication failed"
|
||||
case http.StatusNotFound:
|
||||
probeErr.lastError = "gateway pico channel is unavailable"
|
||||
probeErr.message = "gateway pico channel is unavailable"
|
||||
default:
|
||||
probeErr.lastError = fmt.Sprintf("gateway websocket handshake failed: HTTP %d", response.StatusCode)
|
||||
probeErr.message = "gateway websocket handshake failed"
|
||||
}
|
||||
|
||||
return probeErr
|
||||
}
|
||||
|
||||
func buildGatewayURL(cfg Config, sessionID string) (string, error) {
|
||||
parsed, err := url.Parse(cfg.GatewayURL)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
"NanoKVM-Server/service/stream/mjpeg"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -30,6 +31,19 @@ type relayResult struct {
|
||||
}
|
||||
|
||||
func (s *Service) ConnectGateway(c *gin.Context) {
|
||||
s.ensureDependencies()
|
||||
modeStatus, modeErr := s.control.Status()
|
||||
if modeErr != nil {
|
||||
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, modeErr.Error()))
|
||||
return
|
||||
}
|
||||
if modeStatus.Transitioning || modeStatus.Mode == controlmode.ModeMCP {
|
||||
controlErr := s.controlWriteError(controlmode.ModePicoclaw, nil)
|
||||
controlErr.StatusCode = http.StatusConflict
|
||||
writePicoclawError(c, controlErr)
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := strings.TrimSpace(c.Query("session_id"))
|
||||
if sessionID == "" {
|
||||
sessionID = uuid.NewString()
|
||||
@@ -69,7 +83,7 @@ func (s *Service) ConnectGateway(c *gin.Context) {
|
||||
if err != nil {
|
||||
log.Errorf("failed to upgrade gateway websocket: %s", err)
|
||||
_ = upstream.Close()
|
||||
ReleaseSession(sessionID)
|
||||
s.releaseGatewaySession(sessionID)
|
||||
GetSessionManager().SetState(sessionID, SessionStateClosed)
|
||||
GetSessionManager().Remove(sessionID)
|
||||
return
|
||||
@@ -92,7 +106,6 @@ func (s *Service) ConnectGateway(c *gin.Context) {
|
||||
go s.runPingLoop("upstream", session.SessionID, upstream, cfg, &wg)
|
||||
go s.proxyMessages("downstream", session, downstream, cfg, &wg, results)
|
||||
go s.proxyMessages("upstream", session, upstream, cfg, &wg, results)
|
||||
|
||||
result := <-results
|
||||
closeCode := result.CloseCode
|
||||
if closeCode == 0 {
|
||||
@@ -142,6 +155,7 @@ func (s *Service) proxyMessages(source string, session *GatewaySession, src *web
|
||||
}
|
||||
return
|
||||
}
|
||||
s.updateTaskCaptureLease(source, session.SessionID, data)
|
||||
|
||||
var writeErr error
|
||||
switch source {
|
||||
@@ -202,6 +216,7 @@ func (s *Service) closeGatewaySession(session *GatewaySession, closeCode int, re
|
||||
hadDownstream := session.Downstream != nil
|
||||
|
||||
mjpeg.DisableLatestFrameCache()
|
||||
s.releaseCaptureLeasesForSession(session.SessionID)
|
||||
GetSessionManager().SetState(session.SessionID, SessionStateClosing)
|
||||
|
||||
if session.Upstream != nil {
|
||||
@@ -217,7 +232,7 @@ func (s *Service) closeGatewaySession(session *GatewaySession, closeCode int, re
|
||||
cleanupPicoclawMediaTempDir()
|
||||
}
|
||||
|
||||
ReleaseSession(session.SessionID)
|
||||
s.releaseGatewaySession(session.SessionID)
|
||||
GetSessionManager().SetState(session.SessionID, SessionStateClosed)
|
||||
GetSessionManager().Remove(session.SessionID)
|
||||
|
||||
|
||||
@@ -28,8 +28,9 @@ type jsonRPCResponse struct {
|
||||
}
|
||||
|
||||
type jsonRPCError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// MCP tool definitions
|
||||
@@ -59,17 +60,22 @@ var mcpToolDefinitions = []map[string]interface{}{
|
||||
"items": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"action": map[string]interface{}{"type": "string"},
|
||||
"x": map[string]interface{}{"type": "number"},
|
||||
"y": map[string]interface{}{"type": "number"},
|
||||
"button": map[string]interface{}{"type": "string"},
|
||||
"text": map[string]interface{}{"type": "string"},
|
||||
"keys": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
|
||||
"direction": map[string]interface{}{"type": "string"},
|
||||
"amount": map[string]interface{}{"type": "integer"},
|
||||
"duration_ms": map[string]interface{}{"type": "integer"},
|
||||
"from": map[string]interface{}{"type": "object", "properties": map[string]interface{}{"x": map[string]interface{}{"type": "number"}, "y": map[string]interface{}{"type": "number"}}},
|
||||
"to": map[string]interface{}{"type": "object", "properties": map[string]interface{}{"x": map[string]interface{}{"type": "number"}, "y": map[string]interface{}{"type": "number"}}},
|
||||
"action": map[string]interface{}{"type": "string"},
|
||||
"x": map[string]interface{}{"type": "number"},
|
||||
"y": map[string]interface{}{"type": "number"},
|
||||
"button": map[string]interface{}{"type": "string"},
|
||||
"text": map[string]interface{}{"type": "string"},
|
||||
"keys": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
|
||||
"direction": map[string]interface{}{"type": "string"},
|
||||
"amount": map[string]interface{}{"type": "integer"},
|
||||
"duration_ms": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": maxWaitDurationMS,
|
||||
"description": "Wait duration in milliseconds, up to 30000",
|
||||
},
|
||||
"from": map[string]interface{}{"type": "object", "properties": map[string]interface{}{"x": map[string]interface{}{"type": "number"}, "y": map[string]interface{}{"type": "number"}}},
|
||||
"to": map[string]interface{}{"type": "object", "properties": map[string]interface{}{"x": map[string]interface{}{"type": "number"}, "y": map[string]interface{}{"type": "number"}}},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
},
|
||||
@@ -122,6 +128,33 @@ func (s *Service) MCPHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func mcpModeConflictResponse(req jsonRPCRequest, err *PicoclawError, status controlStatusForMCP) jsonRPCResponse {
|
||||
message := "PicoClaw control mode is not active"
|
||||
reason := CodeControlRequired
|
||||
if err != nil && err.Message != "" {
|
||||
message = err.Message
|
||||
reason = err.Code
|
||||
}
|
||||
return jsonRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Error: &jsonRPCError{
|
||||
Code: -32003,
|
||||
Message: message,
|
||||
Data: map[string]interface{}{
|
||||
"reason": reason,
|
||||
"control_mode": status.Mode,
|
||||
"transitioning": status.Transitioning,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type controlStatusForMCP struct {
|
||||
Mode string
|
||||
Transitioning bool
|
||||
}
|
||||
|
||||
func (s *Service) mcpInitialize(req jsonRPCRequest) jsonRPCResponse {
|
||||
return jsonRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
@@ -166,6 +199,22 @@ func (s *Service) mcpToolsCall(req jsonRPCRequest, c *gin.Context) jsonRPCRespon
|
||||
case "kvm_screenshot":
|
||||
return s.mcpScreenshot(req, params.Arguments, c)
|
||||
case "kvm_actions":
|
||||
releaseMode, modeErr := s.acquireControlMode()
|
||||
if modeErr != nil {
|
||||
controlStatus := controlStatusForMCP{}
|
||||
if status, err := s.control.Status(); err == nil {
|
||||
controlStatus = controlStatusForMCP{
|
||||
Mode: string(status.Mode),
|
||||
Transitioning: status.Transitioning,
|
||||
}
|
||||
}
|
||||
return mcpModeConflictResponse(req, modeErr, controlStatus)
|
||||
}
|
||||
defer releaseMode()
|
||||
|
||||
operationCtx, releaseOperation := s.beginControlOperation(c.Request.Context())
|
||||
defer releaseOperation()
|
||||
c.Request = c.Request.WithContext(operationCtx)
|
||||
return s.mcpActions(req, params.Arguments, c)
|
||||
default:
|
||||
return jsonRPCResponse{
|
||||
@@ -193,7 +242,7 @@ func (s *Service) mcpScreenshot(req jsonRPCRequest, args json.RawMessage, c *gin
|
||||
Quality: params.Quality,
|
||||
}
|
||||
|
||||
data, meta, err := s.captureScreenshot(query)
|
||||
data, meta, err := s.captureScreenshot(c.Request.Context(), query)
|
||||
if err != nil {
|
||||
return mcpToolError(req, err.Message)
|
||||
}
|
||||
@@ -262,7 +311,7 @@ func (s *Service) mcpActions(req jsonRPCRequest, args json.RawMessage, c *gin.Co
|
||||
sessionID = s.lock.Owner()
|
||||
}
|
||||
|
||||
result, err := s.executeActions(sessionID, params.Actions)
|
||||
result, err := s.executeActions(c.Request.Context(), sessionID, params.Actions)
|
||||
if err != nil {
|
||||
return mcpToolError(req, err.Message)
|
||||
}
|
||||
|
||||
128
server/service/picoclaw/mcp_handler_test.go
Normal file
128
server/service/picoclaw/mcp_handler_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestMCPBootstrapMethodsAllowedInAllControlModes(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
for _, mode := range []controlmode.Mode{controlmode.ModeOff, controlmode.ModeMCP, controlmode.ModePicoclaw} {
|
||||
t.Run(string(mode), func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "mode")
|
||||
if err := os.WriteFile(path, []byte(string(mode)+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := &Service{control: controlmode.NewManager(path, controlmode.ModePicoclaw)}
|
||||
|
||||
for _, method := range []string{"initialize", "tools/list", "ping"} {
|
||||
response := performMCPRequest(service, jsonRPCBody(method))
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("%s status = %d, body = %s", method, response.Code, response.Body.String())
|
||||
}
|
||||
if strings.Contains(response.Body.String(), `"error"`) {
|
||||
t.Fatalf("%s returned error: %s", method, response.Body.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPBootstrapAllowedDuringControlTransition(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
manager := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeOff)
|
||||
service := &Service{control: manager}
|
||||
|
||||
err := manager.Switch(controlmode.ModePicoclaw, func() error {
|
||||
response := performMCPRequest(service, jsonRPCBody("initialize"))
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
if strings.Contains(response.Body.String(), `"error"`) {
|
||||
t.Fatalf("bootstrap method returned error: %s", response.Body.String())
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPActionsReturnStructuredControlErrors(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
for _, test := range []struct {
|
||||
mode controlmode.Mode
|
||||
reason string
|
||||
}{
|
||||
{mode: controlmode.ModeOff, reason: CodeControlRequired},
|
||||
{mode: controlmode.ModeMCP, reason: CodeControlOwnedByMCP},
|
||||
} {
|
||||
t.Run(string(test.mode), func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "mode")
|
||||
if err := os.WriteFile(path, []byte(string(test.mode)+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := &Service{control: controlmode.NewManager(path, controlmode.ModePicoclaw)}
|
||||
response := performMCPRequest(
|
||||
service,
|
||||
`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"kvm_actions","arguments":{"actions":[]}}}`,
|
||||
)
|
||||
body := response.Body.String()
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, body)
|
||||
}
|
||||
if !strings.Contains(body, `"code":-32003`) || !strings.Contains(body, `"reason":"`+test.reason+`"`) {
|
||||
t.Fatalf("body = %s, want structured %s error", body, test.reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPActionsRejectedDuringControlTransition(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
manager := controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModePicoclaw)
|
||||
service := &Service{control: manager}
|
||||
|
||||
err := manager.Switch(controlmode.ModeMCP, func() error {
|
||||
response := performMCPRequest(
|
||||
service,
|
||||
`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"kvm_actions","arguments":{"actions":[]}}}`,
|
||||
)
|
||||
body := response.Body.String()
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, body)
|
||||
}
|
||||
if !strings.Contains(body, `"reason":"`+CodeControlTransitioning+`"`) {
|
||||
t.Fatalf("body = %s, want transitioning control error", body)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func performMCPRequest(service *Service, body string) *httptest.ResponseRecorder {
|
||||
router := gin.New()
|
||||
router.POST("/", service.MCPHandler)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func jsonRPCBody(method string) string {
|
||||
return `{"jsonrpc":"2.0","id":1,"method":"` + method + `"}`
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package picoclaw
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -20,6 +21,66 @@ func extractPicoclawModelName(model string) string {
|
||||
return model
|
||||
}
|
||||
|
||||
var (
|
||||
picoclawProviderPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]*$`)
|
||||
picoclawKnownProviders = map[string]struct{}{
|
||||
"anthropic": {},
|
||||
"azure": {},
|
||||
"azure_openai": {},
|
||||
"baichuan": {},
|
||||
"bedrock": {},
|
||||
"cerebras": {},
|
||||
"cohere": {},
|
||||
"dashscope": {},
|
||||
"deepseek": {},
|
||||
"fireworks_ai": {},
|
||||
"gemini": {},
|
||||
"google": {},
|
||||
"groq": {},
|
||||
"lmstudio": {},
|
||||
"mistral": {},
|
||||
"moonshot": {},
|
||||
"ollama": {},
|
||||
"openai": {},
|
||||
"openai_compatible": {},
|
||||
"openrouter": {},
|
||||
"qwen": {},
|
||||
"siliconflow": {},
|
||||
"together_ai": {},
|
||||
"vertex_ai": {},
|
||||
"vllm": {},
|
||||
"volcengine": {},
|
||||
"xai": {},
|
||||
"zhipu": {},
|
||||
}
|
||||
)
|
||||
|
||||
func validatePicoclawModelIdentifier(model string) (string, error) {
|
||||
model = strings.TrimSpace(model)
|
||||
provider, modelRef, ok := strings.Cut(model, "/")
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
modelRef = strings.TrimSpace(modelRef)
|
||||
|
||||
if !ok || provider == "" || modelRef == "" {
|
||||
return "", fmt.Errorf("model identifier must use provider/model format")
|
||||
}
|
||||
if !picoclawProviderPattern.MatchString(provider) {
|
||||
return "", fmt.Errorf("model provider %q is invalid", provider)
|
||||
}
|
||||
if provider == "openao" {
|
||||
return "", fmt.Errorf("model provider %q is invalid; did you mean openai?", provider)
|
||||
}
|
||||
if _, ok := picoclawKnownProviders[provider]; !ok {
|
||||
return "", fmt.Errorf("model provider %q is not supported by this PicoClaw integration", provider)
|
||||
}
|
||||
|
||||
modelName := extractPicoclawModelName(model)
|
||||
if modelName == "" {
|
||||
return "", fmt.Errorf("model identifier must include a model name")
|
||||
}
|
||||
return modelName, nil
|
||||
}
|
||||
|
||||
func isPicoclawModelConfigured(cfg picoclawConfigFile, security picoclawSecurityConfig, modelName string) bool {
|
||||
if modelName == "" {
|
||||
return false
|
||||
@@ -71,6 +132,7 @@ type ModelConfigUpdateRequest struct {
|
||||
}
|
||||
|
||||
func (s *Service) UpdateModelConfig(c *gin.Context) {
|
||||
s.ensureDependencies()
|
||||
var req ModelConfigUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid model config payload"))
|
||||
@@ -79,6 +141,33 @@ func (s *Service) UpdateModelConfig(c *gin.Context) {
|
||||
|
||||
currentStatus := s.runtime.Get()
|
||||
shouldRestart := currentStatus.Ready || currentStatus.Status == "ready"
|
||||
var releaseControl func()
|
||||
if shouldRestart {
|
||||
// Restart follows StartRuntime's lock order: stable PicoClaw control
|
||||
// lease first, then the runtime lifecycle lock around config write and
|
||||
// stop/start.
|
||||
var controlErr *PicoclawError
|
||||
releaseControl, controlErr = s.acquireControlMode()
|
||||
if controlErr != nil {
|
||||
writePicoclawErrorWithData(c, controlErr, gin.H{"status": s.runtimeStatus()})
|
||||
return
|
||||
}
|
||||
defer releaseControl()
|
||||
}
|
||||
|
||||
unlockLifecycle := s.lockRuntimeLifecycle()
|
||||
defer unlockLifecycle()
|
||||
currentStatus = s.runtime.Get()
|
||||
if !shouldRestart && (currentStatus.Ready || currentStatus.Status == "ready") {
|
||||
var controlErr *PicoclawError
|
||||
releaseControl, controlErr = s.acquireControlMode()
|
||||
if controlErr != nil {
|
||||
writePicoclawErrorWithData(c, controlErr, gin.H{"status": s.runtimeStatus()})
|
||||
return
|
||||
}
|
||||
defer releaseControl()
|
||||
shouldRestart = true
|
||||
}
|
||||
|
||||
modelName, err := updatePicoclawModelConfig(
|
||||
strings.TrimSpace(req.APIBase),
|
||||
@@ -103,14 +192,15 @@ func (s *Service) UpdateModelConfig(c *gin.Context) {
|
||||
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "model config saved, but failed to restart picoclaw runtime: "+startErr.Message))
|
||||
return
|
||||
}
|
||||
s.setRuntimeIntentDesired(true, "model_config")
|
||||
} else {
|
||||
_ = s.syncConfigFromPicoclaw()
|
||||
_ = s.ensureRuntimeReady()
|
||||
_ = s.ensureRuntimeReadyForLifecycle()
|
||||
}
|
||||
|
||||
writeSuccess(c, gin.H{
|
||||
"model_name": modelName,
|
||||
"status": s.runtime.Get(),
|
||||
"status": s.runtimeStatus(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -125,16 +215,18 @@ func updatePicoclawModelConfig(apiBase string, apiKey string, model string) (str
|
||||
return "", fmt.Errorf("model identifier is required")
|
||||
}
|
||||
|
||||
modelName := extractPicoclawModelName(model)
|
||||
if modelName == "" {
|
||||
return "", fmt.Errorf("model identifier is required")
|
||||
}
|
||||
|
||||
doc, err := loadPicoclawConfigDocument()
|
||||
modelName, err := validatePicoclawModelIdentifier(model)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
doc, err := loadOrInitializePicoclawConfigDocument()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
doc.raw["version"] = currentPicoclawConfigVersion
|
||||
|
||||
modelListValue, ok := doc.raw["model_list"].([]any)
|
||||
if !ok {
|
||||
modelListValue = []any{}
|
||||
|
||||
170
server/service/picoclaw/model_config_test.go
Normal file
170
server/service/picoclaw/model_config_test.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUpdatePicoclawModelConfigInitializesMissingConfig(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("PICOCLAW_HOME", home)
|
||||
|
||||
previousOnboard := runPicoclawOnboardForConfig
|
||||
onboardCalled := false
|
||||
runPicoclawOnboardForConfig = func() (string, *PicoclawError) {
|
||||
onboardCalled = true
|
||||
configPath := filepath.Join(home, "config.json")
|
||||
err := os.WriteFile(configPath, []byte(`{
|
||||
"agents": {
|
||||
"defaults": {}
|
||||
},
|
||||
"gateway": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 18790
|
||||
},
|
||||
"model_list": [],
|
||||
"channel_list": {}
|
||||
}`), 0o600)
|
||||
if err != nil {
|
||||
return "", newPicoclawError(CodeRuntimeUnavailable, err.Error())
|
||||
}
|
||||
return "initialized", nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
runPicoclawOnboardForConfig = previousOnboard
|
||||
})
|
||||
|
||||
modelName, err := updatePicoclawModelConfig(
|
||||
"https://api.example.invalid",
|
||||
"secret-key",
|
||||
"openai/test-model",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !onboardCalled {
|
||||
t.Fatal("missing config did not trigger PicoClaw onboard")
|
||||
}
|
||||
if modelName != "test-model" {
|
||||
t.Fatalf("model name = %q, want test-model", modelName)
|
||||
}
|
||||
|
||||
doc, err := loadPicoclawConfigDocument()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc.config.Agents.Defaults.ModelName != "test-model" {
|
||||
t.Fatalf("default model = %q, want test-model", doc.config.Agents.Defaults.ModelName)
|
||||
}
|
||||
if version, ok := doc.raw["version"].(float64); !ok || int(version) != currentPicoclawConfigVersion {
|
||||
t.Fatalf("config version = %v, want %d", doc.raw["version"], currentPicoclawConfigVersion)
|
||||
}
|
||||
if !isPicoclawModelConfigured(doc.config, doc.security, "test-model") {
|
||||
t.Fatalf("model was not configured: config=%+v security=%+v", doc.config.ModelList, doc.security.ModelList)
|
||||
}
|
||||
if len(doc.config.ModelList) != 1 || doc.config.ModelList[0].APIKey != "" || len(doc.config.ModelList[0].APIKeys) != 0 {
|
||||
t.Fatalf("model API key leaked into config.json: %+v", doc.config.ModelList)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePicoclawModelConfigReportsOnboardFailure(t *testing.T) {
|
||||
t.Setenv("PICOCLAW_HOME", t.TempDir())
|
||||
|
||||
previousOnboard := runPicoclawOnboardForConfig
|
||||
runPicoclawOnboardForConfig = func() (string, *PicoclawError) {
|
||||
return "", newPicoclawError(CodeRuntimeUnavailable, "onboard boom")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
runPicoclawOnboardForConfig = previousOnboard
|
||||
})
|
||||
|
||||
_, err := updatePicoclawModelConfig("https://api.example.invalid", "secret-key", "openai/test-model")
|
||||
if err == nil {
|
||||
t.Fatal("expected onboard failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to initialize PicoClaw config before saving model config") {
|
||||
t.Fatalf("error = %v, want initialization context", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePicoclawModelConfigMigratesVersionAndKeepsUnknownFields(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("PICOCLAW_HOME", home)
|
||||
|
||||
configPath := filepath.Join(home, "config.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{
|
||||
"version": 4,
|
||||
"unknown_top_level": {
|
||||
"keep": true
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "old-model"
|
||||
}
|
||||
},
|
||||
"gateway": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 18790
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "old-model",
|
||||
"model": "openai/old-model",
|
||||
"api_base": "https://api.example.invalid"
|
||||
}
|
||||
],
|
||||
"channel_list": {}
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
modelName, err := updatePicoclawModelConfig(
|
||||
"https://api.example.invalid",
|
||||
"secret-key",
|
||||
"openai/new-model",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if modelName != "new-model" {
|
||||
t.Fatalf("model name = %q, want new-model", modelName)
|
||||
}
|
||||
|
||||
doc, err := loadPicoclawConfigDocument()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if version, ok := doc.raw["version"].(float64); !ok || int(version) != currentPicoclawConfigVersion {
|
||||
t.Fatalf("config version = %v, want %d", doc.raw["version"], currentPicoclawConfigVersion)
|
||||
}
|
||||
unknown, ok := doc.raw["unknown_top_level"].(map[string]any)
|
||||
if !ok || unknown["keep"] != true {
|
||||
t.Fatalf("unknown fields were not preserved: %#v", doc.raw["unknown_top_level"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePicoclawModelConfigRejectsInvalidProvider(t *testing.T) {
|
||||
t.Setenv("PICOCLAW_HOME", t.TempDir())
|
||||
|
||||
_, err := updatePicoclawModelConfig("https://api.example.invalid", "secret-key", "openao/deepseek-v4-flash")
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid provider error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "did you mean openai") {
|
||||
t.Fatalf("error = %v, want provider hint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePicoclawModelConfigRequiresProviderModelFormat(t *testing.T) {
|
||||
t.Setenv("PICOCLAW_HOME", t.TempDir())
|
||||
|
||||
_, err := updatePicoclawModelConfig("https://api.example.invalid", "secret-key", "deepseek-v4-flash")
|
||||
if err == nil {
|
||||
t.Fatal("expected provider/model format error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "provider/model") {
|
||||
t.Fatalf("error = %v, want provider/model format hint", err)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
const (
|
||||
picoclawBinaryPath = "/usr/bin/picoclaw"
|
||||
picoclawCacheDir = "/root/.picoclaw-cache"
|
||||
picoclawPIDFileName = ".picoclaw.pid"
|
||||
picoclawDownloadURL = "https://cdn.sipeed.com/nanokvm/resources/picoclaw/v0.2.8/picoclaw_Linux_riscv64.tar.gz"
|
||||
picoclawChecksumURL = "https://cdn.sipeed.com/nanokvm/resources/picoclaw/v0.2.8/sha512.txt"
|
||||
etcInitPicoclawScript = "/etc/init.d/S96picoclaw"
|
||||
|
||||
@@ -11,10 +11,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPicoclawPingSec = 30
|
||||
defaultPicoclawReadSec = 60
|
||||
defaultPicoclawWriteSec = 10
|
||||
defaultPicoclawMaxConns = 100
|
||||
currentPicoclawConfigVersion = 3
|
||||
defaultPicoclawPingSec = 30
|
||||
defaultPicoclawReadSec = 60
|
||||
defaultPicoclawWriteSec = 10
|
||||
defaultPicoclawMaxConns = 100
|
||||
)
|
||||
|
||||
type picoclawConfigDefault struct {
|
||||
@@ -90,6 +91,7 @@ func ensurePicoclawPicoChannelEnabled(doc *picoclawConfigDocument) error {
|
||||
}
|
||||
|
||||
func applyPicoclawStartupDefaults(editor *picoclawConfigEditor) error {
|
||||
editor.setValue(currentPicoclawConfigVersion, "version")
|
||||
for _, entry := range picoclawNanoKVMDefaults {
|
||||
editor.setValue(entry.value, entry.path...)
|
||||
}
|
||||
|
||||
@@ -1,44 +1,241 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func (s *Service) StartRuntime(c *gin.Context) {
|
||||
command, output, err := s.startRuntime()
|
||||
if err != nil {
|
||||
writePicoclawError(c, err)
|
||||
defer s.recoverRuntimeHandler(c, "start")
|
||||
s.ensureDependencies()
|
||||
log.Info("picoclaw runtime start requested")
|
||||
|
||||
// Lock order: hold a stable PicoClaw control lease before entering the
|
||||
// runtime lifecycle section, so MCP/off transitions wait instead of racing
|
||||
// the start script and desired-running commit.
|
||||
releaseControl, controlErr := s.acquireControlMode()
|
||||
if controlErr != nil {
|
||||
status := s.runtimeStatus()
|
||||
log.WithFields(log.Fields{
|
||||
"code": controlErr.Code,
|
||||
"runtimeStatus": status.Status,
|
||||
"ready": status.Ready,
|
||||
"controlMode": status.ControlMode,
|
||||
}).Warn("picoclaw runtime start rejected by control mode")
|
||||
writePicoclawErrorWithData(c, controlErr, gin.H{"status": status})
|
||||
return
|
||||
}
|
||||
defer releaseControl()
|
||||
|
||||
operationCtx, releaseOperation := s.beginRuntimeLifecycleOperation(c.Request.Context())
|
||||
defer releaseOperation()
|
||||
|
||||
unlockLifecycle := s.lockRuntimeLifecycle()
|
||||
defer unlockLifecycle()
|
||||
|
||||
if lifecycleErr := runtimeLifecycleOperationError(operationCtx); lifecycleErr != nil {
|
||||
writePicoclawErrorWithData(c, lifecycleErr, gin.H{"status": s.runtimeStatus()})
|
||||
return
|
||||
}
|
||||
|
||||
if currentStatus := s.runtime.Get(); currentStatus.Installing {
|
||||
runtimeErr := newPicoclawError(CodeRuntimeUnavailable, "picoclaw installation is in progress")
|
||||
writePicoclawErrorWithData(c, runtimeErr, gin.H{"status": s.runtimeStatus()})
|
||||
return
|
||||
}
|
||||
|
||||
if readyErr := s.ensureRuntimeReadyForLifecycleContext(operationCtx); readyErr == nil {
|
||||
s.setRuntimeIntentDesired(true, "web")
|
||||
status := s.runtimeStatus()
|
||||
log.WithFields(log.Fields{
|
||||
"runtimeStatus": status.Status,
|
||||
"ready": status.Ready,
|
||||
"controlMode": status.ControlMode,
|
||||
}).Info("picoclaw runtime start skipped because runtime is already ready")
|
||||
writeSuccess(c, RuntimeStartResult{
|
||||
Started: true,
|
||||
Status: status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Restoring = false
|
||||
status.Status = "starting"
|
||||
status.LastError = ""
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
|
||||
command, output, startErr := s.startRuntimeContext(operationCtx)
|
||||
if startErr != nil {
|
||||
s.markRuntimeLifecycleCanceled(startErr)
|
||||
s.setRuntimeIntentError(startErr.Message)
|
||||
status := s.runtimeStatus()
|
||||
log.WithFields(log.Fields{
|
||||
"code": startErr.Code,
|
||||
"runtimeStatus": status.Status,
|
||||
"ready": status.Ready,
|
||||
"controlMode": status.ControlMode,
|
||||
}).Warn("picoclaw runtime start returning structured error")
|
||||
writePicoclawErrorWithData(c, startErr, gin.H{
|
||||
"command": command,
|
||||
"output": output,
|
||||
"status": status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.control.RequireWrite(controlmode.ModePicoclaw); err != nil {
|
||||
controlErr := s.controlWriteError(controlmode.ModePicoclaw, err)
|
||||
s.setRuntimeIntentError(controlErr.Message)
|
||||
status := s.runtimeStatus()
|
||||
log.WithFields(log.Fields{
|
||||
"code": controlErr.Code,
|
||||
"runtimeStatus": status.Status,
|
||||
"ready": status.Ready,
|
||||
"controlMode": status.ControlMode,
|
||||
}).Warn("picoclaw runtime start lost control before intent commit")
|
||||
writePicoclawErrorWithData(c, controlErr, gin.H{
|
||||
"command": command,
|
||||
"output": output,
|
||||
"status": status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s.setRuntimeIntentDesired(true, "web")
|
||||
status := s.runtimeStatus()
|
||||
log.WithFields(log.Fields{
|
||||
"runtimeStatus": status.Status,
|
||||
"ready": status.Ready,
|
||||
"controlMode": status.ControlMode,
|
||||
}).Info("picoclaw runtime start returning success")
|
||||
writeSuccess(c, RuntimeStartResult{
|
||||
Started: true,
|
||||
Command: command,
|
||||
Output: output,
|
||||
Status: s.runtime.Get(),
|
||||
Status: status,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) StopRuntime(c *gin.Context) {
|
||||
command, output, err := s.stopRuntime()
|
||||
defer s.recoverRuntimeHandler(c, "stop")
|
||||
s.ensureDependencies()
|
||||
log.Info("picoclaw runtime stop requested")
|
||||
|
||||
switched, err := s.control.SwitchIfWithCleanup(
|
||||
controlmode.ModePicoclaw,
|
||||
controlmode.ModeOff,
|
||||
func() error {
|
||||
s.CancelActiveControlOperations()
|
||||
return nil
|
||||
},
|
||||
s.releaseHID,
|
||||
)
|
||||
if err != nil {
|
||||
writePicoclawError(c, err)
|
||||
status := s.runtimeStatus()
|
||||
picoclawErr := newPicoclawError(CodeRuntimeStartFailed, err.Error())
|
||||
log.WithFields(log.Fields{
|
||||
"code": picoclawErr.Code,
|
||||
"runtimeStatus": status.Status,
|
||||
"ready": status.Ready,
|
||||
"controlMode": status.ControlMode,
|
||||
}).Warn("picoclaw runtime stop returning structured error")
|
||||
writePicoclawErrorWithData(c, picoclawErr, gin.H{"status": status})
|
||||
return
|
||||
}
|
||||
if switched {
|
||||
if status, statusErr := s.control.Status(); statusErr == nil {
|
||||
s.PublishControlModeChangedFrom(status, "runtime_stop")
|
||||
}
|
||||
}
|
||||
|
||||
unlockLifecycle := s.lockRuntimeLifecycle()
|
||||
defer unlockLifecycle()
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Restoring = false
|
||||
status.Status = "stopping"
|
||||
status.LastError = ""
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
s.setRuntimeIntentDesired(false, "web")
|
||||
|
||||
err = s.stopForRuntimeStop()
|
||||
if err != nil {
|
||||
s.setRuntimeIntentError(err.Error())
|
||||
status := s.runtimeStatus()
|
||||
picoclawErr := newPicoclawError(CodeRuntimeStartFailed, err.Error())
|
||||
log.WithFields(log.Fields{
|
||||
"code": picoclawErr.Code,
|
||||
"runtimeStatus": status.Status,
|
||||
"ready": status.Ready,
|
||||
"controlMode": status.ControlMode,
|
||||
}).Warn("picoclaw runtime stop returning structured error")
|
||||
writePicoclawErrorWithData(c, picoclawErr, gin.H{"status": status})
|
||||
return
|
||||
}
|
||||
|
||||
status := s.runtimeStatus()
|
||||
log.WithFields(log.Fields{
|
||||
"runtimeStatus": status.Status,
|
||||
"ready": status.Ready,
|
||||
"controlMode": status.ControlMode,
|
||||
}).Info("picoclaw runtime stop returning success")
|
||||
writeSuccess(c, RuntimeStartResult{
|
||||
Started: false,
|
||||
Command: command,
|
||||
Output: output,
|
||||
Status: s.runtime.Get(),
|
||||
Status: status,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) recoverRuntimeHandler(c *gin.Context, operation string) {
|
||||
if recovered := recover(); recovered != nil {
|
||||
message := fmt.Sprintf("picoclaw runtime %s panicked: %v", operation, recovered)
|
||||
log.Errorf("%s\n%s", message, debug.Stack())
|
||||
if c.Writer.Written() {
|
||||
return
|
||||
}
|
||||
writePicoclawErrorWithData(
|
||||
c,
|
||||
newPicoclawError(CodeRuntimeStartFailed, message),
|
||||
gin.H{"status": s.safeRuntimeStatusForRecovery()},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) safeRuntimeStatusForRecovery() (status RuntimeStatus) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
status = RuntimeStatus{
|
||||
Ready: false,
|
||||
Installed: false,
|
||||
InstallPath: picoclawBinaryPath,
|
||||
Status: "error",
|
||||
}
|
||||
}
|
||||
}()
|
||||
if s == nil {
|
||||
return RuntimeStatus{
|
||||
Ready: false,
|
||||
Installed: false,
|
||||
InstallPath: picoclawBinaryPath,
|
||||
Status: "error",
|
||||
}
|
||||
}
|
||||
return s.runtimeStatus()
|
||||
}
|
||||
|
||||
func (s *Service) InstallRuntime(c *gin.Context) {
|
||||
s.ensureDependencies()
|
||||
output, err := s.installRuntime()
|
||||
if err != nil {
|
||||
writePicoclawError(c, err)
|
||||
@@ -51,11 +248,12 @@ func (s *Service) InstallRuntime(c *gin.Context) {
|
||||
Binary: picoclawBinaryPath,
|
||||
Download: picoclawDownloadURL,
|
||||
Output: output,
|
||||
Status: currentStatus,
|
||||
Status: s.runtimeStatus(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) UninstallRuntime(c *gin.Context) {
|
||||
s.ensureDependencies()
|
||||
currentStatus := s.runtime.Get()
|
||||
uninstallOutput := "picoclaw uninstalled successfully"
|
||||
if currentStatus.Installing {
|
||||
@@ -63,17 +261,53 @@ func (s *Service) UninstallRuntime(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
switched, switchErr := s.control.SwitchIfWithCleanup(
|
||||
controlmode.ModePicoclaw,
|
||||
controlmode.ModeOff,
|
||||
func() error {
|
||||
s.CancelActiveControlOperations()
|
||||
return nil
|
||||
},
|
||||
s.releaseHID,
|
||||
)
|
||||
if switchErr != nil {
|
||||
status := s.runtimeStatus()
|
||||
writePicoclawErrorWithData(c, newPicoclawError(CodeRuntimeStartFailed, "control release failed before uninstall: "+switchErr.Error()), gin.H{"status": status})
|
||||
return
|
||||
}
|
||||
if switched {
|
||||
if status, statusErr := s.control.Status(); statusErr == nil {
|
||||
s.PublishControlModeChangedFrom(status, "runtime_uninstall")
|
||||
}
|
||||
}
|
||||
|
||||
unlockLifecycle := s.lockRuntimeLifecycle()
|
||||
defer unlockLifecycle()
|
||||
|
||||
currentStatus = s.runtime.Get()
|
||||
if currentStatus.Installing {
|
||||
writePicoclawError(c, newPicoclawError(CodeRuntimeStartFailed, "cannot uninstall while installation is in progress"))
|
||||
return
|
||||
}
|
||||
running, err := isRuntimeRunning()
|
||||
if err != nil {
|
||||
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to check picoclaw runtime status"))
|
||||
return
|
||||
}
|
||||
|
||||
if running || currentStatus.Ready || currentStatus.Status == "ready" {
|
||||
if _, _, stopErr := s.stopRuntime(); stopErr != nil {
|
||||
uninstallOutput = "picoclaw uninstalled successfully (stop failed before uninstall: " + stopErr.Message + ")"
|
||||
if running || currentStatus.Ready || currentStatus.Status == "ready" || isRuntimeLifecycleStatusPending(currentStatus) {
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Restoring = false
|
||||
status.Status = "stopping"
|
||||
status.LastError = ""
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
if stopErr := s.stopRuntimeAndCloseSessions(CloseCodeRuntimeStopped, "PicoClaw runtime stopped"); stopErr != nil {
|
||||
uninstallOutput = "picoclaw uninstalled successfully (stop failed before uninstall: " + stopErr.Error() + ")"
|
||||
}
|
||||
}
|
||||
s.setRuntimeIntentDesired(false, "web")
|
||||
|
||||
if configPath, err := resolvePicoclawConfigPath(); err == nil {
|
||||
_ = os.RemoveAll(filepath.Dir(configPath))
|
||||
@@ -105,6 +339,6 @@ func (s *Service) UninstallRuntime(c *gin.Context) {
|
||||
Binary: picoclawBinaryPath,
|
||||
Download: picoclawDownloadURL,
|
||||
Output: uninstallOutput,
|
||||
Status: s.runtime.Get(),
|
||||
Status: s.runtimeStatus(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ import (
|
||||
)
|
||||
|
||||
func (s *Service) installRuntime() (string, *PicoclawError) {
|
||||
if s == nil {
|
||||
return "", newPicoclawError(CodeRuntimeUnavailable, "picoclaw service is unavailable")
|
||||
}
|
||||
s.ensureDependencies()
|
||||
log.Debugf("picoclaw install: start, binary=%s, cache=%s", picoclawBinaryPath, picoclawCacheDir)
|
||||
|
||||
currentStatus := s.runtime.Get()
|
||||
@@ -62,6 +66,7 @@ func (s *Service) installRuntime() (string, *PicoclawError) {
|
||||
}
|
||||
|
||||
func (s *Service) runInstallRuntime(ctx context.Context, cancel context.CancelFunc) {
|
||||
s.ensureDependencies()
|
||||
defer cancel()
|
||||
|
||||
_ = os.RemoveAll(picoclawCacheDir)
|
||||
@@ -243,6 +248,7 @@ func copyWithProgress(ctx context.Context, dst io.Writer, src io.Reader, total i
|
||||
}
|
||||
|
||||
func (s *Service) setInstallProgress(stage string, progress int, lastError string) {
|
||||
s.ensureDependencies()
|
||||
if progress < 0 {
|
||||
progress = 0
|
||||
}
|
||||
@@ -263,6 +269,7 @@ func (s *Service) setInstallProgress(stage string, progress int, lastError strin
|
||||
}
|
||||
|
||||
func (s *Service) finishInstallFailure(status string, message string) {
|
||||
s.ensureDependencies()
|
||||
s.runtime.Set(RuntimeStatus{
|
||||
Ready: false,
|
||||
Installed: false,
|
||||
|
||||
436
server/service/picoclaw/runtime_intent.go
Normal file
436
server/service/picoclaw/runtime_intent.go
Normal file
@@ -0,0 +1,436 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const RuntimeIntentFile = "/etc/kvm/picoclaw-runtime.json"
|
||||
|
||||
func NewRuntimeIntentStore(path string) *RuntimeIntentStore {
|
||||
if path == "" {
|
||||
path = RuntimeIntentFile
|
||||
}
|
||||
return &RuntimeIntentStore{path: path}
|
||||
}
|
||||
|
||||
func (s *RuntimeIntentStore) Load() (RuntimeIntentStatus, error) {
|
||||
if s == nil {
|
||||
return RuntimeIntentStatus{}, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.loadLocked()
|
||||
}
|
||||
|
||||
func (s *RuntimeIntentStore) SetDesiredRunning(desired bool, updatedBy string) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
intent, err := s.loadLocked()
|
||||
if err != nil {
|
||||
intent = RuntimeIntentStatus{
|
||||
DesiredRunning: false,
|
||||
LastError: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
intent.DesiredRunning = desired
|
||||
intent.UpdatedAt = now
|
||||
intent.UpdatedBy = updatedBy
|
||||
intent.LastError = ""
|
||||
if desired {
|
||||
intent.LastStartedAt = now
|
||||
} else {
|
||||
intent.LastStoppedAt = now
|
||||
}
|
||||
|
||||
return s.saveLocked(intent)
|
||||
}
|
||||
|
||||
func (s *RuntimeIntentStore) SetLastError(message string) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
intent, err := s.loadLocked()
|
||||
if err != nil {
|
||||
intent = RuntimeIntentStatus{
|
||||
DesiredRunning: false,
|
||||
}
|
||||
}
|
||||
|
||||
intent.LastError = message
|
||||
intent.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
if intent.UpdatedBy == "" {
|
||||
intent.UpdatedBy = "system"
|
||||
}
|
||||
|
||||
return s.saveLocked(intent)
|
||||
}
|
||||
|
||||
func (s *RuntimeIntentStore) loadLocked() (RuntimeIntentStatus, error) {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return RuntimeIntentStatus{DesiredRunning: false}, nil
|
||||
}
|
||||
return RuntimeIntentStatus{DesiredRunning: false}, fmt.Errorf("read PicoClaw runtime intent: %w", err)
|
||||
}
|
||||
|
||||
var intent RuntimeIntentStatus
|
||||
if err := json.Unmarshal(data, &intent); err != nil {
|
||||
return RuntimeIntentStatus{
|
||||
DesiredRunning: false,
|
||||
LastError: err.Error(),
|
||||
}, fmt.Errorf("parse PicoClaw runtime intent: %w", err)
|
||||
}
|
||||
|
||||
return intent, nil
|
||||
}
|
||||
|
||||
func (s *RuntimeIntentStore) saveLocked(intent RuntimeIntentStatus) error {
|
||||
dir := filepath.Dir(s.path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create PicoClaw runtime intent directory: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(intent, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode PicoClaw runtime intent: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
|
||||
tmp, err := os.CreateTemp(dir, ".picoclaw-runtime.*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary PicoClaw runtime intent: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer func() { _ = os.Remove(tmpPath) }()
|
||||
|
||||
if err := tmp.Chmod(0o600); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("set PicoClaw runtime intent permissions: %w", err)
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("write PicoClaw runtime intent: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("sync PicoClaw runtime intent: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close PicoClaw runtime intent: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, s.path); err != nil {
|
||||
return fmt.Errorf("replace PicoClaw runtime intent: %w", err)
|
||||
}
|
||||
|
||||
directory, err := os.Open(dir)
|
||||
if err == nil {
|
||||
if syncErr := directory.Sync(); syncErr != nil {
|
||||
_ = directory.Close()
|
||||
return fmt.Errorf("sync PicoClaw runtime intent directory: %w", syncErr)
|
||||
}
|
||||
_ = directory.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) setRuntimeIntentDesired(desired bool, updatedBy string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.ensureDependencies()
|
||||
if err := s.runtimeIntent.SetDesiredRunning(desired, updatedBy); err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"desired_running": desired,
|
||||
"updated_by": updatedBy,
|
||||
}).WithError(err).Warn("failed to persist PicoClaw runtime intent")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) SetRuntimeIntentDesired(desired bool, updatedBy string) {
|
||||
s.setRuntimeIntentDesired(desired, updatedBy)
|
||||
}
|
||||
|
||||
func (s *Service) setRuntimeIntentError(message string) {
|
||||
if s == nil || message == "" {
|
||||
return
|
||||
}
|
||||
s.ensureDependencies()
|
||||
if err := s.runtimeIntent.SetLastError(message); err != nil {
|
||||
log.WithError(err).Warn("failed to persist PicoClaw runtime intent error")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) startRuntimeIntentReconcile() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.ensureDependencies()
|
||||
s.reconcileOnce.Do(func() {
|
||||
go s.reconcileRuntimeIntent("startup")
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) reconcileRuntimeIntent(source string) {
|
||||
startedAt := time.Now()
|
||||
s.ensureDependencies()
|
||||
|
||||
unlockLifecycle := s.lockRuntimeLifecycle()
|
||||
defer unlockLifecycle()
|
||||
|
||||
intent, intentErr := s.runtimeIntent.Load()
|
||||
if intentErr != nil {
|
||||
s.setRuntimeIntentError(intentErr.Error())
|
||||
log.WithError(intentErr).Warn("PicoClaw runtime restore skipped because intent is invalid")
|
||||
return
|
||||
}
|
||||
|
||||
modeStatus, modeErr := s.control.Status()
|
||||
if modeErr != nil {
|
||||
s.setRuntimeIntentError(modeErr.Error())
|
||||
log.WithError(modeErr).Warn("PicoClaw runtime restore skipped because control mode is unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
fields := log.Fields{
|
||||
"source": source,
|
||||
"desired_running": intent.DesiredRunning,
|
||||
"control_mode": string(modeStatus.Mode),
|
||||
}
|
||||
|
||||
if !intent.DesiredRunning {
|
||||
s.reconcileDisabledRuntimeIntent(source)
|
||||
log.WithFields(fields).Info("PicoClaw runtime restore skipped because desired_running is false")
|
||||
return
|
||||
}
|
||||
if modeStatus.Mode == controlmode.ModeMCP {
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Status = "blocked_by_mcp"
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
log.WithFields(fields).Info("PicoClaw runtime restore skipped because MCP owns device control")
|
||||
return
|
||||
}
|
||||
if modeStatus.Mode != controlmode.ModePicoclaw {
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Status = "stopped"
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
log.WithFields(fields).Info("PicoClaw runtime restore skipped because PicoClaw does not own device control")
|
||||
return
|
||||
}
|
||||
|
||||
releaseControl, controlErr := s.acquireControlMode()
|
||||
if controlErr != nil {
|
||||
s.setRuntimeIntentError(controlErr.Message)
|
||||
log.WithFields(fields).Warn("PicoClaw runtime restore skipped because PicoClaw control is not stable")
|
||||
return
|
||||
}
|
||||
defer releaseControl()
|
||||
|
||||
intent, intentErr = s.runtimeIntent.Load()
|
||||
if intentErr != nil {
|
||||
s.setRuntimeIntentError(intentErr.Error())
|
||||
log.WithError(intentErr).Warn("PicoClaw runtime restore skipped because intent changed to an invalid state")
|
||||
return
|
||||
}
|
||||
if !intent.DesiredRunning {
|
||||
fields["desired_running"] = intent.DesiredRunning
|
||||
log.WithFields(fields).Info("PicoClaw runtime restore skipped because desired_running changed while waiting")
|
||||
return
|
||||
}
|
||||
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Restoring = true
|
||||
status.Status = "restoring"
|
||||
status.LastError = ""
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
|
||||
if readyErr := s.ensureRuntimeReadyForLifecycle(); readyErr == nil {
|
||||
fields["elapsed_ms"] = time.Since(startedAt).Milliseconds()
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Restoring = false
|
||||
status.Status = "ready"
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
log.WithFields(fields).Info("PicoClaw runtime restore found an already-ready runtime")
|
||||
return
|
||||
}
|
||||
|
||||
currentStatus := s.runtime.Get()
|
||||
switch currentStatus.Status {
|
||||
case "not_installed", "model_not_configured", "config_error":
|
||||
message := currentStatus.LastError
|
||||
if message == "" {
|
||||
message = currentStatus.ConfigError
|
||||
}
|
||||
if message == "" {
|
||||
message = "PicoClaw runtime restore prerequisites are not satisfied"
|
||||
}
|
||||
s.setRuntimeIntentError(message)
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Restoring = false
|
||||
status.LastError = message
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
fields["elapsed_ms"] = time.Since(startedAt).Milliseconds()
|
||||
fields["runtime_status"] = currentStatus.Status
|
||||
log.WithFields(fields).Warn("PicoClaw runtime restore skipped because prerequisites are not satisfied")
|
||||
return
|
||||
}
|
||||
|
||||
command, output, startErr := s.startRuntime()
|
||||
fields["elapsed_ms"] = time.Since(startedAt).Milliseconds()
|
||||
fields["command"] = command
|
||||
if output != "" {
|
||||
fields["output"] = output
|
||||
}
|
||||
if startErr != nil {
|
||||
s.setRuntimeIntentError(startErr.Message)
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Restoring = false
|
||||
if status.Status == "restoring" {
|
||||
status.Status = "unavailable"
|
||||
}
|
||||
status.LastError = startErr.Message
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
log.WithFields(fields).WithError(errors.New(startErr.Message)).Warn("PicoClaw runtime restore failed")
|
||||
return
|
||||
}
|
||||
|
||||
intent, intentErr = s.runtimeIntent.Load()
|
||||
if intentErr != nil {
|
||||
s.setRuntimeIntentError(intentErr.Error())
|
||||
log.WithError(intentErr).Warn("PicoClaw runtime restore result discarded because intent became invalid")
|
||||
return
|
||||
}
|
||||
if !intent.DesiredRunning {
|
||||
log.WithFields(fields).Info("PicoClaw runtime restore result discarded because desired_running is now false")
|
||||
return
|
||||
}
|
||||
if err := s.control.RequireWrite(controlmode.ModePicoclaw); err != nil {
|
||||
controlErr := s.controlWriteError(controlmode.ModePicoclaw, err)
|
||||
s.setRuntimeIntentError(controlErr.Message)
|
||||
log.WithFields(fields).Warn("PicoClaw runtime restore result discarded because control changed")
|
||||
return
|
||||
}
|
||||
|
||||
s.setRuntimeIntentDesired(true, "restore")
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Restoring = false
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
log.WithFields(fields).Info("PicoClaw runtime restored from persisted intent")
|
||||
}
|
||||
|
||||
func (s *Service) reconcileDisabledRuntimeIntent(source string) {
|
||||
status := s.runtime.Get()
|
||||
running, err := isRuntimeRunning()
|
||||
fields := log.Fields{
|
||||
"source": source,
|
||||
"runtime_status": status.Status,
|
||||
"ready": status.Ready,
|
||||
}
|
||||
if err != nil {
|
||||
s.setRuntimeIntentError(err.Error())
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Restoring = false
|
||||
status.Status = "unavailable"
|
||||
status.LastError = err.Error()
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
log.WithFields(fields).WithError(err).Warn("PicoClaw disabled runtime intent could not check runtime")
|
||||
return
|
||||
}
|
||||
|
||||
if !running && !status.Ready && !isRuntimeLifecycleStatusPending(status) {
|
||||
s.applyDisabledRuntimeIntentStatus()
|
||||
log.WithFields(fields).Info("PicoClaw disabled runtime intent kept runtime stopped")
|
||||
return
|
||||
}
|
||||
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Restoring = false
|
||||
status.Status = "stopping"
|
||||
status.LastError = ""
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
if err := s.stopRuntimeAndCloseSessions(CloseCodeRuntimeStopped, "PicoClaw runtime disabled"); err != nil {
|
||||
s.setRuntimeIntentError(err.Error())
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Restoring = false
|
||||
status.Status = "unavailable"
|
||||
status.LastError = err.Error()
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
log.WithFields(fields).WithError(err).Warn("PicoClaw disabled runtime intent failed to stop runtime")
|
||||
return
|
||||
}
|
||||
|
||||
s.applyDisabledRuntimeIntentStatus()
|
||||
log.WithFields(fields).Info("PicoClaw disabled runtime intent stopped runtime")
|
||||
}
|
||||
|
||||
func (s *Service) applyDisabledRuntimeIntentStatus() {
|
||||
installed, installedKnown := picoclawInstalledState()
|
||||
settings, settingsErr := loadPicoclawGatewaySettings()
|
||||
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Restoring = false
|
||||
status.CurrentSession = ""
|
||||
status.CheckedAt = time.Now()
|
||||
if installedKnown {
|
||||
status.Installed = installed
|
||||
}
|
||||
if installedKnown && !installed {
|
||||
status.ModelConfigured = false
|
||||
status.ModelName = ""
|
||||
status.Status = "not_installed"
|
||||
status.LastError = ""
|
||||
return
|
||||
}
|
||||
if settingsErr == nil {
|
||||
status.ModelConfigured = settings.ModelConfigured
|
||||
status.ModelName = settings.ModelName
|
||||
if !settings.ModelConfigured {
|
||||
status.ModelName = settings.TargetModelName
|
||||
status.Status = "model_not_configured"
|
||||
return
|
||||
}
|
||||
}
|
||||
status.Status = "stopped"
|
||||
if status.LastError == "picoclaw runtime is stopped" {
|
||||
status.LastError = ""
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func (s *Service) ReleaseRuntimeSession(c *gin.Context) {
|
||||
s.ensureDependencies()
|
||||
sessionID := strings.TrimSpace(c.GetHeader(sessionIDHeader))
|
||||
if sessionID == "" {
|
||||
writePicoclawError(c, newPicoclawError(CodeSessionIDMissing, "missing X-PicoClaw-Session-ID"))
|
||||
@@ -16,9 +17,8 @@ func (s *Service) ReleaseRuntimeSession(c *gin.Context) {
|
||||
|
||||
if session, ok := GetSessionManager().Get(sessionID); ok {
|
||||
s.closeGatewaySession(session, websocket.CloseNormalClosure, "session released")
|
||||
} else {
|
||||
ReleaseSession(sessionID)
|
||||
}
|
||||
s.releaseCaptureLeasesForSession(sessionID)
|
||||
|
||||
status := s.runtime.Get()
|
||||
status.CurrentSession = s.lock.Owner()
|
||||
|
||||
33
server/service/picoclaw/runtime_session_test.go
Normal file
33
server/service/picoclaw/runtime_session_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestReleaseRuntimeSessionAllowsMCPMode(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
service := &Service{
|
||||
control: controlmode.NewManager(filepath.Join(t.TempDir(), "mode"), controlmode.ModeMCP),
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodDelete, "/api/picoclaw/runtime/session", nil)
|
||||
c.Request.Header.Set(sessionIDHeader, "stale-session")
|
||||
|
||||
service.ReleaseRuntimeSession(c)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
|
||||
}
|
||||
if strings.Contains(recorder.Body.String(), CodeControlModeConflict) {
|
||||
t.Fatalf("body = %s, did not expect control conflict", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,32 @@ package picoclaw
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Service) startRuntime() (string, string, *PicoclawError) {
|
||||
return s.startRuntimeContext(context.Background())
|
||||
}
|
||||
|
||||
func (s *Service) startRuntimeContext(ctx context.Context) (string, string, *PicoclawError) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if s == nil {
|
||||
return "", "", newPicoclawError(CodeRuntimeStartFailed, "picoclaw service is unavailable")
|
||||
}
|
||||
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
|
||||
return "", "", lifecycleErr
|
||||
}
|
||||
s.ensureDependencies()
|
||||
|
||||
if installed, statErr := isPicoclawInstalled(); statErr != nil {
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
@@ -57,14 +75,27 @@ func (s *Service) startRuntime() (string, string, *PicoclawError) {
|
||||
})
|
||||
return "", "", newPicoclawError(CodeRuntimeStartFailed, err.Error())
|
||||
}
|
||||
if err := s.detectGatewayPortConflict(); err != nil {
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Installed = true
|
||||
status.Status = "unavailable"
|
||||
status.LastError = err.Error()
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
return "", "", newPicoclawError(CodeRuntimeStartFailed, err.Error())
|
||||
}
|
||||
|
||||
command := scriptPath + " start"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), picoclawStartTimeout)
|
||||
startCtx, cancel := context.WithTimeout(ctx, picoclawStartTimeout)
|
||||
defer cancel()
|
||||
|
||||
output, execErr := exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput()
|
||||
output, execErr := exec.CommandContext(startCtx, "sh", "-c", command).CombinedOutput()
|
||||
trimmedOutput := strings.TrimSpace(string(output))
|
||||
if execErr != nil {
|
||||
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
|
||||
return command, trimmedOutput, lifecycleErr
|
||||
}
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Installed = true
|
||||
@@ -78,16 +109,61 @@ func (s *Service) startRuntime() (string, string, *PicoclawError) {
|
||||
return command, trimmedOutput, newPicoclawError(CodeRuntimeStartFailed, "failed to start picoclaw runtime")
|
||||
}
|
||||
|
||||
time.Sleep(picoclawStartWaitPeriod)
|
||||
if runtimeErr := s.waitForRuntimeReady(picoclawStartTimeout); runtimeErr != nil {
|
||||
if lifecycleErr := sleepRuntimeLifecycleContext(ctx, picoclawStartWaitPeriod); lifecycleErr != nil {
|
||||
return command, trimmedOutput, lifecycleErr
|
||||
}
|
||||
if runtimeErr := s.waitForRuntimeReadyContext(ctx, picoclawStartTimeout); runtimeErr != nil {
|
||||
startErr := newPicoclawError(CodeRuntimeStartFailed, runtimeErr.Message)
|
||||
failureStatus := "unavailable"
|
||||
if runtimeErr.Code == CodeControlModeConflict {
|
||||
startErr = runtimeErr
|
||||
failureStatus = "starting"
|
||||
} else {
|
||||
if cleanupErr := s.stopRuntimeAndVerify(true); cleanupErr != nil {
|
||||
failureStatus = "error"
|
||||
startErr.Message = fmt.Sprintf(
|
||||
"%s; failed to stop partially started runtime: %v",
|
||||
startErr.Message,
|
||||
cleanupErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Status = failureStatus
|
||||
status.LastError = startErr.Message
|
||||
status.CurrentSession = ""
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
return command, trimmedOutput, startErr
|
||||
}
|
||||
|
||||
return command, trimmedOutput, nil
|
||||
}
|
||||
|
||||
func (s *Service) markRuntimeLifecycleCanceled(err *PicoclawError) {
|
||||
if s == nil || err == nil || err.Code != CodeControlModeConflict {
|
||||
return
|
||||
}
|
||||
s.ensureDependencies()
|
||||
s.runtime.Update(func(status *RuntimeStatus) {
|
||||
status.Ready = false
|
||||
status.Restoring = false
|
||||
if isRuntimeLifecycleStatusPending(*status) {
|
||||
status.Status = "stopped"
|
||||
}
|
||||
status.LastError = err.Message
|
||||
status.CurrentSession = ""
|
||||
status.CheckedAt = time.Now()
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) stopRuntime() (string, string, *PicoclawError) {
|
||||
if s == nil {
|
||||
return "", "", newPicoclawError(CodeRuntimeStartFailed, "picoclaw service is unavailable")
|
||||
}
|
||||
s.ensureDependencies()
|
||||
|
||||
settings, _ := loadPicoclawGatewaySettings()
|
||||
scriptPath, err := resolvePicoclawStartScript()
|
||||
if err != nil {
|
||||
@@ -144,11 +220,23 @@ func (s *Service) stopRuntime() (string, string, *PicoclawError) {
|
||||
}
|
||||
|
||||
func (s *Service) waitForRuntimeReady(timeout time.Duration) *PicoclawError {
|
||||
return s.waitForRuntimeReadyContext(context.Background(), timeout)
|
||||
}
|
||||
|
||||
func (s *Service) waitForRuntimeReadyContext(ctx context.Context, timeout time.Duration) *PicoclawError {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
var lastErr *PicoclawError
|
||||
|
||||
for {
|
||||
if runtimeErr := s.ensureRuntimeReady(); runtimeErr == nil {
|
||||
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
|
||||
return lifecycleErr
|
||||
}
|
||||
|
||||
runtimeErr := s.ensureRuntimeReadyForLifecycleContext(ctx)
|
||||
if runtimeErr == nil {
|
||||
return nil
|
||||
} else {
|
||||
lastErr = runtimeErr
|
||||
@@ -158,7 +246,9 @@ func (s *Service) waitForRuntimeReady(timeout time.Duration) *PicoclawError {
|
||||
break
|
||||
}
|
||||
|
||||
time.Sleep(picoclawReadyPollPeriod)
|
||||
if lifecycleErr := sleepRuntimeLifecycleContext(ctx, picoclawReadyPollPeriod); lifecycleErr != nil {
|
||||
return lifecycleErr
|
||||
}
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
@@ -168,28 +258,41 @@ func (s *Service) waitForRuntimeReady(timeout time.Duration) *PicoclawError {
|
||||
}
|
||||
|
||||
func resolvePicoclawStartScript() (string, error) {
|
||||
if _, err := os.Stat(etcInitPicoclawScript); err == nil {
|
||||
return etcInitPicoclawScript, nil
|
||||
}
|
||||
if _, err := os.Stat(kvmappPicoclawScript); err == nil {
|
||||
return kvmappPicoclawScript, nil
|
||||
}
|
||||
if _, err := os.Stat(etcInitPicoclawScript); err == nil {
|
||||
return etcInitPicoclawScript, nil
|
||||
}
|
||||
return "", fmt.Errorf("picoclaw start script not found: %s or %s", etcInitPicoclawScript, kvmappPicoclawScript)
|
||||
}
|
||||
|
||||
func runPicoclawOnboard() (string, *PicoclawError) {
|
||||
return runPicoclawOnboardContext(context.Background())
|
||||
}
|
||||
|
||||
func runPicoclawOnboardContext(ctx context.Context) (string, *PicoclawError) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
scriptPath, err := resolvePicoclawStartScript()
|
||||
if err != nil {
|
||||
return "", newPicoclawError(CodeRuntimeUnavailable, err.Error())
|
||||
}
|
||||
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
|
||||
return "", lifecycleErr
|
||||
}
|
||||
|
||||
command := scriptPath + " onboard"
|
||||
ctx, cancel := context.WithTimeout(context.Background(), picoclawOnboardTimeout)
|
||||
onboardCtx, cancel := context.WithTimeout(ctx, picoclawOnboardTimeout)
|
||||
defer cancel()
|
||||
|
||||
output, execErr := exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput()
|
||||
output, execErr := exec.CommandContext(onboardCtx, "sh", "-c", command).CombinedOutput()
|
||||
trimmedOutput := strings.TrimSpace(string(output))
|
||||
if execErr != nil {
|
||||
if lifecycleErr := runtimeLifecycleOperationError(ctx); lifecycleErr != nil {
|
||||
return trimmedOutput, lifecycleErr
|
||||
}
|
||||
if trimmedOutput == "" {
|
||||
trimmedOutput = execErr.Error()
|
||||
}
|
||||
@@ -199,6 +302,23 @@ func runPicoclawOnboard() (string, *PicoclawError) {
|
||||
return trimmedOutput, nil
|
||||
}
|
||||
|
||||
func sleepRuntimeLifecycleContext(ctx context.Context, delay time.Duration) *PicoclawError {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if delay <= 0 {
|
||||
return runtimeLifecycleOperationError(ctx)
|
||||
}
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return runtimeLifecycleOperationError(ctx)
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func isPicoclawInstalled() (bool, error) {
|
||||
info, err := os.Stat(picoclawBinaryPath)
|
||||
if err == nil {
|
||||
@@ -211,18 +331,162 @@ func isPicoclawInstalled() (bool, error) {
|
||||
}
|
||||
|
||||
func isRuntimeRunning() (bool, error) {
|
||||
binName := filepath.Base(picoclawBinaryPath)
|
||||
if binName == "" || binName == "." || binName == string(filepath.Separator) {
|
||||
return false, fmt.Errorf("invalid picoclaw binary path: %s", picoclawBinaryPath)
|
||||
pid, err := runtimeGatewayPID()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return pid > 0, nil
|
||||
}
|
||||
|
||||
func (s *Service) detectGatewayPortConflict() error {
|
||||
running, err := isRuntimeRunning()
|
||||
if err != nil {
|
||||
return fmt.Errorf("check PicoClaw gateway process: %w", err)
|
||||
}
|
||||
if running {
|
||||
return nil
|
||||
}
|
||||
|
||||
command := exec.Command("pidof", binName)
|
||||
if err := command.Run(); err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
||||
settings, err := loadPicoclawGatewaySettings()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
parsed, err := url.Parse(settings.GatewayURL)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
hostPort, err := gatewayHostPort(parsed)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
timeout := 2 * time.Second
|
||||
if s != nil && s.config != nil {
|
||||
if configured := time.Duration(s.config.Get().ConnectTimeoutMs) * time.Millisecond; configured > 0 {
|
||||
timeout = configured
|
||||
}
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", hostPort, timeout)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
_ = conn.Close()
|
||||
|
||||
return fmt.Errorf("gateway port %s is already in use by another process", hostPort)
|
||||
}
|
||||
|
||||
func resolvePicoclawPIDPath() (string, error) {
|
||||
home, err := resolvePicoclawHome()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, picoclawPIDFileName), nil
|
||||
}
|
||||
|
||||
func runtimeGatewayPID() (int, error) {
|
||||
pidPath, err := resolvePicoclawPIDPath()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
pid, err := readRuntimePIDFile(pidPath)
|
||||
if err == nil {
|
||||
running, processErr := isPicoclawGatewayProcess(pid)
|
||||
if processErr != nil {
|
||||
return 0, processErr
|
||||
}
|
||||
if running {
|
||||
return pid, nil
|
||||
}
|
||||
_ = os.Remove(pidPath)
|
||||
} else if !os.IsNotExist(err) {
|
||||
_ = os.Remove(pidPath)
|
||||
}
|
||||
|
||||
pid, err = findPicoclawGatewayProcess()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if pid > 0 {
|
||||
_ = os.MkdirAll(filepath.Dir(pidPath), 0o755)
|
||||
_ = os.WriteFile(pidPath, []byte(strconv.Itoa(pid)+"\n"), 0o600)
|
||||
}
|
||||
return pid, nil
|
||||
}
|
||||
|
||||
func readRuntimePIDFile(path string) (int, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
pid, parseErr := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
if parseErr != nil || pid <= 0 {
|
||||
return 0, fmt.Errorf("invalid PicoClaw pid file: %s", path)
|
||||
}
|
||||
return pid, nil
|
||||
}
|
||||
|
||||
func isPicoclawGatewayProcess(pid int) (bool, error) {
|
||||
if pid <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
cmdlinePath := filepath.Join("/proc", strconv.Itoa(pid), "cmdline")
|
||||
data, err := os.ReadFile(cmdlinePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
return isPicoclawGatewayCmdline(data), nil
|
||||
}
|
||||
|
||||
func isPicoclawGatewayCmdline(data []byte) bool {
|
||||
parts := strings.Split(string(data), "\x00")
|
||||
args := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
args = append(args, part)
|
||||
}
|
||||
}
|
||||
if len(args) < 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
binName := filepath.Base(picoclawBinaryPath)
|
||||
if binName == "" || binName == "." || binName == string(filepath.Separator) {
|
||||
return false
|
||||
}
|
||||
|
||||
return filepath.Base(args[0]) == binName && args[1] == "gateway"
|
||||
}
|
||||
|
||||
func findPicoclawGatewayProcess() (int, error) {
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
pid, err := strconv.Atoi(entry.Name())
|
||||
if err != nil || pid <= 0 {
|
||||
continue
|
||||
}
|
||||
running, err := isPicoclawGatewayProcess(pid)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if running {
|
||||
return pid, nil
|
||||
}
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
171
server/service/picoclaw/runtime_status_test.go
Normal file
171
server/service/picoclaw/runtime_status_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package picoclaw
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"NanoKVM-Server/service/controlmode"
|
||||
)
|
||||
|
||||
func TestSyncRuntimeConfigMetadataFromPicoclawReadsPersistedModelWithoutMutation(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("PICOCLAW_HOME", home)
|
||||
|
||||
configData := []byte(`{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "test-model"
|
||||
}
|
||||
},
|
||||
"gateway": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 18790
|
||||
},
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "test-model",
|
||||
"model": "provider/test-model",
|
||||
"api_base": "https://example.invalid"
|
||||
}
|
||||
],
|
||||
"channel_list": {
|
||||
"pico": {
|
||||
"type": "pico",
|
||||
"enabled": false,
|
||||
"settings": {}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
configPath := filepath.Join(home, "config.json")
|
||||
if err := os.WriteFile(configPath, configData, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
securityData := []byte("model_list:\n test-model:\n api_keys:\n - test-key\n")
|
||||
if err := os.WriteFile(filepath.Join(home, ".security.yml"), securityData, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
service := &Service{
|
||||
runtime: &RuntimeStore{status: RuntimeStatus{
|
||||
Status: "model_not_configured",
|
||||
LastError: "stale model error",
|
||||
}},
|
||||
}
|
||||
if syncErr := service.syncRuntimeConfigMetadataFromPicoclaw(); syncErr != nil {
|
||||
t.Fatal(syncErr)
|
||||
}
|
||||
|
||||
status := service.runtime.Get()
|
||||
if !status.ModelConfigured || status.ModelName != "test-model" {
|
||||
t.Fatalf("runtime model metadata = configured:%v name:%q", status.ModelConfigured, status.ModelName)
|
||||
}
|
||||
if status.CheckedAt.IsZero() {
|
||||
t.Fatal("runtime metadata refresh did not update checked_at")
|
||||
}
|
||||
if status.Status != "checking" || status.LastError != "" {
|
||||
t.Fatalf("runtime kept stale model status: %+v", status)
|
||||
}
|
||||
|
||||
after, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(after, configData) {
|
||||
t.Fatalf("passive metadata refresh mutated config:\n%s", after)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
mode controlmode.Mode
|
||||
wantStatus string
|
||||
}{
|
||||
{mode: controlmode.ModeOff, wantStatus: "checking"},
|
||||
{mode: controlmode.ModeMCP, wantStatus: "checking"},
|
||||
} {
|
||||
rendered := applyControlModeStatus(status, controlmode.Status{Mode: test.mode})
|
||||
if !rendered.ModelConfigured || rendered.ModelName != "test-model" {
|
||||
t.Fatalf("mode %q lost model metadata: %+v", test.mode, rendered)
|
||||
}
|
||||
if rendered.Status != test.wantStatus {
|
||||
t.Fatalf("mode %q status = %q, want %q", test.mode, rendered.Status, test.wantStatus)
|
||||
}
|
||||
if rendered.Ready {
|
||||
t.Fatalf("mode %q unexpectedly changed ready to true", test.mode)
|
||||
}
|
||||
if rendered.Capabilities.DeviceWrite {
|
||||
t.Fatalf("mode %q allows PicoClaw device writes", test.mode)
|
||||
}
|
||||
}
|
||||
|
||||
ready := status
|
||||
ready.Ready = true
|
||||
ready.Installed = true
|
||||
ready.Status = "ready"
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
status controlmode.Status
|
||||
wantChat bool
|
||||
wantReadOnlyTools bool
|
||||
wantDeviceWrite bool
|
||||
}{
|
||||
{
|
||||
name: "picoclaw",
|
||||
status: controlmode.Status{Mode: controlmode.ModePicoclaw},
|
||||
wantChat: true,
|
||||
wantReadOnlyTools: true,
|
||||
wantDeviceWrite: true,
|
||||
},
|
||||
{name: "mcp", status: controlmode.Status{Mode: controlmode.ModeMCP}},
|
||||
{
|
||||
name: "off",
|
||||
status: controlmode.Status{Mode: controlmode.ModeOff},
|
||||
wantChat: true,
|
||||
wantReadOnlyTools: true,
|
||||
},
|
||||
{
|
||||
name: "transitioning",
|
||||
status: controlmode.Status{Mode: controlmode.ModePicoclaw, Transitioning: true},
|
||||
},
|
||||
} {
|
||||
rendered := applyControlModeStatus(ready, test.status)
|
||||
if !rendered.Ready || rendered.Status != "ready" {
|
||||
t.Fatalf("%s changed runtime readiness: %+v", test.name, rendered)
|
||||
}
|
||||
if rendered.Capabilities.Chat != test.wantChat ||
|
||||
rendered.Capabilities.ReadOnlyTools != test.wantReadOnlyTools ||
|
||||
rendered.Capabilities.DeviceWrite != test.wantDeviceWrite {
|
||||
t.Fatalf("%s capabilities = %+v, want chat=%v readOnlyTools=%v deviceWrite=%v",
|
||||
test.name,
|
||||
rendered.Capabilities,
|
||||
test.wantChat,
|
||||
test.wantReadOnlyTools,
|
||||
test.wantDeviceWrite,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncRuntimeConfigMetadataFromPicoclawTreatsMissingConfigAsUnconfigured(t *testing.T) {
|
||||
t.Setenv("PICOCLAW_HOME", t.TempDir())
|
||||
service := &Service{
|
||||
runtime: &RuntimeStore{status: RuntimeStatus{
|
||||
ModelConfigured: true,
|
||||
ModelName: "stale-model",
|
||||
Status: "config_error",
|
||||
ConfigError: "stale config error",
|
||||
LastError: "stale config error",
|
||||
}},
|
||||
}
|
||||
|
||||
if syncErr := service.syncRuntimeConfigMetadataFromPicoclaw(); syncErr != nil {
|
||||
t.Fatal(syncErr)
|
||||
}
|
||||
status := service.runtime.Get()
|
||||
if status.ModelConfigured || status.ModelName != "" {
|
||||
t.Fatalf("missing config kept stale model metadata: %+v", status)
|
||||
}
|
||||
if status.Status == "config_error" || status.ConfigError != "" || status.LastError != "" {
|
||||
t.Fatalf("missing config reported a parse error: %+v", status)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user