# 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 {} // A typed property, not a bare child: Module is a QtObject with no default // property, so a bare child object fails to load. Same as VmModule. property Connections conn: 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's `Service.qml` or vm's `Virsh.qml`, 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` (network only; Bluetooth lines land in Task 6)** In the module list, add the one line for the module that exists at this commit: ```markdown modules/network/ wired and wifi, radio, scan, join, forget ``` 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, Mail, Appearance, Machines. ``` Then fix the three sentences this module makes stale, in the same file: - the line "Sound, mail and vm each carry their own README" becomes "Sound, mail, vm and network each carry their own README" - the line "Sound, mail and vm each add a service and a page on top of that same shape" becomes "Sound, mail, vm and network each add a service and a page on top of that same shape" - the line "Sound and mail are `alwaysActive: true`; vm is false." becomes "Sound, mail and network are `alwaysActive: true`; vm is false." - [ ] **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) * 100) + "%" 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`, `error`, `busy`, signal `finished(address, ok)`, and `pair(address)`. Progress is shown from `busy` plus `address`; there is no separate `status` property. - [ ] **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`, as a typed property (a bare `Connections` child does not load on a `QtObject`; same reason as `NetworkModule`): ```qml // Pairing runs through bluetoothctl; on success connect natively, on // failure notify, since the drawer may have closed by then. property Connections pairingConn: 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: Add the Bluetooth lines to `desktop/README.md`** Task 3 deliberately left these out so every commit stays self-consistent, since the module did not exist then. Now it does: - module list: add ` modules/bluetooth/ adapter, scan, pair, connect, forget, trust` - grid sentence: extend the order to `Sound, Network, Bluetooth, Mail, Appearance, Machines` - the README enumeration becomes "Sound, mail, vm, network and bluetooth each carry their own README" - the service enumeration becomes "Sound, mail, vm, network and bluetooth each add a service and a page on top of that same shape" - the alwaysActive line becomes "Sound, mail, network and bluetooth are `alwaysActive: true`; vm is false." - [ ] **Step 3: Commit** ```bash git add desktop/modules/bluetooth/README.md desktop/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." ```