diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b7a4d6f..1c2e7f4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,15 +1,31 @@
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:
pull_request:
branches: [main]
push:
branches: [main]
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:
contents: write
@@ -17,7 +33,7 @@ permissions:
jobs:
changes:
name: Changes
- runs-on: ubuntu-latest
+ runs-on: [self-hosted, netty]
outputs:
rust: ${{ steps.check.outputs.rust }}
version: ${{ steps.version.outputs.version }}
@@ -56,34 +72,36 @@ jobs:
id: version
if: github.event_name != 'pull_request' && steps.check.outputs.rust == 'true'
run: |
- BASE=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
- IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE"
+ CURRENT=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
+ IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
- LATEST=$(git tag -l "v${MAJOR}.${MINOR}.*" | sort -V | tail -1)
-
- if [ -z "$LATEST" ]; then
- NEW="$BASE"
- else
- LATEST_VER="${LATEST#v}"
- IFS='.' read -r _ _ LATEST_PATCH <<< "$LATEST_VER"
- NEW_PATCH=$((LATEST_PATCH + 1))
- NEW="${MAJOR}.${MINOR}.${NEW_PATCH}"
- fi
-
- # Ensure the computed version does not already have a tag
- while git rev-parse "v${NEW}" >/dev/null 2>&1; do
- IFS='.' read -r MAJOR MINOR PATCH <<< "$NEW"
- NEW="${MAJOR}.${MINOR}.$((PATCH + 1))"
- done
+ BUMP="${{ inputs.bump || 'patch' }}"
+ case "$BUMP" in
+ major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
+ minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
+ patch)
+ LATEST=$(git tag -l "v${MAJOR}.${MINOR}.*" | sort -V | tail -1)
+ if [ -z "$LATEST" ]; then
+ NEW_PATCH=$PATCH
+ else
+ LATEST_VER="${LATEST#v}"
+ IFS='.' read -r _ _ LATEST_PATCH <<< "$LATEST_VER"
+ NEW_PATCH=$((LATEST_PATCH + 1))
+ fi
+ PATCH=$NEW_PATCH
+ ;;
+ esac
+ NEW="${MAJOR}.${MINOR}.${PATCH}"
echo "version=${NEW}" >> "$GITHUB_OUTPUT"
echo "tag=v${NEW}" >> "$GITHUB_OUTPUT"
+ echo "Computed version: ${NEW} (v${NEW})"
validate:
name: Validate
needs: changes
if: needs.changes.outputs.rust == 'true'
- runs-on: ubuntu-latest
+ runs-on: [self-hosted, netty]
steps:
- uses: actions/checkout@v4
@@ -107,9 +125,6 @@ jobs:
- name: Install site dependencies
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
run: make fmt-check
@@ -126,7 +141,7 @@ jobs:
name: Integration (Xvfb)
needs: changes
if: needs.changes.outputs.rust == 'true'
- runs-on: ubuntu-latest
+ runs-on: [self-hosted, netty]
steps:
- uses: actions/checkout@v4
@@ -134,9 +149,6 @@ jobs:
- 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
run: make test-integration
@@ -144,7 +156,7 @@ jobs:
name: Distribution Validate
needs: changes
if: needs.changes.outputs.rust == 'true'
- runs-on: ubuntu-latest
+ runs-on: [self-hosted, netty]
steps:
- uses: actions/checkout@v4
@@ -156,21 +168,16 @@ jobs:
with:
node-version: 22
- - uses: cachix/install-nix-action@v30
- with:
- extra_nix_config: |
- experimental-features = nix-command flakes
-
- - name: Install system dependencies
- run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev
-
- name: Distribution validation
run: make dist-validate
+ # --- Release pipeline: update-manifests -> build -> release -> publish ---
+ # These stay on ubuntu-latest for artifact upload/download and registry publishing.
+
update-manifests:
name: Update Manifests
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
steps:
- uses: actions/checkout@v4
@@ -183,29 +190,31 @@ jobs:
with:
node-version: 22
- - name: Update version in Cargo.toml
+ - name: Update versions
run: |
CURRENT=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
NEW="${{ needs.changes.outputs.version }}"
if [ "$CURRENT" != "$NEW" ]; then
sed -i "0,/^version = \"${CURRENT}\"/s//version = \"${NEW}\"/" Cargo.toml
- node -e 'const fs=require("node:fs"); const path="npm/deskctl/package.json"; const pkg=JSON.parse(fs.readFileSync(path,"utf8")); pkg.version=process.argv[1]; fs.writeFileSync(path, JSON.stringify(pkg, null, 2)+"\n");' "$NEW"
cargo generate-lockfile
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
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
-
- if ! git diff --quiet; then
- git add Cargo.toml Cargo.lock npm/deskctl/package.json
+ git add Cargo.toml Cargo.lock npm/deskctl/package.json
+ if ! git diff --cached --quiet; then
git commit -m "release: ${{ needs.changes.outputs.tag }} [skip ci]"
fi
-
- if ! git rev-parse "${{ needs.changes.outputs.tag }}" >/dev/null 2>&1; then
- git tag "${{ needs.changes.outputs.tag }}"
- fi
+ git tag "${{ needs.changes.outputs.tag }}"
git push origin main --tags
build:
@@ -217,7 +226,6 @@ jobs:
- uses: actions/checkout@v4
with:
ref: ${{ needs.changes.outputs.tag }}
- fetch-depth: 0
- uses: dtolnay/rust-toolchain@stable
with:
@@ -228,6 +236,16 @@ jobs:
- 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
@@ -243,7 +261,7 @@ jobs:
release:
name: Release
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
steps:
- uses: actions/checkout@v4
@@ -272,3 +290,75 @@ jobs:
artifacts/deskctl-linux-x86_64 \
artifacts/checksums.txt
fi
+
+ publish-npm:
+ name: Publish npm
+ needs: [changes, update-manifests, release]
+ if: >-
+ 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
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
deleted file mode 100644
index 1f6b282..0000000
--- a/.github/workflows/publish.yml
+++ /dev/null
@@ -1,103 +0,0 @@
-name: Publish Registries
-
-on:
- workflow_dispatch:
- inputs:
- tag:
- description: Release tag to publish (for example v0.1.5)
- required: true
- type: string
- publish_npm:
- description: Publish deskctl to npm
- required: true
- type: boolean
- default: false
- publish_crates:
- description: Publish deskctl to crates.io
- required: true
- type: boolean
- default: false
-
-permissions:
- contents: read
-
-jobs:
- publish:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- with:
- ref: ${{ inputs.tag }}
-
- - uses: dtolnay/rust-toolchain@stable
-
- - uses: actions/setup-node@v4
- with:
- node-version: 22
- registry-url: https://registry.npmjs.org
-
- - name: Install system dependencies
- run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev
-
- - name: Verify release exists and contains canonical assets
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- gh release view "${{ inputs.tag }}" --json assets --jq '.assets[].name' > /tmp/release-assets.txt
- grep -Fx "deskctl-linux-x86_64" /tmp/release-assets.txt >/dev/null
- grep -Fx "checksums.txt" /tmp/release-assets.txt >/dev/null
-
- - name: Verify versions align with tag
- run: |
- TAG="${{ inputs.tag }}"
- VERSION="${TAG#v}"
- CARGO_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
- NPM_VERSION=$(node -p 'require("./npm/deskctl/package.json").version')
-
- test "$VERSION" = "$CARGO_VERSION"
- test "$VERSION" = "$NPM_VERSION"
-
- - name: Check current published state
- id: published
- run: |
- VERSION="${{ inputs.tag }}"
- VERSION="${VERSION#v}"
-
- if npm view "deskctl@${VERSION}" version >/dev/null 2>&1; then
- echo "npm=true" >> "$GITHUB_OUTPUT"
- else
- echo "npm=false" >> "$GITHUB_OUTPUT"
- fi
-
- 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: Validate npm package
- run: |
- mkdir -p ./tmp/npm-pack
- node npm/deskctl/scripts/validate-package.js
- npm pack ./npm/deskctl --pack-destination ./tmp/npm-pack >/dev/null
-
- - name: Validate crate publish path
- run: cargo publish --dry-run --locked
-
- - name: Publish npm
- if: inputs.publish_npm && steps.published.outputs.npm != 'true'
- env:
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- run: npm publish ./npm/deskctl --access public
-
- - name: Publish crates.io
- if: inputs.publish_crates && steps.published.outputs.crates != 'true'
- env:
- CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
- run: cargo publish --locked
-
- - name: Summary
- run: |
- echo "tag=${{ inputs.tag }}"
- echo "npm_already_published=${{ steps.published.outputs.npm }}"
- echo "crates_already_published=${{ steps.published.outputs.crates }}"
diff --git a/Cargo.lock b/Cargo.lock
index 6922004..eb0e2ce 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -241,9 +241,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cc"
-version = "1.2.57"
+version = "1.2.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
+checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -400,7 +400,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "deskctl"
-version = "0.1.7"
+version = "0.1.14"
dependencies = [
"ab_glyph",
"anyhow",
@@ -911,9 +911,9 @@ dependencies = [
[[package]]
name = "js-sys"
-version = "0.3.91"
+version = "0.3.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
+checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"
dependencies = [
"once_cell",
"wasm-bindgen",
@@ -1039,9 +1039,9 @@ dependencies = [
[[package]]
name = "mio"
-version = "1.1.1"
+version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
+checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"wasi",
@@ -1699,9 +1699,9 @@ dependencies = [
[[package]]
name = "simd-adler32"
-version = "0.3.8"
+version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simd_helpers"
@@ -1861,9 +1861,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
-version = "1.22.0"
+version = "1.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
+checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
dependencies = [
"getrandom 0.4.2",
"js-sys",
@@ -1907,9 +1907,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
-version = "0.2.114"
+version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
+checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"
dependencies = [
"cfg-if",
"once_cell",
@@ -1920,9 +1920,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
-version = "0.2.114"
+version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
+checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -1930,9 +1930,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
-version = "0.2.114"
+version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
+checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -1943,9 +1943,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
-version = "0.2.114"
+version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
+checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"
dependencies = [
"unicode-ident",
]
@@ -2297,9 +2297,9 @@ dependencies = [
[[package]]
name = "zune-jpeg"
-version = "0.5.14"
+version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b7a1c0af6e5d8d1363f4994b7a091ccf963d8b694f7da5b0b9cceb82da2c0a6"
+checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
diff --git a/Cargo.toml b/Cargo.toml
index 5872639..be051c7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "deskctl"
-version = "0.1.7"
+version = "0.1.14"
edition = "2021"
description = "X11 desktop control CLI for agents"
license = "MIT"
diff --git a/README.md b/README.md
index 4b42b5f..dccbe04 100644
--- a/README.md
+++ b/README.md
@@ -1,55 +1,36 @@
# deskctl
-
[](https://www.npmjs.com/package/deskctl)
-[](https://github.com/harivansh-afk/deskctl/releases)
-[](#support-boundary)
[](skills/deskctl)
-Non-interactive desktop control for AI agents on Linux X11.
+Desktop control cli for AI agents on X11.
+
+https://github.com/user-attachments/assets/e820787e-4d1a-463f-bdcf-a829588778bf
+
## Install
```bash
npm install -g deskctl
-deskctl doctor
-deskctl snapshot --annotate
```
-One-shot execution also works:
-
-```bash
-npx deskctl --help
-```
-
-`deskctl` installs the command by downloading the matching GitHub Release asset for the supported runtime target.
-
-
-## Installable skill
-
-```bash
-npx skills add harivansh-afk/deskctl -s deskctl
-```
-
-The installable skill lives in [`skills/deskctl`](skills/deskctl) and is built around the same observe -> wait -> act -> verify loop as the CLI.
-
-## Quick example
-
```bash
deskctl doctor
deskctl snapshot --annotate
-deskctl wait window --selector 'title=Firefox' --timeout 10
-deskctl focus 'title=Firefox'
-deskctl type "hello world"
+```
+
+## Skill
+
+```bash
+npx skills add harivansh-afk/deskctl
```
## Docs
- runtime contract: [docs/runtime-contract.md](docs/runtime-contract.md)
-- release flow: [docs/releasing.md](docs/releasing.md)
-- installable skill: [skills/deskctl](skills/deskctl)
-- contributor workflow: [CONTRIBUTING.md](CONTRIBUTING.md)
+- releasing: [docs/releasing.md](docs/releasing.md)
+- contributing: [CONTRIBUTING.md](CONTRIBUTING.md)
-## Other install paths
+## Install paths
Nix:
@@ -58,12 +39,8 @@ nix run github:harivansh-afk/deskctl -- --help
nix profile install github:harivansh-afk/deskctl
```
-Source build:
+Rust:
```bash
cargo build
```
-
-## Support boundary
-
-`deskctl` currently supports Linux X11. Use `--json` for stable machine parsing, use `window_id` for programmatic targeting inside a live session, and use `deskctl doctor` first when the runtime looks broken.
diff --git a/demo/index.html b/demo/index.html
new file mode 100644
index 0000000..70ac230
--- /dev/null
+++ b/demo/index.html
@@ -0,0 +1,969 @@
+
+
+
+
+
+deskctl - Desktop Control for AI Agents
+
+
+
+
+
+
deskctl
+
desktop control CLI for AI agents
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 📝
+ task_brief.txt
+ 2.1 KB
+
+
+ 📊
+ nvda_q1_data.csv
+ 48 KB
+
+
+ 📄
+ prev_report.pdf
+ 1.2 MB
+
+
+ 📁
+ archive/
+ --
+
+
+
+ task: Prepare NVDA Q1 earnings summary
+ source: finance.yahoo.com, local csv
+ output: Google Docs report with chart
+
+
+
+
+
+
+
+
+
Chrome - Yahoo Finance
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $950
+ $800
+ $650
+
+
+
+
+
+
+
+
+
+
+
Chrome - Google Docs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ NVDA 1Y
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Files
+
Yahoo Finance
+
Google Docs
+
+
+
+
+
+
+
+
+
AI agent controlling a live desktop via deskctl
+
↺ Replay
+
+
+
+
+
diff --git a/docs/releasing.md b/docs/releasing.md
index 8f39d3f..849d661 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -59,12 +59,12 @@ The repository release workflow:
- publishes the canonical GitHub Release asset
- uploads `checksums.txt`
-The registry publish workflow:
+The registry publish jobs (npm and crates.io run in parallel):
-- targets an existing release tag
-- checks that Cargo, npm, and the requested tag all agree on version
-- checks whether that version is already published on npm and crates.io
-- only publishes the channels explicitly requested
+- 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
diff --git a/npm/deskctl/README.md b/npm/deskctl/README.md
index 7bb42a9..81f07f4 100644
--- a/npm/deskctl/README.md
+++ b/npm/deskctl/README.md
@@ -14,6 +14,18 @@ After install, run:
deskctl --help
```
+To upgrade version:
+
+```bash
+deskctl upgrade
+```
+
+For non-interactive use:
+
+```bash
+deskctl upgrade --yes
+```
+
One-shot usage is also supported:
```bash
diff --git a/npm/deskctl/package.json b/npm/deskctl/package.json
index 6085bca..c676924 100644
--- a/npm/deskctl/package.json
+++ b/npm/deskctl/package.json
@@ -1,6 +1,6 @@
{
"name": "deskctl",
- "version": "0.1.7",
+ "version": "0.1.14",
"description": "Installable deskctl package for Linux X11 agents",
"license": "MIT",
"homepage": "https://github.com/harivansh-afk/deskctl",
diff --git a/site/src/layouts/DocLayout.astro b/site/src/layouts/DocLayout.astro
index f2608de..afc8648 100644
--- a/site/src/layouts/DocLayout.astro
+++ b/site/src/layouts/DocLayout.astro
@@ -30,7 +30,7 @@ function formatTocText(text: string): string {
{
!isIndex && (
-
+
deskctl
diff --git a/site/src/pages/architecture.mdx b/site/src/pages/architecture.mdx
deleted file mode 100644
index 9478246..0000000
--- a/site/src/pages/architecture.mdx
+++ /dev/null
@@ -1,98 +0,0 @@
----
-layout: ../layouts/DocLayout.astro
-title: Architecture
-toc: true
----
-
-# Architecture
-
-## Public model
-
-`deskctl` is a thin, non-interactive X11 control primitive for agent loops.
-The public flow is:
-
-- diagnose with `deskctl doctor`
-- observe with `snapshot`, `list-windows`, and grouped `get` commands
-- wait with grouped `wait` commands instead of shell `sleep`
-- act with explicit selectors or coordinates
-- verify with another read or snapshot
-
-The tool stays intentionally narrow. It does not try to be a full desktop shell
-or a speculative Wayland abstraction.
-
-## Client-daemon architecture
-
-The CLI talks to an auto-managed daemon over a Unix socket. The daemon keeps
-the X11 connection alive so repeated commands stay fast and share the same
-session-scoped window identity map.
-
-Each CLI invocation sends one request, reads one response, and exits.
-
-## Runtime contract
-
-Requests and responses are newline-delimited JSON (NDJSON) over a Unix socket.
-
-All commands share the same JSON envelope:
-
-```json
-{
- "success": true,
- "data": {},
- "error": null
-}
-```
-
-For window payloads, the public identity is `window_id`, not an X11 handle.
-That keeps the contract backend-neutral even though the current support
-boundary is X11-only.
-
-The complete stable-vs-best-effort policy lives on the
-[runtime contract](/runtime-contract) page.
-
-## Sessions and sockets
-
-Each session gets its own socket path, PID file, and live window mapping.
-
-Public socket resolution order:
-
-1. `--socket`
-2. `DESKCTL_SOCKET_DIR/{session}.sock`
-3. `XDG_RUNTIME_DIR/deskctl/{session}.sock`
-4. `~/.deskctl/{session}.sock`
-
-Most users should let `deskctl` manage this automatically. `--session` is the
-main public knob when you need isolated daemon instances.
-
-## Diagnostics and failure handling
-
-`deskctl doctor` runs before daemon startup and checks:
-
-- display/session setup
-- X11 connectivity
-- basic window enumeration
-- screenshot viability
-- socket directory and stale-socket health
-
-Selector and wait failures are structured in `--json` mode so clients can
-recover without scraping text.
-
-## Backend notes
-
-The backend is built around a `DesktopBackend` trait and currently ships with
-an X11 implementation backed by `x11rb`.
-
-The important public guarantee is not "portable desktop automation." The
-important guarantee is "a correct and unsurprising Linux X11 runtime contract."
-
-## X11 support boundary
-
-This phase supports Linux X11 only.
-
-That means:
-
-- EWMH/window-manager properties matter
-- monitor naming and some ordering details are best-effort
-- Wayland and Hyprland are out of scope for the current contract
-
-The runtime documents those boundaries explicitly instead of pretending the
-surface is broader than it is.
diff --git a/site/src/pages/commands.mdx b/site/src/pages/commands.mdx
index 8a5132b..0696558 100644
--- a/site/src/pages/commands.mdx
+++ b/site/src/pages/commands.mdx
@@ -6,10 +6,14 @@ toc: true
# Commands
-## Observe
+The public CLI is intentionally small. Most workflows boil down to grouped
+reads, grouped waits, selector-driven actions, and a few input primitives.
+
+## Observe and inspect
```sh
deskctl doctor
+deskctl upgrade
deskctl snapshot
deskctl snapshot --annotate
deskctl list-windows
@@ -23,27 +27,30 @@ deskctl get-screen-size
deskctl get-mouse-position
```
-`doctor` checks the runtime before daemon startup. `snapshot` produces a
+`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.
+side effect of writing a screenshot. The grouped `get` commands are the
+preferred read surface for focused state queries.
-## Wait
+## Wait for state transitions
```sh
-deskctl wait window --selector 'title=Firefox' --timeout 10
+deskctl wait window --selector 'title=Chromium' --timeout 10
deskctl wait focus --selector 'id=win3' --timeout 5
-deskctl --json wait window --selector 'class=firefox' --poll-ms 100
+deskctl --json wait window --selector 'class=chromium' --poll-ms 100
```
Wait commands return the matched window payload on success. In `--json` mode,
timeouts and selector failures expose structured `kind` values.
-## Act on a window
+## Act on windows
```sh
-deskctl launch firefox
+deskctl launch chromium
deskctl focus @w1
-deskctl focus 'title=Firefox'
+deskctl focus 'title=Chromium'
deskctl click @w1
deskctl click 960,540
deskctl dblclick @w2
@@ -55,7 +62,7 @@ deskctl resize-window @w1 1280 720
Selector-driven actions accept refs, explicit selector modes, or absolute
coordinates where appropriate.
-## Input and mouse
+## Keyboard and mouse input
```sh
deskctl type "hello world"
@@ -71,22 +78,16 @@ Supported key names include `enter`, `tab`, `escape`, `backspace`, `delete`,
`space`, arrow keys, paging keys, `f1` through `f12`, and any single
character.
-## Launch
-
-```sh
-deskctl launch firefox
-deskctl launch code -- --new-window
-```
-
## Selectors
-Prefer explicit selectors when the target matters:
+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=Firefox
-class=firefox
+title=Chromium
+class=chromium
focused
```
@@ -98,7 +99,7 @@ w1
win1
```
-Bare strings like `firefox` are fuzzy matches. They resolve when there is one
+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
diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro
index 8b8d4b4..478c7a2 100644
--- a/site/src/pages/index.astro
+++ b/site/src/pages/index.astro
@@ -8,49 +8,46 @@ import DocLayout from "../layouts/DocLayout.astro";
- non-interactive desktop control for AI agents
+ non-interactive desktop control cli for AI agents
- deskctl is a thin X11 control primitive for agent loops: diagnose
- the runtime, observe the desktop, wait for state transitions, act deterministically,
- then verify.
+ A thin X11 control primitive for agent loops: diagnose the runtime, observe
+ the desktop, wait for state transitions, act deterministically, then verify.
- Start here
+ Start
Reference
- Agent skill
-
-
- There is also an installable skill for `skills.sh`-style agent runtimes:
-
-
- npx skills add harivansh-afk/deskctl -s deskctl
-
Links
diff --git a/site/src/pages/installation.mdx b/site/src/pages/installation.mdx
index df53fcc..e35f4eb 100644
--- a/site/src/pages/installation.mdx
+++ b/site/src/pages/installation.mdx
@@ -6,33 +6,31 @@ toc: true
# Installation
-## Default install
+Install the public `deskctl` command first, then validate the desktop runtime
+with `deskctl doctor` before trying to automate anything.
+
+## Recommended path
```sh
npm install -g deskctl
-deskctl --help
+deskctl doctor
```
`deskctl` is the default install path. It installs the command by
downloading the matching GitHub Release asset for the supported runtime target.
-## One-shot usage
+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 deskctl --help
+npx skills add harivansh-afk/deskctl
```
-## Agent skill
-
-For `skills.sh`-style runtimes:
-
-```sh
-npx skills add harivansh-afk/deskctl -s deskctl
-```
-
-The repo skill lives under `skills/deskctl` and is designed around the same
-observe -> wait -> act -> verify loop as the CLI.
-
## Other install paths
### Nix
@@ -42,7 +40,7 @@ nix run github:harivansh-afk/deskctl -- --help
nix profile install github:harivansh-afk/deskctl
```
-### Build from source
+### Rust
```sh
git clone https://github.com/harivansh-afk/deskctl
@@ -66,8 +64,13 @@ Source builds on Linux require:
The binary itself only depends on the standard Linux glibc runtime.
-If setup fails, run:
+## 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.
diff --git a/site/src/pages/quick-start.mdx b/site/src/pages/quick-start.mdx
index 10f3ec0..4cc0e25 100644
--- a/site/src/pages/quick-start.mdx
+++ b/site/src/pages/quick-start.mdx
@@ -6,17 +6,19 @@ toc: true
# Quick start
-## Install and diagnose
+The fastest way to use `deskctl` is to follow the same four-step loop : observe, wait, act, verify.
+
+## 1. Install and diagnose
```sh
npm install -g deskctl
deskctl doctor
```
-Use `deskctl doctor` first. It checks X11 connectivity, basic enumeration,
+Run `deskctl doctor` first. It checks X11 connectivity, basic enumeration,
screenshot viability, and socket health before you start driving the desktop.
-## Observe
+## 2. Observe the desktop
```sh
deskctl snapshot --annotate
@@ -29,22 +31,22 @@ 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.
-## Target windows cleanly
+## 3. Pick selectors that stay readable
Prefer explicit selectors when you need deterministic targeting:
```sh
ref=w1
id=win1
-title=Firefox
-class=firefox
+title=Chromium
+class=chromium
focused
```
Legacy refs such as `@w1` still work after `snapshot` or `list-windows`. Bare
-strings like `firefox` are fuzzy matches and now fail on ambiguity.
+strings like `chromium` are fuzzy matches and now fail on ambiguity.
-## Wait, act, verify
+## 4. Wait, act, verify
The core loop is:
@@ -53,23 +55,23 @@ The core loop is:
deskctl snapshot --annotate
# wait
-deskctl wait window --selector 'title=Firefox' --timeout 10
+deskctl wait window --selector 'title=Chromium' --timeout 10
# act
-deskctl focus 'title=Firefox'
+deskctl focus 'title=Chromium'
deskctl hotkey ctrl l
deskctl type "https://example.com"
deskctl press enter
# verify
-deskctl wait focus --selector 'title=Firefox' --timeout 5
+deskctl wait focus --selector 'title=Chromium' --timeout 5
deskctl snapshot
```
The wait commands return the matched window payload on success, so they compose
cleanly into the next action.
-## Use `--json` when parsing matters
+## 5. Use `--json` when parsing matters
Every command supports `--json` and uses the same top-level envelope:
@@ -82,8 +84,8 @@ Every command supports `--json` and uses the same top-level envelope:
{
"ref_id": "w1",
"window_id": "win1",
- "title": "Firefox",
- "app_name": "firefox",
+ "title": "Chromium",
+ "app_name": "chromium",
"x": 0,
"y": 0,
"width": 1920,
diff --git a/site/src/pages/runtime-contract.mdx b/site/src/pages/runtime-contract.mdx
index 4fca14c..e33e999 100644
--- a/site/src/pages/runtime-contract.mdx
+++ b/site/src/pages/runtime-contract.mdx
@@ -11,7 +11,7 @@ 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.
-## JSON envelope
+## Stable top-level envelope
Every command supports `--json` and uses the same top-level envelope:
@@ -32,7 +32,7 @@ Stable top-level fields:
If `success` is `false`, the command exits non-zero in both text mode and JSON
mode.
-## Stable window fields
+## Stable window payload
Whenever a response includes a window payload, these fields are stable:
diff --git a/site/src/styles/base.css b/site/src/styles/base.css
index cd569a9..e05552e 100644
--- a/site/src/styles/base.css
+++ b/site/src/styles/base.css
@@ -224,30 +224,30 @@ hr {
}
}
-nav {
+.breadcrumbs {
max-width: 50rem;
margin: 0 auto;
padding: 1.5rem clamp(1.25rem, 5vw, 3rem) 0;
font-size: 0.9rem;
}
-nav a {
+.breadcrumbs a {
color: inherit;
text-decoration: none;
opacity: 0.6;
transition: opacity 0.15s;
}
-nav a:hover {
+.breadcrumbs a:hover {
opacity: 1;
}
-nav .title {
+.breadcrumbs .title {
font-weight: 500;
opacity: 1;
}
-nav .sep {
+.breadcrumbs .sep {
opacity: 0.3;
margin: 0 0.5em;
}
diff --git a/skills/deskctl/SKILL.md b/skills/deskctl/SKILL.md
index 244a1fb..c79ca21 100644
--- a/skills/deskctl/SKILL.md
+++ b/skills/deskctl/SKILL.md
@@ -18,14 +18,20 @@ 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=Firefox' --timeout 10 # wait
-deskctl click 'title=Firefox' # act
+deskctl wait window --selector 'title=Chromium' --timeout 10 # wait
+deskctl click 'title=Chromium' # act
deskctl snapshot # verify
```
@@ -36,12 +42,12 @@ See [workflows/observe-act.sh](workflows/observe-act.sh) for a reusable script.
```bash
ref=w1 # snapshot ref (short-lived)
id=win1 # stable window ID (session-scoped)
-title=Firefox # match by title
-class=firefox # match by WM class
+title=Chromium # match by title
+class=chromium # match by WM class
focused # currently focused window
```
-Bare strings like `firefox` do fuzzy matching but fail on ambiguity. Prefer explicit selectors.
+Bare strings like `chromium` do fuzzy matching but fail on ambiguity. Prefer explicit selectors.
## References
diff --git a/skills/deskctl/agents/openai.yaml b/skills/deskctl/agents/openai.yaml
new file mode 100644
index 0000000..8a5ca13
--- /dev/null
+++ b/skills/deskctl/agents/openai.yaml
@@ -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
diff --git a/skills/deskctl/references/commands.md b/skills/deskctl/references/commands.md
index 77b9513..df69350 100644
--- a/skills/deskctl/references/commands.md
+++ b/skills/deskctl/references/commands.md
@@ -7,6 +7,7 @@ runtime contract.
```bash
deskctl doctor
+deskctl upgrade
deskctl snapshot
deskctl snapshot --annotate
deskctl list-windows
@@ -22,8 +23,8 @@ deskctl get-mouse-position
## Wait
```bash
-deskctl wait window --selector 'title=Firefox' --timeout 10
-deskctl wait focus --selector 'class=firefox' --timeout 5
+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
@@ -34,8 +35,8 @@ Returns the matched window payload on success. Failures include structured
```bash
ref=w1
id=win1
-title=Firefox
-class=firefox
+title=Chromium
+class=chromium
focused
```
@@ -45,7 +46,7 @@ on ambiguity.
## Act
```bash
-deskctl focus 'class=firefox'
+deskctl focus 'class=chromium'
deskctl click @w1
deskctl dblclick @w2
deskctl type "hello world"
@@ -58,7 +59,7 @@ deskctl mouse drag 100 100 500 500
deskctl move-window @w1 100 120
deskctl resize-window @w1 1280 720
deskctl close @w3
-deskctl launch firefox
+deskctl launch chromium
```
The daemon starts automatically on first command. In normal usage you should
diff --git a/skills/deskctl/references/runtime-contract.md b/skills/deskctl/references/runtime-contract.md
deleted file mode 120000
index 8de0781..0000000
--- a/skills/deskctl/references/runtime-contract.md
+++ /dev/null
@@ -1 +0,0 @@
-../../../docs/runtime-contract.md
\ No newline at end of file
diff --git a/skills/deskctl/references/runtime-contract.md b/skills/deskctl/references/runtime-contract.md
new file mode 100644
index 0000000..6efd2bc
--- /dev/null
+++ b/skills/deskctl/references/runtime-contract.md
@@ -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
diff --git a/skills/deskctl/workflows/observe-act.sh b/skills/deskctl/workflows/observe-act.sh
index 0e336ae..8c3abc2 100755
--- a/skills/deskctl/workflows/observe-act.sh
+++ b/skills/deskctl/workflows/observe-act.sh
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# observe-act.sh - main desktop interaction loop
# usage: ./observe-act.sh [action] [action-args...]
-# example: ./observe-act.sh 'title=Firefox' click
+# example: ./observe-act.sh 'title=Chromium' click
# example: ./observe-act.sh 'class=terminal' type "ls -la"
set -euo pipefail
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
index bab44c9..79008de 100644
--- a/src/cli/mod.rs
+++ b/src/cli/mod.rs
@@ -1,4 +1,5 @@
pub mod connection;
+pub mod upgrade;
use anyhow::Result;
use clap::{Args, Parser, Subcommand};
@@ -7,7 +8,12 @@ use std::path::PathBuf;
use crate::core::protocol::{Request, Response};
#[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 {
#[command(flatten)]
pub global: GlobalOpts,
@@ -42,13 +48,13 @@ pub enum Command {
/// Click a window ref or coordinates
#[command(after_help = CLICK_EXAMPLES)]
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,
},
/// Double-click a window ref or coordinates
#[command(after_help = DBLCLICK_EXAMPLES)]
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,
},
/// Type text into the focused window
@@ -75,19 +81,19 @@ pub enum Command {
/// Focus a window by ref or name
#[command(after_help = FOCUS_EXAMPLES)]
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,
},
/// Close a window by ref or name
#[command(after_help = CLOSE_EXAMPLES)]
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,
},
/// Move a window
#[command(after_help = MOVE_WINDOW_EXAMPLES)]
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,
/// X position
x: i32,
@@ -97,7 +103,7 @@ pub enum Command {
/// Resize a window
#[command(after_help = RESIZE_WINDOW_EXAMPLES)]
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,
/// Width
w: u32,
@@ -116,6 +122,9 @@ pub enum Command {
/// Diagnose X11 runtime, screenshot, and daemon health
#[command(after_help = DOCTOR_EXAMPLES)]
Doctor,
+ /// Upgrade deskctl using the current install channel
+ #[command(after_help = UPGRADE_EXAMPLES)]
+ Upgrade(UpgradeOpts),
/// Query runtime state
#[command(subcommand)]
Get(GetCmd),
@@ -201,19 +210,19 @@ const SNAPSHOT_EXAMPLES: &str =
const LIST_WINDOWS_EXAMPLES: &str =
"Examples:\n deskctl list-windows\n deskctl --json list-windows";
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 =
- "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 =
"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 HOTKEY_EXAMPLES: &str = "Examples:\n deskctl hotkey ctrl l\n deskctl hotkey ctrl shift t";
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 =
- "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 =
- "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 =
"Examples:\n deskctl resize-window @w1 1280 720\n deskctl resize-window 'id=win2' 800 600";
const GET_MONITORS_EXAMPLES: &str =
@@ -226,12 +235,14 @@ const GET_SCREEN_SIZE_EXAMPLES: &str =
const GET_MOUSE_POSITION_EXAMPLES: &str =
"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 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 SCREENSHOT_EXAMPLES: &str =
"Examples:\n deskctl screenshot\n deskctl screenshot /tmp/screen.png\n deskctl screenshot --annotate";
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 =
"Examples:\n deskctl mouse move 500 300\n deskctl mouse move 0 0";
const MOUSE_SCROLL_EXAMPLES: &str =
@@ -266,7 +277,7 @@ pub enum WaitCmd {
#[derive(Args)]
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)]
pub selector: String,
@@ -279,6 +290,13 @@ pub struct WaitSelectorOpts {
pub poll_ms: u64,
}
+#[derive(Args)]
+pub struct UpgradeOpts {
+ /// Skip confirmation and upgrade non-interactively
+ #[arg(long)]
+ pub yes: bool,
+}
+
pub fn run() -> Result<()> {
let app = App::parse();
@@ -295,6 +313,22 @@ pub fn run() -> Result<()> {
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
let request = build_request(&app.command)?;
let response = connection::send_command(&app.global, &request)?;
@@ -358,6 +392,7 @@ fn build_request(cmd: &Command) -> Result {
Command::GetScreenSize => Request::new("get-screen-size"),
Command::GetMousePosition => Request::new("get-mouse-position"),
Command::Doctor => unreachable!(),
+ Command::Upgrade(_) => unreachable!(),
Command::Get(sub) => match sub {
GetCmd::ActiveWindow => Request::new("get-active-window"),
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::GetScreenSize => vec![render_screen_size_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::Click { .. } => vec![render_click_line(data, false)],
Command::Dblclick { .. } => vec![render_click_line(data, true)],
@@ -521,6 +557,41 @@ fn render_error_lines(response: &Response) -> Vec {
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 Vec {
+ 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 {
let action = 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 {
use super::{
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 serde_json::json;
@@ -988,6 +1089,12 @@ mod tests {
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] "));
+ }
+
#[test]
fn window_listing_text_includes_window_ids() {
let lines = render_success_lines(
@@ -996,8 +1103,8 @@ mod tests {
"windows": [{
"ref_id": "w1",
"window_id": "win1",
- "title": "Firefox",
- "app_name": "firefox",
+ "title": "Chromium",
+ "app_name": "chromium",
"x": 0,
"y": 0,
"width": 1280,
@@ -1018,37 +1125,37 @@ mod tests {
fn action_text_includes_target_identity() {
let lines = render_success_lines(
&Command::Focus {
- selector: "title=Firefox".to_string(),
+ selector: "title=Chromium".to_string(),
},
Some(&json!({
"action": "focus",
- "window": "Firefox",
- "title": "Firefox",
+ "window": "Chromium",
+ "title": "Chromium",
"ref_id": "w2",
"window_id": "win7"
})),
)
.unwrap();
- assert_eq!(lines, vec!["Focused @w2 [win7] \"Firefox\""]);
+ assert_eq!(lines, vec!["Focused @w2 [win7] \"Chromium\""]);
}
#[test]
fn timeout_errors_render_last_observation() {
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!({
"kind": "timeout",
"wait": "focus",
- "selector": "title=Firefox",
+ "selector": "title=Chromium",
"timeout_ms": 1000,
"last_observation": {
"kind": "window_not_focused",
"window": {
"ref_id": "w1",
"window_id": "win1",
- "title": "Firefox",
- "app_name": "firefox",
+ "title": "Chromium",
+ "app_name": "chromium",
"x": 0,
"y": 0,
"width": 1280,
@@ -1060,10 +1167,8 @@ mod tests {
}),
));
- assert!(lines
- .iter()
- .any(|line| line
- .contains("Timed out after 1000ms waiting for focus selector title=Firefox")));
+ assert!(lines.iter().any(|line| line
+ .contains("Timed out after 1000ms waiting for focus selector title=Chromium")));
assert!(lines
.iter()
.any(|line| line.contains("matching window exists but is not focused yet")));
@@ -1083,9 +1188,9 @@ mod tests {
let summary = target_summary(&json!({
"ref_id": "w1",
"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]
@@ -1093,4 +1198,22 @@ mod tests {
let input = format!("fire{}fox", '\u{00E9}');
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)"]
+ );
+ }
}
diff --git a/src/cli/upgrade.rs b/src/cli/upgrade.rs
new file mode 100644
index 0000000..acc844e
--- /dev/null
+++ b/src/cli/upgrade.rs
@@ -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::>()
+ .join(" ")
+ }
+}
+
+#[derive(Debug)]
+struct VersionInfo {
+ current: String,
+ latest: String,
+}
+
+pub fn run_upgrade(opts: &GlobalOpts, upgrade_opts: &UpgradeOpts) -> Result {
+ 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 {
+ 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 {
+ 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::(&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 {
+ 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 {
+ 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 {
+ 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");
+ }
+}
diff --git a/src/core/refs.rs b/src/core/refs.rs
index 34e1ba7..7fd7b6c 100644
--- a/src/core/refs.rs
+++ b/src/core/refs.rs
@@ -412,8 +412,8 @@ mod tests {
SelectorQuery::WindowId("win4".to_string())
);
assert_eq!(
- SelectorQuery::parse("title=Firefox"),
- SelectorQuery::Title("Firefox".to_string())
+ SelectorQuery::parse("title=Chromium"),
+ SelectorQuery::Title("Chromium".to_string())
);
assert_eq!(
SelectorQuery::parse("class=Navigator"),
@@ -458,11 +458,11 @@ mod tests {
fn fuzzy_resolution_fails_with_candidates_when_ambiguous() {
let mut refs = RefMap::new();
refs.rebuild(&[
- sample_window(1, "Firefox"),
+ sample_window(1, "Chromium"),
BackendWindow {
native_id: 2,
- title: "Firefox Settings".to_string(),
- app_name: "Firefox".to_string(),
+ title: "Chromium Settings".to_string(),
+ app_name: "Chromium".to_string(),
x: 0,
y: 0,
width: 10,
@@ -472,7 +472,7 @@ mod tests {
},
]);
- match refs.resolve("firefox") {
+ match refs.resolve("chromium") {
ResolveResult::Ambiguous {
mode, candidates, ..
} => {
diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs
index 3df1d9a..9e7e931 100644
--- a/src/daemon/mod.rs
+++ b/src/daemon/mod.rs
@@ -1,6 +1,7 @@
mod handler;
mod state;
+use std::path::{Path, PathBuf};
use std::sync::Arc;
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 state::DaemonState;
+struct RuntimePathsGuard {
+ socket_path: PathBuf,
+ pid_path: Option,
+}
+
+impl RuntimePathsGuard {
+ fn new(socket_path: PathBuf, pid_path: Option) -> 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<()> {
// Validate session before starting
session::detect_session()?;
@@ -25,7 +49,6 @@ pub fn run() -> Result<()> {
async fn async_run() -> Result<()> {
let socket_path = socket_path_from_env().context("DESKCTL_SOCKET_PATH not set")?;
-
let pid_path = pid_path_from_env();
// Clean up stale socket
@@ -33,20 +56,21 @@ async fn async_run() -> Result<()> {
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 state = Arc::new(Mutex::new(
DaemonState::new(session, socket_path.clone())
.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_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(())
}
@@ -123,3 +139,11 @@ async fn handle_connection(
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());
+ }
+ }
+}
diff --git a/tests/support/mod.rs b/tests/support/mod.rs
index 5c6f0be..07cc5a7 100644
--- a/tests/support/mod.rs
+++ b/tests/support/mod.rs
@@ -4,6 +4,7 @@ use std::os::unix::net::UnixListener;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::{Mutex, OnceLock};
+use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{anyhow, bail, Context, Result};
@@ -60,8 +61,7 @@ pub struct FixtureWindow {
impl FixtureWindow {
pub fn create(title: &str, app_class: &str) -> Result {
- let (conn, screen_num) =
- x11rb::connect(None).context("Failed to connect to the integration test display")?;
+ let (conn, screen_num) = connect_to_test_display()?;
let screen = &conn.setup().roots[screen_num];
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 {
fn drop(&mut self) {
let _ = self.conn.destroy_window(self.window);
@@ -142,6 +162,10 @@ impl TestSession {
.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<()> {
let listener = UnixListener::bind(self.socket_path())
.with_context(|| format!("Failed to bind {}", self.socket_path().display()))?;
@@ -187,6 +211,29 @@ impl TestSession {
)
})
}
+
+ pub fn run_daemon(&self, env: I) -> Result
+ where
+ I: IntoIterator- ,
+ K: AsRef
,
+ V: AsRef,
+ {
+ 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 {
@@ -195,6 +242,9 @@ impl Drop for TestSession {
if self.socket_path().exists() {
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);
}
}
diff --git a/tests/x11_runtime.rs b/tests/x11_runtime.rs
index 2aac58c..30308cb 100644
--- a/tests/x11_runtime.rs
+++ b/tests/x11_runtime.rs
@@ -114,6 +114,31 @@ fn daemon_start_recovers_from_stale_socket() -> Result<()> {
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]
fn wait_window_returns_matched_window_payload() -> Result<()> {
let _guard = env_lock_guard();