feat: plugins v1.5

This commit is contained in:
2026-04-29 01:14:30 +02:00
parent 6920f93b41
commit eabbc08896
59 changed files with 2197 additions and 352 deletions

View File

@@ -28,22 +28,22 @@ test.describe('Plugin API multi-user runtime', () => {
test('runs chat, embed, soundboard, and profile APIs between two users', async ({ createClient }) => {
const scenario = await createPluginApiScenario(createClient);
await test.step('Install and activate the plugin for Bob as the embed/soundboard receiver', async () => {
await installGrantAndActivatePlugin(scenario.bob.page);
await closeSettingsModal(scenario.bob.page);
await expect(soundboardComposerButton(scenario.bob.page)).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page.getByTestId('e2e-plugin-owned-dom')).toHaveAttribute('data-plugin-owner', 'e2e.all-api-plugin');
});
await test.step('Install and activate the plugin for Alice as the API driver', async () => {
await installGrantAndActivatePlugin(scenario.alice.page);
await test.step('Install the server plugin as Alice', async () => {
await installGrantAndActivatePlugin(scenario.alice.page, true);
await closeSettingsModal(scenario.alice.page);
await expect(soundboardComposerButton(scenario.alice.page)).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.getByTestId('e2e-plugin-owned-dom')).toHaveAttribute('data-plugin-owner', 'e2e.all-api-plugin');
});
await test.step('Activate the server plugin for Bob as the embed/soundboard receiver', async () => {
await installGrantAndActivatePlugin(scenario.bob.page, false);
await closeSettingsModal(scenario.bob.page);
await expect(soundboardComposerButton(scenario.bob.page)).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page.getByTestId('e2e-plugin-owned-dom')).toHaveAttribute('data-plugin-owner', 'e2e.all-api-plugin');
});
await test.step('Alice opens the plugin soundboard modal and plays a sound to voice', async () => {
await soundboardComposerButton(scenario.alice.page).click();
await expect(scenario.alice.page.getByRole('dialog', { name: SOUND_BOARD_LABEL })).toBeVisible({ timeout: 20_000 });
@@ -140,15 +140,21 @@ async function registerUser(page: Page, username: string, displayName: string):
await expect(page).toHaveURL(/\/search/, { timeout: 30_000 });
}
async function installGrantAndActivatePlugin(page: Page): Promise<void> {
async function installGrantAndActivatePlugin(page: Page, installFromStore: boolean): Promise<void> {
await page.getByRole('button', { name: 'Plugins' }).click();
await expect(page).toHaveURL(/\/plugin-store/, { timeout: 20_000 });
await expect(page.getByTestId('plugin-store-page')).toBeVisible({ timeout: 20_000 });
await page.getByPlaceholder('https://example.com/plugins.json').fill(PLUGIN_SOURCE_URL);
if (installFromStore) {
await page.getByLabel('Plugin source manifest URL').fill(PLUGIN_SOURCE_URL);
await page.getByRole('button', { name: 'Add Source' }).click();
await expect(page.getByRole('heading', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 });
await page.getByRole('button', { exact: true, name: 'Install' }).click();
await page.getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ }).click();
await expect(page.getByRole('dialog', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Install and Activate' }).click();
await expect(page.locator('article', { hasText: PLUGIN_TITLE }).getByText('Installed')).toBeVisible({ timeout: 20_000 });
}
await page.getByRole('button', { name: 'Manage Plugins' }).click();
await expect(page.getByTestId('plugin-manager')).toBeVisible({ timeout: 20_000 });
await expect(page.locator('article', { hasText: PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 });

View File

@@ -30,20 +30,25 @@ test.describe('Plugin manager UI', () => {
});
await test.step('Install fixture plugin from source manifest', async () => {
await page.getByPlaceholder('https://example.com/plugins.json').fill('http://localhost:4200/plugins/e2e-plugin-source.json');
await page.getByLabel('Plugin source manifest URL').fill('http://localhost:4200/plugins/e2e-plugin-source.json');
await page.getByRole('button', { name: 'Add Source' }).click();
await expect(page.getByRole('heading', { name: 'E2E All API Plugin' })).toBeVisible({ timeout: 15_000 });
await page.getByRole('button', { name: 'Readme' }).click();
await expect(page.getByText('Fixture plugin for Playwright coverage.')).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { exact: true, name: 'Install' }).click();
await page.getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ }).click();
const installDialog = page.getByRole('dialog', { name: 'E2E All API Plugin' });
await expect(installDialog).toBeVisible({ timeout: 10_000 });
await expect(installDialog.getByText('Install to server', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Install and Activate' }).click();
await expect(page.locator('article', { hasText: 'E2E All API Plugin' }).getByText('Installed')).toBeVisible({ timeout: 10_000 });
});
await test.step('Open plugin manager from the store page', async () => {
await page.getByRole('button', { name: 'Manage Plugins' }).click();
await expect(page.getByTestId('plugin-manager')).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId('plugin-manager').getByRole('heading', { name: 'Plugins' })).toBeVisible();
await expect(page.getByText('Development Plugin')).toBeVisible();
await expect(page.getByTestId('plugin-manager').getByRole('heading', { name: 'Server plugins' })).toBeVisible();
await expect(page.getByText('E2E All API Plugin')).toBeVisible();
});
await test.step('Grant capabilities and activate runtime', async () => {

View File

@@ -34,21 +34,6 @@ interface PluginEventDefinitionResponse {
};
}
interface PluginDataResponse {
record: {
key: string;
ownerId?: string;
pluginId: string;
schemaVersion: number;
scope: string;
value: unknown;
};
}
interface PluginDataListResponse {
records: PluginDataResponse['record'][];
}
interface PluginSnapshotResponse {
eventDefinitions: PluginEventDefinitionResponse['eventDefinition'][];
requirements: PluginRequirementResponse['requirement'][];
@@ -136,8 +121,8 @@ test.describe('Plugin support API', () => {
expect(snapshot.eventDefinitions.map((entry) => entry.eventName).sort()).toEqual([TEST_PLUGIN_P2P_EVENT, TEST_PLUGIN_RELAY_EVENT]);
});
await test.step('Plugin data API stores, lists, and deletes server scoped data', async () => {
const stored = await expectJson<PluginDataResponse>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, {
await test.step('Plugin data API refuses arbitrary server persistence', async () => {
const stored = await expectJson<{ errorCode: string }>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, {
data: {
actorUserId: OWNER_USER_ID,
schemaVersion: 1,
@@ -147,49 +132,28 @@ test.describe('Plugin support API', () => {
pluginVersion: manifest.version
}
}
}));
}), 410);
expect(stored.record).toEqual(expect.objectContaining({
key: 'settings',
pluginId: TEST_PLUGIN_ID,
schemaVersion: 1,
scope: 'server',
value: {
enabled: true,
pluginVersion: manifest.version
}
}));
expect(stored.errorCode).toBe('PLUGIN_DATA_DISABLED');
const listed = await expectJson<PluginDataListResponse>(await request.get(`${pluginsApi}/${TEST_PLUGIN_ID}/data`, {
const listed = await expectJson<{ errorCode: string }>(await request.get(`${pluginsApi}/${TEST_PLUGIN_ID}/data`, {
params: {
key: 'settings',
scope: 'server',
userId: OWNER_USER_ID
}
}));
}), 410);
expect(listed.records).toHaveLength(1);
expect(listed.records[0]?.value).toEqual({
enabled: true,
pluginVersion: manifest.version
});
expect(listed.errorCode).toBe('PLUGIN_DATA_DISABLED');
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, {
const afterDelete = await expectJson<{ errorCode: string }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, {
data: {
actorUserId: OWNER_USER_ID,
scope: 'server'
}
}));
}), 410);
const afterDelete = await expectJson<PluginDataListResponse>(await request.get(`${pluginsApi}/${TEST_PLUGIN_ID}/data`, {
params: {
key: 'settings',
scope: 'server',
userId: OWNER_USER_ID
}
}));
expect(afterDelete.records).toEqual([]);
expect(afterDelete.errorCode).toBe('PLUGIN_DATA_DISABLED');
});
await test.step('WebSocket plugin API sends snapshots, relays server events, and rejects p2p relays', async () => {

View File

@@ -28,5 +28,6 @@ Electron main-process package for MetoYou / Toju. This directory owns desktop bo
## Notes
- When adding a renderer-facing capability, update the Electron implementation, `preload.ts`, and the renderer bridge in `toju-app/` together.
- Plugin client data is stored in the local Electron SQLite database in the dedicated `plugin_data` table. Renderer plugins reach it through CQRS commands/queries exposed by the preload bridge; the signal server must not be used for arbitrary plugin data persistence.
- Treat `dist/electron/` and `dist-electron/` as generated output.
- See [AGENTS.md](AGENTS.md) for package-level editing rules.

View File

@@ -11,7 +11,8 @@ import {
ReactionEntity,
BanEntity,
AttachmentEntity,
MetaEntity
MetaEntity,
PluginDataEntity
} from '../../../entities';
export async function handleClearAllData(dataSource: DataSource): Promise<void> {
@@ -27,4 +28,5 @@ export async function handleClearAllData(dataSource: DataSource): Promise<void>
await dataSource.getRepository(BanEntity).clear();
await dataSource.getRepository(AttachmentEntity).clear();
await dataSource.getRepository(MetaEntity).clear();
await dataSource.getRepository(PluginDataEntity).clear();
}

View File

@@ -0,0 +1,14 @@
import { DataSource } from 'typeorm';
import { PluginDataEntity } from '../../../entities';
import { DeletePluginDataCommand } from '../../types';
export async function handleDeletePluginData(command: DeletePluginDataCommand, dataSource: DataSource): Promise<void> {
const { payload } = command;
await dataSource.getRepository(PluginDataEntity).delete({
key: payload.key,
pluginId: payload.pluginId,
scope: payload.scope,
serverId: payload.serverId ?? ''
});
}

View File

@@ -0,0 +1,10 @@
import { DataSource } from 'typeorm';
import { MetaEntity } from '../../../entities';
import { SaveMetaCommand } from '../../types';
export async function handleSaveMeta(command: SaveMetaCommand, dataSource: DataSource): Promise<void> {
await dataSource.getRepository(MetaEntity).save({
key: command.payload.key,
value: command.payload.value
});
}

View File

@@ -0,0 +1,16 @@
import { DataSource } from 'typeorm';
import { PluginDataEntity } from '../../../entities';
import { SavePluginDataCommand } from '../../types';
export async function handleSavePluginData(command: SavePluginDataCommand, dataSource: DataSource): Promise<void> {
const { payload } = command;
await dataSource.getRepository(PluginDataEntity).save({
key: payload.key,
pluginId: payload.pluginId,
scope: payload.scope,
serverId: payload.serverId ?? '',
updatedAt: Date.now(),
valueJson: JSON.stringify(payload.value ?? null)
});
}

View File

@@ -18,7 +18,10 @@ import {
SaveBanCommand,
RemoveBanCommand,
SaveAttachmentCommand,
DeleteAttachmentsForMessageCommand
DeleteAttachmentsForMessageCommand,
SavePluginDataCommand,
DeletePluginDataCommand,
SaveMetaCommand
} from '../types';
import { handleSaveMessage } from './handlers/saveMessage';
import { handleDeleteMessage } from './handlers/deleteMessage';
@@ -36,6 +39,9 @@ import { handleSaveBan } from './handlers/saveBan';
import { handleRemoveBan } from './handlers/removeBan';
import { handleSaveAttachment } from './handlers/saveAttachment';
import { handleDeleteAttachmentsForMessage } from './handlers/deleteAttachmentsForMessage';
import { handleSavePluginData } from './handlers/savePluginData';
import { handleDeletePluginData } from './handlers/deletePluginData';
import { handleSaveMeta } from './handlers/saveMeta';
import { handleClearAllData } from './handlers/clearAllData';
export const buildCommandHandlers = (dataSource: DataSource): Record<CommandTypeKey, (command: Command) => Promise<unknown>> => ({
@@ -55,5 +61,8 @@ export const buildCommandHandlers = (dataSource: DataSource): Record<CommandType
[CommandType.RemoveBan]: (cmd) => handleRemoveBan(cmd as RemoveBanCommand, dataSource),
[CommandType.SaveAttachment]: (cmd) => handleSaveAttachment(cmd as SaveAttachmentCommand, dataSource),
[CommandType.DeleteAttachmentsForMessage]: (cmd) => handleDeleteAttachmentsForMessage(cmd as DeleteAttachmentsForMessageCommand, dataSource),
[CommandType.SavePluginData]: (cmd) => handleSavePluginData(cmd as SavePluginDataCommand, dataSource),
[CommandType.DeletePluginData]: (cmd) => handleDeletePluginData(cmd as DeletePluginDataCommand, dataSource),
[CommandType.SaveMeta]: (cmd) => handleSaveMeta(cmd as SaveMetaCommand, dataSource),
[CommandType.ClearAllData]: () => handleClearAllData(dataSource)
});

View File

@@ -0,0 +1,11 @@
import { DataSource } from 'typeorm';
import { MetaEntity } from '../../../entities';
import { GetMetaQuery } from '../../types';
export async function handleGetMeta(query: GetMetaQuery, dataSource: DataSource): Promise<string | null> {
const meta = await dataSource.getRepository(MetaEntity).findOne({
where: { key: query.payload.key }
});
return meta?.value ?? null;
}

View File

@@ -0,0 +1,25 @@
import { DataSource } from 'typeorm';
import { PluginDataEntity } from '../../../entities';
import { GetPluginDataQuery } from '../../types';
export async function handleGetPluginData(query: GetPluginDataQuery, dataSource: DataSource): Promise<unknown> {
const { payload } = query;
const record = await dataSource.getRepository(PluginDataEntity).findOne({
where: {
key: payload.key,
pluginId: payload.pluginId,
scope: payload.scope,
serverId: payload.serverId ?? ''
}
});
if (!record) {
return null;
}
try {
return JSON.parse(record.valueJson) as unknown;
} catch {
return null;
}
}

View File

@@ -8,11 +8,12 @@ import {
GetMessageByIdQuery,
GetReactionsForMessageQuery,
GetUserQuery,
GetCurrentUserIdQuery,
GetRoomQuery,
GetBansForRoomQuery,
IsUserBannedQuery,
GetAttachmentsForMessageQuery
GetAttachmentsForMessageQuery,
GetPluginDataQuery,
GetMetaQuery
} from '../types';
import { handleGetMessages } from './handlers/getMessages';
import { handleGetMessagesSince } from './handlers/getMessagesSince';
@@ -28,6 +29,8 @@ import { handleGetBansForRoom } from './handlers/getBansForRoom';
import { handleIsUserBanned } from './handlers/isUserBanned';
import { handleGetAttachmentsForMessage } from './handlers/getAttachmentsForMessage';
import { handleGetAllAttachments } from './handlers/getAllAttachments';
import { handleGetPluginData } from './handlers/getPluginData';
import { handleGetMeta } from './handlers/getMeta';
export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey, (query: Query) => Promise<unknown>> => ({
[QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource),
@@ -43,5 +46,7 @@ export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey,
[QueryType.GetBansForRoom]: (query) => handleGetBansForRoom(query as GetBansForRoomQuery, dataSource),
[QueryType.IsUserBanned]: (query) => handleIsUserBanned(query as IsUserBannedQuery, dataSource),
[QueryType.GetAttachmentsForMessage]: (query) => handleGetAttachmentsForMessage(query as GetAttachmentsForMessageQuery, dataSource),
[QueryType.GetAllAttachments]: () => handleGetAllAttachments(dataSource)
[QueryType.GetAllAttachments]: () => handleGetAllAttachments(dataSource),
[QueryType.GetPluginData]: (query) => handleGetPluginData(query as GetPluginDataQuery, dataSource),
[QueryType.GetMeta]: (query) => handleGetMeta(query as GetMetaQuery, dataSource)
});

View File

@@ -15,6 +15,9 @@ export const CommandType = {
RemoveBan: 'remove-ban',
SaveAttachment: 'save-attachment',
DeleteAttachmentsForMessage: 'delete-attachments-for-message',
SavePluginData: 'save-plugin-data',
DeletePluginData: 'delete-plugin-data',
SaveMeta: 'save-meta',
ClearAllData: 'clear-all-data'
} as const;
@@ -34,7 +37,9 @@ export const QueryType = {
GetBansForRoom: 'get-bans-for-room',
IsUserBanned: 'is-user-banned',
GetAttachmentsForMessage: 'get-attachments-for-message',
GetAllAttachments: 'get-all-attachments'
GetAllAttachments: 'get-all-attachments',
GetPluginData: 'get-plugin-data',
GetMeta: 'get-meta'
} as const;
export type QueryTypeKey = typeof QueryType[keyof typeof QueryType];
@@ -172,6 +177,16 @@ export interface AttachmentPayload {
savedPath?: string;
}
export type PluginDataScopePayload = 'local' | 'server';
export interface PluginDataPayload {
key: string;
pluginId: string;
scope: PluginDataScopePayload;
serverId?: string;
value: unknown;
}
export interface SaveMessageCommand { type: typeof CommandType.SaveMessage; payload: { message: MessagePayload } }
export interface DeleteMessageCommand { type: typeof CommandType.DeleteMessage; payload: { messageId: string } }
export interface UpdateMessageCommand { type: typeof CommandType.UpdateMessage; payload: { messageId: string; updates: Partial<MessagePayload> } }
@@ -188,6 +203,9 @@ export interface SaveBanCommand { type: typeof CommandType.SaveBan; payload: { b
export interface RemoveBanCommand { type: typeof CommandType.RemoveBan; payload: { oderId: string } }
export interface SaveAttachmentCommand { type: typeof CommandType.SaveAttachment; payload: { attachment: AttachmentPayload } }
export interface DeleteAttachmentsForMessageCommand { type: typeof CommandType.DeleteAttachmentsForMessage; payload: { messageId: string } }
export interface SavePluginDataCommand { type: typeof CommandType.SavePluginData; payload: PluginDataPayload }
export interface DeletePluginDataCommand { type: typeof CommandType.DeletePluginData; payload: Omit<PluginDataPayload, 'value'> }
export interface SaveMetaCommand { type: typeof CommandType.SaveMeta; payload: { key: string; value: string | null } }
export interface ClearAllDataCommand { type: typeof CommandType.ClearAllData; payload: Record<string, never> }
export type Command =
@@ -207,6 +225,9 @@ export type Command =
| RemoveBanCommand
| SaveAttachmentCommand
| DeleteAttachmentsForMessageCommand
| SavePluginDataCommand
| DeletePluginDataCommand
| SaveMetaCommand
| ClearAllDataCommand;
export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } }
@@ -223,6 +244,8 @@ export interface GetBansForRoomQuery { type: typeof QueryType.GetBansForRoom; pa
export interface IsUserBannedQuery { type: typeof QueryType.IsUserBanned; payload: { userId: string; roomId: string } }
export interface GetAttachmentsForMessageQuery { type: typeof QueryType.GetAttachmentsForMessage; payload: { messageId: string } }
export interface GetAllAttachmentsQuery { type: typeof QueryType.GetAllAttachments; payload: Record<string, never> }
export interface GetPluginDataQuery { type: typeof QueryType.GetPluginData; payload: Omit<PluginDataPayload, 'value'> }
export interface GetMetaQuery { type: typeof QueryType.GetMeta; payload: { key: string } }
export type Query =
| GetMessagesQuery
@@ -238,4 +261,6 @@ export type Query =
| GetBansForRoomQuery
| IsUserBannedQuery
| GetAttachmentsForMessageQuery
| GetAllAttachmentsQuery;
| GetAllAttachmentsQuery
| GetPluginDataQuery
| GetMetaQuery;

View File

@@ -25,7 +25,8 @@ import {
ReactionEntity,
BanEntity,
AttachmentEntity,
MetaEntity
MetaEntity,
PluginDataEntity
} from './entities';
const projectRootDatabaseFilePath = path.join(__dirname, '..', '..', settings.databaseName);
@@ -51,7 +52,8 @@ export const AppDataSource = new DataSource({
ReactionEntity,
BanEntity,
AttachmentEntity,
MetaEntity
MetaEntity,
PluginDataEntity
],
migrations: [path.join(__dirname, 'migrations', '*.{ts,js}')],
synchronize: false,

View File

@@ -16,7 +16,8 @@ import {
ReactionEntity,
BanEntity,
AttachmentEntity,
MetaEntity
MetaEntity,
PluginDataEntity
} from '../entities';
import { settings } from '../settings';
@@ -171,7 +172,8 @@ export async function initializeDatabase(): Promise<void> {
ReactionEntity,
BanEntity,
AttachmentEntity,
MetaEntity
MetaEntity,
PluginDataEntity
],
migrations: [path.join(__dirname, '..', 'migrations', '*.js'), path.join(__dirname, '..', 'migrations', '*.ts')],
synchronize: false,

View File

@@ -0,0 +1,26 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('plugin_data')
export class PluginDataEntity {
@PrimaryColumn('text')
pluginId!: string;
@PrimaryColumn('text')
scope!: string;
@PrimaryColumn('text')
serverId!: string;
@PrimaryColumn('text')
key!: string;
@Column('text')
valueJson!: string;
@Column('integer')
updatedAt!: number;
}

View File

@@ -10,3 +10,4 @@ export { ReactionEntity } from './ReactionEntity';
export { BanEntity } from './BanEntity';
export { AttachmentEntity } from './AttachmentEntity';
export { MetaEntity } from './MetaEntity';
export { PluginDataEntity } from './PluginDataEntity';

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPluginData1000000000008 implements MigrationInterface {
name = 'AddPluginData1000000000008';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "plugin_data" (
"pluginId" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"serverId" TEXT NOT NULL DEFAULT '',
"key" TEXT NOT NULL,
"valueJson" TEXT NOT NULL,
"updatedAt" INTEGER NOT NULL,
PRIMARY KEY ("pluginId", "scope", "serverId", "key")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_plugin_data_plugin_scope" ON "plugin_data" ("pluginId", "scope")`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS "idx_plugin_data_plugin_scope"`);
await queryRunner.query(`DROP TABLE IF EXISTS "plugin_data"`);
}
}

View File

@@ -20,7 +20,7 @@ Node/TypeScript signaling server for MetoYou / Toju. This package owns the publi
- `SSL` can override the effective HTTP protocol, and `PORT` can override the effective port.
- `DB_PATH` can override the SQLite database file location.
- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `rawgApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`.
- `openApiDocs.enabled` in `data/variables.json`, or `OPENAPI_DOCS_ENABLED=true`, exposes the plugin support OpenAPI document at `/api/openapi.json` and a small docs page at `/api/docs`. It is disabled by default.
- `openApiDocs.enabled` in `data/variables.json`, or `OPENAPI_DOCS_ENABLED=true`, exposes the plugin support OpenAPI document at `/api/openapi.json` and a small docs page at `/api/docs`. It is disabled by default. Plugin support is metadata-only: the server stores install requirements and event definitions, but arbitrary plugin data persistence is disabled.
- `RAWG_API_KEY` can override `rawgApiKey` for the `/api/games/match` now-playing metadata resolver. Successful matches include a preferred store link from RAWG store metadata, with Steam selected first when available. Negative game-match results are stored in the SQLite `game_match_misses` table so non-game process names do not repeatedly consume RAWG quota.
- Packaged server builds store `metoyou.sqlite` in the OS app-data directory by default so upgrades do not overwrite runtime data. On first start, the server copies forward legacy packaged databases that still live beside the executable.
- When HTTPS is enabled, certificates are read from the repository `.certs/` directory.

Binary file not shown.

View File

@@ -25,6 +25,15 @@ export class ServerPluginRequirementEntity {
@Column('text', { nullable: true })
reason!: string | null;
@Column('text', { nullable: true })
installUrl!: string | null;
@Column('text', { nullable: true })
sourceUrl!: string | null;
@Column('text', { nullable: true })
manifestJson!: string | null;
@Column('text', { nullable: true })
configuredBy!: string | null;

View File

@@ -0,0 +1,30 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ServerPluginInstallMetadata1000000000008 implements MigrationInterface {
name = 'ServerPluginInstallMetadata1000000000008';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "server_plugin_requirements" ADD COLUMN "installUrl" TEXT`);
await queryRunner.query(`ALTER TABLE "server_plugin_requirements" ADD COLUMN "sourceUrl" TEXT`);
await queryRunner.query(`ALTER TABLE "server_plugin_requirements" ADD COLUMN "manifestJson" TEXT`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "temporary_server_plugin_requirements" (
"serverId" TEXT NOT NULL,
"pluginId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"versionRange" TEXT,
"reason" TEXT,
"configuredBy" TEXT,
"createdAt" INTEGER NOT NULL,
"updatedAt" INTEGER NOT NULL,
PRIMARY KEY ("serverId", "pluginId")
)`);
await queryRunner.query(`INSERT INTO "temporary_server_plugin_requirements" ("serverId", "pluginId", "status", "versionRange", "reason", "configuredBy", "createdAt", "updatedAt")
SELECT "serverId", "pluginId", "status", "versionRange", "reason", "configuredBy", "createdAt", "updatedAt" FROM "server_plugin_requirements"`);
await queryRunner.query(`DROP TABLE "server_plugin_requirements"`);
await queryRunner.query(`ALTER TABLE "temporary_server_plugin_requirements" RENAME TO "server_plugin_requirements"`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_plugin_requirements_status" ON "server_plugin_requirements" ("status")`);
}
}

View File

@@ -6,6 +6,7 @@ import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeSer
import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl';
import { GameMatchMisses1000000000006 } from './1000000000006-GameMatchMisses';
import { PluginSupport1000000000007 } from './1000000000007-PluginSupport';
import { ServerPluginInstallMetadata1000000000008 } from './1000000000008-ServerPluginInstallMetadata';
export const serverMigrations = [
InitialSchema1000000000000,
@@ -15,5 +16,6 @@ export const serverMigrations = [
NormalizeServerArrays1000000000004,
ServerRoleAccessControl1000000000005,
GameMatchMisses1000000000006,
PluginSupport1000000000007
PluginSupport1000000000007,
ServerPluginInstallMetadata1000000000008
];

View File

@@ -9,7 +9,7 @@ function createOpenApiDocument(baseUrl: string) {
info: {
title: 'MetoYou Plugin Support API',
version: '1.0.0',
description: 'Official HTTP endpoints for plugin metadata, event definitions, and plugin data. '
description: 'Official HTTP endpoints for plugin install metadata and event definitions. '
+ 'Plugin code is never executed by the signal server.'
},
servers: [{ url: `${baseUrl}/api` }],
@@ -43,18 +43,18 @@ function createOpenApiDocument(baseUrl: string) {
},
'/servers/{serverId}/plugins/{pluginId}/data': {
get: {
summary: 'List plugin data records',
responses: { '200': { description: 'Plugin data records' }, '403': { description: 'Not a server member' } }
summary: 'Plugin data persistence disabled',
responses: { '410': { description: 'Plugin data persistence is disabled on the signal server' } }
}
},
'/servers/{serverId}/plugins/{pluginId}/data/{key}': {
put: {
summary: 'Write plugin data',
responses: { '200': { description: 'Plugin data saved' }, '403': { description: 'Not a server member' } }
summary: 'Plugin data persistence disabled',
responses: { '410': { description: 'Plugin data persistence is disabled on the signal server' } }
},
delete: {
summary: 'Delete plugin data',
responses: { '200': { description: 'Plugin data deleted' }, '403': { description: 'Not a server member' } }
summary: 'Plugin data persistence disabled',
responses: { '410': { description: 'Plugin data persistence is disabled on the signal server' } }
}
},
'/openapi/settings': {
@@ -98,7 +98,7 @@ router.get('/docs', (_req, res) => {
<body style="font-family:system-ui;margin:2rem;line-height:1.5">
<h1>MetoYou Plugin Support API</h1>
<p>Plugin support endpoints are available at <a href="/api/openapi.json">/api/openapi.json</a>.</p>
<p>The signal server stores metadata, data, and event definitions only. It never executes plugin code.</p>
<p>The signal server stores plugin install metadata and event definitions only. It never executes plugin code or stores arbitrary plugin data.</p>
</body>
</html>`);
});

View File

@@ -1,12 +1,9 @@
import { Response, Router } from 'express';
import {
deletePluginData,
deletePluginEventDefinition,
deletePluginRequirement,
getPluginRequirementsSnapshot,
listPluginData,
PluginSupportError,
upsertPluginData,
upsertPluginEventDefinition,
upsertPluginRequirement
} from '../services/plugin-support.service';
@@ -52,9 +49,12 @@ router.put('/:serverId/plugins/:pluginId/requirement', async (req, res) => {
try {
const requirement = await upsertPluginRequirement({
actorUserId: readActorUserId(req.body.actorUserId),
installUrl: req.body.installUrl,
manifest: req.body.manifest,
pluginId,
reason: req.body.reason,
serverId,
sourceUrl: req.body.sourceUrl,
status: req.body.status,
versionRange: req.body.versionRange
});
@@ -124,85 +124,25 @@ router.delete('/:serverId/plugins/:pluginId/events/:eventName', async (req, res)
}
});
router.get('/:serverId/plugins/:pluginId/data', async (req, res) => {
const { serverId, pluginId } = req.params;
try {
const records = await listPluginData({
actorUserId: readActorUserId(req.query.userId),
key: req.query.key,
ownerId: req.query.ownerId,
pluginId,
scope: req.query.scope,
serverId
router.get('/:serverId/plugins/:pluginId/data', (_req, res) => {
res.status(410).json({
error: 'Plugin data persistence is disabled on the signal server',
errorCode: 'PLUGIN_DATA_DISABLED'
});
});
res.json({ records });
} catch (error) {
sendPluginSupportError(error, res);
}
router.put('/:serverId/plugins/:pluginId/data/:key', (_req, res) => {
res.status(410).json({
error: 'Plugin data persistence is disabled on the signal server',
errorCode: 'PLUGIN_DATA_DISABLED'
});
});
router.put('/:serverId/plugins/:pluginId/data/:key', async (req, res) => {
const { serverId, pluginId, key } = req.params;
try {
const record = await upsertPluginData({
actorUserId: readActorUserId(req.body.actorUserId),
key,
ownerId: req.body.ownerId,
pluginId,
schemaVersion: req.body.schemaVersion,
scope: req.body.scope,
serverId,
value: req.body.value
router.delete('/:serverId/plugins/:pluginId/data/:key', (_req, res) => {
res.status(410).json({
error: 'Plugin data persistence is disabled on the signal server',
errorCode: 'PLUGIN_DATA_DISABLED'
});
broadcastToServer(serverId, {
type: 'plugin_data_changed',
serverId,
pluginId: record.pluginId,
scope: record.scope,
ownerId: record.ownerId,
key: record.key,
updatedAt: record.updatedAt
});
res.json({ record });
} catch (error) {
sendPluginSupportError(error, res);
}
});
router.delete('/:serverId/plugins/:pluginId/data/:key', async (req, res) => {
const { serverId, pluginId, key } = req.params;
const scope = req.body.scope ?? req.query.scope;
const ownerId = req.body.ownerId ?? req.query.ownerId;
try {
await deletePluginData({
actorUserId: readActorUserId(req.body.actorUserId),
key,
ownerId,
pluginId,
scope,
serverId
});
broadcastToServer(serverId, {
type: 'plugin_data_changed',
serverId,
pluginId,
scope: typeof scope === 'string' ? scope : 'server',
ownerId: typeof ownerId === 'string' && ownerId.trim() ? ownerId.trim() : undefined,
key,
updatedAt: Date.now()
});
res.json({ ok: true });
} catch (error) {
sendPluginSupportError(error, res);
}
});
export default router;

View File

@@ -37,8 +37,11 @@ const DATA_KEY_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/;
const DATA_SCOPE_PATTERN = /^[a-zA-Z][a-zA-Z0-9._:-]{0,63}$/;
export interface PluginRequirementSummary {
installUrl?: string;
manifest?: unknown;
pluginId: string;
reason?: string;
sourceUrl?: string;
status: ServerPluginRequirementStatus;
updatedAt: number;
versionRange?: string;
@@ -174,6 +177,10 @@ function parseJsonValue(valueJson: string): unknown {
}
}
function parseOptionalJsonValue(valueJson: string | null): unknown {
return valueJson ? parseJsonValue(valueJson) : undefined;
}
function serializeJsonValue(value: unknown, code: string): string {
try {
return JSON.stringify(value ?? null);
@@ -184,8 +191,11 @@ function serializeJsonValue(value: unknown, code: string): string {
function toRequirementSummary(entity: ServerPluginRequirementEntity): PluginRequirementSummary {
return {
installUrl: entity.installUrl ?? undefined,
manifest: parseOptionalJsonValue(entity.manifestJson),
pluginId: entity.pluginId,
reason: entity.reason ?? undefined,
sourceUrl: entity.sourceUrl ?? undefined,
status: entity.status,
updatedAt: entity.updatedAt,
versionRange: entity.versionRange ?? undefined
@@ -282,9 +292,12 @@ export async function getPluginRequirementsSnapshot(serverId: string): Promise<P
export async function upsertPluginRequirement(options: {
actorUserId: string;
installUrl?: unknown;
manifest?: unknown;
pluginId: string;
reason?: unknown;
serverId: string;
sourceUrl?: unknown;
status: unknown;
versionRange?: unknown;
}): Promise<PluginRequirementSummary> {
@@ -306,6 +319,9 @@ export async function upsertPluginRequirement(options: {
status: status as ServerPluginRequirementStatus,
versionRange: normalizeOptionalString(options.versionRange, 128),
reason: normalizeOptionalString(options.reason, 512),
installUrl: normalizeOptionalString(options.installUrl, 2_000),
sourceUrl: normalizeOptionalString(options.sourceUrl, 2_000),
manifestJson: options.manifest === undefined ? null : serializeJsonValue(options.manifest, 'INVALID_PLUGIN_MANIFEST_METADATA'),
configuredBy: options.actorUserId,
createdAt: existing?.createdAt ?? now,
updatedAt: now

View File

@@ -96,13 +96,13 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "2.5MB",
"maximumError": "2.6MB"
"maximumWarning": "10mb",
"maximumError": "20mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "7kB",
"maximumError": "8kB"
"maximumWarning": "10mb",
"maximumError": "20mb"
}
],
"outputHashing": "all"

View File

@@ -73,6 +73,8 @@ export async function activate(context) {
api.storage.set('coverage', { ok: true });
api.storage.get('coverage');
await api.clientData.write('coverage', { ok: true });
await api.clientData.read('coverage');
await api.serverData.write('coverage', { ok: true });
await api.serverData.read('coverage');
@@ -127,6 +129,23 @@ export async function activate(context) {
});
api.messages.moderateDelete('missing-message-id');
api.messages.sync(api.messages.readCurrent());
context.subscriptions.push(api.messageBus.subscribe({
handler: () => {},
latestMessageLimit: 5,
replayLatest: true,
topic: 'e2e:latest'
}));
api.messageBus.publish({
includeLatestMessages: true,
includeSelf: true,
latestMessageLimit: 5,
payload: { ok: true },
topic: 'e2e:latest'
});
api.messageBus.sendLatestMessages({
limit: 5,
topic: 'e2e:latest'
});
api.p2p.connectedPeers();
api.p2p.broadcastData('e2e:p2p', { ok: true });
@@ -146,6 +165,7 @@ export async function activate(context) {
await audioContext.close();
api.storage.remove('coverage');
await api.clientData.remove('coverage');
await api.serverData.remove('coverage');
api.logger.info('all-api plugin completed');
}

View File

@@ -5,6 +5,7 @@
"description": "Calls every public Toju plugin API surface for user-facing Playwright coverage.",
"version": "1.0.0",
"kind": "client",
"scope": "server",
"apiVersion": "1.0.0",
"compatibility": {
"minimumTojuVersion": "1.0.0",

View File

@@ -6,6 +6,7 @@
"title": "E2E All API Plugin",
"description": "Test plugin that calls every public Toju plugin API surface.",
"version": "1.0.0",
"scope": "server",
"author": "MetoYou Tests",
"image": "./e2e-all-api/icon.svg",
"github": "https://git.azaaxin.com/myxelium/Toju",

View File

@@ -11,6 +11,7 @@ export type SettingsPage =
| 'data'
| 'debugging'
| 'server'
| 'serverPlugins'
| 'members'
| 'bans'
| 'permissions';

View File

@@ -2,16 +2,24 @@
Owns the client-only plugin runtime foundation: manifest validation, deterministic load ordering, registry state, local manifest discovery, capability grants, browser-imported client entrypoints, disposable UI extension registries, plugin logs, and typed access to signal-server plugin support metadata.
The signal server can store plugin metadata/data and relay registered plugin events, but it must never execute plugin code. Executable plugin loading belongs to the renderer/Electron boundary and should enter this domain through `PluginHostService`.
The signal server stores plugin install metadata and event definitions, but it must never execute plugin code or store arbitrary plugin data. Executable plugin loading belongs to the renderer/Electron boundary and should enter this domain through `PluginHostService`.
Desktop local plugins are discovered from the Electron app data `plugins` folder. Discovery reads `toju-plugin.json` or `plugin.json` from immediate child folders and resolves declared entrypoint/readme paths only when they stay inside that plugin folder.
The standalone plugin store is available from the title bar Plugins button, the title-bar Plugin Store menu item, the legacy Settings page button, and the Plugin Manager header. It owns source manifest management, search, readmes, install/update/uninstall actions, and links back to installed-plugin management.
The standalone plugin store is available from the title bar Plugins button, the title-bar Plugin Store menu item, the legacy Settings page button, and the Plugin Manager header. It owns source manifest management, search, readmes, install/update/uninstall actions, and links back to installed-plugin management. Manifest `kind` describes runtime shape (`client` or `library`), while top-level manifest `scope` describes installation scope: omit it or use `scope: "client"` for global client plugins, and use `scope: "server"` for chat-server plugins. Server-scoped store entries are presented as Install to Server, Update Server, or Remove from Server.
The plugin manager UI is available from Settings -> Plugins and from the store page Manage Plugins button. It includes installed plugins, capability grant toggles, activate/reload/unload actions, runtime logs, extension-point counts, server requirements, generated settings, and docs.
The plugin manager UI is split between Settings -> Client plugins for global client plugins and Settings -> Server -> Server plugins for chat-server plugins. The two pages filter by manifest `scope` and include installed plugins, capability grant toggles, per-plugin activate/reload/unload actions, runtime logs, extension-point counts, server requirements, generated settings, and docs.
The Store tab consumes user-managed HTTP(S) source manifests. A source manifest can expose a `plugins` array whose entries include `id`, `title`, `description`, `version`, `author`/`authors`, `image`/`imageUrl`, `github`/`githubUrl`, `install`/`installUrl`/`manifestUrl`, and `readme`/`readmeUrl`. Installing from the store fetches the linked plugin manifest, validates it, registers it with the client registry, and persists the manifest locally; it does not execute plugin code on the signal server.
The Store tab consumes user-managed HTTP(S), `file://`, or absolute local-path source manifests. Local-path sources and entrypoints are read through the Electron desktop file bridge. A source manifest can expose a `plugins` array whose entries include `id`, `title`, `description`, `version`, `scope`, `author`/`authors`, `image`/`imageUrl`, `github`/`githubUrl`, `install`/`installUrl`/`manifestUrl`, and `readme`/`readmeUrl`. Installing a `scope: "server"` plugin fetches the linked plugin manifest, validates it, registers it with the client registry, and persists the basic install metadata as a server plugin requirement. Required server plugins are installed on each member client when that chat server opens; optional server plugins stay listed as server requirements but are not auto-installed. Installing a `scope: "client"` plugin persists it locally for the current desktop/browser client.
Runtime activation is explicit. `PluginHostService.activateReadyPlugins()` imports browser-safe plugin entrypoints from URL-resolvable manifests, passes a frozen `TojuClientPluginApi`, runs `activate`, then runs `ready` after the load-order pass. `deactivate` runs during unload/reload, disposables are cleaned in reverse order, and UI contributions are removed by plugin id.
The server-side plugin support API is metadata-only. The signal server can keep plugin id, requirement status, version range, install/source URLs, and the validated manifest snapshot needed for member clients to install required plugins. Plugin `serverData` API calls are handled as local per-user/per-server client state; HTTP plugin data persistence on the signal server returns `PLUGIN_DATA_DISABLED`.
Plugin data that belongs to the current client uses the Electron database when the desktop bridge is available. The plugin runtime writes `api.clientData.*` and `api.serverData.*` records to Electron's dedicated `plugin_data` table, with renderer localStorage as the browser fallback. The legacy synchronous `api.storage.*` surface remains local and mirrors writes to the same Electron table when possible; plugins that need guaranteed database reads should use the async `api.clientData.*` methods.
Plugins can communicate over a plugin-only message bus through `api.messageBus`. It sends `plugin-message-bus` data-channel events that are ignored by the normal chat message reducers/effects, can target a peer or broadcast to connected users, and can include a bounded latest-message snapshot filtered by channel, timestamp, and deletion state.
Desktop plugin preferences that belong to the local user, including capability grants, disabled plugin ids, and previously activated plugin ids, are persisted through Electron's local database meta table with renderer localStorage as the browser fallback.
Runtime activation is explicit. `PluginHostService.activateReadyPlugins()` imports browser-safe plugin entrypoints from URL-resolvable manifests, passes a frozen `TojuClientPluginApi`, runs `activate`, then runs `ready` after the load-order pass. Successfully activated plugin ids are remembered locally, and store-installed plugins are reactivated for the active server when their persisted manifests load again. `deactivate` runs during unload/reload, disposables are cleaned in reverse order, and UI contributions are removed by plugin id.
Plugins that need fully custom UI can call `api.ui.mountElement(id, { target, element, position })` with the `ui.dom` capability. The runtime tags mounted elements with plugin ownership metadata, replaces duplicate mounts for the same plugin/id pair, and removes remaining mounted elements when the plugin is unloaded.

View File

@@ -1,10 +1,12 @@
import {
Injectable,
computed,
inject,
signal
} from '@angular/core';
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
import type { PluginCapabilityId, TojuPluginManifest } from '../../../../shared-kernel';
import { PluginDesktopStateService } from './plugin-desktop-state.service';
const STORAGE_KEY_PLUGIN_CAPABILITIES = 'metoyou_plugin_capability_grants';
@@ -19,15 +21,20 @@ export class PluginCapabilityError extends Error {
export class PluginCapabilityService {
readonly grants = computed(() => this.grantsSignal());
private readonly desktopState = inject(PluginDesktopStateService);
private readonly grantsSignal = signal<Record<string, PluginCapabilityId[]>>(this.loadGrants());
constructor() {
void this.loadDesktopGrants();
}
grant(pluginId: string, capability: PluginCapabilityId): void {
this.grantsSignal.update((grants) => ({
...grants,
[pluginId]: Array.from(new Set([...(grants[pluginId] ?? []), capability])).sort()
}));
this.saveGrants();
void this.saveGrants();
}
grantAll(manifest: TojuPluginManifest): void {
@@ -36,7 +43,7 @@ export class PluginCapabilityService {
[manifest.id]: [...(manifest.capabilities ?? [])].sort()
}));
this.saveGrants();
void this.saveGrants();
}
revoke(pluginId: string, capability: PluginCapabilityId): void {
@@ -45,7 +52,7 @@ export class PluginCapabilityService {
[pluginId]: (grants[pluginId] ?? []).filter((entry) => entry !== capability)
}));
this.saveGrants();
void this.saveGrants();
}
revokeAll(pluginId: string): void {
@@ -55,7 +62,7 @@ export class PluginCapabilityService {
return next;
});
this.saveGrants();
void this.saveGrants();
}
has(pluginId: string, capability: PluginCapabilityId): boolean {
@@ -88,13 +95,23 @@ export class PluginCapabilityService {
}
}
private saveGrants(): void {
private async loadDesktopGrants(): Promise<void> {
const grants = await this.desktopState.readJson<Record<string, PluginCapabilityId[]>>(STORAGE_KEY_PLUGIN_CAPABILITIES, this.grantsSignal());
if (isGrantRecord(grants)) {
this.grantsSignal.set(grants);
}
}
private async saveGrants(): Promise<void> {
try {
localStorage.setItem(
getUserScopedStorageKey(STORAGE_KEY_PLUGIN_CAPABILITIES),
JSON.stringify(this.grantsSignal())
);
} catch {}
await this.desktopState.writeJson(STORAGE_KEY_PLUGIN_CAPABILITIES, this.grantsSignal());
}
}

View File

@@ -33,6 +33,7 @@ import type {
} from '../../domain/models/plugin-api.models';
import { PluginCapabilityService } from './plugin-capability.service';
import { PluginLoggerService } from './plugin-logger.service';
import { PluginMessageBusService } from './plugin-message-bus.service';
import { PluginStorageService } from './plugin-storage.service';
import { PluginUiRegistryService } from './plugin-ui-registry.service';
@@ -40,6 +41,7 @@ import { PluginUiRegistryService } from './plugin-ui-registry.service';
export class PluginClientApiService {
private readonly capabilities = inject(PluginCapabilityService);
private readonly logger = inject(PluginLoggerService);
private readonly messageBus = inject(PluginMessageBusService);
private readonly realtime = inject(RealtimeSessionFacade);
private readonly store = inject(Store);
private readonly storage = inject(PluginStorageService);
@@ -118,6 +120,20 @@ export class PluginClientApiService {
info: (message, data) => this.logger.info(pluginId, message, data),
warn: (message, data) => this.logger.warn(pluginId, message, data)
},
clientData: {
read: async (key) => {
requireCapability('storage.local');
return await this.storage.readClientData(pluginId, key);
},
remove: async (key) => {
requireCapability('storage.local');
await this.storage.removeClientData(pluginId, key);
},
write: async (key, value) => {
requireCapability('storage.local');
await this.storage.writeClientData(pluginId, key, value);
}
},
media: {
addCustomAudioStream: async (request) => {
requireCapability('media.addAudioStream');
@@ -170,6 +186,31 @@ export class PluginClientApiService {
this.store.dispatch(MessagesActions.syncMessages({ messages }));
}
},
messageBus: {
publish: (request) => {
requireCapability('events.p2p.publish');
if (request.includeLatestMessages) {
requireCapability('messages.read');
}
return this.messageBus.publish(pluginId, request);
},
sendLatestMessages: (request = {}) => {
requireCapability('events.p2p.publish');
requireCapability('messages.read');
return this.messageBus.sendLatestMessages(pluginId, request);
},
subscribe: (subscription) => {
requireCapability('events.p2p.subscribe');
if (subscription.replayLatest) {
requireCapability('messages.read');
}
return this.messageBus.subscribe(pluginId, subscription);
}
},
p2p: {
broadcastData: (eventName, payload) => {
requireCapability('p2p.data');

View File

@@ -0,0 +1,54 @@
import { Injectable, inject } from '@angular/core';
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
@Injectable({ providedIn: 'root' })
export class PluginDesktopStateService {
private readonly electronBridge = inject(ElectronBridgeService);
async readJson<TValue>(key: string, fallback: TValue): Promise<TValue> {
const raw = await this.readRaw(key);
if (!raw) {
return fallback;
}
try {
return JSON.parse(raw) as TValue;
} catch {
return fallback;
}
}
async writeJson(key: string, value: unknown): Promise<void> {
await this.writeRaw(key, JSON.stringify(value));
}
private async readRaw(key: string): Promise<string | null> {
const api = this.electronBridge.getApi();
if (api) {
return await api.query<string | null>({
type: 'get-meta',
payload: { key }
});
}
return localStorage.getItem(getUserScopedStorageKey(key));
}
private async writeRaw(key: string, value: string): Promise<void> {
const api = this.electronBridge.getApi();
if (api) {
await api.command({
type: 'save-meta',
payload: { key, value }
});
return;
}
localStorage.setItem(getUserScopedStorageKey(key), value);
}
}

View File

@@ -5,6 +5,7 @@ import type { LocalPluginDiscoveryResult } from '../../domain/models/plugin-runt
import { LocalPluginDiscoveryService } from '../../infrastructure/local-plugin-discovery.service';
import { PluginCapabilityService } from './plugin-capability.service';
import { PluginClientApiService } from './plugin-client-api.service';
import { PluginDesktopStateService } from './plugin-desktop-state.service';
import { PluginHostService } from './plugin-host.service';
import { PluginLoggerService } from './plugin-logger.service';
import { PluginRegistryService } from './plugin-registry.service';
@@ -99,6 +100,13 @@ function createHostService(readDiscoveryResult: () => LocalPluginDiscoveryResult
missing: vi.fn(() => [])
}
},
{
provide: PluginDesktopStateService,
useValue: {
readJson: vi.fn(async (_key: string, fallback: unknown) => fallback),
writeJson: vi.fn(async () => undefined)
}
},
{
provide: PluginClientApiService,
useValue: {

View File

@@ -1,5 +1,6 @@
import { Injectable, inject } from '@angular/core';
import { environment } from '../../../../../environments/environment';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import type { TojuPluginManifest } from '../../../../shared-kernel';
import {
DEVELOPMENT_PLUGIN_ENTRYPOINT,
@@ -18,6 +19,7 @@ import type {
} from '../../domain/models/plugin-runtime.models';
import { LocalPluginDiscoveryService } from '../../infrastructure/local-plugin-discovery.service';
import { PluginCapabilityService } from './plugin-capability.service';
import { PluginDesktopStateService } from './plugin-desktop-state.service';
import { PluginClientApiService } from './plugin-client-api.service';
import { PluginLoggerService } from './plugin-logger.service';
import { PluginRegistryService } from './plugin-registry.service';
@@ -25,21 +27,29 @@ import { PluginUiRegistryService } from './plugin-ui-registry.service';
interface ActivePluginRuntime {
context: TojuPluginActivationContext;
moduleObjectUrl?: string;
module: TojuClientPluginModule;
}
const STORAGE_KEY_PLUGIN_ACTIVATION = 'metoyou_plugin_activation_state';
@Injectable({ providedIn: 'root' })
export class PluginHostService {
private readonly apiFactory = inject(PluginClientApiService);
private readonly capabilities = inject(PluginCapabilityService);
private readonly desktopState = inject(PluginDesktopStateService);
private readonly electronBridge = inject(ElectronBridgeService, { optional: true });
private readonly localDiscovery = inject(LocalPluginDiscoveryService);
private readonly logger = inject(PluginLoggerService);
private readonly registry = inject(PluginRegistryService);
private readonly uiRegistry = inject(PluginUiRegistryService);
private readonly activePlugins = new Map<string, ActivePluginRuntime>();
private readonly activationStateReady: Promise<void>;
private activatedPluginIds = new Set<string>();
constructor() {
this.registerDevelopmentPlugin();
this.activationStateReady = this.loadActivationState();
}
registerLocalManifest(manifestValue: unknown, sourcePath?: string): RegisteredPlugin {
@@ -75,6 +85,8 @@ export class PluginHostService {
}
async activateReadyPlugins(): Promise<void> {
await this.activationStateReady;
const activated: TojuPluginActivationContext[] = [];
for (const manifest of this.registry.loadOrder().ordered) {
@@ -90,29 +102,92 @@ export class PluginHostService {
if (active) {
activated.push(active.context);
this.activatedPluginIds.add(active.context.pluginId);
}
}
for (const context of activated) {
const active = this.activePlugins.get(context.pluginId);
await this.saveActivationState();
if (!active?.module.ready) {
continue;
await this.runReadyHooks(activated);
}
try {
await active.module.ready(context);
this.registry.setState(context.pluginId, 'ready');
} catch (error) {
this.failPlugin(context.pluginId, error);
}
}
async activatePluginById(pluginId: string): Promise<void> {
await this.activationStateReady;
if (this.activePlugins.has(pluginId)) {
this.activatedPluginIds.add(pluginId);
await this.saveActivationState();
return;
}
async deactivatePlugin(pluginId: string): Promise<void> {
const entry = this.registry.find(pluginId);
if (!entry?.enabled) {
return;
}
await this.activatePlugin(entry);
const active = this.activePlugins.get(pluginId);
if (!active) {
return;
}
this.activatedPluginIds.add(pluginId);
await this.saveActivationState();
await this.runReadyHooks([active.context]);
}
async rememberActivation(pluginId: string): Promise<void> {
await this.activationStateReady;
this.activatedPluginIds.add(pluginId);
await this.saveActivationState();
}
async activatePersistedPlugins(): Promise<void> {
await this.activationStateReady;
const activated: TojuPluginActivationContext[] = [];
for (const manifest of this.registry.loadOrder().ordered) {
if (!this.activatedPluginIds.has(manifest.id) || this.activePlugins.has(manifest.id)) {
continue;
}
const entry = this.registry.find(manifest.id);
if (!entry?.enabled) {
continue;
}
await this.activatePlugin(entry);
const active = this.activePlugins.get(manifest.id);
if (active) {
activated.push(active.context);
}
}
await this.runReadyHooks(activated);
}
isPluginActive(pluginId: string): boolean {
return this.activePlugins.has(pluginId);
}
async deactivatePlugin(pluginId: string, options: { forgetActivation?: boolean } = {}): Promise<void> {
await this.activationStateReady;
const active = this.activePlugins.get(pluginId);
if (!active) {
if (options.forgetActivation) {
this.activatedPluginIds.delete(pluginId);
await this.saveActivationState();
}
this.registry.setState(pluginId, 'unloaded');
this.uiRegistry.unregisterPlugin(pluginId);
return;
@@ -132,6 +207,13 @@ export class PluginHostService {
this.uiRegistry.unregisterPlugin(pluginId);
this.activePlugins.delete(pluginId);
this.revokeModuleObjectUrl(pluginId);
if (options.forgetActivation) {
this.activatedPluginIds.delete(pluginId);
await this.saveActivationState();
}
this.registry.setState(pluginId, 'unloaded');
}
@@ -150,6 +232,11 @@ export class PluginHostService {
if (entry?.enabled) {
await this.activatePlugin(entry);
if (this.activePlugins.has(pluginId)) {
this.activatedPluginIds.add(pluginId);
await this.saveActivationState();
}
}
}
@@ -161,6 +248,23 @@ export class PluginHostService {
this.registry.setState(pluginId, 'failed');
}
private async runReadyHooks(contexts: TojuPluginActivationContext[]): Promise<void> {
for (const context of contexts) {
const active = this.activePlugins.get(context.pluginId);
if (!active?.module.ready) {
continue;
}
try {
await active.module.ready(context);
this.registry.setState(context.pluginId, 'ready');
} catch (error) {
this.failPlugin(context.pluginId, error);
}
}
}
private async activatePlugin(entry: RegisteredPlugin): Promise<void> {
const manifest = entry.manifest;
const missingCapabilities = this.capabilities.missing(manifest);
@@ -179,7 +283,7 @@ export class PluginHostService {
this.registry.setState(manifest.id, 'loading');
try {
const module = await this.loadPluginModule(manifest, entry.sourcePath);
const { module, moduleObjectUrl } = await this.loadPluginModule(manifest, entry.sourcePath);
const context: TojuPluginActivationContext = {
api: this.apiFactory.createApi(manifest),
manifest,
@@ -188,7 +292,7 @@ export class PluginHostService {
};
await module.activate?.(context);
this.activePlugins.set(manifest.id, { context, module });
this.activePlugins.set(manifest.id, { context, module, moduleObjectUrl });
this.registry.setState(manifest.id, 'loaded');
this.logger.info(manifest.id, 'Plugin activated');
} catch (error) {
@@ -203,14 +307,51 @@ export class PluginHostService {
this.logger.error(pluginId, message, error);
this.uiRegistry.unregisterPlugin(pluginId);
this.activePlugins.delete(pluginId);
this.revokeModuleObjectUrl(pluginId);
}
private async loadPluginModule(manifest: TojuPluginManifest, sourcePath?: string): Promise<TojuClientPluginModule> {
private async loadPluginModule(
manifest: TojuPluginManifest,
sourcePath?: string
): Promise<{ module: TojuClientPluginModule; moduleObjectUrl?: string }> {
if (manifest.entrypoint === DEVELOPMENT_PLUGIN_ENTRYPOINT) {
return DEVELOPMENT_PLUGIN_MODULE;
return { module: DEVELOPMENT_PLUGIN_MODULE };
}
return await import(/* @vite-ignore */ this.resolveEntrypoint(manifest, sourcePath)) as TojuClientPluginModule;
const entrypointUrl = this.resolveEntrypoint(manifest, sourcePath);
if (entrypointUrl.startsWith('file://')) {
const moduleObjectUrl = await this.createLocalModuleObjectUrl(entrypointUrl);
const module = await import(/* @vite-ignore */ moduleObjectUrl) as TojuClientPluginModule;
return { module, moduleObjectUrl };
}
return {
module: await import(/* @vite-ignore */ entrypointUrl) as TojuClientPluginModule
};
}
private async createLocalModuleObjectUrl(entrypointUrl: string): Promise<string> {
const api = this.electronBridge?.getApi();
if (!api) {
throw new Error('Local plugin entrypoints require the desktop app');
}
const base64Data = await api.readFile(fileUrlToPath(entrypointUrl));
const bytes = Uint8Array.from(atob(base64Data), (character) => character.charCodeAt(0));
const source = new TextDecoder().decode(bytes);
return URL.createObjectURL(new Blob([source], { type: 'text/javascript' }));
}
private revokeModuleObjectUrl(pluginId: string): void {
const moduleObjectUrl = this.activePlugins.get(pluginId)?.moduleObjectUrl;
if (moduleObjectUrl) {
URL.revokeObjectURL(moduleObjectUrl);
}
}
private registerDevelopmentPlugin(): void {
@@ -225,6 +366,22 @@ export class PluginHostService {
}
}
private async loadActivationState(): Promise<void> {
const state = await this.desktopState.readJson<{ activatedPluginIds?: string[] }>(STORAGE_KEY_PLUGIN_ACTIVATION, {});
this.activatedPluginIds = new Set(
Array.isArray(state.activatedPluginIds)
? state.activatedPluginIds.filter((pluginId): pluginId is string => typeof pluginId === 'string')
: []
);
}
private async saveActivationState(): Promise<void> {
await this.desktopState.writeJson(STORAGE_KEY_PLUGIN_ACTIVATION, {
activatedPluginIds: Array.from(this.activatedPluginIds).sort()
});
}
private resolveEntrypoint(manifest: TojuPluginManifest, sourcePath?: string): string {
if (!manifest.entrypoint) {
throw new Error('Plugin entrypoint is missing');
@@ -246,6 +403,17 @@ export class PluginHostService {
}
}
function fileUrlToPath(fileUrl: string): string {
const url = new URL(fileUrl);
const decodedPath = decodeURIComponent(url.pathname);
if (/^\/[A-Za-z]:\//.test(decodedPath)) {
return decodedPath.slice(1).replace(/\//g, '\\');
}
return decodedPath;
}
function safeDispose(disposable: TojuPluginDisposable, pluginId: string, logger: PluginLoggerService): void {
try {
disposable.dispose();

View File

@@ -0,0 +1,236 @@
import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { Subscription } from 'rxjs';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import type {
ChatEvent,
Message,
User
} from '../../../../shared-kernel';
import { selectCurrentRoomMessages } from '../../../../store/messages/messages.selectors';
import { selectCurrentRoomId } from '../../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import type {
PluginApiMessageBusEnvelope,
PluginApiMessageBusLatestRequest,
PluginApiMessageBusPublishRequest,
PluginApiMessageBusSubscription,
TojuPluginDisposable
} from '../../domain/models/plugin-api.models';
const DEFAULT_LATEST_MESSAGE_LIMIT = 50;
const MAX_LATEST_MESSAGE_LIMIT = 250;
const LATEST_MESSAGES_TOPIC = 'latest-messages';
@Injectable({ providedIn: 'root' })
export class PluginMessageBusService {
private readonly realtime = inject(RealtimeSessionFacade);
private readonly store = inject(Store);
private readonly currentMessages = this.store.selectSignal(selectCurrentRoomMessages);
private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private readonly localSubscriptions = new Map<string, Set<PluginApiMessageBusSubscription>>();
publish(pluginId: string, request: PluginApiMessageBusPublishRequest): PluginApiMessageBusEnvelope {
const envelope = this.createEnvelope(pluginId, request.topic, request.payload, request, request.includeLatestMessages === true);
this.sendEnvelope(envelope, request.targetPeerId);
if (request.includeSelf) {
this.dispatchLocal(envelope);
}
return envelope;
}
sendLatestMessages(pluginId: string, request: PluginApiMessageBusLatestRequest = {}): PluginApiMessageBusEnvelope {
const envelope = this.createEnvelope(pluginId, request.topic ?? LATEST_MESSAGES_TOPIC, undefined, request, true);
this.sendEnvelope(envelope, request.targetPeerId);
return envelope;
}
subscribe(pluginId: string, subscription: PluginApiMessageBusSubscription): TojuPluginDisposable {
const pluginSubscriptions = this.localSubscriptions.get(pluginId) ?? new Set<PluginApiMessageBusSubscription>();
const realtimeSubscription = new Subscription();
pluginSubscriptions.add(subscription);
this.localSubscriptions.set(pluginId, pluginSubscriptions);
realtimeSubscription.add(this.realtime.onMessageReceived.subscribe((event) => {
const envelope = readPluginMessageBusEnvelope(event);
if (!envelope || envelope.pluginId !== pluginId || !matchesSubscription(envelope, subscription)) {
return;
}
subscription.handler(envelope);
}));
if (subscription.replayLatest) {
subscription.handler(this.createEnvelope(
pluginId,
subscription.topic ?? LATEST_MESSAGES_TOPIC,
undefined,
{
channelId: subscription.channelId,
limit: subscription.latestMessageLimit
},
true
));
}
return {
dispose: () => {
realtimeSubscription.unsubscribe();
pluginSubscriptions.delete(subscription);
if (pluginSubscriptions.size === 0) {
this.localSubscriptions.delete(pluginId);
}
}
};
}
private createEnvelope(
pluginId: string,
topic: string,
payload: unknown,
request: PluginApiMessageBusLatestRequest,
includeMessages: boolean
): PluginApiMessageBusEnvelope {
const currentUser = this.currentUser();
const envelope: PluginApiMessageBusEnvelope = {
eventId: createId(),
pluginId,
roomId: this.requireRoomId(),
sentAt: Date.now(),
sourceUserId: readUserId(currentUser),
topic
};
if (request.channelId) {
envelope.channelId = request.channelId;
}
if (payload !== undefined) {
envelope.payload = payload;
}
if (includeMessages) {
envelope.messages = this.latestMessages(request);
}
return envelope;
}
private latestMessages(request: PluginApiMessageBusLatestRequest): Message[] {
const limit = clampLimit(request.limit);
const sinceTimestamp = typeof request.sinceTimestamp === 'number' ? request.sinceTimestamp : null;
return this.currentMessages()
.filter((message) => !request.channelId || message.channelId === request.channelId)
.filter((message) => request.includeDeleted || !message.isDeleted)
.filter((message) => sinceTimestamp === null || message.timestamp > sinceTimestamp)
.slice(-limit);
}
private sendEnvelope(envelope: PluginApiMessageBusEnvelope, targetPeerId?: string): void {
const event: ChatEvent = {
pluginMessage: envelope,
roomId: envelope.roomId,
timestamp: envelope.sentAt,
type: 'plugin-message-bus'
};
if (targetPeerId) {
this.realtime.sendToPeer(targetPeerId, event);
return;
}
this.realtime.broadcastMessage(event);
}
private dispatchLocal(envelope: PluginApiMessageBusEnvelope): void {
for (const subscription of this.localSubscriptions.get(envelope.pluginId) ?? []) {
if (matchesSubscription(envelope, subscription)) {
subscription.handler(envelope);
}
}
}
private requireRoomId(): string {
const roomId = this.currentRoomId();
if (!roomId) {
throw new Error('No active server');
}
return roomId;
}
}
function readPluginMessageBusEnvelope(event: ChatEvent): PluginApiMessageBusEnvelope | null {
if (event.type !== 'plugin-message-bus' || !isRecord(event.pluginMessage)) {
return null;
}
const envelope = event.pluginMessage;
if (typeof envelope['eventId'] !== 'string'
|| typeof envelope['pluginId'] !== 'string'
|| typeof envelope['roomId'] !== 'string'
|| typeof envelope['sentAt'] !== 'number'
|| typeof envelope['topic'] !== 'string') {
return null;
}
return {
channelId: typeof envelope['channelId'] === 'string' ? envelope['channelId'] : undefined,
eventId: envelope['eventId'],
messages: Array.isArray(envelope['messages']) ? envelope['messages'].filter(isMessage) : undefined,
payload: envelope['payload'],
pluginId: envelope['pluginId'],
roomId: envelope['roomId'],
sentAt: envelope['sentAt'],
sourcePeerId: event.fromPeerId,
sourceUserId: typeof envelope['sourceUserId'] === 'string' ? envelope['sourceUserId'] : undefined,
topic: envelope['topic']
};
}
function matchesSubscription(envelope: PluginApiMessageBusEnvelope, subscription: PluginApiMessageBusSubscription): boolean {
return (!subscription.topic || subscription.topic === envelope.topic)
&& (!subscription.channelId || subscription.channelId === envelope.channelId);
}
function clampLimit(limit: number | undefined): number {
if (typeof limit !== 'number' || !Number.isFinite(limit)) {
return DEFAULT_LATEST_MESSAGE_LIMIT;
}
return Math.max(1, Math.min(MAX_LATEST_MESSAGE_LIMIT, Math.floor(limit)));
}
function readUserId(user: User | null): string | undefined {
return user?.oderId || user?.id || undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
function isMessage(value: unknown): value is Message {
return isRecord(value)
&& typeof value['id'] === 'string'
&& typeof value['roomId'] === 'string'
&& typeof value['senderId'] === 'string'
&& typeof value['content'] === 'string'
&& typeof value['timestamp'] === 'number';
}
function createId(): string {
return globalThis.crypto?.randomUUID?.() ?? `plugin-bus-${Date.now()}-${Math.random().toString(36)
.slice(2)}`;
}

View File

@@ -2,6 +2,7 @@ import {
Injectable,
type Signal,
computed,
inject,
signal
} from '@angular/core';
import {
@@ -11,6 +12,9 @@ import {
} from '../../domain/models/plugin-runtime.models';
import { resolvePluginLoadOrder } from '../../domain/logic/plugin-dependency-resolver.logic';
import { validateTojuPluginManifest } from '../../domain/logic/plugin-manifest-validation.logic';
import { PluginDesktopStateService } from './plugin-desktop-state.service';
const STORAGE_KEY_PLUGIN_REGISTRY_STATE = 'metoyou_plugin_registry_state';
@Injectable({ providedIn: 'root' })
export class PluginRegistryService {
@@ -18,7 +22,9 @@ export class PluginRegistryService {
readonly enabledEntries: Signal<RegisteredPlugin[]>;
readonly loadOrder: Signal<PluginLoadOrderResult>;
private readonly desktopState = inject(PluginDesktopStateService);
private readonly entriesSignal = signal<RegisteredPlugin[]>([]);
private disabledPluginIds = new Set<string>();
constructor() {
this.entries = this.entriesSignal.asReadonly();
@@ -26,6 +32,8 @@ export class PluginRegistryService {
this.loadOrder = computed<PluginLoadOrderResult>(() =>
resolvePluginLoadOrder(this.entries().map((entry) => ({ enabled: entry.enabled, manifest: entry.manifest })))
);
void this.loadRegistryState();
}
clear(): void {
@@ -41,7 +49,7 @@ export class PluginRegistryService {
const existingIndex = this.entries().findIndex((entry) => entry.manifest.id === validation.manifest?.id);
const entry: RegisteredPlugin = {
enabled: true,
enabled: !this.disabledPluginIds.has(validation.manifest.id),
manifest: validation.manifest,
sourcePath,
state: validation.valid ? 'validated' : 'blocked',
@@ -59,6 +67,12 @@ export class PluginRegistryService {
}
setEnabled(pluginId: string, enabled: boolean): void {
if (enabled) {
this.disabledPluginIds.delete(pluginId);
} else {
this.disabledPluginIds.add(pluginId);
}
this.entriesSignal.update((entries) => entries.map((entry) => entry.manifest.id === pluginId
? {
...entry,
@@ -68,6 +82,7 @@ export class PluginRegistryService {
: entry));
this.syncLoadState();
void this.saveRegistryState();
}
unregister(pluginId: string): void {
@@ -114,4 +129,27 @@ export class PluginRegistryService {
};
}));
}
private async loadRegistryState(): Promise<void> {
const state = await this.desktopState.readJson<{ disabledPluginIds?: string[] }>(STORAGE_KEY_PLUGIN_REGISTRY_STATE, {});
this.disabledPluginIds = new Set(
Array.isArray(state.disabledPluginIds)
? state.disabledPluginIds.filter((pluginId): pluginId is string => typeof pluginId === 'string')
: []
);
this.entriesSignal.update((entries) => entries.map((entry) => ({
...entry,
enabled: !this.disabledPluginIds.has(entry.manifest.id)
})));
this.syncLoadState();
}
private async saveRegistryState(): Promise<void> {
await this.desktopState.writeJson(STORAGE_KEY_PLUGIN_REGISTRY_STATE, {
disabledPluginIds: Array.from(this.disabledPluginIds).sort()
});
}
}

View File

@@ -5,12 +5,16 @@ import type {
PluginEventDefinitionSummary,
PluginRequirementStatus,
PluginRequirementSummary,
PluginRequirementsSnapshot
PluginRequirementsSnapshot,
TojuPluginManifest
} from '../../../../shared-kernel';
export interface UpsertPluginRequirementRequest {
actorUserId: string;
installUrl?: string;
manifest?: TojuPluginManifest;
reason?: string;
sourceUrl?: string;
status: PluginRequirementStatus;
versionRange?: string;
}
@@ -44,6 +48,13 @@ export class PluginRequirementService {
);
}
deleteRequirement(apiBaseUrl: string, serverId: string, pluginId: string, actorUserId: string): Observable<{ ok: boolean }> {
return this.http.delete<{ ok: boolean }>(
`${this.apiBase(apiBaseUrl)}/servers/${encodeURIComponent(serverId)}/plugins/${encodeURIComponent(pluginId)}/requirement`,
{ body: { actorUserId } }
);
}
upsertEventDefinition(
apiBaseUrl: string,
serverId: string,

View File

@@ -1,30 +1,19 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { firstValueFrom } from 'rxjs';
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
import { ServerDirectoryFacade } from '../../../server-directory';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { selectCurrentRoomId } from '../../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
const STORAGE_PREFIX_PLUGIN_LOCAL = 'metoyou_plugin_local';
const STORAGE_PREFIX_PLUGIN_SERVER_LOCAL = 'metoyou_plugin_server_local';
interface PluginDataResponse {
record?: {
value: unknown;
};
records?: {
value: unknown;
}[];
}
type PluginDataScope = 'local' | 'server';
@Injectable({ providedIn: 'root' })
export class PluginStorageService {
private readonly http = inject(HttpClient);
private readonly serverDirectory = inject(ServerDirectoryFacade);
private readonly electronBridge = inject(ElectronBridgeService);
private readonly store = inject(Store);
private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
getLocal(pluginId: string, key: string): unknown {
return this.read(`${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`);
@@ -32,61 +21,113 @@ export class PluginStorageService {
removeLocal(pluginId: string, key: string): void {
localStorage.removeItem(getUserScopedStorageKey(`${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`));
void this.deleteFromClientDatabase(pluginId, 'local', key);
}
setLocal(pluginId: string, key: string, value: unknown): void {
this.write(`${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`, value);
void this.writeToClientDatabase(pluginId, 'local', key, value);
}
async readClientData(pluginId: string, key: string): Promise<unknown> {
return await this.readScopedData(pluginId, 'local', key);
}
async removeClientData(pluginId: string, key: string): Promise<void> {
localStorage.removeItem(getUserScopedStorageKey(`${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`));
await this.deleteFromClientDatabase(pluginId, 'local', key);
}
async writeClientData(pluginId: string, key: string, value: unknown): Promise<void> {
this.write(`${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`, value);
await this.writeToClientDatabase(pluginId, 'local', key, value);
}
async readServerData(pluginId: string, key: string): Promise<unknown> {
const response = await firstValueFrom(this.http.get<PluginDataResponse>(`${this.pluginsApi(pluginId)}/data`, {
params: new HttpParams()
.set('key', key)
.set('scope', 'server')
.set('userId', this.requireActorUserId())
}));
return response.records?.[0]?.value ?? null;
return await this.readScopedData(pluginId, 'server', key, this.requireRoomId());
}
async removeServerData(pluginId: string, key: string): Promise<void> {
await firstValueFrom(this.http.delete(`${this.pluginsApi(pluginId)}/data/${encodeURIComponent(key)}`, {
body: {
actorUserId: this.requireActorUserId(),
scope: 'server'
}
}));
localStorage.removeItem(getUserScopedStorageKey(this.serverLocalKey(pluginId, key)));
await this.deleteFromClientDatabase(pluginId, 'server', key, this.requireRoomId());
}
async writeServerData(pluginId: string, key: string, value: unknown): Promise<void> {
await firstValueFrom(this.http.put<PluginDataResponse>(`${this.pluginsApi(pluginId)}/data/${encodeURIComponent(key)}`, {
actorUserId: this.requireActorUserId(),
scope: 'server',
value
}));
this.write(this.serverLocalKey(pluginId, key), value);
await this.writeToClientDatabase(pluginId, 'server', key, value, this.requireRoomId());
}
private pluginsApi(pluginId: string): string {
private async readScopedData(pluginId: string, scope: PluginDataScope, key: string, serverId?: string): Promise<unknown> {
const api = this.electronBridge.getApi();
if (api) {
return await api.query({
type: 'get-plugin-data',
payload: {
key,
pluginId,
scope,
serverId
}
});
}
return this.read(scope === 'server'
? this.serverLocalKey(pluginId, key)
: `${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`);
}
private async writeToClientDatabase(pluginId: string, scope: PluginDataScope, key: string, value: unknown, serverId?: string): Promise<void> {
const api = this.electronBridge.getApi();
if (!api) {
return;
}
await api.command({
type: 'save-plugin-data',
payload: {
key,
pluginId,
scope,
serverId,
value
}
});
}
private async deleteFromClientDatabase(pluginId: string, scope: PluginDataScope, key: string, serverId?: string): Promise<void> {
const api = this.electronBridge.getApi();
if (!api) {
return;
}
await api.command({
type: 'delete-plugin-data',
payload: {
key,
pluginId,
scope,
serverId
}
});
}
private serverLocalKey(pluginId: string, key: string): string {
const roomId = this.requireRoomId();
return `${STORAGE_PREFIX_PLUGIN_SERVER_LOCAL}:${roomId}:${pluginId}:${key}`;
}
private requireRoomId(): string {
const roomId = this.currentRoomId();
if (!roomId) {
throw new Error('No active server for plugin server data');
}
const apiBase = this.serverDirectory.getApiBaseUrl();
return `${apiBase}/servers/${encodeURIComponent(roomId)}/plugins/${encodeURIComponent(pluginId)}`;
}
private requireActorUserId(): string {
const user = this.currentUser();
const userId = user?.oderId || user?.id;
if (!userId) {
throw new Error('No current user for plugin server data');
}
return userId;
return roomId;
}
private read(key: string): unknown {

View File

@@ -1,7 +1,10 @@
import { Injector } from '@angular/core';
import type { TojuPluginManifest } from '../../../../shared-kernel';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { PluginStoreService } from './plugin-store.service';
import { PluginHostService } from './plugin-host.service';
import { PluginDesktopStateService } from './plugin-desktop-state.service';
import { PluginRequirementService } from './plugin-requirement.service';
import { PluginRegistryService } from './plugin-registry.service';
import type { PluginStoreEntry } from '../../domain/models/plugin-store.models';
@@ -71,6 +74,40 @@ describe('PluginStoreService', () => {
]);
});
it('accepts local source manifest paths and resolves relative file links', async () => {
const localSourceManifest = {
plugins: [
{
description: 'Local plugin source.',
id: 'example.local-plugin',
image: './icon.svg',
install: './toju-plugin.json',
readme: './README.md',
title: 'Local Plugin',
version: '1.0.0'
}
],
title: 'Local Plugins'
};
const readFile = vi.fn(async () => toBase64(JSON.stringify(localSourceManifest)));
const service = createService(registerLocalManifest, unregister, { readFile });
await service.addSourceUrl('/home/ludde/Desktop/TestPlugin/plugin-source.json');
expect(fetchMock).not.toHaveBeenCalled();
expect(readFile).toHaveBeenCalledWith('/home/ludde/Desktop/TestPlugin/plugin-source.json');
expect(service.sourceUrls()).toEqual(['file:///home/ludde/Desktop/TestPlugin/plugin-source.json']);
expect(service.availablePlugins()).toEqual([
expect.objectContaining({
id: 'example.local-plugin',
imageUrl: 'file:///home/ludde/Desktop/TestPlugin/icon.svg',
installUrl: 'file:///home/ludde/Desktop/TestPlugin/toju-plugin.json',
readmeUrl: 'file:///home/ludde/Desktop/TestPlugin/README.md',
sourceTitle: 'Local Plugins'
})
]);
});
it('installs, detects updates, and uninstalls store plugins', async () => {
const manifest = createManifest({ version: '1.0.0' });
const plugin = createStoreEntry({ version: '1.0.0' });
@@ -86,7 +123,7 @@ describe('PluginStoreService', () => {
expect(service.getActionLabel(plugin)).toBe('Uninstall');
expect(service.getActionLabel(createStoreEntry({ version: '1.1.0' }))).toBe('Update');
service.uninstallPlugin(plugin.id);
await service.uninstallPlugin(plugin.id);
expect(unregister).toHaveBeenCalledWith(plugin.id);
expect(service.installedPlugins()).toEqual([]);
@@ -111,18 +148,40 @@ describe('PluginStoreService', () => {
function createService(
registerLocalManifest: ReturnType<typeof vi.fn>,
unregister: ReturnType<typeof vi.fn>
unregister: ReturnType<typeof vi.fn>,
electronApi: { readFile: (filePath: string) => Promise<string> } | null = null
): PluginStoreService {
const injector = Injector.create({
providers: [
PluginStoreService,
{
provide: ElectronBridgeService,
useValue: {
getApi: vi.fn(() => electronApi)
}
},
{
provide: PluginHostService,
useValue: { registerLocalManifest }
useValue: {
activatePersistedPlugins: vi.fn(async () => {}),
deactivatePlugin: vi.fn(async () => {}),
registerLocalManifest
}
},
{
provide: PluginDesktopStateService,
useValue: {
readJson: vi.fn(async (_key: string, fallback: unknown) => fallback),
writeJson: vi.fn(async () => undefined)
}
},
{
provide: PluginRegistryService,
useValue: { unregister }
},
{
provide: PluginRequirementService,
useValue: {}
}
]
});
@@ -130,6 +189,10 @@ function createService(
return injector.get(PluginStoreService);
}
function toBase64(value: string): string {
return Buffer.from(value, 'utf8').toString('base64');
}
function createManifest(overrides: Partial<TojuPluginManifest> = {}): TojuPluginManifest {
return {
apiVersion: '1.0.0',

View File

@@ -1,22 +1,40 @@
/* 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 { firstValueFrom } from 'rxjs';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
import type { TojuPluginManifest } from '../../../../shared-kernel';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import type {
PluginRequirementSummary,
TojuPluginInstallScope,
TojuPluginManifest
} from '../../../../shared-kernel';
import { selectCurrentRoomId, selectCurrentRoomName } from '../../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { ServerDirectoryFacade } from '../../../server-directory';
import { getPluginInstallScope } from '../../domain/logic/plugin-install-scope.logic';
import { validateTojuPluginManifest } from '../../domain/logic/plugin-manifest-validation.logic';
import type {
InstalledStorePlugin,
PersistedPluginStoreState,
PluginStoreEntry,
PluginStoreInstallState,
PluginStoreActionLabel,
PluginStoreReadme,
PluginStoreSourceResult
} from '../../domain/models/plugin-store.models';
import { PluginHostService } from './plugin-host.service';
import { PluginDesktopStateService } from './plugin-desktop-state.service';
import { PluginRequirementService } from './plugin-requirement.service';
import { PluginRegistryService } from './plugin-registry.service';
const STORE_SCHEMA_VERSION = 1;
@@ -26,34 +44,78 @@ const DEFAULT_STORE_STATE: PersistedPluginStoreState = {
sourceUrls: []
};
export interface PluginStoreInstallOptions {
activate?: boolean;
manifest?: TojuPluginManifest;
optional?: boolean;
serverId?: string;
}
@Injectable({ providedIn: 'root' })
export class PluginStoreService {
private readonly electronBridge = inject(ElectronBridgeService);
private readonly desktopState = inject(PluginDesktopStateService);
private readonly destroyRef = inject(DestroyRef);
private readonly host = inject(PluginHostService);
private readonly pluginRequirements = inject(PluginRequirementService);
private readonly realtime = inject(RealtimeSessionFacade, { optional: true });
private readonly registry = inject(PluginRegistryService);
private readonly serverDirectory = inject(ServerDirectoryFacade, { optional: true });
private readonly store = inject(Store, { optional: true });
private readonly currentRoomId = this.store?.selectSignal(selectCurrentRoomId) ?? null;
private readonly currentRoomName = this.store?.selectSignal(selectCurrentRoomName) ?? null;
private readonly currentUser = this.store?.selectSignal(selectCurrentUser) ?? null;
private readonly sourceUrlsSignal = signal<string[]>([]);
private readonly sourcesSignal = signal<PluginStoreSourceResult[]>([]);
private readonly installedPluginsSignal = signal<InstalledStorePlugin[]>([]);
private readonly clientInstalledPluginsSignal = signal<InstalledStorePlugin[]>([]);
private readonly serverInstalledPluginsSignal = signal<InstalledStorePlugin[]>([]);
private readonly loadingSignal = signal(false);
private refreshAbortController: AbortController | null = null;
private refreshVersion = 0;
private installedLoadVersion = 0;
private stateMutated = false;
readonly sourceUrls = this.sourceUrlsSignal.asReadonly();
readonly sources = this.sourcesSignal.asReadonly();
readonly installedPlugins = this.installedPluginsSignal.asReadonly();
readonly installedPlugins = computed(() => {
const installedPlugins = this.clientInstalledPluginsSignal().concat(this.serverInstalledPluginsSignal());
return installedPlugins.sort(sortInstalledPlugins);
});
readonly isLoading = this.loadingSignal.asReadonly();
readonly availablePlugins = computed(() => this.sources().flatMap((source) => source.plugins));
readonly hasActiveServerInstallScope = computed(() => !!this.currentRoomId?.());
readonly installedById = computed(() => new Map(this.installedPlugins().map((plugin) => [plugin.manifest.id, plugin])));
readonly installScopeLabel = computed(() => this.currentRoomName?.() || 'this device');
constructor() {
const state = this.loadState();
this.sourceUrlsSignal.set(state.sourceUrls);
this.installedPluginsSignal.set(state.installedPlugins);
this.hydrateInstalledPlugins(state.installedPlugins);
void this.applyInstalledPlugins(state.installedPlugins, 'client');
if (this.currentRoomId && this.currentUser && this.serverDirectory) {
effect(() => {
const roomId = this.currentRoomId?.() ?? null;
const actorUserId = this.currentActorUserId();
void this.loadInstalledPluginsForScope(roomId, actorUserId);
});
this.realtime?.onSignalingMessage
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((message) => {
if (isPluginRequirementsChangedMessage(message) && message.serverId === this.currentRoomId?.()) {
void this.loadInstalledPluginsForScope(message.serverId, this.currentActorUserId());
}
});
}
void this.loadDesktopState();
}
async addSourceUrl(rawUrl: string): Promise<void> {
const sourceUrl = normalizeRemoteUrl(rawUrl, 'Plugin source URL');
const sourceUrl = normalizeSourceUrl(rawUrl, 'Plugin source URL');
if (this.sourceUrls().includes(sourceUrl)) {
throw new Error('Plugin source already exists');
@@ -94,41 +156,80 @@ export class PluginStoreService {
}
}
async installPlugin(plugin: PluginStoreEntry): Promise<InstalledStorePlugin> {
async installPlugin(plugin: PluginStoreEntry, options: PluginStoreInstallOptions = {}): Promise<InstalledStorePlugin> {
if (!plugin.installUrl) {
throw new Error('Plugin does not provide an install manifest URL');
}
const manifest = await this.fetchPluginManifest(plugin.installUrl);
const registered = this.host.registerLocalManifest(manifest, plugin.installUrl);
const manifest = options.manifest ?? await this.fetchPluginManifest(plugin.installUrl);
const installScope = getPluginInstallScope(manifest);
const targetServerId = installScope === 'server' ? (options.serverId ?? this.currentRoomId?.() ?? null) : null;
if (installScope === 'server' && !targetServerId) {
throw new Error('Open a chat server before installing server-scoped plugins');
}
const now = Date.now();
const existing = this.installedById().get(registered.manifest.id);
const currentScopePlugins = installScope === 'server'
? await this.installedPluginsForServer(targetServerId)
: this.installedPluginsForScope(installScope);
const existing = currentScopePlugins.find((candidate) => candidate.manifest.id === manifest.id);
const installedPlugin: InstalledStorePlugin = {
installedAt: existing?.installedAt ?? now,
installUrl: plugin.installUrl,
manifest: registered.manifest,
manifest,
sourceUrl: plugin.sourceUrl,
updatedAt: now
};
const nextInstalledPlugins = currentScopePlugins
.filter((candidate) => candidate.manifest.id !== manifest.id)
.concat(installedPlugin)
.sort(sortInstalledPlugins);
this.installedPluginsSignal.update((installedPlugins) => {
const existingPlugins = installedPlugins.filter((candidate) => candidate.manifest.id !== registered.manifest.id);
if (installScope === 'server') {
await this.saveServerPluginRequirement(installedPlugin, targetServerId, options.optional === true ? 'optional' : 'required');
} else {
await this.persistInstalledPlugins(nextInstalledPlugins, installScope);
}
return [...existingPlugins, installedPlugin].sort(sortInstalledPlugins);
});
if (installScope === 'client' || targetServerId === this.currentRoomId?.()) {
this.host.registerLocalManifest(manifest, plugin.installUrl);
this.saveState();
if (installScope === 'client' || options.optional !== true) {
this.setInstalledPluginsForScope(installScope, nextInstalledPlugins);
}
if (options.activate) {
await this.host.activatePluginById(manifest.id);
}
} else if (options.activate) {
await this.host.rememberActivation(manifest.id);
}
return installedPlugin;
}
uninstallPlugin(pluginId: string): void {
this.registry.unregister(pluginId);
this.installedPluginsSignal.update((installedPlugins) =>
installedPlugins.filter((installedPlugin) => installedPlugin.manifest.id !== pluginId)
);
async loadInstallManifest(plugin: PluginStoreEntry): Promise<TojuPluginManifest> {
if (!plugin.installUrl) {
throw new Error('Plugin does not provide an install manifest URL');
}
this.saveState();
return await this.fetchPluginManifest(plugin.installUrl);
}
async uninstallPlugin(pluginId: string, scope?: TojuPluginInstallScope): Promise<void> {
const installScope = scope ?? this.findInstalledPluginScope(pluginId) ?? 'client';
const nextInstalledPlugins = this.installedPluginsForScope(installScope).filter((installedPlugin) => installedPlugin.manifest.id !== pluginId);
if (installScope === 'server') {
await this.deleteServerPluginRequirement(pluginId);
} else {
await this.persistInstalledPlugins(nextInstalledPlugins, installScope);
}
await this.host.deactivatePlugin(pluginId, { forgetActivation: true });
this.registry.unregister(pluginId);
this.setInstalledPluginsForScope(installScope, nextInstalledPlugins);
}
async loadReadme(plugin: PluginStoreEntry): Promise<PluginStoreReadme> {
@@ -136,14 +237,8 @@ export class PluginStoreService {
throw new Error('Plugin does not provide a readme URL');
}
const response = await fetch(plugin.readmeUrl, { headers: { Accept: 'text/markdown,text/plain,*/*' } });
if (!response.ok) {
throw new Error(`Unable to load readme (${response.status})`);
}
return {
markdown: await response.text(),
markdown: await this.fetchText(plugin.readmeUrl, 'text/markdown,text/plain,*/*'),
pluginId: plugin.id,
title: plugin.title,
url: plugin.readmeUrl
@@ -151,7 +246,7 @@ export class PluginStoreService {
}
getInstallState(plugin: PluginStoreEntry): PluginStoreInstallState {
const installed = this.installedById().get(plugin.id);
const installed = this.installedPluginForScope(plugin.id, getStoreEntryInstallScope(plugin));
if (!installed) {
return 'notInstalled';
@@ -162,25 +257,28 @@ export class PluginStoreService {
: 'installed';
}
getActionLabel(plugin: PluginStoreEntry): 'Install' | 'Uninstall' | 'Update' {
getActionLabel(plugin: PluginStoreEntry): PluginStoreActionLabel {
const state = this.getInstallState(plugin);
const serverScoped = getStoreEntryInstallScope(plugin) === 'server';
if (state === 'updateAvailable') {
return 'Update';
return serverScoped ? 'Update Server' : 'Update';
}
return state === 'installed' ? 'Uninstall' : 'Install';
if (state === 'installed') {
return serverScoped ? 'Remove from Server' : 'Uninstall';
}
return serverScoped ? 'Install to Server' : 'Install';
}
canInstallPlugin(plugin: PluginStoreEntry): boolean {
return getStoreEntryInstallScope(plugin) !== 'server' || this.hasActiveServerInstallScope();
}
private async loadSource(sourceUrl: string, signal: AbortSignal): Promise<PluginStoreSourceResult> {
try {
const response = await fetch(sourceUrl, { headers: { Accept: 'application/json' }, signal });
if (!response.ok) {
throw new Error(`Source returned ${response.status}`);
}
const sourceValue = await response.json() as unknown;
const sourceValue = await this.fetchJson(sourceUrl, signal);
return parsePluginSource(sourceUrl, sourceValue);
} catch (error) {
@@ -193,13 +291,7 @@ export class PluginStoreService {
}
private async fetchPluginManifest(manifestUrl: string): Promise<TojuPluginManifest> {
const response = await fetch(manifestUrl, { headers: { Accept: 'application/json' } });
if (!response.ok) {
throw new Error(`Install manifest returned ${response.status}`);
}
const manifestValue = await response.json() as unknown;
const manifestValue = await this.fetchJson(manifestUrl);
const validation = validateTojuPluginManifest(manifestValue);
if (!validation.manifest) {
@@ -209,10 +301,50 @@ export class PluginStoreService {
return validation.manifest;
}
private hydrateInstalledPlugins(installedPlugins: InstalledStorePlugin[]): void {
const usableInstalledPlugins: InstalledStorePlugin[] = [];
private async fetchJson(url: string, signal?: AbortSignal): Promise<unknown> {
return JSON.parse(await this.fetchText(url, 'application/json', signal)) as unknown;
}
for (const installedPlugin of installedPlugins) {
private async fetchText(url: string, accept: string, signal?: AbortSignal): Promise<string> {
if (url.startsWith('file://')) {
return await this.readLocalFileUrl(url);
}
const response = await fetch(url, { headers: { Accept: accept }, signal });
if (!response.ok) {
throw new Error(`Request returned ${response.status}`);
}
return await response.text();
}
private async readLocalFileUrl(fileUrl: string): Promise<string> {
const api = this.electronBridge.getApi();
if (!api) {
throw new Error('Local plugin source paths require the desktop app');
}
const base64Data = await api.readFile(fileUrlToPath(fileUrl));
const bytes = Uint8Array.from(atob(base64Data), (character) => character.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
private async applyInstalledPlugins(installedPlugins: InstalledStorePlugin[], scope: TojuPluginInstallScope): Promise<void> {
const usableInstalledPlugins: InstalledStorePlugin[] = [];
const scopedInstalledPlugins = installedPlugins.filter((installedPlugin) => getPluginInstallScope(installedPlugin.manifest) === scope);
const nextIds = new Set(scopedInstalledPlugins.map((installedPlugin) => installedPlugin.manifest.id));
for (const previousPlugin of this.installedPluginsForScope(scope)) {
if (!nextIds.has(previousPlugin.manifest.id)) {
await this.host.deactivatePlugin(previousPlugin.manifest.id);
this.registry.unregister(previousPlugin.manifest.id);
}
}
for (const installedPlugin of scopedInstalledPlugins) {
try {
this.host.registerLocalManifest(installedPlugin.manifest, installedPlugin.installUrl);
usableInstalledPlugins.push(installedPlugin);
@@ -221,10 +353,143 @@ export class PluginStoreService {
}
}
if (usableInstalledPlugins.length !== installedPlugins.length) {
this.installedPluginsSignal.set(usableInstalledPlugins);
this.setInstalledPluginsForScope(scope, usableInstalledPlugins);
await this.host.activatePersistedPlugins();
if (usableInstalledPlugins.length !== scopedInstalledPlugins.length) {
if (scope === 'client') {
await this.persistInstalledPlugins(usableInstalledPlugins, scope);
}
}
}
private async loadInstalledPluginsForScope(roomId: string | null, actorUserId: string | null): Promise<void> {
const currentLoad = this.installedLoadVersion + 1;
this.installedLoadVersion = currentLoad;
await Promise.resolve();
if (!roomId || !actorUserId || !this.serverDirectory) {
if (this.installedLoadVersion === currentLoad) {
await this.applyInstalledPlugins([], 'server');
}
return;
}
try {
const installedPlugins = await this.readServerInstalledPlugins(roomId);
if (this.installedLoadVersion === currentLoad && this.currentRoomId?.() === roomId) {
await this.applyInstalledPlugins(installedPlugins, 'server');
}
} catch {
if (this.installedLoadVersion === currentLoad && this.currentRoomId?.() === roomId) {
await this.applyInstalledPlugins([], 'server');
}
}
}
private async persistInstalledPlugins(
installedPlugins: InstalledStorePlugin[],
scope: TojuPluginInstallScope,
serverId?: string | null
): Promise<void> {
const roomId = serverId ?? this.currentRoomId?.() ?? null;
const actorUserId = this.currentActorUserId();
if (scope === 'server') {
if (!roomId || !actorUserId || !this.serverDirectory) {
throw new Error('Open a chat server before saving server-scoped plugins');
}
await Promise.all(installedPlugins.map((installedPlugin) => this.saveServerPluginRequirement(installedPlugin, roomId, 'required')));
return;
}
this.clientInstalledPluginsSignal.set(installedPlugins);
this.saveState();
}
private async readServerInstalledPlugins(roomId: string): Promise<InstalledStorePlugin[]> {
if (!this.serverDirectory) {
return [];
}
const snapshot = await firstValueFrom(this.pluginRequirements.getSnapshot(this.serverDirectory.getApiBaseUrl(), roomId));
return snapshot.requirements
.map((requirement) => installedPluginFromRequirement(requirement))
.filter((installedPlugin): installedPlugin is InstalledStorePlugin => !!installedPlugin)
.sort(sortInstalledPlugins);
}
private async saveServerPluginRequirement(
installedPlugin: InstalledStorePlugin,
roomId: string | null,
status: 'optional' | 'required'
): Promise<void> {
const actorUserId = this.currentActorUserId();
if (!roomId || !actorUserId || !this.serverDirectory) {
throw new Error('Open a chat server before saving server-scoped plugins');
}
await firstValueFrom(this.pluginRequirements.upsertRequirement(
this.serverDirectory.getApiBaseUrl(),
roomId,
installedPlugin.manifest.id,
{
actorUserId,
installUrl: installedPlugin.installUrl,
manifest: installedPlugin.manifest,
reason: installedPlugin.manifest.description,
sourceUrl: installedPlugin.sourceUrl,
status,
versionRange: `^${installedPlugin.manifest.version}`
}
));
}
private async deleteServerPluginRequirement(pluginId: string): Promise<void> {
const roomId = this.currentRoomId?.() ?? null;
const actorUserId = this.currentActorUserId();
if (!roomId || !actorUserId || !this.serverDirectory) {
throw new Error('Open a chat server before removing server-scoped plugins');
}
await firstValueFrom(this.pluginRequirements.deleteRequirement(this.serverDirectory.getApiBaseUrl(), roomId, pluginId, actorUserId));
}
private currentActorUserId(): string | null {
const user = this.currentUser?.() ?? null;
return user?.oderId || user?.id || null;
}
private async installedPluginsForServer(serverId: string | null): Promise<InstalledStorePlugin[]> {
if (!serverId) {
return [];
}
if (serverId === this.currentRoomId?.()) {
return this.serverInstalledPluginsSignal();
}
const actorUserId = this.currentActorUserId();
if (!actorUserId || !this.serverDirectory) {
throw new Error('Unable to read server plugins without an active user and server directory');
}
try {
return await this.readServerInstalledPlugins(serverId);
} catch {
return [];
}
}
private loadState(): PersistedPluginStoreState {
@@ -242,8 +507,10 @@ export class PluginStoreService {
}
private saveState(): void {
this.stateMutated = true;
const state = {
installedPlugins: this.installedPlugins(),
installedPlugins: this.clientInstalledPluginsSignal(),
schemaVersion: STORE_SCHEMA_VERSION,
sourceUrls: this.sourceUrls()
};
@@ -251,7 +518,85 @@ export class PluginStoreService {
try {
localStorage.setItem(getUserScopedStorageKey(STORAGE_KEY_PLUGIN_STORE), JSON.stringify(state));
} catch {}
void this.desktopState.writeJson(STORAGE_KEY_PLUGIN_STORE, state);
}
private async loadDesktopState(): Promise<void> {
const state = await this.desktopState.readJson<PersistedPluginStoreState>(STORAGE_KEY_PLUGIN_STORE, this.loadState());
if (this.stateMutated) {
return;
}
const normalized = normalizePersistedState(state);
const sourceUrlsChanged = JSON.stringify(normalized.sourceUrls) !== JSON.stringify(this.sourceUrls());
if (sourceUrlsChanged) {
this.sourceUrlsSignal.set(normalized.sourceUrls);
void this.refreshSources();
}
await this.applyInstalledPlugins(normalized.installedPlugins, 'client');
}
private installedPluginsForScope(scope: TojuPluginInstallScope): InstalledStorePlugin[] {
return scope === 'server' ? this.serverInstalledPluginsSignal() : this.clientInstalledPluginsSignal();
}
private setInstalledPluginsForScope(scope: TojuPluginInstallScope, installedPlugins: InstalledStorePlugin[]): void {
if (scope === 'server') {
this.serverInstalledPluginsSignal.set(installedPlugins);
return;
}
this.clientInstalledPluginsSignal.set(installedPlugins);
}
private installedPluginForScope(pluginId: string, scope: TojuPluginInstallScope): InstalledStorePlugin | undefined {
return this.installedPluginsForScope(scope).find((installedPlugin) => installedPlugin.manifest.id === pluginId);
}
private findInstalledPluginScope(pluginId: string): TojuPluginInstallScope | null {
if (this.serverInstalledPluginsSignal().some((installedPlugin) => installedPlugin.manifest.id === pluginId)) {
return 'server';
}
if (this.clientInstalledPluginsSignal().some((installedPlugin) => installedPlugin.manifest.id === pluginId)) {
return 'client';
}
return null;
}
}
function isPluginRequirementsChangedMessage(message: unknown): message is { serverId: string; type: string } {
if (!isRecord(message)) {
return false;
}
return (message['type'] === 'plugin_requirements' || message['type'] === 'plugin_requirements_changed')
&& typeof message['serverId'] === 'string';
}
function installedPluginFromRequirement(requirement: PluginRequirementSummary): InstalledStorePlugin | null {
if (requirement.status === 'optional' || requirement.status === 'blocked' || requirement.status === 'incompatible') {
return null;
}
const manifest = requirement.manifest;
if (!manifest || !isInstalledStorePlugin({ manifest })) {
return null;
}
return {
installedAt: requirement.updatedAt,
installUrl: requirement.installUrl,
manifest,
sourceUrl: requirement.sourceUrl,
updatedAt: requirement.updatedAt
};
}
function parsePluginSource(sourceUrl: string, sourceValue: unknown): PluginStoreSourceResult {
@@ -302,6 +647,7 @@ function parsePluginEntry(sourceUrl: string, sourceTitle: string, value: unknown
imageUrl: resolveOptionalUrl(sourceUrl, readString(value, 'image', 'imageUrl', 'icon', 'iconUrl', 'banner')),
installUrl: resolveOptionalUrl(sourceUrl, readString(value, 'install', 'installUrl', 'manifest', 'manifestUrl')),
readmeUrl: resolveOptionalUrl(sourceUrl, readString(value, 'readme', 'readmeUrl')),
scope: readPluginInstallScope(value),
sourceTitle,
sourceUrl,
title: readString(value, 'title', 'name') ?? id,
@@ -309,6 +655,16 @@ function parsePluginEntry(sourceUrl: string, sourceTitle: string, value: unknown
};
}
function getStoreEntryInstallScope(plugin: PluginStoreEntry): TojuPluginInstallScope {
return plugin.scope === 'server' ? 'server' : 'client';
}
function readPluginInstallScope(record: Record<string, unknown>): TojuPluginInstallScope | undefined {
const scope = readString(record, 'scope', 'installScope', 'pluginScope');
return scope === 'server' || scope === 'client' ? scope : undefined;
}
function normalizePersistedState(value: unknown): PersistedPluginStoreState {
if (!isRecord(value)) {
return { ...DEFAULT_STORE_STATE };
@@ -321,7 +677,7 @@ function normalizePersistedState(value: unknown): PersistedPluginStoreState {
sourceUrls: Array.isArray(value['sourceUrls'])
? value['sourceUrls']
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => normalizeOptionalRemoteUrl(entry))
.map((entry) => normalizeOptionalSourceUrl(entry))
.filter((entry): entry is string => !!entry)
: []
};
@@ -384,28 +740,34 @@ function readGithubUrl(record: Record<string, unknown>): string | undefined {
return isRecord(repository) ? readString(repository, 'url') : typeof repository === 'string' ? repository.trim() : undefined;
}
function normalizeRemoteUrl(rawUrl: string, label: string): string {
const url = normalizeOptionalRemoteUrl(rawUrl);
function normalizeSourceUrl(rawUrl: string, label: string): string {
const url = normalizeOptionalSourceUrl(rawUrl);
if (!url) {
throw new Error(`${label} must be an http or https URL`);
throw new Error(`${label} must be an http, https, file URL, or absolute local path`);
}
return url;
}
function normalizeOptionalRemoteUrl(rawUrl: string): string | undefined {
try {
const url = new URL(rawUrl.trim());
function normalizeOptionalSourceUrl(rawUrl: string): string | undefined {
const trimmedUrl = rawUrl.trim();
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
if (!trimmedUrl) {
return undefined;
}
try {
const url = new URL(trimmedUrl);
if (!isAllowedPluginSourceProtocol(url.protocol)) {
return undefined;
}
url.hash = '';
return url.toString();
} catch {
return undefined;
return localPathToFileUrl(trimmedUrl);
}
}
@@ -417,7 +779,7 @@ function resolveOptionalUrl(sourceUrl: string, rawUrl?: string): string | undefi
try {
const url = new URL(rawUrl, sourceUrl);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
if (!isAllowedPluginSourceProtocol(url.protocol)) {
return undefined;
}
@@ -428,6 +790,40 @@ function resolveOptionalUrl(sourceUrl: string, rawUrl?: string): string | undefi
}
}
function isAllowedPluginSourceProtocol(protocol: string): boolean {
return protocol === 'http:' || protocol === 'https:' || protocol === 'file:';
}
function localPathToFileUrl(filePath: string): string | undefined {
if (!isAbsoluteLocalPath(filePath)) {
return undefined;
}
const normalizedPath = filePath.replace(/\\/g, '/');
const pathWithLeadingSlash = /^[A-Za-z]:\//.test(normalizedPath)
? `/${normalizedPath}`
: normalizedPath;
return `file://${pathWithLeadingSlash.split('/')
.map(encodeURIComponent)
.join('/')}`;
}
function fileUrlToPath(fileUrl: string): string {
const url = new URL(fileUrl);
const decodedPath = decodeURIComponent(url.pathname);
if (/^\/[A-Za-z]:\//.test(decodedPath)) {
return decodedPath.slice(1).replace(/\//g, '\\');
}
return decodedPath;
}
function isAbsoluteLocalPath(filePath: string): boolean {
return filePath.startsWith('/') || /^[A-Za-z]:[\\/]/.test(filePath);
}
function compareVersions(leftVersion: string, rightVersion: string): number {
const leftParts = parseVersion(leftVersion);
const rightParts = parseVersion(rightVersion);

View File

@@ -0,0 +1,5 @@
import type { TojuPluginInstallScope, TojuPluginManifest } from '../../../../shared-kernel';
export function getPluginInstallScope(manifest: Pick<TojuPluginManifest, 'scope'>): TojuPluginInstallScope {
return manifest.scope === 'server' ? 'server' : 'client';
}

View File

@@ -58,6 +58,29 @@ describe('plugin manifest validation', () => {
expect(result.valid).toBe(true);
});
it('accepts server-scoped client plugin manifests', () => {
const result = validateTojuPluginManifest(createManifest({
scope: 'server'
}));
expect(result.valid).toBe(true);
expect(result.manifest?.scope).toBe('server');
});
it('rejects unknown plugin install scopes', () => {
const result = validateTojuPluginManifest({
...createManifest(),
scope: 'workspace'
});
expect(result.valid).toBe(false);
expect(result.issues).toContainEqual({
message: 'scope must be client or server',
path: 'scope',
severity: 'error'
});
});
it('rejects unknown capabilities and event dimensions', () => {
const result = validateTojuPluginManifest({
...createManifest(),

View File

@@ -178,6 +178,10 @@ export function validateTojuPluginManifest(value: unknown): PluginManifestValida
pushIssue(issues, 'kind', 'kind must be client or library');
}
if (value['scope'] !== undefined && value['scope'] !== 'client' && value['scope'] !== 'server') {
pushIssue(issues, 'scope', 'scope must be client or server');
}
if (!isRecord(value['compatibility'])) {
pushIssue(issues, 'compatibility', 'compatibility is required');
} else {

View File

@@ -84,6 +84,43 @@ export interface PluginApiEventSubscription {
handler: (event: PluginEventEnvelope) => void;
}
export interface PluginApiMessageBusEnvelope {
channelId?: string;
eventId: string;
messages?: Message[];
payload?: unknown;
pluginId: string;
roomId: string;
sentAt: number;
sourcePeerId?: string;
sourceUserId?: string;
topic: string;
}
export interface PluginApiMessageBusLatestRequest {
channelId?: string;
includeDeleted?: boolean;
limit?: number;
sinceTimestamp?: number;
targetPeerId?: string;
topic?: string;
}
export interface PluginApiMessageBusPublishRequest extends PluginApiMessageBusLatestRequest {
includeLatestMessages?: boolean;
includeSelf?: boolean;
payload?: unknown;
topic: string;
}
export interface PluginApiMessageBusSubscription {
channelId?: string;
handler: (event: PluginApiMessageBusEnvelope) => void;
latestMessageLimit?: number;
replayLatest?: boolean;
topic?: string;
}
export interface PluginApiSettingsPageContribution {
label: string;
order?: number;
@@ -158,6 +195,11 @@ export interface TojuClientPluginApi {
info: (message: string, data?: unknown) => void;
warn: (message: string, data?: unknown) => void;
};
readonly clientData: {
read: (key: string) => Promise<unknown>;
remove: (key: string) => Promise<void>;
write: (key: string, value: unknown) => Promise<void>;
};
readonly media: {
addCustomAudioStream: (request: PluginApiCustomStreamRequest) => Promise<void>;
addCustomVideoStream: (request: PluginApiCustomStreamRequest) => Promise<void>;
@@ -174,6 +216,11 @@ export interface TojuClientPluginApi {
sendAsPluginUser: (request: PluginApiMessageAsPluginUserRequest) => void;
sync: (messages: Message[]) => void;
};
readonly messageBus: {
publish: (request: PluginApiMessageBusPublishRequest) => PluginApiMessageBusEnvelope;
sendLatestMessages: (request?: PluginApiMessageBusLatestRequest) => PluginApiMessageBusEnvelope;
subscribe: (subscription: PluginApiMessageBusSubscription) => TojuPluginDisposable;
};
readonly p2p: {
broadcastData: (eventName: string, payload: unknown) => void;
connectedPeers: () => string[];

View File

@@ -1,6 +1,7 @@
import type { TojuPluginManifest } from '../../../../shared-kernel';
import type { TojuPluginInstallScope, TojuPluginManifest } from '../../../../shared-kernel';
export type PluginStoreInstallState = 'installed' | 'notInstalled' | 'updateAvailable';
export type PluginStoreActionLabel = 'Install' | 'Install to Server' | 'Remove from Server' | 'Uninstall' | 'Update' | 'Update Server';
export interface PluginStoreEntry {
author?: string;
@@ -11,6 +12,7 @@ export interface PluginStoreEntry {
imageUrl?: string;
installUrl?: string;
readmeUrl?: string;
scope?: TojuPluginInstallScope;
sourceTitle?: string;
sourceUrl: string;
title: string;

View File

@@ -17,8 +17,8 @@
/>
</button>
<div class="min-w-0">
<h2 class="truncate text-base font-semibold">Plugins</h2>
<p class="truncate text-xs text-muted-foreground">Local runtime, store install, capabilities, logs, extension points.</p>
<h2 class="truncate text-base font-semibold">{{ managerTitle() }}</h2>
<p class="truncate text-xs text-muted-foreground">{{ managerDescription() }}</p>
</div>
</div>
<button
@@ -232,7 +232,7 @@
@for (page of selectedSettingsPages(); track page.id) {
<article class="rounded-md border border-border bg-background/40 p-3">
<h4 class="mb-2 text-sm font-medium">{{ page.contribution.label }}</h4>
<app-plugin-render-host [render]="page.contribution.render"></app-plugin-render-host>
<app-plugin-render-host [render]="page.contribution.render" />
</article>
}
</div>
@@ -331,8 +331,8 @@
name="lucidePackage"
size="28"
/>
<p class="mt-3 text-sm font-medium">No plugins installed.</p>
<p class="mt-1 text-sm text-muted-foreground">Use Store tab or local plugin folder discovery.</p>
<p class="mt-3 text-sm font-medium">{{ emptyTitle() }}</p>
<p class="mt-1 text-sm text-muted-foreground">{{ emptyBody() }}</p>
</div>
} @else {
@for (entry of entries(); track trackEntry($index, entry)) {
@@ -370,6 +370,18 @@
/>
{{ entry.enabled ? 'Disable' : 'Enable' }}
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50"
[disabled]="busyPluginId() === entry.manifest.id || !entry.enabled || isActive(entry)"
(click)="activate(entry)"
>
<ng-icon
name="lucidePlay"
size="14"
/>
Activate
</button>
<button
type="button"
class="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2 text-sm hover:bg-muted disabled:opacity-50"

View File

@@ -5,6 +5,7 @@ import {
Output,
computed,
inject,
input,
signal
} from '@angular/core';
import { Router } from '@angular/router';
@@ -21,13 +22,14 @@ import {
lucideStore,
lucideX
} from '@ng-icons/lucide';
import type { PluginCapabilityId } from '../../../../shared-kernel';
import type { PluginCapabilityId, TojuPluginInstallScope } from '../../../../shared-kernel';
import { PluginCapabilityService } from '../../application/services/plugin-capability.service';
import { PluginHostService } from '../../application/services/plugin-host.service';
import { PluginLoggerService } from '../../application/services/plugin-logger.service';
import { PluginRegistryService } from '../../application/services/plugin-registry.service';
import { PluginRequirementStateService } from '../../application/services/plugin-requirement-state.service';
import { PluginUiRegistryService } from '../../application/services/plugin-ui-registry.service';
import { getPluginInstallScope } from '../../domain/logic/plugin-install-scope.logic';
import type { RegisteredPlugin } from '../../domain/models/plugin-runtime.models';
import { PluginRenderHostComponent } from '../plugin-render-host/plugin-render-host.component';
@@ -60,6 +62,8 @@ type PluginManagerTab = 'docs' | 'extensions' | 'installed' | 'logs' | 'requirem
export class PluginManagerComponent {
@Output() readonly closed = new EventEmitter<void>();
readonly scope = input<TojuPluginInstallScope>('client');
readonly capabilities = inject(PluginCapabilityService);
readonly host = inject(PluginHostService);
readonly logger = inject(PluginLoggerService);
@@ -71,7 +75,12 @@ export class PluginManagerComponent {
readonly busyPluginId = signal<string | null>(null);
readonly busyAll = signal(false);
readonly selectedPluginId = signal<string | null>(null);
readonly entries = this.registry.entries;
readonly allEntries = this.registry.entries;
readonly entries = computed(() => this.allEntries().filter((entry) => this.entryBelongsToScope(entry)));
readonly managerTitle = computed(() => this.scope() === 'server' ? 'Server plugins' : 'Client plugins');
readonly managerDescription = computed(() => this.scope() === 'server'
? 'Plugins installed for the current chat server.'
: 'Global client plugins installed on this device.');
readonly selectedPlugin = computed(() => {
const selectedPluginId = this.selectedPluginId();
@@ -89,17 +98,18 @@ export class PluginManagerComponent {
.slice(-20) : [];
});
readonly extensionCounts = computed(() => ({
appPages: this.uiRegistry.appPages().length,
channelSections: this.uiRegistry.channelSections().length,
composerActions: this.uiRegistry.composerActions().length,
embeds: this.uiRegistry.embeds().length,
profileActions: this.uiRegistry.profileActions().length,
settingsPages: this.uiRegistry.settingsPages().length,
sidePanels: this.uiRegistry.sidePanels().length,
toolbarActions: this.uiRegistry.toolbarActions().length
appPages: this.uiRegistry.appPageRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
channelSections: this.uiRegistry.channelSectionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
composerActions: this.uiRegistry.composerActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
embeds: this.uiRegistry.embedRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
profileActions: this.uiRegistry.profileActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
settingsPages: this.uiRegistry.settingsPageRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
sidePanels: this.uiRegistry.sidePanelRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
toolbarActions: this.uiRegistry.toolbarActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length
}));
readonly requirementComparisons = this.requirementState.comparisons;
readonly uiConflicts = this.uiRegistry.conflicts;
readonly requirementComparisons = computed(() => this.scope() === 'server' ? this.requirementState.comparisons() : []);
readonly uiConflicts = computed(() => this.uiRegistry.conflicts()
.filter((conflict) => conflict.pluginIds.some((pluginId) => this.hasVisiblePlugin(pluginId))));
readonly selectedRequirement = computed(() => {
const selectedPlugin = this.selectedPlugin();
@@ -113,6 +123,10 @@ export class PluginManagerComponent {
? this.uiRegistry.settingsPageRecords().filter((record) => record.pluginId === selectedPlugin.manifest.id)
: [];
});
readonly emptyTitle = computed(() => this.scope() === 'server' ? 'No server plugins installed.' : 'No client plugins installed.');
readonly emptyBody = computed(() => this.scope() === 'server'
? 'Server-scoped plugins use scope: server in toju-plugin.json.'
: 'Client-scoped plugins use scope: client or omit scope in toju-plugin.json.');
readonly selectedDocs = computed(() => {
const manifest = this.selectedPlugin()?.manifest;
@@ -176,11 +190,21 @@ export class PluginManagerComponent {
}
}
async activate(entry: RegisteredPlugin): Promise<void> {
this.busyPluginId.set(entry.manifest.id);
try {
await this.host.activatePluginById(entry.manifest.id);
} finally {
this.busyPluginId.set(null);
}
}
async unload(entry: RegisteredPlugin): Promise<void> {
this.busyPluginId.set(entry.manifest.id);
try {
await this.host.deactivatePlugin(entry.manifest.id);
await this.host.deactivatePlugin(entry.manifest.id, { forgetActivation: true });
} finally {
this.busyPluginId.set(null);
}
@@ -194,6 +218,10 @@ export class PluginManagerComponent {
return this.selectedPlugin()?.manifest.id === entry.manifest.id;
}
isActive(entry: RegisteredPlugin): boolean {
return this.host.isPluginActive(entry.manifest.id);
}
close(): void {
this.closed.emit();
}
@@ -205,4 +233,12 @@ export class PluginManagerComponent {
trackCapability(index: number, capability: PluginCapabilityId): string {
return capability;
}
private entryBelongsToScope(entry: RegisteredPlugin): boolean {
return getPluginInstallScope(entry.manifest) === this.scope();
}
private hasVisiblePlugin(pluginId: string): boolean {
return this.entries().some((entry) => entry.manifest.id === pluginId);
}
}

View File

@@ -20,7 +20,9 @@
<div class="plugin-store__title-copy">
<h1>Plugin Store</h1>
<p>{{ installedCount() }} installed · {{ totalSourcePlugins() }} available · {{ sourceCount() }} sources</p>
<p>
{{ installedCount() }} installed for {{ store.installScopeLabel() }} · {{ totalSourcePlugins() }} available · {{ sourceCount() }} sources
</p>
</div>
</div>
@@ -52,10 +54,10 @@
<div class="plugin-store__source-form">
<label class="plugin-store__input-shell plugin-store__source-input">
<input
type="url"
type="text"
[(ngModel)]="newSourceUrl"
(keyup.enter)="addSourceUrl()"
placeholder="https://example.com/plugins.json"
placeholder="https://example.com/plugins.json or /home/me/plugins/source.json"
aria-label="Plugin source manifest URL"
/>
</label>
@@ -232,8 +234,9 @@
type="button"
(click)="runPrimaryAction(plugin)"
[disabled]="isPrimaryActionDisabled(plugin)"
[title]="serverInstallButtonTitle(plugin)"
class="plugin-store__primary-button plugin-card__primary-action"
[class.plugin-card__primary-action--danger]="store.getActionLabel(plugin) === 'Uninstall'"
[class.plugin-card__primary-action--danger]="store.getActionLabel(plugin) === 'Uninstall' || store.getActionLabel(plugin) === 'Remove from Server'"
>
<ng-icon
[name]="primaryActionIcon(plugin)"
@@ -311,4 +314,107 @@
</aside>
}
</div>
@if (serverInstallDialog(); as dialog) {
<div
class="plugin-store__modal-backdrop"
role="presentation"
></div>
<section
class="plugin-store__install-modal"
role="dialog"
aria-modal="true"
aria-labelledby="server-plugin-install-title"
>
<header class="plugin-store__install-header">
<div>
<p>Server plugin install</p>
<h2 id="server-plugin-install-title">{{ dialog.manifest.title }}</h2>
</div>
<button
type="button"
(click)="closeServerInstallDialog()"
class="plugin-store__icon-button"
title="Cancel install"
>
<ng-icon name="lucideX" />
</button>
</header>
<div class="plugin-store__install-body">
<label class="plugin-store__field">
<span>Install to server</span>
<select
[value]="dialog.selectedServerId"
[disabled]="serverInstallBusy()"
(change)="selectServerInstallTarget($any($event.target).value)"
>
@for (server of manageableServers(); track trackServer($index, server)) {
<option [value]="server.id">{{ server.name }}</option>
}
</select>
</label>
<label class="plugin-store__capability-row">
<input
type="checkbox"
[checked]="serverInstallOptional()"
[disabled]="serverInstallBusy()"
(change)="serverInstallOptional.set($any($event.target).checked)"
/>
<span>Optional for server members</span>
</label>
<div class="plugin-store__capability-list">
<div class="plugin-store__capability-list-header">
<h3>Capabilities</h3>
<span>{{ dialog.manifest.capabilities?.length ?? 0 }}</span>
</div>
@if ((dialog.manifest.capabilities?.length ?? 0) > 0) {
@for (capability of dialog.manifest.capabilities; track trackInstallCapability($index, capability)) {
<label class="plugin-store__capability-row">
<input
type="checkbox"
[checked]="selectedCapabilityIds().has(capability)"
[disabled]="serverInstallBusy()"
(change)="toggleInstallCapability(capability, $any($event.target).checked)"
/>
<span>{{ capability }}</span>
</label>
}
} @else {
<p class="plugin-store__muted-text">This plugin requests no capabilities.</p>
}
</div>
@if (serverInstallError()) {
<p class="plugin-store__error-banner">{{ serverInstallError() }}</p>
}
</div>
<footer class="plugin-store__install-actions">
<button
type="button"
(click)="closeServerInstallDialog()"
[disabled]="serverInstallBusy()"
class="plugin-store__secondary-button"
>
Cancel
</button>
<button
type="button"
(click)="confirmServerInstall()"
[disabled]="serverInstallBusy() || !dialog.selectedServerId"
class="plugin-store__primary-button"
>
<ng-icon
name="lucidePlus"
[class.is-spinning]="serverInstallBusy()"
/>
Install and Activate
</button>
</footer>
</section>
}
</main>

View File

@@ -262,6 +262,7 @@ ng-icon {
.plugin-store__source-row {
gap: 0.375rem;
min-width: 0;
}
.plugin-store__source-filter,
@@ -401,7 +402,6 @@ ng-icon {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.plugin-store__readme pre {
@@ -414,6 +414,123 @@ ng-icon {
background: hsl(var(--secondary) / 0.5);
}
.plugin-store__modal-backdrop {
position: fixed;
inset: 0;
z-index: 80;
background: rgb(0 0 0 / 0.6);
}
.plugin-store__install-modal {
position: fixed;
z-index: 81;
top: 50%;
left: 50%;
display: flex;
width: min(34rem, calc(100vw - 2rem));
max-height: min(42rem, calc(100vh - 2rem));
flex-direction: column;
overflow: hidden;
transform: translate(-50%, -50%);
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
color: hsl(var(--foreground));
background: hsl(var(--card));
box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 0.35);
}
.plugin-store__install-header,
.plugin-store__install-actions,
.plugin-store__capability-list-header,
.plugin-store__capability-row {
display: flex;
align-items: center;
}
.plugin-store__install-header {
justify-content: space-between;
gap: 1rem;
border-bottom: 1px solid hsl(var(--border));
padding: 1rem;
}
.plugin-store__install-header p,
.plugin-store__install-header h2,
.plugin-store__capability-list-header h3,
.plugin-store__muted-text {
margin: 0;
}
.plugin-store__install-header p,
.plugin-store__field span,
.plugin-store__capability-list-header span,
.plugin-store__muted-text {
color: hsl(var(--muted-foreground));
font-size: 0.78rem;
}
.plugin-store__install-header h2 {
margin-top: 0.2rem;
font-size: 1.05rem;
}
.plugin-store__install-body {
display: grid;
min-height: 0;
gap: 1rem;
overflow: auto;
padding: 1rem;
}
.plugin-store__field {
display: grid;
gap: 0.4rem;
}
.plugin-store__field select {
min-height: 2.25rem;
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
padding: 0.45rem 0.65rem;
color: hsl(var(--foreground));
background: hsl(var(--secondary));
}
.plugin-store__capability-list {
display: grid;
gap: 0.4rem;
}
.plugin-store__capability-list-header {
justify-content: space-between;
gap: 0.75rem;
}
.plugin-store__capability-list-header h3 {
font-size: 0.9rem;
}
.plugin-store__capability-row {
gap: 0.55rem;
border: 1px solid hsl(var(--border));
border-radius: 0.45rem;
padding: 0.5rem 0.6rem;
background: hsl(var(--background) / 0.5);
font-size: 0.82rem;
}
.plugin-store__capability-row input {
width: 1rem;
height: 1rem;
}
.plugin-store__install-actions {
justify-content: flex-end;
gap: 0.5rem;
border-top: 1px solid hsl(var(--border));
padding: 0.85rem 1rem;
}
.plugin-store__empty {
display: grid;
min-height: 14rem;

View File

@@ -9,6 +9,7 @@ import {
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { Store as NgRxStore } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideArrowLeft,
@@ -24,9 +25,25 @@ import {
} from '@ng-icons/lucide';
import { ExternalLinkService } from '../../../../core/platform';
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
import { resolveLegacyRole, resolveRoomPermission } from '../../../access-control';
import type {
PluginCapabilityId,
Room,
TojuPluginManifest,
User
} from '../../../../shared-kernel';
import { selectCurrentRoom, selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { PluginCapabilityService } from '../../application/services/plugin-capability.service';
import { PluginStoreService } from '../../application/services/plugin-store.service';
import type { PluginStoreEntry, PluginStoreReadme } from '../../domain/models/plugin-store.models';
interface ServerPluginInstallDialog {
manifest: TojuPluginManifest;
plugin: PluginStoreEntry;
selectedServerId: string;
}
@Component({
selector: 'app-plugin-store',
standalone: true,
@@ -54,6 +71,28 @@ import type { PluginStoreEntry, PluginStoreReadme } from '../../domain/models/pl
})
export class PluginStoreComponent implements OnInit {
readonly store = inject(PluginStoreService);
readonly capabilities = inject(PluginCapabilityService);
readonly ngrxStore = inject(NgRxStore);
readonly savedRooms = this.ngrxStore.selectSignal(selectSavedRooms);
readonly currentRoom = this.ngrxStore.selectSignal(selectCurrentRoom);
readonly currentUser = this.ngrxStore.selectSignal(selectCurrentUser);
readonly manageableServers = computed(() => {
const user = this.currentUser();
if (!user) {
return [];
}
const roomsById = new Map(this.savedRooms().map((room) => [room.id, room]));
const currentRoom = this.currentRoom();
if (currentRoom) {
roomsById.set(currentRoom.id, currentRoom);
}
return Array.from(roomsById.values())
.filter((room) => this.canManageServerPlugins(room, user));
});
readonly sourceErrors = computed(() => this.store.sources().filter((source) => !!source.error));
readonly installedIds = computed(() => new Set(this.store.installedPlugins().map((plugin) => plugin.manifest.id)));
readonly filteredPlugins = computed(() => {
@@ -96,6 +135,11 @@ export class PluginStoreComponent implements OnInit {
readonly readme = signal<PluginStoreReadme | null>(null);
readonly readmeError = signal<string | null>(null);
readonly readmeLoadingPluginId = signal<string | null>(null);
readonly serverInstallDialog = signal<ServerPluginInstallDialog | null>(null);
readonly selectedCapabilityIds = signal<Set<PluginCapabilityId>>(new Set());
readonly serverInstallOptional = signal(false);
readonly serverInstallError = signal<string | null>(null);
readonly serverInstallBusy = signal(false);
private destroyed = false;
private readonly destroyRef = inject(DestroyRef);
@@ -181,8 +225,10 @@ export class PluginStoreComponent implements OnInit {
this.actionBusyPluginId.set(plugin.id);
try {
if (action === 'Uninstall') {
this.store.uninstallPlugin(plugin.id);
if (action === 'Uninstall' || action === 'Remove from Server') {
await this.store.uninstallPlugin(plugin.id, plugin.scope);
} else if (this.isServerScopedPlugin(plugin)) {
await this.openServerInstallDialog(plugin);
} else {
await this.store.installPlugin(plugin);
}
@@ -229,13 +275,118 @@ export class PluginStoreComponent implements OnInit {
this.readmeError.set(null);
}
async openServerInstallDialog(plugin: PluginStoreEntry): Promise<void> {
this.actionBusyPluginId.set(plugin.id);
this.serverInstallError.set(null);
try {
const manifest = await this.store.loadInstallManifest(plugin);
const selectedServerId = this.defaultServerInstallTargetId();
if (!selectedServerId) {
throw new Error('You need owner or Manage Server access on a chat server before installing server plugins');
}
this.selectedCapabilityIds.set(new Set(manifest.capabilities ?? []));
this.serverInstallOptional.set(false);
this.serverInstallDialog.set({ manifest, plugin, selectedServerId });
} catch (error) {
if (this.destroyed) {
return;
}
this.actionError.set(error instanceof Error ? error.message : 'Unable to prepare server plugin install');
} finally {
if (!this.destroyed) {
this.actionBusyPluginId.set(null);
}
}
}
closeServerInstallDialog(): void {
if (this.serverInstallBusy()) {
return;
}
this.serverInstallDialog.set(null);
this.serverInstallError.set(null);
this.serverInstallOptional.set(false);
this.selectedCapabilityIds.set(new Set());
}
selectServerInstallTarget(serverId: string): void {
this.serverInstallDialog.update((dialog) => dialog ? { ...dialog, selectedServerId: serverId } : dialog);
}
toggleInstallCapability(capability: PluginCapabilityId, checked: boolean): void {
this.selectedCapabilityIds.update((capabilities) => {
const nextCapabilities = new Set(capabilities);
if (checked) {
nextCapabilities.add(capability);
} else {
nextCapabilities.delete(capability);
}
return nextCapabilities;
});
}
async confirmServerInstall(): Promise<void> {
const dialog = this.serverInstallDialog();
if (!dialog) {
return;
}
this.serverInstallBusy.set(true);
this.serverInstallError.set(null);
try {
for (const capability of dialog.manifest.capabilities ?? []) {
if (this.selectedCapabilityIds().has(capability)) {
this.capabilities.grant(dialog.manifest.id, capability);
} else {
this.capabilities.revoke(dialog.manifest.id, capability);
}
}
await this.store.installPlugin(dialog.plugin, {
activate: true,
manifest: dialog.manifest,
optional: this.serverInstallOptional(),
serverId: dialog.selectedServerId
});
if (this.destroyed) {
return;
}
this.serverInstallDialog.set(null);
this.serverInstallOptional.set(false);
this.selectedCapabilityIds.set(new Set());
} catch (error) {
if (this.destroyed) {
return;
}
this.serverInstallError.set(error instanceof Error ? error.message : 'Unable to install server plugin');
} finally {
if (!this.destroyed) {
this.serverInstallBusy.set(false);
}
}
}
goBack(): void {
void this.router.navigateByUrl(this.getReturnUrl());
}
async openManager(): Promise<void> {
const currentRoomId = this.currentRoom()?.id;
await this.router.navigateByUrl(this.getReturnUrl());
this.settingsModal.open('plugins');
this.settingsModal.open(this.store.hasActiveServerInstallScope() ? 'serverPlugins' : 'plugins', currentRoomId);
}
selectSource(sourceUrl: string | null): void {
@@ -262,9 +413,18 @@ export class PluginStoreComponent implements OnInit {
isPrimaryActionDisabled(plugin: PluginStoreEntry): boolean {
return this.isPluginBusy(plugin)
|| !this.canRunPrimaryAction(plugin)
|| (!plugin.installUrl && this.store.getInstallState(plugin) !== 'installed');
}
canRunPrimaryAction(plugin: PluginStoreEntry): boolean {
if (!this.isServerScopedPlugin(plugin)) {
return this.store.canInstallPlugin(plugin);
}
return this.manageableServers().length > 0;
}
primaryActionIcon(plugin: PluginStoreEntry): string {
const action = this.store.getActionLabel(plugin);
@@ -272,6 +432,10 @@ export class PluginStoreComponent implements OnInit {
return 'lucideTrash2';
}
if (action === 'Remove from Server') {
return 'lucideTrash2';
}
return 'lucidePlus';
}
@@ -287,6 +451,24 @@ export class PluginStoreComponent implements OnInit {
}
}
trackServer(index: number, server: Room): string {
return server.id;
}
trackInstallCapability(index: number, capability: PluginCapabilityId): string {
return capability;
}
isServerScopedPlugin(plugin: PluginStoreEntry): boolean {
return plugin.scope === 'server';
}
serverInstallButtonTitle(plugin: PluginStoreEntry): string {
return this.isServerScopedPlugin(plugin) && this.manageableServers().length === 0
? 'Requires owner or Manage Server access on a chat server'
: this.store.getActionLabel(plugin);
}
private matchesSearch(plugin: PluginStoreEntry, searchTerm: string): boolean {
return [
plugin.author,
@@ -307,4 +489,14 @@ export class PluginStoreComponent implements OnInit {
return '/search';
}
private defaultServerInstallTargetId(): string | null {
const currentRoomId = this.currentRoom()?.id ?? null;
return this.manageableServers().find((room) => room.id === currentRoomId)?.id ?? this.manageableServers()[0]?.id ?? null;
}
private canManageServerPlugins(room: Room, user: User): boolean {
return resolveLegacyRole(room, user) === 'host' || resolveRoomPermission(room, user, 'manageServer');
}
}

View File

@@ -1,7 +1,9 @@
export * from './application/services/plugin-capability.service';
export * from './application/services/plugin-client-api.service';
export * from './application/services/plugin-desktop-state.service';
export * from './application/services/plugin-host.service';
export * from './application/services/plugin-logger.service';
export * from './application/services/plugin-message-bus.service';
export * from './application/services/plugin-registry.service';
export * from './application/services/plugin-requirement.service';
export * from './application/services/plugin-requirement-state.service';

View File

@@ -34,7 +34,7 @@
<!-- Channels View -->
@if (panelMode() === 'channels') {
<div class="flex-1 overflow-auto">
<div class="min-h-0 flex-1 overflow-auto">
<!-- Text Channels -->
<section
appThemeNode="roomTextChannelsSection"
@@ -276,7 +276,7 @@
@if (pluginSidePanels().length > 0) {
<div class="mt-3 space-y-2">
@for (record of pluginSidePanels(); track record.id) {
<article class="rounded-md border border-border bg-background/40 p-2">
<article class="max-h-64 overflow-auto rounded-md border border-border bg-background/40 p-2">
<p class="mb-2 truncate text-xs font-medium text-muted-foreground">{{ record.contribution.label }}</p>
<app-plugin-render-host [render]="record.contribution.render" />
</article>

View File

@@ -136,7 +136,7 @@
General
}
@case ('plugins') {
Plugins
Client Plugins
}
@case ('network') {
Network
@@ -162,6 +162,9 @@
@case ('server') {
Server Settings
}
@case ('serverPlugins') {
Server Plugins
}
@case ('members') {
Members
}
@@ -197,7 +200,10 @@
<app-general-settings />
}
@case ('plugins') {
<app-plugin-manager (closed)="navigate('general')" />
<app-plugin-manager
scope="client"
(closed)="navigate('general')"
/>
}
@case ('network') {
<app-network-settings />
@@ -306,6 +312,21 @@
[isAdmin]="isSelectedServerOwner()"
/>
}
@case ('serverPlugins') {
@if (currentRoom()) {
<app-plugin-manager
scope="server"
(closed)="navigate('server')"
/>
} @else {
<section class="rounded-lg border border-border bg-card p-5">
<h4 class="text-sm font-semibold text-foreground">Open this server to manage plugins</h4>
<p class="mt-2 text-sm text-muted-foreground">
Server plugin installs and activation are shown for the currently open chat server. Select or open {{ selectedServer()?.name || 'this server' }} in the app, then return here.
</p>
</section>
}
}
@case ('members') {
<app-members-settings
[server]="selectedServer()"

View File

@@ -121,7 +121,7 @@ export class SettingsModalComponent {
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
{ id: 'general', label: 'General', icon: 'lucideSettings' },
{ id: 'plugins', label: 'Plugins', icon: 'lucidePackage' },
{ id: 'plugins', label: 'Client plugins', icon: 'lucidePackage' },
{ id: 'theme', label: 'Theme Studio', icon: 'lucidePalette' },
{ id: 'network', label: 'Network', icon: 'lucideGlobe' },
{ id: 'notifications', label: 'Notifications', icon: 'lucideBell' },
@@ -132,6 +132,7 @@ export class SettingsModalComponent {
];
readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [
{ id: 'server', label: 'Server', icon: 'lucideSettings' },
{ id: 'serverPlugins', label: 'Server plugins', icon: 'lucidePackage' },
{ id: 'members', label: 'Members', icon: 'lucideUsers' },
{ id: 'bans', label: 'Bans', icon: 'lucideBan' },
{ id: 'permissions', label: 'Permissions', icon: 'lucideShield' }
@@ -143,8 +144,15 @@ export class SettingsModalComponent {
if (!user)
return [];
return this.savedRooms().filter((room) => {
const viewedRoom = this.currentRoom()?.id === room.id ? (this.currentRoom() ?? room) : room;
const roomsById = new Map(this.savedRooms().map((room) => [room.id, room]));
const currentRoom = this.currentRoom();
if (currentRoom) {
roomsById.set(currentRoom.id, currentRoom);
}
return Array.from(roomsById.values()).filter((room) => {
const viewedRoom = currentRoom?.id === room.id ? currentRoom : room;
const role = resolveLegacyRole(viewedRoom, user);
return (
@@ -162,11 +170,12 @@ export class SettingsModalComponent {
selectedServerId = signal<string | null>(null);
selectedServer = computed<Room | null>(() => {
const id = this.selectedServerId();
const currentRoom = this.currentRoom();
if (!id)
return null;
return this.manageableRooms().find((room) => room.id === id) ?? null;
return currentRoom?.id === id ? currentRoom : (this.manageableRooms().find((room) => room.id === id) ?? null);
});
showServerTabs = computed(() => {
@@ -238,6 +247,13 @@ export class SettingsModalComponent {
return this.selectedServerRole() === 'host';
});
isSelectedServerCurrent = computed(() => {
const selectedServerId = this.selectedServerId();
const currentRoomId = this.currentRoom()?.id ?? null;
return !!selectedServerId && selectedServerId === currentRoomId;
});
animating = signal(false);
showThirdPartyLicenses = signal(false);

View File

@@ -87,6 +87,7 @@ export interface ChatEventBase {
directMessage?: DirectMessageEventPayload;
directMessageStatus?: DirectMessageStatusEventPayload;
directMessageMutation?: DirectMessageMutationEventPayload;
pluginMessage?: unknown;
}
export interface ChatMessageEvent extends ChatEventBase {
@@ -390,6 +391,11 @@ export interface DirectMessageMutationPeerEvent extends ChatEventBase {
directMessageMutation: DirectMessageMutationEventPayload;
}
export interface PluginMessageBusPeerEvent extends ChatEventBase {
type: 'plugin-message-bus';
pluginMessage: unknown;
}
/** Discriminated union of all P2P chat events. Narrow via `event.type`. */
export type ChatEvent =
| ChatMessageEvent
@@ -442,7 +448,8 @@ export type ChatEvent =
| ChannelsUpdateEvent
| DirectMessagePeerEvent
| DirectMessageStatusPeerEvent
| DirectMessageMutationPeerEvent;
| DirectMessageMutationPeerEvent
| PluginMessageBusPeerEvent;
/** All possible `type` values, derived from the union. */
export type ChatEventType = ChatEvent['type'];

View File

@@ -24,6 +24,7 @@ export const PLUGIN_EVENT_SCOPES = [
] as const;
export type PluginEventScope = typeof PLUGIN_EVENT_SCOPES[number];
export type TojuPluginInstallScope = 'client' | 'server';
export const PLUGIN_CAPABILITIES = [
'profile.read',
@@ -67,8 +68,11 @@ export const PLUGIN_CAPABILITIES = [
export type PluginCapabilityId = typeof PLUGIN_CAPABILITIES[number];
export interface PluginRequirementSummary {
installUrl?: string;
manifest?: TojuPluginManifest;
pluginId: string;
reason?: string;
sourceUrl?: string;
status: PluginRequirementStatus;
updatedAt: number;
versionRange?: string;
@@ -186,6 +190,7 @@ export interface TojuPluginManifest {
requires?: { id: string; versionRange?: string }[];
};
schemaVersion: 1;
scope?: TojuPluginInstallScope;
settings?: Record<string, unknown>;
title: string;
ui?: Record<string, unknown>;