From b30bf40407ccfbed3eb417375446f55d3a657afb Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 24 Aug 2026 16:31:52 -0400 Subject: [PATCH] Own the network: details, VPN, enterprise Wi-Fi, and a firewall that can also allow Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8 --- README.md | 2 +- .../modules/settings/BluetoothPanel.qml | 33 +- .../modules/settings/ConnectionDetails.qml | 148 +++ .../modules/settings/ConnectivityPage.qml | 471 ++++++++-- .../modules/settings/EnterpriseJoinForm.qml | 98 ++ .../modules/settings/FirewallPage.qml | 334 ++++++- .../modules/settings/HealthPage.qml | 11 +- .../modules/settings/PrintersPage.qml | 135 ++- .../modules/settings/SharingPage.qml | 93 +- .../quickshell/modules/settings/WifiPanel.qml | 314 ++++++- config/dot/quickshell/modules/settings/qmldir | 2 + config/dot/quickshell/scripts/panama-firewall | 70 +- config/dot/quickshell/scripts/panama-network | 866 ++++++++++++++++++ config/dot/quickshell/scripts/panama-printers | 129 ++- .../dot/quickshell/services/Connectivity.qml | 66 +- config/dot/quickshell/services/Firewall.qml | 83 ++ .../dot/quickshell/services/NetworkTools.qml | 392 ++++++++ config/dot/quickshell/services/Printers.qml | 105 +++ .../quickshell/services/SettingsSearch.qml | 19 +- ...26-08-23-settings-redesign-test-backlog.md | 123 +++ .../2026-08-24-network-sharing-redesign.md | 138 +++ tests/quickshell/connectivity-contract | 114 +++ tests/quickshell/firewall-contract | 97 ++ tests/quickshell/gnome-handoff-contract | 20 +- tests/quickshell/health-ui-contract | 10 +- tests/quickshell/network-tools-contract | 614 +++++++++++++ tests/quickshell/printers-contract | 128 +++ tests/quickshell/settings-pages-contract | 32 +- tests/quickshell/sharing-contract | 46 + 29 files changed, 4452 insertions(+), 241 deletions(-) create mode 100644 config/dot/quickshell/modules/settings/ConnectionDetails.qml create mode 100644 config/dot/quickshell/modules/settings/EnterpriseJoinForm.qml create mode 100755 config/dot/quickshell/scripts/panama-network create mode 100644 config/dot/quickshell/services/NetworkTools.qml create mode 100644 docs/superpowers/specs/2026-08-24-network-sharing-redesign.md create mode 100755 tests/quickshell/network-tools-contract diff --git a/README.md b/README.md index 57e901c..a32ef62 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ docs/ Settings reference, and the design specs behind the work ## Tests -169 of them, under `tests/`. Run the lot, or a subset by pattern: +170 of them, under `tests/`. Run the lot, or a subset by pattern: ```sh panama test # everything diff --git a/config/dot/quickshell/modules/settings/BluetoothPanel.qml b/config/dot/quickshell/modules/settings/BluetoothPanel.qml index 7720c99..b589d5a 100644 --- a/config/dot/quickshell/modules/settings/BluetoothPanel.qml +++ b/config/dot/quickshell/modules/settings/BluetoothPanel.qml @@ -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 diff --git a/config/dot/quickshell/modules/settings/ConnectionDetails.qml b/config/dot/quickshell/modules/settings/ConnectionDetails.qml new file mode 100644 index 0000000..c427d0a --- /dev/null +++ b/config/dot/quickshell/modules/settings/ConnectionDetails.qml @@ -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 + } +} diff --git a/config/dot/quickshell/modules/settings/ConnectivityPage.qml b/config/dot/quickshell/modules/settings/ConnectivityPage.qml index 6a97d43..f195f2e 100644 --- a/config/dot/quickshell/modules/settings/ConnectivityPage.qml +++ b/config/dot/quickshell/modules/settings/ConnectivityPage.qml @@ -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()) } - } } diff --git a/config/dot/quickshell/modules/settings/EnterpriseJoinForm.qml b/config/dot/quickshell/modules/settings/EnterpriseJoinForm.qml new file mode 100644 index 0000000..f500c35 --- /dev/null +++ b/config/dot/quickshell/modules/settings/EnterpriseJoinForm.qml @@ -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: "you@example.edu" + 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()) + } +} diff --git a/config/dot/quickshell/modules/settings/FirewallPage.qml b/config/dot/quickshell/modules/settings/FirewallPage.qml index 118635e..d3d66f6 100644 --- a/config/dot/quickshell/modules/settings/FirewallPage.qml +++ b/config/dot/quickshell/modules/settings/FirewallPage.qml @@ -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 } } diff --git a/config/dot/quickshell/modules/settings/HealthPage.qml b/config/dot/quickshell/modules/settings/HealthPage.qml index f2d0c56..20ab0b1 100644 --- a/config/dot/quickshell/modules/settings/HealthPage.qml +++ b/config/dot/quickshell/modules/settings/HealthPage.qml @@ -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") } } diff --git a/config/dot/quickshell/modules/settings/PrintersPage.qml b/config/dot/quickshell/modules/settings/PrintersPage.qml index f95dc57..7aec98a 100644 --- a/config/dot/quickshell/modules/settings/PrintersPage.qml +++ b/config/dot/quickshell/modules/settings/PrintersPage.qml @@ -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() diff --git a/config/dot/quickshell/modules/settings/SharingPage.qml b/config/dot/quickshell/modules/settings/SharingPage.qml index 90363c2..118cdd4 100644 --- a/config/dot/quickshell/modules/settings/SharingPage.qml +++ b/config/dot/quickshell/modules/settings/SharingPage.qml @@ -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" } diff --git a/config/dot/quickshell/modules/settings/WifiPanel.qml b/config/dot/quickshell/modules/settings/WifiPanel.qml index 3671ed6..77e359a 100644 --- a/config/dot/quickshell/modules/settings/WifiPanel.qml +++ b/config/dot/quickshell/modules/settings/WifiPanel.qml @@ -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 diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index ce625bd..ab9dd70 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -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 diff --git a/config/dot/quickshell/scripts/panama-firewall b/config/dot/quickshell/scripts/panama-firewall index 6622aeb..94a1011 100755 --- a/config/dot/quickshell/scripts/panama-firewall +++ b/config/dot/quickshell/scripts/panama-firewall @@ -13,6 +13,7 @@ publish on all interfaces. Changes go through firewall-cmd, which is polkit-aware, so they prompt. panama-firewall snapshot + panama-firewall zone-info ZONE panama-firewall add-service NAME | remove-service NAME panama-firewall add-port PORT/PROTO | remove-port PORT/PROTO panama-firewall set-zone INTERFACE ZONE @@ -305,6 +306,57 @@ def snapshot() -> dict: } +# Zone targets, as a sentence rather than firewalld's vocabulary. "default" is +# the one that catches people out: it does not mean "the default zone", it means +# "reject anything no rule allowed", which is the answer someone browsing zones +# is actually looking for. +TARGETS = { + "": "anything no rule allows is rejected", + "default": "anything no rule allows is rejected", + "%%REJECT%%": "anything no rule allows is rejected, with a refusal sent back", + "REJECT": "anything no rule allows is rejected, with a refusal sent back", + "DROP": "anything no rule allows is dropped without an answer", + "ACCEPT": "anything not explicitly blocked is allowed in", +} + + +def zone_info(name: str) -> dict: + """One zone, described -- read-only, for browsing before choosing. + + Separate from `snapshot` because the zone browser asks about zones this + machine is not using, and a snapshot only ever describes the active ones. + Nothing here changes anything, so it needs no authorization and no confirm. + """ + require(ZONE, name, "That is not a zone.") + known = firewall("--get-zones").split() + if name not in known: + raise BoundaryError("There is no zone by that name.") + + detail = zone_detail(name) + if not detail: + raise BoundaryError("That zone could not be read.") + + services = detail.get("services", []) + ports = detail.get("ports", []) + parts = [] + parts.append(f"{len(services)} service{'' if len(services) == 1 else 's'}") + parts.append(f"{len(ports)} port rule{'' if len(ports) == 1 else 's'}") + summary = ", ".join(parts) + "; " + TARGETS.get( + detail.get("target", ""), "custom handling for anything no rule allows") + + return { + "zone": name, + "services": services, + "ports": ports, + "interfaces": detail.get("interfaces", []), + "target": detail.get("target", ""), + "richRules": detail.get("richRules", []), + "isDefault": name == firewall("--get-default-zone"), + "summary": summary, + "error": "", + } + + def require(pattern: re.Pattern, value: str, message: str) -> str: if not pattern.fullmatch(value or ""): raise BoundaryError(message) @@ -333,6 +385,18 @@ def main(arguments: list[str]) -> int: print(json.dumps(snapshot(), separators=(",", ":"))) return 0 + # Read-only, so it answers with its own shape rather than a snapshot -- + # the browser wants one zone described, not the machine's exposure. + if len(arguments) == 2 and arguments[0] == "zone-info": + try: + answer = zone_info(arguments[1]) + except BoundaryError as error: + answer = {"zone": arguments[1], "services": [], "ports": [], + "interfaces": [], "target": "", "richRules": [], + "isDefault": False, "summary": "", "error": str(error)} + print(json.dumps(answer, separators=(",", ":"))) + return 0 + if len(arguments) == 2 and arguments[0] in ("add-service", "remove-service"): name = require(SERVICE, arguments[1], "That is not a service name.") verb = "--add-service" if arguments[0] == "add-service" else "--remove-service" @@ -351,9 +415,9 @@ def main(arguments: list[str]) -> int: firewall(f"--set-default-zone={zone}", timeout=120) else: raise BoundaryError( - "Usage: panama-firewall snapshot | add-service NAME | remove-service NAME | " - "add-port PORT/PROTO | remove-port PORT/PROTO | set-zone INTERFACE ZONE | " - "set-default-zone ZONE") + "Usage: panama-firewall snapshot | zone-info ZONE | add-service NAME | " + "remove-service NAME | add-port PORT/PROTO | remove-port PORT/PROTO | " + "set-zone INTERFACE ZONE | set-default-zone ZONE") except BoundaryError as error: try: state = snapshot() diff --git a/config/dot/quickshell/scripts/panama-network b/config/dot/quickshell/scripts/panama-network new file mode 100755 index 0000000..5f67253 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-network @@ -0,0 +1,866 @@ +#!/usr/bin/env python3 + +"""The parts of networking Quickshell has no surface for. + +services/Connectivity.qml is pinned pure-native: Wi-Fi scanning, joining and +Bluetooth pairing already work over DBus through Quickshell.Networking, and +nothing there may shell out. But NetworkManager knows a great deal that module +never exposes -- a connection's addresses, whether it comes back by itself, +whether its MAC is randomised, VPN profile import, hotspots, enterprise +authentication -- and the system proxy and the radio kill switches are not +NetworkManager's at all. This helper is where all of that lives, so that the +native service stays native. + + panama-network details CONNECTION + panama-network forget CONNECTION + panama-network set-autoconnect CONNECTION true|false + panama-network set-mac-random CONNECTION true|false + panama-network import-vpn FILE + panama-network hotspot start SSID | hotspot stop | hotspot status + panama-network join-enterprise SSID PROFILE IDENTITY [CA_CERT] (password on stdin) + panama-network proxy get + panama-network proxy set none + panama-network proxy set manual [HOST PORT] + panama-network proxy set auto [PAC_URL] + panama-network airplane status + panama-network airplane set true|false + +SECRETS + +Two rules, both enforced in code rather than by care: + + * Nothing a page renders can contain a secret. Every profile NetworkManager + describes is read through `listing()`, which drops any property named as a + secret OR merely shaped like one -- so a field a future NetworkManager adds + is excluded before anybody notices it exists, rather than after. + * An enterprise password never appears in argv, which is world-readable + through /proc for the life of the process. It is read from stdin, and only + after the rest of the request has been validated: reading it first would + mean waiting on a password for a request that was always going to be + refused. + +HOW THE ENTERPRISE PASSWORD REACHES NetworkManager + +Preferred: libnm's GObject-introspection bindings, which build the profile in +memory and hand it to NetworkManager over D-Bus with AddConnection. The secret +is a value in a D-Bus message: never a command line, never a temporary file. +This machine has them (NM 1.56), so this is the live path. + +The probe constructs a client rather than only importing the module, because +the question is not "are the bindings installed" but "can the password be +handed over on the bus" -- a machine with the typelib and a NetworkManager that +will not answer must fall through, not fail. + +Fallback: a scripted `nmcli connection edit` session driven over stdin. nmcli's +editor takes `set 802-1x.password …` as input rather than as an argument, which +keeps the secret out of ps just the same. Second choice only because it depends +on the editor's prompt behaviour rather than on a stable API. + +The hotspot password is different in kind: NetworkManager generates it, and it +is useless unless it is shown. It is returned once, by the verb that created or +looked up the hotspot, and never logged. +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import sys +from pathlib import Path + +# Connection names. Every command here is an argument list, never a shell +# string, so this is not defending against quoting: it is defending against +# argument confusion. A name beginning with '-' would be read by nmcli as an +# option, which is the one shape that turns a validated list back into an +# injection -- so a name must begin with a letter, digit or underscore. No +# slashes either, so a name can never be mistaken for or built into a path. +# The rest is broad but bounded, because real networks are called things like +# "Cafe: Guest" and "Bob's iPhone" and refusing those helps nobody. +NAME = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9 _.:+()@'&#!-]{0,62}$") + +# An SSID is at most 32 bytes on the wire. Names that cannot fit are refused +# here rather than truncated silently into a network nobody can find. +SSID = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9 _.:+()@'&#!-]{0,31}$") + +HOSTNAME = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9.-]{0,253}[A-Za-z0-9])?$") +PAC_URL = re.compile(r"^(https?|file)://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]{1,500}$") + +# The EAP profiles offered. A closed set, because "type your own EAP string" +# produces a profile that fails to authenticate with no way to tell why. +EAP_PROFILES = { + "peap-mschapv2": {"eap": "peap", "phase2": "mschapv2"}, + "ttls-pap": {"eap": "ttls", "phase2": "pap"}, +} + +# VPN profile formats, by extension. NetworkManager's importer picks the plugin +# by type, so the extension is what decides -- and nothing else is accepted. +VPN_TYPES = {".conf": "wireguard", ".ovpn": "openvpn"} + +# Properties that hold a secret, named so the filter is readable rather than +# only regular. +SECRET_PROPERTIES = frozenset({ + "802-11-wireless-security.psk", + "802-11-wireless-security.leap-password", + "802-1x.password", + "802-1x.private-key-password", + "802-1x.phase2-private-key-password", + "802-1x.pin", + "wireguard.private-key", + "gsm.password", + "gsm.pin", + "ppp.password", +}) + +# And the shape of one, because the list above can only ever name the fields +# that existed when it was written. A property whose name reads like a +# credential is dropped before anything downstream can render it -- so a field +# NetworkManager adds in a future release is excluded by default rather than +# leaked until somebody notices. +SECRET_SHAPE = re.compile( + r"(password|passwd|secret|psk|passphrase|private-key|wep-key|leap|\.pin)", re.I) + +# The hotspot's own profile. A fixed name so that stopping and restarting reuse +# one profile rather than accumulating "Hotspot 1", "Hotspot 2" forever, and so +# scripts/panama-wifi-qr can be pointed at it by name for the QR code. +HOTSPOT_CONNECTION = "Panama Hotspot" + +# The MAC randomisation property, by connection type. nmcli's short aliases, +# because those are what a person reads in a bug report -- and because a profile +# that does not say what type it is still needs an answer, which for anything +# with a MAC worth randomising means Wi-Fi. +CLONED_MAC = { + "802-3-ethernet": "ethernet.cloned-mac-address", + "ethernet": "ethernet.cloned-mac-address", +} +CLONED_MAC_DEFAULT = "wifi.cloned-mac-address" + +# Where a profile records the same setting when it is read back, either spelling. +CLONED_MAC_KEYS = ("wifi.cloned-mac-address", "802-11-wireless.cloned-mac-address", + "ethernet.cloned-mac-address", "802-3-ethernet.cloned-mac-address") + + +class BoundaryError(RuntimeError): + """A user-visible validation or NetworkManager failure.""" + + +def run(command: list[str], timeout: float = 15.0, + stdin_text: str | None = None) -> subprocess.CompletedProcess: + try: + return subprocess.run(command, capture_output=True, text=True, + timeout=timeout, check=False, input=stdin_text) + except (OSError, subprocess.TimeoutExpired) as error: + raise BoundaryError(f"{command[0]} did not answer.") from error + + +def tool(name: str, absent: str) -> str: + found = shutil.which(name) + if not found: + raise BoundaryError(absent) + return name + + +def nmcli(*arguments: str, timeout: float = 15.0, + stdin_text: str | None = None) -> str: + tool("nmcli", "NetworkManager is not available.") + result = run(["nmcli", *arguments], timeout=timeout, stdin_text=stdin_text) + if result.returncode != 0: + raise BoundaryError(refusal(result, "NetworkManager refused that.")) + return result.stdout.strip() + + +def refusal(result: subprocess.CompletedProcess, fallback: str) -> str: + lines = (result.stderr or result.stdout or "").strip().splitlines() + if not lines: + return fallback + text = lines[-1].strip() + lowered = text.lower() + if "not authorized" in lowered or "dismissed" in lowered: + return "That network change was not authorized." + if "no such connection profile" in lowered: + return "That connection no longer exists." + return text[:200] + + +def require(pattern: re.Pattern, value: str, message: str) -> str: + if not pattern.fullmatch(value or ""): + raise BoundaryError(message) + return value + + +def require_bool(value: str) -> bool: + if value not in ("true", "false"): + raise BoundaryError("That is not true or false.") + return value == "true" + + +def require_connection(value: str) -> str: + return require(NAME, value, "That is not a connection name.") + + +# ---------------------------------------------------------------- reading + + +def is_secret(key: str) -> bool: + """Whether a property name is one whose value must not travel.""" + bare = re.sub(r"\[\d+\]$", "", key).strip().lower() + return bare in SECRET_PROPERTIES or SECRET_SHAPE.search(bare) is not None + + +def listing(*arguments: str) -> dict[str, str]: + """A terse nmcli listing, parsed to property -> value, secrets dropped. + + Filtered here rather than at each use. `details` renders whatever it is + handed, so the one function that reads NetworkManager is the one place that + has to be certain a passphrase never gets that far -- and a filter applied + once cannot be forgotten by the next field somebody adds. + + `-e no` turns off nmcli's in-field escaping, so a value containing ':' -- + every MAC address, for one -- arrives whole and splits at the first colon + exactly where the property name ends. + """ + tool("nmcli", "NetworkManager is not available.") + result = run(["nmcli", "-t", "-e", "no", *arguments]) + if result.returncode != 0: + return {} + + found: dict[str, str] = {} + for line in result.stdout.splitlines(): + key, separator, value = line.partition(":") + if not separator: + continue + key = key.strip() + if not key or is_secret(key): + continue + found[key] = value.strip() + return found + + +def indexed(found: dict[str, str], base: str) -> list[str]: + """Every value of a repeated property, in order. IP4.DNS[1], [2], and so on.""" + pattern = re.compile(rf"^{re.escape(base)}(\[(\d+)\])?$") + matches = [] + for key, value in found.items(): + hit = pattern.match(key) + if hit and value: + matches.append((int(hit.group(2) or 0), value)) + return [value for _, value in sorted(matches)] + + +def first(found: dict[str, str], *keys: str) -> str: + for key in keys: + if found.get(key): + return found[key] + return "" + + +def connection_listing(name: str) -> dict[str, str]: + return listing("connection", "show", name) + + +def connection_exists(name: str) -> bool: + return bool(connection_listing(name)) + + +def connection_state(name: str, note: str = "") -> dict: + """Everything the details grid shows, and nothing a page should not have. + + This is the shape every per-connection verb answers with, so a mutation and + a plain read are the same object to the page -- the change is visible in the + reply rather than in a refresh that may or may not arrive. + """ + state = { + "connection": name, + "exists": False, + "uuid": "", + "type": "", + "interface": "", + "active": False, + "ip4": "", + "ip6": "", + "gateway": "", + "dns": [], + "mac": "", + "macRandomized": False, + "autoconnect": False, + "note": note, + "error": "", + } + found = connection_listing(name) + if not found: + return state + + state["exists"] = True + state["uuid"] = found.get("connection.uuid", "") + state["type"] = found.get("connection.type", "") + state["autoconnect"] = found.get("connection.autoconnect", "") in ("yes", "true") + state["macRandomized"] = first(found, *CLONED_MAC_KEYS).lower() == "random" + + state["active"] = found.get("GENERAL.STATE", "") == "activated" + state["ip4"] = (indexed(found, "IP4.ADDRESS") or [""])[0] + state["ip6"] = (indexed(found, "IP6.ADDRESS") or [""])[0] + state["gateway"] = found.get("IP4.GATEWAY", "") + state["dns"] = indexed(found, "IP4.DNS") + indexed(found, "IP6.DNS") + + device = (indexed(found, "GENERAL.DEVICES") or [""])[0] + state["interface"] = device + + # The address actually on the wire, which is the randomised one when + # randomisation is on -- the profile records only the policy. A profile + # listing does not carry it, so the device is asked; the listing is checked + # first because some builds of nmcli do include it. + state["mac"] = found.get("GENERAL.HWADDR", "") + if not state["mac"] and device: + state["mac"] = listing("-f", "GENERAL.HWADDR", "device", "show", + device).get("GENERAL.HWADDR", "") + + return state + + +# ---------------------------------------------------------------- per-connection + + +def forget(name: str) -> dict: + require_connection(name) + if not connection_exists(name): + raise BoundaryError("That connection no longer exists.") + nmcli("connection", "delete", name, timeout=60) + return connection_state(name, "Forgotten.") + + +def set_autoconnect(name: str, enabled: bool) -> dict: + require_connection(name) + nmcli("connection", "modify", name, + "connection.autoconnect", "yes" if enabled else "no", timeout=60) + return connection_state(name) + + +def set_mac_random(name: str, enabled: bool) -> dict: + require_connection(name) + kind = connection_listing(name).get("connection.type", "") + field = CLONED_MAC.get(kind, CLONED_MAC_DEFAULT) + nmcli("connection", "modify", name, field, + "random" if enabled else "permanent", timeout=60) + # NetworkManager applies a cloned address when the connection comes up, so + # the address on the wire is the old one until it does. Saying so is the + # difference between "this did nothing" and "this takes effect next time". + return connection_state(name, "Reconnect for this to take effect.") + + +# ---------------------------------------------------------------- VPN import + + +def connection_uuids() -> set[str]: + """Every stored profile's UUID, for spotting the one an import added.""" + tool("nmcli", "NetworkManager is not available.") + result = run(["nmcli", "-t", "-e", "no", "-f", "UUID", "connection", "show"]) + return {line.strip() for line in result.stdout.splitlines() if line.strip()} + + +IMPORTED = re.compile(r"'([^']+)'\s*\(([0-9a-fA-F-]{36})\)") + + +def import_vpn(path_text: str) -> dict: + """A WireGuard or OpenVPN profile, by whichever plugin its extension names. + + The extension is what decides, and nothing else: NetworkManager's importer + picks its plugin by type, and guessing from file contents would mean + guessing wrong on a file that is neither. + + nmcli names what it made -- "Connection 'x' (uuid) successfully added." -- + and the quoted name and parenthesised UUID survive translation even where + the sentence around them does not. When they do not, the UUID list either + side of the import says which profile is new. + """ + path = Path(path_text).expanduser() + kind = VPN_TYPES.get(path.suffix.lower()) + if not kind: + raise BoundaryError("A VPN profile must be a .conf or .ovpn file.") + if not path.is_file(): + raise BoundaryError("There is no file at that path.") + + before = connection_uuids() + output = nmcli("connection", "import", "type", kind, "file", str(path), timeout=60) + + name, uuid = "", "" + announced = IMPORTED.search(output) + if announced: + name, uuid = announced.group(1), announced.group(2) + else: + added = connection_uuids() - before + if added: + uuid = added.pop() + name = connection_listing(uuid).get("connection.id", "") + + if not name and not uuid: + raise BoundaryError("The profile imported but no new connection appeared.") + + return {"name": name, "uuid": uuid, "kind": kind, "error": ""} + + +# ---------------------------------------------------------------- hotspot + + +def hotspot_state(password: str = "") -> dict: + state = { + "active": False, + "ssid": "", + "password": password, + "connection": HOTSPOT_CONNECTION, + "band": "", + "interface": "", + "error": "", + } + found = connection_listing(HOTSPOT_CONNECTION) + if not found: + return state + state["ssid"] = first(found, "802-11-wireless.ssid", "wifi.ssid") + state["band"] = first(found, "802-11-wireless.band", "wifi.band") + state["active"] = found.get("GENERAL.STATE", "") == "activated" + state["interface"] = (indexed(found, "GENERAL.DEVICES") or [""])[0] + return state + + +def wifi_interface() -> str: + """The Wi-Fi device to share from, or "" to let NetworkManager pick. + + Naming it is better when there is more than one adapter; not naming it is + better than refusing to start because this could not work out which was + which. NetworkManager makes the same choice, and says so if it cannot. + """ + result = run(["nmcli", "-t", "-e", "no", "-f", "DEVICE,TYPE", "device"]) + for line in result.stdout.splitlines(): + device, _, kind = line.partition(":") + if kind.strip() == "wifi" and device.strip(): + return device.strip() + return "" + + +def hotspot_password() -> str: + """The passphrase NetworkManager generated, read back once to be shown. + + `nmcli device wifi show-password` exists for exactly this and prints the + live hotspot's credentials; the stored profile is asked only if that output + is not in the expected shape. Never logged, never written down here. + """ + result = run(["nmcli", "device", "wifi", "show-password"], timeout=20) + if result.returncode != 0: + return "" + for line in result.stdout.splitlines(): + key, _, value = line.partition(":") + if key.strip().lower() == "password": + return value.strip() + return "" + + +def hotspot_start(ssid: str) -> dict: + require(SSID, ssid, "That is not a network name.") + device = wifi_interface() + where = ["ifname", device] if device else [] + nmcli("device", "wifi", "hotspot", *where, + "con-name", HOTSPOT_CONNECTION, "ssid", ssid, timeout=45) + return hotspot_state(hotspot_password()) + + +def hotspot_stop() -> dict: + if not connection_exists(HOTSPOT_CONNECTION): + return hotspot_state() + nmcli("connection", "down", HOTSPOT_CONNECTION, timeout=45) + return hotspot_state() + + +# ---------------------------------------------------------------- enterprise + + +def native_bindings(): + """A live libnm client through GObject introspection, or None. + + The probe constructs the client rather than merely importing the module, + because the question is not "are the bindings installed" but "can the + password be handed over on the bus". A machine with the typelib and a + NetworkManager that will not answer must fall through to the command-line + client, not fail. + """ + try: + import gi + + gi.require_version("NM", "1.0") + from gi.repository import GLib, NM + + return GLib, NM, NM.Client.new(None) + except Exception: # noqa: BLE001 - any failure here means "use the other path" + return None + + +def join_enterprise(ssid: str, profile_name: str, identity: str, + ca_cert: str) -> dict: + """Validate first, then read the password, then join. + + The order is deliberate. Reading stdin before the arguments are known to be + good would leave the helper waiting on a password for a request it was + always going to refuse -- which, when nothing is piped in, is a hang rather + than an error message. + """ + require(SSID, ssid, "That is not a network name.") + if profile_name not in EAP_PROFILES: + raise BoundaryError("That is not an authentication method this can use.") + if not identity.strip(): + raise BoundaryError("An enterprise network needs a username.") + if len(identity) > 128 or "\n" in identity: + raise BoundaryError("That username cannot be used.") + if ca_cert and not Path(ca_cert).expanduser().is_file(): + raise BoundaryError("There is no certificate at that path.") + + password = read_password() + if not password: + raise BoundaryError("An enterprise network needs a password.") + + bindings = native_bindings() + if bindings is not None: + join_enterprise_native(bindings, ssid, profile_name, identity, ca_cert, password) + else: + join_enterprise_nmcli(ssid, profile_name, identity, ca_cert, password) + + state = connection_state(ssid, "Joined.") + state["joined"] = state["exists"] + return state + + +def join_enterprise_native(bindings, ssid: str, profile_name: str, identity: str, + ca_cert: str, password: str) -> None: + """Build the profile in memory and hand it over on the bus. + + AddConnection carries the password as a value in a D-Bus message: it is + never a command-line argument, so it never appears in /proc, and never a + temporary file, so it never reaches disk unencrypted on its way in. + """ + GLib, NM, client = bindings + method = EAP_PROFILES[profile_name] + + connection = NM.SimpleConnection.new() + + setting = NM.SettingConnection.new() + setting.set_property(NM.SETTING_CONNECTION_ID, ssid) + setting.set_property(NM.SETTING_CONNECTION_UUID, NM.utils_uuid_generate()) + setting.set_property(NM.SETTING_CONNECTION_TYPE, "802-11-wireless") + connection.add_setting(setting) + + wireless = NM.SettingWireless.new() + wireless.set_property(NM.SETTING_WIRELESS_SSID, + GLib.Bytes.new(ssid.encode("utf-8"))) + wireless.set_property(NM.SETTING_WIRELESS_MODE, "infrastructure") + connection.add_setting(wireless) + + security = NM.SettingWirelessSecurity.new() + security.set_property(NM.SETTING_WIRELESS_SECURITY_KEY_MGMT, "wpa-eap") + connection.add_setting(security) + + eap = NM.Setting8021x.new() + eap.add_eap_method(method["eap"]) + eap.set_property(NM.SETTING_802_1X_PHASE2_AUTH, method["phase2"]) + eap.set_property(NM.SETTING_802_1X_IDENTITY, identity) + eap.set_property(NM.SETTING_802_1X_PASSWORD, password) + if ca_cert: + eap.set_ca_cert(str(Path(ca_cert).expanduser()), + NM.Setting8021xCKScheme.PATH, None) + connection.add_setting(eap) + + ip4 = NM.SettingIP4Config.new() + ip4.set_property(NM.SETTING_IP_CONFIG_METHOD, "auto") + connection.add_setting(ip4) + ip6 = NM.SettingIP6Config.new() + ip6.set_property(NM.SETTING_IP_CONFIG_METHOD, "auto") + connection.add_setting(ip6) + + loop = GLib.MainLoop() + outcome: dict = {} + + def added(source, result, _data): + try: + outcome["connection"] = source.add_connection_finish(result) + except GLib.Error as error: # noqa: BLE001 - reported, not raised, on this thread + outcome["error"] = error.message + loop.quit() + + client.add_connection_async(connection, True, None, added, None) + # Bounded, because a NetworkManager that never answers must not leave a + # settings page spinning forever. + GLib.timeout_add_seconds(45, lambda: (loop.quit(), False)[1]) + loop.run() + + if "error" in outcome: + raise BoundaryError(str(outcome["error"])[:200]) + if "connection" not in outcome: + raise BoundaryError("NetworkManager did not answer.") + + # Added, now bring it up so "Connect" means connected. + activation = run(["nmcli", "-w", "45", "connection", "up", ssid], timeout=60) + if activation.returncode != 0: + raise BoundaryError(refusal(activation, "That network refused the sign-in.")) + + +def join_enterprise_nmcli(ssid: str, profile_name: str, identity: str, + ca_cert: str, password: str) -> None: + """The fallback for a machine without libnm's bindings. + + nmcli's connection editor takes its input as lines on stdin, so the password + arrives the same way it would be typed -- as data, not as an argument. That + is the whole reason this shape is used rather than `nmcli connection add` + with the secret on the command line. + """ + method = EAP_PROFILES[profile_name] + script = [ + f"set connection.id {ssid}", + f"set 802-11-wireless.ssid {ssid}", + "set 802-11-wireless-security.key-mgmt wpa-eap", + f"set 802-1x.eap {method['eap']}", + f"set 802-1x.phase2-auth {method['phase2']}", + f"set 802-1x.identity {identity}", + f"set 802-1x.password {password}", + ] + if ca_cert: + script.append(f"set 802-1x.ca-cert {Path(ca_cert).expanduser()}") + script += ["save", "quit", ""] + + nmcli("connection", "edit", "type", "wifi", "con-name", ssid, + timeout=60, stdin_text="\n".join(script)) + + activation = run(["nmcli", "-w", "45", "connection", "up", ssid], timeout=60) + if activation.returncode != 0: + raise BoundaryError(refusal(activation, "That network refused the sign-in.")) + + +# ---------------------------------------------------------------- proxy + + +PROXY_SCHEMA = "org.gnome.system.proxy" + + +def gsettings_get(schema: str, key: str) -> str: + tool("gsettings", "The desktop settings store is not available.") + result = run(["gsettings", "get", schema, key]) + return result.stdout.strip() if result.returncode == 0 else "" + + +def gsettings_set(schema: str, key: str, value: str) -> None: + tool("gsettings", "The desktop settings store is not available.") + result = run(["gsettings", "set", schema, key, value], timeout=20) + if result.returncode != 0: + raise BoundaryError(refusal(result, "The proxy could not be changed.")) + + +def unquote(raw: str) -> str: + """gsettings prints strings quoted and everything else bare.""" + text = raw.strip() + if len(text) >= 2 and text[0] == text[-1] and text[0] in ("'", '"'): + return text[1:-1] + return text + + +def proxy_get() -> dict: + mode = unquote(gsettings_get(PROXY_SCHEMA, "mode")) or "none" + port_text = gsettings_get(f"{PROXY_SCHEMA}.http", "port") + return { + "mode": mode, + "host": unquote(gsettings_get(f"{PROXY_SCHEMA}.http", "host")), + "port": int(port_text) if port_text.isdigit() else 0, + "pacUrl": unquote(gsettings_get(PROXY_SCHEMA, "autoconfig-url")), + "error": "", + } + + +def proxy_set(mode: str, arguments: list[str]) -> dict: + if mode == "none": + if arguments: + raise BoundaryError("Turning the proxy off takes no address.") + gsettings_set(PROXY_SCHEMA, "mode", "none") + elif mode == "manual": + # No address means "switch to the manual proxy already stored", which + # is what choosing Manual from a dropdown means: the fields to fill in + # only appear once the mode is chosen, so demanding them first would be + # a mode nobody could select. + if len(arguments) not in (0, 2): + raise BoundaryError("A manual proxy needs a host and a port.") + if arguments: + host, port = arguments + require(HOSTNAME, host, "That is not a proxy address.") + if not port.isdigit() or not (1 <= int(port) <= 65535): + raise BoundaryError("That is not a port number.") + # http, https and socks together this phase: separate proxies per + # protocol is a real configuration but not one anybody asks a + # settings page for, and three fields that must agree is three ways + # to get it subtly wrong. + for protocol in ("http", "https", "socks"): + gsettings_set(f"{PROXY_SCHEMA}.{protocol}", "host", host) + gsettings_set(f"{PROXY_SCHEMA}.{protocol}", "port", port) + gsettings_set(PROXY_SCHEMA, "mode", "manual") + elif mode == "auto": + if len(arguments) not in (0, 1): + raise BoundaryError("An automatic proxy needs a configuration URL.") + if arguments: + require(PAC_URL, arguments[0], "That is not a proxy configuration URL.") + gsettings_set(PROXY_SCHEMA, "autoconfig-url", arguments[0]) + gsettings_set(PROXY_SCHEMA, "mode", "auto") + else: + raise BoundaryError("A proxy is off, manual, or automatic.") + return proxy_get() + + +# ---------------------------------------------------------------- airplane + + +def airplane_status() -> dict: + """Every radio's kill switch, read the way the keybind reads it. + + Any radio still unblocked reads as radios-on, matching scripts/panama-osd: + airplane mode is a claim about all of them, so a mixed state is not it. + """ + tool("rfkill", "The radio kill switches are not available.") + radios: list[dict] = [] + + result = run(["rfkill", "-J"]) + parsed = None + if result.returncode == 0: + try: + parsed = json.loads(result.stdout or "{}") + except json.JSONDecodeError: + parsed = None + + if parsed is not None: + for entry in parsed.get("rfkilldevices", []): + radios.append({ + "type": str(entry.get("type") or ""), + "soft": str(entry.get("soft") or "") == "blocked", + "hard": str(entry.get("hard") or "") == "blocked", + }) + else: + # Older util-linux has no --json; its list output is stable enough. + current: dict | None = None + for line in run(["rfkill", "list"]).stdout.splitlines(): + heading = re.match(r"^\d+:\s+\S+:\s+(.+)$", line) + if heading: + current = {"type": heading.group(1).strip().lower(), + "soft": False, "hard": False} + radios.append(current) + elif current is not None: + key, _, value = line.strip().partition(":") + if key == "Soft blocked": + current["soft"] = value.strip() == "yes" + elif key == "Hard blocked": + current["hard"] = value.strip() == "yes" + + def blocked(kinds: tuple[str, ...]) -> bool: + matched = [r for r in radios if any(k in r["type"] for k in kinds)] + return bool(matched) and all(r["soft"] or r["hard"] for r in matched) + + return { + "on": bool(radios) and all(radio["soft"] for radio in radios), + "wifiBlocked": blocked(("wlan", "wireless")), + "bluetoothBlocked": blocked(("bluetooth",)), + "hardBlocked": any(radio["hard"] for radio in radios), + "radios": len(radios), + "error": "", + } + + +def airplane_set(enabled: bool) -> dict: + tool("rfkill", "The radio kill switches are not available.") + result = run(["rfkill", "block" if enabled else "unblock", "all"], timeout=20) + if result.returncode != 0: + raise BoundaryError(refusal(result, "The radios could not be changed.")) + return airplane_status() + + +# ---------------------------------------------------------------- entry + + +def read_password() -> str: + """The enterprise password, from stdin, stripped of its trailing newline. + + Only the newline: a password may legitimately begin or end with a space, + and quietly trimming it would produce an authentication failure nobody + could explain. + """ + return sys.stdin.readline().rstrip("\n").rstrip("\r") + + +# What to answer with when a verb fails: the same shape it would have answered +# with, so a page never has to branch on whether the reply is an error. +FALLBACKS = { + "connection": {"connection": "", "exists": False, "uuid": "", "type": "", + "interface": "", "active": False, "ip4": "", "ip6": "", + "gateway": "", "dns": [], "mac": "", "macRandomized": False, + "autoconnect": False, "note": ""}, + "import": {"name": "", "uuid": "", "kind": ""}, + "hotspot": {"active": False, "ssid": "", "password": "", + "connection": HOTSPOT_CONNECTION, "band": "", "interface": ""}, + "proxy": {"mode": "none", "host": "", "port": 0, "pacUrl": ""}, + "airplane": {"on": False, "wifiBlocked": False, "bluetoothBlocked": False, + "hardBlocked": False, "radios": 0}, +} + +USAGE = ("Usage: panama-network details CONNECTION | forget CONNECTION | " + "set-autoconnect CONNECTION true|false | set-mac-random CONNECTION true|false | " + "import-vpn FILE | hotspot start SSID|stop|status | " + "join-enterprise SSID PROFILE IDENTITY [CA_CERT] | " + "proxy get | proxy set none|manual [HOST PORT]|auto [PAC_URL] | " + "airplane status | airplane set true|false") + + +def dispatch(arguments: list[str]) -> tuple[str, dict]: + verb = arguments[0] if arguments else "" + rest = arguments[1:] + + if verb == "details" and len(rest) == 1: + return "connection", connection_state(require_connection(rest[0])) + if verb == "forget" and len(rest) == 1: + return "connection", forget(rest[0]) + if verb == "set-autoconnect" and len(rest) == 2: + return "connection", set_autoconnect(rest[0], require_bool(rest[1])) + if verb == "set-mac-random" and len(rest) == 2: + return "connection", set_mac_random(rest[0], require_bool(rest[1])) + if verb == "import-vpn" and len(rest) == 1: + return "import", import_vpn(rest[0]) + if verb == "hotspot" and rest == ["status"]: + return "hotspot", hotspot_state() + if verb == "hotspot" and rest == ["stop"]: + return "hotspot", hotspot_stop() + if verb == "hotspot" and len(rest) == 2 and rest[0] == "start": + return "hotspot", hotspot_start(rest[1]) + if verb == "join-enterprise" and len(rest) in (3, 4): + return "connection", join_enterprise( + rest[0], rest[1], rest[2], rest[3] if len(rest) == 4 else "") + if verb == "proxy" and rest == ["get"]: + return "proxy", proxy_get() + if verb == "proxy" and len(rest) >= 2 and rest[0] == "set": + return "proxy", proxy_set(rest[1], rest[2:]) + if verb == "airplane" and rest == ["status"]: + return "airplane", airplane_status() + if verb == "airplane" and len(rest) == 2 and rest[0] == "set": + return "airplane", airplane_set(require_bool(rest[1])) + + raise BoundaryError(USAGE) + + +def shape_for(arguments: list[str]) -> str: + verb = arguments[0] if arguments else "" + return {"details": "connection", "forget": "connection", + "set-autoconnect": "connection", "set-mac-random": "connection", + "join-enterprise": "connection", "import-vpn": "import", + "hotspot": "hotspot", "proxy": "proxy", + "airplane": "airplane"}.get(verb, "connection") + + +def main(arguments: list[str]) -> int: + try: + _, answer = dispatch(arguments) + except BoundaryError as error: + answer = dict(FALLBACKS[shape_for(arguments)]) + answer["error"] = str(error) + print(json.dumps(answer, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/config/dot/quickshell/scripts/panama-printers b/config/dot/quickshell/scripts/panama-printers index 1bdaa28..a4ac18f 100755 --- a/config/dot/quickshell/scripts/panama-printers +++ b/config/dot/quickshell/scripts/panama-printers @@ -17,6 +17,9 @@ says so rather than pretending. panama-printers set-default NAME panama-printers pause NAME | resume NAME panama-printers cancel JOB_ID + panama-printers hold JOB_ID | release JOB_ID + panama-printers get-options NAME + panama-printers set-option NAME KEY VALUE panama-printers test-page NAME """ @@ -41,6 +44,24 @@ QUEUE = re.compile(r"^[A-Za-z0-9_.-]{1,127}$") # is checkable rather than scattered. DRIVERLESS_MODEL = "everywhere" +# The printer defaults this page will change, and every value it will accept. +# +# Closed on purpose. `lpadmin -o anything=anything` is a passthrough into a +# daemon running as root, and the two settings people actually reach for -- +# paper size and double-siding -- are worth exactly two dropdowns. Anything not +# in this table is refused rather than forwarded, so what this page can do to a +# print queue is readable in one place. +OPTION_VOCABULARY: dict[str, tuple[str, ...]] = { + "media": ("Letter", "A4", "Legal"), + "sides": ("one-sided", "two-sided-long-edge", "two-sided-short-edge"), +} + +# How each choice looks when the printer names it, rather than when a PPD does. +# IPP spells paper sizes as self-describing keywords ("na_letter_8.5x11in"), +# PPDs as short names ("Letter"), and a driverless queue can report either -- +# so a choice is recognised by the token both spellings share. +OPTION_TOKENS = {"Letter": "letter", "A4": "a4", "Legal": "legal"} + class BoundaryError(RuntimeError): """A user-visible validation or CUPS failure.""" @@ -235,6 +256,86 @@ def cancel(job_id: str) -> None: raise BoundaryError("That job could not be cancelled.") from error +def matches(choice: str, reported: str) -> bool: + """Whether a value the printer reported is the choice this page offers.""" + token = OPTION_TOKENS.get(choice, choice).lower() + return token in str(reported).lower() + + +def get_options(name: str) -> dict: + """The two defaults this page can change, and which values are on offer. + + Asked of the printer rather than assumed: a queue that cannot do two-sided + should not be offered a two-sided dropdown, and a printer loaded with A4 + should not have to be told it is A4 every time. When the printer reports no + opinion, the whole vocabulary is offered -- an empty dropdown is worse than + a choice that might be refused. + """ + require_queue(name) + cups, connection = connect() + try: + attributes = connection.getPrinterAttributes(name) + except Exception as error: # noqa: BLE001 + raise BoundaryError("That printer's settings could not be read.") from error + + options: dict[str, str] = {} + choices: dict[str, list[str]] = {} + raw: dict[str, str] = {} + for key, vocabulary in OPTION_VOCABULARY.items(): + supported = attributes.get(f"{key}-supported") or [] + if isinstance(supported, (str, bytes)): + supported = [supported] + supported = [str(entry) for entry in supported] + offered = [choice for choice in vocabulary + if any(matches(choice, entry) for entry in supported)] + choices[key] = offered or list(vocabulary) + + current = str(attributes.get(f"{key}-default") or "") + raw[key] = current + options[key] = next( + (choice for choice in vocabulary if current and matches(choice, current)), "") + + return {"printer": name, "options": options, "choices": choices, + "reported": raw, "error": ""} + + +def set_option(name: str, key: str, value: str) -> None: + """One printer default, from the closed table above and nowhere else.""" + require_queue(name) + vocabulary = OPTION_VOCABULARY.get(key) + if vocabulary is None: + raise BoundaryError("That is not a setting this page changes.") + if value not in vocabulary: + raise BoundaryError("That is not a value this setting accepts.") + + cups, connection = connect() + try: + connection.addPrinterOptionDefault(name, key, value) + except Exception as error: # noqa: BLE001 + message = str(error).lower() + if "not-authorized" in message or "forbidden" in message: + raise BoundaryError("Changing that setting was not authorized.") from error + raise BoundaryError("That setting could not be changed.") from error + + +def set_held(job_id: str, held: bool) -> None: + """Hold a job where it is, or let it go. + + A held job stays in the queue rather than leaving it, which is the whole + point: cancelling to stop a print and then reprinting is how a fifty-page + document gets printed twice. + """ + if not job_id.isdigit(): + raise BoundaryError("That is not a job.") + cups, connection = connect() + try: + connection.setJobHoldUntil(int(job_id), "indefinite" if held else "no-hold") + except Exception as error: # noqa: BLE001 + raise BoundaryError( + "That job could not be held." if held + else "That job could not be released.") from error + + def test_page(name: str) -> None: require_queue(name) result = run(["lp", "-d", name, "/usr/share/cups/data/testprint"], timeout=30) @@ -251,6 +352,26 @@ def main(arguments: list[str]) -> int: print(json.dumps(discover(), separators=(",", ":"))) return 0 + # Read-only, and about one printer, so it answers with its own shape. + if len(arguments) == 2 and arguments[0] == "get-options": + try: + answer = get_options(arguments[1]) + except BoundaryError as error: + answer = {"printer": arguments[1], "options": {}, "choices": {}, + "reported": {}, "error": str(error)} + print(json.dumps(answer, separators=(",", ":"))) + return 0 + + # A mutation, so it answers with the fresh snapshot -- carrying the + # printer's re-read options alongside, so the dropdown that made the + # change updates from the reply rather than from a second round trip. + if len(arguments) == 4 and arguments[0] == "set-option": + set_option(arguments[1], arguments[2], arguments[3]) + state = snapshot() + state["options"] = get_options(arguments[1]) + print(json.dumps(state, separators=(",", ":"))) + return 0 + if len(arguments) == 3 and arguments[0] == "add": add(arguments[1], arguments[2]) elif len(arguments) == 2 and arguments[0] == "remove": @@ -263,12 +384,18 @@ def main(arguments: list[str]) -> int: set_paused(arguments[1], False) elif len(arguments) == 2 and arguments[0] == "cancel": cancel(arguments[1]) + elif len(arguments) == 2 and arguments[0] == "hold": + set_held(arguments[1], True) + elif len(arguments) == 2 and arguments[0] == "release": + set_held(arguments[1], False) elif len(arguments) == 2 and arguments[0] == "test-page": test_page(arguments[1]) else: raise BoundaryError( "Usage: panama-printers snapshot | discover | add URI NAME | remove NAME | " - "set-default NAME | pause NAME | resume NAME | cancel JOB_ID | test-page NAME") + "set-default NAME | pause NAME | resume NAME | cancel JOB_ID | " + "hold JOB_ID | release JOB_ID | get-options NAME | " + "set-option NAME KEY VALUE | test-page NAME") except BoundaryError as error: try: state = snapshot() diff --git a/config/dot/quickshell/services/Connectivity.qml b/config/dot/quickshell/services/Connectivity.qml index ad86105..c0ada94 100644 --- a/config/dot/quickshell/services/Connectivity.qml +++ b/config/dot/quickshell/services/Connectivity.qml @@ -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"; diff --git a/config/dot/quickshell/services/Firewall.qml b/config/dot/quickshell/services/Firewall.qml index ac3afc7..a878337 100644 --- a/config/dot/quickshell/services/Firewall.qml +++ b/config/dot/quickshell/services/Firewall.qml @@ -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() + } } diff --git a/config/dot/quickshell/services/NetworkTools.qml b/config/dot/quickshell/services/NetworkTools.qml new file mode 100644 index 0000000..f918928 --- /dev/null +++ b/config/dot/quickshell/services/NetworkTools.qml @@ -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 = "" + } +} diff --git a/config/dot/quickshell/services/Printers.qml b/config/dot/quickshell/services/Printers.qml index 7aa0b20..ed91fd7 100644 --- a/config/dot/quickshell/services/Printers.qml +++ b/config/dot/quickshell/services/Printers.qml @@ -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 { diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index c8726fb..baa5de4 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -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" }, diff --git a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md index 5dda4a9..12fa730 100644 --- a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md +++ b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md @@ -596,3 +596,126 @@ schema itself: F9` against the **real compositor** with an isolated `XDG_CONFIG_HOME`. That was true before this phase and is unchanged, but it is the one contract in this wave that writes to the running keymap, so it wants a quiet moment. + +## Phase 9 (Network & Sharing) — append below + +Spec: `2026-08-24-network-sharing-redesign.md`. Connections became a full +network manager (per-connection details, forget, autoconnect, MAC +randomization, VPN import, hotspot, enterprise Wi-Fi, airplane mode, system +proxy), Firewall grew its add side and a zone browser, Printers gained per- +printer defaults and job hold/release, and Sharing got copy and structure work. +The last two GNOME punt rows in the app died with the "Owned by Fedora" card. + +**Nothing here was run against a live harness.** Three agents edited the tree +concurrently. What *was* verified is listed per contract below. + +### New contracts (1) + +`quickshell/network-tools-contract`. The README count line moves **169 → 170**; +`setup/readme-contract` was run and passes ("170 contracts, as documented"), +counted the same way `panama test` collects the suite. + +### Verified + +- **`quickshell/network-tools-contract` — RUN END TO END, PASS.** It is safe to + run because it never reaches the machine: `env -i` with a stub directory first + on `PATH`, stubs for nmcli/gsettings/rfkill plus every D-Bus client, a + PyGObject stand-in on `PYTHONPATH` whose `require_version` always raises, and + both D-Bus bus addresses pointed at sockets that do not exist. It asserts the + first two of those before running the helper at all, and refuses to continue + if `nmcli`, `gsettings` or `rfkill` resolves anywhere but the stub directory. + Checked with a debug copy that it is doing real work rather than passing + vacuously: the stub's terse listing drives the shipped parse all the way to + `{"ip4":"192.168.7.42/24","gateway":"192.168.7.1","dns":["192.168.7.1", + "1.1.1.1"],"mac":"AA:BB:CC:DD:EE:FF","macRandomized":true,…}` while the three + secret properties in the same listing are absent from it. + - The libnm/D-Bus branch of `join-enterprise` is deliberately **not** + exercised — running it would add a real connection profile. It is covered + statically instead, by an AST walk that fails if any command list in the + helper carries a password-shaped identifier. The nmcli fallback is what the + dynamic half drives, and there the password is asserted present on the + editor's **stdin** and absent from **argv**, stdout, stderr and disk. +- **`quickshell/connectivity-contract` — static half run, and it found a bug.** + See "Still open" below. The new enum check reads the member list out of the + installed `quickshell-network.qmltypes` rather than a hand-kept list, so it + cannot drift the way the two bugs it catches did. +- **`quickshell/firewall-contract` — static half run, PASS** (everything up to + the first live `firewall-cmd` read). +- **`quickshell/printers-contract` — static half run, PASS.** +- **`quickshell/sharing-contract` — static half run, PASS.** +- **`quickshell/gnome-handoff-contract` — RUN END TO END, PASS** (source-only): + "ok (12 handoffs checked against 39 pages)" with `network` and `wifi` now in + OWNED. +- **`quickshell/health-ui-contract` — static half run, PASS.** +- **`PANAMA_SETTINGS_STATIC_ONLY=1 settings-pages-contract` — PASS.** +- **`setup/readme-contract` — RUN END TO END, PASS.** +- `bash -n` on every changed contract; every embedded `python3` heredoc compiled + separately with `py_compile`. + +### Still open before the run + +- **`Connectivity.qml:204` names an enum member that does not exist.** + `ConnectionFailReason.Authentication` is not a member; the real ones are + `NoSecrets`, `Unknown`, `WifiAuthTimeout`, `WifiClientDisconnected`, + `WifiClientFailed`, `WifiNetworkLost`. QML resolves it to `undefined`, the + switch arm never matches, and a wrong password is reported with the + fallthrough text. `NoSecrets` is almost certainly the intended member. + `connectivity-contract`'s new enum check fails on exactly this line and passes + on a copy with it corrected, so the check is confirmed working and the fix is + a one-word edit in a service file this phase's contract owner does not own. + **This is the one item blocking a clean run.** +- **The enum check is scoped to `Connectivity.qml`.** The same class of bug — + a plausible enum member that does not exist, resolving to `undefined` in + silence — can live anywhere that imports `Quickshell.Networking`, including + `modules/quicksettings/WifiList.qml` and the bar's status cluster. Widening + the sweep to every QML file that imports the module is a small change and the + obvious next one. Owner: whoever holds `connectivity-contract` next. +- **`network-tools-contract` cannot see the libnm branch.** That is a deliberate + trade, but it means the branch that actually runs on this machine (NM 1.56, + bindings present) is only ever checked by reading. A machine without PyGObject + would exercise the fallback for real; nothing here has one. +- **Two contracts still pin prose.** `firewall-contract` requires the add flow + to say "permanent" and "ask for your password", and `sharing-contract` + requires "never passes through Panama", "install it and this becomes a switch" + and "does not install software". The spec names all five phrases, so they are + pinned deliberately, but they are the needles a copy edit will trip. +- **`printers-contract` pins the option vocabulary exactly** — `media` ∈ + {Letter, A4, Legal}, `sides` ∈ {one-sided, two-sided-long-edge, + two-sided-short-edge}. Adding a third settable option is meant to be a + deliberate act that updates this contract, but it will read as a surprise the + first time somebody tries. +- **`connectivity-contract`'s live half is unchanged** and still starts a + harness beside the running session, so it wants the same quiet moment it + always did. `PANAMA_CONNECTIVITY_STATIC_ONLY=1` runs the new source-only half + alone. +- Run order for this phase: the source-only contracts first + (`gnome-handoff-contract`, `PANAMA_NETWORK_STATIC_ONLY=1 + network-tools-contract`, `PANAMA_CONNECTIVITY_STATIC_ONLY=1 + connectivity-contract`, `PANAMA_SETTINGS_STATIC_ONLY=1 + settings-pages-contract`, `setup/readme-contract`), then + `network-tools-contract` in full — it is hermetic, so it can run at any time — + then the read-only system contracts (`firewall-contract`, + `printers-contract`, `sharing-contract`), then the harness contracts + (`connectivity-contract`, `health-ui-contract`, `settings-search-contract`), + and `settings-pages-contract` last, as before. + +### Docs updated in the same wave + +- `services/SettingsSearch.qml` — the three "Managed by GNOME Settings" + connectivity entries are gone, and with them the duplicate **Printers** entry + that routed to `connectivity` rather than to the Printers page. Ten entries + replace them, all routing to `connectivity`: **Wi-Fi**, **Bluetooth**, **VPN**, + **Import a VPN**, **Hotspot**, **Airplane mode**, **Network proxy**, **IP + address**, **Forget a Wi-Fi network**, **Enterprise Wi-Fi**. Wi-Fi and + Bluetooth were rewritten rather than deleted: the spec lists eight additions, + none of which contains the word "Bluetooth", so deleting the lying entry + outright would have made a switch the page has unreachable by search. +- Checked against `settings-search-contract`'s pinned query list: none of its 21 + ranked queries, nor the nine leaf-routing queries, matches any new or changed + entry, so no pinned top result moves. `connectivity` is a leaf in + `SettingsRoutes`, so the routing sweep holds. +- No settings docs or launcher commands were regenerated: this phase added no + schema keys, so there is nothing for `panama-settings-docs` or + `panama-settings-commands` to pick up. Verified by reading the diff — every + new setting here is system state (NetworkManager, firewalld, CUPS, gsettings), + not a Panama preference. diff --git a/docs/superpowers/specs/2026-08-24-network-sharing-redesign.md b/docs/superpowers/specs/2026-08-24-network-sharing-redesign.md new file mode 100644 index 0000000..f83af2f --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-network-sharing-redesign.md @@ -0,0 +1,138 @@ +# Network & Sharing redesign — the last punt dies + +Approved mock: `home-mocks/network.html` (scratchpad, :8642). Spec wins over mock on conflict. + +## Goals + +1. **Connections becomes a full network manager**: per-connection details (IP/DNS/gateway/MAC), + forget + autoconnect + MAC randomization, native VPN card with import, hotspot, airplane + mode, enterprise Wi-Fi join, system proxy. The "Owned by Fedora" card and both GNOME punt + rows are removed. +2. **Firewall gains its add side**: allow service/port flow, per-connection zone dropdowns, + default zone, zone browser — all existing helper verbs plus one new read-only one. +3. **Printers**: per-printer defaults (paper size, two-sided) and job hold/release. +4. **Sharing**: copy/structure polish only (error banner into a card, honest Samba row, RDP + credential flow copy without naming the terminal in user-facing text). +5. Search stops lying ("Managed by GNOME Settings" entries die; duplicate Printers entry + deduped; new entries for VPN, hotspot, airplane, proxy, IP address). + +Non-goals: Samba share management (not installed here — the row explains what installing +unlocks), PPD/vendor drivers (contract-banned), WWAN (no hardware), per-connection static IP +editing (view-only details this phase). + +## New helper: `scripts/panama-network` (pinned verbs) + +Python, the panama-vpn/panama-sharing discipline: validated inputs, JSON out, bounded +timeouts, mutations return fresh state. Input validation: connection/SSID names against a +conservative charset, file paths must exist and end in .conf/.ovpn for import. + +- `details ` → `{ ip4, gateway, dns: [], mac, macRandomized }` — **never secrets**. +- `forget ` — `nmcli connection delete`. +- `set-autoconnect `. +- `set-mac-random ` — `wifi.cloned-mac-address random|permanent`; the JSON + notes a reconnect is needed to take effect. +- `import-vpn ` — `nmcli connection import type wireguard|openvpn file …` (type by + extension); returns the imported connection's name. +- `hotspot start ` / `hotspot stop` / `hotspot status` — `nmcli device wifi hotspot`; + the generated password is read back via `nmcli device wifi show-password` and returned once + for the UI to display alongside a QR (reuse the panama-wifi-qr pipeline if trivial, else + text-only this phase). +- `join-enterprise` — SSID + eap profile (peap-mschapv2 | ttls-pap) + identity on argv, + **password on stdin, never argv**. Mechanism: probe for python NM gi bindings + (`gi.require_version('NM','1.0')`) and use D-Bus AddConnection when present; else fall back + to a scripted `nmcli connection edit` session over stdin (which keeps the secret out of ps). + Agent A picks after probing and documents the choice in the script header. +- `proxy get` / `proxy set [host port | pac-url]` — gsettings `org.gnome.system.proxy` + (mode none|manual|auto; manual sets http+https+socks host/port together this phase). +- `airplane status` / `airplane set ` — rfkill, matching the existing keybind path. + +## Services (A) + +- **`services/NetworkTools.qml`** (new singleton) wraps panama-network: cached `detailsFor + (connection)` (refreshed on page open + active-connection change), `forget`, `setAutoconnect`, + `setMacRandom`, `importVpn(path)`, hotspot state/start/stop, `proxyMode`+setters, + `airplaneOn`+toggle, `joinEnterprise(...)` (password handed through a Process stdin write), + `busy`/`lastError`. Test seam: `PANAMA_NETWORK_HELPER` env override for the helper path. +- **`services/Connectivity.qml`** stays pure-native (contract-pinned no-shell-out): add + `setWifiEnabled(bool)` and `setBluetoothEnabled(bool)` wrappers (native writes) so the page + toggles stop bypassing the service; add `objectName`-friendly derived state if needed. +- **`services/Firewall.qml`** + `scripts/panama-firewall`: new read-only verb + `zone-info ` → services/ports/summary for the zone browser; service exposes + `zoneInfo(zone)` with a small cache. Existing verbs untouched. +- **`services/Printers.qml`** + `scripts/panama-printers`: new verbs + `get-options ` (lpoptions: media, sides — curated keys only), `set-option + ` (validated against a closed key/value vocabulary: + media=Letter|A4|Legal, sides=one-sided|two-sided-long-edge|two-sided-short-edge), + `hold ` / `release ` (python3-cups). Mutations return fresh snapshots. +- `services/Vpn.qml` untouched (import lands through NetworkTools; the list/toggle stays as-is, + now also consumed by the page). + +## UI (B) + +**ConnectivityPage.qml** rebuilt: Wired card (connection row expands to a details KV grid); +Wi-Fi card (toggle via `Connectivity.setWifiEnabled`, connected network expands to details + +autoconnect + MAC-randomize + QR share + Forget with a two-stage confirm; other networks keep +Join/inline password; enterprise networks get an inline join form — auth dropdown, identity, +password field, optional CA file path, Connect; hotspot row at the bottom); **VPN card** +(list from `Vpn.qml` with per-VPN toggles, empty state + Import row → file path entry or +zenity-free inline TextField this phase); Bluetooth card (toggle via service wrapper); +**Radios & proxy card** (airplane toggle, proxy dropdown expanding to manual host/port or PAC +URL fields). The "Owned by Fedora" card is deleted. Page gains `objectName: "connectivity"`. +KV details grid is a new small component (`ConnectionDetails.qml`) with mono values. + +**FirewallPage.qml**: existing exposure/remove flows untouched; "Allow something new" row +expanding to a kind dropdown (Named service / Port) + validated TextField + Allow button + +the "permanent rule, the system will ask for your password" caption; Zones card gains +per-connection zone dropdowns (`setZone`, with a confirm that names the interface), default +zone dropdown (`setDefaultZone`), and a zone-browser chip row driving `zoneInfo` into a detail +line. Only render `zones[0]`'s rules card per current behavior, but the zones card now lists +every active zone. + +**SharingPage.qml**: error banner moves into the This-machine card; Remote-login card +compresses port/password-mode/connect-with into row details; RDP credentials copy becomes +"Set in a terminal so the password never passes through Panama" (kitty stays the mechanism); +Samba row explains "install it and this becomes a switch". + +**PrintersPage.qml**: expanded printer gains Paper size + Two-sided dropdowns (from +`get-options`, written via `set-option`); queue rows gain Hold/Release next to Cancel; the +two duplicate "Search the network" rows merge into the single empty-state card per mock. + +## Search & docs (C) + +Delete the three "Managed by GNOME Settings" connectivity entries and the duplicate Printers +entry. Add (page connectivity): VPN, Import a VPN, Hotspot, Airplane mode, Network proxy, +IP address, Forget a Wi-Fi network, Enterprise Wi-Fi. Keep existing printers/firewall/sharing +entries. Docs regen: none needed (no schema keys) — verify. + +## Contracts (C — write, never run) + +- `connectivity-contract`: still native-only — verify the no-shell-out pin survives (all nmcli + lives in NetworkTools/panama-network); add needles for the service-wrapper toggles. +- New `network-tools-contract`: stubbed-nmcli fixtures (the vpn-contract pattern): details + excludes secrets, forget/autoconnect argv shapes, import type-by-extension, enterprise + password arrives via stdin and NEVER argv (assert the argv builder), proxy gsettings calls, + airplane rfkill. +- `firewall-contract`: extend for the add flow (additions are non-destructive, no confirm + required; zone changes ARE consequence-bearing — pin that changing a connection's zone names + the interface in its confirm), `zone-info` read-only. +- `printers-contract`: closed option vocabulary pinned (no arbitrary lpadmin -o passthrough), + hold/release verbs, driverless pins untouched. +- `gnome-handoff-contract`: the `network` exclusion reason ("Panama has no VPN or + per-connection routing") is now false — move `network` and `wifi` into OWNED so no page may + punt to them, and verify nothing does. +- `sharing-contract`, `settings-pages-contract`: needles reconciled. +- Backlog: Phase 9 section. + +## Agent ownership (parallel) + +- **A**: `scripts/panama-network` (new), `scripts/panama-firewall`, `scripts/panama-printers`, + `services/NetworkTools.qml` (new), `services/Connectivity.qml`, `services/Firewall.qml`, + `services/Printers.qml`. +- **B**: the four pages + new components (+ qmldir). +- **C**: `services/SettingsSearch.qml`, contracts above, backlog, README count line only if + count changes. + +B programs against the pinned verbs/APIs; A must not change them without updating this spec. +Live-desktop rules apply to everyone: no test runs, no harness boots, valid QML/Python at every +save, and **no live mutations** — no nmcli writes, no firewall-cmd writes, no lpadmin, no +rfkill, no gsettings writes; read-only probes only. diff --git a/tests/quickshell/connectivity-contract b/tests/quickshell/connectivity-contract index 2c3a5c6..71e3714 100755 --- a/tests/quickshell/connectivity-contract +++ b/tests/quickshell/connectivity-contract @@ -20,12 +20,126 @@ set -euo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" harness="$repo_dir/config/dot/quickshell/connectivity-harness.qml" +service="$repo_dir/config/dot/quickshell/services/Connectivity.qml" +page="$repo_dir/config/dot/quickshell/modules/settings/ConnectivityPage.qml" fail() { printf 'connectivity contract: %s\n' "$1" >&2 exit 1 } +# ── Connectivity stays native ──────────────────────────────────────────────── +# +# This service reads NetworkManager and BlueZ through Quickshell's own bindings, +# never by shelling out. That is not a style preference: a subprocess per read +# turns a property binding into a fork on every repaint, and it loses the change +# signals the whole page is built on -- the page would go back to polling and +# would go stale between polls. +# +# When Connections grew a helper (`panama-network`, driven by NetworkTools), the +# obvious shortcut was to let this service reach for it too. It must not. Every +# nmcli invocation belongs on the far side of that helper; what stays here is +# the native state and the wrappers over it. +for path in "$service" "$page"; do + [[ -r "$path" ]] || fail "missing $path" +done +service_code="$(grep -vE '^\s*//' "$service")" +! grep -qE '\bnmcli\b|\bProcess\b|execDetached|exec\(' <<<"$service_code" \ + || fail 'Connectivity.qml shells out; NetworkManager reads here are native, and nmcli belongs in panama-network' + +# The page used to write NetworkManager state straight from a switch +# (`Networking.wifiEnabled = value`, `adapter.enabled = value`), which put two +# owners on one piece of state and left the service unable to react to its own +# change. The wrappers exist so the page has exactly one way in. +grep -q 'function setWifiEnabled' <<<"$service_code" \ + || fail 'the service has no Wi-Fi wrapper, so the page has to write NetworkManager itself' +grep -q 'function setBluetoothEnabled' <<<"$service_code" \ + || fail 'the service has no Bluetooth wrapper, so the page has to write the adapter itself' + +page_code="$(grep -vE '^\s*//' "$page")" +grep -q 'Connectivity.setWifiEnabled' <<<"$page_code" \ + || fail 'the Wi-Fi switch does not go through the service' +grep -q 'Connectivity.setBluetoothEnabled' <<<"$page_code" \ + || fail 'the Bluetooth switch does not go through the service' +! grep -qE 'Networking\.wifiEnabled\s*=' <<<"$page_code" \ + || fail 'the page still writes Networking.wifiEnabled directly, bypassing the service' +! grep -qE 'adapter\.enabled\s*=' <<<"$page_code" \ + || fail 'the page still writes the Bluetooth adapter directly, bypassing the service' + +# The card that sent people to GNOME for Wi-Fi and networking is gone, and with +# it the claim that Fedora owns this page. gnome-handoff-contract pins the rule; +# this pins the specific rows, because they are what the card was made of. +! grep -q 'Owned by Fedora' <<<"$page_code" \ + || fail 'the "Owned by Fedora" card is back on a page that now manages the network itself' +! grep -qE 'openGnomePanel\("(wifi|network)"' <<<"$page_code" \ + || fail 'Connections still hands the user to GNOME for something it now does' + +# ── Every enum member the service names must exist ─────────────────────────── +# +# This is the first bug in this file's header, generalized. `NetworkDeviceType +# .Wifi` does not exist, so the lookup returned null and the page said "No Wi-Fi +# adapter" on a machine whose Wi-Fi was connected. The same thing happened again +# in securityLabel: `WifiSecurityType.Wep`, `.Wpa`, `.Wpa2`, `.Wpa3` and +# `.Enterprise` are not members either -- the real ones are WpaPsk, Wpa2Psk, +# Sae, StaticWep, WpaEap and so on -- so every switch arm compared against +# `undefined`, nothing ever matched, and every secured network was labeled with +# the fallthrough. Plausible names, no error, wrong answer. +# +# QML resolves an unknown enum member to undefined and says nothing, so no test +# that runs the code can notice. This reads the member list out of the installed +# Quickshell's own type description, so it stays true across upgrades rather +# than becoming a second hand-kept list that can drift the same way. +networking_types="" +for candidate in /usr/lib64/qt6/qml/Quickshell/Networking/quickshell-network.qmltypes \ + /usr/lib/qt6/qml/Quickshell/Networking/quickshell-network.qmltypes; do + [[ -r "$candidate" ]] && { networking_types="$candidate"; break; } +done +if [[ -z "$networking_types" ]]; then + printf 'connectivity contract: NOTE (no Quickshell.Networking qmltypes found; enum members unchecked)\n' +else + python3 - "$service" "$networking_types" <<'PY' || fail 'the service names enum members that do not exist, which QML resolves to undefined without complaining' +import re +import sys + +service_path, types_path = sys.argv[1:3] +types_text = open(types_path, encoding="utf-8").read() + +# Each exported enum singleton: the name QML sees, and the members it has. +enums = {} +for block in re.findall(r"Component \{.*?\n \}", types_text, re.S): + export = re.search(r'exports: \["Quickshell\.Networking/([A-Za-z0-9_]+) ', block) + values = re.search(r"values: \[(.*?)\]", block, re.S) + if export and values: + enums[export.group(1)] = set(re.findall(r'"([A-Za-z0-9_]+)"', values.group(1))) +if not enums: + print(f"no enums could be read from {types_path}", file=sys.stderr) + raise SystemExit(1) + +source = "\n".join(line for line in open(service_path, encoding="utf-8") + if not line.lstrip().startswith("//")) + +bad = [] +for enum_name, members in enums.items(): + for member in set(re.findall(rf"\b{re.escape(enum_name)}\.([A-Za-z0-9_]+)\b", source)): + if member not in members: + bad.append(f"{enum_name}.{member} (real members: {', '.join(sorted(members))})") + +# A switch on a security type that names no member of the enum at all is the +# same failure wearing a different hat, so the reference must be there too. +if "securityLabel" in source and not re.search(r"\bWifiSecurityType\.", source): + bad.append("securityLabel decides security without naming a WifiSecurityType member") + +if bad: + print("\n".join(bad), file=sys.stderr) + raise SystemExit(1) +PY +fi + +if [[ "${PANAMA_CONNECTIVITY_STATIC_ONLY:-0}" == "1" ]]; then + printf 'connectivity contract: PASS (static)\n' + exit 0 +fi + command -v nmcli >/dev/null || fail 'nmcli is needed to check the service against reality' run() { qs -p "$harness" "$@"; } diff --git a/tests/quickshell/firewall-contract b/tests/quickshell/firewall-contract index bd699ba..7dcc6cc 100755 --- a/tests/quickshell/firewall-contract +++ b/tests/quickshell/firewall-contract @@ -113,6 +113,86 @@ grep -q 'richRules' <<<"$page_code" || fail 'rich rules are not shown' grep -qiE 'addRichRule|removeRichRule|--add-rich-rule' "$helper" "$page" \ && fail 'the page edits rich rules, which are a syntax rather than a setting' +# ── 6. Opening something is not the same act as closing it ────────────────── +# +# The page gained its add side long after its remove side, and the temptation +# was to give both the same confirm-then-act shape for symmetry. That would be +# wrong, and wrong in the direction that matters: a confirmation dialog is how +# this page says "this has a consequence you cannot see from here". Allowing a +# port has exactly one consequence, and it is the sentence the user just read on +# the button. Spending a confirm on it teaches people to click through the ones +# that mean something. +# +# So: additions go straight through, removals and zone changes do not. +grep -q 'Firewall.addService\|addService(' <<<"$page_code" \ + || fail 'the page cannot allow a named service, so the firewall is still read-only from here' +grep -q 'Firewall.addPort\|addPort(' <<<"$page_code" \ + || fail 'the page cannot allow a port' +grep -qE 'confirming(Add|Allow|Service|Port)\b' <<<"$page_code" \ + && fail 'allowing something asks for a confirmation; that ceremony belongs to the actions that cut people off' +# What it must say instead, because both facts are invisible from the button: +# the rule outlives a reboot, and firewalld will raise a polkit prompt. +grep -qi 'permanent' <<<"$page_code" \ + || fail 'the add flow never says the rule is permanent' +grep -qi 'ask for your password' <<<"$page_code" \ + || fail 'the add flow never warns that the system will ask for a password' + +# A zone change IS consequence-bearing, and its consequence is specific: it +# changes which rules apply to one named interface, and every other interface +# keeps the zone it had. A confirm that says "change zone?" tells the user +# nothing they did not already know, so this pins that the interface is named. +grep -q 'setZone' <<<"$page_code" \ + || fail 'the page cannot change a connection zone' +grep -q 'setDefaultZone' <<<"$page_code" \ + || fail 'the page cannot change the default zone' +grep -qE '(confirming|pending)(Zone|Interface)' <<<"$page_code" \ + || fail 'a connection zone can be changed without confirming, and it decides which rules apply to that link' +python3 - "$page" <<'PY' || fail 'the zone-change confirmation does not name the interface it applies to' +import re +import sys + +lines = open(sys.argv[1], encoding="utf-8").read().splitlines() +anchors = [i for i, line in enumerate(lines) + if re.search(r"(confirming|pending)(Zone|Interface)", line)] +if not anchors: + raise SystemExit(1) +# Any one of the two-stage anchors may be the one carrying the prose; the +# declaration of the state is usually not. +for anchor in anchors: + window = "\n".join(lines[max(0, anchor - 20):anchor + 60]) + named = re.search(r"(interface|iface)", window, re.I) + interpolated = re.search(r"\$\{|\" \+ |\+ \"", window) + if named and interpolated: + raise SystemExit(0) +raise SystemExit(1) +PY + +# ── 7. The zone browser reads and does not write ──────────────────────────── +# +# `zone-info` exists so somebody can look at what a zone would do before moving +# an interface into it. A read that can write is not a browser, it is a foot-gun +# with a magnifying glass on it. +grep -q 'zone-info' "$helper" \ + || fail 'the helper cannot describe a zone, so the zone browser has nothing to show' +grep -q 'zoneInfo' "$service" \ + || fail 'the service does not expose zone descriptions' +python3 - "$helper" <<'PY' || fail 'the zone-info path can change the firewall' +import re +import sys + +source = open(sys.argv[1], encoding="utf-8").read() +match = re.search(r"\ndef zone_info\b.*?(?=\ndef |\Z)", source, re.S) +if not match: + raise SystemExit(1) +body = match.group(0) +# --info-zone and --list-* are reads. Anything that adds, removes, changes or +# makes permanent is not. +if re.search(r"--(add|remove|change|set|permanent|reload)", body): + print(body[:400], file=sys.stderr) + raise SystemExit(1) +raise SystemExit(0) +PY + command -v jq >/dev/null 2>&1 || { printf 'firewall contract: SKIP (no jq)\n'; exit 0; } state="$("$helper" snapshot 2>/dev/null)" || fail 'snapshot failed' @@ -142,8 +222,25 @@ if [[ "$(jq -r '.available' <<<"$state")" == "true" ]]; then || fail 'a loopback-only listener is reported as reachable' fi +# ── zone-info describes a zone and leaves it exactly as it found it ───────── +if [[ "$(jq -r '.available' <<<"$state")" == "true" ]]; then + zone_name="$(jq -r '.zones[0].name // ""' <<<"$state")" + if [[ -n "$zone_name" ]]; then + info="$("$helper" zone-info "$zone_name" 2>/dev/null)" \ + || fail "zone-info failed for the zone this machine is actually in ($zone_name)" + jq -e '(.services | type == "array") and (.ports | type == "array") and has("summary")' \ + <<<"$info" >/dev/null \ + || fail "zone-info does not describe services, ports and a summary: $info" + after="$("$helper" snapshot 2>/dev/null)" || fail 'the snapshot after zone-info failed' + [[ "$(jq -cS '.zones' <<<"$after")" == "$(jq -cS '.zones' <<<"$state")" ]] \ + || fail 'reading a zone changed the firewall, which is the one thing a browser must not do' + fi +fi + # ── Refusals ──────────────────────────────────────────────────────────────── refusal() { "$helper" "$@" 2>/dev/null | jq -r '.error // ""'; } +[[ -n "$(refusal zone-info 'public; reboot')" ]] || fail 'a bad zone name was accepted by zone-info' +[[ -n "$(refusal zone-info '')" ]] || fail 'an empty zone name was accepted by zone-info' for bad in "ssh; rm -rf /" "../escape" "" "UPPER CASE"; do [[ -n "$(refusal add-service "$bad")" ]] || fail "a bad service name was accepted: $bad" done diff --git a/tests/quickshell/gnome-handoff-contract b/tests/quickshell/gnome-handoff-contract index 202ed0a..ccebc14 100755 --- a/tests/quickshell/gnome-handoff-contract +++ b/tests/quickshell/gnome-handoff-contract @@ -33,11 +33,23 @@ fail() { [[ -r "$routes" ]] || fail "missing $routes" # GNOME panel names that correspond to a Panama page. Only entries whose panel -# genuinely duplicates a Panama page belong here: "network" stays off it because -# Panama has no VPN or per-connection routing, and "online-accounts" is listed -# because Panama has an Online Accounts page -- but adding an account still has -# to go through GOA's own dialog, so that one exception is named explicitly. +# genuinely duplicates a Panama page belong here. +# +# "network" and "wifi" were deliberately kept off this list, on the reason that +# Panama had no VPN and no per-connection routing, so GNOME's panel really did +# do more. That stopped being true: Connections now carries per-connection +# details, forget, autoconnect, MAC randomization, a VPN list with import, a +# hotspot, enterprise Wi-Fi, airplane mode and the system proxy. The two rows +# that pointed at GNOME were the last thing on that page telling the user to go +# somewhere else for something it does, so both panels moved here and the rows +# went with them. +# +# "online-accounts" is listed because Panama has an Online Accounts page -- but +# adding an account still has to go through GOA's own dialog, so that one +# exception is named explicitly below. declare -A OWNED=( + [network]=connectivity + [wifi]=connectivity [printers]=printers [online-accounts]=accounts [sharing]=sharing diff --git a/tests/quickshell/health-ui-contract b/tests/quickshell/health-ui-contract index 4be8e72..29fd62d 100755 --- a/tests/quickshell/health-ui-contract +++ b/tests/quickshell/health-ui-contract @@ -61,8 +61,16 @@ rg -Fq 'implicitHeight: 62' "$settings_dir/HealthCheckRow.qml" \ || fail 'health rows are below the approved 62px target' rg -Fq 'Health.refresh()' "$settings_dir/HealthPage.qml" \ || fail 'opening System Health does not request a fresh scan' -rg -Fq 'SystemSettings.openGnomePanel("network")' "$settings_dir/HealthPage.qml" \ +# "Open GNOME Settings" opens the application, not a panel -- but +# gnome-control-center will not start without naming one, so it names the +# landing page. It used to name "network", which stopped being honest when +# Connections absorbed VPN, hotspot, proxy and per-connection details: Panama +# owns that panel now, and gnome-handoff-contract fails any page pointing at an +# owned one. "system" is a panel Panama does not have. +rg -Fq 'SystemSettings.openGnomePanel("system")' "$settings_dir/HealthPage.qml" \ || fail 'Fedora ownership boundary does not open GNOME Settings' +rg -Fq 'SystemSettings.openGnomePanel("network")' "$settings_dir/HealthPage.qml" \ + && fail 'System Health lands GNOME Settings on its network panel, which Panama now owns' # Users and Sharing are Panama pages now. A handoff here would send someone to # GNOME for a panel this app owns, which is the opposite of the point -- so the # assertion is inverted rather than deleted. diff --git a/tests/quickshell/network-tools-contract b/tests/quickshell/network-tools-contract new file mode 100755 index 0000000..4cc1561 --- /dev/null +++ b/tests/quickshell/network-tools-contract @@ -0,0 +1,614 @@ +#!/usr/bin/env bash + +# Everything Connections learned to do, against a NetworkManager that is not +# real. +# +# This helper is the first one in Panama that handles a secret the user types. +# Three of its verbs sit next to a password: joining an enterprise network takes +# one, starting a hotspot generates one, and reading a connection's details sits +# on top of a store full of them. Each of those is a different way for a +# credential to escape: +# +# * on argv, where /proc publishes it to every process on this machine for as +# long as the command runs -- the same failure the Sharing page already +# refuses to have (sharing-contract, "the remote desktop password never +# passes through Panama"); +# * in a log line, which outlives the command; +# * in the details JSON, which the page renders and which is the one place a +# stored PSK would look like it belonged. +# +# So the pins here are mostly about what must NOT appear, and they are checked +# from the data rather than from the source: the stub NetworkManager will hand +# over a passphrase to anything that asks for one, and the contract fails if any +# of them reaches the JSON, the argv log, or a file the helper wrote. +# +# The rest is the shape of the commands. `import-vpn` picking its plugin from +# the file extension, and the connection-name charset, are both places where a +# wrong guess produces a nonsense nmcli invocation rather than an error, so both +# are exercised rather than read. +# +# SAFETY. This runs the real helper, so it must be impossible for it to reach +# the real NetworkManager. Four things make that true, and the contract verifies +# the first two before running anything: +# +# 1. every system binary the helper names is resolved through PATH (asserted +# statically -- an absolute /usr/bin/nmcli would walk straight past the +# stubs), and PATH's first entry is the stub directory; +# 2. nmcli, gsettings, rfkill and every D-Bus client are stubbed, each +# recording its arguments and its stdin instead of doing anything; +# 3. `import gi` resolves to a stand-in on PYTHONPATH whose require_version +# always raises, so join-enterprise cannot take its libnm/D-Bus branch and +# falls back to nmcli, where the recording can see what it did; +# 4. both D-Bus bus addresses point at sockets that do not exist, so anything +# that got past (3) still could not connect. +# +# That means the native libnm branch is not exercised here. It is covered +# statically instead, by the check that no command list anywhere in the helper +# carries the password -- the failure that branch could have. +# +# Set PANAMA_NETWORK_STATIC_ONLY=1 to run only the source-reading half, which +# touches nothing at all. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$repo_dir/config/dot/quickshell/scripts/panama-network" +service="$repo_dir/config/dot/quickshell/services/NetworkTools.qml" + +fail() { + printf 'network tools contract: %s\n' "$1" >&2 + exit 1 +} + +for path in "$helper" "$service"; do + [[ -r "$path" ]] || fail "missing $path" +done +[[ -x "$helper" ]] || fail 'panama-network is not executable' + +# Sentinels. Every one of these is a value the fake NetworkManager will hand +# over, and none of them may come back out. +readonly WIFI_PSK='psk-must-never-leave-9c1f' +readonly VPN_SECRET='vpn-secret-must-never-leave-7b20' +readonly ENTERPRISE_PW='enterprise-pw-must-never-leave-4e88' +readonly HOTSPOT_PW='hotspot-pw-must-never-leave-3a55' + +# ── Static: the helper cannot walk past the stubs ──────────────────────────── +# +# Checked first because the dynamic half's safety rests on it. A helper that +# spelled its NetworkManager client `/usr/bin/nmcli` would ignore the stub +# directory entirely and reconfigure the machine running the test. +absolute="$(grep -nE '"/(usr/)?s?bin/[a-z-]+"' "$helper")" +[[ -z "$absolute" ]] \ + || fail "the helper names a binary by absolute path, so PATH stubs cannot contain it: $absolute" + +# ── Static: reading a connection cannot read a secret ──────────────────────── +# +# By name, in one table, refused in the single function every property read goes +# through -- so a field added later cannot quietly become a leak, which a +# hand-written allowlist of safe fields would eventually permit. +grep -q 'SECRET_PROPERTIES' "$helper" \ + || fail 'the secret-holding properties are not named anywhere, so nothing can refuse them' +grep -q 'SECRET_SHAPE' "$helper" \ + || fail 'only a fixed list guards the secrets; a property NetworkManager adds later would leak until somebody noticed' +python3 - "$helper" <<'PY' || fail 'the property reader does not drop the secret properties' +import ast +import sys + +source = open(sys.argv[1], encoding="utf-8").read() +tree = ast.parse(source) + +secrets = set() +for node in tree.body: + target = "" + if isinstance(node, ast.AnnAssign): + target = getattr(node.target, "id", "") + elif isinstance(node, ast.Assign): + target = getattr(node.targets[0], "id", "") + if target != "SECRET_PROPERTIES": + continue + value = node.value + # frozenset({...}) / set({...}) is a call wrapped around the literal. + if isinstance(value, ast.Call) and value.args: + value = value.args[0] + try: + secrets = set(ast.literal_eval(value)) + except (ValueError, TypeError): + secrets = set() + +# The properties that actually hold a passphrase must be in it, or the table is +# decorative. +required = {"802-11-wireless-security.psk", "802-1x.password"} +if not required <= secrets: + print(f"missing from the refusal table: {sorted(required - secrets)}", file=sys.stderr) + raise SystemExit(1) + +# And the filter has to be applied where NetworkManager is read, not at each +# use: one place that can be certain, rather than every caller remembering. +reader = next((node for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name == "listing"), None) +if reader is None: + print("no single function parses nmcli output", file=sys.stderr) + raise SystemExit(1) +raise SystemExit(0 if "is_secret" in ast.dump(reader) else 1) +PY +grep -q -- '--show-secrets' "$helper" \ + && fail 'the helper asks NetworkManager to print secrets; the details view has no use for them' + +# ── Static: the enterprise password is read, not passed ────────────────────── +# +# The mechanism is a choice (libnm's GObject bindings when they are installed, a +# scripted `nmcli connection edit` otherwise), but neither may build a command +# that carries the password. This is the only check that reaches the libnm +# branch, because the dynamic half deliberately disables it. +grep -q 'sys.stdin' "$helper" \ + || fail 'the enterprise password is never read from stdin' +python3 - "$helper" <<'PY' || fail 'a command list in the helper carries the enterprise password' +import ast +import re +import sys + +source = open(sys.argv[1], encoding="utf-8").read() +# Names, not values: the value only exists at runtime. Any list literal that +# holds a system command and a password-shaped identifier is argv exposure. +SECRETISH = re.compile(r"(password|passwd|secret|psk|passphrase)", re.I) +COMMANDS = {"nmcli", "gsettings", "rfkill", "gdbus", "busctl", "dbus-send"} +bad = [] +for node in ast.walk(ast.parse(source)): + if not isinstance(node, (ast.List, ast.Tuple)): + continue + literals = {element.value for element in node.elts + if isinstance(element, ast.Constant) and isinstance(element.value, str)} + if not literals & COMMANDS: + continue + for element in node.elts: + if isinstance(element, ast.Name) and SECRETISH.search(element.id): + bad.append(f"line {node.lineno}: {element.id}") + elif isinstance(element, ast.Attribute) and SECRETISH.search(element.attr): + bad.append(f"line {node.lineno}: .{element.attr}") + elif isinstance(element, ast.JoinedStr): + for part in ast.walk(element): + if isinstance(part, ast.Name) and SECRETISH.search(part.id): + bad.append(f"line {node.lineno}: f-string {part.id}") +if bad: + print("; ".join(bad), file=sys.stderr) + raise SystemExit(1) +PY + +# ── Static: the service hands the password down the same way ───────────────── +grep -q 'PANAMA_NETWORK_HELPER' "$service" \ + || fail 'the service has no helper-path seam, so nothing can point it at a stub' +grep -q 'function joinEnterprise' "$service" \ + || fail 'the service cannot join an enterprise network' +python3 - "$service" <<'PY' || fail 'the service puts the enterprise password on the command line' +import re +import sys + +text = open(sys.argv[1], encoding="utf-8").read() +start = text.find("function joinEnterprise") +if start < 0: + raise SystemExit(1) +depth = 0 +end = start +for index in range(text.find("{", start), len(text)): + if text[index] == "{": + depth += 1 + elif text[index] == "}": + depth -= 1 + if depth == 0: + end = index + break +body = text[start:end + 1] + +# Every argument list the function builds, checked for a password-shaped name. +lists = re.findall(r"\[[^\[\]]*\]", body) +if not lists: + print("joinEnterprise builds no argument list at all", file=sys.stderr) + raise SystemExit(1) +for argv in lists: + if re.search(r"(password|secret|passphrase|psk)", argv, re.I): + print(argv.strip()[:200], file=sys.stderr) + raise SystemExit(1) +raise SystemExit(0) +PY +grep -qE '\.write\(|stdinEnabled' "$service" \ + || fail 'the service never writes to the helper process stdin, so the password has no way in' + +if [[ "${PANAMA_NETWORK_STATIC_ONLY:-0}" == "1" ]]; then + printf 'network tools contract: PASS (static)\n' + exit 0 +fi + +command -v jq >/dev/null 2>&1 || { printf 'network tools contract: SKIP (no jq)\n'; exit 0; } +command -v python3 >/dev/null 2>&1 || { printf 'network tools contract: SKIP (no python3)\n'; exit 0; } + +# ── The fake machine ───────────────────────────────────────────────────────── + +work="$(mktemp -d /tmp/panama-network-contract.XXXXXX)" +stub_dir="$work/bin" +state_dir="$work/state" +home_dir="$work/home" +config_home="$work/config" +state_home="$work/xdg-state" +run_dir="$work/run" +pystub="$work/pystub" +mkdir -p "$stub_dir" "$state_dir" "$home_dir" "$config_home" "$state_home" "$run_dir" "$pystub/gi" +: >"$state_dir/argv" +: >"$state_dir/stdin" +printf '11111111-0000-0000-0000-000000000001\n22222222-0000-0000-0000-000000000002\n' \ + >"$state_dir/uuids" +trap 'rm -rf "$work"' EXIT + +# The stand-in for PyGObject. libnm's own branch hands the password to +# NetworkManager over D-Bus, which is correct and untestable from here -- it +# would configure the real machine. Forcing its absence puts the fallback under +# test instead, and that is the branch where a password could become an +# argument. +cat >"$pystub/gi/__init__.py" <<'GISTUB' +"""Stand-in for PyGObject, so panama-network takes its nmcli fallback.""" + + +def require_version(namespace, version): + raise ValueError(f"Namespace {namespace} not available") +GISTUB + +# nmcli, recorded rather than performed. `-g FIELD` is answered from a table +# that INCLUDES the secret properties: a helper that asked for a passphrase +# would be given one, which is what makes "no secret reached the JSON" a real +# result rather than a tautology. +cat >"$stub_dir/nmcli" <>"\$state/argv" +joined="\$*" + +# The detail listing INCLUDES the secret properties. A helper that handed its +# output straight to the page would leak them, which is what makes "no secret +# reached the JSON" a result rather than a tautology. +detail() { + printf 'connection.id:Home Wi-Fi\n' + printf 'connection.uuid:22222222-0000-0000-0000-000000000002\n' + printf 'connection.type:802-11-wireless\n' + printf 'connection.autoconnect:yes\n' + printf '802-11-wireless.cloned-mac-address:random\n' + printf '802-11-wireless.ssid:panama-hotspot\n' + printf '802-11-wireless.band:bg\n' + printf 'GENERAL.STATE:activated\n' + printf 'GENERAL.DEVICES:wlp4s0\n' + printf 'GENERAL.HWADDR:AA:BB:CC:DD:EE:FF\n' + printf 'IP4.ADDRESS[1]:192.168.7.42/24\n' + printf 'IP6.ADDRESS[1]:fd00::42/64\n' + printf 'IP4.GATEWAY:192.168.7.1\n' + printf 'IP4.DNS[1]:192.168.7.1\n' + printf 'IP4.DNS[2]:1.1.1.1\n' + printf '802-11-wireless-security.psk:$WIFI_PSK\n' + printf '802-1x.password:$ENTERPRISE_PW\n' + printf 'vpn.secrets.password:$VPN_SECRET\n' +} + +case "\$joined" in + *"connection edit"*) + # The only invocation that is fed anything, and it is drained under a + # timeout: every other one inherits whatever stdin the test runner had, + # and reading that would hang the suite. + timeout 5 cat >>"\$state/stdin" 2>/dev/null || true + exit 0 ;; + *"connection import"*) + printf '33333333-4444-5555-6666-777777777777\n' >>"\$state/uuids" + printf "Connection 'imported-profile' (33333333-4444-5555-6666-777777777777) successfully added.\n" + exit 0 ;; + *"device wifi show-password"*) + printf 'SSID: panama-hotspot\n' + printf 'Security: WPA2\n' + printf 'Password: $HOTSPOT_PW\n' + exit 0 ;; + *"device wifi hotspot"*) + printf "Device 'wlp4s0' successfully activated.\n" + exit 0 ;; + *"-f UUID connection show"*) + cat "\$state/uuids" + exit 0 ;; + *"-f DEVICE,TYPE device"*) + printf 'wlp4s0:wifi\nenp5s0:ethernet\nlo:loopback\n' + exit 0 ;; + *"device show"*) + printf 'GENERAL.HWADDR:AA:BB:CC:DD:EE:FF\n' + exit 0 ;; + *"connection show "*) + detail + exit 0 ;; +esac +exit 0 +STUB + +cat >"$stub_dir/gsettings" <>"$state_dir/argv" +case "\$*" in + "get org.gnome.system.proxy mode") printf "'none'\n" ;; + *" port") printf '0\n' ;; + get*) printf "''\n" ;; +esac +exit 0 +STUB + +cat >"$stub_dir/rfkill" <>"$state_dir/argv" +case "\$*" in + *-J*|*--json*) + printf '{"rfkilldevices":[{"id":0,"type":"wlan","device":"phy0","soft":"unblocked","hard":"unblocked"},{"id":1,"type":"bluetooth","device":"hci0","soft":"unblocked","hard":"unblocked"}]}\n' ;; + list*|"") + printf '0: phy0: Wireless LAN\n\tSoft blocked: no\n\tHard blocked: no\n' + printf '1: hci0: Bluetooth\n\tSoft blocked: no\n\tHard blocked: no\n' ;; +esac +exit 0 +STUB + +# Any other route out of this process is closed rather than left open. +for blocked in gdbus busctl dbus-send nm-connection-editor nmtui pkexec; do + cat >"$stub_dir/$blocked" <>"$state_dir/argv" +printf 'network tools contract: the helper reached for $blocked\n' >&2 +exit 1 +STUB +done +chmod +x "$stub_dir"/* + +runh() { + env -i \ + PATH="$stub_dir:/usr/bin:/bin" \ + PYTHONPATH="$pystub" \ + HOME="$home_dir" \ + XDG_CONFIG_HOME="$config_home" \ + XDG_STATE_HOME="$state_home" \ + XDG_RUNTIME_DIR="$run_dir" \ + DBUS_SESSION_BUS_ADDRESS="unix:path=$run_dir/absent-session-bus" \ + DBUS_SYSTEM_BUS_ADDRESS="unix:path=$run_dir/absent-system-bus" \ + LANG=C LC_ALL=C \ + "$helper" "$@" +} + +# The safety claim, verified rather than assumed. +for binary in nmcli gsettings rfkill; do + resolved="$(env -i PATH="$stub_dir:/usr/bin:/bin" bash -c "command -v $binary")" + [[ "$resolved" == "$stub_dir/$binary" ]] \ + || fail "$binary resolves to '$resolved', not the stub; refusing to run against the real one" +done + +error_of() { runh "$@" 2>/dev/null | jq -r '.error // ""'; } +log() { cat "$state_dir/argv"; } +leak_in_scratch() { + grep -rlF "$1" "$home_dir" "$config_home" "$state_home" "$run_dir" 2>/dev/null | head -1 +} + +# ── details: the whole point is what is missing ────────────────────────────── + +: >"$state_dir/argv" +details="$(runh details 'Home Wi-Fi' 2>/dev/null)" \ + || fail 'details failed against the stub NetworkManager' +jq -e 'has("ip4") and has("gateway") and has("dns") and has("mac") and has("macRandomized")' \ + <<<"$details" >/dev/null || fail "details is missing part of its shape: $details" +jq -e '.dns | type == "array"' <<<"$details" >/dev/null \ + || fail 'dns is not a list, so a second nameserver has nowhere to go' +jq -e '(.ip4 | length) > 0 and (.gateway | length) > 0 and (.mac | length) > 0' <<<"$details" >/dev/null \ + || fail "details parsed nothing out of the connection listing: $details" +jq -e '.macRandomized == true' <<<"$details" >/dev/null \ + || fail 'a connection whose cloned MAC is "random" is not reported as randomized' + +for secret in "$WIFI_PSK" "$ENTERPRISE_PW" "$VPN_SECRET"; do + grep -Fq "$secret" <<<"$details" \ + && fail 'a stored secret reached the details JSON, which the page renders' +done +# The absence above only means something if the secrets were on offer. They +# were: the very listing that produced the address, the gateway and the MAC +# carries all three, asked of the fake NetworkManager directly. So that block +# says the filter ran, not that there was nothing to filter. +served="$(PATH="$stub_dir:$PATH" nmcli -t -e no connection show 'Home Wi-Fi')" +for secret in "$WIFI_PSK" "$ENTERPRISE_PW" "$VPN_SECRET"; do + grep -Fq "$secret" <<<"$served" \ + || fail 'the fake NetworkManager offered no secret, so the details filter was never actually tested' +done +# Not just the values: a key shaped like a credential is where a future field +# would land without anybody noticing. +offenders="$(jq -r '[paths | map(tostring) | join(".")] + | map(select(test("(password|secret|psk|passphrase)$";"i"))) | join(", ")' <<<"$details")" +[[ -z "$offenders" ]] || fail "details carries credential-shaped fields: $offenders" + +# ── forget and the two modifiers ───────────────────────────────────────────── + +: >"$state_dir/argv" +runh forget 'Home Wi-Fi' >/dev/null 2>&1 +grep -Fq 'connection delete Home Wi-Fi' "$state_dir/argv" \ + || fail "forget did not delete the connection profile: $(log)" + +: >"$state_dir/argv" +runh set-autoconnect 'Home Wi-Fi' true >/dev/null 2>&1 +grep -Fq 'connection modify' "$state_dir/argv" \ + || fail "set-autoconnect did not modify the profile: $(log)" +grep -Eq 'connection\.autoconnect +(yes|true)' "$state_dir/argv" \ + || fail "set-autoconnect did not set connection.autoconnect: $(log)" +: >"$state_dir/argv" +runh set-autoconnect 'Home Wi-Fi' false >/dev/null 2>&1 +grep -Eq 'connection\.autoconnect +(no|false)' "$state_dir/argv" \ + || fail "turning autoconnect off did not reach nmcli as a negative: $(log)" + +: >"$state_dir/argv" +mac_on="$(runh set-mac-random 'Home Wi-Fi' true 2>/dev/null)" +grep -Eq 'cloned-mac-address +random' "$state_dir/argv" \ + || fail "randomizing the MAC did not set the cloned address: $(log)" +# The setting does nothing until the connection comes back up, and a switch +# that appears to have taken effect when it has not is the whole bug. +grep -qi 'reconnect' <<<"$mac_on" \ + || fail "set-mac-random does not say a reconnect is needed: $mac_on" +: >"$state_dir/argv" +runh set-mac-random 'Home Wi-Fi' false >/dev/null 2>&1 +grep -Eq 'cloned-mac-address +permanent' "$state_dir/argv" \ + || fail "turning randomization off did not restore the permanent address: $(log)" + +# ── import-vpn picks its plugin from the extension ─────────────────────────── + +printf '[Interface]\n' >"$work/tunnel.conf" +printf 'client\n' >"$work/tunnel.ovpn" +printf 'not a tunnel\n' >"$work/tunnel.txt" + +: >"$state_dir/argv" +imported="$(runh import-vpn "$work/tunnel.conf" 2>/dev/null)" +grep -Fq 'connection import type wireguard' "$state_dir/argv" \ + || fail "a .conf file was not imported as WireGuard: $(log)" +jq -e '((.name // "") | length > 0) and ((.uuid // "") | length > 0)' <<<"$imported" >/dev/null \ + || fail "import-vpn does not name the connection it made: $imported" +jq -e '.kind == "wireguard"' <<<"$imported" >/dev/null \ + || fail "a .conf import is not reported as WireGuard: $imported" + +: >"$state_dir/argv" +ovpn="$(runh import-vpn "$work/tunnel.ovpn" 2>/dev/null)" +grep -Fq 'connection import type openvpn' "$state_dir/argv" \ + || fail "a .ovpn file was not imported as OpenVPN: $(log)" +jq -e '.kind == "openvpn"' <<<"$ovpn" >/dev/null \ + || fail "a .ovpn import is not reported as OpenVPN: $ovpn" + +: >"$state_dir/argv" +[[ -n "$(error_of import-vpn "$work/tunnel.txt")" ]] \ + || fail 'a file that is neither .conf nor .ovpn was accepted for import' +grep -Fq 'connection import' "$state_dir/argv" \ + && fail 'an unimportable file still reached nmcli' +[[ -n "$(error_of import-vpn "$work/does-not-exist.conf")" ]] \ + || fail 'a path that does not exist was accepted for import' + +# ── hotspot: the password comes back once and is written nowhere ───────────── + +: >"$state_dir/argv" +hotspot="$(runh hotspot start panama-hotspot 2>/dev/null)" +grep -Fq 'device wifi hotspot' "$state_dir/argv" \ + || fail "starting a hotspot did not reach nmcli: $(log)" +grep -Fq 'device wifi show-password' "$state_dir/argv" \ + || fail 'the generated hotspot password is never read back, so the UI cannot show it' +grep -Fq "$HOTSPOT_PW" <<<"$hotspot" \ + || fail "the hotspot password is not returned to the caller: $hotspot" +# It may pass through the return value exactly once. It may not be an argument, +# and it may not be left behind on disk. +grep -Fq "$HOTSPOT_PW" "$state_dir/argv" \ + && fail 'the hotspot password was passed to a command, where /proc publishes it' +leaked="$(leak_in_scratch "$HOTSPOT_PW")" +[[ -z "$leaked" ]] || fail "the hotspot password was written to $leaked" + +runh hotspot status >/dev/null 2>&1 || fail 'hotspot status failed' +: >"$state_dir/argv" +runh hotspot stop >/dev/null 2>&1 +[[ -s "$state_dir/argv" ]] || fail 'stopping the hotspot did nothing at all' + +# ── join-enterprise: the password arrives on stdin and never on argv ───────── +# +# The pin the whole file is built around. libnm is unavailable here by +# construction, so this is the nmcli fallback: the editor takes `set +# 802-1x.password …` as a line of input, which keeps the secret out of ps. + +: >"$state_dir/argv" +: >"$state_dir/stdin" +enterprise_out="$(printf '%s\n' "$ENTERPRISE_PW" \ + | runh join-enterprise 'Campus Secure' peap-mschapv2 'gib@example.edu' 2>"$work/enterprise.err")" + +grep -Fq "$ENTERPRISE_PW" "$state_dir/argv" \ + && fail 'the enterprise password was passed as a command argument' +grep -Fq "$ENTERPRISE_PW" <<<"$enterprise_out" \ + && fail 'the enterprise password is echoed back in the helper output' +grep -Fq "$ENTERPRISE_PW" "$work/enterprise.err" \ + && fail 'the enterprise password was written to stderr' +leaked="$(leak_in_scratch "$ENTERPRISE_PW")" +[[ -z "$leaked" ]] || fail "the enterprise password was written to $leaked" +# And it did reach NetworkManager, or the verb is a no-op wearing a JSON hat. +grep -Fq 'connection edit' "$state_dir/argv" \ + || fail "join-enterprise never opened the connection editor: $(log)" +grep -Fq "$ENTERPRISE_PW" "$state_dir/stdin" \ + || fail 'the enterprise password never reached nmcli at all, on stdin or otherwise' + +# The EAP method is a closed set, not free text handed to nmcli. Nothing is +# piped in on purpose: the arguments are checked before stdin is read, so a +# request that was always going to be refused must not sit waiting for a +# password first. +: >"$state_dir/argv" +bad_method="$(runh join-enterprise 'Campus Secure' ldap-md5 'gib' /dev/null \ + | jq -r '.error // ""')" +[[ -n "$bad_method" ]] || fail 'an unknown EAP method was accepted' +grep -Fq 'connection edit' "$state_dir/argv" \ + && fail 'an unknown EAP method still reached nmcli' +[[ -n "$(runh join-enterprise 'Campus Secure' peap-mschapv2 'gib' /dev/null \ + | jq -r '.error // ""')" ]] \ + || fail 'an enterprise join with no password was accepted' + +# ── proxy and airplane are the settings they claim to be ───────────────────── + +: >"$state_dir/argv" +proxy="$(runh proxy get 2>/dev/null)" +grep -Fq 'org.gnome.system.proxy' "$state_dir/argv" \ + || fail "proxy get does not read the GNOME proxy settings: $(log)" +jq -e 'has("mode")' <<<"$proxy" >/dev/null || fail "proxy get reports no mode: $proxy" + +: >"$state_dir/argv" +runh proxy set manual 192.168.7.9 3128 >/dev/null 2>&1 +grep -Fq 'set org.gnome.system.proxy mode' "$state_dir/argv" \ + || fail "setting a manual proxy did not set the mode: $(log)" +# All three or none: a proxy applied to http alone silently leaks the rest. +for scheme in http https socks; do + grep -Fq "org.gnome.system.proxy.$scheme host" "$state_dir/argv" \ + || fail "the manual proxy was not applied to $scheme, so that traffic ignores it" +done +[[ -n "$(error_of proxy set sideways)" ]] || fail 'an unknown proxy mode was accepted' +[[ -n "$(error_of proxy set manual 'host; reboot' 3128)" ]] \ + || fail 'a proxy host with a shell metacharacter was accepted' +[[ -n "$(error_of proxy set manual 192.168.7.9 99999)" ]] \ + || fail 'an impossible proxy port was accepted' +[[ -n "$(error_of proxy set auto 'javascript:alert(1)')" ]] \ + || fail 'a PAC URL that is not a URL was accepted' + +: >"$state_dir/argv" +airplane="$(runh airplane status 2>/dev/null)" +grep -Fq 'rfkill' "$state_dir/argv" || fail "airplane status does not read rfkill: $(log)" +jq -e 'has("on")' <<<"$airplane" >/dev/null || fail "airplane status reports no state: $airplane" +: >"$state_dir/argv" +runh airplane set true >/dev/null 2>&1 +grep -Eq 'rfkill +block' "$state_dir/argv" \ + || fail "turning airplane mode on did not block the radios: $(log)" +: >"$state_dir/argv" +runh airplane set false >/dev/null 2>&1 +grep -Eq 'rfkill +unblock' "$state_dir/argv" \ + || fail "turning airplane mode off did not unblock the radios: $(log)" +[[ -n "$(error_of airplane set maybe)" ]] || fail 'a non-boolean was accepted for airplane mode' + +# ── Names are validated here, not by nmcli ─────────────────────────────────── +# +# The reason matters: without validation these still fail, because nmcli refuses +# them too -- so a check that only asks "did something error" passes with the +# validation deleted. Each must also leave the recording untouched, which is +# what says the refusal happened before anything was run. + +long_name="$(printf 'a%.0s' {1..300})" +for bad in 'Home; rm -rf /' '-x-not-a-name' '' "$long_name" 'new +line'; do + : >"$state_dir/argv" + [[ -n "$(error_of details "$bad")" ]] \ + || fail "a bad connection name was accepted: ${bad@Q}" + [[ ! -s "$state_dir/argv" ]] \ + || fail "a bad connection name reached nmcli before being refused: ${bad@Q}" + [[ -n "$(error_of forget "$bad")" ]] \ + || fail "forget accepted a bad connection name: ${bad@Q}" +done +for bad in 'ssid; reboot' '-x-not-a-name' '' "$(printf 'a%.0s' {1..33})"; do + : >"$state_dir/argv" + [[ -n "$(error_of hotspot start "$bad")" ]] \ + || fail "a bad hotspot SSID was accepted: ${bad@Q}" + [[ ! -s "$state_dir/argv" ]] \ + || fail "a bad hotspot SSID reached nmcli before being refused: ${bad@Q}" +done +[[ -n "$(error_of set-autoconnect 'Home Wi-Fi' perhaps)" ]] \ + || fail 'a non-boolean was accepted for autoconnect' +[[ -n "$(error_of bogus-verb)" ]] || fail 'an unknown command was accepted' + +# ── Nothing anywhere left a secret behind ─────────────────────────────────── +for secret in "$WIFI_PSK" "$VPN_SECRET" "$ENTERPRISE_PW" "$HOTSPOT_PW"; do + leaked="$(leak_in_scratch "$secret")" + [[ -z "$leaked" ]] || fail "a secret was left behind in $leaked" +done + +printf 'network tools contract: PASS (details, forget, autoconnect, MAC, import, hotspot, enterprise, proxy, airplane)\n' diff --git a/tests/quickshell/printers-contract b/tests/quickshell/printers-contract index bbc7963..c5114d9 100755 --- a/tests/quickshell/printers-contract +++ b/tests/quickshell/printers-contract @@ -62,6 +62,90 @@ for scheme in file pipe; do && fail "the $scheme scheme is allowed, and it does not lead to a printer" done +# ── Printer options are a short closed list, not a passthrough ────────────── +# +# `lpadmin -o` is the same door the driver ban closed, reopened from the side. +# Anything can be set through it -- including ppd-name, and including options +# that make a printer accept jobs and print nothing -- so the page offers two +# settings, and the helper knows both of them by name and by value. A caller's +# string is never spliced into `-o`; it is looked up, and refused when absent. +grep -qE '^OPTION_[A-Z_]+\s*[:=]' "$helper" \ + || fail 'the settable options are not declared in one place, so "closed vocabulary" cannot be checked' +python3 - "$helper" <<'PY' || fail 'the option vocabulary is not the closed media/sides set this page promises' +import ast +import sys + +source = open(sys.argv[1], encoding="utf-8").read() +vocabulary = {} +for node in ast.parse(source).body: + if isinstance(node, ast.AnnAssign): + name = getattr(node.target, "id", "") + value = node.value + elif isinstance(node, ast.Assign): + name = getattr(node.targets[0], "id", "") + value = node.value + else: + continue + if not name.startswith("OPTION_") or value is None: + continue + try: + literal = ast.literal_eval(value) + except ValueError: + continue + # A key-to-choices table, not the spelling table beside it: only the dict + # whose values are collections of choices describes what may be set. + if isinstance(literal, dict) and literal and all( + isinstance(choices, (list, tuple, set)) for choices in literal.values()): + vocabulary.update(literal) + +if set(vocabulary) != {"media", "sides"}: + print(f"settable keys are {sorted(vocabulary)}, expected ['media', 'sides']", file=sys.stderr) + raise SystemExit(1) +if set(vocabulary["media"]) != {"Letter", "A4", "Legal"}: + print(f"media values are {sorted(vocabulary['media'])}", file=sys.stderr) + raise SystemExit(1) +if set(vocabulary["sides"]) != {"one-sided", "two-sided-long-edge", "two-sided-short-edge"}: + print(f"sides values are {sorted(vocabulary['sides'])}", file=sys.stderr) + raise SystemExit(1) +raise SystemExit(0) +PY +# And set-option must reach for that vocabulary rather than trusting its +# arguments. The values are validated in one place or they are validated +# nowhere. +python3 - "$helper" <<'OPTIONS' || fail 'set-option does not check its key and value against the closed vocabulary' +import re +import sys + +source = open(sys.argv[1], encoding="utf-8").read() +match = re.search(r"\ndef set_option\b.*?(?=\ndef |\Z)", source, re.S) +if not match: + raise SystemExit(1) +raise SystemExit(0 if re.search(r"OPTION_[A-Z_]+", match.group(0)) else 1) +OPTIONS +grep -q 'get-options' "$helper" || fail 'the helper cannot read the options a printer is set to' +grep -q 'set-option' "$helper" || fail 'the helper cannot set a printer option' + +# ── A queued job can be held and let go again ─────────────────────────────── +# +# Cancel was the only verb, so the only way to stop a job going out on the +# wrong paper was to throw it away and print it again. +for verb in hold release; do + grep -q "\"$verb\"" "$helper" || fail "the helper has no $verb verb" + grep -qi "$verb" <<<"$page_code" || fail "the queue offers no $verb" +done +grep -qi 'paper size\|media' <<<"$page_code" \ + || fail 'the expanded printer offers no paper size' +grep -qi 'two-sided\|sides' <<<"$page_code" \ + || fail 'the expanded printer offers no two-sided setting' +# The dropdowns must show what the printer is set to, not a guess. +grep -q 'getOptions\|optionsFor\|options(' "$service" \ + || fail 'the service never reads printer options, so the dropdowns would be showing defaults' + +# The empty state is one card. It was two rows saying the same thing, which read +# as two different things you could try. +[[ "$(grep -c 'Search the network' <<<"$page_code")" -le 1 ]] \ + || fail 'the "Search the network" row appears more than once' + command -v jq >/dev/null 2>&1 || { printf 'printers contract: SKIP (no jq)\n'; exit 0; } refusal() { "$helper" "$@" 2>/dev/null | jq -r '.error // ""'; } @@ -80,6 +164,26 @@ for bad in "../escape" "has space" "a#b" ""; do || fail "the helper accepted \"$bad\" as a printer name" done [[ -n "$(refusal cancel notanumber)" ]] || fail 'the helper accepted a job id that is not a number' +for verb in hold release; do + [[ -n "$(refusal "$verb" notanumber)" ]] \ + || fail "the helper accepted a job id that is not a number for $verb" +done +# Options: a key outside the vocabulary, and a value outside its own key's list. +# Both are refused before anything reaches lpadmin, so running this touches no +# real printer. +# The reason again, not merely an error: the printer name is only checked for +# shape here, so these reach the vocabulary and are refused by it rather than by +# CUPS -- which means nothing is sent to a real printer either. +[[ "$(refusal set-option office-laser ppd-name everywhere)" == "That is not a setting this page changes." ]] \ + || fail 'an option outside the vocabulary was not refused by the vocabulary, which reopens the driver door from the side' +[[ "$(refusal set-option office-laser media Tabloid)" == "That is not a value this setting accepts." ]] \ + || fail 'a paper size outside the offered list was not refused by the vocabulary' +[[ "$(refusal set-option office-laser sides sideways)" == "That is not a value this setting accepts." ]] \ + || fail 'a two-sided value outside the offered list was not refused by the vocabulary' +[[ -n "$(refusal set-option '../escape' media Letter)" ]] \ + || fail 'set-option accepted a bad printer name' +[[ -n "$(refusal get-options 'has space')" ]] \ + || fail 'get-options accepted a bad printer name' [[ -n "$(refusal bogus-command)" ]] || fail 'an unknown command was accepted' # ── The snapshot describes the machine ────────────────────────────────────── @@ -94,6 +198,30 @@ jq -e '[.printers[] | has("name") and has("state") and has("isDefault")] | all' [[ "$(jq '[.printers[] | select(.isDefault)] | length' <<<"$snapshot")" -le 1 ]] \ || fail 'more than one printer is reported as the default' +# ── Reading a printer's options is a read ─────────────────────────────────── +# Only the keys the page can write are reported: a dropdown that lists an +# option nothing can set is a control that does nothing. +first_printer="$(jq -r '.printers[0].name // ""' <<<"$snapshot")" +if [[ -n "$first_printer" ]]; then + options="$("$helper" get-options "$first_printer" 2>/dev/null)" \ + || fail "get-options failed for $first_printer" + jq -e '(.options | has("media") and has("sides")) and (.choices | has("media") and has("sides"))' \ + <<<"$options" >/dev/null \ + || fail "get-options does not report the two settings the page offers, with their choices: $options" + extra="$(jq -r '[(.options | keys[]), (.choices | keys[])] + | unique | map(select(. != "media" and . != "sides")) | join(", ")' <<<"$options")" + [[ -z "$extra" ]] \ + || fail "get-options offers keys the page cannot set: $extra" + # Every offered choice has to be one set-option would accept, or the + # dropdown lists something that is refused the moment it is chosen. + bad_choice="$(jq -r ' + (.choices.media // []) - ["Letter","A4","Legal"] + + ((.choices.sides // []) - ["one-sided","two-sided-long-edge","two-sided-short-edge"]) + | join(", ")' <<<"$options")" + [[ -z "$bad_choice" ]] \ + || fail "get-options offers values outside the vocabulary: $bad_choice" +fi + # ── Removal is confirmed ──────────────────────────────────────────────────── grep -q 'confirmingRemoval' "$page" \ || fail 'the page removes a printer without a confirmation step' diff --git a/tests/quickshell/settings-pages-contract b/tests/quickshell/settings-pages-contract index 29b5856..7464557 100755 --- a/tests/quickshell/settings-pages-contract +++ b/tests/quickshell/settings-pages-contract @@ -9,7 +9,11 @@ fail() { exit 1 } -pages=(Home MyHome Phone Displays Connectivity Bar Dock ControlCenter Tiling Workspaces Sync Sound Shortcuts Mouse Dictation Notifications Focus ScreenIntelligence Health About) +# Firewall, Printers and Sharing joined this list when the Network & Sharing +# rebuild touched all four Connections-category pages at once: three of them had +# never been checked for the page scaffold at all, and a rebuild is exactly when +# a hand-rolled Flickable comes back. +pages=(Home MyHome Phone Displays Connectivity Firewall Printers Sharing Bar Dock ControlCenter Tiling Workspaces Sync Sound Shortcuts Mouse Dictation Notifications Focus ScreenIntelligence Health About) for page in "${pages[@]}"; do page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml" [[ -f "$page_file" ]] || fail "${page}Page.qml is missing" @@ -29,6 +33,32 @@ for page in "${pages[@]}"; do || fail "${page}Page.qml still copies the page Flickable scaffold" done +settings_dir="$repo_dir/config/dot/quickshell/modules/settings" + +# A page's objectName is how the running shell is asked to point at it, and how +# a contract finds it without walking the visual tree. The four pages under +# Connections all carry the id their route uses. +while IFS='|' read -r page_file object_name; do + rg -Fq "objectName: \"$object_name\"" "$settings_dir/$page_file" \ + || fail "$page_file does not carry objectName \"$object_name\"" +done <<'OBJECTS' +ConnectivityPage.qml|connectivity +FirewallPage.qml|firewall +PrintersPage.qml|printers +SharingPage.qml|sharing +OBJECTS + +# A component file that exists but is not exported from qmldir is not a missing +# import error at startup -- it is an unresolved type at the moment the row is +# first rendered, which is to say when somebody expands a connection. +qmldir="$settings_dir/qmldir" +for component in ConnectionDetails EnterpriseJoinForm; do + [[ -f "$settings_dir/$component.qml" ]] \ + || fail "$component.qml is missing" + rg -Fq "$component 1.0 $component.qml" "$qmldir" \ + || fail "$component.qml is not exported from qmldir, so the type resolves to nothing when it is first used" +done + require_row() { local file="$1" local row_type="$2" diff --git a/tests/quickshell/sharing-contract b/tests/quickshell/sharing-contract index ce340ba..1c33186 100755 --- a/tests/quickshell/sharing-contract +++ b/tests/quickshell/sharing-contract @@ -89,6 +89,52 @@ grep -q 'kitty' "$service" \ grep -q 'clear-rdp-credentials' "$helper" \ || fail 'stored credentials cannot be cleared' +# ── The page says the true thing in the right place ───────────────────────── +# +# Three copy rules, each of which was a real failure before it was a rule. +# +# The failure banner floated above every card as a full-width red bar, so a +# grdctl error that concerned one row repainted the whole page as broken. It +# belongs inside the card whose action failed -- the machine card, which is +# where refresh and the hostname live. +page_code="$(grep -vE '^\s*//' "$page")" +python3 - "$page" <<'PY' || fail 'the failure message is not inside the first card, so one row failing reads as the page failing' +import re +import sys + +lines = open(sys.argv[1], encoding="utf-8").read().splitlines() +start = next((i for i, line in enumerate(lines) if re.search(r"\bSettingsCard\s*\{", line)), None) +if start is None: + raise SystemExit(1) +depth = 0 +end = None +for index in range(start, len(lines)): + depth += lines[index].count("{") - lines[index].count("}") + if depth <= 0: + end = index + break +if end is None: + raise SystemExit(1) +card = "\n".join(lines[start:end + 1]) +raise SystemExit(0 if "Sharing.lastError" in card else 1) +PY + +# The terminal is the mechanism, not the explanation. "Opens kitty" tells +# somebody the name of a program they did not ask about and still leaves them +# wondering why a settings page cannot take a password; the reason it cannot is +# the sentence worth printing. +! grep -qi 'kitty' <<<"$page_code" \ + || fail 'the page names the terminal application in text the user reads; that belongs to the service' +grep -q 'never passes through Panama' <<<"$page_code" \ + || fail 'the credentials row does not say why the password is set elsewhere' + +# A row for software that is not here has to be worth reading. "Not installed" +# on its own is a dead end; what installing it would give you is not. +grep -q 'install it and this becomes a switch' <<<"$page_code" \ + || fail 'the file sharing row does not say what installing Samba would unlock' +grep -q 'does not install software' <<<"$page_code" \ + || fail 'the file sharing row does not say that Settings will not install it for you' + # ── The snapshot reflects the machine ─────────────────────────────────────── command -v jq >/dev/null 2>&1 || { printf 'sharing contract: SKIP (no jq)\n'; exit 0; } snapshot="$("$helper" snapshot 2>/dev/null)" || fail 'snapshot failed'