feat: plugins v1.7

This commit is contained in:
2026-04-29 15:24:56 +02:00
parent eabbc08896
commit d261bac0ed
45 changed files with 5621 additions and 867 deletions

View File

@@ -2,7 +2,7 @@
<aside class="flex h-full min-h-0 flex-col bg-card">
<div
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') {
<div class="flex items-center gap-3">
@@ -252,7 +252,10 @@
</section>
@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">
<h4 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Plugins</h4>
</div>
@@ -262,7 +265,8 @@
@for (record of pluginChannelSections(); track record.id) {
<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"
[title]="record.pluginId">
[title]="record.pluginId"
>
<ng-icon
[name]="record.contribution.type === 'video' ? 'lucideVideo' : 'lucideHash'"
class="h-4 w-4 text-muted-foreground"
@@ -531,7 +535,7 @@
<!-- Voice controls pinned to sidebar bottom (hidden when floating controls visible) -->
@if (panelMode() === 'channels' && showVoiceControls() && voiceEnabled()) {
@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
name="lucideAlertTriangle"
class="w-4 h-4 shrink-0"
@@ -540,7 +544,7 @@
</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()"
>
<app-voice-controls />

View File

@@ -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>

View File

@@ -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);
}
}
}

View File

@@ -153,6 +153,9 @@
@case ('updates') {
Updates
}
@case ('localApi') {
Local API
}
@case ('data') {
Data
}
@@ -203,6 +206,7 @@
<app-plugin-manager
scope="client"
(closed)="navigate('general')"
(storeOpened)="closeForExternalNavigation()"
/>
}
@case ('network') {
@@ -294,6 +298,9 @@
@case ('updates') {
<app-updates-settings />
}
@case ('localApi') {
<app-local-api-settings />
}
@case ('data') {
@defer {
<app-data-settings />
@@ -317,12 +324,14 @@
<app-plugin-manager
scope="server"
(closed)="navigate('server')"
(storeOpened)="closeForExternalNavigation()"
/>
} @else {
<section class="rounded-lg border border-border bg-card p-5">
<h4 class="text-sm font-semibold text-foreground">Open this server to manage plugins</h4>
<p class="mt-2 text-sm text-muted-foreground">
Server plugin installs and activation are shown for the currently open chat server. Select or open {{ selectedServer()?.name || 'this server' }} in the app, then return here.
Server plugin installs and activation are shown for the currently open chat server. Select or open
{{ selectedServer()?.name || 'this server' }} in the app, then return here.
</p>
</section>
}

View File

@@ -23,6 +23,7 @@ import {
lucidePalette,
lucidePackage,
lucideSettings,
lucideTerminal,
lucideUsers,
lucideBan,
lucideShield
@@ -46,6 +47,7 @@ import { BansSettingsComponent } from './bans-settings/bans-settings.component';
import { PermissionsSettingsComponent } from './permissions-settings/permissions-settings.component';
import { DebuggingSettingsComponent } from './debugging-settings/debugging-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 { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-licenses';
import {
@@ -67,6 +69,7 @@ import {
PluginManagerComponent,
VoiceSettingsComponent,
UpdatesSettingsComponent,
LocalApiSettingsComponent,
DataSettingsComponent,
DebuggingSettingsComponent,
ServerSettingsComponent,
@@ -86,6 +89,7 @@ import {
lucidePalette,
lucidePackage,
lucideSettings,
lucideTerminal,
lucideUsers,
lucideBan,
lucideShield
@@ -127,6 +131,7 @@ export class SettingsModalComponent {
{ id: 'notifications', label: 'Notifications', icon: 'lucideBell' },
{ id: 'voice', label: 'Voice & Audio', icon: 'lucideAudioLines' },
{ id: 'updates', label: 'Updates', icon: 'lucideDownload' },
{ id: 'localApi', label: 'Local API', icon: 'lucideTerminal' },
{ id: 'data', label: 'Data', icon: 'lucideDownload' },
{ id: 'debugging', label: 'Debugging', icon: 'lucideBug' }
];
@@ -349,6 +354,12 @@ export class SettingsModalComponent {
setTimeout(() => this.modal.close(), 200);
}
closeForExternalNavigation(): void {
this.showThirdPartyLicenses.set(false);
this.animating.set(false);
this.modal.close();
}
openThirdPartyLicenses(): void {
this.showThirdPartyLicenses.set(true);
}

View File

@@ -96,7 +96,6 @@
name="lucidePackage"
class="h-4 w-4 text-muted-foreground"
/>
Plugins
</button>
<div class="relative">