A Process's exited and streamFinished signals aren't guaranteed to fire in order, and several services decided an outcome on whichever fired first: KdeConnect could report a successful file transfer as failed if exited landed before the real stdout payload; Clipboard could present a failed history query as an empty-but-healthy one; Brightness could strand the last queued write of a drag; SoundFeedback and SystemLocale could drop or misapply a rapid second toggle/click because re-arming an already-running Process is a no-op. All five now wait for both signals and let the authoritative one decide, matching the pattern HomeAssistantConfig.qml already used correctly. Health's "copy report" never enabled stdin, so it copied nothing while claiming success. Capture announced every recording as saved regardless of the recorder's actual exit code. Connectivity never restarted Bluetooth discovery when the adapter was enabled from an already-open page. CalendarAgenda left the UI in "loading" forever if its helper died at startup, and the helper itself could crash unguarded instead of reporting unavailable. Geocoding silently dropped a query typed while the previous one was still in flight. Notifs leaked tracked-but-undisplayed notifications under Do Not Disturb, and dismissAll() skipped them. Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
257 lines
8.5 KiB
QML
257 lines
8.5 KiB
QML
pragma Singleton
|
|
|
|
// Shared KDE Connect state. The Python helper owns all CLI and D-Bus parsing;
|
|
// this singleton exposes only normalized devices and user-initiated actions.
|
|
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import QtQuick
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-kdeconnect"
|
|
|
|
property bool fixtureMode: false
|
|
property bool available: false
|
|
property var devices: []
|
|
property string lastError: ""
|
|
|
|
property bool transferActive: false
|
|
property string transferFileName: ""
|
|
property string transferDeviceName: ""
|
|
property var recentExchange: null
|
|
|
|
property string actionKind: ""
|
|
property string actionPath: ""
|
|
|
|
// actionProc's `exited` and its stdout `streamFinished` are not guaranteed
|
|
// to fire in a particular order (same hazard HomeAssistantConfig.qml's
|
|
// settle-both pattern guards against). These track which of the two have
|
|
// been observed for the action currently in flight so finishAction() is
|
|
// only ever called once both have arrived, with the real stdout JSON as
|
|
// the authoritative result.
|
|
property bool actionExited: false
|
|
property bool actionStdoutDone: false
|
|
property string actionStdoutText: ""
|
|
|
|
readonly property var preferredPhone: {
|
|
const phones = root.devices.filter(device => device.type === "phone" && device.paired);
|
|
return phones.find(device => device.reachable) ?? phones[0] ?? null;
|
|
}
|
|
readonly property bool phoneReachable: root.preferredPhone?.reachable === true
|
|
readonly property int pairedCount: root.devices.filter(device => device.paired).length
|
|
readonly property var phoneActions: root.preferredPhone?.actions ?? []
|
|
|
|
function supports(action: string): bool {
|
|
return root.phoneActions.indexOf(action) >= 0;
|
|
}
|
|
|
|
function refresh(): void {
|
|
if (root.fixtureMode || statusProc.running)
|
|
return;
|
|
statusProc.running = true;
|
|
}
|
|
|
|
function consumeStatus(text: string): void {
|
|
if (root.fixtureMode)
|
|
return;
|
|
try {
|
|
const result = JSON.parse(text);
|
|
root.available = result.available === true;
|
|
root.devices = Array.isArray(result.devices) ? result.devices : [];
|
|
root.lastError = String(result.error ?? "");
|
|
} catch (error) {
|
|
root.available = false;
|
|
root.devices = [];
|
|
root.lastError = "invalid-response";
|
|
}
|
|
}
|
|
|
|
function sendFile(path: string): void {
|
|
if (!root.phoneReachable || !root.supports("share") || path === "" || actionProc.running)
|
|
return;
|
|
root.actionKind = "share";
|
|
root.actionPath = path;
|
|
root.transferActive = true;
|
|
root.transferFileName = path.split("/").pop();
|
|
root.transferDeviceName = root.preferredPhone.name;
|
|
root.actionExited = false;
|
|
root.actionStdoutDone = false;
|
|
actionProc.command = [root.helperPath, "send-file", root.preferredPhone.id, path];
|
|
actionProc.running = true;
|
|
}
|
|
|
|
function sendClipboard(): void {
|
|
root.runSimpleAction("clipboard", "send-clipboard");
|
|
}
|
|
|
|
function ring(): void {
|
|
root.runSimpleAction("ring", "ring");
|
|
}
|
|
|
|
function runSimpleAction(kind: string, command: string): void {
|
|
if (!root.phoneReachable || !root.supports(kind) || actionProc.running)
|
|
return;
|
|
root.actionKind = kind;
|
|
root.actionPath = "";
|
|
root.actionExited = false;
|
|
root.actionStdoutDone = false;
|
|
actionProc.command = [root.helperPath, command, root.preferredPhone.id];
|
|
actionProc.running = true;
|
|
}
|
|
|
|
function cancelTransfer(): void {
|
|
if (actionProc.running && root.actionKind === "share")
|
|
actionProc.signal(2);
|
|
root.actionKind = "";
|
|
root.actionPath = "";
|
|
root.transferActive = false;
|
|
root.transferFileName = "";
|
|
root.transferDeviceName = "";
|
|
root.actionExited = false;
|
|
root.actionStdoutDone = false;
|
|
}
|
|
|
|
// Called from both actionProc.onExited and its stdout streamFinished.
|
|
// Only finalizes once both signals have arrived for the in-flight action,
|
|
// since their firing order is not guaranteed -- see the actionExited /
|
|
// actionStdoutDone comment above.
|
|
function settleAction(): void {
|
|
if (root.actionKind === "")
|
|
return;
|
|
if (!root.actionExited || !root.actionStdoutDone)
|
|
return;
|
|
root.actionExited = false;
|
|
root.actionStdoutDone = false;
|
|
root.finishAction(root.actionStdoutText);
|
|
}
|
|
|
|
function finishAction(text: string): void {
|
|
if (root.actionKind === "")
|
|
return;
|
|
|
|
const kind = root.actionKind;
|
|
const localPath = root.actionPath;
|
|
const fileName = root.transferFileName;
|
|
const deviceName = root.transferDeviceName || root.preferredPhone?.name || "Phone";
|
|
let result = null;
|
|
try {
|
|
result = JSON.parse(text);
|
|
} catch (error) {
|
|
result = { ok: false, error: "invalid-response" };
|
|
}
|
|
|
|
root.actionKind = "";
|
|
root.actionPath = "";
|
|
if (kind === "share") {
|
|
root.transferActive = false;
|
|
root.transferFileName = "";
|
|
root.transferDeviceName = "";
|
|
}
|
|
|
|
if (result.ok === true && kind === "share") {
|
|
root.recentExchange = {
|
|
direction: "sent",
|
|
fileName: String(result.fileName || fileName),
|
|
deviceName,
|
|
timestamp: Date.now()
|
|
};
|
|
StatusEvents.publish({
|
|
key: "phone-transfer",
|
|
glyph: "\u{F03F2}",
|
|
title: "Sent to " + deviceName,
|
|
detail: String(result.fileName || fileName),
|
|
tone: "ok",
|
|
priority: StatusEvents.importantPriority,
|
|
actionId: localPath !== "" ? "open-path" : "",
|
|
actionData: localPath
|
|
});
|
|
} else if (result.ok !== true) {
|
|
root.lastError = String(result.error || "action-failed");
|
|
StatusEvents.publish({
|
|
key: "phone-action",
|
|
glyph: "\u{F03F2}",
|
|
title: kind === "share" ? "File was not sent" : "Phone action failed",
|
|
detail: "Check that the phone is nearby and try again",
|
|
tone: "warn",
|
|
priority: StatusEvents.importantPriority
|
|
});
|
|
}
|
|
refreshDelay.restart();
|
|
}
|
|
|
|
function applyFixture(name: string): void {
|
|
if (["reachable", "offline", "transfer"].indexOf(name) < 0)
|
|
return;
|
|
root.fixtureMode = true;
|
|
root.available = true;
|
|
root.lastError = "";
|
|
root.recentExchange = null;
|
|
root.devices = [{
|
|
id: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
|
name: "Fixture iPhone",
|
|
type: "phone",
|
|
paired: true,
|
|
reachable: name !== "offline",
|
|
actions: ["clipboard", "ping", "ring", "share"]
|
|
}];
|
|
root.transferActive = name === "transfer";
|
|
root.transferFileName = name === "transfer" ? "Fixture document.pdf" : "";
|
|
root.transferDeviceName = name === "transfer" ? "Fixture iPhone" : "";
|
|
root.actionKind = name === "transfer" ? "share" : "";
|
|
root.actionPath = "";
|
|
}
|
|
|
|
function clearFixture(): void {
|
|
root.fixtureMode = false;
|
|
root.available = false;
|
|
root.devices = [];
|
|
root.lastError = "";
|
|
root.transferActive = false;
|
|
root.transferFileName = "";
|
|
root.transferDeviceName = "";
|
|
root.actionKind = "";
|
|
root.actionPath = "";
|
|
root.recentExchange = null;
|
|
root.refresh();
|
|
}
|
|
|
|
Process {
|
|
id: statusProc
|
|
command: [root.helperPath, "status"]
|
|
stdout: StdioCollector {
|
|
onStreamFinished: root.consumeStatus(this.text)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: actionProc
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
root.actionStdoutDone = true;
|
|
root.actionStdoutText = this.text;
|
|
root.settleAction();
|
|
}
|
|
}
|
|
onExited: (code, status) => {
|
|
root.actionExited = true;
|
|
root.settleAction();
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
interval: 30000
|
|
repeat: true
|
|
running: !root.fixtureMode
|
|
triggeredOnStart: true
|
|
onTriggered: root.refresh()
|
|
}
|
|
|
|
Timer {
|
|
id: refreshDelay
|
|
interval: 500
|
|
onTriggered: root.refresh()
|
|
}
|
|
}
|