* fix: reopen wifi passphrase prompt after a wrong saved password A failed first connection attempt leaves the network profile saved, so the network shows up as known. Clicking it again reconnects with the stored wrong PSK and fails with WifiAuthTimeout, but the inline passphrase prompt only reopened on NoSecrets, leaving no way to re-enter the password short of forgetting the network. Treat an auth timeout on a protected network as a wrong saved passphrase and reopen the prompt; connectWithPsk overwrites the stored PSK on submit. Fixes #6582 * Scope the wifi passphrase reprompt to panel-initiated connects Background auto-connect retries also fire connectionFailed; without a gate they would pop the passphrase prompt open unbidden, stealing focus and wiping a passphrase mid-entry when another network fails. For the gate to see the failure, the action safety-net timer must outlast NetworkManager's 25s supplicant timeout -- at 15s it cleared the action state before WifiAuthTimeout arrived, so a wrong saved password showed "Timed out connecting" instead of "Wrong password". Bump it to 30s. Also share the one ConnectionFailReason map between the Model.js helpers instead of building a second partial copy inline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: David Heinemeier Hansson <david@hey.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
366 lines
11 KiB
JavaScript
366 lines
11 KiB
JavaScript
function parseNetworkStatus(raw) {
|
|
var parts = String(raw || "disconnected\t\t\t").replace(/\r?\n+$/, "").split("\t")
|
|
return {
|
|
kind: parts[0] || "disconnected",
|
|
label: parts[1] || "",
|
|
signalStrength: parts[2] ? parseInt(parts[2], 10) : -1,
|
|
frequency: parts[3] || ""
|
|
}
|
|
}
|
|
|
|
function wifiIconFor(strength) {
|
|
var icons = ["", "", "", "", ""]
|
|
var index = Math.max(0, Math.min(4, Math.ceil(strength / 20) - 1))
|
|
return icons[index]
|
|
}
|
|
|
|
function connectionIcon(kind, signalStrength) {
|
|
if (kind === "wifi") return wifiIconFor(signalStrength)
|
|
if (kind === "ethernet") return ""
|
|
return ""
|
|
}
|
|
|
|
function formatHeaderSpeed(mbps) {
|
|
var v = parseInt(mbps, 10)
|
|
if (!v || v < 0) return ""
|
|
if (v >= 1000) return (v / 1000).toFixed(v % 1000 === 0 ? 0 : 1) + "gbit"
|
|
return v + "mbit"
|
|
}
|
|
|
|
function formatHeaderFreq(mhz) {
|
|
var v = parseFloat(mhz)
|
|
if (!v) return ""
|
|
|
|
if (v >= 2400 && v < 2500) return "2.4ghz"
|
|
if (v >= 4900 && v < 5925) return "5ghz"
|
|
if (v >= 5925 && v < 7125) return "6ghz"
|
|
if (v >= 57000 && v < 71000) return "60ghz"
|
|
|
|
var ghz = v / 1000
|
|
return ghz.toFixed(ghz % 1 === 0 ? 0 : 1) + "ghz"
|
|
}
|
|
|
|
// Wi-Fi band state belongs in the selector section, not beside the hero name.
|
|
// Ethernet has no equivalent selector, so keep its negotiated link speed here.
|
|
function headerDetail(info) {
|
|
var value = info || {}
|
|
if (value.type === "ethernet") return formatHeaderSpeed(value.speed || "")
|
|
return ""
|
|
}
|
|
|
|
function bandLabel(band) {
|
|
if (band === "auto") return "Auto"
|
|
if (!band) return ""
|
|
return band + "ghz"
|
|
}
|
|
|
|
// Under Automatic the pills are hidden, so the header carries the live band
|
|
// instead -- "WI-FI BAND: 2.4GHZ". Once a band is pinned the pills are on
|
|
// screen and say it themselves, so the header drops back to a plain label.
|
|
function bandSectionTitle(selected, current) {
|
|
if (selected !== "auto") return "WI-FI BAND"
|
|
|
|
var label = bandLabel(current)
|
|
if (label === "") return "WI-FI BAND"
|
|
|
|
return "WI-FI BAND: " + label.toUpperCase()
|
|
}
|
|
|
|
function bandTooltip(band) {
|
|
if (band === "auto") return "Let Wi-Fi pick the band"
|
|
if (!band) return ""
|
|
return "Stay on " + bandLabel(band)
|
|
}
|
|
|
|
function parseBandStatus(raw) {
|
|
var next = parseKeyValue(raw)
|
|
var tokens = String(next.available || "").split(" ")
|
|
var available = []
|
|
|
|
for (var i = 0; i < tokens.length; i++) {
|
|
if (tokens[i] !== "") available.push(tokens[i])
|
|
}
|
|
|
|
return {
|
|
band: next.band || "",
|
|
selected: next.selected || "auto",
|
|
available: available
|
|
}
|
|
}
|
|
|
|
function decodeIwSsid(value) {
|
|
var raw = String(value || "")
|
|
|
|
try {
|
|
var encoded = ""
|
|
|
|
for (var i = 0; i < raw.length; i++) {
|
|
if (raw[i] === "\\" && raw[i + 1] === "x" && /^[0-9a-f]{2}$/i.test(raw.substring(i + 2, i + 4))) {
|
|
var hex = raw.substring(i + 2, i + 4)
|
|
var byte = parseInt(hex, 16)
|
|
encoded += byte < 32 || byte === 127 ? encodeURIComponent(raw.substring(i, i + 4)) : "%" + hex
|
|
i += 3
|
|
} else {
|
|
encoded += encodeURIComponent(raw[i])
|
|
}
|
|
}
|
|
|
|
return decodeURIComponent(encoded)
|
|
} catch (error) {
|
|
return raw
|
|
}
|
|
}
|
|
|
|
function parseKeyValue(raw) {
|
|
var next = {}
|
|
var lines = String(raw || "").split("\n")
|
|
for (var i = 0; i < lines.length; i++) {
|
|
var line = lines[i]
|
|
if (!line) continue
|
|
var idx = line.indexOf("\t")
|
|
if (idx === -1) continue
|
|
var key = line.substring(0, idx)
|
|
var value = line.substring(idx + 1)
|
|
next[key] = key === "ssid" ? decodeIwSsid(value) : value.trim()
|
|
}
|
|
return next
|
|
}
|
|
|
|
function throughputState(previous, next, now) {
|
|
var prev = previous || {}
|
|
var sample = next || {}
|
|
var iface = sample.iface || ""
|
|
var rx = parseFloat(sample.rx_bytes || "0")
|
|
var tx = parseFloat(sample.tx_bytes || "0")
|
|
var previousTime = Number(prev.prevSampleTime || 0)
|
|
|
|
if (iface !== (prev.prevIface || "") || previousTime === 0) {
|
|
return {
|
|
prevIface: iface,
|
|
prevRxBytes: rx,
|
|
prevTxBytes: tx,
|
|
prevSampleTime: now,
|
|
downloadRate: 0,
|
|
uploadRate: 0
|
|
}
|
|
}
|
|
|
|
var downloadRate = Number(prev.downloadRate || 0)
|
|
var uploadRate = Number(prev.uploadRate || 0)
|
|
var dt = now - previousTime
|
|
if (dt > 0) {
|
|
downloadRate = Math.max(0, (rx - Number(prev.prevRxBytes || 0)) / dt)
|
|
uploadRate = Math.max(0, (tx - Number(prev.prevTxBytes || 0)) / dt)
|
|
}
|
|
|
|
return {
|
|
prevIface: iface,
|
|
prevRxBytes: rx,
|
|
prevTxBytes: tx,
|
|
prevSampleTime: now,
|
|
downloadRate: downloadRate,
|
|
uploadRate: uploadRate
|
|
}
|
|
}
|
|
|
|
function pingSampleValue(raw) {
|
|
var value = parseFloat(raw)
|
|
if (!isFinite(value) || value < 0) return null
|
|
return value
|
|
}
|
|
|
|
function appendPingSample(samples, raw, limit) {
|
|
var values = Array.isArray(samples) ? samples.slice() : []
|
|
|
|
values.push(pingSampleValue(raw))
|
|
while (values.length > limit) values.shift()
|
|
|
|
return values
|
|
}
|
|
|
|
function averagePingLatency(samples, limit) {
|
|
var values = Array.isArray(samples) ? samples : []
|
|
var sampleLimit = Math.max(1, parseInt(limit, 10) || values.length || 1)
|
|
var total = 0
|
|
var count = 0
|
|
|
|
for (var i = Math.max(0, values.length - sampleLimit); i < values.length; i++) {
|
|
var value = values[i]
|
|
if (typeof value !== "number" || !isFinite(value) || value < 0) continue
|
|
total += value
|
|
count++
|
|
}
|
|
|
|
return count > 0 ? total / count : -1
|
|
}
|
|
|
|
function pingPacketLossPercent(samples) {
|
|
var values = Array.isArray(samples) ? samples : []
|
|
if (values.length === 0) return 0
|
|
|
|
var lost = 0
|
|
for (var i = 0; i < values.length; i++) {
|
|
if (values[i] === null) lost++
|
|
}
|
|
|
|
return Math.round((lost / values.length) * 100)
|
|
}
|
|
|
|
function formatPacketLoss(percent, hasSamples) {
|
|
if (hasSamples === false) return "--"
|
|
|
|
var value = parseInt(percent, 10)
|
|
if (!value || value < 0) return "0%"
|
|
return value + "%"
|
|
}
|
|
|
|
function pingLatencyState(previous, next, limit, averageLimit) {
|
|
var prev = previous || {}
|
|
var sample = next || {}
|
|
var iface = sample.iface || ""
|
|
var window = Math.max(1, parseInt(limit, 10) || 5)
|
|
var averageWindow = Math.max(1, parseInt(averageLimit, 10) || window)
|
|
var reset = iface === "" || iface !== (prev.pingIface || "")
|
|
var routerSamples = reset ? [] : prev.routerPingSamples
|
|
var internetSamples = reset ? [] : prev.internetPingSamples
|
|
|
|
routerSamples = sample.router_ping_ms === undefined ? [] : appendPingSample(routerSamples, sample.router_ping_ms, window)
|
|
internetSamples = sample.internet_ping_ms === undefined ? [] : appendPingSample(internetSamples, sample.internet_ping_ms, window)
|
|
|
|
return {
|
|
pingIface: iface,
|
|
routerPingSamples: routerSamples,
|
|
internetPingSamples: internetSamples,
|
|
routerPingLatency: averagePingLatency(routerSamples, averageWindow),
|
|
internetPingLatency: averagePingLatency(internetSamples, averageWindow),
|
|
internetPingPacketLoss: pingPacketLossPercent(internetSamples)
|
|
}
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
var n = Number(bytes)
|
|
if (!isFinite(n) || n < 0) n = 0
|
|
if (n < 1024) return Math.round(n) + " B"
|
|
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB"
|
|
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MB"
|
|
return (n / (1024 * 1024 * 1024)).toFixed(2) + " GB"
|
|
}
|
|
|
|
function formatRate(bytesPerSec) {
|
|
return formatBytes(bytesPerSec) + "/s"
|
|
}
|
|
|
|
// `hasSamples` false means no probe has come back yet, which is different from
|
|
// a probe that timed out. The rows stay mounted through that gap and read "--"
|
|
// so the grid doesn't reflow a second after the panel opens.
|
|
function formatPingLatency(ms, hasSamples) {
|
|
if (hasSamples === false) return "--"
|
|
|
|
var value = parseFloat(ms)
|
|
if (!isFinite(value) || value < 0) return "Timeout"
|
|
return value.toFixed(value > 0 && value < 10 ? 1 : 0) + " ms"
|
|
}
|
|
|
|
function wifiRow(network) {
|
|
if (!network) return null
|
|
return {
|
|
network: network,
|
|
connected: !!network.connected,
|
|
known: !!network.known,
|
|
ssid: network.name || "",
|
|
signal: Math.round((network.signalStrength || 0) * 100),
|
|
security: network.security
|
|
}
|
|
}
|
|
|
|
function sortWifiRows(rows) {
|
|
var nets = Array.isArray(rows) ? rows.slice() : []
|
|
nets.sort(function(a, b) {
|
|
if (a.connected !== b.connected) return a.connected ? -1 : 1
|
|
if (a.known !== b.known) return a.known ? -1 : 1
|
|
return b.signal - a.signal
|
|
})
|
|
return nets
|
|
}
|
|
|
|
function wifiSectionTitle(wifiNetworks, index) {
|
|
var networks = Array.isArray(wifiNetworks) ? wifiNetworks : []
|
|
if (index < 0 || index >= networks.length) return ""
|
|
|
|
var net = networks[index]
|
|
if (!net) return ""
|
|
|
|
if (net.known && index === 0) return "KNOWN NETWORKS"
|
|
if (!net.known && (index === 0 || (networks[index - 1] && networks[index - 1].known))) return "OTHER NETWORKS"
|
|
return ""
|
|
}
|
|
|
|
function isProtected(security, openSecurity) {
|
|
return security !== openSecurity
|
|
}
|
|
|
|
// The password arrives on stdin and reaches nmcli through the scriptable
|
|
// `connection edit` editor -- argv is world-readable in /proc, so the secret
|
|
// must never be an argument (printf is a bash builtin, so no process spawns
|
|
// with it either).
|
|
var enterpriseConnectScript =
|
|
"u=$(uuidgen); IFS= read -r pw;" +
|
|
" nmcli connection add type wifi con-name \"$1\" ssid \"$1\" connection.uuid \"$u\"" +
|
|
" wifi-sec.key-mgmt wpa-eap 802-1x.eap peap 802-1x.phase2-auth mschapv2" +
|
|
" 802-1x.identity \"$2\" 802-1x.auth-timeout 8 >/dev/null" +
|
|
" && printf 'set 802-1x.password %s\\nsave\\nquit\\n' \"$pw\" | nmcli connection edit uuid \"$u\" >/dev/null" +
|
|
" && nmcli connection up uuid \"$u\"" +
|
|
" || { nmcli connection delete uuid \"$u\" >/dev/null 2>&1; false; }"
|
|
|
|
function networkFailureReason(reason, reasons) {
|
|
var r = reasons || {}
|
|
if (reason === r.NoSecrets) return "Passphrase required"
|
|
if (reason === r.WifiAuthTimeout) return "Wrong password"
|
|
if (reason === r.WifiNetworkLost) return "Network lost"
|
|
if (reason === r.WifiClientDisconnected) return "Disconnected"
|
|
if (reason === r.WifiClientFailed) return "Connection failed"
|
|
return "Failed to connect"
|
|
}
|
|
|
|
// Whether a failed connect should reopen the passphrase prompt. NoSecrets
|
|
// always means credentials are missing. An auth timeout on a protected
|
|
// network means the saved passphrase is wrong (the same profile a first
|
|
// failed attempt leaves behind as "known"), so the user needs a chance to
|
|
// re-enter it -- connectWithPsk overwrites the stored PSK on submit.
|
|
function shouldRepromptPassphrase(reason, isProtected, reasons) {
|
|
var r = reasons || {}
|
|
if (reason === r.NoSecrets) return true
|
|
return !!isProtected && reason === r.WifiAuthTimeout
|
|
}
|
|
|
|
if (typeof module !== "undefined") {
|
|
module.exports = {
|
|
parseNetworkStatus: parseNetworkStatus,
|
|
wifiIconFor: wifiIconFor,
|
|
connectionIcon: connectionIcon,
|
|
formatHeaderSpeed: formatHeaderSpeed,
|
|
formatHeaderFreq: formatHeaderFreq,
|
|
headerDetail: headerDetail,
|
|
bandLabel: bandLabel,
|
|
bandSectionTitle: bandSectionTitle,
|
|
bandTooltip: bandTooltip,
|
|
parseBandStatus: parseBandStatus,
|
|
decodeIwSsid: decodeIwSsid,
|
|
parseKeyValue: parseKeyValue,
|
|
throughputState: throughputState,
|
|
pingLatencyState: pingLatencyState,
|
|
pingPacketLossPercent: pingPacketLossPercent,
|
|
formatPacketLoss: formatPacketLoss,
|
|
formatBytes: formatBytes,
|
|
formatRate: formatRate,
|
|
formatPingLatency: formatPingLatency,
|
|
wifiRow: wifiRow,
|
|
sortWifiRows: sortWifiRows,
|
|
wifiSectionTitle: wifiSectionTitle,
|
|
isProtected: isProtected,
|
|
enterpriseConnectScript: enterpriseConnectScript,
|
|
networkFailureReason: networkFailureReason,
|
|
shouldRepromptPassphrase: shouldRepromptPassphrase
|
|
}
|
|
}
|