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 ?? [] // Vitals are null whenever the phone is away or the plugin is off, which is // the normal state rather than an error -- consumers show nothing, not a // zero. `?? null` keeps `undefined` from leaking out as a distinct case. readonly property var phoneBattery: root.preferredPhone?.battery ?? null readonly property var phoneSignal: root.preferredPhone?.signal ?? null function supports(action: string): bool { return root.phoneActions.indexOf(action) >= 0; } function refresh(): void { if (root.fixtureMode || statusProc.running) return; statusProc.running = true; } // The helper emits `battery` and `signal` as either null or a complete // object. These rebuild them anyway so a partial payload -- an old helper, // a field that arrived as a string -- becomes null rather than a card // rendering "undefined%". function normalizeBattery(value: var): var { if (!value || typeof value.charge !== "number" || value.charge < 0) return null; return { charge: Math.round(value.charge), charging: value.charging === true }; } function normalizeSignal(value: var): var { if (!value || typeof value.strength !== "number" || value.strength < 0) return null; return { networkType: String(value.networkType ?? ""), strength: Math.round(value.strength) }; } function normalizeDevice(device: var): var { return Object.assign({}, device, { battery: root.normalizeBattery(device.battery), signal: root.normalizeSignal(device.signal) }); } 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.map(device => root.normalizeDevice(device)) : []; 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; // Vitals track reachability the way the helper's do: the plugin objects // only exist for a phone that is actually there, so the offline fixture // carries nulls and the reachable ones carry readable values. const reachable = name !== "offline"; root.devices = [{ id: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", name: "Fixture iPhone", type: "phone", paired: true, reachable: reachable, actions: ["clipboard", "ping", "ring", "share"], battery: reachable ? { charge: 82, charging: true } : null, signal: reachable ? { networkType: "LTE", strength: 3 } : null }]; 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() } }