import QtQuick import Quickshell.Io import "network.js" as Network // Service / native boundary for iwd WiFi management. // // iwd exposes a full D-Bus API (net.connman.iwd). We talk to it via // `busctl --json=short`, which emits structured JSON — so there is NO text/ANSI // parsing. Native objects (Process) stay here; all JSON shaping lives in // network.js. Two reads drive the model: // ObjectManager.GetManagedObjects -> devices / stations / networks / known // Station.GetOrderedNetworks -> [path, signal] strongest-first // // Writes are D-Bus method calls (Scan / Connect / Forget / Disconnect) and a // set-property (Powered) — except connecting to a NEW secured network, which // needs iwd to call back to an agent for the passphrase; for that one case we // use `iwctl --passphrase` (a registered agent), as a DISCRETE ARGV array. // // SECURITY: every external call uses a discrete argv array — never sh -c with // interpolation, because SSIDs may contain spaces / quotes / shell metachars. // The passphrase is a transient argument to the connect call only; it NEVER // enters stateJson / lastAction / error / any log line. Item { id: root // --- reactive state, fed by the read Processes --- property bool autoRefresh: true property string deviceName: "" property string devicePath: "" property bool wifiEnabled: true property bool scanning: false property bool actionPending: false property string lastError: "" property string actionError: "" // Parsed GetManagedObjects payload (device/station/networks/knownBySsid). property var managed: ({}) property var mergedModel: [] property var lastAction: ({}) property int scanRetriesLeft: 0 // StationDiagnostic.GetDiagnostics detail for the CONNECTED BSS only // (bssid/band/channel/rssi/rates/mode), fetched lazily on expand. {} = none. property var connectionDetail: ({}) // Emitted whenever the model is recomputed after an async read. The view // connects to this to re-pull stateJson(). signal refreshed // D-Bus constants. readonly property string svc: "net.connman.iwd" readonly property string ifaceStation: "net.connman.iwd.Station" readonly property string ifaceDevice: "net.connman.iwd.Device" readonly property string ifaceNetwork: "net.connman.iwd.Network" readonly property string ifaceKnown: "net.connman.iwd.KnownNetwork" readonly property string ifaceDiag: "net.connman.iwd.StationDiagnostic" function busctlCall(objectPath, iface, method) { return ["busctl", "--json=short", "call", svc, objectPath, iface, method]; } function activeRow() { for (var i = 0; i < mergedModel.length; i++) if (mergedModel[i].connected) { return mergedModel[i]; } return null; } function rowForId(id) { const name = String(id); for (var i = 0; i < mergedModel.length; i++) if (mergedModel[i].id === name || mergedModel[i].ssid === name) { return mergedModel[i]; } return null; } function role() { if (actionError.length > 0 || lastError.length > 0) return "error"; if (!wifiEnabled) return "ui.minor"; const active = activeRow(); if (active && active.state === "connecting") return "warning"; return active ? "ui.accent" : "ui.minor"; } // Build the view model from parsed managed objects + ordered list. Decorates // the pure network.js rows with label/percent/glyph/role/state view fields. function buildModel(ordered) { const rows = Network.networkRows(managed, ordered); const station = managed.station || {}; const connecting = station.state === "connecting"; const out = []; for (var i = 0; i < rows.length; i++) { const n = rows[i]; const isConnecting = connecting && station.connectedNetwork === n.path; out.push({ "id": n.id, "ssid": n.ssid, "security": n.security, "securityLabel": Network.securityLabel(n.security), "signal": n.signal, "signalDbm": n.signalDbm, "signalPercent": n.signal * 25, "signalRole": Network.signalRole(n.signal), "signalGlyph": Network.signalGlyph(n.signal), "known": n.known, "connected": n.connected, "needsPassphrase": n.needsPassphrase, "path": n.path, "state": n.connected ? "connected" : (isConnecting ? "connecting" : "available") }); } mergedModel = out; // Drop stale per-BSS detail if nothing is connected anymore. var stillConnected = false; for (var j = 0; j < out.length; j++) if (out[j].connected) stillConnected = true; if (!stillConnected) connectionDetail = ({}); refreshed(); } function stateObject() { const active = activeRow(); const compactSignal = wifiEnabled && active ? active.signal * 25 : 0; return { "scanning": scanning, "pending": actionPending, "error": actionError || lastError, "device": deviceName, "wifi": { "enabled": wifiEnabled }, "active": active, "connectionDetail": connectionDetail, "networks": mergedModel, "knownNetworks": mergedModel, "compact": { "label": "network", "role": role(), "signal": compactSignal }, // MUST stay a real object: shell.qml:networkWidgetState() assigns // state.shader.values / state.shader.history (throws on undefined). "shader": { "values": [], "history": {} }, "lastAction": lastAction }; } function stateJson() { return JSON.stringify(stateObject()); } // selfTest() — runs the pure network.js functions over small INLINE JSON // literals (the fixtures are not shipped to runtime). Returns {pass,fail, // cases}, same shape as Audio/Brightness selfTest. The literals mirror the // busctl --json=short reply envelope ({type,data:[value]}). function selfTest() { var cases = []; var pass = 0; var fail = 0; var check = function (label, got, want) { const ok = JSON.stringify(got) === JSON.stringify(want); if (ok) pass += 1; else fail += 1; cases.push({ "case": label, "ok": ok, "got": got, "want": want }); }; var devPath = "/net/connman/iwd/0/4"; var homePath = devPath + "/home_psk"; var fivegPath = devPath + "/fiveg_psk"; var cafePath = devPath + "/cafe_open"; // Build with bracket assignment (avoid ES6 computed keys for V4 safety). var objs = {}; objs["/net/connman/iwd/0"] = { "net.connman.iwd.Adapter": { "Powered": { "type": "b", "data": true } } }; objs[devPath] = { "net.connman.iwd.Device": { "Name": { "type": "s", "data": "wlan0" }, "Powered": { "type": "b", "data": true }, "Mode": { "type": "s", "data": "station" } }, "net.connman.iwd.Station": { "State": { "type": "s", "data": "connected" }, "Scanning": { "type": "b", "data": false }, "ConnectedNetwork": { "type": "o", "data": homePath } } }; objs[homePath] = { "net.connman.iwd.Network": { "Name": { "type": "s", "data": "home-wifi" }, "Type": { "type": "s", "data": "psk" }, "Connected": { "type": "b", "data": true }, "KnownNetwork": { "type": "o", "data": "/known/home" } } }; objs[fivegPath] = { "net.connman.iwd.Network": { "Name": { "type": "s", "data": "home-wifi-5g" }, "Type": { "type": "s", "data": "psk" }, "Connected": { "type": "b", "data": false } } }; objs[cafePath] = { "net.connman.iwd.Network": { "Name": { "type": "s", "data": "Cafe Guest WiFi" }, "Type": { "type": "s", "data": "open" }, "Connected": { "type": "b", "data": false } } }; var gmo = JSON.stringify({ "type": "a{oa{sa{sv}}}", "data": [objs] }); var gon = JSON.stringify({ "type": "a(on)", "data": [[[homePath, -6800], [fivegPath, -4900], [cafePath, -6500]]] }); var m = Network.parseManaged(gmo); check("parseManaged device", m.device.name, "wlan0"); check("parseManaged mode", m.device.mode, "station"); check("parseManaged powered", m.device.powered, true); check("parseManaged devicePath", m.devicePath, devPath); check("parseManaged state", m.station.state, "connected"); check("parseManaged home known", m.networks[homePath].known, true); check("parseManaged 5g known", m.networks[fivegPath].known, false); var ordered = Network.parseOrdered(gon); check("parseOrdered length", ordered.length, 3); check("parseOrdered first signal", ordered[0].signal, -6800); var rows = Network.networkRows(m, ordered); check("networkRows length", rows.length, 3); check("rows home ssid", rows[0].ssid, "home-wifi"); check("rows home signal", rows[0].signal, 2); check("rows home connected", rows[0].connected, true); check("rows home needsPass", rows[0].needsPassphrase, false); check("rows home path", rows[0].path, homePath); check("rows 5g signal", rows[1].signal, 4); check("rows 5g needsPass", rows[1].needsPassphrase, true); check("rows cafe ssid (spaces)", rows[2].ssid, "Cafe Guest WiFi"); check("rows cafe needsPass", rows[2].needsPassphrase, false); check("signalBars(-4900)", Network.signalBars(-4900), 4); check("signalBars(-6800)", Network.signalBars(-6800), 2); check("signalBars(NaN)", Network.signalBars("x"), 0); check("signalRole(0)", Network.signalRole(0), "ui.minor"); check("signalRole(2)", Network.signalRole(2), "ui.accent"); check("signalRole(4)", Network.signalRole(4), "content.accent"); check("securityLabel psk", Network.securityLabel("psk"), "wpa"); check("securityLabel open", Network.securityLabel("open"), "open"); check("needsPassphrase open", Network.needsPassphrase("open", false), false); check("needsPassphrase psk unknown", Network.needsPassphrase("psk", false), true); check("networkId empty non-empty", Network.networkId("").length > 0, true); check("signalGlyph(0) non-empty", Network.signalGlyph(0).length > 0, true); check("parseManaged(null) safe", Network.parseManaged(null).devicePath, ""); // connectionDetail (StationDiagnostic) — synthetic, sanitized BSSID. var diag = JSON.stringify({ "type": "a{sv}", "data": [ { "ConnectedBss": { "type": "s", "data": "00:aa:bb:cc:dd:01" }, "Frequency": { "type": "u", "data": 5220 }, "Channel": { "type": "q", "data": 44 }, "RSSI": { "type": "n", "data": -65 }, "RxMode": { "type": "s", "data": "802.11n" }, "TxBitrate": { "type": "u", "data": 1080 }, "RxBitrate": { "type": "u", "data": 1350 } } ] }); var det = Network.connectionDetail(diag); check("connectionDetail band", det.band, "5 GHz"); check("connectionDetail channel", det.channel, 44); check("connectionDetail rxRate", det.rxRate, "135 Mbps"); check("connectionDetail rssi", det.rssi, -65); check("connectionDetail disconnected null", Network.connectionDetail('{"type":"a{sv}","data":[{}]}'), null); check("dbmFromCenti", Network.dbmFromCenti(-6500), -65); check("successful action has no error", actionFailure(0), ""); check("failed action is sanitized", actionFailure(1), "action-failed"); return JSON.stringify({ "pass": pass, "fail": fail, "cases": cases }); } // --- read cadence ------------------------------------------------------ // refresh(): one call returns every iwd object + properties; its handler // then reads the ordered list (which needs the discovered device path). function refresh() { gmoProc.exec(["busctl", "--json=short", "call", svc, "/", "org.freedesktop.DBus.ObjectManager", "GetManagedObjects"]); } // --- actions ----------------------------------------------------------- function actionFailure(exitCode) { return Number(exitCode) === 0 ? "" : "action-failed"; } function executeAction(command) { actionError = ""; actionPending = true; actionProc.exec(command); } function unavailableAction() { actionPending = false; actionError = "action-unavailable"; } function scan() { if (!devicePath) { refresh(); return stateJson(); } scanning = true; lastError = ""; actionError = ""; scanRetriesLeft = 2; scanProc.exec(busctlCall(devicePath, ifaceStation, "Scan")); scanTimer.start(); return stateJson(); } // connect(ssid, passphrase) — in-process secured/open/known connect. NOT an // IPC handler. open/known go over D-Bus Network.Connect (no agent needed). // A NEW secured network needs the passphrase handed to iwd via an agent, so // it uses `iwctl --passphrase`. The passphrase is a transient argv only. function connect(ssid, passphrase) { const name = String(ssid); const pp = (passphrase === undefined || passphrase === null) ? "" : String(passphrase); const row = rowForId(name); lastError = ""; actionError = ""; lastAction = { "kind": "connect", "ssid": name }; if (pp.length > 0) { // New secured network: iwctl registers an agent and supplies the key. if (deviceName) executeAction(["iwctl", "--passphrase", pp, "station", deviceName, "connect", name]); else unavailableAction(); } else if (row && row.path) { executeAction(busctlCall(row.path, ifaceNetwork, "Connect")); } else { unavailableAction(); } return stateJson(); } // select(id) — IPC entry: connect a known/open network by id (= ssid). function select(id) { const name = String(id); lastAction = { "kind": "select", "id": name }; return connect(name, ""); } function forget(id) { const name = String(id); lastAction = { "kind": "forget", "id": name }; const knownPath = (managed.knownBySsid || {})[name]; if (knownPath) executeAction(busctlCall(knownPath, ifaceKnown, "Forget")); else unavailableAction(); return stateJson(); } function toggleWifi(enabled) { const on = !!enabled; lastAction = { "kind": "toggle-wifi", "enabled": on }; if (devicePath) { wifiEnabled = on; executeAction(["busctl", "set-property", svc, devicePath, ifaceDevice, "Powered", "b", on ? "true" : "false"]); } else { unavailableAction(); } return stateJson(); } function disconnect() { lastAction = { "kind": "disconnect" }; if (devicePath) executeAction(busctlCall(devicePath, ifaceStation, "Disconnect")); else unavailableAction(); return stateJson(); } // diagnostics() — lazy fetch of the connected BSS detail. The view calls // this when the connected row is expanded; diagProc.onStreamFinished folds // the result into connectionDetail and emits refreshed() so the drawer fills. function diagnostics() { if (devicePath && activeRow()) diagProc.exec(busctlCall(devicePath, ifaceDiag, "GetDiagnostics")); return stateJson(); } function checkConnectivity() { lastAction = { "kind": "check-connectivity" }; refresh(); return stateJson(); } Component.onCompleted: if (autoRefresh) refresh() // --- read Processes ---------------------------------------------------- Process { id: gmoProc running: false stdout: StdioCollector { waitForEnd: true onStreamFinished: { root.managed = Network.parseManaged(this.text); const m = root.managed; if (!m.device || !m.devicePath) { root.lastError = "no-wifi-device"; root.deviceName = ""; root.devicePath = ""; root.buildModel([]); return; } root.deviceName = m.device.name; root.devicePath = m.devicePath; root.wifiEnabled = m.device.powered; root.lastError = ""; if (m.device.powered) { gonProc.exec(root.busctlCall(m.devicePath, root.ifaceStation, "GetOrderedNetworks")); } else { root.buildModel([]); } } } } Process { id: gonProc running: false stdout: StdioCollector { waitForEnd: true onStreamFinished: { root.buildModel(Network.parseOrdered(this.text)); } } } // Lazy StationDiagnostic read (connected BSS only). On reply, shape via // network.js#connectionDetail and emit refreshed() so the open drawer fills. Process { id: diagProc running: false stdout: StdioCollector { waitForEnd: true onStreamFinished: { root.connectionDetail = Network.connectionDetail(this.text) || ({}); root.refreshed(); } } } // --- action Processes -------------------------------------------------- Process { id: scanProc running: false stderr: StdioCollector { id: scanErr waitForEnd: true } // "Operation already in progress" = scan already running, NOT a hard // error: keep scanning, let the retry timer re-read the list. function onExited(exitCode) { const err = String(scanErr.text || ""); if (exitCode !== 0 && err.indexOf("already in progress") < 0) root.lastError = "scan-failed"; } } // Shared action runner. Re-reads state when done so the model reflects the // effect. stderr is intentionally NOT surfaced verbatim (belt-and-suspenders: // a connect arg must never leak via an error string). Process { id: actionProc running: false function onExited(exitCode) { command = []; root.actionPending = false; root.actionError = root.actionFailure(exitCode); root.refresh(); root.refreshed(); } } // Delayed/polled re-read after an async scan (results lag ~1-4 s). Timer { id: scanTimer interval: 2000 repeat: true running: false onTriggered: { root.refresh(); root.scanRetriesLeft -= 1; if (root.scanRetriesLeft <= 0) { root.scanning = false; running = false; } } } }