445 lines
13 KiB
TypeScript
445 lines
13 KiB
TypeScript
import type Peer from 'peerjs';
|
|
import type { DataConnection, MediaConnection } from 'peerjs';
|
|
import type MessageDistributor from '../MessageDistributor';
|
|
import type { TimedMessage } from '../Timed';
|
|
import type { Message } from '../messages'
|
|
import { ID_TP_MAP } from '../messages';
|
|
import store from 'App/store';
|
|
import type { LocalStream } from './LocalStream';
|
|
import { update, getState } from '../../store';
|
|
import { iceServerConfigFromString } from 'App/utils'
|
|
|
|
|
|
export enum CallingState {
|
|
Reconnecting,
|
|
Requesting,
|
|
True,
|
|
False,
|
|
};
|
|
|
|
export enum ConnectionStatus {
|
|
Connecting,
|
|
WaitingMessages,
|
|
Connected,
|
|
Inactive,
|
|
Disconnected,
|
|
Error,
|
|
};
|
|
|
|
|
|
export function getStatusText(status: ConnectionStatus): string {
|
|
switch(status) {
|
|
case ConnectionStatus.Connecting:
|
|
return "Connecting...";
|
|
case ConnectionStatus.Connected:
|
|
return "";
|
|
case ConnectionStatus.Inactive:
|
|
return "Client tab is inactive";
|
|
case ConnectionStatus.Disconnected:
|
|
return "Disconnected";
|
|
case ConnectionStatus.Error:
|
|
return "Something went wrong. Try to reload the page.";
|
|
case ConnectionStatus.WaitingMessages:
|
|
return "Connected. Waiting for the data... (The tab might be inactive)"
|
|
}
|
|
}
|
|
|
|
export interface State {
|
|
calling: CallingState,
|
|
peerConnectionStatus: ConnectionStatus,
|
|
}
|
|
|
|
export const INITIAL_STATE: State = {
|
|
calling: CallingState.False,
|
|
peerConnectionStatus: ConnectionStatus.Connecting,
|
|
}
|
|
|
|
const MAX_RECONNECTION_COUNT = 4;
|
|
|
|
|
|
function resolveURL(baseURL: string, relURL: string): string {
|
|
if (relURL.startsWith('#') || relURL === "") {
|
|
return relURL;
|
|
}
|
|
return new URL(relURL, baseURL).toString();
|
|
}
|
|
|
|
|
|
var match = /bar/.exec("foobar");
|
|
const re1 = /url\(("[^"]*"|'[^']*'|[^)]*)\)/g
|
|
const re2 = /@import "(.*?)"/g
|
|
function cssUrlsIndex(css: string): Array<[number, number]> {
|
|
const idxs: Array<[number, number]> = [];
|
|
const i1 = css.matchAll(re1);
|
|
// @ts-ignore
|
|
for (let m of i1) {
|
|
// @ts-ignore
|
|
const s: number = m.index + m[0].indexOf(m[1]);
|
|
const e: number = s + m[1].length;
|
|
idxs.push([s, e]);
|
|
}
|
|
const i2 = css.matchAll(re2);
|
|
// @ts-ignore
|
|
for (let m of i2) {
|
|
// @ts-ignore
|
|
const s = m.index + m[0].indexOf(m[1]);
|
|
const e = s + m[1].length;
|
|
idxs.push([s, e])
|
|
}
|
|
return idxs;
|
|
}
|
|
function unquote(str: string): [string, string] {
|
|
str = str.trim();
|
|
if (str.length <= 2) {
|
|
return [str, ""]
|
|
}
|
|
if (str[0] == '"' && str[str.length-1] == '"') {
|
|
return [ str.substring(1, str.length-1), "\""];
|
|
}
|
|
if (str[0] == '\'' && str[str.length-1] == '\'') {
|
|
return [ str.substring(1, str.length-1), "'" ];
|
|
}
|
|
return [str, ""]
|
|
}
|
|
function rewriteCSSLinks(css: string, rewriter: (rawurl: string) => string): string {
|
|
for (let idx of cssUrlsIndex(css)) {
|
|
const f = idx[0]
|
|
const t = idx[1]
|
|
const [ rawurl, q ] = unquote(css.substring(f, t));
|
|
css = css.substring(0,f) + q + rewriter(rawurl) + q + css.substring(t);
|
|
}
|
|
return css
|
|
}
|
|
|
|
function resolveCSS(baseURL: string, css: string): string {
|
|
return rewriteCSSLinks(css, rawurl => resolveURL(baseURL, rawurl));
|
|
}
|
|
|
|
|
|
export default class AssistManager {
|
|
constructor(private session, private md: MessageDistributor) {}
|
|
|
|
|
|
private setStatus(status: ConnectionStatus) {
|
|
if (status === ConnectionStatus.Connecting) {
|
|
this.md.setMessagesLoading(true);
|
|
} else {
|
|
this.md.setMessagesLoading(false);
|
|
}
|
|
if (status === ConnectionStatus.Connected) {
|
|
this.md.display(true);
|
|
} else {
|
|
this.md.display(false);
|
|
}
|
|
update({ peerConnectionStatus: status });
|
|
}
|
|
|
|
private get peerID(): string {
|
|
return `${this.session.projectKey}-${this.session.sessionId}`
|
|
}
|
|
|
|
private peer: Peer | null = null;
|
|
connectionAttempts: number = 0;
|
|
private peeropened: boolean = false;
|
|
connect() {
|
|
if (this.peer != null) {
|
|
console.error("AssistManager: trying to connect more than once");
|
|
return;
|
|
}
|
|
this.setStatus(ConnectionStatus.Connecting)
|
|
import('peerjs').then(({ default: Peer }) => {
|
|
// @ts-ignore
|
|
const iceServers = iceServerConfigFromString(window.ENV.ICE_SERVERS);
|
|
const _config = {
|
|
// @ts-ignore
|
|
host: new URL(window.ENV.API_EDP).host,
|
|
path: '/assist',
|
|
port: location.protocol === 'https:' ? 443 : 80,
|
|
sdpSemantics: 'unified-plan'
|
|
}
|
|
|
|
if (iceServers) {
|
|
_config['config'] = iceServers;
|
|
}
|
|
|
|
const peer = new Peer(_config);
|
|
this.peer = peer;
|
|
peer.on('error', e => {
|
|
if (e.type !== 'peer-unavailable') {
|
|
console.warn("AssistManager PeerJS peer error: ", e.type, e)
|
|
}
|
|
if (['peer-unavailable', 'network', 'webrtc'].includes(e.type)) {
|
|
if (this.peer && this.connectionAttempts++ < MAX_RECONNECTION_COUNT) {
|
|
this.setStatus(ConnectionStatus.Connecting);
|
|
this.connectToPeer();
|
|
} else {
|
|
this.setStatus(ConnectionStatus.Disconnected);
|
|
this.dataCheckIntervalID && clearInterval(this.dataCheckIntervalID);
|
|
}
|
|
} else {
|
|
console.error(`PeerJS error (on peer). Type ${e.type}`, e);
|
|
this.setStatus(ConnectionStatus.Error)
|
|
}
|
|
})
|
|
peer.on("open", () => {
|
|
if (this.peeropened) { return; }
|
|
this.peeropened = true;
|
|
this.connectToPeer();
|
|
});
|
|
});
|
|
}
|
|
|
|
private dataCheckIntervalID: ReturnType<typeof setInterval> | undefined;
|
|
private connectToPeer() {
|
|
if (!this.peer) { return; }
|
|
this.setStatus(ConnectionStatus.Connecting);
|
|
const id = this.peerID;
|
|
const conn = this.peer.connect(id, { serialization: 'json', reliable: true});
|
|
conn.on('open', () => {
|
|
window.addEventListener("beforeunload", ()=>conn.open &&conn.send("unload"));
|
|
|
|
//console.log("peer connected")
|
|
|
|
|
|
if (getState().calling === CallingState.Reconnecting) {
|
|
this._call()
|
|
}
|
|
|
|
let i = 0;
|
|
let firstMessage = true;
|
|
|
|
this.setStatus(ConnectionStatus.WaitingMessages)
|
|
|
|
conn.on('data', (data) => {
|
|
if (!Array.isArray(data)) { return this.handleCommand(data); }
|
|
this.disconnectTimeout && clearTimeout(this.disconnectTimeout);
|
|
if (firstMessage) {
|
|
firstMessage = false;
|
|
this.setStatus(ConnectionStatus.Connected)
|
|
}
|
|
|
|
let time = 0;
|
|
let ts0 = 0;
|
|
(data as Array<Message & { _id: number}>).forEach(msg => {
|
|
|
|
// TODO: more appropriate way to do it.
|
|
if (msg._id === 60) {
|
|
// @ts-ignore
|
|
if (msg.name === 'src' || msg.name === 'href') {
|
|
// @ts-ignore
|
|
msg.value = resolveURL(msg.baseURL, msg.value);
|
|
// @ts-ignore
|
|
} else if (msg.name === 'style') {
|
|
// @ts-ignore
|
|
msg.value = resolveCSS(msg.baseURL, msg.value);
|
|
}
|
|
msg._id = 12;
|
|
} else if (msg._id === 61) { // "SetCSSDataURLBased"
|
|
// @ts-ignore
|
|
msg.data = resolveCSS(msg.baseURL, msg.data);
|
|
msg._id = 15;
|
|
} else if (msg._id === 67) { // "insert_rule"
|
|
// @ts-ignore
|
|
msg.rule = resolveCSS(msg.baseURL, msg.rule);
|
|
msg._id = 37;
|
|
}
|
|
|
|
|
|
msg.tp = ID_TP_MAP[msg._id]; // _id goes from tracker
|
|
|
|
if (msg.tp === "timestamp") {
|
|
ts0 = ts0 || msg.timestamp
|
|
time = msg.timestamp - ts0;
|
|
return;
|
|
}
|
|
const tMsg: TimedMessage = Object.assign(msg, {
|
|
time,
|
|
_index: i,
|
|
});
|
|
this.md.distributeMessage(tMsg, i++);
|
|
});
|
|
});
|
|
});
|
|
|
|
|
|
const onDataClose = () => {
|
|
this.onCallDisconnect()
|
|
//console.log('closed peer conn. Reconnecting...')
|
|
this.connectToPeer();
|
|
}
|
|
|
|
// this.dataCheckIntervalID = setInterval(() => {
|
|
// if (!this.dataConnection && getState().peerConnectionStatus === ConnectionStatus.Connected) {
|
|
// onDataClose();
|
|
// }
|
|
// }, 3000);
|
|
conn.on('close', onDataClose);// Does it work ?
|
|
conn.on("error", (e) => {
|
|
this.setStatus(ConnectionStatus.Error);
|
|
})
|
|
}
|
|
|
|
|
|
private get dataConnection(): DataConnection | undefined {
|
|
return this.peer?.connections[this.peerID]?.find(c => c.type === 'data' && c.open);
|
|
}
|
|
|
|
private get callConnection(): MediaConnection | undefined {
|
|
return this.peer?.connections[this.peerID]?.find(c => c.type === 'media' && c.open);
|
|
}
|
|
|
|
private send(data: any) {
|
|
this.dataConnection?.send(data);
|
|
}
|
|
|
|
|
|
private forceCallEnd() {
|
|
this.callConnection?.close();
|
|
}
|
|
private notifyCallEnd() {
|
|
const dataConn = this.dataConnection;
|
|
if (dataConn) {
|
|
dataConn.send("call_end");
|
|
}
|
|
}
|
|
private initiateCallEnd = () => {
|
|
this.forceCallEnd();
|
|
this.notifyCallEnd();
|
|
this.localCallData && this.localCallData.onCallEnd();
|
|
}
|
|
|
|
private onTrackerCallEnd = () => {
|
|
console.log('onTrackerCallEnd')
|
|
this.forceCallEnd();
|
|
if (getState().calling === CallingState.Requesting) {
|
|
this.localCallData && this.localCallData.onReject();
|
|
}
|
|
this.localCallData && this.localCallData.onCallEnd();
|
|
}
|
|
|
|
private onCallDisconnect = () => {
|
|
if (getState().calling === CallingState.True) {
|
|
update({ calling: CallingState.Reconnecting });
|
|
}
|
|
}
|
|
|
|
|
|
private disconnectTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
private handleCommand(command: string) {
|
|
console.log("Data command", command)
|
|
switch (command) {
|
|
case "unload":
|
|
//this.onTrackerCallEnd();
|
|
this.onCallDisconnect()
|
|
this.dataConnection?.close();
|
|
this.disconnectTimeout = setTimeout(() => {
|
|
this.onTrackerCallEnd();
|
|
this.setStatus(ConnectionStatus.Disconnected);
|
|
}, 15000); // TODO: more convenient way
|
|
//this.dataConnection?.close();
|
|
return;
|
|
case "call_end":
|
|
this.onTrackerCallEnd();
|
|
return;
|
|
case "call_error":
|
|
this.onTrackerCallEnd();
|
|
this.setStatus(ConnectionStatus.Error);
|
|
return;
|
|
}
|
|
}
|
|
|
|
private onMouseMove = (e: MouseEvent ): void => {
|
|
const conn = this.dataConnection;
|
|
if (!conn) { return; }
|
|
// @ts-ignore ???
|
|
const data = this.md.getInternalCoordinates(e);
|
|
conn.send({ x: Math.round(data.x), y: Math.round(data.y) });
|
|
}
|
|
|
|
private onMouseClick = (e: MouseEvent): void => {
|
|
const conn = this.dataConnection;
|
|
if (!conn) { return; }
|
|
const data = this.md.getInternalCoordinates(e);
|
|
// const el = this.md.getElementFromPoint(e); // requires requestiong node_id from domManager
|
|
conn.send({ type: "click", x: Math.round(data.x), y: Math.round(data.y) });
|
|
}
|
|
|
|
private localCallData: {
|
|
localStream: LocalStream,
|
|
onStream: (s: MediaStream)=>void,
|
|
onCallEnd: () => void,
|
|
onReject: () => void,
|
|
onError?: ()=> void
|
|
} | null = null
|
|
|
|
call(localStream: LocalStream, onStream: (s: MediaStream)=>void, onCallEnd: () => void, onReject: () => void, onError?: ()=> void): null | Function {
|
|
this.localCallData = {
|
|
localStream,
|
|
onStream,
|
|
onCallEnd: () => {
|
|
onCallEnd();
|
|
this.md.overlay.removeEventListener("mousemove", this.onMouseMove);
|
|
this.md.overlay.removeEventListener("click", this.onMouseClick);
|
|
update({ calling: CallingState.False });
|
|
this.localCallData = null;
|
|
},
|
|
onReject,
|
|
onError,
|
|
}
|
|
this._call()
|
|
return this.initiateCallEnd;
|
|
}
|
|
|
|
private _call() {
|
|
if (!this.peer || !this.localCallData || ![CallingState.False, CallingState.Reconnecting].includes(getState().calling)) { return null; }
|
|
|
|
update({ calling: CallingState.Requesting });
|
|
|
|
//console.log('calling...', this.localCallData.localStream)
|
|
|
|
const call = this.peer.call(this.peerID, this.localCallData.localStream.stream);
|
|
this.localCallData.localStream.onVideoTrack(vTrack => {
|
|
const sender = call.peerConnection.getSenders().find(s => s.track?.kind === "video")
|
|
if (!sender) {
|
|
//logger.warn("No video sender found")
|
|
return
|
|
}
|
|
//logger.log("sender found:", sender)
|
|
sender.replaceTrack(vTrack)
|
|
})
|
|
|
|
call.on('stream', stream => {
|
|
update({ calling: CallingState.True });
|
|
this.localCallData && this.localCallData.onStream(stream);
|
|
this.send({
|
|
name: store.getState().getIn([ 'user', 'account', 'name']),
|
|
});
|
|
|
|
this.md.overlay.addEventListener("mousemove", this.onMouseMove)
|
|
this.md.overlay.addEventListener("click", this.onMouseClick)
|
|
});
|
|
//call.peerConnection.addEventListener("track", e => console.log('newtrack',e.track))
|
|
|
|
call.on("close", this.localCallData.onCallEnd);
|
|
call.on("error", (e) => {
|
|
console.error("PeerJS error (on call):", e)
|
|
this.initiateCallEnd();
|
|
this.localCallData && this.localCallData.onError && this.localCallData.onError();
|
|
});
|
|
|
|
window.addEventListener("beforeunload", this.initiateCallEnd)
|
|
}
|
|
|
|
clear() {
|
|
this.initiateCallEnd();
|
|
this.dataCheckIntervalID && clearInterval(this.dataCheckIntervalID);
|
|
if (this.peer) {
|
|
//console.log("destroying peer...")
|
|
const peer = this.peer; // otherwise it calls reconnection on data chan close
|
|
this.peer = null;
|
|
peer.destroy();
|
|
}
|
|
}
|
|
}
|
|
|
|
|