482 lines
18 KiB
QML
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();
|
|
}
|
|
}
|
|
}
|