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:
parent
0657e04abf
commit
d664549b8a
5 changed files with 376 additions and 361 deletions
|
@ -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
|
|
||||||
''
|
|
|
@ -1,3 +0,0 @@
|
||||||
{
|
|
||||||
# :D
|
|
||||||
}
|
|
|
@ -1,5 +1,4 @@
|
||||||
# This module just provides a customized .desktop file with gamescope args dynamically created based on the
|
# switch.nix
|
||||||
# host's monitors configuration
|
|
||||||
{
|
{
|
||||||
pkgs,
|
pkgs,
|
||||||
config,
|
config,
|
||||||
|
@ -8,26 +7,156 @@
|
||||||
}:
|
}:
|
||||||
|
|
||||||
let
|
let
|
||||||
|
citron-emu = pkgs.callPackage (lib.custom.relativeToRoot "pkgs/common/citron-emu/package.nix") {
|
||||||
path = lib.custom.relativeToRoot "pkgs/common/citron-emu/package.nix";
|
inherit pkgs;
|
||||||
citron-emu = pkgs.callPackage path { inherit pkgs; };
|
};
|
||||||
|
borgtui = pkgs.callPackage (lib.custom.relativeToRoot "pkgs/common/borgtui/package.nix") {
|
||||||
backup-wrapper = import ./scripts/backup.nix { inherit pkgs; };
|
inherit pkgs;
|
||||||
|
};
|
||||||
|
|
||||||
user = config.hostSpec.username;
|
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
|
in
|
||||||
{
|
{
|
||||||
home.packages = with pkgs; [
|
home.packages = with pkgs; [
|
||||||
citron-emu
|
citron-emu
|
||||||
ryubing
|
ryubing
|
||||||
|
borgbackup
|
||||||
|
borgtui
|
||||||
|
inotify-tools
|
||||||
];
|
];
|
||||||
|
|
||||||
xdg.desktopEntries = {
|
xdg.desktopEntries = {
|
||||||
Ryujinx = {
|
Ryujinx = {
|
||||||
name = "Ryubing w/ Backups";
|
name = "Ryujinx w/ Borg Backups";
|
||||||
comment = "Ryubing Emulator with Save Backups";
|
comment = "Ryujinx Emulator with Borg Backups";
|
||||||
exec = ''fish ${backup-wrapper} -p /home/${user}/.config/Ryujinx/bis/user/save -o /pool/Backups/Switch/RyubingSaves -m 30 -d 120 -- ryujinx'';
|
exec = mkLaunchCommand {
|
||||||
|
savePath = "/home/${user}/.config/Ryujinx/bis/user/save";
|
||||||
|
backupPath = "/pool/Backups/Switch/RyubingSaves";
|
||||||
|
maxBackups = 30;
|
||||||
|
command = "ryujinx";
|
||||||
|
};
|
||||||
icon = "Ryujinx";
|
icon = "Ryujinx";
|
||||||
type = "Application";
|
type = "Application";
|
||||||
terminal = false;
|
terminal = false;
|
||||||
|
@ -50,9 +179,14 @@ in
|
||||||
};
|
};
|
||||||
|
|
||||||
citron-emu = {
|
citron-emu = {
|
||||||
name = "Citron w/ Backups";
|
name = "Citron w/ Borg Backups";
|
||||||
comment = "Citron Emulator with Save Backups";
|
comment = "Citron Emulator with Borg 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'';
|
exec = mkLaunchCommand {
|
||||||
|
savePath = "/home/${user}/.local/share/citron/nand/user/save";
|
||||||
|
backupPath = "/pool/Backups/Switch/CitronSaves";
|
||||||
|
maxBackups = 30;
|
||||||
|
command = "citron-emu";
|
||||||
|
};
|
||||||
icon = "applications-games";
|
icon = "applications-games";
|
||||||
type = "Application";
|
type = "Application";
|
||||||
terminal = false;
|
terminal = false;
|
||||||
|
|
42
pkgs/common/borgtui/package.nix
Normal file
42
pkgs/common/borgtui/package.nix
Normal 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
187
pkgs/common/borgtui/tui.sh
Normal 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.")
|
Loading…
Add table
Reference in a new issue