Handle Wi-Fi and Bluetooth in Settings
Network & Devices was 92 lines and two buttons that opened GNOME. It now scans, joins, and pairs directly through Quickshell.Networking and Quickshell.Bluetooth -- NetworkManager and BlueZ over DBus, no shelling out to nmcli or bluetoothctl. That was the founding requirement for this desktop: never having to drop to a terminal to join a network. Scanning follows the page being visible. Wi-Fi scanning and especially Bluetooth discovery hold the radio, and running either for a list nobody is looking at spends airtime on nothing. Joining a secured network gets a real password field, not the clipboard popover's search box with different placeholder text: a Wi-Fi key typed into a field that echoes it is readable by anyone behind you, and a search glyph in front of a password prompt is simply wrong. Two bugs found by looking at the rendered page, both silent: The device lookups used enum names that do not exist -- NetworkDeviceType.Wifi rather than DeviceType.Wifi -- so both returned null and the page reported "No Wi-Fi adapter" on a machine whose Wi-Fi was connected. Nothing was logged; QML resolves an unknown enum member to undefined and compares happily. signalStrength is 0.0-1.0, not a percentage, so thresholds written for 0-100 put every network including the connected one in the bottom bucket. The labels now use the same buckets as the icons in quicksettings/WifiList.qml so the two cannot disagree. The contract compares what the service resolves against what nmcli reports, rather than only checking that nothing crashed. Also makes the Home Assistant bridge hermetic: resolve_config read the user's private env file even when a caller supplied an explicit environment, so adding a real PANAMA_HOME_ASSISTANT_ENTITIES to that file silently overrode a fixture asserting the legacy fallback. An explicit environment is now the whole environment; production still reads the file. Its live contract skips when no token is configured -- an absent credential is not a defect, and a suite expected to be red stops being read -- while a configured-but-broken bridge still fails. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
// Bluetooth, at page size.
|
||||
//
|
||||
// Paired devices first, because reconnecting to something you already own is
|
||||
// what you are here for nine times out of ten; discovered devices follow.
|
||||
// Battery is shown where BlueZ reports it, which is the one thing people
|
||||
// routinely open a terminal for.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Bluetooth
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
spacing: 0
|
||||
|
||||
function primaryAction(device: var): void {
|
||||
if (device.connected) {
|
||||
device.disconnect();
|
||||
return;
|
||||
}
|
||||
if (device.paired) {
|
||||
device.connect();
|
||||
return;
|
||||
}
|
||||
device.pair();
|
||||
}
|
||||
|
||||
function stateLabel(device: var): string {
|
||||
if (device.pairing)
|
||||
return "Pairing…";
|
||||
if (device.connected)
|
||||
return device.batteryAvailable
|
||||
? `Connected · ${Math.round(device.battery * 100)}% battery`
|
||||
: "Connected";
|
||||
if (device.paired)
|
||||
return "Paired";
|
||||
return device.address || "Not paired";
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Connectivity.bluetoothDevices
|
||||
|
||||
SettingRow {
|
||||
id: entry
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: parent.width
|
||||
label: entry.modelData.name || entry.modelData.address || "Unknown device"
|
||||
detail: root.stateLabel(entry.modelData)
|
||||
divider: entry.index < Connectivity.bluetoothDevices.length - 1
|
||||
controlWidth: 200
|
||||
activatable: !entry.modelData.pairing
|
||||
onActivated: root.primaryAction(entry.modelData)
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 7
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
enabled: !entry.modelData.pairing
|
||||
text: entry.modelData.connected
|
||||
? "Disconnect"
|
||||
: (entry.modelData.paired ? "Connect" : "Pair")
|
||||
onClicked: root.primaryAction(entry.modelData)
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: entry.modelData.paired
|
||||
text: "Forget"
|
||||
onClicked: entry.modelData.forget()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: Connectivity.bluetoothDevices.length === 0
|
||||
label: !Connectivity.adapter
|
||||
? "No Bluetooth adapter"
|
||||
: (Connectivity.adapter.enabled ? "Looking for devices…" : "Bluetooth is off")
|
||||
detail: Connectivity.adapter && !Connectivity.adapter.enabled
|
||||
? "Turn it on above to discover devices"
|
||||
: "Put the device into pairing mode to make it appear"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
@@ -1,92 +1,124 @@
|
||||
// Network & Devices.
|
||||
//
|
||||
// Wi-Fi and Bluetooth are handled here rather than delegated. Everything goes
|
||||
// through Quickshell.Networking and Quickshell.Bluetooth -- NetworkManager and
|
||||
// BlueZ over DBus -- and nothing shells out to nmcli or bluetoothctl. That was
|
||||
// the founding requirement for this desktop: never having to drop to a terminal
|
||||
// to join a network.
|
||||
//
|
||||
// Scanning follows this page being on screen. Wi-Fi scanning and especially
|
||||
// Bluetooth discovery hold the radio, and doing either for a list nobody is
|
||||
// looking at is battery and airtime spent on nothing.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Networking
|
||||
import Quickshell.Bluetooth
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.quicksettings
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
title: "Network & Devices"
|
||||
lede: "Connect graphically—no terminal workflow required."
|
||||
lede: Connectivity.activeNetwork
|
||||
? "Connected to " + Connectivity.activeNetwork.name
|
||||
: "Wi-Fi, Bluetooth, and the things Fedora owns."
|
||||
|
||||
readonly property var wifiDevice: {
|
||||
for (const device of Networking.devices.values) {
|
||||
if (device.type === DeviceType.Wifi)
|
||||
return device;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
readonly property var bluetoothAdapter: Bluetooth.defaultAdapter
|
||||
// Drive the scanners only while this page is the one being shown.
|
||||
Component.onCompleted: Connectivity.active = true
|
||||
Component.onDestruction: Connectivity.active = false
|
||||
|
||||
SettingsCard {
|
||||
title: "Wi‑Fi"
|
||||
subtitle: Networking.wifiEnabled ? "Available networks" : "Wireless networking is off"
|
||||
title: "Wired"
|
||||
visible: Connectivity.wiredDevice !== null
|
||||
|
||||
TextRow {
|
||||
label: "Ethernet"
|
||||
detail: Connectivity.wiredDevice ? Connectivity.wiredDevice.name : ""
|
||||
value: Connectivity.wiredDevice && Connectivity.wiredDevice.connected ? "Connected" : "Not connected"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Wi-Fi"
|
||||
// A Wi-Fi switch reading "On" above the words "No Wi-Fi adapter" is a
|
||||
// contradiction; with no radio the card simply does not belong.
|
||||
visible: Connectivity.wifiDevice !== null
|
||||
subtitle: "Networks are re-scanned while this page is open."
|
||||
|
||||
SettingRow {
|
||||
label: "Wi‑Fi"
|
||||
detail: root.wifiDevice ? "Managed by NetworkManager" : "No wireless adapter found"
|
||||
label: "Wi-Fi"
|
||||
detail: Connectivity.wifiEnabled ? "On" : "Off"
|
||||
controlWidth: 48
|
||||
divider: Connectivity.wifiEnabled
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: Networking.wifiEnabled
|
||||
enabled: Networking.wifiHardwareEnabled
|
||||
checked: Connectivity.wifiEnabled
|
||||
enabled: Connectivity.wifiAvailable
|
||||
onToggled: value => Networking.wifiEnabled = value
|
||||
}
|
||||
}
|
||||
|
||||
WifiList {
|
||||
WifiPanel {
|
||||
width: parent.width
|
||||
device: root.wifiDevice
|
||||
active: true
|
||||
maxHeight: 240
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Advanced network settings"
|
||||
detail: "VPN, wired profiles, DNS, and connection details"
|
||||
divider: false
|
||||
action: "Open panel"
|
||||
onTriggered: SystemSettings.openGnomePanel("network")
|
||||
visible: Connectivity.wifiEnabled
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Bluetooth"
|
||||
subtitle: root.bluetoothAdapter?.enabled ? "Nearby and paired devices" : "Bluetooth is off"
|
||||
visible: Connectivity.adapter !== null
|
||||
subtitle: "Discovery runs while this page is open."
|
||||
|
||||
SettingRow {
|
||||
label: "Bluetooth"
|
||||
detail: root.bluetoothAdapter ? "Pair and reconnect without leaving Settings" : "No Bluetooth adapter found"
|
||||
detail: Connectivity.adapter
|
||||
? (Connectivity.adapter.enabled ? "On" : "Off")
|
||||
: "Unavailable"
|
||||
controlWidth: 48
|
||||
divider: !!(Connectivity.adapter && Connectivity.adapter.enabled)
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: root.bluetoothAdapter?.enabled ?? false
|
||||
enabled: root.bluetoothAdapter !== null
|
||||
checked: !!(Connectivity.adapter && Connectivity.adapter.enabled)
|
||||
enabled: Connectivity.adapter !== null
|
||||
onToggled: value => {
|
||||
if (root.bluetoothAdapter)
|
||||
root.bluetoothAdapter.enabled = value;
|
||||
if (Connectivity.adapter)
|
||||
Connectivity.adapter.enabled = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BluetoothList {
|
||||
BluetoothPanel {
|
||||
width: parent.width
|
||||
active: true
|
||||
maxHeight: 220
|
||||
visible: !!(Connectivity.adapter && Connectivity.adapter.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Owned by Fedora"
|
||||
subtitle: "VPNs, per-connection routing, printers, and online accounts are configured by GNOME's panels, which are installed and searchable."
|
||||
|
||||
ActionRow {
|
||||
label: "Advanced Bluetooth settings"
|
||||
detail: "Device details and system-level options"
|
||||
label: "Network connections"
|
||||
detail: "VPN, proxies, and per-connection settings"
|
||||
action: "Open"
|
||||
onTriggered: SystemSettings.openGnomePanel("network")
|
||||
}
|
||||
ActionRow {
|
||||
label: "Printers"
|
||||
action: "Open"
|
||||
onTriggered: SystemSettings.openGnomePanel("printers")
|
||||
}
|
||||
ActionRow {
|
||||
label: "Online accounts"
|
||||
action: "Open"
|
||||
divider: false
|
||||
action: "Open panel"
|
||||
onTriggered: SystemSettings.openGnomePanel("bluetooth")
|
||||
onTriggered: SystemSettings.openGnomePanel("online-accounts")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// A password entry with a reveal toggle.
|
||||
//
|
||||
// Deliberately not the clipboard popover's SearchField with different text: a
|
||||
// Wi-Fi key typed into a field that echoes it is readable by anyone behind you,
|
||||
// and a search glyph in front of a password prompt is simply wrong. Masked by
|
||||
// default, revealable while held, because the reason people want to see it is
|
||||
// to check a character they just typed.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property alias text: input.text
|
||||
property string placeholder: "Password"
|
||||
property bool revealed: false
|
||||
|
||||
signal accepted
|
||||
|
||||
implicitHeight: 32
|
||||
radius: Theme.pillRadius
|
||||
color: Theme.alpha(Theme.fg, 0.07)
|
||||
// Not left at 0 so the focus ring has something to animate. See the note in
|
||||
// modules/clipboard/SearchField.qml.
|
||||
border.width: 1
|
||||
border.color: input.activeFocus ? Theme.alpha(Theme.accent, 0.55) : "transparent"
|
||||
|
||||
Behavior on border.color {
|
||||
ColorAnimation { duration: Theme.durFast }
|
||||
}
|
||||
|
||||
function grab(): void {
|
||||
input.forceActiveFocus();
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
input.text = "";
|
||||
root.revealed = false;
|
||||
}
|
||||
|
||||
TextInput {
|
||||
id: input
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 13
|
||||
anchors.right: revealButton.left
|
||||
anchors.rightMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
color: Theme.fg
|
||||
selectionColor: Theme.alpha(Theme.accent, 0.5)
|
||||
selectedTextColor: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
echoMode: root.revealed ? TextInput.Normal : TextInput.Password
|
||||
passwordCharacter: "•"
|
||||
clip: true
|
||||
|
||||
onAccepted: root.accepted()
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
visible: input.text === ""
|
||||
text: root.placeholder
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: revealButton
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 5
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 26
|
||||
height: 24
|
||||
radius: 7
|
||||
color: revealHover.hovered ? Theme.alpha(Theme.fg, 0.12) : "transparent"
|
||||
border.width: 0
|
||||
visible: input.text !== ""
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
// Nerd Font eye / eye-slash. fontMono is used for icon glyphs only.
|
||||
text: root.revealed ? "\u{F070}" : "\u{F06E}"
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 12
|
||||
color: root.revealed ? Theme.accent : Theme.fgMuted
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: revealHover
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: root.revealed = !root.revealed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// Wi-Fi, at page size.
|
||||
//
|
||||
// The quick settings version is a popover: a compact list you glance at. This
|
||||
// is the one you sit in front of when a network is not behaving, so each row
|
||||
// carries what you would otherwise open a terminal to find out — signal,
|
||||
// security, and whether it is a network this machine already knows.
|
||||
//
|
||||
// Joining a secured network reveals an inline password field rather than
|
||||
// failing silently, which is the one interaction the popover already got right
|
||||
// and is worth keeping identical.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Networking
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
spacing: 0
|
||||
|
||||
// SSID whose password field is open, and the last failure.
|
||||
property string passwordFor: ""
|
||||
property string failedSsid: ""
|
||||
property string failedText: ""
|
||||
|
||||
function activate(network: var): void {
|
||||
root.failedSsid = "";
|
||||
if (network.connected)
|
||||
return;
|
||||
if (network.known || !Connectivity.isSecured(network)) {
|
||||
root.passwordFor = "";
|
||||
network.connect();
|
||||
return;
|
||||
}
|
||||
root.passwordFor = root.passwordFor === network.name ? "" : network.name;
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Connectivity.networks
|
||||
|
||||
Column {
|
||||
id: entry
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: parent.width
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
|
||||
label: entry.modelData.name || "Hidden network"
|
||||
detail: {
|
||||
const bits = [];
|
||||
if (entry.modelData.connected)
|
||||
bits.push("Connected");
|
||||
else if (entry.modelData.known)
|
||||
bits.push("Saved");
|
||||
bits.push(Connectivity.signalLabel(entry.modelData.signalStrength));
|
||||
bits.push(Connectivity.securityLabel(entry.modelData));
|
||||
return bits.join(" · ");
|
||||
}
|
||||
divider: entry.index < Connectivity.networks.length - 1 || root.passwordFor === entry.modelData.name
|
||||
controlWidth: 190
|
||||
activatable: !entry.modelData.connected
|
||||
onActivated: root.activate(entry.modelData)
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 7
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: entry.modelData.connected
|
||||
text: "Connected"
|
||||
color: Theme.accent
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: entry.modelData.connected
|
||||
text: "Disconnect"
|
||||
onClicked: entry.modelData.disconnect()
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: !entry.modelData.connected
|
||||
text: entry.modelData.known ? "Connect" : "Join"
|
||||
onClicked: root.activate(entry.modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The password field for this network, when it is the one being
|
||||
// joined. Inline rather than a dialog: a dialog over a tiled window
|
||||
// is a worse place to type than the row you just clicked.
|
||||
Item {
|
||||
width: parent.width
|
||||
height: root.passwordFor === entry.modelData.name ? 54 : 0
|
||||
visible: height > 0
|
||||
clip: true
|
||||
|
||||
onVisibleChanged: {
|
||||
if (visible)
|
||||
password.grab();
|
||||
else
|
||||
password.clear();
|
||||
}
|
||||
|
||||
PasswordField {
|
||||
id: password
|
||||
anchors.left: parent.left
|
||||
anchors.right: joinButton.left
|
||||
anchors.rightMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
placeholder: "Password for " + (entry.modelData.name || "network")
|
||||
onAccepted: joinButton.join()
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: joinButton
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Join"
|
||||
|
||||
function join(): void {
|
||||
entry.modelData.connect(password.text);
|
||||
root.passwordFor = "";
|
||||
password.text = "";
|
||||
}
|
||||
|
||||
onClicked: joinButton.join()
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.failedSsid === entry.modelData.name
|
||||
leftPadding: 2
|
||||
bottomPadding: 8
|
||||
text: root.failedText
|
||||
color: Theme.warn
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: entry.modelData
|
||||
function onConnectionFailed(reason): void {
|
||||
root.failedSsid = entry.modelData.name;
|
||||
root.failedText = Connectivity.connectionFailureText(reason);
|
||||
root.passwordFor = entry.modelData.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: Connectivity.networks.length === 0
|
||||
label: !Connectivity.wifiDevice
|
||||
? "No Wi-Fi adapter"
|
||||
: (Connectivity.wifiEnabled ? "Looking for networks…" : "Wi-Fi is off")
|
||||
detail: Connectivity.wifiDevice && !Connectivity.wifiEnabled
|
||||
? "Turn it on above to see what is nearby"
|
||||
: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
@@ -37,3 +37,6 @@ DockAppPicker 1.0 DockAppPicker.qml
|
||||
ShortcutCapture 1.0 ShortcutCapture.qml
|
||||
ChoiceGrid 1.0 ChoiceGrid.qml
|
||||
DisplayModePicker 1.0 DisplayModePicker.qml
|
||||
WifiPanel 1.0 WifiPanel.qml
|
||||
BluetoothPanel 1.0 BluetoothPanel.qml
|
||||
PasswordField 1.0 PasswordField.qml
|
||||
|
||||
Reference in New Issue
Block a user