diff --git a/main.js b/main.js index d1b7852..087473b 100644 --- a/main.js +++ b/main.js @@ -217,6 +217,7 @@ async function start() { 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; @@ -264,6 +265,27 @@ async function start() { 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) { const stale = patcher.staleComponents(release); const broken = stale.some((name) => !patcher.componentIntact(name)); @@ -325,6 +347,7 @@ async function start() { } catch (e) { } patcher.saveCurrentVersion(ver, manSha, ids); + patcher.writeIntegrity(['frontend', 'player']); } catch (e) { try { patcher.rollback(); } catch (x) { } throw e; diff --git a/patcher.js b/patcher.js index 81f6341..0bd2429 100644 --- a/patcher.js +++ b/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) { const probe = COMPONENT_PROBES[name]; if (!probe || name === 'secrets') return true; 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) { return false; } + return this.integrityOk(name); } staleComponents(release) { diff --git a/release.js b/release.js index 8aff1e5..c95baa5 100644 --- a/release.js +++ b/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; @@ -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...'); const timestamp = Date.now(); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3f98e94..fd4f3dd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4073,7 +4073,7 @@ dependencies = [ [[package]] name = "vyla-home" -version = "1.1.54" +version = "1.1.55" dependencies = [ "objc2 0.5.2", "serde", diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index dc55178..b9e8edf 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -733,6 +733,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()); @@ -796,6 +798,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 = 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() { diff --git a/update-client.js b/update-client.js index efb9507..d08c621 100644 --- a/update-client.js +++ b/update-client.js @@ -43,7 +43,12 @@ function canonicalManifest(manifest) { 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'); + 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) { @@ -381,11 +386,25 @@ class UpdateClient { 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 = { version: release.tag_name, sha256: manifest.sha256 || null, manifest, - components + components, + installer }; this.writeCache({ checkedAt: now, etag: result.etag || null, release: out }); return out;