fix(renderer-scripts): await the bridge bind and retry it on poll

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.
This commit is contained in:
kitbyte
2026-08-29 16:56:51 +03:00
parent 5d1aa69a97
commit f2e88e9247
8 changed files with 224 additions and 184 deletions
@@ -12,9 +12,12 @@ export const RETRY_DELAY_MS = 1000
export const MAX_BOOTSTRAP_ATTEMPTS = 60
export const SYNC_INTERVAL_MS = 15000
export const OPTIONAL_SERVICES_RETRY_INTERVAL_MS = 1000
export const MAX_OPTIONAL_SERVICES_ATTEMPTS = 60
export const FOLLOW_UP_SYNC_DELAY_MS = 2500
export const UNAVAILABLE_TITLES_BATCH_SIZE = 250
export const BOOTSTRAP_LOG_THROTTLE_ATTEMPTS = 5
export const CONTAINER_LOG_THROTTLE_ATTEMPTS = 10
export const CONTAINER_GRAPH_MAX_DEPTH = 4
// Wand's webpack module exports the trainer-launch-request class under key `vO`.
// Required so `trainerService.launch(req)` records `getMetadata(vO)` state in Wand. See AGENTS.md "Remote Play".
export const TRAINER_LAUNCH_REQUEST_EXPORT_KEY = "vO"
@@ -7,7 +7,7 @@ import {
TRAINER_ENDED_EVENT,
TRAINER_SNAPSHOT_CHANNEL,
} from "./constants.js"
import { isRecord, safeString, toStringId } from "./runtime.js"
import { invokeIpc, isRecord, safeString, toStringId } from "./runtime.js"
export function createIdleGameSession() {
return {
@@ -97,19 +97,7 @@ export function clearTrainerSnapshot(state, reason, clearSession = false) {
}
void syncGameStatus(state, true)
if (!state.ipcRenderer) {
return
}
try {
void state.ipcRenderer.invoke(TRAINER_SNAPSHOT_CHANNEL, null)
} catch (error) {
state.log(
"warn",
"Trainer snapshot clear IPC failed.",
error?.stack || String(error)
)
}
void invokeIpc(state, TRAINER_SNAPSHOT_CHANNEL, null, "Trainer snapshot clear")
}
export async function syncGameStatus(state, force = false) {
@@ -125,22 +113,21 @@ export async function syncGameStatus(state, force = false) {
state.lastGameStatusSignature = signature
try {
await state.ipcRenderer.invoke(GAME_STATUS_CHANNEL, snapshot)
const sent = await invokeIpc(
state,
GAME_STATUS_CHANNEL,
snapshot,
"Game status snapshot",
"error"
)
if (sent) {
state.log(
"info",
"Game status snapshot sent.",
`session=${snapshot.session.state}/${snapshot.session.event}, trainer=${snapshot.trainer.state}/${snapshot.trainer.event}`
)
return true
} catch (error) {
state.log(
"error",
"Game status snapshot IPC failed.",
error?.stack || String(error)
)
return false
}
return sent
}
function installLifecycleSubscriptions(state) {
+52 -23
View File
@@ -2,9 +2,11 @@ import {
BIND_CHANNEL,
BOOTSTRAP_LOG_THROTTLE_ATTEMPTS,
COMMAND_REQUEST_CHANNEL,
CONTAINER_LOG_THROTTLE_ATTEMPTS,
FOLLOW_UP_SYNC_DELAY_MS,
GLOBAL_FLAG,
MAX_BOOTSTRAP_ATTEMPTS,
MAX_OPTIONAL_SERVICES_ATTEMPTS,
OPTIONAL_SERVICES_RETRY_INTERVAL_MS,
RETRY_DELAY_MS,
SYNC_CHANNEL,
@@ -24,11 +26,13 @@ import {
import { createLogger } from "./logger.js"
import { handleRemoteCommandRequest } from "./remote-commands.js"
import {
formatError,
getAppRoot,
getAureliaContainer,
getRequire,
getWebpackRequire,
hasAppRoot,
invokeIpc,
isRecord,
summarizeAureliaSubtree,
} from "./runtime.js"
@@ -36,6 +40,7 @@ import {
getInstalledAppsService,
getStoreRef,
hasMissingOptionalServices,
hasUnresolvedServices,
resolveOptionalServices,
} from "./services.js"
@@ -70,7 +75,9 @@ function createState(WandEnhancer) {
pollTimer: null,
optionalServicesTimer: null,
bootstrapAttempts: 0,
optionalServicesAttempts: 0,
bridgeBound: false,
bridgeBinding: false,
refreshPatched: false,
installedAppsService: null,
gameLifecycleService: null,
@@ -114,13 +121,11 @@ function setBootstrapReason(state, reason) {
)
}
function bindBridge(state) {
if (state.bridgeBound || !state.ipcRenderer) {
async function bindBridge(state) {
if (state.bridgeBound || state.bridgeBinding || !state.ipcRenderer) {
return
}
state.bridgeBound = true
if (!state.commandListenerInstalled) {
state.ipcRenderer.on(COMMAND_REQUEST_CHANNEL, (event, request) =>
handleRemoteCommandRequest(state, event, request)
@@ -129,11 +134,18 @@ function bindBridge(state) {
state.log("info", "Bridge remote command handler installed.")
}
// Await the bind: invoke rejects asynchronously, so marking the bridge bound up
// front left set-value permanently dead whenever the main-process handler was not
// registered yet - and the log still claimed success.
state.bridgeBinding = true
try {
void state.ipcRenderer.invoke(BIND_CHANNEL)
state.log("info", "Bridge set-value handler bind requested.")
await state.ipcRenderer.invoke(BIND_CHANNEL)
state.bridgeBound = true
state.log("info", "Bridge set-value handler bound.")
} catch (error) {
state.log("warn", "Bridge bind failed.", error?.stack || String(error))
state.log("warn", "Bridge bind failed; will retry on the next sync.", formatError(error))
} finally {
state.bridgeBinding = false
}
}
@@ -170,22 +182,21 @@ async function syncInstalledApps(state, force = false) {
state.lastSignature = signature
try {
await state.ipcRenderer.invoke(SYNC_CHANNEL, snapshot)
const sent = await invokeIpc(
state,
SYNC_CHANNEL,
snapshot,
"Installed apps snapshot",
"error"
)
if (sent) {
state.log(
"info",
"Installed apps snapshot sent.",
`apps=${snapshot.apps.length}, catalogGames=${snapshot.diagnostics.catalogGames}, rawInstalledApps=${snapshot.diagnostics.rawInstalledApps}`
)
return true
} catch (error) {
state.log(
"error",
"Installed apps snapshot IPC failed.",
error?.stack || String(error)
)
return false
}
return sent
}
function queueSync(state, force = false) {
@@ -269,11 +280,25 @@ function startOptionalServicesRetry(state) {
}
state.optionalServicesTimer = setInterval(() => {
state.optionalServicesAttempts += 1
const container = getAureliaContainer()
const webpackRequire = getWebpackRequire()
if (container && webpackRequire) {
resolveRuntimeServices(state, container, webpackRequire)
}
if (
state.optionalServicesTimer &&
state.optionalServicesAttempts >= MAX_OPTIONAL_SERVICES_ATTEMPTS
) {
stopOptionalServicesRetry(state)
state.log(
"warn",
"Optional service retry exhausted.",
`attempts=${state.optionalServicesAttempts}`
)
}
}, OPTIONAL_SERVICES_RETRY_INTERVAL_MS)
state.log(
@@ -341,7 +366,7 @@ function bootstrap(state) {
}
}
bindBridge(state)
void bindBridge(state)
patchRefreshApps(state)
queueSync(state, true)
queueFollowUpSync(state)
@@ -369,11 +394,15 @@ function startPollTimer(state) {
}
state.pollTimer = setInterval(() => {
const container = getAureliaContainer()
const webpackRequire = getWebpackRequire()
if (container && webpackRequire) {
resolveRuntimeServices(state, container, webpackRequire)
if (hasUnresolvedServices(state)) {
const container = getAureliaContainer()
const webpackRequire = getWebpackRequire()
if (container && webpackRequire) {
resolveRuntimeServices(state, container, webpackRequire)
}
}
void bindBridge(state)
void syncInstalledApps(state)
}, SYNC_INTERVAL_MS)
@@ -385,7 +414,7 @@ function startPollTimer(state) {
}
function logMissingContainer(state) {
if (state.bootstrapAttempts % 10 !== 0) {
if (state.bootstrapAttempts % CONTAINER_LOG_THROTTLE_ATTEMPTS !== 0) {
return
}
@@ -13,6 +13,7 @@ import {
} from "./artwork.js"
import {
getBasename,
formatError,
isRecord,
normalizeStringList,
safeString,
@@ -140,7 +141,8 @@ export function buildSnapshot(state) {
const preferredApp = pickPreferredInstalledApp(
rawInstalledApps,
getCatalogGameCorrelationIds(game, versions)
game,
versions
)
if (!preferredApp) {
continue
@@ -192,7 +194,7 @@ export function buildSnapshot(state) {
const preferredApp = pickPreferredInstalledApp(
rawInstalledApps,
game.correlationIds
game
)
if (!preferredApp) {
continue
@@ -221,7 +223,7 @@ export function buildSnapshot(state) {
}
}
const apps = Array.from(entriesByKey.values()).sort(compareSnapshotEntries)
const apps = Array.from(entriesByKey.values()).sort(compareInstalledAppRecords)
return {
instanceId: "wand-installed-apps",
@@ -241,22 +243,13 @@ export function buildSnapshot(state) {
}
}
/**
* 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 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")
return JSON.stringify(snapshot.apps)
}
export function toInstalledAppRecord(correlationId, app) {
@@ -407,7 +400,7 @@ async function fetchUnavailableTitles(state, correlationIds) {
state.log(
"warn",
"Unavailable titles refresh failed.",
error?.stack || String(error)
formatError(error)
)
} finally {
state.unavailableTitlesFetchPromise = null
@@ -469,44 +462,60 @@ function normalizeUnavailableTitleGame(game) {
}
}
function getCatalogGameCorrelationIds(game, versions) {
const correlationIds = []
function collectCorrelationIds(game, versions) {
const entries = []
if (Array.isArray(game.correlationIds)) {
if (Array.isArray(game?.correlationIds)) {
for (const correlationId of game.correlationIds) {
if (typeof correlationId === "string" && correlationId.trim()) {
correlationIds.push(correlationId.trim())
entries.push({ correlationId: correlationId.trim(), version: null })
}
}
}
for (const version of versions) {
if (
typeof version?.correlationId === "string" &&
version.correlationId.trim()
) {
correlationIds.push(version.correlationId.trim())
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 correlationIds
return entries
}
function pickPreferredInstalledApp(rawInstalledApps, correlationIds) {
const candidates = Array.from(new Set(correlationIds))
.map((correlationId) =>
toInstalledAppRecord(correlationId, rawInstalledApps[correlationId])
)
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(compareInstalledAppRecords)
.sort((left, right) => compareInstalledAppRecords(left.record, right.record))
}
return candidates[0] || null
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 || compareSnapshotEntries(entry, current) < 0) {
if (!current || compareInstalledAppRecords(entry, current) < 0) {
entriesByKey.set(key, entry)
}
}
@@ -523,24 +532,6 @@ function getSnapshotEntryKey(entry) {
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) {
@@ -1,6 +1,7 @@
import { LOG_FILE_NAME, LOG_PREFIX } from "./constants.js"
import { getRequire } from "./runtime.js"
// Logging runs inside Wand's own process; a failure here must never take the app down.
export function createLogger(WandEnhancer) {
let filePath = null
@@ -12,7 +13,7 @@ export function createLogger(WandEnhancer) {
filePath = path.join(os.tmpdir(), LOG_FILE_NAME)
globalThis.__wandInstalledAppsSyncLogFile = filePath
}
} catch (error) {}
} catch {}
return function log(level, message, detail) {
const method =
@@ -21,13 +22,13 @@ export function createLogger(WandEnhancer) {
try {
console[method](LOG_PREFIX, message, detail || "")
} catch (error) {}
} catch {}
try {
if (WandEnhancer?.log) {
WandEnhancer.log(`${LOG_PREFIX} ${message}`, detail || "")
}
} catch (error) {}
} catch {}
writeFile(filePath, line)
}
@@ -42,5 +43,5 @@ function writeFile(filePath, line) {
const require = getRequire()
const fs = require?.("node:fs")
fs?.appendFileSync(filePath, `${line}\n`)
} catch (error) {}
} catch {}
}
@@ -7,13 +7,14 @@ import {
} from "./constants.js"
import { clearTrainerSnapshot, syncGameStatus } from "./game-status.js"
import {
compareInstalledAppRecords,
getInstalledVersionsForGame,
rankInstalledAppCandidates,
resolveInstalledData,
toInstalledAppRecord,
} from "./installed-data.js"
import {
formatError,
getPreferredLocale,
invokeIpc,
isRecord,
safeString,
toStringId,
@@ -131,7 +132,7 @@ async function executeRemoteLaunchCommand(state, request) {
state.log(
"warn",
"Remote trainer launch failed.",
error?.stack || String(error)
formatError(error)
)
return buildCommandResponse(request, false, {
code: "launch_failed",
@@ -176,7 +177,7 @@ async function executeRemoteStopCommand(state, request) {
state.log(
"warn",
"Remote trainer stop failed.",
error?.stack || String(error)
formatError(error)
)
return buildCommandResponse(request, false, {
code: "stop_failed",
@@ -192,47 +193,14 @@ function getLaunchInfoForGame(gameId, data) {
const game = isRecord(data?.catalog?.games?.[gameId])
? data.catalog.games[gameId]
: null
const candidates = []
if (Array.isArray(game?.correlationIds)) {
for (const correlationId of game.correlationIds) {
if (typeof correlationId === "string" && correlationId.trim()) {
candidates.push({ correlationId: correlationId.trim(), version: null })
}
}
}
const top = rankInstalledAppCandidates(
data?.rawInstalledApps ?? {},
game,
versions
)[0]
for (const versionEntry of versions) {
if (
typeof versionEntry?.correlationId === "string" &&
versionEntry.correlationId.trim()
) {
candidates.push({
correlationId: versionEntry.correlationId.trim(),
version: versionEntry.version ?? null,
})
}
}
const rankedCandidates = Array.from(
new Map(
candidates.map((candidate) => [candidate.correlationId, candidate])
).values()
)
.map((candidate) => normalizeLaunchCandidate(candidate, data))
.filter(Boolean)
.sort((left, right) =>
compareInstalledAppRecords(left.normalizedApp, right.normalizedApp)
)
if (!rankedCandidates[0]) {
return { app: null, version: null }
}
return {
app: rankedCandidates[0].app,
version: rankedCandidates[0].version,
}
return top ? { app: top.app, version: top.version } : { app: null, version: null }
}
async function resolveTrainerInfoForGame(state, gameId, data) {
@@ -251,7 +219,7 @@ async function resolveTrainerInfoForGame(state, gameId, data) {
state.log(
"warn",
"Local trainer lookup failed.",
error?.stack || String(error)
formatError(error)
)
}
@@ -268,26 +236,12 @@ async function resolveTrainerInfoForGame(state, gameId, data) {
state.log(
"warn",
"Compatible trainer lookup failed.",
error?.stack || String(error)
formatError(error)
)
return null
}
}
function normalizeLaunchCandidate(candidate, data) {
const app = data?.rawInstalledApps?.[candidate.correlationId]
const normalizedApp = toInstalledAppRecord(candidate.correlationId, app)
if (!normalizedApp || !isRecord(app)) {
return null
}
return {
app,
version: candidate.version,
normalizedApp,
}
}
function unwrapTrainerInfo(value) {
if (isRecord(value?.trainer)) {
return value.trainer
@@ -297,17 +251,10 @@ function unwrapTrainerInfo(value) {
}
async function sendRemoteCommandResponse(state, response) {
if (!state.ipcRenderer) {
return
}
try {
await state.ipcRenderer.invoke(COMMAND_RESPONSE_CHANNEL, response)
} catch (error) {
state.log(
"warn",
"Remote command response IPC failed.",
error?.stack || String(error)
)
}
await invokeIpc(
state,
COMMAND_RESPONSE_CHANNEL,
response,
"Remote command response"
)
}
@@ -1,7 +1,50 @@
import { CONTAINER_GRAPH_MAX_DEPTH } from "./constants.js"
export function isRecord(value) {
return typeof value === "object" && value !== null
}
export function formatError(error) {
return error?.stack || String(error)
}
export async function invokeIpc(state, channel, payload, label, level = "warn") {
if (!state.ipcRenderer) {
return false
}
try {
await state.ipcRenderer.invoke(channel, payload)
return true
} catch (error) {
state.log(level, `${label} IPC failed.`, formatError(error))
return false
}
}
export function isDiagnosticsDebugEnabled() {
return globalThis.__wandInstalledAppsSyncDebug === true
}
let lastPredicateErrorLogAt = 0
function logContainerGraphPredicateError(error) {
if (!isDiagnosticsDebugEnabled()) {
return
}
const now = Date.now()
if (now - lastPredicateErrorLogAt < 1000) {
return
}
lastPredicateErrorLogAt = now
console.debug(
"[wand-installed-apps-sync] container graph predicate threw",
formatError(error)
)
}
export function getRequire() {
return (
globalThis.require ||
@@ -57,7 +100,26 @@ export function hasAppRoot() {
return Boolean(getAppRoot())
}
let cachedAureliaContainer = null
function isUsableContainer(container) {
return isRecord(container) && typeof container.get === "function"
}
export function getAureliaContainer() {
if (isUsableContainer(cachedAureliaContainer)) {
return cachedAureliaContainer
}
const resolved = resolveAureliaContainer()
if (resolved) {
cachedAureliaContainer = resolved
}
return resolved
}
function resolveAureliaContainer() {
const root = getAppRoot()
const rootContainer = getContainerFromSubtree(root)
if (rootContainer) {
@@ -77,6 +139,10 @@ export function getAureliaContainer() {
}
export function summarizeAureliaSubtree(root) {
if (!isDiagnosticsDebugEnabled()) {
return "(debug-disabled)"
}
if (!root) {
return "root=null"
}
@@ -149,7 +215,11 @@ export function findExportedConstructor(webpackRequire, predicate) {
return null
}
export function findInstanceInContainerGraph(root, predicate, maxDepth = 4) {
export function findInstanceInContainerGraph(
root,
predicate,
maxDepth = CONTAINER_GRAPH_MAX_DEPTH
) {
if (!root) {
return null
}
@@ -171,7 +241,9 @@ export function findInstanceInContainerGraph(root, predicate, maxDepth = 4) {
if (predicate(value)) {
return value
}
} catch (error) {}
} catch (error) {
logContainerGraphPredicateError(error)
}
if (depth >= maxDepth) {
continue
@@ -2,6 +2,7 @@ import { TRAINER_LAUNCH_REQUEST_EXPORT_KEY } from "./constants.js"
import {
findExportedConstructor,
findInstanceInContainerGraph,
formatError,
isRecord,
} from "./runtime.js"
@@ -13,6 +14,15 @@ export function hasMissingOptionalServices(state) {
)
}
export function hasUnresolvedServices(state) {
return (
hasMissingOptionalServices(state) ||
!state.trainerApiService ||
!state.trainerService ||
!state.trainerLaunchRequestCtor
)
}
const OPTIONAL_SERVICE_SPECS = [
{
stateKey: "unavailableTitlesService",
@@ -87,7 +97,7 @@ export function getInstalledAppsService(state, container, webpackRequire) {
state.log(
"warn",
"Failed to resolve installed apps service from Aurelia container.",
error?.stack || String(error)
formatError(error)
)
return null
}
@@ -129,7 +139,7 @@ export function getStoreRef(state, container, webpackRequire) {
state.log(
"warn",
"Failed to resolve Store from container.",
error?.stack || String(error)
formatError(error)
)
return null
}
@@ -216,7 +226,7 @@ function getContainerService(state, container, ctor, warningKey, label) {
state.log(
"warn",
`Failed to resolve ${label.toLowerCase()} from Aurelia container.`,
error?.stack || String(error)
formatError(error)
)
return null
}