add GUI automation tools (10 tools + prompt + Makefile targets)

New tools for KDE desktop automation via D-Bus:
- gui_clipboard: Klipper read/write/history
- gui_notify: interactive notifications with actions
- gui_screenshot: spectacle/KWin capture
- gui_windows: window management (list/focus/move/resize/desktops)
- gui_apps: launch/list/activate applications
- gui_input: synthetic keyboard/mouse (ydotool/xdotool)
- gui_activities: Plasma Activities management
- gui_files: Baloo file search
- gui_shortcuts: KGlobalAccel shortcuts
- gui_accessibility: AT-SPI2 full GUI automation

Adds prompts/tools-gui.md for agent discovery and Makefile
tools-install/tools-uninstall targets.
This commit is contained in:
Levi Neely 2026-07-14 11:53:09 +02:00
parent a86d7391b2
commit 606bb1bbaa
12 changed files with 1970 additions and 1 deletions

View File

@ -6,7 +6,7 @@ BUILD_DIR := build-cmake
.PHONY: all clean run install uninstall \
kf5 kf5-install kf5-uninstall \
krunner-install dolphin-install plasmoid-install plasmoid-uninstall \
tray data
tray data tools-install tools-uninstall
all: build data
@ -186,6 +186,29 @@ data:
cp -rf tools/_lib $(HOME)/.config/ollie/tools/_lib
cp -rf tools/* $(HOME)/.config/ollie/tools
# --- GUI Tools (standalone install) ---
GUI_TOOLS := gui_accessibility gui_activities gui_apps gui_clipboard \
gui_files gui_input gui_notify gui_screenshot \
gui_shortcuts gui_windows
tools-install:
@echo "=== Installing GUI automation tools ==="
mkdir -p $(HOME)/.config/ollie/tools
@for t in $(GUI_TOOLS); do \
install -m755 tools/$$t $(HOME)/.config/ollie/tools/$$t; \
echo " installed: $$t"; \
done
@echo "=== Done. Tools available in next ollie session. ==="
tools-uninstall:
@echo "=== Removing GUI automation tools ==="
@for t in $(GUI_TOOLS); do \
rm -f $(HOME)/.config/ollie/tools/$$t; \
echo " removed: $$t"; \
done
@echo "=== Done ==="
# --- Development helpers ---
tray: build

213
prompts/tools-gui.md Normal file
View File

@ -0,0 +1,213 @@
# GUI Automation Tools
KDE desktop automation via D-Bus and accessibility interfaces. All tools use `call_tool`.
**Dependencies**: `gdbus` (always), `wmctrl`/`xdotool` (window ops), `ydotool` (Wayland input), `spectacle` (screenshots), `python3-gobject` + AT-SPI2 typelib (accessibility).
---
## gui_clipboard
Read/write the KDE clipboard via Klipper.
**Args**: `[command]` or `[command, value]`
Commands: `get`, `set`, `history`, `clear`
```
call_tool: calls=[{tool: "gui_clipboard", args: ["get"]}]
call_tool: calls=[{tool: "gui_clipboard", args: ["set", "hello world"]}]
call_tool: calls=[{tool: "gui_clipboard", args: ["history", "2"]}]
```
---
## gui_notify
Show desktop notifications with optional interactive action buttons. Waits for user response when actions are specified.
**Args**: `[summary]` or `[summary, body, --flags...]`
Flags: `--actions=label1,label2,...`, `--timeout=ms`, `--icon=name`, `--urgency=low|normal|critical`
```
call_tool: calls=[{tool: "gui_notify", args: ["Build complete", "All tests passed"]}]
call_tool: calls=[{tool: "gui_notify", args: ["Deploy?", "Ready to push to prod", "--actions=Yes,No", "--urgency=critical"]}]
```
**Returns**: For action notifications, prints the chosen label (e.g., `Yes`) or `dismissed`/`timeout`.
---
## gui_screenshot
Capture screenshots. Uses spectacle (KDE) with fallbacks to ImageMagick.
**Args**: `[mode]` with optional flags
Modes: `active` (default), `screen`, `area`, `window`
Flags: `--output=path`, `--screen=N`, `--title=substr`
```
call_tool: calls=[{tool: "gui_screenshot", args: ["active"]}]
call_tool: calls=[{tool: "gui_screenshot", args: ["screen", "--output=/tmp/shot.png"]}]
call_tool: calls=[{tool: "gui_screenshot", args: ["window", "--title=Firefox"]}]
```
**Returns**: output file path and size.
---
## gui_windows
Manage windows: list, focus, close, move, resize, virtual desktops.
**Args**: `[command, ...params]`
Commands:
- `list` — all windows with geometry and state
- `focus <title>` — raise and focus
- `close <title>` — close window
- `minimize <title>` / `maximize <title>`
- `move <title> <x> <y>` — reposition
- `resize <title> <w> <h>` — resize
- `desktops` — list virtual desktops
- `desktop <n>` — switch to desktop N
```
call_tool: calls=[{tool: "gui_windows", args: ["list"]}]
call_tool: calls=[{tool: "gui_windows", args: ["focus", "Kate"]}]
call_tool: calls=[{tool: "gui_windows", args: ["move", "Terminal", "0", "0"]}]
call_tool: calls=[{tool: "gui_windows", args: ["resize", "Firefox", "1920", "1080"]}]
call_tool: calls=[{tool: "gui_windows", args: ["desktop", "2"]}]
```
Title matching is case-insensitive substring.
---
## gui_apps
Launch, list, or activate applications.
**Args**: `[command]` or `[command, app_name]`
Commands: `list` (running), `installed` (all .desktop), `launch <name>`, `activate <title_or_class>`
```
call_tool: calls=[{tool: "gui_apps", args: ["launch", "firefox"]}]
call_tool: calls=[{tool: "gui_apps", args: ["activate", "dolphin"]}]
call_tool: calls=[{tool: "gui_apps", args: ["installed"]}]
```
---
## gui_input
Simulate keyboard and mouse input. Uses ydotool (Wayland) or xdotool (X11).
**Args**: `[command, ...params]`
Commands:
- `key <combo>` — press key combo (e.g., `ctrl+c`, `Return`, `alt+F4`, `super`)
- `type <text>` — type a string character by character
- `click [button] [x y]` — mouse click (1=left, 2=middle, 3=right)
- `move <x> <y> [--relative]` — move cursor
- `scroll <up|down|left|right> [amount]`
```
call_tool: calls=[{tool: "gui_input", args: ["key", "ctrl+s"]}]
call_tool: calls=[{tool: "gui_input", args: ["type", "Hello, world!"]}]
call_tool: calls=[{tool: "gui_input", args: ["click", "1", "500", "300"]}]
call_tool: calls=[{tool: "gui_input", args: ["scroll", "down", "5"]}]
```
**Caution**: Input simulation is a write operation — confirm intent before using `key` with destructive combos (alt+F4, ctrl+w) or `click` on unknown targets.
---
## gui_activities
Manage KDE Plasma Activities.
**Args**: `[command]` or `[command, name_or_id]`
Commands: `list`, `current`, `switch <name_or_id>`, `create <name>`, `remove <name_or_id>`
```
call_tool: calls=[{tool: "gui_activities", args: ["list"]}]
call_tool: calls=[{tool: "gui_activities", args: ["switch", "Development"]}]
call_tool: calls=[{tool: "gui_activities", args: ["create", "Research"]}]
```
---
## gui_files
Search files via Baloo (KDE file indexer). Falls back to `locate`.
**Args**: `[query, ...flags]`
Flags: `--type=document|image|video|audio|folder`, `--path=dir`, `--limit=N`
```
call_tool: calls=[{tool: "gui_files", args: ["meeting notes"]}]
call_tool: calls=[{tool: "gui_files", args: ["invoice", "--type=document", "--path=/home/lkn/doc"]}]
call_tool: calls=[{tool: "gui_files", args: ["screenshot", "--type=image", "--limit=10"]}]
```
---
## gui_shortcuts
Manage and trigger KDE global keyboard shortcuts.
**Args**: `[command, ...params]`
Commands:
- `list` — all shortcut components
- `list <component>` — shortcuts for that component
- `get <component> <name>` — current key binding
- `trigger <component> <name>` — invoke the shortcut action
- `set <component> <name> <keys>` — change key binding
```
call_tool: calls=[{tool: "gui_shortcuts", args: ["list"]}]
call_tool: calls=[{tool: "gui_shortcuts", args: ["list", "kwin"]}]
call_tool: calls=[{tool: "gui_shortcuts", args: ["trigger", "kwin", "Overview"]}]
call_tool: calls=[{tool: "gui_shortcuts", args: ["trigger", "plasmashell", "show dashboard"]}]
```
---
## gui_accessibility
Full GUI automation via the AT-SPI2 accessibility tree. Inspect, navigate, and interact with any application's UI elements.
**Args**: `[command, app_name, ...params]`
Commands:
- `apps` — list all accessible applications (no app_name needed)
- `tree <app> [depth]` — dump widget tree (default depth: 4)
- `inspect <app> [depth]` — brief overview (default depth: 2)
- `find <app> <role> [name]` — find elements by role and optional name
- `read <app> <path>` — read text/value of element
- `click <app> <path>` — activate/click element
- `type <app> <path> <text>` — type into element
- `action <app> <path> <action_name>` — invoke named action
Paths use slash-separated indices (`0/2/1`) or `role:name` patterns (`push button:OK`).
```
call_tool: calls=[{tool: "gui_accessibility", args: ["apps"]}]
call_tool: calls=[{tool: "gui_accessibility", args: ["inspect", "Kate"]}]
call_tool: calls=[{tool: "gui_accessibility", args: ["find", "Firefox", "push button", "Reload"]}]
call_tool: calls=[{tool: "gui_accessibility", args: ["click", "Firefox", "push button:Reload"]}]
call_tool: calls=[{tool: "gui_accessibility", args: ["type", "Kate", "0/0/text:untitled", "Hello world"]}]
call_tool: calls=[{tool: "gui_accessibility", args: ["tree", "Dolphin", "3"]}]
```
**Workflow**: Use `apps` → `inspect` → `find` to locate elements, then `click`/`type`/`action` to interact.
**Requires**: `python3-gobject` and AT-SPI2 typelib (`dev-libs/at-spi2-core` on Gentoo).

464
tools/gui_accessibility Executable file
View File

@ -0,0 +1,464 @@
#!/usr/bin/env python3
# description: GUI automation via AT-SPI2 accessibility tree. Args: <inspect|find|click|type|read|tree|action> [app] [query...]
"""Interact with any GUI application through the accessibility (AT-SPI2) interface.
This uses the D-Bus-based accessibility tree to inspect, navigate, and interact
with UI elements in any application that supports AT-SPI (virtually all GTK, Qt,
and Electron apps).
Commands:
tree <app> - dump full widget tree
inspect <app> [depth] - brief overview of top-level widgets
find <app> <role> [name] - find elements by role and optional name
read <app> <path> - read text/value of element
click <app> <path> - click/activate element
type <app> <path> <text> - type text into element
action <app> <path> <action_name> - invoke named action on element
apps - list accessible applications
Paths are slash-separated indices (e.g., "0/2/1" = first child, third child, second child)
or you can use "role:name" patterns (e.g., "push button:OK").
Requires: python3 with gi (PyGObject) and Atspi typelib, OR the 'accerciser' package.
"""
import sys
import os
import time
def check_deps():
"""Check for AT-SPI2 dependencies."""
try:
import gi
gi.require_version('Atspi', '2.0')
from gi.repository import Atspi
return True
except (ImportError, ValueError):
return False
def get_atspi():
import gi
gi.require_version('Atspi', '2.0')
from gi.repository import Atspi
return Atspi
def list_apps():
"""List all accessible applications."""
Atspi = get_atspi()
desktop = Atspi.get_desktop(0)
count = desktop.get_child_count()
print(f"{'INDEX':<6} {'NAME':<30} {'CHILDREN'}")
for i in range(count):
app = desktop.get_child_at_index(i)
if app:
name = app.get_name() or "(unnamed)"
children = app.get_child_count()
print(f"{i:<6} {name:<30} {children}")
def find_app(name_substr):
"""Find application by name substring."""
Atspi = get_atspi()
desktop = Atspi.get_desktop(0)
count = desktop.get_child_count()
for i in range(count):
app = desktop.get_child_at_index(i)
if app:
app_name = app.get_name() or ""
if name_substr.lower() in app_name.lower():
return app
return None
def role_name(accessible):
"""Get human-readable role name."""
Atspi = get_atspi()
try:
role = accessible.get_role()
return accessible.get_role_name()
except Exception:
return "unknown"
def get_states(accessible):
"""Get state set as list of strings."""
Atspi = get_atspi()
try:
state_set = accessible.get_state_set()
states = []
for s in dir(Atspi.StateType):
if s.startswith('_'):
continue
try:
st = getattr(Atspi.StateType, s)
if state_set.contains(st):
states.append(s.lower())
except Exception:
pass
return states
except Exception:
return []
def resolve_path(root, path_str):
"""Resolve a path string to an accessible element.
Path can be:
- Numeric indices: "0/2/1"
- Role:name pattern: "push button:OK"
- Mixed: "0/frame:Main/push button:OK"
"""
if not path_str:
return root
parts = path_str.split('/')
current = root
for part in parts:
if ':' in part:
# Role:name pattern
target_role, target_name = part.split(':', 1)
found = False
count = current.get_child_count()
for i in range(count):
child = current.get_child_at_index(i)
if child:
r = role_name(child)
n = child.get_name() or ""
if (target_role.lower() in r.lower() and
target_name.lower() in n.lower()):
current = child
found = True
break
if not found:
print(f"ERROR: no child matching '{part}' under {role_name(current)}:{current.get_name()}",
file=sys.stderr)
sys.exit(1)
else:
# Numeric index
try:
idx = int(part)
child = current.get_child_at_index(idx)
if not child:
print(f"ERROR: no child at index {idx}", file=sys.stderr)
sys.exit(1)
current = child
except ValueError:
# Try as name match
found = False
count = current.get_child_count()
for i in range(count):
child = current.get_child_at_index(i)
if child and part.lower() in (child.get_name() or "").lower():
current = child
found = True
break
if not found:
print(f"ERROR: no child matching '{part}'", file=sys.stderr)
sys.exit(1)
return current
def dump_tree(accessible, indent=0, max_depth=4, path=""):
"""Dump accessibility tree."""
if indent > max_depth:
return
name = accessible.get_name() or ""
role = role_name(accessible)
children = accessible.get_child_count()
# Get text content if available
text = ""
try:
ti = accessible.get_text()
if ti:
text_content = ti.get_text(0, min(ti.get_character_count(), 50))
if text_content and text_content != name:
text = f' text="{text_content}"'
except Exception:
pass
# Get value if available
value = ""
try:
vi = accessible.get_value()
if vi:
value = f' value={vi.get_current_value()}'
except Exception:
pass
prefix = " " * indent
path_display = path or "/"
# States of interest
states = get_states(accessible)
state_flags = ""
if "focused" in states:
state_flags += " [focused]"
if "checked" in states:
state_flags += " [checked]"
if "selected" in states:
state_flags += " [selected]"
if not ("visible" in states and "showing" in states):
state_flags += " [hidden]"
print(f"{prefix}[{path_display}] {role}: \"{name}\"{text}{value}{state_flags} ({children} children)")
for i in range(children):
child = accessible.get_child_at_index(i)
if child:
child_path = f"{path}/{i}" if path else str(i)
dump_tree(child, indent + 1, max_depth, child_path)
def find_elements(accessible, target_role, target_name="", results=None, path=""):
"""Find elements matching role and optional name."""
if results is None:
results = []
r = role_name(accessible)
n = accessible.get_name() or ""
role_match = target_role.lower() in r.lower() if target_role else True
name_match = target_name.lower() in n.lower() if target_name else True
if role_match and name_match:
results.append((path or "/", r, n, accessible))
count = accessible.get_child_count()
for i in range(min(count, 200)): # cap to avoid infinite trees
child = accessible.get_child_at_index(i)
if child:
child_path = f"{path}/{i}" if path else str(i)
find_elements(child, target_role, target_name, results, child_path)
return results
def read_element(accessible):
"""Read text/value from element."""
# Try text interface
try:
ti = accessible.get_text()
if ti:
count = ti.get_character_count()
if count > 0:
text = ti.get_text(0, count)
print(text)
return
except Exception:
pass
# Try value interface
try:
vi = accessible.get_value()
if vi:
print(vi.get_current_value())
return
except Exception:
pass
# Fall back to name
name = accessible.get_name()
if name:
print(name)
else:
print("(no readable content)")
def click_element(accessible):
"""Activate/click an element."""
Atspi = get_atspi()
# Try action interface
try:
ai = accessible.get_action()
if ai:
n_actions = ai.get_n_actions()
for i in range(n_actions):
action_name = ai.get_action_name(i)
if action_name in ('click', 'activate', 'press', 'invoke'):
ai.do_action(i)
print(f"performed: {action_name}")
return
# If no preferred action found, do the first one
if n_actions > 0:
action_name = ai.get_action_name(0)
ai.do_action(0)
print(f"performed: {action_name}")
return
except Exception as e:
pass
# Try generating a click event via component interface
try:
ci = accessible.get_component()
if ci:
pos = ci.get_position(Atspi.CoordType.SCREEN)
size = ci.get_size()
# Click center
x = pos.x + size.x // 2
y = pos.y + size.y // 2
Atspi.generate_mouse_event(x, y, 'b1c')
print(f"clicked at: {x},{y}")
return
except Exception:
pass
print("ERROR: no way to click this element", file=sys.stderr)
sys.exit(1)
def type_text(accessible, text):
"""Type text into an element."""
Atspi = get_atspi()
# Try editable text interface
try:
ei = accessible.get_editable_text()
if ei:
# Clear existing and insert
ti = accessible.get_text()
if ti:
count = ti.get_character_count()
if count > 0:
ei.delete_text(0, count)
ei.insert_text(0, text, len(text))
print(f"typed: {len(text)} chars")
return
except Exception:
pass
# Fallback: focus and use keyboard events
try:
ci = accessible.get_component()
if ci:
ci.grab_focus()
time.sleep(0.1)
for ch in text:
Atspi.generate_keyboard_event(0, ch, Atspi.KeySynthType.STRING)
time.sleep(0.01)
print(f"typed via keyboard: {len(text)} chars")
return
except Exception:
pass
print("ERROR: cannot type into this element", file=sys.stderr)
sys.exit(1)
def do_action(accessible, action_name):
"""Invoke a named action on element."""
try:
ai = accessible.get_action()
if ai:
n_actions = ai.get_n_actions()
for i in range(n_actions):
if ai.get_action_name(i) == action_name:
ai.do_action(i)
print(f"performed: {action_name}")
return
# List available actions
available = [ai.get_action_name(i) for i in range(n_actions)]
print(f"ERROR: action '{action_name}' not found. Available: {available}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
def main():
if not check_deps():
print("ERROR: AT-SPI2 dependencies not available", file=sys.stderr)
print("Install: pip install PyGObject (or system package python3-gobject)", file=sys.stderr)
print("Also need: gir1.2-atspi-2.0 (Debian/Ubuntu) or gobject-introspection with Atspi typelib", file=sys.stderr)
sys.exit(1)
if len(sys.argv) < 2:
print("usage: gui_accessibility <apps|tree|inspect|find|read|click|type|action> [app] [args...]", file=sys.stderr)
sys.exit(1)
cmd = sys.argv[1]
if cmd == "apps":
list_apps()
return
if len(sys.argv) < 3:
print(f"usage: gui_accessibility {cmd} <app_name> [args...]", file=sys.stderr)
sys.exit(1)
app_name = sys.argv[2]
app = find_app(app_name)
if not app:
print(f"ERROR: no accessible application matching '{app_name}'", file=sys.stderr)
print("Tip: run 'gui_accessibility apps' to see available applications", file=sys.stderr)
sys.exit(1)
if cmd == "tree":
depth = int(sys.argv[3]) if len(sys.argv) > 3 else 4
dump_tree(app, max_depth=depth)
elif cmd == "inspect":
depth = int(sys.argv[3]) if len(sys.argv) > 3 else 2
dump_tree(app, max_depth=depth)
elif cmd == "find":
if len(sys.argv) < 4:
print("usage: gui_accessibility find <app> <role> [name]", file=sys.stderr)
sys.exit(1)
target_role = sys.argv[3]
target_name = sys.argv[4] if len(sys.argv) > 4 else ""
results = find_elements(app, target_role, target_name)
if not results:
print(f"no elements found matching role='{target_role}' name='{target_name}'")
else:
print(f"{'PATH':<20} {'ROLE':<20} {'NAME'}")
for path, role, name, _ in results[:50]:
print(f"{path:<20} {role:<20} {name}")
elif cmd == "read":
if len(sys.argv) < 4:
print("usage: gui_accessibility read <app> <path>", file=sys.stderr)
sys.exit(1)
path = sys.argv[3]
element = resolve_path(app, path)
read_element(element)
elif cmd == "click":
if len(sys.argv) < 4:
print("usage: gui_accessibility click <app> <path>", file=sys.stderr)
sys.exit(1)
path = sys.argv[3]
element = resolve_path(app, path)
click_element(element)
elif cmd == "type":
if len(sys.argv) < 5:
print("usage: gui_accessibility type <app> <path> <text>", file=sys.stderr)
sys.exit(1)
path = sys.argv[3]
text = sys.argv[4]
element = resolve_path(app, path)
type_text(element, text)
elif cmd == "action":
if len(sys.argv) < 5:
print("usage: gui_accessibility action <app> <path> <action_name>", file=sys.stderr)
sys.exit(1)
path = sys.argv[3]
action_name = sys.argv[4]
element = resolve_path(app, path)
do_action(element, action_name)
else:
print(f"unknown command: {cmd}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

137
tools/gui_activities Executable file
View File

@ -0,0 +1,137 @@
#!/usr/bin/env bash
# description: Manage KDE Plasma Activities via D-Bus. Args: <list|current|switch|create|remove> [name_or_id]
set -euo pipefail
SERVICE="org.kde.ActivityManager"
OBJ_PATH="/ActivityManager/Activities"
IFACE="org.kde.ActivityManager.Activities"
cmd="${1:-list}"
case "$cmd" in
list)
# List all activities with their names and states
ids=$(gdbus call --session --dest "$SERVICE" \
--object-path "$OBJ_PATH" \
--method "$IFACE.ListActivities" 2>/dev/null | \
sed "s/^(\[//;s/\],)$//;s/'//g;s/, /\n/g")
if [ -z "$ids" ]; then
echo "ERROR: cannot list activities (ActivityManager not running?)" >&2
exit 1
fi
current=$(gdbus call --session --dest "$SERVICE" \
--object-path "$OBJ_PATH" \
--method "$IFACE.CurrentActivity" 2>/dev/null | \
sed "s/^('//;s/',)$//")
echo "ID STATE NAME"
while IFS= read -r id; do
[ -z "$id" ] && continue
name=$(gdbus call --session --dest "$SERVICE" \
--object-path "$OBJ_PATH" \
--method "$IFACE.ActivityName" "$id" 2>/dev/null | \
sed "s/^('//;s/',)$//")
if [ "$id" = "$current" ]; then
state="active"
else
state="stopped"
fi
echo "$id $state $name"
done <<< "$ids"
;;
current)
id=$(gdbus call --session --dest "$SERVICE" \
--object-path "$OBJ_PATH" \
--method "$IFACE.CurrentActivity" 2>/dev/null | \
sed "s/^('//;s/',)$//")
name=$(gdbus call --session --dest "$SERVICE" \
--object-path "$OBJ_PATH" \
--method "$IFACE.ActivityName" "$id" 2>/dev/null | \
sed "s/^('//;s/',)$//")
echo "$id $name"
;;
switch)
target="${2:?usage: gui_activities switch <name_or_id>}"
# Try as ID first
result=$(gdbus call --session --dest "$SERVICE" \
--object-path "$OBJ_PATH" \
--method "$IFACE.SetCurrentActivity" "$target" 2>/dev/null) && {
echo "switched to: $target"
exit 0
}
# Search by name
ids=$(gdbus call --session --dest "$SERVICE" \
--object-path "$OBJ_PATH" \
--method "$IFACE.ListActivities" 2>/dev/null | \
sed "s/^(\[//;s/\],)$//;s/'//g;s/, /\n/g")
while IFS= read -r id; do
[ -z "$id" ] && continue
name=$(gdbus call --session --dest "$SERVICE" \
--object-path "$OBJ_PATH" \
--method "$IFACE.ActivityName" "$id" 2>/dev/null | \
sed "s/^('//;s/',)$//")
if echo "$name" | grep -qi "$target"; then
gdbus call --session --dest "$SERVICE" \
--object-path "$OBJ_PATH" \
--method "$IFACE.SetCurrentActivity" "$id" >/dev/null
echo "switched to: $name ($id)"
exit 0
fi
done <<< "$ids"
echo "ERROR: no activity matching '$target'" >&2
exit 1
;;
create)
name="${2:?usage: gui_activities create <name>}"
id=$(gdbus call --session --dest "$SERVICE" \
--object-path "$OBJ_PATH" \
--method "$IFACE.AddActivity" "$name" 2>/dev/null | \
sed "s/^('//;s/',)$//")
echo "created: $id ($name)"
;;
remove)
target="${2:?usage: gui_activities remove <name_or_id>}"
# Try as ID directly
gdbus call --session --dest "$SERVICE" \
--object-path "$OBJ_PATH" \
--method "$IFACE.RemoveActivity" "$target" 2>/dev/null && {
echo "removed: $target"
exit 0
}
# Search by name
ids=$(gdbus call --session --dest "$SERVICE" \
--object-path "$OBJ_PATH" \
--method "$IFACE.ListActivities" 2>/dev/null | \
sed "s/^(\[//;s/\],)$//;s/'//g;s/, /\n/g")
while IFS= read -r id; do
[ -z "$id" ] && continue
name=$(gdbus call --session --dest "$SERVICE" \
--object-path "$OBJ_PATH" \
--method "$IFACE.ActivityName" "$id" 2>/dev/null | \
sed "s/^('//;s/',)$//")
if echo "$name" | grep -qi "$target"; then
gdbus call --session --dest "$SERVICE" \
--object-path "$OBJ_PATH" \
--method "$IFACE.RemoveActivity" "$id" >/dev/null
echo "removed: $name ($id)"
exit 0
fi
done <<< "$ids"
echo "ERROR: no activity matching '$target'" >&2
exit 1
;;
*)
echo "usage: gui_activities <list|current|switch|create|remove> [name_or_id]" >&2
exit 1
;;
esac

104
tools/gui_apps Executable file
View File

@ -0,0 +1,104 @@
#!/usr/bin/env bash
# description: Launch/list/activate applications via D-Bus and KService. Args: <list|launch|activate> [app_name_or_desktop_file]
set -euo pipefail
cmd="${1:-list}"
case "$cmd" in
list)
# List running applications via KWin
if command -v wmctrl &>/dev/null; then
echo "CLASS TITLE"
wmctrl -l -x | while read -r wid desktop class host title; do
echo "$class $title"
done
elif command -v xdotool &>/dev/null; then
echo "WID NAME"
xdotool search --onlyvisible --name '' 2>/dev/null | while read -r wid; do
name=$(xdotool getwindowname "$wid" 2>/dev/null || echo '?')
echo "$wid $name"
done
else
# D-Bus fallback: list org.freedesktop.DBus registered names
echo "ERROR: install wmctrl or xdotool for window listing" >&2
exit 1
fi
;;
launch)
app="${2:?usage: gui_apps launch <app_name_or_desktop_file>}"
# Try gtk-launch first (handles .desktop files)
if command -v gtk-launch &>/dev/null; then
gtk-launch "$app" 2>/dev/null && { echo "launched: $app"; exit 0; }
fi
# Try kioclient
if command -v kioclient5 &>/dev/null || command -v kioclient &>/dev/null; then
kio=$(command -v kioclient5 || command -v kioclient)
"$kio" exec "applications:$app.desktop" 2>/dev/null && { echo "launched: $app"; exit 0; }
fi
# Try gio
if command -v gio &>/dev/null; then
# Find .desktop file
desktop_file=$(find /usr/share/applications ~/.local/share/applications \
-name "*${app}*.desktop" 2>/dev/null | head -1)
if [ -n "$desktop_file" ]; then
gio launch "$desktop_file" && { echo "launched: $app"; exit 0; }
fi
fi
# Try D-Bus activation
gdbus call --session \
--dest org.freedesktop.DBus \
--object-path /org/freedesktop/DBus \
--method org.freedesktop.DBus.StartServiceByName \
"$app" 0 2>/dev/null && { echo "activated: $app"; exit 0; }
# Last resort: just exec it
if command -v "$app" &>/dev/null; then
setsid "$app" &>/dev/null &
echo "launched: $app (pid $!)"
else
echo "ERROR: cannot find or launch '$app'" >&2
exit 1
fi
;;
activate)
app="${2:?usage: gui_apps activate <title_or_class>}"
# Focus existing window
if command -v wmctrl &>/dev/null; then
# Try by class first
wid=$(wmctrl -l -x | grep -i "$app" | head -1 | awk '{print $1}')
if [ -n "$wid" ]; then
wmctrl -i -a "$wid"
echo "activated: $wid"
exit 0
fi
fi
if command -v xdotool &>/dev/null; then
wid=$(xdotool search --name "$app" 2>/dev/null | head -1)
if [ -n "$wid" ]; then
xdotool windowactivate "$wid"
echo "activated: $wid"
exit 0
fi
fi
echo "ERROR: no window matching '$app'" >&2
exit 1
;;
installed)
# List installed .desktop files
find /usr/share/applications ~/.local/share/applications \
-name '*.desktop' 2>/dev/null | \
xargs grep -l '^Name=' | \
while read -r f; do
name=$(grep '^Name=' "$f" | head -1 | cut -d= -f2)
echo "$(basename "$f" .desktop) $name"
done | sort
;;
*)
echo "usage: gui_apps <list|launch|activate|installed> [app]" >&2
exit 1
;;
esac

43
tools/gui_clipboard Executable file
View File

@ -0,0 +1,43 @@
#!/usr/bin/env bash
# ollie:parallel read
# description: Read/write KDE clipboard via Klipper D-Bus. Args: [get|set|history] [text|index]
set -euo pipefail
SERVICE="org.kde.klipper"
PATH_OBJ="/klipper"
IFACE="org.kde.klipper.klipper"
cmd="${1:-get}"
case "$cmd" in
get)
gdbus call --session --dest "$SERVICE" \
--object-path "$PATH_OBJ" \
--method "$IFACE.getClipboardContents" | \
sed "s/^('//;s/')$//;s/\\\\n/\n/g"
;;
set)
text="${2:?usage: gui_clipboard set <text>}"
gdbus call --session --dest "$SERVICE" \
--object-path "$PATH_OBJ" \
--method "$IFACE.setClipboardContents" "$text" >/dev/null
echo "OK: clipboard set"
;;
history)
index="${2:-0}"
gdbus call --session --dest "$SERVICE" \
--object-path "$PATH_OBJ" \
--method "$IFACE.getClipboardHistoryItem" "$index" | \
sed "s/^('//;s/')$//;s/\\\\n/\n/g"
;;
clear)
gdbus call --session --dest "$SERVICE" \
--object-path "$PATH_OBJ" \
--method "$IFACE.clearClipboardHistory" >/dev/null
echo "OK: clipboard history cleared"
;;
*)
echo "usage: gui_clipboard [get|set|history|clear] [text|index]" >&2
exit 1
;;
esac

83
tools/gui_files Executable file
View File

@ -0,0 +1,83 @@
#!/usr/bin/env bash
# ollie:parallel read
# description: Search files using Baloo (KDE file indexer). Args: <query> [--type=document|image|video|audio] [--path=dir] [--limit=n]
set -euo pipefail
query="${1:?usage: gui_files <query> [--type=TYPE] [--path=DIR] [--limit=N]}"
shift
# Parse flags
file_type=""
path_filter=""
limit="50"
while [ $# -gt 0 ]; do
case "$1" in
--type=*) file_type="${1#--type=}" ;;
--path=*) path_filter="${1#--path=}" ;;
--limit=*) limit="${1#--limit=}" ;;
*) ;;
esac
shift
done
# Build baloosearch command
cmd=(baloosearch)
# Add type filter
case "$file_type" in
document|doc) cmd+=(-t Document) ;;
image|img) cmd+=(-t Image) ;;
video|vid) cmd+=(-t Video) ;;
audio|snd) cmd+=(-t Audio) ;;
folder|dir) cmd+=(-t Folder) ;;
"")
;;
*)
cmd+=(-t "$file_type")
;;
esac
# Add path filter
if [ -n "$path_filter" ]; then
cmd+=(-d "$path_filter")
fi
# Add limit
cmd+=(-l "$limit")
# Add query
cmd+=("$query")
# Try baloosearch first
if command -v baloosearch &>/dev/null; then
"${cmd[@]}" 2>/dev/null
exit $?
fi
# Fallback: try baloosearch6
if command -v baloosearch6 &>/dev/null; then
cmd[0]="baloosearch6"
"${cmd[@]}" 2>/dev/null
exit $?
fi
# Fallback: D-Bus query to Baloo
if gdbus introspect --session --dest org.kde.baloo --object-path /org/kde/baloo &>/dev/null 2>&1; then
result=$(gdbus call --session \
--dest org.kde.baloo \
--object-path /org/kde/baloo \
--method org.kde.baloo.main.search "$query" "$limit" 2>/dev/null)
echo "$result" | sed "s/^(\[//;s/\],)$//;s/'//g;s/, /\n/g"
exit 0
fi
# Last fallback: locate
if command -v locate &>/dev/null; then
echo "# (using locate fallback - results may be stale)" >&2
locate -i "$query" | head -n "$limit"
exit 0
fi
echo "ERROR: Baloo not available and no fallback found (install baloo-utils or mlocate)" >&2
exit 1

116
tools/gui_input Executable file
View File

@ -0,0 +1,116 @@
#!/usr/bin/env bash
# description: Synthetic keyboard/mouse input via ydotool (Wayland) or xdotool (X11). Args: <key|type|click|move|scroll> [args...]
set -euo pipefail
cmd="${1:?usage: gui_input <key|type|click|move|scroll> [args...]}"
shift
# Detect input method: prefer ydotool (Wayland-safe), fall back to xdotool
if command -v ydotool &>/dev/null; then
INPUT_TOOL="ydotool"
elif command -v xdotool &>/dev/null; then
INPUT_TOOL="xdotool"
else
echo "ERROR: install ydotool (Wayland) or xdotool (X11)" >&2
exit 1
fi
case "$cmd" in
key)
# Simulate key press. Examples: "Return", "ctrl+c", "alt+F4", "super"
keys="${1:?usage: gui_input key <key_combo>}"
if [ "$INPUT_TOOL" = "ydotool" ]; then
# ydotool uses key codes; map common names
ydotool key "$keys"
else
xdotool key "$keys"
fi
echo "key: $keys"
;;
type)
# Type a string
text="${1:?usage: gui_input type <text>}"
if [ "$INPUT_TOOL" = "ydotool" ]; then
ydotool type -- "$text"
else
xdotool type --clearmodifiers -- "$text"
fi
echo "typed: ${#text} chars"
;;
click)
# Mouse click. Args: [button] [x y]
button="${1:-1}" # 1=left, 2=middle, 3=right
if [ $# -ge 3 ]; then
x="$2"; y="$3"
if [ "$INPUT_TOOL" = "ydotool" ]; then
ydotool mousemove --absolute "$x" "$y"
sleep 0.05
ydotool click "$button"
else
xdotool mousemove "$x" "$y"
sleep 0.05
xdotool click "$button"
fi
echo "click: button=$button at $x,$y"
else
if [ "$INPUT_TOOL" = "ydotool" ]; then
ydotool click "$button"
else
xdotool click "$button"
fi
echo "click: button=$button at cursor"
fi
;;
move)
# Move mouse. Args: <x> <y> [--relative]
x="${1:?usage: gui_input move <x> <y> [--relative]}"
y="${2:?usage: gui_input move <x> <y> [--relative]}"
relative="${3:-}"
if [ "$INPUT_TOOL" = "ydotool" ]; then
if [ "$relative" = "--relative" ]; then
ydotool mousemove -- "$x" "$y"
else
ydotool mousemove --absolute "$x" "$y"
fi
else
if [ "$relative" = "--relative" ]; then
xdotool mousemove_relative "$x" "$y"
else
xdotool mousemove "$x" "$y"
fi
fi
echo "moved: $x,$y${relative:+ (relative)}"
;;
scroll)
# Scroll. Args: <up|down|left|right> [amount]
direction="${1:?usage: gui_input scroll <up|down|left|right> [amount]}"
amount="${2:-3}"
if [ "$INPUT_TOOL" = "ydotool" ]; then
case "$direction" in
up) ydotool mousemove --wheel -- 0 "-$amount" ;;
down) ydotool mousemove --wheel -- 0 "$amount" ;;
left) ydotool mousemove --wheel -- "-$amount" 0 ;;
right) ydotool mousemove --wheel -- "$amount" 0 ;;
esac
else
case "$direction" in
up) xdotool click --repeat "$amount" 4 ;;
down) xdotool click --repeat "$amount" 5 ;;
left) xdotool click --repeat "$amount" 6 ;;
right) xdotool click --repeat "$amount" 7 ;;
esac
fi
echo "scroll: $direction $amount"
;;
*)
echo "usage: gui_input <key|type|click|move|scroll> [args...]" >&2
echo "" >&2
echo "Commands:" >&2
echo " key <combo> - press key combo (e.g. ctrl+c, Return, alt+F4)" >&2
echo " type <text> - type a string" >&2
echo " click [btn] [x y] - mouse click (1=left 2=mid 3=right)" >&2
echo " move <x> <y> [--relative] - move cursor" >&2
echo " scroll <dir> [amount] - scroll (up/down/left/right)" >&2
exit 1
;;
esac

119
tools/gui_notify Executable file
View File

@ -0,0 +1,119 @@
#!/usr/bin/env python3
# description: Show desktop notifications with optional actions. Args: <summary> [body] [--actions=label1,label2,...] [--timeout=ms] [--icon=name]
"""Send interactive notifications via org.freedesktop.Notifications D-Bus interface.
If --actions are specified, waits for user response and prints chosen action label.
Timeout defaults to 10000ms (10s) for action notifications, 5000ms otherwise.
"""
import sys
import argparse
import subprocess
import json
def parse_args():
p = argparse.ArgumentParser(description="Desktop notification with optional actions")
p.add_argument("summary", help="Notification title")
p.add_argument("body", nargs="?", default="", help="Notification body text")
p.add_argument("--actions", default="", help="Comma-separated action labels")
p.add_argument("--timeout", type=int, default=0, help="Timeout in ms (0=default)")
p.add_argument("--icon", default="ollie", help="Icon name (default: ollie)")
p.add_argument("--urgency", choices=["low", "normal", "critical"], default="normal")
return p.parse_args()
def main():
args = parse_args()
# Build actions array: ["id1", "label1", "id2", "label2", ...]
actions_list = []
if args.actions:
labels = [a.strip() for a in args.actions.split(",") if a.strip()]
for i, label in enumerate(labels):
actions_list.append(str(i))
actions_list.append(label)
timeout = args.timeout
if timeout == 0:
timeout = 10000 if actions_list else 5000
urgency_map = {"low": 0, "normal": 1, "critical": 2}
# Build gdbus call
# Notify(app_name, replaces_id, icon, summary, body, actions, hints, timeout)
actions_str = "[" + ", ".join(f"'{a}'" for a in actions_list) + "]"
hints_str = "{'urgency': <byte %d>}" % urgency_map[args.urgency]
cmd = [
"gdbus", "call", "--session",
"--dest", "org.freedesktop.Notifications",
"--object-path", "/org/freedesktop/Notifications",
"--method", "org.freedesktop.Notifications.Notify",
"ollie", # app_name
"0", # replaces_id (uint32)
args.icon, # icon
args.summary, # summary
args.body, # body
actions_str, # actions
hints_str, # hints
str(timeout), # timeout
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"ERROR: {result.stderr.strip()}", file=sys.stderr)
sys.exit(1)
# Parse notification ID from "(uint32 N,)"
nid = result.stdout.strip().strip("()").split(",")[0].replace("uint32 ", "")
if not actions_list:
print(f"notification:{nid}")
return
# Wait for ActionInvoked or NotificationClosed signal
labels = [actions_list[i+1] for i in range(0, len(actions_list), 2)]
monitor_cmd = [
"gdbus", "monitor", "--session",
"--dest", "org.freedesktop.Notifications",
"--object-path", "/org/freedesktop/Notifications",
]
import signal
class TimeoutError(Exception):
pass
def alarm_handler(signum, frame):
raise TimeoutError()
signal.signal(signal.SIGALRM, alarm_handler)
signal.alarm((timeout // 1000) + 2) # extra 2s grace
try:
proc = subprocess.Popen(monitor_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
for line in proc.stdout:
if "ActionInvoked" in line and nid in line:
# Parse: /org/freedesktop/Notifications: org.freedesktop.Notifications.ActionInvoked (uint32 N, 'action_id')
parts = line.split("'")
if len(parts) >= 2:
action_id = parts[1]
try:
idx = int(action_id)
print(labels[idx])
except (ValueError, IndexError):
print(action_id)
proc.terminate()
return
if "NotificationClosed" in line and nid in line:
print("dismissed")
proc.terminate()
return
except TimeoutError:
proc.terminate()
print("timeout")
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

158
tools/gui_screenshot Executable file
View File

@ -0,0 +1,158 @@
#!/usr/bin/env python3
# description: Capture screenshots via KWin ScreenShot2 D-Bus (Wayland-safe). Args: [active|screen|area|window] [--output=path] [--screen=n]
"""KDE screenshot tool using org.kde.KWin.ScreenShot2.
Modes:
active - capture the currently active window (default)
screen - capture entire screen (or specific with --screen=N)
area - capture a region (interactive picker)
window - capture window by title substring match
Output defaults to /tmp/ollie_screenshot_<timestamp>.png
"""
import sys
import os
import time
import argparse
import subprocess
import tempfile
SERVICE = "org.kde.KWin"
OBJ_PATH = "/org/kde/KWin/ScreenShot2"
IFACE = "org.kde.KWin.ScreenShot2"
def parse_args():
p = argparse.ArgumentParser(description="KDE screenshot via KWin D-Bus")
p.add_argument("mode", nargs="?", default="active",
choices=["active", "screen", "area", "window"],
help="Capture mode (default: active)")
p.add_argument("--output", "-o", default="",
help="Output file path (default: /tmp/ollie_screenshot_<ts>.png)")
p.add_argument("--screen", type=int, default=0,
help="Screen index for 'screen' mode")
p.add_argument("--title", default="",
help="Window title substring for 'window' mode")
p.add_argument("--include-cursor", action="store_true", default=False)
p.add_argument("--include-decoration", action="store_true", default=True)
return p.parse_args()
def get_output_path(args):
if args.output:
return args.output
ts = int(time.time())
return f"/tmp/ollie_screenshot_{ts}.png"
def capture_with_spectacle(mode, output, title=""):
"""Fallback: use spectacle CLI if ScreenShot2 is unavailable."""
cmd = ["spectacle", "-b", "-n", "-o", output]
if mode == "active":
cmd.append("-a")
elif mode == "screen":
cmd.append("-f")
elif mode == "area":
cmd.append("-r")
elif mode == "window":
cmd.append("-a") # fall back to active window
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
if result.returncode != 0:
return None
return output
def find_window_id_by_title(title):
"""Use KWin scripting to find window UUID by title."""
script = f"""
const clients = workspace.windowList();
for (let i = 0; i < clients.length; i++) {{
if (clients[i].caption.toLowerCase().includes('{title.lower()}')) {{
print(clients[i].internalId);
break;
}}
}}
"""
# Load script via D-Bus
load_cmd = [
"gdbus", "call", "--session",
"--dest", "org.kde.KWin",
"--object-path", "/Scripting",
"--method", "org.kde.kwin.Scripting.loadScript", "", script
]
# This is complex; for now fall back to xdotool/wmctrl
try:
result = subprocess.run(
["xdotool", "search", "--name", title],
capture_output=True, text=True, timeout=5
)
if result.stdout.strip():
return result.stdout.strip().split("\n")[0]
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
return None
def capture_via_dbus(mode, output, args):
"""Use KWin ScreenShot2 portal-style capture with pipe fd."""
hints = {
"include-cursor": args.include_cursor,
"include-decoration": args.include_decoration,
}
# ScreenShot2 methods use file descriptors which gdbus can't handle directly.
# Use the portal interface instead, or fall back to spectacle.
# Try the portal first:
portal_cmd = [
"gdbus", "call", "--session",
"--dest", "org.freedesktop.portal.Desktop",
"--object-path", "/org/freedesktop/portal/desktop",
"--method", "org.freedesktop.portal.Screenshot.Screenshot",
"", # parent_window
"{'interactive': <false>}",
]
# Spectacle is more reliable for non-interactive capture
result = capture_with_spectacle(mode, output, args.title)
if result and os.path.isfile(result):
return result
# Last resort: try import (ImageMagick) for X11
if os.environ.get("DISPLAY"):
if mode == "active":
wid = subprocess.run(
["xdotool", "getactivewindow"],
capture_output=True, text=True
).stdout.strip()
if wid:
r = subprocess.run(["import", "-window", wid, output],
capture_output=True, timeout=10)
if r.returncode == 0:
return output
elif mode == "screen":
r = subprocess.run(["import", "-window", "root", output],
capture_output=True, timeout=10)
if r.returncode == 0:
return output
return None
def main():
args = parse_args()
output = get_output_path(args)
result = capture_via_dbus(args.mode, output, args)
if result and os.path.isfile(result):
size = os.path.getsize(result)
print(f"{result} ({size} bytes)")
else:
print("ERROR: screenshot capture failed", file=sys.stderr)
print("Ensure spectacle or imagemagick is installed", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

95
tools/gui_shortcuts Executable file
View File

@ -0,0 +1,95 @@
#!/usr/bin/env bash
# description: Manage KDE global shortcuts via KGlobalAccel D-Bus. Args: <list|trigger|get|set> [component] [shortcut] [keys]
set -euo pipefail
SERVICE="org.kde.kglobalaccel"
OBJ_PATH="/kglobalaccel"
IFACE="org.kde.KGlobalAccel"
# Also component interface
COMP_OBJ="/component"
COMP_IFACE="org.kde.kglobalaccel.Component"
cmd="${1:-list}"
case "$cmd" in
list)
# List all registered shortcut components
components=$(gdbus call --session --dest "$SERVICE" \
--object-path "$OBJ_PATH" \
--method "$IFACE.allComponents" 2>/dev/null | \
sed "s/^(\[//;s/\],)$//;s/'//g;s/, /\n/g")
if [ -z "$components" ]; then
echo "ERROR: KGlobalAccel not responding" >&2
exit 1
fi
if [ -n "${2:-}" ]; then
# List shortcuts for specific component
comp="$2"
echo "# Shortcuts for: $comp"
gdbus call --session --dest "$SERVICE" \
--object-path "/component/$comp" \
--method "$COMP_IFACE.shortcutNames" 2>/dev/null | \
sed "s/^(\[//;s/\],)$//;s/'//g;s/, /\n/g"
else
echo "$components"
fi
;;
get)
# Get key binding for a shortcut
comp="${2:?usage: gui_shortcuts get <component> <shortcut_name>}"
name="${3:?usage: gui_shortcuts get <component> <shortcut_name>}"
gdbus call --session --dest "$SERVICE" \
--object-path "$OBJ_PATH" \
--method "$IFACE.shortcut" \
"['$comp', '$name', '', '']" 2>/dev/null
;;
trigger)
# Trigger (invoke) a global shortcut by component/name
comp="${2:?usage: gui_shortcuts trigger <component> <shortcut_name>}"
name="${3:?usage: gui_shortcuts trigger <component> <shortcut_name>}"
# First get the shortcut keys, then simulate them
# Or use the invokeShortcut method if available
gdbus call --session --dest "$SERVICE" \
--object-path "/component/$comp" \
--method "$COMP_IFACE.invokeShortcut" "$name" 2>/dev/null && {
echo "triggered: $comp/$name"
exit 0
}
echo "ERROR: could not trigger $comp/$name" >&2
exit 1
;;
set)
# Set key binding for a shortcut
comp="${2:?usage: gui_shortcuts set <component> <shortcut_name> <keys>}"
name="${3:?usage: gui_shortcuts set <component> <shortcut_name> <keys>}"
keys="${4:?usage: gui_shortcuts set <component> <shortcut_name> <keys>}"
gdbus call --session --dest "$SERVICE" \
--object-path "$OBJ_PATH" \
--method "$IFACE.setShortcut" \
"['$comp', '$name', '', '']" \
"[['$keys']]" 0 2>/dev/null && {
echo "set: $comp/$name = $keys"
exit 0
}
echo "ERROR: could not set shortcut" >&2
exit 1
;;
*)
echo "usage: gui_shortcuts <list|get|trigger|set> [component] [shortcut] [keys]" >&2
echo "" >&2
echo "Examples:" >&2
echo " gui_shortcuts list - list all components" >&2
echo " gui_shortcuts list kwin - list kwin shortcuts" >&2
echo " gui_shortcuts trigger kwin 'Walk Through Windows' - trigger Alt+Tab" >&2
echo " gui_shortcuts get kwin 'Walk Through Windows'" >&2
exit 1
;;
esac

414
tools/gui_windows Executable file
View File

@ -0,0 +1,414 @@
#!/usr/bin/env python3
# description: KWin window management via D-Bus scripting. Args: <list|focus|close|minimize|maximize|move|resize|desktops> [options]
"""Manage KDE windows via KWin D-Bus scripting interface.
Commands:
list - list all windows (title, class, geometry, desktop, state)
focus <title_substr> - raise and focus window matching title
close <title_substr> - close window matching title
minimize <title_substr> - minimize window
maximize <title_substr> - toggle maximize on window
move <title_substr> <x> <y> - move window to position
resize <title_substr> <w> <h> - resize window
desktops - list virtual desktops
desktop <number> - switch to virtual desktop
tile <left|right|title_substr> - tile window to half-screen
"""
import sys
import subprocess
import json
import tempfile
import os
import time
def kwin_script(js_code):
"""Execute a KWin script and return output."""
# Write script to temp file
with tempfile.NamedTemporaryFile(mode='w', suffix='.js', delete=False) as f:
f.write(js_code)
script_path = f.name
try:
# Load the script
load_result = subprocess.run([
"gdbus", "call", "--session",
"--dest", "org.kde.KWin",
"--object-path", "/Scripting",
"--method", "org.kde.kwin.Scripting.loadScript",
script_path, "ollie_gui_windows"
], capture_output=True, text=True, timeout=5)
if load_result.returncode != 0:
print(f"ERROR: loadScript failed: {load_result.stderr}", file=sys.stderr)
return None
# Parse script ID from "(int32 N,)"
script_id = load_result.stdout.strip().strip("()").split(",")[0].replace("int32 ", "")
# Run the script
run_result = subprocess.run([
"gdbus", "call", "--session",
"--dest", "org.kde.KWin",
"--object-path", f"/Scripting/Script{script_id}",
"--method", "org.kde.kwin.Script.run"
], capture_output=True, text=True, timeout=5)
# Give script time to execute (async)
time.sleep(0.3)
# Read output from KWin's journal/stdout - scripts use print()
# Unfortunately KWin script output goes to systemd journal, not easily capturable.
# For listing we'll use wmctrl/qdbus instead.
# Unload
subprocess.run([
"gdbus", "call", "--session",
"--dest", "org.kde.KWin",
"--object-path", "/Scripting",
"--method", "org.kde.kwin.Scripting.unloadScript",
"ollie_gui_windows"
], capture_output=True, text=True, timeout=5)
return True
finally:
os.unlink(script_path)
def list_windows_wmctrl():
"""List windows using wmctrl (works on X11 and XWayland)."""
try:
result = subprocess.run(
["wmctrl", "-l", "-G", "-p"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
return result.stdout
except FileNotFoundError:
pass
return None
def list_windows_qdbus():
"""List windows using KWin's D-Bus interface for Wayland."""
# Use KWin scripting to dump window list to a known file
out_file = "/tmp/ollie_winlist.txt"
js = f"""
var clients = workspace.windowList();
var lines = [];
for (var i = 0; i < clients.length; i++) {{
var c = clients[i];
if (c.skipTaskbar || c.specialWindow) continue;
var geo = c.frameGeometry;
var state = [];
if (c.minimized) state.push('minimized');
if (c.maximized) state.push('maximized');
if (c.fullScreen) state.push('fullscreen');
if (c.active) state.push('active');
lines.push(
c.caption + '\\t' +
c.resourceClass + '\\t' +
geo.x + ',' + geo.y + ',' + geo.width + ',' + geo.height + '\\t' +
'desktop:' + c.desktop + '\\t' +
state.join(',')
);
}}
callDBus('org.ollie.SessionManager', '/org/ollie/SessionManager',
'org.ollie.SessionManager', 'SetConfig',
'winlist', lines.join('\\n'));
"""
# Alternative: write to file via script. But KWin scripts are sandboxed.
# Use qdbus to get window list if available
try:
result = subprocess.run(
["qdbus6", "org.kde.KWin", "/KWin", "org.kde.KWin.queryWindowInfo"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
return result.stdout
except FileNotFoundError:
try:
result = subprocess.run(
["qdbus", "org.kde.KWin", "/KWin", "org.kde.KWin.queryWindowInfo"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
return result.stdout
except FileNotFoundError:
pass
return None
def list_windows():
"""List windows using best available method."""
# Try wmctrl first (most parseable)
result = list_windows_wmctrl()
if result:
print("ID\tDESKTOP\tPID\tX\tY\tW\tH\tTITLE")
for line in result.strip().split("\n"):
parts = line.split(None, 7)
if len(parts) >= 8:
wid, desktop, pid, x, y, w, h = parts[0], parts[1], parts[2], parts[3], parts[4], parts[5], parts[6]
title = parts[7] if len(parts) > 7 else ""
# Strip hostname prefix from title
if " " in title:
title = title.split(" ", 1)[1] if title.split(" ", 1)[0].isalpha() else title
print(f"{wid}\t{desktop}\t{pid}\t{x}\t{y}\t{w}\t{h}\t{title}")
return
# Try xdotool
try:
result = subprocess.run(
["xdotool", "search", "--onlyvisible", "--name", ""],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0 and result.stdout.strip():
print("ID\tTITLE")
for wid in result.stdout.strip().split("\n"):
name_result = subprocess.run(
["xdotool", "getwindowname", wid],
capture_output=True, text=True, timeout=2
)
name = name_result.stdout.strip() if name_result.returncode == 0 else "?"
print(f"{wid}\t{name}")
return
except FileNotFoundError:
pass
print("ERROR: no window listing tool available (install wmctrl or xdotool)", file=sys.stderr)
sys.exit(1)
def find_window(title_substr):
"""Find window ID by title substring."""
try:
result = subprocess.run(
["wmctrl", "-l"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
for line in result.stdout.strip().split("\n"):
if title_substr.lower() in line.lower():
return line.split()[0]
except FileNotFoundError:
pass
try:
result = subprocess.run(
["xdotool", "search", "--name", title_substr],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip().split("\n")[0]
except FileNotFoundError:
pass
return None
def focus_window(title_substr):
wid = find_window(title_substr)
if not wid:
print(f"ERROR: no window matching '{title_substr}'", file=sys.stderr)
sys.exit(1)
try:
subprocess.run(["wmctrl", "-i", "-a", wid], check=True, timeout=5)
except (FileNotFoundError, subprocess.CalledProcessError):
try:
subprocess.run(["xdotool", "windowactivate", wid], check=True, timeout=5)
except (FileNotFoundError, subprocess.CalledProcessError) as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
print(f"focused: {wid}")
def close_window(title_substr):
wid = find_window(title_substr)
if not wid:
print(f"ERROR: no window matching '{title_substr}'", file=sys.stderr)
sys.exit(1)
try:
subprocess.run(["wmctrl", "-i", "-c", wid], check=True, timeout=5)
except (FileNotFoundError, subprocess.CalledProcessError):
try:
subprocess.run(["xdotool", "windowclose", wid], check=True, timeout=5)
except (FileNotFoundError, subprocess.CalledProcessError) as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
print(f"closed: {wid}")
def minimize_window(title_substr):
wid = find_window(title_substr)
if not wid:
print(f"ERROR: no window matching '{title_substr}'", file=sys.stderr)
sys.exit(1)
try:
subprocess.run(["xdotool", "windowminimize", wid], check=True, timeout=5)
except (FileNotFoundError, subprocess.CalledProcessError):
# KWin script fallback
kwin_script(f"""
var clients = workspace.windowList();
for (var i = 0; i < clients.length; i++) {{
if (clients[i].caption.toLowerCase().includes('{title_substr.lower()}')) {{
clients[i].minimized = true;
break;
}}
}}
""")
print(f"minimized: {wid}")
def maximize_window(title_substr):
# KWin script is the clean way
kwin_script(f"""
var clients = workspace.windowList();
for (var i = 0; i < clients.length; i++) {{
if (clients[i].caption.toLowerCase().includes('{title_substr.lower()}')) {{
clients[i].setMaximize(!clients[i].maximized, !clients[i].maximized);
break;
}}
}}
""")
print(f"toggled maximize: {title_substr}")
def move_window(title_substr, x, y):
wid = find_window(title_substr)
if not wid:
print(f"ERROR: no window matching '{title_substr}'", file=sys.stderr)
sys.exit(1)
try:
subprocess.run(["wmctrl", "-i", "-r", wid, "-e", f"0,{x},{y},-1,-1"],
check=True, timeout=5)
except (FileNotFoundError, subprocess.CalledProcessError):
try:
subprocess.run(["xdotool", "windowmove", wid, str(x), str(y)],
check=True, timeout=5)
except (FileNotFoundError, subprocess.CalledProcessError) as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
print(f"moved {wid} to {x},{y}")
def resize_window(title_substr, w, h):
wid = find_window(title_substr)
if not wid:
print(f"ERROR: no window matching '{title_substr}'", file=sys.stderr)
sys.exit(1)
try:
subprocess.run(["wmctrl", "-i", "-r", wid, "-e", f"0,-1,-1,{w},{h}"],
check=True, timeout=5)
except (FileNotFoundError, subprocess.CalledProcessError):
try:
subprocess.run(["xdotool", "windowsize", wid, str(w), str(h)],
check=True, timeout=5)
except (FileNotFoundError, subprocess.CalledProcessError) as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
print(f"resized {wid} to {w}x{h}")
def list_desktops():
try:
result = subprocess.run(
["wmctrl", "-d"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
print(result.stdout.rstrip())
return
except FileNotFoundError:
pass
# Fallback via KWin D-Bus
try:
result = subprocess.run([
"gdbus", "call", "--session",
"--dest", "org.kde.KWin",
"--object-path", "/VirtualDesktopManager",
"--method", "org.freedesktop.DBus.Properties.Get",
"org.kde.KWin.VirtualDesktopManager", "desktops"
], capture_output=True, text=True, timeout=5)
if result.returncode == 0:
print(result.stdout.rstrip())
return
except FileNotFoundError:
pass
print("ERROR: cannot list desktops", file=sys.stderr)
sys.exit(1)
def switch_desktop(num):
try:
subprocess.run(["wmctrl", "-s", str(int(num) - 1)], check=True, timeout=5)
print(f"switched to desktop {num}")
except (FileNotFoundError, subprocess.CalledProcessError):
try:
subprocess.run([
"gdbus", "call", "--session",
"--dest", "org.kde.KWin",
"--object-path", "/VirtualDesktopManager",
"--method", "org.kde.KWin.VirtualDesktopManager.setCurrent",
str(num)
], check=True, capture_output=True, timeout=5)
print(f"switched to desktop {num}")
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
def main():
if len(sys.argv) < 2:
print("usage: gui_windows <list|focus|close|minimize|maximize|move|resize|desktops|desktop> [args...]", file=sys.stderr)
sys.exit(1)
cmd = sys.argv[1]
if cmd == "list":
list_windows()
elif cmd == "focus":
if len(sys.argv) < 3:
print("usage: gui_windows focus <title_substr>", file=sys.stderr)
sys.exit(1)
focus_window(sys.argv[2])
elif cmd == "close":
if len(sys.argv) < 3:
print("usage: gui_windows close <title_substr>", file=sys.stderr)
sys.exit(1)
close_window(sys.argv[2])
elif cmd == "minimize":
if len(sys.argv) < 3:
print("usage: gui_windows minimize <title_substr>", file=sys.stderr)
sys.exit(1)
minimize_window(sys.argv[2])
elif cmd == "maximize":
if len(sys.argv) < 3:
print("usage: gui_windows maximize <title_substr>", file=sys.stderr)
sys.exit(1)
maximize_window(sys.argv[2])
elif cmd == "move":
if len(sys.argv) < 5:
print("usage: gui_windows move <title_substr> <x> <y>", file=sys.stderr)
sys.exit(1)
move_window(sys.argv[2], sys.argv[3], sys.argv[4])
elif cmd == "resize":
if len(sys.argv) < 5:
print("usage: gui_windows resize <title_substr> <w> <h>", file=sys.stderr)
sys.exit(1)
resize_window(sys.argv[2], sys.argv[3], sys.argv[4])
elif cmd == "desktops":
list_desktops()
elif cmd == "desktop":
if len(sys.argv) < 3:
print("usage: gui_windows desktop <number>", file=sys.stderr)
sys.exit(1)
switch_desktop(sys.argv[2])
else:
print(f"unknown command: {cmd}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()