Files
Home/main.js

464 lines
18 KiB
JavaScript

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 {}; }
}
function liveReposEnabled() {
const cfg = getEnvConfig();
return !!(cfg.updater && cfg.updater.liveRepos === true);
}
const updateClient = new UpdateClient(APPDATA_DIR);
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) < 24 * 60 * 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: 'Installing launcher update...' });
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.");
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();