diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index 9b2b40c730..8c2d30ef84 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -10122,6 +10122,10 @@
"messageformat": "This helps us learn more about calls and what is working or not working. You can view your debug log before submitting.",
"description": "Call Quality Survey Dialog > Help us improve > Page Description"
},
+ "icu:CallQualitySurvey__ConfirmSubmission__PageDescriptionWithDiagnosticLink": {
+ "messageformat": "Submitting will share your feedback along with diagnostic information about your call. You can optionally share a debug log to help us improve call quality.",
+ "description": "Call Quality Survey Dialog > Help us improve > Page Description with link to view diagnostic info"
+ },
"icu:CallQualitySurvey__ConfirmSubmission__ShareDebugLog__Label": {
"messageformat": "Share debug log",
"description": "Call Quality Survey Dialog > Help us improve > Share debug log > Label"
@@ -10134,6 +10138,14 @@
"messageformat": "Debug logs contain low level app information and do not reveal any of your message contents.",
"description": "Call Quality Survey Dialog > Help us improve > Share debug log > Help Text"
},
+ "icu:CallQualitySurvey__ConfirmSubmission__PrivacyNote": {
+ "messageformat": "Information shared with us contains low level app information and does not include the contents of your calls.",
+ "description": "Call Quality Survey Dialog > Help us improve > Privacy note about what information is shared"
+ },
+ "icu:CallDiagnosticWindow__title": {
+ "messageformat": "Diagnostic information",
+ "description": "Title of the call diagnostic information window"
+ },
"icu:CallQualitySurvey__ConfirmSubmission__SubmitButton": {
"messageformat": "Submit",
"description": "Call Quality Survey Dialog > Help us improve > Submit Button"
diff --git a/app/main.main.ts b/app/main.main.ts
index d3ccf9f504..10ae8d8295 100644
--- a/app/main.main.ts
+++ b/app/main.main.ts
@@ -1401,16 +1401,28 @@ async function openArtCreator() {
}
let debugLogWindow: BrowserWindow | undefined;
+let debugLogCurrentMode: 'submit' | 'close' | undefined;
type DebugLogWindowOptions = {
mode?: 'submit' | 'close';
};
async function showDebugLogWindow(options: DebugLogWindowOptions = {}) {
+ const newMode = options.mode ?? 'submit';
+
if (debugLogWindow) {
+ if (debugLogCurrentMode !== newMode) {
+ debugLogCurrentMode = newMode;
+ const url = pathToFileURL(join(__dirname, '../debug_log.html'));
+ url.searchParams.set('mode', newMode);
+ await safeLoadURL(debugLogWindow, url.href);
+ }
+
doShowDebugLogWindow();
return;
}
+ debugLogCurrentMode = newMode;
+
function doShowDebugLogWindow() {
if (debugLogWindow) {
// Electron has [a macOS bug][0] that causes parent windows to become unresponsive
@@ -1452,6 +1464,7 @@ async function showDebugLogWindow(options: DebugLogWindowOptions = {}) {
debugLogWindow.on('closed', () => {
debugLogWindow = undefined;
+ debugLogCurrentMode = undefined;
});
debugLogWindow.once('ready-to-show', () => {
@@ -1471,6 +1484,71 @@ async function showDebugLogWindow(options: DebugLogWindowOptions = {}) {
await safeLoadURL(debugLogWindow, url.href);
}
+let callDiagnosticWindow: BrowserWindow | undefined;
+let storedCallDiagnosticData: string | undefined;
+
+async function showCallDiagnosticWindow() {
+ if (callDiagnosticWindow) {
+ doShowCallDiagnosticWindow();
+ return;
+ }
+
+ function doShowCallDiagnosticWindow() {
+ if (callDiagnosticWindow) {
+ // Electron has [a macOS bug][0] that causes parent windows to become unresponsive
+ // if it's fullscreen and opens a fullscreen child window. Until that's fixed, we
+ // only set the parent on MacOS is if the mainWindow is not fullscreen
+ // [0]: https://github.com/electron/electron/issues/32374
+ if (OS.isMacOS() && mainWindow?.isFullScreen()) {
+ callDiagnosticWindow.setParentWindow(null);
+ } else {
+ callDiagnosticWindow.setParentWindow(mainWindow ?? null);
+ }
+ callDiagnosticWindow.show();
+ }
+ }
+
+ const windowOptions: Electron.BrowserWindowConstructorOptions = {
+ width: 700,
+ height: 500,
+ resizable: false,
+ title: getResolvedMessagesLocale().i18n('icu:CallDiagnosticWindow__title'),
+ titleBarStyle: nonMainTitleBarStyle,
+ autoHideMenuBar: true,
+ backgroundColor: await getBackgroundColor(),
+ show: false,
+ webPreferences: {
+ ...defaultWebPrefs,
+ nodeIntegration: false,
+ nodeIntegrationInWorker: false,
+ sandbox: true,
+ contextIsolation: true,
+ preload: join(__dirname, '../bundles/calldiagnostic/preload.preload.js'),
+ },
+ parent: mainWindow,
+ };
+
+ callDiagnosticWindow = new BrowserWindow(windowOptions);
+
+ await handleCommonWindowEvents(callDiagnosticWindow);
+
+ callDiagnosticWindow.on('closed', () => {
+ callDiagnosticWindow = undefined;
+ });
+
+ callDiagnosticWindow.once('ready-to-show', () => {
+ if (callDiagnosticWindow) {
+ doShowCallDiagnosticWindow();
+
+ // Electron sometimes puts the window in a strange spot until it's shown.
+ callDiagnosticWindow.center();
+ }
+ });
+
+ const url = pathToFileURL(join(__dirname, '../call_diagnostic.html'));
+ await safeLoadURL(callDiagnosticWindow, url.href);
+}
+
let permissionsPopupWindow: BrowserWindow | undefined;
function showPermissionsPopupWindow(forCalling: boolean, forCamera: boolean) {
// eslint-disable-next-line no-async-promise-executor
@@ -2686,6 +2764,35 @@ ipc.on(
}
);
+// Call Diagnostic Window-related IPC calls
+
+ipc.on('show-call-diagnostic', () => {
+ void showCallDiagnosticWindow();
+});
+
+ipc.handle('get-call-diagnostic-data', () => {
+ return storedCallDiagnosticData ?? '';
+});
+
+ipc.on('close-call-diagnostic', () => {
+ storedCallDiagnosticData = undefined;
+ callDiagnosticWindow?.close();
+});
+
+ipc.on('close-debug-log', () => {
+ if (debugLogCurrentMode === 'close') {
+ debugLogWindow?.close();
+ }
+});
+
+ipc.on('update-call-diagnostic-data', (_event, diagnosticData: string) => {
+ storedCallDiagnosticData = diagnosticData;
+
+ if (callDiagnosticWindow && !callDiagnosticWindow.isDestroyed()) {
+ callDiagnosticWindow.webContents.send('call-diagnostic-data-updated');
+ }
+});
+
// Permissions Popup-related IPC calls
ipc.handle(
diff --git a/call_diagnostic.html b/call_diagnostic.html
new file mode 100644
index 0000000000..fa9b2da47b
--- /dev/null
+++ b/call_diagnostic.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/scripts/esbuild.js b/scripts/esbuild.js
index 4ed3b2ae9e..8b02581be4 100644
--- a/scripts/esbuild.js
+++ b/scripts/esbuild.js
@@ -178,6 +178,7 @@ async function sandboxedEnv() {
mainFields: ['browser', 'main'],
entryPoints: [
path.join(ROOT_DIR, 'ts', 'windows', 'about', 'app.dom.tsx'),
+ path.join(ROOT_DIR, 'ts', 'windows', 'calldiagnostic', 'app.dom.tsx'),
path.join(ROOT_DIR, 'ts', 'windows', 'debuglog', 'app.dom.tsx'),
path.join(ROOT_DIR, 'ts', 'windows', 'loading', 'start.dom.ts'),
path.join(ROOT_DIR, 'ts', 'windows', 'permissions', 'app.dom.tsx'),
@@ -189,6 +190,13 @@ async function sandboxedEnv() {
mainFields: ['browser', 'main'],
entryPoints: [
path.join(ROOT_DIR, 'ts', 'windows', 'about', 'preload.preload.ts'),
+ path.join(
+ ROOT_DIR,
+ 'ts',
+ 'windows',
+ 'calldiagnostic',
+ 'preload.preload.ts'
+ ),
path.join(ROOT_DIR, 'ts', 'windows', 'debuglog', 'preload.preload.ts'),
path.join(ROOT_DIR, 'ts', 'windows', 'loading', 'preload.preload.ts'),
path.join(
diff --git a/ts/components/CallDiagnosticWindow.dom.tsx b/ts/components/CallDiagnosticWindow.dom.tsx
new file mode 100644
index 0000000000..33d75c069f
--- /dev/null
+++ b/ts/components/CallDiagnosticWindow.dom.tsx
@@ -0,0 +1,70 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React, { useMemo } from 'react';
+
+import type { LocalizerType } from '../types/Util.std.js';
+import { tw } from '../axo/tw.dom.js';
+import { AxoButton } from '../axo/AxoButton.dom.js';
+import { useEscapeHandling } from '../hooks/useEscapeHandling.dom.js';
+
+export type PropsType = {
+ closeWindow: () => unknown;
+ i18n: LocalizerType;
+ diagnosticData: string;
+};
+
+export function CallDiagnosticWindow({
+ closeWindow,
+ i18n,
+ diagnosticData,
+}: PropsType): React.JSX.Element {
+ useEscapeHandling(closeWindow);
+
+ const formattedData = useMemo(() => {
+ try {
+ const parsed = JSON.parse(diagnosticData);
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { rawStats, rawStatsText, ...rest } = parsed;
+ const pretty = {
+ ...rest,
+ rawStats: JSON.parse(rawStatsText),
+ };
+ return JSON.stringify(pretty, null, 2);
+ } catch {
+ return diagnosticData;
+ }
+ }, [diagnosticData]);
+
+ return (
+
+
+
+ {i18n('icu:CallDiagnosticWindow__title')}
+
+
+
+
+
+ {i18n('icu:close')}
+
+
+
+ );
+}
diff --git a/ts/components/CallQualitySurvey.dom.stories.tsx b/ts/components/CallQualitySurvey.dom.stories.tsx
index 53ee5880c8..82a352a5e5 100644
--- a/ts/components/CallQualitySurvey.dom.stories.tsx
+++ b/ts/components/CallQualitySurvey.dom.stories.tsx
@@ -19,6 +19,8 @@ export function Default(): React.JSX.Element {
open={open}
onOpenChange={setOpen}
onSubmit={action('onSubmit')}
+ onViewDebugLog={action('onViewDebugLog')}
+ onViewDiagnosticInfo={action('onViewDiagnosticInfo')}
/>
);
}
diff --git a/ts/components/CallQualitySurveyDialog.dom.tsx b/ts/components/CallQualitySurveyDialog.dom.tsx
index 7a9c4dc743..bc90713ef3 100644
--- a/ts/components/CallQualitySurveyDialog.dom.tsx
+++ b/ts/components/CallQualitySurveyDialog.dom.tsx
@@ -11,9 +11,28 @@ import { missingCaseError } from '../util/missingCaseError.std.js';
import { AxoCheckbox } from '../axo/AxoCheckbox.dom.js';
import { strictAssert } from '../util/assert.std.js';
import { Tooltip, TooltipPlacement } from './Tooltip.dom.js';
+import { I18n } from './I18n.dom.js';
import Issue = CallQualitySurvey.Issue;
+function DiagnosticInfoLink({
+ parts,
+ onClick,
+}: {
+ parts: Array;
+ onClick: () => void;
+}): React.JSX.Element {
+ return (
+
+ );
+}
+
enum Page {
HOW_WAS_YOUR_CALL,
WHAT_ISSUES_DID_YOU_HAVE,
@@ -25,14 +44,16 @@ export type CallQualitySurveyDialogProps = Readonly<{
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (form: CallQualitySurvey.Form) => void;
- onViewDebugLog?: () => void;
+ onViewDebugLog: () => void;
+ onViewDiagnosticInfo: () => void;
isSubmitting?: boolean;
}>;
export function CallQualitySurveyDialog(
props: CallQualitySurveyDialogProps
): React.JSX.Element {
- const { i18n, onSubmit, onViewDebugLog, isSubmitting } = props;
+ const { i18n, onSubmit, onViewDebugLog, onViewDiagnosticInfo, isSubmitting } =
+ props;
const [page, setPage] = useState(Page.HOW_WAS_YOUR_CALL);
const [userSatisfied, setUserSatisfied] = useState(null);
@@ -79,6 +100,20 @@ export function CallQualitySurveyDialog(
shareDebugLog,
]);
+ const renderDiagnosticInfoLink = useCallback(
+ (parts: Array) => (
+
+ ),
+ [onViewDiagnosticInfo]
+ );
+
+ const diagnosticInfoLinkComponents = useMemo(
+ () => ({
+ diagnosticInfoLink: renderDiagnosticInfoLink,
+ }),
+ [renderDiagnosticInfoLink]
+ );
+
return (
@@ -291,9 +326,11 @@ export function CallQualitySurveyDialog(
- {i18n(
- 'icu:CallQualitySurvey__ConfirmSubmission__PageDescription'
- )}
+
@@ -314,9 +351,7 @@ export function CallQualitySurveyDialog(
{
- onViewDebugLog?.();
- }}
+ onClick={onViewDebugLog}
>
{i18n(
'icu:CallQualitySurvey__ConfirmSubmission__ShareDebugLog__ViewButton'
@@ -324,9 +359,7 @@ export function CallQualitySurveyDialog(
- {i18n(
- 'icu:CallQualitySurvey__ConfirmSubmission__ShareDebugLog__HelpText'
- )}
+ {i18n('icu:CallQualitySurvey__ConfirmSubmission__PrivacyNote')}
diff --git a/ts/state/ducks/calling.preload.ts b/ts/state/ducks/calling.preload.ts
index 93cc5cabef..7eb44cc586 100644
--- a/ts/state/ducks/calling.preload.ts
+++ b/ts/state/ducks/calling.preload.ts
@@ -103,10 +103,10 @@ import type {
ToggleConfirmLeaveCallModalActionType,
} from './globalModals.preload.js';
import {
- HIDE_CALL_QUALITY_SURVEY,
SHOW_CALL_QUALITY_SURVEY,
SHOW_ERROR_MODAL,
toggleConfirmLeaveCallModal,
+ hideCallQualitySurvey,
} from './globalModals.preload.js';
import { CallQualitySurvey } from '../../types/CallQualitySurvey.std.js';
import { isCallFailure } from '../../util/callQualitySurvey.dom.js';
@@ -2935,6 +2935,9 @@ function showCallQualitySurvey(
return dispatch => {
dispatch({ type: RESET_CQS_SUBMISSION_STATE });
dispatch({ type: SHOW_CALL_QUALITY_SURVEY, payload });
+
+ const diagnosticData = JSON.stringify(payload.callSummary);
+ window.IPC.updateCallDiagnosticData(diagnosticData);
};
}
@@ -3031,7 +3034,7 @@ function submitCallQualitySurvey(
},
});
} finally {
- dispatch({ type: HIDE_CALL_QUALITY_SURVEY });
+ dispatch(hideCallQualitySurvey());
}
};
}
diff --git a/ts/state/ducks/globalModals.preload.ts b/ts/state/ducks/globalModals.preload.ts
index 200cabdc70..14f1f66ea0 100644
--- a/ts/state/ducks/globalModals.preload.ts
+++ b/ts/state/ducks/globalModals.preload.ts
@@ -657,9 +657,16 @@ function showCallQualitySurvey(
};
}
-function hideCallQualitySurvey(): HideCallQualitySurveyActionType {
- return {
- type: HIDE_CALL_QUALITY_SURVEY,
+export function hideCallQualitySurvey(): ThunkAction<
+ void,
+ RootStateType,
+ unknown,
+ HideCallQualitySurveyActionType
+> {
+ return dispatch => {
+ window.IPC.closeDebugLog();
+ window.IPC.closeCallDiagnostic();
+ dispatch({ type: HIDE_CALL_QUALITY_SURVEY });
};
}
diff --git a/ts/state/smart/CallQualitySurveyDialog.preload.tsx b/ts/state/smart/CallQualitySurveyDialog.preload.tsx
index fe6e7418b0..fc3dcbf490 100644
--- a/ts/state/smart/CallQualitySurveyDialog.preload.tsx
+++ b/ts/state/smart/CallQualitySurveyDialog.preload.tsx
@@ -52,6 +52,10 @@ export const SmartCallQualitySurveyDialog = memo(
[submitCallQualitySurvey, callSummary, callType]
);
+ const handleViewDiagnosticInfo = useCallback(() => {
+ window.IPC.showCallDiagnostic();
+ }, []);
+
return (
window.IPC.showDebugLog({ mode: 'close' })}
+ onViewDiagnosticInfo={handleViewDiagnosticInfo}
isSubmitting={isSubmitting}
/>
);
diff --git a/ts/window.d.ts b/ts/window.d.ts
index bbd2ea4de7..1c1d00b0fc 100644
--- a/ts/window.d.ts
+++ b/ts/window.d.ts
@@ -55,6 +55,10 @@ export type IPCType = {
setMediaCameraPermissions: (value: boolean) => Promise;
setMenuBarVisibility: (value: boolean) => void;
showDebugLog: (options?: { mode?: 'submit' | 'close' }) => void;
+ showCallDiagnostic: () => void;
+ closeCallDiagnostic: () => void;
+ closeDebugLog: () => void;
+ updateCallDiagnosticData: (data: string) => void;
showPermissionsPopup: (
forCalling: boolean,
forCamera: boolean
@@ -91,6 +95,11 @@ type DebugLogWindowPropsType = {
mode: 'submit' | 'close';
};
+type CallDiagnosticWindowPropsType = {
+ subscribe: (listener: () => void) => () => void;
+ getSnapshot: () => string | null;
+};
+
type PermissionsWindowPropsType = {
forCamera: boolean;
forCalling: boolean;
@@ -113,6 +122,7 @@ type SettingsWindowPropsType = {
export type SignalCoreType = {
AboutWindowProps?: AboutWindowPropsType;
+ CallDiagnosticWindowProps?: CallDiagnosticWindowPropsType;
DebugLogWindowProps?: DebugLogWindowPropsType;
PermissionsWindowProps?: PermissionsWindowPropsType;
ScreenShareWindowProps?: ScreenShareWindowPropsType;
diff --git a/ts/windows/calldiagnostic/app.dom.tsx b/ts/windows/calldiagnostic/app.dom.tsx
new file mode 100644
index 0000000000..d564b13dd2
--- /dev/null
+++ b/ts/windows/calldiagnostic/app.dom.tsx
@@ -0,0 +1,46 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React, { StrictMode, useSyncExternalStore } from 'react';
+import { createRoot } from 'react-dom/client';
+import '../sandboxedInit.dom.js';
+import { CallDiagnosticWindow } from '../../components/CallDiagnosticWindow.dom.js';
+import { FunDefaultEnglishEmojiLocalizationProvider } from '../../components/fun/FunEmojiLocalizationProvider.dom.js';
+import { strictAssert } from '../../util/assert.std.js';
+import { AxoProvider } from '../../axo/AxoProvider.dom.js';
+
+const { CallDiagnosticWindowProps } = window.Signal;
+strictAssert(CallDiagnosticWindowProps, 'window values not provided');
+const { subscribe, getSnapshot } = CallDiagnosticWindowProps;
+const { i18n } = window.SignalContext;
+
+function App(): React.JSX.Element | null {
+ const diagnosticData = useSyncExternalStore(subscribe, getSnapshot);
+
+ if (diagnosticData == null) {
+ return null;
+ }
+
+ return (
+ window.SignalContext.executeMenuRole('close')}
+ i18n={i18n}
+ diagnosticData={diagnosticData}
+ />
+ );
+}
+
+const app = document.getElementById('app');
+strictAssert(app != null, 'No #app');
+
+createRoot(app).render(
+
+
+
+
+
+
+
+);
diff --git a/ts/windows/calldiagnostic/preload.preload.ts b/ts/windows/calldiagnostic/preload.preload.ts
new file mode 100644
index 0000000000..d7c9bd8783
--- /dev/null
+++ b/ts/windows/calldiagnostic/preload.preload.ts
@@ -0,0 +1,38 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { contextBridge, ipcRenderer } from 'electron';
+import { MinimalSignalContext } from '../minimalContext.preload.js';
+
+// External store for useSyncExternalStore
+let currentData: string | null = null;
+const listeners = new Set<() => void>();
+
+async function fetchData(): Promise {
+ currentData = await ipcRenderer.invoke('get-call-diagnostic-data');
+ listeners.forEach(listener => listener());
+}
+
+ipcRenderer.on('call-diagnostic-data-updated', () => {
+ void fetchData();
+});
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+function getSnapshot(): string | null {
+ return currentData;
+}
+
+void fetchData();
+
+const Signal = {
+ CallDiagnosticWindowProps: {
+ subscribe,
+ getSnapshot,
+ },
+};
+contextBridge.exposeInMainWorld('Signal', Signal);
+contextBridge.exposeInMainWorld('SignalContext', MinimalSignalContext);
diff --git a/ts/windows/main/phase1-ipc.preload.ts b/ts/windows/main/phase1-ipc.preload.ts
index 2eed5c118f..ab09211a97 100644
--- a/ts/windows/main/phase1-ipc.preload.ts
+++ b/ts/windows/main/phase1-ipc.preload.ts
@@ -133,6 +133,19 @@ const IPC: IPCType = {
log.info('showDebugLog', options);
ipc.send('show-debug-log', options);
},
+ showCallDiagnostic: () => {
+ log.info('showCallDiagnostic');
+ ipc.send('show-call-diagnostic');
+ },
+ closeCallDiagnostic: () => {
+ ipc.send('close-call-diagnostic');
+ },
+ closeDebugLog: () => {
+ ipc.send('close-debug-log');
+ },
+ updateCallDiagnosticData: (data: string) => {
+ ipc.send('update-call-diagnostic-data', data);
+ },
showPermissionsPopup: (forCalling, forCamera) =>
ipc.invoke('show-permissions-popup', forCalling, forCamera),
setMediaPermissions: (value: boolean) =>