diff --git a/.gitattributes b/.gitattributes index 3ed0f36..bba8b5d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ media/** export-ignore uv.lock export-ignore +tests/** export-ignore diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6c5049e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce5794b..4418576 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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" diff --git a/AGENTS.md b/AGENTS.md index 3c73538..041900f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. @@ -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 /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). diff --git a/Default.sublime-commands b/Default.sublime-commands new file mode 100644 index 0000000..2f5a365 --- /dev/null +++ b/Default.sublime-commands @@ -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" + }, +] diff --git a/Main.sublime-menu b/Main.sublime-menu new file mode 100644 index 0000000..f0b9399 --- /dev/null +++ b/Main.sublime-menu @@ -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" + }, + ], + }, + ], + }, + ], + }, +] diff --git a/README.md b/README.md index d25e3af..df6d871 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/TabStack.sublime-settings b/TabStack.sublime-settings new file mode 100644 index 0000000..a60582e --- /dev/null +++ b/TabStack.sublime-settings @@ -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, +} diff --git a/keystate/tabstack-keystate.c b/keystate/tabstack-keystate.c new file mode 100644 index 0000000..a23680a --- /dev/null +++ b/keystate/tabstack-keystate.c @@ -0,0 +1,292 @@ +/* + * tabstack-keystate - reports whether a Ctrl key is held, for the TabStack + * Sublime Text package. + * + * A native Wayland client cannot query the global keyboard state, so TabStack + * cannot tell when Ctrl is released after Ctrl+Tab. This helper reads the key + * state from the kernel's evdev devices (/dev/input/event*) instead. + * + * Those devices are readable only by root and the `input` group. Installed with + * `cap_dac_read_search=ep`, the helper can open them read-only without either. + * It sets no_new_privs first and drops all capabilities right after opening + * the devices, before it reads any input or reports anything that depends on + * them. + * + * Usage: tabstack-keystate [key...] + * The keys that count as Ctrl, by default "leftctrl rightctrl". Only the + * names in `allowed_keys` are accepted, the keys that XKB `ctrl:*` options + * can turn into Ctrl. Since any local user can run the helper with its + * capability, reporting arbitrary keys would make it a keylogger. + * + * Protocol, version 2: ASCII lines on stdout, each flushed. + * "tabstack-keystate 2" first, then either "error " and + * exit status 1, or the state ("1" while a watched key is held, else "0") + * followed by one line per change. Exits 0 when stdin reaches EOF. + * + * Build: cc -std=c11 -O2 -Wall -Wextra -o tabstack-keystate tabstack-keystate.c + */ +#define _DEFAULT_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define MAX_DEVICES 64 +#define BITS_PER_LONG (8 * sizeof(unsigned long)) +#define KEY_LONGS ((KEY_CNT + BITS_PER_LONG - 1) / BITS_PER_LONG) + +struct named_key { + const char *name; + unsigned int code; +}; + +static const struct named_key allowed_keys[] = { + {"leftctrl", KEY_LEFTCTRL}, + {"rightctrl", KEY_RIGHTCTRL}, + {"capslock", KEY_CAPSLOCK}, + {"leftalt", KEY_LEFTALT}, + {"rightalt", KEY_RIGHTALT}, + {"leftmeta", KEY_LEFTMETA}, + {"rightmeta", KEY_RIGHTMETA}, + {"compose", KEY_COMPOSE}, +}; +#define ALLOWED_KEY_COUNT (sizeof allowed_keys / sizeof allowed_keys[0]) + +static unsigned int watched_keys[ALLOWED_KEY_COUNT]; +static size_t watched_key_count; +static int devices[MAX_DEVICES]; +static int device_count; + +static void emit(const char *line) { + if (puts(line) == EOF || fflush(stdout) == EOF) { + exit(errno == EPIPE ? 0 : 1); + } +} + +static void fail(const char *code, const char *message) { + char line[256]; + snprintf(line, sizeof line, "error %s %s", code, message); + emit(line); + exit(1); +} + +/* Adds the allowed key `name` to the watched keys, or fails. */ +static void watch_key(const char *name) { + for (size_t i = 0; i < ALLOWED_KEY_COUNT; i++) { + if (strcmp(name, allowed_keys[i].name) != 0) { + continue; + } + for (size_t j = 0; j < watched_key_count; j++) { + if (watched_keys[j] == allowed_keys[i].code) { + fail("usage", "duplicate key"); + } + } + watched_keys[watched_key_count++] = allowed_keys[i].code; + return; + } + char message[128]; + snprintf(message, sizeof message, "key \"%.32s\" is not allowed", name); + fail("usage", message); +} + +static bool has_watched_key(const unsigned long *keys) { + for (size_t i = 0; i < watched_key_count; i++) { + unsigned int code = watched_keys[i]; + if ((keys[code / BITS_PER_LONG] >> (code % BITS_PER_LONG)) & 1UL) { + return true; + } + } + return false; +} + +/* + * Reads the key capabilities that sysfs exposes for `name`, so that devices + * without a watched key need not be opened: closing an evdev device waits for an + * RCU grace period, which adds up to hundreds of milliseconds before the + * initial state could be reported. The file holds hex longs, most significant + * first, with leading zero longs omitted. Returns false if it cannot be read. + */ +static bool read_sysfs_keys(const char *name, unsigned long *keys) { + char path[128]; + int length = snprintf(path, sizeof path, "/sys/class/input/%s/device/capabilities/key", name); + if (length < 0 || (size_t)length >= sizeof path) { + return false; + } + FILE *file = fopen(path, "re"); + if (file == NULL) { + return false; + } + char line[KEY_LONGS * 17 + 2]; + bool ok = fgets(line, sizeof line, file) != NULL; + fclose(file); + if (!ok) { + return false; + } + + unsigned long words[KEY_LONGS]; + size_t count = 0; + for (char *token = strtok(line, " \n"); token != NULL; token = strtok(NULL, " \n")) { + if (count == KEY_LONGS) { + return false; + } + char *end; + errno = 0; + words[count++] = strtoul(token, &end, 16); + if (errno != 0 || *end != '\0') { + return false; + } + } + for (size_t i = 0; i < count; i++) { + keys[i] = words[count - 1 - i]; + } + return true; +} + +/* Opens every device with a watched key. Returns whether any open was denied. */ +static bool open_keyboards(void) { + DIR *dir = opendir("/dev/input"); + if (dir == NULL) { + return errno == EACCES || errno == EPERM; + } + bool denied = false; + struct dirent *entry; + while (device_count < MAX_DEVICES && (entry = readdir(dir)) != NULL) { + if (strncmp(entry->d_name, "event", 5) != 0) { + continue; + } + unsigned long sysfs_keys[KEY_LONGS] = {0}; + if (read_sysfs_keys(entry->d_name, sysfs_keys) && !has_watched_key(sysfs_keys)) { + continue; + } + int fd = openat(dirfd(dir), entry->d_name, O_RDONLY | O_NONBLOCK | O_CLOEXEC); + if (fd < 0) { + denied = denied || errno == EACCES || errno == EPERM; + continue; + } + unsigned long keys[KEY_LONGS] = {0}; + if (ioctl(fd, EVIOCGBIT(EV_KEY, sizeof keys), keys) < 0 || !has_watched_key(keys)) { + close(fd); + continue; + } + devices[device_count++] = fd; + } + closedir(dir); + return denied; +} + +/* Raw capset(2) instead of libcap, so that building needs no extra library. */ +static int drop_capabilities(void) { + struct __user_cap_header_struct header = {.version = _LINUX_CAPABILITY_VERSION_3, .pid = 0}; + struct __user_cap_data_struct data[_LINUX_CAPABILITY_U32S_3]; + memset(data, 0, sizeof data); + return (int)syscall(SYS_capset, &header, data); +} + +static int ctrl_state(void) { + for (int i = 0; i < device_count; i++) { + unsigned long keys[KEY_LONGS] = {0}; + if (ioctl(devices[i], EVIOCGKEY(sizeof keys), keys) >= 0 && has_watched_key(keys)) { + return 1; + } + } + return 0; +} + +/* Discards pending events; only EVIOCGKEY matters. Returns false once the device is gone. */ +static bool drain(int fd) { + struct input_event events[64]; + for (;;) { + ssize_t n = read(fd, events, sizeof events); + if (n > 0 || (n < 0 && errno == EINTR)) { + continue; + } + return n < 0 && errno == EAGAIN; + } +} + +/* Returns false on stdin EOF, hangup or error. */ +static bool stdin_open(short revents) { + if (!(revents & (POLLIN | POLLHUP | POLLERR | POLLNVAL))) { + return true; + } + char buffer[256]; + ssize_t n = read(STDIN_FILENO, buffer, sizeof buffer); + return n > 0 || (n < 0 && (errno == EINTR || errno == EAGAIN)); +} + +int main(int argc, char **argv) { + signal(SIGPIPE, SIG_IGN); + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) { + perror("tabstack-keystate: prctl"); + return 1; + } + emit("tabstack-keystate 2"); + + if (argc < 2) { + watch_key("leftctrl"); + watch_key("rightctrl"); + } + for (int i = 1; i < argc; i++) { + watch_key(argv[i]); + } + + bool denied = open_keyboards(); + if (drop_capabilities() != 0) { + fail("capset", strerror(errno)); + } + if (device_count == 0) { + if (denied) { + fail("no-access", "cannot open /dev/input/event* (is cap_dac_read_search set?)"); + } + fail("no-keyboards", "no input device has a watched key"); + } + + int state = ctrl_state(); + emit(state ? "1" : "0"); + + for (;;) { + struct pollfd fds[MAX_DEVICES + 1]; + fds[0] = (struct pollfd){.fd = STDIN_FILENO, .events = POLLIN}; + for (int i = 0; i < device_count; i++) { + fds[i + 1] = (struct pollfd){.fd = devices[i], .events = POLLIN}; + } + if (poll(fds, (nfds_t)device_count + 1, -1) < 0) { + if (errno == EINTR) { + continue; + } + return 1; + } + if (!stdin_open(fds[0].revents)) { + return 0; + } + /* Backwards, so that moving the last device into a removed slot is safe. */ + for (int i = device_count - 1; i >= 0; i--) { + short revents = fds[i + 1].revents; + bool gone = revents & (POLLERR | POLLHUP | POLLNVAL); + if (!gone && (revents & POLLIN)) { + gone = !drain(devices[i]); + } + if (gone) { + close(devices[i]); + devices[i] = devices[--device_count]; + } + } + int next = ctrl_state(); + if (next != state) { + state = next; + emit(state ? "1" : "0"); + } + } +} diff --git a/main.py b/main.py index 0135057..ad3458c 100644 --- a/main.py +++ b/main.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import sys # Clear sys.modules cache if package is reloaded, manually or after an update. diff --git a/plugin/__init__.py b/plugin/__init__.py index f4adc99..5a2571c 100644 --- a/plugin/__init__.py +++ b/plugin/__init__.py @@ -1,9 +1,17 @@ +from __future__ import annotations + from .commands import * # noqa: F401,F403 +from .ctrl_release import plugin_loaded as _ctrl_release_plugin_loaded from .ctrl_release import plugin_unloaded as _ctrl_release_plugin_unloaded from .listener import * # noqa: F401,F403 +from .setup import * # noqa: F401,F403 from .state import iter_states as _iter_states +def plugin_loaded() -> None: + _ctrl_release_plugin_loaded() + + def plugin_unloaded() -> None: for state in _iter_states(): if state.selection_poller is not None: diff --git a/plugin/commands.py b/plugin/commands.py index e85a147..db6c4c2 100644 --- a/plugin/commands.py +++ b/plugin/commands.py @@ -6,6 +6,7 @@ from .history import current_group_selection_state, sync_selection_history from .mru import collect_entries from .session import cancel_session, reopen_panel_at_index, schedule_panel, show_panel +from .setup import offer_wayland_setup from .state import get_state @@ -13,6 +14,9 @@ class TabStackOpenCommand(sublime_plugin.WindowCommand): def run(self, *, forward: bool = True, selected_index: int = 1) -> None: window = self.window if not is_available(): + # The dialog replaces the fallback for this key press, whatever the answer. + if offer_wayland_setup(window): + return window.run_command("next_view_in_stack" if forward else "prev_view_in_stack") return elif not forward: diff --git a/plugin/constants.py b/plugin/constants.py index 7938deb..ca6d82c 100644 --- a/plugin/constants.py +++ b/plugin/constants.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import sublime TAB_HISTORY_LIMIT = 20 diff --git a/plugin/ctrl_release/__init__.py b/plugin/ctrl_release/__init__.py index 5dd6020..f8294af 100644 --- a/plugin/ctrl_release/__init__.py +++ b/plugin/ctrl_release/__init__.py @@ -1,33 +1,89 @@ from __future__ import annotations import sys +from collections.abc import Callable from ._availability import is_available as _is_available from ._availability import reset as _reset_availability if sys.platform == "darwin": - from .macos import CtrlReleasePoller, probe + from .macos import CtrlReleasePoller as create_poller from .macos import plugin_unloaded as _platform_plugin_unloaded + from .macos import probe + + def _start_platform_probe(on_done: Callable[[], None]) -> None: + on_done() + + def _watch_platform(on_invalidated: Callable[[], None]) -> None: + _ = on_invalidated + + def is_native_wayland() -> bool: + return False + elif sys.platform == "win32": - from .windows import CtrlReleasePoller, probe + from .windows import CtrlReleasePoller as create_poller from .windows import plugin_unloaded as _platform_plugin_unloaded + from .windows import probe + + def _start_platform_probe(on_done: Callable[[], None]) -> None: + on_done() + + def _watch_platform(on_invalidated: Callable[[], None]) -> None: + _ = on_invalidated + + def is_native_wayland() -> bool: + return False + else: - from .linux import CtrlReleasePoller, probe + from .linux import create_poller, is_native_wayland, probe from .linux import plugin_unloaded as _platform_plugin_unloaded + from .linux.keystate_helper import HelperProbe, start_helper_probe + from .linux.keystate_helper import watch_helper as _watch_platform + + def _start_platform_probe(on_done: Callable[[], None]) -> None: + def on_helper_probed(_: HelperProbe) -> None: + on_done() + + start_helper_probe(on_helper_probed) def is_available() -> bool: return _is_available(probe) +def refresh_availability(on_done: Callable[[], None] = lambda: None) -> None: + """Probe the platform in the background and call `on_done` on the main thread. + + Until then, `is_available` answers without the platform probe's result. + """ + + def on_probed() -> None: + # Drop the answer given while the probe was running. + _reset_availability() + on_done() + + _reset_availability() + _start_platform_probe(on_probed) + + +def plugin_loaded() -> None: + refresh_availability() + # The probe result only holds for the settings it was made with, + # and only until the probed helper fails. + _watch_platform(refresh_availability) + + def plugin_unloaded() -> None: _platform_plugin_unloaded() _reset_availability() __all__ = [ - "CtrlReleasePoller", + "create_poller", "is_available", + "is_native_wayland", "probe", + "plugin_loaded", "plugin_unloaded", + "refresh_availability", ] diff --git a/plugin/ctrl_release/_availability.py b/plugin/ctrl_release/_availability.py index ee5abd5..3d34adb 100644 --- a/plugin/ctrl_release/_availability.py +++ b/plugin/ctrl_release/_availability.py @@ -1,9 +1,8 @@ from __future__ import annotations from collections.abc import Callable -from typing import Optional -_available: Optional[bool] = None +_available: bool | None = None def set_unavailable() -> None: diff --git a/plugin/ctrl_release/linux/__init__.py b/plugin/ctrl_release/linux/__init__.py new file mode 100644 index 0000000..9179f78 --- /dev/null +++ b/plugin/ctrl_release/linux/__init__.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import os +from collections.abc import Callable + +from ...state import CtrlPoller +from .keystate_helper import HelperCtrlReleasePoller, HelperProbe, HelperStatus, get_helper_probe +from .keystate_helper import plugin_unloaded as _helper_plugin_unloaded +from .x11 import X11CtrlReleasePoller, is_x11_available +from .x11 import plugin_unloaded as _x11_plugin_unloaded + + +def is_native_wayland() -> bool: + return bool(os.environ.get("WAYLAND_DISPLAY")) and os.environ.get("GDK_BACKEND") != "x11" + + +def create_poller(on_release: Callable[[], None], interval_ms: int) -> CtrlPoller: + helper_probe = _working_helper_probe() + if helper_probe is not None: + return HelperCtrlReleasePoller(on_release, interval_ms, helper_probe) + return X11CtrlReleasePoller(on_release, interval_ms) + + +def probe() -> bool: + if _working_helper_probe() is not None: + return True + # Under native Wayland, XWayland reports Ctrl as released while Sublime has focus, + # which would commit every session immediately. + return not is_native_wayland() and is_x11_available() + + +def _working_helper_probe() -> HelperProbe | None: + helper_probe = get_helper_probe() + if helper_probe is None or helper_probe.status is not HelperStatus.OK: + return None + return helper_probe + + +def plugin_unloaded() -> None: + _x11_plugin_unloaded() + _helper_plugin_unloaded() diff --git a/plugin/ctrl_release/linux/keystate_helper.py b/plugin/ctrl_release/linux/keystate_helper.py new file mode 100644 index 0000000..2930582 --- /dev/null +++ b/plugin/ctrl_release/linux/keystate_helper.py @@ -0,0 +1,338 @@ +from __future__ import annotations + +import os +import subprocess +import threading +from collections.abc import Callable +from dataclasses import dataclass +from enum import Enum +from typing import IO, cast, final + +import sublime + +HANDSHAKE = "tabstack-keystate 2" +DEFAULT_HELPER_PATH = "/usr/local/libexec/tabstack-keystate" +DEFAULT_CTRL_KEYS = ("leftctrl", "rightctrl") + +_HANDSHAKE_PREFIX = "tabstack-keystate " +_SETTINGS_FILE = "TabStack.sublime-settings" +_SETTINGS_WATCH_KEY = "tab_stack_keystate_helper" +_PROBE_TIMEOUT_S = 2 +_EXIT_TIMEOUT_S = 1 + + +class HelperStatus(Enum): + OK = "ok" + MISSING = "missing" + OUTDATED = "outdated" + NO_ACCESS = "no-access" + FAILED = "failed" + + +@dataclass(frozen=True) +class HelperProbe: + # The probed path and keys; the poller runs exactly these, so both stay consistent + # until the next probe even if the settings change in between. + path: str + ctrl_keys: tuple[str, ...] + status: HelperStatus + detail: str + + @property + def command(self) -> list[str]: + return [self.path, *self.ctrl_keys] + + +_probe: HelperProbe | None = None +_probe_generation = 0 +# The path and keys of the probe that is running or finished, as `_probe` may still be `None`. +_probed_config: tuple[str, tuple[str, ...]] | None = None +# Called on the main thread once the probe result no longer holds. +_on_invalidated: Callable[[], None] | None = None + + +def helper_path() -> str: + path = sublime.load_settings(_SETTINGS_FILE).get("keystate_helper_path", DEFAULT_HELPER_PATH) + if not isinstance(path, str) or not path: + path = DEFAULT_HELPER_PATH + return os.path.expanduser(path) + + +def helper_ctrl_keys() -> tuple[str, ...]: + keys: object = sublime.load_settings(_SETTINGS_FILE).get("keystate_ctrl_keys") + if not isinstance(keys, list) or not keys: + return DEFAULT_CTRL_KEYS + # The helper validates the names against its allowlist and reports a usage error. + return tuple(str(key) for key in cast(list[object], keys)) + + +def _helper_config() -> tuple[str, tuple[str, ...]]: + return helper_path(), helper_ctrl_keys() + + +def probe_helper(path: str, ctrl_keys: tuple[str, ...]) -> HelperProbe: + try: + # Empty stdin makes the helper exit right after reporting the initial state. + result = subprocess.run( + [path, *ctrl_keys], input=b"", capture_output=True, timeout=_PROBE_TIMEOUT_S + ) + except FileNotFoundError: + return HelperProbe(path, ctrl_keys, HelperStatus.MISSING, f"{path} does not exist.") + except subprocess.TimeoutExpired: + return HelperProbe( + path, + ctrl_keys, + HelperStatus.FAILED, + f"{path} did not exit within {_PROBE_TIMEOUT_S} seconds.", + ) + except OSError as exc: + return HelperProbe(path, ctrl_keys, HelperStatus.FAILED, f"{path} could not be run: {exc}") + + lines = result.stdout.decode("ascii", errors="replace").splitlines() + return _parse_probe_output(path, ctrl_keys, lines) + + +def _parse_probe_output(path: str, ctrl_keys: tuple[str, ...], lines: list[str]) -> HelperProbe: + handshake = lines[0] if lines else "" + if handshake != HANDSHAKE: + if handshake.startswith(_HANDSHAKE_PREFIX): + return HelperProbe( + path, + ctrl_keys, + HelperStatus.OUTDATED, + f"{path} speaks protocol {handshake!r}, but {HANDSHAKE!r} is required.", + ) + return HelperProbe( + path, + ctrl_keys, + HelperStatus.FAILED, + f"{path} did not identify itself as `tabstack-keystate`.", + ) + + report = lines[1] if len(lines) > 1 else "" + if report in ("0", "1"): + return HelperProbe(path, ctrl_keys, HelperStatus.OK, f"{path} reports the Ctrl key state.") + if report.split(" ")[:2] == ["error", "no-access"]: + return HelperProbe( + path, + ctrl_keys, + HelperStatus.NO_ACCESS, + f"{path} cannot open the keyboard devices: {report}", + ) + if report.startswith("error "): + return HelperProbe(path, ctrl_keys, HelperStatus.FAILED, f"{path} reported: {report}") + return HelperProbe(path, ctrl_keys, HelperStatus.FAILED, f"{path} reported no Ctrl key state.") + + +def get_helper_probe() -> HelperProbe | None: + """Return the result of the last probe, or `None` while it is still running.""" + return _probe + + +def start_helper_probe(on_done: Callable[[HelperProbe], None]) -> None: + """Probe the helper on the async thread and call `on_done` on the main thread. + + Running the helper can take up to `_PROBE_TIMEOUT_S`, which must not block the UI. + """ + global _probe, _probe_generation, _probed_config + _probe = None + _probe_generation += 1 + generation = _probe_generation + # Resolved here because the Sublime API is only safe on the main thread. + path, ctrl_keys = _probed_config = _helper_config() + + def probe() -> None: + result = probe_helper(path, ctrl_keys) + sublime.set_timeout(lambda: _finish_probe(generation, result, on_done)) + + sublime.set_timeout_async(probe) + + +def _finish_probe( + generation: int, result: HelperProbe, on_done: Callable[[HelperProbe], None] +) -> None: + global _probe + # A newer probe or a reset supersedes this one. + if generation != _probe_generation: + return + _probe = result + _report_broken_helper(result) + on_done(result) + + +def _report_broken_helper(result: HelperProbe) -> None: + # A missing helper is the normal state without a setup; any other failure + # means that a set-up helper stopped working, e.g. after a package update. + if result.status in (HelperStatus.OK, HelperStatus.MISSING): + return + print( + f"TabStack: the tabstack-keystate helper does not work ({result.status.value}):" + + f" {result.detail} Run `TabStack: Set Up Ctrl Detection for Wayland` for instructions." + ) + + +def reset_helper_probe() -> None: + global _probe, _probe_generation, _probed_config + _probe = None + _probe_generation += 1 + _probed_config = None + + +def watch_helper(on_invalidated: Callable[[], None]) -> None: + """Call `on_invalidated` whenever the probe result no longer holds. + + That is when `keystate_helper_path` or `keystate_ctrl_keys` no longer match the probe, + or when the probed helper fails during a session. + """ + global _on_invalidated + _on_invalidated = on_invalidated + + def on_settings_changed() -> None: + if _helper_config() != _probed_config: + on_invalidated() + + sublime.load_settings(_SETTINGS_FILE).add_on_change(_SETTINGS_WATCH_KEY, on_settings_changed) + + +def _invalidate_probe(probe: HelperProbe) -> None: + # A newer probe or a reset already superseded the failed one. + if probe is not _probe or _on_invalidated is None: + return + _on_invalidated() + + +@final +class HelperCtrlReleasePoller(threading.Thread): + def __init__( + self, on_release: Callable[[], None], interval_ms: int, probe: HelperProbe + ) -> None: + super().__init__(daemon=True) + # The helper reports state changes as they happen, so there is nothing to poll. + _ = interval_ms + self._on_release = on_release + self._probe = probe + self._path = probe.path + self._stop_event = threading.Event() + self._lock = threading.Lock() + self._process: subprocess.Popen[str] | None = None + # Unknown until the helper reports the initial state, which can take longer + # than the panel delay; assuming Ctrl held would flash the panel on a quick press. + self._ctrl_down: bool | None = None + + def start(self) -> None: + if self.is_alive(): + return + super().start() + + def stop(self) -> None: + with self._lock: + self._stop_event.set() + self._close_stdin() + + def run(self) -> None: + process = self._spawn() + if process is None: + self._fail() + return + try: + if process.stdout is None: + self._fail() + return + self._read_states(process.stdout) + finally: + self._terminate(process) + + def is_ctrl_down(self) -> bool | None: + return self._ctrl_down + + def _spawn(self) -> subprocess.Popen[str] | None: + with self._lock: + if self._stop_event.is_set(): + return None + try: + self._process = subprocess.Popen( + self._probe.command, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + encoding="ascii", + errors="replace", + bufsize=1, + ) + except OSError as exc: + print(f"TabStack: failed to start {self._path}: {exc}") + return None + return self._process + + def _read_states(self, stdout: IO[str]) -> None: + handshake = stdout.readline().rstrip("\n") + if handshake != HANDSHAKE: + print(f"TabStack: unexpected handshake from {self._path}: {handshake!r}") + self._fail() + return + + for raw_line in iter(stdout.readline, ""): + line = raw_line.rstrip("\n") + if line == "1": + self._ctrl_down = True + elif line == "0": + self._ctrl_down = False + _ = self._fire_release() + return + elif line.startswith("error "): + print(f"TabStack: {self._path} reported: {line}") + self._fail() + return + else: + print(f"TabStack: unexpected line from {self._path}: {line!r}") + + self._fail() + + def _fail(self) -> None: + self._ctrl_down = False + if not self._fire_release(): + return + # The helper worked when probed; probe it again so that later sessions + # fall back to X11 or offer the setup instead of committing immediately. + probe = self._probe + sublime.set_timeout(lambda: _invalidate_probe(probe)) + + def _fire_release(self) -> bool: + """Schedule the release callback; return whether it was scheduled.""" + # The lock makes the check-and-set atomic with respect to `stop`, + # so that no release is scheduled once the session stopped the poller. + with self._lock: + if self._stop_event.is_set(): + return False + self._stop_event.set() + sublime.set_timeout(self._on_release) + return True + + def _close_stdin(self) -> None: + with self._lock: + process = self._process + if process is None or process.stdin is None: + return + try: + process.stdin.close() + except OSError: + pass + + def _terminate(self, process: subprocess.Popen[str]) -> None: + # The helper exits on stdin EOF; killing it is only the fallback. + self._close_stdin() + try: + _ = process.wait(timeout=_EXIT_TIMEOUT_S) + except subprocess.TimeoutExpired: + process.kill() + _ = process.wait() + if process.stdout is not None: + process.stdout.close() + + +def plugin_unloaded() -> None: + global _on_invalidated + sublime.load_settings(_SETTINGS_FILE).clear_on_change(_SETTINGS_WATCH_KEY) + _on_invalidated = None + reset_helper_probe() diff --git a/plugin/ctrl_release/linux.py b/plugin/ctrl_release/linux/x11.py similarity index 94% rename from plugin/ctrl_release/linux.py rename to plugin/ctrl_release/linux/x11.py index 2283c20..53ed930 100644 --- a/plugin/ctrl_release/linux.py +++ b/plugin/ctrl_release/linux/x11.py @@ -10,11 +10,11 @@ import sublime -from ._availability import set_unavailable +from .._availability import set_unavailable _XK_Control_L = 0xFFE3 _XK_Control_R = 0xFFE4 -_x11: Optional[ctypes.CDLL] = None +_x11: ctypes.CDLL | None = None @dataclass @@ -27,7 +27,7 @@ class _X11State: @final -class CtrlReleasePoller(threading.Thread): +class X11CtrlReleasePoller(threading.Thread): def __init__(self, on_release: Callable[[], None], interval_ms: int) -> None: super().__init__(daemon=True) self._on_release = on_release @@ -85,7 +85,7 @@ def _ctrl_down(self) -> bool: def is_ctrl_down(self) -> bool: return self._ctrl_down() - def _open_x11_state(self) -> Optional[_X11State]: + def _open_x11_state(self) -> _X11State | None: display_name = os.environ.get("DISPLAY") if not display_name: return None @@ -111,7 +111,7 @@ def _open_x11_state(self) -> Optional[_X11State]: ) -def probe() -> bool: +def is_x11_available() -> bool: return _get_x11() is not None @@ -125,7 +125,7 @@ def _keycode_is_down(keymap: ctypes.Array[ctypes.c_ubyte], keycode: int) -> bool return bool(keymap_byte & (1 << bit)) -def _get_x11() -> Optional[ctypes.CDLL]: +def _get_x11() -> ctypes.CDLL | None: global _x11 if _x11 is not None: return _x11 diff --git a/plugin/ctrl_release/macos.py b/plugin/ctrl_release/macos.py index 0b123d0..00664fc 100644 --- a/plugin/ctrl_release/macos.py +++ b/plugin/ctrl_release/macos.py @@ -4,7 +4,7 @@ import ctypes.util import threading from collections.abc import Callable -from typing import Optional, cast, final +from typing import cast, final import sublime @@ -13,7 +13,7 @@ _CG_EVENT_SOURCE_STATE_COMBINED_SESSION_STATE = 0 _K_VK_CONTROL = 59 _K_VK_RIGHT_CONTROL = 62 -_key_state: Optional[Callable[[int, int], bool]] = None +_key_state: Callable[[int, int], bool] | None = None @final @@ -66,7 +66,7 @@ def probe() -> bool: return _load_key_state() is not None -def _load_key_state() -> Optional[Callable[[int, int], bool]]: +def _load_key_state() -> Callable[[int, int], bool] | None: global _key_state if _key_state is not None: return _key_state diff --git a/plugin/ctrl_release/windows.py b/plugin/ctrl_release/windows.py index b155e0f..ee4eff1 100644 --- a/plugin/ctrl_release/windows.py +++ b/plugin/ctrl_release/windows.py @@ -3,14 +3,14 @@ import ctypes import threading from collections.abc import Callable -from typing import Optional, cast, final +from typing import cast, final import sublime from ._availability import set_unavailable _VK_CONTROL = 0x11 -_get_async_key_state: Optional[Callable[[int], int]] = None +_get_async_key_state: Callable[[int], int] | None = None @final @@ -57,7 +57,7 @@ def probe() -> bool: return _load_key_state() is not None -def _load_key_state() -> Optional[Callable[[int], int]]: +def _load_key_state() -> Callable[[int], int] | None: global _get_async_key_state if _get_async_key_state is not None: return _get_async_key_state diff --git a/plugin/history.py b/plugin/history.py index 16a52ee..68f99e8 100644 --- a/plugin/history.py +++ b/plugin/history.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional, cast, final +from typing import TYPE_CHECKING, cast, final import sublime @@ -55,9 +55,7 @@ def _poll(self) -> None: self._schedule() -def current_group_selection_state( - window: sublime.Window, group: int -) -> Optional[GroupSelectionState]: +def current_group_selection_state(window: sublime.Window, group: int) -> GroupSelectionState | None: selected_sheets = list(window.selected_sheets_in_group(group)) selected_identities = [ sheet_identity(sheet, window) for sheet in selected_sheets if sheet.group() is not None @@ -190,7 +188,7 @@ def prune_group_state_for_live_sheets( window: sublime.Window, group: int, group_state: GroupSelectionState, -) -> Optional[GroupSelectionState]: +) -> GroupSelectionState | None: selected_sheets = [ identity for identity in group_state["selected_sheets"] @@ -210,10 +208,10 @@ def prune_group_state_for_live_sheets( def prune_active_sheet_index( - active_sheet_index: Optional[int], + active_sheet_index: int | None, original_selected_sheets: list[SheetIdentity], pruned_selected_sheets: list[SheetIdentity], -) -> Optional[int]: +) -> int | None: if active_sheet_index is None: return None if active_sheet_index < 0 or active_sheet_index >= len(original_selected_sheets): @@ -229,7 +227,7 @@ def prune_active_sheet_index( def prune_group_state( group_state: GroupSelectionState, seen_identities: list[SheetIdentity], -) -> Optional[GroupSelectionState]: +) -> GroupSelectionState | None: selected_sheets = [ identity for identity in group_state["selected_sheets"] if identity not in seen_identities ] @@ -249,7 +247,7 @@ def prune_group_state( def prune_history_stack( group_state_stack: list[GroupSelectionState], - removed_identities: Optional[list[SheetIdentity]] = None, + removed_identities: list[SheetIdentity] | None = None, ) -> list[GroupSelectionState]: updated_stack: list[GroupSelectionState] = [] seen_identities: list[SheetIdentity] = list(removed_identities or []) diff --git a/plugin/listener.py b/plugin/listener.py index b793634..ab4f468 100644 --- a/plugin/listener.py +++ b/plugin/listener.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING import sublime import sublime_plugin @@ -84,12 +84,12 @@ def on_deactivated(self, view: sublime.View) -> None: def on_query_context( self, # `view` is widened because ST may query contexts without a view. - view: Optional[sublime.View], + view: sublime.View | None, key: str, operator: sublime.QueryOperator, operand: Value, match_all: bool, - ) -> Optional[bool]: + ) -> bool | None: if key == "tab_stack.ctrl_release_available": value = is_available() elif key in { diff --git a/plugin/models.py b/plugin/models.py index 307ad01..109ec6c 100644 --- a/plugin/models.py +++ b/plugin/models.py @@ -7,20 +7,20 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Optional, TypedDict +from typing import TypedDict class SheetIdentity(TypedDict): - name: Optional[str] + name: str | None """The sheet name is `None` on ST builds <4205 because the required API method does not exist there.""" occurrence: int - kind: Optional[str] - path: Optional[str] + kind: str | None + path: str | None class GroupSelectionState(TypedDict): - active_sheet_index: Optional[int] + active_sheet_index: int | None selected_sheets: list[SheetIdentity] diff --git a/plugin/mru.py b/plugin/mru.py index 4e429f5..9533ba6 100644 --- a/plugin/mru.py +++ b/plugin/mru.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import Optional - import sublime from .captions import caption_for_sheet_identities @@ -13,7 +11,7 @@ def collect_entries(window: sublime.Window, history: SheetSelectionHistory) -> list[Entry]: entries: list[Entry] = [] active_group = window.active_group() - seen_identities: set[tuple[Optional[str], int, Optional[str], Optional[str]]] = set() + seen_identities: set[tuple[str | None, int, str | None, str | None]] = set() group_state_stack = history["groups"].get(str(active_group)) if group_state_stack: @@ -62,13 +60,13 @@ def _non_transient_sheets_in_group( def _selection_identity_keys( selection: GroupSelectionState, -) -> set[tuple[Optional[str], int, Optional[str], Optional[str]]]: +) -> set[tuple[str | None, int, str | None, str | None]]: return {_identity_key(identity) for identity in selection["selected_sheets"]} def _identity_key( identity: SheetIdentity, -) -> tuple[Optional[str], int, Optional[str], Optional[str]]: +) -> tuple[str | None, int, str | None, str | None]: return ( identity["name"], identity["occurrence"], diff --git a/plugin/session.py b/plugin/session.py index aca481a..de260e1 100644 --- a/plugin/session.py +++ b/plugin/session.py @@ -3,11 +3,14 @@ import sublime from .constants import KIND_MODIFIED -from .ctrl_release import CtrlReleasePoller +from .ctrl_release import create_poller from .sheets import apply_group_selection from .state import TabStackWindowState SHOW_PANEL_DELAY_MS = 150 +# How long to keep a session pending past the panel delay while the Ctrl state is unknown. +CTRL_STATE_TIMEOUT_MS = 1000 +CTRL_STATE_RETRY_MS = 10 def _ensure_ctrl_release_poller(window: sublime.Window, state: TabStackWindowState) -> None: @@ -15,12 +18,15 @@ def _ensure_ctrl_release_poller(window: sublime.Window, state: TabStackWindowSta return def on_release() -> None: + # A release queued before this poller was stopped must not commit a newer session. + if state.ctrl_release_poller is not poller: + return if state.session_active: _commit_session(window, state) elif state.session_pending: _commit_pending_session(window, state) - poller = CtrlReleasePoller(on_release, 25) + poller = create_poller(on_release, 25) state.ctrl_release_poller = poller poller.start() @@ -66,17 +72,27 @@ def on_highlight(index: int) -> None: def schedule_panel(window: sublime.Window, state: TabStackWindowState, *, token: int) -> None: _ensure_ctrl_release_poller(window, state) - def open_if_still_pending() -> None: + def open_if_still_pending(waited_ms: int) -> None: if not state.session_pending or state.session_pending_token != token: return - if state.ctrl_release_poller is not None and not state.ctrl_release_poller.is_ctrl_down(): - state.clear_session() + poller = state.ctrl_release_poller + ctrl_down = poller.is_ctrl_down() if poller is not None else True + if ctrl_down is None and waited_ms < CTRL_STATE_TIMEOUT_MS: + # A release reported meanwhile commits the pending session through the poller. + sublime.set_timeout( + lambda: open_if_still_pending(waited_ms + CTRL_STATE_RETRY_MS), + CTRL_STATE_RETRY_MS, + ) + return + if ctrl_down is False: + # Ctrl was released before the panel appeared; switch like a quick press would. + _commit_pending_session(window, state) return state.session_active = True state.session_pending = False show_panel(window, state) - sublime.set_timeout(open_if_still_pending, SHOW_PANEL_DELAY_MS) + sublime.set_timeout(lambda: open_if_still_pending(0), SHOW_PANEL_DELAY_MS) def preview_entry(window: sublime.Window, state: TabStackWindowState) -> None: diff --git a/plugin/setup.py b/plugin/setup.py new file mode 100644 index 0000000..f56b6eb --- /dev/null +++ b/plugin/setup.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +import os +import shlex + +import sublime +import sublime_plugin + +from .ctrl_release import is_native_wayland, refresh_availability +from .ctrl_release.linux.keystate_helper import ( + DEFAULT_HELPER_PATH, + HelperProbe, + HelperStatus, + get_helper_probe, +) + +__all__ = ["TabStackReplaceContentsCommand", "TabStackSetupKeystateHelperCommand"] + +assert __package__ is not None +# This module lives in the `plugin` subpackage; resources are addressed by the top-level package. +_PACKAGE_NAME = __package__.split(".")[0] +_SETTINGS_FILE = "TabStack.sublime-settings" +_SOURCE_NAME = "tabstack-keystate.c" +_SETUP_COMMAND = "tab_stack_setup_keystate_helper" +_SETUP_CAPTION = "TabStack: Set Up Ctrl Detection for Wayland" +_INSTRUCTIONS_VIEW_SETTING = "tab_stack_setup_instructions" + +_wayland_dialog_shown = False + + +class TabStackSetupKeystateHelperCommand(sublime_plugin.WindowCommand): + def is_visible(self) -> bool: + return sublime.platform() == "linux" + + def run(self) -> None: + # Re-probing makes this command double as the "check again" step. + sublime.status_message("TabStack: checking the tabstack-keystate helper...") + refresh_availability(self._on_probed) + + def _on_probed(self) -> None: + probe = get_helper_probe() + if probe is None: + # The helper is only probed on Linux. + return + path = probe.path + if probe.status is HelperStatus.OK: + sublime.message_dialog( + f"Ctrl detection via the tabstack-keystate helper at {path} works." + ) + return + + extraction_dir = os.path.join(sublime.cache_path(), "TabStack", "keystate") + try: + source_path = _extract_source(extraction_dir) + except OSError as exc: + sublime.error_message(f"TabStack: could not extract the helper source: {exc}") + return + + view = _find_instructions_view() + window = self.window if view is None else view.window() or self.window + source_view = window.open_file(source_path) + if view is None: + view = _new_instructions_view(window) + view.set_read_only(False) + view.run_command( + "tab_stack_replace_contents", + {"characters": _instructions(probe, extraction_dir, source_path)}, + ) + view.set_read_only(True) + _show_side_by_side(window, view, source_view) + window.bring_to_front() + + +class TabStackReplaceContentsCommand(sublime_plugin.TextCommand): + def run(self, edit: sublime.Edit, characters: str) -> None: + _ = self.view.replace(edit, sublime.Region(0, self.view.size()), characters) + # A caret at the replaced region's start grows into a selection of the new text. + selection = self.view.sel() + selection.clear() + selection.add(0) + + +def offer_wayland_setup(window: sublime.Window) -> bool: + """Show the setup dialog once per plugin load on native Wayland. + + Returns whether the dialog was shown. + """ + global _wayland_dialog_shown + if _wayland_dialog_shown or not is_native_wayland(): + return False + if get_helper_probe() is None: + # The background probe has not finished; the helper may still turn out to work. + return False + settings = sublime.load_settings(_SETTINGS_FILE) + if not settings.get("show_wayland_setup_dialog", True): + return False + + _wayland_dialog_shown = True + result = sublime.yes_no_cancel_dialog( + _DIALOG_MESSAGE, + yes_title="Show Setup", + no_title="Don't Ask Again", + ) + if result == sublime.DIALOG_YES: + window.run_command(_SETUP_COMMAND) + elif result == sublime.DIALOG_NO: + settings.set("show_wayland_setup_dialog", False) + sublime.save_settings(_SETTINGS_FILE) + return True + + +def _extract_source(extraction_dir: str) -> str: + source = sublime.load_binary_resource(f"Packages/{_PACKAGE_NAME}/keystate/{_SOURCE_NAME}") + os.makedirs(extraction_dir, exist_ok=True) + source_path = os.path.join(extraction_dir, _SOURCE_NAME) + with open(source_path, "wb") as file: + _ = file.write(source) + return source_path + + +def _find_instructions_view() -> sublime.View | None: + for window in sublime.windows(): + for view in window.views(): + if view.settings().get(_INSTRUCTIONS_VIEW_SETTING, False): + return view + return None + + +def _new_instructions_view(window: sublime.Window) -> sublime.View: + view = window.new_file(syntax="Packages/Markdown/Markdown.sublime-syntax") + view.set_name("TabStack Setup") + view.set_scratch(True) + view.settings().set(_INSTRUCTIONS_VIEW_SETTING, True) + return view + + +def _show_side_by_side(window: sublime.Window, left: sublime.View, right: sublime.View) -> None: + """Select both views' sheets so that Sublime Text shows them side by side. + + The source may have been open already, possibly in another group, + so `left` is moved directly in front of it instead of the other way around. + """ + left_sheet = left.sheet() + right_sheet = right.sheet() + if left_sheet is None or right_sheet is None: + return + group, index = window.get_sheet_index(right_sheet) + left_group, left_index = window.get_sheet_index(left_sheet) + if left_group == group and left_index < index: + # Moving `left` out of the group shifts `right` one position to the front. + index -= 1 + window.set_sheet_index(left_sheet, group, index) + window.select_sheets([left_sheet, right_sheet]) + + +def _instructions(probe: HelperProbe, extraction_dir: str, source_path: str) -> str: + quoted_path = shlex.quote(probe.path) + custom_path_note = _CUSTOM_PATH_NOTE if probe.path != DEFAULT_HELPER_PATH else "" + return _INSTRUCTIONS.format( + caption=_SETUP_CAPTION, + status=probe.status.name, + detail=probe.detail, + status_note=_STATUS_NOTES.get(probe.status, ""), + source_path=source_path, + extraction_dir=shlex.quote(extraction_dir), + helper_path=quoted_path, + custom_path_note=custom_path_note, + ) + + +_DIALOG_MESSAGE = """\ +TabStack cannot detect when Ctrl is released under native Wayland \ +without the tabstack-keystate helper, \ +so Ctrl+Tab falls back to Sublime Text's built-in tab cycling. + +Show the instructions for setting up the helper?""" + +_STATUS_NOTES = { + HelperStatus.OUTDATED: """ +The installed helper speaks a different protocol version than this version of TabStack expects, +usually because the package was updated. +Rebuild it from the extracted source and run the install and grant commands again. +""", + HelperStatus.NO_ACCESS: """ +The helper runs but cannot open the keyboard devices. +Usually its capability is missing, e.g. because the binary was replaced. +Run the install and grant commands below again. +""", +} + +_CUSTOM_PATH_NOTE = """ +The helper path comes from the `keystate_helper_path` setting. +Its file system must not be mounted with `nosuid`, +or the kernel ignores the file capability. +""" + +_INSTRUCTIONS = """\ +# {caption} + +Status: {status} +{detail} +{status_note} +## Why a helper is needed + +Sublime Text does not tell plugins when a key is released, +so TabStack polls the global keyboard state to notice when Ctrl is released. +A native Wayland session has no such global key state. +The `tabstack-keystate` helper reads the Ctrl state +from the kernel's input devices (`/dev/input/event*`) instead +and reports changes to TabStack on its standard output. + +## Security implications + +Review the helper's source before building it. +It was extracted to `{source_path}` and opened next to these instructions. + +- The input devices are only readable by root (and usually the `input` group). + The helper is therefore granted the `cap_dac_read_search` file capability, + which allows reading *any* file on the system, not only input devices. +- At startup, the helper sets `no_new_privs`, + opens every `/dev/input/event*` device read-only, + keeps only the devices that advertise a Ctrl key, + and then drops all capabilities before it reports any state. +- Any local user can run the helper + and read through it whether Ctrl is held on any keyboard of this machine. + It reports nothing but the Ctrl state. +- The capability is attached to the installed file. + Replacing the file, e.g. after rebuilding, drops the capability, + so run the grant command again after every install. + +## Setup + +Building needs a C compiler and the kernel headers; +`setcap` usually comes with the libcap tools package. +Build the helper, install it, and grant it the capability: + +```sh +cd {extraction_dir} +cc -std=c11 -O2 -Wall -Wextra -o tabstack-keystate tabstack-keystate.c +sudo install -D -o root -g root -m 0755 tabstack-keystate {helper_path} +sudo setcap cap_dac_read_search=ep {helper_path} +``` + +Then run `{caption}` from the Command Palette again +to verify that the helper works. +{custom_path_note} +Flatpak and Snap builds of Sublime Text are not supported: +their sandbox makes the kernel ignore file capabilities +when Sublime Text runs the helper. + +## Uninstall + +```sh +sudo rm {helper_path} +``` +""" diff --git a/plugin/sheets.py b/plugin/sheets.py index e19854e..d04e9d4 100644 --- a/plugin/sheets.py +++ b/plugin/sheets.py @@ -7,7 +7,7 @@ from .models import GroupSelectionState, SheetIdentity -def active_sheet_identity(window: sublime.Window) -> Optional[SheetIdentity]: +def active_sheet_identity(window: sublime.Window) -> SheetIdentity | None: sheet = window.active_sheet() if sheet is None: return None @@ -18,7 +18,7 @@ def find_sheet_by_identity_and_group( window: sublime.Window, identity: SheetIdentity, group: int, -) -> Optional[sublime.Sheet]: +) -> sublime.Sheet | None: matching = [ sheet for sheet in window.sheets_in_group(group) @@ -62,7 +62,7 @@ def sheet_occurrence(sheet: sublime.Sheet, window: sublime.Window) -> int: return 0 -def sheet_name(sheet: sublime.Sheet) -> Optional[str]: +def sheet_name(sheet: sublime.Sheet) -> str | None: # `Sheet.name` only exists on ST builds >=4205. get_name = cast(Optional[Callable[[], str]], getattr(sheet, "name", None)) return get_name() if get_name else None diff --git a/plugin/state.py b/plugin/state.py index 772c932..afcde31 100644 --- a/plugin/state.py +++ b/plugin/state.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Optional, Protocol +from typing import Protocol import sublime @@ -15,7 +15,11 @@ def stop(self) -> None: ... class CtrlPoller(Poller, Protocol): - def is_ctrl_down(self) -> bool: ... + def start(self) -> None: ... + + def is_ctrl_down(self) -> bool | None: + """Return whether Ctrl is held, or `None` while the state is not known yet.""" + ... @dataclass @@ -24,13 +28,13 @@ class TabStackWindowState: session_pending: bool = False session_pending_token: int = 0 """Monotonically incremented token to track and implicitly cancel set_timeout callbacks.""" - session_origin_selection: Optional[GroupSelectionState] = None + session_origin_selection: GroupSelectionState | None = None session_selected_index: int = 0 session_group: int = 0 - session_entries: Optional[list[Entry]] = None + session_entries: list[Entry] | None = None session_panel_reopening: bool = False - selection_poller: Optional[Poller] = None - ctrl_release_poller: Optional[CtrlPoller] = None + selection_poller: Poller | None = None + ctrl_release_poller: CtrlPoller | None = None overlay_views: set[int] = field(default_factory=set) """Ids of the currently focused overlay/quick-panel widget views. diff --git a/pyproject.toml b/pyproject.toml index 3d1faf4..596b381 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,9 @@ target-version = "py38" [tool.ruff.lint] select = ["E", "F", "I", "UP"] -ignore = ["UP045"] + +[tool.ruff.lint.isort] +required-imports = ["from __future__ import annotations"] [tool.basedpyright] pythonVersion = "3.8" @@ -28,3 +30,7 @@ reportMissingModuleSource = false # `typing.override` requires 3.12 and the plugin host provides no # typing_extensions, so the decorator cannot be used at runtime. reportImplicitOverride = false + +[tool.ty.environment] +# Sublime Text imports packages from `Packages/`, so `main.py` is `TabStack.main`. +root = [".."] diff --git a/sublime-package.json b/sublime-package.json new file mode 100644 index 0000000..69c7cad --- /dev/null +++ b/sublime-package.json @@ -0,0 +1,45 @@ +{ + "contributions": { + "settings": [ + { + "file_patterns": ["/TabStack.sublime-settings"], + "schema": { + "$id": "sublime://settings/TabStack", + "type": "object", + "properties": { + "keystate_helper_path": { + "type": "string", + "default": "/usr/local/libexec/tabstack-keystate", + "markdownDescription": "Path to the `tabstack-keystate` helper that detects Ctrl releases on Linux.\n\nIt is used whenever it works, and it is required under native Wayland." + }, + "keystate_ctrl_keys": { + "type": "array", + "items": { + "enum": [ + "leftctrl", + "rightctrl", + "capslock", + "leftalt", + "rightalt", + "leftmeta", + "rightmeta", + "compose" + ] + }, + "minItems": 1, + "uniqueItems": true, + "default": ["leftctrl", "rightctrl"], + "markdownDescription": "The keys the `tabstack-keystate` helper treats as Ctrl, for keyboard layouts that remap Ctrl, e.g. `[\"leftctrl\", \"rightctrl\", \"capslock\"]` for the XKB option `ctrl:nocaps`.\n\nOnly modifier keys are allowed, so that the helper cannot be used to log keystrokes." + }, + "show_wayland_setup_dialog": { + "type": "boolean", + "default": true, + "markdownDescription": "Offer the helper's setup instructions on the first `ctrl+tab` per plugin load when Ctrl-release detection is unavailable under native Wayland.\n\nThe dialog's \"Don't Ask Again\" button sets this to `false`." + } + }, + "additionalProperties": false + } + } + ] + } +} diff --git a/tests/keystate_smoke.py b/tests/keystate_smoke.py new file mode 100644 index 0000000..ecd23f6 --- /dev/null +++ b/tests/keystate_smoke.py @@ -0,0 +1,286 @@ +"""Smoke test for the `tabstack-keystate` helper against a virtual uinput keyboard. + +Must run as root, with the `uinput` module loaded: + + sudo python3 -I tests/keystate_smoke.py --helper build/tabstack-keystate --user "$USER" + +The user given with `--user` must not be able to read `/dev/input/event*` on its own, +otherwise the check without the capability is not meaningful. +""" + +from __future__ import annotations + +import argparse +import fcntl +import os +import pwd +import selectors +import shutil +import struct +import subprocess +import sys +import tempfile +import time +from typing import cast + +HANDSHAKE = "tabstack-keystate 2" +LINE_TIMEOUT = 2.0 +SILENCE_TIMEOUT = 0.3 +EXIT_TIMEOUT = 1.0 +# `/tmp` is often a `nosuid` tmpfs, e.g. on systemd hosts, and the capable copy lives here. +TEMP_PARENT = "/var/tmp" + +# +EV_SYN = 0x00 +EV_KEY = 0x01 +SYN_REPORT = 0 +KEY_LEFTCTRL = 29 +KEY_A = 30 +KEY_CAPSLOCK = 58 +KEY_RIGHTCTRL = 97 +BUS_USB = 0x03 + +# struct input_event: struct timeval, __u16 type, __u16 code, __s32 value +INPUT_EVENT = struct.Struct("@llHHi") +# struct uinput_setup: struct input_id (4 x __u16), char name[80], __u32 ff_effects_max +UINPUT_SETUP = struct.Struct("@HHHH80sI") + + +def _ioc(direction: int, number: int, size: int) -> int: + return (direction << 30) | (size << 16) | (ord("U") << 8) | number + + +# ; _IOC_WRITE is 1 +UI_DEV_CREATE = _ioc(0, 1, 0) +UI_DEV_DESTROY = _ioc(0, 2, 0) +UI_DEV_SETUP = _ioc(1, 3, UINPUT_SETUP.size) +UI_SET_EVBIT = _ioc(1, 100, 4) +UI_SET_KEYBIT = _ioc(1, 101, 4) + + +class SmokeFailure(Exception): + pass + + +class VirtualKeyboard: + def __init__(self) -> None: + self._fd: int = os.open("/dev/uinput", os.O_WRONLY | os.O_NONBLOCK) + _ = fcntl.ioctl(self._fd, UI_SET_EVBIT, EV_KEY) + for key in (KEY_LEFTCTRL, KEY_RIGHTCTRL, KEY_A, KEY_CAPSLOCK): + _ = fcntl.ioctl(self._fd, UI_SET_KEYBIT, key) + setup = UINPUT_SETUP.pack(BUS_USB, 0x1234, 0x5678, 1, b"tabstack-keystate smoke test", 0) + _ = fcntl.ioctl(self._fd, UI_DEV_SETUP, setup) + _ = fcntl.ioctl(self._fd, UI_DEV_CREATE) + # udev needs a moment to create the device node. + time.sleep(0.5) + + def press(self, key: int, value: int) -> None: + _ = os.write(self._fd, INPUT_EVENT.pack(0, 0, EV_KEY, key, value)) + _ = os.write(self._fd, INPUT_EVENT.pack(0, 0, EV_SYN, SYN_REPORT, 0)) + + def destroy(self) -> None: + try: + _ = fcntl.ioctl(self._fd, UI_DEV_DESTROY) + finally: + os.close(self._fd) + + +class Helper: + def __init__(self, path: str, user: pwd.struct_passwd, *keys: str) -> None: + command = [ + "setpriv", + "--reuid", + str(user.pw_uid), + "--regid", + str(user.pw_gid), + "--init-groups", + path, + *keys, + ] + self.process: subprocess.Popen[bytes] = subprocess.Popen( + command, stdin=subprocess.PIPE, stdout=subprocess.PIPE + ) + stdout = self.process.stdout + assert stdout is not None + self._stdout_fd: int = stdout.fileno() + self._selector: selectors.DefaultSelector = selectors.DefaultSelector() + _ = self._selector.register(self._stdout_fd, selectors.EVENT_READ) + self._buffer: bytes = b"" + + def read_line(self, timeout: float) -> str | None: + """Return the next line, or `None` on timeout or EOF.""" + deadline = time.monotonic() + timeout + while b"\n" not in self._buffer: + remaining = deadline - time.monotonic() + if remaining <= 0 or not self._selector.select(remaining): + return None + chunk = os.read(self._stdout_fd, 4096) + if not chunk: + return None + self._buffer += chunk + line, self._buffer = self._buffer.split(b"\n", 1) + return line.decode("ascii", "replace") + + def expect_line(self, expected: str, step: str) -> None: + line = self.read_line(LINE_TIMEOUT) + if line != expected: + raise SmokeFailure(f"{step}: expected {expected!r}, got {line!r}") + + def expect_error(self, code: str, step: str) -> None: + line = self.read_line(LINE_TIMEOUT) + if line is None or not line.startswith(f"error {code} "): + raise SmokeFailure(f"{step}: expected 'error {code} ...', got {line!r}") + self.expect_exit(1, f"{step}: after error") + + def expect_silence(self, step: str) -> None: + line = self.read_line(SILENCE_TIMEOUT) + if line is not None: + raise SmokeFailure(f"{step}: expected no output, got {line!r}") + + def expect_exit(self, expected: int, step: str) -> None: + try: + status = self.process.wait(EXIT_TIMEOUT) + except subprocess.TimeoutExpired: + raise SmokeFailure(f"{step}: helper still running after {EXIT_TIMEOUT} s") from None + if status != expected: + raise SmokeFailure(f"{step}: expected exit status {expected}, got {status}") + + def close(self) -> None: + self._selector.close() + if self.process.poll() is None: + self.process.kill() + _ = self.process.wait() + for stream in (self.process.stdin, self.process.stdout): + if stream is not None: + stream.close() + + +def check_capabilities_dropped(pid: int) -> None: + with open(f"/proc/{pid}/status") as status: + for line in status: + name, _, value = line.partition(":") + if name in ("CapEff", "CapPrm", "CapAmb") and int(value, 16) != 0: + raise SmokeFailure(f"capabilities not dropped: {name} = {value.strip()}") + + +def run_capable(path: str, user: pwd.struct_passwd, keyboard: VirtualKeyboard) -> None: + helper = Helper(path, user) + try: + helper.expect_line(HANDSHAKE, "capable: handshake") + helper.expect_line("0", "capable: initial state") + check_capabilities_dropped(helper.process.pid) + for name, key in (("left", KEY_LEFTCTRL), ("right", KEY_RIGHTCTRL)): + keyboard.press(key, 1) + helper.expect_line("1", f"capable: {name} Ctrl down") + keyboard.press(key, 0) + helper.expect_line("0", f"capable: {name} Ctrl up") + keyboard.press(KEY_A, 1) + keyboard.press(KEY_A, 0) + helper.expect_silence("capable: KEY_A down/up") + stdin = helper.process.stdin + assert stdin is not None + stdin.close() + helper.expect_exit(0, "capable: stdin closed") + finally: + helper.close() + + +def run_capable_remapped(path: str, user: pwd.struct_passwd, keyboard: VirtualKeyboard) -> None: + helper = Helper(path, user, "capslock") + try: + helper.expect_line(HANDSHAKE, "remapped: handshake") + helper.expect_line("0", "remapped: initial state") + keyboard.press(KEY_LEFTCTRL, 1) + keyboard.press(KEY_LEFTCTRL, 0) + helper.expect_silence("remapped: unwatched Ctrl down/up") + keyboard.press(KEY_CAPSLOCK, 1) + helper.expect_line("1", "remapped: Caps Lock down") + keyboard.press(KEY_CAPSLOCK, 0) + helper.expect_line("0", "remapped: Caps Lock up") + finally: + helper.close() + + +def run_disallowed_key(path: str, user: pwd.struct_passwd) -> None: + helper = Helper(path, user, "a") + try: + helper.expect_line(HANDSHAKE, "disallowed key: handshake") + helper.expect_error("usage", "disallowed key") + finally: + helper.close() + + +def run_incapable(path: str, user: pwd.struct_passwd) -> None: + helper = Helper(path, user) + try: + helper.expect_line(HANDSHAKE, "incapable: handshake") + line = helper.read_line(LINE_TIMEOUT) + if line in ("0", "1"): + raise SmokeFailure( + f"incapable: helper reported state {line!r} without the capability;" + + f" user {user.pw_name!r} can already read input devices" + + " (e.g. `input` group membership), so the negative case is not meaningful" + ) + if line is None or not line.startswith("error no-access"): + raise SmokeFailure(f"incapable: expected 'error no-access ...', got {line!r}") + helper.expect_exit(1, "incapable: after error") + finally: + helper.close() + + +def check_capabilities_honored(directory: str) -> None: + # The kernel ignores file capabilities on `nosuid` mounts (capabilities(7)). + if os.statvfs(directory).f_flag & os.ST_NOSUID: + raise SmokeFailure( + f"{directory} is on a `nosuid` mount, which ignores file capabilities;" + + f" use a temporary directory without `nosuid` (see {TEMP_PARENT})" + ) + + +def install_copies(helper: str, directory: str) -> tuple[str, str]: + os.chmod(directory, 0o755) + check_capabilities_honored(directory) + capable = os.path.join(directory, "capable") + incapable = os.path.join(directory, "incapable") + for target in (capable, incapable): + _ = shutil.copyfile(helper, target) + os.chmod(target, 0o755) + _ = subprocess.run(["setcap", "cap_dac_read_search=ep", capable], check=True) + return capable, incapable + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + _ = parser.add_argument("--helper", required=True, help="path to the built helper") + _ = parser.add_argument("--user", required=True, help="unprivileged user to run it as") + args = parser.parse_args() + helper_path = cast(str, args.helper) + user_name = cast(str, args.user) + + if os.geteuid() != 0: + print("FAIL: must run as root", file=sys.stderr) + return 1 + user = pwd.getpwnam(user_name) + + keyboard = VirtualKeyboard() + try: + directory = tempfile.mkdtemp(prefix="tabstack-keystate-", dir=TEMP_PARENT) + try: + capable, incapable = install_copies(helper_path, directory) + run_capable(capable, user, keyboard) + run_capable_remapped(capable, user, keyboard) + run_disallowed_key(capable, user) + run_incapable(incapable, user) + finally: + shutil.rmtree(directory) + except SmokeFailure as failure: + print(f"FAIL: {failure}", file=sys.stderr) + return 1 + finally: + keyboard.destroy() + print("OK: tabstack-keystate smoke test passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/uv.lock b/uv.lock index 64fcaa1..7ec7a0e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,58 +1,80 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.8" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] [[package]] name = "basedpyright" -version = "1.39.9" +version = "1.39.10" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] dependencies = [ { name = "nodejs-wheel-binaries" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7a/4b/c1f4e211e50389304d6af32b9280e026a7133e3ad59bbdf8f7a3250f8bee/basedpyright-1.39.9.tar.gz", hash = "sha256:32cbea5fc8273e89df3db20daea56cb7286e419ccdfdc479c64759d2dc071901", size = 24412216, upload-time = "2026-06-27T02:19:49.834Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/43/ad2999f3b09eb2b1e59931d88fac0f7bcc9c17fc18c903268779bd10cc97/basedpyright-1.39.10.tar.gz", hash = "sha256:c8eaf5302f3265e275c7df4fba194d7afa7c1cb53fbfd448e90098360aca2c2e", size = 24740347, upload-time = "2026-08-13T17:09:02.51Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/d4/e1fa108710d0498a18c77b1e13897f31eab47c69aa8cfe2d2a4df746541e/basedpyright-1.39.9-py3-none-any.whl", hash = "sha256:6b0837b9eba972c71895167ab9b127e6afdbc17abc92312e3f8d15ca82a5611c", size = 13374276, upload-time = "2026-06-27T02:19:54.431Z" }, + { url = "https://files.pythonhosted.org/packages/be/2a/a224054d75a58786c482f63b8ff2a09fc3362268bd1ebd0a61fb3f982153/basedpyright-1.39.10-py3-none-any.whl", hash = "sha256:cbd75d83c0be841329bcfef2d2f1182f152a6d975b8eb199e75cf5b8e9a3de78", size = 13482322, upload-time = "2026-08-13T17:08:59.074Z" }, +] + +[[package]] +name = "basedpyright" +version = "1.40.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "nodejs-wheel-binaries" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/36/1cf637b15704e4c1f09f8bcc48ab3bfaff089e7466565bd34d8d1dcc03e9/basedpyright-1.40.2.tar.gz", hash = "sha256:d4db36d095230379acc66c7e5fba7e4b50ca902f6e803d696782dd031cb8d7d7", size = 25559682, upload-time = "2026-10-04T11:28:30.415Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/53/0031b22e118264a57a3b64dfc0c8f0659b81af5bccd419ac62933013c601/basedpyright-1.40.2-py3-none-any.whl", hash = "sha256:3a53aeb93a86675ded0e12bc27bd8476c1742356de07ad8cab67bff18110c690", size = 13694487, upload-time = "2026-10-04T11:28:26.271Z" }, ] [[package]] name = "nodejs-wheel-binaries" -version = "24.16.0" +version = "24.19.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/22/2a5beb4e21417c73233d9f65cf6f3e96e891b80d2f550a8f630ebc6b88c6/nodejs_wheel_binaries-24.16.0.tar.gz", hash = "sha256:c973cb69dc5fd16e6f6dc6e579e2c3d5534e2a1f57619dddf5ba070efa7dde37", size = 8056, upload-time = "2026-05-30T16:52:09.807Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/76/7e97195e14346598565a0de4ca8bdbd5e634b3fb5b1ba590b7b1b89f8a63/nodejs_wheel_binaries-24.19.0.tar.gz", hash = "sha256:db217eef8cab8551667863379b08db4d9067403f6cbbe87481eb40edceb8aa9b", size = 8058, upload-time = "2026-08-19T21:47:19.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/d1/68b43b53cd0fa83ae6fd406705023ca988d9e0ca41c724d82e66fbeb2ef6/nodejs_wheel_binaries-24.16.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:d9f8f677dcf30e37ac244f07869726abe043f01eb0f45722b1df31cc2af7093c", size = 55666374, upload-time = "2026-05-30T16:51:39.588Z" }, - { url = "https://files.pythonhosted.org/packages/e9/b2/40a989159599080da485de966c4c2d207e852ac7aa7864702626d96c8bf5/nodejs_wheel_binaries-24.16.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:3d0370fe7120ce9697a4f60d40480d2bd8808d9f30131458d5afc0040d4e5a51", size = 55838487, upload-time = "2026-05-30T16:51:43.383Z" }, - { url = "https://files.pythonhosted.org/packages/d7/a7/cd42174fb5ff6faff7fa8d326a18914d8f232098ab5de055b57c16fa13ca/nodejs_wheel_binaries-24.16.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:85dc92bbb79c851569c5925dcc2a4c915a034efab375f99e4e7e6bbe9cca8342", size = 60179540, upload-time = "2026-05-30T16:51:47.036Z" }, - { url = "https://files.pythonhosted.org/packages/2b/95/c8a1f9ae140aa28df8744d984d01d4b3af7cdd6555af12127f40ceb45a7d/nodejs_wheel_binaries-24.16.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:2f3036292811514ba847b3708492644764f88a833ac425c5f55007014308ddfd", size = 60716262, upload-time = "2026-05-30T16:51:50.711Z" }, - { url = "https://files.pythonhosted.org/packages/64/c9/7c35b3737f59e36d0249c265397b7bff570519b95301d6e16ea361e904ad/nodejs_wheel_binaries-24.16.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:db8a8a76ebd2b28ecbfc9ad464baa3707241b9e050a30e2efdf6f60c0f886502", size = 62230592, upload-time = "2026-05-30T16:51:55Z" }, - { url = "https://files.pythonhosted.org/packages/04/96/d931255cf9d11a84d6b54d882dba7434646467d568ccf070ea3418638df3/nodejs_wheel_binaries-24.16.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f1a3d8f7b4491cbbd023ba3fc4e901fcca2d9fb80d57f24ba3890de8b1dbac03", size = 62841759, upload-time = "2026-05-30T16:51:59.407Z" }, - { url = "https://files.pythonhosted.org/packages/a2/7b/8b7a3f41bc255411be30b6d7d288aab8ffd9ea2055db8555ced3548007b9/nodejs_wheel_binaries-24.16.0-py2.py3-none-win_amd64.whl", hash = "sha256:bb136be9944f0662dcf1120f45193a6b75b13fac378971a95cc42c9f879a81aa", size = 42027734, upload-time = "2026-05-30T16:52:03.348Z" }, - { url = "https://files.pythonhosted.org/packages/17/66/1ed71f1f529b8ca727d42c7ceb9db0bef145ce4a13dfc86fb50aa44f3be6/nodejs_wheel_binaries-24.16.0-py2.py3-none-win_arm64.whl", hash = "sha256:8308940b5edd0a50dc5267ea36ba21c9f668e83fe0d9f293937174d3a7e31c36", size = 39714528, upload-time = "2026-05-30T16:52:06.421Z" }, + { url = "https://files.pythonhosted.org/packages/8c/52/0774b52c7be8151ad9d5aff44edc100c3f13d6d8eb3765f63ffa40e69fe8/nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:e12cbfd69089504e42fb14194ce734a9dcf3eb38c820ca63dd511d36fb964e9c", size = 56047203, upload-time = "2026-08-19T21:46:43.448Z" }, + { url = "https://files.pythonhosted.org/packages/67/3a/4fdbbfecf2c23d52c0e3f68de7f7c1b3c97a26d328c69c5f6c49c48e340e/nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:1c890adf4b7e6556ccc1ca66c866bb81884b6c9a581dee4e530dc7f78fb9d514", size = 56219459, upload-time = "2026-08-19T21:46:48.45Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a8/0147149415195c59b8a72a594916bfb80d6be4d586f9fbfda313889e0efc/nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4e029dadfae1295876063c96b236f673487e8c27379fe146c1e2250283520227", size = 60588256, upload-time = "2026-08-19T21:46:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/89/6631d0982353da1bb7bc00bb1988f702822c62b42634f57999ee53b5c337/nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:4196a947bcc883f2003ab101762d729f3e99b5e86b75bd09151563403e2eceb8", size = 61123607, upload-time = "2026-08-19T21:46:58.117Z" }, + { url = "https://files.pythonhosted.org/packages/32/a2/fa30f0841e4602995782e124359f9b910c7b481d98decf61ef0b2fc3ebfb/nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:352e048ab4dd35e7de5f338d1cc4fcbf77a0e93da30bf7336a8217ee246b31d7", size = 62632842, upload-time = "2026-08-19T21:47:03.42Z" }, + { url = "https://files.pythonhosted.org/packages/18/01/22d97ca72213f66cc386ee638db30c2e62757fdf761c6029074bced83d1c/nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:28d078b2ced9e2069516e652dc4b1380e7a1a7f2d3934eccd1611586d283ba4c", size = 63250653, upload-time = "2026-08-19T21:47:07.938Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/e3be8fa327a795bcaf7a19cd84299e338a7bce32ff0665fdce9cfa22573c/nodejs_wheel_binaries-24.19.0-py2.py3-none-win_amd64.whl", hash = "sha256:67e3abeb9c3830cae8c8487ae8a2af7cc27dfa75af06145cee5ca7d1857c81bd", size = 42448503, upload-time = "2026-08-19T21:47:12.093Z" }, + { url = "https://files.pythonhosted.org/packages/1d/37/34cf28ba1691a060174948a9927fe61091982d6048b2e403071a9acce443/nodejs_wheel_binaries-24.19.0-py2.py3-none-win_arm64.whl", hash = "sha256:d9074c665ea68b04e183d82482c86dc907d3a9bd15eb6cf85542cb785266bb36", size = 40090155, upload-time = "2026-08-19T21:47:16.032Z" }, ] [[package]] name = "ruff" -version = "0.15.17" +version = "0.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/a7/70debb024dfacda67b8e560cc7511f52b34b9348a8cc8c1ec23036dcd51d/ruff-0.17.0.tar.gz", hash = "sha256:5cd03240d8208a557c2a9655a5cb07ebe36aa6bb35065f97d48c1f6adef5a322", size = 5320554, upload-time = "2026-10-09T19:47:29.248Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" }, - { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" }, - { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" }, - { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" }, - { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" }, - { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" }, - { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" }, - { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" }, - { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" }, - { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" }, - { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" }, - { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" }, - { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" }, - { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" }, - { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" }, - { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f8/ee5ab9da6089eae2a33e6008b01deb1eda19992c1c8e10661e98cee1640f/ruff-0.17.0-py3-none-linux_armv6l.whl", hash = "sha256:0e271826af9a20d18c6cfae8c51e82959167c24859686ddd3eb9a7f0842ce81e", size = 10148907, upload-time = "2026-10-09T19:46:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d9/2f81fb5a9d580afbb11b1c8ff915233a11f2a1b27405d7991f183c5e1976/ruff-0.17.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5f0ca4a40f81403689c04f12966e22f44e329ae362072d8f1587b7bda87f603b", size = 10224208, upload-time = "2026-10-09T19:46:41.711Z" }, + { url = "https://files.pythonhosted.org/packages/a7/20/643f3c8f75594f937b2bf74801241c56a2e2b8e139d24dff8b66b28cdd7f/ruff-0.17.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cbf7149e0927dc3295d5d64679a4765576eef71b00782b2ae969ef82274d6bb9", size = 9273324, upload-time = "2026-10-09T19:46:44.323Z" }, + { url = "https://files.pythonhosted.org/packages/ec/91/627700b233d367736cb274f1bd0b47d1f2b12f68878192812bd875adadc3/ruff-0.17.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ee90156522998c3037059d8f66885c8adeeaf7643bdce2caceee196ecd23e0", size = 9953190, upload-time = "2026-10-09T19:46:47.155Z" }, + { url = "https://files.pythonhosted.org/packages/cd/92/91f7b5ed39490f89d6cbf56e1f543c383667a725efa8e2c2dee0f01f5591/ruff-0.17.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d8e4a002a94cd9d0dc48b51dc69d807a172b5b9bf2b668e656424dc5b55ead1", size = 9961746, upload-time = "2026-10-09T19:46:50.098Z" }, + { url = "https://files.pythonhosted.org/packages/87/c5/7310f9fc63ce11ff6394edbd5e85433dfb0e14c9fbf6ccc97f1538491bc7/ruff-0.17.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0b8a60c06a218c337e1161638d34757f83449243e2db161483ddf948e53ad14", size = 10625483, upload-time = "2026-10-09T19:46:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8d/97443f0dca4a03a0bc7629fd396fd494a1cb6666121e38c5075acb217d8f/ruff-0.17.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a330178bdffc4205dbf3bda11d93e059e388fd6546f8cdd304501a9160363c0d", size = 11350129, upload-time = "2026-10-09T19:46:56.486Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0a/c525efd9777be4b6b012e6969a3012648468e7e6c4b3e5b46af69f46e8eb/ruff-0.17.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7bb08489e234876fa2da67ae3ea938e9a2156da80293e0e4365abd6973d98329", size = 10906223, upload-time = "2026-10-09T19:47:00.263Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3c/4a01195d93420cad1175bedad13a515dc8a56f95a6e39789b92e582682f5/ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc73e7c133e82d55b5f15897b2a442d72c0cb4a0c886c46801ce3c247150b60c", size = 10386314, upload-time = "2026-10-09T19:47:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/c7/72/1a3951665485a921f6375f91e754a1854d5a645d41acc3642668064ff64d/ruff-0.17.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:db4f74c533403ab70fe4007873f6ae0c9f94a8b03158cf48d78788e47cdbe399", size = 10565464, upload-time = "2026-10-09T19:47:05.831Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/539b4d8c082f57e18db8ae2be85a460d77861c79dcd5798e32b536a6a06f/ruff-0.17.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3d8cc360e666d1914e47b0777c6906d70cf18891a55532bd0a16844195d70859", size = 10079468, upload-time = "2026-10-09T19:47:08.617Z" }, + { url = "https://files.pythonhosted.org/packages/e0/b8/84286966db79434e8c26b585b0a0f6897cb3ab1c51a4aa4df10c28488b62/ruff-0.17.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d66de796b726c4801e05fa99a2a8d7a780e107be222486c304ab61765561e866", size = 9958556, upload-time = "2026-10-09T19:47:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/69/50/27b6eed27b83fcdd5bfa0d52b83231e29094754374698da404d094487ae3/ruff-0.17.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c3f268baf004aea944f040623327119527ea231af15f7fb7890e82cea0679589", size = 10352173, upload-time = "2026-10-09T19:47:14.188Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/955399fd13044cd827862044117d784a59e3196f6cce7424908ac9a7f914/ruff-0.17.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:864b6c1acb6b0bccf94b5a3938a1531fd09aaca5e5659a2e7bf0f3cf2a685540", size = 10791761, upload-time = "2026-10-09T19:47:16.931Z" }, + { url = "https://files.pythonhosted.org/packages/ae/bf/024e01e1f6aec87768696725e648ed5b438941341eea8f8100beb681961f/ruff-0.17.0-py3-none-win32.whl", hash = "sha256:5e50aa5b84decd9fe5b0bb0e6f71c3b592f1767ed09faa4b7207d933961e35cd", size = 9904112, upload-time = "2026-10-09T19:47:19.75Z" }, + { url = "https://files.pythonhosted.org/packages/cc/77/1ee73df41dcc8d1cdb686ee4bc46ea29704ea175feb6b95c78420f631ab8/ruff-0.17.0-py3-none-win_amd64.whl", hash = "sha256:8ab76bcda86dfd28e13776cb5de3c7bcdcf1ae3d37ed761113d1a5a415dc134c", size = 10115585, upload-time = "2026-10-09T19:47:22.698Z" }, + { url = "https://files.pythonhosted.org/packages/fd/71/eb4f0ccc844aece56963e8578df9c95d4c00f580547d52329d4035d3af18/ruff-0.17.0-py3-none-win_arm64.whl", hash = "sha256:c154c73ff43f9854395e24cac507af13078962e53d2b511605058d22af1fdb88", size = 9872035, upload-time = "2026-10-09T19:47:26.306Z" }, ] [[package]] @@ -71,7 +93,8 @@ source = { virtual = "." } [package.dev-dependencies] dev = [ - { name = "basedpyright" }, + { name = "basedpyright", version = "1.39.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "basedpyright", version = "1.40.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "ruff" }, { name = "sublime-text-stubs" }, ]