@vex-chat/libvex 7.1.6 → 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 +57 -3
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +683 -101
- package/dist/Client.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/Client.ts +915 -118
- package/src/__tests__/harness/shared-suite.ts +80 -1
- package/src/index.ts +11 -1
package/src/Client.ts
CHANGED
|
@@ -15,6 +15,11 @@ import type {
|
|
|
15
15
|
import type { KeyPair } from "@vex-chat/crypto";
|
|
16
16
|
import type {
|
|
17
17
|
ActionToken,
|
|
18
|
+
CallAction,
|
|
19
|
+
CallEnvelopeBody,
|
|
20
|
+
CallEvent,
|
|
21
|
+
CallSession,
|
|
22
|
+
CallSignalPayload,
|
|
18
23
|
ChallMsg,
|
|
19
24
|
Channel,
|
|
20
25
|
Device,
|
|
@@ -22,8 +27,10 @@ import type {
|
|
|
22
27
|
Emoji,
|
|
23
28
|
FileResponse,
|
|
24
29
|
FileSQL,
|
|
30
|
+
IceServerConfig,
|
|
25
31
|
Invite,
|
|
26
32
|
KeyBundle,
|
|
33
|
+
MailNotificationHint,
|
|
27
34
|
MailWS,
|
|
28
35
|
NotifyMsg,
|
|
29
36
|
Passkey,
|
|
@@ -37,6 +44,7 @@ import type {
|
|
|
37
44
|
Server,
|
|
38
45
|
ServerChannelBootstrap,
|
|
39
46
|
SessionSQL,
|
|
47
|
+
SignedCallEnvelope,
|
|
40
48
|
} from "@vex-chat/types";
|
|
41
49
|
import type { ClientMessage } from "@vex-chat/types";
|
|
42
50
|
|
|
@@ -70,9 +78,13 @@ import {
|
|
|
70
78
|
XUtils,
|
|
71
79
|
} from "@vex-chat/crypto";
|
|
72
80
|
import {
|
|
81
|
+
CallEnvelopeBodySchema,
|
|
82
|
+
CallEventSchema,
|
|
83
|
+
IceServerConfigSchema,
|
|
73
84
|
MailType,
|
|
74
85
|
MailWSSchema,
|
|
75
86
|
PermissionSchema,
|
|
87
|
+
SignedCallEnvelopeSchema,
|
|
76
88
|
WSMessageSchema,
|
|
77
89
|
} from "@vex-chat/types";
|
|
78
90
|
|
|
@@ -356,6 +368,28 @@ import { uuidToUint8 } from "./utils/uint8uuid.js";
|
|
|
356
368
|
|
|
357
369
|
const _protocolMsgRegex = /��\w+:\w+��/g;
|
|
358
370
|
|
|
371
|
+
/**
|
|
372
|
+
* Voice-call signaling operations.
|
|
373
|
+
*
|
|
374
|
+
* `libvex` moves authenticated call control over Spire. Platform apps own
|
|
375
|
+
* WebRTC/media capture and pass offers, answers, and ICE candidates through
|
|
376
|
+
* these methods.
|
|
377
|
+
*/
|
|
378
|
+
export interface Calls {
|
|
379
|
+
accept: (callID: string, signal?: CallSignalPayload) => Promise<CallEvent>;
|
|
380
|
+
active: () => Promise<CallSession[]>;
|
|
381
|
+
cancel: (callID: string) => Promise<CallEvent>;
|
|
382
|
+
hangup: (callID: string) => Promise<CallEvent>;
|
|
383
|
+
ice: (callID: string, signal: CallSignalPayload) => Promise<CallEvent>;
|
|
384
|
+
iceServers: () => Promise<IceServerConfig[]>;
|
|
385
|
+
reject: (callID: string) => Promise<CallEvent>;
|
|
386
|
+
signal: (callID: string, signal: CallSignalPayload) => Promise<CallEvent>;
|
|
387
|
+
startDM: (
|
|
388
|
+
recipientUserID: string,
|
|
389
|
+
signal?: CallSignalPayload,
|
|
390
|
+
) => Promise<CallEvent>;
|
|
391
|
+
}
|
|
392
|
+
|
|
359
393
|
/**
|
|
360
394
|
* @ignore
|
|
361
395
|
*/
|
|
@@ -690,7 +724,7 @@ export type { Server } from "@vex-chat/types";
|
|
|
690
724
|
export type { ServerChannelBootstrap } from "@vex-chat/types";
|
|
691
725
|
|
|
692
726
|
export interface NotificationSubscription {
|
|
693
|
-
channel:
|
|
727
|
+
channel: NotificationSubscriptionChannel;
|
|
694
728
|
createdAt: string;
|
|
695
729
|
deviceID: string;
|
|
696
730
|
enabled: boolean;
|
|
@@ -702,9 +736,17 @@ export interface NotificationSubscription {
|
|
|
702
736
|
userID: string;
|
|
703
737
|
}
|
|
704
738
|
|
|
739
|
+
export type NotificationSubscriptionChannel = "apnsVoip" | "expo" | "fcmCall";
|
|
740
|
+
|
|
741
|
+
const NotificationSubscriptionChannelSchema = z.enum([
|
|
742
|
+
"apnsVoip",
|
|
743
|
+
"expo",
|
|
744
|
+
"fcmCall",
|
|
745
|
+
]);
|
|
746
|
+
|
|
705
747
|
const NotificationSubscriptionSchema: z.ZodType<NotificationSubscription> =
|
|
706
748
|
z.object({
|
|
707
|
-
channel:
|
|
749
|
+
channel: NotificationSubscriptionChannelSchema,
|
|
708
750
|
createdAt: z.string(),
|
|
709
751
|
deviceID: z.string(),
|
|
710
752
|
enabled: z.boolean(),
|
|
@@ -717,7 +759,7 @@ const NotificationSubscriptionSchema: z.ZodType<NotificationSubscription> =
|
|
|
717
759
|
});
|
|
718
760
|
|
|
719
761
|
export interface NotificationSubscriptionInput {
|
|
720
|
-
channel:
|
|
762
|
+
channel: NotificationSubscriptionChannel;
|
|
721
763
|
events?: string[];
|
|
722
764
|
platform?: "android" | "ios" | "web";
|
|
723
765
|
token: string;
|
|
@@ -899,17 +941,35 @@ const messageSchema: z.ZodType<Message> = z.object({
|
|
|
899
941
|
timestamp: z.string(),
|
|
900
942
|
});
|
|
901
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;
|
|
902
947
|
const MESSAGE_BLOB_PREFIX = "vex-message:1\n";
|
|
903
948
|
const MAIL_FANOUT_CONCURRENCY = 8;
|
|
904
949
|
const MAIL_BATCH_MAX_SIZE = 32;
|
|
905
950
|
const MAIL_BATCH_FLUSH_DELAY_MS = 8;
|
|
906
951
|
|
|
952
|
+
interface CallWakeNotifyData {
|
|
953
|
+
callID: string;
|
|
954
|
+
expiresAt?: string | undefined;
|
|
955
|
+
mailID?: string | undefined;
|
|
956
|
+
mailNonce?: string | undefined;
|
|
957
|
+
}
|
|
958
|
+
|
|
907
959
|
interface DecodedMessagePlaintext {
|
|
908
960
|
extra?: null | string | undefined;
|
|
909
961
|
message: string;
|
|
910
962
|
retentionHintDays?: number | undefined;
|
|
911
963
|
}
|
|
912
964
|
|
|
965
|
+
interface EncryptedCallState {
|
|
966
|
+
peerDeviceID?: string | undefined;
|
|
967
|
+
peerUserID: string;
|
|
968
|
+
pendingPeerDevices: Device[];
|
|
969
|
+
sequence: number;
|
|
970
|
+
session: CallSession;
|
|
971
|
+
}
|
|
972
|
+
|
|
913
973
|
interface PendingMailBatchDelivery {
|
|
914
974
|
header: Uint8Array;
|
|
915
975
|
mail: MailWS;
|
|
@@ -931,6 +991,62 @@ const mailBatchResponseSchema = z.object({
|
|
|
931
991
|
),
|
|
932
992
|
});
|
|
933
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
|
+
|
|
934
1050
|
function decodeMessageBlob(body: string): DecodedMessagePlaintext {
|
|
935
1051
|
if (!body.startsWith(MESSAGE_BLOB_PREFIX)) {
|
|
936
1052
|
return { message: body };
|
|
@@ -968,6 +1084,10 @@ function decodeMessagePlaintext(plaintext: string): DecodedMessagePlaintext {
|
|
|
968
1084
|
: blob;
|
|
969
1085
|
}
|
|
970
1086
|
|
|
1087
|
+
function encodeCallEnvelopePlaintext(envelope: SignedCallEnvelope): Uint8Array {
|
|
1088
|
+
return XUtils.decodeUTF8(CALL_ENVELOPE_PREFIX + JSON.stringify(envelope));
|
|
1089
|
+
}
|
|
1090
|
+
|
|
971
1091
|
function encodeMessagePlaintext(
|
|
972
1092
|
message: string,
|
|
973
1093
|
opts?: MessageSendOptions,
|
|
@@ -983,6 +1103,13 @@ function encodeMessagePlaintext(
|
|
|
983
1103
|
return formatVexRetentionEnvelope(body, opts?.retentionHintDays);
|
|
984
1104
|
}
|
|
985
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
|
+
|
|
986
1113
|
function messageFromDecodedPlaintext(
|
|
987
1114
|
decoded: DecodedMessagePlaintext,
|
|
988
1115
|
): Pick<Message, "extra" | "message" | "retentionHintDays"> {
|
|
@@ -995,6 +1122,12 @@ function messageFromDecodedPlaintext(
|
|
|
995
1122
|
};
|
|
996
1123
|
}
|
|
997
1124
|
|
|
1125
|
+
function normalizeCallEnvelopeBodyForWire(
|
|
1126
|
+
body: CallEnvelopeBody,
|
|
1127
|
+
): CallEnvelopeBody {
|
|
1128
|
+
return CallEnvelopeBodySchema.parse(jsonWireValue(body));
|
|
1129
|
+
}
|
|
1130
|
+
|
|
998
1131
|
function normalizeForwardedMessage(message: Message): Message {
|
|
999
1132
|
const decoded = decodeMessagePlaintext(message.message);
|
|
1000
1133
|
return {
|
|
@@ -1034,6 +1167,10 @@ const retryRequestNotifyData = z.union([
|
|
|
1034
1167
|
* and {@link Client.once}.
|
|
1035
1168
|
*/
|
|
1036
1169
|
export interface ClientEvents {
|
|
1170
|
+
/** Voice-call signaling changed or an incoming call was received. */
|
|
1171
|
+
call: (event: CallEvent) => void;
|
|
1172
|
+
/** Native/mobile call wake hint arrived; clients should sync call mail. */
|
|
1173
|
+
callWake: (wake: CallWakeNotifyData) => void;
|
|
1037
1174
|
/** The client has been shut down (via {@link Client.close}). */
|
|
1038
1175
|
closed: () => void;
|
|
1039
1176
|
/** WebSocket authorized by the server; pre-auth setup begins. */
|
|
@@ -1280,6 +1417,31 @@ export class Client {
|
|
|
1280
1417
|
|
|
1281
1418
|
private static readonly NOT_FOUND_TTL = 30 * 60 * 1000;
|
|
1282
1419
|
|
|
1420
|
+
/**
|
|
1421
|
+
* Voice-call signaling operations.
|
|
1422
|
+
*
|
|
1423
|
+
* Platform apps own native media capture/WebRTC. These methods only move
|
|
1424
|
+
* authenticated signaling and call state over Spire.
|
|
1425
|
+
*/
|
|
1426
|
+
public calls: Calls = {
|
|
1427
|
+
accept: (callID: string, signal?: CallSignalPayload) =>
|
|
1428
|
+
this.sendEncryptedCallAction("accept", callID, signal),
|
|
1429
|
+
active: this.fetchActiveCalls.bind(this),
|
|
1430
|
+
cancel: (callID: string) =>
|
|
1431
|
+
this.sendEncryptedCallAction("cancel", callID),
|
|
1432
|
+
hangup: (callID: string) =>
|
|
1433
|
+
this.sendEncryptedCallAction("hangup", callID),
|
|
1434
|
+
ice: (callID: string, signal: CallSignalPayload) =>
|
|
1435
|
+
this.sendEncryptedCallAction("ice", callID, signal),
|
|
1436
|
+
iceServers: this.fetchIceServers.bind(this),
|
|
1437
|
+
reject: (callID: string) =>
|
|
1438
|
+
this.sendEncryptedCallAction("reject", callID),
|
|
1439
|
+
signal: (callID: string, signal: CallSignalPayload) =>
|
|
1440
|
+
this.sendEncryptedCallAction("signal", callID, signal),
|
|
1441
|
+
startDM: (recipientUserID: string, signal?: CallSignalPayload) =>
|
|
1442
|
+
this.startEncryptedDmCall(recipientUserID, signal),
|
|
1443
|
+
};
|
|
1444
|
+
|
|
1283
1445
|
/**
|
|
1284
1446
|
* Browser-safe NODE_ENV accessor.
|
|
1285
1447
|
* Uses indirect lookup so the bare `process` global never appears in
|
|
@@ -1591,6 +1753,8 @@ export class Client {
|
|
|
1591
1753
|
|
|
1592
1754
|
private autoReconnectEnabled = false;
|
|
1593
1755
|
|
|
1756
|
+
private readonly callStates = new Map<string, EncryptedCallState>();
|
|
1757
|
+
|
|
1594
1758
|
private readonly cryptoProfile: CryptoProfile;
|
|
1595
1759
|
|
|
1596
1760
|
private readonly database: Storage;
|
|
@@ -1600,13 +1764,12 @@ export class Client {
|
|
|
1600
1764
|
private readonly decryptFailureCounts = new Map<string, number>();
|
|
1601
1765
|
|
|
1602
1766
|
private device?: Device;
|
|
1603
|
-
|
|
1604
1767
|
private deviceRecords: Record<string, Device> = {};
|
|
1768
|
+
|
|
1605
1769
|
// ── Event subscription (composition over inheritance) ───────────────
|
|
1606
1770
|
private readonly emitter = new EventEmitter<ClientEvents>();
|
|
1607
1771
|
|
|
1608
1772
|
private fetchingMail: boolean = false;
|
|
1609
|
-
|
|
1610
1773
|
private firstMailFetch = true;
|
|
1611
1774
|
private readonly forwarded = new Set<string>();
|
|
1612
1775
|
private readonly host: string;
|
|
@@ -1614,8 +1777,8 @@ export class Client {
|
|
|
1614
1777
|
/** Cancels in-flight HTTP work on `close()` so `postAuth`/`getMail` cannot hang forever. */
|
|
1615
1778
|
private readonly httpAbortController = new AbortController();
|
|
1616
1779
|
private readonly idKeys: KeyPair | null;
|
|
1617
|
-
private isAlive: boolean = true;
|
|
1618
1780
|
|
|
1781
|
+
private isAlive: boolean = true;
|
|
1619
1782
|
private localMessageRetentionDays: number;
|
|
1620
1783
|
private localRetentionPurgeTimer: null | ReturnType<typeof setInterval> =
|
|
1621
1784
|
null;
|
|
@@ -2503,6 +2666,92 @@ export class Client {
|
|
|
2503
2666
|
this.acknowledgeInboundMail(mail);
|
|
2504
2667
|
}
|
|
2505
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
|
+
|
|
2506
2755
|
private async approveDeviceRequest(requestID: string): Promise<Device> {
|
|
2507
2756
|
const req = await this.getDeviceRegistrationRequest(requestID);
|
|
2508
2757
|
if (!req) {
|
|
@@ -2584,6 +2833,50 @@ export class Client {
|
|
|
2584
2833
|
return decodeHttpResponse(PasskeyOptionsCodec, response.data);
|
|
2585
2834
|
}
|
|
2586
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
|
+
|
|
2587
2880
|
private censorPreKey(preKey: PreKeysSQL): PreKeysWS {
|
|
2588
2881
|
if (!preKey.index) {
|
|
2589
2882
|
throw new Error("Key index is required.");
|
|
@@ -2742,6 +3035,7 @@ export class Client {
|
|
|
2742
3035
|
* errors should not reject the full read pipeline.
|
|
2743
3036
|
*/
|
|
2744
3037
|
allowKeyBundleFailure = false,
|
|
3038
|
+
notify?: MailNotificationHint,
|
|
2745
3039
|
): Promise<Message | null> {
|
|
2746
3040
|
return this.runWithThisCryptoProfile(async () => {
|
|
2747
3041
|
let keyBundle: KeyBundle;
|
|
@@ -2859,12 +3153,13 @@ export class Client {
|
|
|
2859
3153
|
recipient: device.deviceID,
|
|
2860
3154
|
sender: this.getDevice().deviceID,
|
|
2861
3155
|
};
|
|
3156
|
+
const wireMail: MailWS = notify ? { ...mail, notify } : mail;
|
|
2862
3157
|
|
|
2863
3158
|
const hmac = xHMAC(mail, SK);
|
|
2864
3159
|
|
|
2865
3160
|
const msg: ResourceMsg = {
|
|
2866
3161
|
action: "CREATE",
|
|
2867
|
-
data:
|
|
3162
|
+
data: wireMail,
|
|
2868
3163
|
resourceType: "mail",
|
|
2869
3164
|
transmissionID: uuid.v4(),
|
|
2870
3165
|
type: "resource",
|
|
@@ -2888,35 +3183,41 @@ export class Client {
|
|
|
2888
3183
|
|
|
2889
3184
|
this.emitter.emit("session", sessionEntry, user);
|
|
2890
3185
|
|
|
2891
|
-
|
|
3186
|
+
const rawPlaintext = forward ? "" : XUtils.encodeUTF8(message);
|
|
3187
|
+
const callEnvelope = forward
|
|
3188
|
+
? null
|
|
3189
|
+
: decodeCallEnvelopePlaintext(rawPlaintext);
|
|
2892
3190
|
const forwardedMsg = forward
|
|
2893
3191
|
? messageSchema.parse(msgpack.decode(message))
|
|
2894
3192
|
: null;
|
|
2895
|
-
const
|
|
2896
|
-
const emitMsg: Message = forwardedMsg
|
|
3193
|
+
const emitMsg: Message | null = forwardedMsg
|
|
2897
3194
|
? { ...normalizeForwardedMessage(forwardedMsg), forward: true }
|
|
2898
|
-
:
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
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) {
|
|
2915
3216
|
this.emitter.emit("message", emitMsg);
|
|
2916
3217
|
}
|
|
2917
3218
|
|
|
2918
|
-
await this.deliverMailResource(msg, hmac,
|
|
2919
|
-
return
|
|
3219
|
+
await this.deliverMailResource(msg, hmac, wireMail);
|
|
3220
|
+
return emitMsg;
|
|
2920
3221
|
});
|
|
2921
3222
|
}
|
|
2922
3223
|
|
|
@@ -2957,6 +3258,68 @@ export class Client {
|
|
|
2957
3258
|
await this.http.delete(this.getHost() + "/server/" + serverID);
|
|
2958
3259
|
}
|
|
2959
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
|
+
}
|
|
2960
3323
|
private deliverMailResource(
|
|
2961
3324
|
msg: ResourceMsg,
|
|
2962
3325
|
header: Uint8Array,
|
|
@@ -2980,6 +3343,7 @@ export class Client {
|
|
|
2980
3343
|
}
|
|
2981
3344
|
});
|
|
2982
3345
|
}
|
|
3346
|
+
|
|
2983
3347
|
private async deliverMailResourceOverSocket(
|
|
2984
3348
|
msg: ResourceMsg,
|
|
2985
3349
|
header: Uint8Array,
|
|
@@ -3009,6 +3373,7 @@ export class Client {
|
|
|
3009
3373
|
});
|
|
3010
3374
|
});
|
|
3011
3375
|
}
|
|
3376
|
+
|
|
3012
3377
|
private deviceListFailureDetail(err: unknown): string {
|
|
3013
3378
|
if (!isHttpError(err)) {
|
|
3014
3379
|
return "";
|
|
@@ -3040,6 +3405,70 @@ export class Client {
|
|
|
3040
3405
|
});
|
|
3041
3406
|
}
|
|
3042
3407
|
|
|
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);
|
|
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
|
+
|
|
3463
|
+
private async fetchIceServers(): Promise<IceServerConfig[]> {
|
|
3464
|
+
const res = await this.http.get(this.getHost() + "/calls/ice-servers", {
|
|
3465
|
+
responseType: "json",
|
|
3466
|
+
});
|
|
3467
|
+
return z
|
|
3468
|
+
.object({ iceServers: z.array(IceServerConfigSchema) })
|
|
3469
|
+
.parse(res.data).iceServers;
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3043
3472
|
/**
|
|
3044
3473
|
* Gets a list of permissions for a server.
|
|
3045
3474
|
*
|
|
@@ -3151,6 +3580,24 @@ export class Client {
|
|
|
3151
3580
|
throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
|
|
3152
3581
|
}
|
|
3153
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
|
+
|
|
3154
3601
|
/**
|
|
3155
3602
|
* Finish a passkey login and adopt the resulting JWT as the
|
|
3156
3603
|
* client's bearer token. After this call, `client.passkeys.*`
|
|
@@ -3680,6 +4127,22 @@ export class Client {
|
|
|
3680
4127
|
|
|
3681
4128
|
private async handleNotify(msg: NotifyMsg) {
|
|
3682
4129
|
switch (msg.event) {
|
|
4130
|
+
case "call":
|
|
4131
|
+
case "callInvite": {
|
|
4132
|
+
const parsed = CallEventSchema.safeParse(msg.data);
|
|
4133
|
+
if (parsed.success) {
|
|
4134
|
+
this.emitter.emit("call", parsed.data);
|
|
4135
|
+
}
|
|
4136
|
+
break;
|
|
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
|
+
}
|
|
3683
4146
|
case "deviceRequest": {
|
|
3684
4147
|
const parsed = deviceRequestNotifyData.safeParse(msg.data);
|
|
3685
4148
|
if (parsed.success) {
|
|
@@ -3941,6 +4404,84 @@ export class Client {
|
|
|
3941
4404
|
return decodeHttpResponse(PasskeyArrayCodec, response.data);
|
|
3942
4405
|
}
|
|
3943
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
|
+
|
|
3944
4485
|
private async markSessionVerified(sessionID: string) {
|
|
3945
4486
|
return this.database.markSessionVerified(sessionID);
|
|
3946
4487
|
}
|
|
@@ -4169,6 +4710,41 @@ export class Client {
|
|
|
4169
4710
|
}
|
|
4170
4711
|
}
|
|
4171
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
|
+
|
|
4172
4748
|
private async publishPendingDeviceRegistration(args: {
|
|
4173
4749
|
challenge: string;
|
|
4174
4750
|
requestID: string;
|
|
@@ -4524,46 +5100,61 @@ export class Client {
|
|
|
4524
5100
|
if (!mail.forward) {
|
|
4525
5101
|
plaintext = XUtils.encodeUTF8(unsealed);
|
|
4526
5102
|
}
|
|
4527
|
-
const
|
|
5103
|
+
const callEnvelope = mail.forward
|
|
4528
5104
|
? null
|
|
4529
|
-
:
|
|
4530
|
-
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
|
|
4542
|
-
|
|
4543
|
-
|
|
4544
|
-
|
|
4545
|
-
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
4549
|
-
|
|
4550
|
-
|
|
4551
|
-
|
|
4552
|
-
|
|
4553
|
-
|
|
4554
|
-
|
|
4555
|
-
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
|
-
|
|
4559
|
-
|
|
4560
|
-
|
|
4561
|
-
|
|
4562
|
-
|
|
4563
|
-
|
|
4564
|
-
|
|
4565
|
-
|
|
4566
|
-
|
|
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
|
+
}
|
|
4567
5158
|
}
|
|
4568
5159
|
if (libvexDebugDmEnabled()) {
|
|
4569
5160
|
try {
|
|
@@ -4823,37 +5414,51 @@ export class Client {
|
|
|
4823
5414
|
? messageSchema.parse(msgpack.decode(decrypted))
|
|
4824
5415
|
: null;
|
|
4825
5416
|
const rawIncoming = XUtils.encodeUTF8(decrypted);
|
|
4826
|
-
const
|
|
5417
|
+
const callEnvelope = mail.forward
|
|
4827
5418
|
? null
|
|
4828
|
-
:
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
4832
|
-
|
|
4833
|
-
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
|
|
4839
|
-
|
|
4840
|
-
|
|
4841
|
-
|
|
4842
|
-
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
|
|
4849
|
-
|
|
4850
|
-
|
|
4851
|
-
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
|
|
4855
|
-
|
|
4856
|
-
|
|
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
|
+
}
|
|
4857
5462
|
|
|
4858
5463
|
const sqlPatch = sessionToSqlPatch(session);
|
|
4859
5464
|
const persisted: SessionSQL = {
|
|
@@ -5269,6 +5874,73 @@ export class Client {
|
|
|
5269
5874
|
}
|
|
5270
5875
|
}
|
|
5271
5876
|
|
|
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
|
+
}
|
|
5896
|
+
|
|
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
|
+
);
|
|
5930
|
+
|
|
5931
|
+
await this.deliverCallEnvelopeBatch({
|
|
5932
|
+
bodies,
|
|
5933
|
+
mailID: uuid.v4(),
|
|
5934
|
+
targetUser,
|
|
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);
|
|
5942
|
+
}
|
|
5943
|
+
|
|
5272
5944
|
private async sendGroupMessage(
|
|
5273
5945
|
channelID: string,
|
|
5274
5946
|
message: string,
|
|
@@ -5405,6 +6077,7 @@ export class Client {
|
|
|
5405
6077
|
mailID: null | string,
|
|
5406
6078
|
forward: boolean,
|
|
5407
6079
|
retry = false,
|
|
6080
|
+
notify?: MailNotificationHint,
|
|
5408
6081
|
): Promise<Message | null> {
|
|
5409
6082
|
while (this.sending.has(device.deviceID)) {
|
|
5410
6083
|
await sleep(100);
|
|
@@ -5431,6 +6104,7 @@ export class Client {
|
|
|
5431
6104
|
mailID,
|
|
5432
6105
|
forward,
|
|
5433
6106
|
false,
|
|
6107
|
+
notify,
|
|
5434
6108
|
);
|
|
5435
6109
|
if (libvexDebugDmEnabled()) {
|
|
5436
6110
|
debugLibvexDm("sendMail: createSession returned", {
|
|
@@ -5473,10 +6147,11 @@ export class Client {
|
|
|
5473
6147
|
recipient: device.deviceID,
|
|
5474
6148
|
sender: this.getDevice().deviceID,
|
|
5475
6149
|
};
|
|
6150
|
+
const wireMail: MailWS = notify ? { ...mail, notify } : mail;
|
|
5476
6151
|
|
|
5477
6152
|
const msgb: ResourceMsg = {
|
|
5478
6153
|
action: "CREATE",
|
|
5479
|
-
data:
|
|
6154
|
+
data: wireMail,
|
|
5480
6155
|
resourceType: "mail",
|
|
5481
6156
|
transmissionID: uuid.v4(),
|
|
5482
6157
|
type: "resource",
|
|
@@ -5484,28 +6159,36 @@ export class Client {
|
|
|
5484
6159
|
|
|
5485
6160
|
const hmac = xHMAC(mail, messageKey);
|
|
5486
6161
|
|
|
6162
|
+
const rawPlaintext = forward ? "" : XUtils.encodeUTF8(msg);
|
|
6163
|
+
const callEnvelope = forward
|
|
6164
|
+
? null
|
|
6165
|
+
: decodeCallEnvelopePlaintext(rawPlaintext);
|
|
5487
6166
|
const fwdOut = forward
|
|
5488
6167
|
? messageSchema.parse(msgpack.decode(msg))
|
|
5489
6168
|
: null;
|
|
5490
|
-
const outMsg: Message = fwdOut
|
|
6169
|
+
const outMsg: Message | null = fwdOut
|
|
5491
6170
|
? { ...normalizeForwardedMessage(fwdOut), forward: true }
|
|
5492
|
-
:
|
|
5493
|
-
|
|
5494
|
-
|
|
5495
|
-
|
|
5496
|
-
|
|
5497
|
-
|
|
5498
|
-
|
|
5499
|
-
|
|
5500
|
-
|
|
5501
|
-
|
|
5502
|
-
|
|
5503
|
-
|
|
5504
|
-
|
|
5505
|
-
|
|
5506
|
-
|
|
5507
|
-
|
|
5508
|
-
|
|
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
|
+
}
|
|
5509
6192
|
|
|
5510
6193
|
const sqlPatch = sessionToSqlPatch(session);
|
|
5511
6194
|
const persisted: SessionSQL = {
|
|
@@ -5532,7 +6215,7 @@ export class Client {
|
|
|
5532
6215
|
await this.database.saveSession(persisted);
|
|
5533
6216
|
this.sessionRecords[XUtils.encodeHex(session.publicKey)] = session;
|
|
5534
6217
|
|
|
5535
|
-
await this.deliverMailResource(msgb, hmac,
|
|
6218
|
+
await this.deliverMailResource(msgb, hmac, wireMail);
|
|
5536
6219
|
return outMsg;
|
|
5537
6220
|
} finally {
|
|
5538
6221
|
this.sending.delete(device.deviceID);
|
|
@@ -5547,6 +6230,7 @@ export class Client {
|
|
|
5547
6230
|
mailID: null | string,
|
|
5548
6231
|
forward: boolean,
|
|
5549
6232
|
forceFreshSession = false,
|
|
6233
|
+
notify?: MailNotificationHint,
|
|
5550
6234
|
): Promise<Message | null> {
|
|
5551
6235
|
try {
|
|
5552
6236
|
return await this.sendMail(
|
|
@@ -5557,6 +6241,7 @@ export class Client {
|
|
|
5557
6241
|
mailID,
|
|
5558
6242
|
forward,
|
|
5559
6243
|
forceFreshSession,
|
|
6244
|
+
notify,
|
|
5560
6245
|
);
|
|
5561
6246
|
} catch (err: unknown) {
|
|
5562
6247
|
if (!this.shouldRetryDeliveryWithFreshSession(err)) {
|
|
@@ -5570,6 +6255,7 @@ export class Client {
|
|
|
5570
6255
|
mailID,
|
|
5571
6256
|
forward,
|
|
5572
6257
|
true,
|
|
6258
|
+
notify,
|
|
5573
6259
|
);
|
|
5574
6260
|
}
|
|
5575
6261
|
}
|
|
@@ -5747,6 +6433,33 @@ export class Client {
|
|
|
5747
6433
|
this.send(receipt).catch(ignoreSocketTeardown);
|
|
5748
6434
|
}
|
|
5749
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
|
+
|
|
5750
6463
|
private setAlive(status: boolean) {
|
|
5751
6464
|
this.isAlive = status;
|
|
5752
6465
|
}
|
|
@@ -5793,6 +6506,76 @@ export class Client {
|
|
|
5793
6506
|
);
|
|
5794
6507
|
}
|
|
5795
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
|
+
|
|
5796
6579
|
private async submitOTK(amount: number) {
|
|
5797
6580
|
const otks: UnsavedPreKey[] = [];
|
|
5798
6581
|
|
|
@@ -5926,4 +6709,18 @@ export class Client {
|
|
|
5926
6709
|
return null;
|
|
5927
6710
|
}
|
|
5928
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
|
+
}
|
|
5929
6726
|
}
|