Signed per-component updater, release 1.1.55

This commit is contained in:
2026-10-30 22:38:38 -07:00
parent 0c6a9d1e5b
commit 79dd5e7465
10 changed files with 586 additions and 162 deletions

4
.gitignore vendored
View File

@@ -36,4 +36,6 @@ yarn-error.log*
*.temp *.temp
download-temp.zip download-temp.zip
.env .env
release-assets/

View File

@@ -73,6 +73,8 @@ function cleanBuildDir() {
fs.mkdirSync(BUILD_DIR, { recursive: true }); fs.mkdirSync(BUILD_DIR, { recursive: true });
} }
let credentialsId = null;
function encryptCredentialsFile() { function encryptCredentialsFile() {
const CryptoUtils = require('./crypto-utils'); const CryptoUtils = require('./crypto-utils');
@@ -109,6 +111,7 @@ function encryptCredentialsFile() {
} }
} }
credentialsId = crypto.createHash('sha256').update(JSON.stringify(credentials)).digest('hex');
const buildKey = CryptoUtils.deriveKey('vyla-home-build-key', 'vyla-home-payload-salt-v1'); const buildKey = CryptoUtils.deriveKey('vyla-home-build-key', 'vyla-home-payload-salt-v1');
const encryptedData = CryptoUtils.encryptObject(credentials, buildKey); const encryptedData = CryptoUtils.encryptObject(credentials, buildKey);
@@ -229,6 +232,32 @@ function validatePayload(zipPath) {
return validationResults.checksum; return validationResults.checksum;
} }
function directoryId(dir) {
const hash = crypto.createHash('sha256');
const walk = (current, rel) => {
for (const name of fs.readdirSync(current).sort()) {
const full = path.join(current, name);
const relPath = rel ? `${rel}/${name}` : name;
if (fs.statSync(full).isDirectory()) {
walk(full, relPath);
} else {
hash.update(`${relPath}\0${getFileChecksum(full)}\n`);
}
}
};
walk(dir, '');
return hash.digest('hex');
}
function signManifest(manifest) {
const key = process.env.VYLA_UPDATE_SIGNING_KEY;
if (!key) {
throw new Error('VYLA_UPDATE_SIGNING_KEY is required to sign the update manifest.');
}
const UpdateClient = require('./update-client');
return crypto.sign(null, Buffer.from(UpdateClient.canonicalManifest(manifest)), key.replace(/\\n/g, '\n')).toString('base64');
}
async function build() { async function build() {
try { try {
cloneAllRepos(); cloneAllRepos();
@@ -319,13 +348,41 @@ async function build() {
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')); const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
wrapperVersion = pkg.version || '2.0.0'; wrapperVersion = pkg.version || '2.0.0';
} catch (e) { } } catch (e) { }
const releaseVersion = (process.env.RELEASE_VERSION || wrapperVersion).replace(/^v/, '');
const assetsDir = path.join(__dirname, 'release-assets');
fs.rmSync(assetsDir, { recursive: true, force: true });
fs.mkdirSync(assetsDir, { recursive: true });
const components = {};
const addComponent = (name, fill, id) => {
const file = `${name}.zip`;
const target = path.join(assetsDir, file);
const componentZip = new AdmZip();
fill(componentZip);
componentZip.writeZip(target);
components[name] = { file, sha256: getFileChecksum(target), id, size: fs.statSync(target).size };
};
for (const repo of REPOS) {
const destRepo = path.join(BUILD_DIR, repo);
addComponent(repo, (z) => z.addLocalFolder(destRepo, repo), directoryId(destRepo));
}
addComponent('config', (z) => z.addLocalFolder(configDir, 'config'), directoryId(configDir));
const bundledCredentials = path.join(BUILD_DIR, 'encrypted-credentials.json');
if (fs.existsSync(bundledCredentials) && credentialsId) {
addComponent('secrets', (z) => z.addFile('encrypted-credentials.json', fs.readFileSync(bundledCredentials)), credentialsId);
}
const manifest = { const manifest = {
version: wrapperVersion, version: releaseVersion,
sha256: checksum, sha256: checksum,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
hasEncryptedCredentials: !!encryptedCredentialsPath hasEncryptedCredentials: !!encryptedCredentialsPath,
components
}; };
manifest.signature = signManifest(manifest);
fs.writeFileSync(path.join(__dirname, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8'); fs.writeFileSync(path.join(__dirname, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');

View File

@@ -330,19 +330,6 @@ class FileUpdater {
return changed ? Buffer.from(text, 'utf8') : buf; return changed ? Buffer.from(text, 'utf8') : buf;
} }
async repoTree(root) {
const out = [];
for (let page = 1; page <= 50; page++) {
const url = `${REPO_API}${root}/repository/tree?recursive=true&per_page=100&ref=main&page=${page}`;
return fetchBuffer(`${REPO_API}${root}/repository/files/${file}/raw?ref=main`, { headers: this.liveHeaders(), maxBytes: MAX_FILE, hosts: LIVE_HOSTS });
const items = JSON.parse(raw.toString('utf8'));
if (!Array.isArray(items) || items.length === 0) break;
out.push(...items);
if (items.length < 100) break;
}
return out;
}
async loadArchive(root) { async loadArchive(root) {
const buf = await fetchBuffer(`${REPO_API}${root}/repository/archive.zip?sha=main`, { headers: this.liveHeaders(), maxBytes: 600 * 1024 * 1024, hosts: LIVE_HOSTS }); const buf = await fetchBuffer(`${REPO_API}${root}/repository/archive.zip?sha=main`, { headers: this.liveHeaders(), maxBytes: 600 * 1024 * 1024, hosts: LIVE_HOSTS });
const zip = new AdmZip(buf); const zip = new AdmZip(buf);
@@ -363,7 +350,7 @@ class FileUpdater {
const parts = rel.split('/'); const parts = rel.split('/');
const root = parts.shift(); const root = parts.shift();
const file = encodeURIComponent(parts.join('/')); const file = encodeURIComponent(parts.join('/'));
return fetchBuffer(`${REPO_API}${root}/repository/files/${file}/raw?ref=main`, { maxBytes: MAX_FILE, hosts: LIVE_HOSTS }); return fetchBuffer(`${REPO_API}${root}/repository/files/${file}/raw?ref=main`, { headers: this.liveHeaders(), maxBytes: MAX_FILE, hosts: LIVE_HOSTS });
} }
async headCommit(root) { async headCommit(root) {
@@ -550,6 +537,19 @@ class FileUpdater {
return found; return found;
} }
recoverSdk() {
for (const root of [this.appDataPath, path.join(this.appDataPath, 'frontend'), path.join(this.appDataPath, 'player')]) {
const dir = path.join(root, 'node_modules', ...SDK_NAME.split('/'));
const old = `${dir}.vyla-old`;
const stage = `${dir}.vyla-new`;
try {
if (!fs.existsSync(dir) && fs.existsSync(old)) fs.renameSync(old, dir);
fs.rmSync(stage, { recursive: true, force: true });
if (fs.existsSync(dir)) fs.rmSync(old, { recursive: true, force: true });
} catch (e) { }
}
}
async checkSdk() { async checkSdk() {
const raw = await fetchBuffer(SDK_LATEST_URL, { const raw = await fetchBuffer(SDK_LATEST_URL, {
headers: this.sdkHeaders(), headers: this.sdkHeaders(),

267
main.js
View File

@@ -82,6 +82,11 @@ function getReplacementsConfig() {
} catch (e) { return {}; } } catch (e) { return {}; }
} }
function liveReposEnabled() {
const cfg = getEnvConfig();
return !!(cfg.updater && cfg.updater.liveRepos === true);
}
const updateClient = new UpdateClient(APPDATA_DIR); const updateClient = new UpdateClient(APPDATA_DIR);
const fileUpdater = new FileUpdater(APPDATA_DIR, updateClient); const fileUpdater = new FileUpdater(APPDATA_DIR, updateClient);
fileUpdater.onNotice = (message) => sendToTauri('notice', { message }); fileUpdater.onNotice = (message) => sendToTauri('notice', { message });
@@ -122,93 +127,113 @@ async function start() {
const patcher = new Patcher(APPDATA_DIR); const patcher = new Patcher(APPDATA_DIR);
serviceManager = new ServiceManager(APPDATA_DIR, getEnvConfig()); serviceManager = new ServiceManager(APPDATA_DIR, getEnvConfig());
const installBuild = async (release) => { const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
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) { const downloadVerified = async (name, item, dest) => {
attempts++; let lastError;
for (let attempt = 1; attempt <= 3; attempt++) {
fs.rmSync(dest, { force: true });
try { try {
debugLog(`Attempting to download update (Attempt ${attempts})...`); sendToTauri('status', { message: `Downloading ${name} (attempt ${attempt}/3)...` });
sendToTauri('status', { message: `Connecting to download servers (Attempt ${attempts}/3)...` });
let lastPercent = -1; let lastPercent = -1;
await updateClient.downloadUpdate(release, tempZipPath, (downloaded, total) => { await updateClient.downloadUpdate(item, dest, (downloaded, total) => {
const percent = total > 0 ? Math.round((downloaded / total) * 100) : 0; const percent = total > 0 ? Math.round((downloaded / total) * 100) : 0;
if (percent !== lastPercent) { if (percent !== lastPercent) {
lastPercent = percent; lastPercent = percent;
sendToTauri('download-progress', { downloaded, total, percent }); sendToTauri('download-progress', { downloaded, total, percent });
} }
}); });
sendToTauri('status', { message: `Verifying ${name} SHA-256...` });
debugLog('Download complete. Verifying checksum...'); if (!patcher.verifyChecksum(dest, item.sha256)) {
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.'); 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}`);
};
debugLog('Checksum verified. Extracting to staging...'); const installBuild = async (release) => {
sendToTauri('status', { message: 'Unpacking update archive...' }); 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; let lastEmit = 0;
const stagingDir = await patcher.extractToStaging(tempZipPath, (file, done, total) => { await patcher.extractToStaging(tempZip, (file, done, total) => {
const now = Date.now(); const now = Date.now();
if (done !== total && now - lastEmit < 40) return; if (done !== total && now - lastEmit < 40) return;
lastEmit = now; lastEmit = now;
sendToTauri('extract-progress', { sendToTauri('extract-progress', { file, done, total, percent: Math.round((done / total) * 100) });
file, }, i > 0);
done, fs.rmSync(tempZip, { force: true });
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));
} }
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);
} 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 { 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...' }); sendToTauri('status', { message: 'Inspecting local workspace and installed files...' });
const isFirstRun = !patcher.verifyExtraction(); const isFirstRun = !patcher.verifyExtraction();
const localVersion = patcher.getCurrentVersion(); const localVersion = patcher.getCurrentVersion();
@@ -219,10 +244,10 @@ async function start() {
let release = null; let release = null;
try { try {
debugLog("Fetching updates from updateClient..."); debugLog("Fetching updates from updateClient...");
release = await updateClient.checkForUpdates(); release = await updateClient.checkForUpdates(isFirstRun);
debugLog(`Update check finished. Found: ${release ? release.version : 'None'}`); debugLog(`Update check finished. Found: ${release ? release.version : 'None'}`);
} catch (e) { } catch (e) {
debugLog(`Update check failed (offline?): ${e.message}`); debugLog(`Update check failed: ${e.message}`);
sendToTauri('notice', { message: `Update check skipped: ${e.message}` }); sendToTauri('notice', { message: `Update check skipped: ${e.message}` });
} }
@@ -240,44 +265,73 @@ async function start() {
}; };
if (release) { if (release) {
const versionNewer = isNewer(release.version, localVersion); const stale = patcher.staleComponents(release);
const sameVerNewBuild = normalizeVer(release.version) === normalizeVer(localVersion) && const broken = stale.some((name) => !patcher.componentIntact(name));
!!release.sha256 && !!localSha && release.sha256 !== localSha; const sameVersion = normalizeVer(release.version) === normalizeVer(localVersion);
const hasUpdate = versionNewer || sameVerNewBuild; const hasUpdate = stale.length > 0 && (broken || isNewer(release.version, localVersion) || sameVersion);
if (isFirstRun) { if (hasUpdate) {
debugLog("First run with no local payload. Installing remote build..."); debugLog(`Installing components: ${stale.join(', ')}`);
sendToTauri('status', { message: 'First run detected. Preparing initial installation...' }); sendToTauri('status', {
await installBuild(release); message: isFirstRun ? 'First run detected. Preparing initial installation...' : `Updating to ${release.version}...`
} else if (hasUpdate) { });
debugLog("Update available. Installing update..."); try {
sendToTauri('status', { message: `Updating to ${release.version}...` }); await installBuild(release);
await installBuild(release); updateClient.recordSuccess();
} catch (err) {
updateClient.recordFailure();
debugLog(`Update failed: ${err.message}`);
sendToTauri('notice', { message: `Update failed: ${err.message}` });
}
} else { } else {
debugLog("Already on the latest version."); debugLog("Already on the latest version.");
sendToTauri('status', { message: 'Up to date.' }); 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...");
if (!patcher.verifyExtraction()) {
debugLog("No usable install. Looking for local update-payload.zip...");
sendToTauri('status', { message: 'Looking for local payload archive...' }); sendToTauri('status', { message: 'Looking for local payload archive...' });
const localZip = path.join(__dirname, 'update-payload.zip'); const payloadRoots = [
if (fs.existsSync(localZip)) { 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...' }); sendToTauri('status', { message: 'Extracting local baseline payload...' });
patcher.purgeOldCode(); const stagingDir = await patcher.extractToStaging(localZip);
patcher.extractZipPayload(localZip); if (!patcher.verifyStaged(stagingDir, ['frontend', 'player'])) {
fileUpdater.snapshotBase(); fs.rmSync(stagingDir, { recursive: true, force: true });
patcher.applyPatches(getReplacementsConfig()); throw new Error('Bundled payload is incomplete.');
patcher.bridgeCredentials(serviceManager.credentialManager); }
patcher.commitStaging(stagingDir);
let ver = "1.0.0";
let manSha = null;
try { try {
const man = JSON.parse(fs.readFileSync(path.join(__dirname, 'manifest.json'), 'utf8')); patcher.applyPatches(getReplacementsConfig());
ver = man.version || ver; patcher.bridgeCredentials(serviceManager.credentialManager);
manSha = man.sha256 || null;
} catch (e) { }
patcher.saveCurrentVersion(ver, manSha); 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);
} catch (e) {
try { patcher.rollback(); } catch (x) { }
throw e;
}
patcher.finalizeCommit();
if (liveReposEnabled()) fileUpdater.snapshotBase();
serviceManager.envConfig = getEnvConfig(); serviceManager.envConfig = getEnvConfig();
debugLog("Local payload extracted successfully."); debugLog("Local payload extracted successfully.");
} else { } else {
@@ -287,23 +341,28 @@ async function start() {
sendToTauri('status', { message: 'Validating critical components...' }); sendToTauri('status', { message: 'Validating critical components...' });
if (!patcher.verifyExtraction()) { if (!patcher.verifyExtraction()) {
throw new Error('Could not download the app files. Check your internet connection and relaunch.'); 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.');
} }
sendToTauri('status', { message: 'Checking UI files and SDK...' }); const liveRepos = liveReposEnabled();
sendToTauri('status', { message: liveRepos ? 'Checking UI files and SDK...' : 'Checking SDK...' });
const [filesCheck, sdkCheck] = await Promise.all([ const [filesCheck, sdkCheck] = await Promise.all([
withTimeout(fileUpdater.checkRepos().catch((e) => { liveRepos
debugLog(`Live file check failed: ${e.message}`); ? withTimeout(fileUpdater.checkRepos().catch((e) => {
sendToTauri('notice', { message: `File check failed: ${e.message}` }); debugLog(`Live file check failed: ${e.message}`);
return null; sendToTauri('notice', { message: `File check failed: ${e.message}` });
}), 240000), return null;
}), 60000)
: Promise.resolve(null),
withTimeout(fileUpdater.checkSdk().catch((e) => { withTimeout(fileUpdater.checkSdk().catch((e) => {
debugLog(`SDK check failed: ${e.message}`); debugLog(`SDK check failed: ${e.message}`);
sendToTauri('notice', { message: `SDK check failed: ${e.message}` }); sendToTauri('notice', { message: `SDK check failed: ${e.message}` });
return null; return null;
}), 12000) }), 12000)
]); ]);
debugLog(`Repo check result: ${filesCheck ? `${filesCheck.changes.length} change(s)` : 'failed or timed out'}`); 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)) { if (filesCheck && (filesCheck.changes.length > 0 || filesCheck.removed.length > 0)) {
sendToTauri('status', { message: `Updating ${filesCheck.changes.length} file(s)...` }); sendToTauri('status', { message: `Updating ${filesCheck.changes.length} file(s)...` });

View File

@@ -1,6 +1,6 @@
{ {
"name": "vyla-home", "name": "vyla-home",
"version": "1.1.54", "version": "1.1.55",
"description": "Vyla Home Desktop App Launcher", "description": "Vyla Home Desktop App Launcher",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {

View File

@@ -4,6 +4,29 @@ const crypto = require('crypto');
const AdmZip = require('adm-zip'); const AdmZip = require('adm-zip');
const CryptoUtils = require('./crypto-utils'); const CryptoUtils = require('./crypto-utils');
const COMPONENT_PROBES = {
frontend: 'frontend/server.js',
player: 'player/server.js',
config: 'config/env.json',
secrets: 'encrypted-credentials.json'
};
const COMMIT_ALLOWED = new Set(['frontend', 'player', 'config', 'encrypted-credentials.json']);
function renameSafe(from, to) {
let last;
for (let i = 0; i < 6; i++) {
try {
fs.renameSync(from, to);
return;
} catch (e) {
last = e;
if (!['EPERM', 'EBUSY', 'EACCES', 'ENOTEMPTY'].includes(e.code)) throw e;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 250 * (i + 1));
}
}
throw last;
}
class Patcher { class Patcher {
constructor(appDataPath) { constructor(appDataPath) {
this.appDataPath = appDataPath; this.appDataPath = appDataPath;
@@ -51,10 +74,55 @@ class Patcher {
return true; return true;
} }
saveCurrentVersion(version, sha256 = null) { saveCurrentVersion(version, sha256 = null, components = null) {
const versionFile = path.join(this.appDataPath, 'version.json'); const versionFile = path.join(this.appDataPath, 'version.json');
fs.mkdirSync(this.appDataPath, { recursive: true }); fs.mkdirSync(this.appDataPath, { recursive: true });
fs.writeFileSync(versionFile, JSON.stringify({ version, sha256 }, null, 2), 'utf8'); const tmp = `${versionFile}.tmp`;
fs.writeFileSync(tmp, JSON.stringify({ version, sha256, components: components || {} }, null, 2), 'utf8');
renameSafe(tmp, versionFile);
}
getCurrentComponents() {
try {
const c = JSON.parse(fs.readFileSync(path.join(this.appDataPath, 'version.json'), 'utf8')).components;
return c && typeof c === 'object' ? c : {};
} catch (e) {
return {};
}
}
componentIntact(name) {
const probe = COMPONENT_PROBES[name];
if (!probe || name === 'secrets') return true;
try {
return fs.statSync(path.join(this.appDataPath, probe)).size > 0;
} catch (e) {
return false;
}
}
staleComponents(release) {
const local = this.getCurrentComponents();
const names = Object.keys(release.components);
const stale = new Set(names.filter((n) => !this.componentIntact(n) || local[n] !== release.components[n].id));
if (stale.has('config')) {
for (const n of ['frontend', 'player']) {
if (release.components[n]) stale.add(n);
}
}
return [...stale];
}
verifyStaged(stagingDir, names) {
return names.every((name) => {
const probe = COMPONENT_PROBES[name];
if (!probe) return true;
try {
return fs.statSync(path.join(stagingDir, probe)).size > 0;
} catch (e) {
return false;
}
});
} }
purgeOldCode() { purgeOldCode() {
@@ -89,9 +157,9 @@ class Patcher {
zip.extractAllTo(this.appDataPath, true); zip.extractAllTo(this.appDataPath, true);
} }
async extractToStaging(zipPath, onFile) { async extractToStaging(zipPath, onFile, keep = false) {
const stagingDir = path.join(this.appDataPath, '.staging'); const stagingDir = path.join(this.appDataPath, '.staging');
fs.rmSync(stagingDir, { recursive: true, force: true }); if (!keep) fs.rmSync(stagingDir, { recursive: true, force: true });
fs.mkdirSync(stagingDir, { recursive: true }); fs.mkdirSync(stagingDir, { recursive: true });
const zip = new AdmZip(zipPath); const zip = new AdmZip(zipPath);
const entries = zip.getEntries().filter((e) => !e.isDirectory); const entries = zip.getEntries().filter((e) => !e.isDirectory);
@@ -103,7 +171,7 @@ class Patcher {
for (const entry of entries) { for (const entry of entries) {
const entryPath = path.normalize(entry.entryName); const entryPath = path.normalize(entry.entryName);
const resolvedPath = path.resolve(resolvedStaging, entryPath); const resolvedPath = path.resolve(resolvedStaging, entryPath);
if (!resolvedPath.startsWith(resolvedStaging)) { if (!resolvedPath.startsWith(resolvedStaging + path.sep)) {
throw new Error(`Directory traversal attempt detected: ${entry.entryName}`); throw new Error(`Directory traversal attempt detected: ${entry.entryName}`);
} }
} }
@@ -117,23 +185,133 @@ class Patcher {
return stagingDir; return stagingDir;
} }
journalPath() {
return path.join(this.appDataPath, '.update-journal.json');
}
backupDir() {
return path.join(this.appDataPath, '.backup');
}
commitStaging(stagingDir) { commitStaging(stagingDir) {
this.purgeOldCode(); const names = fs.readdirSync(stagingDir);
for (const name of fs.readdirSync(stagingDir)) { const unexpected = names.filter((n) => !COMMIT_ALLOWED.has(n));
const dest = path.join(this.appDataPath, name); if (unexpected.length > 0) {
fs.rmSync(dest, { recursive: true, force: true }); throw new Error(`Unexpected top-level entries in update: ${unexpected.join(', ')}`);
fs.renameSync(path.join(stagingDir, name), dest); }
const backup = this.backupDir();
fs.rmSync(backup, { recursive: true, force: true });
fs.mkdirSync(backup, { recursive: true });
const versionFile = path.join(this.appDataPath, 'version.json');
if (fs.existsSync(versionFile)) {
fs.copyFileSync(versionFile, path.join(backup, 'version.json.bak'));
}
const journal = { names, hadOld: {}, installed: [] };
for (const name of names) {
journal.hadOld[name] = fs.existsSync(path.join(this.appDataPath, name));
}
const save = () => {
const tmp = `${this.journalPath()}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(journal), 'utf8');
renameSafe(tmp, this.journalPath());
};
save();
try {
for (const name of names) {
const dest = path.join(this.appDataPath, name);
if (journal.hadOld[name]) {
renameSafe(dest, path.join(backup, name));
}
journal.installed.push(name);
save();
renameSafe(path.join(stagingDir, name), dest);
}
} catch (e) {
try { this.rollback(); } catch (x) { }
throw e;
} }
fs.rmSync(stagingDir, { recursive: true, force: true }); fs.rmSync(stagingDir, { recursive: true, force: true });
} }
rollback() {
let journal;
try {
journal = JSON.parse(fs.readFileSync(this.journalPath(), 'utf8'));
} catch (e) {
return false;
}
const backup = this.backupDir();
const hadOld = journal.hadOld || {};
const installed = journal.installed || [];
for (const name of journal.names || []) {
const dest = path.join(this.appDataPath, name);
const saved = path.join(backup, name);
if (hadOld[name]) {
if (fs.existsSync(saved)) {
fs.rmSync(dest, { recursive: true, force: true });
renameSafe(saved, dest);
}
} else if (installed.includes(name)) {
fs.rmSync(dest, { recursive: true, force: true });
}
}
const versionFile = path.join(this.appDataPath, 'version.json');
const savedVersion = path.join(backup, 'version.json.bak');
if (fs.existsSync(savedVersion)) {
fs.copyFileSync(savedVersion, versionFile);
} else {
fs.rmSync(versionFile, { force: true });
}
fs.rmSync(backup, { recursive: true, force: true });
fs.rmSync(this.journalPath(), { force: true });
fs.rmSync(path.join(this.appDataPath, '.staging'), { recursive: true, force: true });
return true;
}
finalizeCommit() {
fs.rmSync(this.backupDir(), { recursive: true, force: true });
fs.rmSync(this.journalPath(), { force: true });
for (const dir of ['stream-api', 'auth-proxy']) {
fs.rmSync(path.join(this.appDataPath, dir), { recursive: true, force: true });
}
}
recoverInterrupted() {
const had = fs.existsSync(this.journalPath());
if (had && !this.rollback()) {
fs.rmSync(this.journalPath(), { force: true });
fs.rmSync(this.backupDir(), { recursive: true, force: true });
}
fs.rmSync(path.join(this.appDataPath, '.staging'), { recursive: true, force: true });
try {
for (const f of fs.readdirSync(this.appDataPath)) {
if (/^download-.*\.zip$/.test(f)) fs.rmSync(path.join(this.appDataPath, f), { force: true });
}
} catch (e) { }
return had;
}
verifyChecksum(zipPath, expectedSha256) { verifyChecksum(zipPath, expectedSha256) {
if (!expectedSha256) return false; if (!expectedSha256) return false;
const hash = crypto.createHash('sha256'); const hash = crypto.createHash('sha256');
const data = fs.readFileSync(zipPath); const fd = fs.openSync(zipPath, 'r');
hash.update(data); const buf = Buffer.alloc(1024 * 1024);
const calculated = hash.digest('hex'); try {
return calculated === String(expectedSha256).trim().toLowerCase(); let n;
while ((n = fs.readSync(fd, buf, 0, buf.length, null)) > 0) {
hash.update(buf.subarray(0, n));
}
} finally {
fs.closeSync(fd);
}
return hash.digest('hex') === String(expectedSha256).trim().toLowerCase();
} }
verifyExtraction(root = this.appDataPath) { verifyExtraction(root = this.appDataPath) {
@@ -194,8 +372,8 @@ class Patcher {
} }
} }
applyPatches(replacements) { applyPatches(replacements, only = null) {
const targetDirs = ['frontend', 'player']; const targetDirs = ['frontend', 'player'].filter((d) => !only || only.includes(d));
for (const dirName of targetDirs) { for (const dirName of targetDirs) {
const dirPath = path.join(this.appDataPath, dirName); const dirPath = path.join(this.appDataPath, dirName);
if (!fs.existsSync(dirPath)) continue; if (!fs.existsSync(dirPath)) continue;

View File

@@ -165,7 +165,7 @@ async function main() {
console.log(`Building Vyla Home Release (${version})`); console.log(`Building Vyla Home Release (${version})`);
console.log('[1/4] Building update payload and manifest...'); console.log('[1/4] Building update payload and manifest...');
execSync('node build-pipeline.js', { stdio: 'inherit' }); execSync('node build-pipeline.js', { stdio: 'inherit', env: { ...process.env, RELEASE_VERSION: version } });
const payloadPath = path.join(__dirname, 'update-payload.zip'); const payloadPath = path.join(__dirname, 'update-payload.zip');
const manifestPath = path.join(__dirname, 'manifest.json'); const manifestPath = path.join(__dirname, 'manifest.json');
@@ -231,6 +231,13 @@ async function main() {
{ name: 'manifest.json', url: manifestUrl, link_type: 'other' } { name: 'manifest.json', url: manifestUrl, link_type: 'other' }
]; ];
const manifestData = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
for (const [name, c] of Object.entries(manifestData.components || {})) {
console.log(`Uploading ${c.file}...`);
const componentUrl = await uploadFileToGitLabPackage(path.join(__dirname, 'release-assets', c.file), 'vyla-home', version, `${name}-${timestamp}.zip`);
releaseLinks.push({ name: c.file, url: componentUrl, link_type: 'other' });
}
if (installerPath && installerUploadName) { if (installerPath && installerUploadName) {
console.log(`Uploading installer ${installerUploadName}...`); console.log(`Uploading installer ${installerUploadName}...`);
const ext = path.extname(installerUploadName); const ext = path.extname(installerUploadName);

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "vyla-home" name = "vyla-home"
version = "1.1.54" version = "1.1.55"
description = "Vyla Home Desktop App Launcher" description = "Vyla Home Desktop App Launcher"
edition = "2021" edition = "2021"

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Vyla Home", "productName": "Vyla Home",
"version": "1.1.54", "version": "1.1.55",
"identifier": "com.vyla.home", "identifier": "com.vyla.home",
"build": { "build": {
"frontendDist": "../ui" "frontendDist": "../ui"

View File

@@ -1,5 +1,14 @@
const https = require('https'); const https = require('https');
const fs = require('fs'); const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const UPDATE_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAKIuPPzTCGdTksi7hp+V4urau6mMQt2XhNlEfRVbjNZ4=
-----END PUBLIC KEY-----`;
const MIN_CHECK_INTERVAL_MS = 15 * 60 * 1000;
const MAX_RETRY_AFTER_MS = 120000;
const MAX_BACKOFF_MS = 6 * 60 * 60 * 1000;
const GITLAB_GROUP = 'vyla-entertainment'; const GITLAB_GROUP = 'vyla-entertainment';
const GITLAB_REPO = 'home'; const GITLAB_REPO = 'home';
@@ -25,25 +34,81 @@ function isAllowedHost(url) {
} }
} }
function normalizeTag(v) {
return String(v || '').replace(/^v/, '').trim();
}
function canonicalManifest(manifest) {
const parts = Object.keys(manifest.components || {}).sort().map((name) => {
const c = manifest.components[name];
return `${name}:${c.file}:${c.sha256}:${c.id}:${c.size}`;
});
return ['vyla-manifest-v2', normalizeTag(manifest.version), manifest.sha256, ...parts].join('\n');
}
function verifyManifest(manifest) {
if (!manifest || typeof manifest.signature !== 'string' || !manifest.components) return false;
try {
return crypto.verify(null, Buffer.from(canonicalManifest(manifest)), UPDATE_PUBLIC_KEY, Buffer.from(manifest.signature, 'base64'));
} catch (e) {
return false;
}
}
class UpdateClient { class UpdateClient {
constructor(appDataPath, token = null) { constructor(appDataPath, token = null) {
this.appDataPath = appDataPath; this.appDataPath = appDataPath;
this.token = token || process.env.GITLAB_TOKEN || process.env.VYLA_GITLAB_TOKEN || null; this.token = token || process.env.GITLAB_TOKEN || process.env.VYLA_GITLAB_TOKEN || null;
} }
fetchJson(url) { cachePath() {
return path.join(this.appDataPath, 'update-cache.json');
}
readCache() {
try {
return JSON.parse(fs.readFileSync(this.cachePath(), 'utf8'));
} catch (e) {
return {};
}
}
writeCache(patch) {
try {
fs.mkdirSync(this.appDataPath, { recursive: true });
fs.writeFileSync(this.cachePath(), JSON.stringify({ ...this.readCache(), ...patch }), 'utf8');
} catch (e) { }
}
recordFailure() {
const failures = (this.readCache().failures || 0) + 1;
const delay = Math.min(5 * 60 * 1000 * 2 ** (failures - 1), MAX_BACKOFF_MS);
this.writeCache({ failures, blockedUntil: Date.now() + delay });
}
recordSuccess() {
this.writeCache({ failures: 0, blockedUntil: 0 });
}
cacheUsable(release) {
return !!release && !!release.components && verifyManifest(release.manifest);
}
fetchJson(url, etag = null, attempt = 0) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const urlObj = new URL(url); const urlObj = new URL(url);
const headers = { const headers = {
'User-Agent': 'VylaHome-Client', 'User-Agent': 'VylaHome-Client',
'Accept': 'application/json', 'Accept': 'application/json',
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache'
'Pragma': 'no-cache'
}; };
if (this.token) { if (this.token) {
headers['PRIVATE-TOKEN'] = this.token; headers['PRIVATE-TOKEN'] = this.token;
} }
if (etag) {
headers['If-None-Match'] = etag;
}
const requestOptions = { const requestOptions = {
hostname: urlObj.hostname, hostname: urlObj.hostname,
@@ -58,12 +123,23 @@ class UpdateClient {
let data = ''; let data = '';
res.on('data', (chunk) => { data += chunk; }); res.on('data', (chunk) => { data += chunk; });
res.on('end', () => { res.on('end', () => {
if (res.statusCode === 304) {
resolve({ notModified: true, etag });
return;
}
if ((res.statusCode === 429 || res.statusCode >= 500) && attempt < 3) {
const retryAfter = parseInt(res.headers['retry-after'], 10);
const base = Number.isFinite(retryAfter) ? retryAfter * 1000 : 1000 * 2 ** attempt;
const wait = Math.min(base, MAX_RETRY_AFTER_MS) + Math.floor(Math.random() * 1000);
setTimeout(() => this.fetchJson(url, etag, attempt + 1).then(resolve, reject), wait);
return;
}
if (res.statusCode !== 200) { if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`)); reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
return; return;
} }
try { try {
resolve(JSON.parse(data)); resolve({ data: JSON.parse(data), etag: res.headers.etag || null });
} catch (e) { } catch (e) {
reject(new Error('Invalid JSON response')); reject(new Error('Invalid JSON response'));
} }
@@ -120,7 +196,13 @@ class UpdateClient {
} }
if (res.statusCode !== 200) { if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`)); res.resume();
const err = new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`);
const retryAfter = parseInt(res.headers['retry-after'], 10);
if (Number.isFinite(retryAfter)) {
err.retryAfterMs = Math.min(retryAfter * 1000, MAX_RETRY_AFTER_MS);
}
reject(err);
return; return;
} }
@@ -236,12 +318,32 @@ class UpdateClient {
}); });
} }
async checkForUpdates() { async checkForUpdates(force = false) {
const query = `?per_page=20&order_by=released_at&sort=desc&cb=${Date.now()}`; const cache = this.readCache();
const releases = await this.fetchJson(RELEASES_API + query); const now = Date.now();
if (!releases || releases.length === 0) {
if (!force && cache.blockedUntil && now < cache.blockedUntil) {
return null; return null;
} }
if (!force && cache.checkedAt && now - cache.checkedAt < MIN_CHECK_INTERVAL_MS && this.cacheUsable(cache.release)) {
return cache.release;
}
const listUrl = `${RELEASES_API}?per_page=20&order_by=released_at&sort=desc`;
let result = await this.fetchJson(listUrl, force ? null : cache.etag);
if (result.notModified) {
if (this.cacheUsable(cache.release)) {
this.writeCache({ checkedAt: now });
return cache.release;
}
result = await this.fetchJson(listUrl, null);
}
const releases = result.data;
if (!Array.isArray(releases) || releases.length === 0) {
return null;
}
const verParts = (v) => String(v || '').replace(/^v/, '').split(/[.\-+]/).map((n) => parseInt(n, 10) || 0); const verParts = (v) => String(v || '').replace(/^v/, '').split(/[.\-+]/).map((n) => parseInt(n, 10) || 0);
const cmp = (a, b) => { const cmp = (a, b) => {
const x = verParts(a); const x = verParts(a);
@@ -252,25 +354,41 @@ class UpdateClient {
return 0; return 0;
}; };
const release = releases.slice().sort((a, b) => cmp(b.tag_name, a.tag_name))[0]; const release = releases.slice().sort((a, b) => cmp(b.tag_name, a.tag_name))[0];
const links = release.assets && release.assets.links ? release.assets.links : [];
const manifestAsset = links.find((a) => a.name === 'manifest.json');
const payloadAsset = (release.assets && release.assets.links ? release.assets.links : []).find(a => a.name === 'update-payload.zip'); if (!manifestAsset) {
const manifestAsset = (release.assets && release.assets.links ? release.assets.links : []).find(a => a.name === 'manifest.json'); throw new Error(`Release ${release.tag_name} has no manifest.json.`);
if (!payloadAsset) {
return null;
} }
let sha256 = null; const manifest = await this.fetchAssetJson(manifestAsset.url);
if (manifestAsset) { if (!manifest.components) {
const manifest = await this.fetchAssetJson(manifestAsset.url); throw new Error(`Release ${release.tag_name} has no signed component manifest.`);
sha256 = manifest.sha256 || null; }
if (!verifyManifest(manifest)) {
throw new Error('Update manifest signature is invalid.');
}
if (normalizeTag(manifest.version) !== normalizeTag(release.tag_name)) {
throw new Error('Update manifest version does not match the release tag.');
} }
return { const components = {};
for (const [name, c] of Object.entries(manifest.components)) {
const link = links.find((a) => a.name === c.file);
if (!link) {
throw new Error(`Release is missing asset ${c.file}.`);
}
components[name] = { url: link.url, sha256: c.sha256, id: c.id, size: c.size };
}
const out = {
version: release.tag_name, version: release.tag_name,
url: payloadAsset.url, sha256: manifest.sha256 || null,
sha256 manifest,
components
}; };
this.writeCache({ checkedAt: now, etag: result.etag || null, release: out });
return out;
} }
async downloadUpdate(release, destPath, onProgress) { async downloadUpdate(release, destPath, onProgress) {
@@ -279,4 +397,7 @@ class UpdateClient {
} }
} }
UpdateClient.canonicalManifest = canonicalManifest;
UpdateClient.verifyManifest = verifyManifest;
module.exports = UpdateClient; module.exports = UpdateClient;