Initial commit: Electron video/audio calling client
This commit is contained in:
commit
9eb33512f4
22 changed files with 8848 additions and 0 deletions
111
src/main/index.ts
Normal file
111
src/main/index.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { app, shell, BrowserWindow, ipcMain, session, desktopCapturer } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import { NetworkManager } from './network' // Import NetworkManager
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let networkManager: NetworkManager | null = null;
|
||||
|
||||
function createWindow(): void {
|
||||
// Create the browser window.
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 670,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false
|
||||
}
|
||||
})
|
||||
|
||||
networkManager = new NetworkManager(mainWindow);
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow?.show()
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// HMR for renderer base on electron-vite cli.
|
||||
// Load the remote URL for development or the local html file for production.
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// Set app user model id for windows
|
||||
electronApp.setAppUserModelId('com.electron')
|
||||
|
||||
// Grant permissions for camera/mic/screen
|
||||
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
|
||||
console.log(`[Main] Requesting permission: ${permission}`);
|
||||
// Grant all permissions for this valid local app
|
||||
callback(true);
|
||||
});
|
||||
|
||||
// Default open or close DevTools by F12 in development
|
||||
// and ignore CommandOrControl + R in production.
|
||||
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
|
||||
// IPC Handlers
|
||||
ipcMain.handle('connect', async (_, { roomCode, displayName }) => {
|
||||
if (networkManager) {
|
||||
return await networkManager.connect(roomCode, displayName);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('disconnect', async () => {
|
||||
if (networkManager) {
|
||||
networkManager.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('send-video-frame', (_, { frame }) => {
|
||||
if (networkManager) {
|
||||
networkManager.sendVideoFrame(frame);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('send-audio-frame', (_, { frame }) => {
|
||||
if (networkManager) {
|
||||
networkManager.sendAudioFrame(new Uint8Array(frame));
|
||||
}
|
||||
});
|
||||
|
||||
// Screen sharing: get available sources
|
||||
ipcMain.handle('get-screen-sources', async () => {
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['screen', 'window'],
|
||||
thumbnailSize: { width: 150, height: 150 }
|
||||
});
|
||||
return sources.map(s => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
thumbnail: s.thumbnail.toDataURL()
|
||||
}));
|
||||
});
|
||||
|
||||
createWindow()
|
||||
|
||||
app.on('activate', function () {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
235
src/main/network.ts
Normal file
235
src/main/network.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { EventEmitter } from 'events';
|
||||
import * as dgram from 'dgram';
|
||||
import WebSocket from 'ws';
|
||||
import { BrowserWindow } from 'electron';
|
||||
|
||||
// Constants - Configure SERVER_HOST for production
|
||||
const SERVER_HOST = process.env.MEET_SERVER_HOST || '127.0.0.1';
|
||||
const SERVER_WS_URL = `ws://${SERVER_HOST}:5000/ws`;
|
||||
const SERVER_UDP_HOST = SERVER_HOST;
|
||||
const SERVER_UDP_PORT = 4000;
|
||||
|
||||
// Packet Header Structure (22 bytes)
|
||||
// version: u8, media_type: u8, user_id: u32, sequence: u32, timestamp: u64, frag_idx: u8, frag_cnt: u8, flags: u16
|
||||
const HEADER_SIZE = 22;
|
||||
|
||||
export enum MediaType {
|
||||
Audio = 0,
|
||||
Video = 1,
|
||||
Screen = 2,
|
||||
}
|
||||
|
||||
export class NetworkManager extends EventEmitter {
|
||||
private ws: WebSocket | null = null;
|
||||
private udp: dgram.Socket | null = null;
|
||||
private userId: number = 0;
|
||||
private roomCode: string = '';
|
||||
private videoSeq: number = 0;
|
||||
private audioSeq: number = 0;
|
||||
private mainWindow: BrowserWindow;
|
||||
|
||||
constructor(mainWindow: BrowserWindow) {
|
||||
super();
|
||||
this.mainWindow = mainWindow;
|
||||
}
|
||||
|
||||
async connect(roomCode: string, displayName: string): Promise<any> {
|
||||
this.roomCode = roomCode; // Store for UDP handshake
|
||||
return new Promise((resolve, reject) => {
|
||||
this.ws = new WebSocket(`${SERVER_WS_URL}?room=${roomCode}&name=${displayName}`);
|
||||
|
||||
this.ws.on('open', () => {
|
||||
console.log('WS Connected');
|
||||
// Send Join Message (serde adjacent tagging: type + data)
|
||||
const joinMsg = {
|
||||
type: 'Join',
|
||||
data: {
|
||||
room_code: roomCode,
|
||||
display_name: displayName
|
||||
}
|
||||
};
|
||||
this.ws?.send(JSON.stringify(joinMsg));
|
||||
console.log('Sent Join:', joinMsg);
|
||||
});
|
||||
|
||||
this.ws.on('message', (data: WebSocket.Data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
this.handleWsMessage(msg, resolve, reject);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse WS msg', e);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('error', (err) => {
|
||||
console.error('WS Error', err);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
this.ws.on('close', () => {
|
||||
console.log('WS Closed');
|
||||
this.emit('disconnected');
|
||||
if (this.udp) this.udp.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
handleWsMessage(msg: any, resolve: any, reject: any) {
|
||||
console.log('Received WS Message:', msg.type);
|
||||
switch (msg.type) {
|
||||
case 'Joined':
|
||||
console.log('Joined Room:', msg.data);
|
||||
this.userId = msg.data.self_id; // Server sends self_id, not user_id
|
||||
// Init UDP
|
||||
this.setupUdp();
|
||||
resolve(msg.data); // Return joined data (peers, etc)
|
||||
break;
|
||||
case 'PeerJoined':
|
||||
this.mainWindow.webContents.send('peer-joined', msg.data);
|
||||
break;
|
||||
case 'PeerLeft':
|
||||
this.mainWindow.webContents.send('peer-left', msg.data);
|
||||
break;
|
||||
case 'Error':
|
||||
console.error('WS Error Msg:', msg.data);
|
||||
reject(msg.data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setupUdp() {
|
||||
this.udp = dgram.createSocket('udp4');
|
||||
|
||||
this.udp.on('listening', () => {
|
||||
const addr = this.udp?.address();
|
||||
console.log(`UDP Listening on ${addr?.port}`);
|
||||
// Send UDP handshake so server can associate our UDP address with room/user
|
||||
this.sendHandshake();
|
||||
});
|
||||
|
||||
this.udp.on('message', (msg) => {
|
||||
this.handleUdpMessage(msg);
|
||||
});
|
||||
|
||||
this.udp.bind(0); // Bind random port
|
||||
}
|
||||
|
||||
handleUdpMessage(msg: Buffer) {
|
||||
if (msg.length < HEADER_SIZE) return;
|
||||
|
||||
// Parse Header (Little Endian)
|
||||
// const version = msg.readUInt8(0); // 1
|
||||
const mediaType = msg.readUInt8(1);
|
||||
const userId = msg.readUInt32LE(2);
|
||||
// const seq = msg.readUInt32LE(6);
|
||||
// const ts = msg.readBigUInt64LE(10);
|
||||
// const fidx = msg.readUInt8(18);
|
||||
// const fcnt = msg.readUInt8(19);
|
||||
// const flags = msg.readUInt16LE(20);
|
||||
|
||||
const payload = msg.subarray(HEADER_SIZE);
|
||||
|
||||
if (mediaType === MediaType.Audio) {
|
||||
// Forward audio? Or decode here?
|
||||
// Electron renderer can use Web Audio API?
|
||||
// Or we decode in Main and send PCM?
|
||||
// Better to send encoded Opus to Renderer and decode there with WASM (libopus)
|
||||
// OR decode here with `opus-native` binding.
|
||||
// For now, let's send payload to renderer via IPC.
|
||||
// Note: IPC with high frequency audio packets might be laggy.
|
||||
// But for MVP it's okay.
|
||||
this.mainWindow.webContents.send('audio-frame', { user_id: userId, data: payload });
|
||||
} else if (mediaType === MediaType.Video) {
|
||||
// Send to renderer
|
||||
this.mainWindow.webContents.send('video-frame', { user_id: userId, data: payload });
|
||||
}
|
||||
}
|
||||
|
||||
sendVideoFrame(frame: Uint8Array) {
|
||||
if (!this.udp) return;
|
||||
|
||||
// Construct Header
|
||||
const header = Buffer.alloc(HEADER_SIZE);
|
||||
header.writeUInt8(1, 0); // Version
|
||||
header.writeUInt8(MediaType.Video, 1);
|
||||
header.writeUInt32LE(this.userId, 2);
|
||||
header.writeUInt32LE(this.videoSeq++, 6);
|
||||
header.writeBigUInt64LE(BigInt(Date.now()), 10);
|
||||
header.writeUInt8(0, 18); // Frag idx
|
||||
header.writeUInt8(1, 19); // Frag cnt
|
||||
header.writeUInt16LE(0, 20); // Flags
|
||||
|
||||
const packet = Buffer.concat([header, Buffer.from(frame)]);
|
||||
|
||||
console.log(`[UDP] Sending Video: ${packet.length} bytes to ${SERVER_UDP_HOST}:${SERVER_UDP_PORT}`);
|
||||
// Send
|
||||
this.udp.send(packet, SERVER_UDP_PORT, SERVER_UDP_HOST, (err) => {
|
||||
if (err) console.error('UDP Send Error', err);
|
||||
});
|
||||
}
|
||||
|
||||
sendAudioFrame(frame: Uint8Array) {
|
||||
if (!this.udp) return;
|
||||
|
||||
// Construct Header (same format as video)
|
||||
const header = Buffer.alloc(HEADER_SIZE);
|
||||
header.writeUInt8(1, 0); // Version
|
||||
header.writeUInt8(MediaType.Audio, 1);
|
||||
header.writeUInt32LE(this.userId, 2);
|
||||
header.writeUInt32LE(this.audioSeq++, 6);
|
||||
header.writeBigUInt64LE(BigInt(Date.now()), 10);
|
||||
header.writeUInt8(0, 18); // Frag idx
|
||||
header.writeUInt8(1, 19); // Frag cnt
|
||||
header.writeUInt16LE(0, 20); // Flags
|
||||
|
||||
const packet = Buffer.concat([header, Buffer.from(frame)]);
|
||||
|
||||
console.log(`[UDP] Sending Audio: ${packet.length} bytes to ${SERVER_UDP_HOST}:${SERVER_UDP_PORT}`);
|
||||
this.udp.send(packet, SERVER_UDP_PORT, SERVER_UDP_HOST, (err) => {
|
||||
if (err) console.error('UDP Audio Send Error', err);
|
||||
});
|
||||
}
|
||||
|
||||
sendHandshake() {
|
||||
if (!this.udp || !this.userId || !this.roomCode) {
|
||||
console.error('[UDP] Cannot send handshake: missing udp, userId, or roomCode');
|
||||
return;
|
||||
}
|
||||
|
||||
// Server expects bincode-serialized Handshake { user_id: u32, room_code: String }
|
||||
// bincode format for String: u64 length prefix (LE) + UTF-8 bytes
|
||||
const roomCodeBytes = Buffer.from(this.roomCode, 'utf-8');
|
||||
|
||||
// Payload: | user_id (4 bytes LE) | room_code_len (8 bytes LE) | room_code (N bytes) |
|
||||
const payloadLen = 4 + 8 + roomCodeBytes.length;
|
||||
const payload = Buffer.alloc(payloadLen);
|
||||
payload.writeUInt32LE(this.userId, 0); // user_id
|
||||
payload.writeBigUInt64LE(BigInt(roomCodeBytes.length), 4); // string length
|
||||
roomCodeBytes.copy(payload, 12); // room_code
|
||||
|
||||
// Construct header with MediaType.Command (3)
|
||||
const header = Buffer.alloc(HEADER_SIZE);
|
||||
header.writeUInt8(1, 0); // Version
|
||||
header.writeUInt8(3, 1); // MediaType.Command = 3
|
||||
header.writeUInt32LE(this.userId, 2);
|
||||
header.writeUInt32LE(0, 6); // Sequence
|
||||
header.writeBigUInt64LE(BigInt(Date.now()), 10);
|
||||
header.writeUInt8(0, 18); // Frag idx
|
||||
header.writeUInt8(1, 19); // Frag cnt
|
||||
header.writeUInt16LE(0, 20); // Flags
|
||||
|
||||
const packet = Buffer.concat([header, payload]);
|
||||
|
||||
console.log(`[UDP] Sending Handshake: userId=${this.userId}, room=${this.roomCode}, ${packet.length} bytes`);
|
||||
this.udp.send(packet, SERVER_UDP_PORT, SERVER_UDP_HOST, (err) => {
|
||||
if (err) console.error('UDP Handshake Send Error', err);
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.ws) this.ws.close();
|
||||
if (this.udp) this.udp.close();
|
||||
this.ws = null;
|
||||
this.udp = null;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue