Bundled KDE Connect: fixes from review

- iPhone build fails if the bundle can't be made, instead of shipping a
  stale archive with an empty version.
- A different build that another device is using right now is left running
  and replaced once nobody is, rather than stopped under them.
- If what's installed changed after the server looked, the agent asks for
  the packages (need-packages) and the server copies them and starts again.
- The installed-build check sends bytes, so Windows' CRLF can't break it.
- Starting again while a stopped start is still copying launches anew;
  concurrent copies use their own temporary names.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-28 21:18:56 +10:00
1 parent 5bf1268c82
commit 75b2478a55
5 files changed
+148 -25

No files matched your search

+17 -3
View File
@@ -16,7 +16,7 @@ argv: client id, client name, the folder holding the packages, and a JSON list
of [file, sha256] naming them (see frame/kdeconnect/packages.json).
Status goes to stdout, one JSON object per line:
{"state": "installing" | "starting" | "pairing" | "ready" | "error", ...}.
{"state": "installing" | "starting" | "pairing" | "ready" | "error" | "need-packages", ...}.
Standard library only: this runs on the Frame's own Python.
"""
@@ -153,10 +153,21 @@ def stop_daemon():
time.sleep(0.1)
class NeedPackages(Exception):
"""This build of KDE Connect isn't unpacked and the server didn't send it (it thought it was there)."""
def ensure_daemon(folder, packages):
"""Start KDE Connect: the Frame's own if it ever has one, else ours, unpacked first if needed."""
"""Start KDE Connect: the Frame's own if it ever has one, else ours, unpacked first if needed.
A copy from another Frame Control version that another device is using right
now is left running and used as it is (they speak the same protocol); it's
replaced the next time nobody is using it.
"""
system = SYSTEM_DAEMON.exists()
if not system and not installed(packages):
if not system and not installed(packages) and not (listening() and our_daemons()):
if not folder:
raise NeedPackages()
install(folder, packages)
if listening():
return
@@ -373,6 +384,9 @@ def run(client, name, folder, packages):
stop_daemon()
ensure_daemon(folder, packages)
link = connect(device, cert, key, name)
except NeedPackages:
say("need-packages") # the server copies them and starts again
return 1
except (OSError, RuntimeError, subprocess.SubprocessError) as e:
say("error", message=str(e))
return 1
+43 -18
View File
@@ -607,7 +607,8 @@ class InputAgent:
def __init__(self, source=HERE / "frame_input_agent.py", packages=None):
self.source, self.proc, self.lock = source, None, threading.Lock()
self.packages = kdeconnect_packages() if packages is None else packages
self.status, self.launching, self.generation = {"state": "off"}, False, 0
# generation counts stop()s; launching is the generation a launch is under way for.
self.status, self.launching, self.generation = {"state": "off"}, None, 0
def command(self, folder=""):
code = base64.b64encode(self.source.read_bytes()).decode()
@@ -617,19 +618,23 @@ class InputAgent:
+ f" {shlex.quote(client)} {shlex.quote(INPUT_NAME)} {shlex.quote(folder)}"
+ f" {shlex.quote(json.dumps(self.packages, separators=(',', ':')))}")
def deliver(self, report):
def deliver(self, report, force=False):
"""Where the agent finds the packages on the Frame, copying them there first if needed.
On the Frame itself (the iPhone app) they came with the bundle. Otherwise they
go over the SSH connection, only if the Frame doesn't already have them unpacked.
go over the SSH connection, unless the Frame already has them unpacked (`force`:
the agent found it didn't after all).
"""
if LOCAL:
return str(KDECONNECT / "packages")
stamp = kdeconnect_stamp(self.packages)
have = ssh(f"{{ test -x /usr/lib/kdeconnectd || cmp -s - {KDECONNECT_HOME}/root/.frame-control-packages; }}"
" && echo yes || true", stdin=stamp, timeout=20).strip()
if have == "yes" or not self.packages:
if not self.packages:
return "" # nothing to copy (the agent says so if it needed them)
if not force:
# Bytes, so Windows doesn't turn the stamp's line ends into CRLF.
have = ssh(f"{{ test -x /usr/lib/kdeconnectd || cmp -s - {KDECONNECT_HOME}/root/.frame-control-packages; }}"
" && echo yes || true", stdin=kdeconnect_stamp(self.packages).encode(), text=False, timeout=20)
if have.strip() == b"yes":
return ""
client = "".join(c for c in input_client() if c.isalnum() or c in "-_")[:64] or "default"
folder = f"{KDECONNECT_HOME}/incoming/{client}"
ssh(f"mkdir -p {folder}", timeout=20)
@@ -638,21 +643,23 @@ class InputAgent:
if not path.is_file() or file_sha256(path) != sha:
raise Failure(f"{name} is missing or damaged in this copy of Frame Control"
" (a build runs app/build/fetch-deps.js to add it)", 500)
report(f"Copying KDE Connect to the Frame ({name.split('-')[0]})")
report(f"Copying KDE Connect to the Frame ({name.rsplit('-', 3)[0]})")
quoted = shlex.quote(name)
ssh(f"cd {folder} && cat > {quoted}.part && mv {quoted}.part {quoted}",
# Its own temporary name: two starts can be copying at once.
ssh(f"cd {folder} && cat > {quoted}.part$$ && mv {quoted}.part$$ {quoted}",
stdin=path.read_bytes(), text=False, timeout=600)
return f"~/{folder}"
def start(self):
with self.lock:
if self.launching or (self.proc and self.proc.poll() is None):
if self.launching == self.generation or (self.proc and self.proc.poll() is None):
return
self.launching, self.status = True, {"state": "starting"}
# (A launch from before a stop() may still be finishing; it ends itself.)
self.launching, self.status = self.generation, {"state": "starting"}
generation = self.generation
threading.Thread(target=self._launch, args=(generation,), daemon=True).start()
def _launch(self, generation):
def _launch(self, generation, force=False):
def report(message):
with self.lock:
if self.generation == generation:
@@ -660,35 +667,50 @@ class InputAgent:
errors = tempfile.TemporaryFile()
try:
ensure_master()
folder = self.deliver(report)
folder = self.deliver(report, force)
proc = subprocess.Popen([*SSH, FRAME, self.command(folder)], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=errors)
except (Failure, OSError) as e:
message = str(e)
friendly = unreachable(message)
with self.lock:
self.launching = False
if self.launching == generation:
self.launching = None
if self.generation == generation:
self.status = {"state": "error", "message": friendly or message,
**({"offline": True} if friendly else {})}
return
_live_tunnels.add(proc)
with self.lock:
self.launching = False
if self.launching == generation:
self.launching = None
stale = self.generation != generation
if not stale:
self.proc = proc
if stale: # turned off meanwhile
proc.terminate()
self._watch(proc, errors)
if self._watch(proc, errors, retry=not force) and not force:
# It needed the packages after all (another device changed what's
# installed after we looked): copy them and start once more.
with self.lock:
if self.proc is not proc or self.generation != generation:
return
self.proc, self.launching, self.status = None, generation, {"state": "starting"}
self._launch(generation, force=True)
def _watch(self, proc, errors):
def _watch(self, proc, errors, retry=False):
"""Follow the agent's status until it exits. True if it asked for the packages
(`retry`: the caller will send them, so that isn't an error yet)."""
wanted = False
for line in proc.stdout:
try:
status = json.loads(line)
except ValueError:
continue
if isinstance(status, dict) and isinstance(status.get("state"), str):
if status["state"] == "need-packages":
wanted = True
continue
with self.lock:
if self.proc is proc:
self.status = status
@@ -697,10 +719,13 @@ class InputAgent:
errors.seek(0)
detail = strip_ansi(errors.read().decode(errors="replace")).strip()
with self.lock:
if self.proc is proc and self.status.get("state") != "error":
if self.proc is proc and self.status.get("state") != "error" and not (wanted and retry):
message = detail.splitlines()[-1] if detail else "The connection to the Frame ended"
if wanted:
message = "KDE Connect didn't reach the Frame"
friendly = unreachable(message)
self.status = {"state": "error", "message": friendly or message, **({"offline": True} if friendly else {})}
return wanted
def send(self, events):
"""Forward events if the agent is ready; start it if it isn't running.