| 1 | #!/usr/bin/env bash |
| 2 | set -euo pipefail |
| 3 | |
| 4 | SESSION="${A0_DESKTOP_SESSION:-agent-zero-desktop}" |
| 5 | BASE_DIR="${A0_BASE_DIR:-/a0}" |
| 6 | PROFILE_DIR="${A0_DESKTOP_PROFILE:-$BASE_DIR/usr/plugins/_desktop/profiles/$SESSION}" |
| 7 | MANIFEST="${A0_DESKTOP_MANIFEST:-$BASE_DIR/usr/plugins/_desktop/sessions/$SESSION.json}" |
| 8 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
| 9 | DESKTOP_STATE_HELPER="$SCRIPT_DIR/../../../helpers/desktop_state.py" |
| 10 | DESKTOP_STATE_PYTHON="${A0_DESKTOP_STATE_PYTHON:-$(command -v /usr/bin/python3 || command -v python3 || true)}" |
| 11 | |
| 12 | display_from_manifest() { |
| 13 | if [ ! -f "$MANIFEST" ] || ! command -v python3 >/dev/null 2>&1; then |
| 14 | return 0 |
| 15 | fi |
| 16 | python3 - "$MANIFEST" <<'PY' |
| 17 | import json |
| 18 | import sys |
| 19 | |
| 20 | try: |
| 21 | with open(sys.argv[1], "r", encoding="utf-8") as handle: |
| 22 | value = json.load(handle).get("display", "") |
| 23 | except Exception: |
| 24 | value = "" |
| 25 | if value != "": |
| 26 | print(value) |
| 27 | PY |
| 28 | } |
| 29 | |
| 30 | DISPLAY_VALUE="${A0_DESKTOP_DISPLAY:-$(display_from_manifest || true)}" |
| 31 | DISPLAY_VALUE="${DISPLAY_VALUE:-120}" |
| 32 | case "$DISPLAY_VALUE" in |
| 33 | :*) export DISPLAY="$DISPLAY_VALUE" ;; |
| 34 | *) export DISPLAY=":$DISPLAY_VALUE" ;; |
| 35 | esac |
| 36 | |
| 37 | export XAUTHORITY="${A0_DESKTOP_XAUTHORITY:-$PROFILE_DIR/.Xauthority}" |
| 38 | export HOME="${A0_DESKTOP_HOME:-$PROFILE_DIR}" |
| 39 | export A0_DESKTOP_SESSION="$SESSION" |
| 40 | export A0_DESKTOP_MANIFEST="$MANIFEST" |
| 41 | export A0_DESKTOP_PROFILE="$PROFILE_DIR" |
| 42 | export A0_DESKTOP_DISPLAY="$DISPLAY" |
| 43 | export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" |
| 44 | export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" |
| 45 | export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" |
| 46 | export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-XFCE}" |
| 47 | |
| 48 | command_name="${1:-help}" |
| 49 | shift || true |
| 50 | |
| 51 | usage() { |
| 52 | cat <<'EOF' |
| 53 | Usage: desktopctl.sh <command> [args] |
| 54 | |
| 55 | Commands: |
| 56 | env Print the X11 environment used for the Desktop. |
| 57 | check Verify that xdotool can reach the Desktop display. |
| 58 | state --json [--screenshot] [--context-id ID] |
| 59 | Return structured Desktop state as JSON. |
| 60 | observe --json [--screenshot] [--context-id ID] |
| 61 | Return structured state, optionally with a fresh screenshot. |
| 62 | screenshot [PATH] [--context-id ID] |
| 63 | Capture the Desktop to PATH, or to the chat screenshot directory when context-id is set. |
| 64 | active-window Print the active window name. |
| 65 | geometry PATTERN Print the first matching visible window geometry. |
| 66 | wait-window PATTERN Wait for a visible matching window and print its id. |
| 67 | location Print the current X pointer location. |
| 68 | windows [PATTERN] List visible window names matching PATTERN. |
| 69 | focus PATTERN Focus the first visible window matching PATTERN. |
| 70 | scroll DIRECTION [UNITS] Scroll up, down, left, or right; UNITS defaults to 5 clicks. |
| 71 | drag X1 Y1 X2 Y2 Drag from X1,Y1 to X2,Y2 in Desktop coordinates. |
| 72 | right-click X Y Move and right-click at X,Y in Desktop coordinates. |
| 73 | paste-text TEXT Put TEXT on the Desktop clipboard and paste it with an app-native shortcut. |
| 74 | sequence FILE|- Run a newline-delimited command sequence in this process. |
| 75 | batch FILE|- Alias for sequence. |
| 76 | key KEY... Send one or more xdotool key names. |
| 77 | type TEXT Type text into the focused window. |
| 78 | click X Y Move and click at X,Y in Desktop coordinates. |
| 79 | dblclick X Y Move and double-click at X,Y in Desktop coordinates. |
| 80 | launch APP Launch writer, calc, impress, terminal, settings, or workdir. |
| 81 | open-path [PATH] Open PATH in Thunar, defaulting to /a0/usr/workdir. |
| 82 | calc-set-cell FILE SHEET CELL VALUE |
| 83 | Open FILE in visible Calc, set SHEET!CELL, save, and verify. |
| 84 | save Send Ctrl+S to the focused app. |
| 85 | EOF |
| 86 | } |
| 87 | |
| 88 | require_xdotool() { |
| 89 | if ! command -v xdotool >/dev/null 2>&1; then |
| 90 | echo "xdotool is required for Desktop control." >&2 |
| 91 | exit 2 |
| 92 | fi |
| 93 | } |
| 94 | |
| 95 | ensure_display() { |
| 96 | require_xdotool |
| 97 | if ! xdotool getmouselocation >/dev/null 2>&1; then |
| 98 | echo "Desktop X display is not reachable. Open the Desktop surface first." >&2 |
| 99 | exit 2 |
| 100 | fi |
| 101 | } |
| 102 | |
| 103 | desktop_state() { |
| 104 | if [ ! -f "$DESKTOP_STATE_HELPER" ]; then |
| 105 | echo "Desktop state helper not found: $DESKTOP_STATE_HELPER" >&2 |
| 106 | exit 2 |
| 107 | fi |
| 108 | "$DESKTOP_STATE_PYTHON" "$DESKTOP_STATE_HELPER" "$@" |
| 109 | } |
| 110 | |
| 111 | run_detached() { |
| 112 | ( "$@" >/tmp/a0-desktopctl.log 2>&1 & ) |
| 113 | } |
| 114 | |
| 115 | close_blocking_dialogs() { |
| 116 | require_xdotool |
| 117 | for title in "Document in Use"; do |
| 118 | window_ids="$(xdotool search --onlyvisible --name "$title" 2>/dev/null || true)" |
| 119 | printf '%s\n' "$window_ids" | while read -r window_id; do |
| 120 | [ -n "$window_id" ] && xdotool windowclose "$window_id" >/dev/null 2>&1 || true |
| 121 | done |
| 122 | done |
| 123 | } |
| 124 | |
| 125 | first_window() { |
| 126 | pattern="$1" |
| 127 | xdotool search --onlyvisible --name "$pattern" 2>/dev/null | head -n 1 || true |
| 128 | } |
| 129 | |
| 130 | active_window_id() { |
| 131 | xdotool getactivewindow 2>/dev/null || true |
| 132 | } |
| 133 | |
| 134 | active_window_class() { |
| 135 | window_id="$(active_window_id)" |
| 136 | if [ -z "$window_id" ]; then |
| 137 | return 0 |
| 138 | fi |
| 139 | if command -v xprop >/dev/null 2>&1; then |
| 140 | xprop -id "$window_id" WM_CLASS 2>/dev/null | awk -F'"' '/WM_CLASS/ { print $(NF - 1); exit }' |
| 141 | fi |
| 142 | } |
| 143 | |
| 144 | active_window_class_lower() { |
| 145 | active_window_class | tr '[:upper:]' '[:lower:]' |
| 146 | } |
| 147 | |
| 148 | active_window_is_terminal() { |
| 149 | window_class="$(active_window_class_lower)" |
| 150 | case "$window_class" in |
| 151 | *terminal*|xterm|uxterm|rxvt|urxvt|kitty|alacritty|wezterm|konsole) |
| 152 | return 0 |
| 153 | ;; |
| 154 | *) |
| 155 | return 1 |
| 156 | ;; |
| 157 | esac |
| 158 | } |
| 159 | |
| 160 | paste_key_for_active_window() { |
| 161 | printf '%s\n' "${A0_DESKTOP_PASTE_KEY:-ctrl+v}" |
| 162 | } |
| 163 | |
| 164 | window_geometry() { |
| 165 | window_id="$1" |
| 166 | if command -v xwininfo >/dev/null 2>&1; then |
| 167 | xwininfo -id "$window_id" 2>/dev/null | awk ' |
| 168 | /Absolute upper-left X:/ { x=$4 } |
| 169 | /Absolute upper-left Y:/ { y=$4 } |
| 170 | /Width:/ { w=$2 } |
| 171 | /Height:/ { h=$2 } |
| 172 | END { if (w != "") printf "X=%s\nY=%s\nWIDTH=%s\nHEIGHT=%s\n", x, y, w, h } |
| 173 | ' |
| 174 | else |
| 175 | xdotool getwindowgeometry --shell "$window_id" |
| 176 | fi |
| 177 | } |
| 178 | |
| 179 | wait_window() { |
| 180 | pattern="$1" |
| 181 | timeout="${2:-15}" |
| 182 | end=$((SECONDS + timeout)) |
| 183 | while [ "$SECONDS" -le "$end" ]; do |
| 184 | window_id="$(first_window "$pattern")" |
| 185 | if [ -n "$window_id" ]; then |
| 186 | printf '%s\n' "$window_id" |
| 187 | return 0 |
| 188 | fi |
| 189 | sleep 0.25 |
| 190 | done |
| 191 | echo "Timed out waiting for visible window: $pattern" >&2 |
| 192 | return 1 |
| 193 | } |
| 194 | |
| 195 | scroll_desktop() { |
| 196 | direction="$1" |
| 197 | units="${2:-5}" |
| 198 | case "$direction" in |
| 199 | up) button=4 ;; |
| 200 | down) button=5 ;; |
| 201 | left) button=6 ;; |
| 202 | right) button=7 ;; |
| 203 | *) |
| 204 | echo "scroll direction must be up, down, left, or right." >&2 |
| 205 | exit 2 |
| 206 | ;; |
| 207 | esac |
| 208 | xdotool click --repeat "$units" "$button" |
| 209 | } |
| 210 | |
| 211 | paste_text() { |
| 212 | text="$*" |
| 213 | if active_window_is_terminal; then |
| 214 | xdotool type --delay "${A0_DESKTOP_PASTE_TYPE_DELAY_MS:-${A0_DESKTOP_TYPE_DELAY_MS:-4}}" -- "$text" |
| 215 | return |
| 216 | fi |
| 217 | if command -v xclip >/dev/null 2>&1; then |
| 218 | printf '%s' "$text" | xclip -selection clipboard |
| 219 | xdotool key --clearmodifiers "$(paste_key_for_active_window)" |
| 220 | return |
| 221 | fi |
| 222 | xdotool type --delay "${A0_DESKTOP_TYPE_DELAY_MS:-1}" -- "$text" |
| 223 | } |
| 224 | |
| 225 | run_sequence_line() { |
| 226 | line="$1" |
| 227 | [ -z "$line" ] && return 0 |
| 228 | case "$line" in |
| 229 | \#*) return 0 ;; |
| 230 | esac |
| 231 | # shellcheck disable=SC2086 |
| 232 | dispatch_command $line |
| 233 | } |
| 234 | |
| 235 | run_sequence() { |
| 236 | source_file="$1" |
| 237 | if [ "$source_file" = "-" ]; then |
| 238 | while IFS= read -r line; do |
| 239 | run_sequence_line "$line" |
| 240 | done |
| 241 | return |
| 242 | fi |
| 243 | if [ ! -f "$source_file" ]; then |
| 244 | echo "sequence requires an existing FILE or - for stdin." >&2 |
| 245 | exit 2 |
| 246 | fi |
| 247 | while IFS= read -r line || [ -n "$line" ]; do |
| 248 | run_sequence_line "$line" |
| 249 | done < "$source_file" |
| 250 | } |
| 251 | |
| 252 | launch_app() { |
| 253 | app="${1:-}" |
| 254 | soffice="${SOFFICE:-$(command -v soffice || true)}" |
| 255 | soffice="${soffice:-soffice}" |
| 256 | case "$app" in |
| 257 | writer) |
| 258 | run_detached "$soffice" --norestore --nofirststartwizard --nolockcheck "-env:UserInstallation=file://$HOME" --writer |
| 259 | ;; |
| 260 | calc|spreadsheet) |
| 261 | run_detached "$soffice" --norestore --nofirststartwizard --nolockcheck "-env:UserInstallation=file://$HOME" --calc |
| 262 | ;; |
| 263 | impress|presentation) |
| 264 | run_detached "$soffice" --norestore --nofirststartwizard --nolockcheck "-env:UserInstallation=file://$HOME" --impress |
| 265 | ;; |
| 266 | terminal) |
| 267 | run_detached xfce4-terminal --working-directory=/a0/usr/workdir |
| 268 | ;; |
| 269 | settings) |
| 270 | run_detached xfce4-settings-manager |
| 271 | ;; |
| 272 | workdir|files|file-manager) |
| 273 | run_detached thunar /a0/usr/workdir |
| 274 | ;; |
| 275 | *) |
| 276 | echo "Unknown app: ${app:-<empty>}" >&2 |
| 277 | echo "Expected: writer, calc, impress, terminal, settings, or workdir." >&2 |
| 278 | exit 2 |
| 279 | ;; |
| 280 | esac |
| 281 | } |
| 282 | |
| 283 | dispatch_command() { |
| 284 | local command_name="${1:-help}" |
| 285 | shift || true |
| 286 | |
| 287 | case "$command_name" in |
| 288 | help|-h|--help) |
| 289 | usage |
| 290 | ;; |
| 291 | env) |
| 292 | printf 'export DISPLAY=%q\n' "$DISPLAY" |
| 293 | printf 'export XAUTHORITY=%q\n' "$XAUTHORITY" |
| 294 | printf 'export HOME=%q\n' "$HOME" |
| 295 | ;; |
| 296 | check) |
| 297 | ensure_display |
| 298 | xdotool getmouselocation --shell |
| 299 | ;; |
| 300 | state) |
| 301 | if [ "${1:-}" != "--json" ]; then |
| 302 | echo "state currently requires --json." >&2 |
| 303 | exit 2 |
| 304 | fi |
| 305 | shift |
| 306 | desktop_state state --json "$@" |
| 307 | ;; |
| 308 | observe) |
| 309 | if [ "${1:-}" != "--json" ]; then |
| 310 | echo "observe currently requires --json." >&2 |
| 311 | exit 2 |
| 312 | fi |
| 313 | shift |
| 314 | desktop_state observe --json "$@" |
| 315 | ;; |
| 316 | screenshot) |
| 317 | if [ "${1:-}" = "--json" ]; then |
| 318 | shift |
| 319 | desktop_state screenshot --json "$@" |
| 320 | elif [ "$#" -gt 0 ]; then |
| 321 | desktop_state screenshot "$@" |
| 322 | else |
| 323 | desktop_state screenshot |
| 324 | fi |
| 325 | ;; |
| 326 | active-window) |
| 327 | ensure_display |
| 328 | window_id="$(active_window_id)" |
| 329 | if [ -z "$window_id" ]; then |
| 330 | echo "No active window." >&2 |
| 331 | exit 1 |
| 332 | fi |
| 333 | xdotool getwindowname "$window_id" |
| 334 | ;; |
| 335 | geometry) |
| 336 | ensure_display |
| 337 | pattern="${1:?geometry requires a window name pattern}" |
| 338 | window_id="$(first_window "$pattern")" |
| 339 | if [ -z "$window_id" ]; then |
| 340 | echo "No visible window matched: $pattern" >&2 |
| 341 | exit 1 |
| 342 | fi |
| 343 | window_geometry "$window_id" |
| 344 | ;; |
| 345 | wait-window) |
| 346 | ensure_display |
| 347 | pattern="${1:?wait-window requires a window name pattern}" |
| 348 | wait_window "$pattern" "${2:-15}" |
| 349 | ;; |
| 350 | location) |
| 351 | ensure_display |
| 352 | xdotool getmouselocation --shell |
| 353 | ;; |
| 354 | windows) |
| 355 | ensure_display |
| 356 | pattern="${1:-.}" |
| 357 | xdotool search --onlyvisible --name "$pattern" getwindowname %@ 2>/dev/null || true |
| 358 | ;; |
| 359 | focus) |
| 360 | ensure_display |
| 361 | pattern="${1:?focus requires a window name pattern}" |
| 362 | window_id="$(first_window "$pattern")" |
| 363 | if [ -z "$window_id" ]; then |
| 364 | echo "No visible window matched: $pattern" >&2 |
| 365 | exit 1 |
| 366 | fi |
| 367 | xdotool windowactivate --sync "$window_id" |
| 368 | ;; |
| 369 | scroll) |
| 370 | ensure_display |
| 371 | scroll_desktop "${1:?scroll requires DIRECTION}" "${2:-5}" |
| 372 | ;; |
| 373 | drag) |
| 374 | ensure_display |
| 375 | x1="${1:?drag requires X1}" |
| 376 | y1="${2:?drag requires Y1}" |
| 377 | x2="${3:?drag requires X2}" |
| 378 | y2="${4:?drag requires Y2}" |
| 379 | xdotool mousemove --sync "$x1" "$y1" mousedown 1 mousemove --sync "$x2" "$y2" mouseup 1 |
| 380 | ;; |
| 381 | right-click) |
| 382 | ensure_display |
| 383 | x="${1:?right-click requires X}" |
| 384 | y="${2:?right-click requires Y}" |
| 385 | xdotool mousemove --sync "$x" "$y" click 3 |
| 386 | ;; |
| 387 | paste-text) |
| 388 | ensure_display |
| 389 | if [ "$#" -eq 0 ]; then |
| 390 | echo "paste-text requires TEXT." >&2 |
| 391 | exit 2 |
| 392 | fi |
| 393 | paste_text "$@" |
| 394 | ;; |
| 395 | sequence|batch) |
| 396 | source_file="${1:?sequence requires FILE or -}" |
| 397 | run_sequence "$source_file" |
| 398 | ;; |
| 399 | key) |
| 400 | ensure_display |
| 401 | if [ "$#" -eq 0 ]; then |
| 402 | echo "key requires at least one xdotool key name." >&2 |
| 403 | exit 2 |
| 404 | fi |
| 405 | xdotool key --clearmodifiers "$@" |
| 406 | ;; |
| 407 | type) |
| 408 | ensure_display |
| 409 | text="$*" |
| 410 | xdotool type --delay "${A0_DESKTOP_TYPE_DELAY_MS:-1}" -- "$text" |
| 411 | ;; |
| 412 | click) |
| 413 | ensure_display |
| 414 | x="${1:?click requires X}" |
| 415 | y="${2:?click requires Y}" |
| 416 | xdotool mousemove --sync "$x" "$y" click 1 |
| 417 | ;; |
| 418 | dblclick) |
| 419 | ensure_display |
| 420 | x="${1:?dblclick requires X}" |
| 421 | y="${2:?dblclick requires Y}" |
| 422 | xdotool mousemove --sync "$x" "$y" click --repeat 2 --delay "${A0_DESKTOP_DBLCLICK_DELAY_MS:-150}" 1 |
| 423 | ;; |
| 424 | launch) |
| 425 | ensure_display |
| 426 | launch_app "${1:-}" |
| 427 | ;; |
| 428 | open-path) |
| 429 | ensure_display |
| 430 | path="${1:-/a0/usr/workdir}" |
| 431 | run_detached thunar "$path" |
| 432 | ;; |
| 433 | calc-set-cell) |
| 434 | ensure_display |
| 435 | file="${1:?calc-set-cell requires FILE}" |
| 436 | sheet="${2:?calc-set-cell requires SHEET}" |
| 437 | cell="${3:?calc-set-cell requires CELL}" |
| 438 | shift 3 |
| 439 | if [ "$#" -eq 0 ]; then |
| 440 | echo "calc-set-cell requires VALUE." >&2 |
| 441 | exit 2 |
| 442 | fi |
| 443 | close_blocking_dialogs |
| 444 | export PYTHONPATH="${PYTHONPATH:-/usr/lib/python3/dist-packages:/usr/lib/libreoffice/program:}" |
| 445 | python3 "$SCRIPT_DIR/calc_set_cell.py" "$file" "$sheet" "$cell" "$@" |
| 446 | ;; |
| 447 | save) |
| 448 | ensure_display |
| 449 | xdotool key --clearmodifiers ctrl+s |
| 450 | ;; |
| 451 | *) |
| 452 | echo "Unknown command: $command_name" >&2 |
| 453 | usage >&2 |
| 454 | exit 2 |
| 455 | ;; |
| 456 | esac |
| 457 | } |
| 458 | |
| 459 | dispatch_command "$command_name" "$@" |