zapo-js 1.4.0 → 1.5.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.
Files changed (94) hide show
  1. package/dist/appstate/types.d.ts +1 -2
  2. package/dist/auth/WaAuthClient.d.ts +10 -0
  3. package/dist/auth/WaAuthClient.js +17 -8
  4. package/dist/auth/credentials-flow.d.ts +8 -0
  5. package/dist/auth/credentials-flow.js +35 -2
  6. package/dist/auth/pairing/companion-host.d.ts +105 -0
  7. package/dist/auth/pairing/companion-host.js +148 -0
  8. package/dist/auth/types.d.ts +20 -9
  9. package/dist/client/WaClient.d.ts +15 -5
  10. package/dist/client/WaClient.js +46 -12
  11. package/dist/client/WaClientFactory.d.ts +3 -0
  12. package/dist/client/WaClientFactory.js +24 -6
  13. package/dist/client/coordinators/WaAppStateMutationCoordinator.d.ts +8 -0
  14. package/dist/client/coordinators/WaAppStateMutationCoordinator.js +37 -1
  15. package/dist/client/coordinators/WaGroupCoordinator.js +6 -1
  16. package/dist/client/coordinators/WaMessageDispatchCoordinator.d.ts +3 -1
  17. package/dist/client/coordinators/WaMessageDispatchCoordinator.js +13 -3
  18. package/dist/client/coordinators/WaMobileCoordinator.d.ts +192 -0
  19. package/dist/client/coordinators/WaMobileCoordinator.js +645 -0
  20. package/dist/client/messaging/companion-host.d.ts +43 -0
  21. package/dist/client/messaging/companion-host.js +61 -0
  22. package/dist/client/messaging/key-protocol.js +25 -1
  23. package/dist/client/persistence/companion-host.d.ts +36 -0
  24. package/dist/client/persistence/companion-host.js +57 -0
  25. package/dist/client/plugins/install.d.ts +2 -1
  26. package/dist/client/plugins/install.js +44 -11
  27. package/dist/client/types.d.ts +71 -7
  28. package/dist/esm/auth/WaAuthClient.js +18 -9
  29. package/dist/esm/auth/credentials-flow.js +36 -3
  30. package/dist/esm/auth/pairing/companion-host.js +140 -0
  31. package/dist/esm/client/WaClient.js +46 -12
  32. package/dist/esm/client/WaClientFactory.js +25 -7
  33. package/dist/esm/client/coordinators/WaAppStateMutationCoordinator.js +37 -1
  34. package/dist/esm/client/coordinators/WaGroupCoordinator.js +6 -1
  35. package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +14 -4
  36. package/dist/esm/client/coordinators/WaMobileCoordinator.js +641 -0
  37. package/dist/esm/client/messaging/companion-host.js +56 -0
  38. package/dist/esm/client/messaging/key-protocol.js +25 -1
  39. package/dist/esm/client/persistence/companion-host.js +54 -0
  40. package/dist/esm/client/plugins/install.js +44 -11
  41. package/dist/esm/index.js +5 -2
  42. package/dist/esm/message/WaMessageClient.js +3 -2
  43. package/dist/esm/message/context-info.js +4 -0
  44. package/dist/esm/protocol/constants.js +4 -3
  45. package/dist/esm/protocol/device-identity.js +18 -0
  46. package/dist/esm/protocol/group.js +6 -0
  47. package/dist/esm/protocol/message.js +12 -0
  48. package/dist/esm/protocol/nodes.js +15 -1
  49. package/dist/esm/retry/replay.js +3 -3
  50. package/dist/esm/signal/attestation/WaAdvSignature.js +67 -2
  51. package/dist/esm/signal/attestation/constants.js +2 -0
  52. package/dist/esm/signal/index.js +1 -0
  53. package/dist/esm/transport/index.js +1 -1
  54. package/dist/esm/transport/node/builders/group.js +4 -3
  55. package/dist/esm/transport/node/builders/mobile.js +113 -0
  56. package/dist/esm/transport/noise/WaClientPayload.js +16 -7
  57. package/dist/esm/transport/wa-version-fetcher.js +152 -0
  58. package/dist/esm/util/index.js +1 -0
  59. package/dist/index.d.ts +10 -4
  60. package/dist/index.js +13 -4
  61. package/dist/message/WaMessageClient.js +2 -1
  62. package/dist/message/context-info.d.ts +2 -0
  63. package/dist/message/context-info.js +5 -0
  64. package/dist/protocol/constants.d.ts +6 -4
  65. package/dist/protocol/constants.js +8 -2
  66. package/dist/protocol/device-identity.d.ts +25 -0
  67. package/dist/protocol/device-identity.js +21 -0
  68. package/dist/protocol/group.d.ts +6 -0
  69. package/dist/protocol/group.js +7 -1
  70. package/dist/protocol/message.d.ts +14 -0
  71. package/dist/protocol/message.js +13 -1
  72. package/dist/protocol/nodes.d.ts +14 -0
  73. package/dist/protocol/nodes.js +15 -1
  74. package/dist/retry/replay.js +2 -2
  75. package/dist/signal/attestation/WaAdvSignature.d.ts +50 -1
  76. package/dist/signal/attestation/WaAdvSignature.js +71 -1
  77. package/dist/signal/attestation/constants.d.ts +2 -0
  78. package/dist/signal/attestation/constants.js +3 -1
  79. package/dist/signal/index.d.ts +1 -0
  80. package/dist/signal/index.js +13 -1
  81. package/dist/transport/index.d.ts +2 -2
  82. package/dist/transport/index.js +4 -3
  83. package/dist/transport/node/builders/group.js +4 -3
  84. package/dist/transport/node/builders/mobile.d.ts +68 -0
  85. package/dist/transport/node/builders/mobile.js +120 -0
  86. package/dist/transport/noise/WaClientPayload.js +16 -7
  87. package/dist/transport/wa-version-fetcher.d.ts +92 -0
  88. package/dist/transport/wa-version-fetcher.js +156 -0
  89. package/dist/util/index.d.ts +1 -0
  90. package/dist/util/index.js +3 -1
  91. package/package.json +1 -1
  92. package/dist/esm/transport/wa-web-version-fetcher.js +0 -91
  93. package/dist/transport/wa-web-version-fetcher.d.ts +0 -44
  94. package/dist/transport/wa-web-version-fetcher.js +0 -94
@@ -0,0 +1,140 @@
1
+ import { aesCtrDecrypt, aesCtrEncrypt, aesGcmDecrypt, hkdf, pbkdf2Sha256, randomBytesAsync, toRawPubKey, X25519 } from '../../crypto/index.js';
2
+ import { proto } from '../../proto.js';
3
+ import { WA_PAIRING_KDF_INFO } from '../../protocol/index.js';
4
+ import { computeAdvIdentityHmac, generateDeviceIdentityAccountSignature, signKeyIndexList } from '../../signal/index.js';
5
+ import { concatBytes, decodeBase64Url, TEXT_ENCODER, uint8Equal } from '../../util/bytes.js';
6
+ const PEM_HKDF_INFO = TEXT_ENCODER.encode('Canonical Ent Companion Nonce Encrypt');
7
+ const PBKDF2_ITERATIONS = 131072;
8
+ const PAIRING_AES_KEY_BYTES = 32;
9
+ /**
10
+ * Parses a companion pairing QR of the form
11
+ * `ref,noisePubB64,identityPubB64,advSecretB64,platform`. The `ref` may itself
12
+ * contain commas, so the four trailing fields are taken from the end.
13
+ *
14
+ * @throws when fewer than 5 comma-separated fields are present.
15
+ */
16
+ export function parseCompanionQr(qr) {
17
+ const parts = qr.split(',');
18
+ if (parts.length < 5) {
19
+ throw new Error(`companion qr must have at least 5 comma-separated parts, got ${parts.length}`);
20
+ }
21
+ const platform = parts[parts.length - 1];
22
+ const advSecretB64 = parts[parts.length - 2];
23
+ const identityPubB64 = parts[parts.length - 3];
24
+ const noisePubB64 = parts[parts.length - 4];
25
+ const ref = parts.slice(0, parts.length - 4).join(',');
26
+ return {
27
+ ref,
28
+ noisePublicKey: decodeBase64Url(noisePubB64, 'companionQr.noisePublicKey'),
29
+ identityPublicKey: decodeBase64Url(identityPubB64, 'companionQr.identityPublicKey'),
30
+ advSecretKey: decodeBase64Url(advSecretB64, 'companionQr.advSecretKey'),
31
+ platform
32
+ };
33
+ }
34
+ /**
35
+ * Builds the account-signed `ADVSignedKeyIndexList` bytes for the current device
36
+ * set. The zero-valued `accountType` enum is omitted to match the phone's byte
37
+ * layout.
38
+ */
39
+ export async function buildSignedKeyIndexList(input) {
40
+ const details = proto.ADVKeyIndexList.encode({
41
+ rawId: input.rawId,
42
+ timestamp: input.timestampSeconds,
43
+ currentIndex: input.currentIndex,
44
+ validIndexes: [...input.validIndexes]
45
+ }).finish();
46
+ const accountSignature = await signKeyIndexList(details, input.accountIdentityKeyPair);
47
+ return proto.ADVSignedKeyIndexList.encode({ details, accountSignature }).finish();
48
+ }
49
+ /**
50
+ * Builds the signed device identity and key-index list for a linking companion.
51
+ * The byte layout mirrors WhatsApp's phone (and the in-repo fake-server
52
+ * reference): the zero-valued `accountType`/`deviceType` enum fields are omitted
53
+ * so the signed `details` match what the companion verifies.
54
+ */
55
+ export async function buildSignedCompanionIdentity(input) {
56
+ const accountSignatureKey = toRawPubKey(input.accountIdentityKeyPair.pubKey);
57
+ const details = proto.ADVDeviceIdentity.encode({
58
+ rawId: input.rawId,
59
+ timestamp: input.timestampSeconds,
60
+ keyIndex: input.keyIndex
61
+ }).finish();
62
+ const accountSignature = await generateDeviceIdentityAccountSignature(details, input.companionIdentityPublicKey, input.accountIdentityKeyPair);
63
+ const signedIdentity = proto.ADVSignedDeviceIdentity.encode({
64
+ details,
65
+ accountSignatureKey,
66
+ accountSignature
67
+ }).finish();
68
+ const hmac = computeAdvIdentityHmac(input.advSecretKey, signedIdentity);
69
+ const deviceIdentityBytes = proto.ADVSignedDeviceIdentityHMAC.encode({
70
+ details: signedIdentity,
71
+ hmac
72
+ }).finish();
73
+ const keyIndexListBytes = await buildSignedKeyIndexList({
74
+ accountIdentityKeyPair: input.accountIdentityKeyPair,
75
+ rawId: input.rawId,
76
+ currentIndex: input.keyIndex,
77
+ timestampSeconds: input.timestampSeconds,
78
+ validIndexes: input.validIndexes
79
+ });
80
+ return { deviceIdentityBytes, keyIndexListBytes };
81
+ }
82
+ /**
83
+ * Derives the `<pem>` AES-GCM key the phone ships in the pair-device upload:
84
+ * `HKDF-SHA256(ikm = advSecret, salt = companionNoisePublicKey, info)`. Not yet
85
+ * verified against a live server from this implementation; see
86
+ * `CompanionHostOptions.includePem`.
87
+ */
88
+ export function derivePemKey(advSecretKey, companionNoisePublicKey) {
89
+ return hkdf(advSecretKey, companionNoisePublicKey, PEM_HKDF_INFO, 32);
90
+ }
91
+ /**
92
+ * Link-code handshake, primary step 1: unwrap the companion ephemeral with the
93
+ * pairing code, generate the primary ephemeral, compute the shared ECDH secret,
94
+ * and wrap the primary ephemeral for the `primary_hello` stanza. Mirror of the
95
+ * companion's `createCompanionHello` + primary-ephemeral unwrap.
96
+ */
97
+ export async function preparePrimaryHello(args) {
98
+ if (args.wrappedCompanionEphemeralPub.length < 48) {
99
+ throw new Error(`wrapped companion ephemeral pub too short: ${args.wrappedCompanionEphemeralPub.length} bytes (need >= 48)`);
100
+ }
101
+ const codeBytes = TEXT_ENCODER.encode(args.pairingCode);
102
+ const companionCipherKey = await pbkdf2Sha256(codeBytes, args.wrappedCompanionEphemeralPub.subarray(0, 32), PBKDF2_ITERATIONS, PAIRING_AES_KEY_BYTES);
103
+ const companionEphemeralPub = aesCtrDecrypt(companionCipherKey, args.wrappedCompanionEphemeralPub.subarray(32, 48), args.wrappedCompanionEphemeralPub.subarray(48));
104
+ const [primaryEphemeralKeyPair, salt, counter] = await Promise.all([
105
+ X25519.generateKeyPair(),
106
+ randomBytesAsync(32),
107
+ randomBytesAsync(16)
108
+ ]);
109
+ const primaryCipherKey = await pbkdf2Sha256(codeBytes, salt, PBKDF2_ITERATIONS, PAIRING_AES_KEY_BYTES);
110
+ const encrypted = aesCtrEncrypt(primaryCipherKey, counter, primaryEphemeralKeyPair.pubKey);
111
+ const sharedEphemeral = await X25519.scalarMult(primaryEphemeralKeyPair.privKey, companionEphemeralPub);
112
+ return {
113
+ primaryEphemeralKeyPair,
114
+ wrappedPrimaryEphemeralPub: concatBytes([salt, counter, encrypted]),
115
+ sharedEphemeral
116
+ };
117
+ }
118
+ /**
119
+ * Link-code handshake, primary step 2: decrypt the `companion_finish` key bundle
120
+ * and derive the shared `advSecret` (byte-identical to what the companion
121
+ * derives). Validates that the bundle binds both identities.
122
+ *
123
+ * @throws when the bundle's embedded identities do not match.
124
+ */
125
+ export async function completePrimaryHandshake(args) {
126
+ const bundleEncryptionKey = hkdf(args.sharedEphemeral, args.wrappedKeyBundle.subarray(0, 32), WA_PAIRING_KDF_INFO.LINK_CODE_BUNDLE, 32);
127
+ const plaintextBundle = aesGcmDecrypt(bundleEncryptionKey, args.wrappedKeyBundle.subarray(32, 44), args.wrappedKeyBundle.subarray(44));
128
+ const bundleCompanionIdentity = plaintextBundle.subarray(0, 32);
129
+ const bundlePrimaryIdentity = plaintextBundle.subarray(32, 64);
130
+ const bundleSecret = plaintextBundle.subarray(64);
131
+ const rawCompanionIdentity = toRawPubKey(args.companionIdentityPub);
132
+ if (!uint8Equal(bundlePrimaryIdentity, toRawPubKey(args.primaryIdentityKeyPair.pubKey))) {
133
+ throw new Error('link-code bundle primary identity mismatch');
134
+ }
135
+ if (!uint8Equal(bundleCompanionIdentity, rawCompanionIdentity)) {
136
+ throw new Error('link-code bundle companion identity mismatch');
137
+ }
138
+ const sharedIdentity = await X25519.scalarMult(args.primaryIdentityKeyPair.privKey, rawCompanionIdentity);
139
+ return hkdf(concatBytes([args.sharedEphemeral, sharedIdentity, bundleSecret]), null, WA_PAIRING_KDF_INFO.ADV_SECRET, 32);
140
+ }
@@ -12,7 +12,7 @@ import { normalizeDeviceJid } from '../protocol/jid.js';
12
12
  import { WA_DISCONNECT_REASONS, WA_LOGOUT_REASONS } from '../protocol/stream.js';
13
13
  import { buildRemoveCompanionDeviceIq } from '../transport/node/builders/device.js';
14
14
  import { assertIqResult, queryWithContext as queryNodeWithContext } from '../transport/node/query.js';
15
- import { fetchLatestWaWebVersion } from '../transport/wa-web-version-fetcher.js';
15
+ import { fetchLatestWaMobileVersion, fetchLatestWaWebVersion } from '../transport/wa-version-fetcher.js';
16
16
  import { toError } from '../util/primitives.js';
17
17
  const SYNC_RELATED_PROTOCOL_TYPES = new Set([
18
18
  proto.Message.ProtocolMessage.Type.APP_STATE_SYNC_KEY_REQUEST,
@@ -70,13 +70,14 @@ class WaClientImpl extends EventEmitter {
70
70
  this.deps = dependencies;
71
71
  this.appStateSync = dependencies.appStateSync;
72
72
  this.mediaTransfer = dependencies.mediaTransfer;
73
- this.disposePlugins = installWaClientPlugins(this, {
73
+ this.pluginInstallInput = {
74
74
  options: this.options,
75
75
  logger: this.logger,
76
76
  stores: this.stores,
77
77
  deps: this.deps,
78
78
  queryWithContext: this.queryWithContext.bind(this)
79
- }, this.options.plugins ?? []);
79
+ };
80
+ this.installPlugins();
80
81
  this.bindNodeTransportEvents();
81
82
  this.on('connection', (event) => {
82
83
  if (event.status !== 'close')
@@ -85,22 +86,43 @@ class WaClientImpl extends EventEmitter {
85
86
  return;
86
87
  if (event.reason !== WA_DISCONNECT_REASONS.FAILURE_CLIENT_TOO_OLD)
87
88
  return;
88
- this.logger.warn('wa rejected the connect with client_too_old: the zapo default WA Web version is outdated. ' +
89
- 'Auto-recovering by fetching the current version from web.whatsapp.com – ' +
89
+ this.logger.warn('wa rejected the connect with client_too_old: the zapo default WA version is outdated. ' +
90
+ 'Auto-recovering by fetching the current version from the public source – ' +
90
91
  'please upgrade zapo so the shipped default catches up.');
91
92
  void this.runClientTooOldRecover();
92
93
  });
94
+ this.on('connection', (event) => {
95
+ if (event.status !== 'open')
96
+ return;
97
+ void this.deps.mobileCoordinator.reconcileCompanions().catch((error) => {
98
+ this.logger.debug('companion reconcile on connect failed', {
99
+ message: toError(error).message
100
+ });
101
+ });
102
+ });
103
+ }
104
+ installPlugins() {
105
+ this.disposePlugins = installWaClientPlugins(this, this.pluginInstallInput, this.options.plugins ?? []);
93
106
  }
94
107
  async runClientTooOldRecover() {
95
108
  try {
96
109
  if (this.connectPromise) {
97
110
  await this.connectPromise.catch(() => undefined);
98
111
  }
99
- const latest = await fetchLatestWaWebVersion();
112
+ const mobile = this.deps.isMobilePrimary();
113
+ const latest = mobile
114
+ ? await fetchLatestWaMobileVersion()
115
+ : await fetchLatestWaWebVersion();
100
116
  this.logger.info('client_too_old auto-recover: reconnecting', {
117
+ transport: mobile ? 'mobile' : 'web',
101
118
  version: latest.version
102
119
  });
103
- this.deps.authClient.setNextConnectVersion(latest.version);
120
+ if (mobile) {
121
+ this.deps.authClient.setNextConnectMobileAppVersion(latest.version);
122
+ }
123
+ else {
124
+ this.deps.authClient.setNextConnectVersion(latest.version);
125
+ }
104
126
  await this.connect();
105
127
  }
106
128
  catch (error) {
@@ -306,6 +328,9 @@ class WaClientImpl extends EventEmitter {
306
328
  this.logger.trace('wa client connect already in-flight');
307
329
  return this.connectPromise;
308
330
  }
331
+ if (!this.disposePlugins) {
332
+ this.installPlugins();
333
+ }
309
334
  this.writeBehind.restart();
310
335
  this.acceptingIncomingEvents = true;
311
336
  this.connectPromise = this.deps.connectionManager
@@ -329,11 +354,12 @@ class WaClientImpl extends EventEmitter {
329
354
  }
330
355
  /**
331
356
  * Closes the transport gracefully: pauses incoming events, flushes the
332
- * write-behind persistence queue, and emits a `connection` close event
333
- * with reason `client_disconnected`. Does not clear stored credentials -
334
- * call {@link connect} again to resume the same session. There is no
335
- * built-in auto-reconnect; subscribe to `connection: { status: 'close' }`
336
- * and decide your own backoff.
357
+ * write-behind persistence queue, disposes installed plugins, and emits a
358
+ * `connection` close event with reason `client_disconnected`. Does not
359
+ * clear stored credentials - call {@link connect} again to resume the same
360
+ * session, which reinstalls the plugins. There is no built-in
361
+ * auto-reconnect; subscribe to `connection: { status: 'close' }` and decide
362
+ * your own backoff.
337
363
  */
338
364
  async disconnect() {
339
365
  await this.pauseIncomingEventsAndWaitDrain();
@@ -450,6 +476,14 @@ class WaClientImpl extends EventEmitter {
450
476
  get email() {
451
477
  return this.deps.emailCoordinator;
452
478
  }
479
+ /**
480
+ * Mobile coordinator: host and manage companion devices linked to this
481
+ * mobile-primary account (link via QR or pairing code, list, revoke).
482
+ * Requires a registered primary session. See {@link WaMobileCoordinator}.
483
+ */
484
+ get mobile() {
485
+ return this.deps.mobileCoordinator;
486
+ }
453
487
  /**
454
488
  * Unpairs this companion device by removing it server-side. Requires an
455
489
  * authenticated session; throws when no `meJid` is present.
@@ -13,6 +13,7 @@ import { WaIncomingNodeCoordinator } from './coordinators/WaIncomingNodeCoordina
13
13
  import { createLowLevelCoordinator } from './coordinators/WaLowLevelCoordinator.js';
14
14
  import { WaMessageCoordinator } from './coordinators/WaMessageCoordinator.js';
15
15
  import { WaMessageDispatchCoordinator } from './coordinators/WaMessageDispatchCoordinator.js';
16
+ import { WaMobileCoordinator } from './coordinators/WaMobileCoordinator.js';
16
17
  import { createNewsletterCoordinator } from './coordinators/WaNewsletterCoordinator.js';
17
18
  import { WaOfflineResumeCoordinator } from './coordinators/WaOfflineResumeCoordinator.js';
18
19
  import { WaPassiveTasksCoordinator } from './coordinators/WaPassiveTasksCoordinator.js';
@@ -38,7 +39,7 @@ import { processNewsletterLiveUpdates } from '../message/kinds/newsletter.js';
38
39
  import { handleIncomingMessageAck } from '../message/primitives/incoming.js';
39
40
  import { createPeerDataOperationRequester } from '../message/primitives/peer-data-operation.js';
40
41
  import { WaMessageClient } from '../message/WaMessageClient.js';
41
- import { getWaCompanionPlatformId, WA_DEFAULTS, WA_DISCONNECT_REASONS, WA_NEWSLETTER_NOTIFICATION_TAGS, WA_NODE_TAGS, WA_NOTIFICATION_TYPES, WA_PRIVACY_TOKEN_NOTIFICATION_TYPE } from '../protocol/constants.js';
42
+ import { resolveWaDeviceIdentity, WA_DEFAULTS, WA_DISCONNECT_REASONS, WA_NEWSLETTER_NOTIFICATION_TAGS, WA_NODE_TAGS, WA_NOTIFICATION_TYPES, WA_PRIVACY_TOKEN_NOTIFICATION_TYPE } from '../protocol/constants.js';
42
43
  import { isNewsletterJid, isOwnAccountJid, parseSignalAddressFromJid, toUserJid } from '../protocol/jid.js';
43
44
  import { WA_PRESENCE_TYPES } from '../protocol/presence.js';
44
45
  import { createOutboundRetryTracker } from '../retry/tracker.js';
@@ -61,7 +62,6 @@ import { WaNodeTransport } from '../transport/node/WaNodeTransport.js';
61
62
  import { isProxyTransport, toProxyAgent } from '../transport/proxy.js';
62
63
  import { createServerClock } from '../util/clock.js';
63
64
  import { toError } from '../util/primitives.js';
64
- import { getRuntimeOsDisplayName } from '../util/runtime.js';
65
65
  function assertProxyTransport(value, path) {
66
66
  if (value === undefined) {
67
67
  return;
@@ -85,7 +85,7 @@ function validateProxyOptions(options) {
85
85
  }
86
86
  export function resolveWaClientBase(options, logger) {
87
87
  validateProxyOptions(options);
88
- const deviceBrowser = options.deviceBrowser ?? WA_DEFAULTS.DEVICE_BROWSER;
88
+ const device = resolveWaDeviceIdentity(options);
89
89
  const sessionId = options.sessionId.trim();
90
90
  if (sessionId.length === 0) {
91
91
  throw new Error('sessionId must be a non-empty string');
@@ -94,9 +94,9 @@ export function resolveWaClientBase(options, logger) {
94
94
  const normalizedOptions = Object.freeze({
95
95
  ...options,
96
96
  sessionId,
97
- deviceBrowser,
98
- deviceOsDisplayName: options.deviceOsDisplayName ?? getRuntimeOsDisplayName(),
99
- devicePlatform: options.devicePlatform ?? getWaCompanionPlatformId(deviceBrowser),
97
+ deviceBrowser: device.browser,
98
+ deviceOsDisplayName: device.osDisplayName,
99
+ devicePlatform: device.platform,
100
100
  urls: options.urls ?? options.chatSocketUrls ?? WA_DEFAULTS.CHAT_SOCKET_URLS,
101
101
  iqTimeoutMs: options.iqTimeoutMs ?? WA_DEFAULTS.IQ_TIMEOUT_MS,
102
102
  nodeQueryTimeoutMs: options.nodeQueryTimeoutMs ?? WA_DEFAULTS.NODE_QUERY_TIMEOUT_MS,
@@ -451,6 +451,7 @@ export function buildWaClientDependencies(input) {
451
451
  });
452
452
  messageDispatch = new WaMessageDispatchCoordinator({
453
453
  logger,
454
+ emitMessageSend: (event) => runtime.emitEvent('message_send', event),
454
455
  messageClient,
455
456
  retryTracker,
456
457
  sessionResolver,
@@ -587,6 +588,7 @@ export function buildWaClientDependencies(input) {
587
588
  serverClock,
588
589
  emitSnapshotMutations: options.chatEvents?.emitSnapshotMutations === true,
589
590
  emitMutation: (event) => runtime.emitEvent('mutation', event),
591
+ emitMutationSend: (event) => runtime.emitEvent('mutation_send', event),
590
592
  nctSaltSink: (salt) => trustedContactToken.handleNctSaltSync(salt),
591
593
  contactSink: runtime.persistContact,
592
594
  pushNameSink: (name) => {
@@ -1013,6 +1015,20 @@ export function buildWaClientDependencies(input) {
1013
1015
  isConnected: () => connectionManager?.isConnected() ?? false,
1014
1016
  defaultIqTimeoutMs: options.iqTimeoutMs
1015
1017
  });
1018
+ const mobileCoordinator = new WaMobileCoordinator({
1019
+ logger,
1020
+ authClient,
1021
+ messageDispatch,
1022
+ chatCoordinator: appStateMutations,
1023
+ deviceSync: signalDeviceSync,
1024
+ appStateStore: sessionStore.appState,
1025
+ queryWithContext: runtime.queryWithContext,
1026
+ emitEvent: runtime.emitEvent,
1027
+ registerIncomingHandler: (registration) => incomingNode.registerIncomingHandler(registration),
1028
+ isMobilePrimary,
1029
+ persistence: options.companionHost?.persistence,
1030
+ includePem: options.companionHost?.includePem
1031
+ });
1016
1032
  return {
1017
1033
  nodeTransport,
1018
1034
  nodeOrchestrator,
@@ -1053,6 +1069,8 @@ export function buildWaClientDependencies(input) {
1053
1069
  connectionManager,
1054
1070
  trustedContactToken,
1055
1071
  abPropsCoordinator,
1056
- peerDataOperation
1072
+ peerDataOperation,
1073
+ mobileCoordinator,
1074
+ isMobilePrimary
1057
1075
  };
1058
1076
  }
@@ -11,6 +11,8 @@ const WA_APP_STATE_MUTATION_FLUSH_SUCCESS_STATES = new Set([
11
11
  WA_APP_STATE_COLLECTION_STATES.SUCCESS_HAS_MORE
12
12
  ]);
13
13
  const WA_APP_STATE_ARCHIVE_RANGE_DEFAULT_LIMIT = 256;
14
+ /** Placeholder MACs for locally-emitted mutation events (the parser ignores them). */
15
+ const WA_EMPTY_MUTATION_MAC = new Uint8Array(0);
14
16
  function buildMutationIndexFromSchema(schema, indexArgs) {
15
17
  const parts = new Array(schema.indexParts.length);
16
18
  const args = indexArgs;
@@ -157,7 +159,6 @@ function buildRemoveMutationFromSchema(input) {
157
159
  collection: input.schema.collection,
158
160
  operation: 'remove',
159
161
  index: buildMutationIndexFromSchema(input.schema, input.indexArgs),
160
- previousValue: { timestamp: input.timestamp },
161
162
  version: input.schema.version,
162
163
  timestamp: input.timestamp
163
164
  };
@@ -177,6 +178,7 @@ export class WaAppStateMutationCoordinator {
177
178
  this.serverClock = options.serverClock;
178
179
  this.archiveRangeLimit = resolvePositive(options.archiveRangeLimit, WA_APP_STATE_ARCHIVE_RANGE_DEFAULT_LIMIT, 'WaAppStateMutationCoordinatorOptions.archiveRangeLimit');
179
180
  this.emitMutation = options.emitMutation;
181
+ this.emitMutationSend = options.emitMutationSend;
180
182
  this.emitSnapshotMutations = options.emitSnapshotMutations === true;
181
183
  this.nctSaltSink = options.nctSaltSink;
182
184
  this.contactSink = options.contactSink;
@@ -614,9 +616,43 @@ export class WaAppStateMutationCoordinator {
614
616
  async enqueueAndFlush(mutations) {
615
617
  for (const mutation of mutations) {
616
618
  this.enqueueMutation(mutation);
619
+ this.emitLocalMutation(mutation);
617
620
  }
618
621
  await this.flushMutations();
619
622
  }
623
+ /**
624
+ * Surfaces an action this client is sending via the outbound `mutation_send`
625
+ * event, so consumers can observe their own change at action time. Parse
626
+ * failures are swallowed (best-effort).
627
+ */
628
+ emitLocalMutation(input) {
629
+ if (!this.emitMutationSend)
630
+ return;
631
+ const value = input.operation === 'set' ? input.value : null;
632
+ try {
633
+ const event = parseAppStateMutationEvent({
634
+ collection: input.collection,
635
+ operation: input.operation,
636
+ source: 'local',
637
+ index: input.index,
638
+ value,
639
+ version: input.version,
640
+ indexMac: WA_EMPTY_MUTATION_MAC,
641
+ valueMac: WA_EMPTY_MUTATION_MAC,
642
+ keyId: WA_EMPTY_MUTATION_MAC,
643
+ timestamp: input.timestamp
644
+ });
645
+ if (event)
646
+ this.emitMutationSend(event);
647
+ }
648
+ catch (error) {
649
+ this.logger.debug('failed to emit local app-state mutation event', {
650
+ collection: input.collection,
651
+ index: input.index,
652
+ message: toError(error).message
653
+ });
654
+ }
655
+ }
620
656
  enqueueMutation(mutation) {
621
657
  const key = `${mutation.collection}\u0001${mutation.index}`;
622
658
  if (this.pendingMutations.has(key)) {
@@ -2,6 +2,7 @@ import { parseParticipants as parseGroupEventParticipants } from '../events/grou
2
2
  import { WA_DEFAULTS } from '../../protocol/defaults.js';
3
3
  import { WA_GROUP_PARTICIPANT_TYPES } from '../../protocol/group.js';
4
4
  import { parseJidFull } from '../../protocol/jid.js';
5
+ import { WA_ADDRESSING_MODES } from '../../protocol/message.js';
5
6
  import { WA_IQ_TYPES, WA_NODE_TAGS, WA_XMLNS } from '../../protocol/nodes.js';
6
7
  import { WA_GROUP_NOTIFICATION_TAGS } from '../../protocol/notification.js';
7
8
  import { buildListParticipatingGroupsIq } from '../../transport/node/builders/account-sync.js';
@@ -87,7 +88,11 @@ function parseGroupCommons(target) {
87
88
  ? findNodeChild(membershipApprovalNode, WA_NODE_TAGS.GROUP_JOIN)
88
89
  : undefined;
89
90
  const addressingModeRaw = attrs.addressing_mode;
90
- const addressingMode = addressingModeRaw === 'lid' ? 'lid' : addressingModeRaw === 'pn' ? 'pn' : undefined;
91
+ const addressingMode = addressingModeRaw === WA_ADDRESSING_MODES.LID
92
+ ? WA_ADDRESSING_MODES.LID
93
+ : addressingModeRaw === WA_ADDRESSING_MODES.PN
94
+ ? WA_ADDRESSING_MODES.PN
95
+ : undefined;
91
96
  const rawJid = attrs.id ?? attrs.jid ?? '';
92
97
  const jid = rawJid && !rawJid.includes('@') ? `${rawJid}@${WA_DEFAULTS.GROUP_SERVER}` : rawJid;
93
98
  return {
@@ -11,7 +11,7 @@ import { wrapDeviceSentMessage } from '../../message/encode/device-sent.js';
11
11
  import { writeRandomPadMax16 } from '../../message/encode/padding.js';
12
12
  import { buildBotInvokeProtoCopy, extractInvokedBotJid, genBotMsgSecret } from '../../message/kinds/bot.js';
13
13
  import { proto } from '../../proto.js';
14
- import { WA_DEFAULTS, WA_NACK_REASONS } from '../../protocol/constants.js';
14
+ import { WA_ADDRESSING_MODES, WA_DEFAULTS, WA_NACK_REASONS } from '../../protocol/constants.js';
15
15
  import { canonicalizeOwnAccountJid, isBotJid, isGroupJid, isHostedDeviceJid, isLidJid, isNewsletterJid, isUserJid, normalizeDeviceJid, normalizeRecipientJid, parseJidFull, parseSignalAddressFromJid, signalAddressKey, toUserJid } from '../../protocol/jid.js';
16
16
  import { encodeBinaryNode } from '../../transport/binary/index.js';
17
17
  import { buildButtonAddonNode, buildDirectMessageFanoutNode, buildGroupSenderKeyMessageNode, buildMetaNode } from '../../transport/node/builders/message.js';
@@ -200,6 +200,16 @@ export class WaMessageDispatchCoordinator {
200
200
  }
201
201
  : withViewOnce;
202
202
  const upload = built.upload;
203
+ if (this.deps.emitMessageSend) {
204
+ try {
205
+ this.deps.emitMessageSend({ to: recipientJid, id: sendOptions.id, message });
206
+ }
207
+ catch (error) {
208
+ this.deps.logger.debug('message_send emit failed', {
209
+ message: toError(error).message
210
+ });
211
+ }
212
+ }
203
213
  const messageWithOverride = options.messageSecret
204
214
  ? {
205
215
  ...message,
@@ -891,17 +901,17 @@ export class WaMessageDispatchCoordinator {
891
901
  resolveGroupAddressingMode(participantUserJids, groupJid) {
892
902
  for (let index = 0; index < participantUserJids.length; index += 1) {
893
903
  if (isLidJid(participantUserJids[index])) {
894
- return 'lid';
904
+ return WA_ADDRESSING_MODES.LID;
895
905
  }
896
906
  }
897
907
  this.deps.logger.trace('group addressing mode resolved to pn (default)', {
898
908
  groupJid,
899
909
  participants: participantUserJids.length
900
910
  });
901
- return 'pn';
911
+ return WA_ADDRESSING_MODES.PN;
902
912
  }
903
913
  resolveSenderForAddressingMode(addressingMode, meJid) {
904
- if (addressingMode === 'lid') {
914
+ if (addressingMode === WA_ADDRESSING_MODES.LID) {
905
915
  const meLid = this.deps.getCurrentCredentials()?.meLid;
906
916
  if (meLid && meLid.includes('@')) {
907
917
  try {