From 7578348db19b7ed73978f72e5b2b6e48bf98deda Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Sun, 23 Aug 2026 22:06:18 -0400 Subject: [PATCH] Merge Home & Phone into a three-tab Home that knows your house MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 3 +- README.md | 2 +- .../quickshell/manual/05-making-it-yours.md | 7 +- .../modules/quicksettings/HomeControls.qml | 2 +- .../modules/settings/HomeLightTile.qml | 163 ++++++ .../quickshell/modules/settings/HomePage.qml | 340 +++++++++---- .../modules/settings/HomeQuickTile.qml | 83 ++++ .../{HomePhonePage.qml => MyHomePage.qml} | 464 ++++++++++-------- .../quickshell/modules/settings/PhonePage.qml | 258 ++++++++++ .../dot/quickshell/modules/settings/README.md | 31 +- .../modules/settings/SettingsShell.qml | 10 +- .../modules/settings/SettingsWindow.qml | 2 +- config/dot/quickshell/modules/settings/qmldir | 5 +- .../quickshell/modules/welcome/Welcome.qml | 2 +- config/dot/quickshell/scripts/panama-doctor | 2 +- .../quickshell/scripts/panama-home-assistant | 64 +++ .../dot/quickshell/scripts/panama-kdeconnect | 195 +++++++- .../quickshell/scripts/panama-settings-docs | 2 +- config/dot/quickshell/services/Health.qml | 2 +- .../dot/quickshell/services/HomeAssistant.qml | 96 ++++ config/dot/quickshell/services/KdeConnect.qml | 49 +- .../quickshell/services/SettingsRoutes.qml | 23 +- .../quickshell/services/SettingsSearch.qml | 10 +- config/dot/quickshell/shell.qml | 2 +- .../{settings-home-phone => settings-my-home} | 8 +- .../share/vicinae/scripts/settings-phone | 10 + docs/settings.md | 2 +- .../2026-08-23-home-category-redesign.md | 109 ++++ ...26-08-23-settings-redesign-test-backlog.md | 29 ++ tests/quickshell/control-center-contract | 4 +- .../quickshell/home-assistant-config-contract | 2 +- tests/quickshell/home-phone-settings-contract | 319 ------------ .../quickshell/home_assistant_bridge_test.py | 177 +++++++ tests/quickshell/kdeconnect-helper-contract | 13 +- tests/quickshell/kdeconnect_bridge_test.py | 192 +++++++- tests/quickshell/my-home-settings-contract | 329 +++++++++++++ tests/quickshell/panama-doctor-contract | 4 +- tests/quickshell/phone-page-contract | 235 +++++++++ tests/quickshell/settings-nav-contract | 27 +- tests/quickshell/settings-pages-contract | 4 +- 40 files changed, 2578 insertions(+), 703 deletions(-) create mode 100644 config/dot/quickshell/modules/settings/HomeLightTile.qml create mode 100644 config/dot/quickshell/modules/settings/HomeQuickTile.qml rename config/dot/quickshell/modules/settings/{HomePhonePage.qml => MyHomePage.qml} (76%) create mode 100644 config/dot/quickshell/modules/settings/PhonePage.qml rename config/local/share/vicinae/scripts/{settings-home-phone => settings-my-home} (60%) create mode 100755 config/local/share/vicinae/scripts/settings-phone create mode 100644 docs/superpowers/specs/2026-08-23-home-category-redesign.md create mode 100644 docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md delete mode 100755 tests/quickshell/home-phone-settings-contract create mode 100755 tests/quickshell/my-home-settings-contract create mode 100755 tests/quickshell/phone-page-contract diff --git a/.gitignore b/.gitignore index d1204ff..daf51bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ -# Ignore bash environment variables. +# Ignore bash environment variables, and the lock its writer takes. /config/bash/env +/config/bash/.env.lock # Personal espanso triggers (name, email), seeded per-machine by setup-identity. /config/dot/espanso/match/identity.yml # Ignore backups of old config files diff --git a/README.md b/README.md index b69cde7..15154df 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ docs/ Settings reference, and the design specs behind the work ## Tests -160 of them, under `tests/`. Run the lot, or a subset by pattern: +162 of them, under `tests/`. Run the lot, or a subset by pattern: ```sh panama test # everything diff --git a/config/dot/quickshell/manual/05-making-it-yours.md b/config/dot/quickshell/manual/05-making-it-yours.md index a0f7121..645ce57 100644 --- a/config/dot/quickshell/manual/05-making-it-yours.md +++ b/config/dot/quickshell/manual/05-making-it-yours.md @@ -2,13 +2,18 @@ ## Settings -`Super + I`. Fifteen categories down the side, covering appearance, displays, +`Super + I`. Fourteen categories down the side, covering appearance, displays, sound, input, network, power, accounts and the rest. A category with more than one subject in it opens a row of tabs above the page, which is where the narrower topics live: Printers is a tab of Network & Sharing, Dictation a tab of Input, and About, Software Update, Storage, Snapshots and this manual are tabs of System. +Home is the first of them, and the page `Super + I` lands on. Its Overview tab +is the one you already know; My Home lists your Home Assistant lights by room +alongside the accessories Control Center shows, and Phone holds the iPhone +handoffs — battery, ring it, send it the clipboard or a file, and Messages. + The search box at the top searches the settings themselves rather than page names, and each result says where it will land, so if you know what you want to change you can type it and never touch the sidebar. diff --git a/config/dot/quickshell/modules/quicksettings/HomeControls.qml b/config/dot/quickshell/modules/quicksettings/HomeControls.qml index 970686d..04d6df9 100644 --- a/config/dot/quickshell/modules/quicksettings/HomeControls.qml +++ b/config/dot/quickshell/modules/quicksettings/HomeControls.qml @@ -301,6 +301,6 @@ Item { function openHomeSettings(): void { ShellState.close(); - ShellState.openSettings("home-phone"); + ShellState.openSettings("my-home"); } } diff --git a/config/dot/quickshell/modules/settings/HomeLightTile.qml b/config/dot/quickshell/modules/settings/HomeLightTile.qml new file mode 100644 index 0000000..80890d8 --- /dev/null +++ b/config/dot/quickshell/modules/settings/HomeLightTile.qml @@ -0,0 +1,163 @@ +// One light on the My Home page: power square, name, state, and a brightness +// slider when the light dims. The slider is Control Center's — one dimmer +// implementation everywhere, covered by home-brightness-slider-contract. + +import QtQuick +import qs.config +import qs.services +import qs.modules.quicksettings + +Rectangle { + id: root + + // A catalog entity: { id, sourceName, name, state, available, active, + // dimmable, brightnessPct }. + property var entity: ({}) + // Whether this light sits in the first four favorites, and therefore in + // Control Center. + property bool featured: false + + readonly property string entityId: String(root.entity.id ?? "") + readonly property bool busy: HomeAssistant.isBusy(root.entityId) + readonly property int pending: HomeAssistant.pendingFor(root.entityId) + readonly property string entityError: HomeAssistant.errorFor(root.entityId) + readonly property bool showsSlider: root.entity.dimmable === true && root.entity.available === true + + radius: 14 + color: Theme.alpha(Theme.fg, 0.05) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.07) + implicitHeight: header.implicitHeight + (showsSlider ? 36 : 0) + + (entityError !== "" ? errorLine.implicitHeight + 4 : 0) + 22 + + Row { + id: header + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 11 + spacing: 11 + + Rectangle { + id: power + + width: 30 + height: 29 + radius: 10 + anchors.verticalCenter: parent.verticalCenter + color: root.entity.active === true + ? Theme.alpha(Theme.warn, 0.16) + : Theme.alpha(Theme.fg, 0.07) + border.width: 1 + border.color: root.entity.active === true + ? Theme.alpha(Theme.warn, 0.4) + : Theme.alpha(Theme.fg, 0.09) + opacity: root.entity.available === true && !root.busy ? 1 : 0.45 + + Text { + anchors.centerIn: parent + text: "\u{F0335}" + color: root.entity.active === true ? Theme.warn : Theme.fgDim + font.family: Theme.fontMono + font.pixelSize: 14 + } + + MouseArea { + anchors.fill: parent + enabled: root.entity.available === true && !root.busy + cursorShape: Qt.PointingHandCursor + onClicked: HomeAssistant.toggleEntity(root.entityId) + } + + Accessible.role: Accessible.Button + Accessible.name: (root.entity.active === true ? "Turn off " : "Turn on ") + + String(root.entity.name ?? root.entity.sourceName ?? "") + } + + Column { + width: parent.width - power.width - parent.spacing + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + + Row { + width: parent.width + spacing: 8 + + Text { + text: String(root.entity.name ?? root.entity.sourceName ?? "") + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.DemiBold + elide: Text.ElideRight + } + + Rectangle { + visible: root.featured + anchors.verticalCenter: parent.verticalCenter + width: featuredCaption.implicitWidth + 16 + height: 19 + radius: Theme.pillRadius + color: Theme.alpha(Theme.accent, 0.1) + border.width: 1 + border.color: Theme.alpha(Theme.accent, 0.2) + + Text { + id: featuredCaption + anchors.centerIn: parent + text: "Control Center" + color: Theme.accent + font.family: Theme.fontFamily + font.pixelSize: 9 + font.weight: Font.DemiBold + } + } + } + + Text { + width: parent.width + text: { + if (root.entity.available !== true) + return "Unavailable"; + if (root.entity.active !== true) + return "Off"; + const pct = root.pending >= 0 ? root.pending : Number(root.entity.brightnessPct ?? 0); + return root.entity.dimmable === true ? "On · " + pct + "%" : "On"; + } + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight + } + } + } + + HomeBrightnessSlider { + visible: root.showsSlider + anchors.left: parent.left + anchors.leftMargin: 11 + anchors.right: parent.right + anchors.rightMargin: 11 + anchors.top: header.bottom + accessibleName: String(root.entity.name ?? root.entity.sourceName ?? "Light") + " brightness" + value: root.pending >= 0 ? root.pending : Number(root.entity.brightnessPct ?? 0) + onCommitted: value => HomeAssistant.setBrightness(root.entityId, value) + } + + Text { + id: errorLine + visible: root.entityError !== "" + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: 11 + text: root.entityError === "unreachable" + ? "Home Assistant did not respond" + : "That change did not apply" + color: Theme.warn + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + } +} diff --git a/config/dot/quickshell/modules/settings/HomePage.qml b/config/dot/quickshell/modules/settings/HomePage.qml index cea0808..3bc8199 100644 --- a/config/dot/quickshell/modules/settings/HomePage.qml +++ b/config/dot/quickshell/modules/settings/HomePage.qml @@ -1,18 +1,11 @@ // Home — the page you open to do something, not to read a report. // -// It used to lead with a diagram of the attached monitor: DP-2, 4500 x 3000, -// 1.13x scale, XRGB2101010. That is Displays-page data, it was the largest -// thing on the screen, and nobody has ever needed it here. Below it sat a -// permanently open search field for a weather location that is set about once a -// year, and then roughly half a page of nothing. -// -// What Panama already knows is the useful material: whether anything needs -// attention, what is running, and the two or three things worth doing next. A -// finding is shown when there is one and the page is quiet when there is not, -// which is the same shape the Firewall and Containers pages use. -// -// Weather stays. It is the one genuinely ambient thing here, and it belongs -// next to a greeting rather than in a card of its own with a search box open. +// The Overview tab of the Home category. Quick actions sit under the greeting +// as tiles; findings follow; then two glance cards into the sibling tabs (My +// Home, Phone), the next calendar event, and weather. The old Focus and +// containers cards retired into the tiles and the System category; the +// reclaim-space prompt is gone on purpose — stopped development containers +// are not clutter, and prompting their deletion was a racket. import QtQuick import qs.config @@ -60,6 +53,14 @@ SettingsPage { action: "Open" }); } + if (Updates.total > 0) { + found.push({ + label: "Install " + Updates.total + " update" + (Updates.total === 1 ? "" : "s"), + detail: "Packages, applications and firmware, from wherever each comes from", + page: "updates", + action: "Open" + }); + } if (Health.summary?.errors > 0 || Health.summary?.warnings > 0) { const count = Number(Health.summary?.errors ?? 0) + Number(Health.summary?.warnings ?? 0); found.push({ @@ -79,7 +80,7 @@ SettingsPage { if (Health.checks.length > 0) parts.push(Health.checks.length + " health checks pass"); if (Disks.rootFilesystem) - parts.push(Disks.formatBytes(Number(Disks.rootFilesystem.available ?? 0)) + " free"); + parts.push(Disks.formatBytes(Number(Disks.rootFilesystem.availBytes ?? 0)) + " free"); const newest = Snapshots.configs.length > 0 ? (Snapshots.configs[0].snapshots ?? [])[0] : null; @@ -106,12 +107,48 @@ SettingsPage { return full.split(" ")[0] || ""; } + // What the glance cards say about the sibling tabs. + readonly property var lightsOn: HomeAssistant.catalog.filter(entity => entity.active === true) + readonly property string myHomeGlance: { + if (HomeAssistant.lastError === "not-configured") + return "Set up Home Assistant"; + if (root.lightsOn.length === 0) + return "No lights are on"; + const names = root.lightsOn.slice(0, 2).map(entity => String(entity.name ?? entity.sourceName)).join(", "); + return root.lightsOn.length + (root.lightsOn.length === 1 ? " light on · " : " lights on · ") + names; + } + readonly property string phoneGlance: { + if (!KdeConnect.preferredPhone) + return "No paired phone"; + const parts = [String(KdeConnect.preferredPhone.name)]; + if (KdeConnect.phoneBattery) + parts.push(KdeConnect.phoneBattery.charge + "%"); + parts.push(KdeConnect.phoneReachable ? "nearby" : "away"); + return parts.join(" · "); + } + + // The next timed calendar event, worded for a card. + readonly property string nextEventWhen: { + const event = CalendarAgenda.nextEvent; + if (!event) + return ""; + const start = new Date(Number(event.start) * 1000); + const clock = start.toLocaleTimeString(Qt.locale(), Locale.ShortFormat); + const today = new Date(); + const sameDay = start.getFullYear() === today.getFullYear() + && start.getMonth() === today.getMonth() && start.getDate() === today.getDate(); + return sameDay ? clock + " today" : clock + " " + start.toLocaleDateString(Qt.locale(), "dddd"); + } + title: root.greetingName !== "" ? `${root.greeting}, ${root.greetingName}` : root.greeting lede: Weather.available ? Math.round(Weather.temperature) + Weather.unitSuffix + " and " + Weather.description.toLowerCase() + " in " + Settings.weatherLocation : "Your desktop is configured and ready." + // These scans also feed the System category's tab gating (Containers, + // Snapshots), so they fire when Settings opens even though this page no + // longer renders their cards. Component.onCompleted: { if (!Firewall.scanned) Firewall.refresh(); @@ -123,90 +160,81 @@ SettingsPage { Disks.refresh(); } - // ── What you came here to do ──────────────────────────────────────────── + // ── Quick actions ─────────────────────────────────────────────────────── Grid { - id: doing + id: quick width: parent.width - columns: width >= 720 ? 2 : 1 - columnSpacing: 16 - rowSpacing: 16 + columns: width >= 640 ? 5 : 2 + columnSpacing: 10 + rowSpacing: 10 - SettingsCard { - width: doing.columns === 2 - ? (doing.width - doing.columnSpacing) / 2 - : doing.width - title: "Focus" - subtitle: FocusSession.active - ? "A session is running · " + FocusSession.remainingText + " left" - : (FocusModes.active - ? FocusModes.activeName + " is on because " + FocusModes.activeReason - : (Notifs.doNotDisturb - ? "Do Not Disturb is on" - : "Nothing is quieting this machine")) + readonly property real tileWidth: (width - (columns - 1) * columnSpacing) / columns - SettingRow { - label: FocusSession.active ? "End the session" : "Start a focus session" - detail: FocusSession.active - ? "Puts Do Not Disturb and Caffeine back as they were" - : Settings.focusDurationMinutes + " minutes · Super+Shift+F" - controlWidth: 120 - - SettingsButton { - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - text: FocusSession.active ? "End" : "Start" - tone: FocusSession.active ? "normal" : "accent" - onClicked: FocusSession.active ? FocusSession.end(false) : FocusSession.startDefault() - } - } - - SettingRow { - label: "Do Not Disturb" - detail: FocusModes.active - ? "Held on by the " + FocusModes.activeName + " mode" - : "Banners are held; the notification centre still fills" - divider: false - controlWidth: 48 - - SettingsToggle { - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - enabled: !FocusModes.active && !FocusSession.active - checked: Notifs.doNotDisturb - onToggled: value => Notifs.doNotDisturb = value - } - } + HomeQuickTile { + width: quick.tileWidth + glyph: "\u{F051F}" + label: FocusSession.active ? "End focus" : "Focus session" + caption: FocusSession.active + ? FocusSession.remainingText + " left · tap to end" + : Settings.focusDurationMinutes + " min · Super+Shift+F" + onActivated: FocusSession.active ? FocusSession.end(false) : FocusSession.startDefault() } - SettingsCard { - width: doing.columns === 2 - ? (doing.width - doing.columnSpacing) / 2 - : doing.width - title: "Right now" - subtitle: Containers.running > 0 - ? Containers.running + " of " + Containers.total + " containers running" - : "No containers are running" - - TextRow { - label: Containers.running > 0 - ? Containers.projects.filter(project => project.running > 0) - .map(project => String(project.title)).join(", ") - : "Nothing to report" - detail: Containers.running > 0 - ? "Started for development, still up" - : "Start a stack from the Containers page" - value: "" + HomeQuickTile { + width: quick.tileWidth + glyph: "\u{F009A}" + glyphColor: Notifs.doNotDisturb ? Theme.warn : Theme.accent + label: "Do Not Disturb" + interactive: !FocusModes.active && !FocusSession.active + caption: { + if (FocusModes.active) + return "Held by the " + FocusModes.activeName + " mode"; + if (FocusSession.active) + return "Held by the focus session"; + return Notifs.doNotDisturb ? "On — tap to allow banners" : "Off — tap to hold banners"; } + onActivated: Notifs.doNotDisturb = !Notifs.doNotDisturb + } - ActionRow { - label: "Containers" - detail: "Projects, what they expose, and what they cost" - action: "Open" - divider: false - onTriggered: ShellState.settingsPage = "containers" + HomeQuickTile { + width: quick.tileWidth + glyph: "\u{F0493}" + glyphColor: Health.status === "error" + ? Theme.danger : (Health.status === "warning" ? Theme.warn : Theme.ok) + label: "Health" + caption: { + if (Health.checks.length === 0) + return "Checking the desktop"; + const count = Number(Health.summary?.errors ?? 0) + Number(Health.summary?.warnings ?? 0); + return count > 0 ? count + " need attention" : Health.checks.length + " checks pass"; } + onActivated: ShellState.openSettings("services") + } + + HomeQuickTile { + width: quick.tileWidth + glyph: "\u{F0954}" + label: "Snapshots" + caption: { + const newest = Snapshots.configs.length > 0 + ? (Snapshots.configs[0].snapshots ?? [])[0] : null; + if (newest) + return "Ran at " + root.clockOf(String(newest.date ?? "")); + return Snapshots.configs.length > 0 ? "Configured" : "Not protected"; + } + onActivated: ShellState.openSettings("snapshots") + } + + HomeQuickTile { + width: quick.tileWidth + glyph: "\u{F02CA}" + label: "Storage" + caption: Disks.rootFilesystem + ? Disks.formatBytes(Number(Disks.rootFilesystem.availBytes ?? 0)) + " free" + : "Reading the drive" + onActivated: ShellState.openSettings("storage") } } @@ -246,29 +274,129 @@ SettingsPage { } } - // ── Worth doing, when there is something ──────────────────────────────── + // ── Glances into the sibling tabs ─────────────────────────────────────── + + Grid { + id: glances + + width: parent.width + columns: width >= 720 ? 2 : 1 + columnSpacing: 16 + rowSpacing: 16 + + readonly property real cardWidth: columns === 2 ? (width - columnSpacing) / 2 : width + + Repeater { + model: [ + { glyph: "\u{F0335}", title: "My Home", sub: root.myHomeGlance, page: "my-home" }, + { glyph: "\u{F011C}", title: "Phone", sub: root.phoneGlance, page: "phone" } + ] + + delegate: Rectangle { + id: glance + + required property var modelData + + width: glances.cardWidth + height: 74 + radius: 14 + color: glanceArea.containsMouse + ? Theme.alpha(Theme.bgPanel, 0.95) + : Theme.alpha(Theme.bgPanel, 0.72) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.07) + + Text { + id: glanceGlyph + anchors.left: parent.left + anchors.leftMargin: 16 + anchors.verticalCenter: parent.verticalCenter + text: String(glance.modelData.glyph) + color: Theme.accent + font.family: Theme.fontMono + font.pixelSize: 19 + } + + Column { + anchors.left: glanceGlyph.right + anchors.leftMargin: 14 + anchors.right: chevron.left + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + spacing: 3 + + Text { + text: String(glance.modelData.title) + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.DemiBold + } + + Text { + width: parent.width + text: String(glance.modelData.sub) + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight + } + } + + Text { + id: chevron + anchors.right: parent.right + anchors.rightMargin: 15 + anchors.verticalCenter: parent.verticalCenter + text: "›" + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: 18 + } + + MouseArea { + id: glanceArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: ShellState.openSettings(String(glance.modelData.page)) + } + + Accessible.role: Accessible.Button + Accessible.name: "Open " + String(glance.modelData.title) + } + } + } + + // ── Today ─────────────────────────────────────────────────────────────── SettingsCard { - visible: Updates.total > 0 || Containers.reclaimable > 0 - title: "Do next" - subtitle: "Small things this machine is waiting on." + visible: CalendarAgenda.available && CalendarAgenda.nextEvent !== null + title: "Today" - ActionRow { - visible: Updates.total > 0 - label: "Install " + Updates.total + " update" + (Updates.total === 1 ? "" : "s") - detail: "Packages, applications and firmware, from wherever each comes from" - action: "Open" - divider: Containers.reclaimable > 0 - onTriggered: ShellState.settingsPage = "updates" - } - - ActionRow { - visible: Containers.reclaimable > 0 - label: "Reclaim " + Containers.formatBytes(Containers.reclaimable) - detail: "Container images and volumes nothing references" - action: "Review" + SettingRow { + label: CalendarAgenda.nextEvent + ? String(CalendarAgenda.nextEvent.summary) + " — " + root.nextEventWhen + : "" + detail: "Next on your calendar" divider: false - onTriggered: ShellState.settingsPage = "containers" + controlWidth: 110 + + SettingsButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: String(CalendarAgenda.nextEvent?.joinUrl ?? "").startsWith("https://") + ? "Join" : "Open" + onClicked: { + const event = CalendarAgenda.nextEvent; + if (!event) + return; + if (String(event.joinUrl ?? "").startsWith("https://")) + CalendarAgenda.join(event); + else + CalendarAgenda.openEvent(event); + } + } } } diff --git a/config/dot/quickshell/modules/settings/HomeQuickTile.qml b/config/dot/quickshell/modules/settings/HomeQuickTile.qml new file mode 100644 index 0000000..827cb86 --- /dev/null +++ b/config/dot/quickshell/modules/settings/HomeQuickTile.qml @@ -0,0 +1,83 @@ +// A quick action on the Home overview: one glyph, one name, one line of state, +// one tap. The fixed set lives in HomePage; this only draws and reports. + +import QtQuick +import qs.config + +Rectangle { + id: root + + property string glyph + property color glyphColor: Theme.accent + property string label + property string caption + property bool interactive: true + + signal activated() + + radius: 14 + color: tileArea.containsMouse && root.interactive + ? Theme.alpha(Theme.fg, 0.09) + : Theme.alpha(Theme.fg, 0.05) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.07) + implicitHeight: body.implicitHeight + 24 + + Column { + id: body + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 12 + spacing: 7 + + Rectangle { + width: 30 + height: 30 + radius: 10 + color: Theme.alpha(root.glyphColor, 0.13) + + Text { + anchors.centerIn: parent + text: root.glyph + color: root.glyphColor + font.family: Theme.fontMono + font.pixelSize: 14 + } + } + + Text { + width: parent.width + text: root.label + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.DemiBold + elide: Text.ElideRight + } + + Text { + width: parent.width + text: root.caption + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + } + } + + MouseArea { + id: tileArea + anchors.fill: parent + hoverEnabled: true + cursorShape: root.interactive ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: if (root.interactive) root.activated() + } + + Accessible.role: Accessible.Button + Accessible.name: root.label + Accessible.description: root.caption +} diff --git a/config/dot/quickshell/modules/settings/HomePhonePage.qml b/config/dot/quickshell/modules/settings/MyHomePage.qml similarity index 76% rename from config/dot/quickshell/modules/settings/HomePhonePage.qml rename to config/dot/quickshell/modules/settings/MyHomePage.qml index 14d34ef..f552e98 100644 --- a/config/dot/quickshell/modules/settings/HomePhonePage.qml +++ b/config/dot/quickshell/modules/settings/MyHomePage.qml @@ -1,12 +1,21 @@ +// My Home — the Home Assistant half of the old Home & Phone page, promoted to +// a tab of Home and taught about rooms. +// +// Lights render grouped by Home Assistant areas when the helper can read them +// (HomeAssistant.rooms); a setup without areas degrades to one flat section +// with no header, which is exactly the old behavior. The favorites editor and +// the connection card move here unchanged — the credential boundary they carry +// is contract-tested and was not worth re-inventing. + import QtQuick import qs.config import qs.services SettingsPage { id: root - objectName: "home-phone-page" - title: "Home & Phone" - lede: "Choose what appears in Control Center and keep phone continuity close at hand." + objectName: "my-home-page" + title: "My Home" + lede: root.homeStatus() property string lightQuery: searchInput.text.trim().toLowerCase() @@ -23,10 +32,20 @@ SettingsPage { : (HomeAssistant.catalog.length === 0 ? "No lights discovered" : "All discovered lights are already selected")) + + // The first four favorites appear in Control Center; the pill on a tile + // says so. Everything else about featuring is HomePreferences' business. + readonly property var featuredIds: HomeAssistant.selectedEntities + .slice(0, 4).map(entity => String(entity.id)) + readonly property var pageDiagnostics: ({ availableLightIds: root.availableLights.map(entity => entity.id), availableEmptyText: root.availableEmptyText, - homeStatus: root.homeStatus() + homeStatus: root.homeStatus(), + rooms: (HomeAssistant.rooms ?? []).map(room => ({ + name: String(room.name ?? ""), + count: (room.lights ?? []).length + })) }) function homeStatus(): string { @@ -34,8 +53,12 @@ SettingsPage { return "Authentication required"; if (HomeAssistant.lastError === "not-configured") return "Home Assistant is not configured"; - if (HomeAssistant.phase === "ready") - return `Connected · ${HomeAssistant.discoveredCount} lights discovered`; + if (HomeAssistant.phase === "ready") { + const named = (HomeAssistant.rooms ?? []).filter(room => String(room.name ?? "") !== "").length; + return named > 0 + ? `Connected · ${HomeAssistant.discoveredCount} lights across ${named} room${named === 1 ? "" : "s"}` + : `Connected · ${HomeAssistant.discoveredCount} lights discovered`; + } if (HomeAssistant.phase === "degraded") return "Last update unavailable · showing saved controls"; if (HomeAssistant.phase === "loading") @@ -64,6 +87,232 @@ SettingsPage { } } + // ── Rooms ─────────────────────────────────────────────────────────────── + + SettingsCard { + visible: HomeAssistant.catalog.length > 0 + title: "Lights" + subtitle: (HomeAssistant.rooms ?? []).some(room => String(room.name ?? "") !== "") + ? "Grouped by Home Assistant areas. Tap to toggle; drag a track to dim." + : "Tap to toggle; drag a track to dim." + + Column { + width: parent.width + spacing: 4 + + Repeater { + model: HomeAssistant.rooms ?? [] + + delegate: Column { + id: roomSection + + required property var modelData + + width: parent.width + spacing: 8 + + Text { + visible: String(roomSection.modelData.name ?? "") !== "" + text: String(roomSection.modelData.name ?? "").toUpperCase() + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + font.letterSpacing: 0.8 + topPadding: 6 + } + + Grid { + id: roomGrid + + width: parent.width + columns: width >= 560 ? 2 : 1 + columnSpacing: 10 + rowSpacing: 10 + bottomPadding: 6 + + Repeater { + model: roomSection.modelData.lights ?? [] + + delegate: HomeLightTile { + required property var modelData + width: roomGrid.columns === 2 + ? (roomGrid.width - roomGrid.columnSpacing) / 2 + : roomGrid.width + entity: modelData + featured: root.featuredIds.indexOf(String(modelData.id)) >= 0 + } + } + } + } + } + } + } + + // ── Accessories (the Control Center selection) ────────────────────────── + + SettingsCard { + title: "Accessories" + subtitle: HomeAssistant.selectedEntities.length === 0 + ? "Select the lights that belong on your Control Center shelf." + : `${Math.min(4, HomeAssistant.selectedEntities.length)} in Control Center · ${HomeAssistant.selectedEntities.length} selected` + + Text { + width: parent.width + visible: HomeAssistant.selectedEntities.length === 0 + text: "Choose lights below to build your Control Center shelf." + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + wrapMode: Text.WordWrap + topPadding: 3 + bottomPadding: 13 + } + + GridView { + id: favoritesGrid + width: parent.width + height: Math.ceil(count / 2) * cellHeight + visible: count > 0 + interactive: false + clip: false + cellWidth: width / 2 + cellHeight: 116 + model: HomeAssistant.selectedEntities + + delegate: HomeFavoriteCard { + required property var modelData + width: GridView.view.cellWidth - 6 + height: 108 + favorite: ({ + id: modelData.id, + alias: modelData.name === modelData.sourceName ? "" : modelData.name + }) + sourceName: modelData.sourceName + featured: index < 4 + canMoveEarlier: index > 0 + canMoveLater: index < favoritesGrid.count - 1 + onAliasCommitted: (id, alias) => HomePreferences.setAlias(id, alias) + onMoveRequested: (id, targetIndex) => HomePreferences.move(id, targetIndex) + onRemoveRequested: id => HomePreferences.remove(id) + } + } + + Rectangle { + width: parent.width + height: 46 + visible: HomePreferences.saveError !== "" + radius: 9 + color: Theme.alpha(Theme.warn, 0.09) + border.width: 1 + border.color: Theme.alpha(Theme.warn, 0.24) + + Text { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.right: retryButton.left + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: HomePreferences.saveError + color: Theme.warn + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight + } + + SettingsButton { + id: retryButton + anchors.right: parent.right + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + text: "Retry" + activeFocusOnTab: true + border.width: activeFocus ? 2 : 1 + border.color: activeFocus ? Theme.warn : Theme.alpha(Theme.fg, 0.08) + onClicked: HomePreferences.retrySave() + Keys.onReturnPressed: HomePreferences.retrySave() + Keys.onSpacePressed: HomePreferences.retrySave() + } + } + + Rectangle { + width: parent.width + height: 38 + radius: 10 + color: Theme.alpha(Theme.fg, searchInput.activeFocus ? 0.08 : 0.05) + border.width: searchInput.activeFocus ? 2 : 1 + border.color: searchInput.activeFocus + ? Theme.alpha(Theme.accent, 0.72) + : Theme.alpha(Theme.fg, 0.065) + + Text { + anchors.left: parent.left + anchors.leftMargin: 11 + anchors.verticalCenter: parent.verticalCenter + text: "\u{F0349}" + color: Theme.fgDim + font.family: Theme.fontMono + font.pixelSize: 14 + } + + TextInput { + id: searchInput + anchors.left: parent.left + anchors.leftMargin: 36 + anchors.right: parent.right + anchors.rightMargin: 11 + anchors.verticalCenter: parent.verticalCenter + activeFocusOnTab: true + color: Theme.fg + selectionColor: Theme.accent + selectedTextColor: Theme.bgDark + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + clip: true + + Text { + anchors.fill: parent + visible: searchInput.text === "" && !searchInput.activeFocus + text: "Search available lights" + color: Theme.fgMuted + font: searchInput.font + verticalAlignment: Text.AlignVCenter + } + } + } + + Column { + width: parent.width + visible: root.availableLights.length > 0 + + Repeater { + model: root.availableLights + + AvailableLightRow { + required property var modelData + width: parent.width + entity: modelData + onAddRequested: id => HomePreferences.add(id) + } + } + } + + Text { + width: parent.width + visible: root.availableLights.length === 0 + text: root.availableEmptyText + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + topPadding: 18 + bottomPadding: 10 + } + } + + // ── Connection ────────────────────────────────────────────────────────── + SettingsCard { title: "Home Assistant" subtitle: root.homeStatus() @@ -216,207 +465,4 @@ SettingsPage { } } } - - SettingsCard { - title: "Control Center lights" - subtitle: HomeAssistant.selectedEntities.length === 0 - ? "Select the lights that belong on your shelf." - : `${Math.min(4, HomeAssistant.selectedEntities.length)} in Control Center · ${HomeAssistant.selectedEntities.length} selected` - - Text { - width: parent.width - visible: HomeAssistant.selectedEntities.length === 0 - text: "Choose lights below to build your Control Center shelf." - color: Theme.fgDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSize - wrapMode: Text.WordWrap - topPadding: 3 - bottomPadding: 13 - } - - GridView { - id: favoritesGrid - width: parent.width - height: Math.ceil(count / 2) * cellHeight - visible: count > 0 - interactive: false - clip: false - cellWidth: width / 2 - cellHeight: 116 - model: HomeAssistant.selectedEntities - - delegate: HomeFavoriteCard { - required property var modelData - width: GridView.view.cellWidth - 6 - height: 108 - favorite: ({ - id: modelData.id, - alias: modelData.name === modelData.sourceName ? "" : modelData.name - }) - sourceName: modelData.sourceName - featured: index < 4 - canMoveEarlier: index > 0 - canMoveLater: index < favoritesGrid.count - 1 - onAliasCommitted: (id, alias) => HomePreferences.setAlias(id, alias) - onMoveRequested: (id, targetIndex) => HomePreferences.move(id, targetIndex) - onRemoveRequested: id => HomePreferences.remove(id) - } - } - - Rectangle { - width: parent.width - height: 46 - visible: HomePreferences.saveError !== "" - radius: 9 - color: Theme.alpha(Theme.warn, 0.09) - border.width: 1 - border.color: Theme.alpha(Theme.warn, 0.24) - - Text { - anchors.left: parent.left - anchors.leftMargin: 12 - anchors.right: retryButton.left - anchors.rightMargin: 12 - anchors.verticalCenter: parent.verticalCenter - text: HomePreferences.saveError - color: Theme.warn - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSmall - elide: Text.ElideRight - } - - SettingsButton { - id: retryButton - anchors.right: parent.right - anchors.rightMargin: 8 - anchors.verticalCenter: parent.verticalCenter - text: "Retry" - activeFocusOnTab: true - border.width: activeFocus ? 2 : 1 - border.color: activeFocus ? Theme.warn : Theme.alpha(Theme.fg, 0.08) - onClicked: HomePreferences.retrySave() - Keys.onReturnPressed: HomePreferences.retrySave() - Keys.onSpacePressed: HomePreferences.retrySave() - } - } - } - - SettingsCard { - title: "Available lights" - subtitle: "Search the Home Assistant catalog by source name or entity ID." - - Rectangle { - width: parent.width - height: 38 - radius: 10 - color: Theme.alpha(Theme.fg, searchInput.activeFocus ? 0.08 : 0.05) - border.width: searchInput.activeFocus ? 2 : 1 - border.color: searchInput.activeFocus - ? Theme.alpha(Theme.accent, 0.72) - : Theme.alpha(Theme.fg, 0.065) - - Text { - anchors.left: parent.left - anchors.leftMargin: 11 - anchors.verticalCenter: parent.verticalCenter - text: "\u{F0349}" - color: Theme.fgDim - font.family: Theme.fontMono - font.pixelSize: 14 - } - - TextInput { - id: searchInput - anchors.left: parent.left - anchors.leftMargin: 36 - anchors.right: parent.right - anchors.rightMargin: 11 - anchors.verticalCenter: parent.verticalCenter - activeFocusOnTab: true - color: Theme.fg - selectionColor: Theme.accent - selectedTextColor: Theme.bgDark - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSize - clip: true - - Text { - anchors.fill: parent - visible: searchInput.text === "" && !searchInput.activeFocus - text: "Search available lights" - color: Theme.fgMuted - font: searchInput.font - verticalAlignment: Text.AlignVCenter - } - } - } - - Column { - width: parent.width - visible: root.availableLights.length > 0 - - Repeater { - model: root.availableLights - - AvailableLightRow { - required property var modelData - width: parent.width - entity: modelData - onAddRequested: id => HomePreferences.add(id) - } - } - } - - Text { - width: parent.width - visible: root.availableLights.length === 0 - text: root.availableEmptyText - color: Theme.fgDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSize - horizontalAlignment: Text.AlignHCenter - wrapMode: Text.WordWrap - topPadding: 18 - bottomPadding: 10 - } - } - - SettingsCard { - title: "Phone continuity" - subtitle: "Keep the Messages handoff independent from phone connectivity." - - SettingRow { - label: "Messages" - detail: "Opens BlueBubbles" - divider: false - controlWidth: 204 - - Row { - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: 12 - - Text { - anchors.verticalCenter: parent.verticalCenter - text: SystemSettings.bluebubblesAvailable ? "Installed" : "Unavailable" - color: SystemSettings.bluebubblesAvailable ? Theme.ok : Theme.fgMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSmall - } - - SettingsButton { - id: openBlueBubblesButton - text: "Open" - enabled: SystemSettings.bluebubblesAvailable - activeFocusOnTab: enabled - border.width: activeFocus ? 2 : 1 - border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) - onClicked: SystemSettings.openApplication("bluebubbles") - Keys.onReturnPressed: if (enabled) SystemSettings.openApplication("bluebubbles") - Keys.onSpacePressed: if (enabled) SystemSettings.openApplication("bluebubbles") - } - } - } - } } diff --git a/config/dot/quickshell/modules/settings/PhonePage.qml b/config/dot/quickshell/modules/settings/PhonePage.qml new file mode 100644 index 0000000..5bd5c08 --- /dev/null +++ b/config/dot/quickshell/modules/settings/PhonePage.qml @@ -0,0 +1,258 @@ +// Phone — the continuity half of the old Home & Phone page, promoted to a tab +// of Home and given vitals. +// +// The vitals strip reads battery and cell signal from KDE Connect's plugin +// D-Bus objects, which exist only while the phone is paired and reachable — a +// missing datum renders as an em-dash, never an error. Actions mirror Control +// Center's semantics exactly: KDE Connect actions need the phone nearby; +// Messages needs only BlueBubbles. + +import QtQuick +import QtQuick.Dialogs +import qs.config +import qs.services + +SettingsPage { + id: root + objectName: "phone-page" + title: "Phone" + lede: { + if (!KdeConnect.available) + return "KDE Connect is unavailable — open System Health for the service."; + if (!KdeConnect.preferredPhone) + return "Pair a phone with KDE Connect to reach it from here."; + return String(KdeConnect.preferredPhone.name) + + (KdeConnect.phoneReachable ? " · nearby on Wi-Fi" : " · not nearby"); + } + + readonly property bool actionsReady: KdeConnect.phoneReachable && !KdeConnect.transferActive + + function localPath(selectedUrl: url): string { + const value = String(selectedUrl); + if (!value.startsWith("file://")) + return ""; + return decodeURIComponent(value.slice(7)); + } + + FileDialog { + id: fileDialog + title: KdeConnect.preferredPhone + ? "Send a file to " + KdeConnect.preferredPhone.name : "Send a file" + fileMode: FileDialog.OpenFile + nameFilters: ["All files (*)"] + onAccepted: { + const path = root.localPath(selectedFile); + if (path !== "") + KdeConnect.sendFile(path); + } + } + + // ── Vitals ────────────────────────────────────────────────────────────── + + Row { + id: vitals + + width: parent.width + spacing: 12 + + Repeater { + model: [ + { + value: KdeConnect.phoneBattery + ? KdeConnect.phoneBattery.charge + "%" : "—", + caption: KdeConnect.phoneBattery + ? (KdeConnect.phoneBattery.charging ? "Battery · charging" : "Battery") + : "Battery" + }, + { + value: KdeConnect.phoneSignal + ? String(KdeConnect.phoneSignal.networkType) : "—", + caption: KdeConnect.phoneSignal + ? "Signal · " + KdeConnect.phoneSignal.strength + " of 4" + : "Signal" + }, + { + value: KdeConnect.phoneReachable + ? "Nearby" + : (KdeConnect.pairedCount > 0 ? "Away" : "Unpaired"), + caption: KdeConnect.phoneReachable + ? "Reachable on Wi-Fi" + : (KdeConnect.pairedCount > 0 + ? "Actions wait for the phone to reconnect" + : "Pair a device to continue") + } + ] + + delegate: Rectangle { + id: vital + + required property var modelData + + width: (vitals.width - vitals.spacing * 2) / 3 + height: 66 + radius: 12 + color: Theme.alpha(Theme.fg, 0.045) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.06) + + Column { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: 14 + anchors.rightMargin: 14 + spacing: 3 + + Text { + text: String(vital.modelData.value) + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeLarge + 3 + font.weight: Font.DemiBold + font.features: Theme.tabularFigures + } + + Text { + width: parent.width + text: String(vital.modelData.caption) + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideRight + } + } + } + } + } + + // ── Reach it ──────────────────────────────────────────────────────────── + + SettingsCard { + title: "Reach it" + subtitle: KdeConnect.transferActive + ? "Sending " + KdeConnect.transferFileName + "…" + : "Everything you actually do weekly, one card." + + SettingRow { + label: "Ring" + detail: "Even when silenced" + controlWidth: 110 + + SettingsButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: "Ring" + enabled: root.actionsReady && KdeConnect.supports("ring") + onClicked: KdeConnect.ring() + } + } + + SettingRow { + label: "Clipboard" + detail: "Send what you just copied" + controlWidth: 110 + + SettingsButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: "Send" + enabled: root.actionsReady && KdeConnect.supports("clipboard") + onClicked: KdeConnect.sendClipboard() + } + } + + SettingRow { + label: "Send a file" + detail: KdeConnect.recentExchange + ? "Last sent " + KdeConnect.recentExchange.fileName + : "Lands in the phone's downloads" + divider: KdeConnect.transferActive + controlWidth: 110 + + SettingsButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: "Choose…" + enabled: root.actionsReady && KdeConnect.supports("share") + onClicked: fileDialog.open() + } + } + + SettingRow { + visible: KdeConnect.transferActive + label: "Sending " + KdeConnect.transferFileName + detail: "To " + KdeConnect.transferDeviceName + divider: false + controlWidth: 110 + + SettingsButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: "Cancel" + tone: "danger" + onClicked: KdeConnect.cancelTransfer() + } + } + } + + // ── Messages ──────────────────────────────────────────────────────────── + + SettingsCard { + title: "Messages" + subtitle: "Kept independent from phone connectivity." + + SettingRow { + label: "iMessage" + detail: "Opens BlueBubbles" + divider: false + controlWidth: 204 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 12 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: SystemSettings.bluebubblesAvailable ? "Installed" : "Unavailable" + color: SystemSettings.bluebubblesAvailable ? Theme.ok : Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + + SettingsButton { + id: openBlueBubblesButton + text: "Open" + enabled: SystemSettings.bluebubblesAvailable + activeFocusOnTab: enabled + border.width: activeFocus ? 2 : 1 + border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) + onClicked: SystemSettings.openApplication("bluebubbles") + Keys.onReturnPressed: if (enabled) SystemSettings.openApplication("bluebubbles") + Keys.onSpacePressed: if (enabled) SystemSettings.openApplication("bluebubbles") + } + } + } + } + + // ── Device ────────────────────────────────────────────────────────────── + + SettingsCard { + title: "Device" + + ActionRow { + label: "Connection" + detail: "Ask the service to look again" + action: "Refresh" + onTriggered: KdeConnect.refresh() + } + + ActionRow { + label: "Device settings" + detail: "Pairing, plugins and service health" + action: "Open" + divider: false + onTriggered: ShellState.openSettings("connectivity") + } + } +} diff --git a/config/dot/quickshell/modules/settings/README.md b/config/dot/quickshell/modules/settings/README.md index 9926bab..bb0d0eb 100644 --- a/config/dot/quickshell/modules/settings/README.md +++ b/config/dot/quickshell/modules/settings/README.md @@ -6,13 +6,21 @@ such rather than half-reimplemented. ## Navigation -The sidebar lists fifteen **categories**, not one row per page. A category -covering several subjects — Input, Network & Sharing, Applications, Users & -Accounts, Privacy & Security, System — draws a tab strip above the page, and -each of its **leaf** pages is one tab; a category with a single subject is a -leaf itself and shows no strip. The column used to be a flat list of thirty-one -pages, which made finding Printers a scan of the whole thing. Grouped, it sits -under Network & Sharing, where somebody looking for it already expects it. +The sidebar lists fourteen **categories**, not one row per page. A category +covering several subjects — Home, Input, Network & Sharing, Applications, +Users & Accounts, Privacy & Security, System — draws a tab strip above the +page, and each of its **leaf** pages is one tab; a category with a single +subject is a leaf itself and shows no strip. The column used to be a flat list +of thirty-one pages, which made finding Printers a scan of the whole thing. +Grouped, it sits under Network & Sharing, where somebody looking for it already +expects it. + +**Home** carries three tabs: **Overview** (`home`, the quick-action tiles, +findings, glance cards, Today and Weather that the shell opens by default), +**My Home** (`my-home`, Home Assistant lights grouped by room, accessories, and +the connection card) and **Phone** (`phone`, vitals, ring, clipboard, send file, +and BlueBubbles Messages). It absorbed the former standalone Home & Phone +category, whose `home-phone` id is now retired. Leaf pages are ordinary `SettingsPage` files and know nothing about this. `SettingsShell` draws the strip and hosts the page inside it, so moving a page @@ -29,6 +37,15 @@ always used, so every existing deep link, IPC call, and search hit keeps working and now lands on the exact tab. A category id is accepted too, and resolves to that category's first available tab. +**A retired id never stops resolving.** When a page is split, merged, or +renamed, its old id goes into `SettingsRoutes.retired` — a map from the dead id +to the leaf that absorbed its content — rather than being deleted. Old Vicinae +commands, shell history, notification handoffs, and muscle memory all hold +those ids, and `resolve()` checks the map first, so `home-phone` still lands on +**My Home** instead of falling back to Home. Retired ids are aliases only: they +are not leaves, so nothing generates a command, a tab, or a doc heading for +them. + Availability gating belongs to the strip rather than the sidebar: `SettingsRoutes.pageAvailable()` drops the Containers tab on a machine without podman and Snapshots without a snapper configuration, but only once a scan has diff --git a/config/dot/quickshell/modules/settings/SettingsShell.qml b/config/dot/quickshell/modules/settings/SettingsShell.qml index 19aedf7..b1f4b52 100644 --- a/config/dot/quickshell/modules/settings/SettingsShell.qml +++ b/config/dot/quickshell/modules/settings/SettingsShell.qml @@ -6,9 +6,9 @@ Rectangle { id: root property var hostWindow: null - readonly property var homePhoneDiagnostics: pageLoader.status === Loader.Ready + readonly property var myHomeDiagnostics: pageLoader.status === Loader.Ready && pageLoader.item - && pageLoader.item.objectName === "home-phone-page" + && pageLoader.item.objectName === "my-home-page" ? pageLoader.item.pageDiagnostics : ({}) readonly property var healthDiagnostics: pageLoader.status === Loader.Ready @@ -134,7 +134,8 @@ Rectangle { case "appearance": return appearancePage; case "displays": return displaysPage; case "connectivity": return connectivityPage; - case "home-phone": return homePhonePage; + case "my-home": return myHomePage; + case "phone": return phonePage; case "desktop": return desktopPage; case "sound": return soundPage; case "gaming": return gamingPage; @@ -213,7 +214,8 @@ Rectangle { Component { id: appearancePage; AppearancePage {} } Component { id: displaysPage; DisplaysPage {} } Component { id: connectivityPage; ConnectivityPage {} } - Component { id: homePhonePage; HomePhonePage {} } + Component { id: myHomePage; MyHomePage {} } + Component { id: phonePage; PhonePage {} } Component { id: desktopPage; DesktopPage {} } Component { id: soundPage; SoundPage {} } Component { id: gamingPage; GamingPage {} } diff --git a/config/dot/quickshell/modules/settings/SettingsWindow.qml b/config/dot/quickshell/modules/settings/SettingsWindow.qml index 1c58eac..2907e49 100644 --- a/config/dot/quickshell/modules/settings/SettingsWindow.qml +++ b/config/dot/quickshell/modules/settings/SettingsWindow.qml @@ -6,7 +6,7 @@ import qs.services FloatingWindow { id: root - readonly property var homePhoneDiagnostics: settingsShell.homePhoneDiagnostics + readonly property var myHomeDiagnostics: settingsShell.myHomeDiagnostics title: "Settings" visible: ShellState.settingsOpen diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 27868a9..e52dfa9 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -11,7 +11,6 @@ AvatarCropper 1.0 AvatarCropper.qml PickerRow 1.0 PickerRow.qml SettingsTabs 1.0 SettingsTabs.qml GamingPage 1.0 GamingPage.qml -HomePhonePage 1.0 HomePhonePage.qml HomeFavoriteCard 1.0 HomeFavoriteCard.qml AvailableLightRow 1.0 AvailableLightRow.qml DesktopPage 1.0 DesktopPage.qml @@ -74,6 +73,10 @@ TimeOfDayRow 1.0 TimeOfDayRow.qml LocationPicker 1.0 LocationPicker.qml FontPicker 1.0 FontPicker.qml MousePage 1.0 MousePage.qml +MyHomePage 1.0 MyHomePage.qml +PhonePage 1.0 PhonePage.qml +HomeQuickTile 1.0 HomeQuickTile.qml +HomeLightTile 1.0 HomeLightTile.qml DictationPage 1.0 DictationPage.qml TextEntryRow 1.0 TextEntryRow.qml PrivacyPage 1.0 PrivacyPage.qml diff --git a/config/dot/quickshell/modules/welcome/Welcome.qml b/config/dot/quickshell/modules/welcome/Welcome.qml index 13b545c..c097892 100644 --- a/config/dot/quickshell/modules/welcome/Welcome.qml +++ b/config/dot/quickshell/modules/welcome/Welcome.qml @@ -1,6 +1,6 @@ // The first thing a new machine shows. // -// Panama has fifteen settings categories and thirty-odd pages inside them, +// Panama has fourteen settings categories and thirty-odd pages inside them, // which is the opposite of the usual problem: somebody arriving from GNOME, // macOS or Windows cannot tell which four things matter. This is those four // things, once, on the first start. diff --git a/config/dot/quickshell/scripts/panama-doctor b/config/dot/quickshell/scripts/panama-doctor index 2756818..e18b800 100755 --- a/config/dot/quickshell/scripts/panama-doctor +++ b/config/dot/quickshell/scripts/panama-doctor @@ -587,7 +587,7 @@ def check_home_assistant(config: DoctorConfig) -> Check: if not configured: return Check("integration.home-assistant", "integrations", "Home Assistant", "unconfigured", "Home Assistant is not configured.") if not helper.is_file(): - return Check("integration.home-assistant", "integrations", "Home Assistant", "warning", "Home Assistant bridge is unavailable.", Action("open", "Open Home settings", target="home-phone")) + return Check("integration.home-assistant", "integrations", "Home Assistant", "warning", "Home Assistant bridge is unavailable.", Action("open", "Open Home settings", target="my-home")) return Check("integration.home-assistant", "integrations", "Home Assistant", "ok", "Home Assistant credentials are configured.") diff --git a/config/dot/quickshell/scripts/panama-home-assistant b/config/dot/quickshell/scripts/panama-home-assistant index dab32aa..fb24ecd 100755 --- a/config/dot/quickshell/scripts/panama-home-assistant +++ b/config/dot/quickshell/scripts/panama-home-assistant @@ -26,6 +26,18 @@ ENV_KEYS = ( "PANAMA_HOME_ASSISTANT_ENTITIES", ) ENTITY_ID = re.compile(r"^[a-z_]+\.[a-z0-9_]+$") +# One template renders the whole area list, so a room grouping costs a single +# request. Home Assistant answers /api/template with the rendered text, which +# `tojson` makes a JSON document the caller can parse like any other response. +AREAS_TEMPLATE = ( + "{% set ns = namespace(items=[]) %}" + "{% for area in areas() %}" + "{% set ns.items = ns.items + [" + "{'id': area, 'name': area_name(area), 'entities': area_entities(area)}" + "] %}" + "{% endfor %}" + "{{ ns.items | tojson }}" +) GNOME_EXTENSION = ( pathlib.Path.home() / ".local/share/gnome-shell/extensions/hass-gshell@geoph9-on-github" @@ -258,6 +270,8 @@ def request_json( error_body = error.read().decode(errors="replace") except OSError: error_body = "" + finally: + error.close() raise BridgeError(public_http_error(error.code, error_body)) from None except (TimeoutError, urllib.error.URLError, OSError): raise BridgeError("unreachable") from None @@ -358,6 +372,53 @@ def collect_snapshot(config: Config) -> dict[str, object]: return collect_catalog(config) +def normalize_areas(raw: Sequence[object]) -> list[dict[str, object]]: + # A template renders whatever Home Assistant happens to hold, so every entry + # is checked on its own: one malformed area is dropped rather than costing + # the caller the whole grouping. Only lights are kept, because lights are + # all the catalog holds today, and an area left with none is not a room the + # UI can draw. + result: list[dict[str, object]] = [] + for item in raw: + if not isinstance(item, dict): + continue + area_id = item.get("id") + name = item.get("name") + entities = item.get("entities") + if not isinstance(area_id, str) or not isinstance(name, str): + continue + if not area_id.strip() or not name.strip() or not isinstance(entities, list): + continue + lights = [ + entity_id + for entity_id in entities + if isinstance(entity_id, str) + and entity_id.startswith("light.") + and ENTITY_ID.fullmatch(entity_id) + ] + if not lights: + continue + result.append( + {"id": area_id.strip(), "name": name.strip(), "entities": lights} + ) + return result + + +def collect_areas(config: Config) -> dict[str, object]: + if not config.configured: + return {"ok": False, "areas": [], "error": "not-configured"} + try: + raw = request_json( + config, "POST", "/api/template", {"template": AREAS_TEMPLATE} + ) + if not isinstance(raw, list): + raise BridgeError("invalid-response") + areas = normalize_areas(raw) + except BridgeError as error: + return {"ok": False, "areas": [], "error": str(error)} + return {"ok": True, "areas": areas, "error": ""} + + def discovered_light_ids(config: Config) -> set[str]: raw = request_json(config, "GET", "/api/states") if not isinstance(raw, list): @@ -466,6 +527,9 @@ def main(argv: list[str]) -> int: elif command in {"catalog", "snapshot"} and len(argv) == 1: result = collect_catalog(config) success = bool(result["ok"]) + elif command == "areas" and len(argv) == 1: + result = collect_areas(config) + success = bool(result["ok"]) elif command == "toggle" and len(argv) == 2: try: result = toggle(config, argv[1]) diff --git a/config/dot/quickshell/scripts/panama-kdeconnect b/config/dot/quickshell/scripts/panama-kdeconnect index ef8d2df..9327881 100755 --- a/config/dot/quickshell/scripts/panama-kdeconnect +++ b/config/dot/quickshell/scripts/panama-kdeconnect @@ -27,6 +27,18 @@ DEVICE_OBJECT_PREFIX = "/modules/kdeconnect/devices" DEVICE_OBJECT_LINE = re.compile( rf"(?P{re.escape(DEVICE_OBJECT_PREFIX)}/(?P[A-Fa-f0-9]{{32,64}}))$" ) +BATTERY_INTERFACE = "org.kde.kdeconnect.device.battery" +CONNECTIVITY_INTERFACE = "org.kde.kdeconnect.device.connectivity_report" +# busctl spells every integer width with its own type code; a property that is +# an int32 today may be read back as a uint32 by another kdeconnectd build. +INT_PROPERTY_TYPES = frozenset({"y", "n", "q", "i", "u", "x", "t"}) +# Vitals are enrichment, not the answer: a phone that has just gone out of range +# can leave a plugin object that blocks, and the status read must not stall +# behind it. Shorter than the eight seconds the identity reads are allowed. +VITALS_TIMEOUT = 3 +# Every device carries both keys so the shell never has to tell "not read" from +# "no such field"; absent vitals are null, not missing. +EMPTY_VITALS: dict[str, object] = {"battery": None, "signal": None} Runner = Callable[..., subprocess.CompletedProcess[str]] @@ -98,14 +110,21 @@ def normalize_device_line( # # The JSON form returns real UTF-8 and needs no unescaping, which is why these # parse a document rather than splitting words. -def parse_property(output: str, expected: str) -> object | None: - """The value of a busctl --json=short property read, or None if it is not - the type asked for.""" +def property_payload(output: str | None) -> dict[str, object] | None: + """The decoded document of a busctl --json=short read, or None when the + output is missing or is not a property document at all.""" try: payload = json.loads(output) except (json.JSONDecodeError, TypeError): return None - if not isinstance(payload, dict) or payload.get("type") != expected: + return payload if isinstance(payload, dict) else None + + +def parse_property(output: str | None, expected: str) -> object | None: + """The value of a busctl --json=short property read, or None if it is not + the type asked for.""" + payload = property_payload(output) + if payload is None or payload.get("type") != expected: return None return payload.get("data") @@ -117,16 +136,31 @@ def parse_loaded_plugins(output: str) -> list[str]: return [str(item) for item in data] -def parse_string_property(output: str) -> str: +def parse_string_property(output: str | None) -> str: data = parse_property(output, "s") return data if isinstance(data, str) else "" -def parse_bool_property(output: str) -> bool | None: +def parse_bool_property(output: str | None) -> bool | None: data = parse_property(output, "b") return data if isinstance(data, bool) else None +def parse_int_property(output: str | None) -> int | None: + """The integer value of a busctl --json=short property read, or None. + + JSON has one number type, so booleans are rejected explicitly: `true` is an + int to isinstance and would otherwise read back as a charge of 1. + """ + payload = property_payload(output) + if payload is None or payload.get("type") not in INT_PROPERTY_TYPES: + return None + data = payload.get("data") + if isinstance(data, bool) or not isinstance(data, int): + return None + return data + + def run_command( command: list[str], *, @@ -222,6 +256,115 @@ def device_property( ) +# Each KDE Connect plugin hangs its own object off the device path, and those +# objects exist only while the device is paired, reachable AND the plugin is +# loaded. A phone with the battery plugin turned off, or one that just walked +# out of range, makes busctl exit non-zero -- that is an absence of data, never +# a fault to report, so every failure mode here collapses to None. +def plugin_property( + device_id: str, + plugin: str, + interface: str, + member: str, + runner: Runner = subprocess.run, +) -> str | None: + """A plugin object's property read as raw busctl output, or None when the + object, the plugin, or the daemon is not there.""" + try: + result = run_command( + [ + "busctl", + "--user", + "--json=short", + "get-property", + "org.kde.kdeconnect", + f"{device_object(device_id)}/{plugin}", + interface, + member, + ], + runner=runner, + timeout=VITALS_TIMEOUT, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + return result.stdout if result.returncode == 0 else None + + +def device_battery( + device_id: str, + runner: Runner = subprocess.run, +) -> dict[str, object] | None: + """Charge and charging state, or None when the phone has no battery to + report. + + Charge is read first because it is the cheapest proof the plugin object + exists at all: when it is missing the other two reads are skipped, which is + the common case for a device that is merely paired. kdeconnectd reports -1 + for "not known yet", and anything outside a percentage is not a charge. + """ + charge = parse_int_property( + plugin_property(device_id, "battery", BATTERY_INTERFACE, "charge", runner) + ) + if charge is None or not 0 <= charge <= 100: + return None + has_battery = parse_bool_property( + plugin_property(device_id, "battery", BATTERY_INTERFACE, "hasBattery", runner) + ) + if has_battery is False: + return None + charging = parse_bool_property( + plugin_property(device_id, "battery", BATTERY_INTERFACE, "isCharging", runner) + ) + return {"charge": charge, "charging": charging is True} + + +def device_signal( + device_id: str, + runner: Runner = subprocess.run, +) -> dict[str, object] | None: + """Cell network type and bar count, or None when there is no cellular + report -- strength is -1 on a phone with no modem or no service.""" + strength = parse_int_property( + plugin_property( + device_id, + "connectivity_report", + CONNECTIVITY_INTERFACE, + "cellularNetworkStrength", + runner, + ) + ) + if strength is None or strength < 0: + return None + network_type = parse_string_property( + plugin_property( + device_id, + "connectivity_report", + CONNECTIVITY_INTERFACE, + "cellularNetworkType", + runner, + ) + ) + return {"networkType": network_type, "strength": strength} + + +def device_vitals( + device: dict[str, object], + runner: Runner = subprocess.run, +) -> dict[str, object]: + """The battery and signal fields for a device entry. + + Only a paired, reachable device is queried; for anything else the plugin + objects cannot exist, so asking would spend busctl calls to learn nothing. + """ + if not (device.get("paired") and device.get("reachable")): + return dict(EMPTY_VITALS) + device_id = str(device["id"]) + return { + "battery": device_battery(device_id, runner), + "signal": device_signal(device_id, runner), + } + + def dbus_device_ids(runner: Runner = subprocess.run) -> list[str]: try: result = run_command( @@ -239,7 +382,11 @@ def dbus_device_ids(runner: Runner = subprocess.run) -> list[str]: ] -def dbus_devices(runner: Runner = subprocess.run) -> list[dict[str, object]]: +def dbus_devices( + runner: Runner = subprocess.run, + *, + vitals: bool = True, +) -> list[dict[str, object]]: devices: list[dict[str, object]] = [] for device_id in dbus_device_ids(runner): try: @@ -266,20 +413,27 @@ def dbus_devices(runner: Runner = subprocess.run) -> list[dict[str, object]]: if plugin in plugins } ) - devices.append( - { - "id": device_id, - "name": name, - "type": device_type or inferred_type(name), - "paired": paired, - "reachable": reachable, - "actions": actions, - } - ) + device = { + "id": device_id, + "name": name, + "type": device_type or inferred_type(name), + "paired": paired, + "reachable": reachable, + "actions": actions, + } + device.update(device_vitals(device, runner) if vitals else EMPTY_VITALS) + devices.append(device) return devices -def collect_status(runner: Runner = subprocess.run) -> dict[str, object]: +# `vitals` is off for the read that guards an action: ringing a phone needs its +# identity and its action list, not its charge, and the extra busctl round trips +# would sit between the tap and the ring. +def collect_status( + runner: Runner = subprocess.run, + *, + vitals: bool = True, +) -> dict[str, object]: try: listing = run_command( ["kdeconnect-cli", "--list-devices"], @@ -309,12 +463,13 @@ def collect_status(runner: Runner = subprocess.run) -> dict[str, object]: device = normalize_device_line(line, plugins, device_type) except ValueError: continue + device.update(device_vitals(device, runner) if vitals else EMPTY_VITALS) devices.append(device) known_ids = {str(device["id"]) for device in devices} devices.extend( device - for device in dbus_devices(runner) + for device in dbus_devices(runner, vitals=vitals) if str(device["id"]) not in known_ids ) @@ -358,7 +513,7 @@ def invoke_action( runner: Runner = subprocess.run, ) -> dict[str, object]: device_id = validate_device_id(device_id) - status = collect_status(runner) + status = collect_status(runner, vitals=False) device = next( ( item diff --git a/config/dot/quickshell/scripts/panama-settings-docs b/config/dot/quickshell/scripts/panama-settings-docs index e8e236d..7519228 100755 --- a/config/dot/quickshell/scripts/panama-settings-docs +++ b/config/dot/quickshell/scripts/panama-settings-docs @@ -52,7 +52,7 @@ class SchemaError(RuntimeError): def read_titles(): """Leaf page id -> the name a person sees, from SettingsRoutes. - The sidebar is fifteen categories of tabs rather than a flat list, so a page + The sidebar is fourteen categories of tabs rather than a flat list, so a page is named the way a hit names it in search: "System › Storage" for a tab, "Displays" for a category that is a page by itself. """ diff --git a/config/dot/quickshell/services/Health.qml b/config/dot/quickshell/services/Health.qml index b36ec4f..be26e9a 100644 --- a/config/dot/quickshell/services/Health.qml +++ b/config/dot/quickshell/services/Health.qml @@ -33,7 +33,7 @@ Singleton { readonly property var statuses: ["ok", "warning", "error", "unconfigured"] readonly property var groups: ["desktop-foundation", "input-media", "integrations", "panama-tools"] readonly property var overallStatuses: ["healthy", "warning", "error"] - readonly property var settingsTargets: ["home-phone", "datetime"] + readonly property var settingsTargets: ["my-home", "datetime"] readonly property var instructionTargets: ["ddc-permissions"] Process { diff --git a/config/dot/quickshell/services/HomeAssistant.qml b/config/dot/quickshell/services/HomeAssistant.qml index 2969635..c79baa4 100644 --- a/config/dot/quickshell/services/HomeAssistant.qml +++ b/config/dot/quickshell/services/HomeAssistant.qml @@ -16,6 +16,7 @@ Singleton { // "loading" | "ready" | "degraded" | "unavailable" property string phase: "loading" property var catalog: [] + property var areas: [] property var selectedEntities: [] property bool stale: false property string lastError: "" @@ -38,6 +39,10 @@ Singleton { property var pendingFixtureTarget: null property int delayedFixtureExitCode: 0 + // Catalog lights grouped by Home Assistant area, recomputed whenever either + // half changes. Both halves are read here so the binding tracks them. + readonly property var rooms: root.composeRooms(root.catalog, root.areas) + readonly property var visibleEntities: root.selectedEntities.slice(0, 4) readonly property int discoveredCount: root.catalog.length readonly property int configuredCount: root.selectedEntities.length @@ -56,6 +61,56 @@ Singleton { if (root.catalog.length === 0) root.phase = "loading"; refreshProc.running = true; + if (!areasProc.running) + areasProc.running = true; + } + + // Rooms hold the SAME entity objects as the catalog, so a tile bound to a + // room light sees exactly what the catalog says. Areas keep the order Home + // Assistant reports them in; catalog lights no area claims fall into a + // trailing "Other" bucket. With no usable areas the whole catalog becomes a + // single unnamed bucket, which the UI renders as a flat list with no header. + function composeRooms(catalog: var, areas: var): var { + const lights = Array.isArray(catalog) ? catalog : []; + const flat = [{ id: "", name: "", lights: lights.slice() }]; + if (!Array.isArray(areas) || areas.length === 0) + return flat; + + const catalogById = {}; + for (let index = 0; index < lights.length; index++) + catalogById[lights[index].id] = lights[index]; + + const claimed = {}; + const grouped = []; + for (let index = 0; index < areas.length; index++) { + const area = areas[index]; + const entityIds = area && Array.isArray(area.entities) ? area.entities : []; + const roomLights = []; + for (let inner = 0; inner < entityIds.length; inner++) { + const entity = catalogById[entityIds[inner]]; + if (!entity || claimed[entity.id] === true) + continue; + claimed[entity.id] = true; + roomLights.push(entity); + } + if (roomLights.length === 0) + continue; + grouped.push({ + id: String(area.id || ""), + name: String(area.name || ""), + lights: roomLights + }); + } + + // Areas that name nothing in the catalog leave every light an orphan; + // one "Other" heading over the whole list is worse than no heading. + if (grouped.length === 0) + return flat; + + const orphans = lights.filter(entity => claimed[entity.id] !== true); + if (orphans.length > 0) + grouped.push({ id: "", name: "Other", lights: orphans }); + return grouped; } function consumeCatalog(text: string): void { @@ -91,6 +146,26 @@ Singleton { } } + // Rooms are a nicety layered over the catalog: a Home Assistant whose + // template endpoint is unavailable still gets its lights. A failure here + // only empties `areas` -- it never touches `phase`, `stale`, or `lastError`. + function consumeAreas(text: string): void { + if (root.fixtureMode) + return; + + let result = null; + try { + result = JSON.parse(text); + } catch (error) { + root.areas = []; + return; + } + + root.areas = result && result.ok === true && Array.isArray(result.areas) + ? result.areas + : []; + } + function rebuildSelection(): void { const favorites = root.fixtureMode ? root.fixtureFavorites @@ -364,6 +439,16 @@ Singleton { ]; } + // Deliberately leaves light.fixture_all and light.fixture_corner unclaimed + // so fixtures exercise the trailing "Other" bucket as well as the rooms. + function fixtureAreas(): var { + return [ + { id: "kitchen", name: "Kitchen", entities: ["light.fixture_kitchen"] }, + { id: "living_room", name: "Living room", entities: ["light.fixture_living", "light.fixture_desk"] }, + { id: "bedroom", name: "Bedroom", entities: ["light.fixture_bedroom", "light.fixture_hall"] } + ]; + } + function fixturePreferenceRecords(): var { return [ { id: "light.fixture_all", alias: " Whole home " }, @@ -479,6 +564,7 @@ Singleton { : (name === "process-slow-actions" ? "slow-success" : ""))); if (name === "unavailable") { root.catalog = []; + root.areas = []; root.fixtureFavorites = []; root.rebuildSelection(); root.phase = "unavailable"; @@ -488,6 +574,7 @@ Singleton { } root.catalog = root.fixtureEntities(); + root.areas = root.fixtureAreas(); root.fixtureFavorites = root.fixturePreferenceRecords(); if (name === "available-extra") { root.catalog = root.catalog.concat([{ @@ -530,6 +617,7 @@ Singleton { root.fixtureProcessMode = ""; root.phase = "loading"; root.catalog = []; + root.areas = []; root.selectedEntities = []; root.fixtureFavorites = []; root.stale = false; @@ -545,6 +633,14 @@ Singleton { } } + Process { + id: areasProc + command: [root.helperPath, "areas"] + stdout: StdioCollector { + onStreamFinished: root.consumeAreas(this.text) + } + } + Process { id: actionProc stdout: StdioCollector { diff --git a/config/dot/quickshell/services/KdeConnect.qml b/config/dot/quickshell/services/KdeConnect.qml index 5507f85..a32b58c 100644 --- a/config/dot/quickshell/services/KdeConnect.qml +++ b/config/dot/quickshell/services/KdeConnect.qml @@ -43,6 +43,12 @@ Singleton { 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; } @@ -53,13 +59,44 @@ Singleton { 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 : []; + root.devices = Array.isArray(result.devices) + ? result.devices.map(device => root.normalizeDevice(device)) + : []; root.lastError = String(result.error ?? ""); } catch (error) { root.available = false; @@ -188,13 +225,19 @@ Singleton { 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: name !== "offline", - actions: ["clipboard", "ping", "ring", "share"] + 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" : ""; diff --git a/config/dot/quickshell/services/SettingsRoutes.qml b/config/dot/quickshell/services/SettingsRoutes.qml index 351d6d6..cffa1b4 100644 --- a/config/dot/quickshell/services/SettingsRoutes.qml +++ b/config/dot/quickshell/services/SettingsRoutes.qml @@ -27,7 +27,11 @@ Singleton { // alias. Three category ids ("applications", "users", "privacy") double as // the id of their first tab, which resolves to the same place either way. readonly property var categories: [ - { page: "home", label: "Home", icon: "\u{F02DC}", tabs: [] }, + { page: "home", label: "Home", icon: "\u{F02DC}", tabs: [ + { page: "home", label: "Overview" }, + { page: "my-home", label: "My Home" }, + { page: "phone", label: "Phone" } + ] }, { page: "appearance", label: "Appearance", icon: "\u{F0E0D}", tabs: [] }, { page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}", tabs: [] }, { page: "displays", label: "Displays", icon: "\u{F0379}", tabs: [] }, @@ -44,7 +48,6 @@ Singleton { { page: "sharing", label: "Sharing" }, { page: "printers", label: "Printers" } ] }, - { page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}", tabs: [] }, { page: "applications", label: "Applications", icon: "\u{F003B}", tabs: [ { page: "applications", label: "Applications" }, { page: "gaming", label: "Gaming" }, @@ -98,10 +101,20 @@ Singleton { return root.categories.find(cat => cat.page === leaf) ?? root.categories[0]; } - // Any id a caller may hold — leaf, category, or garbage — to the leaf that - // should render: a leaf resolves to itself, a category to its first - // available tab, anything unknown to home. + // Retired page ids keep resolving forever: old Vicinae commands, shell + // history, and muscle memory all hold them. Each maps to the leaf that + // absorbed its content. + readonly property var retired: ({ "home-phone": "my-home" }) + + // Any id a caller may hold — leaf, category, retired id, or garbage — to + // the leaf that should render: a leaf resolves to itself, a category to + // its first available tab, anything unknown to home. function resolve(id: string): string { + // hasOwnProperty guard: `retired` is a plain JS object, so a bare + // index would answer for prototype names like "toString" and leak a + // function into settingsPage. + if (Object.prototype.hasOwnProperty.call(root.retired, id)) + return root.retired[id]; const asLeaf = root.categories.some(cat => (cat.page === id && cat.tabs.length === 0) || cat.tabs.some(tab => tab.page === id)); if (asLeaf) diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index c0dc30a..5459e12 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -130,8 +130,14 @@ Singleton { { label: "Language", detail: "The system language, applied to programs started afterwards", page: "region" }, { label: "Regional formats", detail: "How dates, times, and numbers are written", page: "region" }, { label: "Online accounts", detail: "Sign in to mail, calendar, and contacts", page: "accounts" }, - { label: "Home Assistant", detail: "Connect the desktop to a Home Assistant server", page: "home-phone" }, - { label: "Phone", detail: "Pair a phone for messages and notifications", page: "home-phone" }, + { label: "Home Assistant", detail: "Connect the desktop to a Home Assistant server", page: "my-home" }, + { label: "Lights", detail: "Toggle and dim lights, grouped by room", page: "my-home" }, + { label: "Control Center lights", detail: "Choose the accessories on your shelf", page: "my-home" }, + { label: "Phone", detail: "Battery, signal, and reaching your phone", page: "phone" }, + { label: "Ring your phone", detail: "Find it, even when silenced", page: "phone" }, + { label: "Send clipboard to phone", detail: "Hand off what you just copied", page: "phone" }, + { label: "Send a file to phone", detail: "Lands in the phone's downloads", page: "phone" }, + { label: "iMessage", detail: "Opens BlueBubbles", page: "phone" }, { label: "System information", detail: "Kernel, distribution, and hardware", page: "about" }, { label: "Desktop version", detail: "Which Hyprland and Quickshell this session runs", page: "about" }, { label: "Restore defaults", detail: "Return every Panama setting to its shipped value", page: "desktop" }, diff --git a/config/dot/quickshell/shell.qml b/config/dot/quickshell/shell.qml index 9438c54..e483817 100644 --- a/config/dot/quickshell/shell.qml +++ b/config/dot/quickshell/shell.qml @@ -489,7 +489,7 @@ ShellRoot { page: ShellState.settingsPage, discoveredCount: HomeAssistant.discoveredCount, selectedCount: HomeAssistant.configuredCount, - homePhone: settingsWindow.homePhoneDiagnostics + myHome: settingsWindow.myHomeDiagnostics }); } } diff --git a/config/local/share/vicinae/scripts/settings-home-phone b/config/local/share/vicinae/scripts/settings-my-home similarity index 60% rename from config/local/share/vicinae/scripts/settings-home-phone rename to config/local/share/vicinae/scripts/settings-my-home index bd5edd8..71b76bb 100755 --- a/config/local/share/vicinae/scripts/settings-home-phone +++ b/config/local/share/vicinae/scripts/settings-my-home @@ -1,10 +1,10 @@ #!/usr/bin/env bash # Generated by scripts/panama-settings-commands -- do not edit by hand. # @vicinae.schemaVersion 1 -# @vicinae.title Settings: Home & Phone +# @vicinae.title Settings: My Home # @vicinae.mode silent # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg -# @vicinae.description Open Home & Phone in Settings. -# @vicinae.keywords ["settings", "home assistant", "phone"] +# @vicinae.description Open My Home in Settings. +# @vicinae.keywords ["settings", "home assistant", "lights", "control center lights"] -exec "$HOME/.config/quickshell/scripts/panama-action" settings-page home-phone +exec "$HOME/.config/quickshell/scripts/panama-action" settings-page my-home diff --git a/config/local/share/vicinae/scripts/settings-phone b/config/local/share/vicinae/scripts/settings-phone new file mode 100755 index 0000000..60fc328 --- /dev/null +++ b/config/local/share/vicinae/scripts/settings-phone @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Generated by scripts/panama-settings-commands -- do not edit by hand. +# @vicinae.schemaVersion 1 +# @vicinae.title Settings: Phone +# @vicinae.mode silent +# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg +# @vicinae.description Open Phone in Settings. +# @vicinae.keywords ["settings", "phone", "ring your phone", "send clipboard to phone", "send a file to phone", "imessage"] + +exec "$HOME/.config/quickshell/scripts/panama-action" settings-page phone diff --git a/docs/settings.md b/docs/settings.md index 0f4cc28..00dcfaa 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -345,7 +345,7 @@ Found on **Appearance**. ## weather -Found on **Home**. +Found on **Home › Overview**. | Setting | Default | What it does | |---|---|---| diff --git a/docs/superpowers/specs/2026-08-23-home-category-redesign.md b/docs/superpowers/specs/2026-08-23-home-category-redesign.md new file mode 100644 index 0000000..d452b4c --- /dev/null +++ b/docs/superpowers/specs/2026-08-23-home-category-redesign.md @@ -0,0 +1,109 @@ +# Home category redesign — Overview | My Home | Phone + +Approved 2026-08-23 (mock B, "Rooms & Tabs"). Home absorbs Home & Phone and becomes a +three-tab category. Phase-2 feature picks: rooms grouping, phone vitals, Today card. +The reclaim-space prompt is removed. Deferred with reserved slots: Assist command box, +phone notification inbox, scenes/climate/cameras, now-playing, HA todos. + +## Taxonomy + +`SettingsRoutes` home category gains tabs; the `home-phone` category is deleted: + +``` +{ page: "home", label: "Home", tabs: [ + { page: "home", label: "Overview" }, + { page: "my-home", label: "My Home" }, + { page: "phone", label: "Phone" } +] } +``` + +`home` doubles as category id and first tab (the applications/users/privacy pattern). +New leaf ids `my-home`, `phone`. A new alias map in `SettingsRoutes.resolve()` sends +the retired `home-phone` id to `my-home` so muscle memory, old Vicinae commands, and +`panama-action settings-page home-phone` keep landing correctly. In-repo callers are +updated to the new ids anyway: `HomeControls.qml`, `panama-doctor`'s HA-warning target, +`Health.qml`'s `settingsTargets`. + +## Pages + +**HomePage.qml (Overview) — rebuilt.** Greeting header and weather lede stay. Then: +- A five-tile quick-action grid (new `HomeQuickTile.qml`): Focus session (start/end), + Do Not Disturb (live toggle state), Health (→ `services`), Snapshots (→ `snapshots`), + Storage (→ `storage`). Fixed set, per the phase decision. +- "This machine": the existing findings array unchanged (firewall exposure, security + advisories, reboot-needed, health attention) plus the "Install N updates" row folded + in as a finding when `Updates.total > 0`. The reclaim-space row is gone entirely. +- Two glance cards linking into the sibling tabs: My Home ("2 lights on · Living Room, + Kitchen") and Phone ("iPhone · 82% · nearby"), each `openSettings("my-home"/"phone")`. +- "Today": `CalendarAgenda.nextEvent` with Join/Open; hidden when nothing upcoming or + no calendar. Zero new plumbing — the service already streams. +- "Weather": the existing card, unchanged. +- The old Focus card and "Right now" containers card are retired (tiles and the System + category cover them). + +**MyHomePage.qml (new, `objectName: "my-home-page"`).** Absorbs the Home-Assistant half +of HomePhonePage: +- "Rooms" card: every catalog light grouped by HA area, rendered as interactive tiles + (new `HomeLightTile.qml`: power square, name, state, brightness track for dimmables, + "Control Center" pill on the first four favorites). Toggle → `HomeAssistant.toggleEntity`, + brightness → `setBrightness` (commit-on-release, like `HomeBrightnessSlider`). Falls + back to a single flat "Lights" section when areas are unavailable. +- "Accessories" card: the existing favorites editor (`HomeFavoriteCard` grid — alias, + reorder, remove, Control Center badge) and the existing Available-lights search + (`AvailableLightRow`), moved intact. +- "Home Assistant" card: the existing connection card (URL, token, save/clear, refresh, + open) moved intact. +- Exposes `pageDiagnostics` (same shape as HomePhonePage's plus `rooms` summary); + the IPC key in `settings status` renames `homePhone` → `myHome`. + +**PhonePage.qml (new, `objectName: "phone-page"`).** +- Vitals strip: three stat blocks — Battery (% + charging), Signal (type + strength), + Reachability — from the extended KdeConnect service. A stat whose datum is absent + (unpaired, plugin missing) shows an em-dash, never an error. +- "Reach it" card: Ring, Send clipboard, Send file (FileDialog, the quicksettings + pattern) via existing service functions; transfer progress/cancel as in Control Center. +- "Messages" card: BlueBubbles status + open (moved from HomePhonePage). +- "Device" card: pairing/plugins handoff to `connectivity` (as Control Center does). + +`HomePhonePage.qml` is deleted. `HomeFavoriteCard`/`AvailableLightRow` survive under +MyHomePage. + +## Service contracts (pages are written against these; helpers implement them) + +**`panama-home-assistant areas` (new command).** REST-only: `POST /api/template` +rendering a Jinja template that emits `[{ "id", "name", "entities": [...] }]` via +`areas()` / `area_name()` / `area_entities()` / `tojson`. Same redacted error codes as +the other commands. `HomeAssistant.qml` gains `areas: []` (fetched with refresh) and +`rooms: [{ id, name, lights: [entity] }]` — catalog lights joined to areas, un-areaed +lights in a trailing "Other" bucket; empty `areas` ⇒ `rooms` is one unnamed bucket. + +**`panama-kdeconnect status` (extended).** Per reachable paired device, read via +`busctl get-property` from the plugin objects (paths exist only when paired+reachable — +absence is data, not an error): `battery: { charge: int, charging: bool } | null` from +`org.kde.kdeconnect.device.battery`, `signal: { networkType: string, strength: 0-4 } | null` +from `...device.connectivity_report`. `KdeConnect.qml` surfaces `phoneBattery`, +`phoneSignal` (null when absent). Unit tests extend the two bridge test files' fake +busctl/CLI fixtures. + +## Blast radius owned by this change + +- `SettingsShell.qml`: `my-home`/`phone` cases + components; `homePhoneDiagnostics` + plumbing renamed and repointed at MyHomePage (`SettingsWindow.qml`, `shell.qml` + status JSON key `myHome`). +- `SettingsSearch.qml`: `home-phone` extraEntries re-pointed (`my-home` for Home + Assistant entries, `phone` for phone entries); new entries for rooms, phone battery, + ring, clipboard, Today. +- Generators re-run: Vicinae commands (`settings-home-phone` → `settings-my-home` + + `settings-phone`; `home` still skipped so Overview adds no command), `docs/settings.md`. +- Contracts: `home-phone-settings-contract` rewritten as `my-home-settings-contract` + (+ phone coverage), `control-center-contract` (Manage target), `health-ui-contract` / + `panama-doctor-contract` (settings target), nav/jump/search/gnome-handoff/ + panama-commands re-verified against the new route table. `home-preferences-contract` + and `phone-messages-contract` unchanged. +- Docs: settings README, manual chapter 05 (Home description), Welcome comment + (fourteen categories). + +## Non-goals (reserved slots, later phases) + +Assist box, notification mirror/reply, scenes, climate, cameras, now-playing, todos, +HA WebSocket `--watch` streaming, customizable quick actions. diff --git a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md new file mode 100644 index 0000000..5380e9f --- /dev/null +++ b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md @@ -0,0 +1,29 @@ +# Settings redesign — deferred test runs + +No contract is executed while the settings redesign is in flight: many are live +harnesses that open windows, drive overlays, and run display transactions on +the real desktop. Everything below runs **once, at the end of the redesign, +with Gabriel's go-ahead**, and failures get fixed then. + +## The run + +- `panama test` — the full suite (162 contracts at last count; the top-level + README's count line must match the final number). + +## Known items to verify or investigate at the end + +- `quickshell/welcome-contract` — failed once ("the welcome screen did not + open") during a full-suite run storming the live session; passed in + isolation minutes earlier after the Welcome.qml comment edit. Suspected + contention flake, not a regression. Confirm. +- `setup/readme-contract` — counts contracts; keep the README number in sync + as later phases add contracts (phase 2 added `phone-page-contract`). +- Phase 2 additions already written and passing when last run: + `my-home-settings-contract` (renamed from `home-phone-settings-contract`), + `phone-page-contract`, extended `home_assistant_bridge_test.py` and + `kdeconnect_bridge_test.py`. +- Each later phase (Appearance, …) appends its new/changed contracts here + instead of running them. + +## Phase 3 (Appearance) — append below + diff --git a/tests/quickshell/control-center-contract b/tests/quickshell/control-center-contract index f11d210..9013dc2 100755 --- a/tests/quickshell/control-center-contract +++ b/tests/quickshell/control-center-contract @@ -134,8 +134,8 @@ rg -Fq 'HomeAssistant.pendingFor(' "$quicksettings_path/HomeControls.qml" \ || fail 'Home tiles do not receive per-light pending brightness' rg -Fq 'HomeAssistant.setBrightness(' "$quicksettings_path/HomeControls.qml" \ || fail 'Home brightness commits are not wired to the service' -rg -Fq 'ShellState.openSettings("home-phone")' "$quicksettings_path/HomeControls.qml" \ - || fail 'Manage in Settings does not open Home & Phone' +rg -Fq 'ShellState.openSettings("my-home")' "$quicksettings_path/HomeControls.qml" \ + || fail 'Manage in Settings does not open My Home' rg -Fq 'signal previewChanged(int value)' "$quicksettings_path/HomeBrightnessSlider.qml" \ || fail 'brightness slider has no preview contract' diff --git a/tests/quickshell/home-assistant-config-contract b/tests/quickshell/home-assistant-config-contract index b520dd5..55d635d 100755 --- a/tests/quickshell/home-assistant-config-contract +++ b/tests/quickshell/home-assistant-config-contract @@ -5,7 +5,7 @@ set -euo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" helper="$repo_dir/config/dot/quickshell/scripts/panama-home-assistant-config" service="$repo_dir/config/dot/quickshell/services/HomeAssistantConfig.qml" -page="$repo_dir/config/dot/quickshell/modules/settings/HomePhonePage.qml" +page="$repo_dir/config/dot/quickshell/modules/settings/MyHomePage.qml" password_field="$repo_dir/config/dot/quickshell/modules/settings/PasswordField.qml" harness_fixture="$repo_dir/tests/quickshell/HomeAssistantConfigHarness.qml" work="$(mktemp -d /tmp/panama-ha-config.XXXXXX)" diff --git a/tests/quickshell/home-phone-settings-contract b/tests/quickshell/home-phone-settings-contract deleted file mode 100755 index 01f756b..0000000 --- a/tests/quickshell/home-phone-settings-contract +++ /dev/null @@ -1,319 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -state_home="$(mktemp -d /tmp/panama-home-phone-settings-state.XXXXXX)" -source_config_path="$repo_dir/config/dot/quickshell" -config_path="$state_home/quickshell" -test_bin="$state_home/bin" -shell_log="$state_home/quickshell.log" -flatpak_log="$state_home/flatpak.log" - -cleanup_bootstrap() { - rm -rf "$state_home" -} -trap cleanup_bootstrap EXIT - -mkdir -p "$test_bin" -cp -a "$source_config_path" "$config_path" -: >"$flatpak_log" - -cat >"$config_path/scripts/panama-home-assistant" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail - -case "${1:-}" in - catalog) - printf '%s\n' '{"ok":false,"error":"test-helper"}' - ;; - toggle|brightness) - printf '%s\n' '{"ok":true}' - ;; -esac -EOF -chmod +x "$config_path/scripts/panama-home-assistant" - -cat >"$test_bin/hyprctl" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail - -if [[ "${1:-}" == "-j" && "${2:-}" == "monitors" ]]; then - printf '%s\n' '[{"focused":true,"name":"TEST-1","description":"Home and Phone contract","width":1920,"height":1080,"refreshRate":60,"scale":1,"currentFormat":"XRGB8888","colorManagementPreset":"srgb","vrr":false}]' - exit 0 -fi -if [[ "${1:-}" == "keyword" ]]; then - exit 0 -fi -exec /usr/sbin/hyprctl "$@" -EOF -chmod +x "$test_bin/hyprctl" - -cat >"$test_bin/flatpak" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail - -printf '%s\n' "$*" >>"$PANAMA_FLATPAK_LOG" -if [[ "${1:-}" == "info" && "${2:-}" == "app.bluebubbles.BlueBubbles" ]]; then - exit 0 -fi -exit 97 -EOF -chmod +x "$test_bin/flatpak" - -fail() { - printf 'home phone settings contract: %s\n' "$1" >&2 - exit 1 -} - -assert_contains() { - local needle="$1" - local file="$2" - rg -Fq "$needle" "$file" || fail "$file is missing: $needle" -} - -home_page="$repo_dir/config/dot/quickshell/modules/settings/HomePhonePage.qml" -favorite_card="$repo_dir/config/dot/quickshell/modules/settings/HomeFavoriteCard.qml" -available_row="$repo_dir/config/dot/quickshell/modules/settings/AvailableLightRow.qml" -system_settings="$repo_dir/config/dot/quickshell/services/SystemSettings.qml" - -[[ -f "$home_page" ]] || fail 'HomePhonePage.qml is missing' -[[ -f "$favorite_card" ]] || fail 'HomeFavoriteCard.qml is missing' -[[ -f "$available_row" ]] || fail 'AvailableLightRow.qml is missing' -assert_contains 'SettingsPage {' "$home_page" -assert_contains 'title: "Home & Phone"' "$home_page" -assert_contains 'lede: "Choose what appears in Control Center and keep phone continuity close at hand."' "$home_page" -if rg -q '^\s*Flickable \{' "$home_page"; then - fail 'HomePhonePage.qml still owns a copied Flickable scaffold' -fi -assert_contains 'Connected · ' "$home_page" -assert_contains 'Last update unavailable · showing saved controls' "$home_page" -assert_contains 'Authentication required' "$home_page" -assert_contains 'Home Assistant is not configured' "$home_page" -assert_contains 'HomePreferences.setAlias' "$home_page" -assert_contains 'HomePreferences.move' "$home_page" -assert_contains 'HomePreferences.remove' "$home_page" -assert_contains 'HomePreferences.add' "$home_page" -assert_contains 'HomePreferences.retrySave' "$home_page" -assert_contains 'Choose lights below to build your Control Center shelf.' "$home_page" -assert_contains 'All discovered lights are already selected' "$home_page" -assert_contains 'No lights discovered' "$home_page" -assert_contains 'No lights match that search' "$home_page" -assert_contains 'Opens BlueBubbles' "$home_page" -[[ "$(rg -Fc 'required property var modelData' "$home_page")" -ge 2 ]] \ - || fail 'HomePhonePage.qml does not bind both reusable delegates to modelData' -if rg -Fq 'index: model.index' "$home_page"; then - fail 'HomePhonePage.qml reads an undefined model.index instead of the delegate index' -fi -if rg -qi 'bearer|api/states' "$home_page"; then - fail 'HomePhonePage.qml crosses the Home Assistant REST boundary' -fi -assert_contains 'signal aliasCommitted(string id, string alias)' "$favorite_card" -assert_contains 'signal removeRequested(string id)' "$favorite_card" -assert_contains 'signal moveRequested(string id, int targetIndex)' "$favorite_card" -assert_contains 'text: "↑"' "$favorite_card" -assert_contains 'text: "↓"' "$favorite_card" -assert_contains 'enabled: root.canMoveEarlier' "$favorite_card" -assert_contains 'enabled: root.canMoveLater' "$favorite_card" -if rg -Fq 'DragHandler {' "$favorite_card"; then - fail 'Home light cards still expose the broken drag affordance' -fi -assert_contains 'canMoveEarlier: index > 0' "$home_page" -assert_contains 'canMoveLater: index < favoritesGrid.count - 1' "$home_page" -assert_contains 'onEditingFinished:' "$favorite_card" -assert_contains 'text: "Control Center"' "$favorite_card" -assert_contains 'activeFocusOnTab: true' "$favorite_card" -assert_contains 'signal addRequested(string id)' "$available_row" -assert_contains 'activeFocusOnTab: true' "$available_row" -assert_contains 'readonly property bool bluebubblesAvailable: root.bluebubblesDetected' "$system_settings" -assert_contains 'command: ["flatpak", "info", "app.bluebubbles.BlueBubbles"]' "$system_settings" -assert_contains '"bluebubbles": ["flatpak", "run", "app.bluebubbles.BlueBubbles"]' "$system_settings" - -qs_for_test() { - PATH="$test_bin:$PATH" QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \ - PANAMA_FLATPAK_LOG="$flatpak_log" qs -p "$config_path" "$@" -} - -stop_test_shell() { - qs_for_test kill >/dev/null 2>&1 || true - for _ in $(seq 1 80); do - if ! qs_for_test list 2>/dev/null | rg '^Instance ' >/dev/null \ - && ! qs_for_test ipc show >/dev/null 2>&1; then - return 0 - fi - sleep 0.1 - done - return 1 -} - -cleanup() { - qs_for_test ipc call settings close >/dev/null 2>&1 || true - if stop_test_shell; then - rm -rf "$state_home" - else - printf 'home phone settings contract: branch shell did not stop; retained %s\n' \ - "$state_home" >&2 - fi -} -trap cleanup EXIT - -start_test_shell() { - stop_test_shell || fail 'pre-existing branch shell did not stop cleanly' - for _attempt in 1 2; do - qs_for_test --daemonize >"$shell_log" 2>&1 - for _ in $(seq 1 80); do - if qs_for_test ipc show 2>/dev/null | rg '^target settings-system$' >/dev/null; then - return - fi - sleep 0.1 - done - stop_test_shell || fail 'failed branch-shell attempt did not stop cleanly' - done - sed -n '1,240p' "$shell_log" >&2 - fail 'isolated branch shell did not start' -} - -start_test_shell -qs_for_test ipc call home-assistant fixture ready >/dev/null -qs_for_test ipc call settings page home-phone >/dev/null - -status='{}' -for _ in $(seq 1 40); do - status="$(qs_for_test ipc call settings status | jq -c .)" - if jq -e '.page == "home-phone" and .discoveredCount == 7 and .selectedCount == 7' \ - <<<"$status" >/dev/null; then - break - fi - sleep 0.1 -done -jq -e ' - .open == true and - .page == "home-phone" and - .discoveredCount == 7 and - .selectedCount == 7 and - .homePhone.availableLightIds == [] and - .homePhone.availableEmptyText == "All discovered lights are already selected" -' \ - <<<"$status" >/dev/null || fail "Home & Phone diagnostics are incomplete: $status" - -qs_for_test ipc call home-assistant fixture available-extra >/dev/null -available_status='{}' -for _ in $(seq 1 40); do - available_status="$(qs_for_test ipc call settings status | jq -c .)" - if jq -e ' - .discoveredCount == 8 and - .selectedCount == 7 and - .homePhone.availableLightIds == ["light.fixture_guest"] - ' <<<"$available_status" >/dev/null; then - break - fi - sleep 0.1 -done -jq -e ' - .discoveredCount == 8 and - .selectedCount == 7 and - .homePhone.availableLightIds == ["light.fixture_guest"] -' <<<"$available_status" >/dev/null \ - || fail "an unselected fixture light was not the only available row: $available_status" - -qs_for_test ipc call home-assistant fixture stale-authentication >/dev/null -authentication_status='{}' -for _ in $(seq 1 40); do - authentication_status="$(qs_for_test ipc call settings status | jq -c .)" - if jq -e ' - .discoveredCount == 7 and - .selectedCount == 7 and - .homePhone.homeStatus == "Authentication required" - ' <<<"$authentication_status" >/dev/null; then - break - fi - sleep 0.1 -done -jq -e ' - .discoveredCount == 7 and - .selectedCount == 7 and - .homePhone.homeStatus == "Authentication required" -' <<<"$authentication_status" >/dev/null \ - || fail "stale authentication did not retain actionable copy: $authentication_status" - -qs_for_test ipc call home-assistant fixture stale-not-configured >/dev/null -not_configured_status='{}' -for _ in $(seq 1 40); do - not_configured_status="$(qs_for_test ipc call settings status | jq -c .)" - if jq -e ' - .discoveredCount == 7 and - .selectedCount == 7 and - .homePhone.homeStatus == "Home Assistant is not configured" - ' <<<"$not_configured_status" >/dev/null; then - break - fi - sleep 0.1 -done -jq -e ' - .discoveredCount == 7 and - .selectedCount == 7 and - .homePhone.homeStatus == "Home Assistant is not configured" -' <<<"$not_configured_status" >/dev/null \ - || fail "stale not-configured state did not retain actionable copy: $not_configured_status" - -qs_for_test ipc call home-assistant fixture unavailable >/dev/null -empty_status='{}' -for _ in $(seq 1 40); do - empty_status="$(qs_for_test ipc call settings status | jq -c .)" - if jq -e ' - .discoveredCount == 0 and - .selectedCount == 0 and - .homePhone.homeStatus == "Home Assistant is not configured" and - .homePhone.availableLightIds == [] and - .homePhone.availableEmptyText == "No lights discovered" - ' <<<"$empty_status" >/dev/null; then - break - fi - sleep 0.1 -done -jq -e ' - .discoveredCount == 0 and - .selectedCount == 0 and - .homePhone.homeStatus == "Home Assistant is not configured" and - .homePhone.availableLightIds == [] and - .homePhone.availableEmptyText == "No lights discovered" -' <<<"$empty_status" >/dev/null \ - || fail "an empty catalog did not render its distinct copy: $empty_status" - -system_status="$(qs_for_test ipc call settings-system status | jq -c .)" -jq -e '.bluebubblesAvailable == true' <<<"$system_status" >/dev/null \ - || fail "BlueBubbles availability was not exposed: $system_status" - -shell_pid="$(qs_for_test list | awk '/Process ID:/ { print $3; exit }')" -[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process' -for _ in $(seq 1 40); do - if /usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \ - '[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null; then - break - fi - sleep 0.1 -done -/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \ - '[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null \ - || fail 'the branch shell did not own exactly one tiled Panama Settings client' - -if [[ -n "${PANAMA_TEST_SCREENSHOT_PATH:-}" ]]; then - geometry="$(/usr/sbin/hyprctl -j clients | jq -r --argjson pid "$shell_pid" \ - '.[] | select(.pid == $pid and .title == "Settings") | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')" - [[ -n "$geometry" ]] || fail 'could not resolve the Settings client geometry' - grim -g "$geometry" "$PANAMA_TEST_SCREENSHOT_PATH" -fi - -rg -Fxq 'info app.bluebubbles.BlueBubbles' "$flatpak_log" \ - || fail 'the fixed BlueBubbles availability probe did not run' -if rg -q '^run ' "$flatpak_log"; then - fail 'the contract started BlueBubbles' -fi -if find "$state_home" -name panama-home.json -print -quit | rg -q .; then - fail 'the read-only fixture route wrote Home preferences' -fi - -trap - EXIT -cleanup -[[ ! -e "$state_home" ]] || fail 'temporary Home & Phone state was not removed after shell exit' -printf 'home phone settings contract: PASS\n' diff --git a/tests/quickshell/home_assistant_bridge_test.py b/tests/quickshell/home_assistant_bridge_test.py index b9e5002..d149523 100644 --- a/tests/quickshell/home_assistant_bridge_test.py +++ b/tests/quickshell/home_assistant_bridge_test.py @@ -5,7 +5,9 @@ from __future__ import annotations import importlib.machinery import importlib.util import json +import os import pathlib +import subprocess import sys import threading import unittest @@ -16,6 +18,23 @@ ROOT = pathlib.Path(__file__).resolve().parents[2] HELPER = ROOT / "config/dot/quickshell/scripts/panama-home-assistant" QML_SERVICE = ROOT / "config/dot/quickshell/services/HomeAssistant.qml" +# What a Home Assistant would render for the areas template: every area it +# holds, including ones whose entities are not lights, plus entries no template +# should produce but a bridge still has to survive. +TEMPLATE_AREAS = [ + { + "id": "kitchen", + "name": "Kitchen", + "entities": ["light.kitchen", "switch.kettle", "sensor.private"], + }, + {"id": "hallway", "name": "Hall", "entities": ["light.hall", "light.corner"]}, + {"id": "garage", "name": "Garage", "entities": ["sensor.garage_door"]}, + {"id": 12, "name": "Numeric", "entities": ["light.kitchen"]}, + {"id": "unnamed", "name": "", "entities": ["light.kitchen"]}, + {"id": "not_a_list", "name": "Loose", "entities": "light.kitchen"}, + "not-an-area", +] + loader = importlib.machinery.SourceFileLoader("panama_home_assistant", str(HELPER)) spec = importlib.util.spec_from_loader(loader.name, loader) bridge = importlib.util.module_from_spec(spec) @@ -25,6 +44,8 @@ loader.exec_module(bridge) class FakeHomeAssistant(BaseHTTPRequestHandler): requests: list[dict[str, object]] = [] + # "rendered" | "malformed" | "unauthorized" + template_mode: str = "rendered" def log_message(self, _format: str, *args: object) -> None: del args @@ -47,6 +68,25 @@ class FakeHomeAssistant(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(payload) + # Home Assistant answers /api/template with the rendered text, not with a + # JSON document, so the fixture replies in kind. + def _text(self, body: str, status: int = 200) -> None: + payload = body.encode() + self.send_response(status) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def _template(self) -> None: + if self.template_mode == "unauthorized": + self._json({"message": "Unauthorized: token abc123"}, 401) + return + if self.template_mode == "malformed": + self._text("Error rendering template: UndefinedError") + return + self._text(json.dumps(TEMPLATE_AREAS)) + def do_GET(self) -> None: self._record() if self.path == "/api/": @@ -95,6 +135,9 @@ class FakeHomeAssistant(BaseHTTPRequestHandler): length = int(self.headers.get("Content-Length", "0")) body = self.rfile.read(length) self._record(body) + if self.path == "/api/template": + self._template() + return if self.path in { "/api/services/homeassistant/toggle", "/api/services/light/turn_on", @@ -121,6 +164,7 @@ class HomeAssistantBridgeTest(unittest.TestCase): def setUp(self) -> None: FakeHomeAssistant.requests.clear() + FakeHomeAssistant.template_mode = "rendered" def config(self) -> object: return bridge.Config( @@ -129,6 +173,15 @@ class HomeAssistantBridgeTest(unittest.TestCase): entity_ids=("light.kitchen", "light.hall"), ) + def helper_env(self) -> dict[str, str]: + return { + "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + "HOME": os.environ.get("HOME", "/tmp"), + "PANAMA_HOME_ASSISTANT_URL": self.base_url, + "PANAMA_HOME_ASSISTANT_TOKEN": "fixture-token", + "PANAMA_HOME_ASSISTANT_ENTITIES": "light.kitchen", + } + def test_environment_configuration_wins_over_legacy_sources(self) -> None: env = { "PANAMA_HOME_ASSISTANT_URL": "https://home.example", @@ -203,6 +256,108 @@ class HomeAssistantBridgeTest(unittest.TestCase): ["Kitchen", "Hall", "Corner"], ) + def test_areas_group_lights_by_home_assistant_area(self) -> None: + result = bridge.collect_areas(self.config()) + + self.assertTrue(result["ok"]) + self.assertEqual(result["error"], "") + self.assertEqual( + result["areas"], + [ + {"id": "kitchen", "name": "Kitchen", "entities": ["light.kitchen"]}, + { + "id": "hallway", + "name": "Hall", + "entities": ["light.hall", "light.corner"], + }, + ], + ) + + def test_areas_render_the_whole_list_from_one_template_request(self) -> None: + bridge.collect_areas(self.config()) + + request = FakeHomeAssistant.requests[-1] + self.assertEqual(len(FakeHomeAssistant.requests), 1) + self.assertEqual(request["method"], "POST") + self.assertEqual(request["path"], "/api/template") + self.assertEqual(request["authorization"], "Bearer fixture-token") + template = json.loads(request["body"])["template"] + for fragment in ("areas()", "area_name(area)", "area_entities(area)", "tojson"): + self.assertIn(fragment, template) + + def test_areas_keep_only_lights_and_drop_areas_left_with_none(self) -> None: + rendered = json.dumps(bridge.collect_areas(self.config())) + + self.assertNotIn("switch.kettle", rendered) + self.assertNotIn("sensor.private", rendered) + self.assertNotIn("Garage", rendered) + + def test_areas_discard_malformed_entries_without_losing_the_rest(self) -> None: + rendered = json.dumps(bridge.collect_areas(self.config())) + + self.assertNotIn("Numeric", rendered) + self.assertNotIn("unnamed", rendered) + self.assertNotIn("Loose", rendered) + self.assertIn("Kitchen", rendered) + + def test_areas_reject_a_template_response_that_is_not_json(self) -> None: + FakeHomeAssistant.template_mode = "malformed" + + result = bridge.collect_areas(self.config()) + + self.assertFalse(result["ok"]) + self.assertEqual(result["error"], "invalid-response") + self.assertEqual(result["areas"], []) + + def test_areas_redact_an_authentication_failure(self) -> None: + FakeHomeAssistant.template_mode = "unauthorized" + + result = bridge.collect_areas(self.config()) + + self.assertFalse(result["ok"]) + self.assertEqual(result["error"], "authentication-required") + self.assertNotIn("abc123", json.dumps(result)) + + def test_areas_report_not_configured_before_any_request(self) -> None: + result = bridge.collect_areas( + bridge.Config(base_url=self.base_url, token="", entity_ids=()) + ) + + self.assertEqual( + result, {"ok": False, "areas": [], "error": "not-configured"} + ) + self.assertEqual(FakeHomeAssistant.requests, []) + + def test_cli_areas_prints_one_json_object(self) -> None: + completed = subprocess.run( + [sys.executable, str(HELPER), "areas"], + capture_output=True, + text=True, + env=self.helper_env(), + timeout=15, + ) + + self.assertEqual(completed.returncode, 0) + self.assertEqual(len(completed.stdout.strip().splitlines()), 1) + self.assertEqual( + [area["name"] for area in json.loads(completed.stdout)["areas"]], + ["Kitchen", "Hall"], + ) + + def test_cli_still_rejects_unknown_arguments(self) -> None: + completed = subprocess.run( + [sys.executable, str(HELPER), "areas", "kitchen"], + capture_output=True, + text=True, + env=self.helper_env(), + timeout=15, + ) + + self.assertEqual(completed.returncode, 2) + self.assertEqual( + json.loads(completed.stdout), {"ok": False, "error": "invalid-command"} + ) + def test_action_rejects_an_entity_not_in_the_live_catalog(self) -> None: with self.assertRaisesRegex(ValueError, "entity-not-discovered"): bridge.toggle(self.config(), "light.office") @@ -271,6 +426,28 @@ class HomeAssistantBridgeTest(unittest.TestCase): self.assertIn("name: alias || entity.sourceName,", source) self.assertIn("root.selectedEntities = nextSelection;", source) + def test_qml_fetches_areas_alongside_the_catalog(self) -> None: + source = QML_SERVICE.read_text() + + self.assertIn('command: [root.helperPath, "areas"]', source) + self.assertIn("if (!areasProc.running)\n areasProc.running = true;", source) + + def test_qml_composes_rooms_and_tolerates_missing_areas(self) -> None: + source = QML_SERVICE.read_text() + + self.assertIn("property var areas: []", source) + self.assertIn( + "readonly property var rooms: root.composeRooms(root.catalog, root.areas)", + source, + ) + self.assertIn('grouped.push({ id: "", name: "Other", lights: orphans });', source) + # A failed areas fetch empties the grouping and nothing else: no phase, + # no stale flag, no lastError, so lights keep working without it. + consume = source.split("function consumeAreas(text: string): void {", 1)[1] + consume = consume.split("\n }\n", 1)[0] + for untouched in ("root.phase", "root.stale", "root.lastError"): + self.assertNotIn(untouched, consume) + def test_authentication_error_is_redacted(self) -> None: self.assertEqual( bridge.public_http_error(401, "sensitive response"), diff --git a/tests/quickshell/kdeconnect-helper-contract b/tests/quickshell/kdeconnect-helper-contract index 2a83e26..adfec89 100755 --- a/tests/quickshell/kdeconnect-helper-contract +++ b/tests/quickshell/kdeconnect-helper-contract @@ -28,10 +28,19 @@ if [[ "$(jq -r '.devices | length' <<<"$status")" == "0" ]]; then exit 0 fi +# battery and signal are always present and are null far more often than not: +# the plugin objects exist only while a device is paired, reachable and has the +# plugin loaded. Null is the answer, so the check is on the shape when a value +# is there, never on a value being there. jq -e ' - ([.devices[] | (keys | sort) == (["actions", "id", "name", "paired", "reachable", "type"] | sort)] | all) and + ([.devices[] | (keys | sort) == (["actions", "battery", "id", "name", "paired", "reachable", "signal", "type"] | sort)] | all) and ([.devices[] | (.id | test("^[A-Fa-f0-9]{32,64}$"))] | all) and - ([.devices[].actions[] | . == "clipboard" or . == "ping" or . == "ring" or . == "share"] | all) + ([.devices[].actions[] | . == "clipboard" or . == "ping" or . == "ring" or . == "share"] | all) and + ([.devices[] | select(.battery != null) | (.battery | keys | sort) == ["charge", "charging"]] | all) and + ([.devices[] | select(.battery != null) | .battery.charge >= 0 and .battery.charge <= 100 and (.battery.charging | type == "boolean")] | all) and + ([.devices[] | select(.signal != null) | (.signal | keys | sort) == ["networkType", "strength"]] | all) and + ([.devices[] | select(.signal != null) | .signal.strength >= 0 and (.signal.networkType | type == "string")] | all) and + ([.devices[] | select(.paired and .reachable | not) | .battery == null and .signal == null] | all) ' <<<"$status" >/dev/null || fail 'live status shape is invalid' paired_count="$(jq '[.devices[] | select(.paired)] | length' <<<"$status")" diff --git a/tests/quickshell/kdeconnect_bridge_test.py b/tests/quickshell/kdeconnect_bridge_test.py index b7517f8..cd6eda7 100644 --- a/tests/quickshell/kdeconnect_bridge_test.py +++ b/tests/quickshell/kdeconnect_bridge_test.py @@ -193,6 +193,8 @@ class KdeConnectBridgeTest(unittest.TestCase): "paired": True, "reachable": False, "actions": ["clipboard", "ring", "share"], + "battery": None, + "signal": None, } ], "error": "", @@ -269,7 +271,11 @@ class KdeConnectBridgeTest(unittest.TestCase): if command[:4] == ["busctl", "--user", "--json=short", "call"]: return subprocess.CompletedProcess(command, 0, '{"type":"as","data":[]}\n', "") if command[:4] == ["busctl", "--user", "--json=short", "get-property"]: - is_dbus_device = dbus_id in command[4] + # The reachable CLI device gets its vitals read; nothing answers + # for the plugin objects here. + if not command[5].endswith(cli_id) and not command[5].endswith(dbus_id): + return subprocess.CompletedProcess(command, 1, "", "No such object") + is_dbus_device = command[5].endswith(dbus_id) values = { "name": '{"type":"s","data":"Fixture iPhone"}\n', "type": '{"type":"s","data":"phone"}\n' if is_dbus_device else '{"type":"s","data":"desktop"}\n', @@ -284,6 +290,190 @@ class KdeConnectBridgeTest(unittest.TestCase): self.assertEqual({device["id"] for device in status["devices"]}, {cli_id, dbus_id}) + def test_vitals_are_read_for_a_reachable_phone(self) -> None: + device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + runner = vitals_runner( + device_id, + f"- Fixture iPhone: {device_id} (paired and reachable)\n", + { + "charge": '{"type":"i","data":82}\n', + "isCharging": '{"type":"b","data":true}\n', + "hasBattery": '{"type":"b","data":true}\n', + "cellularNetworkType": '{"type":"s","data":"LTE"}\n', + "cellularNetworkStrength": '{"type":"i","data":3}\n', + }, + ) + + device = bridge.collect_status(runner)["devices"][0] + + self.assertEqual(device["battery"], {"charge": 82, "charging": True}) + self.assertEqual(device["signal"], {"networkType": "LTE", "strength": 3}) + + def test_absent_plugin_objects_are_no_data_not_an_error(self) -> None: + """A phone with the battery plugin off is not a broken status read. + + kdeconnectd only publishes a plugin's object while the device is paired, + reachable and the plugin is loaded, so busctl exiting non-zero here is + the ordinary answer "nothing to report" -- the device still appears, + with null vitals and no error on the envelope. + """ + device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + runner = vitals_runner( + device_id, + f"- Fixture iPhone: {device_id} (paired and reachable)\n", + {}, + ) + + status = bridge.collect_status(runner) + + self.assertEqual(status["available"], True) + self.assertEqual(status["error"], "") + self.assertIsNone(status["devices"][0]["battery"]) + self.assertIsNone(status["devices"][0]["signal"]) + + def test_negative_charge_reports_no_battery(self) -> None: + device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + runner = vitals_runner( + device_id, + f"- Fixture iPhone: {device_id} (paired and reachable)\n", + { + "charge": '{"type":"i","data":-1}\n', + "isCharging": '{"type":"b","data":false}\n', + "hasBattery": '{"type":"b","data":true}\n', + "cellularNetworkType": '{"type":"s","data":"LTE"}\n', + "cellularNetworkStrength": '{"type":"i","data":3}\n', + }, + ) + + device = bridge.collect_status(runner)["devices"][0] + + self.assertIsNone(device["battery"]) + self.assertEqual(device["signal"], {"networkType": "LTE", "strength": 3}) + + def test_a_phone_without_a_battery_reports_none(self) -> None: + device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + runner = vitals_runner( + device_id, + f"- Fixture iPhone: {device_id} (paired and reachable)\n", + { + "charge": '{"type":"i","data":0}\n', + "isCharging": '{"type":"b","data":false}\n', + "hasBattery": '{"type":"b","data":false}\n', + }, + ) + + self.assertIsNone(bridge.collect_status(runner)["devices"][0]["battery"]) + + def test_strength_of_minus_one_reports_no_signal(self) -> None: + device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + runner = vitals_runner( + device_id, + f"- Fixture iPhone: {device_id} (paired and reachable)\n", + { + "charge": '{"type":"i","data":82}\n', + "isCharging": '{"type":"b","data":false}\n', + "hasBattery": '{"type":"b","data":true}\n', + "cellularNetworkType": '{"type":"s","data":"Unknown"}\n', + "cellularNetworkStrength": '{"type":"i","data":-1}\n', + }, + ) + + device = bridge.collect_status(runner)["devices"][0] + + self.assertEqual(device["battery"], {"charge": 82, "charging": False}) + self.assertIsNone(device["signal"]) + + def test_an_unreachable_device_is_never_asked_for_vitals(self) -> None: + """Plugin objects cannot exist for an absent phone, so asking is waste. + + The runner fails the test outright if a plugin path is touched, which is + what keeps a busctl call per device off the path taken every 30 seconds + by the phone that is simply not home. + """ + device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + runner = vitals_runner( + device_id, + f"- Fixture iPhone: {device_id} (paired)\n", + {}, + forbid_plugin_reads=True, + ) + + device = bridge.collect_status(runner)["devices"][0] + + self.assertIsNone(device["battery"]) + self.assertIsNone(device["signal"]) + + def test_an_action_does_not_wait_on_vitals(self) -> None: + """Ringing a phone reads identity only -- no charge between tap and ring.""" + device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" + listing = f"- Fixture iPhone: {device_id} (paired and reachable)\n" + + def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + if command == ["kdeconnect-cli", "--list-devices"]: + return subprocess.CompletedProcess(command, 0, listing, "") + if command == ["kdeconnect-cli", "-d", device_id, "--ring"]: + return subprocess.CompletedProcess(command, 0, "", "") + if command[:4] == ["busctl", "--user", "--json=short", "call"]: + return subprocess.CompletedProcess( + command, + 0, + '{"type":"as","data":["kdeconnect_findmyphone"]}\n', + "", + ) + if command[:4] == ["busctl", "--user", "--json=short", "get-property"]: + if not command[5].endswith(device_id): + raise AssertionError(f"vitals read on the action path: {command}") + return subprocess.CompletedProcess(command, 0, '{"type":"s","data":"phone"}\n', "") + if command[:3] == ["busctl", "--user", "tree"]: + return subprocess.CompletedProcess(command, 0, "", "") + raise AssertionError(command) + + self.assertEqual( + bridge.invoke_action("ring", device_id, runner=runner), + {"ok": True, "action": "ring", "error": ""}, + ) + + +def vitals_runner( + device_id: str, + listing: str, + plugin_values: dict[str, str], + *, + forbid_plugin_reads: bool = False, +) -> bridge.Runner: + """A fake busctl/kdeconnect-cli for one CLI-listed device. + + Reads of the device object itself always answer; reads of a plugin object + answer only from `plugin_values`, and exit non-zero for anything missing -- + which is exactly how busctl behaves when the object is not published. + """ + device_path = f"/modules/kdeconnect/devices/{device_id}" + + def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + if command == ["kdeconnect-cli", "--list-devices"]: + return subprocess.CompletedProcess(command, 0, listing, "") + if command[:3] == ["busctl", "--user", "tree"]: + return subprocess.CompletedProcess(command, 0, "", "") + if command[:4] == ["busctl", "--user", "--json=short", "call"]: + return subprocess.CompletedProcess( + command, + 0, + '{"type":"as","data":["kdeconnect_findmyphone"]}\n', + "", + ) + if command[:4] == ["busctl", "--user", "--json=short", "get-property"]: + if command[5] == device_path: + return subprocess.CompletedProcess(command, 0, '{"type":"s","data":"phone"}\n', "") + if forbid_plugin_reads: + raise AssertionError(f"unexpected plugin read: {command}") + member = command[-1] + if member not in plugin_values: + return subprocess.CompletedProcess(command, 1, "", "No such object") + return subprocess.CompletedProcess(command, 0, plugin_values[member], "") + raise AssertionError(command) + + return runner + if __name__ == "__main__": unittest.main() diff --git a/tests/quickshell/my-home-settings-contract b/tests/quickshell/my-home-settings-contract new file mode 100755 index 0000000..3021c96 --- /dev/null +++ b/tests/quickshell/my-home-settings-contract @@ -0,0 +1,329 @@ +#!/usr/bin/env bash + +# My Home is the Home Assistant tab of the Home category, and the page that +# owns a long-lived access token. Two things have to stay true of it: +# +# the page never reads Home Assistant directly. Every light, every toggle and +# every dim goes through the helper, so a bearer token or an /api/states URL +# appearing in the QML is the boundary breaking, not a style question +# +# the read-only route stays read-only. Driving fixtures through the page must +# never write panama-home.json, or a contract run would rewrite the favorites +# of whoever ran it +# +# Everything else here is the page structure the old Home & Phone page proved +# and this one inherited -- the favorites editor, the connection card, the +# empty-state copy -- plus what is new: lights grouped into rooms. +# +# The phone half of that old page now lives on its own tab; phone-page-contract +# covers it. + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +state_home="$(mktemp -d /tmp/panama-my-home-settings-state.XXXXXX)" +source_config_path="$repo_dir/config/dot/quickshell" +config_path="$state_home/quickshell" +test_bin="$state_home/bin" +shell_log="$state_home/quickshell.log" + +cleanup_bootstrap() { + rm -rf "$state_home" +} +trap cleanup_bootstrap EXIT + +mkdir -p "$test_bin" +cp -a "$source_config_path" "$config_path" + +cat >"$config_path/scripts/panama-home-assistant" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +case "${1:-}" in + catalog|areas) + printf '%s\n' '{"ok":false,"error":"test-helper"}' + ;; + toggle|brightness) + printf '%s\n' '{"ok":true}' + ;; +esac +EOF +chmod +x "$config_path/scripts/panama-home-assistant" + +cat >"$test_bin/hyprctl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${1:-}" == "-j" && "${2:-}" == "monitors" ]]; then + printf '%s\n' '[{"focused":true,"name":"TEST-1","description":"My Home contract","width":1920,"height":1080,"refreshRate":60,"scale":1,"currentFormat":"XRGB8888","colorManagementPreset":"srgb","vrr":false}]' + exit 0 +fi +if [[ "${1:-}" == "keyword" ]]; then + exit 0 +fi +exec /usr/sbin/hyprctl "$@" +EOF +chmod +x "$test_bin/hyprctl" + +# Nothing on this page consults Flatpak; the stub only keeps the isolated shell +# from reaching the real one while it probes at startup. +cat >"$test_bin/flatpak" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +exit 97 +EOF +chmod +x "$test_bin/flatpak" + +fail() { + printf 'my home settings contract: %s\n' "$1" >&2 + exit 1 +} + +assert_contains() { + local needle="$1" + local file="$2" + rg -Fq "$needle" "$file" || fail "$file is missing: $needle" +} + +settings_dir="$repo_dir/config/dot/quickshell/modules/settings" +my_home_page="$settings_dir/MyHomePage.qml" +light_tile="$settings_dir/HomeLightTile.qml" +favorite_card="$settings_dir/HomeFavoriteCard.qml" +available_row="$settings_dir/AvailableLightRow.qml" +settings_qmldir="$settings_dir/qmldir" + +for required in "$my_home_page" "$light_tile" "$favorite_card" "$available_row"; do + [[ -f "$required" ]] || fail "$(basename "$required") is missing" +done +if [[ -e "$settings_dir/HomePhonePage.qml" ]]; then + fail 'the retired Home & Phone page is still on disk' +fi + +# ── The page ───────────────────────────────────────────────────────────────── + +assert_contains 'SettingsPage {' "$my_home_page" +assert_contains 'objectName: "my-home-page"' "$my_home_page" +assert_contains 'title: "My Home"' "$my_home_page" +assert_contains 'lede: root.homeStatus()' "$my_home_page" +if rg -q '^\s*Flickable \{' "$my_home_page"; then + fail 'MyHomePage.qml still owns a copied Flickable scaffold' +fi + +# The status line is the lede, so each state has to say something a person can +# act on rather than collapsing to one "unavailable". +assert_contains 'Connected · ' "$my_home_page" +assert_contains 'Last update unavailable · showing saved controls' "$my_home_page" +assert_contains 'Authentication required' "$my_home_page" +assert_contains 'Home Assistant is not configured' "$my_home_page" + +# ── Rooms ──────────────────────────────────────────────────────────────────── +# Lights render through the shared tile, grouped by Home Assistant area. A +# setup with no areas is one unnamed bucket, and an unnamed bucket must not +# draw a heading -- an empty grey label above the only list reads as a bug. + +assert_contains 'HomeLightTile 1.0 HomeLightTile.qml' "$settings_qmldir" +assert_contains 'HomeLightTile {' "$my_home_page" +assert_contains 'model: HomeAssistant.rooms ?? []' "$my_home_page" +assert_contains 'model: roomSection.modelData.lights ?? []' "$my_home_page" +rg -Fq 'visible: String(roomSection.modelData.name ?? "") !== ""' "$my_home_page" \ + || fail 'room headings render for unnamed rooms, which is what a setup without areas produces' + +# One dimmer implementation everywhere: the tile reuses Control Center's +# slider rather than growing a second one that drifts from it. +assert_contains 'import qs.modules.quicksettings' "$light_tile" +assert_contains 'HomeBrightnessSlider {' "$light_tile" +assert_contains 'HomeAssistant.toggleEntity(root.entityId)' "$light_tile" +assert_contains 'HomeAssistant.setBrightness(root.entityId, value)' "$light_tile" +assert_contains 'Accessible.role: Accessible.Button' "$light_tile" + +# ── The favorites editor ───────────────────────────────────────────────────── + +assert_contains 'HomePreferences.setAlias' "$my_home_page" +assert_contains 'HomePreferences.move' "$my_home_page" +assert_contains 'HomePreferences.remove' "$my_home_page" +assert_contains 'HomePreferences.add' "$my_home_page" +assert_contains 'HomePreferences.retrySave' "$my_home_page" +assert_contains 'Choose lights below to build your Control Center shelf.' "$my_home_page" +assert_contains 'All discovered lights are already selected' "$my_home_page" +assert_contains 'No lights discovered' "$my_home_page" +assert_contains 'No lights match that search' "$my_home_page" +[[ "$(rg -Fc 'required property var modelData' "$my_home_page")" -ge 2 ]] \ + || fail 'MyHomePage.qml does not bind its reusable delegates to modelData' +if rg -Fq 'index: model.index' "$my_home_page"; then + fail 'MyHomePage.qml reads an undefined model.index instead of the delegate index' +fi +assert_contains 'signal aliasCommitted(string id, string alias)' "$favorite_card" +assert_contains 'signal removeRequested(string id)' "$favorite_card" +assert_contains 'signal moveRequested(string id, int targetIndex)' "$favorite_card" +assert_contains 'text: "↑"' "$favorite_card" +assert_contains 'text: "↓"' "$favorite_card" +assert_contains 'enabled: root.canMoveEarlier' "$favorite_card" +assert_contains 'enabled: root.canMoveLater' "$favorite_card" +if rg -Fq 'DragHandler {' "$favorite_card"; then + fail 'Home light cards still expose the broken drag affordance' +fi +assert_contains 'canMoveEarlier: index > 0' "$my_home_page" +assert_contains 'canMoveLater: index < favoritesGrid.count - 1' "$my_home_page" +assert_contains 'onEditingFinished:' "$favorite_card" +assert_contains 'text: "Control Center"' "$favorite_card" +assert_contains 'activeFocusOnTab: true' "$favorite_card" +assert_contains 'signal addRequested(string id)' "$available_row" +assert_contains 'activeFocusOnTab: true' "$available_row" + +# ── The credential boundary ────────────────────────────────────────────────── + +for boundary_file in "$my_home_page" "$light_tile"; do + if rg -qi 'bearer|api/states' "$boundary_file"; then + fail "$(basename "$boundary_file") crosses the Home Assistant REST boundary" + fi +done + +qs_for_test() { + PATH="$test_bin:$PATH" QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \ + qs -p "$config_path" "$@" +} + +stop_test_shell() { + qs_for_test kill >/dev/null 2>&1 || true + for _ in $(seq 1 80); do + if ! qs_for_test list 2>/dev/null | rg '^Instance ' >/dev/null \ + && ! qs_for_test ipc show >/dev/null 2>&1; then + return 0 + fi + sleep 0.1 + done + return 1 +} + +cleanup() { + qs_for_test ipc call settings close >/dev/null 2>&1 || true + if stop_test_shell; then + rm -rf "$state_home" + else + printf 'my home settings contract: branch shell did not stop; retained %s\n' \ + "$state_home" >&2 + fi +} +trap cleanup EXIT + +start_test_shell() { + stop_test_shell || fail 'pre-existing branch shell did not stop cleanly' + for _attempt in 1 2; do + qs_for_test --daemonize >"$shell_log" 2>&1 + for _ in $(seq 1 80); do + if qs_for_test ipc show 2>/dev/null | rg '^target settings-system$' >/dev/null; then + return + fi + sleep 0.1 + done + stop_test_shell || fail 'failed branch-shell attempt did not stop cleanly' + done + sed -n '1,240p' "$shell_log" >&2 + fail 'isolated branch shell did not start' +} + +# Reads `settings status` until the diagnostics settle, then holds the caller to +# the same expression. The page recomputes on a binding, so an early read is a +# stale read rather than a wrong one. +await_status() { + local expression="$1" + local label="$2" + local status='{}' + + for _ in $(seq 1 40); do + status="$(qs_for_test ipc call settings status | jq -c .)" + if jq -e "$expression" <<<"$status" >/dev/null; then + printf '%s\n' "$status" + return 0 + fi + sleep 0.1 + done + fail "$label: $status" +} + +start_test_shell +qs_for_test ipc call home-assistant fixture ready >/dev/null +qs_for_test ipc call settings page my-home >/dev/null + +# The fixture areas claim five of the seven lights; the two nobody claims fall +# into the trailing "Other" bucket, which is the case a flat catalog would hide. +await_status ' + .open == true and + .page == "my-home" and + .discoveredCount == 7 and + .selectedCount == 7 and + .myHome.availableLightIds == [] and + .myHome.availableEmptyText == "All discovered lights are already selected" and + .myHome.homeStatus == "Connected · 7 lights across 4 rooms" and + .myHome.rooms == [ + { "name": "Kitchen", "count": 1 }, + { "name": "Living room", "count": 2 }, + { "name": "Bedroom", "count": 2 }, + { "name": "Other", "count": 2 } + ] +' 'My Home diagnostics are incomplete' >/dev/null + +qs_for_test ipc call home-assistant fixture available-extra >/dev/null +await_status ' + .discoveredCount == 8 and + .selectedCount == 7 and + .myHome.availableLightIds == ["light.fixture_guest"] and + (.myHome.rooms[] | select(.name == "Other") | .count) == 3 +' 'an unselected fixture light was not the only available row' >/dev/null + +qs_for_test ipc call home-assistant fixture stale-authentication >/dev/null +await_status ' + .discoveredCount == 7 and + .selectedCount == 7 and + .myHome.homeStatus == "Authentication required" +' 'stale authentication did not retain actionable copy' >/dev/null + +qs_for_test ipc call home-assistant fixture stale-not-configured >/dev/null +await_status ' + .discoveredCount == 7 and + .selectedCount == 7 and + .myHome.homeStatus == "Home Assistant is not configured" +' 'stale not-configured state did not retain actionable copy' >/dev/null + +# No catalog means no areas either, and the room composition degrades to the +# single unnamed bucket the page renders without a heading. +qs_for_test ipc call home-assistant fixture unavailable >/dev/null +await_status ' + .discoveredCount == 0 and + .selectedCount == 0 and + .myHome.homeStatus == "Home Assistant is not configured" and + .myHome.availableLightIds == [] and + .myHome.availableEmptyText == "No lights discovered" and + .myHome.rooms == [{ "name": "", "count": 0 }] +' 'an empty catalog did not render its distinct copy' >/dev/null + +shell_pid="$(qs_for_test list | awk '/Process ID:/ { print $3; exit }')" +[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process' +for _ in $(seq 1 40); do + if /usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \ + '[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null; then + break + fi + sleep 0.1 +done +/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \ + '[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null \ + || fail 'the branch shell did not own exactly one tiled Panama Settings client' + +if [[ -n "${PANAMA_TEST_SCREENSHOT_PATH:-}" ]]; then + qs_for_test ipc call home-assistant fixture ready >/dev/null + sleep 0.5 + geometry="$(/usr/sbin/hyprctl -j clients | jq -r --argjson pid "$shell_pid" \ + '.[] | select(.pid == $pid and .title == "Settings") | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')" + [[ -n "$geometry" ]] || fail 'could not resolve the Settings client geometry' + grim -g "$geometry" "$PANAMA_TEST_SCREENSHOT_PATH" +fi + +if find "$state_home" -name panama-home.json -print -quit | rg -q .; then + fail 'the read-only fixture route wrote Home preferences' +fi + +trap - EXIT +cleanup +[[ ! -e "$state_home" ]] || fail 'temporary My Home state was not removed after shell exit' +printf 'my home settings contract: PASS\n' diff --git a/tests/quickshell/panama-doctor-contract b/tests/quickshell/panama-doctor-contract index 76d29bc..0b1757c 100755 --- a/tests/quickshell/panama-doctor-contract +++ b/tests/quickshell/panama-doctor-contract @@ -318,8 +318,8 @@ mkdir -p "$config_home/quickshell/scripts" home_assistant_failure="$(run_doctor --json)" check_status "$home_assistant_failure" integration.home-assistant warning jq -e '.checks[] | select(.id == "integration.home-assistant") - | .action == {kind:"open", label:"Open Home settings", confirm:false, target:"home-phone"}' \ - >/dev/null <<<"$home_assistant_failure" || fail 'Home Assistant action was not routed to home-phone' + | .action == {kind:"open", label:"Open Home settings", confirm:false, target:"my-home"}' \ + >/dev/null <<<"$home_assistant_failure" || fail 'Home Assistant action was not routed to my-home' # Invalid probe text is contained in its own check and never copied to JSON. malformed_calendar="$(PANAMA_DOCTOR_FIXTURE_CALENDAR=malformed run_doctor --json)" diff --git a/tests/quickshell/phone-page-contract b/tests/quickshell/phone-page-contract new file mode 100755 index 0000000..cdf2272 --- /dev/null +++ b/tests/quickshell/phone-page-contract @@ -0,0 +1,235 @@ +#!/usr/bin/env bash + +# Phone is the continuity tab of the Home category -- the half of the old Home +# & Phone page that did not go to My Home. Three things carried over or arrived +# with it, and each has already been got wrong once: +# +# the vitals strip reads plugin data that only exists while a phone is paired +# and nearby. A missing battery or signal reading is the ordinary case, not an +# error, so it renders as an em-dash +# +# the Reach it buttons mirror Control Center exactly: nearby, not mid-transfer, +# and the plugin present. Dropping any one of those three offers an action +# that silently does nothing +# +# Messages is not a KDE Connect action. It needs BlueBubbles installed and +# nothing else, and it must stay that way -- coupling it to phone reachability +# is the regression phone-messages-contract was written for, and this page +# inherited the same row +# +# The live half boots an isolated shell with a fake Flatpak to prove the +# installed-state probe runs and that nothing here launches the app. + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +settings_dir="$repo_dir/config/dot/quickshell/modules/settings" +phone_page="$settings_dir/PhonePage.qml" +settings_qmldir="$settings_dir/qmldir" +system_settings="$repo_dir/config/dot/quickshell/services/SystemSettings.qml" + +fail() { + printf 'phone page contract: %s\n' "$1" >&2 + exit 1 +} + +assert_contains() { + local needle="$1" + local file="$2" + rg -Fq "$needle" "$file" || fail "$file is missing: $needle" +} + +[[ -f "$phone_page" ]] || fail 'PhonePage.qml is missing' + +assert_contains 'SettingsPage {' "$phone_page" +assert_contains 'objectName: "phone-page"' "$phone_page" +assert_contains 'title: "Phone"' "$phone_page" +assert_contains 'PhonePage 1.0 PhonePage.qml' "$settings_qmldir" +if rg -q '^\s*Flickable \{' "$phone_page"; then + fail 'PhonePage.qml still owns a copied Flickable scaffold' +fi + +# ── Vitals ─────────────────────────────────────────────────────────────────── +# KdeConnect.phoneBattery and phoneSignal are null whenever the plugin objects +# are absent, which is most of the time on an unpaired or away phone. + +vitals_block="$(sed -n '/id: vitals/,/── Reach it/p' "$phone_page")" +[[ -n "$vitals_block" ]] || fail 'PhonePage.qml has no vitals strip' +rg -Fq 'KdeConnect.phoneBattery' <<<"$vitals_block" \ + || fail 'the vitals strip does not read the phone battery' +rg -Fq 'KdeConnect.phoneSignal' <<<"$vitals_block" \ + || fail 'the vitals strip does not read the phone signal' +[[ "$(rg -Fc -- '"—"' <<<"$vitals_block")" -ge 2 ]] \ + || fail 'a null battery or signal does not render as an em-dash' + +# ── Reach it ───────────────────────────────────────────────────────────────── + +assert_contains 'readonly property bool actionsReady: KdeConnect.phoneReachable && !KdeConnect.transferActive' \ + "$phone_page" +for action in ring clipboard share; do + rg -Fq "enabled: root.actionsReady && KdeConnect.supports(\"$action\")" "$phone_page" \ + || fail "the $action action does not gate on nearby, idle, and plugin support together" +done +assert_contains 'KdeConnect.ring()' "$phone_page" +assert_contains 'KdeConnect.sendClipboard()' "$phone_page" +assert_contains 'KdeConnect.sendFile(path)' "$phone_page" +assert_contains 'KdeConnect.cancelTransfer()' "$phone_page" + +# ── Messages ───────────────────────────────────────────────────────────────── + +assert_contains 'Opens BlueBubbles' "$phone_page" +assert_contains 'SystemSettings.openApplication("bluebubbles")' "$phone_page" +messages_card="$(sed -n '/title: "Messages"/,/title: "Device"/p' "$phone_page")" +[[ -n "$messages_card" ]] || fail 'PhonePage.qml has no Messages card' +rg -Fq 'SystemSettings.bluebubblesAvailable' <<<"$messages_card" \ + || fail 'Messages enablement does not read BlueBubbles availability' +if rg -Fq 'KdeConnect.' <<<"$messages_card"; then + fail 'Messages is coupled to KDE Connect' +fi +assert_contains 'readonly property bool bluebubblesAvailable: root.bluebubblesDetected' "$system_settings" +assert_contains 'command: ["flatpak", "info", "app.bluebubbles.BlueBubbles"]' "$system_settings" +assert_contains '"bluebubbles": ["flatpak", "run", "app.bluebubbles.BlueBubbles"]' "$system_settings" + +# ── The live half ──────────────────────────────────────────────────────────── + +state_home="$(mktemp -d /tmp/panama-phone-page-state.XXXXXX)" +config_path="$state_home/quickshell" +test_bin="$state_home/bin" +shell_log="$state_home/quickshell.log" +flatpak_log="$state_home/flatpak.log" + +cleanup_bootstrap() { + rm -rf "$state_home" +} +trap cleanup_bootstrap EXIT + +mkdir -p "$test_bin" +cp -a "$repo_dir/config/dot/quickshell" "$config_path" +: >"$flatpak_log" + +cat >"$config_path/scripts/panama-home-assistant" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +case "${1:-}" in + catalog|areas) + printf '%s\n' '{"ok":false,"error":"test-helper"}' + ;; +esac +EOF +chmod +x "$config_path/scripts/panama-home-assistant" + +cat >"$test_bin/hyprctl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${1:-}" == "-j" && "${2:-}" == "monitors" ]]; then + printf '%s\n' '[{"focused":true,"name":"TEST-1","description":"Phone contract","width":1920,"height":1080,"refreshRate":60,"scale":1,"currentFormat":"XRGB8888","colorManagementPreset":"srgb","vrr":false}]' + exit 0 +fi +if [[ "${1:-}" == "keyword" ]]; then + exit 0 +fi +exec /usr/sbin/hyprctl "$@" +EOF +chmod +x "$test_bin/hyprctl" + +cat >"$test_bin/flatpak" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +printf '%s\n' "$*" >>"$PANAMA_FLATPAK_LOG" +if [[ "${1:-}" == "info" && "${2:-}" == "app.bluebubbles.BlueBubbles" ]]; then + exit 0 +fi +exit 97 +EOF +chmod +x "$test_bin/flatpak" + +qs_for_test() { + PATH="$test_bin:$PATH" QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \ + PANAMA_FLATPAK_LOG="$flatpak_log" qs -p "$config_path" "$@" +} + +stop_test_shell() { + qs_for_test kill >/dev/null 2>&1 || true + for _ in $(seq 1 80); do + if ! qs_for_test list 2>/dev/null | rg '^Instance ' >/dev/null \ + && ! qs_for_test ipc show >/dev/null 2>&1; then + return 0 + fi + sleep 0.1 + done + return 1 +} + +cleanup() { + qs_for_test ipc call settings close >/dev/null 2>&1 || true + if stop_test_shell; then + rm -rf "$state_home" + else + printf 'phone page contract: branch shell did not stop; retained %s\n' "$state_home" >&2 + fi +} +trap cleanup EXIT + +start_test_shell() { + stop_test_shell || fail 'pre-existing branch shell did not stop cleanly' + for _attempt in 1 2; do + qs_for_test --daemonize >"$shell_log" 2>&1 + for _ in $(seq 1 80); do + if qs_for_test ipc show 2>/dev/null | rg '^target settings-system$' >/dev/null; then + return + fi + sleep 0.1 + done + stop_test_shell || fail 'failed branch-shell attempt did not stop cleanly' + done + sed -n '1,240p' "$shell_log" >&2 + fail 'isolated branch shell did not start' +} + +start_test_shell +qs_for_test ipc call settings page phone >/dev/null +for _ in $(seq 1 40); do + [[ "$(qs_for_test ipc call settings status | jq -r .page)" == "phone" ]] && break + sleep 0.1 +done +status="$(qs_for_test ipc call settings status | jq -c .)" +jq -e '.open == true and .page == "phone"' <<<"$status" >/dev/null \ + || fail "the Phone tab did not render: $status" + +system_status="$(qs_for_test ipc call settings-system status | jq -c .)" +jq -e '.bluebubblesAvailable == true' <<<"$system_status" >/dev/null \ + || fail "BlueBubbles availability was not exposed: $system_status" + +shell_pid="$(qs_for_test list | awk '/Process ID:/ { print $3; exit }')" +[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process' +for _ in $(seq 1 40); do + if /usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \ + '[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null; then + break + fi + sleep 0.1 +done +/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \ + '[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null \ + || fail 'the branch shell did not own exactly one tiled Panama Settings client' + +if [[ -n "${PANAMA_TEST_SCREENSHOT_PATH:-}" ]]; then + geometry="$(/usr/sbin/hyprctl -j clients | jq -r --argjson pid "$shell_pid" \ + '.[] | select(.pid == $pid and .title == "Settings") | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')" + [[ -n "$geometry" ]] || fail 'could not resolve the Settings client geometry' + grim -g "$geometry" "$PANAMA_TEST_SCREENSHOT_PATH" +fi + +rg -Fxq 'info app.bluebubbles.BlueBubbles' "$flatpak_log" \ + || fail 'the fixed BlueBubbles availability probe did not run' +if rg -q '^run ' "$flatpak_log"; then + fail 'the contract started BlueBubbles' +fi + +trap - EXIT +cleanup +[[ ! -e "$state_home" ]] || fail 'temporary Phone state was not removed after shell exit' +printf 'phone page contract: PASS\n' diff --git a/tests/quickshell/settings-nav-contract b/tests/quickshell/settings-nav-contract index d46e717..d6e3480 100755 --- a/tests/quickshell/settings-nav-contract +++ b/tests/quickshell/settings-nav-contract @@ -80,6 +80,29 @@ duplicate_categories="$(sort <<<"$category_ids" | uniq -d)" [[ -z "$duplicate_categories" ]] \ || fail "these category ids are declared twice: $(tr '\n' ' ' <<<"$duplicate_categories")" +# ── Retired ids still land on a real page ──────────────────────────────────── +# Old Vicinae commands, shell history and muscle memory keep handing over page +# ids that no longer exist, and resolve() consults the retired map before +# anything else. An entry aimed at a leaf that has itself since been renamed +# sends every one of those callers to Home without saying so, and an entry that +# names a live leaf shadows the real page. +retired_block="$(sed -n '/property var retired:/,/})/p' "$routes")" +retired_pairs="$(grep -oE '"[a-z-]+"[[:space:]]*:[[:space:]]*"[a-z-]+"' <<<"$retired_block" || true)" +retired_count=0 +while read -r pair; do + [[ -n "$pair" ]] || continue + retired_id="$(sed -E 's/"([a-z-]+)".*/\1/' <<<"$pair")" + retired_target="$(sed -E 's/.*"([a-z-]+)"$/\1/' <<<"$pair")" + retired_count=$((retired_count + 1)) + + if ! grep -qx "$retired_target" <<<"$leaves"; then + fail "the retired id \"$retired_id\" resolves to \"$retired_target\", which is not a leaf, so everyone still holding it silently lands on Home" + fi + if grep -qx "$retired_id" <<<"$leaves"; then + fail "\"$retired_id\" is listed as retired and is also a live leaf, so resolve() answers with the retired target instead of the page itself" + fi +done <<<"$retired_pairs" + # ── Every leaf resolves everywhere ─────────────────────────────────────────── while read -r page; do [[ -n "$page" ]] || continue @@ -130,5 +153,5 @@ while read -r page_file; do || fail "$type_name.qml exists but nothing in SettingsShell instantiates it" done < <(find "$settings_dir" -maxdepth 1 -name '*Page.qml') -printf 'settings nav contract: PASS (%d categories, %d leaves)\n' \ - "$(grep -c . <<<"$category_ids")" "$(grep -c . <<<"$leaves")" +printf 'settings nav contract: PASS (%d categories, %d leaves, %d retired ids)\n' \ + "$(grep -c . <<<"$category_ids")" "$(grep -c . <<<"$leaves")" "$retired_count" diff --git a/tests/quickshell/settings-pages-contract b/tests/quickshell/settings-pages-contract index 27bd8d7..72b7c31 100755 --- a/tests/quickshell/settings-pages-contract +++ b/tests/quickshell/settings-pages-contract @@ -9,7 +9,7 @@ fail() { exit 1 } -pages=(Home Displays Connectivity Sound Dictation Notifications ScreenIntelligence Health About) +pages=(Home MyHome Phone Displays Connectivity Sound Dictation Notifications ScreenIntelligence Health About) for page in "${pages[@]}"; do page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml" [[ -f "$page_file" ]] || fail "${page}Page.qml is missing" @@ -307,7 +307,7 @@ shell_pid="$harness_pid" # four different categories, and the page the tab strip was introduced for. # Routing to a tab must land on that tab, not on whatever its category opens # first, which is the failure the SettingsRoutes resolution could introduce. -pages=(home appearance displays connectivity home-phone desktop sound dictation notifications screen-intelligence shortcuts services manual about) +pages=(home appearance displays connectivity my-home phone desktop sound dictation notifications screen-intelligence shortcuts services manual about) for page in "${pages[@]}"; do qs_for_test ipc call settings page "$page" >/dev/null for _ in $(seq 1 20); do