Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[UX] Add option to backup saves after closing a game #4247

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,10 @@
"version": "Wine Version"
},
"infobox": {
"backupSaves": {
"details": "Backups are stored in Heroic&apos;s config folder, in `savesBackups/{appName}`.<3></3><4></4>To restore a backup, you must do it manually. This is intended as a safety measure and not as a full backup system.<6></6><7></7>You may want to delete old backups periodically to free up space.",
"title": "About Heroic backups"
},
"help": "Help",
"requirements": "System Requirements",
"warning": "Warning",
Expand Down Expand Up @@ -592,6 +596,7 @@
"autosync": "Autosync Saves",
"autoUpdateGames": "Automatically update games",
"autovkd3d": "Auto Install/Update VKD3D on Prefix",
"backupSavesAfterClosingGame": "Backup saves in Heroic's config after closing a game.",
"before-launch-script-path": "Select a script to run before the game is launched",
"change-target-exe": "Select an alternative EXE to run",
"checkForUpdatesOnStartup": "Check for Heroic Updates on Startup",
Expand Down
3 changes: 2 additions & 1 deletion src/backend/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,8 @@ class GlobalConfigV0 extends GlobalConfig {
framelessWindow: false,
beforeLaunchScriptPath: '',
afterLaunchScriptPath: '',
disableUMU: false
disableUMU: false,
backupSavesAfterClosingGame: true
}
// @ts-expect-error TODO: We need to settle on *one* place to define settings defaults
return settings
Expand Down
4 changes: 3 additions & 1 deletion src/backend/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ const defaultWinePrefix = join(defaultWinePrefixDir, 'default')
const anticheatDataPath = join(appFolder, 'areweanticheatyet.json')
const imagesCachePath = join(appFolder, 'images-cache')
const fixesPath = join(appFolder, 'fixes')
const savesBackupsPath = join(appFolder, 'savesBackups')

const {
currentLogFile,
Expand Down Expand Up @@ -295,5 +296,6 @@ export {
nileLibrary,
nileUserData,
fixesPath,
thirdPartyInstalled
thirdPartyInstalled,
savesBackupsPath
}
2 changes: 2 additions & 0 deletions src/backend/game_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ class GameConfigV0 extends GameConfig {
battlEyeRuntime,
beforeLaunchScriptPath,
afterLaunchScriptPath,
backupSavesAfterClosingGame,
gamescope
} = GlobalConfig.get().getSettings()

Expand Down Expand Up @@ -266,6 +267,7 @@ class GameConfigV0 extends GameConfig {
language: '', // we want to fallback to '' always here, fallback lang for games should be ''
beforeLaunchScriptPath,
afterLaunchScriptPath,
backupSavesAfterClosingGame,
gamescope
} as GameSettings

Expand Down
11 changes: 10 additions & 1 deletion src/backend/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,12 @@ const launchEventCallback: (args: LaunchParams) => StatusPromise = async ({
}) => {
const game = gameManagerMap[runner].getGameInfo(appName)
const gameSettings = await gameManagerMap[runner].getSettings(appName)
const { autoSyncSaves, savesPath, gogSaves = [] } = gameSettings
const {
autoSyncSaves,
savesPath,
gogSaves = [],
backupSavesAfterClosingGame
} = gameSettings

const { title } = game

Expand Down Expand Up @@ -259,6 +264,10 @@ const launchEventCallback: (args: LaunchParams) => StatusPromise = async ({
}
await addRecentGame(game)

if (autoSyncSaves && backupSavesAfterClosingGame) {
await gameManagerMap[runner].backupSaves(appName, savesPath, gogSaves)
}

if (autoSyncSaves && isOnline()) {
sendGameStatusUpdate({
appName,
Expand Down
26 changes: 26 additions & 0 deletions src/backend/storeManagers/gog/games.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ import ini from 'ini'
import { getRequiredRedistList, updateRedist } from './redist'
import { spawn } from 'child_process'
import { getUmuId } from 'backend/wiki_game_info/umu/utils'
import { copySavesIntoBackup } from '../storeManagerCommon/games'

export async function getExtraInfo(appName: string): Promise<ExtraInfo> {
const gameInfo = getGameInfo(appName)
Expand Down Expand Up @@ -877,6 +878,31 @@ export async function syncSaves(
return fullOutput
}

export async function backupSaves(
appName: string,
path: string,
gogSaves?: GOGCloudSavesLocation[]
) {
if (!gogSaves) {
logError(
'No gogSaves paths given, nothing to backup. Check your settings!',
LogPrefix.Gog
)
return
}

for (const savePath of gogSaves) {
if (!existsSync(savePath.location)) {
logError(
`Saves path '${savePath.location}' does no exist, nothing to backup.`,
LogPrefix.Gog
)
} else {
copySavesIntoBackup(appName, savePath.location, savePath.name)
}
}
}

export async function uninstall({
appName,
shouldRemovePrefix
Expand Down
21 changes: 21 additions & 0 deletions src/backend/storeManagers/legendary/games.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ import { getUmuId } from 'backend/wiki_game_info/umu/utils'
import thirdParty from './thirdParty'
import { Path } from 'backend/schemas'
import { mkdirSync } from 'fs'
import { copySavesIntoBackup } from '../storeManagerCommon/games'

/**
* Alias for `LegendaryLibrary.listUpdateableGames`
Expand Down Expand Up @@ -835,6 +836,26 @@ export async function syncSaves(
return fullOutput
}

export async function backupSaves(appName: string, path: string) {
if (!path) {
logError(
'No path provided for SavesSync, nothing to backup. Check your settings!',
LogPrefix.Legendary
)
return
}

if (!existsSync(path)) {
logError(
'Saves path does no exist, nothing to backup.',
LogPrefix.Legendary
)
return
}

copySavesIntoBackup(appName, path)
}

export async function launch(
appName: string,
launchArguments?: LaunchOption,
Expand Down
4 changes: 4 additions & 0 deletions src/backend/storeManagers/nile/games.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,10 @@ export async function syncSaves(): Promise<string> {
return ''
}

export async function backupSaves() {
// Amazon Games doesn't support cloud saves
}

export async function uninstall({ appName }: RemoveArgs): Promise<ExecResult> {
const commandParts = ['uninstall', appName]

Expand Down
6 changes: 6 additions & 0 deletions src/backend/storeManagers/sideload/games.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,12 @@ export async function syncSaves(
return ''
}

export async function backupSaves(appName: string) {
logWarning(
`backupSaves not implemented on Sideload Game Manager. called for appName = ${appName}`
)
}

export async function forceUninstall(appName: string): Promise<void> {
logWarning(
`forceUninstall not implemented on Sideload Game Manager. called for appName = ${appName}`
Expand Down
49 changes: 46 additions & 3 deletions src/backend/storeManagers/storeManagerCommon/games.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import { GameInfo, GameSettings, Runner } from 'common/types'
import { GameConfig } from '../../game_config'
import { isMac, isLinux, icon } from '../../constants'
import { isMac, isLinux, icon, savesBackupsPath } from '../../constants'
import {
appendGamePlayLog,
appendWinetricksGamePlayLog,
lastPlayLogFileLocation,
logError,
logInfo,
LogPrefix,
logsDisabled,
logWarning
} from '../../logger/logger'
import { basename, dirname } from 'path'
import { constants as FS_CONSTANTS } from 'graceful-fs'
import { basename, dirname, join } from 'path'
import {
cpSync,
existsSync,
constants as FS_CONSTANTS,
mkdirSync
} from 'graceful-fs'
import i18next from 'i18next'
import {
callRunner,
Expand Down Expand Up @@ -273,3 +279,40 @@ export async function launchGame(
}
return false
}

export function copySavesIntoBackup(
appName: string,
savesPath: string,
subfolder?: string
) {
logInfo(`Creating backup of ${savesPath} for ${appName}`)

const timeStamp = new Date().toISOString().replace(/\.\d+Z/g, '')
let gameBackupsFolder = join(savesBackupsPath, appName)

// support GOGs multiple save paths for a single game using the save path name as a subfolder
if (subfolder) gameBackupsFolder = join(gameBackupsFolder, subfolder)

if (!existsSync(gameBackupsFolder)) {
try {
mkdirSync(gameBackupsFolder, { recursive: true })
} catch (error) {
logError(`Backup folder for ${appName} could not be created.`)
logError(error)
return
}
}

try {
cpSync(savesPath, join(gameBackupsFolder, timeStamp.toString()), {
recursive: true
})

logInfo(`Backup of ${savesPath} for ${appName} completed.`)
} catch (error) {
logError(
`An error ocurred while creating a backup of the saves of ${appName}.`
)
logError(error)
}
}
1 change: 1 addition & 0 deletions src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ export interface GameSettings {
beforeLaunchScriptPath: string
afterLaunchScriptPath: string
disableUMU: boolean
backupSavesAfterClosingGame: boolean
}

export type Status =
Expand Down
5 changes: 5 additions & 0 deletions src/common/types/game_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ export interface GameManager {
path: string,
gogSaves?: GOGCloudSavesLocation[]
) => Promise<string>
backupSaves: (
appName: string,
path: string,
gogSaves?: GOGCloudSavesLocation[]
) => Promise<void>
uninstall: (args: RemoveArgs) => Promise<ExecResult>
update: (
appName: string,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React, { useContext } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { InfoBox, ToggleSwitch } from 'frontend/components/UI'
import useSetting from 'frontend/hooks/useSetting'
import SettingsContext from '../SettingsContext'

const BackupSavesAfterClosingGame = () => {
const { t, i18n } = useTranslation()
const { appName } = useContext(SettingsContext)

const [backupSavesAfterClosingGame, setBackupSavesAfterClosingGame] =
useSetting('backupSavesAfterClosingGame', false)
const [autoSyncSaves] = useSetting('autoSyncSaves', false)

if (!autoSyncSaves) {
return <></>
}

return (
<div>
<ToggleSwitch
htmlId="backupSavesAfterClosingGame"
value={backupSavesAfterClosingGame}
handleChange={() =>
setBackupSavesAfterClosingGame(!backupSavesAfterClosingGame)
}
title={t(
'setting.backupSavesAfterClosingGame',
"Backup saves in Heroic's config after closing a game."
)}
/>

<InfoBox text={t('infobox.backupSaves.title', 'About Heroic backups')}>
<Trans i18n={i18n} key="infobox.backupSaves.details">
Backups are stored in Heroic&apos;s config folder, in `savesBackups/
{appName}`.
<br />
<br />
To restore a backup, you must do it manually. This is intended as a
safety measure and not as a full backup system.
<br />
<br />
You may want to delete old backups periodically to free up space.
</Trans>
</InfoBox>
</div>
)
}

export default BackupSavesAfterClosingGame
3 changes: 3 additions & 0 deletions src/frontend/screens/Settings/sections/SyncSaves/gog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'
import { ProgressDialog } from 'frontend/components/UI/ProgressDialog'
import SettingsContext from '../../SettingsContext'
import TextWithProgress from 'frontend/components/UI/TextWithProgress'
import BackupSavesAfterClosingGame from '../../components/BackupSavesAfterClosingGame'

interface Props {
gogSaves: GOGCloudSavesLocation[]
Expand Down Expand Up @@ -203,6 +204,8 @@ export default function GOGSyncSaves({
<li>{t('help.sync.part4')}</li>
</ul>
</InfoBox>

<BackupSavesAfterClosingGame />
</>
)}
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { SyncType } from 'frontend/types'
import { ProgressDialog } from 'frontend/components/UI/ProgressDialog'
import SettingsContext from '../../SettingsContext'
import TextWithProgress from 'frontend/components/UI/TextWithProgress'
import BackupSavesAfterClosingGame from '../../components/BackupSavesAfterClosingGame'

interface Props {
autoSyncSaves: boolean
Expand Down Expand Up @@ -177,6 +178,8 @@ export default function LegendarySyncSaves({
<li>{t('help.sync.part4')}</li>
</ul>
</InfoBox>

<BackupSavesAfterClosingGame />
</>
)}
</>
Expand Down
Loading