diff --git a/backend/backup_manager.py b/backend/backup_manager.py new file mode 100644 index 0000000..cd624b7 --- /dev/null +++ b/backend/backup_manager.py @@ -0,0 +1,337 @@ +"""Backup and restore functionality for Steam configuration folders.""" + +from __future__ import annotations + +import json +import os +import shutil +import threading +import time +import zipfile +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List + +from logger import logger +from paths import backend_path + +BACKUP_LOCK = threading.Lock() + +# Folders to backup +FOLDERS_TO_BACKUP = [ + "C:\\Program Files (x86)\\Steam\\config\\depotcache", + "C:\\Program Files (x86)\\Steam\\config\\stplug-in", +] + + +def _get_backup_dir() -> str: + """Get the backup directory path (user's Downloads folder).""" + try: + # Get user's Downloads folder + downloads_path = str(Path.home() / "Downloads" / "LuaTools Backups") + os.makedirs(downloads_path, exist_ok=True) + return downloads_path + except Exception as e: + logger.error(f"Failed to get Downloads folder: {e}") + # Fallback to plugin backup dir if Downloads fails + backup_path = backend_path("backups") + os.makedirs(backup_path, exist_ok=True) + return backup_path + + +def _get_timestamp() -> str: + """Get current timestamp for backup naming.""" + return datetime.now().strftime("%Y%m%d_%H%M%S") + + +def create_backup(backup_name: str = "", destination: str = "") -> Dict[str, Any]: + """Create a backup of Steam config folders. + + Args: + backup_name: Optional name for the backup (default: timestamp) + destination: Optional destination path (default: plugin backup dir) + + Returns: + Dict with success status and backup info + """ + try: + if not backup_name: + backup_name = f"steam_config_backup_{_get_timestamp()}" + + # Use default destination if not provided + if not destination: + destination = _get_backup_dir() + else: + # Ensure destination directory exists + os.makedirs(destination, exist_ok=True) + + backup_path = os.path.join(destination, f"{backup_name}.zip") + + # Check if backup already exists + if os.path.exists(backup_path): + return { + "success": False, + "error": f"Backup file already exists: {backup_path}", + } + + logger.log(f"LuaTools: Creating backup to {backup_path}") + + with zipfile.ZipFile(backup_path, 'w', zipfile.ZIP_DEFLATED) as zipf: + for folder_path in FOLDERS_TO_BACKUP: + if os.path.exists(folder_path): + folder_name = os.path.basename(folder_path) + logger.log(f"LuaTools: Backing up {folder_path}") + + # Add entire folder to zip + for root, dirs, files in os.walk(folder_path): + for file in files: + file_path = os.path.join(root, file) + # Calculate archive name (relative path) + arcname = os.path.relpath(file_path, os.path.dirname(folder_path)) + zipf.write(file_path, arcname) + else: + logger.warn(f"LuaTools: Folder not found: {folder_path}") + + file_size = os.path.getsize(backup_path) + logger.log(f"LuaTools: Backup created successfully: {backup_path} ({file_size} bytes)") + + return { + "success": True, + "backup_path": backup_path, + "backup_name": backup_name, + "file_size": file_size, + "timestamp": _get_timestamp(), + "message": f"Backup created successfully at {backup_path}", + } + + except Exception as exc: + logger.error(f"LuaTools: Backup creation failed: {exc}") + return { + "success": False, + "error": str(exc), + } + + +def restore_backup(backup_path: str, restore_location: str = "") -> Dict[str, Any]: + """Restore a backup of Steam config folders. + + Args: + backup_path: Path to the backup zip file + restore_location: Optional location to restore to (default: original locations) + + Returns: + Dict with success status and restore info + """ + try: + if not os.path.exists(backup_path): + return { + "success": False, + "error": f"Backup file not found: {backup_path}", + } + + if not backup_path.endswith('.zip'): + return { + "success": False, + "error": "Invalid backup file: must be a .zip file", + } + + logger.log(f"LuaTools: Restoring backup from {backup_path}") + + with zipfile.ZipFile(backup_path, 'r') as zipf: + if restore_location: + # Extract to custom location + os.makedirs(restore_location, exist_ok=True) + zipf.extractall(restore_location) + logger.log(f"LuaTools: Backup restored to {restore_location}") + else: + # Extract to original locations + # Handle various archive path layouts. create_backup writes files + # with arcname relative to the parent of the config folder, e.g. + # "depotcache/..." or "stplug-in/...". Some older archives may + # include a "Steam/config/" prefix. Normalize and handle both. + steam_config_dir = os.path.join("C:\\Program Files (x86)\\Steam", "config") + + for member in zipf.namelist(): + # Normalize path separators and strip any leading ./ + norm = member.replace('\\', '/').lstrip('./') + + relative_path = None + + # Common case: arcname like 'depotcache/...' or 'stplug-in/...' + if norm.startswith('depotcache/') or norm == 'depotcache' or norm.startswith('stplug-in/') or norm == 'stplug-in': + relative_path = norm + + # Older/alternative case: 'Steam/config/depotcache/...' + elif '/Steam/config/' in norm: + relative_path = norm.split('/Steam/config/', 1)[1] + + # Alternative case: 'Steam/config' as prefix without leading slash + elif norm.startswith('Steam/config/'): + relative_path = norm[len('Steam/config/') :] + + if not relative_path: + # Not part of config backup, skip + continue + + target_path = os.path.join(steam_config_dir, *relative_path.split('/')) + + # Directory entry + if norm.endswith('/'): + os.makedirs(target_path, exist_ok=True) + continue + + # Ensure parent directories exist + os.makedirs(os.path.dirname(target_path), exist_ok=True) + + # Extract file contents + with zipf.open(member) as source, open(target_path, 'wb') as target: + shutil.copyfileobj(source, target) + + logger.log(f"LuaTools: Backup restored to original locations") + + return { + "success": True, + "backup_path": backup_path, + "message": "Backup restored successfully", + } + + except Exception as exc: + logger.error(f"LuaTools: Backup restoration failed: {exc}") + return { + "success": False, + "error": str(exc), + } + + +def get_backups_list(backup_location: str = "") -> Dict[str, Any]: + """Get list of available backups. + + Args: + backup_location: Optional location to search for backups (default: plugin backup dir) + + Returns: + Dict with list of backups + """ + try: + if not backup_location: + backup_location = _get_backup_dir() + + if not os.path.exists(backup_location): + return { + "success": True, + "backups": [], + "message": "No backups found", + } + + backups = [] + for file in os.listdir(backup_location): + if file.endswith('.zip'): + file_path = os.path.join(backup_location, file) + file_size = os.path.getsize(file_path) + mod_time = os.path.getmtime(file_path) + mod_date = datetime.fromtimestamp(mod_time).strftime("%Y-%m-%d %H:%M:%S") + + backups.append({ + "name": file, + "path": file_path, + "size": file_size, + "size_mb": round(file_size / (1024 * 1024), 2), + "date": mod_date, + }) + + # Sort by modification time (newest first) + backups.sort(key=lambda x: x["date"], reverse=True) + + return { + "success": True, + "backups": backups, + "count": len(backups), + } + + except Exception as exc: + logger.error(f"LuaTools: Failed to list backups: {exc}") + return { + "success": False, + "error": str(exc), + } + + +def delete_backup(backup_path: str) -> Dict[str, Any]: + """Delete a backup file. + + Args: + backup_path: Path to the backup zip file + + Returns: + Dict with success status + """ + try: + if not os.path.exists(backup_path): + return { + "success": False, + "error": f"Backup file not found: {backup_path}", + } + + os.remove(backup_path) + logger.log(f"LuaTools: Backup deleted: {backup_path}") + + return { + "success": True, + "message": f"Backup deleted successfully", + } + + except Exception as exc: + logger.error(f"LuaTools: Failed to delete backup: {exc}") + return { + "success": False, + "error": str(exc), + } + + +def open_backup_location(backup_path: str) -> Dict[str, Any]: + """Open the backup file location in file manager. + + Args: + backup_path: Path to the backup zip file + + Returns: + Dict with success status + """ + try: + if not os.path.exists(backup_path): + return { + "success": False, + "error": f"Backup file not found: {backup_path}", + } + + import subprocess + import sys + + # Normalize path + backup_path = os.path.normpath(backup_path) + + # Open file manager with file selected + if sys.platform == "win32": + # Windows: use explorer with /select to highlight the file + subprocess.Popen(f'explorer /select,"{backup_path}"') + elif sys.platform == "darwin": + # macOS: use open command + subprocess.Popen(["open", "-R", backup_path]) + else: + # Linux: open the directory + dir_path = os.path.dirname(backup_path) + subprocess.Popen(["xdg-open", dir_path]) + + logger.log(f"LuaTools: Opened backup location: {backup_path}") + + return { + "success": True, + "message": "File location opened in file manager", + } + + except Exception as exc: + logger.error(f"LuaTools: Failed to open backup location: {exc}") + return { + "success": False, + "error": str(exc), + } diff --git a/backend/main.py b/backend/main.py index ee732a3..8a3e3c7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -34,6 +34,15 @@ read_loaded_apps, start_add_via_luatools, ) + +from backup_manager import ( + create_backup, + delete_backup, + get_backups_list, + open_backup_location, + restore_backup, +) + from fixes import ( apply_game_fix, cancel_apply_fix, @@ -364,6 +373,103 @@ def GetTranslations(contentScriptQuery: str = "", language: str = "", **kwargs: logger.warn(f"LuaTools: GetTranslations failed: {exc}") return json.dumps({"success": False, "error": str(exc)}) +def CreateBackup(backup_name: str = "", destination: str = "", contentScriptQuery: str = "") -> str: + """Create a backup of Steam config folders.""" + try: + result = create_backup(backup_name, destination) + return json.dumps(result) + except Exception as exc: + logger.warn(f"LuaTools: CreateBackup failed: {exc}") + return json.dumps({"success": False, "error": str(exc)}) + + +def RestoreBackup(backup_path: str, restore_location: str = "", contentScriptQuery: str = "") -> str: + """Restore a backup of Steam config folders.""" + try: + result = restore_backup(backup_path, restore_location) + return json.dumps(result) + except Exception as exc: + logger.warn(f"LuaTools: RestoreBackup failed: {exc}") + return json.dumps({"success": False, "error": str(exc)}) + + +def GetBackupsList(backup_location: str = "", contentScriptQuery: str = "") -> str: + """Get list of available backups.""" + try: + result = get_backups_list(backup_location) + return json.dumps(result) + except Exception as exc: + logger.warn(f"LuaTools: GetBackupsList failed: {exc}") + return json.dumps({"success": False, "error": str(exc)}) + + +def DeleteBackup(backup_path: str, contentScriptQuery: str = "") -> str: + """Delete a backup file.""" + try: + result = delete_backup(backup_path) + return json.dumps(result) + except Exception as exc: + logger.warn(f"LuaTools: DeleteBackup failed: {exc}") + return json.dumps({"success": False, "error": str(exc)}) + + +def OpenBackupLocation(backup_path: str, contentScriptQuery: str = "") -> str: + """Open a backup file location in file manager.""" + try: + result = open_backup_location(backup_path) + return json.dumps(result) + except Exception as exc: + logger.warn(f"LuaTools: OpenBackupLocation failed: {exc}") + return json.dumps({"success": False, "error": str(exc)}) + + +class Plugin: + def _front_end_loaded(self): + _copy_webkit_files() + + def _load(self): + logger.log(f"bootstrapping LuaTools plugin, millennium {Millennium.version()}") + + try: + detect_steam_install_path() + except Exception as exc: + logger.warn(f"LuaTools: steam path detection failed: {exc}") + + ensure_http_client("InitApis") + ensure_temp_download_dir() + + try: + message = apply_pending_update_if_any() + if message: + store_last_message(message) + except Exception as exc: + logger.warn(f"AutoUpdate: apply pending failed: {exc}") + + try: + init_applist() + except Exception as exc: + logger.warn(f"LuaTools: Applist initialization failed: {exc}") + + _copy_webkit_files() + _inject_webkit_files() + + try: + result = InitApis("boot") + logger.log(f"InitApis (boot) return: {result}") + except Exception as exc: + logger.error(f"InitApis (boot) failed: {exc}") + + try: + start_auto_update_background_check() + except Exception as exc: + logger.warn(f"AutoUpdate: start background check failed: {exc}") + + Millennium.ready() + + def _unload(self): + logger.log("unloading") + close_http_client("InitApis") + class Plugin: def _front_end_loaded(self): diff --git a/public/luatools.js b/public/luatools.js index c063153..42bca95 100644 --- a/public/luatools.js +++ b/public/luatools.js @@ -205,6 +205,7 @@ const fixesMenuBtn = createMenuButton('lt-settings-fixes-menu', 'menu.fixesMenu', 'Fixes Menu', 'fa-wrench'); createSectionLabel('menu.advancedLabel', 'Advanced'); + const backupBtn = createMenuButton('lt-settings-backup', 'menu.backup', 'Backup & Restore', 'fa-database'); const checkBtn = createMenuButton('lt-settings-check', 'menu.checkForUpdates', 'Check For Updates', 'fa-cloud-arrow-down'); const fetchApisBtn = createMenuButton('lt-settings-fetch-apis', 'menu.fetchFreeApis', 'Fetch Free APIs', 'fa-server'); @@ -279,6 +280,14 @@ }); } + if (backupBtn) { + backupBtn.addEventListener('click', function(e){ + e.preventDefault(); + try { overlay.remove(); } catch(_) {} + showBackupManagerUI(); + }); + } + if (fixesMenuBtn) { fixesMenuBtn.addEventListener('click', function(e){ e.preventDefault(); @@ -2837,7 +2846,289 @@ } catch(_){ clearInterval(timer); } }, 300); } + + function showBackupManagerUI() { + if (document.querySelector('.luatools-backup-overlay')) return; + + ensureLuaToolsStyles(); + ensureFontAwesome(); + + const overlay = document.createElement('div'); + overlay.className = 'luatools-backup-overlay'; + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.75);backdrop-filter:blur(8px);z-index:99999;display:flex;align-items:center;justify-content:center;animation:fadeIn 0.2s ease-out;overflow:auto;'; + + const modal = document.createElement('div'); + modal.style.cssText = 'background:linear-gradient(135deg, #1b2838 0%, #2a475e 100%);color:#fff;border:2px solid #66c0f4;border-radius:8px;min-width:500px;max-width:700px;padding:28px 32px;box-shadow:0 20px 60px rgba(0,0,0,.8), 0 0 0 1px rgba(102,192,244,0.3);animation:slideUp 0.1s ease-out;margin:20px auto;'; + + const header = document.createElement('div'); + header.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:28px;padding-bottom:20px;border-bottom:2px solid rgba(102,192,244,0.3);'; + + const title = document.createElement('div'); + title.style.cssText = 'font-size:24px;color:#fff;font-weight:700;text-shadow:0 2px 8px rgba(102,192,244,0.4);background:linear-gradient(135deg, #66c0f4 0%, #a4d7f5 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;'; + title.textContent = lt('Backup & Restore'); + header.appendChild(title); + + const closeBtn = document.createElement('button'); + closeBtn.innerHTML = ''; + closeBtn.style.cssText = 'background:none;border:none;color:#8f98a0;cursor:pointer;font-size:20px;padding:4px 8px;transition:color 0.2s;'; + closeBtn.onmouseover = function() { this.style.color = '#fff'; }; + closeBtn.onmouseout = function() { this.style.color = '#8f98a0'; }; + closeBtn.onclick = function() { overlay.remove(); }; + header.appendChild(closeBtn); + + modal.appendChild(header); + // Create Backup Section + const createSection = document.createElement('div'); + createSection.style.cssText = 'margin-bottom:24px;padding:16px;background:rgba(102,192,244,0.08);border:1px solid rgba(102,192,244,0.2);border-radius:8px;'; + + const sectionTitle = document.createElement('div'); + sectionTitle.style.cssText = 'font-weight:600;color:#fff;margin-bottom:12px;font-size:14px;'; + sectionTitle.textContent = lt('Create New Backup'); + createSection.appendChild(sectionTitle); + + const createButtonRow = document.createElement('div'); + createButtonRow.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;'; + + const createBtn = document.createElement('button'); + createBtn.innerHTML = ' ' + lt('Create Backup'); + createBtn.style.cssText = 'flex:1;padding:10px 16px;background:linear-gradient(135deg, #66c0f4 0%, #a4d7f5 100%);color:#0a0e27;border:none;border-radius:4px;cursor:pointer;font-weight:600;font-size:13px;transition:all 0.3s ease;'; + createBtn.onmouseover = function() { this.style.transform = 'translateY(-2px)'; this.style.boxShadow = '0 8px 16px rgba(102,192,244,0.4)'; }; + createBtn.onmouseout = function() { this.style.transform = 'translateY(0)'; this.style.boxShadow = 'none'; }; + createBtn.onclick = function() { + createBtn.disabled = true; + createBtn.textContent = lt('Processing...'); + + Millennium.callServerMethod('luatools', 'CreateBackup', { + backup_name: 'steam_config_backup', + destination: '', + contentScriptQuery: '' + }).then(function(res) { + try { + const payload = typeof res === 'string' ? JSON.parse(res) : res; + if (payload && payload.success) { + ShowLuaToolsAlert('LuaTools', lt('Backup created successfully!') + ' ' + (payload.path || '')); + refreshBackupList(); + } else { + const errorMsg = (payload && payload.error) ? String(payload.error) : lt('Failed to create backup'); + ShowLuaToolsAlert('LuaTools', errorMsg); + } + } catch(err) { + ShowLuaToolsAlert('LuaTools', lt('Error creating backup') + ': ' + err); + } + createBtn.disabled = false; + createBtn.innerHTML = ' ' + lt('Create Backup'); + }).catch(function(err) { + ShowLuaToolsAlert('LuaTools', lt('Failed to create backup')); + createBtn.disabled = false; + createBtn.innerHTML = ' ' + lt('Create Backup'); + }); + }; + createButtonRow.appendChild(createBtn); + createSection.appendChild(createButtonRow); + + modal.appendChild(createSection); + + // Backups List Section + const listSection = document.createElement('div'); + listSection.style.cssText = 'margin-bottom:24px;'; + + const listTitle = document.createElement('div'); + listTitle.style.cssText = 'font-weight:600;color:#fff;margin-bottom:12px;font-size:14px;'; + listTitle.textContent = lt('Your Backups'); + listSection.appendChild(listTitle); + + const backupList = document.createElement('div'); + backupList.id = 'luatools-backup-list'; + backupList.style.cssText = 'max-height:400px;overflow-y:auto;'; + listSection.appendChild(backupList); + + modal.appendChild(listSection); + + // Instructions + const instructions = document.createElement('div'); + instructions.style.cssText = 'font-size:12px;color:#8f98a0;padding:12px;background:rgba(42,71,94,0.5);border-radius:4px;border-left:3px solid #66c0f4;'; + instructions.innerHTML = 'Info: Backups are stored in your Downloads folder. Backups include depotcache and stplug-in folders.'; + modal.appendChild(instructions); + + overlay.appendChild(modal); + overlay.addEventListener('click', function(e) { if (e.target === overlay) overlay.remove(); }); + document.body.appendChild(overlay); + + function refreshBackupList() { + backupList.innerHTML = '