Show captive portal status and sign-in action in network panel

This commit is contained in:
Tobi Lutke committed 2026-08-28 20:08:26 -05:00
1 parent 7d58bb9a62
commit 41a40ccc78
6 files changed
+438 -15

No files matched your search

@@ -0,0 +1,31 @@
pragma Singleton
import QtQuick
import Quickshell.Networking
QtObject {
property int backend: NetworkBackendType.NetworkManager
property bool wifiEnabled: true
property bool canCheckConnectivity: true
property bool connectivityCheckEnabled: true
property int connectivity: NetworkConnectivity.Full
property int checks: 0
function checkConnectivity() { checks++ }
property var devices: ({ values: [wifi] })
property QtObject wifi: QtObject {
property int type: DeviceType.Wifi
property string name: "test-wifi"
property bool connected: true
property bool scannerEnabled: false
property var networks: ({ values: [network] })
}
property QtObject network: QtObject {
property string name: "Guest Wi-Fi"
property bool connected: true
property bool known: true
property bool stateChanging: false
property real signalStrength: 0.8
property int security: WifiSecurityType.Open
signal connectionFailed(int reason)
}
}
@@ -0,0 +1 @@
singleton NetworkMock 1.0 NetworkMock.qml
@@ -0,0 +1,161 @@
import QtQuick
import Quickshell
import Quickshell.Networking
import qs.Commons
import "mocks"
import "network" as Network
ShellRoot {
id: test
property bool failed: false
function check(ok, message) {
if (!ok) {
failed = true
console.log("RESULT fail " + message)
}
}
// Not visible in the normal test run. The optional preview maps the real
// KeyboardPanel for a screenshot, without ever altering the host network.
Item {
Network.Panel {
id: panel
bar: QtObject {
property color foreground: Color.foreground
property color barForeground: Color.foreground
property color urgent: Color.urgent
property string fontFamily: Style.font.family
property string position: "top"
property int barSize: 24
property bool vertical: false
property bool foregroundAnimationEnabled: false
property var activePopout: null
function requestPopout(owner) { activePopout = owner }
function releasePopout(owner) { activePopout = null }
function registerClickTarget(target) {}
function unregisterClickTarget(target) {}
function hideTooltip(target) {}
function showTooltip(target, text) {}
}
}
}
Timer {
interval: 250
running: true
onTriggered: {
test.check(panel.kind === "wifi", "connected Wi-Fi fixture")
test.check(panel.connectivity === "full", "normal connectivity")
test.check(!panel.testButton.visible && !panel.testBarButton.active, "no false portal banner")
test.check(!panel.testPoll.running, "normal connectivity adds no polling")
test.check(NetworkMock.checks > 0, "checks at connection/startup")
var before = NetworkMock.checks
panel.checkConnectivity()
test.check(NetworkMock.checks === before + 1, "manual check delegates to NM")
NetworkMock.connectivity = NetworkConnectivity.Portal
Qt.callLater(portalChecks)
}
}
function portalChecks() {
check(panel.hasCaptivePortal && panel.restricted, "native portal activates restricted mode")
check(panel.testButton.visible, "portal button visible")
check(panel.testButton.text === "Open Captive Portal", "prominent action label")
check(panel.icon === "󰤩" && panel.testBarButton.active, "blocked bar icon and warning color")
check(panel.testMeta.text === "SIGN-IN REQUIRED", "status replaces cheerful connection phrase")
check(panel.testTitle.text === "Guest Wi-Fi", "connected SSID survives missing route details")
check(panel.testPoll.running && panel.testPoll.interval === 10000, "restricted recheck runs while closed")
var before = NetworkMock.checks
panel.testPoll.triggered()
check(NetworkMock.checks === before + 1, "background timer rechecks through NM")
panel.testKeys.textKey("r")
check(NetworkMock.checks === before + 2, "r requests fresh connectivity")
// Exercise the existing cursor model, not a separate test-only action.
panel.cursorActive = true
panel.focusSection = "header"
panel.testKeys.moveRequested(0, 1)
check(panel.focusSection === "portal", "down from header reaches portal")
panel.testKeys.moveRequested(0, 1)
check(panel.focusSection === "dns", "down from portal skips absent band")
panel.testKeys.moveRequested(0, -1)
check(panel.focusSection === "portal", "up from DNS reaches portal")
panel.bandAvailable = ["2.4", "5"]
panel.testKeys.moveRequested(0, 1)
check(panel.focusSection === "band", "down from portal reaches available band")
panel.testKeys.moveRequested(0, -1)
check(panel.focusSection === "portal", "up from band reaches portal")
panel.testKeys.activateRequested()
NetworkMock.connectivity = NetworkConnectivity.Full
Qt.callLater(recoveryChecks)
}
function recoveryChecks() {
check(!panel.hasCaptivePortal && !panel.restricted, "login recovery clears restriction")
check(!panel.testPoll.running, "recovery stops extra checks")
check(!panel.testButton.visible && !panel.testBarButton.active, "recovery hides button and warning color")
check(panel.focusSection === "header", "disappearing button leaves valid cursor")
check(panel.icon !== "󰤩", "signal icon returns")
// No browser launch when the portal is gone (runner asserts one launch).
panel.openCaptivePortal()
NetworkMock.connectivity = NetworkConnectivity.Limited
Qt.callLater(limitedChecks)
}
function limitedChecks() {
check(panel.restricted && !panel.hasCaptivePortal, "outage is not mislabelled as a portal")
check(!panel.testButton.visible && panel.testMeta.text === "LIMITED INTERNET ACCESS", "limited state has no login button")
NetworkMock.connectivity = NetworkConnectivity.Portal
NetworkMock.connectivityCheckEnabled = false
Qt.callLater(disabledChecks)
}
function disabledChecks() {
check(!panel.hasCaptivePortal && panel.connectivity === "unknown", "disabled checks ignore cached portal")
check(!panel.testPoll.running, "disabled checks stop polling")
var before = NetworkMock.checks
panel.checkConnectivity()
check(NetworkMock.checks === before, "does not enable or invoke disabled checks")
NetworkMock.connectivityCheckEnabled = true
NetworkMock.network.connected = false
NetworkMock.wifi.connected = false
Qt.callLater(disconnectedChecks)
}
function disconnectedChecks() {
check(panel.kind === "disconnected" && !panel.hasCaptivePortal, "disconnect clears stale portal")
check(!panel.testButton.visible && panel.icon === "󰤮", "disconnected icon not portal icon")
if (failed) { Qt.quit(); return }
console.log("RESULT pass")
var preview = Quickshell.env("NETWORK_TEST_PREVIEW")
if (preview === "portal" || preview === "full") {
NetworkMock.network.connected = true
NetworkMock.wifi.connected = true
NetworkMock.connectivity = preview === "portal" ? NetworkConnectivity.Portal : NetworkConnectivity.Full
panel.open()
previewCapture.start()
previewDone.start()
} else {
// Give the detached, stubbed browser command time to append its argv.
done.start()
}
}
// Optional fresh, panel-only captures. Rendering the card itself excludes
// the host desktop, and the network details above come only from fixtures.
// NETWORK_TEST_PREVIEW=portal (or full), NETWORK_TEST_SCREENSHOT=/tmp/new.png
Timer {
id: previewCapture
interval: 750
onTriggered: {
var path = Quickshell.env("NETWORK_TEST_SCREENSHOT")
if (!path) return
var card = panel.testKeys.parent.parent
card.grabToImage(function(result) {
test.check(result.saveToFile(path), "save fresh preview screenshot")
Qt.quit()
})
}
}
Timer { id: done; interval: 300; onTriggered: Qt.quit() }
Timer { id: previewDone; interval: 15000; onTriggered: Qt.quit() }
}
+95
View File
@@ -0,0 +1,95 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const network = requireFromRoot('shell/plugins/panels/network/Model.js')
const states = { Unknown: 0, None: 1, Portal: 2, Limited: 3, Full: 4 }
for (const kind of ['wifi', 'ethernet']) {
for (const [native, expected] of [
['Unknown', 'unknown'], ['None', 'none'], ['Portal', 'portal'],
['Limited', 'limited'], ['Full', 'full']
]) {
assertEqual(network.connectivityState(kind, states[native], states, true), expected,
`${kind} maps native ${native} connectivity without confusing an outage with a portal`)
}
assertEqual(network.connectivityState(kind, 99, states, true), 'unknown', `${kind} handles unknown connectivity`)
for (const native of Object.values(states)) {
assertEqual(network.connectivityState(kind, native, states, false), 'unknown', `${kind} ignores stale results with probing disabled (${native})`)
}
}
for (const native of Object.values(states)) {
assertEqual(network.connectivityState('disconnected', native, states, true), 'none', `disconnect clears stale connectivity (${native})`)
}
for (const state of ['portal', 'limited']) {
assertEqual(network.connectionIcon('wifi', 80, state), '󰤩', `${state} uses a blocked Wi-Fi icon`)
assertEqual(network.connectionIcon('ethernet', 80, state), '󰈂', `${state} uses a blocked Ethernet icon`)
assertEqual(network.connectionIcon('disconnected', 80, state), '󰤮', `${state} does not override the disconnected icon`)
}
for (const state of ['full', 'unknown', 'none', undefined]) {
for (const signal of [-1, 0, 20, 40, 60, 80, 100]) {
assertEqual(network.connectionIcon('wifi', signal, state), network.wifiIconFor(signal), `${state} preserves Wi-Fi strength ${signal}`)
}
assertEqual(network.connectionIcon('ethernet', -1, state), '󰈀', `${state} preserves the Ethernet icon`)
}
const url = new URL(network.captivePortalUrl)
assertEqual(url.protocol, 'http:', 'browser entry point uses plain HTTP so a portal can intercept it')
assertEqual(url.hostname, 'ping.archlinux.org', 'browser entry point is fixed rather than portal-supplied')
assertEqual(url.username + url.password, '', 'browser entry point contains no credentials')
JS
require_compositor "network captive-portal runtime test"
require_command quickshell
stage=$(mktemp -d)
trap 'rm -rf -- "$stage"' EXIT
fixture="$SHELL_TEST_DIR/fixtures/network-captive-portal"
mkdir -p "$stage/network" "$stage/bin" "$stage/home"
ln -s "$ROOT/shell/Ui" "$stage/Ui"
ln -s "$ROOT/shell/Commons" "$stage/Commons"
cp -r "$fixture/mocks" "$stage/mocks"
cp "$fixture/shell.qml" "$stage/shell.qml"
cp "$ROOT/shell/plugins/panels/network/Model.js" "$stage/network/Model.js"
node - "$ROOT" "$stage" <<'JS'
const fs = require('fs')
const [root, stage] = process.argv.slice(2)
let source = fs.readFileSync(`${root}/shell/plugins/panels/network/Panel.qml`, 'utf8')
// Keep installed enum values and actual UI bindings. Only replace the singleton
// and expose private IDs in the disposable copy, never in production code.
source = source.replace('import Quickshell.Networking', 'import Quickshell.Networking\nimport "../mocks"')
source = source.replace(/\bNetworking\./g, 'NetworkMock.')
source = source.replace(' id: root', ` id: root
property alias testButton: portalAction
property alias testKeys: keyCatcher
property alias testMeta: heroMeta
property alias testTitle: heroSsid
property alias testPoll: connectivityPoll
property alias testBarButton: button`)
fs.writeFileSync(`${stage}/network/Panel.qml`, source)
JS
printf '#!/bin/bash\nexit 0\n' > "$stage/bin/noop"
chmod +x "$stage/bin/noop"
for command in omarchy-dns omarchy-network-band; do
ln -s noop "$stage/bin/$command"
done
# Preview uses only synthetic details, never the host's SSID or addresses.
# Normal assertions keep the details empty to exercise missing-route handling.
printf '#!/bin/bash\nif [[ -n ${NETWORK_TEST_PREVIEW:-} ]]; then\n printf "type\\twifi\\niface\\ttest-wifi\\nssid\\tGuest Wi-Fi\\nip\\t192.0.2.10\\ngateway\\t192.0.2.1\\n"\nfi\n' > "$stage/bin/omarchy-network-status"
chmod +x "$stage/bin/omarchy-network-status"
printf '#!/bin/bash\nprintf "%%s\\n" "$@" >> "$NETWORK_TEST_BROWSER_LOG"\n' > "$stage/bin/omarchy-launch-browser"
chmod +x "$stage/bin/omarchy-launch-browser"
# All networking and external actions are mocked; the real connection and
# browser are never touched, and the fixture writes only to its scratch HOME.
output=$(HOME="$stage/home" OMARCHY_PATH="$ROOT" PATH="$stage/bin:$PATH" \
NETWORK_TEST_BROWSER_LOG="$stage/browser.log" \
timeout 30 quickshell -p "$stage" --no-color 2>&1) || fail "network portal fixture exits cleanly" "$output"
[[ $output == *"RESULT pass"* ]] || fail "network portal runtime assertions pass" "$output"
if rg -q 'RESULT fail|ReferenceError|TypeError|Error:|Unable to assign|Binding loop' <<< "$output"; then
fail "network portal fixture has no QML errors" "$output"
fi
[[ -f $stage/browser.log ]] || fail "portal action launches the browser"
[[ $(<"$stage/browser.log") == "http://ping.archlinux.org/nm-check.txt" ]] || fail "portal opens exactly one fixed HTTP URL"
pass "network portal, recovery, disabled checks, outage, disconnect, keyboard navigation, and browser argv work in QML"