Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / packages / shared / lib / browser / extension.ts
blob18ac18fc613b7dffcbe3e22bbcbe1a44513b96b2
1 import { c } from 'ttag';
3 import getRandomString from '@proton/utils/getRandomString';
4 import lastItem from '@proton/utils/lastItem';
6 import type { ExtensionForkPayload } from '../authentication/fork/extension';
7 import { APPS, EXTENSIONS } from '../constants';
8 import { browserAPI, isChromiumBased, isSafari } from '../helpers/browser';
10 export type ExtensionForkMessage = { type: 'fork'; payload: ExtensionForkPayload };
11 export type ExtensionAuthenticatedMessage = { type: 'auth-ext' };
12 export type PassInstalledMessage = { type: 'pass-installed' };
13 export type PassOnboardingMessage = { type: 'pass-onboarding' };
15 export type ExtensionMessage =
16     | ExtensionForkMessage
17     | ExtensionAuthenticatedMessage
18     | PassInstalledMessage
19     | PassOnboardingMessage;
21 export type ExtensionApp = keyof typeof EXTENSIONS;
23 /* extension communicating with account should
24  * conform to this message response type */
25 export type ExtensionMessageResponse<P = any> =
26     | { type: 'success'; payload: P }
27     | { type: 'error'; payload?: P; error?: string };
29 export type ExtensionMessageFallbackResponse<P = any> = ExtensionMessageResponse<P> & {
30     token: string;
33 export const sendMessageSupported = () =>
34     (isChromiumBased() || isSafari()) && browserAPI?.runtime?.sendMessage !== undefined;
36 const isValidExtensionResponse = <R = any>(response: any): response is ExtensionMessageResponse<R> =>
37     response?.type === 'success' || response?.type === 'error';
39 type SendMessageResult = { ok: boolean; response: ExtensionMessageResponse };
41 const sendMessage = async (extensionId: string, message: any): Promise<SendMessageResult> => {
42     const onError = (error?: string): SendMessageResult => ({
43         ok: false,
44         response: {
45             type: 'error',
46             error: error ?? c('Warning').t`Please check that the extension is properly installed and enabled`,
47         },
48     });
50     const onResponse = (response: unknown): SendMessageResult => {
51         if (browserAPI.runtime.lastError || response === undefined) {
52             return onError();
53         }
55         if (!isValidExtensionResponse(response)) {
56             return {
57                 ok: true,
58                 response: {
59                     type: 'error',
60                     error: c('Warning').t`Please update the extension to its latest version`,
61                 },
62             };
63         }
65         return { ok: true, response };
66     };
68     return new Promise<unknown>((resolve) => browserAPI.runtime.sendMessage(extensionId, message, resolve))
69         .then(onResponse)
70         .catch(() => onError());
73 /** When dealing with extensions residing in multiple stores, it's essential to handle
74  * all possible extensionIds when dispatching a message. In this scenario, the first
75  * extension to respond takes precedence. The messages are dispatched sequentially
76  * to ensure accurate `lastError` checking. If no extensions respond, last runtime
77  * error will be returned. This broadcasting mechanism only works for extensions and
78  * browsers supporting the `externally_connectable` API. */
79 const broadcastMessage = async (
80     extensionIds: readonly string[],
81     message: ExtensionMessage
82 ): Promise<ExtensionMessageResponse> => {
83     /* Iterate until we have a successful link with one of the extensionIds. If establishing
84      * connection with all of the extensionIds failed, returns the last failure response. */
85     const results = await Promise.all(extensionIds.map((extensionId) => sendMessage(extensionId, message)));
87     return results.find(({ ok }) => ok)?.response ?? lastItem(results)?.response ?? { type: 'error' };
90 /** Extension messaging must account for Chrome & Firefox specifics :
91  * Chrome : we can leverage the `externally_connectable` permissions
92  * Firefox : we have to result to fallback postMessaging via content
93  * script injections */
94 export const sendExtensionMessage = async <R = any>(
95     message: ExtensionMessage,
96     options: { app: ExtensionApp; maxTimeout?: number }
97 ): Promise<ExtensionMessageResponse<R>> => {
98     let timeout: ReturnType<typeof setTimeout> | undefined;
99     const extension = EXTENSIONS[options.app];
100     const token = getRandomString(16);
102     return new Promise<ExtensionMessageResponse<R>>((resolve) => {
103         const onFallbackMessage = ({ data, source }: MessageEvent) => {
104             if (source === window && data?.from !== APPS.PROTONACCOUNT && data?.token === token) {
105                 clearTimeout(timeout);
106                 window.removeEventListener('message', onFallbackMessage);
107                 resolve(isValidExtensionResponse(data) ? data : { type: 'error', error: 'Bad format' });
108             }
109         };
111         timeout = setTimeout(() => {
112             window.removeEventListener('message', onFallbackMessage);
113             resolve({ type: 'error', error: 'Extension timed out' });
114         }, options.maxTimeout ?? 15_000);
116         if (sendMessageSupported()) {
117             return broadcastMessage(extension.IDs, message).then((result) => {
118                 clearTimeout(timeout);
119                 resolve(result);
120             });
121         }
123         /* Firefox extensions should prefer checking the `message.data.app` property.
124          * `message.data.extension` is kept for backward compatibility but will
125          * be deprecated in the future */
126         window.postMessage(
127             {
128                 app: options.app,
129                 extension: extension.IDs[0],
130                 from: APPS.PROTONACCOUNT,
131                 token,
132                 ...message,
133             },
134             '/'
135         );
137         window.addEventListener('message', onFallbackMessage);
138     });