Compare commits

...

16 Commits

Author SHA1 Message Date
8a333d6770 potentially fixed mac support 2026-11-04 21:19:30 -08:00
9c4d250640 updated 2026-11-01 14:16:41 -08:00
03ed2f0e53 fixed lock file 2026-11-01 14:16:04 -08:00
47c97fdf21 refactor: move component archives to GitLab Package Registry and keep release assets minimal 2026-10-30 23:00:25 -07:00
e7204589a4 v1.1.54 2026-10-30 22:58:34 -07:00
3e2dbd312e feat: add signed Windows launcher self-updates with SHA-256 verification 2026-10-30 22:50:07 -07:00
79dd5e7465 Signed per-component updater, release 1.1.55 2026-10-30 22:38:38 -07:00
0c6a9d1e5b v1.1.54 2026-10-30 20:13:21 -07:00
609891f478 updated debug 2026-10-30 20:02:41 -07:00
b201510b5a v1.1.53 2026-10-30 19:37:14 -07:00
acd46d665b fixed image issue 2026-10-30 19:23:15 -07:00
f07c0ea0fd v1.1.52 2026-10-30 19:08:41 -07:00
aaa0df5f17 fixed an extreme ammount of issues 2026-10-30 19:01:30 -07:00
08950e101f v1.1.51 2026-10-30 17:43:06 -07:00
f8aa86eecf fixed UI issue 2026-10-30 17:36:45 -07:00
587d70c46a v1.1.50 2026-10-29 22:28:46 -07:00
22 changed files with 1142 additions and 764 deletions

4
.gitignore vendored
View File

@@ -36,4 +36,6 @@ yarn-error.log*
*.temp
download-temp.zip
.env
.env
release-assets/

View File

@@ -24,14 +24,14 @@ Fixing os error 32
Stop-Process -Name "vyla-home", "node", "cargo" -Force -ErrorAction SilentlyContinue
```
Fake a test?
```powershell
$env:VYLA_TEST_UI="1"; npm run dev
```
Fake an old local version:
```powershell
'{"version":"0.0.1","sha256":null}' | Set-Content "$env:APPDATA\vyla-home\vyla-home-files\version.json"
```
Fake a test?
```powershell
$env:VYLA_TEST_UI="1"; npm run dev
```

View File

@@ -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);
@@ -174,7 +177,7 @@ function validatePayload(zipPath) {
const requiredFiles = [
'frontend/index.html'
];
const validationResults = {
valid: true,
errors: [],
@@ -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');

View File

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

View File

@@ -9,6 +9,9 @@ const SDK_NAME = '@vyla-entertainment/sdk';
const SDK_LATEST_URL = 'https://registry.npmjs.org/@vyla-entertainment%2Fsdk/latest';
const ALLOWED_ROOTS = ['frontend', 'player'];
const MAX_TARBALL = 100 * 1024 * 1024;
const MAX_FILE = 50 * 1024 * 1024;
const LIVE_HOSTS = ['gitlab.com'];
const REPO_API = 'https://gitlab.com/api/v4/projects/vyla-entertainment%2F';
const verParts = (v) => String(v || '').replace(/^v/, '').split(/[.\-+]/).map((n) => parseInt(n, 10) || 0);
const cmpVer = (a, b) => {
@@ -22,8 +25,11 @@ const cmpVer = (a, b) => {
const sha256Buf = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
const sha256File = (p) => sha256Buf(fs.readFileSync(p));
const gitBlobSha = (buf) => crypto.createHash('sha1').update(Buffer.concat([Buffer.from(`blob ${buf.length}\0`), buf])).digest('hex');
function fetchBuffer(url, { headers = {}, maxBytes, hosts, redirects = 0 }) {
const hooks = { notice: null };
function fetchBuffer(url, { headers = {}, maxBytes, hosts, redirects = 0, retries = 0 }) {
return new Promise((resolve, reject) => {
if (redirects > 5) return reject(new Error('Too many redirects'));
let u;
@@ -44,6 +50,15 @@ function fetchBuffer(url, { headers = {}, maxBytes, hosts, redirects = 0 }) {
fetchBuffer(next, { headers: keep, maxBytes, hosts, redirects: redirects + 1 }).then(resolve, reject);
return;
}
if (res.statusCode === 429 && retries < 4) {
res.resume();
const wait = Math.min(parseInt(res.headers['retry-after'], 10) || 5, 30) * 1000;
if (hooks.notice) hooks.notice(`GitLab rate limit (HTTP 429). Retrying in ${Math.round(wait / 1000)}s (${retries + 1}/4)...`);
setTimeout(() => {
fetchBuffer(url, { headers, maxBytes, hosts, redirects, retries: retries + 1 }).then(resolve, reject);
}, wait);
return;
}
if (res.statusCode !== 200) {
res.resume();
reject(new Error(`HTTP ${res.statusCode}`));
@@ -114,16 +129,24 @@ class FileUpdater {
this.appDataPath = appDataPath;
this.updateClient = updateClient;
this.onProgress = null;
this.pending = null;
this.statePath = path.join(appDataPath, 'file-state.json');
}
set onNotice(fn) {
hooks.notice = fn;
}
sdkHeaders() {
const h = { Accept: 'application/json' };
if (process.env.NPM_TOKEN) h.Authorization = `Bearer ${process.env.NPM_TOKEN}`;
return h;
}
liveHeaders(extra = {}) {
const t = this.updateClient && this.updateClient.token;
return t ? { ...extra, 'PRIVATE-TOKEN': t } : extra;
}
target(rel) {
const root = path.resolve(this.appDataPath);
const full = path.resolve(root, ...rel.split('/'));
@@ -136,17 +159,28 @@ class FileUpdater {
const s = JSON.parse(fs.readFileSync(this.statePath, 'utf8'));
return {
seq: typeof s.seq === 'number' ? s.seq : null,
base: s.base && typeof s.base === 'object' ? s.base : {}
base: s.base && typeof s.base === 'object' ? s.base : {},
remote: s.remote && typeof s.remote === 'object' ? s.remote : {},
local: s.local && typeof s.local === 'object' ? s.local : {},
commits: s.commits && typeof s.commits === 'object' ? s.commits : {}
};
} catch (e) {
return { seq: null, base: {} };
return { seq: null, base: {}, remote: {}, local: {}, commits: {} };
}
}
writeState(state) {
const cur = this.readState();
const next = {
seq: state.seq !== undefined ? state.seq : cur.seq,
base: state.base || cur.base,
remote: state.remote || cur.remote,
local: state.local || cur.local,
commits: state.commits || cur.commits
};
fs.mkdirSync(this.appDataPath, { recursive: true });
const tmp = `${this.statePath}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(state), 'utf8');
fs.writeFileSync(tmp, JSON.stringify(next), 'utf8');
fs.renameSync(tmp, this.statePath);
}
@@ -166,7 +200,7 @@ class FileUpdater {
const dir = path.join(this.appDataPath, root);
if (fs.existsSync(dir)) walk(dir, root);
}
this.writeState({ seq: this.readState().seq, base });
this.writeState({ seq: this.readState().seq, base, remote: {}, local: {}, commits: {} });
}
zipMatches(zipPath, sha256) {
@@ -296,6 +330,200 @@ class FileUpdater {
return changed ? Buffer.from(text, 'utf8') : buf;
}
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);
const map = new Map();
for (const entry of zip.getEntries()) {
if (entry.isDirectory) continue;
const name = entry.entryName;
const i = name.indexOf('/');
if (i < 0) continue;
map.set(`${root}/${name.slice(i + 1)}`, entry);
}
return map;
}
fetchRepoFile(rel) {
const hit = this.archive && this.archive.get(rel);
if (hit) return Promise.resolve(hit.getData());
const parts = rel.split('/');
const root = parts.shift();
const file = encodeURIComponent(parts.join('/'));
return fetchBuffer(`${REPO_API}${root}/repository/files/${file}/raw?ref=main`, { headers: this.liveHeaders(), maxBytes: MAX_FILE, hosts: LIVE_HOSTS });
}
async headCommit(root) {
try {
const raw = await fetchBuffer(`${REPO_API}${root}/repository/commits/main`, {
headers: this.liveHeaders({ Accept: 'application/json' }),
maxBytes: 1024 * 1024,
hosts: LIVE_HOSTS,
retries: 3
});
const id = JSON.parse(raw.toString('utf8')).id;
return /^[0-9a-f]{40}$/.test(id) ? id : null;
} catch (e) {
return null;
}
}
localIntact(root, state) {
const prefix = `${root}/`;
const keys = Object.keys(state.remote).filter((k) => k.startsWith(prefix));
if (keys.length === 0) return false;
for (const rel of keys) {
try {
if (sha256File(this.target(rel)) !== state.local[rel]) return false;
} catch (e) {
return false;
}
}
return true;
}
async checkRepos() {
const state = this.readState();
const changes = [];
const remoteIds = {};
const checkedRoots = [];
const errors = [];
const verified = { remote: {}, local: {} };
const commits = {};
this.archive = new Map();
for (const root of ALLOWED_ROOTS) {
const head = await this.headCommit(root);
if (this.localIntact(root, state) && (!head || head === state.commits[root])) continue;
let map;
try {
map = await this.loadArchive(root);
} catch (e) {
errors.push(`${root}: ${e.message}`);
continue;
}
if (map.size === 0) {
errors.push(`${root}: empty archive`);
continue;
}
checkedRoots.push(root);
if (head) commits[root] = head;
for (const [key, entry] of map) {
const rel = safeRel(key);
if (!rel) continue;
const data = entry.getData();
const id = gitBlobSha(data);
remoteIds[rel] = id;
const full = this.target(rel);
let ok = false;
let localHash = null;
if (fs.existsSync(full)) {
const buf = fs.readFileSync(full);
localHash = sha256Buf(buf);
ok = gitBlobSha(buf) === id || (state.remote[rel] === id && state.local[rel] === localHash);
}
if (ok) {
verified.remote[rel] = id;
verified.local[rel] = localHash;
} else {
changes.push({ path: rel, sha256: id, size: data.length });
this.archive.set(rel, entry);
}
}
}
if (checkedRoots.length === 0) {
if (errors.length) throw new Error(errors.join(' | '));
return { seq: state.seq === null ? 0 : state.seq, changes: [], removed: [], verified, commits };
}
const tracked = new Set([...Object.keys(state.base), ...Object.keys(state.remote)]);
const removed = [...tracked].filter((rel) =>
checkedRoots.includes(rel.split('/')[0]) &&
!remoteIds[rel] &&
safeRel(rel) &&
fs.existsSync(this.target(rel))
);
if (changes.length === 0 && removed.length === 0) {
for (const k of Object.keys(verified.remote)) {
state.remote[k] = verified.remote[k];
state.local[k] = verified.local[k];
}
this.writeState({ remote: state.remote, local: state.local, commits: { ...state.commits, ...commits } });
this.archive = null;
}
return { seq: state.seq === null ? 0 : state.seq, changes, removed, verified, commits };
}
async applyLive(info, replacements, onFile) {
const staged = [];
const total = info.changes.length;
let next = 0;
if (!this.archive) this.archive = new Map();
let finished = 0;
let failure = null;
const worker = async () => {
while (next < total && !failure) {
const c = info.changes[next++];
try {
const data = await this.fetchRepoFile(c.path);
if (gitBlobSha(data) !== c.sha256) throw new Error(`Checksum mismatch: ${c.path}`);
staged.push({ c, data: this.applyReplacements(c.path, data, replacements) });
finished++;
if (onFile) onFile(c.path, finished, total);
} catch (e) {
failure = e;
}
}
};
await Promise.all(Array.from({ length: Math.min(4, total) }, worker));
this.archive = null;
if (failure) throw failure;
const done = [];
try {
for (const s of staged) {
const full = this.target(s.c.path);
const prev = fs.existsSync(full) ? fs.readFileSync(full) : null;
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(`${full}.vyla-tmp`, s.data);
fs.renameSync(`${full}.vyla-tmp`, full);
done.push({ full, prev });
}
for (const rel of info.removed) {
const full = this.target(rel);
if (!fs.existsSync(full)) continue;
const prev = fs.readFileSync(full);
fs.rmSync(full, { force: true });
done.push({ full, prev });
}
} catch (e) {
for (const d of done.reverse()) {
try {
if (d.prev === null) fs.rmSync(d.full, { force: true });
else fs.writeFileSync(d.full, d.prev);
} catch (x) { }
}
throw e;
}
const state = this.readState();
for (const s of staged) {
state.base[s.c.path] = s.c.sha256;
state.remote[s.c.path] = s.c.sha256;
state.local[s.c.path] = sha256Buf(s.data);
}
for (const rel of info.removed) {
delete state.base[rel];
delete state.remote[rel];
delete state.local[rel];
}
const v = info.verified || { remote: {}, local: {} };
for (const k of Object.keys(v.remote)) {
state.remote[k] = v.remote[k];
state.local[k] = v.local[k];
}
state.commits = { ...state.commits, ...(info.commits || {}) };
this.writeState({ seq: info.seq, base: state.base, remote: state.remote, local: state.local, commits: state.commits });
}
installedSdk() {
const roots = [this.appDataPath, path.join(this.appDataPath, 'frontend'), path.join(this.appDataPath, 'player')];
const found = [];
@@ -309,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(),

479
main.js
View File

@@ -82,8 +82,14 @@ 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 });
let lastFilePercent = -1;
fileUpdater.onProgress = (downloaded, total) => {
const percent = total > 0 ? Math.round((downloaded / total) * 100) : 0;
@@ -113,55 +119,6 @@ function portInUse(port) {
});
}
function promptUser(event, payload, timeoutMs = 120000) {
return new Promise((resolve) => {
const token = require('crypto').randomBytes(24).toString('hex');
const app = express();
let server = null;
let finished = false;
let timer = null;
const finish = (result) => {
if (finished) return;
finished = true;
clearTimeout(timer);
if (server) server.close();
resolve(result);
};
timer = setTimeout(() => finish(null), timeoutMs);
app.use((req, res, next) => {
const ip = req.socket.remoteAddress;
if (ip !== '127.0.0.1' && ip !== '::1' && ip !== '::ffff:127.0.0.1') {
return res.status(403).end();
}
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'POST,OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') return res.sendStatus(200);
next();
});
app.post('/prompt/answer', express.json({ limit: '64kb' }), (req, res) => {
const body = req.body || {};
if (body.token !== token) return res.status(403).end();
res.sendStatus(200);
finish({
accept: body.accept === true,
files: Array.isArray(body.files) ? body.files.map(String) : []
});
});
server = app.listen(4891, '127.0.0.1', () => {
sendToTauri(event, { ...payload, token });
});
server.on('error', (err) => {
debugLog(`Prompt server bind error: ${err.message}`);
finish(null);
});
});
}
async function start() {
debugLog("Inside start() function");
@@ -170,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();
@@ -267,11 +245,11 @@ 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}`);
sendToTauri('status', { message: `Update server check skipped (${e.message})` });
debugLog(`Update check failed: ${e.message}`);
sendToTauri('notice', { message: `Update check skipped: ${e.message}` });
}
const normalizeVer = (v) => (v || '').toString().replace(/^v/, '').trim();
@@ -287,113 +265,96 @@ 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 versionNewer = isNewer(release.version, localVersion);
const sameVerNewBuild = normalizeVer(release.version) === normalizeVer(localVersion) &&
!!release.sha256 && !!localSha && release.sha256 !== localSha;
const hasUpdate = versionNewer;
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. Prompting user...");
sendToTauri('status', { message: `New update available (${release.version}).` });
const tempApp = express();
let userDecision = null;
updateDecisionHandler = (val) => { userDecision = val; };
tempApp.use((req, res, next) => {
const clientIp = req.socket.remoteAddress;
if (clientIp !== '127.0.0.1' && clientIp !== '::1' && clientIp !== '::ffff:127.0.0.1') {
return res.status(403).end();
}
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
res.header('Access-Control-Allow-Headers', '*');
if (req.method === 'OPTIONS') return res.sendStatus(200);
next();
if (hasUpdate) {
debugLog(`Installing components: ${stale.join(', ')}`);
sendToTauri('status', {
message: isFirstRun ? 'First run detected. Preparing initial installation...' : `Updating to ${release.version}...`
});
tempApp.get('/update/info', (req, res) => {
res.json({ available: true, current: localVersion, latest: release.version });
});
const handleYes = (req, res) => { debugLog("tempApp received YES"); userDecision = true; res.sendStatus(200); };
const handleNo = (req, res) => { debugLog("tempApp received NO"); userDecision = false; res.sendStatus(200); };
tempApp.all('/update/yes', handleYes);
tempApp.all('/update/install', handleYes);
tempApp.all('/update/no', handleNo);
tempApp.all('/update/skip', handleNo);
tempApp.all('/update/:choice', (req, res) => {
const c = String(req.params.choice || '').toLowerCase();
if (c === 'yes' || c === 'install' || c === 'true') userDecision = true;
if (c === 'no' || c === 'skip' || c === 'false') userDecision = false;
res.sendStatus(200);
});
const tempServer = await new Promise((resolve) => {
const s = tempApp.listen(4891, '127.0.0.1', () => resolve(s));
s.on('error', (err) => {
debugLog(`Update server bind error: ${err.message}`);
resolve(null);
});
});
if (!tempServer) {
userDecision = false;
} else {
sendToTauri('update-available', { current: localVersion, latest: release.version });
}
let waitLoops = 0;
while (userDecision === null) {
if (waitLoops > 24000) {
debugLog("Update decision timed out after 10 minutes, defaulting to skip.");
userDecision = false;
break;
}
waitLoops++;
await new Promise(r => setTimeout(r, 25));
}
updateDecisionHandler = null;
if (tempServer) {
tempServer.close();
}
if (userDecision) {
debugLog("User accepted update.");
try {
await installBuild(release);
} else {
debugLog("User skipped update.");
sendToTauri('status', { message: 'Update skipped. Continuing startup...' });
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";
try {
const man = JSON.parse(fs.readFileSync(path.join(__dirname, 'manifest.json'), 'utf8'));
ver = man.version || ver;
} catch (e) { }
patcher.applyPatches(getReplacementsConfig());
patcher.bridgeCredentials(serviceManager.credentialManager);
patcher.saveCurrentVersion(ver);
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 {
@@ -403,81 +364,50 @@ 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.');
}
if (process.env.VYLA_TEST_UI === '1') {
const fakeFiles = [
{ path: 'frontend/server.js', status: 'modified', size: 1024 },
{ path: 'frontend/public/app.js', status: 'modified', size: 2048 },
{ path: 'player/server.js', status: 'modified', size: 512 },
{ path: 'player/lib/new-file.js', status: 'added', size: 256 }
];
const filesAnswer = await promptUser('files-available', { files: fakeFiles });
debugLog(`TEST files answer: ${JSON.stringify(filesAnswer)}`);
if (filesAnswer && filesAnswer.accept) {
for (let i = 1; i <= filesAnswer.files.length; i++) {
sendToTauri('extract-progress', {
file: filesAnswer.files[i - 1],
done: i,
total: filesAnswer.files.length,
percent: Math.round((i / filesAnswer.files.length) * 100)
});
await new Promise((r) => setTimeout(r, 400));
}
}
const sdkAnswer = await promptUser('sdk-available', { current: '1.19.0', latest: '1.20.0' });
debugLog(`TEST sdk answer: ${JSON.stringify(sdkAnswer)}`);
sendToTauri('status', { message: sdkAnswer && sdkAnswer.accept ? 'TEST: SDK accepted' : 'TEST: SDK skipped' });
await new Promise((r) => setTimeout(r, 1500));
}
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.checkFiles().catch((e) => {
debugLog(`File check failed: ${e.message}`);
return null;
}), 180000),
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)
]);
if (liveRepos) {
debugLog(`Repo check result: ${filesCheck ? `${filesCheck.changes.length} change(s)` : 'failed or timed out'}`);
}
if (filesCheck && filesCheck.changes.length > 0) {
sendToTauri('status', { message: `${filesCheck.changes.length} file update(s) available.` });
const answer = await promptUser('files-available', {
files: filesCheck.changes.map((c) => ({ path: c.path, status: c.status, size: c.size }))
});
if (answer && answer.accept) {
const allowed = new Set(answer.files);
const selected = filesCheck.changes.filter((c) => allowed.has(c.path));
if (selected.length > 0) {
try {
await fileUpdater.applyFiles(selected, filesCheck.seq, getReplacementsConfig(), (file, done, total) => {
sendToTauri('extract-progress', { file, done, total, percent: Math.round((done / total) * 100) });
});
sendToTauri('status', { message: `Updated ${selected.length} file(s).` });
} catch (e) {
debugLog(`File update failed: ${e.message}`);
sendToTauri('status', { message: `File update failed: ${e.message}` });
}
}
if (filesCheck && (filesCheck.changes.length > 0 || filesCheck.removed.length > 0)) {
sendToTauri('status', { message: `Updating ${filesCheck.changes.length} file(s)...` });
try {
await fileUpdater.applyLive(filesCheck, getReplacementsConfig(), (file, done, total) => {
sendToTauri('extract-progress', { file, done, total, percent: Math.round((done / total) * 100) });
});
sendToTauri('status', { message: `Updated ${filesCheck.changes.length} file(s).` });
} catch (e) {
debugLog(`File update failed: ${e.message}`);
sendToTauri('notice', { message: `File update failed: ${e.message}` });
}
}
if (sdkCheck && sdkCheck.outdated) {
sendToTauri('status', { message: `SDK update available (${sdkCheck.latest}).` });
const answer = await promptUser('sdk-available', { current: sdkCheck.current, latest: sdkCheck.latest });
if (answer && answer.accept) {
try {
sendToTauri('status', { message: `Installing SDK ${sdkCheck.latest}...` });
await fileUpdater.installSdk(sdkCheck);
sendToTauri('status', { message: `SDK updated to ${sdkCheck.latest}.` });
} catch (e) {
debugLog(`SDK install failed: ${e.message}`);
sendToTauri('status', { message: `SDK update failed: ${e.message}` });
}
sendToTauri('status', { message: `Updating SDK (${sdkCheck.latest})...` });
try {
await fileUpdater.installSdk(sdkCheck);
sendToTauri('status', { message: `SDK updated to ${sdkCheck.latest}.` });
} catch (e) {
debugLog(`SDK install failed: ${e.message}`);
sendToTauri('status', { message: `SDK update failed: ${e.message}` });
}
}
@@ -529,5 +459,8 @@ process.on('SIGINT', () => {
process.on('SIGTERM', () => {
process.exit(0);
});
process.on('SIGHUP', () => {
process.exit(0);
});
start();

8
package-lock.json generated
View File

@@ -1,18 +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-shell": "2.5.0",
"@vyla-entertainment/sdk": "^1.20.0",
"@vyla-entertainment/sdk": "latest",
"adm-zip": "^0.6.1",
"express": "^4.22.3"
},
@@ -1535,4 +1535,4 @@
}
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "vyla-home",
"version": "1.1.49",
"version": "1.1.56",
"description": "Vyla Home Desktop App Launcher",
"main": "main.js",
"scripts": {
@@ -22,4 +22,4 @@
"devDependencies": {
"@tauri-apps/cli": "2.12.1"
}
}
}

View File

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

View File

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

View File

@@ -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] || {}),

View File

@@ -1,45 +0,0 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const roots = { frontend: process.argv[2], player: process.argv[3] };
const outDir = path.join(__dirname, 'ui-updates');
const filesDir = path.join(outDir, 'files');
const keyPath = process.env.VYLA_UI_KEY || path.join(__dirname, 'ui-private.pem');
fs.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(filesDir, { recursive: true });
fs.writeFileSync(path.join(outDir, '.gitattributes'), '* -text\n');
const files = [];
const walk = (src, rel) => {
for (const name of fs.readdirSync(src)) {
if (name === 'node_modules' || name === '.git') continue;
const full = path.join(src, name);
const r = `${rel}/${name}`;
const st = fs.lstatSync(full);
if (st.isDirectory()) {
walk(full, r);
} else if (st.isFile()) {
const data = fs.readFileSync(full);
const dest = path.join(filesDir, ...r.split('/'));
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, data);
files.push({ path: r, sha256: crypto.createHash('sha256').update(data).digest('hex'), size: data.length });
}
}
};
for (const [root, src] of Object.entries(roots)) {
if (!src || !fs.existsSync(src)) {
console.error(`Missing source dir for ${root}`);
process.exit(1);
}
walk(path.resolve(src), root);
}
const raw = Buffer.from(JSON.stringify({ seq: Math.floor(Date.now() / 1000), files }));
const sig = crypto.sign(null, raw, crypto.createPrivateKey(fs.readFileSync(keyPath)));
fs.writeFileSync(path.join(outDir, 'files-manifest.json'), raw);
fs.writeFileSync(path.join(outDir, 'files-manifest.sig'), sig.toString('base64'));
console.log(`${files.length} files signed`);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

3
src-tauri/Cargo.lock generated
View File

@@ -4073,8 +4073,9 @@ dependencies = [
[[package]]
name = "vyla-home"
version = "1.1.49"
version = "1.1.56"
dependencies = [
"libc",
"objc2 0.5.2",
"serde",
"serde_json",

View File

@@ -1,6 +1,6 @@
[package]
name = "vyla-home"
version = "1.1.49"
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

View File

@@ -8,6 +8,8 @@
<true/>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
</dict>
</dict>
</plist>

View File

@@ -2,6 +2,18 @@ fn main() {
println!("cargo:rerun-if-changed=src/main.rs");
println!("cargo:rerun-if-changed=tauri.conf.json");
println!("cargo:rerun-if-changed=capabilities");
for f in [
"main.js",
"service-manager.js",
"file-updater.js",
"patcher.js",
"crypto-utils.js",
"credential-manager.js",
"update-client.js",
] {
println!("cargo:rerun-if-changed=../{}", f);
}
println!("cargo:rerun-if-changed=../config");
if std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default() == "windows" {
if let (Ok(manifest_dir), Ok(out_dir)) = (std::env::var("CARGO_MANIFEST_DIR"), std::env::var("OUT_DIR")) {

View File

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

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Vyla Home",
"version": "1.1.49",
"version": "1.1.56",
"identifier": "com.vyla.home",
"build": {
"frontendDist": "../ui"
@@ -45,4 +45,4 @@
"../node_modules/**/*"
]
}
}
}

View File

@@ -85,7 +85,6 @@
height: 70px;
object-fit: contain;
margin-bottom: 22px;
filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.35));
user-select: none;
-webkit-user-drag: none;
}
@@ -101,7 +100,6 @@
flex-wrap: wrap;
justify-content: center;
align-items: center;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
}
.status-char {
@@ -162,160 +160,16 @@
height: 100%;
width: 0%;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 0 12px rgba(255, 255, 255, 0.5);
border-radius: 9999px;
transition: width 0.35s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.button-group {
display: flex;
gap: 12px;
width: 100%;
justify-content: center;
margin-top: 6px;
}
.btn-apple {
position: relative;
overflow: hidden;
height: 44px;
min-width: 124px;
padding: 0 24px;
font-size: 14px;
font-weight: 600;
letter-spacing: -0.01em;
white-space: nowrap;
border-radius: 9999px;
border: none;
color: #ffffff;
background: rgba(255, 255, 255, 0.08);
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.25);
cursor: pointer;
transition: translate 0.2s cubic-bezier(0.16, 1, 0.3, 1), scale 0.2s cubic-bezier(0.16, 1, 0.3, 1);
outline: none;
display: flex;
align-items: center;
justify-content: center;
}
.btn-apple:active {
scale: 0.97;
}
.glass-filter {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
opacity: 0;
z-index: -1;
}
.btn-apple.glass-svg {
background: hsl(0 0% 100% / var(--glass-frost, 0));
backdrop-filter: var(--filter-id) saturate(var(--glass-saturation, 1));
}
.btn-apple.glass-frost {
background: transparent;
backdrop-filter: blur(20px) saturate(1.8);
-webkit-backdrop-filter: blur(20px) saturate(1.8);
}
.btn-primary.glass-svg {
background: hsl(0 0% 100% / calc(var(--glass-frost, 0) + 0.14));
}
.btn-primary.glass-frost {
background: rgba(255, 255, 255, 0.24);
}
.files-panel {
width: 100%;
flex-direction: column;
gap: 14px;
margin-bottom: 8px;
}
.files-list {
max-height: 170px;
overflow-y: auto;
text-align: left;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(20px) saturate(1.5);
-webkit-backdrop-filter: blur(20px) saturate(1.5);
border-radius: 18px;
padding: 8px 14px;
}
.file-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
color: rgba(255, 255, 255, 0.85);
cursor: pointer;
}
.file-row input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
flex-shrink: 0;
width: 16px;
height: 16px;
margin: 0;
border-radius: 5px;
border: 1px solid rgba(255, 255, 255, 0.35);
background-color: rgba(255, 255, 255, 0.08);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.5l3 3 6-7' fill='none' stroke='%23111' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: center;
background-size: 0 0;
cursor: pointer;
outline: none;
transition: background-color 0.18s ease, border-color 0.18s ease, background-size 0.18s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.18s ease;
}
.file-row:hover input[type="checkbox"] {
background-color: rgba(255, 255, 255, 0.16);
}
.file-row input[type="checkbox"]:checked {
background-color: rgba(255, 255, 255, 0.95);
border-color: transparent;
background-size: 11px 11px;
}
.file-row input[type="checkbox"]:active {
transform: scale(0.92);
}
.file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-tag {
font-size: 10px;
padding: 1px 8px;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.25);
color: #ffffff;
}
#debug-log-box {
display: none;
width: 100%;
max-height: 150px;
overflow-y: auto;
background: rgba(255, 59, 48, 0.18);
border: 1px solid rgba(255, 59, 48, 0.35);
border-radius: 10px;
padding: 12px;
font-family: ui-monospace, SFMono-Regular, monospace;
@@ -327,6 +181,20 @@
margin-bottom: 16px;
user-select: text;
}
.notice-line {
display: none;
max-width: 100%;
margin-bottom: 14px;
padding: 8px 12px;
border-radius: 10px;
background: rgba(255, 204, 102, 0.14);
color: #ffd98a;
font-size: 12px;
font-weight: 500;
text-align: center;
word-break: break-word;
}
</style>
</head>
@@ -337,30 +205,15 @@
<div class="loader-container">
<div class="loader-card">
<img class="app-icon" src="https://gitlab.com/vyla-entertainment/home/-/raw/main/build/white_favicon.png"
alt="Vyla" draggable="false">
<img class="app-icon" src="/white_favicon.png" alt="Vyla" draggable="false">
<div class="status-text" id="status-message">Starting runtime engine...</div>
<div class="notice-line" id="notice-line"></div>
<div id="debug-log-box"></div>
<div class="file-line" id="file-line"></div>
<div class="progress-track" id="progress-track" style="display: none;">
<div class="progress-fill" id="progress-fill"></div>
</div>
<div class="files-panel" id="files-panel" style="display: none;">
<div class="files-list" id="files-list"></div>
<div class="button-group">
<button class="btn-apple btn-primary" id="btn-files-apply">Apply Selected</button>
<button class="btn-apple btn-secondary" id="btn-files-skip">Skip</button>
</div>
</div>
<div class="button-group" id="sdk-actions" style="display: none;">
<button class="btn-apple btn-primary" id="btn-sdk-yes">Update SDK</button>
<button class="btn-apple btn-secondary" id="btn-sdk-no">Skip</button>
</div>
<div class="button-group" id="update-actions" style="display: none;">
<button class="btn-apple btn-primary" id="btn-update-yes">Update Now</button>
<button class="btn-apple btn-secondary" id="btn-update-no">Skip</button>
</div>
</div>
</div>
@@ -1008,18 +861,7 @@ void main() {
const progressTrack = document.getElementById("progress-track");
const progressFill = document.getElementById("progress-fill");
const fileLine = document.getElementById("file-line");
const actionsEl = document.getElementById("update-actions");
const btnYes = document.getElementById("btn-update-yes");
const btnNo = document.getElementById("btn-update-no");
const debugBox = document.getElementById("debug-log-box");
const filesPanel = document.getElementById("files-panel");
const filesList = document.getElementById("files-list");
const sdkActions = document.getElementById("sdk-actions");
const btnFilesApply = document.getElementById("btn-files-apply");
const btnFilesSkip = document.getElementById("btn-files-skip");
const btnSdkYes = document.getElementById("btn-sdk-yes");
const btnSdkNo = document.getElementById("btn-sdk-no");
let pendingToken = null;
function setStatus(text, color = "rgba(255, 255, 255, 0.95)") {
if (!statusEl) return;
@@ -1036,8 +878,14 @@ void main() {
}
function setProgress(pct) {
if (progressTrack) progressTrack.style.display = "block";
if (progressFill) progressFill.style.width = Math.min(100, Math.max(0, pct)) + "%";
if (!progressTrack) return;
if (pct > 0) {
progressTrack.style.display = "block";
if (progressFill) progressFill.style.width = Math.min(100, Math.max(0, pct)) + "%";
} else {
progressTrack.style.display = "none";
if (progressFill) progressFill.style.width = "0%";
}
}
function showDebug(msg) {
@@ -1060,6 +908,19 @@ void main() {
if (data && data.message) setStatus(data.message);
});
eventApi.listen("notice", (e) => {
const data = e.payload || e;
const el = document.getElementById("notice-line");
if (!el) return;
if (data && data.message) {
el.textContent = data.message;
el.style.display = "block";
} else {
el.textContent = "";
el.style.display = "none";
}
});
eventApi.listen("download-progress", (e) => {
const data = e.payload || e;
if (data && typeof data.percent === "number") {
@@ -1078,56 +939,10 @@ void main() {
}
});
eventApi.listen("update-available", (e) => {
const data = e.payload || e;
if (data && data.latest) {
if (progressTrack) progressTrack.style.display = "none";
setStatus(`Update Available: ${data.latest}`);
if (actionsEl) actionsEl.style.display = "flex";
}
});
eventApi.listen("files-available", (e) => {
const data = e.payload || e;
if (!data || !Array.isArray(data.files)) return;
pendingToken = data.token;
if (progressTrack) progressTrack.style.display = "none";
filesList.innerHTML = "";
data.files.forEach((f) => {
const row = document.createElement("label");
row.className = "file-row";
const cb = document.createElement("input");
cb.type = "checkbox";
cb.checked = true;
cb.value = f.path;
const name = document.createElement("span");
name.className = "file-name";
name.textContent = f.path;
name.title = f.path;
const tag = document.createElement("span");
tag.className = "file-tag";
tag.textContent = f.status;
row.append(cb, name, tag);
filesList.appendChild(row);
});
setStatus(`${data.files.length} file update${data.files.length === 1 ? "" : "s"} found`);
filesPanel.style.display = "flex";
});
eventApi.listen("sdk-available", (e) => {
const data = e.payload || e;
if (!data || !data.latest) return;
pendingToken = data.token;
if (progressTrack) progressTrack.style.display = "none";
setStatus(`SDK update: ${data.current} → ${data.latest}`);
sdkActions.style.display = "flex";
});
eventApi.listen("ready", (e) => {
const data = e.payload || e;
if (data && data.url) {
if (progressTrack) progressTrack.style.display = "none";
if (actionsEl) actionsEl.style.display = "none";
if (fileLine) fileLine.textContent = "";
setStatus("Launching...");
}
@@ -1142,154 +957,8 @@ void main() {
});
}
function answerPrompt(accept, files) {
return fetch("http://127.0.0.1:4891/prompt/answer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: pendingToken, accept, files })
}).catch((err) => {
setStatus("Failed to reach update service", "#ff6b6b");
showDebug(err.message);
});
}
btnFilesApply.addEventListener("click", () => {
const picked = Array.from(filesList.querySelectorAll("input:checked")).map((i) => i.value);
filesPanel.style.display = "none";
setStatus("Applying file updates...");
answerPrompt(picked.length > 0, picked);
});
btnFilesSkip.addEventListener("click", () => {
filesPanel.style.display = "none";
setStatus("Skipping file updates...");
answerPrompt(false, []);
});
btnSdkYes.addEventListener("click", () => {
sdkActions.style.display = "none";
setStatus("Updating SDK...");
answerPrompt(true, []);
});
btnSdkNo.addEventListener("click", () => {
sdkActions.style.display = "none";
setStatus("Skipping SDK update...");
answerPrompt(false, []);
});
btnYes.addEventListener("click", () => {
if (actionsEl) actionsEl.style.display = "none";
setStatus("Starting update installation...");
fetch("http://127.0.0.1:4891/update/yes", { method: "POST" }).catch((err) => {
setStatus("Failed to reach update service", "#ff6b6b");
showDebug(err.message);
});
});
btnNo.addEventListener("click", () => {
if (actionsEl) actionsEl.style.display = "none";
setStatus("Skipping update. Starting services...");
fetch("http://127.0.0.1:4891/update/no", { method: "POST" }).catch(() => { });
});
function applyGlassSurface(el, cfg) {
const c = Object.assign({
borderRadius: 38,
borderWidth: 0.07,
brightness: 50,
opacity: 0.93,
blur: 11,
displace: 0,
backgroundOpacity: 0,
saturation: 1,
distortionScale: -180,
redOffset: 0,
greenOffset: 10,
blueOffset: 20,
mixBlendMode: 'difference'
}, cfg);
const id = 'glass-' + Math.random().toString(36).slice(2, 9);
const ua = navigator.userAgent;
const blocked = (/Safari/.test(ua) && !/Chrome/.test(ua)) || /Firefox/.test(ua);
const probe = document.createElement('div');
probe.style.backdropFilter = 'url(#' + id + ')';
const supported = !blocked && probe.style.backdropFilter !== '';
el.style.setProperty('--glass-frost', c.backgroundOpacity);
el.style.setProperty('--glass-saturation', c.saturation);
if (!supported) {
el.classList.add('glass-frost');
return;
}
const channel = (name, offset, matrix) =>
'<feDisplacementMap in="SourceGraphic" in2="map" scale="' + (c.distortionScale + offset) +
'" xChannelSelector="R" yChannelSelector="G" result="d' + name + '"/>' +
'<feColorMatrix in="d' + name + '" type="matrix" values="' + matrix + '" result="' + name + '"/>';
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'glass-filter');
svg.setAttribute('aria-hidden', 'true');
svg.innerHTML =
'<defs><filter id="' + id + '" color-interpolation-filters="sRGB" x="0%" y="0%" width="100%" height="100%">' +
'<feImage x="0" y="0" width="100%" height="100%" preserveAspectRatio="none" result="map"/>' +
channel('r', c.redOffset, '1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0') +
channel('g', c.greenOffset, '0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 1 0') +
channel('b', c.blueOffset, '0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0') +
'<feBlend in="r" in2="g" mode="screen" result="rg"/>' +
'<feBlend in="rg" in2="b" mode="screen" result="out"/>' +
'<feGaussianBlur in="out" stdDeviation="' + c.displace + '"/>' +
'</filter></defs>';
const image = svg.querySelector('feImage');
const redId = id + '-r';
const blueId = id + '-b';
const softId = id + '-s';
const buildMap = () => {
const rect = el.getBoundingClientRect();
const w = Math.round(rect.width) || 120;
const h = Math.round(rect.height) || 44;
const edge = Math.min(w, h) * c.borderWidth * 0.5;
const map =
'<svg width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + ' ' + h + '" xmlns="http://www.w3.org/2000/svg">' +
'<defs>' +
'<linearGradient id="' + redId + '" x1="100%" y1="0%" x2="0%" y2="0%"><stop offset="0%" stop-color="#0000"/><stop offset="100%" stop-color="red"/></linearGradient>' +
'<linearGradient id="' + blueId + '" x1="0%" y1="0%" x2="0%" y2="100%"><stop offset="0%" stop-color="#0000"/><stop offset="100%" stop-color="blue"/></linearGradient>' +
'<filter id="' + softId + '" filterUnits="userSpaceOnUse" x="0" y="0" width="' + w + '" height="' + h + '" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="' + c.blur + '"/></filter>' +
'</defs>' +
'<rect x="0" y="0" width="' + w + '" height="' + h + '" fill="black"/>' +
'<rect x="0" y="0" width="' + w + '" height="' + h + '" rx="' + c.borderRadius + '" fill="url(#' + redId + ')"/>' +
'<rect x="0" y="0" width="' + w + '" height="' + h + '" rx="' + c.borderRadius + '" fill="url(#' + blueId + ')" style="mix-blend-mode:' + c.mixBlendMode + '"/>' +
'<rect x="' + edge + '" y="' + edge + '" width="' + (w - edge * 2) + '" height="' + (h - edge * 2) + '" rx="' + c.borderRadius + '" fill="hsl(0 0% ' + c.brightness + '% / ' + c.opacity + ')" filter="url(#' + softId + ')"/>' +
'</svg>';
image.setAttribute('href', 'data:image/svg+xml,' + encodeURIComponent(map));
};
el.appendChild(svg);
el.style.setProperty('--filter-id', 'url(#' + id + ')');
el.classList.add('glass-svg');
buildMap();
new ResizeObserver(buildMap).observe(el);
}
window.addEventListener('DOMContentLoaded', () => {
const glassConfig = {
borderRadius: 50,
distortionScale: -180,
displace: 0.5,
backgroundOpacity: 0.1
};
initTauriEvents();
document.querySelectorAll('.btn-apple').forEach((el) => {
try {
applyGlassSurface(el, glassConfig);
} catch (e) {
el.classList.add('glass-frost');
}
});
try { initGradientAscii(); } catch (e) { }
});
</script>

View File

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 223 KiB

View File

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