Skip to content
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
12 changes: 12 additions & 0 deletions backend/decky_loader/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,18 @@
"decky_version": "Decky Version",
"header": "About"
},
"backup": {
"export_button": "Export",
"export_error": "Export failed",
"export_label": "Export settings and data",
"export_success": "Settings exported",
"header": "Backup & Restore",
"import_button": "Import",
"import_count": "{{count}} files restored",
"import_error": "Import failed",
"import_label": "Import from backup",
"import_success": "Settings imported"
},
"beta": {
"header": "Beta participation"
},
Expand Down
76 changes: 75 additions & 1 deletion backend/decky_loader/utilities.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
from __future__ import annotations
from datetime import datetime
import json
import os
from os import path, stat_result
import shutil
import uuid
from urllib.parse import unquote
from json.decoder import JSONDecodeError
from os.path import splitext
import re
from traceback import format_exc
from stat import FILE_ATTRIBUTE_HIDDEN # pyright: ignore [reportAttributeAccessIssue, reportUnknownVariableType]
import zipfile

from asyncio import StreamReader, StreamWriter, sleep, start_server, gather, open_connection
from aiohttp import ClientSession, hdrs
Expand Down Expand Up @@ -83,6 +88,8 @@ def __init__(self, context: PluginManager) -> None:
context.ws.add_route("utilities/enable_plugin", self.enable_plugin)
context.ws.add_route("utilities/disable_plugin", self.disable_plugin)
context.ws.add_route("utilities/set_all_plugins_disabled", self.set_all_plugins_disabled)
context.ws.add_route("utilities/export_settings", self.export_settings)
context.ws.add_route("utilities/import_settings", self.import_settings)

context.web_app.add_routes([
post("/methods/{method_name}", self._handle_legacy_server_method_call)
Expand Down Expand Up @@ -513,4 +520,71 @@ async def set_all_plugins_disabled(self):
if name not in disabled_plugins:
disabled_plugins.append(name)

await self.set_setting("disabled_plugins", disabled_plugins)
await self.set_setting("disabled_plugins", disabled_plugins)

async def export_settings(self, destination_path: str) -> Dict[str, Any]:
homebrew_path = helpers.get_homebrew_path()
settings_dir = path.join(homebrew_path, "settings")
data_dir = path.join(homebrew_path, "data")

timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
if path.isdir(destination_path):
zip_path = path.join(destination_path, f"decky_backup_{timestamp}.zip")
else:
zip_path = destination_path

manifest = {
"version": helpers.get_loader_version(),
"date": datetime.now().isoformat(),
"plugins": list(self.context.plugin_loader.plugins.keys()),
}

with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr("manifest.json", json.dumps(manifest, indent=2))

if path.isdir(settings_dir):
for root, _, files in os.walk(settings_dir):
for file in files:
file_path = path.join(root, file)
arcname = path.join("settings", path.relpath(file_path, settings_dir))
zf.write(file_path, arcname)

if path.isdir(data_dir):
for root, _, files in os.walk(data_dir):
for file in files:
file_path = path.join(root, file)
arcname = path.join("data", path.relpath(file_path, data_dir))
zf.write(file_path, arcname)

self.logger.info(f"Settings exported to {zip_path}")
return {"path": zip_path, "size": path.getsize(zip_path)}

async def import_settings(self, zip_path: str) -> Dict[str, Any]:
homebrew_path = helpers.get_homebrew_path()
settings_dir = path.join(homebrew_path, "settings")
data_dir = path.join(homebrew_path, "data")

if not path.isfile(zip_path):
raise FileNotFoundError(f"File not found: {zip_path}")

restored: List[str] = []

with zipfile.ZipFile(zip_path, 'r') as zf:
for member in zf.namelist():
if member.startswith("settings/"):
target = path.join(settings_dir, member[len("settings/"):])
elif member.startswith("data/"):
target = path.join(data_dir, member[len("data/"):])
else:
continue

if member.endswith('/'):
Path(target).mkdir(parents=True, exist_ok=True)
else:
Path(path.dirname(target)).mkdir(parents=True, exist_ok=True)
with zf.open(member) as src, open(target, 'wb') as dst:
shutil.copyfileobj(src, dst)
restored.append(member)

self.logger.info(f"Settings imported from {zip_path}: {len(restored)} files restored")
return {"restored_count": len(restored), "files": restored}
91 changes: 90 additions & 1 deletion frontend/src/components/settings/pages/general/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
import { DialogBody, DialogControlsSection, DialogControlsSectionHeader, Field, Toggle } from '@decky/ui';
import { DialogBody, DialogButton, DialogControlsSection, DialogControlsSectionHeader, Field, Toggle } from '@decky/ui';
import { useTranslation } from 'react-i18next';

import { getSetting } from '../../../../utils/settings';
import Logger from '../../../../logger';
import { FileSelectionType } from '../../../modals/filepicker';
import { useDeckyState } from '../../../DeckyState';
import BranchSelect from './BranchSelect';
import NotificationSettings from './NotificationSettings';
import StoreSelect from './StoreSelect';
import UpdaterSettings from './Updater';

const logger = new Logger('GeneralSettings');

const exportSettings = DeckyBackend.callable<[destination_path: string], { path: string; size: number }>(
'utilities/export_settings',
);
const importSettings = DeckyBackend.callable<[zip_path: string], { restored_count: number; files: string[] }>(
'utilities/import_settings',
);

export default function GeneralSettings({
isDeveloper,
setIsDeveloper,
Expand Down Expand Up @@ -43,6 +55,83 @@ export default function GeneralSettings({
/>
</Field>
</DialogControlsSection>
<DialogControlsSection>
<DialogControlsSectionHeader>{t('SettingsGeneralIndex.backup.header')}</DialogControlsSectionHeader>
<Field label={t('SettingsGeneralIndex.backup.export_label')}>
<DialogButton
onClick={async () => {
const homePath = await getSetting<string>('user_info.user_home', '');
if (!homePath) {
logger.error('Could not determine home path');
return;
}
DeckyPluginLoader.openFilePicker(
FileSelectionType.FOLDER,
homePath,
true,
true,
undefined,
undefined,
false,
false,
).then(async (val) => {
try {
const result = await exportSettings(val.path);
DeckyPluginLoader.toaster.toast({
title: t('SettingsGeneralIndex.backup.export_success'),
body: result.path,
});
} catch (e) {
logger.error('Export failed', e);
DeckyPluginLoader.toaster.toast({
title: t('SettingsGeneralIndex.backup.export_error'),
body: String(e),
});
}
});
}}
>
{t('SettingsGeneralIndex.backup.export_button')}
</DialogButton>
</Field>
<Field label={t('SettingsGeneralIndex.backup.import_label')}>
<DialogButton
onClick={async () => {
const homePath = await getSetting<string>('user_info.user_home', '');
if (!homePath) {
logger.error('Could not determine home path');
return;
}
DeckyPluginLoader.openFilePicker(
FileSelectionType.FILE,
homePath,
true,
true,
undefined,
['zip'],
false,
false,
).then(async (val) => {
try {
const result = await importSettings(val.path);
DeckyPluginLoader.toaster.toast({
title: t('SettingsGeneralIndex.backup.import_success'),
body: t('SettingsGeneralIndex.backup.import_count', { count: result.restored_count }),
});
} catch (e) {
logger.error('Import failed', e);
DeckyPluginLoader.toaster.toast({
title: t('SettingsGeneralIndex.backup.import_error'),
body: String(e),
});
}
});
}}
>
{t('SettingsGeneralIndex.backup.import_button')}
</DialogButton>
</Field>
</DialogControlsSection>
<DialogControlsSection>
<DialogControlsSectionHeader>{t('SettingsGeneralIndex.about.header')}</DialogControlsSectionHeader>
<Field label={t('SettingsGeneralIndex.about.decky_version')} focusable={true}>
Expand Down