mirror of
https://gitlab.com/vyla-entertainment/home.git
synced 2026-11-13 21:57:21 -05:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a333d6770 | |||
| 9c4d250640 | |||
| 03ed2f0e53 | |||
| 47c97fdf21 | |||
| e7204589a4 | |||
| 3e2dbd312e | |||
| 79dd5e7465 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -36,4 +36,6 @@ yarn-error.log*
|
||||
*.temp
|
||||
download-temp.zip
|
||||
|
||||
.env
|
||||
.env
|
||||
|
||||
release-assets/
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -17,13 +17,14 @@ class CryptoUtils {
|
||||
|
||||
let key;
|
||||
try {
|
||||
const isMac = os.platform() === 'darwin';
|
||||
const machineId = [
|
||||
os.hostname(),
|
||||
isMac ? '' : os.hostname(),
|
||||
os.platform(),
|
||||
os.arch(),
|
||||
os.cpus()[0] ? os.cpus()[0].model : '',
|
||||
process.env.COMPUTERNAME || '',
|
||||
process.env.USERNAME || ''
|
||||
process.env.USERNAME || (isMac ? os.userInfo().username : '')
|
||||
].join('|');
|
||||
|
||||
const hash = crypto.createHash('sha256');
|
||||
|
||||
@@ -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(),
|
||||
|
||||
295
main.js
295
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,114 @@ 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);
|
||||
patcher.writeIntegrity(names);
|
||||
} catch (err) {
|
||||
try { patcher.rollback(); } catch (x) { debugLog(`Rollback failed: ${x.message}`); }
|
||||
throw err;
|
||||
}
|
||||
|
||||
patcher.finalizeCommit();
|
||||
if (liveReposEnabled()) fileUpdater.snapshotBase();
|
||||
serviceManager.envConfig = getEnvConfig();
|
||||
debugLog(`Update installed successfully: ${names.join(', ')}`);
|
||||
};
|
||||
|
||||
try {
|
||||
if (patcher.recoverInterrupted()) {
|
||||
debugLog('Recovered from an interrupted update.');
|
||||
sendToTauri('notice', { message: 'Recovered from an interrupted update.' });
|
||||
}
|
||||
fileUpdater.recoverSdk();
|
||||
sendToTauri('status', { message: 'Inspecting local workspace and installed files...' });
|
||||
const isFirstRun = !patcher.verifyExtraction();
|
||||
const localVersion = patcher.getCurrentVersion();
|
||||
@@ -219,10 +245,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}` });
|
||||
}
|
||||
|
||||
@@ -239,45 +265,96 @@ async function start() {
|
||||
return false;
|
||||
};
|
||||
|
||||
if (release) {
|
||||
const versionNewer = isNewer(release.version, localVersion);
|
||||
const sameVerNewBuild = normalizeVer(release.version) === normalizeVer(localVersion) &&
|
||||
!!release.sha256 && !!localSha && release.sha256 !== localSha;
|
||||
const hasUpdate = versionNewer || sameVerNewBuild;
|
||||
if (release && release.installer && process.platform === 'win32' && process.env.VYLA_DEV !== '1') {
|
||||
const appVersion = process.env.VYLA_APP_VERSION || '0.0.0';
|
||||
const tried = updateClient.readCache().selfUpdate || {};
|
||||
const recentlyTried = tried.version === release.installer.version && Date.now() - (tried.at || 0) < 24 * 60 * 60 * 1000;
|
||||
if (isNewer(release.installer.version, appVersion) && !recentlyTried) {
|
||||
try {
|
||||
const dest = path.join(APPDATA_DIR, `vyla-installer-${normalizeVer(release.installer.version)}.exe`);
|
||||
sendToTauri('status', { message: 'Downloading launcher update...' });
|
||||
await downloadVerified('launcher', release.installer, dest);
|
||||
updateClient.writeCache({ selfUpdate: { version: release.installer.version, at: Date.now() } });
|
||||
sendToTauri('status', { message: 'Installing launcher update...' });
|
||||
sendToTauri('self-update', { path: dest });
|
||||
await sleep(30000);
|
||||
sendToTauri('notice', { message: 'Launcher update did not start. Continuing on the current version.' });
|
||||
} catch (err) {
|
||||
debugLog(`Launcher update failed: ${err.message}`);
|
||||
sendToTauri('notice', { message: `Launcher update failed: ${err.message}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (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 (release) {
|
||||
const stale = patcher.staleComponents(release);
|
||||
const broken = stale.some((name) => !patcher.componentIntact(name));
|
||||
const sameVersion = normalizeVer(release.version) === normalizeVer(localVersion);
|
||||
const hasUpdate = stale.length > 0 && (broken || isNewer(release.version, localVersion) || sameVersion);
|
||||
|
||||
if (hasUpdate) {
|
||||
debugLog(`Installing components: ${stale.join(', ')}`);
|
||||
sendToTauri('status', {
|
||||
message: isFirstRun ? 'First run detected. Preparing initial installation...' : `Updating to ${release.version}...`
|
||||
});
|
||||
try {
|
||||
await installBuild(release);
|
||||
updateClient.recordSuccess();
|
||||
} catch (err) {
|
||||
updateClient.recordFailure();
|
||||
debugLog(`Update failed: ${err.message}`);
|
||||
sendToTauri('notice', { message: `Update failed: ${err.message}` });
|
||||
}
|
||||
} else {
|
||||
debugLog("Already on the latest version.");
|
||||
sendToTauri('status', { message: 'Up to date.' });
|
||||
}
|
||||
} 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);
|
||||
patcher.writeIntegrity(['frontend', 'player']);
|
||||
} catch (e) {
|
||||
try { patcher.rollback(); } catch (x) { }
|
||||
throw e;
|
||||
}
|
||||
|
||||
patcher.finalizeCommit();
|
||||
if (liveReposEnabled()) fileUpdater.snapshotBase();
|
||||
serviceManager.envConfig = getEnvConfig();
|
||||
debugLog("Local payload extracted successfully.");
|
||||
} else {
|
||||
@@ -287,23 +364,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)...` });
|
||||
@@ -377,5 +459,8 @@ process.on('SIGINT', () => {
|
||||
process.on('SIGTERM', () => {
|
||||
process.exit(0);
|
||||
});
|
||||
process.on('SIGHUP', () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
start();
|
||||
|
||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -1,19 +1,18 @@
|
||||
{
|
||||
"name": "vyla-home",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.56",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "vyla-home",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.56",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "2.12.1",
|
||||
"@tauri-apps/plugin-opener": "2.7.0",
|
||||
"@tauri-apps/plugin-process": "2.4.0",
|
||||
"@tauri-apps/plugin-updater": "^2",
|
||||
"@tauri-apps/plugin-shell": "2.5.0",
|
||||
"@vyla-entertainment/sdk": "^1.20.0",
|
||||
"@vyla-entertainment/sdk": "latest",
|
||||
"adm-zip": "^0.6.1",
|
||||
"express": "^4.22.3"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vyla-home",
|
||||
"version": "1.1.54",
|
||||
"version": "1.1.56",
|
||||
"description": "Vyla Home Desktop App Launcher",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
258
patcher.js
258
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,103 @@ 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 {};
|
||||
}
|
||||
}
|
||||
|
||||
integrityPath() {
|
||||
return path.join(this.appDataPath, 'integrity.json');
|
||||
}
|
||||
|
||||
readIntegrity() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(this.integrityPath(), 'utf8'));
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
writeIntegrity(names) {
|
||||
const all = this.readIntegrity();
|
||||
for (const name of names) {
|
||||
if (name !== 'frontend' && name !== 'player') continue;
|
||||
const files = {};
|
||||
const walk = (dir, rel) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
const r = `${rel}/${entry.name}`;
|
||||
if (r.includes('/node_modules/@vyla-entertainment/sdk')) continue;
|
||||
if (entry.isDirectory()) walk(full, r);
|
||||
else if (entry.isFile()) files[r] = fs.statSync(full).size;
|
||||
}
|
||||
};
|
||||
walk(path.join(this.appDataPath, name), name);
|
||||
all[name] = files;
|
||||
}
|
||||
const tmp = `${this.integrityPath()}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(all), 'utf8');
|
||||
renameSafe(tmp, this.integrityPath());
|
||||
}
|
||||
|
||||
integrityOk(name) {
|
||||
const files = this.readIntegrity()[name];
|
||||
if (!files) return true;
|
||||
for (const [rel, size] of Object.entries(files)) {
|
||||
try {
|
||||
if (fs.statSync(path.join(this.appDataPath, rel)).size !== size) return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
componentIntact(name) {
|
||||
const probe = COMPONENT_PROBES[name];
|
||||
if (!probe || name === 'secrets') return true;
|
||||
try {
|
||||
if (fs.statSync(path.join(this.appDataPath, probe)).size <= 0) return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
return this.integrityOk(name);
|
||||
}
|
||||
|
||||
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 +205,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 +219,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 +233,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 +420,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;
|
||||
|
||||
36
release.js
36
release.js
@@ -2,6 +2,8 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
const { execSync } = require('child_process');
|
||||
const crypto = require('crypto');
|
||||
const UpdateClient = require('./update-client');
|
||||
|
||||
const GITLAB_PROJECT_PATH = 'vyla-entertainment%2Fhome';
|
||||
const PROJECT_ID = process.env.CI_PROJECT_ID || GITLAB_PROJECT_PATH;
|
||||
@@ -165,7 +167,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');
|
||||
@@ -217,12 +219,44 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
if (installerPath && installerUploadName && process.platform === 'win32') {
|
||||
const signingKey = process.env.VYLA_UPDATE_SIGNING_KEY;
|
||||
if (!signingKey) {
|
||||
throw new Error('VYLA_UPDATE_SIGNING_KEY is required to sign the installer entry.');
|
||||
}
|
||||
const installerBuf = fs.readFileSync(installerPath);
|
||||
const signedManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
signedManifest.installer = {
|
||||
file: installerUploadName,
|
||||
sha256: crypto.createHash('sha256').update(installerBuf).digest('hex'),
|
||||
size: installerBuf.length,
|
||||
version: version.replace(/^v/, '')
|
||||
};
|
||||
delete signedManifest.signature;
|
||||
signedManifest.signature = crypto.sign(null, Buffer.from(UpdateClient.canonicalManifest(signedManifest)), signingKey.replace(/\\n/g, '\n')).toString('base64');
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(signedManifest, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
console.log('[2/4] Uploading artifacts to GitLab Package Registry...');
|
||||
const timestamp = Date.now();
|
||||
|
||||
console.log('Uploading update-payload.zip...');
|
||||
const payloadUrl = await uploadFileToGitLabPackage(payloadPath, 'vyla-home', version, `update-payload-${timestamp}.zip`);
|
||||
|
||||
const manifestData = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
for (const [name, c] of Object.entries(manifestData.components || {})) {
|
||||
console.log(`Uploading ${c.file}...`);
|
||||
c.url = await uploadFileToGitLabPackage(path.join(__dirname, 'release-assets', c.file), 'vyla-home', version, `${name}-${timestamp}.zip`);
|
||||
}
|
||||
|
||||
const signingKeyPem = process.env.VYLA_UPDATE_SIGNING_KEY;
|
||||
if (!signingKeyPem) {
|
||||
throw new Error('VYLA_UPDATE_SIGNING_KEY is required to sign the update manifest.');
|
||||
}
|
||||
delete manifestData.signature;
|
||||
manifestData.signature = crypto.sign(null, Buffer.from(UpdateClient.canonicalManifest(manifestData)), signingKeyPem.replace(/\\n/g, '\n')).toString('base64');
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifestData, null, 2), 'utf8');
|
||||
|
||||
console.log('Uploading manifest.json...');
|
||||
const manifestUrl = await uploadFileToGitLabPackage(manifestPath, 'vyla-home', version, `manifest-${timestamp}.json`);
|
||||
|
||||
|
||||
@@ -54,6 +54,9 @@ class ServiceManager {
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
...(process.platform === 'darwin'
|
||||
? { PATH: [process.env.PATH, '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin'].filter(Boolean).join(path.delimiter) }
|
||||
: {}),
|
||||
PORT: (this.envConfig[name] && this.envConfig[name].PORT) || defaultPorts[name] || process.env.PORT || 3000,
|
||||
TOKEN_SECRET: tokenSecret,
|
||||
...(this.envConfig[name] || {}),
|
||||
|
||||
3
src-tauri/Cargo.lock
generated
3
src-tauri/Cargo.lock
generated
@@ -4073,8 +4073,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vyla-home"
|
||||
version = "1.1.54"
|
||||
version = "1.1.56"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"objc2 0.5.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "vyla-home"
|
||||
version = "1.1.54"
|
||||
version = "1.1.56"
|
||||
description = "Vyla Home Desktop App Launcher"
|
||||
edition = "2021"
|
||||
|
||||
@@ -17,6 +17,9 @@ window-vibrancy = "0.8"
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.5"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
@@ -27,4 +30,4 @@ panic = "abort"
|
||||
[profile.dev]
|
||||
opt-level = 0
|
||||
debug = true
|
||||
incremental = true
|
||||
incremental = true
|
||||
@@ -8,6 +8,8 @@
|
||||
<true/>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -44,6 +44,8 @@ static WINDOW_COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::Atomi
|
||||
|
||||
static READY_URL: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
static APP_LOADED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
fn js_log(app: &AppHandle, msg: &str) {
|
||||
debug_log(msg);
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
@@ -94,6 +96,17 @@ fn kill_child_process(child: &mut Child) {
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output();
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let pgid = child.id() as i32;
|
||||
unsafe {
|
||||
libc::killpg(pgid, libc::SIGTERM);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(400));
|
||||
unsafe {
|
||||
libc::killpg(pgid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
@@ -580,7 +593,7 @@ fn open_new_window(app: AppHandle, url: String) -> Result<(), String> {
|
||||
|
||||
fn handle_ready(w: &tauri::WebviewWindow, msg: &BackendMessage) {
|
||||
let url_str = match msg.data.get("url").and_then(|u| u.as_str()) {
|
||||
Some(u) => u,
|
||||
Some(u) => u.to_string(),
|
||||
None => return,
|
||||
};
|
||||
|
||||
@@ -592,19 +605,36 @@ fn handle_ready(w: &tauri::WebviewWindow, msg: &BackendMessage) {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use objc2::msg_send;
|
||||
use objc2::runtime::AnyObject;
|
||||
debug_log("Attempting with_webview on ready event");
|
||||
let result = w.with_webview(|webview| unsafe {
|
||||
debug_log("Inside with_webview closure");
|
||||
use objc2::runtime::{AnyObject, Sel};
|
||||
use objc2::sel;
|
||||
let _ = w.with_webview(|webview| unsafe {
|
||||
let wk_webview = webview.inner() as *mut AnyObject;
|
||||
let config: *mut AnyObject = msg_send![wk_webview, configuration];
|
||||
let _: () = msg_send![config, setUpgradeKnownHostsToHTTPS: false];
|
||||
debug_log("setUpgradeKnownHostsToHTTPS set to false");
|
||||
let selector: Sel = sel!(setUpgradeKnownHostsToHTTPS:);
|
||||
let supported: bool = msg_send![config, respondsToSelector: selector];
|
||||
if supported {
|
||||
let _: () = msg_send![config, setUpgradeKnownHostsToHTTPS: false];
|
||||
}
|
||||
});
|
||||
debug_log(&format!("with_webview result: {:?}", result));
|
||||
}
|
||||
|
||||
let _ = w.navigate(url_parsed);
|
||||
APP_LOADED.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
let _ = w.navigate(url_parsed.clone());
|
||||
|
||||
let watcher = w.clone();
|
||||
std::thread::spawn(move || {
|
||||
for attempt in 1..=8 {
|
||||
std::thread::sleep(std::time::Duration::from_secs(4));
|
||||
if APP_LOADED.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
debug_log(&format!("App page not loaded yet, retry {}", attempt));
|
||||
let target = serde_json::to_string(&url_str).unwrap_or_default();
|
||||
let _ = watcher.eval(&format!("window.location.replace({});", target));
|
||||
let _ = watcher.navigate(url_parsed.clone());
|
||||
}
|
||||
debug_log("App page never loaded after retries");
|
||||
});
|
||||
}
|
||||
|
||||
fn main() {
|
||||
@@ -663,6 +693,9 @@ fn main() {
|
||||
.initialization_script("window.__VYLA_WINDOW_LABEL = 'main';")
|
||||
.on_page_load(move |wv, payload| {
|
||||
if matches!(payload.event(), tauri::webview::PageLoadEvent::Finished) {
|
||||
if payload.url().host_str() == Some("127.0.0.1") {
|
||||
APP_LOADED.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
if payload.url().host_str() != Some("127.0.0.1") {
|
||||
let known = READY_URL.lock().ok().and_then(|g| g.clone());
|
||||
if let Some(u) = known {
|
||||
@@ -726,6 +759,16 @@ fn main() {
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Ok(meta) = std::fs::metadata(&node_bin) {
|
||||
let mut perms = meta.permissions();
|
||||
perms.set_mode(0o755);
|
||||
let _ = std::fs::set_permissions(&node_bin, perms);
|
||||
}
|
||||
}
|
||||
|
||||
let vyla_files_str = clean_path(&vyla_files);
|
||||
|
||||
let mut cmd = Command::new(&node_bin);
|
||||
@@ -733,6 +776,8 @@ fn main() {
|
||||
.current_dir(&root_dir)
|
||||
.env("APPDATA_PATH", &vyla_files_str)
|
||||
.env("NODE_EXE", &node_bin)
|
||||
.env("VYLA_APP_VERSION", env!("CARGO_PKG_VERSION"))
|
||||
.env("VYLA_DEV", if cfg!(debug_assertions) { "1" } else { "0" })
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
@@ -744,6 +789,12 @@ fn main() {
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
cmd.process_group(0);
|
||||
}
|
||||
|
||||
match cmd.spawn() {
|
||||
Ok(mut child) => {
|
||||
debug_log(&format!(
|
||||
@@ -796,6 +847,40 @@ fn main() {
|
||||
};
|
||||
|
||||
match msg.event.as_str() {
|
||||
"self-update" => {
|
||||
let target = msg
|
||||
.data
|
||||
.get("path")
|
||||
.and_then(|p| p.as_str())
|
||||
.map(PathBuf::from);
|
||||
if let Some(p) = target {
|
||||
let name = p
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if name.starts_with("vyla-installer-")
|
||||
&& name.ends_with(".exe")
|
||||
&& p.exists()
|
||||
{
|
||||
let state: State<NodeServices> = w.state();
|
||||
if let Ok(mut lock) = state.0.lock() {
|
||||
if let Some(mut child) = lock.take() {
|
||||
kill_child_process(&mut child);
|
||||
}
|
||||
};
|
||||
let mut installer = Command::new(&p);
|
||||
installer.args(["/S", "/UPDATE"]);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
installer.creation_flags(0x00000008 | 0x00000200);
|
||||
}
|
||||
let _ = installer.spawn();
|
||||
w.app_handle().exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
"ready" => {
|
||||
if let Some(u) = msg.data.get("url").and_then(|u| u.as_str()) {
|
||||
if let Ok(mut g) = READY_URL.lock() {
|
||||
@@ -842,6 +927,16 @@ fn main() {
|
||||
}
|
||||
}
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app, event| {
|
||||
if let tauri::RunEvent::Exit = event {
|
||||
let state: State<NodeServices> = app.state();
|
||||
if let Ok(mut lock) = state.0.lock() {
|
||||
if let Some(mut child) = lock.take() {
|
||||
kill_child_process(&mut child);
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Vyla Home",
|
||||
"version": "1.1.54",
|
||||
"version": "1.1.56",
|
||||
"identifier": "com.vyla.home",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
|
||||
186
update-client.js
186
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,86 @@ 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}`;
|
||||
});
|
||||
const lines = ['vyla-manifest-v2', normalizeTag(manifest.version), manifest.sha256, ...parts];
|
||||
if (manifest.installer) {
|
||||
const i = manifest.installer;
|
||||
lines.push(`installer:${i.file}:${i.sha256}:${i.size}:${normalizeTag(i.version)}`);
|
||||
}
|
||||
return lines.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 +128,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 +201,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 +323,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 +359,59 @@ 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);
|
||||
const direct = typeof c.url === 'string' &&
|
||||
c.url.startsWith('https://gitlab.com/api/v4/projects/') &&
|
||||
c.url.includes('/packages/generic/vyla-home/');
|
||||
const url = direct ? c.url : (link && link.url);
|
||||
if (!url) {
|
||||
throw new Error(`Release is missing asset ${c.file}.`);
|
||||
}
|
||||
components[name] = { url, sha256: c.sha256, id: c.id, size: c.size };
|
||||
}
|
||||
|
||||
let installer = null;
|
||||
if (manifest.installer) {
|
||||
const installerLink = links.find((a) => a.name === manifest.installer.file);
|
||||
if (installerLink) {
|
||||
installer = {
|
||||
url: installerLink.url,
|
||||
sha256: manifest.installer.sha256,
|
||||
size: manifest.installer.size,
|
||||
version: manifest.installer.version
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const out = {
|
||||
version: release.tag_name,
|
||||
url: payloadAsset.url,
|
||||
sha256
|
||||
sha256: manifest.sha256 || null,
|
||||
manifest,
|
||||
components,
|
||||
installer
|
||||
};
|
||||
this.writeCache({ checkedAt: now, etag: result.etag || null, release: out });
|
||||
return out;
|
||||
}
|
||||
|
||||
async downloadUpdate(release, destPath, onProgress) {
|
||||
@@ -279,4 +420,7 @@ class UpdateClient {
|
||||
}
|
||||
}
|
||||
|
||||
UpdateClient.canonicalManifest = canonicalManifest;
|
||||
UpdateClient.verifyManifest = verifyManifest;
|
||||
|
||||
module.exports = UpdateClient;
|
||||
|
||||
Reference in New Issue
Block a user