Show critical-idle-primary-device banner in response to WS upgrade response headers

This commit is contained in:
trevor-signal
2025-03-06 12:58:57 -05:00
committed by GitHub
parent bf438e2456
commit f5fe787ed7
23 changed files with 337 additions and 14 deletions

View File

@@ -6675,6 +6675,14 @@
"messageformat": "Update Downloaded",
"description": "The title of update dialog when update download is completed."
},
"icu:CriticalIdlePrimaryDevice__title": {
"messageformat": "Open Signal on your phone",
"description": "Title of a banner alerting users if their primary device (e.g. phone) has not been logged into recently."
},
"icu:CriticalIdlePrimaryDevice__body": {
"messageformat": "Your account will be deleted soon unless you open Signal on your phone. This message will go away if you've done it successfully. <learnMoreLink>Learn more</learnMoreLink>",
"description": "The text in a banner alerting users if their primary device (e.g. phone) has not been logged into recently. "
},
"icu:DialogNetworkStatus__outage": {
"messageformat": "Signal is experiencing technical difficulties. We are working hard to restore service as quickly as possible.",
"description": "The title of outage dialog during service outage."

View File

@@ -221,7 +221,7 @@
"@indutny/parallel-prettier": "3.0.0",
"@indutny/rezip-electron": "2.0.1",
"@napi-rs/canvas": "0.1.61",
"@signalapp/mock-server": "11.0.0",
"@signalapp/mock-server": "11.1.0",
"@storybook/addon-a11y": "8.4.4",
"@storybook/addon-actions": "8.4.4",
"@storybook/addon-controls": "8.4.4",
@@ -374,6 +374,7 @@
"app-builder-lib@26.0.0-alpha.8": "patches/app-builder-lib+26.0.0-alpha.8.patch",
"growing-file@0.1.3": "patches/growing-file+0.1.3.patch",
"websocket@1.0.34": "patches/websocket+1.0.34.patch",
"@types/websocket@1.0.0": "patches/@types+websocket+1.0.0.patch",
"backbone@1.6.0": "patches/backbone+1.6.0.patch",
"node-fetch@2.6.7": "patches/node-fetch+2.6.7.patch",
"zod@3.23.8": "patches/zod+3.23.8.patch"

View File

@@ -0,0 +1,16 @@
diff --git a/index.d.ts b/index.d.ts
index eb23f3c479e6d368e1563de7531a8459566d5c56..146769d33ce61f036371c05743cd31fb44d25d49 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -650,9 +650,11 @@ export class client extends events.EventEmitter {
on(event: 'connect', cb: (connection: connection) => void): this;
on(event: 'connectFailed', cb: (err: Error) => void): this;
on(event: 'httpResponse', cb: (response: http.IncomingMessage, client: client) => void): this;
+ on(event: 'upgradeResponse', cb: (response: http.IncomingMessage) => void): this;
addListener(event: 'connect', cb: (connection: connection) => void): this;
addListener(event: 'connectFailed', cb: (err: Error) => void): this;
addListener(event: 'httpResponse', cb: (response: http.IncomingMessage, client: client) => void): this;
+ addListener(event: 'upgradeResponse', cb: (response: http.IncomingMessage) => void): this;
}
export interface IRouterRequest extends events.EventEmitter {

View File

@@ -1,5 +1,17 @@
diff --git a/lib/WebSocketClient.js b/lib/WebSocketClient.js
index fb7572b382de289f7fa3ff35a9647f118f3bd4eb..0000a94607520b4b7ac71f5eee4ed297df8d1bf7 100644
--- a/lib/WebSocketClient.js
+++ b/lib/WebSocketClient.js
@@ -258,6 +258,7 @@ WebSocketClient.prototype.connect = function(requestUrl, protocols, origin, head
self.socket = socket;
self.response = response;
self.firstDataChunk = head;
+ self.emit('upgradeResponse', response);
self.validateHandshake();
});
req.on('error', handleRequestError);
diff --git a/lib/WebSocketConnection.js b/lib/WebSocketConnection.js
index 219de63..93d3800 100644
index 219de631dabe578e64574732fc95353a1c1047fa..93d380004b5bc58ba9895fb5a760709389641071 100644
--- a/lib/WebSocketConnection.js
+++ b/lib/WebSocketConnection.js
@@ -271,7 +271,7 @@ WebSocketConnection.prototype.handleSocketData = function(data) {

23
pnpm-lock.yaml generated
View File

@@ -26,6 +26,9 @@ patchedDependencies:
'@types/node-fetch@2.6.12':
hash: bc43fb8cfed85fb4f7917b5bd3b47644ae15a000c26a6fe23078b2f5217efaf0
path: patches/@types+node-fetch+2.6.12.patch
'@types/websocket@1.0.0':
hash: 53fdd65a6b35f379eb8f5807d315f00c66e9bfe9741f4859a0fa21026bdb30fa
path: patches/@types+websocket+1.0.0.patch
'@vitest/expect@2.0.5':
hash: e8a96f71e52bf903c9f1eadba4740489a0beb48da33db52354adca484fe1f495
path: patches/@vitest+expect+2.0.5.patch
@@ -72,7 +75,7 @@ patchedDependencies:
hash: 94614db18e4db7ff5eee4c2acf88e3ef245bff2f9637416e99e401bec3531286
path: patches/react-textarea-autosize+8.5.5.patch
websocket@1.0.34:
hash: 4ce5bb237501a5576bd9e09fbff46551dd3e57f7702f6033bcffa49dde7f71bb
hash: b8d361a6a73e44000bb51102dea0d841c22d2bb455dd6c54de566d0e0bd86355
path: patches/websocket+1.0.34.patch
zod@3.23.8:
hash: 239818e5d88990616205c8cdc1de1660bf5e18b157d00c4a5f726dde6094af4d
@@ -375,7 +378,7 @@ importers:
version: 11.0.2
websocket:
specifier: 1.0.34
version: 1.0.34(patch_hash=4ce5bb237501a5576bd9e09fbff46551dd3e57f7702f6033bcffa49dde7f71bb)
version: 1.0.34(patch_hash=b8d361a6a73e44000bb51102dea0d841c22d2bb455dd6c54de566d0e0bd86355)
write-file-atomic:
specifier: 6.0.0
version: 6.0.0
@@ -429,8 +432,8 @@ importers:
specifier: 0.1.61
version: 0.1.61
'@signalapp/mock-server':
specifier: 11.0.0
version: 11.0.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
specifier: 11.1.0
version: 11.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
'@storybook/addon-a11y':
specifier: 8.4.4
version: 8.4.4(storybook@8.4.4(bufferutil@4.0.9)(prettier@3.3.3)(utf-8-validate@5.0.10))
@@ -586,7 +589,7 @@ importers:
version: 10.0.0
'@types/websocket':
specifier: 1.0.0
version: 1.0.0
version: 1.0.0(patch_hash=53fdd65a6b35f379eb8f5807d315f00c66e9bfe9741f4859a0fa21026bdb30fa)
'@types/write-file-atomic':
specifier: 4.0.3
version: 4.0.3
@@ -2535,8 +2538,8 @@ packages:
'@signalapp/libsignal-client@0.67.3':
resolution: {integrity: sha512-GIiXJMqiIByPZbomytoYQcQLJ3pNgHBCjt5BvlE/3rkrmNwyXW1UVqszX6/WfZi91aqUapO4+7Op+8JCbDGRWA==}
'@signalapp/mock-server@11.0.0':
resolution: {integrity: sha512-JHEqdjXvWcXyLJ90OtaTIQVtizH/w+rmnPplNmHuUiDZIQIEvE3lRyZFyIvgVTNg29lQWs/Gw/o+hICerdOChQ==}
'@signalapp/mock-server@11.1.0':
resolution: {integrity: sha512-SYYek3QCh57vZZGNVQW+fSXMr+xHjnO8sMAFfj8DovjqW4HIBWbt3itGyyjxSoIXnaCMK346O6I/R79w8xY/aw==}
'@signalapp/parchment-cjs@3.0.1':
resolution: {integrity: sha512-hSBMQ1M7wE4GcC8ZeNtvpJF+DAJg3eIRRf1SiHS3I3Algav/sgJJNm6HIYm6muHuK7IJmuEjkL3ILSXgmu0RfQ==}
@@ -12266,7 +12269,7 @@ snapshots:
type-fest: 4.26.1
uuid: 8.3.2
'@signalapp/mock-server@11.0.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)':
'@signalapp/mock-server@11.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)':
dependencies:
'@indutny/parallel-prettier': 3.0.0(prettier@3.3.3)
'@signalapp/libsignal-client': 0.60.2
@@ -13134,7 +13137,7 @@ snapshots:
dependencies:
'@types/node': 20.17.6
'@types/websocket@1.0.0':
'@types/websocket@1.0.0(patch_hash=53fdd65a6b35f379eb8f5807d315f00c66e9bfe9741f4859a0fa21026bdb30fa)':
dependencies:
'@types/node': 20.17.6
@@ -20849,7 +20852,7 @@ snapshots:
websocket-extensions@0.1.4: {}
websocket@1.0.34(patch_hash=4ce5bb237501a5576bd9e09fbff46551dd3e57f7702f6033bcffa49dde7f71bb):
websocket@1.0.34(patch_hash=b8d361a6a73e44000bb51102dea0d841c22d2bb455dd6c54de566d0e0bd86355):
dependencies:
bufferutil: 4.0.9
debug: 2.6.9

View File

@@ -208,6 +208,7 @@ import { handleDataMessage } from './messages/handleDataMessage';
import { MessageModel } from './models/messages';
import { waitForEvent } from './shims/events';
import { sendSyncRequests } from './textsecure/syncRequests';
import { handleServerAlerts } from './util/handleServerAlerts';
export function isOverHourIntoPast(timestamp: number): boolean {
return isNumber(timestamp) && isOlderThan(timestamp, HOUR);
@@ -511,6 +512,8 @@ export async function startApp(): Promise<void> {
restoreRemoteConfigFromStorage();
window.Whisper.events.on('firstEnvelope', checkFirstEnvelope);
window.Whisper.events.on('serverAlerts', handleServerAlerts);
server = window.WebAPI.connect({
...window.textsecure.storage.user.getWebAPICredentials(),
hasStoriesDisabled: window.storage.get('hasStoriesDisabled', false),

View File

@@ -0,0 +1,46 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { LeftPaneDialog } from './LeftPaneDialog';
import type { WidthBreakpoint } from './_util';
import type { LocalizerType } from '../types/I18N';
import { I18n } from './I18n';
export type PropsType = {
containerWidthBreakpoint: WidthBreakpoint;
i18n: LocalizerType;
};
export function CriticalIdlePrimaryDeviceDialog({
containerWidthBreakpoint,
i18n,
}: PropsType): JSX.Element {
const learnMoreLink = (parts: Array<string | JSX.Element>) => (
<a
key="signal-support"
// TODO: DESKTOP-8377
href="https://support.signal.org/"
rel="noreferrer"
target="_blank"
>
{parts}
</a>
);
return (
<LeftPaneDialog
containerWidthBreakpoint={containerWidthBreakpoint}
type="warning"
title={i18n('icu:CriticalIdlePrimaryDevice__title')}
>
<I18n
id="icu:CriticalIdlePrimaryDevice__body"
i18n={i18n}
components={{
learnMoreLink,
}}
/>
</LeftPaneDialog>
);
}

View File

@@ -35,6 +35,7 @@ import {
useUuidFetchState,
} from '../test-both/helpers/fakeLookupConversationWithoutServiceId';
import type { GroupListItemConversationType } from './conversationList/GroupListItem';
import { CriticalIdlePrimaryDeviceDialog } from './CriticalIdlePrimaryDeviceDialog';
const i18n = setupI18n('en', enMessages);
@@ -173,6 +174,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
getPreferredBadge: () => undefined,
hasFailedStorySends: false,
hasPendingUpdate: false,
hasCriticalIdlePrimaryDeviceAlert: false,
i18n,
isMacOS: false,
preferredWidthFromStorage: 320,
@@ -271,6 +273,9 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
/>
),
renderExpiredBuildDialog: props => <DialogExpiredBuild {...props} />,
renderCriticalIdlePrimaryDeviceDialog: props => (
<CriticalIdlePrimaryDeviceDialog {...props} />
),
renderUnsupportedOSDialog: props => (
<UnsupportedOSDialog
i18n={i18n}
@@ -393,6 +398,15 @@ export function InboxBackupMediaDownloadWithDialogsAndUnpinnedConversations(): J
/>
);
}
export function InboxCriticalIdlePrimaryDeviceAlert(): JSX.Element {
return (
<LeftPaneInContainer
{...useProps({
hasCriticalIdlePrimaryDeviceAlert: true,
})}
/>
);
}
export function InboxUsernameCorrupted(): JSX.Element {
return (

View File

@@ -69,6 +69,7 @@ export type PropsType = {
otherTabsUnreadStats: UnreadStats;
hasExpiredDialog: boolean;
hasFailedStorySends: boolean;
hasCriticalIdlePrimaryDeviceAlert: boolean;
hasNetworkDialog: boolean;
hasPendingUpdate: boolean;
hasRelinkDialog: boolean;
@@ -180,6 +181,12 @@ export type PropsType = {
renderCaptchaDialog: (props: { onSkip(): void }) => JSX.Element;
renderCrashReportDialog: () => JSX.Element;
renderExpiredBuildDialog: (_: DialogExpiredBuildPropsType) => JSX.Element;
renderCriticalIdlePrimaryDeviceDialog: (
_: Readonly<{
containerWidthBreakpoint: WidthBreakpoint;
i18n: LocalizerType;
}>
) => JSX.Element;
renderToastManager: (_: {
containerWidthBreakpoint: WidthBreakpoint;
}) => JSX.Element;
@@ -206,6 +213,7 @@ export function LeftPane({
getPreferredBadge,
hasExpiredDialog,
hasFailedStorySends,
hasCriticalIdlePrimaryDeviceAlert,
hasNetworkDialog,
hasPendingUpdate,
hasRelinkDialog,
@@ -227,6 +235,7 @@ export function LeftPane({
renderCaptchaDialog,
renderCrashReportDialog,
renderExpiredBuildDialog,
renderCriticalIdlePrimaryDeviceDialog,
renderMessageSearchResult,
renderNetworkStatus,
renderUnsupportedOSDialog,
@@ -611,6 +620,9 @@ export function LeftPane({
maybeYellowDialog = renderNetworkStatus(commonDialogProps);
} else if (hasRelinkDialog) {
maybeYellowDialog = renderRelinkDialog(commonDialogProps);
} else if (hasCriticalIdlePrimaryDeviceAlert) {
maybeYellowDialog =
renderCriticalIdlePrimaryDeviceDialog(commonDialogProps);
}
// Update dialog

View File

@@ -23,6 +23,7 @@ import { actions as mediaGallery } from './ducks/mediaGallery';
import { actions as network } from './ducks/network';
import { actions as safetyNumber } from './ducks/safetyNumber';
import { actions as search } from './ducks/search';
import { actions as server } from './ducks/server';
import { actions as stickers } from './ducks/stickers';
import { actions as stories } from './ducks/stories';
import { actions as storyDistributionLists } from './ducks/storyDistributionLists';
@@ -55,6 +56,7 @@ export const actionCreators: ReduxActions = {
network,
safetyNumber,
search,
server,
stickers,
stories,
storyDistributionLists,

59
ts/state/ducks/server.ts Normal file
View File

@@ -0,0 +1,59 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReadonlyDeep } from 'type-fest';
// State
export enum ServerAlert {
CRITICAL_IDLE_PRIMARY_DEVICE = 'critical_idle_primary_device',
}
export type ServerStateType = ReadonlyDeep<{
alerts: Array<ServerAlert>;
}>;
// Actions
const UPDATE_SERVER_ALERTS = 'server/UPDATE_SERVER_ALERTS';
type UpdateServerAlertsType = ReadonlyDeep<{
type: 'server/UPDATE_SERVER_ALERTS';
payload: { alerts: Array<ServerAlert> };
}>;
export type ServerActionType = ReadonlyDeep<UpdateServerAlertsType>;
// Action Creators
function updateServerAlerts(alerts: Array<ServerAlert>): ServerActionType {
return {
type: UPDATE_SERVER_ALERTS,
payload: { alerts },
};
}
export const actions = {
updateServerAlerts,
};
// Reducer
export function getEmptyState(): ServerStateType {
return {
alerts: [],
};
}
export function reducer(
state: Readonly<ServerStateType> = getEmptyState(),
action: Readonly<ServerActionType>
): ServerStateType {
if (action.type === UPDATE_SERVER_ALERTS) {
return {
alerts: action.payload.alerts,
};
}
return state;
}

View File

@@ -25,6 +25,7 @@ import { getEmptyState as networkEmptyState } from './ducks/network';
import { getEmptyState as preferredReactionsEmptyState } from './ducks/preferredReactions';
import { getEmptyState as safetyNumberEmptyState } from './ducks/safetyNumber';
import { getEmptyState as searchEmptyState } from './ducks/search';
import { getEmptyState as serverEmptyState } from './ducks/server';
import { getEmptyState as stickersEmptyState } from './ducks/stickers';
import { getEmptyState as storiesEmptyState } from './ducks/stories';
import { getEmptyState as storyDistributionListsEmptyState } from './ducks/storyDistributionLists';
@@ -144,6 +145,7 @@ function getEmptyState(): StateType {
preferredReactions: preferredReactionsEmptyState(),
safetyNumber: safetyNumberEmptyState(),
search: searchEmptyState(),
server: serverEmptyState(),
stickers: stickersEmptyState(),
stories: storiesEmptyState(),
storyDistributionLists: storyDistributionListsEmptyState(),

View File

@@ -83,6 +83,7 @@ export function initializeRedux(data: ReduxInitData): void {
store.dispatch
),
search: bindActionCreators(actionCreators.search, store.dispatch),
server: bindActionCreators(actionCreators.server, store.dispatch),
stickers: bindActionCreators(actionCreators.stickers, store.dispatch),
stories: bindActionCreators(actionCreators.stories, store.dispatch),
storyDistributionLists: bindActionCreators(

View File

@@ -27,6 +27,7 @@ import { reducer as network } from './ducks/network';
import { reducer as preferredReactions } from './ducks/preferredReactions';
import { reducer as safetyNumber } from './ducks/safetyNumber';
import { reducer as search } from './ducks/search';
import { reducer as server } from './ducks/server';
import { reducer as stickers } from './ducks/stickers';
import { reducer as stories } from './ducks/stories';
import { reducer as storyDistributionLists } from './ducks/storyDistributionLists';
@@ -60,6 +61,7 @@ export const reducer = combineReducers({
preferredReactions,
safetyNumber,
search,
server,
stickers,
stories,
storyDistributionLists,

View File

@@ -0,0 +1,17 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { createSelector } from 'reselect';
import type { StateType } from '../reducer';
import { ServerAlert } from '../ducks/server';
export const getServerAlerts = (state: StateType): ReadonlyArray<ServerAlert> =>
state.server.alerts;
export const getHasCriticalIdlePrimaryDeviceAlert = createSelector(
getServerAlerts,
(alerts): boolean => {
return alerts.includes(ServerAlert.CRITICAL_IDLE_PRIMARY_DEVICE);
}
);

View File

@@ -104,6 +104,9 @@ import {
pauseBackupMediaDownload,
resumeBackupMediaDownload,
} from '../../util/backupMediaDownload';
import { getHasCriticalIdlePrimaryDeviceAlert } from '../selectors/server';
import { CriticalIdlePrimaryDeviceDialog } from '../../components/CriticalIdlePrimaryDeviceDialog';
import type { LocalizerType } from '../../types/I18N';
function renderMessageSearchResult(id: string): JSX.Element {
return <SmartMessageSearchResult id={id} />;
@@ -123,7 +126,14 @@ function renderUpdateDialog(
): JSX.Element {
return <SmartUpdateDialog {...props} />;
}
function renderCriticalIdlePrimaryDeviceDialog(
props: Readonly<{
containerWidthBreakpoint: WidthBreakpoint;
i18n: LocalizerType;
}>
): JSX.Element {
return <CriticalIdlePrimaryDeviceDialog {...props} />;
}
function renderCaptchaDialog({ onSkip }: { onSkip(): void }): JSX.Element {
return <SmartCaptchaDialog onSkip={onSkip} />;
}
@@ -297,6 +307,10 @@ export const SmartLeftPane = memo(function SmartLeftPane({
const backupMediaDownloadProgress = useSelector(
getBackupMediaDownloadProgress
);
const hasCriticalIdlePrimaryDeviceAlert = useSelector(
getHasCriticalIdlePrimaryDeviceAlert
);
const {
blockConversation,
clearGroupCreationError,
@@ -388,6 +402,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({
getPreferredBadge={getPreferredBadge}
hasExpiredDialog={hasExpiredDialog}
hasFailedStorySends={hasFailedStorySends}
hasCriticalIdlePrimaryDeviceAlert={hasCriticalIdlePrimaryDeviceAlert}
hasNetworkDialog={hasNetworkDialog}
hasPendingUpdate={hasPendingUpdate}
hasRelinkDialog={hasRelinkDialog}
@@ -409,6 +424,9 @@ export const SmartLeftPane = memo(function SmartLeftPane({
renderCaptchaDialog={renderCaptchaDialog}
renderCrashReportDialog={renderCrashReportDialog}
renderExpiredBuildDialog={renderExpiredBuildDialog}
renderCriticalIdlePrimaryDeviceDialog={
renderCriticalIdlePrimaryDeviceDialog
}
renderMessageSearchResult={renderMessageSearchResult}
renderNetworkStatus={renderNetworkStatus}
renderRelinkDialog={renderRelinkDialog}

View File

@@ -23,6 +23,7 @@ import type { actions as mediaGallery } from './ducks/mediaGallery';
import type { actions as network } from './ducks/network';
import type { actions as safetyNumber } from './ducks/safetyNumber';
import type { actions as search } from './ducks/search';
import type { actions as server } from './ducks/server';
import type { actions as stickers } from './ducks/stickers';
import type { actions as stories } from './ducks/stories';
import type { actions as storyDistributionLists } from './ducks/storyDistributionLists';
@@ -54,6 +55,7 @@ export type ReduxActions = {
network: typeof network;
safetyNumber: typeof safetyNumber;
search: typeof search;
server: typeof server;
stickers: typeof stickers;
stories: typeof stories;
storyDistributionLists: typeof storyDistributionLists;

View File

@@ -252,12 +252,15 @@ export async function createGroup(
await phone.setStorageState(state);
return group;
}
export function getLeftPane(page: Page): Locator {
return page.locator('#LeftPane');
}
export async function clickOnConversationWithAci(
page: Page,
aci: string
): Promise<void> {
const leftPane = page.locator('#LeftPane');
const leftPane = getLeftPane(page);
await leftPane.getByTestId(aci).click();
}

View File

@@ -0,0 +1,40 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import createDebug from 'debug';
import type { App } from '../playwright';
import { Bootstrap } from '../bootstrap';
import { getLeftPane } from '../helpers';
import { MINUTE } from '../../util/durations';
export const debug = createDebug('mock:test:serverAlerts');
describe('serverAlerts', function (this: Mocha.Suite) {
this.timeout(MINUTE);
let bootstrap: Bootstrap;
let app: App;
beforeEach(async () => {
bootstrap = new Bootstrap();
await bootstrap.init();
});
afterEach(async function (this: Mocha.Context) {
if (!bootstrap) {
return;
}
await bootstrap.maybeSaveLogs(this.currentTest, app);
await app.close();
await bootstrap.teardown();
});
it('shows idle primary device alert', async () => {
bootstrap.server.setWebsocketUpgradeResponseHeaders({
'X-Signal-Alert': 'critical-idle-primary-device',
});
app = await bootstrap.link();
const window = await app.getWindow();
await getLeftPane(window).getByText('Open Signal on your phone').waitFor();
});
});

View File

@@ -12,6 +12,7 @@ import { Headers } from 'node-fetch';
import type { connection as WebSocket } from 'websocket';
import qs from 'querystring';
import EventListener from 'events';
import type { IncomingMessage } from 'http';
import type { AbortableProcess } from '../util/AbortableProcess';
import { strictAssert } from '../util/assert';
@@ -50,6 +51,7 @@ import { isNightly, isBeta, isStaging } from '../util/version';
import { getBasicAuth } from '../util/getBasicAuth';
import { isTestOrMockEnvironment } from '../environment';
import type { ConfigKeyType } from '../RemoteConfig';
import { ServerAlert } from '../state/ducks/server';
const FIVE_MINUTES = 5 * durations.MINUTE;
@@ -220,6 +222,9 @@ export class SocketManager extends EventListener {
Authorization: getBasicAuth({ username, password }),
'X-Signal-Receive-Stories': String(!this.#hasStoriesDisabled),
},
onUpgradeResponse: (response: IncomingMessage) => {
this.#handleAuthenticatedUpgradeResponseHeaders(response.headers);
},
proxyAgent,
});
@@ -758,6 +763,7 @@ export class SocketManager extends EventListener {
resourceOptions,
query = {},
extraHeaders = {},
onUpgradeResponse,
timeout,
}: {
name: string;
@@ -766,6 +772,7 @@ export class SocketManager extends EventListener {
resourceOptions: WebSocketResourceOptions;
query?: Record<string, string>;
extraHeaders?: Record<string, string>;
onUpgradeResponse?: (response: IncomingMessage) => void;
timeout?: number;
}): AbortableProcess<IWebSocketResource> {
const queryWithDefaults = {
@@ -787,6 +794,7 @@ export class SocketManager extends EventListener {
timeout,
extraHeaders,
onUpgradeResponse,
createResource(socket: WebSocket): WebSocketResource {
const duration = (performance.now() - start).toFixed(1);
@@ -947,6 +955,33 @@ export class SocketManager extends EventListener {
return this.#lazyProxyAgent;
}
#handleAuthenticatedUpgradeResponseHeaders(
headers: Record<string, string | Array<string> | undefined>
) {
let alerts: Array<string> = [];
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === 'x-signal-alert') {
if (value == null) {
alerts = [];
} else if (Array.isArray(value)) {
alerts = value;
} else {
alerts = [value];
}
break;
}
}
const serverAlerts: Array<ServerAlert> = [];
alerts.forEach(alert => {
if (alert.toLowerCase() === 'critical-idle-primary-device') {
serverAlerts.push(ServerAlert.CRITICAL_IDLE_PRIMARY_DEVICE);
}
});
this.emit('serverAlerts', serverAlerts);
}
// EventEmitter types
public override on(type: 'authError', callback: () => void): this;
@@ -957,6 +992,10 @@ export class SocketManager extends EventListener {
type: 'firstEnvelope',
callback: (incoming: IncomingWebSocketRequest) => void
): this;
public override on(
type: 'serverAlerts',
callback: (alerts: Array<ServerAlert>) => void
): this;
public override on(
type: string | symbol,
@@ -974,6 +1013,10 @@ export class SocketManager extends EventListener {
type: 'firstEnvelope',
incoming: IncomingWebSocketRequest
): boolean;
public override emit(
type: 'serverAlerts',
alerts: Array<ServerAlert>
): boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public override emit(type: string | symbol, ...args: Array<any>): boolean {

View File

@@ -1812,6 +1812,10 @@ export function initialize({
window.Whisper.events.trigger('firstEnvelope', incoming);
});
socketManager.on('serverAlerts', alerts => {
window.Whisper.events.trigger('serverAlerts', alerts);
});
if (useWebSocket) {
void socketManager.authenticate({ username, password });
}

View File

@@ -3,6 +3,7 @@
import { client as WebSocketClient } from 'websocket';
import type { connection as WebSocket } from 'websocket';
import type { IncomingMessage } from 'http';
import { AbortableProcess } from '../util/AbortableProcess';
import { strictAssert } from '../util/assert';
@@ -32,6 +33,7 @@ export type ConnectOptionsType<Resource extends IResource> = Readonly<{
proxyAgent?: ProxyAgent;
timeout?: number;
extraHeaders?: Record<string, string>;
onUpgradeResponse?: (response: IncomingMessage) => void;
createResource(socket: WebSocket): Resource;
}>;
@@ -44,6 +46,7 @@ export function connect<Resource extends IResource>({
proxyAgent,
extraHeaders = {},
timeout = WEBSOCKET_CONNECT_TIMEOUT,
onUpgradeResponse,
createResource,
}: ConnectOptionsType<Resource>): AbortableProcess<Resource> {
const fixedScheme = url
@@ -84,6 +87,10 @@ export function connect<Resource extends IResource>({
resolve(resource);
});
client.on('upgradeResponse', response => {
onUpgradeResponse?.(response);
});
client.on('httpResponse', async response => {
Timers.clearTimeout(timer);

View File

@@ -0,0 +1,8 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ServerAlert } from '../state/ducks/server';
export function handleServerAlerts(alerts: Array<ServerAlert>): void {
window.reduxActions.server.updateServerAlerts(alerts);
}