wayctl is a script-friendly CLI for Wayland compositors with clear JSON output and a stable command model.
Unlike swaymsg or hyprctl, wayctl uses the Wayland protocol stack (wayland-protocols and wlr-protocols) to provide relevant information from the compositor, as well as managed outputs, workspaces, and windows. Where that is not possible, conservative heuristic methods are used (for example, for PID matching).
Actions are also possible through the Wayland protocol stack. Where that is not sufficient, wayctl falls back to key sending (set/send send_key).
- Features
- Current Status (Version: 0.8.1)
- CLI Structure
- Labwc Action Reference (Grouped by Topic)
- General Action Mappings via TOML
- Labwc Action Reference for TOML Mappings
- Layer-Shell Preparation
- PID Selector and Portability
- PID Enrichment Heuristics
- Render a Labwc Block from TOML
- TOML Hardening
- Architecture at a Glance
- Installation
- Development
- Tests
- Output Format
- Contributing, Bugs, and Feature Requests
- Authors
- Structured queries:
get toplevels,get workspaces,get outputs - Direct window control: focus, close, fullscreen, maximized, minimized
- Workspace control (depending on compositor capabilities): switch, create, remove, assign
- Action/fallback layer:
set/send trigger_action(TOML-driven) andset/send send_key - Configuration tools:
render labwc,config lint,config lint --strict - Global logging:
--verbose [LEVEL]and--log [PATH]with invocation headers and run summaries
- Directly supported: central GET and SET/SEND paths and deterministic target selection via session-stable
program_id, cross-process selectors (--pid,--title,--app-id), orhandle_idfallback - Partially supported: labwc actions, currently running via TOML +
trigger_action - Not directly supported: runtime workspace rename and deterministic
set/send rectangle
Beyond the basic features, wayctl offers several advanced capabilities:
-
PID Heuristic (
-H pid)
Stack-order-based mapping of Wayland toplevels to process IDs. Stack matching is the primary strategy; when ambiguous, a configurable filter pipeline (pid_matchers.toml) is used as fallback. Thepid_statefield reports process status (running/defunct/dead) formatcher = "pid"; it is alwaysnullformatcher = "ppid"(Wayland does not expose which child process owns a specific window). No shell expansion — only allowlisted filter commands. -
Configuration Validation (
config lint --strict)
TOML file, key combinations, and mappings are verified before processing. With--strict, warnings are also treated as errors — ideal for CI/CD. -
Action Mapping Model (TOML-driven)
Logical actions can be mapped both via direct protocol commands (direct = [...]) and labwc actions (action = "..."). A singlewayctl_actions.tomlserves multiple compositors (labwc, Hyprland, niri, etc.). -
Fallback Filters for Process Lookup (
pid_matchers.toml)
When stack order is not unique, matcher rules use command pipelines (grep, awk, sed, ...) for process selection. Each filter is executed individually; timeouts and line limits are hardcoded (plus optionally tightened via TOML). -
Runtime Logging and Diagnostics
Optional stderr/file logging with levels (TRACE,DEBUG,INFO,WARNING,ERROR,CRITICAL). Use--verboseto print logs and--logto append to a file (./wayctl.log, fallback~/.config/wayctl.log). Each invocation writes a header (separator, timestamp, full command) and a structured summary line:run_summary command_group=... exit_code=... duration_ms=.... Log files are automatically rotated by size; configure rotation policy and other defaults inwayctl.toml. -
Configuration File (
wayctl.toml)
Central configuration file for logging, CLI defaults, and timeouts. Loaded from./wayctl.tomlor~/.config/wayctl/wayctl.toml. Includes sections for logging (log level, file path, rotation settings), CLI defaults (verbose, pretty, heuristic_pid), and PID matcher timeouts. All settings are optional; defaults are sensible for standard use. -
Daemon IPC Monitoring (
daemon+monitor.subscribe)
wayctlcan run as a background daemon over Unix socket IPC (daemon start). Clients can subscribe to event streams withmonitor.subscribeand topic filters (daemon,command,toplevel,workspace,output,*). The daemon emits command-completion events and change events forget toplevels/workspaces/outputs. For diagnostics, monitor events can additionally be mirrored to stdout or a JSONL sink file. Snapshot commands now explicitly release/destroy Wayland protocol proxies after each poll cycle to keep long-running daemon loops stable and avoid native pywayland/libwayland teardown crashes.
IPC clients can perform a lightweight capability handshake before sending commands:
- Send
action = "hello"to receive protocol identity and supported features. - Send
action = "capabilities"to receive full action and topic lists.
Example hello request envelope:
{"src":"client","type":"command","action":"hello","payload":{},"expect_response":true,"request_id":"req-hello-1","version":"1.0"}Example monitor.subscribe request envelope:
{"src":"client","type":"command","action":"monitor.subscribe","payload":{"subscriptions":["workspace","output"]},"expect_response":true,"request_id":"req-sub-1","version":"1.0"}Example event emitted by daemon:
{"src":"wayctl.daemon","type":"event","action":"monitor.workspace.changed","payload":{"topic":"workspace","action":"get.workspaces","data":{}},"version":"1.0"}- Labwc-specific gaps and runtime extensions: labwc_feature_requests.md
- Protocol extension for workspace rename (
ext_workspace_v1): freedesktop_feature_request.md
- Wayland registry connection is implemented.
zwlr_foreign_toplevel_manager_v1is bound.- CLI is structured as an extensible command system with namespaces.
The command line is hierarchical so new commands can grow cleanly:
get ...for read-only queriesset/send ...for state-changing commandsversionas a global meta command
The following matrix replaces the earlier example and note blocks. It shows at a glance what wayctl already implements directly, what runs through trigger_action, and what is still open.
Status legend:
yes: a direct, stablewayctlcommand exists for the function.partial: the function is currently only available indirectly, typically throughset/send trigger_actionplus TOML mapping, or it depends on compositor capabilities. See General Action Mappings via TOML, TOML Hardening, and PID Selector and Portability.no: no direct runtime path is available yet. For background and requests, see labwc_feature_requests.md and freedesktop_feature_request.md.
| Labwc Action | Description (Functionality) | wayctl Command | Notes | Implemented |
|---|---|---|---|---|
Focus |
Sets keyboard focus to a window. | set/send focus <program_id>, set/send focus --pid <PID>, set/send focus --title "title", set/send focus --app-id app.id |
Session-stable program_id is preferred; alternatives work cross-process. |
yes |
Raise |
Raises a window to the top within its layer. | set/send trigger_action <name> |
Only available through labwc action mapping. | partial |
Lower |
Pushes a window to the bottom within its layer. | set/send trigger_action <name> |
Only available through labwc action mapping. | partial |
Close |
Sends a close request to the application. | set/send close_window <program_id> or cross-process selectors |
Direct foreign toplevel path with multiple selector options. | yes |
Kill |
Terminates the window process immediately. | set/send trigger_action <name> |
No direct wayctl API command; mapping only. |
partial |
Iconify |
Minimizes a window. | set/send minimized <program_id> on or cross-process selectors |
Direct state path available. | yes |
Unminimize |
Restores a minimized window. | set/send minimized <program_id> off or cross-process selectors |
Direct state path available. | yes |
Maximize |
Maximizes a window. | set/send maximized <program_id> on or cross-process selectors |
Direct state path available. | yes |
Unmaximize |
Restores the original window size. | set/send maximized <program_id> off or cross-process selectors |
Direct state path available. | yes |
ToggleMaximize |
Switches between maximized and normal state. | set/send trigger_action <name> |
Toggle behavior is only available through action mapping; the direct command is state-based (on/off). |
partial |
ToggleFullscreen |
Switches fullscreen on or off. | set/send fullscreen <program_id> on/off, optionally set/send trigger_action <name> |
Direct state path with multiple selector options; toggle via action mapping. | yes |
ToggleDecorations |
Toggles server-side decorations. | set/send trigger_action <name> |
No direct wayctl API command. |
partial |
ToggleAlwaysOnTop |
Pins a window into the top layer. | set/send trigger_action <name> |
No direct wayctl API command. |
partial |
ToggleAlwaysOnBottom |
Moves a window into the bottom layer. | set/send trigger_action <name> |
No direct wayctl API command. |
partial |
ToggleOmnipresent |
Makes a window sticky across all workspaces. | set/send trigger_action <name> |
No direct wayctl API command. |
partial |
Examples — Window Management:
# Focus a terminal by program_id (session-stable selector)
wayctl send focus wctl-123
# Minimize all browser windows by app-id
wayctl send minimized --app-id firefox on
# Maximize a window, then undo it via normalize
wayctl send maximized wctl-123 on
wayctl send normalize wctl-123| Labwc Action | Description (Functionality) | wayctl Command | Notes | Implemented |
|---|---|---|---|---|
Move |
Starts interactive moving. | set/send trigger_action <name> |
Only available through action mapping. | partial |
Resize |
Starts interactive resizing. | set/send trigger_action <name> |
Only available through action mapping. | partial |
MoveToEdge |
Moves a window to a screen edge. | set/send trigger_action <name> |
Only available through action mapping. | partial |
SnapToEdge |
Snaps a window to an edge (50%). | set/send trigger_action <name> |
Only available through action mapping. | partial |
SnapToRegion |
Moves a window into a predefined region. | set/send trigger_action <name> |
Only available through action mapping. | partial |
MoveToCursor |
Centers a window under the cursor. | set/send trigger_action <name> |
Only available through action mapping. | partial |
VirtualLineUp / VirtualLineDown |
Simulates mouse wheel scrolling up and down. | set/send trigger_action <name> or set/send send_key <combo> |
Typical use goes through keybinding mapping. | partial |
set_rectangle (foreign toplevel hint) |
Passes a position and size hint to the compositor. | set/send rectangle <program_id> <x> <y> <width> <height> |
Intentionally returns unsupported for now; the wl_surface object from the same session is missing. See labwc_feature_requests.md. |
no |
Examples — Window Positioning:
# Set focus on window with program_id wctl-42 and then snap the focused window to the left half via TOML action
wayctl send focus wctl-42 && wayctl send trigger_action snap_left
# Move the focused window to the right screen edge via TOML action
wayctl send trigger_action move_right_edge
# Scroll down in a window without moving the mouse (virtual scroll)
wayctl send trigger_action scroll_down| Labwc Action | Description (Functionality) | wayctl Command | Notes | Implemented |
|---|---|---|---|---|
GoToDesktop |
Switches workspace (index or relative). | set/send switch_workspace <workspace_id/number/#index/workspace_name> or set/send trigger_action <name> |
The direct wayctl path is preferred for deterministic target selection. |
yes |
SendToDesktop |
Sends the active window to another workspace. | set/send trigger_action <name> |
No direct wayctl API command. |
partial |
NextWindow |
Moves focus to the next window. | set/send trigger_action <name> |
Only available through action mapping. | partial |
PreviousWindow |
Moves focus to the previous window. | set/send trigger_action <name> |
Only available through action mapping. | partial |
DirectionalFocus |
Moves focus in a direction. | set/send trigger_action <name> |
Only available through action mapping. | partial |
| Create workspace | Creates a new workspace in a group. | set/send create_workspace <group_id> <name> |
Works only if the compositor advertises the corresponding capability. See labwc_feature_requests.md. | partial |
| Remove workspace | Removes an existing workspace. | set/send remove_workspace <workspace_id/number/workspace_name> |
Works only if the compositor advertises the corresponding capability. See labwc_feature_requests.md. | partial |
| Assign workspace | Assigns a workspace to a group. | set/send assign_workspace <workspace_id/number/workspace_name> <group_id> |
Works only if the compositor advertises the corresponding capability. See labwc_feature_requests.md. | partial |
| Rename workspace | Renames a workspace at runtime. | set/send rename_workspace <workspace_id/number/workspace_name> <new_name> |
Intentionally returns unsupported; ext_workspace_v1 has no rename request. See freedesktop_feature_request.md and labwc_feature_requests.md. |
no |
Examples — Workspaces:
# Switch to workspace 2 by index
wayctl send switch_workspace #2
# Switch to an existing workspace by name
wayctl send switch_workspace dev
# Send the active window to workspace 2 via action mapping
wayctl send trigger_action send_to_workspace_2| Labwc Action | Description (Functionality) | wayctl Command | Notes | Implemented |
|---|---|---|---|---|
If |
Executes part of an action chain conditionally. | set/send trigger_action <name> |
Only indirectly available through labwc actions. | partial |
ForEach |
Applies an action to multiple windows. | set/send trigger_action <name> |
Only indirectly available through labwc actions. | partial |
Execute |
Runs an external command. | set/send trigger_action <name> |
Only indirectly available through labwc actions. | partial |
ShowMenu |
Opens a menu at the cursor. | set/send trigger_action <name> |
Only indirectly available through labwc actions. | partial |
Reconfigure |
Reloads the labwc configuration. | set/send trigger_action <name> |
Only indirectly available through labwc actions. | partial |
Exit |
Ends the session. | set/send trigger_action <name> |
Only indirectly available through labwc actions. | partial |
Stop |
Stops further action processing. | set/send trigger_action <name> |
Only indirectly available through labwc actions. | partial |
None |
Intentionally performs no action. | set/send trigger_action <name> |
Only indirectly available through labwc actions. | partial |
Examples — Logic, Control, and System:
# Reload labwc configuration after editing rc.xml
wayctl send trigger_action reconfigure
# Open the root context menu at the current cursor position
wayctl send trigger_action show_menu
# Kill a frozen window immediately via TOML action
wayctl send trigger_action kill_window| wayctl Command | Notes |
|---|---|
get toplevels |
Snapshot of all detected toplevels with program_id (session-stable), title, app_id, states, and optional pid. Optional stabilization flags: --stabilize, --stabilize-timeout-ms, --stabilize-roundtrips. |
get workspaces |
Requires ext_workspace_manager_v1; if privilegedInterfaces is used, <allow> must be set. |
get outputs |
Requires zwlr_output_manager_v1; if privilegedInterfaces is used, <allow> must be set. |
set/send close_window <program_id> |
Sends a compositor close request. status=closed means the window disappeared quickly; status=close_requested means the request was accepted but the app may still show a save/discard dialog. |
set/send normalize <program_id> |
Resets sticky window states in one call (fullscreen=off, maximized=off, minimized=off). Useful as a quick recovery path when interactive resize is blocked by window state. |
render labwc |
Renders an XML keybind block from wayctl_actions.toml for rc.xml. |
config lint |
Validates the TOML schema and semantic collisions. |
config lint --strict |
Same as config lint, but additionally treats warnings as errors for CI hardening. |
Global option -c / --conf |
Selects the TOML file explicitly; otherwise the search path is ./wayctl_actions.toml followed by ~/.config/wayctl/actions.toml. |
version |
Returns tool metadata as JSON. |
The wayctl_actions.toml file defines the general mapping between logical actions and compositor-specific shortcuts. This keeps wayctl compositor-agnostic, while labwc, niri, Hyprland, or other compositors can provide their own bindings.
The example combos in this file use W-C-A-S-F13 through W-C-A-S-F23. These keys do not exist as physical keys on standard keyboards, but F13–F24 are valid XKB keysyms with corresponding Linux evdev keycodes (KEY_F13–KEY_F24). They are effectively collision-free: no system shortcut will ever accidentally fire one of these combos. wayctl automatically patches the XKB keymap at runtime when the system keymap does not include F13–F24 (which is the case for most standard pc/evdev layouts, regardless of BIOS Fn-Lock settings). If a custom keymap already maps F13 directly, the patch is skipped.
Example:
version = 1
active_profile = "labwc"
[actions.toggle_fullscreen]
description = "Toggle fullscreen on the focused window"
[actions.toggle_fullscreen.backends.labwc]
combo = "W-C-A-S-F13"
action = "ToggleFullscreen"
[actions.close_window.backends.labwc]
combo = "W-C-A-S-F15"
action = "Close"
direct = ["send", "close_window", "{focused}"]Placeholder tokens for direct = [...]:
{focused}resolves at runtime to the currently active toplevel handle.{target}resolves to an explicitly selected target token. For toplevel commands, use positional selector (defaultprogram_id) or--program-id,--pid,--title,--app-id. For workspace commands likeswitch_workspace, use positional selector or--program-idwith a workspace token.
When a direct protocol path is not available, exec lets you run arbitrary external commands directly from the TOML binding:
[actions.screenshot_focused_window.backends.labwc]
combo = "W-Print"
var_out = "$(xdg-user-dir PICTURES)/screenshot_{timestamp}.png"
exec = ["sh", "-c", "grim -g \"$(slurp)\" \"$var_out\""]
notify = "Screenshot saved:\n$var_out"exec — argv list executed via subprocess (no shell). combo becomes optional; when both are set, wayctl render labwc emits an Execute keybind block automatically.
var_* variables — user-defined string templates resolved before substitution into exec and notify:
- Built-in
{placeholder}tokens are substituted first. - Any remaining
$(...)expressions are expanded viash. - The resolved string is stored and substituted as
$var_name.
Built-in placeholders available in exec tokens, var_* values, and notify:
| Placeholder | Resolves to |
|---|---|
{timestamp} |
Current time as HH-MM-SS |
{date} |
Current date as YYYY-MM-DD |
{focused_title} |
Title of the activated toplevel |
{focused_app_id} |
App ID of the activated toplevel |
{focused_program_id} |
Program ID of the activated toplevel |
notify — sends a desktop notification via notify-send on exec success. Supports the same {placeholder} and $var_name substitution as exec.
Example:
wayctl send trigger_action close_window wctl-42Labwc action names and attributes form the backend-specific mapping layer for wayctl_actions.toml. For automation, the main rule is:
- If
wayctlalready has a stable direct protocol path,direct = [...]should be preferred. - Labwc actions are especially useful when the behavior is explicitly a toggle, a cycle action, or a labwc-specific workspace navigation path.
| Logical Action | labwc action |
Important attrs |
Recommended wayctl Path |
|---|---|---|---|
| Close window | Close |
none | Prefer direct: direct = ["send", "close_window", "{focused}"] |
| Toggle fullscreen | ToggleFullscreen |
none | For true toggle behavior via labwc action; for explicit state use set/send fullscreen <program_id> on/off |
| Toggle maximization | ToggleMaximize |
optional direction = both or horizontal or vertical |
For toggle behavior via labwc action; for explicit state use set/send maximized <program_id> on/off |
| Minimize window | Iconify |
none | Directly possible for state on: set/send minimized <program_id> on; no direct toggle exists |
| Next window | NextWindow |
optional workspace = current or all, output = all or focused or cursor, identifier = all or current |
Labwc action only |
| Previous window | PreviousWindow |
optional workspace = current or all, output = all or focused or cursor, identifier = all or current |
Labwc action only |
| Switch workspace | GoToDesktop |
to, optional wrap, optional toggle |
Prefer direct for deterministic targeting: set/send switch_workspace <workspace_id/number/#index/name> |
| Send window to workspace | SendToDesktop |
to, optional follow, optional wrap |
Labwc action only |
| Sticky / on all workspaces | ToggleOmnipresent |
none | Labwc action only |
| Focus window under cursor | Focus |
none | Labwc action only; not suitable as a replacement for handle-based targeting |
Examples of TOML mappings with labwc attributes:
[actions.workspace_left]
description = "Switch to the workspace on the left"
[actions.workspace_left.backends.labwc]
combo = "W-C-A-S-F16"
action = "GoToDesktop"
attrs = { to = "left", wrap = "yes" }
[actions.send_to_workspace_2]
description = "Send the focused window to workspace 2 and follow it"
[actions.send_to_workspace_2.backends.labwc]
combo = "W-C-A-S-F17"
action = "SendToDesktop"
attrs = { to = "2", follow = "yes", wrap = "yes" }
[actions.next_window_current_output]
description = "Cycle windows on the focused output"
[actions.next_window_current_output.backends.labwc]
combo = "W-C-A-S-F18"
action = "NextWindow"
attrs = { workspace = "current", output = "focused", identifier = "all" }Source for this reference: labwc labwc-actions(5), default bindings, and src/action.c (action names plus attribute parsing and defaults).
wayctl does not yet provide its own get layer_surfaces or set/send layer_surface path. For the later API, labwc semantics are already clear enough to define the target mapping.
Future wayctl State |
labwc / zwlr-layer-shell Semantics | Notes |
|---|---|---|
passthrough = true |
keyboard_interactivity = none |
The layer surface does not take keyboard focus; input continues through normal focus rules |
exclusive = true |
keyboard_interactivity = exclusive |
The layer surface blocks normal toplevel focus until focus is explicitly taken or released |
passthrough = false, exclusive = false |
keyboard_interactivity = on-demand |
Focus only on normal user interaction; in labwc this makes sense for top/overlay, while background/bottom should not steal focus |
margin = {top,right,bottom,left} |
per-surface layer-shell margins | Belongs to the layer surface itself, not to global output configuration |
reserved_area / exclusive_zone |
exclusive_zone affects output->usable_area |
labwc reduces the usable area for normal windows before placement, maximize, and tiling |
Important design rules derived from labwc:
-
exclusive_zoneandmarginare not the same thing. Layer-shell margins are surface-local. By contrast, labwc<margin>inrc.xmlis a compositor-wide output override for panels or docks without layer-shell support. -
passthroughis not its own protocol field. Inwayctl,passthroughshould be treated as a readable abstraction forkeyboard_interactivity = none. -
On-demand focus is intentionally restrictive. labwc does not allow
on-demandlayer surfaces inbackground/bottomto steal focus freely; in practice it mainly makes sense fortop/overlay, such as panel menus, launchers, or nags. -
output->usable_areais the actual effect of reserved layer space. Placement, maximize, tiling, and SSD resize extents in labwc are based onusable_area, not directly on individual layer surfaces. -
Global
<margin>remains the fallback for non-layer-shell clients. If a panel does not speakzwlr_layer_shell_v1, labwc<margin>is the fallback model. A futurewayctlAPI should not mix those two levels.
Practical consequence for the future wayctl API:
- GET side: fields such as
layer,keyboard_interactivity,exclusive_zone,margin,output,anchors,mapped - Derived JSON flags: for example
passthrough,exclusive,reserves_space - No premature toggle API design: layer-shell is stateful and surface-specific, not as stably addressable as toplevel handles
wayctl supports --pid <PID> as an alternative targeting scheme for toplevel control commands. The following fallback rules apply:
-
No Wayland protocol exposes PIDs natively. Neither
zwlr_foreign_toplevel_manager_v1norext_foreign_toplevel_list_v1contain a PID field. Thepidfield inget toplevelsoutput therefore remainsnullby default. -
--pidfails when no enrichment source is available. Without externally enriched PID data (enrich_pid()),get_by_pid()returnsnot_foundimmediately. No guessing takes place. -
Ambiguity is rejected hard. If two toplevel handles carry the same PID, for example in a multi-window app,
--pidreturnsambiguous; it does not silently choose an arbitrary window. -
Recommended primary path:
program_idfromget toplevels(session-stable across process boundaries). Fallback selectors include--pid,--title,--app-id, or legacyhandle_id(ephemeral, same-process only). -
app_idheuristics are intentionally not implemented. Multiple instances of the same app share the sameapp_id; matching on it would be fragile and could target the wrong window.
The default key combinations are intentionally unusual so they do not block day-to-day shortcuts.
get toplevels supports optional best-effort PID enrichment via -H pid / --heuristic pid.
When enabled, wayctl tries to fill pid, ppid, and pid_state in the toplevel JSON output.
This is the primary strategy and is applied first for each app_id group:
- Wayland toplevel records are collected in compositor stack order (oldest first).
- Matching processes are collected via
ps -o pid=,stat= -C <process_name>. - If the process count exactly matches the toplevel count for that
app_id, records are matched positionally.
pid_state is derived from ps STAT values:
running(default)defunct(Z)dead(X)
If stack-order matching cannot assign PIDs because counts differ, wayctl uses matcher rules from pid_matchers.toml.
- Matcher config is loaded from the first existing default location:
./pid_matchers.toml~/.config/wayctl/pid_matchers.toml
- Rules are configured per
app_id. filtersare a command pipeline (argv lists) applied topsoutput.aliascan override the process name used forps -C.extra_statscan add extrapscolumns (for exampleppid) for matcher decisions.matcher = "pid"performs positional PID assignment when filtered counts match.matcher = "ppid"requires a single unambiguous parent PID candidate; all matching toplevels receiveppid(the parent PID).pidandpid_stateremainnull(see Safeguards and Limitations).
Example:
[code]
extra_stats = "ppid"
filters = [["grep", "server.bundle.js"]]
matcher = "ppid"
[google-chrome]
alias = "chrome"
extra_stats = "ppid"
filters = [["awk", "'$0 !~ /--type=/ {print $0}'"]]
matcher = "ppid"Field reference:
| Field | Type | Default | Notes |
|---|---|---|---|
alias |
string |
app_id | Optional process-name override used with ps -C. |
extra_stats |
string or list[string] |
empty | Optional extra ps columns. Common example: ppid. |
filters |
list[list[string]] |
none | Required command pipeline (argv lists). Legacy list[string] is treated as grep needles. |
matcher |
"pid" or "ppid" |
"pid" |
pid: positional PID assignment on exact match count. ppid: requires one unambiguous parent PID. |
step_timeout_ms |
int |
bounded default | Optional per-step timeout; cannot exceed hard-coded max. |
total_timeout_ms |
int |
bounded default | Optional end-to-end timeout; cannot exceed hard-coded max. |
max_output_lines |
int |
bounded default | Optional output cap for ps and filter stages; cannot exceed hard-coded max. |
- The heuristic is best-effort and never a protocol guarantee; Wayland toplevel protocols do not expose PIDs natively.
- Assignment is conservative: if matching is not reliable, fields remain
nullinstead of guessing. - Filter commands run with
shell=False(hardcoded) and cannot use shell expansion. - Only allowlisted filter commands are executed.
- Runtime safety limits are hard-capped in code (per-step timeout, total timeout, max output lines); config can only tighten them.
app_idand process names can differ (for example wrappers, Flatpak, sandboxing).- Snapshot races are possible (window/process lifecycle changes between Wayland snapshot and
ps). - A single process can own multiple toplevels, which can reduce deterministic matching quality.
pid_stateis alwaysnullformatcher = "ppid"entries. The Wayland protocol does not expose which child process owns a specific window, and neitherps --ppidnor socket inspection can reliably link a renderer PID to a specific toplevel. This is by design —ppidis still available in the toplevel output and usable with--pid.- If
matcher = "ppid"resolves a parent PID of 1 (init/systemd), the heuristic logs a warning and skips assignment. This guards against re-parented or system-service processes producing spurious matches.
For labwc, an XML block can be rendered from the same TOML file:
python wayctl.py render labwcExample output:
<keyboard>
<keybind key="W-C-A-S-F13">
<action name="ToggleFullscreen" />
</keybind>
</keyboard>That block can then be copied into rc.xml. For other compositors, the TOML file remains the same source of truth; only the target format changes.
If labwc uses a <privilegedInterfaces> block, zwp_virtual_keyboard_manager_v1 must also be allowed there for set/send send_key and set/send trigger_action.
The following command validates the mapping file before use or in CI:
python wayctl.py -c ./wayctl_actions.toml config lint
python wayctl.py -c ./wayctl_actions.toml config lint --strict--strict treats warnings as errors and is therefore suitable for stricter CI checks.
Validation currently covers, among other things:
- schema hardening (
version = 1, valid identifiers, required fields) - parsability of all key combinations
- collisions of the same key combination within a backend
Notes on get workspaces:
- The compositor must provide
ext_workspace_manager_v1. - If a
privilegedInterfacesblock is used,ext_workspace_manager_v1must be allowed there. numberis derived from numeric workspace names (for example1orWorkspace 1) and isnullfor non-numeric names.workspace_id_dbgis a debug-only field and is only included with-v DEBUGor-v TRACE.
Notes on get toplevels:
outputscontains resolved monitor names (for exampleX11-1,eDP-1).ppidis set bymatcher = "ppid"entries and can be used with--pidselectors.outputs_dbg,pid_state, andparent_idare debug-only fields and are only included with-v DEBUGor-v TRACE.--stabilizeruns extra roundtrips until consecutive snapshots match (bounded by--stabilize-timeout-ms, default300).--stabilize-roundtripscontrols how many identical consecutive snapshots are required (default2).
Notes on get outputs:
- The compositor must provide
zwlr_output_manager_v1. - If a
privilegedInterfacesblock is used,zwlr_output_manager_v1must be allowed there.
wayctl is built around a simple, extensible command system:
- Each command lives in its own module under
wayctl_core/commands/. No changes to the main entry point required. CommandSpec(leaf) andCommandGroupSpec(group) are immutable dataclasses frombase.py. Both implement theCommandNodeprotocol withregister()for argparse wiring.__init__.pyaggregates all modules viaall_commands()into the CLI groupsget,set,render,config,daemon.- Every handler opens its own short-lived Wayland connection (
with WaylandRegistryClient()), runs two roundtrips (announce handles, drain metadata), and returns a JSON-serializabledict. - Resolvers (
ToplevelHandleResolver, workspace/group resolvers) provide deterministic target selection with no silent guessing — ambiguity or no match always yields a structured error payload. - Output format:
getcommands always return a schema envelopewayctl.get.v1.0;setcommands always return at leaststatus.
Detailed technical documentation (dataclasses, roundtrip model, resolver priorities, module template, test conventions) lives in wayctl_core/commands/README.de.md.
For normal usage, install wayctl as a script into your prefix bin directory:
make install PREFIX=~/.localOr system-wide:
sudo make install PREFIX=/usr/localmake install now uses the system Python dependency check and does not activate or rely on the project venv.
Before copying the executable, the Makefile runs check-install-deps and verifies that runtime packages from requirements.txt are available in system Python. If packages are missing, installation stops with a clear message.
If you want more console output from Make:
make V=1 install PREFIX=~/.localFor contributors and test/dev workflows:
make dev-installAlias:
make install-devInstead of using make install-dev, you can perform all steps manually:
source venv/bin/activate
pip install -r requirements.txt
python wayctl.py --help
pytest -q tests/test_wayland_registry.pyThis project currently uses pytest for the core registry and binding area.
Quick focused test run:
source venv/bin/activate
pytest -q tests/test_wayland_registry.pyRun the full project test suite:
source venv/bin/activate
pytest -qCLI smoke test without Wayland operations:
source venv/bin/activate
python wayctl.py --help
python wayctl.py versionLive smoke tests (active Wayland session required):
WAYLAND_DISPLAY=wayland-0 python wayctl.py -p get toplevels
WAYLAND_DISPLAY=wayland-0 python wayctl.py -p get workspaces
WAYLAND_DISPLAY=wayland-0 python wayctl.py -p get outputsIf a test fails:
- use
pytest -vvfor more detail - if imports fail, run
pip install -r requirements.txtagain first - for Wayland-related failures, verify that the session and environment are set correctly
- JSON output uses UTF-8 (
ensure_ascii=False) so characters such as umlauts remain directly readable, for exampleArbeitsfläche 1instead of\u00e4escapes. - All
getcommands return a unified schema envelopewayctl.get.v1.0withschema(name + query) and normalizedmeta.counts. - Error output is structured (
title,details,next steps) and includes concrete hints about the Wayland session and interface permissions.
Contributions are welcome. Please use the repository for issues, pull requests, and feedback.
Help is currently most needed in these areas:
-
Compositor coverage:
wayctlis primarily developed and tested against labwc. Testing and feedback for other compositors (Hyprland, niri, KWin, Sway, ...) help identify protocol differences and incompatibilities early. -
pid_matchers.toml: Help with robust matcher rules for additional programs is highly appreciated, soget toplevelscan enrichpid/ppidmore reliably. -
wayctl_actions.toml: The file currently contains mostly test commands. A practical baseline with useful default actions per backend is needed.
Note: Earlier external feature-request references were removed. This documentation intentionally focuses only on the currently verified feature set and the roadmap maintained within this project.
- Thomas Funk (lead author, strategy, manual testing)
- GitHub Copilot Pro (lead implementation, automated testing, documentation)