// 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: "" 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: NetworkTools.proxyMode === "none" ? "Applications that honour the system proxy use this. Not every application does." : "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: NetworkTools.proxyMode onPicked: value => NetworkTools.setProxyMode(String(value)) } Column { width: parent.width visible: NetworkTools.proxyMode === "manual" // Host and port are written together, because the proxy is only // usable as a pair -- so each field commits with whatever the other // one currently holds, and neither writes a half-configuration. TextFieldRow { width: parent.width label: "Proxy host" detail: "The machine applications should go through" placeholder: "proxy.example.com" text: NetworkTools.proxyHost enabled: !NetworkTools.busy onAccepted: value => { if (value.trim() !== "" && NetworkTools.proxyPort !== "") NetworkTools.setProxyManual(value.trim(), NetworkTools.proxyPort); } } TextFieldRow { width: parent.width label: "Port" detail: "The port that proxy listens on" placeholder: "8080" text: NetworkTools.proxyPort enabled: !NetworkTools.busy divider: false onAccepted: value => { if (value.trim() !== "" && NetworkTools.proxyHost !== "") NetworkTools.setProxyManual(NetworkTools.proxyHost, value.trim()); } } } TextFieldRow { width: parent.width visible: NetworkTools.proxyMode === "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()) } } }