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
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:
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user