/* eslint-disable @typescript-eslint/member-ordering */ import { DestroyRef, Injectable, computed, effect, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Store } from '@ngrx/store'; import type { PluginRequirementSummary, PluginRequirementsSnapshot, TojuPluginManifest } from '../../../../shared-kernel'; import { RealtimeSessionFacade } from '../../../../core/realtime'; import { selectCurrentRoom, selectCurrentRoomId } from '../../../../store/rooms/rooms.selectors'; import { ServerDirectoryFacade, type ServerSourceSelector } from '../../../server-directory'; import { PluginRegistryService } from './plugin-registry.service'; import { PluginRequirementService } from './plugin-requirement.service'; export type PluginRequirementComparisonStatus = | 'blockedByServer' | 'disabled' | 'enabled' | 'incompatible' | 'missing' | 'notRequired'; export interface PluginRequirementComparison { installed?: TojuPluginManifest; pluginId: string; requirement?: PluginRequirementSummary; status: PluginRequirementComparisonStatus; } @Injectable({ providedIn: 'root' }) export class PluginRequirementStateService { private readonly destroyRef = inject(DestroyRef); private readonly pluginRequirements = inject(PluginRequirementService); private readonly realtime = inject(RealtimeSessionFacade); private readonly registry = inject(PluginRegistryService); private readonly serverDirectory = inject(ServerDirectoryFacade); private readonly store = inject(Store); private readonly currentRoom = this.store.selectSignal(selectCurrentRoom); private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId); private readonly snapshotsSignal = signal>({}); private readonly refreshErrorsSignal = signal>({}); readonly currentSnapshot = computed(() => { const roomId = this.currentRoomId(); return roomId ? this.snapshotsSignal()[roomId] ?? null : null; }); readonly refreshErrors = this.refreshErrorsSignal.asReadonly(); readonly comparisons = computed(() => { const snapshot = this.currentSnapshot(); const installedEntries = this.registry.entries(); const installedById = new Map(installedEntries.map((entry) => [entry.manifest.id, entry])); const requirementIds = new Set(snapshot?.requirements.map((requirement) => requirement.pluginId) ?? []); const comparisons: PluginRequirementComparison[] = []; for (const requirement of snapshot?.requirements ?? []) { const entry = installedById.get(requirement.pluginId); comparisons.push({ installed: entry?.manifest, pluginId: requirement.pluginId, requirement, status: this.resolveStatus(requirement, entry) }); } for (const entry of installedEntries) { if (!requirementIds.has(entry.manifest.id)) { comparisons.push({ installed: entry.manifest, pluginId: entry.manifest.id, status: entry.enabled ? 'enabled' : 'disabled' }); } } return comparisons.sort((left, right) => left.pluginId.localeCompare(right.pluginId)); }); constructor() { this.realtime.onSignalingMessage .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((message) => { if ((message.type === 'plugin_requirements' || message.type === 'plugin_requirements_changed') && isSnapshotMessage(message)) { this.setSnapshot(message.serverId, message.snapshot); } }); effect(() => { const roomId = this.currentRoomId(); if (roomId) { void this.refreshCurrent(); } }); } async refreshCurrent(): Promise { const roomId = this.currentRoomId(); if (!roomId) { return; } try { const apiBaseUrl = this.serverDirectory.getApiBaseUrl(this.currentRoomSourceSelector()); const snapshot = await new Promise((resolve, reject) => { this.pluginRequirements.getSnapshot(apiBaseUrl, roomId).subscribe({ error: reject, next: resolve }); }); this.setSnapshot(roomId, snapshot); this.refreshErrorsSignal.update((errors) => { const { [roomId]: _removed, ...next } = errors; return next; }); } catch (error) { this.refreshErrorsSignal.update((errors) => ({ ...errors, [roomId]: error instanceof Error ? error.message : 'Unable to refresh plugin requirements' })); } } comparisonFor(pluginId: string): PluginRequirementComparison | null { return this.comparisons().find((comparison) => comparison.pluginId === pluginId) ?? null; } private setSnapshot(serverId: string, snapshot: PluginRequirementsSnapshot): void { this.snapshotsSignal.update((snapshots) => ({ ...snapshots, [serverId]: snapshot })); } private currentRoomSourceSelector(): ServerSourceSelector | undefined { const room = this.currentRoom(); if (!room?.sourceId && !room?.sourceUrl) { return undefined; } return { sourceId: room.sourceId, sourceUrl: room.sourceUrl }; } private resolveStatus( requirement: PluginRequirementSummary, entry: { enabled: boolean; manifest: TojuPluginManifest } | undefined ): PluginRequirementComparisonStatus { if (requirement.status === 'blocked') { return 'blockedByServer'; } if (requirement.status === 'incompatible') { return 'incompatible'; } if (!entry) { return 'missing'; } if (!entry.enabled) { return 'disabled'; } if (requirement.versionRange && !isVersionCompatible(entry.manifest.version, requirement.versionRange)) { return 'incompatible'; } return 'enabled'; } } function isSnapshotMessage(message: unknown): message is { serverId: string; snapshot: PluginRequirementsSnapshot } { const record = message as Record; return typeof record['serverId'] === 'string' && !!record['snapshot'] && typeof record['snapshot'] === 'object'; } function isVersionCompatible(version: string, versionRange: string): boolean { const normalizedRange = versionRange.trim(); if (!normalizedRange || normalizedRange === '*') { return true; } if (normalizedRange.startsWith('^')) { return version.split('.')[0] === normalizedRange.slice(1).split('.')[0]; } if (normalizedRange.startsWith('~')) { const [major, minor] = version.split('.'); const [rangeMajor, rangeMinor] = normalizedRange.slice(1).split('.'); return major === rangeMajor && minor === rangeMinor; } return version === normalizedRange; }