vpn eww and eww crash

This commit is contained in:
2026-09-10 21:02:27 +02:00
parent 7bb27f9373
commit a53f120b63
14 changed files with 246 additions and 51 deletions
@@ -34,3 +34,10 @@
.bt-device-btn:hover { @include background-active; }
.net-toggle-on, .bt-btn-on { @include color-base; }
.net-toggle-off, .bt-btn-off { @include color-inactive; }
// WireGuard
.wg-accent { background-color: $base0E; }
.wg-row { margin-bottom: $popup-scale * 4pt; }
.wg-name { font-size: 0.78em; font-weight: bold; }
.wg-up { @include color-base; }
.wg-down { @include color-inactive; }
@@ -4,8 +4,8 @@
(defwidget clock-mod []
(module
(eventbox
:onhover "${EWW_CMD} update date_rev=true"
:onhoverlost "${EWW_CMD} update date_rev=false"
:onhover "${EWW_CMD} --no-daemonize update date_rev=true"
:onhoverlost "${EWW_CMD} --no-daemonize update date_rev=false"
:onclick "scripts/panel-toggle clock"
:onrightclick "scripts/powermenu-toggle"
(box
@@ -1,5 +1,5 @@
(deflisten net
:initial '{"wifi":{"connected":false,"enabled":false,"icon":"󰤮","ssid":"","ip":"","freq":0,"band":"","gen":"","signal":0},"ethernet":{"connected":false,"interface":"","ip":"","speed":""},"usb":{"connected":false,"interface":"","ip":""}}'
:initial '{"wifi":{"connected":false,"enabled":false,"icon":"󰤮","ssid":"","ip":"","freq":0,"band":"","gen":"","signal":0},"ethernet":{"connected":false,"interface":"","ip":"","speed":""},"usb":{"connected":false,"interface":"","ip":""},"wireguard":[],"vpn":{"up":false,"name":""}}'
"scripts/net/net")
(deflisten bt
@@ -20,6 +20,11 @@
:class "net-icon net-active"
:tooltip "USB: Connected"
:text "󰌷")
(label
:visible {net.vpn.up}
:class "net-icon net-active"
:tooltip "VPN: ${net.vpn.name}"
:text "󰖂")
(label
:class "net-icon ${net.wifi.connected ? 'net-active' : 'net-dim'}"
:tooltip {net.wifi.connected ? "WiFi: ${net.wifi.ssid}" : "WiFi: Disconnected"}
@@ -3,6 +3,13 @@
# top of every wrapped script, right after setting PATH.
# ---------------------------------------------------------------------------
# Every eww client call goes through this wrapper. A client waits only 100 ms
# for the daemon's reply; a slower command (opening the popup) would otherwise
# make a plain `eww open` fork a second daemon that takes over the socket —
# the "two bars" bug. With --no-daemonize it is just an error, and the daemon
# still carries out the command.
eww() { command eww --no-daemonize "$@"; }
# Print the payload only when it differs from the previous one.
emit() {
[[ $1 == "${EMIT_LAST-}" ]] && return 0
@@ -70,8 +70,60 @@ state() {
"$w_connected" "$w_enabled" "$w_icon" "$(jstr "$w_ssid")" "$w_ip" "${w_freq:-0}" "$(band "$w_freq")" "$w_gen" "${w_signal:-0}"
printf '"ethernet":{"connected":%s,"interface":"%s","ip":"%s","speed":"%s"},' \
"$e_connected" "$eth" "$e_ip" "$e_speed"
printf '"usb":{"connected":%s,"interface":"%s","ip":"%s"}}' \
printf '"usb":{"connected":%s,"interface":"%s","ip":"%s"},' \
"$u_connected" "$usb" "$u_ip"
wg_json
printf '}'
}
# --- WireGuard: interfaces from the wireguard-<name>.service units (so a
# tunnel that is down still shows up) plus any live wireguard-type link.
# `wg show` needs root, so peer names and the endpoint come from the peer
# units instead.
wg_ifaces() {
{
systemctl list-units --all --plain --no-legend 'wireguard-*.service' 2>/dev/null \
| awk '$1 !~ /-peer-/ { sub(/^wireguard-/, "", $1); sub(/\.service$/, "", $1); print $1 }'
ip -d -o link 2>/dev/null | awk '/ wireguard / { sub(":", "", $2); print $2 }'
} | sort -u
}
human_bytes() {
awk -v b="${1:-0}" 'BEGIN {
split("B KiB MiB GiB TiB", u, " "); i = 1
while (b >= 1024 && i < 5) { b /= 1024; i++ }
printf (i == 1 ? "%d %s" : "%.1f %s"), b, u[i] }'
}
wg_json() { # prints the "wireguard" array and the "vpn" summary
local name up ip rx tx peers endpoint peer_unit peer_script sep="" out="[" vpn_up=false vpn_name=""
while read -r name; do
[[ -n $name ]] || continue
up=false ip="" rx=0 tx=0 endpoint=""
if [[ -d /sys/class/net/$name ]]; then
ip -o link show "$name" 2>/dev/null | grep -q '<[^>]*UP' && up=true
ip=$(ipv4 "$name")
rx=$(<"/sys/class/net/$name/statistics/rx_bytes")
tx=$(<"/sys/class/net/$name/statistics/tx_bytes")
fi
peers=$(systemctl list-units --all --plain --no-legend "wireguard-$name-peer-*.service" 2>/dev/null \
| awk '{ print $1 }' | sed "s/^wireguard-$name-peer-//; s/\.service\$//" | paste -sd, -)
peer_unit=$(systemctl list-units --all --plain --no-legend "wireguard-$name-peer-*.service" 2>/dev/null \
| awk 'NR == 1 { print $1 }')
# NixOS puts `wg set … endpoint "host:port"` in the peer unit's start script
if [[ -n $peer_unit ]]; then
peer_script=$(systemctl show -p ExecStart --value "$peer_unit" 2>/dev/null \
| sed -n 's/.*path=\([^ ;]*\).*/\1/p' | awk 'NR == 1')
[[ -r $peer_script ]] && endpoint=$(grep -o 'endpoint "[^"]*"' "$peer_script" 2>/dev/null \
| awk -F'"' 'NR == 1 { print $2 }')
fi
if [[ $up == true && $vpn_up == false ]]; then vpn_up=true vpn_name=$name; fi
[[ -z $vpn_name ]] && vpn_name=$name
out+="$sep{\"name\":\"$(jstr "$name")\",\"up\":$up,\"ip\":\"$ip\",\"rx\":\"$(human_bytes "$rx")\",\"tx\":\"$(human_bytes "$tx")\",\"peers\":\"$(jstr "$peers")\",\"endpoint\":\"$(jstr "$endpoint")\"}"
sep=,
done < <(wg_ifaces)
printf '"wireguard":%s],"vpn":{"up":%s,"name":"%s"}' "$out" "$vpn_up" "$(jstr "$vpn_name")"
}
refresh() { emit "$(state)"; }
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Bring the WireGuard tunnel $1 down (stop its service, which takes the peer
# units with it) or up (start its target, which pulls service and peers).
# Needs the polkit rule from modules/nixos/system/network/wireguard.
name=$1
[[ -n $name ]] || exit 64
if [[ -d /sys/class/net/$name ]]; then
systemctl stop "wireguard-$name.service"
else
systemctl start "wireguard-$name.target"
fi
@@ -4,6 +4,11 @@
# The popup hosts every panel behind revealers, so switching panels while it
# is open is just a variable update (animated, no scripts restarted). It is
# only closed and re-opened when it has to move to another monitor.
#
# `eww open popup` takes longer than the client's 100 ms reply deadline, so
# its exit status and error output are meaningless and ignored (the daemon
# carries the command out anyway; the lib's eww wrapper makes sure a slow
# reply can never spawn a second daemon).
panel=$1
[[ -n $panel ]] || exit 64
@@ -12,7 +17,7 @@ panel=$1
exec 9>"${XDG_RUNTIME_DIR:-/tmp}/eww-panel-toggle.lock"
flock -n 9 || exit 0
ew() { timeout 5 eww "$@" 9>&-; }
ew() { eww "$@" 9>&-; }
current=$(ew get active-panel 2>/dev/null | tr -d '"')
popup_screen=$(ew get popup-screen 2>/dev/null | tr -d '"')
@@ -21,15 +26,16 @@ if ew active-windows 2>/dev/null | grep -qx 'popup: popup'; then open=1; else op
if ((open)) && [[ $current == "$panel" ]]; then
ew update active-panel=
ew close popup
ew close popup 2>/dev/null
elif ((open)) && [[ -n $screen && $popup_screen == "$screen" ]]; then
ew update active-panel="$panel"
else
((open)) && ew close popup
((open)) && ew close popup 2>/dev/null
ew update active-panel="$panel" popup-screen="$screen"
if [[ -n $screen ]]; then
ew open popup --screen "$screen"
ew open popup --screen "$screen" >/dev/null 2>&1
else
ew open popup
ew open popup >/dev/null 2>&1
fi
fi
exit 0
@@ -1,11 +1,12 @@
#!/usr/bin/env bash
# Toggle the full-screen power menu on the focused monitor.
ew() { timeout 5 eww "$@"; }
ew close popup 2>/dev/null
ew update active-panel=
# Toggle the full-screen power menu on the focused monitor. The open's exit
# status is ignored: it exceeds the client's 100 ms reply deadline.
eww close popup 2>/dev/null
eww update active-panel= 2>/dev/null
screen=$(eww-bar focused)
if [[ -n $screen ]]; then
ew open powermenu --toggle --screen "$screen"
eww open powermenu --toggle --screen "$screen" >/dev/null 2>&1
else
ew open powermenu --toggle
eww open powermenu --toggle >/dev/null 2>&1
fi
exit 0
@@ -83,6 +83,29 @@
:visible {!bt.powered}
:text "Disabled")))
; --- WireGuard ---
(defwidget wg-row [wg]
(box :orientation "v" :space-evenly false :class "wg-row"
(box :orientation "h" :space-evenly false :valign "center"
(label :class "wg-name ${wg.up ? 'wg-up' : 'wg-down'}" :halign "start" :hexpand true :text {wg.name})
(button
:class "net-toggle-btn ${wg.up ? 'net-toggle-on' : 'net-toggle-off'}"
:onclick "scripts/net/wg-toggle ${wg.name}"
:tooltip {wg.up ? "Disconnect" : "Connect"}
(label :text {wg.up ? "󰖂" : "󰦞"})))
(box :orientation "v" :space-evenly false :visible {wg.up}
(netinfo-row :label "IP" :value {wg.ip})
(netinfo-row :label "Peer" :value {wg.endpoint != "" ? "${wg.peers} · ${wg.endpoint}" : wg.peers})
(netinfo-row :label "Traffic" :value "󰇚 ${wg.rx} 󰕒 ${wg.tx}"))
(label :class "netinfo-dim" :halign "start" :visible {!wg.up} :text "Down")))
(defwidget wireguard-net-section []
(box :orientation "v" :space-evenly false :class "sys-section"
(section-header :title "WireGuard" :accent "wg-accent")
(for wg in {net.wireguard}
(wg-row :wg {wg}))))
; --- Root ---
(defwidget net-win []
@@ -94,4 +117,7 @@
(box :class "section-sep")
(wifi-net-section)
(box :class "section-sep")
(box :visible {arraylength(net.wireguard) > 0} :space-evenly false :orientation "v"
(wireguard-net-section)
(box :class "section-sep"))
(bluetooth-net-section)))
@@ -22,7 +22,7 @@
(pm-btn :icon "󰗼" :label "Sign out"
:onclick "hyprctl eval \"hl.dispatch(hl.dsp.exit())\"")
(pm-btn :icon "󰅖" :label "Cancel"
:onclick "eww close powermenu")))))))
:onclick "${EWW_CMD} --no-daemonize close powermenu")))))))
(defwindow powermenu
:monitor 0
@@ -20,6 +20,7 @@ let
"scripts/net/net" = mkScript "net" ./bar/scripts/net/net [ iproute2 wpa_supplicant ];
"scripts/net/bt" = mkScript "bt" ./bar/scripts/net/bt [ jq glib ];
"scripts/net/bt-toggle" = mkScript "bt-toggle" ./bar/scripts/net/bt-toggle [ bluez ];
"scripts/net/wg-toggle" = mkScript "wg-toggle" ./bar/scripts/net/wg-toggle [ ];
"scripts/sys/gpu" = mkScript "gpu" ./bar/scripts/sys/gpu [ custom.amdgpu_top jq ];
"scripts/sys/memory" = mkScript "memory" ./bar/scripts/sys/memory [ ];
"scripts/sys/battery" = mkScript "battery" ./bar/scripts/sys/battery [ ];
@@ -36,6 +36,20 @@ in
};
};
# Let the desktop (eww network panel, scripts/net/wg-toggle) bring the
# tunnel up and down. eww runs as a user service outside the login
# session, where polkit cannot ask for a password, so wheel users may
# start/stop wireguard-* units without one.
security.polkit.extraConfig = ''
polkit.addRule(function(action, subject) {
if (action.id == "org.freedesktop.systemd1.manage-units" &&
subject.isInGroup("wheel") &&
/^wireguard-/.test(action.lookup("unit") || "")) {
return polkit.Result.YES;
}
});
'';
systemd.services."wireguard-wg0-peer-vpn-helcel" = {
after = [ "network-online.target" "nss-lookup.target" ];
bindsTo = [ "network-online.target" ];
+2 -1
View File
@@ -4,6 +4,7 @@
, gnugrep
, gnused
, hyprland
, iproute2
, jq
, procps
, systemd
@@ -15,6 +16,6 @@
# profiles and the user (`eww-bar status`, `eww-bar reset`).
writeShellApplication {
name = "eww-bar";
runtimeInputs = [ coreutils eww gnugrep gnused hyprland jq procps systemd util-linux ];
runtimeInputs = [ coreutils eww gnugrep gnused hyprland iproute2 jq procps systemd util-linux ];
text = builtins.readFile ./eww-bar.sh;
}
+98 -34
View File
@@ -1,9 +1,17 @@
# eww-bar — lifecycle manager for the eww daemon and the bar window.
#
# The daemon is owned by the user unit eww.service. Every window open goes
# through `ensure` first: a plain `eww open` against a dead or stale socket
# silently forks a *second* daemon that takes over the socket path and orphans
# the first daemon's surfaces ("a bar/popup I can't close").
# The daemon is owned by the user unit eww.service. Two eww facts shape this
# script:
#
# * A client waits only 100 ms for the daemon's reply. Any slower command
# (opening the popup, reload) fails on the client side, and a plain
# `eww open` then forks a SECOND daemon that takes over the socket path and
# orphans the first one's surfaces ("two bars", "a popup I can't close").
# Every client call here passes --no-daemonize, which turns that into a
# harmless error; outcomes are verified with `active-windows`, never with
# the exit status.
# * `eww ping` is answered by the IPC thread; `eww active-windows` by the GTK
# main loop. Only the latter proves the UI is alive.
#
# SCREEN is a GDK monitor index (0), a connector name (DP-1) or
# desc:<prefix of the description shown by `hyprctl monitors`>. Descriptions
@@ -23,7 +31,7 @@ usage: eww-bar COMMAND [ARGS]
check health check run by eww-watchdog.timer
clean ExecStartPre: kill stray daemons, wipe stale sockets
focused print the name of the focused Hyprland monitor
status show unit state, open windows and the bar's screen
status show unit state, socket owner, open windows and screen
USAGE
}
@@ -42,6 +50,25 @@ mkdir -p "$state_dir"
log() { printf 'eww-bar: %s\n' "$*" >&2; }
# ---------------------------------------------------------------- client ---
# fd 9 (the lock) is closed for every client call so nothing can inherit it.
ewwc() { timeout 5 eww --no-daemonize "$@" 9>&-; }
ping_daemon() { ewwc ping >/dev/null 2>&1; }
list_windows() { ewwc active-windows 2>/dev/null || true; }
bar_open() { list_windows | grep -qx 'bar: bar'; }
# wait_for_window NAME [SECONDS]
wait_for_window() {
local i n=$((${2:-5} * 5))
for ((i = 0; i < n; i++)); do
list_windows | grep -qx "$1: $1" && return 0
sleep 0.2
done
return 1
}
# ---------------------------------------------------------------- daemon ---
# nixpkgs wraps eww (wrapGAppsHook), so the running process is `.eww-wrapped`;
@@ -60,18 +87,33 @@ unit_running() {
*) return 1 ;;
esac
}
main_pid() { systemctl --user show -p MainPID --value "$unit" 2>/dev/null || echo 0; }
# `eww ping` is answered by the IPC thread and only proves the socket is alive.
ping_daemon() { timeout 3 eww ping >/dev/null 2>&1 9>&-; }
# pids listening on an eww socket path. After a rival rebinds the path, ss
# lists BOTH sockets under the same name (the old one is merely unlinked), so
# every pid matters, not just the first.
socket_owners() {
ss -xlp 2>/dev/null | grep -F "$runtime_dir/eww-server_" \
| sed -n 's/.*pid=\([0-9]*\).*/\1/p' | sort -u | tr '\n' ' '
}
# `active-windows` is answered from the GTK main loop, so it also detects a
# frozen UI: the reply is then empty instead of listing the windows.
# fd 9 is closed for every client call so a daemonizing client (which can
# only happen when the socket is dead) can never inherit our lock.
list_windows() { timeout 5 eww active-windows 2>/dev/null 9>&- || true; }
bar_open() { list_windows | grep -qx 'bar: bar'; }
# The socket file is gone while the daemon lives on: a rival that crashed at
# startup unlinked it, and no client can reach the daemon any more.
socket_missing() { ! ls "$runtime_dir"/eww-server_* >/dev/null 2>&1; }
daemon_age() { ps -o etimes= -p "$(main_pid)" 2>/dev/null | tr -d " " || echo 0; }
healthy() { unit_running && ping_daemon; }
# pids of daemons other than the service's main process listening on the socket
rival_pids() {
local main pid
main=$(main_pid)
for pid in $(socket_owners); do
[[ $pid != "$main" ]] && printf '%s ' "$pid"
done
return 0
}
rival_daemon() { [[ -n $(rival_pids) ]]; }
healthy() { unit_running && ping_daemon && ! rival_daemon; }
wait_for_daemon() {
local i
@@ -98,10 +140,13 @@ ensure() {
(
flock -x 9
healthy && exit 0
# The unit is (re)starting — possibly running us from ExecStartPost —
# so give the daemon time to bind before declaring it dead.
if unit_running && wait_for_daemon; then exit 0; fi
log "daemon unreachable, restarting $unit"
if rival_daemon; then
log "a second eww daemon (pid $(rival_pids)) owns the socket"
elif unit_running && wait_for_daemon && ! rival_daemon; then
# The unit was (re)starting — possibly running us from ExecStartPost.
exit 0
fi
log "daemon unhealthy, restarting $unit"
systemctl --user stop "$unit" >/dev/null 2>&1 || true
kill_stray_daemons
start_unit
@@ -142,9 +187,9 @@ current_screen() { cat "$current_file" 2>/dev/null || true; }
close_popups() {
local w
for w in popup powermenu; do
timeout 3 eww close "$w" >/dev/null 2>&1 9>&- || true
list_windows | grep -qx "$w: $w" && { ewwc close "$w" >/dev/null 2>&1 || true; }
done
timeout 3 eww update active-panel= >/dev/null 2>&1 9>&- || true
ewwc update active-panel= >/dev/null 2>&1 || true
}
# open_bar [--force] [SPEC]
@@ -161,17 +206,21 @@ open_bar() {
exit 0
fi
# Popups may sit on an output that just vanished; `eww open` itself
# replaces an already-open bar.
# replaces an already-open bar. The exit status is meaningless (100 ms
# client deadline), so the result is verified instead.
close_popups
if ! timeout 10 eww open bar --screen "$screen" >/dev/null 9>&-; then
if [[ $screen == "$default_screen" ]]; then
log "eww open failed, restarting $unit"
ewwc open bar --screen "$screen" >/dev/null 2>&1 || true
if ! wait_for_window bar 5; then
if [[ $screen != "$default_screen" ]]; then
log "cannot open the bar on '$screen', using screen $default_screen"
screen=$default_screen
ewwc open bar --screen "$screen" >/dev/null 2>&1 || true
fi
if ! wait_for_window bar 5; then
log "the bar does not come up, restarting $unit"
restart_unit
exit 1
fi
log "cannot open the bar on '$screen', using screen $default_screen"
screen=$default_screen
timeout 10 eww open bar --screen "$screen" >/dev/null 9>&- || { restart_unit; exit 1; }
fi
printf '%s\n' "$spec" >"$screen_file"
printf '%s\n' "$screen" >"$current_file"
@@ -181,16 +230,20 @@ open_bar() {
close_bar() {
close_popups
timeout 3 eww close bar >/dev/null 2>&1 9>&- || true
ewwc close bar >/dev/null 2>&1 || true
rm -f "$current_file"
}
reload() {
ensure || return 1
if ! timeout 15 eww reload >/dev/null 9>&-; then
log "reload failed, restarting $unit"
# Slow: the client always hits its deadline, so ignore the status and
# verify that the daemon is still with us afterwards.
ewwc reload >/dev/null 2>&1 || true
sleep 1
if ! wait_for_daemon; then
log "daemon lost during reload, restarting $unit"
restart_unit
return
return 0
fi
open_bar --force
}
@@ -234,18 +287,28 @@ check() {
return 0
fi
[[ $(unit_state) == active ]] || return 0 # still starting; ExecStartPost handles it
if rival_daemon; then
log "a second eww daemon (pid $(rival_pids)) owns the socket, restarting $unit"
restart_unit
return 0
fi
if socket_missing && (($(daemon_age) > 15)); then
log "socket file is gone, restarting $unit"
restart_unit
return 0
fi
if ! ping_daemon; then
strike "daemon does not answer"
return 0
fi
if bar_open; then
if wait_for_window bar 3; then
rm -f "$fail_file"
return 0
fi
# Either nothing is open or the main loop is frozen (empty reply). Try to
# open the bar: a frozen daemon still won't list it afterwards.
open_bar || true
if bar_open; then
if wait_for_window bar 3; then
rm -f "$fail_file"
log "bar was missing, reopened"
else
@@ -255,7 +318,8 @@ check() {
status() {
systemctl --user --no-pager status "$unit" 2>&1 | head -n 12 || true
printf '\nwindows:\n'
printf '\nsocket listeners: %s(service main pid %s)\n' "$(socket_owners)" "$(main_pid)"
printf 'windows:\n'
list_windows | sed 's/^/ /'
printf 'screen: %s (requested: %s)\n' "$(current_screen)" "$(saved_screen)"
printf 'strikes: %s\n' "$(cat "$fail_file" 2>/dev/null || echo 0)"