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:
Gabriel Brown
2026-08-24 16:31:52 -04:00
parent aba2d16ffa
commit b30bf40407
29 changed files with 4452 additions and 241 deletions
@@ -84,6 +84,30 @@ Singleton {
readonly property bool wifiEnabled: Networking.wifiEnabled
readonly property bool wifiAvailable: Networking.wifiHardwareEnabled
// The radio switches, as functions rather than as writes a page makes for
// itself. Both are one-line assignments to the native modules, which is
// exactly the point: a page that reached past this service to set
// Networking.wifiEnabled directly would be one page's worth of the rule
// this file exists to keep -- and the next such write, needing a retry or a
// guard, would have nowhere to live but the page.
//
// Native both ways. Nothing here shells out to nmcli or rfkill; airplane
// mode, which genuinely needs rfkill, lives in NetworkTools.qml instead.
function setWifiEnabled(enabled: bool): void {
if (Networking.wifiEnabled !== enabled)
Networking.wifiEnabled = enabled;
}
function setBluetoothEnabled(enabled: bool): void {
const device = root.adapter;
if (!device || device.enabled === enabled)
return;
device.enabled = enabled;
}
readonly property bool bluetoothEnabled: root.adapter?.enabled ?? false
readonly property bool bluetoothAvailable: !!root.adapter
// Current network, then saved, then by signal -- the order GNOME uses,
// which is the order you actually look for things in.
readonly property var networks: {
@@ -124,15 +148,37 @@ Singleton {
&& network.security !== WifiSecurityType.Unknown;
}
// Member names come from the installed plugin's own qmltypes --
// Quickshell/Networking/quickshell-network.qmltypes, whose WifiSecurityType
// is exactly: Wpa3SuiteB192, Sae, Wpa2Eap, Wpa2Psk, WpaEap, WpaPsk,
// StaticWep, DynamicWep, Leap, Owe, Open, Unknown -- rather than the
// GNOME-style names this switch used to test (Wep, Wpa, Wpa2, Wpa3,
// Enterprise). None of those five exist, and a `case` against an undefined
// member simply never matches, so every secured network read "Secured" and
// "Enterprise" was unreachable. The enterprise join form keys off exactly
// that string, so the whole 802.1X flow was dead.
//
// The open cases are named here rather than delegated to isSecured(),
// which treats Unknown as unsecured. Calling a network whose security
// nobody could read "Open" is a claim this cannot back up; Unknown falls
// through to "Secured" instead. isSecured() keeps its own meaning -- "does
// joining this need a passphrase" -- and is unchanged.
//
// Owe is Enhanced Open: encrypted, but joined without a passphrase, so to
// someone picking a network it reads as open.
function securityLabel(network: var): string {
if (!root.isSecured(network))
return "Open";
switch (network.security) {
case WifiSecurityType.Wep: return "WEP";
case WifiSecurityType.Wpa: return "WPA";
case WifiSecurityType.Wpa2: return "WPA2";
case WifiSecurityType.Wpa3: return "WPA3";
case WifiSecurityType.Enterprise: return "Enterprise";
case WifiSecurityType.Open: return "Open";
case WifiSecurityType.Owe: return "Open";
case WifiSecurityType.StaticWep: return "WEP";
case WifiSecurityType.WpaPsk: return "WPA";
case WifiSecurityType.Wpa2Psk: return "WPA2";
case WifiSecurityType.Sae: return "WPA3";
case WifiSecurityType.WpaEap: return "Enterprise";
case WifiSecurityType.Wpa2Eap: return "Enterprise";
case WifiSecurityType.Wpa3SuiteB192: return "Enterprise";
case WifiSecurityType.DynamicWep: return "Enterprise";
case WifiSecurityType.Leap: return "Enterprise";
}
return "Secured";
}
@@ -152,10 +198,14 @@ Singleton {
return "No signal";
}
// NoSecrets, not "Authentication" -- the qmltypes members are NoSecrets,
// Unknown, WifiAuthTimeout, WifiClientDisconnected, WifiClientFailed,
// WifiNetworkLost. A case against an undefined member never matches, so a
// wrong password used to fall through to the generic text.
function connectionFailureText(reason: var): string {
switch (reason) {
case ConnectionFailReason.WifiAuthTimeout:
case ConnectionFailReason.Authentication:
case ConnectionFailReason.NoSecrets:
return "Wrong password";
case ConnectionFailReason.WifiNetworkLost:
return "Network out of range";
@@ -57,6 +57,12 @@ Singleton {
return root.exposed.filter(entry => root.allowedByRange(entry));
}
// zone name -> the helper's zone-info shape, for the zone browser. Cached
// because browsing means asking about the same handful of zones as a chip
// row repaints, and each answer is two firewall-cmd calls.
property var zoneDetails: ({})
property var pendingZones: []
function refresh(): void {
if (query.running)
return;
@@ -64,6 +70,52 @@ Singleton {
query.running = true;
}
// What a zone allows, for reading before choosing one. Returns the cached
// answer, or null while the first one is on its way -- and asks for it, so
// a chip that binds to this fills itself in.
//
// Read-only, so it needs no authorization and never prompts: this is the
// difference between looking at a zone and moving an interface into it.
function zoneInfo(zoneName: string): var {
if (zoneName === "")
return null;
if (root.zoneDetails[zoneName] !== undefined)
return root.zoneDetails[zoneName];
root.requestZoneInfo(zoneName);
return null;
}
function requestZoneInfo(zoneName: string): void {
if (zoneName === "" || root.pendingZones.indexOf(zoneName) >= 0)
return;
root.pendingZones = root.pendingZones.concat([zoneName]);
root.drainZones();
}
function drainZones(): void {
if (zoneQuery.running || root.pendingZones.length === 0)
return;
zoneQuery.subject = root.pendingZones[0];
zoneQuery.command = [root.helperPath, "zone-info", zoneQuery.subject];
zoneQuery.running = true;
}
function absorbZone(zoneName: string, text: string): void {
try {
const parsed = JSON.parse(text);
// Reassigned rather than mutated: QML does not notice a property
// change made inside a var object.
const next = Object.assign({}, root.zoneDetails);
next[zoneName] = parsed;
root.zoneDetails = next;
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
} catch (error) {
root.lastError = "Could not read what that zone allows.";
console.warn("Firewall: could not parse zone-info output:", error);
}
}
function absorb(text: string): void {
try {
const parsed = JSON.parse(text);
@@ -90,6 +142,10 @@ Singleton {
if (mutation.running)
return;
root.lastError = "";
// Every mutation here can change what a zone allows or which zone an
// interface is in, so the browser's cached descriptions are dropped
// rather than left to describe the firewall as it used to be.
root.zoneDetails = ({});
mutation.command = [root.helperPath].concat(arguments);
mutation.running = true;
}
@@ -120,4 +176,31 @@ Singleton {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
// Kept out of `busy` on purpose: reading what a zone allows changes nothing,
// so it must not disable the buttons that do.
Process {
id: zoneQuery
property string subject: ""
stdout: StdioCollector {
onStreamFinished: root.absorbZone(zoneQuery.subject, this.text)
}
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: {
root.pendingZones = root.pendingZones.filter(name => name !== zoneQuery.subject);
zoneDrain.restart();
}
}
// One tick later, because `running` has not gone false inside onExited and
// the queue would stall on its own guard.
Timer {
id: zoneDrain
interval: 0
onTriggered: root.drainZones()
}
}
@@ -0,0 +1,392 @@
pragma Singleton
// The networking Quickshell has no surface for, so that Connectivity.qml can
// stay the pure-native thing it is pinned to be.
//
// Scanning, joining and pairing already work over DBus in Connectivity.qml, and
// nothing there may shell out. Everything NetworkManager knows but does not
// expose -- a connection's addresses, autoconnect, MAC randomisation, VPN
// import, hotspots, enterprise sign-in -- plus the system proxy and the radio
// kill switches, which are not NetworkManager's at all, live here and go
// through scripts/panama-network.
//
// The split is the point: one file that may never grow a Process, and one that
// is nothing but.
//
// Details are cached per connection rather than fetched on every paint. A
// details grid asks for the same connection every time it repaints, and each
// answer is five nmcli invocations.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.env("PANAMA_NETWORK_HELPER")
|| Quickshell.shellDir + "/scripts/panama-network"
// Set by the page while it is on screen. Networking state changes whether
// or not anyone is looking; re-reading it when nobody is costs nmcli
// invocations for an answer that will be stale again by the time it matters.
property bool active: false
// connection name -> the helper's connection shape. See detailsFor().
property var details: ({})
property var hotspot: ({})
property string proxyMode: "none"
property string proxyHost: ""
// A string, not a number, because the field that shows it is a text field:
// "no port set" and "port 0" are different answers, and only one of them is
// true of a proxy nobody has configured.
property string proxyPort: ""
property string proxyPac: ""
property bool airplaneOn: false
property bool airplaneHardBlocked: false
// The connection the last import produced, so the page can say which
// profile appeared rather than "done".
property string lastImport: ""
property string lastError: ""
// Guards read the Process objects directly; a derived binding is stale
// inside the handler that changes it. See DefaultApps.qml.
readonly property bool busy: mutation.running || enterprise.running || detailsQuery.running
// Connections whose details have been asked for, in order, so a burst of
// requests becomes one query at a time rather than one Process each.
property var pendingDetails: []
// Held only between "join" and the moment the helper's stdin is open.
property string pendingPassword: ""
readonly property bool hotspotActive: root.hotspot?.active === true
readonly property string hotspotSsid: String(root.hotspot?.ssid ?? "")
// Returned once by the verb that started or found the hotspot, for display
// beside a QR code. Never stored beyond the reply that carried it, and
// never logged.
readonly property string hotspotPassword: String(root.hotspot?.password ?? "")
readonly property string proxySummary: {
if (root.proxyMode === "manual")
return root.proxyHost !== "" ? root.proxyHost + ":" + root.proxyPort : "Manual";
if (root.proxyMode === "auto")
return root.proxyPac !== "" ? root.proxyPac : "Automatic";
return "Off";
}
// What a page binds to. Returns the cached answer, or null while the first
// one is on its way -- and asks for it, so a grid that binds to this fills
// itself in without the page having to sequence a fetch.
function detailsFor(connection: string): var {
if (connection === "")
return null;
if (root.details[connection] !== undefined)
return root.details[connection];
root.requestDetails(connection);
return null;
}
function requestDetails(connection: string): void {
if (connection === "")
return;
if (root.pendingDetails.indexOf(connection) >= 0)
return;
root.pendingDetails = root.pendingDetails.concat([connection]);
root.drainDetails();
}
// Forces a re-read of a connection already in the cache -- what the page
// calls when the active connection changes, because addresses and the MAC
// in use both change with it.
function refreshDetails(connection: string): void {
root.requestDetails(connection);
}
function drainDetails(): void {
if (detailsQuery.running || root.pendingDetails.length === 0)
return;
detailsQuery.subject = root.pendingDetails[0];
detailsQuery.command = [root.helperPath, "details", detailsQuery.subject];
detailsQuery.running = true;
}
// One connection's answer into the cache. Reassigned rather than mutated:
// QML does not notice a property change inside a var object.
function absorbDetails(connection: string, text: string): void {
try {
const parsed = JSON.parse(text);
const next = Object.assign({}, root.details);
next[connection] = parsed;
root.details = next;
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
} catch (error) {
root.lastError = "Could not read the network helper's answer.";
console.warn("NetworkTools: could not parse details:", error);
}
}
function absorbHotspot(text: string): void {
try {
const parsed = JSON.parse(text);
root.hotspot = parsed;
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
} catch (error) {
root.lastError = "Could not read the hotspot's state.";
}
}
function absorbProxy(text: string): void {
try {
const parsed = JSON.parse(text);
root.proxyMode = String(parsed.mode ?? "none");
root.proxyHost = String(parsed.host ?? "");
root.proxyPort = Number(parsed.port ?? 0) > 0 ? String(parsed.port) : "";
root.proxyPac = String(parsed.pacUrl ?? "");
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
} catch (error) {
root.lastError = "Could not read the proxy setting.";
}
}
function absorbAirplane(text: string): void {
try {
const parsed = JSON.parse(text);
root.airplaneOn = parsed.on === true;
root.airplaneHardBlocked = parsed.hardBlocked === true;
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
} catch (error) {
root.lastError = "Could not read the radio state.";
}
}
function absorbImport(text: string): void {
try {
const parsed = JSON.parse(text);
root.lastImport = String(parsed.name ?? "");
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
} catch (error) {
root.lastError = "Could not read the import result.";
}
}
// Which absorber a reply belongs to. The helper answers each verb with that
// verb's shape, so the caller records what it asked for rather than the
// reader guessing from the keys.
function absorb(shape: string, subject: string, text: string): void {
switch (shape) {
case "connection": root.absorbDetails(subject, text); break;
case "hotspot": root.absorbHotspot(text); break;
case "proxy": root.absorbProxy(text); break;
case "airplane": root.absorbAirplane(text); break;
case "import": root.absorbImport(text); break;
}
}
function run(shape: string, subject: string, argv: var): void {
if (mutation.running)
return;
root.lastError = "";
mutation.shape = shape;
mutation.subject = subject;
mutation.command = [root.helperPath].concat(argv);
mutation.running = true;
}
// ---- per-connection
function forget(connection: string): void {
root.run("connection", connection, ["forget", connection]);
}
function setAutoconnect(connection: string, enabled: bool): void {
root.run("connection", connection,
["set-autoconnect", connection, enabled ? "true" : "false"]);
}
function setMacRandom(connection: string, enabled: bool): void {
root.run("connection", connection,
["set-mac-random", connection, enabled ? "true" : "false"]);
}
// ---- VPN
function importVpn(path: string): void {
root.lastImport = "";
root.run("import", "", ["import-vpn", path]);
}
// ---- hotspot
function refreshHotspot(): void { root.run("hotspot", "", ["hotspot", "status"]); }
function startHotspot(ssid: string): void { root.run("hotspot", "", ["hotspot", "start", ssid]); }
function stopHotspot(): void { root.run("hotspot", "", ["hotspot", "stop"]); }
// ---- proxy
function refreshProxy(): void { root.run("proxy", "", ["proxy", "get"]); }
// The dropdown: switch which kind of proxy applies, leaving whatever
// address is already stored alone. The fields for a manual or automatic
// proxy only appear once its mode is chosen, so a mode that demanded them
// up front would be a mode nobody could pick.
function setProxyMode(mode: string): void {
root.run("proxy", "", ["proxy", "set", mode]);
}
function setProxyOff(): void { root.setProxyMode("none"); }
// Host and port together, because a proxy is only usable as a pair.
function setProxyManual(host: string, port: string): void {
root.run("proxy", "", ["proxy", "set", "manual", String(host), String(port)]);
}
function setProxyPac(pacUrl: string): void {
root.run("proxy", "", ["proxy", "set", "auto", pacUrl]);
}
// ---- radios
function refreshAirplane(): void { root.run("airplane", "", ["airplane", "status"]); }
function setAirplane(enabled: bool): void {
root.run("airplane", "", ["airplane", "set", enabled ? "true" : "false"]);
}
function toggleAirplane(): void { root.setAirplane(!root.airplaneOn); }
// ---- enterprise Wi-Fi
// The password goes down the helper's stdin and is never an argument.
// argv is world-readable through /proc for the life of the process, so a
// password passed that way is published to every process on the machine --
// which is why this has a Process of its own rather than reusing the one
// above: only this one ever opens stdin.
function joinEnterprise(ssid: string, profile: string, identity: string,
password: string, caCert: string): void {
if (enterprise.running)
return;
root.lastError = "";
root.pendingPassword = password;
enterprise.subject = ssid;
enterprise.command = String(caCert ?? "") !== ""
? [root.helperPath, "join-enterprise", ssid, profile, identity, caCert]
: [root.helperPath, "join-enterprise", ssid, profile, identity];
enterprise.stdinEnabled = true;
enterprise.running = true;
}
// Everything that is not per-connection, in one call: what a page asks for
// when it opens.
function refresh(): void {
root.refreshProxy();
root.refreshAirplaneSoon();
root.refreshHotspotSoon();
for (const connection in root.details)
root.requestDetails(connection);
}
// The mutation Process is one at a time, so the opening reads are spread
// over it rather than dropped by its running guard.
function refreshAirplaneSoon(): void { airplaneSoon.restart(); }
function refreshHotspotSoon(): void { hotspotSoon.restart(); }
onActiveChanged: if (root.active) root.refresh()
Timer {
id: airplaneSoon
interval: 120
onTriggered: {
if (mutation.running)
airplaneSoon.restart();
else
root.refreshAirplane();
}
}
Timer {
id: hotspotSoon
interval: 260
onTriggered: {
if (mutation.running)
hotspotSoon.restart();
else
root.refreshHotspot();
}
}
// The next queued details read, one tick after the last one exits. Draining
// from inside onExited would look at a `running` that has not gone false
// yet, and the queue would stall on its own guard.
Timer {
id: detailsDrain
interval: 0
onTriggered: root.drainDetails()
}
Process {
id: detailsQuery
property string subject: ""
stdout: StdioCollector {
onStreamFinished: root.absorbDetails(detailsQuery.subject, this.text)
}
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: {
root.pendingDetails = root.pendingDetails.filter(name => name !== detailsQuery.subject);
detailsDrain.restart();
}
}
Process {
id: mutation
// What the reply is, recorded when the command is built. The helper
// answers each verb with that verb's own shape.
property string shape: "connection"
property string subject: ""
// Mutations answer with the fresh state, so the page updates from the
// change itself rather than asking again afterwards.
stdout: StdioCollector {
onStreamFinished: root.absorb(mutation.shape, mutation.subject, this.text)
}
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
Process {
id: enterprise
property string subject: ""
onStarted: {
enterprise.write(root.pendingPassword + "\n");
// Held for as long as it takes to hand over, and no longer.
root.pendingPassword = "";
// Closing stdin is what lets the helper's read return; without it
// the join waits forever for a line that is already sent.
enterprise.stdinEnabled = false;
}
stdout: StdioCollector {
onStreamFinished: root.absorbDetails(enterprise.subject, this.text)
}
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: root.pendingPassword = ""
}
}
+105
View File
@@ -74,6 +74,12 @@ Singleton {
return count;
}
// printer name -> the helper's get-options shape. Cached because a pair of
// dropdowns asks for the same printer on every repaint, and each answer is
// a round trip to CUPS.
property var options: ({})
property var pendingOptions: []
function refresh(): void {
if (query.running)
return;
@@ -81,6 +87,48 @@ Singleton {
query.running = true;
}
// Paper size and two-sided, as the printer reports them. Returns the cached
// answer, or null while the first one is on its way -- and asks for it, so
// a dropdown that binds to this fills itself in.
function optionsFor(name: string): var {
if (name === "")
return null;
if (root.options[name] !== undefined)
return root.options[name];
root.requestOptions(name);
return null;
}
function requestOptions(name: string): void {
if (name === "" || root.pendingOptions.indexOf(name) >= 0)
return;
root.pendingOptions = root.pendingOptions.concat([name]);
root.drainOptions();
}
function refreshOptions(name: string): void { root.requestOptions(name); }
function drainOptions(): void {
if (optionsQuery.running || root.pendingOptions.length === 0)
return;
optionsQuery.subject = root.pendingOptions[0];
optionsQuery.command = [root.helperPath, "get-options", optionsQuery.subject];
optionsQuery.running = true;
}
// Reassigned rather than mutated: QML does not notice a property change
// made inside a var object.
function absorbOptions(parsed: var): void {
const name = String(parsed?.printer ?? "");
if (name === "")
return;
const next = Object.assign({}, root.options);
next[name] = parsed;
root.options = next;
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
}
function search(): void {
if (root.searching)
return;
@@ -96,6 +144,11 @@ Singleton {
root.jobs = Array.isArray(parsed.jobs) ? parsed.jobs : [];
root.service = parsed.service ?? ({});
root.lastError = String(parsed.error ?? "");
// set-option answers with the snapshot AND the printer it touched,
// re-read -- so the dropdown that made the change updates from the
// reply rather than from a second round trip.
if (parsed.options)
root.absorbOptions(parsed.options);
} catch (error) {
root.lastError = "Could not read the printing service's answer.";
console.warn("Printers: could not parse helper output:", error);
@@ -119,6 +172,23 @@ Singleton {
function cancel(jobId: int): void { root.run(["cancel", String(jobId)]); }
function testPage(name: string): void { root.run(["test-page", name]); }
// Holding keeps the job in the queue; cancelling throws it away. The
// difference matters because "stop this print" and "reprint fifty pages"
// are not meant to be the same button.
function hold(jobId: int): void { root.run(["hold", String(jobId)]); }
function release(jobId: int): void { root.run(["release", String(jobId)]); }
// One printer default, from the helper's closed vocabulary: media is
// Letter, A4 or Legal; sides is one-sided or one of the two two-sided
// bindings. Anything else the helper refuses -- there is no passthrough to
// lpadmin here.
function setOption(name: string, key: string, value: string): void {
root.run(["set-option", name, key, value]);
}
// Whether a job is held, from the state the helper reports.
function isHeld(job: var): bool { return String(job?.state ?? "") === "held"; }
// A queue name CUPS will accept, derived from what the printer calls itself.
function suggestedName(label: string): string {
const cleaned = String(label).replace(/[^A-Za-z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "");
@@ -143,6 +213,41 @@ Singleton {
}
}
// Kept out of `busy`: reading a printer's defaults changes nothing, so it
// must not disable the controls that do.
Process {
id: optionsQuery
property string subject: ""
stdout: StdioCollector {
onStreamFinished: {
try {
root.absorbOptions(JSON.parse(this.text));
} catch (error) {
root.lastError = "Could not read that printer's settings.";
console.warn("Printers: could not parse get-options output:", error);
}
}
}
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: {
root.pendingOptions = root.pendingOptions.filter(
name => name !== optionsQuery.subject);
optionsDrain.restart();
}
}
// One tick later, because `running` has not gone false inside onExited and
// the queue would stall on its own guard.
Timer {
id: optionsDrain
interval: 0
onTriggered: root.drainOptions()
}
Process {
id: discovery
stdout: StdioCollector {
@@ -75,9 +75,22 @@ Singleton {
{ label: "Help", detail: "The manual", page: "manual" },
{ label: "Timezone", detail: "Set the system timezone", page: "datetime" },
{ label: "Network time", detail: "Synchronize the clock with a time server", page: "datetime" },
{ label: "Wi-Fi", detail: "Managed by GNOME Settings", page: "connectivity" },
{ label: "Bluetooth", detail: "Managed by GNOME Settings", page: "connectivity" },
{ label: "Printers", detail: "Managed by GNOME Settings", page: "connectivity" },
// Connections. These said "Managed by GNOME Settings" for as long as
// that was true; it stopped being true when the page grew VPN, hotspot,
// proxy and per-connection details, and a search result that hands the
// user to another application for something this page now does is worse
// than no result at all. The Printers entry here was also a second copy
// of the one that routes to the Printers page.
{ label: "Wi-Fi", detail: "Join a network, see the one you are on, and share it", page: "connectivity" },
{ label: "Bluetooth", detail: "Pair and connect devices", page: "connectivity" },
{ label: "VPN", detail: "Turn a tunnel on, and see which one is up", page: "connectivity" },
{ label: "Import a VPN", detail: "Add a WireGuard or OpenVPN profile from a file", page: "connectivity" },
{ label: "Hotspot", detail: "Share this machine's connection over Wi-Fi", page: "connectivity" },
{ label: "Airplane mode", detail: "Turn every radio off at once", page: "connectivity" },
{ label: "Network proxy", detail: "Send traffic through a proxy, or a PAC file", page: "connectivity" },
{ label: "IP address", detail: "The address, gateway, DNS servers, and hardware address of a connection", page: "connectivity" },
{ label: "Forget a Wi-Fi network", detail: "Remove a saved network so it stops connecting on its own", page: "connectivity" },
{ label: "Enterprise Wi-Fi", detail: "Join a network that asks for an identity and a password", page: "connectivity" },
{ label: "Default applications", detail: "Browser, mail, files", page: "applications" },
{ label: "User account", detail: "Your name, picture, and password", page: "users" },
{ label: "Profile picture", detail: "The avatar shown on the lock screen and in the Control Center", page: "users" },