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 = "" } }