Red-Pill-Omarchy/Redpill_update.sh
28allday 20e0e77e72 Initial commit: Red Pill backup & restore for Omarchy
Unified backup system using rsync hardlink snapshots to external drives.
Includes TUI, CLI, auto-backup on shutdown, integrity verification,
new machine restore, and clean uninstaller.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:22:35 +00:00

2113 lines
69 KiB
Bash
Executable file

#!/usr/bin/env bash
set -euo pipefail
# ===== RED PILL — unified backup (green #9ece6a on black, invert highlight, CaskaydiaMono) =====
# Never edits icons or your System menu.
# 0) Deps — check only, these all ship with omarchy
missing=()
for pkg in rsync libnewt zstd alacritty desktop-file-utils; do
pacman -Q "$pkg" &>/dev/null || missing+=("$pkg")
done
if (( ${#missing[@]} > 0 )); then
echo "Installing missing packages: ${missing[*]}"
sudo pacman -S --needed --noconfirm "${missing[@]}" || {
echo "Error: Failed to install dependencies" >&2
exit 1
}
fi
# 1) redpill (TUI) — unified config + data backup with rsync hardlink snapshots
sudo tee /usr/local/bin/redpill >/dev/null <<'BASH'
#!/usr/bin/env bash
set -euo pipefail
# Load NEWT_COLORS if present (whiptail palette)
[[ -f "$HOME/.config/redpill/theme.env" ]] && set -a && . "$HOME/.config/redpill/theme.env" && set +a
APP_NAME="redpill"; APP_TITLE="RED PILL"
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/$APP_NAME"
CONF_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/$APP_NAME"
LOCAL_BACKUP_DIR="${STATE_DIR}/backups"
INCLUDES_FILE="${CONF_DIR}/includes.conf"
EXCLUDES_FILE="${CONF_DIR}/excludes.conf"
DEST_FILE="${CONF_DIR}/destination.conf"
mkdir -p "$LOCAL_BACKUP_DIR" "$CONF_DIR"
# ── First-run: includes.conf ──
# If the file doesn't exist, create with full defaults.
# If it exists but lacks the "User data" section, append it (upgrade path).
if [[ ! -f "$INCLUDES_FILE" ]]; then
cat >"$INCLUDES_FILE" <<'EOF'
# All configs
~/.config
# Shell
~/.bashrc
~/.bash_profile
# Keys
~/.ssh
~/.gnupg
# Fonts & launchers
~/.local/share/fonts
~/.local/share/applications
# User data
~/Documents
~/Pictures
~/Music
~/Videos
~/Desktop
~/Downloads
~/Templates
EOF
elif ! grep -q '# User data' "$INCLUDES_FILE"; then
cat >>"$INCLUDES_FILE" <<'EOF'
# User data (added by redpill upgrade)
~/Documents
~/Pictures
~/Music
~/Videos
~/Desktop
~/Downloads
~/Templates
EOF
fi
# ── First-run: excludes.conf ──
if [[ ! -f "$EXCLUDES_FILE" ]]; then
cat >"$EXCLUDES_FILE" <<'EOF'
.cache/
.local/state/
node_modules/
__pycache__/
.Trash*/
*.tmp
*.swp
.thumbnails/
EOF
fi
# ── Helpers ──
have(){ command -v "$1" >/dev/null 2>&1; }
timestamp(){ date +"%Y-%m-%d_%H-%M-%S"; }
hosttag(){ hostnamectl --static 2>/dev/null || hostname; }
choose_comp(){ have zstd && echo zstd || echo gzip; }
# ── Prompt to reboot after restore ──
prompt_reboot(){
echo
_hr
printf ' %s%sA reboot is recommended to fully apply all restored configs.%s\n' "$_c_bold" "$_c_yellow" "$_c_reset"
echo
read -r -p " Reboot now? [Y/n] " _reboot_yn
if [[ "${_reboot_yn,,}" != "n" ]]; then
_ok "Rebooting in 3 seconds..."
sleep 3
systemctl reboot
else
_warn "Skipped reboot. Some changes may not take effect until you reboot."
fi
}
# ── Fix paths after restore if username/home changed ──
# Usage: fix_restored_paths /path/to/snapshot_or_backup.log
fix_restored_paths(){
local source_log="$1"
local old_home=""
# Read old home from backup.log
if [[ -f "$source_log" ]]; then
old_home="$(grep -oP 'Home:\s+\K.*' "$source_log" 2>/dev/null)" || true
fi
# Nothing to fix if same home or no old home recorded
[[ -n "$old_home" && "$old_home" != "$HOME" ]] || return 0
_info "Username change" "$(basename "$old_home") → $(basename "$HOME")"
_info "Fixing paths" "$old_home → $HOME"
# Fix absolute symlinks that point to old home
while IFS= read -r link; do
local target
target="$(readlink "$link")"
if [[ "$target" == "$old_home/"* || "$target" == "$old_home" ]]; then
local new_target="${target/$old_home/$HOME}"
ln -snf "$new_target" "$link"
fi
done < <(find "$HOME/.config" "$HOME/.local" -type l 2>/dev/null)
# Fix hardcoded paths in all config files (escape regex metacharacters in paths)
local old_home_escaped new_home_escaped
old_home_escaped="$(printf '%s\n' "$old_home" | sed 's/[][\/.^$*]/\\&/g')"
new_home_escaped="$(printf '%s\n' "$HOME" | sed 's/[\/&]/\\&/g')"
while IFS= read -r file; do
if grep -Iql "$old_home" "$file" 2>/dev/null; then
sed -i "s|${old_home_escaped}|${new_home_escaped}|g" "$file"
fi
done < <(find "$HOME/.config" -type f -size -1M 2>/dev/null)
_ok "Paths updated for new home directory"
}
# Pick a terminal-friendly editor (GUI editors like kate won't work in TTY)
tui_editor(){
# If EDITOR is a known terminal editor, use it
local e="${EDITOR:-}"
case "${e##*/}" in
nvim|vim|vi|micro|helix|hx|nano|emacs) echo "$e"; return ;;
esac
# Otherwise find one
for cmd in nvim vim vi micro nano; do
have "$cmd" && echo "$cmd" && return
done
echo "vi"
}
# ── TUI formatting ──
_c_green=$'\033[38;2;158;206;106m'
_c_red=$'\033[38;2;247;118;142m'
_c_yellow=$'\033[38;2;224;175;104m'
_c_cyan=$'\033[38;2;125;207;255m'
_c_dim=$'\033[2m'
_c_bold=$'\033[1m'
_c_reset=$'\033[0m'
_hr(){
printf '%s────────────────────────────────────────────────────────%s\n' "$_c_dim" "$_c_reset"
}
_header(){
echo
_hr
printf ' %s%s%s\n' "${_c_bold}${_c_green}" "$1" "$_c_reset"
_hr
}
_info(){
printf ' %s%-18s%s %s\n' "$_c_dim" "$1" "$_c_reset" "$2"
}
_ok(){
printf '\n %s✓%s %s\n' "$_c_green" "$_c_reset" "$1"
}
_warn(){
printf '\n %s⚠%s %s\n' "$_c_yellow" "$_c_reset" "$1"
}
_err(){
printf '\n %s✗%s %s\n' "$_c_red" "$_c_reset" "$1"
}
_pause(){
echo
_hr
read -r -p " Press Enter to continue..."
}
# ── Read backup mode from a snapshot's backup.log ──
_snap_mode(){
local log="$1/backup.log"
if [[ -f "$log" ]]; then
grep -oP 'Mode:\s+\K.*' "$log" 2>/dev/null || echo "unknown"
else
echo "unknown"
fi
}
_mode_tag(){
case "$1" in
configs) printf '%s[configs]%s' "$_c_cyan" "$_c_reset" ;;
data) printf '%s[data]%s' "$_c_yellow" "$_c_reset" ;;
all) printf '%s[full]%s' "$_c_green" "$_c_reset" ;;
*) printf '%s[unknown]%s' "$_c_dim" "$_c_reset" ;;
esac
}
# Read include paths by section
# Usage: read_includes [all|configs|data]
read_includes(){
local mode="${1:-all}"
case "$mode" in
configs)
# Lines from start until "# User data" header (exclusive)
awk '/^# User data/{exit} /^\s*#/{next} /^\s*$/{next} {print}' "$INCLUDES_FILE"
;;
data)
# Lines after "# User data" header
awk 'BEGIN{found=0} /^# User data/{found=1; next} !found{next} /^\s*#/{next} /^\s*$/{next} {print}' "$INCLUDES_FILE"
;;
*)
# All non-comment, non-empty lines
grep -vE '^\s*#' "$INCLUDES_FILE" | sed '/^\s*$/d'
;;
esac
}
pre_backup_note(){
echo
printf ' %s%s%s\n' "$_c_dim" "You take the red pill, you stay in hyprland and" "$_c_reset"
printf ' %s%s%s\n' "$_c_dim" "I show you how deep the rabbit-hole goes..." "$_c_reset"
sleep 3
}
# ── Destination management ──
read_destination(){
if [[ -f "$DEST_FILE" ]]; then
local d
d="$(head -n1 "$DEST_FILE" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
[[ -n "$d" ]] && echo "$d" && return 0
fi
return 1
}
check_destination(){
local dest
dest="$(read_destination)" || { _err "No backup destination configured. Use 'Set backup destination' first."; return 1; }
if [[ ! -d "$dest" ]]; then
_err "Destination not mounted: $dest"
printf ' %s\n' "Plug in the drive or run 'Set backup destination' to pick a new one."
return 1
fi
echo "$dest"
}
# ── Scan for external drives (mounted or not) ──
# Populates parallel arrays: _scan_dev, _scan_mount, _scan_size, _scan_label, _scan_fstype
scan_drives(){
_scan_dev=(); _scan_mount=(); _scan_size=(); _scan_label=(); _scan_fstype=()
# Use lsblk pairs mode (-P) for reliable parsing of fields with spaces/empty values
while IFS= read -r line; do
local dev="" mnt="" sz="" lbl="" typ="" fs=""
# Parse KEY="VALUE" pairs
while [[ "$line" =~ ([A-Z]+)=\"([^\"]*)\" ]]; do
case "${BASH_REMATCH[1]}" in
PATH) dev="${BASH_REMATCH[2]}" ;;
MOUNTPOINT) mnt="${BASH_REMATCH[2]}" ;;
SIZE) sz="${BASH_REMATCH[2]}" ;;
LABEL) lbl="${BASH_REMATCH[2]}" ;;
TYPE) typ="${BASH_REMATCH[2]}" ;;
FSTYPE) fs="${BASH_REMATCH[2]}" ;;
esac
line="${line#*\""${BASH_REMATCH[2]}"\"}"
done
# Only partitions or unlocked LUKS volumes with a real filesystem
[[ "$typ" == "part" || "$typ" == "crypt" ]] || continue
[[ -n "$fs" && "$fs" != "crypto_LUKS" && "$fs" != "swap" ]] || continue
# Skip system volumes by mountpoint
case "$mnt" in
/|/boot|/boot/*|/home|/var|/var/*) continue ;;
esac
_scan_dev+=("$dev")
_scan_mount+=("${mnt:-(not mounted)}")
_scan_size+=("$sz")
_scan_label+=("${lbl:-(no label)}")
_scan_fstype+=("$fs")
done < <(lsblk -Pno PATH,MOUNTPOINT,SIZE,LABEL,TYPE,FSTYPE 2>/dev/null)
}
set_destination(){
scan_drives
local -a menu_items=()
if (( ${#_scan_dev[@]} == 0 )); then
if command -v whiptail >/dev/null; then
whiptail --title "$APP_TITLE" --msgbox "No external drives detected.\nPlug in a drive and try again, or enter a path manually." 10 60
local manual
manual=$(whiptail --title "$APP_TITLE" --inputbox "Enter backup destination path:" 10 60 "" 3>&1 1>&2 2>&3) || return 1
[[ -n "$manual" ]] || return 1
echo "$manual" > "$DEST_FILE"
_ok "Destination set to: $manual"
else
_warn "No external drives detected."
local manual
read -r -p " Enter backup destination path: " manual
[[ -n "$manual" ]] || return 1
echo "$manual" > "$DEST_FILE"
_ok "Destination set to: $manual"
fi
return 0
fi
# Build menu
local i
for i in "${!_scan_dev[@]}"; do
local desc="${_scan_label[$i]} ${_scan_size[$i]} ${_scan_fstype[$i]} ${_scan_mount[$i]}"
menu_items+=("$((i+1))" "$desc")
done
menu_items+=("M" "Enter path manually")
local pick
if command -v whiptail >/dev/null; then
pick=$(whiptail --title "$APP_TITLE" --menu "Select backup destination" 18 78 10 "${menu_items[@]}" 3>&1 1>&2 2>&3) || return 1
else
echo "Available drives:"
for i in "${!_scan_dev[@]}"; do
echo " $((i+1))) ${_scan_label[$i]} ${_scan_size[$i]} ${_scan_fstype[$i]} ${_scan_mount[$i]}"
done
echo " M) Enter path manually"
read -r -p "Pick: " pick
fi
if [[ "$pick" == "M" || "$pick" == "m" ]]; then
local manual
if command -v whiptail >/dev/null; then
manual=$(whiptail --title "$APP_TITLE" --inputbox "Enter backup destination path:" 10 60 "" 3>&1 1>&2 2>&3) || return 1
else
read -r -p "Enter backup destination path: " manual
fi
[[ -n "$manual" ]] || return 1
echo "$manual" > "$DEST_FILE"
echo "Destination set to: $manual"
return 0
fi
if ! [[ "$pick" =~ ^[0-9]+$ ]] || (( pick < 1 || pick > ${#_scan_dev[@]} )); then
_err "Invalid selection: $pick"
return 1
fi
local idx=$((pick-1))
local chosen_dev="${_scan_dev[$idx]}"
local chosen_mount="${_scan_mount[$idx]}"
# If not mounted, mount it via udisksctl
if [[ "$chosen_mount" == "(not mounted)" ]]; then
printf ' Mounting %s ...\n' "$chosen_dev"
if command -v udisksctl >/dev/null; then
local mount_output
mount_output="$(udisksctl mount -b "$chosen_dev" 2>&1)" || {
echo "Failed to mount: $mount_output"
return 1
}
# udisksctl prints "Mounted /dev/sda1 at /run/media/user/Label"
chosen_mount="$(echo "$mount_output" | grep -oP 'at \K.+')" || {
# Fallback: re-read from lsblk
chosen_mount="$(lsblk -Pno MOUNTPOINT "$chosen_dev" 2>/dev/null | grep -oP 'MOUNTPOINT="\K[^"]+')"
}
if [[ -z "$chosen_mount" || ! -d "$chosen_mount" ]]; then
echo "Mounted but could not determine mount point."
return 1
fi
_ok "Mounted at: $chosen_mount"
else
echo "udisksctl not found. Please mount $chosen_dev manually and try again."
return 1
fi
fi
echo "$chosen_mount" > "$DEST_FILE"
_ok "Destination set to: $chosen_mount"
}
# ── Expand include paths ──
# Usage: expand_includes [all|configs|data]
expand_includes(){
local mode="${1:-all}"
local -a paths=()
while IFS= read -r p; do
local x="${p/#\~/$HOME}"
if [[ -e "$x" ]]; then
paths+=("$x")
else
echo "Skip (not found): $x" >&2
fi
done < <(read_includes "$mode")
(( ${#paths[@]} )) || { echo "Nothing to back up." >&2; return 1; }
printf '%s\n' "${paths[@]}"
}
# ── Build rsync args + relative source list (shared by estimate & backup) ──
# Usage: build_rsync_plan [all|configs|data]
build_rsync_plan(){
local mode="${1:-all}"
_bk_mode="$mode"
_bk_dest="$(check_destination)" || return 1
_bk_snap_dir="$_bk_dest/redpill/snapshots"
_bk_latest="$_bk_dest/redpill/latest"
local -a srcs=()
mapfile -t srcs < <(expand_includes "$mode")
(( ${#srcs[@]} )) || { echo "Nothing to back up." >&2; return 1; }
_bk_args=( -aHAX --delete --no-owner --no-group )
[[ -f "$EXCLUDES_FILE" ]] && _bk_args+=( "--exclude-from=$EXCLUDES_FILE" )
# Find best --link-dest: most recent snapshot whose mode matches or is broader
# e.g. backing up "all" should prefer the last "all" snapshot, not a "configs" one
local _best_link=""
if [[ -d "$_bk_snap_dir" ]]; then
local _snap _snap_mode
while IFS= read -r _snap; do
[[ -f "$_snap/backup.log" ]] || continue
_snap_mode="$(grep -oP 'Mode:\s+\K.*' "$_snap/backup.log" 2>/dev/null)" || continue
# Accept: same mode, or "all" covers everything
if [[ "$_snap_mode" == "$mode" || "$_snap_mode" == "all" ]]; then
_best_link="$_snap"
break
fi
done < <(find "$_bk_snap_dir" -mindepth 1 -maxdepth 1 -type d -not -name '.*' | sort -r)
fi
if [[ -n "$_best_link" ]]; then
_bk_args+=( "--link-dest=$_best_link" )
# Also add latest if it's a different snapshot (rsync dedupes across multiple link-dests)
if [[ -L "$_bk_latest" && -d "$_bk_latest" ]] && \
[[ "$(readlink -f "$_bk_latest")" != "$(readlink -f "$_best_link")" ]]; then
_bk_args+=( "--link-dest=$_bk_latest" )
fi
elif [[ -L "$_bk_latest" && -d "$_bk_latest" ]]; then
_bk_args+=( "--link-dest=$_bk_latest" )
fi
_bk_rel_srcs=()
for s in "${srcs[@]}"; do
local rel="${s/#$HOME\//}"
if [[ -d "$s" ]]; then
_bk_rel_srcs+=( "./$rel/" )
else
_bk_rel_srcs+=( "./$rel" )
fi
done
_bk_src_count=${#srcs[@]}
}
# ── Human-readable byte count ──
human_bytes(){
local b=$1
if (( b >= 1073741824 )); then
awk "BEGIN{printf \"%.1f GiB\", $b/1073741824}"
elif (( b >= 1048576 )); then
awk "BEGIN{printf \"%.1f MiB\", $b/1048576}"
elif (( b >= 1024 )); then
awk "BEGIN{printf \"%.1f KiB\", $b/1024}"
else
echo "${b} B"
fi
}
# ── Estimate backup size (dry-run rsync) ──
# Usage: estimate_backup [all|configs|data]
estimate_backup(){
local mode="${1:-all}"
build_rsync_plan "$mode" || return 1
local tmp_target
tmp_target="$_bk_snap_dir/.estimate_tmp_$$"
mkdir -p "$tmp_target"
printf ' %sScanning files...%s' "$_c_dim" "$_c_reset"
local stats
stats="$(cd "$HOME" && rsync "${_bk_args[@]}" --dry-run --stats \
--relative "${_bk_rel_srcs[@]}" --no-implied-dirs \
"$tmp_target/" 2>&1)" || true
rm -rf "$tmp_target" 2>/dev/null || true
printf '\r %s %s\n' "$_c_dim" "$_c_reset"
# Parse stats
local total_size transfer_size total_files transfer_files
total_size="$(echo "$stats" | grep -oP 'Total file size: \K[\d,]+' | tr -d ',')" || total_size=0
transfer_size="$(echo "$stats" | grep -oP 'Total transferred file size: \K[\d,]+' | tr -d ',')" || transfer_size=0
total_files="$(echo "$stats" | grep -oP 'Number of files: \K[\d,]+' | tr -d ',')" || total_files=0
transfer_files="$(echo "$stats" | grep -oP 'Number of .* files transferred: \K[\d,]+' | tr -d ',')" || transfer_files=0
local mode_label
case "$mode" in
configs) mode_label="Configs only" ;;
data) mode_label="User data only" ;;
*) mode_label="Everything" ;;
esac
_header "BACKUP ESTIMATE — $mode_label"
_info "Sources" "$_bk_src_count paths"
_info "Total files" "$total_files"
_info "Total size" "$(human_bytes "$total_size")"
if [[ -L "$_bk_latest" && -d "$_bk_latest" ]]; then
_info "Changed files" "$transfer_files"
_info "New data" "${_c_cyan}$(human_bytes "$transfer_size")${_c_reset} (rest will be hardlinked)"
else
echo
printf ' %s(First backup — no hardlink savings yet)%s\n' "$_c_dim" "$_c_reset"
fi
# Check free space on destination
local free_bytes
free_bytes="$(df -B1 --output=avail "$_bk_dest" 2>/dev/null | tail -n1 | tr -d ' ')" || free_bytes=0
_info "Drive free" "$(human_bytes "$free_bytes")"
_hr
if (( free_bytes > 0 && transfer_size > free_bytes )); then
_err "Not enough free space on destination!"
return 1
fi
}
# ── Unified backup: rsync hardlink snapshots to external drive ──
# Usage: mk_backup [all|configs|data]
mk_backup(){
local mode="${1:-all}"
build_rsync_plan "$mode" || return 1
local ts
ts="$(timestamp)"
local target="$_bk_snap_dir/$ts"
mkdir -p "$target"
local logfile="$target/backup.log"
# Start log
{
echo "RED PILL backup log"
echo "==================="
echo "Date: $(date)"
echo "Mode: $mode"
echo "Destination: $target"
echo "Sources: $_bk_src_count paths"
echo "Hostname: $(hosttag)"
echo "User: $USER"
echo "Home: $HOME"
echo
} > "$logfile"
local mode_label
case "$mode" in
configs) mode_label="Configs only" ;;
data) mode_label="User data only" ;;
*) mode_label="Everything" ;;
esac
_header "BACKING UP — $mode_label"
_info "Destination" "$target"
_info "Sources" "$_bk_src_count paths"
echo
# Log skipped paths (re-run expand to capture stderr)
expand_includes "$mode" >/dev/null 2>> "$logfile" || true
# Run rsync — capture stderr to log, stdout (progress) to terminal
# Skip progress output during shutdown autobackup (no terminal)
local -a _rsync_extra=()
[[ "${REDPILL_AUTOBACKUP:-}" != "1" ]] && _rsync_extra+=( --info=progress2 )
local rsync_exit=0
(cd "$HOME" && rsync "${_bk_args[@]}" "${_rsync_extra[@]}" --log-file="$logfile" \
--relative "${_bk_rel_srcs[@]}" --no-implied-dirs \
"$target/") || rsync_exit=$?
# Append summary to log
local snap_size
snap_size="$(du -sh "$target" 2>/dev/null | cut -f1)" || snap_size="unknown"
{
echo
echo "=== Summary ==="
echo "Snapshot size: $snap_size"
echo "rsync exit code: $rsync_exit"
if (( rsync_exit == 0 )); then
echo "Status: SUCCESS"
else
echo "Status: COMPLETED WITH ERRORS"
fi
echo "Finished: $(date)"
} >> "$logfile"
# Generate checksum manifest in background (xxh128sum is ~10x faster than sha256)
# Skip during shutdown autobackup — background job would be killed or block unmount
if [[ "${REDPILL_AUTOBACKUP:-}" != "1" ]]; then
local cksum_file="$target/checksums.xxh128"
local cksum_hash="xxh128sum"
have xxh128sum || { cksum_hash="sha256sum"; cksum_file="$target/checksums.sha256"; }
# Write to .tmp first — atomic rename on completion so partial files are never seen
# Note: disable set -e/pipefail in subshell so a find/xargs warning doesn't kill the rename
(set +eo pipefail; cd "$target" && find . -type f ! -name 'checksums.*' ! -name 'backup.log' -print0 \
| sort -z | xargs -0 -r "$cksum_hash" > "${cksum_file}.tmp" 2>/dev/null \
&& mv -f "${cksum_file}.tmp" "$cksum_file") &
disown
fi
# Update latest symlink (atomic: create tmp, rename) — use relative path
# so the symlink works regardless of where the drive is mounted
local tmp_link="$_bk_dest/redpill/.latest_tmp_$$"
ln -snf "snapshots/$ts" "$tmp_link"
mv -Tf "$tmp_link" "$_bk_latest"
# Write standalone restore script to drive root (for new machine recovery)
cat > "$_bk_dest/redpill/restore.sh" <<'RESTORE'
#!/usr/bin/env bash
set -euo pipefail
# RED PILL — standalone restore for new machines
# Usage: bash /path/to/drive/redpill/restore.sh
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SNAP_DIR="$SCRIPT_DIR/snapshots"
LATEST="$SCRIPT_DIR/latest"
# Ensure rsync is installed (needed for restore)
if ! command -v rsync >/dev/null 2>&1; then
echo " Installing rsync..."
sudo pacman -S --needed --noconfirm rsync || { echo " ERROR: Failed to install rsync"; exit 1; }
fi
_dim=$'\033[2m'
_green=$'\033[38;2;158;206;106m'
_cyan=$'\033[38;2;125;207;255m'
_yellow=$'\033[38;2;224;175;104m'
_reset=$'\033[0m'
# Read backup mode from a snapshot's backup.log
snap_mode(){
local log="$1/backup.log"
if [[ -f "$log" ]]; then
grep -oP 'Mode:\s+\K.*' "$log" 2>/dev/null || echo "unknown"
else
echo "unknown"
fi
}
snap_mode_tag(){
case "$1" in
configs) printf '%s[configs]%s' "$_cyan" "$_reset" ;;
data) printf '%s[data]%s' "$_yellow" "$_reset" ;;
all) printf '%s[full]%s' "$_green" "$_reset" ;;
*) printf '%s[unknown]%s' "$_dim" "$_reset" ;;
esac
}
echo
echo " RED PILL — New Machine Restore"
echo " ────────────────────────────────────────"
# Collect all snapshots
SNAPS=()
while IFS= read -r d; do
[[ -d "$d" ]] && SNAPS+=("$d")
done < <(find "$SNAP_DIR" -mindepth 1 -maxdepth 1 -type d -not -name '.*' 2>/dev/null | sort -r)
if (( ${#SNAPS[@]} == 0 )); then
echo " ERROR: No snapshots found in $SNAP_DIR"
exit 1
fi
# Find which one is "latest"
LATEST_REAL=""
if [[ -L "$LATEST" && -d "$LATEST" ]]; then
LATEST_REAL="$(readlink -f "$LATEST")"
fi
# Display all snapshots
echo
echo " Available snapshots:"
echo " ────────────────────────────────────────"
IDX=0
for s in "${SNAPS[@]}"; do
IDX=$((IDX + 1))
SNAME="$(basename "$s")"
SMODE="$(snap_mode "$s")"
STAG="$(snap_mode_tag "$SMODE")"
SSIZE="?"
if [[ -f "$s/backup.log" ]]; then
SSIZE="$(grep -oP 'Snapshot size: \K.*' "$s/backup.log" 2>/dev/null)" || SSIZE="?"
fi
LATEST_MARK=""
if [[ "$(readlink -f "$s")" == "$LATEST_REAL" ]]; then
LATEST_MARK=" ${_green}← latest${_reset}"
fi
printf ' %s%2d.%s %s %-12s %s(%s)%s%s\n' "$_dim" "$IDX" "$_reset" "$SNAME" "$STAG" "$_dim" "$SSIZE" "$_reset" "$LATEST_MARK"
done
echo " ────────────────────────────────────────"
echo
# Let user pick
DEFAULT=1
read -r -p " Select snapshot to restore [${DEFAULT}]: " PICK
PICK="${PICK:-$DEFAULT}"
if ! [[ "$PICK" =~ ^[0-9]+$ ]] || (( PICK < 1 || PICK > ${#SNAPS[@]} )); then
echo " Invalid selection: $PICK"
exit 1
fi
SNAP="${SNAPS[$((PICK-1))]}"
SNAP_NAME="$(basename "$SNAP")"
SNAP_TYPE="$(snap_mode "$SNAP")"
SNAP_TAG="$(snap_mode_tag "$SNAP_TYPE")"
echo
echo " Selected: $SNAP_NAME $SNAP_TAG"
echo " Source: $SNAP"
echo " Target: $HOME"
echo
# Show what this backup type means
case "$SNAP_TYPE" in
configs)
echo " ${_cyan}Note:${_reset} This is a configs-only backup."
echo " User data (Documents, Pictures, etc.) is NOT included."
echo ;;
data)
echo " ${_yellow}Note:${_reset} This is a user-data-only backup."
echo " System configs (Hyprland, terminal, etc.) are NOT included."
echo ;;
all)
echo " ${_green}Note:${_reset} This is a full backup (configs + user data)."
echo ;;
esac
# Count files
FILE_COUNT="$(find "$SNAP" -type f 2>/dev/null | wc -l)" || FILE_COUNT=0
echo " Files to restore: $FILE_COUNT"
echo
echo " This will copy files into your home directory."
echo " Existing files with the same name will be overwritten."
echo " Files not in the backup will NOT be deleted."
echo
read -r -p " Proceed? [y/N] " confirm
[[ "${confirm,,}" == "y" ]] || { echo " Cancelled."; exit 0; }
echo
echo " Restoring..."
rsync -aHAX --info=progress2 --exclude='backup.log' --exclude='checksums.*' "$SNAP/" "$HOME/"
# Fix paths if username/home directory changed since backup
OLD_HOME=""
BACKUP_LOG="$SNAP/backup.log"
if [[ -f "$BACKUP_LOG" ]]; then
OLD_HOME="$(grep -oP 'Home:\s+\K.*' "$BACKUP_LOG" 2>/dev/null)" || true
fi
if [[ -n "$OLD_HOME" && "$OLD_HOME" != "$HOME" ]]; then
echo
echo " ${_yellow}Username change detected:${_reset} $(basename "$OLD_HOME")$(basename "$HOME")"
echo " Fixing paths..."
# Fix absolute symlinks pointing to old home
while IFS= read -r link; do
target="$(readlink "$link")"
if [[ "$target" == "$OLD_HOME/"* || "$target" == "$OLD_HOME" ]]; then
new_target="${target/$OLD_HOME/$HOME}"
ln -snf "$new_target" "$link"
fi
done < <(find "$HOME/.config" "$HOME/.local" -type l 2>/dev/null)
# Fix hardcoded paths in all config files (escape regex metacharacters)
OLD_ESCAPED="$(printf '%s\n' "$OLD_HOME" | sed 's/[][\/.^$*]/\\&/g')"
NEW_ESCAPED="$(printf '%s\n' "$HOME" | sed 's/[\/&]/\\&/g')"
while IFS= read -r file; do
if grep -Iql "$OLD_HOME" "$file" 2>/dev/null; then
sed -i "s|${OLD_ESCAPED}|${NEW_ESCAPED}|g" "$file"
fi
done < <(find "$HOME/.config" -type f -size -1M 2>/dev/null)
echo " ${_green}${_reset} Paths updated for new home directory"
fi
# Reapply omarchy theme if available
THEME_NAME_FILE="$HOME/.config/omarchy/current/theme.name"
if command -v omarchy-theme-set >/dev/null 2>&1 && [[ -f "$THEME_NAME_FILE" ]]; then
RESTORED_THEME="$(cat "$THEME_NAME_FILE")"
if [[ -n "$RESTORED_THEME" ]]; then
echo " Applying theme: $RESTORED_THEME"
omarchy-theme-set "$RESTORED_THEME" >/dev/null 2>&1 || true
echo " ${_green}${_reset} Theme applied"
fi
fi
echo
echo " ✓ Restore complete from: $SNAP_NAME $SNAP_TAG"
echo
echo " ────────────────────────────────────────"
echo " ${_yellow}A reboot is recommended to fully apply all restored configs.${_reset}"
echo
read -r -p " Reboot now? [Y/n] " _reboot_yn
if [[ "${_reboot_yn,,}" != "n" ]]; then
echo " ${_green}${_reset} Rebooting in 3 seconds..."
sleep 3
systemctl reboot
else
echo
echo " Next steps:"
echo " 1. Reboot to fully apply config changes"
echo " 2. Run the Red Pill installer to set up the backup tool:"
echo " bash ~/Downloads/Redpill.sh"
echo
fi
RESTORE
_header "BACKUP COMPLETE"
_info "Snapshot" "$ts"
_info "Size" "$snap_size (hardlinked — actual new disk is smaller)"
if [[ "${REDPILL_AUTOBACKUP:-}" != "1" ]]; then
_info "Checksums" "generating in background (${cksum_hash%sum})"
else
_info "Checksums" "skipped (shutdown backup)"
fi
_info "Log" "$logfile"
# Show warnings if any issues
if (( rsync_exit != 0 )); then
_warn "rsync reported errors (exit code $rsync_exit). Check log for details."
else
_ok "All files backed up successfully"
fi
# Record last successful backup timestamp (used by redpill-notify)
# Exit 23 = partial transfer (e.g. ACL/xattr unsupported on FAT32/exFAT) — files are fine
if (( rsync_exit == 0 || rsync_exit == 23 )); then
date -Iseconds > "$STATE_DIR/last_backup"
fi
local skip_count
skip_count="$(grep -c 'Skip (not found)' "$logfile" 2>/dev/null)" || skip_count=0
if (( skip_count > 0 )); then
_warn "$skip_count path(s) were skipped (not found). See log for details."
fi
}
# ── Config-only local tarball (legacy / no-drive fallback) ──
config_backup(){
local tag="${1:-$(timestamp)}" comp ext out
comp="$(choose_comp)"; [[ "$comp" == zstd ]] && ext=tar.zst || ext=tar.gz
out="${LOCAL_BACKUP_DIR}/${tag}_$(hosttag).${ext}"
# Only back up config paths (lines before "# User data" or all if no marker)
local -a items=()
while IFS= read -r p; do
local x="${p/#\~/$HOME}"
if [[ -e "$x" ]]; then
items+=("$x")
fi
done < <(read_includes configs)
(( ${#items[@]} )) || { echo "Nothing to back up."; return 1; }
pushd "$HOME" >/dev/null
local -a rels=()
for f in "${items[@]}"; do
rels+=("${f/#$HOME\//}")
done
if [[ "$comp" == zstd ]]; then
tar --zstd -cpf "$out" "${rels[@]}"
else
tar -czpf "$out" "${rels[@]}"
fi
popd >/dev/null
_ok "Config backup written: $out"
}
# ── List snapshots on external drive ──
list_snapshots(){
local dest
dest="$(read_destination)" || { _err "No destination configured."; return 1; }
local snap_dir="$dest/redpill/snapshots"
if [[ ! -d "$snap_dir" ]]; then
printf ' %sNo snapshots found.%s\n' "$_c_dim" "$_c_reset"
return 1
fi
local -a snaps=()
while IFS= read -r d; do
snaps+=("$(basename "$d")")
done < <(find "$snap_dir" -mindepth 1 -maxdepth 1 -type d -not -name '.*' 2>/dev/null | sort -r)
if (( ${#snaps[@]} == 0 )); then
printf ' %sNo snapshots found.%s\n' "$_c_dim" "$_c_reset"
return 1
fi
local idx=0
for s in "${snaps[@]}"; do
idx=$((idx + 1))
local snap_size="?" log_status=""
if [[ -f "$snap_dir/$s/backup.log" ]]; then
snap_size="$(grep -oP 'Snapshot size: \K.*' "$snap_dir/$s/backup.log" 2>/dev/null)" || snap_size="?"
if grep -q "Status: SUCCESS" "$snap_dir/$s/backup.log" 2>/dev/null; then
log_status="${_c_green}ok${_c_reset}"
else
log_status="${_c_yellow}warnings${_c_reset}"
fi
fi
local mtag
mtag="$(_mode_tag "$(_snap_mode "$snap_dir/$s")")"
printf ' %s%2d.%s %s %-12s %s(%s)%s %s\n' "$_c_dim" "$idx" "$_c_reset" "$s" "$mtag" "$_c_dim" "$snap_size" "$_c_reset" "$log_status"
done
}
# ── List local tarballs (legacy) ──
list_local(){
ls -1t "$LOCAL_BACKUP_DIR"/*.tar.zst "$LOCAL_BACKUP_DIR"/*.tar.gz 2>/dev/null || true
}
# ── Delete backups ──
delete_backups(){
local -a del_items=() del_labels=()
local idx=0
# Find destination
local dest=""
if read_destination >/dev/null 2>&1; then
dest="$(read_destination)"
fi
# Collect external snapshots
if [[ -n "$dest" ]]; then
local snap_dir="$dest/redpill/snapshots"
if [[ -d "$snap_dir" ]]; then
while IFS= read -r d; do
local name snap_size="?" smode=""
name="$(basename "$d")"
smode="$(_snap_mode "$d")"
if [[ -f "$d/backup.log" ]]; then
snap_size="$(grep -oP 'Snapshot size: \K.*' "$d/backup.log" 2>/dev/null)" || snap_size="?"
fi
local mode_label
case "$smode" in
configs) mode_label="configs" ;; data) mode_label="data" ;; all) mode_label="full" ;; *) mode_label="?" ;;
esac
idx=$((idx + 1))
del_items+=("snap:$name")
del_labels+=("$idx" "[Drive] $name [$mode_label] ($snap_size)" "OFF")
done < <(find "$snap_dir" -mindepth 1 -maxdepth 1 -type d -not -name '.*' 2>/dev/null | sort -r)
fi
fi
# Collect local tarballs
while IFS= read -r f; do
[[ -n "$f" ]] || continue
local fsize
fsize="$(du -sh "$f" 2>/dev/null | cut -f1)" || fsize="?"
idx=$((idx + 1))
del_items+=("local:$f")
del_labels+=("$idx" "[Local] $(basename "$f") ($fsize)" "OFF")
done < <(ls -1t "$LOCAL_BACKUP_DIR"/*.tar.zst "$LOCAL_BACKUP_DIR"/*.tar.gz 2>/dev/null || true)
if (( ${#del_items[@]} == 0 )); then
if command -v whiptail >/dev/null; then
whiptail --title "$APP_TITLE" --msgbox "No backups found." 8 40
else
_warn "No backups found."
fi
return 0
fi
# Select which to delete
local -a picks=()
if command -v whiptail >/dev/null; then
local result
result=$(whiptail --title "$APP_TITLE" --checklist \
"Select backups to delete (Space to toggle, Enter to confirm)" \
20 100 12 "${del_labels[@]}" 3>&1 1>&2 2>&3) || return 0
# whiptail returns quoted space-separated values: "1" "3" "5"
read -ra picks <<< "$(echo "$result" | tr -d '"')"
else
_header "SELECT BACKUPS TO DELETE"
local i=0
for di in "${del_items[@]}"; do
i=$((i + 1))
local dtype="${di%%:*}" dval="${di#*:}"
if [[ "$dtype" == "snap" ]]; then
local dest_dir
dest_dir="$(read_destination 2>/dev/null)" || dest_dir=""
local mtag
mtag="$(_mode_tag "$(_snap_mode "$dest_dir/redpill/snapshots/$dval")")"
printf ' %s%2d.%s [Drive] %s %s\n' "$_c_dim" "$i" "$_c_reset" "$dval" "$mtag"
else
printf ' %s%2d.%s [Local] %s\n' "$_c_dim" "$i" "$_c_reset" "$(basename "$dval")"
fi
done
_hr
printf ' Enter numbers to delete (space-separated), or q to cancel: '
local input
read -r input
[[ "$input" != "q" && -n "$input" ]] || return 0
read -ra picks <<< "$input"
fi
(( ${#picks[@]} > 0 )) || return 0
# Validate all picks are in range
local valid_picks=()
for p in "${picks[@]}"; do
if [[ "$p" =~ ^[0-9]+$ ]] && (( p >= 1 && p <= ${#del_items[@]} )); then
valid_picks+=("$p")
fi
done
picks=("${valid_picks[@]}")
(( ${#picks[@]} > 0 )) || { _warn "No valid selections."; return 0; }
# Show what will be deleted and confirm
_header "CONFIRM DELETION"
for p in "${picks[@]}"; do
local sel="${del_items[$((p-1))]}"
local stype="${sel%%:*}" sval="${sel#*:}"
if [[ "$stype" == "snap" ]]; then
local mtag
mtag="$(_mode_tag "$(_snap_mode "$dest/redpill/snapshots/$sval")")"
printf ' %s✗%s [Drive] %s %s\n' "$_c_red" "$_c_reset" "$sval" "$mtag"
else
printf ' %s✗%s [Local] %s\n' "$_c_red" "$_c_reset" "$(basename "$sval")"
fi
done
_hr
echo
printf ' %sThis cannot be undone.%s\n' "${_c_red}${_c_bold}" "$_c_reset"
read -r -p " Type YES to confirm deletion: " confirm
[[ "$confirm" == "YES" ]] || { printf ' %sCancelled.%s\n' "$_c_dim" "$_c_reset"; return 0; }
# Perform deletion
local deleted=0
for p in "${picks[@]}"; do
local sel="${del_items[$((p-1))]}"
local stype="${sel%%:*}" sval="${sel#*:}"
if [[ "$stype" == "snap" && -n "$dest" ]]; then
local snap_path="$dest/redpill/snapshots/$sval"
if [[ -d "$snap_path" ]]; then
if ! rm -rf "$snap_path" 2>/dev/null; then
printf ' %s⚠%s Permission denied, retrying with sudo...\n' "$_c_yellow" "$_c_reset"
sudo rm -rf "$snap_path"
fi
deleted=$((deleted + 1))
printf ' %s✓%s Deleted snapshot: %s\n' "$_c_green" "$_c_reset" "$sval"
fi
elif [[ "$stype" == "local" ]]; then
if [[ -f "$sval" ]]; then
if ! rm -f "$sval" 2>/dev/null; then
printf ' %s⚠%s Permission denied, retrying with sudo...\n' "$_c_yellow" "$_c_reset"
sudo rm -f "$sval"
fi
deleted=$((deleted + 1))
printf ' %s✓%s Deleted local backup: %s\n' "$_c_green" "$_c_reset" "$(basename "$sval")"
fi
fi
done
# Fix latest symlink if it points to a deleted snapshot
if [[ -n "$dest" && -L "$dest/redpill/latest" ]]; then
if [[ ! -d "$dest/redpill/latest" ]]; then
rm -f "$dest/redpill/latest" 2>/dev/null || sudo rm -f "$dest/redpill/latest"
# Point to the newest remaining snapshot
local newest
newest="$(find "$dest/redpill/snapshots" -mindepth 1 -maxdepth 1 -type d -not -name '.*' 2>/dev/null | sort -r | head -n1)" || true
if [[ -n "$newest" ]]; then
ln -snf "snapshots/$(basename "$newest")" "$dest/redpill/latest"
printf '\n %s↳%s Updated latest → %s\n' "$_c_cyan" "$_c_reset" "$(basename "$newest")"
else
printf '\n %s(no snapshots remaining)%s\n' "$_c_dim" "$_c_reset"
fi
fi
fi
_ok "Deleted $deleted backup(s)"
}
# ── Verify backup integrity ──
# Finds the checksum manifest (xxh128 or sha256) and uses the matching tool
_find_cksum(){
# Returns: sets _ck_file and _ck_cmd for the caller
local snap="$1"
if [[ -f "$snap/checksums.xxh128" ]]; then
_ck_file="$snap/checksums.xxh128"; _ck_cmd="xxh128sum"
elif [[ -f "$snap/checksums.sha256" ]]; then
_ck_file="$snap/checksums.sha256"; _ck_cmd="sha256sum"
else
_ck_file=""; _ck_cmd=""
fi
}
verify_backup(){
# Find destination
local dest=""
if read_destination >/dev/null 2>&1; then
dest="$(read_destination)"
fi
if [[ -z "$dest" ]]; then
_err "No backup destination configured."
return 1
fi
local snap_dir="$dest/redpill/snapshots"
if [[ ! -d "$snap_dir" ]]; then
_err "No snapshots found."
return 1
fi
# Collect snapshots that have checksums
local -a snap_items=() snap_labels=()
local idx=0
while IFS= read -r d; do
local name
name="$(basename "$d")"
_find_cksum "$d"
if [[ -n "$_ck_file" ]]; then
# Check if still being generated (file is open by another process)
local status_tag=""
if fuser "$_ck_file" &>/dev/null; then
status_tag=" [generating...]"
fi
local fcount smode="" mode_label=""
fcount="$(wc -l < "$_ck_file" 2>/dev/null)" || fcount="?"
smode="$(_snap_mode "$d")"
case "$smode" in
configs) mode_label="configs" ;; data) mode_label="data" ;; all) mode_label="full" ;; *) mode_label="?" ;;
esac
idx=$((idx + 1))
snap_items+=("$name")
snap_labels+=("$idx" "$name [$mode_label] ($fcount files)$status_tag")
fi
done < <(find "$snap_dir" -mindepth 1 -maxdepth 1 -type d -not -name '.*' 2>/dev/null | sort -r)
if (( ${#snap_items[@]} == 0 )); then
_warn "No snapshots with checksum manifests found."
printf ' %sRun a new backup to generate checksums.%s\n' "$_c_dim" "$_c_reset"
return 1
fi
# Pick snapshot to verify
local pick
if command -v whiptail >/dev/null; then
pick=$(whiptail --title "$APP_TITLE" --menu "Select snapshot to verify" 18 70 10 \
"${snap_labels[@]}" 3>&1 1>&2 2>&3) || return 0
else
_header "SELECT SNAPSHOT TO VERIFY"
local i=0
for s in "${snap_items[@]}"; do
i=$((i + 1))
local mtag
mtag="$(_mode_tag "$(_snap_mode "$snap_dir/$s")")"
printf ' %s%2d.%s %s %s\n' "$_c_dim" "$i" "$_c_reset" "$s" "$mtag"
done
_hr
printf ' Enter number (or q to cancel): '
read -r pick
[[ "$pick" != "q" && -n "$pick" ]] || return 0
fi
if ! [[ "$pick" =~ ^[0-9]+$ ]] || (( pick < 1 || pick > ${#snap_items[@]} )); then
_err "Invalid selection: $pick"
return 1
fi
local snap_name="${snap_items[$((pick-1))]}"
local snap_path="$snap_dir/$snap_name"
_find_cksum "$snap_path"
[[ -n "$_ck_file" ]] || { _err "No checksum file in snapshot: $snap_name"; return 1; }
# Check if checksums are still being generated
if fuser "$_ck_file" &>/dev/null; then
_warn "Checksums are still being generated for this snapshot."
printf ' %sPlease wait for it to finish, then try again.%s\n' "$_c_dim" "$_c_reset"
return 1
fi
have "$_ck_cmd" || { _err "$_ck_cmd not found — cannot verify."; return 1; }
local total_files
total_files="$(wc -l < "$_ck_file")"
local cksum_name="${_ck_file##*/}"
_header "VERIFYING — $snap_name"
_info "Files to check" "$total_files"
_info "Hash" "${_ck_cmd%sum}"
echo
# Run checksum verification
local fail_count=0 pass_count=0 missing_count=0
local -a failures=()
while IFS= read -r line; do
if [[ "$line" == *": OK" ]]; then
pass_count=$((pass_count + 1))
if (( pass_count % 500 == 0 )); then
printf '\r %sVerified %d / %d files...%s' "$_c_dim" "$pass_count" "$total_files" "$_c_reset"
fi
elif [[ "$line" == *": FAILED" ]]; then
fail_count=$((fail_count + 1))
failures+=("${line%%:*}")
elif [[ "$line" == *"No such file"* || "$line" == *"FAILED open"* ]]; then
missing_count=$((missing_count + 1))
failures+=("MISSING: ${line%%:*}")
fi
done < <(cd "$snap_path" && "$_ck_cmd" --check "$cksum_name" 2>&1)
printf '\r %s %s\n' "$_c_dim" "$_c_reset"
_header "VERIFICATION RESULT"
_info "Snapshot" "$snap_name"
_info "Passed" "${_c_green}$pass_count${_c_reset} / $total_files"
if (( fail_count > 0 )); then
_info "Corrupted" "${_c_red}$fail_count${_c_reset}"
fi
if (( missing_count > 0 )); then
_info "Missing" "${_c_yellow}$missing_count${_c_reset}"
fi
if (( fail_count == 0 && missing_count == 0 )); then
_ok "All files intact — backup is healthy"
else
_warn "Issues detected"
if (( ${#failures[@]} > 0 )); then
echo
printf ' %sAffected files:%s\n' "$_c_dim" "$_c_reset"
local shown=0
for f in "${failures[@]}"; do
printf ' %s✗%s %s\n' "$_c_red" "$_c_reset" "$f"
shown=$((shown + 1))
if (( shown >= 20 )); then
local remaining=$(( ${#failures[@]} - shown ))
if (( remaining > 0 )); then
printf ' %s... and %d more%s\n' "$_c_dim" "$remaining" "$_c_reset"
fi
break
fi
done
fi
fi
}
# ── Restore from snapshot on external drive ──
restore_snapshot(){
local snap_name="${1:-}"
local dest
dest="$(read_destination)" || { echo "No destination configured."; return 1; }
local snap_dir="$dest/redpill/snapshots"
if [[ -z "$snap_name" ]]; then
# Use latest
if [[ -L "$dest/redpill/latest" && -d "$dest/redpill/latest" ]]; then
snap_name="$(basename "$(readlink -f "$dest/redpill/latest")")"
else
echo "No snapshots found. Run a backup first."
return 1
fi
fi
local source="$snap_dir/$snap_name"
[[ -d "$source" ]] || { _err "Snapshot not found: $source"; return 1; }
_header "RESTORING SNAPSHOT"
_info "Snapshot" "$snap_name"
_info "Source" "$source"
echo
# Restore without --delete (safe: won't remove files not in backup)
rsync -aHAX --info=progress2 --exclude='backup.log' --exclude='checksums.*' "$source/" "$HOME/"
_ok "Restore complete from: $snap_name"
# Fix paths if username/home directory changed since backup
fix_restored_paths "$source/backup.log"
# Reapply omarchy theme so the restored theme takes effect in the running session
if have omarchy-theme-set; then
local theme_name_file="$HOME/.config/omarchy/current/theme.name"
if [[ -f "$theme_name_file" ]]; then
local restored_theme
restored_theme="$(cat "$theme_name_file")"
if [[ -n "$restored_theme" ]]; then
_info "Reapplying theme" "$restored_theme"
omarchy-theme-set "$restored_theme" >/dev/null 2>&1 || true
_ok "Theme '$restored_theme' applied to running session"
fi
fi
fi
prompt_reboot
}
# ── Restore from local tarball (legacy) ──
restore_local(){
local a="${1:-}"
if [[ -z "$a" ]]; then
a="$(ls -1t "$LOCAL_BACKUP_DIR"/*.tar.zst "$LOCAL_BACKUP_DIR"/*.tar.gz 2>/dev/null | head -n1 || true)"
fi
[[ -f "$a" ]] || { echo "No local backups found."; return 1; }
local tmp
tmp="$(mktemp -d)"
tar -xpf "$a" -C "$tmp"
while IFS= read -r p; do
local x="${p/#\~/$HOME}"
[[ -e "$x" ]] || mkdir -p "$(dirname "$x")"
done < <(read_includes)
rsync -aHAX --info=NAME2 "$tmp/" "$HOME/"
rm -rf "$tmp"
_ok "Restore complete from local backup: $(basename "$a")"
# Detect old home from symlinks and fix paths if username changed
local old_home=""
old_home="$(find "$HOME/.config" -type l -exec readlink {} \; 2>/dev/null \
| grep -oP '^/home/[^/]+' | grep -vF "$HOME" | head -n1)" || true
if [[ -n "$old_home" && "$old_home" != "$HOME" ]]; then
# Create a temporary log-like input for fix_restored_paths
local tmp_log
tmp_log="$(mktemp)"
echo "Home: $old_home" > "$tmp_log"
fix_restored_paths "$tmp_log"
rm -f "$tmp_log"
fi
# Reapply omarchy theme so the restored theme takes effect in the running session
if have omarchy-theme-set; then
local theme_name_file="$HOME/.config/omarchy/current/theme.name"
if [[ -f "$theme_name_file" ]]; then
local restored_theme
restored_theme="$(cat "$theme_name_file")"
if [[ -n "$restored_theme" ]]; then
_info "Reapplying theme" "$restored_theme"
omarchy-theme-set "$restored_theme" >/dev/null 2>&1 || true
_ok "Theme '$restored_theme' applied to running session"
fi
fi
fi
prompt_reboot
}
# ── Auto-detect: scan mounted drives for redpill snapshots ──
auto_detect_destination(){
scan_drives
local i
for i in "${!_scan_dev[@]}"; do
local mnt="${_scan_mount[$i]}"
[[ "$mnt" != "(not mounted)" ]] || continue
if [[ -d "$mnt/redpill/snapshots" ]]; then
echo "$mnt"
return 0
fi
done
return 1
}
# ── Unified restore: try external drive first, fall back to local ──
restore(){
local arg="${1:-}"
# If given a .tar.zst or .tar.gz file, use local restore
if [[ -n "$arg" && "$arg" == *.tar.* ]]; then
restore_local "$arg"
return
fi
# Try configured destination
if read_destination >/dev/null 2>&1; then
local dest
dest="$(read_destination)"
if [[ -d "$dest/redpill/snapshots" ]]; then
restore_snapshot "$arg"
return
fi
fi
# No destination configured (or not mounted) — scan for drives with redpill snapshots
local detected
if detected="$(auto_detect_destination)"; then
_ok "Found redpill snapshots on: $detected"
echo "$detected" > "$DEST_FILE"
printf ' %sSaved as backup destination.%s\n' "$_c_dim" "$_c_reset"
restore_snapshot "$arg"
return
fi
# No auto-detect — offer to set up destination (drive may need mounting)
_warn "No backup drive found."
printf ' %sYour drive may need to be mounted. Setting up destination...%s\n' "$_c_dim" "$_c_reset"
echo
if set_destination; then
local dest
dest="$(read_destination)" || { _err "Destination not set."; return 1; }
if [[ -d "$dest/redpill/snapshots" ]]; then
restore_snapshot "$arg"
return
fi
fi
# Fall back to local tarball
_warn "No external drive snapshots available. Falling back to local backup."
restore_local "$arg"
}
# ── Toggle auto-backup on shutdown ──
toggle_autobackup(){
local svc="redpill-autobackup"
local svc_file="/etc/systemd/system/${svc}.service"
if systemctl is-enabled "$svc" &>/dev/null; then
_header "AUTO-BACKUP ON SHUTDOWN"
_info "Status" "${_c_green}enabled${_c_reset}"
echo
printf ' A full backup runs automatically every shutdown/reboot.\n'
printf ' %s(Skips silently if drive is unplugged or no first backup done)%s\n' "$_c_dim" "$_c_reset"
echo
read -r -p " Disable auto-backup? [y/N] " confirm
[[ "${confirm,,}" == "y" ]] || return 0
sudo systemctl disable "$svc"
_ok "Auto-backup on shutdown disabled (takes effect next boot)"
else
_header "AUTO-BACKUP ON SHUTDOWN"
_info "Status" "${_c_dim}disabled${_c_reset}"
echo
printf ' When enabled, a full backup runs automatically every time\n'
printf ' you shut down or reboot (if the drive is plugged in).\n'
printf ' %sSkips silently if no first backup has been done yet.%s\n' "$_c_dim" "$_c_reset"
echo
if [[ ! -f "$svc_file" ]]; then
_err "Service not installed. Re-run: bash ~/Downloads/Redpill.sh"
return 1
fi
read -r -p " Enable auto-backup? [Y/n] " confirm
[[ "${confirm,,}" == "n" ]] && return 0
sudo systemctl enable --now "$svc"
_ok "Auto-backup on shutdown enabled"
fi
}
# ── TUI ──
tui(){
# On launch: if no destination configured, scan for drives and offer setup
if ! read_destination >/dev/null 2>&1; then
scan_drives
if (( ${#_scan_dev[@]} > 0 )); then
local drive_summary="${#_scan_dev[@]} drive(s) detected"
if command -v whiptail >/dev/null; then
if whiptail --title "$APP_TITLE" --yesno "No backup destination set.\n$drive_summary — set one up now?" 10 50; then
set_destination || true
fi
else
echo "No backup destination set. $drive_summary."
read -r -p "Set one up now? [Y/n] " yn
[[ "${yn,,}" == "n" ]] || set_destination || true
fi
fi
fi
if command -v whiptail >/dev/null; then
while true; do
local choice
local ab_tag="OFF"
systemctl is-enabled redpill-autobackup &>/dev/null && ab_tag="ON"
choice=$(whiptail --title "$APP_TITLE" --menu "Choose" 26 70 14 \
1 "Backup configs only" \
2 "Backup user data only" \
3 "Backup everything" \
4 "Restore (choose)" \
5 "List backups" \
6 "Delete backups" \
7 "Verify backup" \
8 "Edit include list" \
9 "Edit exclude list" \
10 "Set backup destination" \
11 "Auto-backup on shutdown [$ab_tag]" \
12 "Show paths" \
13 "Exit" \
3>&1 1>&2 2>&3) || break
case "$choice" in
1|2|3)
if ! read_destination >/dev/null 2>&1; then
if whiptail --title "$APP_TITLE" --yesno "No backup destination set.\nSet one now?" 10 50; then
set_destination || continue
else
continue
fi
fi
local bk_mode
case "$choice" in
1) bk_mode="configs" ;;
2) bk_mode="data" ;;
3) bk_mode="all" ;;
esac
estimate_backup "$bk_mode" || { _pause; continue; }
echo
read -r -p " Proceed with backup? [Y/n] " confirm
[[ "${confirm,,}" == "n" ]] && continue
pre_backup_note
mk_backup "$bk_mode"
_pause
;;
4)
# Collect external snapshots
local -a all_items=() all_labels=()
local idx=0
# Find destination: configured, auto-detect, or prompt to set up
local dest=""
if read_destination >/dev/null 2>&1; then
dest="$(read_destination)"
elif dest="$(auto_detect_destination)"; then
echo "$dest" > "$DEST_FILE"
else
if whiptail --title "$APP_TITLE" --yesno "No backup drive found.\nSet up a backup destination to find your snapshots?" 10 55; then
set_destination || true
read_destination >/dev/null 2>&1 && dest="$(read_destination)"
fi
fi
if [[ -n "$dest" ]]; then
local snap_dir="$dest/redpill/snapshots"
if [[ -d "$snap_dir" ]]; then
while IFS= read -r d; do
local name smode="" mode_label=""
name="$(basename "$d")"
smode="$(_snap_mode "$d")"
case "$smode" in
configs) mode_label="configs" ;; data) mode_label="data" ;; all) mode_label="full" ;; *) mode_label="?" ;;
esac
all_items+=("snap:$name")
idx=$((idx + 1))
all_labels+=("$idx" "[Drive] $name [$mode_label]")
done < <(find "$snap_dir" -mindepth 1 -maxdepth 1 -type d -not -name '.*' 2>/dev/null | sort -r)
fi
fi
# Collect local tarballs
while IFS= read -r f; do
[[ -n "$f" ]] || continue
idx=$((idx + 1))
all_items+=("local:$f")
all_labels+=("$idx" "[Local] $(basename "$f")")
done < <(ls -1t "$LOCAL_BACKUP_DIR"/*.tar.zst "$LOCAL_BACKUP_DIR"/*.tar.gz 2>/dev/null || true)
if (( ${#all_items[@]} == 0 )); then
whiptail --title "$APP_TITLE" --msgbox "No backups found." 8 40
continue
fi
local pick
pick=$(whiptail --title "$APP_TITLE" --menu "Select backup to restore" 20 100 12 "${all_labels[@]}" 3>&1 1>&2 2>&3) || continue
local selected="${all_items[$((pick-1))]}"
local stype="${selected%%:*}" sval="${selected#*:}"
if [[ "$stype" == "snap" ]]; then
restore_snapshot "$sval"
else
restore_local "$sval"
fi
_pause
;;
5)
_header "EXTERNAL DRIVE SNAPSHOTS"
list_snapshots 2>/dev/null || printf ' %s(none)%s\n' "$_c_dim" "$_c_reset"
_header "LOCAL CONFIG BACKUPS"
local -a local_files=()
mapfile -t local_files < <(list_local)
if (( ${#local_files[@]} == 0 )); then
printf ' %s(none)%s\n' "$_c_dim" "$_c_reset"
else
local li=0
for lf in "${local_files[@]}"; do
[[ -n "$lf" ]] || continue
li=$((li + 1))
local lf_size
lf_size="$(du -sh "$lf" 2>/dev/null | cut -f1)" || lf_size="?"
printf ' %s%2d.%s %s %s(%s)%s\n' "$_c_dim" "$li" "$_c_reset" "$(basename "$lf")" "$_c_dim" "$lf_size" "$_c_reset"
done
fi
_pause
;;
6) delete_backups || true; _pause ;;
7) verify_backup || true; _pause ;;
8) "$(tui_editor)" "$INCLUDES_FILE" ;;
9) "$(tui_editor)" "$EXCLUDES_FILE" ;;
10) set_destination || true; _pause ;;
11) toggle_autobackup || true; _pause ;;
12)
local dest_display
dest_display="$(read_destination 2>/dev/null)" || dest_display="(not set)"
_header "PATHS & CONFIGURATION"
_info "Destination" "$dest_display"
_info "Local backups" "$LOCAL_BACKUP_DIR"
_info "Config dir" "$CONF_DIR"
_info "Includes file" "$INCLUDES_FILE"
_info "Excludes file" "$EXCLUDES_FILE"
_info "Destination file" "$DEST_FILE"
_pause
;;
13) break ;;
esac
done
else
# Fallback: no whiptail
PS3="redpill> "
select a in "Backup configs only" "Backup user data only" "Backup everything" "Restore (latest)" "List backups" "Delete backups" "Verify backup" "Edit include list" "Edit exclude list" "Set backup destination" "Auto-backup on shutdown" "Show paths" "Quit"; do
case "$REPLY" in
1|2|3)
if ! read_destination >/dev/null 2>&1; then
echo "No backup destination set."
set_destination || continue
fi
local bk_mode
case "$REPLY" in
1) bk_mode="configs" ;;
2) bk_mode="data" ;;
3) bk_mode="all" ;;
esac
estimate_backup "$bk_mode" || continue
echo
read -r -p " Proceed with backup? [Y/n] " confirm
[[ "${confirm,,}" == "n" ]] && continue
pre_backup_note; mk_backup "$bk_mode"
;;
4) restore ;;
5)
_header "EXTERNAL DRIVE SNAPSHOTS"
list_snapshots 2>/dev/null || printf ' %s(none)%s\n' "$_c_dim" "$_c_reset"
_header "LOCAL CONFIG BACKUPS"
list_local || printf ' %s(none)%s\n' "$_c_dim" "$_c_reset"
;;
6) delete_backups || true ;;
7) verify_backup || true ;;
8) "$(tui_editor)" "$INCLUDES_FILE" ;;
9) "$(tui_editor)" "$EXCLUDES_FILE" ;;
10) set_destination || true ;;
11) toggle_autobackup || true ;;
12)
local dest_display
dest_display="$(read_destination 2>/dev/null)" || dest_display="(not set)"
_header "PATHS & CONFIGURATION"
_info "Destination" "$dest_display"
_info "Local backups" "$LOCAL_BACKUP_DIR"
_info "Config dir" "$CONF_DIR"
_info "Includes file" "$INCLUDES_FILE"
_info "Excludes file" "$EXCLUDES_FILE"
_info "Destination file" "$DEST_FILE"
;;
13) break ;;
*) continue ;;
esac
done
fi
}
# ── CLI dispatch ──
case "${1:-tui}" in
tui) tui ;;
backup) estimate_backup "${2:-all}" && mk_backup "${2:-all}" ;;
estimate) estimate_backup "${2:-all}" ;;
config-backup) config_backup "${2:-}" ;;
list) list_snapshots 2>/dev/null || true; echo; list_local ;;
restore) restore "${2:-}" ;;
verify) verify_backup ;;
includes) "$(tui_editor)" "$INCLUDES_FILE" ;;
excludes) "$(tui_editor)" "$EXCLUDES_FILE" ;;
autobackup) mk_backup "${2:-all}" ;;
destination) set_destination ;;
where)
dest_display="$(read_destination 2>/dev/null)" || dest_display="(not set)"
_header "PATHS & CONFIGURATION"
_info "Destination" "$dest_display"
_info "Local backups" "$LOCAL_BACKUP_DIR"
_info "Config dir" "$CONF_DIR"
_info "Includes file" "$INCLUDES_FILE"
_info "Excludes file" "$EXCLUDES_FILE"
_info "Destination file" "$DEST_FILE"
;;
*) echo "Usage: redpill [tui|backup [configs|data|all]|autobackup [configs|data|all]|estimate [configs|data|all]|config-backup|restore [FILE|SNAP]|verify|list|includes|excludes|destination|where]" ;;
esac
BASH
if ! sudo chmod +x /usr/local/bin/redpill; then
echo "Error: Failed to make redpill executable" >&2
exit 1
fi
# 2) bluepill (TTY restore with 5s quote) — tries external drive, falls back to local
sudo tee /usr/local/bin/bluepill >/dev/null <<'BASH'
#!/usr/bin/env bash
set -euo pipefail
echo
echo 'You take the blue pill, the story ends, you wake up in your bed and believe whatever you want to believe'
sleep 5
exec redpill restore "$@"
BASH
if ! sudo chmod +x /usr/local/bin/bluepill; then
echo "Error: Failed to make bluepill executable" >&2
exit 1
fi
# 3) Applications entry — write to BOTH system & user dirs
USER_DESK="$HOME/.local/share/applications/redpill.desktop"
SYS_DESK="/usr/share/applications/redpill.desktop"
mkdir -p "$(dirname "$USER_DESK")"
cat > "$USER_DESK" <<'DESK'
[Desktop Entry]
Name=RED PILL
Comment=Unified backup & restore for Hyprland configs and user data
Exec=alacritty --title "RED PILL" -e redpill
Terminal=false
Type=Application
Categories=System;Utility;
NoDisplay=false
StartupNotify=false
Keywords=hyprland;backup;restore;configs;redpill;bluepill;rsync;
DESK
if ! sudo tee "$SYS_DESK" >/dev/null <<'DESK'; then
[Desktop Entry]
Name=RED PILL
Comment=Unified backup & restore for Hyprland configs and user data
Exec=alacritty --title "RED PILL" -e redpill
Terminal=false
Type=Application
Categories=System;Utility;
NoDisplay=false
StartupNotify=false
Keywords=hyprland;backup;restore;configs;redpill;bluepill;rsync;
DESK
echo "Warning: Failed to create system desktop entry" >&2
fi
update-desktop-database "$HOME/.local/share/applications" >/dev/null 2>&1 || true
sudo update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
# 4) Walker: unhide Applications (sed-only) + add custom command safety net
WCONF="$HOME/.config/walker/config.toml"
mkdir -p "$(dirname "$WCONF")"
if [ ! -f "$WCONF" ]; then
cat >"$WCONF" <<'TOML'
[builtins.applications]
launch_prefix = "uwsm app -- "
hidden = false
[builtins.custom_commands]
hidden = false
[[builtins.custom_commands.entries]]
label = "RED PILL"
command = "alacritty --title 'RED PILL' -e redpill"
keywords = ["redpill","backup","restore","hyprland"]
icon = ""
TOML
else
if grep -q '^\[builtins\.applications\]' "$WCONF"; then
sed -i '/^\[builtins\.applications\]/,/^\[/{s/^\s*hidden\s*=.*/hidden = false/}' "$WCONF"
if ! sed -n '/^\[builtins\.applications\]/,/^\[/{/^\s*hidden\s*=/p}' "$WCONF" | grep -q .; then
sed -i '/^\[builtins\.applications\]/a hidden = false' "$WCONF"
fi
if ! sed -n '/^\[builtins\.applications\]/,/^\[/{/^\s*launch_prefix\s*=/p}' "$WCONF" | grep -q .; then
sed -i '/^\[builtins\.applications\]/a launch_prefix = "uwsm app -- "' "$WCONF"
fi
else
cat >>"$WCONF" <<'TOML'
[builtins.applications]
launch_prefix = "uwsm app -- "
hidden = false
TOML
fi
if ! grep -q '^\[builtins\.custom_commands\]' "$WCONF"; then
cat >>"$WCONF" <<'TOML'
[builtins.custom_commands]
hidden = false
TOML
fi
if ! grep -q 'label = "RED PILL"' "$WCONF"; then
cat >>"$WCONF" <<'TOML'
[[builtins.custom_commands.entries]]
label = "RED PILL"
command = "alacritty --title 'RED PILL' -e redpill"
keywords = ["redpill","backup","restore","hyprland"]
icon = ""
TOML
fi
fi
# 5) Hyprland keybinding: Super+Alt+B → floating RED PILL window
BINDINGS_FILE="$HOME/.config/hypr/bindings.conf"
# Detect preferred terminal with --title support
REDPILL_TERM=""
if command -v ghostty >/dev/null 2>&1; then
REDPILL_TERM="ghostty --title='RED PILL' -e"
elif command -v kitty >/dev/null 2>&1; then
REDPILL_TERM="kitty --title='RED PILL' -e"
elif command -v alacritty >/dev/null 2>&1; then
REDPILL_TERM="alacritty --title='RED PILL' -e"
elif command -v foot >/dev/null 2>&1; then
REDPILL_TERM="foot --title='RED PILL' --"
fi
if [[ -n "$REDPILL_TERM" && -f "$BINDINGS_FILE" ]]; then
if ! grep -q "# Red Pill Backup TUI" "$BINDINGS_FILE"; then
echo "Adding keybinding (Super+Alt+B) to $BINDINGS_FILE"
{
echo ""
echo "# Red Pill Backup TUI"
echo "windowrule = match:title RED PILL, float on"
echo "windowrule = match:title RED PILL, size 800 600"
echo "windowrule = match:title RED PILL, center on"
echo "windowrule = match:title RED PILL, pin on"
echo "bindd = SUPER ALT, B, Red Pill Backup, exec, $REDPILL_TERM redpill"
echo "# End Red Pill Backup TUI"
} >> "$BINDINGS_FILE"
fi
# Apply dynamically so no Hyprland reload needed
if command -v hyprctl >/dev/null 2>&1 && hyprctl monitors -j &>/dev/null; then
hyprctl keyword unbind "SUPER ALT, B" >/dev/null 2>&1 || true
hyprctl keyword windowrule "match:title RED PILL, float on" >/dev/null 2>&1 || true
hyprctl keyword windowrule "match:title RED PILL, size 800 600" >/dev/null 2>&1 || true
hyprctl keyword windowrule "match:title RED PILL, center on" >/dev/null 2>&1 || true
hyprctl keyword windowrule "match:title RED PILL, pin on" >/dev/null 2>&1 || true
hyprctl keyword bindd "SUPER ALT, B, Red Pill Backup, exec, $REDPILL_TERM redpill" >/dev/null 2>&1 || true
fi
fi
# 6) Config directory (no custom theming - uses system defaults)
mkdir -p "$HOME/.config/redpill"
# 7) Create RECOVERY.txt guide
cat > "$HOME/.config/redpill/RECOVERY.txt" <<'RECOVERY'
RED PILL - Recovery Guide
==============================
NEW MACHINE RESTORE:
1. Install omarchy on the new machine
2. Plug in your backup drive and mount it
3. Run: bash /run/media/$USER/<drive>/redpill/restore.sh
4. Log out and back in (or reboot) to apply configs
5. Re-install Red Pill:
bash ~/Downloads/red\ pill\ update\ /Redpill.sh
The restore script is self-contained — it only needs rsync
(which ships with omarchy). No install required.
QUICK RESTORE (same machine, TTY):
1. Press Ctrl+Alt+F2 to switch to TTY2
2. Login with your username and password
3. Run: bluepill
This restores from the latest snapshot on your external drive.
If no drive is found, it falls back to the latest local config backup.
MANUAL RESTORE:
List all backups:
redpill list
Restore a specific drive snapshot:
redpill restore 2026-02-08_14-30-00
Restore a specific local tarball:
redpill restore /path/to/backup.tar.zst
VERIFY BACKUP INTEGRITY:
redpill verify
CONFIG-ONLY BACKUP (no external drive needed):
redpill config-backup
AUTO-BACKUP ON SHUTDOWN:
A systemd service runs a full backup every time you shut down
or reboot — but only after your first manual backup is done.
If the backup drive isn't plugged in, it silently skips.
Check status: systemctl status redpill-autobackup
View last log: journalctl -u redpill-autobackup -b -1
Test manually: sudo systemctl stop redpill-autobackup
Disable: sudo systemctl disable redpill-autobackup
SETUP:
Set backup destination (external drive):
redpill destination
Edit what gets backed up:
redpill includes
Edit exclusion patterns:
redpill excludes
PATHS:
Backup destination: ~/.config/redpill/destination.conf
Include list: ~/.config/redpill/includes.conf
Exclude patterns: ~/.config/redpill/excludes.conf
Local backups: ~/.local/state/redpill/backups/
Config dir: ~/.config/redpill/
SNAPSHOT STRUCTURE (on external drive):
<destination>/redpill/
├── restore.sh # standalone restore for new machines
├── latest/ # symlink to most recent snapshot
└── snapshots/
├── 2026-02-08_14-30-00/ # full tree (hardlinked to prev)
│ ├── checksums.xxh128 # integrity manifest
│ └── backup.log # backup report
└── 2026-02-08_10-00-00/
Each snapshot is a complete directory tree. Unchanged files are
hardlinked to the previous snapshot, so they use no extra disk space.
You can browse any snapshot directly in a file manager.
RECOVERY
# 8) Auto-backup on shutdown — systemd service + wrapper script
# Wrapper: checks preconditions silently, runs full backup if ready
sudo tee /usr/local/bin/redpill-autobackup >/dev/null <<'AUTOBACKUP'
#!/usr/bin/env bash
set -euo pipefail
# Resolve the real user (systemd sets USER/HOME via service env)
CONF_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/redpill"
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/redpill"
DEST_FILE="$CONF_DIR/destination.conf"
STATUS_FILE="$STATE_DIR/last_autobackup"
LAST_BACKUP_FILE="$STATE_DIR/last_backup"
mkdir -p "$STATE_DIR"
# Write status file and exit
write_status(){
local status="$1" reason="${2:-}"
{
echo "timestamp=$(date -Iseconds)"
echo "status=$status"
[[ -n "$reason" ]] && echo "reason=$reason" || true
} > "$STATUS_FILE"
}
# Forward SIGTERM to entire process group so rsync dies with us
cleanup(){
write_status error "signal_killed" 2>/dev/null || true
kill -- -$$ 2>/dev/null || true
exit 1
}
trap cleanup SIGTERM SIGINT
# Precondition 1: destination configured
if [[ ! -f "$DEST_FILE" ]]; then
write_status skipped no_destination
exit 0
fi
DEST="$(head -n1 "$DEST_FILE" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
if [[ -z "$DEST" ]]; then
write_status skipped no_destination
exit 0
fi
# Precondition 2: destination is mounted/accessible
if [[ ! -d "$DEST" ]]; then
write_status skipped drive_not_mounted
exit 0
fi
# Precondition 3: at least one snapshot exists (first backup was done manually)
SNAP_DIR="$DEST/redpill/snapshots"
if [[ ! -d "$SNAP_DIR" ]]; then
write_status skipped no_snapshots
exit 0
fi
SNAP_COUNT="$(find "$SNAP_DIR" -mindepth 1 -maxdepth 1 -type d -not -name '.*' 2>/dev/null | wc -l)"
if (( SNAP_COUNT == 0 )); then
write_status skipped no_snapshots
exit 0
fi
# All preconditions met — run full backup (autobackup skips estimate dry-run + checksums)
echo "redpill-autobackup: starting full backup to $DEST"
export REDPILL_AUTOBACKUP=1
if /usr/local/bin/redpill autobackup all; then
trap - SIGTERM SIGINT
write_status success
date -Iseconds > "$LAST_BACKUP_FILE"
else
write_status error "backup_failed"
fi
AUTOBACKUP
sudo chmod +x /usr/local/bin/redpill-autobackup
# Systemd service: ExecStop fires on shutdown/reboot
sudo tee /etc/systemd/system/redpill-autobackup.service >/dev/null <<EOF
[Unit]
Description=Red Pill auto-backup on shutdown
Before=umount.target
After=local-fs.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/true
ExecStop=/usr/local/bin/redpill-autobackup
User=$USER
Group=$(id -gn)
Environment=HOME=$HOME
Environment=XDG_CONFIG_HOME=$HOME/.config
Environment=XDG_STATE_HOME=$HOME/.local/state
Environment=USER=$USER
KillMode=mixed
TimeoutStopSec=600
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
# Don't enable here — user opts in via TUI menu option 11
# 9) Login notification checker — reminds user to plug in backup drive
sudo tee /usr/local/bin/redpill-notify >/dev/null <<'NOTIFY'
#!/usr/bin/env bash
set -euo pipefail
# Wait for notification daemon (mako) to be ready
sleep 5
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/redpill"
LAST_BACKUP_FILE="$STATE_DIR/last_backup"
# If auto-backup isn't enabled, nothing to nag about
systemctl is-enabled redpill-autobackup &>/dev/null || exit 0
# If no last_backup file, this is the first run — remind to do a backup
if [[ ! -f "$LAST_BACKUP_FILE" ]]; then
notify-send -a "Red Pill" "Red Pill Backup" \
"No backup on record — plug in your drive and run Red Pill"
exit 0
fi
# Parse last backup timestamp and check age
last_ts="$(head -n1 "$LAST_BACKUP_FILE" 2>/dev/null)" || exit 0
[[ -n "$last_ts" ]] || exit 0
last_epoch="$(date -d "$last_ts" +%s 2>/dev/null)" || exit 0
now_epoch="$(date +%s)"
age_days=$(( (now_epoch - last_epoch) / 86400 ))
if (( age_days >= 7 )); then
notify-send -a "Red Pill" "Red Pill Backup" \
"No backup in ${age_days} days — plug in your drive"
fi
NOTIFY
sudo chmod +x /usr/local/bin/redpill-notify
# Add redpill-notify to Hyprland autostart (once only)
AUTOSTART_FILE="$HOME/.config/hypr/autostart.conf"
if [[ -f "$AUTOSTART_FILE" ]]; then
if ! grep -q 'redpill-notify' "$AUTOSTART_FILE"; then
echo "Adding redpill-notify to $AUTOSTART_FILE"
{
echo ""
echo "# Red Pill backup reminder on login"
echo "exec-once = redpill-notify"
} >> "$AUTOSTART_FILE"
fi
fi
# 10) Reindex and restart Walker so the new entry is searchable immediately
update-desktop-database "$HOME/.local/share/applications" >/dev/null 2>&1 || true
sudo update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
# Kill walker more safely - only exact process name match
pkill -x walker >/dev/null 2>&1 || true
echo "Installed:"
echo " - redpill (TUI) at /usr/local/bin/redpill"
echo " - bluepill (quick restore) at /usr/local/bin/bluepill"
echo " - Super+Alt+B keybinding (floating window)"
echo " - Auto-backup on shutdown (enable in menu option 11)"
echo " - Backup reminder notifications on login"
echo " - TTY guide at ~/.config/redpill/RECOVERY.txt"
echo ""
echo "Next steps:"
echo " 1. Press Super+Alt+B (or run: redpill)"
echo " 2. Set your backup destination (option 10)"
echo " 3. Run 'Backup configs/data/everything'"
echo " 4. Enable auto-backup on shutdown (option 11 in menu)"
echo ""
echo "Open your launcher (Super + Space) and search: RED PILL"