Files

482 lines
18 KiB
QML

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();
}
}
}