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
@@ -16,6 +16,21 @@ Column {
spacing: 0
// Connected and paired devices always render (they lead the sort, so the
// slice keeps them); unpaired strangers fill up to the cap and the rest
// wait behind the "nearby devices" row. Discovery in a busy room finds
// dozens of phones and TVs that are not yours.
property bool showAll: false
readonly property int visibleCap: 5
readonly property var shown: {
const list = Connectivity.bluetoothDevices;
if (root.showAll || list.length <= root.visibleCap)
return list;
const pinned = list.filter(device => device.connected || device.paired).length;
return list.slice(0, Math.max(root.visibleCap, pinned));
}
readonly property int hiddenCount: Connectivity.bluetoothDevices.length - root.shown.length
function primaryAction(device: var): void {
if (device.connected) {
device.disconnect();
@@ -41,7 +56,7 @@ Column {
}
Repeater {
model: Connectivity.bluetoothDevices
model: root.shown
SettingRow {
id: entry
@@ -52,7 +67,8 @@ Column {
width: parent.width
label: entry.modelData.name || entry.modelData.address || "Unknown device"
detail: root.stateLabel(entry.modelData)
divider: entry.index < Connectivity.bluetoothDevices.length - 1
divider: entry.index < root.shown.length - 1
|| root.hiddenCount > 0 || root.showAll
controlWidth: 200
activatable: !entry.modelData.pairing
onActivated: root.primaryAction(entry.modelData)
@@ -81,6 +97,19 @@ Column {
}
}
SettingRow {
width: parent.width
visible: root.hiddenCount > 0
|| (root.showAll && Connectivity.bluetoothDevices.length > root.visibleCap)
label: root.showAll
? "Show fewer devices"
: root.hiddenCount + (root.hiddenCount === 1 ? " more nearby device" : " more nearby devices")
detail: root.showAll ? "" : "Unpaired devices in range, folded to keep the list short"
activatable: true
onActivated: root.showAll = !root.showAll
divider: false
}
SettingRow {
width: parent.width
visible: Connectivity.bluetoothDevices.length === 0
@@ -0,0 +1,148 @@
// The facts about one connection that otherwise need a terminal.
//
// IP address, gateway, DNS and MAC are the four things people leave this app
// for, and they were the reason the Connections page still pointed at GNOME.
// They are read-only here: editing them properly means static addressing, which
// is a page of its own rather than four fields smuggled into a details drawer.
//
// Nothing here is ever a secret. panama-network's `details` verb returns
// addresses only -- no PSK, no enterprise password -- so this component can be
// shown for any connection without deciding what is safe to draw.
//
// The values are set in the interface face with tabular figures rather than a
// monospaced one. Theme bans monospaced text outright (fontMono is the icon
// face, not a text face), and an address only needs its digits to line up.
import QtQuick
import qs.config
Column {
id: root
// { ip4, gateway, dns: [], mac, macRandomized } as the helper reports it,
// or null while the read has not come back. Null is NOT "no address": the
// component says it is still reading rather than claiming an answer.
property var details: null
// [{ key, value, note }] -- only the facts that actually have a value, so a
// connection with no gateway shows three rows rather than a blank one.
readonly property var facts: {
const source = root.details;
if (!source)
return [];
const rows = [];
const ip4 = String(source.ip4 ?? "");
if (ip4 !== "")
rows.push({ key: "IPv4 address", value: ip4, note: "" });
const ip6 = String(source.ip6 ?? "");
if (ip6 !== "")
rows.push({ key: "IPv6 address", value: ip6, note: "" });
const gateway = String(source.gateway ?? "");
if (gateway !== "")
rows.push({ key: "Gateway", value: gateway, note: "" });
const dns = Array.isArray(source.dns)
? source.dns.map(entry => String(entry)).filter(entry => entry !== "")
: [];
if (dns.length > 0)
rows.push({
key: dns.length === 1 ? "DNS" : "DNS servers",
value: dns.join(" · "),
note: ""
});
const mac = String(source.mac ?? "");
if (mac !== "")
rows.push({
key: "MAC address",
value: mac,
note: source.macRandomized === true ? "randomized" : ""
});
return rows;
}
width: parent ? parent.width : 620
spacing: 5
topPadding: 8
bottomPadding: 10
Repeater {
model: root.facts
Item {
id: fact
required property var modelData
width: parent.width
implicitHeight: Math.max(key.implicitHeight, value.implicitHeight)
Text {
id: key
anchors.left: parent.left
anchors.top: parent.top
width: 132
text: String(fact.modelData.key ?? "")
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
Text {
id: value
anchors.left: key.right
anchors.right: parent.right
anchors.top: parent.top
text: String(fact.modelData.value ?? "")
+ (String(fact.modelData.note ?? "") !== ""
? " · " + String(fact.modelData.note) : "")
color: Theme.fg
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WrapAnywhere
}
}
}
// Two honest empty states, and they say different things. "Still reading"
// is not "no address", and a page that renders the first as the second is
// how a working connection comes to look broken for half a second.
Text {
width: parent.width
visible: !root.details
text: "Reading this connection's addresses…"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Text {
width: parent.width
visible: !!root.details && root.facts.length === 0
text: "NetworkManager reports no addresses for this connection."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
// The helper's own aside -- chiefly "this takes effect on the next
// reconnect" after a MAC randomization change. It belongs to the answer,
// so it is shown with the answer rather than guessed at by the page.
Text {
width: parent.width
visible: String(root.details?.note ?? "") !== ""
topPadding: 4
text: String(root.details?.note ?? "")
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
@@ -1,10 +1,18 @@
// Connections — wired, Wi-Fi, and Bluetooth.
// Connections — the whole network, not the half that was easy.
//
// Wi-Fi and Bluetooth are handled here rather than delegated. Everything goes
// through Quickshell.Networking and Quickshell.Bluetooth -- NetworkManager and
// BlueZ over DBus -- and nothing shells out to nmcli or bluetoothctl. That was
// the founding requirement for this desktop: never having to drop to a terminal
// to join a network.
// This page used to end in a card headed "Owned by Fedora" with two doors back
// to GNOME's panels: one for hidden and enterprise networks, one for VPN and
// proxies. Everything behind those doors now lives here, so the card is gone.
//
// Two mechanisms, deliberately kept apart:
//
// * Wi-Fi and Bluetooth state -- scanning, joining, pairing, the radio
// switches -- go through Quickshell.Networking and Quickshell.Bluetooth,
// which speak to NetworkManager and BlueZ over DBus. Connectivity.qml is
// pinned shell-out-free and stays that way.
// * Everything NetworkManager exposes only through nmcli -- per-connection
// addresses, forgetting a profile, MAC randomization, VPN import, hotspot,
// enterprise join -- goes through NetworkTools.qml and scripts/panama-network.
//
// Scanning follows this page being on screen. Wi-Fi scanning and especially
// Bluetooth discovery hold the radio, and doing either for a list nobody is
@@ -19,26 +27,78 @@ import qs.services
SettingsPage {
id: root
objectName: "connectivity"
title: "Connections"
lede: Connectivity.activeNetwork
? "Connected to " + Connectivity.activeNetwork.name
: "Wi-Fi, Bluetooth, and the things Fedora owns."
lede: {
const wifi = Connectivity.activeNetwork ? Connectivity.activeNetwork.name : "";
if (wifi !== "" && Connectivity.wiredOn)
return "On " + wifi + " and wired.";
if (wifi !== "")
return "On " + wifi + ".";
if (Connectivity.wiredOn)
return "Wired.";
return "Wi-Fi, Bluetooth, VPN, and what this machine can reach.";
}
// Drive the scanners only while this page is the one being shown.
// The wired connection's own drawer, and the two inline forms that are only
// open while someone is filling them in.
property bool wiredOpen: false
property bool hotspotOpen: false
property string hotspotName: ""
property bool importOpen: false
property string importPath: ""
readonly property string wiredConnection:
String(Connectivity.wiredDevice?.network?.name ?? "")
readonly property string activeWifi:
Connectivity.activeNetwork ? Connectivity.activeNetwork.name : ""
// Drive the scanners and the helper only while this page is the one being
// shown. Both cost radio time or nmcli invocations for a list nobody is
// reading.
Component.onCompleted: {
Connectivity.active = true;
NetworkTools.active = true;
if (!WifiShare.scanned)
WifiShare.refresh();
Vpn.refresh();
}
Component.onDestruction: Connectivity.active = false
Component.onDestruction: {
Connectivity.active = false;
NetworkTools.active = false;
}
// Addresses and the MAC in use both change with the connection, so the
// cached details for a network that just came up are stale the moment it
// does.
onActiveWifiChanged: {
if (root.activeWifi !== "")
NetworkTools.refreshDetails(root.activeWifi);
}
onWiredConnectionChanged: {
if (root.wiredConnection !== "")
NetworkTools.refreshDetails(root.wiredConnection);
}
TextRow {
visible: NetworkTools.lastError !== ""
label: "The network needs attention"
detail: NetworkTools.lastError
value: ""
divider: false
}
// ── Wired ────────────────────────────────────────────────────────────────
SettingsCard {
title: "Wired"
visible: Connectivity.wiredDevice !== null
SwitchRow {
label: "Ethernet"
SettingRow {
width: parent.width
label: root.wiredConnection !== "" ? root.wiredConnection : "Ethernet"
// Three states worth telling apart: on, off but plugged in, and
// nothing in the socket. "Not connected" covered all three and
// explained none of them.
@@ -54,13 +114,44 @@ SettingsPage {
// that blamed the hardware for what it had just done itself.
return device.name + " · off";
}
checked: Connectivity.wiredOn
enabled: Connectivity.wiredAvailable
controlWidth: 78
divider: false
onToggled: value => Connectivity.setWired(value)
activatable: root.wiredConnection !== "" && Connectivity.wiredOn
onActivated: root.wiredOpen = !root.wiredOpen
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 9
SettingsToggle {
anchors.verticalCenter: parent.verticalCenter
checked: Connectivity.wiredOn
enabled: Connectivity.wiredAvailable
onToggled: value => Connectivity.setWired(value)
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: root.wiredConnection !== "" && Connectivity.wiredOn
text: root.wiredOpen ? "▴" : "▾"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
ConnectionDetails {
width: parent.width
visible: root.wiredOpen && Connectivity.wiredOn
details: root.wiredConnection !== ""
? NetworkTools.detailsFor(root.wiredConnection) : null
}
}
// ── Wi-Fi ────────────────────────────────────────────────────────────────
SettingsCard {
title: "Wi-Fi"
// A Wi-Fi switch reading "On" above the words "No Wi-Fi adapter" is a
@@ -79,7 +170,7 @@ SettingsPage {
anchors.verticalCenter: parent.verticalCenter
checked: Connectivity.wifiEnabled
enabled: Connectivity.wifiAvailable
onToggled: value => Networking.wifiEnabled = value
onToggled: value => Connectivity.setWifiEnabled(value)
}
}
@@ -87,71 +178,188 @@ SettingsPage {
width: parent.width
visible: Connectivity.wifiEnabled
}
}
// Sharing a network by QR, the way GNOME's Wi-Fi panel does. The
// alternative is reading a passphrase out loud.
//
// The image holds the password in machine-readable form, so it is generated
// on demand rather than up front, and the helper writes it to tmpfs under
// XDG_RUNTIME_DIR instead of anywhere persistent.
SettingsCard {
visible: Connectivity.wifiDevice !== null && WifiShare.shareable.length > 0
title: "Share a network"
subtitle: WifiShare.sharing !== ""
? "Point a phone's camera at the code to join " + WifiShare.sharing + "."
: "Shows a QR code a phone can scan to join, without reading the password out."
// ── Hotspot ──────────────────────────────────────────────────────────
//
// One radio cannot be a client and an access point at the same time, so
// starting this drops whatever network the machine is on. Said before it
// happens rather than discovered when the browser stops loading.
Repeater {
model: WifiShare.shareable
ActionRow {
id: shareRow
required property var modelData
required property int index
label: shareRow.modelData.ssid
detail: WifiShare.sharing === shareRow.modelData.name
? "Showing a code below — anyone who can see the screen can join"
: "Saved network"
action: WifiShare.sharing === shareRow.modelData.name ? "Hide" : "Show code"
divider: shareRow.index < WifiShare.shareable.length - 1 || WifiShare.sharing !== ""
onTriggered: WifiShare.sharing === shareRow.modelData.name
? WifiShare.stopSharing()
: WifiShare.share(shareRow.modelData.name)
ActionRow {
width: parent.width
visible: Connectivity.wifiEnabled && !NetworkTools.hotspotActive
label: "Hotspot"
detail: "Share this machine's connection over Wi-Fi"
action: root.hotspotOpen ? "Cancel" : "Start hotspot…"
enabled: !NetworkTools.busy
divider: root.hotspotOpen
onTriggered: {
root.hotspotOpen = !root.hotspotOpen;
root.hotspotName = "";
}
}
// 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 {
Column {
width: parent.width
visible: WifiShare.sharing !== "" && WifiShare.imagePath !== ""
implicitHeight: visible ? plate.height + 20 : 0
visible: root.hotspotOpen && !NetworkTools.hotspotActive
Rectangle {
id: plate
anchors.horizontalCenter: parent.horizontalCenter
y: 10
width: 208
height: 208
radius: 10
color: "white"
TextFieldRow {
width: parent.width
label: "Network name"
detail: "What the hotspot calls itself to phones and laptops nearby"
placeholder: "panama-hotspot"
text: root.hotspotName
enabled: !NetworkTools.busy
onAccepted: value => root.hotspotName = value
}
Image {
anchors.centerIn: parent
width: 184
height: 184
smooth: false
fillMode: Image.PreserveAspectFit
cache: false
source: WifiShare.imagePath !== "" ? "file://" + WifiShare.imagePath : ""
ActionRow {
width: parent.width
label: "Start the hotspot"
detail: Connectivity.activeNetwork
? "This machine leaves " + Connectivity.activeNetwork.name
+ " while the hotspot runs — one radio cannot do both."
: "NetworkManager makes up a password and shows it once."
action: NetworkTools.busy ? "Starting…" : "Start"
enabled: root.hotspotName.trim() !== "" && !NetworkTools.busy
divider: false
onTriggered: {
NetworkTools.startHotspot(root.hotspotName.trim());
root.hotspotOpen = false;
}
}
}
TextRow {
width: parent.width
visible: NetworkTools.hotspotActive
label: "Hotspot is running"
detail: "Phones and laptops nearby can see this network and join it"
value: NetworkTools.hotspotSsid
}
// Shown once, on purpose. NetworkManager keeps the passphrase; this page
// never stores it, so leaving this screen means asking NetworkManager
// again rather than reading it back from Panama.
TextRow {
width: parent.width
visible: NetworkTools.hotspotActive && NetworkTools.hotspotPassword !== ""
label: "Password"
detail: "Shown once. Panama does not keep a copy — write it down or let someone type it in now."
value: NetworkTools.hotspotPassword
}
ActionRow {
width: parent.width
visible: NetworkTools.hotspotActive
label: "Stop the hotspot"
detail: "Anything connected through this machine loses its connection"
action: "Stop"
enabled: !NetworkTools.busy
divider: false
onTriggered: NetworkTools.stopHotspot()
}
}
// ── VPN ──────────────────────────────────────────────────────────────────
SettingsCard {
title: "VPN"
subtitle: "WireGuard and OpenVPN profiles NetworkManager holds for you."
TextRow {
width: parent.width
visible: Vpn.lastError !== ""
label: "The last VPN action did not finish"
detail: Vpn.lastError
value: ""
}
Repeater {
model: Vpn.connections
delegate: SwitchRow {
id: vpnRow
required property var modelData
required property int index
width: parent.width
label: String(vpnRow.modelData.name ?? "")
detail: String(vpnRow.modelData.kind ?? "VPN")
+ (vpnRow.modelData.active === true ? " · connected" : "")
checked: vpnRow.modelData.active === true
enabled: !Vpn.busy
onToggled: value => Vpn.setActive(String(vpnRow.modelData.uuid ?? ""), value)
}
}
TextRow {
width: parent.width
visible: Vpn.scanned && Vpn.connections.length === 0
label: "No VPNs configured"
detail: "Import a WireGuard or OpenVPN file to add one"
value: ""
}
// Names the profile that appeared rather than saying "done". An import
// that succeeds under a name you did not choose is otherwise invisible
// until you go looking for it in the list.
TextRow {
width: parent.width
visible: NetworkTools.lastImport !== ""
label: "Imported " + NetworkTools.lastImport
detail: "It is switched off until you turn it on above"
value: ""
}
ActionRow {
width: parent.width
label: "Import a VPN"
detail: "A .conf file from WireGuard, or a .ovpn file from OpenVPN"
action: root.importOpen ? "Cancel" : "Import…"
enabled: !NetworkTools.busy
divider: root.importOpen
onTriggered: {
root.importOpen = !root.importOpen;
root.importPath = "";
}
}
// A typed path rather than a file chooser this phase. A chooser is the
// better answer and is worth doing properly; a typed path is worth far
// more than the door back to GNOME it replaces.
Column {
width: parent.width
visible: root.importOpen
TextFieldRow {
width: parent.width
label: "File"
detail: "The full path to the profile you were given"
placeholder: "~/Downloads/work.ovpn"
text: root.importPath
enabled: !NetworkTools.busy
onAccepted: value => root.importPath = value
}
ActionRow {
width: parent.width
label: "Import this profile"
detail: "NetworkManager reads it and adds a profile. Nothing connects until you switch it on."
action: NetworkTools.busy ? "Importing…" : "Import"
enabled: root.importPath.trim() !== "" && !NetworkTools.busy
divider: false
onTriggered: {
NetworkTools.importVpn(root.importPath.trim());
root.importOpen = false;
}
}
}
}
// ── Bluetooth ────────────────────────────────────────────────────────────
SettingsCard {
title: "Bluetooth"
visible: Connectivity.adapter !== null
@@ -159,54 +367,119 @@ SettingsPage {
SettingRow {
label: "Bluetooth"
detail: Connectivity.adapter
? (Connectivity.adapter.enabled ? "On" : "Off")
detail: Connectivity.bluetoothAvailable
? (Connectivity.bluetoothEnabled ? "On" : "Off")
: "Unavailable"
controlWidth: 48
divider: !!(Connectivity.adapter && Connectivity.adapter.enabled)
divider: Connectivity.bluetoothEnabled
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: !!(Connectivity.adapter && Connectivity.adapter.enabled)
enabled: Connectivity.adapter !== null
onToggled: value => {
if (Connectivity.adapter)
Connectivity.adapter.enabled = value;
}
checked: Connectivity.bluetoothEnabled
enabled: Connectivity.bluetoothAvailable
onToggled: value => Connectivity.setBluetoothEnabled(value)
}
}
BluetoothPanel {
width: parent.width
visible: !!(Connectivity.adapter && Connectivity.adapter.enabled)
visible: Connectivity.bluetoothEnabled
}
}
// ── Radios and proxy ─────────────────────────────────────────────────────
SettingsCard {
title: "Owned by Fedora"
// These two panels drive NetworkManager over D-Bus, which is why they
// work in this session when most GNOME panels do not. They are split
// because GNOME splits them: "network" is wired, VPN and proxies, and
// does not contain Wi-Fi -- the one panel used to point everyone
// there, so the road to a hidden SSID or eduroam ended on a page
// without Wi-Fi on it.
subtitle: "Wired, VPN and Wi-Fi connection editing stay with GNOME's panels, which drive the same NetworkManager this page reads. Printers and online accounts have their own pages here."
title: "Radios and proxy"
subtitle: "The two settings that apply to every connection at once."
ActionRow {
label: "Wi-Fi networks"
detail: "Hidden networks, enterprise (802.1X) logins, and per-network settings"
action: "Open"
onTriggered: SystemSettings.openGnomePanel("wifi")
// A hardware kill switch cannot be overridden from software, so the
// switch says so rather than moving and having nothing happen.
SwitchRow {
width: parent.width
label: "Airplane mode"
detail: NetworkTools.airplaneHardBlocked
? "A switch on this machine is holding the radios off. Software cannot turn them back on."
: "Turns Wi-Fi and Bluetooth off together — the same switch the keyboard's airplane key throws"
checked: NetworkTools.airplaneOn
enabled: !NetworkTools.busy && !NetworkTools.airplaneHardBlocked
onToggled: value => NetworkTools.setAirplane(value)
}
ActionRow {
label: "Network connections"
detail: "VPN, proxies, and wired connection settings"
action: "Open"
OptionPickerRow {
width: parent.width
label: "Network proxy"
detail: NetworkTools.proxyMode === "none"
? "Applications that honour the system proxy use this. Not every application does."
: "In use: " + NetworkTools.proxySummary
enabled: !NetworkTools.busy
options: [
{
value: "none",
label: "Off",
detail: "Applications reach the network directly"
},
{
value: "manual",
label: "Manual",
detail: "A host and port you enter, used for http, https and socks alike"
},
{
value: "auto",
label: "Automatic (PAC)",
detail: "A configuration URL decides, per address"
}
]
current: NetworkTools.proxyMode
onPicked: value => NetworkTools.setProxyMode(String(value))
}
Column {
width: parent.width
visible: NetworkTools.proxyMode === "manual"
// Host and port are written together, because the proxy is only
// usable as a pair -- so each field commits with whatever the other
// one currently holds, and neither writes a half-configuration.
TextFieldRow {
width: parent.width
label: "Proxy host"
detail: "The machine applications should go through"
placeholder: "proxy.example.com"
text: NetworkTools.proxyHost
enabled: !NetworkTools.busy
onAccepted: value => {
if (value.trim() !== "" && NetworkTools.proxyPort !== "")
NetworkTools.setProxyManual(value.trim(), NetworkTools.proxyPort);
}
}
TextFieldRow {
width: parent.width
label: "Port"
detail: "The port that proxy listens on"
placeholder: "8080"
text: NetworkTools.proxyPort
enabled: !NetworkTools.busy
divider: false
onAccepted: value => {
if (value.trim() !== "" && NetworkTools.proxyHost !== "")
NetworkTools.setProxyManual(NetworkTools.proxyHost, value.trim());
}
}
}
TextFieldRow {
width: parent.width
visible: NetworkTools.proxyMode === "auto"
label: "Configuration URL"
detail: "The .pac file whoever runs the network published"
placeholder: "http://example.com/proxy.pac"
text: NetworkTools.proxyPac
enabled: !NetworkTools.busy
divider: false
onTriggered: SystemSettings.openGnomePanel("network")
onAccepted: value => NetworkTools.setProxyPac(value.trim())
}
}
}
@@ -0,0 +1,98 @@
// Joining an 802.1X network -- eduroam and every corporate SSID -- inline,
// under the row you clicked.
//
// This is the single road that used to end at GNOME's Wi-Fi panel. A PSK field
// cannot join these networks: they want an authentication method, an identity,
// a password, and sometimes a certificate to check the network against.
//
// The password never becomes a command-line argument. It is handed to
// panama-network over stdin, because argv is world-readable through /proc for
// as long as the process lives -- see the pin in network-tools-contract. It is
// also dropped from this form the moment the form closes.
//
// No file dialog for the certificate this phase: a path typed in is worse than
// a picker and much better than a road back to GNOME.
import QtQuick
Column {
id: root
property string ssid: ""
property bool busy: false
// Live form state. There is no reset(): WifiPanel loads this form and
// destroys it when the row closes, so the typed password goes with it
// rather than sitting in a hidden object waiting to be reopened.
property string eap: "peap-mschapv2"
property string identity: ""
property string password: ""
property string caPath: ""
readonly property bool ready: root.identity.trim() !== "" && root.password !== ""
signal submitted(eap: string, identity: string, password: string, caPath: string)
width: parent ? parent.width : 620
spacing: 0
OptionPickerRow {
width: parent.width
label: "Authentication"
detail: "What the network expects. Whoever runs it publishes this."
options: [
{
value: "peap-mschapv2",
label: "PEAP · MSCHAPv2",
detail: "The usual choice for university and workplace networks"
},
{
value: "ttls-pap",
label: "TTLS · PAP",
detail: "For networks built around a plain-text inner method"
}
]
current: root.eap
onPicked: value => root.eap = String(value)
}
TextFieldRow {
width: parent.width
label: "Identity"
detail: "The username the network knows you by, often an email address"
placeholder: "[email protected]"
text: root.identity
enabled: !root.busy
onAccepted: value => root.identity = value
}
PasswordRow {
width: parent.width
label: "Password"
detail: "Handed to NetworkManager directly. It is never written to a command line."
placeholder: "Password for " + (root.ssid !== "" ? root.ssid : "this network")
onChanged: value => root.password = value
}
TextFieldRow {
width: parent.width
label: "CA certificate"
detail: "Optional. A path to the certificate that proves the network is the one it claims to be."
placeholder: "/etc/ssl/certs/example.pem"
text: root.caPath
enabled: !root.busy
onAccepted: value => root.caPath = value
}
ActionRow {
width: parent.width
label: "Connect to " + (root.ssid !== "" ? root.ssid : "this network")
detail: root.ready
? "NetworkManager saves this network, so it comes back on its own next time"
: "An identity and a password are needed before this network will answer"
action: root.busy ? "Connecting…" : "Connect"
enabled: root.ready && !root.busy
divider: false
onTriggered: root.submitted(root.eap, root.identity.trim(), root.password, root.caPath.trim())
}
}
@@ -25,6 +25,96 @@ SettingsPage {
property string confirmingRemoval: ""
property bool confirmingRange: false
// The add flow. Additions are not destructive -- they open something rather
// than closing it -- so there is no confirm here, only the caption saying
// the rule is permanent and will prompt.
property bool addOpen: false
property string addKind: "service"
property string addValue: ""
// Changing a connection's zone IS consequence-bearing: it silently rewrites
// what every machine on that network can reach. Two-stage, and the confirm
// names the interface it is about to move.
property string pendingInterface: ""
property string pendingZone: ""
// The zone being read in the browser. Read-only: this looks at a zone
// without applying it to anything.
property string browsingZone: ""
readonly property bool addValid: root.addKind === "service"
? /^[a-z0-9][a-z0-9-]*$/.test(root.addValue.trim())
: /^[0-9]{1,5}(-[0-9]{1,5})?\/(tcp|udp)$/.test(root.addValue.trim())
// [{ iface, zone }] -- one row per network interface the firewall has
// actually placed in a zone, flattened out of activeZones.
readonly property var zonedInterfaces: {
const placed = Firewall.activeZones ?? ({});
const rows = [];
for (const zoneName of Object.keys(placed)) {
for (const iface of (placed[zoneName] ?? []))
rows.push({ iface: String(iface), zone: String(zoneName) });
}
rows.sort((a, b) => a.iface.localeCompare(b.iface));
return rows;
}
readonly property var zoneOptions: (Firewall.allZones ?? []).map(zoneName => ({
value: String(zoneName),
label: String(zoneName),
detail: String(zoneName) === Firewall.defaultZone
? "The default for new connections" : ""
}))
// What an interface is carrying, said the way a person names it: the Wi-Fi
// network or the wired profile, falling back to the kernel's name for it.
function connectionOn(iface: string): string {
if (Connectivity.wifiDevice && Connectivity.wifiDevice.name === iface
&& Connectivity.activeNetwork)
return Connectivity.activeNetwork.name;
if (Connectivity.wiredDevice && Connectivity.wiredDevice.name === iface
&& Connectivity.wiredDevice.network)
return String(Connectivity.wiredDevice.network.name ?? iface);
return iface;
}
// The rules by name, then what the zone does with everything else. The
// helper's `summary` counts them; the names are what someone comparing two
// zones actually needs, so the names are listed and the helper's own
// sentence about the target is kept for the tail.
function describeZone(zoneName: string): string {
const info = Firewall.zoneInfo(zoneName);
if (!info)
return "Reading what " + zoneName + " allows…";
const services = (info.services ?? []).map(entry => String(entry));
const ports = (info.ports ?? []).map(entry => String(entry));
const parts = [];
if (services.length > 0)
parts.push(services.join(", "));
if (ports.length > 0)
parts.push("ports " + ports.join(", "));
let text = parts.length === 0
? zoneName + " allows nothing in."
: zoneName + " allows: " + parts.join(", and ") + ".";
// "3 services, 1 port rule; anything no rule allows is rejected" --
// everything after the semicolon is the helper's phrasing for the
// zone's target, which the list above does not say.
const summary = String(info.summary ?? "");
const cut = summary.indexOf("; ");
if (cut >= 0)
text += " Otherwise, " + summary.slice(cut + 2) + ".";
const rich = (info.richRules ?? []).length;
if (rich > 0)
text += " It also carries " + rich + " rich rule"
+ (rich === 1 ? "" : "s") + ", which this page never edits.";
return text;
}
Component.onCompleted: Firewall.refresh()
TextRow {
@@ -180,7 +270,9 @@ SettingsPage {
: "Anything relying on this service stops being reachable.")
: "Allowed by name, so it works whatever the port range says"
controlWidth: 210
divider: serviceRow.index < (Firewall.zone?.services ?? []).length - 1
// The add row always follows, so the last service still needs
// its hairline.
divider: true
Row {
anchors.right: parent.right
@@ -208,6 +300,80 @@ SettingsPage {
}
}
// ── The add side ─────────────────────────────────────────────────────
//
// Opening a port is not destructive, so it is not confirmed. It is
// permanent and it prompts, and both of those are said before the
// button rather than discovered by the polkit dialog appearing.
ActionRow {
width: parent.width
label: "Allow something new"
detail: "A service firewalld already knows by name, or a port and protocol"
action: root.addOpen ? "Cancel" : "Add…"
enabled: !Firewall.busy
divider: root.addOpen
onTriggered: {
root.addOpen = !root.addOpen;
root.addValue = "";
}
}
Column {
width: parent.width
visible: root.addOpen
SegmentRow {
width: parent.width
label: "What to allow"
detail: "A named service carries its own ports, so it keeps working if they change"
controlWidth: 230
options: [
{ value: "service", label: "Named service" },
{ value: "port", label: "Port" }
]
value: root.addKind
enabled: !Firewall.busy
onSelected: value => {
root.addKind = String(value);
root.addValue = "";
}
}
TextFieldRow {
width: parent.width
label: root.addKind === "service" ? "Service name" : "Port and protocol"
detail: root.addKind === "service"
? "One of firewalld's own service names, in lower case"
: "A port or range, then tcp or udp"
placeholder: root.addKind === "service" ? "syncthing" : "8080/tcp"
text: root.addValue
enabled: !Firewall.busy
onAccepted: value => root.addValue = value
}
ActionRow {
width: parent.width
label: root.addValue.trim() === "" || root.addValid
? "Allow it"
: (root.addKind === "service"
? "That is not a service name firewalld would accept"
: "That is not a port firewalld would accept")
detail: "This writes a permanent rule — the system will ask for your password."
action: "Allow"
enabled: root.addValid && !Firewall.busy
divider: false
onTriggered: {
if (root.addKind === "service")
Firewall.addService(root.addValue.trim());
else
Firewall.addPort(root.addValue.trim());
root.addOpen = false;
root.addValue = "";
}
}
}
// Shown, never edited.
TextRow {
visible: (Firewall.zone?.richRules ?? []).length > 0
@@ -222,26 +388,176 @@ SettingsPage {
SettingsCard {
title: "Zones"
subtitle: "A zone is a set of rules. Each network connection uses one."
subtitle: "A zone is a set of rules. Each network connection uses one, and moving a connection between zones changes what the machines on that network can reach."
Repeater {
model: Object.keys(Firewall.activeZones ?? ({}))
model: root.zonedInterfaces
delegate: Column {
id: placement
delegate: TextRow {
required property var modelData
required property int index
readonly property string iface: String(placement.modelData.iface ?? "")
readonly property string zone: String(placement.modelData.zone ?? "")
readonly property bool pending: root.pendingInterface === placement.iface
width: parent.width
label: String(modelData)
detail: "Applied to " + (Firewall.activeZones[String(modelData)] ?? []).join(", ")
value: String(modelData) === Firewall.defaultZone ? "Default" : ""
divider: true
OptionPickerRow {
width: parent.width
label: root.connectionOn(placement.iface)
detail: root.connectionOn(placement.iface) === placement.iface
? "This interface"
: "On " + placement.iface
enabled: !Firewall.busy
options: root.zoneOptions
current: placement.zone
divider: !placement.pending
onPicked: value => {
root.pendingInterface = placement.iface;
root.pendingZone = String(value);
}
}
SettingRow {
width: parent.width
visible: placement.pending
label: "Move " + placement.iface + " to " + root.pendingZone + "?"
// The interface is named because that is the thing being
// moved, and because "public" on the wrong one is the
// difference between a safe café and an unreachable desk.
detail: placement.iface + " leaves " + placement.zone + " for "
+ root.pendingZone + ". Everything reachable over "
+ placement.iface + " is decided by " + root.pendingZone
+ " from then on."
controlWidth: 210
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Keep " + placement.zone
enabled: !Firewall.busy
onClicked: {
root.pendingInterface = "";
root.pendingZone = "";
}
}
SettingsButton {
text: "Move it"
tone: "danger"
enabled: !Firewall.busy
onClicked: {
const target = root.pendingZone;
root.pendingInterface = "";
root.pendingZone = "";
Firewall.setZone(placement.iface, target);
}
}
}
}
}
}
TextRow {
width: parent.width
visible: Firewall.scanned && root.zonedInterfaces.length === 0
label: "No connection is in a zone"
detail: !Firewall.available
? "The firewall is not running, so nothing has been placed"
: "firewalld reports no active zones, which usually means no interface is up"
value: ""
}
OptionPickerRow {
width: parent.width
label: "Default for new connections"
detail: "Used when a network does not ask for a particular zone"
value: Firewall.defaultZone
enabled: !Firewall.busy
options: root.zoneOptions
current: Firewall.defaultZone
onPicked: value => Firewall.setDefaultZone(String(value))
}
// ── The zone browser ─────────────────────────────────────────────────
//
// Read-only. Picking a chip here applies nothing: it answers "what would
// this zone do", which is the question you have to answer before the
// dropdowns above are anything but a guess.
TextRow {
width: parent.width
visible: (Firewall.allZones ?? []).length > 0
label: "Browse zones"
detail: "What each of the " + (Firewall.allZones ?? []).length
+ " zones would allow. Nothing here applies anything."
value: ""
divider: false
}
// Full width rather than in the row's trailing slot: fourteen chips wrap
// to several lines, and a row's control area is one line tall.
Flow {
width: parent.width
visible: (Firewall.allZones ?? []).length > 0
spacing: 6
bottomPadding: 12
Repeater {
model: Firewall.allZones ?? []
delegate: Rectangle {
id: chip
required property var modelData
readonly property string zoneName: String(chip.modelData)
readonly property bool current: root.browsingZone === chip.zoneName
width: chipLabel.implicitWidth + 20
height: 26
radius: Theme.pillRadius
color: chip.current
? Theme.alpha(Theme.accent, 0.18)
: Theme.alpha(Theme.fg, chipHover.hovered ? 0.11 : 0.055)
border.width: 1
border.color: chip.current
? Theme.alpha(Theme.accent, 0.45)
: Theme.alpha(Theme.fg, 0.09)
Text {
id: chipLabel
anchors.centerIn: parent
text: chip.zoneName
color: chip.current ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: chip.current ? Font.DemiBold : Font.Normal
}
HoverHandler {
id: chipHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: root.browsingZone = chip.current ? "" : chip.zoneName
}
}
}
}
TextRow {
width: parent.width
visible: root.browsingZone !== ""
label: root.browsingZone
detail: root.describeZone(root.browsingZone)
value: root.browsingZone === Firewall.defaultZone ? "Default" : ""
divider: false
}
}
@@ -408,6 +408,11 @@ SettingsPage {
wrapMode: Text.WordWrap
}
// Lands on System rather than Network. This button used to open the
// Network panel as a generic front door, which stopped being true
// the moment Connections absorbed VPN, proxies, hotspot and
// enterprise Wi-Fi: sending someone to GNOME for a page Panama now
// owns is exactly what gnome-handoff-contract exists to catch.
SettingsButton {
id: gnomeSettingsButton
anchors.right: parent.right
@@ -416,9 +421,9 @@ SettingsPage {
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: SystemSettings.openGnomePanel("network")
Keys.onReturnPressed: SystemSettings.openGnomePanel("network")
Keys.onSpacePressed: SystemSettings.openGnomePanel("network")
onClicked: SystemSettings.openGnomePanel("system")
Keys.onReturnPressed: SystemSettings.openGnomePanel("system")
Keys.onSpacePressed: SystemSettings.openGnomePanel("system")
}
}
@@ -29,6 +29,43 @@ SettingsPage {
readonly property bool manualReady: /^(ipp|ipps|socket):\/\/\S+$/.test(root.manualUri)
&& root.manualName.trim() !== ""
// The two defaults a queue can be told to change, dressed for reading. The
// values are the helper's closed vocabulary and nothing else reaches
// lpadmin; the choices come from what the printer said it supports, so a
// queue that cannot print two-sided never grows a two-sided dropdown.
//
// Empty while get-options is still on its way, which is why the rows are
// gated on the list rather than on the current value: an empty dropdown
// above a printer is worse than no dropdown at all.
function mediaChoices(reported: var): var {
const detail = {
"Letter": "8.5 × 11 in",
"A4": "210 × 297 mm",
"Legal": "8.5 × 14 in"
};
return (reported?.choices?.media ?? []).map(choice => ({
value: String(choice),
label: String(choice),
detail: detail[String(choice)] ?? ""
}));
}
function sidesChoices(reported: var): var {
const named = {
"one-sided": { label: "Off", detail: "One page per sheet" },
"two-sided-long-edge": { label: "Long edge", detail: "Bound like a book" },
"two-sided-short-edge": { label: "Short edge", detail: "Bound like a notepad" }
};
return (reported?.choices?.sides ?? []).map(choice => {
const known = named[String(choice)];
return {
value: String(choice),
label: known ? known.label : String(choice),
detail: known ? known.detail : ""
};
});
}
Component.onCompleted: Printers.refresh()
TextRow {
@@ -80,9 +117,18 @@ SettingsPage {
}
Column {
id: printerBody
width: printerBlock.width
visible: printerBlock.open
// What this queue is set to, as CUPS reports it. Null until
// the read comes back, and a printer that reports neither
// key simply has no dropdowns -- an option offered for a
// printer that does not have it is a setting that silently
// does nothing.
readonly property var reported: Printers.optionsFor(printerBlock.name)
TextRow {
width: printerBlock.width
label: "Model"
@@ -90,6 +136,28 @@ SettingsPage {
value: String(printerBlock.modelData.makeAndModel ?? "Unknown")
}
OptionPickerRow {
width: printerBlock.width
visible: root.mediaChoices(printerBody.reported).length > 0
label: "Paper size"
detail: "What applications print onto unless they ask for something else"
enabled: !Printers.busy
options: root.mediaChoices(printerBody.reported)
current: String(printerBody.reported?.options?.media ?? "")
onPicked: value => Printers.setOption(printerBlock.name, "media", String(value))
}
OptionPickerRow {
width: printerBlock.width
visible: root.sidesChoices(printerBody.reported).length > 0
label: "Two-sided"
detail: "Long edge is the usual choice for text; short edge flips like a notepad"
enabled: !Printers.busy
options: root.sidesChoices(printerBody.reported)
current: String(printerBody.reported?.options?.sides ?? "")
onPicked: value => Printers.setOption(printerBlock.name, "sides", String(value))
}
ActionRow {
width: printerBlock.width
visible: !printerBlock.modelData.isDefault
@@ -173,22 +241,47 @@ SettingsPage {
Repeater {
model: Printers.jobs
delegate: ActionRow {
delegate: SettingRow {
id: jobRow
required property var modelData
required property int index
readonly property bool held: Printers.isHeld(jobRow.modelData)
width: parent.width
label: String(jobRow.modelData.name ?? "Untitled")
detail: String(jobRow.modelData.printer ?? "") + " · "
+ String(jobRow.modelData.state ?? "")
+ (Number(jobRow.modelData.pages ?? 0) > 0
? " · " + jobRow.modelData.pages + " pages" : "")
action: "Cancel"
enabled: !Printers.busy
controlWidth: 175
divider: jobRow.index < Printers.jobs.length - 1
onTriggered: Printers.cancel(Number(jobRow.modelData.id))
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
// Holding is the answer to "stop this one, I have not
// finished with it" -- cancelling was the only way out of
// the queue before, and it is not reversible.
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: jobRow.held ? "Release" : "Hold"
enabled: !Printers.busy
onClicked: jobRow.held
? Printers.release(Number(jobRow.modelData.id))
: Printers.hold(Number(jobRow.modelData.id))
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: "Cancel"
enabled: !Printers.busy
onClicked: Printers.cancel(Number(jobRow.modelData.id))
}
}
}
}
@@ -201,42 +294,26 @@ SettingsPage {
}
}
// ── Nothing set up yet ───────────────────────────────────────────────────
SettingsCard {
visible: Printers.scanned && !Printers.anyPrinters
title: "No printers yet"
subtitle: "Printers that announce themselves on your network appear here on their own."
ActionRow {
label: "Search the network"
detail: Printers.searching
? "Listening for printers that announce themselves…"
: (Printers.searched
? Printers.addable.length + " found"
: "Looks for printers over mDNS, the same way phones and laptops find them")
action: Printers.searching ? "Searching…" : "Search"
enabled: !Printers.searching
divider: false
onTriggered: Printers.search()
}
}
// ── Adding ───────────────────────────────────────────────────────────────
//
// One card, not two. "No printers yet" and "Add a printer" carried the same
// Search row with two different wordings, so a machine with no printers
// offered the identical button twice under two headings.
SettingsCard {
title: "Add a printer"
subtitle: "Driverless printers only. One that needs a manufacturer driver has to be set up with the system printer tool."
title: Printers.anyPrinters ? "Add a printer" : "No printers yet"
subtitle: Printers.anyPrinters
? "Driverless printers only. One that needs a manufacturer driver has to be set up with the system printer tool."
: "Printers on this network that speak modern IPP appear here on their own. Driverless only."
ActionRow {
visible: Printers.anyPrinters
label: "Search the network"
detail: Printers.searching
? "Listening for printers that announce themselves…"
: (Printers.searched
? Printers.addable.length + " printer"
+ (Printers.addable.length === 1 ? "" : "s") + " found that are not set up here"
: "Looks for printers over mDNS")
: "Looks for printers over mDNS, the same way phones and laptops find them")
action: Printers.searching ? "Searching…" : "Search"
enabled: !Printers.searching
onTriggered: Printers.search()
@@ -22,18 +22,21 @@ SettingsPage {
Component.onCompleted: Sharing.refresh()
TextRow {
visible: Sharing.lastError !== ""
label: "Sharing needs attention"
detail: Sharing.lastError
value: ""
divider: false
}
SettingsCard {
title: "This machine"
subtitle: "The name other machines see."
// The error belongs to the machine, not to a floating banner above the
// title: a bare row above the first card read as part of the page
// furniture, which is exactly what an error must not do.
TextRow {
width: parent.width
visible: Sharing.lastError !== ""
label: "Sharing needs attention"
detail: Sharing.lastError
value: ""
}
TextFieldRow {
label: "Network name"
detail: "Used for ssh and for anything else that finds this machine by name"
@@ -47,32 +50,37 @@ SettingsPage {
SettingsCard {
title: "Remote login"
subtitle: "Sign in to a terminal on this machine over SSH."
subtitle: Sharing.remoteLoginOn
? "Sign in to a terminal on this machine with: ssh " + Sharing.networkName
: "Sign in to a terminal on this machine over SSH."
// Port and the password-sign-in claim used to be two rows of their own
// below this one. They describe this switch rather than standing beside
// it, so they read as its detail line -- and the card is three rows
// shorter for saying the same things.
//
// "Keys only" is reported from sshd's configuration rather than assumed:
// claiming it on a machine that actually accepts passwords would be a
// security claim this page cannot back up.
SwitchRow {
label: "Allow remote login"
detail: Sharing.remoteLogin?.installed === true
? (Sharing.remoteLoginOn
? "Running, and starts automatically at boot"
: "Not running")
? "OpenSSH · port " + String(Sharing.remoteLogin?.port ?? "22")
+ " · password sign-in: " + Sharing.passwordLoginSummary().toLowerCase()
+ (Sharing.remoteLoginOn ? " · starts at boot" : "")
: "OpenSSH server is not installed"
checked: Sharing.remoteLoginOn
enabled: !Sharing.busy && Sharing.remoteLogin?.installed === true
divider: Sharing.remoteLoginOn
onToggled: value => Sharing.setRemoteLogin(value)
}
TextRow {
visible: Sharing.remoteLoginOn
label: "Connect with"
detail: "From another machine on your network"
value: "ssh " + Sharing.networkName
}
TextRow {
visible: Sharing.remoteLoginOn && Sharing.remoteSessions.length === 0
label: "Nobody is signed in"
label: "Nobody is signed in remotely"
detail: "Remote login is on, and no one is connected from another machine."
value: ""
divider: false
}
Repeater {
@@ -80,30 +88,14 @@ SettingsPage {
delegate: TextRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.user ?? "") + " is signed in from " + String(modelData.from ?? "")
detail: "Since " + String(modelData.since ?? "") + " · " + String(modelData.line ?? "")
value: ""
divider: index < Sharing.remoteSessions.length - 1
}
}
TextRow {
visible: Sharing.remoteLoginOn
label: "Port"
detail: "Where the SSH server is listening"
value: String(Sharing.remoteLogin?.port ?? "22")
}
// Reported from the configuration rather than assumed. Saying "keys
// only" on a machine that actually accepts passwords would be a
// security claim this page cannot back up.
TextRow {
visible: Sharing.remoteLoginOn
label: "Password sign-in"
detail: Sharing.passwordLoginSummary()
value: ""
divider: false
}
}
SettingsCard {
@@ -113,11 +105,12 @@ SettingsPage {
SwitchRow {
label: "Allow remote desktop"
detail: Sharing.remoteDesktop?.available === true
? (Sharing.remoteDesktopOn
? "Running for your session"
? "RDP · port " + String(Sharing.remoteDesktop?.port ?? "3389") + " · "
+ (Sharing.remoteDesktopOn
? "running for your session"
: (Sharing.remoteDesktop?.hasCredentials === true
? "Not running"
: "Set a username and password before turning this on"))
? "not running"
: "set a username and password before turning this on"))
: "Remote desktop support is not installed"
checked: Sharing.remoteDesktopOn
enabled: !Sharing.busy
@@ -149,12 +142,16 @@ SettingsPage {
// terminal, never into this page. grdctl prompts for it on a terminal
// and crashes without one, and passing it as an argument would publish
// it through /proc to every process on this machine.
//
// The row says why rather than naming the mechanism: "opens kitty" is
// an implementation detail, and the reason -- the password never passes
// through Panama -- is the part worth reading.
ActionRow {
visible: Sharing.remoteDesktop?.available === true
label: "Credentials"
detail: Sharing.remoteDesktop?.hasCredentials === true
? "Stored in the login keyring · setting new ones opens a terminal to type into"
: "None stored yet · remote desktop cannot be turned on without them"
? "Stored in the login keyring · set in a terminal so the password never passes through Panama"
: "None stored yet · set in a terminal so the password never passes through Panama"
action: "Set…"
enabled: !Sharing.busy
onTriggered: Sharing.setRdpCredentials(Quickshell.env("USER") || "")
@@ -176,11 +173,15 @@ SettingsPage {
title: "File and media sharing"
subtitle: "Sharing folders and media needs software this machine does not necessarily have."
// Not a switch, because there is nothing behind it. Naming what
// installing Samba would unlock is the difference between a dead row
// and an answer -- the panel this replaces shows a switch that silently
// does nothing.
TextRow {
label: "Share folders on the network"
detail: Sharing.fileSharing?.installed === true
? "Samba is installed"
: "Needs Samba, which is not installed. Settings does not install software."
? "Samba is installed, so folders can be published to Windows, macOS and Linux machines alike"
: "Samba is not installed — install it and this becomes a switch. Settings does not install software."
value: Sharing.fileSharing?.installed === true ? "Available" : "Not installed"
}
@@ -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
@@ -83,6 +83,8 @@ GradientSliderRow 1.0 GradientSliderRow.qml
OptionPickerRow 1.0 OptionPickerRow.qml
WifiPanel 1.0 WifiPanel.qml
BluetoothPanel 1.0 BluetoothPanel.qml
ConnectionDetails 1.0 ConnectionDetails.qml
EnterpriseJoinForm 1.0 EnterpriseJoinForm.qml
PasswordField 1.0 PasswordField.qml
AudioBalance 1.0 AudioBalance.qml
SoundDeviceList 1.0 SoundDeviceList.qml