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:
kitbyte
2026-05-06 13:07:09 +03:00
parent 3b2f373946
commit 13759b1db6
125 changed files with 8933 additions and 2838 deletions
@@ -0,0 +1,3 @@
import { installInstalledAppsSync } from "./installed-apps-sync/index.js"
installInstalledAppsSync(globalThis.WandEnhancer)
@@ -0,0 +1,255 @@
import {
DATA_IMAGE_URL_PREFIX,
IMAGE_FIELD_NAMES,
MAX_IMAGE_URL_SEARCH_DEPTH,
PROTOCOL_RELATIVE_IMAGE_URL_PATTERN,
REMOTE_IMAGE_URL_PATTERN,
SIDEBAR_GAME_ROW_CONTAINER_SELECTOR,
SIDEBAR_GAME_ROW_IMAGE_SELECTOR,
SIDEBAR_GAME_ROW_MORE_SELECTOR,
SIDEBAR_GAME_ROW_TITLE_SELECTOR,
SIDEBAR_GAME_ROW_TOOLTIP_ID_PATTERN,
STEAM_APP_ID_FIELD_NAMES,
STEAM_APP_ID_PATTERN,
STEAM_CONTAINER_FIELD_PATTERN,
STEAM_CONTAINER_ID_FIELD_NAMES,
STEAM_PLATFORM,
WEMOD_STEAM_COMMUNITY_CDN_BASE_URL,
} from "./constants.js"
import { isRecord, safeString, toStringId } from "./runtime.js"
export function pickImageUrl(...values) {
for (const value of values) {
const imageUrl = normalizeImageUrl(value)
if (imageUrl) {
return imageUrl
}
}
return null
}
export function normalizeImageUrl(value, depth = 0) {
if (typeof value === "string") {
const trimmed = value.trim()
if (
REMOTE_IMAGE_URL_PATTERN.test(trimmed) ||
trimmed.startsWith(DATA_IMAGE_URL_PREFIX)
) {
return trimmed
}
if (PROTOCOL_RELATIVE_IMAGE_URL_PATTERN.test(trimmed)) {
return `https:${trimmed}`
}
return null
}
if (depth > MAX_IMAGE_URL_SEARCH_DEPTH || !value) {
return null
}
if (Array.isArray(value)) {
for (const entry of value) {
const imageUrl = normalizeImageUrl(entry, depth + 1)
if (imageUrl) {
return imageUrl
}
}
return null
}
if (!isRecord(value)) {
return null
}
for (const key of IMAGE_FIELD_NAMES) {
const imageUrl = normalizeImageUrl(value[key], depth + 1)
if (imageUrl) {
return imageUrl
}
}
return null
}
export function getSteamClientIconUrl(...values) {
for (const value of values) {
const steamAppId = toStringId(value)
if (steamAppId && STEAM_APP_ID_PATTERN.test(steamAppId)) {
return `${WEMOD_STEAM_COMMUNITY_CDN_BASE_URL}/${steamAppId}/client_icon/96.webp`
}
}
return null
}
export function getSidebarGameRowClientIcons() {
const byTitleId = new Map()
const byTitleName = new Map()
if (typeof document === "undefined") {
return { byTitleId, byTitleName }
}
const containers = document.querySelectorAll(
SIDEBAR_GAME_ROW_CONTAINER_SELECTOR
)
for (const container of containers) {
const imageElement = container.querySelector(
SIDEBAR_GAME_ROW_IMAGE_SELECTOR
)
const titleElement = container.querySelector(
SIDEBAR_GAME_ROW_TITLE_SELECTOR
)
const moreButton = container.querySelector(SIDEBAR_GAME_ROW_MORE_SELECTOR)
if (!imageElement || !titleElement) {
continue
}
const backgroundImageUrl = getCssBackgroundImageUrl(
safeString(imageElement.style?.backgroundImage) ||
getComputedStyle(imageElement).backgroundImage
)
if (!backgroundImageUrl) {
continue
}
const tooltipId = safeString(
moreButton?.getAttribute?.("data-tooltip-trigger-for")
)
const titleId =
tooltipId.match(SIDEBAR_GAME_ROW_TOOLTIP_ID_PATTERN)?.[1] ?? null
const titleName = normalizeTitleMatchKey(titleElement.textContent)
if (titleId) {
byTitleId.set(titleId, backgroundImageUrl)
}
if (titleName) {
byTitleName.set(titleName, backgroundImageUrl)
}
}
return { byTitleId, byTitleName }
}
export function getSidebarGameRowClientIconUrl(
sidebarIcons,
titleId,
...names
) {
const normalizedTitleId = toStringId(titleId)
if (normalizedTitleId && sidebarIcons.byTitleId.has(normalizedTitleId)) {
return sidebarIcons.byTitleId.get(normalizedTitleId) ?? null
}
for (const name of names) {
const normalizedName = normalizeTitleMatchKey(name)
if (normalizedName && sidebarIcons.byTitleName.has(normalizedName)) {
return sidebarIcons.byTitleName.get(normalizedName) ?? null
}
}
return null
}
export function findSteamAppId(...roots) {
const seen = new Set()
const queue = roots.map((value) => ({ value, depth: 0, steamContext: false }))
while (queue.length > 0) {
const current = queue.shift()
const value = current?.value
const depth = current?.depth ?? 0
const steamContext = current?.steamContext ?? false
if (!value || depth > MAX_IMAGE_URL_SEARCH_DEPTH || seen.has(value)) {
continue
}
if (typeof value === "string" || typeof value === "number") {
const steamAppId = steamContext ? toStringId(value) : null
if (steamAppId && STEAM_APP_ID_PATTERN.test(steamAppId)) {
return steamAppId
}
continue
}
seen.add(value)
if (Array.isArray(value)) {
for (const entry of value) {
queue.push({ value: entry, depth: depth + 1, steamContext })
}
continue
}
if (!isRecord(value)) {
continue
}
for (const [key, entry] of Object.entries(value)) {
const steamAppId = getSteamAppIdFromEntry(key, entry, steamContext)
if (steamAppId) {
return steamAppId
}
if (isRecord(entry) || Array.isArray(entry)) {
queue.push({
value: entry,
depth: depth + 1,
steamContext: steamContext || STEAM_CONTAINER_FIELD_PATTERN.test(key),
})
}
}
}
return null
}
export function getInstalledAppSteamAppId(platform, sku) {
if (safeString(platform).toLowerCase() !== STEAM_PLATFORM) {
return null
}
return sku
}
function normalizeTitleMatchKey(value) {
const normalized = safeString(value).trim().toLowerCase().replace(/\s+/g, " ")
return normalized || null
}
function getCssBackgroundImageUrl(value) {
const backgroundImage = safeString(value)
if (!backgroundImage || backgroundImage === "none") {
return null
}
const match = backgroundImage.match(/url\((['"]?)(.*?)\1\)/i)
if (!match?.[2]) {
return null
}
return normalizeImageUrl(match[2])
}
function getSteamAppIdFromEntry(key, entry, steamContext) {
if (STEAM_APP_ID_FIELD_NAMES.has(key)) {
const steamAppId = toStringId(entry)
if (steamAppId && STEAM_APP_ID_PATTERN.test(steamAppId)) {
return steamAppId
}
}
if (steamContext && STEAM_CONTAINER_ID_FIELD_NAMES.has(key)) {
const steamAppId = toStringId(entry)
if (steamAppId && STEAM_APP_ID_PATTERN.test(steamAppId)) {
return steamAppId
}
}
return null
}
@@ -0,0 +1,95 @@
export const GLOBAL_FLAG = "__wandInstalledAppsSyncInstalled"
export const BIND_CHANNEL = "wand-remote-set-handler-bind"
export const SYNC_CHANNEL = "wand-remote-installed-apps"
export const TRAINER_SNAPSHOT_CHANNEL = "wand-remote-sync"
export const GAME_STATUS_CHANNEL = "wand-remote-game-status"
export const COMMAND_REQUEST_CHANNEL = "wand-remote-command"
export const COMMAND_RESPONSE_CHANNEL = "wand-remote-command-response"
export const REMOTE_COMMAND_LAUNCH = "launch"
export const REMOTE_COMMAND_STOP = "stop"
export const REMOTE_COMMAND_TRIGGER = "remote"
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 FOLLOW_UP_SYNC_DELAY_MS = 2500
export const UNAVAILABLE_TITLES_BATCH_SIZE = 250
export const BOOTSTRAP_LOG_THROTTLE_ATTEMPTS = 5
// 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"
export const SNAPSHOT_ENTRY_KEY_PREFIX = Object.freeze({
TITLE: "title:",
GAME: "game:",
APP: "app:",
})
export const GAME_LAUNCHED_EVENT = "game-launched"
export const GAME_ENDED_EVENT = "game-ended"
export const SYNTHETIC_SESSION_RUNNING_EVENT = "trainer-running"
export const SYNTHETIC_SESSION_IDLE_EVENT = "trainer-idle"
export const TRAINER_ENDED_EVENT = "trainer-ended"
export const REMOTE_STOP_EVENT = "remote-stop"
export const STEAM_PLATFORM = "steam"
export const STEAM_APP_ID_PATTERN = /^\d+$/
export const WEMOD_STEAM_COMMUNITY_CDN_BASE_URL =
"https://api-cdn.wemod.com/steam_community"
export const REMOTE_IMAGE_URL_PATTERN = /^https?:\/\//i
export const PROTOCOL_RELATIVE_IMAGE_URL_PATTERN = /^\/\//
export const DATA_IMAGE_URL_PREFIX = "data:image/"
export const MAX_IMAGE_URL_SEARCH_DEPTH = 4
export const SIDEBAR_GAME_ROW_CONTAINER_SELECTOR = ".sidebar-game-row-container"
export const SIDEBAR_GAME_ROW_IMAGE_SELECTOR = ".sidebar-game-row-image"
export const SIDEBAR_GAME_ROW_TITLE_SELECTOR = ".sidebar-game-row-title"
export const SIDEBAR_GAME_ROW_MORE_SELECTOR = ".sidebar-game-row-more"
export const SIDEBAR_GAME_ROW_TOOLTIP_ID_PATTERN =
/sidebar-game-row-(.+?)-more-button-tooltip/
export const STEAM_APP_ID_FIELD_NAMES = new Set(["steamAppId", "steamAppID"])
export const STEAM_CONTAINER_FIELD_PATTERN = /steam/i
export const STEAM_CONTAINER_ID_FIELD_NAMES = new Set(["appId", "appID", "id"])
export const LOG_PREFIX = "[wand-installed-apps-sync]"
export const LOG_FILE_NAME = "wand-remote-installed-apps-sync.log"
export const EXCLUDED_UNAVAILABLE_TITLE_PLATFORMS = new Set(["standalone"])
export const IMAGE_FIELD_NAMES = [
"imageUrl",
"imageURL",
"iconUrl",
"iconURL",
"coverUrl",
"coverURL",
"thumbnailUrl",
"thumbnailURL",
"logoUrl",
"logoURL",
"headerImageUrl",
"headerImageURL",
"boxArtUrl",
"boxartUrl",
"posterUrl",
"tileUrl",
"capsuleUrl",
"heroUrl",
"backgroundUrl",
"image",
"icon",
"cover",
"thumbnail",
"logo",
"headerImage",
"header",
"boxArt",
"boxart",
"poster",
"tile",
"capsule",
"hero",
"background",
"large",
"medium",
"small",
"original",
"source",
"href",
"uri",
"url",
"src",
]
@@ -0,0 +1,338 @@
import {
GAME_ENDED_EVENT,
GAME_LAUNCHED_EVENT,
GAME_STATUS_CHANNEL,
SYNTHETIC_SESSION_IDLE_EVENT,
SYNTHETIC_SESSION_RUNNING_EVENT,
TRAINER_ENDED_EVENT,
TRAINER_SNAPSHOT_CHANNEL,
} from "./constants.js"
import { isRecord, safeString, toStringId } from "./runtime.js"
export function createIdleGameSession() {
return {
state: "idle",
event: "snapshot",
processId: null,
gameId: null,
titleId: null,
titleName: null,
sessionDurationSeconds: null,
startedAt: null,
endedAt: null,
}
}
export function createIdleTrainerStatus() {
return {
state: "idle",
event: "snapshot",
trainerId: null,
displayName: null,
gameId: null,
titleId: null,
}
}
export function installGameStatusSubscriptions(state) {
let installed = false
if (
state.gameLifecycleService &&
!state.gameLifecycleSubscriptionsInstalled
) {
installed = installLifecycleSubscriptions(state) || installed
}
if (
state.trainerVisibilityService &&
!state.trainerVisibilitySubscriptionInstalled
) {
state.currentRunningTrainer = normalizeRunningTrainerStatus(
state.trainerVisibilityService.runningTrainer,
"snapshot"
)
syncGameSessionFromTrainerStatus(state, state.currentRunningTrainer)
installed = installTrainerVisibilitySubscription(state) || installed
state.trainerVisibilitySubscriptionInstalled = true
}
if (
state.trainerService &&
!state.trainerEndedSubscriptionInstalled &&
typeof state.trainerService.onTrainerEnded === "function"
) {
state.trainerService.onTrainerEnded(() => {
clearTrainerSnapshot(state, TRAINER_ENDED_EVENT, true)
})
state.trainerEndedSubscriptionInstalled = true
installed = true
}
if (!installed) {
return
}
state.log(
"info",
"Game status hooks installed.",
`lifecycle=${state.gameLifecycleSubscriptionsInstalled ? "yes" : "no"}, trainer=${state.trainerVisibilitySubscriptionInstalled ? "yes" : "no"}, trainerEnded=${state.trainerEndedSubscriptionInstalled ? "yes" : "no"}`
)
void syncGameStatus(state, true)
}
export function clearTrainerSnapshot(state, reason, clearSession = false) {
state.currentRunningTrainer = {
...createIdleTrainerStatus(),
event: reason,
}
if (clearSession) {
clearGameSession(state, reason)
} else if (
state.currentGameSession.state === "running" &&
isSyntheticGameSessionEvent(state.currentGameSession.event)
) {
syncGameSessionFromTrainerStatus(state, state.currentRunningTrainer)
}
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)
)
}
}
export async function syncGameStatus(state, force = false) {
if (!state.ipcRenderer) {
return false
}
const snapshot = buildGameStatusSnapshot(state)
const signature = makeGameStatusSignature(snapshot)
if (!force && signature === state.lastGameStatusSignature) {
return false
}
state.lastGameStatusSignature = signature
try {
await state.ipcRenderer.invoke(GAME_STATUS_CHANNEL, snapshot)
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
}
}
function installLifecycleSubscriptions(state) {
let installed = false
if (typeof state.gameLifecycleService.onGameLaunched === "function") {
state.gameLifecycleService.onGameLaunched((event) => {
state.currentGameSession = {
state: "running",
event: GAME_LAUNCHED_EVENT,
processId:
typeof event?.processId === "number" ? event.processId : null,
gameId: toStringId(event?.gameId),
titleId: toStringId(event?.titleId),
titleName: safeString(event?.titleName),
sessionDurationSeconds: null,
startedAt: new Date().toISOString(),
endedAt: null,
}
void syncGameStatus(state, true)
})
installed = true
}
if (typeof state.gameLifecycleService.onGameEnded === "function") {
state.gameLifecycleService.onGameEnded((event) => {
clearGameSession(
state,
GAME_ENDED_EVENT,
typeof event?.sessionDurationSeconds === "number"
? event.sessionDurationSeconds
: state.currentGameSession.sessionDurationSeconds
)
void syncGameStatus(state, true)
})
installed = true
}
if (installed) {
state.gameLifecycleSubscriptionsInstalled = true
}
return installed
}
function installTrainerVisibilitySubscription(state) {
if (
typeof state.trainerVisibilityService.onRunningTrainerChanged !== "function"
) {
return false
}
state.trainerVisibilityService.onRunningTrainerChanged((runningTrainer) => {
state.currentRunningTrainer = normalizeRunningTrainerStatus(
runningTrainer,
runningTrainer ? "trainer-running" : "trainer-idle"
)
syncGameSessionFromTrainerStatus(state, state.currentRunningTrainer)
void syncGameStatus(state, true)
})
return true
}
function normalizeRunningTrainerStatus(runningTrainer, event = "snapshot") {
const info = isRecord(runningTrainer?.info)
? runningTrainer.info
: isRecord(runningTrainer)
? runningTrainer
: null
if (!info) {
return {
...createIdleTrainerStatus(),
event,
}
}
return {
state: "running",
event,
trainerId: toStringId(info.trainerId) || toStringId(info.id),
displayName: safeString(
info.displayName,
info.gameName,
info.titleName,
info.title,
info.name
),
gameId: toStringId(info.gameId),
titleId: toStringId(info.titleId),
}
}
function syncGameSessionFromTrainerStatus(state, trainerStatus) {
if (trainerStatus?.state === "running") {
if (
state.currentGameSession.state === "running" &&
!isSyntheticGameSessionEvent(state.currentGameSession.event)
) {
return false
}
state.currentGameSession = {
state: "running",
event: SYNTHETIC_SESSION_RUNNING_EVENT,
processId: state.currentGameSession.processId,
gameId: trainerStatus.gameId ?? state.currentGameSession.gameId,
titleId: trainerStatus.titleId ?? state.currentGameSession.titleId,
titleName:
trainerStatus.displayName ?? state.currentGameSession.titleName,
sessionDurationSeconds: null,
startedAt:
state.currentGameSession.state === "running" &&
isSyntheticGameSessionEvent(state.currentGameSession.event)
? state.currentGameSession.startedAt
: new Date().toISOString(),
endedAt: null,
}
return true
}
if (
state.currentGameSession.state !== "running" ||
!isSyntheticGameSessionEvent(state.currentGameSession.event)
) {
return false
}
const startedAt = state.currentGameSession.startedAt
const sessionDurationSeconds = startedAt
? Math.max(
0,
Math.round((Date.now() - new Date(startedAt).getTime()) / 1000)
)
: null
clearGameSession(state, SYNTHETIC_SESSION_IDLE_EVENT, sessionDurationSeconds)
return true
}
function clearGameSession(
state,
event,
sessionDurationSeconds = state.currentGameSession.sessionDurationSeconds
) {
state.currentGameSession = {
state: "idle",
event,
processId: null,
gameId: null,
titleId: null,
titleName: null,
sessionDurationSeconds,
startedAt: state.currentGameSession.startedAt,
endedAt: new Date().toISOString(),
}
}
function isSyntheticGameSessionEvent(event) {
return (
event === SYNTHETIC_SESSION_RUNNING_EVENT ||
event === SYNTHETIC_SESSION_IDLE_EVENT
)
}
function buildGameStatusSnapshot(state) {
return {
instanceId: "wand-game-status",
updatedAt: new Date().toISOString(),
session: { ...state.currentGameSession },
trainer: { ...state.currentRunningTrainer },
}
}
function makeGameStatusSignature(snapshot) {
return [
snapshot.session.state,
snapshot.session.event,
snapshot.session.processId ?? "",
snapshot.session.gameId ?? "",
snapshot.session.titleId ?? "",
snapshot.session.titleName ?? "",
snapshot.session.sessionDurationSeconds ?? "",
snapshot.session.startedAt ?? "",
snapshot.session.endedAt ?? "",
snapshot.trainer.state,
snapshot.trainer.event,
snapshot.trainer.trainerId ?? "",
snapshot.trainer.displayName ?? "",
snapshot.trainer.gameId ?? "",
snapshot.trainer.titleId ?? "",
].join("|")
}
@@ -0,0 +1,421 @@
import {
BIND_CHANNEL,
BOOTSTRAP_LOG_THROTTLE_ATTEMPTS,
COMMAND_REQUEST_CHANNEL,
FOLLOW_UP_SYNC_DELAY_MS,
GLOBAL_FLAG,
MAX_BOOTSTRAP_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 {
getAppRoot,
getAureliaContainer,
getRequire,
getWebpackRequire,
hasAppRoot,
isRecord,
summarizeAureliaSubtree,
} from "./runtime.js"
import {
getInstalledAppsService,
getStoreRef,
hasMissingOptionalServices,
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,
bridgeBound: 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}`
)
}
function bindBridge(state) {
if (state.bridgeBound || !state.ipcRenderer) {
return
}
state.bridgeBound = true
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.")
}
try {
void state.ipcRenderer.invoke(BIND_CHANNEL)
state.log("info", "Bridge set-value handler bind requested.")
} catch (error) {
state.log("warn", "Bridge bind failed.", error?.stack || String(error))
}
}
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
try {
await state.ipcRenderer.invoke(SYNC_CHANNEL, snapshot)
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
}
}
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(() => {
const container = getAureliaContainer()
const webpackRequire = getWebpackRequire()
if (container && webpackRequire) {
resolveRuntimeServices(state, container, webpackRequire)
}
}, 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.")
}
}
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(() => {
const container = getAureliaContainer()
const webpackRequire = getWebpackRequire()
if (container && webpackRequire) {
resolveRuntimeServices(state, container, webpackRequire)
}
void syncInstalledApps(state)
}, SYNC_INTERVAL_MS)
state.log(
"info",
"Installed apps poll timer started.",
`${SYNC_INTERVAL_MS}ms`
)
}
function logMissingContainer(state) {
if (state.bootstrapAttempts % 10 !== 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"
)
}
@@ -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
)
}
@@ -0,0 +1,46 @@
import { LOG_FILE_NAME, LOG_PREFIX } from "./constants.js"
import { getRequire } from "./runtime.js"
export function createLogger(WandEnhancer) {
let filePath = null
try {
const require = getRequire()
const os = require?.("node:os")
const path = require?.("node:path")
if (os && path) {
filePath = path.join(os.tmpdir(), LOG_FILE_NAME)
globalThis.__wandInstalledAppsSyncLogFile = filePath
}
} catch (error) {}
return function log(level, message, detail) {
const method =
level === "error" ? "error" : level === "warn" ? "warn" : "info"
const line = `[${new Date().toISOString()}] [${level}] ${message}${detail ? ` :: ${detail}` : ""}`
try {
console[method](LOG_PREFIX, message, detail || "")
} catch (error) {}
try {
if (WandEnhancer?.log) {
WandEnhancer.log(`${LOG_PREFIX} ${message}`, detail || "")
}
} catch (error) {}
writeFile(filePath, line)
}
}
function writeFile(filePath, line) {
if (!filePath) {
return
}
try {
const require = getRequire()
const fs = require?.("node:fs")
fs?.appendFileSync(filePath, `${line}\n`)
} catch (error) {}
}
@@ -0,0 +1,309 @@
import {
COMMAND_RESPONSE_CHANNEL,
REMOTE_COMMAND_LAUNCH,
REMOTE_COMMAND_STOP,
REMOTE_COMMAND_TRIGGER,
REMOTE_STOP_EVENT,
} from "./constants.js"
import { clearTrainerSnapshot, syncGameStatus } from "./game-status.js"
import {
compareInstalledAppRecords,
getInstalledVersionsForGame,
resolveInstalledData,
toInstalledAppRecord,
} from "./installed-data.js"
import {
getPreferredLocale,
isRecord,
safeString,
toStringId,
} from "./runtime.js"
export function handleRemoteCommandRequest(state, _event, request) {
void (async () => {
let response
if (request?.action === REMOTE_COMMAND_LAUNCH) {
response = await executeRemoteLaunchCommand(state, request)
} else if (request?.action === REMOTE_COMMAND_STOP) {
response = await executeRemoteStopCommand(state, request)
} else {
response = buildCommandResponse(request, false, {
code: "invalid_command",
message: "Unknown remote command.",
})
}
await sendRemoteCommandResponse(state, response)
})()
}
function buildCommandResponse(request, ok, error = null) {
const response = {
requestId: safeString(request?.requestId),
ok,
action:
request?.action === REMOTE_COMMAND_STOP
? REMOTE_COMMAND_STOP
: REMOTE_COMMAND_LAUNCH,
gameId: toStringId(request?.gameId),
titleId: toStringId(request?.titleId),
}
if (!error) {
return response
}
return {
...response,
error,
}
}
async function executeRemoteLaunchCommand(state, request) {
const gameId = toStringId(request?.gameId)
if (!gameId) {
return buildCommandResponse(request, false, {
code: "invalid_game",
message: "A game id is required to launch a trainer.",
})
}
if (!state.resolveRemoteCommandServices()) {
return buildCommandResponse(request, false, {
code: "bridge_not_ready",
message: "The Wand renderer container is not ready yet.",
})
}
if (!state.trainerService) {
return buildCommandResponse(request, false, {
code: "trainer_service_missing",
message: "The Wand trainer service is not available yet.",
})
}
if (!state.trainerLaunchRequestCtor) {
return buildCommandResponse(request, false, {
code: "trainer_launch_missing",
message:
"The Wand trainer launch request constructor is not available yet.",
})
}
const data = resolveInstalledData(state)
if (!data) {
return buildCommandResponse(request, false, {
code: "installations_missing",
message: "Installed game data is not available yet.",
})
}
const launchInfo = getLaunchInfoForGame(gameId, data)
if (!isRecord(launchInfo.app)) {
return buildCommandResponse(request, false, {
code: "game_not_installed",
message: "Wand could not resolve a preferred installation for this game.",
})
}
const trainerInfo = await resolveTrainerInfoForGame(state, gameId, data)
if (!trainerInfo) {
return buildCommandResponse(request, false, {
code: "trainer_not_found",
message: "Wand could not find a compatible trainer for this game.",
})
}
try {
const launchRequest = new state.trainerLaunchRequestCtor(
trainerInfo,
launchInfo.app,
launchInfo.version,
REMOTE_COMMAND_TRIGGER
)
await state.trainerService.launch(launchRequest)
state.queueSync(true)
state.queueFollowUpSync()
void syncGameStatus(state, true)
return buildCommandResponse(request, true)
} catch (error) {
return buildCommandResponse(request, false, {
code: "launch_failed",
message:
error instanceof Error
? error.message
: "Failed to launch the trainer.",
})
}
}
async function executeRemoteStopCommand(state, request) {
if (!state.resolveRemoteCommandServices()) {
return buildCommandResponse(request, false, {
code: "bridge_not_ready",
message: "The Wand renderer container is not ready yet.",
})
}
if (
!state.trainerService ||
typeof state.trainerService.endTrainer !== "function"
) {
return buildCommandResponse(request, false, {
code: "trainer_service_missing",
message: "The Wand trainer service is not available yet.",
})
}
if (
!state.trainerService.trainer &&
state.currentRunningTrainer.state !== "running"
) {
return buildCommandResponse(request, false, {
code: "no_active_trainer",
message: "No trainer is running right now.",
})
}
try {
await state.trainerService.endTrainer()
clearTrainerSnapshot(state, REMOTE_STOP_EVENT, true)
return buildCommandResponse(request, true)
} catch (error) {
return buildCommandResponse(request, false, {
code: "stop_failed",
message:
error instanceof Error
? error.message
: "Failed to stop the running trainer.",
})
}
}
function getLaunchInfoForGame(gameId, data) {
const versions = Array.isArray(data?.installedGameVersions?.[gameId])
? data.installedGameVersions[gameId]
: []
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 })
}
}
}
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,
}
}
async function resolveTrainerInfoForGame(state, gameId, data) {
if (!state.trainerApiService) {
return null
}
try {
const localTrainer = unwrapTrainerInfo(
await state.trainerApiService.getLatestLocalTrainerForGame(gameId)
)
if (localTrainer) {
return localTrainer
}
} catch (error) {
state.log(
"warn",
"Local trainer lookup failed.",
error?.stack || String(error)
)
}
try {
return unwrapTrainerInfo(
await state.trainerApiService.getMostCompatibleTrainerForGame(
gameId,
getPreferredLocale(),
getInstalledVersionsForGame(gameId, data),
false
)
)
} catch (error) {
state.log(
"warn",
"Compatible trainer lookup failed.",
error?.stack || String(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
}
return isRecord(value) ? value : null
}
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)
)
}
}
@@ -0,0 +1,324 @@
export function isRecord(value) {
return typeof value === "object" && value !== null
}
export function getRequire() {
return (
globalThis.require ||
(typeof window !== "undefined" ? window.require : null)
)
}
export function safeString(...values) {
for (const value of values) {
if (typeof value === "string" && value.trim()) {
return value.trim()
}
}
return ""
}
export function getWebpackRequire() {
const chunk = globalThis.webpackChunkWeMod
if (!Array.isArray(chunk)) {
return null
}
if (typeof chunk.__wandWebpackRequire === "function") {
return chunk.__wandWebpackRequire
}
let resolvedRequire = null
chunk.push([
[`wand-enhancer-${Date.now()}`],
{},
(webpackRequire) => {
resolvedRequire = webpackRequire
},
])
if (typeof resolvedRequire === "function") {
chunk.__wandWebpackRequire = resolvedRequire
}
return resolvedRequire
}
export function getAppRoot() {
return (
document.getElementById("root") ||
document.querySelector("[aurelia-app]") ||
document.querySelector("root")
)
}
export function hasAppRoot() {
return Boolean(getAppRoot())
}
export function getAureliaContainer() {
const root = getAppRoot()
const rootContainer = getContainerFromSubtree(root)
if (rootContainer) {
return rootContainer
}
const bodyContainer = getContainerFromElement(document.body)
if (bodyContainer) {
return bodyContainer
}
if (isRecord(globalThis.aurelia) && globalThis.aurelia.container) {
return globalThis.aurelia.container
}
return null
}
export function summarizeAureliaSubtree(root) {
if (!root) {
return "root=null"
}
let elementsWithAu = 0
let controllerEntries = 0
let namedAuEntries = 0
function inspectElement(element) {
if (!isRecord(element?.au)) {
return
}
elementsWithAu += 1
if (element.au.controller) {
controllerEntries += 1
}
for (const [key, value] of Object.entries(element.au)) {
if (key !== "controller" && isRecord(value)) {
namedAuEntries += 1
}
}
}
inspectElement(root)
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)
let element = walker.nextNode()
while (element) {
inspectElement(element)
element = walker.nextNode()
}
return `elementsWithAu=${elementsWithAu}, controllerEntries=${controllerEntries}, namedAuEntries=${namedAuEntries}`
}
export function findExportedConstructor(webpackRequire, predicate) {
const cache = webpackRequire?.c
if (!cache || typeof cache !== "object") {
return null
}
for (const record of Object.values(cache)) {
const exports = record?.exports
const candidates = []
if (typeof exports === "function") {
candidates.push(exports)
} else if (isRecord(exports)) {
if (typeof exports.default === "function") {
candidates.push(exports.default)
}
for (const value of Object.values(exports)) {
if (typeof value === "function") {
candidates.push(value)
}
}
}
for (const candidate of candidates) {
if (candidate?.prototype && predicate(candidate.prototype)) {
return candidate
}
}
}
return null
}
export function findInstanceInContainerGraph(root, predicate, maxDepth = 4) {
if (!root) {
return null
}
const seen = new Set()
const queue = [{ value: root, depth: 0 }]
while (queue.length > 0) {
const current = queue.shift()
const value = current?.value
const depth = current?.depth ?? 0
if (!value || seen.has(value)) {
continue
}
seen.add(value)
try {
if (predicate(value)) {
return value
}
} catch (error) {}
if (depth >= maxDepth) {
continue
}
enqueueNestedValues(queue, seen, value, depth + 1)
}
return null
}
export function getBasename(location) {
if (typeof location !== "string" || !location.trim()) {
return ""
}
const normalized = location.replace(/\\/g, "/").replace(/\/+$/, "")
const leaf = normalized.split("/").filter(Boolean).pop()
return leaf ? leaf.trim() : ""
}
export function toStringId(value) {
if (typeof value === "string" && value.trim()) {
return value.trim()
}
if (typeof value === "number" && Number.isFinite(value)) {
return String(value)
}
return null
}
export function normalizeStringList(value) {
if (!Array.isArray(value)) {
return []
}
return value
.filter((entry) => typeof entry === "string" && entry.trim())
.map((entry) => entry.trim())
}
export function getPreferredLocale() {
return safeString(
document.documentElement?.lang,
Array.isArray(globalThis.navigator?.languages)
? globalThis.navigator.languages.find(
(entry) => typeof entry === "string" && entry.trim()
)
: "",
globalThis.navigator?.language,
"en-US"
)
}
function getContainerFromAu(au) {
if (!isRecord(au)) {
return null
}
if (au.container) {
return au.container
}
const directControllerContainer =
au.controller?.container || au.controller?.viewModel?.container
if (directControllerContainer) {
return directControllerContainer
}
for (const value of Object.values(au)) {
if (!isRecord(value)) {
continue
}
const container =
value.container ||
value.controller?.container ||
value.viewModel?.container ||
value.controller?.viewModel?.container
if (container) {
return container
}
}
return null
}
function getContainerFromElement(element) {
if (!element) {
return null
}
if (element.__aurelia__?.container) {
return element.__aurelia__.container
}
return getContainerFromAu(element.au)
}
function getContainerFromSubtree(root) {
if (!root) {
return null
}
const rootContainer = getContainerFromElement(root)
if (rootContainer) {
return rootContainer
}
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)
let element = walker.nextNode()
while (element) {
const container = getContainerFromElement(element)
if (container) {
return container
}
element = walker.nextNode()
}
return null
}
function enqueueNestedValues(queue, seen, value, depth) {
if (value instanceof Map) {
enqueueIterable(queue, seen, value.values(), depth)
return
}
if (value instanceof Set || Array.isArray(value)) {
enqueueIterable(queue, seen, value.values(), depth)
return
}
if (!isRecord(value) && typeof value !== "function") {
return
}
enqueueIterable(queue, seen, Object.values(value), depth)
}
function enqueueIterable(queue, seen, values, depth) {
for (const entry of values) {
if ((isRecord(entry) || typeof entry === "function") && !seen.has(entry)) {
queue.push({ value: entry, depth })
}
}
}
@@ -0,0 +1,254 @@
import { TRAINER_LAUNCH_REQUEST_EXPORT_KEY } from "./constants.js"
import {
findExportedConstructor,
findInstanceInContainerGraph,
isRecord,
} from "./runtime.js"
export function hasMissingOptionalServices(state) {
return (
!state.gameLifecycleService ||
!state.trainerVisibilityService ||
!state.unavailableTitlesService
)
}
const OPTIONAL_SERVICE_SPECS = [
{
stateKey: "unavailableTitlesService",
methods: ["getUnavailableTitle", "searchUnavailableTitles", "getUnavailableTitlesByCorrelationIds"],
label: "Unavailable titles service",
},
{
stateKey: "gameLifecycleService",
methods: ["onGameLaunched", "onGameEnded", "launch"],
label: "Game lifecycle service",
},
{
stateKey: "trainerVisibilityService",
methods: ["onDisplayTrainerChanged", "onVisibleTrainerChanged", "onRunningTrainerChanged"],
label: "Trainer visibility service",
},
{
stateKey: "trainerApiService",
methods: ["getLatestLocalTrainerForGame", "getMostCompatibleTrainerForGame", "getTrainerById"],
label: "Trainer API service",
},
{
stateKey: "trainerService",
methods: ["launch", "endTrainer", "onNewTrainer", "onTrainerEnded"],
label: "Trainer service",
},
]
export function resolveOptionalServices(state, container, webpackRequire) {
for (const spec of OPTIONAL_SERVICE_SPECS) {
if (!state[spec.stateKey]) {
state[spec.stateKey] = resolveOptionalService(state, container, webpackRequire, spec)
}
}
if (!state.trainerLaunchRequestCtor) {
state.trainerLaunchRequestCtor = getTrainerLaunchRequestCtor(state, webpackRequire)
}
}
export function getInstalledAppsService(state, container, webpackRequire) {
const ctor = findExportedConstructor(webpackRequire, (prototype) => {
return (
typeof prototype.refreshApps === "function" &&
typeof prototype.watchGame === "function"
)
})
if (!ctor) {
state.log(
"warn",
"Installed apps service constructor not found in webpack cache."
)
return null
}
try {
const service = container.get(ctor)
const appsCount = isRecord(service.installedApps)
? Object.keys(service.installedApps).length
: -1
const catalogGamesCount = isRecord(service.catalog?.games)
? Object.keys(service.catalog.games).length
: -1
state.log(
"info",
"Installed apps service resolved.",
`ctor=${ctor.name || "<anon>"}, installedApps=${appsCount}, catalogGames=${catalogGamesCount}`
)
return service
} catch (error) {
state.log(
"warn",
"Failed to resolve installed apps service from Aurelia container.",
error?.stack || String(error)
)
return null
}
}
export function getStoreRef(state, container, webpackRequire) {
const storeCtor = findExportedConstructor(webpackRequire, (prototype) => {
return (
typeof prototype.dispatch === "function" &&
typeof prototype.registerAction === "function" &&
typeof prototype.unregisterAction === "function"
)
})
if (!storeCtor) {
state.log("warn", "Store constructor not found in webpack cache.")
return null
}
try {
const store = container.get(storeCtor)
const storeState =
typeof store.state?.getValue === "function"
? store.state.getValue()
: null
const installedAppsCount = isRecord(storeState?.installedApps)
? Object.keys(storeState.installedApps).length
: -1
const catalogGamesCount = isRecord(storeState?.catalog?.games)
? Object.keys(storeState.catalog.games).length
: -1
state.log(
"info",
"Store resolved via fallback.",
`ctor=${storeCtor.name || "<anon>"}, installedApps=${installedAppsCount}, catalogGames=${catalogGamesCount}`
)
return store
} catch (error) {
state.log(
"warn",
"Failed to resolve Store from container.",
error?.stack || String(error)
)
return null
}
}
function resolveOptionalService(state, container, webpackRequire, spec) {
const matchesMethods = (target) => hasAllMethods(target, spec.methods)
const ctor = findExportedConstructor(webpackRequire, matchesMethods)
if (ctor) {
return getContainerService(state, container, ctor, spec.stateKey, spec.label)
}
return findFallbackService(
state,
container,
spec.stateKey,
`${spec.label} constructor not found in webpack cache.`,
matchesMethods
)
}
function hasAllMethods(target, methods) {
if (!target) {
return false
}
for (const method of methods) {
if (typeof target[method] !== "function") {
return false
}
}
return true
}
function getTrainerLaunchRequestCtor(state, webpackRequire) {
const cache = webpackRequire?.c
if (!cache || typeof cache !== "object") {
warnMissingOptionalService(
state,
"trainerLaunchRequestCtor",
"Trainer launch request constructor cache is unavailable."
)
return null
}
for (const record of Object.values(cache)) {
const exports = record?.exports
if (!isRecord(exports)) {
continue
}
const candidate = exports[TRAINER_LAUNCH_REQUEST_EXPORT_KEY]
if (
typeof candidate === "function" &&
typeof exports.ZS === "function" &&
typeof exports.jR === "function" &&
typeof exports.UY === "function"
) {
clearMissingOptionalServiceWarning(state, "trainerLaunchRequestCtor")
return candidate
}
}
warnMissingOptionalService(
state,
"trainerLaunchRequestCtor",
"Trainer launch request constructor not found in webpack cache."
)
return null
}
function getContainerService(state, container, ctor, warningKey, label) {
try {
const service = container.get(ctor)
clearMissingOptionalServiceWarning(state, warningKey)
state.log(
"info",
`${label} resolved.`,
`ctor=${ctor.name || "<anon>"}${service?.runningTrainer ? ", running=yes" : ""}`
)
return service
} catch (error) {
state.log(
"warn",
`Failed to resolve ${label.toLowerCase()} from Aurelia container.`,
error?.stack || String(error)
)
return null
}
}
function findFallbackService(
state,
container,
warningKey,
missingMessage,
predicate
) {
const fallbackService = findInstanceInContainerGraph(container, predicate)
if (fallbackService) {
clearMissingOptionalServiceWarning(state, warningKey)
state.log("info", `${warningKey} resolved from container graph.`)
return fallbackService
}
warnMissingOptionalService(state, warningKey, missingMessage)
return null
}
function warnMissingOptionalService(state, key, message) {
if (state.missingOptionalServiceWarnings.has(key)) {
return
}
state.missingOptionalServiceWarnings.add(key)
state.log("warn", message)
}
function clearMissingOptionalServiceWarning(state, key) {
state.missingOptionalServiceWarnings.delete(key)
}
@@ -0,0 +1,95 @@
;(function installRemotePopupCleanup(WandEnhancer) {
if (globalThis.__wandRemotePopupCleanupInstalled) {
return
}
globalThis.__wandRemotePopupCleanupInstalled = true
const style = document.createElement("style")
style.id = "wand-remote-popup-cleanup-style"
style.textContent = `
remote-tooltip .remote-tooltip .top-wrapper,
remote-tooltip .remote-tooltip .remote-tooltip-section-divider,
remote-tooltip .remote-tooltip .instructions .header,
remote-tooltip .remote-tooltip .instructions .content .text,
remote-tooltip .remote-tooltip .instructions .platforms {
display: none !important;
}
remote-tooltip .remote-tooltip .instructions-wrapper {
margin: 0 !important;
padding: 18px !important;
text-align: center !important;
}
remote-tooltip .remote-tooltip .instructions,
remote-tooltip .remote-tooltip .instructions .content {
display: flex !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important;
gap: 0 !important;
}
remote-tooltip .remote-tooltip .instructions remote-qr-code {
all: unset !important;
--wand-qr-size: clamp(220px, 100vw, 300px);
width: var(--wand-qr-size) !important;
height: var(--wand-qr-size) !important;
min-width: var(--wand-qr-size) !important;
min-height: var(--wand-qr-size) !important;
max-width: var(--wand-qr-size) !important;
max-height: var(--wand-qr-size) !important;
flex: 0 0 var(--wand-qr-size) !important;
aspect-ratio: 1 / 1 !important;
display: block !important;
border-radius: 12px !important;
overflow: hidden !important;
transform: none !important;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35) !important;
}
remote-tooltip .remote-tooltip .instructions remote-qr-code canvas {
width: 100% !important;
height: 100% !important;
aspect-ratio: 1 / 1 !important;
display: block !important;
object-fit: contain !important;
image-rendering: pixelated !important;
border-radius: 12px !important;
transform: none !important;
}
`
const installStyle = () => {
if (!document.getElementById(style.id)) {
document.head.appendChild(style)
}
}
const updateLinks = () => {
const remoteUrl =
globalThis.__wandRemoteBridgeUrl || WandEnhancer?.remoteUrl
if (!remoteUrl) {
return
}
for (const anchor of document.querySelectorAll("remote-tooltip a[href]")) {
anchor.setAttribute("href", remoteUrl)
anchor.textContent = remoteUrl.replace(/\/$/, "")
}
}
installStyle()
updateLinks()
const observer = new MutationObserver(() => {
installStyle()
updateLinks()
})
observer.observe(document.documentElement, {
childList: true,
subtree: true,
})
})(globalThis.WandEnhancer)