Files

299 lines
10 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` carry half the
// result each -- the exit and the JSON -- and Quickshell documents no order
// between them. So neither one finalizes on its own: these track which have
// been observed for the action currently in flight, and settleAction() runs
// finishAction() once, on whichever arrives last, with the collected stdout
// 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 --
// 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()
}
}