Skip to content

Instantly share code, notes, and snippets.

@XTheocharis
Last active August 22, 2026 20:16
Show Gist options
  • Select an option

  • Save XTheocharis/1a09b92e1182da4e4c6923efb9da2527 to your computer and use it in GitHub Desktop.

Select an option

Save XTheocharis/1a09b92e1182da4e4c6923efb9da2527 to your computer and use it in GitHub Desktop.
Edit Steam's JumplistSettings bitmask on Linux
#!/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