const fs = require('fs'); const path = require('path'); const { execFile } = require('child_process'); const { downloadFile, retryDownload } = require('../utils/fileManager'); const { getOS, getArch } = require('../utils/platformUtils'); const { validateChecksum, extractVersionDetails, getInstalledClientVersion, getUpdatePlan, extractVersionNumber } = require('../services/versionManager'); const { installButler } = require('./butlerManager'); const { GAME_DIR, CACHE_DIR, TOOLS_DIR } = require('../core/paths'); const { saveVersionClient } = require('../core/config'); async function acquireGameArchive(downloadUrl, targetPath, checksum, progressCallback, allowRetry = true) { const osName = getOS(); const arch = getArch(); if (osName === 'darwin' && arch === 'amd64') { throw new Error('Hytale x86_64 Intel Mac Support has not been released yet. Please check back later.'); } if (fs.existsSync(targetPath)) { const stats = fs.statSync(targetPath); if (stats.size > 1024 * 1024) { const isValid = await validateChecksum(targetPath, checksum); if (isValid) { console.log(`Valid archive found in cache: ${targetPath}`); return targetPath; } console.log('Cached archive checksum mismatch, re-downloading'); fs.unlinkSync(targetPath); } } console.log(`Downloading game archive from: ${downloadUrl}`); try { if (allowRetry) { await retryDownload(downloadUrl, targetPath, progressCallback); } else { await downloadFile(downloadUrl, targetPath, progressCallback); } } catch (error) { const enhancedError = new Error(`Archive download failed: ${error.message}`); enhancedError.originalError = error; enhancedError.downloadUrl = downloadUrl; enhancedError.targetPath = targetPath; throw enhancedError; } const stats = fs.statSync(targetPath); console.log(`Archive downloaded, size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`); const isValid = await validateChecksum(targetPath, checksum); if (!isValid) { console.log('Downloaded archive checksum validation failed, removing corrupted file'); fs.unlinkSync(targetPath); throw new Error('Downloaded archive is corrupted or invalid. Please retry'); } console.log(`Archive validation passed: ${targetPath}`); return targetPath; } async function deployGameArchive(archivePath, destinationDir, toolsDir, progressCallback, isDifferential = false) { if (!archivePath || !fs.existsSync(archivePath)) { throw new Error(`Archive not found: ${archivePath || 'undefined'}`); } const stats = fs.statSync(archivePath); console.log(`Deploying archive: ${archivePath}`); console.log(`Archive size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`); console.log(`Deployment mode: ${isDifferential ? 'differential' : 'full'}`); const butlerPath = await installButler(toolsDir); const stagingDir = path.join(destinationDir, 'staging-temp'); if (!fs.existsSync(destinationDir)) { fs.mkdirSync(destinationDir, { recursive: true }); } if (fs.existsSync(stagingDir)) { fs.rmSync(stagingDir, { recursive: true, force: true }); } fs.mkdirSync(stagingDir, { recursive: true }); if (progressCallback) { progressCallback(isDifferential ? 'Applying differential update...' : 'Installing game files...', null, null, null, null); } const args = [ 'apply', '--staging-dir', stagingDir, archivePath, destinationDir ]; console.log(`Executing deployment: ${butlerPath} ${args.join(' ')}`); return new Promise((resolve, reject) => { const child = execFile(butlerPath, args, { maxBuffer: 1024 * 1024 * 10, timeout: 600000 }, (error, stdout, stderr) => { if (error) { const cleanStderr = stderr.replace(/[\u2714\u2716\u2713\u2717\u26A0\uD83D[\uDC00-\uDFFF]]/g, '').trim(); const cleanStdout = stdout.replace(/[\u2714\u2716\u2713\u2717\u26A0\uD83D[\uDC00-\uDFFF]]/g, '').trim(); if (cleanStderr) console.error('Deployment stderr:', cleanStderr); if (cleanStdout) console.error('Deployment stdout:', cleanStdout); const errorText = (stderr + ' ' + error.message).toLowerCase(); let message = 'Game deployment failed'; if (errorText.includes('unexpected eof')) { message = 'Corrupted archive detected. Please retry download.'; if (fs.existsSync(archivePath)) { fs.unlinkSync(archivePath); } } else if (errorText.includes('permission denied')) { message = 'Permission denied. Check file permissions and try again.'; } else if (errorText.includes('no space left') || errorText.includes('device full')) { message = 'Insufficient disk space. Free up space and try again.'; } const deployError = new Error(message); deployError.originalError = error; deployError.stderr = cleanStderr; deployError.stdout = cleanStdout; return reject(deployError); } console.log('Game deployment completed successfully'); const cleanOutput = stdout.replace(/[\u2714\u2716\u2713\u2717\u26A0\uD83D[\uDC00-\uDFFF]]/g, '').trim(); if (cleanOutput) { console.log(cleanOutput); } if (fs.existsSync(stagingDir)) { try { fs.rmSync(stagingDir, { recursive: true, force: true }); } catch (cleanupErr) { console.warn('Failed to cleanup staging directory:', cleanupErr.message); } } resolve(); }); child.on('error', (err) => { console.error('Deployment process error:', err); reject(new Error(`Failed to execute deployment tool: ${err.message}`)); }); }); } async function performIntelligentUpdate(targetVersion, branch = 'release', progressCallback, gameDir = GAME_DIR, cacheDir = CACHE_DIR, toolsDir = TOOLS_DIR) { console.log(`Initiating intelligent update to version ${targetVersion}`); const currentVersion = getInstalledClientVersion(); const currentBuild = extractVersionNumber(currentVersion) || 0; const targetBuild = extractVersionNumber(targetVersion); console.log(`Current build: ${currentBuild}, Target build: ${targetBuild}, Branch: ${branch}`); // For non-release branches, always do full install if (branch !== 'release') { console.log('Pre-release branch detected - forcing full archive download'); const versionDetails = await extractVersionDetails(targetVersion, branch); const archivePath = path.join(cacheDir, `${branch}_0_to_${targetBuild}.pwr`); if (progressCallback) { progressCallback('Downloading full game archive (pre-release)...', 0, null, null, null); } await acquireGameArchive(versionDetails.fullUrl, archivePath, null, progressCallback); await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, false); saveVersionClient(targetVersion); console.log(`Pre-release installation completed. Version ${targetVersion} is now installed.`); return; } // Clean install (no current version) if (currentBuild === 0) { console.log('No existing installation detected - downloading full archive'); const versionDetails = await extractVersionDetails(targetVersion, branch); const archivePath = path.join(cacheDir, `${branch}_0_to_${targetBuild}.pwr`); if (progressCallback) { progressCallback(`Downloading full game archive (first install - v${targetBuild})...`, 0, null, null, null); } await acquireGameArchive(versionDetails.fullUrl, archivePath, null, progressCallback); await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, false); saveVersionClient(targetVersion); console.log(`Initial installation completed. Version ${targetVersion} is now installed.`); return; } // Already at target if (currentBuild >= targetBuild) { console.log('Already at target version or newer'); return; } // Use mirror's update plan for optimal patch routing try { const plan = await getUpdatePlan(currentBuild, targetBuild, branch); console.log(`Applying ${plan.steps.length} patch(es): ${plan.steps.map(s => `${s.from}\u2192${s.to}`).join(' + ')}`); for (let i = 0; i < plan.steps.length; i++) { const step = plan.steps[i]; const stepName = `${step.from}_to_${step.to}`; const archivePath = path.join(cacheDir, `${branch}_${stepName}.pwr`); const isDifferential = step.from !== 0; if (progressCallback) { progressCallback(`Downloading patch ${i + 1}/${plan.steps.length}: ${stepName}...`, 0, null, null, null); } await acquireGameArchive(step.url, archivePath, null, progressCallback); if (progressCallback) { progressCallback(`Applying patch ${i + 1}/${plan.steps.length}: ${stepName}...`, 50, null, null, null); } await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, isDifferential); // Clean up patch file if (fs.existsSync(archivePath)) { try { fs.unlinkSync(archivePath); console.log(`Cleaned up: ${stepName}.pwr`); } catch (cleanupErr) { console.warn(`Failed to cleanup: ${cleanupErr.message}`); } } saveVersionClient(`v${step.to}`); console.log(`Patch ${stepName} applied (${i + 1}/${plan.steps.length})`); } console.log(`Update completed. Version ${targetVersion} is now installed.`); } catch (planError) { console.error('Update plan failed:', planError.message); console.log('Falling back to full archive download'); // Fallback: full install const versionDetails = await extractVersionDetails(targetVersion, branch); const archivePath = path.join(cacheDir, `${branch}_0_to_${targetBuild}.pwr`); if (progressCallback) { progressCallback(`Downloading full game archive (fallback)...`, 0, null, null, null); } await acquireGameArchive(versionDetails.fullUrl, archivePath, null, progressCallback); await deployGameArchive(archivePath, gameDir, toolsDir, progressCallback, false); saveVersionClient(targetVersion); } } async function ensureGameInstalled(targetVersion, branch = 'release', progressCallback, gameDir = GAME_DIR, cacheDir = CACHE_DIR, toolsDir = TOOLS_DIR) { const { findClientPath } = require('../core/paths'); const clientPath = findClientPath(gameDir); if (clientPath) { const currentVersion = getInstalledClientVersion(); if (currentVersion === targetVersion) { console.log(`Game already installed at correct version: ${targetVersion}`); return; } } await performIntelligentUpdate(targetVersion, branch, progressCallback, gameDir, cacheDir, toolsDir); } module.exports = { acquireGameArchive, deployGameArchive, performIntelligentUpdate, ensureGameInstalled };