Skip to content

Commit 7091e9f

Browse files
authored
Merge pull request #12 from SublimeText/feature/wayland-keystate-helper
Add support for Wayland by polling `/dev/input`
2 parents 24cb0c9 + f638011 commit 7091e9f

32 files changed

Lines changed: 1671 additions & 98 deletions

‎.gitattributes‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
media/** export-ignore
22
uv.lock export-ignore
3+
tests/** export-ignore

‎.github/dependabot.yml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
version: 2
2+
updates:
3+
- package-ecosystem: github-actions
4+
directory: /
5+
schedule:
6+
interval: weekly
7+
groups:
8+
github-actions:
9+
patterns:
10+
- "*"

‎.github/workflows/ci.yml‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,22 @@ jobs:
88
check:
99
runs-on: ubuntu-latest
1010
steps:
11-
- uses: actions/checkout@v6.0.3
12-
- uses: actions/setup-python@v5
11+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
12+
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
1313
with:
1414
python-version: "3.8"
15-
- uses: astral-sh/setup-uv@v8.2.0
15+
- uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
1616
with:
1717
enable-cache: true
1818
- run: uv sync --group dev --no-install-project
1919
- run: uv run ruff check .
2020
- run: uv run basedpyright
21+
22+
keystate-helper:
23+
runs-on: ubuntu-latest
24+
steps:
25+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
26+
- run: sudo modprobe uinput
27+
- run: mkdir -p build
28+
- run: cc -std=c11 -O2 -Wall -Wextra -Werror -o build/tabstack-keystate keystate/tabstack-keystate.c
29+
- run: sudo python3 -I tests/keystate_smoke.py --helper build/tabstack-keystate --user "$USER"

‎AGENTS.md‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,17 @@
1515
- `TabStackListener.on_close` prunes closed sheets and removes empty window state.
1616
- Tab captions come from `plugin/captions.py`; widgets, panels, and transient/non-tab views are excluded from MRU.
1717
- Ctrl-release detection lives in `plugin/ctrl_release/`.
18-
- Uner Linux, Ctrl-release polling uses the X11 key state API, so Sublime must run under X11 or XWayland; a native Wayland session has no pollable global key state and Sublime exposes no key-release API.
18+
- Under Linux, Ctrl-release detection uses the `tabstack-keystate` helper (`plugin/ctrl_release/linux/keystate_helper.py`) when its probe succeeds.
19+
`plugin_loaded` probes it on the async thread, because running it can take up to 2 s; until the result arrives, Linux behaves as if the helper were missing, but without the setup dialog.
20+
The probe records the helper path and the `keystate_ctrl_keys` it ran with, the poller runs exactly that command, and a change of either setting triggers a new probe.
21+
The helper only accepts the modifier names in its `allowed_keys` table as arguments, so that it cannot serve as a keylogger; keep it that way.
22+
A helper that fails during a session (spawn error, bad handshake, `error` line, early EOF) still commits that session, but triggers a new probe unless a newer one has superseded it.
23+
The helper reads evdev key state with `cap_dac_read_search`, is spawned once per session, and reports Ctrl state changes on stdout.
24+
- Without a working helper, Linux falls back to the X11 key state API (`plugin/ctrl_release/linux/x11.py`), but not on native Wayland (`is_native_wayland()` in `plugin/ctrl_release/linux/__init__.py`), because XWayland reports Ctrl as released while a Wayland client has focus; Sublime exposes no key-release API.
25+
- On native Wayland without a working helper, the first `ctrl+tab` per plugin load shows a dialog offering the setup (`offer_wayland_setup` in `plugin/setup.py`), unless `show_wayland_setup_dialog` is `false`.
26+
- `tab_stack_setup_keystate_helper` (Command Palette: `TabStack: Set Up Ctrl Detection for Wayland`) re-probes, extracts `keystate/tabstack-keystate.c` to `sublime.cache_path()/TabStack/keystate/`, and shows the build, install and `setcap` commands.
27+
It reuses an open instructions view (marked by the `tab_stack_setup_instructions` view setting) and selects it together with the source, instructions on the left.
28+
The plugin never runs `sudo`, a compiler or `setcap`; the user installs the helper to `keystate_helper_path` (default `/usr/local/libexec/tabstack-keystate`) with `cap_dac_read_search`, which the helper drops right after opening the input devices.
1929
- `tab_stack_open` arms a pending session, starts a Ctrl-release poller, and opens the quick panel after a short delay unless Ctrl is released first.
2030
- Wrap-around cycling is implemented by intentionally closing and reopening the quick panel at the opposite boundary.
2131
That handoff keeps the session active and reuses the existing Ctrl-release poller.
@@ -31,4 +41,6 @@
3141
- Use `uv run ruff check .` for linting.
3242
- Use `uv run ruff format .` for formatting.
3343
- Use `uv run basedpyright` for type checks.
34-
- `pyproject.toml` only defines Ruff settings; there is no repo-local test runner.
44+
- `pyproject.toml` only defines Ruff settings; there is no test runner for the plugin.
45+
- The helper builds with `cc -std=c11 -O2 -Wall -Wextra -Werror -o <scratch>/tabstack-keystate keystate/tabstack-keystate.c`.
46+
- `tests/keystate_smoke.py` tests the helper against a virtual uinput keyboard; it needs root and `/dev/uinput` and runs in CI (`tests/` is export-ignored).

‎Default.sublime-commands‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[
2+
{
3+
"caption": "Preferences: TabStack Settings",
4+
"command": "edit_settings",
5+
"args": {
6+
"base_file": "${packages}/TabStack/TabStack.sublime-settings",
7+
"default": "{\n\t$0\n}\n",
8+
},
9+
},
10+
{
11+
"caption": "TabStack: Set Up Ctrl Detection for Wayland",
12+
"command": "tab_stack_setup_keystate_helper"
13+
},
14+
]

‎Main.sublime-menu‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
[
2+
{
3+
"caption": "Preferences",
4+
"mnemonic": "n",
5+
"id": "preferences",
6+
"children": [
7+
{
8+
"caption": "Package Settings",
9+
"mnemonic": "P",
10+
"id": "package-settings",
11+
"children": [
12+
{
13+
"caption": "TabStack",
14+
"children": [
15+
{
16+
"caption": "Settings",
17+
"command": "edit_settings",
18+
"args": {
19+
"base_file": "${packages}/TabStack/TabStack.sublime-settings",
20+
"default": "{\n\t$0\n}\n",
21+
},
22+
},
23+
{ "caption": "-" },
24+
{
25+
"caption": "Set Up Ctrl Detection for Wayland",
26+
"command": "tab_stack_setup_keystate_helper"
27+
},
28+
],
29+
},
30+
],
31+
},
32+
],
33+
},
34+
]

‎README.md‎

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,17 +42,121 @@ Press `ctrl+escape` to cancel.
4242
## Platform Support
4343

4444
TabStack supports **Linux, Windows, and macOS**.
45-
It polls the native key state APIs to detect modifier release events.
45+
It reads the native key state to detect modifier release events.
4646

47-
On Linux, detection uses the X11 key state API,
47+
On Linux, TabStack uses the `tabstack-keystate` helper
48+
whenever it is installed and works,
49+
see [Wayland Setup](#wayland-setup).
50+
Otherwise it falls back to the X11 key state API,
4851
so Sublime must run under X11 or XWayland
4952
(for example with `GDK_BACKEND=x11`).
50-
Note that this has not been reported to work yet.
53+
Note that X11 detection has not been reported to work yet.
54+
TabStack treats a session as native Wayland
55+
when `WAYLAND_DISPLAY` is set and `GDK_BACKEND` is not `x11`,
56+
so `GDK_BACKEND=x11` is also the way
57+
to use X11 detection without the helper on a Wayland desktop.
5158

5259
A native Wayland session exposes no way to poll global key state
5360
(a deliberate security restriction in the protocol),
5461
and Sublime Text does not provide a key-release API to plugins.
5562
See also the related [upstream issue][].
63+
As a workaround,
64+
TabStack ships the source of a small helper program, `tabstack-keystate`,
65+
that reads the Ctrl key state from the kernel's input devices.
66+
Native Wayland needs this helper,
67+
see [Wayland Setup](#wayland-setup).
68+
69+
70+
## Wayland Setup
71+
72+
The helper is not installed automatically.
73+
You review its source,
74+
then build and install it yourself.
75+
TabStack never runs `sudo`, a compiler or `setcap`.
76+
77+
Run `TabStack: Set Up Ctrl Detection for Wayland` from the Command Palette.
78+
It extracts the helper source,
79+
opens it for review,
80+
and shows the commands below.
81+
When the first `ctrl+tab` on native Wayland finds no working helper,
82+
TabStack also offers to show this setup once per plugin load.
83+
84+
In the directory the command extracted the source to,
85+
build, install and grant the capability:
86+
87+
```sh
88+
cc -std=c11 -O2 -Wall -Wextra -o tabstack-keystate tabstack-keystate.c
89+
sudo install -D -o root -g root -m 0755 tabstack-keystate /usr/local/libexec/tabstack-keystate
90+
sudo setcap cap_dac_read_search=ep /usr/local/libexec/tabstack-keystate
91+
```
92+
93+
Then run the setup command again to verify that the helper works.
94+
95+
To uninstall:
96+
97+
```sh
98+
sudo rm /usr/local/libexec/tabstack-keystate
99+
```
100+
101+
After a package update,
102+
the setup command may report an outdated helper.
103+
In that case, build and install it again with the commands above.
104+
105+
### Settings
106+
107+
In `TabStack.sublime-settings`,
108+
which `Preferences: TabStack Settings` opens from the Command Palette:
109+
110+
- `keystate_helper_path`:
111+
the path of the installed helper,
112+
`/usr/local/libexec/tabstack-keystate` by default.
113+
- `keystate_ctrl_keys`:
114+
the keys the helper treats as Ctrl,
115+
`["leftctrl", "rightctrl"]` by default.
116+
The helper reads physical keys
117+
and does not see the keyboard layout,
118+
so set this if your layout remaps Ctrl,
119+
for example `["leftctrl", "rightctrl", "capslock"]` for the XKB option `ctrl:nocaps`,
120+
or `["capslock", "rightctrl"]` for `ctrl:swapcaps`.
121+
Allowed are `leftctrl`, `rightctrl`, `capslock`,
122+
`leftalt`, `rightalt`, `leftmeta`, `rightmeta` and `compose`.
123+
- `show_wayland_setup_dialog`:
124+
set to `false` to suppress the dialog
125+
that offers the setup on native Wayland.
126+
The dialog's "Don't Ask Again" button sets it for you.
127+
128+
### Security Notes
129+
130+
- Reading `/dev/input/event*` requires privileges.
131+
The helper gets them through the file capability `cap_dac_read_search`,
132+
which allows reading any file regardless of permissions.
133+
- The helper sets `no_new_privs` at startup,
134+
opens every `/dev/input/event*` device read-only
135+
and keeps only those that advertise one of the watched keys.
136+
It then drops all capabilities immediately,
137+
before it reports any state.
138+
Review the source to confirm this.
139+
- Anyone who can execute the helper can run it,
140+
so any local user can read the Ctrl state of your keyboards through it.
141+
It reports nothing but whether one of the watched keys is held,
142+
and it only accepts the modifier keys listed under `keystate_ctrl_keys`,
143+
so it cannot be used to log typed characters.
144+
- Installing it as `root:root` with mode `0755` in `/usr/local/libexec`
145+
prevents the user's own processes from modifying it.
146+
Replacing the binary drops the capability,
147+
so grant it again after every rebuild.
148+
- An alternative is to add your user to the `input` group
149+
and skip the capability.
150+
Note that this typically gives every process of your user
151+
read-write access to all input event devices (keyboards and mice),
152+
allowing it to read all keystrokes including passwords,
153+
inject events and grab devices.
154+
155+
### Limitations
156+
157+
Flatpak and Snap builds of Sublime Text are not supported.
158+
Their sandbox or `no_new_privs` setting
159+
makes the kernel ignore file capabilities when running the helper.
56160

57161

58162
## Caveats

‎TabStack.sublime-settings‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
// Path to the `tabstack-keystate` helper that detects Ctrl releases on Linux.
3+
// It is used whenever it works, and it is required under native Wayland.
4+
"keystate_helper_path": "/usr/local/libexec/tabstack-keystate",
5+
6+
// The keys the helper treats as Ctrl, for keyboard layouts that remap Ctrl,
7+
// e.g. `["leftctrl", "rightctrl", "capslock"]` for the XKB option `ctrl:nocaps`.
8+
// Allowed: "leftctrl", "rightctrl", "capslock", "leftalt", "rightalt",
9+
// "leftmeta", "rightmeta", "compose".
10+
"keystate_ctrl_keys": ["leftctrl", "rightctrl"],
11+
12+
// Offer the helper's setup instructions on the first Ctrl+Tab per plugin load
13+
// when Ctrl-release detection is unavailable under native Wayland.
14+
"show_wayland_setup_dialog": true,
15+
}

0 commit comments

Comments
 (0)