import os import subprocess import shutil import argparse from pathlib import Path import sys import time import PyInstaller import atexit import requests import psutil import re class DeckyBuilder: def __init__(self, release: str = None): self.release = release or self.prompt_for_version() self.root_dir = Path(__file__).resolve().parent self.app_dir = self.root_dir / "app" self.src_dir = self.root_dir / "src" self.dist_dir = self.root_dir / "dist" self.homebrew_dir = self.dist_dir / "homebrew" self.temp_files = [] # Track temporary files for cleanup atexit.register(self.cleanup) # Register cleanup on exit # Setup user homebrew directory self.user_home = Path.home() self.user_homebrew_dir = self.user_home / "homebrew" self.homebrew_folders = [ "data", "logs", "plugins", "services", "settings", "themes" ] def cleanup(self): """Clean up temporary files and directories""" try: # Clean up any temporary files we created for temp_file in self.temp_files: if os.path.exists(temp_file): try: if os.path.isfile(temp_file): os.remove(temp_file) elif os.path.isdir(temp_file): shutil.rmtree(temp_file, ignore_errors=True) except Exception as e: print(f"Warning: Failed to remove temporary file {temp_file}: {e}") # Clean up PyInstaller temp files for dir_name in ['build', 'dist']: dir_path = self.root_dir / dir_name if dir_path.exists(): try: shutil.rmtree(dir_path, ignore_errors=True) except Exception as e: print(f"Warning: Failed to remove {dir_name} directory: {e}") # Clean up PyInstaller spec files for spec_file in self.root_dir.glob("*.spec"): try: os.remove(spec_file) except Exception as e: print(f"Warning: Failed to remove spec file {spec_file}: {e}") except Exception as e: print(f"Warning: Error during cleanup: {e}") def safe_remove_directory(self, path): """Safely remove a directory with retries for Windows""" max_retries = 3 retry_delay = 1 # seconds for attempt in range(max_retries): try: if path.exists(): # On Windows, sometimes we need to remove .git directory separately git_dir = path / '.git' if git_dir.exists(): for item in git_dir.glob('**/*'): if item.is_file(): try: item.chmod(0o777) # Give full permissions item.unlink() except: pass shutil.rmtree(path, ignore_errors=True) return except Exception as e: print(f"Attempt {attempt + 1} failed to remove {path}: {str(e)}") if attempt < max_retries - 1: time.sleep(retry_delay) continue else: print(f"Warning: Could not fully remove {path}. Continuing anyway...") def setup_directories(self): """Setup directory structure""" print("Setting up directories...") # Clean up any existing directories if self.app_dir.exists(): self.safe_remove_directory(self.app_dir) if self.src_dir.exists(): self.safe_remove_directory(self.src_dir) if self.homebrew_dir.exists(): self.safe_remove_directory(self.homebrew_dir) # Create fresh directories self.src_dir.mkdir(parents=True, exist_ok=True) self.homebrew_dir.mkdir(parents=True, exist_ok=True) def setup_homebrew(self): """Setup homebrew directory structure""" print("Setting up homebrew directory structure...") # Create dist directory (self.homebrew_dir / "dist").mkdir(parents=True, exist_ok=True) # Setup homebrew directory structure for both temp and user directories print("Setting up homebrew directory structure...") for directory in [self.homebrew_dir, self.user_homebrew_dir]: if not directory.exists(): directory.mkdir(parents=True) for folder in self.homebrew_folders: folder_path = directory / folder if not folder_path.exists(): folder_path.mkdir(parents=True) def clone_repository(self): """Clone Decky Loader repository and checkout specific version""" print(f"\nCloning Decky Loader repository version: {self.release}") # Clean up existing directory if os.path.exists(self.app_dir): print("Removing existing repository...") self.safe_remove_directory(self.app_dir) try: # Clone the repository subprocess.run([ 'git', 'clone', '--no-checkout', # Don't checkout anything yet 'https://github.com/SteamDeckHomebrew/decky-loader.git', str(self.app_dir) ], check=True) os.chdir(self.app_dir) # Fetch all refs subprocess.run(['git', 'fetch', '--all', '--tags'], check=True) # Try to checkout the exact version first try: subprocess.run(['git', 'checkout', self.release], check=True) except subprocess.CalledProcessError: # If exact version fails, try to find the commit for pre-releases if '-pre' in self.release: # Get all tags and their commit hashes result = subprocess.run( ['git', 'ls-remote', '--tags', 'origin'], capture_output=True, text=True, check=True ) # Find the commit hash for our version for line in result.stdout.splitlines(): commit_hash, ref = line.split('\t') ref = ref.replace('refs/tags/', '') ref = ref.replace('^{}', '') # Remove annotated tag suffix if ref == self.release: print(f"Found commit {commit_hash} for version {self.release}") subprocess.run(['git', 'checkout', commit_hash], check=True) break else: raise Exception(f"Could not find commit for version {self.release}") else: raise print(f"Successfully checked out version: {self.release}") # Create version files in key locations with the requested version version_files = [ '.loader.version', 'frontend/.loader.version', 'backend/.loader.version', 'backend/decky_loader/.loader.version' ] for version_file in version_files: file_path = os.path.join(self.app_dir, version_file) os.makedirs(os.path.dirname(file_path), exist_ok=True) with open(file_path, 'w') as f: f.write(self.release) # Use the requested version except subprocess.CalledProcessError as e: raise Exception(f"Failed to clone/checkout repository: {str(e)}") finally: os.chdir(self.root_dir) def build_frontend(self): """Build frontend files""" print("Building frontend...") batch_file = None original_dir = os.getcwd() try: frontend_dir = self.app_dir / "frontend" if not frontend_dir.exists(): raise Exception(f"Frontend directory not found at {frontend_dir}") print(f"Changing to frontend directory: {frontend_dir}") os.chdir(frontend_dir) # Create .loader.version file with the release tag version_file = frontend_dir / ".loader.version" with open(version_file, "w") as f: f.write(self.release) self.temp_files.append(str(version_file)) # Create a batch file to run the commands batch_file = frontend_dir / "build_frontend.bat" with open(batch_file, "w") as f: f.write("@echo off\n") f.write("call pnpm install\n") f.write("if %errorlevel% neq 0 exit /b %errorlevel%\n") f.write("call pnpm run build\n") f.write("if %errorlevel% neq 0 exit /b %errorlevel%\n") self.temp_files.append(str(batch_file)) print("Running build commands...") result = subprocess.run([str(batch_file)], check=True, capture_output=True, text=True, shell=True) print(result.stdout) except subprocess.CalledProcessError as e: print(f"Command failed: {e.cmd}") print(f"Output: {e.output}") print(f"Error: {e.stderr}") raise Exception(f"Error building frontend: Command failed - {str(e)}") except Exception as e: print(f"Error building frontend: {str(e)}") raise finally: # Always return to original directory os.chdir(original_dir) def prepare_backend(self): """Prepare backend files for building.""" print("Preparing backend files...") print("Copying files according to Dockerfile structure...") # Create src directory if it doesn't exist os.makedirs(self.src_dir, exist_ok=True) # Copy backend files from app/backend/decky_loader to src/decky_loader print("Copying backend files...") shutil.copytree(os.path.join(self.app_dir, "backend", "decky_loader"), os.path.join(self.src_dir, "decky_loader"), dirs_exist_ok=True) # Copy static, locales, and plugin directories to maintain decky_loader structure os.makedirs(os.path.join(self.src_dir, "decky_loader"), exist_ok=True) shutil.copytree(os.path.join(self.app_dir, "backend", "decky_loader", "static"), os.path.join(self.src_dir, "decky_loader", "static"), dirs_exist_ok=True) shutil.copytree(os.path.join(self.app_dir, "backend", "decky_loader", "locales"), os.path.join(self.src_dir, "decky_loader", "locales"), dirs_exist_ok=True) shutil.copytree(os.path.join(self.app_dir, "backend", "decky_loader", "plugin"), os.path.join(self.src_dir, "decky_loader", "plugin"), dirs_exist_ok=True) # Create legacy directory os.makedirs(os.path.join(self.src_dir, "src", "legacy"), exist_ok=True) # Copy main.py to src directory shutil.copy2(os.path.join(self.app_dir, "backend", "main.py"), os.path.join(self.src_dir, "main.py")) # Create version file in the src directory version_file = os.path.join(self.src_dir, ".loader.version") with open(version_file, "w") as f: f.write(self.release) print("Backend preparation completed successfully!") return True def install_requirements(self): """Install Python requirements""" print("Installing Python requirements...") try: # Try both requirements.txt and pyproject.toml requirements_file = self.app_dir / "backend" / "requirements.txt" pyproject_file = self.app_dir / "backend" / "pyproject.toml" if requirements_file.exists(): subprocess.run([ sys.executable, "-m", "pip", "install", "--user", "-r", str(requirements_file) ], check=True) elif pyproject_file.exists(): # Install core dependencies directly instead of using poetry dependencies = [ "aiohttp>=3.8.1", "psutil>=5.9.0", "fastapi>=0.78.0", "uvicorn>=0.17.6", "python-multipart>=0.0.5", "watchdog>=2.1.7", "requests>=2.27.1", "setuptools>=60.0.0", "wheel>=0.37.1", "winregistry>=1.1.1; platform_system == 'Windows'", "pywin32>=303; platform_system == 'Windows'" ] # Install each dependency for dep in dependencies: try: subprocess.run([ sys.executable, "-m", "pip", "install", "--user", dep ], check=True) except subprocess.CalledProcessError as e: print(f"Warning: Failed to install {dep}: {str(e)}") continue else: print("Warning: No requirements.txt or pyproject.toml found") except Exception as e: print(f"Error installing requirements: {str(e)}") raise def add_defender_exclusion(self, path): """Add Windows Defender exclusion for a path""" try: subprocess.run([ "powershell", "-Command", f"Add-MpPreference -ExclusionPath '{path}'" ], check=True, capture_output=True) return True except: print("Warning: Could not add Windows Defender exclusion. You may need to run as administrator or manually add an exclusion.") return False def remove_defender_exclusion(self, path): """Remove Windows Defender exclusion for a path""" try: subprocess.run([ "powershell", "-Command", f"Remove-MpPreference -ExclusionPath '{path}'" ], check=True, capture_output=True) except: print("Warning: Could not remove Windows Defender exclusion.") def build_executables(self): """Build executables using PyInstaller""" print("\nBuilding executables...") # Read version from .loader.version version_file = os.path.join(self.app_dir, '.loader.version') if not os.path.exists(version_file): raise Exception("Version file not found. Run clone_repository first.") with open(version_file, 'r') as f: version = f.read().strip() # Normalize version for Python packaging # Convert v3.0.5-pre1 to 3.0.5rc1 py_version = version.lstrip('v') # Remove v prefix if '-pre' in py_version: py_version = py_version.replace('-pre', 'rc') print(f"Building version: {version} (Python package version: {py_version})") original_dir = os.getcwd() backend_dir = os.path.join(self.app_dir, "backend") dist_dir = os.path.join(backend_dir, "dist") # Add Windows Defender exclusion for build directories added_exclusion = self.add_defender_exclusion(backend_dir) try: os.chdir(backend_dir) # Create setup.py with the correct version setup_py = """ from setuptools import setup, find_packages setup( name="decky_loader", version="%s", packages=find_packages(), package_data={ 'decky_loader': [ 'locales/*', 'static/*', '.loader.version' ], }, install_requires=[ 'aiohttp>=3.8.1', 'certifi>=2022.6.15', 'packaging>=21.3', 'psutil>=5.9.1', 'requests>=2.28.1', ], ) """ % py_version with open("setup.py", "w") as f: f.write(setup_py) # Install the package in development mode subprocess.run([sys.executable, "-m", "pip", "install", "-e", "."], check=True) # Common PyInstaller arguments pyinstaller_args = [ sys.executable, "-m", "PyInstaller", "--clean", "--noconfirm", "pyinstaller.spec" ] # First build console version print("Building PluginLoader.exe (console version)...") os.environ.pop('DECKY_NOCONSOLE', None) # Ensure env var is not set subprocess.run(pyinstaller_args, check=True) # Then build no-console version print("Building PluginLoader_noconsole.exe...") os.environ['DECKY_NOCONSOLE'] = '1' subprocess.run(pyinstaller_args, check=True) # Clean up environment os.environ.pop('DECKY_NOCONSOLE', None) # Copy the built executables to dist os.makedirs(os.path.join(self.root_dir, "dist"), exist_ok=True) if os.path.exists(os.path.join("dist", "PluginLoader.exe")): shutil.copy2( os.path.join("dist", "PluginLoader.exe"), os.path.join(self.root_dir, "dist", "PluginLoader.exe") ) else: raise Exception("PluginLoader.exe not found after build") if os.path.exists(os.path.join("dist", "PluginLoader_noconsole.exe")): shutil.copy2( os.path.join("dist", "PluginLoader_noconsole.exe"), os.path.join(self.root_dir, "dist", "PluginLoader_noconsole.exe") ) else: raise Exception("PluginLoader_noconsole.exe not found after build") print("Successfully built executables") except subprocess.CalledProcessError as e: raise Exception(f"Failed to build executables: {str(e)}") finally: if added_exclusion: self.remove_defender_exclusion(backend_dir) os.chdir(original_dir) def install_files(self): """Install files to homebrew directory""" print("\nInstalling files to homebrew directory...") # Create homebrew directory if it doesn't exist homebrew_dir = os.path.join(os.path.expanduser("~"), "homebrew") services_dir = os.path.join(homebrew_dir, "services") os.makedirs(services_dir, exist_ok=True) try: # Copy PluginLoader.exe and PluginLoader_noconsole.exe for exe_name in ["PluginLoader.exe", "PluginLoader_noconsole.exe"]: exe_source = os.path.join(self.root_dir, "dist", exe_name) exe_dest = os.path.join(services_dir, exe_name) if not os.path.exists(exe_source): raise Exception(f"{exe_name} not found at {exe_source}") shutil.copy2(exe_source, exe_dest) # Create .loader.version file version_file = os.path.join(services_dir, ".loader.version") with open(version_file, "w") as f: f.write(self.release) print("Successfully installed files") except Exception as e: raise Exception(f"Failed to copy files to homebrew: {str(e)}") def install_nodejs(self): """Install Node.js v18.18.0 with npm""" print("Installing Node.js v18.18.0...") try: # First check if Node.js v18.18.0 is already installed in common locations nodejs_paths = [ r"C:\Program Files\nodejs\node.exe", r"C:\Program Files (x86)\nodejs\node.exe", os.path.expandvars(r"%APPDATA%\Local\Programs\nodejs\node.exe") ] # Try to use existing Node.js 18.18.0 first for node_path in nodejs_paths: if os.path.exists(node_path): try: version = subprocess.run([node_path, "--version"], capture_output=True, text=True).stdout.strip() if version.startswith("v18.18.0"): print(f"Found Node.js {version} at {node_path}") node_dir = os.path.dirname(node_path) if node_dir not in os.environ["PATH"]: os.environ["PATH"] = node_dir + os.pathsep + os.environ["PATH"] return True except: continue # If we get here, we need to install Node.js 18.18.0 print("Installing Node.js v18.18.0...") # Create temp directory for downloads temp_dir = self.root_dir / "temp" temp_dir.mkdir(exist_ok=True) # Download Node.js installer node_installer = temp_dir / "node-v18.18.0-x64.msi" if not node_installer.exists(): print("Downloading Node.js installer...") try: import urllib.request urllib.request.urlretrieve( "https://nodejs.org/dist/v18.18.0/node-v18.18.0-x64.msi", node_installer ) except Exception as e: print(f"Error downloading Node.js installer: {str(e)}") raise # Install Node.js silently print("Installing Node.js (this may take a few minutes)...") try: # First try to uninstall any existing Node.js using PowerShell uninstall_cmd = 'Get-WmiObject -Class Win32_Product | Where-Object { $_.Name -like "*Node.js*" } | ForEach-Object { $_.Uninstall() }' subprocess.run(["powershell", "-Command", uninstall_cmd], capture_output=True, timeout=60) # Wait a bit for uninstallation to complete time.sleep(5) # Now install Node.js 18.18.0 subprocess.run( ["msiexec", "/i", str(node_installer), "/qn", "ADDLOCAL=ALL"], check=True, timeout=300 # 5 minute timeout ) print("Waiting for Node.js installation to complete...") time.sleep(10) # Add to PATH nodejs_path = r"C:\Program Files\nodejs" npm_path = os.path.join(os.environ["APPDATA"], "npm") # Update PATH for current process if nodejs_path not in os.environ["PATH"]: os.environ["PATH"] = nodejs_path + os.pathsep + os.environ["PATH"] if npm_path not in os.environ["PATH"]: os.environ["PATH"] = npm_path + os.pathsep + os.environ["PATH"] # Verify installation node_version = subprocess.run(["node", "--version"], capture_output=True, text=True, check=True).stdout.strip() if not node_version.startswith("v18.18.0"): raise Exception(f"Wrong Node.js version installed: {node_version}") npm_version = subprocess.run(["npm", "--version"], capture_output=True, text=True, check=True).stdout.strip() print(f"Successfully installed Node.js {node_version} with npm {npm_version}") # Clean up self.safe_remove_directory(temp_dir) return True except subprocess.TimeoutExpired: print("Installation timed out. Please try installing Node.js v18.18.0 manually.") raise except Exception as e: print(f"Installation failed: {str(e)}") raise except Exception as e: print(f"Error installing Node.js: {str(e)}") raise def setup_steam_config(self): """Configure Steam for Decky Loader""" print("Configuring Steam...") try: # Add -dev argument to Steam shortcut import winreg steam_path = None # Try to find Steam installation path from registry try: with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Valve\Steam") as key: steam_path = winreg.QueryValueEx(key, "InstallPath")[0] except: try: with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Valve\Steam") as key: steam_path = winreg.QueryValueEx(key, "InstallPath")[0] except: print("Steam installation not found in registry") if steam_path: steam_exe = Path(steam_path) / "steam.exe" if steam_exe.exists(): # Create .cef-enable-remote-debugging file debug_file = Path(steam_path) / ".cef-enable-remote-debugging" debug_file.touch() print("Created .cef-enable-remote-debugging file") # Create/modify Steam shortcut desktop = Path.home() / "Desktop" shortcut_path = desktop / "Steam.lnk" import pythoncom from win32com.client import Dispatch shell = Dispatch("WScript.Shell") shortcut = shell.CreateShortCut(str(shortcut_path)) shortcut.Targetpath = str(steam_exe) shortcut.Arguments = "-dev" shortcut.save() print("Created Steam shortcut with -dev argument") except Exception as e: print(f"Error configuring Steam: {str(e)}") raise def setup_autostart(self): """Setup PluginLoader to run at startup""" print("Setting up autostart...") try: # Get the path to the no-console executable services_dir = os.path.join(os.path.expanduser("~"), "homebrew", "services") plugin_loader = os.path.join(services_dir, "PluginLoader_noconsole.exe") # Get the Windows Startup folder path startup_folder = os.path.join(os.environ["APPDATA"], "Microsoft", "Windows", "Start Menu", "Programs", "Startup") # Create a batch file in the startup folder startup_bat = os.path.join(startup_folder, "start_decky.bat") # Write the batch file with proper path escaping with open(startup_bat, "w") as f: f.write(f'@echo off\n"{plugin_loader}"') print(f"Created startup script at: {startup_bat}") return True except Exception as e: print(f"Error setting up autostart: {str(e)}") return False def check_python_version(self): """Check if correct Python version is being used""" print("Checking Python version...") if sys.version_info.major != 3 or sys.version_info.minor != 11: raise Exception("This script requires Python 3.11. Please run using decky_builder.bat") def check_dependencies(self): """Check and install required dependencies""" print("Checking dependencies...") try: # Check Node.js and npm first try: # Use shell=True to find node in PATH node_version = subprocess.run("node --version", shell=True, check=True, capture_output=True, text=True).stdout.strip() npm_version = subprocess.run("npm --version", shell=True, check=True, capture_output=True, text=True).stdout.strip() # Check if version meets requirements if not node_version.startswith("v18."): print(f"Node.js {node_version} found, but v18.18.0 is required") self.install_nodejs() else: print(f"Node.js {node_version} with npm {npm_version} is installed") except Exception as e: print(f"Node.js/npm not found or error: {str(e)}") self.install_nodejs() # Install pnpm globally if not present try: pnpm_version = subprocess.run("pnpm --version", shell=True, check=True, capture_output=True, text=True).stdout.strip() print(f"pnpm version {pnpm_version} is installed") except: print("Installing pnpm globally...") subprocess.run("npm i -g pnpm", shell=True, check=True) pnpm_version = subprocess.run("pnpm --version", shell=True, check=True, capture_output=True, text=True).stdout.strip() print(f"Installed pnpm version {pnpm_version}") # Check git try: git_version = subprocess.run("git --version", shell=True, check=True, capture_output=True, text=True).stdout.strip() print(f"{git_version} is installed") except: raise Exception("git is not installed. Please install git from https://git-scm.com/downloads") print("All dependencies are satisfied") except Exception as e: print(f"Error checking dependencies: {str(e)}") raise def get_release_versions(self): """Get list of available release versions""" print("Fetching available versions...") try: response = requests.get( "https://api.github.com/repos/SteamDeckHomebrew/decky-loader/releases" ) response.raise_for_status() releases = response.json() # Split releases into stable and pre-release stable_releases = [] pre_releases = [] for release in releases: version = release['tag_name'] if release['prerelease']: pre_releases.append(version) else: stable_releases.append(version) # Sort versions and take only the latest 3 of each stable_releases.sort(reverse=True) pre_releases.sort(reverse=True) stable_releases = stable_releases[:3] pre_releases = pre_releases[:3] # Combine and sort all versions all_versions = stable_releases + pre_releases all_versions.sort(reverse=True) return all_versions except requests.RequestException as e: raise Exception(f"Failed to fetch release versions: {str(e)}") def prompt_for_version(self): """Prompt the user to select a version to install.""" versions = self.get_release_versions() print("\nAvailable versions:") print("Stable versions:") stable_count = 0 for i, version in enumerate(versions): if '-pre' not in version: print(f"{i+1}. {version}") stable_count += 1 print("\nPre-release versions:") for i, version in enumerate(versions): if '-pre' in version: print(f"{i+1}. {version}") while True: try: choice = input("\nSelect a version (1-{}): ".format(len(versions))) index = int(choice) - 1 if 0 <= index < len(versions): return versions[index] print("Invalid selection, please try again.") except ValueError: print("Invalid input, please enter a number.") def terminate_processes(self): """Terminate running instances of executables that may interfere with the build.""" for proc in psutil.process_iter(['pid', 'name', 'exe']): if proc.info['name'] in ['PluginLoader.exe', 'PluginLoader_noconsole.exe']: try: proc.terminate() proc.wait() except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): pass def run(self): """Run the build and installation process.""" # Terminate interfering processes self.terminate_processes() try: print("Starting Decky Loader build process...") self.check_python_version() self.check_dependencies() self.setup_directories() self.clone_repository() self.setup_homebrew() self.build_frontend() self.prepare_backend() self.install_requirements() self.build_executables() self.install_files() self.setup_steam_config() self.setup_autostart() print("\nBuild process completed successfully!") print("\nNext steps:") print("1. Close Steam if it's running") print("2. Launch Steam using the new shortcut on your desktop") print("3. Enter Big Picture Mode") print("4. Hold the STEAM button and press A to access the Decky menu") except Exception as e: print(f"Error during build process: {str(e)}") raise finally: self.cleanup() def main(): parser = argparse.ArgumentParser(description='Build and Install Decky Loader for Windows') parser.add_argument('--release', required=False, default=None, help='Release version/branch to build (if not specified, will prompt for version)') args = parser.parse_args() try: builder = DeckyBuilder(args.release) builder.run() print(f"\nDecky Loader has been installed to: {builder.user_homebrew_dir}") except Exception as e: print(f"Error during build process: {str(e)}") sys.exit(1) if __name__ == "__main__": main()