| 1 | pragma Singleton |
| 2 | import QtQuick |
| 3 | import Quickshell |
| 4 | import Quickshell.Io |
| 5 | |
| 6 | // Wi-Fi backend: thin wrapper over nmcli (NetworkManager). |
| 7 | Singleton { |
| 8 | id: root |
| 9 | |
| 10 | property bool active: false // NetPage sets this while visible (faster polling + rescan) |
| 11 | property var wiredDevs: [] // [{dev, name, ip, usb}] connected ethernet-type devices (incl. USB tethering) |
| 12 | readonly property bool wired: wiredDevs.length > 0 |
| 13 | readonly property string wiredIcon: wiredDevs.length > 0 && wiredDevs[0].usb ? Theme.iPhone : Theme.iEthernet |
| 14 | property bool wifiEnabled: true |
| 15 | property string wifiDevice: "" |
| 16 | property var raw: [] // [{inUse, ssid, signal, security}] |
| 17 | property var savedNames: [] |
| 18 | property string askFor: "" // ssid currently asking for a password |
| 19 | property string message: "" |
| 20 | property bool busy: false |
| 21 | property bool scanning: false |
| 22 | |
| 23 | property string lastSsid: "" |
| 24 | property bool lastWasNew: false |
| 25 | |
| 26 | readonly property var networks: build(raw, savedNames) |
| 27 | readonly property int activeSignal: { |
| 28 | for (var i = 0; i < raw.length; i++) if (raw[i].inUse) return raw[i].signal |
| 29 | return 0 |
| 30 | } |
| 31 | readonly property string connected: { |
| 32 | for (var i = 0; i < raw.length; i++) if (raw[i].inUse) return raw[i].ssid |
| 33 | return "" |
| 34 | } |
| 35 | |
| 36 | // ---- captive portal ---------------------------------------------- |
| 37 | // NetworkManager's connectivity check (`nmcli monitor`) says *that* there is a portal; a curl probe |
| 38 | // finds *where* (the redirect). Opening the real URL matters: librewolf's HTTPS-only mode upgrades a |
| 39 | // plain http:// test page, so the portal never gets to intercept it. |
| 40 | readonly property string probeUrl: "http://connectivitycheck.gstatic.com/generate_204" |
| 41 | property string connectivity: "unknown" // none | portal | limited | full | unknown |
| 42 | property bool portalProbed: false // the probe got hijacked (also catches portals NM calls "limited") |
| 43 | property string portalUrl: "" |
| 44 | property string portalSeen: "" // connection we already opened/notified for (once per connection) |
| 45 | property bool probeManual: false // signIn() asked: report "already online" too |
| 46 | readonly property bool portal: connectivity === "portal" || portalProbed |
| 47 | readonly property string portalNet: connected !== "" ? connected : (wiredDevs.length > 0 ? wiredDevs[0].name : "") |
| 48 | |
| 49 | onPortalNetChanged: if (portalNet !== "") checkNow() |
| 50 | |
| 51 | function checkNow() { if (!checkProc.running) checkProc.running = true } |
| 52 | |
| 53 | function gotConnectivity(s) { |
| 54 | if (s === "") return |
| 55 | var changed = s !== connectivity |
| 56 | connectivity = s |
| 57 | if (s === "portal" || s === "limited") { |
| 58 | if (changed || (portalNet !== "" && portalSeen !== portalNet)) probe(false) |
| 59 | } else { |
| 60 | if (s === "full" && portalUrl !== "") |
| 61 | Quickshell.execDetached(["notify-send", "-a", "aurora", "-i", "network-wireless", "online", |
| 62 | (portalNet !== "" ? portalNet + ": " : "") + "signed in"]) |
| 63 | portalProbed = false |
| 64 | portalUrl = "" |
| 65 | portalSeen = "" // next portal (even on the same network) opens again |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | function probe(manual) { |
| 70 | if (manual) probeManual = true |
| 71 | if (!probeProc.running) probeProc.running = true |
| 72 | } |
| 73 | |
| 74 | function gotProbe(text) { |
| 75 | var m = /^(\d+) ?(.*)$/.exec(text.trim()), code = m ? m[1] : "000", url = m ? m[2] : "" |
| 76 | var manual = probeManual |
| 77 | probeManual = false |
| 78 | if (code === "204") { |
| 79 | portalProbed = false |
| 80 | if (connectivity !== "full") checkNow() |
| 81 | if (manual) Quickshell.execDetached(["notify-send", "-a", "aurora", "-i", "network-wireless", "already online", "no captive portal"]) |
| 82 | return |
| 83 | } |
| 84 | if (code === "000") { |
| 85 | if (manual) Quickshell.execDetached(["notify-send", "-a", "aurora", "-i", "network-error", "no connection", "couldn't reach the network"]) |
| 86 | return |
| 87 | } |
| 88 | portalProbed = true |
| 89 | portalUrl = url !== "" ? url : probeUrl // 200 without a redirect: let the portal hijack the test page |
| 90 | if (manual) { openPortal(); return } |
| 91 | if (portalSeen === portalNet) return |
| 92 | portalSeen = portalNet |
| 93 | if (Settings.portalAutoOpen) openPortal() |
| 94 | Quickshell.execDetached(["notify-send", "-a", "aurora", "-i", "network-wireless-acquiring", |
| 95 | "sign in to " + (portalNet !== "" ? portalNet : "network"), portalUrl]) |
| 96 | } |
| 97 | |
| 98 | function openPortal() { |
| 99 | Session.launch(["sh", "-c", 'exec ${BROWSER:-xdg-open} "$1"', "sh", portalUrl !== "" ? portalUrl : probeUrl]) |
| 100 | } |
| 101 | |
| 102 | // manual "sign in" (IPC): open the portal if there is one, else say why not |
| 103 | function signIn() { |
| 104 | if (portal && portalUrl !== "") openPortal() |
| 105 | else probe(true) |
| 106 | } |
| 107 | |
| 108 | onActiveChanged: if (active) { askFor = ""; message = ""; rescan() } |
| 109 | |
| 110 | // nmcli -t output: ':' separates fields, '\:' and '\\' are escapes |
| 111 | function parseTerse(line) { |
| 112 | var out = [], cur = "" |
| 113 | for (var i = 0; i < line.length; i++) { |
| 114 | var ch = line[i] |
| 115 | if (ch === "\\" && i + 1 < line.length) { cur += line[i + 1]; i++ } |
| 116 | else if (ch === ":") { out.push(cur); cur = "" } |
| 117 | else cur += ch |
| 118 | } |
| 119 | out.push(cur) |
| 120 | return out |
| 121 | } |
| 122 | |
| 123 | // `nmcli device show` output (terse): one block per device, blank line between. |
| 124 | // Android USB tethering (and USB ethernet dongles) show up as type "ethernet". |
| 125 | function parseShow(text) { |
| 126 | var devs = [], cur = null, lines = text.split("\n") |
| 127 | for (var i = 0; i < lines.length; i++) { |
| 128 | if (lines[i] === "") continue |
| 129 | var f = parseTerse(lines[i]), key = f[0], val = f.slice(1).join(":") |
| 130 | if (key === "GENERAL.DEVICE") { cur = { dev: val, type: "", state: "", name: "", ip: "" }; devs.push(cur) } |
| 131 | else if (cur === null) continue |
| 132 | else if (key === "GENERAL.TYPE") cur.type = val |
| 133 | else if (key === "GENERAL.STATE") cur.state = val |
| 134 | else if (key === "GENERAL.CONNECTION") cur.name = val |
| 135 | else if (key.indexOf("IP4.ADDRESS") === 0 && cur.ip === "") cur.ip = val |
| 136 | } |
| 137 | var wired = [] |
| 138 | for (var j = 0; j < devs.length; j++) { |
| 139 | var d = devs[j] |
| 140 | if (d.type === "wifi") wifiDevice = d.dev |
| 141 | var up = d.state.indexOf("connected") >= 0 && d.state.indexOf("disconnected") < 0 |
| 142 | if (d.type === "ethernet" && up) |
| 143 | wired.push({ |
| 144 | dev: d.dev, name: d.name !== "" ? d.name : d.dev, ip: d.ip, |
| 145 | usb: /^usb|^enx|u\d+(c\d+)?(i\d+)?$/.test(d.dev) |
| 146 | }) |
| 147 | } |
| 148 | wiredDevs = wired |
| 149 | } |
| 150 | |
| 151 | function build(list, saved) { |
| 152 | var out = [] |
| 153 | for (var i = 0; i < list.length; i++) { |
| 154 | var n = list[i] |
| 155 | out.push({ |
| 156 | ssid: n.ssid, signal: n.signal, security: n.security, inUse: n.inUse, |
| 157 | saved: saved.indexOf(n.ssid) >= 0 |
| 158 | }) |
| 159 | } |
| 160 | out.sort(function (a, b) { |
| 161 | if (a.inUse !== b.inUse) return a.inUse ? -1 : 1 |
| 162 | return b.signal - a.signal |
| 163 | }) |
| 164 | return out |
| 165 | } |
| 166 | |
| 167 | function refresh() { |
| 168 | radioProc.running = true |
| 169 | devProc.running = true |
| 170 | savedProc.running = true |
| 171 | listProc.running = true |
| 172 | connProc.running = true |
| 173 | } |
| 174 | |
| 175 | function rescan() { |
| 176 | scanning = true |
| 177 | rescanProc.running = true |
| 178 | refresh() |
| 179 | } |
| 180 | |
| 181 | // ---- actions ------------------------------------------------------ |
| 182 | function run(args) { |
| 183 | if (busy) return |
| 184 | busy = true |
| 185 | runProc.command = ["sh", "-c", |
| 186 | 'out=$(nmcli "$@" 2>&1); rc=$?; printf "%s\\n%d" "$out" "$rc"', "sh"].concat(args) |
| 187 | runProc.running = true |
| 188 | } |
| 189 | |
| 190 | function finish(text) { |
| 191 | busy = false |
| 192 | var lines = text.split("\n") |
| 193 | var rc = parseInt(lines.pop()) |
| 194 | var out = lines.join(" ").trim() |
| 195 | if (rc !== 0) { |
| 196 | message = out |
| 197 | // a failed first-time connect (wrong password: rc 4) leaves a broken profile behind; drop it. |
| 198 | // Not on a timeout (rc 3): NM is still activating (slow campus DHCP) and deleting would abort it. |
| 199 | if (rc === 4 && lastWasNew && lastSsid !== "") |
| 200 | Quickshell.execDetached(["nmcli", "connection", "delete", "id", lastSsid]) |
| 201 | } else { |
| 202 | message = "" |
| 203 | askFor = "" |
| 204 | checkNow() // NM checks on activation too; this just makes the portal pop up sooner |
| 205 | } |
| 206 | lastWasNew = false |
| 207 | refresh() |
| 208 | } |
| 209 | |
| 210 | function connectTo(entry) { |
| 211 | if (entry.inUse) return |
| 212 | if (entry.saved || entry.security === "" || entry.security === "--") connect(entry.ssid, "") |
| 213 | else askFor = (askFor === entry.ssid) ? "" : entry.ssid |
| 214 | } |
| 215 | |
| 216 | function connect(ssid, password) { |
| 217 | message = "" |
| 218 | lastSsid = ssid |
| 219 | var isSaved = savedNames.indexOf(ssid) >= 0 |
| 220 | if (isSaved && !password) { |
| 221 | lastWasNew = false |
| 222 | run(["connection", "up", "id", ssid]) |
| 223 | } else { |
| 224 | lastWasNew = !isSaved |
| 225 | var cmd = ["--wait", "60", "device", "wifi", "connect", ssid] |
| 226 | if (password) cmd = cmd.concat(["password", password]) |
| 227 | run(cmd) |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | function disconnectDev(dev) { run(["device", "disconnect", dev]) } |
| 232 | function disconnect() { if (wifiDevice !== "") run(["device", "disconnect", wifiDevice]) } |
| 233 | function forget(ssid) { run(["connection", "delete", "id", ssid]) } |
| 234 | function setWifi(on) { run(["radio", "wifi", on ? "on" : "off"]) } |
| 235 | |
| 236 | // ---- processes ---------------------------------------------------- |
| 237 | // always on (the bar shows wifi state); quicker while the Net tab is open |
| 238 | Timer { |
| 239 | interval: root.active ? 10000 : 30000 |
| 240 | running: true |
| 241 | repeat: true |
| 242 | triggeredOnStart: true |
| 243 | onTriggered: root.refresh() |
| 244 | } |
| 245 | |
| 246 | // connectivity events; restarted if nmcli exits (NM restart) |
| 247 | Process { |
| 248 | id: monitorProc |
| 249 | running: true |
| 250 | command: ["nmcli", "monitor"] |
| 251 | stdout: SplitParser { |
| 252 | onRead: line => { |
| 253 | var m = /Connectivity is now '(\w+)'/.exec(line) |
| 254 | if (m) root.gotConnectivity(m[1]) |
| 255 | } |
| 256 | } |
| 257 | onExited: monitorRestart.start() |
| 258 | } |
| 259 | Timer { id: monitorRestart; interval: 5000; onTriggered: monitorProc.running = true } |
| 260 | |
| 261 | // NM only re-checks every 300s: poll while stuck behind a portal so "signed in" shows up quickly |
| 262 | Timer { |
| 263 | interval: 5000 |
| 264 | running: root.portal |
| 265 | repeat: true |
| 266 | onTriggered: root.checkNow() |
| 267 | } |
| 268 | |
| 269 | Process { |
| 270 | id: connProc |
| 271 | command: ["nmcli", "-t", "-f", "CONNECTIVITY", "general"] |
| 272 | stdout: StdioCollector { onStreamFinished: root.gotConnectivity(text.trim()) } |
| 273 | } |
| 274 | |
| 275 | Process { |
| 276 | id: checkProc |
| 277 | command: ["nmcli", "networking", "connectivity", "check"] |
| 278 | stdout: StdioCollector { onStreamFinished: root.gotConnectivity(text.trim()) } |
| 279 | } |
| 280 | |
| 281 | Process { |
| 282 | id: probeProc |
| 283 | command: ["curl", "-s", "-o", "/dev/null", "--max-time", "5", "-w", "%{http_code} %{redirect_url}", root.probeUrl] |
| 284 | stdout: StdioCollector { onStreamFinished: root.gotProbe(text) } |
| 285 | } |
| 286 | |
| 287 | Process { |
| 288 | id: runProc |
| 289 | stdout: StdioCollector { onStreamFinished: root.finish(text) } |
| 290 | } |
| 291 | |
| 292 | Process { |
| 293 | id: rescanProc |
| 294 | command: ["nmcli", "device", "wifi", "rescan"] |
| 295 | onExited: { root.scanning = false; listProc.running = true } |
| 296 | } |
| 297 | |
| 298 | Process { |
| 299 | id: radioProc |
| 300 | command: ["nmcli", "radio", "wifi"] |
| 301 | stdout: StdioCollector { onStreamFinished: root.wifiEnabled = text.trim() === "enabled" } |
| 302 | } |
| 303 | |
| 304 | Process { |
| 305 | id: devProc |
| 306 | command: ["nmcli", "-t", "-f", "GENERAL.DEVICE,GENERAL.TYPE,GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS", "device", "show"] |
| 307 | stdout: StdioCollector { onStreamFinished: root.parseShow(text) } |
| 308 | } |
| 309 | |
| 310 | Process { |
| 311 | id: savedProc |
| 312 | command: ["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"] |
| 313 | stdout: StdioCollector { |
| 314 | onStreamFinished: { |
| 315 | var out = [], lines = text.split("\n") |
| 316 | for (var i = 0; i < lines.length; i++) { |
| 317 | var f = root.parseTerse(lines[i]) |
| 318 | if (f.length >= 2 && f[1].indexOf("wireless") >= 0) out.push(f[0]) |
| 319 | } |
| 320 | root.savedNames = out |
| 321 | } |
| 322 | } |
| 323 | } |
| 324 | |
| 325 | // --rescan no: a plain `wifi list` starts a full scan when the last one is >30s old, i.e. on every |
| 326 | // poll. Constant scans make the card roam (5 GHz -> weak 2.4 GHz), which drops the DHCP lease |
| 327 | // behind captive portals. Scans only come from rescan() (Net tab opened / button) and NM itself. |
| 328 | Process { |
| 329 | id: listProc |
| 330 | command: ["nmcli", "-t", "-f", "IN-USE,SSID,SIGNAL,SECURITY", "device", "wifi", "list", "--rescan", "no"] |
| 331 | stdout: StdioCollector { |
| 332 | onStreamFinished: { |
| 333 | var best = {}, lines = text.split("\n") |
| 334 | for (var i = 0; i < lines.length; i++) { |
| 335 | var f = root.parseTerse(lines[i]) |
| 336 | if (f.length < 4 || f[1] === "") continue |
| 337 | var e = { inUse: f[0] === "*", ssid: f[1], signal: parseInt(f[2]) || 0, security: f[3] } |
| 338 | var o = best[e.ssid] |
| 339 | if (!o || e.inUse || (!o.inUse && e.signal > o.signal)) best[e.ssid] = e |
| 340 | } |
| 341 | var out = [] |
| 342 | for (var k in best) out.push(best[k]) |
| 343 | root.raw = out |
| 344 | } |
| 345 | } |
| 346 | } |
| 347 | } |