Home is now Overview | My Home | Phone. Overview leads with quick-action tiles (focus, Do Not Disturb, health, snapshots, storage), keeps the findings card — updates fold in, the reclaim-space prompt is gone on purpose — and adds glance cards, the next calendar event, and weather. My Home groups every light by Home Assistant area: the helper gained an `areas` command (one REST template render, no websocket), and the rooms degrade to a flat list on setups without areas. The favorites editor and connection card moved intact. Phone gains a vitals strip — battery and cell signal read from KDE Connect's plugin D-Bus objects, where absence is data, not an error — beside ring, clipboard, send-a-file, and the BlueBubbles handoff. The retired home-phone id resolves to my-home forever via a new alias map in SettingsRoutes (with a hasOwnProperty guard so prototype names cannot leak into settingsPage). Storage no longer claims 0 B free — the old page read a field the disks helper never emitted. Contracts updated alongside; per the new workflow, the full suite runs once at the end of the redesign (see the test backlog note). Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
300 lines
10 KiB
QML
300 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` 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()
|
|
}
|
|
}
|