// 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. // // The connected network opens: addresses, whether it comes back on its own, // whether this machine shows the same hardware address to it every time, a QR // code for a guest, and the way out. Those five were the whole reason this page // still had a door back to GNOME's Wi-Fi panel. // // Three ways in, and they are genuinely different networks rather than three // styles of the same one: // // * open or already known -- connect, nothing to type; // * WPA with a passphrase -- an inline field, revealed on the row you clicked // rather than in a dialog over a tiled window; // * 802.1X enterprise -- a form, because a passphrase field cannot join // eduroam and pretending otherwise is how people ended up in a terminal. // // Forgetting is two-stage and says what it costs, per the danger pattern the // rest of Settings uses: the arming press changes the row's copy, and only the // second press does anything. import QtQuick import Quickshell import Quickshell.Networking import qs.config import qs.services import qs.widgets Column { id: root spacing: 0 // Exactly one of these is non-empty at a time, so the panel never shows two // ways to join the same network or two networks half-open at once. property string expandedFor: "" property string passwordFor: "" property string enterpriseFor: "" property string confirmingForget: "" property string failedSsid: "" property string failedText: "" // The list shows what matters and folds the rest: connected and saved // networks always render (they lead the sort, so a slice keeps them), and // strangers fill the remaining slots up to the cap. Everything else waits // behind the "more networks" row -- an apartment building's worth of // neighbors' SSIDs is noise, not a list. property bool showAll: false readonly property int visibleCap: 6 readonly property var shown: { const list = Connectivity.networks; if (root.showAll || list.length <= root.visibleCap) return list; const pinned = list.filter(network => network.connected || network.known).length; return list.slice(0, Math.max(root.visibleCap, pinned)); } readonly property int hiddenCount: Connectivity.networks.length - root.shown.length function isEnterprise(network: var): bool { return Connectivity.securityLabel(network) === "Enterprise"; } function closeAll(): void { root.expandedFor = ""; root.passwordFor = ""; root.enterpriseFor = ""; root.confirmingForget = ""; } // One click on a row means whatever that row's state makes it mean. The // connected network opens rather than reconnecting to itself. function activate(network: var): void { root.failedSsid = ""; const name = network.name; if (network.connected) { const wasOpen = root.expandedFor === name; root.closeAll(); root.expandedFor = wasOpen ? "" : name; return; } if (root.isEnterprise(network)) { const wasOpen = root.enterpriseFor === name; root.closeAll(); root.enterpriseFor = wasOpen ? "" : name; return; } if (network.known || !Connectivity.isSecured(network)) { root.closeAll(); network.connect(); return; } const wasOpen = root.passwordFor === name; root.closeAll(); root.passwordFor = wasOpen ? "" : name; } Repeater { model: root.shown Column { id: entry required property var modelData required property int index readonly property string ssid: entry.modelData.name || "" readonly property bool open: root.expandedFor === entry.ssid && entry.ssid !== "" readonly property bool joining: root.passwordFor === entry.ssid && entry.ssid !== "" readonly property bool enterprising: root.enterpriseFor === entry.ssid && entry.ssid !== "" readonly property bool confirming: root.confirmingForget === entry.ssid && entry.ssid !== "" // Addresses for the connected network, once the helper has answered. // Null means "not read yet", which the details grid renders as such // rather than as "no address". readonly property var details: entry.modelData.connected && entry.ssid !== "" ? NetworkTools.detailsFor(entry.ssid) : null // The saved profile behind this SSID, if it holds a passphrase a QR // code could carry. Networks with no stored key cannot be shared. readonly property var shareEntry: WifiShare.shareable.find( candidate => String(candidate.ssid ?? "") === entry.ssid) ?? null 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(root.isEnterprise(entry.modelData) ? "Enterprise (802.1X)" : Connectivity.securityLabel(entry.modelData)); return bits.join(" · "); } // When the drawer is open its own last row carries the hairline, // so the header does not draw one immediately above it. divider: entry.open ? false : (entry.index < root.shown.length - 1 || root.hiddenCount > 0 || root.showAll || entry.joining || entry.enterprising) controlWidth: 190 activatable: true onActivated: root.activate(entry.modelData) Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 7 SettingsButton { anchors.verticalCenter: parent.verticalCenter visible: entry.modelData.connected text: "Disconnect" onClicked: { root.closeAll(); entry.modelData.disconnect(); } } SettingsButton { anchors.verticalCenter: parent.verticalCenter visible: !entry.modelData.connected text: root.isEnterprise(entry.modelData) ? (entry.enterprising ? "Cancel" : "Join…") : (entry.modelData.known ? "Connect" : "Join") onClicked: root.activate(entry.modelData) } // Only the connected row opens, so only it gets a caret. Text { anchors.verticalCenter: parent.verticalCenter visible: entry.modelData.connected text: entry.open ? "▴" : "▾" color: Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall } } } // ── The connected network, opened ──────────────────────────────── Column { width: parent.width visible: entry.open ConnectionDetails { width: parent.width details: entry.details } // These two describe a saved profile, and until the helper has // answered this panel does not know what the profile says. A // switch drawn on a guess is a switch that lies about the state // it is reporting. SwitchRow { width: parent.width visible: !!entry.details label: "Connect automatically" detail: "Rejoin this network whenever it is in range" checked: entry.details?.autoconnect !== false enabled: !NetworkTools.busy onToggled: value => NetworkTools.setAutoconnect(entry.ssid, value) } SwitchRow { width: parent.width visible: !!entry.details label: "Randomize MAC address" detail: entry.details?.macRandomized === true ? "This network sees a made-up hardware address, so it cannot follow this machine between visits. Takes effect on the next reconnect." : "Show this network a made-up hardware address instead of the adapter's real one. Takes effect on the next reconnect." checked: entry.details?.macRandomized === true enabled: !NetworkTools.busy onToggled: value => NetworkTools.setMacRandom(entry.ssid, value) } ActionRow { width: parent.width visible: entry.shareEntry !== null label: "Share this network" detail: WifiShare.sharing === String(entry.shareEntry?.name ?? "") ? "Anyone who can see this screen can join" : "Shows a QR code a phone can scan, so nobody has to read the password out" action: WifiShare.sharing === String(entry.shareEntry?.name ?? "") ? "Hide" : "Show code" onTriggered: WifiShare.sharing === String(entry.shareEntry?.name ?? "") ? WifiShare.stopSharing() : WifiShare.share(String(entry.shareEntry?.name ?? "")) } // Drawn at its natural size on a white plate: a QR code inverted // or tinted to match a dark theme is unreliable to scan, and this // one has exactly one job. Item { width: parent.width visible: entry.shareEntry !== null && WifiShare.sharing === String(entry.shareEntry?.name ?? "") && WifiShare.imagePath !== "" implicitHeight: visible ? plate.height + 20 : 0 Rectangle { id: plate anchors.horizontalCenter: parent.horizontalCenter y: 10 width: 208 height: 208 radius: 10 color: "white" Image { anchors.centerIn: parent width: 184 height: 184 smooth: false fillMode: Image.PreserveAspectFit cache: false source: WifiShare.imagePath !== "" ? "file://" + WifiShare.imagePath : "" } } } SettingRow { width: parent.width label: "Forget this network" detail: entry.confirming ? "This disconnects now and deletes the saved password. Rejoining " + entry.ssid + " means typing it again." : "Remove the saved profile for " + entry.ssid controlWidth: 200 divider: entry.index < root.shown.length - 1 || root.hiddenCount > 0 || root.showAll Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 8 SettingsButton { text: entry.confirming ? "Keep it" : "Forget…" enabled: !NetworkTools.busy onClicked: root.confirmingForget = entry.confirming ? "" : entry.ssid } SettingsButton { visible: entry.confirming text: "Forget" tone: "danger" enabled: !NetworkTools.busy onClicked: { root.closeAll(); NetworkTools.forget(entry.ssid); } } } } } // ── A passphrase, for the network 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: entry.joining ? 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() } } // ── 802.1X, for the networks a passphrase cannot reach ─────────── // Loaded rather than merely hidden, so closing the form destroys it // and the password that was typed into it. A hidden form keeps its // field, which is both a stale value on the way back in and a // passphrase sitting in a live object for no reason. Loader { id: enterpriseLoader width: parent.width active: entry.enterprising visible: enterpriseLoader.active sourceComponent: EnterpriseJoinForm { ssid: entry.ssid busy: NetworkTools.busy onSubmitted: (eap, identity, secret, caPath) => { NetworkTools.joinEnterprise(entry.ssid, eap, identity, secret, caPath); root.enterpriseFor = ""; } } } Text { width: parent.width visible: root.failedSsid === entry.ssid && entry.ssid !== "" 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.ssid; root.failedText = Connectivity.connectionFailureText(reason); if (!root.isEnterprise(entry.modelData)) root.passwordFor = entry.ssid; } } } } SettingRow { width: parent.width visible: root.hiddenCount > 0 || (root.showAll && Connectivity.networks.length > root.visibleCap) label: root.showAll ? "Show fewer networks" : root.hiddenCount + (root.hiddenCount === 1 ? " more network" : " more networks") detail: root.showAll ? "" : "Weaker signals, folded to keep the list short" activatable: true onActivated: root.showAll = !root.showAll divider: false } 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 } }