Interact with Windows notifications from a thread

Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com>
This commit is contained in:
automated-signal
2025-12-03 15:03:53 -06:00
committed by GitHub
parent e2fafc3de4
commit 1bd8d18069
7 changed files with 229 additions and 64 deletions

View File

@@ -1,55 +1,78 @@
// Copyright 2017 Signal Messenger, LLC // Copyright 2017 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // 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 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 { 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 OS from '../ts/util/os/osMain.node.js';
import { renderWindowsToast } from './renderWindowsToast.std.js'; import { createLogger } from '../ts/logging/log.std.js';
export { sendDummyKeystroke };
const log = createLogger('WindowsNotifications'); const log = createLogger('WindowsNotifications');
let worker: Worker | undefined;
if (OS.isWindows()) { if (OS.isWindows()) {
const notifier = new Notifier(AUMID); const scriptPath = join(
app.getAppPath(),
const NOTIFICATION_ID = { 'app',
group: 'group', 'WindowsNotificationsWorker.node.js'
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}`
);
}
}
); );
ipc.handle('windows-notifications:clear-all', () => { worker = new Worker(scriptPath, {
try { workerData: {
notifier.remove(NOTIFICATION_ID); AUMID,
} catch (error) { } satisfies WindowsNotificationWorkerDataType,
log.error(
`Windows Notifications: Failed to clear notifications: ${error.stack}`
);
}
}); });
} }
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);
});

View File

@@ -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);
});

View File

@@ -47,6 +47,7 @@ import { strictAssert } from '../ts/util/assert.std.js';
import { drop } from '../ts/util/drop.std.js'; import { drop } from '../ts/util/drop.std.js';
import type { ThemeSettingType } from '../ts/types/StorageUIKeys.std.js'; import type { ThemeSettingType } from '../ts/types/StorageUIKeys.std.js';
import { ThemeType } from '../ts/types/Util.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 * as Errors from '../ts/types/errors.std.js';
import { resolveCanonicalLocales } from '../ts/util/resolveCanonicalLocales.std.js'; import { resolveCanonicalLocales } from '../ts/util/resolveCanonicalLocales.std.js';
import { createLogger } from '../ts/logging/log.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 { getAppErrorIcon } from '../ts/util/getAppErrorIcon.node.js';
import { promptOSAuth } from '../ts/util/os/promptOSAuthMain.main.js'; import { promptOSAuth } from '../ts/util/os/promptOSAuthMain.main.js';
import { appRelaunch } from '../ts/util/relaunch.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 { chmod, realpath, writeFile } = fsExtra;
const { get, pick, isNumber, isBoolean, some, debounce, noop } = lodash; const { get, pick, isNumber, isBoolean, some, debounce, noop } = lodash;
@@ -926,7 +930,10 @@ async function createWindow() {
!windowState.shouldQuit() && !windowState.shouldQuit() &&
(usingTrayIcon || OS.isMacOS()) (usingTrayIcon || OS.isMacOS())
) { ) {
if (usingTrayIcon) { if (!usingTrayIcon) {
return;
}
const shownTrayNotice = ephemeralConfig.get('shown-tray-notice'); const shownTrayNotice = ephemeralConfig.get('shown-tray-notice');
if (shownTrayNotice) { if (shownTrayNotice) {
log.info('close: not showing tray notice'); log.info('close: not showing tray notice');
@@ -936,6 +943,20 @@ async function createWindow() {
ephemeralConfig.set('shown-tray-notice', true); ephemeralConfig.set('shown-tray-notice', true);
log.info('close: showing tray notice'); 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'
),
});
return;
}
const n = new Notification({ const n = new Notification({
title: getResolvedMessagesLocale().i18n( title: getResolvedMessagesLocale().i18n(
'icu:minimizeToTrayNotification--title' 'icu:minimizeToTrayNotification--title'
@@ -946,7 +967,6 @@ async function createWindow() {
}); });
n.show(); n.show();
}
return; return;
} }

View File

@@ -4,9 +4,10 @@
import React from 'react'; import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server'; import { renderToStaticMarkup } from 'react-dom/server';
import type { WindowsNotificationData } from '../ts/services/notifications.preload.js'; import {
type WindowsNotificationData,
import { NotificationType } from '../ts/types/notifications.std.js'; NotificationType,
} from '../ts/types/notifications.std.js';
import { missingCaseError } from '../ts/util/missingCaseError.std.js'; import { missingCaseError } from '../ts/util/missingCaseError.std.js';
import { import {
cancelPresentingRoute, cancelPresentingRoute,
@@ -78,6 +79,8 @@ export function renderWindowsToast({
launch = showWindowRoute.toAppUrl({}); launch = showWindowRoute.toAppUrl({});
} else if (type === NotificationType.IsPresenting) { } else if (type === NotificationType.IsPresenting) {
launch = cancelPresentingRoute.toAppUrl({}); launch = cancelPresentingRoute.toAppUrl({});
} else if (type === NotificationType.MinimizedToTray) {
launch = showWindowRoute.toAppUrl({});
} else { } else {
throw missingCaseError(type); throw missingCaseError(type);
} }

View File

@@ -53,13 +53,6 @@ export type NotificationClickData = Readonly<{
messageId: string | undefined; messageId: string | undefined;
storyId: 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 // 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. // 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(); window.reduxActions?.calling?.cancelPresenting();
} else if (type === NotificationType.IncomingCall) { } else if (type === NotificationType.IncomingCall) {
window.IPC.showWindow(); window.IPC.showWindow();
} else if (type === NotificationType.MinimizedToTray) {
// Unused since the notification is shown by main process instead
window.IPC.showWindow();
} else { } else {
throw missingCaseError(type); throw missingCaseError(type);
} }

View File

@@ -1,10 +1,50 @@
// Copyright 2025 Signal Messenger, LLC // Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import z from 'zod';
export enum NotificationType { export enum NotificationType {
IncomingCall = 'IncomingCall', IncomingCall = 'IncomingCall',
IncomingGroupCall = 'IncomingGroupCall', IncomingGroupCall = 'IncomingGroupCall',
IsPresenting = 'IsPresenting', IsPresenting = 'IsPresenting',
Message = 'Message', Message = 'Message',
Reaction = 'Reaction', 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
>;

View File

@@ -20,7 +20,7 @@ import { strictAssert } from '../../util/assert.std.js';
import { drop } from '../../util/drop.std.js'; import { drop } from '../../util/drop.std.js';
import { explodePromise } from '../../util/explodePromise.std.js'; import { explodePromise } from '../../util/explodePromise.std.js';
import { DataReader } from '../../sql/Client.preload.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 { finish3dsValidation } from '../../services/donations.preload.js';
import { AggregatedStats } from '../../textsecure/WebsocketResources.preload.js'; import { AggregatedStats } from '../../textsecure/WebsocketResources.preload.js';
import { UNAUTHENTICATED_CHANNEL_NAME } from '../../textsecure/SocketManager.preload.js'; import { UNAUTHENTICATED_CHANNEL_NAME } from '../../textsecure/SocketManager.preload.js';