Own the network: details, VPN, enterprise Wi-Fi, and a firewall that can also allow
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -2,12 +2,26 @@
|
||||
//
|
||||
// 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,
|
||||
// carries what you would otherwise open a terminal to find out -- signal,
|
||||
// security, and whether it is a network this machine already knows.
|
||||
//
|
||||
// Joining a secured network reveals an inline password field rather than
|
||||
// failing silently, which is the one interaction the popover already got right
|
||||
// and is worth keeping identical.
|
||||
// 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
|
||||
@@ -21,25 +35,76 @@ Column {
|
||||
|
||||
spacing: 0
|
||||
|
||||
// SSID whose password field is open, and the last failure.
|
||||
// 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 = "";
|
||||
if (network.connected)
|
||||
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.passwordFor = "";
|
||||
root.closeAll();
|
||||
network.connect();
|
||||
return;
|
||||
}
|
||||
root.passwordFor = root.passwordFor === network.name ? "" : network.name;
|
||||
|
||||
const wasOpen = root.passwordFor === name;
|
||||
root.closeAll();
|
||||
root.passwordFor = wasOpen ? "" : name;
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Connectivity.networks
|
||||
model: root.shown
|
||||
|
||||
Column {
|
||||
id: entry
|
||||
@@ -47,6 +112,23 @@ Column {
|
||||
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 {
|
||||
@@ -60,12 +142,20 @@ Column {
|
||||
else if (entry.modelData.known)
|
||||
bits.push("Saved");
|
||||
bits.push(Connectivity.signalLabel(entry.modelData.signalStrength));
|
||||
bits.push(Connectivity.securityLabel(entry.modelData));
|
||||
bits.push(root.isEnterprise(entry.modelData)
|
||||
? "Enterprise (802.1X)"
|
||||
: Connectivity.securityLabel(entry.modelData));
|
||||
return bits.join(" · ");
|
||||
}
|
||||
divider: entry.index < Connectivity.networks.length - 1 || root.passwordFor === entry.modelData.name
|
||||
// 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: !entry.modelData.connected
|
||||
activatable: true
|
||||
onActivated: root.activate(entry.modelData)
|
||||
|
||||
Row {
|
||||
@@ -73,38 +163,162 @@ Column {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 7
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: entry.modelData.connected
|
||||
text: "Connected"
|
||||
color: Theme.accent
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: entry.modelData.connected
|
||||
text: "Disconnect"
|
||||
onClicked: entry.modelData.disconnect()
|
||||
onClicked: {
|
||||
root.closeAll();
|
||||
entry.modelData.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: !entry.modelData.connected
|
||||
text: entry.modelData.known ? "Connect" : "Join"
|
||||
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 password field for this network, when it is the one 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.
|
||||
// ── 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: root.passwordFor === entry.modelData.name ? 54 : 0
|
||||
height: entry.joining ? 54 : 0
|
||||
visible: height > 0
|
||||
clip: true
|
||||
|
||||
@@ -141,9 +355,33 @@ Column {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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.modelData.name
|
||||
visible: root.failedSsid === entry.ssid && entry.ssid !== ""
|
||||
leftPadding: 2
|
||||
bottomPadding: 8
|
||||
text: root.failedText
|
||||
@@ -155,14 +393,28 @@ Column {
|
||||
Connections {
|
||||
target: entry.modelData
|
||||
function onConnectionFailed(reason): void {
|
||||
root.failedSsid = entry.modelData.name;
|
||||
root.failedSsid = entry.ssid;
|
||||
root.failedText = Connectivity.connectionFailureText(reason);
|
||||
root.passwordFor = entry.modelData.name;
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user