* Add Wi-Fi QR sharing to network panel * Refine Wi-Fi QR sharing * Use QR glyph for Wi-Fi sharing * Add click-to-reveal password to the Wi-Fi share card Scanning the QR is the fast path, but the person typing on a laptop needs the actual password. A dimmed "Show password" hint under the QR toggles the secret in place. The password stays out of the shell until asked for: a click runs the new omarchy-network-password helper (a private pipe, never an argument), and closing the card drops it again. Open and enterprise networks never show the control. The card loses its Close button -- Escape and clicking outside already cover it -- and now sizes itself to its content. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Keep a dismissed Wi-Fi share card closed and make Escape reliable Closing the card mid-generation killed the helper, but its buffered stdout still arrived and repopulated the matrix, reopening the card the user just closed. Both collectors now honor qrExpectedStop, and the flag survives onExited because exit and stream-finished have no guaranteed order. The password fetch gets the same treatment so a reveal in flight during dismissal can't stash the secret into a closed card's state. The content's focus was claimed while the window was still unmapped, so Escape could land nowhere. Re-acquire it after mapping, the way KeyboardPanel does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Share WEP networks correctly and restore the QR quiet zone NetworkManager models WEP as key-mgmt "none" plus a wep-key, so the QR helper encoded WEP networks as open -- a QR that scans fine and then silently fails to join. Encode them as T:WEP and let the password helper print the key. Also widen qrencode's margin from 2 to the spec's 4-module quiet zone; the card surround is dark, so that white border is all a scanner gets. 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>
352 lines
11 KiB
JavaScript
352 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 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
|
|
next[line.substring(0, idx)] = line.substring(idx + 1).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"
|
|
}
|
|
|
|
function formatSpeedMbps(mbps) {
|
|
var value = parseFloat(mbps)
|
|
if (!isFinite(value) || value <= 0) return "--"
|
|
return value.toFixed(value > 0 && value < 10 ? 1 : 0) + " Mbps"
|
|
}
|
|
|
|
// `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
|
|
}
|
|
|
|
function parseQrMatrix(raw) {
|
|
var lines = String(raw || "").trim().split(/\r?\n/).filter(function(line) { return line !== "" })
|
|
if (lines.length === 0) return { rows: [], size: 0 }
|
|
|
|
var size = lines[0].length
|
|
if (size !== lines.length) return { rows: [], size: 0 }
|
|
|
|
for (var i = 0; i < lines.length; i++) {
|
|
if (lines[i].length !== size || !/^[01]+$/.test(lines[i])) return { rows: [], size: 0 }
|
|
}
|
|
|
|
return { rows: lines, size: size }
|
|
}
|
|
|
|
// 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"
|
|
}
|
|
|
|
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,
|
|
parseKeyValue: parseKeyValue,
|
|
throughputState: throughputState,
|
|
pingLatencyState: pingLatencyState,
|
|
pingPacketLossPercent: pingPacketLossPercent,
|
|
formatPacketLoss: formatPacketLoss,
|
|
formatBytes: formatBytes,
|
|
formatRate: formatRate,
|
|
formatSpeedMbps: formatSpeedMbps,
|
|
formatPingLatency: formatPingLatency,
|
|
wifiRow: wifiRow,
|
|
sortWifiRows: sortWifiRows,
|
|
wifiSectionTitle: wifiSectionTitle,
|
|
isProtected: isProtected,
|
|
parseQrMatrix: parseQrMatrix,
|
|
enterpriseConnectScript: enterpriseConnectScript,
|
|
networkFailureReason: networkFailureReason
|
|
}
|
|
}
|