diff --git a/_locales/en/messages.json b/_locales/en/messages.json index f8968355ab..2915fdb683 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" diff --git a/app/main.main.ts b/app/main.main.ts index 41c2dad46c..107d017e93 100644 --- a/app/main.main.ts +++ b/app/main.main.ts @@ -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>(); + + 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); + } 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); }); diff --git a/knip.js b/knip.js index ad58af304d..bc193e4fd0 100644 --- a/knip.js +++ b/knip.js @@ -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 diff --git a/rolldown.config.ts b/rolldown.config.ts index 2f1caeb9c6..c709de2813 100644 --- a/rolldown.config.ts +++ b/rolldown.config.ts @@ -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', }; diff --git a/ts/axo/_tailwind-theme/fonts.css b/ts/axo/_tailwind-theme/fonts.css index 9c16f7b836..6b6e4704fd 100644 --- a/ts/axo/_tailwind-theme/fonts.css +++ b/ts/axo/_tailwind-theme/fonts.css @@ -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, diff --git a/ts/axo/_tailwind-theme/global.css b/ts/axo/_tailwind-theme/global.css index cd65eead64..35badd2cd3 100644 --- a/ts/axo/_tailwind-theme/global.css +++ b/ts/axo/_tailwind-theme/global.css @@ -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] { diff --git a/ts/components/Pdf.dom.tsx b/ts/components/Pdf.dom.tsx new file mode 100644 index 0000000000..217ada223b --- /dev/null +++ b/ts/components/Pdf.dom.tsx @@ -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 ( + + ); + } + throw missingCaseError(props.view); +} diff --git a/ts/components/Preferences.dom.stories.tsx b/ts/components/Preferences.dom.stories.tsx index 94cbd81c9d..8b6bf3301d 100644 --- a/ts/components/Preferences.dom.stories.tsx +++ b/ts/components/Preferences.dom.stories.tsx @@ -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); }, diff --git a/ts/components/Preferences.dom.tsx b/ts/components/Preferences.dom.tsx index 943c34158b..217f494018 100644 --- a/ts/components/Preferences.dom.tsx +++ b/ts/components/Preferences.dom.tsx @@ -408,6 +408,7 @@ type PropsFunctionType = { setGroupSvcMode: (value: string | undefined) => void; setGroupSvcModeForScreenshare: (value: string | undefined) => void; setSfuUrl: (value: string | undefined) => void; + saveAccountKeysPDF: () => Promise; forceKeyTransparencyCheck: () => Promise; 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} /> ); diff --git a/ts/components/preferences/SignalAccountKeys.dom.stories.tsx b/ts/components/preferences/SignalAccountKeys.dom.stories.tsx new file mode 100644 index 0000000000..43eccfe162 --- /dev/null +++ b/ts/components/preferences/SignalAccountKeys.dom.stories.tsx @@ -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 ( + Promise.resolve()} + /> + ); +} + +export function Print(): JSX.Element { + return ( + + ); +} diff --git a/ts/components/preferences/SignalAccountKeys.dom.tsx b/ts/components/preferences/SignalAccountKeys.dom.tsx index 3fffd6e93f..d1cd26be6e 100644 --- a/ts/components/preferences/SignalAccountKeys.dom.tsx +++ b/ts/components/preferences/SignalAccountKeys.dom.tsx @@ -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) | 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 {part}; + }); + + return ( + <> + {display} + + {print} + + + ); + }, [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 ( - <> - {i18n('icu:Preferences__SignalAccount__label')} +
+
+ {i18n('icu:Preferences__SignalAccount__label')} + +
+

+ {i18n('icu:Preferences__SignalAccountKeys__Title')} +

+ {/* oxlint-disable-next-line better-tailwindcss/no-restricted-classes */} +
+ {i18n('icu:Preferences__SignalAccountKeys__Description')} +
+
+
{serviceId && ( + label={i18n('icu:Preferences__SignalAccountKeys__AccountID')} + > + {serviceId.toUpperCase()} + )} -
{backupKeyForDisplay && ( + > + {backupKeyForDisplay} + )} - + + {doSaveAccountKeysPDF && ( +
+ + {i18n('icu:Preferences__SignalAccountKeys__Save')} + +
+ )} +
); } function KeySection({ label, - value, + children, }: { label: string; - value: string; + children: ReactNode; }): ReactNode { return (
-

+

{label}

-
{value}
+
+ {children} +
); diff --git a/ts/state/smart/Preferences.preload.tsx b/ts/state/smart/Preferences.preload.tsx index 9fc5ef9389..f5f799424b 100644 --- a/ts/state/smart/Preferences.preload.tsx +++ b/ts/state/smart/Preferences.preload.tsx @@ -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} /> diff --git a/ts/util/createIPCEvents.preload.ts b/ts/util/createIPCEvents.preload.ts index 723eb03b83..84d57ac3af 100644 --- a/ts/util/createIPCEvents.preload.ts +++ b/ts/util/createIPCEvents.preload.ts @@ -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>; installStickerPack: (packId: string, key: string) => Promise; + generatePDF: (args: PDFWindowPropsType) => Promise>; requestCloseConfirmation: () => Promise; 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 => { try { await new Promise((resolve, reject) => { diff --git a/ts/window.d.ts b/ts/window.d.ts index d590027377..76fff1011d 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -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; diff --git a/ts/windows/pdf/app.dom.tsx b/ts/windows/pdf/app.dom.tsx new file mode 100644 index 0000000000..72fe80c89d --- /dev/null +++ b/ts/windows/pdf/app.dom.tsx @@ -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( + + +
+ +
+
+
+); diff --git a/ts/windows/pdf/preload.preload.ts b/ts/windows/pdf/preload.preload.ts new file mode 100644 index 0000000000..f8d561c4f2 --- /dev/null +++ b/ts/windows/pdf/preload.preload.ts @@ -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); diff --git a/ts/windows/pdf/types.std.ts b/ts/windows/pdf/types.std.ts new file mode 100644 index 0000000000..47753e114a --- /dev/null +++ b/ts/windows/pdf/types.std.ts @@ -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;