mirror of
https://gitlab.com/vyla-entertainment/home.git
synced 2026-11-14 14:17:25 -05:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec5950b6ca | |||
| a506a2efb4 | |||
| b0096a2073 | |||
| 8a333d6770 | |||
| 9c4d250640 | |||
| 03ed2f0e53 | |||
| 47c97fdf21 | |||
| e7204589a4 | |||
| 3e2dbd312e | |||
| 79dd5e7465 | |||
| 0c6a9d1e5b | |||
| 609891f478 | |||
| b201510b5a | |||
| acd46d665b | |||
| f07c0ea0fd |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -36,4 +36,6 @@ yarn-error.log*
|
|||||||
*.temp
|
*.temp
|
||||||
download-temp.zip
|
download-temp.zip
|
||||||
|
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
release-assets/
|
||||||
@@ -73,6 +73,8 @@ function cleanBuildDir() {
|
|||||||
fs.mkdirSync(BUILD_DIR, { recursive: true });
|
fs.mkdirSync(BUILD_DIR, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let credentialsId = null;
|
||||||
|
|
||||||
function encryptCredentialsFile() {
|
function encryptCredentialsFile() {
|
||||||
const CryptoUtils = require('./crypto-utils');
|
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 buildKey = CryptoUtils.deriveKey('vyla-home-build-key', 'vyla-home-payload-salt-v1');
|
||||||
const encryptedData = CryptoUtils.encryptObject(credentials, buildKey);
|
const encryptedData = CryptoUtils.encryptObject(credentials, buildKey);
|
||||||
|
|
||||||
@@ -229,6 +232,32 @@ function validatePayload(zipPath) {
|
|||||||
return validationResults.checksum;
|
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() {
|
async function build() {
|
||||||
try {
|
try {
|
||||||
cloneAllRepos();
|
cloneAllRepos();
|
||||||
@@ -319,13 +348,41 @@ async function build() {
|
|||||||
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
|
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
|
||||||
wrapperVersion = pkg.version || '2.0.0';
|
wrapperVersion = pkg.version || '2.0.0';
|
||||||
} catch (e) { }
|
} 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 = {
|
const manifest = {
|
||||||
version: wrapperVersion,
|
version: releaseVersion,
|
||||||
sha256: checksum,
|
sha256: checksum,
|
||||||
timestamp: new Date().toISOString(),
|
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');
|
fs.writeFileSync(path.join(__dirname, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
{
|
{
|
||||||
"player": {
|
"player": {
|
||||||
"PORT": "3000"
|
"PORT": "3000"
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientId": "1394887879154598038",
|
||||||
|
"logoImage": "vyla_logo",
|
||||||
|
"tmdbApiKey": "3f6703f3e2a0b2b40fe4fbb3e1546244"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,13 +17,14 @@ class CryptoUtils {
|
|||||||
|
|
||||||
let key;
|
let key;
|
||||||
try {
|
try {
|
||||||
|
const isMac = os.platform() === 'darwin';
|
||||||
const machineId = [
|
const machineId = [
|
||||||
os.hostname(),
|
isMac ? '' : os.hostname(),
|
||||||
os.platform(),
|
os.platform(),
|
||||||
os.arch(),
|
os.arch(),
|
||||||
os.cpus()[0] ? os.cpus()[0].model : '',
|
os.cpus()[0] ? os.cpus()[0].model : '',
|
||||||
process.env.COMPUTERNAME || '',
|
process.env.COMPUTERNAME || '',
|
||||||
process.env.USERNAME || ''
|
process.env.USERNAME || (isMac ? os.userInfo().username : '')
|
||||||
].join('|');
|
].join('|');
|
||||||
|
|
||||||
const hash = crypto.createHash('sha256');
|
const hash = crypto.createHash('sha256');
|
||||||
|
|||||||
442
discord-rpc.js
Normal file
442
discord-rpc.js
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
const net = require('net');
|
||||||
|
const path = require('path');
|
||||||
|
const https = require('https');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const DEFAULT_CLIENT_ID = '1394887879154598038';
|
||||||
|
const DEFAULT_LOGO = 'vyla_logo';
|
||||||
|
|
||||||
|
const TMDB_IMAGE_BASE = 'https://image.tmdb.org/t/p/w500';
|
||||||
|
const OP = { HANDSHAKE: 0, FRAME: 1, CLOSE: 2, PING: 3, PONG: 4 };
|
||||||
|
|
||||||
|
function ipcPaths() {
|
||||||
|
const out = [];
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
for (let i = 0; i < 10; i++) out.push(`\\\\?\\pipe\\discord-ipc-${i}`);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const bases = [...new Set([process.env.XDG_RUNTIME_DIR, process.env.TMPDIR, process.env.TMP, process.env.TEMP, '/tmp'].filter(Boolean))];
|
||||||
|
for (const base of bases) {
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
out.push(path.join(base, `discord-ipc-${i}`));
|
||||||
|
out.push(path.join(base, 'app', 'com.discordapp.Discord', `discord-ipc-${i}`));
|
||||||
|
out.push(path.join(base, 'snap.discord', `discord-ipc-${i}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DiscordIpc {
|
||||||
|
constructor(clientId, log) {
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.log = log || (() => { });
|
||||||
|
this.socket = null;
|
||||||
|
this.ready = false;
|
||||||
|
this.connecting = false;
|
||||||
|
this.stopped = true;
|
||||||
|
this.buffer = Buffer.alloc(0);
|
||||||
|
this.retryTimer = null;
|
||||||
|
this.desired = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.stopped = false;
|
||||||
|
this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.stopped = true;
|
||||||
|
clearTimeout(this.retryTimer);
|
||||||
|
const s = this.socket;
|
||||||
|
this.socket = null;
|
||||||
|
this.ready = false;
|
||||||
|
if (s) {
|
||||||
|
try { s.destroy(); } catch (e) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleRetry() {
|
||||||
|
if (this.stopped) return;
|
||||||
|
clearTimeout(this.retryTimer);
|
||||||
|
this.retryTimer = setTimeout(() => this.connect(), 15000);
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
if (this.stopped || this.socket || this.connecting) return;
|
||||||
|
this.connecting = true;
|
||||||
|
const paths = ipcPaths();
|
||||||
|
const attempt = (i) => {
|
||||||
|
if (this.stopped) { this.connecting = false; return; }
|
||||||
|
if (i >= paths.length) {
|
||||||
|
this.connecting = false;
|
||||||
|
this.scheduleRetry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sock = net.createConnection(paths[i]);
|
||||||
|
let opened = false;
|
||||||
|
sock.once('error', () => {
|
||||||
|
if (!opened) {
|
||||||
|
sock.destroy();
|
||||||
|
attempt(i + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sock.once('connect', () => {
|
||||||
|
opened = true;
|
||||||
|
this.connecting = false;
|
||||||
|
this.attach(sock);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
attempt(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
attach(sock) {
|
||||||
|
this.socket = sock;
|
||||||
|
this.buffer = Buffer.alloc(0);
|
||||||
|
this.ready = false;
|
||||||
|
sock.on('data', (chunk) => this.onData(sock, chunk));
|
||||||
|
sock.on('error', () => { });
|
||||||
|
sock.on('close', () => {
|
||||||
|
if (this.socket === sock) {
|
||||||
|
this.socket = null;
|
||||||
|
this.ready = false;
|
||||||
|
this.log('Discord IPC closed');
|
||||||
|
this.scheduleRetry();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.send(OP.HANDSHAKE, { v: 1, client_id: this.clientId });
|
||||||
|
}
|
||||||
|
|
||||||
|
send(op, payload) {
|
||||||
|
if (!this.socket) return;
|
||||||
|
const body = Buffer.from(JSON.stringify(payload), 'utf8');
|
||||||
|
const header = Buffer.alloc(8);
|
||||||
|
header.writeInt32LE(op, 0);
|
||||||
|
header.writeInt32LE(body.length, 4);
|
||||||
|
try {
|
||||||
|
this.socket.write(Buffer.concat([header, body]));
|
||||||
|
} catch (e) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
onData(sock, chunk) {
|
||||||
|
if (this.socket !== sock) return;
|
||||||
|
this.buffer = Buffer.concat([this.buffer, chunk]);
|
||||||
|
while (this.buffer.length >= 8) {
|
||||||
|
const op = this.buffer.readInt32LE(0);
|
||||||
|
const len = this.buffer.readInt32LE(4);
|
||||||
|
if (len < 0 || len > 1024 * 1024) {
|
||||||
|
sock.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.buffer.length < 8 + len) return;
|
||||||
|
const raw = this.buffer.slice(8, 8 + len).toString('utf8');
|
||||||
|
this.buffer = this.buffer.slice(8 + len);
|
||||||
|
let payload = {};
|
||||||
|
try { payload = JSON.parse(raw); } catch (e) { }
|
||||||
|
this.handle(sock, op, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle(sock, op, payload) {
|
||||||
|
if (op === OP.PING) {
|
||||||
|
this.send(OP.PONG, payload);
|
||||||
|
} else if (op === OP.CLOSE) {
|
||||||
|
this.log(`Discord closed the connection: ${JSON.stringify(payload)}`);
|
||||||
|
sock.destroy();
|
||||||
|
} else if (op === OP.FRAME) {
|
||||||
|
if (payload.evt === 'READY') {
|
||||||
|
this.ready = true;
|
||||||
|
this.log('Discord RPC ready');
|
||||||
|
this.flush();
|
||||||
|
} else if (payload.evt === 'ERROR') {
|
||||||
|
this.log(`Discord RPC error: ${JSON.stringify(payload.data)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flush() {
|
||||||
|
if (!this.ready) return;
|
||||||
|
const args = { pid: process.pid };
|
||||||
|
if (this.desired) args.activity = this.desired;
|
||||||
|
this.send(OP.FRAME, { cmd: 'SET_ACTIVITY', args, nonce: crypto.randomUUID() });
|
||||||
|
}
|
||||||
|
|
||||||
|
setActivity(activity) {
|
||||||
|
this.desired = activity || null;
|
||||||
|
this.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRoute(href) {
|
||||||
|
let u;
|
||||||
|
try { u = new URL(href); } catch (e) { return { kind: 'idle' }; }
|
||||||
|
if (u.pathname !== '/' && u.pathname !== '/index.html') return { kind: 'idle' };
|
||||||
|
|
||||||
|
const p = u.searchParams;
|
||||||
|
const id = (p.get('id') || '').trim();
|
||||||
|
if (!/^\d+$/.test(id)) return { kind: 'idle' };
|
||||||
|
|
||||||
|
const type = (p.get('type') || '').toLowerCase();
|
||||||
|
if (type === 'movie' || type === 'tv') {
|
||||||
|
return { kind: 'browse', type, id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const season = parseInt(p.get('season'), 10);
|
||||||
|
const episode = parseInt(p.get('episode'), 10);
|
||||||
|
if (Number.isFinite(season) && Number.isFinite(episode)) {
|
||||||
|
return { kind: 'watch', type: 'tv', id, season, episode };
|
||||||
|
}
|
||||||
|
return { kind: 'watch', type: 'movie', id };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJson(url, headers) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = https.get(url, { headers: { 'User-Agent': 'VylaHome-Client', 'Accept': 'application/json', ...(headers || {}) } }, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (c) => { data += c; });
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
reject(new Error(`HTTP ${res.statusCode}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try { resolve(JSON.parse(data)); } catch (e) { reject(new Error('Invalid JSON')); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.setTimeout(8000, () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error('Request timeout'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clip(s, max = 128) {
|
||||||
|
const str = String(s || '').trim();
|
||||||
|
return str.length > max ? str.slice(0, max - 1) + '…' : str;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanPageTitle(t) {
|
||||||
|
const s = String(t || '').replace(/\s*[-|–—•]\s*Vyla.*$/i, '').trim();
|
||||||
|
if (!s || /^vyla/i.test(s)) return null;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
class VylaPresence {
|
||||||
|
constructor({ getConfig, log }) {
|
||||||
|
this.getConfig = getConfig || (() => ({}));
|
||||||
|
this.log = log || (() => { });
|
||||||
|
this.windows = new Map();
|
||||||
|
this.ipc = null;
|
||||||
|
this.started = false;
|
||||||
|
this.metaCache = new Map();
|
||||||
|
this.pageMemory = new Map();
|
||||||
|
this.lastStart = 0;
|
||||||
|
this.watchKey = null;
|
||||||
|
this.watchStart = 0;
|
||||||
|
this.lastActivityKey = null;
|
||||||
|
this.timer = null;
|
||||||
|
this.token = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
settings() {
|
||||||
|
const cfg = (this.getConfig() || {}).discord || {};
|
||||||
|
const clientId = [process.env.VYLA_DISCORD_CLIENT_ID, cfg.clientId, DEFAULT_CLIENT_ID]
|
||||||
|
.map((v) => String(v || '').trim())
|
||||||
|
.find((v) => /^\d{17,20}$/.test(v)) || '';
|
||||||
|
const tmdbKey = String(process.env.VYLA_TMDB_API_KEY || cfg.tmdbApiKey || '').trim();
|
||||||
|
return {
|
||||||
|
enabled: cfg.enabled !== false,
|
||||||
|
clientId: /^\d{17,20}$/.test(clientId) ? clientId : '',
|
||||||
|
logo: cfg.logoImage || DEFAULT_LOGO,
|
||||||
|
tmdbKey: /^YOUR_/i.test(tmdbKey) ? '' : tmdbKey
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.started) return;
|
||||||
|
const s = this.settings();
|
||||||
|
if (!s.enabled) {
|
||||||
|
this.log('Discord presence disabled in config.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!s.clientId) {
|
||||||
|
this.log('Discord presence not started: no clientId configured.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.started = true;
|
||||||
|
this.ipc = new DiscordIpc(s.clientId, (m) => this.log(m));
|
||||||
|
this.ipc.start();
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.started = false;
|
||||||
|
if (this.ipc) {
|
||||||
|
this.ipc.stop();
|
||||||
|
this.ipc = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
report(payload) {
|
||||||
|
if (!payload || !payload.label) return;
|
||||||
|
this.log(`report ${payload.label}: ${payload.closed ? 'closed' : payload.url}`);
|
||||||
|
if (payload.closed) {
|
||||||
|
this.windows.delete(payload.label);
|
||||||
|
} else {
|
||||||
|
this.windows.set(payload.label, {
|
||||||
|
url: String(payload.url || ''),
|
||||||
|
title: String(payload.title || ''),
|
||||||
|
image: String(payload.image || ''),
|
||||||
|
position: Number(payload.position),
|
||||||
|
duration: Number(payload.duration),
|
||||||
|
paused: payload.paused === true,
|
||||||
|
at: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
current() {
|
||||||
|
let best = null;
|
||||||
|
for (const w of this.windows.values()) {
|
||||||
|
if (!best || w.at >= best.at) best = w;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
if (!this.started) return;
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = setTimeout(() => this.apply().catch((e) => this.log(`Presence update failed: ${e.message}`)), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchMeta(route) {
|
||||||
|
const s = this.settings();
|
||||||
|
if (!s.tmdbKey) return null;
|
||||||
|
const key = `${route.type}:${route.id}`;
|
||||||
|
if (this.metaCache.has(key)) return this.metaCache.get(key);
|
||||||
|
try {
|
||||||
|
const bearer = s.tmdbKey.length > 40;
|
||||||
|
const url = `https://api.themoviedb.org/3/${route.type}/${route.id}` + (bearer ? '' : `?api_key=${encodeURIComponent(s.tmdbKey)}`);
|
||||||
|
const d = await getJson(url, bearer ? { Authorization: `Bearer ${s.tmdbKey}` } : null);
|
||||||
|
const meta = {
|
||||||
|
title: d.title || d.name || null,
|
||||||
|
poster: d.poster_path ? `${TMDB_IMAGE_BASE}${d.poster_path}` : null,
|
||||||
|
runtime: d.runtime || (Array.isArray(d.episode_run_time) && d.episode_run_time[0]) || 0
|
||||||
|
};
|
||||||
|
this.metaCache.set(key, meta);
|
||||||
|
return meta;
|
||||||
|
} catch (e) {
|
||||||
|
this.log(`TMDB lookup failed for ${key}: ${e.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchEpisodeName(route) {
|
||||||
|
const s = this.settings();
|
||||||
|
if (!s.tmdbKey) return null;
|
||||||
|
const key = `ep:${route.id}:${route.season}:${route.episode}`;
|
||||||
|
if (this.metaCache.has(key)) return this.metaCache.get(key);
|
||||||
|
try {
|
||||||
|
const bearer = s.tmdbKey.length > 40;
|
||||||
|
const url = `https://api.themoviedb.org/3/tv/${route.id}/season/${route.season}/episode/${route.episode}` + (bearer ? '' : `?api_key=${encodeURIComponent(s.tmdbKey)}`);
|
||||||
|
const d = await getJson(url, bearer ? { Authorization: `Bearer ${s.tmdbKey}` } : null);
|
||||||
|
const info = { name: d.name || null, runtime: d.runtime || 0 };
|
||||||
|
this.metaCache.set(key, info);
|
||||||
|
return info;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async apply() {
|
||||||
|
const token = ++this.token;
|
||||||
|
const s = this.settings();
|
||||||
|
const win = this.current();
|
||||||
|
const route = win ? parseRoute(win.url) : { kind: 'idle' };
|
||||||
|
|
||||||
|
let activity;
|
||||||
|
if (route.kind === 'idle') {
|
||||||
|
this.watchKey = null;
|
||||||
|
activity = {
|
||||||
|
type: 0,
|
||||||
|
details: 'On Vyla Home',
|
||||||
|
assets: { large_image: s.logo, large_text: 'Vyla Home' }
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const meta = await this.fetchMeta(route);
|
||||||
|
const episode = (route.kind === 'watch' && route.type === 'tv' ? await this.fetchEpisodeName(route) : null) || {};
|
||||||
|
if (token !== this.token) return;
|
||||||
|
|
||||||
|
const pageTitle = win ? cleanPageTitle(win.title) : null;
|
||||||
|
const pageImage = win && /^https:\/\//i.test(win.image) ? win.image : null;
|
||||||
|
const memKey = `${route.type}:${route.id}`;
|
||||||
|
const mem = this.pageMemory.get(memKey) || {};
|
||||||
|
if (pageTitle) mem.title = pageTitle;
|
||||||
|
if (pageImage) mem.poster = pageImage;
|
||||||
|
this.pageMemory.set(memKey, mem);
|
||||||
|
const title = (meta && meta.title) || pageTitle || mem.title || (route.type === 'tv' ? 'a TV show' : 'a movie');
|
||||||
|
const poster = (meta && meta.poster) || pageImage || mem.poster;
|
||||||
|
|
||||||
|
const assets = {
|
||||||
|
large_image: poster || s.logo,
|
||||||
|
large_text: clip(title)
|
||||||
|
};
|
||||||
|
if (poster) {
|
||||||
|
assets.small_image = s.logo;
|
||||||
|
assets.small_text = 'Vyla Home';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.kind === 'browse') {
|
||||||
|
this.watchKey = null;
|
||||||
|
activity = {
|
||||||
|
type: 0,
|
||||||
|
details: 'Browsing Vyla',
|
||||||
|
state: clip(`Browsing ${title}`),
|
||||||
|
assets
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const key = route.type === 'tv' ? `tv:${route.id}:${route.season}:${route.episode}` : `movie:${route.id}`;
|
||||||
|
if (this.watchKey !== key) {
|
||||||
|
this.watchKey = key;
|
||||||
|
this.watchStart = Date.now();
|
||||||
|
this.lastStart = 0;
|
||||||
|
}
|
||||||
|
const runtimeMin = episode.runtime || (meta && meta.runtime) || 0;
|
||||||
|
let timestamps;
|
||||||
|
if (win && Number.isFinite(win.duration) && win.duration > 0) {
|
||||||
|
if (win.paused) {
|
||||||
|
this.lastStart = 0;
|
||||||
|
} else {
|
||||||
|
let start = Date.now() - Math.round(win.position * 1000);
|
||||||
|
if (this.lastStart && Math.abs(start - this.lastStart) < 4000) start = this.lastStart;
|
||||||
|
this.lastStart = start;
|
||||||
|
timestamps = { start, end: start + Math.round(win.duration * 1000) };
|
||||||
|
}
|
||||||
|
} else if (runtimeMin) {
|
||||||
|
timestamps = { start: this.watchStart, end: this.watchStart + runtimeMin * 60000 };
|
||||||
|
} else {
|
||||||
|
timestamps = { start: this.watchStart };
|
||||||
|
}
|
||||||
|
activity = {
|
||||||
|
type: 3,
|
||||||
|
details: clip(`Watching ${title}`),
|
||||||
|
assets
|
||||||
|
};
|
||||||
|
if (timestamps) activity.timestamps = timestamps;
|
||||||
|
if (route.type === 'tv') {
|
||||||
|
activity.state = clip(`Season ${route.season}, Episode ${route.episode}` + (episode.name ? ` · ${episode.name}` : ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sig = JSON.stringify(activity);
|
||||||
|
if (sig === this.lastActivityKey) return;
|
||||||
|
this.lastActivityKey = sig;
|
||||||
|
this.log(`activity: ${sig}`);
|
||||||
|
if (this.ipc) this.ipc.setActivity(activity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VylaPresence.parseRoute = parseRoute;
|
||||||
|
module.exports = VylaPresence;
|
||||||
201
file-updater.js
201
file-updater.js
@@ -27,7 +27,9 @@ const sha256Buf = (buf) => crypto.createHash('sha256').update(buf).digest('hex')
|
|||||||
const sha256File = (p) => sha256Buf(fs.readFileSync(p));
|
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');
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (redirects > 5) return reject(new Error('Too many redirects'));
|
if (redirects > 5) return reject(new Error('Too many redirects'));
|
||||||
let u;
|
let u;
|
||||||
@@ -48,6 +50,15 @@ function fetchBuffer(url, { headers = {}, maxBytes, hosts, redirects = 0 }) {
|
|||||||
fetchBuffer(next, { headers: keep, maxBytes, hosts, redirects: redirects + 1 }).then(resolve, reject);
|
fetchBuffer(next, { headers: keep, maxBytes, hosts, redirects: redirects + 1 }).then(resolve, reject);
|
||||||
return;
|
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) {
|
if (res.statusCode !== 200) {
|
||||||
res.resume();
|
res.resume();
|
||||||
reject(new Error(`HTTP ${res.statusCode}`));
|
reject(new Error(`HTTP ${res.statusCode}`));
|
||||||
@@ -121,12 +132,21 @@ class FileUpdater {
|
|||||||
this.statePath = path.join(appDataPath, 'file-state.json');
|
this.statePath = path.join(appDataPath, 'file-state.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
set onNotice(fn) {
|
||||||
|
hooks.notice = fn;
|
||||||
|
}
|
||||||
|
|
||||||
sdkHeaders() {
|
sdkHeaders() {
|
||||||
const h = { Accept: 'application/json' };
|
const h = { Accept: 'application/json' };
|
||||||
if (process.env.NPM_TOKEN) h.Authorization = `Bearer ${process.env.NPM_TOKEN}`;
|
if (process.env.NPM_TOKEN) h.Authorization = `Bearer ${process.env.NPM_TOKEN}`;
|
||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
liveHeaders(extra = {}) {
|
||||||
|
const t = this.updateClient && this.updateClient.token;
|
||||||
|
return t ? { ...extra, 'PRIVATE-TOKEN': t } : extra;
|
||||||
|
}
|
||||||
|
|
||||||
target(rel) {
|
target(rel) {
|
||||||
const root = path.resolve(this.appDataPath);
|
const root = path.resolve(this.appDataPath);
|
||||||
const full = path.resolve(root, ...rel.split('/'));
|
const full = path.resolve(root, ...rel.split('/'));
|
||||||
@@ -139,17 +159,28 @@ class FileUpdater {
|
|||||||
const s = JSON.parse(fs.readFileSync(this.statePath, 'utf8'));
|
const s = JSON.parse(fs.readFileSync(this.statePath, 'utf8'));
|
||||||
return {
|
return {
|
||||||
seq: typeof s.seq === 'number' ? s.seq : null,
|
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) {
|
} catch (e) {
|
||||||
return { seq: null, base: {} };
|
return { seq: null, base: {}, remote: {}, local: {}, commits: {} };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
writeState(state) {
|
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 });
|
fs.mkdirSync(this.appDataPath, { recursive: true });
|
||||||
const tmp = `${this.statePath}.tmp`;
|
const tmp = `${this.statePath}.tmp`;
|
||||||
fs.writeFileSync(tmp, JSON.stringify(state), 'utf8');
|
fs.writeFileSync(tmp, JSON.stringify(next), 'utf8');
|
||||||
fs.renameSync(tmp, this.statePath);
|
fs.renameSync(tmp, this.statePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +200,7 @@ class FileUpdater {
|
|||||||
const dir = path.join(this.appDataPath, root);
|
const dir = path.join(this.appDataPath, root);
|
||||||
if (fs.existsSync(dir)) walk(dir, 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) {
|
zipMatches(zipPath, sha256) {
|
||||||
@@ -299,47 +330,133 @@ class FileUpdater {
|
|||||||
return changed ? Buffer.from(text, 'utf8') : buf;
|
return changed ? Buffer.from(text, 'utf8') : buf;
|
||||||
}
|
}
|
||||||
|
|
||||||
async repoTree(root) {
|
async loadArchive(root) {
|
||||||
const out = [];
|
const buf = await fetchBuffer(`${REPO_API}${root}/repository/archive.zip?sha=main`, { headers: this.liveHeaders(), maxBytes: 600 * 1024 * 1024, hosts: LIVE_HOSTS });
|
||||||
for (let page = 1; page <= 50; page++) {
|
const zip = new AdmZip(buf);
|
||||||
const url = `${REPO_API}${root}/repository/tree?recursive=true&per_page=100&ref=main&page=${page}`;
|
const map = new Map();
|
||||||
const raw = await fetchBuffer(url, { headers: { Accept: 'application/json' }, maxBytes: 5 * 1024 * 1024, hosts: LIVE_HOSTS });
|
for (const entry of zip.getEntries()) {
|
||||||
const items = JSON.parse(raw.toString('utf8'));
|
if (entry.isDirectory) continue;
|
||||||
if (!Array.isArray(items) || items.length === 0) break;
|
const name = entry.entryName;
|
||||||
out.push(...items);
|
const i = name.indexOf('/');
|
||||||
if (items.length < 100) break;
|
if (i < 0) continue;
|
||||||
|
map.set(`${root}/${name.slice(i + 1)}`, entry);
|
||||||
}
|
}
|
||||||
return out;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchRepoFile(rel) {
|
fetchRepoFile(rel) {
|
||||||
|
const hit = this.archive && this.archive.get(rel);
|
||||||
|
if (hit) return Promise.resolve(hit.getData());
|
||||||
const parts = rel.split('/');
|
const parts = rel.split('/');
|
||||||
const root = parts.shift();
|
const root = parts.shift();
|
||||||
const file = encodeURIComponent(parts.join('/'));
|
const file = encodeURIComponent(parts.join('/'));
|
||||||
return fetchBuffer(`${REPO_API}${root}/repository/files/${file}/raw?ref=main`, { maxBytes: MAX_FILE, hosts: LIVE_HOSTS });
|
return fetchBuffer(`${REPO_API}${root}/repository/files/${file}/raw?ref=main`, { headers: this.liveHeaders(), maxBytes: MAX_FILE, hosts: LIVE_HOSTS });
|
||||||
|
}
|
||||||
|
|
||||||
|
async headCommit(root) {
|
||||||
|
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() {
|
async checkRepos() {
|
||||||
const state = this.readState();
|
const state = this.readState();
|
||||||
const changes = [];
|
const changes = [];
|
||||||
|
const remoteIds = {};
|
||||||
|
const checkedRoots = [];
|
||||||
|
const errors = [];
|
||||||
|
const verified = { remote: {}, local: {} };
|
||||||
|
const commits = {};
|
||||||
|
this.archive = new Map();
|
||||||
for (const root of ALLOWED_ROOTS) {
|
for (const root of ALLOWED_ROOTS) {
|
||||||
const tree = await this.repoTree(root);
|
const head = await this.headCommit(root);
|
||||||
for (const item of tree) {
|
if (this.localIntact(root, state) && (!head || head === state.commits[root])) continue;
|
||||||
if (item.type !== 'blob') continue;
|
let map;
|
||||||
const rel = safeRel(`${root}/${item.path}`);
|
try {
|
||||||
if (!rel || !/^[0-9a-f]{40}$/.test(item.id)) continue;
|
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);
|
const full = this.target(rel);
|
||||||
if (fs.existsSync(full) && (state.base[rel] === item.id || gitBlobSha(fs.readFileSync(full)) === item.id)) continue;
|
let ok = false;
|
||||||
changes.push({ path: rel, sha256: item.id, size: 0 });
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { seq: state.seq === null ? 0 : state.seq, changes, removed: [] };
|
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) {
|
async applyLive(info, replacements, onFile) {
|
||||||
const staged = [];
|
const staged = [];
|
||||||
const total = info.changes.length;
|
const total = info.changes.length;
|
||||||
let next = 0;
|
let next = 0;
|
||||||
|
if (!this.archive) this.archive = new Map();
|
||||||
let finished = 0;
|
let finished = 0;
|
||||||
let failure = null;
|
let failure = null;
|
||||||
const worker = async () => {
|
const worker = async () => {
|
||||||
@@ -356,7 +473,8 @@ class FileUpdater {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
await Promise.all(Array.from({ length: Math.min(8, total) }, worker));
|
await Promise.all(Array.from({ length: Math.min(4, total) }, worker));
|
||||||
|
this.archive = null;
|
||||||
if (failure) throw failure;
|
if (failure) throw failure;
|
||||||
|
|
||||||
const done = [];
|
const done = [];
|
||||||
@@ -387,9 +505,23 @@ class FileUpdater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const state = this.readState();
|
const state = this.readState();
|
||||||
for (const s of staged) state.base[s.c.path] = s.c.sha256;
|
for (const s of staged) {
|
||||||
for (const rel of info.removed) delete state.base[rel];
|
state.base[s.c.path] = s.c.sha256;
|
||||||
this.writeState({ seq: info.seq, base: state.base });
|
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() {
|
installedSdk() {
|
||||||
@@ -405,6 +537,19 @@ class FileUpdater {
|
|||||||
return found;
|
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() {
|
async checkSdk() {
|
||||||
const raw = await fetchBuffer(SDK_LATEST_URL, {
|
const raw = await fetchBuffer(SDK_LATEST_URL, {
|
||||||
headers: this.sdkHeaders(),
|
headers: this.sdkHeaders(),
|
||||||
|
|||||||
310
main.js
310
main.js
@@ -6,6 +6,7 @@ const ServiceManager = require('./service-manager');
|
|||||||
const Patcher = require('./patcher');
|
const Patcher = require('./patcher');
|
||||||
const UpdateClient = require('./update-client');
|
const UpdateClient = require('./update-client');
|
||||||
const FileUpdater = require('./file-updater');
|
const FileUpdater = require('./file-updater');
|
||||||
|
const VylaPresence = require('./discord-rpc');
|
||||||
|
|
||||||
function getDefaultAppData() {
|
function getDefaultAppData() {
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
@@ -43,6 +44,12 @@ function sendToTauri(event, data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
process.stdin.resume();
|
process.stdin.resume();
|
||||||
|
require('readline').createInterface({ input: process.stdin }).on('line', (line) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(line);
|
||||||
|
if (msg && msg.type === 'presence') presence.report(msg.payload);
|
||||||
|
} catch (e) { }
|
||||||
|
});
|
||||||
process.stdin.on('end', () => {
|
process.stdin.on('end', () => {
|
||||||
debugLog('stdin closed - parent died. Self-terminating...');
|
debugLog('stdin closed - parent died. Self-terminating...');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -82,8 +89,15 @@ function getReplacementsConfig() {
|
|||||||
} catch (e) { return {}; }
|
} catch (e) { return {}; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function liveReposEnabled() {
|
||||||
|
const cfg = getEnvConfig();
|
||||||
|
return !!(cfg.updater && cfg.updater.liveRepos === true);
|
||||||
|
}
|
||||||
|
|
||||||
const updateClient = new UpdateClient(APPDATA_DIR);
|
const updateClient = new UpdateClient(APPDATA_DIR);
|
||||||
|
const presence = new VylaPresence({ getConfig: getEnvConfig, log: debugLog });
|
||||||
const fileUpdater = new FileUpdater(APPDATA_DIR, updateClient);
|
const fileUpdater = new FileUpdater(APPDATA_DIR, updateClient);
|
||||||
|
fileUpdater.onNotice = (message) => sendToTauri('notice', { message });
|
||||||
let lastFilePercent = -1;
|
let lastFilePercent = -1;
|
||||||
fileUpdater.onProgress = (downloaded, total) => {
|
fileUpdater.onProgress = (downloaded, total) => {
|
||||||
const percent = total > 0 ? Math.round((downloaded / total) * 100) : 0;
|
const percent = total > 0 ? Math.round((downloaded / total) * 100) : 0;
|
||||||
@@ -121,93 +135,114 @@ async function start() {
|
|||||||
const patcher = new Patcher(APPDATA_DIR);
|
const patcher = new Patcher(APPDATA_DIR);
|
||||||
serviceManager = new ServiceManager(APPDATA_DIR, getEnvConfig());
|
serviceManager = new ServiceManager(APPDATA_DIR, getEnvConfig());
|
||||||
|
|
||||||
const installBuild = async (release) => {
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
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;
|
|
||||||
|
|
||||||
while (attempts < 3 && !downloadSuccess) {
|
const downloadVerified = async (name, item, dest) => {
|
||||||
attempts++;
|
let lastError;
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
|
fs.rmSync(dest, { force: true });
|
||||||
try {
|
try {
|
||||||
debugLog(`Attempting to download update (Attempt ${attempts})...`);
|
sendToTauri('status', { message: `Downloading ${name} (attempt ${attempt}/3)...` });
|
||||||
sendToTauri('status', { message: `Connecting to download servers (Attempt ${attempts}/3)...` });
|
|
||||||
|
|
||||||
let lastPercent = -1;
|
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;
|
const percent = total > 0 ? Math.round((downloaded / total) * 100) : 0;
|
||||||
if (percent !== lastPercent) {
|
if (percent !== lastPercent) {
|
||||||
lastPercent = percent;
|
lastPercent = percent;
|
||||||
sendToTauri('download-progress', { downloaded, total, percent });
|
sendToTauri('download-progress', { downloaded, total, percent });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
sendToTauri('status', { message: `Verifying ${name} SHA-256...` });
|
||||||
debugLog('Download complete. Verifying checksum...');
|
if (!patcher.verifyChecksum(dest, item.sha256)) {
|
||||||
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)) {
|
|
||||||
throw new Error('SHA-256 checksum verification failed.');
|
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...');
|
const installBuild = async (release) => {
|
||||||
sendToTauri('status', { message: 'Unpacking update archive...' });
|
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;
|
let lastEmit = 0;
|
||||||
const stagingDir = await patcher.extractToStaging(tempZipPath, (file, done, total) => {
|
await patcher.extractToStaging(tempZip, (file, done, total) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (done !== total && now - lastEmit < 40) return;
|
if (done !== total && now - lastEmit < 40) return;
|
||||||
lastEmit = now;
|
lastEmit = now;
|
||||||
sendToTauri('extract-progress', {
|
sendToTauri('extract-progress', { file, done, total, percent: Math.round((done / total) * 100) });
|
||||||
file,
|
}, i > 0);
|
||||||
done,
|
fs.rmSync(tempZip, { force: true });
|
||||||
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('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 {
|
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...' });
|
sendToTauri('status', { message: 'Inspecting local workspace and installed files...' });
|
||||||
const isFirstRun = !patcher.verifyExtraction();
|
const isFirstRun = !patcher.verifyExtraction();
|
||||||
const localVersion = patcher.getCurrentVersion();
|
const localVersion = patcher.getCurrentVersion();
|
||||||
@@ -218,11 +253,11 @@ async function start() {
|
|||||||
let release = null;
|
let release = null;
|
||||||
try {
|
try {
|
||||||
debugLog("Fetching updates from updateClient...");
|
debugLog("Fetching updates from updateClient...");
|
||||||
release = await updateClient.checkForUpdates();
|
release = await updateClient.checkForUpdates(isFirstRun);
|
||||||
debugLog(`Update check finished. Found: ${release ? release.version : 'None'}`);
|
debugLog(`Update check finished. Found: ${release ? release.version : 'None'}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugLog(`Update check failed (offline?): ${e.message}`);
|
debugLog(`Update check failed: ${e.message}`);
|
||||||
sendToTauri('status', { message: `Update server check skipped (${e.message})` });
|
sendToTauri('notice', { message: `Update check skipped: ${e.message}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeVer = (v) => (v || '').toString().replace(/^v/, '').trim();
|
const normalizeVer = (v) => (v || '').toString().replace(/^v/, '').trim();
|
||||||
@@ -238,45 +273,96 @@ async function start() {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (release) {
|
if (release && release.installer && process.platform === 'win32' && process.env.VYLA_DEV !== '1') {
|
||||||
const versionNewer = isNewer(release.version, localVersion);
|
const appVersion = process.env.VYLA_APP_VERSION || '0.0.0';
|
||||||
const sameVerNewBuild = normalizeVer(release.version) === normalizeVer(localVersion) &&
|
const tried = updateClient.readCache().selfUpdate || {};
|
||||||
!!release.sha256 && !!localSha && release.sha256 !== localSha;
|
const recentlyTried = tried.version === release.installer.version && Date.now() - (tried.at || 0) < 24 * 60 * 60 * 1000;
|
||||||
const hasUpdate = versionNewer || sameVerNewBuild;
|
if (isNewer(release.installer.version, appVersion) && !recentlyTried) {
|
||||||
|
try {
|
||||||
|
const dest = path.join(APPDATA_DIR, `vyla-installer-${normalizeVer(release.installer.version)}.exe`);
|
||||||
|
sendToTauri('status', { message: 'Downloading launcher update...' });
|
||||||
|
await downloadVerified('launcher', release.installer, dest);
|
||||||
|
updateClient.writeCache({ selfUpdate: { version: release.installer.version, at: Date.now() } });
|
||||||
|
sendToTauri('status', { message: 'Installing launcher update...' });
|
||||||
|
sendToTauri('self-update', { path: dest });
|
||||||
|
await sleep(30000);
|
||||||
|
sendToTauri('notice', { message: 'Launcher update did not start. Continuing on the current version.' });
|
||||||
|
} catch (err) {
|
||||||
|
debugLog(`Launcher update failed: ${err.message}`);
|
||||||
|
sendToTauri('notice', { message: `Launcher update failed: ${err.message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isFirstRun) {
|
if (release) {
|
||||||
debugLog("First run with no local payload. Installing remote build...");
|
const stale = patcher.staleComponents(release);
|
||||||
sendToTauri('status', { message: 'First run detected. Preparing initial installation...' });
|
const broken = stale.some((name) => !patcher.componentIntact(name));
|
||||||
await installBuild(release);
|
const sameVersion = normalizeVer(release.version) === normalizeVer(localVersion);
|
||||||
} else if (hasUpdate) {
|
const hasUpdate = stale.length > 0 && (broken || isNewer(release.version, localVersion) || sameVersion);
|
||||||
debugLog("Update available. Installing update...");
|
|
||||||
sendToTauri('status', { message: `Updating to ${release.version}...` });
|
if (hasUpdate) {
|
||||||
await installBuild(release);
|
debugLog(`Installing components: ${stale.join(', ')}`);
|
||||||
|
sendToTauri('status', {
|
||||||
|
message: isFirstRun ? 'First run detected. Preparing initial installation...' : `Updating to ${release.version}...`
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await installBuild(release);
|
||||||
|
updateClient.recordSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
updateClient.recordFailure();
|
||||||
|
debugLog(`Update failed: ${err.message}`);
|
||||||
|
sendToTauri('notice', { message: `Update failed: ${err.message}` });
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
debugLog("Already on the latest version.");
|
debugLog("Already on the latest version.");
|
||||||
sendToTauri('status', { message: 'Up to date.' });
|
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...' });
|
sendToTauri('status', { message: 'Looking for local payload archive...' });
|
||||||
const localZip = path.join(__dirname, 'update-payload.zip');
|
const payloadRoots = [
|
||||||
if (fs.existsSync(localZip)) {
|
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...' });
|
sendToTauri('status', { message: 'Extracting local baseline payload...' });
|
||||||
patcher.purgeOldCode();
|
const stagingDir = await patcher.extractToStaging(localZip);
|
||||||
patcher.extractZipPayload(localZip);
|
if (!patcher.verifyStaged(stagingDir, ['frontend', 'player'])) {
|
||||||
fileUpdater.snapshotBase();
|
fs.rmSync(stagingDir, { recursive: true, force: true });
|
||||||
patcher.applyPatches(getReplacementsConfig());
|
throw new Error('Bundled payload is incomplete.');
|
||||||
patcher.bridgeCredentials(serviceManager.credentialManager);
|
}
|
||||||
|
patcher.commitStaging(stagingDir);
|
||||||
|
|
||||||
let ver = "1.0.0";
|
|
||||||
let manSha = null;
|
|
||||||
try {
|
try {
|
||||||
const man = JSON.parse(fs.readFileSync(path.join(__dirname, 'manifest.json'), 'utf8'));
|
patcher.applyPatches(getReplacementsConfig());
|
||||||
ver = man.version || ver;
|
patcher.bridgeCredentials(serviceManager.credentialManager);
|
||||||
manSha = man.sha256 || null;
|
|
||||||
} catch (e) { }
|
|
||||||
|
|
||||||
patcher.saveCurrentVersion(ver, manSha);
|
let ver = "1.0.0";
|
||||||
|
let manSha = null;
|
||||||
|
let ids = {};
|
||||||
|
try {
|
||||||
|
const man = JSON.parse(fs.readFileSync(path.join(payloadRoot, 'manifest.json'), 'utf8'));
|
||||||
|
ver = man.version || ver;
|
||||||
|
manSha = man.sha256 || null;
|
||||||
|
ids = Object.fromEntries(Object.entries(man.components || {}).map(([k, v]) => [k, v.id]));
|
||||||
|
} catch (e) { }
|
||||||
|
|
||||||
|
patcher.saveCurrentVersion(ver, manSha, ids);
|
||||||
|
patcher.writeIntegrity(['frontend', 'player']);
|
||||||
|
} catch (e) {
|
||||||
|
try { patcher.rollback(); } catch (x) { }
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
patcher.finalizeCommit();
|
||||||
|
if (liveReposEnabled()) fileUpdater.snapshotBase();
|
||||||
serviceManager.envConfig = getEnvConfig();
|
serviceManager.envConfig = getEnvConfig();
|
||||||
debugLog("Local payload extracted successfully.");
|
debugLog("Local payload extracted successfully.");
|
||||||
} else {
|
} else {
|
||||||
@@ -286,21 +372,28 @@ async function start() {
|
|||||||
|
|
||||||
sendToTauri('status', { message: 'Validating critical components...' });
|
sendToTauri('status', { message: 'Validating critical components...' });
|
||||||
if (!patcher.verifyExtraction()) {
|
if (!patcher.verifyExtraction()) {
|
||||||
throw new Error('Could not download the app files. Check your internet connection and relaunch.');
|
throw new Error('No usable install and no valid signed release or bundled payload was found. Check your internet connection and relaunch, or reinstall the app.');
|
||||||
}
|
}
|
||||||
|
|
||||||
sendToTauri('status', { message: 'Checking UI files and SDK...' });
|
const liveRepos = liveReposEnabled();
|
||||||
|
sendToTauri('status', { message: liveRepos ? 'Checking UI files and SDK...' : 'Checking SDK...' });
|
||||||
const [filesCheck, sdkCheck] = await Promise.all([
|
const [filesCheck, sdkCheck] = await Promise.all([
|
||||||
withTimeout(fileUpdater.checkRepos().catch((e) => {
|
liveRepos
|
||||||
debugLog(`Live file check failed: ${e.message}`);
|
? withTimeout(fileUpdater.checkRepos().catch((e) => {
|
||||||
return null;
|
debugLog(`Live file check failed: ${e.message}`);
|
||||||
}), 60000),
|
sendToTauri('notice', { message: `File check failed: ${e.message}` });
|
||||||
|
return null;
|
||||||
|
}), 60000)
|
||||||
|
: Promise.resolve(null),
|
||||||
withTimeout(fileUpdater.checkSdk().catch((e) => {
|
withTimeout(fileUpdater.checkSdk().catch((e) => {
|
||||||
debugLog(`SDK check failed: ${e.message}`);
|
debugLog(`SDK check failed: ${e.message}`);
|
||||||
|
sendToTauri('notice', { message: `SDK check failed: ${e.message}` });
|
||||||
return null;
|
return null;
|
||||||
}), 12000)
|
}), 12000)
|
||||||
]);
|
]);
|
||||||
debugLog(`Repo check result: ${filesCheck ? `${filesCheck.changes.length} change(s)` : 'failed or timed out'}`);
|
if (liveRepos) {
|
||||||
|
debugLog(`Repo check result: ${filesCheck ? `${filesCheck.changes.length} change(s)` : 'failed or timed out'}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (filesCheck && (filesCheck.changes.length > 0 || filesCheck.removed.length > 0)) {
|
if (filesCheck && (filesCheck.changes.length > 0 || filesCheck.removed.length > 0)) {
|
||||||
sendToTauri('status', { message: `Updating ${filesCheck.changes.length} file(s)...` });
|
sendToTauri('status', { message: `Updating ${filesCheck.changes.length} file(s)...` });
|
||||||
@@ -311,7 +404,7 @@ async function start() {
|
|||||||
sendToTauri('status', { message: `Updated ${filesCheck.changes.length} file(s).` });
|
sendToTauri('status', { message: `Updated ${filesCheck.changes.length} file(s).` });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugLog(`File update failed: ${e.message}`);
|
debugLog(`File update failed: ${e.message}`);
|
||||||
sendToTauri('status', { message: `File update failed: ${e.message}` });
|
sendToTauri('notice', { message: `File update failed: ${e.message}` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,6 +444,7 @@ async function start() {
|
|||||||
throw new Error(`${e.message}. Check logs in ${LOGS_DIR}`);
|
throw new Error(`${e.message}. Check logs in ${LOGS_DIR}`);
|
||||||
}
|
}
|
||||||
debugLog("Ports 3000 and 4890 are ready.");
|
debugLog("Ports 3000 and 4890 are ready.");
|
||||||
|
try { presence.start(); } catch (e) { debugLog(`Presence start failed: ${e.message}`); }
|
||||||
|
|
||||||
sendToTauri('status', { message: 'All backend services running and healthy.' });
|
sendToTauri('status', { message: 'All backend services running and healthy.' });
|
||||||
debugLog("Sending READY to Tauri.");
|
debugLog("Sending READY to Tauri.");
|
||||||
@@ -363,6 +457,7 @@ async function start() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
process.on('exit', () => {
|
process.on('exit', () => {
|
||||||
|
try { presence.stop(); } catch (e) { }
|
||||||
if (serviceManager) {
|
if (serviceManager) {
|
||||||
serviceManager.stopAll();
|
serviceManager.stopAll();
|
||||||
}
|
}
|
||||||
@@ -374,5 +469,8 @@ process.on('SIGINT', () => {
|
|||||||
process.on('SIGTERM', () => {
|
process.on('SIGTERM', () => {
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
process.on('SIGHUP', () => {
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
start();
|
start();
|
||||||
|
|||||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -1,19 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "vyla-home",
|
"name": "vyla-home",
|
||||||
"version": "1.1.49",
|
"version": "1.1.56",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "vyla-home",
|
"name": "vyla-home",
|
||||||
"version": "1.1.49",
|
"version": "1.1.56",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "2.12.1",
|
"@tauri-apps/api": "2.12.1",
|
||||||
"@tauri-apps/plugin-opener": "2.7.0",
|
"@tauri-apps/plugin-opener": "2.7.0",
|
||||||
"@tauri-apps/plugin-process": "2.4.0",
|
"@tauri-apps/plugin-process": "2.4.0",
|
||||||
"@tauri-apps/plugin-updater": "^2",
|
|
||||||
"@tauri-apps/plugin-shell": "2.5.0",
|
"@tauri-apps/plugin-shell": "2.5.0",
|
||||||
"@vyla-entertainment/sdk": "^1.20.0",
|
"@vyla-entertainment/sdk": "latest",
|
||||||
"adm-zip": "^0.6.1",
|
"adm-zip": "^0.6.1",
|
||||||
"express": "^4.22.3"
|
"express": "^4.22.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "vyla-home",
|
"name": "vyla-home",
|
||||||
"version": "1.1.51",
|
"version": "1.1.57",
|
||||||
"description": "Vyla Home Desktop App Launcher",
|
"description": "Vyla Home Desktop App Launcher",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
258
patcher.js
258
patcher.js
@@ -4,6 +4,29 @@ const crypto = require('crypto');
|
|||||||
const AdmZip = require('adm-zip');
|
const AdmZip = require('adm-zip');
|
||||||
const CryptoUtils = require('./crypto-utils');
|
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 {
|
class Patcher {
|
||||||
constructor(appDataPath) {
|
constructor(appDataPath) {
|
||||||
this.appDataPath = appDataPath;
|
this.appDataPath = appDataPath;
|
||||||
@@ -51,10 +74,103 @@ class Patcher {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
saveCurrentVersion(version, sha256 = null) {
|
saveCurrentVersion(version, sha256 = null, components = null) {
|
||||||
const versionFile = path.join(this.appDataPath, 'version.json');
|
const versionFile = path.join(this.appDataPath, 'version.json');
|
||||||
fs.mkdirSync(this.appDataPath, { recursive: true });
|
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() {
|
purgeOldCode() {
|
||||||
@@ -89,9 +205,9 @@ class Patcher {
|
|||||||
zip.extractAllTo(this.appDataPath, true);
|
zip.extractAllTo(this.appDataPath, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async extractToStaging(zipPath, onFile) {
|
async extractToStaging(zipPath, onFile, keep = false) {
|
||||||
const stagingDir = path.join(this.appDataPath, '.staging');
|
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 });
|
fs.mkdirSync(stagingDir, { recursive: true });
|
||||||
const zip = new AdmZip(zipPath);
|
const zip = new AdmZip(zipPath);
|
||||||
const entries = zip.getEntries().filter((e) => !e.isDirectory);
|
const entries = zip.getEntries().filter((e) => !e.isDirectory);
|
||||||
@@ -103,7 +219,7 @@ class Patcher {
|
|||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const entryPath = path.normalize(entry.entryName);
|
const entryPath = path.normalize(entry.entryName);
|
||||||
const resolvedPath = path.resolve(resolvedStaging, entryPath);
|
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}`);
|
throw new Error(`Directory traversal attempt detected: ${entry.entryName}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,23 +233,133 @@ class Patcher {
|
|||||||
return stagingDir;
|
return stagingDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
journalPath() {
|
||||||
|
return path.join(this.appDataPath, '.update-journal.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
backupDir() {
|
||||||
|
return path.join(this.appDataPath, '.backup');
|
||||||
|
}
|
||||||
|
|
||||||
commitStaging(stagingDir) {
|
commitStaging(stagingDir) {
|
||||||
this.purgeOldCode();
|
const names = fs.readdirSync(stagingDir);
|
||||||
for (const name of fs.readdirSync(stagingDir)) {
|
const unexpected = names.filter((n) => !COMMIT_ALLOWED.has(n));
|
||||||
const dest = path.join(this.appDataPath, name);
|
if (unexpected.length > 0) {
|
||||||
fs.rmSync(dest, { recursive: true, force: true });
|
throw new Error(`Unexpected top-level entries in update: ${unexpected.join(', ')}`);
|
||||||
fs.renameSync(path.join(stagingDir, name), dest);
|
}
|
||||||
|
|
||||||
|
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 });
|
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) {
|
verifyChecksum(zipPath, expectedSha256) {
|
||||||
if (!expectedSha256) return false;
|
if (!expectedSha256) return false;
|
||||||
const hash = crypto.createHash('sha256');
|
const hash = crypto.createHash('sha256');
|
||||||
const data = fs.readFileSync(zipPath);
|
const fd = fs.openSync(zipPath, 'r');
|
||||||
hash.update(data);
|
const buf = Buffer.alloc(1024 * 1024);
|
||||||
const calculated = hash.digest('hex');
|
try {
|
||||||
return calculated === String(expectedSha256).trim().toLowerCase();
|
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) {
|
verifyExtraction(root = this.appDataPath) {
|
||||||
@@ -194,8 +420,8 @@ class Patcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyPatches(replacements) {
|
applyPatches(replacements, only = null) {
|
||||||
const targetDirs = ['frontend', 'player'];
|
const targetDirs = ['frontend', 'player'].filter((d) => !only || only.includes(d));
|
||||||
for (const dirName of targetDirs) {
|
for (const dirName of targetDirs) {
|
||||||
const dirPath = path.join(this.appDataPath, dirName);
|
const dirPath = path.join(this.appDataPath, dirName);
|
||||||
if (!fs.existsSync(dirPath)) continue;
|
if (!fs.existsSync(dirPath)) continue;
|
||||||
|
|||||||
59
release.js
59
release.js
@@ -2,6 +2,8 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const UpdateClient = require('./update-client');
|
||||||
|
|
||||||
const GITLAB_PROJECT_PATH = 'vyla-entertainment%2Fhome';
|
const GITLAB_PROJECT_PATH = 'vyla-entertainment%2Fhome';
|
||||||
const PROJECT_ID = process.env.CI_PROJECT_ID || GITLAB_PROJECT_PATH;
|
const PROJECT_ID = process.env.CI_PROJECT_ID || GITLAB_PROJECT_PATH;
|
||||||
@@ -156,6 +158,24 @@ function createGitLabRelease(tag, releaseLinks) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function injectDiscordConfig() {
|
||||||
|
const envPath = path.join(__dirname, 'config', 'env.json');
|
||||||
|
const cfg = fs.existsSync(envPath) ? JSON.parse(fs.readFileSync(envPath, 'utf8')) : {};
|
||||||
|
const tmdb = process.env.TMDB_API_KEY || process.env.TMDB_KEY || '';
|
||||||
|
const clientId = process.env.DISCORD_CLIENT_ID || '';
|
||||||
|
|
||||||
|
cfg.discord = cfg.discord || {};
|
||||||
|
if (tmdb) {
|
||||||
|
cfg.discord.tmdbApiKey = tmdb;
|
||||||
|
} else {
|
||||||
|
console.log('[WARNING] No TMDB_API_KEY or TMDB_KEY set. Discord posters will fall back to page data.');
|
||||||
|
}
|
||||||
|
if (clientId) cfg.discord.clientId = clientId;
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(envPath), { recursive: true });
|
||||||
|
fs.writeFileSync(envPath, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const isFullBuild = process.argv.includes('--installer');
|
const isFullBuild = process.argv.includes('--installer');
|
||||||
const customTag = process.argv.find(arg => arg.startsWith('--tag='))?.split('=')[1];
|
const customTag = process.argv.find(arg => arg.startsWith('--tag='))?.split('=')[1];
|
||||||
@@ -164,8 +184,10 @@ async function main() {
|
|||||||
|
|
||||||
console.log(`Building Vyla Home Release (${version})`);
|
console.log(`Building Vyla Home Release (${version})`);
|
||||||
|
|
||||||
|
injectDiscordConfig();
|
||||||
|
|
||||||
console.log('[1/4] Building update payload and manifest...');
|
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 payloadPath = path.join(__dirname, 'update-payload.zip');
|
||||||
const manifestPath = path.join(__dirname, 'manifest.json');
|
const manifestPath = path.join(__dirname, 'manifest.json');
|
||||||
@@ -217,12 +239,47 @@ 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();
|
||||||
|
|
||||||
console.log('Uploading update-payload.zip...');
|
console.log('Uploading update-payload.zip...');
|
||||||
const payloadUrl = await uploadFileToGitLabPackage(payloadPath, 'vyla-home', version, `update-payload-${timestamp}.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`);
|
||||||
|
if (!c.url || !c.url.includes('/packages/generic/vyla-home/')) {
|
||||||
|
throw new Error(`Upload for ${name} did not return a package URL.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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...');
|
console.log('Uploading manifest.json...');
|
||||||
const manifestUrl = await uploadFileToGitLabPackage(manifestPath, 'vyla-home', version, `manifest-${timestamp}.json`);
|
const manifestUrl = await uploadFileToGitLabPackage(manifestPath, 'vyla-home', version, `manifest-${timestamp}.json`);
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ class ServiceManager {
|
|||||||
|
|
||||||
const env = {
|
const env = {
|
||||||
...process.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,
|
PORT: (this.envConfig[name] && this.envConfig[name].PORT) || defaultPorts[name] || process.env.PORT || 3000,
|
||||||
TOKEN_SECRET: tokenSecret,
|
TOKEN_SECRET: tokenSecret,
|
||||||
...(this.envConfig[name] || {}),
|
...(this.envConfig[name] || {}),
|
||||||
|
|||||||
3
src-tauri/Cargo.lock
generated
3
src-tauri/Cargo.lock
generated
@@ -4073,8 +4073,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vyla-home"
|
name = "vyla-home"
|
||||||
version = "1.1.51"
|
version = "1.1.57"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"libc",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "vyla-home"
|
name = "vyla-home"
|
||||||
version = "1.1.51"
|
version = "1.1.57"
|
||||||
description = "Vyla Home Desktop App Launcher"
|
description = "Vyla Home Desktop App Launcher"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
@@ -17,6 +17,9 @@ window-vibrancy = "0.8"
|
|||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
objc2 = "0.5"
|
objc2 = "0.5"
|
||||||
|
|
||||||
|
[target.'cfg(unix)'.dependencies]
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
lto = true
|
lto = true
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -2,6 +2,19 @@ fn main() {
|
|||||||
println!("cargo:rerun-if-changed=src/main.rs");
|
println!("cargo:rerun-if-changed=src/main.rs");
|
||||||
println!("cargo:rerun-if-changed=tauri.conf.json");
|
println!("cargo:rerun-if-changed=tauri.conf.json");
|
||||||
println!("cargo:rerun-if-changed=capabilities");
|
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",
|
||||||
|
"discord-rpc.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 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")) {
|
if let (Ok(manifest_dir), Ok(out_dir)) = (std::env::var("CARGO_MANIFEST_DIR"), std::env::var("OUT_DIR")) {
|
||||||
@@ -37,6 +50,7 @@ fn main() {
|
|||||||
"win_close",
|
"win_close",
|
||||||
"win_drag",
|
"win_drag",
|
||||||
"open_new_window",
|
"open_new_window",
|
||||||
|
"report_location",
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"allow-win-close",
|
"allow-win-close",
|
||||||
"allow-win-drag",
|
"allow-win-drag",
|
||||||
"allow-open-new-window",
|
"allow-open-new-window",
|
||||||
|
"allow-report-location",
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
"core:window:allow-minimize",
|
"core:window:allow-minimize",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
{"default":{"identifier":"default","description":"Window and opener permissions for local and localhost-served pages","remote":{"urls":["http://localhost:*","http://localhost:*/*","http://127.0.0.1:*","http://127.0.0.1:*/*"]},"local":true,"windows":["*"],"webviews":["*"],"permissions":["allow-win-minimize","allow-win-maximize","allow-win-close","allow-win-drag","allow-open-new-window","core:default","core:window:allow-start-dragging","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-unmaximize","core:window:allow-toggle-maximize","core:window:allow-close","core:window:allow-destroy","core:window:allow-is-maximized","core:window:allow-is-fullscreen","core:window:allow-set-fullscreen","core:window:allow-set-focus","core:window:allow-show","core:window:allow-hide","opener:default"]}}
|
{"default":{"identifier":"default","description":"Window and opener permissions for local and localhost-served pages","remote":{"urls":["http://localhost:*","http://localhost:*/*","http://127.0.0.1:*","http://127.0.0.1:*/*"]},"local":true,"windows":["*"],"webviews":["*"],"permissions":["allow-win-minimize","allow-win-maximize","allow-win-close","allow-win-drag","allow-open-new-window","allow-report-location","core:default","core:window:allow-start-dragging","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-unmaximize","core:window:allow-toggle-maximize","core:window:allow-close","core:window:allow-destroy","core:window:allow-is-maximized","core:window:allow-is-fullscreen","core:window:allow-set-fullscreen","core:window:allow-set-focus","core:window:allow-show","core:window:allow-hide","opener:default"]}}
|
||||||
@@ -345,10 +345,10 @@
|
|||||||
"description": "Permission identifier",
|
"description": "Permission identifier",
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
"description": "Default permissions for Vyla Home app commands\n#### This permission set includes:\n\n- `allow-win-minimize`\n- `allow-win-maximize`\n- `allow-win-close`\n- `allow-win-drag`\n- `allow-open-new-window`",
|
"description": "Default permissions for Vyla Home app commands\n#### This permission set includes:\n\n- `allow-win-minimize`\n- `allow-win-maximize`\n- `allow-win-close`\n- `allow-win-drag`\n- `allow-open-new-window`\n- `allow-report-location`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "default",
|
"const": "default",
|
||||||
"markdownDescription": "Default permissions for Vyla Home app commands\n#### This permission set includes:\n\n- `allow-win-minimize`\n- `allow-win-maximize`\n- `allow-win-close`\n- `allow-win-drag`\n- `allow-open-new-window`"
|
"markdownDescription": "Default permissions for Vyla Home app commands\n#### This permission set includes:\n\n- `allow-win-minimize`\n- `allow-win-maximize`\n- `allow-win-close`\n- `allow-win-drag`\n- `allow-open-new-window`\n- `allow-report-location`"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Allows opening a new webview window",
|
"description": "Allows opening a new webview window",
|
||||||
@@ -356,6 +356,12 @@
|
|||||||
"const": "allow-open-new-window",
|
"const": "allow-open-new-window",
|
||||||
"markdownDescription": "Allows opening a new webview window"
|
"markdownDescription": "Allows opening a new webview window"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Allows reporting the current page to the backend",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-report-location",
|
||||||
|
"markdownDescription": "Allows reporting the current page to the backend"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Allows closing the window",
|
"description": "Allows closing the window",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -386,6 +392,12 @@
|
|||||||
"const": "deny-open-new-window",
|
"const": "deny-open-new-window",
|
||||||
"markdownDescription": "Denies the open_new_window command without any pre-configured scope."
|
"markdownDescription": "Denies the open_new_window command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the report_location command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-report-location",
|
||||||
|
"markdownDescription": "Denies the report_location command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the win_close command without any pre-configured scope.",
|
"description": "Denies the win_close command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -345,10 +345,10 @@
|
|||||||
"description": "Permission identifier",
|
"description": "Permission identifier",
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{
|
{
|
||||||
"description": "Default permissions for Vyla Home app commands\n#### This permission set includes:\n\n- `allow-win-minimize`\n- `allow-win-maximize`\n- `allow-win-close`\n- `allow-win-drag`\n- `allow-open-new-window`",
|
"description": "Default permissions for Vyla Home app commands\n#### This permission set includes:\n\n- `allow-win-minimize`\n- `allow-win-maximize`\n- `allow-win-close`\n- `allow-win-drag`\n- `allow-open-new-window`\n- `allow-report-location`",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "default",
|
"const": "default",
|
||||||
"markdownDescription": "Default permissions for Vyla Home app commands\n#### This permission set includes:\n\n- `allow-win-minimize`\n- `allow-win-maximize`\n- `allow-win-close`\n- `allow-win-drag`\n- `allow-open-new-window`"
|
"markdownDescription": "Default permissions for Vyla Home app commands\n#### This permission set includes:\n\n- `allow-win-minimize`\n- `allow-win-maximize`\n- `allow-win-close`\n- `allow-win-drag`\n- `allow-open-new-window`\n- `allow-report-location`"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Allows opening a new webview window",
|
"description": "Allows opening a new webview window",
|
||||||
@@ -356,6 +356,12 @@
|
|||||||
"const": "allow-open-new-window",
|
"const": "allow-open-new-window",
|
||||||
"markdownDescription": "Allows opening a new webview window"
|
"markdownDescription": "Allows opening a new webview window"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Allows reporting the current page to the backend",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-report-location",
|
||||||
|
"markdownDescription": "Allows reporting the current page to the backend"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Allows closing the window",
|
"description": "Allows closing the window",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -386,6 +392,12 @@
|
|||||||
"const": "deny-open-new-window",
|
"const": "deny-open-new-window",
|
||||||
"markdownDescription": "Denies the open_new_window command without any pre-configured scope."
|
"markdownDescription": "Denies the open_new_window command without any pre-configured scope."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the report_location command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-report-location",
|
||||||
|
"markdownDescription": "Denies the report_location command without any pre-configured scope."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Denies the win_close command without any pre-configured scope.",
|
"description": "Denies the win_close command without any pre-configured scope.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
11
src-tauri/permissions/autogenerated/report_location.toml
Normal file
11
src-tauri/permissions/autogenerated/report_location.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-report-location"
|
||||||
|
description = "Enables the report_location command without any pre-configured scope."
|
||||||
|
commands.allow = ["report_location"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-report-location"
|
||||||
|
description = "Denies the report_location command without any pre-configured scope."
|
||||||
|
commands.deny = ["report_location"]
|
||||||
@@ -30,6 +30,12 @@ description = "Allows opening a new webview window"
|
|||||||
[[permission.allow]]
|
[[permission.allow]]
|
||||||
cmd = "open_new_window"
|
cmd = "open_new_window"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-report-location"
|
||||||
|
description = "Allows reporting the current page to the backend"
|
||||||
|
[[permission.allow]]
|
||||||
|
cmd = "report_location"
|
||||||
|
|
||||||
[[set]]
|
[[set]]
|
||||||
identifier = "default"
|
identifier = "default"
|
||||||
description = "Default permissions for Vyla Home app commands"
|
description = "Default permissions for Vyla Home app commands"
|
||||||
@@ -38,5 +44,6 @@ permissions = [
|
|||||||
"allow-win-maximize",
|
"allow-win-maximize",
|
||||||
"allow-win-close",
|
"allow-win-close",
|
||||||
"allow-win-drag",
|
"allow-win-drag",
|
||||||
"allow-open-new-window"
|
"allow-open-new-window",
|
||||||
]
|
"allow-report-location"
|
||||||
|
]
|
||||||
@@ -4,7 +4,7 @@ use std::path::PathBuf;
|
|||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tauri::webview::NewWindowResponse;
|
use tauri::webview::NewWindowResponse;
|
||||||
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder};
|
use tauri::{AppHandle, Emitter, Listener, Manager, State, WebviewUrl, WebviewWindowBuilder};
|
||||||
|
|
||||||
fn debug_log(msg: &str) {
|
fn debug_log(msg: &str) {
|
||||||
let log_dir = if let Ok(app_data) = std::env::var("APPDATA") {
|
let log_dir = if let Ok(app_data) = std::env::var("APPDATA") {
|
||||||
@@ -40,10 +40,25 @@ fn debug_log(msg: &str) {
|
|||||||
|
|
||||||
struct NodeServices(Mutex<Option<Child>>);
|
struct NodeServices(Mutex<Option<Child>>);
|
||||||
|
|
||||||
|
struct NodeStdin(Mutex<Option<std::process::ChildStdin>>);
|
||||||
|
|
||||||
|
fn write_node_line(state: &NodeStdin, line: &str) -> Result<(), String> {
|
||||||
|
use std::io::Write;
|
||||||
|
let mut lock = state.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
if let Some(stdin) = lock.as_mut() {
|
||||||
|
stdin.write_all(line.as_bytes()).map_err(|e| e.to_string())?;
|
||||||
|
stdin.write_all(b"\n").map_err(|e| e.to_string())?;
|
||||||
|
stdin.flush().map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
static WINDOW_COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(1);
|
static WINDOW_COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(1);
|
||||||
|
|
||||||
static READY_URL: Mutex<Option<String>> = Mutex::new(None);
|
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) {
|
fn js_log(app: &AppHandle, msg: &str) {
|
||||||
debug_log(msg);
|
debug_log(msg);
|
||||||
if let Some(w) = app.get_webview_window("main") {
|
if let Some(w) = app.get_webview_window("main") {
|
||||||
@@ -94,6 +109,17 @@ fn kill_child_process(child: &mut Child) {
|
|||||||
.creation_flags(CREATE_NO_WINDOW)
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
.output();
|
.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.kill();
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
}
|
}
|
||||||
@@ -472,6 +498,70 @@ const LINK_HANDLER_SCRIPT: &str = r##"
|
|||||||
injectLiquidNavBar();
|
injectLiquidNavBar();
|
||||||
}
|
}
|
||||||
startNavObserver();
|
startNavObserver();
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
let last = '';
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
function metaImage() {
|
||||||
|
const m = document.querySelector("meta[property='og:image']");
|
||||||
|
return m && m.content ? m.content : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(attempt) {
|
||||||
|
const n = typeof attempt === 'number' ? attempt : 0;
|
||||||
|
const tauri = window.__TAURI__;
|
||||||
|
if (!tauri || !tauri.core) {
|
||||||
|
if (n < 40) setTimeout(() => send(n + 1), 250);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
label: window.__VYLA_WINDOW_LABEL || 'main',
|
||||||
|
url: location.href,
|
||||||
|
title: document.title || '',
|
||||||
|
image: metaImage(),
|
||||||
|
closed: false
|
||||||
|
};
|
||||||
|
const v = document.querySelector('video');
|
||||||
|
if (v && Number.isFinite(v.duration) && v.duration > 0) {
|
||||||
|
payload.position = v.currentTime;
|
||||||
|
payload.duration = v.duration;
|
||||||
|
payload.paused = v.paused;
|
||||||
|
}
|
||||||
|
if (!tauri.event) {
|
||||||
|
if (n < 40) setTimeout(() => send(n + 1), 250);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tauri.event.emit('vyla-presence', payload).catch((e) => console.error('[vyla] presence emit failed', e));
|
||||||
|
}
|
||||||
|
|
||||||
|
function check() {
|
||||||
|
if (location.href === last) return;
|
||||||
|
last = location.href;
|
||||||
|
send();
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(send, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
['pushState', 'replaceState'].forEach((fn) => {
|
||||||
|
const orig = history[fn];
|
||||||
|
history[fn] = function () {
|
||||||
|
const r = orig.apply(this, arguments);
|
||||||
|
setTimeout(check, 0);
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('popstate', check);
|
||||||
|
window.addEventListener('hashchange', check);
|
||||||
|
window.addEventListener('focus', () => send());
|
||||||
|
['play', 'pause', 'seeked'].forEach((name) => {
|
||||||
|
document.addEventListener(name, () => send(), true);
|
||||||
|
});
|
||||||
|
setInterval(check, 2000);
|
||||||
|
setInterval(() => { if (document.hasFocus()) send(); }, 5000);
|
||||||
|
check();
|
||||||
|
})();
|
||||||
"##;
|
"##;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -502,6 +592,13 @@ fn win_drag(window: tauri::Window) -> Result<(), String> {
|
|||||||
window.start_dragging().map_err(|e| e.to_string())
|
window.start_dragging().map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn report_location(state: State<'_, NodeStdin>, payload: serde_json::Value) -> Result<(), String> {
|
||||||
|
let line = serde_json::json!({ "type": "presence", "payload": payload }).to_string();
|
||||||
|
debug_log(&format!("[PRESENCE] {}", line));
|
||||||
|
write_node_line(&state, &line)
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_url(url: &str) -> Option<tauri::Url> {
|
fn normalize_url(url: &str) -> Option<tauri::Url> {
|
||||||
let final_url = if url.starts_with("http") || url.starts_with("tauri://") {
|
let final_url = if url.starts_with("http") || url.starts_with("tauri://") {
|
||||||
url.to_string()
|
url.to_string()
|
||||||
@@ -580,7 +677,7 @@ fn open_new_window(app: AppHandle, url: String) -> Result<(), String> {
|
|||||||
|
|
||||||
fn handle_ready(w: &tauri::WebviewWindow, msg: &BackendMessage) {
|
fn handle_ready(w: &tauri::WebviewWindow, msg: &BackendMessage) {
|
||||||
let url_str = match msg.data.get("url").and_then(|u| u.as_str()) {
|
let url_str = match msg.data.get("url").and_then(|u| u.as_str()) {
|
||||||
Some(u) => u,
|
Some(u) => u.to_string(),
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -592,19 +689,36 @@ fn handle_ready(w: &tauri::WebviewWindow, msg: &BackendMessage) {
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
use objc2::msg_send;
|
use objc2::msg_send;
|
||||||
use objc2::runtime::AnyObject;
|
use objc2::runtime::{AnyObject, Sel};
|
||||||
debug_log("Attempting with_webview on ready event");
|
use objc2::sel;
|
||||||
let result = w.with_webview(|webview| unsafe {
|
let _ = w.with_webview(|webview| unsafe {
|
||||||
debug_log("Inside with_webview closure");
|
|
||||||
let wk_webview = webview.inner() as *mut AnyObject;
|
let wk_webview = webview.inner() as *mut AnyObject;
|
||||||
let config: *mut AnyObject = msg_send![wk_webview, configuration];
|
let config: *mut AnyObject = msg_send![wk_webview, configuration];
|
||||||
let _: () = msg_send![config, setUpgradeKnownHostsToHTTPS: false];
|
let selector: Sel = sel!(setUpgradeKnownHostsToHTTPS:);
|
||||||
debug_log("setUpgradeKnownHostsToHTTPS set to false");
|
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() {
|
fn main() {
|
||||||
@@ -617,9 +731,11 @@ fn main() {
|
|||||||
win_maximize,
|
win_maximize,
|
||||||
win_close,
|
win_close,
|
||||||
win_drag,
|
win_drag,
|
||||||
open_new_window
|
open_new_window,
|
||||||
|
report_location
|
||||||
])
|
])
|
||||||
.manage(NodeServices(Mutex::new(None)))
|
.manage(NodeServices(Mutex::new(None)))
|
||||||
|
.manage(NodeStdin(Mutex::new(None)))
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
debug_log("Tauri setup started");
|
debug_log("Tauri setup started");
|
||||||
let app_data = app.path().app_data_dir().unwrap_or_default();
|
let app_data = app.path().app_data_dir().unwrap_or_default();
|
||||||
@@ -663,6 +779,9 @@ fn main() {
|
|||||||
.initialization_script("window.__VYLA_WINDOW_LABEL = 'main';")
|
.initialization_script("window.__VYLA_WINDOW_LABEL = 'main';")
|
||||||
.on_page_load(move |wv, payload| {
|
.on_page_load(move |wv, payload| {
|
||||||
if matches!(payload.event(), tauri::webview::PageLoadEvent::Finished) {
|
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") {
|
if payload.url().host_str() != Some("127.0.0.1") {
|
||||||
let known = READY_URL.lock().ok().and_then(|g| g.clone());
|
let known = READY_URL.lock().ok().and_then(|g| g.clone());
|
||||||
if let Some(u) = known {
|
if let Some(u) = known {
|
||||||
@@ -726,6 +845,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 vyla_files_str = clean_path(&vyla_files);
|
||||||
|
|
||||||
let mut cmd = Command::new(&node_bin);
|
let mut cmd = Command::new(&node_bin);
|
||||||
@@ -733,6 +862,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());
|
||||||
@@ -744,6 +875,12 @@ fn main() {
|
|||||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
cmd.process_group(0);
|
||||||
|
}
|
||||||
|
|
||||||
match cmd.spawn() {
|
match cmd.spawn() {
|
||||||
Ok(mut child) => {
|
Ok(mut child) => {
|
||||||
debug_log(&format!(
|
debug_log(&format!(
|
||||||
@@ -796,6 +933,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() {
|
||||||
@@ -810,6 +981,13 @@ fn main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(stdin) = child.stdin.take() {
|
||||||
|
let stdin_state: State<NodeStdin> = app.state();
|
||||||
|
if let Ok(mut lock) = stdin_state.0.lock() {
|
||||||
|
*lock = Some(stdin);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let state: State<NodeServices> = app.state();
|
let state: State<NodeServices> = app.state();
|
||||||
if let Ok(mut lock) = state.0.lock() {
|
if let Ok(mut lock) = state.0.lock() {
|
||||||
*lock = Some(child);
|
*lock = Some(child);
|
||||||
@@ -826,6 +1004,14 @@ fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let presence_handle = app.handle().clone();
|
||||||
|
app.listen("vyla-presence", move |event| {
|
||||||
|
debug_log(&format!("[PRESENCE] {}", event.payload()));
|
||||||
|
let line = format!("{{\"type\":\"presence\",\"payload\":{}}}", event.payload());
|
||||||
|
let state: State<NodeStdin> = presence_handle.state();
|
||||||
|
let _ = write_node_line(&state, &line);
|
||||||
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
@@ -839,9 +1025,24 @@ fn main() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.app_handle().exit(0);
|
window.app_handle().exit(0);
|
||||||
|
} else {
|
||||||
|
let label = window.label().to_string();
|
||||||
|
let line = serde_json::json!({ "type": "presence", "payload": { "label": label, "closed": true } }).to_string();
|
||||||
|
let state: State<NodeStdin> = window.state();
|
||||||
|
let _ = write_node_line(&state, &line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.run(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while building tauri application")
|
||||||
|
.run(|app, event| {
|
||||||
|
if let tauri::RunEvent::Exit = event {
|
||||||
|
let state: State<NodeServices> = app.state();
|
||||||
|
if let Ok(mut lock) = state.0.lock() {
|
||||||
|
if let Some(mut child) = lock.take() {
|
||||||
|
kill_child_process(&mut child);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Vyla Home",
|
"productName": "Vyla Home",
|
||||||
"version": "1.1.51",
|
"version": "1.1.57",
|
||||||
"identifier": "com.vyla.home",
|
"identifier": "com.vyla.home",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../ui"
|
"frontendDist": "../ui"
|
||||||
@@ -41,8 +41,9 @@
|
|||||||
"../crypto-utils.js",
|
"../crypto-utils.js",
|
||||||
"../credential-manager.js",
|
"../credential-manager.js",
|
||||||
"../update-client.js",
|
"../update-client.js",
|
||||||
|
"../discord-rpc.js",
|
||||||
"../config/**/*",
|
"../config/**/*",
|
||||||
"../node_modules/**/*"
|
"../node_modules/**/*"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,6 @@
|
|||||||
height: 70px;
|
height: 70px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
margin-bottom: 22px;
|
margin-bottom: 22px;
|
||||||
filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.35));
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-drag: none;
|
-webkit-user-drag: none;
|
||||||
}
|
}
|
||||||
@@ -101,7 +100,6 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-char {
|
.status-char {
|
||||||
@@ -162,20 +160,16 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
width: 0%;
|
width: 0%;
|
||||||
background: rgba(255, 255, 255, 0.95);
|
background: rgba(255, 255, 255, 0.95);
|
||||||
box-shadow: 0 0 12px rgba(255, 255, 255, 0.5);
|
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
transition: width 0.35s cubic-bezier(0.2, 0.8, 0.2, 1);
|
transition: width 0.35s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#debug-log-box {
|
#debug-log-box {
|
||||||
display: none;
|
display: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 150px;
|
max-height: 150px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: rgba(255, 59, 48, 0.18);
|
background: rgba(255, 59, 48, 0.18);
|
||||||
border: 1px solid rgba(255, 59, 48, 0.35);
|
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||||
@@ -187,6 +181,20 @@
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
user-select: text;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -197,9 +205,9 @@
|
|||||||
|
|
||||||
<div class="loader-container">
|
<div class="loader-container">
|
||||||
<div class="loader-card">
|
<div class="loader-card">
|
||||||
<img class="app-icon" src="https://gitlab.com/vyla-entertainment/home/-/raw/main/build/white_favicon.png"
|
<img class="app-icon" src="/white_favicon.png" alt="Vyla" draggable="false">
|
||||||
alt="Vyla" draggable="false">
|
|
||||||
<div class="status-text" id="status-message">Starting runtime engine...</div>
|
<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 id="debug-log-box"></div>
|
||||||
<div class="file-line" id="file-line"></div>
|
<div class="file-line" id="file-line"></div>
|
||||||
<div class="progress-track" id="progress-track" style="display: none;">
|
<div class="progress-track" id="progress-track" style="display: none;">
|
||||||
@@ -900,6 +908,19 @@ void main() {
|
|||||||
if (data && data.message) setStatus(data.message);
|
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) => {
|
eventApi.listen("download-progress", (e) => {
|
||||||
const data = e.payload || e;
|
const data = e.payload || e;
|
||||||
if (data && typeof data.percent === "number") {
|
if (data && typeof data.percent === "number") {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 223 KiB |
186
update-client.js
186
update-client.js
@@ -1,5 +1,14 @@
|
|||||||
const https = require('https');
|
const https = require('https');
|
||||||
const fs = require('fs');
|
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_GROUP = 'vyla-entertainment';
|
||||||
const GITLAB_REPO = 'home';
|
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 {
|
class UpdateClient {
|
||||||
constructor(appDataPath, token = null) {
|
constructor(appDataPath, token = null) {
|
||||||
this.appDataPath = appDataPath;
|
this.appDataPath = appDataPath;
|
||||||
this.token = token || process.env.GITLAB_TOKEN || process.env.VYLA_GITLAB_TOKEN || null;
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
const headers = {
|
const headers = {
|
||||||
'User-Agent': 'VylaHome-Client',
|
'User-Agent': 'VylaHome-Client',
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'Cache-Control': 'no-cache',
|
'Cache-Control': 'no-cache'
|
||||||
'Pragma': 'no-cache'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.token) {
|
if (this.token) {
|
||||||
headers['PRIVATE-TOKEN'] = this.token;
|
headers['PRIVATE-TOKEN'] = this.token;
|
||||||
}
|
}
|
||||||
|
if (etag) {
|
||||||
|
headers['If-None-Match'] = etag;
|
||||||
|
}
|
||||||
|
|
||||||
const requestOptions = {
|
const requestOptions = {
|
||||||
hostname: urlObj.hostname,
|
hostname: urlObj.hostname,
|
||||||
@@ -58,12 +128,23 @@ class UpdateClient {
|
|||||||
let data = '';
|
let data = '';
|
||||||
res.on('data', (chunk) => { data += chunk; });
|
res.on('data', (chunk) => { data += chunk; });
|
||||||
res.on('end', () => {
|
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) {
|
if (res.statusCode !== 200) {
|
||||||
reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
|
reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
resolve(JSON.parse(data));
|
resolve({ data: JSON.parse(data), etag: res.headers.etag || null });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reject(new Error('Invalid JSON response'));
|
reject(new Error('Invalid JSON response'));
|
||||||
}
|
}
|
||||||
@@ -120,7 +201,13 @@ class UpdateClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (res.statusCode !== 200) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,12 +323,32 @@ class UpdateClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkForUpdates() {
|
async checkForUpdates(force = false) {
|
||||||
const query = `?per_page=20&order_by=released_at&sort=desc&cb=${Date.now()}`;
|
const cache = this.readCache();
|
||||||
const releases = await this.fetchJson(RELEASES_API + query);
|
const now = Date.now();
|
||||||
if (!releases || releases.length === 0) {
|
|
||||||
|
if (!force && cache.blockedUntil && now < cache.blockedUntil) {
|
||||||
return null;
|
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 verParts = (v) => String(v || '').replace(/^v/, '').split(/[.\-+]/).map((n) => parseInt(n, 10) || 0);
|
||||||
const cmp = (a, b) => {
|
const cmp = (a, b) => {
|
||||||
const x = verParts(a);
|
const x = verParts(a);
|
||||||
@@ -252,25 +359,59 @@ class UpdateClient {
|
|||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
const release = releases.slice().sort((a, b) => cmp(b.tag_name, a.tag_name))[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');
|
if (!manifestAsset) {
|
||||||
const manifestAsset = (release.assets && release.assets.links ? release.assets.links : []).find(a => a.name === 'manifest.json');
|
throw new Error(`Release ${release.tag_name} has no manifest.json.`);
|
||||||
|
|
||||||
if (!payloadAsset) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let sha256 = null;
|
const manifest = await this.fetchAssetJson(manifestAsset.url);
|
||||||
if (manifestAsset) {
|
if (!manifest.components) {
|
||||||
const manifest = await this.fetchAssetJson(manifestAsset.url);
|
throw new Error(`Release ${release.tag_name} has no signed component manifest.`);
|
||||||
sha256 = manifest.sha256 || null;
|
}
|
||||||
|
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 direct = typeof c.url === 'string' &&
|
||||||
|
c.url.startsWith('https://') &&
|
||||||
|
isAllowedHost(c.url) &&
|
||||||
|
c.url.includes('/packages/generic/vyla-home/');
|
||||||
|
const url = direct ? c.url : null;
|
||||||
|
if (!url) {
|
||||||
|
throw new Error(`Release ${release.tag_name} manifest has no download URL for ${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,
|
version: release.tag_name,
|
||||||
url: payloadAsset.url,
|
sha256: manifest.sha256 || null,
|
||||||
sha256
|
manifest,
|
||||||
|
components,
|
||||||
|
installer
|
||||||
};
|
};
|
||||||
|
this.writeCache({ checkedAt: now, etag: result.etag || null, release: out });
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadUpdate(release, destPath, onProgress) {
|
async downloadUpdate(release, destPath, onProgress) {
|
||||||
@@ -279,4 +420,7 @@ class UpdateClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UpdateClient.canonicalManifest = canonicalManifest;
|
||||||
|
UpdateClient.verifyManifest = verifyManifest;
|
||||||
|
|
||||||
module.exports = UpdateClient;
|
module.exports = UpdateClient;
|
||||||
|
|||||||
Reference in New Issue
Block a user