217 lines
6.6 KiB
TypeScript
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;
|
|
}
|