536 lines
18 KiB
Python
536 lines
18 KiB
Python
#!/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
|
|
# (<kcfgfile arg="true">), 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:]))
|