feat: plugins v1.5
This commit is contained in:
@@ -28,22 +28,22 @@ test.describe('Plugin API multi-user runtime', () => {
|
|||||||
test('runs chat, embed, soundboard, and profile APIs between two users', async ({ createClient }) => {
|
test('runs chat, embed, soundboard, and profile APIs between two users', async ({ createClient }) => {
|
||||||
const scenario = await createPluginApiScenario(createClient);
|
const scenario = await createPluginApiScenario(createClient);
|
||||||
|
|
||||||
await test.step('Install and activate the plugin for Bob as the embed/soundboard receiver', async () => {
|
await test.step('Install the server plugin as Alice', async () => {
|
||||||
await installGrantAndActivatePlugin(scenario.bob.page);
|
await installGrantAndActivatePlugin(scenario.alice.page, true);
|
||||||
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 closeSettingsModal(scenario.alice.page);
|
await closeSettingsModal(scenario.alice.page);
|
||||||
await expect(soundboardComposerButton(scenario.alice.page)).toBeVisible({ timeout: 20_000 });
|
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.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 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 test.step('Alice opens the plugin soundboard modal and plays a sound to voice', async () => {
|
||||||
await soundboardComposerButton(scenario.alice.page).click();
|
await soundboardComposerButton(scenario.alice.page).click();
|
||||||
await expect(scenario.alice.page.getByRole('dialog', { name: SOUND_BOARD_LABEL })).toBeVisible({ timeout: 20_000 });
|
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 });
|
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 page.getByRole('button', { name: 'Plugins' }).click();
|
||||||
await expect(page).toHaveURL(/\/plugin-store/, { timeout: 20_000 });
|
await expect(page).toHaveURL(/\/plugin-store/, { timeout: 20_000 });
|
||||||
await expect(page.getByTestId('plugin-store-page')).toBeVisible({ 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);
|
|
||||||
await page.getByRole('button', { name: 'Add Source' }).click();
|
if (installFromStore) {
|
||||||
await expect(page.getByRole('heading', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 });
|
await page.getByLabel('Plugin source manifest URL').fill(PLUGIN_SOURCE_URL);
|
||||||
await page.getByRole('button', { exact: true, name: 'Install' }).click();
|
await page.getByRole('button', { name: 'Add Source' }).click();
|
||||||
await expect(page.locator('article', { hasText: PLUGIN_TITLE }).getByText('Installed')).toBeVisible({ timeout: 20_000 });
|
await expect(page.getByRole('heading', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 });
|
||||||
|
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 page.getByRole('button', { name: 'Manage Plugins' }).click();
|
||||||
await expect(page.getByTestId('plugin-manager')).toBeVisible({ timeout: 20_000 });
|
await expect(page.getByTestId('plugin-manager')).toBeVisible({ timeout: 20_000 });
|
||||||
await expect(page.locator('article', { hasText: PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 });
|
await expect(page.locator('article', { hasText: PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 });
|
||||||
|
|||||||
@@ -30,20 +30,25 @@ test.describe('Plugin manager UI', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Install fixture plugin from source manifest', async () => {
|
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 page.getByRole('button', { name: 'Add Source' }).click();
|
||||||
await expect(page.getByRole('heading', { name: 'E2E All API Plugin' })).toBeVisible({ timeout: 15_000 });
|
await expect(page.getByRole('heading', { name: 'E2E All API Plugin' })).toBeVisible({ timeout: 15_000 });
|
||||||
await page.getByRole('button', { name: 'Readme' }).click();
|
await page.getByRole('button', { name: 'Readme' }).click();
|
||||||
await expect(page.getByText('Fixture plugin for Playwright coverage.')).toBeVisible({ timeout: 10_000 });
|
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 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 test.step('Open plugin manager from the store page', async () => {
|
||||||
await page.getByRole('button', { name: 'Manage Plugins' }).click();
|
await page.getByRole('button', { name: 'Manage Plugins' }).click();
|
||||||
await expect(page.getByTestId('plugin-manager')).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByTestId('plugin-manager')).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(page.getByTestId('plugin-manager').getByRole('heading', { name: 'Plugins' })).toBeVisible();
|
await expect(page.getByTestId('plugin-manager').getByRole('heading', { name: 'Server plugins' })).toBeVisible();
|
||||||
await expect(page.getByText('Development Plugin')).toBeVisible();
|
await expect(page.getByText('E2E All API Plugin')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Grant capabilities and activate runtime', async () => {
|
await test.step('Grant capabilities and activate runtime', async () => {
|
||||||
|
|||||||
@@ -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 {
|
interface PluginSnapshotResponse {
|
||||||
eventDefinitions: PluginEventDefinitionResponse['eventDefinition'][];
|
eventDefinitions: PluginEventDefinitionResponse['eventDefinition'][];
|
||||||
requirements: PluginRequirementResponse['requirement'][];
|
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]);
|
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 () => {
|
await test.step('Plugin data API refuses arbitrary server persistence', async () => {
|
||||||
const stored = await expectJson<PluginDataResponse>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, {
|
const stored = await expectJson<{ errorCode: string }>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, {
|
||||||
data: {
|
data: {
|
||||||
actorUserId: OWNER_USER_ID,
|
actorUserId: OWNER_USER_ID,
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
@@ -147,49 +132,28 @@ test.describe('Plugin support API', () => {
|
|||||||
pluginVersion: manifest.version
|
pluginVersion: manifest.version
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}), 410);
|
||||||
|
|
||||||
expect(stored.record).toEqual(expect.objectContaining({
|
expect(stored.errorCode).toBe('PLUGIN_DATA_DISABLED');
|
||||||
key: 'settings',
|
|
||||||
pluginId: TEST_PLUGIN_ID,
|
|
||||||
schemaVersion: 1,
|
|
||||||
scope: 'server',
|
|
||||||
value: {
|
|
||||||
enabled: true,
|
|
||||||
pluginVersion: manifest.version
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
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: {
|
params: {
|
||||||
key: 'settings',
|
key: 'settings',
|
||||||
scope: 'server',
|
scope: 'server',
|
||||||
userId: OWNER_USER_ID
|
userId: OWNER_USER_ID
|
||||||
}
|
}
|
||||||
}));
|
}), 410);
|
||||||
|
|
||||||
expect(listed.records).toHaveLength(1);
|
expect(listed.errorCode).toBe('PLUGIN_DATA_DISABLED');
|
||||||
expect(listed.records[0]?.value).toEqual({
|
|
||||||
enabled: true,
|
|
||||||
pluginVersion: manifest.version
|
|
||||||
});
|
|
||||||
|
|
||||||
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: {
|
data: {
|
||||||
actorUserId: OWNER_USER_ID,
|
actorUserId: OWNER_USER_ID,
|
||||||
scope: 'server'
|
scope: 'server'
|
||||||
}
|
}
|
||||||
}));
|
}), 410);
|
||||||
|
|
||||||
const afterDelete = await expectJson<PluginDataListResponse>(await request.get(`${pluginsApi}/${TEST_PLUGIN_ID}/data`, {
|
expect(afterDelete.errorCode).toBe('PLUGIN_DATA_DISABLED');
|
||||||
params: {
|
|
||||||
key: 'settings',
|
|
||||||
scope: 'server',
|
|
||||||
userId: OWNER_USER_ID
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
expect(afterDelete.records).toEqual([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('WebSocket plugin API sends snapshots, relays server events, and rejects p2p relays', async () => {
|
await test.step('WebSocket plugin API sends snapshots, relays server events, and rejects p2p relays', async () => {
|
||||||
|
|||||||
@@ -28,5 +28,6 @@ Electron main-process package for MetoYou / Toju. This directory owns desktop bo
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- When adding a renderer-facing capability, update the Electron implementation, `preload.ts`, and the renderer bridge in `toju-app/` together.
|
- 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.
|
- Treat `dist/electron/` and `dist-electron/` as generated output.
|
||||||
- See [AGENTS.md](AGENTS.md) for package-level editing rules.
|
- See [AGENTS.md](AGENTS.md) for package-level editing rules.
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
ReactionEntity,
|
ReactionEntity,
|
||||||
BanEntity,
|
BanEntity,
|
||||||
AttachmentEntity,
|
AttachmentEntity,
|
||||||
MetaEntity
|
MetaEntity,
|
||||||
|
PluginDataEntity
|
||||||
} from '../../../entities';
|
} from '../../../entities';
|
||||||
|
|
||||||
export async function handleClearAllData(dataSource: DataSource): Promise<void> {
|
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(BanEntity).clear();
|
||||||
await dataSource.getRepository(AttachmentEntity).clear();
|
await dataSource.getRepository(AttachmentEntity).clear();
|
||||||
await dataSource.getRepository(MetaEntity).clear();
|
await dataSource.getRepository(MetaEntity).clear();
|
||||||
|
await dataSource.getRepository(PluginDataEntity).clear();
|
||||||
}
|
}
|
||||||
|
|||||||
14
electron/cqrs/commands/handlers/deletePluginData.ts
Normal file
14
electron/cqrs/commands/handlers/deletePluginData.ts
Normal 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 ?? ''
|
||||||
|
});
|
||||||
|
}
|
||||||
10
electron/cqrs/commands/handlers/saveMeta.ts
Normal file
10
electron/cqrs/commands/handlers/saveMeta.ts
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
16
electron/cqrs/commands/handlers/savePluginData.ts
Normal file
16
electron/cqrs/commands/handlers/savePluginData.ts
Normal 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)
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -18,7 +18,10 @@ import {
|
|||||||
SaveBanCommand,
|
SaveBanCommand,
|
||||||
RemoveBanCommand,
|
RemoveBanCommand,
|
||||||
SaveAttachmentCommand,
|
SaveAttachmentCommand,
|
||||||
DeleteAttachmentsForMessageCommand
|
DeleteAttachmentsForMessageCommand,
|
||||||
|
SavePluginDataCommand,
|
||||||
|
DeletePluginDataCommand,
|
||||||
|
SaveMetaCommand
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { handleSaveMessage } from './handlers/saveMessage';
|
import { handleSaveMessage } from './handlers/saveMessage';
|
||||||
import { handleDeleteMessage } from './handlers/deleteMessage';
|
import { handleDeleteMessage } from './handlers/deleteMessage';
|
||||||
@@ -36,6 +39,9 @@ import { handleSaveBan } from './handlers/saveBan';
|
|||||||
import { handleRemoveBan } from './handlers/removeBan';
|
import { handleRemoveBan } from './handlers/removeBan';
|
||||||
import { handleSaveAttachment } from './handlers/saveAttachment';
|
import { handleSaveAttachment } from './handlers/saveAttachment';
|
||||||
import { handleDeleteAttachmentsForMessage } from './handlers/deleteAttachmentsForMessage';
|
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';
|
import { handleClearAllData } from './handlers/clearAllData';
|
||||||
|
|
||||||
export const buildCommandHandlers = (dataSource: DataSource): Record<CommandTypeKey, (command: Command) => Promise<unknown>> => ({
|
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.RemoveBan]: (cmd) => handleRemoveBan(cmd as RemoveBanCommand, dataSource),
|
||||||
[CommandType.SaveAttachment]: (cmd) => handleSaveAttachment(cmd as SaveAttachmentCommand, dataSource),
|
[CommandType.SaveAttachment]: (cmd) => handleSaveAttachment(cmd as SaveAttachmentCommand, dataSource),
|
||||||
[CommandType.DeleteAttachmentsForMessage]: (cmd) => handleDeleteAttachmentsForMessage(cmd as DeleteAttachmentsForMessageCommand, 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)
|
[CommandType.ClearAllData]: () => handleClearAllData(dataSource)
|
||||||
});
|
});
|
||||||
|
|||||||
11
electron/cqrs/queries/handlers/getMeta.ts
Normal file
11
electron/cqrs/queries/handlers/getMeta.ts
Normal 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;
|
||||||
|
}
|
||||||
25
electron/cqrs/queries/handlers/getPluginData.ts
Normal file
25
electron/cqrs/queries/handlers/getPluginData.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,12 @@ import {
|
|||||||
GetMessageByIdQuery,
|
GetMessageByIdQuery,
|
||||||
GetReactionsForMessageQuery,
|
GetReactionsForMessageQuery,
|
||||||
GetUserQuery,
|
GetUserQuery,
|
||||||
GetCurrentUserIdQuery,
|
|
||||||
GetRoomQuery,
|
GetRoomQuery,
|
||||||
GetBansForRoomQuery,
|
GetBansForRoomQuery,
|
||||||
IsUserBannedQuery,
|
IsUserBannedQuery,
|
||||||
GetAttachmentsForMessageQuery
|
GetAttachmentsForMessageQuery,
|
||||||
|
GetPluginDataQuery,
|
||||||
|
GetMetaQuery
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { handleGetMessages } from './handlers/getMessages';
|
import { handleGetMessages } from './handlers/getMessages';
|
||||||
import { handleGetMessagesSince } from './handlers/getMessagesSince';
|
import { handleGetMessagesSince } from './handlers/getMessagesSince';
|
||||||
@@ -28,6 +29,8 @@ import { handleGetBansForRoom } from './handlers/getBansForRoom';
|
|||||||
import { handleIsUserBanned } from './handlers/isUserBanned';
|
import { handleIsUserBanned } from './handlers/isUserBanned';
|
||||||
import { handleGetAttachmentsForMessage } from './handlers/getAttachmentsForMessage';
|
import { handleGetAttachmentsForMessage } from './handlers/getAttachmentsForMessage';
|
||||||
import { handleGetAllAttachments } from './handlers/getAllAttachments';
|
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>> => ({
|
export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey, (query: Query) => Promise<unknown>> => ({
|
||||||
[QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource),
|
[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.GetBansForRoom]: (query) => handleGetBansForRoom(query as GetBansForRoomQuery, dataSource),
|
||||||
[QueryType.IsUserBanned]: (query) => handleIsUserBanned(query as IsUserBannedQuery, dataSource),
|
[QueryType.IsUserBanned]: (query) => handleIsUserBanned(query as IsUserBannedQuery, dataSource),
|
||||||
[QueryType.GetAttachmentsForMessage]: (query) => handleGetAttachmentsForMessage(query as GetAttachmentsForMessageQuery, 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)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export const CommandType = {
|
|||||||
RemoveBan: 'remove-ban',
|
RemoveBan: 'remove-ban',
|
||||||
SaveAttachment: 'save-attachment',
|
SaveAttachment: 'save-attachment',
|
||||||
DeleteAttachmentsForMessage: 'delete-attachments-for-message',
|
DeleteAttachmentsForMessage: 'delete-attachments-for-message',
|
||||||
|
SavePluginData: 'save-plugin-data',
|
||||||
|
DeletePluginData: 'delete-plugin-data',
|
||||||
|
SaveMeta: 'save-meta',
|
||||||
ClearAllData: 'clear-all-data'
|
ClearAllData: 'clear-all-data'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -34,7 +37,9 @@ export const QueryType = {
|
|||||||
GetBansForRoom: 'get-bans-for-room',
|
GetBansForRoom: 'get-bans-for-room',
|
||||||
IsUserBanned: 'is-user-banned',
|
IsUserBanned: 'is-user-banned',
|
||||||
GetAttachmentsForMessage: 'get-attachments-for-message',
|
GetAttachmentsForMessage: 'get-attachments-for-message',
|
||||||
GetAllAttachments: 'get-all-attachments'
|
GetAllAttachments: 'get-all-attachments',
|
||||||
|
GetPluginData: 'get-plugin-data',
|
||||||
|
GetMeta: 'get-meta'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type QueryTypeKey = typeof QueryType[keyof typeof QueryType];
|
export type QueryTypeKey = typeof QueryType[keyof typeof QueryType];
|
||||||
@@ -172,6 +177,16 @@ export interface AttachmentPayload {
|
|||||||
savedPath?: string;
|
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 SaveMessageCommand { type: typeof CommandType.SaveMessage; payload: { message: MessagePayload } }
|
||||||
export interface DeleteMessageCommand { type: typeof CommandType.DeleteMessage; payload: { messageId: string } }
|
export interface DeleteMessageCommand { type: typeof CommandType.DeleteMessage; payload: { messageId: string } }
|
||||||
export interface UpdateMessageCommand { type: typeof CommandType.UpdateMessage; payload: { messageId: string; updates: Partial<MessagePayload> } }
|
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 RemoveBanCommand { type: typeof CommandType.RemoveBan; payload: { oderId: string } }
|
||||||
export interface SaveAttachmentCommand { type: typeof CommandType.SaveAttachment; payload: { attachment: AttachmentPayload } }
|
export interface SaveAttachmentCommand { type: typeof CommandType.SaveAttachment; payload: { attachment: AttachmentPayload } }
|
||||||
export interface DeleteAttachmentsForMessageCommand { type: typeof CommandType.DeleteAttachmentsForMessage; payload: { messageId: string } }
|
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 interface ClearAllDataCommand { type: typeof CommandType.ClearAllData; payload: Record<string, never> }
|
||||||
|
|
||||||
export type Command =
|
export type Command =
|
||||||
@@ -207,6 +225,9 @@ export type Command =
|
|||||||
| RemoveBanCommand
|
| RemoveBanCommand
|
||||||
| SaveAttachmentCommand
|
| SaveAttachmentCommand
|
||||||
| DeleteAttachmentsForMessageCommand
|
| DeleteAttachmentsForMessageCommand
|
||||||
|
| SavePluginDataCommand
|
||||||
|
| DeletePluginDataCommand
|
||||||
|
| SaveMetaCommand
|
||||||
| ClearAllDataCommand;
|
| ClearAllDataCommand;
|
||||||
|
|
||||||
export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } }
|
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 IsUserBannedQuery { type: typeof QueryType.IsUserBanned; payload: { userId: string; roomId: string } }
|
||||||
export interface GetAttachmentsForMessageQuery { type: typeof QueryType.GetAttachmentsForMessage; payload: { messageId: string } }
|
export interface GetAttachmentsForMessageQuery { type: typeof QueryType.GetAttachmentsForMessage; payload: { messageId: string } }
|
||||||
export interface GetAllAttachmentsQuery { type: typeof QueryType.GetAllAttachments; payload: Record<string, never> }
|
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 =
|
export type Query =
|
||||||
| GetMessagesQuery
|
| GetMessagesQuery
|
||||||
@@ -238,4 +261,6 @@ export type Query =
|
|||||||
| GetBansForRoomQuery
|
| GetBansForRoomQuery
|
||||||
| IsUserBannedQuery
|
| IsUserBannedQuery
|
||||||
| GetAttachmentsForMessageQuery
|
| GetAttachmentsForMessageQuery
|
||||||
| GetAllAttachmentsQuery;
|
| GetAllAttachmentsQuery
|
||||||
|
| GetPluginDataQuery
|
||||||
|
| GetMetaQuery;
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ import {
|
|||||||
ReactionEntity,
|
ReactionEntity,
|
||||||
BanEntity,
|
BanEntity,
|
||||||
AttachmentEntity,
|
AttachmentEntity,
|
||||||
MetaEntity
|
MetaEntity,
|
||||||
|
PluginDataEntity
|
||||||
} from './entities';
|
} from './entities';
|
||||||
|
|
||||||
const projectRootDatabaseFilePath = path.join(__dirname, '..', '..', settings.databaseName);
|
const projectRootDatabaseFilePath = path.join(__dirname, '..', '..', settings.databaseName);
|
||||||
@@ -51,7 +52,8 @@ export const AppDataSource = new DataSource({
|
|||||||
ReactionEntity,
|
ReactionEntity,
|
||||||
BanEntity,
|
BanEntity,
|
||||||
AttachmentEntity,
|
AttachmentEntity,
|
||||||
MetaEntity
|
MetaEntity,
|
||||||
|
PluginDataEntity
|
||||||
],
|
],
|
||||||
migrations: [path.join(__dirname, 'migrations', '*.{ts,js}')],
|
migrations: [path.join(__dirname, 'migrations', '*.{ts,js}')],
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
ReactionEntity,
|
ReactionEntity,
|
||||||
BanEntity,
|
BanEntity,
|
||||||
AttachmentEntity,
|
AttachmentEntity,
|
||||||
MetaEntity
|
MetaEntity,
|
||||||
|
PluginDataEntity
|
||||||
} from '../entities';
|
} from '../entities';
|
||||||
import { settings } from '../settings';
|
import { settings } from '../settings';
|
||||||
|
|
||||||
@@ -171,7 +172,8 @@ export async function initializeDatabase(): Promise<void> {
|
|||||||
ReactionEntity,
|
ReactionEntity,
|
||||||
BanEntity,
|
BanEntity,
|
||||||
AttachmentEntity,
|
AttachmentEntity,
|
||||||
MetaEntity
|
MetaEntity,
|
||||||
|
PluginDataEntity
|
||||||
],
|
],
|
||||||
migrations: [path.join(__dirname, '..', 'migrations', '*.js'), path.join(__dirname, '..', 'migrations', '*.ts')],
|
migrations: [path.join(__dirname, '..', 'migrations', '*.js'), path.join(__dirname, '..', 'migrations', '*.ts')],
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
|
|||||||
26
electron/entities/PluginDataEntity.ts
Normal file
26
electron/entities/PluginDataEntity.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -10,3 +10,4 @@ export { ReactionEntity } from './ReactionEntity';
|
|||||||
export { BanEntity } from './BanEntity';
|
export { BanEntity } from './BanEntity';
|
||||||
export { AttachmentEntity } from './AttachmentEntity';
|
export { AttachmentEntity } from './AttachmentEntity';
|
||||||
export { MetaEntity } from './MetaEntity';
|
export { MetaEntity } from './MetaEntity';
|
||||||
|
export { PluginDataEntity } from './PluginDataEntity';
|
||||||
|
|||||||
25
electron/migrations/1000000000008-AddPluginData.ts
Normal file
25
electron/migrations/1000000000008-AddPluginData.ts
Normal 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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
- `SSL` can override the effective HTTP protocol, and `PORT` can override the effective port.
|
||||||
- `DB_PATH` can override the SQLite database file location.
|
- `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`.
|
- `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.
|
- `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.
|
- 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.
|
- When HTTPS is enabled, certificates are read from the repository `.certs/` directory.
|
||||||
|
|||||||
Binary file not shown.
@@ -25,6 +25,15 @@ export class ServerPluginRequirementEntity {
|
|||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
reason!: string | null;
|
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 })
|
@Column('text', { nullable: true })
|
||||||
configuredBy!: string | null;
|
configuredBy!: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -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")`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeSer
|
|||||||
import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl';
|
import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl';
|
||||||
import { GameMatchMisses1000000000006 } from './1000000000006-GameMatchMisses';
|
import { GameMatchMisses1000000000006 } from './1000000000006-GameMatchMisses';
|
||||||
import { PluginSupport1000000000007 } from './1000000000007-PluginSupport';
|
import { PluginSupport1000000000007 } from './1000000000007-PluginSupport';
|
||||||
|
import { ServerPluginInstallMetadata1000000000008 } from './1000000000008-ServerPluginInstallMetadata';
|
||||||
|
|
||||||
export const serverMigrations = [
|
export const serverMigrations = [
|
||||||
InitialSchema1000000000000,
|
InitialSchema1000000000000,
|
||||||
@@ -15,5 +16,6 @@ export const serverMigrations = [
|
|||||||
NormalizeServerArrays1000000000004,
|
NormalizeServerArrays1000000000004,
|
||||||
ServerRoleAccessControl1000000000005,
|
ServerRoleAccessControl1000000000005,
|
||||||
GameMatchMisses1000000000006,
|
GameMatchMisses1000000000006,
|
||||||
PluginSupport1000000000007
|
PluginSupport1000000000007,
|
||||||
|
ServerPluginInstallMetadata1000000000008
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ function createOpenApiDocument(baseUrl: string) {
|
|||||||
info: {
|
info: {
|
||||||
title: 'MetoYou Plugin Support API',
|
title: 'MetoYou Plugin Support API',
|
||||||
version: '1.0.0',
|
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.'
|
+ 'Plugin code is never executed by the signal server.'
|
||||||
},
|
},
|
||||||
servers: [{ url: `${baseUrl}/api` }],
|
servers: [{ url: `${baseUrl}/api` }],
|
||||||
@@ -43,18 +43,18 @@ function createOpenApiDocument(baseUrl: string) {
|
|||||||
},
|
},
|
||||||
'/servers/{serverId}/plugins/{pluginId}/data': {
|
'/servers/{serverId}/plugins/{pluginId}/data': {
|
||||||
get: {
|
get: {
|
||||||
summary: 'List plugin data records',
|
summary: 'Plugin data persistence disabled',
|
||||||
responses: { '200': { description: 'Plugin data records' }, '403': { description: 'Not a server member' } }
|
responses: { '410': { description: 'Plugin data persistence is disabled on the signal server' } }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'/servers/{serverId}/plugins/{pluginId}/data/{key}': {
|
'/servers/{serverId}/plugins/{pluginId}/data/{key}': {
|
||||||
put: {
|
put: {
|
||||||
summary: 'Write plugin data',
|
summary: 'Plugin data persistence disabled',
|
||||||
responses: { '200': { description: 'Plugin data saved' }, '403': { description: 'Not a server member' } }
|
responses: { '410': { description: 'Plugin data persistence is disabled on the signal server' } }
|
||||||
},
|
},
|
||||||
delete: {
|
delete: {
|
||||||
summary: 'Delete plugin data',
|
summary: 'Plugin data persistence disabled',
|
||||||
responses: { '200': { description: 'Plugin data deleted' }, '403': { description: 'Not a server member' } }
|
responses: { '410': { description: 'Plugin data persistence is disabled on the signal server' } }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'/openapi/settings': {
|
'/openapi/settings': {
|
||||||
@@ -98,7 +98,7 @@ router.get('/docs', (_req, res) => {
|
|||||||
<body style="font-family:system-ui;margin:2rem;line-height:1.5">
|
<body style="font-family:system-ui;margin:2rem;line-height:1.5">
|
||||||
<h1>MetoYou Plugin Support API</h1>
|
<h1>MetoYou Plugin Support API</h1>
|
||||||
<p>Plugin support endpoints are available at <a href="/api/openapi.json">/api/openapi.json</a>.</p>
|
<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>
|
</body>
|
||||||
</html>`);
|
</html>`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { Response, Router } from 'express';
|
import { Response, Router } from 'express';
|
||||||
import {
|
import {
|
||||||
deletePluginData,
|
|
||||||
deletePluginEventDefinition,
|
deletePluginEventDefinition,
|
||||||
deletePluginRequirement,
|
deletePluginRequirement,
|
||||||
getPluginRequirementsSnapshot,
|
getPluginRequirementsSnapshot,
|
||||||
listPluginData,
|
|
||||||
PluginSupportError,
|
PluginSupportError,
|
||||||
upsertPluginData,
|
|
||||||
upsertPluginEventDefinition,
|
upsertPluginEventDefinition,
|
||||||
upsertPluginRequirement
|
upsertPluginRequirement
|
||||||
} from '../services/plugin-support.service';
|
} from '../services/plugin-support.service';
|
||||||
@@ -52,9 +49,12 @@ router.put('/:serverId/plugins/:pluginId/requirement', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const requirement = await upsertPluginRequirement({
|
const requirement = await upsertPluginRequirement({
|
||||||
actorUserId: readActorUserId(req.body.actorUserId),
|
actorUserId: readActorUserId(req.body.actorUserId),
|
||||||
|
installUrl: req.body.installUrl,
|
||||||
|
manifest: req.body.manifest,
|
||||||
pluginId,
|
pluginId,
|
||||||
reason: req.body.reason,
|
reason: req.body.reason,
|
||||||
serverId,
|
serverId,
|
||||||
|
sourceUrl: req.body.sourceUrl,
|
||||||
status: req.body.status,
|
status: req.body.status,
|
||||||
versionRange: req.body.versionRange
|
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) => {
|
router.get('/:serverId/plugins/:pluginId/data', (_req, res) => {
|
||||||
const { serverId, pluginId } = req.params;
|
res.status(410).json({
|
||||||
|
error: 'Plugin data persistence is disabled on the signal server',
|
||||||
try {
|
errorCode: 'PLUGIN_DATA_DISABLED'
|
||||||
const records = await listPluginData({
|
});
|
||||||
actorUserId: readActorUserId(req.query.userId),
|
|
||||||
key: req.query.key,
|
|
||||||
ownerId: req.query.ownerId,
|
|
||||||
pluginId,
|
|
||||||
scope: req.query.scope,
|
|
||||||
serverId
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ records });
|
|
||||||
} catch (error) {
|
|
||||||
sendPluginSupportError(error, res);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:serverId/plugins/:pluginId/data/:key', async (req, res) => {
|
router.put('/:serverId/plugins/:pluginId/data/:key', (_req, res) => {
|
||||||
const { serverId, pluginId, key } = req.params;
|
res.status(410).json({
|
||||||
|
error: 'Plugin data persistence is disabled on the signal server',
|
||||||
try {
|
errorCode: 'PLUGIN_DATA_DISABLED'
|
||||||
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
|
|
||||||
});
|
|
||||||
|
|
||||||
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) => {
|
router.delete('/:serverId/plugins/:pluginId/data/:key', (_req, res) => {
|
||||||
const { serverId, pluginId, key } = req.params;
|
res.status(410).json({
|
||||||
const scope = req.body.scope ?? req.query.scope;
|
error: 'Plugin data persistence is disabled on the signal server',
|
||||||
const ownerId = req.body.ownerId ?? req.query.ownerId;
|
errorCode: 'PLUGIN_DATA_DISABLED'
|
||||||
|
});
|
||||||
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;
|
export default router;
|
||||||
|
|||||||
@@ -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}$/;
|
const DATA_SCOPE_PATTERN = /^[a-zA-Z][a-zA-Z0-9._:-]{0,63}$/;
|
||||||
|
|
||||||
export interface PluginRequirementSummary {
|
export interface PluginRequirementSummary {
|
||||||
|
installUrl?: string;
|
||||||
|
manifest?: unknown;
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
status: ServerPluginRequirementStatus;
|
status: ServerPluginRequirementStatus;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
versionRange?: string;
|
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 {
|
function serializeJsonValue(value: unknown, code: string): string {
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value ?? null);
|
return JSON.stringify(value ?? null);
|
||||||
@@ -184,8 +191,11 @@ function serializeJsonValue(value: unknown, code: string): string {
|
|||||||
|
|
||||||
function toRequirementSummary(entity: ServerPluginRequirementEntity): PluginRequirementSummary {
|
function toRequirementSummary(entity: ServerPluginRequirementEntity): PluginRequirementSummary {
|
||||||
return {
|
return {
|
||||||
|
installUrl: entity.installUrl ?? undefined,
|
||||||
|
manifest: parseOptionalJsonValue(entity.manifestJson),
|
||||||
pluginId: entity.pluginId,
|
pluginId: entity.pluginId,
|
||||||
reason: entity.reason ?? undefined,
|
reason: entity.reason ?? undefined,
|
||||||
|
sourceUrl: entity.sourceUrl ?? undefined,
|
||||||
status: entity.status,
|
status: entity.status,
|
||||||
updatedAt: entity.updatedAt,
|
updatedAt: entity.updatedAt,
|
||||||
versionRange: entity.versionRange ?? undefined
|
versionRange: entity.versionRange ?? undefined
|
||||||
@@ -282,9 +292,12 @@ export async function getPluginRequirementsSnapshot(serverId: string): Promise<P
|
|||||||
|
|
||||||
export async function upsertPluginRequirement(options: {
|
export async function upsertPluginRequirement(options: {
|
||||||
actorUserId: string;
|
actorUserId: string;
|
||||||
|
installUrl?: unknown;
|
||||||
|
manifest?: unknown;
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
reason?: unknown;
|
reason?: unknown;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
sourceUrl?: unknown;
|
||||||
status: unknown;
|
status: unknown;
|
||||||
versionRange?: unknown;
|
versionRange?: unknown;
|
||||||
}): Promise<PluginRequirementSummary> {
|
}): Promise<PluginRequirementSummary> {
|
||||||
@@ -306,6 +319,9 @@ export async function upsertPluginRequirement(options: {
|
|||||||
status: status as ServerPluginRequirementStatus,
|
status: status as ServerPluginRequirementStatus,
|
||||||
versionRange: normalizeOptionalString(options.versionRange, 128),
|
versionRange: normalizeOptionalString(options.versionRange, 128),
|
||||||
reason: normalizeOptionalString(options.reason, 512),
|
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,
|
configuredBy: options.actorUserId,
|
||||||
createdAt: existing?.createdAt ?? now,
|
createdAt: existing?.createdAt ?? now,
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
|
|||||||
@@ -96,13 +96,13 @@
|
|||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "2.5MB",
|
"maximumWarning": "10mb",
|
||||||
"maximumError": "2.6MB"
|
"maximumError": "20mb"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
"maximumWarning": "7kB",
|
"maximumWarning": "10mb",
|
||||||
"maximumError": "8kB"
|
"maximumError": "20mb"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all"
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ export async function activate(context) {
|
|||||||
|
|
||||||
api.storage.set('coverage', { ok: true });
|
api.storage.set('coverage', { ok: true });
|
||||||
api.storage.get('coverage');
|
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.write('coverage', { ok: true });
|
||||||
await api.serverData.read('coverage');
|
await api.serverData.read('coverage');
|
||||||
|
|
||||||
@@ -127,6 +129,23 @@ export async function activate(context) {
|
|||||||
});
|
});
|
||||||
api.messages.moderateDelete('missing-message-id');
|
api.messages.moderateDelete('missing-message-id');
|
||||||
api.messages.sync(api.messages.readCurrent());
|
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.connectedPeers();
|
||||||
api.p2p.broadcastData('e2e:p2p', { ok: true });
|
api.p2p.broadcastData('e2e:p2p', { ok: true });
|
||||||
@@ -146,6 +165,7 @@ export async function activate(context) {
|
|||||||
await audioContext.close();
|
await audioContext.close();
|
||||||
|
|
||||||
api.storage.remove('coverage');
|
api.storage.remove('coverage');
|
||||||
|
await api.clientData.remove('coverage');
|
||||||
await api.serverData.remove('coverage');
|
await api.serverData.remove('coverage');
|
||||||
api.logger.info('all-api plugin completed');
|
api.logger.info('all-api plugin completed');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"description": "Calls every public Toju plugin API surface for user-facing Playwright coverage.",
|
"description": "Calls every public Toju plugin API surface for user-facing Playwright coverage.",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"kind": "client",
|
"kind": "client",
|
||||||
|
"scope": "server",
|
||||||
"apiVersion": "1.0.0",
|
"apiVersion": "1.0.0",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"minimumTojuVersion": "1.0.0",
|
"minimumTojuVersion": "1.0.0",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"title": "E2E All API Plugin",
|
"title": "E2E All API Plugin",
|
||||||
"description": "Test plugin that calls every public Toju plugin API surface.",
|
"description": "Test plugin that calls every public Toju plugin API surface.",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"scope": "server",
|
||||||
"author": "MetoYou Tests",
|
"author": "MetoYou Tests",
|
||||||
"image": "./e2e-all-api/icon.svg",
|
"image": "./e2e-all-api/icon.svg",
|
||||||
"github": "https://git.azaaxin.com/myxelium/Toju",
|
"github": "https://git.azaaxin.com/myxelium/Toju",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type SettingsPage =
|
|||||||
| 'data'
|
| 'data'
|
||||||
| 'debugging'
|
| 'debugging'
|
||||||
| 'server'
|
| 'server'
|
||||||
|
| 'serverPlugins'
|
||||||
| 'members'
|
| 'members'
|
||||||
| 'bans'
|
| 'bans'
|
||||||
| 'permissions';
|
| 'permissions';
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
computed,
|
computed,
|
||||||
|
inject,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
|
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
|
||||||
import type { PluginCapabilityId, TojuPluginManifest } from '../../../../shared-kernel';
|
import type { PluginCapabilityId, TojuPluginManifest } from '../../../../shared-kernel';
|
||||||
|
import { PluginDesktopStateService } from './plugin-desktop-state.service';
|
||||||
|
|
||||||
const STORAGE_KEY_PLUGIN_CAPABILITIES = 'metoyou_plugin_capability_grants';
|
const STORAGE_KEY_PLUGIN_CAPABILITIES = 'metoyou_plugin_capability_grants';
|
||||||
|
|
||||||
@@ -19,15 +21,20 @@ export class PluginCapabilityError extends Error {
|
|||||||
export class PluginCapabilityService {
|
export class PluginCapabilityService {
|
||||||
readonly grants = computed(() => this.grantsSignal());
|
readonly grants = computed(() => this.grantsSignal());
|
||||||
|
|
||||||
|
private readonly desktopState = inject(PluginDesktopStateService);
|
||||||
private readonly grantsSignal = signal<Record<string, PluginCapabilityId[]>>(this.loadGrants());
|
private readonly grantsSignal = signal<Record<string, PluginCapabilityId[]>>(this.loadGrants());
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
void this.loadDesktopGrants();
|
||||||
|
}
|
||||||
|
|
||||||
grant(pluginId: string, capability: PluginCapabilityId): void {
|
grant(pluginId: string, capability: PluginCapabilityId): void {
|
||||||
this.grantsSignal.update((grants) => ({
|
this.grantsSignal.update((grants) => ({
|
||||||
...grants,
|
...grants,
|
||||||
[pluginId]: Array.from(new Set([...(grants[pluginId] ?? []), capability])).sort()
|
[pluginId]: Array.from(new Set([...(grants[pluginId] ?? []), capability])).sort()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.saveGrants();
|
void this.saveGrants();
|
||||||
}
|
}
|
||||||
|
|
||||||
grantAll(manifest: TojuPluginManifest): void {
|
grantAll(manifest: TojuPluginManifest): void {
|
||||||
@@ -36,7 +43,7 @@ export class PluginCapabilityService {
|
|||||||
[manifest.id]: [...(manifest.capabilities ?? [])].sort()
|
[manifest.id]: [...(manifest.capabilities ?? [])].sort()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.saveGrants();
|
void this.saveGrants();
|
||||||
}
|
}
|
||||||
|
|
||||||
revoke(pluginId: string, capability: PluginCapabilityId): void {
|
revoke(pluginId: string, capability: PluginCapabilityId): void {
|
||||||
@@ -45,7 +52,7 @@ export class PluginCapabilityService {
|
|||||||
[pluginId]: (grants[pluginId] ?? []).filter((entry) => entry !== capability)
|
[pluginId]: (grants[pluginId] ?? []).filter((entry) => entry !== capability)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.saveGrants();
|
void this.saveGrants();
|
||||||
}
|
}
|
||||||
|
|
||||||
revokeAll(pluginId: string): void {
|
revokeAll(pluginId: string): void {
|
||||||
@@ -55,7 +62,7 @@ export class PluginCapabilityService {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.saveGrants();
|
void this.saveGrants();
|
||||||
}
|
}
|
||||||
|
|
||||||
has(pluginId: string, capability: PluginCapabilityId): boolean {
|
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 {
|
try {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
getUserScopedStorageKey(STORAGE_KEY_PLUGIN_CAPABILITIES),
|
getUserScopedStorageKey(STORAGE_KEY_PLUGIN_CAPABILITIES),
|
||||||
JSON.stringify(this.grantsSignal())
|
JSON.stringify(this.grantsSignal())
|
||||||
);
|
);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
await this.desktopState.writeJson(STORAGE_KEY_PLUGIN_CAPABILITIES, this.grantsSignal());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import type {
|
|||||||
} from '../../domain/models/plugin-api.models';
|
} from '../../domain/models/plugin-api.models';
|
||||||
import { PluginCapabilityService } from './plugin-capability.service';
|
import { PluginCapabilityService } from './plugin-capability.service';
|
||||||
import { PluginLoggerService } from './plugin-logger.service';
|
import { PluginLoggerService } from './plugin-logger.service';
|
||||||
|
import { PluginMessageBusService } from './plugin-message-bus.service';
|
||||||
import { PluginStorageService } from './plugin-storage.service';
|
import { PluginStorageService } from './plugin-storage.service';
|
||||||
import { PluginUiRegistryService } from './plugin-ui-registry.service';
|
import { PluginUiRegistryService } from './plugin-ui-registry.service';
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ import { PluginUiRegistryService } from './plugin-ui-registry.service';
|
|||||||
export class PluginClientApiService {
|
export class PluginClientApiService {
|
||||||
private readonly capabilities = inject(PluginCapabilityService);
|
private readonly capabilities = inject(PluginCapabilityService);
|
||||||
private readonly logger = inject(PluginLoggerService);
|
private readonly logger = inject(PluginLoggerService);
|
||||||
|
private readonly messageBus = inject(PluginMessageBusService);
|
||||||
private readonly realtime = inject(RealtimeSessionFacade);
|
private readonly realtime = inject(RealtimeSessionFacade);
|
||||||
private readonly store = inject(Store);
|
private readonly store = inject(Store);
|
||||||
private readonly storage = inject(PluginStorageService);
|
private readonly storage = inject(PluginStorageService);
|
||||||
@@ -118,6 +120,20 @@ export class PluginClientApiService {
|
|||||||
info: (message, data) => this.logger.info(pluginId, message, data),
|
info: (message, data) => this.logger.info(pluginId, message, data),
|
||||||
warn: (message, data) => this.logger.warn(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: {
|
media: {
|
||||||
addCustomAudioStream: async (request) => {
|
addCustomAudioStream: async (request) => {
|
||||||
requireCapability('media.addAudioStream');
|
requireCapability('media.addAudioStream');
|
||||||
@@ -170,6 +186,31 @@ export class PluginClientApiService {
|
|||||||
this.store.dispatch(MessagesActions.syncMessages({ messages }));
|
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: {
|
p2p: {
|
||||||
broadcastData: (eventName, payload) => {
|
broadcastData: (eventName, payload) => {
|
||||||
requireCapability('p2p.data');
|
requireCapability('p2p.data');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import type { LocalPluginDiscoveryResult } from '../../domain/models/plugin-runt
|
|||||||
import { LocalPluginDiscoveryService } from '../../infrastructure/local-plugin-discovery.service';
|
import { LocalPluginDiscoveryService } from '../../infrastructure/local-plugin-discovery.service';
|
||||||
import { PluginCapabilityService } from './plugin-capability.service';
|
import { PluginCapabilityService } from './plugin-capability.service';
|
||||||
import { PluginClientApiService } from './plugin-client-api.service';
|
import { PluginClientApiService } from './plugin-client-api.service';
|
||||||
|
import { PluginDesktopStateService } from './plugin-desktop-state.service';
|
||||||
import { PluginHostService } from './plugin-host.service';
|
import { PluginHostService } from './plugin-host.service';
|
||||||
import { PluginLoggerService } from './plugin-logger.service';
|
import { PluginLoggerService } from './plugin-logger.service';
|
||||||
import { PluginRegistryService } from './plugin-registry.service';
|
import { PluginRegistryService } from './plugin-registry.service';
|
||||||
@@ -99,6 +100,13 @@ function createHostService(readDiscoveryResult: () => LocalPluginDiscoveryResult
|
|||||||
missing: vi.fn(() => [])
|
missing: vi.fn(() => [])
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: PluginDesktopStateService,
|
||||||
|
useValue: {
|
||||||
|
readJson: vi.fn(async (_key: string, fallback: unknown) => fallback),
|
||||||
|
writeJson: vi.fn(async () => undefined)
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: PluginClientApiService,
|
provide: PluginClientApiService,
|
||||||
useValue: {
|
useValue: {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { environment } from '../../../../../environments/environment';
|
import { environment } from '../../../../../environments/environment';
|
||||||
|
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||||
import type { TojuPluginManifest } from '../../../../shared-kernel';
|
import type { TojuPluginManifest } from '../../../../shared-kernel';
|
||||||
import {
|
import {
|
||||||
DEVELOPMENT_PLUGIN_ENTRYPOINT,
|
DEVELOPMENT_PLUGIN_ENTRYPOINT,
|
||||||
@@ -18,6 +19,7 @@ import type {
|
|||||||
} from '../../domain/models/plugin-runtime.models';
|
} from '../../domain/models/plugin-runtime.models';
|
||||||
import { LocalPluginDiscoveryService } from '../../infrastructure/local-plugin-discovery.service';
|
import { LocalPluginDiscoveryService } from '../../infrastructure/local-plugin-discovery.service';
|
||||||
import { PluginCapabilityService } from './plugin-capability.service';
|
import { PluginCapabilityService } from './plugin-capability.service';
|
||||||
|
import { PluginDesktopStateService } from './plugin-desktop-state.service';
|
||||||
import { PluginClientApiService } from './plugin-client-api.service';
|
import { PluginClientApiService } from './plugin-client-api.service';
|
||||||
import { PluginLoggerService } from './plugin-logger.service';
|
import { PluginLoggerService } from './plugin-logger.service';
|
||||||
import { PluginRegistryService } from './plugin-registry.service';
|
import { PluginRegistryService } from './plugin-registry.service';
|
||||||
@@ -25,21 +27,29 @@ import { PluginUiRegistryService } from './plugin-ui-registry.service';
|
|||||||
|
|
||||||
interface ActivePluginRuntime {
|
interface ActivePluginRuntime {
|
||||||
context: TojuPluginActivationContext;
|
context: TojuPluginActivationContext;
|
||||||
|
moduleObjectUrl?: string;
|
||||||
module: TojuClientPluginModule;
|
module: TojuClientPluginModule;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY_PLUGIN_ACTIVATION = 'metoyou_plugin_activation_state';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class PluginHostService {
|
export class PluginHostService {
|
||||||
private readonly apiFactory = inject(PluginClientApiService);
|
private readonly apiFactory = inject(PluginClientApiService);
|
||||||
private readonly capabilities = inject(PluginCapabilityService);
|
private readonly capabilities = inject(PluginCapabilityService);
|
||||||
|
private readonly desktopState = inject(PluginDesktopStateService);
|
||||||
|
private readonly electronBridge = inject(ElectronBridgeService, { optional: true });
|
||||||
private readonly localDiscovery = inject(LocalPluginDiscoveryService);
|
private readonly localDiscovery = inject(LocalPluginDiscoveryService);
|
||||||
private readonly logger = inject(PluginLoggerService);
|
private readonly logger = inject(PluginLoggerService);
|
||||||
private readonly registry = inject(PluginRegistryService);
|
private readonly registry = inject(PluginRegistryService);
|
||||||
private readonly uiRegistry = inject(PluginUiRegistryService);
|
private readonly uiRegistry = inject(PluginUiRegistryService);
|
||||||
private readonly activePlugins = new Map<string, ActivePluginRuntime>();
|
private readonly activePlugins = new Map<string, ActivePluginRuntime>();
|
||||||
|
private readonly activationStateReady: Promise<void>;
|
||||||
|
private activatedPluginIds = new Set<string>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.registerDevelopmentPlugin();
|
this.registerDevelopmentPlugin();
|
||||||
|
this.activationStateReady = this.loadActivationState();
|
||||||
}
|
}
|
||||||
|
|
||||||
registerLocalManifest(manifestValue: unknown, sourcePath?: string): RegisteredPlugin {
|
registerLocalManifest(manifestValue: unknown, sourcePath?: string): RegisteredPlugin {
|
||||||
@@ -75,6 +85,8 @@ export class PluginHostService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async activateReadyPlugins(): Promise<void> {
|
async activateReadyPlugins(): Promise<void> {
|
||||||
|
await this.activationStateReady;
|
||||||
|
|
||||||
const activated: TojuPluginActivationContext[] = [];
|
const activated: TojuPluginActivationContext[] = [];
|
||||||
|
|
||||||
for (const manifest of this.registry.loadOrder().ordered) {
|
for (const manifest of this.registry.loadOrder().ordered) {
|
||||||
@@ -90,29 +102,92 @@ export class PluginHostService {
|
|||||||
|
|
||||||
if (active) {
|
if (active) {
|
||||||
activated.push(active.context);
|
activated.push(active.context);
|
||||||
|
this.activatedPluginIds.add(active.context.pluginId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const context of activated) {
|
await this.saveActivationState();
|
||||||
const active = this.activePlugins.get(context.pluginId);
|
|
||||||
|
|
||||||
if (!active?.module.ready) {
|
await this.runReadyHooks(activated);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await active.module.ready(context);
|
|
||||||
this.registry.setState(context.pluginId, 'ready');
|
|
||||||
} catch (error) {
|
|
||||||
this.failPlugin(context.pluginId, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deactivatePlugin(pluginId: string): Promise<void> {
|
async activatePluginById(pluginId: string): Promise<void> {
|
||||||
|
await this.activationStateReady;
|
||||||
|
|
||||||
|
if (this.activePlugins.has(pluginId)) {
|
||||||
|
this.activatedPluginIds.add(pluginId);
|
||||||
|
await this.saveActivationState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = this.registry.find(pluginId);
|
||||||
|
|
||||||
|
if (!entry?.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.activatePlugin(entry);
|
||||||
|
|
||||||
const active = this.activePlugins.get(pluginId);
|
const active = this.activePlugins.get(pluginId);
|
||||||
|
|
||||||
if (!active) {
|
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.registry.setState(pluginId, 'unloaded');
|
||||||
this.uiRegistry.unregisterPlugin(pluginId);
|
this.uiRegistry.unregisterPlugin(pluginId);
|
||||||
return;
|
return;
|
||||||
@@ -132,6 +207,13 @@ export class PluginHostService {
|
|||||||
|
|
||||||
this.uiRegistry.unregisterPlugin(pluginId);
|
this.uiRegistry.unregisterPlugin(pluginId);
|
||||||
this.activePlugins.delete(pluginId);
|
this.activePlugins.delete(pluginId);
|
||||||
|
this.revokeModuleObjectUrl(pluginId);
|
||||||
|
|
||||||
|
if (options.forgetActivation) {
|
||||||
|
this.activatedPluginIds.delete(pluginId);
|
||||||
|
await this.saveActivationState();
|
||||||
|
}
|
||||||
|
|
||||||
this.registry.setState(pluginId, 'unloaded');
|
this.registry.setState(pluginId, 'unloaded');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +232,11 @@ export class PluginHostService {
|
|||||||
|
|
||||||
if (entry?.enabled) {
|
if (entry?.enabled) {
|
||||||
await this.activatePlugin(entry);
|
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');
|
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> {
|
private async activatePlugin(entry: RegisteredPlugin): Promise<void> {
|
||||||
const manifest = entry.manifest;
|
const manifest = entry.manifest;
|
||||||
const missingCapabilities = this.capabilities.missing(manifest);
|
const missingCapabilities = this.capabilities.missing(manifest);
|
||||||
@@ -179,7 +283,7 @@ export class PluginHostService {
|
|||||||
this.registry.setState(manifest.id, 'loading');
|
this.registry.setState(manifest.id, 'loading');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const module = await this.loadPluginModule(manifest, entry.sourcePath);
|
const { module, moduleObjectUrl } = await this.loadPluginModule(manifest, entry.sourcePath);
|
||||||
const context: TojuPluginActivationContext = {
|
const context: TojuPluginActivationContext = {
|
||||||
api: this.apiFactory.createApi(manifest),
|
api: this.apiFactory.createApi(manifest),
|
||||||
manifest,
|
manifest,
|
||||||
@@ -188,7 +292,7 @@ export class PluginHostService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await module.activate?.(context);
|
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.registry.setState(manifest.id, 'loaded');
|
||||||
this.logger.info(manifest.id, 'Plugin activated');
|
this.logger.info(manifest.id, 'Plugin activated');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -203,14 +307,51 @@ export class PluginHostService {
|
|||||||
this.logger.error(pluginId, message, error);
|
this.logger.error(pluginId, message, error);
|
||||||
this.uiRegistry.unregisterPlugin(pluginId);
|
this.uiRegistry.unregisterPlugin(pluginId);
|
||||||
this.activePlugins.delete(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) {
|
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 {
|
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 {
|
private resolveEntrypoint(manifest: TojuPluginManifest, sourcePath?: string): string {
|
||||||
if (!manifest.entrypoint) {
|
if (!manifest.entrypoint) {
|
||||||
throw new Error('Plugin entrypoint is missing');
|
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 {
|
function safeDispose(disposable: TojuPluginDisposable, pluginId: string, logger: PluginLoggerService): void {
|
||||||
try {
|
try {
|
||||||
disposable.dispose();
|
disposable.dispose();
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
type Signal,
|
type Signal,
|
||||||
computed,
|
computed,
|
||||||
|
inject,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +12,9 @@ import {
|
|||||||
} from '../../domain/models/plugin-runtime.models';
|
} from '../../domain/models/plugin-runtime.models';
|
||||||
import { resolvePluginLoadOrder } from '../../domain/logic/plugin-dependency-resolver.logic';
|
import { resolvePluginLoadOrder } from '../../domain/logic/plugin-dependency-resolver.logic';
|
||||||
import { validateTojuPluginManifest } from '../../domain/logic/plugin-manifest-validation.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' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class PluginRegistryService {
|
export class PluginRegistryService {
|
||||||
@@ -18,7 +22,9 @@ export class PluginRegistryService {
|
|||||||
readonly enabledEntries: Signal<RegisteredPlugin[]>;
|
readonly enabledEntries: Signal<RegisteredPlugin[]>;
|
||||||
readonly loadOrder: Signal<PluginLoadOrderResult>;
|
readonly loadOrder: Signal<PluginLoadOrderResult>;
|
||||||
|
|
||||||
|
private readonly desktopState = inject(PluginDesktopStateService);
|
||||||
private readonly entriesSignal = signal<RegisteredPlugin[]>([]);
|
private readonly entriesSignal = signal<RegisteredPlugin[]>([]);
|
||||||
|
private disabledPluginIds = new Set<string>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.entries = this.entriesSignal.asReadonly();
|
this.entries = this.entriesSignal.asReadonly();
|
||||||
@@ -26,6 +32,8 @@ export class PluginRegistryService {
|
|||||||
this.loadOrder = computed<PluginLoadOrderResult>(() =>
|
this.loadOrder = computed<PluginLoadOrderResult>(() =>
|
||||||
resolvePluginLoadOrder(this.entries().map((entry) => ({ enabled: entry.enabled, manifest: entry.manifest })))
|
resolvePluginLoadOrder(this.entries().map((entry) => ({ enabled: entry.enabled, manifest: entry.manifest })))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
void this.loadRegistryState();
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
@@ -41,7 +49,7 @@ export class PluginRegistryService {
|
|||||||
|
|
||||||
const existingIndex = this.entries().findIndex((entry) => entry.manifest.id === validation.manifest?.id);
|
const existingIndex = this.entries().findIndex((entry) => entry.manifest.id === validation.manifest?.id);
|
||||||
const entry: RegisteredPlugin = {
|
const entry: RegisteredPlugin = {
|
||||||
enabled: true,
|
enabled: !this.disabledPluginIds.has(validation.manifest.id),
|
||||||
manifest: validation.manifest,
|
manifest: validation.manifest,
|
||||||
sourcePath,
|
sourcePath,
|
||||||
state: validation.valid ? 'validated' : 'blocked',
|
state: validation.valid ? 'validated' : 'blocked',
|
||||||
@@ -59,6 +67,12 @@ export class PluginRegistryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setEnabled(pluginId: string, enabled: boolean): void {
|
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
|
this.entriesSignal.update((entries) => entries.map((entry) => entry.manifest.id === pluginId
|
||||||
? {
|
? {
|
||||||
...entry,
|
...entry,
|
||||||
@@ -68,6 +82,7 @@ export class PluginRegistryService {
|
|||||||
: entry));
|
: entry));
|
||||||
|
|
||||||
this.syncLoadState();
|
this.syncLoadState();
|
||||||
|
void this.saveRegistryState();
|
||||||
}
|
}
|
||||||
|
|
||||||
unregister(pluginId: string): void {
|
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()
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ import type {
|
|||||||
PluginEventDefinitionSummary,
|
PluginEventDefinitionSummary,
|
||||||
PluginRequirementStatus,
|
PluginRequirementStatus,
|
||||||
PluginRequirementSummary,
|
PluginRequirementSummary,
|
||||||
PluginRequirementsSnapshot
|
PluginRequirementsSnapshot,
|
||||||
|
TojuPluginManifest
|
||||||
} from '../../../../shared-kernel';
|
} from '../../../../shared-kernel';
|
||||||
|
|
||||||
export interface UpsertPluginRequirementRequest {
|
export interface UpsertPluginRequirementRequest {
|
||||||
actorUserId: string;
|
actorUserId: string;
|
||||||
|
installUrl?: string;
|
||||||
|
manifest?: TojuPluginManifest;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
status: PluginRequirementStatus;
|
status: PluginRequirementStatus;
|
||||||
versionRange?: string;
|
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(
|
upsertEventDefinition(
|
||||||
apiBaseUrl: string,
|
apiBaseUrl: string,
|
||||||
serverId: string,
|
serverId: string,
|
||||||
|
|||||||
@@ -1,30 +1,19 @@
|
|||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { firstValueFrom } from 'rxjs';
|
|
||||||
import { getUserScopedStorageKey } from '../../../../core/storage/current-user-storage';
|
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 { 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_LOCAL = 'metoyou_plugin_local';
|
||||||
|
const STORAGE_PREFIX_PLUGIN_SERVER_LOCAL = 'metoyou_plugin_server_local';
|
||||||
|
|
||||||
interface PluginDataResponse {
|
type PluginDataScope = 'local' | 'server';
|
||||||
record?: {
|
|
||||||
value: unknown;
|
|
||||||
};
|
|
||||||
records?: {
|
|
||||||
value: unknown;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class PluginStorageService {
|
export class PluginStorageService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
|
||||||
private readonly store = inject(Store);
|
private readonly store = inject(Store);
|
||||||
private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId);
|
private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId);
|
||||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
|
||||||
|
|
||||||
getLocal(pluginId: string, key: string): unknown {
|
getLocal(pluginId: string, key: string): unknown {
|
||||||
return this.read(`${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`);
|
return this.read(`${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`);
|
||||||
@@ -32,61 +21,113 @@ export class PluginStorageService {
|
|||||||
|
|
||||||
removeLocal(pluginId: string, key: string): void {
|
removeLocal(pluginId: string, key: string): void {
|
||||||
localStorage.removeItem(getUserScopedStorageKey(`${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`));
|
localStorage.removeItem(getUserScopedStorageKey(`${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`));
|
||||||
|
void this.deleteFromClientDatabase(pluginId, 'local', key);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLocal(pluginId: string, key: string, value: unknown): void {
|
setLocal(pluginId: string, key: string, value: unknown): void {
|
||||||
this.write(`${STORAGE_PREFIX_PLUGIN_LOCAL}:${pluginId}:${key}`, value);
|
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> {
|
async readServerData(pluginId: string, key: string): Promise<unknown> {
|
||||||
const response = await firstValueFrom(this.http.get<PluginDataResponse>(`${this.pluginsApi(pluginId)}/data`, {
|
return await this.readScopedData(pluginId, 'server', key, this.requireRoomId());
|
||||||
params: new HttpParams()
|
|
||||||
.set('key', key)
|
|
||||||
.set('scope', 'server')
|
|
||||||
.set('userId', this.requireActorUserId())
|
|
||||||
}));
|
|
||||||
|
|
||||||
return response.records?.[0]?.value ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeServerData(pluginId: string, key: string): Promise<void> {
|
async removeServerData(pluginId: string, key: string): Promise<void> {
|
||||||
await firstValueFrom(this.http.delete(`${this.pluginsApi(pluginId)}/data/${encodeURIComponent(key)}`, {
|
localStorage.removeItem(getUserScopedStorageKey(this.serverLocalKey(pluginId, key)));
|
||||||
body: {
|
await this.deleteFromClientDatabase(pluginId, 'server', key, this.requireRoomId());
|
||||||
actorUserId: this.requireActorUserId(),
|
|
||||||
scope: 'server'
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async writeServerData(pluginId: string, key: string, value: unknown): Promise<void> {
|
async writeServerData(pluginId: string, key: string, value: unknown): Promise<void> {
|
||||||
await firstValueFrom(this.http.put<PluginDataResponse>(`${this.pluginsApi(pluginId)}/data/${encodeURIComponent(key)}`, {
|
this.write(this.serverLocalKey(pluginId, key), value);
|
||||||
actorUserId: this.requireActorUserId(),
|
await this.writeToClientDatabase(pluginId, 'server', key, value, this.requireRoomId());
|
||||||
scope: 'server',
|
|
||||||
value
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
const roomId = this.currentRoomId();
|
||||||
|
|
||||||
if (!roomId) {
|
if (!roomId) {
|
||||||
throw new Error('No active server for plugin server data');
|
throw new Error('No active server for plugin server data');
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiBase = this.serverDirectory.getApiBaseUrl();
|
return roomId;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private read(key: string): unknown {
|
private read(key: string): unknown {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Injector } from '@angular/core';
|
import { Injector } from '@angular/core';
|
||||||
import type { TojuPluginManifest } from '../../../../shared-kernel';
|
import type { TojuPluginManifest } from '../../../../shared-kernel';
|
||||||
|
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||||
import { PluginStoreService } from './plugin-store.service';
|
import { PluginStoreService } from './plugin-store.service';
|
||||||
import { PluginHostService } from './plugin-host.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 { PluginRegistryService } from './plugin-registry.service';
|
||||||
import type { PluginStoreEntry } from '../../domain/models/plugin-store.models';
|
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 () => {
|
it('installs, detects updates, and uninstalls store plugins', async () => {
|
||||||
const manifest = createManifest({ version: '1.0.0' });
|
const manifest = createManifest({ version: '1.0.0' });
|
||||||
const plugin = createStoreEntry({ 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(plugin)).toBe('Uninstall');
|
||||||
expect(service.getActionLabel(createStoreEntry({ version: '1.1.0' }))).toBe('Update');
|
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(unregister).toHaveBeenCalledWith(plugin.id);
|
||||||
expect(service.installedPlugins()).toEqual([]);
|
expect(service.installedPlugins()).toEqual([]);
|
||||||
@@ -111,18 +148,40 @@ describe('PluginStoreService', () => {
|
|||||||
|
|
||||||
function createService(
|
function createService(
|
||||||
registerLocalManifest: ReturnType<typeof vi.fn>,
|
registerLocalManifest: ReturnType<typeof vi.fn>,
|
||||||
unregister: ReturnType<typeof vi.fn>
|
unregister: ReturnType<typeof vi.fn>,
|
||||||
|
electronApi: { readFile: (filePath: string) => Promise<string> } | null = null
|
||||||
): PluginStoreService {
|
): PluginStoreService {
|
||||||
const injector = Injector.create({
|
const injector = Injector.create({
|
||||||
providers: [
|
providers: [
|
||||||
PluginStoreService,
|
PluginStoreService,
|
||||||
|
{
|
||||||
|
provide: ElectronBridgeService,
|
||||||
|
useValue: {
|
||||||
|
getApi: vi.fn(() => electronApi)
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: PluginHostService,
|
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,
|
provide: PluginRegistryService,
|
||||||
useValue: { unregister }
|
useValue: { unregister }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: PluginRequirementService,
|
||||||
|
useValue: {}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -130,6 +189,10 @@ function createService(
|
|||||||
return injector.get(PluginStoreService);
|
return injector.get(PluginStoreService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toBase64(value: string): string {
|
||||||
|
return Buffer.from(value, 'utf8').toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
function createManifest(overrides: Partial<TojuPluginManifest> = {}): TojuPluginManifest {
|
function createManifest(overrides: Partial<TojuPluginManifest> = {}): TojuPluginManifest {
|
||||||
return {
|
return {
|
||||||
apiVersion: '1.0.0',
|
apiVersion: '1.0.0',
|
||||||
|
|||||||
@@ -1,22 +1,40 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import {
|
import {
|
||||||
|
DestroyRef,
|
||||||
Injectable,
|
Injectable,
|
||||||
computed,
|
computed,
|
||||||
|
effect,
|
||||||
inject,
|
inject,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} 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 { 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 { validateTojuPluginManifest } from '../../domain/logic/plugin-manifest-validation.logic';
|
||||||
import type {
|
import type {
|
||||||
InstalledStorePlugin,
|
InstalledStorePlugin,
|
||||||
PersistedPluginStoreState,
|
PersistedPluginStoreState,
|
||||||
PluginStoreEntry,
|
PluginStoreEntry,
|
||||||
PluginStoreInstallState,
|
PluginStoreInstallState,
|
||||||
|
PluginStoreActionLabel,
|
||||||
PluginStoreReadme,
|
PluginStoreReadme,
|
||||||
PluginStoreSourceResult
|
PluginStoreSourceResult
|
||||||
} from '../../domain/models/plugin-store.models';
|
} from '../../domain/models/plugin-store.models';
|
||||||
import { PluginHostService } from './plugin-host.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 { PluginRegistryService } from './plugin-registry.service';
|
||||||
|
|
||||||
const STORE_SCHEMA_VERSION = 1;
|
const STORE_SCHEMA_VERSION = 1;
|
||||||
@@ -26,34 +44,78 @@ const DEFAULT_STORE_STATE: PersistedPluginStoreState = {
|
|||||||
sourceUrls: []
|
sourceUrls: []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface PluginStoreInstallOptions {
|
||||||
|
activate?: boolean;
|
||||||
|
manifest?: TojuPluginManifest;
|
||||||
|
optional?: boolean;
|
||||||
|
serverId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class PluginStoreService {
|
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 host = inject(PluginHostService);
|
||||||
|
private readonly pluginRequirements = inject(PluginRequirementService);
|
||||||
|
private readonly realtime = inject(RealtimeSessionFacade, { optional: true });
|
||||||
private readonly registry = inject(PluginRegistryService);
|
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 sourceUrlsSignal = signal<string[]>([]);
|
||||||
private readonly sourcesSignal = signal<PluginStoreSourceResult[]>([]);
|
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 readonly loadingSignal = signal(false);
|
||||||
private refreshAbortController: AbortController | null = null;
|
private refreshAbortController: AbortController | null = null;
|
||||||
private refreshVersion = 0;
|
private refreshVersion = 0;
|
||||||
|
private installedLoadVersion = 0;
|
||||||
|
private stateMutated = false;
|
||||||
|
|
||||||
readonly sourceUrls = this.sourceUrlsSignal.asReadonly();
|
readonly sourceUrls = this.sourceUrlsSignal.asReadonly();
|
||||||
readonly sources = this.sourcesSignal.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 isLoading = this.loadingSignal.asReadonly();
|
||||||
readonly availablePlugins = computed(() => this.sources().flatMap((source) => source.plugins));
|
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 installedById = computed(() => new Map(this.installedPlugins().map((plugin) => [plugin.manifest.id, plugin])));
|
||||||
|
readonly installScopeLabel = computed(() => this.currentRoomName?.() || 'this device');
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const state = this.loadState();
|
const state = this.loadState();
|
||||||
|
|
||||||
this.sourceUrlsSignal.set(state.sourceUrls);
|
this.sourceUrlsSignal.set(state.sourceUrls);
|
||||||
this.installedPluginsSignal.set(state.installedPlugins);
|
void this.applyInstalledPlugins(state.installedPlugins, 'client');
|
||||||
this.hydrateInstalledPlugins(state.installedPlugins);
|
|
||||||
|
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> {
|
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)) {
|
if (this.sourceUrls().includes(sourceUrl)) {
|
||||||
throw new Error('Plugin source already exists');
|
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) {
|
if (!plugin.installUrl) {
|
||||||
throw new Error('Plugin does not provide an install manifest URL');
|
throw new Error('Plugin does not provide an install manifest URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
const manifest = await this.fetchPluginManifest(plugin.installUrl);
|
const manifest = options.manifest ?? await this.fetchPluginManifest(plugin.installUrl);
|
||||||
const registered = this.host.registerLocalManifest(manifest, 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 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 = {
|
const installedPlugin: InstalledStorePlugin = {
|
||||||
installedAt: existing?.installedAt ?? now,
|
installedAt: existing?.installedAt ?? now,
|
||||||
installUrl: plugin.installUrl,
|
installUrl: plugin.installUrl,
|
||||||
manifest: registered.manifest,
|
manifest,
|
||||||
sourceUrl: plugin.sourceUrl,
|
sourceUrl: plugin.sourceUrl,
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
};
|
};
|
||||||
|
const nextInstalledPlugins = currentScopePlugins
|
||||||
|
.filter((candidate) => candidate.manifest.id !== manifest.id)
|
||||||
|
.concat(installedPlugin)
|
||||||
|
.sort(sortInstalledPlugins);
|
||||||
|
|
||||||
this.installedPluginsSignal.update((installedPlugins) => {
|
if (installScope === 'server') {
|
||||||
const existingPlugins = installedPlugins.filter((candidate) => candidate.manifest.id !== registered.manifest.id);
|
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;
|
return installedPlugin;
|
||||||
}
|
}
|
||||||
|
|
||||||
uninstallPlugin(pluginId: string): void {
|
async loadInstallManifest(plugin: PluginStoreEntry): Promise<TojuPluginManifest> {
|
||||||
this.registry.unregister(pluginId);
|
if (!plugin.installUrl) {
|
||||||
this.installedPluginsSignal.update((installedPlugins) =>
|
throw new Error('Plugin does not provide an install manifest URL');
|
||||||
installedPlugins.filter((installedPlugin) => installedPlugin.manifest.id !== pluginId)
|
}
|
||||||
);
|
|
||||||
|
|
||||||
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> {
|
async loadReadme(plugin: PluginStoreEntry): Promise<PluginStoreReadme> {
|
||||||
@@ -136,14 +237,8 @@ export class PluginStoreService {
|
|||||||
throw new Error('Plugin does not provide a readme URL');
|
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 {
|
return {
|
||||||
markdown: await response.text(),
|
markdown: await this.fetchText(plugin.readmeUrl, 'text/markdown,text/plain,*/*'),
|
||||||
pluginId: plugin.id,
|
pluginId: plugin.id,
|
||||||
title: plugin.title,
|
title: plugin.title,
|
||||||
url: plugin.readmeUrl
|
url: plugin.readmeUrl
|
||||||
@@ -151,7 +246,7 @@ export class PluginStoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getInstallState(plugin: PluginStoreEntry): PluginStoreInstallState {
|
getInstallState(plugin: PluginStoreEntry): PluginStoreInstallState {
|
||||||
const installed = this.installedById().get(plugin.id);
|
const installed = this.installedPluginForScope(plugin.id, getStoreEntryInstallScope(plugin));
|
||||||
|
|
||||||
if (!installed) {
|
if (!installed) {
|
||||||
return 'notInstalled';
|
return 'notInstalled';
|
||||||
@@ -162,25 +257,28 @@ export class PluginStoreService {
|
|||||||
: 'installed';
|
: 'installed';
|
||||||
}
|
}
|
||||||
|
|
||||||
getActionLabel(plugin: PluginStoreEntry): 'Install' | 'Uninstall' | 'Update' {
|
getActionLabel(plugin: PluginStoreEntry): PluginStoreActionLabel {
|
||||||
const state = this.getInstallState(plugin);
|
const state = this.getInstallState(plugin);
|
||||||
|
const serverScoped = getStoreEntryInstallScope(plugin) === 'server';
|
||||||
|
|
||||||
if (state === 'updateAvailable') {
|
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> {
|
private async loadSource(sourceUrl: string, signal: AbortSignal): Promise<PluginStoreSourceResult> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(sourceUrl, { headers: { Accept: 'application/json' }, signal });
|
const sourceValue = await this.fetchJson(sourceUrl, signal);
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Source returned ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceValue = await response.json() as unknown;
|
|
||||||
|
|
||||||
return parsePluginSource(sourceUrl, sourceValue);
|
return parsePluginSource(sourceUrl, sourceValue);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -193,13 +291,7 @@ export class PluginStoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async fetchPluginManifest(manifestUrl: string): Promise<TojuPluginManifest> {
|
private async fetchPluginManifest(manifestUrl: string): Promise<TojuPluginManifest> {
|
||||||
const response = await fetch(manifestUrl, { headers: { Accept: 'application/json' } });
|
const manifestValue = await this.fetchJson(manifestUrl);
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Install manifest returned ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const manifestValue = await response.json() as unknown;
|
|
||||||
const validation = validateTojuPluginManifest(manifestValue);
|
const validation = validateTojuPluginManifest(manifestValue);
|
||||||
|
|
||||||
if (!validation.manifest) {
|
if (!validation.manifest) {
|
||||||
@@ -209,10 +301,50 @@ export class PluginStoreService {
|
|||||||
return validation.manifest;
|
return validation.manifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
private hydrateInstalledPlugins(installedPlugins: InstalledStorePlugin[]): void {
|
private async fetchJson(url: string, signal?: AbortSignal): Promise<unknown> {
|
||||||
const usableInstalledPlugins: InstalledStorePlugin[] = [];
|
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 {
|
try {
|
||||||
this.host.registerLocalManifest(installedPlugin.manifest, installedPlugin.installUrl);
|
this.host.registerLocalManifest(installedPlugin.manifest, installedPlugin.installUrl);
|
||||||
usableInstalledPlugins.push(installedPlugin);
|
usableInstalledPlugins.push(installedPlugin);
|
||||||
@@ -221,9 +353,142 @@ export class PluginStoreService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (usableInstalledPlugins.length !== installedPlugins.length) {
|
this.setInstalledPluginsForScope(scope, usableInstalledPlugins);
|
||||||
this.installedPluginsSignal.set(usableInstalledPlugins);
|
|
||||||
this.saveState();
|
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 [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,8 +507,10 @@ export class PluginStoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private saveState(): void {
|
private saveState(): void {
|
||||||
|
this.stateMutated = true;
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
installedPlugins: this.installedPlugins(),
|
installedPlugins: this.clientInstalledPluginsSignal(),
|
||||||
schemaVersion: STORE_SCHEMA_VERSION,
|
schemaVersion: STORE_SCHEMA_VERSION,
|
||||||
sourceUrls: this.sourceUrls()
|
sourceUrls: this.sourceUrls()
|
||||||
};
|
};
|
||||||
@@ -251,7 +518,85 @@ export class PluginStoreService {
|
|||||||
try {
|
try {
|
||||||
localStorage.setItem(getUserScopedStorageKey(STORAGE_KEY_PLUGIN_STORE), JSON.stringify(state));
|
localStorage.setItem(getUserScopedStorageKey(STORAGE_KEY_PLUGIN_STORE), JSON.stringify(state));
|
||||||
} catch {}
|
} 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 {
|
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')),
|
imageUrl: resolveOptionalUrl(sourceUrl, readString(value, 'image', 'imageUrl', 'icon', 'iconUrl', 'banner')),
|
||||||
installUrl: resolveOptionalUrl(sourceUrl, readString(value, 'install', 'installUrl', 'manifest', 'manifestUrl')),
|
installUrl: resolveOptionalUrl(sourceUrl, readString(value, 'install', 'installUrl', 'manifest', 'manifestUrl')),
|
||||||
readmeUrl: resolveOptionalUrl(sourceUrl, readString(value, 'readme', 'readmeUrl')),
|
readmeUrl: resolveOptionalUrl(sourceUrl, readString(value, 'readme', 'readmeUrl')),
|
||||||
|
scope: readPluginInstallScope(value),
|
||||||
sourceTitle,
|
sourceTitle,
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
title: readString(value, 'title', 'name') ?? id,
|
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 {
|
function normalizePersistedState(value: unknown): PersistedPluginStoreState {
|
||||||
if (!isRecord(value)) {
|
if (!isRecord(value)) {
|
||||||
return { ...DEFAULT_STORE_STATE };
|
return { ...DEFAULT_STORE_STATE };
|
||||||
@@ -321,7 +677,7 @@ function normalizePersistedState(value: unknown): PersistedPluginStoreState {
|
|||||||
sourceUrls: Array.isArray(value['sourceUrls'])
|
sourceUrls: Array.isArray(value['sourceUrls'])
|
||||||
? value['sourceUrls']
|
? value['sourceUrls']
|
||||||
.filter((entry): entry is string => typeof entry === 'string')
|
.filter((entry): entry is string => typeof entry === 'string')
|
||||||
.map((entry) => normalizeOptionalRemoteUrl(entry))
|
.map((entry) => normalizeOptionalSourceUrl(entry))
|
||||||
.filter((entry): entry is string => !!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;
|
return isRecord(repository) ? readString(repository, 'url') : typeof repository === 'string' ? repository.trim() : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRemoteUrl(rawUrl: string, label: string): string {
|
function normalizeSourceUrl(rawUrl: string, label: string): string {
|
||||||
const url = normalizeOptionalRemoteUrl(rawUrl);
|
const url = normalizeOptionalSourceUrl(rawUrl);
|
||||||
|
|
||||||
if (!url) {
|
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;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeOptionalRemoteUrl(rawUrl: string): string | undefined {
|
function normalizeOptionalSourceUrl(rawUrl: string): string | undefined {
|
||||||
try {
|
const trimmedUrl = rawUrl.trim();
|
||||||
const url = new URL(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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
url.hash = '';
|
url.hash = '';
|
||||||
return url.toString();
|
return url.toString();
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return localPathToFileUrl(trimmedUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,7 +779,7 @@ function resolveOptionalUrl(sourceUrl: string, rawUrl?: string): string | undefi
|
|||||||
try {
|
try {
|
||||||
const url = new URL(rawUrl, sourceUrl);
|
const url = new URL(rawUrl, sourceUrl);
|
||||||
|
|
||||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
if (!isAllowedPluginSourceProtocol(url.protocol)) {
|
||||||
return undefined;
|
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 {
|
function compareVersions(leftVersion: string, rightVersion: string): number {
|
||||||
const leftParts = parseVersion(leftVersion);
|
const leftParts = parseVersion(leftVersion);
|
||||||
const rightParts = parseVersion(rightVersion);
|
const rightParts = parseVersion(rightVersion);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -58,6 +58,29 @@ describe('plugin manifest validation', () => {
|
|||||||
expect(result.valid).toBe(true);
|
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', () => {
|
it('rejects unknown capabilities and event dimensions', () => {
|
||||||
const result = validateTojuPluginManifest({
|
const result = validateTojuPluginManifest({
|
||||||
...createManifest(),
|
...createManifest(),
|
||||||
|
|||||||
@@ -178,6 +178,10 @@ export function validateTojuPluginManifest(value: unknown): PluginManifestValida
|
|||||||
pushIssue(issues, 'kind', 'kind must be client or library');
|
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'])) {
|
if (!isRecord(value['compatibility'])) {
|
||||||
pushIssue(issues, 'compatibility', 'compatibility is required');
|
pushIssue(issues, 'compatibility', 'compatibility is required');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -84,6 +84,43 @@ export interface PluginApiEventSubscription {
|
|||||||
handler: (event: PluginEventEnvelope) => void;
|
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 {
|
export interface PluginApiSettingsPageContribution {
|
||||||
label: string;
|
label: string;
|
||||||
order?: number;
|
order?: number;
|
||||||
@@ -158,6 +195,11 @@ export interface TojuClientPluginApi {
|
|||||||
info: (message: string, data?: unknown) => void;
|
info: (message: string, data?: unknown) => void;
|
||||||
warn: (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: {
|
readonly media: {
|
||||||
addCustomAudioStream: (request: PluginApiCustomStreamRequest) => Promise<void>;
|
addCustomAudioStream: (request: PluginApiCustomStreamRequest) => Promise<void>;
|
||||||
addCustomVideoStream: (request: PluginApiCustomStreamRequest) => Promise<void>;
|
addCustomVideoStream: (request: PluginApiCustomStreamRequest) => Promise<void>;
|
||||||
@@ -174,6 +216,11 @@ export interface TojuClientPluginApi {
|
|||||||
sendAsPluginUser: (request: PluginApiMessageAsPluginUserRequest) => void;
|
sendAsPluginUser: (request: PluginApiMessageAsPluginUserRequest) => void;
|
||||||
sync: (messages: Message[]) => void;
|
sync: (messages: Message[]) => void;
|
||||||
};
|
};
|
||||||
|
readonly messageBus: {
|
||||||
|
publish: (request: PluginApiMessageBusPublishRequest) => PluginApiMessageBusEnvelope;
|
||||||
|
sendLatestMessages: (request?: PluginApiMessageBusLatestRequest) => PluginApiMessageBusEnvelope;
|
||||||
|
subscribe: (subscription: PluginApiMessageBusSubscription) => TojuPluginDisposable;
|
||||||
|
};
|
||||||
readonly p2p: {
|
readonly p2p: {
|
||||||
broadcastData: (eventName: string, payload: unknown) => void;
|
broadcastData: (eventName: string, payload: unknown) => void;
|
||||||
connectedPeers: () => string[];
|
connectedPeers: () => string[];
|
||||||
|
|||||||
@@ -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 PluginStoreInstallState = 'installed' | 'notInstalled' | 'updateAvailable';
|
||||||
|
export type PluginStoreActionLabel = 'Install' | 'Install to Server' | 'Remove from Server' | 'Uninstall' | 'Update' | 'Update Server';
|
||||||
|
|
||||||
export interface PluginStoreEntry {
|
export interface PluginStoreEntry {
|
||||||
author?: string;
|
author?: string;
|
||||||
@@ -11,6 +12,7 @@ export interface PluginStoreEntry {
|
|||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
installUrl?: string;
|
installUrl?: string;
|
||||||
readmeUrl?: string;
|
readmeUrl?: string;
|
||||||
|
scope?: TojuPluginInstallScope;
|
||||||
sourceTitle?: string;
|
sourceTitle?: string;
|
||||||
sourceUrl: string;
|
sourceUrl: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<h2 class="truncate text-base font-semibold">Plugins</h2>
|
<h2 class="truncate text-base font-semibold">{{ managerTitle() }}</h2>
|
||||||
<p class="truncate text-xs text-muted-foreground">Local runtime, store install, capabilities, logs, extension points.</p>
|
<p class="truncate text-xs text-muted-foreground">{{ managerDescription() }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -232,7 +232,7 @@
|
|||||||
@for (page of selectedSettingsPages(); track page.id) {
|
@for (page of selectedSettingsPages(); track page.id) {
|
||||||
<article class="rounded-md border border-border bg-background/40 p-3">
|
<article class="rounded-md border border-border bg-background/40 p-3">
|
||||||
<h4 class="mb-2 text-sm font-medium">{{ page.contribution.label }}</h4>
|
<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>
|
</article>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -331,8 +331,8 @@
|
|||||||
name="lucidePackage"
|
name="lucidePackage"
|
||||||
size="28"
|
size="28"
|
||||||
/>
|
/>
|
||||||
<p class="mt-3 text-sm font-medium">No plugins installed.</p>
|
<p class="mt-3 text-sm font-medium">{{ emptyTitle() }}</p>
|
||||||
<p class="mt-1 text-sm text-muted-foreground">Use Store tab or local plugin folder discovery.</p>
|
<p class="mt-1 text-sm text-muted-foreground">{{ emptyBody() }}</p>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
@for (entry of entries(); track trackEntry($index, entry)) {
|
@for (entry of entries(); track trackEntry($index, entry)) {
|
||||||
@@ -370,6 +370,18 @@
|
|||||||
/>
|
/>
|
||||||
{{ entry.enabled ? 'Disable' : 'Enable' }}
|
{{ entry.enabled ? 'Disable' : 'Enable' }}
|
||||||
</button>
|
</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
|
<button
|
||||||
type="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"
|
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"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Output,
|
Output,
|
||||||
computed,
|
computed,
|
||||||
inject,
|
inject,
|
||||||
|
input,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
@@ -21,13 +22,14 @@ import {
|
|||||||
lucideStore,
|
lucideStore,
|
||||||
lucideX
|
lucideX
|
||||||
} from '@ng-icons/lucide';
|
} 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 { PluginCapabilityService } from '../../application/services/plugin-capability.service';
|
||||||
import { PluginHostService } from '../../application/services/plugin-host.service';
|
import { PluginHostService } from '../../application/services/plugin-host.service';
|
||||||
import { PluginLoggerService } from '../../application/services/plugin-logger.service';
|
import { PluginLoggerService } from '../../application/services/plugin-logger.service';
|
||||||
import { PluginRegistryService } from '../../application/services/plugin-registry.service';
|
import { PluginRegistryService } from '../../application/services/plugin-registry.service';
|
||||||
import { PluginRequirementStateService } from '../../application/services/plugin-requirement-state.service';
|
import { PluginRequirementStateService } from '../../application/services/plugin-requirement-state.service';
|
||||||
import { PluginUiRegistryService } from '../../application/services/plugin-ui-registry.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 type { RegisteredPlugin } from '../../domain/models/plugin-runtime.models';
|
||||||
import { PluginRenderHostComponent } from '../plugin-render-host/plugin-render-host.component';
|
import { PluginRenderHostComponent } from '../plugin-render-host/plugin-render-host.component';
|
||||||
|
|
||||||
@@ -60,6 +62,8 @@ type PluginManagerTab = 'docs' | 'extensions' | 'installed' | 'logs' | 'requirem
|
|||||||
export class PluginManagerComponent {
|
export class PluginManagerComponent {
|
||||||
@Output() readonly closed = new EventEmitter<void>();
|
@Output() readonly closed = new EventEmitter<void>();
|
||||||
|
|
||||||
|
readonly scope = input<TojuPluginInstallScope>('client');
|
||||||
|
|
||||||
readonly capabilities = inject(PluginCapabilityService);
|
readonly capabilities = inject(PluginCapabilityService);
|
||||||
readonly host = inject(PluginHostService);
|
readonly host = inject(PluginHostService);
|
||||||
readonly logger = inject(PluginLoggerService);
|
readonly logger = inject(PluginLoggerService);
|
||||||
@@ -71,7 +75,12 @@ export class PluginManagerComponent {
|
|||||||
readonly busyPluginId = signal<string | null>(null);
|
readonly busyPluginId = signal<string | null>(null);
|
||||||
readonly busyAll = signal(false);
|
readonly busyAll = signal(false);
|
||||||
readonly selectedPluginId = signal<string | null>(null);
|
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(() => {
|
readonly selectedPlugin = computed(() => {
|
||||||
const selectedPluginId = this.selectedPluginId();
|
const selectedPluginId = this.selectedPluginId();
|
||||||
|
|
||||||
@@ -89,17 +98,18 @@ export class PluginManagerComponent {
|
|||||||
.slice(-20) : [];
|
.slice(-20) : [];
|
||||||
});
|
});
|
||||||
readonly extensionCounts = computed(() => ({
|
readonly extensionCounts = computed(() => ({
|
||||||
appPages: this.uiRegistry.appPages().length,
|
appPages: this.uiRegistry.appPageRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
||||||
channelSections: this.uiRegistry.channelSections().length,
|
channelSections: this.uiRegistry.channelSectionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
||||||
composerActions: this.uiRegistry.composerActions().length,
|
composerActions: this.uiRegistry.composerActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
||||||
embeds: this.uiRegistry.embeds().length,
|
embeds: this.uiRegistry.embedRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
||||||
profileActions: this.uiRegistry.profileActions().length,
|
profileActions: this.uiRegistry.profileActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
||||||
settingsPages: this.uiRegistry.settingsPages().length,
|
settingsPages: this.uiRegistry.settingsPageRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
||||||
sidePanels: this.uiRegistry.sidePanels().length,
|
sidePanels: this.uiRegistry.sidePanelRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length,
|
||||||
toolbarActions: this.uiRegistry.toolbarActions().length
|
toolbarActions: this.uiRegistry.toolbarActionRecords().filter((record) => this.hasVisiblePlugin(record.pluginId)).length
|
||||||
}));
|
}));
|
||||||
readonly requirementComparisons = this.requirementState.comparisons;
|
readonly requirementComparisons = computed(() => this.scope() === 'server' ? this.requirementState.comparisons() : []);
|
||||||
readonly uiConflicts = this.uiRegistry.conflicts;
|
readonly uiConflicts = computed(() => this.uiRegistry.conflicts()
|
||||||
|
.filter((conflict) => conflict.pluginIds.some((pluginId) => this.hasVisiblePlugin(pluginId))));
|
||||||
readonly selectedRequirement = computed(() => {
|
readonly selectedRequirement = computed(() => {
|
||||||
const selectedPlugin = this.selectedPlugin();
|
const selectedPlugin = this.selectedPlugin();
|
||||||
|
|
||||||
@@ -113,6 +123,10 @@ export class PluginManagerComponent {
|
|||||||
? this.uiRegistry.settingsPageRecords().filter((record) => record.pluginId === selectedPlugin.manifest.id)
|
? 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(() => {
|
readonly selectedDocs = computed(() => {
|
||||||
const manifest = this.selectedPlugin()?.manifest;
|
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> {
|
async unload(entry: RegisteredPlugin): Promise<void> {
|
||||||
this.busyPluginId.set(entry.manifest.id);
|
this.busyPluginId.set(entry.manifest.id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.host.deactivatePlugin(entry.manifest.id);
|
await this.host.deactivatePlugin(entry.manifest.id, { forgetActivation: true });
|
||||||
} finally {
|
} finally {
|
||||||
this.busyPluginId.set(null);
|
this.busyPluginId.set(null);
|
||||||
}
|
}
|
||||||
@@ -194,6 +218,10 @@ export class PluginManagerComponent {
|
|||||||
return this.selectedPlugin()?.manifest.id === entry.manifest.id;
|
return this.selectedPlugin()?.manifest.id === entry.manifest.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isActive(entry: RegisteredPlugin): boolean {
|
||||||
|
return this.host.isPluginActive(entry.manifest.id);
|
||||||
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
this.closed.emit();
|
this.closed.emit();
|
||||||
}
|
}
|
||||||
@@ -205,4 +233,12 @@ export class PluginManagerComponent {
|
|||||||
trackCapability(index: number, capability: PluginCapabilityId): string {
|
trackCapability(index: number, capability: PluginCapabilityId): string {
|
||||||
return capability;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,9 @@
|
|||||||
|
|
||||||
<div class="plugin-store__title-copy">
|
<div class="plugin-store__title-copy">
|
||||||
<h1>Plugin Store</h1>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -52,10 +54,10 @@
|
|||||||
<div class="plugin-store__source-form">
|
<div class="plugin-store__source-form">
|
||||||
<label class="plugin-store__input-shell plugin-store__source-input">
|
<label class="plugin-store__input-shell plugin-store__source-input">
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="text"
|
||||||
[(ngModel)]="newSourceUrl"
|
[(ngModel)]="newSourceUrl"
|
||||||
(keyup.enter)="addSourceUrl()"
|
(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"
|
aria-label="Plugin source manifest URL"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -232,8 +234,9 @@
|
|||||||
type="button"
|
type="button"
|
||||||
(click)="runPrimaryAction(plugin)"
|
(click)="runPrimaryAction(plugin)"
|
||||||
[disabled]="isPrimaryActionDisabled(plugin)"
|
[disabled]="isPrimaryActionDisabled(plugin)"
|
||||||
|
[title]="serverInstallButtonTitle(plugin)"
|
||||||
class="plugin-store__primary-button plugin-card__primary-action"
|
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
|
<ng-icon
|
||||||
[name]="primaryActionIcon(plugin)"
|
[name]="primaryActionIcon(plugin)"
|
||||||
@@ -311,4 +314,107 @@
|
|||||||
</aside>
|
</aside>
|
||||||
}
|
}
|
||||||
</div>
|
</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>
|
</main>
|
||||||
|
|||||||
@@ -262,6 +262,7 @@ ng-icon {
|
|||||||
|
|
||||||
.plugin-store__source-row {
|
.plugin-store__source-row {
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugin-store__source-filter,
|
.plugin-store__source-filter,
|
||||||
@@ -401,7 +402,6 @@ ng-icon {
|
|||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugin-store__readme pre {
|
.plugin-store__readme pre {
|
||||||
@@ -414,6 +414,123 @@ ng-icon {
|
|||||||
background: hsl(var(--secondary) / 0.5);
|
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 {
|
.plugin-store__empty {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 14rem;
|
min-height: 14rem;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { Store as NgRxStore } from '@ngrx/store';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import {
|
import {
|
||||||
lucideArrowLeft,
|
lucideArrowLeft,
|
||||||
@@ -24,9 +25,25 @@ import {
|
|||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
import { ExternalLinkService } from '../../../../core/platform';
|
import { ExternalLinkService } from '../../../../core/platform';
|
||||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
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 { PluginStoreService } from '../../application/services/plugin-store.service';
|
||||||
import type { PluginStoreEntry, PluginStoreReadme } from '../../domain/models/plugin-store.models';
|
import type { PluginStoreEntry, PluginStoreReadme } from '../../domain/models/plugin-store.models';
|
||||||
|
|
||||||
|
interface ServerPluginInstallDialog {
|
||||||
|
manifest: TojuPluginManifest;
|
||||||
|
plugin: PluginStoreEntry;
|
||||||
|
selectedServerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-plugin-store',
|
selector: 'app-plugin-store',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -54,6 +71,28 @@ import type { PluginStoreEntry, PluginStoreReadme } from '../../domain/models/pl
|
|||||||
})
|
})
|
||||||
export class PluginStoreComponent implements OnInit {
|
export class PluginStoreComponent implements OnInit {
|
||||||
readonly store = inject(PluginStoreService);
|
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 sourceErrors = computed(() => this.store.sources().filter((source) => !!source.error));
|
||||||
readonly installedIds = computed(() => new Set(this.store.installedPlugins().map((plugin) => plugin.manifest.id)));
|
readonly installedIds = computed(() => new Set(this.store.installedPlugins().map((plugin) => plugin.manifest.id)));
|
||||||
readonly filteredPlugins = computed(() => {
|
readonly filteredPlugins = computed(() => {
|
||||||
@@ -96,6 +135,11 @@ export class PluginStoreComponent implements OnInit {
|
|||||||
readonly readme = signal<PluginStoreReadme | null>(null);
|
readonly readme = signal<PluginStoreReadme | null>(null);
|
||||||
readonly readmeError = signal<string | null>(null);
|
readonly readmeError = signal<string | null>(null);
|
||||||
readonly readmeLoadingPluginId = 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 destroyed = false;
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
@@ -181,8 +225,10 @@ export class PluginStoreComponent implements OnInit {
|
|||||||
this.actionBusyPluginId.set(plugin.id);
|
this.actionBusyPluginId.set(plugin.id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (action === 'Uninstall') {
|
if (action === 'Uninstall' || action === 'Remove from Server') {
|
||||||
this.store.uninstallPlugin(plugin.id);
|
await this.store.uninstallPlugin(plugin.id, plugin.scope);
|
||||||
|
} else if (this.isServerScopedPlugin(plugin)) {
|
||||||
|
await this.openServerInstallDialog(plugin);
|
||||||
} else {
|
} else {
|
||||||
await this.store.installPlugin(plugin);
|
await this.store.installPlugin(plugin);
|
||||||
}
|
}
|
||||||
@@ -229,13 +275,118 @@ export class PluginStoreComponent implements OnInit {
|
|||||||
this.readmeError.set(null);
|
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 {
|
goBack(): void {
|
||||||
void this.router.navigateByUrl(this.getReturnUrl());
|
void this.router.navigateByUrl(this.getReturnUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
async openManager(): Promise<void> {
|
async openManager(): Promise<void> {
|
||||||
|
const currentRoomId = this.currentRoom()?.id;
|
||||||
|
|
||||||
await this.router.navigateByUrl(this.getReturnUrl());
|
await this.router.navigateByUrl(this.getReturnUrl());
|
||||||
this.settingsModal.open('plugins');
|
this.settingsModal.open(this.store.hasActiveServerInstallScope() ? 'serverPlugins' : 'plugins', currentRoomId);
|
||||||
}
|
}
|
||||||
|
|
||||||
selectSource(sourceUrl: string | null): void {
|
selectSource(sourceUrl: string | null): void {
|
||||||
@@ -262,9 +413,18 @@ export class PluginStoreComponent implements OnInit {
|
|||||||
|
|
||||||
isPrimaryActionDisabled(plugin: PluginStoreEntry): boolean {
|
isPrimaryActionDisabled(plugin: PluginStoreEntry): boolean {
|
||||||
return this.isPluginBusy(plugin)
|
return this.isPluginBusy(plugin)
|
||||||
|
|| !this.canRunPrimaryAction(plugin)
|
||||||
|| (!plugin.installUrl && this.store.getInstallState(plugin) !== 'installed');
|
|| (!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 {
|
primaryActionIcon(plugin: PluginStoreEntry): string {
|
||||||
const action = this.store.getActionLabel(plugin);
|
const action = this.store.getActionLabel(plugin);
|
||||||
|
|
||||||
@@ -272,6 +432,10 @@ export class PluginStoreComponent implements OnInit {
|
|||||||
return 'lucideTrash2';
|
return 'lucideTrash2';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === 'Remove from Server') {
|
||||||
|
return 'lucideTrash2';
|
||||||
|
}
|
||||||
|
|
||||||
return 'lucidePlus';
|
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 {
|
private matchesSearch(plugin: PluginStoreEntry, searchTerm: string): boolean {
|
||||||
return [
|
return [
|
||||||
plugin.author,
|
plugin.author,
|
||||||
@@ -307,4 +489,14 @@ export class PluginStoreComponent implements OnInit {
|
|||||||
|
|
||||||
return '/search';
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
export * from './application/services/plugin-capability.service';
|
export * from './application/services/plugin-capability.service';
|
||||||
export * from './application/services/plugin-client-api.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-host.service';
|
||||||
export * from './application/services/plugin-logger.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-registry.service';
|
||||||
export * from './application/services/plugin-requirement.service';
|
export * from './application/services/plugin-requirement.service';
|
||||||
export * from './application/services/plugin-requirement-state.service';
|
export * from './application/services/plugin-requirement-state.service';
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
<!-- Channels View -->
|
<!-- Channels View -->
|
||||||
@if (panelMode() === 'channels') {
|
@if (panelMode() === 'channels') {
|
||||||
<div class="flex-1 overflow-auto">
|
<div class="min-h-0 flex-1 overflow-auto">
|
||||||
<!-- Text Channels -->
|
<!-- Text Channels -->
|
||||||
<section
|
<section
|
||||||
appThemeNode="roomTextChannelsSection"
|
appThemeNode="roomTextChannelsSection"
|
||||||
@@ -276,7 +276,7 @@
|
|||||||
@if (pluginSidePanels().length > 0) {
|
@if (pluginSidePanels().length > 0) {
|
||||||
<div class="mt-3 space-y-2">
|
<div class="mt-3 space-y-2">
|
||||||
@for (record of pluginSidePanels(); track record.id) {
|
@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>
|
<p class="mb-2 truncate text-xs font-medium text-muted-foreground">{{ record.contribution.label }}</p>
|
||||||
<app-plugin-render-host [render]="record.contribution.render" />
|
<app-plugin-render-host [render]="record.contribution.render" />
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -136,7 +136,7 @@
|
|||||||
General
|
General
|
||||||
}
|
}
|
||||||
@case ('plugins') {
|
@case ('plugins') {
|
||||||
Plugins
|
Client Plugins
|
||||||
}
|
}
|
||||||
@case ('network') {
|
@case ('network') {
|
||||||
Network
|
Network
|
||||||
@@ -162,6 +162,9 @@
|
|||||||
@case ('server') {
|
@case ('server') {
|
||||||
Server Settings
|
Server Settings
|
||||||
}
|
}
|
||||||
|
@case ('serverPlugins') {
|
||||||
|
Server Plugins
|
||||||
|
}
|
||||||
@case ('members') {
|
@case ('members') {
|
||||||
Members
|
Members
|
||||||
}
|
}
|
||||||
@@ -197,7 +200,10 @@
|
|||||||
<app-general-settings />
|
<app-general-settings />
|
||||||
}
|
}
|
||||||
@case ('plugins') {
|
@case ('plugins') {
|
||||||
<app-plugin-manager (closed)="navigate('general')" />
|
<app-plugin-manager
|
||||||
|
scope="client"
|
||||||
|
(closed)="navigate('general')"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
@case ('network') {
|
@case ('network') {
|
||||||
<app-network-settings />
|
<app-network-settings />
|
||||||
@@ -306,6 +312,21 @@
|
|||||||
[isAdmin]="isSelectedServerOwner()"
|
[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') {
|
@case ('members') {
|
||||||
<app-members-settings
|
<app-members-settings
|
||||||
[server]="selectedServer()"
|
[server]="selectedServer()"
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export class SettingsModalComponent {
|
|||||||
|
|
||||||
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
|
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||||
{ id: 'general', label: 'General', icon: 'lucideSettings' },
|
{ 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: 'theme', label: 'Theme Studio', icon: 'lucidePalette' },
|
||||||
{ id: 'network', label: 'Network', icon: 'lucideGlobe' },
|
{ id: 'network', label: 'Network', icon: 'lucideGlobe' },
|
||||||
{ id: 'notifications', label: 'Notifications', icon: 'lucideBell' },
|
{ id: 'notifications', label: 'Notifications', icon: 'lucideBell' },
|
||||||
@@ -132,6 +132,7 @@ export class SettingsModalComponent {
|
|||||||
];
|
];
|
||||||
readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [
|
readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||||
{ id: 'server', label: 'Server', icon: 'lucideSettings' },
|
{ id: 'server', label: 'Server', icon: 'lucideSettings' },
|
||||||
|
{ id: 'serverPlugins', label: 'Server plugins', icon: 'lucidePackage' },
|
||||||
{ id: 'members', label: 'Members', icon: 'lucideUsers' },
|
{ id: 'members', label: 'Members', icon: 'lucideUsers' },
|
||||||
{ id: 'bans', label: 'Bans', icon: 'lucideBan' },
|
{ id: 'bans', label: 'Bans', icon: 'lucideBan' },
|
||||||
{ id: 'permissions', label: 'Permissions', icon: 'lucideShield' }
|
{ id: 'permissions', label: 'Permissions', icon: 'lucideShield' }
|
||||||
@@ -143,8 +144,15 @@ export class SettingsModalComponent {
|
|||||||
if (!user)
|
if (!user)
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
return this.savedRooms().filter((room) => {
|
const roomsById = new Map(this.savedRooms().map((room) => [room.id, room]));
|
||||||
const viewedRoom = this.currentRoom()?.id === room.id ? (this.currentRoom() ?? room) : 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);
|
const role = resolveLegacyRole(viewedRoom, user);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -162,11 +170,12 @@ export class SettingsModalComponent {
|
|||||||
selectedServerId = signal<string | null>(null);
|
selectedServerId = signal<string | null>(null);
|
||||||
selectedServer = computed<Room | null>(() => {
|
selectedServer = computed<Room | null>(() => {
|
||||||
const id = this.selectedServerId();
|
const id = this.selectedServerId();
|
||||||
|
const currentRoom = this.currentRoom();
|
||||||
|
|
||||||
if (!id)
|
if (!id)
|
||||||
return null;
|
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(() => {
|
showServerTabs = computed(() => {
|
||||||
@@ -238,6 +247,13 @@ export class SettingsModalComponent {
|
|||||||
return this.selectedServerRole() === 'host';
|
return this.selectedServerRole() === 'host';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
isSelectedServerCurrent = computed(() => {
|
||||||
|
const selectedServerId = this.selectedServerId();
|
||||||
|
const currentRoomId = this.currentRoom()?.id ?? null;
|
||||||
|
|
||||||
|
return !!selectedServerId && selectedServerId === currentRoomId;
|
||||||
|
});
|
||||||
|
|
||||||
animating = signal(false);
|
animating = signal(false);
|
||||||
showThirdPartyLicenses = signal(false);
|
showThirdPartyLicenses = signal(false);
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export interface ChatEventBase {
|
|||||||
directMessage?: DirectMessageEventPayload;
|
directMessage?: DirectMessageEventPayload;
|
||||||
directMessageStatus?: DirectMessageStatusEventPayload;
|
directMessageStatus?: DirectMessageStatusEventPayload;
|
||||||
directMessageMutation?: DirectMessageMutationEventPayload;
|
directMessageMutation?: DirectMessageMutationEventPayload;
|
||||||
|
pluginMessage?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatMessageEvent extends ChatEventBase {
|
export interface ChatMessageEvent extends ChatEventBase {
|
||||||
@@ -390,6 +391,11 @@ export interface DirectMessageMutationPeerEvent extends ChatEventBase {
|
|||||||
directMessageMutation: DirectMessageMutationEventPayload;
|
directMessageMutation: DirectMessageMutationEventPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PluginMessageBusPeerEvent extends ChatEventBase {
|
||||||
|
type: 'plugin-message-bus';
|
||||||
|
pluginMessage: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
/** Discriminated union of all P2P chat events. Narrow via `event.type`. */
|
/** Discriminated union of all P2P chat events. Narrow via `event.type`. */
|
||||||
export type ChatEvent =
|
export type ChatEvent =
|
||||||
| ChatMessageEvent
|
| ChatMessageEvent
|
||||||
@@ -442,7 +448,8 @@ export type ChatEvent =
|
|||||||
| ChannelsUpdateEvent
|
| ChannelsUpdateEvent
|
||||||
| DirectMessagePeerEvent
|
| DirectMessagePeerEvent
|
||||||
| DirectMessageStatusPeerEvent
|
| DirectMessageStatusPeerEvent
|
||||||
| DirectMessageMutationPeerEvent;
|
| DirectMessageMutationPeerEvent
|
||||||
|
| PluginMessageBusPeerEvent;
|
||||||
|
|
||||||
/** All possible `type` values, derived from the union. */
|
/** All possible `type` values, derived from the union. */
|
||||||
export type ChatEventType = ChatEvent['type'];
|
export type ChatEventType = ChatEvent['type'];
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const PLUGIN_EVENT_SCOPES = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type PluginEventScope = typeof PLUGIN_EVENT_SCOPES[number];
|
export type PluginEventScope = typeof PLUGIN_EVENT_SCOPES[number];
|
||||||
|
export type TojuPluginInstallScope = 'client' | 'server';
|
||||||
|
|
||||||
export const PLUGIN_CAPABILITIES = [
|
export const PLUGIN_CAPABILITIES = [
|
||||||
'profile.read',
|
'profile.read',
|
||||||
@@ -67,8 +68,11 @@ export const PLUGIN_CAPABILITIES = [
|
|||||||
export type PluginCapabilityId = typeof PLUGIN_CAPABILITIES[number];
|
export type PluginCapabilityId = typeof PLUGIN_CAPABILITIES[number];
|
||||||
|
|
||||||
export interface PluginRequirementSummary {
|
export interface PluginRequirementSummary {
|
||||||
|
installUrl?: string;
|
||||||
|
manifest?: TojuPluginManifest;
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
status: PluginRequirementStatus;
|
status: PluginRequirementStatus;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
versionRange?: string;
|
versionRange?: string;
|
||||||
@@ -186,6 +190,7 @@ export interface TojuPluginManifest {
|
|||||||
requires?: { id: string; versionRange?: string }[];
|
requires?: { id: string; versionRange?: string }[];
|
||||||
};
|
};
|
||||||
schemaVersion: 1;
|
schemaVersion: 1;
|
||||||
|
scope?: TojuPluginInstallScope;
|
||||||
settings?: Record<string, unknown>;
|
settings?: Record<string, unknown>;
|
||||||
title: string;
|
title: string;
|
||||||
ui?: Record<string, unknown>;
|
ui?: Record<string, unknown>;
|
||||||
|
|||||||
Reference in New Issue
Block a user