added discord rpc

This commit is contained in:
2026-11-05 19:22:42 -08:00
parent a506a2efb4
commit ec5950b6ca
14 changed files with 640 additions and 10 deletions

View File

@@ -1,5 +1,11 @@
{ {
"player": { "player": {
"PORT": "3000" "PORT": "3000"
},
"discord": {
"enabled": true,
"clientId": "1394887879154598038",
"logoImage": "vyla_logo",
"tmdbApiKey": "3f6703f3e2a0b2b40fe4fbb3e1546244"
} }
} }

442
discord-rpc.js Normal file
View 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;

10
main.js
View File

@@ -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);
@@ -88,6 +95,7 @@ function liveReposEnabled() {
} }
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 }); fileUpdater.onNotice = (message) => sendToTauri('notice', { message });
let lastFilePercent = -1; let lastFilePercent = -1;
@@ -436,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.");
@@ -448,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();
} }

View File

@@ -158,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];
@@ -166,6 +184,8 @@ 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', env: { ...process.env, RELEASE_VERSION: version } }); execSync('node build-pipeline.js', { stdio: 'inherit', env: { ...process.env, RELEASE_VERSION: version } });

View File

@@ -10,6 +10,7 @@ fn main() {
"crypto-utils.js", "crypto-utils.js",
"credential-manager.js", "credential-manager.js",
"update-client.js", "update-client.js",
"discord-rpc.js",
] { ] {
println!("cargo:rerun-if-changed=../{}", f); println!("cargo:rerun-if-changed=../{}", f);
} }
@@ -49,6 +50,7 @@ fn main() {
"win_close", "win_close",
"win_drag", "win_drag",
"open_new_window", "open_new_window",
"report_location",
]), ]),
), ),
) )

View File

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

View File

@@ -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"]}}

View File

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

View File

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

View 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"]

View File

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

View File

@@ -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,6 +40,19 @@ 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);
@@ -485,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]
@@ -515,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()
@@ -647,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();
@@ -895,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);
@@ -911,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| {
@@ -924,6 +1025,11 @@ 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);
} }
} }
}) })

View File

@@ -41,6 +41,7 @@
"../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/**/*"
] ]