From f5fe787ed75cadf359ee063f74f314648ffd1796 Mon Sep 17 00:00:00 2001 From: trevor-signal <131492920+trevor-signal@users.noreply.github.com> Date: Thu, 6 Mar 2025 12:58:57 -0500 Subject: [PATCH] Show critical-idle-primary-device banner in response to WS upgrade response headers --- _locales/en/messages.json | 8 +++ package.json | 3 +- patches/@types+websocket+1.0.0.patch | 16 +++++ patches/websocket+1.0.34.patch | 14 ++++- pnpm-lock.yaml | 23 ++++---- ts/background.ts | 3 + .../CriticalIdlePrimaryDeviceDialog.tsx | 46 +++++++++++++++ ts/components/LeftPane.stories.tsx | 14 +++++ ts/components/LeftPane.tsx | 12 ++++ ts/state/actions.ts | 2 + ts/state/ducks/server.ts | 59 +++++++++++++++++++ ts/state/getInitialState.ts | 2 + ts/state/initializeRedux.ts | 1 + ts/state/reducer.ts | 2 + ts/state/selectors/server.ts | 17 ++++++ ts/state/smart/LeftPane.tsx | 20 ++++++- ts/state/types.ts | 2 + ts/test-mock/helpers.ts | 5 +- ts/test-mock/network/serverAlerts_test.ts | 40 +++++++++++++ ts/textsecure/SocketManager.ts | 43 ++++++++++++++ ts/textsecure/WebAPI.ts | 4 ++ ts/textsecure/WebSocket.ts | 7 +++ ts/util/handleServerAlerts.ts | 8 +++ 23 files changed, 337 insertions(+), 14 deletions(-) create mode 100644 patches/@types+websocket+1.0.0.patch create mode 100644 ts/components/CriticalIdlePrimaryDeviceDialog.tsx create mode 100644 ts/state/ducks/server.ts create mode 100644 ts/state/selectors/server.ts create mode 100644 ts/test-mock/network/serverAlerts_test.ts create mode 100644 ts/util/handleServerAlerts.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 2a1a46904..e345ec21c 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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. Learn more", + "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." diff --git a/package.json b/package.json index 86934c809..407cfede6 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/patches/@types+websocket+1.0.0.patch b/patches/@types+websocket+1.0.0.patch new file mode 100644 index 000000000..5e76694fe --- /dev/null +++ b/patches/@types+websocket+1.0.0.patch @@ -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 { diff --git a/patches/websocket+1.0.34.patch b/patches/websocket+1.0.34.patch index 1a33914b4..783ecaca1 100644 --- a/patches/websocket+1.0.34.patch +++ b/patches/websocket+1.0.34.patch @@ -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) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51dc72bdf..b6185b202 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/ts/background.ts b/ts/background.ts index 6118e01e8..df9c92a49 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -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 { 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), diff --git a/ts/components/CriticalIdlePrimaryDeviceDialog.tsx b/ts/components/CriticalIdlePrimaryDeviceDialog.tsx new file mode 100644 index 000000000..53cd6f573 --- /dev/null +++ b/ts/components/CriticalIdlePrimaryDeviceDialog.tsx @@ -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) => ( + + {parts} + + ); + return ( + + + + ); +} diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx index bc2d02bb8..66a0c607e 100644 --- a/ts/components/LeftPane.stories.tsx +++ b/ts/components/LeftPane.stories.tsx @@ -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 => , + renderCriticalIdlePrimaryDeviceDialog: props => ( + + ), renderUnsupportedOSDialog: props => ( ); } +export function InboxCriticalIdlePrimaryDeviceAlert(): JSX.Element { + return ( + + ); +} export function InboxUsernameCorrupted(): JSX.Element { return ( diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index e1b0801bb..26146bfae 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -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 diff --git a/ts/state/actions.ts b/ts/state/actions.ts index eb6fde5e4..711efeee6 100644 --- a/ts/state/actions.ts +++ b/ts/state/actions.ts @@ -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, diff --git a/ts/state/ducks/server.ts b/ts/state/ducks/server.ts new file mode 100644 index 000000000..adf560773 --- /dev/null +++ b/ts/state/ducks/server.ts @@ -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; +}>; + +// Actions + +const UPDATE_SERVER_ALERTS = 'server/UPDATE_SERVER_ALERTS'; + +type UpdateServerAlertsType = ReadonlyDeep<{ + type: 'server/UPDATE_SERVER_ALERTS'; + payload: { alerts: Array }; +}>; + +export type ServerActionType = ReadonlyDeep; + +// Action Creators + +function updateServerAlerts(alerts: Array): ServerActionType { + return { + type: UPDATE_SERVER_ALERTS, + payload: { alerts }, + }; +} + +export const actions = { + updateServerAlerts, +}; + +// Reducer + +export function getEmptyState(): ServerStateType { + return { + alerts: [], + }; +} + +export function reducer( + state: Readonly = getEmptyState(), + action: Readonly +): ServerStateType { + if (action.type === UPDATE_SERVER_ALERTS) { + return { + alerts: action.payload.alerts, + }; + } + + return state; +} diff --git a/ts/state/getInitialState.ts b/ts/state/getInitialState.ts index 863112298..07d15e1da 100644 --- a/ts/state/getInitialState.ts +++ b/ts/state/getInitialState.ts @@ -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(), diff --git a/ts/state/initializeRedux.ts b/ts/state/initializeRedux.ts index 2f8b8d964..c81e9c517 100644 --- a/ts/state/initializeRedux.ts +++ b/ts/state/initializeRedux.ts @@ -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( diff --git a/ts/state/reducer.ts b/ts/state/reducer.ts index 56be010ab..13079b14b 100644 --- a/ts/state/reducer.ts +++ b/ts/state/reducer.ts @@ -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, diff --git a/ts/state/selectors/server.ts b/ts/state/selectors/server.ts new file mode 100644 index 000000000..d2e221b57 --- /dev/null +++ b/ts/state/selectors/server.ts @@ -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 => + state.server.alerts; + +export const getHasCriticalIdlePrimaryDeviceAlert = createSelector( + getServerAlerts, + (alerts): boolean => { + return alerts.includes(ServerAlert.CRITICAL_IDLE_PRIMARY_DEVICE); + } +); diff --git a/ts/state/smart/LeftPane.tsx b/ts/state/smart/LeftPane.tsx index a3a5efa5d..547d54430 100644 --- a/ts/state/smart/LeftPane.tsx +++ b/ts/state/smart/LeftPane.tsx @@ -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 ; @@ -123,7 +126,14 @@ function renderUpdateDialog( ): JSX.Element { return ; } - +function renderCriticalIdlePrimaryDeviceDialog( + props: Readonly<{ + containerWidthBreakpoint: WidthBreakpoint; + i18n: LocalizerType; + }> +): JSX.Element { + return ; +} function renderCaptchaDialog({ onSkip }: { onSkip(): void }): JSX.Element { return ; } @@ -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} diff --git a/ts/state/types.ts b/ts/state/types.ts index e629b1792..6d8378fef 100644 --- a/ts/state/types.ts +++ b/ts/state/types.ts @@ -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; diff --git a/ts/test-mock/helpers.ts b/ts/test-mock/helpers.ts index 4bcf8c773..12a7c02a4 100644 --- a/ts/test-mock/helpers.ts +++ b/ts/test-mock/helpers.ts @@ -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 { - const leftPane = page.locator('#LeftPane'); + const leftPane = getLeftPane(page); await leftPane.getByTestId(aci).click(); } diff --git a/ts/test-mock/network/serverAlerts_test.ts b/ts/test-mock/network/serverAlerts_test.ts new file mode 100644 index 000000000..d27a9bfc5 --- /dev/null +++ b/ts/test-mock/network/serverAlerts_test.ts @@ -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(); + }); +}); diff --git a/ts/textsecure/SocketManager.ts b/ts/textsecure/SocketManager.ts index 1d808b890..6b85f8e0f 100644 --- a/ts/textsecure/SocketManager.ts +++ b/ts/textsecure/SocketManager.ts @@ -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; extraHeaders?: Record; + onUpgradeResponse?: (response: IncomingMessage) => void; timeout?: number; }): AbortableProcess { 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 | undefined> + ) { + let alerts: Array = []; + 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 = []; + 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) => 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 + ): boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any public override emit(type: string | symbol, ...args: Array): boolean { diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index ed8363e90..a04d6e71e 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -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 }); } diff --git a/ts/textsecure/WebSocket.ts b/ts/textsecure/WebSocket.ts index 23f1c4a03..5c7c3577c 100644 --- a/ts/textsecure/WebSocket.ts +++ b/ts/textsecure/WebSocket.ts @@ -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 = Readonly<{ proxyAgent?: ProxyAgent; timeout?: number; extraHeaders?: Record; + onUpgradeResponse?: (response: IncomingMessage) => void; createResource(socket: WebSocket): Resource; }>; @@ -44,6 +46,7 @@ export function connect({ proxyAgent, extraHeaders = {}, timeout = WEBSOCKET_CONNECT_TIMEOUT, + onUpgradeResponse, createResource, }: ConnectOptionsType): AbortableProcess { const fixedScheme = url @@ -84,6 +87,10 @@ export function connect({ resolve(resource); }); + client.on('upgradeResponse', response => { + onUpgradeResponse?.(response); + }); + client.on('httpResponse', async response => { Timers.clearTimeout(timer); diff --git a/ts/util/handleServerAlerts.ts b/ts/util/handleServerAlerts.ts new file mode 100644 index 000000000..027349ba7 --- /dev/null +++ b/ts/util/handleServerAlerts.ts @@ -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): void { + window.reduxActions.server.updateServerAlerts(alerts); +}