mirror of
https://gitlab.com/vyla-entertainment/home.git
synced 2026-11-09 19:57:20 -05:00
534 lines
20 KiB
JavaScript
534 lines
20 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 {}; }
|
|
}
|
|
|
|
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();
|