feat: add signed Windows launcher self-updates with SHA-256 verification

This commit is contained in:
2026-10-30 22:50:07 -07:00
parent 79dd5e7465
commit 3e2dbd312e
6 changed files with 150 additions and 4 deletions

23
main.js
View File

@@ -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;

View File

@@ -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) {

View File

@@ -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
View File

@@ -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",

View File

@@ -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() {

View File

@@ -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;