Own the network: details, VPN, enterprise Wi-Fi, and a firewall that can also allow

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 16:31:52 -04:00
parent aba2d16ffa
commit b30bf40407
29 changed files with 4452 additions and 241 deletions
@@ -16,6 +16,21 @@ Column {
spacing: 0
// Connected and paired devices always render (they lead the sort, so the
// slice keeps them); unpaired strangers fill up to the cap and the rest
// wait behind the "nearby devices" row. Discovery in a busy room finds
// dozens of phones and TVs that are not yours.
property bool showAll: false
readonly property int visibleCap: 5
readonly property var shown: {
const list = Connectivity.bluetoothDevices;
if (root.showAll || list.length <= root.visibleCap)
return list;
const pinned = list.filter(device => device.connected || device.paired).length;
return list.slice(0, Math.max(root.visibleCap, pinned));
}
readonly property int hiddenCount: Connectivity.bluetoothDevices.length - root.shown.length
function primaryAction(device: var): void {
if (device.connected) {
device.disconnect();
@@ -41,7 +56,7 @@ Column {
}
Repeater {
model: Connectivity.bluetoothDevices
model: root.shown
SettingRow {
id: entry
@@ -52,7 +67,8 @@ Column {
width: parent.width
label: entry.modelData.name || entry.modelData.address || "Unknown device"
detail: root.stateLabel(entry.modelData)
divider: entry.index < Connectivity.bluetoothDevices.length - 1
divider: entry.index < root.shown.length - 1
|| root.hiddenCount > 0 || root.showAll
controlWidth: 200
activatable: !entry.modelData.pairing
onActivated: root.primaryAction(entry.modelData)
@@ -81,6 +97,19 @@ Column {
}
}
SettingRow {
width: parent.width
visible: root.hiddenCount > 0
|| (root.showAll && Connectivity.bluetoothDevices.length > root.visibleCap)
label: root.showAll
? "Show fewer devices"
: root.hiddenCount + (root.hiddenCount === 1 ? " more nearby device" : " more nearby devices")
detail: root.showAll ? "" : "Unpaired devices in range, folded to keep the list short"
activatable: true
onActivated: root.showAll = !root.showAll
divider: false
}
SettingRow {
width: parent.width
visible: Connectivity.bluetoothDevices.length === 0
@@ -0,0 +1,148 @@
// The facts about one connection that otherwise need a terminal.
//
// IP address, gateway, DNS and MAC are the four things people leave this app
// for, and they were the reason the Connections page still pointed at GNOME.
// They are read-only here: editing them properly means static addressing, which
// is a page of its own rather than four fields smuggled into a details drawer.
//
// Nothing here is ever a secret. panama-network's `details` verb returns
// addresses only -- no PSK, no enterprise password -- so this component can be
// shown for any connection without deciding what is safe to draw.
//
// The values are set in the interface face with tabular figures rather than a
// monospaced one. Theme bans monospaced text outright (fontMono is the icon
// face, not a text face), and an address only needs its digits to line up.
import QtQuick
import qs.config
Column {
id: root
// { ip4, gateway, dns: [], mac, macRandomized } as the helper reports it,
// or null while the read has not come back. Null is NOT "no address": the
// component says it is still reading rather than claiming an answer.
property var details: null
// [{ key, value, note }] -- only the facts that actually have a value, so a
// connection with no gateway shows three rows rather than a blank one.
readonly property var facts: {
const source = root.details;
if (!source)
return [];
const rows = [];
const ip4 = String(source.ip4 ?? "");
if (ip4 !== "")
rows.push({ key: "IPv4 address", value: ip4, note: "" });
const ip6 = String(source.ip6 ?? "");
if (ip6 !== "")
rows.push({ key: "IPv6 address", value: ip6, note: "" });
const gateway = String(source.gateway ?? "");
if (gateway !== "")
rows.push({ key: "Gateway", value: gateway, note: "" });
const dns = Array.isArray(source.dns)
? source.dns.map(entry => String(entry)).filter(entry => entry !== "")
: [];
if (dns.length > 0)
rows.push({
key: dns.length === 1 ? "DNS" : "DNS servers",
value: dns.join(" · "),
note: ""
});
const mac = String(source.mac ?? "");
if (mac !== "")
rows.push({
key: "MAC address",
value: mac,
note: source.macRandomized === true ? "randomized" : ""
});
return rows;
}
width: parent ? parent.width : 620
spacing: 5
topPadding: 8
bottomPadding: 10
Repeater {
model: root.facts
Item {
id: fact
required property var modelData
width: parent.width
implicitHeight: Math.max(key.implicitHeight, value.implicitHeight)
Text {
id: key
anchors.left: parent.left
anchors.top: parent.top
width: 132
text: String(fact.modelData.key ?? "")
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
Text {
id: value
anchors.left: key.right
anchors.right: parent.right
anchors.top: parent.top
text: String(fact.modelData.value ?? "")
+ (String(fact.modelData.note ?? "") !== ""
? " · " + String(fact.modelData.note) : "")
color: Theme.fg
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WrapAnywhere
}
}
}
// Two honest empty states, and they say different things. "Still reading"
// is not "no address", and a page that renders the first as the second is
// how a working connection comes to look broken for half a second.
Text {
width: parent.width
visible: !root.details
text: "Reading this connection's addresses…"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Text {
width: parent.width
visible: !!root.details && root.facts.length === 0
text: "NetworkManager reports no addresses for this connection."
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
// The helper's own aside -- chiefly "this takes effect on the next
// reconnect" after a MAC randomization change. It belongs to the answer,
// so it is shown with the answer rather than guessed at by the page.
Text {
width: parent.width
visible: String(root.details?.note ?? "") !== ""
topPadding: 4
text: String(root.details?.note ?? "")
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
@@ -1,10 +1,18 @@
// Connections — wired, Wi-Fi, and Bluetooth.
// Connections — the whole network, not the half that was easy.
//
// Wi-Fi and Bluetooth are handled here rather than delegated. Everything goes
// through Quickshell.Networking and Quickshell.Bluetooth -- NetworkManager and
// BlueZ over DBus -- and nothing shells out to nmcli or bluetoothctl. That was
// the founding requirement for this desktop: never having to drop to a terminal
// to join a network.
// This page used to end in a card headed "Owned by Fedora" with two doors back
// to GNOME's panels: one for hidden and enterprise networks, one for VPN and
// proxies. Everything behind those doors now lives here, so the card is gone.
//
// Two mechanisms, deliberately kept apart:
//
// * Wi-Fi and Bluetooth state -- scanning, joining, pairing, the radio
// switches -- go through Quickshell.Networking and Quickshell.Bluetooth,
// which speak to NetworkManager and BlueZ over DBus. Connectivity.qml is
// pinned shell-out-free and stays that way.
// * Everything NetworkManager exposes only through nmcli -- per-connection
// addresses, forgetting a profile, MAC randomization, VPN import, hotspot,
// enterprise join -- goes through NetworkTools.qml and scripts/panama-network.
//
// Scanning follows this page being on screen. Wi-Fi scanning and especially
// Bluetooth discovery hold the radio, and doing either for a list nobody is
@@ -19,26 +27,78 @@ import qs.services
SettingsPage {
id: root
objectName: "connectivity"
title: "Connections"
lede: Connectivity.activeNetwork
? "Connected to " + Connectivity.activeNetwork.name
: "Wi-Fi, Bluetooth, and the things Fedora owns."
lede: {
const wifi = Connectivity.activeNetwork ? Connectivity.activeNetwork.name : "";
if (wifi !== "" && Connectivity.wiredOn)
return "On " + wifi + " and wired.";
if (wifi !== "")
return "On " + wifi + ".";
if (Connectivity.wiredOn)
return "Wired.";
return "Wi-Fi, Bluetooth, VPN, and what this machine can reach.";
}
// Drive the scanners only while this page is the one being shown.
// The wired connection's own drawer, and the two inline forms that are only
// open while someone is filling them in.
property bool wiredOpen: false
property bool hotspotOpen: false
property string hotspotName: ""
property bool importOpen: false
property string importPath: ""
readonly property string wiredConnection:
String(Connectivity.wiredDevice?.network?.name ?? "")
readonly property string activeWifi:
Connectivity.activeNetwork ? Connectivity.activeNetwork.name : ""
// Drive the scanners and the helper only while this page is the one being
// shown. Both cost radio time or nmcli invocations for a list nobody is
// reading.
Component.onCompleted: {
Connectivity.active = true;
NetworkTools.active = true;
if (!WifiShare.scanned)
WifiShare.refresh();
Vpn.refresh();
}
Component.onDestruction: Connectivity.active = false
Component.onDestruction: {
Connectivity.active = false;
NetworkTools.active = false;
}
// Addresses and the MAC in use both change with the connection, so the
// cached details for a network that just came up are stale the moment it
// does.
onActiveWifiChanged: {
if (root.activeWifi !== "")
NetworkTools.refreshDetails(root.activeWifi);
}
onWiredConnectionChanged: {
if (root.wiredConnection !== "")
NetworkTools.refreshDetails(root.wiredConnection);
}
TextRow {
visible: NetworkTools.lastError !== ""
label: "The network needs attention"
detail: NetworkTools.lastError
value: ""
divider: false
}
// ── Wired ────────────────────────────────────────────────────────────────
SettingsCard {
title: "Wired"
visible: Connectivity.wiredDevice !== null
SwitchRow {
label: "Ethernet"
SettingRow {
width: parent.width
label: root.wiredConnection !== "" ? root.wiredConnection : "Ethernet"
// Three states worth telling apart: on, off but plugged in, and
// nothing in the socket. "Not connected" covered all three and
// explained none of them.
@@ -54,13 +114,44 @@ SettingsPage {
// that blamed the hardware for what it had just done itself.
return device.name + " · off";
}
checked: Connectivity.wiredOn
enabled: Connectivity.wiredAvailable
controlWidth: 78
divider: false
onToggled: value => Connectivity.setWired(value)
activatable: root.wiredConnection !== "" && Connectivity.wiredOn
onActivated: root.wiredOpen = !root.wiredOpen
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 9
SettingsToggle {
anchors.verticalCenter: parent.verticalCenter
checked: Connectivity.wiredOn
enabled: Connectivity.wiredAvailable
onToggled: value => Connectivity.setWired(value)
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: root.wiredConnection !== "" && Connectivity.wiredOn
text: root.wiredOpen ? "▴" : "▾"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
ConnectionDetails {
width: parent.width
visible: root.wiredOpen && Connectivity.wiredOn
details: root.wiredConnection !== ""
? NetworkTools.detailsFor(root.wiredConnection) : null
}
}
// ── Wi-Fi ────────────────────────────────────────────────────────────────
SettingsCard {
title: "Wi-Fi"
// A Wi-Fi switch reading "On" above the words "No Wi-Fi adapter" is a
@@ -79,7 +170,7 @@ SettingsPage {
anchors.verticalCenter: parent.verticalCenter
checked: Connectivity.wifiEnabled
enabled: Connectivity.wifiAvailable
onToggled: value => Networking.wifiEnabled = value
onToggled: value => Connectivity.setWifiEnabled(value)
}
}
@@ -87,71 +178,188 @@ SettingsPage {
width: parent.width
visible: Connectivity.wifiEnabled
}
}
// Sharing a network by QR, the way GNOME's Wi-Fi panel does. The
// alternative is reading a passphrase out loud.
//
// The image holds the password in machine-readable form, so it is generated
// on demand rather than up front, and the helper writes it to tmpfs under
// XDG_RUNTIME_DIR instead of anywhere persistent.
SettingsCard {
visible: Connectivity.wifiDevice !== null && WifiShare.shareable.length > 0
title: "Share a network"
subtitle: WifiShare.sharing !== ""
? "Point a phone's camera at the code to join " + WifiShare.sharing + "."
: "Shows a QR code a phone can scan to join, without reading the password out."
// ── Hotspot ──────────────────────────────────────────────────────────
//
// One radio cannot be a client and an access point at the same time, so
// starting this drops whatever network the machine is on. Said before it
// happens rather than discovered when the browser stops loading.
Repeater {
model: WifiShare.shareable
ActionRow {
id: shareRow
required property var modelData
required property int index
label: shareRow.modelData.ssid
detail: WifiShare.sharing === shareRow.modelData.name
? "Showing a code below — anyone who can see the screen can join"
: "Saved network"
action: WifiShare.sharing === shareRow.modelData.name ? "Hide" : "Show code"
divider: shareRow.index < WifiShare.shareable.length - 1 || WifiShare.sharing !== ""
onTriggered: WifiShare.sharing === shareRow.modelData.name
? WifiShare.stopSharing()
: WifiShare.share(shareRow.modelData.name)
ActionRow {
width: parent.width
visible: Connectivity.wifiEnabled && !NetworkTools.hotspotActive
label: "Hotspot"
detail: "Share this machine's connection over Wi-Fi"
action: root.hotspotOpen ? "Cancel" : "Start hotspot…"
enabled: !NetworkTools.busy
divider: root.hotspotOpen
onTriggered: {
root.hotspotOpen = !root.hotspotOpen;
root.hotspotName = "";
}
}
// Drawn at its natural size on a white plate: a QR code inverted or
// tinted to match a dark theme is unreliable to scan, and this one has
// exactly one job.
Item {
Column {
width: parent.width
visible: WifiShare.sharing !== "" && WifiShare.imagePath !== ""
implicitHeight: visible ? plate.height + 20 : 0
visible: root.hotspotOpen && !NetworkTools.hotspotActive
Rectangle {
id: plate
anchors.horizontalCenter: parent.horizontalCenter
y: 10
width: 208
height: 208
radius: 10
color: "white"
TextFieldRow {
width: parent.width
label: "Network name"
detail: "What the hotspot calls itself to phones and laptops nearby"
placeholder: "panama-hotspot"
text: root.hotspotName
enabled: !NetworkTools.busy
onAccepted: value => root.hotspotName = value
}
Image {
anchors.centerIn: parent
width: 184
height: 184
smooth: false
fillMode: Image.PreserveAspectFit
cache: false
source: WifiShare.imagePath !== "" ? "file://" + WifiShare.imagePath : ""
ActionRow {
width: parent.width
label: "Start the hotspot"
detail: Connectivity.activeNetwork
? "This machine leaves " + Connectivity.activeNetwork.name
+ " while the hotspot runs — one radio cannot do both."
: "NetworkManager makes up a password and shows it once."
action: NetworkTools.busy ? "Starting…" : "Start"
enabled: root.hotspotName.trim() !== "" && !NetworkTools.busy
divider: false
onTriggered: {
NetworkTools.startHotspot(root.hotspotName.trim());
root.hotspotOpen = false;
}
}
}
TextRow {
width: parent.width
visible: NetworkTools.hotspotActive
label: "Hotspot is running"
detail: "Phones and laptops nearby can see this network and join it"
value: NetworkTools.hotspotSsid
}
// Shown once, on purpose. NetworkManager keeps the passphrase; this page
// never stores it, so leaving this screen means asking NetworkManager
// again rather than reading it back from Panama.
TextRow {
width: parent.width
visible: NetworkTools.hotspotActive && NetworkTools.hotspotPassword !== ""
label: "Password"
detail: "Shown once. Panama does not keep a copy — write it down or let someone type it in now."
value: NetworkTools.hotspotPassword
}
ActionRow {
width: parent.width
visible: NetworkTools.hotspotActive
label: "Stop the hotspot"
detail: "Anything connected through this machine loses its connection"
action: "Stop"
enabled: !NetworkTools.busy
divider: false
onTriggered: NetworkTools.stopHotspot()
}
}
// ── VPN ──────────────────────────────────────────────────────────────────
SettingsCard {
title: "VPN"
subtitle: "WireGuard and OpenVPN profiles NetworkManager holds for you."
TextRow {
width: parent.width
visible: Vpn.lastError !== ""
label: "The last VPN action did not finish"
detail: Vpn.lastError
value: ""
}
Repeater {
model: Vpn.connections
delegate: SwitchRow {
id: vpnRow
required property var modelData
required property int index
width: parent.width
label: String(vpnRow.modelData.name ?? "")
detail: String(vpnRow.modelData.kind ?? "VPN")
+ (vpnRow.modelData.active === true ? " · connected" : "")
checked: vpnRow.modelData.active === true
enabled: !Vpn.busy
onToggled: value => Vpn.setActive(String(vpnRow.modelData.uuid ?? ""), value)
}
}
TextRow {
width: parent.width
visible: Vpn.scanned && Vpn.connections.length === 0
label: "No VPNs configured"
detail: "Import a WireGuard or OpenVPN file to add one"
value: ""
}
// Names the profile that appeared rather than saying "done". An import
// that succeeds under a name you did not choose is otherwise invisible
// until you go looking for it in the list.
TextRow {
width: parent.width
visible: NetworkTools.lastImport !== ""
label: "Imported " + NetworkTools.lastImport
detail: "It is switched off until you turn it on above"
value: ""
}
ActionRow {
width: parent.width
label: "Import a VPN"
detail: "A .conf file from WireGuard, or a .ovpn file from OpenVPN"
action: root.importOpen ? "Cancel" : "Import…"
enabled: !NetworkTools.busy
divider: root.importOpen
onTriggered: {
root.importOpen = !root.importOpen;
root.importPath = "";
}
}
// A typed path rather than a file chooser this phase. A chooser is the
// better answer and is worth doing properly; a typed path is worth far
// more than the door back to GNOME it replaces.
Column {
width: parent.width
visible: root.importOpen
TextFieldRow {
width: parent.width
label: "File"
detail: "The full path to the profile you were given"
placeholder: "~/Downloads/work.ovpn"
text: root.importPath
enabled: !NetworkTools.busy
onAccepted: value => root.importPath = value
}
ActionRow {
width: parent.width
label: "Import this profile"
detail: "NetworkManager reads it and adds a profile. Nothing connects until you switch it on."
action: NetworkTools.busy ? "Importing…" : "Import"
enabled: root.importPath.trim() !== "" && !NetworkTools.busy
divider: false
onTriggered: {
NetworkTools.importVpn(root.importPath.trim());
root.importOpen = false;
}
}
}
}
// ── Bluetooth ────────────────────────────────────────────────────────────
SettingsCard {
title: "Bluetooth"
visible: Connectivity.adapter !== null
@@ -159,54 +367,119 @@ SettingsPage {
SettingRow {
label: "Bluetooth"
detail: Connectivity.adapter
? (Connectivity.adapter.enabled ? "On" : "Off")
detail: Connectivity.bluetoothAvailable
? (Connectivity.bluetoothEnabled ? "On" : "Off")
: "Unavailable"
controlWidth: 48
divider: !!(Connectivity.adapter && Connectivity.adapter.enabled)
divider: Connectivity.bluetoothEnabled
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: !!(Connectivity.adapter && Connectivity.adapter.enabled)
enabled: Connectivity.adapter !== null
onToggled: value => {
if (Connectivity.adapter)
Connectivity.adapter.enabled = value;
}
checked: Connectivity.bluetoothEnabled
enabled: Connectivity.bluetoothAvailable
onToggled: value => Connectivity.setBluetoothEnabled(value)
}
}
BluetoothPanel {
width: parent.width
visible: !!(Connectivity.adapter && Connectivity.adapter.enabled)
visible: Connectivity.bluetoothEnabled
}
}
// ── Radios and proxy ─────────────────────────────────────────────────────
SettingsCard {
title: "Owned by Fedora"
// These two panels drive NetworkManager over D-Bus, which is why they
// work in this session when most GNOME panels do not. They are split
// because GNOME splits them: "network" is wired, VPN and proxies, and
// does not contain Wi-Fi -- the one panel used to point everyone
// there, so the road to a hidden SSID or eduroam ended on a page
// without Wi-Fi on it.
subtitle: "Wired, VPN and Wi-Fi connection editing stay with GNOME's panels, which drive the same NetworkManager this page reads. Printers and online accounts have their own pages here."
title: "Radios and proxy"
subtitle: "The two settings that apply to every connection at once."
ActionRow {
label: "Wi-Fi networks"
detail: "Hidden networks, enterprise (802.1X) logins, and per-network settings"
action: "Open"
onTriggered: SystemSettings.openGnomePanel("wifi")
// A hardware kill switch cannot be overridden from software, so the
// switch says so rather than moving and having nothing happen.
SwitchRow {
width: parent.width
label: "Airplane mode"
detail: NetworkTools.airplaneHardBlocked
? "A switch on this machine is holding the radios off. Software cannot turn them back on."
: "Turns Wi-Fi and Bluetooth off together — the same switch the keyboard's airplane key throws"
checked: NetworkTools.airplaneOn
enabled: !NetworkTools.busy && !NetworkTools.airplaneHardBlocked
onToggled: value => NetworkTools.setAirplane(value)
}
ActionRow {
label: "Network connections"
detail: "VPN, proxies, and wired connection settings"
action: "Open"
OptionPickerRow {
width: parent.width
label: "Network proxy"
detail: NetworkTools.proxyMode === "none"
? "Applications that honour the system proxy use this. Not every application does."
: "In use: " + NetworkTools.proxySummary
enabled: !NetworkTools.busy
options: [
{
value: "none",
label: "Off",
detail: "Applications reach the network directly"
},
{
value: "manual",
label: "Manual",
detail: "A host and port you enter, used for http, https and socks alike"
},
{
value: "auto",
label: "Automatic (PAC)",
detail: "A configuration URL decides, per address"
}
]
current: NetworkTools.proxyMode
onPicked: value => NetworkTools.setProxyMode(String(value))
}
Column {
width: parent.width
visible: NetworkTools.proxyMode === "manual"
// Host and port are written together, because the proxy is only
// usable as a pair -- so each field commits with whatever the other
// one currently holds, and neither writes a half-configuration.
TextFieldRow {
width: parent.width
label: "Proxy host"
detail: "The machine applications should go through"
placeholder: "proxy.example.com"
text: NetworkTools.proxyHost
enabled: !NetworkTools.busy
onAccepted: value => {
if (value.trim() !== "" && NetworkTools.proxyPort !== "")
NetworkTools.setProxyManual(value.trim(), NetworkTools.proxyPort);
}
}
TextFieldRow {
width: parent.width
label: "Port"
detail: "The port that proxy listens on"
placeholder: "8080"
text: NetworkTools.proxyPort
enabled: !NetworkTools.busy
divider: false
onAccepted: value => {
if (value.trim() !== "" && NetworkTools.proxyHost !== "")
NetworkTools.setProxyManual(NetworkTools.proxyHost, value.trim());
}
}
}
TextFieldRow {
width: parent.width
visible: NetworkTools.proxyMode === "auto"
label: "Configuration URL"
detail: "The .pac file whoever runs the network published"
placeholder: "http://example.com/proxy.pac"
text: NetworkTools.proxyPac
enabled: !NetworkTools.busy
divider: false
onTriggered: SystemSettings.openGnomePanel("network")
onAccepted: value => NetworkTools.setProxyPac(value.trim())
}
}
}
@@ -0,0 +1,98 @@
// Joining an 802.1X network -- eduroam and every corporate SSID -- inline,
// under the row you clicked.
//
// This is the single road that used to end at GNOME's Wi-Fi panel. A PSK field
// cannot join these networks: they want an authentication method, an identity,
// a password, and sometimes a certificate to check the network against.
//
// The password never becomes a command-line argument. It is handed to
// panama-network over stdin, because argv is world-readable through /proc for
// as long as the process lives -- see the pin in network-tools-contract. It is
// also dropped from this form the moment the form closes.
//
// No file dialog for the certificate this phase: a path typed in is worse than
// a picker and much better than a road back to GNOME.
import QtQuick
Column {
id: root
property string ssid: ""
property bool busy: false
// Live form state. There is no reset(): WifiPanel loads this form and
// destroys it when the row closes, so the typed password goes with it
// rather than sitting in a hidden object waiting to be reopened.
property string eap: "peap-mschapv2"
property string identity: ""
property string password: ""
property string caPath: ""
readonly property bool ready: root.identity.trim() !== "" && root.password !== ""
signal submitted(eap: string, identity: string, password: string, caPath: string)
width: parent ? parent.width : 620
spacing: 0
OptionPickerRow {
width: parent.width
label: "Authentication"
detail: "What the network expects. Whoever runs it publishes this."
options: [
{
value: "peap-mschapv2",
label: "PEAP · MSCHAPv2",
detail: "The usual choice for university and workplace networks"
},
{
value: "ttls-pap",
label: "TTLS · PAP",
detail: "For networks built around a plain-text inner method"
}
]
current: root.eap
onPicked: value => root.eap = String(value)
}
TextFieldRow {
width: parent.width
label: "Identity"
detail: "The username the network knows you by, often an email address"
placeholder: "[email protected]"
text: root.identity
enabled: !root.busy
onAccepted: value => root.identity = value
}
PasswordRow {
width: parent.width
label: "Password"
detail: "Handed to NetworkManager directly. It is never written to a command line."
placeholder: "Password for " + (root.ssid !== "" ? root.ssid : "this network")
onChanged: value => root.password = value
}
TextFieldRow {
width: parent.width
label: "CA certificate"
detail: "Optional. A path to the certificate that proves the network is the one it claims to be."
placeholder: "/etc/ssl/certs/example.pem"
text: root.caPath
enabled: !root.busy
onAccepted: value => root.caPath = value
}
ActionRow {
width: parent.width
label: "Connect to " + (root.ssid !== "" ? root.ssid : "this network")
detail: root.ready
? "NetworkManager saves this network, so it comes back on its own next time"
: "An identity and a password are needed before this network will answer"
action: root.busy ? "Connecting…" : "Connect"
enabled: root.ready && !root.busy
divider: false
onTriggered: root.submitted(root.eap, root.identity.trim(), root.password, root.caPath.trim())
}
}
@@ -25,6 +25,96 @@ SettingsPage {
property string confirmingRemoval: ""
property bool confirmingRange: false
// The add flow. Additions are not destructive -- they open something rather
// than closing it -- so there is no confirm here, only the caption saying
// the rule is permanent and will prompt.
property bool addOpen: false
property string addKind: "service"
property string addValue: ""
// Changing a connection's zone IS consequence-bearing: it silently rewrites
// what every machine on that network can reach. Two-stage, and the confirm
// names the interface it is about to move.
property string pendingInterface: ""
property string pendingZone: ""
// The zone being read in the browser. Read-only: this looks at a zone
// without applying it to anything.
property string browsingZone: ""
readonly property bool addValid: root.addKind === "service"
? /^[a-z0-9][a-z0-9-]*$/.test(root.addValue.trim())
: /^[0-9]{1,5}(-[0-9]{1,5})?\/(tcp|udp)$/.test(root.addValue.trim())
// [{ iface, zone }] -- one row per network interface the firewall has
// actually placed in a zone, flattened out of activeZones.
readonly property var zonedInterfaces: {
const placed = Firewall.activeZones ?? ({});
const rows = [];
for (const zoneName of Object.keys(placed)) {
for (const iface of (placed[zoneName] ?? []))
rows.push({ iface: String(iface), zone: String(zoneName) });
}
rows.sort((a, b) => a.iface.localeCompare(b.iface));
return rows;
}
readonly property var zoneOptions: (Firewall.allZones ?? []).map(zoneName => ({
value: String(zoneName),
label: String(zoneName),
detail: String(zoneName) === Firewall.defaultZone
? "The default for new connections" : ""
}))
// What an interface is carrying, said the way a person names it: the Wi-Fi
// network or the wired profile, falling back to the kernel's name for it.
function connectionOn(iface: string): string {
if (Connectivity.wifiDevice && Connectivity.wifiDevice.name === iface
&& Connectivity.activeNetwork)
return Connectivity.activeNetwork.name;
if (Connectivity.wiredDevice && Connectivity.wiredDevice.name === iface
&& Connectivity.wiredDevice.network)
return String(Connectivity.wiredDevice.network.name ?? iface);
return iface;
}
// The rules by name, then what the zone does with everything else. The
// helper's `summary` counts them; the names are what someone comparing two
// zones actually needs, so the names are listed and the helper's own
// sentence about the target is kept for the tail.
function describeZone(zoneName: string): string {
const info = Firewall.zoneInfo(zoneName);
if (!info)
return "Reading what " + zoneName + " allows…";
const services = (info.services ?? []).map(entry => String(entry));
const ports = (info.ports ?? []).map(entry => String(entry));
const parts = [];
if (services.length > 0)
parts.push(services.join(", "));
if (ports.length > 0)
parts.push("ports " + ports.join(", "));
let text = parts.length === 0
? zoneName + " allows nothing in."
: zoneName + " allows: " + parts.join(", and ") + ".";
// "3 services, 1 port rule; anything no rule allows is rejected" --
// everything after the semicolon is the helper's phrasing for the
// zone's target, which the list above does not say.
const summary = String(info.summary ?? "");
const cut = summary.indexOf("; ");
if (cut >= 0)
text += " Otherwise, " + summary.slice(cut + 2) + ".";
const rich = (info.richRules ?? []).length;
if (rich > 0)
text += " It also carries " + rich + " rich rule"
+ (rich === 1 ? "" : "s") + ", which this page never edits.";
return text;
}
Component.onCompleted: Firewall.refresh()
TextRow {
@@ -180,7 +270,9 @@ SettingsPage {
: "Anything relying on this service stops being reachable.")
: "Allowed by name, so it works whatever the port range says"
controlWidth: 210
divider: serviceRow.index < (Firewall.zone?.services ?? []).length - 1
// The add row always follows, so the last service still needs
// its hairline.
divider: true
Row {
anchors.right: parent.right
@@ -208,6 +300,80 @@ SettingsPage {
}
}
// ── The add side ─────────────────────────────────────────────────────
//
// Opening a port is not destructive, so it is not confirmed. It is
// permanent and it prompts, and both of those are said before the
// button rather than discovered by the polkit dialog appearing.
ActionRow {
width: parent.width
label: "Allow something new"
detail: "A service firewalld already knows by name, or a port and protocol"
action: root.addOpen ? "Cancel" : "Add…"
enabled: !Firewall.busy
divider: root.addOpen
onTriggered: {
root.addOpen = !root.addOpen;
root.addValue = "";
}
}
Column {
width: parent.width
visible: root.addOpen
SegmentRow {
width: parent.width
label: "What to allow"
detail: "A named service carries its own ports, so it keeps working if they change"
controlWidth: 230
options: [
{ value: "service", label: "Named service" },
{ value: "port", label: "Port" }
]
value: root.addKind
enabled: !Firewall.busy
onSelected: value => {
root.addKind = String(value);
root.addValue = "";
}
}
TextFieldRow {
width: parent.width
label: root.addKind === "service" ? "Service name" : "Port and protocol"
detail: root.addKind === "service"
? "One of firewalld's own service names, in lower case"
: "A port or range, then tcp or udp"
placeholder: root.addKind === "service" ? "syncthing" : "8080/tcp"
text: root.addValue
enabled: !Firewall.busy
onAccepted: value => root.addValue = value
}
ActionRow {
width: parent.width
label: root.addValue.trim() === "" || root.addValid
? "Allow it"
: (root.addKind === "service"
? "That is not a service name firewalld would accept"
: "That is not a port firewalld would accept")
detail: "This writes a permanent rule — the system will ask for your password."
action: "Allow"
enabled: root.addValid && !Firewall.busy
divider: false
onTriggered: {
if (root.addKind === "service")
Firewall.addService(root.addValue.trim());
else
Firewall.addPort(root.addValue.trim());
root.addOpen = false;
root.addValue = "";
}
}
}
// Shown, never edited.
TextRow {
visible: (Firewall.zone?.richRules ?? []).length > 0
@@ -222,26 +388,176 @@ SettingsPage {
SettingsCard {
title: "Zones"
subtitle: "A zone is a set of rules. Each network connection uses one."
subtitle: "A zone is a set of rules. Each network connection uses one, and moving a connection between zones changes what the machines on that network can reach."
Repeater {
model: Object.keys(Firewall.activeZones ?? ({}))
model: root.zonedInterfaces
delegate: Column {
id: placement
delegate: TextRow {
required property var modelData
required property int index
readonly property string iface: String(placement.modelData.iface ?? "")
readonly property string zone: String(placement.modelData.zone ?? "")
readonly property bool pending: root.pendingInterface === placement.iface
width: parent.width
label: String(modelData)
detail: "Applied to " + (Firewall.activeZones[String(modelData)] ?? []).join(", ")
value: String(modelData) === Firewall.defaultZone ? "Default" : ""
divider: true
OptionPickerRow {
width: parent.width
label: root.connectionOn(placement.iface)
detail: root.connectionOn(placement.iface) === placement.iface
? "This interface"
: "On " + placement.iface
enabled: !Firewall.busy
options: root.zoneOptions
current: placement.zone
divider: !placement.pending
onPicked: value => {
root.pendingInterface = placement.iface;
root.pendingZone = String(value);
}
}
SettingRow {
width: parent.width
visible: placement.pending
label: "Move " + placement.iface + " to " + root.pendingZone + "?"
// The interface is named because that is the thing being
// moved, and because "public" on the wrong one is the
// difference between a safe café and an unreachable desk.
detail: placement.iface + " leaves " + placement.zone + " for "
+ root.pendingZone + ". Everything reachable over "
+ placement.iface + " is decided by " + root.pendingZone
+ " from then on."
controlWidth: 210
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: "Keep " + placement.zone
enabled: !Firewall.busy
onClicked: {
root.pendingInterface = "";
root.pendingZone = "";
}
}
SettingsButton {
text: "Move it"
tone: "danger"
enabled: !Firewall.busy
onClicked: {
const target = root.pendingZone;
root.pendingInterface = "";
root.pendingZone = "";
Firewall.setZone(placement.iface, target);
}
}
}
}
}
}
TextRow {
width: parent.width
visible: Firewall.scanned && root.zonedInterfaces.length === 0
label: "No connection is in a zone"
detail: !Firewall.available
? "The firewall is not running, so nothing has been placed"
: "firewalld reports no active zones, which usually means no interface is up"
value: ""
}
OptionPickerRow {
width: parent.width
label: "Default for new connections"
detail: "Used when a network does not ask for a particular zone"
value: Firewall.defaultZone
enabled: !Firewall.busy
options: root.zoneOptions
current: Firewall.defaultZone
onPicked: value => Firewall.setDefaultZone(String(value))
}
// ── The zone browser ─────────────────────────────────────────────────
//
// Read-only. Picking a chip here applies nothing: it answers "what would
// this zone do", which is the question you have to answer before the
// dropdowns above are anything but a guess.
TextRow {
width: parent.width
visible: (Firewall.allZones ?? []).length > 0
label: "Browse zones"
detail: "What each of the " + (Firewall.allZones ?? []).length
+ " zones would allow. Nothing here applies anything."
value: ""
divider: false
}
// Full width rather than in the row's trailing slot: fourteen chips wrap
// to several lines, and a row's control area is one line tall.
Flow {
width: parent.width
visible: (Firewall.allZones ?? []).length > 0
spacing: 6
bottomPadding: 12
Repeater {
model: Firewall.allZones ?? []
delegate: Rectangle {
id: chip
required property var modelData
readonly property string zoneName: String(chip.modelData)
readonly property bool current: root.browsingZone === chip.zoneName
width: chipLabel.implicitWidth + 20
height: 26
radius: Theme.pillRadius
color: chip.current
? Theme.alpha(Theme.accent, 0.18)
: Theme.alpha(Theme.fg, chipHover.hovered ? 0.11 : 0.055)
border.width: 1
border.color: chip.current
? Theme.alpha(Theme.accent, 0.45)
: Theme.alpha(Theme.fg, 0.09)
Text {
id: chipLabel
anchors.centerIn: parent
text: chip.zoneName
color: chip.current ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: chip.current ? Font.DemiBold : Font.Normal
}
HoverHandler {
id: chipHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: root.browsingZone = chip.current ? "" : chip.zoneName
}
}
}
}
TextRow {
width: parent.width
visible: root.browsingZone !== ""
label: root.browsingZone
detail: root.describeZone(root.browsingZone)
value: root.browsingZone === Firewall.defaultZone ? "Default" : ""
divider: false
}
}
@@ -408,6 +408,11 @@ SettingsPage {
wrapMode: Text.WordWrap
}
// Lands on System rather than Network. This button used to open the
// Network panel as a generic front door, which stopped being true
// the moment Connections absorbed VPN, proxies, hotspot and
// enterprise Wi-Fi: sending someone to GNOME for a page Panama now
// owns is exactly what gnome-handoff-contract exists to catch.
SettingsButton {
id: gnomeSettingsButton
anchors.right: parent.right
@@ -416,9 +421,9 @@ SettingsPage {
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: SystemSettings.openGnomePanel("network")
Keys.onReturnPressed: SystemSettings.openGnomePanel("network")
Keys.onSpacePressed: SystemSettings.openGnomePanel("network")
onClicked: SystemSettings.openGnomePanel("system")
Keys.onReturnPressed: SystemSettings.openGnomePanel("system")
Keys.onSpacePressed: SystemSettings.openGnomePanel("system")
}
}
@@ -29,6 +29,43 @@ SettingsPage {
readonly property bool manualReady: /^(ipp|ipps|socket):\/\/\S+$/.test(root.manualUri)
&& root.manualName.trim() !== ""
// The two defaults a queue can be told to change, dressed for reading. The
// values are the helper's closed vocabulary and nothing else reaches
// lpadmin; the choices come from what the printer said it supports, so a
// queue that cannot print two-sided never grows a two-sided dropdown.
//
// Empty while get-options is still on its way, which is why the rows are
// gated on the list rather than on the current value: an empty dropdown
// above a printer is worse than no dropdown at all.
function mediaChoices(reported: var): var {
const detail = {
"Letter": "8.5 × 11 in",
"A4": "210 × 297 mm",
"Legal": "8.5 × 14 in"
};
return (reported?.choices?.media ?? []).map(choice => ({
value: String(choice),
label: String(choice),
detail: detail[String(choice)] ?? ""
}));
}
function sidesChoices(reported: var): var {
const named = {
"one-sided": { label: "Off", detail: "One page per sheet" },
"two-sided-long-edge": { label: "Long edge", detail: "Bound like a book" },
"two-sided-short-edge": { label: "Short edge", detail: "Bound like a notepad" }
};
return (reported?.choices?.sides ?? []).map(choice => {
const known = named[String(choice)];
return {
value: String(choice),
label: known ? known.label : String(choice),
detail: known ? known.detail : ""
};
});
}
Component.onCompleted: Printers.refresh()
TextRow {
@@ -80,9 +117,18 @@ SettingsPage {
}
Column {
id: printerBody
width: printerBlock.width
visible: printerBlock.open
// What this queue is set to, as CUPS reports it. Null until
// the read comes back, and a printer that reports neither
// key simply has no dropdowns -- an option offered for a
// printer that does not have it is a setting that silently
// does nothing.
readonly property var reported: Printers.optionsFor(printerBlock.name)
TextRow {
width: printerBlock.width
label: "Model"
@@ -90,6 +136,28 @@ SettingsPage {
value: String(printerBlock.modelData.makeAndModel ?? "Unknown")
}
OptionPickerRow {
width: printerBlock.width
visible: root.mediaChoices(printerBody.reported).length > 0
label: "Paper size"
detail: "What applications print onto unless they ask for something else"
enabled: !Printers.busy
options: root.mediaChoices(printerBody.reported)
current: String(printerBody.reported?.options?.media ?? "")
onPicked: value => Printers.setOption(printerBlock.name, "media", String(value))
}
OptionPickerRow {
width: printerBlock.width
visible: root.sidesChoices(printerBody.reported).length > 0
label: "Two-sided"
detail: "Long edge is the usual choice for text; short edge flips like a notepad"
enabled: !Printers.busy
options: root.sidesChoices(printerBody.reported)
current: String(printerBody.reported?.options?.sides ?? "")
onPicked: value => Printers.setOption(printerBlock.name, "sides", String(value))
}
ActionRow {
width: printerBlock.width
visible: !printerBlock.modelData.isDefault
@@ -173,22 +241,47 @@ SettingsPage {
Repeater {
model: Printers.jobs
delegate: ActionRow {
delegate: SettingRow {
id: jobRow
required property var modelData
required property int index
readonly property bool held: Printers.isHeld(jobRow.modelData)
width: parent.width
label: String(jobRow.modelData.name ?? "Untitled")
detail: String(jobRow.modelData.printer ?? "") + " · "
+ String(jobRow.modelData.state ?? "")
+ (Number(jobRow.modelData.pages ?? 0) > 0
? " · " + jobRow.modelData.pages + " pages" : "")
action: "Cancel"
enabled: !Printers.busy
controlWidth: 175
divider: jobRow.index < Printers.jobs.length - 1
onTriggered: Printers.cancel(Number(jobRow.modelData.id))
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
// Holding is the answer to "stop this one, I have not
// finished with it" -- cancelling was the only way out of
// the queue before, and it is not reversible.
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: jobRow.held ? "Release" : "Hold"
enabled: !Printers.busy
onClicked: jobRow.held
? Printers.release(Number(jobRow.modelData.id))
: Printers.hold(Number(jobRow.modelData.id))
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: "Cancel"
enabled: !Printers.busy
onClicked: Printers.cancel(Number(jobRow.modelData.id))
}
}
}
}
@@ -201,42 +294,26 @@ SettingsPage {
}
}
// ── Nothing set up yet ───────────────────────────────────────────────────
SettingsCard {
visible: Printers.scanned && !Printers.anyPrinters
title: "No printers yet"
subtitle: "Printers that announce themselves on your network appear here on their own."
ActionRow {
label: "Search the network"
detail: Printers.searching
? "Listening for printers that announce themselves…"
: (Printers.searched
? Printers.addable.length + " found"
: "Looks for printers over mDNS, the same way phones and laptops find them")
action: Printers.searching ? "Searching…" : "Search"
enabled: !Printers.searching
divider: false
onTriggered: Printers.search()
}
}
// ── Adding ───────────────────────────────────────────────────────────────
//
// One card, not two. "No printers yet" and "Add a printer" carried the same
// Search row with two different wordings, so a machine with no printers
// offered the identical button twice under two headings.
SettingsCard {
title: "Add a printer"
subtitle: "Driverless printers only. One that needs a manufacturer driver has to be set up with the system printer tool."
title: Printers.anyPrinters ? "Add a printer" : "No printers yet"
subtitle: Printers.anyPrinters
? "Driverless printers only. One that needs a manufacturer driver has to be set up with the system printer tool."
: "Printers on this network that speak modern IPP appear here on their own. Driverless only."
ActionRow {
visible: Printers.anyPrinters
label: "Search the network"
detail: Printers.searching
? "Listening for printers that announce themselves…"
: (Printers.searched
? Printers.addable.length + " printer"
+ (Printers.addable.length === 1 ? "" : "s") + " found that are not set up here"
: "Looks for printers over mDNS")
: "Looks for printers over mDNS, the same way phones and laptops find them")
action: Printers.searching ? "Searching…" : "Search"
enabled: !Printers.searching
onTriggered: Printers.search()
@@ -22,18 +22,21 @@ SettingsPage {
Component.onCompleted: Sharing.refresh()
TextRow {
visible: Sharing.lastError !== ""
label: "Sharing needs attention"
detail: Sharing.lastError
value: ""
divider: false
}
SettingsCard {
title: "This machine"
subtitle: "The name other machines see."
// The error belongs to the machine, not to a floating banner above the
// title: a bare row above the first card read as part of the page
// furniture, which is exactly what an error must not do.
TextRow {
width: parent.width
visible: Sharing.lastError !== ""
label: "Sharing needs attention"
detail: Sharing.lastError
value: ""
}
TextFieldRow {
label: "Network name"
detail: "Used for ssh and for anything else that finds this machine by name"
@@ -47,32 +50,37 @@ SettingsPage {
SettingsCard {
title: "Remote login"
subtitle: "Sign in to a terminal on this machine over SSH."
subtitle: Sharing.remoteLoginOn
? "Sign in to a terminal on this machine with: ssh " + Sharing.networkName
: "Sign in to a terminal on this machine over SSH."
// Port and the password-sign-in claim used to be two rows of their own
// below this one. They describe this switch rather than standing beside
// it, so they read as its detail line -- and the card is three rows
// shorter for saying the same things.
//
// "Keys only" is reported from sshd's configuration rather than assumed:
// claiming it on a machine that actually accepts passwords would be a
// security claim this page cannot back up.
SwitchRow {
label: "Allow remote login"
detail: Sharing.remoteLogin?.installed === true
? (Sharing.remoteLoginOn
? "Running, and starts automatically at boot"
: "Not running")
? "OpenSSH · port " + String(Sharing.remoteLogin?.port ?? "22")
+ " · password sign-in: " + Sharing.passwordLoginSummary().toLowerCase()
+ (Sharing.remoteLoginOn ? " · starts at boot" : "")
: "OpenSSH server is not installed"
checked: Sharing.remoteLoginOn
enabled: !Sharing.busy && Sharing.remoteLogin?.installed === true
divider: Sharing.remoteLoginOn
onToggled: value => Sharing.setRemoteLogin(value)
}
TextRow {
visible: Sharing.remoteLoginOn
label: "Connect with"
detail: "From another machine on your network"
value: "ssh " + Sharing.networkName
}
TextRow {
visible: Sharing.remoteLoginOn && Sharing.remoteSessions.length === 0
label: "Nobody is signed in"
label: "Nobody is signed in remotely"
detail: "Remote login is on, and no one is connected from another machine."
value: ""
divider: false
}
Repeater {
@@ -80,30 +88,14 @@ SettingsPage {
delegate: TextRow {
required property var modelData
required property int index
width: parent.width
label: String(modelData.user ?? "") + " is signed in from " + String(modelData.from ?? "")
detail: "Since " + String(modelData.since ?? "") + " · " + String(modelData.line ?? "")
value: ""
divider: index < Sharing.remoteSessions.length - 1
}
}
TextRow {
visible: Sharing.remoteLoginOn
label: "Port"
detail: "Where the SSH server is listening"
value: String(Sharing.remoteLogin?.port ?? "22")
}
// Reported from the configuration rather than assumed. Saying "keys
// only" on a machine that actually accepts passwords would be a
// security claim this page cannot back up.
TextRow {
visible: Sharing.remoteLoginOn
label: "Password sign-in"
detail: Sharing.passwordLoginSummary()
value: ""
divider: false
}
}
SettingsCard {
@@ -113,11 +105,12 @@ SettingsPage {
SwitchRow {
label: "Allow remote desktop"
detail: Sharing.remoteDesktop?.available === true
? (Sharing.remoteDesktopOn
? "Running for your session"
? "RDP · port " + String(Sharing.remoteDesktop?.port ?? "3389") + " · "
+ (Sharing.remoteDesktopOn
? "running for your session"
: (Sharing.remoteDesktop?.hasCredentials === true
? "Not running"
: "Set a username and password before turning this on"))
? "not running"
: "set a username and password before turning this on"))
: "Remote desktop support is not installed"
checked: Sharing.remoteDesktopOn
enabled: !Sharing.busy
@@ -149,12 +142,16 @@ SettingsPage {
// terminal, never into this page. grdctl prompts for it on a terminal
// and crashes without one, and passing it as an argument would publish
// it through /proc to every process on this machine.
//
// The row says why rather than naming the mechanism: "opens kitty" is
// an implementation detail, and the reason -- the password never passes
// through Panama -- is the part worth reading.
ActionRow {
visible: Sharing.remoteDesktop?.available === true
label: "Credentials"
detail: Sharing.remoteDesktop?.hasCredentials === true
? "Stored in the login keyring · setting new ones opens a terminal to type into"
: "None stored yet · remote desktop cannot be turned on without them"
? "Stored in the login keyring · set in a terminal so the password never passes through Panama"
: "None stored yet · set in a terminal so the password never passes through Panama"
action: "Set…"
enabled: !Sharing.busy
onTriggered: Sharing.setRdpCredentials(Quickshell.env("USER") || "")
@@ -176,11 +173,15 @@ SettingsPage {
title: "File and media sharing"
subtitle: "Sharing folders and media needs software this machine does not necessarily have."
// Not a switch, because there is nothing behind it. Naming what
// installing Samba would unlock is the difference between a dead row
// and an answer -- the panel this replaces shows a switch that silently
// does nothing.
TextRow {
label: "Share folders on the network"
detail: Sharing.fileSharing?.installed === true
? "Samba is installed"
: "Needs Samba, which is not installed. Settings does not install software."
? "Samba is installed, so folders can be published to Windows, macOS and Linux machines alike"
: "Samba is not installed — install it and this becomes a switch. Settings does not install software."
value: Sharing.fileSharing?.installed === true ? "Available" : "Not installed"
}
@@ -2,12 +2,26 @@
//
// The quick settings version is a popover: a compact list you glance at. This
// is the one you sit in front of when a network is not behaving, so each row
// carries what you would otherwise open a terminal to find out signal,
// carries what you would otherwise open a terminal to find out -- signal,
// security, and whether it is a network this machine already knows.
//
// Joining a secured network reveals an inline password field rather than
// failing silently, which is the one interaction the popover already got right
// and is worth keeping identical.
// The connected network opens: addresses, whether it comes back on its own,
// whether this machine shows the same hardware address to it every time, a QR
// code for a guest, and the way out. Those five were the whole reason this page
// still had a door back to GNOME's Wi-Fi panel.
//
// Three ways in, and they are genuinely different networks rather than three
// styles of the same one:
//
// * open or already known -- connect, nothing to type;
// * WPA with a passphrase -- an inline field, revealed on the row you clicked
// rather than in a dialog over a tiled window;
// * 802.1X enterprise -- a form, because a passphrase field cannot join
// eduroam and pretending otherwise is how people ended up in a terminal.
//
// Forgetting is two-stage and says what it costs, per the danger pattern the
// rest of Settings uses: the arming press changes the row's copy, and only the
// second press does anything.
import QtQuick
import Quickshell
@@ -21,25 +35,76 @@ Column {
spacing: 0
// SSID whose password field is open, and the last failure.
// Exactly one of these is non-empty at a time, so the panel never shows two
// ways to join the same network or two networks half-open at once.
property string expandedFor: ""
property string passwordFor: ""
property string enterpriseFor: ""
property string confirmingForget: ""
property string failedSsid: ""
property string failedText: ""
// The list shows what matters and folds the rest: connected and saved
// networks always render (they lead the sort, so a slice keeps them), and
// strangers fill the remaining slots up to the cap. Everything else waits
// behind the "more networks" row -- an apartment building's worth of
// neighbors' SSIDs is noise, not a list.
property bool showAll: false
readonly property int visibleCap: 6
readonly property var shown: {
const list = Connectivity.networks;
if (root.showAll || list.length <= root.visibleCap)
return list;
const pinned = list.filter(network => network.connected || network.known).length;
return list.slice(0, Math.max(root.visibleCap, pinned));
}
readonly property int hiddenCount: Connectivity.networks.length - root.shown.length
function isEnterprise(network: var): bool {
return Connectivity.securityLabel(network) === "Enterprise";
}
function closeAll(): void {
root.expandedFor = "";
root.passwordFor = "";
root.enterpriseFor = "";
root.confirmingForget = "";
}
// One click on a row means whatever that row's state makes it mean. The
// connected network opens rather than reconnecting to itself.
function activate(network: var): void {
root.failedSsid = "";
if (network.connected)
const name = network.name;
if (network.connected) {
const wasOpen = root.expandedFor === name;
root.closeAll();
root.expandedFor = wasOpen ? "" : name;
return;
}
if (root.isEnterprise(network)) {
const wasOpen = root.enterpriseFor === name;
root.closeAll();
root.enterpriseFor = wasOpen ? "" : name;
return;
}
if (network.known || !Connectivity.isSecured(network)) {
root.passwordFor = "";
root.closeAll();
network.connect();
return;
}
root.passwordFor = root.passwordFor === network.name ? "" : network.name;
const wasOpen = root.passwordFor === name;
root.closeAll();
root.passwordFor = wasOpen ? "" : name;
}
Repeater {
model: Connectivity.networks
model: root.shown
Column {
id: entry
@@ -47,6 +112,23 @@ Column {
required property var modelData
required property int index
readonly property string ssid: entry.modelData.name || ""
readonly property bool open: root.expandedFor === entry.ssid && entry.ssid !== ""
readonly property bool joining: root.passwordFor === entry.ssid && entry.ssid !== ""
readonly property bool enterprising: root.enterpriseFor === entry.ssid && entry.ssid !== ""
readonly property bool confirming: root.confirmingForget === entry.ssid && entry.ssid !== ""
// Addresses for the connected network, once the helper has answered.
// Null means "not read yet", which the details grid renders as such
// rather than as "no address".
readonly property var details: entry.modelData.connected && entry.ssid !== ""
? NetworkTools.detailsFor(entry.ssid) : null
// The saved profile behind this SSID, if it holds a passphrase a QR
// code could carry. Networks with no stored key cannot be shared.
readonly property var shareEntry: WifiShare.shareable.find(
candidate => String(candidate.ssid ?? "") === entry.ssid) ?? null
width: parent.width
SettingRow {
@@ -60,12 +142,20 @@ Column {
else if (entry.modelData.known)
bits.push("Saved");
bits.push(Connectivity.signalLabel(entry.modelData.signalStrength));
bits.push(Connectivity.securityLabel(entry.modelData));
bits.push(root.isEnterprise(entry.modelData)
? "Enterprise (802.1X)"
: Connectivity.securityLabel(entry.modelData));
return bits.join(" · ");
}
divider: entry.index < Connectivity.networks.length - 1 || root.passwordFor === entry.modelData.name
// When the drawer is open its own last row carries the hairline,
// so the header does not draw one immediately above it.
divider: entry.open
? false
: (entry.index < root.shown.length - 1
|| root.hiddenCount > 0 || root.showAll
|| entry.joining || entry.enterprising)
controlWidth: 190
activatable: !entry.modelData.connected
activatable: true
onActivated: root.activate(entry.modelData)
Row {
@@ -73,38 +163,162 @@ Column {
anchors.verticalCenter: parent.verticalCenter
spacing: 7
Text {
anchors.verticalCenter: parent.verticalCenter
visible: entry.modelData.connected
text: "Connected"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: entry.modelData.connected
text: "Disconnect"
onClicked: entry.modelData.disconnect()
onClicked: {
root.closeAll();
entry.modelData.disconnect();
}
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: !entry.modelData.connected
text: entry.modelData.known ? "Connect" : "Join"
text: root.isEnterprise(entry.modelData)
? (entry.enterprising ? "Cancel" : "Join…")
: (entry.modelData.known ? "Connect" : "Join")
onClicked: root.activate(entry.modelData)
}
// Only the connected row opens, so only it gets a caret.
Text {
anchors.verticalCenter: parent.verticalCenter
visible: entry.modelData.connected
text: entry.open ? "▴" : "▾"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
// The password field for this network, when it is the one being
// joined. Inline rather than a dialog: a dialog over a tiled window
// is a worse place to type than the row you just clicked.
// ── The connected network, opened ────────────────────────────────
Column {
width: parent.width
visible: entry.open
ConnectionDetails {
width: parent.width
details: entry.details
}
// These two describe a saved profile, and until the helper has
// answered this panel does not know what the profile says. A
// switch drawn on a guess is a switch that lies about the state
// it is reporting.
SwitchRow {
width: parent.width
visible: !!entry.details
label: "Connect automatically"
detail: "Rejoin this network whenever it is in range"
checked: entry.details?.autoconnect !== false
enabled: !NetworkTools.busy
onToggled: value => NetworkTools.setAutoconnect(entry.ssid, value)
}
SwitchRow {
width: parent.width
visible: !!entry.details
label: "Randomize MAC address"
detail: entry.details?.macRandomized === true
? "This network sees a made-up hardware address, so it cannot follow this machine between visits. Takes effect on the next reconnect."
: "Show this network a made-up hardware address instead of the adapter's real one. Takes effect on the next reconnect."
checked: entry.details?.macRandomized === true
enabled: !NetworkTools.busy
onToggled: value => NetworkTools.setMacRandom(entry.ssid, value)
}
ActionRow {
width: parent.width
visible: entry.shareEntry !== null
label: "Share this network"
detail: WifiShare.sharing === String(entry.shareEntry?.name ?? "")
? "Anyone who can see this screen can join"
: "Shows a QR code a phone can scan, so nobody has to read the password out"
action: WifiShare.sharing === String(entry.shareEntry?.name ?? "")
? "Hide" : "Show code"
onTriggered: WifiShare.sharing === String(entry.shareEntry?.name ?? "")
? WifiShare.stopSharing()
: WifiShare.share(String(entry.shareEntry?.name ?? ""))
}
// Drawn at its natural size on a white plate: a QR code inverted
// or tinted to match a dark theme is unreliable to scan, and this
// one has exactly one job.
Item {
width: parent.width
visible: entry.shareEntry !== null
&& WifiShare.sharing === String(entry.shareEntry?.name ?? "")
&& WifiShare.imagePath !== ""
implicitHeight: visible ? plate.height + 20 : 0
Rectangle {
id: plate
anchors.horizontalCenter: parent.horizontalCenter
y: 10
width: 208
height: 208
radius: 10
color: "white"
Image {
anchors.centerIn: parent
width: 184
height: 184
smooth: false
fillMode: Image.PreserveAspectFit
cache: false
source: WifiShare.imagePath !== "" ? "file://" + WifiShare.imagePath : ""
}
}
}
SettingRow {
width: parent.width
label: "Forget this network"
detail: entry.confirming
? "This disconnects now and deletes the saved password. Rejoining " + entry.ssid + " means typing it again."
: "Remove the saved profile for " + entry.ssid
controlWidth: 200
divider: entry.index < root.shown.length - 1
|| root.hiddenCount > 0 || root.showAll
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: entry.confirming ? "Keep it" : "Forget…"
enabled: !NetworkTools.busy
onClicked: root.confirmingForget = entry.confirming ? "" : entry.ssid
}
SettingsButton {
visible: entry.confirming
text: "Forget"
tone: "danger"
enabled: !NetworkTools.busy
onClicked: {
root.closeAll();
NetworkTools.forget(entry.ssid);
}
}
}
}
}
// ── A passphrase, for the network being joined ───────────────────
//
// Inline rather than a dialog: a dialog over a tiled window is a
// worse place to type than the row you just clicked.
Item {
width: parent.width
height: root.passwordFor === entry.modelData.name ? 54 : 0
height: entry.joining ? 54 : 0
visible: height > 0
clip: true
@@ -141,9 +355,33 @@ Column {
}
}
// ── 802.1X, for the networks a passphrase cannot reach ───────────
// Loaded rather than merely hidden, so closing the form destroys it
// and the password that was typed into it. A hidden form keeps its
// field, which is both a stale value on the way back in and a
// passphrase sitting in a live object for no reason.
Loader {
id: enterpriseLoader
width: parent.width
active: entry.enterprising
visible: enterpriseLoader.active
sourceComponent: EnterpriseJoinForm {
ssid: entry.ssid
busy: NetworkTools.busy
onSubmitted: (eap, identity, secret, caPath) => {
NetworkTools.joinEnterprise(entry.ssid, eap, identity, secret, caPath);
root.enterpriseFor = "";
}
}
}
Text {
width: parent.width
visible: root.failedSsid === entry.modelData.name
visible: root.failedSsid === entry.ssid && entry.ssid !== ""
leftPadding: 2
bottomPadding: 8
text: root.failedText
@@ -155,14 +393,28 @@ Column {
Connections {
target: entry.modelData
function onConnectionFailed(reason): void {
root.failedSsid = entry.modelData.name;
root.failedSsid = entry.ssid;
root.failedText = Connectivity.connectionFailureText(reason);
root.passwordFor = entry.modelData.name;
if (!root.isEnterprise(entry.modelData))
root.passwordFor = entry.ssid;
}
}
}
}
SettingRow {
width: parent.width
visible: root.hiddenCount > 0
|| (root.showAll && Connectivity.networks.length > root.visibleCap)
label: root.showAll
? "Show fewer networks"
: root.hiddenCount + (root.hiddenCount === 1 ? " more network" : " more networks")
detail: root.showAll ? "" : "Weaker signals, folded to keep the list short"
activatable: true
onActivated: root.showAll = !root.showAll
divider: false
}
SettingRow {
width: parent.width
visible: Connectivity.networks.length === 0
@@ -83,6 +83,8 @@ GradientSliderRow 1.0 GradientSliderRow.qml
OptionPickerRow 1.0 OptionPickerRow.qml
WifiPanel 1.0 WifiPanel.qml
BluetoothPanel 1.0 BluetoothPanel.qml
ConnectionDetails 1.0 ConnectionDetails.qml
EnterpriseJoinForm 1.0 EnterpriseJoinForm.qml
PasswordField 1.0 PasswordField.qml
AudioBalance 1.0 AudioBalance.qml
SoundDeviceList 1.0 SoundDeviceList.qml
+67 -3
View File
@@ -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()
+866
View File
@@ -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:]))
+128 -1
View File
@@ -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()
@@ -84,6 +84,30 @@ Singleton {
readonly property bool wifiEnabled: Networking.wifiEnabled
readonly property bool wifiAvailable: Networking.wifiHardwareEnabled
// The radio switches, as functions rather than as writes a page makes for
// itself. Both are one-line assignments to the native modules, which is
// exactly the point: a page that reached past this service to set
// Networking.wifiEnabled directly would be one page's worth of the rule
// this file exists to keep -- and the next such write, needing a retry or a
// guard, would have nowhere to live but the page.
//
// Native both ways. Nothing here shells out to nmcli or rfkill; airplane
// mode, which genuinely needs rfkill, lives in NetworkTools.qml instead.
function setWifiEnabled(enabled: bool): void {
if (Networking.wifiEnabled !== enabled)
Networking.wifiEnabled = enabled;
}
function setBluetoothEnabled(enabled: bool): void {
const device = root.adapter;
if (!device || device.enabled === enabled)
return;
device.enabled = enabled;
}
readonly property bool bluetoothEnabled: root.adapter?.enabled ?? false
readonly property bool bluetoothAvailable: !!root.adapter
// Current network, then saved, then by signal -- the order GNOME uses,
// which is the order you actually look for things in.
readonly property var networks: {
@@ -124,15 +148,37 @@ Singleton {
&& network.security !== WifiSecurityType.Unknown;
}
// Member names come from the installed plugin's own qmltypes --
// Quickshell/Networking/quickshell-network.qmltypes, whose WifiSecurityType
// is exactly: Wpa3SuiteB192, Sae, Wpa2Eap, Wpa2Psk, WpaEap, WpaPsk,
// StaticWep, DynamicWep, Leap, Owe, Open, Unknown -- rather than the
// GNOME-style names this switch used to test (Wep, Wpa, Wpa2, Wpa3,
// Enterprise). None of those five exist, and a `case` against an undefined
// member simply never matches, so every secured network read "Secured" and
// "Enterprise" was unreachable. The enterprise join form keys off exactly
// that string, so the whole 802.1X flow was dead.
//
// The open cases are named here rather than delegated to isSecured(),
// which treats Unknown as unsecured. Calling a network whose security
// nobody could read "Open" is a claim this cannot back up; Unknown falls
// through to "Secured" instead. isSecured() keeps its own meaning -- "does
// joining this need a passphrase" -- and is unchanged.
//
// Owe is Enhanced Open: encrypted, but joined without a passphrase, so to
// someone picking a network it reads as open.
function securityLabel(network: var): string {
if (!root.isSecured(network))
return "Open";
switch (network.security) {
case WifiSecurityType.Wep: return "WEP";
case WifiSecurityType.Wpa: return "WPA";
case WifiSecurityType.Wpa2: return "WPA2";
case WifiSecurityType.Wpa3: return "WPA3";
case WifiSecurityType.Enterprise: return "Enterprise";
case WifiSecurityType.Open: return "Open";
case WifiSecurityType.Owe: return "Open";
case WifiSecurityType.StaticWep: return "WEP";
case WifiSecurityType.WpaPsk: return "WPA";
case WifiSecurityType.Wpa2Psk: return "WPA2";
case WifiSecurityType.Sae: return "WPA3";
case WifiSecurityType.WpaEap: return "Enterprise";
case WifiSecurityType.Wpa2Eap: return "Enterprise";
case WifiSecurityType.Wpa3SuiteB192: return "Enterprise";
case WifiSecurityType.DynamicWep: return "Enterprise";
case WifiSecurityType.Leap: return "Enterprise";
}
return "Secured";
}
@@ -152,10 +198,14 @@ Singleton {
return "No signal";
}
// NoSecrets, not "Authentication" -- the qmltypes members are NoSecrets,
// Unknown, WifiAuthTimeout, WifiClientDisconnected, WifiClientFailed,
// WifiNetworkLost. A case against an undefined member never matches, so a
// wrong password used to fall through to the generic text.
function connectionFailureText(reason: var): string {
switch (reason) {
case ConnectionFailReason.WifiAuthTimeout:
case ConnectionFailReason.Authentication:
case ConnectionFailReason.NoSecrets:
return "Wrong password";
case ConnectionFailReason.WifiNetworkLost:
return "Network out of range";
@@ -57,6 +57,12 @@ Singleton {
return root.exposed.filter(entry => root.allowedByRange(entry));
}
// zone name -> the helper's zone-info shape, for the zone browser. Cached
// because browsing means asking about the same handful of zones as a chip
// row repaints, and each answer is two firewall-cmd calls.
property var zoneDetails: ({})
property var pendingZones: []
function refresh(): void {
if (query.running)
return;
@@ -64,6 +70,52 @@ Singleton {
query.running = true;
}
// What a zone allows, for reading before choosing one. Returns the cached
// answer, or null while the first one is on its way -- and asks for it, so
// a chip that binds to this fills itself in.
//
// Read-only, so it needs no authorization and never prompts: this is the
// difference between looking at a zone and moving an interface into it.
function zoneInfo(zoneName: string): var {
if (zoneName === "")
return null;
if (root.zoneDetails[zoneName] !== undefined)
return root.zoneDetails[zoneName];
root.requestZoneInfo(zoneName);
return null;
}
function requestZoneInfo(zoneName: string): void {
if (zoneName === "" || root.pendingZones.indexOf(zoneName) >= 0)
return;
root.pendingZones = root.pendingZones.concat([zoneName]);
root.drainZones();
}
function drainZones(): void {
if (zoneQuery.running || root.pendingZones.length === 0)
return;
zoneQuery.subject = root.pendingZones[0];
zoneQuery.command = [root.helperPath, "zone-info", zoneQuery.subject];
zoneQuery.running = true;
}
function absorbZone(zoneName: string, text: string): void {
try {
const parsed = JSON.parse(text);
// Reassigned rather than mutated: QML does not notice a property
// change made inside a var object.
const next = Object.assign({}, root.zoneDetails);
next[zoneName] = parsed;
root.zoneDetails = next;
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
} catch (error) {
root.lastError = "Could not read what that zone allows.";
console.warn("Firewall: could not parse zone-info output:", error);
}
}
function absorb(text: string): void {
try {
const parsed = JSON.parse(text);
@@ -90,6 +142,10 @@ Singleton {
if (mutation.running)
return;
root.lastError = "";
// Every mutation here can change what a zone allows or which zone an
// interface is in, so the browser's cached descriptions are dropped
// rather than left to describe the firewall as it used to be.
root.zoneDetails = ({});
mutation.command = [root.helperPath].concat(arguments);
mutation.running = true;
}
@@ -120,4 +176,31 @@ Singleton {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
// Kept out of `busy` on purpose: reading what a zone allows changes nothing,
// so it must not disable the buttons that do.
Process {
id: zoneQuery
property string subject: ""
stdout: StdioCollector {
onStreamFinished: root.absorbZone(zoneQuery.subject, this.text)
}
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: {
root.pendingZones = root.pendingZones.filter(name => name !== zoneQuery.subject);
zoneDrain.restart();
}
}
// One tick later, because `running` has not gone false inside onExited and
// the queue would stall on its own guard.
Timer {
id: zoneDrain
interval: 0
onTriggered: root.drainZones()
}
}
@@ -0,0 +1,392 @@
pragma Singleton
// The networking Quickshell has no surface for, so that Connectivity.qml can
// stay the pure-native thing it is pinned to be.
//
// Scanning, joining and pairing already work over DBus in Connectivity.qml, and
// nothing there may shell out. Everything NetworkManager knows but does not
// expose -- a connection's addresses, autoconnect, MAC randomisation, VPN
// import, hotspots, enterprise sign-in -- plus the system proxy and the radio
// kill switches, which are not NetworkManager's at all, live here and go
// through scripts/panama-network.
//
// The split is the point: one file that may never grow a Process, and one that
// is nothing but.
//
// Details are cached per connection rather than fetched on every paint. A
// details grid asks for the same connection every time it repaints, and each
// answer is five nmcli invocations.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.env("PANAMA_NETWORK_HELPER")
|| Quickshell.shellDir + "/scripts/panama-network"
// Set by the page while it is on screen. Networking state changes whether
// or not anyone is looking; re-reading it when nobody is costs nmcli
// invocations for an answer that will be stale again by the time it matters.
property bool active: false
// connection name -> the helper's connection shape. See detailsFor().
property var details: ({})
property var hotspot: ({})
property string proxyMode: "none"
property string proxyHost: ""
// A string, not a number, because the field that shows it is a text field:
// "no port set" and "port 0" are different answers, and only one of them is
// true of a proxy nobody has configured.
property string proxyPort: ""
property string proxyPac: ""
property bool airplaneOn: false
property bool airplaneHardBlocked: false
// The connection the last import produced, so the page can say which
// profile appeared rather than "done".
property string lastImport: ""
property string lastError: ""
// Guards read the Process objects directly; a derived binding is stale
// inside the handler that changes it. See DefaultApps.qml.
readonly property bool busy: mutation.running || enterprise.running || detailsQuery.running
// Connections whose details have been asked for, in order, so a burst of
// requests becomes one query at a time rather than one Process each.
property var pendingDetails: []
// Held only between "join" and the moment the helper's stdin is open.
property string pendingPassword: ""
readonly property bool hotspotActive: root.hotspot?.active === true
readonly property string hotspotSsid: String(root.hotspot?.ssid ?? "")
// Returned once by the verb that started or found the hotspot, for display
// beside a QR code. Never stored beyond the reply that carried it, and
// never logged.
readonly property string hotspotPassword: String(root.hotspot?.password ?? "")
readonly property string proxySummary: {
if (root.proxyMode === "manual")
return root.proxyHost !== "" ? root.proxyHost + ":" + root.proxyPort : "Manual";
if (root.proxyMode === "auto")
return root.proxyPac !== "" ? root.proxyPac : "Automatic";
return "Off";
}
// What a page binds to. Returns the cached answer, or null while the first
// one is on its way -- and asks for it, so a grid that binds to this fills
// itself in without the page having to sequence a fetch.
function detailsFor(connection: string): var {
if (connection === "")
return null;
if (root.details[connection] !== undefined)
return root.details[connection];
root.requestDetails(connection);
return null;
}
function requestDetails(connection: string): void {
if (connection === "")
return;
if (root.pendingDetails.indexOf(connection) >= 0)
return;
root.pendingDetails = root.pendingDetails.concat([connection]);
root.drainDetails();
}
// Forces a re-read of a connection already in the cache -- what the page
// calls when the active connection changes, because addresses and the MAC
// in use both change with it.
function refreshDetails(connection: string): void {
root.requestDetails(connection);
}
function drainDetails(): void {
if (detailsQuery.running || root.pendingDetails.length === 0)
return;
detailsQuery.subject = root.pendingDetails[0];
detailsQuery.command = [root.helperPath, "details", detailsQuery.subject];
detailsQuery.running = true;
}
// One connection's answer into the cache. Reassigned rather than mutated:
// QML does not notice a property change inside a var object.
function absorbDetails(connection: string, text: string): void {
try {
const parsed = JSON.parse(text);
const next = Object.assign({}, root.details);
next[connection] = parsed;
root.details = next;
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
} catch (error) {
root.lastError = "Could not read the network helper's answer.";
console.warn("NetworkTools: could not parse details:", error);
}
}
function absorbHotspot(text: string): void {
try {
const parsed = JSON.parse(text);
root.hotspot = parsed;
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
} catch (error) {
root.lastError = "Could not read the hotspot's state.";
}
}
function absorbProxy(text: string): void {
try {
const parsed = JSON.parse(text);
root.proxyMode = String(parsed.mode ?? "none");
root.proxyHost = String(parsed.host ?? "");
root.proxyPort = Number(parsed.port ?? 0) > 0 ? String(parsed.port) : "";
root.proxyPac = String(parsed.pacUrl ?? "");
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
} catch (error) {
root.lastError = "Could not read the proxy setting.";
}
}
function absorbAirplane(text: string): void {
try {
const parsed = JSON.parse(text);
root.airplaneOn = parsed.on === true;
root.airplaneHardBlocked = parsed.hardBlocked === true;
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
} catch (error) {
root.lastError = "Could not read the radio state.";
}
}
function absorbImport(text: string): void {
try {
const parsed = JSON.parse(text);
root.lastImport = String(parsed.name ?? "");
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
} catch (error) {
root.lastError = "Could not read the import result.";
}
}
// Which absorber a reply belongs to. The helper answers each verb with that
// verb's shape, so the caller records what it asked for rather than the
// reader guessing from the keys.
function absorb(shape: string, subject: string, text: string): void {
switch (shape) {
case "connection": root.absorbDetails(subject, text); break;
case "hotspot": root.absorbHotspot(text); break;
case "proxy": root.absorbProxy(text); break;
case "airplane": root.absorbAirplane(text); break;
case "import": root.absorbImport(text); break;
}
}
function run(shape: string, subject: string, argv: var): void {
if (mutation.running)
return;
root.lastError = "";
mutation.shape = shape;
mutation.subject = subject;
mutation.command = [root.helperPath].concat(argv);
mutation.running = true;
}
// ---- per-connection
function forget(connection: string): void {
root.run("connection", connection, ["forget", connection]);
}
function setAutoconnect(connection: string, enabled: bool): void {
root.run("connection", connection,
["set-autoconnect", connection, enabled ? "true" : "false"]);
}
function setMacRandom(connection: string, enabled: bool): void {
root.run("connection", connection,
["set-mac-random", connection, enabled ? "true" : "false"]);
}
// ---- VPN
function importVpn(path: string): void {
root.lastImport = "";
root.run("import", "", ["import-vpn", path]);
}
// ---- hotspot
function refreshHotspot(): void { root.run("hotspot", "", ["hotspot", "status"]); }
function startHotspot(ssid: string): void { root.run("hotspot", "", ["hotspot", "start", ssid]); }
function stopHotspot(): void { root.run("hotspot", "", ["hotspot", "stop"]); }
// ---- proxy
function refreshProxy(): void { root.run("proxy", "", ["proxy", "get"]); }
// The dropdown: switch which kind of proxy applies, leaving whatever
// address is already stored alone. The fields for a manual or automatic
// proxy only appear once its mode is chosen, so a mode that demanded them
// up front would be a mode nobody could pick.
function setProxyMode(mode: string): void {
root.run("proxy", "", ["proxy", "set", mode]);
}
function setProxyOff(): void { root.setProxyMode("none"); }
// Host and port together, because a proxy is only usable as a pair.
function setProxyManual(host: string, port: string): void {
root.run("proxy", "", ["proxy", "set", "manual", String(host), String(port)]);
}
function setProxyPac(pacUrl: string): void {
root.run("proxy", "", ["proxy", "set", "auto", pacUrl]);
}
// ---- radios
function refreshAirplane(): void { root.run("airplane", "", ["airplane", "status"]); }
function setAirplane(enabled: bool): void {
root.run("airplane", "", ["airplane", "set", enabled ? "true" : "false"]);
}
function toggleAirplane(): void { root.setAirplane(!root.airplaneOn); }
// ---- enterprise Wi-Fi
// The password goes down the helper's stdin and is never an argument.
// argv is world-readable through /proc for the life of the process, so a
// password passed that way is published to every process on the machine --
// which is why this has a Process of its own rather than reusing the one
// above: only this one ever opens stdin.
function joinEnterprise(ssid: string, profile: string, identity: string,
password: string, caCert: string): void {
if (enterprise.running)
return;
root.lastError = "";
root.pendingPassword = password;
enterprise.subject = ssid;
enterprise.command = String(caCert ?? "") !== ""
? [root.helperPath, "join-enterprise", ssid, profile, identity, caCert]
: [root.helperPath, "join-enterprise", ssid, profile, identity];
enterprise.stdinEnabled = true;
enterprise.running = true;
}
// Everything that is not per-connection, in one call: what a page asks for
// when it opens.
function refresh(): void {
root.refreshProxy();
root.refreshAirplaneSoon();
root.refreshHotspotSoon();
for (const connection in root.details)
root.requestDetails(connection);
}
// The mutation Process is one at a time, so the opening reads are spread
// over it rather than dropped by its running guard.
function refreshAirplaneSoon(): void { airplaneSoon.restart(); }
function refreshHotspotSoon(): void { hotspotSoon.restart(); }
onActiveChanged: if (root.active) root.refresh()
Timer {
id: airplaneSoon
interval: 120
onTriggered: {
if (mutation.running)
airplaneSoon.restart();
else
root.refreshAirplane();
}
}
Timer {
id: hotspotSoon
interval: 260
onTriggered: {
if (mutation.running)
hotspotSoon.restart();
else
root.refreshHotspot();
}
}
// The next queued details read, one tick after the last one exits. Draining
// from inside onExited would look at a `running` that has not gone false
// yet, and the queue would stall on its own guard.
Timer {
id: detailsDrain
interval: 0
onTriggered: root.drainDetails()
}
Process {
id: detailsQuery
property string subject: ""
stdout: StdioCollector {
onStreamFinished: root.absorbDetails(detailsQuery.subject, this.text)
}
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: {
root.pendingDetails = root.pendingDetails.filter(name => name !== detailsQuery.subject);
detailsDrain.restart();
}
}
Process {
id: mutation
// What the reply is, recorded when the command is built. The helper
// answers each verb with that verb's own shape.
property string shape: "connection"
property string subject: ""
// Mutations answer with the fresh state, so the page updates from the
// change itself rather than asking again afterwards.
stdout: StdioCollector {
onStreamFinished: root.absorb(mutation.shape, mutation.subject, this.text)
}
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
Process {
id: enterprise
property string subject: ""
onStarted: {
enterprise.write(root.pendingPassword + "\n");
// Held for as long as it takes to hand over, and no longer.
root.pendingPassword = "";
// Closing stdin is what lets the helper's read return; without it
// the join waits forever for a line that is already sent.
enterprise.stdinEnabled = false;
}
stdout: StdioCollector {
onStreamFinished: root.absorbDetails(enterprise.subject, this.text)
}
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: root.pendingPassword = ""
}
}
+105
View File
@@ -74,6 +74,12 @@ Singleton {
return count;
}
// printer name -> the helper's get-options shape. Cached because a pair of
// dropdowns asks for the same printer on every repaint, and each answer is
// a round trip to CUPS.
property var options: ({})
property var pendingOptions: []
function refresh(): void {
if (query.running)
return;
@@ -81,6 +87,48 @@ Singleton {
query.running = true;
}
// Paper size and two-sided, as the printer reports them. Returns the cached
// answer, or null while the first one is on its way -- and asks for it, so
// a dropdown that binds to this fills itself in.
function optionsFor(name: string): var {
if (name === "")
return null;
if (root.options[name] !== undefined)
return root.options[name];
root.requestOptions(name);
return null;
}
function requestOptions(name: string): void {
if (name === "" || root.pendingOptions.indexOf(name) >= 0)
return;
root.pendingOptions = root.pendingOptions.concat([name]);
root.drainOptions();
}
function refreshOptions(name: string): void { root.requestOptions(name); }
function drainOptions(): void {
if (optionsQuery.running || root.pendingOptions.length === 0)
return;
optionsQuery.subject = root.pendingOptions[0];
optionsQuery.command = [root.helperPath, "get-options", optionsQuery.subject];
optionsQuery.running = true;
}
// Reassigned rather than mutated: QML does not notice a property change
// made inside a var object.
function absorbOptions(parsed: var): void {
const name = String(parsed?.printer ?? "");
if (name === "")
return;
const next = Object.assign({}, root.options);
next[name] = parsed;
root.options = next;
if (String(parsed.error ?? "") !== "")
root.lastError = String(parsed.error);
}
function search(): void {
if (root.searching)
return;
@@ -96,6 +144,11 @@ Singleton {
root.jobs = Array.isArray(parsed.jobs) ? parsed.jobs : [];
root.service = parsed.service ?? ({});
root.lastError = String(parsed.error ?? "");
// set-option answers with the snapshot AND the printer it touched,
// re-read -- so the dropdown that made the change updates from the
// reply rather than from a second round trip.
if (parsed.options)
root.absorbOptions(parsed.options);
} catch (error) {
root.lastError = "Could not read the printing service's answer.";
console.warn("Printers: could not parse helper output:", error);
@@ -119,6 +172,23 @@ Singleton {
function cancel(jobId: int): void { root.run(["cancel", String(jobId)]); }
function testPage(name: string): void { root.run(["test-page", name]); }
// Holding keeps the job in the queue; cancelling throws it away. The
// difference matters because "stop this print" and "reprint fifty pages"
// are not meant to be the same button.
function hold(jobId: int): void { root.run(["hold", String(jobId)]); }
function release(jobId: int): void { root.run(["release", String(jobId)]); }
// One printer default, from the helper's closed vocabulary: media is
// Letter, A4 or Legal; sides is one-sided or one of the two two-sided
// bindings. Anything else the helper refuses -- there is no passthrough to
// lpadmin here.
function setOption(name: string, key: string, value: string): void {
root.run(["set-option", name, key, value]);
}
// Whether a job is held, from the state the helper reports.
function isHeld(job: var): bool { return String(job?.state ?? "") === "held"; }
// A queue name CUPS will accept, derived from what the printer calls itself.
function suggestedName(label: string): string {
const cleaned = String(label).replace(/[^A-Za-z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "");
@@ -143,6 +213,41 @@ Singleton {
}
}
// Kept out of `busy`: reading a printer's defaults changes nothing, so it
// must not disable the controls that do.
Process {
id: optionsQuery
property string subject: ""
stdout: StdioCollector {
onStreamFinished: {
try {
root.absorbOptions(JSON.parse(this.text));
} catch (error) {
root.lastError = "Could not read that printer's settings.";
console.warn("Printers: could not parse get-options output:", error);
}
}
}
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: {
root.pendingOptions = root.pendingOptions.filter(
name => name !== optionsQuery.subject);
optionsDrain.restart();
}
}
// One tick later, because `running` has not gone false inside onExited and
// the queue would stall on its own guard.
Timer {
id: optionsDrain
interval: 0
onTriggered: root.drainOptions()
}
Process {
id: discovery
stdout: StdioCollector {
@@ -75,9 +75,22 @@ Singleton {
{ label: "Help", detail: "The manual", page: "manual" },
{ label: "Timezone", detail: "Set the system timezone", page: "datetime" },
{ label: "Network time", detail: "Synchronize the clock with a time server", page: "datetime" },
{ label: "Wi-Fi", detail: "Managed by GNOME Settings", page: "connectivity" },
{ label: "Bluetooth", detail: "Managed by GNOME Settings", page: "connectivity" },
{ label: "Printers", detail: "Managed by GNOME Settings", page: "connectivity" },
// Connections. These said "Managed by GNOME Settings" for as long as
// that was true; it stopped being true when the page grew VPN, hotspot,
// proxy and per-connection details, and a search result that hands the
// user to another application for something this page now does is worse
// than no result at all. The Printers entry here was also a second copy
// of the one that routes to the Printers page.
{ label: "Wi-Fi", detail: "Join a network, see the one you are on, and share it", page: "connectivity" },
{ label: "Bluetooth", detail: "Pair and connect devices", page: "connectivity" },
{ label: "VPN", detail: "Turn a tunnel on, and see which one is up", page: "connectivity" },
{ label: "Import a VPN", detail: "Add a WireGuard or OpenVPN profile from a file", page: "connectivity" },
{ label: "Hotspot", detail: "Share this machine's connection over Wi-Fi", page: "connectivity" },
{ label: "Airplane mode", detail: "Turn every radio off at once", page: "connectivity" },
{ label: "Network proxy", detail: "Send traffic through a proxy, or a PAC file", page: "connectivity" },
{ label: "IP address", detail: "The address, gateway, DNS servers, and hardware address of a connection", page: "connectivity" },
{ label: "Forget a Wi-Fi network", detail: "Remove a saved network so it stops connecting on its own", page: "connectivity" },
{ label: "Enterprise Wi-Fi", detail: "Join a network that asks for an identity and a password", page: "connectivity" },
{ label: "Default applications", detail: "Browser, mail, files", page: "applications" },
{ label: "User account", detail: "Your name, picture, and password", page: "users" },
{ label: "Profile picture", detail: "The avatar shown on the lock screen and in the Control Center", page: "users" },