mirror of
https://github.com/harivansh-afk/deskctl.git
synced 2026-04-15 11:02:18 +00:00
Compare commits
37 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32c6d337f1 | |||
| 13119eecf7 | |||
|
|
2b7de5fcef | ||
| 2b3d422c7b | |||
| 19669fb4c1 | |||
|
|
2107449d9b | ||
|
|
85e1916635 | ||
| 9bfada8b4b | |||
| 3ca6c90eaf | |||
|
|
3a8d9f90c1 | ||
|
|
ff26c57035 | ||
|
|
580ea79c27 | ||
| a58912284b | |||
|
|
8d690a62b4 | ||
| 07a478b0ee | |||
|
|
e61c5bc33f | ||
|
|
a64b46b479 | ||
|
|
2b02513d6e | ||
|
|
844f2f2bc6 | ||
| 6c6f33040f | |||
| 848ef97e87 | |||
| bf603671f9 | |||
| 3bfec9eecc | |||
| c907e800af | |||
| 2a8b51b4f5 | |||
| eedb5de2d4 | |||
| 47047e9064 | |||
| deaffff45a | |||
| 1d72c7b852 | |||
| 86c36a3b50 | |||
|
|
eac3a61ceb | ||
| 88f9ff85a3 | |||
| 14c8956321 | |||
| c37589ccf4 | |||
| 3dbd9ce52d | |||
|
|
1092e503be | ||
|
|
714e34ba19 |
40 changed files with 3366 additions and 1005 deletions
300
.github/workflows/ci.yml
vendored
300
.github/workflows/ci.yml
vendored
|
|
@ -1,24 +1,39 @@
|
||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
# Runners: uvacompute (https://uvacompute.com)
|
|
||||||
# To enable, set the UVA_RUNNER repo variable to the correct runner label.
|
|
||||||
# runs-on: ${{ vars.UVA_RUNNER || 'ubuntu-latest' }}
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
bump:
|
||||||
|
description: Version bump type (only for workflow_dispatch)
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- patch
|
||||||
|
- minor
|
||||||
|
- major
|
||||||
|
default: patch
|
||||||
|
publish_npm:
|
||||||
|
description: Publish to npm
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
publish_crates:
|
||||||
|
description: Publish to crates.io
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
changes:
|
changes:
|
||||||
name: Changes
|
name: Changes
|
||||||
runs-on: ubuntu-latest
|
runs-on: [self-hosted, netty]
|
||||||
outputs:
|
outputs:
|
||||||
rust: ${{ steps.check.outputs.rust }}
|
rust: ${{ steps.check.outputs.rust }}
|
||||||
version: ${{ steps.version.outputs.version }}
|
version: ${{ steps.version.outputs.version }}
|
||||||
|
|
@ -37,7 +52,11 @@ jobs:
|
||||||
- 'tests/**'
|
- 'tests/**'
|
||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
|
- 'npm/**'
|
||||||
|
- 'flake.nix'
|
||||||
|
- 'flake.lock'
|
||||||
- 'docker/**'
|
- 'docker/**'
|
||||||
|
- '.github/workflows/**'
|
||||||
- 'Makefile'
|
- 'Makefile'
|
||||||
|
|
||||||
- name: Set outputs
|
- name: Set outputs
|
||||||
|
|
@ -53,34 +72,36 @@ jobs:
|
||||||
id: version
|
id: version
|
||||||
if: github.event_name != 'pull_request' && steps.check.outputs.rust == 'true'
|
if: github.event_name != 'pull_request' && steps.check.outputs.rust == 'true'
|
||||||
run: |
|
run: |
|
||||||
BASE=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
|
CURRENT=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE"
|
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
|
||||||
|
|
||||||
LATEST=$(git tag -l "v${MAJOR}.${MINOR}.*" | sort -V | tail -1)
|
BUMP="${{ inputs.bump || 'patch' }}"
|
||||||
|
case "$BUMP" in
|
||||||
if [ -z "$LATEST" ]; then
|
major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
|
||||||
NEW="$BASE"
|
minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
|
||||||
else
|
patch)
|
||||||
LATEST_VER="${LATEST#v}"
|
LATEST=$(git tag -l "v${MAJOR}.${MINOR}.*" | sort -V | tail -1)
|
||||||
IFS='.' read -r _ _ LATEST_PATCH <<< "$LATEST_VER"
|
if [ -z "$LATEST" ]; then
|
||||||
NEW_PATCH=$((LATEST_PATCH + 1))
|
NEW_PATCH=$PATCH
|
||||||
NEW="${MAJOR}.${MINOR}.${NEW_PATCH}"
|
else
|
||||||
fi
|
LATEST_VER="${LATEST#v}"
|
||||||
|
IFS='.' read -r _ _ LATEST_PATCH <<< "$LATEST_VER"
|
||||||
# Ensure the computed version does not already have a tag
|
NEW_PATCH=$((LATEST_PATCH + 1))
|
||||||
while git rev-parse "v${NEW}" >/dev/null 2>&1; do
|
fi
|
||||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$NEW"
|
PATCH=$NEW_PATCH
|
||||||
NEW="${MAJOR}.${MINOR}.$((PATCH + 1))"
|
;;
|
||||||
done
|
esac
|
||||||
|
|
||||||
|
NEW="${MAJOR}.${MINOR}.${PATCH}"
|
||||||
echo "version=${NEW}" >> "$GITHUB_OUTPUT"
|
echo "version=${NEW}" >> "$GITHUB_OUTPUT"
|
||||||
echo "tag=v${NEW}" >> "$GITHUB_OUTPUT"
|
echo "tag=v${NEW}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Computed version: ${NEW} (v${NEW})"
|
||||||
|
|
||||||
validate:
|
validate:
|
||||||
name: Validate
|
name: Validate
|
||||||
needs: changes
|
needs: changes
|
||||||
if: needs.changes.outputs.rust == 'true'
|
if: needs.changes.outputs.rust == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: [self-hosted, netty]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|
@ -104,9 +125,6 @@ jobs:
|
||||||
- name: Install site dependencies
|
- name: Install site dependencies
|
||||||
run: pnpm --dir site install --frozen-lockfile
|
run: pnpm --dir site install --frozen-lockfile
|
||||||
|
|
||||||
- name: Install system dependencies
|
|
||||||
run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev
|
|
||||||
|
|
||||||
- name: Format check
|
- name: Format check
|
||||||
run: make fmt-check
|
run: make fmt-check
|
||||||
|
|
||||||
|
|
@ -123,7 +141,7 @@ jobs:
|
||||||
name: Integration (Xvfb)
|
name: Integration (Xvfb)
|
||||||
needs: changes
|
needs: changes
|
||||||
if: needs.changes.outputs.rust == 'true'
|
if: needs.changes.outputs.rust == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: [self-hosted, netty]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|
@ -131,79 +149,35 @@ jobs:
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
- name: Install system dependencies
|
|
||||||
run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev xvfb
|
|
||||||
|
|
||||||
- name: Xvfb integration tests
|
- name: Xvfb integration tests
|
||||||
run: make test-integration
|
run: make test-integration
|
||||||
|
|
||||||
build:
|
distribution:
|
||||||
name: Build (${{ matrix.target }})
|
name: Distribution Validate
|
||||||
needs: [changes, validate, integration]
|
needs: changes
|
||||||
if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true'
|
if: needs.changes.outputs.rust == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: [self-hosted, netty]
|
||||||
strategy:
|
|
||||||
fail-fast: true
|
|
||||||
matrix:
|
|
||||||
target: [cargo, docker]
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
# --- Cargo steps ---
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
if: matrix.target == 'cargo'
|
|
||||||
with:
|
|
||||||
components: clippy
|
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
if: matrix.target == 'cargo'
|
|
||||||
|
|
||||||
- name: Install system dependencies
|
- uses: actions/setup-node@v4
|
||||||
if: matrix.target == 'cargo'
|
|
||||||
run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev
|
|
||||||
|
|
||||||
- name: Clippy
|
|
||||||
if: matrix.target == 'cargo'
|
|
||||||
run: cargo clippy -- -D warnings
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
if: matrix.target == 'cargo'
|
|
||||||
run: cargo build --release --locked
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
if: matrix.target == 'cargo'
|
|
||||||
with:
|
with:
|
||||||
name: deskctl-linux-x86_64
|
node-version: 22
|
||||||
path: target/release/deskctl
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
# --- Docker steps ---
|
- name: Distribution validation
|
||||||
- uses: docker/setup-buildx-action@v3
|
run: make dist-validate
|
||||||
if: matrix.target == 'docker'
|
|
||||||
|
|
||||||
- uses: docker/login-action@v3
|
# --- Release pipeline: update-manifests -> build -> release -> publish ---
|
||||||
if: matrix.target == 'docker'
|
# These stay on ubuntu-latest for artifact upload/download and registry publishing.
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- uses: docker/build-push-action@v6
|
|
||||||
if: matrix.target == 'docker'
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: docker/Dockerfile
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
ghcr.io/${{ github.repository }}:latest
|
|
||||||
ghcr.io/${{ github.repository }}:${{ needs.changes.outputs.tag }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
update-manifests:
|
update-manifests:
|
||||||
name: Update Manifests
|
name: Update Manifests
|
||||||
needs: [changes, build]
|
needs: [changes, validate, integration, distribution]
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
@ -212,7 +186,11 @@ jobs:
|
||||||
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
- name: Update version in Cargo.toml
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Update versions
|
||||||
run: |
|
run: |
|
||||||
CURRENT=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
|
CURRENT=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||||
NEW="${{ needs.changes.outputs.version }}"
|
NEW="${{ needs.changes.outputs.version }}"
|
||||||
|
|
@ -220,26 +198,70 @@ jobs:
|
||||||
sed -i "0,/^version = \"${CURRENT}\"/s//version = \"${NEW}\"/" Cargo.toml
|
sed -i "0,/^version = \"${CURRENT}\"/s//version = \"${NEW}\"/" Cargo.toml
|
||||||
cargo generate-lockfile
|
cargo generate-lockfile
|
||||||
fi
|
fi
|
||||||
|
node -e '
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const p = "npm/deskctl/package.json";
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(p, "utf8"));
|
||||||
|
pkg.version = process.argv[1];
|
||||||
|
fs.writeFileSync(p, JSON.stringify(pkg, null, 2) + "\n");
|
||||||
|
' "$NEW"
|
||||||
|
|
||||||
- name: Commit, tag, and push
|
- name: Commit, tag, and push
|
||||||
run: |
|
run: |
|
||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add Cargo.toml Cargo.lock npm/deskctl/package.json
|
||||||
if ! git diff --quiet; then
|
if ! git diff --cached --quiet; then
|
||||||
git add Cargo.toml Cargo.lock
|
|
||||||
git commit -m "release: ${{ needs.changes.outputs.tag }} [skip ci]"
|
git commit -m "release: ${{ needs.changes.outputs.tag }} [skip ci]"
|
||||||
fi
|
fi
|
||||||
|
git tag "${{ needs.changes.outputs.tag }}"
|
||||||
if ! git rev-parse "${{ needs.changes.outputs.tag }}" >/dev/null 2>&1; then
|
|
||||||
git tag "${{ needs.changes.outputs.tag }}"
|
|
||||||
fi
|
|
||||||
git push origin main --tags
|
git push origin main --tags
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build Release Asset
|
||||||
|
needs: [changes, update-manifests]
|
||||||
|
if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.changes.outputs.tag }}
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: clippy
|
||||||
|
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev
|
||||||
|
|
||||||
|
- name: Verify version
|
||||||
|
run: |
|
||||||
|
CARGO_VER=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||||
|
EXPECTED="${{ needs.changes.outputs.version }}"
|
||||||
|
if [ "$CARGO_VER" != "$EXPECTED" ]; then
|
||||||
|
echo "Version mismatch: Cargo.toml=$CARGO_VER expected=$EXPECTED"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Building version $CARGO_VER"
|
||||||
|
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy -- -D warnings
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: cargo build --release --locked
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: deskctl-linux-x86_64
|
||||||
|
path: target/release/deskctl
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
release:
|
release:
|
||||||
name: Release
|
name: Release
|
||||||
needs: [changes, build, update-manifests]
|
needs: [changes, build, update-manifests]
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
@ -256,9 +278,87 @@ jobs:
|
||||||
chmod +x artifacts/deskctl
|
chmod +x artifacts/deskctl
|
||||||
mv artifacts/deskctl artifacts/deskctl-linux-x86_64
|
mv artifacts/deskctl artifacts/deskctl-linux-x86_64
|
||||||
cd artifacts && sha256sum deskctl-linux-x86_64 > checksums.txt && cd ..
|
cd artifacts && sha256sum deskctl-linux-x86_64 > checksums.txt && cd ..
|
||||||
|
if gh release view "${{ needs.changes.outputs.tag }}" >/dev/null 2>&1; then
|
||||||
|
gh release upload "${{ needs.changes.outputs.tag }}" \
|
||||||
|
artifacts/deskctl-linux-x86_64 \
|
||||||
|
artifacts/checksums.txt \
|
||||||
|
--clobber
|
||||||
|
else
|
||||||
|
gh release create "${{ needs.changes.outputs.tag }}" \
|
||||||
|
--title "${{ needs.changes.outputs.tag }}" \
|
||||||
|
--generate-notes \
|
||||||
|
artifacts/deskctl-linux-x86_64 \
|
||||||
|
artifacts/checksums.txt
|
||||||
|
fi
|
||||||
|
|
||||||
gh release create "${{ needs.changes.outputs.tag }}" \
|
publish-npm:
|
||||||
--title "${{ needs.changes.outputs.tag }}" \
|
name: Publish npm
|
||||||
--generate-notes \
|
needs: [changes, update-manifests, release]
|
||||||
artifacts/deskctl-linux-x86_64 \
|
if: >-
|
||||||
artifacts/checksums.txt
|
github.event_name != 'pull_request'
|
||||||
|
&& needs.changes.outputs.rust == 'true'
|
||||||
|
&& (inputs.publish_npm == true || inputs.publish_npm == '')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.changes.outputs.tag }}
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
registry-url: https://registry.npmjs.org
|
||||||
|
|
||||||
|
- name: Check if already published
|
||||||
|
id: published
|
||||||
|
run: |
|
||||||
|
VERSION="${{ needs.changes.outputs.version }}"
|
||||||
|
if npm view "deskctl@${VERSION}" version >/dev/null 2>&1; then
|
||||||
|
echo "npm=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "npm=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Validate npm package
|
||||||
|
if: steps.published.outputs.npm != 'true'
|
||||||
|
run: node npm/deskctl/scripts/validate-package.js
|
||||||
|
|
||||||
|
- name: Publish npm
|
||||||
|
if: steps.published.outputs.npm != 'true'
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
run: npm publish ./npm/deskctl --access public
|
||||||
|
|
||||||
|
publish-crates:
|
||||||
|
name: Publish crates.io
|
||||||
|
needs: [changes, update-manifests, release]
|
||||||
|
if: >-
|
||||||
|
github.event_name != 'pull_request'
|
||||||
|
&& needs.changes.outputs.rust == 'true'
|
||||||
|
&& (inputs.publish_crates == true || inputs.publish_crates == '')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.changes.outputs.tag }}
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev
|
||||||
|
|
||||||
|
- name: Check if already published
|
||||||
|
id: published
|
||||||
|
run: |
|
||||||
|
VERSION="${{ needs.changes.outputs.version }}"
|
||||||
|
if curl -fsSL "https://crates.io/api/v1/crates/deskctl/${VERSION}" >/dev/null 2>&1; then
|
||||||
|
echo "crates=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "crates=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Publish crates.io
|
||||||
|
if: steps.published.outputs.crates != 'true'
|
||||||
|
env:
|
||||||
|
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||||
|
run: cargo publish --locked
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -5,3 +5,5 @@ secret/
|
||||||
.claude/
|
.claude/
|
||||||
.codex/
|
.codex/
|
||||||
openspec/
|
openspec/
|
||||||
|
npm/deskctl/vendor/
|
||||||
|
npm/deskctl/*.tgz
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ pnpm --dir site install
|
||||||
- `src/` holds production code and unit tests
|
- `src/` holds production code and unit tests
|
||||||
- `tests/` holds integration tests
|
- `tests/` holds integration tests
|
||||||
- `tests/support/` holds shared X11 and daemon helpers for integration coverage
|
- `tests/support/` holds shared X11 and daemon helpers for integration coverage
|
||||||
- `docs/runtime-output.md` is the stable-vs-best-effort runtime output contract for agent-facing CLI work
|
- `docs/runtime-contract.md` is the stable-vs-best-effort runtime output contract for agent-facing CLI work
|
||||||
|
|
||||||
Keep integration-only helpers out of `src/`.
|
Keep integration-only helpers out of `src/`.
|
||||||
|
|
||||||
|
|
@ -35,10 +35,15 @@ make lint
|
||||||
make test-unit
|
make test-unit
|
||||||
make test-integration
|
make test-integration
|
||||||
make site-format-check
|
make site-format-check
|
||||||
|
make cargo-publish-dry-run
|
||||||
|
make npm-package-check
|
||||||
|
make nix-flake-check
|
||||||
|
make dist-validate
|
||||||
make validate
|
make validate
|
||||||
```
|
```
|
||||||
|
|
||||||
`make validate` runs the full Phase 2 validation stack. It requires Linux, `xvfb-run`, and site dependencies to be installed.
|
`make validate` runs the full Phase 2 validation stack. It requires Linux, `xvfb-run`, and site dependencies to be installed.
|
||||||
|
`make dist-validate` runs the distribution validation stack. It requires `npm`, `nix`, and Linux for the full npm runtime smoke path.
|
||||||
|
|
||||||
## Pre-commit Hooks
|
## Pre-commit Hooks
|
||||||
|
|
||||||
|
|
@ -60,6 +65,19 @@ The hook config intentionally stays small:
|
||||||
- Site files reuse the existing `site/` Prettier setup
|
- Site files reuse the existing `site/` Prettier setup
|
||||||
- Slower checks stay in CI or `make validate`
|
- Slower checks stay in CI or `make validate`
|
||||||
|
|
||||||
|
## Distribution Work
|
||||||
|
|
||||||
|
Distribution support currently ships through:
|
||||||
|
|
||||||
|
- crate: `deskctl`
|
||||||
|
- npm package: `deskctl`
|
||||||
|
- repo flake: `flake.nix`
|
||||||
|
- command name on every channel: `deskctl`
|
||||||
|
|
||||||
|
For maintainer release and publish steps, see [docs/releasing.md](docs/releasing.md).
|
||||||
|
|
||||||
|
Source-build and packaging work should keep Docker as a local Linux build convenience, not as the canonical registry release path.
|
||||||
|
|
||||||
## Integration Tests
|
## Integration Tests
|
||||||
|
|
||||||
Integration coverage is Linux/X11-only in this phase. The supported local entrypoint is:
|
Integration coverage is Linux/X11-only in this phase. The supported local entrypoint is:
|
||||||
|
|
|
||||||
44
Cargo.lock
generated
44
Cargo.lock
generated
|
|
@ -1,6 +1,6 @@
|
||||||
# This file is automatically @generated by Cargo.
|
# This file is automatically @generated by Cargo.
|
||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 3
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ab_glyph"
|
name = "ab_glyph"
|
||||||
|
|
@ -241,9 +241,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.57"
|
version = "1.2.58"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
|
checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"jobserver",
|
"jobserver",
|
||||||
|
|
@ -400,7 +400,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deskctl"
|
name = "deskctl"
|
||||||
version = "0.1.5"
|
version = "0.1.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ab_glyph",
|
"ab_glyph",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
|
@ -911,9 +911,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.91"
|
version = "0.3.92"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
|
checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
|
|
@ -1039,9 +1039,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.1.1"
|
version = "1.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
|
|
@ -1699,9 +1699,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simd-adler32"
|
name = "simd-adler32"
|
||||||
version = "0.3.8"
|
version = "0.3.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simd_helpers"
|
name = "simd_helpers"
|
||||||
|
|
@ -1861,9 +1861,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.22.0"
|
version = "1.23.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
|
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
|
@ -1907,9 +1907,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.114"
|
version = "0.2.115"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
|
checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
|
@ -1920,9 +1920,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.114"
|
version = "0.2.115"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
|
checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"wasm-bindgen-macro-support",
|
"wasm-bindgen-macro-support",
|
||||||
|
|
@ -1930,9 +1930,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro-support"
|
name = "wasm-bindgen-macro-support"
|
||||||
version = "0.2.114"
|
version = "0.2.115"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
|
checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
|
|
@ -1943,9 +1943,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-shared"
|
name = "wasm-bindgen-shared"
|
||||||
version = "0.2.114"
|
version = "0.2.115"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
|
checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
@ -2297,9 +2297,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zune-jpeg"
|
name = "zune-jpeg"
|
||||||
version = "0.5.14"
|
version = "0.5.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b7a1c0af6e5d8d1363f4994b7a091ccf963d8b694f7da5b0b9cceb82da2c0a6"
|
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zune-core",
|
"zune-core",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
15
Cargo.toml
15
Cargo.toml
|
|
@ -1,10 +1,23 @@
|
||||||
[package]
|
[package]
|
||||||
name = "deskctl"
|
name = "deskctl"
|
||||||
version = "0.1.5"
|
version = "0.1.14"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "X11 desktop control CLI for agents"
|
description = "X11 desktop control CLI for agents"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/harivansh-afk/deskctl"
|
repository = "https://github.com/harivansh-afk/deskctl"
|
||||||
|
homepage = "https://github.com/harivansh-afk/deskctl"
|
||||||
|
readme = "README.md"
|
||||||
|
keywords = ["x11", "desktop", "automation", "cli", "agent"]
|
||||||
|
categories = ["command-line-utilities"]
|
||||||
|
rust-version = "1.75"
|
||||||
|
include = [
|
||||||
|
"/Cargo.toml",
|
||||||
|
"/Cargo.lock",
|
||||||
|
"/README.md",
|
||||||
|
"/LICENCE",
|
||||||
|
"/assets/**",
|
||||||
|
"/src/**",
|
||||||
|
]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap = { version = "4", features = ["derive", "env"] }
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
|
|
|
||||||
32
Makefile
32
Makefile
|
|
@ -1,4 +1,4 @@
|
||||||
.PHONY: fmt fmt-check lint test-unit test-integration site-format-check validate
|
.PHONY: fmt fmt-check lint test-unit test-integration site-format-check cargo-publish-dry-run npm-package-check nix-flake-check dist-validate validate
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
cargo fmt --all
|
cargo fmt --all
|
||||||
|
|
@ -30,4 +30,34 @@ site-format-check:
|
||||||
fi
|
fi
|
||||||
pnpm --dir site format:check
|
pnpm --dir site format:check
|
||||||
|
|
||||||
|
cargo-publish-dry-run:
|
||||||
|
cargo publish --dry-run --allow-dirty --locked
|
||||||
|
|
||||||
|
npm-package-check:
|
||||||
|
@if ! command -v npm >/dev/null 2>&1; then \
|
||||||
|
echo "npm is required for npm packaging validation."; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
node npm/deskctl/scripts/validate-package.js
|
||||||
|
rm -rf tmp/npm-pack tmp/npm-install
|
||||||
|
mkdir -p tmp/npm-pack tmp/npm-install/bin
|
||||||
|
npm pack ./npm/deskctl --pack-destination ./tmp/npm-pack >/dev/null
|
||||||
|
@if [ "$$(uname -s)" != "Linux" ]; then \
|
||||||
|
echo "Skipping npm package runtime smoke test on non-Linux host."; \
|
||||||
|
else \
|
||||||
|
cargo build && \
|
||||||
|
PACK_TGZ=$$(ls ./tmp/npm-pack/*.tgz | head -n 1) && \
|
||||||
|
DESKCTL_BINARY_PATH="$$(pwd)/target/debug/deskctl" npm install --prefix ./tmp/npm-install "$${PACK_TGZ}" && \
|
||||||
|
./tmp/npm-install/node_modules/.bin/deskctl --version; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
nix-flake-check:
|
||||||
|
@if ! command -v nix >/dev/null 2>&1; then \
|
||||||
|
echo "nix is required for flake validation."; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
nix flake check
|
||||||
|
|
||||||
|
dist-validate: test-unit cargo-publish-dry-run npm-package-check nix-flake-check
|
||||||
|
|
||||||
validate: fmt-check lint test-unit test-integration site-format-check
|
validate: fmt-check lint test-unit test-integration site-format-check
|
||||||
|
|
|
||||||
215
README.md
215
README.md
|
|
@ -1,213 +1,46 @@
|
||||||
# deskctl
|
# deskctl
|
||||||
|
[](https://www.npmjs.com/package/deskctl)
|
||||||
|
[](skills/deskctl)
|
||||||
|
|
||||||
|
Desktop control cli for AI agents on X11.
|
||||||
|
|
||||||
|
https://github.com/user-attachments/assets/e820787e-4d1a-463f-bdcf-a829588778bf
|
||||||
|
|
||||||
Desktop control CLI for AI agents on Linux X11.
|
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo install deskctl
|
npm install -g deskctl
|
||||||
```
|
```
|
||||||
|
|
||||||
Build a Linux binary with Docker:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker/docker-compose.yml run --rm build
|
deskctl doctor
|
||||||
|
deskctl snapshot --annotate
|
||||||
```
|
```
|
||||||
|
|
||||||
This writes `dist/deskctl-linux-x86_64`.
|
## Skill
|
||||||
|
|
||||||
Copy it to an SSH machine where `scp` is unavailable:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh -p 443 deskctl@ssh.agentcomputer.ai 'cat > ~/deskctl && chmod +x ~/deskctl' < dist/deskctl-linux-x86_64
|
npx skills add harivansh-afk/deskctl
|
||||||
```
|
```
|
||||||
|
|
||||||
Run it on an X11 session:
|
## Docs
|
||||||
|
|
||||||
|
- runtime contract: [docs/runtime-contract.md](docs/runtime-contract.md)
|
||||||
|
- releasing: [docs/releasing.md](docs/releasing.md)
|
||||||
|
- contributing: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||||
|
|
||||||
|
## Install paths
|
||||||
|
|
||||||
|
Nix:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DISPLAY=:1 XDG_SESSION_TYPE=x11 ~/deskctl --json snapshot --annotate
|
nix run github:harivansh-afk/deskctl -- --help
|
||||||
|
nix profile install github:harivansh-afk/deskctl
|
||||||
```
|
```
|
||||||
|
|
||||||
Local source build requirements:
|
Rust:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo build
|
cargo build
|
||||||
```
|
```
|
||||||
|
|
||||||
At the moment there are no extra native build dependencies beyond a Rust toolchain.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Diagnose the environment first
|
|
||||||
deskctl doctor
|
|
||||||
|
|
||||||
# See the desktop
|
|
||||||
deskctl snapshot
|
|
||||||
|
|
||||||
# Query focused runtime state
|
|
||||||
deskctl get active-window
|
|
||||||
deskctl get monitors
|
|
||||||
|
|
||||||
# Click a window
|
|
||||||
deskctl click @w1
|
|
||||||
|
|
||||||
# Type text
|
|
||||||
deskctl type "hello world"
|
|
||||||
|
|
||||||
# Wait for a window or focus transition
|
|
||||||
deskctl wait window --selector 'title=Firefox' --timeout 10
|
|
||||||
deskctl wait focus --selector 'class=firefox' --timeout 5
|
|
||||||
|
|
||||||
# Focus by explicit selector
|
|
||||||
deskctl focus 'title=Firefox'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Client-daemon architecture over Unix sockets (NDJSON wire protocol).
|
|
||||||
The daemon starts automatically on first command and keeps the X11 connection alive for fast repeated calls.
|
|
||||||
|
|
||||||
Source layout:
|
|
||||||
|
|
||||||
- `src/lib.rs` exposes the shared library target
|
|
||||||
- `src/main.rs` is the thin CLI wrapper
|
|
||||||
- `src/` contains production code and unit tests
|
|
||||||
- `tests/` contains Linux/X11 integration tests
|
|
||||||
- `tests/support/` contains shared integration helpers
|
|
||||||
|
|
||||||
## Runtime Requirements
|
|
||||||
|
|
||||||
- Linux with X11 session
|
|
||||||
- Rust 1.75+ (for build)
|
|
||||||
|
|
||||||
The binary itself only links the standard glibc runtime on Linux (`libc`, `libm`, `libgcc_s`).
|
|
||||||
|
|
||||||
For deskctl to be fully functional on a fresh VM you still need:
|
|
||||||
|
|
||||||
- an X11 server and an active `DISPLAY`
|
|
||||||
- `XDG_SESSION_TYPE=x11` or an equivalent X11 session environment
|
|
||||||
- a window manager or desktop environment that exposes standard EWMH properties such as `_NET_CLIENT_LIST_STACKING` and `_NET_ACTIVE_WINDOW`
|
|
||||||
- an X server with the extensions needed for input simulation and screen metadata, which is standard on normal desktop X11 setups
|
|
||||||
|
|
||||||
If setup fails, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
deskctl doctor
|
|
||||||
```
|
|
||||||
|
|
||||||
## Contract Notes
|
|
||||||
|
|
||||||
- `@wN` refs are short-lived handles assigned by `snapshot` and `list-windows`
|
|
||||||
- `--json` output includes a stable `window_id` for programmatic targeting within the current daemon session
|
|
||||||
- `list-windows` is a cheap read-only operation and does not capture or write a screenshot
|
|
||||||
- the stable runtime JSON/error contract is documented in [docs/runtime-output.md](docs/runtime-output.md)
|
|
||||||
|
|
||||||
## Read and Wait Surface
|
|
||||||
|
|
||||||
The grouped runtime reads are:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
deskctl get active-window
|
|
||||||
deskctl get monitors
|
|
||||||
deskctl get version
|
|
||||||
deskctl get systeminfo
|
|
||||||
```
|
|
||||||
|
|
||||||
The grouped runtime waits are:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
deskctl wait window --selector 'title=Firefox' --timeout 10
|
|
||||||
deskctl wait focus --selector 'id=win3' --timeout 5
|
|
||||||
```
|
|
||||||
|
|
||||||
Successful `get active-window`, `wait window`, and `wait focus` responses return a `window` payload with:
|
|
||||||
- `ref_id`
|
|
||||||
- `window_id`
|
|
||||||
- `title`
|
|
||||||
- `app_name`
|
|
||||||
- geometry (`x`, `y`, `width`, `height`)
|
|
||||||
- state flags (`focused`, `minimized`)
|
|
||||||
|
|
||||||
`get monitors` returns:
|
|
||||||
- `count`
|
|
||||||
- `monitors[]` with geometry and primary/automatic flags
|
|
||||||
|
|
||||||
`get version` returns:
|
|
||||||
- `version`
|
|
||||||
- `backend`
|
|
||||||
|
|
||||||
`get systeminfo` stays runtime-scoped and returns:
|
|
||||||
- `backend`
|
|
||||||
- `display`
|
|
||||||
- `session_type`
|
|
||||||
- `session`
|
|
||||||
- `socket_path`
|
|
||||||
- `screen`
|
|
||||||
- `monitor_count`
|
|
||||||
- `monitors`
|
|
||||||
|
|
||||||
Wait timeout and selector failures are structured in `--json` mode so agents can recover without string parsing.
|
|
||||||
|
|
||||||
## Output Policy
|
|
||||||
|
|
||||||
Text mode is compact and follow-up-oriented, but JSON is the parsing contract.
|
|
||||||
|
|
||||||
- use `--json` when an agent needs strict parsing
|
|
||||||
- rely on `window_id`, selector-related fields, grouped read payloads, and structured error `kind` values for stable automation
|
|
||||||
- treat monitor naming, incidental whitespace, and default screenshot file names as best-effort
|
|
||||||
|
|
||||||
See [docs/runtime-output.md](docs/runtime-output.md) for the exact stable-vs-best-effort breakdown.
|
|
||||||
|
|
||||||
## Selector Contract
|
|
||||||
|
|
||||||
Explicit selector modes:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ref=w1
|
|
||||||
id=win1
|
|
||||||
title=Firefox
|
|
||||||
class=firefox
|
|
||||||
focused
|
|
||||||
```
|
|
||||||
|
|
||||||
Legacy refs remain supported:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
@w1
|
|
||||||
w1
|
|
||||||
win1
|
|
||||||
```
|
|
||||||
|
|
||||||
Bare selectors such as `firefox` are still supported as fuzzy substring matches, but they now fail on ambiguity and return candidate windows instead of silently picking the first match.
|
|
||||||
|
|
||||||
## Support Boundary
|
|
||||||
|
|
||||||
`deskctl` supports Linux X11 in this phase. Wayland and Hyprland are explicitly out of scope for the current runtime contract.
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
Local validation uses the root `Makefile`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make fmt-check
|
|
||||||
make lint
|
|
||||||
make test-unit
|
|
||||||
make test-integration
|
|
||||||
make site-format-check
|
|
||||||
make validate
|
|
||||||
```
|
|
||||||
|
|
||||||
`make validate` is the full repo-quality check and requires Linux with `xvfb-run` plus `pnpm --dir site install`.
|
|
||||||
|
|
||||||
The repository standardizes on `pre-commit` for fast commit-time checks:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pre-commit install
|
|
||||||
pre-commit run --all-files
|
|
||||||
```
|
|
||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full contributor guide.
|
|
||||||
|
|
||||||
## Acknowledgements
|
|
||||||
|
|
||||||
- [@barrettruth](github.com/barrettruth) - i stole the website from [vimdoc](https://github.com/barrettruth/vimdoc-language-server)
|
|
||||||
|
|
|
||||||
969
demo/index.html
Normal file
969
demo/index.html
Normal file
|
|
@ -0,0 +1,969 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>deskctl - Desktop Control for AI Agents</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* cozybox light */
|
||||||
|
--page-bg: #f2f2f2;
|
||||||
|
--bg: #e7e7e7;
|
||||||
|
--surface: #dcdcdc;
|
||||||
|
--surface-2: #e1e1e1;
|
||||||
|
--border: #c3c7c9;
|
||||||
|
--text: #282828;
|
||||||
|
--text-dim: #504945;
|
||||||
|
--text-muted: #928374;
|
||||||
|
--selection: #c3c7c9;
|
||||||
|
--accent: #4261a5;
|
||||||
|
--green: #427b58;
|
||||||
|
--red: #c5524a;
|
||||||
|
--yellow: #d79921;
|
||||||
|
--orange: #af3a03;
|
||||||
|
--purple: #8f3f71;
|
||||||
|
--aqua: #427b58;
|
||||||
|
--cyan: #3c7678;
|
||||||
|
--gray: #928374;
|
||||||
|
--mono: 'Berkeley Mono', 'JetBrains Mono', 'Fira Code', 'SF Mono', Consolas, monospace;
|
||||||
|
--sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--sans);
|
||||||
|
background: var(--page-bg);
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p { font-size: 14px; color: var(--text-dim); }
|
||||||
|
|
||||||
|
.demo-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
width: 1140px;
|
||||||
|
max-width: 96vw;
|
||||||
|
height: 580px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Desktop ──────────────────────────────────────── */
|
||||||
|
.desktop-panel {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.08), 0 0 0 1px rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-titlebar {
|
||||||
|
height: 30px;
|
||||||
|
background: var(--surface);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 12px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||||
|
|
||||||
|
.viewport {
|
||||||
|
position: relative;
|
||||||
|
height: calc(100% - 30px);
|
||||||
|
background: var(--bg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallpaper {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 25% 35%, rgba(66,97,165,0.04) 0%, transparent 55%),
|
||||||
|
radial-gradient(ellipse at 75% 65%, rgba(66,123,88,0.03) 0%, transparent 55%),
|
||||||
|
var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Taskbar ──────────────────────────────────────── */
|
||||||
|
.taskbar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0; left: 0; right: 0;
|
||||||
|
height: 28px;
|
||||||
|
background: var(--surface);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 8px;
|
||||||
|
gap: 2px;
|
||||||
|
z-index: 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-item {
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 3px;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-4px);
|
||||||
|
transition: opacity 0.3s, transform 0.3s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tb-item.visible { opacity: 1; transform: translateX(0); }
|
||||||
|
.tb-item.active { background: rgba(0,0,0,0.06); color: var(--text); }
|
||||||
|
|
||||||
|
/* ── Windows ──────────────────────────────────────── */
|
||||||
|
.win {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.08), 0 0 0 1px rgba(0,0,0,0.04);
|
||||||
|
transition: box-shadow 0.2s, opacity 0.4s ease, transform 0.4s ease;
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.92) translateY(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.win.visible { opacity: 1; transform: scale(1) translateY(0); }
|
||||||
|
.win.focused { box-shadow: 0 4px 20px rgba(0,0,0,0.12), 0 0 0 1px rgba(66,97,165,0.15); z-index: 10; }
|
||||||
|
|
||||||
|
.wbar {
|
||||||
|
height: 26px;
|
||||||
|
background: var(--surface);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 8px;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wbar .dots { display: flex; gap: 3px; }
|
||||||
|
.wbar .dots span { width: 7px; height: 7px; border-radius: 50%; }
|
||||||
|
|
||||||
|
.wbody {
|
||||||
|
background: #f8f8f8;
|
||||||
|
height: calc(100% - 26px);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── File Manager ─────────────────────────────────── */
|
||||||
|
.file-list { padding: 8px; }
|
||||||
|
|
||||||
|
.file-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row.selected { background: var(--selection); color: var(--text); }
|
||||||
|
.file-row .ficon { font-size: 13px; width: 18px; text-align: center; }
|
||||||
|
.file-row .fmeta { margin-left: auto; font-size: 8px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
.file-preview {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0; left: 0; right: 0;
|
||||||
|
height: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: height 0.3s ease;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview.open { height: 58px; padding: 8px 10px; }
|
||||||
|
|
||||||
|
/* ── Stock Chart ──────────────────────────────────── */
|
||||||
|
.chart-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px 2px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-ticker { font-size: 14px; font-weight: 700; color: var(--text); }
|
||||||
|
.chart-price { font-size: 12px; color: var(--green); }
|
||||||
|
.chart-change { font-size: 9px; color: var(--green); }
|
||||||
|
.chart-period { font-size: 8px; color: var(--text-muted); margin-left: auto; }
|
||||||
|
|
||||||
|
.chart-area { padding: 4px 12px 8px; height: calc(100% - 60px); }
|
||||||
|
.chart-area svg { width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
.chart-vol {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-vol div {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 1px 1px 0 0;
|
||||||
|
min-height: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Google Docs ──────────────────────────────────── */
|
||||||
|
.gdoc-toolbar {
|
||||||
|
height: 24px;
|
||||||
|
background: #f1f3f4;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 8px;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gdoc-toolbar .tb { width: 16px; height: 12px; background: #dadce0; border-radius: 2px; }
|
||||||
|
.gdoc-toolbar .tb.wide { width: 28px; }
|
||||||
|
.gdoc-toolbar .sep { width: 1px; height: 14px; background: #dadce0; margin: 0 3px; }
|
||||||
|
|
||||||
|
.gdoc-page {
|
||||||
|
background: #ffffff;
|
||||||
|
margin: 10px auto;
|
||||||
|
width: 88%;
|
||||||
|
height: calc(100% - 44px);
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
|
||||||
|
padding: 20px 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gdoc-title { font-family: var(--sans); font-size: 16px; font-weight: 700; color: #202124; min-height: 22px; margin-bottom: 4px; }
|
||||||
|
.gdoc-subtitle { font-family: var(--sans); font-size: 9px; color: #5f6368; margin-bottom: 10px; min-height: 12px; }
|
||||||
|
.gdoc-body { font-family: var(--sans); font-size: 9px; line-height: 1.6; color: #3c4043; min-height: 14px; }
|
||||||
|
|
||||||
|
.gdoc-chart-img {
|
||||||
|
margin-top: 8px;
|
||||||
|
width: 100%;
|
||||||
|
height: 80px;
|
||||||
|
background: #fafafa;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
transition: opacity 0.3s, transform 0.3s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gdoc-chart-img.visible { opacity: 1; transform: scale(1); }
|
||||||
|
.gdoc-chart-img svg { width: 95%; height: 80%; }
|
||||||
|
|
||||||
|
@keyframes blink { 50% { opacity: 0; } }
|
||||||
|
|
||||||
|
/* ── Annotations ──────────────────────────────────── */
|
||||||
|
.annot {
|
||||||
|
position: absolute;
|
||||||
|
border: 2px solid;
|
||||||
|
border-radius: 4px;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot.visible { opacity: 1; }
|
||||||
|
|
||||||
|
.annot-label {
|
||||||
|
position: absolute;
|
||||||
|
top: -16px;
|
||||||
|
left: -2px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px 3px 0 0;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annot.c1 { border-color: var(--accent); }
|
||||||
|
.annot.c1 .annot-label { background: var(--accent); }
|
||||||
|
.annot.c2 { border-color: var(--green); }
|
||||||
|
.annot.c2 .annot-label { background: var(--green); }
|
||||||
|
.annot.c3 { border-color: var(--orange); }
|
||||||
|
.annot.c3 .annot-label { background: var(--orange); }
|
||||||
|
|
||||||
|
.flash {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: white;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 50;
|
||||||
|
transition: opacity 0.05s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash.fire { opacity: 0.3; }
|
||||||
|
|
||||||
|
/* ── Cursor ───────────────────────────────────────── */
|
||||||
|
.agent-cursor {
|
||||||
|
position: absolute;
|
||||||
|
width: 18px;
|
||||||
|
height: 22px;
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: none;
|
||||||
|
filter: drop-shadow(0 1px 2px rgba(0,0,0,0.15));
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-cursor svg { width: 100%; height: 100%; }
|
||||||
|
.agent-cursor.clicking { transform: scale(0.85); transition: transform 0.06s ease-out; }
|
||||||
|
|
||||||
|
.click-ripple {
|
||||||
|
position: absolute;
|
||||||
|
width: 24px; height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 99;
|
||||||
|
transform: translate(-50%, -50%) scale(0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.click-ripple.animate { animation: ripple 0.4s ease-out forwards; }
|
||||||
|
|
||||||
|
@keyframes ripple {
|
||||||
|
0% { opacity: 0.6; transform: translate(-50%, -50%) scale(0.3); }
|
||||||
|
100% { opacity: 0; transform: translate(-50%, -50%) scale(1.5); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Command Panel (light) ────────────────────────── */
|
||||||
|
.cmd-panel {
|
||||||
|
width: 340px;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.08), 0 0 0 1px rgba(0,0,0,0.04);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmd-titlebar {
|
||||||
|
height: 30px;
|
||||||
|
background: var(--surface);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 12px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmd-titlebar .label {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmd-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.7;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmd-body::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.cmd-line {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
transition: opacity 0.25s, transform 0.25s;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmd-line.visible { opacity: 1; transform: translateY(0); }
|
||||||
|
.cmd-line .ps { color: var(--green); user-select: none; }
|
||||||
|
.cmd-line .c { color: var(--text); }
|
||||||
|
.cmd-line .f { color: var(--orange); }
|
||||||
|
.cmd-line .s { color: var(--accent); }
|
||||||
|
.cmd-line .o { color: var(--text-dim); font-size: 10px; padding-left: 2px; }
|
||||||
|
.cmd-line .ok { color: var(--green); }
|
||||||
|
.cmd-line .jk { color: var(--purple); }
|
||||||
|
.cmd-line .jv { color: var(--accent); }
|
||||||
|
.cmd-line .link { color: var(--accent); text-decoration: underline; }
|
||||||
|
.cmd-line .agent-msg { color: var(--text); font-size: 10px; line-height: 1.5; padding-left: 2px; }
|
||||||
|
|
||||||
|
.cmd-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 8px 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmd-divider.visible { opacity: 1; }
|
||||||
|
|
||||||
|
.step-ind {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
transition: opacity 0.25s, transform 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-ind.visible { opacity: 1; transform: translateY(0); }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.observe { background: rgba(66,97,165,0.12); color: var(--accent); }
|
||||||
|
.badge.act { background: rgba(66,123,88,0.12); color: var(--green); }
|
||||||
|
.badge.wait { background: rgba(175,58,3,0.1); color: var(--orange); }
|
||||||
|
.badge.verify { background: rgba(143,63,113,0.1); color: var(--purple); }
|
||||||
|
.badge.done { background: rgba(66,123,88,0.15); color: var(--green); }
|
||||||
|
|
||||||
|
.step-lbl { font-size: 9px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
.caption {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caption p { font-size: 11px; color: var(--text-muted); font-family: var(--mono); }
|
||||||
|
|
||||||
|
.caption .replay-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caption .replay-btn:hover { background: var(--bg); color: var(--text); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<h1>deskctl</h1>
|
||||||
|
<p>desktop control CLI for AI agents</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-container">
|
||||||
|
<div class="desktop-panel">
|
||||||
|
<div class="desktop-titlebar">
|
||||||
|
<div class="dot" style="background:#c5524a"></div>
|
||||||
|
<div class="dot" style="background:#d79921"></div>
|
||||||
|
<div class="dot" style="background:#427b58"></div>
|
||||||
|
</div>
|
||||||
|
<div class="viewport" id="vp">
|
||||||
|
<div class="wallpaper"></div>
|
||||||
|
|
||||||
|
<!-- File Manager -->
|
||||||
|
<div class="win" id="w-files" style="left:16px; top:16px; width:200px; height:220px;">
|
||||||
|
<div class="wbar">
|
||||||
|
<div class="dots"><span style="background:#c5524a"></span><span style="background:#d79921"></span><span style="background:#427b58"></span></div>
|
||||||
|
<span>Files ~/reports</span>
|
||||||
|
</div>
|
||||||
|
<div class="wbody">
|
||||||
|
<div class="file-list">
|
||||||
|
<div class="file-row" id="f-notes">
|
||||||
|
<span class="ficon">📝</span>
|
||||||
|
<span>task_brief.txt</span>
|
||||||
|
<span class="fmeta">2.1 KB</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-row" id="f-csv">
|
||||||
|
<span class="ficon">📊</span>
|
||||||
|
<span>nvda_q1_data.csv</span>
|
||||||
|
<span class="fmeta">48 KB</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-row" id="f-prev">
|
||||||
|
<span class="ficon">📄</span>
|
||||||
|
<span>prev_report.pdf</span>
|
||||||
|
<span class="fmeta">1.2 MB</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-row">
|
||||||
|
<span class="ficon">📁</span>
|
||||||
|
<span>archive/</span>
|
||||||
|
<span class="fmeta">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-preview" id="file-preview">
|
||||||
|
<span style="color:#427b58">task:</span> Prepare NVDA Q1 earnings summary<br>
|
||||||
|
<span style="color:#427b58">source:</span> finance.yahoo.com, local csv<br>
|
||||||
|
<span style="color:#427b58">output:</span> Google Docs report with chart
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stock Chart -->
|
||||||
|
<div class="win" id="w-chart" style="left:140px; top:40px; width:380px; height:260px;">
|
||||||
|
<div class="wbar">
|
||||||
|
<div class="dots"><span style="background:#c5524a"></span><span style="background:#d79921"></span><span style="background:#427b58"></span></div>
|
||||||
|
<span>Chrome - Yahoo Finance</span>
|
||||||
|
</div>
|
||||||
|
<div class="wbody">
|
||||||
|
<div class="chart-header">
|
||||||
|
<span class="chart-ticker">NVDA</span>
|
||||||
|
<span class="chart-price">$924.68</span>
|
||||||
|
<span class="chart-change">+3.42%</span>
|
||||||
|
<span class="chart-period">1Y</span>
|
||||||
|
</div>
|
||||||
|
<div class="chart-area">
|
||||||
|
<svg viewBox="0 0 360 140" preserveAspectRatio="none">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="cg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#427b58" stop-opacity="0.2"/>
|
||||||
|
<stop offset="100%" stop-color="#427b58" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<line x1="0" y1="35" x2="360" y2="35" stroke="#dcdcdc" stroke-width="0.5"/>
|
||||||
|
<line x1="0" y1="70" x2="360" y2="70" stroke="#dcdcdc" stroke-width="0.5"/>
|
||||||
|
<line x1="0" y1="105" x2="360" y2="105" stroke="#dcdcdc" stroke-width="0.5"/>
|
||||||
|
<path d="M0,120 L20,115 40,118 60,110 80,105 100,95 120,100 140,85 160,75 180,80 200,65 220,55 240,60 260,45 280,35 300,40 320,28 340,22 360,18 L360,140 L0,140 Z" fill="url(#cg)"/>
|
||||||
|
<path d="M0,120 L20,115 40,118 60,110 80,105 100,95 120,100 140,85 160,75 180,80 200,65 220,55 240,60 260,45 280,35 300,40 320,28 340,22 360,18" fill="none" stroke="#427b58" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<text x="352" y="33" fill="#928374" font-size="7" font-family="monospace" text-anchor="end">$950</text>
|
||||||
|
<text x="352" y="68" fill="#928374" font-size="7" font-family="monospace" text-anchor="end">$800</text>
|
||||||
|
<text x="352" y="103" fill="#928374" font-size="7" font-family="monospace" text-anchor="end">$650</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="chart-vol" id="chart-vol"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Google Docs -->
|
||||||
|
<div class="win" id="w-docs" style="left:80px; top:60px; width:440px; height:340px;">
|
||||||
|
<div class="wbar">
|
||||||
|
<div class="dots"><span style="background:#c5524a"></span><span style="background:#d79921"></span><span style="background:#427b58"></span></div>
|
||||||
|
<span>Chrome - Google Docs</span>
|
||||||
|
</div>
|
||||||
|
<div class="wbody" style="background:#f1f3f4">
|
||||||
|
<div class="gdoc-toolbar">
|
||||||
|
<div class="tb"></div><div class="tb"></div><div class="tb wide"></div>
|
||||||
|
<div class="sep"></div>
|
||||||
|
<div class="tb"></div><div class="tb"></div><div class="tb"></div>
|
||||||
|
<div class="sep"></div>
|
||||||
|
<div class="tb wide"></div><div class="tb"></div>
|
||||||
|
</div>
|
||||||
|
<div class="gdoc-page">
|
||||||
|
<div class="gdoc-title" id="doc-title"></div>
|
||||||
|
<div class="gdoc-subtitle" id="doc-subtitle"></div>
|
||||||
|
<div class="gdoc-body" id="doc-body"></div>
|
||||||
|
<div class="gdoc-chart-img" id="doc-chart">
|
||||||
|
<svg viewBox="0 0 360 80" preserveAspectRatio="none">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="cg2" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#427b58" stop-opacity="0.15"/>
|
||||||
|
<stop offset="100%" stop-color="#427b58" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="360" height="80" fill="#fafafa"/>
|
||||||
|
<path d="M0,65 L20,62 40,64 60,58 80,55 100,48 120,52 140,42 160,36 180,39 200,30 220,24 240,27 260,19 280,14 300,17 320,10 340,7 360,5 L360,80 L0,80 Z" fill="url(#cg2)"/>
|
||||||
|
<path d="M0,65 L20,62 40,64 60,58 80,55 100,48 120,52 140,42 160,36 180,39 200,30 220,24 240,27 260,19 280,14 300,17 320,10 340,7 360,5" fill="none" stroke="#427b58" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<text x="8" y="12" fill="#928374" font-size="7" font-family="monospace">NVDA 1Y</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Annotations -->
|
||||||
|
<div class="annot c1" id="a1"><div class="annot-label">@w1</div></div>
|
||||||
|
<div class="annot c2" id="a2"><div class="annot-label">@w2</div></div>
|
||||||
|
<div class="annot c3" id="a3"><div class="annot-label">@w3</div></div>
|
||||||
|
|
||||||
|
<div class="flash" id="flash"></div>
|
||||||
|
|
||||||
|
<div class="agent-cursor" id="cur" style="left:380px; top:260px;">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none"><path d="M5.5 3.21V20.8c0 .45.54.67.85.35l4.86-4.86a.5.5 0 0 1 .35-.15h6.87a.5.5 0 0 0 .35-.85L6.35 2.86a.5.5 0 0 0-.85.35Z" fill="#282828" stroke="#fff" stroke-width="1"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="click-ripple" id="rip"></div>
|
||||||
|
|
||||||
|
<!-- Taskbar -->
|
||||||
|
<div class="taskbar">
|
||||||
|
<div class="tb-item" id="tb-files">Files</div>
|
||||||
|
<div class="tb-item" id="tb-chart">Yahoo Finance</div>
|
||||||
|
<div class="tb-item" id="tb-docs">Google Docs</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cmd-panel">
|
||||||
|
<div class="cmd-titlebar">
|
||||||
|
<div class="dot" style="background:#c5524a"></div>
|
||||||
|
<div class="dot" style="background:#d79921"></div>
|
||||||
|
<div class="dot" style="background:#427b58"></div>
|
||||||
|
<span class="label">agent computer</span>
|
||||||
|
</div>
|
||||||
|
<div class="cmd-body" id="cb"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="caption">
|
||||||
|
<p>AI agent controlling a live desktop via deskctl</p>
|
||||||
|
<button class="replay-btn" id="replay" style="display:none" onclick="run()">↺ Replay</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = s => document.getElementById(s);
|
||||||
|
const W = ms => new Promise(r => setTimeout(r, ms));
|
||||||
|
const cur = $('cur'), rip = $('rip'), cb = $('cb');
|
||||||
|
let cx = 380, cy = 260;
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const v = $('chart-vol');
|
||||||
|
[8,12,6,14,10,18,8,15,20,12,7,16,10,22,14,8,18,12,9,16].forEach(h => {
|
||||||
|
const d = document.createElement('div'); d.style.height = h + 'px'; v.appendChild(d);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
function move(x, y, dur = 500) {
|
||||||
|
return new Promise(res => {
|
||||||
|
const sx = cx, sy = cy, dx = x - sx, dy = y - sy, t0 = performance.now();
|
||||||
|
(function f(n) {
|
||||||
|
const t = Math.min((n - t0) / dur, 1), e = 1 - (1 - t) ** 3;
|
||||||
|
const arc = -Math.sin(t * Math.PI) * Math.min(Math.abs(dy) * 0.25, 25);
|
||||||
|
cur.style.left = (sx + dx * e) + 'px';
|
||||||
|
cur.style.top = (sy + dy * e + arc) + 'px';
|
||||||
|
if (t < 1) requestAnimationFrame(f); else { cx = x; cy = y; res(); }
|
||||||
|
})(performance.now());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clk() {
|
||||||
|
cur.classList.add('clicking');
|
||||||
|
rip.style.left = (cx + 4) + 'px'; rip.style.top = (cy + 4) + 'px';
|
||||||
|
rip.classList.remove('animate'); void rip.offsetWidth; rip.classList.add('animate');
|
||||||
|
await W(80); cur.classList.remove('clicking');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flash() {
|
||||||
|
const f = $('flash'); f.classList.add('fire'); await W(80); f.classList.remove('fire');
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(id) { $(id).classList.add('visible'); }
|
||||||
|
function hide(id) { $(id).classList.remove('visible'); }
|
||||||
|
|
||||||
|
function tbShow(id) { const el = $(id); el.classList.add('visible'); }
|
||||||
|
function tbActive(id) {
|
||||||
|
document.querySelectorAll('.tb-item').forEach(t => t.classList.remove('active'));
|
||||||
|
if (id) $(id).classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function focus(id) {
|
||||||
|
document.querySelectorAll('.win').forEach(w => { w.classList.remove('focused'); w.style.zIndex = ''; });
|
||||||
|
if (id) { $(id).classList.add('focused'); $(id).style.zIndex = '10'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function posAnnot(aid, wid) {
|
||||||
|
const w = $(wid), a = $(aid);
|
||||||
|
a.style.left = (parseInt(w.style.left) - 2) + 'px';
|
||||||
|
a.style.top = (parseInt(w.style.top) + 26) + 'px';
|
||||||
|
a.style.width = (parseInt(w.style.width) + 4) + 'px';
|
||||||
|
a.style.height = (parseInt(w.style.height) - 22) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAnnots() { document.querySelectorAll('.annot').forEach(a => a.classList.remove('visible')); }
|
||||||
|
|
||||||
|
function typeEl(el, text, ms = 40) {
|
||||||
|
return new Promise(async res => {
|
||||||
|
for (const c of text) { el.textContent += c; await W(ms); }
|
||||||
|
res();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function step(type, label) {
|
||||||
|
const d = document.createElement('div'); d.className = 'step-ind';
|
||||||
|
d.innerHTML = `<span class="badge ${type}">${type}</span><span class="step-lbl">${label}</span>`;
|
||||||
|
cb.appendChild(d); void d.offsetWidth; d.classList.add('visible'); cb.scrollTop = cb.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ln(html) {
|
||||||
|
const d = document.createElement('div'); d.className = 'cmd-line';
|
||||||
|
d.innerHTML = `<span class="ps">$ </span>${html}`;
|
||||||
|
cb.appendChild(d); void d.offsetWidth; d.classList.add('visible'); cb.scrollTop = cb.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function out(html) {
|
||||||
|
const d = document.createElement('div'); d.className = 'cmd-line';
|
||||||
|
d.innerHTML = `<span class="o">${html}</span>`;
|
||||||
|
cb.appendChild(d); void d.offsetWidth; d.classList.add('visible'); cb.scrollTop = cb.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentMsg(html) {
|
||||||
|
const d = document.createElement('div'); d.className = 'cmd-line';
|
||||||
|
d.innerHTML = `<span class="agent-msg">${html}</span>`;
|
||||||
|
cb.appendChild(d); void d.offsetWidth; d.classList.add('visible'); cb.scrollTop = cb.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function div() {
|
||||||
|
const d = document.createElement('div'); d.className = 'cmd-divider';
|
||||||
|
cb.appendChild(d); void d.offsetWidth; d.classList.add('visible'); cb.scrollTop = cb.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cm(c, f, s) {
|
||||||
|
let h = `<span class="c">${c}</span>`;
|
||||||
|
if (f) h += ` <span class="f">${f}</span>`;
|
||||||
|
if (s) h += ` <span class="s">${s}</span>`;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
$('replay').style.display = 'none';
|
||||||
|
cb.innerHTML = '';
|
||||||
|
['w-files','w-chart','w-docs'].forEach(id => { hide(id); $(id).classList.remove('focused'); $(id).style.zIndex = ''; });
|
||||||
|
document.querySelectorAll('.tb-item').forEach(t => { t.classList.remove('visible','active'); });
|
||||||
|
hideAnnots();
|
||||||
|
$('f-notes').classList.remove('selected');
|
||||||
|
$('f-csv').classList.remove('selected');
|
||||||
|
$('file-preview').classList.remove('open');
|
||||||
|
$('doc-title').textContent = '';
|
||||||
|
$('doc-subtitle').textContent = '';
|
||||||
|
$('doc-body').textContent = '';
|
||||||
|
$('doc-chart').classList.remove('visible');
|
||||||
|
cur.style.left = '380px'; cur.style.top = '260px'; cur.style.opacity = '0';
|
||||||
|
cx = 380; cy = 260;
|
||||||
|
|
||||||
|
await W(500);
|
||||||
|
cur.style.transition = 'opacity 0.3s'; cur.style.opacity = '1';
|
||||||
|
await W(400); cur.style.transition = 'none';
|
||||||
|
|
||||||
|
// 1: Empty desktop
|
||||||
|
step('observe', 'Scan desktop');
|
||||||
|
await W(250);
|
||||||
|
ln(cm('deskctl snapshot'));
|
||||||
|
await W(400);
|
||||||
|
out('<span class="jk">"windows"</span>: <span class="o">[]</span>');
|
||||||
|
out('<span class="ok">empty desktop</span>');
|
||||||
|
await W(400); div();
|
||||||
|
|
||||||
|
// 2: Launch file manager
|
||||||
|
step('act', 'Open local files');
|
||||||
|
await W(250);
|
||||||
|
ln(cm('deskctl launch', '', 'nautilus ~/reports'));
|
||||||
|
await W(350);
|
||||||
|
show('w-files'); focus('w-files');
|
||||||
|
tbShow('tb-files'); tbActive('tb-files');
|
||||||
|
await W(300);
|
||||||
|
out('<span class="ok">launched nautilus (pid 3841)</span>');
|
||||||
|
await W(300);
|
||||||
|
|
||||||
|
step('wait', 'Wait for window');
|
||||||
|
ln(cm('deskctl wait window', "--selector 'title=Files'", '--timeout 5'));
|
||||||
|
await W(500);
|
||||||
|
out('<span class="ok">window ready: "Files ~/reports"</span>');
|
||||||
|
await W(300); div();
|
||||||
|
|
||||||
|
// 3: Read task brief
|
||||||
|
step('observe', 'Read task brief');
|
||||||
|
await W(250);
|
||||||
|
ln(cm('deskctl click', '', "'title=Files'"));
|
||||||
|
await move(100, 62, 450);
|
||||||
|
await clk();
|
||||||
|
$('f-notes').classList.add('selected');
|
||||||
|
await W(200);
|
||||||
|
out('<span class="ok">clicked "task_brief.txt"</span>');
|
||||||
|
await W(200);
|
||||||
|
|
||||||
|
ln(cm('deskctl hotkey', '', 'space'));
|
||||||
|
await W(300);
|
||||||
|
$('file-preview').classList.add('open');
|
||||||
|
await W(400);
|
||||||
|
out('<span class="o">task: Prepare NVDA Q1 earnings summary</span>');
|
||||||
|
out('<span class="o">source: finance.yahoo.com, local csv</span>');
|
||||||
|
out('<span class="o">output: Google Docs report with chart</span>');
|
||||||
|
await W(500); div();
|
||||||
|
|
||||||
|
// 4: Launch browser
|
||||||
|
step('act', 'Research stock data');
|
||||||
|
await W(250);
|
||||||
|
ln(cm('deskctl launch', '', 'google-chrome finance.yahoo.com/NVDA'));
|
||||||
|
await W(400);
|
||||||
|
show('w-chart'); focus('w-chart');
|
||||||
|
tbShow('tb-chart'); tbActive('tb-chart');
|
||||||
|
await W(350);
|
||||||
|
out('<span class="ok">launched chrome (pid 3912)</span>');
|
||||||
|
|
||||||
|
step('wait', 'Wait for page');
|
||||||
|
ln(cm('deskctl wait window', "--selector 'title=Yahoo'", '--timeout 8'));
|
||||||
|
await W(600);
|
||||||
|
out('<span class="ok">window ready: "Yahoo Finance - NVDA"</span>');
|
||||||
|
await W(300); div();
|
||||||
|
|
||||||
|
// 5: Snapshot chart
|
||||||
|
step('observe', 'Capture chart screenshot');
|
||||||
|
await W(250);
|
||||||
|
ln(cm('deskctl snapshot', '--annotate'));
|
||||||
|
await W(300);
|
||||||
|
await flash();
|
||||||
|
posAnnot('a1', 'w-files'); posAnnot('a2', 'w-chart');
|
||||||
|
show('a1'); show('a2');
|
||||||
|
await W(200);
|
||||||
|
out('<span class="jk">"windows"</span>: [');
|
||||||
|
out(' { <span class="jv">"@w1"</span>: <span class="jv">"Files"</span> }');
|
||||||
|
out(' { <span class="jv">"@w2"</span>: <span class="jv">"Yahoo Finance"</span> }');
|
||||||
|
out(']');
|
||||||
|
out('<span class="ok">screenshot saved: chart_nvda.png</span>');
|
||||||
|
await W(600);
|
||||||
|
hideAnnots(); div();
|
||||||
|
|
||||||
|
// 6: Open Google Docs
|
||||||
|
step('act', 'Create report document');
|
||||||
|
await W(250);
|
||||||
|
ln(cm('deskctl hotkey', '', 'ctrl t'));
|
||||||
|
await W(300);
|
||||||
|
out('<span class="ok">new tab opened</span>');
|
||||||
|
await W(200);
|
||||||
|
|
||||||
|
ln(cm('deskctl type', '', '"docs.google.com/document/new"'));
|
||||||
|
await W(200);
|
||||||
|
ln(cm('deskctl press', '', 'enter'));
|
||||||
|
await W(400);
|
||||||
|
show('w-docs'); focus('w-docs');
|
||||||
|
tbShow('tb-docs'); tbActive('tb-docs');
|
||||||
|
await W(350);
|
||||||
|
out('<span class="ok">navigated to Google Docs</span>');
|
||||||
|
|
||||||
|
step('wait', 'Wait for Docs');
|
||||||
|
ln(cm('deskctl wait window', "--selector 'title=Google Docs'", '--timeout 8'));
|
||||||
|
await W(500);
|
||||||
|
out('<span class="ok">document ready</span>');
|
||||||
|
await W(300); div();
|
||||||
|
|
||||||
|
// 7: Type title
|
||||||
|
step('act', 'Write report');
|
||||||
|
await W(250);
|
||||||
|
await move(310, 140, 450);
|
||||||
|
await clk();
|
||||||
|
await W(200);
|
||||||
|
|
||||||
|
ln(cm('deskctl type', '', '"NVDA Q1 2025 Earnings Summary"'));
|
||||||
|
await W(200);
|
||||||
|
await typeEl($('doc-title'), 'NVDA Q1 2025 Earnings Summary', 35);
|
||||||
|
out('<span class="ok">typed title</span>');
|
||||||
|
await W(200);
|
||||||
|
|
||||||
|
ln(cm('deskctl press', '', 'enter'));
|
||||||
|
await W(150);
|
||||||
|
ln(cm('deskctl type', '', '"Prepared by AI Agent via deskctl"'));
|
||||||
|
await W(200);
|
||||||
|
await typeEl($('doc-subtitle'), 'Prepared by AI Agent via deskctl', 28);
|
||||||
|
await W(200);
|
||||||
|
ln(cm('deskctl press', '', 'enter enter'));
|
||||||
|
await W(200); div();
|
||||||
|
|
||||||
|
// 8: Type body
|
||||||
|
step('act', 'Write analysis');
|
||||||
|
await W(250);
|
||||||
|
const body = "NVIDIA reported strong Q1 results driven by data center revenue growth of 427% YoY. The stock is up 3.42% today at $924.68. Key drivers include H100/H200 GPU demand from hyperscalers and continued AI infrastructure buildout.";
|
||||||
|
ln(cm('deskctl type', '', '"NVIDIA reported strong Q1..."'));
|
||||||
|
await W(200);
|
||||||
|
await typeEl($('doc-body'), body, 12);
|
||||||
|
out('<span class="ok">typed analysis (224 chars)</span>');
|
||||||
|
await W(400); div();
|
||||||
|
|
||||||
|
// 9: Paste chart
|
||||||
|
step('act', 'Insert chart screenshot');
|
||||||
|
await W(250);
|
||||||
|
ln(cm('deskctl press', '', 'enter enter'));
|
||||||
|
await W(200);
|
||||||
|
ln(cm('deskctl hotkey', '', 'ctrl v'));
|
||||||
|
await W(400);
|
||||||
|
$('doc-chart').classList.add('visible');
|
||||||
|
await W(300);
|
||||||
|
out('<span class="ok">pasted chart_nvda.png into document</span>');
|
||||||
|
await W(500); div();
|
||||||
|
|
||||||
|
// 10: Final verify
|
||||||
|
step('verify', 'Verify completed report');
|
||||||
|
await W(250);
|
||||||
|
ln(cm('deskctl snapshot', '--annotate'));
|
||||||
|
await W(300);
|
||||||
|
await flash();
|
||||||
|
posAnnot('a1', 'w-files'); posAnnot('a2', 'w-chart'); posAnnot('a3', 'w-docs');
|
||||||
|
show('a1'); show('a2'); show('a3');
|
||||||
|
await W(200);
|
||||||
|
out('<span class="jk">"windows"</span>: [');
|
||||||
|
out(' { <span class="jv">"@w1"</span>: <span class="jv">"Files"</span>, <span class="jv">"@w2"</span>: <span class="jv">"Yahoo Finance"</span>, <span class="jv">"@w3"</span>: <span class="jv">"Google Docs"</span> }');
|
||||||
|
out(']');
|
||||||
|
await W(600);
|
||||||
|
hideAnnots();
|
||||||
|
await W(300); div();
|
||||||
|
|
||||||
|
// 11: Agent summary (Claude-style)
|
||||||
|
step('done', 'Task complete');
|
||||||
|
await W(400);
|
||||||
|
agentMsg('I\'ve completed the NVDA Q1 earnings report.');
|
||||||
|
await W(300);
|
||||||
|
agentMsg('');
|
||||||
|
await W(100);
|
||||||
|
agentMsg('Here\'s what I did:');
|
||||||
|
await W(200);
|
||||||
|
agentMsg(' - Read task_brief.txt from ~/reports for context');
|
||||||
|
await W(150);
|
||||||
|
agentMsg(' - Pulled the NVDA 1Y chart from Yahoo Finance');
|
||||||
|
await W(150);
|
||||||
|
agentMsg(' - Created a new Google Doc with title, analysis,');
|
||||||
|
await W(100);
|
||||||
|
agentMsg(' and embedded the stock chart screenshot');
|
||||||
|
await W(300);
|
||||||
|
agentMsg('');
|
||||||
|
agentMsg('Document: <span class="link">docs.google.com/d/1xK9m...r4/edit</span>');
|
||||||
|
|
||||||
|
// Cursor exits
|
||||||
|
await W(500);
|
||||||
|
await move(600, 10, 700);
|
||||||
|
cur.style.transition = 'opacity 0.5s'; cur.style.opacity = '0';
|
||||||
|
await W(600);
|
||||||
|
$('replay').style.display = 'inline-flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('load', () => setTimeout(run, 300));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
110
docs/releasing.md
Normal file
110
docs/releasing.md
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
# Releasing deskctl
|
||||||
|
|
||||||
|
This document covers the operator flow for shipping `deskctl` across:
|
||||||
|
|
||||||
|
- GitHub Releases
|
||||||
|
- crates.io
|
||||||
|
- npm
|
||||||
|
- the repo flake
|
||||||
|
|
||||||
|
GitHub Releases are the canonical binary source. The npm package consumes those release assets instead of building a separate binary.
|
||||||
|
|
||||||
|
## Package Names
|
||||||
|
|
||||||
|
- crate: `deskctl`
|
||||||
|
- npm package: `deskctl`
|
||||||
|
- installed command: `deskctl`
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before the first live publish on each registry:
|
||||||
|
|
||||||
|
- npm ownership for `deskctl`
|
||||||
|
- crates.io ownership for `deskctl`
|
||||||
|
- repository secrets:
|
||||||
|
- `NPM_TOKEN`
|
||||||
|
- `CARGO_REGISTRY_TOKEN`
|
||||||
|
|
||||||
|
These are user-owned prerequisites. The repo can validate and automate the rest, but it cannot create registry ownership for you.
|
||||||
|
|
||||||
|
## Normal Release Flow
|
||||||
|
|
||||||
|
1. Merge release-ready changes to `main`.
|
||||||
|
2. Let CI run:
|
||||||
|
- validation
|
||||||
|
- integration
|
||||||
|
- distribution validation
|
||||||
|
- release asset build
|
||||||
|
3. Confirm the GitHub Release exists for the version tag and includes:
|
||||||
|
- `deskctl-linux-x86_64`
|
||||||
|
- `checksums.txt`
|
||||||
|
4. Trigger the `Publish Registries` workflow with:
|
||||||
|
- `tag`
|
||||||
|
- `publish_npm`
|
||||||
|
- `publish_crates`
|
||||||
|
5. Confirm the publish summary for each channel.
|
||||||
|
|
||||||
|
## What CI Validates
|
||||||
|
|
||||||
|
The repository validates:
|
||||||
|
|
||||||
|
- `cargo publish --dry-run --locked`
|
||||||
|
- npm package metadata and packability
|
||||||
|
- npm install smoke path on Linux using the packaged `deskctl` command
|
||||||
|
- repo flake evaluation/build
|
||||||
|
|
||||||
|
The repository release workflow:
|
||||||
|
|
||||||
|
- builds the Linux release binary
|
||||||
|
- publishes the canonical GitHub Release asset
|
||||||
|
- uploads `checksums.txt`
|
||||||
|
|
||||||
|
The registry publish jobs (npm and crates.io run in parallel):
|
||||||
|
|
||||||
|
- target an existing release tag
|
||||||
|
- check whether that version is already published on the respective registry
|
||||||
|
- skip already-published versions
|
||||||
|
- both default to enabled; can be toggled via workflow_dispatch inputs
|
||||||
|
|
||||||
|
## Rerun Safety
|
||||||
|
|
||||||
|
Registry publishing is intentionally separate from release asset creation.
|
||||||
|
|
||||||
|
If a partial failure happens:
|
||||||
|
|
||||||
|
- GitHub Release assets remain the source of truth
|
||||||
|
- rerun the `Publish Registries` workflow for the same tag
|
||||||
|
- already-published channels are reported and skipped
|
||||||
|
- remaining channels can still be published
|
||||||
|
|
||||||
|
## Local Validation
|
||||||
|
|
||||||
|
Run the distribution checks locally with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make cargo-publish-dry-run
|
||||||
|
make npm-package-check
|
||||||
|
make nix-flake-check
|
||||||
|
make dist-validate
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `make npm-package-check` does a runtime smoke test only on Linux
|
||||||
|
- `make nix-flake-check` requires a local Nix installation
|
||||||
|
- Docker remains a local Linux build convenience, not the canonical release path
|
||||||
|
|
||||||
|
## Nix Boundary
|
||||||
|
|
||||||
|
The repo-owned `flake.nix` is the supported Nix surface in this phase.
|
||||||
|
|
||||||
|
In scope:
|
||||||
|
|
||||||
|
- `nix run github:harivansh-afk/deskctl`
|
||||||
|
- `nix profile install github:harivansh-afk/deskctl`
|
||||||
|
- CI validation for the repo flake
|
||||||
|
|
||||||
|
Out of scope for this phase:
|
||||||
|
|
||||||
|
- `nixpkgs` upstreaming
|
||||||
|
- extra distro packaging outside the repo
|
||||||
70
docs/runtime-contract.md
Normal file
70
docs/runtime-contract.md
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# deskctl runtime contract
|
||||||
|
|
||||||
|
All commands support `--json` and use the same top-level envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {},
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--json` whenever you need to parse output programmatically.
|
||||||
|
|
||||||
|
## Stable window fields
|
||||||
|
|
||||||
|
Whenever a response includes a window payload, these fields are stable:
|
||||||
|
|
||||||
|
- `ref_id`
|
||||||
|
- `window_id`
|
||||||
|
- `title`
|
||||||
|
- `app_name`
|
||||||
|
- `x`
|
||||||
|
- `y`
|
||||||
|
- `width`
|
||||||
|
- `height`
|
||||||
|
- `focused`
|
||||||
|
- `minimized`
|
||||||
|
|
||||||
|
Use `window_id` for stable targeting inside a live daemon session. Use
|
||||||
|
`ref_id` or `@wN` for short-lived follow-up actions after `snapshot` or
|
||||||
|
`list-windows`.
|
||||||
|
|
||||||
|
## Stable grouped reads
|
||||||
|
|
||||||
|
- `deskctl get active-window` -> `data.window`
|
||||||
|
- `deskctl get monitors` -> `data.count`, `data.monitors`
|
||||||
|
- `deskctl get version` -> `data.version`, `data.backend`
|
||||||
|
- `deskctl get systeminfo` -> runtime-scoped diagnostic fields such as
|
||||||
|
`backend`, `display`, `session_type`, `session`, `socket_path`, `screen`,
|
||||||
|
`monitor_count`, and `monitors`
|
||||||
|
|
||||||
|
## Stable waits
|
||||||
|
|
||||||
|
- `deskctl wait window` -> `data.wait`, `data.selector`, `data.elapsed_ms`,
|
||||||
|
`data.window`
|
||||||
|
- `deskctl wait focus` -> `data.wait`, `data.selector`, `data.elapsed_ms`,
|
||||||
|
`data.window`
|
||||||
|
|
||||||
|
## Stable structured error kinds
|
||||||
|
|
||||||
|
When a command fails with structured JSON data, these `kind` values are stable:
|
||||||
|
|
||||||
|
- `selector_not_found`
|
||||||
|
- `selector_ambiguous`
|
||||||
|
- `selector_invalid`
|
||||||
|
- `timeout`
|
||||||
|
- `not_found`
|
||||||
|
|
||||||
|
Wait failures may also include `window_not_focused` in the last observation
|
||||||
|
payload.
|
||||||
|
|
||||||
|
## Best-effort fields
|
||||||
|
|
||||||
|
Treat these as useful but non-contractual:
|
||||||
|
|
||||||
|
- exact monitor names
|
||||||
|
- incidental text formatting in non-JSON mode
|
||||||
|
- default screenshot file names when no explicit path was provided
|
||||||
|
- environment-dependent ordering details from the window manager
|
||||||
|
|
@ -1,178 +0,0 @@
|
||||||
# Runtime Output Contract
|
|
||||||
|
|
||||||
This document defines the current output contract for `deskctl`.
|
|
||||||
|
|
||||||
It is intentionally scoped to the current Linux X11 runtime surface.
|
|
||||||
It does not promise stability for future Wayland or window-manager-specific features.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
- Keep `deskctl` fully non-interactive
|
|
||||||
- Make text output actionable for quick terminal and agent loops
|
|
||||||
- Make `--json` safe for agent consumption without depending on incidental formatting
|
|
||||||
|
|
||||||
## JSON Envelope
|
|
||||||
|
|
||||||
Every runtime command uses the same top-level JSON envelope:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"data": {},
|
|
||||||
"error": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Stable top-level fields:
|
|
||||||
|
|
||||||
- `success`
|
|
||||||
- `data`
|
|
||||||
- `error`
|
|
||||||
|
|
||||||
`success` is always the authoritative success/failure bit.
|
|
||||||
When `success` is `false`, the CLI exits non-zero in both text mode and `--json` mode.
|
|
||||||
|
|
||||||
## Stable Fields
|
|
||||||
|
|
||||||
These fields are stable for agent consumption in the current Phase 1 runtime contract.
|
|
||||||
|
|
||||||
### Window Identity
|
|
||||||
|
|
||||||
Whenever a runtime response includes a window payload, these fields are stable:
|
|
||||||
|
|
||||||
- `ref_id`
|
|
||||||
- `window_id`
|
|
||||||
- `title`
|
|
||||||
- `app_name`
|
|
||||||
- `x`
|
|
||||||
- `y`
|
|
||||||
- `width`
|
|
||||||
- `height`
|
|
||||||
- `focused`
|
|
||||||
- `minimized`
|
|
||||||
|
|
||||||
`window_id` is the stable public identifier for a live daemon session.
|
|
||||||
`ref_id` is a short-lived convenience handle for the current window snapshot/ref map.
|
|
||||||
|
|
||||||
### Grouped Reads
|
|
||||||
|
|
||||||
`deskctl get active-window`
|
|
||||||
|
|
||||||
- stable: `data.window`
|
|
||||||
|
|
||||||
`deskctl get monitors`
|
|
||||||
|
|
||||||
- stable: `data.count`
|
|
||||||
- stable: `data.monitors`
|
|
||||||
- stable per monitor:
|
|
||||||
- `name`
|
|
||||||
- `x`
|
|
||||||
- `y`
|
|
||||||
- `width`
|
|
||||||
- `height`
|
|
||||||
- `width_mm`
|
|
||||||
- `height_mm`
|
|
||||||
- `primary`
|
|
||||||
- `automatic`
|
|
||||||
|
|
||||||
`deskctl get version`
|
|
||||||
|
|
||||||
- stable: `data.version`
|
|
||||||
- stable: `data.backend`
|
|
||||||
|
|
||||||
`deskctl get systeminfo`
|
|
||||||
|
|
||||||
- stable: `data.backend`
|
|
||||||
- stable: `data.display`
|
|
||||||
- stable: `data.session_type`
|
|
||||||
- stable: `data.session`
|
|
||||||
- stable: `data.socket_path`
|
|
||||||
- stable: `data.screen`
|
|
||||||
- stable: `data.monitor_count`
|
|
||||||
- stable: `data.monitors`
|
|
||||||
|
|
||||||
### Waits
|
|
||||||
|
|
||||||
`deskctl wait window`
|
|
||||||
`deskctl wait focus`
|
|
||||||
|
|
||||||
- stable: `data.wait`
|
|
||||||
- stable: `data.selector`
|
|
||||||
- stable: `data.elapsed_ms`
|
|
||||||
- stable: `data.window`
|
|
||||||
|
|
||||||
### Selector-Driven Action Success
|
|
||||||
|
|
||||||
For selector-driven action commands that resolve a window target, these identifiers are stable when present:
|
|
||||||
|
|
||||||
- `data.ref_id`
|
|
||||||
- `data.window_id`
|
|
||||||
- `data.title`
|
|
||||||
- `data.selector`
|
|
||||||
|
|
||||||
This applies to:
|
|
||||||
|
|
||||||
- `click`
|
|
||||||
- `dblclick`
|
|
||||||
- `focus`
|
|
||||||
- `close`
|
|
||||||
- `move-window`
|
|
||||||
- `resize-window`
|
|
||||||
|
|
||||||
The exact human-readable text rendering of those commands is not part of the JSON contract.
|
|
||||||
|
|
||||||
### Artifact-Producing Commands
|
|
||||||
|
|
||||||
`snapshot`
|
|
||||||
`screenshot`
|
|
||||||
|
|
||||||
- stable: `data.screenshot`
|
|
||||||
|
|
||||||
When the command also returns windows, `data.windows` uses the stable window payload documented above.
|
|
||||||
|
|
||||||
## Stable Structured Error Kinds
|
|
||||||
|
|
||||||
When a runtime command returns structured JSON failure data, these error kinds are stable:
|
|
||||||
|
|
||||||
- `selector_not_found`
|
|
||||||
- `selector_ambiguous`
|
|
||||||
- `selector_invalid`
|
|
||||||
- `timeout`
|
|
||||||
- `not_found`
|
|
||||||
- `window_not_focused` as `data.last_observation.kind` or equivalent observation payload
|
|
||||||
|
|
||||||
Stable structured failure fields include:
|
|
||||||
|
|
||||||
- `data.kind`
|
|
||||||
- `data.selector` when selector-related
|
|
||||||
- `data.mode` when selector-related
|
|
||||||
- `data.candidates` for ambiguous selector failures
|
|
||||||
- `data.message` for invalid selector failures
|
|
||||||
- `data.wait`
|
|
||||||
- `data.timeout_ms`
|
|
||||||
- `data.poll_ms`
|
|
||||||
- `data.last_observation`
|
|
||||||
|
|
||||||
## Best-Effort Fields
|
|
||||||
|
|
||||||
These values are useful but environment-dependent and should be treated as best-effort:
|
|
||||||
|
|
||||||
- exact monitor naming conventions
|
|
||||||
- EWMH/window-manager-dependent window ordering details
|
|
||||||
- cosmetic text formatting in non-JSON mode
|
|
||||||
- screenshot file names when the caller did not provide an explicit path
|
|
||||||
- command stderr wording outside the structured `kind` classifications above
|
|
||||||
|
|
||||||
## Text Mode Expectations
|
|
||||||
|
|
||||||
Text mode is intended to stay compact and follow-up-useful.
|
|
||||||
|
|
||||||
The exact whitespace/alignment of text output is not stable.
|
|
||||||
The following expectations are stable at the behavioral level:
|
|
||||||
|
|
||||||
- important runtime reads print actionable identifiers or geometry
|
|
||||||
- selector failures print enough detail to recover without `--json`
|
|
||||||
- artifact-producing commands print the artifact path
|
|
||||||
- window listings print both `@wN` refs and `window_id` values
|
|
||||||
|
|
||||||
If an agent needs strict parsing, it should use `--json`.
|
|
||||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1774386573,
|
||||||
|
"narHash": "sha256-4hAV26quOxdC6iyG7kYaZcM3VOskcPUrdCQd/nx8obc=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "46db2e09e1d3f113a13c0d7b81e2f221c63b8ce9",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
77
flake.nix
Normal file
77
flake.nix
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
{
|
||||||
|
description = "deskctl - Desktop control CLI for AI agents on Linux X11";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{ self, nixpkgs, flake-utils }:
|
||||||
|
flake-utils.lib.eachDefaultSystem (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
lib = pkgs.lib;
|
||||||
|
cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
|
||||||
|
|
||||||
|
deskctl =
|
||||||
|
pkgs.rustPlatform.buildRustPackage {
|
||||||
|
pname = cargoToml.package.name;
|
||||||
|
version = cargoToml.package.version;
|
||||||
|
src = ./.;
|
||||||
|
cargoLock.lockFile = ./Cargo.lock;
|
||||||
|
nativeBuildInputs = [ pkgs.pkg-config ];
|
||||||
|
buildInputs = lib.optionals pkgs.stdenv.isLinux [
|
||||||
|
pkgs.libx11
|
||||||
|
pkgs.libxtst
|
||||||
|
];
|
||||||
|
doCheck = false;
|
||||||
|
|
||||||
|
meta = with lib; {
|
||||||
|
description = cargoToml.package.description;
|
||||||
|
homepage = cargoToml.package.homepage;
|
||||||
|
license = licenses.mit;
|
||||||
|
mainProgram = "deskctl";
|
||||||
|
platforms = platforms.linux;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
formatter = pkgs.nixfmt;
|
||||||
|
|
||||||
|
packages = lib.optionalAttrs pkgs.stdenv.isLinux {
|
||||||
|
inherit deskctl;
|
||||||
|
default = deskctl;
|
||||||
|
};
|
||||||
|
|
||||||
|
apps = lib.optionalAttrs pkgs.stdenv.isLinux {
|
||||||
|
default = flake-utils.lib.mkApp { drv = deskctl; };
|
||||||
|
deskctl = flake-utils.lib.mkApp { drv = deskctl; };
|
||||||
|
};
|
||||||
|
|
||||||
|
checks = lib.optionalAttrs pkgs.stdenv.isLinux {
|
||||||
|
build = deskctl;
|
||||||
|
};
|
||||||
|
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
packages =
|
||||||
|
[
|
||||||
|
pkgs.cargo
|
||||||
|
pkgs.clippy
|
||||||
|
pkgs.nodejs
|
||||||
|
pkgs.nixfmt
|
||||||
|
pkgs.pkg-config
|
||||||
|
pkgs.pnpm
|
||||||
|
pkgs.rustc
|
||||||
|
pkgs.rustfmt
|
||||||
|
]
|
||||||
|
++ lib.optionals pkgs.stdenv.isLinux [
|
||||||
|
pkgs.libx11
|
||||||
|
pkgs.libxtst
|
||||||
|
pkgs.xorg.xorgserver
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
48
npm/deskctl/README.md
Normal file
48
npm/deskctl/README.md
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# deskctl
|
||||||
|
|
||||||
|
`deskctl` installs the command for Linux X11 systems.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g deskctl
|
||||||
|
```
|
||||||
|
|
||||||
|
After install, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
deskctl --help
|
||||||
|
```
|
||||||
|
|
||||||
|
To upgrade version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
deskctl upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
For non-interactive use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
deskctl upgrade --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
One-shot usage is also supported:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx deskctl --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Runtime Support
|
||||||
|
|
||||||
|
- Linux
|
||||||
|
- X11 session
|
||||||
|
- currently packaged release asset: `linux-x64`
|
||||||
|
|
||||||
|
`deskctl` downloads the matching GitHub Release binary during install.
|
||||||
|
Unsupported targets fail during install with a clear runtime support error instead of installing a broken command.
|
||||||
|
|
||||||
|
If you want the Rust source-install path instead, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo install deskctl
|
||||||
|
```
|
||||||
36
npm/deskctl/bin/deskctl.js
Normal file
36
npm/deskctl/bin/deskctl.js
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const { spawn } = require("node:child_process");
|
||||||
|
|
||||||
|
const { readPackageJson, releaseTag, supportedTarget, vendorBinaryPath } = require("../scripts/support");
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const pkg = readPackageJson();
|
||||||
|
const target = supportedTarget();
|
||||||
|
const binaryPath = vendorBinaryPath(target);
|
||||||
|
|
||||||
|
if (!fs.existsSync(binaryPath)) {
|
||||||
|
console.error(
|
||||||
|
[
|
||||||
|
"deskctl binary is missing from the npm package install.",
|
||||||
|
`Expected: ${binaryPath}`,
|
||||||
|
`Package version: ${pkg.version}`,
|
||||||
|
`Release tag: ${releaseTag(pkg)}`,
|
||||||
|
"Try reinstalling deskctl or check that your target is supported."
|
||||||
|
].join("\n")
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit" });
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
if (signal) {
|
||||||
|
process.kill(process.pid, signal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
process.exit(code ?? 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
36
npm/deskctl/package.json
Normal file
36
npm/deskctl/package.json
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"name": "deskctl",
|
||||||
|
"version": "0.1.14",
|
||||||
|
"description": "Installable deskctl package for Linux X11 agents",
|
||||||
|
"license": "MIT",
|
||||||
|
"homepage": "https://github.com/harivansh-afk/deskctl",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/harivansh-afk/deskctl.git"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/harivansh-afk/deskctl/issues"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"deskctl": "bin/deskctl.js"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"README.md",
|
||||||
|
"bin",
|
||||||
|
"scripts"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"postinstall": "node scripts/postinstall.js",
|
||||||
|
"validate": "node scripts/validate-package.js"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"deskctl",
|
||||||
|
"x11",
|
||||||
|
"desktop",
|
||||||
|
"automation",
|
||||||
|
"cli"
|
||||||
|
]
|
||||||
|
}
|
||||||
49
npm/deskctl/scripts/postinstall.js
Normal file
49
npm/deskctl/scripts/postinstall.js
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
const fs = require("node:fs");
|
||||||
|
|
||||||
|
const {
|
||||||
|
checksumsUrl,
|
||||||
|
checksumForAsset,
|
||||||
|
download,
|
||||||
|
ensureVendorDir,
|
||||||
|
installLocalBinary,
|
||||||
|
readPackageJson,
|
||||||
|
releaseAssetUrl,
|
||||||
|
releaseTag,
|
||||||
|
sha256,
|
||||||
|
supportedTarget,
|
||||||
|
vendorBinaryPath
|
||||||
|
} = require("./support");
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const pkg = readPackageJson();
|
||||||
|
const target = supportedTarget();
|
||||||
|
const targetPath = vendorBinaryPath(target);
|
||||||
|
|
||||||
|
ensureVendorDir();
|
||||||
|
|
||||||
|
if (process.env.DESKCTL_BINARY_PATH) {
|
||||||
|
installLocalBinary(process.env.DESKCTL_BINARY_PATH, targetPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = releaseTag(pkg);
|
||||||
|
const assetUrl = releaseAssetUrl(tag, target.assetName);
|
||||||
|
const checksumText = (await download(checksumsUrl(tag))).toString("utf8");
|
||||||
|
const expectedSha = checksumForAsset(checksumText, target.assetName);
|
||||||
|
const asset = await download(assetUrl);
|
||||||
|
const actualSha = sha256(asset);
|
||||||
|
|
||||||
|
if (actualSha !== expectedSha) {
|
||||||
|
throw new Error(
|
||||||
|
`Checksum mismatch for ${target.assetName}. Expected ${expectedSha}, got ${actualSha}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(targetPath, asset);
|
||||||
|
fs.chmodSync(targetPath, 0o755);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(`deskctl install failed: ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
120
npm/deskctl/scripts/support.js
Normal file
120
npm/deskctl/scripts/support.js
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
const crypto = require("node:crypto");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const https = require("node:https");
|
||||||
|
|
||||||
|
const PACKAGE_ROOT = path.resolve(__dirname, "..");
|
||||||
|
const VENDOR_DIR = path.join(PACKAGE_ROOT, "vendor");
|
||||||
|
const PACKAGE_JSON = path.join(PACKAGE_ROOT, "package.json");
|
||||||
|
|
||||||
|
function readPackageJson() {
|
||||||
|
return JSON.parse(fs.readFileSync(PACKAGE_JSON, "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseTag(pkg) {
|
||||||
|
return process.env.DESKCTL_RELEASE_TAG || `v${pkg.version}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportedTarget(platform = process.platform, arch = process.arch) {
|
||||||
|
if (platform === "linux" && arch === "x64") {
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
arch,
|
||||||
|
assetName: "deskctl-linux-x86_64",
|
||||||
|
binaryName: "deskctl-linux-x86_64"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`deskctl currently supports linux-x64 only. Received ${platform}-${arch}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function vendorBinaryPath(target) {
|
||||||
|
return path.join(VENDOR_DIR, target.binaryName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseBaseUrl(tag) {
|
||||||
|
return (
|
||||||
|
process.env.DESKCTL_RELEASE_BASE_URL ||
|
||||||
|
`https://github.com/harivansh-afk/deskctl/releases/download/${tag}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseAssetUrl(tag, assetName) {
|
||||||
|
return process.env.DESKCTL_DOWNLOAD_URL || `${releaseBaseUrl(tag)}/${assetName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checksumsUrl(tag) {
|
||||||
|
return `${releaseBaseUrl(tag)}/checksums.txt`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureVendorDir() {
|
||||||
|
fs.mkdirSync(VENDOR_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function checksumForAsset(contents, assetName) {
|
||||||
|
const line = contents
|
||||||
|
.split("\n")
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.find((value) => value.endsWith(` ${assetName}`) || value.endsWith(` *${assetName}`));
|
||||||
|
|
||||||
|
if (!line) {
|
||||||
|
throw new Error(`Could not find checksum entry for ${assetName}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return line.split(/\s+/)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(buffer) {
|
||||||
|
return crypto.createHash("sha256").update(buffer).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function download(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
https
|
||||||
|
.get(url, (response) => {
|
||||||
|
if (
|
||||||
|
response.statusCode &&
|
||||||
|
response.statusCode >= 300 &&
|
||||||
|
response.statusCode < 400 &&
|
||||||
|
response.headers.location
|
||||||
|
) {
|
||||||
|
response.resume();
|
||||||
|
resolve(download(response.headers.location));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
reject(new Error(`Download failed for ${url}: HTTP ${response.statusCode}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
response.on("data", (chunk) => chunks.push(chunk));
|
||||||
|
response.on("end", () => resolve(Buffer.concat(chunks)));
|
||||||
|
})
|
||||||
|
.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function installLocalBinary(sourcePath, targetPath) {
|
||||||
|
fs.copyFileSync(sourcePath, targetPath);
|
||||||
|
fs.chmodSync(targetPath, 0o755);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
PACKAGE_ROOT,
|
||||||
|
VENDOR_DIR,
|
||||||
|
checksumsUrl,
|
||||||
|
checksumForAsset,
|
||||||
|
download,
|
||||||
|
ensureVendorDir,
|
||||||
|
installLocalBinary,
|
||||||
|
readPackageJson,
|
||||||
|
releaseAssetUrl,
|
||||||
|
releaseTag,
|
||||||
|
sha256,
|
||||||
|
supportedTarget,
|
||||||
|
vendorBinaryPath
|
||||||
|
};
|
||||||
40
npm/deskctl/scripts/validate-package.js
Normal file
40
npm/deskctl/scripts/validate-package.js
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const { readPackageJson, supportedTarget, vendorBinaryPath } = require("./support");
|
||||||
|
|
||||||
|
function readCargoVersion() {
|
||||||
|
const cargoToml = fs.readFileSync(
|
||||||
|
path.resolve(__dirname, "..", "..", "..", "Cargo.toml"),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const match = cargoToml.match(/^version = "([^"]+)"/m);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error("Could not determine Cargo.toml version.");
|
||||||
|
}
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const pkg = readPackageJson();
|
||||||
|
const cargoVersion = readCargoVersion();
|
||||||
|
|
||||||
|
if (pkg.version !== cargoVersion) {
|
||||||
|
throw new Error(
|
||||||
|
`Version mismatch: npm package is ${pkg.version}, Cargo.toml is ${cargoVersion}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pkg.bin?.deskctl !== "bin/deskctl.js") {
|
||||||
|
throw new Error("deskctl must expose the deskctl bin entrypoint.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = supportedTarget("linux", "x64");
|
||||||
|
const targetPath = vendorBinaryPath(target);
|
||||||
|
const vendorDir = path.dirname(targetPath);
|
||||||
|
if (!vendorDir.endsWith(path.join("deskctl", "vendor"))) {
|
||||||
|
throw new Error("Vendor binary directory resolved unexpectedly.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
|
@ -30,7 +30,7 @@ function formatTocText(text: string): string {
|
||||||
<body>
|
<body>
|
||||||
{
|
{
|
||||||
!isIndex && (
|
!isIndex && (
|
||||||
<nav>
|
<nav class="breadcrumbs">
|
||||||
<a class="title" href="/">
|
<a class="title" href="/">
|
||||||
deskctl
|
deskctl
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
---
|
|
||||||
layout: ../layouts/DocLayout.astro
|
|
||||||
title: Architecture
|
|
||||||
toc: true
|
|
||||||
---
|
|
||||||
|
|
||||||
# Architecture
|
|
||||||
|
|
||||||
## Client-daemon model
|
|
||||||
|
|
||||||
deskctl uses a client-daemon architecture over Unix sockets. The daemon starts automatically on the first command and keeps the X11 connection alive so repeated calls skip the connection setup overhead.
|
|
||||||
|
|
||||||
Each command opens a new connection to the daemon, sends a single NDJSON request, reads one NDJSON response, and exits.
|
|
||||||
|
|
||||||
## Wire protocol
|
|
||||||
|
|
||||||
Requests and responses are newline-delimited JSON (NDJSON) over a Unix socket.
|
|
||||||
|
|
||||||
**Request:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "id": "r123456", "action": "snapshot", "annotate": true }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"success": true, "data": {"screenshot": "/tmp/deskctl-1234567890.png", "windows": [...]}}
|
|
||||||
```
|
|
||||||
|
|
||||||
Error responses include an `error` field:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "success": false, "error": "window not found: @w99" }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Socket location
|
|
||||||
|
|
||||||
The daemon socket is resolved in this order:
|
|
||||||
|
|
||||||
1. `--socket` flag (highest priority)
|
|
||||||
2. `$DESKCTL_SOCKET_DIR/{session}.sock`
|
|
||||||
3. `$XDG_RUNTIME_DIR/deskctl/{session}.sock`
|
|
||||||
4. `~/.deskctl/{session}.sock`
|
|
||||||
|
|
||||||
PID files are stored alongside the socket.
|
|
||||||
|
|
||||||
## Sessions
|
|
||||||
|
|
||||||
Multiple isolated daemon instances can run simultaneously using the `--session` flag:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
deskctl --session workspace1 snapshot
|
|
||||||
deskctl --session workspace2 snapshot
|
|
||||||
```
|
|
||||||
|
|
||||||
Each session has its own socket, PID file, and window ref map.
|
|
||||||
|
|
||||||
## Backend design
|
|
||||||
|
|
||||||
The core is built around a `DesktopBackend` trait. The current implementation uses `x11rb` for X11 protocol operations and `enigo` for input simulation.
|
|
||||||
|
|
||||||
The trait-based design means adding Wayland support is a single trait implementation with no changes to the core, CLI, or daemon code.
|
|
||||||
|
|
||||||
## X11 integration
|
|
||||||
|
|
||||||
Window detection uses EWMH properties:
|
|
||||||
|
|
||||||
| Property | Purpose |
|
|
||||||
| --------------------------- | ------------------------ |
|
|
||||||
| `_NET_CLIENT_LIST_STACKING` | Window stacking order |
|
|
||||||
| `_NET_ACTIVE_WINDOW` | Currently focused window |
|
|
||||||
| `_NET_WM_NAME` | Window title (UTF-8) |
|
|
||||||
| `_NET_WM_STATE_HIDDEN` | Minimized state |
|
|
||||||
| `_NET_CLOSE_WINDOW` | Graceful close |
|
|
||||||
| `WM_CLASS` | Application class/name |
|
|
||||||
|
|
||||||
Falls back to `XQueryTree` if `_NET_CLIENT_LIST_STACKING` is unavailable.
|
|
||||||
|
|
@ -6,167 +6,102 @@ toc: true
|
||||||
|
|
||||||
# Commands
|
# Commands
|
||||||
|
|
||||||
## Snapshot
|
The public CLI is intentionally small. Most workflows boil down to grouped
|
||||||
|
reads, grouped waits, selector-driven actions, and a few input primitives.
|
||||||
|
|
||||||
Capture a screenshot and get the window tree:
|
## Observe and inspect
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
deskctl doctor
|
||||||
|
deskctl upgrade
|
||||||
deskctl snapshot
|
deskctl snapshot
|
||||||
deskctl snapshot --annotate
|
deskctl snapshot --annotate
|
||||||
```
|
|
||||||
|
|
||||||
With `--annotate`, colored bounding boxes and `@wN` labels are drawn on the screenshot. Each window gets a unique color from an 8-color palette. Minimized windows are skipped.
|
|
||||||
|
|
||||||
The screenshot is saved to `/tmp/deskctl-{timestamp}.png`.
|
|
||||||
|
|
||||||
## Click
|
|
||||||
|
|
||||||
Click the center of a window by ref, or click exact coordinates:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
deskctl click @w1
|
|
||||||
deskctl click 960,540
|
|
||||||
```
|
|
||||||
|
|
||||||
## Double click
|
|
||||||
|
|
||||||
```sh
|
|
||||||
deskctl dblclick @w1
|
|
||||||
deskctl dblclick 500,300
|
|
||||||
```
|
|
||||||
|
|
||||||
## Type
|
|
||||||
|
|
||||||
Type a string into the focused window:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
deskctl type "hello world"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Press
|
|
||||||
|
|
||||||
Press a single key:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
deskctl press enter
|
|
||||||
deskctl press tab
|
|
||||||
deskctl press escape
|
|
||||||
```
|
|
||||||
|
|
||||||
Supported key names: `enter`, `tab`, `escape`, `backspace`, `delete`, `space`, `up`, `down`, `left`, `right`, `home`, `end`, `pageup`, `pagedown`, `f1`-`f12`, or any single character.
|
|
||||||
|
|
||||||
## Hotkey
|
|
||||||
|
|
||||||
Send a key combination. List modifier keys first, then the target key:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
deskctl hotkey ctrl c
|
|
||||||
deskctl hotkey ctrl shift t
|
|
||||||
deskctl hotkey alt f4
|
|
||||||
```
|
|
||||||
|
|
||||||
Modifier names: `ctrl`, `alt`, `shift`, `super` (also `meta` or `win`).
|
|
||||||
|
|
||||||
## Mouse move
|
|
||||||
|
|
||||||
Move the cursor to absolute coordinates:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
deskctl mouse move 100 200
|
|
||||||
```
|
|
||||||
|
|
||||||
## Mouse scroll
|
|
||||||
|
|
||||||
Scroll the mouse wheel. Positive values scroll down, negative scroll up:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
deskctl mouse scroll 3
|
|
||||||
deskctl mouse scroll -5
|
|
||||||
deskctl mouse scroll 3 --axis horizontal
|
|
||||||
```
|
|
||||||
|
|
||||||
## Mouse drag
|
|
||||||
|
|
||||||
Drag from one position to another:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
deskctl mouse drag 100 200 500 600
|
|
||||||
```
|
|
||||||
|
|
||||||
## Focus
|
|
||||||
|
|
||||||
Focus a window by ref or by name (case-insensitive substring match):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
deskctl focus @w1
|
|
||||||
deskctl focus "firefox"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Close
|
|
||||||
|
|
||||||
Close a window gracefully:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
deskctl close @w2
|
|
||||||
deskctl close "terminal"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Move window
|
|
||||||
|
|
||||||
Move a window to an absolute position:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
deskctl move-window @w1 0 0
|
|
||||||
deskctl move-window "firefox" 100 100
|
|
||||||
```
|
|
||||||
|
|
||||||
## Resize window
|
|
||||||
|
|
||||||
Resize a window:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
deskctl resize-window @w1 1280 720
|
|
||||||
```
|
|
||||||
|
|
||||||
## List windows
|
|
||||||
|
|
||||||
List all windows without taking a screenshot:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
deskctl list-windows
|
deskctl list-windows
|
||||||
```
|
deskctl screenshot
|
||||||
|
deskctl screenshot /tmp/screen.png
|
||||||
## Get screen size
|
deskctl get active-window
|
||||||
|
deskctl get monitors
|
||||||
```sh
|
deskctl get version
|
||||||
|
deskctl get systeminfo
|
||||||
deskctl get-screen-size
|
deskctl get-screen-size
|
||||||
```
|
|
||||||
|
|
||||||
## Get mouse position
|
|
||||||
|
|
||||||
```sh
|
|
||||||
deskctl get-mouse-position
|
deskctl get-mouse-position
|
||||||
```
|
```
|
||||||
|
|
||||||
## Screenshot
|
`doctor` checks the runtime before daemon startup. `upgrade` checks for a newer
|
||||||
|
published version, shows a short confirmation prompt when an update is
|
||||||
|
available, and supports `--yes` for non-interactive use. `snapshot` produces a
|
||||||
|
screenshot plus window refs. `list-windows` is the same window tree without the
|
||||||
|
side effect of writing a screenshot. The grouped `get` commands are the
|
||||||
|
preferred read surface for focused state queries.
|
||||||
|
|
||||||
Take a screenshot without the window tree. Optionally specify a save path:
|
## Wait for state transitions
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
deskctl screenshot
|
deskctl wait window --selector 'title=Chromium' --timeout 10
|
||||||
deskctl screenshot /tmp/my-screenshot.png
|
deskctl wait focus --selector 'id=win3' --timeout 5
|
||||||
deskctl screenshot --annotate
|
deskctl --json wait window --selector 'class=chromium' --poll-ms 100
|
||||||
```
|
```
|
||||||
|
|
||||||
## Launch
|
Wait commands return the matched window payload on success. In `--json` mode,
|
||||||
|
timeouts and selector failures expose structured `kind` values.
|
||||||
|
|
||||||
Launch an application:
|
## Act on windows
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
deskctl launch firefox
|
deskctl launch chromium
|
||||||
deskctl launch code --args /path/to/project
|
deskctl focus @w1
|
||||||
|
deskctl focus 'title=Chromium'
|
||||||
|
deskctl click @w1
|
||||||
|
deskctl click 960,540
|
||||||
|
deskctl dblclick @w2
|
||||||
|
deskctl close @w3
|
||||||
|
deskctl move-window @w1 100 120
|
||||||
|
deskctl resize-window @w1 1280 720
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Selector-driven actions accept refs, explicit selector modes, or absolute
|
||||||
|
coordinates where appropriate.
|
||||||
|
|
||||||
|
## Keyboard and mouse input
|
||||||
|
|
||||||
|
```sh
|
||||||
|
deskctl type "hello world"
|
||||||
|
deskctl press enter
|
||||||
|
deskctl hotkey ctrl shift t
|
||||||
|
deskctl mouse move 100 200
|
||||||
|
deskctl mouse scroll 3
|
||||||
|
deskctl mouse scroll 3 --axis horizontal
|
||||||
|
deskctl mouse drag 100 200 500 600
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported key names include `enter`, `tab`, `escape`, `backspace`, `delete`,
|
||||||
|
`space`, arrow keys, paging keys, `f1` through `f12`, and any single
|
||||||
|
character.
|
||||||
|
|
||||||
|
## Selectors
|
||||||
|
|
||||||
|
Prefer explicit selectors when the target matters. They are clearer in logs,
|
||||||
|
more deterministic for automation, and easier to retry safely.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ref=w1
|
||||||
|
id=win1
|
||||||
|
title=Chromium
|
||||||
|
class=chromium
|
||||||
|
focused
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy shorthand is still supported:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
@w1
|
||||||
|
w1
|
||||||
|
win1
|
||||||
|
```
|
||||||
|
|
||||||
|
Bare strings like `chromium` are fuzzy matches. They resolve when there is one
|
||||||
|
match and fail with candidate windows when there are multiple matches.
|
||||||
|
|
||||||
## Global options
|
## Global options
|
||||||
|
|
||||||
| Flag | Env | Description |
|
| Flag | Env | Description |
|
||||||
|
|
@ -174,3 +109,6 @@ deskctl launch code --args /path/to/project
|
||||||
| `--json` | | Output as JSON |
|
| `--json` | | Output as JSON |
|
||||||
| `--socket <path>` | `DESKCTL_SOCKET` | Path to daemon Unix socket |
|
| `--socket <path>` | `DESKCTL_SOCKET` | Path to daemon Unix socket |
|
||||||
| `--session <name>` | | Session name for multiple daemons (default: `default`) |
|
| `--session <name>` | | Session name for multiple daemons (default: `default`) |
|
||||||
|
|
||||||
|
`deskctl` manages the daemon automatically. Most users never need to think
|
||||||
|
about it beyond `--session` and `--socket`.
|
||||||
|
|
|
||||||
|
|
@ -8,24 +8,33 @@ import DocLayout from "../layouts/DocLayout.astro";
|
||||||
<img src="/favicon.svg" alt="" width="40" height="40" />
|
<img src="/favicon.svg" alt="" width="40" height="40" />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p>
|
<p class="tagline">non-interactive desktop control cli for AI agents</p>
|
||||||
Desktop control CLI for AI agents on Linux X11. Compact JSON output for
|
|
||||||
agent loops. Screenshot, click, type, scroll, drag, and manage windows
|
<p class="lede">
|
||||||
through a fast client-daemon architecture. 100% native Rust.
|
A thin X11 control primitive for agent loops: diagnose the runtime, observe
|
||||||
|
the desktop, wait for state transitions, act deterministically, then verify.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Getting started</h2>
|
<h2>Start</h2>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/installation">Installation</a></li>
|
<li>
|
||||||
<li><a href="/quick-start">Quick start</a></li>
|
<a href="/installation">Installation</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/quick-start">Quick start</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Reference</h2>
|
<h2>Reference</h2>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/commands">Commands</a></li>
|
<li>
|
||||||
<li><a href="/architecture">Architecture</a></li>
|
<a href="/commands">Commands</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/runtime-contract">Runtime contract</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Links</h2>
|
<h2>Links</h2>
|
||||||
|
|
@ -37,5 +46,8 @@ import DocLayout from "../layouts/DocLayout.astro";
|
||||||
<li>
|
<li>
|
||||||
<a href="https://crates.io/crates/deskctl">crates.io</a>
|
<a href="https://crates.io/crates/deskctl">crates.io</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://www.npmjs.com/package/deskctl">npm</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</DocLayout>
|
</DocLayout>
|
||||||
|
|
|
||||||
|
|
@ -6,43 +6,71 @@ toc: true
|
||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
## Cargo
|
Install the public `deskctl` command first, then validate the desktop runtime
|
||||||
|
with `deskctl doctor` before trying to automate anything.
|
||||||
|
|
||||||
|
## Recommended path
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo install deskctl
|
npm install -g deskctl
|
||||||
|
deskctl doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
## From source
|
`deskctl` is the default install path. It installs the command by
|
||||||
|
downloading the matching GitHub Release asset for the supported runtime target.
|
||||||
|
|
||||||
|
This path does not require a Rust toolchain. The installed command is always
|
||||||
|
`deskctl`, even though the release asset itself is target-specific.
|
||||||
|
|
||||||
|
## Skill install
|
||||||
|
|
||||||
|
The repo skill lives under `skills/deskctl`, so you can install it
|
||||||
|
directly uring `skills.sh`
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx skills add harivansh-afk/deskctl
|
||||||
|
```
|
||||||
|
|
||||||
|
## Other install paths
|
||||||
|
|
||||||
|
### Nix
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix run github:harivansh-afk/deskctl -- --help
|
||||||
|
nix profile install github:harivansh-afk/deskctl
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rust
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/harivansh-afk/deskctl
|
git clone https://github.com/harivansh-afk/deskctl
|
||||||
cd deskctl
|
cd deskctl
|
||||||
cargo build --release
|
cargo build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker (cross-compile for Linux)
|
Source builds on Linux require:
|
||||||
|
|
||||||
Build a static Linux binary from any platform:
|
- Rust 1.75+
|
||||||
|
- `pkg-config`
|
||||||
|
- X11 development libraries such as `libx11-dev` and `libxtst-dev`
|
||||||
|
|
||||||
```sh
|
## Runtime requirements
|
||||||
docker compose -f docker/docker-compose.yml run --rm build
|
|
||||||
```
|
|
||||||
|
|
||||||
This writes `dist/deskctl-linux-x86_64`.
|
|
||||||
|
|
||||||
## Deploy to a remote machine
|
|
||||||
|
|
||||||
Copy the binary over SSH when `scp` is not available:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
ssh -p 443 user@host 'cat > ~/deskctl && chmod +x ~/deskctl' < dist/deskctl-linux-x86_64
|
|
||||||
```
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- Linux with an active X11 session
|
- Linux with an active X11 session
|
||||||
- `DISPLAY` environment variable set (e.g. `DISPLAY=:1`)
|
- `DISPLAY` set to a usable X11 display, such as `DISPLAY=:1`
|
||||||
- `XDG_SESSION_TYPE=x11`
|
- `XDG_SESSION_TYPE=x11` or an equivalent X11 session environment
|
||||||
- A window manager that exposes EWMH properties (`_NET_CLIENT_LIST_STACKING`, `_NET_ACTIVE_WINDOW`)
|
- a window manager or desktop environment that exposes standard EWMH properties
|
||||||
|
such as `_NET_CLIENT_LIST_STACKING` and `_NET_ACTIVE_WINDOW`
|
||||||
|
|
||||||
No extra native libraries are needed beyond the standard glibc runtime (`libc`, `libm`, `libgcc_s`).
|
The binary itself only depends on the standard Linux glibc runtime.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
If setup fails for any reason start here:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
deskctl doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
`doctor` checks X11 connectivity, window enumeration, screenshot viability, and
|
||||||
|
daemon/socket health before normal command execution.
|
||||||
|
|
|
||||||
|
|
@ -6,50 +6,74 @@ toc: true
|
||||||
|
|
||||||
# Quick start
|
# Quick start
|
||||||
|
|
||||||
## Core workflow
|
The fastest way to use `deskctl` is to follow the same four-step loop : observe, wait, act, verify.
|
||||||
|
|
||||||
The typical agent loop is: snapshot the desktop, interpret the result, act on it.
|
## 1. Install and diagnose
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 1. see the desktop
|
npm install -g deskctl
|
||||||
deskctl --json snapshot --annotate
|
deskctl doctor
|
||||||
|
```
|
||||||
|
|
||||||
# 2. click a window by its ref
|
Run `deskctl doctor` first. It checks X11 connectivity, basic enumeration,
|
||||||
deskctl click @w1
|
screenshot viability, and socket health before you start driving the desktop.
|
||||||
|
|
||||||
# 3. type into the focused window
|
## 2. Observe the desktop
|
||||||
deskctl type "hello world"
|
|
||||||
|
|
||||||
# 4. press a key
|
```sh
|
||||||
|
deskctl snapshot --annotate
|
||||||
|
deskctl list-windows
|
||||||
|
deskctl get active-window
|
||||||
|
deskctl get monitors
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `snapshot` when you want a screenshot artifact plus window refs. Use
|
||||||
|
`list-windows` when you only need the current window tree without writing a
|
||||||
|
screenshot.
|
||||||
|
|
||||||
|
## 3. Pick selectors that stay readable
|
||||||
|
|
||||||
|
Prefer explicit selectors when you need deterministic targeting:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ref=w1
|
||||||
|
id=win1
|
||||||
|
title=Chromium
|
||||||
|
class=chromium
|
||||||
|
focused
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy refs such as `@w1` still work after `snapshot` or `list-windows`. Bare
|
||||||
|
strings like `chromium` are fuzzy matches and now fail on ambiguity.
|
||||||
|
|
||||||
|
## 4. Wait, act, verify
|
||||||
|
|
||||||
|
The core loop is:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# observe
|
||||||
|
deskctl snapshot --annotate
|
||||||
|
|
||||||
|
# wait
|
||||||
|
deskctl wait window --selector 'title=Chromium' --timeout 10
|
||||||
|
|
||||||
|
# act
|
||||||
|
deskctl focus 'title=Chromium'
|
||||||
|
deskctl hotkey ctrl l
|
||||||
|
deskctl type "https://example.com"
|
||||||
deskctl press enter
|
deskctl press enter
|
||||||
|
|
||||||
|
# verify
|
||||||
|
deskctl wait focus --selector 'title=Chromium' --timeout 5
|
||||||
|
deskctl snapshot
|
||||||
```
|
```
|
||||||
|
|
||||||
The `--annotate` flag draws colored bounding boxes and `@wN` labels on the screenshot so agents can visually identify windows.
|
The wait commands return the matched window payload on success, so they compose
|
||||||
|
cleanly into the next action.
|
||||||
|
|
||||||
## Window refs
|
## 5. Use `--json` when parsing matters
|
||||||
|
|
||||||
Every `snapshot` assigns refs like `@w1`, `@w2`, etc. to each visible window, ordered top-to-bottom by stacking order. Use these refs anywhere a selector is expected:
|
Every command supports `--json` and uses the same top-level envelope:
|
||||||
|
|
||||||
```sh
|
|
||||||
deskctl click @w1
|
|
||||||
deskctl focus @w3
|
|
||||||
deskctl close @w2
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also select windows by name (case-insensitive substring match):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
deskctl focus "firefox"
|
|
||||||
deskctl close "terminal"
|
|
||||||
```
|
|
||||||
|
|
||||||
## JSON output
|
|
||||||
|
|
||||||
Pass `--json` for machine-readable output. This is the primary mode for agent integrations:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
deskctl --json snapshot
|
|
||||||
```
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -59,9 +83,9 @@ deskctl --json snapshot
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"ref_id": "w1",
|
"ref_id": "w1",
|
||||||
"xcb_id": 12345678,
|
"window_id": "win1",
|
||||||
"title": "Firefox",
|
"title": "Chromium",
|
||||||
"app_name": "firefox",
|
"app_name": "chromium",
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
"width": 1920,
|
"width": 1920,
|
||||||
|
|
@ -74,14 +98,8 @@ deskctl --json snapshot
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Daemon lifecycle
|
Use `window_id` for stable targeting inside a live daemon session. The exact
|
||||||
|
text formatting is intentionally compact, but JSON is the parsing contract.
|
||||||
|
|
||||||
The daemon starts automatically on the first command. It keeps the X11 connection alive so repeated calls are fast. You do not need to manage it manually.
|
The full stable-vs-best-effort contract lives on the
|
||||||
|
[runtime contract](/runtime-contract) page.
|
||||||
```sh
|
|
||||||
# check if the daemon is running
|
|
||||||
deskctl daemon status
|
|
||||||
|
|
||||||
# stop it explicitly
|
|
||||||
deskctl daemon stop
|
|
||||||
```
|
|
||||||
|
|
|
||||||
177
site/src/pages/runtime-contract.mdx
Normal file
177
site/src/pages/runtime-contract.mdx
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
---
|
||||||
|
layout: ../layouts/DocLayout.astro
|
||||||
|
title: Runtime contract
|
||||||
|
toc: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Runtime contract
|
||||||
|
|
||||||
|
This page defines the current public output contract for `deskctl`.
|
||||||
|
|
||||||
|
It is intentionally scoped to the current Linux X11 runtime surface. It does
|
||||||
|
not promise stability for future Wayland or window-manager-specific features.
|
||||||
|
|
||||||
|
## Stable top-level envelope
|
||||||
|
|
||||||
|
Every command supports `--json` and uses the same top-level envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {},
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Stable top-level fields:
|
||||||
|
|
||||||
|
- `success`
|
||||||
|
- `data`
|
||||||
|
- `error`
|
||||||
|
|
||||||
|
If `success` is `false`, the command exits non-zero in both text mode and JSON
|
||||||
|
mode.
|
||||||
|
|
||||||
|
## Stable window payload
|
||||||
|
|
||||||
|
Whenever a response includes a window payload, these fields are stable:
|
||||||
|
|
||||||
|
- `ref_id`
|
||||||
|
- `window_id`
|
||||||
|
- `title`
|
||||||
|
- `app_name`
|
||||||
|
- `x`
|
||||||
|
- `y`
|
||||||
|
- `width`
|
||||||
|
- `height`
|
||||||
|
- `focused`
|
||||||
|
- `minimized`
|
||||||
|
|
||||||
|
`window_id` is the public session-scoped identifier for programmatic targeting.
|
||||||
|
`ref_id` is a short-lived convenience handle from the current ref map.
|
||||||
|
|
||||||
|
## Stable grouped reads
|
||||||
|
|
||||||
|
`deskctl get active-window`
|
||||||
|
|
||||||
|
- stable: `data.window`
|
||||||
|
|
||||||
|
`deskctl get monitors`
|
||||||
|
|
||||||
|
- stable: `data.count`
|
||||||
|
- stable: `data.monitors`
|
||||||
|
|
||||||
|
Stable per-monitor fields:
|
||||||
|
|
||||||
|
- `name`
|
||||||
|
- `x`
|
||||||
|
- `y`
|
||||||
|
- `width`
|
||||||
|
- `height`
|
||||||
|
- `width_mm`
|
||||||
|
- `height_mm`
|
||||||
|
- `primary`
|
||||||
|
- `automatic`
|
||||||
|
|
||||||
|
`deskctl get version`
|
||||||
|
|
||||||
|
- stable: `data.version`
|
||||||
|
- stable: `data.backend`
|
||||||
|
|
||||||
|
`deskctl get systeminfo`
|
||||||
|
|
||||||
|
- stable: `data.backend`
|
||||||
|
- stable: `data.display`
|
||||||
|
- stable: `data.session_type`
|
||||||
|
- stable: `data.session`
|
||||||
|
- stable: `data.socket_path`
|
||||||
|
- stable: `data.screen`
|
||||||
|
- stable: `data.monitor_count`
|
||||||
|
- stable: `data.monitors`
|
||||||
|
|
||||||
|
## Stable waits
|
||||||
|
|
||||||
|
`deskctl wait window`
|
||||||
|
`deskctl wait focus`
|
||||||
|
|
||||||
|
- stable: `data.wait`
|
||||||
|
- stable: `data.selector`
|
||||||
|
- stable: `data.elapsed_ms`
|
||||||
|
- stable: `data.window`
|
||||||
|
|
||||||
|
## Stable selector-driven action fields
|
||||||
|
|
||||||
|
When selector-driven actions return resolved window data, these fields are
|
||||||
|
stable when present:
|
||||||
|
|
||||||
|
- `data.ref_id`
|
||||||
|
- `data.window_id`
|
||||||
|
- `data.title`
|
||||||
|
- `data.selector`
|
||||||
|
|
||||||
|
This applies to:
|
||||||
|
|
||||||
|
- `click`
|
||||||
|
- `dblclick`
|
||||||
|
- `focus`
|
||||||
|
- `close`
|
||||||
|
- `move-window`
|
||||||
|
- `resize-window`
|
||||||
|
|
||||||
|
## Stable artifact fields
|
||||||
|
|
||||||
|
For `snapshot` and `screenshot`:
|
||||||
|
|
||||||
|
- stable: `data.screenshot`
|
||||||
|
|
||||||
|
When a command also returns windows, `data.windows` uses the stable window
|
||||||
|
payload documented above.
|
||||||
|
|
||||||
|
## Stable structured error kinds
|
||||||
|
|
||||||
|
When a command fails with structured JSON data, these error kinds are stable:
|
||||||
|
|
||||||
|
- `selector_not_found`
|
||||||
|
- `selector_ambiguous`
|
||||||
|
- `selector_invalid`
|
||||||
|
- `timeout`
|
||||||
|
- `not_found`
|
||||||
|
- `window_not_focused` in `data.last_observation.kind` or an equivalent wait
|
||||||
|
observation payload
|
||||||
|
|
||||||
|
Stable structured failure fields include:
|
||||||
|
|
||||||
|
- `data.kind`
|
||||||
|
- `data.selector`
|
||||||
|
- `data.mode`
|
||||||
|
- `data.candidates`
|
||||||
|
- `data.message`
|
||||||
|
- `data.wait`
|
||||||
|
- `data.timeout_ms`
|
||||||
|
- `data.poll_ms`
|
||||||
|
- `data.last_observation`
|
||||||
|
|
||||||
|
## Best-effort fields
|
||||||
|
|
||||||
|
These values are useful but environment-dependent and should not be treated as
|
||||||
|
strict parsing guarantees:
|
||||||
|
|
||||||
|
- exact monitor naming conventions
|
||||||
|
- EWMH/window-manager-dependent ordering details
|
||||||
|
- cosmetic text formatting in non-JSON mode
|
||||||
|
- default screenshot file names when no explicit path was provided
|
||||||
|
- stderr wording outside the structured `kind` classifications above
|
||||||
|
|
||||||
|
## Text mode expectations
|
||||||
|
|
||||||
|
Text mode is intended to stay compact and follow-up-useful.
|
||||||
|
|
||||||
|
The exact whitespace and alignment are not stable. The stable behavioral
|
||||||
|
expectations are:
|
||||||
|
|
||||||
|
- important reads print actionable identifiers or geometry
|
||||||
|
- selector failures print enough detail to recover without `--json`
|
||||||
|
- artifact-producing commands print the artifact path
|
||||||
|
- window listings print both `@wN` refs and `window_id` values
|
||||||
|
|
||||||
|
If you need strict parsing, use `--json`.
|
||||||
|
|
@ -65,6 +65,11 @@ main {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lede {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
max-width: 42rem;
|
||||||
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -117,6 +122,10 @@ a:hover {
|
||||||
text-decoration-thickness: 2px;
|
text-decoration-thickness: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
ul,
|
ul,
|
||||||
ol {
|
ol {
|
||||||
padding-left: 1.25em;
|
padding-left: 1.25em;
|
||||||
|
|
@ -215,30 +224,30 @@ hr {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nav {
|
.breadcrumbs {
|
||||||
max-width: 50rem;
|
max-width: 50rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1.5rem clamp(1.25rem, 5vw, 3rem) 0;
|
padding: 1.5rem clamp(1.25rem, 5vw, 3rem) 0;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav a {
|
.breadcrumbs a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav a:hover {
|
.breadcrumbs a:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav .title {
|
.breadcrumbs .title {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav .sep {
|
.breadcrumbs .sep {
|
||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
margin: 0 0.5em;
|
margin: 0 0.5em;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
149
skills/SKILL.md
149
skills/SKILL.md
|
|
@ -1,149 +0,0 @@
|
||||||
---
|
|
||||||
name: deskctl
|
|
||||||
description: Desktop control CLI for AI agents
|
|
||||||
allowed-tools: Bash(deskctl:*)
|
|
||||||
---
|
|
||||||
|
|
||||||
# deskctl
|
|
||||||
|
|
||||||
Desktop control CLI for AI agents on Linux X11. Provides a unified interface for screenshots, mouse/keyboard input, and window management with compact `@wN` window references.
|
|
||||||
|
|
||||||
## Core Workflow
|
|
||||||
|
|
||||||
1. **Snapshot** to see the desktop and get window refs
|
|
||||||
2. **Query / wait** using grouped `get` and `wait` commands
|
|
||||||
3. **Act** using refs, explicit selectors, or coordinates
|
|
||||||
4. **Repeat** as needed
|
|
||||||
|
|
||||||
## Quick Reference
|
|
||||||
|
|
||||||
### See the Desktop
|
|
||||||
|
|
||||||
```bash
|
|
||||||
deskctl snapshot # Screenshot + window tree with @wN refs
|
|
||||||
deskctl snapshot --annotate # Screenshot with bounding boxes and labels
|
|
||||||
deskctl snapshot --json # Structured JSON output
|
|
||||||
deskctl list-windows # Window tree without screenshot
|
|
||||||
deskctl screenshot /tmp/s.png # Screenshot only (no window tree)
|
|
||||||
deskctl get active-window # Currently focused window
|
|
||||||
deskctl get monitors # Monitor geometry
|
|
||||||
deskctl get version # deskctl version + backend
|
|
||||||
deskctl get systeminfo # Runtime-scoped diagnostics
|
|
||||||
deskctl wait window --selector 'title=Firefox' --timeout 10
|
|
||||||
deskctl wait focus --selector 'class=firefox' --timeout 5
|
|
||||||
```
|
|
||||||
|
|
||||||
### Click and Type
|
|
||||||
|
|
||||||
```bash
|
|
||||||
deskctl click @w1 # Click center of window @w1
|
|
||||||
deskctl click 500,300 # Click absolute coordinates
|
|
||||||
deskctl dblclick @w2 # Double-click window @w2
|
|
||||||
deskctl type "hello world" # Type text into focused window
|
|
||||||
deskctl press enter # Press a key
|
|
||||||
deskctl hotkey ctrl c # Send Ctrl+C
|
|
||||||
deskctl hotkey ctrl shift t # Send Ctrl+Shift+T
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mouse Control
|
|
||||||
|
|
||||||
```bash
|
|
||||||
deskctl mouse move 500 300 # Move cursor to coordinates
|
|
||||||
deskctl mouse scroll 3 # Scroll down 3 units
|
|
||||||
deskctl mouse scroll -3 # Scroll up 3 units
|
|
||||||
deskctl mouse drag 100 100 500 500 # Drag from (100,100) to (500,500)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Window Management
|
|
||||||
|
|
||||||
```bash
|
|
||||||
deskctl focus @w2 # Focus window by ref
|
|
||||||
deskctl focus 'title=Firefox' # Focus by explicit title selector
|
|
||||||
deskctl focus 'class=firefox' # Focus by explicit class selector
|
|
||||||
deskctl focus "firefox" # Fuzzy substring match (fails on ambiguity)
|
|
||||||
deskctl close @w3 # Close window gracefully
|
|
||||||
deskctl move-window @w1 100 200 # Move window to position
|
|
||||||
deskctl resize-window @w1 800 600 # Resize window
|
|
||||||
```
|
|
||||||
|
|
||||||
### Utilities
|
|
||||||
|
|
||||||
```bash
|
|
||||||
deskctl doctor # Diagnose X11, screenshot, and daemon health
|
|
||||||
deskctl get-screen-size # Screen resolution
|
|
||||||
deskctl get-mouse-position # Current cursor position
|
|
||||||
deskctl launch firefox # Launch an application
|
|
||||||
deskctl launch code -- --new-window # Launch with arguments
|
|
||||||
```
|
|
||||||
|
|
||||||
### Daemon
|
|
||||||
|
|
||||||
```bash
|
|
||||||
deskctl daemon start # Start daemon manually
|
|
||||||
deskctl daemon stop # Stop daemon
|
|
||||||
deskctl daemon status # Check daemon status
|
|
||||||
```
|
|
||||||
|
|
||||||
## Global Options
|
|
||||||
|
|
||||||
- `--json` : Output as structured JSON (all commands)
|
|
||||||
- `--session NAME` : Session name for multiple daemon instances (default: "default")
|
|
||||||
- `--socket PATH` : Custom Unix socket path
|
|
||||||
|
|
||||||
## Output Contract
|
|
||||||
|
|
||||||
- Prefer `--json` when an agent needs strict parsing.
|
|
||||||
- Use `window_id` for stable targeting inside a live daemon session.
|
|
||||||
- Use `ref_id` / `@wN` for quick short-lived follow-up actions after `snapshot` or `list-windows`.
|
|
||||||
- Structured JSON failures expose machine-usable `kind` values for selector and wait failures.
|
|
||||||
- The exact text formatting is intentionally compact but not the parsing contract. See `docs/runtime-output.md` for the stable field policy.
|
|
||||||
|
|
||||||
## Window Refs
|
|
||||||
|
|
||||||
After `snapshot` or `list-windows`, windows are assigned short refs:
|
|
||||||
- `@w1` is the topmost (usually focused) window
|
|
||||||
- `@w2`, `@w3`, etc. follow z-order (front to back)
|
|
||||||
- Refs reset on each `snapshot` call
|
|
||||||
- Use `--json` to see stable `window_id` values for programmatic tracking within the current daemon session
|
|
||||||
|
|
||||||
## Selector Contract
|
|
||||||
|
|
||||||
Prefer explicit selectors when an agent needs deterministic targeting:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ref=w1
|
|
||||||
id=win1
|
|
||||||
title=Firefox
|
|
||||||
class=firefox
|
|
||||||
focused
|
|
||||||
```
|
|
||||||
|
|
||||||
Bare selectors such as `firefox` still work as fuzzy substring matches, but they now fail with candidate windows if multiple matches exist.
|
|
||||||
|
|
||||||
## Example Agent Workflow
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. See what's on screen
|
|
||||||
deskctl snapshot --annotate
|
|
||||||
|
|
||||||
# 2. Wait for the browser and focus it deterministically
|
|
||||||
deskctl wait window --selector 'class=firefox' --timeout 10
|
|
||||||
deskctl focus 'class=firefox'
|
|
||||||
|
|
||||||
# 3. Navigate to a URL
|
|
||||||
deskctl hotkey ctrl l
|
|
||||||
deskctl type "https://example.com"
|
|
||||||
deskctl press enter
|
|
||||||
|
|
||||||
# 4. Take a new snapshot to see the result
|
|
||||||
deskctl snapshot
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Names for press/hotkey
|
|
||||||
|
|
||||||
Modifiers: `ctrl`, `alt`, `shift`, `super`
|
|
||||||
Navigation: `enter`, `tab`, `escape`, `backspace`, `delete`, `space`
|
|
||||||
Arrows: `up`, `down`, `left`, `right`
|
|
||||||
Page: `home`, `end`, `pageup`, `pagedown`
|
|
||||||
Function: `f1` through `f12`
|
|
||||||
Characters: any single character (e.g. `a`, `1`, `/`)
|
|
||||||
60
skills/deskctl/SKILL.md
Normal file
60
skills/deskctl/SKILL.md
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
---
|
||||||
|
name: deskctl
|
||||||
|
description: Non-interactive X11 desktop control for AI agents. Use when the task involves controlling a Linux desktop - clicking, typing, reading windows, waiting for UI state, or taking screenshots inside a sandbox or VM.
|
||||||
|
allowed-tools: Bash(deskctl:*), Bash(npx deskctl:*), Bash(npm:*), Bash(which:*), Bash(printenv:*), Bash(echo:*)
|
||||||
|
---
|
||||||
|
|
||||||
|
# deskctl
|
||||||
|
|
||||||
|
Non-interactive desktop control CLI for Linux X11 agents.
|
||||||
|
|
||||||
|
All output follows the runtime contract defined in [references/runtime-contract.md](references/runtime-contract.md). Every command returns a stable JSON envelope when called with `--json`. Use `--json` whenever you need to parse output programmatically.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g deskctl
|
||||||
|
deskctl doctor
|
||||||
|
deskctl snapshot --annotate
|
||||||
|
```
|
||||||
|
|
||||||
|
If `deskctl` was installed through npm, refresh it later with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
deskctl upgrade --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent loop
|
||||||
|
|
||||||
|
Every desktop interaction follows: **observe -> wait -> act -> verify**.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
deskctl snapshot --annotate # observe
|
||||||
|
deskctl wait window --selector 'title=Chromium' --timeout 10 # wait
|
||||||
|
deskctl click 'title=Chromium' # act
|
||||||
|
deskctl snapshot # verify
|
||||||
|
```
|
||||||
|
|
||||||
|
See [workflows/observe-act.sh](workflows/observe-act.sh) for a reusable script. See [workflows/poll-condition.sh](workflows/poll-condition.sh) for polling loops.
|
||||||
|
|
||||||
|
## Selectors
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ref=w1 # snapshot ref (short-lived)
|
||||||
|
id=win1 # stable window ID (session-scoped)
|
||||||
|
title=Chromium # match by title
|
||||||
|
class=chromium # match by WM class
|
||||||
|
focused # currently focused window
|
||||||
|
```
|
||||||
|
|
||||||
|
Bare strings like `chromium` do fuzzy matching but fail on ambiguity. Prefer explicit selectors.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [references/runtime-contract.md](references/runtime-contract.md) - output contract, stable fields, error kinds
|
||||||
|
- [references/commands.md](references/commands.md) - all available commands
|
||||||
|
|
||||||
|
## Workflows
|
||||||
|
|
||||||
|
- [workflows/observe-act.sh](workflows/observe-act.sh) - main observe-act loop
|
||||||
|
- [workflows/poll-condition.sh](workflows/poll-condition.sh) - poll for a condition on screen
|
||||||
7
skills/deskctl/agents/openai.yaml
Normal file
7
skills/deskctl/agents/openai.yaml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
interface:
|
||||||
|
display_name: "deskctl"
|
||||||
|
short_description: "Control Linux X11 desktops from agent loops"
|
||||||
|
default_prompt: "Use $deskctl to diagnose the desktop, observe state, wait for UI changes, act deterministically, and verify the result."
|
||||||
|
|
||||||
|
policy:
|
||||||
|
allow_implicit_invocation: true
|
||||||
66
skills/deskctl/references/commands.md
Normal file
66
skills/deskctl/references/commands.md
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
# deskctl commands
|
||||||
|
|
||||||
|
All commands support `--json` for machine-parseable output following the
|
||||||
|
runtime contract.
|
||||||
|
|
||||||
|
## Observe
|
||||||
|
|
||||||
|
```bash
|
||||||
|
deskctl doctor
|
||||||
|
deskctl upgrade
|
||||||
|
deskctl snapshot
|
||||||
|
deskctl snapshot --annotate
|
||||||
|
deskctl list-windows
|
||||||
|
deskctl screenshot /tmp/screen.png
|
||||||
|
deskctl get active-window
|
||||||
|
deskctl get monitors
|
||||||
|
deskctl get version
|
||||||
|
deskctl get systeminfo
|
||||||
|
deskctl get-screen-size
|
||||||
|
deskctl get-mouse-position
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wait
|
||||||
|
|
||||||
|
```bash
|
||||||
|
deskctl wait window --selector 'title=Chromium' --timeout 10
|
||||||
|
deskctl wait focus --selector 'class=chromium' --timeout 5
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the matched window payload on success. Failures include structured
|
||||||
|
`kind` values in `--json` mode.
|
||||||
|
|
||||||
|
## Selectors
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ref=w1
|
||||||
|
id=win1
|
||||||
|
title=Chromium
|
||||||
|
class=chromium
|
||||||
|
focused
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy shorthand: `@w1`, `w1`, `win1`. Bare strings do fuzzy matching but fail
|
||||||
|
on ambiguity.
|
||||||
|
|
||||||
|
## Act
|
||||||
|
|
||||||
|
```bash
|
||||||
|
deskctl focus 'class=chromium'
|
||||||
|
deskctl click @w1
|
||||||
|
deskctl dblclick @w2
|
||||||
|
deskctl type "hello world"
|
||||||
|
deskctl press enter
|
||||||
|
deskctl hotkey ctrl shift t
|
||||||
|
deskctl mouse move 500 300
|
||||||
|
deskctl mouse scroll 3
|
||||||
|
deskctl mouse scroll 3 --axis horizontal
|
||||||
|
deskctl mouse drag 100 100 500 500
|
||||||
|
deskctl move-window @w1 100 120
|
||||||
|
deskctl resize-window @w1 1280 720
|
||||||
|
deskctl close @w3
|
||||||
|
deskctl launch chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
The daemon starts automatically on first command. In normal usage you should
|
||||||
|
not need to manage it directly.
|
||||||
73
skills/deskctl/references/runtime-contract.md
Normal file
73
skills/deskctl/references/runtime-contract.md
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
# deskctl runtime contract
|
||||||
|
|
||||||
|
This copy ships inside the installable skill so `npx skills add ...` installs a
|
||||||
|
self-contained reference bundle.
|
||||||
|
|
||||||
|
All commands support `--json` and use the same top-level envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {},
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--json` whenever you need to parse output programmatically.
|
||||||
|
|
||||||
|
## Stable window fields
|
||||||
|
|
||||||
|
Whenever a response includes a window payload, these fields are stable:
|
||||||
|
|
||||||
|
- `ref_id`
|
||||||
|
- `window_id`
|
||||||
|
- `title`
|
||||||
|
- `app_name`
|
||||||
|
- `x`
|
||||||
|
- `y`
|
||||||
|
- `width`
|
||||||
|
- `height`
|
||||||
|
- `focused`
|
||||||
|
- `minimized`
|
||||||
|
|
||||||
|
Use `window_id` for stable targeting inside a live daemon session. Use
|
||||||
|
`ref_id` or `@wN` for short-lived follow-up actions after `snapshot` or
|
||||||
|
`list-windows`.
|
||||||
|
|
||||||
|
## Stable grouped reads
|
||||||
|
|
||||||
|
- `deskctl get active-window` -> `data.window`
|
||||||
|
- `deskctl get monitors` -> `data.count`, `data.monitors`
|
||||||
|
- `deskctl get version` -> `data.version`, `data.backend`
|
||||||
|
- `deskctl get systeminfo` -> runtime-scoped diagnostic fields such as
|
||||||
|
`backend`, `display`, `session_type`, `session`, `socket_path`, `screen`,
|
||||||
|
`monitor_count`, and `monitors`
|
||||||
|
|
||||||
|
## Stable waits
|
||||||
|
|
||||||
|
- `deskctl wait window` -> `data.wait`, `data.selector`, `data.elapsed_ms`,
|
||||||
|
`data.window`
|
||||||
|
- `deskctl wait focus` -> `data.wait`, `data.selector`, `data.elapsed_ms`,
|
||||||
|
`data.window`
|
||||||
|
|
||||||
|
## Stable structured error kinds
|
||||||
|
|
||||||
|
When a command fails with structured JSON data, these `kind` values are stable:
|
||||||
|
|
||||||
|
- `selector_not_found`
|
||||||
|
- `selector_ambiguous`
|
||||||
|
- `selector_invalid`
|
||||||
|
- `timeout`
|
||||||
|
- `not_found`
|
||||||
|
|
||||||
|
Wait failures may also include `window_not_focused` in the last observation
|
||||||
|
payload.
|
||||||
|
|
||||||
|
## Best-effort fields
|
||||||
|
|
||||||
|
Treat these as useful but non-contractual:
|
||||||
|
|
||||||
|
- exact monitor names
|
||||||
|
- incidental text formatting in non-JSON mode
|
||||||
|
- default screenshot file names when no explicit path was provided
|
||||||
|
- environment-dependent ordering details from the window manager
|
||||||
37
skills/deskctl/workflows/observe-act.sh
Executable file
37
skills/deskctl/workflows/observe-act.sh
Executable file
|
|
@ -0,0 +1,37 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# observe-act.sh - main desktop interaction loop
|
||||||
|
# usage: ./observe-act.sh <selector> [action] [action-args...]
|
||||||
|
# example: ./observe-act.sh 'title=Chromium' click
|
||||||
|
# example: ./observe-act.sh 'class=terminal' type "ls -la"
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SELECTOR="${1:?usage: observe-act.sh <selector> [action] [action-args...]}"
|
||||||
|
ACTION="${2:-click}"
|
||||||
|
shift 2 2>/dev/null || true
|
||||||
|
|
||||||
|
# 1. observe - snapshot the desktop, get current state
|
||||||
|
echo "--- observe ---"
|
||||||
|
deskctl snapshot --annotate --json | head -1
|
||||||
|
deskctl get active-window
|
||||||
|
|
||||||
|
# 2. wait - ensure target exists
|
||||||
|
echo "--- wait ---"
|
||||||
|
deskctl wait window --selector "$SELECTOR" --timeout 10
|
||||||
|
|
||||||
|
# 3. act - perform the action on the target
|
||||||
|
echo "--- act ---"
|
||||||
|
case "$ACTION" in
|
||||||
|
click) deskctl click "$SELECTOR" ;;
|
||||||
|
dblclick) deskctl dblclick "$SELECTOR" ;;
|
||||||
|
focus) deskctl focus "$SELECTOR" ;;
|
||||||
|
type) deskctl focus "$SELECTOR" && deskctl type "$@" ;;
|
||||||
|
press) deskctl focus "$SELECTOR" && deskctl press "$@" ;;
|
||||||
|
hotkey) deskctl focus "$SELECTOR" && deskctl hotkey "$@" ;;
|
||||||
|
close) deskctl close "$SELECTOR" ;;
|
||||||
|
*) echo "unknown action: $ACTION"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# 4. verify - snapshot again to confirm result
|
||||||
|
echo "--- verify ---"
|
||||||
|
sleep 0.5
|
||||||
|
deskctl snapshot --json | head -1
|
||||||
42
skills/deskctl/workflows/poll-condition.sh
Executable file
42
skills/deskctl/workflows/poll-condition.sh
Executable file
|
|
@ -0,0 +1,42 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# poll-condition.sh - poll the desktop until a condition is met
|
||||||
|
# usage: ./poll-condition.sh <match-string> [interval-seconds] [max-attempts]
|
||||||
|
# example: ./poll-condition.sh "Tickets Available" 5 60
|
||||||
|
# example: ./poll-condition.sh "Order Confirmed" 3 20
|
||||||
|
# example: ./poll-condition.sh "Download Complete" 10 30
|
||||||
|
#
|
||||||
|
# checks window titles for the match string every N seconds.
|
||||||
|
# exits 0 when found, exits 1 after max attempts.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MATCH="${1:?usage: poll-condition.sh <match-string> [interval] [max-attempts]}"
|
||||||
|
INTERVAL="${2:-5}"
|
||||||
|
MAX="${3:-60}"
|
||||||
|
|
||||||
|
attempt=0
|
||||||
|
while [ "$attempt" -lt "$MAX" ]; do
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
|
||||||
|
# snapshot and check window titles
|
||||||
|
windows=$(deskctl list-windows --json 2>/dev/null || echo '{"success":false}')
|
||||||
|
if echo "$windows" | grep -qi "$MATCH"; then
|
||||||
|
echo "FOUND: '$MATCH' detected on attempt $attempt"
|
||||||
|
deskctl snapshot --annotate
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# also check screenshot text via active window title
|
||||||
|
active=$(deskctl get active-window --json 2>/dev/null || echo '{}')
|
||||||
|
if echo "$active" | grep -qi "$MATCH"; then
|
||||||
|
echo "FOUND: '$MATCH' in active window on attempt $attempt"
|
||||||
|
deskctl snapshot --annotate
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "attempt $attempt/$MAX - '$MATCH' not found, waiting ${INTERVAL}s..."
|
||||||
|
sleep "$INTERVAL"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "NOT FOUND: '$MATCH' after $MAX attempts"
|
||||||
|
deskctl snapshot --annotate
|
||||||
|
exit 1
|
||||||
187
src/cli/mod.rs
187
src/cli/mod.rs
|
|
@ -1,4 +1,5 @@
|
||||||
pub mod connection;
|
pub mod connection;
|
||||||
|
pub mod upgrade;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::{Args, Parser, Subcommand};
|
use clap::{Args, Parser, Subcommand};
|
||||||
|
|
@ -7,7 +8,12 @@ use std::path::PathBuf;
|
||||||
use crate::core::protocol::{Request, Response};
|
use crate::core::protocol::{Request, Response};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "deskctl", version, about = "Desktop control CLI for AI agents")]
|
#[command(
|
||||||
|
name = "deskctl",
|
||||||
|
bin_name = "deskctl",
|
||||||
|
version,
|
||||||
|
about = "Desktop control CLI for AI agents"
|
||||||
|
)]
|
||||||
pub struct App {
|
pub struct App {
|
||||||
#[command(flatten)]
|
#[command(flatten)]
|
||||||
pub global: GlobalOpts,
|
pub global: GlobalOpts,
|
||||||
|
|
@ -42,13 +48,13 @@ pub enum Command {
|
||||||
/// Click a window ref or coordinates
|
/// Click a window ref or coordinates
|
||||||
#[command(after_help = CLICK_EXAMPLES)]
|
#[command(after_help = CLICK_EXAMPLES)]
|
||||||
Click {
|
Click {
|
||||||
/// Selector (ref=w1, id=win1, title=Firefox, class=firefox, focused) or x,y coordinates
|
/// Selector (ref=w1, id=win1, title=Chromium, class=chromium, focused) or x,y coordinates
|
||||||
selector: String,
|
selector: String,
|
||||||
},
|
},
|
||||||
/// Double-click a window ref or coordinates
|
/// Double-click a window ref or coordinates
|
||||||
#[command(after_help = DBLCLICK_EXAMPLES)]
|
#[command(after_help = DBLCLICK_EXAMPLES)]
|
||||||
Dblclick {
|
Dblclick {
|
||||||
/// Selector (ref=w1, id=win1, title=Firefox, class=firefox, focused) or x,y coordinates
|
/// Selector (ref=w1, id=win1, title=Chromium, class=chromium, focused) or x,y coordinates
|
||||||
selector: String,
|
selector: String,
|
||||||
},
|
},
|
||||||
/// Type text into the focused window
|
/// Type text into the focused window
|
||||||
|
|
@ -75,19 +81,19 @@ pub enum Command {
|
||||||
/// Focus a window by ref or name
|
/// Focus a window by ref or name
|
||||||
#[command(after_help = FOCUS_EXAMPLES)]
|
#[command(after_help = FOCUS_EXAMPLES)]
|
||||||
Focus {
|
Focus {
|
||||||
/// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring
|
/// Selector: ref=w1, id=win1, title=Chromium, class=chromium, focused, or a fuzzy substring
|
||||||
selector: String,
|
selector: String,
|
||||||
},
|
},
|
||||||
/// Close a window by ref or name
|
/// Close a window by ref or name
|
||||||
#[command(after_help = CLOSE_EXAMPLES)]
|
#[command(after_help = CLOSE_EXAMPLES)]
|
||||||
Close {
|
Close {
|
||||||
/// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring
|
/// Selector: ref=w1, id=win1, title=Chromium, class=chromium, focused, or a fuzzy substring
|
||||||
selector: String,
|
selector: String,
|
||||||
},
|
},
|
||||||
/// Move a window
|
/// Move a window
|
||||||
#[command(after_help = MOVE_WINDOW_EXAMPLES)]
|
#[command(after_help = MOVE_WINDOW_EXAMPLES)]
|
||||||
MoveWindow {
|
MoveWindow {
|
||||||
/// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring
|
/// Selector: ref=w1, id=win1, title=Chromium, class=chromium, focused, or a fuzzy substring
|
||||||
selector: String,
|
selector: String,
|
||||||
/// X position
|
/// X position
|
||||||
x: i32,
|
x: i32,
|
||||||
|
|
@ -97,7 +103,7 @@ pub enum Command {
|
||||||
/// Resize a window
|
/// Resize a window
|
||||||
#[command(after_help = RESIZE_WINDOW_EXAMPLES)]
|
#[command(after_help = RESIZE_WINDOW_EXAMPLES)]
|
||||||
ResizeWindow {
|
ResizeWindow {
|
||||||
/// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring
|
/// Selector: ref=w1, id=win1, title=Chromium, class=chromium, focused, or a fuzzy substring
|
||||||
selector: String,
|
selector: String,
|
||||||
/// Width
|
/// Width
|
||||||
w: u32,
|
w: u32,
|
||||||
|
|
@ -116,6 +122,9 @@ pub enum Command {
|
||||||
/// Diagnose X11 runtime, screenshot, and daemon health
|
/// Diagnose X11 runtime, screenshot, and daemon health
|
||||||
#[command(after_help = DOCTOR_EXAMPLES)]
|
#[command(after_help = DOCTOR_EXAMPLES)]
|
||||||
Doctor,
|
Doctor,
|
||||||
|
/// Upgrade deskctl using the current install channel
|
||||||
|
#[command(after_help = UPGRADE_EXAMPLES)]
|
||||||
|
Upgrade(UpgradeOpts),
|
||||||
/// Query runtime state
|
/// Query runtime state
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
Get(GetCmd),
|
Get(GetCmd),
|
||||||
|
|
@ -201,19 +210,19 @@ const SNAPSHOT_EXAMPLES: &str =
|
||||||
const LIST_WINDOWS_EXAMPLES: &str =
|
const LIST_WINDOWS_EXAMPLES: &str =
|
||||||
"Examples:\n deskctl list-windows\n deskctl --json list-windows";
|
"Examples:\n deskctl list-windows\n deskctl --json list-windows";
|
||||||
const CLICK_EXAMPLES: &str =
|
const CLICK_EXAMPLES: &str =
|
||||||
"Examples:\n deskctl click @w1\n deskctl click 'title=Firefox'\n deskctl click 500,300";
|
"Examples:\n deskctl click @w1\n deskctl click 'title=Chromium'\n deskctl click 500,300";
|
||||||
const DBLCLICK_EXAMPLES: &str =
|
const DBLCLICK_EXAMPLES: &str =
|
||||||
"Examples:\n deskctl dblclick @w2\n deskctl dblclick 'class=firefox'\n deskctl dblclick 500,300";
|
"Examples:\n deskctl dblclick @w2\n deskctl dblclick 'class=chromium'\n deskctl dblclick 500,300";
|
||||||
const TYPE_EXAMPLES: &str =
|
const TYPE_EXAMPLES: &str =
|
||||||
"Examples:\n deskctl type \"hello world\"\n deskctl type \"https://example.com\"";
|
"Examples:\n deskctl type \"hello world\"\n deskctl type \"https://example.com\"";
|
||||||
const PRESS_EXAMPLES: &str = "Examples:\n deskctl press enter\n deskctl press escape";
|
const PRESS_EXAMPLES: &str = "Examples:\n deskctl press enter\n deskctl press escape";
|
||||||
const HOTKEY_EXAMPLES: &str = "Examples:\n deskctl hotkey ctrl l\n deskctl hotkey ctrl shift t";
|
const HOTKEY_EXAMPLES: &str = "Examples:\n deskctl hotkey ctrl l\n deskctl hotkey ctrl shift t";
|
||||||
const FOCUS_EXAMPLES: &str =
|
const FOCUS_EXAMPLES: &str =
|
||||||
"Examples:\n deskctl focus @w1\n deskctl focus 'title=Firefox'\n deskctl focus focused";
|
"Examples:\n deskctl focus @w1\n deskctl focus 'title=Chromium'\n deskctl focus focused";
|
||||||
const CLOSE_EXAMPLES: &str =
|
const CLOSE_EXAMPLES: &str =
|
||||||
"Examples:\n deskctl close @w3\n deskctl close 'id=win2'\n deskctl close 'class=firefox'";
|
"Examples:\n deskctl close @w3\n deskctl close 'id=win2'\n deskctl close 'class=chromium'";
|
||||||
const MOVE_WINDOW_EXAMPLES: &str =
|
const MOVE_WINDOW_EXAMPLES: &str =
|
||||||
"Examples:\n deskctl move-window @w1 100 200\n deskctl move-window 'title=Firefox' 0 0";
|
"Examples:\n deskctl move-window @w1 100 200\n deskctl move-window 'title=Chromium' 0 0";
|
||||||
const RESIZE_WINDOW_EXAMPLES: &str =
|
const RESIZE_WINDOW_EXAMPLES: &str =
|
||||||
"Examples:\n deskctl resize-window @w1 1280 720\n deskctl resize-window 'id=win2' 800 600";
|
"Examples:\n deskctl resize-window @w1 1280 720\n deskctl resize-window 'id=win2' 800 600";
|
||||||
const GET_MONITORS_EXAMPLES: &str =
|
const GET_MONITORS_EXAMPLES: &str =
|
||||||
|
|
@ -226,12 +235,14 @@ const GET_SCREEN_SIZE_EXAMPLES: &str =
|
||||||
const GET_MOUSE_POSITION_EXAMPLES: &str =
|
const GET_MOUSE_POSITION_EXAMPLES: &str =
|
||||||
"Examples:\n deskctl get-mouse-position\n deskctl --json get-mouse-position";
|
"Examples:\n deskctl get-mouse-position\n deskctl --json get-mouse-position";
|
||||||
const DOCTOR_EXAMPLES: &str = "Examples:\n deskctl doctor\n deskctl --json doctor";
|
const DOCTOR_EXAMPLES: &str = "Examples:\n deskctl doctor\n deskctl --json doctor";
|
||||||
const WAIT_WINDOW_EXAMPLES: &str = "Examples:\n deskctl wait window --selector 'title=Firefox' --timeout 10\n deskctl --json wait window --selector 'class=firefox' --poll-ms 100";
|
const UPGRADE_EXAMPLES: &str =
|
||||||
|
"Examples:\n deskctl upgrade\n deskctl upgrade --yes\n deskctl --json upgrade --yes";
|
||||||
|
const WAIT_WINDOW_EXAMPLES: &str = "Examples:\n deskctl wait window --selector 'title=Chromium' --timeout 10\n deskctl --json wait window --selector 'class=chromium' --poll-ms 100";
|
||||||
const WAIT_FOCUS_EXAMPLES: &str = "Examples:\n deskctl wait focus --selector 'id=win3' --timeout 5\n deskctl wait focus --selector focused --poll-ms 200";
|
const WAIT_FOCUS_EXAMPLES: &str = "Examples:\n deskctl wait focus --selector 'id=win3' --timeout 5\n deskctl wait focus --selector focused --poll-ms 200";
|
||||||
const SCREENSHOT_EXAMPLES: &str =
|
const SCREENSHOT_EXAMPLES: &str =
|
||||||
"Examples:\n deskctl screenshot\n deskctl screenshot /tmp/screen.png\n deskctl screenshot --annotate";
|
"Examples:\n deskctl screenshot\n deskctl screenshot /tmp/screen.png\n deskctl screenshot --annotate";
|
||||||
const LAUNCH_EXAMPLES: &str =
|
const LAUNCH_EXAMPLES: &str =
|
||||||
"Examples:\n deskctl launch firefox\n deskctl launch code -- --new-window";
|
"Examples:\n deskctl launch chromium\n deskctl launch code -- --new-window";
|
||||||
const MOUSE_MOVE_EXAMPLES: &str =
|
const MOUSE_MOVE_EXAMPLES: &str =
|
||||||
"Examples:\n deskctl mouse move 500 300\n deskctl mouse move 0 0";
|
"Examples:\n deskctl mouse move 500 300\n deskctl mouse move 0 0";
|
||||||
const MOUSE_SCROLL_EXAMPLES: &str =
|
const MOUSE_SCROLL_EXAMPLES: &str =
|
||||||
|
|
@ -266,7 +277,7 @@ pub enum WaitCmd {
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
pub struct WaitSelectorOpts {
|
pub struct WaitSelectorOpts {
|
||||||
/// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring
|
/// Selector: ref=w1, id=win1, title=Chromium, class=chromium, focused, or a fuzzy substring
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub selector: String,
|
pub selector: String,
|
||||||
|
|
||||||
|
|
@ -279,6 +290,13 @@ pub struct WaitSelectorOpts {
|
||||||
pub poll_ms: u64,
|
pub poll_ms: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct UpgradeOpts {
|
||||||
|
/// Skip confirmation and upgrade non-interactively
|
||||||
|
#[arg(long)]
|
||||||
|
pub yes: bool,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run() -> Result<()> {
|
pub fn run() -> Result<()> {
|
||||||
let app = App::parse();
|
let app = App::parse();
|
||||||
|
|
||||||
|
|
@ -295,6 +313,22 @@ pub fn run() -> Result<()> {
|
||||||
return connection::run_doctor(&app.global);
|
return connection::run_doctor(&app.global);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Command::Upgrade(ref upgrade_opts) = app.command {
|
||||||
|
let response = upgrade::run_upgrade(&app.global, upgrade_opts)?;
|
||||||
|
let success = response.success;
|
||||||
|
|
||||||
|
if app.global.json {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&response)?);
|
||||||
|
if !success {
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print_response(&app.command, &response)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
// All other commands need a daemon connection
|
// All other commands need a daemon connection
|
||||||
let request = build_request(&app.command)?;
|
let request = build_request(&app.command)?;
|
||||||
let response = connection::send_command(&app.global, &request)?;
|
let response = connection::send_command(&app.global, &request)?;
|
||||||
|
|
@ -358,6 +392,7 @@ fn build_request(cmd: &Command) -> Result<Request> {
|
||||||
Command::GetScreenSize => Request::new("get-screen-size"),
|
Command::GetScreenSize => Request::new("get-screen-size"),
|
||||||
Command::GetMousePosition => Request::new("get-mouse-position"),
|
Command::GetMousePosition => Request::new("get-mouse-position"),
|
||||||
Command::Doctor => unreachable!(),
|
Command::Doctor => unreachable!(),
|
||||||
|
Command::Upgrade(_) => unreachable!(),
|
||||||
Command::Get(sub) => match sub {
|
Command::Get(sub) => match sub {
|
||||||
GetCmd::ActiveWindow => Request::new("get-active-window"),
|
GetCmd::ActiveWindow => Request::new("get-active-window"),
|
||||||
GetCmd::Monitors => Request::new("get-monitors"),
|
GetCmd::Monitors => Request::new("get-monitors"),
|
||||||
|
|
@ -417,6 +452,7 @@ fn render_success_lines(cmd: &Command, data: Option<&serde_json::Value>) -> Resu
|
||||||
Command::Get(GetCmd::Systeminfo) => render_systeminfo_lines(data),
|
Command::Get(GetCmd::Systeminfo) => render_systeminfo_lines(data),
|
||||||
Command::GetScreenSize => vec![render_screen_size_line(data)],
|
Command::GetScreenSize => vec![render_screen_size_line(data)],
|
||||||
Command::GetMousePosition => vec![render_mouse_position_line(data)],
|
Command::GetMousePosition => vec![render_mouse_position_line(data)],
|
||||||
|
Command::Upgrade(_) => render_upgrade_lines(data),
|
||||||
Command::Screenshot { annotate, .. } => render_screenshot_lines(data, *annotate),
|
Command::Screenshot { annotate, .. } => render_screenshot_lines(data, *annotate),
|
||||||
Command::Click { .. } => vec![render_click_line(data, false)],
|
Command::Click { .. } => vec![render_click_line(data, false)],
|
||||||
Command::Dblclick { .. } => vec![render_click_line(data, true)],
|
Command::Dblclick { .. } => vec![render_click_line(data, true)],
|
||||||
|
|
@ -521,6 +557,41 @@ fn render_error_lines(response: &Response) -> Vec<String> {
|
||||||
lines.push("No focused window is available.".to_string());
|
lines.push("No focused window is available.".to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"upgrade_failed" => {
|
||||||
|
if let Some(reason) = data.get("io_error").and_then(|value| value.as_str()) {
|
||||||
|
lines.push(format!("Reason: {reason}"));
|
||||||
|
}
|
||||||
|
if let Some(reason) = data.get("reason").and_then(|value| value.as_str()) {
|
||||||
|
lines.push(format!("Reason: {reason}"));
|
||||||
|
}
|
||||||
|
if let Some(command) = data.get("command").and_then(|value| value.as_str()) {
|
||||||
|
lines.push(format!("Command: {command}"));
|
||||||
|
}
|
||||||
|
if let Some(hint) = data.get("hint").and_then(|value| value.as_str()) {
|
||||||
|
lines.push(format!("Hint: {hint}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"upgrade_unsupported" => {
|
||||||
|
if let Some(hint) = data.get("hint").and_then(|value| value.as_str()) {
|
||||||
|
lines.push(format!("Hint: {hint}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"upgrade_confirmation_required" => {
|
||||||
|
if let Some(current_version) =
|
||||||
|
data.get("current_version").and_then(|value| value.as_str())
|
||||||
|
{
|
||||||
|
if let Some(latest_version) =
|
||||||
|
data.get("latest_version").and_then(|value| value.as_str())
|
||||||
|
{
|
||||||
|
lines.push(format!(
|
||||||
|
"Update available: {current_version} -> {latest_version}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(hint) = data.get("hint").and_then(|value| value.as_str()) {
|
||||||
|
lines.push(format!("Hint: {hint}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -718,6 +789,36 @@ fn render_screenshot_lines(data: &serde_json::Value, annotate: bool) -> Vec<Stri
|
||||||
lines
|
lines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_upgrade_lines(data: &serde_json::Value) -> Vec<String> {
|
||||||
|
match data.get("status").and_then(|value| value.as_str()) {
|
||||||
|
Some("up_to_date") => {
|
||||||
|
let version = data
|
||||||
|
.get("latest_version")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.or_else(|| data.get("current_version").and_then(|value| value.as_str()))
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
vec![format!(
|
||||||
|
"✔ You're already on the latest version! ({version})"
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
Some("upgraded") => {
|
||||||
|
let current_version = data
|
||||||
|
.get("current_version")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let latest_version = data
|
||||||
|
.get("latest_version")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
vec![format!(
|
||||||
|
"✔ Upgraded deskctl from {current_version} -> {latest_version}"
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
Some("cancelled") => vec!["No changes made.".to_string()],
|
||||||
|
_ => vec!["Upgrade completed.".to_string()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn render_click_line(data: &serde_json::Value, double: bool) -> String {
|
fn render_click_line(data: &serde_json::Value, double: bool) -> String {
|
||||||
let action = if double { "Double-clicked" } else { "Clicked" };
|
let action = if double { "Double-clicked" } else { "Clicked" };
|
||||||
let key = if double { "double_clicked" } else { "clicked" };
|
let key = if double { "double_clicked" } else { "clicked" };
|
||||||
|
|
@ -973,7 +1074,7 @@ fn truncate_display(value: &str, max_chars: usize) -> String {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
render_error_lines, render_screen_size_line, render_success_lines, target_summary,
|
render_error_lines, render_screen_size_line, render_success_lines, target_summary,
|
||||||
truncate_display, App, Command, Response,
|
truncate_display, App, Command, Response, UpgradeOpts,
|
||||||
};
|
};
|
||||||
use clap::CommandFactory;
|
use clap::CommandFactory;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
@ -988,6 +1089,12 @@ mod tests {
|
||||||
assert!(help.contains("deskctl snapshot --annotate"));
|
assert!(help.contains("deskctl snapshot --annotate"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn root_help_uses_public_bin_name() {
|
||||||
|
let help = App::command().render_help().to_string();
|
||||||
|
assert!(help.contains("Usage: deskctl [OPTIONS] <COMMAND>"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn window_listing_text_includes_window_ids() {
|
fn window_listing_text_includes_window_ids() {
|
||||||
let lines = render_success_lines(
|
let lines = render_success_lines(
|
||||||
|
|
@ -996,8 +1103,8 @@ mod tests {
|
||||||
"windows": [{
|
"windows": [{
|
||||||
"ref_id": "w1",
|
"ref_id": "w1",
|
||||||
"window_id": "win1",
|
"window_id": "win1",
|
||||||
"title": "Firefox",
|
"title": "Chromium",
|
||||||
"app_name": "firefox",
|
"app_name": "chromium",
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
"width": 1280,
|
"width": 1280,
|
||||||
|
|
@ -1018,37 +1125,37 @@ mod tests {
|
||||||
fn action_text_includes_target_identity() {
|
fn action_text_includes_target_identity() {
|
||||||
let lines = render_success_lines(
|
let lines = render_success_lines(
|
||||||
&Command::Focus {
|
&Command::Focus {
|
||||||
selector: "title=Firefox".to_string(),
|
selector: "title=Chromium".to_string(),
|
||||||
},
|
},
|
||||||
Some(&json!({
|
Some(&json!({
|
||||||
"action": "focus",
|
"action": "focus",
|
||||||
"window": "Firefox",
|
"window": "Chromium",
|
||||||
"title": "Firefox",
|
"title": "Chromium",
|
||||||
"ref_id": "w2",
|
"ref_id": "w2",
|
||||||
"window_id": "win7"
|
"window_id": "win7"
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(lines, vec!["Focused @w2 [win7] \"Firefox\""]);
|
assert_eq!(lines, vec!["Focused @w2 [win7] \"Chromium\""]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn timeout_errors_render_last_observation() {
|
fn timeout_errors_render_last_observation() {
|
||||||
let lines = render_error_lines(&Response::err_with_data(
|
let lines = render_error_lines(&Response::err_with_data(
|
||||||
"Timed out waiting for focus to match selector: title=Firefox",
|
"Timed out waiting for focus to match selector: title=Chromium",
|
||||||
json!({
|
json!({
|
||||||
"kind": "timeout",
|
"kind": "timeout",
|
||||||
"wait": "focus",
|
"wait": "focus",
|
||||||
"selector": "title=Firefox",
|
"selector": "title=Chromium",
|
||||||
"timeout_ms": 1000,
|
"timeout_ms": 1000,
|
||||||
"last_observation": {
|
"last_observation": {
|
||||||
"kind": "window_not_focused",
|
"kind": "window_not_focused",
|
||||||
"window": {
|
"window": {
|
||||||
"ref_id": "w1",
|
"ref_id": "w1",
|
||||||
"window_id": "win1",
|
"window_id": "win1",
|
||||||
"title": "Firefox",
|
"title": "Chromium",
|
||||||
"app_name": "firefox",
|
"app_name": "chromium",
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
"width": 1280,
|
"width": 1280,
|
||||||
|
|
@ -1060,10 +1167,8 @@ mod tests {
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
|
|
||||||
assert!(lines
|
assert!(lines.iter().any(|line| line
|
||||||
.iter()
|
.contains("Timed out after 1000ms waiting for focus selector title=Chromium")));
|
||||||
.any(|line| line
|
|
||||||
.contains("Timed out after 1000ms waiting for focus selector title=Firefox")));
|
|
||||||
assert!(lines
|
assert!(lines
|
||||||
.iter()
|
.iter()
|
||||||
.any(|line| line.contains("matching window exists but is not focused yet")));
|
.any(|line| line.contains("matching window exists but is not focused yet")));
|
||||||
|
|
@ -1083,9 +1188,9 @@ mod tests {
|
||||||
let summary = target_summary(&json!({
|
let summary = target_summary(&json!({
|
||||||
"ref_id": "w1",
|
"ref_id": "w1",
|
||||||
"window_id": "win1",
|
"window_id": "win1",
|
||||||
"title": "Firefox"
|
"title": "Chromium"
|
||||||
}));
|
}));
|
||||||
assert_eq!(summary.as_deref(), Some("@w1 [win1] \"Firefox\""));
|
assert_eq!(summary.as_deref(), Some("@w1 [win1] \"Chromium\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1093,4 +1198,22 @@ mod tests {
|
||||||
let input = format!("fire{}fox", '\u{00E9}');
|
let input = format!("fire{}fox", '\u{00E9}');
|
||||||
assert_eq!(truncate_display(&input, 7), "fire...");
|
assert_eq!(truncate_display(&input, 7), "fire...");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upgrade_success_text_is_neat() {
|
||||||
|
let lines = render_success_lines(
|
||||||
|
&Command::Upgrade(UpgradeOpts { yes: false }),
|
||||||
|
Some(&json!({
|
||||||
|
"status": "up_to_date",
|
||||||
|
"current_version": "0.1.8",
|
||||||
|
"latest_version": "0.1.8"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
lines,
|
||||||
|
vec!["✔ You're already on the latest version! (0.1.8)"]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
465
src/cli/upgrade.rs
Normal file
465
src/cli/upgrade.rs
Normal file
|
|
@ -0,0 +1,465 @@
|
||||||
|
use std::io::{self, IsTerminal, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::cli::{GlobalOpts, UpgradeOpts};
|
||||||
|
use crate::core::protocol::Response;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum InstallMethod {
|
||||||
|
Npm,
|
||||||
|
Cargo,
|
||||||
|
Nix,
|
||||||
|
Source,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InstallMethod {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Npm => "npm",
|
||||||
|
Self::Cargo => "cargo",
|
||||||
|
Self::Nix => "nix",
|
||||||
|
Self::Source => "source",
|
||||||
|
Self::Unknown => "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct UpgradePlan {
|
||||||
|
install_method: InstallMethod,
|
||||||
|
program: &'static str,
|
||||||
|
args: Vec<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpgradePlan {
|
||||||
|
fn command_line(&self) -> String {
|
||||||
|
std::iter::once(self.program)
|
||||||
|
.chain(self.args.iter().copied())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct VersionInfo {
|
||||||
|
current: String,
|
||||||
|
latest: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_upgrade(opts: &GlobalOpts, upgrade_opts: &UpgradeOpts) -> Result<Response> {
|
||||||
|
let current_exe = std::env::current_exe().context("Failed to determine executable path")?;
|
||||||
|
let install_method = detect_install_method(¤t_exe);
|
||||||
|
|
||||||
|
let Some(plan) = upgrade_plan(install_method) else {
|
||||||
|
return Ok(Response::err_with_data(
|
||||||
|
format!(
|
||||||
|
"deskctl upgrade is not supported for {} installs.",
|
||||||
|
install_method.as_str()
|
||||||
|
),
|
||||||
|
json!({
|
||||||
|
"kind": "upgrade_unsupported",
|
||||||
|
"install_method": install_method.as_str(),
|
||||||
|
"current_exe": current_exe.display().to_string(),
|
||||||
|
"hint": upgrade_hint(install_method),
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
if !opts.json {
|
||||||
|
println!("- Checking for updates...");
|
||||||
|
}
|
||||||
|
|
||||||
|
let versions = match resolve_versions(&plan) {
|
||||||
|
Ok(versions) => versions,
|
||||||
|
Err(response) => return Ok(response),
|
||||||
|
};
|
||||||
|
|
||||||
|
if versions.current == versions.latest {
|
||||||
|
return Ok(Response::ok(json!({
|
||||||
|
"action": "upgrade",
|
||||||
|
"status": "up_to_date",
|
||||||
|
"install_method": plan.install_method.as_str(),
|
||||||
|
"current_version": versions.current,
|
||||||
|
"latest_version": versions.latest,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !upgrade_opts.yes {
|
||||||
|
if opts.json || !io::stdin().is_terminal() {
|
||||||
|
return Ok(Response::err_with_data(
|
||||||
|
format!(
|
||||||
|
"Upgrade confirmation required for {} -> {}.",
|
||||||
|
versions.current, versions.latest
|
||||||
|
),
|
||||||
|
json!({
|
||||||
|
"kind": "upgrade_confirmation_required",
|
||||||
|
"install_method": plan.install_method.as_str(),
|
||||||
|
"current_version": versions.current,
|
||||||
|
"latest_version": versions.latest,
|
||||||
|
"command": plan.command_line(),
|
||||||
|
"hint": "Re-run with --yes to upgrade non-interactively.",
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !confirm_upgrade(&versions)? {
|
||||||
|
return Ok(Response::ok(json!({
|
||||||
|
"action": "upgrade",
|
||||||
|
"status": "cancelled",
|
||||||
|
"install_method": plan.install_method.as_str(),
|
||||||
|
"current_version": versions.current,
|
||||||
|
"latest_version": versions.latest,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.json {
|
||||||
|
println!(
|
||||||
|
"- Upgrading deskctl from {} -> {}...",
|
||||||
|
versions.current, versions.latest
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = match Command::new(plan.program).args(&plan.args).output() {
|
||||||
|
Ok(output) => output,
|
||||||
|
Err(error) => return Ok(upgrade_spawn_error_response(&plan, &versions, &error)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
return Ok(Response::ok(json!({
|
||||||
|
"action": "upgrade",
|
||||||
|
"status": "upgraded",
|
||||||
|
"install_method": plan.install_method.as_str(),
|
||||||
|
"current_version": versions.current,
|
||||||
|
"latest_version": versions.latest,
|
||||||
|
"command": plan.command_line(),
|
||||||
|
"exit_code": output.status.code(),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(upgrade_command_failed_response(&plan, &versions, &output))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_versions(plan: &UpgradePlan) -> std::result::Result<VersionInfo, Response> {
|
||||||
|
let current = env!("CARGO_PKG_VERSION").to_string();
|
||||||
|
let latest = match plan.install_method {
|
||||||
|
InstallMethod::Npm => query_npm_latest_version()?,
|
||||||
|
InstallMethod::Cargo => query_cargo_latest_version()?,
|
||||||
|
InstallMethod::Nix | InstallMethod::Source | InstallMethod::Unknown => {
|
||||||
|
return Err(Response::err_with_data(
|
||||||
|
"Could not determine the latest published version.".to_string(),
|
||||||
|
json!({
|
||||||
|
"kind": "upgrade_failed",
|
||||||
|
"install_method": plan.install_method.as_str(),
|
||||||
|
"reason": "Could not determine the latest published version for this install method.",
|
||||||
|
"command": plan.command_line(),
|
||||||
|
"hint": upgrade_hint(plan.install_method),
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(VersionInfo { current, latest })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_npm_latest_version() -> std::result::Result<String, Response> {
|
||||||
|
let output = Command::new("npm")
|
||||||
|
.args(["view", "deskctl", "version", "--json"])
|
||||||
|
.output()
|
||||||
|
.map_err(|error| {
|
||||||
|
Response::err_with_data(
|
||||||
|
"Failed to check the latest npm version.".to_string(),
|
||||||
|
json!({
|
||||||
|
"kind": "upgrade_failed",
|
||||||
|
"install_method": InstallMethod::Npm.as_str(),
|
||||||
|
"reason": "Failed to run npm view deskctl version --json.",
|
||||||
|
"io_error": error.to_string(),
|
||||||
|
"command": "npm view deskctl version --json",
|
||||||
|
"hint": upgrade_hint(InstallMethod::Npm),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(Response::err_with_data(
|
||||||
|
"Failed to check the latest npm version.".to_string(),
|
||||||
|
json!({
|
||||||
|
"kind": "upgrade_failed",
|
||||||
|
"install_method": InstallMethod::Npm.as_str(),
|
||||||
|
"reason": command_failure_reason(&output),
|
||||||
|
"command": "npm view deskctl version --json",
|
||||||
|
"hint": upgrade_hint(InstallMethod::Npm),
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::from_slice::<String>(&output.stdout).map_err(|_| {
|
||||||
|
Response::err_with_data(
|
||||||
|
"Failed to parse the latest npm version.".to_string(),
|
||||||
|
json!({
|
||||||
|
"kind": "upgrade_failed",
|
||||||
|
"install_method": InstallMethod::Npm.as_str(),
|
||||||
|
"reason": "npm view returned an unexpected version payload.",
|
||||||
|
"command": "npm view deskctl version --json",
|
||||||
|
"hint": upgrade_hint(InstallMethod::Npm),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn query_cargo_latest_version() -> std::result::Result<String, Response> {
|
||||||
|
let output = Command::new("cargo")
|
||||||
|
.args(["search", "deskctl", "--limit", "1"])
|
||||||
|
.output()
|
||||||
|
.map_err(|error| {
|
||||||
|
Response::err_with_data(
|
||||||
|
"Failed to check the latest crates.io version.".to_string(),
|
||||||
|
json!({
|
||||||
|
"kind": "upgrade_failed",
|
||||||
|
"install_method": InstallMethod::Cargo.as_str(),
|
||||||
|
"reason": "Failed to run cargo search deskctl --limit 1.",
|
||||||
|
"io_error": error.to_string(),
|
||||||
|
"command": "cargo search deskctl --limit 1",
|
||||||
|
"hint": upgrade_hint(InstallMethod::Cargo),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(Response::err_with_data(
|
||||||
|
"Failed to check the latest crates.io version.".to_string(),
|
||||||
|
json!({
|
||||||
|
"kind": "upgrade_failed",
|
||||||
|
"install_method": InstallMethod::Cargo.as_str(),
|
||||||
|
"reason": command_failure_reason(&output),
|
||||||
|
"command": "cargo search deskctl --limit 1",
|
||||||
|
"hint": upgrade_hint(InstallMethod::Cargo),
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let latest = stdout
|
||||||
|
.split('"')
|
||||||
|
.nth(1)
|
||||||
|
.map(str::to_string)
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
|
||||||
|
latest.ok_or_else(|| {
|
||||||
|
Response::err_with_data(
|
||||||
|
"Failed to determine the latest crates.io version.".to_string(),
|
||||||
|
json!({
|
||||||
|
"kind": "upgrade_failed",
|
||||||
|
"install_method": InstallMethod::Cargo.as_str(),
|
||||||
|
"reason": "cargo search did not return a published deskctl crate version.",
|
||||||
|
"command": "cargo search deskctl --limit 1",
|
||||||
|
"hint": upgrade_hint(InstallMethod::Cargo),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn confirm_upgrade(versions: &VersionInfo) -> Result<bool> {
|
||||||
|
print!(
|
||||||
|
"Upgrade deskctl from {} -> {}? [y/N] ",
|
||||||
|
versions.current, versions.latest
|
||||||
|
);
|
||||||
|
io::stdout().flush()?;
|
||||||
|
|
||||||
|
let mut input = String::new();
|
||||||
|
io::stdin().read_line(&mut input)?;
|
||||||
|
|
||||||
|
let trimmed = input.trim();
|
||||||
|
Ok(matches!(trimmed, "y" | "Y" | "yes" | "YES" | "Yes"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upgrade_command_failed_response(
|
||||||
|
plan: &UpgradePlan,
|
||||||
|
versions: &VersionInfo,
|
||||||
|
output: &std::process::Output,
|
||||||
|
) -> Response {
|
||||||
|
Response::err_with_data(
|
||||||
|
format!("Upgrade command failed: {}", plan.command_line()),
|
||||||
|
json!({
|
||||||
|
"kind": "upgrade_failed",
|
||||||
|
"install_method": plan.install_method.as_str(),
|
||||||
|
"current_version": versions.current,
|
||||||
|
"latest_version": versions.latest,
|
||||||
|
"command": plan.command_line(),
|
||||||
|
"exit_code": output.status.code(),
|
||||||
|
"reason": command_failure_reason(output),
|
||||||
|
"hint": upgrade_hint(plan.install_method),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upgrade_spawn_error_response(
|
||||||
|
plan: &UpgradePlan,
|
||||||
|
versions: &VersionInfo,
|
||||||
|
error: &std::io::Error,
|
||||||
|
) -> Response {
|
||||||
|
Response::err_with_data(
|
||||||
|
format!("Failed to run {}", plan.command_line()),
|
||||||
|
json!({
|
||||||
|
"kind": "upgrade_failed",
|
||||||
|
"install_method": plan.install_method.as_str(),
|
||||||
|
"current_version": versions.current,
|
||||||
|
"latest_version": versions.latest,
|
||||||
|
"command": plan.command_line(),
|
||||||
|
"io_error": error.to_string(),
|
||||||
|
"hint": upgrade_hint(plan.install_method),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_failure_reason(output: &std::process::Output) -> String {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
|
||||||
|
stderr
|
||||||
|
.lines()
|
||||||
|
.chain(stdout.lines())
|
||||||
|
.map(str::trim)
|
||||||
|
.find(|line| !line.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
output
|
||||||
|
.status
|
||||||
|
.code()
|
||||||
|
.map(|code| format!("Command exited with status {code}."))
|
||||||
|
.unwrap_or_else(|| "Command exited unsuccessfully.".to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upgrade_plan(install_method: InstallMethod) -> Option<UpgradePlan> {
|
||||||
|
match install_method {
|
||||||
|
InstallMethod::Npm => Some(UpgradePlan {
|
||||||
|
install_method,
|
||||||
|
program: "npm",
|
||||||
|
args: vec!["install", "-g", "deskctl@latest"],
|
||||||
|
}),
|
||||||
|
InstallMethod::Cargo => Some(UpgradePlan {
|
||||||
|
install_method,
|
||||||
|
program: "cargo",
|
||||||
|
args: vec!["install", "deskctl", "--locked"],
|
||||||
|
}),
|
||||||
|
InstallMethod::Nix | InstallMethod::Source | InstallMethod::Unknown => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upgrade_hint(install_method: InstallMethod) -> &'static str {
|
||||||
|
match install_method {
|
||||||
|
InstallMethod::Nix => {
|
||||||
|
"Use nix profile upgrade or update the flake reference you installed from."
|
||||||
|
}
|
||||||
|
InstallMethod::Source => {
|
||||||
|
"Rebuild from source or reinstall deskctl through npm, cargo, or nix."
|
||||||
|
}
|
||||||
|
InstallMethod::Unknown => {
|
||||||
|
"Reinstall deskctl through a supported channel such as npm, cargo, or nix."
|
||||||
|
}
|
||||||
|
InstallMethod::Npm => "Retry with --yes or run npm install -g deskctl@latest directly.",
|
||||||
|
InstallMethod::Cargo => "Retry with --yes or run cargo install deskctl --locked directly.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_install_method(current_exe: &Path) -> InstallMethod {
|
||||||
|
if looks_like_npm_install(current_exe) {
|
||||||
|
return InstallMethod::Npm;
|
||||||
|
}
|
||||||
|
if looks_like_nix_install(current_exe) {
|
||||||
|
return InstallMethod::Nix;
|
||||||
|
}
|
||||||
|
if looks_like_cargo_install(current_exe) {
|
||||||
|
return InstallMethod::Cargo;
|
||||||
|
}
|
||||||
|
if looks_like_source_tree(current_exe) {
|
||||||
|
return InstallMethod::Source;
|
||||||
|
}
|
||||||
|
InstallMethod::Unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
fn looks_like_npm_install(path: &Path) -> bool {
|
||||||
|
let value = normalize(path);
|
||||||
|
value.contains("/node_modules/deskctl/") && value.contains("/vendor/")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn looks_like_nix_install(path: &Path) -> bool {
|
||||||
|
normalize(path).starts_with("/nix/store/")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn looks_like_cargo_install(path: &Path) -> bool {
|
||||||
|
let Some(home) = std::env::var_os("HOME") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
let cargo_home = std::env::var_os("CARGO_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| PathBuf::from(home).join(".cargo"));
|
||||||
|
path == cargo_home.join("bin").join("deskctl")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn looks_like_source_tree(path: &Path) -> bool {
|
||||||
|
let value = normalize(path);
|
||||||
|
value.contains("/target/debug/deskctl") || value.contains("/target/release/deskctl")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize(path: &Path) -> String {
|
||||||
|
path.to_string_lossy().replace('\\', "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::os::unix::process::ExitStatusExt;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use super::{command_failure_reason, detect_install_method, upgrade_plan, InstallMethod};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_npm_install_path() {
|
||||||
|
let method = detect_install_method(Path::new(
|
||||||
|
"/usr/local/lib/node_modules/deskctl/vendor/deskctl-linux-x86_64",
|
||||||
|
));
|
||||||
|
assert_eq!(method, InstallMethod::Npm);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_nix_install_path() {
|
||||||
|
let method = detect_install_method(Path::new("/nix/store/abc123-deskctl/bin/deskctl"));
|
||||||
|
assert_eq!(method, InstallMethod::Nix);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_source_tree_path() {
|
||||||
|
let method =
|
||||||
|
detect_install_method(Path::new("/Users/example/src/deskctl/target/debug/deskctl"));
|
||||||
|
assert_eq!(method, InstallMethod::Source);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn npm_upgrade_plan_uses_global_install() {
|
||||||
|
let plan = upgrade_plan(InstallMethod::Npm).expect("npm installs should support upgrade");
|
||||||
|
assert_eq!(plan.command_line(), "npm install -g deskctl@latest");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nix_install_has_no_upgrade_plan() {
|
||||||
|
assert!(upgrade_plan(InstallMethod::Nix).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failure_reason_prefers_stderr() {
|
||||||
|
let output = std::process::Output {
|
||||||
|
status: std::process::ExitStatus::from_raw(1 << 8),
|
||||||
|
stdout: b"".to_vec(),
|
||||||
|
stderr: b"boom\n".to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(command_failure_reason(&output), "boom");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -412,8 +412,8 @@ mod tests {
|
||||||
SelectorQuery::WindowId("win4".to_string())
|
SelectorQuery::WindowId("win4".to_string())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
SelectorQuery::parse("title=Firefox"),
|
SelectorQuery::parse("title=Chromium"),
|
||||||
SelectorQuery::Title("Firefox".to_string())
|
SelectorQuery::Title("Chromium".to_string())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
SelectorQuery::parse("class=Navigator"),
|
SelectorQuery::parse("class=Navigator"),
|
||||||
|
|
@ -458,11 +458,11 @@ mod tests {
|
||||||
fn fuzzy_resolution_fails_with_candidates_when_ambiguous() {
|
fn fuzzy_resolution_fails_with_candidates_when_ambiguous() {
|
||||||
let mut refs = RefMap::new();
|
let mut refs = RefMap::new();
|
||||||
refs.rebuild(&[
|
refs.rebuild(&[
|
||||||
sample_window(1, "Firefox"),
|
sample_window(1, "Chromium"),
|
||||||
BackendWindow {
|
BackendWindow {
|
||||||
native_id: 2,
|
native_id: 2,
|
||||||
title: "Firefox Settings".to_string(),
|
title: "Chromium Settings".to_string(),
|
||||||
app_name: "Firefox".to_string(),
|
app_name: "Chromium".to_string(),
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
width: 10,
|
width: 10,
|
||||||
|
|
@ -472,7 +472,7 @@ mod tests {
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
match refs.resolve("firefox") {
|
match refs.resolve("chromium") {
|
||||||
ResolveResult::Ambiguous {
|
ResolveResult::Ambiguous {
|
||||||
mode, candidates, ..
|
mode, candidates, ..
|
||||||
} => {
|
} => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
mod handler;
|
mod handler;
|
||||||
mod state;
|
mod state;
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
|
@ -12,6 +13,29 @@ use crate::core::paths::{pid_path_from_env, socket_path_from_env};
|
||||||
use crate::core::session;
|
use crate::core::session;
|
||||||
use state::DaemonState;
|
use state::DaemonState;
|
||||||
|
|
||||||
|
struct RuntimePathsGuard {
|
||||||
|
socket_path: PathBuf,
|
||||||
|
pid_path: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimePathsGuard {
|
||||||
|
fn new(socket_path: PathBuf, pid_path: Option<PathBuf>) -> Self {
|
||||||
|
Self {
|
||||||
|
socket_path,
|
||||||
|
pid_path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for RuntimePathsGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
remove_runtime_path(&self.socket_path);
|
||||||
|
if let Some(ref pid_path) = self.pid_path {
|
||||||
|
remove_runtime_path(pid_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run() -> Result<()> {
|
pub fn run() -> Result<()> {
|
||||||
// Validate session before starting
|
// Validate session before starting
|
||||||
session::detect_session()?;
|
session::detect_session()?;
|
||||||
|
|
@ -25,7 +49,6 @@ pub fn run() -> Result<()> {
|
||||||
|
|
||||||
async fn async_run() -> Result<()> {
|
async fn async_run() -> Result<()> {
|
||||||
let socket_path = socket_path_from_env().context("DESKCTL_SOCKET_PATH not set")?;
|
let socket_path = socket_path_from_env().context("DESKCTL_SOCKET_PATH not set")?;
|
||||||
|
|
||||||
let pid_path = pid_path_from_env();
|
let pid_path = pid_path_from_env();
|
||||||
|
|
||||||
// Clean up stale socket
|
// Clean up stale socket
|
||||||
|
|
@ -33,20 +56,21 @@ async fn async_run() -> Result<()> {
|
||||||
std::fs::remove_file(&socket_path)?;
|
std::fs::remove_file(&socket_path)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write PID file
|
|
||||||
if let Some(ref pid_path) = pid_path {
|
|
||||||
std::fs::write(pid_path, std::process::id().to_string())?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let listener = UnixListener::bind(&socket_path)
|
|
||||||
.context(format!("Failed to bind socket: {}", socket_path.display()))?;
|
|
||||||
|
|
||||||
let session = std::env::var("DESKCTL_SESSION").unwrap_or_else(|_| "default".to_string());
|
let session = std::env::var("DESKCTL_SESSION").unwrap_or_else(|_| "default".to_string());
|
||||||
let state = Arc::new(Mutex::new(
|
let state = Arc::new(Mutex::new(
|
||||||
DaemonState::new(session, socket_path.clone())
|
DaemonState::new(session, socket_path.clone())
|
||||||
.context("Failed to initialize daemon state")?,
|
.context("Failed to initialize daemon state")?,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
let listener = UnixListener::bind(&socket_path)
|
||||||
|
.context(format!("Failed to bind socket: {}", socket_path.display()))?;
|
||||||
|
let _runtime_paths = RuntimePathsGuard::new(socket_path.clone(), pid_path.clone());
|
||||||
|
|
||||||
|
// Write PID file only after the daemon is ready to serve requests.
|
||||||
|
if let Some(ref pid_path) = pid_path {
|
||||||
|
std::fs::write(pid_path, std::process::id().to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
let shutdown = Arc::new(tokio::sync::Notify::new());
|
let shutdown = Arc::new(tokio::sync::Notify::new());
|
||||||
let shutdown_clone = shutdown.clone();
|
let shutdown_clone = shutdown.clone();
|
||||||
|
|
||||||
|
|
@ -75,14 +99,6 @@ async fn async_run() -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
if socket_path.exists() {
|
|
||||||
let _ = std::fs::remove_file(&socket_path);
|
|
||||||
}
|
|
||||||
if let Some(ref pid_path) = pid_path {
|
|
||||||
let _ = std::fs::remove_file(pid_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,3 +139,11 @@ async fn handle_connection(
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn remove_runtime_path(path: &Path) {
|
||||||
|
if let Err(error) = std::fs::remove_file(path) {
|
||||||
|
if error.kind() != std::io::ErrorKind::NotFound {
|
||||||
|
eprintln!("Failed to remove runtime path {}: {error}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ use std::os::unix::net::UnixListener;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Output};
|
use std::process::{Command, Output};
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
use std::thread;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
|
|
@ -60,8 +61,7 @@ pub struct FixtureWindow {
|
||||||
|
|
||||||
impl FixtureWindow {
|
impl FixtureWindow {
|
||||||
pub fn create(title: &str, app_class: &str) -> Result<Self> {
|
pub fn create(title: &str, app_class: &str) -> Result<Self> {
|
||||||
let (conn, screen_num) =
|
let (conn, screen_num) = connect_to_test_display()?;
|
||||||
x11rb::connect(None).context("Failed to connect to the integration test display")?;
|
|
||||||
let screen = &conn.setup().roots[screen_num];
|
let screen = &conn.setup().roots[screen_num];
|
||||||
let window = conn.generate_id()?;
|
let window = conn.generate_id()?;
|
||||||
|
|
||||||
|
|
@ -103,6 +103,26 @@ impl FixtureWindow {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn connect_to_test_display() -> Result<(RustConnection, usize)> {
|
||||||
|
let max_attempts = 10;
|
||||||
|
let mut last_error = None;
|
||||||
|
|
||||||
|
for attempt in 0..max_attempts {
|
||||||
|
match x11rb::connect(None) {
|
||||||
|
Ok(connection) => return Ok(connection),
|
||||||
|
Err(error) => {
|
||||||
|
last_error = Some(anyhow!(error));
|
||||||
|
if attempt + 1 < max_attempts {
|
||||||
|
thread::sleep(std::time::Duration::from_millis(100 * (attempt + 1) as u64));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(last_error.expect("x11 connection attempts should capture an error"))
|
||||||
|
.context("Failed to connect to the integration test display")
|
||||||
|
}
|
||||||
|
|
||||||
impl Drop for FixtureWindow {
|
impl Drop for FixtureWindow {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let _ = self.conn.destroy_window(self.window);
|
let _ = self.conn.destroy_window(self.window);
|
||||||
|
|
@ -142,6 +162,10 @@ impl TestSession {
|
||||||
.expect("TestSession always has an explicit socket path")
|
.expect("TestSession always has an explicit socket path")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn pid_path(&self) -> PathBuf {
|
||||||
|
self.root.join("deskctl.pid")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn create_stale_socket(&self) -> Result<()> {
|
pub fn create_stale_socket(&self) -> Result<()> {
|
||||||
let listener = UnixListener::bind(self.socket_path())
|
let listener = UnixListener::bind(self.socket_path())
|
||||||
.with_context(|| format!("Failed to bind {}", self.socket_path().display()))?;
|
.with_context(|| format!("Failed to bind {}", self.socket_path().display()))?;
|
||||||
|
|
@ -187,6 +211,29 @@ impl TestSession {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn run_daemon<I, K, V>(&self, env: I) -> Result<Output>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = (K, V)>,
|
||||||
|
K: AsRef<std::ffi::OsStr>,
|
||||||
|
V: AsRef<std::ffi::OsStr>,
|
||||||
|
{
|
||||||
|
let mut command = Command::new(env!("CARGO_BIN_EXE_deskctl"));
|
||||||
|
command
|
||||||
|
.env("DESKCTL_DAEMON", "1")
|
||||||
|
.env("DESKCTL_SOCKET_PATH", self.socket_path())
|
||||||
|
.env("DESKCTL_PID_PATH", self.pid_path())
|
||||||
|
.env("DESKCTL_SESSION", &self.opts.session)
|
||||||
|
.envs(env);
|
||||||
|
|
||||||
|
command.output().with_context(|| {
|
||||||
|
format!(
|
||||||
|
"Failed to run daemon {} against {}",
|
||||||
|
env!("CARGO_BIN_EXE_deskctl"),
|
||||||
|
self.socket_path().display()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for TestSession {
|
impl Drop for TestSession {
|
||||||
|
|
@ -195,6 +242,9 @@ impl Drop for TestSession {
|
||||||
if self.socket_path().exists() {
|
if self.socket_path().exists() {
|
||||||
let _ = std::fs::remove_file(self.socket_path());
|
let _ = std::fs::remove_file(self.socket_path());
|
||||||
}
|
}
|
||||||
|
if self.pid_path().exists() {
|
||||||
|
let _ = std::fs::remove_file(self.pid_path());
|
||||||
|
}
|
||||||
let _ = std::fs::remove_dir_all(&self.root);
|
let _ = std::fs::remove_dir_all(&self.root);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,31 @@ fn daemon_start_recovers_from_stale_socket() -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn daemon_init_failure_cleans_runtime_state() -> Result<()> {
|
||||||
|
let _guard = env_lock_guard();
|
||||||
|
let session = TestSession::new("daemon-init-failure")?;
|
||||||
|
|
||||||
|
let output = session.run_daemon([("XDG_SESSION_TYPE", "x11"), ("DISPLAY", ":99999")])?;
|
||||||
|
assert!(!output.status.success(), "daemon startup should fail");
|
||||||
|
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
assert!(
|
||||||
|
stderr.contains("Failed to initialize daemon state"),
|
||||||
|
"unexpected stderr: {stderr}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!session.socket_path().exists(),
|
||||||
|
"failed startup should remove the socket path"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!session.pid_path().exists(),
|
||||||
|
"failed startup should remove the pid path"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn wait_window_returns_matched_window_payload() -> Result<()> {
|
fn wait_window_returns_matched_window_payload() -> Result<()> {
|
||||||
let _guard = env_lock_guard();
|
let _guard = env_lock_guard();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue