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'); 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(); 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 {}; } } const updateClient = new UpdateClient(APPDATA_DIR); const fileUpdater = new FileUpdater(APPDATA_DIR, updateClient); 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'); }); } function promptUser(event, payload, timeoutMs = 120000) { return new Promise((resolve) => { const token = require('crypto').randomBytes(24).toString('hex'); const app = express(); let server = null; let finished = false; let timer = null; const finish = (result) => { if (finished) return; finished = true; clearTimeout(timer); if (server) server.close(); resolve(result); }; timer = setTimeout(() => finish(null), timeoutMs); app.use((req, res, next) => { const ip = req.socket.remoteAddress; if (ip !== '127.0.0.1' && ip !== '::1' && ip !== '::ffff:127.0.0.1') { return res.status(403).end(); } res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Methods', 'POST,OPTIONS'); res.header('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') return res.sendStatus(200); next(); }); app.post('/prompt/answer', express.json({ limit: '64kb' }), (req, res) => { const body = req.body || {}; if (body.token !== token) return res.status(403).end(); res.sendStatus(200); finish({ accept: body.accept === true, files: Array.isArray(body.files) ? body.files.map(String) : [] }); }); server = app.listen(4891, '127.0.0.1', () => { sendToTauri(event, { ...payload, token }); }); server.on('error', (err) => { debugLog(`Prompt server bind error: ${err.message}`); finish(null); }); }); } 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 installBuild = async (release) => { const tempZipPath = path.join(APPDATA_DIR, 'download-temp.zip'); if (fs.existsSync(tempZipPath)) { try { fs.unlinkSync(tempZipPath); } catch (e) { } } let attempts = 0; let downloadSuccess = false; while (attempts < 3 && !downloadSuccess) { attempts++; try { debugLog(`Attempting to download update (Attempt ${attempts})...`); sendToTauri('status', { message: `Connecting to download servers (Attempt ${attempts}/3)...` }); let lastPercent = -1; await updateClient.downloadUpdate(release, tempZipPath, (downloaded, total) => { const percent = total > 0 ? Math.round((downloaded / total) * 100) : 0; if (percent !== lastPercent) { lastPercent = percent; sendToTauri('download-progress', { downloaded, total, percent }); } }); debugLog('Download complete. Verifying checksum...'); sendToTauri('status', { message: 'Verifying SHA-256 integrity...' }); if (!release.sha256) { throw new Error('Release has no SHA-256 in manifest.json; refusing unverified install.'); } if (!patcher.verifyChecksum(tempZipPath, release.sha256)) { throw new Error('SHA-256 checksum verification failed.'); } debugLog('Checksum verified. Extracting to staging...'); sendToTauri('status', { message: 'Unpacking update archive...' }); let lastEmit = 0; const stagingDir = await patcher.extractToStaging(tempZipPath, (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) }); }); sendToTauri('status', { message: 'Verifying file integrity...' }); if (!patcher.verifyExtraction(stagingDir)) { fs.rmSync(stagingDir, { recursive: true, force: true }); throw new Error('Extraction verification failed: critical startup files missing.'); } sendToTauri('status', { message: 'Replacing old version...' }); patcher.commitStaging(stagingDir); debugLog('Extraction complete. Applying patches...'); sendToTauri('status', { message: 'Applying configuration patches...' }); fileUpdater.snapshotBase(); patcher.applyPatches(getReplacementsConfig()); sendToTauri('status', { message: 'Migrating security credentials...' }); patcher.bridgeCredentials(serviceManager.credentialManager); patcher.saveCurrentVersion(release.version, release.sha256); serviceManager.envConfig = getEnvConfig(); if (fs.existsSync(tempZipPath)) { fs.unlinkSync(tempZipPath); } downloadSuccess = true; debugLog('Update installed successfully!'); } catch (err) { debugLog(`Download/Install error on attempt ${attempts}: ${err.message}`); sendToTauri('status', { message: `Install error: ${err.message}. Retrying...` }); if (fs.existsSync(tempZipPath)) { try { fs.unlinkSync(tempZipPath); } catch (e) { } } if (attempts >= 3) { throw new Error(`Failed to download and verify build after 3 attempts: ${err.message}`); } await new Promise(r => setTimeout(r, 2000)); } } }; try { 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(); debugLog(`Update check finished. Found: ${release ? release.version : 'None'}`); } catch (e) { debugLog(`Update check failed (offline?): ${e.message}`); sendToTauri('status', { message: `Update server 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) { const versionNewer = isNewer(release.version, localVersion); const sameVerNewBuild = normalizeVer(release.version) === normalizeVer(localVersion) && !!release.sha256 && !!localSha && release.sha256 !== localSha; const hasUpdate = versionNewer; if (isFirstRun) { debugLog("First run with no local payload. Installing remote build..."); sendToTauri('status', { message: 'First run detected. Preparing initial installation...' }); await installBuild(release); } else if (hasUpdate) { debugLog("Update available. Prompting user..."); sendToTauri('status', { message: `New update available (${release.version}).` }); const tempApp = express(); let userDecision = null; updateDecisionHandler = (val) => { userDecision = val; }; tempApp.use((req, res, next) => { const clientIp = req.socket.remoteAddress; if (clientIp !== '127.0.0.1' && clientIp !== '::1' && clientIp !== '::ffff:127.0.0.1') { return res.status(403).end(); } res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); res.header('Access-Control-Allow-Headers', '*'); if (req.method === 'OPTIONS') return res.sendStatus(200); next(); }); tempApp.get('/update/info', (req, res) => { res.json({ available: true, current: localVersion, latest: release.version }); }); const handleYes = (req, res) => { debugLog("tempApp received YES"); userDecision = true; res.sendStatus(200); }; const handleNo = (req, res) => { debugLog("tempApp received NO"); userDecision = false; res.sendStatus(200); }; tempApp.all('/update/yes', handleYes); tempApp.all('/update/install', handleYes); tempApp.all('/update/no', handleNo); tempApp.all('/update/skip', handleNo); tempApp.all('/update/:choice', (req, res) => { const c = String(req.params.choice || '').toLowerCase(); if (c === 'yes' || c === 'install' || c === 'true') userDecision = true; if (c === 'no' || c === 'skip' || c === 'false') userDecision = false; res.sendStatus(200); }); const tempServer = await new Promise((resolve) => { const s = tempApp.listen(4891, '127.0.0.1', () => resolve(s)); s.on('error', (err) => { debugLog(`Update server bind error: ${err.message}`); resolve(null); }); }); if (!tempServer) { userDecision = false; } else { sendToTauri('update-available', { current: localVersion, latest: release.version }); } let waitLoops = 0; while (userDecision === null) { if (waitLoops > 24000) { debugLog("Update decision timed out after 10 minutes, defaulting to skip."); userDecision = false; break; } waitLoops++; await new Promise(r => setTimeout(r, 25)); } updateDecisionHandler = null; if (tempServer) { tempServer.close(); } if (userDecision) { debugLog("User accepted update."); await installBuild(release); } else { debugLog("User skipped update."); sendToTauri('status', { message: 'Update skipped. Continuing startup...' }); } } else { debugLog("Already on the latest version."); sendToTauri('status', { message: 'Up to date.' }); } } else if (isFirstRun) { debugLog("No remote release found, but it's the first run. Looking for local update-payload.zip..."); sendToTauri('status', { message: 'Looking for local payload archive...' }); const localZip = path.join(__dirname, 'update-payload.zip'); if (fs.existsSync(localZip)) { sendToTauri('status', { message: 'Extracting local baseline payload...' }); patcher.purgeOldCode(); patcher.extractZipPayload(localZip); fileUpdater.snapshotBase(); patcher.applyPatches(getReplacementsConfig()); patcher.bridgeCredentials(serviceManager.credentialManager); let ver = "1.0.0"; try { const man = JSON.parse(fs.readFileSync(path.join(__dirname, 'manifest.json'), 'utf8')); ver = man.version || ver; } catch (e) { } patcher.saveCurrentVersion(ver); 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('Could not download the app files. Check your internet connection and relaunch.'); } if (process.env.VYLA_TEST_UI === '1') { const fakeFiles = [ { path: 'frontend/server.js', status: 'modified', size: 1024 }, { path: 'frontend/public/app.js', status: 'modified', size: 2048 }, { path: 'player/server.js', status: 'modified', size: 512 }, { path: 'player/lib/new-file.js', status: 'added', size: 256 } ]; const filesAnswer = await promptUser('files-available', { files: fakeFiles }); debugLog(`TEST files answer: ${JSON.stringify(filesAnswer)}`); if (filesAnswer && filesAnswer.accept) { for (let i = 1; i <= filesAnswer.files.length; i++) { sendToTauri('extract-progress', { file: filesAnswer.files[i - 1], done: i, total: filesAnswer.files.length, percent: Math.round((i / filesAnswer.files.length) * 100) }); await new Promise((r) => setTimeout(r, 400)); } } const sdkAnswer = await promptUser('sdk-available', { current: '1.19.0', latest: '1.20.0' }); debugLog(`TEST sdk answer: ${JSON.stringify(sdkAnswer)}`); sendToTauri('status', { message: sdkAnswer && sdkAnswer.accept ? 'TEST: SDK accepted' : 'TEST: SDK skipped' }); await new Promise((r) => setTimeout(r, 1500)); } sendToTauri('status', { message: 'Checking UI files and SDK...' }); const [filesCheck, sdkCheck] = await Promise.all([ withTimeout(fileUpdater.checkFiles().catch((e) => { debugLog(`File check failed: ${e.message}`); return null; }), 180000), withTimeout(fileUpdater.checkSdk().catch((e) => { debugLog(`SDK check failed: ${e.message}`); return null; }), 12000) ]); if (filesCheck && filesCheck.changes.length > 0) { sendToTauri('status', { message: `${filesCheck.changes.length} file update(s) available.` }); const answer = await promptUser('files-available', { files: filesCheck.changes.map((c) => ({ path: c.path, status: c.status, size: c.size })) }); if (answer && answer.accept) { const allowed = new Set(answer.files); const selected = filesCheck.changes.filter((c) => allowed.has(c.path)); if (selected.length > 0) { try { await fileUpdater.applyFiles(selected, filesCheck.seq, getReplacementsConfig(), (file, done, total) => { sendToTauri('extract-progress', { file, done, total, percent: Math.round((done / total) * 100) }); }); sendToTauri('status', { message: `Updated ${selected.length} file(s).` }); } catch (e) { debugLog(`File update failed: ${e.message}`); sendToTauri('status', { message: `File update failed: ${e.message}` }); } } } } if (sdkCheck && sdkCheck.outdated) { sendToTauri('status', { message: `SDK update available (${sdkCheck.latest}).` }); const answer = await promptUser('sdk-available', { current: sdkCheck.current, latest: sdkCheck.latest }); if (answer && answer.accept) { try { sendToTauri('status', { message: `Installing SDK ${sdkCheck.latest}...` }); 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."); 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', () => { if (serviceManager) { serviceManager.stopAll(); } }); process.on('SIGINT', () => { process.exit(0); }); process.on('SIGTERM', () => { process.exit(0); }); start();