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: ({}) // Every profile NetworkManager holds, in range or not. See the `saved` // verb: this is the list that is otherwise invisible until you are standing // next to the network you wanted to tidy up. property var savedConnections: [] property bool savedScanned: false 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 || passwordJoin.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 absorbSaved(text: string): void { try { const parsed = JSON.parse(text); root.savedConnections = Array.isArray(parsed.connections) ? parsed.connections : []; root.savedScanned = true; if (String(parsed.error ?? "") !== "") root.lastError = String(parsed.error); } catch (error) { root.lastError = "Could not read the saved networks."; } } 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 "saved": root.absorbSaved(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]); // The saved list is the one surface that shows profiles nobody is // standing next to, so a forget nobody re-reads leaves a row for a // profile that no longer exists. The timer waits for the mutation. root.refreshSavedSoon(); } 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"]); } // "yes", "no" or "auto". Three states rather than two, because "automatic" // is NetworkManager guessing from what the network said and "no" is a claim // — see the helper's set_metered. function setMetered(connection: string, mode: string): void { root.run("connection", connection, ["set-metered", connection, String(mode)]); } // ---- static addressing // // family is "4" or "6" — the helper's own spelling, so nothing has to // translate between two vocabularies on the way down. function setIpAuto(connection: string, family: string): void { root.run("connection", connection, ["set-ip", connection, String(family), "auto"]); } // Every field on every call, including the empty ones: the helper writes // the whole stack at once so that switching modes cannot leave half the old // configuration behind. `dns` is the comma-separated list as typed. function setIpManual(connection: string, family: string, address: string, gateway: string, dns: string): void { root.run("connection", connection, ["set-ip", connection, String(family), "manual", String(address), String(gateway), String(dns)]); } // ---- saved profiles function refreshSaved(): void { root.run("saved", "", ["saved"]); } // ---- 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 (passwordJoin.running) return; root.lastError = ""; root.pendingPassword = password; passwordJoin.subject = ssid; passwordJoin.command = String(caCert ?? "") !== "" ? [root.helperPath, "join-enterprise", ssid, profile, identity, caCert] : [root.helperPath, "join-enterprise", ssid, profile, identity]; passwordJoin.stdinEnabled = true; passwordJoin.running = true; } // ---- hidden Wi-Fi // // Same passphrase path as the enterprise join, for the same reason: a // passphrase in argv is published to every process on this machine through // /proc. An open hidden network still goes down this path and writes an // empty line, so the helper's read returns rather than waiting forever. function joinHidden(ssid: string, profile: string, security: string, password: string): void { if (passwordJoin.running) return; root.lastError = ""; root.pendingPassword = password; passwordJoin.subject = profile; passwordJoin.command = [root.helperPath, "join-hidden", ssid, profile, security]; passwordJoin.stdinEnabled = true; passwordJoin.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(); root.refreshSavedSoon(); 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(); } function refreshSavedSoon(): void { savedSoon.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(); } } Timer { id: savedSoon interval: 400 onTriggered: { if (mutation.running) savedSoon.restart(); else root.refreshSaved(); } } // 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: passwordJoin property string subject: "" onStarted: { passwordJoin.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. passwordJoin.stdinEnabled = false; } stdout: StdioCollector { onStreamFinished: root.absorbDetails(passwordJoin.subject, this.text) } stderr: StdioCollector { onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() } onExited: { root.pendingPassword = ""; // A join makes a profile, so the saved list is out of date the // moment this returns. root.refreshSavedSoon(); } } }