mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-20 02:08:57 +00:00
Interact with Windows notifications from a thread
Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com>
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
83
app/WindowsNotificationsWorker.node.ts
Normal file
83
app/WindowsNotificationsWorker.node.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
>;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user