diff --git a/.gitignore b/.gitignore index a2391e3..391bebb 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,6 @@ yarn-error.log* *.temp download-temp.zip -.env \ No newline at end of file +.env + +release-assets/ \ No newline at end of file diff --git a/build-pipeline.js b/build-pipeline.js index 7042a28..b516349 100644 --- a/build-pipeline.js +++ b/build-pipeline.js @@ -73,6 +73,8 @@ function cleanBuildDir() { fs.mkdirSync(BUILD_DIR, { recursive: true }); } +let credentialsId = null; + function encryptCredentialsFile() { 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 encryptedData = CryptoUtils.encryptObject(credentials, buildKey); @@ -229,6 +232,32 @@ function validatePayload(zipPath) { 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() { try { cloneAllRepos(); @@ -319,13 +348,41 @@ async function build() { const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')); wrapperVersion = pkg.version || '2.0.0'; } 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 = { - version: wrapperVersion, + version: releaseVersion, sha256: checksum, 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'); diff --git a/file-updater.js b/file-updater.js index 5863542..6c71fe4 100644 --- a/file-updater.js +++ b/file-updater.js @@ -330,19 +330,6 @@ class FileUpdater { 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) { 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); @@ -363,7 +350,7 @@ class FileUpdater { const parts = rel.split('/'); const root = parts.shift(); 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) { @@ -550,6 +537,19 @@ class FileUpdater { 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() { const raw = await fetchBuffer(SDK_LATEST_URL, { headers: this.sdkHeaders(), diff --git a/main.js b/main.js index 98e6cd3..d1b7852 100644 --- a/main.js +++ b/main.js @@ -82,6 +82,11 @@ function getReplacementsConfig() { } 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 }); @@ -122,93 +127,113 @@ async function start() { 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; + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - while (attempts < 3 && !downloadSuccess) { - attempts++; + const downloadVerified = async (name, item, dest) => { + let lastError; + for (let attempt = 1; attempt <= 3; attempt++) { + fs.rmSync(dest, { force: true }); try { - debugLog(`Attempting to download update (Attempt ${attempts})...`); - sendToTauri('status', { message: `Connecting to download servers (Attempt ${attempts}/3)...` }); - + sendToTauri('status', { message: `Downloading ${name} (attempt ${attempt}/3)...` }); 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; 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)) { + 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}`); + }; - debugLog('Checksum verified. Extracting to staging...'); - sendToTauri('status', { message: 'Unpacking update archive...' }); + 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; - const stagingDir = await patcher.extractToStaging(tempZipPath, (file, done, total) => { + 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) - }); - }); - - 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('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); + } 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(); @@ -219,10 +244,10 @@ async function start() { let release = null; try { debugLog("Fetching updates from updateClient..."); - release = await updateClient.checkForUpdates(); + release = await updateClient.checkForUpdates(isFirstRun); debugLog(`Update check finished. Found: ${release ? release.version : 'None'}`); } catch (e) { - debugLog(`Update check failed (offline?): ${e.message}`); + debugLog(`Update check failed: ${e.message}`); sendToTauri('notice', { message: `Update check skipped: ${e.message}` }); } @@ -240,44 +265,73 @@ async function start() { }; if (release) { - const versionNewer = isNewer(release.version, localVersion); - const sameVerNewBuild = normalizeVer(release.version) === normalizeVer(localVersion) && - !!release.sha256 && !!localSha && release.sha256 !== localSha; - const hasUpdate = versionNewer || sameVerNewBuild; + 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 (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. Installing update..."); - sendToTauri('status', { message: `Updating to ${release.version}...` }); - await installBuild(release); + 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.' }); } - } 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...' }); - const localZip = path.join(__dirname, 'update-payload.zip'); - if (fs.existsSync(localZip)) { + 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...' }); - patcher.purgeOldCode(); - patcher.extractZipPayload(localZip); - fileUpdater.snapshotBase(); - patcher.applyPatches(getReplacementsConfig()); - patcher.bridgeCredentials(serviceManager.credentialManager); + 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); - let ver = "1.0.0"; - let manSha = null; try { - const man = JSON.parse(fs.readFileSync(path.join(__dirname, 'manifest.json'), 'utf8')); - ver = man.version || ver; - manSha = man.sha256 || null; - } catch (e) { } + patcher.applyPatches(getReplacementsConfig()); + patcher.bridgeCredentials(serviceManager.credentialManager); - 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(); debugLog("Local payload extracted successfully."); } else { @@ -287,23 +341,28 @@ async function start() { sendToTauri('status', { message: 'Validating critical components...' }); 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([ - withTimeout(fileUpdater.checkRepos().catch((e) => { - debugLog(`Live file check failed: ${e.message}`); - sendToTauri('notice', { message: `File check failed: ${e.message}` }); - return null; - }), 240000), + 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) ]); - 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)) { sendToTauri('status', { message: `Updating ${filesCheck.changes.length} file(s)...` }); diff --git a/package.json b/package.json index 09ef8e8..4946eca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vyla-home", - "version": "1.1.54", + "version": "1.1.55", "description": "Vyla Home Desktop App Launcher", "main": "main.js", "scripts": { diff --git a/patcher.js b/patcher.js index a2d79c8..81f6341 100644 --- a/patcher.js +++ b/patcher.js @@ -4,6 +4,29 @@ const crypto = require('crypto'); const AdmZip = require('adm-zip'); 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 { constructor(appDataPath) { this.appDataPath = appDataPath; @@ -51,10 +74,55 @@ class Patcher { return true; } - saveCurrentVersion(version, sha256 = null) { + saveCurrentVersion(version, sha256 = null, components = null) { const versionFile = path.join(this.appDataPath, 'version.json'); 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() { @@ -89,9 +157,9 @@ class Patcher { zip.extractAllTo(this.appDataPath, true); } - async extractToStaging(zipPath, onFile) { + async extractToStaging(zipPath, onFile, keep = false) { 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 }); const zip = new AdmZip(zipPath); const entries = zip.getEntries().filter((e) => !e.isDirectory); @@ -103,7 +171,7 @@ class Patcher { for (const entry of entries) { const entryPath = path.normalize(entry.entryName); 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}`); } } @@ -117,23 +185,133 @@ class Patcher { return stagingDir; } + journalPath() { + return path.join(this.appDataPath, '.update-journal.json'); + } + + backupDir() { + return path.join(this.appDataPath, '.backup'); + } + commitStaging(stagingDir) { - this.purgeOldCode(); - for (const name of fs.readdirSync(stagingDir)) { - const dest = path.join(this.appDataPath, name); - fs.rmSync(dest, { recursive: true, force: true }); - fs.renameSync(path.join(stagingDir, name), dest); + const names = fs.readdirSync(stagingDir); + const unexpected = names.filter((n) => !COMMIT_ALLOWED.has(n)); + if (unexpected.length > 0) { + throw new Error(`Unexpected top-level entries in update: ${unexpected.join(', ')}`); + } + + 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 }); } + 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) { if (!expectedSha256) return false; const hash = crypto.createHash('sha256'); - const data = fs.readFileSync(zipPath); - hash.update(data); - const calculated = hash.digest('hex'); - return calculated === String(expectedSha256).trim().toLowerCase(); + const fd = fs.openSync(zipPath, 'r'); + const buf = Buffer.alloc(1024 * 1024); + try { + 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) { @@ -194,8 +372,8 @@ class Patcher { } } - applyPatches(replacements) { - const targetDirs = ['frontend', 'player']; + applyPatches(replacements, only = null) { + const targetDirs = ['frontend', 'player'].filter((d) => !only || only.includes(d)); for (const dirName of targetDirs) { const dirPath = path.join(this.appDataPath, dirName); if (!fs.existsSync(dirPath)) continue; diff --git a/release.js b/release.js index 6f79d21..8aff1e5 100644 --- a/release.js +++ b/release.js @@ -165,7 +165,7 @@ async function main() { console.log(`Building Vyla Home Release (${version})`); 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 manifestPath = path.join(__dirname, 'manifest.json'); @@ -231,6 +231,13 @@ async function main() { { 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) { console.log(`Uploading installer ${installerUploadName}...`); const ext = path.extname(installerUploadName); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c4e1478..e8c45d1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vyla-home" -version = "1.1.54" +version = "1.1.55" description = "Vyla Home Desktop App Launcher" edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6186eea..b50aa0f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Vyla Home", - "version": "1.1.54", + "version": "1.1.55", "identifier": "com.vyla.home", "build": { "frontendDist": "../ui" diff --git a/update-client.js b/update-client.js index fb4cf3d..efb9507 100644 --- a/update-client.js +++ b/update-client.js @@ -1,5 +1,14 @@ const https = require('https'); 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_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 { constructor(appDataPath, token = null) { this.appDataPath = appDataPath; 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) => { const urlObj = new URL(url); const headers = { 'User-Agent': 'VylaHome-Client', 'Accept': 'application/json', - 'Cache-Control': 'no-cache', - 'Pragma': 'no-cache' + 'Cache-Control': 'no-cache' }; if (this.token) { headers['PRIVATE-TOKEN'] = this.token; } + if (etag) { + headers['If-None-Match'] = etag; + } const requestOptions = { hostname: urlObj.hostname, @@ -58,12 +123,23 @@ class UpdateClient { let data = ''; res.on('data', (chunk) => { data += chunk; }); 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) { reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`)); return; } try { - resolve(JSON.parse(data)); + resolve({ data: JSON.parse(data), etag: res.headers.etag || null }); } catch (e) { reject(new Error('Invalid JSON response')); } @@ -120,7 +196,13 @@ class UpdateClient { } 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; } @@ -236,12 +318,32 @@ class UpdateClient { }); } - async checkForUpdates() { - const query = `?per_page=20&order_by=released_at&sort=desc&cb=${Date.now()}`; - const releases = await this.fetchJson(RELEASES_API + query); - if (!releases || releases.length === 0) { + async checkForUpdates(force = false) { + const cache = this.readCache(); + const now = Date.now(); + + if (!force && cache.blockedUntil && now < cache.blockedUntil) { 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 cmp = (a, b) => { const x = verParts(a); @@ -252,25 +354,41 @@ class UpdateClient { return 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'); - const manifestAsset = (release.assets && release.assets.links ? release.assets.links : []).find(a => a.name === 'manifest.json'); - - if (!payloadAsset) { - return null; + if (!manifestAsset) { + throw new Error(`Release ${release.tag_name} has no manifest.json.`); } - let sha256 = null; - if (manifestAsset) { - const manifest = await this.fetchAssetJson(manifestAsset.url); - sha256 = manifest.sha256 || null; + const manifest = await this.fetchAssetJson(manifestAsset.url); + if (!manifest.components) { + throw new Error(`Release ${release.tag_name} has no signed component manifest.`); + } + 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, - url: payloadAsset.url, - sha256 + sha256: manifest.sha256 || null, + manifest, + components }; + this.writeCache({ checkedAt: now, etag: result.etag || null, release: out }); + return out; } async downloadUpdate(release, destPath, onProgress) { @@ -279,4 +397,7 @@ class UpdateClient { } } +UpdateClient.canonicalManifest = canonicalManifest; +UpdateClient.verifyManifest = verifyManifest; + module.exports = UpdateClient;