diff --git a/src/ClientWidgetApi.ts b/src/ClientWidgetApi.ts index a5bef70..d9fbcbc 100644 --- a/src/ClientWidgetApi.ts +++ b/src/ClientWidgetApi.ts @@ -66,6 +66,12 @@ import { SimpleObservable } from "./util/SimpleObservable"; import { IOpenIDCredentialsActionRequestData } from "./interfaces/OpenIDCredentialsAction"; import { INavigateActionRequest } from "./interfaces/NavigateAction"; import { IReadEventFromWidgetActionRequest, IReadEventFromWidgetResponseData } from "./interfaces/ReadEventAction"; +import { + ITurnServer, + IWatchTurnServersRequest, + IUnwatchTurnServersRequest, + IUpdateTurnServersRequestData, +} from "./interfaces/TurnServerActions"; import { Symbols } from "./Symbols"; /** @@ -100,6 +106,7 @@ export class ClientWidgetApi extends EventEmitter { private allowedCapabilities = new Set(); private allowedEvents: WidgetEventCapability[] = []; private isStopped = false; + private turnServers: AsyncGenerator | null = null; /** * Creates a new client widget API. This will instantiate the transport @@ -492,6 +499,71 @@ export class ClientWidgetApi extends EventEmitter { } } + private async pollTurnServers(turnServers: AsyncGenerator, initialServer: ITurnServer) { + try { + await this.transport.send( + WidgetApiToWidgetAction.UpdateTurnServers, + initialServer as IUpdateTurnServersRequestData, // it's compatible, but missing the index signature + ); + + // Pick the generator up where we left off + for await (const server of turnServers) { + await this.transport.send( + WidgetApiToWidgetAction.UpdateTurnServers, + server as IUpdateTurnServersRequestData, // it's compatible, but missing the index signature + ); + } + } catch (e) { + console.error("error polling for TURN servers", e); + } + } + + private async handleWatchTurnServers(request: IWatchTurnServersRequest): Promise { + if (!this.hasCapability(MatrixCapabilities.MSC3846TurnServers)) { + await this.transport.reply(request, { + error: {message: "Missing capability"}, + }); + } else if (this.turnServers) { + // We're already polling, so this is a no-op + await this.transport.reply(request, {}); + } else { + try { + const turnServers = this.driver.getTurnServers(); + + // Peek at the first result, so we can at least verify that the + // client isn't banned from getting TURN servers entirely + const { done, value } = await turnServers.next(); + if (done) throw new Error("Client refuses to provide any TURN servers"); + await this.transport.reply(request, {}); + + // Start the poll loop, sending the widget the initial result + this.pollTurnServers(turnServers, value); + this.turnServers = turnServers; + } catch (e) { + console.error("error getting first TURN server results", e); + await this.transport.reply(request, { + error: {message: "TURN servers not available"}, + }); + } + } + } + + private async handleUnwatchTurnServers(request: IUnwatchTurnServersRequest): Promise { + if (!this.hasCapability(MatrixCapabilities.MSC3846TurnServers)) { + await this.transport.reply(request, { + error: {message: "Missing capability"}, + }); + } else if (!this.turnServers) { + // We weren't polling anyways, so this is a no-op + await this.transport.reply(request, {}); + } else { + // Stop the generator, allowing it to clean up + await this.turnServers.return(undefined); + this.turnServers = null; + await this.transport.reply(request, {}); + } + } + private handleMessage(ev: CustomEvent) { if (this.isStopped) return; const actionEv = new CustomEvent(`action:${ev.detail.action}`, { @@ -517,6 +589,10 @@ export class ClientWidgetApi extends EventEmitter { return this.handleCapabilitiesRenegotiate(ev.detail); case WidgetApiFromWidgetAction.MSC2876ReadEvents: return this.handleReadEvents(ev.detail); + case WidgetApiFromWidgetAction.WatchTurnServers: + return this.handleWatchTurnServers(ev.detail); + case WidgetApiFromWidgetAction.UnwatchTurnServers: + return this.handleUnwatchTurnServers(ev.detail); default: return this.transport.reply(ev.detail, { error: { diff --git a/src/WidgetApi.ts b/src/WidgetApi.ts index efe26d1..4630a92 100644 --- a/src/WidgetApi.ts +++ b/src/WidgetApi.ts @@ -17,6 +17,7 @@ import { EventEmitter } from "events"; import { Capability } from "./interfaces/Capabilities"; import { IWidgetApiRequest, IWidgetApiRequestEmptyData } from "./interfaces/IWidgetApiRequest"; +import { IWidgetApiAcknowledgeResponseData } from "./interfaces/IWidgetApiResponse"; import { WidgetApiDirection } from "./interfaces/WidgetApiDirection"; import { ISupportedVersionsActionRequest, @@ -61,6 +62,7 @@ import { EventDirection, WidgetEventCapability } from "./models/WidgetEventCapab import { INavigateActionRequestData } from "./interfaces/NavigateAction"; import { IReadEventFromWidgetRequestData, IReadEventFromWidgetResponseData } from "./interfaces/ReadEventAction"; import { IRoomEvent } from "./interfaces/IRoomEvent"; +import { ITurnServer, IUpdateTurnServersRequest } from "./interfaces/TurnServerActions"; import { Symbols } from "./Symbols"; /** @@ -88,6 +90,7 @@ export class WidgetApi extends EventEmitter { private requestedCapabilities: Capability[] = []; private approvedCapabilities: Capability[]; private cachedClientVersions: ApiVersion[]; + private turnServerWatchers = 0; /** * Creates a new API handler for the given widget. @@ -487,6 +490,53 @@ export class WidgetApi extends EventEmitter { ).then(); } + /** + * Starts watching for TURN servers, yielding an initial set of credentials as soon as possible, + * and thereafter yielding new credentials whenever the previous ones expire. + * @yields {ITurnServer} The TURN server URIs and credentials currently available to the widget. + */ + public async* getTurnServers(): AsyncGenerator { + let setTurnServer: (server: ITurnServer) => void; + + const onUpdateTurnServers = async (ev: CustomEvent) => { + ev.preventDefault(); + setTurnServer(ev.detail.data); + await this.transport.reply(ev.detail, {}); + }; + + // Start listening for updates before we even start watching, to catch + // TURN data that is sent immediately + this.on(`action:${WidgetApiToWidgetAction.UpdateTurnServers}`, onUpdateTurnServers); + + // Only send the 'watch' action if we aren't already watching + if (this.turnServerWatchers === 0) { + try { + await this.transport.send(WidgetApiFromWidgetAction.WatchTurnServers, {}); + } catch (e) { + this.off(`action:${WidgetApiToWidgetAction.UpdateTurnServers}`, onUpdateTurnServers); + throw e; + } + } + this.turnServerWatchers++; + + try { + // Watch for new data indefinitely (until this generator's return method is called) + while (true) { + yield await new Promise(resolve => setTurnServer = resolve); + } + } finally { + // The loop was broken by the caller - clean up + this.off(`action:${WidgetApiToWidgetAction.UpdateTurnServers}`, onUpdateTurnServers); + + // Since sending the 'unwatch' action will end updates for all other + // consumers, only send it if we're the only consumer remaining + this.turnServerWatchers--; + if (this.turnServerWatchers === 0) { + await this.transport.send(WidgetApiFromWidgetAction.UnwatchTurnServers, {}); + } + } + } + /** * Starts the communication channel. This should be done early to ensure * that messages are not missed. Communication can only be stopped by the client. diff --git a/src/driver/WidgetDriver.ts b/src/driver/WidgetDriver.ts index c69e72a..1410b4f 100644 --- a/src/driver/WidgetDriver.ts +++ b/src/driver/WidgetDriver.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Capability, IOpenIDCredentials, OpenIDRequestState, SimpleObservable, IRoomEvent } from ".."; +import { Capability, IOpenIDCredentials, OpenIDRequestState, SimpleObservable, IRoomEvent, ITurnServer } from ".."; export interface ISendEventDetails { roomId: string; @@ -169,4 +169,14 @@ export abstract class WidgetDriver { public navigate(uri: string): Promise { throw new Error("Navigation is not implemented"); } + + /** + * Polls for TURN server data, yielding an initial set of credentials as soon as possible, and + * thereafter yielding new credentials whenever the previous ones expire. The widget API will + * have already verified that the widget has permission to access TURN servers. + * @yields {ITurnServer} The TURN server URIs and credentials currently available to the client. + */ + public getTurnServers(): AsyncGenerator { + throw new Error("TURN server support is not implemented"); + } } diff --git a/src/index.ts b/src/index.ts index 9f2bc2e..2e160fc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -55,6 +55,7 @@ export * from "./interfaces/SendToDeviceAction"; export * from "./interfaces/ReadEventAction"; export * from "./interfaces/IRoomEvent"; export * from "./interfaces/NavigateAction"; +export * from "./interfaces/TurnServerActions"; // Complex models export * from "./models/WidgetEventCapability"; diff --git a/src/interfaces/ApiVersion.ts b/src/interfaces/ApiVersion.ts index 41b046d..3730cc9 100644 --- a/src/interfaces/ApiVersion.ts +++ b/src/interfaces/ApiVersion.ts @@ -27,6 +27,7 @@ export enum UnstableApiVersion { MSC2974 = "org.matrix.msc2974", MSC2876 = "org.matrix.msc2876", MSC3819 = "org.matrix.msc3819", + MSC3846 = "town.robin.msc3846", } export type ApiVersion = MatrixApiVersion | UnstableApiVersion | string; @@ -41,4 +42,5 @@ export const CurrentApiVersions: ApiVersion[] = [ UnstableApiVersion.MSC2974, UnstableApiVersion.MSC2876, UnstableApiVersion.MSC3819, + UnstableApiVersion.MSC3846, ]; diff --git a/src/interfaces/Capabilities.ts b/src/interfaces/Capabilities.ts index f204be5..0d05443 100644 --- a/src/interfaces/Capabilities.ts +++ b/src/interfaces/Capabilities.ts @@ -29,6 +29,7 @@ export enum MatrixCapabilities { * @deprecated It is not recommended to rely on this existing - it can be removed without notice. */ MSC2931Navigate = "org.matrix.msc2931.navigate", + MSC3846TurnServers = "town.robin.msc3846.turn_servers", } export type Capability = MatrixCapabilities | string; diff --git a/src/interfaces/TurnServerActions.ts b/src/interfaces/TurnServerActions.ts new file mode 100644 index 0000000..3a9bd29 --- /dev/null +++ b/src/interfaces/TurnServerActions.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { IWidgetApiRequest, IWidgetApiRequestData, IWidgetApiRequestEmptyData } from "./IWidgetApiRequest"; +import { WidgetApiFromWidgetAction, WidgetApiToWidgetAction } from "./WidgetApiAction"; +import { IWidgetApiAcknowledgeResponseData, IWidgetApiResponse } from "./IWidgetApiResponse"; + +export interface ITurnServer { + uris: string[]; + username: string; + password: string; +} + +export interface IWatchTurnServersRequest extends IWidgetApiRequest { + action: WidgetApiFromWidgetAction.WatchTurnServers; + data: IWidgetApiRequestEmptyData; +} + +export interface IWatchTurnServersResponse extends IWidgetApiResponse { + response: IWidgetApiAcknowledgeResponseData; +} + +export interface IUnwatchTurnServersRequest extends IWidgetApiRequest { + action: WidgetApiFromWidgetAction.UnwatchTurnServers; + data: IWidgetApiRequestEmptyData; +} + +export interface IUnwatchTurnServersResponse extends IWidgetApiResponse { + response: IWidgetApiAcknowledgeResponseData; +} + +export interface IUpdateTurnServersRequestData extends IWidgetApiRequestData, ITurnServer { +} + +export interface IUpdateTurnServersRequest extends IWidgetApiRequest { + action: WidgetApiToWidgetAction.UpdateTurnServers; + data: IUpdateTurnServersRequestData; +} + +export interface IUpdateTurnServersResponse extends IWidgetApiResponse { + response: IWidgetApiAcknowledgeResponseData; +} diff --git a/src/interfaces/WidgetApiAction.ts b/src/interfaces/WidgetApiAction.ts index a75a373..6236161 100644 --- a/src/interfaces/WidgetApiAction.ts +++ b/src/interfaces/WidgetApiAction.ts @@ -26,6 +26,7 @@ export enum WidgetApiToWidgetAction { ButtonClicked = "button_clicked", SendEvent = "send_event", SendToDevice = "send_to_device", + UpdateTurnServers = "update_turn_servers", } export enum WidgetApiFromWidgetAction { @@ -39,6 +40,8 @@ export enum WidgetApiFromWidgetAction { SetModalButtonEnabled = "set_button_enabled", SendEvent = "send_event", SendToDevice = "send_to_device", + WatchTurnServers = "watch_turn_servers", + UnwatchTurnServers = "unwatch_turn_servers", /** * @deprecated It is not recommended to rely on this existing - it can be removed without notice. diff --git a/tsconfig.json b/tsconfig.json index 3bf3770..bc933d3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,11 @@ "sourceMap": true, "outDir": "./lib", "declaration": true, - "types": [] + "types": [], + "lib": [ + "es2020", + "dom" + ] }, "include": [ "./src/**/*.ts"