Merge Home & Phone into a three-tab Home that knows your house

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
This commit is contained in:
Gabriel Brown
2026-08-23 22:06:18 -04:00
parent 5490fd285d
commit 7578348db1
40 changed files with 2578 additions and 703 deletions
@@ -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.
@@ -301,6 +301,6 @@ Item {
function openHomeSettings(): void {
ShellState.close();
ShellState.openSettings("home-phone");
ShellState.openSettings("my-home");
}
}
@@ -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
}
}
@@ -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);
}
}
}
}
@@ -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
}
@@ -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")
}
}
}
}
}
@@ -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")
}
}
}
@@ -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
@@ -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 {} }
@@ -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
@@ -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
@@ -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.
+1 -1
View File
@@ -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.")
@@ -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])
+175 -20
View File
@@ -27,6 +27,18 @@ DEVICE_OBJECT_PREFIX = "/modules/kdeconnect/devices"
DEVICE_OBJECT_LINE = re.compile(
rf"(?P<path>{re.escape(DEVICE_OBJECT_PREFIX)}/(?P<id>[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
@@ -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.
"""
+1 -1
View File
@@ -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 {
@@ -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 {
+46 -3
View File
@@ -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" : "";
@@ -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)
@@ -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" },
+1 -1
View File
@@ -489,7 +489,7 @@ ShellRoot {
page: ShellState.settingsPage,
discoveredCount: HomeAssistant.discoveredCount,
selectedCount: HomeAssistant.configuredCount,
homePhone: settingsWindow.homePhoneDiagnostics
myHome: settingsWindow.myHomeDiagnostics
});
}
}
@@ -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
+10
View File
@@ -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