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