Compare commits

...

11 commits

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
18 changed files with 1226 additions and 151 deletions

View file

@ -1,9 +1,5 @@
name: CI name: CI
# Runners: uvacompute (https://uvacompute.com)
# To enable, set the UVA_RUNNER repo variable to the correct runner label.
# runs-on: ${{ vars.UVA_RUNNER || 'ubuntu-latest' }}
on: on:
pull_request: pull_request:
branches: [main] branches: [main]
@ -26,7 +22,7 @@ on:
publish_crates: publish_crates:
description: Publish to crates.io description: Publish to crates.io
type: boolean type: boolean
default: false default: true
env: env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
@ -37,7 +33,7 @@ permissions:
jobs: jobs:
changes: changes:
name: Changes name: Changes
runs-on: ubuntu-latest runs-on: [self-hosted, netty]
outputs: outputs:
rust: ${{ steps.check.outputs.rust }} rust: ${{ steps.check.outputs.rust }}
version: ${{ steps.version.outputs.version }} version: ${{ steps.version.outputs.version }}
@ -105,7 +101,7 @@ jobs:
name: Validate name: Validate
needs: changes needs: changes
if: needs.changes.outputs.rust == 'true' if: needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest runs-on: [self-hosted, netty]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -129,9 +125,6 @@ jobs:
- name: Install site dependencies - name: Install site dependencies
run: pnpm --dir site install --frozen-lockfile run: pnpm --dir site install --frozen-lockfile
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev
- name: Format check - name: Format check
run: make fmt-check run: make fmt-check
@ -148,7 +141,7 @@ jobs:
name: Integration (Xvfb) name: Integration (Xvfb)
needs: changes needs: changes
if: needs.changes.outputs.rust == 'true' if: needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest runs-on: [self-hosted, netty]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -156,9 +149,6 @@ jobs:
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev xvfb
- name: Xvfb integration tests - name: Xvfb integration tests
run: make test-integration run: make test-integration
@ -166,7 +156,7 @@ jobs:
name: Distribution Validate name: Distribution Validate
needs: changes needs: changes
if: needs.changes.outputs.rust == 'true' if: needs.changes.outputs.rust == 'true'
runs-on: ubuntu-latest runs-on: [self-hosted, netty]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -178,19 +168,11 @@ jobs:
with: with:
node-version: 22 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 - name: Distribution validation
run: make dist-validate run: make dist-validate
# --- Release pipeline: update-manifests -> build -> release -> publish --- # --- Release pipeline: update-manifests -> build -> release -> publish ---
# Version bump happens BEFORE build so the binary has the correct version. # These stay on ubuntu-latest for artifact upload/download and registry publishing.
update-manifests: update-manifests:
name: Update Manifests name: Update Manifests
@ -309,27 +291,25 @@ jobs:
artifacts/checksums.txt artifacts/checksums.txt
fi fi
publish: publish-npm:
name: Publish name: Publish npm
needs: [changes, update-manifests, release] needs: [changes, update-manifests, release]
if: github.event_name != 'pull_request' && needs.changes.outputs.rust == 'true' if: >-
github.event_name != 'pull_request'
&& needs.changes.outputs.rust == 'true'
&& (inputs.publish_npm == true || inputs.publish_npm == '')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
ref: ${{ needs.changes.outputs.tag }} ref: ${{ needs.changes.outputs.tag }}
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
registry-url: https://registry.npmjs.org registry-url: https://registry.npmjs.org
- name: Install system dependencies - name: Check if already published
run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev
- name: Check current published state
id: published id: published
run: | run: |
VERSION="${{ needs.changes.outputs.version }}" VERSION="${{ needs.changes.outputs.version }}"
@ -338,13 +318,9 @@ jobs:
else else
echo "npm=false" >> "$GITHUB_OUTPUT" echo "npm=false" >> "$GITHUB_OUTPUT"
fi 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 - name: Validate npm package
if: steps.published.outputs.npm != 'true'
run: node npm/deskctl/scripts/validate-package.js run: node npm/deskctl/scripts/validate-package.js
- name: Publish npm - name: Publish npm
@ -353,8 +329,36 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npm publish ./npm/deskctl --access public 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 - name: Publish crates.io
if: inputs.publish_crates && steps.published.outputs.crates != 'true' if: steps.published.outputs.crates != 'true'
env: env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: cargo publish --locked run: cargo publish --locked

42
Cargo.lock generated
View file

@ -241,9 +241,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.57" version = "1.2.58"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
@ -400,7 +400,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]] [[package]]
name = "deskctl" name = "deskctl"
version = "0.1.11" version = "0.1.14"
dependencies = [ dependencies = [
"ab_glyph", "ab_glyph",
"anyhow", "anyhow",
@ -911,9 +911,9 @@ dependencies = [
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.91" version = "0.3.92"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"wasm-bindgen", "wasm-bindgen",
@ -1039,9 +1039,9 @@ dependencies = [
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.1.1" version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [ dependencies = [
"libc", "libc",
"wasi", "wasi",
@ -1699,9 +1699,9 @@ dependencies = [
[[package]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.8" version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]] [[package]]
name = "simd_helpers" name = "simd_helpers"
@ -1861,9 +1861,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.22.0" version = "1.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
dependencies = [ dependencies = [
"getrandom 0.4.2", "getrandom 0.4.2",
"js-sys", "js-sys",
@ -1907,9 +1907,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.114" version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@ -1920,9 +1920,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.114" version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@ -1930,9 +1930,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.114" version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
@ -1943,9 +1943,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.114" version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@ -2297,9 +2297,9 @@ dependencies = [
[[package]] [[package]]
name = "zune-jpeg" name = "zune-jpeg"
version = "0.5.14" version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7a1c0af6e5d8d1363f4994b7a091ccf963d8b694f7da5b0b9cceb82da2c0a6" checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [ dependencies = [
"zune-core", "zune-core",
] ]

View file

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

View file

@ -1,9 +1,11 @@
# deskctl # deskctl
[![npm](https://img.shields.io/npm/v/deskctl?label=npm)](https://www.npmjs.com/package/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) [![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 ## 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 - publishes the canonical GitHub Release asset
- uploads `checksums.txt` - uploads `checksums.txt`
The registry publish workflow: The registry publish jobs (npm and crates.io run in parallel):
- targets an existing release tag - target an existing release tag
- checks that Cargo, npm, and the requested tag all agree on version - check whether that version is already published on the respective registry
- checks whether that version is already published on npm and crates.io - skip already-published versions
- only publishes the channels explicitly requested - both default to enabled; can be toggled via workflow_dispatch inputs
## Rerun Safety ## Rerun Safety

View file

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

View file

@ -37,9 +37,9 @@ preferred read surface for focused state queries.
## Wait for state transitions ## Wait for state transitions
```sh ```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 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, Wait commands return the matched window payload on success. In `--json` mode,
@ -48,9 +48,9 @@ timeouts and selector failures expose structured `kind` values.
## Act on windows ## Act on windows
```sh ```sh
deskctl launch firefox deskctl launch chromium
deskctl focus @w1 deskctl focus @w1
deskctl focus 'title=Firefox' deskctl focus 'title=Chromium'
deskctl click @w1 deskctl click @w1
deskctl click 960,540 deskctl click 960,540
deskctl dblclick @w2 deskctl dblclick @w2
@ -86,8 +86,8 @@ more deterministic for automation, and easier to retry safely.
```sh ```sh
ref=w1 ref=w1
id=win1 id=win1
title=Firefox title=Chromium
class=firefox class=chromium
focused focused
``` ```
@ -99,7 +99,7 @@ w1
win1 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. match and fail with candidate windows when there are multiple matches.
## Global options ## Global options

View file

@ -43,6 +43,9 @@ import DocLayout from "../layouts/DocLayout.astro";
<li> <li>
<a href="https://github.com/harivansh-afk/deskctl">GitHub</a> <a href="https://github.com/harivansh-afk/deskctl">GitHub</a>
</li> </li>
<li>
<a href="https://crates.io/crates/deskctl">crates.io</a>
</li>
<li> <li>
<a href="https://www.npmjs.com/package/deskctl">npm</a> <a href="https://www.npmjs.com/package/deskctl">npm</a>
</li> </li>

View file

@ -38,13 +38,13 @@ Prefer explicit selectors when you need deterministic targeting:
```sh ```sh
ref=w1 ref=w1
id=win1 id=win1
title=Firefox title=Chromium
class=firefox class=chromium
focused focused
``` ```
Legacy refs such as `@w1` still work after `snapshot` or `list-windows`. Bare 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.
## 4. Wait, act, verify ## 4. Wait, act, verify
@ -55,16 +55,16 @@ The core loop is:
deskctl snapshot --annotate deskctl snapshot --annotate
# wait # wait
deskctl wait window --selector 'title=Firefox' --timeout 10 deskctl wait window --selector 'title=Chromium' --timeout 10
# act # act
deskctl focus 'title=Firefox' deskctl focus 'title=Chromium'
deskctl hotkey ctrl l deskctl hotkey ctrl l
deskctl type "https://example.com" deskctl type "https://example.com"
deskctl press enter deskctl press enter
# verify # verify
deskctl wait focus --selector 'title=Firefox' --timeout 5 deskctl wait focus --selector 'title=Chromium' --timeout 5
deskctl snapshot deskctl snapshot
``` ```
@ -84,8 +84,8 @@ Every command supports `--json` and uses the same top-level envelope:
{ {
"ref_id": "w1", "ref_id": "w1",
"window_id": "win1", "window_id": "win1",
"title": "Firefox", "title": "Chromium",
"app_name": "firefox", "app_name": "chromium",
"x": 0, "x": 0,
"y": 0, "y": 0,
"width": 1920, "width": 1920,

View file

@ -30,8 +30,8 @@ Every desktop interaction follows: **observe -> wait -> act -> verify**.
```bash ```bash
deskctl snapshot --annotate # observe deskctl snapshot --annotate # observe
deskctl wait window --selector 'title=Firefox' --timeout 10 # wait deskctl wait window --selector 'title=Chromium' --timeout 10 # wait
deskctl click 'title=Firefox' # act deskctl click 'title=Chromium' # act
deskctl snapshot # verify deskctl snapshot # verify
``` ```
@ -42,12 +42,12 @@ See [workflows/observe-act.sh](workflows/observe-act.sh) for a reusable script.
```bash ```bash
ref=w1 # snapshot ref (short-lived) ref=w1 # snapshot ref (short-lived)
id=win1 # stable window ID (session-scoped) id=win1 # stable window ID (session-scoped)
title=Firefox # match by title title=Chromium # match by title
class=firefox # match by WM class class=chromium # match by WM class
focused # currently focused window 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 ## References

View file

@ -23,8 +23,8 @@ deskctl get-mouse-position
## Wait ## Wait
```bash ```bash
deskctl wait window --selector 'title=Firefox' --timeout 10 deskctl wait window --selector 'title=Chromium' --timeout 10
deskctl wait focus --selector 'class=firefox' --timeout 5 deskctl wait focus --selector 'class=chromium' --timeout 5
``` ```
Returns the matched window payload on success. Failures include structured Returns the matched window payload on success. Failures include structured
@ -35,8 +35,8 @@ Returns the matched window payload on success. Failures include structured
```bash ```bash
ref=w1 ref=w1
id=win1 id=win1
title=Firefox title=Chromium
class=firefox class=chromium
focused focused
``` ```
@ -46,7 +46,7 @@ on ambiguity.
## Act ## Act
```bash ```bash
deskctl focus 'class=firefox' deskctl focus 'class=chromium'
deskctl click @w1 deskctl click @w1
deskctl dblclick @w2 deskctl dblclick @w2
deskctl type "hello world" deskctl type "hello world"
@ -59,7 +59,7 @@ deskctl mouse drag 100 100 500 500
deskctl move-window @w1 100 120 deskctl move-window @w1 100 120
deskctl resize-window @w1 1280 720 deskctl resize-window @w1 1280 720
deskctl close @w3 deskctl close @w3
deskctl launch firefox deskctl launch chromium
``` ```
The daemon starts automatically on first command. In normal usage you should The daemon starts automatically on first command. In normal usage you should

View file

@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# observe-act.sh - main desktop interaction loop # observe-act.sh - main desktop interaction loop
# usage: ./observe-act.sh <selector> [action] [action-args...] # 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" # example: ./observe-act.sh 'class=terminal' type "ls -la"
set -euo pipefail set -euo pipefail

View file

@ -48,13 +48,13 @@ pub enum Command {
/// Click a window ref or coordinates /// Click a window ref or coordinates
#[command(after_help = CLICK_EXAMPLES)] #[command(after_help = CLICK_EXAMPLES)]
Click { Click {
/// Selector (ref=w1, id=win1, title=Firefox, class=firefox, focused) or x,y coordinates /// Selector (ref=w1, id=win1, title=Chromium, class=chromium, focused) or x,y coordinates
selector: String, selector: String,
}, },
/// Double-click a window ref or coordinates /// Double-click a window ref or coordinates
#[command(after_help = DBLCLICK_EXAMPLES)] #[command(after_help = DBLCLICK_EXAMPLES)]
Dblclick { Dblclick {
/// Selector (ref=w1, id=win1, title=Firefox, class=firefox, focused) or x,y coordinates /// Selector (ref=w1, id=win1, title=Chromium, class=chromium, focused) or x,y coordinates
selector: String, selector: String,
}, },
/// Type text into the focused window /// Type text into the focused window
@ -81,19 +81,19 @@ pub enum Command {
/// Focus a window by ref or name /// Focus a window by ref or name
#[command(after_help = FOCUS_EXAMPLES)] #[command(after_help = FOCUS_EXAMPLES)]
Focus { Focus {
/// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring /// Selector: ref=w1, id=win1, title=Chromium, class=chromium, focused, or a fuzzy substring
selector: String, selector: String,
}, },
/// Close a window by ref or name /// Close a window by ref or name
#[command(after_help = CLOSE_EXAMPLES)] #[command(after_help = CLOSE_EXAMPLES)]
Close { Close {
/// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring /// Selector: ref=w1, id=win1, title=Chromium, class=chromium, focused, or a fuzzy substring
selector: String, selector: String,
}, },
/// Move a window /// Move a window
#[command(after_help = MOVE_WINDOW_EXAMPLES)] #[command(after_help = MOVE_WINDOW_EXAMPLES)]
MoveWindow { MoveWindow {
/// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring /// Selector: ref=w1, id=win1, title=Chromium, class=chromium, focused, or a fuzzy substring
selector: String, selector: String,
/// X position /// X position
x: i32, x: i32,
@ -103,7 +103,7 @@ pub enum Command {
/// Resize a window /// Resize a window
#[command(after_help = RESIZE_WINDOW_EXAMPLES)] #[command(after_help = RESIZE_WINDOW_EXAMPLES)]
ResizeWindow { ResizeWindow {
/// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring /// Selector: ref=w1, id=win1, title=Chromium, class=chromium, focused, or a fuzzy substring
selector: String, selector: String,
/// Width /// Width
w: u32, w: u32,
@ -210,19 +210,19 @@ const SNAPSHOT_EXAMPLES: &str =
const LIST_WINDOWS_EXAMPLES: &str = const LIST_WINDOWS_EXAMPLES: &str =
"Examples:\n deskctl list-windows\n deskctl --json list-windows"; "Examples:\n deskctl list-windows\n deskctl --json list-windows";
const CLICK_EXAMPLES: &str = const CLICK_EXAMPLES: &str =
"Examples:\n deskctl click @w1\n deskctl click 'title=Firefox'\n deskctl click 500,300"; "Examples:\n deskctl click @w1\n deskctl click 'title=Chromium'\n deskctl click 500,300";
const DBLCLICK_EXAMPLES: &str = const DBLCLICK_EXAMPLES: &str =
"Examples:\n deskctl dblclick @w2\n deskctl dblclick 'class=firefox'\n deskctl dblclick 500,300"; "Examples:\n deskctl dblclick @w2\n deskctl dblclick 'class=chromium'\n deskctl dblclick 500,300";
const TYPE_EXAMPLES: &str = const TYPE_EXAMPLES: &str =
"Examples:\n deskctl type \"hello world\"\n deskctl type \"https://example.com\""; "Examples:\n deskctl type \"hello world\"\n deskctl type \"https://example.com\"";
const PRESS_EXAMPLES: &str = "Examples:\n deskctl press enter\n deskctl press escape"; const PRESS_EXAMPLES: &str = "Examples:\n deskctl press enter\n deskctl press escape";
const HOTKEY_EXAMPLES: &str = "Examples:\n deskctl hotkey ctrl l\n deskctl hotkey ctrl shift t"; const HOTKEY_EXAMPLES: &str = "Examples:\n deskctl hotkey ctrl l\n deskctl hotkey ctrl shift t";
const FOCUS_EXAMPLES: &str = const FOCUS_EXAMPLES: &str =
"Examples:\n deskctl focus @w1\n deskctl focus 'title=Firefox'\n deskctl focus focused"; "Examples:\n deskctl focus @w1\n deskctl focus 'title=Chromium'\n deskctl focus focused";
const CLOSE_EXAMPLES: &str = const CLOSE_EXAMPLES: &str =
"Examples:\n deskctl close @w3\n deskctl close 'id=win2'\n deskctl close 'class=firefox'"; "Examples:\n deskctl close @w3\n deskctl close 'id=win2'\n deskctl close 'class=chromium'";
const MOVE_WINDOW_EXAMPLES: &str = const MOVE_WINDOW_EXAMPLES: &str =
"Examples:\n deskctl move-window @w1 100 200\n deskctl move-window 'title=Firefox' 0 0"; "Examples:\n deskctl move-window @w1 100 200\n deskctl move-window 'title=Chromium' 0 0";
const RESIZE_WINDOW_EXAMPLES: &str = const RESIZE_WINDOW_EXAMPLES: &str =
"Examples:\n deskctl resize-window @w1 1280 720\n deskctl resize-window 'id=win2' 800 600"; "Examples:\n deskctl resize-window @w1 1280 720\n deskctl resize-window 'id=win2' 800 600";
const GET_MONITORS_EXAMPLES: &str = const GET_MONITORS_EXAMPLES: &str =
@ -237,12 +237,12 @@ const GET_MOUSE_POSITION_EXAMPLES: &str =
const DOCTOR_EXAMPLES: &str = "Examples:\n deskctl doctor\n deskctl --json doctor"; const DOCTOR_EXAMPLES: &str = "Examples:\n deskctl doctor\n deskctl --json doctor";
const UPGRADE_EXAMPLES: &str = const UPGRADE_EXAMPLES: &str =
"Examples:\n deskctl upgrade\n deskctl upgrade --yes\n deskctl --json upgrade --yes"; "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=Firefox' --timeout 10\n deskctl --json wait window --selector 'class=firefox' --poll-ms 100"; const WAIT_WINDOW_EXAMPLES: &str = "Examples:\n deskctl wait window --selector 'title=Chromium' --timeout 10\n deskctl --json wait window --selector 'class=chromium' --poll-ms 100";
const WAIT_FOCUS_EXAMPLES: &str = "Examples:\n deskctl wait focus --selector 'id=win3' --timeout 5\n deskctl wait focus --selector focused --poll-ms 200"; const WAIT_FOCUS_EXAMPLES: &str = "Examples:\n deskctl wait focus --selector 'id=win3' --timeout 5\n deskctl wait focus --selector focused --poll-ms 200";
const SCREENSHOT_EXAMPLES: &str = const SCREENSHOT_EXAMPLES: &str =
"Examples:\n deskctl screenshot\n deskctl screenshot /tmp/screen.png\n deskctl screenshot --annotate"; "Examples:\n deskctl screenshot\n deskctl screenshot /tmp/screen.png\n deskctl screenshot --annotate";
const LAUNCH_EXAMPLES: &str = const LAUNCH_EXAMPLES: &str =
"Examples:\n deskctl launch firefox\n deskctl launch code -- --new-window"; "Examples:\n deskctl launch chromium\n deskctl launch code -- --new-window";
const MOUSE_MOVE_EXAMPLES: &str = const MOUSE_MOVE_EXAMPLES: &str =
"Examples:\n deskctl mouse move 500 300\n deskctl mouse move 0 0"; "Examples:\n deskctl mouse move 500 300\n deskctl mouse move 0 0";
const MOUSE_SCROLL_EXAMPLES: &str = const MOUSE_SCROLL_EXAMPLES: &str =
@ -277,7 +277,7 @@ pub enum WaitCmd {
#[derive(Args)] #[derive(Args)]
pub struct WaitSelectorOpts { pub struct WaitSelectorOpts {
/// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring /// Selector: ref=w1, id=win1, title=Chromium, class=chromium, focused, or a fuzzy substring
#[arg(long)] #[arg(long)]
pub selector: String, pub selector: String,
@ -1103,8 +1103,8 @@ mod tests {
"windows": [{ "windows": [{
"ref_id": "w1", "ref_id": "w1",
"window_id": "win1", "window_id": "win1",
"title": "Firefox", "title": "Chromium",
"app_name": "firefox", "app_name": "chromium",
"x": 0, "x": 0,
"y": 0, "y": 0,
"width": 1280, "width": 1280,
@ -1125,37 +1125,37 @@ mod tests {
fn action_text_includes_target_identity() { fn action_text_includes_target_identity() {
let lines = render_success_lines( let lines = render_success_lines(
&Command::Focus { &Command::Focus {
selector: "title=Firefox".to_string(), selector: "title=Chromium".to_string(),
}, },
Some(&json!({ Some(&json!({
"action": "focus", "action": "focus",
"window": "Firefox", "window": "Chromium",
"title": "Firefox", "title": "Chromium",
"ref_id": "w2", "ref_id": "w2",
"window_id": "win7" "window_id": "win7"
})), })),
) )
.unwrap(); .unwrap();
assert_eq!(lines, vec!["Focused @w2 [win7] \"Firefox\""]); assert_eq!(lines, vec!["Focused @w2 [win7] \"Chromium\""]);
} }
#[test] #[test]
fn timeout_errors_render_last_observation() { fn timeout_errors_render_last_observation() {
let lines = render_error_lines(&Response::err_with_data( let lines = render_error_lines(&Response::err_with_data(
"Timed out waiting for focus to match selector: title=Firefox", "Timed out waiting for focus to match selector: title=Chromium",
json!({ json!({
"kind": "timeout", "kind": "timeout",
"wait": "focus", "wait": "focus",
"selector": "title=Firefox", "selector": "title=Chromium",
"timeout_ms": 1000, "timeout_ms": 1000,
"last_observation": { "last_observation": {
"kind": "window_not_focused", "kind": "window_not_focused",
"window": { "window": {
"ref_id": "w1", "ref_id": "w1",
"window_id": "win1", "window_id": "win1",
"title": "Firefox", "title": "Chromium",
"app_name": "firefox", "app_name": "chromium",
"x": 0, "x": 0,
"y": 0, "y": 0,
"width": 1280, "width": 1280,
@ -1167,10 +1167,8 @@ mod tests {
}), }),
)); ));
assert!(lines assert!(lines.iter().any(|line| line
.iter() .contains("Timed out after 1000ms waiting for focus selector title=Chromium")));
.any(|line| line
.contains("Timed out after 1000ms waiting for focus selector title=Firefox")));
assert!(lines assert!(lines
.iter() .iter()
.any(|line| line.contains("matching window exists but is not focused yet"))); .any(|line| line.contains("matching window exists but is not focused yet")));
@ -1190,9 +1188,9 @@ mod tests {
let summary = target_summary(&json!({ let summary = target_summary(&json!({
"ref_id": "w1", "ref_id": "w1",
"window_id": "win1", "window_id": "win1",
"title": "Firefox" "title": "Chromium"
})); }));
assert_eq!(summary.as_deref(), Some("@w1 [win1] \"Firefox\"")); assert_eq!(summary.as_deref(), Some("@w1 [win1] \"Chromium\""));
} }
#[test] #[test]

View file

@ -412,8 +412,8 @@ mod tests {
SelectorQuery::WindowId("win4".to_string()) SelectorQuery::WindowId("win4".to_string())
); );
assert_eq!( assert_eq!(
SelectorQuery::parse("title=Firefox"), SelectorQuery::parse("title=Chromium"),
SelectorQuery::Title("Firefox".to_string()) SelectorQuery::Title("Chromium".to_string())
); );
assert_eq!( assert_eq!(
SelectorQuery::parse("class=Navigator"), SelectorQuery::parse("class=Navigator"),
@ -458,11 +458,11 @@ mod tests {
fn fuzzy_resolution_fails_with_candidates_when_ambiguous() { fn fuzzy_resolution_fails_with_candidates_when_ambiguous() {
let mut refs = RefMap::new(); let mut refs = RefMap::new();
refs.rebuild(&[ refs.rebuild(&[
sample_window(1, "Firefox"), sample_window(1, "Chromium"),
BackendWindow { BackendWindow {
native_id: 2, native_id: 2,
title: "Firefox Settings".to_string(), title: "Chromium Settings".to_string(),
app_name: "Firefox".to_string(), app_name: "Chromium".to_string(),
x: 0, x: 0,
y: 0, y: 0,
width: 10, width: 10,
@ -472,7 +472,7 @@ mod tests {
}, },
]); ]);
match refs.resolve("firefox") { match refs.resolve("chromium") {
ResolveResult::Ambiguous { ResolveResult::Ambiguous {
mode, candidates, .. mode, candidates, ..
} => { } => {

View file

@ -1,6 +1,7 @@
mod handler; mod handler;
mod state; mod state;
use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
@ -12,6 +13,29 @@ use crate::core::paths::{pid_path_from_env, socket_path_from_env};
use crate::core::session; use crate::core::session;
use state::DaemonState; use state::DaemonState;
struct RuntimePathsGuard {
socket_path: PathBuf,
pid_path: Option<PathBuf>,
}
impl RuntimePathsGuard {
fn new(socket_path: PathBuf, pid_path: Option<PathBuf>) -> Self {
Self {
socket_path,
pid_path,
}
}
}
impl Drop for RuntimePathsGuard {
fn drop(&mut self) {
remove_runtime_path(&self.socket_path);
if let Some(ref pid_path) = self.pid_path {
remove_runtime_path(pid_path);
}
}
}
pub fn run() -> Result<()> { pub fn run() -> Result<()> {
// Validate session before starting // Validate session before starting
session::detect_session()?; session::detect_session()?;
@ -25,7 +49,6 @@ pub fn run() -> Result<()> {
async fn async_run() -> Result<()> { async fn async_run() -> Result<()> {
let socket_path = socket_path_from_env().context("DESKCTL_SOCKET_PATH not set")?; let socket_path = socket_path_from_env().context("DESKCTL_SOCKET_PATH not set")?;
let pid_path = pid_path_from_env(); let pid_path = pid_path_from_env();
// Clean up stale socket // Clean up stale socket
@ -33,20 +56,21 @@ async fn async_run() -> Result<()> {
std::fs::remove_file(&socket_path)?; std::fs::remove_file(&socket_path)?;
} }
// Write PID file
if let Some(ref pid_path) = pid_path {
std::fs::write(pid_path, std::process::id().to_string())?;
}
let listener = UnixListener::bind(&socket_path)
.context(format!("Failed to bind socket: {}", socket_path.display()))?;
let session = std::env::var("DESKCTL_SESSION").unwrap_or_else(|_| "default".to_string()); let session = std::env::var("DESKCTL_SESSION").unwrap_or_else(|_| "default".to_string());
let state = Arc::new(Mutex::new( let state = Arc::new(Mutex::new(
DaemonState::new(session, socket_path.clone()) DaemonState::new(session, socket_path.clone())
.context("Failed to initialize daemon state")?, .context("Failed to initialize daemon state")?,
)); ));
let listener = UnixListener::bind(&socket_path)
.context(format!("Failed to bind socket: {}", socket_path.display()))?;
let _runtime_paths = RuntimePathsGuard::new(socket_path.clone(), pid_path.clone());
// Write PID file only after the daemon is ready to serve requests.
if let Some(ref pid_path) = pid_path {
std::fs::write(pid_path, std::process::id().to_string())?;
}
let shutdown = Arc::new(tokio::sync::Notify::new()); let shutdown = Arc::new(tokio::sync::Notify::new());
let shutdown_clone = shutdown.clone(); let shutdown_clone = shutdown.clone();
@ -75,14 +99,6 @@ async fn async_run() -> Result<()> {
} }
} }
// Cleanup
if socket_path.exists() {
let _ = std::fs::remove_file(&socket_path);
}
if let Some(ref pid_path) = pid_path {
let _ = std::fs::remove_file(pid_path);
}
Ok(()) Ok(())
} }
@ -123,3 +139,11 @@ async fn handle_connection(
Ok(()) Ok(())
} }
fn remove_runtime_path(path: &Path) {
if let Err(error) = std::fs::remove_file(path) {
if error.kind() != std::io::ErrorKind::NotFound {
eprintln!("Failed to remove runtime path {}: {error}", path.display());
}
}
}

View file

@ -4,6 +4,7 @@ use std::os::unix::net::UnixListener;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Output}; use std::process::{Command, Output};
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
@ -60,8 +61,7 @@ pub struct FixtureWindow {
impl FixtureWindow { impl FixtureWindow {
pub fn create(title: &str, app_class: &str) -> Result<Self> { pub fn create(title: &str, app_class: &str) -> Result<Self> {
let (conn, screen_num) = let (conn, screen_num) = connect_to_test_display()?;
x11rb::connect(None).context("Failed to connect to the integration test display")?;
let screen = &conn.setup().roots[screen_num]; let screen = &conn.setup().roots[screen_num];
let window = conn.generate_id()?; let window = conn.generate_id()?;
@ -103,6 +103,26 @@ impl FixtureWindow {
} }
} }
fn connect_to_test_display() -> Result<(RustConnection, usize)> {
let max_attempts = 10;
let mut last_error = None;
for attempt in 0..max_attempts {
match x11rb::connect(None) {
Ok(connection) => return Ok(connection),
Err(error) => {
last_error = Some(anyhow!(error));
if attempt + 1 < max_attempts {
thread::sleep(std::time::Duration::from_millis(100 * (attempt + 1) as u64));
}
}
}
}
Err(last_error.expect("x11 connection attempts should capture an error"))
.context("Failed to connect to the integration test display")
}
impl Drop for FixtureWindow { impl Drop for FixtureWindow {
fn drop(&mut self) { fn drop(&mut self) {
let _ = self.conn.destroy_window(self.window); let _ = self.conn.destroy_window(self.window);
@ -142,6 +162,10 @@ impl TestSession {
.expect("TestSession always has an explicit socket path") .expect("TestSession always has an explicit socket path")
} }
pub fn pid_path(&self) -> PathBuf {
self.root.join("deskctl.pid")
}
pub fn create_stale_socket(&self) -> Result<()> { pub fn create_stale_socket(&self) -> Result<()> {
let listener = UnixListener::bind(self.socket_path()) let listener = UnixListener::bind(self.socket_path())
.with_context(|| format!("Failed to bind {}", self.socket_path().display()))?; .with_context(|| format!("Failed to bind {}", self.socket_path().display()))?;
@ -187,6 +211,29 @@ impl TestSession {
) )
}) })
} }
pub fn run_daemon<I, K, V>(&self, env: I) -> Result<Output>
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<std::ffi::OsStr>,
V: AsRef<std::ffi::OsStr>,
{
let mut command = Command::new(env!("CARGO_BIN_EXE_deskctl"));
command
.env("DESKCTL_DAEMON", "1")
.env("DESKCTL_SOCKET_PATH", self.socket_path())
.env("DESKCTL_PID_PATH", self.pid_path())
.env("DESKCTL_SESSION", &self.opts.session)
.envs(env);
command.output().with_context(|| {
format!(
"Failed to run daemon {} against {}",
env!("CARGO_BIN_EXE_deskctl"),
self.socket_path().display()
)
})
}
} }
impl Drop for TestSession { impl Drop for TestSession {
@ -195,6 +242,9 @@ impl Drop for TestSession {
if self.socket_path().exists() { if self.socket_path().exists() {
let _ = std::fs::remove_file(self.socket_path()); let _ = std::fs::remove_file(self.socket_path());
} }
if self.pid_path().exists() {
let _ = std::fs::remove_file(self.pid_path());
}
let _ = std::fs::remove_dir_all(&self.root); let _ = std::fs::remove_dir_all(&self.root);
} }
} }

View file

@ -114,6 +114,31 @@ fn daemon_start_recovers_from_stale_socket() -> Result<()> {
Ok(()) Ok(())
} }
#[test]
fn daemon_init_failure_cleans_runtime_state() -> Result<()> {
let _guard = env_lock_guard();
let session = TestSession::new("daemon-init-failure")?;
let output = session.run_daemon([("XDG_SESSION_TYPE", "x11"), ("DISPLAY", ":99999")])?;
assert!(!output.status.success(), "daemon startup should fail");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Failed to initialize daemon state"),
"unexpected stderr: {stderr}"
);
assert!(
!session.socket_path().exists(),
"failed startup should remove the socket path"
);
assert!(
!session.pid_path().exists(),
"failed startup should remove the pid path"
);
Ok(())
}
#[test] #[test]
fn wait_window_returns_matched_window_payload() -> Result<()> { fn wait_window_returns_matched_window_payload() -> Result<()> {
let _guard = env_lock_guard(); let _guard = env_lock_guard();