Files
Toju/toju-app/src/app/domains/plugins/application/services/plugin-requirement-state.service.ts
2026-04-29 15:24:56 +02:00

217 lines
6.6 KiB
TypeScript

/* 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<Record<string, PluginRequirementsSnapshot>>({});
private readonly refreshErrorsSignal = signal<Record<string, string>>({});
readonly currentSnapshot = computed(() => {
const roomId = this.currentRoomId();
return roomId ? this.snapshotsSignal()[roomId] ?? null : null;
});
readonly refreshErrors = this.refreshErrorsSignal.asReadonly();
readonly comparisons = computed<PluginRequirementComparison[]>(() => {
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<void> {
const roomId = this.currentRoomId();
if (!roomId) {
return;
}
try {
const apiBaseUrl = this.serverDirectory.getApiBaseUrl(this.currentRoomSourceSelector());
const snapshot = await new Promise<PluginRequirementsSnapshot>((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<string, unknown>;
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;
}