Sno Station
Sno Station은 AI 에이전트를 하나의 팀으로 묶는 오픈소스 워크스테이션으로, 로컬 머신에서 에이전트 협업을 가능하게 함
스크립트·규칙·키보드로 GNOME Wayland 창을 나열·이동·타일링하는 GNOME Shell 확장 기능이며 D‑Bus와 wctl CLI 제공
List, move and tile windows on GNOME Wayland from scripts, rules or the keyboard. A GNOME Shell extension with a D-Bus interface and the wctl CLI.
플러그인Rust · ShellGitHub @carlo9890온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
메이커가 쓴 소개는 위의 한 줄이 전부입니다. 더 자세한 내용은 저장소 README에서 읽을 수 있습니다. 저장소 README ↗
README 전체를 불러오는 중입니다.
README 전문 보기 ↗List, move and tile windows on GNOME Shell from a script, a rules file, or the
keyboard. It works on Wayland, where wmctrl and xdotool reach only
XWayland windows.
One GNOME Shell extension, three ways to use it:
wctl, a single static binary, or any D-Bus client such
as gdbus. See Usage.rules.json place a matching window the
moment it opens. See Automatic window placement.Super+Ctrl plus a numpad key tiles the focused
window. See Keyboard shortcuts.wctl list # every window, with ID, class and title
wctl tile -c kitty left # tile the kitty window to the left column
wctl place focused right top 50% 100% # right half of the workarea
wctl minimize focused # minimize the focused window
focused, WM class, title, title substring, or PID in every wctl commandDownload the latest release from the GitHub Releases page
Install the downloaded zip file:
gnome-extensions install [email protected]_v*.zip --force
Restart GNOME Shell:
Alt+F2, type r, and press EnterEnable the extension:
gnome-extensions enable [email protected]
Install it from the Window Control page on extensions.gnome.org, with the browser integration or the Extension Manager app.
Every upload there waits for a manual review, so that page can be several
versions behind the GitHub release, and an older version has no rules and no
keyboard shortcuts. Run wctl version --json and read capabilities: rules
and keybindings are listed when the loaded extension has them. The same
command reports "compatible": false for an install from that page - see
Checking the extension version.
The extension UUID changed to [email protected]. GNOME treats
the new UUID as a separate extension, so the old one keeps running until you
remove it. Both register the same D-Bus object, so do not leave both enabled.
gnome-extensions disable window-control@hko9890
gnome-extensions uninstall window-control@hko9890
gnome-extensions install [email protected]_v*.zip --force
# restart GNOME Shell (Alt+F2 r on X11, log out and back in on Wayland)
gnome-extensions enable [email protected]
Building and installing from source is a contributor task — see CONTRIBUTING.md and docs/CODING.md.
wctl is a statically linked binary. It needs nothing at runtime — no shell,
no jq, no gdbus.
Use the install script, which puts wctl in ~/.local/bin:
curl -fsSL https://github.com/carlo9890/gnome-window-control/releases/latest/download/install-wctl.sh | bash
Piped from curl, the script downloads the published binary. Run from a
checkout, it installs a local build when one exists and downloads otherwise;
--download always downloads, --local always builds. --help shows the
options.
The script checks the download against the release's wctl.sha256 and runs it
once before it replaces an installed wctl, so a failed download keeps the old
one. Set WCTL_VERSION to a release tag (vN) to install the wctl of that
release. The installer attached to a release defaults to that release.
Releases up to v16 have no wctl.sha256, so the script cannot install them.
Or download wctl and wctl.sha256 from the releases page
and put it on your PATH:
sha256sum -c wctl.sha256
chmod +x wctl
mv wctl ~/.local/bin/
# or
sudo mv wctl /usr/local/bin/
The published binary is x86_64 only; on another architecture build it from a checkout — see CONTRIBUTING.md.
wctl list # every window; --json for the full document
# (has_been_shown: false until mutter has mapped it)
wctl focused # the focused window
wctl info 12345 --json # one window's details
# Every command that takes a window accepts a selector instead of an ID:
# <ID> | focused | -c <CLASS> | -t <TITLE> | -s <SUBSTR> | -p <PID>
# A selector must match exactly one window. With several matches wctl lists
# the candidates and exits 1; with none it exits 2. An empty -s "" is refused.
wctl tile -c kitty left
wctl close -s "Untitled"
# Place with workarea-relative tokens: X/Y take pixels or left|center|right /
# top|center|bottom; width/height take pixels or a percentage of the workarea
# (the monitor minus panels and docks).
wctl place 12345 center top 50% 100%
# Wait for a window to be shown, then place it
kitty &
wctl tile "$(wctl wait -p $! --timeout 5)" right
# Hide and show a window with no minimize animation, e.g. a popup
wctl minimize -c my-popup --no-animation
wctl unminimize -c my-popup --no-animation
# Show and focus a minimized window in one call, with no animation
wctl activate 12345 --no-animation
wctl --help # every command, global options, and --json/--settled
wctl activate keeps the extension's first-match rule for -t/-s/-c/-p
(useful for run-or-raise scripts). Every other command requires the selector to
be unambiguous.
A failing wctl classifies itself, so a script can tell the cases apart without
matching the message text:
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Usage error, or a failure with no more specific code below |
| 2 | The window, workspace or monitor does not exist |
| 3 | The shell refused: the frame is pinned by maximize, fullscreen or tiling, the window is held on all workspaces, or it cannot be minimized |
| 4 | Timed out waiting for a window, or for the shell to reply |
| 5 | The extension is not usable: not running, or a version this wctl cannot rely on |
Code 1 stays the catch-all it always was, so a script that only tests for a non-zero status is unaffected.
resolve-place answers what a placement would be, without a window and without
moving anything — so a program can be launched at its final size instead of being
moved after it appears:
read -r w h < <(wctl resolve-place center top 50% 100% --json |
jq -r '.target | "\(.width) \(.height)"')
place, tile and center take --json too, reporting the workarea used and
the rectangle computed. All of them report the rectangle wctl requested;
mutter still clamps to size hints, and a client that quantises its own size (a
terminal, to whole cells) settles a few pixels off, so comparing against it needs
a tolerance.
A geometry request is applied asynchronously, and a client may resize itself once
more after being placed, so the frame can still be moving when wctl exits.
--settled returns only once it has stopped. The window is placed either way: a
frame that never settles exits 4 and still reports "placed":true.
wctl version # just this binary, no D-Bus call
wctl version --json
# {"wctl":"0.17.0","expects_extension":"17","min_extension":"16","extension":"17","compatible":true,"capabilities":["rules","rules-title-affix","rules-class-prefix","keybindings","no-animation","activate-no-animation"]}
--json asks the running shell what it has loaded. That is the useful
question: on Wayland an install lands on disk while the shell keeps serving the
old code until you log out, so metadata.json can say 10 while the answer here
is still 9. compatible is true for every extension version from
min_extension to expects_extension; outside that range the command exits 5.
capabilities is what the extension says it supports, by name. Ask for a name
rather than comparing version numbers: an install from extensions.gnome.org
carries that site's own upload number in extension, which no release of this
project ever had. An empty list means the extension is too old to answer.
# List all windows
gdbus call --session \
--dest org.gnome.Shell \
--object-path /org/gnome/Shell/Extensions/WindowControl \
--method org.gnome.Shell.Extensions.WindowControl.List
# Get detailed JSON
gdbus call --session \
--dest org.gnome.Shell \
--object-path /org/gnome/Shell/Extensions/WindowControl \
--method org.gnome.Shell.Extensions.WindowControl.ListDetailed
# Activate by WM class
gdbus call --session \
--dest org.gnome.Shell \
--object-path /org/gnome/Shell/Extensions/WindowControl \
--method org.gnome.Shell.Extensions.WindowControl.ActivateByWmClass \
"kitty"
The extension can place a window the moment it is shown, so it opens where you
want it without a wctl call. Rules live in
~/.config/gnome-window-control/rules.json, a JSON array read at enable and
re-read whenever the file changes (no restart needed). The file is optional; with
no file, nothing happens.
Each rule names the windows it matches and one placement, in the same vocabulary
as the wctl commands:
[
{ "match": { "class": "kitty" }, "tile": "left" },
{ "match": { "substr": "Report" }, "place": ["right", "top", "50%", "100%"] },
{ "match": { "title": "Calculator" }, "center": "both", "monitor": 1 },
{ "match": { "class": "Slack" }, "workspace": 2 }
]
class (exact WM class), title (exact title),
substr (title contains), title_prefix (title starts with),
title_suffix (title ends with), class_prefix (WM class starts with, for
apps that put a per-window part in the class, such as a Chrome app window's
chrome-github.com__<owner>_<repo>-Default). Several keys must all match.[X, Y, WIDTH, HEIGHT], the tokens of wctl place: X is a number
or left|center|right, Y a number or top|center|bottom, WIDTH/HEIGHT a
positive number or a percentage like 50%.wctl tile position (left, top-right, center, ...).horizontal, vertical, or both; keeps the window's own size.place/tile/center
resolve against, and where the window is placed. When that monitor does not
exist, for example on an undocked laptop, the window is not moved or placed;
only workspace applies.place, tile and center are mutually exclusive in one rule; combine any of
them with workspace and monitor. The first matching rule wins, in file order,
and applies once per window.
On GNOME 49 and later a place or tile rule takes effect before the window
is drawn, so it opens in place. On GNOME 45 to 48, and for center,
workspace, an X11 application, or a window whose class or title arrives late,
the window appears first and moves a moment later.
This sets the initial position only: an app that resizes itself afterwards is left alone. A rule with a bad value is reported to the journal and the whole file is ignored until you fix it, so one typo never places a window half-right.
wctl writes and reads the file, so you need not edit JSON by hand:
wctl rules add -c kitty tile left # Tile every kitty window left
wctl rules add -s Report place right top 50% 100%
wctl rules add -t Calculator center both --monitor 1 --workspace 2
wctl rules add -c Slack --workspace 2 # Only move it to a workspace
wctl rules list # What is in the file
wctl rules remove 0 # Drop a rule by index
wctl rules path # Where the file lives
$ wctl rules list
INDEX MATCH ACTION WORKSPACE MONITOR
0 class=kitty tile left
1 substr=Report place right top 50% 100%
2 title=Calculator center both 2 1
A rule matches with -c <CLASS>, -t <TITLE>, -s <SUBSTR>,
--title-prefix <TEXT>, --title-suffix <TEXT> or --class-prefix <TEXT>
only. A window ID, focused
and -p <PID> name a window that already exists, and a rule is evaluated
against windows that do not exist yet, so those are refused.
add validates the action through the same grammar wctl tile and wctl place
use, appends unless --at <N> names a position, and writes atomically. A file
that does not parse or does not validate is never rewritten, so a hand-edited
file with a typo in it is not clobbered. --dry-run prints the document it
would write and changes nothing. Because the first matching rule wins, add
warns when an earlier rule already matches everything the new one would.
If rules.json is a symbolic link, for example into a dotfiles repository,
add and remove write the linked file and keep the link, and the extension
loads the change. The extension watches the directory of rules.json, not the
linked file, so an edit you make to the linked file is not seen. Run
touch -h ~/.config/gnome-window-control/rules.json after such an edit, or
disable and enable the extension, to load it.
Rules are applied only by an extension that reports the rules capability
(wctl version --json lists it). When the running shell has an older one
loaded, add and remove still write the file but warn that no rule will be
applied until you install the newer extension and restart the shell. With no
shell running, and with --file, they say nothing.
title_prefix and title_suffix need an extension that also reports
rules-title-affix. An older one refuses a file that has either key and then
applies no rule at all; add and remove warn about that too. class_prefix
needs rules-class-prefix in the same way.
Because one bad value disables every rule, check the file rather than saving it and watching whether windows move:
wctl rules check # the configured file
wctl rules check --file ./draft.json # any file
wctl rules check --json # for a script
$ wctl rules check
/home/you/.config/gnome-window-control/rules.json: 3 rules, valid
$ wctl rules check
Error: rules[1].tile: must be one of top-left, top-center, top-right, left, center, right, bottom-left, bottom-center, bottom-right, wide-left, wide-right
It exits 0 when the shell would load the file and 1 when it would not, and the message is the same one the extension writes to the journal. It reads only the file: no D-Bus, and it works with the extension stopped or not installed.
wctl rules test -c kitty
$ wctl rules test -c kitty
Window 4152763 class=kitty title=vim
Matched rule 0: class=kitty -> tile left
rule 2 also matches but is shadowed: class=kitty -> tile right
monitor 0, workarea 0,27 1920x1053
would place at 0,27 480x1052
It names the rule that wins, any later rule the first-match-wins order makes
dead, the workarea used, and the exact rectangle the action resolves to. When
the rule names a monitor that does not exist, it says the extension skips the
rest of the rule and exits 0; with --json, workarea and target are null
and "skipped" is "no such monitor". This is
the one rules subcommand that needs the extension running, because it resolves
a live window. It never moves anything. Against an extension that does not
report the rules capability it exits 5 instead, because that extension
ignores the file. It also exits 5 when the file uses title_prefix,
title_suffix or class_prefix and the extension does not report
rules-title-affix or rules-class-prefix for it, because that extension
refuses the whole file.
The complete format, the token grammar, the tile grid and every validation rule are specified in docs/specs/RULES-JSON.md.
The extension tiles the focused window from the keyboard, to the same 4x2 grid
wctl tile and a rule use. The numpad mirrors the grid; the defaults are
Super+Ctrl plus the key, and they work with Num Lock on or off:
| Key | Position | Key | Position | Key | Position | ||
|---|---|---|---|---|---|---|---|
7 | top-left | 8 | top-center | 9 | top-right | ||
4 | left | 5 | center | 6 | right | ||
1 | bottom-left | 2 | bottom-center | 3 | bottom-right |
Super+Ctrl+KP_Add (the numpad +) tiles the window wide-right, three
columns of four; press it again on the same window and it goes wide-left.
A maximized window is unmaximized first; a fullscreen one is left alone.
Every shortcut is a GSettings key, so you change one without editing anything:
S=org.gnome.shell.extensions.window-control
D=~/.local/share/gnome-shell/extensions/[email protected]/schemas
gsettings --schemadir $D list-recursively $S # what is bound
gsettings --schemadir $D set $S tile-left "['<Super>KP_Left']" # rebind one
gsettings --schemadir $D set $S cycle-wide "[]" # unbind one
gsettings --schemadir $D reset-recursively $S # back to the defaults
The keys are tile-<position> for the nine positions and cycle-wide. Each
holds a list of accelerators in GTK's <Super><Control>KP_Home notation, so one
key can carry several combinations. Pick a chord nothing else binds: when two
bindings share one, mutter gives it to either at random, and the shortcut then
works on one machine and not on the next. Super+Shift on the numpad is such
a case, since GNOME 50 uses Super+Shift+KP_Page_Up/KP_Page_Down to move a
window between workspaces. The numpad names are the Num-Lock-off ones
(KP_Home, not KP_7); mutter binds the physical key, so they fire in either
state. --schemadir is needed because the schema ships with the extension,
not with the system.
wctl version --json lists keybindings under capabilities when the loaded
extension serves the shortcuts.
The extension exports its object on GNOME Shell's own bus connection, so the D-Bus
destination is org.gnome.Shell (not a standalone service name).
Bus name (dest): org.gnome.Shell
Path: /org/gnome/Shell/Extensions/WindowControl
Interface: org.gnome.Shell.Extensions.WindowControl
| Method | Signature | Description |
|---|---|---|
List | () -> a(tssssbiiii) | List all windows |
ListDetailed | () -> s | List windows as JSON with full details |
ListMonitors | () -> s | List monitors as JSON |
Activate | (t) -> b | Activate window by ID |
ActivateByTitle | (s) -> b | Activate by exact title match |
ActivateByTitleSubstring | (s) -> b | Activate by title substring |
ActivateByWmClass | (s) -> b | Activate by WM_CLASS |
ActivateByPid | (i) -> b | Activate by process ID |
Focus | (t) -> b | Focus window (without raising) |
Close | (t) -> b | Close window (polite) |
GetFocused | () -> (tss) | Get focused window (id, title, class) |
Move | (tii) -> () | Move window to (x, y). Raises on failure — see Errors below |
Resize | (tii) -> () | Resize window to (width, height). Raises on failure |
MoveResize | (tiiii) -> () | Move and resize window. Raises on failure |
GetGeometry | (t) -> (iiii) | Get window geometry |
GetWorkarea | (i) -> (iiii) | Get a monitor's usable work area; (-1, -1, -1, -1) for an index that is not a monitor, and only then |
GetVersion | () -> s | The extension version the running shell has (not what is on disk) |
Move, Resize and MoveResize return nothing and raise instead;
WaitForWindow and WaitForGeometry raise too. The ERRORS block in
[email protected]/dbus-interface.js names the extension's own
errors; org.freedesktop.DBus.Error.* below are the standard names a handler
raises without declaring them there, and org.gnome.gjs.JSError.* is the name
GJS raises when any other method throws. Before GNOME 50, a wait method that
throws before it starts waiting sends no reply, and the caller's D-Bus timeout
ends the call.
| Error name | Meaning |
|---|---|
org.gnome.Shell.Extensions.WindowControl.NotFound | No window has that ID (also WaitForGeometry, if the window closes while waiting) |
org.gnome.Shell.Extensions.WindowControl.Refused | The frame is pinned by fullscreen, maximize or tiling — the message names which |
org.gnome.Shell.Extensions.WindowControl.Timeout | WaitForGeometry gave up: the frame never settled |
org.gnome.Shell.Extensions.WindowControl.Disabled | The extension was disabled while a deferred call was pending |
org.freedesktop.DBus.Error.InvalidArgs | A size or a wait time was not positive, timeout_ms was below quiet_ms, or a wait selector was refused (unknown kind, empty substring, pid that is not a positive decimal integer) |
org.freedesktop.DBus.Error.Failed | Move, Resize or MoveResize raised something unexpected |
org.gnome.gjs.JSError.* | Any other method threw a JavaScript Error unexpectedly; GJS names the D-Bus error after it (org.gnome.gjs.JSError.TypeError) |
Breaking change in extension version 10. Before it, those three methods
returned b success. A client written against the old signature cannot parse
the new reply, so extension and wctl must be upgraded together — which is what
wctl version --json checks.
Read this before you enable the extension.
Any application in your session can call this interface. The object is exported on GNOME Shell's own bus name, and the session bus applies no access control between processes of the same user. Any program you run — a script, a package's helper daemon, anything started by your desktop — can enumerate your window titles and move, resize or close your windows.
There is no way to fix this from inside the extension. Same-user processes have no trust boundary on the session bus: an allowlist keyed on the caller's PID is useless here (the caller is whatever program invoked the client) and defeated by the confused deputy, and a shared secret in a file is readable by anything that can read your files. Any mechanism claiming otherwise would be theater. So the extension does not pretend to have one — it states plainly what it exposes, and leaves the decision to you.
What this means in practice:
List, ListDetailed and GetFocused return them, and
ActivateByTitleSubstring and WaitForWindow leak them by probing.console.debug(), gated behind G_MESSAGES_DEBUG, and no log line contains a
title or a caller-supplied match string at any level. See
docs/MONITORING.md.metadata.json sets no
session-modes, so it defaults to user, and GNOME unloads the extension on
the lock screen. The interface cannot be queried until you unlock.disable() unexports the
object; there are no signal handlers or timers left behind.org.gnome.Shell by default. This is a
property of their sandbox, not of this extension, and it does not apply to an
application granted full session-bus access.If that trade is not acceptable to you, do not enable the extension. On X11 the same capability was available to every application with no gate at all; on Wayland it is off until you turn it on, and this is the switch.
wctl --version 0.N.0 ↔ release vN): docs/RELEASING.mdMIT License - see LICENSE file.
GetCapabilities | () -> as | The feature names this extension supports, e.g. ['rules'] |
Minimize | (t) -> b | Minimize window; false also for a window the shell does not let minimize |
Unminimize | (t) -> b | Restore minimized window |
MinimizeNoAnimation | (t) -> b | Minimize window without the shell's minimize animation; false as Minimize reports it |
UnminimizeNoAnimation | (t) -> b | Restore minimized window without the animation |
ActivateNoAnimation | (t) -> b | Activate window by ID, restoring a minimized one without the animation |
Maximize | (t) -> b | Maximize window |
Unmaximize | (t) -> b | Restore maximized window |
Fullscreen | (t) -> b | Make window fullscreen |
Unfullscreen | (t) -> b | Exit fullscreen |
SetAbove | (tb) -> b | Set/unset always-on-top |
SetSticky | (tb) -> b | Set/unset sticky (all workspaces); false if the state did not take |
ListWorkspaces | () -> s | List workspaces as JSON (index, name, is_active, window_count) |
ActivateWorkspace | (i) -> b | Switch to a workspace. Hides the Activities overview first (the switch is ignored while it is shown) and returns whether the switch took effect |
MoveToWorkspace | (ti) -> b | Move window to a workspace |
MoveToMonitor | (ti) -> b | Move window to a monitor |
WaitForWindow | (ssi) -> t | Wait until a window matching kind (class, title, substring, pid) and value is shown (mapped and placed), up to timeout_ms; returns its ID, or 0 on timeout. The reply is deferred, the shell is never blocked, and the call is dropped if its caller leaves the bus. |
WaitForGeometry | (tii) -> (iiii) | Wait until the window's frame has held still for quiet_ms, up to timeout_ms, then return it. The reply is deferred, the shell is never blocked, and the call is dropped if its caller leaves the bus. |