feat: plugins v1.7
This commit is contained in:
70
electron/api/auth-store.ts
Normal file
70
electron/api/auth-store.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
|
||||||
|
export interface IssuedToken {
|
||||||
|
token: string;
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
signalingServerUrl: string;
|
||||||
|
issuedAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const tokens = new Map<string, IssuedToken>();
|
||||||
|
|
||||||
|
export function issueToken(params: {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
signalingServerUrl: string;
|
||||||
|
}): IssuedToken {
|
||||||
|
const token = randomBytes(32).toString('hex');
|
||||||
|
const issuedAt = Date.now();
|
||||||
|
const issued: IssuedToken = {
|
||||||
|
token,
|
||||||
|
issuedAt,
|
||||||
|
expiresAt: issuedAt + TOKEN_TTL_MS,
|
||||||
|
userId: params.userId,
|
||||||
|
username: params.username,
|
||||||
|
displayName: params.displayName,
|
||||||
|
signalingServerUrl: params.signalingServerUrl
|
||||||
|
};
|
||||||
|
|
||||||
|
tokens.set(token, issued);
|
||||||
|
return issued;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumeToken(token: string): IssuedToken | null {
|
||||||
|
const issued = tokens.get(token);
|
||||||
|
|
||||||
|
if (!issued) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (issued.expiresAt < Date.now()) {
|
||||||
|
tokens.delete(token);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return issued;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeToken(token: string): void {
|
||||||
|
tokens.delete(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAllTokens(): void {
|
||||||
|
tokens.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneExpiredTokens(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const [token, issued] of tokens) {
|
||||||
|
if (issued.expiresAt < now) {
|
||||||
|
tokens.delete(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
122
electron/api/docs-html.ts
Normal file
122
electron/api/docs-html.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
function getScalarBundleCandidates(): string[] {
|
||||||
|
const processWithResources = process as NodeJS.Process & { resourcesPath?: string };
|
||||||
|
const candidates: string[] = [];
|
||||||
|
|
||||||
|
if (processWithResources.resourcesPath) {
|
||||||
|
candidates.push(path.join(processWithResources.resourcesPath, 'scalar', 'api-reference.js'));
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.push(path.join(process.cwd(), 'node_modules', '@scalar', 'api-reference', 'dist', 'browser', 'standalone.js'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
candidates.push(path.join(path.dirname(require.resolve('@scalar/api-reference')), 'browser', 'standalone.js'));
|
||||||
|
} catch {
|
||||||
|
// ignore; the packaged app path above is the production path
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getScalarApiReferenceBundlePath(): Promise<string | null> {
|
||||||
|
for (const candidate of getScalarBundleCandidates()) {
|
||||||
|
try {
|
||||||
|
await fs.access(candidate);
|
||||||
|
return candidate;
|
||||||
|
} catch {
|
||||||
|
// try the next candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDocsHtml(specUrl: string): string {
|
||||||
|
const scalarConfig = {
|
||||||
|
url: specUrl,
|
||||||
|
theme: 'default',
|
||||||
|
layout: 'modern',
|
||||||
|
proxyUrl: '',
|
||||||
|
telemetry: false,
|
||||||
|
persistAuth: false,
|
||||||
|
showDeveloperTools: 'never',
|
||||||
|
hideDownloadButton: false,
|
||||||
|
hideTestRequestButton: false,
|
||||||
|
hideClientButton: false,
|
||||||
|
externalUrls: {
|
||||||
|
dashboardUrl: '',
|
||||||
|
registryUrl: '',
|
||||||
|
proxyUrl: '',
|
||||||
|
apiBaseUrl: ''
|
||||||
|
},
|
||||||
|
agent: {
|
||||||
|
disabled: true,
|
||||||
|
hideAddApi: true
|
||||||
|
},
|
||||||
|
mcp: {
|
||||||
|
disabled: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta
|
||||||
|
http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'none'; script-src 'self' 'nonce-metoyou-local-api-docs'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
|
||||||
|
/>
|
||||||
|
<title>MetoYou Local API</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: #0b0d11;
|
||||||
|
color: #e6e9ee;
|
||||||
|
}
|
||||||
|
.placeholder {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 8vh auto;
|
||||||
|
padding: 2rem;
|
||||||
|
background: #14181f;
|
||||||
|
border: 1px solid #232a35;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
h1 { margin-top: 0; }
|
||||||
|
a { color: #7aa2f7; }
|
||||||
|
code { background: #1f262f; padding: 0.1rem 0.4rem; border-radius: 4px; }
|
||||||
|
#api-reference { min-height: 100vh; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="api-reference"></div>
|
||||||
|
<noscript>
|
||||||
|
<div class="placeholder">
|
||||||
|
<h1>API Documentation</h1>
|
||||||
|
<p>JavaScript is required to render Scalar. The OpenAPI specification is available directly:</p>
|
||||||
|
<p><a href="${specUrl}">${specUrl}</a></p>
|
||||||
|
</div>
|
||||||
|
</noscript>
|
||||||
|
<script nonce="metoyou-local-api-docs" src="/scalar/api-reference.js"></script>
|
||||||
|
<script nonce="metoyou-local-api-docs">
|
||||||
|
(function () {
|
||||||
|
var config = ${JSON.stringify(scalarConfig)};
|
||||||
|
|
||||||
|
if (!window.Scalar || typeof window.Scalar.createApiReference !== 'function') {
|
||||||
|
var root = document.getElementById('api-reference');
|
||||||
|
root.innerHTML = '<div class="placeholder"><h1>API Documentation</h1>'
|
||||||
|
+ '<p>The bundled Scalar UI could not be loaded.</p>'
|
||||||
|
+ '<p>Spec: <a href="' + config.url + '">' + config.url + '</a></p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Scalar.createApiReference('#api-reference', config);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
107
electron/api/http-helpers.ts
Normal file
107
electron/api/http-helpers.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { IncomingMessage, ServerResponse } from 'http';
|
||||||
|
|
||||||
|
export interface RequestContext {
|
||||||
|
method: string;
|
||||||
|
url: URL;
|
||||||
|
pathname: string;
|
||||||
|
headers: IncomingMessage['headers'];
|
||||||
|
remoteAddress: string;
|
||||||
|
bearerToken: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_BODY_BYTES = 1 * 1024 * 1024; // 1 MiB
|
||||||
|
|
||||||
|
export function getBearerToken(headers: IncomingMessage['headers']): string | null {
|
||||||
|
const raw = headers.authorization;
|
||||||
|
|
||||||
|
if (typeof raw !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
|
||||||
|
if (!/^bearer\s+/iu.test(trimmed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = trimmed.replace(/^bearer\s+/iu, '').trim();
|
||||||
|
|
||||||
|
return token.length > 0 ? token : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readJsonBody<T>(req: IncomingMessage): Promise<T> {
|
||||||
|
const length = Number(req.headers['content-length'] ?? 0);
|
||||||
|
|
||||||
|
if (length > MAX_BODY_BYTES) {
|
||||||
|
throw new HttpError(413, 'Request body too large', 'BODY_TOO_LARGE');
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let received = 0;
|
||||||
|
|
||||||
|
for await (const chunk of req) {
|
||||||
|
const buffer = chunk instanceof Buffer ? chunk : Buffer.from(chunk as string);
|
||||||
|
|
||||||
|
received += buffer.length;
|
||||||
|
|
||||||
|
if (received > MAX_BODY_BYTES) {
|
||||||
|
throw new HttpError(413, 'Request body too large', 'BODY_TOO_LARGE');
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.push(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunks.length === 0) {
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = Buffer.concat(chunks).toString('utf8');
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
throw new HttpError(400, 'Invalid JSON body', 'INVALID_JSON');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendJson(res: ServerResponse, status: number, payload: unknown): void {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.statusCode = status;
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendText(res: ServerResponse, status: number, text: string, contentType = 'text/plain; charset=utf-8'): void {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.statusCode = status;
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.end(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly code: string;
|
||||||
|
|
||||||
|
constructor(status: number, message: string, code: string) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendError(res: ServerResponse, error: unknown): void {
|
||||||
|
if (error instanceof HttpError) {
|
||||||
|
sendJson(res, error.status, { error: error.message, errorCode: error.code });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||||
|
|
||||||
|
sendJson(res, 500, { error: message, errorCode: 'INTERNAL_ERROR' });
|
||||||
|
}
|
||||||
8
electron/api/index.ts
Normal file
8
electron/api/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export {
|
||||||
|
applyLocalApiSettings,
|
||||||
|
getLocalApiSnapshot,
|
||||||
|
startLocalApiServer,
|
||||||
|
stopLocalApiServer,
|
||||||
|
type LocalApiSnapshot,
|
||||||
|
type LocalApiStatus
|
||||||
|
} from './local-api-server';
|
||||||
276
electron/api/local-api-server.ts
Normal file
276
electron/api/local-api-server.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import { createServer, IncomingMessage, Server, ServerResponse } from 'http';
|
||||||
|
import { createReadStream } from 'fs';
|
||||||
|
import { AddressInfo } from 'net';
|
||||||
|
import { pipeline } from 'stream/promises';
|
||||||
|
import { getDataSource } from '../db/database';
|
||||||
|
import { LocalApiSettings, readDesktopSettings } from '../desktop-settings';
|
||||||
|
import { authenticate, matchRoute } from './router';
|
||||||
|
import { clearAllTokens } from './auth-store';
|
||||||
|
import {
|
||||||
|
HttpError,
|
||||||
|
RequestContext,
|
||||||
|
getBearerToken,
|
||||||
|
readJsonBody,
|
||||||
|
sendError,
|
||||||
|
sendJson,
|
||||||
|
sendText
|
||||||
|
} from './http-helpers';
|
||||||
|
|
||||||
|
export type LocalApiStatus = 'stopped' | 'starting' | 'running' | 'error';
|
||||||
|
|
||||||
|
export interface LocalApiSnapshot {
|
||||||
|
status: LocalApiStatus;
|
||||||
|
host: string | null;
|
||||||
|
port: number | null;
|
||||||
|
baseUrl: string | null;
|
||||||
|
error: string | null;
|
||||||
|
exposeOnLan: boolean;
|
||||||
|
scalarEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let server: Server | null = null;
|
||||||
|
let currentStatus: LocalApiStatus = 'stopped';
|
||||||
|
let currentBindHost: string | null = null;
|
||||||
|
let currentBindPort: number | null = null;
|
||||||
|
let currentError: string | null = null;
|
||||||
|
let activeSettings: LocalApiSettings | null = null;
|
||||||
|
|
||||||
|
function pickBindHost(settings: LocalApiSettings): string {
|
||||||
|
return settings.exposeOnLan ? '0.0.0.0' : '127.0.0.1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBaseUrl(host: string, port: number): string {
|
||||||
|
const safeHost = host === '0.0.0.0' ? '127.0.0.1' : host;
|
||||||
|
return `http://${safeHost}:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendFile(res: ServerResponse, status: number, filePath: string, contentType: string): Promise<void> {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.statusCode = status;
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
}
|
||||||
|
|
||||||
|
await pipeline(createReadStream(filePath), res);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalApiSnapshot(): LocalApiSnapshot {
|
||||||
|
const settings = activeSettings ?? readDesktopSettings().localApi;
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: currentStatus,
|
||||||
|
host: currentBindHost,
|
||||||
|
port: currentBindPort,
|
||||||
|
baseUrl: currentBindHost && currentBindPort ? buildBaseUrl(currentBindHost, currentBindPort) : null,
|
||||||
|
error: currentError,
|
||||||
|
exposeOnLan: settings.exposeOnLan,
|
||||||
|
scalarEnabled: settings.scalarEnabled
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRequest(req: IncomingMessage, res: ServerResponse, settings: LocalApiSettings): Promise<void> {
|
||||||
|
// CORS for loopback origin only. Local-first; not a public API.
|
||||||
|
const origin = req.headers.origin;
|
||||||
|
const allowOrigin = origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/iu.test(origin) ? origin : 'null';
|
||||||
|
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', allowOrigin);
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||||
|
res.setHeader('Access-Control-Max-Age', '600');
|
||||||
|
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
res.statusCode = 204;
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let urlObj: URL;
|
||||||
|
|
||||||
|
try {
|
||||||
|
urlObj = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
||||||
|
} catch {
|
||||||
|
sendJson(res, 400, { error: 'Invalid URL', errorCode: 'INVALID_URL' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestContext: RequestContext = {
|
||||||
|
method: (req.method ?? 'GET').toUpperCase(),
|
||||||
|
url: urlObj,
|
||||||
|
pathname: urlObj.pathname,
|
||||||
|
headers: req.headers,
|
||||||
|
remoteAddress: req.socket.remoteAddress ?? '',
|
||||||
|
bearerToken: getBearerToken(req.headers)
|
||||||
|
};
|
||||||
|
|
||||||
|
const { match, methodNotAllowed } = matchRoute(requestContext.method, requestContext.pathname);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
if (methodNotAllowed) {
|
||||||
|
sendJson(res, 405, { error: 'Method not allowed', errorCode: 'METHOD_NOT_ALLOWED' });
|
||||||
|
} else {
|
||||||
|
sendJson(res, 404, { error: 'Not found', errorCode: 'NOT_FOUND' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.requiresAuth) {
|
||||||
|
const issued = authenticate(requestContext.bearerToken);
|
||||||
|
|
||||||
|
if (!issued) {
|
||||||
|
sendJson(res, 401, { error: 'Authentication required', errorCode: 'UNAUTHORIZED' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataSource = getDataSource();
|
||||||
|
|
||||||
|
if (!dataSource || !dataSource.isInitialized) {
|
||||||
|
sendJson(res, 503, { error: 'Database not initialised', errorCode: 'DB_UNAVAILABLE' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bodyCache: unknown | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const baseUrl = buildBaseUrl(currentBindHost ?? '127.0.0.1', currentBindPort ?? settings.port);
|
||||||
|
const result = await match.handler({
|
||||||
|
request: requestContext,
|
||||||
|
settings,
|
||||||
|
baseUrl,
|
||||||
|
dataSource,
|
||||||
|
bodyBuffer: async () => {
|
||||||
|
if (bodyCache === undefined) {
|
||||||
|
bodyCache = await readJsonBody<unknown>(req);
|
||||||
|
}
|
||||||
|
return bodyCache;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status === 204) {
|
||||||
|
res.statusCode = 204;
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.filePath) {
|
||||||
|
await sendFile(res, result.status, result.filePath, result.contentType ?? 'application/octet-stream');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.rawBody !== undefined) {
|
||||||
|
sendText(res, result.status, result.rawBody, result.contentType ?? 'text/plain; charset=utf-8');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(res, result.status, result.body);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof HttpError)) {
|
||||||
|
console.error('[LocalApi] Request handler error:', error);
|
||||||
|
}
|
||||||
|
sendError(res, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartResult {
|
||||||
|
ok: boolean;
|
||||||
|
snapshot: LocalApiSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startLocalApiServer(settings: LocalApiSettings): Promise<StartResult> {
|
||||||
|
if (server) {
|
||||||
|
await stopLocalApiServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
activeSettings = { ...settings, allowedSignalingServers: [...settings.allowedSignalingServers] };
|
||||||
|
currentStatus = 'starting';
|
||||||
|
currentError = null;
|
||||||
|
currentBindHost = pickBindHost(settings);
|
||||||
|
currentBindPort = settings.port;
|
||||||
|
|
||||||
|
const httpServer = createServer((req, res) => {
|
||||||
|
void handleRequest(req, res, activeSettings!).catch((error) => {
|
||||||
|
console.error('[LocalApi] Unhandled request error:', error);
|
||||||
|
try {
|
||||||
|
sendError(res, error);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return await new Promise<StartResult>((resolve) => {
|
||||||
|
httpServer.once('error', (error) => {
|
||||||
|
currentStatus = 'error';
|
||||||
|
currentError = (error as Error).message;
|
||||||
|
currentBindPort = null;
|
||||||
|
server = null;
|
||||||
|
activeSettings = null;
|
||||||
|
console.error('[LocalApi] Failed to start:', error);
|
||||||
|
resolve({ ok: false, snapshot: getLocalApiSnapshot() });
|
||||||
|
});
|
||||||
|
|
||||||
|
httpServer.listen(settings.port, pickBindHost(settings), () => {
|
||||||
|
const address = httpServer.address() as AddressInfo | null;
|
||||||
|
|
||||||
|
server = httpServer;
|
||||||
|
currentStatus = 'running';
|
||||||
|
currentBindPort = address?.port ?? settings.port;
|
||||||
|
currentError = null;
|
||||||
|
console.log(`[LocalApi] Listening on http://${currentBindHost}:${currentBindPort}`);
|
||||||
|
resolve({ ok: true, snapshot: getLocalApiSnapshot() });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopLocalApiServer(): Promise<LocalApiSnapshot> {
|
||||||
|
const httpServer = server;
|
||||||
|
|
||||||
|
if (!httpServer) {
|
||||||
|
currentStatus = 'stopped';
|
||||||
|
currentBindHost = null;
|
||||||
|
currentBindPort = null;
|
||||||
|
activeSettings = null;
|
||||||
|
return getLocalApiSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
httpServer.close(() => resolve());
|
||||||
|
// close() waits for connections; force-close keep-alives so it returns promptly.
|
||||||
|
httpServer.closeAllConnections?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
server = null;
|
||||||
|
currentStatus = 'stopped';
|
||||||
|
currentBindHost = null;
|
||||||
|
currentBindPort = null;
|
||||||
|
currentError = null;
|
||||||
|
activeSettings = null;
|
||||||
|
clearAllTokens();
|
||||||
|
console.log('[LocalApi] Stopped');
|
||||||
|
return getLocalApiSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyLocalApiSettings(): Promise<LocalApiSnapshot> {
|
||||||
|
const settings = readDesktopSettings().localApi;
|
||||||
|
|
||||||
|
if (!settings.enabled) {
|
||||||
|
return await stopLocalApiServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If already running with the same bind config, no-op (settings like
|
||||||
|
// scalarEnabled / allowedSignalingServers are read on every request).
|
||||||
|
if (
|
||||||
|
server
|
||||||
|
&& activeSettings
|
||||||
|
&& currentStatus === 'running'
|
||||||
|
&& activeSettings.port === settings.port
|
||||||
|
&& activeSettings.exposeOnLan === settings.exposeOnLan
|
||||||
|
) {
|
||||||
|
activeSettings = { ...settings, allowedSignalingServers: [...settings.allowedSignalingServers] };
|
||||||
|
return getLocalApiSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await startLocalApiServer(settings);
|
||||||
|
|
||||||
|
return result.snapshot;
|
||||||
|
}
|
||||||
241
electron/api/openapi.ts
Normal file
241
electron/api/openapi.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
export interface OpenApiBuildOptions {
|
||||||
|
baseUrl: string;
|
||||||
|
appVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOpenApiDocument(options: OpenApiBuildOptions): unknown {
|
||||||
|
const { baseUrl, appVersion } = options;
|
||||||
|
|
||||||
|
return {
|
||||||
|
openapi: '3.1.0',
|
||||||
|
info: {
|
||||||
|
title: 'MetoYou Local Desktop API',
|
||||||
|
version: appVersion,
|
||||||
|
description:
|
||||||
|
'Authenticated local HTTP API exposed by the MetoYou desktop app. '
|
||||||
|
+ 'Authentication is performed against a configured signaling server. '
|
||||||
|
+ 'Bearer tokens issued here are scoped to this device only.'
|
||||||
|
},
|
||||||
|
servers: [{ url: baseUrl }],
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
bearerAuth: {
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'bearer',
|
||||||
|
bearerFormat: 'opaque'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
schemas: {
|
||||||
|
Error: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['error'],
|
||||||
|
properties: {
|
||||||
|
error: { type: 'string' },
|
||||||
|
errorCode: { type: 'string' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
LoginRequest: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['username', 'password', 'serverUrl'],
|
||||||
|
properties: {
|
||||||
|
username: { type: 'string' },
|
||||||
|
password: { type: 'string' },
|
||||||
|
serverUrl: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uri',
|
||||||
|
description: 'Base URL of the signaling server to authenticate against. Must be in the allowed list configured in the desktop app.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
LoginResponse: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['token', 'expiresAt', 'user'],
|
||||||
|
properties: {
|
||||||
|
token: { type: 'string' },
|
||||||
|
expiresAt: { type: 'integer', format: 'int64' },
|
||||||
|
user: { $ref: '#/components/schemas/AuthUser' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AuthUser: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['id', 'username', 'displayName'],
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
username: { type: 'string' },
|
||||||
|
displayName: { type: 'string' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Profile: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
username: { type: 'string' },
|
||||||
|
displayName: { type: 'string' },
|
||||||
|
description: { type: 'string' },
|
||||||
|
avatarUrl: { type: 'string' },
|
||||||
|
status: { type: 'string' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Room: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
name: { type: 'string' }
|
||||||
|
},
|
||||||
|
additionalProperties: true
|
||||||
|
},
|
||||||
|
Message: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
roomId: { type: 'string' },
|
||||||
|
channelId: { type: 'string' },
|
||||||
|
senderId: { type: 'string' },
|
||||||
|
senderName: { type: 'string' },
|
||||||
|
content: { type: 'string' },
|
||||||
|
timestamp: { type: 'integer', format: 'int64' },
|
||||||
|
editedAt: { type: 'integer', format: 'int64' },
|
||||||
|
isDeleted: { type: 'boolean' }
|
||||||
|
},
|
||||||
|
additionalProperties: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
paths: {
|
||||||
|
'/api/health': {
|
||||||
|
get: {
|
||||||
|
security: [],
|
||||||
|
summary: 'Liveness probe',
|
||||||
|
responses: {
|
||||||
|
'200': {
|
||||||
|
description: 'Service is alive',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
status: { type: 'string' },
|
||||||
|
version: { type: 'string' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'/api/openapi.json': {
|
||||||
|
get: {
|
||||||
|
security: [],
|
||||||
|
summary: 'OpenAPI specification',
|
||||||
|
responses: { '200': { description: 'This document' } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'/api/auth/login': {
|
||||||
|
post: {
|
||||||
|
security: [],
|
||||||
|
summary: 'Exchange username/password (validated by a signaling server) for a bearer token',
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/LoginRequest' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
'200': {
|
||||||
|
description: 'Token issued',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/LoginResponse' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'401': {
|
||||||
|
description: 'Invalid credentials',
|
||||||
|
content: {
|
||||||
|
'application/json': { schema: { $ref: '#/components/schemas/Error' } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'403': {
|
||||||
|
description: 'Signaling server URL not allowed',
|
||||||
|
content: {
|
||||||
|
'application/json': { schema: { $ref: '#/components/schemas/Error' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'/api/auth/logout': {
|
||||||
|
post: {
|
||||||
|
summary: 'Revoke the current bearer token',
|
||||||
|
responses: { '204': { description: 'Token revoked' } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'/api/profile': {
|
||||||
|
get: {
|
||||||
|
summary: 'Get the current user profile',
|
||||||
|
responses: {
|
||||||
|
'200': {
|
||||||
|
description: 'Current user profile',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/Profile' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'404': {
|
||||||
|
description: 'No current user is set on this device',
|
||||||
|
content: {
|
||||||
|
'application/json': { schema: { $ref: '#/components/schemas/Error' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'/api/rooms': {
|
||||||
|
get: {
|
||||||
|
summary: 'List rooms (servers) known to this device',
|
||||||
|
responses: {
|
||||||
|
'200': {
|
||||||
|
description: 'Rooms array',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'array',
|
||||||
|
items: { $ref: '#/components/schemas/Room' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'/api/rooms/{roomId}/messages': {
|
||||||
|
get: {
|
||||||
|
summary: 'List messages for a room',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
|
{ name: 'limit', in: 'query', required: false, schema: { type: 'integer', minimum: 1, maximum: 500 } },
|
||||||
|
{ name: 'offset', in: 'query', required: false, schema: { type: 'integer', minimum: 0 } }
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
'200': {
|
||||||
|
description: 'Messages array',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'array',
|
||||||
|
items: { $ref: '#/components/schemas/Message' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
294
electron/api/router.ts
Normal file
294
electron/api/router.ts
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import { app, net } from 'electron';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { buildQueryHandlers } from '../cqrs/queries';
|
||||||
|
import { QueryType, QueryTypeKey, Query } from '../cqrs/types';
|
||||||
|
import { issueToken, consumeToken, revokeToken, IssuedToken } from './auth-store';
|
||||||
|
import { buildOpenApiDocument } from './openapi';
|
||||||
|
import { HttpError, RequestContext, readJsonBody } from './http-helpers';
|
||||||
|
import { getDocsHtml, getScalarApiReferenceBundlePath } from './docs-html';
|
||||||
|
import { LocalApiSettings } from '../desktop-settings';
|
||||||
|
|
||||||
|
export interface RouteResponse {
|
||||||
|
status: number;
|
||||||
|
body: unknown;
|
||||||
|
contentType?: string;
|
||||||
|
filePath?: string;
|
||||||
|
rawBody?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteContext {
|
||||||
|
request: RequestContext;
|
||||||
|
settings: LocalApiSettings;
|
||||||
|
baseUrl: string;
|
||||||
|
dataSource: DataSource;
|
||||||
|
bodyBuffer: () => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RouteHandler = (context: RouteContext) => Promise<RouteResponse>;
|
||||||
|
|
||||||
|
interface RouteMatch {
|
||||||
|
handler: RouteHandler;
|
||||||
|
params: Record<string, string>;
|
||||||
|
requiresAuth: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteDefinition {
|
||||||
|
method: string;
|
||||||
|
pattern: RegExp;
|
||||||
|
paramKeys: string[];
|
||||||
|
handler: RouteHandler;
|
||||||
|
requiresAuth: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compilePattern(template: string): { pattern: RegExp; paramKeys: string[] } {
|
||||||
|
const paramKeys: string[] = [];
|
||||||
|
const escaped = template.replace(/[.*+?^${}()|[\]\\]/g, (match) => {
|
||||||
|
if (match === '*' || match === '+' || match === '?')
|
||||||
|
return `\\${match}`;
|
||||||
|
return `\\${match}`;
|
||||||
|
});
|
||||||
|
const source = template.replace(/\{([^}]+)\}/g, (_full, key: string) => {
|
||||||
|
paramKeys.push(key);
|
||||||
|
return '([^/]+)';
|
||||||
|
});
|
||||||
|
void escaped;
|
||||||
|
|
||||||
|
return { pattern: new RegExp(`^${source}$`), paramKeys };
|
||||||
|
}
|
||||||
|
|
||||||
|
function defineRoute(method: string, template: string, handler: RouteHandler, requiresAuth: boolean): RouteDefinition {
|
||||||
|
const compiled = compilePattern(template);
|
||||||
|
|
||||||
|
return { method: method.toUpperCase(), pattern: compiled.pattern, paramKeys: compiled.paramKeys, handler, requiresAuth };
|
||||||
|
}
|
||||||
|
|
||||||
|
function runQuery<T>(dataSource: DataSource, query: Query): Promise<T> {
|
||||||
|
const handlers = buildQueryHandlers(dataSource) as Record<QueryTypeKey, (q: Query) => Promise<unknown>>;
|
||||||
|
const handler = handlers[query.type as QueryTypeKey];
|
||||||
|
|
||||||
|
if (!handler) {
|
||||||
|
throw new HttpError(500, `No handler registered for query: ${query.type}`, 'UNKNOWN_QUERY');
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler(query) as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampInt(value: unknown, min: number, max: number, fallback: number): number {
|
||||||
|
const parsed = typeof value === 'string' ? Number(value) : NaN;
|
||||||
|
|
||||||
|
if (!Number.isFinite(parsed))
|
||||||
|
return fallback;
|
||||||
|
|
||||||
|
return Math.max(min, Math.min(max, Math.floor(parsed)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROUTES: RouteDefinition[] = [
|
||||||
|
defineRoute('GET', '/api/health', async (ctx): Promise<RouteResponse> => ({
|
||||||
|
status: 200,
|
||||||
|
body: { status: 'ok', version: app.getVersion(), timestamp: Date.now(), exposeOnLan: ctx.settings.exposeOnLan }
|
||||||
|
}), false),
|
||||||
|
|
||||||
|
defineRoute('GET', '/api/openapi.json', async (ctx): Promise<RouteResponse> => ({
|
||||||
|
status: 200,
|
||||||
|
body: buildOpenApiDocument({ baseUrl: ctx.baseUrl, appVersion: app.getVersion() })
|
||||||
|
}), false),
|
||||||
|
|
||||||
|
defineRoute('GET', '/docs', async (ctx): Promise<RouteResponse> => {
|
||||||
|
if (!ctx.settings.scalarEnabled) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: null,
|
||||||
|
contentType: 'text/plain; charset=utf-8',
|
||||||
|
rawBody: 'API documentation is disabled. Enable Scalar in desktop settings to view it.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: null,
|
||||||
|
contentType: 'text/html; charset=utf-8',
|
||||||
|
rawBody: getDocsHtml(`${ctx.baseUrl}/api/openapi.json`)
|
||||||
|
};
|
||||||
|
}, false),
|
||||||
|
|
||||||
|
defineRoute('GET', '/scalar/api-reference.js', async (ctx): Promise<RouteResponse> => {
|
||||||
|
if (!ctx.settings.scalarEnabled) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: null,
|
||||||
|
contentType: 'text/plain; charset=utf-8',
|
||||||
|
rawBody: 'API documentation is disabled. Enable Scalar in desktop settings to view it.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundlePath = await getScalarApiReferenceBundlePath();
|
||||||
|
|
||||||
|
if (!bundlePath) {
|
||||||
|
throw new HttpError(503, 'Scalar API reference bundle is not available in this build', 'SCALAR_BUNDLE_MISSING');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: null,
|
||||||
|
contentType: 'application/javascript; charset=utf-8',
|
||||||
|
filePath: bundlePath
|
||||||
|
};
|
||||||
|
}, false),
|
||||||
|
|
||||||
|
defineRoute('POST', '/api/auth/login', async (ctx): Promise<RouteResponse> => {
|
||||||
|
const body = await ctx.bodyBuffer() as { username?: unknown; password?: unknown; serverUrl?: unknown };
|
||||||
|
const username = typeof body.username === 'string' ? body.username.trim() : '';
|
||||||
|
const password = typeof body.password === 'string' ? body.password : '';
|
||||||
|
const serverUrl = typeof body.serverUrl === 'string' ? body.serverUrl.trim().replace(/\/+$/u, '') : '';
|
||||||
|
|
||||||
|
if (!username || !password || !serverUrl) {
|
||||||
|
throw new HttpError(400, 'username, password, and serverUrl are required', 'INVALID_REQUEST');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^https?:\/\//iu.test(serverUrl)) {
|
||||||
|
throw new HttpError(400, 'serverUrl must be an http or https URL', 'INVALID_REQUEST');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.settings.allowedSignalingServers.length === 0) {
|
||||||
|
throw new HttpError(403, 'No signaling servers are allowed for local API authentication. Add one in desktop settings.', 'NO_ALLOWED_SERVERS');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctx.settings.allowedSignalingServers.includes(serverUrl)) {
|
||||||
|
throw new HttpError(403, 'Signaling server URL is not in the allowed list', 'SERVER_NOT_ALLOWED');
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await net.fetch(`${serverUrl}/api/users/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new HttpError(502, `Failed to reach signaling server: ${(error as Error).message}`, 'UPSTREAM_UNREACHABLE');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new HttpError(401, 'Invalid credentials', 'INVALID_CREDENTIALS');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new HttpError(502, `Signaling server rejected login (${response.status})`, 'UPSTREAM_ERROR');
|
||||||
|
}
|
||||||
|
|
||||||
|
const remote = await response.json() as { id?: string; username?: string; displayName?: string };
|
||||||
|
|
||||||
|
if (!remote.id || !remote.username) {
|
||||||
|
throw new HttpError(502, 'Signaling server returned an unexpected response', 'UPSTREAM_BAD_RESPONSE');
|
||||||
|
}
|
||||||
|
|
||||||
|
const issued = issueToken({
|
||||||
|
userId: remote.id,
|
||||||
|
username: remote.username,
|
||||||
|
displayName: remote.displayName ?? remote.username,
|
||||||
|
signalingServerUrl: serverUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
token: issued.token,
|
||||||
|
expiresAt: issued.expiresAt,
|
||||||
|
user: {
|
||||||
|
id: issued.userId,
|
||||||
|
username: issued.username,
|
||||||
|
displayName: issued.displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, false),
|
||||||
|
|
||||||
|
defineRoute('POST', '/api/auth/logout', async (ctx): Promise<RouteResponse> => {
|
||||||
|
if (ctx.request.bearerToken) {
|
||||||
|
revokeToken(ctx.request.bearerToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 204, body: null };
|
||||||
|
}, true),
|
||||||
|
|
||||||
|
defineRoute('GET', '/api/profile', async (ctx): Promise<RouteResponse> => {
|
||||||
|
const user = await runQuery<unknown>(ctx.dataSource, {
|
||||||
|
type: QueryType.GetCurrentUser,
|
||||||
|
payload: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new HttpError(404, 'No current user is set on this device', 'NO_CURRENT_USER');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 200, body: user };
|
||||||
|
}, true),
|
||||||
|
|
||||||
|
defineRoute('GET', '/api/rooms', async (ctx): Promise<RouteResponse> => {
|
||||||
|
const rooms = await runQuery<unknown[]>(ctx.dataSource, {
|
||||||
|
type: QueryType.GetAllRooms,
|
||||||
|
payload: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status: 200, body: rooms ?? [] };
|
||||||
|
}, true),
|
||||||
|
|
||||||
|
defineRoute('GET', '/api/rooms/{roomId}/messages', async (ctx): Promise<RouteResponse> => {
|
||||||
|
const roomId = ctx.request.url.pathname.match(/\/api\/rooms\/([^/]+)\/messages$/u)?.[1];
|
||||||
|
|
||||||
|
if (!roomId)
|
||||||
|
throw new HttpError(400, 'roomId is required', 'INVALID_REQUEST');
|
||||||
|
|
||||||
|
const limit = clampInt(ctx.request.url.searchParams.get('limit'), 1, 500, 100);
|
||||||
|
const offset = clampInt(ctx.request.url.searchParams.get('offset'), 0, Number.MAX_SAFE_INTEGER, 0);
|
||||||
|
|
||||||
|
const messages = await runQuery<unknown[]>(ctx.dataSource, {
|
||||||
|
type: QueryType.GetMessages,
|
||||||
|
payload: { roomId: decodeURIComponent(roomId), limit, offset }
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status: 200, body: messages ?? [] };
|
||||||
|
}, true)
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface RoutingResult {
|
||||||
|
match: RouteMatch | null;
|
||||||
|
methodNotAllowed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchRoute(method: string, pathname: string): RoutingResult {
|
||||||
|
let methodNotAllowed = false;
|
||||||
|
|
||||||
|
for (const route of ROUTES) {
|
||||||
|
const result = route.pattern.exec(pathname);
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (route.method !== method) {
|
||||||
|
methodNotAllowed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (let index = 0; index < route.paramKeys.length; index++) {
|
||||||
|
params[route.paramKeys[index]] = result[index + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
match: { handler: route.handler, params, requiresAuth: route.requiresAuth },
|
||||||
|
methodNotAllowed: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { match: null, methodNotAllowed };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authenticate(token: string | null): IssuedToken | null {
|
||||||
|
if (!token)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return consumeToken(token);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { app, BrowserWindow } from 'electron';
|
|||||||
import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing';
|
import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing';
|
||||||
import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater';
|
import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater';
|
||||||
import { synchronizeAutoStartSetting } from './auto-start';
|
import { synchronizeAutoStartSetting } from './auto-start';
|
||||||
|
import { applyLocalApiSettings, stopLocalApiServer } from '../api';
|
||||||
import {
|
import {
|
||||||
initializeDatabase,
|
initializeDatabase,
|
||||||
destroyDatabase,
|
destroyDatabase,
|
||||||
@@ -21,6 +22,14 @@ import {
|
|||||||
} from '../ipc';
|
} from '../ipc';
|
||||||
import { startIdleMonitor, stopIdleMonitor } from '../idle/idle-monitor';
|
import { startIdleMonitor, stopIdleMonitor } from '../idle/idle-monitor';
|
||||||
|
|
||||||
|
function startLocalApiAfterWindowReady(): void {
|
||||||
|
setImmediate(() => {
|
||||||
|
void applyLocalApiSettings().catch((error: unknown) => {
|
||||||
|
console.error('[LocalApi] Failed to apply settings after window startup:', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function registerAppLifecycle(): void {
|
export function registerAppLifecycle(): void {
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
const dockIconPath = getDockIconPath();
|
const dockIconPath = getDockIconPath();
|
||||||
@@ -35,6 +44,7 @@ export function registerAppLifecycle(): void {
|
|||||||
await synchronizeAutoStartSetting();
|
await synchronizeAutoStartSetting();
|
||||||
initializeDesktopUpdater();
|
initializeDesktopUpdater();
|
||||||
await createWindow();
|
await createWindow();
|
||||||
|
startLocalApiAfterWindowReady();
|
||||||
startIdleMonitor();
|
startIdleMonitor();
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
@@ -60,6 +70,7 @@ export function registerAppLifecycle(): void {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
shutdownDesktopUpdater();
|
shutdownDesktopUpdater();
|
||||||
stopIdleMonitor();
|
stopIdleMonitor();
|
||||||
|
await stopLocalApiServer();
|
||||||
await cleanupLinuxScreenShareAudioRouting();
|
await cleanupLinuxScreenShareAudioRouting();
|
||||||
await destroyDatabase();
|
await destroyDatabase();
|
||||||
app.quit();
|
app.quit();
|
||||||
|
|||||||
@@ -4,11 +4,20 @@ import * as path from 'path';
|
|||||||
|
|
||||||
export type AutoUpdateMode = 'auto' | 'off' | 'version';
|
export type AutoUpdateMode = 'auto' | 'off' | 'version';
|
||||||
|
|
||||||
|
export interface LocalApiSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
port: number;
|
||||||
|
exposeOnLan: boolean;
|
||||||
|
scalarEnabled: boolean;
|
||||||
|
allowedSignalingServers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface DesktopSettings {
|
export interface DesktopSettings {
|
||||||
autoUpdateMode: AutoUpdateMode;
|
autoUpdateMode: AutoUpdateMode;
|
||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
closeToTray: boolean;
|
closeToTray: boolean;
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
|
localApi: LocalApiSettings;
|
||||||
manifestUrls: string[];
|
manifestUrls: string[];
|
||||||
preferredVersion: string | null;
|
preferredVersion: string | null;
|
||||||
vaapiVideoEncode: boolean;
|
vaapiVideoEncode: boolean;
|
||||||
@@ -19,11 +28,20 @@ export interface DesktopSettingsSnapshot extends DesktopSettings {
|
|||||||
restartRequired: boolean;
|
restartRequired: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_LOCAL_API_SETTINGS: LocalApiSettings = {
|
||||||
|
enabled: false,
|
||||||
|
port: 17878,
|
||||||
|
exposeOnLan: false,
|
||||||
|
scalarEnabled: false,
|
||||||
|
allowedSignalingServers: []
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
||||||
autoUpdateMode: 'auto',
|
autoUpdateMode: 'auto',
|
||||||
autoStart: true,
|
autoStart: true,
|
||||||
closeToTray: true,
|
closeToTray: true,
|
||||||
hardwareAcceleration: true,
|
hardwareAcceleration: true,
|
||||||
|
localApi: { ...DEFAULT_LOCAL_API_SETTINGS },
|
||||||
manifestUrls: [],
|
manifestUrls: [],
|
||||||
preferredVersion: null,
|
preferredVersion: null,
|
||||||
vaapiVideoEncode: false
|
vaapiVideoEncode: false
|
||||||
@@ -61,6 +79,60 @@ function normalizeManifestUrls(value: unknown): string[] {
|
|||||||
return manifestUrls;
|
return manifestUrls;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePort(value: unknown, fallback: number): number {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = Math.floor(value);
|
||||||
|
|
||||||
|
if (port < 1 || port > 65535) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAllowedSignalingServers(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const urls: string[] = [];
|
||||||
|
|
||||||
|
for (const entry of value) {
|
||||||
|
if (typeof entry !== 'string') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = entry.trim().replace(/\/+$/u, '');
|
||||||
|
|
||||||
|
if (!trimmed || urls.includes(trimmed)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^https?:\/\//iu.test(trimmed)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
urls.push(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLocalApiSettings(value: unknown): LocalApiSettings {
|
||||||
|
const source = (value && typeof value === 'object') ? value as Partial<LocalApiSettings> : {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: typeof source.enabled === 'boolean' ? source.enabled : DEFAULT_LOCAL_API_SETTINGS.enabled,
|
||||||
|
port: normalizePort(source.port, DEFAULT_LOCAL_API_SETTINGS.port),
|
||||||
|
exposeOnLan: typeof source.exposeOnLan === 'boolean' ? source.exposeOnLan : DEFAULT_LOCAL_API_SETTINGS.exposeOnLan,
|
||||||
|
scalarEnabled: typeof source.scalarEnabled === 'boolean' ? source.scalarEnabled : DEFAULT_LOCAL_API_SETTINGS.scalarEnabled,
|
||||||
|
allowedSignalingServers: normalizeAllowedSignalingServers(source.allowedSignalingServers)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getDesktopSettingsSnapshot(): DesktopSettingsSnapshot {
|
export function getDesktopSettingsSnapshot(): DesktopSettingsSnapshot {
|
||||||
const storedSettings = readDesktopSettings();
|
const storedSettings = readDesktopSettings();
|
||||||
const runtimeHardwareAcceleration = app.isHardwareAccelerationEnabled();
|
const runtimeHardwareAcceleration = app.isHardwareAccelerationEnabled();
|
||||||
@@ -97,6 +169,7 @@ export function readDesktopSettings(): DesktopSettings {
|
|||||||
hardwareAcceleration: typeof parsed.hardwareAcceleration === 'boolean'
|
hardwareAcceleration: typeof parsed.hardwareAcceleration === 'boolean'
|
||||||
? parsed.hardwareAcceleration
|
? parsed.hardwareAcceleration
|
||||||
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
||||||
|
localApi: normalizeLocalApiSettings(parsed.localApi),
|
||||||
manifestUrls: normalizeManifestUrls(parsed.manifestUrls),
|
manifestUrls: normalizeManifestUrls(parsed.manifestUrls),
|
||||||
preferredVersion: normalizePreferredVersion(parsed.preferredVersion)
|
preferredVersion: normalizePreferredVersion(parsed.preferredVersion)
|
||||||
};
|
};
|
||||||
@@ -106,9 +179,13 @@ export function readDesktopSettings(): DesktopSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopSettingsSnapshot {
|
export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopSettingsSnapshot {
|
||||||
|
const previousSettings = readDesktopSettings();
|
||||||
const mergedSettings = {
|
const mergedSettings = {
|
||||||
...readDesktopSettings(),
|
...previousSettings,
|
||||||
...patch
|
...patch,
|
||||||
|
localApi: patch.localApi
|
||||||
|
? { ...previousSettings.localApi, ...patch.localApi }
|
||||||
|
: previousSettings.localApi
|
||||||
};
|
};
|
||||||
const nextSettings: DesktopSettings = {
|
const nextSettings: DesktopSettings = {
|
||||||
autoUpdateMode: normalizeAutoUpdateMode(mergedSettings.autoUpdateMode),
|
autoUpdateMode: normalizeAutoUpdateMode(mergedSettings.autoUpdateMode),
|
||||||
@@ -121,6 +198,7 @@ export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopS
|
|||||||
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
|
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
|
||||||
? mergedSettings.hardwareAcceleration
|
? mergedSettings.hardwareAcceleration
|
||||||
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
||||||
|
localApi: normalizeLocalApiSettings(mergedSettings.localApi),
|
||||||
manifestUrls: normalizeManifestUrls(mergedSettings.manifestUrls),
|
manifestUrls: normalizeManifestUrls(mergedSettings.manifestUrls),
|
||||||
preferredVersion: normalizePreferredVersion(mergedSettings.preferredVersion),
|
preferredVersion: normalizePreferredVersion(mergedSettings.preferredVersion),
|
||||||
vaapiVideoEncode: typeof mergedSettings.vaapiVideoEncode === 'boolean'
|
vaapiVideoEncode: typeof mergedSettings.vaapiVideoEncode === 'boolean'
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import {
|
|||||||
updateDesktopSettings,
|
updateDesktopSettings,
|
||||||
type DesktopSettings
|
type DesktopSettings
|
||||||
} from '../desktop-settings';
|
} from '../desktop-settings';
|
||||||
|
import {
|
||||||
|
applyLocalApiSettings,
|
||||||
|
getLocalApiSnapshot
|
||||||
|
} from '../api';
|
||||||
import {
|
import {
|
||||||
activateLinuxScreenShareAudioRouting,
|
activateLinuxScreenShareAudioRouting,
|
||||||
deactivateLinuxScreenShareAudioRouting,
|
deactivateLinuxScreenShareAudioRouting,
|
||||||
@@ -452,9 +456,27 @@ export function setupSystemHandlers(): void {
|
|||||||
await synchronizeAutoStartSetting(snapshot.autoStart);
|
await synchronizeAutoStartSetting(snapshot.autoStart);
|
||||||
updateCloseToTraySetting(snapshot.closeToTray);
|
updateCloseToTraySetting(snapshot.closeToTray);
|
||||||
await handleDesktopSettingsChanged();
|
await handleDesktopSettingsChanged();
|
||||||
|
await applyLocalApiSettings();
|
||||||
return snapshot;
|
return snapshot;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('get-local-api-status', () => getLocalApiSnapshot());
|
||||||
|
|
||||||
|
ipcMain.handle('open-local-api-docs', async () => {
|
||||||
|
const snapshot = getLocalApiSnapshot();
|
||||||
|
|
||||||
|
if (snapshot.status !== 'running' || !snapshot.baseUrl) {
|
||||||
|
return { opened: false, reason: 'Local API is not running' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!snapshot.scalarEnabled) {
|
||||||
|
return { opened: false, reason: 'Scalar docs are disabled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await shell.openExternal(`${snapshot.baseUrl}/docs`);
|
||||||
|
return { opened: true };
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('relaunch-app', () => {
|
ipcMain.handle('relaunch-app', () => {
|
||||||
app.relaunch();
|
app.relaunch();
|
||||||
app.exit(0);
|
app.exit(0);
|
||||||
|
|||||||
@@ -119,6 +119,26 @@ export interface LocalPluginManifestDescriptor {
|
|||||||
readmePath?: string;
|
readmePath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LocalApiSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
port: number;
|
||||||
|
exposeOnLan: boolean;
|
||||||
|
scalarEnabled: boolean;
|
||||||
|
allowedSignalingServers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LocalApiStatus = 'stopped' | 'starting' | 'running' | 'error';
|
||||||
|
|
||||||
|
export interface LocalApiSnapshot {
|
||||||
|
status: LocalApiStatus;
|
||||||
|
host: string | null;
|
||||||
|
port: number | null;
|
||||||
|
baseUrl: string | null;
|
||||||
|
error: string | null;
|
||||||
|
exposeOnLan: boolean;
|
||||||
|
scalarEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LocalPluginDiscoveryError {
|
export interface LocalPluginDiscoveryError {
|
||||||
manifestPath?: string;
|
manifestPath?: string;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -215,10 +235,12 @@ export interface ElectronAPI {
|
|||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
closeToTray: boolean;
|
closeToTray: boolean;
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
|
localApi: LocalApiSettings;
|
||||||
manifestUrls: string[];
|
manifestUrls: string[];
|
||||||
preferredVersion: string | null;
|
preferredVersion: string | null;
|
||||||
runtimeHardwareAcceleration: boolean;
|
runtimeHardwareAcceleration: boolean;
|
||||||
restartRequired: boolean;
|
restartRequired: boolean;
|
||||||
|
vaapiVideoEncode: boolean;
|
||||||
}>;
|
}>;
|
||||||
showDesktopNotification: (payload: DesktopNotificationPayload) => Promise<boolean>;
|
showDesktopNotification: (payload: DesktopNotificationPayload) => Promise<boolean>;
|
||||||
requestWindowAttention: () => Promise<boolean>;
|
requestWindowAttention: () => Promise<boolean>;
|
||||||
@@ -235,6 +257,7 @@ export interface ElectronAPI {
|
|||||||
autoStart?: boolean;
|
autoStart?: boolean;
|
||||||
closeToTray?: boolean;
|
closeToTray?: boolean;
|
||||||
hardwareAcceleration?: boolean;
|
hardwareAcceleration?: boolean;
|
||||||
|
localApi?: Partial<LocalApiSettings>;
|
||||||
manifestUrls?: string[];
|
manifestUrls?: string[];
|
||||||
preferredVersion?: string | null;
|
preferredVersion?: string | null;
|
||||||
vaapiVideoEncode?: boolean;
|
vaapiVideoEncode?: boolean;
|
||||||
@@ -243,11 +266,15 @@ export interface ElectronAPI {
|
|||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
closeToTray: boolean;
|
closeToTray: boolean;
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
|
localApi: LocalApiSettings;
|
||||||
manifestUrls: string[];
|
manifestUrls: string[];
|
||||||
preferredVersion: string | null;
|
preferredVersion: string | null;
|
||||||
runtimeHardwareAcceleration: boolean;
|
runtimeHardwareAcceleration: boolean;
|
||||||
restartRequired: boolean;
|
restartRequired: boolean;
|
||||||
|
vaapiVideoEncode: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
getLocalApiStatus: () => Promise<LocalApiSnapshot>;
|
||||||
|
openLocalApiDocs: () => Promise<{ opened: boolean; reason?: string }>;
|
||||||
relaunchApp: () => Promise<boolean>;
|
relaunchApp: () => Promise<boolean>;
|
||||||
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
|
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
|
||||||
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
||||||
@@ -357,6 +384,8 @@ const electronAPI: ElectronAPI = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch),
|
setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch),
|
||||||
|
getLocalApiStatus: () => ipcRenderer.invoke('get-local-api-status'),
|
||||||
|
openLocalApiDocs: () => ipcRenderer.invoke('open-local-api-docs'),
|
||||||
relaunchApp: () => ipcRenderer.invoke('relaunch-app'),
|
relaunchApp: () => ipcRenderer.invoke('relaunch-app'),
|
||||||
onDeepLinkReceived: (listener) => {
|
onDeepLinkReceived: (listener) => {
|
||||||
const wrappedListener = (_event: Electron.IpcRendererEvent, url: string) => {
|
const wrappedListener = (_event: Electron.IpcRendererEvent, url: string) => {
|
||||||
|
|||||||
2918
package-lock.json
generated
2918
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,7 @@
|
|||||||
"@ngrx/entity": "^21.0.1",
|
"@ngrx/entity": "^21.0.1",
|
||||||
"@ngrx/store": "^21.0.1",
|
"@ngrx/store": "^21.0.1",
|
||||||
"@ngrx/store-devtools": "^21.0.1",
|
"@ngrx/store-devtools": "^21.0.1",
|
||||||
|
"@scalar/api-reference": "^1.53.1",
|
||||||
"@spartan-ng/brain": "^0.0.1-alpha.589",
|
"@spartan-ng/brain": "^0.0.1-alpha.589",
|
||||||
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
||||||
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
||||||
@@ -169,6 +170,10 @@
|
|||||||
"filter": [
|
"filter": [
|
||||||
"**/*"
|
"**/*"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "node_modules/@scalar/api-reference/dist/browser/standalone.js",
|
||||||
|
"to": "scalar/api-reference.js"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"nodeGypRebuild": false,
|
"nodeGypRebuild": false,
|
||||||
|
|||||||
Binary file not shown.
@@ -35,6 +35,7 @@ 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 { GameActivityService } from './domains/game-activity';
|
||||||
|
import { PluginBootstrapService } from './domains/plugins';
|
||||||
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';
|
||||||
@@ -78,6 +79,8 @@ import {
|
|||||||
styleUrl: './app.scss'
|
styleUrl: './app.scss'
|
||||||
})
|
})
|
||||||
export class App implements OnInit, OnDestroy {
|
export class App implements OnInit, OnDestroy {
|
||||||
|
readonly plugins = inject(PluginBootstrapService);
|
||||||
|
|
||||||
private static readonly THEME_STUDIO_CONTROLS_MARGIN = 16;
|
private static readonly THEME_STUDIO_CONTROLS_MARGIN = 16;
|
||||||
private static readonly TITLE_BAR_HEIGHT = 40;
|
private static readonly TITLE_BAR_HEIGHT = 40;
|
||||||
|
|
||||||
|
|||||||
@@ -91,10 +91,12 @@ export interface DesktopSettingsSnapshot {
|
|||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
closeToTray: boolean;
|
closeToTray: boolean;
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
|
localApi: LocalApiSettings;
|
||||||
manifestUrls: string[];
|
manifestUrls: string[];
|
||||||
preferredVersion: string | null;
|
preferredVersion: string | null;
|
||||||
runtimeHardwareAcceleration: boolean;
|
runtimeHardwareAcceleration: boolean;
|
||||||
restartRequired: boolean;
|
restartRequired: boolean;
|
||||||
|
vaapiVideoEncode: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DesktopSettingsPatch {
|
export interface DesktopSettingsPatch {
|
||||||
@@ -102,11 +104,32 @@ export interface DesktopSettingsPatch {
|
|||||||
autoStart?: boolean;
|
autoStart?: boolean;
|
||||||
closeToTray?: boolean;
|
closeToTray?: boolean;
|
||||||
hardwareAcceleration?: boolean;
|
hardwareAcceleration?: boolean;
|
||||||
|
localApi?: Partial<LocalApiSettings>;
|
||||||
manifestUrls?: string[];
|
manifestUrls?: string[];
|
||||||
preferredVersion?: string | null;
|
preferredVersion?: string | null;
|
||||||
vaapiVideoEncode?: boolean;
|
vaapiVideoEncode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LocalApiSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
port: number;
|
||||||
|
exposeOnLan: boolean;
|
||||||
|
scalarEnabled: boolean;
|
||||||
|
allowedSignalingServers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LocalApiStatus = 'stopped' | 'starting' | 'running' | 'error';
|
||||||
|
|
||||||
|
export interface LocalApiSnapshot {
|
||||||
|
status: LocalApiStatus;
|
||||||
|
host: string | null;
|
||||||
|
port: number | null;
|
||||||
|
baseUrl: string | null;
|
||||||
|
error: string | null;
|
||||||
|
exposeOnLan: boolean;
|
||||||
|
scalarEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DesktopNotificationPayload {
|
export interface DesktopNotificationPayload {
|
||||||
body: string;
|
body: string;
|
||||||
requestAttention: boolean;
|
requestAttention: boolean;
|
||||||
@@ -230,6 +253,8 @@ export interface ElectronApi {
|
|||||||
restartToApplyUpdate: () => Promise<boolean>;
|
restartToApplyUpdate: () => Promise<boolean>;
|
||||||
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
|
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
|
||||||
setDesktopSettings: (patch: DesktopSettingsPatch) => Promise<DesktopSettingsSnapshot>;
|
setDesktopSettings: (patch: DesktopSettingsPatch) => Promise<DesktopSettingsSnapshot>;
|
||||||
|
getLocalApiStatus: () => Promise<LocalApiSnapshot>;
|
||||||
|
openLocalApiDocs: () => Promise<{ opened: boolean; reason?: string }>;
|
||||||
relaunchApp: () => Promise<boolean>;
|
relaunchApp: () => Promise<boolean>;
|
||||||
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
|
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
|
||||||
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type SettingsPage =
|
|||||||
| 'notifications'
|
| 'notifications'
|
||||||
| 'voice'
|
| 'voice'
|
||||||
| 'updates'
|
| 'updates'
|
||||||
|
| 'localApi'
|
||||||
| 'data'
|
| 'data'
|
||||||
| 'debugging'
|
| 'debugging'
|
||||||
| 'server'
|
| 'server'
|
||||||
|
|||||||
@@ -91,16 +91,18 @@
|
|||||||
@if (msg.isDeleted) {
|
@if (msg.isDeleted) {
|
||||||
<div class="mt-1 text-sm italic text-muted-foreground">{{ deletedMessageContent }}</div>
|
<div class="mt-1 text-sm italic text-muted-foreground">{{ deletedMessageContent }}</div>
|
||||||
} @else {
|
} @else {
|
||||||
@if (requiresRichMarkdown(msg.content)) {
|
@if (pluginEmbeds().length === 0) {
|
||||||
@defer {
|
@if (requiresRichMarkdown(msg.content)) {
|
||||||
<div class="chat-markdown mt-1 break-words">
|
@defer {
|
||||||
<app-chat-message-markdown [content]="msg.content" />
|
<div class="chat-markdown mt-1 break-words">
|
||||||
</div>
|
<app-chat-message-markdown [content]="msg.content" />
|
||||||
} @placeholder {
|
</div>
|
||||||
|
} @placeholder {
|
||||||
|
<div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
<div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div>
|
<div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div>
|
||||||
}
|
}
|
||||||
} @else {
|
|
||||||
<div class="mt-1 whitespace-pre-wrap break-words text-sm text-foreground">{{ msg.content }}</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (msg.linkMetadata?.length) {
|
@if (msg.linkMetadata?.length) {
|
||||||
@@ -116,7 +118,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@if (pluginEmbeds().length > 0) {
|
@if (pluginEmbeds().length > 0) {
|
||||||
<div class="mt-2 space-y-2" data-testid="plugin-message-embeds">
|
<div
|
||||||
|
class="mt-2 space-y-2"
|
||||||
|
data-testid="plugin-message-embeds"
|
||||||
|
>
|
||||||
@for (embed of pluginEmbeds(); track embed.id) {
|
@for (embed of pluginEmbeds(); track embed.id) {
|
||||||
<article class="rounded-md border border-border bg-secondary/30 p-3">
|
<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">
|
<div class="mb-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ The standalone plugin store is available from the title bar Plugins button, the
|
|||||||
|
|
||||||
The plugin manager UI is split between Settings -> Client plugins for global client plugins and Settings -> Server -> Server plugins for chat-server plugins. The two pages filter by manifest `scope` and include installed plugins, capability grant toggles, per-plugin activate/reload/unload actions, runtime logs, extension-point counts, server requirements, generated settings, and docs.
|
The plugin manager UI is split between Settings -> Client plugins for global client plugins and Settings -> Server -> Server plugins for chat-server plugins. The two pages filter by manifest `scope` and include installed plugins, capability grant toggles, per-plugin activate/reload/unload actions, runtime logs, extension-point counts, server requirements, generated settings, and docs.
|
||||||
|
|
||||||
The Store tab consumes user-managed HTTP(S), `file://`, or absolute local-path source manifests. Local-path sources and entrypoints are read through the Electron desktop file bridge. A source manifest can expose a `plugins` array whose entries include `id`, `title`, `description`, `version`, `scope`, `author`/`authors`, `image`/`imageUrl`, `github`/`githubUrl`, `install`/`installUrl`/`manifestUrl`, and `readme`/`readmeUrl`. Installing a `scope: "server"` plugin fetches the linked plugin manifest, validates it, registers it with the client registry, and persists the basic install metadata as a server plugin requirement. Required server plugins are installed on each member client when that chat server opens; optional server plugins stay listed as server requirements but are not auto-installed. Installing a `scope: "client"` plugin persists it locally for the current desktop/browser client.
|
The Store tab consumes user-managed HTTP(S), `file://`, or absolute local-path source manifests. Local-path sources and entrypoints are read through the Electron desktop file bridge. A source manifest can expose a `plugins` array whose entries include `id`, `title`, `description`, `version`, `scope`, `author`/`authors`, `image`/`imageUrl`, `github`/`githubUrl`, `install`/`installUrl`/`manifestUrl`, `bundle`/`bundleUrl`, and `readme`/`readmeUrl`. Installing a `scope: "server"` plugin fetches the linked plugin manifest, validates it, registers it with the client registry, and persists the basic install metadata as a server plugin requirement. Required server plugins are installed on each member client when that chat server opens; optional server plugins stay listed as server requirements but are not auto-installed. Installing a `scope: "client"` plugin persists it locally for the current desktop/browser client.
|
||||||
|
|
||||||
|
Store plugins can be published as cached browser bundles by adding `bundle` or `bundleUrl` to the source manifest entry. The bundle is a browser-safe ESM JavaScript file. During install, Electron downloads the bundle into app data under `plugin-bundles/<plugin-id>/<version>/main.js`, writes a cached manifest next to it, and registers the plugin from that local cached manifest path. If no bundle URL is provided and the manifest entrypoint is a relative browser module, Electron caches that entrypoint path instead. Browser-only clients still load directly from the source URL. Saved store sources refresh during app bootstrap; when a source advertises a higher version for an installed plugin, the store attempts to update the local cached bundle and persisted install metadata automatically.
|
||||||
|
|
||||||
The server-side plugin support API is metadata-only. The signal server can keep plugin id, requirement status, version range, install/source URLs, and the validated manifest snapshot needed for member clients to install required plugins. Plugin `serverData` API calls are handled as local per-user/per-server client state; HTTP plugin data persistence on the signal server returns `PLUGIN_DATA_DISABLED`.
|
The server-side plugin support API is metadata-only. The signal server can keep plugin id, requirement status, version range, install/source URLs, and the validated manifest snapshot needed for member clients to install required plugins. Plugin `serverData` API calls are handled as local per-user/per-server client state; HTTP plugin data persistence on the signal server returns `PLUGIN_DATA_DISABLED`.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { PluginRequirementStateService } from './plugin-requirement-state.service';
|
||||||
|
import { PluginStoreService } from './plugin-store.service';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class PluginBootstrapService {
|
||||||
|
readonly requirementState = inject(PluginRequirementStateService);
|
||||||
|
readonly store = inject(PluginStoreService);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core';
|
|||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||||
|
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||||
import { VoiceConnectionFacade } from '../../../voice-connection/application/facades/voice-connection.facade';
|
import { VoiceConnectionFacade } from '../../../voice-connection/application/facades/voice-connection.facade';
|
||||||
import type {
|
import type {
|
||||||
Channel,
|
Channel,
|
||||||
@@ -40,6 +41,7 @@ import { PluginUiRegistryService } from './plugin-ui-registry.service';
|
|||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class PluginClientApiService {
|
export class PluginClientApiService {
|
||||||
private readonly capabilities = inject(PluginCapabilityService);
|
private readonly capabilities = inject(PluginCapabilityService);
|
||||||
|
private readonly db = inject(DatabaseService);
|
||||||
private readonly logger = inject(PluginLoggerService);
|
private readonly logger = inject(PluginLoggerService);
|
||||||
private readonly messageBus = inject(PluginMessageBusService);
|
private readonly messageBus = inject(PluginMessageBusService);
|
||||||
private readonly realtime = inject(RealtimeSessionFacade);
|
private readonly realtime = inject(RealtimeSessionFacade);
|
||||||
@@ -159,11 +161,11 @@ export class PluginClientApiService {
|
|||||||
messages: {
|
messages: {
|
||||||
delete: (messageId) => {
|
delete: (messageId) => {
|
||||||
requireCapability('messages.deleteOwn');
|
requireCapability('messages.deleteOwn');
|
||||||
this.deletePluginMessage(messageId);
|
this.deletePluginMessage(pluginId, messageId);
|
||||||
},
|
},
|
||||||
edit: (messageId, content) => {
|
edit: (messageId, content) => {
|
||||||
requireCapability('messages.editOwn');
|
requireCapability('messages.editOwn');
|
||||||
this.editPluginMessage(messageId, content);
|
this.editPluginMessage(pluginId, messageId, content);
|
||||||
},
|
},
|
||||||
moderateDelete: (messageId) => {
|
moderateDelete: (messageId) => {
|
||||||
requireCapability('messages.moderate');
|
requireCapability('messages.moderate');
|
||||||
@@ -175,7 +177,7 @@ export class PluginClientApiService {
|
|||||||
},
|
},
|
||||||
send: (content, channelId) => {
|
send: (content, channelId) => {
|
||||||
requireCapability('messages.send');
|
requireCapability('messages.send');
|
||||||
return this.sendPluginMessage(content, channelId);
|
return this.sendPluginMessage(pluginId, content, channelId);
|
||||||
},
|
},
|
||||||
sendAsPluginUser: (request) => {
|
sendAsPluginUser: (request) => {
|
||||||
requireCapability('messages.send');
|
requireCapability('messages.send');
|
||||||
@@ -481,11 +483,18 @@ export class PluginClientApiService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.logger.info(pluginId, 'Plugin user message emitted', { messageId: message.id });
|
this.logger.info(pluginId, 'Plugin user message emitted', { messageId: message.id });
|
||||||
|
this.persistPluginMessage(pluginId, message);
|
||||||
this.store.dispatch(MessagesActions.receiveMessage({ message }));
|
this.store.dispatch(MessagesActions.receiveMessage({ message }));
|
||||||
this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent);
|
this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
private deletePluginMessage(messageId: string): void {
|
private deletePluginMessage(pluginId: string, messageId: string): void {
|
||||||
|
this.persistPluginMessageUpdate(pluginId, messageId, {
|
||||||
|
content: '[Message deleted]',
|
||||||
|
editedAt: Date.now(),
|
||||||
|
isDeleted: true
|
||||||
|
});
|
||||||
|
|
||||||
this.store.dispatch(MessagesActions.deleteMessageSuccess({ messageId }));
|
this.store.dispatch(MessagesActions.deleteMessageSuccess({ messageId }));
|
||||||
this.voice.broadcastMessage({
|
this.voice.broadcastMessage({
|
||||||
deletedAt: Date.now(),
|
deletedAt: Date.now(),
|
||||||
@@ -494,9 +503,11 @@ export class PluginClientApiService {
|
|||||||
} as unknown as ChatEvent);
|
} as unknown as ChatEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
private editPluginMessage(messageId: string, content: string): void {
|
private editPluginMessage(pluginId: string, messageId: string, content: string): void {
|
||||||
const editedAt = Date.now();
|
const editedAt = Date.now();
|
||||||
|
|
||||||
|
this.persistPluginMessageUpdate(pluginId, messageId, { content, editedAt });
|
||||||
|
|
||||||
this.store.dispatch(MessagesActions.editMessageSuccess({
|
this.store.dispatch(MessagesActions.editMessageSuccess({
|
||||||
content,
|
content,
|
||||||
editedAt,
|
editedAt,
|
||||||
@@ -511,7 +522,7 @@ export class PluginClientApiService {
|
|||||||
} as unknown as ChatEvent);
|
} as unknown as ChatEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendPluginMessage(content: string, channelId?: string): Message {
|
private sendPluginMessage(pluginId: string, content: string, channelId?: string): Message {
|
||||||
const currentUser = this.currentUser();
|
const currentUser = this.currentUser();
|
||||||
const roomId = this.requireRoomId();
|
const roomId = this.requireRoomId();
|
||||||
const message: Message = {
|
const message: Message = {
|
||||||
@@ -526,12 +537,25 @@ export class PluginClientApiService {
|
|||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.persistPluginMessage(pluginId, message);
|
||||||
this.store.dispatch(MessagesActions.sendMessageSuccess({ message }));
|
this.store.dispatch(MessagesActions.sendMessageSuccess({ message }));
|
||||||
this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent);
|
this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent);
|
||||||
|
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private persistPluginMessage(pluginId: string, message: Message): void {
|
||||||
|
void this.db.saveMessage(message).catch((error: unknown) => {
|
||||||
|
this.logger.warn(pluginId, 'Failed to persist plugin message', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistPluginMessageUpdate(pluginId: string, messageId: string, updates: Partial<Message>): void {
|
||||||
|
void this.db.updateMessage(messageId, updates).catch((error: unknown) => {
|
||||||
|
this.logger.warn(pluginId, 'Failed to persist plugin message update', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private rememberSubscription(pluginId: string, eventName: string) {
|
private rememberSubscription(pluginId: string, eventName: string) {
|
||||||
this.logger.info(pluginId, `Subscribed to ${eventName}`);
|
this.logger.info(pluginId, `Subscribed to ${eventName}`);
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export class PluginHostService {
|
|||||||
private readonly registry = inject(PluginRegistryService);
|
private readonly registry = inject(PluginRegistryService);
|
||||||
private readonly uiRegistry = inject(PluginUiRegistryService);
|
private readonly uiRegistry = inject(PluginUiRegistryService);
|
||||||
private readonly activePlugins = new Map<string, ActivePluginRuntime>();
|
private readonly activePlugins = new Map<string, ActivePluginRuntime>();
|
||||||
|
private readonly activationRequests = new Map<string, Promise<boolean>>();
|
||||||
private readonly activationStateReady: Promise<void>;
|
private readonly activationStateReady: Promise<void>;
|
||||||
private activatedPluginIds = new Set<string>();
|
private activatedPluginIds = new Set<string>();
|
||||||
|
|
||||||
@@ -96,11 +97,10 @@ export class PluginHostService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.activatePlugin(entry);
|
const didActivate = await this.activatePlugin(entry);
|
||||||
|
|
||||||
const active = this.activePlugins.get(manifest.id);
|
const active = this.activePlugins.get(manifest.id);
|
||||||
|
|
||||||
if (active) {
|
if (didActivate && active) {
|
||||||
activated.push(active.context);
|
activated.push(active.context);
|
||||||
this.activatedPluginIds.add(active.context.pluginId);
|
this.activatedPluginIds.add(active.context.pluginId);
|
||||||
}
|
}
|
||||||
@@ -126,11 +126,10 @@ export class PluginHostService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.activatePlugin(entry);
|
const didActivate = await this.activatePlugin(entry);
|
||||||
|
|
||||||
const active = this.activePlugins.get(pluginId);
|
const active = this.activePlugins.get(pluginId);
|
||||||
|
|
||||||
if (!active) {
|
if (!didActivate || !active) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,11 +160,10 @@ export class PluginHostService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.activatePlugin(entry);
|
const didActivate = await this.activatePlugin(entry);
|
||||||
|
|
||||||
const active = this.activePlugins.get(manifest.id);
|
const active = this.activePlugins.get(manifest.id);
|
||||||
|
|
||||||
if (active) {
|
if (didActivate && active) {
|
||||||
activated.push(active.context);
|
activated.push(active.context);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,19 +263,46 @@ export class PluginHostService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async activatePlugin(entry: RegisteredPlugin): Promise<void> {
|
private async activatePlugin(entry: RegisteredPlugin): Promise<boolean> {
|
||||||
|
const pluginId = entry.manifest.id;
|
||||||
|
|
||||||
|
if (this.activePlugins.has(pluginId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingActivation = this.activationRequests.get(pluginId);
|
||||||
|
|
||||||
|
if (pendingActivation) {
|
||||||
|
await pendingActivation;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activation = this.activatePluginInternal(entry);
|
||||||
|
|
||||||
|
this.activationRequests.set(pluginId, activation);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await activation;
|
||||||
|
} finally {
|
||||||
|
if (this.activationRequests.get(pluginId) === activation) {
|
||||||
|
this.activationRequests.delete(pluginId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async activatePluginInternal(entry: RegisteredPlugin): Promise<boolean> {
|
||||||
const manifest = entry.manifest;
|
const manifest = entry.manifest;
|
||||||
const missingCapabilities = this.capabilities.missing(manifest);
|
const missingCapabilities = this.capabilities.missing(manifest);
|
||||||
|
|
||||||
if (missingCapabilities.length > 0) {
|
if (missingCapabilities.length > 0) {
|
||||||
this.registry.setFailed(manifest.id, `Missing capabilities: ${missingCapabilities.join(', ')}`);
|
this.registry.setFailed(manifest.id, `Missing capabilities: ${missingCapabilities.join(', ')}`);
|
||||||
this.logger.warn(manifest.id, 'Plugin blocked by missing capability grants', missingCapabilities);
|
this.logger.warn(manifest.id, 'Plugin blocked by missing capability grants', missingCapabilities);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!manifest.entrypoint) {
|
if (!manifest.entrypoint) {
|
||||||
this.registry.setState(manifest.id, 'ready');
|
this.registry.setState(manifest.id, 'ready');
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.registry.setState(manifest.id, 'loading');
|
this.registry.setState(manifest.id, 'loading');
|
||||||
@@ -291,12 +316,14 @@ export class PluginHostService {
|
|||||||
subscriptions: []
|
subscriptions: []
|
||||||
};
|
};
|
||||||
|
|
||||||
await module.activate?.(context);
|
await this.runWithPluginRuntimeGuards(manifest.id, () => module.activate?.(context));
|
||||||
this.activePlugins.set(manifest.id, { context, module, moduleObjectUrl });
|
this.activePlugins.set(manifest.id, { context, module, moduleObjectUrl });
|
||||||
this.registry.setState(manifest.id, 'loaded');
|
this.registry.setState(manifest.id, 'loaded');
|
||||||
this.logger.info(manifest.id, 'Plugin activated');
|
this.logger.info(manifest.id, 'Plugin activated');
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.failPlugin(manifest.id, error);
|
this.failPlugin(manifest.id, error);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +337,27 @@ export class PluginHostService {
|
|||||||
this.revokeModuleObjectUrl(pluginId);
|
this.revokeModuleObjectUrl(pluginId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async runWithPluginRuntimeGuards(pluginId: string, activate: () => Promise<void> | void): Promise<void> {
|
||||||
|
const originalMutationObserver = globalThis.MutationObserver;
|
||||||
|
|
||||||
|
if (!originalMutationObserver) {
|
||||||
|
await activate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const guardedMutationObserver = createGuardedMutationObserver(originalMutationObserver, pluginId, this.logger);
|
||||||
|
|
||||||
|
globalThis.MutationObserver = guardedMutationObserver;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await activate();
|
||||||
|
} finally {
|
||||||
|
if (globalThis.MutationObserver === guardedMutationObserver) {
|
||||||
|
globalThis.MutationObserver = originalMutationObserver;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async loadPluginModule(
|
private async loadPluginModule(
|
||||||
manifest: TojuPluginManifest,
|
manifest: TojuPluginManifest,
|
||||||
sourcePath?: string
|
sourcePath?: string
|
||||||
@@ -391,6 +439,10 @@ export class PluginHostService {
|
|||||||
return new URL(manifest.entrypoint).toString();
|
return new URL(manifest.entrypoint).toString();
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
if (manifest.bundle?.url && !sourcePath?.startsWith('file://')) {
|
||||||
|
return manifest.bundle.url;
|
||||||
|
}
|
||||||
|
|
||||||
if (sourcePath?.startsWith('http://') || sourcePath?.startsWith('https://') || sourcePath?.startsWith('file://')) {
|
if (sourcePath?.startsWith('http://') || sourcePath?.startsWith('https://') || sourcePath?.startsWith('file://')) {
|
||||||
return new URL(manifest.entrypoint, sourcePath).toString();
|
return new URL(manifest.entrypoint, sourcePath).toString();
|
||||||
}
|
}
|
||||||
@@ -421,3 +473,61 @@ function safeDispose(disposable: TojuPluginDisposable, pluginId: string, logger:
|
|||||||
logger.warn(pluginId, 'Plugin disposable failed', error);
|
logger.warn(pluginId, 'Plugin disposable failed', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createGuardedMutationObserver(
|
||||||
|
NativeMutationObserver: typeof MutationObserver,
|
||||||
|
pluginId: string,
|
||||||
|
logger: PluginLoggerService
|
||||||
|
): typeof MutationObserver {
|
||||||
|
return class GuardedPluginMutationObserver implements MutationObserver {
|
||||||
|
private readonly nativeObserver: MutationObserver;
|
||||||
|
private readonly observations: { options?: MutationObserverInit; target: Node }[] = [];
|
||||||
|
private isDispatching = false;
|
||||||
|
|
||||||
|
constructor(private readonly callback: MutationCallback) {
|
||||||
|
this.nativeObserver = new NativeMutationObserver((records) => this.dispatch(records));
|
||||||
|
}
|
||||||
|
|
||||||
|
observe(target: Node, options?: MutationObserverInit): void {
|
||||||
|
const existing = this.observations.find((observation) => observation.target === target);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.options = options;
|
||||||
|
} else {
|
||||||
|
this.observations.push({ options, target });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.nativeObserver.observe(target, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
this.observations.length = 0;
|
||||||
|
this.nativeObserver.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
takeRecords(): MutationRecord[] {
|
||||||
|
return this.nativeObserver.takeRecords();
|
||||||
|
}
|
||||||
|
|
||||||
|
private dispatch(records: MutationRecord[]): void {
|
||||||
|
if (this.isDispatching) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isDispatching = true;
|
||||||
|
this.nativeObserver.disconnect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.callback(records, this);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(pluginId, 'Plugin MutationObserver callback failed', error);
|
||||||
|
} finally {
|
||||||
|
this.isDispatching = false;
|
||||||
|
|
||||||
|
for (const observation of this.observations) {
|
||||||
|
this.nativeObserver.observe(observation.target, observation.options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export class PluginMessageBusService {
|
|||||||
request: PluginApiMessageBusLatestRequest,
|
request: PluginApiMessageBusLatestRequest,
|
||||||
includeMessages: boolean
|
includeMessages: boolean
|
||||||
): PluginApiMessageBusEnvelope {
|
): PluginApiMessageBusEnvelope {
|
||||||
const currentUser = this.currentUser();
|
const currentUser = this.currentUser() ?? null;
|
||||||
const envelope: PluginApiMessageBusEnvelope = {
|
const envelope: PluginApiMessageBusEnvelope = {
|
||||||
eventId: createId(),
|
eventId: createId(),
|
||||||
pluginId,
|
pluginId,
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import type {
|
|||||||
TojuPluginManifest
|
TojuPluginManifest
|
||||||
} from '../../../../shared-kernel';
|
} from '../../../../shared-kernel';
|
||||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||||
import { selectCurrentRoomId } from '../../../../store/rooms/rooms.selectors';
|
import { selectCurrentRoom, selectCurrentRoomId } from '../../../../store/rooms/rooms.selectors';
|
||||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
import { ServerDirectoryFacade, type ServerSourceSelector } from '../../../server-directory';
|
||||||
import { PluginRegistryService } from './plugin-registry.service';
|
import { PluginRegistryService } from './plugin-registry.service';
|
||||||
import { PluginRequirementService } from './plugin-requirement.service';
|
import { PluginRequirementService } from './plugin-requirement.service';
|
||||||
|
|
||||||
@@ -44,6 +44,7 @@ export class PluginRequirementStateService {
|
|||||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||||
private readonly store = inject(Store);
|
private readonly store = inject(Store);
|
||||||
|
|
||||||
|
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId);
|
private readonly currentRoomId = this.store.selectSignal(selectCurrentRoomId);
|
||||||
private readonly snapshotsSignal = signal<Record<string, PluginRequirementsSnapshot>>({});
|
private readonly snapshotsSignal = signal<Record<string, PluginRequirementsSnapshot>>({});
|
||||||
private readonly refreshErrorsSignal = signal<Record<string, string>>({});
|
private readonly refreshErrorsSignal = signal<Record<string, string>>({});
|
||||||
@@ -111,7 +112,7 @@ export class PluginRequirementStateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiBaseUrl = this.serverDirectory.getApiBaseUrl();
|
const apiBaseUrl = this.serverDirectory.getApiBaseUrl(this.currentRoomSourceSelector());
|
||||||
const snapshot = await new Promise<PluginRequirementsSnapshot>((resolve, reject) => {
|
const snapshot = await new Promise<PluginRequirementsSnapshot>((resolve, reject) => {
|
||||||
this.pluginRequirements.getSnapshot(apiBaseUrl, roomId).subscribe({
|
this.pluginRequirements.getSnapshot(apiBaseUrl, roomId).subscribe({
|
||||||
error: reject,
|
error: reject,
|
||||||
@@ -144,6 +145,19 @@ export class PluginRequirementStateService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private currentRoomSourceSelector(): ServerSourceSelector | undefined {
|
||||||
|
const room = this.currentRoom();
|
||||||
|
|
||||||
|
if (!room?.sourceId && !room?.sourceUrl) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceId: room.sourceId,
|
||||||
|
sourceUrl: room.sourceUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private resolveStatus(
|
private resolveStatus(
|
||||||
requirement: PluginRequirementSummary,
|
requirement: PluginRequirementSummary,
|
||||||
entry: { enabled: boolean; manifest: TojuPluginManifest } | undefined
|
entry: { enabled: boolean; manifest: TojuPluginManifest } | undefined
|
||||||
|
|||||||
@@ -129,6 +129,45 @@ describe('PluginStoreService', () => {
|
|||||||
expect(service.installedPlugins()).toEqual([]);
|
expect(service.installedPlugins()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('caches plugin bundle entrypoints locally before registering installed plugins', async () => {
|
||||||
|
const manifest = createManifest({ entrypoint: './dist/main.js' });
|
||||||
|
const plugin = createStoreEntry({
|
||||||
|
bundleUrl: 'https://plugins.example.test/better/bundle.js',
|
||||||
|
version: '1.0.0'
|
||||||
|
});
|
||||||
|
const electronApi = {
|
||||||
|
ensureDir: vi.fn(async () => true),
|
||||||
|
getAppDataPath: vi.fn(async () => '/tmp/metoyou-user-data'),
|
||||||
|
writeFile: vi.fn(async () => true)
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(jsonResponse(manifest))
|
||||||
|
.mockResolvedValueOnce(textResponse('export function activate() {}'));
|
||||||
|
|
||||||
|
const service = createService(registerLocalManifest, unregister, electronApi);
|
||||||
|
|
||||||
|
await service.installPlugin(plugin);
|
||||||
|
|
||||||
|
expect(electronApi.ensureDir).toHaveBeenCalledWith('/tmp/metoyou-user-data/plugin-bundles/example.better-channels/1.0.0');
|
||||||
|
expect(electronApi.writeFile).toHaveBeenCalledWith(
|
||||||
|
'/tmp/metoyou-user-data/plugin-bundles/example.better-channels/1.0.0/main.js',
|
||||||
|
expect.any(String)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(registerLocalManifest).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
bundle: {
|
||||||
|
entrypoint: './main.js',
|
||||||
|
url: plugin.bundleUrl
|
||||||
|
},
|
||||||
|
entrypoint: './main.js',
|
||||||
|
id: manifest.id
|
||||||
|
}),
|
||||||
|
'file:///tmp/metoyou-user-data/plugin-bundles/example.better-channels/1.0.0/toju-plugin.json'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('loads plugin readmes as markdown text', async () => {
|
it('loads plugin readmes as markdown text', async () => {
|
||||||
const plugin = createStoreEntry({ readmeUrl: 'https://plugins.example.test/better/README.md' });
|
const plugin = createStoreEntry({ readmeUrl: 'https://plugins.example.test/better/README.md' });
|
||||||
|
|
||||||
@@ -149,7 +188,12 @@ describe('PluginStoreService', () => {
|
|||||||
function createService(
|
function createService(
|
||||||
registerLocalManifest: ReturnType<typeof vi.fn>,
|
registerLocalManifest: ReturnType<typeof vi.fn>,
|
||||||
unregister: ReturnType<typeof vi.fn>,
|
unregister: ReturnType<typeof vi.fn>,
|
||||||
electronApi: { readFile: (filePath: string) => Promise<string> } | null = null
|
electronApi: {
|
||||||
|
ensureDir?: (dirPath: string) => Promise<boolean>;
|
||||||
|
getAppDataPath?: () => Promise<string>;
|
||||||
|
readFile?: (filePath: string) => Promise<string>;
|
||||||
|
writeFile?: (filePath: string, data: string) => Promise<boolean>;
|
||||||
|
} | null = null
|
||||||
): PluginStoreService {
|
): PluginStoreService {
|
||||||
const injector = Injector.create({
|
const injector = Injector.create({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -165,6 +209,7 @@ function createService(
|
|||||||
useValue: {
|
useValue: {
|
||||||
activatePersistedPlugins: vi.fn(async () => {}),
|
activatePersistedPlugins: vi.fn(async () => {}),
|
||||||
deactivatePlugin: vi.fn(async () => {}),
|
deactivatePlugin: vi.fn(async () => {}),
|
||||||
|
isPluginActive: vi.fn(() => false),
|
||||||
registerLocalManifest
|
registerLocalManifest
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,9 +18,14 @@ import type {
|
|||||||
TojuPluginInstallScope,
|
TojuPluginInstallScope,
|
||||||
TojuPluginManifest
|
TojuPluginManifest
|
||||||
} from '../../../../shared-kernel';
|
} from '../../../../shared-kernel';
|
||||||
import { selectCurrentRoomId, selectCurrentRoomName } from '../../../../store/rooms/rooms.selectors';
|
import {
|
||||||
|
selectCurrentRoom,
|
||||||
|
selectCurrentRoomId,
|
||||||
|
selectCurrentRoomName,
|
||||||
|
selectSavedRooms
|
||||||
|
} from '../../../../store/rooms/rooms.selectors';
|
||||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
import { ServerDirectoryFacade, type ServerSourceSelector } from '../../../server-directory';
|
||||||
import { getPluginInstallScope } from '../../domain/logic/plugin-install-scope.logic';
|
import { getPluginInstallScope } from '../../domain/logic/plugin-install-scope.logic';
|
||||||
import { validateTojuPluginManifest } from '../../domain/logic/plugin-manifest-validation.logic';
|
import { validateTojuPluginManifest } from '../../domain/logic/plugin-manifest-validation.logic';
|
||||||
import type {
|
import type {
|
||||||
@@ -39,6 +44,7 @@ import { PluginRegistryService } from './plugin-registry.service';
|
|||||||
|
|
||||||
const STORE_SCHEMA_VERSION = 1;
|
const STORE_SCHEMA_VERSION = 1;
|
||||||
const STORAGE_KEY_PLUGIN_STORE = 'metoyou_plugin_store';
|
const STORAGE_KEY_PLUGIN_STORE = 'metoyou_plugin_store';
|
||||||
|
const PLUGIN_CACHE_DIR = 'plugin-bundles';
|
||||||
const DEFAULT_STORE_STATE: PersistedPluginStoreState = {
|
const DEFAULT_STORE_STATE: PersistedPluginStoreState = {
|
||||||
installedPlugins: [],
|
installedPlugins: [],
|
||||||
sourceUrls: []
|
sourceUrls: []
|
||||||
@@ -62,8 +68,10 @@ export class PluginStoreService {
|
|||||||
private readonly registry = inject(PluginRegistryService);
|
private readonly registry = inject(PluginRegistryService);
|
||||||
private readonly serverDirectory = inject(ServerDirectoryFacade, { optional: true });
|
private readonly serverDirectory = inject(ServerDirectoryFacade, { optional: true });
|
||||||
private readonly store = inject(Store, { optional: true });
|
private readonly store = inject(Store, { optional: true });
|
||||||
|
private readonly currentRoom = this.store?.selectSignal(selectCurrentRoom) ?? null;
|
||||||
private readonly currentRoomId = this.store?.selectSignal(selectCurrentRoomId) ?? null;
|
private readonly currentRoomId = this.store?.selectSignal(selectCurrentRoomId) ?? null;
|
||||||
private readonly currentRoomName = this.store?.selectSignal(selectCurrentRoomName) ?? null;
|
private readonly currentRoomName = this.store?.selectSignal(selectCurrentRoomName) ?? null;
|
||||||
|
private readonly savedRooms = this.store?.selectSignal(selectSavedRooms) ?? null;
|
||||||
private readonly currentUser = this.store?.selectSignal(selectCurrentUser) ?? null;
|
private readonly currentUser = this.store?.selectSignal(selectCurrentUser) ?? null;
|
||||||
private readonly sourceUrlsSignal = signal<string[]>([]);
|
private readonly sourceUrlsSignal = signal<string[]>([]);
|
||||||
private readonly sourcesSignal = signal<PluginStoreSourceResult[]>([]);
|
private readonly sourcesSignal = signal<PluginStoreSourceResult[]>([]);
|
||||||
@@ -73,6 +81,7 @@ export class PluginStoreService {
|
|||||||
private refreshAbortController: AbortController | null = null;
|
private refreshAbortController: AbortController | null = null;
|
||||||
private refreshVersion = 0;
|
private refreshVersion = 0;
|
||||||
private installedLoadVersion = 0;
|
private installedLoadVersion = 0;
|
||||||
|
private autoUpdateInProgress = false;
|
||||||
private stateMutated = false;
|
private stateMutated = false;
|
||||||
|
|
||||||
readonly sourceUrls = this.sourceUrlsSignal.asReadonly();
|
readonly sourceUrls = this.sourceUrlsSignal.asReadonly();
|
||||||
@@ -94,6 +103,10 @@ export class PluginStoreService {
|
|||||||
this.sourceUrlsSignal.set(state.sourceUrls);
|
this.sourceUrlsSignal.set(state.sourceUrls);
|
||||||
void this.applyInstalledPlugins(state.installedPlugins, 'client');
|
void this.applyInstalledPlugins(state.installedPlugins, 'client');
|
||||||
|
|
||||||
|
if (state.sourceUrls.length > 0) {
|
||||||
|
void this.refreshSources();
|
||||||
|
}
|
||||||
|
|
||||||
if (this.currentRoomId && this.currentUser && this.serverDirectory) {
|
if (this.currentRoomId && this.currentUser && this.serverDirectory) {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const roomId = this.currentRoomId?.() ?? null;
|
const roomId = this.currentRoomId?.() ?? null;
|
||||||
@@ -147,6 +160,7 @@ export class PluginStoreService {
|
|||||||
|
|
||||||
if (this.refreshVersion === currentRefresh) {
|
if (this.refreshVersion === currentRefresh) {
|
||||||
this.sourcesSignal.set(sources);
|
this.sourcesSignal.set(sources);
|
||||||
|
void this.autoUpdateInstalledPlugins();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (this.refreshVersion === currentRefresh) {
|
if (this.refreshVersion === currentRefresh) {
|
||||||
@@ -161,54 +175,88 @@ export class PluginStoreService {
|
|||||||
throw new Error('Plugin does not provide an install manifest URL');
|
throw new Error('Plugin does not provide an install manifest URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
const manifest = options.manifest ?? await this.fetchPluginManifest(plugin.installUrl);
|
const manifest = this.withStoreBundleMetadata(options.manifest ?? await this.fetchPluginManifest(plugin.installUrl), plugin);
|
||||||
const installScope = getPluginInstallScope(manifest);
|
const installScope = getPluginInstallScope(manifest);
|
||||||
const targetServerId = installScope === 'server' ? (options.serverId ?? this.currentRoomId?.() ?? null) : null;
|
const targetServerId = this.resolveInstallTargetServerId(installScope, options.serverId);
|
||||||
|
|
||||||
if (installScope === 'server' && !targetServerId) {
|
|
||||||
throw new Error('Open a chat server before installing server-scoped plugins');
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const currentScopePlugins = installScope === 'server'
|
const currentScopePlugins = installScope === 'server'
|
||||||
? await this.installedPluginsForServer(targetServerId)
|
? await this.installedPluginsForServer(targetServerId)
|
||||||
: this.installedPluginsForScope(installScope);
|
: this.installedPluginsForScope(installScope);
|
||||||
const existing = currentScopePlugins.find((candidate) => candidate.manifest.id === manifest.id);
|
const existing = currentScopePlugins.find((candidate) => candidate.manifest.id === manifest.id);
|
||||||
const installedPlugin: InstalledStorePlugin = {
|
const installedPlugin = await this.cacheInstalledPlugin({
|
||||||
|
bundleUrl: manifest.bundle?.url ?? plugin.bundleUrl,
|
||||||
installedAt: existing?.installedAt ?? now,
|
installedAt: existing?.installedAt ?? now,
|
||||||
installUrl: plugin.installUrl,
|
installUrl: plugin.installUrl,
|
||||||
manifest,
|
manifest,
|
||||||
sourceUrl: plugin.sourceUrl,
|
sourceUrl: plugin.sourceUrl,
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
};
|
});
|
||||||
const nextInstalledPlugins = currentScopePlugins
|
const nextInstalledPlugins = currentScopePlugins
|
||||||
.filter((candidate) => candidate.manifest.id !== manifest.id)
|
.filter((candidate) => candidate.manifest.id !== manifest.id)
|
||||||
.concat(installedPlugin)
|
.concat(installedPlugin)
|
||||||
.sort(sortInstalledPlugins);
|
.sort(sortInstalledPlugins);
|
||||||
|
|
||||||
if (installScope === 'server') {
|
await this.persistInstallResult(installScope, targetServerId, nextInstalledPlugins, installedPlugin, options);
|
||||||
await this.saveServerPluginRequirement(installedPlugin, targetServerId, options.optional === true ? 'optional' : 'required');
|
await this.registerInstallResult(installScope, targetServerId, nextInstalledPlugins, installedPlugin, options);
|
||||||
} else {
|
|
||||||
await this.persistInstalledPlugins(nextInstalledPlugins, installScope);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (installScope === 'client' || targetServerId === this.currentRoomId?.()) {
|
|
||||||
this.host.registerLocalManifest(manifest, plugin.installUrl);
|
|
||||||
|
|
||||||
if (installScope === 'client' || options.optional !== true) {
|
|
||||||
this.setInstalledPluginsForScope(installScope, nextInstalledPlugins);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.activate) {
|
|
||||||
await this.host.activatePluginById(manifest.id);
|
|
||||||
}
|
|
||||||
} else if (options.activate) {
|
|
||||||
await this.host.rememberActivation(manifest.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return installedPlugin;
|
return installedPlugin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveInstallTargetServerId(installScope: TojuPluginInstallScope, requestedServerId: string | undefined): string | null {
|
||||||
|
if (installScope !== 'server') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetServerId = requestedServerId ?? this.currentRoomId?.() ?? null;
|
||||||
|
|
||||||
|
if (!targetServerId) {
|
||||||
|
throw new Error('Open a chat server before installing server-scoped plugins');
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetServerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistInstallResult(
|
||||||
|
installScope: TojuPluginInstallScope,
|
||||||
|
targetServerId: string | null,
|
||||||
|
nextInstalledPlugins: InstalledStorePlugin[],
|
||||||
|
installedPlugin: InstalledStorePlugin,
|
||||||
|
options: PluginStoreInstallOptions
|
||||||
|
): Promise<void> {
|
||||||
|
if (installScope === 'server') {
|
||||||
|
await this.saveServerPluginRequirement(installedPlugin, targetServerId, options.optional === true ? 'optional' : 'required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.persistInstalledPlugins(nextInstalledPlugins, installScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async registerInstallResult(
|
||||||
|
installScope: TojuPluginInstallScope,
|
||||||
|
targetServerId: string | null,
|
||||||
|
nextInstalledPlugins: InstalledStorePlugin[],
|
||||||
|
installedPlugin: InstalledStorePlugin,
|
||||||
|
options: PluginStoreInstallOptions
|
||||||
|
): Promise<void> {
|
||||||
|
if (installScope !== 'client' && targetServerId !== this.currentRoomId?.()) {
|
||||||
|
if (options.activate) {
|
||||||
|
await this.host.rememberActivation(installedPlugin.manifest.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.host.registerLocalManifest(installedPlugin.manifest, installedPlugin.cachedSourcePath ?? installedPlugin.installUrl);
|
||||||
|
|
||||||
|
if (installScope === 'client' || options.optional !== true) {
|
||||||
|
this.setInstalledPluginsForScope(installScope, nextInstalledPlugins);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.activate) {
|
||||||
|
await this.host.activatePluginById(installedPlugin.manifest.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async loadInstallManifest(plugin: PluginStoreEntry): Promise<TojuPluginManifest> {
|
async loadInstallManifest(plugin: PluginStoreEntry): Promise<TojuPluginManifest> {
|
||||||
if (!plugin.installUrl) {
|
if (!plugin.installUrl) {
|
||||||
throw new Error('Plugin does not provide an install manifest URL');
|
throw new Error('Plugin does not provide an install manifest URL');
|
||||||
@@ -217,21 +265,32 @@ export class PluginStoreService {
|
|||||||
return await this.fetchPluginManifest(plugin.installUrl);
|
return await this.fetchPluginManifest(plugin.installUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
async uninstallPlugin(pluginId: string, scope?: TojuPluginInstallScope): Promise<void> {
|
async uninstallPlugin(pluginId: string, scope?: TojuPluginInstallScope, options: { serverId?: string } = {}): Promise<void> {
|
||||||
const installScope = scope ?? this.findInstalledPluginScope(pluginId) ?? 'client';
|
const installScope = scope ?? this.findInstalledPluginScope(pluginId) ?? 'client';
|
||||||
const nextInstalledPlugins = this.installedPluginsForScope(installScope).filter((installedPlugin) => installedPlugin.manifest.id !== pluginId);
|
const currentInstalledPlugins = installScope === 'server'
|
||||||
|
? await this.installedPluginsForServer(options.serverId ?? this.currentRoomId?.() ?? null)
|
||||||
|
: this.installedPluginsForScope(installScope);
|
||||||
|
const nextInstalledPlugins = currentInstalledPlugins.filter((installedPlugin) => installedPlugin.manifest.id !== pluginId);
|
||||||
|
|
||||||
if (installScope === 'server') {
|
if (installScope === 'server') {
|
||||||
await this.deleteServerPluginRequirement(pluginId);
|
await this.deleteServerPluginRequirement(pluginId, options.serverId);
|
||||||
} else {
|
} else {
|
||||||
await this.persistInstalledPlugins(nextInstalledPlugins, installScope);
|
await this.persistInstalledPlugins(nextInstalledPlugins, installScope);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (installScope === 'server' && options.serverId && options.serverId !== this.currentRoomId?.()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.host.deactivatePlugin(pluginId, { forgetActivation: true });
|
await this.host.deactivatePlugin(pluginId, { forgetActivation: true });
|
||||||
this.registry.unregister(pluginId);
|
this.registry.unregister(pluginId);
|
||||||
this.setInstalledPluginsForScope(installScope, nextInstalledPlugins);
|
this.setInstalledPluginsForScope(installScope, nextInstalledPlugins);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loadInstalledPluginsForServer(serverId: string): Promise<InstalledStorePlugin[]> {
|
||||||
|
return await this.installedPluginsForServer(serverId);
|
||||||
|
}
|
||||||
|
|
||||||
async loadReadme(plugin: PluginStoreEntry): Promise<PluginStoreReadme> {
|
async loadReadme(plugin: PluginStoreEntry): Promise<PluginStoreReadme> {
|
||||||
if (!plugin.readmeUrl) {
|
if (!plugin.readmeUrl) {
|
||||||
throw new Error('Plugin does not provide a readme URL');
|
throw new Error('Plugin does not provide a readme URL');
|
||||||
@@ -332,6 +391,107 @@ export class PluginStoreService {
|
|||||||
return new TextDecoder().decode(bytes);
|
return new TextDecoder().decode(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private withStoreBundleMetadata(manifest: TojuPluginManifest, plugin: PluginStoreEntry): TojuPluginManifest {
|
||||||
|
if (!plugin.bundleUrl || manifest.bundle?.url) {
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...manifest,
|
||||||
|
bundle: {
|
||||||
|
entrypoint: './main.js',
|
||||||
|
url: plugin.bundleUrl
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cacheInstalledPlugin(installedPlugin: InstalledStorePlugin): Promise<InstalledStorePlugin> {
|
||||||
|
if (installedPlugin.cachedSourcePath) {
|
||||||
|
return installedPlugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = this.electronBridge.getApi();
|
||||||
|
const entrypointSourceUrl = this.resolvePluginBundleSourceUrl(installedPlugin);
|
||||||
|
const cachedEntrypoint = this.resolveCachedEntrypointPath(installedPlugin.manifest);
|
||||||
|
|
||||||
|
if (!api || !entrypointSourceUrl || !cachedEntrypoint) {
|
||||||
|
return installedPlugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedManifest = this.toCachedRuntimeManifest(installedPlugin.manifest, cachedEntrypoint);
|
||||||
|
const appDataPath = await api.getAppDataPath();
|
||||||
|
const pluginCacheDir = joinLocalPath(
|
||||||
|
appDataPath,
|
||||||
|
PLUGIN_CACHE_DIR,
|
||||||
|
sanitizePathSegment(installedPlugin.manifest.id),
|
||||||
|
sanitizePathSegment(installedPlugin.manifest.version)
|
||||||
|
);
|
||||||
|
const manifestPath = joinLocalPath(pluginCacheDir, 'toju-plugin.json');
|
||||||
|
const entrypointPath = joinLocalPath(pluginCacheDir, cachedEntrypoint);
|
||||||
|
const cacheRootUrl = localPathToFileUrl(manifestPath);
|
||||||
|
|
||||||
|
if (!cacheRootUrl) {
|
||||||
|
return installedPlugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.ensureDir(dirnameLocalPath(entrypointPath));
|
||||||
|
await api.writeFile(entrypointPath, bytesToBase64(new TextEncoder().encode(await this.fetchText(entrypointSourceUrl, 'text/javascript,*/*'))));
|
||||||
|
await api.writeFile(manifestPath, bytesToBase64(new TextEncoder().encode(JSON.stringify(cachedManifest, null, 2))));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...installedPlugin,
|
||||||
|
bundleUrl: installedPlugin.bundleUrl ?? installedPlugin.manifest.bundle?.url,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
cachedSourcePath: cacheRootUrl,
|
||||||
|
manifest: cachedManifest
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toCachedRuntimeManifest(manifest: TojuPluginManifest, cachedEntrypoint: string): TojuPluginManifest {
|
||||||
|
if (!manifest.bundle?.url) {
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...manifest,
|
||||||
|
entrypoint: cachedEntrypoint.startsWith('./') ? cachedEntrypoint : `./${cachedEntrypoint}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolvePluginBundleSourceUrl(installedPlugin: InstalledStorePlugin): string | null {
|
||||||
|
const bundleUrl = installedPlugin.bundleUrl ?? installedPlugin.manifest.bundle?.url;
|
||||||
|
|
||||||
|
if (bundleUrl) {
|
||||||
|
return bundleUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entrypoint = installedPlugin.manifest.entrypoint;
|
||||||
|
|
||||||
|
if (!entrypoint || !installedPlugin.installUrl || isAbsolutePluginUrl(entrypoint)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveOptionalUrl(installedPlugin.installUrl, entrypoint) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveCachedEntrypointPath(manifest: TojuPluginManifest): string | null {
|
||||||
|
const entrypoint = manifest.bundle?.url
|
||||||
|
? manifest.bundle.entrypoint ?? './main.js'
|
||||||
|
: manifest.entrypoint;
|
||||||
|
|
||||||
|
if (!entrypoint || isAbsolutePluginUrl(entrypoint)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = entrypoint.replace(/^\.\//, '').replace(/\\/g, '/');
|
||||||
|
|
||||||
|
if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
private async applyInstalledPlugins(installedPlugins: InstalledStorePlugin[], scope: TojuPluginInstallScope): Promise<void> {
|
private async applyInstalledPlugins(installedPlugins: InstalledStorePlugin[], scope: TojuPluginInstallScope): Promise<void> {
|
||||||
const usableInstalledPlugins: InstalledStorePlugin[] = [];
|
const usableInstalledPlugins: InstalledStorePlugin[] = [];
|
||||||
const scopedInstalledPlugins = installedPlugins.filter((installedPlugin) => getPluginInstallScope(installedPlugin.manifest) === scope);
|
const scopedInstalledPlugins = installedPlugins.filter((installedPlugin) => getPluginInstallScope(installedPlugin.manifest) === scope);
|
||||||
@@ -346,8 +506,10 @@ export class PluginStoreService {
|
|||||||
|
|
||||||
for (const installedPlugin of scopedInstalledPlugins) {
|
for (const installedPlugin of scopedInstalledPlugins) {
|
||||||
try {
|
try {
|
||||||
this.host.registerLocalManifest(installedPlugin.manifest, installedPlugin.installUrl);
|
const cachedPlugin = await this.cacheInstalledPlugin(installedPlugin);
|
||||||
usableInstalledPlugins.push(installedPlugin);
|
|
||||||
|
this.host.registerLocalManifest(cachedPlugin.manifest, cachedPlugin.cachedSourcePath ?? cachedPlugin.installUrl);
|
||||||
|
usableInstalledPlugins.push(cachedPlugin);
|
||||||
} catch {
|
} catch {
|
||||||
// Corrupt persisted manifests are ignored so the store can recover on next install.
|
// Corrupt persisted manifests are ignored so the store can recover on next install.
|
||||||
}
|
}
|
||||||
@@ -355,15 +517,77 @@ export class PluginStoreService {
|
|||||||
|
|
||||||
this.setInstalledPluginsForScope(scope, usableInstalledPlugins);
|
this.setInstalledPluginsForScope(scope, usableInstalledPlugins);
|
||||||
|
|
||||||
await this.host.activatePersistedPlugins();
|
if (scope === 'server') {
|
||||||
|
await this.activateServerPlugins(usableInstalledPlugins);
|
||||||
|
} else {
|
||||||
|
await this.host.activatePersistedPlugins();
|
||||||
|
}
|
||||||
|
|
||||||
if (usableInstalledPlugins.length !== scopedInstalledPlugins.length) {
|
if (usableInstalledPlugins.length !== scopedInstalledPlugins.length) {
|
||||||
if (scope === 'client') {
|
if (scope === 'client') {
|
||||||
await this.persistInstalledPlugins(usableInstalledPlugins, scope);
|
await this.persistInstalledPlugins(usableInstalledPlugins, scope);
|
||||||
}
|
}
|
||||||
|
} else if (
|
||||||
|
scope === 'client'
|
||||||
|
&& usableInstalledPlugins.some((plugin, index) => plugin.cachedSourcePath !== scopedInstalledPlugins[index]?.cachedSourcePath)
|
||||||
|
) {
|
||||||
|
await this.persistInstalledPlugins(usableInstalledPlugins, scope);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async activateServerPlugins(installedPlugins: InstalledStorePlugin[]): Promise<void> {
|
||||||
|
for (const installedPlugin of installedPlugins) {
|
||||||
|
await this.host.activatePluginById(installedPlugin.manifest.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async autoUpdateInstalledPlugins(): Promise<void> {
|
||||||
|
if (this.autoUpdateInProgress || this.sources().length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.autoUpdateInProgress = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.autoUpdateScope('client');
|
||||||
|
|
||||||
|
if (this.currentRoomId?.()) {
|
||||||
|
await this.autoUpdateScope('server');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.autoUpdateInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async autoUpdateScope(scope: TojuPluginInstallScope): Promise<void> {
|
||||||
|
for (const installedPlugin of this.installedPluginsForScope(scope)) {
|
||||||
|
const update = this.findUpdateCandidate(installedPlugin, scope);
|
||||||
|
|
||||||
|
if (!update) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.installPlugin(update, {
|
||||||
|
activate: this.host.isPluginActive(installedPlugin.manifest.id),
|
||||||
|
serverId: scope === 'server' ? this.currentRoomId?.() ?? undefined : undefined
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private findUpdateCandidate(installedPlugin: InstalledStorePlugin, scope: TojuPluginInstallScope): PluginStoreEntry | null {
|
||||||
|
const candidates = this.availablePlugins().filter((plugin) => {
|
||||||
|
return plugin.id === installedPlugin.manifest.id
|
||||||
|
&& getStoreEntryInstallScope(plugin) === scope
|
||||||
|
&& (!installedPlugin.sourceUrl || plugin.sourceUrl === installedPlugin.sourceUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
.filter((plugin) => compareVersions(plugin.version, installedPlugin.manifest.version) > 0)
|
||||||
|
.sort((left, right) => compareVersions(right.version, left.version))[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
private async loadInstalledPluginsForScope(roomId: string | null, actorUserId: string | null): Promise<void> {
|
private async loadInstalledPluginsForScope(roomId: string | null, actorUserId: string | null): Promise<void> {
|
||||||
const currentLoad = this.installedLoadVersion + 1;
|
const currentLoad = this.installedLoadVersion + 1;
|
||||||
|
|
||||||
@@ -418,7 +642,7 @@ export class PluginStoreService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = await firstValueFrom(this.pluginRequirements.getSnapshot(this.serverDirectory.getApiBaseUrl(), roomId));
|
const snapshot = await firstValueFrom(this.pluginRequirements.getSnapshot(this.getPluginApiBaseUrl(roomId), roomId));
|
||||||
|
|
||||||
return snapshot.requirements
|
return snapshot.requirements
|
||||||
.map((requirement) => installedPluginFromRequirement(requirement))
|
.map((requirement) => installedPluginFromRequirement(requirement))
|
||||||
@@ -438,7 +662,7 @@ export class PluginStoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await firstValueFrom(this.pluginRequirements.upsertRequirement(
|
await firstValueFrom(this.pluginRequirements.upsertRequirement(
|
||||||
this.serverDirectory.getApiBaseUrl(),
|
this.getPluginApiBaseUrl(roomId),
|
||||||
roomId,
|
roomId,
|
||||||
installedPlugin.manifest.id,
|
installedPlugin.manifest.id,
|
||||||
{
|
{
|
||||||
@@ -453,15 +677,51 @@ export class PluginStoreService {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deleteServerPluginRequirement(pluginId: string): Promise<void> {
|
private async deleteServerPluginRequirement(pluginId: string, serverId?: string): Promise<void> {
|
||||||
const roomId = this.currentRoomId?.() ?? null;
|
const roomId = serverId ?? this.currentRoomId?.() ?? null;
|
||||||
const actorUserId = this.currentActorUserId();
|
const actorUserId = this.currentActorUserId();
|
||||||
|
|
||||||
if (!roomId || !actorUserId || !this.serverDirectory) {
|
if (!roomId || !actorUserId || !this.serverDirectory) {
|
||||||
throw new Error('Open a chat server before removing server-scoped plugins');
|
throw new Error('Open a chat server before removing server-scoped plugins');
|
||||||
}
|
}
|
||||||
|
|
||||||
await firstValueFrom(this.pluginRequirements.deleteRequirement(this.serverDirectory.getApiBaseUrl(), roomId, pluginId, actorUserId));
|
await firstValueFrom(this.pluginRequirements.deleteRequirement(this.getPluginApiBaseUrl(roomId), roomId, pluginId, actorUserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPluginApiBaseUrl(serverId: string): string {
|
||||||
|
const selector = this.serverSourceSelector(serverId);
|
||||||
|
|
||||||
|
return this.serverDirectory?.getApiBaseUrl(selector) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private serverSourceSelector(serverId: string): ServerSourceSelector | undefined {
|
||||||
|
if (serverId === this.currentRoomId?.()) {
|
||||||
|
return this.currentRoomSourceSelector();
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = this.savedRooms?.().find((candidate) => candidate.id === serverId) ?? null;
|
||||||
|
|
||||||
|
if (!room?.sourceId && !room?.sourceUrl) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceId: room.sourceId,
|
||||||
|
sourceUrl: room.sourceUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private currentRoomSourceSelector(): ServerSourceSelector | undefined {
|
||||||
|
const room = this.currentRoom?.() ?? null;
|
||||||
|
|
||||||
|
if (!room?.sourceId && !room?.sourceUrl) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceId: room.sourceId,
|
||||||
|
sourceUrl: room.sourceUrl
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private currentActorUserId(): string | null {
|
private currentActorUserId(): string | null {
|
||||||
@@ -591,6 +851,7 @@ function installedPluginFromRequirement(requirement: PluginRequirementSummary):
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
bundleUrl: manifest.bundle?.url,
|
||||||
installedAt: requirement.updatedAt,
|
installedAt: requirement.updatedAt,
|
||||||
installUrl: requirement.installUrl,
|
installUrl: requirement.installUrl,
|
||||||
manifest,
|
manifest,
|
||||||
@@ -640,6 +901,7 @@ function parsePluginEntry(sourceUrl: string, sourceTitle: string, value: unknown
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
author: readAuthor(value),
|
author: readAuthor(value),
|
||||||
|
bundleUrl: resolveOptionalUrl(sourceUrl, readString(value, 'bundle', 'bundleUrl')),
|
||||||
description: readString(value, 'description', 'summary') ?? '',
|
description: readString(value, 'description', 'summary') ?? '',
|
||||||
githubUrl: resolveOptionalUrl(sourceUrl, readGithubUrl(value)),
|
githubUrl: resolveOptionalUrl(sourceUrl, readGithubUrl(value)),
|
||||||
homepageUrl: resolveOptionalUrl(sourceUrl, readString(value, 'homepage', 'homepageUrl', 'website')),
|
homepageUrl: resolveOptionalUrl(sourceUrl, readString(value, 'homepage', 'homepageUrl', 'website')),
|
||||||
@@ -794,6 +1056,44 @@ function isAllowedPluginSourceProtocol(protocol: string): boolean {
|
|||||||
return protocol === 'http:' || protocol === 'https:' || protocol === 'file:';
|
return protocol === 'http:' || protocol === 'https:' || protocol === 'file:';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAbsolutePluginUrl(value: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
|
||||||
|
return isAllowedPluginSourceProtocol(url.protocol);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePathSegment(value: string): string {
|
||||||
|
return value.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 128) || 'plugin';
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinLocalPath(...parts: string[]): string {
|
||||||
|
return parts
|
||||||
|
.map((part, index) => index === 0 ? part.replace(/[\\/]+$/, '') : part.replace(/^[\\/]+|[\\/]+$/g, ''))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function dirnameLocalPath(filePath: string): string {
|
||||||
|
const normalized = filePath.replace(/\\/g, '/');
|
||||||
|
const index = normalized.lastIndexOf('/');
|
||||||
|
|
||||||
|
return index > 0 ? normalized.slice(0, index) : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToBase64(bytes: Uint8Array): string {
|
||||||
|
let binary = '';
|
||||||
|
|
||||||
|
for (const byte of bytes) {
|
||||||
|
binary += String.fromCharCode(byte);
|
||||||
|
}
|
||||||
|
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
function localPathToFileUrl(filePath: string): string | undefined {
|
function localPathToFileUrl(filePath: string): string | undefined {
|
||||||
if (!isAbsoluteLocalPath(filePath)) {
|
if (!isAbsoluteLocalPath(filePath)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type PluginStoreActionLabel = 'Install' | 'Install to Server' | 'Remove f
|
|||||||
|
|
||||||
export interface PluginStoreEntry {
|
export interface PluginStoreEntry {
|
||||||
author?: string;
|
author?: string;
|
||||||
|
bundleUrl?: string;
|
||||||
description: string;
|
description: string;
|
||||||
githubUrl?: string;
|
githubUrl?: string;
|
||||||
homepageUrl?: string;
|
homepageUrl?: string;
|
||||||
@@ -28,6 +29,9 @@ export interface PluginStoreSourceResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface InstalledStorePlugin {
|
export interface InstalledStorePlugin {
|
||||||
|
bundleUrl?: string;
|
||||||
|
cachedAt?: number;
|
||||||
|
cachedSourcePath?: string;
|
||||||
installedAt: number;
|
installedAt: number;
|
||||||
installUrl?: string;
|
installUrl?: string;
|
||||||
manifest: TojuPluginManifest;
|
manifest: TojuPluginManifest;
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ type PluginManagerTab = 'docs' | 'extensions' | 'installed' | 'logs' | 'requirem
|
|||||||
})
|
})
|
||||||
export class PluginManagerComponent {
|
export class PluginManagerComponent {
|
||||||
@Output() readonly closed = new EventEmitter<void>();
|
@Output() readonly closed = new EventEmitter<void>();
|
||||||
|
@Output() readonly storeOpened = new EventEmitter<void>();
|
||||||
|
|
||||||
readonly scope = input<TojuPluginInstallScope>('client');
|
readonly scope = input<TojuPluginInstallScope>('client');
|
||||||
|
|
||||||
@@ -149,7 +150,7 @@ export class PluginManagerComponent {
|
|||||||
openStore(): void {
|
openStore(): void {
|
||||||
const returnUrl = this.router.url.startsWith('/plugin-store') ? '/search' : this.router.url;
|
const returnUrl = this.router.url.startsWith('/plugin-store') ? '/search' : this.router.url;
|
||||||
|
|
||||||
this.closed.emit();
|
this.storeOpened.emit();
|
||||||
void this.router.navigate(['/plugin-store'], { queryParams: { returnUrl } });
|
void this.router.navigate(['/plugin-store'], { queryParams: { returnUrl } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,255 +1,332 @@
|
|||||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity, @angular-eslint/template/prefer-ngsrc -->
|
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity, @angular-eslint/template/prefer-ngsrc -->
|
||||||
<main
|
<main
|
||||||
class="plugin-store"
|
class="min-h-[calc(100vh-2.5rem)] bg-background px-3 py-4 text-foreground sm:px-6"
|
||||||
data-testid="plugin-store-page"
|
data-testid="plugin-store-page"
|
||||||
>
|
>
|
||||||
<header class="plugin-store__topbar">
|
<header class="flex flex-col gap-3 border-b border-border pb-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div class="plugin-store__title-row">
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="goBack()"
|
(click)="goBack()"
|
||||||
class="plugin-store__icon-button"
|
class="grid h-8 w-8 shrink-0 place-items-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||||
title="Back to app"
|
title="Back to app"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideArrowLeft" />
|
<ng-icon
|
||||||
|
name="lucideArrowLeft"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="plugin-store__brand-icon">
|
<div class="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-primary/10 text-primary">
|
||||||
<ng-icon name="lucideStore" />
|
<ng-icon
|
||||||
|
name="lucideStore"
|
||||||
|
class="h-5 w-5"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="plugin-store__title-copy">
|
<div class="min-w-0">
|
||||||
<h1>Plugin Store</h1>
|
<h1 class="truncate text-xl font-semibold leading-7">Plugin Store</h1>
|
||||||
<p>
|
<p class="truncate text-sm text-muted-foreground">
|
||||||
{{ installedCount() }} installed for {{ store.installScopeLabel() }} · {{ totalSourcePlugins() }} available · {{ sourceCount() }} sources
|
{{ installedCount() }} installed for {{ store.installScopeLabel() }} · {{ totalSourcePlugins() }} available · {{ sourceCount() }} sources
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="plugin-store__top-actions">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="openManager()"
|
(click)="openManager()"
|
||||||
class="plugin-store__secondary-button"
|
class="inline-flex min-h-8 items-center justify-center gap-2 rounded-lg border border-border bg-card px-3 py-1.5 text-sm font-semibold transition-colors hover:bg-secondary"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideSettings" />
|
<ng-icon
|
||||||
|
name="lucideSettings"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
Manage Plugins
|
Manage Plugins
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="refreshSources()"
|
(click)="refreshSources()"
|
||||||
[disabled]="store.isLoading()"
|
[disabled]="store.isLoading()"
|
||||||
class="plugin-store__secondary-button"
|
class="inline-flex min-h-8 items-center justify-center gap-2 rounded-lg border border-border bg-card px-3 py-1.5 text-sm font-semibold transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-55"
|
||||||
>
|
>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideRefreshCw"
|
name="lucideRefreshCw"
|
||||||
[class.is-spinning]="store.isLoading()"
|
class="h-4 w-4"
|
||||||
|
[class.animate-spin]="store.isLoading()"
|
||||||
/>
|
/>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="plugin-store__source-strip">
|
<section class="grid gap-3 py-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||||
<div class="plugin-store__source-form">
|
<div class="flex min-w-0 flex-col gap-2 sm:flex-row">
|
||||||
<label class="plugin-store__input-shell plugin-store__source-input">
|
<label class="relative flex min-w-0 flex-1">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
[(ngModel)]="newSourceUrl"
|
[(ngModel)]="newSourceUrl"
|
||||||
(keyup.enter)="addSourceUrl()"
|
(keyup.enter)="addSourceUrl()"
|
||||||
placeholder="https://example.com/plugins.json or /home/me/plugins/source.json"
|
placeholder="https://example.com/plugins.json or /home/me/plugins/source.json"
|
||||||
aria-label="Plugin source manifest URL"
|
aria-label="Plugin source manifest URL"
|
||||||
|
class="min-h-9 w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="addSourceUrl()"
|
(click)="addSourceUrl()"
|
||||||
[disabled]="!newSourceUrl.trim() || store.isLoading()"
|
[disabled]="!newSourceUrl.trim() || store.isLoading()"
|
||||||
class="plugin-store__primary-button"
|
class="inline-flex min-h-9 items-center justify-center gap-2 rounded-lg border border-primary bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-55"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucidePlus" />
|
<ng-icon
|
||||||
|
name="lucidePlus"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
Add Source
|
Add Source
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (sourceError()) {
|
@if (sourceError()) {
|
||||||
<p class="plugin-store__error-text">{{ sourceError() }}</p>
|
<p class="text-sm text-destructive">{{ sourceError() }}</p>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="plugin-store__layout">
|
<div
|
||||||
|
class="grid items-start gap-3"
|
||||||
|
[ngClass]="readme() ? 'xl:grid-cols-[minmax(13rem,17rem)_minmax(0,1fr)_minmax(24rem,38rem)]' : 'xl:grid-cols-[minmax(13rem,17rem)_minmax(0,1fr)]'"
|
||||||
|
>
|
||||||
<aside
|
<aside
|
||||||
class="plugin-store__rail"
|
class="grid gap-3 xl:sticky xl:top-3"
|
||||||
aria-label="Plugin sources"
|
aria-label="Plugin sources and filters"
|
||||||
>
|
>
|
||||||
<section class="plugin-store__panel">
|
<section class="grid min-w-0 gap-1 rounded-lg border border-border bg-card p-3">
|
||||||
<div class="plugin-store__panel-header">
|
<div class="mb-1 flex items-center justify-between gap-2">
|
||||||
<h2>Sources</h2>
|
<h2 class="text-xs font-bold uppercase text-foreground">Sources</h2>
|
||||||
<span>{{ sourceCount() }}</span>
|
<span class="rounded-full bg-secondary px-2 py-0.5 text-xs text-muted-foreground">{{ sourceCount() }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="plugin-store__source-filter"
|
class="flex min-w-0 items-center justify-between gap-2 rounded-md px-2 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||||
[class.is-active]="selectedSourceUrl() === null"
|
[class.bg-secondary]="selectedSourceUrl() === null"
|
||||||
|
[class.text-foreground]="selectedSourceUrl() === null"
|
||||||
(click)="selectSource(null)"
|
(click)="selectSource(null)"
|
||||||
>
|
>
|
||||||
<span>All sources</span>
|
<span class="truncate">All sources</span>
|
||||||
<strong>{{ totalSourcePlugins() }}</strong>
|
<strong class="rounded-full bg-background px-2 py-0.5 text-xs text-muted-foreground">{{ totalSourcePlugins() }}</strong>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@for (source of store.sources(); track source.url) {
|
@for (source of store.sources(); track source.url) {
|
||||||
<div
|
<div class="flex min-w-0 items-center gap-1">
|
||||||
class="plugin-store__source-row"
|
|
||||||
[class.has-error]="!!source.error"
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="plugin-store__source-filter"
|
class="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-md px-2 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||||
[class.is-active]="selectedSourceUrl() === source.url"
|
[class.bg-secondary]="selectedSourceUrl() === source.url"
|
||||||
|
[class.text-foreground]="selectedSourceUrl() === source.url"
|
||||||
(click)="selectSource(source.url)"
|
(click)="selectSource(source.url)"
|
||||||
>
|
>
|
||||||
<span>{{ source.title || source.url }}</span>
|
<span class="truncate">{{ source.title || source.url }}</span>
|
||||||
<strong>{{ source.plugins.length }}</strong>
|
<strong class="rounded-full bg-background px-2 py-0.5 text-xs text-muted-foreground">{{ source.plugins.length }}</strong>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="removeSourceUrl(source.url)"
|
(click)="removeSourceUrl(source.url)"
|
||||||
class="plugin-store__icon-button plugin-store__icon-button--danger"
|
class="grid h-8 w-8 shrink-0 place-items-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||||
title="Remove source"
|
title="Remove source"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideTrash2" />
|
<ng-icon
|
||||||
|
name="lucideTrash2"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@if (source.error) {
|
@if (source.error) {
|
||||||
<p class="plugin-store__source-error">{{ source.error }}</p>
|
<p class="px-2 text-xs text-destructive">{{ source.error }}</p>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@for (sourceUrl of pendingSourceUrls(); track sourceUrl) {
|
@for (sourceUrl of pendingSourceUrls(); track sourceUrl) {
|
||||||
<div class="plugin-store__source-row">
|
<div class="flex min-w-0 items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="plugin-store__source-filter"
|
class="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-md px-2 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||||
[class.is-active]="selectedSourceUrl() === sourceUrl"
|
[class.bg-secondary]="selectedSourceUrl() === sourceUrl"
|
||||||
|
[class.text-foreground]="selectedSourceUrl() === sourceUrl"
|
||||||
(click)="selectSource(sourceUrl)"
|
(click)="selectSource(sourceUrl)"
|
||||||
>
|
>
|
||||||
<span>{{ sourceUrl }}</span>
|
<span class="truncate">{{ sourceUrl }}</span>
|
||||||
<strong>0</strong>
|
<strong class="rounded-full bg-background px-2 py-0.5 text-xs text-muted-foreground">0</strong>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="removeSourceUrl(sourceUrl)"
|
(click)="removeSourceUrl(sourceUrl)"
|
||||||
class="plugin-store__icon-button plugin-store__icon-button--danger"
|
class="grid h-8 w-8 shrink-0 place-items-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||||
title="Remove source"
|
title="Remove source"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideTrash2" />
|
<ng-icon
|
||||||
|
name="lucideTrash2"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="plugin-store__panel">
|
<section class="grid min-w-0 gap-2 rounded-lg border border-border bg-card p-3">
|
||||||
<div class="plugin-store__panel-header">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<h2>Filters</h2>
|
<h2 class="text-xs font-bold uppercase text-foreground">Filters</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="plugin-store__toggle-button"
|
class="flex min-w-0 items-center justify-between gap-2 rounded-md px-2 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||||
[class.is-active]="showInstalledOnly()"
|
[class.bg-secondary]="showInstalledOnly()"
|
||||||
|
[class.text-foreground]="showInstalledOnly()"
|
||||||
(click)="toggleInstalledOnly()"
|
(click)="toggleInstalledOnly()"
|
||||||
>
|
>
|
||||||
<span>Installed only</span>
|
<span>Installed only</span>
|
||||||
<strong>{{ installedCount() }}</strong>
|
<strong class="rounded-full bg-background px-2 py-0.5 text-xs text-muted-foreground">{{ installedCount() }}</strong>
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="grid min-w-0 gap-2 rounded-lg border border-border bg-card p-3">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<h2 class="text-xs font-bold uppercase text-foreground">Install server</h2>
|
||||||
|
<span class="rounded-full bg-secondary px-2 py-0.5 text-xs text-muted-foreground">{{ manageableServers().length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (manageableServers().length > 0) {
|
||||||
|
@for (server of manageableServers(); track trackServer($index, server)) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="selectStoreServer(server.id)"
|
||||||
|
class="group flex min-w-0 items-start gap-2 rounded-md border border-transparent px-2 py-2 text-left transition-colors hover:border-primary/40 hover:bg-secondary"
|
||||||
|
[class.border-primary]="selectedStoreServerId() === server.id"
|
||||||
|
[ngClass]="{ 'bg-primary/10': selectedStoreServerId() === server.id }"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="mt-1 h-2 w-2 shrink-0 rounded-full bg-muted-foreground group-hover:bg-primary"
|
||||||
|
[class.bg-primary]="selectedStoreServerId() === server.id"
|
||||||
|
></span>
|
||||||
|
<span class="min-w-0">
|
||||||
|
<span class="block truncate text-sm font-semibold text-foreground">{{ server.name }}</span>
|
||||||
|
<span class="block truncate text-xs text-muted-foreground">{{ server.sourceUrl || 'Default endpoint' }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<p class="rounded-md border border-border bg-secondary/40 px-2 py-2 text-xs leading-5 text-muted-foreground">
|
||||||
|
No server is available for plugin installs. Owner or Manage Server access is required.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
class="plugin-store__catalog"
|
class="grid min-w-0 gap-3 rounded-lg border border-border bg-card p-3"
|
||||||
aria-label="Available plugins"
|
aria-label="Available plugins"
|
||||||
>
|
>
|
||||||
<div class="plugin-store__toolbar">
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<label class="plugin-store__input-shell plugin-store__search">
|
<label class="relative flex min-w-0 flex-1 sm:max-w-xl">
|
||||||
<ng-icon name="lucideSearch" />
|
<ng-icon
|
||||||
|
name="lucideSearch"
|
||||||
|
class="pointer-events-none absolute inset-y-0 left-3 my-auto h-4 w-4 text-muted-foreground"
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
[ngModel]="searchTerm()"
|
[ngModel]="searchTerm()"
|
||||||
(ngModelChange)="searchTerm.set($event)"
|
(ngModelChange)="searchTerm.set($event)"
|
||||||
placeholder="Search plugins, authors, ids"
|
placeholder="Search plugins, authors, ids"
|
||||||
aria-label="Search plugins"
|
aria-label="Search plugins"
|
||||||
|
class="min-h-9 w-full rounded-lg border border-border bg-secondary py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="plugin-store__count">{{ filteredPlugins().length }} shown</div>
|
<div class="text-sm text-muted-foreground">{{ filteredPlugins().length }} shown</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (actionError()) {
|
@if (actionError()) {
|
||||||
<p class="plugin-store__error-banner">{{ actionError() }}</p>
|
<p class="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">{{ actionError() }}</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (readmeError()) {
|
@if (readmeError()) {
|
||||||
<p class="plugin-store__error-banner">{{ readmeError() }}</p>
|
<p class="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">{{ readmeError() }}</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (filteredPlugins().length > 0) {
|
@if (filteredPlugins().length > 0) {
|
||||||
<div class="plugin-store__grid">
|
<div class="grid gap-3">
|
||||||
@for (plugin of filteredPlugins(); track trackPlugin($index, plugin)) {
|
@for (plugin of filteredPlugins(); track trackPlugin($index, plugin)) {
|
||||||
<article class="plugin-card">
|
<article class="grid min-w-0 overflow-hidden rounded-lg border border-border bg-background sm:grid-cols-[5.5rem_minmax(0,1fr)]">
|
||||||
<div class="plugin-card__media">
|
<div class="grid min-h-24 place-items-center bg-secondary text-muted-foreground sm:min-h-full">
|
||||||
@if (plugin.imageUrl) {
|
@if (plugin.imageUrl) {
|
||||||
<img
|
<img
|
||||||
[src]="plugin.imageUrl"
|
[src]="plugin.imageUrl"
|
||||||
[alt]="plugin.title"
|
[alt]="plugin.title"
|
||||||
(error)="hideBrokenImage($event)"
|
(error)="hideBrokenImage($event)"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<ng-icon name="lucidePackage" />
|
<ng-icon
|
||||||
|
name="lucidePackage"
|
||||||
|
class="h-6 w-6"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="plugin-card__body">
|
<div class="grid min-w-0 gap-2 p-3">
|
||||||
<div class="plugin-card__header">
|
<div class="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||||
<div>
|
<div class="min-w-0">
|
||||||
<h2>{{ plugin.title }}</h2>
|
<h2 class="truncate text-base font-semibold leading-6">{{ plugin.title }}</h2>
|
||||||
<p>{{ plugin.author || 'Unknown author' }} · v{{ plugin.version }}</p>
|
<p class="truncate text-sm text-muted-foreground">{{ plugin.author || 'Unknown author' }} · v{{ plugin.version }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (store.getInstallState(plugin) === 'updateAvailable') {
|
@if (getPluginInstallState(plugin) === 'updateAvailable') {
|
||||||
<span class="plugin-card__badge">Update</span>
|
<span
|
||||||
} @else if (store.getInstallState(plugin) === 'installed') {
|
class="self-start whitespace-nowrap rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-primary"
|
||||||
<span class="plugin-card__badge plugin-card__badge--installed">Installed</span>
|
>Update</span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
@if (getPluginInstallState(plugin) === 'installed') {
|
||||||
|
<span
|
||||||
|
class="self-start whitespace-nowrap rounded-full bg-emerald-600/10 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-emerald-600"
|
||||||
|
>Installed</span
|
||||||
|
>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="plugin-card__description">{{ plugin.description }}</p>
|
<p class="line-clamp-2 min-h-10 text-sm text-muted-foreground">{{ plugin.description }}</p>
|
||||||
|
|
||||||
<div class="plugin-card__meta">
|
<div class="flex min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span>{{ plugin.id }}</span>
|
<span class="max-w-full truncate rounded-full bg-secondary px-2 py-1">{{ plugin.id }}</span>
|
||||||
<span>{{ plugin.sourceTitle || plugin.sourceUrl }}</span>
|
<span class="max-w-full truncate rounded-full bg-secondary px-2 py-1">{{ plugin.sourceTitle || plugin.sourceUrl }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="plugin-card__actions">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="runPrimaryAction(plugin)"
|
(click)="runPrimaryAction(plugin)"
|
||||||
[disabled]="isPrimaryActionDisabled(plugin)"
|
[disabled]="isPrimaryActionDisabled(plugin)"
|
||||||
[title]="serverInstallButtonTitle(plugin)"
|
[title]="serverInstallButtonTitle(plugin)"
|
||||||
class="plugin-store__primary-button plugin-card__primary-action"
|
class="inline-flex min-h-8 items-center justify-center gap-2 rounded-lg border border-primary bg-primary px-3 py-1.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-55"
|
||||||
[class.plugin-card__primary-action--danger]="store.getActionLabel(plugin) === 'Uninstall' || store.getActionLabel(plugin) === 'Remove from Server'"
|
[ngClass]="{
|
||||||
|
'border-destructive/35':
|
||||||
|
getPrimaryActionLabel(plugin) === 'Uninstall' || getPrimaryActionLabel(plugin) === 'Remove from Server',
|
||||||
|
'bg-destructive/10': getPrimaryActionLabel(plugin) === 'Uninstall' || getPrimaryActionLabel(plugin) === 'Remove from Server'
|
||||||
|
}"
|
||||||
|
[class.text-destructive]="getPrimaryActionLabel(plugin) === 'Uninstall' || getPrimaryActionLabel(plugin) === 'Remove from Server'"
|
||||||
>
|
>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
[name]="primaryActionIcon(plugin)"
|
[name]="primaryActionIcon(plugin)"
|
||||||
[class.is-spinning]="isPluginBusy(plugin)"
|
class="h-4 w-4"
|
||||||
|
[class.animate-spin]="isPluginBusy(plugin)"
|
||||||
/>
|
/>
|
||||||
{{ store.getActionLabel(plugin) }}
|
{{ getPrimaryActionLabel(plugin) }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@if (plugin.readmeUrl) {
|
@if (plugin.readmeUrl) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="loadReadme(plugin)"
|
(click)="loadReadme(plugin)"
|
||||||
class="plugin-store__text-button"
|
class="inline-flex min-h-8 items-center justify-center rounded-lg px-3 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||||
title="Load readme"
|
title="Load readme"
|
||||||
>
|
>
|
||||||
{{ isReadmeLoading(plugin) ? 'Loading' : 'Readme' }}
|
{{ isReadmeLoading(plugin) ? 'Loading' : 'Readme' }}
|
||||||
@@ -260,10 +337,13 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="openExternal(plugin.githubUrl)"
|
(click)="openExternal(plugin.githubUrl)"
|
||||||
class="plugin-store__icon-button"
|
class="grid h-8 w-8 place-items-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||||
title="Open GitHub"
|
title="Open GitHub"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideExternalLink" />
|
<ng-icon
|
||||||
|
name="lucideExternalLink"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -272,43 +352,77 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<section class="plugin-store__empty">
|
<section class="grid min-h-56 place-items-center rounded-lg border border-dashed border-border bg-background p-8 text-center">
|
||||||
<ng-icon name="lucidePackage" />
|
<div class="grid justify-items-center gap-2">
|
||||||
<h2>No plugins found</h2>
|
<ng-icon
|
||||||
<p>{{ sourceCount() ? 'Adjust filters or add another source manifest.' : 'Add a plugin source manifest URL to populate the catalog.' }}</p>
|
name="lucidePackage"
|
||||||
|
class="h-7 w-7 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<h2 class="text-base font-semibold">No plugins found</h2>
|
||||||
|
<p class="max-w-md text-sm text-muted-foreground">
|
||||||
|
{{ sourceCount() ? 'Adjust filters or add another source manifest.' : 'Add a plugin source manifest URL to populate the catalog.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@if (readme()) {
|
@if (readme()) {
|
||||||
<aside
|
<aside
|
||||||
class="plugin-store__readme"
|
class="grid min-w-0 gap-3 rounded-lg border border-border bg-card p-3 xl:sticky xl:top-3 xl:max-h-[calc(100vh-6rem)]"
|
||||||
aria-label="Plugin readme"
|
aria-label="Plugin readme"
|
||||||
>
|
>
|
||||||
<div class="plugin-store__readme-header">
|
<div class="grid grid-cols-[minmax(0,1fr)_auto] gap-3">
|
||||||
<div>
|
<div class="min-w-0">
|
||||||
<p>Readme</p>
|
<p class="mb-1 text-xs font-bold uppercase text-primary">Readme</p>
|
||||||
<h2>{{ readme()!.title }}</h2>
|
<h2 class="truncate text-base font-semibold">{{ readme()!.title }}</h2>
|
||||||
@if (selectedReadmePlugin(); as plugin) {
|
@if (selectedReadmePlugin(); as plugin) {
|
||||||
<span>{{ plugin.author || 'Unknown author' }} · v{{ plugin.version }}</span>
|
<span class="block truncate text-sm text-muted-foreground">{{ plugin.author || 'Unknown author' }} · v{{ plugin.version }}</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="flex items-start gap-2">
|
||||||
type="button"
|
<button
|
||||||
(click)="closeReadme()"
|
type="button"
|
||||||
class="plugin-store__icon-button"
|
(click)="toggleReadmeRawMode()"
|
||||||
title="Close readme"
|
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-border bg-secondary px-3 py-1.5 text-xs font-semibold text-foreground transition-colors hover:bg-secondary/80"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideX" />
|
{{ readmeRawMode() ? 'Parsed' : 'Raw' }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="closeReadme()"
|
||||||
|
class="grid h-8 w-8 place-items-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||||
|
title="Close readme"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideX"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<pre>{{ readme()!.markdown }}</pre>
|
|
||||||
|
@if (readmeRawMode()) {
|
||||||
|
<pre class="max-h-[calc(100vh-14rem)] overflow-auto rounded-lg bg-secondary/50 p-3 text-sm whitespace-pre-wrap">{{
|
||||||
|
readme()!.markdown
|
||||||
|
}}</pre>
|
||||||
|
} @else {
|
||||||
|
<div
|
||||||
|
class="max-h-[calc(100vh-14rem)] overflow-auto rounded-lg bg-secondary/30 p-3 text-sm leading-6 [&_a]:text-primary [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_code]:rounded [&_code]:bg-background [&_code]:px-1 [&_h1]:mb-2 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:mb-2 [&_h2]:mt-4 [&_h2]:text-lg [&_h2]:font-semibold [&_h3]:mb-1 [&_h3]:mt-3 [&_h3]:font-semibold [&_li]:ml-5 [&_ol]:list-decimal [&_p]:mb-3 [&_pre]:mb-3 [&_pre]:overflow-auto [&_pre]:rounded-lg [&_pre]:bg-background [&_pre]:p-3 [&_ul]:list-disc"
|
||||||
|
>
|
||||||
|
<app-chat-message-markdown [content]="readme()!.markdown" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="openExternal(readme()!.url)"
|
(click)="openExternal(readme()!.url)"
|
||||||
class="plugin-store__secondary-button plugin-store__readme-link"
|
class="inline-flex min-h-8 items-center justify-center gap-2 rounded-lg border border-border bg-card px-3 py-1.5 text-sm font-semibold transition-colors hover:bg-secondary"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideExternalLink" />
|
<ng-icon
|
||||||
|
name="lucideExternalLink"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
Open source readme
|
Open source readme
|
||||||
</button>
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -317,37 +431,46 @@
|
|||||||
|
|
||||||
@if (serverInstallDialog(); as dialog) {
|
@if (serverInstallDialog(); as dialog) {
|
||||||
<div
|
<div
|
||||||
class="plugin-store__modal-backdrop"
|
class="fixed inset-0 z-[80] bg-black/60"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
></div>
|
></div>
|
||||||
<section
|
<section
|
||||||
class="plugin-store__install-modal"
|
class="fixed left-1/2 top-1/2 z-[81] flex max-h-[min(42rem,calc(100vh-2rem))] w-[min(34rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-lg border border-border bg-card text-foreground shadow-2xl"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="server-plugin-install-title"
|
aria-labelledby="server-plugin-install-title"
|
||||||
>
|
>
|
||||||
<header class="plugin-store__install-header">
|
<header class="flex items-start justify-between gap-4 border-b border-border p-4">
|
||||||
<div>
|
<div class="min-w-0">
|
||||||
<p>Server plugin install</p>
|
<p class="text-sm text-muted-foreground">Server plugin install</p>
|
||||||
<h2 id="server-plugin-install-title">{{ dialog.manifest.title }}</h2>
|
<h2
|
||||||
|
id="server-plugin-install-title"
|
||||||
|
class="mt-1 truncate text-lg font-semibold"
|
||||||
|
>
|
||||||
|
{{ dialog.manifest.title }}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="closeServerInstallDialog()"
|
(click)="closeServerInstallDialog()"
|
||||||
class="plugin-store__icon-button"
|
class="grid h-8 w-8 shrink-0 place-items-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||||
title="Cancel install"
|
title="Cancel install"
|
||||||
>
|
>
|
||||||
<ng-icon name="lucideX" />
|
<ng-icon
|
||||||
|
name="lucideX"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="plugin-store__install-body">
|
<div class="grid min-h-0 gap-4 overflow-auto p-4">
|
||||||
<label class="plugin-store__field">
|
<label class="grid gap-2">
|
||||||
<span>Install to server</span>
|
<span class="text-sm text-muted-foreground">Install to server</span>
|
||||||
<select
|
<select
|
||||||
[value]="dialog.selectedServerId"
|
[value]="dialog.selectedServerId"
|
||||||
[disabled]="serverInstallBusy()"
|
[disabled]="serverInstallBusy()"
|
||||||
(change)="selectServerInstallTarget($any($event.target).value)"
|
(change)="selectServerInstallTarget($any($event.target).value)"
|
||||||
|
class="min-h-9 rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-55"
|
||||||
>
|
>
|
||||||
@for (server of manageableServers(); track trackServer($index, server)) {
|
@for (server of manageableServers(); track trackServer($index, server)) {
|
||||||
<option [value]="server.id">{{ server.name }}</option>
|
<option [value]="server.id">{{ server.name }}</option>
|
||||||
@@ -355,7 +478,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="plugin-store__capability-row">
|
<label class="flex items-center gap-2 rounded-lg border border-border bg-background/50 px-3 py-2 text-sm">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
[checked]="serverInstallOptional()"
|
[checked]="serverInstallOptional()"
|
||||||
@@ -365,15 +488,15 @@
|
|||||||
<span>Optional for server members</span>
|
<span>Optional for server members</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="plugin-store__capability-list">
|
<div class="grid gap-2">
|
||||||
<div class="plugin-store__capability-list-header">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<h3>Capabilities</h3>
|
<h3 class="text-sm font-semibold">Capabilities</h3>
|
||||||
<span>{{ dialog.manifest.capabilities?.length ?? 0 }}</span>
|
<span class="rounded-full bg-secondary px-2 py-0.5 text-xs text-muted-foreground">{{ dialog.manifest.capabilities?.length ?? 0 }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ((dialog.manifest.capabilities?.length ?? 0) > 0) {
|
@if ((dialog.manifest.capabilities?.length ?? 0) > 0) {
|
||||||
@for (capability of dialog.manifest.capabilities; track trackInstallCapability($index, capability)) {
|
@for (capability of dialog.manifest.capabilities; track trackInstallCapability($index, capability)) {
|
||||||
<label class="plugin-store__capability-row">
|
<label class="flex items-center gap-2 rounded-lg border border-border bg-background/50 px-3 py-2 text-sm">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
[checked]="selectedCapabilityIds().has(capability)"
|
[checked]="selectedCapabilityIds().has(capability)"
|
||||||
@@ -384,21 +507,21 @@
|
|||||||
</label>
|
</label>
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
<p class="plugin-store__muted-text">This plugin requests no capabilities.</p>
|
<p class="text-sm text-muted-foreground">This plugin requests no capabilities.</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (serverInstallError()) {
|
@if (serverInstallError()) {
|
||||||
<p class="plugin-store__error-banner">{{ serverInstallError() }}</p>
|
<p class="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">{{ serverInstallError() }}</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="plugin-store__install-actions">
|
<footer class="flex justify-end gap-2 border-t border-border p-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="closeServerInstallDialog()"
|
(click)="closeServerInstallDialog()"
|
||||||
[disabled]="serverInstallBusy()"
|
[disabled]="serverInstallBusy()"
|
||||||
class="plugin-store__secondary-button"
|
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-border bg-card px-3 py-1.5 text-sm font-semibold transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-55"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -406,11 +529,12 @@
|
|||||||
type="button"
|
type="button"
|
||||||
(click)="confirmServerInstall()"
|
(click)="confirmServerInstall()"
|
||||||
[disabled]="serverInstallBusy() || !dialog.selectedServerId"
|
[disabled]="serverInstallBusy() || !dialog.selectedServerId"
|
||||||
class="plugin-store__primary-button"
|
class="inline-flex min-h-8 items-center justify-center gap-2 rounded-lg border border-primary bg-primary px-3 py-1.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-55"
|
||||||
>
|
>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucidePlus"
|
name="lucidePlus"
|
||||||
[class.is-spinning]="serverInstallBusy()"
|
class="h-4 w-4"
|
||||||
|
[class.animate-spin]="serverInstallBusy()"
|
||||||
/>
|
/>
|
||||||
Install and Activate
|
Install and Activate
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,607 +0,0 @@
|
|||||||
:host {
|
|
||||||
display: block;
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store {
|
|
||||||
min-height: calc(100vh - 2.5rem);
|
|
||||||
padding: 1rem clamp(0.75rem, 1.6vw, 1.5rem);
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
background: hsl(var(--background));
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__topbar,
|
|
||||||
.plugin-store__title-row,
|
|
||||||
.plugin-store__top-actions,
|
|
||||||
.plugin-store__source-form,
|
|
||||||
.plugin-store__toolbar,
|
|
||||||
.plugin-store__source-row,
|
|
||||||
.plugin-store__source-filter,
|
|
||||||
.plugin-store__toggle-button,
|
|
||||||
.plugin-card__actions,
|
|
||||||
.plugin-card__meta,
|
|
||||||
.plugin-store__panel-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__topbar {
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
padding-bottom: 0.875rem;
|
|
||||||
border-bottom: 1px solid hsl(var(--border));
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__title-row,
|
|
||||||
.plugin-store__top-actions,
|
|
||||||
.plugin-store__source-form,
|
|
||||||
.plugin-store__toolbar,
|
|
||||||
.plugin-card__actions,
|
|
||||||
.plugin-card__meta {
|
|
||||||
gap: 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__title-copy,
|
|
||||||
.plugin-store__title-copy h1,
|
|
||||||
.plugin-store__title-copy p,
|
|
||||||
.plugin-store__source-filter span,
|
|
||||||
.plugin-store__count,
|
|
||||||
.plugin-card__header h2,
|
|
||||||
.plugin-card__header p,
|
|
||||||
.plugin-card__meta span,
|
|
||||||
.plugin-store__readme-header h2,
|
|
||||||
.plugin-store__readme-header span {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__title-copy h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.35rem;
|
|
||||||
line-height: 1.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__title-copy p,
|
|
||||||
.plugin-store__count,
|
|
||||||
.plugin-card__header p,
|
|
||||||
.plugin-card__description,
|
|
||||||
.plugin-card__meta,
|
|
||||||
.plugin-store__source-error,
|
|
||||||
.plugin-store__error-text,
|
|
||||||
.plugin-store__readme-header span {
|
|
||||||
margin: 0;
|
|
||||||
color: hsl(var(--muted-foreground));
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__brand-icon,
|
|
||||||
.plugin-store__icon-button {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__brand-icon {
|
|
||||||
width: 2.25rem;
|
|
||||||
height: 2.25rem;
|
|
||||||
color: hsl(var(--primary));
|
|
||||||
background: hsl(var(--primary) / 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__icon-button {
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
border: 1px solid hsl(var(--border));
|
|
||||||
color: hsl(var(--muted-foreground));
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__icon-button:hover,
|
|
||||||
.plugin-store__secondary-button:hover,
|
|
||||||
.plugin-store__text-button:hover,
|
|
||||||
.plugin-store__source-filter:hover,
|
|
||||||
.plugin-store__toggle-button:hover {
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
background: hsl(var(--secondary));
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__icon-button--danger:hover {
|
|
||||||
color: hsl(var(--destructive));
|
|
||||||
background: hsl(var(--destructive) / 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__primary-button,
|
|
||||||
.plugin-store__secondary-button,
|
|
||||||
.plugin-store__text-button {
|
|
||||||
display: inline-flex;
|
|
||||||
min-height: 2rem;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.45rem;
|
|
||||||
border: 1px solid hsl(var(--border));
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 0.4rem 0.7rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
background: hsl(var(--card));
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__primary-button {
|
|
||||||
border-color: hsl(var(--primary));
|
|
||||||
color: hsl(var(--primary-foreground));
|
|
||||||
background: hsl(var(--primary));
|
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
ng-icon {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__brand-icon ng-icon,
|
|
||||||
.plugin-store__empty ng-icon,
|
|
||||||
.plugin-card__media ng-icon {
|
|
||||||
width: 1.4rem;
|
|
||||||
height: 1.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__source-strip {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.75rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__source-form {
|
|
||||||
min-width: 0;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__input-shell {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__input-shell ng-icon {
|
|
||||||
position: absolute;
|
|
||||||
left: 0.7rem;
|
|
||||||
color: hsl(var(--muted-foreground));
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__input-shell input {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 2.2rem;
|
|
||||||
border: 1px solid hsl(var(--border));
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 0.45rem 0.7rem;
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
background: hsl(var(--secondary));
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__search input {
|
|
||||||
padding-left: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(13rem, 17rem) minmax(0, 1fr) minmax(18rem, 24rem);
|
|
||||||
gap: 0.875rem;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__rail {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.75rem;
|
|
||||||
position: sticky;
|
|
||||||
top: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__panel,
|
|
||||||
.plugin-store__catalog,
|
|
||||||
.plugin-store__readme,
|
|
||||||
.plugin-card,
|
|
||||||
.plugin-store__empty {
|
|
||||||
min-width: 0;
|
|
||||||
border: 1px solid hsl(var(--border));
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
background: hsl(var(--card));
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__panel,
|
|
||||||
.plugin-store__catalog,
|
|
||||||
.plugin-store__readme {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__panel {
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding: 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__panel-header {
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__panel-header h2,
|
|
||||||
.plugin-store__readme-header h2,
|
|
||||||
.plugin-card__header h2 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__panel-header h2 {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__panel-header span,
|
|
||||||
.plugin-store__source-filter strong,
|
|
||||||
.plugin-store__toggle-button strong,
|
|
||||||
.plugin-card__meta span {
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0.12rem 0.45rem;
|
|
||||||
color: hsl(var(--muted-foreground));
|
|
||||||
background: hsl(var(--secondary));
|
|
||||||
font-size: 0.72rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__source-row {
|
|
||||||
gap: 0.375rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__source-filter,
|
|
||||||
.plugin-store__toggle-button {
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.5rem;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0.45rem;
|
|
||||||
padding: 0.45rem 0.55rem;
|
|
||||||
color: hsl(var(--muted-foreground));
|
|
||||||
background: transparent;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__source-filter.is-active,
|
|
||||||
.plugin-store__toggle-button.is-active {
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
background: hsl(var(--secondary));
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__source-error,
|
|
||||||
.plugin-store__error-text,
|
|
||||||
.plugin-store__error-banner {
|
|
||||||
color: hsl(var(--destructive));
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__error-banner {
|
|
||||||
margin: 0;
|
|
||||||
border: 1px solid hsl(var(--destructive) / 0.3);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 0.55rem 0.7rem;
|
|
||||||
background: hsl(var(--destructive) / 0.1);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__toolbar {
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__search {
|
|
||||||
max-width: 30rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(21rem, 1fr));
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-card {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 5.5rem minmax(0, 1fr);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-card__media {
|
|
||||||
display: grid;
|
|
||||||
min-height: 100%;
|
|
||||||
place-items: center;
|
|
||||||
color: hsl(var(--muted-foreground));
|
|
||||||
background: hsl(var(--secondary));
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-card__media img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-card__body {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.55rem;
|
|
||||||
padding: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-card__header {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-card__header h2,
|
|
||||||
.plugin-store__readme-header h2 {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-card__badge {
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0.18rem 0.45rem;
|
|
||||||
color: hsl(var(--primary));
|
|
||||||
background: hsl(var(--primary) / 0.1);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-card__badge--installed {
|
|
||||||
color: rgb(5 150 105);
|
|
||||||
background: rgb(5 150 105 / 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-card__description {
|
|
||||||
display: -webkit-box;
|
|
||||||
min-height: 2.45rem;
|
|
||||||
overflow: hidden;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-card__meta,
|
|
||||||
.plugin-card__actions {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-card__primary-action--danger {
|
|
||||||
border-color: hsl(var(--destructive) / 0.35);
|
|
||||||
color: hsl(var(--destructive));
|
|
||||||
background: hsl(var(--destructive) / 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__readme {
|
|
||||||
position: sticky;
|
|
||||||
top: 0.75rem;
|
|
||||||
max-height: calc(100vh - 6rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__readme-header {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__readme-header p {
|
|
||||||
margin: 0 0 0.25rem;
|
|
||||||
color: hsl(var(--primary));
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__readme pre {
|
|
||||||
max-height: calc(100vh - 14rem);
|
|
||||||
overflow: auto;
|
|
||||||
margin: 0;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
background: hsl(var(--secondary) / 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__modal-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 80;
|
|
||||||
background: rgb(0 0 0 / 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__install-modal {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 81;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
display: flex;
|
|
||||||
width: min(34rem, calc(100vw - 2rem));
|
|
||||||
max-height: min(42rem, calc(100vh - 2rem));
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
border: 1px solid hsl(var(--border));
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
background: hsl(var(--card));
|
|
||||||
box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__install-header,
|
|
||||||
.plugin-store__install-actions,
|
|
||||||
.plugin-store__capability-list-header,
|
|
||||||
.plugin-store__capability-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__install-header {
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
border-bottom: 1px solid hsl(var(--border));
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__install-header p,
|
|
||||||
.plugin-store__install-header h2,
|
|
||||||
.plugin-store__capability-list-header h3,
|
|
||||||
.plugin-store__muted-text {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__install-header p,
|
|
||||||
.plugin-store__field span,
|
|
||||||
.plugin-store__capability-list-header span,
|
|
||||||
.plugin-store__muted-text {
|
|
||||||
color: hsl(var(--muted-foreground));
|
|
||||||
font-size: 0.78rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__install-header h2 {
|
|
||||||
margin-top: 0.2rem;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__install-body {
|
|
||||||
display: grid;
|
|
||||||
min-height: 0;
|
|
||||||
gap: 1rem;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__field {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__field select {
|
|
||||||
min-height: 2.25rem;
|
|
||||||
border: 1px solid hsl(var(--border));
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 0.45rem 0.65rem;
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
background: hsl(var(--secondary));
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__capability-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__capability-list-header {
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__capability-list-header h3 {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__capability-row {
|
|
||||||
gap: 0.55rem;
|
|
||||||
border: 1px solid hsl(var(--border));
|
|
||||||
border-radius: 0.45rem;
|
|
||||||
padding: 0.5rem 0.6rem;
|
|
||||||
background: hsl(var(--background) / 0.5);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__capability-row input {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__install-actions {
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.5rem;
|
|
||||||
border-top: 1px solid hsl(var(--border));
|
|
||||||
padding: 0.85rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__empty {
|
|
||||||
display: grid;
|
|
||||||
min-height: 14rem;
|
|
||||||
place-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__empty h2,
|
|
||||||
.plugin-store__empty p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-spinning {
|
|
||||||
animation: plugin-store-spin 0.9s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes plugin-store-spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
|
||||||
.plugin-store__layout {
|
|
||||||
grid-template-columns: minmax(12rem, 16rem) minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__readme {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
position: static;
|
|
||||||
max-height: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 820px) {
|
|
||||||
.plugin-store__topbar,
|
|
||||||
.plugin-store__source-strip,
|
|
||||||
.plugin-store__toolbar,
|
|
||||||
.plugin-store__layout {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__topbar,
|
|
||||||
.plugin-store__source-form,
|
|
||||||
.plugin-store__toolbar {
|
|
||||||
align-items: stretch;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__top-actions,
|
|
||||||
.plugin-card__actions {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__rail {
|
|
||||||
position: static;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-store__search {
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
|
||||||
.plugin-card {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plugin-card__media {
|
|
||||||
min-height: 4.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
DestroyRef,
|
DestroyRef,
|
||||||
OnInit,
|
OnInit,
|
||||||
computed,
|
computed,
|
||||||
|
effect,
|
||||||
inject,
|
inject,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
import { ExternalLinkService } from '../../../../core/platform';
|
import { ExternalLinkService } from '../../../../core/platform';
|
||||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||||
|
import { ChatMessageMarkdownComponent } from '../../../chat';
|
||||||
import { resolveLegacyRole, resolveRoomPermission } from '../../../access-control';
|
import { resolveLegacyRole, resolveRoomPermission } from '../../../access-control';
|
||||||
import type {
|
import type {
|
||||||
PluginCapabilityId,
|
PluginCapabilityId,
|
||||||
@@ -36,7 +38,12 @@ import { selectCurrentRoom, selectSavedRooms } from '../../../../store/rooms/roo
|
|||||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||||
import { PluginCapabilityService } from '../../application/services/plugin-capability.service';
|
import { PluginCapabilityService } from '../../application/services/plugin-capability.service';
|
||||||
import { PluginStoreService } from '../../application/services/plugin-store.service';
|
import { PluginStoreService } from '../../application/services/plugin-store.service';
|
||||||
import type { PluginStoreEntry, PluginStoreReadme } from '../../domain/models/plugin-store.models';
|
import type {
|
||||||
|
InstalledStorePlugin,
|
||||||
|
PluginStoreEntry,
|
||||||
|
PluginStoreInstallState,
|
||||||
|
PluginStoreReadme
|
||||||
|
} from '../../domain/models/plugin-store.models';
|
||||||
|
|
||||||
interface ServerPluginInstallDialog {
|
interface ServerPluginInstallDialog {
|
||||||
manifest: TojuPluginManifest;
|
manifest: TojuPluginManifest;
|
||||||
@@ -50,6 +57,7 @@ interface ServerPluginInstallDialog {
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
|
ChatMessageMarkdownComponent,
|
||||||
NgIcon
|
NgIcon
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
@@ -66,7 +74,6 @@ interface ServerPluginInstallDialog {
|
|||||||
lucideX
|
lucideX
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
styleUrl: './plugin-store.component.scss',
|
|
||||||
templateUrl: './plugin-store.component.html'
|
templateUrl: './plugin-store.component.html'
|
||||||
})
|
})
|
||||||
export class PluginStoreComponent implements OnInit {
|
export class PluginStoreComponent implements OnInit {
|
||||||
@@ -124,15 +131,23 @@ export class PluginStoreComponent implements OnInit {
|
|||||||
|
|
||||||
return readme ? this.store.availablePlugins().find((plugin) => plugin.id === readme.pluginId) ?? null : null;
|
return readme ? this.store.availablePlugins().find((plugin) => plugin.id === readme.pluginId) ?? null : null;
|
||||||
});
|
});
|
||||||
|
readonly selectedStoreServer = computed(() => {
|
||||||
|
const selectedServerId = this.selectedStoreServerId();
|
||||||
|
|
||||||
|
return selectedServerId ? this.manageableServers().find((server) => server.id === selectedServerId) ?? null : null;
|
||||||
|
});
|
||||||
|
|
||||||
newSourceUrl = '';
|
newSourceUrl = '';
|
||||||
readonly searchTerm = signal('');
|
readonly searchTerm = signal('');
|
||||||
readonly selectedSourceUrl = signal<string | null>(null);
|
readonly selectedSourceUrl = signal<string | null>(null);
|
||||||
|
readonly selectedStoreServerId = signal<string | null>(null);
|
||||||
|
readonly selectedServerInstalledPlugins = signal<InstalledStorePlugin[]>([]);
|
||||||
readonly showInstalledOnly = signal(false);
|
readonly showInstalledOnly = signal(false);
|
||||||
readonly sourceError = signal<string | null>(null);
|
readonly sourceError = signal<string | null>(null);
|
||||||
readonly actionError = signal<string | null>(null);
|
readonly actionError = signal<string | null>(null);
|
||||||
readonly actionBusyPluginId = signal<string | null>(null);
|
readonly actionBusyPluginId = signal<string | null>(null);
|
||||||
readonly readme = signal<PluginStoreReadme | null>(null);
|
readonly readme = signal<PluginStoreReadme | null>(null);
|
||||||
|
readonly readmeRawMode = signal(false);
|
||||||
readonly readmeError = signal<string | null>(null);
|
readonly readmeError = signal<string | null>(null);
|
||||||
readonly readmeLoadingPluginId = signal<string | null>(null);
|
readonly readmeLoadingPluginId = signal<string | null>(null);
|
||||||
readonly serverInstallDialog = signal<ServerPluginInstallDialog | null>(null);
|
readonly serverInstallDialog = signal<ServerPluginInstallDialog | null>(null);
|
||||||
@@ -147,8 +162,27 @@ export class PluginStoreComponent implements OnInit {
|
|||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly settingsModal = inject(SettingsModalService);
|
private readonly settingsModal = inject(SettingsModalService);
|
||||||
|
private selectedServerLoadVersion = 0;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const servers = this.manageableServers();
|
||||||
|
const selectedServerId = this.selectedStoreServerId();
|
||||||
|
|
||||||
|
if (servers.length === 0) {
|
||||||
|
this.selectedStoreServerId.set(null);
|
||||||
|
this.selectedServerInstalledPlugins.set([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedServerId || !servers.some((server) => server.id === selectedServerId)) {
|
||||||
|
this.selectedStoreServerId.set(servers[0].id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.loadSelectedServerInstalledPlugins(selectedServerId);
|
||||||
|
});
|
||||||
|
|
||||||
this.destroyRef.onDestroy(() => {
|
this.destroyRef.onDestroy(() => {
|
||||||
this.destroyed = true;
|
this.destroyed = true;
|
||||||
});
|
});
|
||||||
@@ -219,14 +253,17 @@ export class PluginStoreComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async runPrimaryAction(plugin: PluginStoreEntry): Promise<void> {
|
async runPrimaryAction(plugin: PluginStoreEntry): Promise<void> {
|
||||||
const action = this.store.getActionLabel(plugin);
|
const action = this.getPrimaryActionLabel(plugin);
|
||||||
|
|
||||||
this.actionError.set(null);
|
this.actionError.set(null);
|
||||||
this.actionBusyPluginId.set(plugin.id);
|
this.actionBusyPluginId.set(plugin.id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (action === 'Uninstall' || action === 'Remove from Server') {
|
if (action === 'Uninstall') {
|
||||||
await this.store.uninstallPlugin(plugin.id, plugin.scope);
|
await this.store.uninstallPlugin(plugin.id, plugin.scope);
|
||||||
|
} else if (action === 'Remove from Server') {
|
||||||
|
await this.store.uninstallPlugin(plugin.id, plugin.scope, { serverId: this.selectedStoreServerId() ?? undefined });
|
||||||
|
await this.refreshSelectedServerInstalledPlugins();
|
||||||
} else if (this.isServerScopedPlugin(plugin)) {
|
} else if (this.isServerScopedPlugin(plugin)) {
|
||||||
await this.openServerInstallDialog(plugin);
|
await this.openServerInstallDialog(plugin);
|
||||||
} else {
|
} else {
|
||||||
@@ -257,6 +294,7 @@ export class PluginStoreComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.readme.set(readme);
|
this.readme.set(readme);
|
||||||
|
this.readmeRawMode.set(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.destroyed) {
|
if (this.destroyed) {
|
||||||
return;
|
return;
|
||||||
@@ -272,16 +310,21 @@ export class PluginStoreComponent implements OnInit {
|
|||||||
|
|
||||||
closeReadme(): void {
|
closeReadme(): void {
|
||||||
this.readme.set(null);
|
this.readme.set(null);
|
||||||
|
this.readmeRawMode.set(false);
|
||||||
this.readmeError.set(null);
|
this.readmeError.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleReadmeRawMode(): void {
|
||||||
|
this.readmeRawMode.update((value) => !value);
|
||||||
|
}
|
||||||
|
|
||||||
async openServerInstallDialog(plugin: PluginStoreEntry): Promise<void> {
|
async openServerInstallDialog(plugin: PluginStoreEntry): Promise<void> {
|
||||||
this.actionBusyPluginId.set(plugin.id);
|
this.actionBusyPluginId.set(plugin.id);
|
||||||
this.serverInstallError.set(null);
|
this.serverInstallError.set(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const manifest = await this.store.loadInstallManifest(plugin);
|
const manifest = await this.store.loadInstallManifest(plugin);
|
||||||
const selectedServerId = this.defaultServerInstallTargetId();
|
const selectedServerId = this.selectedStoreServerId();
|
||||||
|
|
||||||
if (!selectedServerId) {
|
if (!selectedServerId) {
|
||||||
throw new Error('You need owner or Manage Server access on a chat server before installing server plugins');
|
throw new Error('You need owner or Manage Server access on a chat server before installing server plugins');
|
||||||
@@ -315,9 +358,14 @@ export class PluginStoreComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selectServerInstallTarget(serverId: string): void {
|
selectServerInstallTarget(serverId: string): void {
|
||||||
|
this.selectedStoreServerId.set(serverId);
|
||||||
this.serverInstallDialog.update((dialog) => dialog ? { ...dialog, selectedServerId: serverId } : dialog);
|
this.serverInstallDialog.update((dialog) => dialog ? { ...dialog, selectedServerId: serverId } : dialog);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectStoreServer(serverId: string): void {
|
||||||
|
this.selectedStoreServerId.set(serverId);
|
||||||
|
}
|
||||||
|
|
||||||
toggleInstallCapability(capability: PluginCapabilityId, checked: boolean): void {
|
toggleInstallCapability(capability: PluginCapabilityId, checked: boolean): void {
|
||||||
this.selectedCapabilityIds.update((capabilities) => {
|
this.selectedCapabilityIds.update((capabilities) => {
|
||||||
const nextCapabilities = new Set(capabilities);
|
const nextCapabilities = new Set(capabilities);
|
||||||
@@ -358,6 +406,8 @@ export class PluginStoreComponent implements OnInit {
|
|||||||
serverId: dialog.selectedServerId
|
serverId: dialog.selectedServerId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.loadSelectedServerInstalledPlugins(dialog.selectedServerId);
|
||||||
|
|
||||||
if (this.destroyed) {
|
if (this.destroyed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -414,7 +464,7 @@ export class PluginStoreComponent implements OnInit {
|
|||||||
isPrimaryActionDisabled(plugin: PluginStoreEntry): boolean {
|
isPrimaryActionDisabled(plugin: PluginStoreEntry): boolean {
|
||||||
return this.isPluginBusy(plugin)
|
return this.isPluginBusy(plugin)
|
||||||
|| !this.canRunPrimaryAction(plugin)
|
|| !this.canRunPrimaryAction(plugin)
|
||||||
|| (!plugin.installUrl && this.store.getInstallState(plugin) !== 'installed');
|
|| (!plugin.installUrl && this.getPluginInstallState(plugin) !== 'installed');
|
||||||
}
|
}
|
||||||
|
|
||||||
canRunPrimaryAction(plugin: PluginStoreEntry): boolean {
|
canRunPrimaryAction(plugin: PluginStoreEntry): boolean {
|
||||||
@@ -425,8 +475,39 @@ export class PluginStoreComponent implements OnInit {
|
|||||||
return this.manageableServers().length > 0;
|
return this.manageableServers().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getPluginInstallState(plugin: PluginStoreEntry): PluginStoreInstallState {
|
||||||
|
if (!this.isServerScopedPlugin(plugin)) {
|
||||||
|
return this.store.getInstallState(plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
const installedPlugin = this.selectedServerInstalledPlugins()
|
||||||
|
.find((candidate) => candidate.manifest.id === plugin.id);
|
||||||
|
|
||||||
|
if (!installedPlugin) {
|
||||||
|
return 'notInstalled';
|
||||||
|
}
|
||||||
|
|
||||||
|
return comparePluginVersions(plugin.version, installedPlugin.manifest.version) > 0
|
||||||
|
? 'updateAvailable'
|
||||||
|
: 'installed';
|
||||||
|
}
|
||||||
|
|
||||||
|
getPrimaryActionLabel(plugin: PluginStoreEntry): string {
|
||||||
|
if (!this.isServerScopedPlugin(plugin)) {
|
||||||
|
return this.store.getActionLabel(plugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = this.getPluginInstallState(plugin);
|
||||||
|
|
||||||
|
if (state === 'updateAvailable') {
|
||||||
|
return 'Update Server';
|
||||||
|
}
|
||||||
|
|
||||||
|
return state === 'installed' ? 'Remove from Server' : 'Install to Server';
|
||||||
|
}
|
||||||
|
|
||||||
primaryActionIcon(plugin: PluginStoreEntry): string {
|
primaryActionIcon(plugin: PluginStoreEntry): string {
|
||||||
const action = this.store.getActionLabel(plugin);
|
const action = this.getPrimaryActionLabel(plugin);
|
||||||
|
|
||||||
if (action === 'Uninstall') {
|
if (action === 'Uninstall') {
|
||||||
return 'lucideTrash2';
|
return 'lucideTrash2';
|
||||||
@@ -466,7 +547,7 @@ export class PluginStoreComponent implements OnInit {
|
|||||||
serverInstallButtonTitle(plugin: PluginStoreEntry): string {
|
serverInstallButtonTitle(plugin: PluginStoreEntry): string {
|
||||||
return this.isServerScopedPlugin(plugin) && this.manageableServers().length === 0
|
return this.isServerScopedPlugin(plugin) && this.manageableServers().length === 0
|
||||||
? 'Requires owner or Manage Server access on a chat server'
|
? 'Requires owner or Manage Server access on a chat server'
|
||||||
: this.store.getActionLabel(plugin);
|
: this.getPrimaryActionLabel(plugin);
|
||||||
}
|
}
|
||||||
|
|
||||||
private matchesSearch(plugin: PluginStoreEntry, searchTerm: string): boolean {
|
private matchesSearch(plugin: PluginStoreEntry, searchTerm: string): boolean {
|
||||||
@@ -490,13 +571,47 @@ export class PluginStoreComponent implements OnInit {
|
|||||||
return '/search';
|
return '/search';
|
||||||
}
|
}
|
||||||
|
|
||||||
private defaultServerInstallTargetId(): string | null {
|
|
||||||
const currentRoomId = this.currentRoom()?.id ?? null;
|
|
||||||
|
|
||||||
return this.manageableServers().find((room) => room.id === currentRoomId)?.id ?? this.manageableServers()[0]?.id ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private canManageServerPlugins(room: Room, user: User): boolean {
|
private canManageServerPlugins(room: Room, user: User): boolean {
|
||||||
return resolveLegacyRole(room, user) === 'host' || resolveRoomPermission(room, user, 'manageServer');
|
return resolveLegacyRole(room, user) === 'host' || resolveRoomPermission(room, user, 'manageServer');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async refreshSelectedServerInstalledPlugins(): Promise<void> {
|
||||||
|
const selectedServerId = this.selectedStoreServerId();
|
||||||
|
|
||||||
|
if (selectedServerId) {
|
||||||
|
await this.loadSelectedServerInstalledPlugins(selectedServerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadSelectedServerInstalledPlugins(serverId: string): Promise<void> {
|
||||||
|
const loadVersion = ++this.selectedServerLoadVersion;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const installedPlugins = await this.store.loadInstalledPluginsForServer(serverId);
|
||||||
|
|
||||||
|
if (!this.destroyed && loadVersion === this.selectedServerLoadVersion && this.selectedStoreServerId() === serverId) {
|
||||||
|
this.selectedServerInstalledPlugins.set(installedPlugins);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!this.destroyed && loadVersion === this.selectedServerLoadVersion && this.selectedStoreServerId() === serverId) {
|
||||||
|
this.selectedServerInstalledPlugins.set([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function comparePluginVersions(leftVersion: string, rightVersion: string): number {
|
||||||
|
const leftParts = leftVersion.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0);
|
||||||
|
const rightParts = rightVersion.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0);
|
||||||
|
const length = Math.max(leftParts.length, rightParts.length);
|
||||||
|
|
||||||
|
for (let index = 0; index < length; index += 1) {
|
||||||
|
const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
|
||||||
|
|
||||||
|
if (difference !== 0) {
|
||||||
|
return difference;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './application/services/plugin-capability.service';
|
export * from './application/services/plugin-capability.service';
|
||||||
|
export * from './application/services/plugin-bootstrap.service';
|
||||||
export * from './application/services/plugin-client-api.service';
|
export * from './application/services/plugin-client-api.service';
|
||||||
export * from './application/services/plugin-desktop-state.service';
|
export * from './application/services/plugin-desktop-state.service';
|
||||||
export * from './application/services/plugin-host.service';
|
export * from './application/services/plugin-host.service';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<aside class="flex h-full min-h-0 flex-col bg-card">
|
<aside class="flex h-full min-h-0 flex-col bg-card">
|
||||||
<div
|
<div
|
||||||
appThemeNode="roomPanelHeader"
|
appThemeNode="roomPanelHeader"
|
||||||
class="border-b border-border px-3 py-3"
|
class="shrink-0 border-b border-border px-3 py-3"
|
||||||
>
|
>
|
||||||
@if (panelMode() === 'channels') {
|
@if (panelMode() === 'channels') {
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -252,7 +252,10 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
@if (pluginChannelSections().length > 0 || pluginSidePanels().length > 0) {
|
@if (pluginChannelSections().length > 0 || pluginSidePanels().length > 0) {
|
||||||
<section class="border-t border-border px-2 py-3" data-testid="plugin-room-side-panel">
|
<section
|
||||||
|
class="border-t border-border px-2 py-3"
|
||||||
|
data-testid="plugin-room-side-panel"
|
||||||
|
>
|
||||||
<div class="mb-2 px-1">
|
<div class="mb-2 px-1">
|
||||||
<h4 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Plugins</h4>
|
<h4 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Plugins</h4>
|
||||||
</div>
|
</div>
|
||||||
@@ -262,7 +265,8 @@
|
|||||||
@for (record of pluginChannelSections(); track record.id) {
|
@for (record of pluginChannelSections(); track record.id) {
|
||||||
<button
|
<button
|
||||||
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-foreground/70 transition-colors hover:bg-secondary/60 hover:text-foreground"
|
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-foreground/70 transition-colors hover:bg-secondary/60 hover:text-foreground"
|
||||||
[title]="record.pluginId">
|
[title]="record.pluginId"
|
||||||
|
>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
[name]="record.contribution.type === 'video' ? 'lucideVideo' : 'lucideHash'"
|
[name]="record.contribution.type === 'video' ? 'lucideVideo' : 'lucideHash'"
|
||||||
class="h-4 w-4 text-muted-foreground"
|
class="h-4 w-4 text-muted-foreground"
|
||||||
@@ -531,7 +535,7 @@
|
|||||||
<!-- Voice controls pinned to sidebar bottom (hidden when floating controls visible) -->
|
<!-- Voice controls pinned to sidebar bottom (hidden when floating controls visible) -->
|
||||||
@if (panelMode() === 'channels' && showVoiceControls() && voiceEnabled()) {
|
@if (panelMode() === 'channels' && showVoiceControls() && voiceEnabled()) {
|
||||||
@if (localUserHasDesync()) {
|
@if (localUserHasDesync()) {
|
||||||
<div class="mx-2 mb-1 flex items-center gap-2 rounded-md bg-amber-500/15 px-3 py-2 text-xs text-amber-400">
|
<div class="mx-2 mb-1 flex shrink-0 items-center gap-2 rounded-md bg-amber-500/15 px-3 py-2 text-xs text-amber-400">
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideAlertTriangle"
|
name="lucideAlertTriangle"
|
||||||
class="w-4 h-4 shrink-0"
|
class="w-4 h-4 shrink-0"
|
||||||
@@ -540,7 +544,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div
|
<div
|
||||||
class="border-t border-border px-2 py-3"
|
class="shrink-0 border-t border-border px-2 py-3"
|
||||||
[class.invisible]="showFloatingControls()"
|
[class.invisible]="showFloatingControls()"
|
||||||
>
|
>
|
||||||
<app-voice-controls />
|
<app-voice-controls />
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<div class="space-y-6">
|
||||||
|
<section class="rounded-lg border border-border bg-card/60 p-5">
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-base font-semibold text-foreground">Local HTTP API</h4>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Expose your client to local automation tools and scripts. Authentication is verified against your signaling server,
|
||||||
|
and access is off by default.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="inline-flex items-center rounded-full border border-primary/30 bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
|
||||||
|
{{ statusLabel() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (!isElectron) {
|
||||||
|
<section class="rounded-lg border border-border bg-secondary/30 p-5">
|
||||||
|
<p class="text-sm text-muted-foreground">The local API is only available in the packaged Electron desktop app.</p>
|
||||||
|
</section>
|
||||||
|
} @else {
|
||||||
|
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
|
||||||
|
<div>
|
||||||
|
<h5 class="text-sm font-semibold text-foreground">Server</h5>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Enable to start a local HTTP server. By default it only listens on the loopback interface.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary"
|
||||||
|
[checked]="settings().enabled"
|
||||||
|
[disabled]="busy()"
|
||||||
|
(change)="toggleEnabled($event)"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-foreground">
|
||||||
|
<span class="font-medium">Run local API server</span>
|
||||||
|
<span class="mt-1 block text-xs text-muted-foreground">Start the HTTP server on this machine.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary"
|
||||||
|
[checked]="settings().exposeOnLan"
|
||||||
|
[disabled]="busy() || !settings().enabled"
|
||||||
|
(change)="toggleExposeOnLan($event)"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-foreground">
|
||||||
|
<span class="font-medium">Allow connections from your network</span>
|
||||||
|
<span class="mt-1 block text-xs text-muted-foreground">
|
||||||
|
Bind to all interfaces (0.0.0.0). Other devices on your LAN will be able to reach the API. Only enable this on
|
||||||
|
networks you trust.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="grid gap-3 md:grid-cols-[200px_1fr_auto] md:items-end">
|
||||||
|
<label class="space-y-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">Port</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
[value]="portText()"
|
||||||
|
[disabled]="busy()"
|
||||||
|
(input)="onPortInput($event)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Change the listening port if 17878 is in use. Press save to apply.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
[disabled]="!hasPendingPortChange() || busy()"
|
||||||
|
(click)="savePort()"
|
||||||
|
>
|
||||||
|
Save port
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
|
||||||
|
<div>
|
||||||
|
<h5 class="text-sm font-semibold text-foreground">Authentication</h5>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Bearer tokens are issued only after a username/password is verified against one of the signaling servers below.
|
||||||
|
Add the full URL (including <code>https://</code>) of every signaling server you trust.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
rows="4"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="https://signaling.example.com"
|
||||||
|
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 font-mono text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
[value]="allowedServersText()"
|
||||||
|
[disabled]="busy()"
|
||||||
|
(input)="onAllowedServersInput($event)"
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
[disabled]="!hasPendingAllowedServersChanges() || busy()"
|
||||||
|
(click)="saveAllowedServers()"
|
||||||
|
>
|
||||||
|
Save allowed servers
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
|
||||||
|
<div>
|
||||||
|
<h5 class="text-sm font-semibold text-foreground">Documentation</h5>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Browse the API in a privacy-respecting locally hosted Scalar reference. No telemetry, no AI, no remote network calls.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary"
|
||||||
|
[checked]="settings().scalarEnabled"
|
||||||
|
[disabled]="busy() || !settings().enabled"
|
||||||
|
(change)="toggleScalar($event)"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-foreground">
|
||||||
|
<span class="font-medium">Serve Scalar documentation at <code>/docs</code></span>
|
||||||
|
<span class="mt-1 block text-xs text-muted-foreground">
|
||||||
|
Loads from local app resources only. The OpenAPI document is always available at <code>/api/openapi.json</code>.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center rounded-lg border border-primary/40 bg-primary/10 px-4 py-2 text-sm font-medium text-primary transition-colors hover:bg-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
[disabled]="status().status !== 'running' || !settings().scalarEnabled"
|
||||||
|
(click)="openDocs()"
|
||||||
|
>
|
||||||
|
Open API docs in browser
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
[disabled]="!status().baseUrl"
|
||||||
|
(click)="copyBaseUrl()"
|
||||||
|
>
|
||||||
|
Copy base URL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (status().baseUrl) {
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Listening at <code class="rounded bg-secondary px-2 py-1">{{ status().baseUrl }}</code>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (errorMessage(); as message) {
|
||||||
|
<p class="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">{{ message }}</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||||
|
import type {
|
||||||
|
DesktopSettingsPatch,
|
||||||
|
DesktopSettingsSnapshot,
|
||||||
|
LocalApiSettings,
|
||||||
|
LocalApiSnapshot
|
||||||
|
} from '../../../../core/platform/electron/electron-api.models';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-local-api-settings',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
templateUrl: './local-api-settings.component.html'
|
||||||
|
})
|
||||||
|
export class LocalApiSettingsComponent implements OnInit, OnDestroy {
|
||||||
|
private readonly bridge = inject(ElectronBridgeService);
|
||||||
|
|
||||||
|
readonly isElectron = this.bridge.isAvailable;
|
||||||
|
|
||||||
|
readonly settings = signal<LocalApiSettings>({
|
||||||
|
enabled: false,
|
||||||
|
port: 17878,
|
||||||
|
exposeOnLan: false,
|
||||||
|
scalarEnabled: false,
|
||||||
|
allowedSignalingServers: []
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly status = signal<LocalApiSnapshot>({
|
||||||
|
status: 'stopped',
|
||||||
|
host: null,
|
||||||
|
port: null,
|
||||||
|
baseUrl: null,
|
||||||
|
error: null,
|
||||||
|
exposeOnLan: false,
|
||||||
|
scalarEnabled: false
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly allowedServersText = signal('');
|
||||||
|
readonly hasPendingAllowedServersChanges = signal(false);
|
||||||
|
readonly portText = signal('17878');
|
||||||
|
readonly hasPendingPortChange = signal(false);
|
||||||
|
readonly busy = signal(false);
|
||||||
|
readonly errorMessage = signal<string | null>(null);
|
||||||
|
|
||||||
|
readonly statusLabel = computed(() => {
|
||||||
|
const snapshot = this.status();
|
||||||
|
|
||||||
|
switch (snapshot.status) {
|
||||||
|
case 'running':
|
||||||
|
return `Running at ${snapshot.baseUrl ?? 'unknown'}`;
|
||||||
|
case 'starting':
|
||||||
|
return 'Starting…';
|
||||||
|
case 'error':
|
||||||
|
return `Error: ${snapshot.error ?? 'unknown error'}`;
|
||||||
|
case 'stopped':
|
||||||
|
default:
|
||||||
|
return 'Stopped';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
private statusPollHandle: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
async ngOnInit(): Promise<void> {
|
||||||
|
if (!this.isElectron)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await this.refresh();
|
||||||
|
|
||||||
|
this.statusPollHandle = setInterval(() => {
|
||||||
|
void this.refreshStatus();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.statusPollHandle) {
|
||||||
|
clearInterval(this.statusPollHandle);
|
||||||
|
this.statusPollHandle = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
const api = this.bridge.getApi();
|
||||||
|
|
||||||
|
if (!api)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const desktop: DesktopSettingsSnapshot = await api.getDesktopSettings();
|
||||||
|
|
||||||
|
this.settings.set({
|
||||||
|
enabled: desktop.localApi.enabled,
|
||||||
|
port: desktop.localApi.port,
|
||||||
|
exposeOnLan: desktop.localApi.exposeOnLan,
|
||||||
|
scalarEnabled: desktop.localApi.scalarEnabled,
|
||||||
|
allowedSignalingServers: [...desktop.localApi.allowedSignalingServers]
|
||||||
|
});
|
||||||
|
|
||||||
|
this.allowedServersText.set(desktop.localApi.allowedSignalingServers.join('\n'));
|
||||||
|
this.hasPendingAllowedServersChanges.set(false);
|
||||||
|
this.portText.set(String(desktop.localApi.port));
|
||||||
|
this.hasPendingPortChange.set(false);
|
||||||
|
|
||||||
|
await this.refreshStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshStatus(): Promise<void> {
|
||||||
|
const api = this.bridge.getApi();
|
||||||
|
|
||||||
|
if (!api)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const snapshot = await api.getLocalApiStatus();
|
||||||
|
|
||||||
|
if (snapshot)
|
||||||
|
this.status.set(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleEnabled(event: Event): Promise<void> {
|
||||||
|
const checkbox = event.target as HTMLInputElement;
|
||||||
|
|
||||||
|
await this.savePatch({ enabled: checkbox.checked });
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleExposeOnLan(event: Event): Promise<void> {
|
||||||
|
const checkbox = event.target as HTMLInputElement;
|
||||||
|
|
||||||
|
await this.savePatch({ exposeOnLan: checkbox.checked });
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleScalar(event: Event): Promise<void> {
|
||||||
|
const checkbox = event.target as HTMLInputElement;
|
||||||
|
|
||||||
|
await this.savePatch({ scalarEnabled: checkbox.checked });
|
||||||
|
}
|
||||||
|
|
||||||
|
onPortInput(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
|
||||||
|
this.portText.set(input.value);
|
||||||
|
this.hasPendingPortChange.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async savePort(): Promise<void> {
|
||||||
|
const parsed = Number(this.portText());
|
||||||
|
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
|
||||||
|
this.errorMessage.set('Port must be an integer between 1 and 65535');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.errorMessage.set(null);
|
||||||
|
await this.savePatch({ port: Math.floor(parsed) });
|
||||||
|
this.hasPendingPortChange.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
onAllowedServersInput(event: Event): void {
|
||||||
|
const textarea = event.target as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
this.allowedServersText.set(textarea.value);
|
||||||
|
this.hasPendingAllowedServersChanges.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveAllowedServers(): Promise<void> {
|
||||||
|
const list = this.allowedServersText()
|
||||||
|
.split(/\r?\n/u)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0);
|
||||||
|
|
||||||
|
await this.savePatch({ allowedSignalingServers: list });
|
||||||
|
this.hasPendingAllowedServersChanges.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async openDocs(): Promise<void> {
|
||||||
|
const api = this.bridge.getApi();
|
||||||
|
|
||||||
|
if (!api)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const result = await api.openLocalApiDocs();
|
||||||
|
|
||||||
|
if (result && !result.opened) {
|
||||||
|
this.errorMessage.set(result.reason ?? 'Could not open documentation');
|
||||||
|
} else {
|
||||||
|
this.errorMessage.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async copyBaseUrl(): Promise<void> {
|
||||||
|
const baseUrl = this.status().baseUrl;
|
||||||
|
|
||||||
|
if (!baseUrl)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(baseUrl);
|
||||||
|
} catch {
|
||||||
|
// ignore clipboard errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async savePatch(localApiPatch: Partial<LocalApiSettings>): Promise<void> {
|
||||||
|
if (this.busy())
|
||||||
|
return;
|
||||||
|
|
||||||
|
const api = this.bridge.getApi();
|
||||||
|
|
||||||
|
if (!api)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.busy.set(true);
|
||||||
|
this.errorMessage.set(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const patch: DesktopSettingsPatch = { localApi: localApiPatch };
|
||||||
|
const updated = await api.setDesktopSettings(patch);
|
||||||
|
|
||||||
|
this.settings.set({
|
||||||
|
enabled: updated.localApi.enabled,
|
||||||
|
port: updated.localApi.port,
|
||||||
|
exposeOnLan: updated.localApi.exposeOnLan,
|
||||||
|
scalarEnabled: updated.localApi.scalarEnabled,
|
||||||
|
allowedSignalingServers: [...updated.localApi.allowedSignalingServers]
|
||||||
|
});
|
||||||
|
|
||||||
|
this.portText.set(String(updated.localApi.port));
|
||||||
|
this.allowedServersText.set(updated.localApi.allowedSignalingServers.join('\n'));
|
||||||
|
|
||||||
|
await this.refreshStatus();
|
||||||
|
} catch (error) {
|
||||||
|
this.errorMessage.set((error as Error).message ?? 'Failed to update settings');
|
||||||
|
} finally {
|
||||||
|
this.busy.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -153,6 +153,9 @@
|
|||||||
@case ('updates') {
|
@case ('updates') {
|
||||||
Updates
|
Updates
|
||||||
}
|
}
|
||||||
|
@case ('localApi') {
|
||||||
|
Local API
|
||||||
|
}
|
||||||
@case ('data') {
|
@case ('data') {
|
||||||
Data
|
Data
|
||||||
}
|
}
|
||||||
@@ -203,6 +206,7 @@
|
|||||||
<app-plugin-manager
|
<app-plugin-manager
|
||||||
scope="client"
|
scope="client"
|
||||||
(closed)="navigate('general')"
|
(closed)="navigate('general')"
|
||||||
|
(storeOpened)="closeForExternalNavigation()"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@case ('network') {
|
@case ('network') {
|
||||||
@@ -294,6 +298,9 @@
|
|||||||
@case ('updates') {
|
@case ('updates') {
|
||||||
<app-updates-settings />
|
<app-updates-settings />
|
||||||
}
|
}
|
||||||
|
@case ('localApi') {
|
||||||
|
<app-local-api-settings />
|
||||||
|
}
|
||||||
@case ('data') {
|
@case ('data') {
|
||||||
@defer {
|
@defer {
|
||||||
<app-data-settings />
|
<app-data-settings />
|
||||||
@@ -317,12 +324,14 @@
|
|||||||
<app-plugin-manager
|
<app-plugin-manager
|
||||||
scope="server"
|
scope="server"
|
||||||
(closed)="navigate('server')"
|
(closed)="navigate('server')"
|
||||||
|
(storeOpened)="closeForExternalNavigation()"
|
||||||
/>
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<section class="rounded-lg border border-border bg-card p-5">
|
<section class="rounded-lg border border-border bg-card p-5">
|
||||||
<h4 class="text-sm font-semibold text-foreground">Open this server to manage plugins</h4>
|
<h4 class="text-sm font-semibold text-foreground">Open this server to manage plugins</h4>
|
||||||
<p class="mt-2 text-sm text-muted-foreground">
|
<p class="mt-2 text-sm text-muted-foreground">
|
||||||
Server plugin installs and activation are shown for the currently open chat server. Select or open {{ selectedServer()?.name || 'this server' }} in the app, then return here.
|
Server plugin installs and activation are shown for the currently open chat server. Select or open
|
||||||
|
{{ selectedServer()?.name || 'this server' }} in the app, then return here.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
lucidePalette,
|
lucidePalette,
|
||||||
lucidePackage,
|
lucidePackage,
|
||||||
lucideSettings,
|
lucideSettings,
|
||||||
|
lucideTerminal,
|
||||||
lucideUsers,
|
lucideUsers,
|
||||||
lucideBan,
|
lucideBan,
|
||||||
lucideShield
|
lucideShield
|
||||||
@@ -46,6 +47,7 @@ import { BansSettingsComponent } from './bans-settings/bans-settings.component';
|
|||||||
import { PermissionsSettingsComponent } from './permissions-settings/permissions-settings.component';
|
import { PermissionsSettingsComponent } from './permissions-settings/permissions-settings.component';
|
||||||
import { DebuggingSettingsComponent } from './debugging-settings/debugging-settings.component';
|
import { DebuggingSettingsComponent } from './debugging-settings/debugging-settings.component';
|
||||||
import { UpdatesSettingsComponent } from './updates-settings/updates-settings.component';
|
import { UpdatesSettingsComponent } from './updates-settings/updates-settings.component';
|
||||||
|
import { LocalApiSettingsComponent } from './local-api-settings/local-api-settings.component';
|
||||||
import { DataSettingsComponent } from './data-settings/data-settings.component';
|
import { DataSettingsComponent } from './data-settings/data-settings.component';
|
||||||
import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-licenses';
|
import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-licenses';
|
||||||
import {
|
import {
|
||||||
@@ -67,6 +69,7 @@ import {
|
|||||||
PluginManagerComponent,
|
PluginManagerComponent,
|
||||||
VoiceSettingsComponent,
|
VoiceSettingsComponent,
|
||||||
UpdatesSettingsComponent,
|
UpdatesSettingsComponent,
|
||||||
|
LocalApiSettingsComponent,
|
||||||
DataSettingsComponent,
|
DataSettingsComponent,
|
||||||
DebuggingSettingsComponent,
|
DebuggingSettingsComponent,
|
||||||
ServerSettingsComponent,
|
ServerSettingsComponent,
|
||||||
@@ -86,6 +89,7 @@ import {
|
|||||||
lucidePalette,
|
lucidePalette,
|
||||||
lucidePackage,
|
lucidePackage,
|
||||||
lucideSettings,
|
lucideSettings,
|
||||||
|
lucideTerminal,
|
||||||
lucideUsers,
|
lucideUsers,
|
||||||
lucideBan,
|
lucideBan,
|
||||||
lucideShield
|
lucideShield
|
||||||
@@ -127,6 +131,7 @@ export class SettingsModalComponent {
|
|||||||
{ id: 'notifications', label: 'Notifications', icon: 'lucideBell' },
|
{ id: 'notifications', label: 'Notifications', icon: 'lucideBell' },
|
||||||
{ id: 'voice', label: 'Voice & Audio', icon: 'lucideAudioLines' },
|
{ id: 'voice', label: 'Voice & Audio', icon: 'lucideAudioLines' },
|
||||||
{ id: 'updates', label: 'Updates', icon: 'lucideDownload' },
|
{ id: 'updates', label: 'Updates', icon: 'lucideDownload' },
|
||||||
|
{ id: 'localApi', label: 'Local API', icon: 'lucideTerminal' },
|
||||||
{ id: 'data', label: 'Data', icon: 'lucideDownload' },
|
{ id: 'data', label: 'Data', icon: 'lucideDownload' },
|
||||||
{ id: 'debugging', label: 'Debugging', icon: 'lucideBug' }
|
{ id: 'debugging', label: 'Debugging', icon: 'lucideBug' }
|
||||||
];
|
];
|
||||||
@@ -349,6 +354,12 @@ export class SettingsModalComponent {
|
|||||||
setTimeout(() => this.modal.close(), 200);
|
setTimeout(() => this.modal.close(), 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
closeForExternalNavigation(): void {
|
||||||
|
this.showThirdPartyLicenses.set(false);
|
||||||
|
this.animating.set(false);
|
||||||
|
this.modal.close();
|
||||||
|
}
|
||||||
|
|
||||||
openThirdPartyLicenses(): void {
|
openThirdPartyLicenses(): void {
|
||||||
this.showThirdPartyLicenses.set(true);
|
this.showThirdPartyLicenses.set(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,6 @@
|
|||||||
name="lucidePackage"
|
name="lucidePackage"
|
||||||
class="h-4 w-4 text-muted-foreground"
|
class="h-4 w-4 text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
Plugins
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
|
|||||||
@@ -147,6 +147,10 @@ export interface TojuPluginManifest {
|
|||||||
url?: string;
|
url?: string;
|
||||||
}[];
|
}[];
|
||||||
bugs?: string;
|
bugs?: string;
|
||||||
|
bundle?: {
|
||||||
|
entrypoint?: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
capabilities?: PluginCapabilityId[];
|
capabilities?: PluginCapabilityId[];
|
||||||
changelog?: string;
|
changelog?: string;
|
||||||
compatibility: {
|
compatibility: {
|
||||||
|
|||||||
Reference in New Issue
Block a user