mirror of
https://github.com/k1tbyte/Wand-Enhancer.git
synced 2026-08-30 00:01:17 +00:00
f2e88e9247
ipcRenderer.invoke rejects asynchronously, so marking the bridge bound before awaiting left set-value dead for the session while the log reported success. Compare installed-app snapshots structurally instead of by an explicit field list, which had already drifted from the bridge copy and hid location changes.
579 lines
15 KiB
JavaScript
Vendored
579 lines
15 KiB
JavaScript
Vendored
import {
|
|
EXCLUDED_UNAVAILABLE_TITLE_PLATFORMS,
|
|
SNAPSHOT_ENTRY_KEY_PREFIX,
|
|
UNAVAILABLE_TITLES_BATCH_SIZE,
|
|
} from "./constants.js"
|
|
import {
|
|
findSteamAppId,
|
|
getInstalledAppSteamAppId,
|
|
getSidebarGameRowClientIconUrl,
|
|
getSidebarGameRowClientIcons,
|
|
getSteamClientIconUrl,
|
|
pickImageUrl,
|
|
} from "./artwork.js"
|
|
import {
|
|
getBasename,
|
|
formatError,
|
|
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
|
|
)
|
|
}
|