auroraf2d7765
aurora: captive portal detection, drop campus-login
+143 −112
8 files changed
- M .config/quickshell/aurora/Bar.qml +1 −1
- M .config/quickshell/aurora/Net.qml +116 −0
- M .config/quickshell/aurora/NetPage.qml +18 −2
- M .config/quickshell/aurora/SetShell.qml +3 −0
- M .config/quickshell/aurora/Settings.qml +3 −1
- M .config/quickshell/aurora/Theme.qml +1 −0
- M .config/quickshell/aurora/shell.qml +1 −0
- D .local/bin/campus-login +0 −108
| @@ -136,7 +136,7 @@ PanelWindow { | |||
| 136 | 136 | BarPill { | |
| 137 | 137 | icon: Net.wired && !(Net.wifiEnabled && Net.connected !== "") ? Net.wiredIcon | |
| 138 | 138 | : (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) | |
| 140 | 140 | onActivated: bar.togglePanel("net", bar.modelData) | |
| 141 | 141 | } | |
| 142 | 142 | ||
| @@ -33,6 +33,79 @@ Singleton { | |||
| 33 | 33 | return "" | |
| 34 | 34 | } | |
| 35 | 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 | + | ||
| 36 | 109 | onActiveChanged: if (active) { askFor = ""; message = ""; rescan() } | |
| 37 | 110 | ||
| 38 | 111 | // nmcli -t output: ':' separates fields, '\:' and '\\' are escapes | |
| @@ -97,6 +170,7 @@ Singleton { | |||
| 97 | 170 | devProc.running = true | |
| 98 | 171 | savedProc.running = true | |
| 99 | 172 | listProc.running = true | |
| 173 | + | connProc.running = true | |
| 100 | 174 | } | |
| 101 | 175 | ||
| 102 | 176 | function rescan() { | |
| @@ -127,6 +201,7 @@ Singleton { | |||
| 127 | 201 | } else { | |
| 128 | 202 | message = "" | |
| 129 | 203 | askFor = "" | |
| 204 | + | checkNow() // NM checks on activation too; this just makes the portal pop up sooner | |
| 130 | 205 | } | |
| 131 | 206 | lastWasNew = false | |
| 132 | 207 | refresh() | |
| @@ -168,6 +243,47 @@ Singleton { | |||
| 168 | 243 | onTriggered: root.refresh() | |
| 169 | 244 | } | |
| 170 | 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 | + | ||
| 171 | 287 | Process { | |
| 172 | 288 | id: runProc | |
| 173 | 289 | stdout: StdioCollector { onStreamFinished: root.finish(text) } | |
| @@ -22,9 +22,16 @@ ColumnLayout { | |||
| 22 | 22 | required property var modelData | |
| 23 | 23 | icon: modelData.usb ? Theme.iPhone : Theme.iEthernet | |
| 24 | 24 | 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 : "") | |
| 26 | 27 | trailing: modelData.usb ? "usb" : "" | |
| 27 | 28 | current: true | |
| 29 | + | IconBtn { | |
| 30 | + | visible: Net.portal && Net.portalNet === modelData.name | |
| 31 | + | icon: Theme.iLogin | |
| 32 | + | height: 26 | |
| 33 | + | onClicked: Net.openPortal() | |
| 34 | + | } | |
| 28 | 35 | IconBtn { | |
| 29 | 36 | icon: Theme.iClose | |
| 30 | 37 | height: 26 | |
| @@ -93,15 +100,24 @@ ColumnLayout { | |||
| 93 | 100 | spacing: 0 | |
| 94 | 101 | ||
| 95 | 102 | ListRow { | |
| 103 | + | id: row | |
| 96 | 104 | icon: Theme.wifiIcon(item.modelData.signal) | |
| 97 | 105 | 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" | |
| 99 | 109 | : (item.modelData.saved ? "saved" | |
| 100 | 110 | : (item.modelData.security === "" ? "open" : item.modelData.security)) | |
| 101 | 111 | trailing: item.modelData.signal + "%" | |
| 102 | 112 | current: item.modelData.inUse | |
| 103 | 113 | onClicked: Net.connectTo(item.modelData) | |
| 104 | 114 | ||
| 115 | + | IconBtn { | |
| 116 | + | visible: row.portal | |
| 117 | + | icon: Theme.iLogin | |
| 118 | + | height: 26 | |
| 119 | + | onClicked: Net.openPortal() | |
| 120 | + | } | |
| 105 | 121 | IconBtn { | |
| 106 | 122 | visible: item.modelData.inUse | |
| 107 | 123 | icon: Theme.iClose | |
| @@ -46,6 +46,9 @@ Flickable { | |||
| 46 | 46 | SectionLabel { text: "on-screen display" } | |
| 47 | 47 | Knob { label: "osd timeout"; unit: "ms"; from: 500; to: 4000; value: Settings.osdTimeout; onMoved: v => Settings.osdTimeout = Math.round(v / 100) * 100 } | |
| 48 | 48 | ||
| 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 | + | ||
| 49 | 52 | SectionLabel { text: "notifications" } | |
| 50 | 53 | Switch { label: "do not disturb"; hint: "hide popups and mute the sound (critical ones still show)"; checked: Notifs.dnd; onToggled: on => Notifs.dnd = on } | |
| 51 | 54 | Switch { label: "sound"; checked: Settings.notifSound; onToggled: on => Settings.notifSound = on } | |
| @@ -15,7 +15,7 @@ Singleton { | |||
| 15 | 15 | osdTimeout: 1500, desktopClock: true, desktopViz: true, vizBars: 64, | |
| 16 | 16 | dock: true, dockPinned: ["foot", "librewolf", "pcmanfm", "obsidian"], wallFolder: "dusky-dark", | |
| 17 | 17 | 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 | |
| 19 | 19 | }) | |
| 20 | 20 | ||
| 21 | 21 | property alias fontSize: a.fontSize | |
| @@ -38,6 +38,7 @@ Singleton { | |||
| 38 | 38 | property alias idleLock: a.idleLock | |
| 39 | 39 | property alias idleSuspend: a.idleSuspend | |
| 40 | 40 | property alias charTone: a.charTone | |
| 41 | + | property alias portalAutoOpen: a.portalAutoOpen | |
| 41 | 42 | ||
| 42 | 43 | function reset() { | |
| 43 | 44 | for (var k in defaults) a[k] = defaults[k] | |
| @@ -82,6 +83,7 @@ Singleton { | |||
| 82 | 83 | property int idleLock: 600 | |
| 83 | 84 | property int idleSuspend: 0 | |
| 84 | 85 | 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) | |
| 85 | 87 | } | |
| 86 | 88 | } | |
| 87 | 89 | // first run: create the file once the directory exists | |
| @@ -141,6 +141,7 @@ Singleton { | |||
| 141 | 141 | readonly property string iMusic: cp(0xF075A) | |
| 142 | 142 | readonly property string iTrash: cp(0xF01B4) | |
| 143 | 143 | readonly property string iCheck: cp(0xF012C) | |
| 144 | + | readonly property string iLogin: cp(0xF0342) | |
| 144 | 145 | readonly property string iMouse: cp(0xF037D) | |
| 145 | 146 | readonly property string iKeyboard: cp(0xF030C) | |
| 146 | 147 | readonly property string iPhone: cp(0xF011C) | |
| @@ -210,6 +210,7 @@ ShellRoot { | |||
| 210 | 210 | function rec(mode: string): void { root.close(); Rec.toggle(mode === "region" ? "region" : "screen") } // screen (default) | region | |
| 211 | 211 | function settings(section: string): void { root.settings(section) } // "" = toggle | |
| 212 | 212 | function caffeine(): void { Toggles.caffeine = !Toggles.caffeine } | |
| 213 | + | function portal(): void { Net.signIn() } // open the captive portal login page (or say we're online) | |
| 213 | 214 | function bright(dir: string): void { Bright.step(dir === "down" ? -1 : 1) } // up | down | |
| 214 | 215 | } | |
| 215 | 216 | } | |
| @@ -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 | |