From 42a02dea80094145bea4f07c72e84a5868d63e69 Mon Sep 17 00:00:00 2001 From: Arpit Chakladar Date: Sun, 30 Aug 2026 01:01:48 +0530 Subject: feat: replaced python scripts with lua - using lua scripts where we previously used python for lower resource consumption --- modules/desktop/eww/activate-windows.py | 118 ----- modules/desktop/eww/bar.yuck | 16 +- modules/desktop/eww/niri-status.lua | 254 ++++++++++ modules/desktop/eww/system-stats.lua | 826 ++++++++++++++++++++++++++++++++ modules/desktop/eww/system-stats.py | 587 ----------------------- modules/desktop/eww/workspaces.py | 121 ----- modules/desktop/eww/yuck.nix | 54 +-- 7 files changed, 1113 insertions(+), 863 deletions(-) delete mode 100644 modules/desktop/eww/activate-windows.py create mode 100644 modules/desktop/eww/niri-status.lua create mode 100644 modules/desktop/eww/system-stats.lua delete mode 100644 modules/desktop/eww/system-stats.py delete mode 100644 modules/desktop/eww/workspaces.py (limited to 'modules/desktop') diff --git a/modules/desktop/eww/activate-windows.py b/modules/desktop/eww/activate-windows.py deleted file mode 100644 index f07b6a2..0000000 --- a/modules/desktop/eww/activate-windows.py +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env python3 -"""active-windows: emit icons for windows on the currently active -niri workspace, driven by niri's event-stream instead of polling.""" -import json -import subprocess -import gi -from gi.repository import Gio, Gtk - -gi.require_version("Gtk", "3.0") - -icon_theme = Gtk.IconTheme.get_default() -_icon_cache: dict[str, str] = {} - - -def try_desktop_app_info(desktop_id: str): - try: - return Gio.DesktopAppInfo.new(desktop_id) - except TypeError: - return None - - -def resolve_icon_path(app_id: str) -> str: - if not app_id: - return "" - if app_id in _icon_cache: - return _icon_cache[app_id] - - app_info = try_desktop_app_info(f"{app_id}.desktop") \ - or try_desktop_app_info(f"{app_id.lower()}.desktop") - - if app_info is None: - for info in Gio.AppInfo.get_all(): - if isinstance(info, Gio.DesktopAppInfo): - wm_class = info.get_startup_wm_class() - if wm_class and wm_class.lower() == app_id.lower(): - app_info = info - break - - icon_path = "" - gicon = app_info.get_icon() if app_info else None - if gicon is not None: - lookup = icon_theme.lookup_by_gicon(gicon, 24, Gtk.IconLookupFlags.FORCE_SIZE) - if lookup: - icon_path = lookup.get_filename() or "" - if not icon_path: - lookup = icon_theme.lookup_icon(app_id, 24, Gtk.IconLookupFlags.FORCE_SIZE) - if lookup: - icon_path = lookup.get_filename() or "" - - _icon_cache[app_id] = icon_path - return icon_path - - -def niri_json(*args): - out = subprocess.run(["niri", "msg", "-j", *args], - capture_output=True, - text=True, - check=True) - return json.loads(out.stdout) - - -def emit_active_windows(): - workspaces = niri_json("workspaces") - windows = niri_json("windows") - - active_ws = next((w for w in workspaces if w.get("is_active")), None) - active_id = active_ws["id"] if active_ws else None - - on_workspace = [w for w in windows if w.get("workspace_id") == active_id] - - def sort_key(w): - pos = (w.get("layout") or {}).get("pos_in_scrolling_layout") - if pos is not None: - return (0, pos[0], pos[1]) - # floating / positionless windows: push to the end, stable by id - return (1, w["id"], 0) - - on_workspace.sort(key=sort_key) - - result = [ - { - "id": w["id"], - "title": w.get("title", ""), - "app_id": w.get("app_id") or "", - "is_focused": w.get("is_focused", False), - "icon": resolve_icon_path(w.get("app_id") or ""), - } - for w in on_workspace - ] - print(json.dumps(result), flush=True) - - -RELEVANT_EVENTS = { - "WindowOpenedOrChanged", "WindowClosed", "WindowFocusChanged", - "WorkspaceActivated", "WorkspacesChanged", "WindowsChanged", -} - - -def main(): - emit_active_windows() - proc = subprocess.Popen( - ["niri", "msg", "-j", "event-stream"], - stdout=subprocess.PIPE, text=True, bufsize=1, - ) - for line in proc.stdout: - try: - key = next(iter(json.loads(line))) - except (json.JSONDecodeError, StopIteration): - continue - if key in RELEVANT_EVENTS: - try: - emit_active_windows() - except subprocess.CalledProcessError: - pass - - -if __name__ == "__main__": - main() diff --git a/modules/desktop/eww/bar.yuck b/modules/desktop/eww/bar.yuck index f7fdf49..2f5046d 100644 --- a/modules/desktop/eww/bar.yuck +++ b/modules/desktop/eww/bar.yuck @@ -2,11 +2,10 @@ (defpoll ampm :interval "10s" "date '+%p'") (defpoll date :interval "60s" "date '+%d/%m'") (defpoll full_date :interval "60s" "date '+%A, %d %B %Y'") -(deflisten workspaces :initial "[]" "@@workspaces-script@@") -(deflisten active-windows :initial "[]" "@@windows-script@@") +(deflisten niri_status :initial "{\"workspaces\":[],\"active_windows\":[]}" "@@niri-status-script@@") (deflisten system_stats :initial "{\"cpu\":{\"percent\":0,\"per_core\":[],\"freq_mhz\":null,\"load_avg\":null,\"tooltip\":\"CPU: 0%\"},\"ram\":{\"percent\":0,\"used\":\"0B\",\"total\":\"0B\",\"available\":\"0B\",\"swap_percent\":0,\"swap_used\":\"0B\",\"swap_total\":\"0B\",\"speed_mhz\":null,\"tooltip\":\"RAM: 0%\"},\"net\":{\"status\":\"down\",\"tooltip\":\"Network Offline\",\"routes\":[]},\"sound\":{\"percent\":0,\"mute\":true,\"sink\":null,\"tooltip\":\"Volume: unknown\"}}" - "@@systemstats-script@@") + "@@system-stats-script@@") (defwindow bar :monitor 0 @@ -26,11 +25,11 @@ :space-evenly false :class "bar-container" :vexpand true - (workspaces-widget) - (windows-widget) + (workspaces-widget :workspaces "${niri_status.workspaces}") + (windows-widget :windows "${niri_status.active_windows}") (system-widget))) -(defwidget workspaces-widget [] +(defwidget workspaces-widget [workspaces] (box :orientation "v" :space-evenly false :spacing 4 :halign "fill" :class "workspaces" (for ws in workspaces (eventbox :onclick "niri msg action focus-workspace ${ws.idx}" @@ -43,10 +42,9 @@ :image-width 20 :image-height 20) (label :class "workspace-idx" :text "${ws.idx}" :halign "end" :valign "end"))))))) -(defwidget windows-widget [] +(defwidget windows-widget [windows] (box :orientation "v" :space-evenly false :spacing 4 :halign "center" :class "windows" :vexpand true - - (for w in active-windows + (for w in windows (eventbox :onclick "niri msg action focus-window --id ${w.id}" :cursor "pointer" :halign "fill" diff --git a/modules/desktop/eww/niri-status.lua b/modules/desktop/eww/niri-status.lua new file mode 100644 index 0000000..771a12c --- /dev/null +++ b/modules/desktop/eww/niri-status.lua @@ -0,0 +1,254 @@ +#!/usr/bin/env luajit +-- niri-status: emit {workspaces: [...], active_windows: [...]} as one JSON +-- object per line, driven by a single niri event-stream subscription. +-- +-- This replaces the old activate-windows.lua + workspaces.lua pair. Those +-- ran as two separate long-lived processes, each independently subscribed +-- to niri's event-stream and, on every relevant event, independently +-- re-fetched both `niri msg -j windows` and `niri msg -j workspaces` via a +-- shelled-out io.popen. Combined that was up to 8 process spawns per single +-- event across both scripts. Here there's one event-stream subscription, +-- and one shared windows/workspaces fetch per event, significantly reducing +-- the total number of spawned processes and resting children. + +local cjson = require("cjson") + +local Gio, GioUnix, Gtk, Gdk, icon_theme = nil, nil, nil, nil, nil +local DesktopAppInfo = nil +do + local lgi_ok, lgi = pcall(require, "lgi") + if lgi_ok then + local gio_ok, gio = pcall(function() return lgi.Gio end) + if gio_ok then Gio = gio end + + local giounix_ok, giounix = pcall(function() return lgi.GioUnix end) + if giounix_ok then GioUnix = giounix end + + local gtk_ok, gtk = pcall(function() return lgi.Gtk end) + if gtk_ok then Gtk = gtk end + + local gdk_ok, gdk = pcall(function() return lgi.Gdk end) + if gdk_ok then Gdk = gdk end + + DesktopAppInfo = (GioUnix and GioUnix.DesktopAppInfo) or (Gio and Gio.DesktopAppInfo) + + if Gtk then + local get_ok, theme = pcall(Gtk.IconTheme.get_default) + if get_ok and theme then + icon_theme = theme + elseif Gdk then + local disp_ok, display = pcall(Gdk.Display.get_default) + if disp_ok and display then + local get_ok2, theme2 = pcall(Gtk.IconTheme.get_for_display, display) + if get_ok2 and theme2 then + icon_theme = theme2 + end + end + end + end + end +end + +local icon_cache = {} + +local function g_list_to_table(gl) + if gl == nil then return {} end + if type(gl) == "table" then return gl end + local t = {} + local cur = gl + while cur do + t[#t + 1] = cur.data + cur = cur.next + end + return t +end + +local function resolve_icon_path(app_id) + if not app_id or app_id == "" then return "" end + if icon_cache[app_id] then return icon_cache[app_id] end + + local icon_path = "" + if DesktopAppInfo and Gtk and icon_theme then + local function try_desktop_app_info(desktop_id) + local ok, info = pcall(DesktopAppInfo.new, desktop_id) + if ok and info then return info end + return nil + end + + local app_info = try_desktop_app_info(app_id .. ".desktop") + or try_desktop_app_info(string.lower(app_id) .. ".desktop") + + if not app_info then + local lc = string.lower(app_id) + for _, info in ipairs(g_list_to_table(Gio.AppInfo.get_all())) do + if info:is_a(DesktopAppInfo) then + local wm = info:get_startup_wm_class() + if wm and string.lower(wm) == lc then + app_info = info + break + end + end + end + end + + local gicon = app_info and app_info:get_icon() + if gicon then + local ok2, lookup = pcall(icon_theme.lookup_by_gicon, icon_theme, gicon, 24, Gtk.IconLookupFlags.FORCE_SIZE) + if ok2 and lookup then + icon_path = lookup:get_filename() or "" + end + end + if icon_path == "" then + local ok2, lookup = pcall(icon_theme.lookup_icon, icon_theme, app_id, 24, Gtk.IconLookupFlags.FORCE_SIZE) + if ok2 and lookup then + icon_path = lookup:get_filename() or "" + end + end + end + + icon_cache[app_id] = icon_path + return icon_path +end + +-- Safely run niri msg without crashing due to luaposix version changes +local function niri_json(command) + local f = io.popen("niri msg -j " .. command .. " 2>/dev/null") + if not f then return nil end + local out = f:read("*a") + f:close() + local ok, decoded = pcall(cjson.decode, out) + if ok then return decoded end + return nil +end + +local function window_sort_key(w) + local layout = w.layout or {} + local pos = layout.pos_in_scrolling_layout + if pos then return { 0, pos[1], pos[2] } end + return { 1, w.id, 0 } +end + +local function lt(a, b) + for k = 1, math.max(#a, #b) do + local x, y = a[k], b[k] + if y == nil then return false end + if x == nil then return true end + if x ~= y then return x < y end + end + return false +end + +-- Ensures cjson turns empty tables into `[]` instead of `{}` +local empty_array = cjson.empty_array or setmetatable({}, { __jsontype = "array" }) + +local function build_state() + local workspaces_raw = niri_json("workspaces") or {} + local windows_raw = niri_json("windows") or {} + + local windows_by_id = {} + for _, w in ipairs(windows_raw) do + windows_by_id[w.id] = w + end + + local active_id + for _, ws in ipairs(workspaces_raw) do + if ws.is_active then + active_id = ws.id + break + end + end + + local on_active = {} + for _, w in ipairs(windows_raw) do + if w.workspace_id == active_id then + on_active[#on_active + 1] = w + end + end + table.sort(on_active, function(a, b) + return lt(window_sort_key(a), window_sort_key(b)) + end) + + local active_windows_result = {} + for _, w in ipairs(on_active) do + active_windows_result[#active_windows_result + 1] = { + id = w.id, + title = w.title or "", + app_id = w.app_id or "", + is_focused = w.is_focused or false, + icon = resolve_icon_path(w.app_id or ""), + } + end + -- Enforce JSON Array even if empty + if #active_windows_result == 0 then active_windows_result = empty_array end + + local ws_list = {} + for _, ws in ipairs(workspaces_raw) do + ws_list[#ws_list + 1] = ws + end + table.sort(ws_list, function(a, b) return a.idx < b.idx end) + + local workspaces_result = {} + for _, ws in ipairs(ws_list) do + local active_win = windows_by_id[ws.active_window_id] + local app_id = (active_win and active_win.app_id) or "" + + local ws_name = (type(ws.name) == "string" and ws.name ~= "userdata: NULL" and ws.name) or ("Workspace " .. tostring(ws.idx)) + + local ws_windows = {} + for _, w in ipairs(windows_raw) do + if w.workspace_id == ws.id then + ws_windows[#ws_windows + 1] = w + end + end + table.sort(ws_windows, function(a, b) + return lt(window_sort_key(a), window_sort_key(b)) + end) + + local lines = { ws_name } + for idx, w in ipairs(ws_windows) do + lines[#lines + 1] = string.format("%d. %s: %s", idx, (w.app_id and w.app_id ~= "" and w.app_id) or "unknown", (w.title and w.title ~= "" and w.title) or "(untitled)") + end + + workspaces_result[#workspaces_result + 1] = { + idx = ws.idx, + id = ws.id, + is_active = ws.is_active or false, + icon = resolve_icon_path(app_id), + tooltip = #ws_windows > 0 and table.concat(lines, "\n") or ws_name, + } + end + -- Enforce JSON Array even if empty + if #workspaces_result == 0 then workspaces_result = empty_array end + + -- Output as a ROOT JSON OBJECT + return { + workspaces = workspaces_result, + active_windows = active_windows_result, + } +end + +local function emit_state() + local ok, result = pcall(build_state) + if not ok then + io.stderr:write(tostring(result) .. "\n") + return + end + print(cjson.encode(result)) + io.stdout:flush() +end + +local RELEVANT_EVENTS = { + WindowOpenedOrChanged = true, WindowClosed = true, WindowFocusChanged = true, + WorkspaceActivated = true, WorkspacesChanged = true, WindowsChanged = true, + WorkspaceUrgencyChanged = true, WorkspaceActiveWindowChanged = true +} + +local proc = io.popen("niri msg -j event-stream") +emit_state() +for line in proc:lines() do + local ok, obj = pcall(cjson.decode, line) + if ok and obj then + local key = next(obj) + if RELEVANT_EVENTS[key] then emit_state() end + end +end diff --git a/modules/desktop/eww/system-stats.lua b/modules/desktop/eww/system-stats.lua new file mode 100644 index 0000000..514647f --- /dev/null +++ b/modules/desktop/eww/system-stats.lua @@ -0,0 +1,826 @@ +#!/usr/bin/env luajit +-- system-stats: emit {cpu, ram, net, sound} as JSON. +-- +-- Single-threaded LuaJIT port of the original Python script. Instead of +-- OS threads, a single supervisor loop schedules the periodic cpu/ram and +-- sound refreshes and polls an inotifywait child for link-state changes. +local cjson = require("cjson") +local posix = require("posix") +local unistd = require("posix.unistd") +local gettimeofday = require("posix.sys.time").gettimeofday +local poll = require("posix.poll").poll + +local NULL = cjson.null + +local function nonnull(v) + if v == nil then + return NULL + end + return v +end + +-- Throughput deltas between cpu/ram refreshes +local net_last_time = nil +local net_last_bytes = {} + +-- ------------------------------------------------ time +local function mono_sec() + local g = gettimeofday() + return g.tv_sec + g.tv_usec / 1e6 +end + +-- ------------------------------------------------ process helpers +local function sh(cmd) + local h = io.popen(cmd) + if not h then + return nil + end + local out = h:read("*a") + h:close() + return out +end + +-- ------------------------------------------------ formatting +local function format_rate(bytes_per_sec) + if bytes_per_sec == nil or bytes_per_sec < 0 then + return "0B" + end + local units = { "B", "K", "M", "G", "T" } + local val = bytes_per_sec + local unit_idx = 1 + while val >= 1024 and unit_idx < #units do + val = val / 1024.0 + unit_idx = unit_idx + 1 + end + local unit = units[unit_idx] + if val >= 100 or unit == "B" then + return string.format("%d%s", math.floor(val + 0.5), unit) + end + return string.format("%.1f%s", val, unit) +end + +local function format_bytes(kib) + if kib == nil or kib < 0 then + return "0B" + end + local units = { "K", "M", "G", "T" } + local val = kib + local unit_idx = 1 + while val >= 1024 and unit_idx < #units do + val = val / 1024.0 + unit_idx = unit_idx + 1 + end + local unit = units[unit_idx] + if val >= 100 or unit == "K" then + return string.format("%d%s", math.floor(val + 0.5), unit) + end + return string.format("%.1f%s", val, unit) +end + +-- ------------------------------------------------ /proc readers +local function read_cpu_times() + local times = {} + local f = io.open("/proc/stat") + if not f then + return times + end + for line in f:lines() do + if not line:match("^cpu") then + break + end + local parts = {} + for w in line:gmatch("%S+") do + parts[#parts + 1] = w + end + local label = parts[1] + local total, idle = 0, 0 + for j = 2, #parts do + local v = tonumber(parts[j]) or 0 + total = total + v + if j == 4 or j == 5 then + idle = idle + v + end + end + times[label] = { idle = idle, total = total } + end + f:close() + return times +end + +local function get_cpu_freq_mhz() + local count, sum, ok = 0, 0, false + local f = io.open("/proc/cpuinfo") + if not f then + return nil + end + for line in f:lines() do + local low = line:lower() + if low:find("^cpu mhz") then + local val = line:match(":%s*([%d%.]+)") + if val then + sum = sum + tonumber(val) + count = count + 1 + ok = true + end + end + end + f:close() + if not ok then + return nil + end + return math.floor(sum / count + 0.5) +end + +local function get_load_avg() + local f = io.open("/proc/loadavg") + if not f then + return nil + end + local line = f:read("*l") + f:close() + if not line then + return nil + end + local a, b, c = line:match("^([%d%.]+)%s+([%d%.]+)%s+([%d%.]+)") + if not a then + return nil + end + return { tonumber(a), tonumber(b), tonumber(c) } +end + +local function get_ram_speed_mhz() + local out = sh("dmidecode -t 17 2>/dev/null") + if not out then + return nil + end + local speeds = {} + for line in out:gmatch("[^\n]+") do + local s = line:match("^%s*Configured Memory Speed:%s*(.-)%s*$") + if s then + local low = s:lower() + if low ~= "unknown" and low ~= "no module installed" then + local num = s:match("^(%d+)") + if num then + speeds[#speeds + 1] = tonumber(num) + end + end + end + end + if #speeds == 0 then + for line in out:gmatch("[^\n]+") do + local s = line:match("^%s*Speed:%s*(.-)%s*$") + if s then + local low = s:lower() + if low ~= "unknown" and low ~= "no module installed" then + local num = s:match("^(%d+)") + if num then + speeds[#speeds + 1] = tonumber(num) + end + end + end + end + end + if #speeds == 0 then + return nil + end + local mx = speeds[1] + for j = 2, #speeds do + if speeds[j] > mx then + mx = speeds[j] + end + end + return mx +end + +-- ------------------------------------------------ network helpers +local function get_all_default_ifaces() + local out = sh("ip -o route show default 2>/dev/null") + if not out then + return {} + end + local ifaces = {} + local seen = {} + for line in out:gmatch("[^\n]+") do + local iface = line:match("dev (%S+)") + if iface and not seen[iface] then + seen[iface] = true + ifaces[#ifaces + 1] = iface + end + end + return ifaces +end + +local function get_iface_ip(iface) + if not iface or iface == "" then + return nil + end + local out = sh("ip -o -4 addr show dev " .. iface .. " 2>/dev/null") + if not out then + return nil + end + local ip = out:match("inet (%d+%.%d+%.%d+%.%d+)") + return ip +end + +local function get_operstate(iface) + local f = io.open("/sys/class/net/" .. iface .. "/operstate") + if not f then + return "down" + end + local s = f:read("*l") + f:close() + return (s and s:match("^%s*(.-)%s*$")) or "down" +end + +local function get_iface_type(iface) + if posix.stat("/sys/class/net/" .. iface .. "/wireless") then + return "wifi" + end + local st = posix.stat("/sys/class/net/" .. iface .. "/bridge") + if st and st.type == "directory" then + return "bridge" + end + if posix.stat("/sys/class/net/" .. iface .. "/tun_flags") then + return "tun" + end + return "ethernet" +end + +local function get_speed_mbps(iface, iftype) + if iftype == "ethernet" then + local f = io.open("/sys/class/net/" .. iface .. "/speed") + if f then + local s = f:read("*l") + f:close() + local num = s and tonumber(s:match("^%s*(-?%d+)")) + if num and num >= 0 then + return num + end + end + return nil + end + if iftype == "wifi" then + local out = sh("iw dev " .. iface .. " link 2>/dev/null") + if not out then + return nil + end + for line in out:gmatch("[^\n]+") do + local l = line:match("^%s*tx bitrate:%s*(.-)%s*$") + if l then + local num = tonumber(l:match("^([%d%.]+)")) + if num then + return math.floor(num + 0.5) + end + end + end + end + return nil +end + +local function get_iface_io_bytes(iface) + if not iface or iface == "" then + return nil, nil + end + local f = io.open("/proc/net/dev") + if not f then + return nil, nil + end + local rx, tx + for line in f:lines() do + local colon = line:find(":") + if colon then + local dev = line:sub(1, colon - 1):match("^%s*(.-)%s*$") + if dev == iface then + local fields = {} + for w in line:sub(colon + 1):gmatch("%S+") do + fields[#fields + 1] = w + end + rx = tonumber(fields[1]) or nil + tx = tonumber(fields[9]) or nil + break + end + end + end + f:close() + return rx, tx +end + +-- ------------------------------------------------ tooltips +local function build_cpu_tooltip(overall_pct, per_core_list, freq_mhz, load_avg) + local lines = { string.format("CPU: %d%%", overall_pct) } + if freq_mhz ~= nil then + lines[#lines + 1] = string.format("Freq: %d MHz", freq_mhz) + end + if load_avg ~= nil then + lines[#lines + 1] = string.format( + "Load: %.2f %.2f %.2f", + load_avg[1], + load_avg[2], + load_avg[3] + ) + end + for i, pct in ipairs(per_core_list) do + lines[#lines + 1] = string.format("Core %d: %d%%", i - 1, pct) + end + return table.concat(lines, "\n") +end + +local function build_ram_tooltip(ram_pct, used, total, available, swap_pct, swap_used, swap_total, speed_mhz) + local lines = { string.format("RAM: %d%% (%s / %s)", ram_pct, used, total) } + lines[#lines + 1] = "Available: " .. available + lines[#lines + 1] = string.format("Swap: %d%% (%s / %s)", swap_pct, swap_used, swap_total) + if speed_mhz ~= nil then + lines[#lines + 1] = string.format("Speed: %d MHz", speed_mhz) + else + lines[#lines + 1] = "Speed: unknown" + end + return table.concat(lines, "\n") +end + +local function build_sound_tooltip(percent, mute, sink) + local vol = tostring(percent) .. "%" + local lines = {} + if mute then + lines[#lines + 1] = string.format("Muted (%s)", vol) + else + lines[#lines + 1] = "Volume: " .. vol + end + if sink then + lines[#lines + 1] = "Sink: " .. sink + end + return table.concat(lines, "\n") +end + +-- ------------------------------------------------ state collectors +-- get_all_default_ifaces (`ip route`), get_iface_ip (`ip addr`), and the +-- wifi branch of get_speed_mbps (`iw dev link`) each spawn a subprocess. +-- Those three rarely change second-to-second, so they're only re-probed +-- every NET_SLOW_REFRESH_INTERVAL instead of on every check_net_status() +-- call. operstate, iface type, ethernet speed, and rx/tx byte counters are +-- all plain sysfs/proc reads with no subprocess involved, so they stay +-- live on every call regardless -- rx/tx throughput in particular needs +-- to stay frequent to be useful. +local NET_SLOW_REFRESH_INTERVAL = 15.0 +local net_slow_ifaces = nil +local net_slow_ip = {} +local net_slow_wifi_speed = {} +local net_slow_expires_at = 0 + +local function refresh_net_slow_cache(now) + net_slow_ifaces = get_all_default_ifaces() + local ip_cache, wifi_speed_cache = {}, {} + for _, iface in ipairs(net_slow_ifaces) do + ip_cache[iface] = get_iface_ip(iface) + if get_iface_type(iface) == "wifi" then + wifi_speed_cache[iface] = get_speed_mbps(iface, "wifi") + end + end + net_slow_ip = ip_cache + net_slow_wifi_speed = wifi_speed_cache + net_slow_expires_at = now + NET_SLOW_REFRESH_INTERVAL +end + +-- Pass force=true to bypass the timer and re-probe immediately (used for +-- the initial call and whenever inotify reports an actual link change). +local function check_net_status(force) + local now = mono_sec() + if force or net_slow_ifaces == nil or now >= net_slow_expires_at then + refresh_net_slow_cache(now) + end + + local ifaces = net_slow_ifaces + + if #ifaces == 0 then + return { status = "down", tooltip = "Network Offline", routes = {} } + end + + local routes = {} + local any_up = false + local any_limited = false + + for _, iface in ipairs(ifaces) do + local oper = get_operstate(iface) + if oper == "up" then + any_up = true + elseif oper == "dormant" then + any_limited = true + end + + local iftype = get_iface_type(iface) + local ip_addr = net_slow_ip[iface] + local speed + if oper == "up" then + if iftype == "wifi" then + speed = net_slow_wifi_speed[iface] + else + -- Ethernet speed is a plain sysfs read (no subprocess), so it + -- stays live every call rather than going through the slow cache. + speed = get_speed_mbps(iface, iftype) + end + end + + local rx_bytes, tx_bytes = get_iface_io_bytes(iface) + local rx_str, tx_str = nil, nil + + if oper ~= "down" and rx_bytes ~= nil and tx_bytes ~= nil then + if net_last_time ~= nil and net_last_bytes[iface] then + local dt = now - net_last_time + if dt > 0 then + local prev = net_last_bytes[iface] + local rx_rate = math.max(0, (rx_bytes - prev[1]) / dt) + local tx_rate = math.max(0, (tx_bytes - prev[2]) / dt) + rx_str = format_rate(rx_rate) + tx_str = format_rate(tx_rate) + end + end + net_last_bytes[iface] = { rx_bytes, tx_bytes } + else + rx_str, tx_str = nil, nil + end + + routes[#routes + 1] = { + iface = iface, + ip = nonnull(ip_addr), + ["type"] = iftype, + speed = nonnull(speed), + rx = nonnull(rx_str), + tx = nonnull(tx_str), + } + end + + net_last_time = now + + local overall_status + if any_up then + overall_status = "up" + elseif any_limited then + overall_status = "limited" + else + overall_status = "down" + end + + local tooltip + if overall_status == "down" or #routes == 0 then + tooltip = "Network Offline" + else + local first = routes[1] + local rx = first.rx + local tx = first.tx + if rx == NULL or rx == nil then + rx = "-" + end + if tx == NULL or tx == nil then + tx = "-" + end + tooltip = string.format("\u{2193}%s \u{2191}%s", rx, tx) + for _, r in ipairs(routes) do + local iface = r.iface or "unknown" + local ip = r.ip + if ip == NULL or ip == nil then + ip = "no ip" + end + tooltip = tooltip .. "\n" .. iface .. ": " .. ip + end + end + + return { status = overall_status, tooltip = tooltip, routes = routes } +end + +local function get_sound_state() + local function pamixer_flag(flag) + local out = sh("pamixer " .. flag .. " 2>/dev/null") + if not out then + return nil + end + return out:gsub("^%s+", ""):gsub("%s+$", "") + end + + local percent, mute, sink = nil, nil, nil + + local pout = pamixer_flag("--get-volume") + if pout then + local num = tonumber(pout:match("^(%-?%d+)")) + if num then + percent = math.max(0, math.min(100, num)) + end + end + + local mout = pamixer_flag("--get-mute") + if mout ~= nil then + mute = (mout:lower() == "true") + end + + local sout = pamixer_flag("--get-default-sink") + if sout then + -- Output ends with: INDEX "name" "description" + local names = {} + for q in sout:gmatch('"([^"]*)"') do + names[#names + 1] = q + end + if #names > 0 then + sink = names[#names] + else + local last = sout:match("([^\n]+)$") + sink = last or sout + end + end + + local vol = tostring(percent) .. "%" + local supp = {} + if mute then + supp[#supp + 1] = string.format("Muted (%s)", vol) + else + supp[#supp + 1] = "Volume: " .. vol + end + if sink then + supp[#supp + 1] = "Sink: " .. sink + end + + return { + percent = nonnull(percent), + mute = mute or false, + sink = nonnull(sink), + tooltip = table.concat(supp, "\n"), + } +end + +-- ------------------------------------------------ state + emit +local state = { + cpu = { + percent = 0, + per_core = {}, + freq_mhz = NULL, + load_avg = NULL, + tooltip = "CPU: 0%", + }, + ram = { + percent = 0, + used = "0B", + total = "0B", + available = "0B", + swap_percent = 0, + swap_used = "0B", + swap_total = "0B", + speed_mhz = NULL, + tooltip = "RAM: 0%", + }, + net = { + status = "down", + tooltip = "Network Offline", + routes = {}, + }, + sound = { + percent = NULL, + mute = false, + sink = NULL, + tooltip = "Volume: unknown", + }, +} + +local function emit() + io.write(cjson.encode(state) .. "\n") + io.stdout:flush() +end + +local CPU_INTERVAL = 2.0 +local SOUND_INTERVAL = 1.0 +local INOTIFY_RETRY_INTERVAL = 30.0 -- backoff between (re)spawn attempts + +-- ------------------------------------------------ inotifywait child +-- Returns pid, read_fd on success, or nil, nil if the fork/exec setup +-- itself failed outright (pipe/fork syscall failure, not inotifywait +-- failing later). +local function spawn_inotify() + local r, w = posix.pipe() + if not r or not w then + return nil, nil + end + local pid = posix.fork() + if pid == nil then + posix.close(r) + posix.close(w) + return nil, nil + end + if pid == 0 then + posix.dup2(w, unistd.STDOUT_FILENO) + local nullfd = posix.open("/dev/null", posix.O_WRONLY) + if nullfd then + posix.dup2(nullfd, unistd.STDERR_FILENO) + posix.close(nullfd) + end + posix.close(r) + posix.close(w) + local ok, err = posix.execp( + "inotifywait", + { "inotifywait", "-m", "-e", "modify", "/run/systemd/netif/state" } + ) + if not ok then + io.stderr:write("inotifywait: " .. tostring(err) .. "\n") + os.exit(1) + end + end + posix.close(w) + return pid, r +end + +-- ------------------------------------------------ main loop +local function main() + local ram_speed_mhz = get_ram_speed_mhz() + + -- seed global state copy with all keys (null defaults) + local prev_cpu_times = read_cpu_times() + + -- initial emission + state.net = check_net_status(true) + emit() + + -- net_fd is nil whenever we have no live inotifywait child to poll on. + -- We (re)spawn opportunistically, but never more often than + -- INOTIFY_RETRY_INTERVAL, so a persistently-failing child (missing + -- binary, watched path doesn't exist, permissions, ...) can never turn + -- into a tight loop -- it just falls back to picking up network + -- changes on the regular CPU_INTERVAL cadence below instead. + local inotify_pid = nil + local net_fd = nil + local next_inotify_attempt = 0 -- attempt immediately on first iteration + + local next_cpu = mono_sec() + CPU_INTERVAL + local next_sound = mono_sec() + SOUND_INTERVAL + + while true do + local now = mono_sec() + + if net_fd == nil and now >= next_inotify_attempt then + inotify_pid, net_fd = spawn_inotify() + -- Whether this attempt succeeded or not, don't try again for a + -- while: a child that exits immediately (e.g. because the watched + -- path doesn't exist) would otherwise cause an attempt-per-loop + -- storm identical to the original bug. + next_inotify_attempt = mono_sec() + INOTIFY_RETRY_INTERVAL + end + + if now >= next_cpu then + local cur_times = read_cpu_times() + local overall_pct = 0 + local per_core = {} + for label, cur in pairs(cur_times) do + local prev = prev_cpu_times[label] or cur + local d_idle = cur.idle - prev.idle + local d_total = cur.total - prev.total + local pct = 0 + if d_total > 0 then + pct = math.floor((1 - d_idle / d_total) * 100 + 0.5) + end + if label == "cpu" then + overall_pct = pct + else + per_core[tonumber(label:match("^cpu(.*)$")) or -1] = pct + end + end + prev_cpu_times = cur_times + + local keys = {} + for k in pairs(per_core) do + keys[#keys + 1] = k + end + table.sort(keys) + local per_core_list = {} + for _, k in ipairs(keys) do + per_core_list[#per_core_list + 1] = per_core[k] + end + + local freq_mhz = get_cpu_freq_mhz() + local load_avg = get_load_avg() + + local cpu_state = { + percent = overall_pct, + per_core = per_core_list, + freq_mhz = nonnull(freq_mhz), + load_avg = nonnull(load_avg), + tooltip = build_cpu_tooltip(overall_pct, per_core_list, freq_mhz, load_avg), + } + + -- RAM + local meminfo = {} + local f = io.open("/proc/meminfo") + if f then + for line in f:lines() do + local k, v = line:match("^([^:]+):%s*(%d+)") + if k and v then + meminfo[k] = tonumber(v) + end + end + f:close() + end + + local total_kb = meminfo.MemTotal or 0 + local avail_kb = meminfo.MemAvailable or 0 + local used_kb = math.max(0, total_kb - avail_kb) + local ram_pct = 0 + if total_kb > 0 then + ram_pct = math.floor((1 - avail_kb / total_kb) * 100 + 0.5) + end + + local swap_total_kb = meminfo.SwapTotal or 0 + local swap_free_kb = meminfo.SwapFree or 0 + local swap_used_kb = math.max(0, swap_total_kb - swap_free_kb) + local swap_pct = 0 + if swap_total_kb > 0 then + swap_pct = math.floor(swap_used_kb / swap_total_kb * 100 + 0.5) + end + + local used_fmt = format_bytes(used_kb) + local total_fmt = format_bytes(total_kb) + local avail_fmt = format_bytes(avail_kb) + local swap_used_fmt = format_bytes(swap_used_kb) + local swap_total_fmt = format_bytes(swap_total_kb) + + local ram_state = { + percent = ram_pct, + used = used_fmt, + total = total_fmt, + available = avail_fmt, + swap_percent = swap_pct, + swap_used = swap_used_fmt, + swap_total = swap_total_fmt, + speed_mhz = nonnull(ram_speed_mhz), + tooltip = build_ram_tooltip( + ram_pct, + used_fmt, + total_fmt, + avail_fmt, + swap_pct, + swap_used_fmt, + swap_total_fmt, + ram_speed_mhz + ), + } + + state.cpu = cpu_state + state.ram = ram_state + -- Net status is always refreshed here too, so even with inotify + -- permanently unavailable we still pick up link changes within + -- CPU_INTERVAL seconds. + state.net = check_net_status() + emit() + next_cpu = mono_sec() + CPU_INTERVAL + end + + if now >= next_sound then + state.sound = get_sound_state() + emit() + next_sound = mono_sec() + SOUND_INTERVAL + end + + -- Poll inotifywait for link-state changes (timeout in ms). If we + -- have no live child right now, still sleep until the next + -- scheduled piece of work (or the next respawn attempt) instead of + -- busy-looping. + local wake_at = math.min(next_cpu, next_sound) + if net_fd == nil then + wake_at = math.min(wake_at, next_inotify_attempt) + end + local until_next = wake_at - mono_sec() + if until_next < 0 then + until_next = 0 + end + local timeout_ms = math.floor(until_next * 1000 + 0.5) + + local fds = {} + if net_fd ~= nil then + fds[net_fd] = { events = { IN = true } } + end + + local nready = poll(fds, timeout_ms) + if net_fd ~= nil and nready and nready > 0 then + local revents = fds[net_fd].revents + if revents and (revents.IN or revents.HUP or revents.ERR) then + local data = unistd.read(net_fd, 8192) + if data and #data > 0 then + -- An actual link-state change: force a full re-probe rather + -- than waiting out the slow-refresh timer, since this is + -- exactly the moment the cached IP/speed could be stale. + state.net = check_net_status(true) + emit() + else + -- Empty read on a "ready" fd means the write end closed, i.e. + -- the inotifywait child exited (crashed, watched path missing, + -- binary not found, etc). Reap it, drop the fd, and don't + -- touch it again until the next backed-off retry -- this is + -- the fix for the tight busy-loop: without this branch, poll() + -- would report this dead fd as "ready" forever, on every + -- single iteration, with an effectively-zero timeout. + unistd.close(net_fd) + if inotify_pid then + pcall(posix.wait, inotify_pid) + end + net_fd = nil + inotify_pid = nil + next_inotify_attempt = mono_sec() + INOTIFY_RETRY_INTERVAL + end + end + end + end +end + +main() diff --git a/modules/desktop/eww/system-stats.py b/modules/desktop/eww/system-stats.py deleted file mode 100644 index fdc1503..0000000 --- a/modules/desktop/eww/system-stats.py +++ /dev/null @@ -1,587 +0,0 @@ -#!/usr/bin/env python3 -"""system-stats: emit {cpu, ram, net} as JSON. - -cpu is now an object: { - "percent": 23, # overall utilization, 0-100 - "per_core": [12, 34, ...], # per-core utilization, 0-100 each - "freq_mhz": 2400, # average current frequency across cores - "load_avg": [0.5, 0.6, 0.7] # 1/5/15 min load averages -} - -ram is now an object: { - "percent": 42, # used %, based on MemAvailable - "used": "6.2G", # formatted absolute used - "total": "16.0G", # formatted absolute total - "available": "9.8G", # formatted absolute available - "swap_percent": 5, - "swap_used": "512M", - "swap_total": "8.0G", - "speed_mhz": 3200 # configured RAM speed, or null if unavailable -} - -net is an object: { - "status": "up"|"limited"|"down", - "tooltip": "↓12M ↑5M\\nwlan0: 192.168.1.5", - "routes": [ - { - "iface": "wlan0", - "ip": "192.168.1.5", - "type": "wifi", - "speed": 866, # link speed in Mbps - "rx": "12.5M", # formatted download rate - "tx": "540K" # formatted upload rate - }, - ... - ] -} - -sound is an object: { - "percent": 58, # default sink volume, 0-100 - "mute": false, # whether the default sink is muted - "sink": "Rockerz 430", # description of the default sink, or null - "tooltip": "Volume: 58%\\nSink: Rockerz 430" -} - -Requires `iw` (for wifi link speed), `iproute2` (for route/ip lookup) -and `pamixer` (for volume/mute) on PATH. -RAM speed detection requires `dmidecode`, which typically needs root privileges -(run as root, grant CAP_SYS_RAWIO, or add a passwordless sudo rule) — if it's -unavailable or unreadable, "speed_mhz" is simply reported as null. -""" -import json -import os -import re -import subprocess -import threading -import time - -STATE_LOCK = threading.Lock() -STATE = { - "cpu": { - "percent": 0, - "per_core": [], - "freq_mhz": None, - "load_avg": None, - "tooltip": "CPU: 0%", - }, - "ram": { - "percent": 0, - "used": "0B", - "total": "0B", - "available": "0B", - "swap_percent": 0, - "swap_used": "0B", - "swap_total": "0B", - "speed_mhz": None, - "tooltip": "RAM: 0%", - }, - "net": { - "status": "down", - "tooltip": "Network Offline", - "routes": [], - }, - "sound": { - "percent": None, - "mute": False, - "sink": None, - "tooltip": "Volume: unknown", - }, -} - -# Module-level tracking for throughput calculations -NET_LAST_TIME = None -NET_LAST_BYTES = {} # {iface: (rx_bytes, tx_bytes)} - - -def format_rate(bytes_per_sec): - """Format bytes/sec into an ultra-compact 3-5 character string (e.g., '500B', '1.2M').""" - if bytes_per_sec is None or bytes_per_sec < 0: - return "0B" - - units = ["B", "K", "M", "G", "T"] - val = float(bytes_per_sec) - unit_idx = 0 - - while val >= 1024 and unit_idx < len(units) - 1: - val /= 1024.0 - unit_idx += 1 - - unit = units[unit_idx] - - if val >= 100 or unit == "B": - return f"{int(val)}{unit}" - else: - return f"{val:.1f}{unit}" - - -def format_bytes(kib): - """Format an absolute value given in KiB into a compact human-readable - string (e.g., '6.2G', '512M'). Same style as format_rate, but for - absolute quantities rather than a per-second rate.""" - if kib is None or kib < 0: - return "0B" - - units = ["K", "M", "G", "T"] - val = float(kib) - unit_idx = 0 - - while val >= 1024 and unit_idx < len(units) - 1: - val /= 1024.0 - unit_idx += 1 - - unit = units[unit_idx] - - if val >= 100 or unit == "K": - return f"{int(val)}{unit}" - else: - return f"{val:.1f}{unit}" - - -def get_iface_io_bytes(iface): - """Read cumulative rx/tx bytes for a specific interface from /proc/net/dev.""" - if not iface: - return None, None - try: - with open("/proc/net/dev") as f: - for line in f: - if ":" not in line: - continue - dev, data = line.split(":", 1) - if dev.strip() == iface: - fields = data.split() - return int(fields[0]), int(fields[8]) - except (OSError, ValueError, IndexError): - pass - return None, None - - -def read_cpu_times(): - """Return {'cpu': (idle, total), 'cpu0': (idle, total), ...} for the - aggregate and every individual core, parsed from /proc/stat.""" - times = {} - with open("/proc/stat") as f: - for line in f: - if not line.startswith("cpu"): - break - parts = line.split() - label = parts[0] - vals = list(map(int, parts[1:])) - idle = vals[3] + vals[4] - total = sum(vals) - times[label] = (idle, total) - return times - - -def get_cpu_freq_mhz(): - """Average current CPU frequency (MHz) across cores, from /proc/cpuinfo.""" - freqs = [] - try: - with open("/proc/cpuinfo") as f: - for line in f: - if line.lower().startswith("cpu mhz"): - try: - freqs.append(float(line.split(":", 1)[1].strip())) - except ValueError: - pass - except OSError: - pass - if not freqs: - return None - return round(sum(freqs) / len(freqs)) - - -def get_load_avg(): - try: - with open("/proc/loadavg") as f: - parts = f.read().split() - return [float(parts[0]), float(parts[1]), float(parts[2])] - except (OSError, ValueError, IndexError): - return None - - -def get_ram_speed_mhz(): - """Query DMI tables (SMBIOS type 17) for the configured/current memory - speed via dmidecode. Requires root — returns None if dmidecode is - missing, unreadable (permission denied), or reports no populated slots. - This only needs to be queried once at startup since it doesn't change.""" - try: - out = subprocess.check_output( - ["dmidecode", "-t", "17"], text=True, stderr=subprocess.DEVNULL - ) - except (subprocess.CalledProcessError, FileNotFoundError, PermissionError): - return None - - speeds = [] - for line in out.splitlines(): - line = line.strip() - if line.startswith("Configured Memory Speed:"): - val = line.split(":", 1)[1].strip() - if val.lower() in ("unknown", "no module installed"): - continue - try: - speeds.append(int(val.split()[0])) - except (ValueError, IndexError): - continue - - if not speeds: - # Fall back to rated "Speed" if no configured speed was reported - for line in out.splitlines(): - line = line.strip() - if line.startswith("Speed:"): - val = line.split(":", 1)[1].strip() - if val.lower() in ("unknown", "no module installed"): - continue - try: - speeds.append(int(val.split()[0])) - except (ValueError, IndexError): - continue - - return max(speeds) if speeds else None - - -def build_cpu_tooltip(overall_pct, per_core_list, freq_mhz, load_avg): - lines = [f"CPU: {overall_pct}%"] - if freq_mhz is not None: - lines.append(f"Freq: {freq_mhz} MHz") - if load_avg is not None: - l1, l5, l15 = load_avg - lines.append(f"Load: {l1:.2f} {l5:.2f} {l15:.2f}") - for i, pct in enumerate(per_core_list): - lines.append(f"Core {i}: {pct}%") - return "\n".join(lines) - - -def build_ram_tooltip(ram_pct, used, total, available, swap_pct, swap_used, swap_total, speed_mhz): - lines = [f"RAM: {ram_pct}% ({used} / {total})"] - lines.append(f"Available: {available}") - lines.append(f"Swap: {swap_pct}% ({swap_used} / {swap_total})") - lines.append(f"Speed: {speed_mhz} MHz" if speed_mhz is not None else "Speed: unknown") - return "\n".join(lines) - - -def build_sound_tooltip(percent, mute, sink): - vol = f"{percent}%" if percent is not None else "unknown" - lines = [f"Muted ({vol})" if mute else f"Volume: {vol}"] - if sink: - lines.append(f"Sink: {sink}") - return "\n".join(lines) - - -def get_sound_state(): - """Query the default sink's volume, mute state, and description via - pamixer. Any unavailable field is reported as None/False.""" - def pamixer_flag(flag): - try: - return subprocess.check_output( - ["pamixer", flag], text=True, stderr=subprocess.DEVNULL - ).strip() - except (subprocess.CalledProcessError, FileNotFoundError): - return None - - percent, mute, sink = None, None, None - - out = pamixer_flag("--get-volume") - if out: - try: - percent = max(0, min(100, int(out))) - except ValueError: - percent = None - - out = pamixer_flag("--get-mute") - if out is not None: - mute = out.lower() == "true" - - out = pamixer_flag("--get-default-sink") - if out: - # Output ends with: INDEX "name" "description" - names = re.findall(r'"([^"]*)"', out) - sink = names[-1] if names else out.splitlines()[-1] - - return { - "percent": percent, - "mute": mute, - "sink": sink, - "tooltip": build_sound_tooltip(percent, mute, sink), - } - - -def cpu_ram_loop(interval=2.0): - prev_times = read_cpu_times() - # Static for the life of the process — no need to shell out every tick. - ram_speed_mhz = get_ram_speed_mhz() - - while True: - time.sleep(interval) - - # --- CPU --- - cur_times = read_cpu_times() - overall_pct = 0 - per_core = {} - for label, (idle, total) in cur_times.items(): - prev_idle, prev_total = prev_times.get(label, (idle, total)) - d_idle = idle - prev_idle - d_total = total - prev_total - pct = round((1 - d_idle / d_total) * 100) if d_total else 0 - if label == "cpu": - overall_pct = pct - else: - per_core[int(label[len("cpu"):])] = pct - prev_times = cur_times - per_core_list = [per_core[i] for i in sorted(per_core)] - - freq_mhz = get_cpu_freq_mhz() - load_avg = get_load_avg() - cpu_state = { - "percent": overall_pct, - "per_core": per_core_list, - "freq_mhz": freq_mhz, - "load_avg": load_avg, - "tooltip": build_cpu_tooltip(overall_pct, per_core_list, freq_mhz, load_avg), - } - - # --- RAM --- - with open("/proc/meminfo") as f: - meminfo = {} - for line in f: - k, v, *_ = line.split() - meminfo[k.rstrip(":")] = int(v) - - total_kb = meminfo.get("MemTotal", 0) - avail_kb = meminfo.get("MemAvailable", 0) - used_kb = max(0, total_kb - avail_kb) - ram_pct = round((1 - avail_kb / total_kb) * 100) if total_kb else 0 - - swap_total_kb = meminfo.get("SwapTotal", 0) - swap_free_kb = meminfo.get("SwapFree", 0) - swap_used_kb = max(0, swap_total_kb - swap_free_kb) - swap_pct = round((swap_used_kb / swap_total_kb) * 100) if swap_total_kb else 0 - - used_fmt = format_bytes(used_kb) - total_fmt = format_bytes(total_kb) - avail_fmt = format_bytes(avail_kb) - swap_used_fmt = format_bytes(swap_used_kb) - swap_total_fmt = format_bytes(swap_total_kb) - ram_state = { - "percent": ram_pct, - "used": used_fmt, - "total": total_fmt, - "available": avail_fmt, - "swap_percent": swap_pct, - "swap_used": swap_used_fmt, - "swap_total": swap_total_fmt, - "speed_mhz": ram_speed_mhz, - "tooltip": build_ram_tooltip( - ram_pct, used_fmt, total_fmt, avail_fmt, - swap_pct, swap_used_fmt, swap_total_fmt, ram_speed_mhz, - ), - } - - # --- Network --- - net_status = check_net_status() - - with STATE_LOCK: - STATE["cpu"] = cpu_state - STATE["ram"] = ram_state - STATE["net"] = net_status - emit() - - -def get_all_default_ifaces(): - """Returns a list of unique interfaces carrying default routes.""" - try: - out = subprocess.check_output( - ["ip", "-o", "route", "show", "default"], - text=True, stderr=subprocess.DEVNULL, - ) - except (subprocess.CalledProcessError, FileNotFoundError): - return [] - - ifaces = [] - for line in out.splitlines(): - parts = line.split() - if "dev" in parts: - iface = parts[parts.index("dev") + 1] - if iface not in ifaces: - ifaces.append(iface) - return ifaces - - -def get_iface_ip(iface): - """Get the primary IPv4 address for a given interface.""" - if not iface: - return None - try: - out = subprocess.check_output( - ["ip", "-o", "-4", "addr", "show", "dev", iface], - text=True, stderr=subprocess.DEVNULL, - ) - for line in out.splitlines(): - parts = line.split() - if "inet" in parts: - cidr = parts[parts.index("inet") + 1] - return cidr.split("/")[0] - except (subprocess.CalledProcessError, FileNotFoundError, IndexError): - pass - return None - - -def get_operstate(iface): - try: - with open(f"/sys/class/net/{iface}/operstate") as f: - return f.read().strip() - except OSError: - return "down" - - -def get_iface_type(iface): - if os.path.exists(f"/sys/class/net/{iface}/wireless"): - return "wifi" - if os.path.isdir(f"/sys/class/net/{iface}/bridge"): - return "bridge" - if os.path.exists(f"/sys/class/net/{iface}/tun_flags"): - return "tun" - return "ethernet" - - -def get_speed_mbps(iface, iftype): - if iftype == "ethernet": - try: - with open(f"/sys/class/net/{iface}/speed") as f: - return int(f.read().strip()) - except (OSError, ValueError): - return None - if iftype == "wifi": - try: - out = subprocess.check_output( - ["iw", "dev", iface, "link"], - text=True, stderr=subprocess.DEVNULL, - ) - except (subprocess.CalledProcessError, FileNotFoundError): - return None - for line in out.splitlines(): - line = line.strip() - if line.startswith("tx bitrate:"): - try: - return round(float(line.split(":", 1)[1].split()[0])) - except (IndexError, ValueError): - return None - return None - - -def check_net_status(): - global NET_LAST_TIME, NET_LAST_BYTES - - ifaces = get_all_default_ifaces() - now = time.monotonic() - - if not ifaces: - NET_LAST_TIME = now - NET_LAST_BYTES = {} - return {"status": "down", "tooltip": "Network Offline", "routes": []} - - routes = [] - any_up = False - any_limited = False - - for iface in ifaces: - oper = get_operstate(iface) - if oper == "up": - any_up = True - elif oper == "dormant": - any_limited = True - - iftype = get_iface_type(iface) - ip_addr = get_iface_ip(iface) - speed = get_speed_mbps(iface, iftype) if oper == "up" else None - - # Live throughput delta calculation per interface - rx_bytes, tx_bytes = get_iface_io_bytes(iface) - rx_str, tx_str = "0B", "0B" - - if oper != "down" and rx_bytes is not None and tx_bytes is not None: - if NET_LAST_TIME is not None and iface in NET_LAST_BYTES: - dt = now - NET_LAST_TIME - if dt > 0: - prev_rx, prev_tx = NET_LAST_BYTES[iface] - rx_rate = max(0, (rx_bytes - prev_rx) / dt) - tx_rate = max(0, (tx_bytes - prev_tx) / dt) - rx_str = format_rate(rx_rate) - tx_str = format_rate(tx_rate) - NET_LAST_BYTES[iface] = (rx_bytes, tx_bytes) - else: - rx_str, tx_str = None, None - - routes.append({ - "iface": iface, - "ip": ip_addr, - "type": iftype, - "speed": speed, - "rx": rx_str, - "tx": tx_str, - }) - - NET_LAST_TIME = now - - # Global status aggregate - if any_up: - overall_status = "up" - elif any_limited: - overall_status = "limited" - else: - overall_status = "down" - - # Build tooltip: first line with speed (rx/tx), then each iface: ip - if overall_status == "down" or not routes: - tooltip = "Network Offline" - else: - first = routes[0] - rx = first.get("rx") or "-" - tx = first.get("tx") or "-" - tooltip = f"↓{rx} ↑{tx}" - for r in routes: - iface = r.get("iface") or "unknown" - ip = r.get("ip") or "no ip" - tooltip += f"\n{iface}: {ip}" - - return {"status": overall_status, "tooltip": tooltip, "routes": routes} - - -def net_loop(): - """Event-driven listener for link state transitions.""" - proc = subprocess.Popen( - ["inotifywait", "-m", "-e", "modify", "/run/systemd/netif/state"], - stdout=subprocess.PIPE, text=True, bufsize=1, - ) - for _ in proc.stdout: - with STATE_LOCK: - STATE["net"] = check_net_status() - emit() - - -def sound_loop(interval=1.0): - """Poll the default audio sink for volume/mute changes.""" - while True: - time.sleep(interval) - with STATE_LOCK: - STATE["sound"] = get_sound_state() - emit() - - -def emit(): - with STATE_LOCK: - print(json.dumps(STATE), flush=True) - - -def main(): - with STATE_LOCK: - STATE["net"] = check_net_status() - emit() - - threading.Thread(target=cpu_ram_loop, daemon=True).start() - threading.Thread(target=net_loop, daemon=True).start() - threading.Thread(target=sound_loop, daemon=True).start() - threading.Event().wait() - - -if __name__ == "__main__": - main() diff --git a/modules/desktop/eww/workspaces.py b/modules/desktop/eww/workspaces.py deleted file mode 100644 index 4019a17..0000000 --- a/modules/desktop/eww/workspaces.py +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env python3 -"""workspaces: emit workspace list with each workspace's focused-app icon, -driven by niri's event-stream instead of polling.""" -import json -import subprocess -import gi -from gi.repository import Gio, Gtk -gi.require_version("Gtk", "3.0") - -icon_theme = Gtk.IconTheme.get_default() -_icon_cache: dict[str, str] = {} - - -def try_desktop_app_info(desktop_id: str): - try: - return Gio.DesktopAppInfo.new(desktop_id) - except TypeError: - return None - - -def resolve_icon_path(app_id: str) -> str: - if not app_id: - return "" - if app_id in _icon_cache: - return _icon_cache[app_id] - - app_info = try_desktop_app_info(f"{app_id}.desktop") \ - or try_desktop_app_info(f"{app_id.lower()}.desktop") - - if app_info is None: - for info in Gio.AppInfo.get_all(): - if isinstance(info, Gio.DesktopAppInfo): - wm_class = info.get_startup_wm_class() - if wm_class and wm_class.lower() == app_id.lower(): - app_info = info - break - - icon_path = "" - gicon = app_info.get_icon() if app_info else None - if gicon is not None: - lookup = icon_theme.lookup_by_gicon(gicon, 24, Gtk.IconLookupFlags.FORCE_SIZE) - if lookup: - icon_path = lookup.get_filename() or "" - if not icon_path: - lookup = icon_theme.lookup_icon(app_id, 24, Gtk.IconLookupFlags.FORCE_SIZE) - if lookup: - icon_path = lookup.get_filename() or "" - - _icon_cache[app_id] = icon_path - return icon_path - - -def niri_json(*args): - out = subprocess.run(["niri", "msg", "-j", *args], capture_output=True, text=True, check=True) - return json.loads(out.stdout) - - -def emit_workspaces(): - workspaces = niri_json("workspaces") - windows = {w["id"]: w for w in niri_json("windows")} - - def window_sort_key(w): - # order windows as they appear in the columns; floating windows last - pos = (w.get("layout") or {}).get("pos_in_scrolling_layout") - if pos is not None: - return (0, pos[0], pos[1]) - return (1, w["id"], 0) - - result = [] - for ws in sorted(workspaces, key=lambda w: w["idx"]): - active_win = windows.get(ws.get("active_window_id")) - app_id = (active_win or {}).get("app_id") or "" - - ws_name = ws.get("name") or f"Workspace {ws['idx']}" - - ws_windows = sorted( - (w for w in windows.values() if w.get("workspace_id") == ws["id"]), - key=window_sort_key, - ) - lines = [ws_name] + [ - f"{i}. {(w.get('app_id') or 'unknown')}: {w.get('title') or '(untitled)'}" - for i, w in enumerate(ws_windows, start=1) - ] - tooltip = "\n".join(lines) if ws_windows else ws_name - - result.append({ - "idx": ws["idx"], - "id": ws["id"], - "is_active": ws.get("is_active", False), - "icon": resolve_icon_path(app_id), - "tooltip": tooltip, - }) - print(json.dumps(result), flush=True) - - -RELEVANT_EVENTS = { - "WorkspacesChanged", "WorkspaceActivated", "WorkspaceUrgencyChanged", - "WorkspaceActiveWindowChanged", "WindowOpenedOrChanged", "WindowClosed", -} - - -def main(): - emit_workspaces() - proc = subprocess.Popen( - ["niri", "msg", "-j", "event-stream"], - stdout=subprocess.PIPE, text=True, bufsize=1, - ) - for line in proc.stdout: - try: - key = next(iter(json.loads(line))) - except (json.JSONDecodeError, StopIteration): - continue - if key in RELEVANT_EVENTS: - try: - emit_workspaces() - except subprocess.CalledProcessError: - pass - - -if __name__ == "__main__": - main() diff --git a/modules/desktop/eww/yuck.nix b/modules/desktop/eww/yuck.nix index 353c094..4f6c158 100644 --- a/modules/desktop/eww/yuck.nix +++ b/modules/desktop/eww/yuck.nix @@ -8,21 +8,19 @@ config = lib.mkIf config.desktop.enable { programs.eww.yuckConfig = let - mkPythonScript = + mkLuaScript = { name, path, description ? "", - python ? pkgs.python3, - pythonPackages ? (ps: [ ]), + lua ? pkgs.luajit, + luaPackages ? (_: [ ]), deps ? [ ], gobjectIntrospection ? false, }: let - pythonEnv = python.withPackages pythonPackages; - pathPrefix = lib.concatStringsSep ":" ( - [ "${pythonEnv}/bin" ] ++ map (p: "${lib.getBin p}/bin") deps - ); + luaEnv = lua.withPackages luaPackages; + pathPrefix = lib.concatStringsSep ":" ([ "${luaEnv}/bin" ] ++ map (p: "${lib.getBin p}/bin") deps); in pkgs.stdenv.mkDerivation { pname = name; @@ -61,10 +59,16 @@ ''; }; - system-stats = mkPythonScript { + system-stats = mkLuaScript { name = "eww-system-stats"; - path = ./system-stats.py; + path = ./system-stats.lua; description = "Emit {cpu, ram, net} as JSON."; + luaPackages = ( + ps: [ + ps.lua-cjson + ps.luaposix + ] + ); deps = [ pkgs.iw pkgs.iproute2 @@ -74,29 +78,24 @@ ]; }; - workspaces = mkPythonScript { - name = "eww-workspaces"; - path = ./workspaces.py; - description = "Emit workspace list with each focused-app icon, driven by niri's event-stream."; - pythonPackages = (ps: [ ps.pygobject3 ]); - deps = [ config.desktop.niri.package ]; - gobjectIntrospection = true; - }; - - activate-windows = mkPythonScript { - name = "eww-activate-windows"; - path = ./activate-windows.py; - description = "Emit icons for windows on the active niri workspace via event-stream."; - pythonPackages = (ps: [ ps.pygobject3 ]); + niri-status = mkLuaScript { + name = "niri-status"; + path = ./niri-status.lua; + description = "Emit workspace and active window information list."; + luaPackages = ( + ps: [ + ps.lgi + ps.lua-cjson + ] + ); deps = [ config.desktop.niri.package ]; gobjectIntrospection = true; }; in builtins.replaceStrings [ - "@@workspaces-script@@" - "@@windows-script@@" - "@@systemstats-script@@" + "@@niri-status-script@@" + "@@system-stats-script@@" "@@icon-offline@@" "@@icon-wifi@@" "@@icon-ethernet@@" @@ -107,8 +106,7 @@ "@@icon-calendar@@" ] [ - (lib.getExe workspaces) - (lib.getExe activate-windows) + (lib.getExe niri-status) (lib.getExe system-stats) (toString ../../../assets/icons/bar/offline.svg) (toString ../../../assets/icons/bar/wifi.svg) -- cgit v1.2.3