#!/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 every shortcut registered with kglobalaccel, reporting each one whose live value differs from its default, tagged declared (present in the manifest) or undeclared. Also reports already-declared freeform settings whose live value differs from their default (no schema to broad-scan, so it's 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 _split_on_known_prefix(rest, candidates): matches = [c for c in candidates if rest == c or rest.startswith(c + ".")] if not matches: return None prefix = max(matches, key=len) remainder = rest[len(prefix):].lstrip(".") if not remainder: return None return prefix, remainder def _known_schema_groups(file, kcfg_map): groups = set() for path in kcfg_map.get(file, []): root = _parse_kcfg(path) if root is None: continue for group_elem in root.iter(f"{KCFG_NS}group"): name = group_elem.get("name") if name: groups.add(name) return groups def _split_schema_group_key(file, rest, kcfg_map): match = _split_on_known_prefix(rest, _known_schema_groups(file, kcfg_map)) if match is not None: return match # No schema group matches -- freeform. Its group is never known to contain # a dot (there's no schema to have told us otherwise), so the boundary is # just the first remaining dot. group, _, key = rest.partition(".") if not key: raise ValueError(f"invalid identifier {file}.{rest!r} (expected file.group.key)") return group, key def _split_shortcut_group_key(rest): (components,) = _kglobalaccel_call("allMainComponents", None) match = _split_on_known_prefix(rest, [component[0] for component in components]) if match is None: raise RuntimeError( f"no live kglobalaccel component matches {rest!r} " "(the owning application may need to run once to register its shortcuts with kglobalaccel)" ) return match # Only the file segment is unambiguous (rc file names never contain a dot). # The group/key boundary can't be found by counting dots -- both KConfig group # names (e.g. "org.kde.kdecoration2") and kglobalaccel componentUnique names # (e.g. "org.kde.dolphin.desktop") routinely contain their own dots -- so it's # resolved against known-good data instead: the live kglobalaccel component # list for shortcuts, the kcfg schema's declared group names for everything # else (falling back to freeform's first-dot split when no schema matches). def parse_identifier(identifier, kcfg_map): file, sep, rest = identifier.partition(".") if not sep or not rest: raise ValueError(f"invalid identifier {identifier!r} (expected file.group.key)") if file == "kglobalshortcutsrc": group, key = _split_shortcut_group_key(rest) else: group, key = _split_schema_group_key(file, rest, kcfg_map) return Setting(file, group, key) 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 Setting(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 Setting("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, kcfg_map) 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, kcfg_map) 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 setting in sorted(set(iter_schema_identifiers(kcfg_map))): identifier = f"{setting.file}.{setting.group}.{setting.key}" 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})") # Shortcuts are enumerable via kglobalaccel's allMainComponents/ # allActionsForComponent (the same source iter_shortcut_identifiers already # walks for tab-completion), so unlike freeform they can participate in # broad undeclared-drift discovery too. try: shortcut_settings = sorted(set(iter_shortcut_identifiers())) except (RuntimeError, OSError) as e: print(f"dot kde diff: shortcuts scan unavailable: {e}", file=sys.stderr) shortcut_settings = [] for setting in shortcut_settings: identifier = f"{setting.file}.{setting.group}.{setting.key}" try: live = read_shortcut_value(setting.group, setting.key) default = read_shortcut_value(setting.group, setting.key, method="defaultShortcutKeys") 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 settings have no schema to enumerate from, so unlike the # schema-backed and shortcuts scans above, they can only be checked by # walking identifiers already in the manifest -- they never surface an # undeclared setting via broad scan. Shortcuts entries are skipped here # (rather than re-parsed) since the broad-scan pass above already reports # every declared shortcut mismatch; parsing one here would also mean an # extra live kglobalaccel round-trip per entry for no benefit. for identifier in manifest: if identifier.split(".", 1)[0] == "kglobalshortcutsrc": continue try: setting = parse_identifier(identifier, kcfg_map) mechanism, default = resolve_mechanism(setting, kcfg_map) if mechanism != "freeform": continue live = read_live_value(setting, default) if live == "": 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 setting in sorted(set(iter_schema_identifiers(kcfg_map))): print(f"{setting.file}.{setting.group}.{setting.key}") 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_settings = sorted(set(iter_shortcut_identifiers())) except (RuntimeError, OSError): shortcut_settings = [] for setting in shortcut_settings: print(f"{setting.file}.{setting.group}.{setting.key}") 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:]))