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:
@@ -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