mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 04:04:21 +02:00
Ship KDE Connect inside Frame Control instead of downloading it on the Frame
The keyboard and trackpad no longer fetch KDE Connect from Valve's package repository on the Frame. The desktop apps and the iPhone app's Frame bundle carry Valve's arm64 build of kdeconnect 24.02.2-1 and the five libraries it links (kcontacts, kpeople, modemmanager-qt, pulseaudio-qt, libfakekey), pinned by SHA-256 in frame/kdeconnect/packages.json and downloaded at build time from the kdeconnect-frame-24.02.2-1 release, which also holds Valve's complete source package for each. On first use the computer copies them over its SSH connection (the iPhone bundle already has them on the Frame); the agent checks each SHA-256, unpacks them and stamps which build it is, so later starts copy nothing. No internet on the Frame, 3.6 MB instead of 8 MB, 18 MB unpacked instead of 82 MB (ModemManager and friends were packaging-only dependencies). GPL/LGPL compliance: frame/kdeconnect/NOTICE.md names each exact version, licence and source; per-project licence texts in frame/kdeconnect/LICENSES; THIRD_PARTY_NOTICES.md; an About and licences dialog in the app. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
6217b5f2fa
commit
5bf1268c82
50 files changed
+7301
-78
No files matched your search
+74
-41
@@ -6,10 +6,14 @@ from stdin: one JSON object (or list of them) per line, each a KDE Connect
|
||||
"mousepad" request body such as {"dx": 4, "dy": -2} or {"key": "hello"}.
|
||||
KDE Connect does the typing and clicking.
|
||||
|
||||
KDE Connect isn't installed on the Frame, but Valve's package repository for it
|
||||
has a build. The first run fetches that and the few libraries the Frame lacks
|
||||
into ~/.local/share/frame-control/kdeconnect: no root, and SteamOS updates
|
||||
leave it alone.
|
||||
KDE Connect isn't installed on the Frame. Frame Control ships Valve's build of it
|
||||
for the Frame and the few libraries the Frame lacks (frame/kdeconnect); the server
|
||||
copies them over the SSH connection and this unpacks them into
|
||||
~/.local/share/frame-control/kdeconnect: no root, no internet, and SteamOS
|
||||
updates leave it alone.
|
||||
|
||||
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", ...}.
|
||||
@@ -17,6 +21,7 @@ Status goes to stdout, one JSON object per line:
|
||||
Standard library only: this runs on the Frame's own Python.
|
||||
"""
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import selectors
|
||||
@@ -32,9 +37,7 @@ from pathlib import Path
|
||||
BASE = Path.home() / ".local/share/frame-control/kdeconnect"
|
||||
ROOT = BASE / "root"
|
||||
BRIDGE = BASE / "bridge"
|
||||
# kdeconnect plus the dependencies the Frame's image doesn't have (checked 2026-09-28,
|
||||
# SteamOS 0.4.1); `pacman -Sp` adds any others still missing.
|
||||
PACKAGES = ["kdeconnect", "kpeople", "libfakekey", "modemmanager-qt", "pulseaudio-qt"]
|
||||
STAMP = ".frame-control-packages" # in ROOT: which packages it was unpacked from
|
||||
PORT = int(os.environ.get("FRAME_INPUT_PORT", "1716"))
|
||||
UID = os.getuid()
|
||||
MOUSEPAD = "kdeconnect.mousepad.request"
|
||||
@@ -50,32 +53,49 @@ def packet(kind, body):
|
||||
|
||||
# ---- KDE Connect on the Frame ------------------------------------------------
|
||||
|
||||
def daemon_path():
|
||||
for path in (Path("/usr/lib/kdeconnectd"), ROOT / "usr/lib/kdeconnectd"):
|
||||
if path.exists():
|
||||
return path
|
||||
return None
|
||||
SYSTEM_DAEMON = Path("/usr/lib/kdeconnectd")
|
||||
|
||||
|
||||
def install():
|
||||
say("installing", message="Fetching KDE Connect from the Frame's package repository")
|
||||
found = subprocess.run(["pacman", "-Sp", *PACKAGES], capture_output=True, text=True, timeout=120)
|
||||
urls = [u for u in found.stdout.split() if u.startswith("https://")]
|
||||
if found.returncode or not urls:
|
||||
raise RuntimeError("Couldn't find KDE Connect in the Frame's package repository: "
|
||||
+ (found.stderr.strip() or "no packages listed"))
|
||||
download, stage = BASE / "download", BASE / "root.new"
|
||||
for d in (download, stage):
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
d.mkdir(parents=True)
|
||||
for url in urls:
|
||||
name = download / url.rsplit("/", 1)[1]
|
||||
subprocess.run(["curl", "-fsSL", "--retry", "2", "-o", str(name), url], check=True, timeout=600)
|
||||
if subprocess.run(["tar", "--zstd", "-xf", str(name), "-C", str(stage)], capture_output=True).returncode:
|
||||
subprocess.run(["bsdtar", "-xf", str(name), "-C", str(stage)], check=True, capture_output=True)
|
||||
def stamp(packages):
|
||||
"""What ROOT/STAMP holds once these packages are unpacked (the server checks it too)."""
|
||||
return "".join(f"{sha} {name}\n" for name, sha in packages)
|
||||
|
||||
|
||||
def installed(packages):
|
||||
try:
|
||||
return (ROOT / STAMP).read_text() == stamp(packages)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def sha256(path):
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for block in iter(lambda: f.read(1 << 20), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def install(folder, packages):
|
||||
"""Unpack the packages the server copied to `folder` into ROOT, checking each one first."""
|
||||
if not packages:
|
||||
raise RuntimeError("This copy of Frame Control doesn't include KDE Connect")
|
||||
say("installing", message="Unpacking KDE Connect on the Frame")
|
||||
stage = BASE / "root.new"
|
||||
shutil.rmtree(stage, ignore_errors=True)
|
||||
stage.mkdir(parents=True)
|
||||
for name, sha in packages:
|
||||
path = Path(folder) / name
|
||||
if not path.is_file():
|
||||
raise RuntimeError(f"{name} didn't reach the Frame")
|
||||
if sha256(path) != sha:
|
||||
raise RuntimeError(f"{name} arrived damaged (its SHA-256 doesn't match)")
|
||||
if subprocess.run(["tar", "--zstd", "-xf", str(path), "-C", str(stage)], capture_output=True).returncode:
|
||||
subprocess.run(["bsdtar", "-xf", str(path), "-C", str(stage)], check=True, capture_output=True)
|
||||
(stage / STAMP).write_text(stamp(packages))
|
||||
stop_daemon() # an older copy may still be running from ROOT
|
||||
shutil.rmtree(ROOT, ignore_errors=True)
|
||||
stage.rename(ROOT)
|
||||
shutil.rmtree(download, ignore_errors=True)
|
||||
|
||||
|
||||
def app_display():
|
||||
@@ -133,14 +153,15 @@ def stop_daemon():
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
def ensure_daemon():
|
||||
def ensure_daemon(folder, packages):
|
||||
"""Start KDE Connect: the Frame's own if it ever has one, else ours, unpacked first if needed."""
|
||||
system = SYSTEM_DAEMON.exists()
|
||||
if not system and not installed(packages):
|
||||
install(folder, packages)
|
||||
if listening():
|
||||
return
|
||||
BASE.mkdir(parents=True, exist_ok=True)
|
||||
daemon = daemon_path()
|
||||
if not daemon:
|
||||
install()
|
||||
daemon = daemon_path()
|
||||
daemon = SYSTEM_DAEMON if system else ROOT / "usr/lib/kdeconnectd"
|
||||
say("starting", message="Starting KDE Connect on the Frame")
|
||||
log = open(BASE / "kdeconnectd.log", "ab")
|
||||
# Its own session, so it outlives this connection and serves the next one.
|
||||
@@ -273,15 +294,21 @@ def connect(device, cert, key, name):
|
||||
|
||||
|
||||
def client_args():
|
||||
"""argv: a folder-safe id for the computer or phone, and the name KDE Connect shows for it."""
|
||||
"""argv: a folder-safe id for the computer or phone, the name KDE Connect shows for it,
|
||||
the folder holding the packages, and their [file, sha256] list."""
|
||||
client = sys.argv[1] if len(sys.argv) > 1 else "default"
|
||||
client = "".join(c for c in client if c.isalnum() or c in "-_")[:64] or "default"
|
||||
name = (sys.argv[2] if len(sys.argv) > 2 else "")[:60].strip()
|
||||
return client, f"Frame Control ({name})" if name else "Frame Control"
|
||||
folder = os.path.expanduser(sys.argv[3]) if len(sys.argv) > 3 else ""
|
||||
try:
|
||||
packages = [(str(f), str(h)) for f, h in json.loads(sys.argv[4])] if len(sys.argv) > 4 else []
|
||||
except (ValueError, TypeError):
|
||||
packages = []
|
||||
return client, f"Frame Control ({name})" if name else "Frame Control", folder, packages
|
||||
|
||||
|
||||
def main():
|
||||
client, name = client_args()
|
||||
client, name, folder, packages = client_args()
|
||||
# A dropped ssh (the Frame slept, the app quit) hangs up on us: exit through the
|
||||
# clean-up below rather than dying on the spot.
|
||||
for sig in (signal.SIGHUP, signal.SIGTERM):
|
||||
@@ -293,7 +320,7 @@ def main():
|
||||
clients = open(BASE / "clients.lock", "w")
|
||||
fcntl.flock(clients, fcntl.LOCK_SH)
|
||||
try:
|
||||
return run(client, name)
|
||||
return run(client, name, folder, packages)
|
||||
finally:
|
||||
# Finish the clean-up even if a second hang-up or TERM arrives meanwhile.
|
||||
for sig in (signal.SIGHUP, signal.SIGTERM):
|
||||
@@ -319,10 +346,16 @@ class daemon_lock:
|
||||
self.file.close()
|
||||
|
||||
|
||||
def run(client, name):
|
||||
def run(client, name, folder, packages):
|
||||
try:
|
||||
with daemon_lock():
|
||||
ensure_daemon()
|
||||
ensure_daemon(folder, packages)
|
||||
if folder.startswith(str(BASE / "incoming") + "/"):
|
||||
shutil.rmtree(folder, ignore_errors=True) # unpacked; the copy isn't needed again
|
||||
try:
|
||||
(BASE / "incoming").rmdir()
|
||||
except OSError:
|
||||
pass # another device's copy is still there
|
||||
device, cert, key = identity(client)
|
||||
say("pairing")
|
||||
seen = our_daemons()
|
||||
@@ -338,7 +371,7 @@ def run(client, name):
|
||||
with daemon_lock():
|
||||
if set(our_daemons()) & set(seen):
|
||||
stop_daemon()
|
||||
ensure_daemon()
|
||||
ensure_daemon(folder, packages)
|
||||
link = connect(device, cert, key, name)
|
||||
except (OSError, RuntimeError, subprocess.SubprocessError) as e:
|
||||
say("error", message=str(e))
|
||||
|
||||
+33
-5
@@ -254,10 +254,13 @@
|
||||
.and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; }
|
||||
.and-col { display: grid; gap: 22px; align-content: start; }
|
||||
.rep-item .s { white-space: normal; }
|
||||
#repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||
#repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg, #aboutDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
||||
#repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop { background: rgba(0,0,0,.55); }
|
||||
#repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||||
#repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop, #aboutDlg::backdrop { background: rgba(0,0,0,.55); }
|
||||
#repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2, #aboutDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||||
.about-text { max-height: 55vh; overflow: auto; }
|
||||
.about-text pre { white-space: pre-wrap; font-size: 12px; color: var(--muted); }
|
||||
.about-text summary { cursor: pointer; margin: 8px 0; }
|
||||
#repForm label, #titleForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||||
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
|
||||
#titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||||
@@ -514,8 +517,9 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="hint">Types and points in apps on the Frame, such as Chromium or the desktop's Linux apps; Steam's own VR menus
|
||||
don't take it. It works through KDE Connect on the Frame: the first time, Frame Control fetches Valve's build of it
|
||||
(an 8 MB download, 82 MB unpacked) into your home folder there and pairs with it. Nothing to install on this device.</div>
|
||||
don't take it. It works through KDE Connect, which comes with Frame Control: the first time, it copies Valve's build
|
||||
for the Frame (3.6 MB, 18 MB unpacked) into your home folder there and pairs with it. No internet needed, and nothing
|
||||
else to install. <a href="#" data-about>Licences</a></div>
|
||||
</section>
|
||||
|
||||
<section id="shots">
|
||||
@@ -683,6 +687,7 @@
|
||||
<div class="hint desk-only">Sleep, Restart and Shut down open a terminal window for the Developer Mode password.</div>
|
||||
<div class="hint mobile-only">Sleep, Restart and Shut down ask for the Developer Mode password. SSH, SFTP, Steam Link and remote desktop open in the app that handles them.</div>
|
||||
<div class="row mobile-only" style="margin-top:14px"><button class="small" id="changeHeadset">Change headset…</button></div>
|
||||
<div class="row" style="margin-top:14px"><button class="small" data-about>About and licences</button></div>
|
||||
<div class="links desk-only">
|
||||
<div><a href="https://store.steampowered.com/remoteplay" target="_blank">Steam Link</a>: Valve's remote view of the headset</div>
|
||||
<div><a href="https://streamframe.app/" target="_blank">Stream Frame</a>: third-party recorder (macOS 14+)</div>
|
||||
@@ -713,6 +718,14 @@
|
||||
<div class="actions"><button id="apkAltClose">Close</button></div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="aboutDlg" aria-labelledby="aboutTitle">
|
||||
<h2 id="aboutTitle">About and licences</h2>
|
||||
<p>Frame Control is MIT-licensed. It ships other people's software, each under its own licence: the notices and
|
||||
licence texts follow, with where to get the source.</p>
|
||||
<div id="aboutText" class="about-text">Loading…</div>
|
||||
<div class="actions"><button id="aboutClose">Close</button></div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="repDlg" aria-labelledby="repTitle">
|
||||
<form method="dialog" id="repForm">
|
||||
<h2 id="repTitle">Report an APK</h2>
|
||||
@@ -1517,6 +1530,21 @@ function showApkAlternatives(reason, result) {
|
||||
}
|
||||
$("apkAltClose").onclick = () => $("apkAltDlg").close();
|
||||
|
||||
// ---- about and licences ----
|
||||
document.addEventListener("click", async e => {
|
||||
if (!e.target.closest("[data-about]")) return;
|
||||
e.preventDefault();
|
||||
$("aboutDlg").showModal();
|
||||
try {
|
||||
const { notices } = await api("/api/licenses");
|
||||
$("aboutText").innerHTML = notices.map((n, i) =>
|
||||
`<details${i ? "" : " open"}><summary>${esc(n.title)}</summary><pre>${esc(n.text)}</pre></details>`).join("");
|
||||
} catch (err) {
|
||||
$("aboutText").textContent = `Couldn't load the licences: ${err.message}. They're also in THIRD_PARTY_NOTICES.md and frame/kdeconnect in Frame Control's source.`;
|
||||
}
|
||||
});
|
||||
$("aboutClose").onclick = () => $("aboutDlg").close();
|
||||
|
||||
const TITLE_EXT = /\.(zip|exe)$/i;
|
||||
async function sendFiles(files, dirs = new Set()) {
|
||||
for (const [i, f] of files.entries()) {
|
||||
|
||||
+114
-13
@@ -14,6 +14,7 @@ Env: FRAME_ALIAS (default frame)
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
@@ -568,35 +569,118 @@ def input_client():
|
||||
return made
|
||||
|
||||
|
||||
# KDE Connect for the Frame, as Frame Control ships it (frame/kdeconnect/NOTICE.md).
|
||||
KDECONNECT = HERE.parent / "frame" / "kdeconnect"
|
||||
KDECONNECT_HOME = ".local/share/frame-control/kdeconnect"
|
||||
|
||||
|
||||
def kdeconnect_packages():
|
||||
"""[(file, sha256), ...] from frame/kdeconnect/packages.json; [] if it's missing."""
|
||||
try:
|
||||
manifest = json.loads((KDECONNECT / "packages.json").read_text())
|
||||
return [(p["file"], p["sha256"]) for p in manifest["packages"]]
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
return []
|
||||
|
||||
|
||||
def kdeconnect_stamp(packages):
|
||||
"""What the agent writes once these are unpacked (frame_input_agent.stamp)."""
|
||||
return "".join(f"{sha} {name}\n" for name, sha in packages)
|
||||
|
||||
|
||||
def file_sha256(path):
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for block in iter(lambda: f.read(1 << 20), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
class InputAgent:
|
||||
"""frame_input_agent.py running on the Frame, fed events over one long-lived ssh.
|
||||
|
||||
It sets up KDE Connect there if needed, pairs, and reports its state
|
||||
({"state": "off" | "installing" | "starting" | "pairing" | "ready" | "error"}).
|
||||
Before starting it, copies KDE Connect to the Frame if it isn't there yet. The
|
||||
agent unpacks it, pairs, and reports its state ({"state": "off" | "installing" |
|
||||
"starting" | "pairing" | "ready" | "error"}).
|
||||
"""
|
||||
|
||||
def __init__(self, source=HERE / "frame_input_agent.py"):
|
||||
def __init__(self, source=HERE / "frame_input_agent.py", packages=None):
|
||||
self.source, self.proc, self.lock = source, None, threading.Lock()
|
||||
self.status = {"state": "off"}
|
||||
self.packages = kdeconnect_packages() if packages is None else packages
|
||||
self.status, self.launching, self.generation = {"state": "off"}, False, 0
|
||||
|
||||
def command(self):
|
||||
def command(self, folder=""):
|
||||
code = base64.b64encode(self.source.read_bytes()).decode()
|
||||
client = input_client()
|
||||
return ("python3 -u -c " + shlex.quote(
|
||||
f"import base64;exec(compile(base64.b64decode('{code}'),'frame_input_agent','exec'))")
|
||||
+ f" {shlex.quote(client)} {shlex.quote(INPUT_NAME)}")
|
||||
+ f" {shlex.quote(client)} {shlex.quote(INPUT_NAME)} {shlex.quote(folder)}"
|
||||
+ f" {shlex.quote(json.dumps(self.packages, separators=(',', ':')))}")
|
||||
|
||||
def deliver(self, report):
|
||||
"""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.
|
||||
"""
|
||||
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:
|
||||
return "" # nothing to copy (the agent says so if it needed them)
|
||||
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)
|
||||
for name, sha in self.packages:
|
||||
path = KDECONNECT / "packages" / name
|
||||
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]})")
|
||||
quoted = shlex.quote(name)
|
||||
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.proc and self.proc.poll() is None:
|
||||
if self.launching or (self.proc and self.proc.poll() is None):
|
||||
return
|
||||
self.launching, self.status = True, {"state": "starting"}
|
||||
generation = self.generation
|
||||
threading.Thread(target=self._launch, args=(generation,), daemon=True).start()
|
||||
|
||||
def _launch(self, generation):
|
||||
def report(message):
|
||||
with self.lock:
|
||||
if self.generation == generation:
|
||||
self.status = {"state": "installing", "message": message}
|
||||
errors = tempfile.TemporaryFile()
|
||||
try:
|
||||
ensure_master()
|
||||
self.status = {"state": "starting"}
|
||||
errors = tempfile.TemporaryFile()
|
||||
self.proc = proc = subprocess.Popen([*SSH, FRAME, self.command()], stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, stderr=errors)
|
||||
_live_tunnels.add(proc)
|
||||
threading.Thread(target=self._watch, args=(proc, errors), daemon=True).start()
|
||||
folder = self.deliver(report)
|
||||
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.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
|
||||
stale = self.generation != generation
|
||||
if not stale:
|
||||
self.proc = proc
|
||||
if stale: # turned off meanwhile
|
||||
proc.terminate()
|
||||
self._watch(proc, errors)
|
||||
|
||||
def _watch(self, proc, errors):
|
||||
for line in proc.stdout:
|
||||
@@ -642,6 +726,7 @@ class InputAgent:
|
||||
def stop(self):
|
||||
with self.lock:
|
||||
proc, self.proc, self.status = self.proc, None, {"state": "off"}
|
||||
self.generation += 1
|
||||
if proc and proc.poll() is None:
|
||||
proc.terminate()
|
||||
|
||||
@@ -649,6 +734,20 @@ class InputAgent:
|
||||
_input = InputAgent()
|
||||
|
||||
|
||||
def licenses():
|
||||
"""The notices and licence texts for what Frame Control ships (the About dialog)."""
|
||||
found = [("Third-party notices", HERE.parent / "THIRD_PARTY_NOTICES.md"), ("KDE Connect for the Frame", KDECONNECT / "NOTICE.md"),
|
||||
("Frame Control (MIT)", HERE.parent / "LICENSE")]
|
||||
found += [(f"{p.parent.name}: {p.stem}", p) for p in sorted((KDECONNECT / "LICENSES").glob("*/*.txt"))]
|
||||
notices = []
|
||||
for title, path in found:
|
||||
try:
|
||||
notices.append({"title": title, "text": path.read_text(errors="replace")})
|
||||
except OSError:
|
||||
pass
|
||||
return notices
|
||||
|
||||
|
||||
def remote_input(body):
|
||||
"""{"events": [...]} sends keyboard and pointer events; {} (or none yet) just starts the agent."""
|
||||
events = body.get("events", [])
|
||||
@@ -1504,6 +1603,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json({"titles": frame_titles.list_titles()})
|
||||
elif path == "/api/titles/job":
|
||||
self.send_json(title_job(url.query))
|
||||
elif path == "/api/licenses":
|
||||
self.send_json({"notices": licenses()})
|
||||
elif path == "/api/input":
|
||||
self.send_json(_input.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_input.status))
|
||||
elif path == "/api/job":
|
||||
|
||||
Reference in new issue
Block a user