Save to PDF for recovery keys

Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com>
This commit is contained in:
automated-signal
2026-09-10 08:27:13 -04:00
committed by GitHub
co-authored by Fedor Indutny
parent 9b875ea854
commit 21e267df43
17 changed files with 357 additions and 25 deletions
+16
View File
@@ -8220,14 +8220,30 @@
"messageformat": "2 keys",
"description": "Description for Signal Login preferences item"
},
"icu:Preferences__SignalAccountKeys__Title": {
"messageformat": "Your Signal Login",
"description": "Title displayed in PDF version of Signal Account Keys preferences page"
},
"icu:Preferences__SignalAccountKeys__Description": {
"messageformat": "Store this in a safe place, your login info is required to recover your account and data. Never share it with anyone.",
"description": "Description displayed in PDF version of Signal Account Keys preferences page"
},
"icu:Preferences__SignalAccountKeys__AccountKey": {
"messageformat": "Account key",
"description": "Label for the Account Key in Signal Account Keys preferences page"
},
"icu:Preferences__SignalAccountKeys__AccountID": {
"messageformat": "Account ID",
"description": "Label for the Account ID in Signal Account Keys preferences page"
},
"icu:Preferences__SignalAccountKeys__RecoveryKey": {
"messageformat": "Recovery key",
"description": "Label for the Recovery Key in Signal Account Keys preferences page"
},
"icu:Preferences__SignalAccountKeys__Save": {
"messageformat": "Save",
"description": "Text of button in Signal Account Keys preferences page"
},
"icu:Preferences__Account__LinkedDevice__label": {
"messageformat": "This is a linked device",
"description": "Label of linked device section of Account settings"
+67 -1
View File
@@ -29,7 +29,11 @@ import {
safeStorage,
protocol as electronProtocol,
} from 'electron';
import type { MenuItemConstructorOptions, Settings } from 'electron';
import type {
MenuItemConstructorOptions,
Settings,
BrowserWindowConstructorOptions,
} from 'electron';
import { z } from 'zod';
import { packageJson } from '../ts/util/packageJson.main.ts';
@@ -113,6 +117,7 @@ import { ChallengeMainHandler } from '../ts/main/challengeMain.main.ts';
import { NativeThemeNotifier } from '../ts/main/NativeThemeNotifier.main.ts';
import { PowerChannel } from '../ts/main/powerChannel.main.ts';
import { SettingsChannel } from '../ts/main/settingsChannel.main.ts';
import { PDFWindowPropsSchema } from '../ts/windows/pdf/types.std.ts';
import '../ts/main/clipboardMain.main.ts';
import { maybeParseUrl, setUrlSearchParams } from '../ts/util/url.std.ts';
import { getHeicConverter } from '../ts/workers/heicConverterMain.main.ts';
@@ -1138,6 +1143,67 @@ ipc.on('title-bar-double-click', () => {
}
});
ipc.handle('pdf:generate', async (_event, data) => {
const props = PDFWindowPropsSchema.parse(data);
const options: BrowserWindowConstructorOptions = {
backgroundColor: '#ffffff',
show: false,
width: 612,
height: 792,
maximizable: false,
minimizable: false,
resizable: false,
webPreferences: {
...defaultWebPrefs,
nodeIntegration: false,
nodeIntegrationInWorker: false,
sandbox: true,
contextIsolation: true,
preload: join(rootDir, 'bundles', 'preload', 'pdf.js'),
},
};
const pdfWindow = new BrowserWindow(options);
pdfWindow.webContents.ipc.on('pdf:getProps', async event => {
// oxlint-disable-next-line no-param-reassign
event.returnValue = props;
});
await handleCommonWindowEvents(pdfWindow);
const { promise, resolve, reject } =
Promise.withResolvers<Uint8Array<ArrayBuffer>>();
try {
pdfWindow.webContents.on('did-finish-load', async () => {
try {
const pdf = await pdfWindow.webContents.printToPDF({
margins: {
top: 0,
bottom: 0,
left: 0,
right: 0,
},
preferCSSPageSize: true,
printBackground: true,
});
pdfWindow.close();
resolve(pdf as Uint8Array<ArrayBuffer>);
} catch (error) {
reject(error);
}
});
await safeLoadURL(pdfWindow, prepareFileUrl([rootDir, 'pdf.html']));
} catch (error) {
reject(error);
}
return promise;
});
ipc.on('set-is-call-active', (_event, isCallActive) => {
preventDisplaySleepService.setEnabled(isCallActive);
});
+2
View File
@@ -31,6 +31,7 @@ const config = {
'ts/windows/calldiagnostic/preload.preload.ts!',
'ts/windows/debuglog/preload.preload.ts!',
'ts/windows/loading/preload.preload.ts!',
'ts/windows/pdf/preload.preload.ts!',
'ts/windows/permissions/preload.preload.ts!',
'ts/windows/screenShare/preload.preload.ts!',
'ts/windows/sticker-creator/preload.preload.ts!',
@@ -39,6 +40,7 @@ const config = {
'ts/windows/calldiagnostic/app.dom.tsx!',
'ts/windows/debuglog/app.dom.tsx!',
'ts/windows/loading/start.dom.ts!',
'ts/windows/pdf/app.dom.tsx!',
'ts/windows/permissions/app.dom.tsx!',
'ts/windows/screenShare/app.dom.tsx!',
// Others
+2
View File
@@ -45,6 +45,7 @@ const sandboxPreload = {
'preload/about': 'ts/windows/about/preload.preload.ts',
'preload/calldiagnostic': 'ts/windows/calldiagnostic/preload.preload.ts',
'preload/debuglog': 'ts/windows/debuglog/preload.preload.ts',
'preload/pdf': 'ts/windows/pdf/preload.preload.ts',
'preload/permissions': 'ts/windows/permissions/preload.preload.ts',
'preload/screenShare': 'ts/windows/screenShare/preload.preload.ts',
'preload/sticker-creator': 'ts/windows/sticker-creator/preload.preload.ts',
@@ -55,6 +56,7 @@ const sandboxDOM = {
'dom/calldiagnostic': 'ts/windows/calldiagnostic/app.dom.tsx',
'dom/debuglog': 'ts/windows/debuglog/app.dom.tsx',
'dom/loading': 'ts/windows/loading/start.dom.ts',
'dom/pdf': 'ts/windows/pdf/app.dom.tsx',
'dom/permissions': 'ts/windows/permissions/app.dom.tsx',
'dom/screenShare': 'ts/windows/screenShare/app.dom.tsx',
};
+1
View File
@@ -43,6 +43,7 @@
sans-serif;
/* Note: This font-family is checked for in matchMonospace, to support paste scenarios */
--font-mono:
'MonoSpecial',
'SF Mono',
SFMono-Regular,
ui-monospace,
+5 -1
View File
@@ -8,8 +8,12 @@
@apply h-screen scheme-light-dark select-none;
}
@page {
size: 612px 792px;
}
body {
@apply h-screen w-screen bg-surface-primary font-sans type-body-large text-primary;
@apply h-screen w-screen bg-surface-primary print:bg-white font-sans type-body-large text-primary;
}
a[href] {
+30
View File
@@ -0,0 +1,30 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { JSX } from 'react';
import type { LocalizerType } from '../types/Util.std.ts';
import type { PDFWindowPropsType } from '../windows/pdf/types.std.ts';
import { missingCaseError } from '../util/missingCaseError.std.ts';
import { SignalAccountKeys } from './preferences/SignalAccountKeys.dom.tsx';
export type PDFProps = Readonly<{
i18n: LocalizerType;
}> &
PDFWindowPropsType;
export function Pdf(props: PDFProps): JSX.Element {
const { i18n } = props;
if (props.view === 'account-keys') {
return (
<SignalAccountKeys
i18n={i18n}
serviceId={props.serviceId}
backupKey={props.backupKey}
saveAccountKeysPDF={undefined}
/>
);
}
throw missingCaseError(props.view);
}
@@ -718,6 +718,9 @@ export default {
setGroupSvcModeForScreenshare: action('setGroupSvcModeForScreenshare'),
sfuUrl: 'https://sfu.voip.signal.org',
setSfuUrl: action('setSfuUrl'),
saveAccountKeysPDF: async () => {
await sleep(1000);
},
forceKeyTransparencyCheck: async () => {
await sleep(1000);
},
+3
View File
@@ -408,6 +408,7 @@ type PropsFunctionType = {
setGroupSvcMode: (value: string | undefined) => void;
setGroupSvcModeForScreenshare: (value: string | undefined) => void;
setSfuUrl: (value: string | undefined) => void;
saveAccountKeysPDF: () => Promise<void>;
forceKeyTransparencyCheck: () => Promise<void>;
keyTransparencySelfHealth: StorageAccessType['keyTransparencySelfHealth'];
@@ -665,6 +666,7 @@ export function Preferences({
setGroupSvcModeForScreenshare,
setSfuUrl,
sfuUrl,
saveAccountKeysPDF,
forceKeyTransparencyCheck,
keyTransparencySelfHealth,
weArePrimaryDevice,
@@ -962,6 +964,7 @@ export function Preferences({
i18n={i18n}
serviceId={me.serviceId}
backupKey={backupKey}
saveAccountKeysPDF={saveAccountKeysPDF}
/>
</div>
);
@@ -0,0 +1,33 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { JSX } from 'react';
import type { Meta } from '@storybook/react';
import { SignalAccountKeys } from './SignalAccountKeys.dom.tsx';
const { i18n } = window.SignalContext;
export default {
title: 'Components/Preferences/SignalAccountKeys',
} satisfies Meta;
export function Default(): JSX.Element {
return (
<SignalAccountKeys
i18n={i18n}
serviceId="f83a157c-aed6-41ff-9d84-637c7af95f02"
backupKey="22rsumdlqxa8r7lsghj12ah6ae2anhk4vjvccor4pc75mee49i9t9dcwmvso4wyn"
saveAccountKeysPDF={() => Promise.resolve()}
/>
);
}
export function Print(): JSX.Element {
return (
<SignalAccountKeys
i18n={i18n}
serviceId="f83a157c-aed6-41ff-9d84-637c7af95f02"
backupKey="22rsumdlqxa8r7lsghj12ah6ae2anhk4vjvccor4pc75mee49i9t9dcwmvso4wyn"
saveAccountKeysPDF={undefined}
/>
);
}
@@ -2,7 +2,9 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import { useState, useCallback, useMemo } from 'react';
import { tw } from '../../axo/tw.dom.tsx';
import { AxoButton } from '../../axo/AxoButton.dom.tsx';
import type { LocalizerType } from '../../types/Util.std.ts';
import { formatBackupKeyForDisplay } from '../../util/formatBackupKeyForDisplay.std.ts';
@@ -10,56 +12,134 @@ type SignalAccountKeysPropsType = Readonly<{
i18n: LocalizerType;
serviceId: string | undefined;
backupKey: string | undefined;
saveAccountKeysPDF: (() => Promise<void>) | undefined;
}>;
export function SignalAccountKeys({
i18n,
serviceId,
backupKey,
saveAccountKeysPDF: doSaveAccountKeysPDF,
}: SignalAccountKeysPropsType): ReactNode {
const backupKeyForDisplay =
backupKey &&
formatBackupKeyForDisplay(backupKey, {
const [isSaving, setIsSaving] = useState(false);
const backupKeyForDisplay = useMemo(() => {
if (!backupKey) {
return undefined;
}
const display = formatBackupKeyForDisplay(backupKey, {
convertAmbiguousChars: true,
});
const print = display.split(/\s+/g).map((part, i) => {
// oxlint-disable-next-line react/no-array-index-key
return <span key={`backup-part-${i}`}>{part}</span>;
});
return (
<>
<span className={tw('print:hidden')}>{display}</span>
<span
className={tw(
'hidden grid-cols-4 gap-x-13 print:grid print:leading-6'
)}
>
{print}
</span>
</>
);
}, [backupKey]);
const saveAccountKeysPDF = useCallback(async () => {
if (!doSaveAccountKeysPDF) {
return;
}
setIsSaving(true);
try {
await doSaveAccountKeysPDF();
} catch {
// Ignore
// oxlint-disable-next-line react/todo
} finally {
setIsSaving(false);
}
}, [doSaveAccountKeysPDF]);
return (
<>
<img
alt={i18n('icu:Preferences__SignalAccount__label')}
className={tw('mx-auto mb-6.5')}
src="images/signal-login.svg"
width="98"
height="56"
/>
<div
className={tw(
'flex flex-col gap-4 print:mx-auto print:max-w-97.5 print:gap-8 print:pbs-30'
)}
>
<div className={tw('text-center')}>
<img
alt={i18n('icu:Preferences__SignalAccount__label')}
className={tw('mx-auto mb-2.5 print:mb-4')}
src="images/signal-login.svg"
width="98"
height="56"
/>
<div className={tw('hidden text-center print:block')}>
<h1 className={tw('mb-2 type-title-medium')}>
{i18n('icu:Preferences__SignalAccountKeys__Title')}
</h1>
{/* oxlint-disable-next-line better-tailwindcss/no-restricted-classes */}
<div className={tw('type-body-large text-[#4D4D4D]')}>
{i18n('icu:Preferences__SignalAccountKeys__Description')}
</div>
</div>
</div>
{serviceId && (
<KeySection
label={i18n('icu:Preferences__SignalAccountKeys__AccountKey')}
value={serviceId}
/>
label={i18n('icu:Preferences__SignalAccountKeys__AccountID')}
>
{serviceId.toUpperCase()}
</KeySection>
)}
<div className={tw('mb-4')} />
{backupKeyForDisplay && (
<KeySection
label={i18n('icu:Preferences__SignalAccountKeys__RecoveryKey')}
value={backupKeyForDisplay}
/>
>
{backupKeyForDisplay}
</KeySection>
)}
</>
{doSaveAccountKeysPDF && (
<div className={tw('mx-auto')}>
<AxoButton.Root
symbol="download"
variant="subtle-secondary"
pending={isSaving}
size="md"
onClick={saveAccountKeysPDF}
>
{i18n('icu:Preferences__SignalAccountKeys__Save')}
</AxoButton.Root>
</div>
)}
</div>
);
}
function KeySection({
label,
value,
children,
}: {
label: string;
value: string;
children: ReactNode;
}): ReactNode {
return (
<section>
<h2 className={tw('px-4 py-2 type-body-medium font-semibold')}>
<h2
className={tw(
'px-4 py-2 type-body-medium font-semibold',
'print:px-6 print:py-3 print:type-title-small'
)}
>
{label}
</h2>
<div
@@ -69,10 +149,22 @@ function KeySection({
'curved-2xl bg-surface-card p-1 shadow-elevation-0',
'forced-colors:bg-[Canvas] forced-colors:text-[CanvasText]',
'forced-colors:border forced-colors:border-[ButtonBorder]',
'w-full px-4 py-2.5'
'w-full px-4 py-2.5',
'print:px-5 print:py-4',
// oxlint-disable-next-line better-tailwindcss/no-restricted-classes
'print:bg-[#F4F4F5] print:shadow-none',
'print:flex print:justify-center'
)}
>
<div className={tw('w-full max-w-[40ch] font-mono')}>{value}</div>
<div
className={tw(
'w-full max-w-[40ch] font-mono tracking-[1.12px]',
'print:max-w-fit print:type-body-large print:tracking-[1.12px]'
)}
>
{children}
</div>
</div>
</section>
);
+13
View File
@@ -996,6 +996,18 @@ export function SmartPreferences(): JSX.Element | null {
drop(itemStorage.put('sfuUrl', value));
}, []);
const saveAccountKeysPDF = useCallback(async () => {
const data = await window.Events.generatePDF({
view: 'account-keys',
serviceId: me.serviceId,
backupKey,
});
await saveAttachmentToDisk({
name: 'Signal_AccountKeys.pdf',
data,
});
}, [me.serviceId, backupKey]);
if (currentLocation.tab !== NavTab.Settings) {
return null;
}
@@ -1246,6 +1258,7 @@ export function SmartPreferences(): JSX.Element | null {
sfuUrl={items.sfuUrl}
setSfuUrl={setSfuUrl}
forceKeyTransparencyCheck={forceKeyTransparencyCheck}
saveAccountKeysPDF={saveAccountKeysPDF}
keyTransparencySelfHealth={items.keyTransparencySelfHealth}
weArePrimaryDevice={weArePrimaryDevice}
/>
+5
View File
@@ -35,6 +35,7 @@ import type {
} from './preload.preload.ts';
import { SystemTraySetting } from '../types/SystemTraySetting.std.ts';
import { putStickers } from '../textsecure/WebAPI.preload.ts';
import type { PDFWindowPropsType } from '../windows/pdf/types.std.ts';
import OS from './os/osPreload.preload.ts';
const { noop } = lodash;
@@ -60,6 +61,7 @@ export type IPCEventsCallbacksType = {
mediaType: 'screen' | 'microphone' | 'camera'
) => Promise<ReturnType<SystemPreferences['getMediaAccessStatus']>>;
installStickerPack: (packId: string, key: string) => Promise<void>;
generatePDF: (args: PDFWindowPropsType) => Promise<Uint8Array<ArrayBuffer>>;
requestCloseConfirmation: () => Promise<boolean>;
setMediaPlaybackDisabled: (playbackDisabled: boolean) => void;
showConversationViaNotification: (data: NotificationClickData) => void;
@@ -274,6 +276,9 @@ export function createIPCEvents(
actionSource: 'ui',
});
},
generatePDF: (args: PDFWindowPropsType) => {
return ipcRenderer.invoke('pdf:generate', args);
},
requestCloseConfirmation: async (): Promise<boolean> => {
try {
await new Promise<void>((resolve, reject) => {
+2
View File
@@ -26,6 +26,7 @@ import type { WindowsNotificationData } from './services/notifications.preload.t
import type { QueryStatsOptions } from './sql/main.main.ts';
import type { SocketStatuses } from './textsecure/SocketManager.preload.ts';
import type { SignalClipboardType } from './windows/clipboard.preload.ts';
import type { PDFWindowPropsType } from './windows/pdf/types.std.ts';
export type IPCType = {
addSetupMenuItems: () => void;
@@ -124,6 +125,7 @@ type SettingsWindowPropsType = {
export type SignalCoreType = {
AboutWindowProps?: AboutWindowPropsType;
PDFWindowProps?: PDFWindowPropsType;
CallDiagnosticWindowProps?: CallDiagnosticWindowPropsType;
DebugLogWindowProps?: DebugLogWindowPropsType;
PermissionsWindowProps?: PermissionsWindowPropsType;
+32
View File
@@ -0,0 +1,32 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { createRoot } from 'react-dom/client';
import '../sandboxedInit.dom.ts';
import { Pdf } from '../../components/Pdf.dom.tsx';
import { strictAssert } from '../../util/assert.std.ts';
import { AppProvider } from '../AppProvider.dom.tsx';
import { AxoTheme } from '../../axo/AxoTheme.dom.tsx';
import { tw } from '../../axo/tw.dom.tsx';
import { setDocumentLocale } from '../../util/setDocumentLocale.dom.ts';
const { PDFWindowProps } = window.Signal;
const { i18n } = window.SignalContext;
setDocumentLocale(document);
strictAssert(PDFWindowProps, 'window values not provided');
const app = document.getElementById('app');
strictAssert(app != null, 'No #app');
createRoot(app).render(
<AppProvider>
<AxoTheme.Override theme="force-light">
<div className={tw('text-primary')}>
<Pdf i18n={i18n} {...PDFWindowProps} />
</div>
</AxoTheme.Override>
</AppProvider>
);
+14
View File
@@ -0,0 +1,14 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { contextBridge, ipcRenderer } from 'electron';
import { MinimalSignalContext } from '../minimalContext.preload.ts';
import { PDFWindowPropsSchema } from './types.std.ts';
const Signal = {
PDFWindowProps: PDFWindowPropsSchema.parse(
ipcRenderer.sendSync('pdf:getProps')
),
};
contextBridge.exposeInMainWorld('Signal', Signal);
contextBridge.exposeInMainWorld('SignalContext', MinimalSignalContext);
+14
View File
@@ -0,0 +1,14 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as z from 'zod/mini';
const PDFAccountKeysPropsSchema = z.object({
view: z.literal('account-keys'),
serviceId: z.union([z.string(), z.undefined()]),
backupKey: z.union([z.string(), z.undefined()]),
});
export const PDFWindowPropsSchema = z.union([PDFAccountKeysPropsSchema]);
export type PDFWindowPropsType = z.infer<typeof PDFWindowPropsSchema>;