Notification token on Windows

This commit is contained in:
Fedor Indutny 2025-02-03 14:30:19 -08:00 committed by GitHub
parent 74acb3a2dd
commit 22d30ec4eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 142 additions and 122 deletions

View file

@ -2899,14 +2899,13 @@ function handleSignalRoute(route: ParsedSignalRoute) {
value: route.args.encryptedUsername,
});
} else if (route.key === 'showConversation') {
mainWindow.webContents.send('show-conversation-via-notification', {
conversationId: route.args.conversationId,
messageId: route.args.messageId,
storyId: route.args.storyId,
});
mainWindow.webContents.send(
'show-conversation-via-token',
route.args.token
);
} else if (route.key === 'startCallLobby') {
mainWindow.webContents.send('start-call-lobby', {
conversationId: route.args.conversationId,
token: route.args.token,
});
} else if (route.key === 'linkCall') {
mainWindow.webContents.send('start-call-link', {

View file

@ -37,10 +37,8 @@ const Image = (props: { id: string; src: string; 'hint-crop': string }) =>
export function renderWindowsToast({
avatarPath,
body,
conversationId,
heading,
messageId,
storyId,
token,
type,
}: WindowsNotificationData): string {
// Note: with these templates, the first <text> is one line, bolded
@ -58,13 +56,11 @@ export function renderWindowsToast({
// 2) this also maps to the url-handling in main.ts
if (type === NotificationType.Message || type === NotificationType.Reaction) {
launch = showConversationRoute.toAppUrl({
conversationId,
messageId: messageId ?? null,
storyId: storyId ?? null,
token,
});
} else if (type === NotificationType.IncomingGroupCall) {
launch = startCallLobbyRoute.toAppUrl({
conversationId,
token,
});
} else if (type === NotificationType.IncomingCall) {
launch = showWindowRoute.toAppUrl({});

View file

@ -10,6 +10,7 @@ import * as log from './logging/log';
import { explodePromise } from './util/explodePromise';
import { AccessType, ipcInvoke } from './sql/channels';
import { backupsService } from './services/backups';
import { notificationService } from './services/notifications';
import { AttachmentBackupManager } from './jobs/AttachmentBackupManager';
import { migrateAllMessages } from './messages/migrateMessageData';
import { SECOND } from './util/durations';
@ -22,6 +23,7 @@ type ResolveType = (data: unknown) => void;
export type CIType = {
deviceName: string;
getConversationId: (address: string | null) => string | null;
createNotificationToken: (address: string) => string | undefined;
getMessagesBySentAt(
sentAt: number
): Promise<ReadonlyArray<MessageAttributesType>>;
@ -157,6 +159,19 @@ export function getCI({
return window.ConversationController.getConversationId(address);
}
function createNotificationToken(address: string): string | undefined {
const id = window.ConversationController.getConversationId(address);
if (!id) {
return undefined;
}
return notificationService._createToken({
conversationId: id,
messageId: undefined,
storyId: undefined,
});
}
async function openSignalRoute(url: string) {
strictAssert(
isSignalRoute(url),
@ -201,6 +216,7 @@ export function getCI({
return {
deviceName,
getConversationId,
createNotificationToken,
getMessagesBySentAt,
handleEvent,
setProvisioningURL,

View file

@ -4,6 +4,8 @@
import os from 'os';
import { debounce } from 'lodash';
import EventEmitter from 'events';
import { v4 as getGuid } from 'uuid';
import { Sound, SoundType } from '../util/Sound';
import { shouldHideExpiringMessageBody } from '../types/Settings';
import OS from '../util/os/osMain';
@ -36,16 +38,14 @@ type NotificationDataType = Readonly<{
export type NotificationClickData = Readonly<{
conversationId: string;
messageId: string | null;
storyId: string | null;
messageId: string | undefined;
storyId: string | undefined;
}>;
export type WindowsNotificationData = {
avatarPath?: string;
body: string;
conversationId: string;
heading: string;
messageId?: string;
storyId?: string;
token: string;
type: NotificationType;
};
export enum NotificationType {
@ -86,6 +86,7 @@ class NotificationService extends EventEmitter {
#lastNotification: null | Notification = null;
#notificationData: null | NotificationDataType = null;
#tokenData: { token: string; data: NotificationClickData } | undefined;
// Testing indicated that trying to create/destroy notifications too quickly
// resulted in notifications that stuck around forever, requiring the user
@ -175,16 +176,20 @@ class NotificationService extends EventEmitter {
log.info('NotificationService: showing a notification', sentAt);
if (OS.isWindows()) {
const token = this._createToken({
conversationId,
messageId,
storyId,
});
// Note: showing a windows notification clears all previous notifications first
drop(
window.IPC.showWindowsNotification({
avatarPath: iconPath,
body: message,
conversationId,
heading: title,
messageId,
storyId,
type,
token,
})
);
} else {
@ -206,8 +211,8 @@ class NotificationService extends EventEmitter {
window.IPC.showWindow();
window.Events.showConversationViaNotification({
conversationId,
messageId: messageId ?? null,
storyId: storyId ?? null,
messageId,
storyId,
});
} else if (type === NotificationType.IncomingGroupCall) {
window.IPC.showWindow();
@ -304,6 +309,7 @@ class NotificationService extends EventEmitter {
// adding anythhing new; just one notification at a time. Electron forces it, so
// we replicate it with our Windows notifications.
if (!notificationData) {
this.#tokenData = undefined;
drop(window.IPC.clearAllWindowsNotifications());
}
} else if (this.#lastNotification) {
@ -446,6 +452,32 @@ class NotificationService extends EventEmitter {
);
}
/** @internal */
public _createToken(data: NotificationClickData): string {
const token = getGuid();
this.#tokenData = {
token,
data,
};
return token;
}
public resolveToken(token: string): NotificationClickData | undefined {
if (!this.#tokenData) {
log.warn(`NotificationService: no data when looking up ${token}`);
return undefined;
}
if (this.#tokenData.token !== token) {
log.warn(`NotificationService: token mismatch ${token}`);
return undefined;
}
return this.#tokenData.data;
}
public clear(): void {
log.info(
'NotificationService: clearing notification and requesting an update'

View file

@ -55,18 +55,13 @@ describe('routing', function (this: Mocha.Suite) {
const [friend] = contacts;
const page = await app.getWindow();
await page.locator('#LeftPane').waitFor();
const conversationId = await page.evaluate(
serviceId => window.SignalCI?.getConversationId(serviceId),
const token = await page.evaluate(
serviceId => window.SignalCI?.createNotificationToken(serviceId),
friend.toContact().aci
);
strictAssert(
typeof conversationId === 'string',
'conversationId must exist'
);
strictAssert(typeof token === 'string', 'token must be returned');
const conversationUrl = showConversationRoute.toAppUrl({
conversationId,
messageId: null,
storyId: null,
token,
});
await app.openSignalRoute(conversationUrl);
const title = page.locator(

View file

@ -12,12 +12,12 @@ describe('renderWindowsToast', () => {
avatarPath: 'C:/temp/ab/abcd',
body: 'Hi there!',
heading: 'Alice',
conversationId: 'conversation5',
token: 'token',
type: NotificationType.Message,
});
const expected =
'<toast launch="sgnl://show-conversation?conversationId=conversation5" activationType="protocol"><visual><binding template="ToastImageAndText02"><image id="1" src="file:///C:/temp/ab/abcd" hint-crop="circle"></image><text id="1">Alice</text><text id="2">Hi there!</text></binding></visual></toast>';
'<toast launch="sgnl://show-conversation?token=token" activationType="protocol"><visual><binding template="ToastImageAndText02"><image id="1" src="file:///C:/temp/ab/abcd" hint-crop="circle"></image><text id="1">Alice</text><text id="2">Hi there!</text></binding></visual></toast>';
assert.strictEqual(xml, expected);
});
@ -26,12 +26,12 @@ describe('renderWindowsToast', () => {
const xml = renderWindowsToast({
body: 'Hi there!',
heading: 'Alice',
conversationId: 'conversation5',
token: 'token',
type: NotificationType.Message,
});
const expected =
'<toast launch="sgnl://show-conversation?conversationId=conversation5" activationType="protocol"><visual><binding template="ToastText02"><text id="1">Alice</text><text id="2">Hi there!</text></binding></visual></toast>';
'<toast launch="sgnl://show-conversation?token=token" activationType="protocol"><visual><binding template="ToastText02"><text id="1">Alice</text><text id="2">Hi there!</text></binding></visual></toast>';
assert.strictEqual(xml, expected);
});
@ -40,14 +40,12 @@ describe('renderWindowsToast', () => {
const xml = renderWindowsToast({
body: 'Hi there!',
heading: 'Alice',
conversationId: 'conversation5',
messageId: 'message6',
storyId: 'story7',
token: 'token',
type: NotificationType.Message,
});
const expected =
'<toast launch="sgnl://show-conversation?conversationId=conversation5&amp;messageId=message6&amp;storyId=story7" activationType="protocol"><visual><binding template="ToastText02"><text id="1">Alice</text><text id="2">Hi there!</text></binding></visual></toast>';
'<toast launch="sgnl://show-conversation?token=token" activationType="protocol"><visual><binding template="ToastText02"><text id="1">Alice</text><text id="2">Hi there!</text></binding></visual></toast>';
assert.strictEqual(xml, expected);
});
@ -56,7 +54,7 @@ describe('renderWindowsToast', () => {
const xml = renderWindowsToast({
body: 'Hi there!',
heading: 'Alice',
conversationId: 'conversation5',
token: 'token',
type: NotificationType.IncomingCall,
});
@ -70,12 +68,12 @@ describe('renderWindowsToast', () => {
const xml = renderWindowsToast({
body: 'Hi there!',
heading: 'Alice',
conversationId: 'conversation5',
token: 'token',
type: NotificationType.IncomingGroupCall,
});
const expected =
'<toast launch="sgnl://start-call-lobby?conversationId=conversation5" activationType="protocol"><visual><binding template="ToastText02"><text id="1">Alice</text><text id="2">Hi there!</text></binding></visual></toast>';
'<toast launch="sgnl://start-call-lobby?token=token" activationType="protocol"><visual><binding template="ToastText02"><text id="1">Alice</text><text id="2">Hi there!</text></binding></visual></toast>';
assert.strictEqual(xml, expected);
});
@ -84,7 +82,7 @@ describe('renderWindowsToast', () => {
const xml = renderWindowsToast({
body: 'Hi there!',
heading: 'Alice',
conversationId: 'conversation5',
token: 'token',
type: NotificationType.IsPresenting,
});

View file

@ -186,37 +186,23 @@ describe('signalRoutes', () => {
it('showConversation', () => {
const check = createCheck({ isRoute: true, hasWebUrl: false });
const args1 = `conversationId=${foo}`;
const args2 = `conversationId=${foo}&messageId=${foo}`;
const args3 = `conversationId=${foo}&messageId=${foo}&storyId=${foo}`;
const args1 = `token=${foo}`;
const result1: ParsedSignalRoute = {
key: 'showConversation',
args: { conversationId: foo, messageId: null, storyId: null },
};
const result2: ParsedSignalRoute = {
key: 'showConversation',
args: { conversationId: foo, messageId: foo, storyId: null },
};
const result3: ParsedSignalRoute = {
key: 'showConversation',
args: { conversationId: foo, messageId: foo, storyId: foo },
args: { token: foo },
};
check(`sgnl://show-conversation/?${args1}`, result1);
check(`sgnl://show-conversation?${args1}`, result1);
check(`sgnl://show-conversation/?${args2}`, result2);
check(`sgnl://show-conversation?${args2}`, result2);
check(`sgnl://show-conversation/?${args3}`, result3);
check(`sgnl://show-conversation?${args3}`, result3);
});
it('startCallLobby', () => {
const result: ParsedSignalRoute = {
key: 'startCallLobby',
args: { conversationId: foo },
args: { token: foo },
};
const check = createCheck({ isRoute: true, hasWebUrl: false });
check(`sgnl://start-call-lobby/?conversationId=${foo}`, result);
check(`sgnl://start-call-lobby?conversationId=${foo}`, result);
check(`sgnl://start-call-lobby/?token=${foo}`, result);
check(`sgnl://start-call-lobby?token=${foo}`, result);
});
it('showWindow', () => {

View file

@ -35,7 +35,10 @@ import * as Registration from './registration';
import { lookupConversationWithoutServiceId } from './lookupConversationWithoutServiceId';
import * as log from '../logging/log';
import { deleteAllMyStories } from './deleteAllMyStories';
import type { NotificationClickData } from '../services/notifications';
import {
type NotificationClickData,
notificationService,
} from '../services/notifications';
import { StoryViewModeType, StoryViewTargetType } from '../types/Stories';
import { isValidE164 } from './isValidE164';
import { fromWebSafeBase64 } from './webSafeBase64';
@ -121,6 +124,7 @@ export type IPCEventsCallbacksType = {
resetDefaultChatColor: () => void;
setMediaPlaybackDisabled: (playbackDisabled: boolean) => void;
showConversationViaNotification: (data: NotificationClickData) => void;
showConversationViaToken: (token: string) => void;
showConversationViaSignalDotMe: (
kind: string,
value: string
@ -129,6 +133,7 @@ export type IPCEventsCallbacksType = {
showGroupViaLink: (value: string) => Promise<void>;
showReleaseNotes: () => void;
showStickerPack: (packId: string, key: string) => void;
startCallingLobbyViaToken: (token: string) => void;
requestCloseConfirmation: () => Promise<boolean>;
getIsInCall: () => boolean;
shutdown: () => Promise<void>;
@ -575,23 +580,31 @@ export function createIPCEvents(
messageId,
storyId,
}: NotificationClickData) {
if (conversationId) {
if (storyId) {
window.reduxActions.stories.viewStory({
storyId,
storyViewMode: StoryViewModeType.Single,
viewTarget: StoryViewTargetType.Replies,
});
} else {
window.reduxActions.conversations.showConversation({
conversationId,
messageId: messageId ?? undefined,
});
}
} else {
if (!conversationId) {
window.reduxActions.app.openInbox();
} else if (storyId) {
window.reduxActions.stories.viewStory({
storyId,
storyViewMode: StoryViewModeType.Single,
viewTarget: StoryViewTargetType.Replies,
});
} else {
window.reduxActions.conversations.showConversation({
conversationId,
messageId: messageId ?? undefined,
});
}
},
showConversationViaToken(token: string) {
const data = notificationService.resolveToken(token);
if (!data) {
window.reduxActions.app.openInbox();
} else {
window.Events.showConversationViaNotification(data);
}
},
async showConversationViaSignalDotMe(kind: string, value: string) {
if (!Registration.everDone()) {
log.info(
@ -638,6 +651,17 @@ export function createIPCEvents(
showUnknownSgnlLinkModal();
},
startCallingLobbyViaToken(token: string) {
const data = notificationService.resolveToken(token);
if (!data) {
return;
}
window.reduxActions?.calling?.startCallingLobby({
conversationId: data.conversationId,
isVideoCall: true,
});
},
requestCloseConfirmation: async (): Promise<boolean> => {
try {
await new Promise<void>((resolve, reject) => {

View file

@ -201,7 +201,6 @@ function _route<Key extends string, Args extends object>(
}
const paramSchema = z.string().min(1);
const optionalParamSchema = paramSchema.nullish().default(null);
/**
* signal.me by phone number
@ -452,11 +451,9 @@ export const artAddStickersRoute = _route('artAddStickers', {
* @example
* ```ts
* showConversationRoute.toAppUrl({
* conversationId: "123",
* messageId: "abc",
* storyId: "def",
* token: 'abc',
* })
* // URL { "sgnl://show-conversation?conversationId=123&messageId=abc&storyId=def" }
* // URL { "sgnl://show-conversation?token=abc" }
* ```
*/
export const showConversationRoute = _route('showConversation', {
@ -464,28 +461,16 @@ export const showConversationRoute = _route('showConversation', {
_pattern('sgnl:', 'show-conversation', '{/}?', { search: ':params' }),
],
schema: z.object({
conversationId: paramSchema,
messageId: optionalParamSchema,
storyId: optionalParamSchema,
token: paramSchema,
}),
parse(result) {
const params = new URLSearchParams(result.search.groups.params);
return {
conversationId: params.get('conversationId'),
messageId: params.get('messageId'),
storyId: params.get('storyId'),
token: params.get('token'),
};
},
toAppUrl(args) {
const params = new URLSearchParams({
conversationId: args.conversationId,
});
if (args.messageId != null) {
params.set('messageId', args.messageId);
}
if (args.storyId != null) {
params.set('storyId', args.storyId);
}
const params = new URLSearchParams({ token: args.token });
return new URL(`sgnl://show-conversation?${params.toString()}`);
},
});
@ -495,9 +480,9 @@ export const showConversationRoute = _route('showConversation', {
* @example
* ```ts
* startCallLobbyRoute.toAppUrl({
* conversationId: "123",
* token: "123",
* })
* // URL { "sgnl://start-call-lobby?conversationId=123" }
* // URL { "sgnl://start-call-lobby?token=123" }
* ```
*/
export const startCallLobbyRoute = _route('startCallLobby', {
@ -505,18 +490,16 @@ export const startCallLobbyRoute = _route('startCallLobby', {
_pattern('sgnl:', 'start-call-lobby', '{/}?', { search: ':params' }),
],
schema: z.object({
conversationId: paramSchema,
token: paramSchema,
}),
parse(result) {
const params = new URLSearchParams(result.search.groups.params);
return {
conversationId: params.get('conversationId'),
token: params.get('token'),
};
},
toAppUrl(args) {
const params = new URLSearchParams({
conversationId: args.conversationId,
});
const params = new URLSearchParams({ token: args.token });
return new URL(`sgnl://start-call-lobby?${params.toString()}`);
},
});

View file

@ -18,10 +18,7 @@ import * as Errors from '../../types/errors';
import { strictAssert } from '../../util/assert';
import { drop } from '../../util/drop';
import { DataReader } from '../../sql/Client';
import type {
NotificationClickData,
WindowsNotificationData,
} from '../../services/notifications';
import type { WindowsNotificationData } from '../../services/notifications';
import { AggregatedStats } from '../../textsecure/WebsocketResources';
import { UNAUTHENTICATED_CHANNEL_NAME } from '../../textsecure/SocketManager';
@ -340,12 +337,9 @@ ipc.on('show-group-via-link', (_event, info) => {
drop(window.Events.showGroupViaLink?.(info.value));
});
ipc.on('start-call-lobby', (_event, { conversationId }) => {
ipc.on('start-call-lobby', (_event, info) => {
window.IPC.showWindow();
window.reduxActions?.calling?.startCallingLobby({
conversationId,
isVideoCall: true,
});
window.Events.startCallingLobbyViaToken(info.token);
});
ipc.on('start-call-link', (_event, { key }) => {
@ -362,15 +356,12 @@ ipc.on('cancel-presenting', () => {
window.reduxActions?.calling?.cancelPresenting();
});
ipc.on(
'show-conversation-via-notification',
(_event, data: NotificationClickData) => {
const { showConversationViaNotification } = window.Events;
if (showConversationViaNotification) {
void showConversationViaNotification(data);
}
ipc.on('show-conversation-via-token', (_event, token: string) => {
const { showConversationViaToken } = window.Events;
if (showConversationViaToken) {
void showConversationViaToken(token);
}
);
});
ipc.on('show-conversation-via-signal.me', (_event, info) => {
const { kind, value } = info;
strictAssert(typeof kind === 'string', 'Got an invalid kind over IPC');