Last active
August 22, 2026 20:16
-
-
Save XTheocharis/1a09b92e1182da4e4c6923efb9da2527 to your computer and use it in GitHub Desktop.
Edit Steam's JumplistSettings bitmask on Linux
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Edit Steam's JumplistSettings bitmask on Linux. | |
| Valve dropped the Taskbar Preferences GUI from the Linux client after a UI | |
| redesign; the client still honors the underlying bits. | |
| Upstream: https://github.com/ValveSoftware/steam-for-linux/issues/9860 | |
| Mask location — auto-located under: | |
| ~/.local/share/Steam | |
| ~/.steam | |
| ~/.var/app/com.valvesoftware.Steam | |
| ~/snap/steam | |
| <root>/userdata/<account_id>/config/localconfig.vdf | |
| UserLocalConfigStore → system | |
| Both JumplistSettings (active mask) and JumplistSettingsKnown (valid | |
| bits) are written; JumplistSettingsKnown is pinned to 229375 (every known | |
| bit except bit 15). If it is absent or narrower than JumplistSettings, | |
| Steam resets the mask to 262143 (all entries) on launch. | |
| Controllable items, name → bit: | |
| online 0, away 1, offline 3, store 4, community 5, library 6, | |
| servers 7, friends 9, exit 10, settings 11, screenshots 12, | |
| big_picture 13, friend_activity 14, vr 16, invisible 17 | |
| Bits 2, 8, 15: no visible effect. Games list is always present; no bit | |
| controls it. Bit 10 (Exit Steam) is unreliable: on some (undetermined) | |
| installs the Exit Steam entry is always present regardless of this bit. | |
| Timestamped backup by default (--no-backup to skip). The script refuses to | |
| write while steam or steamwebhelper are running; quit Steam completely | |
| before editing, otherwise its exit-time write races and overwrites the | |
| edit. Relaunch after. | |
| Usage: | |
| steam_jumplist.py --list | |
| steam_jumplist.py --show online friends vr | |
| steam_jumplist.py --add screenshots --remove store | |
| steam_jumplist.py --all-visible | |
| steam_jumplist.py --raw 0x3AF11 # decimal / 0x / 0b | |
| steam_jumplist.py -i # interactive menu | |
| steam_jumplist.py --find-configs | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import datetime as dt | |
| import os | |
| import re | |
| import shutil | |
| import subprocess | |
| import sys | |
| import tempfile | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Iterable, NamedTuple, Sequence | |
| JUMPLIST_SETTINGS_KNOWN = "229375" | |
| STEAM_PROCESS_NAMES = frozenset({"steam", "steamwebhelper"}) | |
| SEARCH_ROOTS = ( | |
| Path("~/.local/share/Steam"), | |
| Path("~/.steam"), | |
| Path("~/.var/app/com.valvesoftware.Steam"), | |
| Path("~/snap/steam"), | |
| ) | |
| NO_VISIBLE_EFFECT_BITS = frozenset({2, 8, 15}) | |
| class UserFacingError(RuntimeError): | |
| """Printed without a traceback.""" | |
| @dataclass(frozen=True, slots=True) | |
| class JumplistItem: | |
| name: str | |
| label: str | |
| bit: int | |
| note: str | None = None | |
| @property | |
| def value(self) -> int: | |
| return 1 << self.bit | |
| class VdfBlock(NamedTuple): | |
| open_brace: int | |
| close_brace: int | |
| JUMPLIST_ITEMS = ( | |
| JumplistItem("online", "Online", 0), | |
| JumplistItem("away", "Away", 1), | |
| JumplistItem("offline", "Offline", 3), | |
| JumplistItem("store", "Store", 4), | |
| JumplistItem("community", "Community", 5), | |
| JumplistItem("library", "Library", 6), | |
| JumplistItem("servers", "Servers", 7), | |
| JumplistItem("friends", "Friends", 9), | |
| JumplistItem("exit", "Exit Steam", 10, note="unreliable; always present on some (undetermined) installs"), | |
| JumplistItem("settings", "Settings", 11), | |
| JumplistItem("screenshots", "Screenshots", 12), | |
| JumplistItem("big_picture", "Big Picture", 13), | |
| JumplistItem("friend_activity", "Friend activity", 14), | |
| JumplistItem("vr", "SteamVR", 16), | |
| JumplistItem("invisible", "Invisible", 17), | |
| ) | |
| ITEM_BY_NAME = {item.name: item for item in JUMPLIST_ITEMS} | |
| ITEM_BY_BIT = {item.bit: item for item in JUMPLIST_ITEMS} | |
| CONTROLLABLE_MASK = sum(item.value for item in JUMPLIST_ITEMS) | |
| def parse_int(value: str) -> int: | |
| try: | |
| result = int(value, 0) | |
| except ValueError as exc: | |
| raise argparse.ArgumentTypeError(f"invalid integer: {value!r}") from exc | |
| if result < 0: | |
| raise argparse.ArgumentTypeError(f"must be non-negative: {value!r}") | |
| return result | |
| def read_text(path: Path) -> str: | |
| return path.read_text(encoding="utf-8") | |
| def extract_account_id(path: Path) -> str | None: | |
| parts = path.parts | |
| try: | |
| index = parts.index("userdata") | |
| except ValueError: | |
| return None | |
| next_index = index + 1 | |
| if next_index >= len(parts): | |
| return None | |
| return parts[next_index] | |
| def is_localconfig_candidate(path: Path) -> bool: | |
| return ( | |
| path.name == "localconfig.vdf" | |
| and path.parent.name == "config" | |
| and extract_account_id(path) is not None | |
| ) | |
| def locate_localconfig_paths(account_id: str | None = None) -> list[Path]: | |
| found: dict[str, Path] = {} | |
| for root_template in SEARCH_ROOTS: | |
| root = root_template.expanduser() | |
| if not root.exists(): | |
| continue | |
| try: | |
| for candidate in root.rglob("localconfig.vdf"): | |
| try: | |
| if not candidate.is_file() or not is_localconfig_candidate(candidate): | |
| continue | |
| except OSError: | |
| continue | |
| if account_id and extract_account_id(candidate) != account_id: | |
| continue | |
| found.setdefault(str(candidate.resolve(strict=False)), candidate) | |
| except OSError: | |
| continue | |
| return sorted(found.values(), key=lambda path: (extract_account_id(path) or "", str(path))) | |
| def choose_config_interactively(matches: Sequence[Path]) -> Path: | |
| print("Multiple Steam localconfig.vdf files found:\n") | |
| for index, path in enumerate(matches, start=1): | |
| account = extract_account_id(path) or "unknown" | |
| print(f"{index:2}. account {account:<12} {path}") | |
| print() | |
| while True: | |
| choice = input("Select config number: ").strip() | |
| if not choice: | |
| continue | |
| try: | |
| index = int(choice) | |
| except ValueError: | |
| print("Enter a number.") | |
| continue | |
| if 1 <= index <= len(matches): | |
| print(f"Using config: {matches[index - 1]}") | |
| return matches[index - 1] | |
| print("Number out of range.") | |
| def resolve_config_path( | |
| explicit_config: Path | None, | |
| account_id: str | None, | |
| interactive: bool, | |
| ) -> Path: | |
| if explicit_config is not None: | |
| path = explicit_config.expanduser() | |
| if not path.exists(): | |
| raise UserFacingError(f"Config not found: {path}") | |
| return path | |
| matches = locate_localconfig_paths(account_id=account_id) | |
| if not matches: | |
| searched = "\n ".join(str(root) for root in SEARCH_ROOTS) | |
| suffix = f" for account id {account_id}" if account_id else "" | |
| raise UserFacingError( | |
| f"Could not auto-locate localconfig.vdf{suffix}.\n" | |
| f"Searched under:\n {searched}\n\n" | |
| "Pass it manually with --config /path/to/localconfig.vdf" | |
| ) | |
| if len(matches) == 1: | |
| print(f"Using config: {matches[0]}") | |
| return matches[0] | |
| if interactive: | |
| return choose_config_interactively(matches) | |
| try: | |
| selected = max(matches, key=lambda p: p.stat().st_mtime) | |
| except OSError: | |
| selected = matches[0] | |
| print("Multiple Steam localconfig.vdf files found; using most recently modified:") | |
| print(f" {selected}") | |
| print("Use --account-id or --config to choose explicitly.") | |
| return selected | |
| def normalize_item_name(raw_name: str) -> str: | |
| name = raw_name.strip().lower() | |
| if name in ITEM_BY_NAME: | |
| return name | |
| valid = ", ".join(item.name for item in JUMPLIST_ITEMS) | |
| raise UserFacingError(f"Unknown item: {raw_name!r}\nValid items: {valid}") | |
| def mask_from_names(names: Iterable[str]) -> int: | |
| mask = 0 | |
| for raw_name in names: | |
| mask |= ITEM_BY_NAME[normalize_item_name(raw_name)].value | |
| return mask | |
| def visible_names_from_mask(mask: int) -> list[str]: | |
| return [item.name for item in JUMPLIST_ITEMS if mask & item.value] | |
| def notes_for_enabled(mask: int) -> list[str]: | |
| return [ | |
| f"{item.label} (bit {item.bit}): {item.note}" | |
| for item in JUMPLIST_ITEMS | |
| if item.note and (mask & item.value) | |
| ] | |
| def find_matching_brace(text: str, open_brace: int) -> int: | |
| if open_brace < 0 or open_brace >= len(text) or text[open_brace] != "{": | |
| raise ValueError("open_brace must point at an opening brace") | |
| depth = 0 | |
| in_string = False | |
| escaped = False | |
| for index in range(open_brace, len(text)): | |
| char = text[index] | |
| if in_string: | |
| if escaped: | |
| escaped = False | |
| elif char == "\\": | |
| escaped = True | |
| elif char == '"': | |
| in_string = False | |
| continue | |
| if char == '"': | |
| in_string = True | |
| elif char == "{": | |
| depth += 1 | |
| elif char == "}": | |
| depth -= 1 | |
| if depth == 0: | |
| return index | |
| raise ValueError("Could not find matching closing brace") | |
| def find_named_block( | |
| text: str, | |
| name: str, | |
| start: int = 0, | |
| end: int | None = None, | |
| ) -> VdfBlock: | |
| effective_end = len(text) if end is None else end | |
| match = re.compile(r'"' + re.escape(name) + r'"\s*\{').search(text, start, effective_end) | |
| if match is None: | |
| raise KeyError(name) | |
| open_brace = text.find("{", match.start(), match.end()) | |
| close_brace = find_matching_brace(text, open_brace) | |
| if close_brace > effective_end: | |
| raise KeyError(name) | |
| return VdfBlock(open_brace, close_brace) | |
| def line_start_before(text: str, index: int) -> int: | |
| return text.rfind("\n", 0, index) + 1 | |
| def line_indent_before(text: str, index: int) -> str: | |
| line_start = line_start_before(text, index) | |
| match = re.match(r"[ \t]*", text[line_start:index]) | |
| return match.group(0) if match else "" | |
| def upsert_key_in_block(text: str, block: VdfBlock, key: str, value: str) -> str: | |
| body_start = block.open_brace + 1 | |
| body = text[body_start:block.close_brace] | |
| match = re.search(r'(?m)^([ \t]*)"' + re.escape(key) + r'"\s+"[^"]*"', body) | |
| if match is not None: | |
| replacement = f'{match.group(1)}"{key}"\t\t"{value}"' | |
| return text[:body_start] + body[: match.start()] + replacement + body[match.end():] + text[block.close_brace:] | |
| insert_at = line_start_before(text, block.close_brace) | |
| indent = line_indent_before(text, block.close_brace) | |
| entry = f'{indent}\t"{key}"\t\t"{value}"\n' | |
| return text[:insert_at] + entry + text[insert_at:] | |
| def find_user_config_store(text: str) -> VdfBlock: | |
| try: | |
| return find_named_block(text, "UserLocalConfigStore") | |
| except KeyError as exc: | |
| raise UserFacingError('Could not find "UserLocalConfigStore" in localconfig.vdf') from exc | |
| def ensure_system_block(text: str) -> str: | |
| user_block = find_user_config_store(text) | |
| try: | |
| find_named_block(text, "system", user_block.open_brace + 1, user_block.close_brace) | |
| return text | |
| except KeyError: | |
| pass | |
| insert_at = line_start_before(text, user_block.close_brace) | |
| indent = line_indent_before(text, user_block.close_brace) + "\t" | |
| new_block = f'{indent}"system"\n{indent}{{\n{indent}}}\n' | |
| return text[:insert_at] + new_block + text[insert_at:] | |
| def upsert_system_value(text: str, key: str, value: str) -> str: | |
| user_block = find_user_config_store(text) | |
| system_block = find_named_block(text, "system", user_block.open_brace + 1, user_block.close_brace) | |
| return upsert_key_in_block(text, system_block, key, value) | |
| def set_jumplist_values(text: str, settings: int) -> str: | |
| text = ensure_system_block(text) | |
| text = upsert_system_value(text, "JumplistSettings", str(settings)) | |
| text = upsert_system_value(text, "JumplistSettingsKnown", JUMPLIST_SETTINGS_KNOWN) | |
| return text | |
| def read_int_value(text: str, key: str) -> int | None: | |
| match = re.search(r'"' + re.escape(key) + r'"\s+"(\d+)"', text) | |
| return int(match.group(1)) if match else None | |
| def ensure_steam_not_running() -> None: | |
| running = [ | |
| name | |
| for name in sorted(STEAM_PROCESS_NAMES) | |
| if subprocess.run( | |
| ["pgrep", "-x", name], | |
| stdout=subprocess.DEVNULL, | |
| stderr=subprocess.DEVNULL, | |
| ).returncode == 0 | |
| ] | |
| if not running: | |
| return | |
| raise UserFacingError( | |
| f"Steam process(es) still running: {', '.join(running)}\n" | |
| "Quit Steam completely (including steamwebhelper) and re-run this script.\n" | |
| "Steam's exit-time write will otherwise race and overwrite this edit." | |
| ) | |
| def backup_and_write(config_path: Path, original_text: str, new_text: str, no_backup: bool) -> bool: | |
| if new_text == original_text: | |
| print("No changes needed.") | |
| return False | |
| ensure_steam_not_running() | |
| if not no_backup: | |
| stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S") | |
| backup_path = config_path.with_suffix(config_path.suffix + f".bak-{stamp}") | |
| shutil.copy2(config_path, backup_path) | |
| print(f"Backup written: {backup_path}") | |
| fd, tmp_name = tempfile.mkstemp(dir=config_path.parent, prefix=".steam_jumplist_") | |
| tmp_path = Path(tmp_name) | |
| try: | |
| with os.fdopen(fd, "w", encoding="utf-8") as f: | |
| f.write(new_text) | |
| f.flush() | |
| os.fsync(f.fileno()) | |
| os.replace(tmp_path, config_path) | |
| except BaseException: | |
| tmp_path.unlink(missing_ok=True) | |
| raise | |
| print(f"Wrote: {config_path}") | |
| return True | |
| def write_config(config_path: Path, original_text: str, new_mask: int, no_backup: bool) -> bool: | |
| new_text = set_jumplist_values(original_text, new_mask) | |
| if not backup_and_write(config_path, original_text, new_text, no_backup): | |
| return False | |
| print(f"JumplistSettings={new_mask}") | |
| print(f"JumplistSettingsKnown={JUMPLIST_SETTINGS_KNOWN}") | |
| print("Enabled:", ", ".join(visible_names_from_mask(new_mask)) or "(none)") | |
| print("Always present: Games list") | |
| notes = notes_for_enabled(new_mask) | |
| if notes: | |
| print("Notes:") | |
| for note in notes: | |
| print(f" {note}") | |
| print("Restart Steam for it to take effect.") | |
| return True | |
| def show_status(mask: int, known_mask: int | None) -> None: | |
| print(f"JumplistSettings={mask}") | |
| print(f"JumplistSettingsKnown={known_mask if known_mask is not None else '(not set)'}") | |
| print("Always present: Games list") | |
| visible = visible_names_from_mask(mask) | |
| print("Visible enabled:", ", ".join(visible) if visible else "(none)") | |
| other_bits = [] | |
| for bit in range(max(mask.bit_length(), 18)): | |
| if not mask & (1 << bit): | |
| continue | |
| if bit in ITEM_BY_BIT: | |
| continue | |
| note = "no visible effect" if bit in NO_VISIBLE_EFFECT_BITS else "unknown/unmapped" | |
| other_bits.append(f"bit {bit}: {note}") | |
| if other_bits: | |
| print("Other set bits:") | |
| for line in other_bits: | |
| print(f" {line}") | |
| notes = notes_for_enabled(mask) | |
| if notes: | |
| print("Notes:") | |
| for note in notes: | |
| print(f" {note}") | |
| def print_menu(mask: int, dirty: bool) -> None: | |
| print() | |
| print("=" * 72) | |
| print(f"Steam jumplist editor{' *unsaved*' if dirty else ''}") | |
| print(f"Current raw value: {mask}") | |
| print("Always present: Games list") | |
| print() | |
| for index, item in enumerate(JUMPLIST_ITEMS, start=1): | |
| checked = "x" if mask & item.value else " " | |
| print(f"{index:2}. [{checked}] {item.label:<16} bit {item.bit:<2} value {item.value}") | |
| notes = [ | |
| f"{item.label} (bit {item.bit}): {item.note}" | |
| for item in JUMPLIST_ITEMS | |
| if item.note | |
| ] | |
| if notes: | |
| print() | |
| print("Notes:") | |
| for note in notes: | |
| print(f" {note}") | |
| print() | |
| print("Commands:") | |
| print(" number(s) Toggle item(s), e.g. 1 or 1 5 12") | |
| print(" name(s) Toggle by name, e.g. online friends vr") | |
| print(" a Enable all visible tested entries") | |
| print(" n Disable all visible tested entries") | |
| print(" r VALUE Set raw value, decimal or 0x...") | |
| print(" s Save") | |
| print(" x Save and quit") | |
| print(" q Quit without saving") | |
| print("=" * 72) | |
| def apply_toggle_tokens(mask: int, tokens: Sequence[str]) -> tuple[int, bool]: | |
| changed = False | |
| for token in tokens: | |
| try: | |
| index = int(token) | |
| except ValueError: | |
| try: | |
| item = ITEM_BY_NAME[normalize_item_name(token)] | |
| except UserFacingError as exc: | |
| print(exc) | |
| continue | |
| else: | |
| if not 1 <= index <= len(JUMPLIST_ITEMS): | |
| print(f"Menu number out of range: {token}") | |
| continue | |
| item = JUMPLIST_ITEMS[index - 1] | |
| mask ^= item.value | |
| print(f"Toggled {item.label}") | |
| changed = True | |
| return mask, changed | |
| def interactive_mode(config_path: Path, no_backup: bool) -> int: | |
| original_text = read_text(config_path) | |
| current_mask = read_int_value(original_text, "JumplistSettings") | |
| known_mask = read_int_value(original_text, "JumplistSettingsKnown") | |
| mask = current_mask or 0 | |
| dirty = False | |
| print(f"Editing: {config_path}") | |
| show_status(mask, known_mask) | |
| while True: | |
| print_menu(mask, dirty) | |
| choice = input("> ").strip() | |
| if not choice: | |
| continue | |
| command = choice.lower() | |
| if command in {"q", "quit"}: | |
| if dirty: | |
| confirm = input("Discard unsaved changes? [y/N] ").strip().lower() | |
| if confirm not in {"y", "yes"}: | |
| continue | |
| return 0 | |
| if command in {"s", "save"}: | |
| write_config(config_path, original_text, mask, no_backup) | |
| original_text = read_text(config_path) | |
| dirty = False | |
| continue | |
| if command in {"x", "savequit", "save-and-quit", "save_and_quit"}: | |
| write_config(config_path, original_text, mask, no_backup) | |
| return 0 | |
| if command in {"a", "all"}: | |
| mask |= CONTROLLABLE_MASK | |
| dirty = True | |
| continue | |
| if command in {"n", "none", "clear"}: | |
| mask &= ~CONTROLLABLE_MASK | |
| dirty = True | |
| continue | |
| if command.startswith("r "): | |
| try: | |
| mask = parse_int(command.split(maxsplit=1)[1]) | |
| except argparse.ArgumentTypeError as exc: | |
| print(exc) | |
| continue | |
| dirty = True | |
| continue | |
| mask, changed = apply_toggle_tokens(mask, command.split()) | |
| dirty = dirty or changed | |
| def print_found_configs(account_id: str | None) -> int: | |
| matches = locate_localconfig_paths(account_id=account_id) | |
| if not matches: | |
| print("No localconfig.vdf files found.") | |
| return 1 | |
| for path in matches: | |
| account = extract_account_id(path) or "unknown" | |
| print(f"{account}\t{path}") | |
| return 0 | |
| def build_parser() -> argparse.ArgumentParser: | |
| parser = argparse.ArgumentParser( | |
| description="Configure Steam JumplistSettings in localconfig.vdf" | |
| ) | |
| location = parser.add_argument_group("config selection") | |
| location.add_argument("--config", type=Path, help="path to localconfig.vdf; auto-located if omitted") | |
| location.add_argument("--account-id", help="Steam userdata account id to select when auto-locating") | |
| location.add_argument("--find-configs", action="store_true", help="print discovered localconfig.vdf files and exit") | |
| actions = parser.add_argument_group("actions") | |
| actions.add_argument("-i", "--interactive", action="store_true", help="open interactive toggle menu") | |
| actions.add_argument("--list", action="store_true", help="show current decoded value and exit") | |
| actions.add_argument("--show", nargs="+", help="set exact visible jumplist items to show") | |
| actions.add_argument("--add", nargs="+", default=[], help="items to add to current mask") | |
| actions.add_argument("--remove", nargs="+", default=[], help="items to remove from current mask") | |
| actions.add_argument("--raw", type=parse_int, help="set raw JumplistSettings value; accepts decimal or 0x...") | |
| actions.add_argument("--all-visible", action="store_true", help="enable all visible tested entries") | |
| actions.add_argument("--clear-visible", action="store_true", help="disable all visible tested entries") | |
| actions.add_argument("--no-backup", action="store_true", help="skip timestamped backup") | |
| return parser | |
| def has_direct_action(args: argparse.Namespace) -> bool: | |
| return any( | |
| ( | |
| args.list, | |
| args.interactive, | |
| args.raw is not None, | |
| args.show, | |
| args.add, | |
| args.remove, | |
| args.all_visible, | |
| args.clear_visible, | |
| ) | |
| ) | |
| def compute_new_mask(args: argparse.Namespace, current_mask: int) -> int: | |
| if args.raw is not None: | |
| return args.raw | |
| if args.show: | |
| return mask_from_names(args.show) | |
| new_mask = current_mask | |
| if args.all_visible: | |
| new_mask |= CONTROLLABLE_MASK | |
| if args.clear_visible: | |
| new_mask &= ~CONTROLLABLE_MASK | |
| if args.add: | |
| new_mask |= mask_from_names(args.add) | |
| if args.remove: | |
| new_mask &= ~mask_from_names(args.remove) | |
| return new_mask | |
| def run(args: argparse.Namespace) -> int: | |
| if args.find_configs: | |
| return print_found_configs(args.account_id) | |
| wants_interactive = args.interactive or not has_direct_action(args) | |
| config_path = resolve_config_path( | |
| explicit_config=args.config, | |
| account_id=args.account_id, | |
| interactive=wants_interactive, | |
| ) | |
| if wants_interactive: | |
| return interactive_mode(config_path, args.no_backup) | |
| text = read_text(config_path) | |
| current_mask = read_int_value(text, "JumplistSettings") or 0 | |
| known_mask = read_int_value(text, "JumplistSettingsKnown") | |
| if args.list: | |
| print(f"Config: {config_path}") | |
| show_status(current_mask, known_mask) | |
| return 0 | |
| write_config(config_path, text, compute_new_mask(args, current_mask), args.no_backup) | |
| return 0 | |
| def main(argv: Sequence[str] | None = None) -> int: | |
| args = build_parser().parse_args(argv) | |
| try: | |
| return run(args) | |
| except UserFacingError as exc: | |
| print(exc, file=sys.stderr) | |
| return 1 | |
| except KeyboardInterrupt: | |
| print("\nInterrupted.", file=sys.stderr) | |
| return 130 | |
| except EOFError: | |
| print("\nEOF.", file=sys.stderr) | |
| return 1 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment