mirror of
https://github.com/k1tbyte/Wand-Enhancer.git
synced 2026-09-04 01:03:26 +00:00
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
This commit is contained in:
@@ -0,0 +1,587 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user