From 6bee5e8d80b2f7c8fec380d16c70d5a85258dbd6 Mon Sep 17 00:00:00 2001 From: Chris Toph Date: Sat, 19 Apr 2025 20:53:56 -0400 Subject: [PATCH] Integrates Borg backups for emulators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • 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 --- .../common/optional/gaming/scripts/backup.nix | 345 ------------------ .../optional/gaming/scripts/default.nix | 3 - home/toph/common/optional/gaming/switch.nix | 160 +++++++- pkgs/common/borgtui/package.nix | 42 +++ pkgs/common/borgtui/tui.sh | 187 ++++++++++ 5 files changed, 376 insertions(+), 361 deletions(-) delete mode 100644 home/toph/common/optional/gaming/scripts/backup.nix delete mode 100644 home/toph/common/optional/gaming/scripts/default.nix create mode 100644 pkgs/common/borgtui/package.nix create mode 100644 pkgs/common/borgtui/tui.sh diff --git a/home/toph/common/optional/gaming/scripts/backup.nix b/home/toph/common/optional/gaming/scripts/backup.nix deleted file mode 100644 index 07bd372..0000000 --- a/home/toph/common/optional/gaming/scripts/backup.nix +++ /dev/null @@ -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 -'' diff --git a/home/toph/common/optional/gaming/scripts/default.nix b/home/toph/common/optional/gaming/scripts/default.nix deleted file mode 100644 index af99a4f..0000000 --- a/home/toph/common/optional/gaming/scripts/default.nix +++ /dev/null @@ -1,3 +0,0 @@ -{ - # :D -} diff --git a/home/toph/common/optional/gaming/switch.nix b/home/toph/common/optional/gaming/switch.nix index 30677bc..cd4c037 100644 --- a/home/toph/common/optional/gaming/switch.nix +++ b/home/toph/common/optional/gaming/switch.nix @@ -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; diff --git a/pkgs/common/borgtui/package.nix b/pkgs/common/borgtui/package.nix new file mode 100644 index 0000000..dc243d2 --- /dev/null +++ b/pkgs/common/borgtui/package.nix @@ -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 + }; +} diff --git a/pkgs/common/borgtui/tui.sh b/pkgs/common/borgtui/tui.sh new file mode 100644 index 0000000..c251b8e --- /dev/null +++ b/pkgs/common/borgtui/tui.sh @@ -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.") \ No newline at end of file