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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Executable
+866
@@ -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:]))
|
||||
@@ -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 = ""
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user