From 41a40ccc78b60c9698a0e45dc358f0e4b5ffd5a3 Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Fri, 28 Aug 2026 20:03:19 -0500 Subject: [PATCH] Show captive portal status and sign-in action in network panel --- shell/plugins/panels/network/Model.js | 24 ++- shell/plugins/panels/network/Panel.qml | 141 +++++++++++++-- .../mocks/NetworkMock.qml | 31 ++++ .../network-captive-portal/mocks/qmldir | 1 + .../fixtures/network-captive-portal/shell.qml | 161 ++++++++++++++++++ test/shell.d/network-captive-portal-test.sh | 95 +++++++++++ 6 files changed, 438 insertions(+), 15 deletions(-) create mode 100644 test/shell.d/fixtures/network-captive-portal/mocks/NetworkMock.qml create mode 100644 test/shell.d/fixtures/network-captive-portal/mocks/qmldir create mode 100644 test/shell.d/fixtures/network-captive-portal/shell.qml create mode 100755 test/shell.d/network-captive-portal-test.sh diff --git a/shell/plugins/panels/network/Model.js b/shell/plugins/panels/network/Model.js index b4c84c68..5ed2c0d6 100644 --- a/shell/plugins/panels/network/Model.js +++ b/shell/plugins/panels/network/Model.js @@ -14,9 +14,25 @@ function wifiIconFor(strength) { return icons[index] } -function connectionIcon(kind, signalStrength) { - if (kind === "wifi") return wifiIconFor(signalStrength) - if (kind === "ethernet") return "󰈀" +// A known plain-HTTP endpoint lets the network redirect the browser to its +// login page. Never execute or automatically open an untrusted Location header. +var captivePortalUrl = "http://ping.archlinux.org/nm-check.txt" + +function connectivityState(kind, connectivity, states, checksEnabled) { + if (kind === "disconnected") return "none" + // Ignore stale cached results when the operator has disabled probing. + if (!checksEnabled) return "unknown" + if (connectivity === states.Portal) return "portal" + if (connectivity === states.Limited) return "limited" + if (connectivity === states.Full) return "full" + if (connectivity === states.None) return "none" + return "unknown" +} + +function connectionIcon(kind, signalStrength, connectivity) { + var restricted = connectivity === "portal" || connectivity === "limited" + if (kind === "wifi") return restricted ? "󰤩" : wifiIconFor(signalStrength) + if (kind === "ethernet") return restricted ? "󰈂" : "󰈀" return "󰤮" } @@ -352,6 +368,8 @@ if (typeof module !== "undefined") { parseNetworkStatus: parseNetworkStatus, wifiIconFor: wifiIconFor, connectionIcon: connectionIcon, + connectivityState: connectivityState, + captivePortalUrl: captivePortalUrl, formatHeaderSpeed: formatHeaderSpeed, formatHeaderFreq: formatHeaderFreq, headerDetail: headerDetail, diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index d1e41149..97729dcc 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -119,9 +119,9 @@ Panel { property bool cursorActive: false // Keyboard focus zone for the panel. j/k crosses row boundaries: - // header actions ⇄ band ⇄ DNS row ⇄ Wi-Fi networks. h/l move + // header actions ⇄ portal ⇄ band ⇄ DNS row ⇄ Wi-Fi networks. h/l move // within header actions, band pills, or DNS providers. - property string focusSection: "dns" // "header" | "band" | "dns" | "wifi" + property string focusSection: "dns" // "header" | "portal" | "band" | "dns" | "wifi" property int headerIndex: 0 readonly property bool canDisconnect: !!connectedWifiNetwork readonly property bool headerHasDisconnect: false @@ -220,6 +220,8 @@ Panel { // network target; both cards are their own plugins now. function showQr() { root.summonWifiQr(true) } function speedTest() { root.summonSpeedTest() } + function openCaptivePortal() { root.openCaptivePortal() } + function checkConnectivity() { root.checkConnectivity() } } function activateHeader() { @@ -322,11 +324,11 @@ Panel { refresh(true) selectedIndex = wifiNetworks.length > 0 ? 0 : -1 wifiActionFocused = false - focusSection = wifiNetworks.length > 0 ? "wifi" : "dns" + focusSection = hasCaptivePortal ? "portal" : (wifiNetworks.length > 0 ? "wifi" : "dns") var idx = dnsProviders.indexOf(dnsProvider) dnsIndex = idx >= 0 ? idx : 0 syncBandIndex() - cursorActive = false + cursorActive = hasCaptivePortal } else { // Drop a restart armed by this open: without it a close/reopen inside // the 100ms window reuses the running timer and re-enables the scanner @@ -450,7 +452,59 @@ Panel { Quickshell.execDetached(["bash", "-c", "printf %s " + Util.shellQuote(value) + " | wl-copy"]) } - readonly property string icon: Model.connectionIcon(kind, signalStrength) + // NetworkManager performs the HTTP probe (including unexpected page bodies, + // not just redirects). Consume its native notifications rather than running + // a second curl loop or mistaking an ordinary timeout for a captive portal. + readonly property bool connectivityChecksEnabled: networkManagerAvailable + && Networking.canCheckConnectivity && Networking.connectivityCheckEnabled + readonly property string connectivity: Model.connectivityState(kind, Networking.connectivity, { + Portal: NetworkConnectivity.Portal, Limited: NetworkConnectivity.Limited, + Full: NetworkConnectivity.Full, None: NetworkConnectivity.None + }, connectivityChecksEnabled) + readonly property bool hasCaptivePortal: connectivity === "portal" + readonly property bool restricted: hasCaptivePortal || connectivity === "limited" + readonly property string icon: Model.connectionIcon(kind, signalStrength, connectivity) + readonly property string connectionKey: kind === "wifi" && wifiDevice && connectedWifiNetwork + ? kind + ":" + wifiDevice.name + ":" + connectedWifiNetwork.name + : (kind === "ethernet" && wiredDevice ? kind + ":" + wiredDevice.name : "") + + onConnectionKeyChanged: Qt.callLater(checkConnectivity) + onConnectivityChecksEnabledChanged: Qt.callLater(checkConnectivity) + onHasCaptivePortalChanged: { + if (hasCaptivePortal && opened && passwordSsid === "") { + focusSection = "portal" + cursorActive = true + } else if (!hasCaptivePortal && focusSection === "portal") { + focusSection = headerActionCount > 0 ? "header" : "dns" + headerIndex = 0 + } + } + onRestrictedChanged: { + connectionPhraseSwap.stop() + heroMeta.opacity = 1.0 + } + + function checkConnectivity() { + if (connectivityChecksEnabled && kind !== "disconnected") Networking.checkConnectivity() + } + + function openCaptivePortal() { + if (!hasCaptivePortal) return + // Explicit user action only. argv (not a shell string), and a fixed HTTP + // URL: let the browser handle the redirect without trusting portal input. + Quickshell.execDetached(["omarchy-launch-browser", Model.captivePortalUrl]) + close() + } + + // Keep checking while login is needed, even with the panel closed in favour + // of the browser. Normal connected operation relies on NM's own schedule. + Timer { + id: connectivityPoll + interval: 10000 + repeat: true + running: root.restricted && root.connectivityChecksEnabled + onTriggered: root.checkConnectivity() + } // The share card is its own panel plugin (omarchy.wifiqr) so a replacement // design can take it over; summon() routes to whichever implementation is @@ -469,6 +523,7 @@ Panel { } function refresh(scanWifi) { + checkConnectivity() if (scanWifi === undefined) scanWifi = false if (!detailsProc.running) detailsProc.running = true if (!dnsProc.running) { @@ -901,7 +956,7 @@ Panel { Timer { id: connectionPhraseTimer interval: 2800 - running: root.opened && (root.info.type === "ethernet" || (root.info.type === "wifi" && root.canDisconnect)) + running: root.opened && !root.restricted && (root.info.type === "ethernet" || (root.info.type === "wifi" && root.canDisconnect)) repeat: true onTriggered: connectionPhraseSwap.restart() } @@ -958,6 +1013,9 @@ Panel { anchors.fill: parent bar: root.bar text: root.icon + active: root.restricted + tooltipText: root.hasCaptivePortal ? "Sign in to this network" + : (root.restricted ? "Limited internet access" : "") onPressed: function(b) { if (root.opened) root.close() @@ -1001,23 +1059,34 @@ Panel { if (dy >= 0) return } if (dy !== 0) { - // Vertical order is header ⇄ band ⇄ DNS ⇄ wifi, with the band section - // dropping out of the chain entirely when it isn't on screen. + // Hidden sections drop out of the keyboard chain entirely. if (root.focusSection === "header") { if (dy > 0) { - if (root.canSelectBand) { + if (root.hasCaptivePortal) { + root.focusSection = "portal" + } else if (root.canSelectBand) { root.focusSection = "band" root.bandAutoFocused = true } else { root.focusSection = "dns" } } + } else if (root.focusSection === "portal") { + if (dy < 0 && root.headerActionCount > 0) { + root.focusSection = "header" + root.headerIndex = 0 + } else if (dy > 0) { + root.focusSection = root.canSelectBand ? "band" : "dns" + root.bandAutoFocused = true + } } else if (root.focusSection === "band") { // Automatic on the header line, then the pills -- which collapse // away under Automatic, leaving a single row to walk. if (dy < 0) { if (!root.bandAutoFocused) { root.bandAutoFocused = true + } else if (root.hasCaptivePortal) { + root.focusSection = "portal" } else if (root.headerActionCount > 0) { root.focusSection = "header" root.headerIndex = 0 @@ -1035,6 +1104,8 @@ Panel { if (root.canSelectBand) { root.focusSection = "band" root.bandAutoFocused = !root.bandPillsVisible + } else if (root.hasCaptivePortal) { + root.focusSection = "portal" } else if (root.headerActionCount > 0) { root.focusSection = "header" root.headerIndex = 0 @@ -1063,6 +1134,7 @@ Panel { onActivateRequested: { if (root.cursorActive) { if (root.focusSection === "header") root.activateHeader() + else if (root.focusSection === "portal") root.openCaptivePortal() else if (root.focusSection === "band") root.activateBand() else if (root.focusSection === "dns") root.activateDns() else root.activateSelected() @@ -1092,7 +1164,7 @@ Panel { id: heroIcon textFormat: Text.PlainText text: root.icon - color: root.bar.foreground + color: root.restricted ? root.bar.urgent : root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.display opacity: root.networkManagerAvailable ? 1.0 : 0.5 @@ -1175,6 +1247,9 @@ Panel { width: parent.width readonly property string title: { + // The HTTP restriction does not undo association. Show the live + // SSID even before route/details polling has returned anything. + if (root.kind === "wifi" && root.connectedWifiNetwork) return root.connectedWifiNetwork.name || "Wi-Fi" if (root.info.type === "wifi") return root.info.ssid || "Wi-Fi" if (root.info.type === "ethernet") return "Ethernet" return root.info.iface || (root.kind === "disconnected" ? "Disconnected" : "No connection") @@ -1194,6 +1269,8 @@ Panel { textFormat: Text.PlainText width: parent.width text: { + if (root.hasCaptivePortal) return "SIGN-IN REQUIRED" + if (root.restricted) return "LIMITED INTERNET ACCESS" if (root.info.type === "wifi") { if (root.canDisconnect) return root.connectionPhrase.toUpperCase() if (root.kind === "disconnected") return "NOT CONNECTED" @@ -1204,7 +1281,7 @@ Panel { return "" } visible: text !== "" - color: Qt.darker(root.bar.foreground, 1.4) + color: root.restricted ? root.bar.urgent : Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily font.pixelSize: Style.font.caption font.bold: true @@ -1215,6 +1292,43 @@ Panel { } + Column { + visible: root.hasCaptivePortal + width: parent.width + spacing: Style.space(6) + + Button { + id: portalAction + width: parent.width + text: "Open Captive Portal" + iconText: "󰏌" + foreground: root.bar.urgent + accent: root.bar.urgent + fontFamily: root.bar.fontFamily + verticalPadding: Style.space(10) + bordered: true + active: true + hasCursor: root.cursorActive && root.focusSection === "portal" + onHovered: function(on) { + if (!on) return + root.cursorActive = true + root.focusSection = "portal" + } + onClicked: root.openCaptivePortal() + } + + Text { + width: parent.width + text: "Sign in or accept this network’s terms to access the internet." + textFormat: Text.PlainText + wrapMode: Text.WordWrap + color: root.bar.foreground + opacity: 0.7 + font.family: root.bar.fontFamily + font.pixelSize: Style.font.bodySmall + } + } + // Connection details: transfer metrics first, then IP/Gateway. Column { visible: !!root.info.iface @@ -1654,6 +1768,7 @@ Panel { if (isBusy && root.actionKind === "disconnect") return "Disconnecting…" if (isBusy && root.actionKind === "forget") return "Forgetting…" if (isFailed) return root.failureReason || "Failed" + if (isConnected && root.kind === "wifi" && root.hasCaptivePortal) return "Sign-in required" if (isConnected) return "Connected" return "" } @@ -1661,6 +1776,7 @@ Panel { readonly property color statusColor: { if (isFailed) return root.bar.urgent if (isBusy) return root.bar.foreground + if (isConnected && root.kind === "wifi" && root.hasCaptivePortal) return root.bar.urgent if (isConnected) return root.bar.foreground return Qt.darker(root.bar.foreground, 1.5) } @@ -1715,7 +1831,8 @@ Panel { Text { id: networkIcon textFormat: Text.PlainText - text: row.net ? root.wifiIconFor(row.net.signal) : "" + text: row.net ? Model.connectionIcon("wifi", row.net.signal, + row.isConnected && root.kind === "wifi" ? root.connectivity : "") : "" color: row.statusColor font.family: root.bar.fontFamily font.pixelSize: Style.font.title diff --git a/test/shell.d/fixtures/network-captive-portal/mocks/NetworkMock.qml b/test/shell.d/fixtures/network-captive-portal/mocks/NetworkMock.qml new file mode 100644 index 00000000..b41c6513 --- /dev/null +++ b/test/shell.d/fixtures/network-captive-portal/mocks/NetworkMock.qml @@ -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) + } +} diff --git a/test/shell.d/fixtures/network-captive-portal/mocks/qmldir b/test/shell.d/fixtures/network-captive-portal/mocks/qmldir new file mode 100644 index 00000000..03b0cb27 --- /dev/null +++ b/test/shell.d/fixtures/network-captive-portal/mocks/qmldir @@ -0,0 +1 @@ +singleton NetworkMock 1.0 NetworkMock.qml diff --git a/test/shell.d/fixtures/network-captive-portal/shell.qml b/test/shell.d/fixtures/network-captive-portal/shell.qml new file mode 100644 index 00000000..24c361bc --- /dev/null +++ b/test/shell.d/fixtures/network-captive-portal/shell.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() } +} diff --git a/test/shell.d/network-captive-portal-test.sh b/test/shell.d/network-captive-portal-test.sh new file mode 100755 index 00000000..0802eeb7 --- /dev/null +++ b/test/shell.d/network-captive-portal-test.sh @@ -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"