diff --git a/cinny b/cinny index 6cb1e14..0cbc5d9 160000 --- a/cinny +++ b/cinny @@ -1 +1 @@ -Subproject commit 6cb1e146325ef96d4bc715ad9e8d3702a4bb8607 +Subproject commit 0cbc5d9a1cfa9d4525f27bb46e86bbeb37c0f8c0 diff --git a/electron/discord-collectibles.js b/electron/discord-collectibles.js new file mode 100644 index 0000000..56e613f --- /dev/null +++ b/electron/discord-collectibles.js @@ -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 }; diff --git a/electron/main.js b/electron/main.js index 2701fc3..afa7606 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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); diff --git a/electron/preload.js b/electron/preload.js index cc7fc64..a1ada2b 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -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