forked from J-Carder/waybar-apt-updates
-
-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathupdate-checker
More file actions
executable file
·1633 lines (1471 loc) · 78.8 KB
/
Copy pathupdate-checker
File metadata and controls
executable file
·1633 lines (1471 loc) · 78.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env bash
# NixOS Update Checker for Waybar
# This script checks for NixOS updates and outputs JSON for Waybar integration
# ===== Configuration =====
UPDATE_INTERVAL="${UPDATE_INTERVAL:-3599}" # Check interval in seconds (1 hour)
NIXOS_CONFIG_PATH="${NIXOS_CONFIG_PATH:-$HOME/.config/nixos}" # Path to NixOS configuration
CACHE_DIR="${CACHE_DIR:-$HOME/.cache}"
NOTIFICATIONS_ENABLED="${NOTIFICATIONS_ENABLED:-true}" # Set to "false" to disable desktop notifications
# Clock format for tooltip timestamps: "24h" (14:23) or "12h" (2:23 PM)
CLOCK_FORMAT="${CLOCK_FORMAT:-24h}"
# Input checker: "disabled" | "show" | "count"
INPUT_CHECKER_MODE="${INPUT_CHECKER_MODE:-disabled}"
INPUT_CHECKER_PINNED="${INPUT_CHECKER_PINNED:-disabled}"
# Explicit upstream policies for sources the package and input checks cannot
# interpret on their own. An empty list means the source checker never runs.
# Deliberately ${VAR-default}, not ${VAR:-default}: only an *unset* value
# means "no checks configured". An empty value is a broken one, and must
# reach source-checker to be reported rather than passing as a clean result.
SOURCE_CHECKS_JSON="${SOURCE_CHECKS_JSON-[]}"
# On-demand update-cost preview, bound to middle-click when enabled. Off by
# default: a preview is a full system evaluation costing minutes, so it
# never runs on a timer.
DRY_RUN_PREVIEW="${DRY_RUN_PREVIEW:-false}"
# Recompute that preview by itself once it goes stale. Also off by default,
# and it only ever refreshes a preview that already exists - see
# maybe_auto_preview.
PREVIEW_AUTO="${PREVIEW_AUTO:-false}"
# What a detected rebuild does to the package count.
#
# "recheck" discards the old result and checks, so a rebuild that applied
# only some of the pending updates is reported correctly - at the cost of a
# full check, which in this mode builds the new closure. "reconcile"
# subtracts the packages the rebuild demonstrably changed, read from the nvd
# diff already being run to detect it, and keeps the rest; no extra work, but
# it cannot see the channel moving on, so it can undercount until the next
# scheduled check. "assume-updated" reports zero packages without checking.
#
# None of them affect the input, pinned or source sections: a rebuild does
# not resolve those.
AFTER_REBUILD="${AFTER_REBUILD:-recheck}"
case "$AFTER_REBUILD" in
assume-updated|reconcile) ;;
*) AFTER_REBUILD="recheck" ;;
esac
STATE_FILE="$CACHE_DIR/nix-update-state"
LAST_RUN_FILE="$CACHE_DIR/nix-update-last-run"
LAST_RUN_TOOLTIP="$CACHE_DIR/nix-update-tooltip"
BOOT_MARKER_FILE="$CACHE_DIR/nix-update-boot-marker" # Marker file to detect boot/resume
TOGGLE_FILE="$CACHE_DIR/nix-update-toggle" # Toggle file to enable/disable update checking
INPUT_STATE_FILE="$CACHE_DIR/nix-update-input-state" # Input checker state
INPUT_TOOLTIP_FILE="$CACHE_DIR/nix-update-input-tooltip" # Input checker tooltip
PINNED_STATE_FILE="$CACHE_DIR/nix-update-pinned-state" # Pinned input checker state
PINNED_TOOLTIP_FILE="$CACHE_DIR/nix-update-pinned-tooltip" # Pinned input checker tooltip
# Source check results. Cached for the same reason the input and pinned
# results are: a rebuild handled as "assume-updated" has to rebuild the
# tooltip without re-running the checks, and source-checker reaches the
# network.
SOURCE_COUNT_FILE="$CACHE_DIR/nix-update-source-count"
SOURCE_SHOWN_FILE="$CACHE_DIR/nix-update-source-shown"
SOURCE_TOOLTIP_FILE="$CACHE_DIR/nix-update-source-tooltip"
# The reported packages as structured data, not just the rendered line.
# Written by every check so "reconcile" can drop entries by package name
# after a rebuild instead of parsing them back out of display text.
PACKAGES_JSON_FILE="$CACHE_DIR/nix-update-packages.json"
# Set while the link is down, so the "not connected" notification is raised
# once per outage rather than once per poll. notify-send is called without a
# replace id, so an unthrottled notification does not update in place - it
# stacks a new one every few seconds for as long as the link stays down.
NETWORK_DOWN_FLAG="$CACHE_DIR/nix-update-network-down"
# Held for the duration of a check. This mode's check realises the closure
# rather than evaluating it, so its duration is however long the pending
# update takes to fetch and build - which can exceed updateInterval, putting
# a second poll inside the first check. Two of those would fetch and build
# the same paths concurrently and race to write the same result files.
CHECK_LOCK_FILE="$CACHE_DIR/nix-update-check.lock"
# The grace period prevents the update checker from running immediately after:
# 1. First boot - when the system has just started up
# 2. Resume from hibernation/suspension - when the system has just woken up
# This avoids unnecessary resource usage and notifications during these transition periods.
# Read from the environment like every other setting: these were plain
# assignments, which overwrote what the module exports, so skipAfterBoot and
# gracePeriod had no effect in this mode whatever they were set to.
SKIP_AFTER_BOOT="${SKIP_AFTER_BOOT:-true}" # false runs the checker even after boot/resume
GRACE_PERIOD="${GRACE_PERIOD:-60}" # Grace period in seconds after boot/resume
# If true, update the lock file in the config folder.
# If false, resolve a speculative lock beside it and leave yours untouched.
#
# Deliberately still a plain assignment, so the module's updateLockFile is
# inert and every check takes the false branch. The true branch runs `nix
# flake update` in the real configuration directory and builds there,
# rewriting your flake.lock and leaving a ./result behind. It has been dead
# code for long enough that honouring the option would newly enable that on
# configurations already setting it, which is not a change to make quietly.
UPDATE_LOCK_FILE="false"
# If you have a separate script to update your lock file (i.e. "nix flake update" script)
# and you have UPDATE_LOCK_FILE set to "false",
# the UPDATE_FLAG will signal that your lock file has been updated.
# Auto-detection cache files - no flag files or alias modifications needed.
# Stores the last seen /run/current-system path to auto-detect rebuilds.
SYSTEM_PATH_FILE="$CACHE_DIR/nix-update-system-path"
# Stores the flake.lock hash to auto-detect input updates.
FLAKE_LOCK_INPUT_HASH_FILE="$CACHE_DIR/nix-update-flake-lock-input-hash"
# The UPDATING_FLAG signals if upgrade process is currently performing
# This is required to force waybar module to render while we wait for nixos rebuild.
UPDATING_FLAG="$CACHE_DIR/nix-update-updating-flag"
# Set by `refresh` to request a check before the interval is up. A flag
# rather than deleting LAST_RUN_FILE, because that file is also where the
# tooltip's "Last checked" time comes from - removing it to force a check
# would erase the very timestamp the header needs to display.
FORCE_CHECK_FLAG="$CACHE_DIR/nix-update-force-check"
# Fingerprint of the last check's findings, used to expire a preview when
# upstream moves. Written by the checker, read by the preview script.
RESULT_HASH_FILE="$CACHE_DIR/nix-update-result-hash"
# Fingerprint of the packages the configuration declares, used to expire a
# preview when that set changes. Written by the checker, read by the preview
# script.
APP_LIST_HASH_FILE="$CACHE_DIR/nix-update-app-list-hash"
# Result of the last update-cost preview, written by the preview script.
PREVIEW_FILE="$CACHE_DIR/nix-update-preview"
# ===== Initialize Files =====
function init_files() {
# Ensure cache directory exists
mkdir -p "$CACHE_DIR"
# Create the state file if it doesn't exist
if [ ! -f "$STATE_FILE" ]; then
echo "0" > "$STATE_FILE"
fi
# Create the last run file if it doesn't exist
if [ ! -f "$LAST_RUN_FILE" ]; then
echo "0" > "$LAST_RUN_FILE"
fi
# Create the tooltip file if it doesn't exist
if [ ! -f "$LAST_RUN_TOOLTIP" ]; then
updates=$(cat "$STATE_FILE" 2>/dev/null || echo "0")
if [ "$updates" = "0" ] || [ -z "$updates" ]; then
echo "System updated" > "$LAST_RUN_TOOLTIP"
else
# Will be populated during update check
echo "Checking for updates..." > "$LAST_RUN_TOOLTIP"
fi
fi
# Create the toggle file if it doesn't exist (default: enabled)
if [ ! -f "$TOGGLE_FILE" ]; then
echo "enabled" > "$TOGGLE_FILE"
fi
}
# ===== Helper Functions =====
function send_notification() {
# Skip if notifications are disabled
if [ "$NOTIFICATIONS_ENABLED" != "true" ]; then
return 0
fi
# Check if notify-send is available
if ! command -v notify-send >/dev/null 2>&1; then
return 0
fi
local icon="$1"
local title="$2"
local message="$3"
local expire_flag="${4:-}" # Optional fourth parameter for -e flag
local args=("$title" "$message")
if [ -f "$HOME/.icons/$icon.png" ]; then
args=(-i "$HOME/.icons/$icon.png" "${args[@]}")
fi
if [ -n "$expire_flag" ]; then
args+=("$expire_flag")
fi
notify-send "${args[@]}" >/dev/null 2>&1 || true
}
function refresh_waybar() {
pkill -x -RTMIN+12 .waybar-wrapped >/dev/null 2>&1 || true
}
function check_boot_resume() {
# Detects if system recently booted OR resumed from suspend/hibernate
# Returns 0 (true) if we're in grace period, 1 (false) otherwise
local current_time=$(date +%s)
local uptime_seconds=$(awk '{print int($1)}' /proc/uptime)
local last_boot_time=$((current_time - uptime_seconds))
# Check if this is a recent boot
if [ $((current_time - last_boot_time)) -lt "$GRACE_PERIOD" ]; then
echo "$current_time" > "$BOOT_MARKER_FILE"
return 0
fi
# Check for recent suspend/hibernate resume using journalctl
if command -v journalctl >/dev/null 2>&1; then
local last_resume=0
# Method 1: Check sleep.target (most reliable - "Stopped target Sleep" = resume)
local resume_timestamp
resume_timestamp=$(journalctl -b -u sleep.target --output=short-unix 2>/dev/null \
| grep "Stopped target Sleep" | tail -1 | awk '{print int($1)}')
if [[ "$resume_timestamp" =~ ^[0-9]+$ ]] && [ "$resume_timestamp" -gt 0 ]; then
last_resume=$resume_timestamp
fi
# Method 2: Check systemd suspend/hibernate service timestamps
if [ "$last_resume" -eq 0 ]; then
resume_timestamp=$(journalctl -b -u systemd-suspend.service -u systemd-hibernate.service \
-u systemd-hybrid-sleep.service -u systemd-suspend-then-hibernate.service \
--output=short-unix -n 1 2>/dev/null | tail -1 | awk '{print int($1)}')
if [[ "$resume_timestamp" =~ ^[0-9]+$ ]] && [ "$resume_timestamp" -gt 0 ]; then
last_resume=$resume_timestamp
fi
fi
# Method 3: Check kernel PM messages as fallback
if [ "$last_resume" -eq 0 ]; then
resume_timestamp=$(journalctl -b -k --grep="PM: suspend exit\|PM: hibernation exit\|PM: restore" \
--output=short-unix -n 1 2>/dev/null | tail -1 | awk '{print int($1)}')
if [[ "$resume_timestamp" =~ ^[0-9]+$ ]] && [ "$resume_timestamp" -gt 0 ]; then
last_resume=$resume_timestamp
fi
fi
# If we found a recent resume event within grace period, trigger it
if [ "$last_resume" -gt 0 ] && [ $((current_time - last_resume)) -lt "$GRACE_PERIOD" ]; then
echo "$current_time" > "$BOOT_MARKER_FILE"
return 0
fi
fi
# Fallback: Check boot marker for existing grace period
if [ -f "$BOOT_MARKER_FILE" ]; then
local marker_time
marker_time=$(cat "$BOOT_MARKER_FILE")
if [[ "$marker_time" =~ ^[0-9]+$ ]] && [ $((current_time - marker_time)) -lt "$GRACE_PERIOD" ]; then
return 0
fi
fi
# Not in grace period
return 1
}
function check_network_connectivity() {
# Check if we have a default route and an IP on a non-loopback interface
if ip route 2>/dev/null | grep -q "^default" && \
ip -4 addr show 2>/dev/null | grep -E "inet .* scope global" > /dev/null 2>&1; then
return 0 # Network is configured
else
return 1 # No network
fi
}
# Assembles the tooltip body from whichever sections have something to show,
# and prints it. Section labels appear only when more than one section is
# present, so a tooltip with a single section stays as bare lines.
#
# Separate from the check because a rebuild handled as "assume-updated" has
# to produce the same body from cached section state without re-running
# anything. Inlining it there is what let the old early-exit path overwrite
# the tooltip with a bare "System updated", discarding the input, pinned and
# source sections a rebuild does not resolve.
#
# Args: pkg_count pkg_tooltip input_count input_tooltip
# pinned_count pinned_tooltip source_shown source_tooltip
# Package names nvd reports as changed, lowercased, one per line.
#
# nvd's format is "[U.] #02 brave 1.93.129 -> 1.93.138": a bracketed
# marker, an index, the name, then versions. Every marker counts, not just
# [U] - a downgrade, a mixed-version change and a removal all mean the same
# thing here, that the entry being held no longer describes what is
# installed.
function nvd_changed_names() {
sed -nE 's/^\[[A-Z][^]]*\][[:space:]]+#[0-9]+[[:space:]]+([^[:space:]]+)[[:space:]].*/\1/p' \
<<<"$1" | tr '[:upper:]' '[:lower:]' | sort -u
}
# Drops the pending entries a rebuild resolved and prints what is left.
#
# The rule is "the rebuild changed this package at all", not "it reached the
# version we were waiting for". A package whose installed version moved is
# one the rebuild acted on, and the stored entry has stopped describing
# reality either way. Entries nvd never mentions were untouched, so they
# stay. What this cannot see is the channel moving forward since the last
# check, so the surviving count can be an undercount; it never invents a
# resolved entry, and the next scheduled check replaces it with a
# measurement.
function reconcile_packages() {
local pending="$1" nvd_out="$2"
local changed_json
changed_json=$(nvd_changed_names "$nvd_out" \
| jq -R -s 'split("\n") | map(select(length > 0))' 2>/dev/null)
[ -n "$changed_json" ] || { printf '%s' "$pending"; return 0; }
jq -c --argjson changed "$changed_json" \
'[ .[] | select((.name | ascii_downcase) as $n | ($changed | index($n)) == null) ]' \
<<<"$pending" 2>/dev/null
}
# Derives the count, the rendered tooltip and the structured package list
# from a single nvd invocation. The diff used to be run twice per check for
# the first two, at roughly four seconds each.
function record_pending_from_nvd() {
local diff_out="$1"
local changed
changed=$(grep -e '\[U' <<<"$diff_out")
updates=$(grep -c . <<<"$changed")
[[ "$updates" =~ ^[0-9]+$ ]] || updates=0
tooltip=$(awk '{ for (i=3; i<NF; i++) printf $i " "; if (NF >= 3) print $NF; }' <<<"$changed" \
| awk '{printf "%s%s", (NR>1 ? "\\n" : ""), $0}')
# name plus the rendered line, so reconciling is a filter and a join and
# never has to re-derive how an entry was displayed.
awk 'NF >= 3 { line = ""; for (i = 3; i < NF; i++) line = line $i " "; line = line $NF;
printf "%s\t%s\n", tolower($3), line }' <<<"$changed" \
| jq -R -s -c 'split("\n") | map(select(length > 0)) | map(split("\t"))
| map({name: .[0], line: .[1]})' > "$PACKAGES_JSON_FILE" 2>/dev/null
}
function compose_tooltip_body() {
local pkg_count="$1" pkg_tooltip="$2"
local input_count="$3" input_tooltip="$4"
local pinned_count="$5" pinned_tooltip="$6"
local source_shown="$7" source_tooltip="$8"
local labels=() texts=()
if [ "$pkg_count" -gt 0 ]; then
labels+=("Packages"); texts+=("$pkg_tooltip")
fi
if [ "$INPUT_CHECKER_MODE" != "disabled" ] && [ "$input_count" -gt 0 ]; then
labels+=("Inputs"); texts+=("$input_tooltip")
fi
if [ "$INPUT_CHECKER_PINNED" != "disabled" ] && [ "$pinned_count" -gt 0 ]; then
labels+=("Pinned"); texts+=("$pinned_tooltip")
fi
if [ "$source_shown" -gt 0 ]; then
labels+=("Sources"); texts+=("$source_tooltip")
fi
local n=${#labels[@]}
if [ "$n" -eq 0 ]; then
printf 'System updated'
return 0
fi
local out="" i
for ((i = 0; i < n; i++)); do
[ -n "$out" ] && out="$out\\n\\n"
[ "$n" -gt 1 ] && out="$out${labels[i]}:\\n"
out="$out${texts[i]}"
done
printf '%s' "$out"
}
# Set by check_system_rebuilt when it had two closures to compare, so
# "reconcile" can subtract from the pending list without diffing again.
REBUILD_NVD_OUT=""
function check_system_rebuilt() {
# Auto-detect rebuild by comparing /run/current-system to cached path.
# Uses nvd diff on existing store paths (fast, no build needed) to distinguish
# package version updates from config-only rebuilds.
# Returns 0 only if package versions actually changed.
local current_system
current_system=$(readlink /run/current-system 2>/dev/null)
local cached_system
cached_system=$(cat "$SYSTEM_PATH_FILE" 2>/dev/null || echo "")
if [ -n "$current_system" ] && [ -z "$cached_system" ]; then
# First run - initialize without treating as a rebuild
echo "$current_system" > "$SYSTEM_PATH_FILE"
return 1
elif [ -n "$current_system" ] && [ "$current_system" != "$cached_system" ]; then
# Rebuild detected - update cache then check if packages actually changed
echo "$current_system" > "$SYSTEM_PATH_FILE"
if [ -d "$cached_system" ]; then
# Both paths exist - diff them to check if package versions
# changed. No building is needed, since both closures are already
# in the store, but it is not free either (~4.3s measured), so the
# output is kept for "reconcile" rather than diffed again.
REBUILD_NVD_OUT=$(nvd diff "$cached_system" "$current_system" 2>/dev/null)
if grep -q '\[U' <<<"$REBUILD_NVD_OUT"; then
return 0 # Package versions changed
fi
return 1 # Config-only rebuild - preserve package state
else
# Previous path was GC'd - can't diff, assume packages may have changed
return 0
fi
fi
return 1
}
function check_inputs_updated() {
# Auto-detect flake.lock changes to trigger an immediate input re-check.
# Triggers whenever any input is updated via nix flake update, regardless
# of which alias or command was used. No flag files needed.
local flake_lock="$NIXOS_CONFIG_PATH/flake.lock"
[ ! -f "$flake_lock" ] && return 1
local current_hash
current_hash=$(md5sum "$flake_lock" 2>/dev/null | cut -d' ' -f1)
local cached_hash
cached_hash=$(cat "$FLAKE_LOCK_INPUT_HASH_FILE" 2>/dev/null || echo "")
if [ -z "$cached_hash" ]; then
# First run - initialize without triggering re-check
echo "$current_hash" > "$FLAKE_LOCK_INPUT_HASH_FILE"
return 1
elif [ "$current_hash" != "$cached_hash" ]; then
# flake.lock changed - update cache and signal re-check needed
echo "$current_hash" > "$FLAKE_LOCK_INPUT_HASH_FILE"
return 0
fi
return 1
}
function calc_next_update() {
local last_run=$(cat "$LAST_RUN_FILE")
local current_time=$(date +%s)
local next_update=$((UPDATE_INTERVAL - (current_time - last_run)))
local next_update_min=$((next_update / 60))
echo "$next_update_min"
}
# With no argument: "Last checked: X · Next check: Y".
# With "checking": "Last checked: X · Checking for updates..." - the layout
# stays put while a check runs, so only the right-hand half changes.
# Waybar renders a custom module's tooltip with set_tooltip_markup, so the
# text is parsed as Pango markup. An unescaped & or < - from a nix error, a
# package name, an input name - is a parse failure that discards the *whole*
# tooltip rather than the offending line, and it happens precisely when
# something has already gone wrong and the tooltip is what you need. Escaped
# centrally here so that no caller can forget it.
#
# The only markup this module emits is the horizontal rule, so callers place
# a token and it is substituted after escaping.
TOOLTIP_HR=$'\002'
function escape_markup() {
local s="$1"
# The ampersands are backslash-escaped because bash 5.2 made a bare & in a
# substitution replacement expand to the matched text (patsub_replacement, on
# by default), which turned every escape into <lt; rather than <. Escaping
# & first is also load-bearing: the & this introduces must not be re-escaped.
s=${s//&/\&}
s=${s//</\<}
s=${s//>/\>}
printf '%s' "$s"
}
function output_json() {
local text="$1" alt="$2" tooltip="$3"
# Kept for the width measurement below, which has to run on what Pango
# will draw rather than on what is handed to it: escaping turns one &
# into the five characters of &, and the rule was sized from the
# escaped text, so it overhung its content by four characters for every
# ampersand. Measured at 57 spaces under a 53-character line.
local raw="$tooltip"
tooltip=$(escape_markup "$tooltip")
# The tooltip is interpolated into a JSON string, so a double quote in a
# nix error or a package name ends that string early and waybar gets
# malformed JSON. Only the quote is escaped: the body carries literal
# backslash-n sequences meant to reach waybar as JSON line breaks, and
# escaping backslashes too would turn them into the characters \ and n.
tooltip=${tooltip//\"/\\\"}
if [[ "$tooltip" == *"$TOOLTIP_HR"* ]]; then
# Width of the widest line, the rule's own line excluded. Counted in
# characters, which needs a UTF-8 locale to be exact; under a C
# locale the multi-byte arrows inflate the count and the rule comes
# out a little long, which is what a fixed width did anyway.
local width=0 line
while IFS= read -r line; do
[[ "$line" == *"$TOOLTIP_HR"* ]] && continue
[ "${#line}" -gt "$width" ] && width=${#line}
done < <(printf '%s\n' "${raw//\\n/$'\n'}")
[ "$width" -gt 0 ] || width=20
# line_height shortens the rule's own line box and is the only thing
# that reduces the space above it; rise lifts the stroke and adds
# space below it only. Full size, not x-small: Pango scales
# underline thickness with font size, and smaller spaces are
# narrower than the body characters the width was measured from.
# line_height needs Pango 1.50+, which rejects unknown attributes
# rather than ignoring them.
local spaces
printf -v spaces '%*s' "$width" ''
# Single-quoted attributes, which Pango accepts: a double quote would
# be interpolated into the JSON string below and end it early.
tooltip=${tooltip//$TOOLTIP_HR/<span line_height=\'0.15\' rise=\'8000\' underline=\'single\'>$spaces</span>}
fi
echo "{ \"text\":\"$text\", \"alt\":\"$alt\", \"tooltip\":\"$tooltip\" }"
}
function format_tooltip_header() {
local state="${1:-idle}"
local last_run
last_run=$(cat "$LAST_RUN_FILE" 2>/dev/null || echo "0")
local time_fmt
if [ "$CLOCK_FORMAT" = "12h" ]; then
time_fmt="+%I:%M %p"
else
time_fmt="+%H:%M"
fi
local last_checked next_check
if [ "$last_run" = "0" ]; then
last_checked="never"
next_check="soon"
else
last_checked=$(date -d "@$last_run" "$time_fmt")
next_check=$(date -d "@$((last_run + UPDATE_INTERVAL))" "$time_fmt")
fi
case "$state" in
checking) echo "Last checked: $last_checked · Checking for updates..." ;;
offline) echo "Last checked: $last_checked · Offline" ;;
*) echo "Last checked: $last_checked · Next check: $next_check" ;;
esac
}
# Renders the "Update cost" tooltip section and reports, via
# PREVIEW_HAS_STATE, whether there is real state behind it. A bare
# "middle-click to calculate" affordance must not by itself give a fully
# updated system a tooltip, so only actual state counts towards showing one.
# True while the process that recorded a "calculating" preview is still
# alive. An empty owner means the launcher has written the record but the
# evaluating process has not claimed it yet - a sub-second window, treated
# as live.
function preview_owner_alive() {
local pid starttime bootid
read -r pid starttime bootid <<<"$1"
[ -z "$pid" ] && return 0
[[ "$pid" =~ ^[0-9]+$ ]] || return 1
# A record from an earlier boot cannot describe a running process, and
# its pid/starttime pair can still match a live one: starttime is
# counted from boot, so both halves repeat every time the machine starts.
if [ -n "$bootid" ] && \
[ "$bootid" != "$(cat /proc/sys/kernel/random/boot_id 2>/dev/null)" ]; then
return 1
fi
[ -d "/proc/$pid" ] || return 1
if [[ "$starttime" =~ ^[0-9]+$ ]]; then
local live
live=$(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null)
[ "$live" = "$starttime" ] || return 1
fi
return 0
}
PREVIEW_SECTION=""
PREVIEW_HAS_STATE=false
function build_preview_section() {
PREVIEW_SECTION=""
PREVIEW_HAS_STATE=false
# Disabled: drop any result so turning the feature off also clears what
# it left behind, rather than stranding a number with no way to dismiss.
if [ "$DRY_RUN_PREVIEW" != "true" ]; then
rm -f "$PREVIEW_FILE"
return 0
fi
if [ ! -f "$PREVIEW_FILE" ]; then
PREVIEW_SECTION="Update cost:\nmiddle-click to calculate"
return 0
fi
local status="" timestamp="" lock_hash="" system_path="" result_hash="" owner="" reason="" cost=""
local app_list_hash=""
local key value
while IFS='=' read -r key value; do
case "$key" in
status) status="$value" ;;
timestamp) timestamp="$value" ;;
lock_hash) lock_hash="$value" ;;
system_path) system_path="$value" ;;
app_list_hash) app_list_hash="$value" ;;
result_hash) result_hash="$value" ;;
owner) owner="$value" ;;
reason) reason="$value" ;;
cost) cost="$value" ;;
esac
done < "$PREVIEW_FILE"
PREVIEW_HAS_STATE=true
local body
case "$status" in
calculating)
# An evaluation that died without recording anything would
# otherwise leave this reading "calculating..." indefinitely,
# since nothing else rewrites the record.
if preview_owner_alive "$owner"; then
body="calculating... (~3 min)"
else
body="interrupted — middle-click to retry"
fi
;;
declined)
body="check in progress — try again shortly"
# Cleared on read: the click has been answered, and the message
# should not outlive the check it refers to.
rm -f "$PREVIEW_FILE"
;;
failed)
body="unavailable${reason:+ — $reason}"
;;
done)
local live_lock live_system
live_lock=$(sha256sum "$NIXOS_CONFIG_PATH/flake.lock" 2>/dev/null | cut -d' ' -f1)
live_system=$(readlink -f /run/current-system 2>/dev/null)
# Inputs first: when both moved, that is the change that governs
# what a prospective update would cost.
if [ "$lock_hash" != "$live_lock" ]; then
body="inputs changed — middle-click to recalculate"
elif [ "$result_hash" != "$(cat "$RESULT_HASH_FILE" 2>/dev/null)" ]; then
# Upstream moved: the check now finds something different, so a cost
# priced against the old upstream no longer holds.
body="updates changed — middle-click to recalculate"
elif [ -n "$app_list_hash" ] && \
[ -n "$(cat "$APP_LIST_HASH_FILE" 2>/dev/null)" ] && \
[ "$app_list_hash" != "$(cat "$APP_LIST_HASH_FILE" 2>/dev/null)" ]; then
# A package was added to or removed from the configuration,
# which adds or drops a whole closure from the plan. Both
# sides are required to be non-empty: a missing hash means
# the extraction has not run or found nothing, which is not
# evidence of a change.
body="packages changed — middle-click to recalculate"
else
local when=""
if [ -n "$timestamp" ] && [ "$timestamp" != "0" ]; then
if [ "$CLOCK_FORMAT" = "12h" ]; then
when=$(date -d "@$timestamp" "+%I:%M %p" 2>/dev/null)
else
when=$(date -d "@$timestamp" "+%H:%M" 2>/dev/null)
fi
fi
# A rebuild marks the cost rather than discarding it. The
# number is stale by however much the rebuild changed the
# closure, which for a config-only rebuild is a few
# config-file derivations against a plan of hundreds of paths
# - well inside the accuracy already claimed for it. The one
# case that bites is a config change adding a package: the
# rebuild makes it resident, so the plan shrinks and the
# stored figure overestimates. That is the safe direction for
# a number already reported as a floor, and cheaper than
# throwing away a three-minute evaluation.
#
# Not a staleness reason, deliberately. recalculateOnChange
# ignores system_path, so treating it as one here would leave
# a config-only rebuild in a state nothing recovers from
# except a click - see maybe_auto_preview.
if [ "$system_path" != "$live_system" ]; then
when="${when:+$when, }pre-rebuild"
fi
PREVIEW_SECTION="Update cost${when:+ ($when)}:\n$cost"
return 0
fi
;;
*)
body="unavailable"
;;
esac
PREVIEW_SECTION="Update cost:\n$body"
}
# Recompute a stale cost preview without being asked, when PREVIEW_AUTO is
# set.
#
# Kept out of build_preview_section on purpose. That function renders, and
# the tail below reaches it from the grace and no-network branches as well;
# starting a multi-minute evaluation from a render is the wrong shape, and
# it would fire from branches where a preview cannot succeed anyway.
#
# This only ever refreshes a preview that already exists. Enabling the
# option never starts the first one - that stays a middle-click - so turning
# it on cannot commit a machine to an evaluation it has not asked for once.
function maybe_auto_preview() {
[ "$PREVIEW_AUTO" = "true" ] || return 0
[ "$DRY_RUN_PREVIEW" = "true" ] || return 0
[ -f "$PREVIEW_FILE" ] || return 0
local status="" lock_hash="" result_hash="" app_list_hash=""
local key value
while IFS='=' read -r key value; do
case "$key" in
status) status="$value" ;;
lock_hash) lock_hash="$value" ;;
result_hash) result_hash="$value" ;;
app_list_hash) app_list_hash="$value" ;;
esac
done < "$PREVIEW_FILE"
# Anything but a completed preview is either already running or a
# message about why one did not, and neither is a baseline to refresh.
# It also stops this re-firing on the poll its own "calculating" record
# provokes.
[ "$status" = "done" ] || return 0
# Every staleness key except system_path, deliberately: a rebuild moves
# that one, and what to do after a rebuild is already the user's choice.
# Every afterRebuild option settles into a check whose findings move
# result_hash anyway, so the cost still refreshes when it genuinely
# changed, without pricing each rebuild as an event in its own right -
# which in this mode would mean building the closure again.
local live_lock live_app_list
live_lock=$(sha256sum "$NIXOS_CONFIG_PATH/flake.lock" 2>/dev/null | cut -d' ' -f1)
live_app_list=$(cat "$APP_LIST_HASH_FILE" 2>/dev/null)
if [ "$lock_hash" = "$live_lock" ] && \
[ "$result_hash" = "$(cat "$RESULT_HASH_FILE" 2>/dev/null)" ] && \
{ [ -z "$app_list_hash" ] || [ -z "$live_app_list" ] || \
[ "$app_list_hash" = "$live_app_list" ]; }; then
return 0
fi
# The lock is tested here rather than left to "preview start", which
# answers a busy lock by recording the "declined" state - and that
# record is cleared on read, taking the stored preview with it. For a
# click that is right: the click has been answered. Here it would delete
# the very baseline this function requires, disabling the option until
# the next manual click.
#
# A check in this mode runs inline and holds the lock on fd 9 until the
# script exits, so a poll that checked skips here and the recompute
# lands on a later one. That is the intended order: the check comes
# first, and its findings are what the new cost is priced against.
flock -n "$CHECK_LOCK_FILE" true 2>/dev/null || return 0
preview start >/dev/null 2>&1 || true
}
function var_setter() {
# Ensure updates is a number
if [ -z "$updates" ]; then
updates=0
fi
local header
header=$(format_tooltip_header)
if [ "$updates" -ne 0 ]; then
alt="has-updates"
local existing_tooltip
existing_tooltip=$(cat "$LAST_RUN_TOOLTIP" 2>/dev/null || echo "$updates updates available")
tooltip="$header\\n${TOOLTIP_HR}\\n$existing_tooltip"
else
alt="updated"
tooltip="$header"
fi
}
# ===== Flake Input Checking =====
function check_flake_inputs() {
local flake_lock="$NIXOS_CONFIG_PATH/flake.lock"
[[ ! -f "$flake_lock" ]] && return 1
local count=0
local tooltip=""
# Check GitHub inputs (unpinned)
while IFS=$'\t' read -r name owner repo rev ref last_modified; do
local remote_ref
[[ "$ref" == "HEAD" ]] && remote_ref="HEAD" || remote_ref="refs/heads/$ref"
local latest
latest=$(GIT_TERMINAL_PROMPT=0 GIT_ASKPASS="" git ls-remote \
"https://github.com/$owner/$repo" "$remote_ref" 2>/dev/null | cut -f1)
if [[ -n "$latest" && "$latest" != "$rev" ]]; then
count=$((count + 1))
local locked_date
locked_date=$(date -d "@$last_modified" +%Y-%m-%d 2>/dev/null || echo "?")
[[ -n "$tooltip" ]] && tooltip+="\\n"
tooltip+="$name (locked: $locked_date)"
fi
done < <(jq -r '
. as $root |
$root.nodes.root.inputs | to_entries[] |
select(.value | type == "string") |
{name: .key, node: $root.nodes[.value]} |
select(.node.locked.type == "github") |
select(.node.original.rev == null) |
[.name, .node.original.owner, .node.original.repo, .node.locked.rev,
(.node.original.ref // "HEAD"), (.node.locked.lastModified | tostring)] |
@tsv
' "$flake_lock")
# Check generic git inputs (Bitbucket, GitLab, self-hosted, etc.) - unpinned
while IFS=$'\t' read -r name url rev ref last_modified; do
local remote_ref="$ref"
local latest
latest=$(GIT_TERMINAL_PROMPT=0 GIT_ASKPASS="" git ls-remote \
"$url" "$remote_ref" 2>/dev/null | cut -f1)
if [[ -n "$latest" && "$latest" != "$rev" ]]; then
count=$((count + 1))
local locked_date
locked_date=$(date -d "@$last_modified" +%Y-%m-%d 2>/dev/null || echo "?")
[[ -n "$tooltip" ]] && tooltip+="\\n"
tooltip+="$name (locked: $locked_date)"
fi
done < <(jq -r '
. as $root |
$root.nodes.root.inputs | to_entries[] |
select(.value | type == "string") |
{name: .key, node: $root.nodes[.value]} |
select(.node.locked.type == "git") |
select(.node.original.rev == null) |
[.name, .node.locked.url, .node.locked.rev,
(.node.locked.ref // "HEAD"), (.node.locked.lastModified | tostring)] |
@tsv
' "$flake_lock")
echo "$count" > "$INPUT_STATE_FILE"
if [ "$count" -eq 0 ]; then
echo "" > "$INPUT_TOOLTIP_FILE"
else
echo "$tooltip" > "$INPUT_TOOLTIP_FILE"
fi
return 0
}
# Check pinned flake inputs
function check_pinned_inputs() {
local flake_lock="$NIXOS_CONFIG_PATH/flake.lock"
[[ ! -f "$flake_lock" ]] && return 1
local count=0
local tooltip=""
# Check pinned GitHub inputs
while IFS=$'\t' read -r name owner repo rev ref last_modified; do
local remote_ref
[[ "$ref" == "HEAD" ]] && remote_ref="HEAD" || remote_ref="refs/heads/$ref"
local latest
latest=$(GIT_TERMINAL_PROMPT=0 GIT_ASKPASS="" git ls-remote \
"https://github.com/$owner/$repo" "$remote_ref" 2>/dev/null | cut -f1)
if [[ -n "$latest" && "$latest" != "$rev" ]]; then
count=$((count + 1))
local locked_date
locked_date=$(date -d "@$last_modified" +%Y-%m-%d 2>/dev/null || echo "?")
[[ -n "$tooltip" ]] && tooltip+="\\n"
tooltip+="$name (pinned: $locked_date)"
fi
done < <(jq -r '
. as $root |
$root.nodes.root.inputs | to_entries[] |
select(.value | type == "string") |
{name: .key, node: $root.nodes[.value]} |
select(.node.locked.type == "github") |
select(.node.original.rev != null) |
[.name, .node.original.owner, .node.original.repo, .node.locked.rev,
(.node.original.ref // "HEAD"), (.node.locked.lastModified | tostring)] |
@tsv
' "$flake_lock")
# Check pinned generic git inputs
while IFS=$'\t' read -r name url rev ref last_modified; do
local remote_ref="$ref"
local latest
latest=$(GIT_TERMINAL_PROMPT=0 GIT_ASKPASS="" git ls-remote \
"$url" "$remote_ref" 2>/dev/null | cut -f1)
if [[ -n "$latest" && "$latest" != "$rev" ]]; then
count=$((count + 1))
local locked_date
locked_date=$(date -d "@$last_modified" +%Y-%m-%d 2>/dev/null || echo "?")
[[ -n "$tooltip" ]] && tooltip+="\\n"
tooltip+="$name (pinned: $locked_date)"
fi
done < <(jq -r '
. as $root |
$root.nodes.root.inputs | to_entries[] |
select(.value | type == "string") |
{name: .key, node: $root.nodes[.value]} |
select(.node.locked.type == "git") |
select(.node.original.rev != null) |
[.name, .node.locked.url, .node.locked.rev,
(.node.locked.ref // "HEAD"), (.node.locked.lastModified | tostring)] |
@tsv
' "$flake_lock")
echo "$count" > "$PINNED_STATE_FILE"
if [ "$count" -eq 0 ]; then
echo "" > "$PINNED_TOOLTIP_FILE"
else
echo "$tooltip" > "$PINNED_TOOLTIP_FILE"
fi
return 0
}
# Reduces nix's stderr to the tooltip line, or two lines when nix said what
# it was doing. The cause and trace extraction is the same as
# lightweight-checker's report_eval_failure, because both scripts write the
# same tooltip and a failure should not read differently depending on which
# checker happened to produce it. Only the last-resort fallback differs, and
# the comment on it says why.
#
# Replaces three separate heuristics that used to live at the call sites:
# two took the *first* line indented by exactly seven spaces, one took the
# last line of stderr whatever it was. The first indented error is nix's
# outermost frame - the least specific thing it knows - and the bare last
# line is as often a "For full logs, run ..." hint as it is the cause.
function nix_error_summary() {
local raw="$1"
local clean detail context
clean=$(sed 's/\x1b\[[0-9;]*m//g' <<<"$raw")
# nix opens with a bare "error:" and puts the actual cause on a later,
# indented "error:" line, after the stack trace. Take the last one that
# has content, which is the most specific; anything else is trace or
# chatter. Any indent, not seven spaces: nix varies it with nesting
# depth, and an unindented single-line error is the whole message.
detail=$(grep -E '^[[:space:]]*error:[[:space:]]*[^[:space:]]' <<<"$clean" \
| tail -1 \
| sed 's/^[[:space:]]*error:[[:space:]]*//; s/[[:space:]]*$//' \
| cut -c1-200)
# The cause on its own does not say what nix was doing when it hit it.
# "cannot read file from tarball" is libarchive reporting a truncated
# channel download, and read alone it looks like a broken configuration
# rather than a network hiccup worth simply retrying. nix already says
# which fetch it was, one line up ("... while fetching the input
# '<url>'"), so carry the nearest trace line preceding the cause.
#
# The marker is stripped in awk rather than matched around, because the
# ellipsis is one thing in nix's output and three dots in a terminal
# that could not render it - and only what follows is worth showing.
context=$(awk '
{
trimmed = $0
sub(/^[[:space:]]*/, "", trimmed)
sub(/^…[[:space:]]*/, "", trimmed)
sub(/^\.\.\.[[:space:]]*/, "", trimmed)
sub(/[[:space:]]*$/, "", trimmed)
}
trimmed ~ /^while / { trace = trimmed }
/^[[:space:]]*error:[[:space:]]*[^[:space:]]/ { found = trace }
END { print found }
' <<<"$clean" | cut -c1-200)
# The whole check is gated on the link being up, but that was minutes
# and a full build ago. A drop since then explains the failure better
# than whichever fetch nix happened to be inside when it noticed.
if ! check_network_connectivity; then
printf 'no network connection'
return
fi
# Nothing matched, so fall back to the last non-empty line - which is
# what the flake-update site used to do unconditionally. Kept as the
# last resort rather than dropped: a nix that fails without ever
# printing an "error:" line is exactly the case with nothing else left
# to show, and a fixed label there would discard the only clue there is.
if [ -z "$detail" ]; then
detail=$(grep -v '^[[:space:]]*$' <<<"$clean" | tail -1 \
| sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | cut -c1-200)
fi
[ -z "$detail" ] && detail="nix gave no error output"
# A literal backslash-n: this is read back a line at a time and the
# tooltip renders the escape itself.
if [ -n "$context" ]; then
printf '%s\\n%s' "$context" "$detail"
else
printf '%s' "$detail"
fi
}
# ===== Update Check Logic =====