From 1c8b7fc45d3681a287d597cf9fbfcc746dcd547c Mon Sep 17 00:00:00 2001 From: trevor-signal <131492920+trevor-signal@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:20:36 -0500 Subject: [PATCH] Bind remote config to libsignal-net --- ts/LibsignalNetRemoteConfig.preload.ts | 63 +++++++++++++++++++ ts/RemoteConfig.dom.ts | 58 ++++++++++++++--- ts/background.preload.ts | 5 ++ ....preload.ts => messageStateCleanup.dom.ts} | 3 - .../isConversationTooBigToRing_test.dom.ts | 3 +- ts/textsecure/WebAPI.preload.ts | 25 +------- ts/types/Util.std.ts | 29 +++++++++ ts/windows/main/preload_test.preload.ts | 8 ++- ts/windows/main/start.preload.ts | 3 - 9 files changed, 157 insertions(+), 40 deletions(-) create mode 100644 ts/LibsignalNetRemoteConfig.preload.ts rename ts/services/{messageStateCleanup.preload.ts => messageStateCleanup.dom.ts} (83%) diff --git a/ts/LibsignalNetRemoteConfig.preload.ts b/ts/LibsignalNetRemoteConfig.preload.ts new file mode 100644 index 0000000000..2b4f21c6fd --- /dev/null +++ b/ts/LibsignalNetRemoteConfig.preload.ts @@ -0,0 +1,63 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import { + type Net, + BuildVariant, + REMOTE_CONFIG_KEYS as KeysExpectedByLibsignalNet, +} from '@signalapp/libsignal-client/dist/net.js'; + +import { isProduction } from './util/version.std.js'; +import * as RemoteConfig from './RemoteConfig.dom.js'; +import type { AddPrefix, ArrayValues } from './types/Util.std.js'; +import { createLogger } from './logging/log.std.js'; + +const log = createLogger('LibsignalNetRemoteConfig'); + +function convertToDesktopRemoteConfigKey< + K extends ArrayValues, +>(key: K): AddPrefix { + return `desktop.libsignalNet.${key}`; +} + +export function bindRemoteConfigToLibsignalNet( + libsignalNet: Net, + appVersion: string +): void { + // Calls setLibsignalRemoteConfig and is reset when any libsignal remote + // config key changes. Doing that asynchronously allows the callbacks for + // multiple keys that are triggered by the same config fetch from the server + // to be coalesced into a single timeout. + let reloadRemoteConfig: NodeJS.Immediate | undefined; + const libsignalBuildVariant = isProduction(appVersion) + ? BuildVariant.Production + : BuildVariant.Beta; + + const setLibsignalRemoteConfig = () => { + const remoteConfigs = KeysExpectedByLibsignalNet.reduce((output, key) => { + const value = RemoteConfig.getValue(convertToDesktopRemoteConfigKey(key)); + if (value !== undefined) { + output.set(key, value); + } + return output; + }, new Map<(typeof KeysExpectedByLibsignalNet)[number], string>()); + + log.info( + 'Setting libsignal-net remote config', + Object.fromEntries(remoteConfigs) + ); + libsignalNet.setRemoteConfig(remoteConfigs, libsignalBuildVariant); + reloadRemoteConfig = undefined; + }; + + setLibsignalRemoteConfig(); + + KeysExpectedByLibsignalNet.map(convertToDesktopRemoteConfigKey).forEach( + key => { + RemoteConfig.onChange(key, () => { + if (reloadRemoteConfig === undefined) { + reloadRemoteConfig = setImmediate(setLibsignalRemoteConfig); + } + }); + } + ); +} diff --git a/ts/RemoteConfig.dom.ts b/ts/RemoteConfig.dom.ts index a16544d52e..90d561350d 100644 --- a/ts/RemoteConfig.dom.ts +++ b/ts/RemoteConfig.dom.ts @@ -3,6 +3,7 @@ import lodash from 'lodash'; import semver from 'semver'; +import type { REMOTE_CONFIG_KEYS as KeysExpectedByLibsignalNet } from '@signalapp/libsignal-client/dist/net.js'; import type { getConfig } from './textsecure/WebAPI.preload.js'; import { createLogger } from './logging/log.std.js'; @@ -16,6 +17,12 @@ import { getCountryCode } from './types/PhoneNumber.std.js'; import { parseRemoteClientExpiration } from './util/parseRemoteClientExpiration.dom.js'; import type { StorageInterface } from './types/Storage.d.ts'; import { ToastType } from './types/Toast.dom.js'; +import { assertDev, strictAssert } from './util/assert.std.js'; +import type { + ArrayValues, + AssertSameMembers, + StripPrefix, +} from './types/Util.std.js'; const { get, throttle } = lodash; @@ -35,7 +42,7 @@ const SemverKeys = [ 'desktop.retireAccessKeyGroupSend.prod', ] as const; -export type SemverKeyType = (typeof SemverKeys)[number]; +export type SemverKeyType = ArrayValues; const ScalarKeys = [ 'desktop.callQualitySurveyPPM', @@ -52,11 +59,6 @@ const ScalarKeys = [ 'desktop.retryRespondMaxAge', 'desktop.senderKey.retry', 'desktop.senderKeyMaxAge', - 'desktop.libsignalNet.enforceMinimumTls', - 'desktop.libsignalNet.shadowUnauthChatWithNoise', - 'desktop.libsignalNet.shadowAuthChatWithNoise', - 'desktop.libsignalNet.chatPermessageDeflate', - 'desktop.libsignalNet.chatPermessageDeflate.prod', 'desktop.pollReceive.alpha', 'desktop.pollReceive.beta1', 'desktop.pollReceive.prod1', @@ -76,9 +78,39 @@ const ScalarKeys = [ 'global.textAttachmentLimitBytes', ] as const; -const KnownConfigKeys = [...SemverKeys, ...ScalarKeys] as const; +// These keys should always match those in Net.REMOTE_CONFIG_KEYS, prefixed by +// `desktop.libsignalNet` +const KnownDesktopLibsignalNetKeys = [ + 'desktop.libsignalNet.chatPermessageDeflate.beta', + 'desktop.libsignalNet.chatPermessageDeflate.prod', + 'desktop.libsignalNet.chatPermessageDeflate', + 'desktop.libsignalNet.chatRequestConnectionCheckTimeoutMillis.beta', + 'desktop.libsignalNet.chatRequestConnectionCheckTimeoutMillis', + 'desktop.libsignalNet.disableNagleAlgorithm.beta', + 'desktop.libsignalNet.disableNagleAlgorithm', + 'desktop.libsignalNet.useH2ForUnauthChat.beta', + 'desktop.libsignalNet.useH2ForUnauthChat', +] as const; -export type ConfigKeyType = (typeof KnownConfigKeys)[number]; +type KnownLibsignalKeysType = StripPrefix< + ArrayValues, + 'desktop.libsignalNet.' +>; +type ExpectedLibsignalKeysType = ArrayValues; + +const _assertLibsignalKeysMatch: AssertSameMembers< + KnownLibsignalKeysType, + ExpectedLibsignalKeysType +> = true; +strictAssert(_assertLibsignalKeysMatch, 'Libsignal keys match'); + +const KnownConfigKeys = [ + ...SemverKeys, + ...ScalarKeys, + ...KnownDesktopLibsignalNetKeys, +] as const; + +export type ConfigKeyType = ArrayValues; type ConfigValueType = { name: ConfigKeyType; @@ -92,7 +124,7 @@ type ConfigListenersMapType = { [key: string]: Array; }; -let config: ConfigMapType = {}; +let config: ConfigMapType | undefined; const listeners: ConfigListenersMapType = {}; export type OptionsType = Readonly<{ @@ -272,6 +304,10 @@ export function isEnabled( // when called from UI component, provide redux config (items.remoteConfig) reduxConfig?: ConfigMapType ): boolean { + assertDev( + reduxConfig != null || config != null, + 'getValue called before remote config is ready' + ); return get(reduxConfig ?? config, [name, 'enabled'], false); } @@ -279,6 +315,10 @@ export function getValue( name: ConfigKeyType, // when called from UI component, provide redux config (items.remoteConfig) reduxConfig?: ConfigMapType ): string | undefined { + assertDev( + reduxConfig != null || config != null, + 'getValue called before remote config is ready' + ); return get(reduxConfig ?? config, [name, 'value']); } diff --git a/ts/background.preload.ts b/ts/background.preload.ts index 276a6153a5..d1d7b4271e 100644 --- a/ts/background.preload.ts +++ b/ts/background.preload.ts @@ -285,6 +285,8 @@ import { import { JobCancelReason } from './jobs/types.std.js'; import { itemStorage } from './textsecure/Storage.preload.js'; import { isPinnedMessagesReceiveEnabled } from './util/isPinnedMessagesEnabled.dom.js'; +import { initMessageCleanup } from './services/messageStateCleanup.dom.js'; +import { MessageCache } from './services/MessageCache.preload.js'; const { isNumber, throttle } = lodash; @@ -551,6 +553,9 @@ export async function startApp(): Promise { storage: itemStorage, }); + MessageCache.install(); + initMessageCleanup(); + window.Whisper.events.on('firstEnvelope', checkFirstEnvelope); const buildExpirationService = new BuildExpirationService(); diff --git a/ts/services/messageStateCleanup.preload.ts b/ts/services/messageStateCleanup.dom.ts similarity index 83% rename from ts/services/messageStateCleanup.preload.ts rename to ts/services/messageStateCleanup.dom.ts index 10ef76e236..7c19022bfb 100644 --- a/ts/services/messageStateCleanup.preload.ts +++ b/ts/services/messageStateCleanup.dom.ts @@ -3,7 +3,6 @@ import * as durations from '../util/durations/index.std.js'; import { isEnabled } from '../RemoteConfig.dom.js'; -import { MessageCache } from './MessageCache.preload.js'; const TEN_MINUTES = 10 * durations.MINUTE; @@ -12,6 +11,4 @@ export function initMessageCleanup(): void { () => window.MessageCache.deleteExpiredMessages(TEN_MINUTES), isEnabled('desktop.messageCleanup') ? TEN_MINUTES : durations.HOUR ); - - MessageCache.install(); } diff --git a/ts/test-node/conversations/isConversationTooBigToRing_test.dom.ts b/ts/test-node/conversations/isConversationTooBigToRing_test.dom.ts index 75db50dac4..840dc0c46e 100644 --- a/ts/test-node/conversations/isConversationTooBigToRing_test.dom.ts +++ b/ts/test-node/conversations/isConversationTooBigToRing_test.dom.ts @@ -16,7 +16,8 @@ describe('isConversationTooBigToRing', () => { const fakeMemberships = (count: number) => times(count, () => ({ aci: generateAci(), isAdmin: false })); - it('returns false if there are no memberships (i.e., for a direct conversation)', () => { + it('returns false if there are no memberships (i.e., for a direct conversation)', async () => { + await updateRemoteConfig([]); assert.isFalse(isConversationTooBigToRing({})); assert.isFalse(isConversationTooBigToRing({ memberships: [] })); }); diff --git a/ts/textsecure/WebAPI.preload.ts b/ts/textsecure/WebAPI.preload.ts index 70a47151d0..8539f032cf 100644 --- a/ts/textsecure/WebAPI.preload.ts +++ b/ts/textsecure/WebAPI.preload.ts @@ -77,7 +77,6 @@ import { import type { CDSAuthType, CDSResponseType } from './cds/Types.d.ts'; import { CDSI } from './cds/CDSI.node.js'; import { SignalService as Proto } from '../protobuf/index.std.js'; -import { isEnabled as isRemoteConfigEnabled } from '../RemoteConfig.dom.js'; import type { WebAPICredentials, @@ -120,6 +119,7 @@ import { RemoteMegaphoneCtaDataSchema, type RemoteMegaphoneId, } from '../types/Megaphone.std.js'; +import { bindRemoteConfigToLibsignalNet } from '../LibsignalNetRemoteConfig.preload.js'; const { escapeRegExp, isNumber, throttle } = lodash; @@ -1701,27 +1701,6 @@ const PARSE_RANGE_HEADER = /\/(\d+)$/; const PARSE_GROUP_LOG_RANGE_HEADER = /^versions\s+(\d{1,10})-(\d{1,10})\/(\d{1,10})/; -const libsignalRemoteConfig = new Map(); -if (isRemoteConfigEnabled('desktop.libsignalNet.enforceMinimumTls')) { - log.info('libsignal net will require TLS 1.3'); - libsignalRemoteConfig.set('enforceMinimumTls', 'true'); -} -if (isRemoteConfigEnabled('desktop.libsignalNet.shadowUnauthChatWithNoise')) { - log.info('libsignal net will shadow unauth chat connections'); - libsignalRemoteConfig.set('shadowUnauthChatWithNoise', 'true'); -} -if (isRemoteConfigEnabled('desktop.libsignalNet.shadowAuthChatWithNoise')) { - log.info('libsignal net will shadow auth chat connections'); - libsignalRemoteConfig.set('shadowAuthChatWithNoise', 'true'); -} -const perMessageDeflateConfigKey = isProduction(version) - ? 'desktop.libsignalNet.chatPermessageDeflate.prod' - : 'desktop.libsignalNet.chatPermessageDeflate'; -if (isRemoteConfigEnabled(perMessageDeflateConfigKey)) { - libsignalRemoteConfig.set('chatPermessageDeflate', 'true'); -} -libsignalNet.setRemoteConfig(libsignalRemoteConfig); - const socketManager = new SocketManager(libsignalNet, { url: chatServiceUrl, certificateAuthority, @@ -1776,6 +1755,8 @@ export async function connect({ hasStoriesDisabled, hasBuildExpired, }: WebAPIConnectOptionsType): Promise { + bindRemoteConfigToLibsignalNet(getLibsignalNet(), window.getVersion()); + username = initialUsername; password = initialPassword; diff --git a/ts/types/Util.std.ts b/ts/types/Util.std.ts index 1b8f9657f2..1cd9f9211e 100644 --- a/ts/types/Util.std.ts +++ b/ts/types/Util.std.ts @@ -131,3 +131,32 @@ export type ExactKeys> = ? T : 'Error: Array has fields not present in object type' : 'Error: Object type has keys not present in array'; + +export type StripPrefix< + T extends string, + Prefix extends string, +> = T extends `${Prefix}${infer Rest}` ? Rest : T; + +export type AddPrefix< + T extends string, + Prefix extends string, +> = `${Prefix}${T}`; + +type Missing = Exclude; +type Extra = Exclude; + +export type AssertSameMembers = [ + Missing, +] extends [never] + ? [Extra] extends [never] + ? true + : { + error: 'Extra keys'; + extra: Extra; + } + : { + error: 'Missing keys'; + missing: Missing; + }; + +export type ArrayValues> = T[number]; diff --git a/ts/windows/main/preload_test.preload.ts b/ts/windows/main/preload_test.preload.ts index 776fb88bbd..a14650dfcb 100644 --- a/ts/windows/main/preload_test.preload.ts +++ b/ts/windows/main/preload_test.preload.ts @@ -14,12 +14,13 @@ import chaiAsPromised from 'chai-as-promised'; // eslint-disable-next-line import/no-extraneous-dependencies import { reporters, type MochaOptions } from 'mocha'; -import { initMessageCleanup } from '../../services/messageStateCleanup.preload.js'; import { initializeMessageCounter } from '../../util/incrementMessageCounter.preload.js'; import { initializeRedux } from '../../state/initializeRedux.preload.js'; import * as Stickers from '../../types/Stickers.preload.js'; import { ThemeType } from '../../types/Util.std.js'; import { itemStorage } from '../../textsecure/Storage.preload.js'; +import { MessageCache } from '../../services/MessageCache.preload.js'; +import { updateRemoteConfig } from '../../test-helpers/RemoteConfigStub.dom.js'; chai.use(chaiAsPromised); @@ -95,7 +96,10 @@ window.testUtilities = { }, async initialize() { - initMessageCleanup(); + // Since background.preload.ts is not loaded in tests, we need to do some minimal + // setup + MessageCache.install(); + await updateRemoteConfig([]); await initializeMessageCounter(); await Stickers.load(); diff --git a/ts/windows/main/start.preload.ts b/ts/windows/main/start.preload.ts index 777966abe4..b64eca120d 100644 --- a/ts/windows/main/start.preload.ts +++ b/ts/windows/main/start.preload.ts @@ -26,7 +26,6 @@ import type { import { cdsLookup, getSocketStatus } from '../../textsecure/WebAPI.preload.js'; import type { FeatureFlagType } from '../../window.d.ts'; import type { StorageAccessType } from '../../types/Storage.d.ts'; -import { initMessageCleanup } from '../../services/messageStateCleanup.preload.js'; import { calling } from '../../services/calling.preload.js'; import { Environment, getEnvironment } from '../../environment.std.js'; import { isProduction } from '../../util/version.std.js'; @@ -57,8 +56,6 @@ if (window.SignalContext.config.proxyUrl) { log.info('Using provided proxy url'); } -initMessageCleanup(); - if ( !isProduction(window.SignalContext.getVersion()) || window.SignalContext.config.devTools