@vex-chat/libvex 7.2.0 → 7.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Client.d.ts +27 -3
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +653 -190
- package/dist/Client.js.map +1 -1
- package/package.json +3 -3
- package/src/Client.ts +845 -228
- package/src/__tests__/harness/shared-suite.ts +80 -1
package/src/Client.ts
CHANGED
|
@@ -15,8 +15,9 @@ import type {
|
|
|
15
15
|
import type { KeyPair } from "@vex-chat/crypto";
|
|
16
16
|
import type {
|
|
17
17
|
ActionToken,
|
|
18
|
+
CallAction,
|
|
19
|
+
CallEnvelopeBody,
|
|
18
20
|
CallEvent,
|
|
19
|
-
CallResourceData,
|
|
20
21
|
CallSession,
|
|
21
22
|
CallSignalPayload,
|
|
22
23
|
ChallMsg,
|
|
@@ -29,6 +30,7 @@ import type {
|
|
|
29
30
|
IceServerConfig,
|
|
30
31
|
Invite,
|
|
31
32
|
KeyBundle,
|
|
33
|
+
MailNotificationHint,
|
|
32
34
|
MailWS,
|
|
33
35
|
NotifyMsg,
|
|
34
36
|
Passkey,
|
|
@@ -42,6 +44,7 @@ import type {
|
|
|
42
44
|
Server,
|
|
43
45
|
ServerChannelBootstrap,
|
|
44
46
|
SessionSQL,
|
|
47
|
+
SignedCallEnvelope,
|
|
45
48
|
} from "@vex-chat/types";
|
|
46
49
|
import type { ClientMessage } from "@vex-chat/types";
|
|
47
50
|
|
|
@@ -75,12 +78,13 @@ import {
|
|
|
75
78
|
XUtils,
|
|
76
79
|
} from "@vex-chat/crypto";
|
|
77
80
|
import {
|
|
81
|
+
CallEnvelopeBodySchema,
|
|
78
82
|
CallEventSchema,
|
|
79
|
-
CallSessionSchema,
|
|
80
83
|
IceServerConfigSchema,
|
|
81
84
|
MailType,
|
|
82
85
|
MailWSSchema,
|
|
83
86
|
PermissionSchema,
|
|
87
|
+
SignedCallEnvelopeSchema,
|
|
84
88
|
WSMessageSchema,
|
|
85
89
|
} from "@vex-chat/types";
|
|
86
90
|
|
|
@@ -198,16 +202,6 @@ function debugLibvexDm(
|
|
|
198
202
|
console.error(`[libvex:debug-dm] ${payload}`);
|
|
199
203
|
}
|
|
200
204
|
|
|
201
|
-
function errorFromUnknown(err: unknown): Error {
|
|
202
|
-
if (err instanceof Error) {
|
|
203
|
-
return err;
|
|
204
|
-
}
|
|
205
|
-
if (typeof err === "string") {
|
|
206
|
-
return new Error(err);
|
|
207
|
-
}
|
|
208
|
-
return new Error(JSON.stringify(err));
|
|
209
|
-
}
|
|
210
|
-
|
|
211
205
|
function ignoreSocketTeardown(err: unknown): void {
|
|
212
206
|
if (err instanceof WebSocketNotOpenError) return;
|
|
213
207
|
// Re-throw anything else as a real unhandled rejection so it
|
|
@@ -730,7 +724,7 @@ export type { Server } from "@vex-chat/types";
|
|
|
730
724
|
export type { ServerChannelBootstrap } from "@vex-chat/types";
|
|
731
725
|
|
|
732
726
|
export interface NotificationSubscription {
|
|
733
|
-
channel:
|
|
727
|
+
channel: NotificationSubscriptionChannel;
|
|
734
728
|
createdAt: string;
|
|
735
729
|
deviceID: string;
|
|
736
730
|
enabled: boolean;
|
|
@@ -742,9 +736,17 @@ export interface NotificationSubscription {
|
|
|
742
736
|
userID: string;
|
|
743
737
|
}
|
|
744
738
|
|
|
739
|
+
export type NotificationSubscriptionChannel = "apnsVoip" | "expo" | "fcmCall";
|
|
740
|
+
|
|
741
|
+
const NotificationSubscriptionChannelSchema = z.enum([
|
|
742
|
+
"apnsVoip",
|
|
743
|
+
"expo",
|
|
744
|
+
"fcmCall",
|
|
745
|
+
]);
|
|
746
|
+
|
|
745
747
|
const NotificationSubscriptionSchema: z.ZodType<NotificationSubscription> =
|
|
746
748
|
z.object({
|
|
747
|
-
channel:
|
|
749
|
+
channel: NotificationSubscriptionChannelSchema,
|
|
748
750
|
createdAt: z.string(),
|
|
749
751
|
deviceID: z.string(),
|
|
750
752
|
enabled: z.boolean(),
|
|
@@ -757,7 +759,7 @@ const NotificationSubscriptionSchema: z.ZodType<NotificationSubscription> =
|
|
|
757
759
|
});
|
|
758
760
|
|
|
759
761
|
export interface NotificationSubscriptionInput {
|
|
760
|
-
channel:
|
|
762
|
+
channel: NotificationSubscriptionChannel;
|
|
761
763
|
events?: string[];
|
|
762
764
|
platform?: "android" | "ios" | "web";
|
|
763
765
|
token: string;
|
|
@@ -939,17 +941,35 @@ const messageSchema: z.ZodType<Message> = z.object({
|
|
|
939
941
|
timestamp: z.string(),
|
|
940
942
|
});
|
|
941
943
|
|
|
944
|
+
const CALL_ENVELOPE_PREFIX = "vex-call:1\n";
|
|
945
|
+
const CALL_INVITE_TTL_MS = 60_000;
|
|
946
|
+
const CALL_MAX_TTL_MS = 2 * 60 * 60 * 1000;
|
|
942
947
|
const MESSAGE_BLOB_PREFIX = "vex-message:1\n";
|
|
943
948
|
const MAIL_FANOUT_CONCURRENCY = 8;
|
|
944
949
|
const MAIL_BATCH_MAX_SIZE = 32;
|
|
945
950
|
const MAIL_BATCH_FLUSH_DELAY_MS = 8;
|
|
946
951
|
|
|
952
|
+
interface CallWakeNotifyData {
|
|
953
|
+
callID: string;
|
|
954
|
+
expiresAt?: string | undefined;
|
|
955
|
+
mailID?: string | undefined;
|
|
956
|
+
mailNonce?: string | undefined;
|
|
957
|
+
}
|
|
958
|
+
|
|
947
959
|
interface DecodedMessagePlaintext {
|
|
948
960
|
extra?: null | string | undefined;
|
|
949
961
|
message: string;
|
|
950
962
|
retentionHintDays?: number | undefined;
|
|
951
963
|
}
|
|
952
964
|
|
|
965
|
+
interface EncryptedCallState {
|
|
966
|
+
peerDeviceID?: string | undefined;
|
|
967
|
+
peerUserID: string;
|
|
968
|
+
pendingPeerDevices: Device[];
|
|
969
|
+
sequence: number;
|
|
970
|
+
session: CallSession;
|
|
971
|
+
}
|
|
972
|
+
|
|
953
973
|
interface PendingMailBatchDelivery {
|
|
954
974
|
header: Uint8Array;
|
|
955
975
|
mail: MailWS;
|
|
@@ -971,6 +991,62 @@ const mailBatchResponseSchema = z.object({
|
|
|
971
991
|
),
|
|
972
992
|
});
|
|
973
993
|
|
|
994
|
+
const callWakeNotifyData = z.object({
|
|
995
|
+
callID: z.string(),
|
|
996
|
+
expiresAt: z.string().optional(),
|
|
997
|
+
mailID: z.string().optional(),
|
|
998
|
+
mailNonce: z.string().optional(),
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
function canonicalizeJson(value: unknown): unknown {
|
|
1002
|
+
if (Array.isArray(value)) {
|
|
1003
|
+
return value.map((item) => canonicalizeJson(item));
|
|
1004
|
+
}
|
|
1005
|
+
if (!isRecord(value)) {
|
|
1006
|
+
return value;
|
|
1007
|
+
}
|
|
1008
|
+
const out: Record<string, unknown> = {};
|
|
1009
|
+
for (const key of Object.keys(value).sort()) {
|
|
1010
|
+
const item = value[key];
|
|
1011
|
+
if (item !== undefined) {
|
|
1012
|
+
out[key] = canonicalizeJson(item);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
return out;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function canonicalJsonBytes(value: unknown): Uint8Array {
|
|
1019
|
+
return XUtils.decodeUTF8(
|
|
1020
|
+
JSON.stringify(canonicalizeJson(jsonWireValue(value))),
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function cloneCallSession(session: CallSession): CallSession {
|
|
1025
|
+
return {
|
|
1026
|
+
...session,
|
|
1027
|
+
participants: session.participants.map((participant) => ({
|
|
1028
|
+
...participant,
|
|
1029
|
+
})),
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function decodeCallEnvelopePlaintext(
|
|
1034
|
+
plaintext: string,
|
|
1035
|
+
): null | SignedCallEnvelope {
|
|
1036
|
+
if (!plaintext.startsWith(CALL_ENVELOPE_PREFIX)) {
|
|
1037
|
+
return null;
|
|
1038
|
+
}
|
|
1039
|
+
try {
|
|
1040
|
+
const raw = JSON.parse(
|
|
1041
|
+
plaintext.slice(CALL_ENVELOPE_PREFIX.length),
|
|
1042
|
+
) as unknown;
|
|
1043
|
+
const parsed = SignedCallEnvelopeSchema.safeParse(raw);
|
|
1044
|
+
return parsed.success ? parsed.data : null;
|
|
1045
|
+
} catch {
|
|
1046
|
+
return null;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
974
1050
|
function decodeMessageBlob(body: string): DecodedMessagePlaintext {
|
|
975
1051
|
if (!body.startsWith(MESSAGE_BLOB_PREFIX)) {
|
|
976
1052
|
return { message: body };
|
|
@@ -1008,6 +1084,10 @@ function decodeMessagePlaintext(plaintext: string): DecodedMessagePlaintext {
|
|
|
1008
1084
|
: blob;
|
|
1009
1085
|
}
|
|
1010
1086
|
|
|
1087
|
+
function encodeCallEnvelopePlaintext(envelope: SignedCallEnvelope): Uint8Array {
|
|
1088
|
+
return XUtils.decodeUTF8(CALL_ENVELOPE_PREFIX + JSON.stringify(envelope));
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1011
1091
|
function encodeMessagePlaintext(
|
|
1012
1092
|
message: string,
|
|
1013
1093
|
opts?: MessageSendOptions,
|
|
@@ -1023,6 +1103,13 @@ function encodeMessagePlaintext(
|
|
|
1023
1103
|
return formatVexRetentionEnvelope(body, opts?.retentionHintDays);
|
|
1024
1104
|
}
|
|
1025
1105
|
|
|
1106
|
+
function jsonWireValue(value: unknown): unknown {
|
|
1107
|
+
// Match the payload JSON.stringify will send, including RTC toJSON() output.
|
|
1108
|
+
const encoded = JSON.stringify(value);
|
|
1109
|
+
const parsed: unknown = JSON.parse(encoded);
|
|
1110
|
+
return parsed;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1026
1113
|
function messageFromDecodedPlaintext(
|
|
1027
1114
|
decoded: DecodedMessagePlaintext,
|
|
1028
1115
|
): Pick<Message, "extra" | "message" | "retentionHintDays"> {
|
|
@@ -1035,6 +1122,12 @@ function messageFromDecodedPlaintext(
|
|
|
1035
1122
|
};
|
|
1036
1123
|
}
|
|
1037
1124
|
|
|
1125
|
+
function normalizeCallEnvelopeBodyForWire(
|
|
1126
|
+
body: CallEnvelopeBody,
|
|
1127
|
+
): CallEnvelopeBody {
|
|
1128
|
+
return CallEnvelopeBodySchema.parse(jsonWireValue(body));
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1038
1131
|
function normalizeForwardedMessage(message: Message): Message {
|
|
1039
1132
|
const decoded = decodeMessagePlaintext(message.message);
|
|
1040
1133
|
return {
|
|
@@ -1076,6 +1169,8 @@ const retryRequestNotifyData = z.union([
|
|
|
1076
1169
|
export interface ClientEvents {
|
|
1077
1170
|
/** Voice-call signaling changed or an incoming call was received. */
|
|
1078
1171
|
call: (event: CallEvent) => void;
|
|
1172
|
+
/** Native/mobile call wake hint arrived; clients should sync call mail. */
|
|
1173
|
+
callWake: (wake: CallWakeNotifyData) => void;
|
|
1079
1174
|
/** The client has been shut down (via {@link Client.close}). */
|
|
1080
1175
|
closed: () => void;
|
|
1081
1176
|
/** WebSocket authorized by the server; pre-auth setup begins. */
|
|
@@ -1330,25 +1425,21 @@ export class Client {
|
|
|
1330
1425
|
*/
|
|
1331
1426
|
public calls: Calls = {
|
|
1332
1427
|
accept: (callID: string, signal?: CallSignalPayload) =>
|
|
1333
|
-
this.
|
|
1334
|
-
callID,
|
|
1335
|
-
...(signal ? { signal } : {}),
|
|
1336
|
-
}),
|
|
1428
|
+
this.sendEncryptedCallAction("accept", callID, signal),
|
|
1337
1429
|
active: this.fetchActiveCalls.bind(this),
|
|
1338
|
-
cancel: (callID: string) =>
|
|
1339
|
-
|
|
1430
|
+
cancel: (callID: string) =>
|
|
1431
|
+
this.sendEncryptedCallAction("cancel", callID),
|
|
1432
|
+
hangup: (callID: string) =>
|
|
1433
|
+
this.sendEncryptedCallAction("hangup", callID),
|
|
1340
1434
|
ice: (callID: string, signal: CallSignalPayload) =>
|
|
1341
|
-
this.
|
|
1435
|
+
this.sendEncryptedCallAction("ice", callID, signal),
|
|
1342
1436
|
iceServers: this.fetchIceServers.bind(this),
|
|
1343
|
-
reject: (callID: string) =>
|
|
1437
|
+
reject: (callID: string) =>
|
|
1438
|
+
this.sendEncryptedCallAction("reject", callID),
|
|
1344
1439
|
signal: (callID: string, signal: CallSignalPayload) =>
|
|
1345
|
-
this.
|
|
1440
|
+
this.sendEncryptedCallAction("signal", callID, signal),
|
|
1346
1441
|
startDM: (recipientUserID: string, signal?: CallSignalPayload) =>
|
|
1347
|
-
this.
|
|
1348
|
-
conversationType: "dm",
|
|
1349
|
-
recipientUserID,
|
|
1350
|
-
...(signal ? { signal } : {}),
|
|
1351
|
-
}),
|
|
1442
|
+
this.startEncryptedDmCall(recipientUserID, signal),
|
|
1352
1443
|
};
|
|
1353
1444
|
|
|
1354
1445
|
/**
|
|
@@ -1662,6 +1753,8 @@ export class Client {
|
|
|
1662
1753
|
|
|
1663
1754
|
private autoReconnectEnabled = false;
|
|
1664
1755
|
|
|
1756
|
+
private readonly callStates = new Map<string, EncryptedCallState>();
|
|
1757
|
+
|
|
1665
1758
|
private readonly cryptoProfile: CryptoProfile;
|
|
1666
1759
|
|
|
1667
1760
|
private readonly database: Storage;
|
|
@@ -1671,13 +1764,12 @@ export class Client {
|
|
|
1671
1764
|
private readonly decryptFailureCounts = new Map<string, number>();
|
|
1672
1765
|
|
|
1673
1766
|
private device?: Device;
|
|
1674
|
-
|
|
1675
1767
|
private deviceRecords: Record<string, Device> = {};
|
|
1768
|
+
|
|
1676
1769
|
// ── Event subscription (composition over inheritance) ───────────────
|
|
1677
1770
|
private readonly emitter = new EventEmitter<ClientEvents>();
|
|
1678
1771
|
|
|
1679
1772
|
private fetchingMail: boolean = false;
|
|
1680
|
-
|
|
1681
1773
|
private firstMailFetch = true;
|
|
1682
1774
|
private readonly forwarded = new Set<string>();
|
|
1683
1775
|
private readonly host: string;
|
|
@@ -1685,8 +1777,8 @@ export class Client {
|
|
|
1685
1777
|
/** Cancels in-flight HTTP work on `close()` so `postAuth`/`getMail` cannot hang forever. */
|
|
1686
1778
|
private readonly httpAbortController = new AbortController();
|
|
1687
1779
|
private readonly idKeys: KeyPair | null;
|
|
1688
|
-
private isAlive: boolean = true;
|
|
1689
1780
|
|
|
1781
|
+
private isAlive: boolean = true;
|
|
1690
1782
|
private localMessageRetentionDays: number;
|
|
1691
1783
|
private localRetentionPurgeTimer: null | ReturnType<typeof setInterval> =
|
|
1692
1784
|
null;
|
|
@@ -2574,6 +2666,92 @@ export class Client {
|
|
|
2574
2666
|
this.acknowledgeInboundMail(mail);
|
|
2575
2667
|
}
|
|
2576
2668
|
|
|
2669
|
+
private applyCallEnvelopeBody(body: CallEnvelopeBody): CallEvent {
|
|
2670
|
+
const localUserID = this.getUser().userID;
|
|
2671
|
+
const peerUserID =
|
|
2672
|
+
body.fromUserID === localUserID ? body.toUserID : body.fromUserID;
|
|
2673
|
+
const peerDeviceID =
|
|
2674
|
+
body.fromUserID === localUserID
|
|
2675
|
+
? body.toDeviceID
|
|
2676
|
+
: body.fromDeviceID;
|
|
2677
|
+
let state = this.callStates.get(body.callID);
|
|
2678
|
+
if (!state) {
|
|
2679
|
+
state = {
|
|
2680
|
+
peerDeviceID,
|
|
2681
|
+
peerUserID,
|
|
2682
|
+
pendingPeerDevices: [],
|
|
2683
|
+
sequence: 0,
|
|
2684
|
+
session: this.sessionFromCallEnvelope(body),
|
|
2685
|
+
};
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
state.peerUserID = peerUserID;
|
|
2689
|
+
if (body.fromUserID !== localUserID || body.action === "accept") {
|
|
2690
|
+
state.peerDeviceID = peerDeviceID;
|
|
2691
|
+
}
|
|
2692
|
+
state.sequence = Math.max(state.sequence, body.sequence);
|
|
2693
|
+
state.session.expiresAt = body.expiresAt;
|
|
2694
|
+
|
|
2695
|
+
const now = new Date().toISOString();
|
|
2696
|
+
switch (body.action) {
|
|
2697
|
+
case "accept":
|
|
2698
|
+
state.session.status = "active";
|
|
2699
|
+
this.upsertCallParticipant(state.session, {
|
|
2700
|
+
acceptedAt: now,
|
|
2701
|
+
deviceID: body.fromDeviceID,
|
|
2702
|
+
joinedAt: now,
|
|
2703
|
+
state: "accepted",
|
|
2704
|
+
userID: body.fromUserID,
|
|
2705
|
+
});
|
|
2706
|
+
break;
|
|
2707
|
+
case "cancel":
|
|
2708
|
+
case "end":
|
|
2709
|
+
case "hangup":
|
|
2710
|
+
case "reject":
|
|
2711
|
+
case "timeout":
|
|
2712
|
+
state.session.status = "ended";
|
|
2713
|
+
state.session.endedAt = now;
|
|
2714
|
+
this.upsertCallParticipant(state.session, {
|
|
2715
|
+
leftAt: now,
|
|
2716
|
+
state: body.action === "reject" ? "rejected" : "left",
|
|
2717
|
+
userID: body.fromUserID,
|
|
2718
|
+
});
|
|
2719
|
+
break;
|
|
2720
|
+
case "ice":
|
|
2721
|
+
case "signal":
|
|
2722
|
+
break;
|
|
2723
|
+
case "invite":
|
|
2724
|
+
state.session.status = "ringing";
|
|
2725
|
+
this.upsertCallParticipant(state.session, {
|
|
2726
|
+
acceptedAt: body.createdAt,
|
|
2727
|
+
deviceID: body.createdByDeviceID,
|
|
2728
|
+
joinedAt: body.createdAt,
|
|
2729
|
+
state: "accepted",
|
|
2730
|
+
userID: body.createdBy,
|
|
2731
|
+
});
|
|
2732
|
+
this.upsertCallParticipant(state.session, {
|
|
2733
|
+
state: "ringing",
|
|
2734
|
+
userID: body.toUserID,
|
|
2735
|
+
});
|
|
2736
|
+
break;
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
const event: CallEvent = {
|
|
2740
|
+
action: body.action,
|
|
2741
|
+
call: cloneCallSession(state.session),
|
|
2742
|
+
fromDeviceID: body.fromDeviceID,
|
|
2743
|
+
fromUserID: body.fromUserID,
|
|
2744
|
+
...(body.signal ? { signal: body.signal } : {}),
|
|
2745
|
+
};
|
|
2746
|
+
|
|
2747
|
+
if (state.session.status === "ended") {
|
|
2748
|
+
this.callStates.delete(body.callID);
|
|
2749
|
+
} else {
|
|
2750
|
+
this.callStates.set(body.callID, state);
|
|
2751
|
+
}
|
|
2752
|
+
return event;
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2577
2755
|
private async approveDeviceRequest(requestID: string): Promise<Device> {
|
|
2578
2756
|
const req = await this.getDeviceRegistrationRequest(requestID);
|
|
2579
2757
|
if (!req) {
|
|
@@ -2655,6 +2833,50 @@ export class Client {
|
|
|
2655
2833
|
return decodeHttpResponse(PasskeyOptionsCodec, response.data);
|
|
2656
2834
|
}
|
|
2657
2835
|
|
|
2836
|
+
private async callEnvelopeForBody(
|
|
2837
|
+
body: CallEnvelopeBody,
|
|
2838
|
+
): Promise<SignedCallEnvelope> {
|
|
2839
|
+
const wireBody = normalizeCallEnvelopeBodyForWire(body);
|
|
2840
|
+
const signed = await xSignAsync(
|
|
2841
|
+
canonicalJsonBytes(wireBody),
|
|
2842
|
+
this.signKeys.secretKey,
|
|
2843
|
+
);
|
|
2844
|
+
return {
|
|
2845
|
+
body: wireBody,
|
|
2846
|
+
signed: XUtils.encodeHex(signed),
|
|
2847
|
+
};
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
private async callTargetsForState(
|
|
2851
|
+
state: EncryptedCallState,
|
|
2852
|
+
): Promise<Device[]> {
|
|
2853
|
+
if (state.peerDeviceID) {
|
|
2854
|
+
const cached = this.deviceRecords[state.peerDeviceID];
|
|
2855
|
+
const device =
|
|
2856
|
+
cached ?? (await this.getDeviceByID(state.peerDeviceID));
|
|
2857
|
+
if (!device) {
|
|
2858
|
+
throw new Error(
|
|
2859
|
+
`Call peer device not found: ${state.peerDeviceID}`,
|
|
2860
|
+
);
|
|
2861
|
+
}
|
|
2862
|
+
return [device];
|
|
2863
|
+
}
|
|
2864
|
+
return state.pendingPeerDevices;
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
private callWakeForEnvelope(
|
|
2868
|
+
body: CallEnvelopeBody,
|
|
2869
|
+
): MailNotificationHint | undefined {
|
|
2870
|
+
if (body.action !== "invite") {
|
|
2871
|
+
return undefined;
|
|
2872
|
+
}
|
|
2873
|
+
return {
|
|
2874
|
+
callID: body.callID,
|
|
2875
|
+
event: "callWake",
|
|
2876
|
+
expiresAt: body.expiresAt,
|
|
2877
|
+
};
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2658
2880
|
private censorPreKey(preKey: PreKeysSQL): PreKeysWS {
|
|
2659
2881
|
if (!preKey.index) {
|
|
2660
2882
|
throw new Error("Key index is required.");
|
|
@@ -2813,6 +3035,7 @@ export class Client {
|
|
|
2813
3035
|
* errors should not reject the full read pipeline.
|
|
2814
3036
|
*/
|
|
2815
3037
|
allowKeyBundleFailure = false,
|
|
3038
|
+
notify?: MailNotificationHint,
|
|
2816
3039
|
): Promise<Message | null> {
|
|
2817
3040
|
return this.runWithThisCryptoProfile(async () => {
|
|
2818
3041
|
let keyBundle: KeyBundle;
|
|
@@ -2930,12 +3153,13 @@ export class Client {
|
|
|
2930
3153
|
recipient: device.deviceID,
|
|
2931
3154
|
sender: this.getDevice().deviceID,
|
|
2932
3155
|
};
|
|
3156
|
+
const wireMail: MailWS = notify ? { ...mail, notify } : mail;
|
|
2933
3157
|
|
|
2934
3158
|
const hmac = xHMAC(mail, SK);
|
|
2935
3159
|
|
|
2936
3160
|
const msg: ResourceMsg = {
|
|
2937
3161
|
action: "CREATE",
|
|
2938
|
-
data:
|
|
3162
|
+
data: wireMail,
|
|
2939
3163
|
resourceType: "mail",
|
|
2940
3164
|
transmissionID: uuid.v4(),
|
|
2941
3165
|
type: "resource",
|
|
@@ -2959,35 +3183,41 @@ export class Client {
|
|
|
2959
3183
|
|
|
2960
3184
|
this.emitter.emit("session", sessionEntry, user);
|
|
2961
3185
|
|
|
2962
|
-
|
|
3186
|
+
const rawPlaintext = forward ? "" : XUtils.encodeUTF8(message);
|
|
3187
|
+
const callEnvelope = forward
|
|
3188
|
+
? null
|
|
3189
|
+
: decodeCallEnvelopePlaintext(rawPlaintext);
|
|
2963
3190
|
const forwardedMsg = forward
|
|
2964
3191
|
? messageSchema.parse(msgpack.decode(message))
|
|
2965
3192
|
: null;
|
|
2966
|
-
const
|
|
2967
|
-
const emitMsg: Message = forwardedMsg
|
|
3193
|
+
const emitMsg: Message | null = forwardedMsg
|
|
2968
3194
|
? { ...normalizeForwardedMessage(forwardedMsg), forward: true }
|
|
2969
|
-
:
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
3195
|
+
: callEnvelope
|
|
3196
|
+
? null
|
|
3197
|
+
: message.length > 0
|
|
3198
|
+
? {
|
|
3199
|
+
authorID: mail.authorID,
|
|
3200
|
+
decrypted: true,
|
|
3201
|
+
direction: "outgoing",
|
|
3202
|
+
forward: mail.forward,
|
|
3203
|
+
group: mail.group ? uuid.stringify(mail.group) : null,
|
|
3204
|
+
mailID: mail.mailID,
|
|
3205
|
+
...messageFromDecodedPlaintext(
|
|
3206
|
+
decodeMessagePlaintext(rawPlaintext),
|
|
3207
|
+
),
|
|
3208
|
+
nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
3209
|
+
readerID: mail.readerID,
|
|
3210
|
+
recipient: mail.recipient,
|
|
3211
|
+
sender: mail.sender,
|
|
3212
|
+
timestamp: new Date().toISOString(),
|
|
3213
|
+
}
|
|
3214
|
+
: null;
|
|
3215
|
+
if (emitMsg) {
|
|
2986
3216
|
this.emitter.emit("message", emitMsg);
|
|
2987
3217
|
}
|
|
2988
3218
|
|
|
2989
|
-
await this.deliverMailResource(msg, hmac,
|
|
2990
|
-
return
|
|
3219
|
+
await this.deliverMailResource(msg, hmac, wireMail);
|
|
3220
|
+
return emitMsg;
|
|
2991
3221
|
});
|
|
2992
3222
|
}
|
|
2993
3223
|
|
|
@@ -3028,6 +3258,68 @@ export class Client {
|
|
|
3028
3258
|
await this.http.delete(this.getHost() + "/server/" + serverID);
|
|
3029
3259
|
}
|
|
3030
3260
|
|
|
3261
|
+
private async deliverCallEnvelopeBatch(args: {
|
|
3262
|
+
bodies: CallEnvelopeBody[];
|
|
3263
|
+
mailID: string;
|
|
3264
|
+
targetUser: User;
|
|
3265
|
+
}): Promise<void> {
|
|
3266
|
+
let failCount = 0;
|
|
3267
|
+
let lastErr: unknown;
|
|
3268
|
+
for (
|
|
3269
|
+
let index = 0;
|
|
3270
|
+
index < args.bodies.length;
|
|
3271
|
+
index += MAIL_FANOUT_CONCURRENCY
|
|
3272
|
+
) {
|
|
3273
|
+
const batch = args.bodies.slice(
|
|
3274
|
+
index,
|
|
3275
|
+
index + MAIL_FANOUT_CONCURRENCY,
|
|
3276
|
+
);
|
|
3277
|
+
const results = await Promise.all(
|
|
3278
|
+
batch.map(async (body): Promise<undefined | unknown> => {
|
|
3279
|
+
try {
|
|
3280
|
+
const targetDevice =
|
|
3281
|
+
this.deviceRecords[body.toDeviceID] ??
|
|
3282
|
+
(await this.getDeviceByID(body.toDeviceID));
|
|
3283
|
+
if (!targetDevice) {
|
|
3284
|
+
throw new Error(
|
|
3285
|
+
`Call target device not found: ${body.toDeviceID}`,
|
|
3286
|
+
);
|
|
3287
|
+
}
|
|
3288
|
+
await this.sendCallEnvelopeMail({
|
|
3289
|
+
body,
|
|
3290
|
+
mailID: args.mailID,
|
|
3291
|
+
notify: this.callWakeForEnvelope(body),
|
|
3292
|
+
targetDevice,
|
|
3293
|
+
targetUser: args.targetUser,
|
|
3294
|
+
});
|
|
3295
|
+
return undefined;
|
|
3296
|
+
} catch (err: unknown) {
|
|
3297
|
+
return err;
|
|
3298
|
+
}
|
|
3299
|
+
}),
|
|
3300
|
+
);
|
|
3301
|
+
for (const result of results) {
|
|
3302
|
+
if (result !== undefined) {
|
|
3303
|
+
lastErr = result;
|
|
3304
|
+
failCount += 1;
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
if (failCount > 0) {
|
|
3310
|
+
const base =
|
|
3311
|
+
lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
3312
|
+
if (failCount === args.bodies.length) {
|
|
3313
|
+
throw base;
|
|
3314
|
+
}
|
|
3315
|
+
const partial = new Error(
|
|
3316
|
+
`Call signaling failed to reach ${String(failCount)} of ` +
|
|
3317
|
+
`${String(args.bodies.length)} peer device(s).`,
|
|
3318
|
+
);
|
|
3319
|
+
partial.cause = base;
|
|
3320
|
+
throw partial;
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3031
3323
|
private deliverMailResource(
|
|
3032
3324
|
msg: ResourceMsg,
|
|
3033
3325
|
header: Uint8Array,
|
|
@@ -3113,13 +3405,61 @@ export class Client {
|
|
|
3113
3405
|
});
|
|
3114
3406
|
}
|
|
3115
3407
|
|
|
3116
|
-
private
|
|
3117
|
-
const
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3408
|
+
private fetchActiveCalls(): Promise<CallSession[]> {
|
|
3409
|
+
const now = Date.now();
|
|
3410
|
+
const active: CallSession[] = [];
|
|
3411
|
+
for (const [callID, state] of this.callStates.entries()) {
|
|
3412
|
+
if (
|
|
3413
|
+
state.session.status === "ended" ||
|
|
3414
|
+
Date.parse(state.session.expiresAt) <= now
|
|
3415
|
+
) {
|
|
3416
|
+
this.callStates.delete(callID);
|
|
3417
|
+
continue;
|
|
3418
|
+
}
|
|
3419
|
+
active.push(cloneCallSession(state.session));
|
|
3420
|
+
}
|
|
3421
|
+
return Promise.resolve(active);
|
|
3122
3422
|
}
|
|
3423
|
+
|
|
3424
|
+
private async fetchCallPeer(args: {
|
|
3425
|
+
userID: string;
|
|
3426
|
+
}): Promise<{ devices: Device[]; user: User }> {
|
|
3427
|
+
const [user, err] = await this.fetchUser(args.userID);
|
|
3428
|
+
if (err) {
|
|
3429
|
+
throw err;
|
|
3430
|
+
}
|
|
3431
|
+
if (!user) {
|
|
3432
|
+
throw new Error("Call peer not found.");
|
|
3433
|
+
}
|
|
3434
|
+
|
|
3435
|
+
const afterBackoff = await this.fetchUserDeviceListWithBackoff(
|
|
3436
|
+
args.userID,
|
|
3437
|
+
"peer",
|
|
3438
|
+
);
|
|
3439
|
+
let deviceListRaw: Device[];
|
|
3440
|
+
try {
|
|
3441
|
+
const again = await this.fetchUserDeviceListOnce(args.userID);
|
|
3442
|
+
const byID = new Map<string, Device>();
|
|
3443
|
+
for (const device of afterBackoff) {
|
|
3444
|
+
byID.set(device.deviceID, device);
|
|
3445
|
+
}
|
|
3446
|
+
for (const device of again) {
|
|
3447
|
+
byID.set(device.deviceID, device);
|
|
3448
|
+
}
|
|
3449
|
+
deviceListRaw = [...byID.values()];
|
|
3450
|
+
} catch {
|
|
3451
|
+
deviceListRaw = afterBackoff;
|
|
3452
|
+
}
|
|
3453
|
+
|
|
3454
|
+
const devices = deviceListRaw
|
|
3455
|
+
.filter((device) => !device.deleted)
|
|
3456
|
+
.sort((a, b) => a.deviceID.localeCompare(b.deviceID, "en"));
|
|
3457
|
+
if (devices.length === 0) {
|
|
3458
|
+
throw new Error("Call peer has no active devices.");
|
|
3459
|
+
}
|
|
3460
|
+
return { devices, user };
|
|
3461
|
+
}
|
|
3462
|
+
|
|
3123
3463
|
private async fetchIceServers(): Promise<IceServerConfig[]> {
|
|
3124
3464
|
const res = await this.http.get(this.getHost() + "/calls/ice-servers", {
|
|
3125
3465
|
responseType: "json",
|
|
@@ -3240,6 +3580,24 @@ export class Client {
|
|
|
3240
3580
|
throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
|
|
3241
3581
|
}
|
|
3242
3582
|
|
|
3583
|
+
private async fetchUserOrThrow(userID: string): Promise<User> {
|
|
3584
|
+
if (userID === this.getUser().userID) {
|
|
3585
|
+
return this.getUser();
|
|
3586
|
+
}
|
|
3587
|
+
const cached = this.userRecords[userID];
|
|
3588
|
+
if (cached) {
|
|
3589
|
+
return cached;
|
|
3590
|
+
}
|
|
3591
|
+
const [user, err] = await this.fetchUser(userID);
|
|
3592
|
+
if (err) {
|
|
3593
|
+
throw err;
|
|
3594
|
+
}
|
|
3595
|
+
if (!user) {
|
|
3596
|
+
throw new Error(`User not found: ${userID}`);
|
|
3597
|
+
}
|
|
3598
|
+
return user;
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3243
3601
|
/**
|
|
3244
3602
|
* Finish a passkey login and adopt the resulting JWT as the
|
|
3245
3603
|
* client's bearer token. After this call, `client.passkeys.*`
|
|
@@ -3777,6 +4135,14 @@ export class Client {
|
|
|
3777
4135
|
}
|
|
3778
4136
|
break;
|
|
3779
4137
|
}
|
|
4138
|
+
case "callWake": {
|
|
4139
|
+
const parsed = callWakeNotifyData.safeParse(msg.data);
|
|
4140
|
+
await this.getMail();
|
|
4141
|
+
if (parsed.success) {
|
|
4142
|
+
this.emitter.emit("callWake", parsed.data);
|
|
4143
|
+
}
|
|
4144
|
+
break;
|
|
4145
|
+
}
|
|
3780
4146
|
case "deviceRequest": {
|
|
3781
4147
|
const parsed = deviceRequestNotifyData.safeParse(msg.data);
|
|
3782
4148
|
if (parsed.success) {
|
|
@@ -4038,6 +4404,84 @@ export class Client {
|
|
|
4038
4404
|
return decodeHttpResponse(PasskeyArrayCodec, response.data);
|
|
4039
4405
|
}
|
|
4040
4406
|
|
|
4407
|
+
private makeCallEnvelopeBody(args: {
|
|
4408
|
+
action: CallAction;
|
|
4409
|
+
expiresAt: string;
|
|
4410
|
+
sequence: number;
|
|
4411
|
+
signal?: CallSignalPayload | undefined;
|
|
4412
|
+
state: EncryptedCallState;
|
|
4413
|
+
toDeviceID: string;
|
|
4414
|
+
toUserID: string;
|
|
4415
|
+
}): CallEnvelopeBody {
|
|
4416
|
+
return {
|
|
4417
|
+
action: args.action,
|
|
4418
|
+
callID: args.state.session.callID,
|
|
4419
|
+
conversationID: args.state.session.conversationID,
|
|
4420
|
+
conversationType: args.state.session.conversationType,
|
|
4421
|
+
createdAt: args.state.session.createdAt,
|
|
4422
|
+
createdBy: args.state.session.createdBy,
|
|
4423
|
+
createdByDeviceID: args.state.session.createdByDeviceID,
|
|
4424
|
+
expiresAt: args.expiresAt,
|
|
4425
|
+
fromDeviceID: this.getDevice().deviceID,
|
|
4426
|
+
fromUserID: this.getUser().userID,
|
|
4427
|
+
media: "audio",
|
|
4428
|
+
sequence: args.sequence,
|
|
4429
|
+
...(args.signal ? { signal: args.signal } : {}),
|
|
4430
|
+
toDeviceID: args.toDeviceID,
|
|
4431
|
+
toUserID: args.toUserID,
|
|
4432
|
+
version: 1,
|
|
4433
|
+
};
|
|
4434
|
+
}
|
|
4435
|
+
|
|
4436
|
+
private markLocalCallAction(
|
|
4437
|
+
state: EncryptedCallState,
|
|
4438
|
+
action: CallAction,
|
|
4439
|
+
signal?: CallSignalPayload,
|
|
4440
|
+
): CallEvent {
|
|
4441
|
+
const now = new Date().toISOString();
|
|
4442
|
+
if (action === "accept") {
|
|
4443
|
+
state.session.status = "active";
|
|
4444
|
+
state.session.expiresAt = new Date(
|
|
4445
|
+
Date.now() + CALL_MAX_TTL_MS,
|
|
4446
|
+
).toISOString();
|
|
4447
|
+
this.upsertCallParticipant(state.session, {
|
|
4448
|
+
acceptedAt: now,
|
|
4449
|
+
deviceID: this.getDevice().deviceID,
|
|
4450
|
+
joinedAt: now,
|
|
4451
|
+
state: "accepted",
|
|
4452
|
+
userID: this.getUser().userID,
|
|
4453
|
+
});
|
|
4454
|
+
} else if (
|
|
4455
|
+
action === "cancel" ||
|
|
4456
|
+
action === "end" ||
|
|
4457
|
+
action === "hangup" ||
|
|
4458
|
+
action === "reject" ||
|
|
4459
|
+
action === "timeout"
|
|
4460
|
+
) {
|
|
4461
|
+
state.session.status = "ended";
|
|
4462
|
+
state.session.endedAt = now;
|
|
4463
|
+
this.upsertCallParticipant(state.session, {
|
|
4464
|
+
leftAt: now,
|
|
4465
|
+
state: action === "reject" ? "rejected" : "left",
|
|
4466
|
+
userID: this.getUser().userID,
|
|
4467
|
+
});
|
|
4468
|
+
}
|
|
4469
|
+
|
|
4470
|
+
const event: CallEvent = {
|
|
4471
|
+
action,
|
|
4472
|
+
call: cloneCallSession(state.session),
|
|
4473
|
+
fromDeviceID: this.getDevice().deviceID,
|
|
4474
|
+
fromUserID: this.getUser().userID,
|
|
4475
|
+
...(signal ? { signal } : {}),
|
|
4476
|
+
};
|
|
4477
|
+
if (state.session.status === "ended") {
|
|
4478
|
+
this.callStates.delete(state.session.callID);
|
|
4479
|
+
} else {
|
|
4480
|
+
this.callStates.set(state.session.callID, state);
|
|
4481
|
+
}
|
|
4482
|
+
return event;
|
|
4483
|
+
}
|
|
4484
|
+
|
|
4041
4485
|
private async markSessionVerified(sessionID: string) {
|
|
4042
4486
|
return this.database.markSessionVerified(sessionID);
|
|
4043
4487
|
}
|
|
@@ -4266,6 +4710,41 @@ export class Client {
|
|
|
4266
4710
|
}
|
|
4267
4711
|
}
|
|
4268
4712
|
|
|
4713
|
+
private async processDecryptedCallEnvelope(args: {
|
|
4714
|
+
envelope: SignedCallEnvelope;
|
|
4715
|
+
mail: MailWS;
|
|
4716
|
+
}): Promise<CallEvent | null> {
|
|
4717
|
+
const body = args.envelope.body;
|
|
4718
|
+
if (
|
|
4719
|
+
body.fromDeviceID !== args.mail.sender ||
|
|
4720
|
+
body.fromUserID !== args.mail.authorID ||
|
|
4721
|
+
body.toDeviceID !== args.mail.recipient ||
|
|
4722
|
+
body.toUserID !== args.mail.readerID ||
|
|
4723
|
+
body.toDeviceID !== this.getDevice().deviceID ||
|
|
4724
|
+
body.toUserID !== this.getUser().userID
|
|
4725
|
+
) {
|
|
4726
|
+
return null;
|
|
4727
|
+
}
|
|
4728
|
+
|
|
4729
|
+
const senderDevice = await this.getDeviceByID(body.fromDeviceID);
|
|
4730
|
+
if (!senderDevice || senderDevice.owner !== body.fromUserID) {
|
|
4731
|
+
return null;
|
|
4732
|
+
}
|
|
4733
|
+
|
|
4734
|
+
const opened = await xSignOpenAsync(
|
|
4735
|
+
XUtils.decodeHex(args.envelope.signed),
|
|
4736
|
+
XUtils.decodeHex(senderDevice.signKey),
|
|
4737
|
+
);
|
|
4738
|
+
if (!opened) {
|
|
4739
|
+
return null;
|
|
4740
|
+
}
|
|
4741
|
+
if (!XUtils.bytesEqual(opened, canonicalJsonBytes(body))) {
|
|
4742
|
+
return null;
|
|
4743
|
+
}
|
|
4744
|
+
|
|
4745
|
+
return this.applyCallEnvelopeBody(body);
|
|
4746
|
+
}
|
|
4747
|
+
|
|
4269
4748
|
private async publishPendingDeviceRegistration(args: {
|
|
4270
4749
|
challenge: string;
|
|
4271
4750
|
requestID: string;
|
|
@@ -4621,46 +5100,61 @@ export class Client {
|
|
|
4621
5100
|
if (!mail.forward) {
|
|
4622
5101
|
plaintext = XUtils.encodeUTF8(unsealed);
|
|
4623
5102
|
}
|
|
4624
|
-
const
|
|
5103
|
+
const callEnvelope = mail.forward
|
|
4625
5104
|
? null
|
|
4626
|
-
:
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
4631
|
-
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
4640
|
-
|
|
4641
|
-
|
|
4642
|
-
|
|
4643
|
-
|
|
4644
|
-
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
5105
|
+
: decodeCallEnvelopePlaintext(plaintext);
|
|
5106
|
+
if (callEnvelope) {
|
|
5107
|
+
const event =
|
|
5108
|
+
await this.processDecryptedCallEnvelope({
|
|
5109
|
+
envelope: callEnvelope,
|
|
5110
|
+
mail,
|
|
5111
|
+
});
|
|
5112
|
+
if (event) {
|
|
5113
|
+
this.emitter.emit("call", event);
|
|
5114
|
+
}
|
|
5115
|
+
} else {
|
|
5116
|
+
const decodedPlaintext = mail.forward
|
|
5117
|
+
? null
|
|
5118
|
+
: decodeMessagePlaintext(plaintext);
|
|
5119
|
+
|
|
5120
|
+
const fwdMsg1 = mail.forward
|
|
5121
|
+
? messageSchema.parse(
|
|
5122
|
+
msgpack.decode(unsealed),
|
|
5123
|
+
)
|
|
5124
|
+
: null;
|
|
5125
|
+
const message: Message = fwdMsg1
|
|
5126
|
+
? {
|
|
5127
|
+
...normalizeForwardedMessage(fwdMsg1),
|
|
5128
|
+
forward: true,
|
|
5129
|
+
}
|
|
5130
|
+
: {
|
|
5131
|
+
authorID: mail.authorID,
|
|
5132
|
+
decrypted: true,
|
|
5133
|
+
direction: "incoming",
|
|
5134
|
+
forward: mail.forward,
|
|
5135
|
+
group: mail.group
|
|
5136
|
+
? uuid.stringify(mail.group)
|
|
5137
|
+
: null,
|
|
5138
|
+
mailID: mail.mailID,
|
|
5139
|
+
...messageFromDecodedPlaintext(
|
|
5140
|
+
decodedPlaintext ?? {
|
|
5141
|
+
message: plaintext,
|
|
5142
|
+
},
|
|
5143
|
+
),
|
|
5144
|
+
nonce: XUtils.encodeHex(
|
|
5145
|
+
new Uint8Array(mail.nonce),
|
|
5146
|
+
),
|
|
5147
|
+
readerID: mail.readerID,
|
|
5148
|
+
recipient: mail.recipient,
|
|
5149
|
+
sender: mail.sender,
|
|
5150
|
+
timestamp: timestamp,
|
|
5151
|
+
};
|
|
5152
|
+
|
|
5153
|
+
const shouldEmitIncomingInitial =
|
|
5154
|
+
mail.forward || plaintext.length > 0;
|
|
5155
|
+
if (shouldEmitIncomingInitial) {
|
|
5156
|
+
this.emitter.emit("message", message);
|
|
5157
|
+
}
|
|
4664
5158
|
}
|
|
4665
5159
|
if (libvexDebugDmEnabled()) {
|
|
4666
5160
|
try {
|
|
@@ -4920,37 +5414,51 @@ export class Client {
|
|
|
4920
5414
|
? messageSchema.parse(msgpack.decode(decrypted))
|
|
4921
5415
|
: null;
|
|
4922
5416
|
const rawIncoming = XUtils.encodeUTF8(decrypted);
|
|
4923
|
-
const
|
|
5417
|
+
const callEnvelope = mail.forward
|
|
4924
5418
|
? null
|
|
4925
|
-
:
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
|
|
4930
|
-
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
4944
|
-
|
|
4945
|
-
|
|
4946
|
-
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
|
|
4950
|
-
|
|
4951
|
-
|
|
4952
|
-
|
|
4953
|
-
|
|
5419
|
+
: decodeCallEnvelopePlaintext(rawIncoming);
|
|
5420
|
+
if (callEnvelope) {
|
|
5421
|
+
const event =
|
|
5422
|
+
await this.processDecryptedCallEnvelope({
|
|
5423
|
+
envelope: callEnvelope,
|
|
5424
|
+
mail,
|
|
5425
|
+
});
|
|
5426
|
+
if (event) {
|
|
5427
|
+
this.emitter.emit("call", event);
|
|
5428
|
+
}
|
|
5429
|
+
} else {
|
|
5430
|
+
const decodedPlaintext = mail.forward
|
|
5431
|
+
? null
|
|
5432
|
+
: decodeMessagePlaintext(rawIncoming);
|
|
5433
|
+
const message: Message = fwdMsg2
|
|
5434
|
+
? {
|
|
5435
|
+
...normalizeForwardedMessage(fwdMsg2),
|
|
5436
|
+
forward: true,
|
|
5437
|
+
}
|
|
5438
|
+
: {
|
|
5439
|
+
authorID: mail.authorID,
|
|
5440
|
+
decrypted: true,
|
|
5441
|
+
direction: "incoming",
|
|
5442
|
+
forward: mail.forward,
|
|
5443
|
+
group: mail.group
|
|
5444
|
+
? uuid.stringify(mail.group)
|
|
5445
|
+
: null,
|
|
5446
|
+
mailID: mail.mailID,
|
|
5447
|
+
...messageFromDecodedPlaintext(
|
|
5448
|
+
decodedPlaintext ?? {
|
|
5449
|
+
message: rawIncoming,
|
|
5450
|
+
},
|
|
5451
|
+
),
|
|
5452
|
+
nonce: XUtils.encodeHex(
|
|
5453
|
+
new Uint8Array(mail.nonce),
|
|
5454
|
+
),
|
|
5455
|
+
readerID: mail.readerID,
|
|
5456
|
+
recipient: mail.recipient,
|
|
5457
|
+
sender: mail.sender,
|
|
5458
|
+
timestamp: timestamp,
|
|
5459
|
+
};
|
|
5460
|
+
this.emitter.emit("message", message);
|
|
5461
|
+
}
|
|
4954
5462
|
|
|
4955
5463
|
const sqlPatch = sessionToSqlPatch(session);
|
|
4956
5464
|
const persisted: SessionSQL = {
|
|
@@ -5366,87 +5874,71 @@ export class Client {
|
|
|
5366
5874
|
}
|
|
5367
5875
|
}
|
|
5368
5876
|
|
|
5369
|
-
private async
|
|
5370
|
-
|
|
5371
|
-
|
|
5372
|
-
|
|
5373
|
-
|
|
5374
|
-
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
5378
|
-
|
|
5379
|
-
|
|
5380
|
-
|
|
5381
|
-
|
|
5382
|
-
|
|
5383
|
-
|
|
5384
|
-
|
|
5385
|
-
|
|
5386
|
-
|
|
5387
|
-
|
|
5388
|
-
if (!event) {
|
|
5389
|
-
reject(new Error("Call signaling response was empty."));
|
|
5390
|
-
return;
|
|
5391
|
-
}
|
|
5392
|
-
resolve(event);
|
|
5393
|
-
};
|
|
5394
|
-
|
|
5395
|
-
const callback = (packedMsg: Uint8Array) => {
|
|
5396
|
-
const [_header, receivedMsg] = XUtils.unpackMessage(packedMsg);
|
|
5397
|
-
if (receivedMsg.transmissionID !== msg.transmissionID) {
|
|
5398
|
-
return;
|
|
5399
|
-
}
|
|
5400
|
-
|
|
5401
|
-
const parsed = WSMessageSchema.safeParse(receivedMsg);
|
|
5402
|
-
if (!parsed.success) {
|
|
5403
|
-
settle(
|
|
5404
|
-
"Call signaling failed: " + JSON.stringify(receivedMsg),
|
|
5405
|
-
);
|
|
5406
|
-
return;
|
|
5407
|
-
}
|
|
5408
|
-
|
|
5409
|
-
if (parsed.data.type === "success") {
|
|
5410
|
-
const event = CallEventSchema.safeParse(parsed.data.data);
|
|
5411
|
-
if (!event.success) {
|
|
5412
|
-
settle(
|
|
5413
|
-
"Invalid call signaling response: " +
|
|
5414
|
-
JSON.stringify(event.error.issues),
|
|
5415
|
-
);
|
|
5416
|
-
return;
|
|
5417
|
-
}
|
|
5418
|
-
settle(null, event.data);
|
|
5419
|
-
return;
|
|
5420
|
-
}
|
|
5421
|
-
|
|
5422
|
-
if (parsed.data.type === "error") {
|
|
5423
|
-
settle(new Error(parsed.data.error));
|
|
5424
|
-
return;
|
|
5425
|
-
}
|
|
5426
|
-
|
|
5427
|
-
if (
|
|
5428
|
-
parsed.data.type === "notify" &&
|
|
5429
|
-
(parsed.data.event === "call" ||
|
|
5430
|
-
parsed.data.event === "callInvite")
|
|
5431
|
-
) {
|
|
5432
|
-
const event = CallEventSchema.safeParse(parsed.data.data);
|
|
5433
|
-
if (event.success) {
|
|
5434
|
-
settle(null, event.data);
|
|
5435
|
-
}
|
|
5436
|
-
return;
|
|
5437
|
-
}
|
|
5877
|
+
private async sendCallEnvelopeMail(args: {
|
|
5878
|
+
body: CallEnvelopeBody;
|
|
5879
|
+
mailID: string;
|
|
5880
|
+
notify?: MailNotificationHint | undefined;
|
|
5881
|
+
targetDevice: Device;
|
|
5882
|
+
targetUser: User;
|
|
5883
|
+
}): Promise<void> {
|
|
5884
|
+
const envelope = await this.callEnvelopeForBody(args.body);
|
|
5885
|
+
await this.sendMailWithRecovery(
|
|
5886
|
+
args.targetDevice,
|
|
5887
|
+
args.targetUser,
|
|
5888
|
+
encodeCallEnvelopePlaintext(envelope),
|
|
5889
|
+
null,
|
|
5890
|
+
args.mailID,
|
|
5891
|
+
false,
|
|
5892
|
+
false,
|
|
5893
|
+
args.notify,
|
|
5894
|
+
);
|
|
5895
|
+
}
|
|
5438
5896
|
|
|
5439
|
-
|
|
5440
|
-
|
|
5441
|
-
|
|
5442
|
-
|
|
5443
|
-
|
|
5897
|
+
private async sendEncryptedCallAction(
|
|
5898
|
+
action: Exclude<CallAction, "end" | "invite" | "timeout">,
|
|
5899
|
+
callID: string,
|
|
5900
|
+
signal?: CallSignalPayload,
|
|
5901
|
+
): Promise<CallEvent> {
|
|
5902
|
+
const state = this.callStates.get(callID);
|
|
5903
|
+
if (!state) {
|
|
5904
|
+
throw new Error("Unknown encrypted call: " + callID);
|
|
5905
|
+
}
|
|
5906
|
+
|
|
5907
|
+
const targets = await this.callTargetsForState(state);
|
|
5908
|
+
if (targets.length === 0) {
|
|
5909
|
+
throw new Error("Call has no reachable peer devices.");
|
|
5910
|
+
}
|
|
5911
|
+
|
|
5912
|
+
const targetUser = await this.fetchUserOrThrow(state.peerUserID);
|
|
5913
|
+
const sequence = state.sequence + 1;
|
|
5914
|
+
state.sequence = sequence;
|
|
5915
|
+
const expiresAt =
|
|
5916
|
+
action === "accept"
|
|
5917
|
+
? new Date(Date.now() + CALL_MAX_TTL_MS).toISOString()
|
|
5918
|
+
: state.session.expiresAt;
|
|
5919
|
+
const bodies = targets.map((target) =>
|
|
5920
|
+
this.makeCallEnvelopeBody({
|
|
5921
|
+
action,
|
|
5922
|
+
expiresAt,
|
|
5923
|
+
sequence,
|
|
5924
|
+
signal,
|
|
5925
|
+
state,
|
|
5926
|
+
toDeviceID: target.deviceID,
|
|
5927
|
+
toUserID: target.owner,
|
|
5928
|
+
}),
|
|
5929
|
+
);
|
|
5444
5930
|
|
|
5445
|
-
|
|
5446
|
-
|
|
5447
|
-
|
|
5448
|
-
|
|
5931
|
+
await this.deliverCallEnvelopeBatch({
|
|
5932
|
+
bodies,
|
|
5933
|
+
mailID: uuid.v4(),
|
|
5934
|
+
targetUser,
|
|
5449
5935
|
});
|
|
5936
|
+
|
|
5937
|
+
if (targets.length === 1) {
|
|
5938
|
+
state.peerDeviceID = targets[0]?.deviceID;
|
|
5939
|
+
}
|
|
5940
|
+
state.session.expiresAt = expiresAt;
|
|
5941
|
+
return this.markLocalCallAction(state, action, signal);
|
|
5450
5942
|
}
|
|
5451
5943
|
|
|
5452
5944
|
private async sendGroupMessage(
|
|
@@ -5585,6 +6077,7 @@ export class Client {
|
|
|
5585
6077
|
mailID: null | string,
|
|
5586
6078
|
forward: boolean,
|
|
5587
6079
|
retry = false,
|
|
6080
|
+
notify?: MailNotificationHint,
|
|
5588
6081
|
): Promise<Message | null> {
|
|
5589
6082
|
while (this.sending.has(device.deviceID)) {
|
|
5590
6083
|
await sleep(100);
|
|
@@ -5611,6 +6104,7 @@ export class Client {
|
|
|
5611
6104
|
mailID,
|
|
5612
6105
|
forward,
|
|
5613
6106
|
false,
|
|
6107
|
+
notify,
|
|
5614
6108
|
);
|
|
5615
6109
|
if (libvexDebugDmEnabled()) {
|
|
5616
6110
|
debugLibvexDm("sendMail: createSession returned", {
|
|
@@ -5653,10 +6147,11 @@ export class Client {
|
|
|
5653
6147
|
recipient: device.deviceID,
|
|
5654
6148
|
sender: this.getDevice().deviceID,
|
|
5655
6149
|
};
|
|
6150
|
+
const wireMail: MailWS = notify ? { ...mail, notify } : mail;
|
|
5656
6151
|
|
|
5657
6152
|
const msgb: ResourceMsg = {
|
|
5658
6153
|
action: "CREATE",
|
|
5659
|
-
data:
|
|
6154
|
+
data: wireMail,
|
|
5660
6155
|
resourceType: "mail",
|
|
5661
6156
|
transmissionID: uuid.v4(),
|
|
5662
6157
|
type: "resource",
|
|
@@ -5664,28 +6159,36 @@ export class Client {
|
|
|
5664
6159
|
|
|
5665
6160
|
const hmac = xHMAC(mail, messageKey);
|
|
5666
6161
|
|
|
6162
|
+
const rawPlaintext = forward ? "" : XUtils.encodeUTF8(msg);
|
|
6163
|
+
const callEnvelope = forward
|
|
6164
|
+
? null
|
|
6165
|
+
: decodeCallEnvelopePlaintext(rawPlaintext);
|
|
5667
6166
|
const fwdOut = forward
|
|
5668
6167
|
? messageSchema.parse(msgpack.decode(msg))
|
|
5669
6168
|
: null;
|
|
5670
|
-
const outMsg: Message = fwdOut
|
|
6169
|
+
const outMsg: Message | null = fwdOut
|
|
5671
6170
|
? { ...normalizeForwardedMessage(fwdOut), forward: true }
|
|
5672
|
-
:
|
|
5673
|
-
|
|
5674
|
-
|
|
5675
|
-
|
|
5676
|
-
|
|
5677
|
-
|
|
5678
|
-
|
|
5679
|
-
|
|
5680
|
-
|
|
5681
|
-
|
|
5682
|
-
|
|
5683
|
-
|
|
5684
|
-
|
|
5685
|
-
|
|
5686
|
-
|
|
5687
|
-
|
|
5688
|
-
|
|
6171
|
+
: callEnvelope
|
|
6172
|
+
? null
|
|
6173
|
+
: {
|
|
6174
|
+
authorID: mail.authorID,
|
|
6175
|
+
decrypted: true,
|
|
6176
|
+
direction: "outgoing",
|
|
6177
|
+
forward: mail.forward,
|
|
6178
|
+
group: mail.group ? uuid.stringify(mail.group) : null,
|
|
6179
|
+
mailID: mail.mailID,
|
|
6180
|
+
...messageFromDecodedPlaintext(
|
|
6181
|
+
decodeMessagePlaintext(rawPlaintext),
|
|
6182
|
+
),
|
|
6183
|
+
nonce: XUtils.encodeHex(new Uint8Array(mail.nonce)),
|
|
6184
|
+
readerID: mail.readerID,
|
|
6185
|
+
recipient: mail.recipient,
|
|
6186
|
+
sender: mail.sender,
|
|
6187
|
+
timestamp: new Date().toISOString(),
|
|
6188
|
+
};
|
|
6189
|
+
if (outMsg) {
|
|
6190
|
+
this.emitter.emit("message", outMsg);
|
|
6191
|
+
}
|
|
5689
6192
|
|
|
5690
6193
|
const sqlPatch = sessionToSqlPatch(session);
|
|
5691
6194
|
const persisted: SessionSQL = {
|
|
@@ -5712,7 +6215,7 @@ export class Client {
|
|
|
5712
6215
|
await this.database.saveSession(persisted);
|
|
5713
6216
|
this.sessionRecords[XUtils.encodeHex(session.publicKey)] = session;
|
|
5714
6217
|
|
|
5715
|
-
await this.deliverMailResource(msgb, hmac,
|
|
6218
|
+
await this.deliverMailResource(msgb, hmac, wireMail);
|
|
5716
6219
|
return outMsg;
|
|
5717
6220
|
} finally {
|
|
5718
6221
|
this.sending.delete(device.deviceID);
|
|
@@ -5727,6 +6230,7 @@ export class Client {
|
|
|
5727
6230
|
mailID: null | string,
|
|
5728
6231
|
forward: boolean,
|
|
5729
6232
|
forceFreshSession = false,
|
|
6233
|
+
notify?: MailNotificationHint,
|
|
5730
6234
|
): Promise<Message | null> {
|
|
5731
6235
|
try {
|
|
5732
6236
|
return await this.sendMail(
|
|
@@ -5737,6 +6241,7 @@ export class Client {
|
|
|
5737
6241
|
mailID,
|
|
5738
6242
|
forward,
|
|
5739
6243
|
forceFreshSession,
|
|
6244
|
+
notify,
|
|
5740
6245
|
);
|
|
5741
6246
|
} catch (err: unknown) {
|
|
5742
6247
|
if (!this.shouldRetryDeliveryWithFreshSession(err)) {
|
|
@@ -5750,6 +6255,7 @@ export class Client {
|
|
|
5750
6255
|
mailID,
|
|
5751
6256
|
forward,
|
|
5752
6257
|
true,
|
|
6258
|
+
notify,
|
|
5753
6259
|
);
|
|
5754
6260
|
}
|
|
5755
6261
|
}
|
|
@@ -5927,6 +6433,33 @@ export class Client {
|
|
|
5927
6433
|
this.send(receipt).catch(ignoreSocketTeardown);
|
|
5928
6434
|
}
|
|
5929
6435
|
|
|
6436
|
+
private sessionFromCallEnvelope(body: CallEnvelopeBody): CallSession {
|
|
6437
|
+
const session: CallSession = {
|
|
6438
|
+
callID: body.callID,
|
|
6439
|
+
conversationID: body.conversationID,
|
|
6440
|
+
conversationType: body.conversationType,
|
|
6441
|
+
createdAt: body.createdAt,
|
|
6442
|
+
createdBy: body.createdBy,
|
|
6443
|
+
createdByDeviceID: body.createdByDeviceID,
|
|
6444
|
+
expiresAt: body.expiresAt,
|
|
6445
|
+
media: "audio",
|
|
6446
|
+
participants: [],
|
|
6447
|
+
status: body.action === "invite" ? "ringing" : "active",
|
|
6448
|
+
};
|
|
6449
|
+
this.upsertCallParticipant(session, {
|
|
6450
|
+
acceptedAt: body.createdAt,
|
|
6451
|
+
deviceID: body.createdByDeviceID,
|
|
6452
|
+
joinedAt: body.createdAt,
|
|
6453
|
+
state: "accepted",
|
|
6454
|
+
userID: body.createdBy,
|
|
6455
|
+
});
|
|
6456
|
+
this.upsertCallParticipant(session, {
|
|
6457
|
+
state: "ringing",
|
|
6458
|
+
userID: body.conversationID,
|
|
6459
|
+
});
|
|
6460
|
+
return session;
|
|
6461
|
+
}
|
|
6462
|
+
|
|
5930
6463
|
private setAlive(status: boolean) {
|
|
5931
6464
|
this.isAlive = status;
|
|
5932
6465
|
}
|
|
@@ -5973,6 +6506,76 @@ export class Client {
|
|
|
5973
6506
|
);
|
|
5974
6507
|
}
|
|
5975
6508
|
|
|
6509
|
+
private async startEncryptedDmCall(
|
|
6510
|
+
recipientUserID: string,
|
|
6511
|
+
signal?: CallSignalPayload,
|
|
6512
|
+
): Promise<CallEvent> {
|
|
6513
|
+
const { devices, user } = await this.fetchCallPeer({
|
|
6514
|
+
userID: recipientUserID,
|
|
6515
|
+
});
|
|
6516
|
+
const now = new Date();
|
|
6517
|
+
const createdAt = now.toISOString();
|
|
6518
|
+
const expiresAt = new Date(
|
|
6519
|
+
now.getTime() + CALL_INVITE_TTL_MS,
|
|
6520
|
+
).toISOString();
|
|
6521
|
+
const session: CallSession = {
|
|
6522
|
+
callID: uuid.v4(),
|
|
6523
|
+
conversationID: recipientUserID,
|
|
6524
|
+
conversationType: "dm",
|
|
6525
|
+
createdAt,
|
|
6526
|
+
createdBy: this.getUser().userID,
|
|
6527
|
+
createdByDeviceID: this.getDevice().deviceID,
|
|
6528
|
+
expiresAt,
|
|
6529
|
+
media: "audio",
|
|
6530
|
+
participants: [
|
|
6531
|
+
{
|
|
6532
|
+
acceptedAt: createdAt,
|
|
6533
|
+
deviceID: this.getDevice().deviceID,
|
|
6534
|
+
joinedAt: createdAt,
|
|
6535
|
+
state: "accepted",
|
|
6536
|
+
userID: this.getUser().userID,
|
|
6537
|
+
},
|
|
6538
|
+
{
|
|
6539
|
+
state: "ringing",
|
|
6540
|
+
userID: recipientUserID,
|
|
6541
|
+
},
|
|
6542
|
+
],
|
|
6543
|
+
status: "ringing",
|
|
6544
|
+
};
|
|
6545
|
+
const state: EncryptedCallState = {
|
|
6546
|
+
peerUserID: recipientUserID,
|
|
6547
|
+
pendingPeerDevices: devices,
|
|
6548
|
+
sequence: 1,
|
|
6549
|
+
session,
|
|
6550
|
+
};
|
|
6551
|
+
this.callStates.set(session.callID, state);
|
|
6552
|
+
|
|
6553
|
+
const bodies = devices.map((device) =>
|
|
6554
|
+
this.makeCallEnvelopeBody({
|
|
6555
|
+
action: "invite",
|
|
6556
|
+
expiresAt,
|
|
6557
|
+
sequence: state.sequence,
|
|
6558
|
+
signal,
|
|
6559
|
+
state,
|
|
6560
|
+
toDeviceID: device.deviceID,
|
|
6561
|
+
toUserID: recipientUserID,
|
|
6562
|
+
}),
|
|
6563
|
+
);
|
|
6564
|
+
await this.deliverCallEnvelopeBatch({
|
|
6565
|
+
bodies,
|
|
6566
|
+
mailID: uuid.v4(),
|
|
6567
|
+
targetUser: user,
|
|
6568
|
+
});
|
|
6569
|
+
|
|
6570
|
+
return {
|
|
6571
|
+
action: "invite",
|
|
6572
|
+
call: cloneCallSession(session),
|
|
6573
|
+
fromDeviceID: this.getDevice().deviceID,
|
|
6574
|
+
fromUserID: this.getUser().userID,
|
|
6575
|
+
...(signal ? { signal } : {}),
|
|
6576
|
+
};
|
|
6577
|
+
}
|
|
6578
|
+
|
|
5976
6579
|
private async submitOTK(amount: number) {
|
|
5977
6580
|
const otks: UnsavedPreKey[] = [];
|
|
5978
6581
|
|
|
@@ -6106,4 +6709,18 @@ export class Client {
|
|
|
6106
6709
|
return null;
|
|
6107
6710
|
}
|
|
6108
6711
|
}
|
|
6712
|
+
|
|
6713
|
+
private upsertCallParticipant(
|
|
6714
|
+
session: CallSession,
|
|
6715
|
+
patch: CallSession["participants"][number],
|
|
6716
|
+
): void {
|
|
6717
|
+
const existing = session.participants.find(
|
|
6718
|
+
(participant) => participant.userID === patch.userID,
|
|
6719
|
+
);
|
|
6720
|
+
if (!existing) {
|
|
6721
|
+
session.participants.push({ ...patch });
|
|
6722
|
+
return;
|
|
6723
|
+
}
|
|
6724
|
+
Object.assign(existing, patch);
|
|
6725
|
+
}
|
|
6109
6726
|
}
|