Krolyxon
Commit 8fda16f

Branches

Refs →

aurora.configquickshellauroraNet.qml

347 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 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}