Skip to content

Commit

Permalink
Merge pull request #235 from sluedecke/feature/launcher
Browse files Browse the repository at this point in the history
Feature: Automatic backup after playing a game
  • Loading branch information
mtkennerly authored Dec 10, 2023
2 parents cfbbe3e + 34e110b commit c886676
Show file tree
Hide file tree
Showing 18 changed files with 599 additions and 66 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/target
/dist
/tmp
assets/*~
tests/root3/game5/data-symlink
*~
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
## Unreleased

* Added:
* CLI: `wrap` command to do a restore before playing a game and a backup afterwards.
([Contributed by sluedecke](https://github.com/mtkennerly/ludusavi/pull/235))
* When a path or URL fails to open, additional information is now logged.
* Fixed:
* When storing file modified times in zip archives,
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,30 @@ but you can customize this by setting the `RUST_LOG` environment variable
(e.g., `RUST_LOG=ludusavi=debug`).
The most recent 5 log files are kept, rotating on app launch or when a log reaches 10 MiB.

<!-- TODO: Uncomment before release
### Game launch wrapping
The CLI has a `wrap` command that can be used as a wrapper around launching a game.
When wrapped, Ludusavi will restore data for the game first, launch it, and back up after playing.
If you want to use this feature, you must manually configure your game launcher app to use this command.
If you use Heroic 2.9.2 or newer, you can run `wrap --infer heroic -- GAME_INVOCATION` to automatically check the game name.
For other launcher apps, you can run `wrap --name GAME_NAME -- GAME_INVOCATION`.
#### Example with Heroic 2.9.2 on Linux
Create a file named `ludusavi-wrap.sh` with this content:
```
$!/bin/sh
ludusavi --try-manifest-update --config $HOME/.config/ludusavi wrap --gui --infer heroic -- "$@"
```
Mark the file as executable and set it as a wrapper within Heroic.
You must set it as a wrapper for each game already installed individually.
Note that the `--config` option is required because Heroic overrides the `XDG_CONFIG_HOME` environment variable,
which would otherwise prevent Ludusavi from finding its configuration.
-->

## Interfaces
### CLI API
CLI mode defaults to a human-readable format, but you can switch to a
Expand Down
12 changes: 12 additions & 0 deletions lang/en-US.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,15 @@ prefix-warning = Warning: {$message}
cloud-app-unavailable = Cloud backups are disabled because {$app} is not available.
cloud-not-configured = Cloud backups are disabled because no cloud system is configured.
cloud-path-invalid = Cloud backups are disabled because the backup path is invalid.
game-is-unrecognized = Ludusavi does not recognize this game.
game-has-nothing-to-restore = This game does not have a backup to restore.
launch-game-after-error = Launch the game anyway?
game-did-not-launch = Game failed to launch.
back-up-specific-game =
.confirm = Back up save data for {$game}?
.failed = Failed to back up save data for {$game}
restore-specific-game =
.confirm = Restore save data for {$game}?
.failed = Failed to restore save data for {$game}
151 changes: 150 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
mod parse;
mod report;
mod ui;

use std::{fmt::Debug, process::Command};

use clap::CommandFactory;
use indicatif::{ParallelProgressIterator, ProgressBar};
Expand All @@ -24,6 +27,7 @@ use crate::{
layout::BackupLayout, prepare_backup_target, scan_game_for_backup, BackupId, DuplicateDetector, Launchers,
OperationStepDecision, SteamShortcuts, TitleFinder,
},
wrap::{heroic::infer_game_from_heroic, WrapGameInfo},
};

#[derive(Clone, Debug, Default)]
Expand Down Expand Up @@ -761,8 +765,153 @@ pub fn run(sub: Subcommand, no_manifest_update: bool, try_manifest_update: bool)
report_cloud_changes(&changes, api);
}
},
}
Subcommand::Wrap {
name_source,
gui,
commands,
} => {
// Determine raw game identifiers
let wrap_game_info = if let Some(name) = name_source.name.as_ref() {
Some(WrapGameInfo {
name: Some(name.clone()),
..Default::default()
})
} else if let Some(infer) = name_source.infer {
let roots = config.expanded_roots();
match infer {
parse::LauncherTypes::Heroic => infer_game_from_heroic(&roots, &commands),
}
} else {
unreachable!();
};
log::debug!("Wrap game info: {:?}", &wrap_game_info);

// Check game identifiers against the manifest
//
// e.g. "Slain: Back From Hell" from legendary to "Slain: Back from
// Hell" as known to ludusavi
let manifest = load_manifest(&config, &mut cache, no_manifest_update, try_manifest_update)?;
let layout = BackupLayout::new(config.restore.path.clone(), config.backup.retention.clone());
let title_finder = TitleFinder::new(&manifest, &layout);
let game_name = wrap_game_info.as_ref().and_then(|wrap_game_info| {
title_finder.maybe_find_one(wrap_game_info.name.as_ref(), None, wrap_game_info.gog_id, true)
});
log::debug!("Title finder result: {:?}", &game_name);

if game_name.is_none()
&& !ui::confirm_with_question(
gui,
&TRANSLATOR.game_is_unrecognized(),
&TRANSLATOR.launch_game_after_error(),
)?
{
return Ok(());
}

// Restore
//
// TODO.2023-07-12 detect if there are differences between backed up
// and actual saves and skip the question if there is none
'restore: {
let Some(game_name) = game_name.as_ref() else {
break 'restore;
};

let game_layout = layout.game_layout(game_name);
if !game_layout.has_backups() {
if ui::confirm_with_question(
gui,
&TRANSLATOR.game_has_nothing_to_restore(),
&TRANSLATOR.launch_game_after_error(),
)? {
break 'restore;
} else {
return Ok(());
}
}

if !ui::confirm(gui, &TRANSLATOR.restore_one_game_confirm(game_name))? {
break 'restore;
}

if let Err(err) = run(
Subcommand::Restore {
games: vec![game_name.clone()],
force: true,
preview: Default::default(),
path: Default::default(),
api: Default::default(),
sort: Default::default(),
backup: Default::default(),
cloud_sync: Default::default(),
no_cloud_sync: Default::default(),
},
no_manifest_update,
try_manifest_update,
) {
log::error!("WRAP::restore: failed for game {:?} with: {:?}", wrap_game_info, err);
ui::alert_with_error(gui, &TRANSLATOR.restore_one_game_failed(game_name), &err)?;
return Err(err);
}
}

// Launch game
//
// TODO.2023-07-12 legendary returns immediately, handle this!
let result = Command::new(&commands[0]).args(&commands[1..]).status();
match result {
Ok(status) => {
// TODO.2023-07-14 handle return status which indicate an error condition, e.g. != 0
log::debug!("WRAP::execute: Game command executed, returning status: {:#?}", status);
}
Err(err) => {
log::error!("WRAP::execute: Game command execution failed with: {:#?}", err);
ui::alert_with_raw_error(gui, &TRANSLATOR.game_did_not_launch(), &err.to_string())?;
return Err(Error::GameDidNotLaunch { why: err.to_string() });
}
}

// Backup
'backup: {
let Some(game_name) = game_name.as_ref() else {
break 'backup;
};

if !ui::confirm(gui, &TRANSLATOR.back_up_one_game_confirm(game_name))? {
break 'backup;
}

if let Err(err) = run(
Subcommand::Backup {
games: vec![game_name.clone()],
force: true,
preview: Default::default(),
path: Default::default(),
merge: Default::default(),
no_merge: Default::default(),
update: Default::default(),
try_update: Default::default(),
wine_prefix: Default::default(),
api: Default::default(),
sort: Default::default(),
format: Default::default(),
compression: Default::default(),
compression_level: Default::default(),
full_limit: Default::default(),
differential_limit: Default::default(),
cloud_sync: Default::default(),
no_cloud_sync: Default::default(),
},
no_manifest_update,
try_manifest_update,
) {
log::error!("WRAP::backup: failed with: {:#?}", err);
ui::alert_with_error(gui, &TRANSLATOR.back_up_one_game_failed(game_name), &err)?;
return Err(err);
}
}
}
}
if failed {
Err(Error::SomeEntriesFailed)
} else {
Expand Down
38 changes: 38 additions & 0 deletions src/cli/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ use crate::{
resource::config::{BackupFormat, Sort, SortKey, ZipCompression},
};

use clap::{ArgGroup, Args, ValueEnum};

macro_rules! possible_values {
($t: ty, $options: ident) => {{
use clap::builder::{PossibleValuesParser, TypedValueParser};
Expand Down Expand Up @@ -99,6 +101,12 @@ impl From<CliSort> for Sort {
}
}

/// Supported launchers for wrap --infer command
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
pub enum LauncherTypes {
Heroic,
}

#[derive(clap::Subcommand, Clone, Debug, PartialEq, Eq)]
pub enum Subcommand {
/// Back up data
Expand Down Expand Up @@ -336,6 +344,21 @@ pub enum Subcommand {
#[clap(subcommand)]
sub: CloudSubcommand,
},
/// Wrap restore/backup around game execution
Wrap {
#[clap(flatten)]
name_source: WrapSubcommand,

/// Show a GUI notification during restore/backup
#[clap(long)]
gui: bool,

/// Commands to launch the game.
/// Use `--` first to separate these from the `wrap` options;
/// e.g., `ludusavi wrap --name foo -- foo.exe --windowed`.
#[clap()]
commands: Vec<String>,
},
}

#[derive(clap::Subcommand, Clone, Debug, PartialEq, Eq)]
Expand Down Expand Up @@ -491,6 +514,21 @@ pub enum CloudSetSubcommand {
},
}

#[derive(Args, Clone, Debug, PartialEq, Eq)]
#[clap(group(ArgGroup::new("name_source_group")
.required(true)
.multiple(false)
.args(&["infer", "name"])))]
pub struct WrapSubcommand {
/// Infer game name from commands based on launcher type
#[clap(long, value_enum, value_name = "LAUNCHER")]
pub infer: Option<LauncherTypes>,

/// Directly set game name as known to Ludusavi
#[clap(long)]
pub name: Option<String>,
}

/// Back up and restore PC game saves
#[derive(clap::Parser, Clone, Debug, PartialEq, Eq)]
#[clap(name = "ludusavi", version, term_width = 79)]
Expand Down
Loading

0 comments on commit c886676

Please sign in to comment.