feat: Add TURN server support
All checks were successful
Queue Release Build / prepare (push) Successful in 15s
Deploy Web Apps / deploy (push) Successful in 5m35s
Queue Release Build / build-linux (push) Successful in 24m45s
Queue Release Build / build-windows (push) Successful in 13m52s
Queue Release Build / finalize (push) Successful in 23s

This commit is contained in:
2026-04-18 21:27:04 +02:00
parent 167c45ba8d
commit 44588e8789
60 changed files with 2404 additions and 365 deletions

View File

@@ -0,0 +1,161 @@
<section data-testid="ice-server-settings">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<ng-icon
name="lucideShield"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">ICE Servers (STUN / TURN)</h4>
</div>
<button
type="button"
data-testid="ice-restore-defaults"
(click)="restoreDefaults()"
class="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
>
<ng-icon
name="lucideRotateCcw"
class="w-3.5 h-3.5"
/>
Restore Defaults
</button>
</div>
<p class="text-xs text-muted-foreground mb-3">
ICE servers are used for NAT traversal. STUN discovers your public address; TURN relays traffic when direct connections fail. Higher entries have
priority.
</p>
<!-- ICE Server List -->
<div
class="space-y-2 mb-3"
data-testid="ice-server-list"
>
@for (entry of entries(); track trackEntry($index, entry); let i = $index) {
<div
class="flex items-center gap-3 p-2.5 rounded-lg border transition-colors"
[class.border-blue-500/40]="entry.type === 'turn'"
[class.bg-blue-500/5]="entry.type === 'turn'"
[class.border-border]="entry.type === 'stun'"
[class.bg-secondary/30]="entry.type === 'stun'"
[attr.data-testid]="'ice-entry-' + i"
>
<span
class="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded"
[class.bg-muted]="entry.type === 'stun'"
[class.text-muted-foreground]="entry.type === 'stun'"
[class.bg-blue-500/15]="entry.type === 'turn'"
[class.text-blue-400]="entry.type === 'turn'"
>
{{ entry.type }}
</span>
<div class="flex-1 min-w-0">
<p class="text-sm text-foreground truncate">{{ entry.urls }}</p>
@if (entry.type === 'turn' && entry.username) {
<p class="text-[10px] text-muted-foreground truncate">User: {{ entry.username }}</p>
}
</div>
<div class="flex items-center gap-0.5 flex-shrink-0">
<button
type="button"
(click)="moveUp(i)"
[disabled]="i === 0"
class="grid h-7 w-7 place-items-center rounded-lg transition-colors hover:bg-secondary disabled:opacity-30"
title="Move up (higher priority)"
>
<ng-icon
name="lucideArrowUp"
class="w-3.5 h-3.5 text-muted-foreground"
/>
</button>
<button
type="button"
(click)="moveDown(i)"
[disabled]="i === entries().length - 1"
class="grid h-7 w-7 place-items-center rounded-lg transition-colors hover:bg-secondary disabled:opacity-30"
title="Move down (lower priority)"
>
<ng-icon
name="lucideArrowDown"
class="w-3.5 h-3.5 text-muted-foreground"
/>
</button>
<button
type="button"
(click)="removeEntry(entry.id)"
class="grid h-7 w-7 place-items-center rounded-lg transition-colors hover:bg-destructive/10"
title="Remove"
>
<ng-icon
name="lucideTrash2"
class="w-3.5 h-3.5 text-muted-foreground hover:text-destructive"
/>
</button>
</div>
</div>
}
@if (entries().length === 0) {
<p class="text-xs text-muted-foreground italic py-2">No ICE servers configured. P2P connections may fail across networks.</p>
}
</div>
<!-- Add New ICE Server -->
<div class="border-t border-border pt-3">
<h4 class="text-xs font-medium text-foreground mb-2">Add ICE Server</h4>
<div class="space-y-1.5">
<div class="flex gap-2">
<select
[(ngModel)]="newType"
data-testid="ice-type-select"
class="px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="stun">STUN</option>
<option value="turn">TURN</option>
</select>
<input
type="text"
[(ngModel)]="newUrl"
data-testid="ice-url-input"
[placeholder]="newType === 'stun' ? 'stun:stun.example.com:19302' : 'turn:turn.example.com:3478'"
class="flex-1 px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
@if (newType === 'turn') {
<div class="flex gap-2">
<input
type="text"
[(ngModel)]="newUsername"
data-testid="ice-username-input"
placeholder="Username"
class="flex-1 px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<input
type="password"
[(ngModel)]="newCredential"
data-testid="ice-credential-input"
placeholder="Credential"
class="flex-1 px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
}
<div class="flex justify-end">
<button
type="button"
data-testid="ice-add-button"
(click)="addEntry()"
[disabled]="!newUrl"
class="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:cursor-not-allowed disabled:opacity-50"
>
<ng-icon
name="lucidePlus"
class="w-3.5 h-3.5"
/>
Add Server
</button>
</div>
</div>
@if (addError()) {
<p class="text-xs text-destructive mt-1.5">{{ addError() }}</p>
}
</div>
</section>

View File

@@ -0,0 +1,121 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideShield,
lucidePlus,
lucideTrash2,
lucideArrowUp,
lucideArrowDown,
lucideRotateCcw
} from '@ng-icons/lucide';
import { IceServerSettingsService, IceServerEntry } from '../../../../infrastructure/realtime/ice-server-settings.service';
@Component({
selector: 'app-ice-server-settings',
standalone: true,
host: {
style: 'display: block;'
},
imports: [
CommonModule,
FormsModule,
NgIcon
],
viewProviders: [
provideIcons({
lucideShield,
lucidePlus,
lucideTrash2,
lucideArrowUp,
lucideArrowDown,
lucideRotateCcw
})
],
templateUrl: './ice-server-settings.component.html'
})
export class IceServerSettingsComponent {
private iceSettings = inject(IceServerSettingsService);
entries = this.iceSettings.entries;
addError = signal<string | null>(null);
newType: 'stun' | 'turn' = 'stun';
newUrl = '';
newUsername = '';
newCredential = '';
addEntry(): void {
this.addError.set(null);
const url = this.newUrl.trim();
if (!url) {
this.addError.set('URL is required');
return;
}
const prefix = this.newType === 'stun' ? 'stun:' : 'turn';
if (!url.startsWith(prefix) && !url.startsWith('turns:')) {
this.addError.set(`URL must start with ${this.newType === 'stun' ? 'stun:' : 'turn: or turns:'}`);
return;
}
if (this.newType === 'turn' && !this.newUsername.trim()) {
this.addError.set('Username is required for TURN servers');
return;
}
if (this.newType === 'turn' && !this.newCredential.trim()) {
this.addError.set('Credential is required for TURN servers');
return;
}
if (this.entries().some((entry) => entry.urls === url)) {
this.addError.set('This URL already exists');
return;
}
this.iceSettings.addEntry({
type: this.newType,
urls: url,
...(this.newType === 'turn'
? { username: this.newUsername.trim(), credential: this.newCredential.trim() }
: {})
});
this.newUrl = '';
this.newUsername = '';
this.newCredential = '';
}
removeEntry(id: string): void {
this.iceSettings.removeEntry(id);
}
moveUp(index: number): void {
if (index > 0)
this.iceSettings.moveEntry(index, index - 1);
}
moveDown(index: number): void {
if (index < this.entries().length - 1)
this.iceSettings.moveEntry(index, index + 1);
}
restoreDefaults(): void {
this.iceSettings.restoreDefaults();
}
trackEntry(_index: number, entry: IceServerEntry): string {
return entry.id;
}
}

View File

@@ -199,4 +199,7 @@
</div>
</div>
</section>
<!-- ICE Server Settings (STUN / TURN) -->
<app-ice-server-settings />
</div>

View File

@@ -20,6 +20,7 @@ import {
import { ServerDirectoryFacade } from '../../../../domains/server-directory';
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
import { IceServerSettingsComponent } from '../ice-server-settings/ice-server-settings.component';
@Component({
selector: 'app-network-settings',
@@ -27,7 +28,8 @@ import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
imports: [
CommonModule,
FormsModule,
NgIcon
NgIcon,
IceServerSettingsComponent
],
viewProviders: [
provideIcons({

View File

@@ -70,7 +70,7 @@
[value]="selectedServerId() || ''"
(change)="onServerSelect($event)"
>
<option value="">Select a server</option>
<option value="">Select a server...</option>
@for (room of manageableRooms(); track room.id) {
<option [value]="room.id">{{ room.name }}</option>
}

View File

@@ -73,7 +73,7 @@
[value]="state().preferredVersion || ''"
(change)="onVersionChange($event)"
>
<option value="">Choose a release</option>
<option value="">Choose a release...</option>
@for (version of state().availableVersions; track version) {
<option [value]="version">{{ version }}</option>
}