mirror of
https://gitlab.com/vyla-entertainment/home.git
synced 2026-11-09 19:57:20 -05:00
added discord rpc
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"player": {
|
||||
"PORT": "3000"
|
||||
},
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"clientId": "1394887879154598038",
|
||||
"logoImage": "vyla_logo",
|
||||
"tmdbApiKey": "3f6703f3e2a0b2b40fe4fbb3e1546244"
|
||||
}
|
||||
}
|
||||
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;
|
||||
10
main.js
10
main.js
@@ -6,6 +6,7 @@ const ServiceManager = require('./service-manager');
|
||||
const Patcher = require('./patcher');
|
||||
const UpdateClient = require('./update-client');
|
||||
const FileUpdater = require('./file-updater');
|
||||
const VylaPresence = require('./discord-rpc');
|
||||
|
||||
function getDefaultAppData() {
|
||||
if (process.platform === 'darwin') {
|
||||
@@ -43,6 +44,12 @@ function sendToTauri(event, data) {
|
||||
}
|
||||
|
||||
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', () => {
|
||||
debugLog('stdin closed - parent died. Self-terminating...');
|
||||
process.exit(0);
|
||||
@@ -88,6 +95,7 @@ function liveReposEnabled() {
|
||||
}
|
||||
|
||||
const updateClient = new UpdateClient(APPDATA_DIR);
|
||||
const presence = new VylaPresence({ getConfig: getEnvConfig, log: debugLog });
|
||||
const fileUpdater = new FileUpdater(APPDATA_DIR, updateClient);
|
||||
fileUpdater.onNotice = (message) => sendToTauri('notice', { message });
|
||||
let lastFilePercent = -1;
|
||||
@@ -436,6 +444,7 @@ async function start() {
|
||||
throw new Error(`${e.message}. Check logs in ${LOGS_DIR}`);
|
||||
}
|
||||
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.' });
|
||||
debugLog("Sending READY to Tauri.");
|
||||
@@ -448,6 +457,7 @@ async function start() {
|
||||
}
|
||||
|
||||
process.on('exit', () => {
|
||||
try { presence.stop(); } catch (e) { }
|
||||
if (serviceManager) {
|
||||
serviceManager.stopAll();
|
||||
}
|
||||
|
||||
20
release.js
20
release.js
@@ -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() {
|
||||
const isFullBuild = process.argv.includes('--installer');
|
||||
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})`);
|
||||
|
||||
injectDiscordConfig();
|
||||
|
||||
console.log('[1/4] Building update payload and manifest...');
|
||||
execSync('node build-pipeline.js', { stdio: 'inherit', env: { ...process.env, RELEASE_VERSION: version } });
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ fn main() {
|
||||
"crypto-utils.js",
|
||||
"credential-manager.js",
|
||||
"update-client.js",
|
||||
"discord-rpc.js",
|
||||
] {
|
||||
println!("cargo:rerun-if-changed=../{}", f);
|
||||
}
|
||||
@@ -49,6 +50,7 @@ fn main() {
|
||||
"win_close",
|
||||
"win_drag",
|
||||
"open_new_window",
|
||||
"report_location",
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"allow-win-close",
|
||||
"allow-win-drag",
|
||||
"allow-open-new-window",
|
||||
"allow-report-location",
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
@@ -356,6 +356,12 @@
|
||||
"const": "allow-open-new-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",
|
||||
"type": "string",
|
||||
@@ -386,6 +392,12 @@
|
||||
"const": "deny-open-new-window",
|
||||
"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.",
|
||||
"type": "string",
|
||||
|
||||
@@ -345,10 +345,10 @@
|
||||
"description": "Permission identifier",
|
||||
"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",
|
||||
"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",
|
||||
@@ -356,6 +356,12 @@
|
||||
"const": "allow-open-new-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",
|
||||
"type": "string",
|
||||
@@ -386,6 +392,12 @@
|
||||
"const": "deny-open-new-window",
|
||||
"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.",
|
||||
"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]]
|
||||
cmd = "open_new_window"
|
||||
|
||||
[[permission]]
|
||||
identifier = "allow-report-location"
|
||||
description = "Allows reporting the current page to the backend"
|
||||
[[permission.allow]]
|
||||
cmd = "report_location"
|
||||
|
||||
[[set]]
|
||||
identifier = "default"
|
||||
description = "Default permissions for Vyla Home app commands"
|
||||
@@ -38,5 +44,6 @@ permissions = [
|
||||
"allow-win-maximize",
|
||||
"allow-win-close",
|
||||
"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::sync::{Arc, Mutex};
|
||||
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) {
|
||||
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 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 READY_URL: Mutex<Option<String>> = Mutex::new(None);
|
||||
@@ -485,6 +498,70 @@ const LINK_HANDLER_SCRIPT: &str = r##"
|
||||
injectLiquidNavBar();
|
||||
}
|
||||
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]
|
||||
@@ -515,6 +592,13 @@ fn win_drag(window: tauri::Window) -> Result<(), 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> {
|
||||
let final_url = if url.starts_with("http") || url.starts_with("tauri://") {
|
||||
url.to_string()
|
||||
@@ -647,9 +731,11 @@ fn main() {
|
||||
win_maximize,
|
||||
win_close,
|
||||
win_drag,
|
||||
open_new_window
|
||||
open_new_window,
|
||||
report_location
|
||||
])
|
||||
.manage(NodeServices(Mutex::new(None)))
|
||||
.manage(NodeStdin(Mutex::new(None)))
|
||||
.setup(|app| {
|
||||
debug_log("Tauri setup started");
|
||||
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();
|
||||
if let Ok(mut lock) = state.0.lock() {
|
||||
*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(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
@@ -924,6 +1025,11 @@ fn main() {
|
||||
}
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"../crypto-utils.js",
|
||||
"../credential-manager.js",
|
||||
"../update-client.js",
|
||||
"../discord-rpc.js",
|
||||
"../config/**/*",
|
||||
"../node_modules/**/*"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user