Skip to content

Commit 3334c01

Browse files
authored
Support nested Matrix clients via the widget API (#2473)
* WIP RoomWidgetClient * Wait for the widget API to become ready before backfilling * Add support for sending user-defined encrypted to-device messages This is a port of the same change from the robertlong/group-call branch. * Fix tests * Emit an event when the client receives TURN servers * Expose the method in MatrixClient * Override the encryptAndSendToDevices method * Add support for TURN servers in embedded mode and make calls mostly work * Don't put unclonable objects into VoIP events RoomWidget clients were unable to send m.call.candidate events, because the candidate objects were not clonable for use with postMessage. Converting such objects to their canonical JSON form before attempting to send them over the wire solves this. * Fix types * Fix more types * Fix lint * Upgrade matrix-widget-api * Save lockfile * Untangle dependencies to fix tests * Add some preliminary tests * Fix tests * Fix indirect export * Add more tests * Resolve TODOs * Add queueToDevice to RoomWidgetClient
1 parent 0b8de25 commit 3334c01

19 files changed

+1057
-184
lines changed

package.json

+2
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"content-type": "^1.0.4",
6262
"loglevel": "^1.7.1",
6363
"matrix-events-sdk": "^0.0.1-beta.7",
64+
"matrix-widget-api": "^1.0.0",
6465
"p-retry": "4",
6566
"qs": "^6.9.6",
6667
"request": "^2.88.2",
@@ -101,6 +102,7 @@
101102
"exorcist": "^2.0.0",
102103
"fake-indexeddb": "^4.0.0",
103104
"jest": "^28.0.0",
105+
"jest-environment-jsdom": "^28.1.3",
104106
"jest-localstorage-mock": "^2.4.6",
105107
"jest-sonar-reporter": "^2.0.0",
106108
"jsdoc": "^3.6.6",

spec/unit/embedded.spec.ts

+264
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
5+
/*
6+
Copyright 2022 The Matrix.org Foundation C.I.C.
7+
8+
Licensed under the Apache License, Version 2.0 (the "License");
9+
you may not use this file except in compliance with the License.
10+
You may obtain a copy of the License at
11+
12+
http://www.apache.org/licenses/LICENSE-2.0
13+
14+
Unless required by applicable law or agreed to in writing, software
15+
distributed under the License is distributed on an "AS IS" BASIS,
16+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17+
See the License for the specific language governing permissions and
18+
limitations under the License.
19+
*/
20+
21+
// We have to use EventEmitter here to mock part of the matrix-widget-api
22+
// project, which doesn't know about our TypeEventEmitter implementation at all
23+
// eslint-disable-next-line no-restricted-imports
24+
import { EventEmitter } from "events";
25+
import { MockedObject } from "jest-mock";
26+
import {
27+
WidgetApi,
28+
WidgetApiToWidgetAction,
29+
MatrixCapabilities,
30+
ITurnServer,
31+
} from "matrix-widget-api";
32+
33+
import { createRoomWidgetClient } from "../../src/matrix";
34+
import { MatrixClient, ClientEvent, ITurnServer as IClientTurnServer } from "../../src/client";
35+
import { SyncState } from "../../src/sync";
36+
import { ICapabilities } from "../../src/embedded";
37+
import { MatrixEvent } from "../../src/models/event";
38+
import { ToDeviceBatch } from "../../src/models/ToDeviceMessage";
39+
import { DeviceInfo } from "../../src/crypto/deviceinfo";
40+
41+
class MockWidgetApi extends EventEmitter {
42+
public start = jest.fn();
43+
public requestCapability = jest.fn();
44+
public requestCapabilities = jest.fn();
45+
public requestCapabilityToSendState = jest.fn();
46+
public requestCapabilityToReceiveState = jest.fn();
47+
public requestCapabilityToSendToDevice = jest.fn();
48+
public requestCapabilityToReceiveToDevice = jest.fn();
49+
public sendStateEvent = jest.fn();
50+
public sendToDevice = jest.fn();
51+
public readStateEvents = jest.fn(() => []);
52+
public getTurnServers = jest.fn(() => []);
53+
54+
public transport = { reply: jest.fn() };
55+
}
56+
57+
describe("RoomWidgetClient", () => {
58+
let widgetApi: MockedObject<WidgetApi>;
59+
let client: MatrixClient;
60+
61+
beforeEach(() => {
62+
widgetApi = new MockWidgetApi() as unknown as MockedObject<WidgetApi>;
63+
});
64+
65+
afterEach(() => {
66+
client.stopClient();
67+
});
68+
69+
const makeClient = async (capabilities: ICapabilities): Promise<void> => {
70+
const baseUrl = "https://example.org";
71+
client = createRoomWidgetClient(widgetApi, capabilities, "!1:example.org", { baseUrl });
72+
expect(widgetApi.start).toHaveBeenCalled(); // needs to have been called early in order to not miss messages
73+
widgetApi.emit("ready");
74+
await client.startClient();
75+
};
76+
77+
describe("state events", () => {
78+
const event = new MatrixEvent({
79+
type: "org.example.foo",
80+
event_id: "$sfkjfsksdkfsd",
81+
room_id: "!1:example.org",
82+
sender: "@alice:example.org",
83+
state_key: "bar",
84+
content: { hello: "world" },
85+
}).getEffectiveEvent();
86+
87+
it("sends", async () => {
88+
await makeClient({ sendState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
89+
expect(widgetApi.requestCapabilityToSendState).toHaveBeenCalledWith("org.example.foo", "bar");
90+
await client.sendStateEvent("!1:example.org", "org.example.foo", { hello: "world" }, "bar");
91+
expect(widgetApi.sendStateEvent).toHaveBeenCalledWith("org.example.foo", "bar", { hello: "world" });
92+
});
93+
94+
it("refuses to send to other rooms", async () => {
95+
await makeClient({ sendState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
96+
expect(widgetApi.requestCapabilityToSendState).toHaveBeenCalledWith("org.example.foo", "bar");
97+
await expect(client.sendStateEvent("!2:example.org", "org.example.foo", { hello: "world" }, "bar"))
98+
.rejects.toBeDefined();
99+
});
100+
101+
it("receives", async () => {
102+
await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
103+
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
104+
105+
const emittedEvent = new Promise<MatrixEvent>(resolve => client.once(ClientEvent.Event, resolve));
106+
const emittedSync = new Promise<SyncState>(resolve => client.once(ClientEvent.Sync, resolve));
107+
widgetApi.emit(
108+
`action:${WidgetApiToWidgetAction.SendEvent}`,
109+
new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, { detail: { data: event } }),
110+
);
111+
112+
// The client should've emitted about the received event
113+
expect((await emittedEvent).getEffectiveEvent()).toEqual(event);
114+
expect(await emittedSync).toEqual(SyncState.Syncing);
115+
// It should've also inserted the event into the room object
116+
const room = client.getRoom("!1:example.org");
117+
expect(room.currentState.getStateEvents("org.example.foo", "bar").getEffectiveEvent()).toEqual(event);
118+
});
119+
120+
it("backfills", async () => {
121+
widgetApi.readStateEvents.mockImplementation(async (eventType, limit, stateKey) =>
122+
eventType === "org.example.foo" && (limit ?? Infinity) > 0 && stateKey === "bar"
123+
? [event]
124+
: [],
125+
);
126+
127+
await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
128+
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
129+
130+
const room = client.getRoom("!1:example.org");
131+
expect(room.currentState.getStateEvents("org.example.foo", "bar").getEffectiveEvent()).toEqual(event);
132+
});
133+
});
134+
135+
describe("to-device messages", () => {
136+
const unencryptedContentMap = {
137+
"@alice:example.org": { "*": { hello: "alice!" } },
138+
"@bob:example.org": { bobDesktop: { hello: "bob!" } },
139+
};
140+
141+
it("sends unencrypted (sendToDevice)", async () => {
142+
await makeClient({ sendToDevice: ["org.example.foo"] });
143+
expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("org.example.foo");
144+
145+
await client.sendToDevice("org.example.foo", unencryptedContentMap);
146+
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, unencryptedContentMap);
147+
});
148+
149+
it("sends unencrypted (queueToDevice)", async () => {
150+
await makeClient({ sendToDevice: ["org.example.foo"] });
151+
expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("org.example.foo");
152+
153+
const batch: ToDeviceBatch = {
154+
eventType: "org.example.foo",
155+
batch: [
156+
{ userId: "@alice:example.org", deviceId: "*", payload: { hello: "alice!" } },
157+
{ userId: "@bob:example.org", deviceId: "bobDesktop", payload: { hello: "bob!" } },
158+
],
159+
};
160+
await client.queueToDevice(batch);
161+
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, unencryptedContentMap);
162+
});
163+
164+
it("sends encrypted (encryptAndSendToDevices)", async () => {
165+
await makeClient({ sendToDevice: ["org.example.foo"] });
166+
expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("org.example.foo");
167+
168+
const payload = { type: "org.example.foo", hello: "world" };
169+
await client.encryptAndSendToDevices(
170+
[
171+
{ userId: "@alice:example.org", deviceInfo: new DeviceInfo("aliceWeb") },
172+
{ userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobDesktop") },
173+
],
174+
payload,
175+
);
176+
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", true, {
177+
"@alice:example.org": { aliceWeb: payload },
178+
"@bob:example.org": { bobDesktop: payload },
179+
});
180+
});
181+
182+
it.each([
183+
{ encrypted: false, title: "unencrypted" },
184+
{ encrypted: true, title: "encrypted" },
185+
])("receives $title", async ({ encrypted }) => {
186+
await makeClient({ receiveToDevice: ["org.example.foo"] });
187+
expect(widgetApi.requestCapabilityToReceiveToDevice).toHaveBeenCalledWith("org.example.foo");
188+
189+
const event = {
190+
type: "org.example.foo",
191+
sender: "@alice:example.org",
192+
encrypted,
193+
content: { hello: "world" },
194+
};
195+
196+
const emittedEvent = new Promise<MatrixEvent>(resolve => client.once(ClientEvent.ToDeviceEvent, resolve));
197+
const emittedSync = new Promise<SyncState>(resolve => client.once(ClientEvent.Sync, resolve));
198+
widgetApi.emit(
199+
`action:${WidgetApiToWidgetAction.SendToDevice}`,
200+
new CustomEvent(`action:${WidgetApiToWidgetAction.SendToDevice}`, { detail: { data: event } }),
201+
);
202+
203+
expect((await emittedEvent).getEffectiveEvent()).toEqual({
204+
type: event.type,
205+
sender: event.sender,
206+
content: event.content,
207+
});
208+
expect((await emittedEvent).isEncrypted()).toEqual(encrypted);
209+
expect(await emittedSync).toEqual(SyncState.Syncing);
210+
});
211+
});
212+
213+
it("gets TURN servers", async () => {
214+
const server1: ITurnServer = {
215+
uris: [
216+
"turn:turn.example.com:3478?transport=udp",
217+
"turn:10.20.30.40:3478?transport=tcp",
218+
"turns:10.20.30.40:443?transport=tcp",
219+
],
220+
username: "1443779631:@user:example.com",
221+
password: "JlKfBy1QwLrO20385QyAtEyIv0=",
222+
};
223+
const server2: ITurnServer = {
224+
uris: [
225+
"turn:turn.example.com:3478?transport=udp",
226+
"turn:10.20.30.40:3478?transport=tcp",
227+
"turns:10.20.30.40:443?transport=tcp",
228+
],
229+
username: "1448999322:@user:example.com",
230+
password: "hunter2",
231+
};
232+
const clientServer1: IClientTurnServer = {
233+
urls: server1.uris,
234+
username: server1.username,
235+
credential: server1.password,
236+
};
237+
const clientServer2: IClientTurnServer = {
238+
urls: server2.uris,
239+
username: server2.username,
240+
credential: server2.password,
241+
};
242+
243+
let emitServer2: () => void;
244+
const getServer2 = new Promise<ITurnServer>(resolve => emitServer2 = () => resolve(server2));
245+
widgetApi.getTurnServers.mockImplementation(async function* () {
246+
yield server1;
247+
yield await getServer2;
248+
});
249+
250+
await makeClient({ turnServers: true });
251+
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC3846TurnServers);
252+
253+
// The first server should've arrived immediately
254+
expect(client.getTurnServers()).toEqual([clientServer1]);
255+
256+
// Subsequent servers arrive asynchronously and should emit an event
257+
const emittedServer = new Promise<IClientTurnServer[]>(resolve =>
258+
client.once(ClientEvent.TurnServers, resolve),
259+
);
260+
emitServer2();
261+
expect(await emittedServer).toEqual([clientServer2]);
262+
expect(client.getTurnServers()).toEqual([clientServer2]);
263+
});
264+
});

src/ToDeviceMessageQueue.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ limitations under the License.
1515
*/
1616

1717
import { logger } from "./logger";
18-
import { MatrixClient } from "./matrix";
18+
import { MatrixClient } from "./client";
1919
import { IndexedToDeviceBatch, ToDeviceBatch, ToDeviceBatchWithTxnId, ToDevicePayload } from "./models/ToDeviceMessage";
2020
import { MatrixScheduler } from "./scheduler";
2121

src/client.ts

+21-25
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import {
7272
CryptoEvent,
7373
CryptoEventHandlerMap,
7474
fixBackupKey,
75+
ICryptoCallbacks,
7576
IBootstrapCrossSigningOpts,
7677
ICheckOwnCrossSigningTrustOpts,
7778
IMegolmSessionData,
@@ -101,29 +102,9 @@ import {
101102
} from "./crypto/keybackup";
102103
import { IIdentityServerProvider } from "./@types/IIdentityServerProvider";
103104
import { MatrixScheduler } from "./scheduler";
104-
import {
105-
IAuthData,
106-
ICryptoCallbacks,
107-
IMinimalEvent,
108-
IRoomEvent,
109-
IStateEvent,
110-
NotificationCountType,
111-
BeaconEvent,
112-
BeaconEventHandlerMap,
113-
RoomEvent,
114-
RoomEventHandlerMap,
115-
RoomMemberEvent,
116-
RoomMemberEventHandlerMap,
117-
RoomStateEvent,
118-
RoomStateEventHandlerMap,
119-
INotificationsResponse,
120-
IFilterResponse,
121-
ITagsResponse,
122-
IStatusResponse,
123-
IPushRule,
124-
PushRuleActionName,
125-
IAuthDict,
126-
} from "./matrix";
105+
import { BeaconEvent, BeaconEventHandlerMap } from "./models/beacon";
106+
import { IAuthData, IAuthDict } from "./interactive-auth";
107+
import { IMinimalEvent, IRoomEvent, IStateEvent } from "./sync-accumulator";
127108
import {
128109
CrossSigningKey,
129110
IAddSecretStorageKeyOpts,
@@ -138,7 +119,9 @@ import { VerificationRequest } from "./crypto/verification/request/VerificationR
138119
import { VerificationBase as Verification } from "./crypto/verification/Base";
139120
import * as ContentHelpers from "./content-helpers";
140121
import { CrossSigningInfo, DeviceTrustLevel, ICacheCallbacks, UserTrustLevel } from "./crypto/CrossSigning";
141-
import { Room } from "./models/room";
122+
import { Room, NotificationCountType, RoomEvent, RoomEventHandlerMap } from "./models/room";
123+
import { RoomMemberEvent, RoomMemberEventHandlerMap } from "./models/room-member";
124+
import { RoomStateEvent, RoomStateEventHandlerMap } from "./models/room-state";
142125
import {
143126
IAddThreePidOnlyBody,
144127
IBindThreePidBody,
@@ -156,6 +139,10 @@ import {
156139
ISearchOpts,
157140
ISendEventResponse,
158141
IUploadOpts,
142+
INotificationsResponse,
143+
IFilterResponse,
144+
ITagsResponse,
145+
IStatusResponse,
159146
} from "./@types/requests";
160147
import {
161148
EventType,
@@ -185,7 +172,16 @@ import {
185172
} from "./@types/search";
186173
import { ISynapseAdminDeactivateResponse, ISynapseAdminWhoisResponse } from "./@types/synapse";
187174
import { IHierarchyRoom } from "./@types/spaces";
188-
import { IPusher, IPusherRequest, IPushRules, PushRuleAction, PushRuleKind, RuleId } from "./@types/PushRules";
175+
import {
176+
IPusher,
177+
IPusherRequest,
178+
IPushRule,
179+
IPushRules,
180+
PushRuleAction,
181+
PushRuleActionName,
182+
PushRuleKind,
183+
RuleId,
184+
} from "./@types/PushRules";
189185
import { IThreepid } from "./@types/threepids";
190186
import { CryptoStore } from "./crypto/store/base";
191187
import {

src/crypto/CrossSigning.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import { DeviceInfo } from "./deviceinfo";
2929
import { SecretStorage } from "./SecretStorage";
3030
import { ICrossSigningKey, ISignedKey, MatrixClient } from "../client";
3131
import { OlmDevice } from "./OlmDevice";
32-
import { ICryptoCallbacks } from "../matrix";
32+
import { ICryptoCallbacks } from ".";
3333
import { ISignatures } from "../@types/signed";
3434
import { CryptoStore } from "./store/base";
3535
import { ISecretStorageKeyInfo } from "./api";

src/crypto/EncryptionSetup.ts

+3-4
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,15 @@ import { MatrixEvent } from "../models/event";
1919
import { createCryptoStoreCacheCallbacks, ICacheCallbacks } from "./CrossSigning";
2020
import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store';
2121
import { Method, PREFIX_UNSTABLE } from "../http-api";
22-
import { Crypto, IBootstrapCrossSigningOpts } from "./index";
22+
import { Crypto, ICryptoCallbacks, IBootstrapCrossSigningOpts } from "./index";
2323
import {
2424
ClientEvent,
25-
CrossSigningKeys,
2625
ClientEventHandlerMap,
26+
CrossSigningKeys,
2727
ICrossSigningKey,
28-
ICryptoCallbacks,
2928
ISignedKey,
3029
KeySignatures,
31-
} from "../matrix";
30+
} from "../client";
3231
import { ISecretStorageKeyInfo } from "./api";
3332
import { IKeyBackupInfo } from "./keybackup";
3433
import { TypedEventEmitter } from "../models/typed-event-emitter";

0 commit comments

Comments
 (0)