#!/usr/bin/env python3 import json import os import subprocess import sys import xml.etree.ElementTree as ET from collections import defaultdict, namedtuple from pathlib import Path KCFG_NS = "{http://www.kde.org/standards/kcfg/1.0}" DEFAULT_SCHEMA_DIR = "/usr/share/config.kcfg" KGLOBALACCEL_SERVICE = "org.kde.kglobalaccel" KGLOBALACCEL_PATH = "/kglobalaccel" KGLOBALACCEL_IFACE = "org.kde.KGlobalAccel" # KGlobalAccel::GlobalShortcutLoading::NoAutoloading, per KF6/KGlobalAccel/kglobalaccel.h -- # makes a write always win over whatever shortcut was previously saved, rather than being # ignored in favor of it (the Autoloading=0x0 default). SHORTCUT_NO_AUTOLOADING = 0x4 # .kcfg files that only declare their target rc file at runtime # (), so it can't be discovered by scanning. ARG_TRUE_RCFILES = { "kwin.kcfg": "kwinrc", } SAVE_USAGE = """usage: dot kde save [identifier] identifier declare a new manifest entry, seeded from its current live value (no args) refresh every already-declared manifest entry from the live system help show this message""" APPLY_USAGE = """usage: dot kde apply Pushes every manifest entry's declared value onto the live system. help show this message""" DIFF_USAGE = """usage: dot kde diff Scans every schema-backed setting reachable through the kcfg mapping table and reports each one whose live value differs from its schema-declared default, tagged declared (present in the manifest) or undeclared. Also reports already-declared freeform and shortcut settings whose live value differs from their default (neither has a schema/mapping table to broad-scan, so both are only checked when already declared). Read-only -- never writes the manifest or the live system. help show this message""" Setting = namedtuple("Setting", ["file", "group", "key"]) def parse_identifier(identifier): parts = identifier.split(".", 2) if len(parts) != 3: raise ValueError(f"invalid identifier {identifier!r} (expected file.group.key)") return Setting(*parts) def load_manifest(path): entries = {} if not path.exists(): return entries for line in path.read_text().splitlines(): if not line.strip(): continue identifier, _, value = line.partition("=") entries[identifier] = value return entries def write_manifest(path, entries): lines = [f"{identifier}={value}" for identifier, value in entries.items()] path.write_text("".join(f"{line}\n" for line in lines)) def _parse_kcfg(path): try: return ET.parse(path).getroot() except ET.ParseError: return None def _kcfgfile_name(root): elem = root.find(f"{KCFG_NS}kcfgfile") if elem is None: return None return elem.get("name") def build_kcfg_map(schema_dir): mapping = defaultdict(list) if not schema_dir.is_dir(): return mapping for path in sorted(schema_dir.glob("*.kcfg")): root = _parse_kcfg(path) if root is None: continue rcfile = _kcfgfile_name(root) or ARG_TRUE_RCFILES.get(path.name) if rcfile: mapping[rcfile].append(path) return mapping def find_schema_default(kcfg_paths, setting): for path in kcfg_paths: root = _parse_kcfg(path) if root is None: continue for group_elem in root.iter(f"{KCFG_NS}group"): if group_elem.get("name") != setting.group: continue for entry in group_elem.findall(f"{KCFG_NS}entry"): if (entry.get("key") or entry.get("name")) != setting.key: continue default_elem = entry.find(f"{KCFG_NS}default") return default_elem.text if default_elem is not None and default_elem.text else "" return None def iter_schema_identifiers(kcfg_map): for rcfile, paths in kcfg_map.items(): for path in paths: root = _parse_kcfg(path) if root is None: continue for group_elem in root.iter(f"{KCFG_NS}group"): group = group_elem.get("name") if not group: continue for entry in group_elem.findall(f"{KCFG_NS}entry"): key = entry.get("key") or entry.get("name") if key: yield f"{rcfile}.{group}.{key}" def resolve_mechanism(setting, kcfg_map): if setting.file == "kglobalshortcutsrc": return "shortcuts", None default = find_schema_default(kcfg_map.get(setting.file, []), setting) if default is not None: return "schema", default return "freeform", None def read_live_value(setting, default): cmd = ["kreadconfig6", "--file", setting.file, "--group", setting.group, "--key", setting.key] if default is not None: cmd += ["--default", default] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: raise RuntimeError( f"kreadconfig6 failed for {setting.file}/{setting.group}/{setting.key}: {result.stderr.strip()}" ) return result.stdout.rstrip("\n") def write_live_value(setting, value): cmd = [ "kwriteconfig6", "--file", setting.file, "--group", setting.group, "--key", setting.key, "--", value, ] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: raise RuntimeError( f"kwriteconfig6 failed for {setting.file}/{setting.group}/{setting.key}: {result.stderr.strip()}" ) def _key_sequence_class(): try: from PyQt6.QtGui import QKeySequence except ImportError as e: raise RuntimeError( "the shortcuts mechanism requires PyQt6 (install python-pyqt6) to translate key names" ) from e return QKeySequence def _keys_to_string(key_ints): QKeySequence = _key_sequence_class() return "\t".join(QKeySequence(key).toString() for key in key_ints) def _string_to_keys(value): if not value: return [] QKeySequence = _key_sequence_class() keys = [] for part in value.split("\t"): part = part.strip() if not part or part.lower() == "none": continue sequence = QKeySequence(part) if sequence.count() != 1: raise RuntimeError(f"invalid key sequence {part!r} (expected exactly one key combination)") keys.append(int(sequence[0].toCombined())) return keys def _kglobalaccel_call(method, signature, *tokens): cmd = ["busctl", "--user", "--json=short", "call", KGLOBALACCEL_SERVICE, KGLOBALACCEL_PATH, KGLOBALACCEL_IFACE, method] if signature: cmd += [signature, *(str(token) for token in tokens)] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: raise RuntimeError(f"kglobalaccel {method} failed: {result.stderr.strip()}") return json.loads(result.stdout)["data"] def _actions_for_component(component_unique): (actions,) = _kglobalaccel_call("allActionsForComponent", "as", 1, component_unique) return actions def iter_shortcut_identifiers(): (components,) = _kglobalaccel_call("allMainComponents", None) for component in components: for action in _actions_for_component(component[0]): yield f"kglobalshortcutsrc.{action[0]}.{action[1]}" def _resolve_shortcut_action_id(component_unique, action_unique): for action in _actions_for_component(component_unique): if action[0] == component_unique and action[1] == action_unique: return action raise RuntimeError( f"no shortcut action {action_unique!r} in component {component_unique!r} " "(the owning application may need to run once to register its shortcuts with kglobalaccel)" ) # The plural *Keys methods (a(ai), one 4-int QKeyCombination chord slot per bound # key sequence) are used instead of the singular shortcut()/defaultShortcut()/ # setShortcut() methods the flat ai signature suggests: on this KF6 build, # defaultShortcut() was empirically found to just mirror shortcut() -- returning # whatever the *current* value is rather than the true packaged default -- while # defaultShortcutKeys() correctly returns the untouched default even after # setShortcutKeys() has changed the current value. Only single, non-chorded key # combinations are supported (see _string_to_keys), so only the first of each # chord's 4 int slots is ever meaningful here; the rest are always 0. def _keys_from_chords(chords): return [chord[0][0] for chord in chords] def read_shortcut_value(component_unique, action_unique, method="shortcutKeys"): action_id = _resolve_shortcut_action_id(component_unique, action_unique) (chords,) = _kglobalaccel_call(method, "as", len(action_id), *action_id) return _keys_to_string(_keys_from_chords(chords)) def write_shortcut_value(component_unique, action_unique, value): action_id = _resolve_shortcut_action_id(component_unique, action_unique) keys = _string_to_keys(value) tokens = [len(action_id), *action_id, len(keys)] for key in keys: tokens += [4, key, 0, 0, 0] tokens.append(SHORTCUT_NO_AUTOLOADING) _kglobalaccel_call("setShortcutKeys", "asa(ai)u", *tokens) def save_one(identifier, kcfg_map): setting = parse_identifier(identifier) mechanism, default = resolve_mechanism(setting, kcfg_map) if mechanism == "shortcuts": return read_shortcut_value(setting.group, setting.key) return read_live_value(setting, default) def apply_one(identifier, value, kcfg_map): setting = parse_identifier(identifier) mechanism, _default = resolve_mechanism(setting, kcfg_map) if mechanism == "shortcuts": write_shortcut_value(setting.group, setting.key, value) return write_live_value(setting, value) def cmd_save(args, manifest_path, schema_dir): if args and args[0] == "help": print(SAVE_USAGE) return 0 if len(args) > 1: print("dot kde save: too many arguments", file=sys.stderr) return 1 kcfg_map = build_kcfg_map(schema_dir) manifest = load_manifest(manifest_path) try: if args: manifest[args[0]] = save_one(args[0], kcfg_map) else: for identifier in manifest: manifest[identifier] = save_one(identifier, kcfg_map) except (ValueError, RuntimeError) as e: print(f"dot kde save: {e}", file=sys.stderr) return 1 write_manifest(manifest_path, manifest) return 0 def cmd_apply(args, manifest_path, schema_dir): if args and args[0] == "help": print(APPLY_USAGE) return 0 if args: print("dot kde apply: too many arguments", file=sys.stderr) return 1 kcfg_map = build_kcfg_map(schema_dir) manifest = load_manifest(manifest_path) try: for identifier, value in manifest.items(): apply_one(identifier, value, kcfg_map) except (ValueError, RuntimeError) as e: print(f"dot kde apply: {e}", file=sys.stderr) return 1 return 0 def cmd_diff(args, manifest_path, schema_dir): if args and args[0] == "help": print(DIFF_USAGE) return 0 if args: print("dot kde diff: too many arguments", file=sys.stderr) return 1 kcfg_map = build_kcfg_map(schema_dir) manifest = load_manifest(manifest_path) for identifier in sorted(set(iter_schema_identifiers(kcfg_map))): setting = parse_identifier(identifier) default = find_schema_default(kcfg_map.get(setting.file, []), setting) try: live = read_live_value(setting, default) except RuntimeError as e: print(f"dot kde diff: {e}", file=sys.stderr) continue if live == default: continue tag = "declared" if identifier in manifest else "undeclared" print(f"{tag} {identifier} = {live} (default: {default})") # Freeform and shortcuts settings have no schema/mapping table to enumerate, # so unlike the schema-backed loop above, they can only be checked by walking # identifiers already in the manifest -- neither ever surfaces an undeclared # setting via broad scan. for identifier in manifest: try: setting = parse_identifier(identifier) mechanism, default = resolve_mechanism(setting, kcfg_map) if mechanism == "shortcuts": live = read_shortcut_value(setting.group, setting.key) default = read_shortcut_value(setting.group, setting.key, method="defaultShortcutKeys") if live == default: continue elif mechanism == "freeform": live = read_live_value(setting, default) if live == "": continue else: continue except (ValueError, RuntimeError) as e: print(f"dot kde diff: {e}", file=sys.stderr) continue print(f"declared {identifier} = {live} (default: {default or ''})") return 0 def cmd_complete(schema_dir): kcfg_map = build_kcfg_map(schema_dir) for identifier in sorted(set(iter_schema_identifiers(kcfg_map))): print(identifier) try: # Fish's completion runs this on every TAB press, in shells that may have no # live KDE session (or no busctl at all) -- a broken shortcuts source must # never cost the schema-backed candidates already printed above. shortcut_identifiers = sorted(set(iter_shortcut_identifiers())) except (RuntimeError, OSError): shortcut_identifiers = [] for identifier in shortcut_identifiers: print(identifier) return 0 def main(argv): if not argv: print("dot kde: no command given", file=sys.stderr) return 1 command, rest = argv[0], argv[1:] schema_dir = Path(os.environ.get("DOT_KDE_KCFG_DIR", DEFAULT_SCHEMA_DIR)) manifest_path = Path(os.environ["HOME"]) / ".config" / "dot" / "kde-manifest" if command == "save": return cmd_save(rest, manifest_path, schema_dir) if command == "apply": return cmd_apply(rest, manifest_path, schema_dir) if command == "diff": return cmd_diff(rest, manifest_path, schema_dir) # Internal, not a user-facing `dot kde` subcommand -- called directly by # completions/dot.fish to source candidates from the live schema, never # dispatched to via kde.fish. if command == "complete": return cmd_complete(schema_dir) print(f"dot kde: unknown command {command!r}", file=sys.stderr) return 1 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))