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:
parent
a86d7391b2
commit
606bb1bbaa
25
Makefile
25
Makefile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
Reference in New Issue