Recovers the crashed iteration #7 (items 21+23, Latitude QA findings): - nomarchy-waybar supervisor (waybar.nix, exec-once'd from hyprland.nix): any waybar exit respawns the bar; crash-loop guard (5 fast exits -> critical notify + stop); TERM trapped for a real stop. Fixes the bar-less session after a theme-switch crash. - nomarchy-theme-sync prefers a clean `pkill -x waybar` (supervisor restart with fresh config+style) over the crash-prone in-place SIGUSR2 reload when the supervisor is running; SIGUSR2 stays as the unsupervised fallback. - sys-rebuild: snapshot-first system rebuild against the CURRENT lock (no `nix flake update`) — the no-update twin of sys-update; README §3/§5 + motd list it. Verified: V0 re-run green after crash recovery (flake check --no-build + py_compile); V1/V2 per the crashed session's journal entry (HM renders exec-once=nomarchy-waybar; headless software-GL VM: SIGKILL -> new pid, clean kill -> respawn, SIGUSR2 -> alive). V3 pending on the Latitude (queued in HARDWARE-QUEUE.md). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
347 lines
13 KiB
Nix
347 lines
13 KiB
Nix
# Waybar — two-tier theming:
|
|
#
|
|
# 1. Default: structure, fonts, geometry AND palette baked from
|
|
# theme-state.json (colors as GTK named colors, @define-color).
|
|
#
|
|
# 2. Whole-swap: themes with their own visual identity ship
|
|
# <themesDir>/<slug>/waybar.css (and optionally waybar.jsonc) which
|
|
# replace the generated style/layout entirely. Probed at eval time —
|
|
# pure, it's flake source.
|
|
{ config, lib, pkgs, ... }:
|
|
|
|
let
|
|
t = config.nomarchy.theme;
|
|
|
|
# Show the active-keyboard-layout indicator only when more than one layout
|
|
# is in play — multiple session layouts (comma-separated) or per-device
|
|
# overrides (nomarchy.keyboard.devices) — so single-layout bars stay clean.
|
|
showLanguage = lib.hasInfix "," config.nomarchy.keyboard.layout
|
|
|| config.nomarchy.keyboard.layouts != [ ]
|
|
|| config.nomarchy.keyboard.devices != { };
|
|
|
|
# Power-profile indicator (power-profiles-daemon). Both scripts self-
|
|
# gate: they exit silently unless this is a laptop (a battery is
|
|
# present) running PPD, so the module hides itself on desktops and
|
|
# under the TLP backend — no system→home coupling. Speedometer glyphs
|
|
# (slow/medium/fast) map to power-saver/balanced/performance.
|
|
#
|
|
# Named writeShellScriptBins (put on PATH via home.packages below) rather
|
|
# than bare writeShellScript store paths, so the whole-swap themes' static
|
|
# waybar.jsonc can exec them by name too — same as the swaync bell.
|
|
# Waybar supervisor — exec-once has no restart, so a crashed bar used to
|
|
# leave the session bar-less until relogin (seen on hardware: a theme
|
|
# switch crashed waybar mid-reload). Respawns on ANY exit — a plain
|
|
# `pkill -x waybar` is now a clean restart, which nomarchy-theme-sync
|
|
# uses instead of the crash-prone in-place SIGUSR2 reload when it sees
|
|
# this supervisor running. Crash-loop guard: 5 exits within 10s of
|
|
# their start → give up with a critical notification instead of
|
|
# spinning. Stop the bar for real: pkill -f nomarchy-waybar (TERM is
|
|
# trapped to take the child down too).
|
|
waybarSupervisor = pkgs.writeShellScriptBin "nomarchy-waybar" ''
|
|
child=
|
|
trap '[ -n "$child" ] && kill "$child" 2>/dev/null; exit 0' TERM INT
|
|
fails=0
|
|
while :; do
|
|
start=$(date +%s)
|
|
waybar & child=$!
|
|
wait "$child"; code=$?
|
|
if [ $(( $(date +%s) - start )) -lt 10 ]; then
|
|
fails=$((fails + 1))
|
|
if [ "$fails" -ge 5 ]; then
|
|
notify-send -u critical "Waybar" \
|
|
"Crashing on start (exit $code) — check ~/.config/waybar. Giving up." 2>/dev/null
|
|
exit 1
|
|
fi
|
|
else
|
|
fails=0
|
|
fi
|
|
sleep 1
|
|
done
|
|
'';
|
|
|
|
powerProfileStatus = pkgs.writeShellScriptBin "nomarchy-powerprofile-status" ''
|
|
case "$(ls /sys/class/power_supply/ 2>/dev/null)" in *BAT*) ;; *) exit 0 ;; esac
|
|
command -v powerprofilesctl >/dev/null 2>&1 || exit 0
|
|
prof=$(powerprofilesctl get 2>/dev/null) || exit 0
|
|
case "$prof" in
|
|
power-saver) icon="" ;;
|
|
performance) icon="" ;;
|
|
*) icon="" ;;
|
|
esac
|
|
printf '{"text":"%s","tooltip":"Power profile: %s","class":"%s"}\n' "$icon" "$prof" "$prof"
|
|
'';
|
|
# Cycle to the next profile this machine actually offers (wraps around).
|
|
powerProfileCycle = pkgs.writeShellScriptBin "nomarchy-powerprofile-cycle" ''
|
|
command -v powerprofilesctl >/dev/null 2>&1 || exit 0
|
|
cur=$(powerprofilesctl get 2>/dev/null) || exit 0
|
|
avail=$(powerprofilesctl list 2>/dev/null | sed -nE 's/^[* ] ([a-z-]+):$/\1/p')
|
|
next=$(printf '%s\n' "$avail" | awk -v cur="$cur" '
|
|
{ a[NR] = $0 }
|
|
END { for (i = 1; i <= NR; i++) if (a[i] == cur) { print a[i % NR + 1]; f = 1 }
|
|
if (!f && NR) print a[1] }')
|
|
[ -n "$next" ] && powerprofilesctl set "$next"
|
|
'';
|
|
|
|
# VPN indicator — shows a shield when a NetworkManager VPN/WireGuard
|
|
# connection is active OR Tailscale is up; prints nothing otherwise so the
|
|
# module self-hides (like nightlight/updates). Click opens the VPN submenu.
|
|
vpnStatus = pkgs.writeShellScriptBin "nomarchy-vpn-status" ''
|
|
active=$(nmcli -t -f TYPE connection show --active 2>/dev/null | grep -Exm1 'vpn|wireguard')
|
|
ts=""
|
|
if command -v tailscale >/dev/null 2>&1; then
|
|
[ "$(tailscale status --json 2>/dev/null | jq -r '.BackendState // empty')" = Running ] && ts=1
|
|
fi
|
|
[ -n "$active" ] || [ -n "$ts" ] || exit 0
|
|
printf '{"text":"","tooltip":"VPN active (click to manage)","class":"on"}\n'
|
|
'';
|
|
|
|
# Per-theme override probe.
|
|
assetDir = config.nomarchy.themesDir + "/${t.slug}";
|
|
styleOverride = assetDir + "/waybar.css";
|
|
configOverride = assetDir + "/waybar.jsonc";
|
|
hasStyleOverride = builtins.pathExists styleOverride;
|
|
hasConfigOverride = builtins.pathExists configOverride;
|
|
|
|
# The palette as GTK named colors, straight from the JSON.
|
|
colorDefs = lib.concatStringsSep "\n"
|
|
(lib.mapAttrsToList (name: value: "@define-color ${name} ${value};") t.colors);
|
|
|
|
generatedSettings = {
|
|
layer = "top";
|
|
position = "top";
|
|
height = 34;
|
|
margin-top = t.ui.gapsOut;
|
|
margin-left = t.ui.gapsOut;
|
|
margin-right = t.ui.gapsOut;
|
|
spacing = 8;
|
|
|
|
# waybar re-reads style.css when it changes on disk, so a
|
|
# home-manager switch restyles the running bar without a restart.
|
|
reload_style_on_change = true;
|
|
|
|
modules-left = [ "hyprland/workspaces" "hyprland/window" ];
|
|
modules-center = [ "clock" ];
|
|
modules-right = [ "tray" "custom/vpn" "pulseaudio" "cpu" "memory" "custom/powerprofile" "custom/nightlight" ]
|
|
++ lib.optional showLanguage "hyprland/language"
|
|
++ [ "battery" "custom/updates" "custom/notification" ];
|
|
|
|
"hyprland/workspaces" = {
|
|
format = "{icon}";
|
|
on-click = "activate";
|
|
};
|
|
|
|
"hyprland/window" = {
|
|
max-length = 48;
|
|
separate-outputs = true;
|
|
};
|
|
|
|
clock = {
|
|
format = "{:%H:%M}";
|
|
format-alt = "{:%A %d %B %Y}";
|
|
tooltip-format = "<tt><small>{calendar}</small></tt>";
|
|
};
|
|
|
|
# Active keyboard layout (per focused device) — only placed in
|
|
# modules-right when showLanguage (see above).
|
|
"hyprland/language" = {
|
|
format = " {short}";
|
|
tooltip = false;
|
|
};
|
|
|
|
pulseaudio = {
|
|
format = "{icon} {volume}%";
|
|
format-muted = "";
|
|
format-icons.default = [ "" "" "" ];
|
|
on-click = "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle";
|
|
};
|
|
|
|
# No network module: nm-applet lives in the tray (the GUI path), so the
|
|
# bar's wifi/ethernet indicator would just duplicate it.
|
|
|
|
cpu.format = " {usage}%";
|
|
memory.format = " {percentage}%";
|
|
|
|
battery = {
|
|
states = { warning = 25; critical = 10; };
|
|
format = "{icon} {capacity}%";
|
|
format-charging = " {capacity}%";
|
|
format-icons = [ "" "" "" "" "" ];
|
|
};
|
|
|
|
"custom/powerprofile" = {
|
|
exec = "nomarchy-powerprofile-status";
|
|
return-type = "json";
|
|
interval = 5;
|
|
on-click = "nomarchy-powerprofile-cycle";
|
|
};
|
|
|
|
# VPN shield — self-hides unless a NM VPN/WireGuard tunnel or Tailscale is
|
|
# up. Click opens the VPN submenu (nomarchy-vpn). 5s poll like powerprofile.
|
|
"custom/vpn" = {
|
|
exec = "nomarchy-vpn-status";
|
|
return-type = "json";
|
|
interval = 5;
|
|
on-click = "nomarchy-vpn";
|
|
};
|
|
|
|
# Night-light (hyprsunset) indicator. Self-gates: the moon shows only while
|
|
# the schedule runs; otherwise the status helper prints nothing => hidden
|
|
# (enable / re-enable from the System menu). Click toggles instantly — writes
|
|
# the in-flake on/off (settings.nightlight.on, no rebuild) and flips the unit
|
|
# with systemctl, so the choice lands in the flake and survives reboot.
|
|
"custom/nightlight" = {
|
|
exec = "nomarchy-nightlight status";
|
|
return-type = "json";
|
|
interval = 3;
|
|
on-click = "nomarchy-nightlight toggle";
|
|
};
|
|
|
|
# Update awareness. Self-gates: hidden unless nomarchy.updates is enabled
|
|
# AND the periodic check found something (the helper prints nothing then).
|
|
# signal 9 lets the checker refresh it instantly; click opens the upgrade
|
|
# flow in a terminal.
|
|
"custom/updates" = {
|
|
exec = "nomarchy-updates status";
|
|
return-type = "json";
|
|
interval = 1800;
|
|
signal = 9;
|
|
on-click = "${config.nomarchy.terminal} -e nomarchy-updates upgrade";
|
|
};
|
|
|
|
# swaync notification bell + Do-Not-Disturb state. `-swb` streams JSON
|
|
# (text/tooltip/class) on every change, so it tracks count and DND with
|
|
# no polling. Left-click toggles the panel; right-click toggles DND.
|
|
# The `dnd-*` classes (bell-off glyph) are styled muted below.
|
|
"custom/notification" = {
|
|
exec = "swaync-client -swb";
|
|
return-type = "json";
|
|
exec-if = "which swaync-client";
|
|
format = "{icon}";
|
|
format-icons = {
|
|
none = "";
|
|
notification = "";
|
|
dnd-none = "";
|
|
dnd-notification = "";
|
|
inhibited-none = "";
|
|
inhibited-notification = "";
|
|
dnd-inhibited-none = "";
|
|
dnd-inhibited-notification = "";
|
|
};
|
|
on-click = "swaync-client -t -sw";
|
|
on-click-right = "swaync-client -d -sw";
|
|
tooltip = true;
|
|
escape = true;
|
|
};
|
|
|
|
tray.spacing = 8;
|
|
};
|
|
|
|
generatedStyle = ''
|
|
/* Palette baked from theme-state.json */
|
|
${colorDefs}
|
|
|
|
* {
|
|
font-family: "${t.fonts.ui}", "${t.fonts.mono}";
|
|
font-size: ${toString t.fonts.size}pt;
|
|
min-height: 0;
|
|
}
|
|
|
|
window#waybar {
|
|
background: alpha(@base, 0.85);
|
|
color: @text;
|
|
border: ${toString t.ui.borderSize}px solid alpha(@accent, 0.4);
|
|
border-radius: ${toString t.ui.rounding}px;
|
|
}
|
|
|
|
#workspaces button {
|
|
padding: 0 8px;
|
|
color: @muted;
|
|
border-radius: ${toString t.ui.rounding}px;
|
|
}
|
|
|
|
#workspaces button.active {
|
|
color: @base;
|
|
background: @accent;
|
|
}
|
|
|
|
#workspaces button.urgent {
|
|
color: @base;
|
|
background: @bad;
|
|
}
|
|
|
|
#window {
|
|
color: @subtext;
|
|
padding: 0 12px;
|
|
}
|
|
|
|
#clock {
|
|
color: @text;
|
|
font-weight: bold;
|
|
}
|
|
|
|
#tray, #pulseaudio, #cpu, #memory, #custom-powerprofile, #custom-nightlight, #custom-updates, #custom-vpn, #language, #battery, #custom-notification {
|
|
color: @subtext;
|
|
padding: 0 10px;
|
|
}
|
|
|
|
/* VPN active → accent green, reading as "connected / protected". */
|
|
#custom-vpn.on { color: @good; }
|
|
|
|
/* Night-light active → warm tone, matching the filter it represents. */
|
|
#custom-nightlight.on { color: @warn; }
|
|
|
|
/* Updates pending → accent, to draw the eye. */
|
|
#custom-updates.available { color: @accent; }
|
|
|
|
/* The power-profile speedometer glyph renders small in its em box —
|
|
size it up so it reads at a glance like the other indicators. */
|
|
#custom-powerprofile { font-size: ${toString (t.fonts.size + 3)}pt; }
|
|
|
|
/* notifications waiting → accent; Do-Not-Disturb → muted bell-off */
|
|
#custom-notification.notification { color: @accent; }
|
|
#custom-notification.dnd-none,
|
|
#custom-notification.dnd-notification { color: @muted; }
|
|
|
|
#pulseaudio.muted { color: @muted; }
|
|
#battery.warning { color: @warn; }
|
|
#battery.critical { color: @bad; }
|
|
#battery.charging { color: @good; }
|
|
'';
|
|
in
|
|
{
|
|
programs.waybar = lib.mkIf config.nomarchy.waybar.enable {
|
|
enable = true;
|
|
# Launched from Hyprland's exec-once (hyprland.nix), NOT a systemd user
|
|
# service. Bound to graphical-session.target the unit raced Hyprland's IPC
|
|
# on a warm relogin — it started before the socket was up, exited, landed
|
|
# in `failed`, and was never retried, so the bar vanished. exec-once only
|
|
# fires once Hyprland is up, dodging the race; theme switches reload the
|
|
# running bar via SIGUSR2 (nomarchy-theme-sync). No uwsm here to manage the
|
|
# session target, so we don't depend on its lifecycle.
|
|
systemd.enable = false;
|
|
|
|
# mkDefault so downstream can replace the whole bar config/style with
|
|
# a plain home.nix assignment. For per-theme identity, prefer the
|
|
# whole-swap assets (themes/<slug>/waybar.{jsonc,css}); for one-off
|
|
# tweaks of the generated bar, lib.mkForce a sub-key. See
|
|
# docs/OVERRIDES.md.
|
|
settings.mainBar = lib.mkDefault (
|
|
if hasConfigOverride
|
|
then builtins.fromJSON (builtins.readFile configOverride)
|
|
else generatedSettings
|
|
);
|
|
|
|
style = lib.mkDefault (
|
|
if hasStyleOverride
|
|
then builtins.readFile styleOverride
|
|
else generatedStyle
|
|
);
|
|
};
|
|
|
|
# The power-profile helpers on PATH, so both the generated bar and the
|
|
# whole-swap themes' static waybar.jsonc can exec them by name — plus
|
|
# the supervisor hyprland.nix exec-onces.
|
|
home.packages = lib.optionals config.nomarchy.waybar.enable
|
|
[ waybarSupervisor powerProfileStatus powerProfileCycle vpnStatus ];
|
|
}
|