Read busctl as JSON so device names keep their characters

A phone named "Gib's iPhone" with a typographic apostrophe was shown as
"Gib\342\200\231s iPhone".

busctl's default text output escapes every non-ASCII byte in octal, and
escapes it into the output rather than into a quoted string a
shell-style parser can undo, so shlex handed back the escape sequences
as literal characters and they went straight to the page. Apostrophes
were only the visible case: accents, emoji, quotes and backslashes were
all affected, and a name containing a quote could have split a field.

Property and method reads now use --json=short, which returns real
UTF-8, and the parsers read a document rather than splitting words.
That removes the class rather than unescaping octal by hand.

The fixtures were the reason this stayed invisible: every test fed the
text form and passed against output the helper is no longer asking for.
They now carry what busctl actually emits in the mode used, plus a case
for a non-ASCII name and one asserting the old text form is refused
rather than parsed wrongly.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
Gabriel Brown
2026-08-19 11:28:36 -04:00
parent eeb49c5aff
commit dbd1472e6b
2 changed files with 93 additions and 53 deletions
+34 -20
View File
@@ -7,7 +7,6 @@ from __future__ import annotations
import json
import pathlib
import re
import shlex
import subprocess
import sys
from collections.abc import Callable
@@ -88,33 +87,44 @@ def normalize_device_line(
}
# 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 parse_property(output: str, expected: str) -> object | None:
"""The value of a busctl --json=short property read, or None if it is not
the type asked for."""
try:
payload = json.loads(output)
except (json.JSONDecodeError, TypeError):
return None
if not isinstance(payload, dict) or payload.get("type") != expected:
return None
return payload.get("data")
def parse_loaded_plugins(output: str) -> list[str]:
try:
parts = shlex.split(output)
except ValueError:
data = parse_property(output, "as")
if not isinstance(data, list):
return []
if len(parts) < 2 or parts[0] != "as":
return []
try:
count = int(parts[1])
except ValueError:
return []
return parts[2 : 2 + max(0, count)]
return [str(item) for item in data]
def parse_string_property(output: str) -> str:
try:
parts = shlex.split(output)
except ValueError:
return ""
return parts[1] if len(parts) == 2 and parts[0] == "s" else ""
data = parse_property(output, "s")
return data if isinstance(data, str) else ""
def parse_bool_property(output: str) -> bool | None:
parts = output.split()
if len(parts) != 2 or parts[0] != "b" or parts[1] not in {"true", "false"}:
return None
return parts[1] == "true"
data = parse_property(output, "b")
return data if isinstance(data, bool) else None
def run_command(
@@ -141,6 +151,7 @@ def loaded_plugins(device_id: str, runner: Runner = subprocess.run) -> list[str]
[
"busctl",
"--user",
"--json=short",
"call",
"org.kde.kdeconnect",
device_object(device_id),
@@ -157,6 +168,7 @@ def supported_plugins(device_id: str, runner: Runner = subprocess.run) -> list[s
[
"busctl",
"--user",
"--json=short",
"get-property",
"org.kde.kdeconnect",
device_object(device_id),
@@ -178,6 +190,7 @@ def reported_type(device_id: str, runner: Runner = subprocess.run) -> str:
[
"busctl",
"--user",
"--json=short",
"get-property",
"org.kde.kdeconnect",
device_object(device_id),
@@ -198,6 +211,7 @@ def device_property(
[
"busctl",
"--user",
"--json=short",
"get-property",
"org.kde.kdeconnect",
device_object(device_id),