From 967bbbae82d6a286df344e722d3db9d5e9b0a8c5 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 14 Sep 2026 13:43:40 +0200 Subject: docs: plan the network and bluetooth modules Eight tasks, each leaving the tree working: network wired page, wifi section, README, bluetooth module and page, pairing, README, then the waybar removal and the trap notes. The spec's failure section is corrected in the same commit: it claimed forget and disconnect failures would surface inline, but neither backend exposes a failure signal for them, so that is a ceiling rather than a feature. Verified by probe before writing any task: the singletons start empty for about two seconds, the device lists are ObjectModels, signalStrength is 0..1, scanner and discovering are writable, and NMSettings is not in QML scope, which drops the hidden-network join the spec had left conditional. --- .../plans/2026-09-14-network-bluetooth.md | 1343 ++++++++++++++++++++ .../specs/2026-09-14-network-bluetooth-design.md | 12 +- 2 files changed, 1350 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-14-network-bluetooth.md (limited to 'docs') diff --git a/docs/superpowers/plans/2026-09-14-network-bluetooth.md b/docs/superpowers/plans/2026-09-14-network-bluetooth.md new file mode 100644 index 0000000..33604c4 --- /dev/null +++ b/docs/superpowers/plans/2026-09-14-network-bluetooth.md @@ -0,0 +1,1343 @@ +# Network and Bluetooth Modules Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `network` module (wired + wifi) and a `bluetooth` module to the `desktop/` drawer, using the native Quickshell backends. + +**Architecture:** Two `Module` objects under `desktop/modules/`, each with a live tile and a full-height page, following the existing contract exactly. All state and actions bind to the `Quickshell.Networking` and `Quickshell.Bluetooth` singletons. The only non-native piece is pairing, which shells out to `bluetoothctl` in `Pairing.qml` because Quickshell ships no BlueZ agent. + +**Tech Stack:** Quickshell 0.3.1, Qt6 QML, NetworkManager D-Bus, BlueZ D-Bus, `bluetoothctl` for pairing only. + +**Spec:** `docs/superpowers/specs/2026-09-14-network-bluetooth-design.md` + +## Global Constraints + +- Quickshell 0.3.1, Qt6 QML. Run configs with `qs -p ./desktop`. The running process is `qs`: `pkill -x qs`, `pgrep -cx qs`, never `pkill -f` (it kills the calling shell). +- GPLv2 only. Every new `.qml` file begins with this exact header, no exceptions: + +```qml +// Copyright (C) 2026 Danilo M. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +``` + +- Module files live under `desktop/modules//` and reference root types (`Module`, `Page`, `Button`, `Theme`), so each uses `import "../.."`. +- Native backends only. `bluetoothctl` appears in `Pairing.qml` and nowhere else. +- Both modules `alwaysActive: true`. +- Grid order in `shell.qml`: `Sound, Network, Bluetooth, Mail, Appearance, Machines`. +- Hidden-network join is dropped: `typeof NMSettings === "undefined"` in QML scope, so no profile can be built from scratch. Do not add a code path for it. +- No em dashes anywhere. No home paths in committed files. Nerd Font glyphs are written as `\uXXXX` and their bytes verified with `git diff`. +- QML's JS engine has no `String.matchAll`; use `exec` loops if parsing is ever needed. +- Reusing a `Process` needs `running = false` immediately before `running = true`. + +## Verified API Facts (probed on this machine, 2026-09-14) + +Do not re-probe these; they are measured, not assumed. + +- Both singletons are empty/false for the first ~2s after launch, then populate. Bindings self-correct; never cache a first paint. +- `Networking.devices` and `Bluetooth.devices` are `ObjectModel`s. Iterate `.values`, which is a plain JS array. `Repeater { model: }` also works directly. +- `DeviceType.Wifi === 1`, `DeviceType.Wired === 2`. `ConnectionState.Connected === 2`, `ConnectionState.Disconnected === 4`. `WifiSecurityType.Open === 10`, `Opaque` = `Owe`. +- `signalStrength` is `0..1`, not a percentage. +- `WifiDevice.scannerEnabled` and `BluetoothAdapter.discovering` are writable and start a scan. +- Network methods present: `connect()`, `connectWithPsk(psk)`, `connectWithSettings()`, `disconnect()`, `forget()`, signal `connectionFailed`. Bluetooth methods present: `pair()`, `cancelPair()`, `connect()`, `disconnect()`, `forget()`. +- Live state used by the smoke checks: `eth0` wired connected, `wlan0` wifi disconnected, adapter on with four paired devices. +- Smoke-check command, harness owns the process and the log is read, never a later `pgrep`: + +```bash +timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: `clean`. This briefly starts a second drawer instance; it dies with the timeout. + +--- + +### Task 1: Network module, tile, and wired page + +**Files:** +- Create: `desktop/modules/network/NetworkModule.qml` +- Create: `desktop/modules/network/NetworkTile.qml` +- Create: `desktop/modules/network/NetworkPage.qml` +- Modify: `desktop/shell.qml` (imports and module registry) + +**Interfaces:** +- Consumes: the `Module`, `Page`, `Button`, `Theme` types, `Quickshell.Networking`. +- Produces: `NetworkModule` exposing `wiredDevices`, `wiredUp`, `wifiDevice`, `wifiUp`, `wifiNetworks`, `wifiNetworksSorted`, `wifiSsid`, `wiredName`, `wifiOn`, `error`, `connect(net)`, `connectWithPsk(net, psk)`, `disconnectNetwork(net)`, `forget(net)`, `notify(title, body)`. Task 2 uses all of these. The page type is `NetworkPage { net: ... }`; the tile type is `NetworkTile { net: ... }`. + +- [ ] **Step 1: Create `NetworkModule.qml`** + +```qml +// + +import Quickshell +import Quickshell.Networking +import Quickshell.Io +import QtQuick +import "../.." + +// Always active: the backends push and there is no poll to gate, so the +// state the tile shows is live from launch. Referencing Networking here is +// also what instantiates the singleton at shell start. +Module { + id: mod + + name: "network" + label: "Network" + alwaysActive: true + + // .values on the device ObjectModel; empty until the backend is ready, + // roughly two seconds in. Bindings repaint when it arrives. + readonly property var devices: Networking.devices ? Networking.devices.values : [] + readonly property var wiredDevices: devices.filter(d => d.type === DeviceType.Wired) + readonly property var wifiDevice: devices.find(d => d.type === DeviceType.Wifi) ?? null + + readonly property bool wiredUp: wiredDevices.some(d => d.connected) + readonly property bool wifiUp: wifiDevice ? wifiDevice.connected : false + readonly property bool wifiOn: Networking.wifiEnabled + readonly property var wifiNetworks: wifiDevice && wifiDevice.networks ? wifiDevice.networks.values : [] + + readonly property string wifiSsid: { + const n = wifiNetworks.find(x => x.connected); + return n ? n.name : ""; + } + readonly property string wiredName: { + const d = wiredDevices.find(x => x.connected); + return d ? d.name : ""; + } + + // Connected first, then known, then strongest. Kept here so both the + // tile and the page read the same order. + readonly property var wifiNetworksSorted: { + const arr = wifiNetworks.slice(); + arr.sort((a, b) => + ((b.connected ? 1 : 0) - (a.connected ? 1 : 0)) || + ((b.known ? 1 : 0) - (a.known ? 1 : 0)) || + ((b.signalStrength ?? 0) - (a.signalStrength ?? 0))); + return arr; + } + + // The icon follows the active link, live, with no contract change: the + // drawer reads this property like any other. + icon: mod.wiredUp ? "\uf796" : (mod.wifiUp ? "\uf1eb" : "\uf05e") + + // The network a connect is pending on, so connectionFailed can be caught. + property var pendingNetwork: null + property string error: "" + + function connect(net) { + mod.pendingNetwork = net; + mod.error = ""; + net.connect(); + } + + function connectWithPsk(net, psk) { + mod.pendingNetwork = net; + mod.error = ""; + net.connectWithPsk(psk); + } + + function disconnectNetwork(net) { if (net) net.disconnect(); } + function forget(net) { if (net) net.forget(); } + + // A failure after the drawer closed would otherwise go unseen, so it also + // raises a notification. Same cached-Process shape as VmModule. + function notify(title, body) { + notifyProc.command = ["notify-send", "--app-name=network", + "--urgency=critical", "--icon=error", title, body]; + notifyProc.running = false; + notifyProc.running = true; + } + + property Process notifyProc: Process {} + + Connections { + target: mod.pendingNetwork + function onConnectionFailed(reason) { + mod.error = "Connection failed: " + reason; + mod.notify("Network", mod.error); + mod.pendingNetwork = null; + } + } + + tileContent: Component { NetworkTile { net: mod } } + + page: Component { + Page { + title: "Network" + NetworkPage { width: parent.width; net: mod } + } + } +} +``` + +- [ ] **Step 2: Create `NetworkTile.qml`** + +```qml +// + +import QtQuick +import "../.." + +// The state line under the tile label. One line per active link: the API +// exposes no route metric, so when both wired and wifi are up the tile shows +// both rather than guessing which one carries traffic. +Column { + required property var net + + width: parent ? parent.width : implicitWidth + spacing: 2 + + Text { + width: parent.width + elide: Text.ElideRight + visible: net.wiredUp + text: net.wiredName + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.text + } + + Text { + width: parent.width + elide: Text.ElideRight + visible: net.wifiUp + text: net.wifiSsid + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.text + } + + Text { + width: parent.width + elide: Text.ElideRight + visible: !net.wiredUp && !net.wifiUp + text: net.wifiOn ? "Disconnected" : "Off" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.subtext + } +} +``` + +- [ ] **Step 3: Create `NetworkPage.qml` with the wired section only** + +```qml +// + +import Quickshell.Networking +import QtQuick +import "../.." + +// The wired half. Sibling of NetworkPage's wifi half, added in Task 2. +Column { + id: page + + required property var net + signal back + + spacing: 14 + + function stateLabel(s) { + return s === ConnectionState.Connected ? "connected" + : s === ConnectionState.Connecting ? "connecting" + : s === ConnectionState.Disconnecting ? "disconnecting" + : "disconnected"; + } + + Text { + visible: page.net.wiredDevices.length > 0 + text: "Wired" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.subtext + } + + Repeater { + model: page.net.wiredDevices + + Rectangle { + required property var modelData + + width: page.width + implicitHeight: wiredText.implicitHeight + 16 + radius: 8 + color: Qt.alpha(Theme.surface, 0.35) + + Text { + id: wiredText + anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter } + text: modelData.name + " " + page.stateLabel(modelData.state) + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: modelData.connected ? Theme.text : Theme.subtext + } + + Button { + anchors { right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter } + visible: modelData.connected + text: "Disconnect" + onClicked: page.net.disconnectNetwork(modelData) + } + } + } +} +``` + +- [ ] **Step 4: Register the module in `shell.qml`** + +Add the import beside the others (after `import "modules/mail"`): + +```qml +import "modules/network" +``` + +Add the module to the registry between Sound and Mail: + +```qml + modules: [ + SoundModule {}, + NetworkModule {}, + MailModule {}, + AppearanceModule {}, + VmModule {}, + ] +``` + +- [ ] **Step 5: Smoke-check the config loads** + +Run the smoke-check command from Global Constraints. Expected: `clean`. The clean marker is deliberate: grep exits 1 when it finds nothing, so do not read that as failure. + +- [ ] **Step 6: Ask the user to confirm visually** + +The running shell hot-reloads on save. Ask the user: the drawer shows a Network tile whose line reads `eth0` (or the SSID), and clicking it opens a Network page listing the wired device as connected with a Disconnect button. Confirm the icon renders as a glyph, not a box. + +- [ ] **Step 7: Commit** + +```bash +git add desktop/modules/network/NetworkModule.qml desktop/modules/network/NetworkTile.qml desktop/modules/network/NetworkPage.qml desktop/shell.qml +git commit -m "feat(desktop): network module, live tile and wired page + +Wired first because it is the simpler half and proves the module wiring. +Native Quickshell.Networking, no polling: the singletons push, and +alwaysActive is true with no Service.qml because there is nothing to gate. + +The tile shows one line per active link rather than picking a primary, +since the API exposes no route metric and guessing wrong would be worse +than showing both." +``` + +--- + +### Task 2: Network wifi section + +**Files:** +- Create: `desktop/modules/network/NetworkRow.qml` +- Modify: `desktop/modules/network/NetworkPage.qml` (replace with wired + wifi) + +**Interfaces:** +- Consumes: everything Task 1's `NetworkModule` produces. +- Produces: `NetworkRow { net: ..., entry: ... }`, a wifi list row that owns its own password-prompt state. + +- [ ] **Step 1: Create `NetworkRow.qml`** + +```qml +// + +import Quickshell.Networking +import QtQuick +import "../.." + +// One wifi network. A row click connects; a secured unknown network reveals +// an inline password field instead. The click area is declared first so the +// buttons and the field, declared later, sit above it. +Rectangle { + id: row + + required property var net + required property var entry + property bool prompting: false + property string psk: "" + + implicitHeight: col.implicitHeight + 16 + radius: 8 + color: entry.connected ? Qt.alpha(Theme.accent, 0.10) : Qt.alpha(Theme.surface, 0.35) + + MouseArea { + anchors.fill: parent + enabled: !row.prompting + cursorShape: Qt.PointingHandCursor + onClicked: row.activate() + } + + Column { + id: col + anchors { left: parent.left; right: parent.right; top: parent.top; margins: 10 } + spacing: 8 + + Item { + width: parent.width + implicitHeight: Math.max(nameText.implicitHeight, actions.implicitHeight) + + Text { + id: nameText + anchors { left: parent.left; right: actions.left; rightMargin: 8; verticalCenter: parent.verticalCenter } + elide: Text.ElideRight + text: row.entry.name + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: row.entry.connected } + color: row.entry.connected ? Theme.accent : Theme.text + } + + Row { + id: actions + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + spacing: 6 + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: row.secured() + text: "\uf023" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.overlay + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: Math.round((row.entry.signalStrength ?? 0) * 100) + "%" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.overlay + } + + Button { + visible: row.entry.connected + text: "Disconnect" + onClicked: row.net.disconnectNetwork(row.entry) + } + + Button { + visible: !row.entry.connected && row.entry.known + text: "Forget" + danger: true + onClicked: row.net.forget(row.entry) + } + } + } + + Row { + width: parent.width + spacing: 8 + visible: row.prompting + + TextInput { + id: pskField + width: parent.width - connectBtn.width - parent.spacing + echoMode: TextInput.Password + text: row.psk + onTextChanged: row.psk = text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.text + Component.onCompleted: forceActiveFocus() + Keys.onReturnPressed: row.submit() + Keys.onEscapePressed: { row.prompting = false; row.psk = ""; } + + Rectangle { + anchors.fill: parent + anchors.margins: -5 + z: -1 + radius: 6 + color: Qt.alpha(Theme.surface, 0.9) + border.width: 1 + border.color: Qt.alpha(Theme.text, 0.15) + } + } + + Button { + id: connectBtn + text: "Connect" + onClicked: row.submit() + } + } + } + + function secured() { + return entry.security !== WifiSecurityType.Open && entry.security !== WifiSecurityType.Owe; + } + + function activate() { + if (entry.connected) { net.disconnectNetwork(entry); return; } + if (entry.known || !secured()) { net.connect(entry); return; } + row.prompting = true; + pskField.forceActiveFocus(); + } + + function submit() { + if (row.psk === "") return; + net.connectWithPsk(entry, row.psk); + row.psk = ""; + row.prompting = false; + } +} +``` + +- [ ] **Step 2: Replace `NetworkPage.qml` with wired + wifi** + +```qml +// + +import Quickshell.Networking +import QtQuick +import QtQuick.Controls +import "../.." + +Column { + id: page + + required property var net + signal back + + spacing: 14 + + function stateLabel(s) { + return s === ConnectionState.Connected ? "connected" + : s === ConnectionState.Connecting ? "connecting" + : s === ConnectionState.Disconnecting ? "disconnecting" + : "disconnected"; + } + + // --- Wired --- + + Text { + visible: page.net.wiredDevices.length > 0 + text: "Wired" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.subtext + } + + Repeater { + model: page.net.wiredDevices + + Rectangle { + required property var modelData + + width: page.width + implicitHeight: wiredText.implicitHeight + 16 + radius: 8 + color: Qt.alpha(Theme.surface, 0.35) + + Text { + id: wiredText + anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter } + text: modelData.name + " " + page.stateLabel(modelData.state) + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: modelData.connected ? Theme.text : Theme.subtext + } + + Button { + anchors { right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter } + visible: modelData.connected + text: "Disconnect" + onClicked: page.net.disconnectNetwork(modelData) + } + } + } + + Rectangle { width: page.width; height: 1; color: Qt.alpha(Theme.text, 0.12) } + + // --- Wifi --- + + Item { + width: page.width + implicitHeight: Math.max(wifiLabel.implicitHeight, wifiSwitch.implicitHeight) + + Text { + id: wifiLabel + anchors.verticalCenter: parent.verticalCenter + text: "Wi-Fi" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true } + color: Theme.text + } + + Switch { + id: wifiSwitch + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + enabled: Networking.wifiHardwareEnabled + checked: page.net.wifiOn + onToggled: Networking.wifiEnabled = checked + } + } + + Text { + width: page.width + visible: !page.net.wifiOn + text: "Wi-Fi off" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.subtext + } + + Row { + width: page.width + visible: page.net.wifiOn + spacing: 8 + + Button { + text: page.net.wifiDevice && page.net.wifiDevice.scannerEnabled ? "Scanning…" : "Scan" + onClicked: if (page.net.wifiDevice) page.net.wifiDevice.scannerEnabled = true + } + } + + Repeater { + model: page.net.wifiOn ? page.net.wifiNetworksSorted : [] + + NetworkRow { + required property var modelData + net: page.net + entry: modelData + width: page.width + } + } + + Text { + width: page.width + visible: page.net.error !== "" + wrapMode: Text.Wrap + text: page.net.error + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.red + } +} +``` + +- [ ] **Step 3: Smoke-check the config loads** + +Run the Global Constraints smoke-check. Expected: `clean`. The clean marker is deliberate: grep exits 1 when it finds nothing, so do not read that as failure. + +- [ ] **Step 4: Ask the user to confirm visually** + +Ask the user: open the Network page, toggle Wi-Fi off and on, press Scan and watch the list grow. Click a known network to connect and a secured unknown one to see the password field. Confirm a wrong password shows the red error line and a right one connects. Confirm the tile shows the SSID after connecting. + +- [ ] **Step 5: Commit** + +```bash +git add desktop/modules/network/NetworkRow.qml desktop/modules/network/NetworkPage.qml +git commit -m "feat(desktop): network wifi page + +Radio switch, scan, and a list ordered connected, known, strongest. A +secured unknown network reveals an inline password field rather than +opening a popup, since a QtQuick.Controls popup is a separate window that +positions badly on this layer surface, the same reason the sound page +expands in place. + +Connection failures come through the Network.connectionFailed signal and +show inline; the same failure also notifies, for the case where the +drawer has closed by the time it lands." +``` + +--- + +### Task 3: Network README and desktop README + +**Files:** +- Create: `desktop/modules/network/README.md` +- Modify: `desktop/README.md` (module list and grid order) + +**Interfaces:** +- Consumes: nothing. Pure documentation. +- Produces: nothing executable. + +- [ ] **Step 1: Create `desktop/modules/network/README.md`** + +```markdown +# network + +Wired and wifi in one module, because a connected `eth0` must not mask the +wifi state. + +The tile shows one line per active link. When both wired and wifi are up it +shows both: the API exposes no route metric, so it does not guess which link +carries traffic. + +The page lists managed wired devices, then a wifi radio switch and list. A +row click connects; a secured network that is not yet known reveals an inline +password field. Known networks offer Forget, the connected one Disconnect. + +## Service lifetime + +`alwaysActive: true`, and truthfully: the `Quickshell.Networking` backend +pushes and there is no poll to gate. There is no `Service.qml`, unlike sound +and vm, because there is no loop to own. Referencing `Networking` in +`NetworkModule.qml` is what instantiates it at shell start. + +The backend is empty for about two seconds after launch and then fills. Every +binding repaints when it arrives; nothing caches the first paint. + +## Not built + +Joining a hidden network. It would need a NetworkManager settings profile +built from scratch, and `NMSettings` is not in QML scope (`typeof` is +`undefined`), so there is no way to construct one from QML. Enterprise and +802.1x networks are out for the same class of reason. + +Disconnect and forget failures. Neither backend exposes a failure signal for +them, so a failure shows only as the list not changing. Noted rather than +faked. +``` + +- [ ] **Step 2: Update `desktop/README.md`** + +In the module list, add the two lines: + +```markdown + modules/network/ wired and wifi, radio, scan, join, forget + modules/bluetooth/ adapter, scan, pair, connect, forget, trust +``` + +Change the geometry paragraph's grid sentence from "three columns at 180px minimum, wrapping and adding rows up to a 3x3 ceiling for the modules that exist" to name the current order: + +```markdown +The bottom is a fixed, never-scrolled `Flow` grid: three columns at 180px +minimum, wrapping and adding rows up to a 3x3 ceiling for the modules that +exist, in the order Sound, Network, Bluetooth, Mail, Appearance, Machines. +``` + +- [ ] **Step 3: Commit** + +```bash +git add desktop/modules/network/README.md desktop/README.md +git commit -m "docs(desktop): document the network module" +``` + +--- + +### Task 4: Bluetooth module, tile, and page + +**Files:** +- Create: `desktop/modules/bluetooth/BluetoothModule.qml` +- Create: `desktop/modules/bluetooth/BluetoothTile.qml` +- Create: `desktop/modules/bluetooth/BluetoothRow.qml` +- Create: `desktop/modules/bluetooth/BluetoothPage.qml` +- Modify: `desktop/shell.qml` (imports and module registry) + +**Interfaces:** +- Consumes: `Module`, `Page`, `Button`, `Theme`, `Quickshell.Bluetooth`. +- Produces: `BluetoothModule` exposing `adapter`, `devices`, `connectedDevices`, `pairedDevices`, `availableDevices`, `anyConnected`, `notify(title, body)`. Task 5 adds `pairing` and the available-devices section. Tile type `BluetoothTile { bt: ... }`, row type `BluetoothRow { bt: ..., device: ... }`. + +- [ ] **Step 1: Create `BluetoothModule.qml`** + +```qml +// + +import Quickshell +import Quickshell.Bluetooth +import Quickshell.Io +import QtQuick +import "../.." + +// Always active: the BlueZ backend pushes and there is no poll to gate. +// Referencing Bluetooth here instantiates it at shell start. +Module { + id: mod + + name: "bluetooth" + label: "Bluetooth" + alwaysActive: true + + readonly property var adapter: Bluetooth.defaultAdapter + readonly property var devices: Bluetooth.devices ? Bluetooth.devices.values : [] + readonly property var connectedDevices: devices.filter(d => d.connected) + readonly property var pairedDevices: devices.filter(d => d.paired && !d.connected) + readonly property var availableDevices: devices.filter(d => !d.paired) + + readonly property bool anyConnected: connectedDevices.length > 0 + + // Null for the first ~2s, then filled. Safe navigation everywhere. + icon: mod.adapter && mod.adapter.enabled ? "\uf293" : "\uf05e" + + function notify(title, body) { + notifyProc.command = ["notify-send", "--app-name=bluetooth", + "--urgency=critical", "--icon=error", title, body]; + notifyProc.running = false; + notifyProc.running = true; + } + + property Process notifyProc: Process {} + + tileContent: Component { BluetoothTile { bt: mod } } + + page: Component { + Page { + title: "Bluetooth" + BluetoothPage { width: parent.width; bt: mod } + } + } +} +``` + +- [ ] **Step 2: Create `BluetoothTile.qml`** + +```qml +// + +import QtQuick +import "../.." + +Text { + required property var bt + + width: parent ? parent.width : implicitWidth + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: bt.anyConnected ? Theme.text : Theme.subtext + text: !bt.adapter || !bt.adapter.enabled ? "Off" + : bt.connectedDevices.length === 0 ? "No devices" + : bt.connectedDevices.length === 1 ? bt.connectedDevices[0].name + : bt.connectedDevices.length + " connected" +} +``` + +- [ ] **Step 3: Create `BluetoothRow.qml` (paired and connected actions only; Pair is Task 5)** + +```qml +// + +import QtQuick +import "../.." + +// One tracked device. The actions shown depend on its state: connected, +// paired-but-idle, or found and not yet paired. +Rectangle { + id: row + + required property var bt + required property var device + + implicitHeight: col.implicitHeight + 16 + radius: 8 + color: device.connected ? Qt.alpha(Theme.accent, 0.10) : Qt.alpha(Theme.surface, 0.35) + + Column { + id: col + anchors { left: parent.left; right: parent.right; top: parent.top; margins: 10 } + spacing: 8 + + Item { + width: parent.width + implicitHeight: Math.max(nameText.implicitHeight, actions.implicitHeight) + + Text { + id: nameText + anchors { left: parent.left; right: actions.left; rightMargin: 8; verticalCenter: parent.verticalCenter } + elide: Text.ElideRight + text: row.device.name || row.device.deviceName || row.device.address + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: row.device.connected } + color: row.device.connected ? Theme.accent : Theme.text + } + + Row { + id: actions + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + spacing: 6 + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: row.device.batteryAvailable ?? false + text: Math.round(row.device.battery ?? 0) + "%" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.overlay + } + + Button { + visible: !row.device.connected && row.device.paired + text: "Connect" + onClicked: row.device.connect() + } + + Button { + visible: row.device.connected + text: "Disconnect" + onClicked: row.device.disconnect() + } + + Button { + visible: row.device.paired + text: row.device.trusted ? "Untrust" : "Trust" + onClicked: row.device.trusted = !row.device.trusted + } + + Button { + visible: row.device.paired + text: "Forget" + danger: true + onClicked: row.device.forget() + } + } + } + } +} +``` + +- [ ] **Step 4: Create `BluetoothPage.qml` (adapter, connected, paired; available is Task 5)** + +```qml +// + +import QtQuick +import QtQuick.Controls +import "../.." + +Column { + id: page + + required property var bt + signal back + + spacing: 14 + + // --- Adapter --- + + Item { + width: page.width + implicitHeight: Math.max(btLabel.implicitHeight, powerSwitch.implicitHeight) + + Text { + id: btLabel + anchors.verticalCenter: parent.verticalCenter + text: "Bluetooth" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true } + color: Theme.text + } + + Switch { + id: powerSwitch + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + checked: page.bt.adapter ? page.bt.adapter.enabled : false + onToggled: if (page.bt.adapter) page.bt.adapter.enabled = checked + } + } + + Text { + width: page.width + visible: !(page.bt.adapter && page.bt.adapter.enabled) + text: "Bluetooth off" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.subtext + } + + Item { + width: page.width + visible: page.bt.adapter && page.bt.adapter.enabled + implicitHeight: Math.max(visLabel.implicitHeight, visSwitch.implicitHeight) + + Text { + id: visLabel + anchors.verticalCenter: parent.verticalCenter + text: "Visible" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.text + } + + Switch { + id: visSwitch + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + checked: page.bt.adapter ? page.bt.adapter.discoverable : false + onToggled: if (page.bt.adapter) page.bt.adapter.discoverable = checked + } + } + + Row { + width: page.width + visible: page.bt.adapter && page.bt.adapter.enabled + spacing: 8 + + Button { + text: page.bt.adapter && page.bt.adapter.discovering ? "Stop scan" : "Scan" + onClicked: if (page.bt.adapter) page.bt.adapter.discovering = !page.bt.adapter.discovering + } + } + + // --- Connected --- + + Text { + width: page.width + visible: page.bt.connectedDevices.length > 0 + text: "Connected" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.subtext + } + + Repeater { + model: page.bt.connectedDevices + + BluetoothRow { + required property var modelData + bt: page.bt + device: modelData + width: page.width + } + } + + // --- Paired --- + + Text { + width: page.width + visible: page.bt.pairedDevices.length > 0 + text: "Paired" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.subtext + } + + Repeater { + model: page.bt.pairedDevices + + BluetoothRow { + required property var modelData + bt: page.bt + device: modelData + width: page.width + } + } +} +``` + +- [ ] **Step 5: Register the module in `shell.qml`** + +Add `import "modules/bluetooth"` beside the network import, and add `BluetoothModule {}` after `NetworkModule {}`. Final registry: + +```qml + modules: [ + SoundModule {}, + NetworkModule {}, + BluetoothModule {}, + MailModule {}, + AppearanceModule {}, + VmModule {}, + ] +``` + +- [ ] **Step 6: Smoke-check the config loads** + +Run the Global Constraints smoke-check. Expected: `clean`. The clean marker is deliberate: grep exits 1 when it finds nothing, so do not read that as failure. + +- [ ] **Step 7: Ask the user to confirm visually** + +Ask the user: the Bluetooth tile shows `Off` or a device name, its icon is a glyph. The page toggles the adapter, shows the four paired devices with Connect/Trust/Forget, and the Scan button starts discovery. Confirm trust toggling round-trips. + +- [ ] **Step 8: Commit** + +```bash +git add desktop/modules/bluetooth/BluetoothModule.qml desktop/modules/bluetooth/BluetoothTile.qml desktop/modules/bluetooth/BluetoothRow.qml desktop/modules/bluetooth/BluetoothPage.qml desktop/shell.qml +git commit -m "feat(desktop): bluetooth module, tile and page + +Native Quickshell.Bluetooth for adapter power, discoverable, scan, +connect, disconnect, trust and forget. Pairing is separate, because +quickshell ships no BlueZ agent. + +The adapter is null for the first ~2s, so every access is navigated +safely rather than assuming a first paint." +``` + +--- + +### Task 5: Bluetooth pairing + +**Files:** +- Create: `desktop/modules/bluetooth/Pairing.qml` +- Modify: `desktop/modules/bluetooth/BluetoothModule.qml` (add `pairing`, handle completion) +- Modify: `desktop/modules/bluetooth/BluetoothPage.qml` (add the Available section and the pair status line) + +**Interfaces:** +- Consumes: `BluetoothModule.devices`, `BluetoothModule.notify`. +- Produces: `Pairing` with `address`, `status`, `error`, `busy`, signal `finished(address, ok)`, and `pair(address)`. + +- [ ] **Step 1: Create `Pairing.qml`** + +```qml +// + +import Quickshell.Io +import QtQuick + +// The one action that is not native. Quickshell ships no BlueZ agent and no +// generic D-Bus module, so a device that needs a passkey or PIN confirmed +// cannot be paired from QML. bluetoothctl registers its own agent and handles +// the prompt, so pairing goes through it and everything else stays native. +// +// ponytail: one-shot bluetoothctl with a timeout, keyed on the exit code. If +// a device needs input bluetoothctl cannot auto-confirm, pairing times out +// and the error surfaces; upgrade to driving interactive bluetoothctl only +// when a real device needs it. +QtObject { + id: root + + property string address: "" + property string error: "" + readonly property bool busy: proc.running + + signal finished(string address, bool ok) + + function pair(address) { + root.address = address; + root.error = ""; + proc.command = ["bluetoothctl", "--timeout", "20", "pair", address]; + proc.running = false; + proc.running = true; + } + + property Process proc: Process { + stdout: StdioCollector { id: out } + stderr: StdioCollector { id: err } + onExited: code => { + const ok = code === 0; + if (!ok) root.error = (err.text.trim() || out.text.trim() || ("bluetoothctl exited " + code)); + root.finished(root.address, ok); + } + } +} +``` + +- [ ] **Step 2: Add `pairing` and completion handling to `BluetoothModule.qml`** + +Add the property beside `adapter`: + +```qml + readonly property Pairing pairing: Pairing {} +``` + +Add the completion handler beside `notifyProc`: + +```qml + // Pairing runs through bluetoothctl; on success connect natively, on + // failure notify, since the drawer may have closed by then. + Connections { + target: mod.pairing + function onFinished(address, ok) { + if (!ok) { + mod.notify("Bluetooth", "Pairing failed: " + mod.pairing.error); + return; + } + const d = mod.devices.find(x => x.address === address); + if (d) d.connect(); + } + } +``` + +- [ ] **Step 3: Add the Available section to `BluetoothPage.qml`** + +Append after the Paired repeater, before the closing brace of the Column: + +```qml + // --- Available (found while scanning, not yet paired) --- + + Text { + width: page.width + visible: page.bt.availableDevices.length > 0 + text: "Available" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.subtext + } + + Repeater { + model: page.bt.availableDevices + + Rectangle { + required property var modelData + + width: page.width + implicitHeight: availText.implicitHeight + 16 + radius: 8 + color: Qt.alpha(Theme.surface, 0.35) + + Text { + id: availText + anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter } + width: parent.width - pairBtn.width - 28 + elide: Text.ElideRight + text: page.bt.pairing.busy && page.bt.pairing.address === modelData.address + ? "Pairing…" + : (modelData.name || modelData.deviceName || modelData.address) + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.text + } + + Button { + id: pairBtn + anchors { right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter } + text: "Pair" + enabled: !page.bt.pairing.busy + onClicked: page.bt.pairing.pair(modelData.address) + } + } + } + + Text { + width: page.width + visible: page.bt.pairing.error !== "" + wrapMode: Text.Wrap + text: page.bt.pairing.error + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.red + } +``` + +- [ ] **Step 4: Smoke-check the config loads** + +Run the Global Constraints smoke-check. Expected: `clean`. The clean marker is deliberate: grep exits 1 when it finds nothing, so do not read that as failure. + +- [ ] **Step 5: Ask the user to confirm visually** + +Ask the user: put a Bluetooth device in pairing mode, press Scan, and Pair on it. Confirm pairing completes and the device moves to Connected. If a device needs a passkey, confirm the failure surfaces rather than hanging past the 20s timeout. + +- [ ] **Step 6: Commit** + +```bash +git add desktop/modules/bluetooth/Pairing.qml desktop/modules/bluetooth/BluetoothModule.qml desktop/modules/bluetooth/BluetoothPage.qml +git commit -m "feat(desktop): pair bluetooth devices + +Quickshell has no BlueZ agent and no generic D-Bus module, so a pairing +that needs a passkey confirmed cannot be done from QML. bluetoothctl +registers its own agent, so pairing is the one shell-out; adapter state, +scanning, connecting and forgetting all stay native. + +The timeout is the ceiling: a device bluetoothctl cannot auto-confirm +fails visibly rather than hanging the page." +``` + +--- + +### Task 6: Bluetooth README + +**Files:** +- Create: `desktop/modules/bluetooth/README.md` + +**Interfaces:** +- Consumes: nothing. Documentation only. + +- [ ] **Step 1: Create the README** + +```markdown +# bluetooth + +The adapter, its devices, and pairing, in one module. + +The tile names the connected device, or a count when several, `No devices` +when the adapter is on and idle, `Off` when it is down. + +The page carries the adapter power switch, a visible toggle, a scan toggle, +then the connected devices, the paired ones, and anything found while +scanning. A paired device offers Connect, Trust and Forget; an unpaired one +offers Pair. + +## Pairing is the one shell-out + +Quickshell ships no BlueZ pairing agent and no generic D-Bus module, so a +device that requires a passkey or PIN confirmation has no way to prompt from +QML. `Pairing.qml` runs `bluetoothctl --timeout 20 pair
`, whose own +agent handles the prompt. + +Everything else is native `Quickshell.Bluetooth`. The cost is that +`bluetoothctl` is the ceiling: a device it cannot auto-confirm times out and +the error shows, rather than pairing. `Pairing.qml` carries a `ponytail:` +comment naming that and the upgrade path. + +## Not built + +Connecting and forgetting report no failure signal in the BlueZ binding; only +pairing does, through the `bluetoothctl` exit code. A connect that fails +shows only as the device staying unconnected. + +## Service lifetime + +`alwaysActive: true`, and truthfully: the BlueZ backend pushes, there is no +poll to gate, and there is no `Service.qml`. Referencing `Bluetooth` in +`BluetoothModule.qml` instantiates it at shell start. + +The adapter is null and the device list empty for about two seconds after +launch. Every access navigates safely; nothing caches the first paint. +``` + +- [ ] **Step 2: Commit** + +```bash +git add desktop/modules/bluetooth/README.md +git commit -m "docs(desktop): document the bluetooth module" +``` + +--- + +### Task 7: Remove the waybar indicators, record the traps, final check + +**Files:** +- Modify: `desktop/README.md` (add a line that waybar's indicators were removed) +- Modify: `AGENTS.md` (add the new traps under Per-component notes) +- Modify outside the repo: the live waybar configuration (user applies) + +**Interfaces:** +- Consumes: the finished modules. +- Produces: nothing executable in the repo. + +- [ ] **Step 1: Ask the user to remove the waybar indicators** + +The live waybar config is not in this repo. Ask the user to remove waybar's own network and Bluetooth modules, since the drawer replaces them, and to reload waybar. Record in the task report which modules were removed and from which file, so the change is traceable. + +- [ ] **Step 2: Add the removal note to `desktop/README.md`** + +Append to the "Hyprland and waybar" section: + +```markdown +The drawer is the only place network and Bluetooth are managed, so waybar's +own network and Bluetooth indicators were removed in the same change. +``` + +- [ ] **Step 3: Add the traps to `AGENTS.md`** + +Append these bullets to the per-component notes list: + +```markdown +- **`Quickshell.Networking` and `Quickshell.Bluetooth` start empty.** Devices, + the adapter and the wifi radio all read empty or false for roughly two + seconds after launch, then populate. Bindings repaint when they arrive; + nothing may cache the first paint. Both device lists are `ObjectModel`s, so + iterate `.values`, which is a plain array. +- **`NMSettings` is not in QML scope.** `typeof NMSettings` is `undefined`, so + a NetworkManager profile cannot be built from scratch in QML. This is why + the network module cannot join a hidden network, and the same class of + limit blocks enterprise 802.1x. +- **Quickshell has no BlueZ pairing agent and no generic D-Bus module.** A + device needing a passkey or PIN confirmed cannot be paired from QML; + `bluetoothctl` registers its own agent, so pairing is the one shell-out in + the bluetooth module. +- **`WifiNetwork.signalStrength` is `0..1`, not a percentage.** The known + networks a scan has not refreshed report a cached `1`. +``` + +- [ ] **Step 4: Full smoke check and process count** + +```bash +timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' ; echo "rc=$?" +pgrep -cx qs +``` + +Expected: `clean`; `pgrep` reports the shells the user has running (three on a normal session, plus the transient test instance only during the timeout window, so run the count after the smoke check returns). Never conclude anything from a `pgrep` issued in a later tool call against a detached process. + +- [ ] **Step 5: Confirm the mail oracle still passes** + +```bash +bash desktop/modules/mail/test-mail-notify.sh +``` + +Expected: all tests pass, unchanged from before this work. + +- [ ] **Step 6: Ask the user for the final visual pass** + +Ask the user to confirm: six tiles in order (Sound, Network, Bluetooth, Mail, Appearance, Machines); the network page handles wifi off, scan, join with a wrong then right password; both wired and wifi up makes the network tile show two lines; the bluetooth page powers on, scans, pairs, connects, trusts and forgets; failed actions notify when the drawer is closed. + +- [ ] **Step 7: Commit** + +```bash +git add desktop/README.md AGENTS.md +git commit -m "docs: record the network and bluetooth traps + +The native backends start empty for about two seconds, device lists are +ObjectModels, NMSettings is not in QML scope, and quickshell has no BlueZ +agent. Each one cost a probe to learn; writing them down is cheaper than +re-learning them." +``` diff --git a/docs/superpowers/specs/2026-09-14-network-bluetooth-design.md b/docs/superpowers/specs/2026-09-14-network-bluetooth-design.md index 53c79ac..30d8c96 100644 --- a/docs/superpowers/specs/2026-09-14-network-bluetooth-design.md +++ b/docs/superpowers/specs/2026-09-14-network-bluetooth-design.md @@ -119,11 +119,13 @@ when the adapter is down. `active` while anything is connected. ## Failures -An inline status line on the page beside the affected row: a rejected PSK -arrives as `connectionFailed`, and forgetting or pairing can fail too. A -failure that lands after the drawer has closed also fires `notify-send`, -using the cached-`Process` pattern already in `VmModule.qml`. The error clears -on the next action. +An inline status line on the page beside the affected row. A rejected PSK +arrives as `connectionFailed`; a pairing failure is the `bluetoothctl` exit, +surfaced by `Pairing.qml`. Disconnect and forget expose no failure signal in +either backend, so a failure there shows only as the list not changing. That +is a ceiling, noted rather than faked. A failure that lands after the drawer +has closed also fires `notify-send`, using the cached-`Process` pattern +already in `VmModule.qml`. The error clears on the next action. ## Files outside this repo -- cgit v1.2.3