mirror of
https://gitlab.com/vyla-entertainment/home.git
synced 2026-11-09 19:57:20 -05:00
feat: add signed Windows launcher self-updates with SHA-256 verification
This commit is contained in:
23
main.js
23
main.js
@@ -217,6 +217,7 @@ async function start() {
|
|||||||
next[name] = names.includes(name) ? release.components[name].id : current[name];
|
next[name] = names.includes(name) ? release.components[name].id : current[name];
|
||||||
}
|
}
|
||||||
patcher.saveCurrentVersion(release.version, release.sha256, next);
|
patcher.saveCurrentVersion(release.version, release.sha256, next);
|
||||||
|
patcher.writeIntegrity(names);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
try { patcher.rollback(); } catch (x) { debugLog(`Rollback failed: ${x.message}`); }
|
try { patcher.rollback(); } catch (x) { debugLog(`Rollback failed: ${x.message}`); }
|
||||||
throw err;
|
throw err;
|
||||||
@@ -264,6 +265,27 @@ async function start() {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 (release) {
|
if (release) {
|
||||||
const stale = patcher.staleComponents(release);
|
const stale = patcher.staleComponents(release);
|
||||||
const broken = stale.some((name) => !patcher.componentIntact(name));
|
const broken = stale.some((name) => !patcher.componentIntact(name));
|
||||||
@@ -325,6 +347,7 @@ async function start() {
|
|||||||
} catch (e) { }
|
} catch (e) { }
|
||||||
|
|
||||||
patcher.saveCurrentVersion(ver, manSha, ids);
|
patcher.saveCurrentVersion(ver, manSha, ids);
|
||||||
|
patcher.writeIntegrity(['frontend', 'player']);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
try { patcher.rollback(); } catch (x) { }
|
try { patcher.rollback(); } catch (x) { }
|
||||||
throw e;
|
throw e;
|
||||||
|
|||||||
50
patcher.js
50
patcher.js
@@ -91,14 +91,62 @@ class Patcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
componentIntact(name) {
|
||||||
const probe = COMPONENT_PROBES[name];
|
const probe = COMPONENT_PROBES[name];
|
||||||
if (!probe || name === 'secrets') return true;
|
if (!probe || name === 'secrets') return true;
|
||||||
try {
|
try {
|
||||||
return fs.statSync(path.join(this.appDataPath, probe)).size > 0;
|
if (fs.statSync(path.join(this.appDataPath, probe)).size <= 0) return false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
return this.integrityOk(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
staleComponents(release) {
|
staleComponents(release) {
|
||||||
|
|||||||
20
release.js
20
release.js
@@ -2,6 +2,8 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const UpdateClient = require('./update-client');
|
||||||
|
|
||||||
const GITLAB_PROJECT_PATH = 'vyla-entertainment%2Fhome';
|
const GITLAB_PROJECT_PATH = 'vyla-entertainment%2Fhome';
|
||||||
const PROJECT_ID = process.env.CI_PROJECT_ID || GITLAB_PROJECT_PATH;
|
const PROJECT_ID = process.env.CI_PROJECT_ID || GITLAB_PROJECT_PATH;
|
||||||
@@ -217,6 +219,24 @@ 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...');
|
console.log('[2/4] Uploading artifacts to GitLab Package Registry...');
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
|
|
||||||
|
|||||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -4073,7 +4073,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vyla-home"
|
name = "vyla-home"
|
||||||
version = "1.1.54"
|
version = "1.1.55"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -733,6 +733,8 @@ fn main() {
|
|||||||
.current_dir(&root_dir)
|
.current_dir(&root_dir)
|
||||||
.env("APPDATA_PATH", &vyla_files_str)
|
.env("APPDATA_PATH", &vyla_files_str)
|
||||||
.env("NODE_EXE", &node_bin)
|
.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())
|
.stdin(Stdio::piped())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped());
|
.stderr(Stdio::piped());
|
||||||
@@ -796,6 +798,40 @@ fn main() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match msg.event.as_str() {
|
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" => {
|
"ready" => {
|
||||||
if let Some(u) = msg.data.get("url").and_then(|u| u.as_str()) {
|
if let Some(u) = msg.data.get("url").and_then(|u| u.as_str()) {
|
||||||
if let Ok(mut g) = READY_URL.lock() {
|
if let Ok(mut g) = READY_URL.lock() {
|
||||||
|
|||||||
@@ -43,7 +43,12 @@ function canonicalManifest(manifest) {
|
|||||||
const c = manifest.components[name];
|
const c = manifest.components[name];
|
||||||
return `${name}:${c.file}:${c.sha256}:${c.id}:${c.size}`;
|
return `${name}:${c.file}:${c.sha256}:${c.id}:${c.size}`;
|
||||||
});
|
});
|
||||||
return ['vyla-manifest-v2', normalizeTag(manifest.version), manifest.sha256, ...parts].join('\n');
|
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) {
|
function verifyManifest(manifest) {
|
||||||
@@ -381,11 +386,25 @@ class UpdateClient {
|
|||||||
components[name] = { url: link.url, sha256: c.sha256, id: c.id, size: c.size };
|
components[name] = { url: link.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 = {
|
const out = {
|
||||||
version: release.tag_name,
|
version: release.tag_name,
|
||||||
sha256: manifest.sha256 || null,
|
sha256: manifest.sha256 || null,
|
||||||
manifest,
|
manifest,
|
||||||
components
|
components,
|
||||||
|
installer
|
||||||
};
|
};
|
||||||
this.writeCache({ checkedAt: now, etag: result.etag || null, release: out });
|
this.writeCache({ checkedAt: now, etag: result.etag || null, release: out });
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
Reference in New Issue
Block a user