Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
93b1893
Add the `tabstack-keystate` evdev helper and its CI smoke test
FichteFoll Oct 14, 2026
5429ee3
Use the `tabstack-keystate` helper for Ctrl-release detection on Linux
FichteFoll Oct 14, 2026
94cd796
Guide Wayland users through setting up the `tabstack-keystate` helper
FichteFoll Oct 14, 2026
df471ee
Document the Wayland setup with the `tabstack-keystate` helper
FichteFoll Oct 14, 2026
2639bbf
Move the Linux Ctrl-release backends into a `linux` sub-package
FichteFoll Oct 14, 2026
b3e4b6f
Add a JSON schema for the TabStack settings
FichteFoll Oct 14, 2026
94e1130
Add a Command Palette entry to open the TabStack settings
FichteFoll Oct 14, 2026
d1e5d9d
Add TabStack entries to the Preferences > Package Settings menu
FichteFoll Oct 14, 2026
0208baf
Resolve relative imports in `main.py` for ty
FichteFoll Oct 14, 2026
b03e87b
Defer annotation evaluation and enforce `X | None`
FichteFoll Oct 14, 2026
a33cc2b
Update locked dev dependencies
FichteFoll Oct 14, 2026
5298d17
Show the helper setup instructions next to the extracted source
FichteFoll Oct 14, 2026
367d57a
Reuse an open setup instructions view
FichteFoll Oct 14, 2026
c0abb05
Skip input devices without a Ctrl key before opening them
FichteFoll Oct 14, 2026
91f7289
Keep a session pending while the Ctrl state is unknown
FichteFoll Oct 14, 2026
633cad5
Pin CI actions to their latest releases by SHA
FichteFoll Oct 14, 2026
3125034
Ignore Ctrl releases reported by a replaced poller
FichteFoll Oct 14, 2026
d5776f5
Re-probe the helper after it fails during a session
FichteFoll Oct 14, 2026
2b68deb
Let the helper count allowlisted modifiers as Ctrl
FichteFoll Oct 14, 2026
04f60b7
Add the `keystate_ctrl_keys` setting
FichteFoll Oct 14, 2026
f638011
Report a broken helper in the console
FichteFoll Oct 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
media/** export-ignore
uv.lock export-ignore
tests/** export-ignore
10 changes: 10 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
github-actions:
patterns:
- "*"
15 changes: 12 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,22 @@ jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.3
- uses: actions/setup-python@v5
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.8"
- uses: astral-sh/setup-uv@v8.2.0
- uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
- run: uv sync --group dev --no-install-project
- run: uv run ruff check .
- run: uv run basedpyright

keystate-helper:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: sudo modprobe uinput
- run: mkdir -p build
- run: cc -std=c11 -O2 -Wall -Wextra -Werror -o build/tabstack-keystate keystate/tabstack-keystate.c
- run: sudo python3 -I tests/keystate_smoke.py --helper build/tabstack-keystate --user "$USER"
16 changes: 14 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,17 @@
- `TabStackListener.on_close` prunes closed sheets and removes empty window state.
- Tab captions come from `plugin/captions.py`; widgets, panels, and transient/non-tab views are excluded from MRU.
- Ctrl-release detection lives in `plugin/ctrl_release/`.
- 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.
- Under Linux, Ctrl-release detection uses the `tabstack-keystate` helper (`plugin/ctrl_release/linux/keystate_helper.py`) when its probe succeeds.
`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.
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.
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.
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.
The helper reads evdev key state with `cap_dac_read_search`, is spawned once per session, and reports Ctrl state changes on stdout.
- 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.
- 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`.
- `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.
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.
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.
- `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.
- Wrap-around cycling is implemented by intentionally closing and reopening the quick panel at the opposite boundary.
That handoff keeps the session active and reuses the existing Ctrl-release poller.
Expand All @@ -31,4 +41,6 @@
- Use `uv run ruff check .` for linting.
- Use `uv run ruff format .` for formatting.
- Use `uv run basedpyright` for type checks.
- `pyproject.toml` only defines Ruff settings; there is no repo-local test runner.
- `pyproject.toml` only defines Ruff settings; there is no test runner for the plugin.
- The helper builds with `cc -std=c11 -O2 -Wall -Wextra -Werror -o <scratch>/tabstack-keystate keystate/tabstack-keystate.c`.
- `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).
14 changes: 14 additions & 0 deletions Default.sublime-commands
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[
{
"caption": "Preferences: TabStack Settings",
"command": "edit_settings",
"args": {
"base_file": "${packages}/TabStack/TabStack.sublime-settings",
"default": "{\n\t$0\n}\n",
},
},
{
"caption": "TabStack: Set Up Ctrl Detection for Wayland",
"command": "tab_stack_setup_keystate_helper"
},
]
34 changes: 34 additions & 0 deletions Main.sublime-menu
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
[
{
"caption": "Preferences",
"mnemonic": "n",
"id": "preferences",
"children": [
{
"caption": "Package Settings",
"mnemonic": "P",
"id": "package-settings",
"children": [
{
"caption": "TabStack",
"children": [
{
"caption": "Settings",
"command": "edit_settings",
"args": {
"base_file": "${packages}/TabStack/TabStack.sublime-settings",
"default": "{\n\t$0\n}\n",
},
},
{ "caption": "-" },
{
"caption": "Set Up Ctrl Detection for Wayland",
"command": "tab_stack_setup_keystate_helper"
},
],
},
],
},
],
},
]
110 changes: 107 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,121 @@ Press `ctrl+escape` to cancel.
## Platform Support

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

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

A native Wayland session exposes no way to poll global key state
(a deliberate security restriction in the protocol),
and Sublime Text does not provide a key-release API to plugins.
See also the related [upstream issue][].
As a workaround,
TabStack ships the source of a small helper program, `tabstack-keystate`,
that reads the Ctrl key state from the kernel's input devices.
Native Wayland needs this helper,
see [Wayland Setup](#wayland-setup).


## Wayland Setup

The helper is not installed automatically.
You review its source,
then build and install it yourself.
TabStack never runs `sudo`, a compiler or `setcap`.

Run `TabStack: Set Up Ctrl Detection for Wayland` from the Command Palette.
It extracts the helper source,
opens it for review,
and shows the commands below.
When the first `ctrl+tab` on native Wayland finds no working helper,
TabStack also offers to show this setup once per plugin load.

In the directory the command extracted the source to,
build, install and grant the capability:

```sh
cc -std=c11 -O2 -Wall -Wextra -o tabstack-keystate tabstack-keystate.c
sudo install -D -o root -g root -m 0755 tabstack-keystate /usr/local/libexec/tabstack-keystate
sudo setcap cap_dac_read_search=ep /usr/local/libexec/tabstack-keystate
```

Then run the setup command again to verify that the helper works.

To uninstall:

```sh
sudo rm /usr/local/libexec/tabstack-keystate
```

After a package update,
the setup command may report an outdated helper.
In that case, build and install it again with the commands above.

### Settings

In `TabStack.sublime-settings`,
which `Preferences: TabStack Settings` opens from the Command Palette:

- `keystate_helper_path`:
the path of the installed helper,
`/usr/local/libexec/tabstack-keystate` by default.
- `keystate_ctrl_keys`:
the keys the helper treats as Ctrl,
`["leftctrl", "rightctrl"]` by default.
The helper reads physical keys
and does not see the keyboard layout,
so set this if your layout remaps Ctrl,
for example `["leftctrl", "rightctrl", "capslock"]` for the XKB option `ctrl:nocaps`,
or `["capslock", "rightctrl"]` for `ctrl:swapcaps`.
Allowed are `leftctrl`, `rightctrl`, `capslock`,
`leftalt`, `rightalt`, `leftmeta`, `rightmeta` and `compose`.
- `show_wayland_setup_dialog`:
set to `false` to suppress the dialog
that offers the setup on native Wayland.
The dialog's "Don't Ask Again" button sets it for you.

### Security Notes

- Reading `/dev/input/event*` requires privileges.
The helper gets them through the file capability `cap_dac_read_search`,
which allows reading any file regardless of permissions.
- The helper sets `no_new_privs` at startup,
opens every `/dev/input/event*` device read-only
and keeps only those that advertise one of the watched keys.
It then drops all capabilities immediately,
before it reports any state.
Review the source to confirm this.
- Anyone who can execute the helper can run it,
so any local user can read the Ctrl state of your keyboards through it.
It reports nothing but whether one of the watched keys is held,
and it only accepts the modifier keys listed under `keystate_ctrl_keys`,
so it cannot be used to log typed characters.
- Installing it as `root:root` with mode `0755` in `/usr/local/libexec`
prevents the user's own processes from modifying it.
Replacing the binary drops the capability,
so grant it again after every rebuild.
- An alternative is to add your user to the `input` group
and skip the capability.
Note that this typically gives every process of your user
read-write access to all input event devices (keyboards and mice),
allowing it to read all keystrokes including passwords,
inject events and grab devices.

### Limitations

Flatpak and Snap builds of Sublime Text are not supported.
Their sandbox or `no_new_privs` setting
makes the kernel ignore file capabilities when running the helper.


## Caveats
Expand Down
15 changes: 15 additions & 0 deletions TabStack.sublime-settings
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
// Path to the `tabstack-keystate` helper that detects Ctrl releases on Linux.
// It is used whenever it works, and it is required under native Wayland.
"keystate_helper_path": "/usr/local/libexec/tabstack-keystate",

// The keys the helper treats as Ctrl, for keyboard layouts that remap Ctrl,
// e.g. `["leftctrl", "rightctrl", "capslock"]` for the XKB option `ctrl:nocaps`.
// Allowed: "leftctrl", "rightctrl", "capslock", "leftalt", "rightalt",
// "leftmeta", "rightmeta", "compose".
"keystate_ctrl_keys": ["leftctrl", "rightctrl"],

// Offer the helper's setup instructions on the first Ctrl+Tab per plugin load
// when Ctrl-release detection is unavailable under native Wayland.
"show_wayland_setup_dialog": true,
}
Loading
Loading