├── tests ├── home │ ├── data.txt │ ├── .config │ │ └── xdgConfig.txt │ ├── .local │ │ └── share │ │ │ └── xdgData.txt │ ├── Documents │ │ └── winDocuments.txt │ └── AppData │ │ ├── Roaming │ │ └── winAppData.txt │ │ └── Local │ │ └── winLocalAppData.txt ├── root2 │ ├── game1 │ │ └── file1.txt │ └── game2 │ │ └── file1.txt ├── root1 │ └── game1 │ │ ├── ignored.txt │ │ └── subdir │ │ └── file2.txt ├── root3 │ ├── game5 │ │ └── data │ │ │ └── file1.txt │ └── game_2 │ │ └── file1.txt ├── .editorconfig ├── backup │ ├── game1 │ │ ├── drive-X │ │ │ ├── file1.txt │ │ │ └── file2.txt │ │ └── mapping.yaml │ ├── ignored-no-mapping │ │ └── .keep │ ├── migrate-legacy-backup │ │ ├── drive-X │ │ │ └── file1.txt │ │ └── mapping.yaml │ ├── ignored-invalid-mapping │ │ └── mapping.yaml │ ├── game1-zipped │ │ ├── test.zip │ │ └── mapping.yaml │ └── game3-renamed │ │ ├── mapping.yaml │ │ └── registry.yaml ├── root-[not]-glob │ └── game-[not]-glob │ │ ├── file1.txt │ │ └── file2.txt ├── wine-prefix │ ├── drive_c │ │ └── users │ │ │ └── anyone │ │ │ └── data.txt │ └── user.reg ├── launchers │ ├── lutris-db │ │ └── pga.db │ ├── lutris-merged │ │ ├── pga.db │ │ └── games │ │ │ ├── windows-game-1683516079.yaml │ │ │ └── windows-game-1683516078.yaml │ ├── lutris-split │ │ ├── data │ │ │ └── pga.db │ │ └── config │ │ │ └── games │ │ │ ├── windows-game-1683516079.yaml │ │ │ └── windows-game-1683516078.yaml │ ├── heroic-legendary │ │ ├── store_cache │ │ │ └── legendary_library.json │ │ └── GamesConfig │ │ │ └── app-2.json │ ├── heroic-nile │ │ ├── store_cache │ │ │ └── nile_library.json │ │ └── GamesConfig │ │ │ └── app-1.json │ ├── heroic-sideload │ │ ├── sideload_apps │ │ │ └── library.json │ │ └── GamesConfig │ │ │ └── app-1.json │ ├── heroic-gog-with-store-cache │ │ ├── GamesConfig │ │ │ └── app-1.json │ │ ├── gog_store │ │ │ └── installed.json │ │ └── store_cache │ │ │ └── gog_library.json │ ├── heroic-gog-without-store-cache │ │ ├── GamesConfig │ │ │ └── app-1.json │ │ └── gog_store │ │ │ ├── installed.json │ │ │ └── library.json │ ├── legendary │ │ └── installed.json │ └── lutris-spec │ │ └── games │ │ └── windows-game.yaml └── ludusavi.reg ├── .gitattributes ├── rustfmt.toml ├── clippy.toml ├── src ├── serialization.rs ├── gui │ ├── font.rs │ ├── notification.rs │ ├── icon.rs │ ├── badge.rs │ └── undoable.rs ├── lib.rs ├── resource.rs ├── testing.rs ├── gui.rs ├── scan │ ├── steam.rs │ ├── launchers.rs │ ├── backup.rs │ ├── launchers │ │ ├── legendary.rs │ │ ├── heroic │ │ │ ├── nile.rs │ │ │ ├── sideload.rs │ │ │ ├── gog.rs │ │ │ └── legendary.rs │ │ ├── heroic.rs │ │ └── generic.rs │ ├── game_filter.rs │ └── change.rs ├── metadata.rs ├── wrap │ └── heroic.rs ├── wrap.rs ├── resource │ ├── cache.rs │ └── config │ │ └── root.rs └── cli │ └── ui.rs ├── assets ├── icon.ico ├── icon.kra ├── icon.png ├── NotoSans-Regular.ttf ├── windows │ ├── manifest.rc │ └── manifest.xml ├── MaterialIcons-Regular.ttf ├── linux │ ├── com.mtkennerly.ludusavi.desktop │ └── com.mtkennerly.ludusavi.metainfo.xml ├── flatpak │ ├── com.github.mtkennerly.ludusavi.desktop │ └── com.github.mtkennerly.ludusavi.metainfo.xml └── icon.svg ├── docs ├── demo-cli.gif ├── demo-gui.gif ├── sample-gui-linux.png ├── help │ ├── backup-exclusions.md │ ├── duplicates.md │ ├── backup-validation.md │ ├── selective-scanning.md │ ├── environment-variables.md │ ├── configuration-file.md │ ├── logging.md │ ├── application-folder.md │ ├── transfer-between-operating-systems.md │ ├── filter.md │ ├── backup-retention.md │ ├── custom-games.md │ ├── missing-saves.md │ ├── command-line.md │ ├── redirects.md │ ├── installation.md │ ├── backup-automation.md │ ├── cloud-backup.md │ ├── backup-structure.md │ ├── troubleshooting.md │ ├── roots.md │ └── game-launch-wrapping.md └── schema │ ├── api-output.yaml │ └── api-input.yaml ├── .gitignore ├── .cargo └── config.toml ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── question.yaml │ ├── feature.yaml │ └── bug.yaml └── workflows │ └── main.yaml ├── crowdin.yml ├── .pre-commit-config.yaml ├── LICENSE ├── CONTRIBUTING.md ├── Cargo.toml └── README.md /tests/home/data.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/root2/game1/file1.txt: -------------------------------------------------------------------------------- 1 | . -------------------------------------------------------------------------------- /tests/root2/game2/file1.txt: -------------------------------------------------------------------------------- 1 | . -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 120 2 | -------------------------------------------------------------------------------- /tests/home/.config/xdgConfig.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/root1/game1/ignored.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/root3/game5/data/file1.txt: -------------------------------------------------------------------------------- 1 | . -------------------------------------------------------------------------------- /tests/root3/game_2/file1.txt: -------------------------------------------------------------------------------- 1 | . -------------------------------------------------------------------------------- /tests/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | -------------------------------------------------------------------------------- /tests/backup/game1/drive-X/file1.txt: -------------------------------------------------------------------------------- 1 | . -------------------------------------------------------------------------------- /tests/backup/game1/drive-X/file2.txt: -------------------------------------------------------------------------------- 1 | .. -------------------------------------------------------------------------------- /tests/backup/ignored-no-mapping/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/home/.local/share/xdgData.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/home/Documents/winDocuments.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/root1/game1/subdir/file2.txt: -------------------------------------------------------------------------------- 1 | .. -------------------------------------------------------------------------------- /tests/home/AppData/Roaming/winAppData.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/home/AppData/Local/winLocalAppData.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/root-[not]-glob/game-[not]-glob/file1.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/root-[not]-glob/game-[not]-glob/file2.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/wine-prefix/drive_c/users/anyone/data.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/backup/migrate-legacy-backup/drive-X/file1.txt: -------------------------------------------------------------------------------- 1 | . -------------------------------------------------------------------------------- /tests/wine-prefix/user.reg: -------------------------------------------------------------------------------- 1 | [HKEY_CURRENT_USER\Software\Ludusavi] -------------------------------------------------------------------------------- /clippy.toml: -------------------------------------------------------------------------------- 1 | ignore-interior-mutability = ["..", "ludusavi::path::StrictPath"] 2 | -------------------------------------------------------------------------------- /src/serialization.rs: -------------------------------------------------------------------------------- 1 | pub const fn default_true() -> bool { 2 | true 3 | } 4 | -------------------------------------------------------------------------------- /assets/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtkennerly/ludusavi/HEAD/assets/icon.ico -------------------------------------------------------------------------------- /assets/icon.kra: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtkennerly/ludusavi/HEAD/assets/icon.kra -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtkennerly/ludusavi/HEAD/assets/icon.png -------------------------------------------------------------------------------- /docs/demo-cli.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtkennerly/ludusavi/HEAD/docs/demo-cli.gif -------------------------------------------------------------------------------- /docs/demo-gui.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtkennerly/ludusavi/HEAD/docs/demo-gui.gif -------------------------------------------------------------------------------- /docs/sample-gui-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtkennerly/ludusavi/HEAD/docs/sample-gui-linux.png -------------------------------------------------------------------------------- /tests/backup/ignored-invalid-mapping/mapping.yaml: -------------------------------------------------------------------------------- 1 | name: Ignored because file is invalid 2 | drives: [] 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /dist 3 | /tmp 4 | tarpaulin-report.html 5 | tests/root3/game5/data-symlink 6 | *~ 7 | -------------------------------------------------------------------------------- /assets/NotoSans-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtkennerly/ludusavi/HEAD/assets/NotoSans-Regular.ttf -------------------------------------------------------------------------------- /assets/windows/manifest.rc: -------------------------------------------------------------------------------- 1 | #define RT_MANIFEST 24 2 | 1 RT_MANIFEST "manifest.xml" 3 | 2 ICON "../icon.ico" 4 | -------------------------------------------------------------------------------- /assets/MaterialIcons-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtkennerly/ludusavi/HEAD/assets/MaterialIcons-Regular.ttf -------------------------------------------------------------------------------- /tests/backup/game1-zipped/test.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtkennerly/ludusavi/HEAD/tests/backup/game1-zipped/test.zip -------------------------------------------------------------------------------- /tests/backup/migrate-legacy-backup/mapping.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: migrate-legacy-backup 3 | drives: 4 | drive-X: "X:" 5 | -------------------------------------------------------------------------------- /tests/launchers/lutris-db/pga.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtkennerly/ludusavi/HEAD/tests/launchers/lutris-db/pga.db -------------------------------------------------------------------------------- /tests/launchers/lutris-merged/pga.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtkennerly/ludusavi/HEAD/tests/launchers/lutris-merged/pga.db -------------------------------------------------------------------------------- /tests/launchers/lutris-split/data/pga.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mtkennerly/ludusavi/HEAD/tests/launchers/lutris-split/data/pga.db -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.x86_64-pc-windows-msvc] 2 | rustflags = ["-Ctarget-feature=+crt-static"] 3 | 4 | [target.i686-pc-windows-msvc] 5 | rustflags = ["-Ctarget-feature=+crt-static"] 6 | -------------------------------------------------------------------------------- /tests/launchers/lutris-merged/games/windows-game-1683516079.yaml: -------------------------------------------------------------------------------- 1 | game: 2 | exe: /home/deck/Games/service/game.exe 3 | prefix: /home/deck/Games/service/windows-game-2 4 | system: {} 5 | wine: {} 6 | -------------------------------------------------------------------------------- /tests/launchers/heroic-legendary/store_cache/legendary_library.json: -------------------------------------------------------------------------------- 1 | { 2 | "library": [ 3 | { 4 | "app_name": "app-2", 5 | "title": "Game 2" 6 | } 7 | ] 8 | } -------------------------------------------------------------------------------- /tests/launchers/lutris-split/config/games/windows-game-1683516079.yaml: -------------------------------------------------------------------------------- 1 | game: 2 | exe: /home/deck/Games/service/game.exe 3 | prefix: /home/deck/Games/service/windows-game-2 4 | system: {} 5 | wine: {} 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [*.{feature,json,md,yaml,yml}] 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.yaml: -------------------------------------------------------------------------------- 1 | name: Question 2 | description: Ask a question. 3 | labels: ["question"] 4 | body: 5 | - type: textarea 6 | attributes: 7 | label: What's your question? 8 | validations: 9 | required: true 10 | -------------------------------------------------------------------------------- /crowdin.yml: -------------------------------------------------------------------------------- 1 | project_id: "518190" 2 | base_path: . 3 | base_url: https://api.crowdin.com 4 | 5 | preserve_hierarchy: true 6 | 7 | files: 8 | - source: /lang/en-US.ftl 9 | translation: /lang/%locale%.ftl 10 | update_option: update_as_unapproved 11 | -------------------------------------------------------------------------------- /tests/backup/game3-renamed/mapping.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: game3 3 | drives: {} 4 | backups: 5 | - name: "." 6 | when: "2000-01-02T03:04:05Z" 7 | files: {} 8 | registry: 9 | hash: 4e2cab4b4e3ab853e5767fae35f317c26c655c52 10 | children: [] 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.yaml: -------------------------------------------------------------------------------- 1 | name: Feature 2 | description: Suggest a new feature or change. 3 | labels: ["enhancement"] 4 | body: 5 | - type: textarea 6 | attributes: 7 | label: What's your idea? 8 | validations: 9 | required: true 10 | -------------------------------------------------------------------------------- /assets/linux/com.mtkennerly.ludusavi.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Type=Application 3 | Encoding=UTF-8 4 | Name=Ludusavi 5 | Comment=Tool for backing up your PC video game save data. 6 | Exec=ludusavi 7 | Icon=com.mtkennerly.ludusavi 8 | Terminal=false 9 | Categories=Game; 10 | Keywords=game;games;backup;save;saves; 11 | -------------------------------------------------------------------------------- /assets/flatpak/com.github.mtkennerly.ludusavi.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Type=Application 3 | Encoding=UTF-8 4 | Name=Ludusavi 5 | Comment=Tool for backing up your PC video game save data. 6 | Exec=ludusavi 7 | Icon=com.github.mtkennerly.ludusavi 8 | Terminal=false 9 | Categories=Game; 10 | Keywords=game;games;backup;save;saves; 11 | -------------------------------------------------------------------------------- /tests/backup/game3-renamed/registry.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | HKEY_CURRENT_USER: 3 | "Software\\Ludusavi\\game3": 4 | binary: 5 | binary: 6 | - 65 7 | qword: 8 | qword: 2 9 | expandSz: 10 | expandSz: baz 11 | multiSz: 12 | multiSz: bar 13 | dword: 14 | dword: 1 15 | sz: 16 | sz: foo -------------------------------------------------------------------------------- /tests/launchers/heroic-nile/store_cache/nile_library.json: -------------------------------------------------------------------------------- 1 | { 2 | "library": [ 3 | { 4 | "app_name": "app-1", 5 | "install": { 6 | "install_path": "/games/game-1", 7 | "install_size": "1 MiB", 8 | "version": "1", 9 | "platform": "Windows" 10 | }, 11 | "title": "game-1" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tests/launchers/heroic-sideload/sideload_apps/library.json: -------------------------------------------------------------------------------- 1 | { 2 | "games": [ 3 | { 4 | "runner": "sideload", 5 | "app_name": "app-1", 6 | "title": "game-1", 7 | "install": { 8 | "executable": "/games/game-1/game.exe", 9 | "platform": "Windows", 10 | "is_dlc": false 11 | }, 12 | "folder_name": "/games/game-1" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /docs/help/backup-exclusions.md: -------------------------------------------------------------------------------- 1 | # Backup exclusions 2 | Backup exclusions let you set paths and registry keys to completely ignore 3 | from all games. They will not be shown at all during backup scans. 4 | 5 | Configure exclusions on the "other" screen. 6 | 7 | For excluded file paths, you can use glob syntax. 8 | For example, to exclude all files named `remotecache.vdf`, you would specify `**/remotecache.vdf`. 9 | -------------------------------------------------------------------------------- /tests/launchers/heroic-nile/GamesConfig/app-1.json: -------------------------------------------------------------------------------- 1 | { 2 | "app-1": { 3 | "wineVersion": { 4 | "bin": "/usr/bin/wine", 5 | "name": "Wine Default - wine-ge-7.0 (Staging)", 6 | "type": "wine", 7 | "wineserver": "/usr/bin/wineserver", 8 | "wineboot": "/usr/bin/wineboot" 9 | }, 10 | "winePrefix": "/prefixes/game-1" 11 | }, 12 | "version": "v0", 13 | "explicit": true 14 | } 15 | -------------------------------------------------------------------------------- /tests/launchers/heroic-legendary/GamesConfig/app-2.json: -------------------------------------------------------------------------------- 1 | { 2 | "app-2": { 3 | "wineVersion": { 4 | "bin": "/usr/bin/wine", 5 | "name": "Wine Default - wine-ge-7.0 (Staging)", 6 | "type": "wine", 7 | "wineserver": "/usr/bin/wineserver", 8 | "wineboot": "/usr/bin/wineboot" 9 | }, 10 | "winePrefix": "/prefixes/game-2" 11 | }, 12 | "version": "v0", 13 | "explicit": true 14 | } 15 | -------------------------------------------------------------------------------- /tests/launchers/heroic-sideload/GamesConfig/app-1.json: -------------------------------------------------------------------------------- 1 | { 2 | "app-1": { 3 | "wineVersion": { 4 | "bin": "/usr/bin/wine", 5 | "name": "Wine Default - wine-ge-7.0 (Staging)", 6 | "type": "wine", 7 | "wineserver": "/usr/bin/wineserver", 8 | "wineboot": "/usr/bin/wineboot" 9 | }, 10 | "winePrefix": "/prefixes/game-1" 11 | }, 12 | "version": "v0", 13 | "explicit": true 14 | } 15 | -------------------------------------------------------------------------------- /tests/launchers/heroic-gog-with-store-cache/GamesConfig/app-1.json: -------------------------------------------------------------------------------- 1 | { 2 | "app-1": { 3 | "wineVersion": { 4 | "bin": "/usr/bin/wine", 5 | "name": "Wine Default - wine-ge-7.0 (Staging)", 6 | "type": "wine", 7 | "wineserver": "/usr/bin/wineserver", 8 | "wineboot": "/usr/bin/wineboot" 9 | }, 10 | "winePrefix": "/prefixes/game-1" 11 | }, 12 | "version": "v0", 13 | "explicit": true 14 | } 15 | -------------------------------------------------------------------------------- /tests/launchers/heroic-gog-without-store-cache/GamesConfig/app-1.json: -------------------------------------------------------------------------------- 1 | { 2 | "app-1": { 3 | "wineVersion": { 4 | "bin": "/usr/bin/wine", 5 | "name": "Wine Default - wine-ge-7.0 (Staging)", 6 | "type": "wine", 7 | "wineserver": "/usr/bin/wineserver", 8 | "wineboot": "/usr/bin/wineboot" 9 | }, 10 | "winePrefix": "/prefixes/game-1" 11 | }, 12 | "version": "v0", 13 | "explicit": true 14 | } 15 | -------------------------------------------------------------------------------- /tests/backup/game1/mapping.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: game1 3 | drives: 4 | drive-X: "X:" 5 | backups: 6 | - name: "." 7 | when: "2000-01-02T03:04:05Z" 8 | files: 9 | "X:/file1.txt": 10 | hash: 3a52ce780950d4d969792a2559cd519d7ee8c727 11 | size: 1 12 | "X:/file2.txt": 13 | hash: 9d891e731f75deae56884d79e9816736b7488080 14 | size: 2 15 | registry: 16 | hash: ~ 17 | children: [] 18 | -------------------------------------------------------------------------------- /tests/backup/game1-zipped/mapping.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: game1-zipped 3 | drives: 4 | drive-X: "X:" 5 | backups: 6 | - name: "test.zip" 7 | when: "2000-01-02T03:04:05Z" 8 | files: 9 | "X:/file1.txt": 10 | hash: 3a52ce780950d4d969792a2559cd519d7ee8c727 11 | size: 1 12 | "X:/file2.txt": 13 | hash: 9d891e731f75deae56884d79e9816736b7488080 14 | size: 2 15 | registry: 16 | hash: ~ 17 | children: [] 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.3.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | exclude: ^tests/ 8 | - repo: https://github.com/Lucas-C/pre-commit-hooks 9 | rev: v1.1.7 10 | hooks: 11 | - id: forbid-tabs 12 | - repo: https://github.com/mtkennerly/pre-commit-hooks 13 | rev: v0.2.0 14 | hooks: 15 | - id: cargo-fmt 16 | - id: cargo-clippy 17 | -------------------------------------------------------------------------------- /tests/launchers/heroic-gog-with-store-cache/gog_store/installed.json: -------------------------------------------------------------------------------- 1 | { 2 | "installed": [ 3 | { 4 | "platform": "windows", 5 | "executable": "", 6 | "install_path": "/games/game-1", 7 | "install_size": "22.74 MiB", 8 | "is_dlc": false, 9 | "version": "3.0", 10 | "appName": "app-1", 11 | "installedWithDLCs": false, 12 | "language": "en-US", 13 | "versionEtag": "\"3640869511\"", 14 | "buildId": "52095636268561745" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /tests/launchers/heroic-gog-without-store-cache/gog_store/installed.json: -------------------------------------------------------------------------------- 1 | { 2 | "installed": [ 3 | { 4 | "platform": "windows", 5 | "executable": "", 6 | "install_path": "/games/game-1", 7 | "install_size": "22.74 MiB", 8 | "is_dlc": false, 9 | "version": "3.0", 10 | "appName": "app-1", 11 | "installedWithDLCs": false, 12 | "language": "en-US", 13 | "versionEtag": "\"3640869511\"", 14 | "buildId": "52095636268561745" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /tests/ludusavi.reg: -------------------------------------------------------------------------------- 1 | Windows Registry Editor Version 5.00 2 | 3 | [HKEY_CURRENT_USER\Software\Ludusavi] 4 | 5 | [HKEY_CURRENT_USER\Software\Ludusavi\game3] 6 | "sz"="foo" 7 | "multiSz"=hex(7):62,00,61,00,72,00,00,00,00,00 8 | "expandSz"=hex(2):62,00,61,00,7a,00,00,00 9 | "dword"=dword:00000001 10 | "qword"=hex(b):02,00,00,00,00,00,00,00 11 | "binary"=hex:41 12 | 13 | [HKEY_CURRENT_USER\Software\Ludusavi\other] 14 | 15 | [HKEY_CURRENT_USER\Software\Ludusavi\invalid] 16 | "dword"=hex(4):00,00,00,00,00,00,00,00 17 | 18 | [HKEY_CURRENT_USER\Software\Ludusavi\sp/ecial] 19 | "va/lu\\e"="" 20 | -------------------------------------------------------------------------------- /docs/help/duplicates.md: -------------------------------------------------------------------------------- 1 | # Duplicates 2 | You may see a "duplicates" badge next to some games. This means that some of 3 | the same files were also backed up for another game. That could be intentional 4 | (e.g., an HD remaster may reuse the original save locations), but it could 5 | also be a sign of an issue in the manifest data. You can expand the game's 6 | file list to see which exact entries are duplicated. 7 | 8 | You can resolve conflicts by disabling certain save files from being backed up. 9 | Once a conflict is resolved, the badge will become faded. 10 | You can also click on the badge to view just the conflicting games. 11 | -------------------------------------------------------------------------------- /src/gui/font.rs: -------------------------------------------------------------------------------- 1 | use iced::{font, Font}; 2 | 3 | pub const TEXT_DATA: &[u8] = include_bytes!("../../assets/NotoSans-Regular.ttf"); 4 | pub const TEXT: Font = Font { 5 | family: font::Family::Name("Noto Sans"), 6 | weight: font::Weight::Normal, 7 | stretch: font::Stretch::Normal, 8 | style: font::Style::Normal, 9 | }; 10 | 11 | pub const ICONS_DATA: &[u8] = include_bytes!("../../assets/MaterialIcons-Regular.ttf"); 12 | pub const ICONS: Font = Font { 13 | family: font::Family::Name("Material Icons"), 14 | weight: font::Weight::Normal, 15 | stretch: font::Stretch::Normal, 16 | style: font::Style::Normal, 17 | }; 18 | -------------------------------------------------------------------------------- /docs/help/backup-validation.md: -------------------------------------------------------------------------------- 1 | # Backup validation 2 | On the restore screen, there is a "validate" button that will check the integrity 3 | of the latest backup (full + differential, if any) for each game. 4 | You won't normally need to use this, but it exists for troubleshooting purposes. 5 | 6 | Specifically, this checks the following: 7 | 8 | * Is mapping.yaml malformed? 9 | * Is any file declared in mapping.yaml, but missing from the actual backup? 10 | 11 | If it finds problems, then it will prompt you to create new full backups for the games in question. 12 | At this time, it will not remove the invalid backups, outside of your normal retention settings. 13 | -------------------------------------------------------------------------------- /tests/launchers/legendary/installed.json: -------------------------------------------------------------------------------- 1 | { 2 | "app-1": { 3 | "app_name": "app-1", 4 | "base_urls": [ 5 | "https://example.com/" 6 | ], 7 | "can_run_offline": true, 8 | "egl_guid": "", 9 | "executable": "game-1.exe", 10 | "install_path": "/games/game-1", 11 | "install_size": 123, 12 | "install_tags": [], 13 | "is_dlc": false, 14 | "launch_parameters": "", 15 | "manifest_path": null, 16 | "needs_verification": false, 17 | "platform": "Windows", 18 | "prereq_info": null, 19 | "requires_ot": false, 20 | "save_path": null, 21 | "title": "game-1", 22 | "version": "1.1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! This library exposes some of the internals of Ludusavi. 2 | //! Most of this code was not originally written with the intention 3 | //! of making it available as a library, 4 | //! so this is currently presented as-is for you to experiment with. 5 | //! In time, this will be refactored and improved, 6 | //! so please understand that the API will be unstable. 7 | 8 | // Some code is only used by the binary crate. 9 | #![allow(unused)] 10 | 11 | pub mod api; 12 | mod cloud; 13 | pub mod lang; 14 | pub mod metadata; 15 | pub mod path; 16 | pub mod prelude; 17 | pub mod report; 18 | pub mod resource; 19 | pub mod scan; 20 | mod serialization; 21 | mod wrap; 22 | 23 | #[cfg(test)] 24 | mod testing; 25 | -------------------------------------------------------------------------------- /docs/help/selective-scanning.md: -------------------------------------------------------------------------------- 1 | # Selective scanning 2 | Once you've done at least one full scan (via the preview/backup buttons), 3 | Ludusavi will remember the games it found and show them to you the next time you run the program. 4 | That way, you can selectively preview or back up a single game without doing a full scan. 5 | Use the three-dot menu next to each game's title to operate on just that one game. 6 | 7 | You can also use keyboard shortcuts to swap the three-dot menu with some specific buttons: 8 | 9 | * preview: shift 10 | * backup/restore: ctrl (Mac: cmd) 11 | * backup/restore without confirmation: ctrl + alt (Mac: cmd + option) 12 | 13 | Additionally, [filters](/docs/help/filter.md) can restrict which games are processed. 14 | -------------------------------------------------------------------------------- /docs/help/environment-variables.md: -------------------------------------------------------------------------------- 1 | # Environment variables 2 | Environment variables can be used to tweak some additional behavior: 3 | 4 | * `RUST_LOG`: Configure logging. 5 | Example: `RUST_LOG=ludusavi=debug` 6 | * `LUDUSAVI_DEBUG`: If this is set to any value, 7 | then Ludusavi will not detach from the console on Windows in GUI mode. 8 | It will also print some debug messages in certain cases. 9 | Example: `LUDUSAVI_DEBUG=1` 10 | * `LUDUSAVI_THREADS`: Overrive the `runtime.threads` value from the config file. 11 | Example: `LUDUSAVI_THREADS=8` 12 | * `LUDUSAVI_LINUX_APP_ID`: On Linux, this can override Ludusavi's application ID. 13 | The default is `com.mtkennerly.ludusavi`. 14 | This should match the corresponding `.desktop` file. 15 | -------------------------------------------------------------------------------- /docs/help/configuration-file.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | Ludusavi stores its configuration in the [application folder](/docs/help/application-folder.md), 3 | in a file named `config.yaml`. 4 | 5 | If you're using the GUI, then it will automatically update the config file 6 | as needed, so you don't need to worry about its content. However, if you're 7 | using the CLI exclusively, then you'll need to edit `config.yaml` yourself. 8 | 9 | ## Schema 10 | [docs/schema/config.yaml](/docs/schema/config.yaml) 11 | 12 | ## Example 13 | ```yaml 14 | manifest: 15 | url: "https://raw.githubusercontent.com/mtkennerly/ludusavi-manifest/master/data/manifest.yaml" 16 | roots: 17 | - path: "D:/Steam" 18 | store: steam 19 | backup: 20 | path: ~/ludusavi-backup 21 | restore: 22 | path: ~/ludusavi-backup 23 | ``` 24 | -------------------------------------------------------------------------------- /docs/help/logging.md: -------------------------------------------------------------------------------- 1 | # Logging 2 | Log files are stored in the [application folder](/docs/help/application-folder.md). 3 | The latest log file is named `ludusavi_rCURRENT.log`, 4 | and any other log files will be named with a timestamp (e.g., `ludusavi_r2000-01-02_03-04-05.log`). 5 | 6 | By default, only warnings and errors are logged, 7 | but you can customize this by setting the `RUST_LOG` environment variable 8 | (e.g., `RUST_LOG=ludusavi=debug`). 9 | The most recent 5 log files are kept, rotating on app launch or when a log reaches 10 MiB. 10 | 11 | The CLI also supports a global `--debug` option, 12 | which sets the maximum log level and opens the log folder after running. 13 | In this case, a separate `ludusavi_debug.log` file will be created, 14 | without any rotation or maximum size. 15 | Be mindful that the file size may increase rapidly during a full scan. 16 | -------------------------------------------------------------------------------- /docs/help/application-folder.md: -------------------------------------------------------------------------------- 1 | # Application folder 2 | Ludusavi stores its configuration/logs/etc in the following locations: 3 | 4 | * Windows: `%APPDATA%/ludusavi` 5 | * Linux: `$XDG_CONFIG_HOME/ludusavi` or `~/.config/ludusavi` 6 | * Flatpak: `~/.var/app/com.github.mtkennerly.ludusavi/config/ludusavi` 7 | * Mac: `~/Library/Application Support/ludusavi` 8 | 9 | Alternatively, if you'd like Ludusavi to store its configuration in the same 10 | place as the executable, then simply create a file called `ludusavi.portable` 11 | in the directory that contains the executable file. You might want to do that 12 | if you're going to run Ludusavi from a flash drive on multiple computers. 13 | 14 | Ludusavi also stores `manifest.yaml` (info on what to back up) here. 15 | You should not modify that file, because Ludusavi will overwrite your changes 16 | whenever it downloads a new copy. 17 | -------------------------------------------------------------------------------- /assets/windows/manifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | true/pm 5 | permonitorv2 6 | true 7 | 8 | 9 | 10 | 11 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /tests/launchers/lutris-spec/games/windows-game.yaml: -------------------------------------------------------------------------------- 1 | game: 2 | args: '' 3 | exe: /home/deck/Games/service/windows-game/drive_c/game/YookaLaylee64.exe 4 | prefix: /home/deck/Games/service/windows-game 5 | working_dir: /home/deck/Games/service/windows-game/drive_c/game 6 | game_slug: windows-game 7 | name: Windows Game 8 | requires: null 9 | script: 10 | files: [] # omitted 11 | game: 12 | args: '' 13 | exe: $GAMEDIR/drive_c/game/YookaLaylee64.exe 14 | prefix: $GAMEDIR 15 | working_dir: $GAMEDIR/drive_c/game 16 | installer: 17 | - task: 18 | arch: win64 19 | prefix: /home/deck/Games/service/windows-game 20 | wine_path: /home/deck/.var/app/net.lutris.Lutris/data/lutris/runners/wine/lutris-7.2-2-x86_64/bin/wine 21 | - mkdir: $GAMEDIR/drive_c/game 22 | - move: ~ # omitted 23 | system: {} 24 | wine: 25 | version: lutris-7.2-2-x86_64 26 | service: service 27 | service_id: ... 28 | slug: windows-game 29 | system: {} 30 | variables: {} 31 | version: Service Name 32 | wine: 33 | version: lutris-7.2-2-x86_64 34 | year: null 35 | -------------------------------------------------------------------------------- /tests/launchers/lutris-merged/games/windows-game-1683516078.yaml: -------------------------------------------------------------------------------- 1 | game: 2 | args: '' 3 | exe: /home/deck/Games/service/windows-game/drive_c/game/YookaLaylee64.exe 4 | prefix: /home/deck/Games/service/windows-game-1b 5 | working_dir: /home/deck/Games/service/windows-game/drive_c/game 6 | game_slug: windows-game 7 | name: Windows Game 8 | requires: null 9 | script: 10 | files: [] # omitted 11 | game: 12 | args: '' 13 | exe: $GAMEDIR/drive_c/game/YookaLaylee64.exe 14 | prefix: $GAMEDIR 15 | working_dir: $GAMEDIR/drive_c/game 16 | installer: 17 | - task: 18 | arch: win64 19 | prefix: /home/deck/Games/service/windows-game 20 | wine_path: /home/deck/.var/app/net.lutris.Lutris/data/lutris/runners/wine/lutris-7.2-2-x86_64/bin/wine 21 | - mkdir: $GAMEDIR/drive_c/game 22 | - move: ~ # omitted 23 | system: {} 24 | wine: 25 | version: lutris-7.2-2-x86_64 26 | service: service 27 | service_id: ... 28 | slug: windows-game 29 | system: {} 30 | variables: {} 31 | version: Service Name 32 | wine: 33 | version: lutris-7.2-2-x86_64 34 | year: null 35 | -------------------------------------------------------------------------------- /tests/launchers/lutris-split/config/games/windows-game-1683516078.yaml: -------------------------------------------------------------------------------- 1 | game: 2 | args: '' 3 | exe: /home/deck/Games/service/windows-game/drive_c/game/YookaLaylee64.exe 4 | prefix: /home/deck/Games/service/windows-game-1b 5 | working_dir: /home/deck/Games/service/windows-game/drive_c/game 6 | game_slug: windows-game 7 | name: Windows Game 8 | requires: null 9 | script: 10 | files: [] # omitted 11 | game: 12 | args: '' 13 | exe: $GAMEDIR/drive_c/game/YookaLaylee64.exe 14 | prefix: $GAMEDIR 15 | working_dir: $GAMEDIR/drive_c/game 16 | installer: 17 | - task: 18 | arch: win64 19 | prefix: /home/deck/Games/service/windows-game 20 | wine_path: /home/deck/.var/app/net.lutris.Lutris/data/lutris/runners/wine/lutris-7.2-2-x86_64/bin/wine 21 | - mkdir: $GAMEDIR/drive_c/game 22 | - move: ~ # omitted 23 | system: {} 24 | wine: 25 | version: lutris-7.2-2-x86_64 26 | service: service 27 | service_id: ... 28 | slug: windows-game 29 | system: {} 30 | variables: {} 31 | version: Service Name 32 | wine: 33 | version: lutris-7.2-2-x86_64 34 | year: null 35 | -------------------------------------------------------------------------------- /tests/launchers/heroic-gog-without-store-cache/gog_store/library.json: -------------------------------------------------------------------------------- 1 | { 2 | "games": [ 3 | { 4 | "runner": "gog", 5 | "app_name": "app-1", 6 | "cloud_save_enabled": true, 7 | "compatible_apps": [], 8 | "extra": { 9 | "about": { 10 | "description": "foo", 11 | "shortDescription": "" 12 | }, 13 | "reqs": [] 14 | }, 15 | "folder_name": "", 16 | "install": { 17 | "version": null, 18 | "executable": "", 19 | "install_path": "", 20 | "install_size": "", 21 | "is_dlc": false, 22 | "platform": "" 23 | }, 24 | "is_game": true, 25 | "is_installed": false, 26 | "is_ue_asset": false, 27 | "is_ue_plugin": false, 28 | "is_ue_project": false, 29 | "namespace": "game-1-namespace", 30 | "save_folder": "", 31 | "title": "game-1", 32 | "canRunOffline": true, 33 | "is_mac_native": false, 34 | "is_linux_native": false 35 | } 36 | ], 37 | "totalGames": 1, 38 | "totalMovies": 0, 39 | "cloud_saves_enabled": true 40 | } 41 | -------------------------------------------------------------------------------- /tests/launchers/heroic-gog-with-store-cache/store_cache/gog_library.json: -------------------------------------------------------------------------------- 1 | { 2 | "games": [ 3 | { 4 | "runner": "gog", 5 | "app_name": "app-1", 6 | "cloud_save_enabled": true, 7 | "compatible_apps": [], 8 | "extra": { 9 | "about": { 10 | "description": "foo", 11 | "shortDescription": "" 12 | }, 13 | "reqs": [] 14 | }, 15 | "folder_name": "", 16 | "install": { 17 | "version": null, 18 | "executable": "", 19 | "install_path": "", 20 | "install_size": "", 21 | "is_dlc": false, 22 | "platform": "" 23 | }, 24 | "is_game": true, 25 | "is_installed": false, 26 | "is_ue_asset": false, 27 | "is_ue_plugin": false, 28 | "is_ue_project": false, 29 | "namespace": "game-1-namespace", 30 | "save_folder": "", 31 | "title": "game-1", 32 | "canRunOffline": true, 33 | "is_mac_native": false, 34 | "is_linux_native": false 35 | } 36 | ], 37 | "totalGames": 1, 38 | "totalMovies": 0, 39 | "cloud_saves_enabled": true 40 | } 41 | -------------------------------------------------------------------------------- /docs/help/transfer-between-operating-systems.md: -------------------------------------------------------------------------------- 1 | # Transfer between operating systems 2 | Although Ludusavi itself runs on Windows, Linux, and Mac, 3 | it does not automatically support backing up on one OS and restoring on another. 4 | 5 | This is a complex problem to solve because 6 | games do not necessarily store data in the same way on each OS. 7 | Ludusavi only knows where each game stores its data on a given OS, 8 | but does not know which save locations correspond to each other, 9 | or even if any of them do correspond. 10 | Some games store data in completely different and incompatible ways on different OSes. 11 | 12 | In simple cases, you may be able to configure [redirects](/docs/help/redirects.md) 13 | to translate between specific Windows and Linux paths, 14 | but this would generally require multiple redirects tailored to each game. 15 | In more complex cases, this is not practical or feasible. 16 | 17 | A subset of cross-OS transfer is under consideration for Windows and Wine prefixes, 18 | but there is no timeline for this. 19 | You can follow this ticket for any future updates: 20 | https://github.com/mtkennerly/ludusavi/issues/194 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Matthew T. Kennerly (mtkennerly) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/help/filter.md: -------------------------------------------------------------------------------- 1 | # Filter 2 | You can click the filter icon at the top of the backup/restore screens to use some filters. 3 | While these filters are active, 4 | Ludusavi will only back up or restore the games that are currently listed. 5 | 6 | You can apply filters for the following: 7 | 8 | * Whether the game title matches a search. 9 | * Whether multiple games have the same save files: 10 | * `Unique` (no conflicts) 11 | * `Duplicated` (conflicts exist) 12 | * Whether all save files for a game are enabled for processing: 13 | * `Complete` (all saves enabled) 14 | * `Partial` (some saves disabled) 15 | * Each game's status: 16 | * `New` (game has new saves) 17 | * `Updated` (game has updated saves) 18 | * `Unchanged` (game has no changes) 19 | * `Unscanned` (game has not been scanned yet, but is still is history) 20 | * Whether the game itself is enabled for processing: 21 | * `Enabled` (checkbox next to game is checked) 22 | * `Disabled` (checkbox next to game is unchecked) 23 | * Game data source: 24 | * `Primary manifest` (Ludusavi's main game list) 25 | * `Custom games` (your custom entries) 26 | * Any secondary manifest you've added on the "other" screen 27 | -------------------------------------------------------------------------------- /src/gui/notification.rs: -------------------------------------------------------------------------------- 1 | use std::time::Instant; 2 | 3 | use iced::{alignment, padding}; 4 | 5 | use crate::gui::{ 6 | style, 7 | widget::{text, Container}, 8 | }; 9 | 10 | pub struct Notification { 11 | text: String, 12 | created: Instant, 13 | expires: Option, 14 | } 15 | 16 | impl Notification { 17 | pub fn new(text: String) -> Self { 18 | Self { 19 | text, 20 | created: Instant::now(), 21 | expires: None, 22 | } 23 | } 24 | 25 | pub fn expires(mut self, expires: u64) -> Self { 26 | self.expires = Some(expires); 27 | self 28 | } 29 | 30 | pub fn expired(&self) -> bool { 31 | match self.expires { 32 | None => false, 33 | Some(expires) => (Instant::now() - self.created).as_secs() > expires, 34 | } 35 | } 36 | 37 | pub fn view(&self) -> Container { 38 | Container::new( 39 | Container::new(text(self.text.clone())) 40 | .padding([3, 40]) 41 | .align_x(alignment::Horizontal::Center) 42 | .align_y(alignment::Vertical::Center) 43 | .class(style::Container::Notification), 44 | ) 45 | .padding(padding::bottom(5)) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /docs/help/backup-retention.md: -------------------------------------------------------------------------------- 1 | # Backup retention 2 | In the "other" screen's backup section, 3 | you can configure how many backups to keep. 4 | A full backup contains all save data for a game, 5 | while a differential backup contains just the data that has changed since the last full backup. 6 | 7 | When Ludusavi makes a new backup for a game, it will also remove any excess backups for that specific game. 8 | When a full backup is deleted, its associated differential backups are deleted as well. 9 | 10 | For example, if you configure a retention limit of 2 full and 2 differential, 11 | then Ludusavi will create 2 differential backups for each full backup, like so: 12 | 13 | * Backup #1: full 14 | * Backup #2: differential 15 | * Backup #3: differential 16 | * Backup #4: full 17 | * Backup #5: differential 18 | * Backup #6: differential 19 | 20 | When backup #7 is created, because the full retention is set to 2, 21 | Ludusavi will delete backups 1 through 3. 22 | 23 | If your full retention is only 1 and your differential retention is 1+, 24 | then Ludusavi will keep the full backup and just delete the oldest differential as needed. 25 | 26 | On the restore screen, you can use the three-dot menu next to a game to lock any of its backups. 27 | Locked backups do not count toward the retention limits and are retained indefinitely. 28 | -------------------------------------------------------------------------------- /docs/help/custom-games.md: -------------------------------------------------------------------------------- 1 | # Custom games 2 | You can create your own game save definitions on the `custom games` screen. 3 | If the game name exactly matches a known game, then your custom entry will override it. 4 | 5 | For file paths, you can click the browse button to quickly select a folder. 6 | The path can be a file too, but the browse button only lets you choose 7 | folders at this time. You can just type in the file name afterwards. 8 | You can also use [globs](https://en.wikipedia.org/wiki/Glob_(programming)) 9 | (e.g., `C:/example/*.txt` selects all TXT files in that folder) 10 | and the placeholders defined in the 11 | [Ludusavi Manifest format](https://github.com/mtkennerly/ludusavi-manifest). 12 | If you have a folder name that contains a special glob character, 13 | you can escape it by wrapping it in brackets (e.g., `[` becomes `[[]`). 14 | 15 | Installed names should be a bare folder name or relative path only (no absolute paths), 16 | because Ludusavi will look for this folder in each root. 17 | Ludusavi automatically looks for the game's own name as well, 18 | so you only need to specify a custom folder name if it's different. 19 | For example, if you have an other-type root at `C:\Games`, 20 | and there's a game called `Some Game` installed at `C:\Games\sg`, 21 | then you would set the installed name as `sg`. 22 | If you had a bundled game like `C:\Games\trilogy\first-game`, 23 | then you could set the installed name as `trilogy\first-game`. 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yaml: -------------------------------------------------------------------------------- 1 | name: Problem 2 | description: Report a problem. 3 | labels: ["bug"] 4 | body: 5 | - type: dropdown 6 | attributes: 7 | label: Ludusavi version 8 | description: If you're not using the latest version, please update and make sure the problem still occurs. 9 | options: 10 | - v0.30.0 11 | - v0.29.1 12 | - v0.29.0 13 | - v0.28.0 14 | - v0.27.0 15 | - v0.26.0 16 | - v0.25.0 17 | - v0.24.3 18 | - v0.24.2 19 | - v0.24.1 20 | - Other 21 | validations: 22 | required: true 23 | - type: dropdown 24 | attributes: 25 | label: Operating system 26 | options: 27 | - Windows 28 | - Mac 29 | - Linux 30 | - Linux (Steam Deck) 31 | validations: 32 | required: true 33 | - type: dropdown 34 | attributes: 35 | label: Installation method 36 | options: 37 | - Standalone 38 | - Cargo 39 | - Flatpak 40 | - Scoop 41 | - Other 42 | validations: 43 | required: true 44 | - type: textarea 45 | attributes: 46 | label: Description 47 | description: What happened? 48 | validations: 49 | required: true 50 | - type: textarea 51 | id: logs 52 | attributes: 53 | label: Logs 54 | description: >- 55 | Please provide any relevant screenshots, CLI output, or log files. 56 | Refer to the documentation to 57 | [find your config file](https://github.com/mtkennerly/ludusavi/blob/master/docs/help/configuration-file.md) 58 | and/or [enable verbose logging](https://github.com/mtkennerly/ludusavi/blob/master/docs/help/logging.md). 59 | -------------------------------------------------------------------------------- /src/resource.rs: -------------------------------------------------------------------------------- 1 | pub mod cache; 2 | pub mod config; 3 | pub mod manifest; 4 | 5 | use crate::prelude::{app_dir, AnyError, StrictPath}; 6 | 7 | pub trait ResourceFile 8 | where 9 | Self: Default + serde::de::DeserializeOwned, 10 | { 11 | const FILE_NAME: &'static str; 12 | 13 | fn path() -> StrictPath { 14 | app_dir().joined(Self::FILE_NAME) 15 | } 16 | 17 | /// If the resource file does not exist, use default data and apply these modifications. 18 | fn initialize(self) -> Self { 19 | self 20 | } 21 | 22 | /// Update any legacy settings on load. 23 | fn migrate(self) -> Self { 24 | self 25 | } 26 | 27 | fn load() -> Result { 28 | Self::load_from(&Self::path()) 29 | } 30 | 31 | fn load_from(path: &StrictPath) -> Result { 32 | if !path.exists() { 33 | return Ok(Self::default().initialize()); 34 | } 35 | let content = Self::load_raw(path)?; 36 | Self::load_from_string(&content) 37 | } 38 | 39 | fn load_from_existing(path: &StrictPath) -> Result { 40 | let content = Self::load_raw(path)?; 41 | Self::load_from_string(&content) 42 | } 43 | 44 | fn load_raw(path: &StrictPath) -> Result { 45 | path.try_read() 46 | } 47 | 48 | fn load_from_string(content: &str) -> Result { 49 | Ok(ResourceFile::migrate(serde_yaml::from_str(content)?)) 50 | } 51 | } 52 | 53 | pub trait SaveableResourceFile 54 | where 55 | Self: ResourceFile + serde::Serialize, 56 | { 57 | fn save(&self) { 58 | let new_content = serde_yaml::to_string(&self).unwrap(); 59 | 60 | if let Ok(old_content) = Self::load_raw(&Self::path()) { 61 | if old_content == new_content { 62 | return; 63 | } 64 | } 65 | 66 | if Self::path().create_parent_dir().is_ok() { 67 | let _ = Self::path().write_with_content(&new_content); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /docs/help/missing-saves.md: -------------------------------------------------------------------------------- 1 | # What if my saves aren't found? 2 | Ludusavi mainly gets its data from [PCGamingWiki](https://www.pcgamingwiki.com). 3 | The first step is to make sure that the game in question has an article, 4 | and if so, that it has save and/or config locations in the `Game data` section 5 | for your version of the game (e.g., Windows vs Linux, Steam vs Epic). 6 | 7 | When the wiki has Windows save locations, but no Linux/Mac locations, 8 | Ludusavi can *derive* some potential paths that aren't listed on the wiki, 9 | such as checking Steam's `compatdata` folder for the game's app ID. 10 | Sometimes, the fact that the game is running via Proton 11 | or was loaded as a non-Steam game can affect the exact save location, 12 | which may require adding/fixing the wiki data. 13 | 14 | For games that have a PCGamingWiki article, but no save info, 15 | Ludusavi also checks for Steam Cloud metadata as a fallback. 16 | If the game doesn't have Steam Cloud support, then this won't apply, 17 | and the Steam Cloud info is ignored once the wiki has save locations listed. 18 | 19 | Every few hours, the latest changes from PCGamingWiki and Steam are assembled into the 20 | [primary manifest](https://github.com/mtkennerly/ludusavi-manifest). 21 | Ludusavi itself relies on this for save info, 22 | rather than constantly checking the wiki when you run the application. 23 | If the save location is listed on PCGamingWiki, 24 | but was only added in the last few hours, 25 | then it may simply not have made its way into the manifest yet. 26 | You can also use Ludusavi's "other" screen to check when the manifest was last downloaded. 27 | 28 | If the paths seem to be listed already, but Ludusavi still doesn't find it, 29 | then try double checking your [configured roots](/docs/help/roots.md). 30 | Ludusavi may only be able to scan some paths if an applicable root is configured. 31 | For example, having a Steam root will enable Ludusavi to check its `compatdata` folder. 32 | 33 | ## Flatpak 34 | If you're using Flatpak on Linux, then by default, 35 | Ludusavi only has permission to view certain folders. 36 | You can use a tool like Flatseal to grant access to additional folders. 37 | -------------------------------------------------------------------------------- /src/testing.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeMap; 2 | 3 | use velcro::btree_map; 4 | 5 | use crate::path::StrictPath; 6 | 7 | pub const EMPTY_HASH: &str = "da39a3ee5e6b4b0d3255bfef95601890afd80709"; 8 | 9 | pub fn repo() -> String { 10 | repo_raw().replace('\\', "/") 11 | } 12 | 13 | pub fn repo_raw() -> String { 14 | env!("CARGO_MANIFEST_DIR").to_string() 15 | } 16 | 17 | pub fn repo_file(path: &str) -> String { 18 | repo_file_raw(path).replace('\\', "/") 19 | } 20 | 21 | pub fn repo_file_raw(path: &str) -> String { 22 | if cfg!(target_os = "windows") { 23 | format!("{}\\{}", repo_raw(), path.replace('/', "\\")) 24 | } else { 25 | format!("{}/{}", repo_raw(), path) 26 | } 27 | } 28 | 29 | pub fn repo_path(path: &str) -> StrictPath { 30 | StrictPath::new(repo_file(path)) 31 | } 32 | 33 | pub fn repo_path_raw(path: &str) -> StrictPath { 34 | StrictPath::new(repo_file_raw(path)) 35 | } 36 | 37 | pub fn absolute_path(file: &str) -> StrictPath { 38 | if cfg!(target_os = "windows") { 39 | StrictPath::new(format!("X:{file}")) 40 | } else { 41 | StrictPath::new(file.to_string()) 42 | } 43 | } 44 | 45 | pub fn mapping_file_key(file: &str) -> String { 46 | if cfg!(target_os = "windows") { 47 | format!("X:{file}") 48 | } else { 49 | file.to_string() 50 | } 51 | } 52 | 53 | pub fn drives_x() -> BTreeMap { 54 | if cfg!(target_os = "windows") { 55 | btree_map! { "drive-X".into(): "X:".into() } 56 | } else { 57 | btree_map! { "drive-0".into(): "".into() } 58 | } 59 | } 60 | 61 | pub fn drives_x_always() -> BTreeMap { 62 | if cfg!(target_os = "windows") { 63 | btree_map! { "drive-X".into(): "X:".into() } 64 | } else { 65 | btree_map! { "drive-X".into(): "".into() } 66 | } 67 | } 68 | 69 | pub fn drives_x_static() -> BTreeMap { 70 | btree_map! { "drive-X".into(): "X:".into() } 71 | } 72 | 73 | pub fn make_original_path(file: &str) -> StrictPath { 74 | StrictPath::new(format!("{}{file}", if cfg!(target_os = "windows") { "X:" } else { "" })) 75 | } 76 | 77 | pub fn s(text: &str) -> String { 78 | text.to_string() 79 | } 80 | -------------------------------------------------------------------------------- /docs/help/command-line.md: -------------------------------------------------------------------------------- 1 | # Command line 2 | Ludusavi provides a [command line interface](https://en.wikipedia.org/wiki/Command-line_interface), 3 | which you can use for automating tasks. 4 | 5 | Run `ludusavi --help` for the overall CLI usage information, 6 | or view info for specific subcommands, such as `ludusavi manifest update --help`. 7 | 8 | You can also view the help text in [the CLI docs](/docs/cli.md). 9 | 10 | ## Demo 11 | > ![CLI demo of previewing a backup](/docs/demo-cli.gif) 12 | 13 | ## JSON output 14 | CLI mode defaults to a human-readable format, but you can switch to a 15 | machine-readable JSON format with the `--api` flag. 16 | 17 | Note that, in some error conditions, there may not be any JSON output, 18 | so you should check if stdout was blank before trying to parse it. 19 | If the command line input cannot be parsed, then the output will not be 20 | in a stable format. 21 | 22 | API output goes on stdout, but stderr may still be used for human-readable warnings/errors. 23 | If stderr is not empty, you may want to log it, 24 | since not all human-readable warnings have an API equivalent. 25 | 26 | There is also an `api` command that supports using JSON for the input as well. 27 | 28 | ## Schemas 29 | * [`--api` mode](/docs/schema/general-output.yaml) 30 | * [`api` command input](/docs/schema/api-input.yaml) 31 | * [`api` command output](/docs/schema/api-output.yaml) 32 | 33 | ## Example 34 | Output for `backup --force --api`: 35 | 36 | ```json 37 | { 38 | "errors": { 39 | "someGamesFailed": true, 40 | }, 41 | "overall": { 42 | "totalGames": 2, 43 | "totalBytes": 150, 44 | "processedGames": 1, 45 | "processedBytes": 100, 46 | }, 47 | "games": { 48 | "Game 1": { 49 | "decision": "Processed", 50 | "files": { 51 | "/games/game1/save.json": { 52 | "bytes": 100 53 | } 54 | }, 55 | "registry": { 56 | "HKEY_CURRENT_USER/Software/Game1": { 57 | "failed": true 58 | } 59 | } 60 | }, 61 | "Game 2": { 62 | "decision": "Ignored", 63 | "files": { 64 | "/games/game2/save.json": { 65 | "bytes": 50 66 | } 67 | }, 68 | "registry": {} 69 | } 70 | } 71 | } 72 | ``` 73 | -------------------------------------------------------------------------------- /src/gui.rs: -------------------------------------------------------------------------------- 1 | mod app; 2 | mod badge; 3 | mod button; 4 | mod common; 5 | mod editor; 6 | mod file_tree; 7 | mod font; 8 | mod game_list; 9 | mod icon; 10 | mod modal; 11 | mod notification; 12 | mod popup_menu; 13 | mod screen; 14 | mod search; 15 | mod shortcuts; 16 | mod style; 17 | mod undoable; 18 | mod widget; 19 | 20 | use iced::Size; 21 | 22 | use self::app::App; 23 | pub use self::common::Flags; 24 | 25 | pub fn run(flags: Flags) { 26 | let app = iced::application(move || App::new(flags.clone()), App::update, App::view) 27 | .subscription(App::subscription) 28 | .theme(App::theme) 29 | .title(App::title) 30 | .executor::() 31 | .settings(iced::Settings { 32 | default_font: font::TEXT, 33 | ..Default::default() 34 | }) 35 | .window(iced::window::Settings { 36 | min_size: Some(Size::new(800.0, 600.0)), 37 | exit_on_close_request: false, 38 | #[cfg(target_os = "linux")] 39 | platform_specific: iced::window::settings::PlatformSpecific { 40 | application_id: std::env::var(crate::prelude::ENV_LINUX_APP_ID) 41 | .unwrap_or_else(|_| crate::prelude::LINUX_APP_ID.to_string()), 42 | ..Default::default() 43 | }, 44 | icon: match image::load_from_memory(include_bytes!("../assets/icon.png")) { 45 | Ok(buffer) => { 46 | let buffer = buffer.to_rgba8(); 47 | let width = buffer.width(); 48 | let height = buffer.height(); 49 | let dynamic_image = image::DynamicImage::ImageRgba8(buffer); 50 | iced::window::icon::from_rgba(dynamic_image.into_bytes(), width, height).ok() 51 | } 52 | Err(_) => None, 53 | }, 54 | ..Default::default() 55 | }); 56 | 57 | if let Err(e) = app.run() { 58 | log::error!("Failed to initialize GUI: {e:?}"); 59 | eprintln!("Failed to initialize GUI: {e:?}"); 60 | 61 | rfd::MessageDialog::new() 62 | .set_level(rfd::MessageLevel::Error) 63 | .set_description(e.to_string()) 64 | .set_buttons(rfd::MessageButtons::Ok) 65 | .show(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/scan/steam.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use crate::{prelude::StrictPath, scan::TitleFinder}; 4 | 5 | #[derive(Clone, Debug, Default)] 6 | pub struct SteamShortcuts(HashMap); 7 | 8 | #[derive(Clone, Debug, Default)] 9 | pub struct SteamShortcut { 10 | pub id: u32, 11 | pub start_dir: Option, 12 | } 13 | 14 | impl SteamShortcuts { 15 | pub fn scan(title_finder: &TitleFinder) -> Self { 16 | let mut instance = Self::default(); 17 | 18 | let steam = match steamlocate::SteamDir::locate() { 19 | Ok(x) => x, 20 | Err(e) => { 21 | log::info!("Unable to locate Steam directory: {:?}", e); 22 | return instance; 23 | } 24 | }; 25 | 26 | log::info!("Inspecting Steam shortcuts from: {:?}", steam.path()); 27 | 28 | let Ok(shortcuts) = steam.shortcuts() else { 29 | log::warn!("Unable to load Steam shortcuts"); 30 | return instance; 31 | }; 32 | 33 | for shortcut in shortcuts.filter_map(|x| x.ok()) { 34 | let Some(official_title) = title_finder.find_one_by_normalized_name(&shortcut.app_name) else { 35 | log::debug!("Ignoring unrecognized Steam shortcut: {}", &shortcut.app_name); 36 | continue; 37 | }; 38 | 39 | log::trace!( 40 | "Found Steam shortcut: app_name='{}', official_title='{}', id={}, start_dir='{}'", 41 | &shortcut.app_name, 42 | &official_title, 43 | shortcut.app_id, 44 | &shortcut.start_dir 45 | ); 46 | let start_dir = std::path::Path::new(shortcut.start_dir.trim_start_matches('"').trim_end_matches('"')); 47 | instance.0.insert( 48 | official_title, 49 | SteamShortcut { 50 | id: shortcut.app_id, 51 | start_dir: if start_dir.is_absolute() { 52 | Some(StrictPath::from(start_dir)) 53 | } else { 54 | None 55 | }, 56 | }, 57 | ); 58 | } 59 | 60 | instance 61 | } 62 | 63 | pub fn get(&self, name: &str) -> Option<&SteamShortcut> { 64 | self.0.get(name) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /docs/help/redirects.md: -------------------------------------------------------------------------------- 1 | # Redirects 2 | You can use redirects to back up or restore to a different location than the original file. 3 | These are listed on the "other" screen, where you can click the plus button to add more 4 | and then enter both the old location (source) and new location (target). 5 | 6 | There are multiple types of redirects: 7 | 8 | * `Backup`: Applies only for backup mode. 9 | * `Restore`: Applies only for restore mode. 10 | * `Bidirectional`: Uses source -> target in backup mode and target -> source in restore mode. 11 | 12 | For example: 13 | 14 | * Let's say you backed up some saves from `C:/Games`, but then you decided to move it to `D:/Games`. 15 | You could create a restore redirect with `C:/Games` as the source and `D:/Games` as the target. 16 | * Let's say you play on two computers with different usernames ("A" and "B"), 17 | but you know that the saves are otherwise the same, 18 | so you'd like them to share backups. 19 | You could create two bidirectional redirects: 20 | 21 | * On Computer A, set source to `C:/Users/A` and target to `C:/Users/main` 22 | * On computer B, set source to `C:/Users/B` and target to `C:/Users/main` 23 | 24 | Both computers' backups would reference the fake user "main", 25 | but then they would be restored to the original location for that computer. 26 | 27 | Tip: As you're editing your redirects, try running a preview and expanding some 28 | games' file lists. This will show you what effect your redirects 29 | will have when you perform the restore for real. 30 | 31 | ## Sequence 32 | Redirects are processed top to bottom, 33 | and the output from one redirect can affect the redirects after it. 34 | 35 | Let's say you have a save file at `C:/Title/save.dat`, 36 | and you set up two redirects: 37 | 38 | * Bidirectional: `C:/Title` -> `C:/Games/Title` 39 | * Bidirectional: `C:/Games` -> `D:/Games` 40 | 41 | When backing up, the transformation will be: 42 | `C:/Title/save.dat` -> `C:/Games/Title/save.dat` -> `D:/Games/Title/save.dat` 43 | 44 | By default, the same order is used when restoring. 45 | When you have chained bidirectional redirects, 46 | that may lead to an undesired result: 47 | `D:/Games/Title/save.dat` won't trigger the first redirect, 48 | so it would restore to `C:/Games/Title/save.dat`. 49 | You can enable the "reverse sequence of redirects when restoring" option to change this behavior. 50 | -------------------------------------------------------------------------------- /docs/help/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | ## Requirements 3 | * Ludusavi is available for Windows, Linux, and Mac. 4 | * For the best performance, your system should support one of DirectX, Vulkan, or Metal. 5 | For other systems, Ludusavi will use a fallback software renderer, 6 | or you can also activate the software renderer by setting the `ICED_BACKEND` environment variable to `tiny-skia`. 7 | 8 | ## Methods 9 | You can install Ludusavi one of these ways: 10 | 11 | * Download the executable for your operating system from the 12 | [releases page](https://github.com/mtkennerly/ludusavi/releases). 13 | It's portable, so you can simply download it and put it anywhere on your system. 14 | **If you're unsure, choose this option.** 15 | 16 | * On Windows, you can use [Winget](https://github.com/microsoft/winget-cli). 17 | 18 | * To install: `winget install -e --id mtkennerly.ludusavi` 19 | * To update: `winget upgrade -e --id mtkennerly.ludusavi` 20 | 21 | * On Windows, you can use [Scoop](https://scoop.sh). 22 | 23 | * To install: `scoop bucket add extras && scoop install ludusavi` 24 | * To update: `scoop update && scoop update ludusavi` 25 | 26 | * For Linux, Ludusavi is available on [Flathub](https://flathub.org/apps/details/com.github.mtkennerly.ludusavi). 27 | Note that it has limited file system access by default (`~` and `/run/media`). 28 | If you'd like to enable broader access, [see here](https://github.com/flathub/com.github.mtkennerly.ludusavi/blob/master/README.md). 29 | 30 | * If you have [Rust](https://www.rust-lang.org), you can use Cargo. 31 | 32 | * To install or update: `cargo install --locked ludusavi` 33 | 34 | On Linux, this requires the following system packages, or their equivalents 35 | for your distribution: 36 | 37 | * Ubuntu: `sudo apt-get install -y gcc cmake libx11-dev libxcb-composite0-dev libfreetype6-dev libexpat1-dev libfontconfig1-dev libgtk-3-dev` 38 | 39 | ## Notes 40 | If you are on Windows: 41 | 42 | * When you first run Ludusavi, you may see a popup that says 43 | "Windows protected your PC", 44 | because Windows does not recognize the program's publisher. 45 | Click "more info" and then "run anyway" to start the program. 46 | 47 | If you are on Mac: 48 | 49 | * When you first run Ludusavi, you may see a popup that says 50 | "Ludusavi can't be opened because it is from an unidentified developer". 51 | To allow Ludusavi to run, please refer to [this article](https://support.apple.com/en-us/102445), 52 | specifically the section on `If you want to open an app [...] from an unidentified developer`. 53 | -------------------------------------------------------------------------------- /src/scan/launchers.rs: -------------------------------------------------------------------------------- 1 | mod generic; 2 | pub mod heroic; 3 | mod legendary; 4 | mod lutris; 5 | 6 | use std::collections::{HashMap, HashSet}; 7 | 8 | use crate::{ 9 | prelude::StrictPath, 10 | resource::{ 11 | config::Root, 12 | manifest::{Manifest, Os}, 13 | }, 14 | scan::TitleFinder, 15 | }; 16 | 17 | #[derive(Clone, Default, Debug)] 18 | pub struct Launchers { 19 | games: HashMap>>, 20 | empty: HashSet, 21 | } 22 | 23 | #[derive(Debug, Clone, Eq, PartialEq, Hash)] 24 | pub struct LauncherGame { 25 | pub install_dir: Option, 26 | pub prefix: Option, 27 | pub platform: Option, 28 | } 29 | 30 | impl LauncherGame { 31 | pub fn is_empty(&self) -> bool { 32 | self.install_dir.is_none() && self.prefix.is_none() && self.platform.is_none() 33 | } 34 | } 35 | 36 | impl Launchers { 37 | pub fn get_game(&self, root: &Root, game: &str) -> impl Iterator { 38 | self.games 39 | .get(root) 40 | .and_then(|root| root.get(game)) 41 | .unwrap_or(&self.empty) 42 | .iter() 43 | } 44 | 45 | pub fn scan( 46 | roots: &[Root], 47 | manifest: &Manifest, 48 | subjects: &[String], 49 | title_finder: &TitleFinder, 50 | legendary: Option, 51 | ) -> Self { 52 | let mut instance = Self::default(); 53 | 54 | for root in roots { 55 | log::debug!("Scanning launcher info: {:?}", &root); 56 | let mut found = match root { 57 | Root::Heroic(root) => heroic::scan(root, title_finder, legendary.as_ref()), 58 | Root::Legendary(root) => legendary::scan(root, title_finder), 59 | Root::Lutris(root) => lutris::scan(root, title_finder), 60 | _ => generic::scan(root, manifest, subjects), 61 | }; 62 | found.retain(|_k, v| { 63 | v.retain(|x| !x.is_empty()); 64 | !v.is_empty() 65 | }); 66 | log::debug!("launcher games found ({:?}): {:#?}", &root, &found); 67 | if !found.is_empty() { 68 | instance.games.entry(root.clone()).or_default().extend(found); 69 | } 70 | } 71 | 72 | instance 73 | } 74 | 75 | #[cfg(test)] 76 | pub fn scan_dirs(roots: &[Root], manifest: &Manifest, subjects: &[String]) -> Self { 77 | Self::scan(roots, manifest, subjects, &TitleFinder::default(), None) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /docs/schema/api-output.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | $schema: "http://json-schema.org/draft-07/schema#" 3 | title: Output 4 | description: "The full output of the `api` command." 5 | anyOf: 6 | - type: object 7 | required: 8 | - responses 9 | properties: 10 | responses: 11 | description: "Responses to each request, in the same order as the request input." 12 | type: array 13 | items: 14 | $ref: "#/definitions/Response" 15 | - type: object 16 | required: 17 | - error 18 | properties: 19 | error: 20 | description: A top-level error not tied to any particular request. 21 | allOf: 22 | - $ref: "#/definitions/Error" 23 | definitions: 24 | AppUpdate: 25 | type: object 26 | properties: 27 | url: 28 | description: Release URL to open in browser. 29 | default: "" 30 | type: string 31 | version: 32 | description: New version number. 33 | default: "" 34 | type: string 35 | CheckAppUpdate: 36 | type: object 37 | properties: 38 | update: 39 | description: An available update. 40 | default: ~ 41 | anyOf: 42 | - $ref: "#/definitions/AppUpdate" 43 | - type: "null" 44 | EditBackup: 45 | type: object 46 | Error: 47 | type: object 48 | properties: 49 | message: 50 | description: Human-readable error message. 51 | default: "" 52 | type: string 53 | FindTitle: 54 | type: object 55 | properties: 56 | titles: 57 | description: Any matching titles found. 58 | default: [] 59 | type: array 60 | items: 61 | type: string 62 | Response: 63 | description: A response to an individual request. 64 | oneOf: 65 | - type: object 66 | required: 67 | - error 68 | properties: 69 | error: 70 | $ref: "#/definitions/Error" 71 | additionalProperties: false 72 | - type: object 73 | required: 74 | - findTitle 75 | properties: 76 | findTitle: 77 | $ref: "#/definitions/FindTitle" 78 | additionalProperties: false 79 | - type: object 80 | required: 81 | - checkAppUpdate 82 | properties: 83 | checkAppUpdate: 84 | $ref: "#/definitions/CheckAppUpdate" 85 | additionalProperties: false 86 | - type: object 87 | required: 88 | - editBackup 89 | properties: 90 | editBackup: 91 | $ref: "#/definitions/EditBackup" 92 | additionalProperties: false 93 | -------------------------------------------------------------------------------- /docs/help/backup-automation.md: -------------------------------------------------------------------------------- 1 | # Backup automation 2 | Normally, Ludusavi only runs when you launch it and manually request a backup. 3 | However, it is possible to set up automatic backups that run in the background. 4 | You can do this using any task automation app that can invoke Ludusavi's [command line](/docs/help/command-line.md). 5 | 6 | ## Windows: Task Scheduler 7 | On Windows, you can use the built-in Task Scheduler app. 8 | This is how to use it on Windows 11: 9 | 10 | * Search for `Task Scheduler` in the Start Menu and click to launch it. 11 | * On the right side of Task Scheduler, click `Create Basic Task...`. 12 | * In the popup window, enter the task name (e.g., `Ludusavi`). 13 | Click `next`. 14 | * Select how often you'd like the backup to occur (e.g., daily). 15 | Click `next`. 16 | * If you want, you may adjust the exact date and time for the task to start. 17 | Click `next`. 18 | * Set the task action to `start a program`. 19 | Click `next`. 20 | * Use the `browse` button to select the full path to your copy of `ludusavi.exe`. 21 | 22 | In the `add arguments` field, enter the following exactly: `backup --force` 23 | 24 | You can leave the `start in` field blank. 25 | 26 | Click `next`. 27 | * On the last screen, click `finish` to create the task. 28 | * You can always view or edit the task on the left side of the main Task Scheduler window, 29 | in the `Task Scheduler Library` section. 30 | 31 | ## Linux: `cron` 32 | On Linux, one option is [`cron`](https://en.wikipedia.org/wiki/Cron). 33 | For example, run `crontab -e` in your terminal to begin editing the list of tasks, 34 | then add a daily backup task by adding this line: 35 | 36 | ``` 37 | 0 0 * * * /opt/ludusavi backup --force 38 | ``` 39 | 40 | (Use the actual path to your copy of `ludusavi` instead of `/opt/ludusavi`) 41 | 42 | ## Linux: `systemd` timers 43 | On Linux, another option is [`systemd`](https://en.wikipedia.org/wiki/Systemd) timers. 44 | For example, create two files: 45 | 46 | * `~/.config/systemd/user/ludusavi-backup.service`: 47 | 48 | ``` 49 | [Unit] 50 | Description="Ludusavi backup" 51 | 52 | [Service] 53 | ExecStart=/opt/ludusavi backup --force 54 | ``` 55 | 56 | (Use the actual path to your copy of `ludusavi` instead of `/opt/ludusavi`) 57 | * `~/.config/systemd/user/ludusavi-backup.timer`: 58 | 59 | ``` 60 | [Unit] 61 | Description="Ludusavi backup timer" 62 | 63 | [Timer] 64 | OnCalendar=*-*-* 00:00:00 65 | Unit=ludusavi-backup.service 66 | 67 | [Install] 68 | WantedBy=timers.target 69 | ``` 70 | 71 | Then run `systemctl --user enable ~/.config/systemd/user/ludusavi-backup.timer` in your terminal. 72 | -------------------------------------------------------------------------------- /src/metadata.rs: -------------------------------------------------------------------------------- 1 | use crate::prelude::{get_reqwest_blocking_client, get_reqwest_client}; 2 | 3 | #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] 4 | pub struct Release { 5 | pub version: semver::Version, 6 | pub url: String, 7 | } 8 | 9 | impl Release { 10 | const URL: &'static str = "https://api.github.com/repos/mtkennerly/ludusavi/releases/latest"; 11 | 12 | pub async fn fetch() -> Result { 13 | #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] 14 | pub struct Response { 15 | pub html_url: String, 16 | pub tag_name: String, 17 | } 18 | 19 | let req = get_reqwest_client() 20 | .get(Self::URL) 21 | .header(reqwest::header::USER_AGENT, &*crate::prelude::USER_AGENT); 22 | let res = req.send().await?; 23 | 24 | match res.status() { 25 | reqwest::StatusCode::OK => { 26 | let bytes = res.bytes().await?.to_vec(); 27 | let raw = String::from_utf8(bytes)?; 28 | let parsed = serde_json::from_str::(&raw)?; 29 | 30 | Ok(Self { 31 | version: semver::Version::parse(parsed.tag_name.trim_start_matches('v'))?, 32 | url: parsed.html_url, 33 | }) 34 | } 35 | code => Err(format!("status code: {code:?}").into()), 36 | } 37 | } 38 | 39 | pub fn fetch_sync() -> Result { 40 | #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] 41 | pub struct Response { 42 | pub html_url: String, 43 | pub tag_name: String, 44 | } 45 | 46 | let req = get_reqwest_blocking_client() 47 | .get(Self::URL) 48 | .header(reqwest::header::USER_AGENT, &*crate::prelude::USER_AGENT); 49 | let res = req.send()?; 50 | 51 | match res.status() { 52 | reqwest::StatusCode::OK => { 53 | let bytes = res.bytes()?.to_vec(); 54 | let raw = String::from_utf8(bytes)?; 55 | let parsed = serde_json::from_str::(&raw)?; 56 | 57 | Ok(Self { 58 | version: semver::Version::parse(parsed.tag_name.trim_start_matches('v'))?, 59 | url: parsed.html_url, 60 | }) 61 | } 62 | code => Err(format!("status code: {code:?}").into()), 63 | } 64 | } 65 | 66 | pub fn is_update(&self) -> bool { 67 | if let Ok(current) = semver::Version::parse(*crate::prelude::VERSION) { 68 | self.version > current 69 | } else { 70 | false 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Development 2 | ### Prerequisites 3 | Use the latest version of Rust. 4 | 5 | On Linux, you'll need some additional system packages. 6 | Refer to [the installation guide](/docs/help/installation.md) for the list. 7 | 8 | ### Commands 9 | * Run program: 10 | * `cargo run` 11 | * Run tests: 12 | * One-time setup: 13 | * Windows: 14 | ``` 15 | reg import tests/ludusavi.reg 16 | cd tests/root3/game5 17 | mklink /J data-symlink data 18 | ``` 19 | * Other: 20 | ``` 21 | cd tests/root3/game5 22 | ln -s data data-symlink 23 | ``` 24 | * `cargo test` 25 | * Activate pre-commit hooks (requires Python) to handle formatting/linting: 26 | ``` 27 | pip install --user pre-commit 28 | pre-commit install 29 | ``` 30 | 31 | ### Environment variables 32 | These are optional: 33 | 34 | * `LUDUSAVI_VERSION`: 35 | * If set, shown in the window title instead of the Cargo.toml version. 36 | * Intended for CI. 37 | * `LUDUSAVI_VARIANT`: 38 | * If set, shown in the window title in parentheses. 39 | * Intended for alternative builds, such as using different Iced renderers. 40 | 41 | ### Icon 42 | The master icon is `assets/icon.kra`, which you can edit using 43 | [Krita](https://krita.org/en) and then export into the other formats. 44 | 45 | ### Release preparation 46 | Commands assume you are using [Git Bash](https://git-scm.com) on Windows. 47 | 48 | #### Dependencies (one-time) 49 | ```bash 50 | pip install invoke 51 | cargo install cargo-lichking 52 | 53 | # Verified with commit ba58a5c44ccb7d2e0ca0238d833d17de17c2b53b: 54 | curl -o /c/opt/flatpak-cargo-generator.py https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py 55 | pip install aiohttp toml 56 | ``` 57 | 58 | Also install the Crowdin CLI tool manually. 59 | 60 | #### Process 61 | * Run `invoke prerelease` 62 | * If you already updated the translations separately, 63 | then run `invoke prerelease --no-update-lang` 64 | * Update the translation percentages in `src/lang.rs` 65 | * Update the documentation if necessary for any new features. 66 | Check for any new content that needs to be uncommented (` 4 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /docs/help/roots.md: -------------------------------------------------------------------------------- 1 | # Roots 2 | Roots are folders that Ludusavi can check for additional game data. When you 3 | first run Ludusavi, it will try to find some common roots on your system, but 4 | you may end up without any configured. These are listed on the "other" screen, 5 | where you can use the plus button in the roots section to configure as many as you need, 6 | along with the root's type: 7 | 8 | * For a Steam root, this should be the folder containing the `steamapps` and 9 | `userdata` subdirectories. Here are some common/standard locations: 10 | * Windows: `C:/Program Files (x86)/Steam` 11 | * Linux: `~/.steam/steam` 12 | 13 | On Linux, for games that use Proton, Ludusavi will back up the `*.reg` files 14 | if the game is known to have registry-based saves. 15 | 16 | On Linux, if you've used Steam's "add a non-Steam game" feature, 17 | then Ludusavi will also back up any Proton save data for those games. 18 | This requires the shortcut name in Steam to match the title by which Ludusavi knows the game 19 | (i.e., the title of its PCGamingWiki article). 20 | * For a Heroic root, this should be the folder containing the `gog_store` 21 | and `GamesConfig` subdirectories. 22 | 23 | Ludusavi can find GOG, Epic, Amazon, and sideloaded game saves in Heroic's game install folders. 24 | On Linux, Ludusavi can also find saves in Heroic's Wine, Proton, and Lutris prefixes. 25 | 26 | When using Wine prefixes with Heroic, Ludusavi will back up the `*.reg` files 27 | if the game is known to have registry-based saves. 28 | * For a Legendary root, this should be the folder containing `installed.json`. 29 | Currently, Ludusavi cannot detect Wine prefixes for Legendary roots. 30 | * For a Lutris root, this should be the folder containing the `games` subdirectory. 31 | 32 | Ludusavi expects the game YAML files to contain a few fields, 33 | particularly `name` and either `game.working_dir` or `game.exe`. 34 | Games will be skipped if they don't have the necessary fields. 35 | * For the "other" root type and the remaining store-specific roots, 36 | this should be a folder whose direct children are individual games. 37 | For example, in the Epic Games store, this would be what you choose as the 38 | "install location" for your games (e.g., if you choose `D:/Epic` and it 39 | creates a subfolder for `D:/Epic/Celeste`, then the root would be `D:/Epic`). 40 | * For a home folder root, you may specify any folder. Whenever Ludusavi 41 | normally checks your standard home folder (Windows: `%USERPROFILE%`, 42 | Linux/Mac: `~`), it will additionally check this root. This is useful if 43 | you set a custom `HOME` to manipulate the location of save data. 44 | * For a Wine prefix root, this should be the folder containing `drive_c`. 45 | Currently, Ludusavi does not back up registry-based saves from the prefix, 46 | but will back up any file-based saves. 47 | * The Windows, Linux, and Mac drive roots can be used 48 | to make Ludusavi scan external hard drives with a separate OS installation. 49 | For example, let's say you had a Windows laptop that broke, 50 | but you recovered the hard drive and turned it into an external drive. 51 | You could add it as a Windows drive root to make Ludusavi scan it. 52 | 53 | In this case, Ludusavi can only look for normal/default locations of system folders. 54 | Ludusavi will not be able to use the Windows API or check `XDG` environment variables 55 | to detect alternative folder locations (e.g., if you've moved the `Documents` folder). 56 | 57 | You may use [globs] in root paths to identify multiple roots at once. 58 | If you have a folder name that contains a special glob character, 59 | you can escape it by wrapping it in brackets (e.g., `[` becomes `[[]`). 60 | 61 | The order of the configured roots is not significant. 62 | The only case where it may make a difference is if Ludusavi finds secondary manifests (`.ludusavi.yaml` files) 63 | *and* those manfiests contain overlapping entries for the same game, 64 | in which case Ludusavi will merge the data together in the order that it finds them. 65 | 66 | [globs]: https://en.wikipedia.org/wiki/Glob_(programming) 67 | -------------------------------------------------------------------------------- /src/scan/backup.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use crate::{ 4 | lang::TRANSLATOR, 5 | path::StrictPath, 6 | scan::{registry::RegistryItem, ScanChange, ScanChangeCount, ScanInfo}, 7 | }; 8 | 9 | #[derive(Clone, Debug)] 10 | pub enum BackupError { 11 | Raw(String), 12 | App(crate::prelude::Error), 13 | #[cfg(test)] 14 | Test, 15 | } 16 | 17 | impl BackupError { 18 | pub fn message(&self) -> String { 19 | match self { 20 | BackupError::Raw(error) => error.clone(), 21 | BackupError::App(error) => TRANSLATOR.handle_error(error), 22 | #[cfg(test)] 23 | BackupError::Test => "test".to_string(), 24 | } 25 | } 26 | } 27 | 28 | #[derive(Clone, Debug, Default)] 29 | pub struct BackupInfo { 30 | pub failed_files: HashMap, 31 | pub failed_registry: HashMap, 32 | } 33 | 34 | impl BackupInfo { 35 | pub fn successful(&self) -> bool { 36 | self.failed_files.is_empty() && self.failed_registry.is_empty() 37 | } 38 | 39 | pub fn total_failure(scan: &ScanInfo, error: BackupError) -> Self { 40 | let mut backup_info = Self::default(); 41 | 42 | for (scan_key, file) in &scan.found_files { 43 | if file.ignored { 44 | continue; 45 | } 46 | backup_info.failed_files.insert(scan_key.clone(), error.clone()); 47 | } 48 | for (scan_key, reg_path) in &scan.found_registry_keys { 49 | if reg_path.ignored { 50 | continue; 51 | } 52 | backup_info.failed_registry.insert(scan_key.clone(), error.clone()); 53 | } 54 | 55 | backup_info 56 | } 57 | } 58 | 59 | #[derive(Clone, Debug, Default, serde::Serialize, schemars::JsonSchema)] 60 | #[serde(rename_all = "camelCase")] 61 | pub struct OperationStatus { 62 | /// How many games were found. 63 | pub total_games: usize, 64 | /// How many bytes are used by files associated with found games. 65 | pub total_bytes: u64, 66 | /// How many games were processed. 67 | /// This excludes ignored, failed, and cancelled games. 68 | pub processed_games: usize, 69 | /// How many bytes were processed. 70 | /// This excludes ignored, failed, and cancelled games. 71 | pub processed_bytes: u64, 72 | /// Total count of `new`, `same`, and `different` games. 73 | pub changed_games: ScanChangeCount, 74 | } 75 | 76 | impl OperationStatus { 77 | pub fn add_game(&mut self, scan_info: &ScanInfo, backup_info: Option<&BackupInfo>, processed: bool) { 78 | self.total_games += 1; 79 | self.total_bytes += scan_info.total_possible_bytes(); 80 | if processed { 81 | self.processed_games += 1; 82 | self.processed_bytes += scan_info.sum_bytes(backup_info); 83 | 84 | match scan_info.overall_change() { 85 | ScanChange::New => { 86 | self.changed_games.new += 1; 87 | } 88 | ScanChange::Different => { 89 | self.changed_games.different += 1; 90 | } 91 | ScanChange::Removed => { 92 | self.changed_games.removed += 1; 93 | } 94 | ScanChange::Same => { 95 | self.changed_games.same += 1; 96 | } 97 | ScanChange::Unknown => {} 98 | } 99 | } 100 | } 101 | 102 | pub fn processed_all_games(&self) -> bool { 103 | self.total_games == self.processed_games 104 | } 105 | 106 | pub fn processed_all_bytes(&self) -> bool { 107 | self.total_bytes == self.processed_bytes 108 | } 109 | } 110 | 111 | #[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, schemars::JsonSchema)] 112 | pub enum OperationStepDecision { 113 | #[default] 114 | Processed, 115 | #[allow(unused)] 116 | Cancelled, 117 | Ignored, 118 | } 119 | 120 | #[derive(Clone, Debug, Default, Eq, PartialEq)] 121 | pub enum BackupId { 122 | #[default] 123 | Latest, 124 | Named(String), 125 | } 126 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | - push 3 | - pull_request 4 | 5 | name: Main 6 | 7 | jobs: 8 | build: 9 | strategy: 10 | matrix: 11 | include: 12 | - os: windows-latest 13 | rust-target: x86_64-pc-windows-msvc 14 | artifact-name: win64 15 | artifact-file: ludusavi.exe 16 | tar: false 17 | - os: windows-latest 18 | rust-target: i686-pc-windows-msvc 19 | artifact-name: win32 20 | artifact-file: ludusavi.exe 21 | tar: false 22 | - os: ubuntu-22.04 23 | rust-target: x86_64-unknown-linux-gnu 24 | artifact-name: linux 25 | artifact-file: ludusavi 26 | tar: true 27 | - os: macos-14 28 | rust-target: aarch64-apple-darwin 29 | artifact-name: mac 30 | artifact-file: ludusavi 31 | tar: true 32 | runs-on: ${{ matrix.os }} 33 | steps: 34 | - uses: actions/setup-python@v5 35 | with: 36 | python-version: '3.10' 37 | - uses: actions/checkout@v4 38 | with: 39 | fetch-depth: 0 40 | - uses: mtkennerly/dunamai-action@v1 41 | with: 42 | env-var: LUDUSAVI_VERSION 43 | args: --style semver 44 | - uses: dtolnay/rust-toolchain@master 45 | with: 46 | toolchain: stable-${{ matrix.rust-target }} 47 | - uses: Swatinem/rust-cache@v2 48 | with: 49 | key: ${{ matrix.os }}-${{ matrix.rust-target }} 50 | - if: ${{ startsWith(matrix.os, 'ubuntu-') }} 51 | run: sudo apt-get update && sudo apt-get install -y gcc libxcb-composite0-dev libgtk-3-dev 52 | - run: cargo build --release 53 | - if: ${{ matrix.tar }} 54 | run: | 55 | cd target/release 56 | tar --create --gzip --file=ludusavi-v${{ env.LUDUSAVI_VERSION }}-${{ matrix.artifact-name }}.tar.gz ${{ matrix.artifact-file }} 57 | - if: ${{ matrix.tar }} 58 | uses: actions/upload-artifact@v4 59 | with: 60 | name: ludusavi-v${{ env.LUDUSAVI_VERSION }}-${{ matrix.artifact-name }} 61 | path: target/release/ludusavi-v${{ env.LUDUSAVI_VERSION }}-${{ matrix.artifact-name }}.tar.gz 62 | - if: ${{ !matrix.tar }} 63 | uses: actions/upload-artifact@v4 64 | with: 65 | name: ludusavi-v${{ env.LUDUSAVI_VERSION }}-${{ matrix.artifact-name }} 66 | path: target/release/${{ matrix.artifact-file }} 67 | 68 | test: 69 | strategy: 70 | matrix: 71 | os: 72 | - windows-latest 73 | - ubuntu-latest 74 | - macos-latest 75 | runs-on: ${{ matrix.os }} 76 | steps: 77 | - uses: actions/checkout@v4 78 | - uses: dtolnay/rust-toolchain@stable 79 | - uses: Swatinem/rust-cache@v2 80 | with: 81 | key: ${{ matrix.os }} 82 | - if: ${{ matrix.os == 'ubuntu-latest' }} 83 | run: sudo apt-get update && sudo apt-get install -y gcc libxcb-composite0-dev libgtk-3-dev 84 | - if: ${{ matrix.os == 'windows-latest' }} 85 | run: reg import tests/ludusavi.reg 86 | - if: ${{ matrix.os == 'windows-latest' }} 87 | run: | 88 | cd tests/root3/game5 && cmd /c "mklink /J data-symlink data" 89 | - if: ${{ matrix.os != 'windows-latest' }} 90 | run: | 91 | cd tests/root3/game5 && ln -s data data-symlink 92 | - run: cargo test 93 | 94 | lint: 95 | strategy: 96 | matrix: 97 | os: 98 | - windows-latest 99 | - ubuntu-latest 100 | runs-on: ${{ matrix.os }} 101 | steps: 102 | - uses: actions/checkout@v4 103 | - uses: dtolnay/rust-toolchain@stable 104 | with: 105 | components: rustfmt, clippy 106 | - uses: Swatinem/rust-cache@v2 107 | with: 108 | key: ${{ matrix.os }} 109 | - if: ${{ matrix.os == 'ubuntu-latest' }} 110 | run: sudo apt-get update && sudo apt-get install -y gcc libxcb-composite0-dev libgtk-3-dev 111 | - run: cargo fmt --all -- --check 112 | - run: cargo clippy --workspace -- --deny warnings 113 | -------------------------------------------------------------------------------- /docs/help/game-launch-wrapping.md: -------------------------------------------------------------------------------- 1 | # Game launch wrapping 2 | The [CLI](/docs/help/command-line.md) has a `wrap` command that can be used as a wrapper around launching a game. 3 | When wrapped, Ludusavi will restore data for the game first, launch it, and back up after playing. 4 | If you want to use this feature, you must manually configure your game launcher app to use this command. 5 | 6 | In general, you can set your launcher to run `ludusavi wrap --name "GAME_NAME" -- GAME_INVOCATION`. 7 | Some specific launchers have built-in support (see below) to make this easier. 8 | 9 | On Linux, this feature works best with a standalone copy of Ludusavi, 10 | rather than the Flatpak version. 11 | In some cases, the Flatpak environment's constraints may keep Ludusavi from launching the game. 12 | 13 | ## Steam 14 | * Right click on a game in your Steam library and click `properties`. 15 | * In the popup window, set the launch options like so: 16 | 17 | `C:\ludusavi.exe wrap --infer steam --gui -- %command%` 18 | 19 | (Use the actual path to your copy of `ludusavi.exe` instead of `C:\ludusavi.exe`) 20 | 21 | You must do this for each game individually. 22 | 23 | ### Non-Steam shortcuts 24 | As of 2024-12-27, 25 | non-Steam games added as shortcuts in Steam won't work with the above method. 26 | Instead, you have to flip the target and launch option fields like so: 27 | 28 | * Set the `target` to `C:\ludusavi.exe` (path to your Ludusavi executable) 29 | * Set the launch options to `wrap --name "GAME NAME" --gui -- "C:\path\to\game.exe"` 30 | 31 | On the Steam Deck in game mode, 32 | using the Steam overlay to quit the game will also quit the Ludusavi wrapper, 33 | preventing the post-game prompt to back up the save data. 34 | To avoid this, you should use the game's built-in quit option instead. 35 | Currently, this has only been confirmed to happen in game mode, not desktop mode or Big Picture. 36 | 37 | ## Heroic 38 | ### Heroic 2.9.2+ (Linux example) 39 | Create a file named `ludusavi-wrap.sh` with this content: 40 | 41 | ``` 42 | $!/bin/sh 43 | ludusavi --config $HOME/.config/ludusavi wrap --gui --infer heroic -- "$@" 44 | ``` 45 | 46 | Mark the file as executable and set it as a wrapper within Heroic. 47 | You must set it as a wrapper for each game already installed individually. 48 | 49 | Note that the `--config` option is required because Heroic overrides the `XDG_CONFIG_HOME` environment variable, 50 | which would otherwise prevent Ludusavi from finding its configuration. 51 | 52 | ## Playnite 53 | For Playnite, you should use the [official plugin](https://github.com/mtkennerly/ludusavi-playnite), 54 | which provides deeper integration between Playnite and Ludusavi. 55 | 56 | That being said, you *can* set up a wrapper script instead if you prefer. 57 | You have to configure two scripts: 58 | one when the game starts, and one when the game stops. 59 | 60 | In Playnite, navigate to settings -> scripts -> game scripts, 61 | and configure the following: 62 | 63 | * Execute before starting a game 64 | (if you want Ludusavi to restore your latest backup): 65 | ``` 66 | C:\ludusavi.exe restore --force "$game" 67 | ``` 68 | * Execute after exiting a game 69 | (if you want Ludusavi to make a new backup): 70 | ``` 71 | C:\ludusavi.exe backup --force "$game" 72 | ``` 73 | 74 | (Use the actual path to your copy of `ludusavi.exe` instead of `C:\ludusavi.exe`) 75 | 76 | ## Lutris 77 | Ludusavi can be configured globally for all games in [Lutris](https://lutris.net/). 78 | Note these instructions are for Lutris and ludusavi which are installed directly on the system, not the Flatpak versions. 79 | In Lutris, open the `Global options` tab in the `Preferences` menu. 80 | Enable `Advanced` in the top-right corner. 81 | Scroll down to the `Game execution` section. 82 | In the `Command prefix` entry, enter `ludusavi wrap --infer lutris --gui --`. 83 | If `ludusavi` isn't available on the `PATH`, be sure to fully qualify the path to the `ludusavi` executable. 84 | Finally, hit `Save`. 85 | Ludusavi will now run for every game. 86 | 87 | If you'd rather just set this directly in a Lutris config file, add the following to `~/.local/share/lutris/system.yml`. 88 | 89 | ```yaml 90 | system: 91 | prefix_command: 'ludusavi wrap --infer lutris --gui --' 92 | ``` 93 | -------------------------------------------------------------------------------- /src/scan/launchers/legendary.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{HashMap, HashSet}; 2 | 3 | use crate::{ 4 | prelude::{StrictPath, ENV_DEBUG}, 5 | resource::{config::root, manifest::Os}, 6 | scan::{launchers::LauncherGame, TitleFinder}, 7 | }; 8 | 9 | pub mod installed { 10 | use std::collections::HashMap; 11 | 12 | pub const PATH: &str = "installed.json"; 13 | 14 | #[derive(serde::Deserialize)] 15 | pub struct Data(pub HashMap); 16 | 17 | #[derive(Clone, serde::Deserialize)] 18 | pub struct Game { 19 | /// This is an opaque ID, not the human-readable title. 20 | pub app_name: String, 21 | pub title: String, 22 | pub platform: String, 23 | pub install_path: String, 24 | } 25 | } 26 | 27 | pub fn scan(root: &root::Legendary, title_finder: &TitleFinder) -> HashMap> { 28 | let mut out = HashMap::>::new(); 29 | 30 | for game in get_games(&root.path) { 31 | let Some(official_title) = title_finder.find_one_by_normalized_name(&game.title) else { 32 | log::trace!("Ignoring unrecognized game: {}", &game.title); 33 | if std::env::var(ENV_DEBUG).is_ok() { 34 | eprintln!( 35 | "Ignoring unrecognized game from Legendary: {} (app = {})", 36 | &game.title, &game.app_name 37 | ); 38 | } 39 | continue; 40 | }; 41 | 42 | log::trace!( 43 | "Detected game: {} | app: {}, raw title: {}", 44 | &official_title, 45 | &game.app_name, 46 | &game.title 47 | ); 48 | out.entry(official_title).or_default().insert(LauncherGame { 49 | install_dir: Some(StrictPath::new(game.install_path)), 50 | prefix: None, 51 | platform: Some(Os::from(game.platform.as_str())), 52 | }); 53 | } 54 | 55 | out 56 | } 57 | 58 | pub fn get_games(source: &StrictPath) -> Vec { 59 | let mut out = vec![]; 60 | 61 | let library = source.joined(installed::PATH); 62 | 63 | let content = match library.try_read() { 64 | Ok(content) => content, 65 | Err(e) => { 66 | log::debug!( 67 | "In Legendary source '{:?}', unable to read installed.json | {:?}", 68 | &library, 69 | e, 70 | ); 71 | return out; 72 | } 73 | }; 74 | 75 | if let Ok(installed_games) = serde_json::from_str::(&content) { 76 | out.extend(installed_games.0.into_values()); 77 | } 78 | 79 | out 80 | } 81 | 82 | #[cfg(test)] 83 | mod tests { 84 | use pretty_assertions::assert_eq; 85 | use velcro::{hash_map, hash_set}; 86 | 87 | use super::*; 88 | use crate::{ 89 | resource::{manifest::Manifest, ResourceFile}, 90 | testing::repo, 91 | }; 92 | 93 | fn manifest() -> Manifest { 94 | Manifest::load_from_string( 95 | r#" 96 | game-1: 97 | files: 98 | /file1.txt: {} 99 | "#, 100 | ) 101 | .unwrap() 102 | } 103 | 104 | fn title_finder() -> TitleFinder { 105 | TitleFinder::new(&Default::default(), &manifest(), Default::default()) 106 | } 107 | 108 | #[test] 109 | fn scan_finds_nothing_when_folder_does_not_exist() { 110 | let root = root::Legendary { 111 | path: format!("{}/tests/nonexistent", repo()).into(), 112 | }; 113 | let games = scan(&root, &title_finder()); 114 | assert_eq!(HashMap::new(), games); 115 | } 116 | 117 | #[test] 118 | fn scan_finds_all_games() { 119 | let root = root::Legendary { 120 | path: format!("{}/tests/launchers/legendary", repo()).into(), 121 | }; 122 | let games = scan(&root, &title_finder()); 123 | assert_eq!( 124 | hash_map! { 125 | "game-1".to_string(): hash_set![LauncherGame { 126 | install_dir: Some(StrictPath::new("/games/game-1".to_string())), 127 | prefix: None, 128 | platform: Some(Os::Windows), 129 | }], 130 | }, 131 | games, 132 | ); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/scan/launchers/heroic/nile.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{HashMap, HashSet}; 2 | 3 | use crate::{ 4 | prelude::{StrictPath, ENV_DEBUG}, 5 | resource::{config::root, manifest::Os}, 6 | scan::{ 7 | launchers::{heroic::find_prefix, LauncherGame}, 8 | TitleFinder, 9 | }, 10 | }; 11 | 12 | pub mod library { 13 | use super::*; 14 | 15 | pub const PATH: &str = "store_cache/nile_library.json"; 16 | 17 | #[derive(serde::Deserialize)] 18 | pub struct Data { 19 | pub library: Vec, 20 | } 21 | 22 | #[derive(serde::Deserialize)] 23 | pub struct Game { 24 | pub app_name: String, 25 | pub title: String, 26 | pub install: Install, 27 | } 28 | 29 | #[derive(serde::Deserialize)] 30 | pub struct Install { 31 | pub install_path: Option, 32 | pub platform: Option, 33 | } 34 | } 35 | 36 | pub fn scan(root: &root::Heroic, title_finder: &TitleFinder) -> HashMap> { 37 | let mut out = HashMap::>::new(); 38 | 39 | for (app_id, game) in get_library(&root.path) { 40 | let raw_title = &game.title; 41 | 42 | let Some(official_title) = title_finder.find_one_by_normalized_name(raw_title) else { 43 | log::trace!("Ignoring unrecognized game: {}", raw_title); 44 | if std::env::var(ENV_DEBUG).is_ok() { 45 | eprintln!( 46 | "Ignoring unrecognized game from Heroic/Nile: {} (app = {})", 47 | raw_title, &app_id 48 | ); 49 | } 50 | continue; 51 | }; 52 | 53 | log::trace!( 54 | "Detected game: {} | app: {}, raw title: {}", 55 | &official_title, 56 | &app_id, 57 | raw_title 58 | ); 59 | let platform = game.install.platform.as_deref(); 60 | let prefix = find_prefix(&root.path, &game.title, platform, &game.app_name); 61 | out.entry(official_title).or_default().insert(LauncherGame { 62 | install_dir: game.install.install_path, 63 | prefix, 64 | platform: platform.map(Os::from), 65 | }); 66 | } 67 | 68 | out 69 | } 70 | 71 | pub fn get_library(source: &StrictPath) -> HashMap { 72 | let mut out = HashMap::new(); 73 | 74 | let file = source.joined(library::PATH); 75 | 76 | let content = match file.try_read() { 77 | Ok(content) => content, 78 | Err(e) => { 79 | log::debug!("In Nile source '{:?}', unable to read library | {:?}", &file, e); 80 | return out; 81 | } 82 | }; 83 | 84 | if let Ok(data) = serde_json::from_str::(&content) { 85 | for game in data.library { 86 | out.insert(game.app_name.clone(), game); 87 | } 88 | } 89 | 90 | out 91 | } 92 | 93 | #[cfg(test)] 94 | mod tests { 95 | use pretty_assertions::assert_eq; 96 | use velcro::{hash_map, hash_set}; 97 | 98 | use super::*; 99 | use crate::{ 100 | resource::{ 101 | manifest::{Manifest, Os}, 102 | ResourceFile, 103 | }, 104 | testing::repo, 105 | }; 106 | 107 | fn manifest() -> Manifest { 108 | Manifest::load_from_string( 109 | r#" 110 | game-1: 111 | files: 112 | /file1.txt: {} 113 | "#, 114 | ) 115 | .unwrap() 116 | } 117 | 118 | fn title_finder() -> TitleFinder { 119 | TitleFinder::new(&Default::default(), &manifest(), Default::default()) 120 | } 121 | 122 | #[test] 123 | fn scan_finds_all_games() { 124 | let root = root::Heroic { 125 | path: format!("{}/tests/launchers/heroic-nile", repo()).into(), 126 | }; 127 | let games = scan(&root, &title_finder()); 128 | assert_eq!( 129 | hash_map! { 130 | "game-1".to_string(): hash_set![LauncherGame { 131 | install_dir: Some(StrictPath::new("/games/game-1".to_string())), 132 | prefix: Some(StrictPath::new("/prefixes/game-1".to_string())), 133 | platform: Some(Os::Windows), 134 | }], 135 | }, 136 | games, 137 | ); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/scan/launchers/heroic/sideload.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{HashMap, HashSet}; 2 | 3 | use crate::{ 4 | prelude::{StrictPath, ENV_DEBUG}, 5 | resource::{config::root, manifest::Os}, 6 | scan::{ 7 | launchers::{heroic::find_prefix, LauncherGame}, 8 | TitleFinder, 9 | }, 10 | }; 11 | 12 | pub mod library { 13 | use super::*; 14 | 15 | pub const PATH: &str = "sideload_apps/library.json"; 16 | 17 | #[derive(serde::Deserialize)] 18 | pub struct Data { 19 | pub games: Vec, 20 | } 21 | 22 | #[derive(serde::Deserialize)] 23 | pub struct Game { 24 | pub app_name: String, 25 | pub title: String, 26 | pub install: Install, 27 | pub folder_name: Option, 28 | } 29 | 30 | #[derive(serde::Deserialize)] 31 | pub struct Install { 32 | pub platform: Option, 33 | } 34 | } 35 | 36 | pub fn scan(root: &root::Heroic, title_finder: &TitleFinder) -> HashMap> { 37 | let mut out = HashMap::>::new(); 38 | 39 | for (app_id, game) in get_library(&root.path) { 40 | let raw_title = &game.title; 41 | 42 | let Some(official_title) = title_finder.find_one_by_normalized_name(raw_title) else { 43 | log::trace!("Ignoring unrecognized game: {}", raw_title); 44 | if std::env::var(ENV_DEBUG).is_ok() { 45 | eprintln!( 46 | "Ignoring unrecognized game from Heroic/sideload: {} (app = {})", 47 | raw_title, &app_id 48 | ); 49 | } 50 | continue; 51 | }; 52 | 53 | log::trace!( 54 | "Detected game: {} | app: {}, raw title: {}", 55 | &official_title, 56 | &app_id, 57 | raw_title 58 | ); 59 | let platform = game.install.platform.as_deref(); 60 | let prefix = find_prefix(&root.path, &game.title, platform, &game.app_name); 61 | out.entry(official_title).or_default().insert(LauncherGame { 62 | install_dir: game.folder_name, 63 | prefix, 64 | platform: platform.map(Os::from), 65 | }); 66 | } 67 | 68 | out 69 | } 70 | 71 | pub fn get_library(source: &StrictPath) -> HashMap { 72 | let mut out = HashMap::new(); 73 | 74 | let file = source.joined(library::PATH); 75 | 76 | let content = match file.try_read() { 77 | Ok(content) => content, 78 | Err(e) => { 79 | log::debug!("In sideload source '{:?}', unable to read library | {:?}", &file, e); 80 | return out; 81 | } 82 | }; 83 | 84 | if let Ok(data) = serde_json::from_str::(&content) { 85 | for game in data.games { 86 | out.insert(game.app_name.clone(), game); 87 | } 88 | } 89 | 90 | out 91 | } 92 | 93 | #[cfg(test)] 94 | mod tests { 95 | use pretty_assertions::assert_eq; 96 | use velcro::{hash_map, hash_set}; 97 | 98 | use super::*; 99 | use crate::{ 100 | resource::{ 101 | manifest::{Manifest, Os}, 102 | ResourceFile, 103 | }, 104 | testing::repo, 105 | }; 106 | 107 | fn manifest() -> Manifest { 108 | Manifest::load_from_string( 109 | r#" 110 | game-1: 111 | files: 112 | /file1.txt: {} 113 | "#, 114 | ) 115 | .unwrap() 116 | } 117 | 118 | fn title_finder() -> TitleFinder { 119 | TitleFinder::new(&Default::default(), &manifest(), Default::default()) 120 | } 121 | 122 | #[test] 123 | fn scan_finds_all_games() { 124 | let root = root::Heroic { 125 | path: format!("{}/tests/launchers/heroic-sideload", repo()).into(), 126 | }; 127 | let games = scan(&root, &title_finder()); 128 | assert_eq!( 129 | hash_map! { 130 | "game-1".to_string(): hash_set![LauncherGame { 131 | install_dir: Some(StrictPath::new("/games/game-1".to_string())), 132 | prefix: Some(StrictPath::new("/prefixes/game-1".to_string())), 133 | platform: Some(Os::Windows), 134 | }], 135 | }, 136 | games, 137 | ); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ludusavi" 3 | version = "0.30.0" 4 | authors = ["mtkennerly "] 5 | edition = "2021" 6 | description = "Game save backup tool" 7 | repository = "https://github.com/mtkennerly/ludusavi" 8 | readme = "README.md" 9 | license = "MIT" 10 | 11 | [[bin]] 12 | name = "ludusavi" 13 | required-features = ["app"] 14 | 15 | [features] 16 | default = ["app"] 17 | # GUI and CLI 18 | app = [ 19 | "dep:clap", 20 | "dep:clap_complete", 21 | "dep:dialoguer", 22 | "dep:flexi_logger", 23 | "dep:iced", 24 | "dep:image", 25 | "dep:indicatif", 26 | "dep:rfd", 27 | "dep:signal-hook", 28 | ] 29 | # Disable certificate and hostname validation when performing downloads. 30 | unsafe-download = [] 31 | 32 | [dependencies] 33 | base64 = "0.22.1" 34 | byte-unit = "5.1.4" 35 | chrono = { version = "0.4.38", features = ["serde"] } 36 | clap = { version = "4.5.17", features = ["derive", "wrap_help"], optional = true } 37 | clap_complete = { version = "4.5.28", optional = true } 38 | dialoguer = { version = "0.11.0", optional = true } 39 | dirs = "5.0.1" 40 | filetime = "0.2.25" 41 | flexi_logger = { version = "0.29.3", features = ["textfilter"], default-features = false, optional = true } 42 | fluent = "0.16.1" 43 | fuzzy-matcher = "0.3.7" 44 | globetter = "0.2.0" 45 | globset = "0.4.15" 46 | iced = { version = "0.14.0", features = ["advanced", "advanced-shaping", "crisp", "image", "lazy", "svg", "tiny-skia", "tokio", "wayland", "wgpu", "x11"], default-features = false, optional = true } 47 | image = { version = "0.25.2", features = ["ico"], default-features = false, optional = true } 48 | indicatif = { version = "0.17.8", features = ["rayon"], optional = true } 49 | intl-memoizer = "0.5.2" 50 | itertools = "0.13.0" 51 | log = "0.4.22" 52 | opener = "0.7.2" 53 | rayon = "1.10.0" 54 | regashii = "0.2.0" 55 | regex = "1.10.6" 56 | reqwest = { version = "0.12.7", features = ["blocking", "gzip", "rustls-tls"], default-features = false } 57 | rfd = { version = "0.15.0", features = ["common-controls-v6", "gtk3"], default-features = false, optional = true } 58 | rusqlite = { version = "0.32.1", features = ["bundled"] } 59 | schemars = { version = "0.8.21", features = ["chrono"] } 60 | semver = { version = "1.0.23", features = ["serde"] } 61 | serde = { version = "1.0.210", features = ["derive"] } 62 | serde_json = "1.0.128" 63 | serde_yaml = "0.8.25" 64 | sha1 = "0.10.6" 65 | shlex = "1.3.0" 66 | signal-hook = { version = "0.3.17", optional = true } 67 | steamlocate = "2.0.0" 68 | strsim = "0.11.1" 69 | sysinfo = "0.36.0" 70 | tokio = { version = "1.40.0", features = ["macros", "time"] } 71 | typed-path = "0.9.2" 72 | unic-langid = "0.9.5" 73 | walkdir = "2.5.0" 74 | which = "6.0.3" 75 | whoami = "1.5.2" 76 | zip = "0.6.6" 77 | 78 | [target.'cfg(windows)'.dependencies] 79 | known-folders = "1.2.0" 80 | winreg = "0.52.0" 81 | windows = { version = "0.60.0", features = ["Win32_System_Console", "Win32_System_Threading", "Win32_Storage_FileSystem", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } 82 | 83 | [target.'cfg(windows)'.build-dependencies] 84 | embed-resource = "3.0.6" 85 | 86 | [dev-dependencies] 87 | pretty_assertions = "1.4.1" 88 | velcro = "0.5.4" 89 | 90 | [profile.dev] 91 | opt-level = 1 92 | 93 | [profile.dev.package."*"] 94 | opt-level = 3 95 | 96 | [profile.release] 97 | lto = "thin" 98 | strip = true 99 | 100 | [package.metadata.binstall] 101 | bin-dir = "{ bin }{ binary-ext }" 102 | pkg-fmt = "zip" 103 | 104 | [package.metadata.binstall.overrides.x86_64-pc-windows-msvc] 105 | pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-win64{ archive-suffix }" 106 | 107 | [package.metadata.binstall.overrides.i686-pc-windows-msvc] 108 | pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-win32{ archive-suffix }" 109 | 110 | [package.metadata.binstall.overrides.x86_64-unknown-linux-gnu] 111 | pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-linux{ archive-suffix }" 112 | pkg-fmt = "tgz" 113 | 114 | [package.metadata.binstall.overrides.x86_64-apple-darwin] 115 | pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-mac{ archive-suffix }" 116 | pkg-fmt = "tgz" 117 | 118 | [lints.rust] 119 | mismatched_lifetime_syntaxes = "allow" 120 | 121 | [lints.clippy] 122 | large_enum_variant = "allow" 123 | new_without_default = "allow" 124 | to_string_trait_impl = "allow" 125 | too_many_arguments = "allow" 126 | -------------------------------------------------------------------------------- /src/scan/game_filter.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | lang::TRANSLATOR, 3 | resource::manifest, 4 | scan::{Duplication, ScanInfo}, 5 | }; 6 | 7 | use super::ScanChange; 8 | 9 | #[derive(Clone, Debug)] 10 | pub enum Event { 11 | Toggled, 12 | ToggledFilter { filter: FilterKind, enabled: bool }, 13 | EditedGameName(String), 14 | Reset, 15 | EditedFilterUniqueness(Uniqueness), 16 | EditedFilterCompleteness(Completeness), 17 | EditedFilterEnablement(Enablement), 18 | EditedFilterChange(Change), 19 | EditedFilterManifest(Manifest), 20 | } 21 | 22 | #[derive(Clone, Copy, Debug)] 23 | pub enum FilterKind { 24 | Uniqueness, 25 | Completeness, 26 | Enablement, 27 | Change, 28 | Manifest, 29 | } 30 | 31 | #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] 32 | pub enum Uniqueness { 33 | Unique, 34 | #[default] 35 | Duplicate, 36 | } 37 | 38 | impl Uniqueness { 39 | pub const ALL: &'static [Self] = &[Self::Unique, Self::Duplicate]; 40 | 41 | pub fn qualifies(&self, duplicated: Duplication) -> bool { 42 | match self { 43 | Self::Unique => duplicated.unique(), 44 | Self::Duplicate => !duplicated.unique(), 45 | } 46 | } 47 | } 48 | 49 | impl ToString for Uniqueness { 50 | fn to_string(&self) -> String { 51 | TRANSLATOR.filter_uniqueness(*self) 52 | } 53 | } 54 | 55 | #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] 56 | pub enum Completeness { 57 | Complete, 58 | #[default] 59 | Partial, 60 | } 61 | 62 | impl Completeness { 63 | pub const ALL: &'static [Self] = &[Self::Complete, Self::Partial]; 64 | 65 | pub fn qualifies(&self, scan: &ScanInfo) -> bool { 66 | match self { 67 | Self::Complete => !scan.any_ignored(), 68 | Self::Partial => scan.any_ignored(), 69 | } 70 | } 71 | } 72 | 73 | impl ToString for Completeness { 74 | fn to_string(&self) -> String { 75 | TRANSLATOR.filter_completeness(*self) 76 | } 77 | } 78 | 79 | #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] 80 | pub enum Enablement { 81 | Enabled, 82 | #[default] 83 | Disabled, 84 | } 85 | 86 | impl Enablement { 87 | pub const ALL: &'static [Self] = &[Self::Enabled, Self::Disabled]; 88 | 89 | pub fn qualifies(&self, enabled: bool) -> bool { 90 | match self { 91 | Self::Enabled => enabled, 92 | Self::Disabled => !enabled, 93 | } 94 | } 95 | } 96 | 97 | impl ToString for Enablement { 98 | fn to_string(&self) -> String { 99 | TRANSLATOR.filter_enablement(*self) 100 | } 101 | } 102 | 103 | #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] 104 | pub enum Change { 105 | New, 106 | Updated, 107 | Unchanged, 108 | #[default] 109 | Unscanned, 110 | } 111 | 112 | impl ToString for Change { 113 | fn to_string(&self) -> String { 114 | TRANSLATOR.filter_freshness(*self) 115 | } 116 | } 117 | 118 | impl Change { 119 | pub const ALL: &'static [Self] = &[Self::New, Self::Updated, Self::Unchanged, Self::Unscanned]; 120 | 121 | pub fn qualifies(&self, scan: &ScanInfo) -> bool { 122 | match self { 123 | Change::New => scan.overall_change() == ScanChange::New, 124 | Change::Updated => scan.overall_change() == ScanChange::Different, 125 | Change::Unchanged => scan.overall_change() == ScanChange::Same, 126 | Change::Unscanned => scan.overall_change() == ScanChange::Unknown, 127 | } 128 | } 129 | } 130 | 131 | #[derive(Clone, Debug, Default, Eq, PartialEq)] 132 | pub struct Manifest { 133 | source: manifest::Source, 134 | } 135 | 136 | impl Manifest { 137 | pub fn new(source: manifest::Source) -> Self { 138 | Self { source } 139 | } 140 | } 141 | 142 | impl ToString for Manifest { 143 | fn to_string(&self) -> String { 144 | match &self.source { 145 | manifest::Source::Primary => TRANSLATOR.primary_manifest_label(), 146 | manifest::Source::Custom => TRANSLATOR.custom_games_label(), 147 | manifest::Source::Secondary(id) => id.to_string(), 148 | } 149 | } 150 | } 151 | 152 | impl Manifest { 153 | pub fn qualifies(&self, game: Option<&manifest::Game>, customized: bool) -> bool { 154 | game.map(|game| game.sources.contains(&self.source)).unwrap_or_default() 155 | || (self.source == manifest::Source::Custom && customized) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/scan/launchers/heroic.rs: -------------------------------------------------------------------------------- 1 | pub mod gog; 2 | pub mod legendary; 3 | pub mod nile; 4 | pub mod sideload; 5 | 6 | use std::collections::{HashMap, HashSet}; 7 | 8 | use crate::prelude::StrictPath; 9 | 10 | use crate::{ 11 | resource::config::root, 12 | scan::{launchers::LauncherGame, TitleFinder}, 13 | }; 14 | 15 | mod games_config { 16 | use std::collections::HashMap; 17 | 18 | pub fn path(id: &str) -> String { 19 | format!("GamesConfig/{id}.json") 20 | } 21 | 22 | #[derive(serde::Deserialize, Debug)] 23 | pub struct Data(pub HashMap); 24 | 25 | #[derive(serde::Deserialize, Debug)] 26 | #[serde(untagged)] 27 | pub enum Game { 28 | #[serde(rename_all = "camelCase")] 29 | Config { 30 | wine_prefix: String, 31 | wine_version: Wine, 32 | }, 33 | IgnoreOther(serde::de::IgnoredAny), 34 | } 35 | 36 | #[derive(serde::Deserialize, Debug)] 37 | pub struct Wine { 38 | #[serde(rename = "type")] 39 | pub wine_type: String, 40 | } 41 | } 42 | 43 | pub fn scan( 44 | root: &root::Heroic, 45 | title_finder: &TitleFinder, 46 | legendary: Option<&StrictPath>, 47 | ) -> HashMap> { 48 | let mut games = HashMap::>::new(); 49 | 50 | for (title, info) in legendary::scan(root, title_finder, legendary) { 51 | games.entry(title).or_default().extend(info); 52 | } 53 | 54 | for (title, info) in gog::scan(root, title_finder) { 55 | games.entry(title).or_default().extend(info); 56 | } 57 | 58 | for (title, info) in nile::scan(root, title_finder) { 59 | games.entry(title).or_default().extend(info); 60 | } 61 | 62 | for (title, info) in sideload::scan(root, title_finder) { 63 | games.entry(title).or_default().extend(info); 64 | } 65 | 66 | games 67 | } 68 | 69 | fn find_prefix( 70 | heroic_path: &StrictPath, 71 | game_name: &str, 72 | platform: Option<&str>, 73 | app_name: &str, 74 | ) -> Option { 75 | log::trace!( 76 | "Will try to find prefix for Heroic game: {} (app={}, platform={:?})", 77 | game_name, 78 | app_name, 79 | platform 80 | ); 81 | 82 | let games_config_path = heroic_path.joined(games_config::path(app_name)); 83 | 84 | let content = match games_config_path.try_read() { 85 | Ok(content) => content, 86 | Err(e) => { 87 | log::trace!("Failed to read {:?}: {}", &games_config_path, e); 88 | return None; 89 | } 90 | }; 91 | 92 | match serde_json::from_str::(&content) { 93 | Ok(games_config_wrapper) => { 94 | let game_config = games_config_wrapper.0.get(app_name)?; 95 | 96 | match game_config { 97 | games_config::Game::Config { 98 | wine_version, 99 | wine_prefix, 100 | } => match wine_version.wine_type.as_str() { 101 | "wine" => { 102 | log::trace!( 103 | "Found Heroic Wine prefix for {} ({}) -> adding {}", 104 | game_name, 105 | app_name, 106 | wine_prefix 107 | ); 108 | Some(StrictPath::new(wine_prefix.clone())) 109 | } 110 | 111 | "proton" => { 112 | let prefix = format!("{wine_prefix}/pfx"); 113 | log::trace!( 114 | "Found Heroic Proton prefix for {} ({}), adding {}", 115 | game_name, 116 | app_name, 117 | &prefix 118 | ); 119 | Some(StrictPath::new(prefix)) 120 | } 121 | 122 | _ => { 123 | log::info!( 124 | "Found Heroic Windows game {} ({}) with unknown wine_type: {:#?}", 125 | game_name, 126 | app_name, 127 | wine_version.wine_type 128 | ); 129 | None 130 | } 131 | }, 132 | games_config::Game::IgnoreOther(_) => None, 133 | } 134 | } 135 | Err(e) => { 136 | log::trace!("Failed to parse {:?}: {}", &games_config_path, e); 137 | None 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /assets/flatpak/com.github.mtkennerly.ludusavi.metainfo.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | com.github.mtkennerly.ludusavi 4 | CC0-1.0 5 | MIT 6 | Ludusavi 7 | mtkennerly 8 | Back up your PC video game save data 9 | https://github.com/mtkennerly/ludusavi/ 10 | https://github.com/mtkennerly/ludusavi/issues 11 | https://crowdin.com/project/ludusavi 12 | 13 |

Ludusavi is a tool for backing up your PC video game save data, written in Rust. It is cross-platform and supports multiple game stores.

14 |

Features:

15 |
    16 |
  • Ability to back up data from more than 19,000 games plus your own custom entries.
  • 17 |
  • Backup and restore for Steam as well as other game libraries.
  • 18 |
  • Preview of the backup/restore before actually performing it.
  • 19 |
  • Both a graphical interface and command line interface for scripting. (NOTE: CLI is named "com.github.mtkennerly.ludusavi" for Flatpak)
  • 20 |
  • Tab completion is available for Bash, Fish, Zsh, PowerShell, and Elvish.
  • 21 |
  • Support for saves that are stored as files and in the Windows registry, Proton saves with Steam, and Steam screenshots.
  • 22 |
23 |
24 | 25 | Utility 26 | Archiving 27 | Compression 28 | 29 | 30 | archive 31 | backup 32 | backups 33 | cloud 34 | saves 35 | 36 | game 37 | games 38 | gaming 39 | videogame 40 | videogames 41 | 42 | CLI 43 | GUI 44 | zip 45 | 46 | Epic 47 | GOG 48 | Heroic 49 | Lutris 50 | Proton 51 | Steam 52 | 53 | 54 | 55 | https://raw.githubusercontent.com/mtkennerly/ludusavi/v0.30.0/docs/sample-gui-linux.png 56 | Graphical user interface 57 | 58 | 59 | 60 | com.github.mtkennerly.ludusavi.desktop 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 |
96 | -------------------------------------------------------------------------------- /assets/linux/com.mtkennerly.ludusavi.metainfo.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | com.mtkennerly.ludusavi 4 | CC0-1.0 5 | MIT 6 | Ludusavi 7 | mtkennerly 8 | Back up your PC video game save data 9 | https://github.com/mtkennerly/ludusavi/ 10 | https://github.com/mtkennerly/ludusavi/issues 11 | https://crowdin.com/project/ludusavi 12 | 13 |

Ludusavi is a tool for backing up your PC video game save data, written in Rust. It is cross-platform and supports multiple game stores.

14 |

Features:

15 |
    16 |
  • Ability to back up data from more than 19,000 games plus your own custom entries.
  • 17 |
  • Backup and restore for Steam as well as other game libraries.
  • 18 |
  • Preview of the backup/restore before actually performing it.
  • 19 |
  • Both a graphical interface and command line interface for scripting.
  • 20 |
  • Tab completion is available for Bash, Fish, Zsh, PowerShell, and Elvish.
  • 21 |
  • Support for saves that are stored as files and in the Windows registry, Proton saves with Steam, and Steam screenshots.
  • 22 |
23 |
24 | 25 | com.github.mtkennerly.ludusavi 26 | 27 | 28 | com.github.mtkennerly.ludusavi 29 | 30 | 31 | Utility 32 | Archiving 33 | Compression 34 | 35 | 36 | archive 37 | backup 38 | backups 39 | cloud 40 | saves 41 | 42 | game 43 | games 44 | gaming 45 | videogame 46 | videogames 47 | 48 | CLI 49 | GUI 50 | zip 51 | 52 | Epic 53 | GOG 54 | Heroic 55 | Lutris 56 | Proton 57 | Steam 58 | 59 | 60 | 61 | https://raw.githubusercontent.com/mtkennerly/ludusavi/v0.30.0/docs/sample-gui-linux.png 62 | Graphical user interface 63 | 64 | 65 | 66 | com.mtkennerly.ludusavi.desktop 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 |
102 | -------------------------------------------------------------------------------- /src/resource/cache.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{BTreeMap, BTreeSet}; 2 | 3 | use crate::{ 4 | lang::Language, 5 | prelude::{app_dir, CANONICAL_VERSION}, 6 | resource::{ 7 | config::{self, Config, Root}, 8 | manifest::ManifestUpdate, 9 | ResourceFile, SaveableResourceFile, 10 | }, 11 | }; 12 | 13 | #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 14 | #[serde(default)] 15 | pub struct Cache { 16 | pub version: Option<(u32, u32, u32)>, 17 | pub release: Release, 18 | pub migrations: Migrations, 19 | pub manifests: Manifests, 20 | pub roots: BTreeSet, 21 | pub backup: Backup, 22 | pub restore: Restore, 23 | } 24 | 25 | #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 26 | #[serde(default)] 27 | pub struct Release { 28 | pub checked: chrono::DateTime, 29 | pub latest: Option, 30 | } 31 | 32 | #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 33 | #[serde(default)] 34 | pub struct Migrations { 35 | pub adopted_cache: bool, 36 | pub fixed_spanish_config: bool, 37 | pub set_default_manifest_url_to_null: bool, 38 | } 39 | 40 | pub type Manifests = BTreeMap; 41 | 42 | #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 43 | #[serde(default)] 44 | pub struct Manifest { 45 | pub etag: Option, 46 | pub checked: Option>, 47 | pub updated: Option>, 48 | } 49 | 50 | #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 51 | #[serde(default)] 52 | pub struct Backup { 53 | pub recent_games: BTreeSet, 54 | } 55 | 56 | #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 57 | #[serde(default)] 58 | pub struct Restore { 59 | pub recent_games: BTreeSet, 60 | } 61 | 62 | impl ResourceFile for Cache { 63 | const FILE_NAME: &'static str = "cache.yaml"; 64 | } 65 | 66 | impl SaveableResourceFile for Cache {} 67 | 68 | impl Cache { 69 | pub fn migrate_config(mut self, config: &mut Config) -> Self { 70 | let mut updated = false; 71 | 72 | if !self.migrations.adopted_cache { 73 | let _ = app_dir().joined(".flag_migrated_legacy_config").remove(); 74 | self.migrations.adopted_cache = true; 75 | updated = true; 76 | } 77 | 78 | if !self.migrations.fixed_spanish_config && self.version.is_none() { 79 | if config.language == Language::Russian { 80 | config.language = Language::Spanish; 81 | } 82 | self.migrations.fixed_spanish_config = true; 83 | updated = true; 84 | } 85 | 86 | if !self.migrations.set_default_manifest_url_to_null { 87 | if config 88 | .manifest 89 | .url 90 | .as_ref() 91 | .is_some_and(|url| url == config::MANIFEST_URL) 92 | { 93 | config.manifest.url = None; 94 | } 95 | self.migrations.set_default_manifest_url_to_null = true; 96 | updated = true; 97 | } 98 | 99 | if self.roots.is_empty() && !config.roots.is_empty() { 100 | self.add_roots(&config.roots); 101 | updated = true; 102 | } 103 | 104 | if self.version != Some(*CANONICAL_VERSION) { 105 | self.version = Some(*CANONICAL_VERSION); 106 | updated = true; 107 | } 108 | 109 | if updated { 110 | self.save(); 111 | config.save(); 112 | } 113 | 114 | self 115 | } 116 | 117 | pub fn update_manifest(&mut self, update: ManifestUpdate) { 118 | let cached = self.manifests.entry(update.url).or_default(); 119 | cached.etag = update.etag; 120 | cached.checked = Some(update.timestamp); 121 | if update.modified { 122 | cached.updated = Some(update.timestamp); 123 | } 124 | } 125 | 126 | pub fn add_roots(&mut self, roots: &Vec) { 127 | for root in roots { 128 | if !self.has_root(root) { 129 | self.roots.insert(root.clone()); 130 | } 131 | } 132 | } 133 | 134 | pub fn has_root(&self, candidate: &Root) -> bool { 135 | self.roots.iter().any(|root| { 136 | let primary = root.path().equivalent(candidate.path()) && root.store() == candidate.store(); 137 | match (root, candidate) { 138 | (Root::Lutris(root), Root::Lutris(candidate)) => { 139 | primary && (root.database.is_some() || candidate.database.is_none()) 140 | } 141 | _ => primary, 142 | } 143 | }) 144 | } 145 | 146 | pub fn should_check_app_update(&self) -> bool { 147 | let now = chrono::offset::Utc::now(); 148 | now.signed_duration_since(self.release.checked).num_hours() >= 24 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/gui/badge.rs: -------------------------------------------------------------------------------- 1 | use iced::{alignment, padding, widget::tooltip, Length}; 2 | 3 | use crate::{ 4 | gui::{ 5 | common::Message, 6 | icon::Icon, 7 | style, 8 | widget::{text, Button, Container, Tooltip}, 9 | }, 10 | lang::TRANSLATOR, 11 | scan::ScanChange, 12 | }; 13 | 14 | const CHANGE_BADGE_WIDTH: f32 = 10.0; 15 | 16 | #[derive(Default)] 17 | pub struct Badge { 18 | text: String, 19 | icon: bool, 20 | change: Option, 21 | tooltip: Option, 22 | on_press: Option, 23 | faded: bool, 24 | width: Option, 25 | } 26 | 27 | impl Badge { 28 | pub fn new(text: &str) -> Self { 29 | Self { 30 | text: text.to_string(), 31 | icon: false, 32 | change: None, 33 | tooltip: None, 34 | on_press: None, 35 | faded: false, 36 | width: None, 37 | } 38 | } 39 | 40 | pub fn icon(icon: Icon) -> Self { 41 | Self { 42 | text: icon.as_char().to_string(), 43 | icon: true, 44 | ..Default::default() 45 | } 46 | } 47 | 48 | pub fn scan_change(change: ScanChange) -> Self { 49 | Self { 50 | text: change.symbol().to_string(), 51 | icon: false, 52 | change: Some(change), 53 | tooltip: match change { 54 | ScanChange::New => Some(TRANSLATOR.new_tooltip()), 55 | ScanChange::Different => Some(TRANSLATOR.updated_tooltip()), 56 | ScanChange::Removed => Some(TRANSLATOR.removed_tooltip()), 57 | ScanChange::Same => None, 58 | ScanChange::Unknown => None, 59 | }, 60 | width: Some(Length::Fixed(CHANGE_BADGE_WIDTH)), 61 | ..Default::default() 62 | } 63 | } 64 | 65 | pub fn new_entry() -> Self { 66 | Self::scan_change(ScanChange::New) 67 | } 68 | 69 | pub fn new_entry_with_count(count: usize) -> Self { 70 | Self { 71 | text: format!("{}{}", crate::lang::ADD_SYMBOL, count), 72 | change: Some(ScanChange::New), 73 | tooltip: Some(TRANSLATOR.new_tooltip()), 74 | ..Default::default() 75 | } 76 | } 77 | 78 | pub fn changed_entry() -> Self { 79 | Self::scan_change(ScanChange::Different) 80 | } 81 | 82 | pub fn removed_entry() -> Self { 83 | Self::scan_change(ScanChange::Removed) 84 | } 85 | 86 | pub fn changed_entry_with_count(count: usize) -> Self { 87 | Self { 88 | text: format!("{}{}", crate::lang::CHANGE_SYMBOL, count), 89 | change: Some(ScanChange::Different), 90 | tooltip: Some(TRANSLATOR.updated_tooltip()), 91 | ..Default::default() 92 | } 93 | } 94 | 95 | pub fn on_press(mut self, message: Message) -> Self { 96 | self.on_press = Some(message); 97 | self 98 | } 99 | 100 | pub fn faded(mut self, faded: bool) -> Self { 101 | self.faded = faded; 102 | self 103 | } 104 | 105 | pub fn tooltip(mut self, tooltip: String) -> Self { 106 | self.tooltip = Some(tooltip); 107 | self 108 | } 109 | 110 | pub fn view(self) -> Container<'static> { 111 | Container::new({ 112 | let content = Container::new({ 113 | let mut text = text(self.text) 114 | .size(12) 115 | .align_x(alignment::Horizontal::Center) 116 | .width(self.width.unwrap_or(Length::Shrink)); 117 | 118 | if self.icon { 119 | text = text.font(crate::gui::font::ICONS); 120 | } 121 | 122 | text 123 | }) 124 | .padding([2, 10]) 125 | .class(match self.change { 126 | None => match self.on_press.as_ref() { 127 | Some(Message::FilterDuplicates { game: None, .. }) => style::Container::BadgeActivated, 128 | _ if self.faded => style::Container::BadgeFaded, 129 | _ => style::Container::Badge, 130 | }, 131 | Some(change) => style::Container::ChangeBadge { 132 | change, 133 | faded: self.faded, 134 | }, 135 | }); 136 | 137 | let content = match self.tooltip { 138 | None => content, 139 | Some(tooltip) => Container::new( 140 | Tooltip::new(content, text(tooltip).size(16), tooltip::Position::Top) 141 | .gap(5) 142 | .class(style::Container::Tooltip), 143 | ), 144 | }; 145 | 146 | match self.on_press { 147 | Some(message) => Container::new( 148 | Button::new(content) 149 | .padding(0) 150 | .on_press(message) 151 | .class(style::Button::Badge), 152 | ), 153 | None => Container::new(content), 154 | } 155 | }) 156 | .padding(padding::top(1)) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /docs/schema/api-input.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | $schema: "http://json-schema.org/draft-07/schema#" 3 | title: Input 4 | description: "The full input to the `api` command." 5 | type: object 6 | required: 7 | - requests 8 | properties: 9 | config: 10 | description: Override configuration. 11 | default: 12 | backupPath: ~ 13 | allOf: 14 | - $ref: "#/definitions/ConfigOverride" 15 | requests: 16 | description: The order of the requests here will match the order of responses in the output. 17 | type: array 18 | items: 19 | $ref: "#/definitions/Request" 20 | definitions: 21 | CheckAppUpdate: 22 | description: Check whether an application update is available. 23 | type: object 24 | ConfigOverride: 25 | description: Overridden configuration. 26 | type: object 27 | properties: 28 | backupPath: 29 | description: Directory where Ludusavi stores backups. 30 | anyOf: 31 | - $ref: "#/definitions/FilePath" 32 | - type: "null" 33 | EditBackup: 34 | description: "Edit a backup's metadata." 35 | type: object 36 | properties: 37 | backup: 38 | description: "Edit a specific backup, using an ID returned by the `backups` command. When not specified, this defaults to the latest backup." 39 | default: ~ 40 | type: 41 | - string 42 | - "null" 43 | comment: 44 | description: "If set, update the backup's comment. To delete an existing comment, set this to an empty string." 45 | default: ~ 46 | type: 47 | - string 48 | - "null" 49 | game: 50 | description: Which game to edit. 51 | default: "" 52 | type: string 53 | locked: 54 | description: "If set, indicates whether the backup should be locked." 55 | default: ~ 56 | type: 57 | - boolean 58 | - "null" 59 | FilePath: 60 | type: string 61 | FindTitle: 62 | description: "Find game titles\n\nPrecedence: Steam ID -> GOG ID -> Lutris ID -> exact names -> normalized names. Once a match is found for one of these options, Ludusavi will stop looking and return that match, unless you set `multiple: true`, in which case, the results will be sorted by how well they match.\n\nDepending on the options chosen, there may be multiple matches, but the default is a single match.\n\nAliases will be resolved to the target title." 63 | type: object 64 | properties: 65 | backup: 66 | description: Ensure the game is recognized in a backup context. 67 | default: false 68 | type: boolean 69 | disabled: 70 | description: Select games that are disabled. 71 | default: false 72 | type: boolean 73 | fuzzy: 74 | description: Look up games with fuzzy matching. This may find multiple games for a single input. 75 | default: false 76 | type: boolean 77 | gogId: 78 | description: Look up game by a GOG ID. 79 | default: ~ 80 | type: 81 | - integer 82 | - "null" 83 | format: uint64 84 | minimum: 0.0 85 | lutrisId: 86 | description: Look up game by a Lutris slug. 87 | default: ~ 88 | type: 89 | - string 90 | - "null" 91 | multiple: 92 | description: "Keep looking for all potential matches, instead of stopping at the first match." 93 | default: false 94 | type: boolean 95 | names: 96 | description: "Look up game by an exact title. With multiple values, they will be checked in the order given." 97 | default: [] 98 | type: array 99 | items: 100 | type: string 101 | normalized: 102 | description: "Look up game by an approximation of the title. Ignores capitalization, \"edition\" suffixes, year suffixes, and some special symbols. This may find multiple games for a single input." 103 | default: false 104 | type: boolean 105 | partial: 106 | description: Select games that have some saves disabled. 107 | default: false 108 | type: boolean 109 | restore: 110 | description: Ensure the game is recognized in a restore context. 111 | default: false 112 | type: boolean 113 | steamId: 114 | description: Look up game by a Steam ID. 115 | default: ~ 116 | type: 117 | - integer 118 | - "null" 119 | format: uint32 120 | minimum: 0.0 121 | Request: 122 | description: An individual request. 123 | oneOf: 124 | - type: object 125 | required: 126 | - findTitle 127 | properties: 128 | findTitle: 129 | $ref: "#/definitions/FindTitle" 130 | additionalProperties: false 131 | - type: object 132 | required: 133 | - checkAppUpdate 134 | properties: 135 | checkAppUpdate: 136 | $ref: "#/definitions/CheckAppUpdate" 137 | additionalProperties: false 138 | - type: object 139 | required: 140 | - editBackup 141 | properties: 142 | editBackup: 143 | $ref: "#/definitions/EditBackup" 144 | additionalProperties: false 145 | -------------------------------------------------------------------------------- /src/gui/undoable.rs: -------------------------------------------------------------------------------- 1 | use iced::{ 2 | advanced::{ 3 | layout, renderer, 4 | widget::{Operation, Tree}, 5 | Clipboard, Layout, Shell, Widget, 6 | }, 7 | event::Event, 8 | keyboard::Key, 9 | mouse, overlay, Element, Length, Rectangle, 10 | }; 11 | 12 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 13 | pub enum Action { 14 | Undo, 15 | Redo, 16 | } 17 | 18 | #[allow(missing_debug_implementations)] 19 | pub struct Undoable<'a, Message, Theme, Renderer, F> 20 | where 21 | Message: Clone, 22 | F: Fn(Action) -> Message + 'a, 23 | { 24 | content: Element<'a, Message, Theme, Renderer>, 25 | on_change: F, 26 | } 27 | 28 | impl<'a, Message, Theme, Renderer, F> Undoable<'a, Message, Theme, Renderer, F> 29 | where 30 | Message: Clone, 31 | F: Fn(Action) -> Message + 'a, 32 | { 33 | pub fn new(content: T, on_change: F) -> Self 34 | where 35 | T: Into>, 36 | { 37 | Self { 38 | content: content.into(), 39 | on_change, 40 | } 41 | } 42 | } 43 | 44 | impl<'a, Message, Theme, Renderer, F> Widget for Undoable<'a, Message, Theme, Renderer, F> 45 | where 46 | Message: Clone, 47 | Renderer: iced::advanced::text::Renderer, 48 | F: Fn(Action) -> Message + 'a, 49 | { 50 | fn diff(&self, tree: &mut Tree) { 51 | self.content.as_widget().diff(tree) 52 | } 53 | 54 | fn size(&self) -> iced::Size { 55 | self.content.as_widget().size() 56 | } 57 | 58 | fn size_hint(&self) -> iced::Size { 59 | self.content.as_widget().size_hint() 60 | } 61 | 62 | fn state(&self) -> iced::advanced::widget::tree::State { 63 | self.content.as_widget().state() 64 | } 65 | 66 | fn tag(&self) -> iced::advanced::widget::tree::Tag { 67 | self.content.as_widget().tag() 68 | } 69 | 70 | fn layout(&mut self, tree: &mut Tree, renderer: &Renderer, limits: &layout::Limits) -> layout::Node { 71 | self.content.as_widget_mut().layout(tree, renderer, limits) 72 | } 73 | 74 | fn operate(&mut self, tree: &mut Tree, layout: Layout<'_>, renderer: &Renderer, operation: &mut dyn Operation) { 75 | self.content.as_widget_mut().operate(tree, layout, renderer, operation) 76 | } 77 | 78 | fn update( 79 | &mut self, 80 | tree: &mut Tree, 81 | event: &Event, 82 | layout: Layout<'_>, 83 | cursor: mouse::Cursor, 84 | renderer: &Renderer, 85 | clipboard: &mut dyn Clipboard, 86 | shell: &mut Shell<'_, Message>, 87 | viewport: &Rectangle, 88 | ) { 89 | if let Event::Keyboard(iced::keyboard::Event::KeyPressed { key, modifiers, .. }) = &event { 90 | let focused = tree 91 | .state 92 | .downcast_ref::>() 93 | .is_focused(); 94 | if focused { 95 | match (key.as_ref(), modifiers.command(), modifiers.shift()) { 96 | (Key::Character("z"), true, false) => { 97 | shell.publish((self.on_change)(Action::Undo)); 98 | shell.capture_event(); 99 | return; 100 | } 101 | (Key::Character("y"), true, false) | (Key::Character("z"), true, true) => { 102 | shell.publish((self.on_change)(Action::Redo)); 103 | shell.capture_event(); 104 | return; 105 | } 106 | _ => (), 107 | }; 108 | } 109 | } 110 | 111 | self.content 112 | .as_widget_mut() 113 | .update(tree, event, layout, cursor, renderer, clipboard, shell, viewport) 114 | } 115 | 116 | fn mouse_interaction( 117 | &self, 118 | tree: &Tree, 119 | layout: Layout<'_>, 120 | cursor_position: mouse::Cursor, 121 | viewport: &Rectangle, 122 | renderer: &Renderer, 123 | ) -> mouse::Interaction { 124 | self.content 125 | .as_widget() 126 | .mouse_interaction(tree, layout, cursor_position, viewport, renderer) 127 | } 128 | 129 | fn draw( 130 | &self, 131 | tree: &Tree, 132 | renderer: &mut Renderer, 133 | theme: &Theme, 134 | style: &renderer::Style, 135 | layout: Layout<'_>, 136 | cursor: mouse::Cursor, 137 | viewport: &Rectangle, 138 | ) { 139 | self.content 140 | .as_widget() 141 | .draw(tree, renderer, theme, style, layout, cursor, viewport) 142 | } 143 | 144 | fn overlay<'b>( 145 | &'b mut self, 146 | tree: &'b mut Tree, 147 | layout: Layout<'b>, 148 | renderer: &Renderer, 149 | viewport: &Rectangle, 150 | translation: iced::Vector, 151 | ) -> Option> { 152 | self.content 153 | .as_widget_mut() 154 | .overlay(tree, layout, renderer, viewport, translation) 155 | } 156 | } 157 | 158 | impl<'a, Message, Theme, Renderer, F> From> 159 | for Element<'a, Message, Theme, Renderer> 160 | where 161 | Message: 'a + Clone, 162 | Theme: 'a, 163 | Renderer: iced::advanced::text::Renderer + 'a, 164 | F: Fn(Action) -> Message + 'a, 165 | { 166 | fn from(undoable: Undoable<'a, Message, Theme, Renderer, F>) -> Self { 167 | Self::new(undoable) 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/scan/change.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | lang::{ADD_SYMBOL, CHANGE_SYMBOL, REMOVAL_SYMBOL}, 3 | prelude::StrictPath, 4 | scan::ScanKind, 5 | }; 6 | 7 | #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, serde::Serialize, schemars::JsonSchema)] 8 | pub enum ScanChange { 9 | New, 10 | Different, 11 | Removed, 12 | Same, 13 | #[default] 14 | Unknown, 15 | } 16 | 17 | impl ScanChange { 18 | pub fn symbol(&self) -> &'static str { 19 | match self { 20 | ScanChange::New => ADD_SYMBOL, 21 | ScanChange::Different => CHANGE_SYMBOL, 22 | ScanChange::Removed => REMOVAL_SYMBOL, 23 | ScanChange::Same => "=", 24 | ScanChange::Unknown => "?", 25 | } 26 | } 27 | 28 | pub fn normalize(&self, ignored: bool, scan_kind: ScanKind) -> Self { 29 | match self { 30 | ScanChange::New if ignored => Self::Same, 31 | ScanChange::New => *self, 32 | ScanChange::Different if ignored && scan_kind.is_restore() => Self::Same, 33 | ScanChange::Different if ignored && scan_kind.is_backup() => Self::Removed, 34 | ScanChange::Different => Self::Different, 35 | ScanChange::Removed => *self, 36 | ScanChange::Same if ignored && scan_kind.is_backup() => Self::Removed, 37 | ScanChange::Same => *self, 38 | ScanChange::Unknown => *self, 39 | } 40 | } 41 | 42 | pub fn is_changed(&self) -> bool { 43 | match self { 44 | Self::New => true, 45 | Self::Different => true, 46 | Self::Removed => true, 47 | Self::Same => false, 48 | // This is because we want unchanged and unscanned games to be filtered differently: 49 | Self::Unknown => true, 50 | } 51 | } 52 | 53 | pub fn will_take_space(&self) -> bool { 54 | match self { 55 | Self::New => true, 56 | Self::Different => true, 57 | Self::Removed => false, 58 | Self::Same => true, 59 | Self::Unknown => true, 60 | } 61 | } 62 | 63 | pub fn is_inert(&self) -> bool { 64 | match self { 65 | ScanChange::New => false, 66 | ScanChange::Different => false, 67 | ScanChange::Removed => true, 68 | ScanChange::Same => false, 69 | ScanChange::Unknown => true, 70 | } 71 | } 72 | } 73 | 74 | #[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, serde::Serialize, schemars::JsonSchema)] 75 | pub struct ScanChangeCount { 76 | pub new: usize, 77 | pub different: usize, 78 | #[serde(skip)] 79 | pub removed: usize, 80 | pub same: usize, 81 | } 82 | 83 | impl ScanChangeCount { 84 | pub fn new() -> Self { 85 | Self { 86 | new: 0, 87 | different: 0, 88 | removed: 0, 89 | same: 0, 90 | } 91 | } 92 | 93 | pub fn add(&mut self, change: ScanChange) { 94 | match change { 95 | ScanChange::New => self.new += 1, 96 | ScanChange::Different => self.different += 1, 97 | ScanChange::Removed => self.removed += 1, 98 | ScanChange::Same => self.same += 1, 99 | ScanChange::Unknown => (), 100 | } 101 | } 102 | 103 | pub fn brand_new(&self) -> bool { 104 | self.only(ScanChange::New) 105 | } 106 | 107 | pub fn updated(&self) -> bool { 108 | !self.brand_new() && (self.new > 0 || self.different > 0 || self.removed > 0) 109 | } 110 | 111 | fn only(&self, change: ScanChange) -> bool { 112 | let total = self.new + self.different + self.removed + self.same; 113 | let only = |count: usize| count > 0 && count == total; 114 | match change { 115 | ScanChange::New => only(self.new), 116 | ScanChange::Different => only(self.different), 117 | ScanChange::Removed => only(self.removed), 118 | ScanChange::Same => only(self.same), 119 | ScanChange::Unknown => false, 120 | } 121 | } 122 | 123 | pub fn overall(&self, only_constructive: bool) -> ScanChange { 124 | if self.brand_new() { 125 | ScanChange::New 126 | } else if self.only(ScanChange::Removed) { 127 | ScanChange::Removed 128 | } else if self.updated() { 129 | if only_constructive && self.new == 0 && self.different == 0 { 130 | ScanChange::Same 131 | } else { 132 | ScanChange::Different 133 | } 134 | } else if self.same != 0 { 135 | ScanChange::Same 136 | } else { 137 | ScanChange::Unknown 138 | } 139 | } 140 | } 141 | 142 | impl ScanChange { 143 | pub fn evaluate_backup(current_hash: &str, previous_hash: Option<&&String>) -> Self { 144 | match previous_hash { 145 | None => Self::New, 146 | Some(&previous) => { 147 | if current_hash == previous { 148 | Self::Same 149 | } else { 150 | Self::Different 151 | } 152 | } 153 | } 154 | } 155 | 156 | pub fn evaluate_restore(original_path: &StrictPath, previous_hash: &str) -> Self { 157 | match original_path.try_sha1() { 158 | Err(_) => Self::New, 159 | Ok(current_hash) => { 160 | if current_hash == previous_hash { 161 | Self::Same 162 | } else { 163 | Self::Different 164 | } 165 | } 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/scan/launchers/heroic/gog.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{HashMap, HashSet}; 2 | 3 | use crate::prelude::StrictPath; 4 | 5 | use crate::{ 6 | prelude::ENV_DEBUG, 7 | resource::{config::root, manifest::Os}, 8 | scan::{ 9 | launchers::{heroic::find_prefix, LauncherGame}, 10 | TitleFinder, TitleQuery, 11 | }, 12 | }; 13 | 14 | pub mod installed { 15 | pub const PATH: &str = "gog_store/installed.json"; 16 | 17 | #[derive(serde::Deserialize)] 18 | pub struct Data { 19 | pub installed: Vec, 20 | } 21 | 22 | #[derive(serde::Deserialize)] 23 | pub struct Game { 24 | /// This is an opaque ID, not the human-readable title. 25 | #[serde(rename = "appName")] 26 | pub app_name: String, 27 | pub platform: String, 28 | pub install_path: String, 29 | } 30 | } 31 | 32 | pub mod library { 33 | pub const PATH: &str = "store_cache/gog_library.json"; 34 | pub const PATH_LEGACY: &str = "gog_store/library.json"; 35 | 36 | #[derive(serde::Deserialize)] 37 | pub struct Data { 38 | pub games: Vec, 39 | } 40 | 41 | #[derive(serde::Deserialize)] 42 | pub struct Game { 43 | /// This is an opaque ID, not the human-readable title. 44 | pub app_name: String, 45 | pub title: String, 46 | } 47 | } 48 | 49 | pub fn scan(root: &root::Heroic, title_finder: &TitleFinder) -> HashMap> { 50 | let mut games = HashMap::>::new(); 51 | 52 | let game_titles: HashMap = get_library(root) 53 | .iter() 54 | .map(|game| (game.app_name.clone(), game.title.clone())) 55 | .collect(); 56 | 57 | if game_titles.is_empty() { 58 | return games; 59 | } 60 | 61 | let installed_path = root.path.joined(installed::PATH); 62 | let content = installed_path.read(); 63 | 64 | match serde_json::from_str::(&content.unwrap_or_default()) { 65 | Ok(installed_games) => { 66 | for game in installed_games.installed { 67 | let Some(game_title) = game_titles.get(&game.app_name) else { 68 | continue; 69 | }; 70 | 71 | let gog_id: Option = game.app_name.parse().ok(); 72 | 73 | let query = TitleQuery { 74 | names: vec![game_title.to_owned()], 75 | gog_id, 76 | normalized: true, 77 | ..Default::default() 78 | }; 79 | let Some(official_title) = title_finder.find_one(query) else { 80 | log::trace!("Ignoring unrecognized game: {}, app: {}", &game_title, &game.app_name); 81 | if std::env::var(ENV_DEBUG).is_ok() { 82 | eprintln!( 83 | "Ignoring unrecognized game from Heroic/GOG: {} (app = {})", 84 | &game_title, &game.app_name 85 | ); 86 | } 87 | continue; 88 | }; 89 | 90 | log::trace!( 91 | "Detected game: {} | app: {}, raw title: {}", 92 | &official_title, 93 | &game.app_name, 94 | &game_title 95 | ); 96 | let prefix = find_prefix(&root.path, game_title, Some(&game.platform), &game.app_name); 97 | games.entry(official_title).or_default().insert(LauncherGame { 98 | install_dir: Some(StrictPath::new(game.install_path.clone())), 99 | prefix, 100 | platform: Some(Os::from(game.platform.as_str())), 101 | }); 102 | } 103 | } 104 | Err(e) => { 105 | log::warn!("Unable to parse installed list from {:?}: {}", &installed_path, e); 106 | } 107 | } 108 | 109 | games 110 | } 111 | 112 | pub fn get_library(root: &root::Heroic) -> Vec { 113 | let libraries = [root.path.joined(library::PATH), root.path.joined(library::PATH_LEGACY)]; 114 | 115 | let library_path = 'outer: { 116 | for library in libraries { 117 | if library.is_file() { 118 | break 'outer library; 119 | } 120 | } 121 | log::warn!("Could not find library in {:?}", root); 122 | return vec![]; 123 | }; 124 | 125 | match serde_json::from_str::(&library_path.read().unwrap_or_default()) { 126 | Ok(gog_library) => { 127 | log::trace!("Found {} games in {:?}", gog_library.games.len(), &library_path); 128 | 129 | gog_library.games 130 | } 131 | Err(e) => { 132 | log::warn!("Unable to parse library in {:?}: {}", &library_path, e); 133 | vec![] 134 | } 135 | } 136 | } 137 | 138 | #[cfg(test)] 139 | mod tests { 140 | use pretty_assertions::assert_eq; 141 | use velcro::{hash_map, hash_set}; 142 | 143 | use super::*; 144 | use crate::{ 145 | resource::{ 146 | manifest::{Manifest, Os}, 147 | ResourceFile, 148 | }, 149 | testing::repo, 150 | }; 151 | 152 | fn manifest() -> Manifest { 153 | Manifest::load_from_string( 154 | r#" 155 | game-1: 156 | files: 157 | /file1.txt: {} 158 | "#, 159 | ) 160 | .unwrap() 161 | } 162 | 163 | fn title_finder() -> TitleFinder { 164 | TitleFinder::new(&Default::default(), &manifest(), Default::default()) 165 | } 166 | 167 | #[test] 168 | fn scan_finds_all_games_without_store_cache() { 169 | let root = root::Heroic { 170 | path: format!("{}/tests/launchers/heroic-gog-without-store-cache", repo()).into(), 171 | }; 172 | let games = scan(&root, &title_finder()); 173 | assert_eq!( 174 | hash_map! { 175 | "game-1".to_string(): hash_set![LauncherGame { 176 | install_dir: Some(StrictPath::new("/games/game-1".to_string())), 177 | prefix: Some(StrictPath::new("/prefixes/game-1".to_string())), 178 | platform: Some(Os::Windows), 179 | }], 180 | }, 181 | games, 182 | ); 183 | } 184 | 185 | #[test] 186 | fn scan_finds_all_games_with_store_cache() { 187 | let root = root::Heroic { 188 | path: format!("{}/tests/launchers/heroic-gog-with-store-cache", repo()).into(), 189 | }; 190 | let games = scan(&root, &title_finder()); 191 | assert_eq!( 192 | hash_map! { 193 | "game-1".to_string(): hash_set![LauncherGame { 194 | install_dir: Some(StrictPath::new("/games/game-1".to_string())), 195 | prefix: Some(StrictPath::new("/prefixes/game-1".to_string())), 196 | platform: Some(Os::Windows), 197 | }], 198 | }, 199 | games, 200 | ); 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/cli/ui.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | lang::TRANSLATOR, 3 | prelude::{Error, SyncDirection}, 4 | }; 5 | 6 | /// GUI looks nicer with an extra empty line as separator, but for terminals a single 7 | /// newline is sufficient 8 | fn get_separator(gui: bool) -> &'static str { 9 | match gui { 10 | true => "\n\n", 11 | false => "\n", 12 | } 13 | } 14 | 15 | fn title(games: &[String]) -> String { 16 | match games.len() { 17 | 0 => TRANSLATOR.app_name(), 18 | 1 => format!("{} - {}", TRANSLATOR.app_name(), &games[0]), 19 | total => format!("{} - {}: {}", TRANSLATOR.app_name(), TRANSLATOR.total_games(), total), 20 | } 21 | } 22 | 23 | fn pause() -> Result<(), Error> { 24 | use std::io::prelude::{Read, Write}; 25 | 26 | let mut stdin = std::io::stdin(); 27 | let mut stdout = std::io::stdout(); 28 | 29 | // TODO: Must be a string literal. Can we support translation? 30 | write!(stdout, "Press any key to continue...").map_err(|_| Error::CliUnableToRequestConfirmation)?; 31 | stdout.flush().map_err(|_| Error::CliUnableToRequestConfirmation)?; 32 | 33 | stdin 34 | .read(&mut [0u8]) 35 | .map_err(|_| Error::CliUnableToRequestConfirmation)?; 36 | 37 | Ok(()) 38 | } 39 | 40 | pub fn alert_with_raw_error(games: &[String], gui: bool, force: bool, msg: &str, error: &str) -> Result<(), Error> { 41 | alert( 42 | games, 43 | gui, 44 | force, 45 | &format!("{}{}{}", msg, get_separator(gui), TRANSLATOR.prefix_error(error)), 46 | ) 47 | } 48 | 49 | pub fn alert_with_error(games: &[String], gui: bool, force: bool, msg: &str, error: &Error) -> Result<(), Error> { 50 | alert( 51 | games, 52 | gui, 53 | force, 54 | &format!("{}{}{}", msg, get_separator(gui), TRANSLATOR.handle_error(error)), 55 | ) 56 | } 57 | 58 | pub fn alert(games: &[String], gui: bool, force: bool, msg: &str) -> Result<(), Error> { 59 | log::debug!("Showing alert to user (GUI={}, force={}): {}", gui, force, msg); 60 | if gui { 61 | rfd::MessageDialog::new() 62 | .set_title(title(games)) 63 | .set_description(msg) 64 | .set_level(rfd::MessageLevel::Error) 65 | .set_buttons(rfd::MessageButtons::Ok) 66 | .show(); 67 | Ok(()) 68 | } else if !force { 69 | // TODO: Dialoguer doesn't have an alert type. 70 | // https://github.com/console-rs/dialoguer/issues/287 71 | println!("{msg}"); 72 | pause() 73 | } else { 74 | println!("{msg}"); 75 | Ok(()) 76 | } 77 | } 78 | 79 | pub fn confirm_with_question( 80 | games: &[String], 81 | gui: bool, 82 | force: bool, 83 | preview: bool, 84 | msg: &str, 85 | question: &str, 86 | ) -> Result { 87 | if force || preview { 88 | _ = alert(games, gui, force, msg); 89 | return Ok(true); 90 | } 91 | 92 | confirm( 93 | games, 94 | gui, 95 | force, 96 | preview, 97 | &format!("{}{}{}", msg, get_separator(gui), question), 98 | ) 99 | } 100 | 101 | pub fn confirm(games: &[String], gui: bool, force: bool, preview: bool, msg: &str) -> Result { 102 | log::debug!( 103 | "Showing confirmation to user (GUI={}, force={}, preview={}): {}", 104 | gui, 105 | force, 106 | preview, 107 | msg 108 | ); 109 | 110 | if force || preview { 111 | return Ok(true); 112 | } 113 | 114 | if gui { 115 | let choice = match rfd::MessageDialog::new() 116 | .set_title(title(games)) 117 | .set_description(msg) 118 | .set_level(rfd::MessageLevel::Info) 119 | .set_buttons(rfd::MessageButtons::YesNo) 120 | .show() 121 | { 122 | rfd::MessageDialogResult::Yes => true, 123 | rfd::MessageDialogResult::No => false, 124 | rfd::MessageDialogResult::Ok => true, 125 | rfd::MessageDialogResult::Cancel => false, 126 | rfd::MessageDialogResult::Custom(_) => false, 127 | }; 128 | log::debug!("User responded: {}", choice); 129 | Ok(choice) 130 | } else { 131 | match dialoguer::Confirm::new().with_prompt(msg).interact() { 132 | Ok(value) => { 133 | log::debug!("User responded: {}", value); 134 | Ok(value) 135 | } 136 | Err(err) => { 137 | log::error!("Unable to request confirmation: {:?}", err); 138 | Err(Error::CliUnableToRequestConfirmation) 139 | } 140 | } 141 | } 142 | } 143 | 144 | pub fn ask_cloud_conflict( 145 | games: &[String], 146 | gui: bool, 147 | force: bool, 148 | preview: bool, 149 | ) -> Result, Error> { 150 | let msg = TRANSLATOR.cloud_synchronize_conflict(); 151 | 152 | log::debug!( 153 | "Asking user about cloud conflict (GUI={}, force={}, preview={}): {}", 154 | gui, 155 | force, 156 | preview, 157 | msg, 158 | ); 159 | 160 | if force || preview { 161 | return Ok(None); 162 | } 163 | 164 | fn parse_response(raw: &str) -> Option { 165 | if raw == TRANSLATOR.download_button() { 166 | Some(SyncDirection::Download) 167 | } else if raw == TRANSLATOR.upload_button() { 168 | Some(SyncDirection::Upload) 169 | } else { 170 | None 171 | } 172 | } 173 | 174 | if gui { 175 | let choice = match rfd::MessageDialog::new() 176 | .set_title(title(games)) 177 | .set_description(msg) 178 | .set_level(rfd::MessageLevel::Info) 179 | .set_buttons(rfd::MessageButtons::YesNoCancelCustom( 180 | TRANSLATOR.ignore_button(), 181 | TRANSLATOR.download_button(), 182 | TRANSLATOR.upload_button(), 183 | )) 184 | .show() 185 | { 186 | rfd::MessageDialogResult::Yes => None, 187 | rfd::MessageDialogResult::No => None, 188 | rfd::MessageDialogResult::Ok => None, 189 | rfd::MessageDialogResult::Cancel => None, 190 | rfd::MessageDialogResult::Custom(raw) => parse_response(&raw), 191 | }; 192 | log::debug!("User responded: {:?}", choice); 193 | Ok(choice) 194 | } else { 195 | let options = vec![ 196 | TRANSLATOR.ignore_button(), 197 | TRANSLATOR.download_button(), 198 | TRANSLATOR.upload_button(), 199 | ]; 200 | 201 | let dialog = dialoguer::Select::new().with_prompt(msg).items(&options); 202 | 203 | match dialog.interact() { 204 | Ok(index) => { 205 | let choice = parse_response(&options[index]); 206 | log::debug!("User responded: {} -> {:?}", index, choice); 207 | Ok(choice) 208 | } 209 | Err(err) => { 210 | log::error!("Unable to request confirmation: {:?}", err); 211 | Err(Error::CliUnableToRequestConfirmation) 212 | } 213 | } 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![Logo](assets/icon.svg) Ludusavi 2 | Ludusavi is a tool for backing up your PC video game save data, 3 | written in [Rust](https://www.rust-lang.org). 4 | It is cross-platform and supports multiple game stores. 5 | 6 | ## Features 7 | * Ability to back up data from more than 19,000 games plus your own custom entries. 8 | * Backup and restore for Steam, GOG, Epic, Heroic, Lutris, and other game libraries. 9 | * Both a graphical interface and command line interface for scripting. 10 | Tab completion is available for Bash, Fish, Zsh, PowerShell, and Elvish. 11 | * Support for: 12 | * Saves that are stored as files and in the Windows registry. 13 | * Proton saves with Steam. 14 | * Steam screenshots. 15 | * Available as a [Playnite](https://playnite.link) extension: 16 | https://github.com/mtkennerly/ludusavi-playnite 17 | * Works on the Steam Deck. 18 | 19 | This tool uses the [Ludusavi Manifest](https://github.com/mtkennerly/ludusavi-manifest) 20 | for info on what to back up for each game. 21 | The data is primarily sourced from [PCGamingWiki](https://www.pcgamingwiki.com/wiki/Home), 22 | so please contribute any new or fixed data back to the wiki itself, 23 | and your improvements will be incorporated into Ludusavi's data as well. 24 | 25 | If you'd like to help translate Ludusavi into other languages, 26 | [check out the Crowdin project](https://crowdin.com/project/ludusavi). 27 | 28 | ## Demo 29 | 30 | 31 | 32 | > ![GUI demo of previewing a backup](docs/demo-gui.gif) 33 | 34 | ## Installation 35 | 36 | 37 | 38 | 39 | Download the executable for Windows, Linux, or Mac from the 40 | [releases page](https://github.com/mtkennerly/ludusavi/releases). 41 | It's portable, so you can simply download it and put it anywhere on your system. 42 | 43 | If you prefer, Ludusavi is also available via 44 | [Winget, Scoop, Flatpak, and Cargo](docs/help/installation.md). 45 | 46 | Note: 47 | 48 | * Windows users may see a popup that says 49 | "Windows protected your PC", 50 | because Windows does not recognize the program's publisher. 51 | Click "more info" and then "run anyway" to start the program. 52 | * Mac users may see a popup that says 53 | "Ludusavi can't be opened because it is from an unidentified developer". 54 | To allow Ludusavi to run, please refer to [this article](https://support.apple.com/en-us/102445), 55 | specifically the section on `If you want to open an app [...] from an unidentified developer`. 56 | 57 | ## Usage 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | Detailed help documentation is available for several topics. 80 | 81 | ### General 82 | * [Backup automation](/docs/help/backup-automation.md) 83 | * [Backup exclusions](/docs/help/backup-exclusions.md) 84 | * [Backup retention](/docs/help/backup-retention.md) 85 | * [Backup validation](/docs/help/backup-validation.md) 86 | * [Cloud backup](/docs/help/cloud-backup.md) 87 | * [Custom games](/docs/help/custom-games.md) 88 | * [Duplicates](/docs/help/duplicates.md) 89 | * [Filter](/docs/help/filter.md) 90 | * [Game launch wrapping](/docs/help/game-launch-wrapping.md) 91 | * [Redirects](/docs/help/redirects.md) 92 | * [Roots](/docs/help/roots.md) 93 | * [Selective scanning](/docs/help/selective-scanning.md) 94 | * [Transfer between operating systems](/docs/help/transfer-between-operating-systems.md) 95 | 96 | ### Interfaces 97 | * [Application folder](/docs/help/application-folder.md) 98 | * [Backup structure](/docs/help/backup-structure.md) 99 | * [Command line](/docs/help/command-line.md) 100 | * [Configuration file](/docs/help/configuration-file.md) 101 | * [Environment variables](/docs/help/environment-variables.md) 102 | * [Logging](/docs/help/logging.md) 103 | 104 | ### Other 105 | * [Troubleshooting](/docs/help/troubleshooting.md) 106 | * [What if my saves aren't found?](/docs/help/missing-saves.md) 107 | 108 | ## Community 109 | 110 | The community has created some additional resources you may find useful. 111 | Please note that this is not an exhaustive list 112 | and that these projects are not officially affiliated with Ludusavi itself: 113 | 114 | * Secondary manifests: 115 | * https://github.com/BloodShed-Oni/ludusavi-extra-manifests 116 | * https://github.com/hblamo/ludusavi-emudeck-manifest 117 | * https://github.com/hvmzx/ludusavi-manifests 118 | * This has an example of using a scheduled GitHub workflow 119 | to generate a manifest that adds more paths to the primary manifest's entries. 120 | * Plugins for Decky Loader on Steam Deck: 121 | * https://github.com/GedasFX/decky-ludusavi 122 | * Plugins for VS Code: 123 | * https://marketplace.visualstudio.com/items?itemName=claui.ludusavi 124 | * Tools: 125 | * https://github.com/jose-l-martins/GSM-to-Ludusavi-converter 126 | 127 | ## Comparison with other tools 128 | There are other excellent backup tools available, but not a singular 129 | cross-platform and cross-store solution: 130 | 131 | * [GameSave Manager](https://www.gamesave-manager.com) (as of v3.1.512.0): 132 | * Only supports Windows. 133 | * Much slower than Ludusavi. On the same hardware and with default settings, 134 | an initial scan of the whole system takes 2 minutes in GSM versus 10 seconds in Ludusavi. 135 | Performing a backup immediately after that scan takes 4 minutes 16 seconds in GSM versus 4.5 seconds in Ludusavi. 136 | In this test, GSM found 257 games with 2.84 GB, and Ludusavi found 297 games with 2.95 GiB. 137 | * Closed source, so the community cannot contribute improvements. 138 | * Interface can be slow or unresponsive. 139 | For example, when clicking "select all / de-select all", each checkbox has to individually toggle itself. 140 | With 257 games, this means you end up having to wait around 42 seconds. 141 | * Minimal command line interface. 142 | * Can create symlinks for games and game data. 143 | Ludusavi does not support this. 144 | * [Game Backup Monitor](https://mikemaximus.github.io/gbm-web) (as of v1.2.2): 145 | * Does not support Mac. 146 | * Database only covers 577 games (as of 2022-11-16), although it can also import 147 | the Ludusavi manifest starting in 1.3.1. 148 | * No command line interface. 149 | * Can automatically back up saves for a game after you play it. 150 | Ludusavi can only do that in conjunction with a launcher like Playnite. 151 | * [Gaming Backup Multitool for Linux](https://supremesonicbrazil.gitlab.io/gbml-web) (as of v1.4.0.0): 152 | * Only supports Linux and Steam. 153 | * Database is not actively updated. As of 2022-11-16, the last update was 2018-06-05. 154 | * No command line interface. 155 | 156 | ## Development 157 | Please refer to [CONTRIBUTING.md](./CONTRIBUTING.md). 158 | -------------------------------------------------------------------------------- /src/scan/launchers/heroic/legendary.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{HashMap, HashSet}; 2 | 3 | use crate::prelude::StrictPath; 4 | 5 | use crate::{ 6 | prelude::ENV_DEBUG, 7 | resource::{config::root, manifest::Os}, 8 | scan::{ 9 | launchers::{heroic::find_prefix, legendary as legendary_standalone, LauncherGame}, 10 | TitleFinder, 11 | }, 12 | }; 13 | 14 | pub mod library { 15 | pub const PATH: &str = "store_cache/legendary_library.json"; 16 | 17 | #[derive(serde::Deserialize)] 18 | pub struct Data { 19 | pub library: Vec, 20 | } 21 | 22 | #[derive(serde::Deserialize)] 23 | pub struct Game { 24 | /// This is an opaque ID, not the human-readable title. 25 | pub app_name: String, 26 | pub title: String, 27 | } 28 | } 29 | 30 | pub fn scan( 31 | root: &root::Heroic, 32 | title_finder: &TitleFinder, 33 | legendary: Option<&StrictPath>, 34 | ) -> HashMap> { 35 | let mut games = HashMap::>::new(); 36 | 37 | for game in get_installed(root, legendary) { 38 | let Some(official_title) = title_finder.find_one_by_normalized_name(&game.title) else { 39 | log::trace!( 40 | "Ignoring unrecognized installed game: {}, app: {}", 41 | &game.title, 42 | &game.app_name 43 | ); 44 | if std::env::var(ENV_DEBUG).is_ok() { 45 | eprintln!( 46 | "Ignoring unrecognized game from Heroic/Legendary: {} (app = {})", 47 | &game.title, &game.app_name 48 | ); 49 | } 50 | continue; 51 | }; 52 | 53 | log::trace!( 54 | "Detected game from installation: {} | app: {}, raw title: {}", 55 | &official_title, 56 | &game.app_name, 57 | &game.title 58 | ); 59 | let prefix = find_prefix(&root.path, &game.title, Some(&game.platform), &game.app_name); 60 | games.entry(official_title).or_default().insert(LauncherGame { 61 | install_dir: Some(StrictPath::new(game.install_path.clone())), 62 | prefix, 63 | platform: Some(Os::from(game.platform.as_str())), 64 | }); 65 | } 66 | 67 | for (id, game) in get_library(root) { 68 | if games.contains_key(&id) { 69 | continue; 70 | } 71 | 72 | let Some(official_title) = title_finder.find_one_by_normalized_name(&game.title) else { 73 | log::trace!( 74 | "Ignoring unrecognized library game: {}, app: {}", 75 | &game.title, 76 | &game.app_name 77 | ); 78 | continue; 79 | }; 80 | 81 | log::trace!( 82 | "Detected game from library: {} | app: {}, raw title: {}", 83 | &official_title, 84 | &game.app_name, 85 | &game.title 86 | ); 87 | let prefix = find_prefix(&root.path, &game.title, None, &game.app_name); 88 | games.entry(official_title).or_default().insert(LauncherGame { 89 | install_dir: None, 90 | prefix, 91 | platform: None, 92 | }); 93 | } 94 | 95 | games 96 | } 97 | 98 | pub fn get_library(root: &root::Heroic) -> HashMap { 99 | let mut out = HashMap::new(); 100 | 101 | let file = root.path.joined(library::PATH); 102 | 103 | let content = match file.try_read() { 104 | Ok(content) => content, 105 | Err(e) => { 106 | log::debug!( 107 | "In Heroic Legendary source '{:?}', unable to read library | {:?}", 108 | &file, 109 | e 110 | ); 111 | return out; 112 | } 113 | }; 114 | 115 | if let Ok(data) = serde_json::from_str::(&content) { 116 | for game in data.library { 117 | out.insert(game.app_name.clone(), game); 118 | } 119 | } 120 | 121 | out 122 | } 123 | 124 | pub fn get_installed( 125 | root: &root::Heroic, 126 | legendary: Option<&StrictPath>, 127 | ) -> Vec { 128 | let mut out = vec![]; 129 | 130 | let legendary_paths = match legendary { 131 | None => vec![ 132 | root.path.popped().joined("legendary"), 133 | root.path.joined("legendaryConfig/legendary"), 134 | StrictPath::new("~/.config/legendary".to_string()), 135 | ], 136 | Some(x) => vec![x.clone()], 137 | }; 138 | 139 | for legendary_path in legendary_paths { 140 | out.extend(legendary_standalone::get_games(&legendary_path)); 141 | } 142 | 143 | out 144 | } 145 | 146 | #[cfg(test)] 147 | mod tests { 148 | use pretty_assertions::assert_eq; 149 | use velcro::{hash_map, hash_set}; 150 | 151 | use super::*; 152 | use crate::{ 153 | resource::{ 154 | manifest::{Manifest, Os}, 155 | ResourceFile, 156 | }, 157 | testing::repo, 158 | }; 159 | 160 | fn manifest() -> Manifest { 161 | Manifest::load_from_string( 162 | r#" 163 | game-1: 164 | files: 165 | /file1.txt: {} 166 | game-2: 167 | files: 168 | /file2.txt: {} 169 | "#, 170 | ) 171 | .unwrap() 172 | } 173 | 174 | fn title_finder() -> TitleFinder { 175 | TitleFinder::new(&Default::default(), &manifest(), Default::default()) 176 | } 177 | 178 | #[test] 179 | fn scan_finds_all_games_without_store_cache() { 180 | let root = root::Heroic { 181 | path: format!("{}/tests/launchers/heroic-gog-without-store-cache", repo()).into(), 182 | }; 183 | let legendary = Some(StrictPath::new(format!("{}/tests/launchers/legendary", repo()))); 184 | let games = scan(&root, &title_finder(), legendary.as_ref()); 185 | assert_eq!( 186 | hash_map! { 187 | "game-1".to_string(): hash_set![LauncherGame { 188 | install_dir: Some(StrictPath::new("/games/game-1".to_string())), 189 | prefix: Some(StrictPath::new("/prefixes/game-1".to_string())), 190 | platform: Some(Os::Windows), 191 | }], 192 | }, 193 | games, 194 | ); 195 | } 196 | 197 | #[test] 198 | fn scan_finds_all_games_with_store_cache() { 199 | let root = root::Heroic { 200 | path: format!("{}/tests/launchers/heroic-legendary", repo()).into(), 201 | }; 202 | let legendary = Some(StrictPath::new(format!("{}/tests/launchers/legendary", repo()))); 203 | let games = scan(&root, &title_finder(), legendary.as_ref()); 204 | assert_eq!( 205 | hash_map! { 206 | "game-1".to_string(): hash_set![LauncherGame { 207 | install_dir: Some(StrictPath::new("/games/game-1".to_string())), 208 | prefix: None, 209 | platform: Some(Os::Windows), 210 | }], 211 | "game-2".to_string(): hash_set![LauncherGame { 212 | install_dir: None, 213 | prefix: Some(StrictPath::new("/prefixes/game-2".to_string())), 214 | platform: None, 215 | }] 216 | }, 217 | games, 218 | ); 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/resource/config/root.rs: -------------------------------------------------------------------------------- 1 | use crate::path::StrictPath; 2 | 3 | #[derive( 4 | Clone, 5 | Debug, 6 | Default, 7 | Eq, 8 | PartialEq, 9 | Ord, 10 | PartialOrd, 11 | Hash, 12 | serde::Serialize, 13 | serde::Deserialize, 14 | schemars::JsonSchema, 15 | )] 16 | #[serde(default, rename_all = "camelCase")] 17 | pub struct Ea { 18 | /// Where the root is located on your system. 19 | pub path: StrictPath, 20 | } 21 | 22 | #[derive( 23 | Clone, 24 | Debug, 25 | Default, 26 | Eq, 27 | PartialEq, 28 | Ord, 29 | PartialOrd, 30 | Hash, 31 | serde::Serialize, 32 | serde::Deserialize, 33 | schemars::JsonSchema, 34 | )] 35 | #[serde(default, rename_all = "camelCase")] 36 | pub struct Epic { 37 | /// Where the root is located on your system. 38 | pub path: StrictPath, 39 | } 40 | 41 | #[derive( 42 | Clone, 43 | Debug, 44 | Default, 45 | Eq, 46 | PartialEq, 47 | Ord, 48 | PartialOrd, 49 | Hash, 50 | serde::Serialize, 51 | serde::Deserialize, 52 | schemars::JsonSchema, 53 | )] 54 | #[serde(default, rename_all = "camelCase")] 55 | pub struct Gog { 56 | /// Where the root is located on your system. 57 | pub path: StrictPath, 58 | } 59 | 60 | #[derive( 61 | Clone, 62 | Debug, 63 | Default, 64 | Eq, 65 | PartialEq, 66 | Ord, 67 | PartialOrd, 68 | Hash, 69 | serde::Serialize, 70 | serde::Deserialize, 71 | schemars::JsonSchema, 72 | )] 73 | #[serde(default, rename_all = "camelCase")] 74 | pub struct GogGalaxy { 75 | /// Where the root is located on your system. 76 | pub path: StrictPath, 77 | } 78 | 79 | #[derive( 80 | Clone, 81 | Debug, 82 | Default, 83 | Eq, 84 | PartialEq, 85 | Ord, 86 | PartialOrd, 87 | Hash, 88 | serde::Serialize, 89 | serde::Deserialize, 90 | schemars::JsonSchema, 91 | )] 92 | #[serde(default, rename_all = "camelCase")] 93 | pub struct Heroic { 94 | /// Where the root is located on your system. 95 | pub path: StrictPath, 96 | } 97 | 98 | #[derive( 99 | Clone, 100 | Debug, 101 | Default, 102 | Eq, 103 | PartialEq, 104 | Ord, 105 | PartialOrd, 106 | Hash, 107 | serde::Serialize, 108 | serde::Deserialize, 109 | schemars::JsonSchema, 110 | )] 111 | #[serde(default, rename_all = "camelCase")] 112 | pub struct Legendary { 113 | /// Where the root is located on your system. 114 | pub path: StrictPath, 115 | } 116 | 117 | #[derive( 118 | Clone, 119 | Debug, 120 | Default, 121 | Eq, 122 | PartialEq, 123 | Ord, 124 | PartialOrd, 125 | Hash, 126 | serde::Serialize, 127 | serde::Deserialize, 128 | schemars::JsonSchema, 129 | )] 130 | #[serde(default, rename_all = "camelCase")] 131 | pub struct Lutris { 132 | /// Where the root is located on your system. 133 | pub path: StrictPath, 134 | /// Full path to the Lutris `pga.db` file, if not contained within the main `path`. 135 | pub database: Option, 136 | } 137 | 138 | #[derive( 139 | Clone, 140 | Debug, 141 | Default, 142 | Eq, 143 | PartialEq, 144 | Ord, 145 | PartialOrd, 146 | Hash, 147 | serde::Serialize, 148 | serde::Deserialize, 149 | schemars::JsonSchema, 150 | )] 151 | #[serde(default, rename_all = "camelCase")] 152 | pub struct Microsoft { 153 | /// Where the root is located on your system. 154 | pub path: StrictPath, 155 | } 156 | 157 | #[derive( 158 | Clone, 159 | Debug, 160 | Default, 161 | Eq, 162 | PartialEq, 163 | Ord, 164 | PartialOrd, 165 | Hash, 166 | serde::Serialize, 167 | serde::Deserialize, 168 | schemars::JsonSchema, 169 | )] 170 | #[serde(default, rename_all = "camelCase")] 171 | pub struct Origin { 172 | /// Where the root is located on your system. 173 | pub path: StrictPath, 174 | } 175 | 176 | #[derive( 177 | Clone, 178 | Debug, 179 | Default, 180 | Eq, 181 | PartialEq, 182 | Ord, 183 | PartialOrd, 184 | Hash, 185 | serde::Serialize, 186 | serde::Deserialize, 187 | schemars::JsonSchema, 188 | )] 189 | #[serde(default, rename_all = "camelCase")] 190 | pub struct Prime { 191 | /// Where the root is located on your system. 192 | pub path: StrictPath, 193 | } 194 | 195 | #[derive( 196 | Clone, 197 | Debug, 198 | Default, 199 | Eq, 200 | PartialEq, 201 | Ord, 202 | PartialOrd, 203 | Hash, 204 | serde::Serialize, 205 | serde::Deserialize, 206 | schemars::JsonSchema, 207 | )] 208 | #[serde(default, rename_all = "camelCase")] 209 | pub struct Steam { 210 | /// Where the root is located on your system. 211 | pub path: StrictPath, 212 | } 213 | 214 | #[derive( 215 | Clone, 216 | Debug, 217 | Default, 218 | Eq, 219 | PartialEq, 220 | Ord, 221 | PartialOrd, 222 | Hash, 223 | serde::Serialize, 224 | serde::Deserialize, 225 | schemars::JsonSchema, 226 | )] 227 | #[serde(default, rename_all = "camelCase")] 228 | pub struct Uplay { 229 | /// Where the root is located on your system. 230 | pub path: StrictPath, 231 | } 232 | 233 | #[derive( 234 | Clone, 235 | Debug, 236 | Default, 237 | Eq, 238 | PartialEq, 239 | Ord, 240 | PartialOrd, 241 | Hash, 242 | serde::Serialize, 243 | serde::Deserialize, 244 | schemars::JsonSchema, 245 | )] 246 | #[serde(default, rename_all = "camelCase")] 247 | pub struct OtherHome { 248 | /// Where the root is located on your system. 249 | pub path: StrictPath, 250 | } 251 | 252 | #[derive( 253 | Clone, 254 | Debug, 255 | Default, 256 | Eq, 257 | PartialEq, 258 | Ord, 259 | PartialOrd, 260 | Hash, 261 | serde::Serialize, 262 | serde::Deserialize, 263 | schemars::JsonSchema, 264 | )] 265 | #[serde(default, rename_all = "camelCase")] 266 | pub struct OtherWine { 267 | /// Where the root is located on your system. 268 | pub path: StrictPath, 269 | } 270 | 271 | #[derive( 272 | Clone, 273 | Debug, 274 | Default, 275 | Eq, 276 | PartialEq, 277 | Ord, 278 | PartialOrd, 279 | Hash, 280 | serde::Serialize, 281 | serde::Deserialize, 282 | schemars::JsonSchema, 283 | )] 284 | #[serde(default, rename_all = "camelCase")] 285 | pub struct OtherWindows { 286 | /// Where the root is located on your system. 287 | pub path: StrictPath, 288 | } 289 | 290 | #[derive( 291 | Clone, 292 | Debug, 293 | Default, 294 | Eq, 295 | PartialEq, 296 | Ord, 297 | PartialOrd, 298 | Hash, 299 | serde::Serialize, 300 | serde::Deserialize, 301 | schemars::JsonSchema, 302 | )] 303 | #[serde(default, rename_all = "camelCase")] 304 | pub struct OtherLinux { 305 | /// Where the root is located on your system. 306 | pub path: StrictPath, 307 | } 308 | 309 | #[derive( 310 | Clone, 311 | Debug, 312 | Default, 313 | Eq, 314 | PartialEq, 315 | Ord, 316 | PartialOrd, 317 | Hash, 318 | serde::Serialize, 319 | serde::Deserialize, 320 | schemars::JsonSchema, 321 | )] 322 | #[serde(default, rename_all = "camelCase")] 323 | pub struct OtherMac { 324 | /// Where the root is located on your system. 325 | pub path: StrictPath, 326 | } 327 | 328 | #[derive( 329 | Clone, 330 | Debug, 331 | Default, 332 | Eq, 333 | PartialEq, 334 | Ord, 335 | PartialOrd, 336 | Hash, 337 | serde::Serialize, 338 | serde::Deserialize, 339 | schemars::JsonSchema, 340 | )] 341 | #[serde(default, rename_all = "camelCase")] 342 | pub struct Other { 343 | /// Where the root is located on your system. 344 | pub path: StrictPath, 345 | } 346 | -------------------------------------------------------------------------------- /src/scan/launchers/generic.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{HashMap, HashSet}; 2 | 3 | use fuzzy_matcher::FuzzyMatcher; 4 | use rayon::prelude::*; 5 | 6 | use crate::prelude::INVALID_FILE_CHARS; 7 | 8 | use crate::{ 9 | resource::{config::Root, manifest::Manifest}, 10 | scan::launchers::LauncherGame, 11 | }; 12 | 13 | fn make_fuzzy_matcher() -> fuzzy_matcher::skim::SkimMatcherV2 { 14 | fuzzy_matcher::skim::SkimMatcherV2::default() 15 | .ignore_case() 16 | .score_config(fuzzy_matcher::skim::SkimScoreConfig { 17 | penalty_case_mismatch: 0, 18 | ..Default::default() 19 | }) 20 | } 21 | 22 | fn fuzzy_match( 23 | matcher: &fuzzy_matcher::skim::SkimMatcherV2, 24 | reference: &str, 25 | candidate: &str, 26 | ideal: Option, 27 | ) -> Option { 28 | if reference == candidate { 29 | return Some(i64::MAX); 30 | } 31 | 32 | // A space-consolidating regex would be better, but is too much of a performance hit. 33 | // Also, this is used for files/folders, so we can always ignore illegal characters. 34 | let candidate = candidate 35 | .replace(['_', '-'], " ") 36 | .replace(INVALID_FILE_CHARS, " ") 37 | .replace(" ", " ") 38 | .replace(" ", " ") 39 | .replace(" ", " "); 40 | 41 | let actual = matcher.fuzzy_match(reference, &candidate); 42 | if let (Some(ideal), Some(actual)) = (ideal, actual) { 43 | if actual == ideal { 44 | return Some(i64::MAX); 45 | } else if actual > (ideal / 4 * 3) { 46 | return Some(actual); 47 | } 48 | } 49 | None 50 | } 51 | 52 | pub fn scan(root: &Root, manifest: &Manifest, subjects: &[String]) -> HashMap> { 53 | log::debug!("ranking installations for root: {:?}", &root); 54 | 55 | let install_parent = root.games_path(); 56 | let matcher = make_fuzzy_matcher(); 57 | 58 | let actual_dirs: Vec<_> = install_parent 59 | .read_dir() 60 | .map(|entries| { 61 | entries 62 | .filter_map(|entry| entry.ok()) 63 | .filter_map(|entry| match entry.file_type() { 64 | Ok(ft) if ft.is_dir() => Some(entry.file_name().to_string_lossy().to_string()), 65 | _ => None, 66 | }) 67 | .collect() 68 | }) 69 | .unwrap_or_default(); 70 | log::debug!("actual install folders: {}", actual_dirs.join(" | ")); 71 | 72 | let scores: Vec<_> = subjects 73 | .into_par_iter() 74 | .filter_map(|name| { 75 | let expected_install_dirs = manifest.0[name].install_dir.keys().chain(std::iter::once(name)); 76 | 77 | let mut best: Option<(i64, &String)> = None; 78 | 'dirs: for expected_dir in expected_install_dirs { 79 | log::trace!("[{name}] looking for install dir: {expected_dir}"); 80 | 81 | if expected_dir.contains(['/', '\\']) { 82 | if root.path().joined(expected_dir).is_dir() { 83 | log::trace!("[{name}] using exact nested install dir"); 84 | best = Some((i64::MAX, expected_dir)); 85 | break; 86 | } else { 87 | continue; 88 | } 89 | } 90 | 91 | let ideal = matcher.fuzzy_match(expected_dir, expected_dir); 92 | for actual_dir in &actual_dirs { 93 | let score = fuzzy_match(&matcher, expected_dir, actual_dir, ideal); 94 | if let Some(score) = score { 95 | if let Some((previous, _)) = best { 96 | if score > previous { 97 | log::trace!("[{name}] score {score} beats previous {previous}: {actual_dir}"); 98 | best = Some((score, actual_dir)); 99 | } 100 | } else { 101 | log::trace!("[{name}] new score {score}: {actual_dir}"); 102 | best = Some((score, actual_dir)); 103 | } 104 | } else { 105 | // irrelevant 106 | } 107 | if score == Some(i64::MAX) { 108 | break 'dirs; 109 | } 110 | } 111 | } 112 | best.map(|(score, subdir)| { 113 | log::debug!("[{name}] selecting subdir with score {score}: {subdir}"); 114 | (score, name, subdir) 115 | }) 116 | }) 117 | .collect(); 118 | 119 | let mut by_title = HashMap::::new(); 120 | for (score, name, subdir) in &scores { 121 | by_title 122 | .entry(name.to_string()) 123 | .and_modify(|(stored_score, stored_subdir)| { 124 | if score > stored_score { 125 | *stored_score = *score; 126 | *stored_subdir = subdir.to_string(); 127 | } 128 | }) 129 | .or_insert((*score, subdir.to_string())); 130 | } 131 | 132 | let mut by_subdir = HashMap::>::new(); 133 | for (_score, name, subdir) in &scores { 134 | by_subdir 135 | .entry(subdir.to_string()) 136 | .and_modify(|names| { 137 | names.push(name.to_string()); 138 | }) 139 | .or_insert(vec![name.to_string()]); 140 | } 141 | 142 | subjects 143 | .iter() 144 | .filter_map(|name| { 145 | let (score, subdir) = by_title.get(name)?; 146 | 147 | if *score < i64::MAX { 148 | if let Some(competitors) = by_subdir.get(subdir) { 149 | for competitor in competitors { 150 | if let Some((competitor_score, _)) = by_title.get(competitor) { 151 | if competitor_score > score { 152 | log::debug!("[{name}] outranked by '{competitor}' for subdir '{subdir}'"); 153 | return None; 154 | } 155 | } 156 | } 157 | } 158 | } 159 | 160 | Some(( 161 | name.clone(), 162 | HashSet::from_iter([LauncherGame { 163 | install_dir: Some(install_parent.joined(subdir)), 164 | prefix: None, 165 | platform: None, 166 | }]), 167 | )) 168 | }) 169 | .collect() 170 | } 171 | 172 | #[cfg(test)] 173 | mod tests { 174 | use pretty_assertions::assert_eq; 175 | 176 | use super::*; 177 | 178 | #[test] 179 | fn fuzzy_matching() { 180 | let matcher = make_fuzzy_matcher(); 181 | 182 | for (reference, candidate, output) in vec![ 183 | ("a", "a", Some(i64::MAX)), 184 | ("a", "b", None), 185 | ("Something", "Something", Some(i64::MAX)), 186 | // Too short: 187 | ("ab", "a", None), 188 | ("ab", "b", None), 189 | ("abc", "ab", None), 190 | // Long enough: 191 | ("abcd", "abc", Some(71)), 192 | ("A Fun Game", "a fun game", Some(i64::MAX)), 193 | ("A Fun Game", "a fun game", Some(i64::MAX)), 194 | ("A Fun Game", "AFunGame", Some(171)), 195 | ("A Fun Game", "A_Fun_Game", Some(i64::MAX)), 196 | ("A Fun Game", "A _ Fun _ Game", Some(i64::MAX)), 197 | ("A Fun Game", "a-fun-game", Some(i64::MAX)), 198 | ("A Fun Game", "a - fun - game", Some(i64::MAX)), 199 | ("A Fun Game", "A FUN GAME", Some(i64::MAX)), 200 | ("A Fun Game!", "A Fun Game", Some(219)), 201 | ("A Funner Game", "A Fun Game", Some(209)), 202 | ("A Fun Game 2", "A Fun Game", Some(219)), 203 | ] { 204 | assert_eq!( 205 | output, 206 | fuzzy_match( 207 | &matcher, 208 | reference, 209 | candidate, 210 | matcher.fuzzy_match(reference, reference) 211 | ) 212 | ); 213 | } 214 | } 215 | } 216 | --------------------------------------------------------------------------------