forked from mystiq/hydrogen-web
add structured logging to call code
This commit is contained in:
parent
a0a07355d4
commit
eaf92b382b
10 changed files with 478 additions and 351 deletions
|
@ -36,6 +36,15 @@ export abstract class BaseLogger implements ILogger {
|
||||||
this._persistItem(item, undefined, false);
|
this._persistItem(item, undefined, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Prefer `run()` or `log()` above this method; only use it if you have a long-running operation
|
||||||
|
* *without* a single call stack that should be logged into one sub-tree.
|
||||||
|
* You need to call `finish()` on the returned item or it will stay open until the app unloads. */
|
||||||
|
child(labelOrValues: LabelOrValues, logLevel: LogLevel = LogLevel.Info, filterCreator?: FilterCreator): ILogItem {
|
||||||
|
const item = new DeferredPersistRootLogItem(labelOrValues, logLevel, this, filterCreator);
|
||||||
|
this._openItems.add(item);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
/** if item is a log item, wrap the callback in a child of it, otherwise start a new root log item. */
|
/** if item is a log item, wrap the callback in a child of it, otherwise start a new root log item. */
|
||||||
wrapOrRun<T>(item: ILogItem | undefined, labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T {
|
wrapOrRun<T>(item: ILogItem | undefined, labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T {
|
||||||
if (item) {
|
if (item) {
|
||||||
|
@ -127,7 +136,7 @@ export abstract class BaseLogger implements ILogger {
|
||||||
|
|
||||||
_finishOpenItems() {
|
_finishOpenItems() {
|
||||||
for (const openItem of this._openItems) {
|
for (const openItem of this._openItems) {
|
||||||
openItem.finish();
|
openItem.forceFinish();
|
||||||
try {
|
try {
|
||||||
// for now, serialize with an all-permitting filter
|
// for now, serialize with an all-permitting filter
|
||||||
// as the createFilter function would get a distorted image anyway
|
// as the createFilter function would get a distorted image anyway
|
||||||
|
@ -158,3 +167,15 @@ export abstract class BaseLogger implements ILogger {
|
||||||
return Math.round(this._platform.random() * Number.MAX_SAFE_INTEGER);
|
return Math.round(this._platform.random() * Number.MAX_SAFE_INTEGER);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class DeferredPersistRootLogItem extends LogItem {
|
||||||
|
finish() {
|
||||||
|
super.finish();
|
||||||
|
(this._logger as BaseLogger)._persistItem(this, undefined, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
forceFinish() {
|
||||||
|
super.finish();
|
||||||
|
/// no need to persist when force-finishing as _finishOpenItems above will do it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -25,7 +25,7 @@ export class LogItem implements ILogItem {
|
||||||
public error?: Error;
|
public error?: Error;
|
||||||
public end?: number;
|
public end?: number;
|
||||||
private _values: LogItemValues;
|
private _values: LogItemValues;
|
||||||
private _logger: BaseLogger;
|
protected _logger: BaseLogger;
|
||||||
private _filterCreator?: FilterCreator;
|
private _filterCreator?: FilterCreator;
|
||||||
private _children?: Array<LogItem>;
|
private _children?: Array<LogItem>;
|
||||||
|
|
||||||
|
@ -221,6 +221,11 @@ export class LogItem implements ILogItem {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
forceFinish(): void {
|
||||||
|
this.finish();
|
||||||
|
}
|
||||||
|
|
||||||
// expose log level without needing import everywhere
|
// expose log level without needing import everywhere
|
||||||
get level(): typeof LogLevel {
|
get level(): typeof LogLevel {
|
||||||
return LogLevel;
|
return LogLevel;
|
||||||
|
@ -235,7 +240,7 @@ export class LogItem implements ILogItem {
|
||||||
|
|
||||||
child(labelOrValues: LabelOrValues, logLevel?: LogLevel, filterCreator?: FilterCreator): LogItem {
|
child(labelOrValues: LabelOrValues, logLevel?: LogLevel, filterCreator?: FilterCreator): LogItem {
|
||||||
if (this.end) {
|
if (this.end) {
|
||||||
console.trace("log item is finished, additional logs will likely not be recorded");
|
console.trace(`log item ${this.values.l} finished, additional log ${JSON.stringify(labelOrValues)} will likely not be recorded`);
|
||||||
}
|
}
|
||||||
if (!logLevel) {
|
if (!logLevel) {
|
||||||
logLevel = this.logLevel || LogLevel.Info;
|
logLevel = this.logLevel || LogLevel.Info;
|
||||||
|
|
|
@ -23,6 +23,10 @@ export class NullLogger implements ILogger {
|
||||||
|
|
||||||
log(): void {}
|
log(): void {}
|
||||||
|
|
||||||
|
child(): ILogItem {
|
||||||
|
return this.item;
|
||||||
|
}
|
||||||
|
|
||||||
run<T>(_, callback: LogCallback<T>): T {
|
run<T>(_, callback: LogCallback<T>): T {
|
||||||
return callback(this.item);
|
return callback(this.item);
|
||||||
}
|
}
|
||||||
|
@ -50,13 +54,13 @@ export class NullLogger implements ILogger {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NullLogItem implements ILogItem {
|
export class NullLogItem implements ILogItem {
|
||||||
public readonly logger: NullLogger;
|
public readonly logger: ILogger;
|
||||||
public readonly logLevel: LogLevel;
|
public readonly logLevel: LogLevel;
|
||||||
public children?: Array<ILogItem>;
|
public children?: Array<ILogItem>;
|
||||||
public values: LogItemValues;
|
public values: LogItemValues;
|
||||||
public error?: Error;
|
public error?: Error;
|
||||||
|
|
||||||
constructor(logger: NullLogger) {
|
constructor(logger: ILogger) {
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -99,6 +103,7 @@ export class NullLogItem implements ILogItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
finish(): void {}
|
finish(): void {}
|
||||||
|
forceFinish(): void {}
|
||||||
|
|
||||||
serialize(): undefined {
|
serialize(): undefined {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
|
@ -51,11 +51,24 @@ export interface ILogItem {
|
||||||
catch(err: Error): Error;
|
catch(err: Error): Error;
|
||||||
serialize(filter: LogFilter, parentStartTime: number | undefined, forced: boolean): ISerializedItem | undefined;
|
serialize(filter: LogFilter, parentStartTime: number | undefined, forced: boolean): ISerializedItem | undefined;
|
||||||
finish(): void;
|
finish(): void;
|
||||||
|
forceFinish(): void;
|
||||||
child(labelOrValues: LabelOrValues, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem;
|
child(labelOrValues: LabelOrValues, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem;
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
extend both ILogger and ILogItem from this interface, but need to rename ILogger.run => wrap then. Or both to `span`?
|
||||||
|
|
||||||
|
export interface ILogItemCreator {
|
||||||
|
child(labelOrValues: LabelOrValues, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem;
|
||||||
|
refDetached(logItem: ILogItem, logLevel?: LogLevel): void;
|
||||||
|
log(labelOrValues: LabelOrValues, logLevel?: LogLevel): ILogItem;
|
||||||
|
wrap<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T;
|
||||||
|
get level(): typeof LogLevel;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
export interface ILogger {
|
export interface ILogger {
|
||||||
log(labelOrValues: LabelOrValues, logLevel?: LogLevel): void;
|
log(labelOrValues: LabelOrValues, logLevel?: LogLevel): void;
|
||||||
|
child(labelOrValues: LabelOrValues, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem;
|
||||||
wrapOrRun<T>(item: ILogItem | undefined, labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T;
|
wrapOrRun<T>(item: ILogItem | undefined, labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T;
|
||||||
runDetached<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem;
|
runDetached<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem;
|
||||||
run<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T;
|
run<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T;
|
||||||
|
|
|
@ -86,7 +86,6 @@ export class DeviceMessageHandler {
|
||||||
this._senderDeviceCache.set(device);
|
this._senderDeviceCache.set(device);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log("incoming device message", senderKey, device, this._senderDeviceCache);
|
|
||||||
return device;
|
return device;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -84,8 +84,10 @@ export class Session {
|
||||||
}
|
}
|
||||||
// TODO: just get the devices we're sending the message to, not all the room devices
|
// TODO: just get the devices we're sending the message to, not all the room devices
|
||||||
// although we probably already fetched all devices to send messages in the likely e2ee room
|
// although we probably already fetched all devices to send messages in the likely e2ee room
|
||||||
await this._deviceTracker.trackRoom(this.rooms.get(roomId), log);
|
const devices = await log.wrap("get device keys", async log => {
|
||||||
const devices = await this._deviceTracker.devicesForRoomMembers(roomId, [userId], this._hsApi, log);
|
await this._deviceTracker.trackRoom(this.rooms.get(roomId), log);
|
||||||
|
return this._deviceTracker.devicesForRoomMembers(roomId, [userId], this._hsApi, log);
|
||||||
|
});
|
||||||
const encryptedMessage = await this._olmEncryption.encrypt(message.type, message.content, devices, this._hsApi, log);
|
const encryptedMessage = await this._olmEncryption.encrypt(message.type, message.content, devices, this._hsApi, log);
|
||||||
return encryptedMessage;
|
return encryptedMessage;
|
||||||
},
|
},
|
||||||
|
@ -93,6 +95,7 @@ export class Session {
|
||||||
webRTC: this._platform.webRTC,
|
webRTC: this._platform.webRTC,
|
||||||
ownDeviceId: sessionInfo.deviceId,
|
ownDeviceId: sessionInfo.deviceId,
|
||||||
ownUserId: sessionInfo.userId,
|
ownUserId: sessionInfo.userId,
|
||||||
|
logger: this._platform.logger,
|
||||||
});
|
});
|
||||||
this._deviceMessageHandler = new DeviceMessageHandler({storage, callHandler: this._callHandler});
|
this._deviceMessageHandler = new DeviceMessageHandler({storage, callHandler: this._callHandler});
|
||||||
this._olm = olm;
|
this._olm = olm;
|
||||||
|
|
|
@ -25,7 +25,7 @@ import type {LocalMedia} from "./LocalMedia";
|
||||||
import type {Room} from "../room/Room";
|
import type {Room} from "../room/Room";
|
||||||
import type {MemberChange} from "../room/members/RoomMember";
|
import type {MemberChange} from "../room/members/RoomMember";
|
||||||
import type {StateEvent} from "../storage/types";
|
import type {StateEvent} from "../storage/types";
|
||||||
import type {ILogItem} from "../../logging/types";
|
import type {ILogItem, ILogger} from "../../logging/types";
|
||||||
import type {Platform} from "../../platform/web/Platform";
|
import type {Platform} from "../../platform/web/Platform";
|
||||||
import type {BaseObservableMap} from "../../observable/map/BaseObservableMap";
|
import type {BaseObservableMap} from "../../observable/map/BaseObservableMap";
|
||||||
import type {SignallingMessage, MGroupCallBase} from "./callEventTypes";
|
import type {SignallingMessage, MGroupCallBase} from "./callEventTypes";
|
||||||
|
@ -35,7 +35,9 @@ const GROUP_CALL_TYPE = "m.call";
|
||||||
const GROUP_CALL_MEMBER_TYPE = "m.call.member";
|
const GROUP_CALL_MEMBER_TYPE = "m.call.member";
|
||||||
const CALL_TERMINATED = "m.terminated";
|
const CALL_TERMINATED = "m.terminated";
|
||||||
|
|
||||||
export type Options = Omit<GroupCallOptions, "emitUpdate">;
|
export type Options = Omit<GroupCallOptions, "emitUpdate"> & {
|
||||||
|
logger: ILogger
|
||||||
|
};
|
||||||
|
|
||||||
export class CallHandler {
|
export class CallHandler {
|
||||||
// group calls by call id
|
// group calls by call id
|
||||||
|
@ -51,7 +53,8 @@ export class CallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCall(roomId: string, localMedia: LocalMedia, name: string): Promise<GroupCall> {
|
async createCall(roomId: string, localMedia: LocalMedia, name: string): Promise<GroupCall> {
|
||||||
const call = new GroupCall(undefined, undefined, roomId, this.groupCallOptions);
|
const logItem = this.options.logger.child({l: "call", incoming: false});
|
||||||
|
const call = new GroupCall(undefined, undefined, roomId, this.groupCallOptions, logItem);
|
||||||
console.log("created call with id", call.id);
|
console.log("created call with id", call.id);
|
||||||
this._calls.set(call.id, call);
|
this._calls.set(call.id, call);
|
||||||
try {
|
try {
|
||||||
|
@ -59,6 +62,7 @@ export class CallHandler {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name === "ConnectionError") {
|
if (err.name === "ConnectionError") {
|
||||||
// if we're offline, give up and remove the call again
|
// if we're offline, give up and remove the call again
|
||||||
|
call.dispose();
|
||||||
this._calls.remove(call.id);
|
this._calls.remove(call.id);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
|
@ -79,13 +83,13 @@ export class CallHandler {
|
||||||
// first update call events
|
// first update call events
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
if (event.type === EventType.GroupCall) {
|
if (event.type === EventType.GroupCall) {
|
||||||
this.handleCallEvent(event, room.id);
|
this.handleCallEvent(event, room.id, log);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// then update members
|
// then update members
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
if (event.type === EventType.GroupCallMember) {
|
if (event.type === EventType.GroupCallMember) {
|
||||||
this.handleCallMemberEvent(event);
|
this.handleCallMemberEvent(event, log);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -108,28 +112,30 @@ export class CallHandler {
|
||||||
call?.handleDeviceMessage(message, userId, deviceId, log);
|
call?.handleDeviceMessage(message, userId, deviceId, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleCallEvent(event: StateEvent, roomId: string) {
|
private handleCallEvent(event: StateEvent, roomId: string, log: ILogItem) {
|
||||||
const callId = event.state_key;
|
const callId = event.state_key;
|
||||||
let call = this._calls.get(callId);
|
let call = this._calls.get(callId);
|
||||||
if (call) {
|
if (call) {
|
||||||
call.updateCallEvent(event.content);
|
call.updateCallEvent(event.content, log);
|
||||||
if (call.isTerminated) {
|
if (call.isTerminated) {
|
||||||
|
call.dispose();
|
||||||
this._calls.remove(call.id);
|
this._calls.remove(call.id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
call = new GroupCall(event.state_key, event.content, roomId, this.groupCallOptions);
|
const logItem = this.options.logger.child({l: "call", incoming: true});
|
||||||
|
call = new GroupCall(event.state_key, event.content, roomId, this.groupCallOptions, logItem);
|
||||||
this._calls.set(call.id, call);
|
this._calls.set(call.id, call);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleCallMemberEvent(event: StateEvent) {
|
private handleCallMemberEvent(event: StateEvent, log: ILogItem) {
|
||||||
const userId = event.state_key;
|
const userId = event.state_key;
|
||||||
const calls = event.content["m.calls"] ?? [];
|
const calls = event.content["m.calls"] ?? [];
|
||||||
for (const call of calls) {
|
for (const call of calls) {
|
||||||
const callId = call["m.call_id"];
|
const callId = call["m.call_id"];
|
||||||
const groupCall = this._calls.get(callId);
|
const groupCall = this._calls.get(callId);
|
||||||
// TODO: also check the member when receiving the m.call event
|
// TODO: also check the member when receiving the m.call event
|
||||||
groupCall?.addMember(userId, call);
|
groupCall?.addMember(userId, call, log);
|
||||||
};
|
};
|
||||||
const newCallIdsMemberOf = new Set<string>(calls.map(call => call["m.call_id"]));
|
const newCallIdsMemberOf = new Set<string>(calls.map(call => call["m.call_id"]));
|
||||||
let previousCallIdsMemberOf = this.memberToCallIds.get(userId);
|
let previousCallIdsMemberOf = this.memberToCallIds.get(userId);
|
||||||
|
@ -138,7 +144,7 @@ export class CallHandler {
|
||||||
for (const previousCallId of previousCallIdsMemberOf) {
|
for (const previousCallId of previousCallIdsMemberOf) {
|
||||||
if (!newCallIdsMemberOf.has(previousCallId)) {
|
if (!newCallIdsMemberOf.has(previousCallId)) {
|
||||||
const groupCall = this._calls.get(previousCallId);
|
const groupCall = this._calls.get(previousCallId);
|
||||||
groupCall?.removeMember(userId);
|
groupCall?.removeMember(userId, log);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,6 @@ import {Disposables, IDisposable} from "../../utils/Disposables";
|
||||||
import type {Room} from "../room/Room";
|
import type {Room} from "../room/Room";
|
||||||
import type {StateEvent} from "../storage/types";
|
import type {StateEvent} from "../storage/types";
|
||||||
import type {ILogItem} from "../../logging/types";
|
import type {ILogItem} from "../../logging/types";
|
||||||
import {Instance as logger} from "../../logging/NullLogger";
|
|
||||||
|
|
||||||
import type {TimeoutCreator, Timeout} from "../../platform/types/types";
|
import type {TimeoutCreator, Timeout} from "../../platform/types/types";
|
||||||
import {WebRTC, PeerConnection, PeerConnectionHandler, DataChannel} from "../../platform/types/WebRTC";
|
import {WebRTC, PeerConnection, PeerConnectionHandler, DataChannel} from "../../platform/types/WebRTC";
|
||||||
|
@ -69,9 +68,8 @@ export class PeerCall implements IDisposable {
|
||||||
// If candidates arrive before we've picked an opponent (which, in particular,
|
// If candidates arrive before we've picked an opponent (which, in particular,
|
||||||
// will happen if the opponent sends candidates eagerly before the user answers
|
// will happen if the opponent sends candidates eagerly before the user answers
|
||||||
// the call) we buffer them up here so we can then add the ones from the party we pick
|
// the call) we buffer them up here so we can then add the ones from the party we pick
|
||||||
private remoteCandidateBuffer? = new Map<string, RTCIceCandidate[]>();
|
private remoteCandidateBuffer? = new Map<PartyId, RTCIceCandidate[]>();
|
||||||
|
|
||||||
private logger: any;
|
|
||||||
private remoteSDPStreamMetadata?: SDPStreamMetadata;
|
private remoteSDPStreamMetadata?: SDPStreamMetadata;
|
||||||
private responsePromiseChain?: Promise<void>;
|
private responsePromiseChain?: Promise<void>;
|
||||||
private opponentPartyId?: PartyId;
|
private opponentPartyId?: PartyId;
|
||||||
|
@ -88,38 +86,44 @@ export class PeerCall implements IDisposable {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private callId: string,
|
private callId: string,
|
||||||
private readonly options: Options
|
private readonly options: Options,
|
||||||
|
private readonly logItem: ILogItem,
|
||||||
) {
|
) {
|
||||||
const outer = this;
|
const outer = this;
|
||||||
this.peerConnection = options.webRTC.createPeerConnection({
|
this.peerConnection = options.webRTC.createPeerConnection({
|
||||||
onIceConnectionStateChange(state: RTCIceConnectionState) {
|
onIceConnectionStateChange(state: RTCIceConnectionState) {
|
||||||
outer.onIceConnectionStateChange(state);
|
outer.logItem.wrap({l: "onIceConnectionStateChange", status: state}, log => {
|
||||||
|
outer.onIceConnectionStateChange(state, log);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onLocalIceCandidate(candidate: RTCIceCandidate) {
|
onLocalIceCandidate(candidate: RTCIceCandidate) {
|
||||||
outer.handleLocalIceCandidate(candidate);
|
outer.logItem.wrap("onLocalIceCandidate", log => {
|
||||||
|
outer.handleLocalIceCandidate(candidate, log);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onIceGatheringStateChange(state: RTCIceGatheringState) {
|
onIceGatheringStateChange(state: RTCIceGatheringState) {
|
||||||
outer.handleIceGatheringState(state);
|
outer.logItem.wrap({l: "onIceGatheringStateChange", status: state}, log => {
|
||||||
|
outer.handleIceGatheringState(state, log);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onRemoteTracksChanged(tracks: Track[]) {
|
onRemoteTracksChanged(tracks: Track[]) {
|
||||||
outer.options.emitUpdate(outer, undefined);
|
outer.logItem.wrap("onRemoteTracksChanged", log => {
|
||||||
|
outer.options.emitUpdate(outer, undefined);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onDataChannelChanged(dataChannel: DataChannel | undefined) {},
|
onDataChannelChanged(dataChannel: DataChannel | undefined) {},
|
||||||
onNegotiationNeeded() {
|
onNegotiationNeeded() {
|
||||||
const promiseCreator = () => outer.handleNegotiation();
|
const log = outer.logItem.child("onNegotiationNeeded");
|
||||||
|
const promiseCreator = async () => {
|
||||||
|
await outer.handleNegotiation(log);
|
||||||
|
log.finish();
|
||||||
|
};
|
||||||
outer.responsePromiseChain = outer.responsePromiseChain?.then(promiseCreator) ?? promiseCreator();
|
outer.responsePromiseChain = outer.responsePromiseChain?.then(promiseCreator) ?? promiseCreator();
|
||||||
},
|
},
|
||||||
getPurposeForStreamId(streamId: string): SDPStreamMetadataPurpose {
|
getPurposeForStreamId(streamId: string): SDPStreamMetadataPurpose {
|
||||||
return outer.remoteSDPStreamMetadata?.[streamId]?.purpose ?? SDPStreamMetadataPurpose.Usermedia;
|
return outer.remoteSDPStreamMetadata?.[streamId]?.purpose ?? SDPStreamMetadataPurpose.Usermedia;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.logger = {
|
|
||||||
info(...args) { console.info.apply(console, ["WebRTC debug:", ...args])},
|
|
||||||
debug(...args) { console.log.apply(console, ["WebRTC debug:", ...args])},
|
|
||||||
log(...args) { console.log.apply(console, ["WebRTC log:", ...args])},
|
|
||||||
warn(...args) { console.log.apply(console, ["WebRTC warn:", ...args])},
|
|
||||||
error(...args) { console.error.apply(console, ["WebRTC error:", ...args])},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get state(): CallState { return this._state; }
|
get state(): CallState { return this._state; }
|
||||||
|
@ -128,108 +132,127 @@ export class PeerCall implements IDisposable {
|
||||||
return this.peerConnection.remoteTracks;
|
return this.peerConnection.remoteTracks;
|
||||||
}
|
}
|
||||||
|
|
||||||
async call(localMedia: LocalMedia): Promise<void> {
|
call(localMedia: LocalMedia): Promise<void> {
|
||||||
if (this._state !== CallState.Fledgling) {
|
return this.logItem.wrap("call", async log => {
|
||||||
return;
|
if (this._state !== CallState.Fledgling) {
|
||||||
}
|
return;
|
||||||
this.localMedia = localMedia;
|
|
||||||
this.direction = CallDirection.Outbound;
|
|
||||||
this.setState(CallState.CreateOffer);
|
|
||||||
for (const t of this.localMedia.tracks) {
|
|
||||||
this.peerConnection.addTrack(t);
|
|
||||||
}
|
|
||||||
// after adding the local tracks, and wait for handleNegotiation to be called,
|
|
||||||
// or invite glare where we give up our invite and answer instead
|
|
||||||
await this.waitForState([CallState.InviteSent, CallState.CreateAnswer]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async answer(localMedia: LocalMedia): Promise<void> {
|
|
||||||
if (this._state !== CallState.Ringing) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.localMedia = localMedia;
|
|
||||||
this.setState(CallState.CreateAnswer);
|
|
||||||
for (const t of this.localMedia.tracks) {
|
|
||||||
this.peerConnection.addTrack(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
let myAnswer: RTCSessionDescriptionInit;
|
|
||||||
try {
|
|
||||||
myAnswer = await this.peerConnection.createAnswer();
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.debug(`Call ${this.callId} Failed to create answer: `, err);
|
|
||||||
this.terminate(CallParty.Local, CallErrorCode.CreateAnswer, true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.peerConnection.setLocalDescription(myAnswer);
|
|
||||||
this.setState(CallState.Connecting);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.debug(`Call ${this.callId} Error setting local description!`, err);
|
|
||||||
this.terminate(CallParty.Local, CallErrorCode.SetLocalDescription, true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Allow a short time for initial candidates to be gathered
|
|
||||||
await this.delay(200);
|
|
||||||
await this.sendAnswer();
|
|
||||||
}
|
|
||||||
|
|
||||||
async setMedia(localMediaPromise: Promise<LocalMedia>) {
|
|
||||||
const oldMedia = this.localMedia;
|
|
||||||
this.localMedia = await localMediaPromise;
|
|
||||||
|
|
||||||
const applyTrack = (selectTrack: (media: LocalMedia | undefined) => Track | undefined) => {
|
|
||||||
const oldTrack = selectTrack(oldMedia);
|
|
||||||
const newTrack = selectTrack(this.localMedia);
|
|
||||||
if (oldTrack && newTrack) {
|
|
||||||
this.peerConnection.replaceTrack(oldTrack, newTrack);
|
|
||||||
} else if (oldTrack) {
|
|
||||||
this.peerConnection.removeTrack(oldTrack);
|
|
||||||
} else if (newTrack) {
|
|
||||||
this.peerConnection.addTrack(newTrack);
|
|
||||||
}
|
}
|
||||||
};
|
this.localMedia = localMedia;
|
||||||
|
this.direction = CallDirection.Outbound;
|
||||||
|
this.setState(CallState.CreateOffer);
|
||||||
|
for (const t of this.localMedia.tracks) {
|
||||||
|
this.peerConnection.addTrack(t);
|
||||||
|
}
|
||||||
|
// after adding the local tracks, and wait for handleNegotiation to be called,
|
||||||
|
// or invite glare where we give up our invite and answer instead
|
||||||
|
await this.waitForState([CallState.InviteSent, CallState.CreateAnswer]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// add the local tracks, and wait for onNegotiationNeeded and handleNegotiation to be called
|
answer(localMedia: LocalMedia): Promise<void> {
|
||||||
applyTrack(m => m?.microphoneTrack);
|
return this.logItem.wrap("answer", async log => {
|
||||||
applyTrack(m => m?.cameraTrack);
|
if (this._state !== CallState.Ringing) {
|
||||||
applyTrack(m => m?.screenShareTrack);
|
return;
|
||||||
|
}
|
||||||
|
this.localMedia = localMedia;
|
||||||
|
this.setState(CallState.CreateAnswer);
|
||||||
|
for (const t of this.localMedia.tracks) {
|
||||||
|
this.peerConnection.addTrack(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
let myAnswer: RTCSessionDescriptionInit;
|
||||||
|
try {
|
||||||
|
myAnswer = await this.peerConnection.createAnswer();
|
||||||
|
} catch (err) {
|
||||||
|
await log.wrap(`Failed to create answer`, log => {
|
||||||
|
log.catch(err);
|
||||||
|
this.terminate(CallParty.Local, CallErrorCode.CreateAnswer, true, log);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.peerConnection.setLocalDescription(myAnswer);
|
||||||
|
this.setState(CallState.Connecting);
|
||||||
|
} catch (err) {
|
||||||
|
await log.wrap(`Error setting local description!`, log => {
|
||||||
|
log.catch(err);
|
||||||
|
this.terminate(CallParty.Local, CallErrorCode.SetLocalDescription, true, log);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Allow a short time for initial candidates to be gathered
|
||||||
|
try { await this.delay(200); }
|
||||||
|
catch (err) { return; }
|
||||||
|
await this.sendAnswer(log);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setMedia(localMediaPromise: Promise<LocalMedia>): Promise<void> {
|
||||||
|
return this.logItem.wrap("setMedia", async log => {
|
||||||
|
const oldMedia = this.localMedia;
|
||||||
|
this.localMedia = await localMediaPromise;
|
||||||
|
|
||||||
|
const applyTrack = (selectTrack: (media: LocalMedia | undefined) => Track | undefined) => {
|
||||||
|
const oldTrack = selectTrack(oldMedia);
|
||||||
|
const newTrack = selectTrack(this.localMedia);
|
||||||
|
if (oldTrack && newTrack) {
|
||||||
|
this.peerConnection.replaceTrack(oldTrack, newTrack);
|
||||||
|
} else if (oldTrack) {
|
||||||
|
this.peerConnection.removeTrack(oldTrack);
|
||||||
|
} else if (newTrack) {
|
||||||
|
this.peerConnection.addTrack(newTrack);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// add the local tracks, and wait for onNegotiationNeeded and handleNegotiation to be called
|
||||||
|
applyTrack(m => m?.microphoneTrack);
|
||||||
|
applyTrack(m => m?.cameraTrack);
|
||||||
|
applyTrack(m => m?.screenShareTrack);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async reject() {
|
async reject() {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async hangup(errorCode: CallErrorCode): Promise<void> {
|
hangup(errorCode: CallErrorCode): Promise<void> {
|
||||||
|
return this.logItem.wrap("hangup", log => {
|
||||||
|
return this._hangup(errorCode, log);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _hangup(errorCode: CallErrorCode, log: ILogItem): Promise<void> {
|
||||||
if (this._state !== CallState.Ended) {
|
if (this._state !== CallState.Ended) {
|
||||||
this._state = CallState.Ended;
|
this._state = CallState.Ended;
|
||||||
await this.sendHangupWithCallId(this.callId, errorCode);
|
await this.sendHangupWithCallId(this.callId, errorCode, log);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleIncomingSignallingMessage<B extends MCallBase>(message: SignallingMessage<B>, partyId: PartyId, log: ILogItem): Promise<void> {
|
handleIncomingSignallingMessage<B extends MCallBase>(message: SignallingMessage<B>, partyId: PartyId): Promise<void> {
|
||||||
switch (message.type) {
|
return this.logItem.wrap({l: "receive", id: message.type, partyId}, async log => {
|
||||||
case EventType.Invite:
|
switch (message.type) {
|
||||||
if (this.callId !== message.content.call_id) {
|
case EventType.Invite:
|
||||||
await this.handleInviteGlare(message.content, partyId);
|
if (this.callId !== message.content.call_id) {
|
||||||
} else {
|
await this.handleInviteGlare(message.content, partyId, log);
|
||||||
await this.handleFirstInvite(message.content, partyId);
|
} else {
|
||||||
}
|
await this.handleFirstInvite(message.content, partyId, log);
|
||||||
break;
|
}
|
||||||
case EventType.Answer:
|
break;
|
||||||
await this.handleAnswer(message.content, partyId);
|
case EventType.Answer:
|
||||||
break;
|
await this.handleAnswer(message.content, partyId, log);
|
||||||
case EventType.Candidates:
|
break;
|
||||||
await this.handleRemoteIceCandidates(message.content, partyId);
|
case EventType.Candidates:
|
||||||
break;
|
await this.handleRemoteIceCandidates(message.content, partyId, log);
|
||||||
case EventType.Hangup:
|
break;
|
||||||
default:
|
case EventType.Hangup:
|
||||||
throw new Error(`Unknown event type for call: ${message.type}`);
|
default:
|
||||||
}
|
throw new Error(`Unknown event type for call: ${message.type}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendHangupWithCallId(callId: string, reason?: CallErrorCode): Promise<void> {
|
private sendHangupWithCallId(callId: string, reason: CallErrorCode | undefined, log: ILogItem): Promise<void> {
|
||||||
const content = {
|
const content = {
|
||||||
call_id: callId,
|
call_id: callId,
|
||||||
version: 1,
|
version: 1,
|
||||||
|
@ -237,27 +260,28 @@ export class PeerCall implements IDisposable {
|
||||||
if (reason) {
|
if (reason) {
|
||||||
content["reason"] = reason;
|
content["reason"] = reason;
|
||||||
}
|
}
|
||||||
return this.options.sendSignallingMessage({
|
return this.sendSignallingMessage({
|
||||||
type: EventType.Hangup,
|
type: EventType.Hangup,
|
||||||
content
|
content
|
||||||
}, logger.item);
|
}, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
// calls are serialized and deduplicated by responsePromiseChain
|
// calls are serialized and deduplicated by responsePromiseChain
|
||||||
private handleNegotiation = async (): Promise<void> => {
|
private handleNegotiation = async (log: ILogItem): Promise<void> => {
|
||||||
this.makingOffer = true;
|
this.makingOffer = true;
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
await this.peerConnection.setLocalDescription();
|
await this.peerConnection.setLocalDescription();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.debug(`Call ${this.callId} Error setting local description!`, err);
|
log.log(`Error setting local description!`).catch(err);
|
||||||
this.terminate(CallParty.Local, CallErrorCode.SetLocalDescription, true);
|
this.terminate(CallParty.Local, CallErrorCode.SetLocalDescription, true, log);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.peerConnection.iceGatheringState === 'gathering') {
|
if (this.peerConnection.iceGatheringState === 'gathering') {
|
||||||
// Allow a short time for initial candidates to be gathered
|
// Allow a short time for initial candidates to be gathered
|
||||||
await this.delay(200);
|
try { await this.delay(200); }
|
||||||
|
catch (err) { return; }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._state === CallState.Ended) {
|
if (this._state === CallState.Ended) {
|
||||||
|
@ -267,7 +291,7 @@ export class PeerCall implements IDisposable {
|
||||||
const offer = this.peerConnection.localDescription!;
|
const offer = this.peerConnection.localDescription!;
|
||||||
// Get rid of any candidates waiting to be sent: they'll be included in the local
|
// Get rid of any candidates waiting to be sent: they'll be included in the local
|
||||||
// description we just got and will send in the offer.
|
// description we just got and will send in the offer.
|
||||||
this.logger.info(`Call ${this.callId} Discarding ${
|
log.log(`Discarding ${
|
||||||
this.candidateSendQueue.length} candidates that will be sent in offer`);
|
this.candidateSendQueue.length} candidates that will be sent in offer`);
|
||||||
this.candidateSendQueue = [];
|
this.candidateSendQueue = [];
|
||||||
|
|
||||||
|
@ -280,63 +304,64 @@ export class PeerCall implements IDisposable {
|
||||||
lifetime: CALL_TIMEOUT_MS
|
lifetime: CALL_TIMEOUT_MS
|
||||||
};
|
};
|
||||||
if (this._state === CallState.CreateOffer) {
|
if (this._state === CallState.CreateOffer) {
|
||||||
await this.options.sendSignallingMessage({type: EventType.Invite, content}, logger.item);
|
await this.sendSignallingMessage({type: EventType.Invite, content}, log);
|
||||||
this.setState(CallState.InviteSent);
|
this.setState(CallState.InviteSent);
|
||||||
} else if (this._state === CallState.Connected || this._state === CallState.Connecting) {
|
} else if (this._state === CallState.Connected || this._state === CallState.Connecting) {
|
||||||
// send Negotiate message
|
// send Negotiate message
|
||||||
//await this.options.sendSignallingMessage({type: EventType.Invite, content});
|
//await this.sendSignallingMessage({type: EventType.Invite, content});
|
||||||
//this.setState(CallState.InviteSent);
|
//this.setState(CallState.InviteSent);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.makingOffer = false;
|
this.makingOffer = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendCandidateQueue();
|
this.sendCandidateQueue(log);
|
||||||
|
|
||||||
if (this._state === CallState.InviteSent) {
|
if (this._state === CallState.InviteSent) {
|
||||||
await this.delay(CALL_TIMEOUT_MS);
|
try { await this.delay(CALL_TIMEOUT_MS); }
|
||||||
|
catch (err) { return; }
|
||||||
// @ts-ignore TS doesn't take the await above into account to know that the state could have changed in between
|
// @ts-ignore TS doesn't take the await above into account to know that the state could have changed in between
|
||||||
if (this._state === CallState.InviteSent) {
|
if (this._state === CallState.InviteSent) {
|
||||||
this.hangup(CallErrorCode.InviteTimeout);
|
this._hangup(CallErrorCode.InviteTimeout, log);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private async handleInviteGlare(content: MCallInvite<MCallBase>, partyId: PartyId): Promise<void> {
|
private async handleInviteGlare(content: MCallInvite<MCallBase>, partyId: PartyId, log: ILogItem): Promise<void> {
|
||||||
// this is only called when the ids are different
|
// this is only called when the ids are different
|
||||||
const newCallId = content.call_id;
|
const newCallId = content.call_id;
|
||||||
if (this.callId! > newCallId) {
|
if (this.callId! > newCallId) {
|
||||||
this.logger.log(
|
log.log(
|
||||||
"Glare detected: answering incoming call " + newCallId +
|
"Glare detected: answering incoming call " + newCallId +
|
||||||
" and canceling outgoing call " + this.callId,
|
" and canceling outgoing call ",
|
||||||
);
|
);
|
||||||
// How do we interrupt `call()`? well, perhaps we need to not just await InviteSent but also CreateAnswer?
|
// How do we interrupt `call()`? well, perhaps we need to not just await InviteSent but also CreateAnswer?
|
||||||
if (this._state === CallState.Fledgling || this._state === CallState.CreateOffer) {
|
if (this._state === CallState.Fledgling || this._state === CallState.CreateOffer) {
|
||||||
// TODO: don't send invite!
|
// TODO: don't send invite!
|
||||||
} else {
|
} else {
|
||||||
await this.sendHangupWithCallId(this.callId, CallErrorCode.Replaced);
|
await this.sendHangupWithCallId(this.callId, CallErrorCode.Replaced, log);
|
||||||
}
|
}
|
||||||
await this.handleInvite(content, partyId);
|
await this.handleInvite(content, partyId, log);
|
||||||
// TODO: need to skip state check
|
// TODO: need to skip state check
|
||||||
await this.answer(this.localMedia!);
|
await this.answer(this.localMedia!);
|
||||||
} else {
|
} else {
|
||||||
this.logger.log(
|
log.log(
|
||||||
"Glare detected: rejecting incoming call " + newCallId +
|
"Glare detected: rejecting incoming call " + newCallId +
|
||||||
" and keeping outgoing call " + this.callId,
|
" and keeping outgoing call ",
|
||||||
);
|
);
|
||||||
await this.sendHangupWithCallId(newCallId, CallErrorCode.Replaced);
|
await this.sendHangupWithCallId(newCallId, CallErrorCode.Replaced, log);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleFirstInvite(content: MCallInvite<MCallBase>, partyId: PartyId): Promise<void> {
|
private async handleFirstInvite(content: MCallInvite<MCallBase>, partyId: PartyId, log: ILogItem): Promise<void> {
|
||||||
if (this._state !== CallState.Fledgling || this.opponentPartyId !== undefined) {
|
if (this._state !== CallState.Fledgling || this.opponentPartyId !== undefined) {
|
||||||
// TODO: hangup or ignore?
|
// TODO: hangup or ignore?
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.handleInvite(content, partyId);
|
await this.handleInvite(content, partyId, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleInvite(content: MCallInvite<MCallBase>, partyId: PartyId): Promise<void> {
|
private async handleInvite(content: MCallInvite<MCallBase>, partyId: PartyId, log: ILogItem): Promise<void> {
|
||||||
|
|
||||||
// we must set the party ID before await-ing on anything: the call event
|
// we must set the party ID before await-ing on anything: the call event
|
||||||
// handler will start giving us more call events (eg. candidates) so if
|
// handler will start giving us more call events (eg. candidates) so if
|
||||||
|
@ -348,17 +373,18 @@ export class PeerCall implements IDisposable {
|
||||||
if (sdpStreamMetadata) {
|
if (sdpStreamMetadata) {
|
||||||
this.updateRemoteSDPStreamMetadata(sdpStreamMetadata);
|
this.updateRemoteSDPStreamMetadata(sdpStreamMetadata);
|
||||||
} else {
|
} else {
|
||||||
this.logger.debug(`Call ${
|
log.log(`Call did not get any SDPStreamMetadata! Can not send/receive multiple streams`);
|
||||||
this.callId} did not get any SDPStreamMetadata! Can not send/receive multiple streams`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Q: Why do we set the remote description before accepting the call? To start creating ICE candidates?
|
// Q: Why do we set the remote description before accepting the call? To start creating ICE candidates?
|
||||||
await this.peerConnection.setRemoteDescription(content.offer);
|
await this.peerConnection.setRemoteDescription(content.offer);
|
||||||
await this.addBufferedIceCandidates();
|
await this.addBufferedIceCandidates(log);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.debug(`Call ${this.callId} failed to set remote description`, e);
|
await log.wrap(`Call failed to set remote description`, async log => {
|
||||||
this.terminate(CallParty.Local, CallErrorCode.SetRemoteDescription, false);
|
log.catch(e);
|
||||||
|
return this.terminate(CallParty.Local, CallErrorCode.SetRemoteDescription, false, log);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -366,17 +392,19 @@ export class PeerCall implements IDisposable {
|
||||||
// add streams until media started arriving on them. Testing latest firefox
|
// add streams until media started arriving on them. Testing latest firefox
|
||||||
// (81 at time of writing), this is no longer a problem, so let's do it the correct way.
|
// (81 at time of writing), this is no longer a problem, so let's do it the correct way.
|
||||||
if (this.peerConnection.remoteTracks.length === 0) {
|
if (this.peerConnection.remoteTracks.length === 0) {
|
||||||
this.logger.error(`Call ${this.callId} no remote stream or no tracks after setting remote description!`);
|
await log.wrap(`Call no remote stream or no tracks after setting remote description!`, async log => {
|
||||||
this.terminate(CallParty.Local, CallErrorCode.SetRemoteDescription, false);
|
return this.terminate(CallParty.Local, CallErrorCode.SetRemoteDescription, false, log);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState(CallState.Ringing);
|
this.setState(CallState.Ringing);
|
||||||
|
|
||||||
await this.delay(content.lifetime ?? CALL_TIMEOUT_MS);
|
try { await this.delay(content.lifetime ?? CALL_TIMEOUT_MS); }
|
||||||
|
catch (err) { return; }
|
||||||
// @ts-ignore TS doesn't take the await above into account to know that the state could have changed in between
|
// @ts-ignore TS doesn't take the await above into account to know that the state could have changed in between
|
||||||
if (this._state === CallState.Ringing) {
|
if (this._state === CallState.Ringing) {
|
||||||
this.logger.debug(`Call ${this.callId} invite has expired. Hanging up.`);
|
log.log(`Invite has expired. Hanging up.`);
|
||||||
this.hangupParty = CallParty.Remote; // effectively
|
this.hangupParty = CallParty.Remote; // effectively
|
||||||
this.setState(CallState.Ended);
|
this.setState(CallState.Ended);
|
||||||
this.stopAllMedia();
|
this.stopAllMedia();
|
||||||
|
@ -386,25 +414,19 @@ export class PeerCall implements IDisposable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleAnswer(content: MCallAnswer<MCallBase>, partyId: PartyId): Promise<void> {
|
private async handleAnswer(content: MCallAnswer<MCallBase>, partyId: PartyId, log: ILogItem): Promise<void> {
|
||||||
this.logger.debug(`Got answer for call ID ${this.callId} from party ID ${partyId}`);
|
|
||||||
|
|
||||||
if (this._state === CallState.Ended) {
|
if (this._state === CallState.Ended) {
|
||||||
this.logger.debug(`Ignoring answer because call ID ${this.callId} has ended`);
|
log.log(`Ignoring answer because call has ended`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.opponentPartyId !== undefined) {
|
if (this.opponentPartyId !== undefined) {
|
||||||
this.logger.info(
|
log.log(`Ignoring answer: we already have an answer/reject from ${this.opponentPartyId}`);
|
||||||
`Call ${this.callId} ` +
|
|
||||||
`Ignoring answer from party ID ${partyId}: ` +
|
|
||||||
`we already have an answer/reject from ${this.opponentPartyId}`,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.opponentPartyId = partyId;
|
this.opponentPartyId = partyId;
|
||||||
await this.addBufferedIceCandidates();
|
await this.addBufferedIceCandidates(log);
|
||||||
|
|
||||||
this.setState(CallState.Connecting);
|
this.setState(CallState.Connecting);
|
||||||
|
|
||||||
|
@ -412,20 +434,22 @@ export class PeerCall implements IDisposable {
|
||||||
if (sdpStreamMetadata) {
|
if (sdpStreamMetadata) {
|
||||||
this.updateRemoteSDPStreamMetadata(sdpStreamMetadata);
|
this.updateRemoteSDPStreamMetadata(sdpStreamMetadata);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`Call ${this.callId} Did not get any SDPStreamMetadata! Can not send/receive multiple streams`);
|
log.log(`Did not get any SDPStreamMetadata! Can not send/receive multiple streams`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.peerConnection.setRemoteDescription(content.answer);
|
await this.peerConnection.setRemoteDescription(content.answer);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.debug(`Call ${this.callId} Failed to set remote description`, e);
|
await log.wrap(`Failed to set remote description`, log => {
|
||||||
this.terminate(CallParty.Local, CallErrorCode.SetRemoteDescription, false);
|
log.catch(e);
|
||||||
|
this.terminate(CallParty.Local, CallErrorCode.SetRemoteDescription, false, log);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleIceGatheringState(state: RTCIceGatheringState) {
|
private handleIceGatheringState(state: RTCIceGatheringState, log: ILogItem) {
|
||||||
this.logger.debug(`Call ${this.callId} ice gathering state changed to ${state}`);
|
log.set("state", state);
|
||||||
if (state === 'complete' && !this.sentEndOfCandidates) {
|
if (state === 'complete' && !this.sentEndOfCandidates) {
|
||||||
// If we didn't get an empty-string candidate to signal the end of candidates,
|
// If we didn't get an empty-string candidate to signal the end of candidates,
|
||||||
// create one ourselves now gathering has finished.
|
// create one ourselves now gathering has finished.
|
||||||
|
@ -437,37 +461,37 @@ export class PeerCall implements IDisposable {
|
||||||
const c = {
|
const c = {
|
||||||
candidate: '',
|
candidate: '',
|
||||||
} as RTCIceCandidate;
|
} as RTCIceCandidate;
|
||||||
this.queueCandidate(c);
|
this.queueCandidate(c, log);
|
||||||
this.sentEndOfCandidates = true;
|
this.sentEndOfCandidates = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleLocalIceCandidate(candidate: RTCIceCandidate) {
|
private handleLocalIceCandidate(candidate: RTCIceCandidate, log: ILogItem) {
|
||||||
this.logger.debug(
|
log.set("sdpMid", candidate.sdpMid);
|
||||||
"Call " + this.callId + " got local ICE " + candidate.sdpMid + " candidate: " +
|
log.set("candidate", candidate.candidate);
|
||||||
candidate.candidate,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (this._state === CallState.Ended) return;
|
|
||||||
|
|
||||||
|
if (this._state === CallState.Ended) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// As with the offer, note we need to make a copy of this object, not
|
// As with the offer, note we need to make a copy of this object, not
|
||||||
// pass the original: that broke in Chrome ~m43.
|
// pass the original: that broke in Chrome ~m43.
|
||||||
if (candidate.candidate !== '' || !this.sentEndOfCandidates) {
|
if (candidate.candidate !== '' || !this.sentEndOfCandidates) {
|
||||||
this.queueCandidate(candidate);
|
this.queueCandidate(candidate, log);
|
||||||
|
if (candidate.candidate === '') {
|
||||||
if (candidate.candidate === '') this.sentEndOfCandidates = true;
|
this.sentEndOfCandidates = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleRemoteIceCandidates(content: MCallCandidates<MCallBase>, partyId) {
|
private async handleRemoteIceCandidates(content: MCallCandidates<MCallBase>, partyId: PartyId, log: ILogItem) {
|
||||||
if (this.state === CallState.Ended) {
|
if (this.state === CallState.Ended) {
|
||||||
//debuglog("Ignoring remote ICE candidate because call has ended");
|
log.log("Ignoring remote ICE candidate because call has ended");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidates = content.candidates;
|
const candidates = content.candidates;
|
||||||
if (!candidates) {
|
if (!candidates) {
|
||||||
this.logger.info(`Call ${this.callId} Ignoring candidates event with no candidates!`);
|
log.log(`Ignoring candidates event with no candidates!`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -475,7 +499,7 @@ export class PeerCall implements IDisposable {
|
||||||
|
|
||||||
if (this.opponentPartyId === undefined) {
|
if (this.opponentPartyId === undefined) {
|
||||||
// we haven't picked an opponent yet so save the candidates
|
// we haven't picked an opponent yet so save the candidates
|
||||||
this.logger.info(`Call ${this.callId} Buffering ${candidates.length} candidates until we pick an opponent`);
|
log.log(`Buffering ${candidates.length} candidates until we pick an opponent`);
|
||||||
const bufferedCandidates = this.remoteCandidateBuffer!.get(fromPartyId) || [];
|
const bufferedCandidates = this.remoteCandidateBuffer!.get(fromPartyId) || [];
|
||||||
bufferedCandidates.push(...candidates);
|
bufferedCandidates.push(...candidates);
|
||||||
this.remoteCandidateBuffer!.set(fromPartyId, bufferedCandidates);
|
this.remoteCandidateBuffer!.set(fromPartyId, bufferedCandidates);
|
||||||
|
@ -483,8 +507,7 @@ export class PeerCall implements IDisposable {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.opponentPartyId !== partyId) {
|
if (this.opponentPartyId !== partyId) {
|
||||||
this.logger.info(
|
log.log(
|
||||||
`Call ${this.callId} `+
|
|
||||||
`Ignoring candidates from party ID ${partyId}: ` +
|
`Ignoring candidates from party ID ${partyId}: ` +
|
||||||
`we have chosen party ID ${this.opponentPartyId}`,
|
`we have chosen party ID ${this.opponentPartyId}`,
|
||||||
);
|
);
|
||||||
|
@ -492,14 +515,14 @@ export class PeerCall implements IDisposable {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.addIceCandidates(candidates);
|
await this.addIceCandidates(candidates, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
// private async onNegotiateReceived(event: MatrixEvent): Promise<void> {
|
// private async onNegotiateReceived(event: MatrixEvent): Promise<void> {
|
||||||
// const content = event.getContent<MCallNegotiate>();
|
// const content = event.getContent<MCallNegotiate>();
|
||||||
// const description = content.description;
|
// const description = content.description;
|
||||||
// if (!description || !description.sdp || !description.type) {
|
// if (!description || !description.sdp || !description.type) {
|
||||||
// this.logger.info(`Call ${this.callId} Ignoring invalid m.call.negotiate event`);
|
// this.logger.info(`Ignoring invalid m.call.negotiate event`);
|
||||||
// return;
|
// return;
|
||||||
// }
|
// }
|
||||||
// // Politeness always follows the direction of the call: in a glare situation,
|
// // Politeness always follows the direction of the call: in a glare situation,
|
||||||
|
@ -516,7 +539,7 @@ export class PeerCall implements IDisposable {
|
||||||
|
|
||||||
// this.ignoreOffer = !polite && offerCollision;
|
// this.ignoreOffer = !polite && offerCollision;
|
||||||
// if (this.ignoreOffer) {
|
// if (this.ignoreOffer) {
|
||||||
// this.logger.info(`Call ${this.callId} Ignoring colliding negotiate event because we're impolite`);
|
// this.logger.info(`Ignoring colliding negotiate event because we're impolite`);
|
||||||
// return;
|
// return;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
@ -524,7 +547,7 @@ export class PeerCall implements IDisposable {
|
||||||
// if (sdpStreamMetadata) {
|
// if (sdpStreamMetadata) {
|
||||||
// this.updateRemoteSDPStreamMetadata(sdpStreamMetadata);
|
// this.updateRemoteSDPStreamMetadata(sdpStreamMetadata);
|
||||||
// } else {
|
// } else {
|
||||||
// this.logger.warn(`Call ${this.callId} Received negotiation event without SDPStreamMetadata!`);
|
// this.logger.warn(`Received negotiation event without SDPStreamMetadata!`);
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// try {
|
// try {
|
||||||
|
@ -532,7 +555,7 @@ export class PeerCall implements IDisposable {
|
||||||
|
|
||||||
// if (description.type === 'offer') {
|
// if (description.type === 'offer') {
|
||||||
// await this.peerConnection.setLocalDescription();
|
// await this.peerConnection.setLocalDescription();
|
||||||
// await this.options.sendSignallingMessage({
|
// await this.sendSignallingMessage({
|
||||||
// type: EventType.CallNegotiate,
|
// type: EventType.CallNegotiate,
|
||||||
// content: {
|
// content: {
|
||||||
// description: this.peerConnection.localDescription!,
|
// description: this.peerConnection.localDescription!,
|
||||||
|
@ -541,11 +564,11 @@ export class PeerCall implements IDisposable {
|
||||||
// });
|
// });
|
||||||
// }
|
// }
|
||||||
// } catch (err) {
|
// } catch (err) {
|
||||||
// this.logger.warn(`Call ${this.callId} Failed to complete negotiation`, err);
|
// this.logger.warn(`Failed to complete negotiation`, err);
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
|
|
||||||
private async sendAnswer(): Promise<void> {
|
private async sendAnswer(log: ILogItem): Promise<void> {
|
||||||
const localDescription = this.peerConnection.localDescription!;
|
const localDescription = this.peerConnection.localDescription!;
|
||||||
const answerContent: MCallAnswer<MCallBase> = {
|
const answerContent: MCallAnswer<MCallBase> = {
|
||||||
call_id: this.callId,
|
call_id: this.callId,
|
||||||
|
@ -560,23 +583,23 @@ export class PeerCall implements IDisposable {
|
||||||
// We have just taken the local description from the peerConn which will
|
// We have just taken the local description from the peerConn which will
|
||||||
// contain all the local candidates added so far, so we can discard any candidates
|
// contain all the local candidates added so far, so we can discard any candidates
|
||||||
// we had queued up because they'll be in the answer.
|
// we had queued up because they'll be in the answer.
|
||||||
this.logger.info(`Call ${this.callId} Discarding ${
|
log.log(`Discarding ${
|
||||||
this.candidateSendQueue.length} candidates that will be sent in answer`);
|
this.candidateSendQueue.length} candidates that will be sent in answer`);
|
||||||
this.candidateSendQueue = [];
|
this.candidateSendQueue = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.options.sendSignallingMessage({type: EventType.Answer, content: answerContent}, logger.item);
|
await this.sendSignallingMessage({type: EventType.Answer, content: answerContent}, log);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.terminate(CallParty.Local, CallErrorCode.SendAnswer, false);
|
this.terminate(CallParty.Local, CallErrorCode.SendAnswer, false, log);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// error handler re-throws so this won't happen on error, but
|
// error handler re-throws so this won't happen on error, but
|
||||||
// we don't want the same error handling on the candidate queue
|
// we don't want the same error handling on the candidate queue
|
||||||
this.sendCandidateQueue();
|
this.sendCandidateQueue(log);
|
||||||
}
|
}
|
||||||
|
|
||||||
private queueCandidate(content: RTCIceCandidate): void {
|
private queueCandidate(content: RTCIceCandidate, log: ILogItem): void {
|
||||||
// We partially de-trickle candidates by waiting for `delay` before sending them
|
// We partially de-trickle candidates by waiting for `delay` before sending them
|
||||||
// amalgamated, in order to avoid sending too many m.call.candidates events and hitting
|
// amalgamated, in order to avoid sending too many m.call.candidates events and hitting
|
||||||
// rate limits in Matrix.
|
// rate limits in Matrix.
|
||||||
|
@ -593,36 +616,48 @@ export class PeerCall implements IDisposable {
|
||||||
|
|
||||||
// MSC2746 recommends these values (can be quite long when calling because the
|
// MSC2746 recommends these values (can be quite long when calling because the
|
||||||
// callee will need a while to answer the call)
|
// callee will need a while to answer the call)
|
||||||
this.delay(this.direction === CallDirection.Inbound ? 500 : 2000).then(() => {
|
const sendLogItem = this.logItem.child("wait to send candidates");
|
||||||
this.sendCandidateQueue();
|
log.refDetached(sendLogItem);
|
||||||
});
|
this.delay(this.direction === CallDirection.Inbound ? 500 : 2000)
|
||||||
|
.then(() => {
|
||||||
|
return this.sendCandidateQueue(sendLogItem);
|
||||||
|
}, err => {}) // swallow delay AbortError
|
||||||
|
.finally(() => {
|
||||||
|
sendLogItem.finish();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendCandidateQueue(): Promise<void> {
|
private async sendCandidateQueue(log: ILogItem): Promise<void> {
|
||||||
if (this.candidateSendQueue.length === 0 || this._state === CallState.Ended) {
|
return log.wrap("send candidates queue", async log => {
|
||||||
return;
|
log.set("queueLength", this.candidateSendQueue.length);
|
||||||
}
|
|
||||||
|
|
||||||
const candidates = this.candidateSendQueue;
|
if (this.candidateSendQueue.length === 0 || this._state === CallState.Ended) {
|
||||||
this.candidateSendQueue = [];
|
return;
|
||||||
this.logger.debug(`Call ${this.callId} attempting to send ${candidates.length} candidates`);
|
}
|
||||||
try {
|
|
||||||
await this.options.sendSignallingMessage({
|
const candidates = this.candidateSendQueue;
|
||||||
type: EventType.Candidates,
|
this.candidateSendQueue = [];
|
||||||
content: {
|
try {
|
||||||
call_id: this.callId,
|
await this.sendSignallingMessage({
|
||||||
version: 1,
|
type: EventType.Candidates,
|
||||||
candidates
|
content: {
|
||||||
},
|
call_id: this.callId,
|
||||||
}, logger.item);
|
version: 1,
|
||||||
// Try to send candidates again just in case we received more candidates while sending.
|
candidates
|
||||||
this.sendCandidateQueue();
|
},
|
||||||
} catch (error) {
|
}, log);
|
||||||
// don't retry this event: we'll send another one later as we might
|
// Try to send candidates again just in case we received more candidates while sending.
|
||||||
// have more candidates by then.
|
this.sendCandidateQueue(log);
|
||||||
// put all the candidates we failed to send back in the queue
|
} catch (error) {
|
||||||
this.terminate(CallParty.Local, CallErrorCode.SignallingFailed, false);
|
log.catch(error);
|
||||||
}
|
// don't retry this event: we'll send another one later as we might
|
||||||
|
// have more candidates by then.
|
||||||
|
// put all the candidates we failed to send back in the queue
|
||||||
|
|
||||||
|
// TODO: terminate doesn't seem to vibe with the comment above?
|
||||||
|
this.terminate(CallParty.Local, CallErrorCode.SignallingFailed, false, log);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateRemoteSDPStreamMetadata(metadata: SDPStreamMetadata): void {
|
private updateRemoteSDPStreamMetadata(metadata: SDPStreamMetadata): void {
|
||||||
|
@ -641,44 +676,44 @@ export class PeerCall implements IDisposable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async addBufferedIceCandidates(): Promise<void> {
|
private async addBufferedIceCandidates(log: ILogItem): Promise<void> {
|
||||||
if (this.remoteCandidateBuffer && this.opponentPartyId) {
|
if (this.remoteCandidateBuffer && this.opponentPartyId) {
|
||||||
const bufferedCandidates = this.remoteCandidateBuffer.get(this.opponentPartyId);
|
const bufferedCandidates = this.remoteCandidateBuffer.get(this.opponentPartyId);
|
||||||
if (bufferedCandidates) {
|
if (bufferedCandidates) {
|
||||||
this.logger.info(`Call ${this.callId} Adding ${
|
log.log(`Adding ${
|
||||||
bufferedCandidates.length} buffered candidates for opponent ${this.opponentPartyId}`);
|
bufferedCandidates.length} buffered candidates for opponent ${this.opponentPartyId}`);
|
||||||
await this.addIceCandidates(bufferedCandidates);
|
await this.addIceCandidates(bufferedCandidates, log);
|
||||||
}
|
}
|
||||||
this.remoteCandidateBuffer = undefined;
|
this.remoteCandidateBuffer = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async addIceCandidates(candidates: RTCIceCandidate[]): Promise<void> {
|
private async addIceCandidates(candidates: RTCIceCandidate[], log: ILogItem): Promise<void> {
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (
|
if (
|
||||||
(candidate.sdpMid === null || candidate.sdpMid === undefined) &&
|
(candidate.sdpMid === null || candidate.sdpMid === undefined) &&
|
||||||
(candidate.sdpMLineIndex === null || candidate.sdpMLineIndex === undefined)
|
(candidate.sdpMLineIndex === null || candidate.sdpMLineIndex === undefined)
|
||||||
) {
|
) {
|
||||||
this.logger.debug(`Call ${this.callId} ignoring remote ICE candidate with no sdpMid or sdpMLineIndex`);
|
log.log(`Ignoring remote ICE candidate with no sdpMid or sdpMLineIndex`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
this.logger.debug(`Call ${this.callId} got remote ICE ${candidate.sdpMid} candidate: ${candidate.candidate}`);
|
log.log(`Got remote ICE ${candidate.sdpMid} candidate: ${candidate.candidate}`);
|
||||||
try {
|
try {
|
||||||
await this.peerConnection.addIceCandidate(candidate);
|
await this.peerConnection.addIceCandidate(candidate);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!this.ignoreOffer) {
|
if (!this.ignoreOffer) {
|
||||||
this.logger.info(`Call ${this.callId} failed to add remote ICE candidate`, err);
|
log.log(`Failed to add remote ICE candidate`, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private onIceConnectionStateChange = (state: RTCIceConnectionState): void => {
|
private onIceConnectionStateChange = (state: RTCIceConnectionState, log: ILogItem): void => {
|
||||||
if (this._state === CallState.Ended) {
|
if (this._state === CallState.Ended) {
|
||||||
return; // because ICE can still complete as we're ending the call
|
return; // because ICE can still complete as we're ending the call
|
||||||
}
|
}
|
||||||
this.logger.debug(
|
log.log(
|
||||||
"Call ID " + this.callId + ": ICE connection state changed to: " + state,
|
"ICE connection state changed to: " + state,
|
||||||
);
|
);
|
||||||
// ideally we'd consider the call to be connected when we get media but
|
// ideally we'd consider the call to be connected when we get media but
|
||||||
// chrome doesn't implement any of the 'onstarted' events yet
|
// chrome doesn't implement any of the 'onstarted' events yet
|
||||||
|
@ -689,11 +724,11 @@ export class PeerCall implements IDisposable {
|
||||||
} else if (state == 'failed') {
|
} else if (state == 'failed') {
|
||||||
this.iceDisconnectedTimeout?.abort();
|
this.iceDisconnectedTimeout?.abort();
|
||||||
this.iceDisconnectedTimeout = undefined;
|
this.iceDisconnectedTimeout = undefined;
|
||||||
this.hangup(CallErrorCode.IceFailed);
|
this._hangup(CallErrorCode.IceFailed, log);
|
||||||
} else if (state == 'disconnected') {
|
} else if (state == 'disconnected') {
|
||||||
this.iceDisconnectedTimeout = this.options.createTimeout(30 * 1000);
|
this.iceDisconnectedTimeout = this.options.createTimeout(30 * 1000);
|
||||||
this.iceDisconnectedTimeout.elapsed().then(() => {
|
this.iceDisconnectedTimeout.elapsed().then(() => {
|
||||||
this.hangup(CallErrorCode.IceFailed);
|
this._hangup(CallErrorCode.IceFailed, log);
|
||||||
}, () => { /* ignore AbortError */ });
|
}, () => { /* ignore AbortError */ });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -725,7 +760,7 @@ export class PeerCall implements IDisposable {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async terminate(hangupParty: CallParty, hangupReason: CallErrorCode, shouldEmit: boolean): Promise<void> {
|
private async terminate(hangupParty: CallParty, hangupReason: CallErrorCode, shouldEmit: boolean, log: ILogItem): Promise<void> {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -744,6 +779,12 @@ export class PeerCall implements IDisposable {
|
||||||
this.disposables.untrack(timeout);
|
this.disposables.untrack(timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sendSignallingMessage(message: SignallingMessage<MCallBase>, log: ILogItem) {
|
||||||
|
return log.wrap({l: "send", id: message.type}, async log => {
|
||||||
|
return this.options.sendSignallingMessage(message, log);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
this.disposables.dispose();
|
this.disposables.dispose();
|
||||||
this.peerConnection.dispose();
|
this.peerConnection.dispose();
|
||||||
|
|
|
@ -61,10 +61,12 @@ export class GroupCall extends EventEmitter<{change: never}> {
|
||||||
id: string | undefined,
|
id: string | undefined,
|
||||||
private callContent: Record<string, any> | undefined,
|
private callContent: Record<string, any> | undefined,
|
||||||
public readonly roomId: string,
|
public readonly roomId: string,
|
||||||
private readonly options: Options
|
private readonly options: Options,
|
||||||
|
private readonly logItem: ILogItem,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.id = id ?? makeId("conf-");
|
this.id = id ?? makeId("conf-");
|
||||||
|
logItem.set("id", this.id);
|
||||||
this._state = id ? GroupCallState.Created : GroupCallState.Fledgling;
|
this._state = id ? GroupCallState.Created : GroupCallState.Fledgling;
|
||||||
this._memberOptions = Object.assign({}, options, {
|
this._memberOptions = Object.assign({}, options, {
|
||||||
confId: this.id,
|
confId: this.id,
|
||||||
|
@ -86,132 +88,158 @@ export class GroupCall extends EventEmitter<{change: never}> {
|
||||||
return this.callContent?.["m.name"];
|
return this.callContent?.["m.name"];
|
||||||
}
|
}
|
||||||
|
|
||||||
async join(localMedia: LocalMedia) {
|
join(localMedia: LocalMedia): Promise<void> {
|
||||||
if (this._state !== GroupCallState.Created) {
|
return this.logItem.wrap("join", async log => {
|
||||||
return;
|
if (this._state !== GroupCallState.Created) {
|
||||||
}
|
return;
|
||||||
this._state = GroupCallState.Joining;
|
}
|
||||||
this._localMedia = localMedia;
|
this._state = GroupCallState.Joining;
|
||||||
this.emitChange();
|
this._localMedia = localMedia;
|
||||||
const memberContent = await this._createJoinPayload();
|
this.emitChange();
|
||||||
// send m.call.member state event
|
const memberContent = await this._createJoinPayload();
|
||||||
const request = this.options.hsApi.sendState(this.roomId, CALL_MEMBER_TYPE, this.options.ownUserId, memberContent);
|
// send m.call.member state event
|
||||||
await request.response();
|
const request = this.options.hsApi.sendState(this.roomId, CALL_MEMBER_TYPE, this.options.ownUserId, memberContent, {log});
|
||||||
this.emitChange();
|
await request.response();
|
||||||
// send invite to all members that are < my userId
|
this.emitChange();
|
||||||
for (const [,member] of this._members) {
|
// send invite to all members that are < my userId
|
||||||
member.connect(this._localMedia);
|
for (const [,member] of this._members) {
|
||||||
}
|
member.connect(this._localMedia);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get hasJoined() {
|
get hasJoined() {
|
||||||
return this._state === GroupCallState.Joining || this._state === GroupCallState.Joined;
|
return this._state === GroupCallState.Joining || this._state === GroupCallState.Joined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async leave() {
|
leave(): Promise<void> {
|
||||||
const memberContent = await this._leaveCallMemberContent();
|
return this.logItem.wrap("leave", async log => {
|
||||||
// send m.call.member state event
|
const memberContent = await this._leaveCallMemberContent();
|
||||||
if (memberContent) {
|
// send m.call.member state event
|
||||||
const request = this.options.hsApi.sendState(this.roomId, CALL_MEMBER_TYPE, this.options.ownUserId, memberContent);
|
if (memberContent) {
|
||||||
await request.response();
|
const request = this.options.hsApi.sendState(this.roomId, CALL_MEMBER_TYPE, this.options.ownUserId, memberContent, {log});
|
||||||
// our own user isn't included in members, so not in the count
|
await request.response();
|
||||||
if (this._members.size === 0) {
|
// our own user isn't included in members, so not in the count
|
||||||
this.terminate();
|
if (this._members.size === 0) {
|
||||||
}
|
await this.terminate();
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async terminate() {
|
|
||||||
if (this._state === GroupCallState.Fledgling) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const request = this.options.hsApi.sendState(this.roomId, CALL_TYPE, this.id, Object.assign({}, this.callContent, {
|
|
||||||
"m.terminated": true
|
|
||||||
}));
|
|
||||||
await request.response();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal */
|
|
||||||
async create(localMedia: LocalMedia, name: string) {
|
|
||||||
if (this._state !== GroupCallState.Fledgling) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._state = GroupCallState.Creating;
|
|
||||||
this.emitChange();
|
|
||||||
this.callContent = {
|
|
||||||
"m.type": localMedia.cameraTrack ? "m.video" : "m.voice",
|
|
||||||
"m.name": name,
|
|
||||||
"m.intent": "m.ring"
|
|
||||||
};
|
|
||||||
const request = this.options.hsApi.sendState(this.roomId, CALL_TYPE, this.id, this.callContent);
|
|
||||||
await request.response();
|
|
||||||
this._state = GroupCallState.Created;
|
|
||||||
this.emitChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal */
|
|
||||||
updateCallEvent(callContent: Record<string, any>) {
|
|
||||||
this.callContent = callContent;
|
|
||||||
if (this._state === GroupCallState.Creating) {
|
|
||||||
this._state = GroupCallState.Created;
|
|
||||||
}
|
|
||||||
this.emitChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal */
|
|
||||||
addMember(userId, memberCallInfo) {
|
|
||||||
if (userId === this.options.ownUserId) {
|
|
||||||
if (this._state === GroupCallState.Joining) {
|
|
||||||
this._state = GroupCallState.Joined;
|
|
||||||
this.emitChange();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let member = this._members.get(userId);
|
|
||||||
if (member) {
|
|
||||||
member.updateCallInfo(memberCallInfo);
|
|
||||||
} else {
|
|
||||||
member = new Member(RoomMember.fromUserId(this.roomId, userId, "join"), memberCallInfo, this._memberOptions);
|
|
||||||
this._members.add(userId, member);
|
|
||||||
if (this._state === GroupCallState.Joining || this._state === GroupCallState.Joined) {
|
|
||||||
member.connect(this._localMedia!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal */
|
|
||||||
removeMember(userId) {
|
|
||||||
if (userId === this.options.ownUserId) {
|
|
||||||
if (this._state === GroupCallState.Joined) {
|
|
||||||
this._localMedia?.dispose();
|
|
||||||
this._localMedia = undefined;
|
|
||||||
for (const [,member] of this._members) {
|
|
||||||
member.disconnect();
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
terminate(): Promise<void> {
|
||||||
|
return this.logItem.wrap("terminate", async log => {
|
||||||
|
if (this._state === GroupCallState.Fledgling) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const request = this.options.hsApi.sendState(this.roomId, CALL_TYPE, this.id, Object.assign({}, this.callContent, {
|
||||||
|
"m.terminated": true
|
||||||
|
}), {log});
|
||||||
|
await request.response();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
create(localMedia: LocalMedia, name: string): Promise<void> {
|
||||||
|
return this.logItem.wrap("create", async log => {
|
||||||
|
if (this._state !== GroupCallState.Fledgling) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._state = GroupCallState.Creating;
|
||||||
|
this.emitChange();
|
||||||
|
this.callContent = {
|
||||||
|
"m.type": localMedia.cameraTrack ? "m.video" : "m.voice",
|
||||||
|
"m.name": name,
|
||||||
|
"m.intent": "m.ring"
|
||||||
|
};
|
||||||
|
const request = this.options.hsApi.sendState(this.roomId, CALL_TYPE, this.id, this.callContent, {log});
|
||||||
|
await request.response();
|
||||||
|
this._state = GroupCallState.Created;
|
||||||
|
this.emitChange();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
updateCallEvent(callContent: Record<string, any>, syncLog: ILogItem) {
|
||||||
|
this.logItem.wrap("updateCallEvent", log => {
|
||||||
|
syncLog.refDetached(log);
|
||||||
|
this.callContent = callContent;
|
||||||
|
if (this._state === GroupCallState.Creating) {
|
||||||
this._state = GroupCallState.Created;
|
this._state = GroupCallState.Created;
|
||||||
}
|
}
|
||||||
} else {
|
log.set("status", this._state);
|
||||||
const member = this._members.get(userId);
|
this.emitChange();
|
||||||
if (member) {
|
});
|
||||||
this._members.remove(userId);
|
|
||||||
member.disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.emitChange();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
handleDeviceMessage(message: SignallingMessage<MGroupCallBase>, userId: string, deviceId: string, log: ILogItem) {
|
addMember(userId: string, memberCallInfo, syncLog: ILogItem) {
|
||||||
console.log("incoming to_device call signalling message from", userId, deviceId, message);
|
this.logItem.wrap({l: "addMember", id: userId}, log => {
|
||||||
|
syncLog.refDetached(log);
|
||||||
|
|
||||||
|
if (userId === this.options.ownUserId) {
|
||||||
|
if (this._state === GroupCallState.Joining) {
|
||||||
|
this._state = GroupCallState.Joined;
|
||||||
|
this.emitChange();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let member = this._members.get(userId);
|
||||||
|
if (member) {
|
||||||
|
member.updateCallInfo(memberCallInfo);
|
||||||
|
} else {
|
||||||
|
const logItem = this.logItem.child("member");
|
||||||
|
member = new Member(RoomMember.fromUserId(this.roomId, userId, "join"), memberCallInfo, this._memberOptions, logItem);
|
||||||
|
this._members.add(userId, member);
|
||||||
|
if (this._state === GroupCallState.Joining || this._state === GroupCallState.Joined) {
|
||||||
|
member.connect(this._localMedia!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
removeMember(userId: string, syncLog: ILogItem) {
|
||||||
|
this.logItem.wrap({l: "removeMember", id: userId}, log => {
|
||||||
|
syncLog.refDetached(log);
|
||||||
|
if (userId === this.options.ownUserId) {
|
||||||
|
if (this._state === GroupCallState.Joined) {
|
||||||
|
this._localMedia?.dispose();
|
||||||
|
this._localMedia = undefined;
|
||||||
|
for (const [,member] of this._members) {
|
||||||
|
member.disconnect();
|
||||||
|
}
|
||||||
|
this._state = GroupCallState.Created;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const member = this._members.get(userId);
|
||||||
|
if (member) {
|
||||||
|
this._members.remove(userId);
|
||||||
|
member.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.emitChange();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
handleDeviceMessage(message: SignallingMessage<MGroupCallBase>, userId: string, deviceId: string, syncLog: ILogItem) {
|
||||||
// TODO: return if we are not membering to the call
|
// TODO: return if we are not membering to the call
|
||||||
let member = this._members.get(userId);
|
let member = this._members.get(userId);
|
||||||
if (member) {
|
if (member) {
|
||||||
member.handleDeviceMessage(message, deviceId, log);
|
member.handleDeviceMessage(message, deviceId, syncLog);
|
||||||
} else {
|
} else {
|
||||||
|
const item = this.logItem.log({l: "could not find member for signalling message", userId, deviceId});
|
||||||
|
syncLog.refDetached(item);
|
||||||
// we haven't received the m.call.member yet for this caller. buffer the device messages or create the member/call anyway?
|
// we haven't received the m.call.member yet for this caller. buffer the device messages or create the member/call anyway?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
dispose() {
|
||||||
|
this.logItem.finish();
|
||||||
|
}
|
||||||
|
|
||||||
private async _createJoinPayload() {
|
private async _createJoinPayload() {
|
||||||
const {storage} = this.options;
|
const {storage} = this.options;
|
||||||
const txn = await storage.readTxn([storage.storeNames.roomState]);
|
const txn = await storage.readTxn([storage.storeNames.roomState]);
|
||||||
|
|
|
@ -44,8 +44,11 @@ export class Member {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly member: RoomMember,
|
public readonly member: RoomMember,
|
||||||
private memberCallInfo: Record<string, any>,
|
private memberCallInfo: Record<string, any>,
|
||||||
private readonly options: Options
|
private readonly options: Options,
|
||||||
) {}
|
private readonly logItem: ILogItem,
|
||||||
|
) {
|
||||||
|
logItem.set("id", member.userId);
|
||||||
|
}
|
||||||
|
|
||||||
get remoteTracks(): Track[] {
|
get remoteTracks(): Track[] {
|
||||||
return this.peerCall?.remoteTracks ?? [];
|
return this.peerCall?.remoteTracks ?? [];
|
||||||
|
@ -57,6 +60,7 @@ export class Member {
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
connect(localMedia: LocalMedia) {
|
connect(localMedia: LocalMedia) {
|
||||||
|
this.logItem.log("connect");
|
||||||
this.localMedia = localMedia;
|
this.localMedia = localMedia;
|
||||||
// otherwise wait for it to connect
|
// otherwise wait for it to connect
|
||||||
if (this.member.userId < this.options.ownUserId) {
|
if (this.member.userId < this.options.ownUserId) {
|
||||||
|
@ -71,6 +75,7 @@ export class Member {
|
||||||
this.peerCall?.dispose();
|
this.peerCall?.dispose();
|
||||||
this.peerCall = undefined;
|
this.peerCall = undefined;
|
||||||
this.localMedia = undefined;
|
this.localMedia = undefined;
|
||||||
|
this.logItem.log("disconnect");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
|
@ -87,7 +92,7 @@ export class Member {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
sendSignallingMessage = async (message: SignallingMessage<MCallBase>, log: ILogItem) => {
|
sendSignallingMessage = async (message: SignallingMessage<MCallBase>, log: ILogItem): Promise<void> => {
|
||||||
const groupMessage = message as SignallingMessage<MGroupCallBase>;
|
const groupMessage = message as SignallingMessage<MGroupCallBase>;
|
||||||
groupMessage.content.conf_id = this.options.confId;
|
groupMessage.content.conf_id = this.options.confId;
|
||||||
const encryptedMessages = await this.options.encryptDeviceMessage(this.member.userId, groupMessage, log);
|
const encryptedMessages = await this.options.encryptDeviceMessage(this.member.userId, groupMessage, log);
|
||||||
|
@ -102,12 +107,13 @@ export class Member {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
handleDeviceMessage(message: SignallingMessage<MGroupCallBase>, deviceId: string, log: ILogItem) {
|
handleDeviceMessage(message: SignallingMessage<MGroupCallBase>, deviceId: string, syncLog: ILogItem) {
|
||||||
|
syncLog.refDetached(this.logItem);
|
||||||
if (message.type === EventType.Invite && !this.peerCall) {
|
if (message.type === EventType.Invite && !this.peerCall) {
|
||||||
this.peerCall = this._createPeerCall(message.content.call_id);
|
this.peerCall = this._createPeerCall(message.content.call_id);
|
||||||
}
|
}
|
||||||
if (this.peerCall) {
|
if (this.peerCall) {
|
||||||
this.peerCall.handleIncomingSignallingMessage(message, deviceId, log);
|
this.peerCall.handleIncomingSignallingMessage(message, deviceId);
|
||||||
} else {
|
} else {
|
||||||
// TODO: need to buffer events until invite comes?
|
// TODO: need to buffer events until invite comes?
|
||||||
}
|
}
|
||||||
|
@ -117,6 +123,6 @@ export class Member {
|
||||||
return new PeerCall(callId, Object.assign({}, this.options, {
|
return new PeerCall(callId, Object.assign({}, this.options, {
|
||||||
emitUpdate: this.emitUpdate,
|
emitUpdate: this.emitUpdate,
|
||||||
sendSignallingMessage: this.sendSignallingMessage
|
sendSignallingMessage: this.sendSignallingMessage
|
||||||
}));
|
}), this.logItem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue