diff --git a/app/WindowsNotifications.main.ts b/app/WindowsNotifications.main.ts index 516e6abe29..57c1583c22 100644 --- a/app/WindowsNotifications.main.ts +++ b/app/WindowsNotifications.main.ts @@ -1,55 +1,78 @@ // Copyright 2017 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { ipcMain as ipc } from 'electron'; +import { ipcMain as ipc, app } from 'electron'; import type { IpcMainInvokeEvent } from 'electron'; +import { join } from 'node:path'; +import { Worker } from 'node:worker_threads'; -import { - Notifier, - sendDummyKeystroke, -} from '@indutny/simple-windows-notifications'; - -import { createLogger } from '../ts/logging/log.std.js'; import { AUMID } from './startup_config.main.js'; -import type { WindowsNotificationData } from '../ts/services/notifications.preload.js'; +import type { + WindowsNotificationWorkerDataType, + WindowsNotificationRequestType, + WindowsNotificationData, +} from '../ts/types/notifications.std.js'; +import { WindowsNotificationDataSchema } from '../ts/types/notifications.std.js'; import OS from '../ts/util/os/osMain.node.js'; -import { renderWindowsToast } from './renderWindowsToast.std.js'; - -export { sendDummyKeystroke }; +import { createLogger } from '../ts/logging/log.std.js'; const log = createLogger('WindowsNotifications'); +let worker: Worker | undefined; + if (OS.isWindows()) { - const notifier = new Notifier(AUMID); - - const NOTIFICATION_ID = { - group: 'group', - tag: 'tag', - }; - - ipc.handle( - 'windows-notifications:show', - (_event: IpcMainInvokeEvent, data: WindowsNotificationData) => { - try { - // First, clear all previous notifications - we want just one - // notification at a time - notifier.remove(NOTIFICATION_ID); - notifier.show(renderWindowsToast(data), NOTIFICATION_ID); - } catch (error) { - log.error( - `Windows Notifications: Failed to show notification: ${error.stack}` - ); - } - } + const scriptPath = join( + app.getAppPath(), + 'app', + 'WindowsNotificationsWorker.node.js' ); - ipc.handle('windows-notifications:clear-all', () => { - try { - notifier.remove(NOTIFICATION_ID); - } catch (error) { - log.error( - `Windows Notifications: Failed to clear notifications: ${error.stack}` - ); - } + worker = new Worker(scriptPath, { + workerData: { + AUMID, + } satisfies WindowsNotificationWorkerDataType, }); } + +export function sendDummyKeystroke(): void { + if (worker == null) { + log.warn('sendDummyKeystroke without worker'); + return; + } + worker.postMessage({ + command: 'sendDummyKeystroke', + } satisfies WindowsNotificationRequestType); +} + +export function show(notificationData: WindowsNotificationData): void { + if (worker == null) { + log.warn('show without worker'); + return; + } + worker.postMessage({ + command: 'show', + notificationData, + } satisfies WindowsNotificationRequestType); +} + +ipc.handle( + 'windows-notifications:show', + (_event: IpcMainInvokeEvent, data: unknown) => { + try { + const notificationData = WindowsNotificationDataSchema.parse(data); + show(notificationData); + } catch (error) { + log.error('failed to parse notification data', error.stack); + } + } +); + +ipc.handle('windows-notifications:clear-all', () => { + if (worker == null) { + log.warn('clear all without worker'); + return; + } + worker.postMessage({ + command: 'clearAll', + } satisfies WindowsNotificationRequestType); +}); diff --git a/app/WindowsNotificationsWorker.node.ts b/app/WindowsNotificationsWorker.node.ts new file mode 100644 index 0000000000..f3840bf8e5 --- /dev/null +++ b/app/WindowsNotificationsWorker.node.ts @@ -0,0 +1,83 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { parentPort, workerData } from 'node:worker_threads'; + +import { + Notifier, + sendDummyKeystroke, +} from '@indutny/simple-windows-notifications'; + +import { createLogger } from '../ts/logging/log.std.js'; +import { + WindowsNotificationWorkerDataSchema, + WindowsNotificationRequestSchema, +} from '../ts/types/notifications.std.js'; +import OS from '../ts/util/os/osMain.node.js'; +import { missingCaseError } from '../ts/util/missingCaseError.std.js'; +import { renderWindowsToast } from './renderWindowsToast.std.js'; + +if (!parentPort) { + throw new Error('Must run as a worker thread'); +} + +if (!OS.isWindows()) { + throw new Error('Runs only on Windows'); +} + +const port = parentPort; + +const log = createLogger('WindowsNotificationsWorker'); + +const { AUMID } = WindowsNotificationWorkerDataSchema.parse(workerData); +const notifier = new Notifier(AUMID); + +const NOTIFICATION_ID = { + group: 'group', + tag: 'tag', +}; + +port.on('message', (message: unknown) => { + const request = WindowsNotificationRequestSchema.parse(message); + + if (request.command === 'show') { + try { + // First, clear all previous notifications - we want just one + // notification at a time + notifier.remove(NOTIFICATION_ID); + notifier.show( + renderWindowsToast(request.notificationData), + NOTIFICATION_ID + ); + } catch (error) { + log.error( + `Windows Notifications: Failed to show notification: ${error.stack}` + ); + } + return; + } + + if (request.command === 'clearAll') { + try { + notifier.remove(NOTIFICATION_ID); + } catch (error) { + log.error( + `Windows Notifications: Failed to clear notifications: ${error.stack}` + ); + } + return; + } + + if (request.command === 'sendDummyKeystroke') { + try { + sendDummyKeystroke(); + } catch (error) { + log.error( + `Windows Notifications: Failed to send dummy keystroke: ${error.stack}` + ); + } + return; + } + + throw missingCaseError(request); +}); diff --git a/app/main.main.ts b/app/main.main.ts index e5e94300a1..69bcf0a137 100644 --- a/app/main.main.ts +++ b/app/main.main.ts @@ -47,6 +47,7 @@ import { strictAssert } from '../ts/util/assert.std.js'; import { drop } from '../ts/util/drop.std.js'; import type { ThemeSettingType } from '../ts/types/StorageUIKeys.std.js'; import { ThemeType } from '../ts/types/Util.std.js'; +import { NotificationType } from '../ts/types/notifications.std.js'; import * as Errors from '../ts/types/errors.std.js'; import { resolveCanonicalLocales } from '../ts/util/resolveCanonicalLocales.std.js'; import { createLogger } from '../ts/logging/log.std.js'; @@ -136,7 +137,10 @@ import { safeParseLoose, safeParseUnknown } from '../ts/util/schemas.std.js'; import { getAppErrorIcon } from '../ts/util/getAppErrorIcon.node.js'; import { promptOSAuth } from '../ts/util/os/promptOSAuthMain.main.js'; import { appRelaunch } from '../ts/util/relaunch.main.js'; -import { sendDummyKeystroke } from './WindowsNotifications.main.js'; +import { + sendDummyKeystroke, + show as showWindowsNotification, +} from './WindowsNotifications.main.js'; const { chmod, realpath, writeFile } = fsExtra; const { get, pick, isNumber, isBoolean, some, debounce, noop } = lodash; @@ -926,27 +930,43 @@ async function createWindow() { !windowState.shouldQuit() && (usingTrayIcon || OS.isMacOS()) ) { - if (usingTrayIcon) { - const shownTrayNotice = ephemeralConfig.get('shown-tray-notice'); - if (shownTrayNotice) { - log.info('close: not showing tray notice'); - return; - } + if (!usingTrayIcon) { + return; + } - ephemeralConfig.set('shown-tray-notice', true); - log.info('close: showing tray notice'); + const shownTrayNotice = ephemeralConfig.get('shown-tray-notice'); + if (shownTrayNotice) { + log.info('close: not showing tray notice'); + return; + } - const n = new Notification({ - title: getResolvedMessagesLocale().i18n( + ephemeralConfig.set('shown-tray-notice', true); + log.info('close: showing tray notice'); + + if (OS.isWindows()) { + showWindowsNotification({ + type: NotificationType.MinimizedToTray, + token: 'unused', + heading: getResolvedMessagesLocale().i18n( 'icu:minimizeToTrayNotification--title' ), body: getResolvedMessagesLocale().i18n( 'icu:minimizeToTrayNotification--body' ), }); - - n.show(); + return; } + + const n = new Notification({ + title: getResolvedMessagesLocale().i18n( + 'icu:minimizeToTrayNotification--title' + ), + body: getResolvedMessagesLocale().i18n( + 'icu:minimizeToTrayNotification--body' + ), + }); + + n.show(); return; } diff --git a/app/renderWindowsToast.std.tsx b/app/renderWindowsToast.std.tsx index ad914897ea..ae3383beb9 100644 --- a/app/renderWindowsToast.std.tsx +++ b/app/renderWindowsToast.std.tsx @@ -4,9 +4,10 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import type { WindowsNotificationData } from '../ts/services/notifications.preload.js'; - -import { NotificationType } from '../ts/types/notifications.std.js'; +import { + type WindowsNotificationData, + NotificationType, +} from '../ts/types/notifications.std.js'; import { missingCaseError } from '../ts/util/missingCaseError.std.js'; import { cancelPresentingRoute, @@ -78,6 +79,8 @@ export function renderWindowsToast({ launch = showWindowRoute.toAppUrl({}); } else if (type === NotificationType.IsPresenting) { launch = cancelPresentingRoute.toAppUrl({}); + } else if (type === NotificationType.MinimizedToTray) { + launch = showWindowRoute.toAppUrl({}); } else { throw missingCaseError(type); } diff --git a/ts/services/notifications.preload.ts b/ts/services/notifications.preload.ts index 1cd47c82a6..03ad822da7 100644 --- a/ts/services/notifications.preload.ts +++ b/ts/services/notifications.preload.ts @@ -53,13 +53,6 @@ export type NotificationClickData = Readonly<{ messageId: string | undefined; storyId: string | undefined; }>; -export type WindowsNotificationData = { - avatarPath?: string; - body: string; - heading: string; - token: string; - type: NotificationType; -}; // The keys and values don't match here. This is because the values correspond to old // setting names. In the future, we may wish to migrate these to match. @@ -230,6 +223,9 @@ class NotificationService extends EventEmitter { window.reduxActions?.calling?.cancelPresenting(); } else if (type === NotificationType.IncomingCall) { window.IPC.showWindow(); + } else if (type === NotificationType.MinimizedToTray) { + // Unused since the notification is shown by main process instead + window.IPC.showWindow(); } else { throw missingCaseError(type); } diff --git a/ts/types/notifications.std.ts b/ts/types/notifications.std.ts index 5e0bf7d865..0cd0268054 100644 --- a/ts/types/notifications.std.ts +++ b/ts/types/notifications.std.ts @@ -1,10 +1,50 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import z from 'zod'; + export enum NotificationType { IncomingCall = 'IncomingCall', IncomingGroupCall = 'IncomingGroupCall', IsPresenting = 'IsPresenting', Message = 'Message', Reaction = 'Reaction', + MinimizedToTray = 'MinimizedToTray', } + +export const WindowsNotificationDataSchema = z.object({ + avatarPath: z.string().optional(), + body: z.string(), + heading: z.string(), + token: z.string(), + type: z.nativeEnum(NotificationType), +}); + +export type WindowsNotificationData = z.infer< + typeof WindowsNotificationDataSchema +>; + +export const WindowsNotificationWorkerDataSchema = z.object({ + AUMID: z.string(), +}); + +export type WindowsNotificationWorkerDataType = z.infer< + typeof WindowsNotificationWorkerDataSchema +>; + +export const WindowsNotificationRequestSchema = z.union([ + z.object({ + command: z.literal('show'), + notificationData: WindowsNotificationDataSchema, + }), + z.object({ + command: z.literal('clearAll'), + }), + z.object({ + command: z.literal('sendDummyKeystroke'), + }), +]); + +export type WindowsNotificationRequestType = z.infer< + typeof WindowsNotificationRequestSchema +>; diff --git a/ts/windows/main/phase1-ipc.preload.ts b/ts/windows/main/phase1-ipc.preload.ts index b38dfd6e7b..15e39010e8 100644 --- a/ts/windows/main/phase1-ipc.preload.ts +++ b/ts/windows/main/phase1-ipc.preload.ts @@ -20,7 +20,7 @@ import { strictAssert } from '../../util/assert.std.js'; import { drop } from '../../util/drop.std.js'; import { explodePromise } from '../../util/explodePromise.std.js'; import { DataReader } from '../../sql/Client.preload.js'; -import type { WindowsNotificationData } from '../../services/notifications.preload.js'; +import type { WindowsNotificationData } from '../../types/notifications.std.js'; import { finish3dsValidation } from '../../services/donations.preload.js'; import { AggregatedStats } from '../../textsecure/WebsocketResources.preload.js'; import { UNAUTHENTICATED_CHANNEL_NAME } from '../../textsecure/SocketManager.preload.js';