Integrates Borg backups for emulators

• Removes legacy backup scripts
• Adds new Borg wrapper with automated backup and rotation
• Introduces a TUI for managing Borg repositories
• Updates emulator launchers to use the new backup mechanism
This commit is contained in:
Chris Toph 2025-04-19 20:53:56 -04:00
parent f909328573
commit 6bee5e8d80
5 changed files with 376 additions and 361 deletions

View file

@ -1,345 +0,0 @@
{ pkgs, ... }:
pkgs.writeScript "backup-wrapper" ''
#!/usr/bin/env fish
#==========================================================#
# Function definitions #
#==========================================================#
# Set up colors for prettier output
set -l blue (set_color blue)
set -l green (set_color green)
set -l yellow (set_color yellow)
set -l red (set_color red)
set -l cyan (set_color cyan)
set -l magenta (set_color magenta)
set -l bold (set_color --bold)
set -l normal (set_color normal)
# Define log file path
set -g log_file ""
function setup_logging
set -g log_file "$argv[1]/backup.log"
echo "# Backup Wrapper Log - Started at "(date) > $log_file
echo "# =====================================================" >> $log_file
end
# Use conditional tee: if log_file is set, tee output; otherwise echo normally.
function print_header
set -l header "$blue[ $bold$argv[1]$normal$blue ]$normal"
if test -n "$log_file"
echo $header | tee -a $log_file
else
echo $header
end
end
function print_step
set -l msg "$green $bold$argv[1]$normal"
if test -n "$log_file"
echo $msg | tee -a $log_file
else
echo $msg
end
end
function print_info
set -l msg "$cyan$normal $argv[1]"
if test -n "$log_file"
echo $msg | tee -a $log_file
else
echo $msg
end
end
function print_warning
set -l msg "$yellow$normal $argv[1]"
if test -n "$log_file"
echo $msg | tee -a $log_file >&2
else
echo $msg >&2
end
end
function print_error
set -l msg "$red$normal $argv[1]"
if test -n "$log_file"
echo $msg | tee -a $log_file >&2
else
echo $msg >&2
end
end
function print_success
set -l msg "$green$normal $argv[1]"
if test -n "$log_file"
echo $msg | tee -a $log_file
else
echo $msg
end
end
function print_usage
print_header "Backup Wrapper Usage"
if test -n "$log_file"
echo "Usage: backup_wrapper [OPTIONS] -- COMMAND [ARGS...]" | tee -a $log_file
echo "Options:" | tee -a $log_file
echo " -p, --path PATH Path to backup" | tee -a $log_file
echo " -o, --output PATH Output directory for backups" | tee -a $log_file
echo " -m, --max NUMBER Maximum number of backups to keep (default: 5)" | tee -a $log_file
echo " -d, --delay SECONDS Delay before backup after changes (default: 5)" | tee -a $log_file
echo " -h, --help Show this help message" | tee -a $log_file
else
echo "Usage: backup_wrapper [OPTIONS] -- COMMAND [ARGS...]"
echo "Options:"
echo " -p, --path PATH Path to backup"
echo " -o, --output PATH Output directory for backups"
echo " -m, --max NUMBER Maximum number of backups to keep (default: 5)"
echo " -d, --delay SECONDS Delay before backup after changes (default: 5)"
echo " -h, --help Show this help message"
end
end
# This is the critical function - needs to return *only* the backup file path
function backup_path
set -l src $argv[1]
set -l out $argv[2]
set -l timestamp (date +"%Y%m%d-%H%M%S")
set -l backup_file "$out/backup-$timestamp.tar.zst"
# Log messages to stderr so they don't interfere with the function output
echo "$green$normal Backing up to $yellow$backup_file$normal" >&2 | tee -a $log_file
pushd (dirname "$src") >/dev/null
tar cf - (basename "$src") | ${pkgs.zstd}/bin/zstd -c -T5 -15 > "$backup_file" 2>> $log_file
set -l exit_status $status
popd >/dev/null
if test $exit_status -eq 0
# IMPORTANT: Only output the backup file path, nothing else
echo $backup_file
else
echo "$red$normal Backup operation failed!" >&2 | tee -a $log_file
return 1
end
end
function rotate_backups
set -l output_dir $argv[1]
set -l max_backups $argv[2]
set -l backups (ls -t "$output_dir"/backup-*.tar.zst 2>/dev/null)
set -l num_backups (count $backups)
if test $num_backups -gt $max_backups
print_step "Rotating backups, keeping $max_backups of $num_backups"
for i in (seq (math "$max_backups + 1") $num_backups)
print_info "Removing old backup: $yellow$backups[$i]$normal"
rm -f "$backups[$i]"
end
end
end
#==========================================================#
# Argument parsing #
#==========================================================#
# Parse arguments
set -l backup_path ""
set -l output_dir ""
set -l max_backups 5
set -l delay 5
set -l cmd ""
while count $argv > 0
switch $argv[1]
case -h --help
print_usage
exit 0
case -p --path
set -e argv[1]
set backup_path $argv[1]
set -e argv[1]
case -o --output
set -e argv[1]
set output_dir $argv[1]
set -e argv[1]
case -m --max
set -e argv[1]
set max_backups $argv[1]
set -e argv[1]
case -d --delay
set -e argv[1]
set delay $argv[1]
set -e argv[1]
case --
set -e argv[1]
set cmd $argv
break
case '*'
print_error "Unknown option $argv[1]"
print_usage
exit 1
end
end
#==========================================================#
# Validation & Setup #
#==========================================================#
# Ensure the output directory exists
mkdir -p "$output_dir" 2>/dev/null
# Set up logging
setup_logging "$output_dir"
print_header "Backup Wrapper Starting"
# Log the original command
echo "# Original command: $argv" >> $log_file
# Validate arguments
if test -z "$backup_path" -o -z "$output_dir" -o -z "$cmd"
print_error "Missing required arguments"
print_usage
exit 1
end
# Display configuration
print_info "Backup path: $yellow$backup_path$normal"
print_info "Output path: $yellow$output_dir$normal"
print_info "Max backups: $yellow$max_backups$normal"
print_info "Backup delay: $yellow$delay seconds$normal"
print_info "Command: $yellow$cmd$normal"
print_info "Log file: $yellow$log_file$normal"
# Validate the backup path exists
if not test -e "$backup_path"
print_error "Backup path '$backup_path' does not exist"
exit 1
end
#==========================================================#
# Initial backup #
#==========================================================#
print_header "Creating Initial Backup"
# Using command substitution to capture only the path output
set -l initial_backup (backup_path "$backup_path" "$output_dir")
set -l status_code $status
if test $status_code -ne 0
print_error "Initial backup failed"
exit 1
end
print_success "Initial backup created: $yellow$initial_backup$normal"
#==========================================================#
# Start wrapped process #
#==========================================================#
print_header "Starting Wrapped Process"
# Start the wrapped process in the background
print_step "Starting wrapped process: $yellow$cmd$normal"
# Using exactly the same execution method as the original working script
$cmd >> $log_file 2>&1 &
set -l pid $last_pid
print_success "Process started with PID: $yellow$pid$normal"
# Set up cleanup function
function cleanup --on-signal INT --on-signal TERM
print_warning "Caught signal, cleaning up..."
kill $pid 2>/dev/null
wait $pid 2>/dev/null
echo "# Script terminated by signal at "(date) >> $log_file
exit 0
end
#==========================================================#
# Monitoring loop #
#==========================================================#
print_header "Monitoring for Changes"
# Monitor for changes and create backups
set -l change_detected 0
set -l last_backup_time (date +%s)
print_step "Monitoring $yellow$backup_path$normal for changes..."
while true
# Check if the process is still running
if not kill -0 $pid 2>/dev/null
print_warning "Wrapped process exited, stopping monitor"
break
end
# Using inotifywait to detect changes
${pkgs.inotify-tools}/bin/inotifywait -r -q -e modify,create,delete,move "$backup_path" -t 1
set -l inotify_status $status
if test $inotify_status -eq 0
# Change detected
set change_detected 1
set -l current_time (date +%s)
set -l time_since_last (math "$current_time - $last_backup_time")
if test $time_since_last -ge $delay
print_step "Changes detected, creating backup"
set -l new_backup (backup_path "$backup_path" "$output_dir")
set -l backup_status $status
if test $backup_status -eq 0
print_success "Backup created: $yellow$new_backup$normal"
rotate_backups "$output_dir" "$max_backups"
set last_backup_time (date +%s)
set change_detected 0
else
print_error "Backup failed"
end
else
print_info "Change detected, batching with other changes ($yellow$delay$normal seconds delay)"
end
else if test $change_detected -eq 1
# No new changes but we had some changes before
set -l current_time (date +%s)
set -l time_since_last (math "$current_time - $last_backup_time")
if test $time_since_last -ge $delay
print_step "Creating backup after batching changes"
set -l new_backup (backup_path "$backup_path" "$output_dir")
set -l backup_status $status
if test $backup_status -eq 0
print_success "Backup created: $yellow$new_backup$normal"
rotate_backups "$output_dir" "$max_backups"
set last_backup_time (date +%s)
set change_detected 0
else
print_error "Backup failed"
end
end
end
end
#==========================================================#
# Cleanup & Exit #
#==========================================================#
print_header "Finishing Up"
# Wait for the wrapped process to finish
print_step "Waiting for process to finish..."
wait $pid
set -l exit_code $status
print_success "Process finished with exit code: $yellow$exit_code$normal"
# Add final log entry
echo "# Script completed at "(date)" with exit code $exit_code" >> $log_file
exit $exit_code
''

View file

@ -1,3 +0,0 @@
{
# :D
}

View file

@ -1,5 +1,4 @@
# This module just provides a customized .desktop file with gamescope args dynamically created based on the
# host's monitors configuration
# switch.nix
{
pkgs,
config,
@ -8,26 +7,156 @@
}:
let
path = lib.custom.relativeToRoot "pkgs/common/citron-emu/package.nix";
citron-emu = pkgs.callPackage path { inherit pkgs; };
backup-wrapper = import ./scripts/backup.nix { inherit pkgs; };
citron-emu = pkgs.callPackage (lib.custom.relativeToRoot "pkgs/common/citron-emu/package.nix") {
inherit pkgs;
};
borgtui = pkgs.callPackage (lib.custom.relativeToRoot "pkgs/common/borgtui/package.nix") {
inherit pkgs;
};
user = config.hostSpec.username;
borg-wrapper = pkgs.writeScript "borg-wrapper" ''
#!${lib.getExe pkgs.fish}
# Enable strict error handling
set -e
# Parse arguments
set -l CMD
while test (count $argv) -gt 0
switch $argv[1]
case -p --path
set BACKUP_PATH $argv[2]
set -e argv[1..2]
case -o --output
set BORG_REPO $argv[2]
set -e argv[1..2]
case -m --max
set MAX_BACKUPS $argv[2]
set -e argv[1..2]
case --
set -e argv[1]
set CMD $argv
set -e argv[1..-1]
break
case '*'
echo "Unknown option: $argv[1]"
exit 1
end
end
# Initialize Borg repository
mkdir -p "$BORG_REPO"
if not ${pkgs.borgbackup}/bin/borg list "$BORG_REPO" &>/dev/null
echo "Initializing new Borg repository at $BORG_REPO"
${pkgs.borgbackup}/bin/borg init --encryption=none "$BORG_REPO"
end
# Backup functions with error suppression
function create_backup
set -l tag $argv[1]
set -l timestamp (date +%Y%m%d-%H%M%S)
echo "Creating $tag backup: $timestamp"
${pkgs.borgbackup}/bin/borg create --stats --compression zstd,15 \
--files-cache=mtime,size \
--lock-wait 5 \
"$BORG_REPO::$tag-$timestamp" "$BACKUP_PATH" || true
end
function prune_backups
echo "Pruning old backups"
${pkgs.borgbackup}/bin/borg prune --keep-last "$MAX_BACKUPS" --stats "$BORG_REPO" || true
end
# Initial backup
create_backup "initial"
prune_backups
# Start emulator in a subprocess group
fish -c "
function on_exit
exit 0
end
trap on_exit INT TERM
exec $CMD
" &
set PID (jobs -lp | tail -n1)
# Cleanup function
function cleanup
# Send TERM to process group
kill -TERM -$PID 2>/dev/null || true
wait $PID 2>/dev/null || true
create_backup "final"
prune_backups
end
function on_exit --on-signal INT --on-signal TERM
cleanup
end
# Debounced backup trigger
set last_backup (date +%s)
set backup_cooldown 30 # Minimum seconds between backups
# Watch loop with timeout
while kill -0 $PID 2>/dev/null
# Wait for changes with 5-second timeout
if ${pkgs.inotify-tools}/bin/inotifywait \
-r \
-qq \
-e close_write,delete,moved_to \
-t 5 \
"$BACKUP_PATH"
set current_time (date +%s)
if test (math "$current_time - $last_backup") -ge $backup_cooldown
create_backup "auto"
prune_backups
set last_backup $current_time
else
echo "Skipping backup:" + (math "$backup_cooldown - ($current_time - $last_backup)") + "s cooldown remaining"
end
end
end
cleanup
exit 0
'';
# Generic function to create launcher scripts
mkLaunchCommand =
{
savePath, # Path to the save directory
backupPath, # Path where backups should be stored
maxBackups ? 30, # Maximum number of backups to keep
command, # Command to execute
}:
"${borg-wrapper} -p \"${savePath}\" -o \"${backupPath}\" -m ${toString maxBackups} -- ${command}";
in
{
home.packages = with pkgs; [
citron-emu
ryubing
borgbackup
borgtui
inotify-tools
];
xdg.desktopEntries = {
Ryujinx = {
name = "Ryubing w/ Backups";
comment = "Ryubing Emulator with Save Backups";
exec = ''fish ${backup-wrapper} -p /home/${user}/.config/Ryujinx/bis/user/save -o /pool/Backups/Switch/RyubingSaves -m 30 -d 120 -- ryujinx'';
name = "Ryujinx w/ Borg Backups";
comment = "Ryujinx Emulator with Borg Backups";
exec = mkLaunchCommand {
savePath = "/home/${user}/.config/Ryujinx/bis/user/save";
backupPath = "/pool/Backups/Switch/RyubingSaves";
maxBackups = 30;
command = "ryujinx";
};
icon = "Ryujinx";
type = "Application";
terminal = false;
@ -50,9 +179,14 @@ in
};
citron-emu = {
name = "Citron w/ Backups";
comment = "Citron Emulator with Save Backups";
exec = ''fish ${backup-wrapper} -p /home/${user}/.local/share/citron/nand/user/save -o /pool/Backups/Switch/CitronSaves -m 30 -d 120 -- citron-emu'';
name = "Citron w/ Borg Backups";
comment = "Citron Emulator with Borg Backups";
exec = mkLaunchCommand {
savePath = "/home/${user}/.local/share/citron/nand/user/save";
backupPath = "/pool/Backups/Switch/CitronSaves";
maxBackups = 30;
command = "citron-emu";
};
icon = "applications-games";
type = "Application";
terminal = false;

View file

@ -0,0 +1,42 @@
{
pkgs,
lib,
}:
pkgs.stdenv.mkDerivation {
pname = "borgtui";
version = "0.1.0";
src = ./.; # This expects tui.sh to be in the same directory as this file
nativeBuildInputs = with pkgs; [
makeWrapper
];
buildInputs = with pkgs; [
python3
];
dontUnpack = true;
installPhase = ''
runHook preInstall
mkdir -p $out/bin
cp ${./tui.sh} $out/bin/borgtui
chmod +x $out/bin/borgtui
wrapProgram $out/bin/borgtui \
--prefix PATH : ${lib.makeBinPath [ pkgs.borgbackup ]}
runHook postInstall
'';
meta = with lib; {
description = "A simple TUI for managing Borg repositories";
# homepage = "https://github.com/toph/borgtui"; # Replace with your actual GitHub repo if you have one
license = licenses.mit;
platforms = platforms.all;
maintainers = with maintainers; [ "tophc7" ]; # Add your name if you're a Nixpkgs maintainer
};
}

187
pkgs/common/borgtui/tui.sh Normal file
View file

@ -0,0 +1,187 @@
#!/usr/bin/env python3
import curses
import subprocess
import sys
import os
import traceback
def run_cmd(cmd):
"""Run cmd list, return (success:bool, output:str)."""
try:
out = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
return True, out.decode()
except subprocess.CalledProcessError as e:
return False, e.output.decode()
def get_archives(repo):
"""List archives by running `borg list repo`."""
success, out = run_cmd(["borg", "list", repo])
if not success:
return [], out
archives = []
for line in out.splitlines():
parts = line.split(maxsplit=1) # Split into name and rest
if parts:
archives.append(parts[0])
return archives, ""
def prompt(stdscr, msg):
curses.echo()
h, w = stdscr.getmaxyx()
stdscr.addstr(h-1, 0, msg)
stdscr.clrtoeol()
stdscr.refresh()
inp = stdscr.getstr(h-1, len(msg)).decode()
curses.noecho()
return inp
def draw(stdscr, repo, archives, sel, offset, status):
stdscr.erase()
h, w = stdscr.getmaxyx()
# Header
header = f"Borg TUI - Repo: {repo}"
stdscr.addstr(0, 0, header[:w-1])
# If no archives, show message
if not archives:
stdscr.addstr(2, 0, "No backups found in repository")
stdscr.addstr(3, 0, "(q) to quit, (r) to refresh")
else:
# Menu
visible = h - 4
for idx, arch in enumerate(archives[offset:offset+visible]):
y = idx + 1
line = f"{idx+offset+1:2}. {arch}"[:w-1]
if idx+offset == sel:
stdscr.attron(curses.A_REVERSE)
stdscr.addstr(y, 0, line)
stdscr.attroff(curses.A_REVERSE)
else:
stdscr.addstr(y, 0, line)
# Help line
help_line = "↑↓:move d:delete r:restore q:quit"
stdscr.addstr(h-3, 0, help_line[:w-1])
# Status line
stdscr.addstr(h-2, 0, status[:w-1])
stdscr.refresh()
def main(stdscr):
# Check if arguments are provided
if len(sys.argv) != 2:
stdscr.erase()
stdscr.addstr(0, 0, "Error: Repository path not provided")
stdscr.addstr(1, 0, "Usage: ./tui.sh /path/to/borg/repository")
stdscr.addstr(3, 0, "Press any key to exit...")
stdscr.refresh()
stdscr.getch()
return
repo = sys.argv[1]
curses.curs_set(0) # Hide cursor
# Check if repo exists
if not os.path.isdir(repo):
stdscr.addstr(0, 0, f"Error: Repository not found: {repo}")
stdscr.addstr(1, 0, "Press any key to exit...")
stdscr.getch()
return
# Get archive list
archives, err = get_archives(repo)
if err:
stdscr.addstr(0, 0, f"Error listing archives: {err}")
stdscr.addstr(1, 0, "Press any key to exit...")
stdscr.getch()
return
sel = 0
offset = 0
status = "Ready"
# Main loop
while True:
h, w = stdscr.getmaxyx()
max_disp = h - 4
# Handle scrolling
if sel < offset:
offset = sel
elif sel >= offset + max_disp:
offset = sel - max_disp + 1
draw(stdscr, repo, archives, sel, offset, status)
try:
key = stdscr.getch()
except KeyboardInterrupt:
break
# Navigation and actions
if key in (curses.KEY_UP, ord('k')):
if archives:
sel = max(0, sel-1)
status = "Moved up"
elif key in (curses.KEY_DOWN, ord('j')):
if archives:
sel = min(len(archives)-1, sel+1)
status = "Moved down"
elif key == ord('q'):
break
elif key == ord('r'):
# If 'r' is pressed with no archives, refresh list
if not archives:
archives, err = get_archives(repo)
status = "Refreshed archive list"
if err:
status = f"Error: {err}"
elif archives: # If archives exist, restore selected archive
name = archives[sel]
dest = prompt(stdscr, f"Restore {name} to dir: ")
if dest:
# Create destination directory
try:
os.makedirs(dest, exist_ok=True)
status = f"Extracting {name} to {dest}..."
draw(stdscr, repo, archives, sel, offset, status)
# Change to destination directory and extract
cwd = os.getcwd()
os.chdir(dest)
ok, out = run_cmd(["borg", "extract", f"{repo}::{name}"])
os.chdir(cwd) # Return to original directory
status = f"Restored {name} to {dest}" if ok else f"Error: {out.strip()}"
except Exception as e:
status = f"Error: {str(e)}"
else:
status = "Restore cancelled - No destination provided"
elif key == ord('d') and archives:
name = archives[sel]
ans = prompt(stdscr, f"Delete {name}? (y/N): ")
if ans.lower().startswith('y'):
# Delete with force flag
ok, out = run_cmd(["borg", "delete", "--force", f"{repo}::{name}"])
if ok:
status = f"Deleted {name}"
# Refresh archives list after deletion
archives, err = get_archives(repo)
sel = min(sel, max(0, len(archives)-1))
else:
status = f"Error: {out.strip()}"
else:
status = "Delete cancelled"
# Entry point with improved error handling
if __name__ == "__main__":
try:
curses.wrapper(main)
except KeyboardInterrupt:
print("Exited Borg TUI")
except Exception as e:
# Log any uncaught exceptions
with open("/tmp/borg-tui-error.log", "a") as f:
f.write(f"{traceback.format_exc()}\n")
print(f"Error: {str(e)}. See /tmp/borg-tui-error.log for details.")