diff --git a/backend/decky_loader/locales/en-US.json b/backend/decky_loader/locales/en-US.json
index 1f87fe3bf..2ac4c931e 100644
--- a/backend/decky_loader/locales/en-US.json
+++ b/backend/decky_loader/locales/en-US.json
@@ -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"
},
diff --git a/backend/decky_loader/utilities.py b/backend/decky_loader/utilities.py
index c74ac5d14..f56a299b7 100644
--- a/backend/decky_loader/utilities.py
+++ b/backend/decky_loader/utilities.py
@@ -1,5 +1,9 @@
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
@@ -7,6 +11,7 @@
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
@@ -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)
@@ -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)
\ No newline at end of file
+ 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}
\ No newline at end of file
diff --git a/frontend/src/components/settings/pages/general/index.tsx b/frontend/src/components/settings/pages/general/index.tsx
index 84ad88f13..5b89ea7ad 100644
--- a/frontend/src/components/settings/pages/general/index.tsx
+++ b/frontend/src/components/settings/pages/general/index.tsx
@@ -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,
@@ -43,6 +55,83 @@ export default function GeneralSettings({
/>
+
+ {t('SettingsGeneralIndex.backup.header')}
+
+ {
+ const homePath = await getSetting('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')}
+
+
+
+ {
+ const homePath = await getSetting('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')}
+
+
+
{t('SettingsGeneralIndex.about.header')}