diff --git a/shared/js/BrowserIPC.ts b/shared/js/BrowserIPC.ts index 27ec94e6..01272cea 100644 --- a/shared/js/BrowserIPC.ts +++ b/shared/js/BrowserIPC.ts @@ -24,6 +24,12 @@ namespace bipc { query_id: string; } + export interface ChannelMessage { + channel_id: string; + key: string; + message: any; + } + export interface ProcessQueryResponse { request_timestamp: number request_query_id: string; @@ -35,14 +41,13 @@ namespace bipc { export interface CertificateAcceptCallback { request_id: string; } - export interface CertificateAcceptSucceeded { - - } + export interface CertificateAcceptSucceeded { } export abstract class BasicIPCHandler { protected static readonly BROADCAST_UNIQUE_ID = "00000000-0000-4000-0000-000000000000"; protected static readonly PROTOCOL_VERSION = 1; + protected _channels: Channel[] = []; protected unique_id; protected constructor() { } @@ -51,6 +56,8 @@ namespace bipc { this.unique_id = uuidv4(); /* lets get an unique identifier */ } + get_local_address() { return this.unique_id; } + abstract send_message(type: string, data: any, target?: string); protected handle_message(message: BroadcastMessage) { @@ -97,10 +104,50 @@ namespace bipc { } this._cert_accept_succeeded[message.sender](); return; + } else if(message.type === "channel") { + const data: ChannelMessage = message.data; + + let channel_invoked = false; + for(const channel of this._channels) + if(channel.channel_id === data.channel_id && (typeof(channel.target_id) === "undefined" || channel.target_id === message.sender)) { + if(channel.message_handler) + channel.message_handler(message.sender, data); + channel_invoked = true; + } + if(!channel_invoked) { + console.warn(tr("Received channel message for unknown channel (%s)"), data.channel_id); + } } } } + create_channel(target_id?: string, channel_id?: string) { + let channel: Channel = { + target_id: target_id, + channel_id: channel_id || uuidv4(), + message_handler: undefined, + send_message: (key: string, message: any) => { + if(!channel.target_id) + throw "channel has no target!"; + + this.send_message("channel", { + key: key, + message: message, + channel_id: channel.channel_id + } as ChannelMessage, channel.target_id) + } + }; + + this._channels.push(channel); + return channel; + } + + channels() : Channel[] { return this._channels; } + + delete_channel(channel: Channel) { + this._channels = this._channels.filter(e => e !== channel); + } + private _query_results: {[key: string]:ProcessQueryResponse[]} = {}; async query_processes(timeout?: number) : Promise { const query_id = uuidv4(); @@ -145,6 +192,14 @@ namespace bipc { } } + export interface Channel { + readonly channel_id: string; + target_id?: string; + + message_handler: (remote_id: string, message: ChannelMessage) => any; + send_message(key: string, message: any); + } + class BroadcastChannelIPC extends BasicIPCHandler { private static readonly CHANNEL_NAME = "TeaSpeak-Web"; @@ -195,6 +250,219 @@ namespace bipc { } } + interface MethodProxyInvokeData { + method_name: string; + arguments: any[]; + promise_id: string; + } + interface MethodProxyResultData { + promise_id: string; + result: any; + success: boolean; + } + interface MethodProxyCallback { + promise: Promise; + promise_id: string; + + resolve: (object: any) => any; + reject: (object: any) => any; + } + + export type MethodProxyConnectParameters = { + channel_id: string; + client_id: string; + } + export abstract class MethodProxy { + readonly ipc_handler: BasicIPCHandler; + private _ipc_channel: Channel; + private _ipc_parameters: MethodProxyConnectParameters; + + private readonly _local: boolean; + private readonly _slave: boolean; + + private _connected: boolean; + private _proxied_methods: {[key: string]:() => Promise} = {}; + private _proxied_callbacks: {[key: string]:MethodProxyCallback} = {}; + + protected constructor(ipc_handler: BasicIPCHandler, connect_params?: MethodProxyConnectParameters) { + this.ipc_handler = ipc_handler; + this._ipc_parameters = connect_params; + this._connected = false; + this._slave = typeof(connect_params) !== "undefined"; + this._local = typeof(connect_params) !== "undefined" && connect_params.channel_id === "local" && connect_params.client_id === "local"; + } + + protected setup() { + if(this._local) { + this._connected = true; + this.on_connected(); + } else { + if(this._slave) + this._ipc_channel = this.ipc_handler.create_channel(this._ipc_parameters.client_id, this._ipc_parameters.channel_id); + else + this._ipc_channel = this.ipc_handler.create_channel(); + + this._ipc_channel.message_handler = this._handle_message.bind(this); + if(this._slave) + this._ipc_channel.send_message("initialize", {}); + } + } + + protected finalize() { + if(!this._local) { + if(this._connected) + this._ipc_channel.send_message("finalize", {}); + + this.ipc_handler.delete_channel(this._ipc_channel); + this._ipc_channel = undefined; + } + for(const promise of Object.values(this._proxied_callbacks)) + promise.reject("disconnected"); + this._proxied_callbacks = {}; + + this._connected = false; + this.on_disconnected(); + } + + protected register_method(method: (...args: any[]) => Promise | string) { + let method_name: string; + if(typeof method === "function") { + console.log("Proxy method: %o", method.name); + method_name = method.name; + } else { + console.log("Proxy method: %o", method); + method_name = method; + } + + if(!this[method_name]) + throw "method is missing in current object"; + + this._proxied_methods[method_name] = this[method_name]; + if(!this._local) { + this[method_name] = (...args: any[]) => { + if(!this._connected) + return Promise.reject("not connected"); + + const proxy_callback = { + promise_id: uuidv4() + } as MethodProxyCallback; + this._proxied_callbacks[proxy_callback.promise_id] = proxy_callback; + proxy_callback.promise = new Promise((resolve, reject) => { + proxy_callback.resolve = resolve; + proxy_callback.reject = reject; + }); + + this._ipc_channel.send_message("invoke", { + promise_id: proxy_callback.promise_id, + arguments: [...args], + method_name: method_name + } as MethodProxyInvokeData); + return proxy_callback.promise; + } + } + } + + private _handle_message(remote_id: string, message: ChannelMessage) { + if(message.key === "finalize") { + this._handle_finalize(); + } else if(message.key === "initialize") { + this._handle_remote_callback(remote_id); + } else if(message.key === "invoke") { + this._handle_invoke(message.message); + } else if(message.key === "result") { + this._handle_result(message.message); + } + } + + private _handle_finalize() { + this.on_disconnected(); + this.finalize(); + this._connected = false; + } + + private _handle_remote_callback(remote_id: string) { + if(!this._ipc_channel.target_id) { + if(this._slave) + throw "initialize wrong state!"; + + this._ipc_channel.target_id = remote_id; /* now we're able to send messages */ + this.on_connected(); + this._ipc_channel.send_message("initialize", true); + } else { + if(!this._slave) + throw "initialize wrong state!"; + + this.on_connected(); + } + this._connected = true; + } + + private _send_result(promise_id: string, success: boolean, message: any) { + this._ipc_channel.send_message("result", { + promise_id: promise_id, + result: message, + success: success + } as MethodProxyResultData); + } + + private _handle_invoke(data: MethodProxyInvokeData) { + if(this._proxied_methods[data.method_name]) + throw "we could not invoke a local proxied method!"; + + if(!this[data.method_name]) { + this._send_result(data.promise_id, false, "missing method"); + return; + } + + try { + console.log(tr("Invoking method %s with arguments: %o"), data.method_name, data.arguments); + + const promise = this[data.method_name](...data.arguments); + promise.then(result => { + console.log(tr("Result: %o"), result); + this._send_result(data.promise_id, true, result); + }).catch(error => { + this._send_result(data.promise_id, false, error); + }); + } catch(error) { + this._send_result(data.promise_id, false, error); + return; + } + } + + private _handle_result(data: MethodProxyResultData) { + if(!this._proxied_callbacks[data.promise_id]) { + console.warn(tr("Received proxy method result for unknown promise")); + return; + } + const callback = this._proxied_callbacks[data.promise_id]; + delete this._proxied_callbacks[data.promise_id]; + + if(data.success) + callback.resolve(data.result); + else + callback.reject(data.result); + } + + generate_connect_parameters() : MethodProxyConnectParameters { + if(this._slave) + throw "only masters can generate connect parameters!"; + if(!this._ipc_channel) + throw "please call setup() before"; + + return { + channel_id: this._ipc_channel.channel_id, + client_id: this.ipc_handler.get_local_address() + }; + } + + is_slave() { return this._local || this._slave; } /* the popout modal */ + is_master() { return this._local || !this._slave; } /* the host (teaweb application) */ + + protected abstract on_connected(); + protected abstract on_disconnected(); + } + let handler: BasicIPCHandler; export function setup() { if(!supported())