mirror of
https://gitlab.com/vyla-entertainment/home.git
synced 2026-11-09 19:57:20 -05:00
520 lines
20 KiB
JavaScript
520 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');
|
|
|
|
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 expressApp;
|
|
let expressServer;
|
|
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');
|
|
}
|
|
|
|
function sameTabNavMiddleware(rootDir) {
|
|
return (req, res, next) => {
|
|
if (req.method !== 'GET') return next();
|
|
const isHtmlRequest = req.path === '/' || req.path.endsWith('/') || req.path.endsWith('.html');
|
|
if (!isHtmlRequest) return next();
|
|
|
|
const filePath = req.path === '/' || req.path.endsWith('/')
|
|
? path.join(rootDir, req.path, 'index.html')
|
|
: path.join(rootDir, req.path);
|
|
|
|
fs.readFile(filePath, 'utf8', (err, data) => {
|
|
if (err) return next();
|
|
const injected = data.replace(
|
|
'</head>',
|
|
`<script>
|
|
document.addEventListener('click', function(e) {
|
|
const a = e.target.closest('a[target]');
|
|
if (a && a.target === '_blank') a.removeAttribute('target');
|
|
}, true);
|
|
window.open = function(url) {
|
|
if (url) window.location.href = url;
|
|
return window;
|
|
};
|
|
</script></head>`
|
|
);
|
|
res.set('Content-Type', 'text/html');
|
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
|
|
res.set('Expires', '-1');
|
|
res.set('Pragma', 'no-cache');
|
|
res.send(injected);
|
|
});
|
|
};
|
|
}
|
|
|
|
const cmdApp = express();
|
|
cmdApp.use((req, res, next) => {
|
|
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();
|
|
});
|
|
cmdApp.all('/update/:choice', (req, res) => {
|
|
const c = String(req.params.choice).toLowerCase();
|
|
if (updateDecisionHandler) {
|
|
if (c === 'yes' || c === 'true' || c === 'install') updateDecisionHandler(true);
|
|
if (c === 'no' || c === 'false' || c === 'skip') updateDecisionHandler(false);
|
|
}
|
|
res.sendStatus(200);
|
|
});
|
|
cmdApp.get('/__tauri_cmd/:cmd', (req, res) => {
|
|
const cmd = req.params.cmd;
|
|
if (updateDecisionHandler) {
|
|
if (cmd === 'update_yes' || cmd === 'update_install') updateDecisionHandler(true);
|
|
if (cmd === 'update_no' || cmd === 'update_skip') updateDecisionHandler(false);
|
|
}
|
|
debugLog(`[CMD-SERVER:4892] Command received: ${req.params.cmd} (window: ${req.query.window || 'main'})`);
|
|
sendToTauri('tauri-cmd', { cmd: req.params.cmd, url: req.query.url, window: req.query.window });
|
|
res.sendStatus(200);
|
|
});
|
|
cmdApp.listen(4892, '127.0.0.1', () => debugLog("[CMD-SERVER] Dedicated command server listening on port 4892"));
|
|
|
|
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);
|
|
|
|
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 (!patcher.verifyChecksum(tempZipPath, release.sha256)) {
|
|
throw new Error('SHA-256 checksum verification failed.');
|
|
}
|
|
|
|
debugLog('Checksum verified. Extracting...');
|
|
sendToTauri('status', { message: 'Unpacking update archive...' });
|
|
patcher.purgeOldCode();
|
|
patcher.extractZipPayload(tempZipPath);
|
|
|
|
sendToTauri('status', { message: 'Verifying file integrity...' });
|
|
if (!patcher.verifyExtraction()) {
|
|
throw new Error('Extraction verification failed: critical startup files missing.');
|
|
}
|
|
|
|
debugLog('Extraction complete. Applying patches...');
|
|
sendToTauri('status', { message: 'Applying configuration patches...' });
|
|
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...' });
|
|
let isFirstRun = !patcher.verifyExtraction();
|
|
let localVersion = patcher.getCurrentVersion();
|
|
let localSha = patcher.getCurrentSha();
|
|
debugLog(`Is First Run: ${isFirstRun}, Local Version: ${localVersion}, Local SHA: ${localSha}`);
|
|
|
|
function findLocalZip() {
|
|
const searchRoots = [__dirname, process.cwd(), path.join(__dirname, '../../..')];
|
|
for (const root of searchRoots) {
|
|
let cur = root;
|
|
for (let i = 0; i < 6; i++) {
|
|
try {
|
|
const zp = path.join(cur, 'update-payload.zip');
|
|
if (fs.existsSync(zp)) {
|
|
const mp = path.join(cur, 'manifest.json');
|
|
return { zipPath: zp, manifestPath: fs.existsSync(mp) ? mp : null };
|
|
}
|
|
const parent = path.dirname(cur);
|
|
if (parent === cur) break;
|
|
cur = parent;
|
|
} catch (e) { }
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const localFound = findLocalZip();
|
|
let localZipPath = localFound ? localFound.zipPath : null;
|
|
let localManifestPath = localFound ? localFound.manifestPath : null;
|
|
let localZipManifest = null;
|
|
|
|
if (localManifestPath) {
|
|
try {
|
|
localZipManifest = JSON.parse(fs.readFileSync(localManifestPath, 'utf8'));
|
|
} catch (e) { }
|
|
}
|
|
|
|
if (localZipPath && (isFirstRun || (localZipManifest && localZipManifest.sha256 !== localSha))) {
|
|
debugLog(`Applying local development payload from: ${localZipPath}`);
|
|
sendToTauri('status', { message: 'Extracting local development payload...' });
|
|
patcher.purgeOldCode();
|
|
patcher.extractZipPayload(localZipPath);
|
|
patcher.applyPatches(getReplacementsConfig());
|
|
patcher.bridgeCredentials(serviceManager.credentialManager);
|
|
|
|
const ver = localZipManifest ? localZipManifest.version : '1.1.32';
|
|
const sha = localZipManifest ? localZipManifest.sha256 : null;
|
|
patcher.saveCurrentVersion(ver, sha);
|
|
serviceManager.envConfig = getEnvConfig();
|
|
|
|
isFirstRun = false;
|
|
localVersion = ver;
|
|
localSha = sha;
|
|
debugLog("Local payload applied successfully.");
|
|
}
|
|
|
|
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'}`);
|
|
|
|
let appPkgVer = '0.0.0';
|
|
try {
|
|
appPkgVer = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')).version || '0.0.0';
|
|
} catch (e) { }
|
|
|
|
if (release && release.version) {
|
|
const rVer = release.version.replace('v', '').split('.').map(Number);
|
|
const aVer = appPkgVer.split('.').map(Number);
|
|
let isOlder = false;
|
|
for (let i = 0; i < 3; i++) {
|
|
if ((rVer[i] || 0) < (aVer[i] || 0)) { isOlder = true; break; }
|
|
if ((rVer[i] || 0) > (aVer[i] || 0)) { break; }
|
|
}
|
|
if (isOlder) {
|
|
debugLog(`Remote version ${release.version} is older than local package ${appPkgVer}. Ignoring remote release.`);
|
|
release = null;
|
|
}
|
|
}
|
|
} 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();
|
|
|
|
if (release) {
|
|
const isNewerSha = release.sha256 && localSha && release.sha256 !== localSha;
|
|
const isDifferentVer = normalizeVer(release.version) !== normalizeVer(localVersion);
|
|
const hasUpdate = (isDifferentVer || isNewerSha) && !localZipPath;
|
|
|
|
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}). Awaiting confirmation...` });
|
|
|
|
try {
|
|
if (process.platform === 'win32') {
|
|
require('child_process').spawnSync('powershell.exe', [
|
|
'-NoProfile', '-NonInteractive', '-Command',
|
|
`Get-NetTCPConnection -LocalPort 4891 -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }`
|
|
], { stdio: 'ignore', windowsHide: true });
|
|
} else {
|
|
require('child_process').spawnSync('fuser', ['-k', '-9', `4891/tcp`], { stdio: 'ignore' });
|
|
}
|
|
} catch (e) { }
|
|
|
|
const tempApp = express();
|
|
let userDecision = null;
|
|
updateDecisionHandler = (val) => { userDecision = val; };
|
|
|
|
tempApp.use((req, res, next) => {
|
|
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);
|
|
});
|
|
});
|
|
|
|
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);
|
|
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('No application files found. Ensure you ran "npm run build:payload" and the extraction succeeded.');
|
|
}
|
|
|
|
sendToTauri('status', { message: 'Loading encrypted credentials...' });
|
|
serviceManager.credentialManager.loadCredentials();
|
|
|
|
sendToTauri('status', { message: 'Starting background services (player)...' });
|
|
await serviceManager.startServices([
|
|
{ name: 'player', relativeDir: 'player', startScript: 'server.js' }
|
|
]);
|
|
debugLog("Backend services spawn requested.");
|
|
|
|
sendToTauri('status', { message: 'Waiting for microservices to bind port 3000...' });
|
|
await serviceManager.waitForPorts([3000]);
|
|
debugLog("Port 3000 is ready.");
|
|
|
|
sendToTauri('status', { message: 'Binding UI server on port 4890...' });
|
|
const frontendDir = path.join(APPDATA_DIR, 'frontend');
|
|
expressApp = express();
|
|
expressApp.use((req, res, next) => {
|
|
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();
|
|
});
|
|
expressApp.get('/__tauri_cmd/:cmd', (req, res) => {
|
|
debugLog(`[FRONTEND-SERVER:4890] Command received: ${req.params.cmd} (window: ${req.query.window || 'main'})`);
|
|
sendToTauri('tauri-cmd', { cmd: req.params.cmd, url: req.query.url, window: req.query.window });
|
|
res.sendStatus(200);
|
|
});
|
|
|
|
expressApp.use(sameTabNavMiddleware(frontendDir));
|
|
expressApp.use(express.static(frontendDir, {
|
|
etag: false,
|
|
setHeaders: (res) => {
|
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
|
|
}
|
|
}));
|
|
expressApp.use(express.static(path.join(frontendDir, 'src'), {
|
|
etag: false,
|
|
setHeaders: (res) => {
|
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
|
|
}
|
|
}));
|
|
expressApp.get('*', (req, res) => {
|
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
|
|
res.sendFile(path.join(frontendDir, 'index.html'));
|
|
});
|
|
|
|
const FRONTEND_PORT = 4890;
|
|
expressApp.use('/build', express.static(path.join(__dirname, 'build')));
|
|
|
|
expressServer = expressApp.listen(FRONTEND_PORT, '127.0.0.1', () => {
|
|
sendToTauri('status', { message: 'All backend services running and healthy.' });
|
|
debugLog("Frontend Express running on 4890. Sending READY to Tauri.");
|
|
sendToTauri('ready', { url: `http://127.0.0.1:${FRONTEND_PORT}` });
|
|
});
|
|
|
|
} catch (error) {
|
|
debugLog(`Fatal Start Error: ${error.message}`);
|
|
sendToTauri('error', { message: error.message });
|
|
}
|
|
}
|
|
|
|
process.on('exit', () => {
|
|
if (serviceManager) {
|
|
for (const name of Object.keys(serviceManager.processes)) {
|
|
const proc = serviceManager.processes[name];
|
|
if (proc && proc.child && proc.child.pid) {
|
|
try {
|
|
if (process.platform === 'win32') {
|
|
require('child_process').spawnSync('taskkill', ['/F', '/T', '/PID', proc.child.pid.toString()], { stdio: 'ignore', windowsHide: true });
|
|
} else {
|
|
process.kill(-proc.child.pid, 'SIGKILL');
|
|
}
|
|
} catch (e) { }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
process.on('SIGINT', () => {
|
|
process.exit(0);
|
|
});
|
|
process.on('SIGTERM', () => {
|
|
process.exit(0);
|
|
});
|
|
|
|
start(); |