Files
omarchycn/shell/plugins/panels/network/Model.js
T
c2889be493 Add Setup > Network menu with DNS, QR Code, and Speed Test (#6499)
* Make omarchy-network-qr detect the connected Wi-Fi interface

The interface argument is now optional so IPC and menu callers can
summon the QR card without knowing the device name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Move the speed test into a modal card with cluster dials

The network panel's Run button and the new omarchy.network speedTest
IPC route open a centered card where download and upload dials sweep
on open and track the live readings, Tucson style. Dismissing the card
stops the traffic workers. The QR card gains a showQr IPC route.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add Setup > Network menu with DNS, QR Code, and Speed Test

DNS switches providers through omarchy-dns with the current choice
checked. QR Code only shows while connected over Wi-Fi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Hold the speed test card steady and add a corner dismiss

The Run Again button now fades instead of unmounting so the card keeps
its size across runs, and a small X in the corner closes the card
alongside Esc and the scrim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Move the speed test action into the network panel hero

A speedometer icon beside the QR share replaces the dedicated inline
section, and the keyboard chain loses its speed stop accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Float the speed test cluster on the scrim

Drop the bordered card and the pulsing halo: like the Tucson's floating
cluster, the dials now sit directly on a near-black scrim with a soft
under-glow along the value arc, fainter ticks, and hubless needles that
fade toward the pivot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Center the retry button between the dials

The measuring status lines and the corner dismiss go away; the retry
button moves into the gap between the two dials like a cluster's center
display, anchored out of the column flow so nothing ever shifts. The
fast.com attribution lives on as its tooltip, and only errors still
print below the cluster.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Put the retry button back beneath the dial pair

The dials close ranks again and the retry button returns below them,
centered on the pair and still fading in place so nothing shifts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Harden the network IPC routes against stale panel state

The QR menu route forces interface self-detection instead of trusting
details that stop refreshing while the panel is closed, and the
widget's canonical close now tears down the centered cards and their
traffic instead of only hiding the compact panel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Re-range the speed dials for every run

The scale latched upward forever, so one unusually fast run would
compress every later one for the lifetime of the shell process. Each
dial now returns to the base scale when its measurement starts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Float the Wi-Fi QR share like the speed test

Same presentation as the dials: no bordered card, just the code on a
heavy scrim. Only the dark modules paint now, so the white canvas can
round its corners while the spec quiet zone keeps the code clear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Pick the default-route device and pin the locale in QR detection

nmcli localizes state names, so the detection fallback pins LC_ALL=C
and accepts states like "connected (externally)". Detection now prefers
the default-route device, matching the connection the panel and the
menu's visibility gate describe when several Wi-Fi adapters are up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Make every network summon path overlay-aware

Opening the widget while a centered card is up now dismisses the card
instead of raising the compact panel behind an exclusive overlay --
the shadowed open() covers the keybind toggle, the bar icon, and IPC.
Re-summoning a card while its process is still tearing down queues the
fresh request for onExited instead of dropping it, and the speed test
route refreshes connection details like the QR route does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Shrink the overlays to fit narrow outputs

The dial cluster and the QR card scale down instead of clipping when a
portrait or heavily scaled display is narrower than their natural size.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 22:15:46 -05:00

369 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
}
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,
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,
parseQrMatrix: parseQrMatrix,
enterpriseConnectScript: enterpriseConnectScript,
networkFailureReason: networkFailureReason
}
}