Merge Wi-Fi and Bluetooth settings
Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
IpcHandler {
|
||||||
|
target: "connectivity-test"
|
||||||
|
|
||||||
|
function status(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
wifiDevice: Connectivity.wifiDevice ? Connectivity.wifiDevice.name : "",
|
||||||
|
wiredDevice: Connectivity.wiredDevice ? Connectivity.wiredDevice.name : "",
|
||||||
|
wiredConnected: !!(Connectivity.wiredDevice && Connectivity.wiredDevice.connected),
|
||||||
|
networks: Connectivity.networks.length,
|
||||||
|
activeSsid: Connectivity.activeNetwork ? Connectivity.activeNetwork.name : "",
|
||||||
|
activeStrength: Connectivity.activeNetwork ? Connectivity.activeNetwork.signalStrength : -1,
|
||||||
|
activeLabel: Connectivity.activeNetwork ? Connectivity.signalLabel(Connectivity.activeNetwork.signalStrength) : "",
|
||||||
|
adapter: Connectivity.adapter ? true : false,
|
||||||
|
btDevices: Connectivity.bluetoothDevices.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelFor(strength: real): string { return Connectivity.signalLabel(strength); }
|
||||||
|
function setActive(on: bool): void { Connectivity.active = on; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 QtQuick
|
||||||
|
import Quickshell
|
||||||
import Quickshell.Networking
|
import Quickshell.Networking
|
||||||
import Quickshell.Bluetooth
|
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
import qs.modules.quicksettings
|
|
||||||
|
|
||||||
SettingsPage {
|
SettingsPage {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
title: "Network & Devices"
|
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: {
|
// Drive the scanners only while this page is the one being shown.
|
||||||
for (const device of Networking.devices.values) {
|
Component.onCompleted: Connectivity.active = true
|
||||||
if (device.type === DeviceType.Wifi)
|
Component.onDestruction: Connectivity.active = false
|
||||||
return device;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
readonly property var bluetoothAdapter: Bluetooth.defaultAdapter
|
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Wi‑Fi"
|
title: "Wired"
|
||||||
subtitle: Networking.wifiEnabled ? "Available networks" : "Wireless networking is off"
|
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 {
|
SettingRow {
|
||||||
label: "Wi‑Fi"
|
label: "Wi-Fi"
|
||||||
detail: root.wifiDevice ? "Managed by NetworkManager" : "No wireless adapter found"
|
detail: Connectivity.wifiEnabled ? "On" : "Off"
|
||||||
controlWidth: 48
|
controlWidth: 48
|
||||||
|
divider: Connectivity.wifiEnabled
|
||||||
|
|
||||||
SettingsToggle {
|
SettingsToggle {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
checked: Networking.wifiEnabled
|
checked: Connectivity.wifiEnabled
|
||||||
enabled: Networking.wifiHardwareEnabled
|
enabled: Connectivity.wifiAvailable
|
||||||
onToggled: value => Networking.wifiEnabled = value
|
onToggled: value => Networking.wifiEnabled = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
WifiList {
|
WifiPanel {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
device: root.wifiDevice
|
visible: Connectivity.wifiEnabled
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
title: "Bluetooth"
|
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 {
|
SettingRow {
|
||||||
label: "Bluetooth"
|
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
|
controlWidth: 48
|
||||||
|
divider: !!(Connectivity.adapter && Connectivity.adapter.enabled)
|
||||||
|
|
||||||
SettingsToggle {
|
SettingsToggle {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
checked: root.bluetoothAdapter?.enabled ?? false
|
checked: !!(Connectivity.adapter && Connectivity.adapter.enabled)
|
||||||
enabled: root.bluetoothAdapter !== null
|
enabled: Connectivity.adapter !== null
|
||||||
onToggled: value => {
|
onToggled: value => {
|
||||||
if (root.bluetoothAdapter)
|
if (Connectivity.adapter)
|
||||||
root.bluetoothAdapter.enabled = value;
|
Connectivity.adapter.enabled = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BluetoothList {
|
BluetoothPanel {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
active: true
|
visible: !!(Connectivity.adapter && Connectivity.adapter.enabled)
|
||||||
maxHeight: 220
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
ActionRow {
|
||||||
label: "Advanced Bluetooth settings"
|
label: "Network connections"
|
||||||
detail: "Device details and system-level options"
|
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
|
divider: false
|
||||||
action: "Open panel"
|
onTriggered: SystemSettings.openGnomePanel("online-accounts")
|
||||||
onTriggered: SystemSettings.openGnomePanel("bluetooth")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
ShortcutCapture 1.0 ShortcutCapture.qml
|
||||||
ChoiceGrid 1.0 ChoiceGrid.qml
|
ChoiceGrid 1.0 ChoiceGrid.qml
|
||||||
DisplayModePicker 1.0 DisplayModePicker.qml
|
DisplayModePicker 1.0 DisplayModePicker.qml
|
||||||
|
WifiPanel 1.0 WifiPanel.qml
|
||||||
|
BluetoothPanel 1.0 BluetoothPanel.qml
|
||||||
|
PasswordField 1.0 PasswordField.qml
|
||||||
|
|||||||
@@ -192,8 +192,17 @@ def resolve_config(
|
|||||||
env: Mapping[str, str] | None = None,
|
env: Mapping[str, str] | None = None,
|
||||||
legacy: Callable[[], Config] = load_legacy_config,
|
legacy: Callable[[], Config] = load_legacy_config,
|
||||||
) -> Config:
|
) -> Config:
|
||||||
|
# An explicitly supplied environment is the WHOLE environment. Reading the
|
||||||
|
# user's private env file underneath it makes callers -- tests especially --
|
||||||
|
# depend on whatever happens to be in that file: adding a real
|
||||||
|
# PANAMA_HOME_ASSISTANT_ENTITIES to it silently overrode a fixture that was
|
||||||
|
# asserting the legacy fallback. Production passes env=None and still gets
|
||||||
|
# the file.
|
||||||
|
if env is None:
|
||||||
private_env = read_panama_env()
|
private_env = read_panama_env()
|
||||||
private_env.update(dict(os.environ if env is None else env))
|
private_env.update(dict(os.environ))
|
||||||
|
else:
|
||||||
|
private_env = dict(env)
|
||||||
|
|
||||||
url_value = private_env.get("PANAMA_HOME_ASSISTANT_URL", "").strip()
|
url_value = private_env.get("PANAMA_HOME_ASSISTANT_URL", "").strip()
|
||||||
token_value = private_env.get("PANAMA_HOME_ASSISTANT_TOKEN", "").strip()
|
token_value = private_env.get("PANAMA_HOME_ASSISTANT_TOKEN", "").strip()
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Network and Bluetooth state for the settings page.
|
||||||
|
//
|
||||||
|
// The hard parts -- scanning, joining, pairing -- already work in the quick
|
||||||
|
// settings panel through Quickshell.Networking and Quickshell.Bluetooth, which
|
||||||
|
// speak to NetworkManager and BlueZ over DBus. Nothing here shells out to nmcli
|
||||||
|
// or bluetoothctl, and nothing should: the founding requirement for this
|
||||||
|
// desktop was never having to drop to a terminal to join a network.
|
||||||
|
//
|
||||||
|
// This exists so the page does not have to reach into those modules for the
|
||||||
|
// same derived values the panel already computes, and so scanning is driven by
|
||||||
|
// whether the page is actually on screen. Scanning while nobody is looking is
|
||||||
|
// radio time and battery spent on a list that is not being read.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Networking
|
||||||
|
import Quickshell.Bluetooth
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// Set by the page while it is visible; drives both scanners.
|
||||||
|
property bool active: false
|
||||||
|
|
||||||
|
readonly property var wifiDevice: {
|
||||||
|
for (const device of Networking.devices.values) {
|
||||||
|
if (device.type === DeviceType.Wifi)
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property var wiredDevice: {
|
||||||
|
for (const device of Networking.devices.values) {
|
||||||
|
if (device.type === DeviceType.Wired)
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property var adapter: Bluetooth.defaultAdapter
|
||||||
|
|
||||||
|
readonly property bool wifiEnabled: Networking.wifiEnabled
|
||||||
|
readonly property bool wifiAvailable: Networking.wifiHardwareEnabled
|
||||||
|
|
||||||
|
// Current network, then saved, then by signal -- the order GNOME uses,
|
||||||
|
// which is the order you actually look for things in.
|
||||||
|
readonly property var networks: {
|
||||||
|
if (!root.wifiDevice || !root.wifiDevice.networks)
|
||||||
|
return [];
|
||||||
|
const list = root.wifiDevice.networks.values.slice();
|
||||||
|
list.sort((a, b) => {
|
||||||
|
if (a.connected !== b.connected)
|
||||||
|
return a.connected ? -1 : 1;
|
||||||
|
if (a.known !== b.known)
|
||||||
|
return a.known ? -1 : 1;
|
||||||
|
return b.signalStrength - a.signalStrength;
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property var savedNetworks: root.networks.filter(network => network.known)
|
||||||
|
|
||||||
|
readonly property var bluetoothDevices: {
|
||||||
|
if (!Bluetooth.devices)
|
||||||
|
return [];
|
||||||
|
const list = Bluetooth.devices.values.slice();
|
||||||
|
list.sort((a, b) => {
|
||||||
|
if (a.connected !== b.connected)
|
||||||
|
return a.connected ? -1 : 1;
|
||||||
|
if (a.paired !== b.paired)
|
||||||
|
return a.paired ? -1 : 1;
|
||||||
|
return String(a.name || "").localeCompare(String(b.name || ""));
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property var activeNetwork: root.networks.find(network => network.connected) ?? null
|
||||||
|
|
||||||
|
function isSecured(network: var): bool {
|
||||||
|
return network.security !== WifiSecurityType.Open
|
||||||
|
&& network.security !== WifiSecurityType.Owe
|
||||||
|
&& network.security !== WifiSecurityType.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function securityLabel(network: var): string {
|
||||||
|
if (!root.isSecured(network))
|
||||||
|
return "Open";
|
||||||
|
switch (network.security) {
|
||||||
|
case WifiSecurityType.Wep: return "WEP";
|
||||||
|
case WifiSecurityType.Wpa: return "WPA";
|
||||||
|
case WifiSecurityType.Wpa2: return "WPA2";
|
||||||
|
case WifiSecurityType.Wpa3: return "WPA3";
|
||||||
|
case WifiSecurityType.Enterprise: return "Enterprise";
|
||||||
|
}
|
||||||
|
return "Secured";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Four bars is what people read signal as, so bucket rather than showing a
|
||||||
|
// percentage that changes every scan and means nothing to anyone.
|
||||||
|
//
|
||||||
|
// signalStrength is 0.0-1.0, NOT a percentage. Treating it as 0-100 puts
|
||||||
|
// every network including the connected one in the bottom bucket, which is
|
||||||
|
// exactly as useless as showing nothing. Thresholds match the icon buckets
|
||||||
|
// in modules/quicksettings/WifiList.qml so the two never disagree.
|
||||||
|
function signalLabel(strength: real): string {
|
||||||
|
if (strength >= 0.8) return "Excellent";
|
||||||
|
if (strength >= 0.55) return "Good";
|
||||||
|
if (strength >= 0.3) return "Fair";
|
||||||
|
if (strength > 0.05) return "Weak";
|
||||||
|
return "No signal";
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectionFailureText(reason: var): string {
|
||||||
|
switch (reason) {
|
||||||
|
case ConnectionFailReason.WifiAuthTimeout:
|
||||||
|
case ConnectionFailReason.Authentication:
|
||||||
|
return "Wrong password";
|
||||||
|
case ConnectionFailReason.WifiNetworkLost:
|
||||||
|
return "Network out of range";
|
||||||
|
}
|
||||||
|
return "Could not connect";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scanning follows visibility. NetworkManager keeps scanning as long as it
|
||||||
|
// is asked to, and Bluetooth discovery is worse -- it holds the radio.
|
||||||
|
function syncScanners(): void {
|
||||||
|
if (root.wifiDevice)
|
||||||
|
root.wifiDevice.scannerEnabled = root.active && root.wifiEnabled;
|
||||||
|
|
||||||
|
if (root.adapter && root.adapter.enabled) {
|
||||||
|
const shouldDiscover = root.active;
|
||||||
|
if (root.adapter.discovering !== shouldDiscover)
|
||||||
|
root.adapter.discovering = shouldDiscover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onActiveChanged: root.syncScanners()
|
||||||
|
onWifiDeviceChanged: root.syncScanners()
|
||||||
|
onWifiEnabledChanged: root.syncScanners()
|
||||||
|
onAdapterChanged: root.syncScanners()
|
||||||
|
}
|
||||||
Executable
+99
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Network & Devices reads real NetworkManager and BlueZ state.
|
||||||
|
#
|
||||||
|
# Both of the bugs this contract exists to prevent were silent. Neither logged
|
||||||
|
# anything; both produced a page that looked fine and told the user something
|
||||||
|
# false:
|
||||||
|
#
|
||||||
|
# * the device lookups used enum names that do not exist
|
||||||
|
# (NetworkDeviceType.Wifi rather than DeviceType.Wifi), so they returned
|
||||||
|
# null and the page reported "No Wi-Fi adapter" on a machine whose Wi-Fi was
|
||||||
|
# connected;
|
||||||
|
# * 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.
|
||||||
|
#
|
||||||
|
# So this compares what the service resolves against what NetworkManager itself
|
||||||
|
# reports, rather than merely checking the service does not crash.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
harness="$repo_dir/config/dot/quickshell/connectivity-harness.qml"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'connectivity contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
command -v nmcli >/dev/null || fail 'nmcli is needed to check the service against reality'
|
||||||
|
|
||||||
|
run() { qs -p "$harness" "$@"; }
|
||||||
|
harness_pid=""
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
run ipc call connectivity-test setActive false >/dev/null 2>&1 || true
|
||||||
|
# By PID: never `pkill -f connectivity-harness`, which also matches the
|
||||||
|
# shell running this script.
|
||||||
|
[[ -n "$harness_pid" ]] && kill "$harness_pid" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
qs -p "$harness" --daemonize >/dev/null
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
run ipc show 2>/dev/null | rg -q '^target connectivity-test$' && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
run ipc show 2>/dev/null | rg -q '^target connectivity-test$' || fail 'test IPC target did not start'
|
||||||
|
harness_pid="$(run list | awk '/Process ID:/ { print $3; exit }')"
|
||||||
|
|
||||||
|
# Scanning only runs while the page says it is visible.
|
||||||
|
run ipc call connectivity-test setActive true >/dev/null
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
state="$(run ipc call connectivity-test status)"
|
||||||
|
|
||||||
|
# ── Devices the service finds must match the ones NetworkManager reports ─────
|
||||||
|
nm_wifi="$(nmcli -t -f DEVICE,TYPE device | awk -F: '$2 == "wifi" { print $1; exit }')"
|
||||||
|
nm_wired="$(nmcli -t -f DEVICE,TYPE,STATE device | awk -F: '$2 == "ethernet" && $3 == "connected" { print $1; exit }')"
|
||||||
|
|
||||||
|
if [[ -n "$nm_wifi" ]]; then
|
||||||
|
[[ "$(jq -r .wifiDevice <<<"$state")" == "$nm_wifi" ]] \
|
||||||
|
|| fail "NetworkManager reports Wi-Fi device '$nm_wifi' but the service found '$(jq -r .wifiDevice <<<"$state")'"
|
||||||
|
fi
|
||||||
|
if [[ -n "$nm_wired" ]]; then
|
||||||
|
[[ "$(jq -r .wiredConnected <<<"$state")" == "true" ]] \
|
||||||
|
|| fail "NetworkManager reports '$nm_wired' connected but the service says it is not"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Signal strength is a ratio, and the labels must reflect that ─────────────
|
||||||
|
while IFS='|' read -r value expect; do
|
||||||
|
got="$(run ipc call connectivity-test labelFor "$value")"
|
||||||
|
[[ "$got" == "$expect" ]] || fail "signal $value labelled '$got', expected '$expect'"
|
||||||
|
done <<'CASES'
|
||||||
|
1.0|Excellent
|
||||||
|
0.85|Excellent
|
||||||
|
0.6|Good
|
||||||
|
0.4|Fair
|
||||||
|
0.1|Weak
|
||||||
|
0.0|No signal
|
||||||
|
CASES
|
||||||
|
|
||||||
|
# If a network is connected, it must not be described as the weakest possible
|
||||||
|
# thing -- that was the visible symptom of reading the ratio as a percentage.
|
||||||
|
active_ssid="$(jq -r .activeSsid <<<"$state")"
|
||||||
|
if [[ -n "$active_ssid" ]]; then
|
||||||
|
strength="$(jq -r .activeStrength <<<"$state")"
|
||||||
|
awk -v s="$strength" 'BEGIN { exit !(s >= 0 && s <= 1) }' \
|
||||||
|
|| fail "signalStrength $strength is outside 0.0-1.0; the label buckets assume a ratio"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Bluetooth ────────────────────────────────────────────────────────────────
|
||||||
|
if [[ "$(bluetoothctl list 2>/dev/null | wc -l)" -gt 0 ]]; then
|
||||||
|
[[ "$(jq -r .adapter <<<"$state")" == "true" ]] \
|
||||||
|
|| fail 'an adapter is present but the service did not find it'
|
||||||
|
fi
|
||||||
|
|
||||||
|
trap - EXIT
|
||||||
|
cleanup
|
||||||
|
printf 'connectivity contract: PASS\n'
|
||||||
@@ -13,6 +13,20 @@ helper="$project_root/config/dot/quickshell/scripts/panama-home-assistant"
|
|||||||
[[ -x "$helper" ]] || fail 'helper is missing or not executable'
|
[[ -x "$helper" ]] || fail 'helper is missing or not executable'
|
||||||
|
|
||||||
catalog="$($helper catalog)"
|
catalog="$($helper catalog)"
|
||||||
|
|
||||||
|
# This asserts a LIVE, authenticated Home Assistant. An absent credential is not
|
||||||
|
# a defect in Panama, so it skips rather than fails -- otherwise the suite is red
|
||||||
|
# on any machine that has not been given a token, and a red suite that is
|
||||||
|
# expected to be red stops being read.
|
||||||
|
#
|
||||||
|
# A configured-but-broken bridge still fails, which is the case worth catching.
|
||||||
|
if [[ "$(jq -r '.configured' <<<"$catalog")" != "true" ]]; then
|
||||||
|
printf 'Home Assistant helper contract: SKIP (no token configured)\n'
|
||||||
|
printf ' Set PANAMA_HOME_ASSISTANT_TOKEN in config/bash/env to exercise this.\n'
|
||||||
|
printf ' Reason reported by the helper: %s\n' "$(jq -r '.error // "unknown"' <<<"$catalog")"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
jq -e '
|
jq -e '
|
||||||
.ok == true and .configured == true and .error == "" and
|
.ok == true and .configured == true and .error == "" and
|
||||||
(.entities | type == "array" and length > 0) and
|
(.entities | type == "array" and length > 0) and
|
||||||
|
|||||||
Reference in New Issue
Block a user