Compare commits

..

18 commits
v0.1.8 ... main

Author SHA1 Message Date
32c6d337f1 ci: use self-hosted netty runners for validation jobs
Some checks failed
CI / Changes (push) Has been cancelled
CI / Validate (push) Has been cancelled
CI / Integration (Xvfb) (push) Has been cancelled
CI / Distribution Validate (push) Has been cancelled
CI / Update Manifests (push) Has been cancelled
CI / Build Release Asset (push) Has been cancelled
CI / Release (push) Has been cancelled
CI / Publish npm (push) Has been cancelled
CI / Publish crates.io (push) Has been cancelled
2026-04-05 11:46:45 -04:00
13119eecf7 update crates.io link
Some checks failed
CI / Changes (push) Has been cancelled
CI / Validate (push) Has been cancelled
CI / Integration (Xvfb) (push) Has been cancelled
CI / Distribution Validate (push) Has been cancelled
CI / Update Manifests (push) Has been cancelled
CI / Build Release Asset (push) Has been cancelled
CI / Release (push) Has been cancelled
CI / Publish npm (push) Has been cancelled
CI / Publish crates.io (push) Has been cancelled
2026-04-02 14:53:56 -04:00
github-actions[bot]
2b7de5fcef release: v0.1.14 [skip ci] 2026-03-27 23:46:33 +00:00
2b3d422c7b crates.io 2026-03-27 19:40:24 -04:00
19669fb4c1 demo 2026-03-27 19:25:26 -04:00
Hari
2107449d9b
Update README with asset link and description change
Added a link to GitHub assets and removed 'Linux' from description.
2026-03-27 18:17:51 -04:00
github-actions[bot]
85e1916635 release: v0.1.13 [skip ci] 2026-03-27 14:10:07 +00:00
9bfada8b4b fix helper 2026-03-27 10:04:10 -04:00
3ca6c90eaf fix termination bug 2026-03-27 00:20:37 -04:00
github-actions[bot]
3a8d9f90c1 release: v0.1.12 [skip ci] 2026-03-26 19:31:47 +00:00
Hari
ff26c57035
replace firefox with chrome (#14) 2026-03-26 15:25:40 -04:00
github-actions[bot]
580ea79c27 release: v0.1.11 [skip ci] 2026-03-26 18:47:09 +00:00
a58912284b reorder pipeline 2026-03-26 14:40:45 -04:00
github-actions[bot]
8d690a62b4 release: v0.1.10 [skip ci] 2026-03-26 18:28:13 +00:00
07a478b0ee couple CI with publish 2026-03-26 14:21:41 -04:00
github-actions[bot]
e61c5bc33f release: v0.1.9 [skip ci] 2026-03-26 16:03:29 +00:00
Hari
a64b46b479
deskctl upgrade (#13)
* deskctl upgrade

* interactive update
as well as --yes flag
2026-03-26 11:53:15 -04:00
Hari
2b02513d6e
Improve docs structure and navigation (#12)
* Improve docs structure and navigation

Co-authored-by: Codex <noreply@openai.com>

* rm

* handwrite docs

---------

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 11:27:35 -04:00
26 changed files with 2038 additions and 413 deletions

View file

@ -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 }}
@ -52,19 +68,40 @@ jobs:
echo "rust=${{ steps.filter.outputs.rust }}" >> "$GITHUB_OUTPUT"
fi
- name: Read current version
- name: Calculate next version
id: version
if: github.event_name != 'pull_request' && steps.check.outputs.rust == 'true'
run: |
VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
CURRENT=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
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
@ -88,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
@ -107,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
@ -115,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
@ -125,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
@ -137,24 +168,64 @@ 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
build:
name: Build Release Asset
# --- 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' && needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-node@v4
with:
node-version: 22
- 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
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"
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
git tag "${{ needs.changes.outputs.tag }}"
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:
@ -165,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
@ -179,8 +260,8 @@ jobs:
release:
name: Release
needs: [changes, build]
if: github.event_name != 'pull_request'
needs: [changes, build, update-manifests]
if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -209,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

View file

@ -1,127 +0,0 @@
name: Publish Registries
on:
workflow_dispatch:
inputs:
bump:
description: Version bump type
required: true
type: choice
options:
- patch
- minor
- major
publish_npm:
description: Publish deskctl to npm
required: true
type: boolean
default: true
publish_crates:
description: Publish deskctl to crates.io
required: true
type: boolean
default: false
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- 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: Compute next version
id: version
run: |
CURRENT=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
case "${{ inputs.bump }}" in
major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
patch) PATCH=$((PATCH + 1)) ;;
esac
NEW="${MAJOR}.${MINOR}.${PATCH}"
TAG="v${NEW}"
echo "version=${NEW}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "Bumping ${CURRENT} -> ${NEW} (${TAG})"
- name: Bump versions
run: |
NEW="${{ steps.version.outputs.version }}"
CURRENT=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
sed -i "0,/^version = \"${CURRENT}\"/s//version = \"${NEW}\"/" Cargo.toml
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"
cargo generate-lockfile
- name: Commit, tag, and push
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Cargo.toml Cargo.lock npm/deskctl/package.json
git commit -m "release: ${{ steps.version.outputs.tag }} [skip ci]"
git tag "${{ steps.version.outputs.tag }}"
git push origin main --tags
- name: Check current published state
id: published
run: |
VERSION="${{ steps.version.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
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=${{ steps.version.outputs.tag }}"
echo "bump=${{ inputs.bump }}"
echo "npm_already_published=${{ steps.published.outputs.npm }}"
echo "crates_already_published=${{ steps.published.outputs.crates }}"

42
Cargo.lock generated
View file

@ -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.8"
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",
]

View file

@ -1,6 +1,6 @@
[package]
name = "deskctl"
version = "0.1.8"
version = "0.1.14"
edition = "2021"
description = "X11 desktop control CLI for agents"
license = "MIT"

View file

@ -1,9 +1,11 @@
# deskctl
[![npm](https://img.shields.io/npm/v/deskctl?label=npm)](https://www.npmjs.com/package/deskctl)
[![skill](https://img.shields.io/badge/skills.sh-deskctl-111827)](skills/deskctl)
Desktop control cli 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

969
demo/index.html Normal file
View 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">&#128221;</span>
<span>task_brief.txt</span>
<span class="fmeta">2.1 KB</span>
</div>
<div class="file-row" id="f-csv">
<span class="ficon">&#128202;</span>
<span>nvda_q1_data.csv</span>
<span class="fmeta">48 KB</span>
</div>
<div class="file-row" id="f-prev">
<span class="ficon">&#128196;</span>
<span>prev_report.pdf</span>
<span class="fmeta">1.2 MB</span>
</div>
<div class="file-row">
<span class="ficon">&#128193;</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()">&#8634; 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('&nbsp;&nbsp;{ <span class="jv">"@w1"</span>: <span class="jv">"Files"</span> }');
out('&nbsp;&nbsp;{ <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('&nbsp;&nbsp;{ <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>

View file

@ -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

View file

@ -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

View file

@ -1,6 +1,6 @@
{
"name": "deskctl",
"version": "0.1.8",
"version": "0.1.14",
"description": "Installable deskctl package for Linux X11 agents",
"license": "MIT",
"homepage": "https://github.com/harivansh-afk/deskctl",

View file

@ -30,7 +30,7 @@ function formatTocText(text: string): string {
<body>
{
!isIndex && (
<nav>
<nav class="breadcrumbs">
<a class="title" href="/">
deskctl
</a>

View file

@ -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.

View file

@ -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

View file

@ -8,27 +8,33 @@ import DocLayout from "../layouts/DocLayout.astro";
<img src="/favicon.svg" alt="" width="40" height="40" />
</header>
<p class="tagline">non-interactive desktop control for AI agents</p>
<p class="tagline">non-interactive desktop control cli for AI agents</p>
<p class="lede">
<code>deskctl</code> 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.
</p>
<h2>Start here</h2>
<h2>Start</h2>
<ul>
<li><a href="/installation">Installation</a></li>
<li><a href="/quick-start">Quick start</a></li>
<li>
<a href="/installation">Installation</a>
</li>
<li>
<a href="/quick-start">Quick start</a>
</li>
</ul>
<h2>Reference</h2>
<ul>
<li><a href="/commands">Commands</a></li>
<li><a href="/architecture">Architecture</a></li>
<li><a href="/runtime-contract">Runtime contract</a></li>
<li>
<a href="/commands">Commands</a>
</li>
<li>
<a href="/runtime-contract">Runtime contract</a>
</li>
</ul>
<h2>Links</h2>
@ -37,5 +43,11 @@ import DocLayout from "../layouts/DocLayout.astro";
<li>
<a href="https://github.com/harivansh-afk/deskctl">GitHub</a>
</li>
<li>
<a href="https://crates.io/crates/deskctl">crates.io</a>
</li>
<li>
<a href="https://www.npmjs.com/package/deskctl">npm</a>
</li>
</ul>
</DocLayout>

View file

@ -6,19 +6,30 @@ 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 doctor
```
`deskctl` is the default install path. It installs the command by
downloading the matching GitHub Release asset for the supported runtime target.
The repo skill lives under `skills/deskctl`, so `skills` can install it
directly from this GitHub repo. It is designed around the same observe -> wait
-> act -> verify loop as the CLI. `-g` installs it globally; omit that flag if
you want a project-local install.
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
@ -29,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
@ -53,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.

View file

@ -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,

View file

@ -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:

View file

@ -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;
}

View file

@ -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

View file

@ -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

View file

@ -1,7 +1,7 @@
#!/usr/bin/env bash
# observe-act.sh - main desktop interaction loop
# usage: ./observe-act.sh <selector> [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

View file

@ -1,4 +1,5 @@
pub mod connection;
pub mod upgrade;
use anyhow::Result;
use clap::{Args, Parser, Subcommand};
@ -47,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
@ -80,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,
@ -102,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,
@ -121,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),
@ -206,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 =
@ -231,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 =
@ -271,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,
@ -284,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();
@ -300,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)?;
@ -363,6 +392,7 @@ fn build_request(cmd: &Command) -> Result<Request> {
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"),
@ -422,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)],
@ -526,6 +557,41 @@ fn render_error_lines(response: &Response) -> Vec<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}"));
}
}
_ => {}
}
@ -723,6 +789,36 @@ fn render_screenshot_lines(data: &serde_json::Value, annotate: bool) -> Vec<Stri
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 {
let action = if double { "Double-clicked" } else { "Clicked" };
let key = if double { "double_clicked" } else { "clicked" };
@ -978,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;
@ -1007,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,
@ -1029,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,
@ -1071,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")));
@ -1094,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]
@ -1104,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)"]
);
}
}

465
src/cli/upgrade.rs Normal file
View 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(&current_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");
}
}

View file

@ -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, ..
} => {

View file

@ -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<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<()> {
// 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());
}
}
}

View file

@ -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<Self> {
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<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 {
@ -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);
}
}

View file

@ -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();