mirror of
https://github.com/luanslimadev/Wand-Enhancer.git
synced 2026-09-04 16:23:18 +00:00
feat: ship 1.0.8.0 release automation and runtime overhaul
- reduce ASAR IO overhead with streamed archive reads, buffered copies, faster relative-path handling and placeholder integrity records - fix in-place app.asar.unpacked packing/extraction self-copy cases that caused locked-file failures - tighten JS patch discovery with candidate bundle filters and search hints - require prebuilt remote-panel dist artifacts and clean up embedded bridge/script packaging - add unified build entrypoints for PowerShell, cmd and bash and move native CMake output under .tmp - add release metadata validation, changelog section extraction, pre-commit hook and GitHub Actions validation/release pipelines - make CHANGELOG the source of truth for release notes and document the tag-driven release flow - add updater release notes UI with latest/full changelog loading and localize the new update strings - modularize bridge renderer scripts, add installed apps and game status sync, and support remote launch/stop commands - centralize bridge protocol, IPC and WebSocket constants and improve LAN IP selection for QR pairing - refactor remote panel controls/state enums, persist accent color, polish library/session UI and refresh assets
This commit is contained in:
@@ -0,0 +1,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)
|
||||
}
|
||||
Reference in New Issue
Block a user