Skip to content

Commit b1eac48

Browse files
committed
feat(turn): add TURN server implementation with Docker support and deployment scripts
1 parent d6eb760 commit b1eac48

20 files changed

Lines changed: 618 additions & 108 deletions

File tree

.claude/settings.local.json

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,10 +111,7 @@
111111
"Bash(bash -n:*)",
112112
"Bash(pnpm --filter @pairux/web build:*)"
113113
],
114-
"deny": [
115-
"Bash(npm *)",
116-
"Bash(npx *)"
117-
],
114+
"deny": ["Bash(npm *)", "Bash(npx *)"],
118115
"additionalDirectories": [
119116
"/home/ettinger/src/profullstack.com/music-torrent/services/dht-search-api/src"
120117
]

apps/desktop/src/main/capture/sources.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import { desktopCapturer, screen } from 'electron';
22
import type { CaptureSource } from '@pairux/shared-types';
33

4-
export async function getCaptureSources(
5-
types: ('screen' | 'window')[]
6-
): Promise<CaptureSource[]> {
4+
export async function getCaptureSources(types: ('screen' | 'window')[]): Promise<CaptureSource[]> {
75
try {
86
const sources = await desktopCapturer.getSources({
97
types,

apps/desktop/src/main/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@ import { registerIpcHandlers } from './ipc';
55
// Detect display server (X11 vs Wayland)
66
const isWayland =
77
process.platform === 'linux' &&
8-
(process.env.XDG_SESSION_TYPE === 'wayland' ||
9-
process.env.WAYLAND_DISPLAY !== undefined);
8+
(process.env.XDG_SESSION_TYPE === 'wayland' || process.env.WAYLAND_DISPLAY !== undefined);
109

1110
console.log(
1211
`[Main] Display server: ${isWayland ? 'Wayland' : process.platform === 'linux' ? 'X11' : 'N/A'}`

apps/desktop/src/main/ipc/index.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,15 @@ import type { CaptureSource } from '@pairux/shared-types';
55
// Detect display server
66
const isWayland =
77
process.platform === 'linux' &&
8-
(process.env.XDG_SESSION_TYPE === 'wayland' ||
9-
process.env.WAYLAND_DISPLAY !== undefined);
8+
(process.env.XDG_SESSION_TYPE === 'wayland' || process.env.WAYLAND_DISPLAY !== undefined);
109

1110
export function registerIpcHandlers(): void {
1211
console.log('[IPC] Registering IPC handlers');
1312

1413
// Capture handlers
1514
ipcMain.handle(
1615
'capture:getSources',
17-
async (
18-
_event,
19-
args: { types: ('screen' | 'window')[] }
20-
): Promise<CaptureSource[]> => {
16+
async (_event, args: { types: ('screen' | 'window')[] }): Promise<CaptureSource[]> => {
2117
console.log('[IPC] capture:getSources called with types:', args.types);
2218
return getCaptureSources(args.types);
2319
}

apps/desktop/src/main/window.ts

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -57,26 +57,24 @@ export async function createMainWindow(): Promise<BrowserWindow> {
5757
});
5858

5959
// Handle permission requests
60-
session.defaultSession.setPermissionRequestHandler(
61-
(_webContents, permission, callback) => {
62-
const allowedPermissions = [
63-
'media',
64-
'display-capture',
65-
'mediaKeySystem',
66-
'geolocation',
67-
'notifications',
68-
'fullscreen',
69-
];
60+
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
61+
const allowedPermissions = [
62+
'media',
63+
'display-capture',
64+
'mediaKeySystem',
65+
'geolocation',
66+
'notifications',
67+
'fullscreen',
68+
];
7069

71-
if (allowedPermissions.includes(permission)) {
72-
console.log('[Main] Permission granted:', permission);
73-
callback(true);
74-
} else {
75-
console.log('[Main] Permission denied:', permission);
76-
callback(false);
77-
}
70+
if (allowedPermissions.includes(permission)) {
71+
console.log('[Main] Permission granted:', permission);
72+
callback(true);
73+
} else {
74+
console.log('[Main] Permission denied:', permission);
75+
callback(false);
7876
}
79-
);
77+
});
8078

8179
// Show window when ready to prevent visual flash
8280
mainWindow.on('ready-to-show', () => {

apps/desktop/src/preload/index.ts

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
11
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron';
2-
import type {
3-
ChannelKey,
4-
EventKey,
5-
InvokeArgs,
6-
InvokeReturn,
7-
EventData,
8-
} from './api';
2+
import type { ChannelKey, EventKey, InvokeArgs, InvokeReturn, EventData } from './api';
93

104
/**
115
* Electron API exposed to the renderer via context bridge.
@@ -16,23 +10,18 @@ const electronAPI = {
1610
* Invoke an IPC channel and wait for response.
1711
* Type-safe: channel names and arguments are validated at compile time.
1812
*/
19-
invoke: <K extends ChannelKey>(
20-
channel: K,
21-
args: InvokeArgs<K>
22-
): Promise<InvokeReturn<K>> => {
13+
invoke: <K extends ChannelKey>(channel: K, args: InvokeArgs<K>): Promise<InvokeReturn<K>> => {
2314
return ipcRenderer.invoke(channel, args);
2415
},
2516

2617
/**
2718
* Subscribe to events from main process.
2819
* Returns an unsubscribe function.
2920
*/
30-
on: <K extends EventKey>(
31-
channel: K,
32-
callback: (data: EventData<K>) => void
33-
): (() => void) => {
34-
const handler = (_event: IpcRendererEvent, data: EventData<K>) =>
35-
{ callback(data); };
21+
on: <K extends EventKey>(channel: K, callback: (data: EventData<K>) => void): (() => void) => {
22+
const handler = (_event: IpcRendererEvent, data: EventData<K>) => {
23+
callback(data);
24+
};
3625
ipcRenderer.on(channel, handler);
3726
return () => ipcRenderer.removeListener(channel, handler);
3827
},

apps/desktop/src/renderer/App.tsx

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,7 @@ import type { CaptureSource } from '@pairux/shared-types';
77
import type { DisplayServer } from '../preload/api';
88

99
function App() {
10-
const [selectedSource, setSelectedSource] = useState<CaptureSource | null>(
11-
null
12-
);
10+
const [selectedSource, setSelectedSource] = useState<CaptureSource | null>(null);
1311
const [stream, setStream] = useState<MediaStream | null>(null);
1412
const [error, setError] = useState<string | null>(null);
1513
const [displayServer, setDisplayServer] = useState<DisplayServer>('x11');
@@ -32,7 +30,9 @@ function App() {
3230
try {
3331
// Stop existing stream
3432
if (stream) {
35-
stream.getTracks().forEach((track) => { track.stop(); });
33+
stream.getTracks().forEach((track) => {
34+
track.stop();
35+
});
3636
}
3737

3838
console.log('[Renderer] Starting capture for source:', source.id);
@@ -81,7 +81,9 @@ function App() {
8181

8282
try {
8383
if (stream) {
84-
stream.getTracks().forEach((track) => { track.stop(); });
84+
stream.getTracks().forEach((track) => {
85+
track.stop();
86+
});
8587
}
8688

8789
console.log('[Renderer] Starting Wayland capture with system picker');
@@ -113,7 +115,9 @@ function App() {
113115

114116
const handleStopCapture = () => {
115117
if (stream) {
116-
stream.getTracks().forEach((track) => { track.stop(); });
118+
stream.getTracks().forEach((track) => {
119+
track.stop();
120+
});
117121
setStream(null);
118122
setSelectedSource(null);
119123
}
@@ -127,7 +131,9 @@ function App() {
127131
<div className="mb-4 rounded-lg bg-destructive/10 p-4 text-destructive">
128132
{error}
129133
<button
130-
onClick={() => { setError(null); }}
134+
onClick={() => {
135+
setError(null);
136+
}}
131137
className="ml-4 text-sm underline"
132138
>
133139
Dismiss
@@ -137,33 +143,33 @@ function App() {
137143

138144
{!stream ? (
139145
<>
140-
<h1 className="mb-6 text-2xl font-semibold">
141-
Select a screen or window to share
142-
</h1>
146+
<h1 className="mb-6 text-2xl font-semibold">Select a screen or window to share</h1>
143147

144148
{isWayland && (
145149
<div className="mb-6 rounded-lg bg-muted p-4">
146150
<p className="mb-3 text-sm text-muted-foreground">
147-
You&apos;re using Wayland. Click below to open the system screen
148-
picker, or select a source from the list.
151+
You&apos;re using Wayland. Click below to open the system screen picker, or select
152+
a source from the list.
149153
</p>
150154
<button
151-
onClick={() => { void handleWaylandCapture(); }}
155+
onClick={() => {
156+
void handleWaylandCapture();
157+
}}
152158
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
153159
>
154160
Open System Screen Picker
155161
</button>
156162
</div>
157163
)}
158164

159-
<SourcePicker onSelect={(source) => { void handleSourceSelect(source); }} />
165+
<SourcePicker
166+
onSelect={(source) => {
167+
void handleSourceSelect(source);
168+
}}
169+
/>
160170
</>
161171
) : (
162-
<CapturePreview
163-
stream={stream}
164-
source={selectedSource}
165-
onStop={handleStopCapture}
166-
/>
172+
<CapturePreview stream={stream} source={selectedSource} onStop={handleStopCapture} />
167173
)}
168174
</main>
169175
</div>

apps/desktop/src/renderer/components/capture/CapturePreview.tsx

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,7 @@ export function CapturePreview({ stream, source, onStop }: CapturePreviewProps)
3737
<AppWindow className="h-5 w-5 text-primary" />
3838
)}
3939
<div>
40-
<h2 className="text-lg font-semibold">
41-
{source?.name ?? 'Capturing'}
42-
</h2>
40+
<h2 className="text-lg font-semibold">{source?.name ?? 'Capturing'}</h2>
4341
<p className="text-sm text-muted-foreground">
4442
{isScreen ? 'Screen' : 'Window'} capture active
4543
</p>
@@ -57,13 +55,7 @@ export function CapturePreview({ stream, source, onStop }: CapturePreviewProps)
5755

5856
{/* Video preview */}
5957
<div className="relative flex-1 overflow-hidden rounded-lg border border-border bg-black">
60-
<video
61-
ref={videoRef}
62-
autoPlay
63-
playsInline
64-
muted
65-
className="h-full w-full object-contain"
66-
/>
58+
<video ref={videoRef} autoPlay playsInline muted className="h-full w-full object-contain" />
6759

6860
{/* Live indicator */}
6961
<div className="absolute left-4 top-4 flex items-center gap-2 rounded-full bg-black/70 px-3 py-1.5">

apps/desktop/src/renderer/components/capture/SourceCard.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ export function SourceCard({ source, onSelect }: SourceCardProps) {
1111

1212
return (
1313
<button
14-
onClick={() => { onSelect(source); }}
14+
onClick={() => {
15+
onSelect(source);
16+
}}
1517
className="group flex flex-col overflow-hidden rounded-lg border border-border bg-card transition-all hover:border-primary hover:ring-2 hover:ring-primary/20"
1618
>
1719
{/* Thumbnail */}

apps/desktop/src/renderer/components/capture/SourcePicker.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ export function SourcePicker({ onSelect }: SourcePickerProps) {
4848
<div className="flex items-center gap-2">
4949
<div className="flex rounded-lg bg-muted p-1">
5050
<button
51-
onClick={() => { setActiveTab('screen'); }}
51+
onClick={() => {
52+
setActiveTab('screen');
53+
}}
5254
className={`flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors ${
5355
activeTab === 'screen'
5456
? 'bg-background text-foreground shadow-sm'
@@ -59,7 +61,9 @@ export function SourcePicker({ onSelect }: SourcePickerProps) {
5961
Screens ({screens.length})
6062
</button>
6163
<button
62-
onClick={() => { setActiveTab('window'); }}
64+
onClick={() => {
65+
setActiveTab('window');
66+
}}
6367
className={`flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors ${
6468
activeTab === 'window'
6569
? 'bg-background text-foreground shadow-sm'
@@ -74,7 +78,9 @@ export function SourcePicker({ onSelect }: SourcePickerProps) {
7478
<div className="flex-1" />
7579

7680
<button
77-
onClick={() => { void loadSources(); }}
81+
onClick={() => {
82+
void loadSources();
83+
}}
7884
disabled={loading}
7985
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
8086
>
@@ -84,11 +90,7 @@ export function SourcePicker({ onSelect }: SourcePickerProps) {
8490
</div>
8591

8692
{/* Error state */}
87-
{error && (
88-
<div className="rounded-lg bg-destructive/10 p-4 text-destructive">
89-
{error}
90-
</div>
91-
)}
93+
{error && <div className="rounded-lg bg-destructive/10 p-4 text-destructive">{error}</div>}
9294

9395
{/* Loading state */}
9496
{loading && !sources.length && (

0 commit comments

Comments
 (0)