Files
Panama/config/dot/quickshell/modules/settings/ConnectivityPage.qml
T

549 lines
22 KiB
QML

// Connections — the whole network, not the half that was easy.
//
// This page used to end in a card headed "Owned by Fedora" with two doors back
// to GNOME's panels: one for hidden and enterprise networks, one for VPN and
// proxies. Everything behind those doors now lives here, so the card is gone.
//
// Two mechanisms, deliberately kept apart:
//
// * Wi-Fi and Bluetooth state -- scanning, joining, pairing, the radio
// switches -- go through Quickshell.Networking and Quickshell.Bluetooth,
// which speak to NetworkManager and BlueZ over DBus. Connectivity.qml is
// pinned shell-out-free and stays that way.
// * Everything NetworkManager exposes only through nmcli -- per-connection
// addresses, forgetting a profile, MAC randomization, VPN import, hotspot,
// enterprise join -- goes through NetworkTools.qml and scripts/panama-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 qs.config
import qs.services
SettingsPage {
id: root
objectName: "connectivity"
title: "Connections"
lede: {
const wifi = Connectivity.activeNetwork ? Connectivity.activeNetwork.name : "";
if (wifi !== "" && Connectivity.wiredOn)
return "On " + wifi + " and wired.";
if (wifi !== "")
return "On " + wifi + ".";
if (Connectivity.wiredOn)
return "Wired.";
return "Wi-Fi, Bluetooth, VPN, and what this machine can reach.";
}
// The wired connection's own drawer, and the two inline forms that are only
// open while someone is filling them in.
property bool wiredOpen: false
property bool hotspotOpen: false
property string hotspotName: ""
property bool importOpen: false
property string importPath: ""
// The proxy dropdown and its address, page-local until they add up to a
// whole setting.
//
// A manual proxy is a host AND a port -- GNOME ignores a proxy whose host
// is empty or whose port is 0 -- so engaging "manual" before an address
// exists produces a mode that says it is proxying and is not. The dropdown
// therefore leads the applied mode: picking Manual reveals the fields, and
// the proxy is written on the edit that completes the pair, whichever of
// the two that is. Committing on the first field instead would have to
// guess at the other one; requiring the other to be stored first, which is
// what this page used to do, meant neither could ever be first.
//
// The three start as bindings and stay bound until the first edit, which is
// what carries the helper's opening read into a page that was built before
// it answered. After that they are the user's, and nothing re-seeds them:
// a reply landing mid-edit must not move the dropdown or empty the field
// somebody is typing into.
property string proxyChoice: NetworkTools.proxyMode
property string draftProxyHost: NetworkTools.proxyHost
property string draftProxyPort: NetworkTools.proxyPort
// "host", "port", "both" or "" -- which half of the pair is still missing.
readonly property string proxyMissing: {
const host = root.draftProxyHost.trim() === "";
const port = root.draftProxyPort.trim() === "";
if (host && port)
return "both";
if (host)
return "host";
if (port)
return "port";
return "";
}
// Called after either field is edited. Writes only a whole address, so a
// half-filled form leaves the proxy exactly as it was.
function commitProxyManual(): void {
if (root.proxyMissing !== "")
return;
NetworkTools.setProxyManual(root.draftProxyHost.trim(), root.draftProxyPort.trim());
}
readonly property string wiredConnection:
String(Connectivity.wiredDevice?.network?.name ?? "")
readonly property string activeWifi:
Connectivity.activeNetwork ? Connectivity.activeNetwork.name : ""
// Drive the scanners and the helper only while this page is the one being
// shown. Both cost radio time or nmcli invocations for a list nobody is
// reading.
Component.onCompleted: {
Connectivity.active = true;
NetworkTools.active = true;
if (!WifiShare.scanned)
WifiShare.refresh();
Vpn.refresh();
}
Component.onDestruction: {
Connectivity.active = false;
NetworkTools.active = false;
}
// Addresses and the MAC in use both change with the connection, so the
// cached details for a network that just came up are stale the moment it
// does.
onActiveWifiChanged: {
if (root.activeWifi !== "")
NetworkTools.refreshDetails(root.activeWifi);
}
onWiredConnectionChanged: {
if (root.wiredConnection !== "")
NetworkTools.refreshDetails(root.wiredConnection);
}
TextRow {
visible: NetworkTools.lastError !== ""
label: "The network needs attention"
detail: NetworkTools.lastError
value: ""
divider: false
}
// ── Wired ────────────────────────────────────────────────────────────────
SettingsCard {
title: "Wired"
visible: Connectivity.wiredDevice !== null
SettingRow {
width: parent.width
label: root.wiredConnection !== "" ? root.wiredConnection : "Ethernet"
// Three states worth telling apart: on, off but plugged in, and
// nothing in the socket. "Not connected" covered all three and
// explained none of them.
detail: {
const device = Connectivity.wiredDevice;
if (!device)
return "";
if (device.connected)
return device.name + (device.linkSpeed > 0
? " · " + device.linkSpeed + " Mb/s" : "");
// Not "no cable": that cannot be told apart from "switched off"
// by anything reliable here, and guessing produced a switch
// that blamed the hardware for what it had just done itself.
return device.name + " · off";
}
controlWidth: 78
divider: false
activatable: root.wiredConnection !== "" && Connectivity.wiredOn
onActivated: root.wiredOpen = !root.wiredOpen
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 9
SettingsToggle {
anchors.verticalCenter: parent.verticalCenter
checked: Connectivity.wiredOn
enabled: Connectivity.wiredAvailable
onToggled: value => Connectivity.setWired(value)
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: root.wiredConnection !== "" && Connectivity.wiredOn
text: root.wiredOpen ? "▴" : "▾"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
ConnectionDetails {
width: parent.width
visible: root.wiredOpen && Connectivity.wiredOn
details: root.wiredConnection !== ""
? NetworkTools.detailsFor(root.wiredConnection) : null
}
}
// ── Wi-Fi ────────────────────────────────────────────────────────────────
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: Connectivity.wifiEnabled ? "On" : "Off"
controlWidth: 48
divider: Connectivity.wifiEnabled
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Connectivity.wifiEnabled
enabled: Connectivity.wifiAvailable
onToggled: value => Connectivity.setWifiEnabled(value)
}
}
WifiPanel {
width: parent.width
visible: Connectivity.wifiEnabled
}
// ── Hotspot ──────────────────────────────────────────────────────────
//
// One radio cannot be a client and an access point at the same time, so
// starting this drops whatever network the machine is on. Said before it
// happens rather than discovered when the browser stops loading.
ActionRow {
width: parent.width
visible: Connectivity.wifiEnabled && !NetworkTools.hotspotActive
label: "Hotspot"
detail: "Share this machine's connection over Wi-Fi"
action: root.hotspotOpen ? "Cancel" : "Start hotspot…"
enabled: !NetworkTools.busy
divider: root.hotspotOpen
onTriggered: {
root.hotspotOpen = !root.hotspotOpen;
root.hotspotName = "";
}
}
Column {
width: parent.width
visible: root.hotspotOpen && !NetworkTools.hotspotActive
TextFieldRow {
width: parent.width
label: "Network name"
detail: "What the hotspot calls itself to phones and laptops nearby"
placeholder: "panama-hotspot"
text: root.hotspotName
enabled: !NetworkTools.busy
onAccepted: value => root.hotspotName = value
}
ActionRow {
width: parent.width
label: "Start the hotspot"
detail: Connectivity.activeNetwork
? "This machine leaves " + Connectivity.activeNetwork.name
+ " while the hotspot runs — one radio cannot do both."
: "NetworkManager makes up a password and shows it once."
action: NetworkTools.busy ? "Starting…" : "Start"
enabled: root.hotspotName.trim() !== "" && !NetworkTools.busy
divider: false
onTriggered: {
NetworkTools.startHotspot(root.hotspotName.trim());
root.hotspotOpen = false;
}
}
}
TextRow {
width: parent.width
visible: NetworkTools.hotspotActive
label: "Hotspot is running"
detail: "Phones and laptops nearby can see this network and join it"
value: NetworkTools.hotspotSsid
}
// Shown once, on purpose. NetworkManager keeps the passphrase; this page
// never stores it, so leaving this screen means asking NetworkManager
// again rather than reading it back from Panama.
TextRow {
width: parent.width
visible: NetworkTools.hotspotActive && NetworkTools.hotspotPassword !== ""
label: "Password"
detail: "Shown once. Panama does not keep a copy — write it down or let someone type it in now."
value: NetworkTools.hotspotPassword
}
ActionRow {
width: parent.width
visible: NetworkTools.hotspotActive
label: "Stop the hotspot"
detail: "Anything connected through this machine loses its connection"
action: "Stop"
enabled: !NetworkTools.busy
divider: false
onTriggered: NetworkTools.stopHotspot()
}
}
// ── VPN ──────────────────────────────────────────────────────────────────
SettingsCard {
title: "VPN"
subtitle: "WireGuard and OpenVPN profiles NetworkManager holds for you."
TextRow {
width: parent.width
visible: Vpn.lastError !== ""
label: "The last VPN action did not finish"
detail: Vpn.lastError
value: ""
}
Repeater {
model: Vpn.connections
delegate: SwitchRow {
id: vpnRow
required property var modelData
required property int index
width: parent.width
label: String(vpnRow.modelData.name ?? "")
detail: String(vpnRow.modelData.kind ?? "VPN")
+ (vpnRow.modelData.active === true ? " · connected" : "")
checked: vpnRow.modelData.active === true
enabled: !Vpn.busy
onToggled: value => Vpn.setActive(String(vpnRow.modelData.uuid ?? ""), value)
}
}
TextRow {
width: parent.width
visible: Vpn.scanned && Vpn.connections.length === 0
label: "No VPNs configured"
detail: "Import a WireGuard or OpenVPN file to add one"
value: ""
}
// Names the profile that appeared rather than saying "done". An import
// that succeeds under a name you did not choose is otherwise invisible
// until you go looking for it in the list.
TextRow {
width: parent.width
visible: NetworkTools.lastImport !== ""
label: "Imported " + NetworkTools.lastImport
detail: "It is switched off until you turn it on above"
value: ""
}
ActionRow {
width: parent.width
label: "Import a VPN"
detail: "A .conf file from WireGuard, or a .ovpn file from OpenVPN"
action: root.importOpen ? "Cancel" : "Import…"
enabled: !NetworkTools.busy
divider: root.importOpen
onTriggered: {
root.importOpen = !root.importOpen;
root.importPath = "";
}
}
// A typed path rather than a file chooser this phase. A chooser is the
// better answer and is worth doing properly; a typed path is worth far
// more than the door back to GNOME it replaces.
Column {
width: parent.width
visible: root.importOpen
TextFieldRow {
width: parent.width
label: "File"
detail: "The full path to the profile you were given"
placeholder: "~/Downloads/work.ovpn"
text: root.importPath
enabled: !NetworkTools.busy
onAccepted: value => root.importPath = value
}
ActionRow {
width: parent.width
label: "Import this profile"
detail: "NetworkManager reads it and adds a profile. Nothing connects until you switch it on."
action: NetworkTools.busy ? "Importing…" : "Import"
enabled: root.importPath.trim() !== "" && !NetworkTools.busy
divider: false
onTriggered: {
NetworkTools.importVpn(root.importPath.trim());
root.importOpen = false;
}
}
}
}
// ── Bluetooth ────────────────────────────────────────────────────────────
SettingsCard {
title: "Bluetooth"
visible: Connectivity.adapter !== null
subtitle: "Discovery runs while this page is open."
SettingRow {
label: "Bluetooth"
detail: Connectivity.bluetoothAvailable
? (Connectivity.bluetoothEnabled ? "On" : "Off")
: "Unavailable"
controlWidth: 48
divider: Connectivity.bluetoothEnabled
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Connectivity.bluetoothEnabled
enabled: Connectivity.bluetoothAvailable
onToggled: value => Connectivity.setBluetoothEnabled(value)
}
}
BluetoothPanel {
width: parent.width
visible: Connectivity.bluetoothEnabled
}
}
// ── Radios and proxy ─────────────────────────────────────────────────────
SettingsCard {
title: "Radios and proxy"
subtitle: "The two settings that apply to every connection at once."
// A hardware kill switch cannot be overridden from software, so the
// switch says so rather than moving and having nothing happen.
SwitchRow {
width: parent.width
label: "Airplane mode"
detail: NetworkTools.airplaneHardBlocked
? "A switch on this machine is holding the radios off. Software cannot turn them back on."
: "Turns Wi-Fi and Bluetooth off together — the same switch the keyboard's airplane key throws"
checked: NetworkTools.airplaneOn
enabled: !NetworkTools.busy && !NetworkTools.airplaneHardBlocked
onToggled: value => NetworkTools.setAirplane(value)
}
OptionPickerRow {
width: parent.width
label: "Network proxy"
detail: {
if (root.proxyChoice === "manual" && NetworkTools.proxyMode !== "manual") {
if (root.proxyMissing === "both")
return "Not applied yet — fill in the host and the port below.";
if (root.proxyMissing !== "")
return "Not applied yet — the " + root.proxyMissing + " below is still empty.";
return "Not applied yet.";
}
if (NetworkTools.proxyMode === "none")
return "Applications that honour the system proxy use this. Not every application does.";
return "In use: " + NetworkTools.proxySummary;
}
enabled: !NetworkTools.busy
options: [
{
value: "none",
label: "Off",
detail: "Applications reach the network directly"
},
{
value: "manual",
label: "Manual",
detail: "A host and port you enter, used for http, https and socks alike"
},
{
value: "auto",
label: "Automatic (PAC)",
detail: "A configuration URL decides, per address"
}
]
current: root.proxyChoice
// Off and Automatic mean something the moment they are picked;
// Manual does not, so it only opens the fields. See proxyChoice.
onPicked: value => {
root.proxyChoice = String(value);
if (root.proxyChoice === "manual")
root.commitProxyManual();
else
NetworkTools.setProxyMode(root.proxyChoice);
}
}
Column {
width: parent.width
visible: root.proxyChoice === "manual"
// Host and port are written together, because the proxy is only
// usable as a pair. Each field edits the page's draft; the pair is
// written once both halves are there.
TextFieldRow {
width: parent.width
label: "Proxy host"
detail: root.proxyMissing === "host"
? "Still empty — the proxy applies once this and the port are both set"
: "The machine applications should go through"
placeholder: "proxy.example.com"
text: root.draftProxyHost
enabled: !NetworkTools.busy
onAccepted: value => {
root.draftProxyHost = value.trim();
root.commitProxyManual();
}
}
TextFieldRow {
width: parent.width
label: "Port"
detail: root.proxyMissing === "port"
? "Still empty — the proxy applies once this and the host are both set"
: "The port that proxy listens on"
placeholder: "8080"
text: root.draftProxyPort
enabled: !NetworkTools.busy
divider: false
onAccepted: value => {
root.draftProxyPort = value.trim();
root.commitProxyManual();
}
}
}
TextFieldRow {
width: parent.width
visible: root.proxyChoice === "auto"
label: "Configuration URL"
detail: "The .pac file whoever runs the network published"
placeholder: "http://example.com/proxy.pac"
text: NetworkTools.proxyPac
enabled: !NetworkTools.busy
divider: false
onAccepted: value => NetworkTools.setProxyPac(value.trim())
}
}
}