const path = require('path'); const fs = require('fs'); const os = require('os'); const express = require('express'); const ServiceManager = require('./service-manager'); const Patcher = require('./patcher'); const UpdateClient = require('./update-client'); const FileUpdater = require('./file-updater'); const VylaPresence = require('./discord-rpc'); function getDefaultAppData() { if (process.platform === 'darwin') { return path.join(os.homedir(), 'Library', 'Application Support', 'vyla-home', 'vyla-home-files'); } if (process.platform === 'linux') { return path.join(os.homedir(), '.local', 'share', 'vyla-home', 'vyla-home-files'); } return path.join(os.homedir(), 'AppData', 'Roaming', 'vyla-home', 'vyla-home-files'); } const APPDATA_DIR = (process.env.APPDATA_PATH && !process.env.APPDATA_PATH.includes('com.vyla.home')) ? process.env.APPDATA_PATH : getDefaultAppData(); const LOGS_DIR = path.join(APPDATA_DIR, 'logs'); try { fs.mkdirSync(LOGS_DIR, { recursive: true }); } catch (e) { } const debugLogPath = path.join(LOGS_DIR, 'node-debug.log'); function debugLog(msg) { try { fs.appendFile(debugLogPath, `[NODE] ${msg}\n`, 'utf8', () => { }); } catch (e) { } } debugLog("--- Node.js Process Started ---"); let serviceManager; let updateDecisionHandler = null; debugLog(`AppData Dir: ${APPDATA_DIR}`); function sendToTauri(event, data) { debugLog(`Emitting to Tauri: [${event}] -> ${JSON.stringify(data)}`); process.stdout.write(JSON.stringify({ event, data }) + '\n'); } process.stdin.resume(); require('readline').createInterface({ input: process.stdin }).on('line', (line) => { try { const msg = JSON.parse(line); if (msg && msg.type === 'presence') presence.report(msg.payload); } catch (e) { } }); process.stdin.on('end', () => { debugLog('stdin closed - parent died. Self-terminating...'); process.exit(0); }); process.stdin.on('error', () => { process.exit(0); }); process.on('uncaughtException', (err) => { debugLog(`CRITICAL UNCAUGHT EXCEPTION: ${err.stack || err.message}`); if (err.code === 'EPIPE' || err.code === 'EOF') { debugLog('Pipe to parent closed - parent process gone, exiting'); process.exit(0); } try { sendToTauri('error', { message: err.message }); } catch (e) { } }); process.on('unhandledRejection', (reason) => { const msg = reason && reason.stack ? reason.stack : String(reason); debugLog(`CRITICAL UNHANDLED REJECTION: ${msg}`); sendToTauri('error', { message: reason && reason.message ? reason.message : String(reason) }); }); function getEnvConfig() { try { const appPath = path.join(APPDATA_DIR, 'config/env.json'); if (fs.existsSync(appPath)) return JSON.parse(fs.readFileSync(appPath, 'utf8')); return JSON.parse(fs.readFileSync(path.join(__dirname, 'config/env.json'), 'utf8')); } catch (e) { return {}; } } function getReplacementsConfig() { try { const appPath = path.join(APPDATA_DIR, 'config/replacements.json'); if (fs.existsSync(appPath)) return JSON.parse(fs.readFileSync(appPath, 'utf8')); return JSON.parse(fs.readFileSync(path.join(__dirname, 'config/replacements.json'), 'utf8')); } catch (e) { return {}; } } function liveReposEnabled() { const cfg = getEnvConfig(); return !!(cfg.updater && cfg.updater.liveRepos === true); } const updateClient = new UpdateClient(APPDATA_DIR); const presence = new VylaPresence({ getConfig: getEnvConfig, log: debugLog }); const fileUpdater = new FileUpdater(APPDATA_DIR, updateClient); fileUpdater.onNotice = (message) => sendToTauri('notice', { message }); let lastFilePercent = -1; fileUpdater.onProgress = (downloaded, total) => { const percent = total > 0 ? Math.round((downloaded / total) * 100) : 0; if (percent === lastFilePercent) return; lastFilePercent = percent; sendToTauri('download-progress', { downloaded, total, percent }); }; function withTimeout(promise, ms) { return Promise.race([promise, new Promise((resolve) => setTimeout(() => resolve(null), ms))]); } function portInUse(port) { return new Promise((resolve) => { const socket = new (require('net').Socket)(); socket.setTimeout(500); socket.once('connect', () => { socket.destroy(); resolve(true); }); socket.once('error', () => resolve(false)); socket.once('timeout', () => { socket.destroy(); resolve(false); }); socket.connect(port, '127.0.0.1'); }); } async function start() { debugLog("Inside start() function"); sendToTauri('status', { message: 'Loading core configuration files...' }); const patcher = new Patcher(APPDATA_DIR); serviceManager = new ServiceManager(APPDATA_DIR, getEnvConfig()); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const downloadVerified = async (name, item, dest) => { let lastError; for (let attempt = 1; attempt <= 3; attempt++) { fs.rmSync(dest, { force: true }); try { sendToTauri('status', { message: `Downloading ${name} (attempt ${attempt}/3)...` }); let lastPercent = -1; await updateClient.downloadUpdate(item, dest, (downloaded, total) => { const percent = total > 0 ? Math.round((downloaded / total) * 100) : 0; if (percent !== lastPercent) { lastPercent = percent; sendToTauri('download-progress', { downloaded, total, percent }); } }); sendToTauri('status', { message: `Verifying ${name} SHA-256...` }); if (!patcher.verifyChecksum(dest, item.sha256)) { throw new Error('SHA-256 checksum verification failed.'); } return; } catch (err) { lastError = err; fs.rmSync(dest, { force: true }); debugLog(`Download error for ${name} on attempt ${attempt}: ${err.message}`); if (attempt < 3) { await sleep(err.retryAfterMs || 2000 * 2 ** (attempt - 1) + Math.floor(Math.random() * 500)); } } } throw new Error(`Failed to download ${name} after 3 attempts: ${lastError.message}`); }; const installBuild = async (release) => { const names = patcher.staleComponents(release); if (names.length === 0) return; const stagingDir = path.join(APPDATA_DIR, '.staging'); fs.rmSync(stagingDir, { recursive: true, force: true }); fs.mkdirSync(stagingDir, { recursive: true }); const tempFiles = []; try { for (let i = 0; i < names.length; i++) { const name = names[i]; const tempZip = path.join(APPDATA_DIR, `download-${name}.zip`); tempFiles.push(tempZip); await downloadVerified(name, release.components[name], tempZip); sendToTauri('status', { message: `Unpacking ${name}...` }); let lastEmit = 0; await patcher.extractToStaging(tempZip, (file, done, total) => { const now = Date.now(); if (done !== total && now - lastEmit < 40) return; lastEmit = now; sendToTauri('extract-progress', { file, done, total, percent: Math.round((done / total) * 100) }); }, i > 0); fs.rmSync(tempZip, { force: true }); } sendToTauri('status', { message: 'Verifying file integrity...' }); if (!patcher.verifyStaged(stagingDir, names)) { throw new Error('Extraction verification failed: critical startup files missing.'); } sendToTauri('status', { message: 'Replacing old version...' }); patcher.commitStaging(stagingDir); } catch (err) { fs.rmSync(stagingDir, { recursive: true, force: true }); for (const f of tempFiles) fs.rmSync(f, { force: true }); throw err; } try { sendToTauri('status', { message: 'Applying configuration patches...' }); patcher.applyPatches(getReplacementsConfig(), names); sendToTauri('status', { message: 'Migrating security credentials...' }); patcher.bridgeCredentials(serviceManager.credentialManager); if (!patcher.verifyExtraction()) { throw new Error('Post-install verification failed.'); } const current = patcher.getCurrentComponents(); const next = {}; for (const name of Object.keys(release.components)) { next[name] = names.includes(name) ? release.components[name].id : current[name]; } patcher.saveCurrentVersion(release.version, release.sha256, next); patcher.writeIntegrity(names); } catch (err) { try { patcher.rollback(); } catch (x) { debugLog(`Rollback failed: ${x.message}`); } throw err; } patcher.finalizeCommit(); if (liveReposEnabled()) fileUpdater.snapshotBase(); serviceManager.envConfig = getEnvConfig(); debugLog(`Update installed successfully: ${names.join(', ')}`); }; try { if (patcher.recoverInterrupted()) { debugLog('Recovered from an interrupted update.'); sendToTauri('notice', { message: 'Recovered from an interrupted update.' }); } fileUpdater.recoverSdk(); sendToTauri('status', { message: 'Inspecting local workspace and installed files...' }); const isFirstRun = !patcher.verifyExtraction(); const localVersion = patcher.getCurrentVersion(); const localSha = patcher.getCurrentSha(); debugLog(`Is First Run: ${isFirstRun}, Local Version: ${localVersion}, Local SHA: ${localSha}`); sendToTauri('status', { message: 'Connecting to update servers...' }); let release = null; try { debugLog("Fetching updates from updateClient..."); release = await updateClient.checkForUpdates(isFirstRun); debugLog(`Update check finished. Found: ${release ? release.version : 'None'}`); } catch (e) { debugLog(`Update check failed: ${e.message}`); sendToTauri('notice', { message: `Update check skipped: ${e.message}` }); } const normalizeVer = (v) => (v || '').toString().replace(/^v/, '').trim(); const parseVer = (v) => normalizeVer(v).split(/[.\-+]/).map((n) => parseInt(n, 10) || 0); const isNewer = (remote, local) => { const a = parseVer(remote); const b = parseVer(local); for (let i = 0; i < Math.max(a.length, b.length); i++) { const x = a[i] || 0; const y = b[i] || 0; if (x !== y) return x > y; } return false; }; if (release && release.installer && process.platform === 'win32' && process.env.VYLA_DEV !== '1') { const appVersion = process.env.VYLA_APP_VERSION || '0.0.0'; const tried = updateClient.readCache().selfUpdate || {}; const recentlyTried = tried.version === release.installer.version && Date.now() - (tried.at || 0) < 10 * 60 * 1000; if (isNewer(release.installer.version, appVersion) && !recentlyTried) { try { const dest = path.join(APPDATA_DIR, `vyla-installer-${normalizeVer(release.installer.version)}.exe`); sendToTauri('status', { message: 'Downloading launcher update...' }); await downloadVerified('launcher', release.installer, dest); updateClient.writeCache({ selfUpdate: { version: release.installer.version, at: Date.now() } }); sendToTauri('status', { message: 'Removing old version and installing the latest...' }); sendToTauri('self-update', { path: dest }); await sleep(30000); sendToTauri('notice', { message: 'Launcher update did not start. Continuing on the current version.' }); } catch (err) { debugLog(`Launcher update failed: ${err.message}`); sendToTauri('notice', { message: `Launcher update failed: ${err.message}` }); } } } if (release) { const stale = patcher.staleComponents(release); const broken = stale.some((name) => !patcher.componentIntact(name)); const sameVersion = normalizeVer(release.version) === normalizeVer(localVersion); const hasUpdate = stale.length > 0 && (broken || isNewer(release.version, localVersion) || sameVersion); if (hasUpdate) { debugLog(`Installing components: ${stale.join(', ')}`); sendToTauri('status', { message: isFirstRun ? 'First run detected. Preparing initial installation...' : `Updating to ${release.version}...` }); try { await installBuild(release); updateClient.recordSuccess(); } catch (err) { updateClient.recordFailure(); debugLog(`Update failed: ${err.message}`); sendToTauri('notice', { message: `Update failed: ${err.message}` }); } } else { debugLog("Already on the latest version."); sendToTauri('status', { message: 'Up to date.' }); } } if (!patcher.verifyExtraction()) { debugLog("No usable install. Looking for local update-payload.zip..."); sendToTauri('status', { message: 'Looking for local payload archive...' }); const payloadRoots = [ process.env.VYLA_PAYLOAD_DIR, __dirname, process.cwd(), path.resolve(__dirname, '..', '..', '..', '..'), APPDATA_DIR ].filter(Boolean); const payloadRoot = payloadRoots.find((r) => fs.existsSync(path.join(r, 'update-payload.zip'))); const localZip = payloadRoot ? path.join(payloadRoot, 'update-payload.zip') : null; if (localZip) { sendToTauri('status', { message: 'Extracting local baseline payload...' }); const stagingDir = await patcher.extractToStaging(localZip); if (!patcher.verifyStaged(stagingDir, ['frontend', 'player'])) { fs.rmSync(stagingDir, { recursive: true, force: true }); throw new Error('Bundled payload is incomplete.'); } patcher.commitStaging(stagingDir); try { patcher.applyPatches(getReplacementsConfig()); patcher.bridgeCredentials(serviceManager.credentialManager); let ver = "1.0.0"; let manSha = null; let ids = {}; try { const man = JSON.parse(fs.readFileSync(path.join(payloadRoot, 'manifest.json'), 'utf8')); ver = man.version || ver; manSha = man.sha256 || null; ids = Object.fromEntries(Object.entries(man.components || {}).map(([k, v]) => [k, v.id])); } catch (e) { } patcher.saveCurrentVersion(ver, manSha, ids); patcher.writeIntegrity(['frontend', 'player']); } catch (e) { try { patcher.rollback(); } catch (x) { } throw e; } patcher.finalizeCommit(); if (liveReposEnabled()) fileUpdater.snapshotBase(); serviceManager.envConfig = getEnvConfig(); debugLog("Local payload extracted successfully."); } else { debugLog("No local payload found!"); } } sendToTauri('status', { message: 'Validating critical components...' }); if (!patcher.verifyExtraction()) { throw new Error('No usable install and no valid signed release or bundled payload was found. Check your internet connection and relaunch, or reinstall the app.'); } const liveRepos = liveReposEnabled(); sendToTauri('status', { message: liveRepos ? 'Checking UI files and SDK...' : 'Checking SDK...' }); const [filesCheck, sdkCheck] = await Promise.all([ liveRepos ? withTimeout(fileUpdater.checkRepos().catch((e) => { debugLog(`Live file check failed: ${e.message}`); sendToTauri('notice', { message: `File check failed: ${e.message}` }); return null; }), 60000) : Promise.resolve(null), withTimeout(fileUpdater.checkSdk().catch((e) => { debugLog(`SDK check failed: ${e.message}`); sendToTauri('notice', { message: `SDK check failed: ${e.message}` }); return null; }), 12000) ]); if (liveRepos) { debugLog(`Repo check result: ${filesCheck ? `${filesCheck.changes.length} change(s)` : 'failed or timed out'}`); } if (filesCheck && (filesCheck.changes.length > 0 || filesCheck.removed.length > 0)) { sendToTauri('status', { message: `Updating ${filesCheck.changes.length} file(s)...` }); try { await fileUpdater.applyLive(filesCheck, getReplacementsConfig(), (file, done, total) => { sendToTauri('extract-progress', { file, done, total, percent: Math.round((done / total) * 100) }); }); sendToTauri('status', { message: `Updated ${filesCheck.changes.length} file(s).` }); } catch (e) { debugLog(`File update failed: ${e.message}`); sendToTauri('notice', { message: `File update failed: ${e.message}` }); } } if (sdkCheck && sdkCheck.outdated) { sendToTauri('status', { message: `Updating SDK (${sdkCheck.latest})...` }); try { await fileUpdater.installSdk(sdkCheck); sendToTauri('status', { message: `SDK updated to ${sdkCheck.latest}.` }); } catch (e) { debugLog(`SDK install failed: ${e.message}`); sendToTauri('status', { message: `SDK update failed: ${e.message}` }); } } sendToTauri('status', { message: 'Loading encrypted credentials...' }); serviceManager.credentialManager.loadCredentials(); const busyPorts = []; for (const port of [3000, 4890]) { if (await portInUse(port)) busyPorts.push(port); } if (busyPorts.length > 0) { throw new Error(`Port ${busyPorts.join(' and ')} is already in use by another program. Close it or any other running Vyla Home, then relaunch.`); } sendToTauri('status', { message: 'Starting services (player, frontend)...' }); await serviceManager.startServices([ { name: 'player', relativeDir: 'player', startScript: 'server.js' }, { name: 'frontend', relativeDir: 'frontend', startScript: 'server.js' } ]); debugLog("Backend services spawn requested."); sendToTauri('status', { message: 'Waiting for microservices to bind...' }); try { await serviceManager.waitForPorts([3000, 4890], 60000); } catch (e) { throw new Error(`${e.message}. Check logs in ${LOGS_DIR}`); } debugLog("Ports 3000 and 4890 are ready."); try { presence.start(); } catch (e) { debugLog(`Presence start failed: ${e.message}`); } sendToTauri('status', { message: 'All backend services running and healthy.' }); debugLog("Sending READY to Tauri."); sendToTauri('ready', { url: 'http://127.0.0.1:4890' }); } catch (error) { debugLog(`Fatal Start Error: ${error.message}`); sendToTauri('error', { message: error.message }); } } process.on('exit', () => { try { presence.stop(); } catch (e) { } if (serviceManager) { serviceManager.stopAll(); } }); process.on('SIGINT', () => { process.exit(0); }); process.on('SIGTERM', () => { process.exit(0); }); process.on('SIGHUP', () => { process.exit(0); }); start();