// 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 saved list, folded at the house cap. Sorted by the helper with the // active and autoconnecting profiles first, so a slice keeps the ones worth // seeing and folds the tail. property bool savedShowAll: false readonly property int savedCap: 6 readonly property var savedShown: { const list = NetworkTools.savedConnections; if (root.savedShowAll || list.length <= root.savedCap) return list; return list.slice(0, root.savedCap); } readonly property int savedHidden: NetworkTools.savedConnections.length - root.savedShown.length // 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 connection: root.wiredConnection 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() } } // ── Saved networks ─────────────────────────────────────────────────────── // // The list above is what is nearby. This is what this machine REMEMBERS, // which is a different set and the more useful one to tidy: a profile you // want rid of is invisible in a scan-driven list until you are standing // next to it, which is exactly when you are least able to deal with it. SettingsCard { title: "Saved networks" visible: NetworkTools.savedConnections.length > 0 subtitle: NetworkTools.savedConnections.length + (NetworkTools.savedConnections.length === 1 ? " profile" : " profiles") + " NetworkManager holds, including the ones nowhere near you." Repeater { model: root.savedShown delegate: SettingRow { id: savedRow required property var modelData required property int index readonly property string name: String(savedRow.modelData.name ?? "") readonly property bool wireless: savedRow.modelData.wifi === true width: parent.width label: savedRow.name // Armed, the row stops describing the profile and names what // the confirming press costs. The saved passphrase goes with // the profile, and nothing else on this page says so. detail: { if (forgetSaved.armed) return "This deletes the saved profile and its password. Rejoining " + savedRow.name + " means typing it again."; const bits = []; if (savedRow.modelData.active === true) bits.push("Connected"); else if (savedRow.wireless) bits.push(savedRow.modelData.inRange === true ? "In range" : "Out of range"); else bits.push(String(savedRow.modelData.type ?? "")); bits.push(savedRow.modelData.autoconnect === true ? "autoconnects" : "never autoconnects"); return bits.join(" · "); } value: savedRow.modelData.active === true ? "this one" : "" controlWidth: 200 divider: savedRow.index < root.savedShown.length - 1 || root.savedHidden > 0 || root.savedShowAll ConfirmAction { id: forgetSaved anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter visible: savedRow.modelData.active !== true actionId: "forget-saved-network:" + savedRow.name armText: "Forget…" confirmText: "Forget it" enabled: !NetworkTools.busy onConfirmed: NetworkTools.forget(savedRow.name) } } } SettingRow { width: parent.width visible: root.savedHidden > 0 || (root.savedShowAll && NetworkTools.savedConnections.length > root.savedCap) label: root.savedShowAll ? "Show fewer" : root.savedHidden + (root.savedHidden === 1 ? " more" : " more") detail: root.savedShowAll ? "" : "Folded to keep the list short — the ones you use least are at the bottom" activatable: true divider: false onActivated: root.savedShowAll = !root.savedShowAll } } // ── 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()) } } }