Show captive portal status and sign-in action in network panel
This commit is contained in:
6 files changed
+438
-15
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
Executable
+95
@@ -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"
|
||||
Reference in new issue
Block a user