7 Commits
27 changed files with 2618 additions and 66 deletions
+3
View File
@@ -8,3 +8,6 @@
/config/dot/nvim/lazy-lock.json /config/dot/nvim/lazy-lock.json
# Local visual-companion sessions and generated design mocks. # Local visual-companion sessions and generated design mocks.
/.superpowers/ /.superpowers/
# Python helper bytecode is local runtime state.
__pycache__/
*.py[cod]
+7 -15
View File
@@ -23,7 +23,7 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
| Date menu | Two-column Daybook with Agenda, Ongoing activity, Notifications, month calendar, weather, and media | Live | | Date menu | Two-column Daybook with Agenda, Ongoing activity, Notifications, month calendar, weather, and media | Live |
| Ongoing activity | Focus, Caffeine, recording, screen sharing, camera, and microphone state with direct controls | Live | | Ongoing activity | Focus, Caffeine, recording, screen sharing, camera, and microphone state with direct controls | Live |
| Calendar agenda | Existing GNOME, Google, iCloud, and Nextcloud sources through Evolution Data Server | Live | | Calendar agenda | Existing GNOME, Google, iCloud, and Nextcloud sources through Evolution Data Server | Live |
| Quick settings | Wi-Fi, Bluetooth, audio input/output, caffeine, night light, focus | Live | | Control Center | Wi-Fi, Bluetooth, audio input/output, Caffeine, Night Light, Focus, Home Assistant favourites, and KDE Connect phone continuity | Live |
| Focus sessions | Persistent workspace-bound timer, DND/caffeine coordination, Signal Glass controls | Live | | Focus sessions | Persistent workspace-bound timer, DND/caffeine coordination, Signal Glass controls | Live |
| Brightness | Appears only when a backlight device exists | Live, not applicable on this monitor | | Brightness | Appears only when a backlight device exists | Live, not applicable on this monitor |
| AppIndicator / tray | Native StatusNotifierItem host and themed menus | Live | | AppIndicator / tray | Native StatusNotifierItem host and themed menus | Live |
@@ -35,6 +35,7 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
| Portals and screen sharing | xdg-desktop-portal-hyprland plus GTK portal | Live | | Portals and screen sharing | xdg-desktop-portal-hyprland plus GTK portal | Live |
| Removable media | udiskie plus udisks notifications | Live | | Removable media | udiskie plus udisks notifications | Live |
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live | | Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
| Printer administration | CUPS with the `system-config-printer` graphical interface | Live |
| System settings | Panama Settings for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live | | System settings | Panama Settings for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live |
## GNOME extension migration ## GNOME extension migration
@@ -52,8 +53,8 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
| Blur My Shell / Openbar / User Theme | Replaced by the Prism shell and compositor blur | | Blur My Shell / Openbar / User Theme | Replaced by the Prism shell and compositor blur |
| Bluetooth Quick Connect | Replaced by the full Bluetooth picker | | Bluetooth Quick Connect | Replaced by the full Bluetooth picker |
| Wi-Fi QR | Deliberately omitted; it is not useful enough to justify another credential-reading surface | | Wi-Fi QR | Deliberately omitted; it is not useful enough to justify another credential-reading surface |
| GSConnect | No paired phone profile was present; KDE Connect is installed and awaiting pairing | | GSConnect | Replaced by capability-aware KDE Connect phone continuity in Control Center; the paired iPhone exposes file, clipboard, and Ring actions when reachable |
| Home Assistant | Useful integration, but needs a purpose-built panel and secure credential migration | | Home Assistant | Replaced by secure favourites in Control Center; explicit private environment values take precedence over the existing GNOME extension and Secret Service setup |
| Custom Hot Corners Extended | No action was configured, so there is no behavior to port | | Custom Hot Corners Extended | No action was configured, so there is no behavior to port |
| App Hider | Hidden app list is now applied in Vicinae | | App Hider | Hidden app list is now applied in Vicinae |
| Allow Locked Remote Desktop | Mutter-specific patch; RustDesk is independent, but lock-screen access needs a real remote test | | Allow Locked Remote Desktop | Mutter-specific patch; RustDesk is independent, but lock-screen access needs a real remote test |
@@ -63,20 +64,11 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
These are not blockers for normal use, but they are the remaining places where These are not blockers for normal use, but they are the remaining places where
a polished general-purpose desktop can go beyond the current shell. a polished general-purpose desktop can go beyond the current shell.
1. Home Assistant favourites in a dedicated, secure quick panel. The token must 1. A real external RustDesk test while hyprlock is active. Local process state
stay in the keyring or a private state file, never in this repository.
2. Phone integration through KDE Connect, including battery/share status in the
tray. The package is installed and ready for a new pairing.
3. A printer administration GUI. CUPS is already active; the
`system-config-printer` package supplies the missing UI.
4. A real external RustDesk test while hyprlock is active. Local process state
cannot prove what a remote client sees or whether input is accepted. cannot prove what a remote client sees or whether input is accepted.
Supporting packages tracked in `setup/packages/hyprland-packages`: KDE Connect, the printer UI, Tesseract, and ZBar are installed and remain
tracked in `setup/packages/hyprland-packages`.
```sh
sudo dnf install kde-connect system-config-printer tesseract zbar
```
## Global menu decision ## Global menu decision
+32
View File
@@ -213,6 +213,38 @@ before a timed event until 5 minutes after it starts. It displays relative time
(`12m` or `Now`); the event title is available on hover but otherwise stays out (`12m` or `Now`); the event title is available on hover but otherwise stays out
of the bar. All-day events never trigger the capsule. of the bar. All-day events never trigger the capsule.
## Control Center
Click the right-side status cluster or press `SUPER + S` for Panama's Control
Center. It is attached directly beneath the bar and keeps Wi-Fi, Bluetooth,
Caffeine, Night Light, Focus, audio input/output, user, settings, and power in
one place. Home and Phone continue the same surface rather than opening extra
dashboard windows.
Home shows the first four configured Home Assistant favourites as direct
controls and expands to the complete configured list. Panama reads the current
GNOME Home Assistant extension configuration and its Secret Service token as a
compatibility fallback, so the existing setup works without copying a secret.
The preferred private configuration lives in the gitignored
`config/bash/env` file:
```sh
export PANAMA_HOME_ASSISTANT_URL=https://home.example.test
export PANAMA_HOME_ASSISTANT_TOKEN=replace-with-a-long-lived-token
export PANAMA_HOME_ASSISTANT_ENTITIES=light.kitchen,light.living_room
```
The helper reads that file directly. No shell restart is required after editing
it; close and reopen Control Center to refresh. If Home Assistant is offline,
the last known values stay visible with a stale-state label and Retry action.
Phone uses KDE Connect for the capabilities the paired iPhone actually
advertises: Send File, Send Clipboard, and Ring. The device remains visible
while iOS suspends KDE Connect, but actions stay disabled until it reconnects.
No battery percentage is invented when iOS reports none, and BlueBubbles
remains the messaging experience. Active file sends appear in Daybook's
Ongoing page; successful sends become a quiet recent exchange.
## Screen Intelligence ## Screen Intelligence
`SUPER + SHIFT + S` opens the capture picker directly in Selection + Read `SUPER + SHIFT + S` opens the capture picker directly in Selection + Read
+2
View File
@@ -78,6 +78,8 @@ Singleton {
readonly property int popoverRadius: 18 readonly property int popoverRadius: 18
readonly property int popoverPadding: 14 readonly property int popoverPadding: 14
readonly property int popoverWidth: 380 readonly property int popoverWidth: 380
readonly property int controlCenterWidth: 430
readonly property int controlCenterTopGap: 2
readonly property int cardRadius: 12 readonly property int cardRadius: 12
readonly property int pillRadius: 999 readonly property int pillRadius: 999
@@ -10,6 +10,7 @@ import Quickshell.Networking
import Quickshell.Services.Pipewire import Quickshell.Services.Pipewire
import QtQuick import QtQuick
import qs.config import qs.config
import qs.services
import qs.widgets import qs.widgets
Pill { Pill {
@@ -106,4 +107,10 @@ Pill {
glyph: "\u{F0176}" // md-coffee glyph: "\u{F0176}" // md-coffee
color: Theme.warn color: Theme.warn
} }
StatusGlyph {
visible: KdeConnect.phoneReachable
glyph: "\u{F03F2}" // md-cellphone-link
color: Theme.cyan
}
} }
@@ -0,0 +1,50 @@
import QtQuick
import qs.config
Item {
id: root
property string label: ""
property string action: ""
property bool actionEnabled: true
signal actionTriggered
implicitHeight: 24
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: root.label.toUpperCase()
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.letterSpacing: 0.9
}
Text {
id: actionLabel
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: root.action !== ""
text: root.action
color: root.actionEnabled
? (actionMouse.containsMouse ? Theme.accentAlt : Theme.accent)
: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
Behavior on color { ColorAnimation { duration: Theme.durFast } }
MouseArea {
id: actionMouse
anchors.fill: parent
anchors.margins: -6
enabled: root.actionEnabled
hoverEnabled: true
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.actionTriggered()
}
}
}
@@ -0,0 +1,280 @@
import QtQuick
import qs.config
import qs.services
Item {
id: root
property bool expanded: false
signal toggleExpanded
implicitHeight: content.implicitHeight
Column {
id: content
width: parent.width
spacing: 6
ControlSectionHeader {
width: parent.width
label: "Home"
action: HomeAssistant.entities.length > 0
? HomeAssistant.entities.length + " accessories " + (root.expanded ? "⌃" : "›")
: "Retry"
actionEnabled: HomeAssistant.phase !== "loading"
onActionTriggered: {
if (HomeAssistant.entities.length > 0)
root.toggleExpanded();
else
HomeAssistant.refresh();
}
}
Rectangle {
id: homeCard
width: parent.width
height: visible ? homeGrid.implicitHeight + 20 : 0
visible: HomeAssistant.entities.length > 0
radius: Theme.cardRadius + 2
color: Theme.alpha(Theme.accent, 0.045)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.12)
Grid {
id: homeGrid
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 10
columns: 4
spacing: 7
Repeater {
model: HomeAssistant.visibleEntities
HomeTile {
required property var modelData
width: (homeGrid.width - homeGrid.spacing * 3) / 4
entity: modelData
busy: HomeAssistant.busyEntityId === modelData.id
onActivated: HomeAssistant.toggleEntity(modelData.id)
}
}
}
}
Rectangle {
width: parent.width
height: visible ? 66 : 0
visible: HomeAssistant.entities.length === 0
radius: Theme.cardRadius
color: Theme.alpha(Theme.fg, 0.05)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.045)
Text {
id: stateIcon
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: HomeAssistant.phase === "loading" ? "\u{F0772}" : "\u{F02DC}"
color: HomeAssistant.phase === "loading" ? Theme.accent : Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 16
}
Column {
anchors.left: stateIcon.right
anchors.leftMargin: 11
anchors.right: openHome.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: root.emptyTitle()
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: root.emptyDetail()
color: Theme.fgDim
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
Rectangle {
id: openHome
anchors.right: parent.right
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
width: 48
height: 28
radius: 9
visible: HomeAssistant.phase !== "loading"
color: openMouse.containsMouse
? Theme.alpha(Theme.accent, 0.20)
: Theme.alpha(Theme.accent, 0.11)
Text {
anchors.centerIn: parent
text: "Open"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
MouseArea {
id: openMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: HomeAssistant.open()
}
}
}
Rectangle {
width: parent.width
height: visible ? 30 : 0
visible: HomeAssistant.stale
radius: 9
color: Theme.alpha(Theme.warn, 0.08)
Text {
anchors.left: parent.left
anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter
text: "Showing the last known state"
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Text {
anchors.right: parent.right
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
text: "Retry"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
MouseArea {
anchors.fill: parent
anchors.margins: -6
cursorShape: Qt.PointingHandCursor
onClicked: HomeAssistant.refresh()
}
}
}
Section {
width: parent.width
expanded: root.expanded && HomeAssistant.entities.length > 0
ScrollColumn {
width: parent.width
maxHeight: 270
spacing: 2
Repeater {
model: HomeAssistant.entities
Rectangle {
id: entityRow
required property var modelData
width: parent.width
height: 44
radius: 10
color: entityMouse.containsMouse
? Theme.alpha(Theme.fg, 0.09)
: "transparent"
Text {
id: entityGlyph
anchors.left: parent.left
anchors.leftMargin: 11
anchors.verticalCenter: parent.verticalCenter
text: "\u{F0335}"
color: entityRow.modelData.active ? Theme.warn : Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 14
}
Column {
anchors.left: entityGlyph.right
anchors.leftMargin: 10
anchors.right: entityState.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: entityRow.modelData.name
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Text {
width: parent.width
text: !entityRow.modelData.available ? "Unavailable" : entityRow.modelData.state
color: Theme.fgMuted
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
Text {
id: entityState
anchors.right: parent.right
anchors.rightMargin: 11
anchors.verticalCenter: parent.verticalCenter
text: HomeAssistant.busyEntityId === entityRow.modelData.id
? "Working"
: (entityRow.modelData.active ? "On" : "Off")
color: entityRow.modelData.active ? Theme.warn : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
MouseArea {
id: entityMouse
anchors.fill: parent
enabled: entityRow.modelData.available && HomeAssistant.busyEntityId === ""
hoverEnabled: true
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: HomeAssistant.toggleEntity(entityRow.modelData.id)
}
}
}
}
}
}
function emptyTitle(): string {
if (HomeAssistant.phase === "loading")
return "Loading your home";
if (HomeAssistant.lastError === "authentication-required")
return "Authentication required";
if (HomeAssistant.lastError === "not-configured")
return "Home Assistant is not configured";
return "Home Assistant is unavailable";
}
function emptyDetail(): string {
if (HomeAssistant.phase === "loading")
return "Reading configured favourites";
if (HomeAssistant.lastError === "authentication-required")
return "Update the long-lived access token";
if (HomeAssistant.lastError === "not-configured")
return "Add a URL, token and favourites";
return "Check the connection and retry";
}
}
@@ -0,0 +1,81 @@
import QtQuick
import qs.config
Rectangle {
id: root
required property var entity
property bool busy: false
signal activated
implicitHeight: 72
radius: Theme.cardRadius
color: {
if (root.entity.active)
return Theme.alpha(Theme.warn, tileMouse.containsMouse ? 0.20 : 0.14);
return Theme.alpha(Theme.fg, tileMouse.containsMouse ? 0.10 : 0.055);
}
border.width: 1
border.color: root.entity.active
? Theme.alpha(Theme.warn, 0.20)
: Theme.alpha(Theme.fg, 0.045)
Behavior on color { ColorAnimation { duration: Theme.durFast } }
Text {
anchors.left: parent.left
anchors.leftMargin: 10
anchors.top: parent.top
anchors.topMargin: 9
text: root.busy ? "\u{F0772}" : root.glyphFor(root.entity.domain)
color: root.entity.active ? Theme.warn : Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 15
}
Column {
anchors.left: parent.left
anchors.leftMargin: 10
anchors.right: parent.right
anchors.rightMargin: 8
anchors.bottom: parent.bottom
anchors.bottomMargin: 8
spacing: 1
Text {
width: parent.width
text: root.entity.name
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: !root.entity.available ? "Unavailable" : (root.entity.active ? "On" : "Off")
color: root.entity.active ? Theme.warn : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: 9
}
}
MouseArea {
id: tileMouse
anchors.fill: parent
enabled: root.entity.available && !root.busy
hoverEnabled: true
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.activated()
}
function glyphFor(domain: string): string {
if (domain === "light")
return "\u{F0335}";
if (domain === "switch")
return "\u{F0521}";
if (domain === "scene")
return "\u{F0FCE}";
return "\u{F02DC}";
}
}
@@ -0,0 +1,273 @@
import QtQuick
import QtQuick.Dialogs
import qs.config
import qs.services
Item {
id: root
property bool expanded: false
signal toggleExpanded
readonly property var phone: KdeConnect.preferredPhone
readonly property var actionModels: [
{ id: "share", glyph: "\u{F0142}", label: "Send file" },
{ id: "clipboard", glyph: "\u{F014C}", label: "Clipboard" },
{ id: "ring", glyph: "\u{F009A}", label: "Ring" }
].filter(item => KdeConnect.supports(item.id))
implicitHeight: content.implicitHeight
Column {
id: content
width: parent.width
spacing: 6
ControlSectionHeader {
width: parent.width
label: "Phone"
action: root.phone ? (root.expanded ? "Details ⌃" : "Details ›") : "Refresh"
onActionTriggered: {
if (root.phone)
root.toggleExpanded();
else
KdeConnect.refresh();
}
}
Rectangle {
width: parent.width
height: phoneContent.implicitHeight + 22
radius: Theme.cardRadius + 2
color: Theme.alpha(Theme.fg, 0.055)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.05)
Column {
id: phoneContent
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 11
spacing: 9
Item {
width: parent.width
height: 46
Rectangle {
id: phoneSilhouette
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 29
height: 43
radius: 8
color: Theme.alpha(Theme.accent, 0.07)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.42)
Rectangle {
anchors.top: parent.top
anchors.topMargin: 4
anchors.horizontalCenter: parent.horizontalCenter
width: 10
height: 2
radius: 1
color: Theme.alpha(Theme.fg, 0.35)
}
Rectangle {
anchors.right: parent.right
anchors.rightMargin: -3
anchors.top: parent.top
anchors.topMargin: 10
width: 6
height: 6
radius: 3
visible: KdeConnect.phoneReachable
color: Theme.ok
}
}
Column {
anchors.left: phoneSilhouette.right
anchors.leftMargin: 11
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: root.phone?.name ?? (KdeConnect.available ? "No paired phone" : "KDE Connect unavailable")
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: root.phone
? (KdeConnect.phoneReachable ? "● Nearby on Wi-Fi" : "Not nearby")
: (KdeConnect.available ? "Pair a device to continue" : "Open settings to repair the service")
color: KdeConnect.phoneReachable ? Theme.ok : Theme.fgDim
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
Grid {
id: actionsGrid
width: parent.width
columns: Math.max(1, root.actionModels.length)
spacing: 7
visible: root.actionModels.length > 0
Repeater {
model: root.actionModels
Rectangle {
id: actionButton
required property var modelData
width: (actionsGrid.width - actionsGrid.spacing * Math.max(0, root.actionModels.length - 1)) / Math.max(1, root.actionModels.length)
height: 48
radius: 11
color: actionMouse.containsMouse && actionMouse.enabled
? Theme.alpha(Theme.accent, 0.15)
: Theme.alpha(Theme.accent, 0.075)
opacity: actionMouse.enabled ? 1 : 0.48
Text {
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: 7
text: actionButton.modelData.glyph
color: Theme.accent
font.family: Theme.fontMono
font.pixelSize: 13
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: 6
text: actionButton.modelData.label
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 9
font.weight: Font.Medium
}
MouseArea {
id: actionMouse
anchors.fill: parent
enabled: KdeConnect.phoneReachable && !KdeConnect.transferActive
hoverEnabled: true
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.invoke(actionButton.modelData.id)
}
}
}
}
Text {
width: parent.width
visible: root.phone && !KdeConnect.phoneReachable && root.actionModels.length > 0
text: "Actions become available when the iPhone reconnects"
color: Theme.fgMuted
horizontalAlignment: Text.AlignHCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Text {
width: parent.width
visible: KdeConnect.lastError !== ""
text: root.errorText(KdeConnect.lastError)
color: Theme.warn
horizontalAlignment: Text.AlignHCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Rectangle {
width: parent.width
height: 1
visible: KdeConnect.recentExchange !== null
color: Theme.alpha(Theme.fg, 0.07)
}
RecentExchange {
width: parent.width
height: visible ? implicitHeight : 0
visible: KdeConnect.recentExchange !== null
exchange: KdeConnect.recentExchange ?? ({})
}
}
}
Section {
width: parent.width
expanded: root.expanded && root.phone !== null
Column {
width: parent.width
spacing: 2
RowButton {
width: parent.width
icon: "network-wireless-symbolic"
label: "Connection"
sublabel: KdeConnect.phoneReachable ? "Nearby on Wi-Fi" : "Waiting for the phone"
selected: KdeConnect.phoneReachable
onClicked: KdeConnect.refresh()
}
RowButton {
width: parent.width
icon: "preferences-system-symbolic"
label: "Device settings"
sublabel: "Pairing, plugins and service health"
onClicked: {
ShellState.close();
ShellState.openSettings("connectivity");
}
}
}
}
}
FileDialog {
id: fileDialog
title: root.phone ? "Send a file to " + root.phone.name : "Send a file"
fileMode: FileDialog.OpenFile
nameFilters: ["All files (*)"]
onAccepted: {
const path = root.localPath(selectedFile);
if (path !== "")
KdeConnect.sendFile(path);
}
}
function invoke(action: string): void {
if (action === "share")
fileDialog.open();
else if (action === "clipboard")
KdeConnect.sendClipboard();
else if (action === "ring")
KdeConnect.ring();
}
function localPath(selectedUrl: url): string {
const value = String(selectedUrl);
if (!value.startsWith("file://"))
return "";
return decodeURIComponent(value.slice(7));
}
function errorText(code: string): string {
if (code === "device-offline")
return "The iPhone is not nearby";
if (code === "unsupported-action")
return "That action is not available on this iPhone";
return "The phone action did not finish";
}
}
@@ -18,15 +18,21 @@ PanelWindow {
visible: ShellState.quickSettingsOpen visible: ShellState.quickSettingsOpen
color: "transparent" color: "transparent"
// Hangs off the top-right corner, clear of the floating bar. // Hangs directly beneath the top-right status cluster. Two logical pixels
// preserve the Prism edge without making the surface feel detached.
anchors.top: true anchors.top: true
anchors.right: true anchors.right: true
margins.top: Theme.barHeight + Theme.barGap * 2 margins.top: Theme.barHeight + Theme.controlCenterTopGap
margins.right: Theme.barSideMargin margins.right: Theme.barSideMargin
exclusiveZone: 0 exclusiveZone: 0
implicitWidth: Theme.popoverWidth implicitWidth: Theme.controlCenterWidth
implicitHeight: panel.implicitHeight implicitHeight: panel.implicitHeight
readonly property string expandedSection: panel.expandedSection
function expand(name: string): void {
panel.expand(name);
}
// The `qs-popover` prefix is matched by a layerrule in hypr/rules.lua. // The `qs-popover` prefix is matched by a layerrule in hypr/rules.lua.
WlrLayershell.namespace: "qs-popover-quicksettings" WlrLayershell.namespace: "qs-popover-quicksettings"
@@ -36,10 +42,13 @@ PanelWindow {
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
onVisibleChanged: { onVisibleChanged: {
if (root.visible) if (root.visible) {
KdeConnect.refresh();
HomeAssistant.refresh();
openAnim.restart(); openAnim.restart();
else } else {
panel.reset(); panel.reset();
}
} }
// Closes the panel when a click lands anywhere else on the desktop. // Closes the panel when a click lands anywhere else on the desktop.
@@ -18,7 +18,7 @@ import qs.widgets
Item { Item {
id: root id: root
implicitWidth: Theme.popoverWidth implicitWidth: Theme.controlCenterWidth
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2 implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
// "" | "wifi" | "bluetooth" | "sink" | "source". Only one detail list is // "" | "wifi" | "bluetooth" | "sink" | "source". Only one detail list is
@@ -233,6 +233,19 @@ Item {
width: content.width width: content.width
} }
// ── Connected life ─────────────────────────────────────────────────
HomeControls {
width: content.width
expanded: root.expandedSection === "home"
onToggleExpanded: root.expand("home")
}
PhoneControls {
width: content.width
expanded: root.expandedSection === "phone"
onToggleExpanded: root.expand("phone")
}
Rectangle { Rectangle {
width: content.width width: content.width
height: 1 height: 1
@@ -0,0 +1,64 @@
import QtQuick
import qs.config
Item {
id: root
required property var exchange
implicitHeight: 40
Rectangle {
width: 24
height: 24
radius: 8
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
color: Theme.alpha(Theme.ok, 0.10)
Text {
anchors.centerIn: parent
text: "\u{F012C}"
color: Theme.ok
font.family: Theme.fontMono
font.pixelSize: 12
}
}
Column {
anchors.left: parent.left
anchors.leftMargin: 34
anchors.right: timeLabel.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: root.exchange.fileName || "File"
color: Theme.fg
elide: Text.ElideMiddle
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: "Sent to " + (root.exchange.deviceName || "phone")
color: Theme.fgMuted
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: 9
}
}
Text {
id: timeLabel
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Now"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: 9
}
}
+434
View File
@@ -0,0 +1,434 @@
#!/usr/bin/env python3
"""Private Home Assistant REST boundary for the Panama Control Center."""
from __future__ import annotations
import ast
import json
import os
import pathlib
import re
import shlex
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from typing import Any
ENV_KEYS = (
"PANAMA_HOME_ASSISTANT_URL",
"PANAMA_HOME_ASSISTANT_TOKEN",
"PANAMA_HOME_ASSISTANT_ENTITIES",
)
ENTITY_ID = re.compile(r"^[a-z_]+\.[a-z0-9_]+$")
GNOME_EXTENSION = (
pathlib.Path.home()
/ ".local/share/gnome-shell/extensions/hass-gshell@geoph9-on-github"
)
GNOME_SCHEMA_DIR = GNOME_EXTENSION / "schemas"
GNOME_SCHEMA = "org.gnome.shell.extensions.hass-data"
PANAMA_ENV = pathlib.Path(__file__).resolve().parents[3] / "bash/env"
Runner = Callable[..., subprocess.CompletedProcess[str]]
class BridgeError(RuntimeError):
"""An error code safe to pass across the shell boundary."""
@dataclass(frozen=True)
class Config:
base_url: str
token: str
entity_ids: tuple[str, ...]
@property
def configured(self) -> bool:
return bool(self.base_url and self.token and self.entity_ids)
def compact_json(value: dict[str, object]) -> str:
return json.dumps(value, separators=(",", ":"))
def read_panama_env(path: pathlib.Path = PANAMA_ENV) -> dict[str, str]:
values: dict[str, str] = {}
try:
lines = path.read_text(encoding="utf-8").splitlines()
except OSError:
return values
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if stripped.startswith("export "):
stripped = stripped[7:].lstrip()
if "=" not in stripped:
continue
key, raw_value = stripped.split("=", 1)
key = key.strip()
if key not in ENV_KEYS:
continue
try:
parsed = shlex.split(raw_value, comments=True, posix=True)
except ValueError:
continue
if len(parsed) == 1:
values[key] = parsed[0]
return values
def normalize_url(value: str) -> str:
value = value.strip().rstrip("/")
if not value:
return ""
parsed = urllib.parse.urlsplit(value)
if (
parsed.scheme not in {"http", "https"}
or not parsed.hostname
or parsed.username
or parsed.password
):
raise ValueError("invalid-url")
return value
def parse_entity_ids(value: str | Sequence[str]) -> tuple[str, ...]:
candidates = value.split(",") if isinstance(value, str) else value
result: list[str] = []
for candidate in candidates:
entity_id = str(candidate).strip()
if not entity_id:
continue
if not ENTITY_ID.fullmatch(entity_id):
raise ValueError("invalid-entity")
if entity_id not in result:
result.append(entity_id)
return tuple(result)
def parse_gsettings_string(output: str) -> str:
try:
value = ast.literal_eval(output.strip())
except (SyntaxError, ValueError):
return ""
return value if isinstance(value, str) else ""
def parse_gsettings_array(output: str) -> tuple[str, ...]:
stripped = output.strip()
if stripped.startswith("@as "):
stripped = stripped[4:].strip()
try:
value = ast.literal_eval(stripped)
except (SyntaxError, ValueError):
return ()
if not isinstance(value, (list, tuple)):
return ()
return tuple(item for item in value if isinstance(item, str))
def run_command(
command: list[str],
*,
runner: Runner = subprocess.run,
timeout: float = 5,
) -> subprocess.CompletedProcess[str]:
return runner(
command,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
def gsettings_value(key: str, runner: Runner = subprocess.run) -> str:
if not GNOME_SCHEMA_DIR.is_dir():
return ""
try:
result = run_command(
[
"gsettings",
"--schemadir",
str(GNOME_SCHEMA_DIR),
"get",
GNOME_SCHEMA,
key,
],
runner=runner,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return ""
return result.stdout.strip() if result.returncode == 0 else ""
def secret_service_token(runner: Runner = subprocess.run) -> str:
try:
result = run_command(
["secret-tool", "lookup", "token_string", "user_token"],
runner=runner,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return ""
return result.stdout.strip() if result.returncode == 0 else ""
def load_legacy_config(runner: Runner = subprocess.run) -> Config:
base_url = parse_gsettings_string(gsettings_value("hass-url", runner))
entity_ids = parse_gsettings_array(
gsettings_value("hass-enabled-entities", runner)
)
token = secret_service_token(runner)
return Config(base_url=base_url, token=token, entity_ids=entity_ids)
def resolve_config(
env: Mapping[str, str] | None = None,
legacy: Callable[[], Config] = load_legacy_config,
) -> Config:
private_env = read_panama_env()
private_env.update(dict(os.environ if env is None else env))
url_value = private_env.get("PANAMA_HOME_ASSISTANT_URL", "").strip()
token_value = private_env.get("PANAMA_HOME_ASSISTANT_TOKEN", "").strip()
entities_value = private_env.get("PANAMA_HOME_ASSISTANT_ENTITIES", "").strip()
legacy_config = Config("", "", ())
if not (url_value and token_value and entities_value):
try:
legacy_config = legacy()
except (OSError, subprocess.SubprocessError, ValueError):
legacy_config = Config("", "", ())
base_url = normalize_url(url_value or legacy_config.base_url)
token = token_value or legacy_config.token
entity_ids = (
parse_entity_ids(entities_value)
if entities_value
else parse_entity_ids(legacy_config.entity_ids)
)
return Config(base_url=base_url, token=token, entity_ids=entity_ids)
def public_http_error(status: int, body: str) -> str:
del body
return "authentication-required" if status in {401, 403} else "request-failed"
def request_json(
config: Config,
method: str,
path: str,
payload: dict[str, str] | None = None,
) -> object:
if not config.base_url or not config.token:
raise BridgeError("not-configured")
body = None
if payload is not None:
body = json.dumps(payload, separators=(",", ":")).encode()
request = urllib.request.Request(
config.base_url + path,
data=body,
method=method,
headers={
"Authorization": f"Bearer {config.token}",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=5) as response:
response_body = response.read()
except urllib.error.HTTPError as error:
try:
error_body = error.read().decode(errors="replace")
except OSError:
error_body = ""
raise BridgeError(public_http_error(error.code, error_body)) from None
except (TimeoutError, urllib.error.URLError, OSError):
raise BridgeError("unreachable") from None
if not response_body:
return None
try:
return json.loads(response_body)
except (json.JSONDecodeError, UnicodeDecodeError):
raise BridgeError("invalid-response") from None
def fallback_name(entity_id: str) -> str:
return entity_id.split(".", 1)[1].replace("_", " ").title()
def normalize_entities(
raw: list[dict[str, Any]],
configured: Sequence[str],
) -> list[dict[str, object]]:
by_id = {
str(item.get("entity_id", "")): item
for item in raw
if isinstance(item, dict)
}
result: list[dict[str, object]] = []
for entity_id in configured:
item = by_id.get(entity_id)
if not item:
continue
attributes = item.get("attributes")
if not isinstance(attributes, dict):
continue
state = str(item.get("state", "unavailable"))
available = state not in {"unknown", "unavailable"}
friendly_name = attributes.get("friendly_name")
name = (
friendly_name.strip()
if isinstance(friendly_name, str) and friendly_name.strip()
else fallback_name(entity_id)
)
result.append(
{
"id": entity_id,
"name": name,
"domain": entity_id.split(".", 1)[0],
"state": state,
"available": available,
"active": available
and state not in {"off", "closed", "idle", "standby"},
}
)
return result
def ensure_configured(entity_id: str, configured: Sequence[str]) -> str:
if entity_id not in configured:
raise ValueError("entity-not-configured")
return entity_id
def collect_snapshot(config: Config) -> dict[str, object]:
if not config.configured:
return {
"ok": False,
"configured": False,
"generatedAt": int(time.time()),
"entities": [],
"error": "not-configured",
}
try:
raw = request_json(config, "GET", "/api/states")
if not isinstance(raw, list):
raise BridgeError("invalid-response")
entities = normalize_entities(raw, config.entity_ids)
except BridgeError as error:
return {
"ok": False,
"configured": True,
"generatedAt": int(time.time()),
"entities": [],
"error": str(error),
}
return {
"ok": True,
"configured": True,
"generatedAt": int(time.time()),
"entities": entities,
"error": "",
}
def probe(config: Config) -> dict[str, object]:
if not config.configured:
return {
"configured": False,
"reachable": False,
"entityCount": len(config.entity_ids),
"error": "not-configured",
}
try:
request_json(config, "GET", "/api/")
except BridgeError as error:
return {
"configured": True,
"reachable": False,
"entityCount": len(config.entity_ids),
"error": str(error),
}
return {
"configured": True,
"reachable": True,
"entityCount": len(config.entity_ids),
"error": "",
}
def toggle(config: Config, entity_id: str) -> dict[str, object]:
entity_id = ensure_configured(entity_id, config.entity_ids)
request_json(
config,
"POST",
"/api/services/homeassistant/toggle",
{"entity_id": entity_id},
)
return {"ok": True, "entityId": entity_id, "error": ""}
def open_home(config: Config) -> dict[str, object]:
if not config.base_url:
return {"ok": False, "error": "not-configured"}
try:
subprocess.Popen(
["xdg-open", config.base_url],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
except OSError:
return {"ok": False, "error": "open-failed"}
return {"ok": True, "error": ""}
def print_result(value: dict[str, object]) -> None:
sys.stdout.write(compact_json(value) + "\n")
def main(argv: list[str]) -> int:
try:
config = resolve_config()
except ValueError as error:
print_result({"ok": False, "configured": False, "error": str(error)})
return 2
command = argv[0] if argv else "probe"
if command == "probe" and len(argv) <= 1:
result = probe(config)
success = bool(result["reachable"])
elif command == "snapshot" and len(argv) == 1:
result = collect_snapshot(config)
success = bool(result["ok"])
elif command == "toggle" and len(argv) == 2:
try:
result = toggle(config, argv[1])
except ValueError as error:
result = {"ok": False, "error": str(error)}
except BridgeError as error:
result = {"ok": False, "error": str(error)}
success = bool(result["ok"])
elif command == "open" and len(argv) == 1:
result = open_home(config)
success = bool(result["ok"])
else:
result = {"ok": False, "error": "invalid-command"}
success = False
print_result(result)
return 0 if success else 2
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env python3
"""Capability-aware KDE Connect boundary for the Panama shell."""
from __future__ import annotations
import json
import pathlib
import re
import shlex
import subprocess
import sys
from collections.abc import Callable
DEVICE_ID = re.compile(r"^[A-Fa-f0-9]{32,64}$")
DEVICE_LINE = re.compile(
r"^-\s+(?P<name>.+):\s+(?P<id>[A-Fa-f0-9]{32,64})"
r"(?:\s+on\s+.+?)?\s+\((?P<state>[^)]+)\)\s*$"
)
PLUGIN_ACTIONS = {
"kdeconnect_clipboard": "clipboard",
"kdeconnect_findmyphone": "ring",
"kdeconnect_ping": "ping",
"kdeconnect_share": "share",
}
DEVICE_OBJECT_PREFIX = "/modules/kdeconnect/devices"
Runner = Callable[..., subprocess.CompletedProcess[str]]
def compact_json(value: dict[str, object]) -> str:
return json.dumps(value, separators=(",", ":"))
def validate_device_id(value: str) -> str:
if not DEVICE_ID.fullmatch(value):
raise ValueError("invalid-device")
return value
def validate_file(value: pathlib.Path) -> pathlib.Path:
try:
resolved = value.expanduser().resolve(strict=True)
except OSError as error:
raise ValueError("invalid-file") from error
if not resolved.is_file():
raise ValueError("invalid-file")
return resolved
def inferred_type(name: str) -> str:
normalized = name.casefold()
if "iphone" in normalized or "phone" in normalized:
return "phone"
if "tablet" in normalized or "ipad" in normalized:
return "tablet"
return "device"
def normalize_device_line(
line: str,
plugins: list[str],
device_type: str = "",
) -> dict[str, object]:
match = DEVICE_LINE.fullmatch(line.strip())
if match is None:
raise ValueError("invalid-device-line")
state = match.group("state").casefold()
actions = sorted(
{
action
for plugin, action in PLUGIN_ACTIONS.items()
if plugin in plugins
}
)
name = match.group("name").strip()
return {
"id": match.group("id"),
"name": name,
"type": device_type or inferred_type(name),
"paired": "paired" in state,
"reachable": "reachable" in state,
"actions": actions,
}
def parse_loaded_plugins(output: str) -> list[str]:
try:
parts = shlex.split(output)
except ValueError:
return []
if len(parts) < 2 or parts[0] != "as":
return []
try:
count = int(parts[1])
except ValueError:
return []
return parts[2 : 2 + max(0, count)]
def parse_string_property(output: str) -> str:
try:
parts = shlex.split(output)
except ValueError:
return ""
return parts[1] if len(parts) == 2 and parts[0] == "s" else ""
def run_command(
command: list[str],
*,
runner: Runner = subprocess.run,
timeout: float = 8,
) -> subprocess.CompletedProcess[str]:
return runner(
command,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
def device_object(device_id: str) -> str:
return f"{DEVICE_OBJECT_PREFIX}/{validate_device_id(device_id)}"
def loaded_plugins(device_id: str, runner: Runner = subprocess.run) -> list[str]:
result = run_command(
[
"busctl",
"--user",
"call",
"org.kde.kdeconnect",
device_object(device_id),
"org.kde.kdeconnect.device",
"loadedPlugins",
],
runner=runner,
)
return parse_loaded_plugins(result.stdout) if result.returncode == 0 else []
def supported_plugins(device_id: str, runner: Runner = subprocess.run) -> list[str]:
result = run_command(
[
"busctl",
"--user",
"get-property",
"org.kde.kdeconnect",
device_object(device_id),
"org.kde.kdeconnect.device",
"supportedPlugins",
],
runner=runner,
)
return parse_loaded_plugins(result.stdout) if result.returncode == 0 else []
def device_plugins(device_id: str, runner: Runner = subprocess.run) -> list[str]:
active = loaded_plugins(device_id, runner)
return active if active else supported_plugins(device_id, runner)
def reported_type(device_id: str, runner: Runner = subprocess.run) -> str:
result = run_command(
[
"busctl",
"--user",
"get-property",
"org.kde.kdeconnect",
device_object(device_id),
"org.kde.kdeconnect.device",
"type",
],
runner=runner,
)
return parse_string_property(result.stdout) if result.returncode == 0 else ""
def collect_status(runner: Runner = subprocess.run) -> dict[str, object]:
try:
listing = run_command(
["kdeconnect-cli", "--list-devices"],
runner=runner,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return {"available": False, "devices": [], "error": "unavailable"}
if listing.returncode != 0:
return {"available": False, "devices": [], "error": "unavailable"}
devices: list[dict[str, object]] = []
for line in listing.stdout.splitlines():
if not line.lstrip().startswith("-"):
continue
match = DEVICE_LINE.fullmatch(line.strip())
if match is None:
continue
device_id = match.group("id")
try:
device = normalize_device_line(
line,
device_plugins(device_id, runner),
reported_type(device_id, runner),
)
except ValueError:
continue
devices.append(device)
devices.sort(
key=lambda device: (
not bool(device["reachable"]),
not bool(device["paired"]),
str(device["name"]).casefold(),
)
)
return {"available": True, "devices": devices, "error": ""}
def action_command(
action: str,
device_id: str,
file_path: pathlib.Path | None = None,
) -> list[str]:
device_id = validate_device_id(device_id)
options = {
"ring": ["--ring"],
"clipboard": ["--send-clipboard"],
}
if action == "share" and file_path is not None:
return [
"kdeconnect-cli",
"-d",
device_id,
"--share",
str(validate_file(file_path)),
]
if action not in options:
raise ValueError("unsupported-action")
return ["kdeconnect-cli", "-d", device_id, *options[action]]
def invoke_action(
action: str,
device_id: str,
file_path: pathlib.Path | None = None,
runner: Runner = subprocess.run,
) -> dict[str, object]:
device_id = validate_device_id(device_id)
status = collect_status(runner)
device = next(
(
item
for item in status["devices"]
if isinstance(item, dict) and item.get("id") == device_id
),
None,
)
if not device or not device.get("paired") or not device.get("reachable"):
return {"ok": False, "action": action, "error": "device-offline"}
if action not in device.get("actions", []):
return {"ok": False, "action": action, "error": "unsupported-action"}
command = action_command(action, device_id, file_path)
try:
result = run_command(command, runner=runner, timeout=3600)
except (FileNotFoundError, subprocess.TimeoutExpired):
return {"ok": False, "action": action, "error": "action-failed"}
if result.returncode != 0:
return {"ok": False, "action": action, "error": "action-failed"}
response: dict[str, object] = {"ok": True, "action": action, "error": ""}
if action == "share" and file_path is not None:
response["fileName"] = validate_file(file_path).name
return response
def print_result(value: dict[str, object]) -> None:
sys.stdout.write(compact_json(value) + "\n")
def main(argv: list[str]) -> int:
if not argv or argv[0] == "status":
print_result(collect_status())
return 0
command = argv[0]
try:
if command == "ring" and len(argv) == 2:
result = invoke_action("ring", argv[1])
elif command == "send-clipboard" and len(argv) == 2:
result = invoke_action("clipboard", argv[1])
elif command == "send-file" and len(argv) == 3:
result = invoke_action("share", argv[1], pathlib.Path(argv[2]))
else:
result = {"ok": False, "action": command, "error": "invalid-command"}
except ValueError as error:
result = {"ok": False, "action": command, "error": str(error)}
print_result(result)
return 0 if bool(result.get("ok")) else 2
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+27 -38
View File
@@ -16,7 +16,8 @@ Singleton {
property bool kdeInitialized: false property bool kdeInitialized: false
property string previousOutput: "" property string previousOutput: ""
property string previousBluetooth: "" property string previousBluetooth: ""
property string previousKde: "" property bool previousKdeReachable: false
property string previousKdeName: ""
PwObjectTracker { objects: [Pipewire.defaultAudioSink] } PwObjectTracker { objects: [Pipewire.defaultAudioSink] }
@@ -33,6 +34,8 @@ Singleton {
.join("\u001f"); .join("\u001f");
} }
readonly property string kdeName: KdeConnect.preferredPhone?.name ?? ""
onOutputNameChanged: { onOutputNameChanged: {
if (!root.outputInitialized) { if (!root.outputInitialized) {
root.previousOutput = root.outputName; root.previousOutput = root.outputName;
@@ -78,55 +81,41 @@ Singleton {
onTriggered: { onTriggered: {
root.previousOutput = root.outputName; root.previousOutput = root.outputName;
root.previousBluetooth = root.bluetoothNames; root.previousBluetooth = root.bluetoothNames;
root.previousKdeReachable = KdeConnect.phoneReachable;
root.previousKdeName = root.kdeName;
root.outputInitialized = true; root.outputInitialized = true;
root.bluetoothInitialized = true; root.bluetoothInitialized = true;
root.kdeInitialized = true;
} }
} }
Timer { Connections {
interval: 15000 target: KdeConnect
repeat: true
running: true
triggeredOnStart: true
onTriggered: {
if (!kdeProbe.running)
kdeProbe.running = true;
}
}
Process { function onPhoneReachableChanged(): void {
id: kdeProbe if (!root.kdeInitialized)
command: ["kdeconnect-cli", "-a", "--name-only"] return;
stdout: StdioCollector { const reachable = KdeConnect.phoneReachable;
onStreamFinished: { if (reachable === root.previousKdeReachable)
const names = this.text.split("\n") return;
.map(line => line.trim()) StatusEvents.publish({
.filter(line => line && !/^\d+ devices? found$/.test(line)) key: "device-kdeconnect",
.sort() glyph: "\u{F03F2}",
.join("\u001f"); title: root.kdeName || root.previousKdeName || "Phone",
if (root.kdeInitialized && names !== root.previousKde) { detail: reachable ? "KDE Connect available" : "KDE Connect disconnected",
const current = names ? names.split("\u001f") : []; tone: reachable ? "accent" : "warn",
const previous = root.previousKde ? root.previousKde.split("\u001f") : []; priority: StatusEvents.ambientPriority
const connected = current.find(name => !previous.includes(name)); });
const disconnected = previous.find(name => !current.includes(name)); root.previousKdeReachable = reachable;
StatusEvents.publish({ root.previousKdeName = root.kdeName || root.previousKdeName;
key: "device-kdeconnect",
glyph: "\u{F03F2}",
title: connected || disconnected || "Phone",
detail: connected ? "KDE Connect available" : "KDE Connect disconnected",
tone: connected ? "accent" : "warn",
priority: StatusEvents.ambientPriority
});
}
root.previousKde = names;
root.kdeInitialized = true;
}
} }
} }
Component.onCompleted: { Component.onCompleted: {
root.previousOutput = root.outputName; root.previousOutput = root.outputName;
root.previousBluetooth = root.bluetoothNames; root.previousBluetooth = root.bluetoothNames;
root.previousKdeReachable = KdeConnect.phoneReachable;
root.previousKdeName = root.kdeName;
discoverySettle.start(); discoverySettle.start();
} }
} }
@@ -0,0 +1,185 @@
pragma Singleton
// Home Assistant state for the Control Center. Credentials and REST details
// remain behind the helper; QML receives configured favourites only.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-home-assistant"
// "loading" | "ready" | "degraded" | "unavailable"
property string phase: "loading"
property var entities: []
property bool stale: false
property string busyEntityId: ""
property string lastError: ""
property bool fixtureMode: false
readonly property var visibleEntities: root.entities.slice(0, 4)
readonly property int configuredCount: root.entities.length
function refresh(): void {
if (root.fixtureMode || refreshProc.running)
return;
if (root.entities.length === 0)
root.phase = "loading";
refreshProc.running = true;
}
function consumeSnapshot(text: string): void {
if (root.fixtureMode)
return;
let result = null;
try {
result = JSON.parse(text);
} catch (error) {
result = { ok: false, error: "invalid-response" };
}
if (result.ok === true) {
root.entities = Array.isArray(result.entities) ? result.entities : [];
root.phase = "ready";
root.stale = false;
root.lastError = "";
return;
}
root.lastError = String(result.error || "unreachable");
if (root.entities.length > 0) {
root.phase = "degraded";
root.stale = true;
} else {
root.phase = "unavailable";
root.stale = false;
}
}
function toggleEntity(entityId: string): void {
if (entityId === "" || root.busyEntityId !== "")
return;
if (root.fixtureMode) {
root.entities = root.entities.map(entity => {
if (entity.id !== entityId)
return entity;
return Object.assign({}, entity, {
active: !entity.active,
state: entity.active ? "off" : "on"
});
});
return;
}
if (!root.entities.some(entity => entity.id === entityId))
return;
root.busyEntityId = entityId;
actionProc.command = [root.helperPath, "toggle", entityId];
actionProc.running = true;
}
function consumeAction(text: string): void {
if (root.busyEntityId === "")
return;
let ok = false;
let errorCode = "action-failed";
try {
const result = JSON.parse(text);
ok = result.ok === true;
errorCode = String(result.error || errorCode);
} catch (error) {
errorCode = "invalid-response";
}
root.busyEntityId = "";
if (ok) {
root.lastError = "";
refreshDelay.restart();
} else {
root.lastError = errorCode;
if (root.entities.length > 0) {
root.phase = "degraded";
root.stale = true;
}
}
}
function open(): void {
Quickshell.execDetached([root.helperPath, "open"]);
}
function fixtureEntities(): var {
return [
{ id: "light.fixture_all", name: "All lights", domain: "light", state: "on", available: true, active: true },
{ id: "light.fixture_kitchen", name: "Kitchen", domain: "light", state: "on", available: true, active: true },
{ id: "light.fixture_living", name: "Living room", domain: "light", state: "off", available: true, active: false },
{ id: "light.fixture_bedroom", name: "Bedroom", domain: "light", state: "on", available: true, active: true },
{ id: "light.fixture_hall", name: "Hall", domain: "light", state: "off", available: true, active: false },
{ id: "light.fixture_desk", name: "Desk", domain: "light", state: "off", available: true, active: false },
{ id: "light.fixture_corner", name: "Corner lamp", domain: "light", state: "unavailable", available: false, active: false }
];
}
function applyFixture(name: string): void {
if (["ready", "stale", "unavailable"].indexOf(name) < 0)
return;
root.fixtureMode = true;
root.busyEntityId = "";
if (name === "unavailable") {
root.entities = [];
root.phase = "unavailable";
root.stale = false;
root.lastError = "not-configured";
return;
}
root.entities = root.fixtureEntities();
root.phase = name === "stale" ? "degraded" : "ready";
root.stale = name === "stale";
root.lastError = name === "stale" ? "unreachable" : "";
}
function clearFixture(): void {
root.fixtureMode = false;
root.phase = "loading";
root.entities = [];
root.stale = false;
root.busyEntityId = "";
root.lastError = "";
root.refresh();
}
Process {
id: refreshProc
command: [root.helperPath, "snapshot"]
stdout: StdioCollector {
onStreamFinished: root.consumeSnapshot(this.text)
}
}
Process {
id: actionProc
stdout: StdioCollector {
onStreamFinished: root.consumeAction(this.text)
}
onExited: (code, status) => {
if (root.busyEntityId !== "")
root.consumeAction("");
}
}
Timer {
interval: 60000
repeat: true
running: ShellState.quickSettingsOpen && !root.fixtureMode
onTriggered: root.refresh()
}
Timer {
id: refreshDelay
interval: 350
onTriggered: root.refresh()
}
Component.onCompleted: root.refresh()
}
@@ -0,0 +1,222 @@
pragma Singleton
// Shared KDE Connect state. The Python helper owns all CLI and D-Bus parsing;
// this singleton exposes only normalized devices and user-initiated actions.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-kdeconnect"
property bool fixtureMode: false
property bool available: false
property var devices: []
property string lastError: ""
property bool transferActive: false
property string transferFileName: ""
property string transferDeviceName: ""
property var recentExchange: null
property string actionKind: ""
property string actionPath: ""
readonly property var preferredPhone: {
const phones = root.devices.filter(device => device.type === "phone" && device.paired);
return phones.find(device => device.reachable) ?? phones[0] ?? null;
}
readonly property bool phoneReachable: root.preferredPhone?.reachable === true
readonly property int pairedCount: root.devices.filter(device => device.paired).length
readonly property var phoneActions: root.preferredPhone?.actions ?? []
function supports(action: string): bool {
return root.phoneActions.indexOf(action) >= 0;
}
function refresh(): void {
if (root.fixtureMode || statusProc.running)
return;
statusProc.running = true;
}
function consumeStatus(text: string): void {
if (root.fixtureMode)
return;
try {
const result = JSON.parse(text);
root.available = result.available === true;
root.devices = Array.isArray(result.devices) ? result.devices : [];
root.lastError = String(result.error ?? "");
} catch (error) {
root.available = false;
root.devices = [];
root.lastError = "invalid-response";
}
}
function sendFile(path: string): void {
if (!root.phoneReachable || !root.supports("share") || path === "" || actionProc.running)
return;
root.actionKind = "share";
root.actionPath = path;
root.transferActive = true;
root.transferFileName = path.split("/").pop();
root.transferDeviceName = root.preferredPhone.name;
actionProc.command = [root.helperPath, "send-file", root.preferredPhone.id, path];
actionProc.running = true;
}
function sendClipboard(): void {
root.runSimpleAction("clipboard", "send-clipboard");
}
function ring(): void {
root.runSimpleAction("ring", "ring");
}
function runSimpleAction(kind: string, command: string): void {
if (!root.phoneReachable || !root.supports(kind) || actionProc.running)
return;
root.actionKind = kind;
root.actionPath = "";
actionProc.command = [root.helperPath, command, root.preferredPhone.id];
actionProc.running = true;
}
function cancelTransfer(): void {
if (actionProc.running && root.actionKind === "share")
actionProc.signal(2);
root.actionKind = "";
root.actionPath = "";
root.transferActive = false;
root.transferFileName = "";
root.transferDeviceName = "";
}
function finishAction(text: string): void {
if (root.actionKind === "")
return;
const kind = root.actionKind;
const localPath = root.actionPath;
const fileName = root.transferFileName;
const deviceName = root.transferDeviceName || root.preferredPhone?.name || "Phone";
let result = null;
try {
result = JSON.parse(text);
} catch (error) {
result = { ok: false, error: "invalid-response" };
}
root.actionKind = "";
root.actionPath = "";
if (kind === "share") {
root.transferActive = false;
root.transferFileName = "";
root.transferDeviceName = "";
}
if (result.ok === true && kind === "share") {
root.recentExchange = {
direction: "sent",
fileName: String(result.fileName || fileName),
deviceName,
timestamp: Date.now()
};
StatusEvents.publish({
key: "phone-transfer",
glyph: "\u{F03F2}",
title: "Sent to " + deviceName,
detail: String(result.fileName || fileName),
tone: "ok",
priority: StatusEvents.importantPriority,
actionId: localPath !== "" ? "open-path" : "",
actionData: localPath
});
} else if (result.ok !== true) {
root.lastError = String(result.error || "action-failed");
StatusEvents.publish({
key: "phone-action",
glyph: "\u{F03F2}",
title: kind === "share" ? "File was not sent" : "Phone action failed",
detail: "Check that the phone is nearby and try again",
tone: "warn",
priority: StatusEvents.importantPriority
});
}
refreshDelay.restart();
}
function applyFixture(name: string): void {
if (["reachable", "offline", "transfer"].indexOf(name) < 0)
return;
root.fixtureMode = true;
root.available = true;
root.lastError = "";
root.recentExchange = null;
root.devices = [{
id: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
name: "Fixture iPhone",
type: "phone",
paired: true,
reachable: name !== "offline",
actions: ["clipboard", "ping", "ring", "share"]
}];
root.transferActive = name === "transfer";
root.transferFileName = name === "transfer" ? "Fixture document.pdf" : "";
root.transferDeviceName = name === "transfer" ? "Fixture iPhone" : "";
root.actionKind = name === "transfer" ? "share" : "";
root.actionPath = "";
}
function clearFixture(): void {
root.fixtureMode = false;
root.available = false;
root.devices = [];
root.lastError = "";
root.transferActive = false;
root.transferFileName = "";
root.transferDeviceName = "";
root.actionKind = "";
root.actionPath = "";
root.recentExchange = null;
root.refresh();
}
Process {
id: statusProc
command: [root.helperPath, "status"]
stdout: StdioCollector {
onStreamFinished: root.consumeStatus(this.text)
}
}
Process {
id: actionProc
stdout: StdioCollector {
onStreamFinished: root.finishAction(this.text)
}
onExited: (code, status) => {
if (root.actionKind !== "")
root.finishAction("");
}
}
Timer {
interval: 30000
repeat: true
running: !root.fixtureMode
triggeredOnStart: true
onTriggered: root.refresh()
}
Timer {
id: refreshDelay
interval: 500
onTriggered: root.refresh()
}
}
@@ -54,6 +54,18 @@ Singleton {
if (PrivacyState.microphoneActive) if (PrivacyState.microphoneActive)
result.push(root.privacyActivity("microphone", "\u{F036C}", "Microphone", PrivacyState.microphoneApp)); result.push(root.privacyActivity("microphone", "\u{F036C}", "Microphone", PrivacyState.microphoneApp));
if (KdeConnect.transferActive) {
result.push({
kind: "phone-transfer",
glyph: "\u{F03F2}",
label: "Sending to " + (KdeConnect.transferDeviceName || "Phone"),
detail: KdeConnect.transferFileName || "Preparing file",
state: "Sending",
tone: "accent",
action: "Cancel"
});
}
return result; return result;
} }
@@ -83,6 +95,8 @@ Singleton {
function activate(kind: string): void { function activate(kind: string): void {
if (kind === "focus") if (kind === "focus")
FocusSession.activateWorkspace(); FocusSession.activateWorkspace();
else if (kind === "phone-transfer")
ShellState.open("quicksettings");
else if (["recording", "screen", "camera", "microphone"].indexOf(kind) >= 0) else if (["recording", "screen", "camera", "microphone"].indexOf(kind) >= 0)
ShellState.open("activity"); ShellState.open("activity");
} }
@@ -94,5 +108,7 @@ Singleton {
Caffeine.toggle(); Caffeine.toggle();
else if (kind === "recording") else if (kind === "recording")
Capture.stopRecording(); Capture.stopRecording();
else if (kind === "phone-transfer")
KdeConnect.cancelTransfer();
} }
} }
+49 -1
View File
@@ -78,7 +78,7 @@ ShellRoot {
// overview or quick settings is instant rather than paying a load. // overview or quick settings is instant rather than paying a load.
Overview {} Overview {}
// QuickSettings is the window; QuickSettingsPanel is its content. // QuickSettings is the window; QuickSettingsPanel is its content.
QuickSettings {} QuickSettings { id: quickSettings }
DateMenu { id: dateMenu } DateMenu { id: dateMenu }
ClipboardPanel {} ClipboardPanel {}
CaptureOverlay {} CaptureOverlay {}
@@ -221,6 +221,54 @@ ShellRoot {
IpcHandler { IpcHandler {
target: "quicksettings" target: "quicksettings"
function toggle(): void { ShellState.toggle("quicksettings"); } function toggle(): void { ShellState.toggle("quicksettings"); }
function open(): void { ShellState.open("quicksettings"); }
function close(): void { ShellState.close(); }
function section(name: string): void { quickSettings.expand(name); }
function status(): string {
return JSON.stringify({
open: ShellState.quickSettingsOpen,
expandedSection: quickSettings.expandedSection
});
}
}
IpcHandler {
target: "kdeconnect"
function fixture(name: string): void { KdeConnect.applyFixture(name); }
function reset(): void { KdeConnect.clearFixture(); }
function refresh(): void { KdeConnect.refresh(); }
function cancel(): void { KdeConnect.cancelTransfer(); }
function status(): string {
return JSON.stringify({
fixture: KdeConnect.fixtureMode,
available: KdeConnect.available,
reachable: KdeConnect.phoneReachable,
pairedCount: KdeConnect.pairedCount,
actionCount: KdeConnect.phoneActions.length,
transferActive: KdeConnect.transferActive,
transferFileName: KdeConnect.transferFileName,
ongoingCount: Ongoing.activities.filter(item => item.kind === "phone-transfer").length,
lastError: KdeConnect.lastError
});
}
}
IpcHandler {
target: "home-assistant"
function fixture(name: string): void { HomeAssistant.applyFixture(name); }
function reset(): void { HomeAssistant.clearFixture(); }
function refresh(): void { HomeAssistant.refresh(); }
function status(): string {
return JSON.stringify({
fixture: HomeAssistant.fixtureMode,
phase: HomeAssistant.phase,
configuredCount: HomeAssistant.configuredCount,
visibleCount: HomeAssistant.visibleEntities.length,
stale: HomeAssistant.stale,
busy: HomeAssistant.busyEntityId !== "",
lastError: HomeAssistant.lastError
});
}
} }
IpcHandler { IpcHandler {
@@ -88,12 +88,12 @@ Create `tests/quickshell/kdeconnect_bridge_test.py` with import-by-path and thes
```python ```python
class KdeConnectBridgeTest(unittest.TestCase): class KdeConnectBridgeTest(unittest.TestCase):
def test_status_omits_network_and_exposes_supported_ios_actions(self): def test_status_omits_network_and_exposes_supported_ios_actions(self):
listing = "- Gib's iPhone: 90B54E84548E4F109268EC2D0E7D6930 on 192.168.1.7 via LAN (paired and reachable)\n1 device found\n" listing = "- Fixture iPhone: BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB on 192.0.2.7 via LAN (paired and reachable)\n1 device found\n"
plugins = ["kdeconnect_clipboard", "kdeconnect_findmyphone", "kdeconnect_ping", "kdeconnect_share"] plugins = ["kdeconnect_clipboard", "kdeconnect_findmyphone", "kdeconnect_ping", "kdeconnect_share"]
device = bridge.normalize_device_line(listing.splitlines()[0], plugins) device = bridge.normalize_device_line(listing.splitlines()[0], plugins)
self.assertEqual(device["name"], "Gib's iPhone") self.assertEqual(device["name"], "Fixture iPhone")
self.assertEqual(device["actions"], ["clipboard", "ping", "ring", "share"]) self.assertEqual(device["actions"], ["clipboard", "ping", "ring", "share"])
self.assertNotIn("192.168.1.7", json.dumps(device)) self.assertNotIn("192.0.2.7", json.dumps(device))
def test_invalid_device_id_is_rejected(self): def test_invalid_device_id_is_rejected(self):
with self.assertRaisesRegex(ValueError, "invalid-device"): with self.assertRaisesRegex(ValueError, "invalid-device"):
@@ -105,8 +105,8 @@ class KdeConnectBridgeTest(unittest.TestCase):
bridge.validate_file(pathlib.Path(directory)) bridge.validate_file(pathlib.Path(directory))
def test_action_command_uses_separate_arguments(self): def test_action_command_uses_separate_arguments(self):
command = bridge.action_command("ring", "90B54E84548E4F109268EC2D0E7D6930") command = bridge.action_command("ring", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")
self.assertEqual(command, ["kdeconnect-cli", "-d", "90B54E84548E4F109268EC2D0E7D6930", "--ring"]) self.assertEqual(command, ["kdeconnect-cli", "-d", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "--ring"])
``` ```
- [ ] **Step 2: Run the test and verify RED** - [ ] **Step 2: Run the test and verify RED**
@@ -46,7 +46,7 @@ the paired iPhone exposes only a useful subset of KDE Connect.
## Verified system state ## Verified system state
KDE Connect currently reports one paired and reachable device: `Gib's iPhone`. KDE Connect currently reports one paired iPhone.
Its live D-Bus object exposes clipboard, find-my-phone, ping, share, and battery Its live D-Bus object exposes clipboard, find-my-phone, ping, share, and battery
plugins. The battery plugin has no value, and the remote-input object exposes no plugins. The battery plugin has no value, and the remote-input object exposes no
callable control methods. Panama must derive action visibility from advertised callable control methods. Panama must derive action visibility from advertised
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -euo pipefail
fail() {
printf 'Control Center contract: %s\n' "$1" >&2
exit 1
}
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cleanup() {
qs ipc call quicksettings close >/dev/null 2>&1 || true
qs ipc call kdeconnect reset >/dev/null 2>&1 || true
qs ipc call home-assistant reset >/dev/null 2>&1 || true
}
trap cleanup EXIT
rg -Fq 'readonly property int controlCenterWidth: 430' \
"$project_root/config/dot/quickshell/config/Theme.qml" \
|| fail 'approved Control Center width is missing'
rg -Fq 'readonly property int controlCenterTopGap: 2' \
"$project_root/config/dot/quickshell/config/Theme.qml" \
|| fail 'approved top attachment is missing'
rg -Fq 'margins.top: Theme.barHeight + Theme.controlCenterTopGap' \
"$project_root/config/dot/quickshell/modules/quicksettings/QuickSettings.qml" \
|| fail 'Control Center is not tightly attached to the bar'
rg -Fq 'implicitWidth: Theme.controlCenterWidth' \
"$project_root/config/dot/quickshell/modules/quicksettings/QuickSettings.qml" \
|| fail 'Control Center window does not use its geometry token'
rg -Fq 'HomeControls' \
"$project_root/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml" \
|| fail 'Home controls are not mounted'
rg -Fq 'PhoneControls' \
"$project_root/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml" \
|| fail 'Phone controls are not mounted'
rg -Fq 'visible: KdeConnect.phoneReachable' \
"$project_root/config/dot/quickshell/modules/bar/StatusCluster.qml" \
|| fail 'reachable phone state is absent from the bar'
for component in ControlSectionHeader HomeControls HomeTile PhoneControls RecentExchange; do
[[ -f "$project_root/config/dot/quickshell/modules/quicksettings/$component.qml" ]] \
|| fail "$component is missing"
done
qs ipc call home-assistant fixture ready >/dev/null
qs ipc call kdeconnect fixture reachable >/dev/null
qs ipc call quicksettings open >/dev/null
hyprctl layers | rg -q 'namespace: qs-popover-quicksettings' \
|| fail 'Control Center layer did not map'
jq -e '.open == true and .expandedSection == ""' \
<<<"$(qs ipc call quicksettings status)" >/dev/null \
|| fail 'Control Center did not open in its resting state'
qs ipc call quicksettings section home >/dev/null
jq -e '.open == true and .expandedSection == "home"' \
<<<"$(qs ipc call quicksettings status)" >/dev/null \
|| fail 'Home section did not expand'
qs ipc call quicksettings section phone >/dev/null
jq -e '.open == true and .expandedSection == "phone"' \
<<<"$(qs ipc call quicksettings status)" >/dev/null \
|| fail 'Phone did not replace Home as the expanded section'
qs ipc call quicksettings section phone >/dev/null
jq -e '.open == true and .expandedSection == ""' \
<<<"$(qs ipc call quicksettings status)" >/dev/null \
|| fail 'expanded Phone section did not collapse'
trap - EXIT
cleanup
printf 'Control Center contract: PASS\n'
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
set -euo pipefail
fail() {
printf 'Control Center services contract: %s\n' "$1" >&2
exit 1
}
cleanup() {
qs ipc call kdeconnect reset >/dev/null 2>&1 || true
qs ipc call home-assistant reset >/dev/null 2>&1 || true
qs ipc call status-events reset >/dev/null 2>&1 || true
qs ipc call quicksettings close >/dev/null 2>&1 || true
}
trap cleanup EXIT
qs ipc show | rg -q '^target kdeconnect$' \
|| fail 'KDE Connect IPC target is missing'
qs ipc show | rg -q '^target home-assistant$' \
|| fail 'Home Assistant IPC target is missing'
qs ipc call kdeconnect fixture reachable >/dev/null
jq -e '
.fixture == true and
.available == true and
.reachable == true and
.actionCount == 4 and
.transferActive == false and
.ongoingCount == 0
' <<<"$(qs ipc call kdeconnect status)" >/dev/null \
|| fail 'reachable phone fixture is malformed'
qs ipc call kdeconnect fixture offline >/dev/null
jq -e '
.fixture == true and
.available == true and
.reachable == false and
.pairedCount == 1 and
.ongoingCount == 0
' <<<"$(qs ipc call kdeconnect status)" >/dev/null \
|| fail 'offline phone fixture is malformed'
qs ipc call kdeconnect fixture transfer >/dev/null
jq -e '
.fixture == true and
.transferActive == true and
.transferFileName == "Fixture document.pdf" and
.ongoingCount == 1
' <<<"$(qs ipc call kdeconnect status)" >/dev/null \
|| fail 'phone transfer did not enter Ongoing'
qs ipc call kdeconnect cancel >/dev/null
jq -e '.transferActive == false and .ongoingCount == 0' \
<<<"$(qs ipc call kdeconnect status)" >/dev/null \
|| fail 'phone transfer did not leave Ongoing'
qs ipc call home-assistant fixture ready >/dev/null
jq -e '
.fixture == true and
.phase == "ready" and
.configuredCount == 7 and
.visibleCount == 4 and
.stale == false and
.lastError == ""
' <<<"$(qs ipc call home-assistant status)" >/dev/null \
|| fail 'ready Home fixture is malformed'
qs ipc call home-assistant fixture stale >/dev/null
jq -e '
.fixture == true and
.phase == "degraded" and
.configuredCount == 7 and
.visibleCount == 4 and
.stale == true and
.lastError == "unreachable"
' <<<"$(qs ipc call home-assistant status)" >/dev/null \
|| fail 'stale Home fixture did not retain entities'
qs ipc call home-assistant fixture unavailable >/dev/null
jq -e '
.fixture == true and
.phase == "unavailable" and
.configuredCount == 0 and
.visibleCount == 0 and
.lastError == "not-configured"
' <<<"$(qs ipc call home-assistant status)" >/dev/null \
|| fail 'unavailable Home fixture is malformed'
trap - EXIT
cleanup
printf 'Control Center services contract: PASS\n'
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
fail() {
printf 'Home Assistant helper contract: %s\n' "$1" >&2
exit 1
}
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$project_root/config/dot/quickshell/scripts/panama-home-assistant"
[[ -x "$helper" ]] || fail 'helper is missing or not executable'
probe="$($helper probe)"
jq -e '
.configured == true and
.reachable == true and
(.entityCount | type == "number" and . > 0) and
.error == ""
' <<<"$probe" >/dev/null || fail 'live API probe failed'
snapshot="$($helper snapshot)"
jq -e --argjson expected "$(jq '.entityCount' <<<"$probe")" '
.ok == true and
.configured == true and
.error == "" and
(.generatedAt | type == "number") and
(.entities | type == "array" and length == $expected) and
([.entities[] | (keys | sort) == (["active", "available", "domain", "id", "name", "state"] | sort)] | all) and
([.entities[] | (.active | type == "boolean") and (.available | type == "boolean")] | all)
' <<<"$snapshot" >/dev/null || fail 'live snapshot shape is invalid'
printf 'Home Assistant helper contract: PASS (configured=true, %s favourites; contents redacted)\n' \
"$(jq '.entities | length' <<<"$snapshot")"
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
from __future__ import annotations
import importlib.machinery
import importlib.util
import json
import pathlib
import sys
import threading
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
ROOT = pathlib.Path(__file__).resolve().parents[2]
HELPER = ROOT / "config/dot/quickshell/scripts/panama-home-assistant"
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)
sys.modules[spec.name] = bridge
loader.exec_module(bridge)
class FakeHomeAssistant(BaseHTTPRequestHandler):
requests: list[dict[str, object]] = []
def log_message(self, _format: str, *args: object) -> None:
del args
def _record(self, body: bytes = b"") -> None:
self.requests.append(
{
"method": self.command,
"path": self.path,
"authorization": self.headers.get("Authorization"),
"body": body,
}
)
def _json(self, value: object, status: int = 200) -> None:
payload = json.dumps(value).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def do_GET(self) -> None:
self._record()
if self.path == "/api/":
self._json({"message": "API running."})
return
if self.path == "/api/states":
self._json(
[
{
"entity_id": "light.hall",
"state": "off",
"attributes": {"friendly_name": "Hall"},
},
{
"entity_id": "sensor.private",
"state": "1",
"attributes": {"friendly_name": "Private"},
},
{
"entity_id": "light.kitchen",
"state": "on",
"attributes": {"friendly_name": "Kitchen"},
},
]
)
return
self._json({"message": "not found"}, 404)
def do_POST(self) -> None:
length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length)
self._record(body)
if self.path == "/api/services/homeassistant/toggle":
self._json([])
return
self._json({"message": "not found"}, 404)
class HomeAssistantBridgeTest(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), FakeHomeAssistant)
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
cls.thread.start()
cls.base_url = f"http://127.0.0.1:{cls.server.server_port}"
@classmethod
def tearDownClass(cls) -> None:
cls.server.shutdown()
cls.server.server_close()
cls.thread.join(timeout=2)
def setUp(self) -> None:
FakeHomeAssistant.requests.clear()
def config(self) -> object:
return bridge.Config(
base_url=self.base_url,
token="fixture-token",
entity_ids=("light.kitchen", "light.hall"),
)
def test_environment_configuration_wins_over_legacy_sources(self) -> None:
env = {
"PANAMA_HOME_ASSISTANT_URL": "https://home.example",
"PANAMA_HOME_ASSISTANT_TOKEN": "fixture-token",
"PANAMA_HOME_ASSISTANT_ENTITIES": "light.kitchen,light.hall",
}
config = bridge.resolve_config(
env,
legacy=lambda: (_ for _ in ()).throw(AssertionError("legacy called")),
)
self.assertEqual(config.base_url, "https://home.example")
self.assertEqual(config.entity_ids, ("light.kitchen", "light.hall"))
def test_snapshot_filters_and_preserves_configured_order(self) -> None:
snapshot = bridge.collect_snapshot(self.config())
self.assertTrue(snapshot["ok"])
self.assertEqual(
[item["name"] for item in snapshot["entities"]],
["Kitchen", "Hall"],
)
self.assertNotIn("sensor.private", json.dumps(snapshot))
def test_snapshot_uses_bearer_authentication(self) -> None:
bridge.collect_snapshot(self.config())
request = FakeHomeAssistant.requests[-1]
self.assertEqual(request["method"], "GET")
self.assertEqual(request["path"], "/api/states")
self.assertEqual(request["authorization"], "Bearer fixture-token")
def test_toggle_rejects_an_unconfigured_entity(self) -> None:
with self.assertRaisesRegex(ValueError, "entity-not-configured"):
bridge.ensure_configured(
"light.office",
("light.kitchen",),
)
def test_toggle_calls_the_homeassistant_service(self) -> None:
result = bridge.toggle(self.config(), "light.kitchen")
self.assertTrue(result["ok"])
request = FakeHomeAssistant.requests[-1]
self.assertEqual(request["method"], "POST")
self.assertEqual(request["path"], "/api/services/homeassistant/toggle")
self.assertEqual(
json.loads(request["body"]),
{"entity_id": "light.kitchen"},
)
def test_authentication_error_is_redacted(self) -> None:
self.assertEqual(
bridge.public_http_error(401, "sensitive response"),
"authentication-required",
)
self.assertEqual(
bridge.public_http_error(500, "sensitive response"),
"request-failed",
)
def test_gsettings_array_parser_handles_empty_and_values(self) -> None:
self.assertEqual(bridge.parse_gsettings_array("@as []"), ())
self.assertEqual(
bridge.parse_gsettings_array("['light.kitchen', 'light.hall']"),
("light.kitchen", "light.hall"),
)
if __name__ == "__main__":
unittest.main()
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
fail() {
printf 'KDE Connect helper contract: %s\n' "$1" >&2
exit 1
}
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$project_root/config/dot/quickshell/scripts/panama-kdeconnect"
[[ -x "$helper" ]] || fail 'helper is missing or not executable'
status="$($helper status)"
jq -e '
.available == true and
(.devices | type == "array" and length >= 1) and
([.devices[] | (keys | sort) == (["actions", "id", "name", "paired", "reachable", "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) and
([.devices[] | select(.paired)] | length >= 1)
' <<<"$status" >/dev/null || fail 'live status shape is invalid'
paired_count="$(jq '[.devices[] | select(.paired)] | length' <<<"$status")"
reachable_count="$(jq '[.devices[] | select(.reachable)] | length' <<<"$status")"
printf 'KDE Connect helper contract: PASS (%s paired, %s reachable; identities redacted)\n' \
"$paired_count" \
"$reachable_count"
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
from __future__ import annotations
import importlib.machinery
import importlib.util
import json
import pathlib
import subprocess
import tempfile
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[2]
HELPER = ROOT / "config/dot/quickshell/scripts/panama-kdeconnect"
loader = importlib.machinery.SourceFileLoader("panama_kdeconnect", str(HELPER))
spec = importlib.util.spec_from_loader(loader.name, loader)
bridge = importlib.util.module_from_spec(spec)
loader.exec_module(bridge)
class KdeConnectBridgeTest(unittest.TestCase):
def test_status_omits_network_and_exposes_supported_ios_actions(self) -> None:
line = (
"- Fixture iPhone: BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB "
"on 192.0.2.7 via LAN (paired and reachable)"
)
plugins = [
"kdeconnect_clipboard",
"kdeconnect_findmyphone",
"kdeconnect_ping",
"kdeconnect_share",
"kdeconnect_shareinputdevices",
]
device = bridge.normalize_device_line(line, plugins)
self.assertEqual(device["name"], "Fixture iPhone")
self.assertEqual(device["type"], "phone")
self.assertTrue(device["paired"])
self.assertTrue(device["reachable"])
self.assertEqual(device["actions"], ["clipboard", "ping", "ring", "share"])
self.assertNotIn("192.0.2.7", json.dumps(device))
self.assertNotIn("LAN", json.dumps(device))
def test_status_parses_an_offline_paired_device(self) -> None:
line = "- Pocket: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA (paired)"
device = bridge.normalize_device_line(line, ["kdeconnect_share"])
self.assertTrue(device["paired"])
self.assertFalse(device["reachable"])
self.assertEqual(device["actions"], ["share"])
def test_invalid_device_id_is_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "invalid-device"):
bridge.validate_device_id("phone; shutdown")
def test_send_file_requires_a_regular_file(self) -> None:
with tempfile.TemporaryDirectory() as directory:
with self.assertRaisesRegex(ValueError, "invalid-file"):
bridge.validate_file(pathlib.Path(directory))
def test_send_file_resolves_a_regular_file(self) -> None:
with tempfile.TemporaryDirectory() as directory:
file_path = pathlib.Path(directory) / "hello world.txt"
file_path.write_text("fixture", encoding="utf-8")
self.assertEqual(bridge.validate_file(file_path), file_path.resolve())
def test_action_commands_use_separate_arguments(self) -> None:
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
self.assertEqual(
bridge.action_command("ring", device_id),
["kdeconnect-cli", "-d", device_id, "--ring"],
)
self.assertEqual(
bridge.action_command("clipboard", device_id),
["kdeconnect-cli", "-d", device_id, "--send-clipboard"],
)
def test_unknown_action_is_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "unsupported-action"):
bridge.action_command(
"unlock",
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
)
def test_busctl_plugin_output_is_normalized(self) -> None:
output = (
'as 5 "kdeconnect_ping" "kdeconnect_share" '
'"kdeconnect_clipboard" "kdeconnect_findmyphone" "unrelated"\n'
)
self.assertEqual(
bridge.parse_loaded_plugins(output),
[
"kdeconnect_ping",
"kdeconnect_share",
"kdeconnect_clipboard",
"kdeconnect_findmyphone",
"unrelated",
],
)
def test_offline_device_falls_back_to_supported_plugins(self) -> None:
def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
member = command[-1]
if member == "loadedPlugins":
return subprocess.CompletedProcess(command, 0, "as 0\n", "")
if member == "supportedPlugins":
return subprocess.CompletedProcess(
command,
0,
'as 3 "kdeconnect_share" "kdeconnect_clipboard" "kdeconnect_findmyphone"\n',
"",
)
raise AssertionError(command)
self.assertEqual(
bridge.device_plugins(
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
runner,
),
[
"kdeconnect_share",
"kdeconnect_clipboard",
"kdeconnect_findmyphone",
],
)
if __name__ == "__main__":
unittest.main()