Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Implement MSC3846: Allowing widgets to access TURN servers #9061

Merged
merged 24 commits into from
Aug 10, 2022
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3212410
Implement MSC3819: Allowing widgets to send/receive to-device messages
robintown Jun 23, 2022
4442c31
Merge branch 'develop' into to-device
robintown Jun 24, 2022
b1ee16b
Merge branch 'develop' into to-device
robintown Jul 13, 2022
1f2d0c5
Don't change the room events and state events drivers
robintown Jul 13, 2022
b3a4594
Merge branch 'develop' into to-device
robintown Jul 15, 2022
bf0e73b
Implement MSC3846: Allowing widgets to access TURN servers
robintown Jul 15, 2022
c753a2f
Update to latest matrix-widget-api changes
robintown Jul 15, 2022
3b02a45
Merge branch 'develop' into to-device
robintown Jul 28, 2022
6e5b719
Merge branch 'develop' into turn-servers
robintown Jul 28, 2022
4c2a0d0
Support sending encrypted to-device messages
robintown Jul 28, 2022
f642ae4
Yield a TURN server immediately
robintown Jul 28, 2022
c4e63f3
Merge branch 'develop' into to-device
robintown Aug 3, 2022
e055512
Use queueToDevice for better reliability
robintown Aug 3, 2022
ceb7fc0
Update types for latest WidgetDriver changes
robintown Aug 3, 2022
15789d8
Upgrade matrix-widget-api
robintown Aug 4, 2022
95c426a
Add tests
robintown Aug 4, 2022
de37bae
Merge branch 'develop' into to-device
robintown Aug 4, 2022
e9b5528
Test StopGapWidget
robintown Aug 5, 2022
c995b7c
Merge branch 'develop' into to-device
robintown Aug 5, 2022
24823ba
Fix a potential memory leak
robintown Aug 5, 2022
fe1b926
Merge branch 'to-device' into turn-servers
robintown Aug 5, 2022
4c85306
Add tests
robintown Aug 5, 2022
ce9cfab
Empty commit to retry CI
robintown Aug 5, 2022
9920442
Merge branch 'develop' into turn-servers
robintown Aug 10, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
"matrix-encrypt-attachment": "^1.0.3",
"matrix-events-sdk": "^0.0.1-beta.7",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
"matrix-widget-api": "^0.1.0-beta.18",
"matrix-widget-api": "^1.0.0",
"minimist": "^1.2.5",
"opus-recorder": "^8.0.3",
"pako": "^2.0.3",
Expand Down
37 changes: 24 additions & 13 deletions src/stores/widgets/StopGapWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
WidgetKind,
} from "matrix-widget-api";
import { EventEmitter } from "events";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event";
import { logger } from "matrix-js-sdk/src/logger";
import { ClientEvent } from "matrix-js-sdk/src/client";
Expand Down Expand Up @@ -148,6 +149,7 @@ export class ElementWidget extends Widget {
}

export class StopGapWidget extends EventEmitter {
private client: MatrixClient;
private messaging: ClientWidgetApi;
private mockWidget: ElementWidget;
private scalarToken: string;
Expand All @@ -157,12 +159,13 @@ export class StopGapWidget extends EventEmitter {

constructor(private appTileProps: IAppTileProps) {
super();
let app = appTileProps.app;
this.client = MatrixClientPeg.get();

let app = appTileProps.app;
// Backwards compatibility: not all old widgets have a creatorUserId
if (!app.creatorUserId) {
app = objectShallowClone(app); // clone to prevent accidental mutation
app.creatorUserId = MatrixClientPeg.get().getUserId();
app.creatorUserId = this.client.getUserId();
}

this.mockWidget = new ElementWidget(app);
Expand Down Expand Up @@ -203,7 +206,7 @@ export class StopGapWidget extends EventEmitter {
const fromCustomisation = WidgetVariableCustomisations?.provideVariables?.() ?? {};
const defaults: ITemplateParams = {
widgetRoomId: this.roomId,
currentUserId: MatrixClientPeg.get().getUserId(),
currentUserId: this.client.getUserId(),
userDisplayName: OwnProfileStore.instance.displayName,
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(),
clientId: ELEMENT_CLIENT_ID,
Expand Down Expand Up @@ -260,8 +263,10 @@ export class StopGapWidget extends EventEmitter {
*/
public startMessaging(iframe: HTMLIFrameElement): any {
if (this.started) return;

const allowedCapabilities = this.appTileProps.whitelistCapabilities || [];
const driver = new StopGapWidgetDriver(allowedCapabilities, this.mockWidget, this.kind, this.roomId);

this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver);
this.messaging.on("preparing", () => this.emit("preparing"));
this.messaging.on("ready", () => this.emit("ready"));
Expand Down Expand Up @@ -302,7 +307,7 @@ export class StopGapWidget extends EventEmitter {
// Populate the map of "read up to" events for this widget with the current event in every room.
// This is a bit inefficient, but should be okay. We do this for all rooms in case the widget
// requests timeline capabilities in other rooms down the road. It's just easier to manage here.
for (const room of MatrixClientPeg.get().getRooms()) {
for (const room of this.client.getRooms()) {
// Timelines are most recent last
const events = room.getLiveTimeline()?.getEvents() || [];
const roomEvent = events[events.length - 1];
Expand All @@ -311,8 +316,9 @@ export class StopGapWidget extends EventEmitter {
}

// Attach listeners for feeding events - the underlying widget classes handle permissions for us
MatrixClientPeg.get().on(ClientEvent.Event, this.onEvent);
MatrixClientPeg.get().on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
this.client.on(ClientEvent.Event, this.onEvent);
this.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);

this.messaging.on(`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`,
(ev: CustomEvent<IStickyActionRequest>) => {
Expand Down Expand Up @@ -363,7 +369,7 @@ export class StopGapWidget extends EventEmitter {

// noinspection JSIgnoredPromiseFromCall
IntegrationManagers.sharedInstance().getPrimaryManager().open(
MatrixClientPeg.get().getRoom(RoomViewStore.instance.getRoomId()),
this.client.getRoom(RoomViewStore.instance.getRoomId()),
`type_${integType}`,
integId,
);
Expand Down Expand Up @@ -428,14 +434,13 @@ export class StopGapWidget extends EventEmitter {
WidgetMessagingStore.instance.stopMessaging(this.mockWidget, this.roomId);
this.messaging = null;

if (MatrixClientPeg.get()) {
MatrixClientPeg.get().off(ClientEvent.Event, this.onEvent);
MatrixClientPeg.get().off(MatrixEventEvent.Decrypted, this.onEventDecrypted);
}
this.client.off(ClientEvent.Event, this.onEvent);
this.client.off(MatrixEventEvent.Decrypted, this.onEventDecrypted);
this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
}

private onEvent = (ev: MatrixEvent) => {
MatrixClientPeg.get().decryptEventIfNeeded(ev);
this.client.decryptEventIfNeeded(ev);
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
this.feedEvent(ev);
};
Expand All @@ -445,6 +450,12 @@ export class StopGapWidget extends EventEmitter {
this.feedEvent(ev);
};

private onToDeviceEvent = async (ev: MatrixEvent) => {
weeman1337 marked this conversation as resolved.
Show resolved Hide resolved
await this.client.decryptEventIfNeeded(ev);
if (ev.isDecryptionFailure()) return;
await this.messaging.feedToDevice(ev.getEffectiveEvent(), ev.isEncrypted());
};

private feedEvent(ev: MatrixEvent) {
if (!this.messaging) return;

Expand All @@ -465,7 +476,7 @@ export class StopGapWidget extends EventEmitter {

// Timelines are most recent last, so reverse the order and limit ourselves to 100 events
// to avoid overusing the CPU.
const timeline = MatrixClientPeg.get().getRoom(ev.getRoomId()).getLiveTimeline();
const timeline = this.client.getRoom(ev.getRoomId()).getLiveTimeline();
const events = arrayFastClone(timeline.getEvents()).reverse().slice(0, 100);

for (const timelineEvent of events) {
Expand Down
90 changes: 87 additions & 3 deletions src/stores/widgets/StopGapWidgetDriver.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2020 - 2021 The Matrix.org Foundation C.I.C.
* Copyright 2020 - 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.
Expand All @@ -20,6 +20,8 @@ import {
IOpenIDCredentials,
IOpenIDUpdate,
ISendEventDetails,
ITurnServer,
IRoomEvent,
MatrixCapabilities,
OpenIDRequestState,
SimpleObservable,
Expand All @@ -29,6 +31,7 @@ import {
WidgetEventCapability,
WidgetKind,
} from "matrix-widget-api";
import { ClientEvent, ITurnServer as IClientTurnServer } from "matrix-js-sdk/src/client";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { IContent, IEvent, MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room";
Expand Down Expand Up @@ -61,6 +64,12 @@ function setRememberedCapabilitiesForWidget(widget: Widget, caps: Capability[])
localStorage.setItem(`widget_${widget.id}_approved_caps`, JSON.stringify(caps));
}

const normalizeTurnServer = ({ urls, username, credential }: IClientTurnServer): ITurnServer => ({
uris: urls,
username,
password: credential,
});

export class StopGapWidgetDriver extends WidgetDriver {
private allowedCapabilities: Set<Capability>;

Expand Down Expand Up @@ -182,6 +191,49 @@ export class StopGapWidgetDriver extends WidgetDriver {
return { roomId, eventId: r.event_id };
}

public async sendToDevice(
eventType: string,
encrypted: boolean,
contentMap: { [userId: string]: { [deviceId: string]: object } },
): Promise<void> {
const client = MatrixClientPeg.get();

if (encrypted) {
const deviceInfoMap = await client.crypto.deviceList.downloadKeys(Object.keys(contentMap), false);

await Promise.all(
Object.entries(contentMap).flatMap(([userId, userContentMap]) =>
Object.entries(userContentMap).map(async ([deviceId, content]) => {
if (deviceId === "*") {
// Send the message to all devices we have keys for
await client.encryptAndSendToDevices(
Object.values(deviceInfoMap[userId]).map(deviceInfo => ({
userId, deviceInfo,
})),
content,
);
} else {
// Send the message to a specific device
await client.encryptAndSendToDevices(
[{ userId, deviceInfo: deviceInfoMap[userId][deviceId] }],
content,
);
}
}),
),
);
} else {
await client.queueToDevice({
eventType,
batch: Object.entries(contentMap).flatMap(([userId, userContentMap]) =>
Object.entries(userContentMap).map(([deviceId, content]) =>
({ userId, deviceId, payload: content }),
),
),
});
}
}

private pickRooms(roomIds: (string | Symbols.AnyRoom)[] = null): Room[] {
const client = MatrixClientPeg.get();
if (!client) throw new Error("Not attached to a client");
Expand All @@ -197,7 +249,7 @@ export class StopGapWidgetDriver extends WidgetDriver {
msgtype: string | undefined,
limitPerRoom: number,
roomIds: (string | Symbols.AnyRoom)[] = null,
): Promise<object[]> {
): Promise<IRoomEvent[]> {
limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary

const rooms = this.pickRooms(roomIds);
Expand All @@ -224,7 +276,7 @@ export class StopGapWidgetDriver extends WidgetDriver {
stateKey: string | undefined,
limitPerRoom: number,
roomIds: (string | Symbols.AnyRoom)[] = null,
): Promise<object[]> {
): Promise<IRoomEvent[]> {
limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary

const rooms = this.pickRooms(roomIds);
Expand Down Expand Up @@ -282,4 +334,36 @@ export class StopGapWidgetDriver extends WidgetDriver {
public async navigate(uri: string): Promise<void> {
navigateToPermalink(uri);
}

public async* getTurnServers(): AsyncGenerator<ITurnServer> {
const client = MatrixClientPeg.get();
if (!client.pollingTurnServers || !client.getTurnServers().length) return;

let setTurnServer: (server: ITurnServer) => void;
let setError: (error: Error) => void;

const onTurnServers = ([server]: IClientTurnServer[]) => setTurnServer(normalizeTurnServer(server));
const onTurnServersError = (error: Error, fatal: boolean) => { if (fatal) setError(error); };

client.on(ClientEvent.TurnServers, onTurnServers);
client.on(ClientEvent.TurnServersError, onTurnServersError);

try {
const initialTurnServer = client.getTurnServers()[0];
yield normalizeTurnServer(initialTurnServer);

// Repeatedly listen for new TURN servers until an error occurs or
// the caller stops this generator
while (true) {
yield await new Promise<ITurnServer>((resolve, reject) => {
setTurnServer = resolve;
setError = reject;
});
}
} finally {
// The loop was broken - clean up
client.off(ClientEvent.TurnServers, onTurnServers);
client.off(ClientEvent.TurnServersError, onTurnServersError);
}
}
}
9 changes: 6 additions & 3 deletions src/widgets/CapabilityText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ limitations under the License.
import {
Capability,
EventDirection,
EventKind,
getTimelineRoomIDFromCapability,
isTimelineCapability,
isTimelineCapabilityFor,
Expand Down Expand Up @@ -134,7 +135,7 @@ export class CapabilityText {
};

private static bylineFor(eventCap: WidgetEventCapability): TranslatedString {
if (eventCap.isState) {
if (eventCap.kind === EventKind.State) {
return !eventCap.keyStr
? _t("with an empty state key")
: _t("with state key %(stateKey)s", { stateKey: eventCap.keyStr });
Expand All @@ -143,6 +144,8 @@ export class CapabilityText {
}

public static for(capability: Capability, kind: WidgetKind): TranslatedCapabilityText {
// TODO: Support MSC3819 (to-device capabilities)

// First see if we have a super simple line of text to provide back
if (CapabilityText.simpleCaps[capability]) {
const textForKind = CapabilityText.simpleCaps[capability];
Expand Down Expand Up @@ -184,13 +187,13 @@ export class CapabilityText {
// Special case room messages so they show up a bit cleaner to the user. Result is
// effectively "Send images" instead of "Send messages... of type images" if we were
// to handle the msgtype nuances in this function.
if (!eventCap.isState && eventCap.eventType === EventType.RoomMessage) {
if (eventCap.kind === EventKind.Event && eventCap.eventType === EventType.RoomMessage) {
return CapabilityText.forRoomMessageCap(eventCap, kind);
}

// See if we have a static line of text to provide for the given event type and
// direction. The hope is that we do for common event types for friendlier copy.
const evSendRecv = eventCap.isState
const evSendRecv = eventCap.kind === EventKind.State
? CapabilityText.stateSendRecvCaps
: CapabilityText.nonStateSendRecvCaps;
if (evSendRecv[eventCap.eventType]) {
Expand Down
70 changes: 70 additions & 0 deletions test/stores/widgets/StopGapWidget-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
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 { mocked, MockedObject } from "jest-mock";
import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client";
import { ClientWidgetApi } from "matrix-widget-api";

import { stubClient, mkRoom, mkEvent } from "../../test-utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { StopGapWidget } from "../../../src/stores/widgets/StopGapWidget";

jest.mock("matrix-widget-api/lib/ClientWidgetApi");

describe("StopGapWidget", () => {
let client: MockedObject<MatrixClient>;
let widget: StopGapWidget;
let messaging: MockedObject<ClientWidgetApi>;

beforeEach(() => {
stubClient();
client = mocked(MatrixClientPeg.get());

widget = new StopGapWidget({
app: {
id: "test",
creatorUserId: "@alice:example.org",
type: "example",
url: "https://example.org",
},
room: mkRoom(client, "!1:example.org"),
userId: "@alice:example.org",
creatorUserId: "@alice:example.org",
waitForIframeLoad: true,
userWidget: false,
});
// Start messaging without an iframe, since ClientWidgetApi is mocked
widget.startMessaging(null as unknown as HTMLIFrameElement);
messaging = mocked(mocked(ClientWidgetApi).mock.instances[0]);
});

afterEach(() => {
widget.stopMessaging();
});

it("feeds incoming to-device messages to the widget", async () => {
const event = mkEvent({
event: true,
type: "org.example.foo",
user: "@alice:example.org",
content: { hello: "world" },
});

client.emit(ClientEvent.ToDeviceEvent, event);
await Promise.resolve(); // flush promises
expect(messaging.feedToDevice).toHaveBeenCalledWith(event.getEffectiveEvent(), false);
});
});
Loading