Skip to main content
Glama
kde-desktop-mcp

kde-desktop-mcp

by ahmetadakli
README.md
# kde-desktop-mcp

A screenshot-free desktop control MCP server for KDE Plasma (Wayland),
Hyprland and Windows. It lets MCP clients such as Claude Code read the inside
of applications through the accessibility tree and act on elements directly.
Written in Rust; a single executable.

> Verified on a real KDE Plasma desktop. Hyprland was verified in a nested
> session inside KDE. On Windows, keyboard, mouse and screenshots were tried
> under Wine; tree reading (`tree`, `find`, `get_text`, `focus`, `click`) has
> not yet been tried on real Windows. Please share results from
> `tools/mcp_smoke_test.py`.

## How it works

Instead of looking at the screen, Claude reads the elements of a window as a
list like `[55] button "Save"` and acts on an element by its number.

| Layer | KDE Plasma (Wayland) | Hyprland | Windows |
|---|---|---|---|
| Reading | AT-SPI (D-Bus) | AT-SPI (D-Bus) | UI Automation cache requests |
| Element actions | AT-SPI actions, EditableText | AT-SPI actions, EditableText | Invoke, Toggle, ExpandCollapse, SelectionItem, Value patterns |
| Keyboard | `/dev/uinput` | `/dev/uinput` | Win32 `SendInput` |
| Mouse | Absolute uinput pointer | Hyprland `movecursor` + uinput buttons | `SetCursorPos` + `SendInput` |
| Typing text | Direct insertion via AT-SPI into Qt multi-line fields (clipboard untouched); elsewhere in-process clipboard + Ctrl+V, after which the previous clipboard text is put back | `wtype` (clipboard untouched); otherwise clipboard + Ctrl+V as on KDE | `KEYEVENTF_UNICODE` (clipboard untouched) |
| Windows | KWin scripting (D-Bus) | Hyprland socket: `clients`, `monitors`, dispatchers, batched in one request | `SetForegroundWindow` |
| Screenshot (fallback) | KWin `ScreenShot2` (D-Bus); else `spectacle` | `grim` | GDI |

The code lives in `rust/src/`: `main.rs` holds the MCP protocol, the
allowlist, element ids and the tools; `render.rs` the tree and search output;
`backend.rs` defines the platform interface. Platform code is in `linux/`
(KDE and Hyprland share an AT-SPI layer) and `windows/`. Claude sees the same
tools on every platform.

The desktop is chosen automatically: Windows on Windows; on Linux, Hyprland if
`HYPRLAND_INSTANCE_SIGNATURE` is set, otherwise KDE. To choose by hand on
Linux, set `DESKTOP_MCP_BACKEND` to `kde` or `hyprland`.

On Hyprland the active window is matched to its AT-SPI application by process
id (pid), so the toolkit's focus flag does not have to be trusted.

`focus` first asks the accessibility layer for focus. If the app refuses (common
with Qt), it clicks the centre of the element for real.

Why it is fast:
- It talks to AT-SPI over D-Bus directly, without libatspi, and pipelines
  requests: all attributes of an element, and every level of the tree, are
  requested at once, so the time is bound by the app's response speed rather
  than by round-trip latency.
- On Windows a window's whole subtree, with the properties needed, arrives in
  a single UI Automation cache request (`BuildUpdatedCache`).
- KWin scripts go over D-Bus instead of `qdbus6` processes, Hyprland commands
  over Hyprland's socket instead of `hyprctl` processes.
- Start-up takes 2 ms; the virtual keyboard and mouse are prepared in parallel
  at start-up.
- On KDE the waits were measured and shortened (uinput events are processed
  in order); `focus` remembers focus methods that do not work in an app and
  skips them; `type_text` starts no process; screenshots come straight from
  KWin instead of `spectacle`.
- Output is short, so Claude reads less: one space of indent per level, sibling
  leaves with the same role and actions share one line
  (`list item ×3: [8] "a" [9] "b" [10] "c"`), and the "Set Focus" action Qt
  gives almost every element is not listed. A Dolphin + Kate `tree` went from
  15,182 to 7,092 characters.
- In browsers too: Firefox's "clickAncestor" action on every element and the
  embedded-object placeholder (U+FFFC) of web pages are not listed; real action
  names are read instead of Firefox's broken batched names (`;;`).
  `tree(document=true)` shows only the active tab's page (found in one call
  through the window's EMBEDS relation). The same Firefox page went from 5,943
  to 1,421 characters.
- In Chromium and Electron apps, the `showContextMenu` action every element
  carries is not listed either, and the generic `doDefault` of their frame,
  panels and pages counts as implied: it no longer keeps nameless panels as
  lines. A Ferdium (WhatsApp) tree went from 19,906 characters, cut at 300
  nodes behind six nested panels, to 7,759 for the whole window; with
  `interactive: true` from 19,924 to 2,586, the chat list's rows included
  (see below). `act` without `action` never picks `showContextMenu`: an
  element with no other action is an error that says to use `click`, or
  `action: "showContextMenu"` when the menu is what is wanted.
- Actions a role implies are left out (a link's jump, a button's press, a
  tab's switch, a web section's click); a button or link without any says
  `actions=none`. Nameless, text-free wrappers (landmarks, forms, scroll panes)
  are left out with their children lifted, as are empty nameless leaves
  (separators, images inside buttons) and labels around a same-named button.
  A Google results page in Firefox went from 6,976 to 5,702 characters.
  A static text or label leaf that only repeats what its parent's line
  shows (a heading's or link's own text node, a status bar's count) is left
  out of `tree`, and of `find` when the parent is listed as high; asked for
  with `role`, it is listed. A YouTube page's `tree(document=true)` went
  from 3,792 to 3,327 characters. `get_text` in Chromium no longer reads
  such leaves a second time after the text of the element that holds them
  (a WhatsApp page: 3,035 instead of 5,010 characters, the same text), and
  the U+FFFC in a container's text goes to the child object it stands for
  (an avatar, an emoji image) rather than to a text leaf whose words the
  text already holds.
- On Linux, a `find`, `tree` of an app, or element picked by `app` + `query`
  reuses the app's tree read by an earlier call while the app provably did not
  change: it sent no AT-SPI change event since (children, name, text, any
  state, visible data, windows), no app or window came or went, no tool sent
  input or activated a window, the user did not touch the keyboard or mouse,
  and the read is under 2 s old. Only apps known to send these events (Qt,
  Firefox, Chromium, or any app once it sent one) and no page still loading.
  An element taken from such a read is checked to still be there (one call)
  before it is used. A `get_text` by query in Firefox went from 8.7 ms to
  1.1 ms. Clicking by query also measures the element in the same round trip
  as the lookup, from the window found in the read.
- `apps` lists blocked apps by name on one line and skips untitled windows.
- An AT-SPI call waits at most 2 s; an app that does not answer cannot lock
  the server. Actions that open a menu (Qt does not reply until the menu
  closes) count as sent after 400 ms.
- Tool calls are handled concurrently: a pending `wait_for` or a slow app does
  not hold up `ping` and other calls. If Claude cancels a call
  (`notifications/cancelled`), it is stopped. Tools that send keyboard or
  pointer input still run one at a time, so keystrokes never interleave.
- Popup menus and overlays of web pages are part of `tree`/`find` output.
  Firefox does not mark these layers as SHOWING; containers that are visible
  and lie inside the window are entered as well, and so are elements without
  a layout box (`display: contents`, -1 extents): YouTube wraps its whole page
  in one, which hid every element of the page from `find` and `query`.
  Content scrolled out of view is skipped. Example: Gemini's upload menu
  ("Dosya yükleyin") is now found without a screenshot.
- Matching (`find`, `query`, `wait_for`) compares names and text case-folded
  (Turkish İ/ı as i), with whitespace runs, no-break spaces and the
  placeholders of web containers as one space and soft hyphens and zero-width
  characters left out; up to 1000 characters of an element's text are
  searched. When nothing matches that way, an element whose name or text holds
  every word of the query, in any order and accents aside, does ("cozum
  yontemleri" for "Çözüm Yöntemleri"). When still nothing visible matches,
  `find` (and the error of a tool given `query`) lists up to five matches
  scrolled out of view in the web page, with ids (`act` and `get_text` work
  on them as they are; `act` with `action: "scroll_to"` brings one into view
  for `click`), else up to five visible elements with the closest
  names (`no matches; closest: …`), so the next call can use an id instead of
  a screenshot.
- Read tools (`apps`, `tree`, `find`, `get_text`, `wait_for`, `wait_idle`,
  `screenshot`, `pause`, `clipboard_get`, `windows`, `doctor`) carry MCP's
  `readOnlyHint`;
  Claude Code runs several of them in parallel when asked for in one message.
  The others carry `destructiveHint`: true for `close_window` and the input
  tools (which can do whatever the user could), false for `activate_window`,
  `move_window` and `launch`.

## On-screen indicator

While the agent uses the keyboard or mouse, the screen edges glow orange, a
label at the top says what it is doing (e.g. "Claude · typing") and a ring
marks each pointer target. All of them are layers that pass clicks through to
the window below (wlr-layer-shell; KDE Plasma and Hyprland). They disappear
2.5 s after the last action and are hidden before a screenshot is taken. The
server starts the indicator on the first action as `desktop-mcp --indicator`;
it exits with the server. To turn it off, set the environment variable
`DESKTOP_MCP_INDICATOR=0` in the MCP configuration.

## When the user takes over

The agent and the user share one keyboard and mouse. Input tools (`act`,
`set_text`, `focus`, `click`, `drag`, `scroll`, `key`, `type_text`, `mouse`,
`activate_window`, `file_dialog`, `move_window`, `launch`, and any tool given
`window`) wait for the user:

- **Pause.** While the user typed or moved the pointer in the last 1.5 s,
  they fail with `paused: the user is using the keyboard ...` without
  sending anything; the agent retries once the user stops. User input during
  a batch, a drag or a repeated `key` stops the remaining input
  (`interrupted: ...`). Read tools and the clipboard tools keep working.
- **Stop shortcut: Meta+Alt+Esc.** It ends a running drag or batch and refuses
  every input tool with `stopped by the user` until the agent calls
  `activate_window` on its own (not as a batch step and not through
  `window`); the agent is told to ask you first. The indicator shows
  "paused" or "stopped" when it refuses. (Plasma 6 already uses Meta+Esc for
  System Monitor.)

How it is seen (`doctor` shows it under "takeover detection"):
- With read access to `/dev/input` (the user is in the `input` group), the
  server reads the real keyboards and pointers there: read-only, never
  grabbed, its own `desktop-mcp-*` devices left out. This is exact, and the
  stop chord is seen on every compositor. Note that the `input` group lets
  every program of the user read all keystrokes.
- Otherwise it follows the compositor's idle notifications
  (`ext-idle-notify-v1`, KDE and Hyprland). They count the agent's own input
  too, so user input is only noticed outside of it (between steps, or when
  it lasts longer than the agent's).
- KDE registers the stop shortcut with KGlobalAccel: it appears under System
  Settings > Shortcuts > Desktop MCP, where it can be changed. On Hyprland the
  stop shortcut needs `/dev/input`.

Settings (environment variables in the MCP configuration):
`DESKTOP_MCP_TAKEOVER_MS` (default 1500; 0 turns the pause off) and
`DESKTOP_MCP_STOP_KEY` (default `meta+alt+esc`, key names as for the `key`
tool; `off` turns it off). Not available on Windows yet.

## Tools

- `apps`, `tree`, `find`, `get_text`: reading and searching. With
  `document: true`, `tree` lists only the document of the active window (in a
  browser, that tab's page without tabs and toolbars). `get_text` on a web page
  or another container returns the text of everything in it in reading order
  (web containers' own text is only a placeholder per child element). It
  returns at most `limit` characters (default 10,000, about 2,500 tokens)
  from `offset` (default 0); a cut reply ends with `… cut at character N;
  get_text with offset=N reads on`. Whole web pages used to come back as
  20,000 characters while the part wanted was near the top.
  A `tree`/`find` line shows up to 80 characters of an element's text with its
  line breaks (`text="Shopping list\nmilk\nbread\n"`), so a short document's
  lines and final newline are visible without `get_text`; `…` after the
  closing quote marks a text that goes on.
- Large trees: `tree(interactive: true)` lists only elements one can operate
  (actions, editable/checkable/selectable/expandable, and on Linux focusable
  named table rows, cells, list and tree items: a web app's chat list whose
  rows say only `clickAncestor`/`showContextMenu`) under their nearest
  named ancestor; on a GitHub issues page in Firefox it was 15,339 instead of
  24,687 characters and still listed all 259 links, buttons and fields. A
  reply is capped at `max_chars` (default 20,000) and `max_nodes`; a cut one
  ends with `… N more nodes; continue with tree(…, after=ID)`, which lists the
  rest after that element (ids are stable, so pages join up exactly).
  `screenshot: true` adds the image of the app's window when it is in front.
- `set_text` reads back what it wrote: the reply says "verified", or it fails
  saying what the field holds (a length limit, reformatting). Firefox's input
  fields report accessibility writes as successful and do nothing, so when
  single-line text does not take, it focuses the element, selects all with
  Ctrl+A and types it. An editable element without a text interface (Qt 6's
  editable combo boxes, such as a KDE file dialog's name field) is typed into
  the same way and read back from its name. Password fields cannot be read
  back.
- `scroll`: turns the wheel over an element (a list, a page, a panel).
- Verified actions of `act` (one tool rather than new ones: `act` is loaded
  up front, so they cost no tool-search turn, and the schema grew by about
  570 characters instead of three new tools of 1,700 each). Each reads the
  state back and replies with it (`Toggle -> checked (verified)`), or fails
  saying what it sent and what it reads instead (`sent Toggle, but it reads
  unchecked afterwards`); one already in the asked state sends nothing and
  says so (`already checked; nothing sent`), so it is safe to repeat:
  - `action: "check"` / `"uncheck"`: a check box, check menu item, radio
    button (only checked: another one of its group unchecks it), toggle button
    or switch, or anything the platform marks checkable. Runs its check,
    uncheck, toggle, press or click action. A checkable list or tree row
    (Qt's item views, GTK's toggle cells) only through an action named
    check/uncheck, or toggle outside Qt (Qt's Toggle selects the row);
    otherwise focus through accessibility and Space (`full` tier).
    `"toggle"` flips it, or the expansion of an element that is expandable,
    not checkable, and has no action named Toggle (one that has it, a Qt
    row, runs that action as before).
  - `action: "expand"` / `"collapse"`: a tree item, disclosure button,
    accordion or combo box through an action named for it (`expand`,
    `collapse`, GTK's `expand or contract`); buttons and combo boxes may use
    their press. A list or tree row is never pressed for it (that opens a
    file in Dolphin): an element with no such action (Qt's tree items) gets
    focused through accessibility and sent Right or Left, which needs the
    `full` tier; when it cannot be focused that way the reply says to click
    it and press the key.
  - `option: "UTF-8"` (with `action: "select"`, or alone): picks the entry
    with that label (matched like `menu` labels: case, `&` markers, ellipses
    aside; a unique part is enough) in a combo box, list, tab list or menu
    button, from its entries in the tree even while it is closed. In order,
    each only while the one before is not verified: the list's AT-SPI
    Selection (`SelectChild`, after `ClearSelection` in a multi-selection
    list; skipped in Firefox and Chromium pages, whose scripts would not see
    it), the entry's own select/click/press action (not
    Qt's Toggle, which only marks a row), then for a closed combo box (with the
    `full` tier) focus through accessibility and as many Down/Up presses as
    entries lie between the shown one and the wanted one. Verified by the
    value a combo box is named after (Qt, GTK), else the entry's `selected`
    state (Firefox); a list where other entries stay selected too says how
    many. A control that lists no entries is opened through its action only
    when it opens a popup (a combo box, a menu, a button marked as having a
    popup or with a Show Menu/open/expand action) and the entry is taken from
    the popup menu; anything else (a plain button, a row) fails with nothing
    sent.
  - `action: "scroll_to"`: brings an element scrolled out of view (an id from
    the off-screen list of a miss) into view through AT-SPI
    `Component.ScrollTo` (on the element, else its nearest ancestors); when
    that is missing or does not show it, turns the wheel over its scrollable
    container (5 notches a turn, at most 10, toward the element) until it is
    showing, and says that the pointer moved. The wheel turns over the part of
    the container inside its window (in a web page over the page, not a
    `<ul>` or `<table>` in it), and stops when the user takes over. An element
    that is hidden (not visible: in a closed popup or a collapsed branch; a Qt
    row scrolled out of a showing list counts as scrolled away) gets no wheel.

  None of them opens a Firefox menu or `<select>` popup through accessibility
  (see `menu`): such a call fails with nothing sent and says to click the
  control. Their key fallbacks stop when the user takes over, and the act
  call's confirmation covers them. On Windows (roles compared without
  spaces: `checkbox`, `listitem`), check/uncheck/expand/collapse use the UI
  Automation Toggle and ExpandCollapse actions and `select` the entry's
  SelectionItem;
  Selection and ScrollTo are not supported there (untested on Windows).
  Verified so far with unit tests only; not yet tried against Kate, Dolphin
  or Firefox.
- Modifier clicks: `click`, `mouse` and `scroll` take `modifiers` (`ctrl`,
  `shift`, `alt`, `meta`), pressed before the pointer action and released in
  reverse order afterwards, also when it fails (ctrl+click adds to a
  selection, shift+click selects a range, ctrl+scroll zooms). `clicks: 2` is a
  double and `clicks: 3` a triple click (select a line); `button` is `left`,
  `right` or `middle`.
- `hold`: presses a key, chord or mouse button and leaves it down across calls
  (`down: false` releases it; without `key`/`button` it releases everything).
  Every input reply ends with what is still held (`; held: shift, left`).
  Everything held is released when a call fails, when a call is cancelled
  (`notifications/cancelled`) and when the server exits (end of input,
  SIGTERM/SIGINT/SIGHUP); on Linux the kernel also releases the keys of the
  virtual devices when the process dies. Meant for modifiers and buttons: a
  held letter auto-repeats like a real key.
- `cursor_position`: where the cursor is, in the coordinates `mouse` takes
  (KDE: `workspace.cursorPos` through a KWin script; Hyprland: `cursorpos`;
  Windows: `GetCursorPos`). Read-only.
- `drag`: presses, moves in steps and releases (drag and drop, sliders,
  selections). Each end is an element id (`from_id`/`to_id`), a screen
  coordinate, or with `image: true` a pixel of the last screenshot.
- `act`, `set_text`, `focus`, `click`: act on an element. The element is picked
  by its `tree`/`find` number (`id`) or in one call by `app` + `query`
  (+ `role`): a visible element whose name equals `query`, else one whose
  name or text contains it. Window, tab and page titles (frame, window, page
  tab, and a web page's document) count only when nothing else matches or
  `role` names that role; `wait_for` skips them unless `role` names them.
  Last come elements holding every word of the query, accents aside.
- Ties: when several elements match equally well (the best of those tiers),
  these tie-breaks apply in turn, while the matches are in several windows of
  the app: (1) a window whose title contains the call's `window` argument
  beats the others (before the active window, whose ACTIVE state can lag
  just after `window` raised another); (2) the app's active window beats its
  background windows; (3) a dialog (dialog, alert, file chooser) beats the app's other windows,
  and of stacked dialogs the one opened last (listed last) wins. Then,
  wherever they are: (4) an element one operates (a button, entry, list
  item: an interactive role or state) beats one that only shows the name (a
  label, a heading, a list named the same); (5) for input tools, an element
  with an on-screen area beats one without (zero size), whether its window
  is in front or not (checked for up to 12 candidates). A tie left after
  that is refused by the input tools (`act`, `click`, `focus`, `set_text`,
  `scroll`), which send nothing and list at most 6 candidates with ids, so the
  next call passes one `id` (or `role`, or `window`):
  ```
  ambiguous: 2 elements in 'firefox' match 'Kapat'; nothing sent. pass one id:
  [3] push button "Kapat" at 3440,0
  [4] push button "Kapat" at 3440,22
  ```
  The position is shown when the window is in front; the window title is
  added when the candidates are in several windows.
  Read tools take the first: `get_text` notes it (`[5] … (first of 2
  matches):`), `wait_for` matches on it, `find` lists them all. Matches are
  counted over one read of the app, made afresh for a refusal.
- Effect and `observe` on input tools (`act`, `menu`, `close_window`, `click`,
  `key`, `type_text`, `mouse`, `drag`, `scroll`, `set_text`): with
  `observe` (and by default for `act`, `menu` and `close_window`) the reply
  ends with an `effect:` line comparing the active window before and after the
  input (its title, the focused element, and on Linux the app's accessibility
  change events): `changed`, `no visible change`, or `window changed to
  '<title>'` (a dialog opened, a window closed). It waits for the app to react
  up to `settle_ms` (default 150), returning as soon as it reported a change;
  the fingerprint itself costs about 1 ms. A canvas that draws without
  accessibility events may report `no visible change`. `observe` adds a view
  in the same reply: `focus` (the focused element), `tree` (compact tree of
  the active window, or of `app`'s, at most 6000 characters) or `screenshot`
  (inline image). Without `observe`, the other input tools still note,
  without waiting, when another window is in front after the input (`; active
  window now '<title>'`) or came to the front since the previous input (`;
  before it, '<title>' had come to the front`: a dialog that opened after
  that reply); about 3 ms per input.
- What an input opened (Linux): after `act`, `menu`, `close_window` and the
  input tools given `observe`, and after `click`, `key`, `mouse` and
  `type_text` without it, the reply lists the menus, popup menus, combo box
  lists, dialogs and alerts of allowed apps that opened, one line each, with
  their entries' ids, so the next call acts on them directly:
  ```
  opened: popup menu (8 entries): [51] "Kopyala" [52] "Yapıştır" [53] check menu item "Satır Sarma" {checked} …
  opened: dialog 'Değişiklikleri kaydet?': "Belge değiştirildi." [60] push button "Kaydet" [61] "Kaydetme" [62] "İptal"
  ```
  A role is named where it changes (a menu's `menu item`s and a list's `list
  item`s go without); a dialog's buttons come first, after its first label
  (the question it asks), those of its last button group (OK, Save, Cancel)
  before the others, then its other controls; its lists, tables, trees,
  documents and tool bars are not read. A KDE file dialog that `file_dialog`
  fills is named as `opened: file dialog 'Open File' (use file_dialog)`,
  without entries. At most 12 entries and about 600 characters a line (the
  rest is counted: `… +5 more`), 3 surfaces a reply; a surface listed once
  in a call (by `close_window`) is not read or listed again in its effect
  line. What showed after a reply (a dialog a button opened a few ms after
  the click) is listed by the next input's reply as `before it, opened: …`;
  what one reply listed is not again. A surface is listed only when its
  app's name reads as an allowed app's. The AT-SPI events
  `object:state-changed:showing` and `window:create` say what showed since
  the input: when none came, this costs nothing (no round trip); otherwise
  the role of each shown element is read (one round trip for all of them),
  and only menus, dialogs and lists under a combo box are read further. A
  right click (`click`, `mouse`), a click on a combo box or menu entry, and
  keys that usually open a menu or dialog (`menu`, `shift+f10`, `f10`,
  `alt`+a letter, `alt+down`, `alt+f4`, `ctrl+o/s/p/w/q`, `ctrl+shift+s`)
  wait up to 150 ms for something to show, ending as soon as it did; other
  inputs only list what showed already. When registering those events
  failed, those likely openers and `act`, `menu` and `close_window` look at
  the active app's top-level windows instead, and a surface one such look
  listed is not read or listed again while it stays open. Not on Windows:
  UI Automation's roles do not name menus and dialogs this way (a dialog is
  a `window`), so nothing is looked at or waited for there.
  Only menus already open are read: nothing here opens a menu through
  accessibility (Firefox can crash when one does).
- Failures say what is on screen: when `find` finds nothing, or `act`,
  `click`, `focus`, `get_text`, `set_text`, `scroll`, `drag`, `wait_for`,
  `wait_idle`, `menu`, `key`, `type_text` or `mouse` fails (or a `batch` step
  fails and the batch does not end with `screenshot` or `tree`), the reply
  ends with `now: active window '<title>' [app]; focus: <element>`, so the
  next call needs no screenshot to see why. Titles in such notes
  (`now:`, `activated '…'`, `active window now '…'`) are cut at 80
  characters like element names, with the `…` after the closing quote so the
  quoted part still works as `window`, and a focus line leaves out the
  `focused` state. Not for refusals by the
  allowlist, confirmations, an ambiguous target or the user taking over. A miss by `query` also
  lists off-screen matches or the closest names with ids (see `find`).
- Stale ids: every use of an `id` reads the element's role and name again (one
  round trip). If the element is gone, or its role or name changed (the page
  was redrawn, the object path now belongs to another element), nothing is
  done and the reply is `stale id N (was <role> "<name>", now ...); call
  tree/find again`. For elements whose name follows their content or title
  (windows, tabs, documents, labels, status bars, text fields, combo boxes,
  editable elements) and for unnamed elements, only the role is compared.
- `wait_for`: waits until an element appears or disappears (checks every
  50 ms, at most 10 minutes); for dialogs opened by a key or click, a long
  load or a build. A longer timeout is cut to 10 minutes and the reply says so.
  `state` (enabled, focused, checked, selected) waits until the element has
  that state (with `gone`, until it no longer does). `window` instead of
  `query` waits for a window of an allowed app whose title or app name
  contains it to open, or with `gone` to close (KDE and Hyprland window
  lists, re-read when AT-SPI reports a window change).
- `wait_idle`: waits until an app's accessibility tree has not changed for
  `quiet_ms` (default 300) and no browser page is still marked busy (loading
  from the network): after opening a URL or a link, use it instead of a fixed
  `pause`. The reply names the app's window title (a browser's page title);
  a timeout says when a page was still loading.
- `pause`: waits a fixed time (`ms`, at most 10 minutes); for waiting on time
  only, not on a visible change.
- `windows`: lists windows of allowed apps with position, size, state (active,
  maximized, minimized), output and virtual desktop in screen coordinates,
  after the screen layout (name, area, scale) and the virtual desktops
  (KDE: number, name, the current one; Hyprland: the regular workspaces and
  the monitor each is on) (KDE and Hyprland).
- `move_window`: moves, resizes, maximizes, minimizes or restores a window,
  and with `desktop` (a number or name as `windows` lists them) moves it to
  that virtual desktop (KDE: the window is then on that one only) or
  workspace (Hyprland: silently, the view stays; a new number makes the
  workspace); waits (at most 1 s) until the window has its new geometry and
  reports the real result (KDE and Hyprland; see the Hyprland limitations).
- `close_window`: closes the one window whose title or app name contains
  `query` (several matches: an error listing them) as its close button does
  (KWin `closeWindow()`, Hyprland `closewindow`), so an app with unsaved
  changes still asks; the reply then says the window is still open and
  lists the app's dialog (the save prompt) with its buttons' ids
  (`showing dialog '…': [60] push button "Save" [61] "Discard" …`). Always
  needs the user's consent, as tools on the `confirm:` list do (see
  [Tiers, confirmation and password fields](#tiers-confirmation-and-password-fields)).
- `menu`: runs a menu bar entry by its labels in one call: `path` is a list
  (`["Dosya", "Farklı Kaydet…"]`) or one string (`"Dosya > Farklı Kaydet"`).
  Labels are matched case-insensitively (Turkish dotted and dotless i
  alike), without `&`/`_` accelerator markers, a shortcut after a tab or a
  trailing ellipsis; an exact label wins, else a unique part of one. A label
  that is not there is also tried against KDE's English action names
  (`file`, `file_save_as`, `edit_copy`), so `"File > Save As"` works in a
  Turkish Kate (it saved two turns per task in the e2e benchmark). Without
  `path` it lists the top level, and a path ending at a submenu lists that
  submenu's entries with ids (read-only where the app keeps closed submenus
  in the tree). Qt and GTK apps keep closed submenus' entries in the tree, so
  nothing is opened. KDE apps that replace the menu bar with a hamburger
  button ("Menüyü Aç", which has no accessibility action and no children)
  keep the hidden menu bar in the tree, and its entries work: `menu` finds it
  too (Kate, Dolphin). Firefox fills a menu only while it is open; `menu`
  does not open Firefox menus itself (doing so through accessibility hung or
  crashed a fresh Firefox in tests), so open the menu with `key` first.
- `launch`: starts an allowed app (`app`, e.g. `kate`) with an optional `args`
  list (no shell; its `.desktop` file first, else the program on `PATH`) and
  waits until its window opens (`wait_ms`, default 15 s, at most 60 s; 0 does
  not wait). Prints the window as `windows` does; also finds launches handed
  to an already running instance (firefox, kate). Window tracking works on
  KDE and Hyprland.
- Terminal windows: `windows` also lists each terminal tab's pts device,
  foreground command and directory; `activate_window` with
  `terminal: "pytest"` brings forward the terminal running that command (or in
  that directory). A terminal with several tabs comes forward on whichever tab
  it shows. The terminal must be in the allowlist (e.g. `konsole`).
- `file_dialog`: fills in the file Open/Save dialog in front: `path` (absolute
  or `~/…`) is written into its file name field, the default button (Open,
  Save) is pressed and the dialog's closing is awaited (Qt 5's name field and
  Qt 6's editable combo box alike). Works on an allowed
  app's own KDE dialog and on the portal dialog (`xdg-desktop-portal-kde`) a
  browser opens for uploads and downloads; a portal dialog is filled only when
  the parent window KWin reports for it belongs to an allowed app, and a
  dialog without a reported parent is refused. The portal is not added to the
  allowlist; no other element of the dialog is reachable. For
  `action: "open"` the file must exist, for `"save"` its folder (KDE only).
- `doctor`: checks the setup in one report: version, allowlist, which running
  apps are allowed, the session, AT-SPI, the virtual keyboard and mouse, KWin
  scripting, screens and the screenshot path.
- `key`, `type_text`: keyboard. The reply names the element that has focus
  afterwards; `type_text` warns when focus is not on an editable field (KDE).
  In a `batch`, a step whose focus is the one the previous report described
  says `focus: [id] (unchanged)` instead of describing it again. A menu or
  dialog the key opened is listed with ids (see "What an input opened").
- `activate_window`: brings a window to the front; fails when no window of an
  allowed app matches.
- `window` on input tools (`key`, `type_text`, `click`, `mouse`, `scroll`,
  `focus`, `drag`, `set_text`, `act`, `screenshot`): a title or app-name
  substring, matched as by `activate_window`. When the window in front is not
  a matching window of an allowed app, that window is activated first (the
  reply starts with `activated '<title>'; `), in the same critical section as
  the input, so no other call takes the focus in between; when it already is,
  nothing extra happens. Without `window` nothing is activated.
- `clipboard_get`, `clipboard_set`: read or write the clipboard as text (Linux
  only). Because the clipboard can hold anything the user copied anywhere
  (passwords included), both work only while a window of an allowed app is
  active. `clipboard_get` returns at most `limit` characters (default 20,000;
  the reply says when it was cut); if the clipboard holds an image or files
  rather than text, it only names the types, and it does not read content a
  password manager marked as a password (`x-kde-passwordManagerHint`).
- `mouse`, `screenshot`: fallback only. `screenshot` returns the image inside
  the reply, shrinks it so its longer side is 1024 px by default (`max_size`)
  and reports which screen area it shows. The inline image is a JPEG
  (quality 80, full colour resolution, so small text stays sharp) when that
  takes at most three quarters of the PNG's bytes, else a PNG: a 1024x576
  YouTube window took 132 KB instead of 670 KB, a Confluence page 94 KB
  instead of 246 KB; flat windows stay PNG. Image tokens depend on the size
  only (about width x height / 750: 786 at 1024x576), so the default size
  stays: at 896 px a 1080p window's 13 px text shrinks to 6 px. An inline
  screenshot leaves no file behind and names none; `inline: false` saves a
  PNG and returns only its path; given `image_x`/`image_y`,
  `mouse` converts a pixel of the image to a screen coordinate (window position
  and display scaling, e.g. 125 %, included). Zoom: `region: [x, y, w, h]`
  in pixels of the last screenshot captures again and returns only that part,
  cropped before shrinking, at up to full resolution (`max_size` still
  applies); `image_x`/`image_y` then refer to the zoomed image.
- `batch`: runs several tools in order in one call and stops at the first
  error; reduces Claude's number of turns. A step with `"optional": true`
  (typically a `wait_for`) may fail without stopping the batch: it is reported
  as `[n] tool failed (optional): …` and the remaining steps still run, so a
  final observation step (`find`, `screenshot`) is not lost. A step's
  `id`/`*_id` argument `"$N"` is the element step N produced (find's first
  hit, wait_for's match, the target of a tool given one).
  A small procedure fits in one batch, with plain JSON control flow:
  - `"if": {"step": N, TEST}` on a step runs it only when the test holds on
    earlier step N; otherwise it is reported as `[n] skipped (if step N …)`.
    TEST is one of `"ok": bool` (the step succeeded), `"found": bool` (it
    succeeded and produced an element; `find` had a match) or
    `"contains": "text"` (its reply or error, case-insensitive). A skipped
    step meets no condition.
  - `"on_fail": [steps]` on a step runs those steps (reported as
    `[n.on_fail] …`) only when the step failed; the batch then goes on. Not
    after a refusal by the allowlist (or a tier, or a password field), a
    confirmation the user declined or was not asked for, or the user taking
    over: then `[n.on_fail] not run (refused)`. Such a refusal also ends a
    repeat, even in an optional step.
  - `{"repeat": {"steps": [...], "until": TEST, "max": 10}}` runs its steps
    (reported as `[n.k] …`, last run only) until TEST holds on the last of
    them, checked after each run; it fails after `max` runs (1 to 20, default
    10) or once it ran 10 minutes, and when a step in it fails that is not
    optional. `"$N"` and conditions on a repeat mean its last step's result in
    the latest run. Its input steps without `observe` (and any such step a
    condition tests) wait for the app to react, up to 150 ms (ending at its
    first change events), before their reply is read: the focus a `key`
    reports is the one it moved to.
  - `{"stop_if": {"step": N, TEST}}` ends the batch there, successfully.
  Steps are numbered at the top level only; steps inside `repeat` and
  `on_fail` take only `tool`, `args` and `optional` (no nesting). The whole
  batch is checked before anything runs (unknown tools or keys, conditions
  and `"$N"` that do not name an earlier step or can never hold, `max` out of
  range): a mistake fails it with `not run (nothing sent): [n] …`. Its input
  lock, the takeover check, the allowlist and `observe` work per step as in a
  plain batch. Example, scroll Dolphin's view of a folder "Reports" (the view
  is named after the folder) until a file shows, then click it, once the
  view stopped moving:
  `[{"repeat": {"steps": [{"tool": "scroll", "args": {"app": "dolphin",
  "query": "Reports", "role": "list"}}, {"tool": "wait_idle", "args": {"app":
  "dolphin", "quiet_ms": 150}}, {"tool": "find", "args": {"app": "dolphin",
  "query": "report.pdf"}}], "until": {"found": true}}}, {"tool": "click",
  "args": {"id": "$1"}}]`.

The `initialize` reply carries `instructions` (about 3 KB): the workflow,
the targeting rules shared by the tools above, the allowlist and which tools
to load together. Clients that load tool schemas on demand, such as Claude
Code, show it at session start before any schema is loaded; the per-tool
descriptions only point to it.

## Allowlist

Re-read on every call; a default list is created on first run.

- KDE and Hyprland: `~/.config/desktop-mcp/allow.txt`, one AT-SPI application
  name per line (`kate`, `firefox`…). Window tools (`windows`, `move_window`,
  `activate_window`) match a window by the AT-SPI application name of its
  process (pid), so apps whose window class differs are found too (Ferdium:
  `electron`). Apps without accessibility match by window class (like `xterm`).
- Windows: `%APPDATA%\desktop-mcp\allow.txt`, one program name per line without
  `.exe` (`notepad`, `firefox`, `chrome`…).

Rules:
- Elements of apps not in the list cannot be read or acted on.
- Keyboard and mouse input is sent only when the window in front belongs to an
  app in the list.
- `activate_window` only brings windows of listed apps to the front.
- `launch` refuses an app not in the list before starting anything.
- `file_dialog` fills in the dialog of the (unlisted) portal only when it was
  opened for a listed app; a dialog opened by another app is refused.

Use the `apps` tool to see the right names.

### Tiers, confirmation and password fields

An app can be limited to a tier with `app:tier`; a plain name means `full`, so
files without tiers keep working unchanged:

```text
firefox                 # full: everything
konsole:view            # read tools only
dolphin:click           # no typing
confirm: close_window, key:konsole
passwords: keepassxc
```

| tier | allows |
| --- | --- |
| `view` | `tree`, `find`, `get_text`, `wait_for`, `wait_idle`, `screenshot`, `clipboard_get`, `cursor_position` (and `activate_window`) |
| `click` | plus `act` (its key fallbacks need `full`), `menu`, `click`, `focus`, `scroll`, `drag`, `mouse`, `move_window`, `close_window`, `launch` without `args` |
| `full` | plus `key`, `hold`, `type_text`, `set_text`, `clipboard_set`, `file_dialog`, `launch` with `args` |

The tier is checked against the app a call reaches: the target element's app
for element tools, the active window's app for keyboard and pointer input
(both for `click`, `scroll` and `focus`). An app listed twice gets the lower
tier; an unknown tier counts as `view`. A refusal names the tier.

`confirm:` lists tools (`tool`) or tools in one app (`tool:app`) that need
the user's consent per call. When the client supports MCP elicitation the
server asks the user through it; otherwise the call is refused unless it
carries `confirm: true` (the agent is told to ask the user first).

Password fields (AT-SPI role `password text`, UIA `IsPassword`) never show
their text in `tree`, `find` or `wait_for`, and `get_text` refuses them.
`set_text`, and `type_text` while one has focus, refuse them too unless the
app is on a `passwords:` line (`passwords: all` for every app) or the server
runs with `DESKTOP_MCP_ALLOW_PASSWORDS=1`. `doctor` shows the tiers, the
confirm list and the password setting.

## Installation

Every platform needs [Rust](https://rustup.rs/). After installing, open a new
Claude Code session and check with `/mcp` that the `desktop` server is
connected.

### KDE Plasma (Wayland)

Requirements:
- KDE Plasma 6, Wayland session
- System packages (Arch): `at-spi2-core`, `libkscreen`, `wl-clipboard`,
  `spectacle`
- KWin 6.8 or later for fast screenshots. On older KWin, `screenshot` falls
  back to `spectacle`; enabling KWin's `ScreenShot2` interface takes installing
  a `.desktop` file by hand once (details in the `SHOT_NOT_AUTHORIZED` comment
  in `rust/src/linux/kwin.rs`)
- Write access for the user on `/dev/uinput` (`getfacl /dev/uinput`)

```bash
git clone https://github.com/ahmetadakli/kde-desktop-mcp.git
cd kde-desktop-mcp/rust
cargo build --release
claude mcp add --scope user desktop -- "$PWD/target/release/desktop-mcp"
```

### Hyprland

Requirements:
- A Hyprland session (the `movecursor` dispatcher)
- System packages (Arch): `at-spi2-core`, `wl-clipboard`; recommended: `wtype`
  (typing without touching the clipboard), `grim` (fallback screenshots)
- A working AT-SPI bus in the session. Qt apps may need
  `env = QT_LINUX_ACCESSIBILITY_ALWAYS_ON,1` in the Hyprland configuration.
- Write access for the user on `/dev/uinput`

The installation commands are the same as for KDE. Start Claude Code from
inside the Hyprland session so `HYPRLAND_INSTANCE_SIGNATURE` reaches the
server.

### Windows

Requirements: Windows 10 or 11. In PowerShell:

```powershell
git clone https://github.com/ahmetadakli/kde-desktop-mcp.git
cd kde-desktop-mcp\rust
cargo build --release
claude mcp add --scope user desktop -- "$PWD\target\release\desktop-mcp.exe"
```

Building for Windows on Linux also works (`mingw-w64-gcc` and
`rustup target add x86_64-pc-windows-gnu`, then
`cargo build --release --target x86_64-pc-windows-gnu`).

### Speed: tools loaded up front

Claude Code defers MCP tools behind a tool search, which costs a model turn at
the start of every desktop task. The server marks the ten tools nearly every
task starts with (`batch`, `find`, `act`, `activate_window`, `key`,
`type_text`, `wait_for`, `wait_idle`, `screenshot`, `apps`) with
`_meta: {"anthropic/alwaysLoad": true}`, so Claude Code loads them with the
session (about 11.5k characters of schema); the others stay deferred. To load
every tool up front instead, add `"alwaysLoad": true` to the server's entry in
the MCP config.

### Speed: skipping the permission prompt

Claude Code sends every MCP call that is not in its allow list to a prompt or,
in auto mode, to its safety classifier; that adds roughly 0.7–2.5 s per call.
If you trust all of the server's tools, add one rule to
`~/.claude/settings.json`:

```json
{ "permissions": { "allow": ["mcp__desktop"] } }
```

## Testing

Unit tests need no real desktop (the pure part of the Windows logic runs on
Linux too):

```bash
cd rust && cargo test --release
```

`tools/mcp_smoke_test.py` tests end to end on the real desktop over the real
MCP protocol (Linux, Kate): it opens the editor with a temporary file, finds the
element, refocuses it after focus moved elsewhere, types text with Turkish
characters through `batch`, tries rapid consecutive typing and key repeat,
saves with Ctrl+S, reads the file back from disk, launches Kate with `launch`,
and checks that the allowlist blocks an unknown app. It prints the duration of
every call. Only the Python standard library is needed. Do not touch the
keyboard and mouse while it runs.

```bash
python3 tools/mcp_smoke_test.py rust/target/release/desktop-mcp
```

`tools/mcp_bench.py` measures one or more servers with the same calls and
prints a table of median ms (the window must be open):

```bash
python3 tools/mcp_bench.py --app kate --window notes.txt \
    --server "Rust=rust/target/release/desktop-mcp"
```

### End-to-end task benchmark

Tool calls take milliseconds; what makes a desktop task slow is the number of
model turns and the size of what the model reads. `tools/e2e_bench.py` measures
that on real tasks: it runs Claude Code headless (`claude -p`, only this
server's tools; built-in tools, the user's settings, plugins, hooks and skills
left out) once per task × model × server binary and records model turns,
desktop tool calls, wall time up to the final result split into startup,
model and tool time (calls of one turn that run at once count once), the
exit after the result on its own, reply characters and images, tokens, the
model actually used, and whether the task got done. `default` in `--models`
means Claude Code's own default model, not the one in your settings. The tasks
in `tools/e2e_tasks.json` each open their own window on a fresh temp file
(Kate: append a line and save, Save As to a new path, search and replace, read
a value; Firefox: fill and submit a local form in a new window; Dolphin:
create a folder), state the goal as a user would, check the
outcome on disk, in the final answer or with a read-only query, and close only
what they opened (the PID their setup recorded, or the one window carrying the
run's unique tag).

```bash
python3 tools/e2e_bench.py --list
python3 tools/e2e_bench.py --tasks kate_append,kate_replace --models default,claude-haiku-5-5 \
    --server "old=/path/to/old/desktop-mcp" \
    --server "new=rust/target/release/desktop-mcp" --repeat 3 --results /tmp/e2e
```

It prints a Markdown table of medians and writes `results.json` (with every
tool call, its time and reply size), `table.md` and each run's raw stream to
`--results` (default: a new temp dir; never inside the repository). The agent
types into the windows the tasks open (each run makes its window the active
one first): do not use the keyboard or mouse while it runs. Firefox and
Dolphin open the task's window in their running instance; close your own
windows of them first, or pass `--exclusive` to skip those runs (each run
notes other open windows of its app). Ctrl+C stops the agent with its whole
process group before cleanup. `python3 -m unittest tools/test_e2e_bench.py`
tests the measuring and the process handling without a desktop.

## Measurements: KDE speed-up

`tools/mcp_bench.py`, KDE Plasma 6.8, Kate; median of 5 rounds (ms). "Before"
is `2f2e5df` (the version that removed Python), "After" this version:

| Operation | Before (ms) | After (ms) | Speed-up |
|---|---:|---:|---:|
| `initialize` | 4.4 | 3.3 | 1.4x |
| `apps` | 3.8 | 4.2 | 0.9x |
| `activate_window` | 8.5 | 10.3 | 0.8x |
| `tree` | 12.0 | 10.0 | 1.2x |
| `find` | 10.1 | 9.9 | 1.0x |
| `get_text` | 0.3 | 0.3 | 0.9x |
| `key` | 19.5 | 6.4 | 3.0x |
| `type_text` | 34.9 | 2.9 | 12x |
| `focus` | 103.5 | 12.1 | 8.5x |
| `click` | 78.1 | 11.1 | 7.0x |
| `mouse` | 29.3 | 4.9 | 6.0x |
| `screenshot` | 258.8 | 12.1 | 21x |

The ~2 ms in `activate_window` is reading the active window title so `act` can
report a window change. The real gain lies beyond milliseconds: short output,
targeting with `app` + `query` in one call, `wait_for` and `batch` reduce the
number of Claude's turns and the text it reads.

## Measurements: compared with the old Python version

The server used to be written in Python; its last state is commit `4cd2632` in
the git history. The Rust version offers the same tools with the same output:
1,499 lines of output on KDE and 2,025 on Hyprland were compared with the
Python version, with no difference. The values were taken with
`tools/mcp_bench.py` applying the same call sequence to both servers in turn;
median of 5 rounds (ms, in a Kate window, same machine).

KDE Plasma 6 (Wayland):

| Operation | Python (ms) | Rust (ms) | Speed-up |
|---|---:|---:|---:|
| `initialize` | 400.5 | 2.2 | 181x |
| `apps` | 48.8 | 4.2 | 11.6x |
| `activate_window` | 22.7 | 7.4 | 3.1x |
| `tree` | 66.6 | 11.5 | 5.8x |
| `find` | 40.8 | 9.4 | 4.3x |
| `get_text` | 5.0 | 0.3 | 15x |
| `key` | 61.3 | 19.4 | 3.2x |
| `type_text` | 197.0 | 35.6 | 5.5x |
| `focus` | 339.2 | 102.8 | 3.3x |
| `click` | 165.5 | 78.4 | 2.1x |
| `mouse` | 66.4 | 28.8 | 2.3x |
| `screenshot` | 269.0 | 254.3 | 1.1x |

Hyprland 0.56 (nested session inside KDE):

| Operation | Python (ms) | Rust (ms) | Speed-up |
|---|---:|---:|---:|
| `initialize` | 394.5 | 2.2 | 178x |
| `apps` | 145.6 | 4.3 | 34x |
| `activate_window` | 225.0 | 3.1 | 73x |
| `tree` | 120.9 | 18.5 | 6.5x |
| `find` | 75.9 | 16.1 | 4.7x |
| `get_text` | 4.5 | 0.3 | 13x |
| `key` | 60.5 | 19.2 | 3.1x |
| `type_text` | 28.6 | 11.1 | 2.6x |
| `focus` | 319.5 | 77.7 | 4.1x |
| `click` | 168.7 | 78.0 | 2.2x |
| `mouse` | 69.1 | 28.3 | 2.4x |
| `screenshot` | 38.1 | 24.2 | 1.6x |

Windows (Notepad inside Wine 11.20; not a real Windows measurement):

| Operation | Python (ms) | Rust (ms) | Speed-up |
|---|---:|---:|---:|
| `initialize` | 1998.6 | 62.8 | 32x |
| `apps` | 43.0 | 1.4 | 32x |
| `activate_window` | 5.9 | 0.9 | 6.4x |
| `key` | 54.8 | 19.3 | 2.8x |
| `type_text` | 7.1 | 1.1 | 6.5x |
| `mouse` | 55.6 | 30.5 | 1.8x |
| `screenshot` | 82.3 | 8.3 | 9.9x |

Wine's UI Automation client does not support cache requests, tree walking and
most properties (`E_NOTIMPL`), so `tree`, `find`, `get_text`, `focus` and
`click` do not work under Wine on either server; the `apps` and
`activate_window` figures in the table are for returning without finding a
window. Most of the difference in the input steps comes from Rust's shorter
waits.

The gap grows with larger trees: a 2,000-element `tree` of Dolphin including
hidden elements takes 507 ms in Python and 48 ms in Rust. `screenshot` time is
mostly the `spectacle` and `grim` programs themselves.

In nested Hyprland, Ctrl/Alt shortcuts from uinput get lost on their way
through the outer KWin (on both servers), so no shortcut tests were run there.
In a real Hyprland session uinput goes straight to Hyprland.

## Known limitations

KDE:
- The server turns on the accessibility bus at run time. Apps started before
  that need one restart.
- Chromium, Chrome and Electron apps show their tree only when started with
  `--force-renderer-accessibility`.
- `type_text` pastes through the clipboard into fields other than Qt's
  multi-line editors (such as Kate). It first saves the clipboard text and
  puts it back once the target app has read the paste (seen through Wayland
  data-control). The typed text does not enter Klipper's history (it is
  offered with the password hint). The clipboard is not put back when it held
  an image, files or text over 4 MiB, nor when the app does not read the paste
  within 1 s (so that a late reader does not paste the user's clipboard); the
  reply says so in both cases. Rich text (HTML) comes back as plain text only.
- Direct insertion differs slightly from pasting: replacing selected text is
  two undo steps, with multiple cursors only the primary cursor gets the text,
  overwrite mode and Kate's vi mode are bypassed, and auto-indent and bracket
  completion do not run as they would on a paste.

Hyprland:
- Client-side shadows of GTK apps can shift `click` and `focus` clicks by a
  few pixels.
- Two windows of the same process with the same title cannot be told apart.
- Without `wtype`, `type_text` pastes through the clipboard as on KDE and puts
  the clipboard text back afterwards.
- Hyprland has no minimize: `move_window` with `state: "minimize"` moves the
  window to the special workspace `special:minimized`, and `windows` reports
  windows on a special workspace no monitor shows as minimized. `restore`
  brings it back to the workspace its monitor shows (not necessarily the one
  it came from).
- Maximize is Hyprland's maximized fullscreen mode (`fullscreenstate 1 1`);
  `windows` reports any fullscreen mode as maximized. Hyprland applies
  fullscreen states only to the focused window, so `maximize` and `restore`
  of a maximized window also focus it.
- Moving or resizing a tiled window makes it floating first (`setfloating`);
  it stays floating afterwards.
- Hyprland 0.57 takes only Lua dispatchers over its socket (`hyprctl
  dispatch X` became `hl.dispatch(X)`). The server notices the first Lua
  error and from then on sends the Lua form of every dispatcher it uses
  (`hl.dsp.focus`, `hl.dsp.window.move`/`resize`/`float`/`fullscreen_state`/
  `close`, `hl.dsp.cursor.move`); verified against a nested Hyprland 0.57.
  Named workspaces have no number there; `windows` lists them by name.

Windows:
- Input cannot be sent to windows running as administrator from a server that
  is not (Windows UIPI).
- Microsoft Store apps may show up as `applicationframehost`.
- Key names follow the US keyboard layout positions; use `type_text` for text.
- In very large windows (browsers, Word) `tree` and `find` can be slow because
  they fetch the whole subtree at once.

All:
- Games and canvas-based interfaces mostly show up empty in the accessibility
  tree.

## Security

The server runs with your user's permissions. The allowlist is a safety layer,
not operating-system level isolation. Think twice before adding terminals, the
file manager or system settings to the list.

## License

MIT

Inline screenshots are encoded with the `jpeg-encoder` crate; this software
is based in part on the work of the Independent JPEG Group.