Add Discord collectibles IPC and bump cinny for profile overlays.
All checks were successful
Build / increment-version (push) Successful in 12s
Build / prepare-release (push) Successful in 15s
Build / build-windows (push) Successful in 6m55s
Build / build-linux (push) Successful in 3m10s
Build / finalize-release (push) Successful in 4s

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:
2026-08-24 00:54:54 +10:00
parent 8ca7be8b42
commit 608cbaf682
4 changed files with 488 additions and 2 deletions

View 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 };

View File

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

View File

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