feat: plugins v1
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
/* 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 { selectCurrentRoomId } from '../../../../store/rooms/rooms.selectors';
|
||||
import { ServerDirectoryFacade } 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 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();
|
||||
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 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;
|
||||
}
|
||||
Reference in New Issue
Block a user