From b1fd73cbe61e0bb449b639647ccceb6a2b747dc8 Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Mon, 11 May 2026 00:41:03 +0200 Subject: [PATCH] fix: profile avatar shows fallback X instead of real picture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reproduction: open the new profile header (4.6.17) for any streamer with a real Twitch avatar. The fallback gradient letter tile renders instead of the actual profile picture. Image works fine pasted into the browser; only the renderer img tag fails. Cause is Electron renderer image loading against the Twitch CDN (static-cdn.jtvnw.net) — undocumented but reproducible: the same HTTPS URL that loads fine in DevTools fails silently from the live page, firing the img.onerror handler which (by design) swaps to the letter-tile fallback. Fix: fetch the avatar bytes in the main process via axios (Node http client, no renderer / CSP / referrer-policy / CORS shenanigans), convert to base64 data URL, and put THAT in the profile.avatarUrl field. The renderer just renders the data URL via img src — same code path, but the URL is now data:image/png;base64,... so no external fetch is involved. Bytes cached by source URL in a small FIFO map (256 entries) so the same avatar across cache misses only downloads once. Profile cache itself is unchanged, just stores the data URL now instead of the remote URL. On a clean restart the user sees the fix on first streamer click; mid-session a click on Refresh (top-right of the header) forces a re-fetch through the new path. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main.ts | 45 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/src/main.ts b/src/main.ts index e77864e..5bfc087 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2319,6 +2319,44 @@ const MAX_STREAMER_PROFILE_CACHE_ENTRIES = 512; const streamerProfileCache = new Map>(); const inFlightProfileRequests = new Map>(); +// Avatar bytes get embedded as data URLs in the profile so the renderer +// doesn't have to do its own HTTPS fetch (Electron's renderer img loader +// has a habit of failing silently against the Twitch CDN — undocumented, +// but reproducibly: the same URL works in DevTools but not in the live +// page). Cached by source URL so a single avatar change across multiple +// streamer entries only downloads once. +const avatarDataUrlCache = new Map(); +const MAX_AVATAR_DATA_URL_CACHE = 256; + +async function fetchAvatarAsDataUrl(url: string): Promise { + if (!url) return ''; + const cached = avatarDataUrlCache.get(url); + if (cached !== undefined) return cached; + try { + const response = await axios.get(url, { + responseType: 'arraybuffer', + timeout: 8000, + headers: { 'User-Agent': 'TwitchVODManager/1.0' } + }); + const buf = Buffer.from(response.data); + // Twitch CDN almost always serves PNG or JPEG. Detect from the + // response content-type when available, fall back to PNG which is + // the default for profile_image_url. + const contentType = (response.headers['content-type'] as string | undefined)?.split(';')[0]?.trim() || 'image/png'; + const dataUrl = `data:${contentType};base64,${buf.toString('base64')}`; + avatarDataUrlCache.set(url, dataUrl); + if (avatarDataUrlCache.size > MAX_AVATAR_DATA_URL_CACHE) { + // FIFO eviction — Map preserves insertion order. + const firstKey = avatarDataUrlCache.keys().next().value as string | undefined; + if (firstKey) avatarDataUrlCache.delete(firstKey); + } + return dataUrl; + } catch (e) { + appendDebugLog('avatar-fetch-failed', { url, error: String(e) }); + return ''; + } +} + interface HelixUser { id: string; login: string; @@ -2477,10 +2515,15 @@ async function getStreamerProfile(login: string, forceRefresh = false): Promise< } } catch (_) { /* best-effort */ } + // Embed the avatar bytes as a data URL so the renderer doesn't + // have to make its own HTTPS request. Bypasses any CSP, CORS, + // referrer-policy, or Electron renderer image-loading quirks. + const avatarDataUrl = avatarUrl ? await fetchAvatarAsDataUrl(avatarUrl) : ''; + const profile: StreamerProfile = { login: normalized, displayName, - avatarUrl, + avatarUrl: avatarDataUrl || avatarUrl, description, broadcasterType, followerCount,