Files

565 lines
23 KiB
QML

// 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 hidden-network form. Its passphrase lives here only while the form is
// open: the form is a Loader, so closing it destroys the field, and
// closeHidden empties this alongside it.
property bool hiddenOpen: false
property string hiddenSsid: ""
property string hiddenSecurity: "wpa-psk"
property string hiddenPassword: ""
readonly property bool hiddenReady: root.hiddenSsid.trim() !== ""
&& (root.hiddenSecurity === "none" || root.hiddenPassword !== "")
function closeHidden(): void {
root.hiddenOpen = false;
root.hiddenSsid = "";
root.hiddenSecurity = "wpa-psk";
root.hiddenPassword = "";
}
// 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 = "";
}
// closeAll is called from row activation, which the hidden form is not part
// of -- opening a network's drawer should not throw away a half-typed
// hidden SSID, but joining one should close the form.
// 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
connection: entry.ssid
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
}
// ── A network that does not say it is there ─────────────────────────────
//
// A hidden network cannot appear in the list above by definition, so the
// only way in is to name it. This was the last thing on the Wi-Fi card that
// sent people back to GNOME's panel.
ActionRow {
width: parent.width
visible: Connectivity.wifiDevice !== null && Connectivity.wifiEnabled
label: "Join a hidden network…"
detail: "A network that does not broadcast its name — you type the name and its security"
action: root.hiddenOpen ? "Cancel" : "Join…"
enabled: !NetworkTools.busy
divider: root.hiddenOpen
onTriggered: {
if (root.hiddenOpen) {
root.closeHidden();
return;
}
root.closeAll();
root.hiddenOpen = true;
}
}
// Loaded rather than hidden, for the same reason the enterprise form is:
// closing it destroys the field, and with it the passphrase that was typed.
Loader {
id: hiddenLoader
width: parent.width
active: root.hiddenOpen
visible: hiddenLoader.active
sourceComponent: Column {
width: hiddenLoader.width
TextFieldRow {
width: parent.width
label: "Network name"
detail: "Exactly as whoever runs the network wrote it — a hidden network is found by name, so a typo simply never connects"
placeholder: "office-private"
text: root.hiddenSsid
enabled: !NetworkTools.busy
onAccepted: value => root.hiddenSsid = value.trim()
}
OptionPickerRow {
width: parent.width
label: "Security"
detail: "What the network expects. The wrong one associates and then fails, with nothing to say why."
enabled: !NetworkTools.busy
options: [
{
value: "wpa-psk",
label: "WPA2 (password)",
detail: "What almost every home and office network uses"
},
{
value: "sae",
label: "WPA3 (password)",
detail: "Newer, and refused outright by anything older"
},
{
value: "none",
label: "Open",
detail: "No password at all"
}
]
current: root.hiddenSecurity
onPicked: value => {
root.hiddenSecurity = String(value);
// An open network has no passphrase, so a passphrase typed
// before the mode changed must not sit in memory waiting to
// be sent to a network that will not ask for one.
if (root.hiddenSecurity === "none")
root.hiddenPassword = "";
}
}
SecretFieldRow {
width: parent.width
visible: root.hiddenSecurity !== "none"
label: "Password"
detail: "Handed to NetworkManager down a pipe, never as a command argument"
enabled: !NetworkTools.busy
onChanged: value => root.hiddenPassword = value
}
ActionRow {
width: parent.width
label: "Join this network"
detail: root.hiddenSsid.trim() === ""
? "Give the network a name first."
: (root.hiddenSecurity !== "none" && root.hiddenPassword === ""
? "This network needs a password."
: "Saves a profile that probes for " + root.hiddenSsid.trim()
+ " by name, and connects to it.")
action: NetworkTools.busy ? "Joining…" : "Join"
enabled: !NetworkTools.busy && root.hiddenReady
divider: false
onTriggered: {
if (!root.hiddenReady)
return;
const ssid = root.hiddenSsid.trim();
NetworkTools.joinHidden(ssid, ssid, root.hiddenSecurity,
root.hiddenPassword);
root.closeHidden();
}
}
}
}
}