mirror of
https://github.com/k1tbyte/Wand-Enhancer.git
synced 2026-08-31 19:01:24 +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.
451 lines
11 KiB
JavaScript
Vendored
451 lines
11 KiB
JavaScript
Vendored
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,
|
|
SYNC_INTERVAL_MS,
|
|
} from "./constants.js"
|
|
import {
|
|
createIdleGameSession,
|
|
createIdleTrainerStatus,
|
|
installGameStatusSubscriptions,
|
|
} from "./game-status.js"
|
|
import {
|
|
buildSnapshot,
|
|
makeInstalledAppsSignature,
|
|
refreshUnavailableTitles,
|
|
resolveInstalledData,
|
|
} from "./installed-data.js"
|
|
import { createLogger } from "./logger.js"
|
|
import { handleRemoteCommandRequest } from "./remote-commands.js"
|
|
import {
|
|
formatError,
|
|
getAppRoot,
|
|
getAureliaContainer,
|
|
getRequire,
|
|
getWebpackRequire,
|
|
hasAppRoot,
|
|
invokeIpc,
|
|
isRecord,
|
|
summarizeAureliaSubtree,
|
|
} from "./runtime.js"
|
|
import {
|
|
getInstalledAppsService,
|
|
getStoreRef,
|
|
hasMissingOptionalServices,
|
|
hasUnresolvedServices,
|
|
resolveOptionalServices,
|
|
} from "./services.js"
|
|
|
|
export function installInstalledAppsSync(WandEnhancer) {
|
|
if (globalThis[GLOBAL_FLAG]) {
|
|
return
|
|
}
|
|
|
|
globalThis[GLOBAL_FLAG] = true
|
|
|
|
const state = createState(WandEnhancer)
|
|
state.resolveRemoteCommandServices = () => resolveRemoteCommandServices(state)
|
|
state.queueSync = (force = false) => queueSync(state, force)
|
|
state.queueFollowUpSync = () => queueFollowUpSync(state)
|
|
|
|
state.log(
|
|
"info",
|
|
"Script loaded.",
|
|
`logFile=${globalThis.__wandInstalledAppsSyncLogFile || "console-only"}`
|
|
)
|
|
retryBootstrap(state)
|
|
}
|
|
|
|
function createState(WandEnhancer) {
|
|
return {
|
|
WandEnhancer,
|
|
log: createLogger(WandEnhancer),
|
|
lastSignature: null,
|
|
lastGameStatusSignature: null,
|
|
refreshTimer: null,
|
|
followUpSyncTimer: null,
|
|
pollTimer: null,
|
|
optionalServicesTimer: null,
|
|
bootstrapAttempts: 0,
|
|
optionalServicesAttempts: 0,
|
|
bridgeBound: false,
|
|
bridgeBinding: false,
|
|
refreshPatched: false,
|
|
installedAppsService: null,
|
|
gameLifecycleService: null,
|
|
trainerVisibilityService: null,
|
|
unavailableTitlesService: null,
|
|
storeRef: null,
|
|
ipcRenderer: null,
|
|
lastBootstrapReason: null,
|
|
gameLifecycleSubscriptionsInstalled: false,
|
|
trainerVisibilitySubscriptionInstalled: false,
|
|
trainerEndedSubscriptionInstalled: false,
|
|
unavailableTitlesFetchKey: null,
|
|
unavailableTitlesFetchPromise: null,
|
|
unavailableTitlesById: {},
|
|
trainerApiService: null,
|
|
trainerService: null,
|
|
trainerLaunchRequestCtor: null,
|
|
commandListenerInstalled: false,
|
|
missingOptionalServiceWarnings: new Set(),
|
|
currentGameSession: createIdleGameSession(),
|
|
currentRunningTrainer: createIdleTrainerStatus(),
|
|
resolveRemoteCommandServices: null,
|
|
queueSync: null,
|
|
queueFollowUpSync: null,
|
|
}
|
|
}
|
|
|
|
function setBootstrapReason(state, reason) {
|
|
if (
|
|
reason === state.lastBootstrapReason &&
|
|
state.bootstrapAttempts % BOOTSTRAP_LOG_THROTTLE_ATTEMPTS !== 0
|
|
) {
|
|
return
|
|
}
|
|
|
|
state.lastBootstrapReason = reason
|
|
state.log(
|
|
"info",
|
|
`Bootstrap waiting: ${reason}.`,
|
|
`attempt=${state.bootstrapAttempts + 1}/${MAX_BOOTSTRAP_ATTEMPTS}`
|
|
)
|
|
}
|
|
|
|
async function bindBridge(state) {
|
|
if (state.bridgeBound || state.bridgeBinding || !state.ipcRenderer) {
|
|
return
|
|
}
|
|
|
|
if (!state.commandListenerInstalled) {
|
|
state.ipcRenderer.on(COMMAND_REQUEST_CHANNEL, (event, request) =>
|
|
handleRemoteCommandRequest(state, event, request)
|
|
)
|
|
state.commandListenerInstalled = true
|
|
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 {
|
|
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; will retry on the next sync.", formatError(error))
|
|
} finally {
|
|
state.bridgeBinding = false
|
|
}
|
|
}
|
|
|
|
async function syncInstalledApps(state, force = false) {
|
|
if (!state.ipcRenderer || (!state.installedAppsService && !state.storeRef)) {
|
|
return false
|
|
}
|
|
|
|
const data = resolveInstalledData(state)
|
|
if (!data) {
|
|
if (state.installedAppsService) {
|
|
state.log(
|
|
"warn",
|
|
"Service resolved but installedApps is empty/undefined. Store fallback also unavailable."
|
|
)
|
|
}
|
|
return false
|
|
}
|
|
|
|
if (Object.keys(data.rawInstalledApps).length > 0) {
|
|
await refreshUnavailableTitles(state, data.rawInstalledApps, force)
|
|
}
|
|
|
|
const snapshot = buildSnapshot(state)
|
|
if (!snapshot) {
|
|
state.log("warn", "Snapshot build returned nothing.")
|
|
return false
|
|
}
|
|
|
|
const signature = makeInstalledAppsSignature(snapshot)
|
|
if (!force && signature === state.lastSignature) {
|
|
return false
|
|
}
|
|
|
|
state.lastSignature = signature
|
|
|
|
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 sent
|
|
}
|
|
|
|
function queueSync(state, force = false) {
|
|
if (state.refreshTimer) {
|
|
clearTimeout(state.refreshTimer)
|
|
}
|
|
|
|
state.refreshTimer = setTimeout(() => {
|
|
state.refreshTimer = null
|
|
void syncInstalledApps(state, force)
|
|
}, 0)
|
|
}
|
|
|
|
function queueFollowUpSync(state) {
|
|
if (state.followUpSyncTimer) {
|
|
clearTimeout(state.followUpSyncTimer)
|
|
}
|
|
|
|
state.followUpSyncTimer = setTimeout(() => {
|
|
state.followUpSyncTimer = null
|
|
void syncInstalledApps(state, true)
|
|
}, FOLLOW_UP_SYNC_DELAY_MS)
|
|
}
|
|
|
|
function patchRefreshApps(state) {
|
|
if (
|
|
!state.installedAppsService ||
|
|
state.refreshPatched ||
|
|
typeof state.installedAppsService.refreshApps !== "function"
|
|
) {
|
|
return
|
|
}
|
|
|
|
const originalRefreshApps = state.installedAppsService.refreshApps.bind(
|
|
state.installedAppsService
|
|
)
|
|
state.refreshPatched = true
|
|
state.log("info", "refreshApps hook installed.")
|
|
|
|
state.installedAppsService.refreshApps = async (...args) => {
|
|
const result = await originalRefreshApps(...args)
|
|
state.log("info", "refreshApps completed; queueing installed apps sync.")
|
|
queueSync(state, true)
|
|
queueFollowUpSync(state)
|
|
return result
|
|
}
|
|
}
|
|
|
|
function resolveRemoteCommandServices(state) {
|
|
const container = getAureliaContainer()
|
|
const webpackRequire = getWebpackRequire()
|
|
if (!container || !webpackRequire) {
|
|
return false
|
|
}
|
|
|
|
resolveRuntimeServices(state, container, webpackRequire)
|
|
return true
|
|
}
|
|
|
|
function resolveRuntimeServices(state, container, webpackRequire) {
|
|
resolveOptionalServices(state, container, webpackRequire)
|
|
installGameStatusSubscriptions(state)
|
|
|
|
if (!hasMissingOptionalServices(state)) {
|
|
stopOptionalServicesRetry(state)
|
|
}
|
|
}
|
|
|
|
function stopOptionalServicesRetry(state) {
|
|
if (!state.optionalServicesTimer) {
|
|
return
|
|
}
|
|
|
|
clearInterval(state.optionalServicesTimer)
|
|
state.optionalServicesTimer = null
|
|
}
|
|
|
|
function startOptionalServicesRetry(state) {
|
|
if (state.optionalServicesTimer || !hasMissingOptionalServices(state)) {
|
|
return
|
|
}
|
|
|
|
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(
|
|
"info",
|
|
"Optional service retry timer started.",
|
|
`${OPTIONAL_SERVICES_RETRY_INTERVAL_MS}ms`
|
|
)
|
|
}
|
|
|
|
function bootstrap(state) {
|
|
if (!hasAppRoot()) {
|
|
setBootstrapReason(state, "app root not ready")
|
|
return false
|
|
}
|
|
|
|
if (!state.ipcRenderer && !resolveIpcRenderer(state)) {
|
|
return false
|
|
}
|
|
|
|
const webpackRequire = getWebpackRequire()
|
|
if (!webpackRequire) {
|
|
setBootstrapReason(state, "webpack runtime not ready")
|
|
return false
|
|
}
|
|
|
|
const container = getAureliaContainer()
|
|
if (!container) {
|
|
logMissingContainer(state)
|
|
setBootstrapReason(state, "Aurelia container not ready")
|
|
return false
|
|
}
|
|
|
|
state.log("info", "Aurelia container resolved.")
|
|
|
|
if (!state.storeRef) {
|
|
state.storeRef = getStoreRef(state, container, webpackRequire)
|
|
if (!state.storeRef) {
|
|
state.log(
|
|
"warn",
|
|
"Store reference unavailable; unsupported installed titles will be missing."
|
|
)
|
|
}
|
|
}
|
|
|
|
state.installedAppsService = getInstalledAppsService(
|
|
state,
|
|
container,
|
|
webpackRequire
|
|
)
|
|
if (!state.installedAppsService) {
|
|
setBootstrapReason(state, "installed apps service not ready")
|
|
return false
|
|
}
|
|
|
|
resolveRuntimeServices(state, container, webpackRequire)
|
|
startOptionalServicesRetry(state)
|
|
|
|
if (!isRecord(state.installedAppsService.installedApps)) {
|
|
state.log(
|
|
"info",
|
|
"Service instance has no installedApps data yet; reading from store until refreshApps populates it."
|
|
)
|
|
if (!state.storeRef) {
|
|
state.log("warn", "Store fallback also unavailable; will retry on poll.")
|
|
}
|
|
}
|
|
|
|
void bindBridge(state)
|
|
patchRefreshApps(state)
|
|
queueSync(state, true)
|
|
queueFollowUpSync(state)
|
|
startPollTimer(state)
|
|
|
|
state.log("info", "Installed apps sync ready.")
|
|
return true
|
|
}
|
|
|
|
function resolveIpcRenderer(state) {
|
|
const electron = getRequire()?.("electron")
|
|
if (!electron?.ipcRenderer) {
|
|
setBootstrapReason(state, "electron ipcRenderer not ready")
|
|
return false
|
|
}
|
|
|
|
state.ipcRenderer = electron.ipcRenderer
|
|
state.log("info", "Electron ipcRenderer resolved.")
|
|
return true
|
|
}
|
|
|
|
function startPollTimer(state) {
|
|
if (state.pollTimer) {
|
|
return
|
|
}
|
|
|
|
state.pollTimer = setInterval(() => {
|
|
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)
|
|
|
|
state.log(
|
|
"info",
|
|
"Installed apps poll timer started.",
|
|
`${SYNC_INTERVAL_MS}ms`
|
|
)
|
|
}
|
|
|
|
function logMissingContainer(state) {
|
|
if (state.bootstrapAttempts % CONTAINER_LOG_THROTTLE_ATTEMPTS !== 0) {
|
|
return
|
|
}
|
|
|
|
const root = getAppRoot()
|
|
const aureliaKeys = root
|
|
? Object.getOwnPropertyNames(root)
|
|
.filter((key) => key.startsWith("__") || key === "au")
|
|
.join(", ")
|
|
: "root=null"
|
|
state.log(
|
|
"warn",
|
|
"Aurelia container not found.",
|
|
`rootProps=${aureliaKeys || "(none)"}, subtree=${summarizeAureliaSubtree(root)}`
|
|
)
|
|
}
|
|
|
|
function retryBootstrap(state) {
|
|
if (bootstrap(state)) {
|
|
return
|
|
}
|
|
|
|
state.bootstrapAttempts += 1
|
|
if (state.bootstrapAttempts < MAX_BOOTSTRAP_ATTEMPTS) {
|
|
setTimeout(() => retryBootstrap(state), RETRY_DELAY_MS)
|
|
return
|
|
}
|
|
|
|
state.log(
|
|
"error",
|
|
"Installed apps sync bootstrap exhausted.",
|
|
state.lastBootstrapReason || "unknown reason"
|
|
)
|
|
}
|