Files
Wand-Enhancer/web-panel/bridge/scripts/default/installed-apps-sync/installed-data.js
T
kitbyte 13759b1db6 feat: ship 1.0.8.0 release automation and runtime overhaul
- reduce ASAR IO overhead with streamed archive reads, buffered copies, faster relative-path handling and placeholder integrity records
- fix in-place app.asar.unpacked packing/extraction self-copy cases that caused locked-file failures
- tighten JS patch discovery with candidate bundle filters and search hints
- require prebuilt remote-panel dist artifacts and clean up embedded bridge/script packaging
- add unified build entrypoints for PowerShell, cmd and bash and move native CMake output under .tmp
- add release metadata validation, changelog section extraction, pre-commit hook and GitHub Actions validation/release pipelines
- make CHANGELOG the source of truth for release notes and document the tag-driven release flow
- add updater release notes UI with latest/full changelog loading and localize the new update strings
- modularize bridge renderer scripts, add installed apps and game status sync, and support remote launch/stop commands
- centralize bridge protocol, IPC and WebSocket constants and improve LAN IP selection for QR pairing
- refactor remote panel controls/state enums, persist accent color, polish library/session UI and refresh assets
2026-05-06 13:07:09 +03:00

588 lines
15 KiB
JavaScript

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,
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,
getCatalogGameCorrelationIds(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.correlationIds
)
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(compareSnapshotEntries)
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,
},
}
}
export function makeInstalledAppsSignature(snapshot) {
return snapshot.apps
.map((app) =>
[
app.platform,
app.sku,
app.displayName,
app.gameId ?? "",
app.titleId ?? "",
app.location,
app.imageUrl ?? "",
app.platformLastPlayedTimestamp ?? "",
app.platformTotalPlaytimeMinutes ?? "",
].join("|")
)
.join("\n")
}
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.",
error?.stack || String(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 getCatalogGameCorrelationIds(game, versions) {
const correlationIds = []
if (Array.isArray(game.correlationIds)) {
for (const correlationId of game.correlationIds) {
if (typeof correlationId === "string" && correlationId.trim()) {
correlationIds.push(correlationId.trim())
}
}
}
for (const version of versions) {
if (
typeof version?.correlationId === "string" &&
version.correlationId.trim()
) {
correlationIds.push(version.correlationId.trim())
}
}
return correlationIds
}
function pickPreferredInstalledApp(rawInstalledApps, correlationIds) {
const candidates = Array.from(new Set(correlationIds))
.map((correlationId) =>
toInstalledAppRecord(correlationId, rawInstalledApps[correlationId])
)
.filter(Boolean)
.sort(compareInstalledAppRecords)
return candidates[0] || null
}
function upsertSnapshotEntry(entriesByKey, entry) {
const key = getSnapshotEntryKey(entry)
const current = entriesByKey.get(key)
if (!current || compareSnapshotEntries(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 compareSnapshotEntries(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)
}
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
)
}