import { findSteamAppId, getInstalledAppSteamAppId, getSidebarGameRowClientIcons, getSidebarGameRowClientIconUrl, getSteamClientIconUrl, pickImageUrl, } from './artwork.js'; import { EXCLUDED_UNAVAILABLE_TITLE_PLATFORMS, SNAPSHOT_ENTRY_KEY_PREFIX, UNAVAILABLE_TITLES_BATCH_SIZE, } from './constants.js'; import { formatError, getBasename, isRecord, normalizeStringList, safeString, toStringId, } from './runtime.js'; export function resolveInstalledData(state) { const storeState = getStoreState(state.storeRef); if (isRecord(state.installedAppsService?.installedApps)) { return { rawInstalledApps: state.installedAppsService.installedApps, catalog: isRecord(state.installedAppsService.catalog) ? state.installedAppsService.catalog : isRecord(storeState?.catalog) ? storeState.catalog : {}, installedGameVersions: isRecord(state.installedAppsService.installedVersions) ? state.installedAppsService.installedVersions : isRecord(storeState?.installedGameVersions) ? storeState.installedGameVersions : {}, correlatedUnavailableTitles: getResolvedUnavailableTitles( state, storeState?.correlatedUnavailableTitles, ), source: 'service', }; } if (isRecord(storeState?.installedApps)) { return { rawInstalledApps: storeState.installedApps, catalog: isRecord(storeState.catalog) ? storeState.catalog : {}, installedGameVersions: isRecord(storeState.installedGameVersions) ? storeState.installedGameVersions : {}, correlatedUnavailableTitles: getResolvedUnavailableTitles( state, storeState.correlatedUnavailableTitles, ), source: 'store', }; } return null; } export async function refreshUnavailableTitles(state, rawInstalledApps, force = false) { if (!state.unavailableTitlesService) { return state.unavailableTitlesById; } const correlationIds = getCorrelationIdsForUnavailableTitles(rawInstalledApps); const fetchKey = correlationIds.join('\n'); if (!fetchKey) { state.unavailableTitlesFetchKey = ''; state.unavailableTitlesById = {}; return state.unavailableTitlesById; } if (!force && fetchKey === state.unavailableTitlesFetchKey) { if (state.unavailableTitlesFetchPromise) { await state.unavailableTitlesFetchPromise; } return state.unavailableTitlesById; } state.unavailableTitlesFetchKey = fetchKey; state.unavailableTitlesFetchPromise = fetchUnavailableTitles(state, correlationIds); await state.unavailableTitlesFetchPromise; return state.unavailableTitlesById; } export function buildSnapshot(state) { const data = resolveInstalledData(state); if (!data) { if (state.installedAppsService) { state.log( 'warn', 'Service resolved but installedApps is empty/undefined. Store fallback also unavailable.', ); } return null; } const { rawInstalledApps, catalog, installedGameVersions, correlatedUnavailableTitles, source, } = data; const catalogGames = isRecord(catalog.games) ? catalog.games : {}; const catalogTitles = isRecord(catalog.titles) ? catalog.titles : {}; const sidebarGameRowClientIcons = getSidebarGameRowClientIcons(); const entriesByKey = new Map(); let matchedCatalogGames = 0; let matchedUnavailableGames = 0; state.log( 'info', `Building snapshot from ${source}.`, `rawInstalledApps=${Object.keys(rawInstalledApps).length}, catalogGames=${Object.keys(catalogGames).length}, installedGameVersions=${Object.keys(installedGameVersions).length}, unavailableTitles=${Object.keys(correlatedUnavailableTitles).length}`, ); for (const [gameId, versions] of Object.entries(installedGameVersions)) { if (!Array.isArray(versions)) { continue; } const game = catalogGames[gameId]; if (!isRecord(game)) { continue; } const preferredApp = pickPreferredInstalledApp(rawInstalledApps, game, versions); if (!preferredApp) { continue; } const titleId = toStringId(game.titleId); const title = titleId ? catalogTitles[titleId] || catalogTitles[game.titleId] || null : null; const sidebarClientIconUrl = getSidebarGameRowClientIconUrl( sidebarGameRowClientIcons, titleId, title?.name, title?.displayName, game.displayName, game.title, game.name, preferredApp.displayName, ); upsertSnapshotEntry(entriesByKey, { ...preferredApp, displayName: safeString( title?.name, title?.displayName, game.displayName, game.title, game.name, preferredApp.displayName, gameId, ), imageUrl: pickImageUrlForTitle( title, game, preferredApp, sidebarClientIconUrl, versions, ), gameId: String(gameId), titleId, }); matchedCatalogGames += 1; } for (const unavailableTitle of Object.values(correlatedUnavailableTitles)) { if (!isRecord(unavailableTitle) || !Array.isArray(unavailableTitle.games)) { continue; } const titleId = toStringId(unavailableTitle.id); for (const game of unavailableTitle.games) { if (!isRecord(game) || !Array.isArray(game.correlationIds)) { continue; } const preferredApp = pickPreferredInstalledApp(rawInstalledApps, game); if (!preferredApp) { continue; } const sidebarClientIconUrl = getSidebarGameRowClientIconUrl( sidebarGameRowClientIcons, titleId, unavailableTitle.name, game.name, preferredApp.displayName, ); upsertSnapshotEntry(entriesByKey, { ...preferredApp, displayName: safeString( unavailableTitle.name, game.name, preferredApp.displayName, preferredApp.correlationId, ), imageUrl: pickImageUrlForTitle( unavailableTitle, game, preferredApp, sidebarClientIconUrl, ), gameId: toStringId(game.id), titleId, }); matchedUnavailableGames += 1; } } const apps = Array.from(entriesByKey.values()).sort(compareInstalledAppRecords); return { instanceId: 'wand-installed-apps', updatedAt: new Date().toISOString(), apps, diagnostics: { catalogGames: Object.keys(catalogGames).length, catalogTitles: Object.keys(catalogTitles).length, installedGameVersions: Object.keys(installedGameVersions).length, correlatedUnavailableTitles: Object.keys(correlatedUnavailableTitles).length, matchedCatalogGames, matchedUnavailableGames, myGames: apps.length, rawInstalledApps: Object.keys(rawInstalledApps).length, }, }; } /** * Structural, not field-by-field. This used to enumerate fields and had already * drifted from the bridge's copy (it listed `location`, the bridge's did not), so a * game moving install directory never reached the panel. */ export function makeInstalledAppsSignature(snapshot) { return JSON.stringify(snapshot.apps); } export function toInstalledAppRecord(correlationId, app) { if (!isRecord(app) || typeof correlationId !== 'string' || !correlationId.trim()) { return null; } const [fallbackPlatform, fallbackSku] = correlationId.split(':'); const platform = safeString(app.platform, fallbackPlatform); const sku = safeString(app.sku, fallbackSku); if (!platform || !sku) { return null; } const location = typeof app.location === 'string' ? app.location : ''; const alternateLocations = normalizeStringList(app.alternateLocations); return { platform, sku, correlationId, displayName: safeString( app.displayName, app.titleName, app.gameName, app.name, getBasename(location), correlationId, ), location, alternateLocations, imageUrl: pickImageUrl( app.imageUrl, app.iconUrl, app.coverUrl, app.thumbnailUrl, app.logoUrl, app.headerImageUrl, app.icon, app.images, app.assets, getSteamClientIconUrl(findSteamAppId(app), getInstalledAppSteamAppId(platform, sku)), ), platformLastPlayedTimestamp: typeof app.platformLastPlayedTimestamp === 'number' ? app.platformLastPlayedTimestamp : null, platformTotalPlaytimeMinutes: typeof app.platformTotalPlaytimeMinutes === 'number' ? app.platformTotalPlaytimeMinutes : null, }; } export function compareInstalledAppRecords(left, right) { const lastPlayedDiff = (right.platformLastPlayedTimestamp ?? 0) - (left.platformLastPlayedTimestamp ?? 0); if (lastPlayedDiff !== 0) { return lastPlayedDiff; } const playtimeDiff = (right.platformTotalPlaytimeMinutes ?? 0) - (left.platformTotalPlaytimeMinutes ?? 0); if (playtimeDiff !== 0) { return playtimeDiff; } return compareByIdentity(left, right); } export function getInstalledVersionsForGame(gameId, data) { const versions = Array.isArray(data?.installedGameVersions?.[gameId]) ? data.installedGameVersions[gameId] : []; return Array.from( new Set( versions .map((entry) => entry?.version) .filter((entry) => typeof entry === 'string' || typeof entry === 'number'), ), ); } function getStoreState(storeRef) { const state = typeof storeRef?.state?.getValue === 'function' ? storeRef.state.getValue() : null; return isRecord(state) ? state : null; } function getResolvedUnavailableTitles(state, liveTitles) { const liveCount = isRecord(liveTitles) ? Object.keys(liveTitles).length : 0; return liveCount > 0 ? liveTitles : state.unavailableTitlesById; } function getCorrelationIdsForUnavailableTitles(rawInstalledApps) { return Object.entries(rawInstalledApps) .filter( ([, app]) => isRecord(app) && !EXCLUDED_UNAVAILABLE_TITLE_PLATFORMS.has(safeString(app.platform)), ) .map(([correlationId]) => correlationId) .sort(); } async function fetchUnavailableTitles(state, correlationIds) { const nextTitlesById = {}; try { for (let index = 0; index < correlationIds.length; index += UNAVAILABLE_TITLES_BATCH_SIZE) { const batch = correlationIds.slice(index, index + UNAVAILABLE_TITLES_BATCH_SIZE); const response = await state.unavailableTitlesService.getUnavailableTitlesByCorrelationIds(batch); for (const title of normalizeUnavailableTitlesResponse(response)) { nextTitlesById[title.id] = title; } } state.unavailableTitlesById = nextTitlesById; state.log( 'info', 'Unavailable titles refreshed.', `correlationIds=${correlationIds.length}, titles=${Object.keys(nextTitlesById).length}`, ); } catch (error) { state.log('warn', 'Unavailable titles refresh failed.', formatError(error)); } finally { state.unavailableTitlesFetchPromise = null; } } function normalizeUnavailableTitlesResponse(value) { const titles = Array.isArray(value) ? value : Array.isArray(value?.data) ? value.data : []; return titles.map(normalizeUnavailableTitle).filter(Boolean); } function normalizeUnavailableTitle(title) { if (!isRecord(title)) { return null; } const titleId = toStringId(title.id ?? title.titleId); if (!titleId) { return null; } const games = Array.isArray(title.games) ? title.games.map(normalizeUnavailableTitleGame).filter(Boolean) : []; if (games.length === 0) { return null; } return { ...title, id: titleId, name: safeString(title.name, title.titleName, titleId), games, }; } function normalizeUnavailableTitleGame(game) { if (!isRecord(game)) { return null; } const gameId = toStringId(game.id ?? game.gameId); const correlationIds = normalizeStringList(game.correlationIds); if (!gameId || correlationIds.length === 0) { return null; } return { ...game, id: gameId, platformId: safeString(game.platformId, 'unknown'), correlationIds, flags: typeof game.flags === 'number' ? game.flags : 0, name: safeString(game.name, game.titleName, game.title, gameId), }; } function collectCorrelationIds(game, versions) { const entries = []; if (Array.isArray(game?.correlationIds)) { for (const correlationId of game.correlationIds) { if (typeof correlationId === 'string' && correlationId.trim()) { entries.push({ correlationId: correlationId.trim(), version: null }); } } } if (Array.isArray(versions)) { for (const version of versions) { if (typeof version?.correlationId === 'string' && version.correlationId.trim()) { entries.push({ correlationId: version.correlationId.trim(), version: version.version ?? null, }); } } } return entries; } export function rankInstalledAppCandidates(rawInstalledApps, game, versions) { return Array.from( new Map( collectCorrelationIds(game, versions).map((entry) => [entry.correlationId, entry]), ).values(), ) .map((entry) => { const app = rawInstalledApps?.[entry.correlationId]; const record = toInstalledAppRecord(entry.correlationId, app); return record ? { app, version: entry.version ?? null, record } : null; }) .filter(Boolean) .sort((left, right) => compareInstalledAppRecords(left.record, right.record)); } function pickPreferredInstalledApp(rawInstalledApps, game, versions) { return rankInstalledAppCandidates(rawInstalledApps, game, versions)[0]?.record || null; } function upsertSnapshotEntry(entriesByKey, entry) { const key = getSnapshotEntryKey(entry); const current = entriesByKey.get(key); if (!current || compareInstalledAppRecords(entry, current) < 0) { entriesByKey.set(key, entry); } } function getSnapshotEntryKey(entry) { if (entry.titleId) { return `${SNAPSHOT_ENTRY_KEY_PREFIX.TITLE}${entry.titleId}`; } if (entry.gameId) { return `${SNAPSHOT_ENTRY_KEY_PREFIX.GAME}${entry.gameId}`; } return `${SNAPSHOT_ENTRY_KEY_PREFIX.APP}${entry.correlationId}`; } function compareByIdentity(left, right) { const displayNameDiff = left.displayName.localeCompare(right.displayName); if (displayNameDiff !== 0) { return displayNameDiff; } const platformDiff = left.platform.localeCompare(right.platform); if (platformDiff !== 0) { return platformDiff; } return left.sku.localeCompare(right.sku); } function pickImageUrlForTitle(title, game, preferredApp, sidebarClientIconUrl, versions) { const steamRoots = versions !== undefined ? [title, game, versions, preferredApp] : [title, game, preferredApp]; return pickImageUrl( title?.imageUrl, title?.iconUrl, title?.coverUrl, title?.thumbnailUrl, title?.logoUrl, title?.headerImageUrl, getSteamClientIconUrl( findSteamAppId(...steamRoots), getInstalledAppSteamAppId(preferredApp.platform, preferredApp.sku), ), sidebarClientIconUrl, title?.images, title?.assets, game.imageUrl, game.iconUrl, game.coverUrl, game.thumbnailUrl, game.logoUrl, game.headerImageUrl, game.images, game.assets, preferredApp.imageUrl, ); }