Krolyxon
Commit f2d7765

Branches

Refs →

auroraf2d7765

aurora: captive portal detection, drop campus-login

Author
krolyxon
Date
2026-10-24 16:55 +0530
Parent
2d6af20
Commit
f2d776589f6fd93d013ecfb17e5d9299bfb3166d

+143 −112

8 files changed

  1. M .config/quickshell/aurora/Bar.qml +1 −1
  2. M .config/quickshell/aurora/Net.qml +116 −0
  3. M .config/quickshell/aurora/NetPage.qml +18 −2
  4. M .config/quickshell/aurora/SetShell.qml +3 −0
  5. M .config/quickshell/aurora/Settings.qml +3 −1
  6. M .config/quickshell/aurora/Theme.qml +1 −0
  7. M .config/quickshell/aurora/shell.qml +1 −0
  8. D .local/bin/campus-login +0 −108
M .config/quickshell/aurora/Bar.qml View
@@ -136,7 +136,7 @@ PanelWindow {
136136 BarPill {
137137 icon: Net.wired && !(Net.wifiEnabled && Net.connected !== "") ? Net.wiredIcon
138138 : (Net.wifiEnabled ? Theme.wifiIcon(Net.connected !== "" ? Net.activeSignal : 0) : Theme.iWifiOff)
139− tint: (Net.wired || (Net.wifiEnabled && Net.connected !== "")) ? Theme.fg : Theme.dim
139+ tint: Net.portal ? Theme.err : ((Net.wired || (Net.wifiEnabled && Net.connected !== "")) ? Theme.fg : Theme.dim)
140140 onActivated: bar.togglePanel("net", bar.modelData)
141141 }
142142
M .config/quickshell/aurora/Net.qml View
@@ -33,6 +33,79 @@ Singleton {
3333 return ""
3434 }
3535
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+
36109 onActiveChanged: if (active) { askFor = ""; message = ""; rescan() }
37110
38111 // nmcli -t output: ':' separates fields, '\:' and '\\' are escapes
@@ -97,6 +170,7 @@ Singleton {
97170 devProc.running = true
98171 savedProc.running = true
99172 listProc.running = true
173+ connProc.running = true
100174 }
101175
102176 function rescan() {
@@ -127,6 +201,7 @@ Singleton {
127201 } else {
128202 message = ""
129203 askFor = ""
204+ checkNow() // NM checks on activation too; this just makes the portal pop up sooner
130205 }
131206 lastWasNew = false
132207 refresh()
@@ -168,6 +243,47 @@ Singleton {
168243 onTriggered: root.refresh()
169244 }
170245
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+
171287 Process {
172288 id: runProc
173289 stdout: StdioCollector { onStreamFinished: root.finish(text) }
M .config/quickshell/aurora/NetPage.qml View
@@ -22,9 +22,16 @@ ColumnLayout {
2222 required property var modelData
2323 icon: modelData.usb ? Theme.iPhone : Theme.iEthernet
2424 title: modelData.name
25− subtitle: modelData.dev + (modelData.ip !== "" ? " · " + modelData.ip : "")
25+ subtitle: Net.portal && Net.portalNet === modelData.name ? "sign in required"
26+ : modelData.dev + (modelData.ip !== "" ? " · " + modelData.ip : "")
2627 trailing: modelData.usb ? "usb" : ""
2728 current: true
29+ IconBtn {
30+ visible: Net.portal && Net.portalNet === modelData.name
31+ icon: Theme.iLogin
32+ height: 26
33+ onClicked: Net.openPortal()
34+ }
2835 IconBtn {
2936 icon: Theme.iClose
3037 height: 26
@@ -93,15 +100,24 @@ ColumnLayout {
93100 spacing: 0
94101
95102 ListRow {
103+ id: row
96104 icon: Theme.wifiIcon(item.modelData.signal)
97105 title: item.modelData.ssid
98− subtitle: item.modelData.inUse ? "connected"
106+ readonly property bool portal: item.modelData.inUse && Net.portal && Net.portalNet === item.modelData.ssid
107+ subtitle: portal ? "sign in required"
108+ : item.modelData.inUse ? "connected"
99109 : (item.modelData.saved ? "saved"
100110 : (item.modelData.security === "" ? "open" : item.modelData.security))
101111 trailing: item.modelData.signal + "%"
102112 current: item.modelData.inUse
103113 onClicked: Net.connectTo(item.modelData)
104114
115+ IconBtn {
116+ visible: row.portal
117+ icon: Theme.iLogin
118+ height: 26
119+ onClicked: Net.openPortal()
120+ }
105121 IconBtn {
106122 visible: item.modelData.inUse
107123 icon: Theme.iClose
M .config/quickshell/aurora/SetShell.qml View
@@ -46,6 +46,9 @@ Flickable {
4646 SectionLabel { text: "on-screen display" }
4747 Knob { label: "osd timeout"; unit: "ms"; from: 500; to: 4000; value: Settings.osdTimeout; onMoved: v => Settings.osdTimeout = Math.round(v / 100) * 100 }
4848
49+ SectionLabel { text: "network" }
50+ Switch { label: "captive portals"; hint: "open the login page in the browser when a network needs one"; checked: Settings.portalAutoOpen; onToggled: on => Settings.portalAutoOpen = on }
51+
4952 SectionLabel { text: "notifications" }
5053 Switch { label: "do not disturb"; hint: "hide popups and mute the sound (critical ones still show)"; checked: Notifs.dnd; onToggled: on => Notifs.dnd = on }
5154 Switch { label: "sound"; checked: Settings.notifSound; onToggled: on => Settings.notifSound = on }
M .config/quickshell/aurora/Settings.qml View
@@ -15,7 +15,7 @@ Singleton {
1515 osdTimeout: 1500, desktopClock: true, desktopViz: true, vizBars: 64,
1616 dock: true, dockPinned: ["foot", "librewolf", "pcmanfm", "obsidian"], wallFolder: "dusky-dark",
1717 notifSound: true, notifVolume: 0.7,
18− idleDim: 150, idleOff: 330, idleLock: 600, idleSuspend: 0, charTone: 0
18+ idleDim: 150, idleOff: 330, idleLock: 600, idleSuspend: 0, charTone: 0, portalAutoOpen: true
1919 })
2020
2121 property alias fontSize: a.fontSize
@@ -38,6 +38,7 @@ Singleton {
3838 property alias idleLock: a.idleLock
3939 property alias idleSuspend: a.idleSuspend
4040 property alias charTone: a.charTone
41+ property alias portalAutoOpen: a.portalAutoOpen
4142
4243 function reset() {
4344 for (var k in defaults) a[k] = defaults[k]
@@ -82,6 +83,7 @@ Singleton {
8283 property int idleLock: 600
8384 property int idleSuspend: 0
8485 property int charTone: 0 // glyph picker emoji skin tone, 0 = none, 1-5 light..dark
86+ property bool portalAutoOpen: true // open captive portal login pages in $BROWSER (Net.qml)
8587 }
8688 }
8789 // first run: create the file once the directory exists
M .config/quickshell/aurora/Theme.qml View
@@ -141,6 +141,7 @@ Singleton {
141141 readonly property string iMusic: cp(0xF075A)
142142 readonly property string iTrash: cp(0xF01B4)
143143 readonly property string iCheck: cp(0xF012C)
144+ readonly property string iLogin: cp(0xF0342)
144145 readonly property string iMouse: cp(0xF037D)
145146 readonly property string iKeyboard: cp(0xF030C)
146147 readonly property string iPhone: cp(0xF011C)
M .config/quickshell/aurora/shell.qml View
@@ -210,6 +210,7 @@ ShellRoot {
210210 function rec(mode: string): void { root.close(); Rec.toggle(mode === "region" ? "region" : "screen") } // screen (default) | region
211211 function settings(section: string): void { root.settings(section) } // "" = toggle
212212 function caffeine(): void { Toggles.caffeine = !Toggles.caffeine }
213+ function portal(): void { Net.signIn() } // open the captive portal login page (or say we're online)
213214 function bright(dir: string): void { Bright.step(dir === "down" ? -1 : 1) } // up | down
214215 }
215216 }
D .local/bin/campus-login View
@@ -1,108 +0,0 @@
1−#!/usr/bin/env bash
2−# campus-login: detect a captive portal and open it in your browser,
3−# reporting every step through the notification daemon.
4−#
5−# Usage: campus-login [-n|--no-wait] [-h|--help]
6−# -n, --no-wait don't wait around to confirm you're online afterwards
7−#
8−# Env overrides:
9−# PROBE_URL plain-HTTP URL that returns 204 when there is no portal
10−# WAIT_TIMEOUT seconds to wait for connectivity after opening the portal
11−
12−set -u
13−
14−PROBE_URL=${PROBE_URL:-http://connectivitycheck.gstatic.com/generate_204}
15−WAIT_TIMEOUT=${WAIT_TIMEOUT:-180}
16−WAIT=1
17−
18−case "${1:-}" in
19− -n|--no-wait) WAIT=0 ;;
20− -h|--help) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
21−esac
22−
23−# --- notifications ----------------------------------------------------------
24−# Every notification shares one "synchronous" tag so they replace each other
25−# instead of stacking (works with dunst, mako, swaync).
26−notify() { # notify <urgency> <icon> <title> <body>
27− local urgency=$1 icon=$2 title=$3 body=$4
28− echo "$title: $body"
29− command -v notify-send >/dev/null 2>&1 || return 0
30− notify-send -a campus-login -u "$urgency" -i "$icon" \
31− -h string:x-canonical-private-synchronous:campus-login \
32− -h string:x-dunst-stack-tag:campus-login \
33− "$title" "$body"
34−}
35−
36−# --- probe ------------------------------------------------------------------
37−# Prints "<http_code> <redirect_url>". curl resolves relative redirects for us,
38−# so no more grepping/sed-ing the Location header.
39−probe() {
40− curl -s -o /dev/null --max-time 5 \
41− -w '%{http_code} %{redirect_url}' "$PROBE_URL" 2>/dev/null
42−}
43−
44−read -r CODE URL < <(probe)
45−CODE=${CODE:-000}
46−
47−case "$CODE" in
48− 000)
49− notify critical network-error "No connection" \
50− "Couldn't reach the network. Are you connected to the Wi-Fi?"
51− exit 2
52− ;;
53− 204)
54− notify low network-wireless "Already online" \
55− "No captive portal detected."
56− exit 0
57− ;;
58−esac
59−
60−if [[ -n "${URL:-}" ]]; then
61− notify normal network-wireless-acquiring "Captive portal detected" "$URL"
62−else
63− # Some portals hijack the request and answer 200 without redirecting.
64− # Opening a plain-HTTP page lets the portal intercept it in the browser.
65− URL=$PROBE_URL
66− notify normal network-wireless-acquiring "Captive portal likely" \
67− "Got HTTP $CODE with no redirect. Opening a test page to trigger it."
68−fi
69−
70−# --- pick a browser ---------------------------------------------------------
71−# $BROWSER is often unset when launched from a menu (aurora-run, rofi), so fall back.
72−launch=()
73−if [[ -n "${BROWSER:-}" ]]; then
74− read -ra launch <<< "$BROWSER"
75−fi
76−if ! (( ${#launch[@]} )) || ! command -v "${launch[0]}" >/dev/null 2>&1; then
77− launch=()
78− for b in xdg-open firefox chromium google-chrome-stable brave; do
79− if command -v "$b" >/dev/null 2>&1; then launch=("$b"); break; fi
80− done
81−fi
82−
83−if ! (( ${#launch[@]} )); then
84− notify critical dialog-error "No browser found" \
85− "Set \$BROWSER or install one. Portal URL: $URL"
86− exit 3
87−fi
88−
89−# Detach so the browser outlives this script (and the launcher).
90−setsid -f "${launch[@]}" "$URL" >/dev/null 2>&1
91−notify normal web-browser "Opened portal" "${launch[0]} -> $URL"
92−
93−# --- wait for confirmation --------------------------------------------------
94−(( WAIT )) || exit 0
95−
96−end=$((SECONDS + WAIT_TIMEOUT))
97−while (( SECONDS < end )); do
98− sleep 3
99− read -r CODE _ < <(probe)
100− if [[ "${CODE:-000}" == 204 ]]; then
101− notify normal network-wireless "Connected" "You're online."
102− exit 0
103− fi
104−done
105−
106−notify normal network-wireless-offline "Still not online" \
107− "No connectivity after ${WAIT_TIMEOUT}s. The login may not have completed."
108−exit 4