|
1 | | -# switch.nix |
| 1 | +# switch.nix - Systemd-based backup service for Switch emulators |
2 | 2 | { |
3 | 3 | pkgs, |
4 | 4 | config, |
5 | 5 | lib, |
6 | 6 | ... |
7 | 7 | }: |
8 | | - |
9 | 8 | let |
10 | | - borg-wrapper = pkgs.writeScript "borg-wrapper" '' |
11 | | - #!${lib.getExe pkgs.fish} |
12 | | -
|
13 | | - # Parse arguments using argparse |
14 | | - function parse_args |
15 | | - argparse 'p/path=' 'o/output=' 'm/max=' 'h/help' -- $argv |
16 | | - or return 1 |
17 | | - |
18 | | - if set -ql _flag_help |
19 | | - echo "Usage: borg-wrapper -p|--path PATH -o|--output REPO -m|--max MAX_BACKUPS -- COMMAND..." |
20 | | - echo " -p, --path PATH Path to backup" |
21 | | - echo " -o, --output REPO Path to store backups" |
22 | | - echo " -m, --max MAX Maximum number of backups to keep" |
23 | | - echo " -h, --help Show this help" |
24 | | - exit 0 |
25 | | - end |
26 | | - |
27 | | - # Check required arguments |
28 | | - if not set -ql _flag_path |
29 | | - echo "Error: --path is required" >&2 |
30 | | - return 1 |
31 | | - end |
32 | | - if not set -ql _flag_output |
33 | | - echo "Error: --output is required" >&2 |
34 | | - return 1 |
35 | | - end |
36 | | - |
37 | | - # Set defaults |
38 | | - set -g BACKUP_PATH $_flag_path |
39 | | - set -g BORG_REPO $_flag_output |
40 | | - |
41 | | - if set -ql _flag_max |
42 | | - set -g MAX_BACKUPS $_flag_max |
43 | | - else |
44 | | - set -g MAX_BACKUPS 30 |
45 | | - end |
46 | | - |
47 | | - # Everything remaining is the command to execute |
48 | | - set -g CMD $argv |
49 | | - |
50 | | - # Verify we have a command |
51 | | - if test (count $CMD) -eq 0 |
52 | | - echo "Error: No command specified after --" >&2 |
53 | | - return 1 |
54 | | - end |
55 | | - end |
56 | | -
|
57 | | - # Parse the arguments |
58 | | - parse_args $argv |
59 | | - or exit 1 |
60 | | -
|
61 | | - # Initialize Borg repository |
62 | | - mkdir -p "$BORG_REPO" |
63 | | - if not ${pkgs.borgbackup}/bin/borg list "$BORG_REPO" &>/dev/null |
64 | | - echo "Initializing new Borg repository at $BORG_REPO" |
65 | | - ${pkgs.borgbackup}/bin/borg init --encryption=none "$BORG_REPO" |
66 | | - end |
67 | | -
|
68 | | - # Backup functions with error suppression |
69 | | - function create_backup |
70 | | - set -l tag $argv[1] |
71 | | - set -l timestamp (date +%Y%m%d-%H%M%S) |
72 | | - echo "Creating $tag backup: $timestamp" |
73 | | - |
74 | | - # Push to parent directory, backup the basename only, then pop back |
75 | | - pushd (dirname "$BACKUP_PATH") >/dev/null |
76 | | - ${pkgs.borgbackup}/bin/borg create --stats --compression zstd,15 \ |
77 | | - --files-cache=mtime,size \ |
78 | | - --lock-wait 5 \ |
79 | | - "$BORG_REPO::$tag-$timestamp" (basename "$BACKUP_PATH") || true |
80 | | - popd >/dev/null |
81 | | - end |
82 | | -
|
83 | | - function prune_backups |
84 | | - echo "Pruning old backups" |
85 | | - ${pkgs.borgbackup}/bin/borg prune --keep-last "$MAX_BACKUPS" --stats "$BORG_REPO" || true |
86 | | - end |
87 | | -
|
88 | | - # Initial backup |
89 | | - create_backup "initial" |
90 | | - prune_backups |
91 | | -
|
92 | | - # Start emulator in a subprocess group |
93 | | - fish -c " |
94 | | - function on_exit |
95 | | - exit 0 |
96 | | - end |
97 | | - |
98 | | - trap on_exit INT TERM |
99 | | - exec $CMD |
100 | | - " & |
101 | | - set PID (jobs -lp | tail -n1) |
102 | | -
|
103 | | - # Cleanup function |
104 | | - function cleanup |
105 | | - # Send TERM to process group |
106 | | - kill -TERM -$PID 2>/dev/null || true |
107 | | - wait $PID 2>/dev/null || true |
108 | | - create_backup "final" |
109 | | - prune_backups |
110 | | - end |
111 | | -
|
112 | | - function on_exit --on-signal INT --on-signal TERM |
113 | | - cleanup |
114 | | - end |
115 | | -
|
116 | | - # Debounced backup trigger |
117 | | - set last_backup (date +%s) |
118 | | - set backup_cooldown 30 # Minimum seconds between backups |
119 | | -
|
120 | | - # Watch loop with timeout |
121 | | - while kill -0 $PID 2>/dev/null |
122 | | - # Wait for changes with 5-second timeout |
123 | | - if ${pkgs.inotify-tools}/bin/inotifywait \ |
124 | | - -r \ |
125 | | - -qq \ |
126 | | - -e close_write,delete,moved_to \ |
127 | | - -t 5 \ |
128 | | - "$BACKUP_PATH" |
129 | | - |
130 | | - set current_time (date +%s) |
131 | | - if test (math "$current_time - $last_backup") -ge $backup_cooldown |
132 | | - create_backup "auto" |
133 | | - prune_backups |
134 | | - set last_backup $current_time |
135 | | - else |
136 | | - echo "Skipping backup:" + (math "$backup_cooldown - ($current_time - $last_backup)") + "s cooldown remaining" |
137 | | - end |
138 | | - end |
139 | | - end |
140 | | -
|
141 | | - cleanup |
142 | | - exit 0 |
| 9 | + # Backup script that systemd services will call |
| 10 | + backupScript = pkgs.writeShellScript "switch-backup" '' |
| 11 | + set -euo pipefail |
| 12 | +
|
| 13 | + LABEL="$1" |
| 14 | + SAVE_PATH="$2" |
| 15 | + BACKUP_PATH="$3" |
| 16 | +
|
| 17 | + # Expand tilde if present |
| 18 | + SAVE_PATH="$(eval echo "$SAVE_PATH")" |
| 19 | + BACKUP_PATH="$(eval echo "$BACKUP_PATH")" |
| 20 | +
|
| 21 | + # Initialize Borg repo if needed |
| 22 | + mkdir -p "$BACKUP_PATH" |
| 23 | + if ! ${pkgs.borgbackup}/bin/borg list "$BACKUP_PATH" &>/dev/null; then |
| 24 | + ${pkgs.borgbackup}/bin/borg init --encryption=none "$BACKUP_PATH" |
| 25 | + fi |
| 26 | +
|
| 27 | + # Create backup |
| 28 | + TIMESTAMP=$(date +%Y%m%d-%H%M%S) |
| 29 | + echo "[Backup] Creating $LABEL-$TIMESTAMP" |
| 30 | +
|
| 31 | + cd "$(dirname "$SAVE_PATH")" |
| 32 | + ${pkgs.borgbackup}/bin/borg create \ |
| 33 | + --compression zstd,15 \ |
| 34 | + "$BACKUP_PATH::$LABEL-$TIMESTAMP" \ |
| 35 | + "$(basename "$SAVE_PATH")" 2>&1 | grep "This archive" || true |
| 36 | +
|
| 37 | + # Prune old backups |
| 38 | + ${pkgs.borgbackup}/bin/borg prune --keep-last 50 "$BACKUP_PATH" 2>/dev/null |
143 | 39 | ''; |
144 | 40 |
|
145 | | - # Generic function to create launcher scripts |
146 | | - mkLaunchCommand = |
147 | | - { |
148 | | - savePath, # Path to the save directory |
149 | | - backupPath, # Path where backups should be stored |
150 | | - maxBackups ? 30, # Maximum number of backups to keep |
151 | | - command, # Command to execute |
152 | | - }: |
153 | | - "${borg-wrapper} -p \"${savePath}\" -o \"${backupPath}\" -m ${toString maxBackups} -- ${command}"; |
| 41 | + # Main emulator service |
| 42 | + mkEmulatorService = name: emulatorCmd: savePath: backupPath: { |
| 43 | + "emulator-${lib.toLower name}" = { |
| 44 | + Unit = { |
| 45 | + Description = "${name} emulator with automatic save backups"; |
| 46 | + After = [ "graphical-session.target" ]; |
| 47 | + # Ensure timer starts/stops with service |
| 48 | + Wants = [ "emulator-${lib.toLower name}-backup.timer" ]; |
| 49 | + }; |
154 | 50 |
|
155 | | -in |
156 | | -{ |
157 | | - home.packages = with pkgs; [ |
158 | | - ryubing |
159 | | - borgbackup |
160 | | - inotify-tools |
161 | | - ]; |
| 51 | + Service = { |
| 52 | + Type = "simple"; |
162 | 53 |
|
163 | | - xdg.desktopEntries = { |
164 | | - Ryujinx = { |
165 | | - name = "Ryujinx w/ Borg Backups"; |
166 | | - comment = "Ryujinx Emulator with Borg Backups"; |
167 | | - exec = mkLaunchCommand { |
168 | | - savePath = "~/.config/Ryujinx/bis/user/save"; |
169 | | - backupPath = "/pool/Backups/Switch/RyubingSaves"; |
170 | | - maxBackups = 30; |
171 | | - command = "ryujinx"; |
| 54 | + # Backup on start |
| 55 | + ExecStartPre = "${backupScript} start '${savePath}' '${backupPath}'"; |
| 56 | + |
| 57 | + # Run emulator |
| 58 | + ExecStart = emulatorCmd; |
| 59 | + |
| 60 | + # Start timer for periodic backups |
| 61 | + ExecStartPost = "${pkgs.systemd}/bin/systemctl --user start emulator-${lib.toLower name}-backup.timer"; |
| 62 | + |
| 63 | + # Stop timer and backup on stop |
| 64 | + ExecStopPost = [ |
| 65 | + "${pkgs.systemd}/bin/systemctl --user stop emulator-${lib.toLower name}-backup.timer" |
| 66 | + "${pkgs.bash}/bin/bash -c 'sleep 3; ${backupScript} stop \"${savePath}\" \"${backupPath}\"'" |
| 67 | + ]; |
| 68 | + |
| 69 | + # Restart policy |
| 70 | + Restart = "on-failure"; |
| 71 | + RestartSec = "5s"; |
| 72 | + }; |
| 73 | + |
| 74 | + Install = { |
| 75 | + WantedBy = [ "default.target" ]; |
| 76 | + }; |
| 77 | + }; |
| 78 | + }; |
| 79 | + |
| 80 | + # One-shot service for timer-triggered backups |
| 81 | + mkBackupService = name: savePath: backupPath: { |
| 82 | + "emulator-${lib.toLower name}-backup" = { |
| 83 | + Unit = { |
| 84 | + Description = "Backup ${name} saves"; |
| 85 | + # Only run when emulator service is active |
| 86 | + Requisite = [ "emulator-${lib.toLower name}.service" ]; |
| 87 | + }; |
| 88 | + |
| 89 | + Service = { |
| 90 | + Type = "oneshot"; |
| 91 | + ExecStart = "${backupScript} interval '${savePath}' '${backupPath}'"; |
| 92 | + }; |
| 93 | + }; |
| 94 | + }; |
| 95 | + |
| 96 | + # Timer for periodic backups |
| 97 | + mkBackupTimer = name: { |
| 98 | + "emulator-${lib.toLower name}-backup" = { |
| 99 | + Unit = { |
| 100 | + Description = "Periodic backup timer for ${name} saves"; |
| 101 | + # Timer is controlled by the emulator service |
| 102 | + PartOf = [ "emulator-${lib.toLower name}.service" ]; |
| 103 | + }; |
| 104 | + |
| 105 | + Timer = { |
| 106 | + OnActiveSec = "5min"; |
| 107 | + OnUnitActiveSec = "5min"; |
| 108 | + Unit = "emulator-${lib.toLower name}-backup.service"; |
172 | 109 | }; |
173 | | - icon = "Ryujinx"; |
| 110 | + |
| 111 | + # No Install section - timer is started/stopped by the emulator service |
| 112 | + }; |
| 113 | + }; |
| 114 | + |
| 115 | + # Desktop entry that starts the systemd service |
| 116 | + mkEmulatorDesktopEntry = |
| 117 | + { |
| 118 | + name, |
| 119 | + icon ? "applications-games", |
| 120 | + wmClass ? name, |
| 121 | + ... |
| 122 | + }@args: |
| 123 | + { |
| 124 | + name = "${name} (Protected)"; |
| 125 | + comment = "${name} with automatic save backups"; |
| 126 | + exec = "systemctl --user start emulator-${lib.toLower name}.service"; |
| 127 | + icon = icon; |
174 | 128 | type = "Application"; |
175 | 129 | terminal = false; |
176 | 130 | categories = [ |
|
186 | 140 | ]; |
187 | 141 | prefersNonDefaultGPU = true; |
188 | 142 | settings = { |
189 | | - StartupWMClass = "Ryujinx"; |
| 143 | + StartupWMClass = wmClass; |
190 | 144 | GenericName = "Nintendo Switch Emulator"; |
191 | | - }; |
| 145 | + } |
| 146 | + // (args.settings or { }); |
192 | 147 | }; |
193 | 148 |
|
194 | | - # FIXME: change to edenemu |
195 | | - # citron-emu = { |
196 | | - # name = "Citron w/ Borg Backups"; |
197 | | - # comment = "Citron Emulator with Borg Backups"; |
198 | | - # exec = mkLaunchCommand { |
199 | | - # savePath = "${homeDir}/.local/share/citron/nand/user/save"; |
200 | | - # backupPath = "/pool/Backups/Switch/CitronSaves"; |
201 | | - # maxBackups = 30; |
202 | | - # command = "citron-emu"; |
203 | | - # }; |
204 | | - # icon = "applications-games"; |
205 | | - # type = "Application"; |
206 | | - # terminal = false; |
207 | | - # categories = [ |
208 | | - # "Game" |
209 | | - # "Emulator" |
210 | | - # ]; |
211 | | - # mimeType = [ |
212 | | - # "application/x-nx-nca" |
213 | | - # "application/x-nx-nro" |
214 | | - # "application/x-nx-nso" |
215 | | - # "application/x-nx-nsp" |
216 | | - # "application/x-nx-xci" |
217 | | - # ]; |
218 | | - # prefersNonDefaultGPU = true; |
219 | | - # settings = { |
220 | | - # StartupWMClass = "Citron"; |
221 | | - # GenericName = "Nintendo Switch Emulator"; |
222 | | - # }; |
223 | | - # }; |
| 149 | + gamescorperun = lib.getExe config.play.gamescoperun.package; |
| 150 | + |
| 151 | + # Configuration for Ryubing emulator |
| 152 | + ryubingConfig = { |
| 153 | + name = "Ryubing"; |
| 154 | + emulatorCmd = "${gamescorperun} ${lib.getExe pkgs.ryubing}"; |
| 155 | + savePath = "~/.config/Ryujinx/bis/user/save"; |
| 156 | + backupPath = "~/.switch/RyubingBackups"; |
| 157 | + icon = "Ryujinx"; |
| 158 | + wmClass = "Ryubing"; |
| 159 | + }; |
| 160 | +in |
| 161 | +{ |
| 162 | + home.packages = with pkgs; [ |
| 163 | + borgbackup |
| 164 | + nsz |
| 165 | + ryubing |
| 166 | + ]; |
| 167 | + |
| 168 | + # Systemd user services |
| 169 | + systemd.user.services = lib.mkMerge [ |
| 170 | + (mkEmulatorService ryubingConfig.name ryubingConfig.emulatorCmd ryubingConfig.savePath |
| 171 | + ryubingConfig.backupPath |
| 172 | + ) |
| 173 | + (mkBackupService ryubingConfig.name ryubingConfig.savePath ryubingConfig.backupPath) |
| 174 | + ]; |
| 175 | + |
| 176 | + # Systemd user timers |
| 177 | + systemd.user.timers = mkBackupTimer ryubingConfig.name; |
| 178 | + |
| 179 | + # Desktop entries |
| 180 | + xdg.desktopEntries = { |
| 181 | + ryubing = mkEmulatorDesktopEntry ryubingConfig; |
| 182 | + }; |
| 183 | + |
| 184 | + # Service management aliases |
| 185 | + home.shellAliases = { |
| 186 | + # Start/stop emulator |
| 187 | + start-ryubing = "systemctl --user start emulator-ryubing.service"; |
| 188 | + stop-ryubing = "systemctl --user stop emulator-ryubing.service"; |
| 189 | + status-ryubing = "systemctl --user status emulator-ryubing.service emulator-ryubing-backup.timer"; |
| 190 | + # View logs |
| 191 | + ryubing-logs = "journalctl --user -u emulator-ryubing.service -u emulator-ryubing-backup.service -n 50"; |
224 | 192 | }; |
225 | 193 | } |
0 commit comments