-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathcommon.sh
More file actions
executable file
·1122 lines (980 loc) · 34.3 KB
/
common.sh
File metadata and controls
executable file
·1122 lines (980 loc) · 34.3 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
# macrift — shared utilities
set -euo pipefail
# CPU architecture — used to detect Apple Silicon vs Intel for brew paths
ARCH=$(uname -m)
# Global flags — set by macrift.sh before sourcing, defaults here for direct sourcing
MACRIFT_DRY_RUN="${MACRIFT_DRY_RUN:-false}"
MACRIFT_NO_CONFIRM="${MACRIFT_NO_CONFIRM:-false}"
MACRIFT_LOG="${MACRIFT_LOG:-}"
# Restore cursor on exit
_macrift_cleanup() {
printf "\033[?25h" 2>/dev/null
}
trap _macrift_cleanup EXIT
trap 'exit 130' INT TERM
# Strip ANSI escape codes and append to log file
_log_file() {
[[ -z "$MACRIFT_LOG" ]] && return
printf "%s %s\n" "$(date '+%H:%M:%S')" "$1" >> "$MACRIFT_LOG"
}
# ANSI colors — adjusted for dark/light theme below
BOLD='\033[1m'
DIM='\033[2m'
RESET='\033[0m'
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
_detect_theme() {
# 1. Explicit override
if [[ "${MACRIFT_THEME:-}" == "light" ]]; then echo "light"; return; fi
if [[ "${MACRIFT_THEME:-}" == "dark" ]]; then echo "dark"; return; fi
# 2. Query terminal background via OSC 11 (iTerm2, Ghostty, Kitty, WezTerm)
if [[ -t 2 ]] && [[ -r /dev/tty ]]; then
local old_stty response="" _dd_pid _timer_pid
local _dd_tmp
_dd_tmp=$(mktemp)
old_stty=$(stty -g </dev/tty 2>/dev/null) || true
stty raw -echo min 0 time 2 </dev/tty 2>/dev/null
printf '\033]11;?\a' > /dev/tty 2>/dev/null
# Read with kill-timer to prevent hang in non-standard terminals
dd bs=1 count=30 </dev/tty >"$_dd_tmp" 2>/dev/null &
_dd_pid=$!
(sleep 1 && kill "$_dd_pid" 2>/dev/null) &
_timer_pid=$!
wait "$_dd_pid" 2>/dev/null
kill "$_timer_pid" 2>/dev/null; wait "$_timer_pid" 2>/dev/null
response=$(cat "$_dd_tmp")
rm -f "$_dd_tmp"
# Drain any leftover bytes from terminal response
dd bs=1 count=64 </dev/tty >/dev/null 2>&1 &
_dd_pid=$!
(sleep 1 && kill "$_dd_pid" 2>/dev/null) &
_timer_pid=$!
wait "$_dd_pid" 2>/dev/null
kill "$_timer_pid" 2>/dev/null; wait "$_timer_pid" 2>/dev/null
stty "$old_stty" </dev/tty 2>/dev/null
if [[ "$response" =~ rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+) ]]; then
local r=$((16#${BASH_REMATCH[1]:0:2}))
local g=$((16#${BASH_REMATCH[2]:0:2}))
local b=$((16#${BASH_REMATCH[3]:0:2}))
# Perceived brightness (ITU-R BT.601 luma coefficients)
local lum=$(( (r * 299 + g * 587 + b * 114) / 1000 ))
if [[ $lum -gt 128 ]]; then echo "light"; else echo "dark"; fi
return
fi
fi
# 3. Fall back to system theme
if defaults read -g AppleInterfaceStyle 2>/dev/null | grep -q Dark; then
echo "dark"
else
echo "light"
fi
}
if [[ "$(_detect_theme)" == "dark" ]]; then
GRAY='\033[38;5;240m'
CYAN='\033[38;5;39m'
ICE='\033[38;5;195m'
else
GRAY='\033[38;5;245m'
CYAN='\033[38;5;25m'
ICE='\033[38;5;24m'
fi
_friendly_val() {
case "$1" in
SCcf) echo "current folder" ;;
Nlsv) echo "list" ;;
PfHm) echo "home" ;;
none) echo "off" ;;
ZeroDiagnosticData) echo "off" ;;
file://*) echo "$HOME/" ;;
default) echo "not set" ;;
*) echo "$1" ;;
esac
}
# Update terminal tab/window title — show path to current menu, not the menu itself
_update_title() {
local count=${#MACRIFT_CRUMBS[@]}
if [[ $count -le 1 ]]; then
printf "\033]0;%s\007" "macrift"
else
local parent=("${MACRIFT_CRUMBS[@]:0:count-1}")
local title
title=$(IFS=" › "; echo "${parent[*]}")
printf "\033]0;%s\007" "$title"
fi
}
MACRIFT_CRUMBS=()
crumb_push() { MACRIFT_CRUMBS+=("$1"); _update_title; }
crumb_pop() {
local last=$(( ${#MACRIFT_CRUMBS[@]} - 1 ))
if [[ $last -ge 0 ]]; then
unset "MACRIFT_CRUMBS[$last]"
fi
_update_title
}
spinner() {
local pid=$1 msg="${2:-}"
local frames='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
local frame_count=${#frames}
local i=0
tput civis 2>/dev/null || true
while kill -0 "$pid" 2>/dev/null; do
printf '\r %b%s%b %s' "$CYAN" "${frames:i%frame_count:1}" "$RESET" "$msg" >&2
sleep 0.08
i=$((i + 1))
done
printf '\r\033[K' >&2
tput cnorm 2>/dev/null || true
}
run_with_spinner() {
local msg="$1"
shift
local _rws_log
_rws_log=$(mktemp)
"$@" &>"$_rws_log" &
spinner $! "$msg"
wait $! 2>/dev/null
local rc=$?
if [[ $rc -ne 0 ]]; then
cat "$_rws_log" >&2
fi
rm -f "$_rws_log"
return $rc
}
# Progress bar — inline redraw
# Usage: show_progress 3 10 "Installing packages..."
# show_progress 10 10 "Done" (auto-clears on complete)
show_progress() {
local current="$1" total="$2" msg="${3:-}"
[[ $total -eq 0 ]] && return
local pct=$((current * 100 / total))
local bar_w=20 # bar width in characters
local filled=$((pct * bar_w / 100))
local empty=$((bar_w - filled))
local bar=""
local i
for ((i=0; i<filled; i++)); do bar+="█"; done
for ((i=0; i<empty; i++)); do bar+="░"; done
if [[ $current -ge $total ]]; then
printf '\r %b%s%b %b%d/%d%b %s\033[K\n' \
"$GREEN" "$bar" "$RESET" "$DIM" "$current" "$total" "$RESET" "$msg" >&2
else
printf '\r %b%s%b %b%d/%d%b %s\033[K' \
"$CYAN" "$bar" "$RESET" "$DIM" "$current" "$total" "$RESET" "$msg" >&2
fi
}
log_info() { printf ' %b›%b %s\n' "${CYAN}" "${RESET}" "$1"; _log_file "[info] $1"; }
log_ok() { printf ' %b✓%b %s\n' "${GREEN}" "${RESET}" "$1"; _log_file "[ ok] $1"; }
log_err() { printf ' %b✗%b %s\n' "${RED}" "${RESET}" "$1"; _log_file "[ err] $1"; }
log_warn() { printf ' %b!%b %s\n' "${YELLOW}" "${RESET}" "$1"; _log_file "[warn] $1"; }
log_skip() { printf ' %b-%b %s\n' "${DIM}" "${RESET}" "$1"; _log_file "[skip] $1"; }
# Box drawing helpers
# All use $BP (border paint) and $R (reset) from caller scope
_box_top() {
local title="$1" inner_w="$2"
local fill=$((inner_w - ${#title} - 3))
[[ $fill -lt 1 ]] && fill=1
printf ' %b╭─ %b%s%b ' "$BP" "${R}${BOLD}${ICE}" "$title" "${R}${BP}" >&2
printf '─%.0s' $(seq 1 "$fill") >&2
printf '╮%b\033[K\n' "$R" >&2
}
_box_bottom() {
local inner_w="$1"
printf ' %b╰' "$BP" >&2
printf '─%.0s' $(seq 1 "$inner_w") >&2
printf '╯%b\033[K\n' "$R" >&2
}
_box_empty() {
local inner_w="$1"
printf ' %b│%b%*s%b│%b\033[K\n' "$BP" "$R" "$inner_w" "" "$BP" "$R" >&2
}
_box_scroll_indicator() {
local inner_w="$1" direction="$2"
local char="▲"
if [[ "$direction" == "down" ]]; then char="▼"; fi
# Center "▲ ···" (5 chars) within inner_w, accounting for _box_row's 2-char left padding
local label="$char ···"
local usable=$((inner_w - 2))
local pad_left=$(( (usable - 5) / 2 ))
local pad_right=$(( usable - pad_left - 5 ))
local content
content=$(printf '%*s%b%s%b%*s' "$pad_left" "" "$DIM" "$label" "$R" "$pad_right" "")
_box_row "$inner_w" "$content" 0
}
_box_row() {
local inner_w="$1" content="$2" pad="$3"
printf ' %b│%b %s%*s%b│%b\033[K\n' "$BP" "$R" "$content" "$pad" "" "$BP" "$R" >&2
}
# Viewport: adjust vp_top to keep cursor visible
# Uses items[], need_scroll, visible_count, vp_top from caller
_adjust_viewport() {
local cursor="$1" item_count="$2"
if $need_scroll && [[ $cursor -lt $item_count ]]; then
if [[ $cursor -lt $vp_top ]]; then
vp_top=$cursor
while [[ $vp_top -gt 0 && ( "${items[$((vp_top-1))]}" == "---" || "${items[$((vp_top-1))]}" == "## "* ) ]]; do
vp_top=$((vp_top - 1))
done
fi
local vp_bottom=$((vp_top + visible_count))
if [[ $cursor -ge $vp_bottom ]]; then
vp_top=$((cursor - visible_count + 1))
[[ $vp_top -lt 0 ]] && vp_top=0
fi
fi
}
# Read a single keypress, return: up/down/left/right/enter/space/a or the char
_read_key() {
local key=""
IFS= read -rsn1 key < /dev/tty || true
if [[ "$key" == $'\x1b' ]]; then
local seq=""
read -rsn2 -t 1 seq < /dev/tty || true
case "$seq" in
'[A') echo "up" ;;
'[B') echo "down" ;;
'[C') echo "right" ;;
'[D') echo "left" ;;
*) echo "" ;;
esac
elif [[ "$key" == '' ]]; then
echo "enter"
elif [[ "$key" == ' ' ]]; then
echo "space"
else
echo "$key"
fi
}
# Begin interactive UI — hide cursor, disable echo
_ui_start() {
stty -echo 2>/dev/null || true
printf "\033[?25l" >&2
}
# End interactive UI — show cursor, restore echo
_ui_end() {
stty echo 2>/dev/null || true
printf "\033[?25h" >&2
}
# Frame start — synchronized output begin + cursor reposition
_frame_start() {
local first_draw="$1" total_lines="$2"
printf "\033[?2026h" >&2
if [[ "$first_draw" == true ]]; then
: # first frame, no reposition
else
printf "\033[%dA\r" "$total_lines" >&2
fi
printf "\033[K\n" >&2
}
_frame_end() {
printf "\033[?2026l" >&2
}
# Calculate scroll parameters
# Sets: need_scroll, visible_count, via output
_calc_scroll() {
local item_count="$1" chrome="$2"
local term_h
term_h=$(tput lines 2>/dev/null || echo 24)
if [[ $item_count -gt $((term_h - chrome)) ]]; then
local vc=$((term_h - chrome - 3))
[[ $vc -lt 3 ]] && vc=3
echo "true $vc"
else
echo "false $item_count"
fi
}
# Menu
show_menu() {
local title="$1"
shift
local items=("$@")
local count=${#items[@]}
local last_idx=$((count - 1))
# Build selectable items map
local sel_nums=() sel_to_item=()
local i num=0
for ((i=0; i<last_idx; i++)); do
[[ "${items[$i]}" == "---" ]] && continue
[[ "${items[$i]}" == "## "* ]] && continue
num=$((num + 1))
sel_nums+=("$num")
sel_to_item+=("$i")
done
sel_nums+=(0)
sel_to_item+=("$last_idx")
local sel_total=${#sel_nums[@]}
# Box dimensions
local max_len=0
for ((i=0; i<count; i++)); do
[[ "${items[$i]}" == "---" ]] && continue
local _wtext="${items[$i]}"
[[ "$_wtext" == "## "* ]] && _wtext="${_wtext#\#\# }"
[[ ${#_wtext} -gt $max_len ]] && max_len=${#_wtext}
done
local no_nums="${MENU_NO_NUMBERS:-false}"
local num_w=${#num}; local num_pad=3
if [[ "$no_nums" == true ]]; then num_w=0; num_pad=2; fi
local inner_w=$((2 + num_w + num_pad + max_len + 2))
local title_min=$((${#title} + 5))
[[ $title_min -gt $inner_w ]] && inner_w=$title_min
local BP="${BOLD}${GRAY}" R="${RESET}"
# Build content for a menu row: text, number, is_selected(bool)
_menu_content() {
local text="$1" num="$2" is_sel="$3"
if [[ "$no_nums" == true ]]; then
if $is_sel; then printf '%b› %s%b' "${BOLD}${ICE}" "$text" "$R"
else printf '%b›%b %s' "$DIM" "$R" "$text"; fi
else
if $is_sel; then printf '%b%*d › %s%b' "${BOLD}${ICE}" "$num_w" "$num" "$text" "$R"
elif [[ "$num" -eq 0 ]]; then printf '%b%*d › %s%b' "$DIM" "$num_w" "$num" "$text" "$R"
else printf '%b%*d%b %b›%b %s' "$CYAN" "$num_w" "$num" "$R" "$DIM" "$R" "$text"; fi
fi
}
# Scrolling
local chrome=8; [[ "$no_nums" == true ]] && chrome=7
local scroll_info
scroll_info=$(_calc_scroll "$last_idx" "$chrome")
local need_scroll=${scroll_info%% *}
local visible_count=${scroll_info##* }
local vp_top=0
local total_lines=$((visible_count + 8))
[[ "$no_nums" == true ]] && total_lines=$((total_lines - 1))
$need_scroll && total_lines=$((total_lines + 2))
local sel=0 first_draw=true
_ui_start
while true; do
# Viewport
if $need_scroll && [[ $sel -lt $((sel_total - 1)) ]]; then
local cur_item=${sel_to_item[$sel]}
_adjust_viewport "$cur_item" "$last_idx"
fi
_frame_start "$first_draw" "$total_lines"
first_draw=false
_box_top "$title" "$inner_w"
_box_empty "$inner_w"
# Scroll-up
if $need_scroll; then
if [[ $vp_top -gt 0 ]]; then
_box_scroll_indicator "$inner_w" "up"
else
_box_empty "$inner_w"
fi
fi
# Items
local cur_num=0 sel_idx=0 rendered=0
for ((i=0; i<last_idx; i++)); do
if [[ "${items[$i]}" != "---" && "${items[$i]}" != "## "* ]]; then
cur_num=$((cur_num + 1))
fi
if $need_scroll; then
if [[ $i -lt $vp_top || $rendered -ge $visible_count ]]; then
if [[ "${items[$i]}" != "---" && "${items[$i]}" != "## "* ]]; then
sel_idx=$((sel_idx + 1))
fi
continue
fi
fi
if [[ "${items[$i]}" == "---" ]]; then
_box_empty "$inner_w"
rendered=$((rendered + 1))
continue
fi
if [[ "${items[$i]}" == "## "* ]]; then
local htext="${items[$i]#\#\# }"
local hindent=$((num_w + num_pad))
local hvis=$((2 + hindent + ${#htext}))
local hpad=$((inner_w - hvis))
local hcontent
hcontent=$(printf '%*s%b%s%b' "$hindent" "" "${BOLD}${GRAY}" "$htext" "$R")
_box_row "$inner_w" "$hcontent" "$hpad"
rendered=$((rendered + 1))
continue
fi
local vis=$((2 + num_w + num_pad + ${#items[$i]}))
local pad=$((inner_w - vis))
local is_sel=false; [[ $sel_idx -eq $sel ]] && is_sel=true
_box_row "$inner_w" "$(_menu_content "${items[$i]}" "$cur_num" "$is_sel")" "$pad"
sel_idx=$((sel_idx + 1))
rendered=$((rendered + 1))
done
# Scroll-down
if $need_scroll; then
if [[ $((vp_top + visible_count)) -lt $last_idx ]]; then
_box_scroll_indicator "$inner_w" "down"
else
_box_empty "$inner_w"
fi
fi
# Back item
[[ "$no_nums" != true ]] && _box_empty "$inner_w"
local vis=$((2 + num_w + num_pad + ${#items[$last_idx]}))
local pad=$((inner_w - vis))
local is_sel=false; [[ $sel -eq $((sel_total - 1)) ]] && is_sel=true
_box_row "$inner_w" "$(_menu_content "${items[$last_idx]}" 0 "$is_sel")" "$pad"
_box_empty "$inner_w"
_box_bottom "$inner_w"
# Footer
local hint="↑↓ navigate enter/→ select"
[[ ${#MACRIFT_CRUMBS[@]} -gt 1 ]] && hint+=" ← back"
local flags=""
[[ "$MACRIFT_DRY_RUN" == true ]] && flags+=" [dry-run]"
[[ "$MACRIFT_NO_CONFIRM" == true ]] && flags+=" [auto]"
[[ -n "$MACRIFT_LOG" ]] && flags+=" [log]"
if [[ -n "$flags" ]]; then
printf ' %b%s%b %b%s%b\033[K\n' "$DIM" "$hint" "$R" "$YELLOW" "$flags" "$R" >&2
else
printf ' %b%s%b\033[K\n' "$DIM" "$hint" "$R" >&2
fi
_frame_end
# Input
local key
key=$(_read_key)
case "$key" in
up) [[ $sel -gt 0 ]] && sel=$((sel - 1)) ;;
down) [[ $sel -lt $((sel_total - 1)) ]] && sel=$((sel + 1)) ;;
right) _ui_end; echo "${sel_nums[$sel]}"; return ;;
left)
if [[ ${#MACRIFT_CRUMBS[@]} -gt 1 ]]; then
_ui_end; echo "0"; return
fi ;;
enter) _ui_end; echo "${sel_nums[$sel]}"; return ;;
[0-9])
if [[ "$no_nums" != true ]]; then
printf "%s\n" "$key" >&2
_ui_end; echo "$key"; return
fi ;;
esac
done
}
# Info box (non-interactive)
show_info_box() {
local title="$1"
shift
local lines=("$@")
local count=${#lines[@]}
local max_len=0 i
for ((i=0; i<count; i++)); do
[[ ${#lines[$i]} -gt $max_len ]] && max_len=${#lines[$i]}
done
local inner_w=$((max_len + 4))
local title_min=$((${#title} + 5))
[[ $title_min -gt $inner_w ]] && inner_w=$title_min
local BP="${BOLD}${GRAY}" R="${RESET}"
printf "\n" >&2
_box_top "$title" "$inner_w"
_box_empty "$inner_w"
for ((i=0; i<count; i++)); do
local pad=$((inner_w - ${#lines[$i]} - 2))
_box_row "$inner_w" "${lines[$i]}" "$pad"
done
_box_empty "$inner_w"
_box_bottom "$inner_w"
}
# Multiselect
show_multiselect() {
local title="$1"
shift
local items=("$@")
local count=${#items[@]}
local total=$((count + 1))
local cursor=0
declare -a selected
local i
for ((i=0; i<count; i++)); do
if [[ "${items[$i]}" == "---" ]]; then selected[i]="-"; else selected[i]="1"; fi
done
while [[ $cursor -lt $count && "${items[$cursor]}" == "---" ]]; do
cursor=$((cursor + 1))
done
# Box width
local max_len=0
for ((i=0; i<count; i++)); do
[[ "${items[$i]}" == "---" ]] && continue
[[ ${#items[$i]} -gt $max_len ]] && max_len=${#items[$i]}
done
local inner_w=$((8 + max_len + 2))
[[ 12 -gt $inner_w ]] && inner_w=12
local title_min=$((${#title} + 5))
[[ $title_min -gt $inner_w ]] && inner_w=$title_min
local BP="${BOLD}${GRAY}" R="${RESET}"
# Scrolling
local scroll_info
scroll_info=$(_calc_scroll "$count" 8)
local need_scroll=${scroll_info%% *}
local visible_count=${scroll_info##* }
local vp_top=0
local redraw_lines=$((visible_count + 8))
$need_scroll && redraw_lines=$((redraw_lines + 2))
local first_draw=true
_ui_start
while true; do
_adjust_viewport "$cursor" "$count"
_frame_start "$first_draw" "$redraw_lines"
first_draw=false
_box_top "$title" "$inner_w"
_box_empty "$inner_w"
# Scroll-up
if $need_scroll; then
if [[ $vp_top -gt 0 ]]; then
_box_scroll_indicator "$inner_w" "up"
else
_box_empty "$inner_w"
fi
fi
# Items
local rendered=0
for ((i=0; i<count; i++)); do
if $need_scroll && [[ $i -lt $vp_top || $rendered -ge $visible_count ]]; then
continue
fi
if [[ "${items[$i]}" == "---" ]]; then
_box_empty "$inner_w"
rendered=$((rendered + 1))
continue
fi
local pad=$((inner_w - 8 - ${#items[$i]}))
local content
if [[ $i -eq $cursor ]]; then
if [[ "${selected[i]}" == "1" ]]; then
content=$(printf '%b›%b %b[*]%b %s' "$CYAN" "$R" "$GREEN" "$R" "${items[$i]}")
else
content=$(printf '%b›%b %b[ ]%b %s' "$CYAN" "$R" "$DIM" "$R" "${items[$i]}")
fi
else
if [[ "${selected[i]}" == "1" ]]; then
content=$(printf ' %b[*]%b %s' "$GREEN" "$R" "${items[$i]}")
else
content=$(printf ' %b[ ]%b %s' "$DIM" "$R" "${items[$i]}")
fi
fi
_box_row "$inner_w" "$content" "$pad"
rendered=$((rendered + 1))
done
# Scroll-down
if $need_scroll; then
if [[ $((vp_top + visible_count)) -lt $count ]]; then
_box_scroll_indicator "$inner_w" "down"
else
_box_empty "$inner_w"
fi
fi
# Back
_box_empty "$inner_w"
local back_pad=$((inner_w - 10))
if [[ $cursor -eq $count ]]; then
_box_row "$inner_w" "$(printf '%b›%b %b‹ Back%b' "$CYAN" "$R" "$DIM" "$R")" "$back_pad"
else
_box_row "$inner_w" "$(printf ' %b‹ Back%b' "$DIM" "$R")" "$back_pad"
fi
_box_empty "$inner_w"
_box_bottom "$inner_w"
local hint="${MULTISELECT_HINT:-↑↓ move space toggle a all enter confirm}"
printf ' %b%s%b\033[K\n' "$DIM" "$hint" "$R" >&2
_frame_end
# Input
local key
key=$(_read_key)
case "$key" in
up)
if [[ $cursor -gt 0 ]]; then
cursor=$((cursor - 1))
while [[ $cursor -gt 0 && $cursor -lt $count && "${items[$cursor]}" == "---" ]]; do
cursor=$((cursor - 1))
done
fi ;;
down)
if [[ $cursor -lt $((total - 1)) ]]; then
cursor=$((cursor + 1))
while [[ $cursor -lt $count && "${items[$cursor]}" == "---" ]]; do
cursor=$((cursor + 1))
done
fi ;;
right)
if [[ $cursor -eq $count ]]; then _ui_end; return 0; fi
break ;;
left) _ui_end; return 0 ;;
space)
if [[ $cursor -lt $count ]]; then
if [[ "${selected[cursor]}" == "1" ]]; then selected[cursor]="0"; else selected[cursor]="1"; fi
fi ;;
a|A)
local all_on=true
for ((i=0; i<count; i++)); do
[[ "${items[$i]}" == "---" ]] && continue
[[ "${selected[$i]}" == "0" ]] && { all_on=false; break; }
done
local val="1"; $all_on && val="0"
for ((i=0; i<count; i++)); do
[[ "${items[$i]}" == "---" ]] && continue
selected[i]="$val"
done ;;
enter)
if [[ $cursor -eq $count ]]; then _ui_end; return 0; fi
break ;;
esac
done
_ui_end
for ((i=0; i<count; i++)); do
[[ "${items[$i]}" == "---" ]] && continue
if [[ "${selected[i]}" == "1" ]]; then echo "${items[$i]}"; fi
done
}
# Reusable prompts
wait_enter() {
printf '\n %bpress enter to continue%b ' "$DIM" "$RESET"
while true; do
local _k=""
IFS= read -rsn1 _k < /dev/tty || true
if [[ "$_k" == $'\x1b' ]]; then
read -rsn2 -t 1 _ < /dev/tty || true
continue
fi
[[ "$_k" == "" ]] && break
done
printf '\n'
}
prompt_path() { printf ' %bpath:%b ' "$CYAN" "$RESET"; }
# Prompt y/n; respects MACRIFT_NO_CONFIRM (auto-yes); returns 0=yes, 1=no
confirm() {
local msg="${1:-Continue?}"
local default="${2:-}"
if [[ "$MACRIFT_NO_CONFIRM" == true ]]; then
printf ' %b%s%b %b[auto: y]%b\n' "$YELLOW" "$msg" "$RESET" "$DIM" "$RESET"
_log_file "[auto] $msg → y"
return 0
fi
local hint="y/n"
[[ "$default" == "y" ]] && hint="Y/n"
[[ "$default" == "n" ]] && hint="y/N"
printf ' %b%s%b %b[%s]%b ' "$YELLOW" "$msg" "$RESET" "$DIM" "$hint" "$RESET"
while true; do
local key=""
IFS= read -rsn1 key < /dev/tty || true
if [[ "$key" == $'\x1b' ]]; then
read -rsn2 -t 1 _ < /dev/tty || true
continue
fi
case "$key" in
[Yy]) printf '%s\n' "$key"; return 0 ;;
[Nn]) printf '%s\n' "$key"; return 1 ;;
"")
printf '\n'
[[ "$default" == "y" ]] && return 0
[[ "$default" == "n" ]] && return 1
;;
*) ;;
esac
done
}
# Prompt for sudo password if not already cached
require_sudo() {
if ! sudo -n true 2>/dev/null; then
printf '\n %bSudo access needed for system tweaks%b\n' "$YELLOW" "$RESET"
sudo -v -p " Password: "
fi
}
# Ensure Homebrew is available; install if missing, load shellenv for current session
check_homebrew() {
if ! command -v brew &>/dev/null; then
# Try to load brew from known paths before declaring missing
if [[ "$ARCH" == "arm64" && -f /opt/homebrew/bin/brew ]]; then
eval "$(/opt/homebrew/bin/brew shellenv)"
elif [[ -f /usr/local/bin/brew ]]; then
eval "$(/usr/local/bin/brew shellenv)"
fi
fi
if ! command -v brew &>/dev/null; then
log_warn "Homebrew not found"
if confirm "Install Homebrew?"; then
if /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" < /dev/tty; then
if [[ "$ARCH" == "arm64" && -f /opt/homebrew/bin/brew ]]; then
eval "$(/opt/homebrew/bin/brew shellenv)"
elif [[ -f /usr/local/bin/brew ]]; then
eval "$(/usr/local/bin/brew shellenv)"
fi
else
log_err "Homebrew installation failed"
return 1
fi
else
log_warn "Some features require Homebrew"
return 1
fi
fi
}
brew_install() {
local package="$1"
local type="${2:-formula}" # formula or cask
local -a flag=(); [[ "$type" == "cask" ]] && flag=("--cask")
if brew list "${flag[@]}" "$package" &>/dev/null; then
log_skip "$package already installed"
return 0
fi
log_info "Installing $package..."
if brew install "${flag[@]}" "$package"; then
log_ok "$package installed"
else
log_err "Failed to install $package"
return 1
fi
}
#
# Stores pending changes for review before applying
declare -a AUDIT_ENTRIES=()
audit_reset() {
AUDIT_ENTRIES=()
}
audit_sep() {
AUDIT_ENTRIES+=("---|---|---|---|---|---")
}
# Queue a defaults write for audit
# Usage: audit_default "com.apple.dock" "autohide" "-bool" "true" "Autohide Dock"
audit_default() {
local domain="$1"
local key="$2"
local type="$3"
local new_value="$4"
local label="${5:-$key}"
local current
current=$(defaults read "$domain" "$key" 2>/dev/null || echo "default")
# Normalize: defaults read returns 1/0 for bools
if [[ "$type" == "-bool" ]]; then
[[ "$current" == "1" ]] && current="true"
[[ "$current" == "0" ]] && current="false"
fi
AUDIT_ENTRIES+=("${label}|${current}|${new_value}|${domain}|${key}|${type}")
}
# Show audit table and ask for confirmation
show_audit_table() {
local category="$1"
if [[ ${#AUDIT_ENTRIES[@]} -eq 0 ]]; then
log_info "No changes to apply"
return 1
fi
printf "\n"
printf ' %b── %s %b' "${BOLD}" "$category" "${RESET}${DIM}"
printf '─%.0s' {1..35}
printf '%b\n' "$RESET"
printf ' %b%-28s %-15s %-15s%b\n' "$DIM" "Setting" "Current" "New" "$RESET"
local has_changes=false
for entry in "${AUDIT_ENTRIES[@]}"; do
IFS='|' read -r label current new_val domain key type sudo_flag <<< "$entry"
if [[ "$current" != "$new_val" ]]; then
if [[ "$current" == "default" ]]; then
printf ' %-28s %b%-15s%b %b%-15s%b\n' "$label" "$DIM" "$current" "$RESET" "$GREEN" "$new_val" "$RESET"
else
printf ' %-28s %b%-15s%b %b%-15s%b\n' "$label" "$RED" "$current" "$RESET" "$GREEN" "$new_val" "$RESET"
fi
has_changes=true
else
printf ' %-28s %b%-15s %-15s%b\n' "$label" "$DIM" "$current" "(no change)" "$RESET"
fi
done
printf ' %b%s%b\n' "$DIM" "$(printf '─%.0s' {1..58})" "$RESET"
if ! $has_changes; then
log_ok "Everything already set"
wait_enter
audit_reset
return 1
fi
if [[ "$MACRIFT_DRY_RUN" == true ]]; then
printf "\n"
log_info "Dry run — no changes applied"
audit_reset
return 1
fi
if confirm "Apply these changes?"; then
return 0
else
log_info "No changes applied"
wait_enter
audit_reset
return 1
fi
}
# Domains that were actually modified (used to decide which services to restart)
declare -a MACRIFT_CHANGED_DOMAINS=()
# Run defaults write/delete with sudo fallback
# Usage: _defaults_cmd "write" domain key type value label sudo_flag
# _defaults_cmd "delete" domain key "" "" label sudo_flag
# Returns 0 on success, 1 on failure. Appends to MACRIFT_CHANGED_DOMAINS on success.
_defaults_cmd() {
local cmd="$1" domain="$2" key="$3" type="$4" value="$5" label="$6" sudo_flag="${7:-}"
local args=("$domain" "$key")
[[ "$cmd" == "write" ]] && args+=("$type" "$value")
if [[ "$sudo_flag" == "sudo" ]]; then
if sudo defaults "$cmd" "${args[@]}" 2>/dev/null; then
MACRIFT_CHANGED_DOMAINS+=("$domain")
return 0
fi
return 1
fi
if defaults "$cmd" "${args[@]}" 2>/dev/null; then
MACRIFT_CHANGED_DOMAINS+=("$domain")
return 0
fi
log_warn "$label needs sudo ($domain is protected)"
if sudo defaults "$cmd" "${args[@]}" 2>/dev/null; then
MACRIFT_CHANGED_DOMAINS+=("$domain")
return 0
fi
return 1
}
# Apply all queued defaults writes
apply_audited_defaults() {
local applied=0 skipped=0 failed=0
for entry in "${AUDIT_ENTRIES[@]}"; do
IFS='|' read -r label current new_val domain key type sudo_flag <<< "$entry"
label="${label%%~*}"
if [[ "$current" == "$new_val" ]]; then
skipped=$((skipped + 1))
continue
fi
local friendly
friendly=$(_friendly_val "$new_val")
# Handle chflags entries (e.g. ~/Library)
if [[ "$domain" == "chflags" ]]; then
local chflag="$key"
[[ "$new_val" == "false" ]] && chflag="hidden"
if chflags "$chflag" ~/Library 2>/dev/null; then
log_ok "$label → $friendly"
applied=$((applied + 1))
else
log_err "Failed: $label → $friendly"
failed=$((failed + 1))
fi
continue
fi
# Handle nvram entries (e.g. StartupMute)
if [[ "$domain" == "nvram" ]]; then
local nvram_val="%01" # %01 = muted, %00 = sound on (NVRAM raw byte)
[[ "$new_val" == "true" ]] && nvram_val="%00"
require_sudo
if sudo nvram "${key}=${nvram_val}" 2>/dev/null; then
log_ok "$label → $friendly"
applied=$((applied + 1))
else
log_err "Failed: $label → $friendly"
failed=$((failed + 1))
fi
continue
fi
# Ensure screenshot directory exists before setting location
if [[ "$domain" == "com.apple.screencapture" && "$key" == "location" ]]; then
mkdir -p "$new_val" 2>/dev/null || true
fi
if _defaults_cmd "write" "$domain" "$key" "$type" "$new_val" "$label" "${sudo_flag:-}"; then
log_ok "$label → $friendly"
applied=$((applied + 1))
else
log_err "Failed: $label → $friendly"
failed=$((failed + 1))
fi
done
local summary="${applied} applied"
[[ $skipped -gt 0 ]] && summary+=", ${skipped} skipped"