Add Discord collectibles IPC and bump cinny for profile overlays.
All checks were successful
All checks were successful
Wire electron-side catalog fetch and asset download, exposing collectibles APIs to the renderer for live Discord shop browsing and Matrix profile uploads.
This commit is contained in:
2
cinny
2
cinny
Submodule cinny updated: 6cb1e14632...0cbc5d9a1c
426
electron/discord-collectibles.js
Normal file
426
electron/discord-collectibles.js
Normal file
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* Discord shop collectibles: fetch catalog via API, download assets on demand from CDN.
|
||||
* Token stays in the main process (electron-store or DISCORD_TOKEN env).
|
||||
*/
|
||||
|
||||
const ITEM_TYPE_FOLDER = {
|
||||
0: 'avatar-decorations',
|
||||
1: 'profile-effects',
|
||||
2: 'nameplates',
|
||||
};
|
||||
|
||||
const ITEM_TYPE_KEY = {
|
||||
0: 'avatar_decoration',
|
||||
1: 'profile_effect',
|
||||
2: 'nameplate',
|
||||
};
|
||||
|
||||
const CDN_BASE = 'https://cdn.discordapp.com';
|
||||
const CDN_HOST_RE = /(^|\.)(discordapp\.com|discordapp\.net|discord\.com)$/i;
|
||||
const DEFAULT_USER_AGENT =
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) discord/1.0 Chrome/120 Electron/28 Safari/537.36';
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
function toAuthHeader(token) {
|
||||
if (!token) return null;
|
||||
const t = String(token).trim();
|
||||
if (/^bot\s+/i.test(t)) return `Bot ${t.replace(/^bot\s+/i, '')}`;
|
||||
return t;
|
||||
}
|
||||
|
||||
function isCdnUrl(value) {
|
||||
if (typeof value !== 'string' || !value.startsWith('http')) return false;
|
||||
try {
|
||||
return CDN_HOST_RE.test(new URL(value).hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function basenameFromUrl(url) {
|
||||
try {
|
||||
const { pathname } = new URL(url);
|
||||
const last = decodeURIComponent(pathname.split('/').filter(Boolean).pop() || '');
|
||||
return last || 'asset';
|
||||
} catch {
|
||||
return 'asset';
|
||||
}
|
||||
}
|
||||
|
||||
function guessMimeType(filename) {
|
||||
const lower = String(filename).toLowerCase();
|
||||
if (lower.endsWith('.png')) return 'image/png';
|
||||
if (lower.endsWith('.webp')) return 'image/webp';
|
||||
if (lower.endsWith('.webm')) return 'video/webm';
|
||||
if (lower.endsWith('.gif')) return 'image/gif';
|
||||
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function deepCollectUrls(node, acc = new Set()) {
|
||||
if (node == null) return acc;
|
||||
if (typeof node === 'string') {
|
||||
if (isCdnUrl(node)) acc.add(node);
|
||||
return acc;
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
for (const v of node) deepCollectUrls(v, acc);
|
||||
return acc;
|
||||
}
|
||||
if (typeof node === 'object') {
|
||||
for (const v of Object.values(node)) deepCollectUrls(v, acc);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
function urlsForItem(item) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
|
||||
function push(url, filename, role) {
|
||||
if (!url || seen.has(url)) return;
|
||||
seen.add(url);
|
||||
out.push({ url, filename, role: role || filename });
|
||||
}
|
||||
|
||||
for (const url of deepCollectUrls(item)) {
|
||||
let role = basenameFromUrl(url);
|
||||
if (item.thumbnailPreviewSrc === url) role = 'thumbnail';
|
||||
else if (item.reducedMotionSrc === url) role = 'reduced_motion';
|
||||
else if (Array.isArray(item.effects)) {
|
||||
const effectIndex = item.effects.findIndex((effect) => effect?.src === url);
|
||||
if (effectIndex >= 0) role = `effect_${effectIndex}`;
|
||||
}
|
||||
push(url, basenameFromUrl(url), role);
|
||||
}
|
||||
|
||||
if (item.type === 0 && item.asset) {
|
||||
const base = `${CDN_BASE}/avatar-decoration-presets/${item.asset}.png`;
|
||||
push(`${base}?passthrough=true`, 'animated.png', 'animated');
|
||||
push(`${base}?passthrough=false`, 'static.png', 'static');
|
||||
}
|
||||
|
||||
if (item.type === 2 && item.asset) {
|
||||
const assetPath = String(item.asset).replace(/^\/+/, '');
|
||||
push(`${CDN_BASE}/assets/collectibles/${assetPath}static.png`, 'static.png', 'static');
|
||||
push(`${CDN_BASE}/assets/collectibles/${assetPath}asset.webm`, 'asset.webm', 'animated');
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function decimalRgbToCss(color) {
|
||||
const value = Number(color) >>> 0;
|
||||
const r = (value >> 16) & 0xff;
|
||||
const g = (value >> 8) & 0xff;
|
||||
const b = value & 0xff;
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
function gradientFromColors(colors) {
|
||||
if (!Array.isArray(colors) || colors.length === 0) return undefined;
|
||||
if (colors.length === 1) return decimalRgbToCss(colors[0]);
|
||||
return `linear-gradient(135deg, ${decimalRgbToCss(colors[0])}, ${decimalRgbToCss(colors[1])})`;
|
||||
}
|
||||
|
||||
function previewAspectRatioForItem(item) {
|
||||
if (item.type === 0) return 1;
|
||||
if (item.type === 2) return 448 / 84;
|
||||
const firstEffect = item.effects?.[0];
|
||||
if (firstEffect?.width && firstEffect?.height) {
|
||||
return firstEffect.width / firstEffect.height;
|
||||
}
|
||||
return 450 / 880;
|
||||
}
|
||||
|
||||
function formatPaletteName(palette) {
|
||||
if (!palette) return undefined;
|
||||
return String(palette)
|
||||
.split('_')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
const NAMEPLATE_PALETTE_GRADIENTS = {
|
||||
crimson: 'linear-gradient(135deg, #5c0a1c, #dc143c)',
|
||||
berry: 'linear-gradient(135deg, #4a1030, #c42d78)',
|
||||
sky: 'linear-gradient(135deg, #0a2a5c, #3b8eed)',
|
||||
teal: 'linear-gradient(135deg, #0a3d3d, #2dd4bf)',
|
||||
forest: 'linear-gradient(135deg, #0a2e1a, #22c55e)',
|
||||
bubble_gum: 'linear-gradient(135deg, #4a1038, #f472b6)',
|
||||
violet: 'linear-gradient(135deg, #2d1050, #8b5cf6)',
|
||||
cobalt: 'linear-gradient(135deg, #0a1448, #3b5bdb)',
|
||||
clover: 'linear-gradient(135deg, #0a3d20, #4ade80)',
|
||||
lemon: 'linear-gradient(135deg, #4a3d0a, #fbbf24)',
|
||||
white: 'linear-gradient(135deg, #888888, #f0f0f0)',
|
||||
black: 'linear-gradient(135deg, #1a1a1a, #404040)',
|
||||
};
|
||||
|
||||
function nameplatePaletteGradient(palette) {
|
||||
if (!palette) return undefined;
|
||||
return NAMEPLATE_PALETTE_GRADIENTS[palette];
|
||||
}
|
||||
|
||||
function thumbnailForItem(item) {
|
||||
if (item.thumbnailPreviewSrc && isCdnUrl(item.thumbnailPreviewSrc)) {
|
||||
return item.thumbnailPreviewSrc;
|
||||
}
|
||||
if (item.type === 0 && item.asset) {
|
||||
return `${CDN_BASE}/avatar-decoration-presets/${item.asset}.png?passthrough=false`;
|
||||
}
|
||||
if (item.type === 2 && item.asset) {
|
||||
const assetPath = String(item.asset).replace(/^\/+/, '');
|
||||
return `${CDN_BASE}/assets/collectibles/${assetPath}static.png`;
|
||||
}
|
||||
const assets = urlsForItem(item);
|
||||
return assets[0]?.url;
|
||||
}
|
||||
|
||||
function* walkProduct(product, category) {
|
||||
yield { product, category };
|
||||
for (const bundled of product.bundled_products ?? []) {
|
||||
yield* walkProduct(bundled, category);
|
||||
}
|
||||
for (const variant of product.variants ?? []) {
|
||||
yield* walkProduct(variant, category);
|
||||
}
|
||||
}
|
||||
|
||||
function* walkProducts(categories) {
|
||||
for (const category of categories ?? []) {
|
||||
for (const product of category.products ?? []) {
|
||||
yield* walkProduct(product, category);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractCollectibleItems(categories) {
|
||||
const items = [];
|
||||
const seenItems = new Set();
|
||||
|
||||
for (const { product, category } of walkProducts(categories)) {
|
||||
const categoryName = category?.name || 'Unknown';
|
||||
for (const item of product.items ?? []) {
|
||||
if (![0, 1, 2].includes(item.type)) continue;
|
||||
|
||||
const skuId = String(item.sku_id ?? item.id ?? product.sku_id ?? 'unknown');
|
||||
const itemKey = `${item.type}:${skuId}:${item.id ?? ''}`;
|
||||
if (seenItems.has(itemKey)) continue;
|
||||
seenItems.add(itemKey);
|
||||
|
||||
const assets = urlsForItem(item);
|
||||
if (assets.length === 0) continue;
|
||||
|
||||
const productName = product.name || item.title || item.label || skuId;
|
||||
const typeKey = ITEM_TYPE_KEY[item.type];
|
||||
|
||||
const entry = {
|
||||
id: `${typeKey}:${skuId}`,
|
||||
skuId,
|
||||
name: productName,
|
||||
type: typeKey,
|
||||
category: categoryName,
|
||||
label: item.label ?? item.title ?? productName,
|
||||
thumbnailUrl: thumbnailForItem(item),
|
||||
previewAspectRatio: previewAspectRatioForItem(item),
|
||||
previewColors: product.styles?.background_colors,
|
||||
palette: item.type === 2 ? item.palette : undefined,
|
||||
paletteLabel: item.type === 2 ? formatPaletteName(item.palette) : undefined,
|
||||
previewGradient: item.type === 2 ? nameplatePaletteGradient(item.palette) : undefined,
|
||||
assets: assets.map((a) => ({
|
||||
role: a.role,
|
||||
url: a.url,
|
||||
filename: a.filename,
|
||||
mimeType: guessMimeType(a.filename),
|
||||
})),
|
||||
};
|
||||
|
||||
if (item.type === 1) {
|
||||
entry.effect = {
|
||||
animationType: item.animationType,
|
||||
thumbnailPreviewSrc: item.thumbnailPreviewSrc,
|
||||
reducedMotionSrc: item.reducedMotionSrc,
|
||||
effects: item.effects,
|
||||
};
|
||||
}
|
||||
|
||||
items.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async function apiRequest(path, authorization, { retries = 4 } = {}) {
|
||||
if (!authorization) {
|
||||
throw new Error('No Discord token configured. Add one in Profile settings.');
|
||||
}
|
||||
|
||||
const url = `https://discord.com/api/v10${path}`;
|
||||
const headers = {
|
||||
Authorization: authorization,
|
||||
'User-Agent': DEFAULT_USER_AGENT,
|
||||
Accept: 'application/json',
|
||||
};
|
||||
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, { headers });
|
||||
} catch (err) {
|
||||
if (attempt >= retries) throw err;
|
||||
await sleep(500 * 2 ** attempt);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (res.status === 429) {
|
||||
const retryAfter = Number(res.headers.get('retry-after')) || 1;
|
||||
await sleep((retryAfter + 0.5) * 1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
throw new Error(
|
||||
`Discord returned ${res.status}. Your token is missing, invalid, or expired.`
|
||||
);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
if (attempt >= retries) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Discord API error: ${res.status} ${res.statusText} ${body}`);
|
||||
}
|
||||
await sleep(500 * 2 ** attempt);
|
||||
continue;
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCatalog(authorization) {
|
||||
const params = new URLSearchParams({
|
||||
country_code: 'US',
|
||||
include_bundles: 'true',
|
||||
include_nameplates_on_mobile: 'true',
|
||||
});
|
||||
|
||||
const data = await apiRequest(`/collectibles-categories?${params.toString()}`, authorization);
|
||||
const categories = Array.isArray(data) ? data : data?.categories ?? [];
|
||||
return { categories, items: extractCollectibleItems(categories) };
|
||||
}
|
||||
|
||||
async function downloadAsset(url, { retries = 4 } = {}) {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
headers: { 'User-Agent': DEFAULT_USER_AGENT, Accept: '*/*' },
|
||||
});
|
||||
} catch (err) {
|
||||
if (attempt >= retries) throw err;
|
||||
await sleep(500 * 2 ** attempt);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (res.status === 429) {
|
||||
const retryAfter = Number(res.headers.get('retry-after')) || 1;
|
||||
await sleep((retryAfter + 0.5) * 1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
if (attempt >= retries) {
|
||||
throw new Error(`Failed to download asset: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
await sleep(500 * 2 ** attempt);
|
||||
continue;
|
||||
}
|
||||
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
|
||||
function createDiscordCollectiblesService(store) {
|
||||
let catalogCache = null;
|
||||
let catalogCacheAt = 0;
|
||||
const CATALOG_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
function getToken() {
|
||||
const fromStore = store.get('discordCollectiblesToken');
|
||||
if (fromStore) return String(fromStore);
|
||||
if (process.env.DISCORD_TOKEN) return String(process.env.DISCORD_TOKEN);
|
||||
return null;
|
||||
}
|
||||
|
||||
function setToken(token) {
|
||||
if (!token || !String(token).trim()) {
|
||||
store.delete('discordCollectiblesToken');
|
||||
catalogCache = null;
|
||||
return { success: true };
|
||||
}
|
||||
store.set('discordCollectiblesToken', String(token).trim());
|
||||
catalogCache = null;
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
store.delete('discordCollectiblesToken');
|
||||
catalogCache = null;
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
function hasToken() {
|
||||
return Boolean(getToken());
|
||||
}
|
||||
|
||||
async function getCatalog({ force = false } = {}) {
|
||||
const authorization = toAuthHeader(getToken());
|
||||
if (!authorization) {
|
||||
return { success: false, error: 'No Discord token configured.' };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (!force && catalogCache && now - catalogCacheAt < CATALOG_TTL_MS) {
|
||||
return { success: true, data: catalogCache };
|
||||
}
|
||||
|
||||
try {
|
||||
const { items } = await fetchCatalog(authorization);
|
||||
catalogCache = { items, fetchedAt: new Date().toISOString() };
|
||||
catalogCacheAt = now;
|
||||
return { success: true, data: catalogCache };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAssets(assetUrls) {
|
||||
const results = [];
|
||||
for (const asset of assetUrls) {
|
||||
const buffer = await downloadAsset(asset.url);
|
||||
results.push({
|
||||
role: asset.role,
|
||||
url: asset.url,
|
||||
filename: asset.filename,
|
||||
mimeType: asset.mimeType || guessMimeType(asset.filename),
|
||||
data: buffer,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
return {
|
||||
getToken,
|
||||
setToken,
|
||||
clearToken,
|
||||
hasToken,
|
||||
getCatalog,
|
||||
downloadAssets,
|
||||
ITEM_TYPE_FOLDER,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createDiscordCollectiblesService, extractCollectibleItems, urlsForItem };
|
||||
@@ -8,6 +8,7 @@ const openPkg = require('open');
|
||||
const open = openPkg.default;
|
||||
const openApps = openPkg.apps;
|
||||
const PaarrotAPIServer = require('./api-server');
|
||||
const { createDiscordCollectiblesService } = require('./discord-collectibles');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const AdmZip = require('adm-zip');
|
||||
@@ -104,6 +105,7 @@ function isAppOwnedUrl(targetUrl, currentUrl) {
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
const store = new Store();
|
||||
const discordCollectibles = createDiscordCollectiblesService(store);
|
||||
const PROTOCOL_SCHEME = 'paarrot';
|
||||
|
||||
let mainWindow = null;
|
||||
@@ -1901,6 +1903,55 @@ ipcMain.handle('plugin:read-code', async (event, pluginId) => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('discord-collectibles:has-token', () => {
|
||||
return { success: true, data: discordCollectibles.hasToken() };
|
||||
});
|
||||
|
||||
ipcMain.handle('discord-collectibles:set-token', (event, { token }) => {
|
||||
try {
|
||||
return discordCollectibles.setToken(token);
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('discord-collectibles:clear-token', () => {
|
||||
try {
|
||||
return discordCollectibles.clearToken();
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('discord-collectibles:fetch-catalog', async (event, { force } = {}) => {
|
||||
try {
|
||||
return await discordCollectibles.getCatalog({ force: Boolean(force) });
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('discord-collectibles:download-assets', async (event, { assets }) => {
|
||||
try {
|
||||
if (!Array.isArray(assets) || assets.length === 0) {
|
||||
return { success: false, error: 'No assets to download' };
|
||||
}
|
||||
const downloaded = await discordCollectibles.downloadAssets(assets);
|
||||
return {
|
||||
success: true,
|
||||
data: downloaded.map((item) => ({
|
||||
role: item.role,
|
||||
url: item.url,
|
||||
filename: item.filename,
|
||||
mimeType: item.mimeType,
|
||||
data: item.data,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Paarrot Electron main process started');
|
||||
console.log('Development mode:', isDev);
|
||||
console.log('Platform:', process.platform);
|
||||
|
||||
@@ -193,7 +193,16 @@ contextBridge.exposeInMainWorld('electron', {
|
||||
protocol: {
|
||||
getStatus: () => ipcRenderer.invoke('protocol:get-status'),
|
||||
repair: () => ipcRenderer.invoke('protocol:repair')
|
||||
}
|
||||
},
|
||||
|
||||
// Discord shop collectibles (catalog + on-demand CDN download)
|
||||
discordCollectibles: {
|
||||
hasToken: () => ipcRenderer.invoke('discord-collectibles:has-token'),
|
||||
setToken: (token) => ipcRenderer.invoke('discord-collectibles:set-token', { token }),
|
||||
clearToken: () => ipcRenderer.invoke('discord-collectibles:clear-token'),
|
||||
fetchCatalog: (force = false) => ipcRenderer.invoke('discord-collectibles:fetch-catalog', { force }),
|
||||
downloadAssets: (assets) => ipcRenderer.invoke('discord-collectibles:download-assets', { assets }),
|
||||
},
|
||||
});
|
||||
|
||||
// Preload script ready
|
||||
|
||||
Reference in New Issue
Block a user