Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eabbc08896 | |||
| 6920f93b41 | |||
| ec3802ade6 | |||
| 66c6f34cd3 | |||
| 3858beb28e | |||
| 1b91eacb5b | |||
| 11c2588e45 | |||
| bc2fa7de22 |
3
e2e/fixtures/plugins/api-test-plugin/README.md
Normal file
3
e2e/fixtures/plugins/api-test-plugin/README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# E2E Plugin API Fixture
|
||||||
|
|
||||||
|
This plugin is intentionally tiny. Tests use its manifest to exercise plugin discovery, server support metadata, server data, and plugin event relay APIs without executing plugin code.
|
||||||
6
e2e/fixtures/plugins/api-test-plugin/dist/main.js
vendored
Normal file
6
e2e/fixtures/plugins/api-test-plugin/dist/main.js
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
id: 'e2e.plugin-api',
|
||||||
|
activate(api) {
|
||||||
|
api?.logger?.info?.('E2E Plugin API Fixture activated');
|
||||||
|
}
|
||||||
|
};
|
||||||
49
e2e/fixtures/plugins/api-test-plugin/toju-plugin.json
Normal file
49
e2e/fixtures/plugins/api-test-plugin/toju-plugin.json
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"apiVersion": "1.0.0",
|
||||||
|
"capabilities": [
|
||||||
|
"storage.serverData.read",
|
||||||
|
"storage.serverData.write",
|
||||||
|
"events.server.publish",
|
||||||
|
"events.server.subscribe",
|
||||||
|
"events.p2p.publish",
|
||||||
|
"events.p2p.subscribe"
|
||||||
|
],
|
||||||
|
"compatibility": {
|
||||||
|
"minimumTojuVersion": "1.0.0",
|
||||||
|
"verifiedTojuVersion": "1.0.0"
|
||||||
|
},
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"key": "settings",
|
||||||
|
"scope": "server",
|
||||||
|
"storage": "serverData"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "presence",
|
||||||
|
"scope": "user",
|
||||||
|
"storage": "serverData"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Fixture plugin used by automated tests for plugin support APIs.",
|
||||||
|
"entrypoint": "./dist/main.js",
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"direction": "serverRelay",
|
||||||
|
"eventName": "e2e:relay",
|
||||||
|
"maxPayloadBytes": 2048,
|
||||||
|
"scope": "server"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"direction": "p2pHint",
|
||||||
|
"eventName": "e2e:p2p",
|
||||||
|
"maxPayloadBytes": 512,
|
||||||
|
"scope": "user"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "e2e.plugin-api",
|
||||||
|
"kind": "client",
|
||||||
|
"readme": "./README.md",
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"title": "E2E Plugin API Fixture",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
42
e2e/helpers/plugin-api-test-fixture.ts
Normal file
42
e2e/helpers/plugin-api-test-fixture.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
export const TEST_PLUGIN_FIXTURE_DIR = join(__dirname, '..', 'fixtures', 'plugins', 'api-test-plugin');
|
||||||
|
export const TEST_PLUGIN_ID = 'e2e.plugin-api';
|
||||||
|
export const TEST_PLUGIN_RELAY_EVENT = 'e2e:relay';
|
||||||
|
export const TEST_PLUGIN_P2P_EVENT = 'e2e:p2p';
|
||||||
|
|
||||||
|
export interface PluginApiTestManifestEvent {
|
||||||
|
direction: 'clientToServer' | 'serverRelay' | 'p2pHint';
|
||||||
|
eventName: string;
|
||||||
|
maxPayloadBytes?: number;
|
||||||
|
scope: 'server' | 'channel' | 'user' | 'plugin';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginApiTestManifest {
|
||||||
|
description: string;
|
||||||
|
events: PluginApiTestManifestEvent[];
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readPluginApiTestManifest(): Promise<PluginApiTestManifest> {
|
||||||
|
const manifestPath = join(TEST_PLUGIN_FIXTURE_DIR, 'toju-plugin.json');
|
||||||
|
const manifestText = await readFile(manifestPath, 'utf8');
|
||||||
|
|
||||||
|
return JSON.parse(manifestText) as PluginApiTestManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPluginApiTestEvent(
|
||||||
|
manifest: PluginApiTestManifest,
|
||||||
|
eventName: string
|
||||||
|
): PluginApiTestManifestEvent {
|
||||||
|
const eventDefinition = manifest.events.find((event) => event.eventName === eventName);
|
||||||
|
|
||||||
|
if (!eventDefinition) {
|
||||||
|
throw new Error(`Expected fixture plugin to define ${eventName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return eventDefinition;
|
||||||
|
}
|
||||||
@@ -58,6 +58,10 @@ function buildSeededEndpointStorageState(
|
|||||||
function applySeededEndpointStorageState(storageState: SeededEndpointStorageState): void {
|
function applySeededEndpointStorageState(storageState: SeededEndpointStorageState): void {
|
||||||
try {
|
try {
|
||||||
const storage = window.localStorage;
|
const storage = window.localStorage;
|
||||||
|
const currentUserId = storage.getItem('metoyou_currentUserId')?.trim() || null;
|
||||||
|
const generalSettings = JSON.stringify({
|
||||||
|
reopenLastViewedChat: false
|
||||||
|
});
|
||||||
|
|
||||||
storage.setItem(storageState.key, JSON.stringify(storageState.endpoints));
|
storage.setItem(storageState.key, JSON.stringify(storageState.endpoints));
|
||||||
storage.setItem(storageState.removedKey, JSON.stringify([
|
storage.setItem(storageState.removedKey, JSON.stringify([
|
||||||
@@ -65,11 +69,56 @@ function applySeededEndpointStorageState(storageState: SeededEndpointStorageStat
|
|||||||
'toju-primary',
|
'toju-primary',
|
||||||
'toju-sweden'
|
'toju-sweden'
|
||||||
]));
|
]));
|
||||||
|
storage.setItem('metoyou_general_settings', generalSettings);
|
||||||
|
|
||||||
|
if (currentUserId) {
|
||||||
|
storage.setItem(`metoyou_general_settings__${encodeURIComponent(currentUserId)}`, generalSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
const keysToRemove: string[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < storage.length; index += 1) {
|
||||||
|
const key = storage.key(index);
|
||||||
|
|
||||||
|
if (key === 'metoyou_lastViewedChat' || key?.startsWith('metoyou_lastViewedChat__')) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of keysToRemove) {
|
||||||
|
storage.removeItem(key);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// about:blank and some Playwright UI pages deny localStorage access.
|
// about:blank and some Playwright UI pages deny localStorage access.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function disableLastViewedChatResume(page: Page): Promise<void> {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const currentUserId = localStorage.getItem('metoyou_currentUserId')?.trim() || null;
|
||||||
|
const generalSettings = JSON.stringify({ reopenLastViewedChat: false });
|
||||||
|
const keysToRemove: string[] = [];
|
||||||
|
|
||||||
|
localStorage.setItem('metoyou_general_settings', generalSettings);
|
||||||
|
|
||||||
|
if (currentUserId) {
|
||||||
|
localStorage.setItem(`metoyou_general_settings__${encodeURIComponent(currentUserId)}`, generalSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < localStorage.length; index += 1) {
|
||||||
|
const key = localStorage.key(index);
|
||||||
|
|
||||||
|
if (key === 'metoyou_lastViewedChat' || key?.startsWith('metoyou_lastViewedChat__')) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of keysToRemove) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function installTestServerEndpoint(
|
export async function installTestServerEndpoint(
|
||||||
context: BrowserContext,
|
context: BrowserContext,
|
||||||
port: number = Number(process.env.TEST_SERVER_PORT) || 3099
|
port: number = Number(process.env.TEST_SERVER_PORT) || 3099
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export class ServerSearchPage {
|
|||||||
readonly dialogCancelButton: Locator;
|
readonly dialogCancelButton: Locator;
|
||||||
|
|
||||||
constructor(private page: Page) {
|
constructor(private page: Page) {
|
||||||
this.searchInput = page.getByPlaceholder('Search servers...');
|
this.searchInput = page.getByPlaceholder('Search servers and users...');
|
||||||
this.railCreateServerButton = page.locator('button[title="Create Server"]');
|
this.railCreateServerButton = page.locator('button[title="Create Server"]');
|
||||||
this.searchCreateServerButton = page.getByRole('button', { name: 'Create New Server' });
|
this.searchCreateServerButton = page.getByRole('button', { name: 'Create New Server' });
|
||||||
this.createServerButton = this.searchCreateServerButton;
|
this.createServerButton = this.searchCreateServerButton;
|
||||||
@@ -80,6 +80,11 @@ export class ServerSearchPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async joinServerFromSearch(name: string) {
|
async joinServerFromSearch(name: string) {
|
||||||
await this.page.locator('button', { hasText: name }).click();
|
await this.searchInput.fill(name);
|
||||||
|
|
||||||
|
const serverCard = this.page.locator('div[title]', { hasText: name }).first();
|
||||||
|
|
||||||
|
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
||||||
|
await serverCard.dblclick();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,9 +44,11 @@ test.describe('Chat messaging features', () => {
|
|||||||
|
|
||||||
await test.step('Opening first server once restores only its channels', async () => {
|
await test.step('Opening first server once restores only its channels', async () => {
|
||||||
await openSavedRoomByName(scenario.client.page, alphaServerName);
|
await openSavedRoomByName(scenario.client.page, alphaServerName);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
|
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
|
||||||
).toBeVisible({ timeout: 20_000 });
|
).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
|
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
@@ -54,9 +56,11 @@ test.describe('Chat messaging features', () => {
|
|||||||
|
|
||||||
await test.step('Opening second server once restores only its channels', async () => {
|
await test.step('Opening second server once restores only its channels', async () => {
|
||||||
await openSavedRoomByName(scenario.client.page, betaServerName);
|
await openSavedRoomByName(scenario.client.page, betaServerName);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
|
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${betaChannelName}"]`)
|
||||||
).toBeVisible({ timeout: 20_000 });
|
).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
|
channelsPanel.locator(`button[data-channel-type="text"][data-channel-name="${alphaChannelName}"]`)
|
||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
@@ -304,11 +308,8 @@ async function createChatScenario(createClient: () => Promise<Client>): Promise<
|
|||||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
|
||||||
const bobSearchPage = new ServerSearchPage(bob.page);
|
const bobSearchPage = new ServerSearchPage(bob.page);
|
||||||
const serverCard = bob.page.locator('button', { hasText: serverName }).first();
|
|
||||||
|
|
||||||
await bobSearchPage.searchInput.fill(serverName);
|
await bobSearchPage.joinServerFromSearch(serverName);
|
||||||
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
|
||||||
await serverCard.click();
|
|
||||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
|
||||||
const aliceRoom = new ChatRoomPage(alice.page);
|
const aliceRoom = new ChatRoomPage(alice.page);
|
||||||
|
|||||||
116
e2e/tests/chat/dm-flow.spec.ts
Normal file
116
e2e/tests/chat/dm-flow.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { type Page } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
test,
|
||||||
|
expect,
|
||||||
|
type Client
|
||||||
|
} from '../../fixtures/multi-client';
|
||||||
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
|
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||||
|
import { disableLastViewedChatResume } from '../../helpers/seed-test-endpoint';
|
||||||
|
|
||||||
|
test.describe('Direct message flow', () => {
|
||||||
|
test.describe.configure({ timeout: 180_000 });
|
||||||
|
|
||||||
|
test('opens a DM from a user card and queues messages while offline', async ({ createClient }) => {
|
||||||
|
const scenario = await createDmScenario(createClient);
|
||||||
|
const offlineMessage = `Offline DM ${uniqueName('msg')}`;
|
||||||
|
|
||||||
|
await test.step('Alice opens Bob from the room user list', async () => {
|
||||||
|
const bobUserCard = scenario.alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();
|
||||||
|
|
||||||
|
await expect(bobUserCard).toBeVisible({ timeout: 20_000 });
|
||||||
|
await bobUserCard.getByRole('button', { name: 'Message Bob' }).click();
|
||||||
|
await expect(scenario.alice.page).toHaveURL(/\/dm\//, { timeout: 15_000 });
|
||||||
|
await expect(scenario.alice.page.getByRole('heading', { name: 'Bob' })).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Offline send persists locally as queued', async () => {
|
||||||
|
await scenario.alice.page.evaluate(() => window.simulateOffline?.());
|
||||||
|
await scenario.alice.page.getByTestId('dm-input').fill(offlineMessage);
|
||||||
|
await scenario.alice.page.getByTestId('dm-input').press('Enter');
|
||||||
|
|
||||||
|
await expect(scenario.alice.page.locator('app-dm-chat').getByText(offlineMessage)).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(scenario.alice.page.getByTestId('message-status').last()).toContainText('QUEUED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows friend and message actions on the search people list', async ({ createClient }) => {
|
||||||
|
const scenario = await createDmScenario(createClient);
|
||||||
|
|
||||||
|
await disableLastViewedChatResume(scenario.alice.page);
|
||||||
|
await scenario.alice.page.goto('/search', { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(scenario.alice.page).toHaveURL(/\/search/, { timeout: 20_000 });
|
||||||
|
await expect(scenario.alice.page.locator('app-server-search')).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(scenario.alice.page.locator('app-user-search-list')).toBeVisible({ timeout: 20_000 });
|
||||||
|
const bobPeopleCard = scenario.alice.page
|
||||||
|
.locator('app-user-search-list [data-testid$="-' + scenario.bobUserId + '"]', { hasText: 'Bob' })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
await expect(bobPeopleCard).toBeVisible({ timeout: 15_000 });
|
||||||
|
const friendButton = bobPeopleCard.locator(`[data-testid="friend-button-${scenario.bobUserId}"]`);
|
||||||
|
const messageButton = bobPeopleCard.getByRole('button', { name: 'Message Bob' });
|
||||||
|
|
||||||
|
await expect(friendButton).toBeAttached({ timeout: 15_000 });
|
||||||
|
await expect(messageButton).toBeAttached({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
interface DmScenario {
|
||||||
|
alice: Client;
|
||||||
|
bob: Client;
|
||||||
|
bobUserId: string;
|
||||||
|
aliceSearch: ServerSearchPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDmScenario(createClient: () => Promise<Client>): Promise<DmScenario> {
|
||||||
|
const suffix = uniqueName('dm');
|
||||||
|
const serverName = `DM Server ${suffix}`;
|
||||||
|
const alice = await createClient();
|
||||||
|
const bob = await createClient();
|
||||||
|
|
||||||
|
await registerUser(alice.page, `alice_${suffix}`, 'Alice');
|
||||||
|
await registerUser(bob.page, `bob_${suffix}`, 'Bob');
|
||||||
|
|
||||||
|
const aliceSearch = new ServerSearchPage(alice.page);
|
||||||
|
|
||||||
|
await aliceSearch.createServer(serverName, { description: 'E2E direct message discovery server' });
|
||||||
|
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
await new ChatMessagesPage(alice.page).waitForReady();
|
||||||
|
|
||||||
|
const bobSearch = new ServerSearchPage(bob.page);
|
||||||
|
|
||||||
|
await bobSearch.joinServerFromSearch(serverName);
|
||||||
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
await new ChatMessagesPage(bob.page).waitForReady();
|
||||||
|
const bobRoomCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();
|
||||||
|
|
||||||
|
await expect(bobRoomCard).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
const bobUserCardTestId = await bobRoomCard.getAttribute('data-testid');
|
||||||
|
const bobUserId = bobUserCardTestId?.replace('room-user-card-', '');
|
||||||
|
|
||||||
|
if (!bobUserId) {
|
||||||
|
throw new Error('Expected Bob room user card to expose a stable test id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
alice,
|
||||||
|
bob,
|
||||||
|
bobUserId,
|
||||||
|
aliceSearch
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerUser(page: Page, username: string, displayName: string): Promise<void> {
|
||||||
|
const registerPage = new RegisterPage(page);
|
||||||
|
|
||||||
|
await registerPage.goto();
|
||||||
|
await registerPage.register(username, displayName, 'TestPass123!');
|
||||||
|
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueName(prefix: string): string {
|
||||||
|
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
||||||
|
.slice(2, 8)}`;
|
||||||
|
}
|
||||||
@@ -3,10 +3,7 @@ import {
|
|||||||
type Locator,
|
type Locator,
|
||||||
type Page
|
type Page
|
||||||
} from '@playwright/test';
|
} from '@playwright/test';
|
||||||
import {
|
import { test, type Client } from '../../fixtures/multi-client';
|
||||||
test,
|
|
||||||
type Client
|
|
||||||
} from '../../fixtures/multi-client';
|
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
import { ChatRoomPage } from '../../pages/chat-room.page';
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
@@ -109,14 +106,12 @@ async function createNotificationScenario(createClient: () => Promise<Client>):
|
|||||||
await aliceSearch.createServer(serverName, {
|
await aliceSearch.createServer(serverName, {
|
||||||
description: 'E2E notification coverage server'
|
description: 'E2E notification coverage server'
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
|
||||||
const bobSearch = new ServerSearchPage(bob.page);
|
const bobSearch = new ServerSearchPage(bob.page);
|
||||||
const serverCard = bob.page.locator('button', { hasText: serverName }).first();
|
|
||||||
|
|
||||||
await bobSearch.searchInput.fill(serverName);
|
await bobSearch.joinServerFromSearch(serverName);
|
||||||
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
|
||||||
await serverCard.click();
|
|
||||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
|
||||||
const aliceRoom = new ChatRoomPage(alice.page);
|
const aliceRoom = new ChatRoomPage(alice.page);
|
||||||
@@ -155,10 +150,6 @@ async function installDesktopNotificationSpy(page: Page): Promise<void> {
|
|||||||
class MockNotification {
|
class MockNotification {
|
||||||
static permission = 'granted';
|
static permission = 'granted';
|
||||||
|
|
||||||
static async requestPermission(): Promise<NotificationPermission> {
|
|
||||||
return 'granted';
|
|
||||||
}
|
|
||||||
|
|
||||||
onclick: (() => void) | null = null;
|
onclick: (() => void) | null = null;
|
||||||
|
|
||||||
constructor(title: string, options?: NotificationOptions) {
|
constructor(title: string, options?: NotificationOptions) {
|
||||||
@@ -168,6 +159,10 @@ async function installDesktopNotificationSpy(page: Page): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async requestPermission(): Promise<NotificationPermission> {
|
||||||
|
return 'granted';
|
||||||
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -256,7 +251,8 @@ function getUnreadBadge(container: Locator): Locator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function uniqueName(prefix: string): string {
|
function uniqueName(prefix: string): string {
|
||||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
||||||
|
.slice(2, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WindowWithDesktopNotifications extends Window {
|
interface WindowWithDesktopNotifications extends Window {
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ const NETSCAPE_LOOP_EXTENSION = Buffer.from([
|
|||||||
]);
|
]);
|
||||||
const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'];
|
const CLIENT_LAUNCH_ARGS = ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'];
|
||||||
const VOICE_CHANNEL = 'General';
|
const VOICE_CHANNEL = 'General';
|
||||||
|
const AVATAR_SYNC_TIMEOUT_MS = 45_000;
|
||||||
|
|
||||||
test.describe('Profile avatar sync', () => {
|
test.describe('Profile avatar sync', () => {
|
||||||
test.describe.configure({ timeout: 240_000 });
|
test.describe.configure({ timeout: 240_000 });
|
||||||
@@ -384,11 +385,8 @@ async function registerUser(client: PersistentClient): Promise<void> {
|
|||||||
|
|
||||||
async function joinServerFromSearch(page: Page, serverName: string): Promise<void> {
|
async function joinServerFromSearch(page: Page, serverName: string): Promise<void> {
|
||||||
const searchPage = new ServerSearchPage(page);
|
const searchPage = new ServerSearchPage(page);
|
||||||
const serverCard = page.locator('button', { hasText: serverName }).first();
|
|
||||||
|
|
||||||
await searchPage.searchInput.fill(serverName);
|
await searchPage.joinServerFromSearch(serverName);
|
||||||
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
|
||||||
await serverCard.click();
|
|
||||||
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,7 +599,7 @@ async function expectSidebarAvatar(page: Page, displayName: string, expectedData
|
|||||||
|
|
||||||
return image.getAttribute('src');
|
return image.getAttribute('src');
|
||||||
}, {
|
}, {
|
||||||
timeout: 20_000,
|
timeout: AVATAR_SYNC_TIMEOUT_MS,
|
||||||
message: `${displayName} avatar src should update`
|
message: `${displayName} avatar src should update`
|
||||||
}).toBe(expectedDataUrl);
|
}).toBe(expectedDataUrl);
|
||||||
|
|
||||||
@@ -618,7 +616,7 @@ async function expectSidebarAvatar(page: Page, displayName: string, expectedData
|
|||||||
return img.complete && img.naturalWidth > 0 && img.naturalHeight > 0;
|
return img.complete && img.naturalWidth > 0 && img.naturalHeight > 0;
|
||||||
});
|
});
|
||||||
}, {
|
}, {
|
||||||
timeout: 20_000,
|
timeout: AVATAR_SYNC_TIMEOUT_MS,
|
||||||
message: `${displayName} avatar image should load`
|
message: `${displayName} avatar image should load`
|
||||||
}).toBe(true);
|
}).toBe(true);
|
||||||
}
|
}
|
||||||
@@ -638,7 +636,7 @@ async function expectChatMessageAvatar(page: Page, messageText: string, expected
|
|||||||
|
|
||||||
return image.getAttribute('src');
|
return image.getAttribute('src');
|
||||||
}, {
|
}, {
|
||||||
timeout: 20_000,
|
timeout: AVATAR_SYNC_TIMEOUT_MS,
|
||||||
message: `Chat message avatar for "${messageText}" should update`
|
message: `Chat message avatar for "${messageText}" should update`
|
||||||
}).toBe(expectedDataUrl);
|
}).toBe(expectedDataUrl);
|
||||||
}
|
}
|
||||||
@@ -665,7 +663,7 @@ async function expectVoiceControlsAvatar(page: Page, expectedDataUrl: string): P
|
|||||||
|
|
||||||
return image.getAttribute('src');
|
return image.getAttribute('src');
|
||||||
}, {
|
}, {
|
||||||
timeout: 20_000,
|
timeout: AVATAR_SYNC_TIMEOUT_MS,
|
||||||
message: 'Voice controls avatar should update'
|
message: 'Voice controls avatar should update'
|
||||||
}).toBe(expectedDataUrl);
|
}).toBe(expectedDataUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
185
e2e/tests/plugins/plugin-api-two-users.spec.ts
Normal file
185
e2e/tests/plugins/plugin-api-two-users.spec.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { type Page } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
expect,
|
||||||
|
test,
|
||||||
|
type Client
|
||||||
|
} from '../../fixtures/multi-client';
|
||||||
|
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||||
|
import { ChatRoomPage } from '../../pages/chat-room.page';
|
||||||
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
|
|
||||||
|
const PLUGIN_SOURCE_URL = 'http://localhost:4200/plugins/e2e-plugin-source.json';
|
||||||
|
const PLUGIN_TITLE = 'E2E All API Plugin';
|
||||||
|
const EDITED_MESSAGE = 'Plugin API edited message';
|
||||||
|
const ORIGINAL_MESSAGE = 'Plugin API original message';
|
||||||
|
const DELETED_MESSAGE = 'Plugin API deleted message';
|
||||||
|
const DELETED_MESSAGE_CONTENT = '[Message deleted]';
|
||||||
|
const PLUGIN_BOT_MESSAGE = 'Plugin bot message from all-api fixture';
|
||||||
|
const CUSTOM_EMBED_TEXT = 'E2E custom embed: Plugin API custom embed';
|
||||||
|
const SOUND_BOARD_TEXT = 'E2E soundboard ready';
|
||||||
|
const SOUND_BOARD_LABEL = 'E2E Soundboard';
|
||||||
|
const SOUND_BOARD_PLAYED_MESSAGE = 'E2E soundboard played Airhorn to voice channel';
|
||||||
|
const VOICE_CHANNEL = 'Plugin Voice';
|
||||||
|
|
||||||
|
test.describe('Plugin API multi-user runtime', () => {
|
||||||
|
test.describe.configure({ timeout: 180_000 });
|
||||||
|
|
||||||
|
test('runs chat, embed, soundboard, and profile APIs between two users', async ({ createClient }) => {
|
||||||
|
const scenario = await createPluginApiScenario(createClient);
|
||||||
|
|
||||||
|
await test.step('Install the server plugin as Alice', async () => {
|
||||||
|
await installGrantAndActivatePlugin(scenario.alice.page, true);
|
||||||
|
await closeSettingsModal(scenario.alice.page);
|
||||||
|
await expect(soundboardComposerButton(scenario.alice.page)).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(scenario.alice.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(scenario.alice.page.getByTestId('e2e-plugin-owned-dom')).toHaveAttribute('data-plugin-owner', 'e2e.all-api-plugin');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Activate the server plugin for Bob as the embed/soundboard receiver', async () => {
|
||||||
|
await installGrantAndActivatePlugin(scenario.bob.page, false);
|
||||||
|
await closeSettingsModal(scenario.bob.page);
|
||||||
|
await expect(soundboardComposerButton(scenario.bob.page)).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(scenario.bob.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(scenario.bob.page.getByTestId('e2e-plugin-owned-dom')).toHaveAttribute('data-plugin-owner', 'e2e.all-api-plugin');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Alice opens the plugin soundboard modal and plays a sound to voice', async () => {
|
||||||
|
await soundboardComposerButton(scenario.alice.page).click();
|
||||||
|
await expect(scenario.alice.page.getByRole('dialog', { name: SOUND_BOARD_LABEL })).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(scenario.alice.page.getByTestId('e2e-soundboard-modal')).toHaveAttribute('data-plugin-owner', 'e2e.all-api-plugin');
|
||||||
|
await scenario.alice.page.getByRole('button', { name: 'Play airhorn to voice' }).click();
|
||||||
|
await expect(scenario.alice.page.getByTestId('e2e-soundboard-status')).toHaveText(SOUND_BOARD_PLAYED_MESSAGE, { timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Bob receives messages sent and edited by Alice through the plugin API', async () => {
|
||||||
|
await expect(scenario.bobMessages.getMessageItemByText(EDITED_MESSAGE)).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(scenario.bobMessages.getMessageItemByText(ORIGINAL_MESSAGE)).toHaveCount(0);
|
||||||
|
await expect(scenario.bob.page.getByText('(edited)')).toBeVisible({ timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Bob sees plugin API deletion state and plugin-user messages', async () => {
|
||||||
|
await expect(scenario.bobMessages.getMessageItemByText(DELETED_MESSAGE_CONTENT)).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(scenario.bobMessages.getMessageItemByText(DELETED_MESSAGE)).toHaveCount(0);
|
||||||
|
await expect(scenario.bobMessages.getMessageItemByText(PLUGIN_BOT_MESSAGE)).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(scenario.bobMessages.getMessageItemByText(SOUND_BOARD_PLAYED_MESSAGE)).toBeVisible({ timeout: 30_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Bob renders Alice custom embed through the plugin embed API', async () => {
|
||||||
|
await expect(scenario.bob.page.getByTestId('plugin-message-embeds')).toContainText(CUSTOM_EMBED_TEXT, { timeout: 30_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Bob sees Alice profile name changed by the plugin API', async () => {
|
||||||
|
await expect(scenario.bobMessages.getMessageItemByText(EDITED_MESSAGE)).toContainText('Alice Plugin Renamed', { timeout: 30_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
interface PluginApiScenario {
|
||||||
|
alice: Client;
|
||||||
|
aliceRoom: ChatRoomPage;
|
||||||
|
bob: Client;
|
||||||
|
bobRoom: ChatRoomPage;
|
||||||
|
aliceMessages: ChatMessagesPage;
|
||||||
|
bobMessages: ChatMessagesPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPluginApiScenario(createClient: () => Promise<Client>): Promise<PluginApiScenario> {
|
||||||
|
const suffix = uniqueName('plugin-api');
|
||||||
|
const serverName = `Plugin API Server ${suffix}`;
|
||||||
|
const alice = await createClient();
|
||||||
|
const bob = await createClient();
|
||||||
|
|
||||||
|
await registerUser(alice.page, `alice_${suffix}`, 'Alice');
|
||||||
|
await registerUser(bob.page, `bob_${suffix}`, 'Bob');
|
||||||
|
|
||||||
|
const aliceSearch = new ServerSearchPage(alice.page);
|
||||||
|
|
||||||
|
await aliceSearch.createServer(serverName, { description: 'Two-user plugin API E2E coverage' });
|
||||||
|
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 30_000 });
|
||||||
|
|
||||||
|
const aliceRoom = new ChatRoomPage(alice.page);
|
||||||
|
|
||||||
|
await aliceRoom.ensureVoiceChannelExists(VOICE_CHANNEL);
|
||||||
|
|
||||||
|
const bobSearch = new ServerSearchPage(bob.page);
|
||||||
|
|
||||||
|
await bobSearch.joinServerFromSearch(serverName);
|
||||||
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 30_000 });
|
||||||
|
|
||||||
|
const bobRoom = new ChatRoomPage(bob.page);
|
||||||
|
|
||||||
|
await aliceRoom.joinVoiceChannel(VOICE_CHANNEL);
|
||||||
|
await bobRoom.joinVoiceChannel(VOICE_CHANNEL);
|
||||||
|
await expect(aliceRoom.voiceControls).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(bobRoom.voiceControls).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
const aliceMessages = new ChatMessagesPage(alice.page);
|
||||||
|
const bobMessages = new ChatMessagesPage(bob.page);
|
||||||
|
|
||||||
|
await aliceMessages.waitForReady();
|
||||||
|
await bobMessages.waitForReady();
|
||||||
|
await expect(alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' })).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(bob.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Alice' })).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
return {
|
||||||
|
alice,
|
||||||
|
aliceRoom,
|
||||||
|
bob,
|
||||||
|
bobRoom,
|
||||||
|
aliceMessages,
|
||||||
|
bobMessages
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerUser(page: Page, username: string, displayName: string): Promise<void> {
|
||||||
|
const registerPage = new RegisterPage(page);
|
||||||
|
|
||||||
|
await registerPage.goto();
|
||||||
|
await registerPage.register(username, displayName, 'TestPass123!');
|
||||||
|
await expect(page).toHaveURL(/\/search/, { timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installGrantAndActivatePlugin(page: Page, installFromStore: boolean): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: 'Plugins' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/plugin-store/, { timeout: 20_000 });
|
||||||
|
await expect(page.getByTestId('plugin-store-page')).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
if (installFromStore) {
|
||||||
|
await page.getByLabel('Plugin source manifest URL').fill(PLUGIN_SOURCE_URL);
|
||||||
|
await page.getByRole('button', { name: 'Add Source' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 });
|
||||||
|
await page.getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ }).click();
|
||||||
|
await expect(page.getByRole('dialog', { name: PLUGIN_TITLE })).toBeVisible({ timeout: 10_000 });
|
||||||
|
await page.getByRole('button', { name: 'Install and Activate' }).click();
|
||||||
|
await expect(page.locator('article', { hasText: PLUGIN_TITLE }).getByText('Installed')).toBeVisible({ timeout: 20_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Manage Plugins' }).click();
|
||||||
|
await expect(page.getByTestId('plugin-manager')).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(page.locator('article', { hasText: PLUGIN_TITLE })).toBeVisible({ timeout: 20_000 });
|
||||||
|
await page.locator('article', { hasText: PLUGIN_TITLE })
|
||||||
|
.getByRole('button', { name: 'Select' })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Grant all requested' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Activate ready plugins' }).click();
|
||||||
|
await expect(page.locator('article', { hasText: PLUGIN_TITLE }).getByText('ready', { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByRole('button', { name: 'Logs' }).click();
|
||||||
|
await expect(page.getByText('all-api plugin completed')).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeSettingsModal(page: Page): Promise<void> {
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(page.getByTestId('plugin-manager')).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueName(prefix: string): string {
|
||||||
|
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
||||||
|
.slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function soundboardComposerButton(page: Page) {
|
||||||
|
return page.locator('app-chat-message-composer')
|
||||||
|
.getByRole('button', { exact: true, name: SOUND_BOARD_LABEL });
|
||||||
|
}
|
||||||
93
e2e/tests/plugins/plugin-manager-ui.spec.ts
Normal file
93
e2e/tests/plugins/plugin-manager-ui.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { expect, test } from '../../fixtures/multi-client';
|
||||||
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
|
|
||||||
|
test.describe('Plugin manager UI', () => {
|
||||||
|
test.describe.configure({ timeout: 180_000 });
|
||||||
|
|
||||||
|
test('installs, grants, activates, and logs an all-API test plugin', async ({ createClient }) => {
|
||||||
|
const client = await createClient();
|
||||||
|
const { page } = client;
|
||||||
|
const suffix = Date.now();
|
||||||
|
const register = new RegisterPage(page);
|
||||||
|
const search = new ServerSearchPage(page);
|
||||||
|
|
||||||
|
await test.step('Register user and create server context', async () => {
|
||||||
|
await register.goto();
|
||||||
|
await register.register(`plugin_${suffix}`, 'Plugin Tester', 'TestPass123!');
|
||||||
|
await expect(page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
|
||||||
|
await search.createServer(`Plugin API Server ${suffix}`, {
|
||||||
|
description: 'Plugin manager UI E2E coverage'
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/room\//, { timeout: 30_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Open visible Plugins button', async () => {
|
||||||
|
await page.getByRole('button', { name: 'Plugins' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/plugin-store/, { timeout: 10_000 });
|
||||||
|
await expect(page.getByTestId('plugin-store-page')).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Install fixture plugin from source manifest', async () => {
|
||||||
|
await page.getByLabel('Plugin source manifest URL').fill('http://localhost:4200/plugins/e2e-plugin-source.json');
|
||||||
|
await page.getByRole('button', { name: 'Add Source' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'E2E All API Plugin' })).toBeVisible({ timeout: 15_000 });
|
||||||
|
await page.getByRole('button', { name: 'Readme' }).click();
|
||||||
|
await expect(page.getByText('Fixture plugin for Playwright coverage.')).toBeVisible({ timeout: 10_000 });
|
||||||
|
await page.getByRole('button', { exact: true, name: /^(Install|Install to Server)$/ }).click();
|
||||||
|
const installDialog = page.getByRole('dialog', { name: 'E2E All API Plugin' });
|
||||||
|
|
||||||
|
await expect(installDialog).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(installDialog.getByText('Install to server', { exact: true })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Install and Activate' }).click();
|
||||||
|
await expect(page.locator('article', { hasText: 'E2E All API Plugin' }).getByText('Installed')).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Open plugin manager from the store page', async () => {
|
||||||
|
await page.getByRole('button', { name: 'Manage Plugins' }).click();
|
||||||
|
await expect(page.getByTestId('plugin-manager')).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(page.getByTestId('plugin-manager').getByRole('heading', { name: 'Server plugins' })).toBeVisible();
|
||||||
|
await expect(page.getByText('E2E All API Plugin')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Grant capabilities and activate runtime', async () => {
|
||||||
|
const manager = page.getByTestId('plugin-manager');
|
||||||
|
const pluginCard = manager.locator('article', { hasText: 'E2E All API Plugin' });
|
||||||
|
|
||||||
|
await manager.getByRole('button', { name: 'Installed' }).click();
|
||||||
|
await expect(pluginCard).toBeVisible({ timeout: 10_000 });
|
||||||
|
await pluginCard.getByRole('button', { name: 'Select' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Grant all requested' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Activate ready plugins' }).click();
|
||||||
|
await expect(page.locator('article', { hasText: 'E2E All API Plugin' }).getByText('ready', { exact: true })).toBeVisible({ timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Verify plugin exercised APIs through logs and extension points', async () => {
|
||||||
|
const manager = page.getByTestId('plugin-manager');
|
||||||
|
|
||||||
|
await manager.getByRole('button', { name: 'Logs' }).click();
|
||||||
|
await expect(page.getByText('all-api plugin completed')).toBeVisible({ timeout: 20_000 });
|
||||||
|
await expect(page.getByText('all-api plugin ready')).toBeVisible({ timeout: 10_000 });
|
||||||
|
await manager.getByRole('button', { name: 'Extension points' }).click();
|
||||||
|
await expect(page.getByTestId('plugin-extension-counts')).toContainText('Settings pages');
|
||||||
|
await expect(page.getByTestId('plugin-extension-counts')).toContainText('Embed renderers');
|
||||||
|
await expect(page.getByTestId('plugin-extension-counts')).toContainText('1');
|
||||||
|
await expect(page.getByTestId('plugin-conflict-diagnostics')).toContainText(
|
||||||
|
'No duplicate route, action, embed, channel, panel, or settings contribution ids detected.'
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.getByRole('button', { exact: true, name: 'Requirements' }).click();
|
||||||
|
await expect(page.getByTestId('plugin-server-requirements')).toContainText('E2E All API Plugin');
|
||||||
|
await expect(page.getByTestId('plugin-server-requirements')).toContainText('enabled');
|
||||||
|
|
||||||
|
await manager.getByRole('button', { exact: true, name: 'Settings' }).click();
|
||||||
|
await expect(page.getByTestId('plugin-generated-settings')).toContainText('E2E settings contribution');
|
||||||
|
await expect(page.getByTestId('plugin-generated-settings')).toContainText('"enabled"');
|
||||||
|
|
||||||
|
await manager.getByRole('button', { exact: true, name: 'Docs' }).click();
|
||||||
|
await expect(page.getByTestId('plugin-installed-docs')).toContainText('Calls every public Toju plugin API surface');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
369
e2e/tests/plugins/plugin-support-api.spec.ts
Normal file
369
e2e/tests/plugins/plugin-support-api.spec.ts
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
import type { APIRequestContext, APIResponse } from '@playwright/test';
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
import { expect, test } from '../../fixtures/multi-client';
|
||||||
|
import {
|
||||||
|
getPluginApiTestEvent,
|
||||||
|
readPluginApiTestManifest,
|
||||||
|
TEST_PLUGIN_ID,
|
||||||
|
TEST_PLUGIN_P2P_EVENT,
|
||||||
|
TEST_PLUGIN_RELAY_EVENT
|
||||||
|
} from '../../helpers/plugin-api-test-fixture';
|
||||||
|
|
||||||
|
const OWNER_USER_ID = 'plugin-api-owner';
|
||||||
|
|
||||||
|
interface CreatedServerResponse {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginRequirementResponse {
|
||||||
|
requirement: {
|
||||||
|
pluginId: string;
|
||||||
|
reason?: string;
|
||||||
|
status: string;
|
||||||
|
versionRange?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginEventDefinitionResponse {
|
||||||
|
eventDefinition: {
|
||||||
|
direction: string;
|
||||||
|
eventName: string;
|
||||||
|
maxPayloadBytes: number;
|
||||||
|
pluginId: string;
|
||||||
|
scope: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginSnapshotResponse {
|
||||||
|
eventDefinitions: PluginEventDefinitionResponse['eventDefinition'][];
|
||||||
|
requirements: PluginRequirementResponse['requirement'][];
|
||||||
|
serverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SocketMessage {
|
||||||
|
[key: string]: unknown;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestSocket {
|
||||||
|
close: () => Promise<void>;
|
||||||
|
messages: SocketMessage[];
|
||||||
|
send: (message: SocketMessage) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Plugin support API', () => {
|
||||||
|
test('covers plugin requirement, event, data, and websocket APIs with the fixture plugin', async ({ request, testServer }) => {
|
||||||
|
const manifest = await readPluginApiTestManifest();
|
||||||
|
const server = await createServer(request, testServer.url, `Plugin API ${Date.now()}`);
|
||||||
|
const relayEvent = getPluginApiTestEvent(manifest, TEST_PLUGIN_RELAY_EVENT);
|
||||||
|
const p2pEvent = getPluginApiTestEvent(manifest, TEST_PLUGIN_P2P_EVENT);
|
||||||
|
const pluginsApi = `${testServer.url}/api/servers/${encodeURIComponent(server.id)}/plugins`;
|
||||||
|
|
||||||
|
await test.step('Initial snapshot is empty', async () => {
|
||||||
|
const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi));
|
||||||
|
|
||||||
|
expect(snapshot).toEqual(expect.objectContaining({
|
||||||
|
eventDefinitions: [],
|
||||||
|
requirements: [],
|
||||||
|
serverId: server.id
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Requirement API enforces server management permission', async () => {
|
||||||
|
const response = await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
|
||||||
|
data: {
|
||||||
|
actorUserId: 'not-the-owner',
|
||||||
|
status: 'required'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const body = await expectJson<{ errorCode: string }>(response, 403);
|
||||||
|
|
||||||
|
expect(body.errorCode).toBe('NOT_AUTHORIZED');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Requirement and event definition APIs persist the test plugin contract', async () => {
|
||||||
|
const requirement = await expectJson<PluginRequirementResponse>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
|
||||||
|
data: {
|
||||||
|
actorUserId: OWNER_USER_ID,
|
||||||
|
reason: manifest.description,
|
||||||
|
status: 'required',
|
||||||
|
versionRange: `^${manifest.version}`
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(requirement.requirement).toEqual(expect.objectContaining({
|
||||||
|
pluginId: TEST_PLUGIN_ID,
|
||||||
|
reason: manifest.description,
|
||||||
|
status: 'required',
|
||||||
|
versionRange: `^${manifest.version}`
|
||||||
|
}));
|
||||||
|
|
||||||
|
const relayDefinition = await upsertEventDefinition(request, pluginsApi, relayEvent);
|
||||||
|
const p2pDefinition = await upsertEventDefinition(request, pluginsApi, p2pEvent);
|
||||||
|
|
||||||
|
expect(relayDefinition.eventDefinition).toEqual(expect.objectContaining({
|
||||||
|
direction: 'serverRelay',
|
||||||
|
eventName: TEST_PLUGIN_RELAY_EVENT,
|
||||||
|
pluginId: TEST_PLUGIN_ID,
|
||||||
|
scope: 'server'
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(p2pDefinition.eventDefinition).toEqual(expect.objectContaining({
|
||||||
|
direction: 'p2pHint',
|
||||||
|
eventName: TEST_PLUGIN_P2P_EVENT,
|
||||||
|
pluginId: TEST_PLUGIN_ID,
|
||||||
|
scope: 'user'
|
||||||
|
}));
|
||||||
|
|
||||||
|
const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi));
|
||||||
|
|
||||||
|
expect(snapshot.requirements.map((entry) => entry.pluginId)).toEqual([TEST_PLUGIN_ID]);
|
||||||
|
expect(snapshot.eventDefinitions.map((entry) => entry.eventName).sort()).toEqual([TEST_PLUGIN_P2P_EVENT, TEST_PLUGIN_RELAY_EVENT]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Plugin data API refuses arbitrary server persistence', async () => {
|
||||||
|
const stored = await expectJson<{ errorCode: string }>(await request.put(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, {
|
||||||
|
data: {
|
||||||
|
actorUserId: OWNER_USER_ID,
|
||||||
|
schemaVersion: 1,
|
||||||
|
scope: 'server',
|
||||||
|
value: {
|
||||||
|
enabled: true,
|
||||||
|
pluginVersion: manifest.version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}), 410);
|
||||||
|
|
||||||
|
expect(stored.errorCode).toBe('PLUGIN_DATA_DISABLED');
|
||||||
|
|
||||||
|
const listed = await expectJson<{ errorCode: string }>(await request.get(`${pluginsApi}/${TEST_PLUGIN_ID}/data`, {
|
||||||
|
params: {
|
||||||
|
key: 'settings',
|
||||||
|
scope: 'server',
|
||||||
|
userId: OWNER_USER_ID
|
||||||
|
}
|
||||||
|
}), 410);
|
||||||
|
|
||||||
|
expect(listed.errorCode).toBe('PLUGIN_DATA_DISABLED');
|
||||||
|
|
||||||
|
const afterDelete = await expectJson<{ errorCode: string }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/data/settings`, {
|
||||||
|
data: {
|
||||||
|
actorUserId: OWNER_USER_ID,
|
||||||
|
scope: 'server'
|
||||||
|
}
|
||||||
|
}), 410);
|
||||||
|
|
||||||
|
expect(afterDelete.errorCode).toBe('PLUGIN_DATA_DISABLED');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('WebSocket plugin API sends snapshots, relays server events, and rejects p2p relays', async () => {
|
||||||
|
const alice = await openTestSocket(testServer.url);
|
||||||
|
const bob = await openTestSocket(testServer.url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
alice.send({ type: 'identify', oderId: OWNER_USER_ID, displayName: 'Plugin Owner' });
|
||||||
|
bob.send({ type: 'identify', oderId: 'plugin-api-peer', displayName: 'Plugin Peer' });
|
||||||
|
alice.send({ type: 'join_server', serverId: server.id });
|
||||||
|
bob.send({ type: 'join_server', serverId: server.id });
|
||||||
|
|
||||||
|
const aliceSnapshot = await waitForSocketMessage(alice, (message) => message.type === 'plugin_requirements');
|
||||||
|
const bobSnapshot = await waitForSocketMessage(bob, (message) => message.type === 'plugin_requirements');
|
||||||
|
const bobEventNames = (bobSnapshot['snapshot'] as PluginSnapshotResponse).eventDefinitions
|
||||||
|
.map((entry) => entry.eventName)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
expect((aliceSnapshot['snapshot'] as PluginSnapshotResponse).requirements[0]?.pluginId).toBe(TEST_PLUGIN_ID);
|
||||||
|
expect(bobEventNames).toEqual([TEST_PLUGIN_P2P_EVENT, TEST_PLUGIN_RELAY_EVENT]);
|
||||||
|
|
||||||
|
alice.send({
|
||||||
|
type: 'plugin_event',
|
||||||
|
eventId: 'relay-event-1',
|
||||||
|
eventName: TEST_PLUGIN_RELAY_EVENT,
|
||||||
|
payload: { message: 'hello from fixture plugin' },
|
||||||
|
pluginId: TEST_PLUGIN_ID,
|
||||||
|
serverId: server.id,
|
||||||
|
sourcePluginUserId: 'fixture-plugin-user'
|
||||||
|
});
|
||||||
|
|
||||||
|
const relayedEvent = await waitForSocketMessage(bob, (message) => message.type === 'plugin_event');
|
||||||
|
|
||||||
|
expect(relayedEvent).toEqual(expect.objectContaining({
|
||||||
|
eventId: 'relay-event-1',
|
||||||
|
eventName: TEST_PLUGIN_RELAY_EVENT,
|
||||||
|
pluginId: TEST_PLUGIN_ID,
|
||||||
|
serverId: server.id,
|
||||||
|
sourcePluginUserId: 'fixture-plugin-user',
|
||||||
|
sourceUserId: OWNER_USER_ID
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(relayedEvent['payload']).toEqual({ message: 'hello from fixture plugin' });
|
||||||
|
expect(typeof relayedEvent['emittedAt']).toBe('number');
|
||||||
|
|
||||||
|
alice.send({
|
||||||
|
type: 'plugin_event',
|
||||||
|
eventId: 'p2p-event-1',
|
||||||
|
eventName: TEST_PLUGIN_P2P_EVENT,
|
||||||
|
payload: { hint: true },
|
||||||
|
pluginId: TEST_PLUGIN_ID,
|
||||||
|
serverId: server.id
|
||||||
|
});
|
||||||
|
|
||||||
|
const p2pError = await waitForSocketMessage(
|
||||||
|
alice,
|
||||||
|
(message) => message.type === 'plugin_error' && message['eventId'] === 'p2p-event-1'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(p2pError['code']).toBe('PLUGIN_EVENT_NOT_RELAYABLE');
|
||||||
|
|
||||||
|
alice.send({
|
||||||
|
type: 'plugin_event',
|
||||||
|
eventId: 'missing-event-1',
|
||||||
|
eventName: 'e2e:missing',
|
||||||
|
payload: {},
|
||||||
|
pluginId: TEST_PLUGIN_ID,
|
||||||
|
serverId: server.id
|
||||||
|
});
|
||||||
|
|
||||||
|
const missingError = await waitForSocketMessage(
|
||||||
|
alice,
|
||||||
|
(message) => message.type === 'plugin_error' && message['eventId'] === 'missing-event-1'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(missingError['code']).toBe('PLUGIN_EVENT_NOT_REGISTERED');
|
||||||
|
} finally {
|
||||||
|
await Promise.all([alice.close(), bob.close()]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Delete APIs remove event definitions and requirements', async () => {
|
||||||
|
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/events/${TEST_PLUGIN_RELAY_EVENT}`, {
|
||||||
|
data: { actorUserId: OWNER_USER_ID }
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/events/${TEST_PLUGIN_P2P_EVENT}`, {
|
||||||
|
data: { actorUserId: OWNER_USER_ID }
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expectJson<{ ok: boolean }>(await request.delete(`${pluginsApi}/${TEST_PLUGIN_ID}/requirement`, {
|
||||||
|
data: { actorUserId: OWNER_USER_ID }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const snapshot = await expectJson<PluginSnapshotResponse>(await request.get(pluginsApi));
|
||||||
|
|
||||||
|
expect(snapshot.eventDefinitions).toEqual([]);
|
||||||
|
expect(snapshot.requirements).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createServer(
|
||||||
|
request: APIRequestContext,
|
||||||
|
baseUrl: string,
|
||||||
|
serverName: string
|
||||||
|
): Promise<CreatedServerResponse> {
|
||||||
|
const response = await request.post(`${baseUrl}/api/servers`, {
|
||||||
|
data: {
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
id: 'general-text',
|
||||||
|
name: 'general',
|
||||||
|
position: 0,
|
||||||
|
type: 'text'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
description: 'Server for plugin API E2E coverage',
|
||||||
|
id: `plugin-api-${Date.now()}`,
|
||||||
|
isPrivate: false,
|
||||||
|
name: serverName,
|
||||||
|
ownerId: OWNER_USER_ID,
|
||||||
|
ownerPublicKey: 'plugin-api-owner-public-key',
|
||||||
|
tags: ['plugins']
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return await expectJson<CreatedServerResponse>(response, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertEventDefinition(
|
||||||
|
request: APIRequestContext,
|
||||||
|
pluginsApi: string,
|
||||||
|
eventDefinition: ReturnType<typeof getPluginApiTestEvent>
|
||||||
|
): Promise<PluginEventDefinitionResponse> {
|
||||||
|
return await expectJson<PluginEventDefinitionResponse>(await request.put(
|
||||||
|
`${pluginsApi}/${TEST_PLUGIN_ID}/events/${encodeURIComponent(eventDefinition.eventName)}`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
actorUserId: OWNER_USER_ID,
|
||||||
|
direction: eventDefinition.direction,
|
||||||
|
maxPayloadBytes: eventDefinition.maxPayloadBytes,
|
||||||
|
schemaJson: '{"type":"object"}',
|
||||||
|
scope: eventDefinition.scope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectJson<T>(response: APIResponse, status = 200): Promise<T> {
|
||||||
|
expect(response.status()).toBe(status);
|
||||||
|
|
||||||
|
return await response.json() as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openTestSocket(baseUrl: string): Promise<TestSocket> {
|
||||||
|
const socketUrl = baseUrl.replace(/^http/, 'ws');
|
||||||
|
const socket = new WebSocket(socketUrl);
|
||||||
|
const messages: SocketMessage[] = [];
|
||||||
|
|
||||||
|
socket.on('message', (data) => {
|
||||||
|
messages.push(JSON.parse(data.toString()) as SocketMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
socket.once('open', () => resolve());
|
||||||
|
socket.once('error', reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForSocketMessage({ messages, send: () => {}, close: async () => {} }, (message) => message.type === 'connected');
|
||||||
|
|
||||||
|
return {
|
||||||
|
close: async () => {
|
||||||
|
if (socket.readyState === WebSocket.CLOSED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
socket.once('close', () => resolve());
|
||||||
|
socket.close();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
messages,
|
||||||
|
send: (message: SocketMessage) => {
|
||||||
|
socket.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSocketMessage(
|
||||||
|
socket: Pick<TestSocket, 'messages'>,
|
||||||
|
predicate: (message: SocketMessage) => boolean,
|
||||||
|
timeoutMs = 10_000
|
||||||
|
): Promise<SocketMessage> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const message = socket.messages.find(predicate);
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
clearInterval(interval);
|
||||||
|
resolve(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() - startedAt > timeoutMs) {
|
||||||
|
clearInterval(interval);
|
||||||
|
reject(new Error('Timed out waiting for websocket message'));
|
||||||
|
}
|
||||||
|
}, 25);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -56,12 +56,7 @@ async function setupServerWithBothUsers(
|
|||||||
// Bob joins server
|
// Bob joins server
|
||||||
const bobSearch = new ServerSearchPage(bob.page);
|
const bobSearch = new ServerSearchPage(bob.page);
|
||||||
|
|
||||||
await bobSearch.searchInput.fill(SERVER_NAME);
|
await bobSearch.joinServerFromSearch(SERVER_NAME);
|
||||||
|
|
||||||
const serverCard = bob.page.locator('button', { hasText: SERVER_NAME }).first();
|
|
||||||
|
|
||||||
await expect(serverCard).toBeVisible({ timeout: 10_000 });
|
|
||||||
await serverCard.click();
|
|
||||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ test.describe('Connectivity warning', () => {
|
|||||||
|
|
||||||
await register.goto();
|
await register.goto();
|
||||||
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
|
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
|
||||||
await expect(alice.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
await expect(alice.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Register Bob', async () => {
|
await test.step('Register Bob', async () => {
|
||||||
@@ -96,7 +96,7 @@ test.describe('Connectivity warning', () => {
|
|||||||
|
|
||||||
await register.goto();
|
await register.goto();
|
||||||
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
|
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
|
||||||
await expect(bob.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
await expect(bob.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Register Charlie', async () => {
|
await test.step('Register Charlie', async () => {
|
||||||
@@ -104,7 +104,7 @@ test.describe('Connectivity warning', () => {
|
|||||||
|
|
||||||
await register.goto();
|
await register.goto();
|
||||||
await register.register(`charlie_${suffix}`, 'Charlie', 'TestPass123!');
|
await register.register(`charlie_${suffix}`, 'Charlie', 'TestPass123!');
|
||||||
await expect(charlie.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
await expect(charlie.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Create server and have everyone join ──
|
// ── Create server and have everyone join ──
|
||||||
@@ -117,22 +117,14 @@ test.describe('Connectivity warning', () => {
|
|||||||
await test.step('Bob joins the server', async () => {
|
await test.step('Bob joins the server', async () => {
|
||||||
const search = new ServerSearchPage(bob.page);
|
const search = new ServerSearchPage(bob.page);
|
||||||
|
|
||||||
await search.searchInput.fill(serverName);
|
await search.joinServerFromSearch(serverName);
|
||||||
const card = bob.page.locator('button', { hasText: serverName }).first();
|
|
||||||
|
|
||||||
await expect(card).toBeVisible({ timeout: 15_000 });
|
|
||||||
await card.click();
|
|
||||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Charlie joins the server', async () => {
|
await test.step('Charlie joins the server', async () => {
|
||||||
const search = new ServerSearchPage(charlie.page);
|
const search = new ServerSearchPage(charlie.page);
|
||||||
|
|
||||||
await search.searchInput.fill(serverName);
|
await search.joinServerFromSearch(serverName);
|
||||||
const card = charlie.page.locator('button', { hasText: serverName }).first();
|
|
||||||
|
|
||||||
await expect(card).toBeVisible({ timeout: 15_000 });
|
|
||||||
await card.click();
|
|
||||||
await expect(charlie.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(charlie.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ test.describe('ICE server settings', () => {
|
|||||||
|
|
||||||
await register.goto();
|
await register.goto();
|
||||||
await register.register(`user_${suffix}`, 'IceTestUser', 'TestPass123!');
|
await register.register(`user_${suffix}`, 'IceTestUser', 'TestPass123!');
|
||||||
await expect(page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
|
||||||
await page.getByTitle('Settings').click();
|
await page.getByTitle('Settings').click();
|
||||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByRole('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 });
|
||||||
await page.getByRole('button', { name: 'Network' }).click();
|
await page.getByRole('button', { name: 'Network' }).click();
|
||||||
|
await expect(page.getByTestId('ice-server-settings')).toBeVisible({ timeout: 10_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
test('allows adding, removing, and reordering ICE servers', async ({ createClient }) => {
|
test('allows adding, removing, and reordering ICE servers', async ({ createClient }) => {
|
||||||
@@ -101,7 +102,7 @@ test.describe('ICE server settings', () => {
|
|||||||
|
|
||||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
await page.getByTitle('Settings').click();
|
await page.getByTitle('Settings').click();
|
||||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByRole('button', { name: 'Network' })).toBeVisible({ timeout: 10_000 });
|
||||||
await page.getByRole('button', { name: 'Network' }).click();
|
await page.getByRole('button', { name: 'Network' }).click();
|
||||||
await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ test.describe('STUN/TURN fallback behaviour', () => {
|
|||||||
|
|
||||||
await register.goto();
|
await register.goto();
|
||||||
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
|
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
|
||||||
await expect(alice.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
await expect(alice.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Register Bob', async () => {
|
await test.step('Register Bob', async () => {
|
||||||
@@ -97,7 +97,7 @@ test.describe('STUN/TURN fallback behaviour', () => {
|
|||||||
|
|
||||||
await register.goto();
|
await register.goto();
|
||||||
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
|
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
|
||||||
await expect(bob.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
await expect(bob.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Alice creates a server', async () => {
|
await test.step('Alice creates a server', async () => {
|
||||||
@@ -109,11 +109,7 @@ test.describe('STUN/TURN fallback behaviour', () => {
|
|||||||
await test.step('Bob joins Alice server', async () => {
|
await test.step('Bob joins Alice server', async () => {
|
||||||
const search = new ServerSearchPage(bob.page);
|
const search = new ServerSearchPage(bob.page);
|
||||||
|
|
||||||
await search.searchInput.fill(serverName);
|
await search.joinServerFromSearch(serverName);
|
||||||
const serverCard = bob.page.locator('button', { hasText: serverName }).first();
|
|
||||||
|
|
||||||
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
|
||||||
await serverCard.click();
|
|
||||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -556,7 +556,7 @@ async function installDeterministicVoiceSettings(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openSearchView(page: Page): Promise<void> {
|
async function openSearchView(page: Page): Promise<void> {
|
||||||
const searchInput = page.getByPlaceholder('Search servers...');
|
const searchInput = page.getByPlaceholder('Search servers and users...');
|
||||||
|
|
||||||
if (await searchInput.isVisible().catch(() => false)) {
|
if (await searchInput.isVisible().catch(() => false)) {
|
||||||
return;
|
return;
|
||||||
@@ -567,15 +567,15 @@ async function openSearchView(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
|
async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
|
||||||
const searchInput = page.getByPlaceholder('Search servers...');
|
const searchInput = page.getByPlaceholder('Search servers and users...');
|
||||||
|
|
||||||
await expect(searchInput).toBeVisible({ timeout: 20_000 });
|
await expect(searchInput).toBeVisible({ timeout: 20_000 });
|
||||||
await searchInput.fill(roomName);
|
await searchInput.fill(roomName);
|
||||||
|
|
||||||
const roomCard = page.locator('button', { hasText: roomName }).first();
|
const roomCard = page.locator('div[title]', { hasText: roomName }).first();
|
||||||
|
|
||||||
await expect(roomCard).toBeVisible({ timeout: 20_000 });
|
await expect(roomCard).toBeVisible({ timeout: 20_000 });
|
||||||
await roomCard.click();
|
await roomCard.dblclick();
|
||||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
||||||
await waitForCurrentRoomName(page, roomName);
|
await waitForCurrentRoomName(page, roomName);
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ async function installDeterministicVoiceSettings(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openSearchView(page: Page): Promise<void> {
|
async function openSearchView(page: Page): Promise<void> {
|
||||||
const searchInput = page.getByPlaceholder('Search servers...');
|
const searchInput = page.getByPlaceholder('Search servers and users...');
|
||||||
|
|
||||||
if (await searchInput.isVisible().catch(() => false)) {
|
if (await searchInput.isVisible().catch(() => false)) {
|
||||||
return;
|
return;
|
||||||
@@ -330,15 +330,15 @@ async function openSearchView(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
|
async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
|
||||||
const searchInput = page.getByPlaceholder('Search servers...');
|
const searchInput = page.getByPlaceholder('Search servers and users...');
|
||||||
|
|
||||||
await expect(searchInput).toBeVisible({ timeout: 20_000 });
|
await expect(searchInput).toBeVisible({ timeout: 20_000 });
|
||||||
await searchInput.fill(roomName);
|
await searchInput.fill(roomName);
|
||||||
|
|
||||||
const roomCard = page.locator('button', { hasText: roomName }).first();
|
const roomCard = page.locator('div[title]', { hasText: roomName }).first();
|
||||||
|
|
||||||
await expect(roomCard).toBeVisible({ timeout: 20_000 });
|
await expect(roomCard).toBeVisible({ timeout: 20_000 });
|
||||||
await roomCard.click();
|
await roomCard.dblclick();
|
||||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||||
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
||||||
await waitForCurrentRoomName(page, roomName);
|
await waitForCurrentRoomName(page, roomName);
|
||||||
|
|||||||
@@ -96,14 +96,7 @@ test.describe('Full user journey: register -> server -> voice chat', () => {
|
|||||||
await test.step('Bob finds and joins the server', async () => {
|
await test.step('Bob finds and joins the server', async () => {
|
||||||
const searchPage = new ServerSearchPage(bob.page);
|
const searchPage = new ServerSearchPage(bob.page);
|
||||||
|
|
||||||
// Search for the server
|
await searchPage.joinServerFromSearch(SERVER_NAME);
|
||||||
await searchPage.searchInput.fill(SERVER_NAME);
|
|
||||||
|
|
||||||
// Wait for search results and click the server
|
|
||||||
const serverCard = bob.page.locator('button', { hasText: SERVER_NAME }).first();
|
|
||||||
|
|
||||||
await expect(serverCard).toBeVisible({ timeout: 10_000 });
|
|
||||||
await serverCard.click();
|
|
||||||
|
|
||||||
// Bob should be in the room now
|
// Bob should be in the room now
|
||||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ Electron main-process package for MetoYou / Toju. This directory owns desktop bo
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `main.ts` | Electron app bootstrap and process entry point |
|
| `main.ts` | Electron app bootstrap and process entry point |
|
||||||
| `preload.ts` | Typed renderer-facing preload bridge |
|
| `preload.ts` | Typed renderer-facing preload bridge |
|
||||||
|
| `process-list.ts` | Linux/Windows process-name scan used by now-playing game detection |
|
||||||
| `app/` | App lifecycle and startup composition |
|
| `app/` | App lifecycle and startup composition |
|
||||||
| `ipc/` | Renderer-invoked IPC handlers |
|
| `ipc/` | Renderer-invoked IPC handlers |
|
||||||
| `cqrs/` | Local database command/query handlers and mappings |
|
| `cqrs/` | Local database command/query handlers and mappings |
|
||||||
@@ -27,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.
|
||||||
@@ -5,6 +5,7 @@ import { createWindow, getMainWindow } from '../window/create-window';
|
|||||||
const CUSTOM_PROTOCOL = 'toju';
|
const CUSTOM_PROTOCOL = 'toju';
|
||||||
const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`;
|
const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`;
|
||||||
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
|
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
|
||||||
|
const DEV_RELOAD_EXISTING_ARG = '--metoyou-dev-reload-existing';
|
||||||
|
|
||||||
let pendingDeepLink: string | null = null;
|
let pendingDeepLink: string | null = null;
|
||||||
|
|
||||||
@@ -95,6 +96,12 @@ export function initializeDeepLinkHandling(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.on('second-instance', (_event, argv) => {
|
app.on('second-instance', (_event, argv) => {
|
||||||
|
if (resolveDevSingleInstanceExitCode() != null && argv.includes(DEV_RELOAD_EXISTING_ARG)) {
|
||||||
|
app.relaunch();
|
||||||
|
app.exit(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
focusMainWindow();
|
focusMainWindow();
|
||||||
|
|
||||||
const deepLink = extractDeepLink(argv);
|
const deepLink = extractDeepLink(argv);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
229
electron/data-archive.ts
Normal file
229
electron/data-archive.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import * as fsp from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
export interface ZipArchiveEntry {
|
||||||
|
data: Buffer;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CentralDirectoryEntry {
|
||||||
|
compressedSize: number;
|
||||||
|
crc: number;
|
||||||
|
data: Buffer;
|
||||||
|
localHeaderOffset: number;
|
||||||
|
name: Buffer;
|
||||||
|
uncompressedSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
|
||||||
|
const ZIP_CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
|
||||||
|
const ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
|
||||||
|
const ZIP_UTF8_FLAG = 0x0800;
|
||||||
|
const ZIP_STORE_METHOD = 0;
|
||||||
|
const ZIP_VERSION = 20;
|
||||||
|
const MAX_UINT32 = 0xffffffff;
|
||||||
|
|
||||||
|
const crcTable = buildCrcTable();
|
||||||
|
|
||||||
|
export function createZipArchive(entries: ZipArchiveEntry[]): Buffer {
|
||||||
|
const localParts: Buffer[] = [];
|
||||||
|
const centralEntries: CentralDirectoryEntry[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const normalizedPath = normalizeZipPath(entry.path);
|
||||||
|
const name = Buffer.from(normalizedPath, 'utf8');
|
||||||
|
const data = entry.data;
|
||||||
|
|
||||||
|
if (name.length > 0xffff || data.length > MAX_UINT32 || offset > MAX_UINT32) {
|
||||||
|
throw new Error('Data archive is too large for the portable ZIP format.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const crc = crc32(data);
|
||||||
|
const localHeader = Buffer.alloc(30);
|
||||||
|
|
||||||
|
localHeader.writeUInt32LE(ZIP_LOCAL_FILE_HEADER_SIGNATURE, 0);
|
||||||
|
localHeader.writeUInt16LE(ZIP_VERSION, 4);
|
||||||
|
localHeader.writeUInt16LE(ZIP_UTF8_FLAG, 6);
|
||||||
|
localHeader.writeUInt16LE(ZIP_STORE_METHOD, 8);
|
||||||
|
localHeader.writeUInt16LE(0, 10);
|
||||||
|
localHeader.writeUInt16LE(0, 12);
|
||||||
|
localHeader.writeUInt32LE(crc, 14);
|
||||||
|
localHeader.writeUInt32LE(data.length, 18);
|
||||||
|
localHeader.writeUInt32LE(data.length, 22);
|
||||||
|
localHeader.writeUInt16LE(name.length, 26);
|
||||||
|
localHeader.writeUInt16LE(0, 28);
|
||||||
|
|
||||||
|
localParts.push(localHeader, name, data);
|
||||||
|
centralEntries.push({
|
||||||
|
compressedSize: data.length,
|
||||||
|
crc,
|
||||||
|
data,
|
||||||
|
localHeaderOffset: offset,
|
||||||
|
name,
|
||||||
|
uncompressedSize: data.length
|
||||||
|
});
|
||||||
|
|
||||||
|
offset += localHeader.length + name.length + data.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const centralDirectoryOffset = offset;
|
||||||
|
const centralParts = centralEntries.map((entry) => {
|
||||||
|
const header = Buffer.alloc(46);
|
||||||
|
|
||||||
|
header.writeUInt32LE(ZIP_CENTRAL_DIRECTORY_SIGNATURE, 0);
|
||||||
|
header.writeUInt16LE(ZIP_VERSION, 4);
|
||||||
|
header.writeUInt16LE(ZIP_VERSION, 6);
|
||||||
|
header.writeUInt16LE(ZIP_UTF8_FLAG, 8);
|
||||||
|
header.writeUInt16LE(ZIP_STORE_METHOD, 10);
|
||||||
|
header.writeUInt16LE(0, 12);
|
||||||
|
header.writeUInt16LE(0, 14);
|
||||||
|
header.writeUInt32LE(entry.crc, 16);
|
||||||
|
header.writeUInt32LE(entry.compressedSize, 20);
|
||||||
|
header.writeUInt32LE(entry.uncompressedSize, 24);
|
||||||
|
header.writeUInt16LE(entry.name.length, 28);
|
||||||
|
header.writeUInt16LE(0, 30);
|
||||||
|
header.writeUInt16LE(0, 32);
|
||||||
|
header.writeUInt16LE(0, 34);
|
||||||
|
header.writeUInt16LE(0, 36);
|
||||||
|
header.writeUInt32LE(0, 38);
|
||||||
|
header.writeUInt32LE(entry.localHeaderOffset, 42);
|
||||||
|
|
||||||
|
offset += header.length + entry.name.length;
|
||||||
|
|
||||||
|
return Buffer.concat([header, entry.name]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const centralDirectorySize = offset - centralDirectoryOffset;
|
||||||
|
|
||||||
|
if (centralEntries.length > 0xffff || centralDirectoryOffset > MAX_UINT32 || centralDirectorySize > MAX_UINT32) {
|
||||||
|
throw new Error('Data archive is too large for the portable ZIP format.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = Buffer.alloc(22);
|
||||||
|
|
||||||
|
end.writeUInt32LE(ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE, 0);
|
||||||
|
end.writeUInt16LE(0, 4);
|
||||||
|
end.writeUInt16LE(0, 6);
|
||||||
|
end.writeUInt16LE(centralEntries.length, 8);
|
||||||
|
end.writeUInt16LE(centralEntries.length, 10);
|
||||||
|
end.writeUInt32LE(centralDirectorySize, 12);
|
||||||
|
end.writeUInt32LE(centralDirectoryOffset, 16);
|
||||||
|
end.writeUInt16LE(0, 20);
|
||||||
|
|
||||||
|
return Buffer.concat([...localParts, ...centralParts, end]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readZipArchive(data: Buffer): ZipArchiveEntry[] {
|
||||||
|
const endOffset = findEndOfCentralDirectory(data);
|
||||||
|
|
||||||
|
if (endOffset < 0) {
|
||||||
|
throw new Error('The selected file is not a supported data archive.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryCount = data.readUInt16LE(endOffset + 10);
|
||||||
|
const centralDirectoryOffset = data.readUInt32LE(endOffset + 16);
|
||||||
|
const entries: ZipArchiveEntry[] = [];
|
||||||
|
let offset = centralDirectoryOffset;
|
||||||
|
|
||||||
|
for (let index = 0; index < entryCount; index += 1) {
|
||||||
|
if (data.readUInt32LE(offset) !== ZIP_CENTRAL_DIRECTORY_SIGNATURE) {
|
||||||
|
throw new Error('The data archive directory is invalid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = data.readUInt16LE(offset + 10);
|
||||||
|
const compressedSize = data.readUInt32LE(offset + 20);
|
||||||
|
const uncompressedSize = data.readUInt32LE(offset + 24);
|
||||||
|
const nameLength = data.readUInt16LE(offset + 28);
|
||||||
|
const extraLength = data.readUInt16LE(offset + 30);
|
||||||
|
const commentLength = data.readUInt16LE(offset + 32);
|
||||||
|
const localHeaderOffset = data.readUInt32LE(offset + 42);
|
||||||
|
const entryPath = normalizeZipPath(data.subarray(offset + 46, offset + 46 + nameLength).toString('utf8'));
|
||||||
|
|
||||||
|
if (method !== ZIP_STORE_METHOD || compressedSize !== uncompressedSize) {
|
||||||
|
throw new Error('Compressed data archives are not supported by this build.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.readUInt32LE(localHeaderOffset) !== ZIP_LOCAL_FILE_HEADER_SIGNATURE) {
|
||||||
|
throw new Error('The data archive contains an invalid file entry.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const localNameLength = data.readUInt16LE(localHeaderOffset + 26);
|
||||||
|
const localExtraLength = data.readUInt16LE(localHeaderOffset + 28);
|
||||||
|
const dataOffset = localHeaderOffset + 30 + localNameLength + localExtraLength;
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
data: Buffer.from(data.subarray(dataOffset, dataOffset + compressedSize)),
|
||||||
|
path: entryPath
|
||||||
|
});
|
||||||
|
|
||||||
|
offset += 46 + nameLength + extraLength + commentLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractZipEntries(entries: ZipArchiveEntry[], destinationPath: string): Promise<void> {
|
||||||
|
const destinationRoot = path.resolve(destinationPath);
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const targetPath = path.resolve(destinationRoot, entry.path);
|
||||||
|
|
||||||
|
if (!targetPath.startsWith(destinationRoot + path.sep) && targetPath !== destinationRoot) {
|
||||||
|
throw new Error('The data archive contains an unsafe path.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsp.mkdir(path.dirname(targetPath), { recursive: true });
|
||||||
|
await fsp.writeFile(targetPath, entry.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findEndOfCentralDirectory(data: Buffer): number {
|
||||||
|
const minimumOffset = Math.max(0, data.length - 0xffff - 22);
|
||||||
|
|
||||||
|
for (let offset = data.length - 22; offset >= minimumOffset; offset -= 1) {
|
||||||
|
if (data.readUInt32LE(offset) === ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE) {
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeZipPath(value: string): string {
|
||||||
|
const normalized = value.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||||
|
|
||||||
|
if (!normalized || normalized.split('/').some((part) => part === '..' || part === '')) {
|
||||||
|
throw new Error('The data archive contains an unsafe path.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCrcTable(): number[] {
|
||||||
|
const table: number[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < 256; index += 1) {
|
||||||
|
let value = index;
|
||||||
|
|
||||||
|
for (let bit = 0; bit < 8; bit += 1) {
|
||||||
|
value = (value & 1) !== 0
|
||||||
|
? 0xedb88320 ^ (value >>> 1)
|
||||||
|
: value >>> 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
table[index] = value >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
function crc32(data: Buffer): number {
|
||||||
|
let crc = 0xffffffff;
|
||||||
|
|
||||||
|
for (const byte of data) {
|
||||||
|
crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (crc ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
257
electron/data-management.ts
Normal file
257
electron/data-management.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import {
|
||||||
|
app,
|
||||||
|
dialog,
|
||||||
|
shell
|
||||||
|
} from 'electron';
|
||||||
|
import * as fsp from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { destroyDatabase, initializeDatabase } from './db/database';
|
||||||
|
import {
|
||||||
|
createZipArchive,
|
||||||
|
extractZipEntries,
|
||||||
|
readZipArchive,
|
||||||
|
type ZipArchiveEntry
|
||||||
|
} from './data-archive';
|
||||||
|
|
||||||
|
export interface ExportUserDataResult {
|
||||||
|
cancelled: boolean;
|
||||||
|
exported: boolean;
|
||||||
|
filePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportUserDataResult {
|
||||||
|
backupPath?: string;
|
||||||
|
cancelled: boolean;
|
||||||
|
imported: boolean;
|
||||||
|
restartRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EraseUserDataResult {
|
||||||
|
erased: boolean;
|
||||||
|
restartRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ARCHIVE_MANIFEST_PATH = 'metoyou-data-manifest.json';
|
||||||
|
const ARCHIVE_DATA_PREFIX = 'data/';
|
||||||
|
const BACKUP_DIRECTORY_NAME = 'metoyou-data-backups';
|
||||||
|
|
||||||
|
export async function openCurrentDataFolder(): Promise<boolean> {
|
||||||
|
const error = await shell.openPath(app.getPath('userData'));
|
||||||
|
|
||||||
|
return error.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportUserData(): Promise<ExportUserDataResult> {
|
||||||
|
const dataPath = app.getPath('userData');
|
||||||
|
const defaultFileName = `metoyou-data-${new Date().toISOString().slice(0, 10)}.dat`;
|
||||||
|
const { canceled, filePath } = await dialog.showSaveDialog({
|
||||||
|
defaultPath: path.join(app.getPath('documents'), defaultFileName),
|
||||||
|
filters: [
|
||||||
|
{ extensions: ['dat'], name: 'MetoYou data archive' }
|
||||||
|
],
|
||||||
|
title: 'Export MetoYou data'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (canceled || !filePath) {
|
||||||
|
return { cancelled: true, exported: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries: ZipArchiveEntry[] = [
|
||||||
|
{
|
||||||
|
data: Buffer.from(JSON.stringify({
|
||||||
|
appVersion: app.getVersion(),
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
format: 'metoyou-user-data',
|
||||||
|
version: 1
|
||||||
|
}, null, 2)),
|
||||||
|
path: ARCHIVE_MANIFEST_PATH
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const file of await collectDataFiles(dataPath)) {
|
||||||
|
const relativePath = toArchivePath(path.relative(dataPath, file));
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
data: await fsp.readFile(file),
|
||||||
|
path: `${ARCHIVE_DATA_PREFIX}${relativePath}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsp.writeFile(ensureDatExtension(filePath), createZipArchive(entries));
|
||||||
|
|
||||||
|
return {
|
||||||
|
cancelled: false,
|
||||||
|
exported: true,
|
||||||
|
filePath: ensureDatExtension(filePath)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importUserData(): Promise<ImportUserDataResult> {
|
||||||
|
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||||
|
filters: [
|
||||||
|
{ extensions: ['dat', 'zip'], name: 'MetoYou data archive' }
|
||||||
|
],
|
||||||
|
properties: ['openFile'],
|
||||||
|
title: 'Import MetoYou data'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (canceled || filePaths.length === 0) {
|
||||||
|
return {
|
||||||
|
cancelled: true,
|
||||||
|
imported: false,
|
||||||
|
restartRequired: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const archiveEntries = readZipArchive(await fsp.readFile(filePaths[0]));
|
||||||
|
|
||||||
|
validateArchiveManifest(archiveEntries);
|
||||||
|
|
||||||
|
const importRoot = path.join(app.getPath('temp'), `metoyou-import-${Date.now()}`);
|
||||||
|
const importDataPath = path.join(importRoot, 'data');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await extractZipEntries(
|
||||||
|
archiveEntries
|
||||||
|
.filter((entry) => entry.path.startsWith(ARCHIVE_DATA_PREFIX))
|
||||||
|
.map((entry) => ({
|
||||||
|
data: entry.data,
|
||||||
|
path: entry.path.slice(ARCHIVE_DATA_PREFIX.length)
|
||||||
|
})),
|
||||||
|
importDataPath
|
||||||
|
);
|
||||||
|
|
||||||
|
await destroyDatabase();
|
||||||
|
|
||||||
|
const backupPath = await moveCurrentDataAside();
|
||||||
|
|
||||||
|
await copyDirectory(importDataPath, app.getPath('userData'));
|
||||||
|
await initializeDatabase();
|
||||||
|
|
||||||
|
return {
|
||||||
|
backupPath,
|
||||||
|
cancelled: false,
|
||||||
|
imported: true,
|
||||||
|
restartRequired: true
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await initializeDatabase().catch(() => {});
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await fsp.rm(importRoot, { force: true, recursive: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function eraseUserData(): Promise<EraseUserDataResult> {
|
||||||
|
const dataPath = app.getPath('userData');
|
||||||
|
|
||||||
|
await destroyDatabase();
|
||||||
|
|
||||||
|
for (const entry of await fsp.readdir(dataPath, { withFileTypes: true }).catch(() => [])) {
|
||||||
|
await fsp.rm(path.join(dataPath, entry.name), { force: true, recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsp.mkdir(dataPath, { recursive: true });
|
||||||
|
await initializeDatabase();
|
||||||
|
|
||||||
|
return {
|
||||||
|
erased: true,
|
||||||
|
restartRequired: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectDataFiles(directoryPath: string): Promise<string[]> {
|
||||||
|
const files: string[] = [];
|
||||||
|
const entries = await fsp.readdir(directoryPath, { withFileTypes: true }).catch(() => []);
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name === BACKUP_DIRECTORY_NAME) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryPath = path.join(directoryPath, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...await collectDataFiles(entryPath));
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
files.push(entryPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveCurrentDataAside(): Promise<string | undefined> {
|
||||||
|
const dataPath = app.getPath('userData');
|
||||||
|
const backupRoot = path.join(dataPath, BACKUP_DIRECTORY_NAME);
|
||||||
|
const backupPath = path.join(backupRoot, `before-import-${new Date().toISOString().replace(/[:.]/g, '-')}`);
|
||||||
|
const entries = await fsp.readdir(dataPath, { withFileTypes: true }).catch(() => []);
|
||||||
|
|
||||||
|
await fsp.mkdir(backupPath, { recursive: true });
|
||||||
|
|
||||||
|
let movedAny = false;
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name === BACKUP_DIRECTORY_NAME) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePath = path.join(dataPath, entry.name);
|
||||||
|
const targetPath = path.join(backupPath, entry.name);
|
||||||
|
|
||||||
|
await fsp.mkdir(path.dirname(targetPath), { recursive: true });
|
||||||
|
await fsp.rename(sourcePath, targetPath).catch(async () => {
|
||||||
|
await copyPath(sourcePath, targetPath);
|
||||||
|
await fsp.rm(sourcePath, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
movedAny = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return movedAny ? backupPath : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyDirectory(sourcePath: string, targetPath: string): Promise<void> {
|
||||||
|
await fsp.mkdir(targetPath, { recursive: true });
|
||||||
|
|
||||||
|
for (const entry of await fsp.readdir(sourcePath, { withFileTypes: true }).catch(() => [])) {
|
||||||
|
await copyPath(path.join(sourcePath, entry.name), path.join(targetPath, entry.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyPath(sourcePath: string, targetPath: string): Promise<void> {
|
||||||
|
const stats = await fsp.stat(sourcePath);
|
||||||
|
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
await copyDirectory(sourcePath, targetPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.isFile()) {
|
||||||
|
await fsp.mkdir(path.dirname(targetPath), { recursive: true });
|
||||||
|
await fsp.copyFile(sourcePath, targetPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateArchiveManifest(entries: ZipArchiveEntry[]): void {
|
||||||
|
const manifest = entries.find((entry) => entry.path === ARCHIVE_MANIFEST_PATH);
|
||||||
|
|
||||||
|
if (!manifest) {
|
||||||
|
throw new Error('The selected file is missing a MetoYou data manifest.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(manifest.data.toString('utf8')) as { format?: string; version?: number };
|
||||||
|
|
||||||
|
if (parsed.format !== 'metoyou-user-data' || parsed.version !== 1) {
|
||||||
|
throw new Error('The selected file uses an unsupported data archive format.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDatExtension(filePath: string): string {
|
||||||
|
return path.extname(filePath).toLowerCase() === '.dat'
|
||||||
|
? filePath
|
||||||
|
: `${filePath}.dat`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArchivePath(filePath: string): string {
|
||||||
|
return filePath.split(path.sep).join('/');
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|
||||||
@@ -26,6 +27,24 @@ let dbBackupPath = '';
|
|||||||
|
|
||||||
// SQLite files start with this 16-byte header string.
|
// SQLite files start with this 16-byte header string.
|
||||||
const SQLITE_MAGIC = 'SQLite format 3\0';
|
const SQLITE_MAGIC = 'SQLite format 3\0';
|
||||||
|
const SAVE_RETRY_DELAYS_MS = [25, 75, 150, 300, 600];
|
||||||
|
const RETRYABLE_SAVE_ERROR_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
||||||
|
|
||||||
|
let saveQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
function wait(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRetryableSaveError(error: unknown): boolean {
|
||||||
|
if (!error || typeof error !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
|
||||||
|
return typeof code === 'string' && RETRYABLE_SAVE_ERROR_CODES.has(code);
|
||||||
|
}
|
||||||
|
|
||||||
export function getDataSource(): DataSource | undefined {
|
export function getDataSource(): DataSource | undefined {
|
||||||
return applicationDataSource;
|
return applicationDataSource;
|
||||||
@@ -87,18 +106,47 @@ function safeguardDbFile(): Uint8Array | undefined {
|
|||||||
* then rename it over the real file. rename() is atomic on the same
|
* then rename it over the real file. rename() is atomic on the same
|
||||||
* filesystem, so a crash mid-write can never leave a half-written DB.
|
* filesystem, so a crash mid-write can never leave a half-written DB.
|
||||||
*/
|
*/
|
||||||
async function atomicSave(data: Uint8Array): Promise<void> {
|
async function replaceDatabaseFile(tmpPath: string): Promise<void> {
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try {
|
||||||
|
await fsp.rename(tmpPath, dbFilePath);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
const delay = SAVE_RETRY_DELAYS_MS[attempt];
|
||||||
|
|
||||||
|
if (!isRetryableSaveError(error) || delay === undefined) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await wait(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeDatabaseSnapshot(snapshot: Buffer): Promise<void> {
|
||||||
const tmpPath = dbFilePath + '.tmp-' + randomBytes(6).toString('hex');
|
const tmpPath = dbFilePath + '.tmp-' + randomBytes(6).toString('hex');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fsp.writeFile(tmpPath, Buffer.from(data));
|
await fsp.writeFile(tmpPath, snapshot);
|
||||||
await fsp.rename(tmpPath, dbFilePath);
|
await replaceDatabaseFile(tmpPath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await fsp.unlink(tmpPath).catch(() => {});
|
await fsp.unlink(tmpPath).catch(() => {});
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function atomicSave(data: Uint8Array): Promise<void> {
|
||||||
|
const snapshot = Buffer.from(data);
|
||||||
|
const saveTask = saveQueue.then(
|
||||||
|
() => writeDatabaseSnapshot(snapshot),
|
||||||
|
() => writeDatabaseSnapshot(snapshot)
|
||||||
|
);
|
||||||
|
|
||||||
|
saveQueue = saveTask.catch(() => {});
|
||||||
|
|
||||||
|
return saveTask;
|
||||||
|
}
|
||||||
|
|
||||||
export async function initializeDatabase(): Promise<void> {
|
export async function initializeDatabase(): Promise<void> {
|
||||||
const userDataPath = app.getPath('userData');
|
const userDataPath = app.getPath('userData');
|
||||||
const dbDir = path.join(userDataPath, 'metoyou');
|
const dbDir = path.join(userDataPath, 'metoyou');
|
||||||
@@ -124,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';
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ import {
|
|||||||
readSavedTheme,
|
readSavedTheme,
|
||||||
writeSavedTheme
|
writeSavedTheme
|
||||||
} from '../theme-library';
|
} from '../theme-library';
|
||||||
|
import { getLocalPluginsPath, listLocalPluginManifests } from '../plugin-library';
|
||||||
|
import {
|
||||||
|
eraseUserData,
|
||||||
|
exportUserData,
|
||||||
|
importUserData,
|
||||||
|
openCurrentDataFolder
|
||||||
|
} from '../data-management';
|
||||||
|
import { listRunningProcessNames } from '../process-list';
|
||||||
|
|
||||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||||
const FILE_CLIPBOARD_FORMATS = [
|
const FILE_CLIPBOARD_FORMATS = [
|
||||||
@@ -314,6 +322,8 @@ export function setupSystemHandlers(): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('get-running-process-names', async () => await listRunningProcessNames());
|
||||||
|
|
||||||
ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => {
|
ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => {
|
||||||
return await prepareLinuxScreenShareAudioRouting();
|
return await prepareLinuxScreenShareAudioRouting();
|
||||||
});
|
});
|
||||||
@@ -335,7 +345,13 @@ export function setupSystemHandlers(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
|
ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
|
||||||
|
ipcMain.handle('open-current-data-folder', async () => await openCurrentDataFolder());
|
||||||
|
ipcMain.handle('export-user-data', async () => await exportUserData());
|
||||||
|
ipcMain.handle('import-user-data', async () => await importUserData());
|
||||||
|
ipcMain.handle('erase-user-data', async () => await eraseUserData());
|
||||||
ipcMain.handle('get-saved-themes-path', async () => await getSavedThemesPath());
|
ipcMain.handle('get-saved-themes-path', async () => await getSavedThemesPath());
|
||||||
|
ipcMain.handle('get-local-plugins-path', async () => await getLocalPluginsPath());
|
||||||
|
ipcMain.handle('list-local-plugin-manifests', async () => await listLocalPluginManifests());
|
||||||
ipcMain.handle('list-saved-themes', async () => await listSavedThemes());
|
ipcMain.handle('list-saved-themes', async () => await listSavedThemes());
|
||||||
ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName));
|
ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName));
|
||||||
ipcMain.handle('write-saved-theme', async (_event, fileName: string, text: string) => {
|
ipcMain.handle('write-saved-theme', async (_event, fileName: string, text: string) => {
|
||||||
|
|||||||
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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
126
electron/plugin-library.spec.ts
Normal file
126
electron/plugin-library.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
import {
|
||||||
|
cp,
|
||||||
|
mkdtemp,
|
||||||
|
mkdir,
|
||||||
|
rm,
|
||||||
|
writeFile
|
||||||
|
} from 'fs/promises';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { TEST_PLUGIN_FIXTURE_DIR, TEST_PLUGIN_ID } from '../e2e/helpers/plugin-api-test-fixture';
|
||||||
|
|
||||||
|
const { mockGetPath } = vi.hoisted(() => ({
|
||||||
|
mockGetPath: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('electron', () => ({
|
||||||
|
app: {
|
||||||
|
getPath: mockGetPath
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { getLocalPluginsPath, listLocalPluginManifests } from './plugin-library';
|
||||||
|
|
||||||
|
describe('plugin-library', () => {
|
||||||
|
let userDataPath: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
userDataPath = await mkdtemp(join(tmpdir(), 'metoyou-plugin-library-'));
|
||||||
|
mockGetPath.mockReturnValue(userDataPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(userDataPath, { recursive: true, force: true });
|
||||||
|
mockGetPath.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates and reports the local plugins folder', async () => {
|
||||||
|
const pluginsPath = await getLocalPluginsPath();
|
||||||
|
const result = await listLocalPluginManifests();
|
||||||
|
|
||||||
|
expect(pluginsPath).toBe(join(userDataPath, 'plugins'));
|
||||||
|
expect(result).toEqual({
|
||||||
|
errors: [],
|
||||||
|
plugins: [],
|
||||||
|
pluginsPath
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discovers immediate child plugin manifests and safe relative files', async () => {
|
||||||
|
const pluginRoot = join(userDataPath, 'plugins', 'api-test-plugin');
|
||||||
|
|
||||||
|
await cp(TEST_PLUGIN_FIXTURE_DIR, pluginRoot, { recursive: true });
|
||||||
|
|
||||||
|
const result = await listLocalPluginManifests();
|
||||||
|
|
||||||
|
expect(result.errors).toEqual([]);
|
||||||
|
expect(result.plugins).toHaveLength(1);
|
||||||
|
expect(result.plugins[0]).toEqual(expect.objectContaining({
|
||||||
|
entrypointPath: join(pluginRoot, 'dist', 'main.js'),
|
||||||
|
manifestPath: join(pluginRoot, 'toju-plugin.json'),
|
||||||
|
pluginRoot,
|
||||||
|
readmePath: join(pluginRoot, 'README.md')
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result.plugins[0]?.manifest).toEqual(expect.objectContaining({ id: TEST_PLUGIN_ID }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports invalid JSON and keeps scanning other plugins', async () => {
|
||||||
|
const invalidRoot = join(userDataPath, 'plugins', 'invalid-plugin');
|
||||||
|
const validRoot = join(userDataPath, 'plugins', 'valid-plugin');
|
||||||
|
|
||||||
|
await mkdir(invalidRoot, { recursive: true });
|
||||||
|
await mkdir(validRoot, { recursive: true });
|
||||||
|
await writeFile(join(invalidRoot, 'plugin.json'), '{', 'utf8');
|
||||||
|
await writeFile(join(validRoot, 'plugin.json'), JSON.stringify({
|
||||||
|
apiVersion: '1.0.0',
|
||||||
|
compatibility: { minimumTojuVersion: '1.0.0' },
|
||||||
|
description: 'Valid plugin',
|
||||||
|
entrypoint: './main.js',
|
||||||
|
id: 'valid.plugin',
|
||||||
|
kind: 'client',
|
||||||
|
schemaVersion: 1,
|
||||||
|
title: 'Valid Plugin',
|
||||||
|
version: '1.0.0'
|
||||||
|
}), 'utf8');
|
||||||
|
|
||||||
|
const result = await listLocalPluginManifests();
|
||||||
|
|
||||||
|
expect(result.plugins.map((plugin) => plugin.pluginRoot)).toEqual([validRoot]);
|
||||||
|
expect(result.errors).toHaveLength(1);
|
||||||
|
expect(result.errors[0]).toEqual(expect.objectContaining({
|
||||||
|
manifestPath: join(invalidRoot, 'plugin.json'),
|
||||||
|
pluginRoot: invalidRoot
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not resolve entrypoints outside the plugin folder', async () => {
|
||||||
|
const pluginRoot = join(userDataPath, 'plugins', 'unsafe-plugin');
|
||||||
|
|
||||||
|
await mkdir(pluginRoot, { recursive: true });
|
||||||
|
await writeFile(join(userDataPath, 'plugins', 'outside.js'), 'export default {};', 'utf8');
|
||||||
|
await writeFile(join(pluginRoot, 'plugin.json'), JSON.stringify({
|
||||||
|
apiVersion: '1.0.0',
|
||||||
|
compatibility: { minimumTojuVersion: '1.0.0' },
|
||||||
|
description: 'Unsafe plugin',
|
||||||
|
entrypoint: '../outside.js',
|
||||||
|
id: 'unsafe.plugin',
|
||||||
|
kind: 'client',
|
||||||
|
schemaVersion: 1,
|
||||||
|
title: 'Unsafe Plugin',
|
||||||
|
version: '1.0.0'
|
||||||
|
}), 'utf8');
|
||||||
|
|
||||||
|
const result = await listLocalPluginManifests();
|
||||||
|
|
||||||
|
expect(result.plugins[0]?.entrypointPath).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
165
electron/plugin-library.ts
Normal file
165
electron/plugin-library.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { app } from 'electron';
|
||||||
|
import * as fsp from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { pathToFileURL } from 'url';
|
||||||
|
|
||||||
|
const PLUGINS_FOLDER_NAME = 'plugins';
|
||||||
|
const MANIFEST_FILE_NAMES = ['toju-plugin.json', 'plugin.json'] as const;
|
||||||
|
|
||||||
|
export interface LocalPluginManifestDescriptor {
|
||||||
|
discoveredAt: number;
|
||||||
|
entrypointPath?: string;
|
||||||
|
pluginRootUrl: string;
|
||||||
|
manifest: unknown;
|
||||||
|
manifestPath: string;
|
||||||
|
pluginRoot: string;
|
||||||
|
readmePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalPluginDiscoveryError {
|
||||||
|
manifestPath?: string;
|
||||||
|
message: string;
|
||||||
|
pluginRoot?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalPluginDiscoveryResult {
|
||||||
|
errors: LocalPluginDiscoveryError[];
|
||||||
|
plugins: LocalPluginManifestDescriptor[];
|
||||||
|
pluginsPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePluginsPath(): string {
|
||||||
|
return path.join(app.getPath('userData'), PLUGINS_FOLDER_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePluginsPath(): Promise<string> {
|
||||||
|
const pluginsPath = resolvePluginsPath();
|
||||||
|
|
||||||
|
await fsp.mkdir(pluginsPath, { recursive: true });
|
||||||
|
|
||||||
|
return pluginsPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function realpathOrSelf(filePath: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await fsp.realpath(filePath);
|
||||||
|
} catch {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathInside(parentPath: string, candidatePath: string): boolean {
|
||||||
|
const relativePath = path.relative(parentPath, candidatePath);
|
||||||
|
|
||||||
|
return !!relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readManifestPath(manifestRecord: Record<string, unknown>, key: string): string | undefined {
|
||||||
|
const value = manifestRecord[key];
|
||||||
|
|
||||||
|
return typeof value === 'string' && value.trim()
|
||||||
|
? value.trim()
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveManifestRelativeFile(pluginRoot: string, relativeFilePath: string | undefined): Promise<string | undefined> {
|
||||||
|
if (!relativeFilePath || path.isAbsolute(relativeFilePath)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPath = path.normalize(relativeFilePath);
|
||||||
|
|
||||||
|
if (normalizedPath.startsWith('..')) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidatePath = path.join(pluginRoot, normalizedPath);
|
||||||
|
const [realPluginRoot, realCandidatePath] = await Promise.all([realpathOrSelf(pluginRoot), realpathOrSelf(candidatePath)]);
|
||||||
|
|
||||||
|
if (!isPathInside(realPluginRoot, realCandidatePath)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = await fsp.stat(realCandidatePath);
|
||||||
|
|
||||||
|
return stats.isFile() ? realCandidatePath : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findManifestPath(pluginRoot: string): Promise<string | undefined> {
|
||||||
|
for (const fileName of MANIFEST_FILE_NAMES) {
|
||||||
|
const manifestPath = path.join(pluginRoot, fileName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = await fsp.stat(manifestPath);
|
||||||
|
|
||||||
|
if (stats.isFile()) {
|
||||||
|
return manifestPath;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Missing manifest candidates are expected while scanning folders.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPluginManifest(pluginRoot: string, manifestPath: string): Promise<LocalPluginManifestDescriptor> {
|
||||||
|
const text = await fsp.readFile(manifestPath, 'utf8');
|
||||||
|
const manifest = JSON.parse(text) as unknown;
|
||||||
|
const manifestRecord = manifest && typeof manifest === 'object' && !Array.isArray(manifest)
|
||||||
|
? manifest as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
const entrypointPromise = resolveManifestRelativeFile(pluginRoot, readManifestPath(manifestRecord, 'entrypoint'));
|
||||||
|
const readmePromise = resolveManifestRelativeFile(pluginRoot, readManifestPath(manifestRecord, 'readme'));
|
||||||
|
const [entrypointPath, readmePath] = await Promise.all([entrypointPromise, readmePromise]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
discoveredAt: Date.now(),
|
||||||
|
entrypointPath,
|
||||||
|
pluginRootUrl: pathToFileURL(pluginRoot + path.sep).toString(),
|
||||||
|
manifest,
|
||||||
|
manifestPath,
|
||||||
|
pluginRoot,
|
||||||
|
readmePath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLocalPluginsPath(): Promise<string> {
|
||||||
|
return await ensurePluginsPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listLocalPluginManifests(): Promise<LocalPluginDiscoveryResult> {
|
||||||
|
const pluginsPath = await ensurePluginsPath();
|
||||||
|
const entries = await fsp.readdir(pluginsPath, { withFileTypes: true });
|
||||||
|
const plugins: LocalPluginManifestDescriptor[] = [];
|
||||||
|
const errors: LocalPluginDiscoveryError[] = [];
|
||||||
|
|
||||||
|
for (const entry of entries.filter((candidate) => candidate.isDirectory())) {
|
||||||
|
const pluginRoot = path.join(pluginsPath, entry.name);
|
||||||
|
const manifestPath = await findManifestPath(pluginRoot);
|
||||||
|
|
||||||
|
if (!manifestPath) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
plugins.push(await readPluginManifest(pluginRoot, manifestPath));
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({
|
||||||
|
manifestPath,
|
||||||
|
message: error instanceof Error ? error.message : 'Unable to read plugin manifest',
|
||||||
|
pluginRoot
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
errors,
|
||||||
|
plugins: plugins.sort((left, right) => left.pluginRoot.localeCompare(right.pluginRoot)),
|
||||||
|
pluginsPath
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -109,6 +109,46 @@ export interface SavedThemeFileDescriptor {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LocalPluginManifestDescriptor {
|
||||||
|
discoveredAt: number;
|
||||||
|
entrypointPath?: string;
|
||||||
|
pluginRootUrl: string;
|
||||||
|
manifest: unknown;
|
||||||
|
manifestPath: string;
|
||||||
|
pluginRoot: string;
|
||||||
|
readmePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalPluginDiscoveryError {
|
||||||
|
manifestPath?: string;
|
||||||
|
message: string;
|
||||||
|
pluginRoot?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalPluginDiscoveryResult {
|
||||||
|
errors: LocalPluginDiscoveryError[];
|
||||||
|
plugins: LocalPluginManifestDescriptor[];
|
||||||
|
pluginsPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportUserDataResult {
|
||||||
|
cancelled: boolean;
|
||||||
|
exported: boolean;
|
||||||
|
filePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportUserDataResult {
|
||||||
|
backupPath?: string;
|
||||||
|
cancelled: boolean;
|
||||||
|
imported: boolean;
|
||||||
|
restartRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EraseUserDataResult {
|
||||||
|
erased: boolean;
|
||||||
|
restartRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
function readLinuxDisplayServer(): string {
|
function readLinuxDisplayServer(): string {
|
||||||
if (process.platform !== 'linux') {
|
if (process.platform !== 'linux') {
|
||||||
return 'N/A';
|
return 'N/A';
|
||||||
@@ -149,6 +189,7 @@ export interface ElectronAPI {
|
|||||||
|
|
||||||
openExternal: (url: string) => Promise<boolean>;
|
openExternal: (url: string) => Promise<boolean>;
|
||||||
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
||||||
|
getRunningProcessNames: () => Promise<string[]>;
|
||||||
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||||
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||||
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
|
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
|
||||||
@@ -157,7 +198,13 @@ export interface ElectronAPI {
|
|||||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||||
getAppDataPath: () => Promise<string>;
|
getAppDataPath: () => Promise<string>;
|
||||||
|
openCurrentDataFolder: () => Promise<boolean>;
|
||||||
|
exportUserData: () => Promise<ExportUserDataResult>;
|
||||||
|
importUserData: () => Promise<ImportUserDataResult>;
|
||||||
|
eraseUserData: () => Promise<EraseUserDataResult>;
|
||||||
getSavedThemesPath: () => Promise<string>;
|
getSavedThemesPath: () => Promise<string>;
|
||||||
|
getLocalPluginsPath: () => Promise<string>;
|
||||||
|
listLocalPluginManifests: () => Promise<LocalPluginDiscoveryResult>;
|
||||||
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
||||||
readSavedTheme: (fileName: string) => Promise<string>;
|
readSavedTheme: (fileName: string) => Promise<string>;
|
||||||
writeSavedTheme: (fileName: string, text: string) => Promise<boolean>;
|
writeSavedTheme: (fileName: string, text: string) => Promise<boolean>;
|
||||||
@@ -230,6 +277,7 @@ const electronAPI: ElectronAPI = {
|
|||||||
|
|
||||||
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
||||||
getSources: () => ipcRenderer.invoke('get-sources'),
|
getSources: () => ipcRenderer.invoke('get-sources'),
|
||||||
|
getRunningProcessNames: () => ipcRenderer.invoke('get-running-process-names'),
|
||||||
prepareLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('prepare-linux-screen-share-audio-routing'),
|
prepareLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('prepare-linux-screen-share-audio-routing'),
|
||||||
activateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('activate-linux-screen-share-audio-routing'),
|
activateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('activate-linux-screen-share-audio-routing'),
|
||||||
deactivateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('deactivate-linux-screen-share-audio-routing'),
|
deactivateLinuxScreenShareAudioRouting: () => ipcRenderer.invoke('deactivate-linux-screen-share-audio-routing'),
|
||||||
@@ -265,7 +313,13 @@ const electronAPI: ElectronAPI = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||||
|
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
|
||||||
|
exportUserData: () => ipcRenderer.invoke('export-user-data'),
|
||||||
|
importUserData: () => ipcRenderer.invoke('import-user-data'),
|
||||||
|
eraseUserData: () => ipcRenderer.invoke('erase-user-data'),
|
||||||
getSavedThemesPath: () => ipcRenderer.invoke('get-saved-themes-path'),
|
getSavedThemesPath: () => ipcRenderer.invoke('get-saved-themes-path'),
|
||||||
|
getLocalPluginsPath: () => ipcRenderer.invoke('get-local-plugins-path'),
|
||||||
|
listLocalPluginManifests: () => ipcRenderer.invoke('list-local-plugin-manifests'),
|
||||||
listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'),
|
listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'),
|
||||||
readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName),
|
readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName),
|
||||||
writeSavedTheme: (fileName, text) => ipcRenderer.invoke('write-saved-theme', fileName, text),
|
writeSavedTheme: (fileName, text) => ipcRenderer.invoke('write-saved-theme', fileName, text),
|
||||||
|
|||||||
85
electron/process-list.ts
Normal file
85
electron/process-list.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { execFile } from 'child_process';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const MAX_PROCESS_NAMES = 512;
|
||||||
|
|
||||||
|
export async function listRunningProcessNames(): Promise<string[]> {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return normalizeProcessNames(await listWindowsProcessNames());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
return normalizeProcessNames(await listLinuxProcessNames());
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listLinuxProcessNames(): Promise<string[]> {
|
||||||
|
const { stdout } = await execFileAsync('ps', ['-eo', 'comm='], {
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
timeout: 5_000
|
||||||
|
});
|
||||||
|
|
||||||
|
return stdout.split('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listWindowsProcessNames(): Promise<string[]> {
|
||||||
|
const { stdout } = await execFileAsync('tasklist', [
|
||||||
|
'/FO',
|
||||||
|
'CSV',
|
||||||
|
'/NH'
|
||||||
|
], {
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
timeout: 5_000,
|
||||||
|
windowsHide: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return stdout
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => parseCsvFirstColumn(line));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCsvFirstColumn(line: string): string {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmed.startsWith('"')) {
|
||||||
|
return trimmed.split(',')[0] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const endQuoteIndex = trimmed.indexOf('"', 1);
|
||||||
|
|
||||||
|
return endQuoteIndex > 1 ? trimmed.slice(1, endQuoteIndex) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProcessNames(names: string[]): string[] {
|
||||||
|
const normalized = new Set<string>();
|
||||||
|
|
||||||
|
for (const rawName of names) {
|
||||||
|
const name = normalizeProcessName(rawName);
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
normalized.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(normalized)
|
||||||
|
.sort()
|
||||||
|
.slice(0, MAX_PROCESS_NAMES);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProcessName(rawName: string): string {
|
||||||
|
const baseName = path.basename(rawName.trim()).trim();
|
||||||
|
|
||||||
|
if (!baseName || baseName.length > 96) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseName;
|
||||||
|
}
|
||||||
26
package-lock.json
generated
26
package-lock.json
generated
@@ -15,8 +15,10 @@
|
|||||||
"@angular/platform-browser": "^21.0.0",
|
"@angular/platform-browser": "^21.0.0",
|
||||||
"@angular/router": "^21.0.0",
|
"@angular/router": "^21.0.0",
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-json": "^6.0.2",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
"@codemirror/language": "^6.12.3",
|
"@codemirror/language": "^6.12.3",
|
||||||
|
"@codemirror/lint": "^6.9.5",
|
||||||
"@codemirror/state": "^6.6.0",
|
"@codemirror/state": "^6.6.0",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.41.0",
|
"@codemirror/view": "^6.41.0",
|
||||||
@@ -2731,6 +2733,19 @@
|
|||||||
"@lezer/common": "^1.1.0"
|
"@lezer/common": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@codemirror/lang-css": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@lezer/common": "^1.0.2",
|
||||||
|
"@lezer/css": "^1.1.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@codemirror/lang-json": {
|
"node_modules/@codemirror/lang-json": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
||||||
@@ -5791,6 +5806,17 @@
|
|||||||
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
|
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@lezer/css": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.2.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@lezer/highlight": {
|
"node_modules/@lezer/highlight": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||||
|
|||||||
@@ -65,8 +65,10 @@
|
|||||||
"@angular/platform-browser": "^21.0.0",
|
"@angular/platform-browser": "^21.0.0",
|
||||||
"@angular/router": "^21.0.0",
|
"@angular/router": "^21.0.0",
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-json": "^6.0.2",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
"@codemirror/language": "^6.12.3",
|
"@codemirror/language": "^6.12.3",
|
||||||
|
"@codemirror/lint": "^6.9.5",
|
||||||
"@codemirror/state": "^6.6.0",
|
"@codemirror/state": "^6.6.0",
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
"@codemirror/view": "^6.41.0",
|
"@codemirror/view": "^6.41.0",
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ Node/TypeScript signaling server for MetoYou / Toju. This package owns the publi
|
|||||||
- The server loads the repository-root `.env` file on startup.
|
- The server loads the repository-root `.env` file on startup.
|
||||||
- `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`, `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. Plugin support is metadata-only: the server stores install requirements and event definitions, but arbitrary plugin data persistence is disabled.
|
||||||
|
- `RAWG_API_KEY` can override `rawgApiKey` for the `/api/games/match` now-playing metadata resolver. Successful matches include a preferred store link from RAWG store metadata, with Steam selected first when available. Negative game-match results are stored in the SQLite `game_match_misses` table so non-game process names do not repeatedly consume RAWG quota.
|
||||||
- Packaged server builds store `metoyou.sqlite` in the OS app-data directory by default so upgrades do not overwrite runtime data. On first start, the server copies forward legacy packaged databases that still live beside the executable.
|
- 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.
@@ -10,13 +10,19 @@ export interface LinkPreviewConfig {
|
|||||||
maxCacheSizeMb: number;
|
maxCacheSizeMb: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OpenApiDocsConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerVariablesConfig {
|
export interface ServerVariablesConfig {
|
||||||
klipyApiKey: string;
|
klipyApiKey: string;
|
||||||
|
rawgApiKey: string;
|
||||||
releaseManifestUrl: string;
|
releaseManifestUrl: string;
|
||||||
serverPort: number;
|
serverPort: number;
|
||||||
serverProtocol: ServerHttpProtocol;
|
serverProtocol: ServerHttpProtocol;
|
||||||
serverHost: string;
|
serverHost: string;
|
||||||
linkPreview: LinkPreviewConfig;
|
linkPreview: LinkPreviewConfig;
|
||||||
|
openApiDocs: OpenApiDocsConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DATA_DIR = resolveRuntimePath('data');
|
const DATA_DIR = resolveRuntimePath('data');
|
||||||
@@ -31,6 +37,10 @@ function normalizeKlipyApiKey(value: unknown): string {
|
|||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRawgApiKey(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeReleaseManifestUrl(value: unknown): string {
|
function normalizeReleaseManifestUrl(value: unknown): string {
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
}
|
}
|
||||||
@@ -97,6 +107,14 @@ function normalizeLinkPreviewConfig(value: unknown): LinkPreviewConfig {
|
|||||||
return { enabled, cacheTtlMinutes: cacheTtl, maxCacheSizeMb: maxSize };
|
return { enabled, cacheTtlMinutes: cacheTtl, maxCacheSizeMb: maxSize };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeOpenApiDocsConfig(value: unknown): OpenApiDocsConfig {
|
||||||
|
const raw = (value && typeof value === 'object' && !Array.isArray(value))
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return { enabled: raw.enabled === true };
|
||||||
|
}
|
||||||
|
|
||||||
function hasEnvironmentOverride(value: string | undefined): value is string {
|
function hasEnvironmentOverride(value: string | undefined): value is string {
|
||||||
return typeof value === 'string' && value.trim().length > 0;
|
return typeof value === 'string' && value.trim().length > 0;
|
||||||
}
|
}
|
||||||
@@ -139,11 +157,13 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
const normalized = {
|
const normalized = {
|
||||||
...remainingParsed,
|
...remainingParsed,
|
||||||
klipyApiKey: normalizeKlipyApiKey(remainingParsed.klipyApiKey),
|
klipyApiKey: normalizeKlipyApiKey(remainingParsed.klipyApiKey),
|
||||||
|
rawgApiKey: normalizeRawgApiKey(remainingParsed.rawgApiKey),
|
||||||
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
|
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
|
||||||
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
||||||
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
||||||
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress),
|
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress),
|
||||||
linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview)
|
linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview),
|
||||||
|
openApiDocs: normalizeOpenApiDocsConfig(remainingParsed.openApiDocs)
|
||||||
};
|
};
|
||||||
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
|
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
|
||||||
|
|
||||||
@@ -153,11 +173,13 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
klipyApiKey: normalized.klipyApiKey,
|
klipyApiKey: normalized.klipyApiKey,
|
||||||
|
rawgApiKey: normalized.rawgApiKey,
|
||||||
releaseManifestUrl: normalized.releaseManifestUrl,
|
releaseManifestUrl: normalized.releaseManifestUrl,
|
||||||
serverPort: normalized.serverPort,
|
serverPort: normalized.serverPort,
|
||||||
serverProtocol: normalized.serverProtocol,
|
serverProtocol: normalized.serverProtocol,
|
||||||
serverHost: normalized.serverHost,
|
serverHost: normalized.serverHost,
|
||||||
linkPreview: normalized.linkPreview
|
linkPreview: normalized.linkPreview,
|
||||||
|
openApiDocs: normalized.openApiDocs
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +191,14 @@ export function getKlipyApiKey(): string {
|
|||||||
return getVariablesConfig().klipyApiKey;
|
return getVariablesConfig().klipyApiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRawgApiKey(): string {
|
||||||
|
if (hasEnvironmentOverride(process.env.RAWG_API_KEY)) {
|
||||||
|
return process.env.RAWG_API_KEY.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return getVariablesConfig().rawgApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
export function hasKlipyApiKey(): boolean {
|
export function hasKlipyApiKey(): boolean {
|
||||||
return getKlipyApiKey().length > 0;
|
return getKlipyApiKey().length > 0;
|
||||||
}
|
}
|
||||||
@@ -203,6 +233,31 @@ export function isHttpsServerEnabled(): boolean {
|
|||||||
return getServerProtocol() === 'https';
|
return getServerProtocol() === 'https';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function areOpenApiDocsEnabled(): boolean {
|
||||||
|
if (hasEnvironmentOverride(process.env.OPENAPI_DOCS_ENABLED)) {
|
||||||
|
return process.env.OPENAPI_DOCS_ENABLED.trim().toLowerCase() === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
return getVariablesConfig().openApiDocs.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setOpenApiDocsEnabled(enabled: boolean): OpenApiDocsConfig {
|
||||||
|
if (!fs.existsSync(DATA_DIR)) {
|
||||||
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { parsed } = readRawVariables();
|
||||||
|
const next = {
|
||||||
|
...parsed,
|
||||||
|
openApiDocs: { enabled }
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(VARIABLES_FILE, JSON.stringify(next, null, 2) + '\n', 'utf8');
|
||||||
|
ensureVariablesConfig();
|
||||||
|
|
||||||
|
return { enabled };
|
||||||
|
}
|
||||||
|
|
||||||
export function getLinkPreviewConfig(): LinkPreviewConfig {
|
export function getLinkPreviewConfig(): LinkPreviewConfig {
|
||||||
return getVariablesConfig().linkPreview;
|
return getVariablesConfig().linkPreview;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ import {
|
|||||||
JoinRequestEntity,
|
JoinRequestEntity,
|
||||||
ServerMembershipEntity,
|
ServerMembershipEntity,
|
||||||
ServerInviteEntity,
|
ServerInviteEntity,
|
||||||
ServerBanEntity
|
ServerBanEntity,
|
||||||
|
GameMatchMissEntity,
|
||||||
|
ServerPluginRequirementEntity,
|
||||||
|
ServerPluginEventDefinitionEntity,
|
||||||
|
PluginDataEntity,
|
||||||
|
ServerPluginSettingsEntity,
|
||||||
|
PluginUserMetadataEntity
|
||||||
} from '../entities';
|
} from '../entities';
|
||||||
import { serverMigrations } from '../migrations';
|
import { serverMigrations } from '../migrations';
|
||||||
import {
|
import {
|
||||||
@@ -48,8 +54,35 @@ const DB_BACKUP = DB_FILE + '.bak';
|
|||||||
const DATA_DIR = path.dirname(DB_FILE);
|
const DATA_DIR = path.dirname(DB_FILE);
|
||||||
// SQLite files start with this 16-byte header string.
|
// SQLite files start with this 16-byte header string.
|
||||||
const SQLITE_MAGIC = 'SQLite format 3\0';
|
const SQLITE_MAGIC = 'SQLite format 3\0';
|
||||||
|
const SAVE_RETRY_DELAYS_MS = [
|
||||||
|
25,
|
||||||
|
75,
|
||||||
|
150,
|
||||||
|
300,
|
||||||
|
600
|
||||||
|
];
|
||||||
|
const RETRYABLE_SAVE_ERROR_CODES = new Set([
|
||||||
|
'EPERM',
|
||||||
|
'EACCES',
|
||||||
|
'EBUSY'
|
||||||
|
]);
|
||||||
|
|
||||||
let applicationDataSource: DataSource | undefined;
|
let applicationDataSource: DataSource | undefined;
|
||||||
|
let saveQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
function wait(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRetryableSaveError(error: unknown): boolean {
|
||||||
|
if (!error || typeof error !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
|
||||||
|
return typeof code === 'string' && RETRYABLE_SAVE_ERROR_CODES.has(code);
|
||||||
|
}
|
||||||
|
|
||||||
function restoreFromBackup(reason: string): Uint8Array | undefined {
|
function restoreFromBackup(reason: string): Uint8Array | undefined {
|
||||||
if (!fs.existsSync(DB_BACKUP)) {
|
if (!fs.existsSync(DB_BACKUP)) {
|
||||||
@@ -159,18 +192,47 @@ function resolveSqlJsConfig(): { locateFile: (file: string) => string } {
|
|||||||
* then rename it over the real file. rename() is atomic on the same
|
* then rename it over the real file. rename() is atomic on the same
|
||||||
* filesystem, so a crash mid-write can never leave a half-written DB.
|
* filesystem, so a crash mid-write can never leave a half-written DB.
|
||||||
*/
|
*/
|
||||||
async function atomicSave(data: Uint8Array): Promise<void> {
|
async function replaceDatabaseFile(tmpPath: string): Promise<void> {
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try {
|
||||||
|
await fsp.rename(tmpPath, DB_FILE);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
const delay = SAVE_RETRY_DELAYS_MS[attempt];
|
||||||
|
|
||||||
|
if (!isRetryableSaveError(error) || delay === undefined) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await wait(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeDatabaseSnapshot(snapshot: Buffer): Promise<void> {
|
||||||
const tmpPath = DB_FILE + '.tmp-' + randomBytes(6).toString('hex');
|
const tmpPath = DB_FILE + '.tmp-' + randomBytes(6).toString('hex');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fsp.writeFile(tmpPath, Buffer.from(data));
|
await fsp.writeFile(tmpPath, snapshot);
|
||||||
await fsp.rename(tmpPath, DB_FILE);
|
await replaceDatabaseFile(tmpPath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await fsp.unlink(tmpPath).catch(() => {});
|
await fsp.unlink(tmpPath).catch(() => {});
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function atomicSave(data: Uint8Array): Promise<void> {
|
||||||
|
const snapshot = Buffer.from(data);
|
||||||
|
const saveTask = saveQueue.then(
|
||||||
|
() => writeDatabaseSnapshot(snapshot),
|
||||||
|
() => writeDatabaseSnapshot(snapshot)
|
||||||
|
);
|
||||||
|
|
||||||
|
saveQueue = saveTask.catch(() => {});
|
||||||
|
|
||||||
|
return saveTask;
|
||||||
|
}
|
||||||
|
|
||||||
export function getDataSource(): DataSource {
|
export function getDataSource(): DataSource {
|
||||||
if (!applicationDataSource?.isInitialized) {
|
if (!applicationDataSource?.isInitialized) {
|
||||||
throw new Error('DataSource not initialised');
|
throw new Error('DataSource not initialised');
|
||||||
@@ -202,7 +264,13 @@ export async function initDatabase(): Promise<void> {
|
|||||||
JoinRequestEntity,
|
JoinRequestEntity,
|
||||||
ServerMembershipEntity,
|
ServerMembershipEntity,
|
||||||
ServerInviteEntity,
|
ServerInviteEntity,
|
||||||
ServerBanEntity
|
ServerBanEntity,
|
||||||
|
GameMatchMissEntity,
|
||||||
|
ServerPluginRequirementEntity,
|
||||||
|
ServerPluginEventDefinitionEntity,
|
||||||
|
PluginDataEntity,
|
||||||
|
ServerPluginSettingsEntity,
|
||||||
|
PluginUserMetadataEntity
|
||||||
],
|
],
|
||||||
migrations: serverMigrations,
|
migrations: serverMigrations,
|
||||||
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
||||||
|
|||||||
22
server/src/entities/GameMatchMissEntity.ts
Normal file
22
server/src/entities/GameMatchMissEntity.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
PrimaryColumn
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('game_match_misses')
|
||||||
|
export class GameMatchMissEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
processKey!: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
processName!: string;
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
missedAt!: number;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column('integer')
|
||||||
|
expiresAt!: number;
|
||||||
|
}
|
||||||
35
server/src/entities/PluginDataEntity.ts
Normal file
35
server/src/entities/PluginDataEntity.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('plugin_data')
|
||||||
|
export class PluginDataEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
serverId!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
pluginId!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
scope!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
ownerId!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
key!: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
valueJson!: string;
|
||||||
|
|
||||||
|
@Column('integer', { default: 1 })
|
||||||
|
schemaVersion!: number;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
updatedBy!: string | null;
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
updatedAt!: number;
|
||||||
|
}
|
||||||
38
server/src/entities/PluginUserMetadataEntity.ts
Normal file
38
server/src/entities/PluginUserMetadataEntity.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('plugin_user_metadata')
|
||||||
|
export class PluginUserMetadataEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
serverId!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
pluginId!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
pluginUserId!: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
displayName!: string;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
avatarHash!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
avatarMime!: string | null;
|
||||||
|
|
||||||
|
@Column('integer', { nullable: true })
|
||||||
|
avatarUpdatedAt!: number | null;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
roleIdsJson!: string;
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
createdAt!: number;
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
updatedAt!: number;
|
||||||
|
}
|
||||||
41
server/src/entities/ServerPluginEventDefinitionEntity.ts
Normal file
41
server/src/entities/ServerPluginEventDefinitionEntity.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type ServerPluginEventDirection = 'clientToServer' | 'serverRelay' | 'p2pHint';
|
||||||
|
export type ServerPluginEventScope = 'server' | 'channel' | 'user' | 'plugin';
|
||||||
|
|
||||||
|
@Entity('server_plugin_event_definitions')
|
||||||
|
export class ServerPluginEventDefinitionEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
serverId!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
pluginId!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
eventName!: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
direction!: ServerPluginEventDirection;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
scope!: ServerPluginEventScope;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
schemaJson!: string | null;
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
maxPayloadBytes!: number;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
rateLimitJson!: string | null;
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
createdAt!: number;
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
updatedAt!: number;
|
||||||
|
}
|
||||||
45
server/src/entities/ServerPluginRequirementEntity.ts
Normal file
45
server/src/entities/ServerPluginRequirementEntity.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
PrimaryColumn
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export type ServerPluginRequirementStatus = 'required' | 'optional' | 'recommended' | 'blocked' | 'incompatible';
|
||||||
|
|
||||||
|
@Entity('server_plugin_requirements')
|
||||||
|
export class ServerPluginRequirementEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
serverId!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
pluginId!: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column('text')
|
||||||
|
status!: ServerPluginRequirementStatus;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
versionRange!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
reason!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
installUrl!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
sourceUrl!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
manifestJson!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
configuredBy!: string | null;
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
createdAt!: number;
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
updatedAt!: number;
|
||||||
|
}
|
||||||
26
server/src/entities/ServerPluginSettingsEntity.ts
Normal file
26
server/src/entities/ServerPluginSettingsEntity.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('server_plugin_settings')
|
||||||
|
export class ServerPluginSettingsEntity {
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
serverId!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn('text')
|
||||||
|
pluginId!: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
settingsJson!: string;
|
||||||
|
|
||||||
|
@Column('integer', { default: 1 })
|
||||||
|
schemaVersion!: number;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
updatedBy!: string | null;
|
||||||
|
|
||||||
|
@Column('integer')
|
||||||
|
updatedAt!: number;
|
||||||
|
}
|
||||||
@@ -9,3 +9,11 @@ export { JoinRequestEntity } from './JoinRequestEntity';
|
|||||||
export { ServerMembershipEntity } from './ServerMembershipEntity';
|
export { ServerMembershipEntity } from './ServerMembershipEntity';
|
||||||
export { ServerInviteEntity } from './ServerInviteEntity';
|
export { ServerInviteEntity } from './ServerInviteEntity';
|
||||||
export { ServerBanEntity } from './ServerBanEntity';
|
export { ServerBanEntity } from './ServerBanEntity';
|
||||||
|
export { GameMatchMissEntity } from './GameMatchMissEntity';
|
||||||
|
export { ServerPluginRequirementEntity } from './ServerPluginRequirementEntity';
|
||||||
|
export type { ServerPluginRequirementStatus } from './ServerPluginRequirementEntity';
|
||||||
|
export { ServerPluginEventDefinitionEntity } from './ServerPluginEventDefinitionEntity';
|
||||||
|
export type { ServerPluginEventDirection, ServerPluginEventScope } from './ServerPluginEventDefinitionEntity';
|
||||||
|
export { PluginDataEntity } from './PluginDataEntity';
|
||||||
|
export { ServerPluginSettingsEntity } from './ServerPluginSettingsEntity';
|
||||||
|
export { PluginUserMetadataEntity } from './PluginUserMetadataEntity';
|
||||||
|
|||||||
24
server/src/migrations/1000000000006-GameMatchMisses.ts
Normal file
24
server/src/migrations/1000000000006-GameMatchMisses.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class GameMatchMisses1000000000006 implements MigrationInterface {
|
||||||
|
name = 'GameMatchMisses1000000000006';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "game_match_misses" (
|
||||||
|
"processKey" TEXT PRIMARY KEY NOT NULL,
|
||||||
|
"processName" TEXT NOT NULL,
|
||||||
|
"missedAt" INTEGER NOT NULL,
|
||||||
|
"expiresAt" INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_game_match_misses_expiresAt"
|
||||||
|
ON "game_match_misses" ("expiresAt")
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "game_match_misses"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
92
server/src/migrations/1000000000007-PluginSupport.ts
Normal file
92
server/src/migrations/1000000000007-PluginSupport.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class PluginSupport1000000000007 implements MigrationInterface {
|
||||||
|
name = 'PluginSupport1000000000007';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "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(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_server_plugin_requirements_status"
|
||||||
|
ON "server_plugin_requirements" ("status")
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "server_plugin_event_definitions" (
|
||||||
|
"serverId" TEXT NOT NULL,
|
||||||
|
"pluginId" TEXT NOT NULL,
|
||||||
|
"eventName" TEXT NOT NULL,
|
||||||
|
"direction" TEXT NOT NULL,
|
||||||
|
"scope" TEXT NOT NULL,
|
||||||
|
"schemaJson" TEXT,
|
||||||
|
"maxPayloadBytes" INTEGER NOT NULL,
|
||||||
|
"rateLimitJson" TEXT,
|
||||||
|
"createdAt" INTEGER NOT NULL,
|
||||||
|
"updatedAt" INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY ("serverId", "pluginId", "eventName")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "plugin_data" (
|
||||||
|
"serverId" TEXT NOT NULL,
|
||||||
|
"pluginId" TEXT NOT NULL,
|
||||||
|
"scope" TEXT NOT NULL,
|
||||||
|
"ownerId" TEXT NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"valueJson" TEXT NOT NULL,
|
||||||
|
"schemaVersion" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"updatedBy" TEXT,
|
||||||
|
"updatedAt" INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY ("serverId", "pluginId", "scope", "ownerId", "key")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "server_plugin_settings" (
|
||||||
|
"serverId" TEXT NOT NULL,
|
||||||
|
"pluginId" TEXT NOT NULL,
|
||||||
|
"settingsJson" TEXT NOT NULL,
|
||||||
|
"schemaVersion" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"updatedBy" TEXT,
|
||||||
|
"updatedAt" INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY ("serverId", "pluginId")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "plugin_user_metadata" (
|
||||||
|
"serverId" TEXT NOT NULL,
|
||||||
|
"pluginId" TEXT NOT NULL,
|
||||||
|
"pluginUserId" TEXT NOT NULL,
|
||||||
|
"displayName" TEXT NOT NULL,
|
||||||
|
"avatarHash" TEXT,
|
||||||
|
"avatarMime" TEXT,
|
||||||
|
"avatarUpdatedAt" INTEGER,
|
||||||
|
"roleIdsJson" TEXT NOT NULL,
|
||||||
|
"createdAt" INTEGER NOT NULL,
|
||||||
|
"updatedAt" INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY ("serverId", "pluginId", "pluginUserId")
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "plugin_user_metadata"`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "server_plugin_settings"`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "plugin_data"`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "server_plugin_event_definitions"`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS "server_plugin_requirements"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ import { ServerChannels1000000000002 } from './1000000000002-ServerChannels';
|
|||||||
import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels';
|
import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels';
|
||||||
import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays';
|
import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays';
|
||||||
import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl';
|
import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl';
|
||||||
|
import { GameMatchMisses1000000000006 } from './1000000000006-GameMatchMisses';
|
||||||
|
import { PluginSupport1000000000007 } from './1000000000007-PluginSupport';
|
||||||
|
import { ServerPluginInstallMetadata1000000000008 } from './1000000000008-ServerPluginInstallMetadata';
|
||||||
|
|
||||||
export const serverMigrations = [
|
export const serverMigrations = [
|
||||||
InitialSchema1000000000000,
|
InitialSchema1000000000000,
|
||||||
@@ -11,5 +14,8 @@ export const serverMigrations = [
|
|||||||
ServerChannels1000000000002,
|
ServerChannels1000000000002,
|
||||||
RepairLegacyVoiceChannels1000000000003,
|
RepairLegacyVoiceChannels1000000000003,
|
||||||
NormalizeServerArrays1000000000004,
|
NormalizeServerArrays1000000000004,
|
||||||
ServerRoleAccessControl1000000000005
|
ServerRoleAccessControl1000000000005,
|
||||||
|
GameMatchMisses1000000000006,
|
||||||
|
PluginSupport1000000000007,
|
||||||
|
ServerPluginInstallMetadata1000000000008
|
||||||
];
|
];
|
||||||
|
|||||||
17
server/src/routes/games.ts
Normal file
17
server/src/routes/games.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { matchRunningGames } from '../services/game-matching.service';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post('/match', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await matchRunningGames(req.body?.processes, req.body?.userId ?? req.ip);
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Games] Failed to match running games', error);
|
||||||
|
res.status(500).json({ error: 'Failed to match running games' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -2,9 +2,12 @@ import { Express } from 'express';
|
|||||||
import healthRouter from './health';
|
import healthRouter from './health';
|
||||||
import klipyRouter from './klipy';
|
import klipyRouter from './klipy';
|
||||||
import linkMetadataRouter from './link-metadata';
|
import linkMetadataRouter from './link-metadata';
|
||||||
|
import gamesRouter from './games';
|
||||||
import proxyRouter from './proxy';
|
import proxyRouter from './proxy';
|
||||||
import usersRouter from './users';
|
import usersRouter from './users';
|
||||||
import serversRouter from './servers';
|
import serversRouter from './servers';
|
||||||
|
import pluginSupportRouter from './plugin-support';
|
||||||
|
import openApiDocsRouter from './openapi-docs';
|
||||||
import joinRequestsRouter from './join-requests';
|
import joinRequestsRouter from './join-requests';
|
||||||
import { invitesApiRouter, invitePageRouter } from './invites';
|
import { invitesApiRouter, invitePageRouter } from './invites';
|
||||||
|
|
||||||
@@ -12,8 +15,11 @@ export function registerRoutes(app: Express): void {
|
|||||||
app.use('/api', healthRouter);
|
app.use('/api', healthRouter);
|
||||||
app.use('/api', klipyRouter);
|
app.use('/api', klipyRouter);
|
||||||
app.use('/api', linkMetadataRouter);
|
app.use('/api', linkMetadataRouter);
|
||||||
|
app.use('/api/games', gamesRouter);
|
||||||
app.use('/api', proxyRouter);
|
app.use('/api', proxyRouter);
|
||||||
app.use('/api/users', usersRouter);
|
app.use('/api/users', usersRouter);
|
||||||
|
app.use('/api', openApiDocsRouter);
|
||||||
|
app.use('/api/servers', pluginSupportRouter);
|
||||||
app.use('/api/servers', serversRouter);
|
app.use('/api/servers', serversRouter);
|
||||||
app.use('/api/invites', invitesApiRouter);
|
app.use('/api/invites', invitesApiRouter);
|
||||||
app.use('/api/requests', joinRequestsRouter);
|
app.use('/api/requests', joinRequestsRouter);
|
||||||
|
|||||||
106
server/src/routes/openapi-docs.ts
Normal file
106
server/src/routes/openapi-docs.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { areOpenApiDocsEnabled, setOpenApiDocsEnabled } from '../config/variables';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
function createOpenApiDocument(baseUrl: string) {
|
||||||
|
return {
|
||||||
|
openapi: '3.1.0',
|
||||||
|
info: {
|
||||||
|
title: 'MetoYou Plugin Support API',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'Official HTTP endpoints for plugin install metadata and event definitions. '
|
||||||
|
+ 'Plugin code is never executed by the signal server.'
|
||||||
|
},
|
||||||
|
servers: [{ url: `${baseUrl}/api` }],
|
||||||
|
paths: {
|
||||||
|
'/servers/{serverId}/plugins': {
|
||||||
|
get: {
|
||||||
|
summary: 'Read plugin requirement snapshot',
|
||||||
|
parameters: [{ name: 'serverId', in: 'path', required: true, schema: { type: 'string' } }],
|
||||||
|
responses: { '200': { description: 'Plugin requirements and event definitions' } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'/servers/{serverId}/plugins/{pluginId}/requirement': {
|
||||||
|
put: {
|
||||||
|
summary: 'Create or update a server plugin requirement',
|
||||||
|
responses: { '200': { description: 'Requirement saved' }, '403': { description: 'Not authorized' } }
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
summary: 'Delete a server plugin requirement',
|
||||||
|
responses: { '200': { description: 'Requirement deleted' }, '403': { description: 'Not authorized' } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'/servers/{serverId}/plugins/{pluginId}/events/{eventName}': {
|
||||||
|
put: {
|
||||||
|
summary: 'Create or update a plugin event definition',
|
||||||
|
responses: { '200': { description: 'Event definition saved' }, '403': { description: 'Not authorized' } }
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
summary: 'Delete a plugin event definition',
|
||||||
|
responses: { '200': { description: 'Event definition deleted' }, '403': { description: 'Not authorized' } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'/servers/{serverId}/plugins/{pluginId}/data': {
|
||||||
|
get: {
|
||||||
|
summary: 'Plugin data persistence disabled',
|
||||||
|
responses: { '410': { description: 'Plugin data persistence is disabled on the signal server' } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'/servers/{serverId}/plugins/{pluginId}/data/{key}': {
|
||||||
|
put: {
|
||||||
|
summary: 'Plugin data persistence disabled',
|
||||||
|
responses: { '410': { description: 'Plugin data persistence is disabled on the signal server' } }
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
summary: 'Plugin data persistence disabled',
|
||||||
|
responses: { '410': { description: 'Plugin data persistence is disabled on the signal server' } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'/openapi/settings': {
|
||||||
|
get: { summary: 'Read OpenAPI docs setting', responses: { '200': { description: 'Setting value' } } },
|
||||||
|
put: { summary: 'Toggle OpenAPI docs exposure', responses: { '200': { description: 'Setting value' } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function docsDisabledResponse() {
|
||||||
|
return { error: 'OpenAPI docs are disabled', errorCode: 'OPENAPI_DOCS_DISABLED' };
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/openapi/settings', (_req, res) => {
|
||||||
|
res.json({ enabled: areOpenApiDocsEnabled() });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/openapi/settings', (req, res) => {
|
||||||
|
res.json(setOpenApiDocsEnabled(req.body?.enabled === true));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/openapi.json', (req, res) => {
|
||||||
|
if (!areOpenApiDocsEnabled()) {
|
||||||
|
res.status(404).json(docsDisabledResponse());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(createOpenApiDocument(`${req.protocol}://${req.get('host') ?? 'localhost'}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/docs', (_req, res) => {
|
||||||
|
if (!areOpenApiDocsEnabled()) {
|
||||||
|
res.status(404).json(docsDisabledResponse());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.type('html').send(`<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="utf-8"><title>MetoYou Plugin API Docs</title></head>
|
||||||
|
<body style="font-family:system-ui;margin:2rem;line-height:1.5">
|
||||||
|
<h1>MetoYou Plugin Support API</h1>
|
||||||
|
<p>Plugin support endpoints are available at <a href="/api/openapi.json">/api/openapi.json</a>.</p>
|
||||||
|
<p>The signal server stores plugin install metadata and event definitions only. It never executes plugin code or stores arbitrary plugin data.</p>
|
||||||
|
</body>
|
||||||
|
</html>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
148
server/src/routes/plugin-support.ts
Normal file
148
server/src/routes/plugin-support.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { Response, Router } from 'express';
|
||||||
|
import {
|
||||||
|
deletePluginEventDefinition,
|
||||||
|
deletePluginRequirement,
|
||||||
|
getPluginRequirementsSnapshot,
|
||||||
|
PluginSupportError,
|
||||||
|
upsertPluginEventDefinition,
|
||||||
|
upsertPluginRequirement
|
||||||
|
} from '../services/plugin-support.service';
|
||||||
|
import { broadcastToServer } from '../websocket/broadcast';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
function sendPluginSupportError(error: unknown, res: Response): void {
|
||||||
|
if (error instanceof PluginSupportError) {
|
||||||
|
res.status(error.status).json({ error: error.message, errorCode: error.code });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Unhandled plugin support error:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error', errorCode: 'INTERNAL_ERROR' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function readActorUserId(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function broadcastRequirementsSnapshot(serverId: string): Promise<void> {
|
||||||
|
const snapshot = await getPluginRequirementsSnapshot(serverId);
|
||||||
|
|
||||||
|
broadcastToServer(serverId, {
|
||||||
|
type: 'plugin_requirements_changed',
|
||||||
|
serverId,
|
||||||
|
snapshot
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/:serverId/plugins', async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await getPluginRequirementsSnapshot(req.params.serverId));
|
||||||
|
} catch (error) {
|
||||||
|
sendPluginSupportError(error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:serverId/plugins/:pluginId/requirement', async (req, res) => {
|
||||||
|
const { serverId, pluginId } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requirement = await upsertPluginRequirement({
|
||||||
|
actorUserId: readActorUserId(req.body.actorUserId),
|
||||||
|
installUrl: req.body.installUrl,
|
||||||
|
manifest: req.body.manifest,
|
||||||
|
pluginId,
|
||||||
|
reason: req.body.reason,
|
||||||
|
serverId,
|
||||||
|
sourceUrl: req.body.sourceUrl,
|
||||||
|
status: req.body.status,
|
||||||
|
versionRange: req.body.versionRange
|
||||||
|
});
|
||||||
|
|
||||||
|
await broadcastRequirementsSnapshot(serverId);
|
||||||
|
res.json({ requirement });
|
||||||
|
} catch (error) {
|
||||||
|
sendPluginSupportError(error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:serverId/plugins/:pluginId/requirement', async (req, res) => {
|
||||||
|
const { serverId, pluginId } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deletePluginRequirement({
|
||||||
|
actorUserId: readActorUserId(req.body.actorUserId),
|
||||||
|
pluginId,
|
||||||
|
serverId
|
||||||
|
});
|
||||||
|
|
||||||
|
await broadcastRequirementsSnapshot(serverId);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
sendPluginSupportError(error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:serverId/plugins/:pluginId/events/:eventName', async (req, res) => {
|
||||||
|
const { serverId, pluginId, eventName } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const eventDefinition = await upsertPluginEventDefinition({
|
||||||
|
actorUserId: readActorUserId(req.body.actorUserId),
|
||||||
|
direction: req.body.direction,
|
||||||
|
eventName,
|
||||||
|
maxPayloadBytes: req.body.maxPayloadBytes,
|
||||||
|
pluginId,
|
||||||
|
rateLimitJson: req.body.rateLimitJson,
|
||||||
|
schemaJson: req.body.schemaJson,
|
||||||
|
scope: req.body.scope,
|
||||||
|
serverId
|
||||||
|
});
|
||||||
|
|
||||||
|
await broadcastRequirementsSnapshot(serverId);
|
||||||
|
res.json({ eventDefinition });
|
||||||
|
} catch (error) {
|
||||||
|
sendPluginSupportError(error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:serverId/plugins/:pluginId/events/:eventName', async (req, res) => {
|
||||||
|
const { serverId, pluginId, eventName } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deletePluginEventDefinition({
|
||||||
|
actorUserId: readActorUserId(req.body.actorUserId),
|
||||||
|
eventName,
|
||||||
|
pluginId,
|
||||||
|
serverId
|
||||||
|
});
|
||||||
|
|
||||||
|
await broadcastRequirementsSnapshot(serverId);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
sendPluginSupportError(error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:serverId/plugins/:pluginId/data', (_req, res) => {
|
||||||
|
res.status(410).json({
|
||||||
|
error: 'Plugin data persistence is disabled on the signal server',
|
||||||
|
errorCode: 'PLUGIN_DATA_DISABLED'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:serverId/plugins/:pluginId/data/:key', (_req, res) => {
|
||||||
|
res.status(410).json({
|
||||||
|
error: 'Plugin data persistence is disabled on the signal server',
|
||||||
|
errorCode: 'PLUGIN_DATA_DISABLED'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:serverId/plugins/:pluginId/data/:key', (_req, res) => {
|
||||||
|
res.status(410).json({
|
||||||
|
error: 'Plugin data persistence is disabled on the signal server',
|
||||||
|
errorCode: 'PLUGIN_DATA_DISABLED'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -19,7 +19,8 @@ import {
|
|||||||
ServerAccessError,
|
ServerAccessError,
|
||||||
kickServerUser,
|
kickServerUser,
|
||||||
ensureServerMembership,
|
ensureServerMembership,
|
||||||
unbanServerUser
|
unbanServerUser,
|
||||||
|
countServerMemberships
|
||||||
} from '../services/server-access.service';
|
} from '../services/server-access.service';
|
||||||
import {
|
import {
|
||||||
buildAppInviteUrl,
|
buildAppInviteUrl,
|
||||||
@@ -78,6 +79,7 @@ function normalizeServerChannels(value: unknown): ServerChannelPayload[] {
|
|||||||
|
|
||||||
async function enrichServer(server: ServerPayload, sourceUrl?: string) {
|
async function enrichServer(server: ServerPayload, sourceUrl?: string) {
|
||||||
const owner = await getUserById(server.ownerId);
|
const owner = await getUserById(server.ownerId);
|
||||||
|
const userCount = await countServerMemberships(server.id);
|
||||||
const { passwordHash, ...publicServer } = server;
|
const { passwordHash, ...publicServer } = server;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -85,7 +87,8 @@ async function enrichServer(server: ServerPayload, sourceUrl?: string) {
|
|||||||
hasPassword: server.hasPassword ?? !!passwordHash,
|
hasPassword: server.hasPassword ?? !!passwordHash,
|
||||||
ownerName: owner?.displayName,
|
ownerName: owner?.displayName,
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
userCount: server.currentUsers
|
currentUsers: userCount,
|
||||||
|
userCount
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
591
server/src/services/game-matching.service.ts
Normal file
591
server/src/services/game-matching.service.ts
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
import { getRawgApiKey } from '../config/variables';
|
||||||
|
import { getDataSource } from '../db/database';
|
||||||
|
import { GameMatchMissEntity } from '../entities';
|
||||||
|
|
||||||
|
export interface MatchedGame {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
iconUrl?: string;
|
||||||
|
store?: GameStoreLink;
|
||||||
|
processName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameStoreLink {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
slug?: string;
|
||||||
|
domain?: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
expiresAt: number;
|
||||||
|
game: Omit<MatchedGame, 'processName'> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawgSearchResponse {
|
||||||
|
results?: RawgGameResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawgGameResult {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
background_image?: string | null;
|
||||||
|
slug?: string;
|
||||||
|
stores?: RawgStoreEntry[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawgStoreEntry {
|
||||||
|
url?: string | null;
|
||||||
|
store?: RawgStore | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawgStore {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
slug?: string;
|
||||||
|
domain?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CandidateProcess {
|
||||||
|
processName: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GameMatchResult {
|
||||||
|
games: MatchedGame[];
|
||||||
|
rateLimited?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawgLookupBudget {
|
||||||
|
used: number;
|
||||||
|
windowStartedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const PERSISTED_MISS_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
const RAWG_LOOKUP_WINDOW_MS = 60 * 60 * 1000;
|
||||||
|
const RAWG_SEARCH_TIMEOUT_MS = 4_000;
|
||||||
|
const MAX_INCOMING_PROCESSES = 256;
|
||||||
|
const MAX_CANDIDATE_PROCESSES = 24;
|
||||||
|
const MAX_UNCACHED_LOOKUPS_PER_REQUEST = 4;
|
||||||
|
const MAX_RAWG_LOOKUPS_PER_USER_PER_WINDOW = 8;
|
||||||
|
const RAWG_SEARCH_URL = 'https://api.rawg.io/api/games';
|
||||||
|
const MIN_SEARCH_QUERY_LENGTH = 4;
|
||||||
|
const IGNORED_PROCESS_NAMES = new Set([
|
||||||
|
'agent',
|
||||||
|
'bash',
|
||||||
|
'baloorunner',
|
||||||
|
'chrome',
|
||||||
|
'code',
|
||||||
|
'conhost',
|
||||||
|
'cursor',
|
||||||
|
'csrss',
|
||||||
|
'dbus-daemon',
|
||||||
|
'discord',
|
||||||
|
'dwm',
|
||||||
|
'electron',
|
||||||
|
'explorer',
|
||||||
|
'firefox',
|
||||||
|
'gameoverlayui',
|
||||||
|
'gamemoded',
|
||||||
|
'gamescopereaper',
|
||||||
|
'gnome-shell',
|
||||||
|
'init',
|
||||||
|
'kernel_task',
|
||||||
|
'metoyou',
|
||||||
|
'nvidia-settings',
|
||||||
|
'node',
|
||||||
|
'npm',
|
||||||
|
'obs',
|
||||||
|
'powershell',
|
||||||
|
'pulseaudio',
|
||||||
|
'services',
|
||||||
|
'steam',
|
||||||
|
'steamwebhelper',
|
||||||
|
'system',
|
||||||
|
'systemd',
|
||||||
|
'taskhostw',
|
||||||
|
'wininit',
|
||||||
|
'winlogon',
|
||||||
|
'xorg'
|
||||||
|
]);
|
||||||
|
const IGNORED_PROCESS_PATTERNS = [
|
||||||
|
new RegExp('(^|\\s)(agent|browser|daemon|desktop|helper|indexer|launcher|monitor|renderer|runner)(\\s|$)'),
|
||||||
|
new RegExp('(^|\\s)(service|settings|shell|tray|updater|utility|watcher|worker)(\\s|$)'),
|
||||||
|
new RegExp('(^|\\s)(audio|bluetooth|clipboard|crash|dbus|file|gpu|input|network|notification)(\\s|$)'),
|
||||||
|
new RegExp('(^|\\s)(portal|proxy|screen|session|sync|system|tracker|web|window)(\\s|$)'),
|
||||||
|
/^(appimage|at-spi|baloo|dconf|gvfs|ibus|kde|kworker)/,
|
||||||
|
/^(pipewire|plasmashell|pulseaudio|xdg|xwayland|zeitgeist)/,
|
||||||
|
/(helper|service|daemon|runner|tracker|portal|updater|worker)$/
|
||||||
|
];
|
||||||
|
const STORE_SEARCH_URL_BUILDERS: Record<string, (query: string) => string> = {
|
||||||
|
steam: (query) => `https://store.steampowered.com/search/?term=${query}`,
|
||||||
|
'epic-games': (query) => `https://store.epicgames.com/en-US/browse?q=${query}`,
|
||||||
|
gog: (query) => `https://www.gog.com/en/games?query=${query}`,
|
||||||
|
itch: (query) => `https://itch.io/search?q=${query}`,
|
||||||
|
'xbox-store': (query) => `https://www.xbox.com/search?q=${query}`,
|
||||||
|
'playstation-store': (query) => `https://store.playstation.com/search/${query}`,
|
||||||
|
nintendo: (query) => `https://www.nintendo.com/search/#q=${query}`,
|
||||||
|
'apple-appstore': (query) => `https://apps.apple.com/us/search?term=${query}`,
|
||||||
|
'google-play': (query) => `https://play.google.com/store/search?q=${query}&c=apps`
|
||||||
|
};
|
||||||
|
const STORE_SEARCH_ALIASES = new Map<string, string>([
|
||||||
|
['steam', 'steam'],
|
||||||
|
['store.steampowered.com', 'steam'],
|
||||||
|
['epic-games', 'epic-games'],
|
||||||
|
['store.epicgames.com', 'epic-games'],
|
||||||
|
['gog', 'gog'],
|
||||||
|
['www.gog.com', 'gog'],
|
||||||
|
['gog.com', 'gog'],
|
||||||
|
['itch', 'itch'],
|
||||||
|
['itch.io', 'itch'],
|
||||||
|
['xbox-store', 'xbox-store'],
|
||||||
|
['www.xbox.com', 'xbox-store'],
|
||||||
|
['xbox.com', 'xbox-store'],
|
||||||
|
['playstation-store', 'playstation-store'],
|
||||||
|
['store.playstation.com', 'playstation-store'],
|
||||||
|
['nintendo', 'nintendo'],
|
||||||
|
['www.nintendo.com', 'nintendo'],
|
||||||
|
['nintendo.com', 'nintendo'],
|
||||||
|
['apple-appstore', 'apple-appstore'],
|
||||||
|
['apps.apple.com', 'apple-appstore'],
|
||||||
|
['google-play', 'google-play'],
|
||||||
|
['play.google.com', 'google-play']
|
||||||
|
]);
|
||||||
|
const STORE_PRIORITY = new Map<string, number>([
|
||||||
|
['steam', 0],
|
||||||
|
['gog', 10],
|
||||||
|
['epic-games', 20],
|
||||||
|
['itch', 30],
|
||||||
|
['xbox-store', 80],
|
||||||
|
['playstation-store', 90]
|
||||||
|
]);
|
||||||
|
const cache = new Map<string, CacheEntry>();
|
||||||
|
const rawgLookupBudgets = new Map<string, RawgLookupBudget>();
|
||||||
|
|
||||||
|
export async function matchRunningGames(
|
||||||
|
processNames: unknown,
|
||||||
|
requester: unknown = 'anonymous'
|
||||||
|
): Promise<GameMatchResult> {
|
||||||
|
const candidates = normalizeProcessList(processNames).slice(0, MAX_CANDIDATE_PROCESSES);
|
||||||
|
const matches: MatchedGame[] = [];
|
||||||
|
const seenGameIds = new Set<string>();
|
||||||
|
const requesterKey = normalizeRequesterKey(requester);
|
||||||
|
const persistedMisses = await loadPersistedMissKeys(candidates.map((candidate) => candidate.processName));
|
||||||
|
|
||||||
|
let uncachedLookups = 0;
|
||||||
|
let rateLimited = false;
|
||||||
|
|
||||||
|
for (const { processName } of candidates) {
|
||||||
|
const cacheKey = normalizeCacheKey(processName);
|
||||||
|
const cached = getCachedGame(cacheKey);
|
||||||
|
|
||||||
|
if (cached !== undefined) {
|
||||||
|
appendMatch(matches, seenGameIds, processName, cached);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (persistedMisses.has(cacheKey)) {
|
||||||
|
setCachedGame(cacheKey, null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uncachedLookups >= MAX_UNCACHED_LOOKUPS_PER_REQUEST) {
|
||||||
|
rateLimited = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tryConsumeRawgLookup(requesterKey)) {
|
||||||
|
rateLimited = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
uncachedLookups += 1;
|
||||||
|
|
||||||
|
const game = await resolveRawgGame(processName);
|
||||||
|
|
||||||
|
setCachedGame(cacheKey, game);
|
||||||
|
|
||||||
|
if (!game) {
|
||||||
|
await rememberPersistedMiss(cacheKey, processName);
|
||||||
|
}
|
||||||
|
|
||||||
|
appendMatch(matches, seenGameIds, processName, game);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
games: matches,
|
||||||
|
rateLimited: rateLimited || undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProcessList(value: unknown): CandidateProcess[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const processes = new Map<string, CandidateProcess>();
|
||||||
|
|
||||||
|
for (const entry of value.slice(0, MAX_INCOMING_PROCESSES)) {
|
||||||
|
const processName = normalizeProcessName(entry);
|
||||||
|
|
||||||
|
if (processName) {
|
||||||
|
const cacheKey = normalizeCacheKey(processName);
|
||||||
|
|
||||||
|
if (!processes.has(cacheKey)) {
|
||||||
|
processes.set(cacheKey, {
|
||||||
|
processName,
|
||||||
|
score: scoreCandidateProcess(String(entry), processName)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(processes.values())
|
||||||
|
.sort((left, right) => right.score - left.score || left.processName.localeCompare(right.processName));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProcessName(value: unknown): string {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value
|
||||||
|
.trim()
|
||||||
|
.replace(/\.exe$/i, '')
|
||||||
|
.replace(/[_-]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
const cacheKey = normalizeCacheKey(normalized);
|
||||||
|
|
||||||
|
if (normalized.length < 3 || normalized.length > 96 || shouldIgnoreProcessName(cacheKey)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldIgnoreProcessName(cacheKey: string): boolean {
|
||||||
|
return IGNORED_PROCESS_NAMES.has(cacheKey)
|
||||||
|
|| IGNORED_PROCESS_PATTERNS.some((pattern) => pattern.test(cacheKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRequesterKey(value: unknown): string {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return 'anonymous';
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
|
||||||
|
return normalized || 'anonymous';
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryConsumeRawgLookup(requesterKey: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const existing = rawgLookupBudgets.get(requesterKey);
|
||||||
|
|
||||||
|
if (!existing || existing.windowStartedAt + RAWG_LOOKUP_WINDOW_MS <= now) {
|
||||||
|
rawgLookupBudgets.set(requesterKey, {
|
||||||
|
used: 1,
|
||||||
|
windowStartedAt: now
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.used >= MAX_RAWG_LOOKUPS_PER_USER_PER_WINDOW) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.used += 1;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreCandidateProcess(rawValue: string, processName: string): number {
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
if (/\.exe$/i.test(rawValue.trim())) {
|
||||||
|
score += 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/[A-Z]/.test(processName) && /[a-z]/.test(processName)) {
|
||||||
|
score += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\d/.test(processName)) {
|
||||||
|
score += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processName.length >= 5 && processName.length <= 32) {
|
||||||
|
score += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processName.includes(' ')) {
|
||||||
|
score -= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCacheKey(value: string): string {
|
||||||
|
return value.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCachedGame(cacheKey: string): Omit<MatchedGame, 'processName'> | null | undefined {
|
||||||
|
const cached = cache.get(cacheKey);
|
||||||
|
|
||||||
|
if (!cached) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cached.expiresAt <= Date.now()) {
|
||||||
|
cache.delete(cacheKey);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cached.game;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCachedGame(cacheKey: string, game: Omit<MatchedGame, 'processName'> | null): void {
|
||||||
|
cache.set(cacheKey, {
|
||||||
|
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||||
|
game
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPersistedMissKeys(processNames: string[]): Promise<Set<string>> {
|
||||||
|
const cacheKeys = Array.from(new Set(processNames.map((name) => normalizeCacheKey(name))));
|
||||||
|
|
||||||
|
if (cacheKeys.length === 0) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const repository = getDataSource().getRepository(GameMatchMissEntity);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
await repository.createQueryBuilder()
|
||||||
|
.delete()
|
||||||
|
.where('expiresAt <= :now', { now })
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
const rows = await repository.createQueryBuilder('miss')
|
||||||
|
.select('miss.processKey')
|
||||||
|
.where('miss.processKey IN (:...cacheKeys)', { cacheKeys })
|
||||||
|
.andWhere('miss.expiresAt > :now', { now })
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
return new Set(rows.map((row) => row.processKey));
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rememberPersistedMiss(cacheKey: string, processName: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
await getDataSource().getRepository(GameMatchMissEntity)
|
||||||
|
.save({
|
||||||
|
processKey: cacheKey,
|
||||||
|
processName,
|
||||||
|
missedAt: now,
|
||||||
|
expiresAt: now + PERSISTED_MISS_TTL_MS
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveRawgGame(processName: string): Promise<Omit<MatchedGame, 'processName'> | null> {
|
||||||
|
const apiKey = getRawgApiKey();
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = buildSearchQuery(processName);
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(RAWG_SEARCH_URL);
|
||||||
|
|
||||||
|
url.searchParams.set('key', apiKey);
|
||||||
|
url.searchParams.set('search', query);
|
||||||
|
url.searchParams.set('search_precise', 'true');
|
||||||
|
url.searchParams.set('exclude_additions', 'true');
|
||||||
|
url.searchParams.set('page_size', '1');
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), RAWG_SEARCH_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal: controller.signal });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await response.json() as RawgSearchResponse;
|
||||||
|
const result = body.results?.[0];
|
||||||
|
|
||||||
|
if (!isAcceptableRawgMatch(query, result)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(result.id),
|
||||||
|
name: result.name.trim(),
|
||||||
|
iconUrl: result.background_image || undefined,
|
||||||
|
store: selectPreferredStore(result, result.name.trim())
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPreferredStore(result: RawgGameResult, gameName: string): GameStoreLink | undefined {
|
||||||
|
const stores = Array.isArray(result.stores) ? result.stores : [];
|
||||||
|
const usableStores = stores
|
||||||
|
.map((entry) => buildStoreLink(entry, gameName))
|
||||||
|
.filter((store): store is GameStoreLink => !!store);
|
||||||
|
|
||||||
|
return usableStores.sort((left, right) => getStorePriority(left) - getStorePriority(right))[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStorePriority(store: GameStoreLink): number {
|
||||||
|
const storeKey = STORE_SEARCH_ALIASES.get(store.slug ?? '')
|
||||||
|
?? STORE_SEARCH_ALIASES.get(store.domain ?? '')
|
||||||
|
?? store.name.trim().toLowerCase();
|
||||||
|
|
||||||
|
return STORE_PRIORITY.get(storeKey) ?? 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStoreLink(entry: RawgStoreEntry, gameName: string): GameStoreLink | undefined {
|
||||||
|
const store = entry.store;
|
||||||
|
|
||||||
|
if (!store || typeof store.name !== 'string' || !store.name.trim()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = typeof store.slug === 'string' && store.slug.trim()
|
||||||
|
? store.slug.trim().toLowerCase()
|
||||||
|
: undefined;
|
||||||
|
const domain = typeof store.domain === 'string' && store.domain.trim()
|
||||||
|
? store.domain.trim()
|
||||||
|
.replace(/^https?:\/\//i, '')
|
||||||
|
.replace(/\/$/, '')
|
||||||
|
: undefined;
|
||||||
|
const url = normalizeExternalUrl(entry.url) ?? buildStoreSearchUrl(slug, domain, gameName);
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: typeof store.id === 'number' ? String(store.id) : undefined,
|
||||||
|
name: store.name.trim(),
|
||||||
|
slug,
|
||||||
|
domain,
|
||||||
|
url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExternalUrl(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== 'string' || !value.trim()) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
|
||||||
|
return trimmed.startsWith('http://') || trimmed.startsWith('https://')
|
||||||
|
? trimmed
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStoreSearchUrl(slug: string | undefined, domain: string | undefined, gameName: string): string | undefined {
|
||||||
|
const query = encodeURIComponent(gameName);
|
||||||
|
const storeKey = STORE_SEARCH_ALIASES.get(slug ?? '') ?? STORE_SEARCH_ALIASES.get(domain ?? '');
|
||||||
|
const buildUrl = storeKey ? STORE_SEARCH_URL_BUILDERS[storeKey] : undefined;
|
||||||
|
|
||||||
|
return buildUrl?.(query) ?? (domain ? `https://${domain}` : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSearchQuery(processName: string): string {
|
||||||
|
const query = processName
|
||||||
|
.replace(/\.exe$/i, '')
|
||||||
|
.replace(/\b(x64|x86|win64|win32|linux|shipping|client|launcher|game)\b/gi, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return query.length >= MIN_SEARCH_QUERY_LENGTH ? query : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAcceptableRawgMatch(
|
||||||
|
query: string,
|
||||||
|
result: RawgGameResult | undefined
|
||||||
|
): result is Required<Pick<RawgGameResult, 'id' | 'name'>> & RawgGameResult {
|
||||||
|
if (!result || typeof result.id !== 'number' || typeof result.name !== 'string' || !result.name.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryKey = normalizeComparableText(query);
|
||||||
|
const nameKey = normalizeComparableText(result.name);
|
||||||
|
const slugKey = normalizeComparableText(result.slug ?? '');
|
||||||
|
const queryTokens = tokenizeComparableText(queryKey);
|
||||||
|
const nameTokens = tokenizeComparableText(nameKey);
|
||||||
|
const slugTokens = tokenizeComparableText(slugKey);
|
||||||
|
|
||||||
|
if (queryKey.length < MIN_SEARCH_QUERY_LENGTH || queryTokens.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryKey === nameKey || queryKey === slugKey) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryTokens.length === 1) {
|
||||||
|
const [queryToken] = queryTokens;
|
||||||
|
|
||||||
|
return queryToken.length >= 5
|
||||||
|
&& (nameTokens.includes(queryToken) || slugTokens.includes(queryToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryTokens.every((token) => nameTokens.includes(token) || slugTokens.includes(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeComparableText(value: string): string {
|
||||||
|
return value.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenizeComparableText(value: string): string[] {
|
||||||
|
return value.split(' ')
|
||||||
|
.filter((token) => token.length >= 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendMatch(
|
||||||
|
matches: MatchedGame[],
|
||||||
|
seenGameIds: Set<string>,
|
||||||
|
processName: string,
|
||||||
|
game: Omit<MatchedGame, 'processName'> | null
|
||||||
|
): void {
|
||||||
|
if (!game || seenGameIds.has(game.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenGameIds.add(game.id);
|
||||||
|
matches.push({
|
||||||
|
...game,
|
||||||
|
processName
|
||||||
|
});
|
||||||
|
}
|
||||||
539
server/src/services/plugin-support.service.ts
Normal file
539
server/src/services/plugin-support.service.ts
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
import { getServerById } from '../cqrs';
|
||||||
|
import { getDataSource } from '../db/database';
|
||||||
|
import {
|
||||||
|
PluginDataEntity,
|
||||||
|
ServerPluginEventDefinitionEntity,
|
||||||
|
ServerPluginEventDirection,
|
||||||
|
ServerPluginEventScope,
|
||||||
|
ServerPluginRequirementEntity,
|
||||||
|
ServerPluginRequirementStatus
|
||||||
|
} from '../entities';
|
||||||
|
import { findServerMembership } from './server-access.service';
|
||||||
|
import { resolveServerPermission } from './server-permissions.service';
|
||||||
|
|
||||||
|
export const DEFAULT_PLUGIN_EVENT_MAX_PAYLOAD_BYTES = 64 * 1024;
|
||||||
|
|
||||||
|
const VALID_REQUIREMENT_STATUSES = new Set<ServerPluginRequirementStatus>([
|
||||||
|
'required',
|
||||||
|
'optional',
|
||||||
|
'recommended',
|
||||||
|
'blocked',
|
||||||
|
'incompatible'
|
||||||
|
]);
|
||||||
|
const VALID_EVENT_DIRECTIONS = new Set<ServerPluginEventDirection>([
|
||||||
|
'clientToServer',
|
||||||
|
'serverRelay',
|
||||||
|
'p2pHint'
|
||||||
|
]);
|
||||||
|
const VALID_EVENT_SCOPES = new Set<ServerPluginEventScope>([
|
||||||
|
'server',
|
||||||
|
'channel',
|
||||||
|
'user',
|
||||||
|
'plugin'
|
||||||
|
]);
|
||||||
|
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9.-]{1,126}[a-z0-9]$/;
|
||||||
|
const EVENT_NAME_PATTERN = /^[a-z][a-z0-9.:-]{1,126}[a-z0-9]$/;
|
||||||
|
const DATA_KEY_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/;
|
||||||
|
const DATA_SCOPE_PATTERN = /^[a-zA-Z][a-zA-Z0-9._:-]{0,63}$/;
|
||||||
|
|
||||||
|
export interface PluginRequirementSummary {
|
||||||
|
installUrl?: string;
|
||||||
|
manifest?: unknown;
|
||||||
|
pluginId: string;
|
||||||
|
reason?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
|
status: ServerPluginRequirementStatus;
|
||||||
|
updatedAt: number;
|
||||||
|
versionRange?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginEventDefinitionSummary {
|
||||||
|
direction: ServerPluginEventDirection;
|
||||||
|
eventName: string;
|
||||||
|
maxPayloadBytes: number;
|
||||||
|
pluginId: string;
|
||||||
|
scope: ServerPluginEventScope;
|
||||||
|
schemaJson?: string;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginRequirementsSnapshot {
|
||||||
|
eventDefinitions: PluginEventDefinitionSummary[];
|
||||||
|
requirements: PluginRequirementSummary[];
|
||||||
|
serverId: string;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginDataRecord {
|
||||||
|
key: string;
|
||||||
|
ownerId?: string;
|
||||||
|
pluginId: string;
|
||||||
|
schemaVersion: number;
|
||||||
|
scope: string;
|
||||||
|
serverId: string;
|
||||||
|
updatedAt: number;
|
||||||
|
updatedBy?: string;
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginEventEnvelope {
|
||||||
|
eventId?: string;
|
||||||
|
eventName: string;
|
||||||
|
payload: unknown;
|
||||||
|
pluginId: string;
|
||||||
|
serverId: string;
|
||||||
|
sourcePluginUserId?: string;
|
||||||
|
type: 'plugin_event';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PluginSupportError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
readonly code: string,
|
||||||
|
message: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'PluginSupportError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirementRepository() {
|
||||||
|
return getDataSource().getRepository(ServerPluginRequirementEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventDefinitionRepository() {
|
||||||
|
return getDataSource().getRepository(ServerPluginEventDefinitionEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluginDataRepository() {
|
||||||
|
return getDataSource().getRepository(PluginDataEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptionalString(value: unknown, maxLength: number): string | null {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim();
|
||||||
|
|
||||||
|
return normalized ? normalized.slice(0, maxLength) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPattern(value: string, pattern: RegExp, code: string, label: string): void {
|
||||||
|
if (!pattern.test(value)) {
|
||||||
|
throw new PluginSupportError(400, code, `Invalid ${label}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePluginId(pluginId: unknown): string {
|
||||||
|
const normalized = normalizeOptionalString(pluginId, 128);
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
throw new PluginSupportError(400, 'MISSING_PLUGIN_ID', 'Missing plugin id');
|
||||||
|
}
|
||||||
|
|
||||||
|
assertPattern(normalized, PLUGIN_ID_PATTERN, 'INVALID_PLUGIN_ID', 'plugin id');
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEventName(eventName: unknown): string {
|
||||||
|
const normalized = normalizeOptionalString(eventName, 128);
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
throw new PluginSupportError(400, 'MISSING_EVENT_NAME', 'Missing event name');
|
||||||
|
}
|
||||||
|
|
||||||
|
assertPattern(normalized, EVENT_NAME_PATTERN, 'INVALID_EVENT_NAME', 'event name');
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDataKey(key: unknown): string {
|
||||||
|
const normalized = normalizeOptionalString(key, 128);
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
throw new PluginSupportError(400, 'MISSING_DATA_KEY', 'Missing data key');
|
||||||
|
}
|
||||||
|
|
||||||
|
assertPattern(normalized, DATA_KEY_PATTERN, 'INVALID_DATA_KEY', 'data key');
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDataScope(scope: unknown): string {
|
||||||
|
const normalized = normalizeOptionalString(scope, 64) ?? 'server';
|
||||||
|
|
||||||
|
assertPattern(normalized, DATA_SCOPE_PATTERN, 'INVALID_DATA_SCOPE', 'data scope');
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOwnerId(ownerId: unknown): string {
|
||||||
|
return normalizeOptionalString(ownerId, 128) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonValue(valueJson: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(valueJson) as unknown;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalJsonValue(valueJson: string | null): unknown {
|
||||||
|
return valueJson ? parseJsonValue(valueJson) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeJsonValue(value: unknown, code: string): string {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value ?? null);
|
||||||
|
} catch {
|
||||||
|
throw new PluginSupportError(400, code, 'Value must be JSON serializable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRequirementSummary(entity: ServerPluginRequirementEntity): PluginRequirementSummary {
|
||||||
|
return {
|
||||||
|
installUrl: entity.installUrl ?? undefined,
|
||||||
|
manifest: parseOptionalJsonValue(entity.manifestJson),
|
||||||
|
pluginId: entity.pluginId,
|
||||||
|
reason: entity.reason ?? undefined,
|
||||||
|
sourceUrl: entity.sourceUrl ?? undefined,
|
||||||
|
status: entity.status,
|
||||||
|
updatedAt: entity.updatedAt,
|
||||||
|
versionRange: entity.versionRange ?? undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEventDefinitionSummary(entity: ServerPluginEventDefinitionEntity): PluginEventDefinitionSummary {
|
||||||
|
return {
|
||||||
|
direction: entity.direction,
|
||||||
|
eventName: entity.eventName,
|
||||||
|
maxPayloadBytes: entity.maxPayloadBytes,
|
||||||
|
pluginId: entity.pluginId,
|
||||||
|
scope: entity.scope,
|
||||||
|
schemaJson: entity.schemaJson ?? undefined,
|
||||||
|
updatedAt: entity.updatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPluginDataRecord(entity: PluginDataEntity): PluginDataRecord {
|
||||||
|
return {
|
||||||
|
key: entity.key,
|
||||||
|
ownerId: entity.ownerId || undefined,
|
||||||
|
pluginId: entity.pluginId,
|
||||||
|
schemaVersion: entity.schemaVersion,
|
||||||
|
scope: entity.scope,
|
||||||
|
serverId: entity.serverId,
|
||||||
|
updatedAt: entity.updatedAt,
|
||||||
|
updatedBy: entity.updatedBy ?? undefined,
|
||||||
|
value: parseJsonValue(entity.valueJson)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertServerExists(serverId: string) {
|
||||||
|
const server = await getServerById(serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
throw new PluginSupportError(404, 'SERVER_NOT_FOUND', 'Server not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertCanManagePluginSupport(serverId: string, actorUserId: string): Promise<void> {
|
||||||
|
const server = await assertServerExists(serverId);
|
||||||
|
|
||||||
|
if (!actorUserId || !resolveServerPermission(server, actorUserId, 'manageServer')) {
|
||||||
|
throw new PluginSupportError(403, 'NOT_AUTHORIZED', 'Not authorized');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertCanUsePluginData(serverId: string, actorUserId: string): Promise<void> {
|
||||||
|
const server = await assertServerExists(serverId);
|
||||||
|
|
||||||
|
if (!actorUserId) {
|
||||||
|
throw new PluginSupportError(400, 'MISSING_USER', 'Missing user id');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.ownerId === actorUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await findServerMembership(serverId, actorUserId);
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
throw new PluginSupportError(403, 'NOT_MEMBER', 'Only joined users can access plugin data');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPluginRequirementsSnapshot(serverId: string): Promise<PluginRequirementsSnapshot> {
|
||||||
|
await assertServerExists(serverId);
|
||||||
|
|
||||||
|
const requirementQuery = requirementRepository().find({ where: { serverId } });
|
||||||
|
const eventDefinitionQuery = eventDefinitionRepository().find({ where: { serverId } });
|
||||||
|
const [requirements, eventDefinitions] = await Promise.all([requirementQuery, eventDefinitionQuery]);
|
||||||
|
const requirementSummaries = requirements
|
||||||
|
.map(toRequirementSummary)
|
||||||
|
.sort((first, second) => first.pluginId.localeCompare(second.pluginId));
|
||||||
|
const eventDefinitionSummaries = eventDefinitions
|
||||||
|
.map(toEventDefinitionSummary)
|
||||||
|
.sort((first, second) => `${first.pluginId}:${first.eventName}`.localeCompare(`${second.pluginId}:${second.eventName}`));
|
||||||
|
const updatedAt = Math.max(
|
||||||
|
0,
|
||||||
|
...requirementSummaries.map((requirement) => requirement.updatedAt),
|
||||||
|
...eventDefinitionSummaries.map((definition) => definition.updatedAt)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventDefinitions: eventDefinitionSummaries,
|
||||||
|
requirements: requirementSummaries,
|
||||||
|
serverId,
|
||||||
|
updatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertPluginRequirement(options: {
|
||||||
|
actorUserId: string;
|
||||||
|
installUrl?: unknown;
|
||||||
|
manifest?: unknown;
|
||||||
|
pluginId: string;
|
||||||
|
reason?: unknown;
|
||||||
|
serverId: string;
|
||||||
|
sourceUrl?: unknown;
|
||||||
|
status: unknown;
|
||||||
|
versionRange?: unknown;
|
||||||
|
}): Promise<PluginRequirementSummary> {
|
||||||
|
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
|
||||||
|
|
||||||
|
const pluginId = normalizePluginId(options.pluginId);
|
||||||
|
const status = options.status;
|
||||||
|
|
||||||
|
if (!VALID_REQUIREMENT_STATUSES.has(status as ServerPluginRequirementStatus)) {
|
||||||
|
throw new PluginSupportError(400, 'INVALID_REQUIREMENT_STATUS', 'Invalid plugin requirement status');
|
||||||
|
}
|
||||||
|
|
||||||
|
const repo = requirementRepository();
|
||||||
|
const now = Date.now();
|
||||||
|
const existing = await repo.findOne({ where: { serverId: options.serverId, pluginId } });
|
||||||
|
const entity = repo.create({
|
||||||
|
serverId: options.serverId,
|
||||||
|
pluginId,
|
||||||
|
status: status as ServerPluginRequirementStatus,
|
||||||
|
versionRange: normalizeOptionalString(options.versionRange, 128),
|
||||||
|
reason: normalizeOptionalString(options.reason, 512),
|
||||||
|
installUrl: normalizeOptionalString(options.installUrl, 2_000),
|
||||||
|
sourceUrl: normalizeOptionalString(options.sourceUrl, 2_000),
|
||||||
|
manifestJson: options.manifest === undefined ? null : serializeJsonValue(options.manifest, 'INVALID_PLUGIN_MANIFEST_METADATA'),
|
||||||
|
configuredBy: options.actorUserId,
|
||||||
|
createdAt: existing?.createdAt ?? now,
|
||||||
|
updatedAt: now
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.save(entity);
|
||||||
|
return toRequirementSummary(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePluginRequirement(options: {
|
||||||
|
actorUserId: string;
|
||||||
|
pluginId: string;
|
||||||
|
serverId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
|
||||||
|
await requirementRepository().delete({ serverId: options.serverId, pluginId: normalizePluginId(options.pluginId) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertPluginEventDefinition(options: {
|
||||||
|
actorUserId: string;
|
||||||
|
direction: unknown;
|
||||||
|
eventName: string;
|
||||||
|
maxPayloadBytes?: unknown;
|
||||||
|
pluginId: string;
|
||||||
|
rateLimitJson?: unknown;
|
||||||
|
schemaJson?: unknown;
|
||||||
|
scope: unknown;
|
||||||
|
serverId: string;
|
||||||
|
}): Promise<PluginEventDefinitionSummary> {
|
||||||
|
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
|
||||||
|
|
||||||
|
const pluginId = normalizePluginId(options.pluginId);
|
||||||
|
const eventName = normalizeEventName(options.eventName);
|
||||||
|
const { direction, scope } = options;
|
||||||
|
|
||||||
|
if (!VALID_EVENT_DIRECTIONS.has(direction as ServerPluginEventDirection)) {
|
||||||
|
throw new PluginSupportError(400, 'INVALID_EVENT_DIRECTION', 'Invalid plugin event direction');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VALID_EVENT_SCOPES.has(scope as ServerPluginEventScope)) {
|
||||||
|
throw new PluginSupportError(400, 'INVALID_EVENT_SCOPE', 'Invalid plugin event scope');
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxPayloadBytes = typeof options.maxPayloadBytes === 'number' && Number.isFinite(options.maxPayloadBytes)
|
||||||
|
? Math.max(1, Math.min(Math.floor(options.maxPayloadBytes), DEFAULT_PLUGIN_EVENT_MAX_PAYLOAD_BYTES))
|
||||||
|
: DEFAULT_PLUGIN_EVENT_MAX_PAYLOAD_BYTES;
|
||||||
|
const repo = eventDefinitionRepository();
|
||||||
|
const now = Date.now();
|
||||||
|
const existing = await repo.findOne({ where: { serverId: options.serverId, pluginId, eventName } });
|
||||||
|
const entity = repo.create({
|
||||||
|
serverId: options.serverId,
|
||||||
|
pluginId,
|
||||||
|
eventName,
|
||||||
|
direction: direction as ServerPluginEventDirection,
|
||||||
|
scope: scope as ServerPluginEventScope,
|
||||||
|
schemaJson: normalizeOptionalString(options.schemaJson, 10_000),
|
||||||
|
maxPayloadBytes,
|
||||||
|
rateLimitJson: normalizeOptionalString(options.rateLimitJson, 2_000),
|
||||||
|
createdAt: existing?.createdAt ?? now,
|
||||||
|
updatedAt: now
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.save(entity);
|
||||||
|
return toEventDefinitionSummary(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePluginEventDefinition(options: {
|
||||||
|
actorUserId: string;
|
||||||
|
eventName: string;
|
||||||
|
pluginId: string;
|
||||||
|
serverId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
|
||||||
|
await eventDefinitionRepository().delete({
|
||||||
|
serverId: options.serverId,
|
||||||
|
pluginId: normalizePluginId(options.pluginId),
|
||||||
|
eventName: normalizeEventName(options.eventName)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPluginData(options: {
|
||||||
|
actorUserId: string;
|
||||||
|
key?: unknown;
|
||||||
|
ownerId?: unknown;
|
||||||
|
pluginId: string;
|
||||||
|
scope?: unknown;
|
||||||
|
serverId: string;
|
||||||
|
}): Promise<PluginDataRecord[]> {
|
||||||
|
await assertCanUsePluginData(options.serverId, options.actorUserId);
|
||||||
|
|
||||||
|
const pluginId = normalizePluginId(options.pluginId);
|
||||||
|
const scope = options.scope === undefined ? undefined : normalizeDataScope(options.scope);
|
||||||
|
const ownerId = options.ownerId === undefined ? undefined : normalizeOwnerId(options.ownerId);
|
||||||
|
const key = options.key === undefined ? undefined : normalizeDataKey(options.key);
|
||||||
|
const query = pluginDataRepository()
|
||||||
|
.createQueryBuilder('data')
|
||||||
|
.where('data.serverId = :serverId', { serverId: options.serverId })
|
||||||
|
.andWhere('data.pluginId = :pluginId', { pluginId });
|
||||||
|
|
||||||
|
if (scope !== undefined) {
|
||||||
|
query.andWhere('data.scope = :scope', { scope });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ownerId !== undefined) {
|
||||||
|
query.andWhere('data.ownerId = :ownerId', { ownerId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key !== undefined) {
|
||||||
|
query.andWhere('data.key = :key', { key });
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = await query
|
||||||
|
.orderBy('data.scope', 'ASC')
|
||||||
|
.addOrderBy('data.ownerId', 'ASC')
|
||||||
|
.addOrderBy('data.key', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
return records.map(toPluginDataRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertPluginData(options: {
|
||||||
|
actorUserId: string;
|
||||||
|
key: string;
|
||||||
|
ownerId?: unknown;
|
||||||
|
pluginId: string;
|
||||||
|
schemaVersion?: unknown;
|
||||||
|
scope?: unknown;
|
||||||
|
serverId: string;
|
||||||
|
value: unknown;
|
||||||
|
}): Promise<PluginDataRecord> {
|
||||||
|
await assertCanUsePluginData(options.serverId, options.actorUserId);
|
||||||
|
|
||||||
|
const pluginId = normalizePluginId(options.pluginId);
|
||||||
|
const scope = normalizeDataScope(options.scope);
|
||||||
|
const ownerId = scope === 'user' ? normalizeOwnerId(options.ownerId ?? options.actorUserId) : normalizeOwnerId(options.ownerId);
|
||||||
|
|
||||||
|
if (scope === 'user' && ownerId !== options.actorUserId) {
|
||||||
|
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = normalizeDataKey(options.key);
|
||||||
|
const schemaVersion = typeof options.schemaVersion === 'number' && Number.isFinite(options.schemaVersion)
|
||||||
|
? Math.max(1, Math.floor(options.schemaVersion))
|
||||||
|
: 1;
|
||||||
|
const repo = pluginDataRepository();
|
||||||
|
const entity = repo.create({
|
||||||
|
serverId: options.serverId,
|
||||||
|
pluginId,
|
||||||
|
scope,
|
||||||
|
ownerId,
|
||||||
|
key,
|
||||||
|
valueJson: serializeJsonValue(options.value, 'INVALID_PLUGIN_DATA'),
|
||||||
|
schemaVersion,
|
||||||
|
updatedBy: options.actorUserId,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.save(entity);
|
||||||
|
return toPluginDataRecord(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePluginData(options: {
|
||||||
|
actorUserId: string;
|
||||||
|
key: string;
|
||||||
|
ownerId?: unknown;
|
||||||
|
pluginId: string;
|
||||||
|
scope?: unknown;
|
||||||
|
serverId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
await assertCanUsePluginData(options.serverId, options.actorUserId);
|
||||||
|
|
||||||
|
const pluginId = normalizePluginId(options.pluginId);
|
||||||
|
const scope = normalizeDataScope(options.scope);
|
||||||
|
const ownerId = scope === 'user' ? normalizeOwnerId(options.ownerId ?? options.actorUserId) : normalizeOwnerId(options.ownerId);
|
||||||
|
|
||||||
|
if (scope === 'user' && ownerId !== options.actorUserId) {
|
||||||
|
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pluginDataRepository().delete({
|
||||||
|
serverId: options.serverId,
|
||||||
|
pluginId,
|
||||||
|
scope,
|
||||||
|
ownerId,
|
||||||
|
key: normalizeDataKey(options.key)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validatePluginEventEnvelope(envelope: PluginEventEnvelope): Promise<ServerPluginEventDefinitionEntity> {
|
||||||
|
const pluginId = normalizePluginId(envelope.pluginId);
|
||||||
|
const eventName = normalizeEventName(envelope.eventName);
|
||||||
|
const definition = await eventDefinitionRepository().findOne({
|
||||||
|
where: {
|
||||||
|
serverId: envelope.serverId,
|
||||||
|
pluginId,
|
||||||
|
eventName
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!definition) {
|
||||||
|
throw new PluginSupportError(404, 'PLUGIN_EVENT_NOT_REGISTERED', 'Plugin event is not registered for this server');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.direction === 'p2pHint') {
|
||||||
|
throw new PluginSupportError(400, 'PLUGIN_EVENT_NOT_RELAYABLE', 'P2P plugin events must not be relayed by the signal server');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadBytes = Buffer.byteLength(serializeJsonValue(envelope.payload, 'INVALID_PLUGIN_EVENT_PAYLOAD'), 'utf8');
|
||||||
|
|
||||||
|
if (payloadBytes > definition.maxPayloadBytes) {
|
||||||
|
throw new PluginSupportError(413, 'PLUGIN_EVENT_TOO_LARGE', 'Plugin event payload is too large');
|
||||||
|
}
|
||||||
|
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
@@ -130,6 +130,10 @@ export async function findServerMembership(serverId: string, userId: string): Pr
|
|||||||
return await getMembershipRepository().findOne({ where: { serverId, userId } });
|
return await getMembershipRepository().findOne({ where: { serverId, userId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function countServerMemberships(serverId: string): Promise<number> {
|
||||||
|
return await getMembershipRepository().count({ where: { serverId } });
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity> {
|
export async function ensureServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity> {
|
||||||
const repo = getMembershipRepository();
|
const repo = getMembershipRepository();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
221
server/src/websocket/handler-plugin.spec.ts
Normal file
221
server/src/websocket/handler-plugin.spec.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi
|
||||||
|
} from 'vitest';
|
||||||
|
import { WebSocket } from 'ws';
|
||||||
|
import { ConnectedUser } from './types';
|
||||||
|
import { connectedUsers } from './state';
|
||||||
|
|
||||||
|
const pluginSupportMocks = vi.hoisted(() => {
|
||||||
|
class MockPluginSupportError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
readonly code: string,
|
||||||
|
message: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'PluginSupportError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getPluginRequirementsSnapshot: vi.fn(),
|
||||||
|
PluginSupportError: MockPluginSupportError,
|
||||||
|
validatePluginEventEnvelope: vi.fn()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../services/server-access.service', () => ({
|
||||||
|
authorizeWebSocketJoin: vi.fn(async () => ({ allowed: true as const }))
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/plugin-support.service', () => pluginSupportMocks);
|
||||||
|
|
||||||
|
import { handleWebSocketMessage } from './handler';
|
||||||
|
|
||||||
|
interface SentMessageStore {
|
||||||
|
sentMessages: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockWs(): WebSocket & SentMessageStore {
|
||||||
|
const sentMessages: string[] = [];
|
||||||
|
const socket = {
|
||||||
|
readyState: WebSocket.OPEN,
|
||||||
|
send: (data: string) => {
|
||||||
|
sentMessages.push(data);
|
||||||
|
},
|
||||||
|
close: () => {},
|
||||||
|
sentMessages
|
||||||
|
} as unknown as WebSocket & SentMessageStore;
|
||||||
|
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConnectedUser(
|
||||||
|
connectionId: string,
|
||||||
|
oderId: string,
|
||||||
|
overrides: Partial<ConnectedUser> = {}
|
||||||
|
): ConnectedUser {
|
||||||
|
const user: ConnectedUser = {
|
||||||
|
displayName: `User ${oderId}`,
|
||||||
|
lastPong: Date.now(),
|
||||||
|
oderId,
|
||||||
|
serverIds: new Set(),
|
||||||
|
ws: createMockWs(),
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
|
||||||
|
connectedUsers.set(connectionId, user);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSentMessages(user: ConnectedUser): Record<string, unknown>[] {
|
||||||
|
return (user.ws as unknown as SentMessageStore).sentMessages.map((messageText) => JSON.parse(messageText) as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('server websocket handler - plugin support', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
connectedUsers.clear();
|
||||||
|
pluginSupportMocks.getPluginRequirementsSnapshot.mockReset();
|
||||||
|
pluginSupportMocks.validatePluginEventEnvelope.mockReset();
|
||||||
|
pluginSupportMocks.getPluginRequirementsSnapshot.mockResolvedValue({
|
||||||
|
eventDefinitions: [],
|
||||||
|
requirements: [],
|
||||||
|
serverId: 'server-1',
|
||||||
|
updatedAt: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
pluginSupportMocks.validatePluginEventEnvelope.mockResolvedValue({ direction: 'serverRelay' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends plugin requirement snapshots after joining a server', async () => {
|
||||||
|
const alice = createConnectedUser('conn-1', 'alice');
|
||||||
|
|
||||||
|
pluginSupportMocks.getPluginRequirementsSnapshot.mockResolvedValue({
|
||||||
|
eventDefinitions: [
|
||||||
|
{
|
||||||
|
direction: 'serverRelay',
|
||||||
|
eventName: 'e2e:relay',
|
||||||
|
maxPayloadBytes: 2048,
|
||||||
|
pluginId: 'e2e.plugin-api',
|
||||||
|
scope: 'server',
|
||||||
|
updatedAt: 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
requirements: [
|
||||||
|
{
|
||||||
|
pluginId: 'e2e.plugin-api',
|
||||||
|
status: 'required',
|
||||||
|
updatedAt: 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
serverId: 'server-1',
|
||||||
|
updatedAt: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'join_server', serverId: 'server-1' });
|
||||||
|
|
||||||
|
const messages = readSentMessages(alice);
|
||||||
|
const pluginRequirements = messages.find((message) => message['type'] === 'plugin_requirements');
|
||||||
|
|
||||||
|
expect(pluginRequirements?.['serverId']).toBe('server-1');
|
||||||
|
expect(pluginRequirements?.['snapshot']).toEqual(expect.objectContaining({ updatedAt: 2 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates and relays plugin events to other joined users', async () => {
|
||||||
|
const alice = createConnectedUser('conn-1', 'alice', { viewedServerId: 'server-1' });
|
||||||
|
const bob = createConnectedUser('conn-2', 'bob', { viewedServerId: 'server-1' });
|
||||||
|
|
||||||
|
alice.serverIds.add('server-1');
|
||||||
|
bob.serverIds.add('server-1');
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', {
|
||||||
|
type: 'plugin_event',
|
||||||
|
eventId: 'event-1',
|
||||||
|
eventName: 'e2e:relay',
|
||||||
|
payload: { ok: true },
|
||||||
|
pluginId: 'e2e.plugin-api',
|
||||||
|
serverId: 'server-1',
|
||||||
|
sourcePluginUserId: 'fixture-user'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pluginSupportMocks.validatePluginEventEnvelope).toHaveBeenCalledWith({
|
||||||
|
type: 'plugin_event',
|
||||||
|
eventId: 'event-1',
|
||||||
|
eventName: 'e2e:relay',
|
||||||
|
payload: { ok: true },
|
||||||
|
pluginId: 'e2e.plugin-api',
|
||||||
|
serverId: 'server-1',
|
||||||
|
sourcePluginUserId: 'fixture-user'
|
||||||
|
});
|
||||||
|
|
||||||
|
const bobMessages = readSentMessages(bob);
|
||||||
|
const relayedEvent = bobMessages.find((message) => message['type'] === 'plugin_event');
|
||||||
|
|
||||||
|
expect(relayedEvent).toEqual(expect.objectContaining({
|
||||||
|
eventId: 'event-1',
|
||||||
|
eventName: 'e2e:relay',
|
||||||
|
pluginId: 'e2e.plugin-api',
|
||||||
|
serverId: 'server-1',
|
||||||
|
sourcePluginUserId: 'fixture-user',
|
||||||
|
sourceUserId: 'alice'
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(typeof relayedEvent?.['emittedAt']).toBe('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns plugin errors for invalid plugin event messages', async () => {
|
||||||
|
const alice = createConnectedUser('conn-1', 'alice');
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', {
|
||||||
|
type: 'plugin_event',
|
||||||
|
eventName: 'e2e:relay',
|
||||||
|
pluginId: 'e2e.plugin-api',
|
||||||
|
serverId: 'server-1'
|
||||||
|
});
|
||||||
|
|
||||||
|
const pluginError = readSentMessages(alice).find((message) => message['type'] === 'plugin_error');
|
||||||
|
|
||||||
|
expect(pluginError).toEqual(expect.objectContaining({
|
||||||
|
code: 'INVALID_PLUGIN_EVENT',
|
||||||
|
eventName: 'e2e:relay',
|
||||||
|
pluginId: 'e2e.plugin-api',
|
||||||
|
serverId: 'server-1'
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(pluginSupportMocks.validatePluginEventEnvelope).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards plugin support validation errors to the sending user', async () => {
|
||||||
|
const alice = createConnectedUser('conn-1', 'alice', { viewedServerId: 'server-1' });
|
||||||
|
|
||||||
|
alice.serverIds.add('server-1');
|
||||||
|
pluginSupportMocks.validatePluginEventEnvelope.mockRejectedValue(new pluginSupportMocks.PluginSupportError(
|
||||||
|
400,
|
||||||
|
'PLUGIN_EVENT_NOT_RELAYABLE',
|
||||||
|
'P2P plugin events must not be relayed by the signal server'
|
||||||
|
));
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', {
|
||||||
|
type: 'plugin_event',
|
||||||
|
eventId: 'event-p2p',
|
||||||
|
eventName: 'e2e:p2p',
|
||||||
|
payload: { hint: true },
|
||||||
|
pluginId: 'e2e.plugin-api',
|
||||||
|
serverId: 'server-1'
|
||||||
|
});
|
||||||
|
|
||||||
|
const pluginError = readSentMessages(alice).find((message) => message['type'] === 'plugin_error');
|
||||||
|
|
||||||
|
expect(pluginError).toEqual(expect.objectContaining({
|
||||||
|
code: 'PLUGIN_EVENT_NOT_RELAYABLE',
|
||||||
|
eventId: 'event-p2p',
|
||||||
|
eventName: 'e2e:p2p',
|
||||||
|
pluginId: 'e2e.plugin-api',
|
||||||
|
serverId: 'server-1'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -67,6 +67,14 @@ describe('server websocket handler - status_update', () => {
|
|||||||
connectedUsers.clear();
|
connectedUsers.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('treats signaling keepalive messages as connection liveness', async () => {
|
||||||
|
createConnectedUser('conn-1', 'user-1', { lastPong: 1 });
|
||||||
|
|
||||||
|
await handleWebSocketMessage('conn-1', { type: 'keepalive' });
|
||||||
|
|
||||||
|
expect(connectedUsers.get('conn-1')?.lastPong).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('updates user status on valid status_update message', async () => {
|
it('updates user status on valid status_update message', async () => {
|
||||||
const user = createConnectedUser('conn-1', 'user-1');
|
const user = createConnectedUser('conn-1', 'user-1');
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import {
|
|||||||
isOderIdConnectedToServer
|
isOderIdConnectedToServer
|
||||||
} from './broadcast';
|
} from './broadcast';
|
||||||
import { authorizeWebSocketJoin } from '../services/server-access.service';
|
import { authorizeWebSocketJoin } from '../services/server-access.service';
|
||||||
|
import {
|
||||||
|
getPluginRequirementsSnapshot,
|
||||||
|
PluginSupportError,
|
||||||
|
validatePluginEventEnvelope
|
||||||
|
} from '../services/plugin-support.service';
|
||||||
|
|
||||||
interface WsMessage {
|
interface WsMessage {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@@ -50,6 +55,29 @@ function readMessageId(value: unknown): string | undefined {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendPluginError(user: ConnectedUser, error: unknown, message: WsMessage): void {
|
||||||
|
if (error instanceof PluginSupportError) {
|
||||||
|
user.ws.send(JSON.stringify({
|
||||||
|
type: 'plugin_error',
|
||||||
|
serverId: typeof message['serverId'] === 'string' ? message['serverId'] : undefined,
|
||||||
|
pluginId: typeof message['pluginId'] === 'string' ? message['pluginId'] : undefined,
|
||||||
|
eventName: typeof message['eventName'] === 'string' ? message['eventName'] : undefined,
|
||||||
|
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
|
||||||
|
code: error.code,
|
||||||
|
message: error.message
|
||||||
|
}));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Unhandled plugin websocket error:', error);
|
||||||
|
user.ws.send(JSON.stringify({
|
||||||
|
type: 'plugin_error',
|
||||||
|
code: 'INTERNAL_ERROR',
|
||||||
|
message: 'Internal server error'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/** Sends the current user list for a given server to a single connected user. */
|
/** Sends the current user list for a given server to a single connected user. */
|
||||||
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
||||||
const users = getUniqueUsersInServer(serverId, user.oderId)
|
const users = getUniqueUsersInServer(serverId, user.oderId)
|
||||||
@@ -64,6 +92,20 @@ function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
|||||||
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendPluginRequirements(user: ConnectedUser, serverId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const snapshot = await getPluginRequirementsSnapshot(serverId);
|
||||||
|
|
||||||
|
user.ws.send(JSON.stringify({
|
||||||
|
type: 'plugin_requirements',
|
||||||
|
serverId,
|
||||||
|
snapshot
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
sendPluginError(user, error, { type: 'plugin_requirements', serverId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
const newOderId = readMessageId(message['oderId']) ?? connectionId;
|
const newOderId = readMessageId(message['oderId']) ?? connectionId;
|
||||||
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
|
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
|
||||||
@@ -71,25 +113,6 @@ function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: s
|
|||||||
const previousDescription = user.description;
|
const previousDescription = user.description;
|
||||||
const previousProfileUpdatedAt = user.profileUpdatedAt;
|
const previousProfileUpdatedAt = user.profileUpdatedAt;
|
||||||
|
|
||||||
// Close stale connections from the same identity AND the same connection
|
|
||||||
// scope so offer routing always targets the freshest socket (e.g. after
|
|
||||||
// page refresh). Connections with a *different* scope (= a different
|
|
||||||
// signal URL that happens to route to this server) are left untouched so
|
|
||||||
// multi-signal-URL setups don't trigger an eviction loop.
|
|
||||||
connectedUsers.forEach((existing, existingId) => {
|
|
||||||
if (existingId !== connectionId
|
|
||||||
&& existing.oderId === newOderId
|
|
||||||
&& existing.connectionScope === newScope) {
|
|
||||||
console.log(`Closing stale connection for ${newOderId} (old=${existingId}, new=${connectionId}, scope=${newScope ?? 'none'})`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
existing.ws.close();
|
|
||||||
} catch { /* already closing */ }
|
|
||||||
|
|
||||||
connectedUsers.delete(existingId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
user.oderId = newOderId;
|
user.oderId = newOderId;
|
||||||
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
||||||
|
|
||||||
@@ -156,6 +179,7 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
|||||||
);
|
);
|
||||||
|
|
||||||
sendServerUsers(user, sid);
|
sendServerUsers(user, sid);
|
||||||
|
await sendPluginRequirements(user, sid);
|
||||||
|
|
||||||
if (isNewIdentityMembership) {
|
if (isNewIdentityMembership) {
|
||||||
broadcastToServer(sid, {
|
broadcastToServer(sid, {
|
||||||
@@ -170,17 +194,22 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
async function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||||
const viewSid = readMessageId(message['serverId']);
|
const viewSid = readMessageId(message['serverId']);
|
||||||
|
|
||||||
if (!viewSid)
|
if (!viewSid)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (!user.serverIds.has(viewSid)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
user.viewedServerId = viewSid;
|
user.viewedServerId = viewSid;
|
||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) viewing server ${viewSid}`);
|
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) viewing server ${viewSid}`);
|
||||||
|
|
||||||
sendServerUsers(user, viewSid);
|
sendServerUsers(user, viewSid);
|
||||||
|
await sendPluginRequirements(user, viewSid);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
@@ -287,13 +316,65 @@ function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionI
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promise<void> {
|
||||||
|
const serverId = readMessageId(message['serverId']) ?? user.viewedServerId;
|
||||||
|
const pluginId = readMessageId(message['pluginId']);
|
||||||
|
const eventName = readMessageId(message['eventName']);
|
||||||
|
|
||||||
|
if (!serverId || !pluginId || !eventName || !user.serverIds.has(serverId)) {
|
||||||
|
user.ws.send(JSON.stringify({
|
||||||
|
type: 'plugin_error',
|
||||||
|
serverId,
|
||||||
|
pluginId,
|
||||||
|
eventName,
|
||||||
|
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
|
||||||
|
code: 'INVALID_PLUGIN_EVENT',
|
||||||
|
message: 'Plugin event is missing required fields or server membership'
|
||||||
|
}));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await validatePluginEventEnvelope({
|
||||||
|
type: 'plugin_event',
|
||||||
|
serverId,
|
||||||
|
pluginId,
|
||||||
|
eventName,
|
||||||
|
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
|
||||||
|
payload: message['payload'],
|
||||||
|
sourcePluginUserId: typeof message['sourcePluginUserId'] === 'string' ? message['sourcePluginUserId'] : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
broadcastToServer(serverId, {
|
||||||
|
type: 'plugin_event',
|
||||||
|
serverId,
|
||||||
|
pluginId,
|
||||||
|
eventName,
|
||||||
|
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
|
||||||
|
payload: message['payload'],
|
||||||
|
sourcePluginUserId: typeof message['sourcePluginUserId'] === 'string' ? message['sourcePluginUserId'] : undefined,
|
||||||
|
sourceUserId: user.oderId,
|
||||||
|
emittedAt: Date.now()
|
||||||
|
}, user.oderId);
|
||||||
|
} catch (error) {
|
||||||
|
sendPluginError(user, error, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
|
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
|
||||||
const user = connectedUsers.get(connectionId);
|
const user = connectedUsers.get(connectionId);
|
||||||
|
|
||||||
if (!user)
|
if (!user)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
user.lastPong = Date.now();
|
||||||
|
connectedUsers.set(connectionId, user);
|
||||||
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
|
case 'keepalive':
|
||||||
|
break;
|
||||||
|
|
||||||
case 'identify':
|
case 'identify':
|
||||||
handleIdentify(user, message, connectionId);
|
handleIdentify(user, message, connectionId);
|
||||||
break;
|
break;
|
||||||
@@ -303,7 +384,7 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'view_server':
|
case 'view_server':
|
||||||
handleViewServer(user, message, connectionId);
|
await handleViewServer(user, message, connectionId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'leave_server':
|
case 'leave_server':
|
||||||
@@ -328,6 +409,10 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
|||||||
handleStatusUpdate(user, message, connectionId);
|
handleStatusUpdate(user, message, connectionId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'plugin_event':
|
||||||
|
await handlePluginEvent(user, message);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.log('Unknown message type:', message.type);
|
console.log('Unknown message type:', message.type);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
} from './broadcast';
|
} from './broadcast';
|
||||||
import { handleWebSocketMessage } from './handler';
|
import { handleWebSocketMessage } from './handler';
|
||||||
|
|
||||||
|
type IncomingWebSocketMessage = Parameters<typeof handleWebSocketMessage>[1];
|
||||||
|
|
||||||
/** How often to ping all connected clients (ms). */
|
/** How often to ping all connected clients (ms). */
|
||||||
const PING_INTERVAL_MS = 30_000;
|
const PING_INTERVAL_MS = 30_000;
|
||||||
/** Maximum time a client can go without a pong before we consider it dead (ms). */
|
/** Maximum time a client can go without a pong before we consider it dead (ms). */
|
||||||
@@ -89,12 +91,20 @@ export function setupWebSocket(server: Server<typeof IncomingMessage, typeof Ser
|
|||||||
});
|
});
|
||||||
|
|
||||||
ws.on('message', async (data) => {
|
ws.on('message', async (data) => {
|
||||||
try {
|
let message: IncomingWebSocketMessage;
|
||||||
const message = JSON.parse(data.toString());
|
|
||||||
|
|
||||||
await handleWebSocketMessage(connectionId, message);
|
try {
|
||||||
|
message = JSON.parse(data.toString()) as IncomingWebSocketMessage;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Invalid WebSocket message:', err);
|
console.error('Invalid WebSocket message:', err);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleWebSocketMessage(connectionId, message);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('WebSocket message handler failed:', err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,6 @@ export interface ConnectedUser {
|
|||||||
connectionScope?: string;
|
connectionScope?: string;
|
||||||
/** User availability status (online, away, busy, offline). */
|
/** User availability status (online, away, busy, offline). */
|
||||||
status?: 'online' | 'away' | 'busy' | 'offline';
|
status?: 'online' | 'away' | 'busy' | 'offline';
|
||||||
/** Timestamp of the last pong received (used to detect dead connections). */
|
/** Timestamp of the last pong or client message received (used to detect dead connections). */
|
||||||
lastPong: number;
|
lastPong: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,13 +96,13 @@
|
|||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "2.2MB",
|
"maximumWarning": "10mb",
|
||||||
"maximumError": "2.35MB"
|
"maximumError": "20mb"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
"maximumWarning": "4kB",
|
"maximumWarning": "10mb",
|
||||||
"maximumError": "8kB"
|
"maximumError": "20mb"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all"
|
||||||
|
|||||||
3
toju-app/public/plugins/e2e-all-api/README.md
Normal file
3
toju-app/public/plugins/e2e-all-api/README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# E2E All API Plugin
|
||||||
|
|
||||||
|
Fixture plugin for Playwright coverage. It calls every public Toju plugin API surface, registers UI contributions, writes storage, publishes events, creates plugin user data, and logs completion.
|
||||||
6
toju-app/public/plugins/e2e-all-api/icon.svg
Normal file
6
toju-app/public/plugins/e2e-all-api/icon.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="E2E plugin icon">
|
||||||
|
<rect width="64" height="64" rx="12" fill="#111827" />
|
||||||
|
<path d="M18 22h28v20H18z" fill="#38bdf8" />
|
||||||
|
<path d="M24 16h16v6H24zM24 42h16v6H24z" fill="#a7f3d0" />
|
||||||
|
<path d="M25 30h14v4H25z" fill="#111827" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 319 B |
293
toju-app/public/plugins/e2e-all-api/main.js
Normal file
293
toju-app/public/plugins/e2e-all-api/main.js
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
const tinyWave = 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAESsAACJWAAACABAAZGF0YQAAAAA=';
|
||||||
|
const originalMessage = 'Plugin API original message';
|
||||||
|
const editedMessage = 'Plugin API edited message';
|
||||||
|
const deletedMessage = 'Plugin API deleted message';
|
||||||
|
const embedMessage = 'toju:embed:e2e.coverage:{"title":"Plugin API custom embed","body":"Rendered by plugin API"}';
|
||||||
|
const soundboardPlayedMessage = 'E2E soundboard played Airhorn to voice channel';
|
||||||
|
|
||||||
|
export async function activate(context) {
|
||||||
|
const api = context.api;
|
||||||
|
const currentUser = api.profile.getCurrent();
|
||||||
|
const shouldMutateChat = !currentUser?.displayName?.includes('Bob');
|
||||||
|
const pluginUserId = api.server.registerPluginUser({
|
||||||
|
displayName: 'E2E Plugin Bot',
|
||||||
|
id: 'e2e-plugin-bot'
|
||||||
|
});
|
||||||
|
|
||||||
|
context.subscriptions.push(api.ui.registerSettingsPage('coverage', {
|
||||||
|
label: 'E2E Coverage',
|
||||||
|
render: () => 'E2E settings contribution'
|
||||||
|
}));
|
||||||
|
context.subscriptions.push(api.ui.registerAppPage('coverage', {
|
||||||
|
label: 'E2E Page',
|
||||||
|
path: '/plugins/e2e/coverage',
|
||||||
|
render: () => 'E2E page contribution'
|
||||||
|
}));
|
||||||
|
context.subscriptions.push(api.ui.registerSidePanel('coverage', {
|
||||||
|
label: 'E2E Soundboard',
|
||||||
|
render: () => 'E2E soundboard ready'
|
||||||
|
}));
|
||||||
|
context.subscriptions.push(api.ui.registerChannelSection('coverage', {
|
||||||
|
label: 'E2E Soundboard',
|
||||||
|
type: 'custom'
|
||||||
|
}));
|
||||||
|
context.subscriptions.push(api.ui.registerComposerAction('coverage', {
|
||||||
|
icon: 'SFX',
|
||||||
|
label: 'E2E Soundboard',
|
||||||
|
run: () => openSoundboardModal(api, pluginUserId)
|
||||||
|
}));
|
||||||
|
context.subscriptions.push(api.ui.registerProfileAction('coverage', {
|
||||||
|
label: 'E2E Profile',
|
||||||
|
run: () => api.logger.info('profile action ran')
|
||||||
|
}));
|
||||||
|
context.subscriptions.push(api.ui.registerToolbarAction('coverage', {
|
||||||
|
label: 'E2E Toolbar',
|
||||||
|
run: () => api.logger.info('toolbar action ran')
|
||||||
|
}));
|
||||||
|
context.subscriptions.push(api.ui.registerEmbedRenderer('coverage', {
|
||||||
|
embedType: 'e2e.coverage',
|
||||||
|
render: (payload) => `E2E custom embed: ${payload?.title ?? 'missing title'}`
|
||||||
|
}));
|
||||||
|
|
||||||
|
const injectedBadge = document.createElement('div');
|
||||||
|
|
||||||
|
injectedBadge.dataset.testid = 'e2e-plugin-owned-dom';
|
||||||
|
injectedBadge.textContent = 'E2E plugin-owned DOM injected into chat';
|
||||||
|
injectedBadge.style.position = 'absolute';
|
||||||
|
injectedBadge.style.left = '1rem';
|
||||||
|
injectedBadge.style.bottom = '5.5rem';
|
||||||
|
injectedBadge.style.zIndex = '20';
|
||||||
|
injectedBadge.style.border = '1px solid hsl(var(--border))';
|
||||||
|
injectedBadge.style.borderRadius = '0.5rem';
|
||||||
|
injectedBadge.style.padding = '0.35rem 0.5rem';
|
||||||
|
injectedBadge.style.background = 'hsl(var(--card))';
|
||||||
|
injectedBadge.style.color = 'hsl(var(--foreground))';
|
||||||
|
injectedBadge.style.fontSize = '0.75rem';
|
||||||
|
context.subscriptions.push(api.ui.mountElement('chat-owned-badge', {
|
||||||
|
element: injectedBadge,
|
||||||
|
target: 'app-chat-messages'
|
||||||
|
}));
|
||||||
|
|
||||||
|
context.subscriptions.push(api.events.subscribeServer({ eventName: 'e2e:server', handler: () => {} }));
|
||||||
|
context.subscriptions.push(api.events.subscribeP2p({ eventName: 'e2e:p2p', handler: () => {} }));
|
||||||
|
|
||||||
|
api.storage.set('coverage', { ok: true });
|
||||||
|
api.storage.get('coverage');
|
||||||
|
await api.clientData.write('coverage', { ok: true });
|
||||||
|
await api.clientData.read('coverage');
|
||||||
|
await api.serverData.write('coverage', { ok: true });
|
||||||
|
await api.serverData.read('coverage');
|
||||||
|
|
||||||
|
api.profile.update({
|
||||||
|
description: 'Updated by E2E plugin',
|
||||||
|
displayName: `${currentUser?.displayName || 'E2E Plugin User'} Plugin Renamed`
|
||||||
|
});
|
||||||
|
api.profile.updateAvatar({
|
||||||
|
avatarHash: 'e2e-plugin-avatar',
|
||||||
|
avatarMime: 'image/svg+xml',
|
||||||
|
avatarUrl: '/plugins/e2e-all-api/icon.svg'
|
||||||
|
});
|
||||||
|
|
||||||
|
api.users.getCurrent();
|
||||||
|
api.users.list();
|
||||||
|
api.users.readMembers();
|
||||||
|
api.users.setRole(pluginUserId, 'member');
|
||||||
|
api.users.kick(pluginUserId);
|
||||||
|
api.users.ban(pluginUserId, 'E2E coverage');
|
||||||
|
|
||||||
|
api.roles.list();
|
||||||
|
api.roles.setAssignments([]);
|
||||||
|
|
||||||
|
api.channels.list();
|
||||||
|
api.channels.addAudioChannel({ id: 'e2e-audio', name: 'E2E Audio', position: 90 });
|
||||||
|
api.channels.addVideoChannel({ id: 'e2e-video', name: 'E2E Video', position: 91 });
|
||||||
|
api.channels.select('general');
|
||||||
|
api.channels.rename('e2e-audio', 'E2E Audio Renamed');
|
||||||
|
|
||||||
|
api.server.getCurrent();
|
||||||
|
api.server.updatePermissions({ allowVoice: true });
|
||||||
|
api.server.updateSettings({
|
||||||
|
name: api.server.getCurrent()?.name,
|
||||||
|
topic: 'Updated by E2E plugin'
|
||||||
|
});
|
||||||
|
|
||||||
|
api.messages.readCurrent();
|
||||||
|
if (shouldMutateChat) {
|
||||||
|
const sentMessage = api.messages.send(originalMessage);
|
||||||
|
|
||||||
|
api.messages.edit(sentMessage.id, editedMessage);
|
||||||
|
|
||||||
|
const removableMessage = api.messages.send(deletedMessage);
|
||||||
|
|
||||||
|
api.messages.delete(removableMessage.id);
|
||||||
|
api.messages.send(embedMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
api.messages.sendAsPluginUser({
|
||||||
|
content: 'Plugin bot message from all-api fixture',
|
||||||
|
pluginUserId
|
||||||
|
});
|
||||||
|
api.messages.moderateDelete('missing-message-id');
|
||||||
|
api.messages.sync(api.messages.readCurrent());
|
||||||
|
context.subscriptions.push(api.messageBus.subscribe({
|
||||||
|
handler: () => {},
|
||||||
|
latestMessageLimit: 5,
|
||||||
|
replayLatest: true,
|
||||||
|
topic: 'e2e:latest'
|
||||||
|
}));
|
||||||
|
api.messageBus.publish({
|
||||||
|
includeLatestMessages: true,
|
||||||
|
includeSelf: true,
|
||||||
|
latestMessageLimit: 5,
|
||||||
|
payload: { ok: true },
|
||||||
|
topic: 'e2e:latest'
|
||||||
|
});
|
||||||
|
api.messageBus.sendLatestMessages({
|
||||||
|
limit: 5,
|
||||||
|
topic: 'e2e:latest'
|
||||||
|
});
|
||||||
|
|
||||||
|
api.p2p.connectedPeers();
|
||||||
|
api.p2p.broadcastData('e2e:p2p', { ok: true });
|
||||||
|
api.p2p.sendData('missing-peer', 'e2e:p2p', { ok: true });
|
||||||
|
api.events.publishServer('e2e:server', { ok: true });
|
||||||
|
api.events.publishP2p('e2e:p2p', { ok: true });
|
||||||
|
|
||||||
|
api.media.setOutputVolume(0.8);
|
||||||
|
api.media.setInputVolume(0.8);
|
||||||
|
await api.media.playAudioClip({ url: tinyWave, volume: 0 }).catch((error) => api.logger.warn('audio clip rejected', String(error)));
|
||||||
|
await api.media.addCustomVideoStream({ label: 'e2e-video', stream: new MediaStream() });
|
||||||
|
|
||||||
|
const audioContext = new AudioContext();
|
||||||
|
const destination = audioContext.createMediaStreamDestination();
|
||||||
|
|
||||||
|
await api.media.addCustomAudioStream({ label: 'e2e-audio', stream: destination.stream }).catch((error) => api.logger.warn('audio stream rejected', String(error)));
|
||||||
|
await audioContext.close();
|
||||||
|
|
||||||
|
api.storage.remove('coverage');
|
||||||
|
await api.clientData.remove('coverage');
|
||||||
|
await api.serverData.remove('coverage');
|
||||||
|
api.logger.info('all-api plugin completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ready(context) {
|
||||||
|
context.api.logger.info('all-api plugin ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deactivate(context) {
|
||||||
|
context.api.logger.info('all-api plugin deactivated');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSoundboardModal(api, pluginUserId) {
|
||||||
|
document.querySelector('[data-testid="e2e-soundboard-modal"]')?.remove();
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
|
||||||
|
overlay.dataset.testid = 'e2e-soundboard-modal';
|
||||||
|
overlay.setAttribute('role', 'dialog');
|
||||||
|
overlay.setAttribute('aria-modal', 'true');
|
||||||
|
overlay.setAttribute('aria-label', 'E2E Soundboard');
|
||||||
|
overlay.style.position = 'fixed';
|
||||||
|
overlay.style.inset = '0';
|
||||||
|
overlay.style.zIndex = '9999';
|
||||||
|
overlay.style.display = 'grid';
|
||||||
|
overlay.style.placeItems = 'center';
|
||||||
|
overlay.style.background = 'rgb(0 0 0 / 0.45)';
|
||||||
|
|
||||||
|
const panel = document.createElement('section');
|
||||||
|
|
||||||
|
panel.style.width = 'min(24rem, calc(100vw - 2rem))';
|
||||||
|
panel.style.border = '1px solid hsl(var(--border))';
|
||||||
|
panel.style.borderRadius = '0.5rem';
|
||||||
|
panel.style.padding = '1rem';
|
||||||
|
panel.style.color = 'hsl(var(--foreground))';
|
||||||
|
panel.style.background = 'hsl(var(--card))';
|
||||||
|
panel.style.boxShadow = '0 1.25rem 3rem rgb(0 0 0 / 0.25)';
|
||||||
|
|
||||||
|
const title = document.createElement('h2');
|
||||||
|
|
||||||
|
title.textContent = 'E2E Soundboard';
|
||||||
|
title.style.margin = '0 0 0.75rem';
|
||||||
|
title.style.fontSize = '1rem';
|
||||||
|
|
||||||
|
const status = document.createElement('p');
|
||||||
|
|
||||||
|
status.dataset.testid = 'e2e-soundboard-status';
|
||||||
|
status.textContent = 'Ready to play to voice channel';
|
||||||
|
status.style.margin = '0 0 1rem';
|
||||||
|
status.style.color = 'hsl(var(--muted-foreground))';
|
||||||
|
status.style.fontSize = '0.875rem';
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
|
||||||
|
actions.style.display = 'flex';
|
||||||
|
actions.style.gap = '0.5rem';
|
||||||
|
actions.style.justifyContent = 'flex-end';
|
||||||
|
|
||||||
|
const closeButton = document.createElement('button');
|
||||||
|
|
||||||
|
closeButton.type = 'button';
|
||||||
|
closeButton.textContent = 'Close';
|
||||||
|
closeButton.style.border = '1px solid hsl(var(--border))';
|
||||||
|
closeButton.style.borderRadius = '0.375rem';
|
||||||
|
closeButton.style.padding = '0.5rem 0.75rem';
|
||||||
|
closeButton.style.background = 'transparent';
|
||||||
|
closeButton.style.color = 'hsl(var(--foreground))';
|
||||||
|
closeButton.addEventListener('click', () => overlay.remove());
|
||||||
|
|
||||||
|
const playButton = document.createElement('button');
|
||||||
|
|
||||||
|
playButton.type = 'button';
|
||||||
|
playButton.textContent = 'Play airhorn to voice';
|
||||||
|
playButton.style.border = '0';
|
||||||
|
playButton.style.borderRadius = '0.375rem';
|
||||||
|
playButton.style.padding = '0.5rem 0.75rem';
|
||||||
|
playButton.style.background = 'hsl(var(--primary))';
|
||||||
|
playButton.style.color = 'hsl(var(--primary-foreground))';
|
||||||
|
playButton.addEventListener('click', async () => {
|
||||||
|
playButton.disabled = true;
|
||||||
|
status.textContent = 'Playing Airhorn to voice channel';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await playSoundboardClipToVoice(api);
|
||||||
|
api.p2p.broadcastData('e2e:p2p', { sound: 'airhorn', source: 'soundboard' });
|
||||||
|
api.events.publishP2p('e2e:p2p', { sound: 'airhorn', source: 'soundboard' });
|
||||||
|
api.messages.sendAsPluginUser({ content: soundboardPlayedMessage, pluginUserId });
|
||||||
|
api.logger.info('soundboard played to voice channel');
|
||||||
|
status.textContent = soundboardPlayedMessage;
|
||||||
|
} catch (error) {
|
||||||
|
status.textContent = error instanceof Error ? error.message : 'Soundboard playback failed';
|
||||||
|
api.logger.warn('soundboard playback failed', String(error));
|
||||||
|
} finally {
|
||||||
|
playButton.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
actions.append(closeButton, playButton);
|
||||||
|
panel.append(title, status, actions);
|
||||||
|
overlay.append(panel);
|
||||||
|
api.ui.mountElement('soundboard-modal', {
|
||||||
|
element: overlay,
|
||||||
|
target: 'body'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playSoundboardClipToVoice(api) {
|
||||||
|
const audioContext = new AudioContext();
|
||||||
|
const oscillator = audioContext.createOscillator();
|
||||||
|
const gain = audioContext.createGain();
|
||||||
|
const destination = audioContext.createMediaStreamDestination();
|
||||||
|
|
||||||
|
oscillator.type = 'square';
|
||||||
|
oscillator.frequency.value = 330;
|
||||||
|
gain.gain.value = 0.08;
|
||||||
|
oscillator.connect(gain);
|
||||||
|
gain.connect(destination);
|
||||||
|
oscillator.start();
|
||||||
|
|
||||||
|
await api.media.addCustomAudioStream({ label: 'e2e-soundboard-airhorn', stream: destination.stream });
|
||||||
|
await api.media.playAudioClip({ url: tinyWave, volume: 0 }).catch((error) => api.logger.warn('soundboard preview rejected', String(error)));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
oscillator.stop();
|
||||||
|
await audioContext.close();
|
||||||
|
}
|
||||||
100
toju-app/public/plugins/e2e-all-api/toju.plugin.json
Normal file
100
toju-app/public/plugins/e2e-all-api/toju.plugin.json
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "e2e.all-api-plugin",
|
||||||
|
"title": "E2E All API Plugin",
|
||||||
|
"description": "Calls every public Toju plugin API surface for user-facing Playwright coverage.",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"kind": "client",
|
||||||
|
"scope": "server",
|
||||||
|
"apiVersion": "1.0.0",
|
||||||
|
"compatibility": {
|
||||||
|
"minimumTojuVersion": "1.0.0",
|
||||||
|
"verifiedTojuVersion": "1.0.0"
|
||||||
|
},
|
||||||
|
"entrypoint": "./main.js",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "MetoYou Tests",
|
||||||
|
"url": "https://git.azaaxin.com/myxelium/Toju"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"homepage": "https://git.azaaxin.com/myxelium/Toju",
|
||||||
|
"readme": "./README.md",
|
||||||
|
"capabilities": [
|
||||||
|
"profile.read",
|
||||||
|
"profile.write",
|
||||||
|
"users.read",
|
||||||
|
"users.manage",
|
||||||
|
"roles.read",
|
||||||
|
"roles.manage",
|
||||||
|
"messages.read",
|
||||||
|
"messages.send",
|
||||||
|
"messages.editOwn",
|
||||||
|
"messages.deleteOwn",
|
||||||
|
"messages.moderate",
|
||||||
|
"messages.sync",
|
||||||
|
"channels.read",
|
||||||
|
"channels.manage",
|
||||||
|
"server.read",
|
||||||
|
"server.manage",
|
||||||
|
"p2p.data",
|
||||||
|
"p2p.media",
|
||||||
|
"media.playAudio",
|
||||||
|
"media.addAudioStream",
|
||||||
|
"media.addVideoStream",
|
||||||
|
"audio.volume",
|
||||||
|
"audio.effects",
|
||||||
|
"ui.settings",
|
||||||
|
"ui.pages",
|
||||||
|
"ui.sidePanel",
|
||||||
|
"ui.channelsSection",
|
||||||
|
"ui.embeds",
|
||||||
|
"ui.dom",
|
||||||
|
"storage.local",
|
||||||
|
"storage.serverData.read",
|
||||||
|
"storage.serverData.write",
|
||||||
|
"events.server.publish",
|
||||||
|
"events.server.subscribe",
|
||||||
|
"events.p2p.publish",
|
||||||
|
"events.p2p.subscribe"
|
||||||
|
],
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"eventName": "e2e:server",
|
||||||
|
"direction": "serverRelay",
|
||||||
|
"scope": "server",
|
||||||
|
"maxPayloadBytes": 2048
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"eventName": "e2e:p2p",
|
||||||
|
"direction": "p2pHint",
|
||||||
|
"scope": "user",
|
||||||
|
"maxPayloadBytes": 2048
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"key": "coverage",
|
||||||
|
"scope": "server",
|
||||||
|
"storage": "serverData"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"settingsPages": ["coverage"],
|
||||||
|
"sidePanels": ["coverage"],
|
||||||
|
"channelSections": ["coverage"]
|
||||||
|
},
|
||||||
|
"pluginUser": {
|
||||||
|
"displayName": "E2E Plugin Bot",
|
||||||
|
"label": "All API fixture"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
toju-app/public/plugins/e2e-plugin-source.json
Normal file
18
toju-app/public/plugins/e2e-plugin-source.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"title": "MetoYou E2E Plugin Source",
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"id": "e2e.all-api-plugin",
|
||||||
|
"title": "E2E All API Plugin",
|
||||||
|
"description": "Test plugin that calls every public Toju plugin API surface.",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scope": "server",
|
||||||
|
"author": "MetoYou Tests",
|
||||||
|
"image": "./e2e-all-api/icon.svg",
|
||||||
|
"github": "https://git.azaaxin.com/myxelium/Toju",
|
||||||
|
"homepage": "https://git.azaaxin.com/myxelium/Toju",
|
||||||
|
"install": "./e2e-all-api/toju.plugin.json",
|
||||||
|
"readme": "./e2e-all-api/README.md"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -145,7 +145,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @if (!isThemeStudioFullscreen()) {
|
} @if (!isThemeStudioFullscreen() && !isDirectMessageRoute()) {
|
||||||
<app-floating-voice-controls />
|
<app-floating-voice-controls />
|
||||||
}
|
}
|
||||||
<app-settings-modal />
|
<app-settings-modal />
|
||||||
|
|||||||
@@ -34,9 +34,29 @@ export const routes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/room/chat-room/chat-room.component').then((module) => module.ChatRoomComponent)
|
import('./features/room/chat-room/chat-room.component').then((module) => module.ChatRoomComponent)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'dm',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./domains/direct-message/feature/dm-workspace/dm-workspace.component').then((module) => module.DmWorkspaceComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dm/:conversationId',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./domains/direct-message/feature/dm-workspace/dm-workspace.component').then((module) => module.DmWorkspaceComponent)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/settings/settings.component').then((module) => module.SettingsComponent)
|
import('./features/settings/settings.component').then((module) => module.SettingsComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'plugin-store',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./domains/plugins/feature/plugin-store/plugin-store.component').then((module) => module.PluginStoreComponent)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'plugins/:pluginId/:pageId',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./domains/plugins/feature/plugin-page-host/plugin-page-host.component').then((module) => module.PluginPageHostComponent)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { ExternalLinkService } from './core/platform';
|
|||||||
import { SettingsModalService } from './core/services/settings-modal.service';
|
import { SettingsModalService } from './core/services/settings-modal.service';
|
||||||
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
|
||||||
import { UserStatusService } from './core/services/user-status.service';
|
import { UserStatusService } from './core/services/user-status.service';
|
||||||
|
import { GameActivityService } from './domains/game-activity';
|
||||||
import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component';
|
import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component';
|
||||||
import { TitleBarComponent } from './features/shell/title-bar/title-bar.component';
|
import { TitleBarComponent } from './features/shell/title-bar/title-bar.component';
|
||||||
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
|
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
|
||||||
@@ -45,10 +46,7 @@ import { UsersActions } from './store/users/users.actions';
|
|||||||
import { RoomsActions } from './store/rooms/rooms.actions';
|
import { RoomsActions } from './store/rooms/rooms.actions';
|
||||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||||
import { ROOM_URL_PATTERN } from './core/constants';
|
import { ROOM_URL_PATTERN } from './core/constants';
|
||||||
import {
|
import { clearStoredCurrentUserId, getStoredCurrentUserId } from './core/storage/current-user-storage';
|
||||||
clearStoredCurrentUserId,
|
|
||||||
getStoredCurrentUserId
|
|
||||||
} from './core/storage/current-user-storage';
|
|
||||||
import {
|
import {
|
||||||
ThemeNodeDirective,
|
ThemeNodeDirective,
|
||||||
ThemePickerOverlayComponent,
|
ThemePickerOverlayComponent,
|
||||||
@@ -98,10 +96,12 @@ export class App implements OnInit, OnDestroy {
|
|||||||
readonly externalLinks = inject(ExternalLinkService);
|
readonly externalLinks = inject(ExternalLinkService);
|
||||||
readonly electronBridge = inject(ElectronBridgeService);
|
readonly electronBridge = inject(ElectronBridgeService);
|
||||||
readonly userStatus = inject(UserStatusService);
|
readonly userStatus = inject(UserStatusService);
|
||||||
|
readonly gameActivity = inject(GameActivityService);
|
||||||
readonly dismissedDesktopUpdateNoticeKey = signal<string | null>(null);
|
readonly dismissedDesktopUpdateNoticeKey = signal<string | null>(null);
|
||||||
readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null);
|
readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null);
|
||||||
readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null);
|
readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null);
|
||||||
readonly isDraggingThemeStudioControls = signal(false);
|
readonly isDraggingThemeStudioControls = signal(false);
|
||||||
|
readonly currentRouteUrl = signal(this.getCurrentRouteUrl());
|
||||||
|
|
||||||
readonly appShellLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('appShell'));
|
readonly appShellLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('appShell'));
|
||||||
readonly serversRailLayoutStyles = computed(() => this.theme.getLayoutItemStyles('serversRail'));
|
readonly serversRailLayoutStyles = computed(() => this.theme.getLayoutItemStyles('serversRail'));
|
||||||
@@ -115,6 +115,7 @@ export class App implements OnInit, OnDestroy {
|
|||||||
return this.settingsModal.activePage() === 'theme'
|
return this.settingsModal.activePage() === 'theme'
|
||||||
&& this.settingsModal.themeStudioMinimized();
|
&& this.settingsModal.themeStudioMinimized();
|
||||||
});
|
});
|
||||||
|
readonly isDirectMessageRoute = computed(() => this.getRoutePath(this.currentRouteUrl()).startsWith('/dm'));
|
||||||
readonly desktopUpdateNoticeKey = computed(() => {
|
readonly desktopUpdateNoticeKey = computed(() => {
|
||||||
const updateState = this.desktopUpdateState();
|
const updateState = this.desktopUpdateState();
|
||||||
|
|
||||||
@@ -247,6 +248,7 @@ export class App implements OnInit, OnDestroy {
|
|||||||
await this.setupDesktopDeepLinks();
|
await this.setupDesktopDeepLinks();
|
||||||
|
|
||||||
this.userStatus.start();
|
this.userStatus.start();
|
||||||
|
this.gameActivity.start();
|
||||||
const currentUrl = this.getCurrentRouteUrl();
|
const currentUrl = this.getCurrentRouteUrl();
|
||||||
|
|
||||||
if (!currentUserId) {
|
if (!currentUserId) {
|
||||||
@@ -276,6 +278,8 @@ export class App implements OnInit, OnDestroy {
|
|||||||
this.router.events.subscribe((evt) => {
|
this.router.events.subscribe((evt) => {
|
||||||
if (evt instanceof NavigationEnd) {
|
if (evt instanceof NavigationEnd) {
|
||||||
const url = evt.urlAfterRedirects || evt.url;
|
const url = evt.urlAfterRedirects || evt.url;
|
||||||
|
|
||||||
|
this.currentRouteUrl.set(url);
|
||||||
const roomMatch = url.match(ROOM_URL_PATTERN);
|
const roomMatch = url.match(ROOM_URL_PATTERN);
|
||||||
const currentRoomId = roomMatch ? roomMatch[1] : null;
|
const currentRoomId = roomMatch ? roomMatch[1] : null;
|
||||||
|
|
||||||
|
|||||||
@@ -49,4 +49,20 @@ export type {
|
|||||||
ChatAttachmentMeta
|
ChatAttachmentMeta
|
||||||
} from '../../shared-kernel';
|
} from '../../shared-kernel';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
PluginCapabilityId,
|
||||||
|
PluginDataChangedMessage,
|
||||||
|
PluginErrorMessage,
|
||||||
|
PluginEventDefinitionSummary,
|
||||||
|
PluginEventDirection,
|
||||||
|
PluginEventEnvelope,
|
||||||
|
PluginEventScope,
|
||||||
|
PluginRequirementStatus,
|
||||||
|
PluginRequirementSummary,
|
||||||
|
PluginRequirementsChangedMessage,
|
||||||
|
PluginRequirementsMessage,
|
||||||
|
PluginRequirementsSnapshot,
|
||||||
|
TojuPluginManifest
|
||||||
|
} from '../../shared-kernel';
|
||||||
|
|
||||||
export type { ServerInfo } from '../../domains/server-directory';
|
export type { ServerInfo } from '../../domains/server-directory';
|
||||||
|
|||||||
@@ -124,6 +124,46 @@ export interface SavedThemeFileDescriptor {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LocalPluginManifestDescriptor {
|
||||||
|
discoveredAt: number;
|
||||||
|
entrypointPath?: string;
|
||||||
|
pluginRootUrl: string;
|
||||||
|
manifest: unknown;
|
||||||
|
manifestPath: string;
|
||||||
|
pluginRoot: string;
|
||||||
|
readmePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalPluginDiscoveryError {
|
||||||
|
manifestPath?: string;
|
||||||
|
message: string;
|
||||||
|
pluginRoot?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalPluginDiscoveryResult {
|
||||||
|
errors: LocalPluginDiscoveryError[];
|
||||||
|
plugins: LocalPluginManifestDescriptor[];
|
||||||
|
pluginsPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportUserDataResult {
|
||||||
|
cancelled: boolean;
|
||||||
|
exported: boolean;
|
||||||
|
filePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportUserDataResult {
|
||||||
|
backupPath?: string;
|
||||||
|
cancelled: boolean;
|
||||||
|
imported: boolean;
|
||||||
|
restartRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EraseUserDataResult {
|
||||||
|
erased: boolean;
|
||||||
|
restartRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElectronCommand {
|
export interface ElectronCommand {
|
||||||
type: string;
|
type: string;
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
@@ -157,6 +197,7 @@ export interface ElectronApi {
|
|||||||
closeWindow: () => void;
|
closeWindow: () => void;
|
||||||
openExternal: (url: string) => Promise<boolean>;
|
openExternal: (url: string) => Promise<boolean>;
|
||||||
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
||||||
|
getRunningProcessNames: () => Promise<string[]>;
|
||||||
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||||
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||||
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
|
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
|
||||||
@@ -165,7 +206,13 @@ export interface ElectronApi {
|
|||||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||||
getAppDataPath: () => Promise<string>;
|
getAppDataPath: () => Promise<string>;
|
||||||
|
openCurrentDataFolder: () => Promise<boolean>;
|
||||||
|
exportUserData: () => Promise<ExportUserDataResult>;
|
||||||
|
importUserData: () => Promise<ImportUserDataResult>;
|
||||||
|
eraseUserData: () => Promise<EraseUserDataResult>;
|
||||||
getSavedThemesPath: () => Promise<string>;
|
getSavedThemesPath: () => Promise<string>;
|
||||||
|
getLocalPluginsPath: () => Promise<string>;
|
||||||
|
listLocalPluginManifests: () => Promise<LocalPluginDiscoveryResult>;
|
||||||
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
||||||
readSavedTheme: (fileName: string) => Promise<string>;
|
readSavedTheme: (fileName: string) => Promise<string>;
|
||||||
writeSavedTheme: (fileName: string, text: string) => Promise<boolean>;
|
writeSavedTheme: (fileName: string, text: string) => Promise<boolean>;
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ import { Injectable, signal } from '@angular/core';
|
|||||||
|
|
||||||
export type SettingsPage =
|
export type SettingsPage =
|
||||||
| 'general'
|
| 'general'
|
||||||
|
| 'plugins'
|
||||||
| 'theme'
|
| 'theme'
|
||||||
| 'network'
|
| 'network'
|
||||||
| 'notifications'
|
| 'notifications'
|
||||||
| 'voice'
|
| 'voice'
|
||||||
| 'updates'
|
| 'updates'
|
||||||
|
| 'data'
|
||||||
| 'debugging'
|
| 'debugging'
|
||||||
| 'server'
|
| 'server'
|
||||||
|
| 'serverPlugins'
|
||||||
| 'members'
|
| 'members'
|
||||||
| 'bans'
|
| 'bans'
|
||||||
| 'permissions';
|
| 'permissions';
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ infrastructure adapters and UI.
|
|||||||
| **access-control** | Role, permission, ban matching, moderation, and room access rules | `normalizeRoomAccessControl()`, `resolveRoomPermission()`, `hasRoomBanForUser()` |
|
| **access-control** | Role, permission, ban matching, moderation, and room access rules | `normalizeRoomAccessControl()`, `resolveRoomPermission()`, `hasRoomBanForUser()` |
|
||||||
| **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` |
|
| **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` |
|
||||||
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
|
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
|
||||||
|
| **direct-message** | One-to-one WebRTC messages, offline queueing, delivery state, and friends | `DirectMessageService`, `FriendService` |
|
||||||
|
| **game-activity** | Local game detection, server metadata matching, P2P now-playing sync, and elapsed playtime formatting | `GameActivityService`, `formatGameActivityElapsed()` |
|
||||||
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
|
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
|
||||||
|
| **plugins** | Client-only plugin manifests, load ordering, registry state, and signal-server support metadata | `PluginHostService`, `PluginRegistryService` |
|
||||||
| **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` |
|
| **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` |
|
||||||
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
|
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
|
||||||
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
|
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
|
||||||
@@ -28,7 +31,9 @@ The larger domains also keep longer design notes in their own folders:
|
|||||||
- [access-control/README.md](access-control/README.md)
|
- [access-control/README.md](access-control/README.md)
|
||||||
- [authentication/README.md](authentication/README.md)
|
- [authentication/README.md](authentication/README.md)
|
||||||
- [chat/README.md](chat/README.md)
|
- [chat/README.md](chat/README.md)
|
||||||
|
- [direct-message/README.md](direct-message/README.md)
|
||||||
- [notifications/README.md](notifications/README.md)
|
- [notifications/README.md](notifications/README.md)
|
||||||
|
- [plugins/README.md](plugins/README.md)
|
||||||
- [profile-avatar/README.md](profile-avatar/README.md)
|
- [profile-avatar/README.md](profile-avatar/README.md)
|
||||||
- [screen-share/README.md](screen-share/README.md)
|
- [screen-share/README.md](screen-share/README.md)
|
||||||
- [server-directory/README.md](server-directory/README.md)
|
- [server-directory/README.md](server-directory/README.md)
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
computed,
|
|
||||||
effect,
|
|
||||||
inject,
|
inject,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
@@ -13,7 +10,11 @@ import {
|
|||||||
throwError
|
throwError
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import { catchError, map } from 'rxjs/operators';
|
import { catchError, map } from 'rxjs/operators';
|
||||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
import {
|
||||||
|
ServerDirectoryFacade,
|
||||||
|
type RoomSignalSourceInput,
|
||||||
|
type ServerSourceSelector
|
||||||
|
} from '../../../server-directory';
|
||||||
|
|
||||||
export interface KlipyGif {
|
export interface KlipyGif {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -37,51 +38,47 @@ export interface KlipyGifSearchResponse {
|
|||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 24;
|
const DEFAULT_PAGE_SIZE = 24;
|
||||||
const KLIPY_CUSTOMER_ID_STORAGE_KEY = 'metoyou_klipy_customer_id';
|
const KLIPY_CUSTOMER_ID_STORAGE_KEY = 'metoyou_klipy_customer_id';
|
||||||
|
const DEFAULT_AVAILABILITY_KEY = 'default';
|
||||||
|
|
||||||
|
interface KlipyAvailabilityState {
|
||||||
|
enabled: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class KlipyService {
|
export class KlipyService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||||
private readonly availabilityState = signal({
|
private readonly availabilityByKey = signal<Record<string, KlipyAvailabilityState>>({});
|
||||||
enabled: false,
|
|
||||||
loading: true
|
|
||||||
});
|
|
||||||
private lastAvailabilityKey = '';
|
|
||||||
|
|
||||||
readonly isEnabled = computed(() => this.availabilityState().enabled);
|
isEnabled(source?: RoomSignalSourceInput | null): boolean {
|
||||||
readonly isLoading = computed(() => this.availabilityState().loading);
|
return this.getAvailabilityState(source).enabled;
|
||||||
|
|
||||||
constructor() {
|
|
||||||
effect(() => {
|
|
||||||
const activeServer = this.serverDirectory.activeServer();
|
|
||||||
const apiBaseUrl = this.serverDirectory.getApiBaseUrl();
|
|
||||||
const nextKey = `${activeServer?.id ?? 'default'}:${apiBaseUrl}`;
|
|
||||||
|
|
||||||
if (nextKey === this.lastAvailabilityKey)
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.lastAvailabilityKey = nextKey;
|
|
||||||
void this.refreshAvailability();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshAvailability(): Promise<void> {
|
isLoading(source?: RoomSignalSourceInput | null): boolean {
|
||||||
this.availabilityState.set({ enabled: false,
|
return this.getAvailabilityState(source).loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshAvailability(source?: RoomSignalSourceInput | null): Promise<void> {
|
||||||
|
const selector = this.getSourceSelector(source);
|
||||||
|
const key = this.getAvailabilityKey(selector);
|
||||||
|
|
||||||
|
this.setAvailabilityState(key, { enabled: false,
|
||||||
loading: true });
|
loading: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await firstValueFrom(
|
const response = await firstValueFrom(
|
||||||
this.http.get<KlipyAvailabilityResponse>(
|
this.http.get<KlipyAvailabilityResponse>(
|
||||||
`${this.serverDirectory.getApiBaseUrl()}/klipy/config`
|
`${this.serverDirectory.getApiBaseUrl(selector)}/klipy/config`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
this.availabilityState.set({
|
this.setAvailabilityState(key, {
|
||||||
enabled: response.enabled === true,
|
enabled: response.enabled === true,
|
||||||
loading: false
|
loading: false
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
this.availabilityState.set({ enabled: false,
|
this.setAvailabilityState(key, { enabled: false,
|
||||||
loading: false });
|
loading: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,8 +86,11 @@ export class KlipyService {
|
|||||||
searchGifs(
|
searchGifs(
|
||||||
query: string,
|
query: string,
|
||||||
page = 1,
|
page = 1,
|
||||||
perPage = DEFAULT_PAGE_SIZE
|
perPage = DEFAULT_PAGE_SIZE,
|
||||||
|
source?: RoomSignalSourceInput | null
|
||||||
): Observable<KlipyGifSearchResponse> {
|
): Observable<KlipyGifSearchResponse> {
|
||||||
|
const selector = this.getSourceSelector(source);
|
||||||
|
|
||||||
let params = new HttpParams()
|
let params = new HttpParams()
|
||||||
.set('page', String(Math.max(1, Math.floor(page))))
|
.set('page', String(Math.max(1, Math.floor(page))))
|
||||||
.set('per_page', String(Math.max(1, Math.floor(perPage))))
|
.set('per_page', String(Math.max(1, Math.floor(perPage))))
|
||||||
@@ -109,7 +109,7 @@ export class KlipyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.http
|
return this.http
|
||||||
.get<KlipyGifSearchResponse>(`${this.serverDirectory.getApiBaseUrl()}/klipy/gifs`, { params })
|
.get<KlipyGifSearchResponse>(`${this.serverDirectory.getApiBaseUrl(selector)}/klipy/gifs`, { params })
|
||||||
.pipe(
|
.pipe(
|
||||||
map((response) => ({
|
map((response) => ({
|
||||||
enabled: response.enabled !== false,
|
enabled: response.enabled !== false,
|
||||||
@@ -138,7 +138,7 @@ export class KlipyService {
|
|||||||
return this.normalizeMediaUrl(url);
|
return this.normalizeMediaUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
buildImageProxyUrl(url: string): string {
|
buildImageProxyUrl(url: string, source?: RoomSignalSourceInput | null): string {
|
||||||
const trimmed = this.normalizeMediaUrl(url);
|
const trimmed = this.normalizeMediaUrl(url);
|
||||||
|
|
||||||
if (!trimmed)
|
if (!trimmed)
|
||||||
@@ -147,7 +147,36 @@ export class KlipyService {
|
|||||||
if (!/^https?:\/\//i.test(trimmed))
|
if (!/^https?:\/\//i.test(trimmed))
|
||||||
return trimmed;
|
return trimmed;
|
||||||
|
|
||||||
return `${this.serverDirectory.getApiBaseUrl()}/image-proxy?url=${encodeURIComponent(trimmed)}`;
|
return `${this.serverDirectory.getApiBaseUrl(this.getSourceSelector(source))}/image-proxy?url=${encodeURIComponent(trimmed)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAvailabilityState(source?: RoomSignalSourceInput | null): KlipyAvailabilityState {
|
||||||
|
return this.availabilityByKey()[this.getAvailabilityKey(this.getSourceSelector(source))]
|
||||||
|
?? { enabled: false,
|
||||||
|
loading: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private setAvailabilityState(key: string, state: KlipyAvailabilityState): void {
|
||||||
|
this.availabilityByKey.update((availabilityByKey) => ({
|
||||||
|
...availabilityByKey,
|
||||||
|
[key]: state
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSourceSelector(source?: RoomSignalSourceInput | null): ServerSourceSelector | undefined {
|
||||||
|
return this.serverDirectory.buildRoomSignalSelector(source ?? undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAvailabilityKey(selector?: ServerSourceSelector): string {
|
||||||
|
if (selector?.sourceId) {
|
||||||
|
return `id:${selector.sourceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selector?.sourceUrl) {
|
||||||
|
return `url:${selector.sourceUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_AVAILABILITY_KEY;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPreferredLocale(): string | null {
|
private getPreferredLocale(): string | null {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { KlipyService } from '../application/services/klipy.service';
|
import { KlipyService } from '../application/services/klipy.service';
|
||||||
|
import type { RoomSignalSourceInput } from '../../server-directory';
|
||||||
|
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: 'img[appChatImageProxyFallback]',
|
selector: 'img[appChatImageProxyFallback]',
|
||||||
@@ -15,6 +16,7 @@ import { KlipyService } from '../application/services/klipy.service';
|
|||||||
})
|
})
|
||||||
export class ChatImageProxyFallbackDirective {
|
export class ChatImageProxyFallbackDirective {
|
||||||
readonly sourceUrl = input('', { alias: 'appChatImageProxyFallback' });
|
readonly sourceUrl = input('', { alias: 'appChatImageProxyFallback' });
|
||||||
|
readonly signalSource = input<RoomSignalSourceInput | null>(null);
|
||||||
|
|
||||||
private readonly klipy = inject(KlipyService);
|
private readonly klipy = inject(KlipyService);
|
||||||
private readonly renderedSource = signal('');
|
private readonly renderedSource = signal('');
|
||||||
@@ -38,7 +40,7 @@ export class ChatImageProxyFallbackDirective {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const proxyUrl = this.klipy.buildImageProxyUrl(this.sourceUrl());
|
const proxyUrl = this.klipy.buildImageProxyUrl(this.sourceUrl(), this.signalSource());
|
||||||
|
|
||||||
if (!proxyUrl || proxyUrl === this.renderedSource()) {
|
if (!proxyUrl || proxyUrl === this.renderedSource()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<div class="chat-layout relative h-full">
|
<div
|
||||||
|
appThemeNode="chatSurface"
|
||||||
|
class="chat-layout relative h-full"
|
||||||
|
>
|
||||||
<app-chat-message-list
|
<app-chat-message-list
|
||||||
[allMessages]="allMessages()"
|
[allMessages]="allMessages()"
|
||||||
[channelMessages]="channelMessages()"
|
[channelMessages]="channelMessages()"
|
||||||
@@ -19,10 +22,15 @@
|
|||||||
(embedRemoved)="handleEmbedRemoved($event)"
|
(embedRemoved)="handleEmbedRemoved($event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">
|
<div
|
||||||
|
appThemeNode="chatComposerBar"
|
||||||
|
class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10"
|
||||||
|
>
|
||||||
<app-chat-message-composer
|
<app-chat-message-composer
|
||||||
[replyTo]="replyTo()"
|
[replyTo]="replyTo()"
|
||||||
[showKlipyGifPicker]="showKlipyGifPicker()"
|
[showKlipyGifPicker]="showKlipyGifPicker()"
|
||||||
|
[klipyEnabled]="klipyEnabled()"
|
||||||
|
[klipySignalSource]="currentRoom()"
|
||||||
(messageSubmitted)="handleMessageSubmitted($event)"
|
(messageSubmitted)="handleMessageSubmitted($event)"
|
||||||
(typingStarted)="handleTypingStarted()"
|
(typingStarted)="handleTypingStarted()"
|
||||||
(replyCleared)="clearReply()"
|
(replyCleared)="clearReply()"
|
||||||
@@ -45,11 +53,13 @@
|
|||||||
|
|
||||||
<div class="pointer-events-none fixed inset-0 z-[90]">
|
<div class="pointer-events-none fixed inset-0 z-[90]">
|
||||||
<div
|
<div
|
||||||
|
appThemeNode="chatGifPickerSurface"
|
||||||
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
|
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
|
||||||
[style.bottom.px]="composerBottomPadding() + 8"
|
[style.bottom.px]="composerBottomPadding() + 8"
|
||||||
[style.right.px]="klipyGifPickerAnchorRight()"
|
[style.right.px]="klipyGifPickerAnchorRight()"
|
||||||
>
|
>
|
||||||
<app-klipy-gif-picker
|
<app-klipy-gif-picker
|
||||||
|
[signalSource]="currentRoom()"
|
||||||
(gifSelected)="handleKlipyGifSelected($event)"
|
(gifSelected)="handleKlipyGifSelected($event)"
|
||||||
(closed)="closeKlipyGifPicker()"
|
(closed)="closeKlipyGifPicker()"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
HostListener,
|
HostListener,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
computed,
|
computed,
|
||||||
|
effect,
|
||||||
inject,
|
inject,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
@@ -11,7 +12,7 @@ import { Store } from '@ngrx/store';
|
|||||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||||
import { Attachment, AttachmentFacade } from '../../../attachment';
|
import { Attachment, AttachmentFacade } from '../../../attachment';
|
||||||
import { KlipyGif } from '../../application/services/klipy.service';
|
import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
|
||||||
import { MessagesActions } from '../../../../store/messages/messages.actions';
|
import { MessagesActions } from '../../../../store/messages/messages.actions';
|
||||||
import {
|
import {
|
||||||
selectAllMessages,
|
selectAllMessages,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors';
|
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors';
|
||||||
import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
||||||
import { Message } from '../../../../shared-kernel';
|
import { Message } from '../../../../shared-kernel';
|
||||||
|
import { ThemeNodeDirective } from '../../../theme';
|
||||||
import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component';
|
import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component';
|
||||||
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
|
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
|
||||||
import { ChatMessageListComponent } from './components/message-list/chat-message-list.component';
|
import { ChatMessageListComponent } from './components/message-list/chat-message-list.component';
|
||||||
@@ -42,7 +44,8 @@ import {
|
|||||||
ChatMessageComposerComponent,
|
ChatMessageComposerComponent,
|
||||||
KlipyGifPickerComponent,
|
KlipyGifPickerComponent,
|
||||||
ChatMessageListComponent,
|
ChatMessageListComponent,
|
||||||
ChatMessageOverlaysComponent
|
ChatMessageOverlaysComponent,
|
||||||
|
ThemeNodeDirective
|
||||||
],
|
],
|
||||||
templateUrl: './chat-messages.component.html',
|
templateUrl: './chat-messages.component.html',
|
||||||
styleUrl: './chat-messages.component.scss'
|
styleUrl: './chat-messages.component.scss'
|
||||||
@@ -54,10 +57,11 @@ export class ChatMessagesComponent {
|
|||||||
private readonly store = inject(Store);
|
private readonly store = inject(Store);
|
||||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||||
private readonly attachmentsSvc = inject(AttachmentFacade);
|
private readonly attachmentsSvc = inject(AttachmentFacade);
|
||||||
|
private readonly klipy = inject(KlipyService);
|
||||||
|
|
||||||
readonly allMessages = this.store.selectSignal(selectAllMessages);
|
readonly allMessages = this.store.selectSignal(selectAllMessages);
|
||||||
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||||
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
|
||||||
readonly loading = this.store.selectSignal(selectMessagesLoading);
|
readonly loading = this.store.selectSignal(selectMessagesLoading);
|
||||||
readonly syncing = this.store.selectSignal(selectMessagesSyncing);
|
readonly syncing = this.store.selectSignal(selectMessagesSyncing);
|
||||||
@@ -68,16 +72,11 @@ export class ChatMessagesComponent {
|
|||||||
const channelId = this.activeChannelId();
|
const channelId = this.activeChannelId();
|
||||||
const roomId = this.currentRoom()?.id;
|
const roomId = this.currentRoom()?.id;
|
||||||
|
|
||||||
return this.allMessages().filter(
|
return this.allMessages().filter((message) => message.roomId === roomId && (message.channelId || 'general') === channelId);
|
||||||
(message) =>
|
|
||||||
message.roomId === roomId &&
|
|
||||||
(message.channelId || 'general') === channelId
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly conversationKey = computed(
|
readonly conversationKey = computed(() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`);
|
||||||
() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`
|
readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom()));
|
||||||
);
|
|
||||||
readonly composerBottomPadding = signal(140);
|
readonly composerBottomPadding = signal(140);
|
||||||
readonly klipyGifPickerAnchorRight = signal(16);
|
readonly klipyGifPickerAnchorRight = signal(16);
|
||||||
readonly replyTo = signal<Message | null>(null);
|
readonly replyTo = signal<Message | null>(null);
|
||||||
@@ -85,6 +84,12 @@ export class ChatMessagesComponent {
|
|||||||
readonly lightboxAttachment = signal<Attachment | null>(null);
|
readonly lightboxAttachment = signal<Attachment | null>(null);
|
||||||
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
|
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
void this.klipy.refreshAvailability(this.currentRoom());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@HostListener('window:resize')
|
@HostListener('window:resize')
|
||||||
onWindowResize(): void {
|
onWindowResize(): void {
|
||||||
if (this.showKlipyGifPicker()) {
|
if (this.showKlipyGifPicker()) {
|
||||||
@@ -167,9 +172,7 @@ export class ChatMessagesComponent {
|
|||||||
if (!message || !currentUserId)
|
if (!message || !currentUserId)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const hasReacted = message.reactions.some(
|
const hasReacted = message.reactions.some((reaction) => reaction.emoji === event.emoji && reaction.userId === currentUserId);
|
||||||
(reaction) => reaction.emoji === event.emoji && reaction.userId === currentUserId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasReacted) {
|
if (hasReacted) {
|
||||||
this.store.dispatch(
|
this.store.dispatch(
|
||||||
@@ -234,9 +237,7 @@ export class ChatMessagesComponent {
|
|||||||
const minRight = 16;
|
const minRight = 16;
|
||||||
const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16);
|
const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16);
|
||||||
|
|
||||||
this.klipyGifPickerAnchorRight.set(
|
this.klipyGifPickerAnchorRight.set(Math.min(Math.max(Math.round(preferredRight), minRight), maxRight));
|
||||||
Math.min(Math.max(Math.round(preferredRight), minRight), maxRight)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getKlipyGifPickerWidth(viewportWidth: number): number {
|
private getKlipyGifPickerWidth(viewportWidth: number): number {
|
||||||
@@ -281,10 +282,7 @@ export class ChatMessagesComponent {
|
|||||||
|
|
||||||
if (blob) {
|
if (blob) {
|
||||||
try {
|
try {
|
||||||
const result = await electronApi.saveFileAs(
|
const result = await electronApi.saveFileAs(attachment.filename, await this.blobToBase64(blob));
|
||||||
attachment.filename,
|
|
||||||
await this.blobToBase64(blob)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.saved || result.cancelled)
|
if (result.saved || result.cancelled)
|
||||||
return;
|
return;
|
||||||
@@ -407,12 +405,7 @@ export class ChatMessagesComponent {
|
|||||||
|
|
||||||
const message = [...this.channelMessages()]
|
const message = [...this.channelMessages()]
|
||||||
.reverse()
|
.reverse()
|
||||||
.find(
|
.find((entry) => entry.senderId === currentUserId && entry.content === content && !entry.isDeleted);
|
||||||
(entry) =>
|
|
||||||
entry.senderId === currentUserId &&
|
|
||||||
entry.content === content &&
|
|
||||||
!entry.isDeleted
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!message) {
|
if (!message) {
|
||||||
setTimeout(() => this.attachFilesToLastOwnMessage(content, pendingFiles), 150);
|
setTimeout(() => this.attachFilesToLastOwnMessage(content, pendingFiles), 150);
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<!-- eslint-disable @angular-eslint/template/button-has-type -->
|
<!-- eslint-disable @angular-eslint/template/button-has-type -->
|
||||||
<div #composerRoot>
|
<div
|
||||||
|
#composerRoot
|
||||||
|
appThemeNode="chatComposerBar"
|
||||||
|
>
|
||||||
@if (replyTo()) {
|
@if (replyTo()) {
|
||||||
<div class="pointer-events-auto flex items-center gap-2 bg-secondary/50 px-4 py-2">
|
<div
|
||||||
|
appThemeNode="chatComposerReplyBar"
|
||||||
|
class="pointer-events-auto flex items-center gap-2 bg-secondary/50 px-4 py-2"
|
||||||
|
>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideReply"
|
name="lucideReply"
|
||||||
class="h-4 w-4 text-muted-foreground"
|
class="h-4 w-4 text-muted-foreground"
|
||||||
@@ -31,6 +37,7 @@
|
|||||||
(mouseleave)="onToolbarMouseLeave()"
|
(mouseleave)="onToolbarMouseLeave()"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
appThemeNode="chatComposerToolbar"
|
||||||
class="mx-4 -mb-2 flex flex-wrap items-center justify-start gap-2 rounded-lg border border-border bg-card/70 px-2 py-1 shadow-sm backdrop-blur"
|
class="mx-4 -mb-2 flex flex-wrap items-center justify-start gap-2 rounded-lg border border-border bg-card/70 px-2 py-1 shadow-sm backdrop-blur"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -124,6 +131,7 @@
|
|||||||
|
|
||||||
<div class="border-border p-4">
|
<div class="border-border p-4">
|
||||||
<div
|
<div
|
||||||
|
appThemeNode="chatComposerInput"
|
||||||
class="chat-input-wrapper relative"
|
class="chat-input-wrapper relative"
|
||||||
(mouseenter)="inputHovered.set(true)"
|
(mouseenter)="inputHovered.set(true)"
|
||||||
(mouseleave)="inputHovered.set(false)"
|
(mouseleave)="inputHovered.set(false)"
|
||||||
@@ -133,7 +141,21 @@
|
|||||||
(drop)="onDrop($event)"
|
(drop)="onDrop($event)"
|
||||||
>
|
>
|
||||||
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2 m-0.5">
|
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2 m-0.5">
|
||||||
@if (klipy.isEnabled()) {
|
@for (record of pluginComposerActions(); track record.id) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="runPluginComposerAction(record.contribution.run)"
|
||||||
|
class="inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-2xl border border-border/70 bg-secondary/55 px-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground shadow-sm backdrop-blur-md transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:text-foreground"
|
||||||
|
[class.opacity-100]="inputHovered()"
|
||||||
|
[class.opacity-70]="!inputHovered()"
|
||||||
|
[attr.aria-label]="record.contribution.label"
|
||||||
|
[title]="record.contribution.label"
|
||||||
|
>
|
||||||
|
<span>{{ record.contribution.icon ?? record.contribution.label }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (klipyEnabled()) {
|
||||||
<button
|
<button
|
||||||
#klipyTrigger
|
#klipyTrigger
|
||||||
type="button"
|
type="button"
|
||||||
@@ -156,6 +178,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
appThemeNode="chatComposerSendButton"
|
||||||
type="button"
|
type="button"
|
||||||
(click)="sendMessage()"
|
(click)="sendMessage()"
|
||||||
[disabled]="!messageContent.trim() && pendingFiles.length === 0 && !pendingKlipyGif()"
|
[disabled]="!messageContent.trim() && pendingFiles.length === 0 && !pendingKlipyGif()"
|
||||||
@@ -172,6 +195,7 @@
|
|||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
#messageInputRef
|
#messageInputRef
|
||||||
|
[attr.data-testid]="textareaTestId()"
|
||||||
rows="1"
|
rows="1"
|
||||||
[(ngModel)]="messageContent"
|
[(ngModel)]="messageContent"
|
||||||
(focus)="onInputFocus()"
|
(focus)="onInputFocus()"
|
||||||
@@ -189,8 +213,8 @@
|
|||||||
[class.border-primary]="dragActive()"
|
[class.border-primary]="dragActive()"
|
||||||
[class.chat-textarea-expanded]="textareaExpanded()"
|
[class.chat-textarea-expanded]="textareaExpanded()"
|
||||||
[class.ctrl-resize]="ctrlHeld()"
|
[class.ctrl-resize]="ctrlHeld()"
|
||||||
[class.pr-16]="!klipy.isEnabled()"
|
[class.pr-16]="!klipyEnabled()"
|
||||||
[class.pr-40]="klipy.isEnabled()"
|
[class.pr-40]="klipyEnabled()"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
@if (dragActive()) {
|
@if (dragActive()) {
|
||||||
@@ -207,6 +231,7 @@
|
|||||||
<div class="relative h-12 w-12 overflow-hidden rounded-lg bg-secondary/80">
|
<div class="relative h-12 w-12 overflow-hidden rounded-lg bg-secondary/80">
|
||||||
<img
|
<img
|
||||||
[appChatImageProxyFallback]="pendingKlipyGif()!.previewUrl || pendingKlipyGif()!.url"
|
[appChatImageProxyFallback]="pendingKlipyGif()!.previewUrl || pendingKlipyGif()!.url"
|
||||||
|
[signalSource]="klipySignalSource()"
|
||||||
[alt]="pendingKlipyGif()!.title || 'KLIPY GIF'"
|
[alt]="pendingKlipyGif()!.title || 'KLIPY GIF'"
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ import type { ClipboardFilePayload } from '../../../../../../core/platform/elect
|
|||||||
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
|
||||||
import { KlipyGif, KlipyService } from '../../../../application/services/klipy.service';
|
import { KlipyGif, KlipyService } from '../../../../application/services/klipy.service';
|
||||||
import { Message } from '../../../../../../shared-kernel';
|
import { Message } from '../../../../../../shared-kernel';
|
||||||
|
import { PluginUiRegistryService } from '../../../../../plugins';
|
||||||
|
import { ThemeNodeDirective } from '../../../../../theme';
|
||||||
|
import type { RoomSignalSourceInput } from '../../../../../server-directory';
|
||||||
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
|
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
|
||||||
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
|
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
|
||||||
import { ChatMarkdownService } from '../../services/chat-markdown.service';
|
import { ChatMarkdownService } from '../../services/chat-markdown.service';
|
||||||
@@ -42,7 +45,8 @@ const DEFAULT_TEXTAREA_HEIGHT = 62;
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
NgIcon,
|
NgIcon,
|
||||||
ChatImageProxyFallbackDirective,
|
ChatImageProxyFallbackDirective,
|
||||||
TypingIndicatorComponent
|
TypingIndicatorComponent,
|
||||||
|
ThemeNodeDirective
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
@@ -66,6 +70,9 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
readonly replyTo = input<Message | null>(null);
|
readonly replyTo = input<Message | null>(null);
|
||||||
readonly showKlipyGifPicker = input(false);
|
readonly showKlipyGifPicker = input(false);
|
||||||
|
readonly klipyEnabled = input(false);
|
||||||
|
readonly klipySignalSource = input<RoomSignalSourceInput | null>(null);
|
||||||
|
readonly textareaTestId = input<string | null>(null);
|
||||||
|
|
||||||
readonly messageSubmitted = output<ChatMessageComposerSubmitEvent>();
|
readonly messageSubmitted = output<ChatMessageComposerSubmitEvent>();
|
||||||
readonly typingStarted = output();
|
readonly typingStarted = output();
|
||||||
@@ -73,11 +80,13 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
|||||||
readonly heightChanged = output<number>();
|
readonly heightChanged = output<number>();
|
||||||
readonly klipyGifPickerToggleRequested = output();
|
readonly klipyGifPickerToggleRequested = output();
|
||||||
|
|
||||||
readonly klipy = inject(KlipyService);
|
private readonly klipy = inject(KlipyService);
|
||||||
private readonly markdown = inject(ChatMarkdownService);
|
private readonly markdown = inject(ChatMarkdownService);
|
||||||
private readonly electronBridge = inject(ElectronBridgeService);
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
|
private readonly pluginUi = inject(PluginUiRegistryService);
|
||||||
|
|
||||||
readonly pendingKlipyGif = signal<KlipyGif | null>(null);
|
readonly pendingKlipyGif = signal<KlipyGif | null>(null);
|
||||||
|
readonly pluginComposerActions = this.pluginUi.composerActionRecords;
|
||||||
readonly toolbarVisible = signal(false);
|
readonly toolbarVisible = signal(false);
|
||||||
readonly dragActive = signal(false);
|
readonly dragActive = signal(false);
|
||||||
readonly inputHovered = signal(false);
|
readonly inputHovered = signal(false);
|
||||||
@@ -207,12 +216,17 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggleKlipyGifPicker(): void {
|
toggleKlipyGifPicker(): void {
|
||||||
if (!this.klipy.isEnabled())
|
if (!this.klipyEnabled())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
this.klipyGifPickerToggleRequested.emit();
|
this.klipyGifPickerToggleRequested.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runPluginComposerAction(action: () => Promise<void> | void): void {
|
||||||
|
void Promise.resolve()
|
||||||
|
.then(() => action());
|
||||||
|
}
|
||||||
|
|
||||||
getKlipyTriggerRect(): DOMRect | null {
|
getKlipyTriggerRect(): DOMRect | null {
|
||||||
return this.klipyTrigger?.nativeElement.getBoundingClientRect() ?? null;
|
return this.klipyTrigger?.nativeElement.getBoundingClientRect() ?? null;
|
||||||
}
|
}
|
||||||
@@ -411,11 +425,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
|||||||
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
|
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
|
||||||
}
|
}
|
||||||
|
|
||||||
private hasPotentialFilePayload(
|
private hasPotentialFilePayload(dataTransfer: DataTransfer | null, treatMissingTypesAsPotentialFile = true): boolean {
|
||||||
dataTransfer: DataTransfer | null,
|
|
||||||
treatMissingTypesAsPotentialFile = true
|
|
||||||
): boolean {
|
|
||||||
|
|
||||||
if (!dataTransfer)
|
if (!dataTransfer)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
@let msg = message();
|
@let msg = message();
|
||||||
@let attachmentsList = attachmentViewModels();
|
@let attachmentsList = attachmentViewModels();
|
||||||
<div
|
<div
|
||||||
|
appThemeNode="chatMessageBubble"
|
||||||
[attr.data-message-id]="msg.id"
|
[attr.data-message-id]="msg.id"
|
||||||
class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30"
|
class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30"
|
||||||
[class.opacity-50]="msg.isDeleted"
|
[class.opacity-50]="msg.isDeleted"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
appThemeNode="chatMessageAvatar"
|
||||||
class="flex-shrink-0 cursor-pointer"
|
class="flex-shrink-0 cursor-pointer"
|
||||||
(click)="openSenderProfileCard($event); $event.stopPropagation()"
|
(click)="openSenderProfileCard($event); $event.stopPropagation()"
|
||||||
>
|
>
|
||||||
@@ -17,7 +19,10 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="min-w-0 flex-1">
|
<div
|
||||||
|
appThemeNode="chatMessageContent"
|
||||||
|
class="min-w-0 flex-1"
|
||||||
|
>
|
||||||
@if (msg.replyToId) {
|
@if (msg.replyToId) {
|
||||||
@let reply = repliedMessage();
|
@let reply = repliedMessage();
|
||||||
<div
|
<div
|
||||||
@@ -110,6 +115,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (pluginEmbeds().length > 0) {
|
||||||
|
<div class="mt-2 space-y-2" data-testid="plugin-message-embeds">
|
||||||
|
@for (embed of pluginEmbeds(); track embed.id) {
|
||||||
|
<article class="rounded-md border border-border bg-secondary/30 p-3">
|
||||||
|
<div class="mb-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>{{ embed.contribution.embedType }}</span>
|
||||||
|
<span>{{ embed.pluginId }}</span>
|
||||||
|
</div>
|
||||||
|
<app-plugin-render-host [render]="embed.render" />
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (attachmentsList.length > 0) {
|
@if (attachmentsList.length > 0) {
|
||||||
<div class="mt-2 space-y-2">
|
<div class="mt-2 space-y-2">
|
||||||
@for (att of attachmentsList; track att.id) {
|
@for (att of attachmentsList; track att.id) {
|
||||||
@@ -150,7 +169,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else if ((att.receivedBytes || 0) > 0) {
|
} @else if ((att.receivedBytes || 0) > 0) {
|
||||||
<div class="max-w-xs rounded-md border border-border bg-secondary/40 p-3">
|
<div
|
||||||
|
appThemeNode="chatAttachmentCard"
|
||||||
|
class="max-w-xs rounded-md border border-border bg-secondary/40 p-3"
|
||||||
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-primary/10">
|
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-primary/10">
|
||||||
<ng-icon
|
<ng-icon
|
||||||
@@ -172,7 +194,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="max-w-xs rounded-md border border-dashed border-border bg-secondary/20 p-4">
|
<div
|
||||||
|
appThemeNode="chatAttachmentCard"
|
||||||
|
class="max-w-xs rounded-md border border-dashed border-border bg-secondary/20 p-4"
|
||||||
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-muted">
|
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-muted">
|
||||||
<ng-icon
|
<ng-icon
|
||||||
@@ -220,7 +245,10 @@
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
} @else if ((att.receivedBytes || 0) > 0) {
|
} @else if ((att.receivedBytes || 0) > 0) {
|
||||||
<div class="max-w-xl rounded-md border border-border bg-secondary/40 p-3">
|
<div
|
||||||
|
appThemeNode="chatAttachmentCard"
|
||||||
|
class="max-w-xl rounded-md border border-border bg-secondary/40 p-3"
|
||||||
|
>
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
|
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
|
||||||
@@ -247,7 +275,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="max-w-xl rounded-md border border-dashed border-border bg-secondary/20 p-4">
|
<div
|
||||||
|
appThemeNode="chatAttachmentCard"
|
||||||
|
class="max-w-xl rounded-md border border-dashed border-border bg-secondary/20 p-4"
|
||||||
|
>
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="truncate text-sm font-medium text-foreground">{{ att.filename }}</div>
|
<div class="truncate text-sm font-medium text-foreground">{{ att.filename }}</div>
|
||||||
@@ -271,7 +302,10 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
<div class="rounded-md border border-border bg-secondary/40 p-2">
|
<div
|
||||||
|
appThemeNode="chatAttachmentCard"
|
||||||
|
class="rounded-md border border-border bg-secondary/40 p-2"
|
||||||
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
|
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
|
||||||
@@ -339,6 +373,7 @@
|
|||||||
<div class="mt-2 flex flex-wrap gap-1">
|
<div class="mt-2 flex flex-wrap gap-1">
|
||||||
@for (reaction of getGroupedReactions(); track reaction.emoji) {
|
@for (reaction of getGroupedReactions(); track reaction.emoji) {
|
||||||
<button
|
<button
|
||||||
|
appThemeNode="chatReactionPill"
|
||||||
(click)="toggleReaction(reaction.emoji)"
|
(click)="toggleReaction(reaction.emoji)"
|
||||||
class="flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-xs transition-colors hover:bg-secondary/80"
|
class="flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-xs transition-colors hover:bg-secondary/80"
|
||||||
[class.ring-1]="reaction.hasCurrentUser"
|
[class.ring-1]="reaction.hasCurrentUser"
|
||||||
@@ -354,6 +389,7 @@
|
|||||||
|
|
||||||
@if (!msg.isDeleted) {
|
@if (!msg.isDeleted) {
|
||||||
<div
|
<div
|
||||||
|
appThemeNode="chatMessageActions"
|
||||||
class="absolute right-2 top-2 flex items-center gap-1 rounded-lg border border-border bg-card shadow-lg opacity-0 transition-opacity group-hover:opacity-100"
|
class="absolute right-2 top-2 flex items-center gap-1 rounded-lg border border-border bg-card shadow-lg opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ import {
|
|||||||
Message,
|
Message,
|
||||||
User
|
User
|
||||||
} from '../../../../../../shared-kernel';
|
} from '../../../../../../shared-kernel';
|
||||||
|
import { ThemeNodeDirective } from '../../../../../theme';
|
||||||
|
import { PluginRenderHostComponent } from '../../../../../plugins/feature/plugin-render-host/plugin-render-host.component';
|
||||||
|
import { PluginUiRegistryService } from '../../../../../plugins';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ChatAudioPlayerComponent,
|
ChatAudioPlayerComponent,
|
||||||
@@ -96,7 +99,9 @@ interface ChatMessageAttachmentViewModel extends Attachment {
|
|||||||
ChatVideoPlayerComponent,
|
ChatVideoPlayerComponent,
|
||||||
ChatMessageMarkdownComponent,
|
ChatMessageMarkdownComponent,
|
||||||
ChatLinkEmbedComponent,
|
ChatLinkEmbedComponent,
|
||||||
UserAvatarComponent
|
UserAvatarComponent,
|
||||||
|
PluginRenderHostComponent,
|
||||||
|
ThemeNodeDirective
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
@@ -122,6 +127,7 @@ export class ChatMessageItemComponent {
|
|||||||
|
|
||||||
private readonly attachmentsSvc = inject(AttachmentFacade);
|
private readonly attachmentsSvc = inject(AttachmentFacade);
|
||||||
private readonly klipy = inject(KlipyService);
|
private readonly klipy = inject(KlipyService);
|
||||||
|
private readonly pluginUi = inject(PluginUiRegistryService);
|
||||||
private readonly profileCard = inject(ProfileCardService);
|
private readonly profileCard = inject(ProfileCardService);
|
||||||
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
|
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
|
||||||
|
|
||||||
@@ -144,21 +150,24 @@ export class ChatMessageItemComponent {
|
|||||||
|
|
||||||
readonly commonEmojis = COMMON_EMOJIS;
|
readonly commonEmojis = COMMON_EMOJIS;
|
||||||
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
|
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
|
||||||
|
readonly pluginEmbeds = computed(() => this.findPluginEmbeds(this.message().content));
|
||||||
readonly isEditing = signal(false);
|
readonly isEditing = signal(false);
|
||||||
readonly showEmojiPicker = signal(false);
|
readonly showEmojiPicker = signal(false);
|
||||||
readonly senderUser = computed<User>(() => {
|
readonly senderUser = computed<User>(() => {
|
||||||
const msg = this.message();
|
const msg = this.message();
|
||||||
const found = this.userLookup().get(msg.senderId);
|
const found = this.userLookup().get(msg.senderId);
|
||||||
|
|
||||||
return found ?? {
|
return (
|
||||||
id: msg.senderId,
|
found ?? {
|
||||||
oderId: msg.senderId,
|
id: msg.senderId,
|
||||||
username: msg.senderName,
|
oderId: msg.senderId,
|
||||||
displayName: msg.senderName,
|
username: msg.senderName,
|
||||||
status: 'disconnected',
|
displayName: msg.senderName,
|
||||||
role: 'member',
|
status: 'disconnected',
|
||||||
joinedAt: 0
|
role: 'member',
|
||||||
};
|
joinedAt: 0
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
editContent = '';
|
editContent = '';
|
||||||
@@ -175,9 +184,7 @@ export class ChatMessageItemComponent {
|
|||||||
readonly attachmentViewModels = computed<ChatMessageAttachmentViewModel[]>(() => {
|
readonly attachmentViewModels = computed<ChatMessageAttachmentViewModel[]>(() => {
|
||||||
void this.attachmentVersion();
|
void this.attachmentVersion();
|
||||||
|
|
||||||
return this.attachmentsSvc.getForMessage(this.message().id).map((attachment) =>
|
return this.attachmentsSvc.getForMessage(this.message().id).map((attachment) => this.buildAttachmentViewModel(attachment));
|
||||||
this.buildAttachmentViewModel(attachment)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
private readonly syncAttachmentVersion = effect(() => {
|
private readonly syncAttachmentVersion = effect(() => {
|
||||||
const version = this.attachmentsSvc.updated();
|
const version = this.attachmentsSvc.updated();
|
||||||
@@ -189,6 +196,28 @@ export class ChatMessageItemComponent {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private findPluginEmbeds(content: string) {
|
||||||
|
const match = /^toju:embed:([a-zA-Z0-9._:-]+):([\s\S]*)$/.exec(content.trim());
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [
|
||||||
|
,
|
||||||
|
embedType,
|
||||||
|
payloadText
|
||||||
|
] = match;
|
||||||
|
const payload = parseEmbedPayload(payloadText);
|
||||||
|
|
||||||
|
return this.pluginUi.embedRecords()
|
||||||
|
.filter((record) => record.contribution.embedType === embedType)
|
||||||
|
.map((record) => ({
|
||||||
|
...record,
|
||||||
|
render: () => record.contribution.render(payload)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
startEdit(): void {
|
startEdit(): void {
|
||||||
this.editContent = this.message().content;
|
this.editContent = this.message().content;
|
||||||
this.isEditing.set(true);
|
this.isEditing.set(true);
|
||||||
@@ -320,8 +349,7 @@ export class ChatMessageItemComponent {
|
|||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
});
|
});
|
||||||
const toDay = (value: Date) =>
|
const toDay = (value: Date) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();
|
||||||
new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();
|
|
||||||
const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24));
|
const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
if (dayDiff === 0)
|
if (dayDiff === 0)
|
||||||
@@ -331,11 +359,7 @@ export class ChatMessageItemComponent {
|
|||||||
return 'Yesterday ' + time;
|
return 'Yesterday ' + time;
|
||||||
|
|
||||||
if (dayDiff < 7) {
|
if (dayDiff < 7) {
|
||||||
return (
|
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' + time;
|
||||||
date.toLocaleDateString([], { weekday: 'short' }) +
|
|
||||||
' ' +
|
|
||||||
time
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -402,10 +426,7 @@ export class ChatMessageItemComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
requiresMediaDownloadAcceptance(attachment: Attachment): boolean {
|
requiresMediaDownloadAcceptance(attachment: Attachment): boolean {
|
||||||
return (
|
return (this.isVideoAttachment(attachment) || this.isAudioAttachment(attachment)) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
|
||||||
(this.isVideoAttachment(attachment) || this.isAudioAttachment(attachment)) &&
|
|
||||||
attachment.size > MAX_AUTO_SAVE_SIZE_BYTES
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getMediaAttachmentStatusText(attachment: Attachment): string {
|
getMediaAttachmentStatusText(attachment: Attachment): string {
|
||||||
@@ -418,9 +439,7 @@ export class ChatMessageItemComponent {
|
|||||||
: 'Large audio file. Accept the download to play it in chat.';
|
: 'Large audio file. Accept the download to play it in chat.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.isVideoAttachment(attachment)
|
return this.isVideoAttachment(attachment) ? 'Waiting for video source...' : 'Waiting for audio source...';
|
||||||
? 'Waiting for video source...'
|
|
||||||
: 'Waiting for audio source...';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getMediaAttachmentActionLabel(attachment: Attachment): string {
|
getMediaAttachmentActionLabel(attachment: Attachment): string {
|
||||||
@@ -484,8 +503,7 @@ export class ChatMessageItemComponent {
|
|||||||
private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel {
|
private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel {
|
||||||
const isVideo = this.isVideoAttachment(attachment);
|
const isVideo = this.isVideoAttachment(attachment);
|
||||||
const isAudio = this.isAudioAttachment(attachment);
|
const isAudio = this.isAudioAttachment(attachment);
|
||||||
const requiresMediaDownloadAcceptance =
|
const requiresMediaDownloadAcceptance = (isVideo || isAudio) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
|
||||||
(isVideo || isAudio) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...attachment,
|
...attachment,
|
||||||
@@ -493,8 +511,12 @@ export class ChatMessageItemComponent {
|
|||||||
isUploader: this.isUploader(attachment),
|
isUploader: this.isUploader(attachment),
|
||||||
isVideo,
|
isVideo,
|
||||||
mediaActionLabel: requiresMediaDownloadAcceptance
|
mediaActionLabel: requiresMediaDownloadAcceptance
|
||||||
? attachment.requestError ? 'Retry download' : 'Accept download'
|
? attachment.requestError
|
||||||
: attachment.requestError ? 'Retry' : 'Request',
|
? 'Retry download'
|
||||||
|
: 'Accept download'
|
||||||
|
: attachment.requestError
|
||||||
|
? 'Retry'
|
||||||
|
: 'Request',
|
||||||
mediaStatusText: attachment.requestError
|
mediaStatusText: attachment.requestError
|
||||||
? attachment.requestError
|
? attachment.requestError
|
||||||
: requiresMediaDownloadAcceptance
|
: requiresMediaDownloadAcceptance
|
||||||
@@ -504,15 +526,23 @@ export class ChatMessageItemComponent {
|
|||||||
: isVideo
|
: isVideo
|
||||||
? 'Waiting for video source...'
|
? 'Waiting for video source...'
|
||||||
: 'Waiting for audio source...',
|
: 'Waiting for audio source...',
|
||||||
progressPercent: attachment.size > 0
|
progressPercent: attachment.size > 0 ? ((attachment.receivedBytes || 0) * 100) / attachment.size : 0
|
||||||
? ((attachment.receivedBytes || 0) * 100) / attachment.size
|
|
||||||
: 0
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getLiveAttachment(attachmentId: string): Attachment | undefined {
|
private getLiveAttachment(attachmentId: string): Attachment | undefined {
|
||||||
return this.attachmentsSvc
|
return this.attachmentsSvc.getForMessage(this.message().id).find((attachment) => attachment.id === attachmentId);
|
||||||
.getForMessage(this.message().id)
|
}
|
||||||
.find((attachment) => attachment.id === attachmentId);
|
}
|
||||||
|
|
||||||
|
function parseEmbedPayload(payloadText: string | undefined): unknown {
|
||||||
|
if (!payloadText?.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(payloadText) as unknown;
|
||||||
|
} catch {
|
||||||
|
return payloadText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<div
|
<div
|
||||||
#messagesContainer
|
#messagesContainer
|
||||||
|
appThemeNode="chatMessageList"
|
||||||
class="absolute inset-0 space-y-4 overflow-y-auto p-4"
|
class="absolute inset-0 space-y-4 overflow-y-auto p-4"
|
||||||
[style.padding-bottom.px]="bottomPadding()"
|
[style.padding-bottom.px]="bottomPadding()"
|
||||||
(scroll)="onScroll()"
|
(scroll)="onScroll()"
|
||||||
@@ -39,7 +40,10 @@
|
|||||||
|
|
||||||
@for (message of messages(); track message.id; let index = $index) {
|
@for (message of messages(); track message.id; let index = $index) {
|
||||||
@if (dateSeparatorLabels().get(index); as separatorLabel) {
|
@if (dateSeparatorLabels().get(index); as separatorLabel) {
|
||||||
<div class="flex items-center gap-3 py-1">
|
<div
|
||||||
|
appThemeNode="chatDateSeparator"
|
||||||
|
class="flex items-center gap-3 py-1"
|
||||||
|
>
|
||||||
<div class="h-px flex-1 bg-border"></div>
|
<div class="h-px flex-1 bg-border"></div>
|
||||||
<span class="rounded-full border border-border bg-background/90 px-3 py-1 text-xs font-medium text-muted-foreground shadow-sm">
|
<span class="rounded-full border border-border bg-background/90 px-3 py-1 text-xs font-medium text-muted-foreground shadow-sm">
|
||||||
{{ separatorLabel }}
|
{{ separatorLabel }}
|
||||||
@@ -70,7 +74,10 @@
|
|||||||
|
|
||||||
@if (showNewMessagesBar()) {
|
@if (showNewMessagesBar()) {
|
||||||
<div class="pointer-events-none sticky bottom-4 flex justify-center">
|
<div class="pointer-events-none sticky bottom-4 flex justify-center">
|
||||||
<div class="pointer-events-auto flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2 shadow">
|
<div
|
||||||
|
appThemeNode="chatNewMessagesBar"
|
||||||
|
class="pointer-events-auto flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2 shadow"
|
||||||
|
>
|
||||||
<span class="text-sm text-muted-foreground">New messages</span>
|
<span class="text-sm text-muted-foreground">New messages</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
ChatMessageReplyEvent
|
ChatMessageReplyEvent
|
||||||
} from '../../models/chat-messages.model';
|
} from '../../models/chat-messages.model';
|
||||||
import { selectAllUsers } from '../../../../../../store/users/users.selectors';
|
import { selectAllUsers } from '../../../../../../store/users/users.selectors';
|
||||||
|
import { ThemeNodeDirective } from '../../../../../theme';
|
||||||
import { ChatMessageItemComponent } from '../message-item/chat-message-item.component';
|
import { ChatMessageItemComponent } from '../message-item/chat-message-item.component';
|
||||||
|
|
||||||
interface PrismGlobal {
|
interface PrismGlobal {
|
||||||
@@ -41,7 +42,11 @@ declare global {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-chat-message-list',
|
selector: 'app-chat-message-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ChatMessageItemComponent],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ChatMessageItemComponent,
|
||||||
|
ThemeNodeDirective
|
||||||
|
],
|
||||||
templateUrl: './chat-message-list.component.html',
|
templateUrl: './chat-message-list.component.html',
|
||||||
host: {
|
host: {
|
||||||
style: 'display: contents;'
|
style: 'display: contents;'
|
||||||
@@ -66,6 +71,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
readonly isAdmin = input(false);
|
readonly isAdmin = input(false);
|
||||||
readonly bottomPadding = input(120);
|
readonly bottomPadding = input(120);
|
||||||
readonly conversationKey = input.required<string>();
|
readonly conversationKey = input.required<string>();
|
||||||
|
readonly userLookupOverrides = input<User[]>([]);
|
||||||
|
|
||||||
readonly replyRequested = output<ChatMessageReplyEvent>();
|
readonly replyRequested = output<ChatMessageReplyEvent>();
|
||||||
readonly deleteRequested = output<ChatMessageDeleteEvent>();
|
readonly deleteRequested = output<ChatMessageDeleteEvent>();
|
||||||
@@ -93,9 +99,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
return all.slice(all.length - limit);
|
return all.slice(all.length - limit);
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly hasMoreMessages = computed(
|
readonly hasMoreMessages = computed(() => this.channelMessages().length > this.displayLimit());
|
||||||
() => this.channelMessages().length > this.displayLimit()
|
|
||||||
);
|
|
||||||
|
|
||||||
readonly dateSeparatorLabels = computed(() => {
|
readonly dateSeparatorLabels = computed(() => {
|
||||||
const labels = new Map<number, string>();
|
const labels = new Map<number, string>();
|
||||||
@@ -126,6 +130,14 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const user of this.userLookupOverrides()) {
|
||||||
|
lookup.set(user.id, user);
|
||||||
|
|
||||||
|
if (user.oderId && user.oderId !== user.id) {
|
||||||
|
lookup.set(user.oderId, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return lookup;
|
return lookup;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,8 +168,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const distanceFromBottom =
|
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
|
||||||
element.scrollHeight - element.scrollTop - element.clientHeight;
|
|
||||||
const newMessages = currentCount > this.lastMessageCount;
|
const newMessages = currentCount > this.lastMessageCount;
|
||||||
|
|
||||||
if (newMessages) {
|
if (newMessages) {
|
||||||
@@ -219,8 +230,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
if (!element || this.isAutoScrolling)
|
if (!element || this.isAutoScrolling)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const distanceFromBottom =
|
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
|
||||||
element.scrollHeight - element.scrollTop - element.clientHeight;
|
|
||||||
const shouldStickToBottom = distanceFromBottom <= 300;
|
const shouldStickToBottom = distanceFromBottom <= 300;
|
||||||
|
|
||||||
if (shouldStickToBottom) {
|
if (shouldStickToBottom) {
|
||||||
@@ -377,11 +387,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.boundOnImageLoad && this.messagesContainer) {
|
if (this.boundOnImageLoad && this.messagesContainer) {
|
||||||
this.messagesContainer.nativeElement.removeEventListener(
|
this.messagesContainer.nativeElement.removeEventListener('load', this.boundOnImageLoad, true);
|
||||||
'load',
|
|
||||||
this.boundOnImageLoad,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
this.boundOnImageLoad = null;
|
this.boundOnImageLoad = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
[appChatImageProxyFallback]="gif.previewUrl || gif.url"
|
[appChatImageProxyFallback]="gif.previewUrl || gif.url"
|
||||||
|
[signalSource]="signalSource()"
|
||||||
[alt]="gif.title || 'KLIPY GIF'"
|
[alt]="gif.title || 'KLIPY GIF'"
|
||||||
class="h-full w-full object-contain p-1.5 transition-transform duration-200 group-hover:scale-[1.03]"
|
class="h-full w-full object-contain p-1.5 transition-transform duration-200 group-hover:scale-[1.03]"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
OnInit,
|
OnInit,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
inject,
|
inject,
|
||||||
|
input,
|
||||||
output,
|
output,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
lucideX
|
lucideX
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
|
import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
|
||||||
|
import type { RoomSignalSourceInput } from '../../../server-directory';
|
||||||
import { ChatImageProxyFallbackDirective } from '../chat-image-proxy-fallback.directive';
|
import { ChatImageProxyFallbackDirective } from '../chat-image-proxy-fallback.directive';
|
||||||
|
|
||||||
const KLIPY_CARD_MIN_WIDTH = 140;
|
const KLIPY_CARD_MIN_WIDTH = 140;
|
||||||
@@ -48,6 +50,8 @@ const KLIPY_CARD_FALLBACK_SIZE = 160;
|
|||||||
templateUrl: './klipy-gif-picker.component.html'
|
templateUrl: './klipy-gif-picker.component.html'
|
||||||
})
|
})
|
||||||
export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy {
|
export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
readonly signalSource = input<RoomSignalSourceInput | null>(null);
|
||||||
|
|
||||||
readonly gifSelected = output<KlipyGif>();
|
readonly gifSelected = output<KlipyGif>();
|
||||||
readonly closed = output<undefined>();
|
readonly closed = output<undefined>();
|
||||||
|
|
||||||
@@ -128,7 +132,7 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await firstValueFrom(
|
const response = await firstValueFrom(
|
||||||
this.klipy.searchGifs(this.searchQuery, this.currentPage)
|
this.klipy.searchGifs(this.searchQuery, this.currentPage, undefined, this.signalSource())
|
||||||
);
|
);
|
||||||
|
|
||||||
if (requestId !== this.requestId)
|
if (requestId !== this.requestId)
|
||||||
|
|||||||
@@ -19,11 +19,10 @@
|
|||||||
@for (user of onlineUsers(); track user.id) {
|
@for (user of onlineUsers(); track user.id) {
|
||||||
<div
|
<div
|
||||||
class="group relative flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 transition-colors cursor-pointer"
|
class="group relative flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 transition-colors cursor-pointer"
|
||||||
(click)="toggleUserMenu(user.id)"
|
[attr.data-testid]="'user-card-' + (user.oderId || user.id)"
|
||||||
(keydown.enter)="toggleUserMenu(user.id)"
|
(click)="openDirectMessage(user)"
|
||||||
(keydown.space)="toggleUserMenu(user.id)"
|
(keydown.enter)="openDirectMessage(user)"
|
||||||
(keyup.enter)="toggleUserMenu(user.id)"
|
(keydown.space)="openDirectMessage(user)"
|
||||||
(keyup.space)="toggleUserMenu(user.id)"
|
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
@@ -70,6 +69,19 @@
|
|||||||
|
|
||||||
<!-- Voice/Screen Status -->
|
<!-- Voice/Screen Status -->
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="grid h-7 w-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-card hover:text-foreground"
|
||||||
|
[class.hidden]="isCurrentUser(user)"
|
||||||
|
title="Message"
|
||||||
|
(click)="$event.stopPropagation(); openDirectMessage(user)"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideMessageCircle"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
@if (user.voiceState?.isSpeaking) {
|
@if (user.voiceState?.isSpeaking) {
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideMic"
|
name="lucideMic"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import {
|
import {
|
||||||
lucideMic,
|
lucideMic,
|
||||||
@@ -19,7 +20,8 @@ import {
|
|||||||
lucideBan,
|
lucideBan,
|
||||||
lucideUserX,
|
lucideUserX,
|
||||||
lucideVolume2,
|
lucideVolume2,
|
||||||
lucideVolumeX
|
lucideVolumeX,
|
||||||
|
lucideMessageCircle
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { UsersActions } from '../../../../store/users/users.actions';
|
import { UsersActions } from '../../../../store/users/users.actions';
|
||||||
@@ -30,6 +32,7 @@ import {
|
|||||||
} from '../../../../store/users/users.selectors';
|
} from '../../../../store/users/users.selectors';
|
||||||
import { User } from '../../../../shared-kernel';
|
import { User } from '../../../../shared-kernel';
|
||||||
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../../shared';
|
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../../shared';
|
||||||
|
import { DirectMessageService } from '../../../direct-message';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user-list',
|
selector: 'app-user-list',
|
||||||
@@ -52,7 +55,8 @@ import { UserAvatarComponent, ConfirmDialogComponent } from '../../../../shared'
|
|||||||
lucideBan,
|
lucideBan,
|
||||||
lucideUserX,
|
lucideUserX,
|
||||||
lucideVolume2,
|
lucideVolume2,
|
||||||
lucideVolumeX
|
lucideVolumeX,
|
||||||
|
lucideMessageCircle
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
templateUrl: './user-list.component.html'
|
templateUrl: './user-list.component.html'
|
||||||
@@ -62,6 +66,8 @@ import { UserAvatarComponent, ConfirmDialogComponent } from '../../../../shared'
|
|||||||
*/
|
*/
|
||||||
export class UserListComponent {
|
export class UserListComponent {
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
|
private router = inject(Router);
|
||||||
|
private directMessages = inject(DirectMessageService);
|
||||||
|
|
||||||
onlineUsers = this.store.selectSignal(selectOnlineUsers) as import('@angular/core').Signal<User[]>;
|
onlineUsers = this.store.selectSignal(selectOnlineUsers) as import('@angular/core').Signal<User[]>;
|
||||||
voiceUsers = computed(() => this.onlineUsers().filter((user: User) => !!user.voiceState?.isConnected));
|
voiceUsers = computed(() => this.onlineUsers().filter((user: User) => !!user.voiceState?.isConnected));
|
||||||
@@ -84,6 +90,16 @@ export class UserListComponent {
|
|||||||
return user.id === this.currentUser()?.id;
|
return user.id === this.currentUser()?.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async openDirectMessage(user: User): Promise<void> {
|
||||||
|
if (this.isCurrentUser(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversation = await this.directMessages.createConversation(user);
|
||||||
|
|
||||||
|
await this.router.navigate(['/dm', conversation.id]);
|
||||||
|
}
|
||||||
|
|
||||||
/** Toggle server-side mute on a user (admin action). */
|
/** Toggle server-side mute on a user (admin action). */
|
||||||
muteUser(user: User): void {
|
muteUser(user: User): void {
|
||||||
if (user.voiceState?.isMutedByAdmin) {
|
if (user.voiceState?.isMutedByAdmin) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user