#!/usr/bin/env python3 """Capability-aware KDE Connect boundary for the Panama shell.""" from __future__ import annotations import json import pathlib import re import subprocess import sys from collections.abc import Callable DEVICE_ID = re.compile(r"^[A-Fa-f0-9]{32,64}$") DEVICE_LINE = re.compile( r"^-\s+(?P.+):\s+(?P[A-Fa-f0-9]{32,64})" r"(?:\s+on\s+.+?)?\s+\((?P[^)]+)\)\s*$" ) PLUGIN_ACTIONS = { "kdeconnect_clipboard": "clipboard", "kdeconnect_findmyphone": "ring", "kdeconnect_ping": "ping", "kdeconnect_share": "share", } DEVICE_OBJECT_PREFIX = "/modules/kdeconnect/devices" DEVICE_OBJECT_LINE = re.compile( rf"(?P{re.escape(DEVICE_OBJECT_PREFIX)}/(?P[A-Fa-f0-9]{{32,64}}))$" ) BATTERY_INTERFACE = "org.kde.kdeconnect.device.battery" CONNECTIVITY_INTERFACE = "org.kde.kdeconnect.device.connectivity_report" # busctl spells every integer width with its own type code; a property that is # an int32 today may be read back as a uint32 by another kdeconnectd build. INT_PROPERTY_TYPES = frozenset({"y", "n", "q", "i", "u", "x", "t"}) # Vitals are enrichment, not the answer: a phone that has just gone out of range # can leave a plugin object that blocks, and the status read must not stall # behind it. Shorter than the eight seconds the identity reads are allowed. VITALS_TIMEOUT = 3 # Every device carries both keys so the shell never has to tell "not read" from # "no such field"; absent vitals are null, not missing. EMPTY_VITALS: dict[str, object] = {"battery": None, "signal": None} Runner = Callable[..., subprocess.CompletedProcess[str]] def compact_json(value: dict[str, object]) -> str: return json.dumps(value, separators=(",", ":")) def validate_device_id(value: str) -> str: if not DEVICE_ID.fullmatch(value): raise ValueError("invalid-device") return value def validate_file(value: pathlib.Path) -> pathlib.Path: try: resolved = value.expanduser().resolve(strict=True) except OSError as error: raise ValueError("invalid-file") from error if not resolved.is_file(): raise ValueError("invalid-file") return resolved def inferred_type(name: str) -> str: normalized = name.casefold() if "iphone" in normalized or "phone" in normalized: return "phone" if "tablet" in normalized or "ipad" in normalized: return "tablet" return "device" def normalize_device_line( line: str, plugins: list[str], device_type: str = "", ) -> dict[str, object]: match = DEVICE_LINE.fullmatch(line.strip()) if match is None: raise ValueError("invalid-device-line") state = match.group("state").casefold() actions = sorted( { action for plugin, action in PLUGIN_ACTIONS.items() if plugin in plugins } ) name = match.group("name").strip() return { "id": match.group("id"), "name": name, "type": device_type or inferred_type(name), "paired": "paired" in state, "reachable": "reachable" in state, "actions": actions, } # busctl properties are read with --json=short, not in its default text form. # # The text form escapes every non-ASCII byte in octal, and it escapes them into # the OUTPUT rather than into a quoted string a shell-style parser can undo -- so # a phone named "Gib's iPhone" with a typographic apostrophe arrived as the # literal characters "Gib\342\200\231s iPhone" and was displayed that way. # That is not specific to apostrophes: any name with an accent, an emoji, a # quote, or a backslash was affected the same way. # # The JSON form returns real UTF-8 and needs no unescaping, which is why these # parse a document rather than splitting words. def property_payload(output: str | None) -> dict[str, object] | None: """The decoded document of a busctl --json=short read, or None when the output is missing or is not a property document at all.""" try: payload = json.loads(output) except (json.JSONDecodeError, TypeError): return None return payload if isinstance(payload, dict) else None def parse_property(output: str | None, expected: str) -> object | None: """The value of a busctl --json=short property read, or None if it is not the type asked for.""" payload = property_payload(output) if payload is None or payload.get("type") != expected: return None return payload.get("data") def parse_loaded_plugins(output: str) -> list[str]: data = parse_property(output, "as") if not isinstance(data, list): return [] return [str(item) for item in data] def parse_string_property(output: str | None) -> str: data = parse_property(output, "s") return data if isinstance(data, str) else "" def parse_bool_property(output: str | None) -> bool | None: data = parse_property(output, "b") return data if isinstance(data, bool) else None def parse_int_property(output: str | None) -> int | None: """The integer value of a busctl --json=short property read, or None. JSON has one number type, so booleans are rejected explicitly: `true` is an int to isinstance and would otherwise read back as a charge of 1. """ payload = property_payload(output) if payload is None or payload.get("type") not in INT_PROPERTY_TYPES: return None data = payload.get("data") if isinstance(data, bool) or not isinstance(data, int): return None return data def run_command( command: list[str], *, runner: Runner = subprocess.run, timeout: float = 8, ) -> subprocess.CompletedProcess[str]: return runner( command, capture_output=True, text=True, timeout=timeout, check=False, ) def device_object(device_id: str) -> str: return f"{DEVICE_OBJECT_PREFIX}/{validate_device_id(device_id)}" def loaded_plugins(device_id: str, runner: Runner = subprocess.run) -> list[str]: result = run_command( [ "busctl", "--user", "--json=short", "call", "org.kde.kdeconnect", device_object(device_id), "org.kde.kdeconnect.device", "loadedPlugins", ], runner=runner, ) return parse_loaded_plugins(result.stdout) if result.returncode == 0 else [] def supported_plugins(device_id: str, runner: Runner = subprocess.run) -> list[str]: result = run_command( [ "busctl", "--user", "--json=short", "get-property", "org.kde.kdeconnect", device_object(device_id), "org.kde.kdeconnect.device", "supportedPlugins", ], runner=runner, ) return parse_loaded_plugins(result.stdout) if result.returncode == 0 else [] def device_plugins(device_id: str, runner: Runner = subprocess.run) -> list[str]: active = loaded_plugins(device_id, runner) return active if active else supported_plugins(device_id, runner) def reported_type(device_id: str, runner: Runner = subprocess.run) -> str: result = run_command( [ "busctl", "--user", "--json=short", "get-property", "org.kde.kdeconnect", device_object(device_id), "org.kde.kdeconnect.device", "type", ], runner=runner, ) return parse_string_property(result.stdout) if result.returncode == 0 else "" def device_property( device_id: str, member: str, runner: Runner = subprocess.run, ) -> subprocess.CompletedProcess[str]: return run_command( [ "busctl", "--user", "--json=short", "get-property", "org.kde.kdeconnect", device_object(device_id), "org.kde.kdeconnect.device", member, ], runner=runner, ) # Each KDE Connect plugin hangs its own object off the device path, and those # objects exist only while the device is paired, reachable AND the plugin is # loaded. A phone with the battery plugin turned off, or one that just walked # out of range, makes busctl exit non-zero -- that is an absence of data, never # a fault to report, so every failure mode here collapses to None. def plugin_property( device_id: str, plugin: str, interface: str, member: str, runner: Runner = subprocess.run, ) -> str | None: """A plugin object's property read as raw busctl output, or None when the object, the plugin, or the daemon is not there.""" try: result = run_command( [ "busctl", "--user", "--json=short", "get-property", "org.kde.kdeconnect", f"{device_object(device_id)}/{plugin}", interface, member, ], runner=runner, timeout=VITALS_TIMEOUT, ) except (FileNotFoundError, subprocess.TimeoutExpired): return None return result.stdout if result.returncode == 0 else None def device_battery( device_id: str, runner: Runner = subprocess.run, ) -> dict[str, object] | None: """Charge and charging state, or None when the phone has no battery to report. Charge is read first because it is the cheapest proof the plugin object exists at all: when it is missing the other two reads are skipped, which is the common case for a device that is merely paired. kdeconnectd reports -1 for "not known yet", and anything outside a percentage is not a charge. """ charge = parse_int_property( plugin_property(device_id, "battery", BATTERY_INTERFACE, "charge", runner) ) if charge is None or not 0 <= charge <= 100: return None has_battery = parse_bool_property( plugin_property(device_id, "battery", BATTERY_INTERFACE, "hasBattery", runner) ) if has_battery is False: return None charging = parse_bool_property( plugin_property(device_id, "battery", BATTERY_INTERFACE, "isCharging", runner) ) return {"charge": charge, "charging": charging is True} def device_signal( device_id: str, runner: Runner = subprocess.run, ) -> dict[str, object] | None: """Cell network type and bar count, or None when there is no cellular report -- strength is -1 on a phone with no modem or no service.""" strength = parse_int_property( plugin_property( device_id, "connectivity_report", CONNECTIVITY_INTERFACE, "cellularNetworkStrength", runner, ) ) if strength is None or strength < 0: return None network_type = parse_string_property( plugin_property( device_id, "connectivity_report", CONNECTIVITY_INTERFACE, "cellularNetworkType", runner, ) ) return {"networkType": network_type, "strength": strength} def device_vitals( device: dict[str, object], runner: Runner = subprocess.run, ) -> dict[str, object]: """The battery and signal fields for a device entry. Only a paired, reachable device is queried; for anything else the plugin objects cannot exist, so asking would spend busctl calls to learn nothing. """ if not (device.get("paired") and device.get("reachable")): return dict(EMPTY_VITALS) device_id = str(device["id"]) return { "battery": device_battery(device_id, runner), "signal": device_signal(device_id, runner), } def dbus_device_ids(runner: Runner = subprocess.run) -> list[str]: try: result = run_command( ["busctl", "--user", "tree", "org.kde.kdeconnect"], runner=runner, ) except (FileNotFoundError, subprocess.TimeoutExpired): return [] if result.returncode != 0: return [] return [ match.group("id") for line in result.stdout.splitlines() if (match := DEVICE_OBJECT_LINE.search(line.strip())) is not None ] def dbus_devices( runner: Runner = subprocess.run, *, vitals: bool = True, ) -> list[dict[str, object]]: devices: list[dict[str, object]] = [] for device_id in dbus_device_ids(runner): try: name_result = device_property(device_id, "name", runner) type_result = device_property(device_id, "type", runner) paired_result = device_property(device_id, "isPaired", runner) reachable_result = device_property(device_id, "isReachable", runner) except (FileNotFoundError, subprocess.TimeoutExpired): continue name = parse_string_property(name_result.stdout) if name_result.returncode == 0 else "" device_type = parse_string_property(type_result.stdout) if type_result.returncode == 0 else "" paired = parse_bool_property(paired_result.stdout) if paired_result.returncode == 0 else None reachable = parse_bool_property(reachable_result.stdout) if reachable_result.returncode == 0 else None if not name or paired is not True or reachable is None: continue try: plugins = device_plugins(device_id, runner) except (FileNotFoundError, subprocess.TimeoutExpired): plugins = [] actions = sorted( { action for plugin, action in PLUGIN_ACTIONS.items() if plugin in plugins } ) device = { "id": device_id, "name": name, "type": device_type or inferred_type(name), "paired": paired, "reachable": reachable, "actions": actions, } device.update(device_vitals(device, runner) if vitals else EMPTY_VITALS) devices.append(device) return devices # `vitals` is off for the read that guards an action: ringing a phone needs its # identity and its action list, not its charge, and the extra busctl round trips # would sit between the tap and the ring. def collect_status( runner: Runner = subprocess.run, *, vitals: bool = True, ) -> dict[str, object]: try: listing = run_command( ["kdeconnect-cli", "--list-devices"], runner=runner, ) except (FileNotFoundError, subprocess.TimeoutExpired): return {"available": False, "devices": [], "error": "unavailable"} if listing.returncode != 0: return {"available": False, "devices": [], "error": "unavailable"} devices: list[dict[str, object]] = [] for line in listing.stdout.splitlines(): if not line.lstrip().startswith("-"): continue match = DEVICE_LINE.fullmatch(line.strip()) if match is None: continue device_id = match.group("id") try: plugins = device_plugins(device_id, runner) device_type = reported_type(device_id, runner) except (FileNotFoundError, subprocess.TimeoutExpired): plugins = [] device_type = "" try: device = normalize_device_line(line, plugins, device_type) except ValueError: continue device.update(device_vitals(device, runner) if vitals else EMPTY_VITALS) devices.append(device) known_ids = {str(device["id"]) for device in devices} devices.extend( device for device in dbus_devices(runner, vitals=vitals) if str(device["id"]) not in known_ids ) devices.sort( key=lambda device: ( not bool(device["reachable"]), not bool(device["paired"]), str(device["name"]).casefold(), ) ) return {"available": True, "devices": devices, "error": ""} def action_command( action: str, device_id: str, file_path: pathlib.Path | None = None, ) -> list[str]: device_id = validate_device_id(device_id) options = { "ring": ["--ring"], "clipboard": ["--send-clipboard"], } if action == "share" and file_path is not None: return [ "kdeconnect-cli", "-d", device_id, "--share", str(validate_file(file_path)), ] if action not in options: raise ValueError("unsupported-action") return ["kdeconnect-cli", "-d", device_id, *options[action]] def invoke_action( action: str, device_id: str, file_path: pathlib.Path | None = None, runner: Runner = subprocess.run, ) -> dict[str, object]: device_id = validate_device_id(device_id) status = collect_status(runner, vitals=False) device = next( ( item for item in status["devices"] if isinstance(item, dict) and item.get("id") == device_id ), None, ) if not device or not device.get("paired") or not device.get("reachable"): return {"ok": False, "action": action, "error": "device-offline"} if action not in device.get("actions", []): return {"ok": False, "action": action, "error": "unsupported-action"} command = action_command(action, device_id, file_path) try: result = run_command(command, runner=runner, timeout=3600) except (FileNotFoundError, subprocess.TimeoutExpired): return {"ok": False, "action": action, "error": "action-failed"} if result.returncode != 0: return {"ok": False, "action": action, "error": "action-failed"} response: dict[str, object] = {"ok": True, "action": action, "error": ""} if action == "share" and file_path is not None: response["fileName"] = validate_file(file_path).name return response def print_result(value: dict[str, object]) -> None: sys.stdout.write(compact_json(value) + "\n") def main(argv: list[str]) -> int: if not argv or argv[0] == "status": print_result(collect_status()) return 0 command = argv[0] try: if command == "ring" and len(argv) == 2: result = invoke_action("ring", argv[1]) elif command == "send-clipboard" and len(argv) == 2: result = invoke_action("clipboard", argv[1]) elif command == "send-file" and len(argv) == 3: result = invoke_action("share", argv[1], pathlib.Path(argv[2])) else: result = {"ok": False, "action": command, "error": "invalid-command"} except ValueError as error: result = {"ok": False, "action": command, "error": str(error)} print_result(result) return 0 if bool(result.get("ok")) else 2 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))