565 lines
23 KiB
QML
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();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|