Files
quickshell/modules/bar/states/Network/NetworkState.qml
T

408 lines
14 KiB
QML

pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Networking
import "../../../../config"
QtObject {
id: root
property string status: "..."
property ListModel activeConnections: ListModel {}
property string downloadSpeed: ""
property string uploadSpeed: ""
property string ping: ""
property var downloadHistory: [{time: Date.now() - 60000, value: 0}, {time: Date.now(), value: 0}]
property var uploadHistory: [{time: Date.now() - 60000, value: 0}, {time: Date.now(), value: 0}]
property var pingHistory: [{time: Date.now() - 60000, value: 0}, {time: Date.now(), value: 0}]
property int _maxHistory: 60
property bool internetAvailable: Networking.connectivity === NetworkConnectivity.Full
property bool vpnConnected: false
property bool vpnExists: false
property string vpnName: ""
property string vpnIp: ""
property bool tailscaleConnected: false
property bool tailscaleExists: false
property string tailscaleIp: ""
property bool running: false
property real _prevRxBytes: 0
property real _prevTxBytes: 0
property var _prevUpdateTime: 0
property string _trafficActiveDevice: ""
property string _lastNetworkSignature: ""
readonly property var networking: Networking
// ---- persistence: survives reload AND close (disk) ----
// use Config.configDir (base dir) so path is stable and visible in repo
readonly property string _historyPath: Config.configDir + "/modules/bar/states/Network/network_history.json"
// QtObject has no default property, so hold FileView as typed property
property FileView historyFile: FileView {
path: _historyPath
printErrors: true
adapter: JsonAdapter {
property var downloadHistory: []
property var uploadHistory: []
property var pingHistory: []
}
onLoaded: {
function prune(arr) {
if (!arr || typeof arr.length !== "number" || arr.length===0) return null
const f = arr.filter(p => p && typeof p.time==="number" && typeof p.value==="number")
if (f.length < 2) return null
return f.length > root._maxHistory ? f.slice(-root._maxHistory) : f
}
const d = prune(adapter.downloadHistory)
if (d) root.downloadHistory = d
const u = prune(adapter.uploadHistory)
if (u) root.uploadHistory = u
const p = prune(adapter.pingHistory)
if (p) root.pingHistory = p
}
onLoadFailed: {} // first run
}
// periodic save every 10s (restart-debounce would never fire while statsTimer pushes every 1s)
property Timer saveTimer: Timer {
interval: 10000
repeat: true
running: true
onTriggered: {
historyFile.adapter.downloadHistory = root.downloadHistory
historyFile.adapter.uploadHistory = root.uploadHistory
historyFile.adapter.pingHistory = root.pingHistory
historyFile.writeAdapter()
}
}
Component.onDestruction: {
if (historyFile.loaded) {
historyFile.adapter.downloadHistory = root.downloadHistory
historyFile.adapter.uploadHistory = root.uploadHistory
historyFile.adapter.pingHistory = root.pingHistory
historyFile.writeAdapter()
}
}
function findAllConnectedDevices() {
var devs = networking.devices.values
var result = []
for (var i = 0; i < devs.length; i++) {
if (devs[i].connected) result.push(devs[i])
}
return result
}
function getNmConnectionName(net) {
if (!net) return ""
var settings = net.nmSettings
if (settings.length > 0) return settings[0].id
return ""
}
function updateAll() {
updateNetworks(true)
updateStats()
}
function updateNetworks(force) {
var connected = findAllConnectedDevices()
var sig = ""
for (var i = 0; i < connected.length; i++) {
var dev = connected[i]
sig += dev.name + ":" + dev.type + ":" + dev.connected
if (dev.type === DeviceType.Wifi) {
var nets = dev.networks.values
for (var j = 0; j < nets.length; j++) {
if (nets[j].connected) {
// include signalStrength & name so hover updates live on signal change
sig += ":" + nets[j].name + ":" + Math.round(nets[j].signalStrength*100)
break
}
}
}
sig += ";"
}
if (!force && sig === _lastNetworkSignature) return
_lastNetworkSignature = sig
activeConnections.clear()
var hasWifi = false
var hasWired = false
var bestWifiSignal = 0
var trafficDev = connected.length > 0 ? connected[0] : null
for (var i = 0; i < connected.length; i++) {
var dev = connected[i]
var entry = {
interfaceName: dev.name,
interfaceType: "",
connectionName: "",
wifiName: "",
signalStrength: 0,
linkSpeed: "",
ipv4: ""
}
if (dev.type === DeviceType.Wifi) {
entry.interfaceType = "wifi"
hasWifi = true
var networks = dev.networks.values
for (var j = 0; j < networks.length; j++) {
var net = networks[j]
if (net.connected) {
entry.wifiName = net.name
entry.signalStrength = Math.round(net.signalStrength * 100)
entry.connectionName = root.getNmConnectionName(net)
if (net.signalStrength > bestWifiSignal) bestWifiSignal = net.signalStrength
break
}
}
} else if (dev.type === DeviceType.Wired) {
entry.interfaceType = "ethernet"
hasWired = true
entry.linkSpeed = dev.linkSpeed + " Mbps"
if (dev.network) {
entry.connectionName = root.getNmConnectionName(dev.network)
}
}
activeConnections.append(entry)
if (dev.type === DeviceType.Wired && trafficDev !== dev) {
trafficDev = dev
}
}
if (trafficDev) {
_trafficActiveDevice = trafficDev.name
}
root.computeStatus(hasWired, hasWifi, bestWifiSignal)
if (connected.length > 0) {
ipv4Script.running = true
}
networkDetailScript.running = true
}
function updateStats() {
if (!_trafficActiveDevice) return
statsScript.running = true
}
property Timer networkTimer: Timer {
interval: 5000
repeat: true
running: true
onTriggered: root.updateNetworks()
}
property Timer statsTimer: Timer {
interval: 1000
repeat: true
running: true
onTriggered: root.updateStats()
}
property Process ipv4Script: Process {
command: ["sh", "-c",
"ip -4 addr show 2>/dev/null | awk '/^[0-9]+:/{iface=$2; gsub(/:.*/, \"\", iface)} /inet /{print iface\" \"$2}' | " +
"grep -v '\\blo\\b' | grep -v '\\bvirbr' | grep -v '\\btailscale' | grep -v '\\bdocker' | grep -v '\\bveth' | " +
"sed 's|/[0-9]*||'"
]
stdout: StdioCollector {
onStreamFinished: root.parseIpv4Output(text.trim())
}
}
function parseIpv4Output(output) {
var ipMap = {}
if (!output) {
root.applyIpv4Map(ipMap)
return
}
var lines = output.split("\n")
for (var i = 0; i < lines.length; i++) {
var parts = lines[i].trim().split(" ")
if (parts.length >= 2) {
ipMap[parts[0]] = parts[1]
}
}
root.applyIpv4Map(ipMap)
}
function applyIpv4Map(ipMap) {
for (var i = 0; i < activeConnections.count; i++) {
var iface = activeConnections.get(i).interfaceName
var ip = ipMap[iface] || ""
activeConnections.setProperty(i, "ipv4", ip)
}
}
property Process networkDetailScript: Process {
command: ["sh", "-c",
"echo '===VPN_ALL==='; " +
"nmcli -t -f NAME,TYPE connection show 2>/dev/null | grep -i vpn || true; " +
"echo '===VPN_ACTIVE==='; " +
"nmcli -t -f NAME,TYPE connection show --active 2>/dev/null | grep -i vpn || true; " +
"echo '===TAILSCALE_EXISTS==='; " +
"ip link show tailscale0 >/dev/null 2>&1 && echo EXISTS || echo ''; " +
"echo '===TAILSCALE_IP==='; " +
"ip -4 addr show tailscale0 2>/dev/null | grep -oP 'inet \\K[\\d.]+' || echo ''"
]
stdout: StdioCollector {
onStreamFinished: root.parseNetworkDetails(text.trim())
}
}
function parseNetworkDetails(output) {
var sections = output.split("===TAILSCALE_IP===")
var tailscaleIpSection = sections.length > 1 ? sections[1].trim() : ""
var beforeTsIp = sections[0]
sections = beforeTsIp.split("===TAILSCALE_EXISTS===")
var tailscaleExistsSection = sections.length > 1 ? sections[1].trim() : ""
var beforeTsExist = sections[0]
sections = beforeTsExist.split("===VPN_ACTIVE===")
var vpnActiveSection = sections.length > 1 ? sections[1].trim() : ""
var beforeVpnActive = sections[0]
sections = beforeVpnActive.split("===VPN_ALL===")
var vpnAllSection = sections.length > 1 ? sections[1].trim() : ""
root.vpnExists = vpnAllSection.length > 0
if (vpnActiveSection) {
root.vpnConnected = true
var vpnLines = vpnActiveSection.split("\n")
if (vpnLines.length > 0) {
root.vpnName = vpnLines[0].split(":")[0] || ""
}
} else {
root.vpnConnected = false
root.vpnName = ""
root.vpnIp = ""
}
root.tailscaleExists = tailscaleExistsSection.indexOf("EXISTS") >= 0
if (tailscaleIpSection) {
root.tailscaleConnected = true
root.tailscaleIp = tailscaleIpSection
} else {
root.tailscaleConnected = false
root.tailscaleIp = ""
}
}
property Process statsScript: Process {
command: ["sh", "-c",
"echo '===TRAFFIC==='; " +
"DEV=\"" + root._trafficActiveDevice + "\"; " +
"if [ -n \"$DEV\" ]; then " +
" echo \"RX_BYTES=$(cat /sys/class/net/$DEV/statistics/rx_bytes 2>/dev/null || echo 0)\"; " +
" echo \"TX_BYTES=$(cat /sys/class/net/$DEV/statistics/tx_bytes 2>/dev/null || echo 0)\"; " +
"fi; " +
"echo '===PING==='; " +
"ping -c 1 -W 2 1.1.1.1 2>/dev/null | awk -F/ '/rtt/{print $5}' || echo ''"
]
stdout: StdioCollector {
onStreamFinished: root.parseStats(text.trim())
}
}
function parseStats(output) {
var sections = output.split("===PING===")
root.ping = sections.length > 1 ? sections[1].trim() : ""
var pingVal = parseFloat(root.ping)
if (!isNaN(pingVal) && pingVal > 0) {
var pingHist = root.pingHistory.slice()
pingHist.push({time: Date.now(), value: pingVal})
if (pingHist.length > root._maxHistory) pingHist.shift()
root.pingHistory = pingHist
}
var trafficSection = sections[0].replace("===TRAFFIC===", "").trim()
if (trafficSection) {
var trafficLines = trafficSection.split("\n")
var rxBytes = 0
var txBytes = 0
for (var t = 0; t < trafficLines.length; t++) {
var tline = trafficLines[t].trim()
if (tline.indexOf("RX_BYTES=") === 0) rxBytes = parseFloat(tline.substring("RX_BYTES=".length))
else if (tline.indexOf("TX_BYTES=") === 0) txBytes = parseFloat(tline.substring("TX_BYTES=".length))
}
if (root._prevRxBytes > 0 && root._prevUpdateTime > 0) {
var now = Date.now()
var interval = (now - root._prevUpdateTime) / 1000
if (interval > 0) {
var dlBytes = (rxBytes - root._prevRxBytes) / interval
var ulBytes = (txBytes - root._prevTxBytes) / interval
root.downloadSpeed = root.formatSpeed(dlBytes)
root.uploadSpeed = root.formatSpeed(ulBytes)
var dlHist = root.downloadHistory.slice()
dlHist.push({time: now, value: dlBytes})
if (dlHist.length > root._maxHistory) dlHist.shift()
root.downloadHistory = dlHist
var ulHist = root.uploadHistory.slice()
ulHist.push({time: now, value: ulBytes})
if (ulHist.length > root._maxHistory) ulHist.shift()
root.uploadHistory = ulHist
}
} else {
root.downloadSpeed = ""
root.uploadSpeed = ""
}
root._prevRxBytes = rxBytes
root._prevTxBytes = txBytes
root._prevUpdateTime = Date.now()
}
}
function computeStatus(hasWired, hasWifi, bestSignal) {
if (!hasWired && !hasWifi) {
root.status = "󰤭/󱐤"
return
}
var parts = []
if (hasWired) parts.push("󰈀")
if (hasWifi) {
var sig = Math.round(bestSignal * 100)
if (sig > 80) parts.push("󰤨")
else if (sig > 60) parts.push("󰤥")
else if (sig > 40) parts.push("󰤢")
else if (sig > 20) parts.push("󰤟")
else if (sig > 0) parts.push("󰤯")
else parts.push("󰤭")
}
root.status = parts.join(" ")
}
function formatSpeed(bytesPerSec) {
if (bytesPerSec >= 1073741824) return (bytesPerSec / 1073741824).toFixed(1) + " GB/s"
if (bytesPerSec >= 1048576) return (bytesPerSec / 1048576).toFixed(1) + " MB/s"
if (bytesPerSec >= 1024) return (bytesPerSec / 1024).toFixed(1) + " KB/s"
return bytesPerSec.toFixed(0) + " B/s"
}
Component.onCompleted: {
root.updateAll()
}
}