Krolyxon
Commit 0e2345d

Branches

Refs →

aurora.configquickshellauroraNet.qml

348 lines · 13.6 KB · QML

1pragma Singleton
2import QtQuick
3import Quickshell
4import Quickshell.Io
5
6// Wi-Fi backend: thin wrapper over nmcli (NetworkManager).
7Singleton {
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 Quickshell.execDetached(["sh", "-c", 'exec ${BROWSER:-xdg-open} "$1"', "sh",
100 portalUrl !== "" ? portalUrl : probeUrl])
101 }
102
103 // manual "sign in" (IPC): open the portal if there is one, else say why not
104 function signIn() {
105 if (portal && portalUrl !== "") openPortal()
106 else probe(true)
107 }
108
109 onActiveChanged: if (active) { askFor = ""; message = ""; rescan() }
110
111 // nmcli -t output: ':' separates fields, '\:' and '\\' are escapes
112 function parseTerse(line) {
113 var out = [], cur = ""
114 for (var i = 0; i < line.length; i++) {
115 var ch = line[i]
116 if (ch === "\\" && i + 1 < line.length) { cur += line[i + 1]; i++ }
117 else if (ch === ":") { out.push(cur); cur = "" }
118 else cur += ch
119 }
120 out.push(cur)
121 return out
122 }
123
124 // `nmcli device show` output (terse): one block per device, blank line between.
125 // Android USB tethering (and USB ethernet dongles) show up as type "ethernet".
126 function parseShow(text) {
127 var devs = [], cur = null, lines = text.split("\n")
128 for (var i = 0; i < lines.length; i++) {
129 if (lines[i] === "") continue
130 var f = parseTerse(lines[i]), key = f[0], val = f.slice(1).join(":")
131 if (key === "GENERAL.DEVICE") { cur = { dev: val, type: "", state: "", name: "", ip: "" }; devs.push(cur) }
132 else if (cur === null) continue
133 else if (key === "GENERAL.TYPE") cur.type = val
134 else if (key === "GENERAL.STATE") cur.state = val
135 else if (key === "GENERAL.CONNECTION") cur.name = val
136 else if (key.indexOf("IP4.ADDRESS") === 0 && cur.ip === "") cur.ip = val
137 }
138 var wired = []
139 for (var j = 0; j < devs.length; j++) {
140 var d = devs[j]
141 if (d.type === "wifi") wifiDevice = d.dev
142 var up = d.state.indexOf("connected") >= 0 && d.state.indexOf("disconnected") < 0
143 if (d.type === "ethernet" && up)
144 wired.push({
145 dev: d.dev, name: d.name !== "" ? d.name : d.dev, ip: d.ip,
146 usb: /^usb|^enx|u\d+(c\d+)?(i\d+)?$/.test(d.dev)
147 })
148 }
149 wiredDevs = wired
150 }
151
152 function build(list, saved) {
153 var out = []
154 for (var i = 0; i < list.length; i++) {
155 var n = list[i]
156 out.push({
157 ssid: n.ssid, signal: n.signal, security: n.security, inUse: n.inUse,
158 saved: saved.indexOf(n.ssid) >= 0
159 })
160 }
161 out.sort(function (a, b) {
162 if (a.inUse !== b.inUse) return a.inUse ? -1 : 1
163 return b.signal - a.signal
164 })
165 return out
166 }
167
168 function refresh() {
169 radioProc.running = true
170 devProc.running = true
171 savedProc.running = true
172 listProc.running = true
173 connProc.running = true
174 }
175
176 function rescan() {
177 scanning = true
178 rescanProc.running = true
179 refresh()
180 }
181
182 // ---- actions ------------------------------------------------------
183 function run(args) {
184 if (busy) return
185 busy = true
186 runProc.command = ["sh", "-c",
187 'out=$(nmcli "$@" 2>&1); rc=$?; printf "%s\\n%d" "$out" "$rc"', "sh"].concat(args)
188 runProc.running = true
189 }
190
191 function finish(text) {
192 busy = false
193 var lines = text.split("\n")
194 var rc = parseInt(lines.pop())
195 var out = lines.join(" ").trim()
196 if (rc !== 0) {
197 message = out
198 // a failed first-time connect (wrong password: rc 4) leaves a broken profile behind; drop it.
199 // Not on a timeout (rc 3): NM is still activating (slow campus DHCP) and deleting would abort it.
200 if (rc === 4 && lastWasNew && lastSsid !== "")
201 Quickshell.execDetached(["nmcli", "connection", "delete", "id", lastSsid])
202 } else {
203 message = ""
204 askFor = ""
205 checkNow() // NM checks on activation too; this just makes the portal pop up sooner
206 }
207 lastWasNew = false
208 refresh()
209 }
210
211 function connectTo(entry) {
212 if (entry.inUse) return
213 if (entry.saved || entry.security === "" || entry.security === "--") connect(entry.ssid, "")
214 else askFor = (askFor === entry.ssid) ? "" : entry.ssid
215 }
216
217 function connect(ssid, password) {
218 message = ""
219 lastSsid = ssid
220 var isSaved = savedNames.indexOf(ssid) >= 0
221 if (isSaved && !password) {
222 lastWasNew = false
223 run(["connection", "up", "id", ssid])
224 } else {
225 lastWasNew = !isSaved
226 var cmd = ["--wait", "60", "device", "wifi", "connect", ssid]
227 if (password) cmd = cmd.concat(["password", password])
228 run(cmd)
229 }
230 }
231
232 function disconnectDev(dev) { run(["device", "disconnect", dev]) }
233 function disconnect() { if (wifiDevice !== "") run(["device", "disconnect", wifiDevice]) }
234 function forget(ssid) { run(["connection", "delete", "id", ssid]) }
235 function setWifi(on) { run(["radio", "wifi", on ? "on" : "off"]) }
236
237 // ---- processes ----------------------------------------------------
238 // always on (the bar shows wifi state); quicker while the Net tab is open
239 Timer {
240 interval: root.active ? 10000 : 30000
241 running: true
242 repeat: true
243 triggeredOnStart: true
244 onTriggered: root.refresh()
245 }
246
247 // connectivity events; restarted if nmcli exits (NM restart)
248 Process {
249 id: monitorProc
250 running: true
251 command: ["nmcli", "monitor"]
252 stdout: SplitParser {
253 onRead: line => {
254 var m = /Connectivity is now '(\w+)'/.exec(line)
255 if (m) root.gotConnectivity(m[1])
256 }
257 }
258 onExited: monitorRestart.start()
259 }
260 Timer { id: monitorRestart; interval: 5000; onTriggered: monitorProc.running = true }
261
262 // NM only re-checks every 300s: poll while stuck behind a portal so "signed in" shows up quickly
263 Timer {
264 interval: 5000
265 running: root.portal
266 repeat: true
267 onTriggered: root.checkNow()
268 }
269
270 Process {
271 id: connProc
272 command: ["nmcli", "-t", "-f", "CONNECTIVITY", "general"]
273 stdout: StdioCollector { onStreamFinished: root.gotConnectivity(text.trim()) }
274 }
275
276 Process {
277 id: checkProc
278 command: ["nmcli", "networking", "connectivity", "check"]
279 stdout: StdioCollector { onStreamFinished: root.gotConnectivity(text.trim()) }
280 }
281
282 Process {
283 id: probeProc
284 command: ["curl", "-s", "-o", "/dev/null", "--max-time", "5", "-w", "%{http_code} %{redirect_url}", root.probeUrl]
285 stdout: StdioCollector { onStreamFinished: root.gotProbe(text) }
286 }
287
288 Process {
289 id: runProc
290 stdout: StdioCollector { onStreamFinished: root.finish(text) }
291 }
292
293 Process {
294 id: rescanProc
295 command: ["nmcli", "device", "wifi", "rescan"]
296 onExited: { root.scanning = false; listProc.running = true }
297 }
298
299 Process {
300 id: radioProc
301 command: ["nmcli", "radio", "wifi"]
302 stdout: StdioCollector { onStreamFinished: root.wifiEnabled = text.trim() === "enabled" }
303 }
304
305 Process {
306 id: devProc
307 command: ["nmcli", "-t", "-f", "GENERAL.DEVICE,GENERAL.TYPE,GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS", "device", "show"]
308 stdout: StdioCollector { onStreamFinished: root.parseShow(text) }
309 }
310
311 Process {
312 id: savedProc
313 command: ["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"]
314 stdout: StdioCollector {
315 onStreamFinished: {
316 var out = [], lines = text.split("\n")
317 for (var i = 0; i < lines.length; i++) {
318 var f = root.parseTerse(lines[i])
319 if (f.length >= 2 && f[1].indexOf("wireless") >= 0) out.push(f[0])
320 }
321 root.savedNames = out
322 }
323 }
324 }
325
326 // --rescan no: a plain `wifi list` starts a full scan when the last one is >30s old, i.e. on every
327 // poll. Constant scans make the card roam (5 GHz -> weak 2.4 GHz), which drops the DHCP lease
328 // behind captive portals. Scans only come from rescan() (Net tab opened / button) and NM itself.
329 Process {
330 id: listProc
331 command: ["nmcli", "-t", "-f", "IN-USE,SSID,SIGNAL,SECURITY", "device", "wifi", "list", "--rescan", "no"]
332 stdout: StdioCollector {
333 onStreamFinished: {
334 var best = {}, lines = text.split("\n")
335 for (var i = 0; i < lines.length; i++) {
336 var f = root.parseTerse(lines[i])
337 if (f.length < 4 || f[1] === "") continue
338 var e = { inUse: f[0] === "*", ssid: f[1], signal: parseInt(f[2]) || 0, security: f[3] }
339 var o = best[e.ssid]
340 if (!o || e.inUse || (!o.inUse && e.signal > o.signal)) best[e.ssid] = e
341 }
342 var out = []
343 for (var k in best) out.push(best[k])
344 root.raw = out
345 }
346 }
347 }
348}