diff --git a/extension/chrome/elements/attachment.ts b/extension/chrome/elements/attachment.ts index b1891a43a82..6cfea1bfa03 100644 --- a/extension/chrome/elements/attachment.ts +++ b/extension/chrome/elements/attachment.ts @@ -24,9 +24,8 @@ import { AttachmentWarnings } from './shared/attachment_warnings.js'; export class AttachmentDownloadView extends View { public fesUrl?: string; - public confirmationResultResolver?: (confirm: boolean) => void; + public readonly parentTabId: string; protected readonly acctEmail: string; - protected readonly parentTabId: string; protected readonly frameId: string; protected readonly origNameBasedOnFilename: string; protected readonly isEncrypted: boolean; @@ -85,8 +84,8 @@ export class AttachmentDownloadView extends View { this.gmail = new Gmail(this.acctEmail); } - public getParentTabId = () => { - return this.parentTabId; + public getDest = () => { + return this.tabId; }; public render = async () => { @@ -151,7 +150,7 @@ export class AttachmentDownloadView extends View { this.ppChangedPromiseCancellation = { cancel: false }; // set to a new, not yet used object } }); - BrowserMsg.addListener('confirmation_result', CommonHandlers.createConfirmationResultHandler(this)); + BrowserMsg.addListener('confirmation_result', CommonHandlers.createAsyncResultHandler()); BrowserMsg.listen(this.tabId); }; diff --git a/extension/chrome/elements/pgp_block.ts b/extension/chrome/elements/pgp_block.ts index 79ed3dc844a..b72ca934bb5 100644 --- a/extension/chrome/elements/pgp_block.ts +++ b/extension/chrome/elements/pgp_block.ts @@ -4,7 +4,7 @@ import { Url } from '../../js/common/core/common.js'; import { Assert } from '../../js/common/assert.js'; -import { RenderMessage, RenderMessageWithFrameId } from '../../js/common/render-message.js'; +import { RenderMessage } from '../../js/common/render-message.js'; import { Attachment } from '../../js/common/core/attachment.js'; import { Xss } from '../../js/common/platform/xss.js'; import { PgpBlockViewAttachmentsModule } from './pgp_block_modules/pgp-block-attachmens-module.js'; @@ -12,9 +12,9 @@ import { PgpBlockViewErrorModule } from './pgp_block_modules/pgp-block-error-mod import { PgpBlockViewPrintModule } from './pgp_block_modules/pgp-block-print-module.js'; import { PgpBlockViewQuoteModule } from './pgp_block_modules/pgp-block-quote-module.js'; import { PgpBlockViewRenderModule } from './pgp_block_modules/pgp-block-render-module.js'; -import { Ui } from '../../js/common/browser/ui.js'; +import { CommonHandlers, Ui } from '../../js/common/browser/ui.js'; import { View } from '../../js/common/view.js'; -import { Bm } from '../../js/common/browser/browser-msg.js'; +import { BrowserMsg } from '../../js/common/browser/browser-msg.js'; export class PgpBlockView extends View { public readonly acctEmail: string; // needed for attachment decryption, probably should be refactored out @@ -27,6 +27,7 @@ export class PgpBlockView extends View { public readonly errorModule: PgpBlockViewErrorModule; public readonly renderModule: PgpBlockViewRenderModule; public readonly printModule = new PgpBlockViewPrintModule(); + private tabId!: string; public constructor() { super(); @@ -41,21 +42,14 @@ export class PgpBlockView extends View { this.quoteModule = new PgpBlockViewQuoteModule(this); this.errorModule = new PgpBlockViewErrorModule(this); this.renderModule = new PgpBlockViewRenderModule(this); - chrome.runtime.onMessage.addListener((message: Bm.Raw) => { - if (message.name === 'pgp_block_render') { - const msg = message.data.bm as RenderMessageWithFrameId; - if (msg.frameId === this.frameId) { - this.processMessage(msg); - return true; - } - } - return false; - }); - window.addEventListener('load', () => window.parent.postMessage({ readyToReceive: this.frameId }, '*')); } + public getDest = () => { + return this.tabId; + }; + public render = async () => { - // + this.tabId = await BrowserMsg.requiredTabId(); }; public setHandlers = () => { @@ -63,6 +57,12 @@ export class PgpBlockView extends View { 'click', this.setHandler(() => this.printModule.printPGPBlock()) ); + BrowserMsg.addListener('pgp_block_render', async (msg: RenderMessage) => { + this.processMessage(msg); + }); + BrowserMsg.addListener('confirmation_result', CommonHandlers.createAsyncResultHandler()); + BrowserMsg.listen(this.tabId); + BrowserMsg.send.pgpBlockReady({ frameId: this.frameId, messageSender: this.tabId }); }; private processMessage = (data: RenderMessage) => { diff --git a/extension/chrome/elements/pgp_block_modules/pgp-block-attachmens-module.ts b/extension/chrome/elements/pgp_block_modules/pgp-block-attachmens-module.ts index 20ddc65c542..656073ee207 100644 --- a/extension/chrome/elements/pgp_block_modules/pgp-block-attachmens-module.ts +++ b/extension/chrome/elements/pgp_block_modules/pgp-block-attachmens-module.ts @@ -7,7 +7,7 @@ import { Attachment } from '../../../js/common/core/attachment.js'; import { Browser } from '../../../js/common/browser/browser.js'; import { BrowserMsg } from '../../../js/common/browser/browser-msg.js'; import { PgpBlockView } from '../pgp_block.js'; -import { CommonHandlers, Ui } from '../../../js/common/browser/ui.js'; +import { Ui } from '../../../js/common/browser/ui.js'; import { Xss } from '../../../js/common/platform/xss.js'; import { KeyStore } from '../../../js/common/platform/store/key-store.js'; import { XssSafeFactory } from '../../../js/common/xss-safe-factory.js'; @@ -21,10 +21,6 @@ export class PgpBlockViewAttachmentsModule { public constructor(private view: PgpBlockView) {} - public getParentTabId = () => { - return this.view.parentTabId; - }; - public renderInnerAttachments = (attachments: Attachment[], isEncrypted: boolean) => { Xss.sanitizeAppend('#pgp_block', '
'); this.includedAttachments = attachments; @@ -78,8 +74,6 @@ export class PgpBlockViewAttachmentsModule { } }) ); - BrowserMsg.addListener('confirmation_result', CommonHandlers.createConfirmationResultHandler(this)); - BrowserMsg.listen(this.view.parentTabId); }; private previewAttachmentClickedHandler = async (attachment: Attachment) => { @@ -102,7 +96,7 @@ export class PgpBlockViewAttachmentsModule { type: encrypted.type, data: decrypted.content, }); - if (await AttachmentWarnings.confirmSaveToDownloadsIfNeeded(attachment, this)) { + if (await AttachmentWarnings.confirmSaveToDownloadsIfNeeded(attachment, this.view)) { Browser.saveToDownloads(attachment); } } else { diff --git a/extension/chrome/elements/pgp_block_modules/pgp-block-render-module.ts b/extension/chrome/elements/pgp_block_modules/pgp-block-render-module.ts index 200dd957442..ed541b52fa2 100644 --- a/extension/chrome/elements/pgp_block_modules/pgp-block-render-module.ts +++ b/extension/chrome/elements/pgp_block_modules/pgp-block-render-module.ts @@ -145,7 +145,7 @@ export class PgpBlockViewRenderModule { public renderSignatureOffline = () => { this.renderSignatureStatus('error verifying signature: offline, click to retry').on( 'click', - this.view.setHandler(() => window.parent.postMessage({ retry: this.view.frameId }, '*')) + this.view.setHandler(() => BrowserMsg.send.pgpBlockRetry({ frameId: this.view.frameId, messageSender: this.view.getDest() })) ); }; } diff --git a/extension/chrome/settings/inbox/inbox-modules/inbox-active-thread-module.ts b/extension/chrome/settings/inbox/inbox-modules/inbox-active-thread-module.ts index 4c83aca3b98..bd5ecea1868 100644 --- a/extension/chrome/settings/inbox/inbox-modules/inbox-active-thread-module.ts +++ b/extension/chrome/settings/inbox/inbox-modules/inbox-active-thread-module.ts @@ -189,7 +189,7 @@ export class InboxActiveThreadModule extends ViewModule { loaderContext.getRenderedMessageXssSafe() + loaderContext.getRenderedAttachmentsXssSafe(); $('.thread').append(this.wrapMsg(htmlId, r)); // xss-safe-value - await this.view.messageRenderer.startProcessingInlineBlocks(this.view.relayManager, this.view.factory, messageInfo, blocksInFrames); + this.view.messageRenderer.startProcessingInlineBlocks(this.view.relayManager, this.view.factory, messageInfo, blocksInFrames); if (exportBtn) { $('.action-export').on( 'click', diff --git a/extension/chrome/settings/inbox/inbox.ts b/extension/chrome/settings/inbox/inbox.ts index c1596a5db91..5ffdd4b71c7 100644 --- a/extension/chrome/settings/inbox/inbox.ts +++ b/extension/chrome/settings/inbox/inbox.ts @@ -24,7 +24,6 @@ import { XssSafeFactory } from '../../../js/common/xss-safe-factory.js'; import { AcctStore } from '../../../js/common/platform/store/acct-store.js'; import { RelayManager } from '../../../js/common/relay-manager.js'; import { MessageRenderer } from '../../../js/common/message-renderer.js'; -import { Env } from '../../../js/common/browser/env.js'; export class InboxView extends View { public readonly inboxMenuModule: InboxMenuModule; @@ -62,16 +61,11 @@ export class InboxView extends View { this.inboxNotificationModule = new InboxNotificationModule(this); this.inboxActiveThreadModule = new InboxActiveThreadModule(this); this.inboxListThreadsModule = new InboxListThreadsModule(this); - window.addEventListener('message', e => { - if (e.origin === Env.getExtensionOrigin()) { - this.relayManager.handleMessageFromFrame(e.data); - } - }); } public render = async () => { this.tabId = await BrowserMsg.requiredTabId(); - this.relayManager = new RelayManager(this.tabId, this.debug); + this.relayManager = new RelayManager(this.debug); this.factory = new XssSafeFactory(this.acctEmail, this.tabId); this.injector = new Injector('settings', undefined, this.factory); this.webmailCommon = new WebmailCommon(this.acctEmail, this.injector); @@ -105,7 +99,9 @@ export class InboxView extends View { public setHandlers = () => { // BrowserMsg.addPgpListeners(); // todo - re-allow when https://github.com/FlowCrypt/flowcrypt-browser/issues/2560 fixed + this.addBrowserMsgListeners(); BrowserMsg.listen(this.tabId); + BrowserMsg.listenForWindowMessages(); // listen for direct messages from child iframes Catch.setHandledInterval(this.webmailCommon.addOrRemoveEndSessionBtnIfNeeded, 30000); $('.action_open_settings').on( 'click', @@ -122,7 +118,6 @@ export class InboxView extends View { 'click', this.setHandlerPrevent('double', async () => await Settings.newGoogleAcctAuthPromptThenAlertOrForward(this.tabId)) ); - this.addBrowserMsgListeners(); }; public redirectToUrl = (params: UrlParams) => { @@ -182,6 +177,12 @@ export class InboxView extends View { await Ui.modal.attachmentPreview(iframeUrl); }); BrowserMsg.addListener('confirmation_show', CommonHandlers.showConfirmationHandler); + BrowserMsg.addListener('pgp_block_ready', async ({ frameId, messageSender }: Bm.PgpBlockReady) => { + this.relayManager.associate(frameId, messageSender); + }); + BrowserMsg.addListener('pgp_block_retry', async ({ frameId, messageSender }: Bm.PgpBlockRetry) => { + this.relayManager.retry(frameId, messageSender); + }); if (this.debug) { BrowserMsg.addListener('open_compose_window', async ({ draftId }: Bm.ComposeWindowOpenDraft) => { console.log('received open_compose_window'); diff --git a/extension/js/background_page/bg-handlers.ts b/extension/js/background_page/bg-handlers.ts index 59dc42fb99c..07bf12c3cdd 100644 --- a/extension/js/background_page/bg-handlers.ts +++ b/extension/js/background_page/bg-handlers.ts @@ -30,7 +30,7 @@ export class BgHandlers { }; public static ajaxHandler = async (r: Bm.Ajax, sender: Bm.Sender): Promise => { - if (r.req.context?.frameId) { + if (r.req.context?.operationId) { // progress updates were requested via messages let dest = r.req.context.tabId; if (typeof dest === 'undefined') { @@ -43,10 +43,10 @@ export class BgHandlers { } if (typeof dest !== 'undefined') { const destination = dest; - const frameId = r.req.context.frameId; + const operationId = r.req.context.operationId; const expectedTransferSize = r.req.context.expectedTransferSize; r.req.xhr = Api.getAjaxProgressXhrFactory({ - download: (percent, loaded, total) => BrowserMsg.send.ajaxProgress(destination, { percent, loaded, total, expectedTransferSize, frameId }), + download: (percent, loaded, total) => BrowserMsg.send.ajaxProgress(destination, { percent, loaded, total, expectedTransferSize, operationId }), }); } } @@ -91,11 +91,15 @@ export class BgHandlers { }); }); - public static respondWithSenderTabId = async (r: unknown, sender: Bm.Sender): Promise => { + public static respondWithSenderTabId = async (r: Bm._tab_, sender: Bm.Sender): Promise => { if (sender === 'background') { return { tabId: null }; // eslint-disable-line no-null/no-null - } else if (sender.tab) { - return { tabId: `${sender.tab.id}:${sender.frameId}` }; + } else if (typeof sender.tab?.id === 'number' && sender.tab.id > 0) { + const tabId = `${sender.tab.id}:${sender.frameId}`; + if (r.contentScript) { + BrowserMsg.contentScriptsRegistry.add(tabId); + } + return { tabId }; } else { // sender.tab: "This property will only be present when the connection was opened from a tab (including content scripts)" // https://developers.chrome.com/extensions/runtime#type-MessageSender diff --git a/extension/js/common/api/email-provider/gmail/google.ts b/extension/js/common/api/email-provider/gmail/google.ts index f55bc7cf2f4..903024c4e9f 100644 --- a/extension/js/common/api/email-provider/gmail/google.ts +++ b/extension/js/common/api/email-provider/gmail/google.ts @@ -40,7 +40,7 @@ export class Google { // eslint-disable-next-line @typescript-eslint/naming-convention const headers = { Authorization: await GoogleAuth.googleApiAuthHeader(acctEmail) }; const context = - 'frameId' in progress ? { frameId: progress.frameId, expectedTransferSize: progress.expectedTransferSize, tabId: progress.tabId } : undefined; + 'operationId' in progress ? { operationId: progress.operationId, expectedTransferSize: progress.expectedTransferSize, tabId: progress.tabId } : undefined; const xhr = Api.getAjaxProgressXhrFactory('download' in progress || 'upload' in progress ? progress : {}); const request = { xhr, context, url, method, data, headers, crossDomain: true, contentType, async: true }; return (await GoogleAuth.apiGoogleCallRetryAuthErrorOneTime(acctEmail, request)) as RT; diff --git a/extension/js/common/api/shared/api.ts b/extension/js/common/api/shared/api.ts index cd7a5ecd954..a2f33292432 100644 --- a/extension/js/common/api/shared/api.ts +++ b/extension/js/common/api/shared/api.ts @@ -25,7 +25,7 @@ type RawAjaxErr = { status?: number; statusText?: string; }; -export type ProgressDestFrame = { frameId: string; expectedTransferSize: number; tabId?: string }; +export type ProgressDestFrame = { operationId: string; expectedTransferSize: number; tabId?: string }; export type ApiCallContext = ProgressDestFrame | undefined; export type ChunkedCb = (r: ProviderContactsResults) => Promise; diff --git a/extension/js/common/browser/browser-msg.ts b/extension/js/common/browser/browser-msg.ts index cf9b99bc78f..0282f55bebe 100644 --- a/extension/js/common/browser/browser-msg.ts +++ b/extension/js/common/browser/browser-msg.ts @@ -18,7 +18,7 @@ import { BrowserMsgCommonHandlers } from './browser-msg-common-handlers.js'; import { Browser } from './browser.js'; import { Env } from './env.js'; import { Time } from './time.js'; -import { RenderMessageWithFrameId } from '../render-message.js'; +import { RenderMessage } from '../render-message.js'; export type GoogleAuthWindowResult$result = 'Success' | 'Denied' | 'Error' | 'Closed'; @@ -34,6 +34,10 @@ export namespace Bm { uid: string; stack: string; }; + export type RawWindowMessageWithSender = Raw & { + data: { bm: AnyRequest & { messageSender: Dest }; objUrls: Dict }; + responseName?: string; + }; export type SetCss = { css: Dict; traverseUp?: number; selector: string }; export type AddOrRemoveClass = { class: string; selector: string }; @@ -60,9 +64,11 @@ export namespace Bm { export type OpenGoogleAuthDialog = { acctEmail?: string; scopes?: string[] }; export type OpenPage = { page: string; addUrlText?: string | UrlParams }; export type PassphraseEntry = { entered: boolean; initiatorFrameId?: string }; - export type ConfirmationResult = { confirm: boolean }; + export type AsyncResult = { requestUid: string; payload: T }; + export type ConfirmationResult = AsyncResult; export type AuthWindowResult = { url?: string; error?: string }; export type Db = { f: string; args: unknown[] }; + export type _tab_ = { contentScript?: boolean }; // eslint-disable-line @typescript-eslint/naming-convention export type InMemoryStoreSet = { acctEmail: string; key: string; @@ -78,12 +84,13 @@ export namespace Bm { export type PgpMsgDecrypt = PgpMsgMethod.Arg.Decrypt; export type PgpKeyBinaryToArmored = { binaryKeysData: Uint8Array }; export type Ajax = { req: JQuery.AjaxSettings; stack: string }; - export type AjaxProgress = { frameId: string; percent?: number; loaded: number; total: number; expectedTransferSize: number }; + export type AjaxProgress = { operationId: string; percent?: number; loaded: number; total: number; expectedTransferSize: number }; export type AjaxGmailAttachmentGetChunk = { acctEmail: string; msgId: string; attachmentId: string }; export type ShowAttachmentPreview = { iframeUrl: string }; - export type ShowConfirmation = { text: string; isHTML: boolean; footer?: string }; + export type ShowConfirmation = { text: string; isHTML: boolean; messageSender: string; requestUid: string; footer?: string }; export type ReRenderRecipient = { email: string }; - export type ShowConfirmationResult = { isConfirmed: boolean }; + export type PgpBlockRetry = { frameId: string; messageSender: Dest }; + export type PgpBlockReady = { frameId: string; messageSender: Dest }; export namespace Res { export type GetActiveTabInfo = { @@ -101,8 +108,7 @@ export namespace Bm { export type PgpMsgDecrypt = DecryptResult; export type PgpKeyBinaryToArmored = { keys: ArmoredKeyIdentityWithEmails[] }; export type AjaxGmailAttachmentGetChunk = { chunk: Buf }; - export type _tab_ = { tabId: string | null | undefined }; // eslint-disable-line @typescript-eslint/naming-convention - export type ShowConfirmationResult = boolean; + export type _tab_ = { tabId: string | null | undefined; contentScript?: boolean }; // eslint-disable-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Db = any; // not included in Any below // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -120,10 +126,12 @@ export namespace Bm { | StoreGlobalGet | StoreGlobalSet | AjaxGmailAttachmentGetChunk - | PgpKeyBinaryToArmored; + | PgpKeyBinaryToArmored + | ConfirmationResult; } export type AnyRequest = + | _tab_ | PassphraseEntry | OpenPage | OpenGoogleAuthDialog @@ -154,11 +162,15 @@ export namespace Bm { | StoreAcctSet | PgpMsgDecrypt | Ajax + | AjaxProgress | ShowAttachmentPreview | ShowConfirmation | ReRenderRecipient | PgpKeyBinaryToArmored | AuthWindowResult + | RenderMessage + | PgpBlockReady + | PgpBlockRetry | ConfirmationResult; // export type RawResponselessHandler = (req: AnyRequest) => Promise; @@ -195,6 +207,8 @@ export class TabIdRequiredError extends Error {} export class BrowserMsg { public static MAX_SIZE = 1024 * 1024; // 1MB + public static contentScriptsRegistry = new Set(); + public static send = { // todo - may want to organise this differently, seems to always confuse me when sending a message bg: { @@ -219,7 +233,6 @@ export class BrowserMsg { BrowserMsg.sendAwait(undefined, 'pgpKeyBinaryToArmored', bm, true) as Promise, }, }, - confirmationResult: (dest: Bm.Dest, bm: Bm.ConfirmationResult) => BrowserMsg.sendCatch(dest, 'confirmation_result', bm), passphraseEntry: (dest: Bm.Dest, bm: Bm.PassphraseEntry) => BrowserMsg.sendCatch(dest, 'passphrase_entry', bm), addEndSessionBtn: (dest: Bm.Dest) => BrowserMsg.sendCatch(dest, 'add_end_session_btn', {}), openPage: (dest: Bm.Dest, bm: Bm.OpenPage) => BrowserMsg.sendCatch(dest, 'open_page', bm), @@ -242,7 +255,7 @@ export class BrowserMsg { notificationShow: (dest: Bm.Dest, bm: Bm.NotificationShow) => BrowserMsg.sendCatch(dest, 'notification_show', bm), notificationShowAuthPopupNeeded: (dest: Bm.Dest, bm: Bm.NotificationShowAuthPopupNeeded) => BrowserMsg.sendCatch(dest, 'notification_show_auth_popup_needed', bm), - showConfirmation: (dest: Bm.Dest, bm: Bm.ShowConfirmation) => BrowserMsg.sendCatch(dest, 'confirmation_show', bm), + showConfirmation: (bm: Bm.ShowConfirmation) => BrowserMsg.sendToParentWindow('confirmation_show', bm, 'confirmation_result'), renderPublicKeys: (dest: Bm.Dest, bm: Bm.RenderPublicKeys) => BrowserMsg.sendCatch(dest, 'render_public_keys', bm), replyPubkeyMismatch: (dest: Bm.Dest) => BrowserMsg.sendCatch(dest, 'reply_pubkey_mismatch', {}), addPubkeyDialog: (dest: Bm.Dest, bm: Bm.AddPubkeyDialog) => BrowserMsg.sendCatch(dest, 'add_pubkey_dialog', bm), @@ -252,8 +265,11 @@ export class BrowserMsg { reRenderRecipient: (dest: Bm.Dest, bm: Bm.ReRenderRecipient) => BrowserMsg.sendCatch(dest, 'reRenderRecipient', bm), showAttachmentPreview: (dest: Bm.Dest, bm: Bm.ShowAttachmentPreview) => BrowserMsg.sendCatch(dest, 'show_attachment_preview', bm), ajaxProgress: (dest: Bm.Dest, bm: Bm.AjaxProgress) => BrowserMsg.sendCatch(dest, 'ajax_progress', bm), - pgpBlockRender: (dest: Bm.Dest, bm: RenderMessageWithFrameId) => BrowserMsg.sendCatch(dest, 'pgp_block_render', bm), + pgpBlockRender: (dest: Bm.Dest, bm: RenderMessage) => BrowserMsg.sendCatch(dest, 'pgp_block_render', bm), + pgpBlockReady: (bm: Bm.PgpBlockReady) => BrowserMsg.sendToParentWindow('pgp_block_ready', bm), + pgpBlockRetry: (bm: Bm.PgpBlockRetry) => BrowserMsg.sendToParentWindow('pgp_block_retry', bm), }; + private static readonly processed: string[] = []; /* eslint-disable @typescript-eslint/naming-convention */ private static HANDLERS_REGISTERED_BACKGROUND: Handlers = {}; private static HANDLERS_REGISTERED_FRAME: Handlers = { @@ -289,9 +305,9 @@ export class BrowserMsg { window.document.body.appendChild(div); }; - public static tabId = async (): Promise => { + public static tabId = async (contentScript?: boolean): Promise => { try { - const { tabId } = (await BrowserMsg.sendAwait(undefined, '_tab_', undefined, true)) as Bm.Res._tab_; + const { tabId } = (await BrowserMsg.sendAwait(undefined, '_tab_', { contentScript }, true)) as Bm.Res._tab_; return tabId; } catch (e) { if (e instanceof BgNotReadyErr) { @@ -301,11 +317,11 @@ export class BrowserMsg { } }; - public static requiredTabId = async (attempts = 10, delay = 200): Promise => { + public static requiredTabId = async (contentScript?: boolean, attempts = 10, delay = 200): Promise => { let tabId; for (let i = 0; i < attempts; i++) { // sometimes returns undefined right after browser start due to BgNotReadyErr - tabId = await BrowserMsg.tabId(); + tabId = await BrowserMsg.tabId(contentScript); if (tabId) { return tabId; } @@ -325,39 +341,28 @@ export class BrowserMsg { BrowserMsg.HANDLERS_REGISTERED_FRAME[name] = handler; }; - public static listen = (listenForTabId: string) => { - const processed: string[] = []; - chrome.runtime.onMessage.addListener((msg: Bm.Raw, sender, rawRespond: (rawResponse: Bm.RawResponse) => void) => { - // console.debug(`listener(${listenForTabId}) new message: ${msg.name} to ${msg.to} with id ${msg.uid} from`, sender); - try { - if (msg.to === listenForTabId || msg.to === 'broadcast') { - if (!processed.includes(msg.uid)) { - processed.push(msg.uid); - if (typeof BrowserMsg.HANDLERS_REGISTERED_FRAME[msg.name] !== 'undefined') { - const handler: Bm.AsyncRespondingHandler = BrowserMsg.HANDLERS_REGISTERED_FRAME[msg.name]; - BrowserMsg.replaceObjUrlWithBuf(msg.data.bm, msg.data.objUrls) - .then(bm => BrowserMsg.sendRawResponse(handler(bm, sender), rawRespond)) - .catch(e => BrowserMsg.sendRawResponse(Promise.reject(e), rawRespond)); - return true; // will respond - } else if (msg.name !== '_tab_' && msg.to !== 'broadcast') { - BrowserMsg.sendRawResponse(Promise.reject(new Error(`BrowserMsg.listen error: handler "${msg.name}" not set`)), rawRespond); - return true; // will respond - } - } else { - // sometimes received events get duplicated - // while first event is being processed, second even will arrive - // that's why we generate a unique id of each request (uid) and filter them above to identify truly unique requests - // if we got here, that means we are handing a duplicate request - // we'll indicate will respond = true, so that the processing of the actual request is not negatively affected - // leaving it at "false" would respond with null, which would throw an error back to the original BrowserMsg sender: - // "Error: BrowserMsg.sendAwait(pgpMsgDiagnosePubkeys) returned(null) with lastError: (no lastError)" - // the duplication is likely caused by our routing mechanism. Sometimes browser will deliver the message directly as well as through bg - return true; + // this is a means to get messages from child iframes + public static listenForWindowMessages = () => { + const allowedOrigin = Env.getExtensionOrigin(); + window.addEventListener('message', e => { + if (e.origin === allowedOrigin) { + const msg = e.data as Bm.RawWindowMessageWithSender; + BrowserMsg.handleMsg(msg, {}, (rawResponse: Bm.RawResponse) => { + if (msg.responseName) { + // send response as a new request + BrowserMsg.sendRaw(msg.data.bm.messageSender, msg.responseName, rawResponse.result as Dict, rawResponse.objUrls).catch(Catch.reportErr); } - } - } catch (e) { - BrowserMsg.sendRawResponse(Promise.reject(e), rawRespond); - return true; // will respond + }); + } + }); + }; + + public static listen = (dest: string) => { + chrome.runtime.onMessage.addListener((msg: Bm.Raw, sender, rawRespond: (rawResponse: Bm.RawResponse) => void) => { + // console.debug(`listener(${dest}) new message: ${msg.name} to ${msg.to} with id ${msg.uid} from`, sender); + if (msg.to === dest || msg.to === 'broadcast') { + BrowserMsg.handleMsg(msg, sender, rawRespond); + return true; } return false; // will not respond }); @@ -425,6 +430,47 @@ export class BrowserMsg { }); }; + private static sendToParentWindow = (name: string, bm: Dict & { messageSender: Bm.Dest }, responseName?: string) => { + const raw: Bm.RawWindowMessageWithSender = { + data: { bm, objUrls: {} }, + name, + stack: '', + // eslint-disable-next-line no-null/no-null + to: null, + uid: Str.sloppyRandom(10), + responseName, + }; + window.parent.postMessage(raw, '*'); + }; + + private static handleMsg = (msg: Bm.Raw, sender: chrome.runtime.MessageSender, rawRespond: (rawResponse: Bm.RawResponse) => void) => { + try { + if (!BrowserMsg.processed.includes(msg.uid)) { + // todo: Set or ExpirationCache? + BrowserMsg.processed.push(msg.uid); + if (typeof BrowserMsg.HANDLERS_REGISTERED_FRAME[msg.name] !== 'undefined') { + const handler: Bm.AsyncRespondingHandler = BrowserMsg.HANDLERS_REGISTERED_FRAME[msg.name]; + BrowserMsg.replaceObjUrlWithBuf(msg.data.bm, msg.data.objUrls) + .then(bm => BrowserMsg.sendRawResponse(handler(bm, sender), rawRespond)) + .catch(e => BrowserMsg.sendRawResponse(Promise.reject(e), rawRespond)); + } else if (msg.name !== '_tab_' && msg.to !== 'broadcast') { + BrowserMsg.sendRawResponse(Promise.reject(new Error(`BrowserMsg.listen error: handler "${msg.name}" not set`)), rawRespond); + } + } else { + // sometimes received events get duplicated + // while first event is being processed, second even will arrive + // that's why we generate a unique id of each request (uid) and filter them above to identify truly unique requests + // if we got here, that means we are handing a duplicate request + // we'll indicate will respond = true, so that the processing of the actual request is not negatively affected + // leaving it at "false" would respond with null, which would throw an error back to the original BrowserMsg sender: + // "Error: BrowserMsg.sendAwait(pgpMsgDiagnosePubkeys) returned(null) with lastError: (no lastError)" + // the duplication is likely caused by our routing mechanism. Sometimes browser will deliver the message directly as well as through bg + } + } catch (e) { + BrowserMsg.sendRawResponse(Promise.reject(e), rawRespond); + } + }; + /** * When sending message from iframe within extension page, the browser will deliver the message to BOTH * the parent frame as well as the background (when we ment to just send to parent). @@ -439,8 +485,7 @@ export class BrowserMsg { if (Catch.browser().name !== 'chrome') { return true; // only chrome sends messages directly to extension frame parent (in addition to sending to bg) } - if (destination !== `${sender.tab.id}:0`) { - // zero mains the main frame in a tab, the parent frame + if (BrowserMsg.contentScriptsRegistry.has(destination)) { return true; // not sending to a parent (must relay, browser does not send directly) } if (sender.url?.includes(chrome.runtime.id) && sender.tab.url?.startsWith('https://')) { @@ -462,12 +507,29 @@ export class BrowserMsg { const handler: Bm.AsyncRespondingHandler = BrowserMsg.HANDLERS_REGISTERED_BACKGROUND[name]; return await handler(bm, 'background'); } - return await new Promise((resolve, reject) => { + return await BrowserMsg.sendRaw( + destString, + name, + bm, // here browser messaging is used - msg has to be serializable - Buf instances need to be converted to object urls, and back upon receipt - const objUrls = BrowserMsg.replaceBufWithObjUrlInplace(bm); + BrowserMsg.replaceBufWithObjUrlInplace(bm), + awaitRes, + isBackgroundPage + ); + }; + + private static sendRaw = async ( + destString: string | undefined, + name: string, + bm: Dict, + objUrls: Dict, + awaitRes = false, + isBackgroundPage = false + ): Promise => { + return await new Promise((resolve, reject) => { const msg: Bm.Raw = { name, - data: { bm: bm!, objUrls }, // eslint-disable-line @typescript-eslint/no-non-null-assertion + data: { bm, objUrls }, to: destString || null, // eslint-disable-line no-null/no-null uid: Str.sloppyRandom(10), stack: Catch.stackTrace(), diff --git a/extension/js/common/browser/ui.ts b/extension/js/common/browser/ui.ts index 9af856c6830..a87f8369933 100644 --- a/extension/js/common/browser/ui.ts +++ b/extension/js/common/browser/ui.ts @@ -4,7 +4,7 @@ import { ApiErr } from '../api/shared/api-error.js'; import { Catch } from '../platform/catch.js'; -import { Dict, Url } from '../core/common.js'; +import { Dict, Str, Url } from '../core/common.js'; import Swal, { SweetAlertIcon, SweetAlertPosition, SweetAlertResult } from 'sweetalert2'; import { Xss } from '../platform/xss.js'; import { Bm, BrowserMsg } from './browser-msg.js'; @@ -13,20 +13,38 @@ type NamedSels = Dict>; type ProvidedEventHandler = (e: HTMLElement, event: JQuery.TriggeredEvent) => void | Promise; -export interface ConfirmationResultTracker { - getParentTabId: () => string; - confirmationResultResolver?: (confirm: boolean) => void; +export interface BrowserMsgResponseTracker { + getDest: () => string; } +export type ConfirmationResultTracker = BrowserMsgResponseTracker; + export class CommonHandlers { - public static createConfirmationResultHandler: (view: ConfirmationResultTracker) => Bm.AsyncResponselessHandler = view => { - return async ({ confirm }: Bm.ConfirmationResult) => { - view.confirmationResultResolver?.(confirm); + protected static respondMap = new Map void>(); + + public static createAsyncResultHandler = () => { + return async ({ payload, requestUid }: Bm.AsyncResult) => { + const respond = CommonHandlers.respondMap.get(requestUid); + if (respond) { + respond(payload); + CommonHandlers.respondMap.delete(requestUid); + } }; }; - public static showConfirmationHandler: Bm.AsyncResponselessHandler = async ({ text, isHTML, footer }: Bm.ShowConfirmation) => { - const confirm = await Ui.modal.confirm(text, isHTML, footer); - BrowserMsg.send.confirmationResult('broadcast', { confirm }); + + public static sendRequestAndHandleAsyncResult = async (send: (requestUid: string) => void): Promise => { + const requestUid = Str.sloppyRandom(10); + const p = new Promise((resolve: (value: T) => void) => { + CommonHandlers.respondMap.set(requestUid, resolve); + }); + send(requestUid); + return await p; + }; + + // for specific types + public static showConfirmationHandler: Bm.AsyncRespondingHandler = async ({ text, isHTML, footer, requestUid }: Bm.ShowConfirmation) => { + const payload = await Ui.modal.confirm(text, isHTML, footer); + return { requestUid, payload }; }; } @@ -331,12 +349,16 @@ export class Ui { * Presents a modal where user can respond with confirm or cancel. * Awaiting this will give you the users choice as a boolean. */ - confirm: async (text: string, isHTML = false, footer?: string): Promise => { - const p = new Promise((resolve: (value: boolean) => void) => { - confirmationResultTracker.confirmationResultResolver = resolve; + confirm: (text: string, isHTML = false, footer?: string): Promise => { + return CommonHandlers.sendRequestAndHandleAsyncResult(requestUid => { + BrowserMsg.send.showConfirmation({ + text, + isHTML, + footer, + messageSender: confirmationResultTracker.getDest(), + requestUid, + }); }); - BrowserMsg.send.showConfirmation(confirmationResultTracker.getParentTabId(), { text, isHTML, footer }); - return await p; }, }; }; diff --git a/extension/js/common/message-renderer.ts b/extension/js/common/message-renderer.ts index b5af3a0cdc0..f95fffe3283 100644 --- a/extension/js/common/message-renderer.ts +++ b/extension/js/common/message-renderer.ts @@ -320,7 +320,7 @@ export class MessageRenderer { // we could change 'Getting file info..' to 'Loading signed message..' in attachment_loader element const raw = await this.downloader.msgGetRaw(msgId); loaderContext.hideAttachment(attachmentSel); - await this.setMsgBodyAndStartProcessing(loaderContext, 'signedDetached', messageInfo.printMailInfo, messageInfo.from?.email, renderModule => + this.setMsgBodyAndStartProcessing(loaderContext, 'signedDetached', messageInfo.printMailInfo, messageInfo.from?.email, renderModule => this.processMessageWithDetachedSignatureFromRaw(raw, renderModule, messageInfo.from?.email, body) ); return 'hidden'; // native attachment should be hidden, the "attachment" goes to the message container @@ -349,8 +349,8 @@ export class MessageRenderer { loaderContext.prependEncryptedAttachment(a); return 'replaced'; // native should be hidden, custom should appear instead } else if (treatAs === 'encryptedMsg') { - await this.setMsgBodyAndStartProcessing(loaderContext, treatAs, messageInfo.printMailInfo, messageInfo.from?.email, (renderModule, frameId) => - this.processCryptoMessage(a, renderModule, frameId, messageInfo.from?.email, messageInfo.isPwdMsgBasedOnMsgSnippet, messageInfo.plainSubject) + this.setMsgBodyAndStartProcessing(loaderContext, treatAs, messageInfo.printMailInfo, messageInfo.from?.email, renderModule => + this.processCryptoMessage(a, renderModule, messageInfo.from?.email, messageInfo.isPwdMsgBasedOnMsgSnippet, messageInfo.plainSubject) ); return 'hidden'; // native attachment should be hidden, the "attachment" goes to the message container } else if (treatAs === 'privateKey') { @@ -372,14 +372,12 @@ export class MessageRenderer { } }; - public startProcessingInlineBlocks = async (relayManager: RelayManager, factory: XssSafeFactory, messageInfo: MessageInfo, blocks: Dict) => { - await Promise.all( - Object.entries(blocks).map(([frameId, block]) => - this.relayAndStartProcessing(relayManager, factory, frameId, messageInfo.printMailInfo, messageInfo.from?.email, renderModule => - this.renderMsgBlock(block, renderModule, messageInfo.from?.email, messageInfo.isPwdMsgBasedOnMsgSnippet, messageInfo.plainSubject) - ) - ) - ); + public startProcessingInlineBlocks = (relayManager: RelayManager, factory: XssSafeFactory, messageInfo: MessageInfo, blocks: Dict) => { + for (const [frameId, block] of Object.entries(blocks)) { + this.relayAndStartProcessing(relayManager, factory, frameId, messageInfo.printMailInfo, messageInfo.from?.email, renderModule => + this.renderMsgBlock(block, renderModule, messageInfo.from?.email, messageInfo.isPwdMsgBasedOnMsgSnippet, messageInfo.plainSubject) + ); + } }; public deleteExpired = (): void => { @@ -577,48 +575,41 @@ export class MessageRenderer { return isEncrypted ? { publicKeys } : {}; }; - private relayAndStartProcessing = async ( + private relayAndStartProcessing = ( relayManager: RelayManager, factory: XssSafeFactory, frameId: string, printMailInfo: PrintMailInfo | undefined, senderEmail: string | undefined, - cb: (renderModule: RenderInterface, frameId: string) => Promise<{ publicKeys?: string[]; needPassphrase?: string[] }> - ): Promise<{ processor: Promise }> => { - const renderModule = relayManager.createRelay(frameId); - if (printMailInfo) { - renderModule.setPrintMailInfo(printMailInfo); - } - const processor = cb(renderModule, frameId) - .then(async result => { - const appendAfter = $(`iframe#${frameId}`); // todo: review inbox-active-thread -- may fail - // todo: how publicKeys and needPassphrase interact? - for (const armoredPubkey of result.publicKeys ?? []) { - appendAfter.after(factory.embeddedPubkey(armoredPubkey, this.isOutgoing(senderEmail))); - } - while (result.needPassphrase && !renderModule.cancellation.cancel) { - // if we need passphrase, we have to be able to re-try decryption indefinitely on button presses, - // so we can only release resources when the frame is detached - await PassphraseStore.waitUntilPassphraseChanged(this.acctEmail, result.needPassphrase, 1000, renderModule.cancellation); - if (renderModule.cancellation.cancel) { - if (this.debug) { - console.debug('Destination frame was detached -- stopping processing'); - } - return; + cb: (renderModule: RenderInterface) => Promise<{ publicKeys?: string[]; needPassphrase?: string[] }> + ): void => { + relayManager.createAndStartRelay(frameId, async (renderModule: RenderInterface) => { + if (printMailInfo) { + renderModule.setPrintMailInfo(printMailInfo); + } + let result = await cb(renderModule); + // todo: review inbox-active-thread -- may fail if the frameId isn't in the DOM yet + // we may stop here and wait until renderModule is attached (receive a signal from RelayManager based on frameData) + const appendAfter = $(`iframe#${frameId}`); + // todo: how publicKeys and needPassphrase interact? + for (const armoredPubkey of result.publicKeys ?? []) { + appendAfter.after(factory.embeddedPubkey(armoredPubkey, this.isOutgoing(senderEmail))); + } + while (result.needPassphrase && !renderModule.cancellation.cancel) { + // wait for either passphrase or cancellation + await PassphraseStore.waitUntilPassphraseChanged(this.acctEmail, result.needPassphrase, 1000, renderModule.cancellation); + if (renderModule.cancellation.cancel) { + if (this.debug) { + console.debug('Destination frame was detached -- stopping processing'); } - renderModule.clearErrorStatus(); - renderModule.renderText('Decrypting...'); - result = await cb(renderModule, frameId); - // I guess, no additional publicKeys will appear here for display... + return; } - }) - .catch(e => { - // normally no exceptions come to this point so let's report it - Catch.reportErr(e); - renderModule.renderErr(Xss.escape(String(e)), undefined); - }) - .finally(() => relayManager.done(frameId)); - return { processor }; + renderModule.clearErrorStatus(); + renderModule.renderText('Decrypting...'); + result = await cb(renderModule); + // I guess, no additional publicKeys will appear here for display... + } + }); }; private renderMsgBlock = async ( @@ -758,22 +749,21 @@ export class MessageRenderer { return {}; }; - private setMsgBodyAndStartProcessing = async ( + private setMsgBodyAndStartProcessing = ( loaderContext: LoaderContextInterface, type: string, // for diagnostics printMailInfo: PrintMailInfo | undefined, senderEmail: string | undefined, - cb: (renderModule: RenderInterface, frameId: string) => Promise<{ publicKeys?: string[] }> - ): Promise<{ processor: Promise }> => { + cb: (renderModule: RenderInterface) => Promise<{ publicKeys?: string[] }> + ) => { const { frameId, frameXssSafe } = this.factory.embeddedMsg(type); // xss-safe-factory loaderContext.setMsgBody_DANGEROUSLY(frameXssSafe, 'set'); // xss-safe-value - return await this.relayAndStartProcessing(this.relayManager, this.factory, frameId, printMailInfo, senderEmail, cb); + this.relayAndStartProcessing(this.relayManager, this.factory, frameId, printMailInfo, senderEmail, cb); }; private processCryptoMessage = async ( attachment: Attachment, renderModule: RenderInterface, - frameId: string, senderEmail: string | undefined, isPwdMsgBasedOnMsgSnippet: boolean | undefined, plainSubject: string | undefined @@ -782,14 +772,8 @@ export class MessageRenderer { if (!attachment.hasData()) { // todo: implement cache similar to chunk downloads // note: this cache should return void or throw an exception because the data bytes are set to the Attachment object - this.relayManager.renderProgressText(frameId, 'Retrieving message...'); - await this.gmail.fetchAttachment(attachment, expectedTransferSize => { - return { - frameId, - expectedTransferSize, - download: (percent, loaded, total) => this.relayManager.renderProgress({ frameId, percent, loaded, total, expectedTransferSize }), // shortcut - }; - }); + const progressFunction = renderModule.startProgressRendering('Retrieving message...'); + await this.gmail.fetchAttachment(attachment, progressFunction); } const armoredMsg = PgpArmor.clip(attachment.getData().toUtfStr()); if (!armoredMsg) { diff --git a/extension/js/common/relay-manager.ts b/extension/js/common/relay-manager.ts index 6ef4289344e..000eec5c912 100644 --- a/extension/js/common/relay-manager.ts +++ b/extension/js/common/relay-manager.ts @@ -9,22 +9,26 @@ import { RenderMessage } from './render-message.js'; import { RenderRelay } from './render-relay.js'; type FrameEntry = { - readyToReceive?: true; + tabId?: string; queue: RenderMessage[]; - progressText?: string; relay?: RenderRelay; }; export class RelayManager implements RelayManagerInterface { - private static readonly completionMessage: RenderMessage = { done: true }; private readonly frames = new Map(); - public constructor(private tabId: string, private debug: boolean = false) { + public constructor(private debug: boolean = false) { const framesObserver = new MutationObserver(async mutationsList => { for (const mutation of mutationsList) { if (mutation.type === 'childList') { - for (const removedNode of mutation.removedNodes) { - this.dropRemovedNodes(removedNode); + const removedFrameIds = this.findFrameIds(mutation.removedNodes); + const addedFrameIds = this.findFrameIds(mutation.addedNodes); + for (const frameId of removedFrameIds) { + if (addedFrameIds.includes(frameId)) { + this.restartRelay(frameId); + } else { + this.dropRemovedFrame(frameId); + } } } } @@ -32,116 +36,99 @@ export class RelayManager implements RelayManagerInterface { framesObserver.observe(window.document, { subtree: true, childList: true }); } - public static getPercentage = (percent: number | undefined, loaded: number, total: number, expectedTransferSize: number) => { - if (typeof percent === 'undefined') { - if (total || expectedTransferSize) { - percent = Math.round((loaded / (total || expectedTransferSize)) * 100); - } - } - return percent; - }; - - public relay = (frameId: string, message: RenderMessage) => { + public relay = (frameId: string, message: RenderMessage, dontEnqueue?: boolean) => { const frameData = this.frames.get(frameId); if (frameData) { - frameData.queue.push(message); - if (frameData.readyToReceive) { - this.flush({ frameId, queue: frameData.queue }); + if (!dontEnqueue || this.flushIfReady(frameId)) { + frameData.queue.push(message); + this.flushIfReady(frameId); } } }; - public createRelay = (frameId: string): RenderInterface => { + public createAndStartRelay = (frameId: string, processor: (renderModule: RenderInterface) => Promise) => { const frameData = this.getOrCreate(frameId); - const relay = new RenderRelay(this, frameId); + const relay = new RenderRelay(this, frameId, processor); frameData.relay = relay; - return relay; + relay.start(); }; - public done = (frameId: string) => { - this.relay(frameId, RelayManager.completionMessage); + public retry = (frameId: string, messageSender: Bm.Dest) => { + const frameData = this.frames.get(frameId); + if (frameData?.tabId === messageSender) { + frameData.relay?.executeRetry(); + } }; - public handleMessageFromFrame = (data: unknown) => { - const typedData = data as { readyToReceive?: string; retry?: string } | undefined; - if (typeof typedData?.readyToReceive === 'string') { - this.readyToReceive(typedData.readyToReceive); - } - if (typeof typedData?.retry === 'string') { - this.retry(typedData.retry); + public renderProgress = (r: Bm.AjaxProgress) => { + // simply forward this message to all relays + // the correct recipient will recognize itself by operationId match + // and return true + for (const [, value] of this.frames) { + if (value.relay?.renderProgress(r)) break; } }; - public renderProgressText = (frameId: string, text: string) => { + public associate = (frameId: string, tabId: string) => { + const frameData = this.getOrCreate(frameId); + frameData.tabId = tabId; + this.flushIfReady(frameId); + }; + + public restartRelay = (frameId: string) => { const frameData = this.frames.get(frameId); - if (frameData) { - frameData.progressText = text; - this.relay(frameId, { renderText: text }); + if (frameData?.relay) { + frameData.relay.cancellation.cancel = true; // cancel the old processing to prevent interference and release resources + const relay = frameData.relay.clone(); + this.frames.set(frameId, { queue: [], relay }); // wire the new relay + relay.start(); // start the processor anew } }; - public renderProgress = ({ frameId, percent, loaded, total, expectedTransferSize }: Bm.AjaxProgress) => { - const perc = RelayManager.getPercentage(percent, loaded, total, expectedTransferSize); - if (typeof perc !== 'undefined') { - const frameData = this.frames.get(frameId); - if (frameData?.readyToReceive && typeof frameData.progressText !== 'undefined') { - this.relay(frameId, { renderText: `${frameData.progressText} ${perc}%` }); + private findFrameIds = (nodes: NodeList): string[] => { + const frameIds: string[] = []; + for (const node of nodes) { + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as HTMLElement; + if (element.tagName === 'IFRAME') { + frameIds.push(element.id); + continue; + } } + frameIds.push(...this.findFrameIds(node.childNodes)); } + return frameIds; }; - private dropRemovedNodes = (removedNode: Node) => { - let frameId: string | undefined; - if (removedNode.nodeType === Node.ELEMENT_NODE) { - const element = removedNode as HTMLElement; - if (element.tagName === 'IFRAME') { - frameId = element.id; - } + private dropRemovedFrame = (frameId: string) => { + if (this.debug) { + console.debug('releasing resources connected to frameId=', frameId); } - if (frameId) { - if (this.debug) { - console.debug('releasing resources connected to frameId=', frameId); - } - const frameData = this.frames.get(frameId); - if (frameData) { - if (frameData.relay?.cancellation) frameData.relay.cancellation.cancel = true; - this.frames.delete(frameId); - } - } else { - for (const childNode of removedNode.childNodes) { - this.dropRemovedNodes(childNode); - } + const frameData = this.frames.get(frameId); + if (frameData) { + if (frameData.relay?.cancellation) frameData.relay.cancellation.cancel = true; + this.frames.delete(frameId); } }; private getOrCreate = (frameId: string): FrameEntry => { const frameEntry = this.frames.get(frameId); if (frameEntry) return frameEntry; - const newFrameEntry = { queue: [], cancellation: { cancel: false } }; + const newFrameEntry = { queue: [] }; this.frames.set(frameId, newFrameEntry); return newFrameEntry; }; - private flush = ({ frameId, queue }: { frameId: string; queue: RenderMessage[] }) => { - while (true) { - const message = queue.shift(); + private flushIfReady = (frameId: string) => { + const frameData = this.frames.get(frameId); + while (frameData?.tabId) { + const message = frameData.queue.shift(); if (message) { - BrowserMsg.send.pgpBlockRender(this.tabId, { ...message, frameId }); - if (message === RelayManager.completionMessage) { - this.frames.delete(frameId); - } - } else break; + BrowserMsg.send.pgpBlockRender(frameData.tabId, message); + } else { + return true; // flushed + } } - }; - - private readyToReceive = (frameId: string) => { - const frameData = this.getOrCreate(frameId); - frameData.readyToReceive = true; - this.flush({ frameId, queue: frameData.queue }); - }; - - private retry = (frameId: string) => { - const frameData = this.frames.get(frameId); - frameData?.relay?.executeRetry(); + return false; // not flushed }; } diff --git a/extension/js/common/render-interface.ts b/extension/js/common/render-interface.ts index 01d6373b628..07d85c566f1 100644 --- a/extension/js/common/render-interface.ts +++ b/extension/js/common/render-interface.ts @@ -2,6 +2,7 @@ 'use strict'; +import { ProgressCb, ProgressDestFrame } from './api/shared/api.js'; import { TransferableAttachment } from './core/attachment.js'; import { PromiseCancellation } from './core/common.js'; import { PrintMailInfo } from './render-message.js'; @@ -16,6 +17,7 @@ export interface RenderInterfaceBase { export interface RenderInterface extends RenderInterfaceBase { cancellation: PromiseCancellation; + startProgressRendering(text: string): (expectedTransferSize: number) => { download: ProgressCb } | ProgressDestFrame; renderAsRegularContent(content: string): void; setPrintMailInfo(info: PrintMailInfo): void; clearErrorStatus(): void; diff --git a/extension/js/common/render-message.ts b/extension/js/common/render-message.ts index 8fee3bd6673..160e6cbfcf9 100644 --- a/extension/js/common/render-message.ts +++ b/extension/js/common/render-message.ts @@ -40,7 +40,3 @@ export type RenderMessage = { printMailInfo?: PrintMailInfo; renderAsRegularContent?: string; }; - -export type RenderMessageWithFrameId = { - frameId: string; -} & RenderMessage; diff --git a/extension/js/common/render-relay.ts b/extension/js/common/render-relay.ts index a03481ae02b..6fac53b761e 100644 --- a/extension/js/common/render-relay.ts +++ b/extension/js/common/render-relay.ts @@ -6,12 +6,74 @@ import { RelayManagerInterface } from './relay-manager-interface.js'; import { RenderInterface } from './render-interface.js'; import { PrintMailInfo, RenderMessage } from './render-message.js'; import { TransferableAttachment } from './core/attachment.js'; -import { PromiseCancellation } from './core/common.js'; +import { PromiseCancellation, Str } from './core/common.js'; +import { Catch } from './platform/catch.js'; +import { Xss } from './platform/xss.js'; +import { ProgressCb } from './api/shared/api.js'; +import { Bm } from './browser/browser-msg.js'; export class RenderRelay implements RenderInterface { public readonly cancellation: PromiseCancellation = { cancel: false }; private retry?: () => void; - public constructor(private relayManager: RelayManagerInterface, private frameId: string) {} + private progressOperation?: { + text: string; + operationId: string; // we can possibly receive a callback from an operation started by the replaced RenderRelay, so need to check operationId + }; + public constructor( + private relayManager: RelayManagerInterface, + private frameId: string, + private processor: (renderModule: RenderInterface) => Promise + ) {} + + public static getPercentage = (percent: number | undefined, loaded: number, total: number, expectedTransferSize: number) => { + if (typeof percent === 'undefined') { + if (total || expectedTransferSize) { + percent = Math.round((loaded / (total || expectedTransferSize)) * 100); + } + } + return percent; + }; + + public startProgressRendering = (text: string) => { + this.relay({ renderText: text }); // we want to enqueue this initial message in case of hanging... + const operationId = Str.sloppyRandom(10); + this.progressOperation = { text, operationId }; + return (expectedTransferSize: number) => { + // the `download` shortcut function can be used in some cases + // if not lost by messaging, it will be given priority over message-based progress implementation + const download: ProgressCb = (percent, loaded, total) => this.renderProgress({ operationId, percent, loaded, total, expectedTransferSize }); + return { + operationId, + expectedTransferSize, + download, // shortcut + }; + }; + }; + + public renderProgress = ({ operationId, percent, loaded, total, expectedTransferSize }: Bm.AjaxProgress) => { + if (this.progressOperation && this.progressOperation.operationId === operationId) { + const perc = RenderRelay.getPercentage(percent, loaded, total, expectedTransferSize); + if (typeof perc !== 'undefined') { + this.relay({ renderText: `${this.progressOperation.text} ${perc}%` }, { progressOperationRendering: true }); + } + return true; + } + return false; + }; + + public clone = () => { + return new RenderRelay(this.relayManager, this.frameId, this.processor); + }; + + public start = () => { + this.processor(this) + .catch(e => { + // normally no exceptions come to this point so let's report it + Catch.reportErr(e); + this.renderErr(Xss.escape(String(e)), undefined); + }) + .finally(() => this.relay({ done: true })); + }; public renderErr = (errBoxContent: string, renderRawMsg: string | undefined, errMsg?: string | undefined) => { this.relay({ renderErr: { errBoxContent, renderRawMsg, errMsg } }); @@ -78,7 +140,13 @@ export class RenderRelay implements RenderInterface { } }; - private relay = (message: RenderMessage) => { - this.relayManager.relay(this.frameId, message); + private relay = (message: RenderMessage, options?: { progressOperationRendering: true }) => { + if (!this.cancellation.cancel) { + if (!options?.progressOperationRendering) { + // "unsubscribe" from further progress callbacks + this.progressOperation = undefined; + } + this.relayManager.relay(this.frameId, message, options?.progressOperationRendering); + } }; } diff --git a/extension/js/content_scripts/webmail/gmail-element-replacer.ts b/extension/js/content_scripts/webmail/gmail-element-replacer.ts index 744cb7929eb..b7d424c8ed9 100644 --- a/extension/js/content_scripts/webmail/gmail-element-replacer.ts +++ b/extension/js/content_scripts/webmail/gmail-element-replacer.ts @@ -93,7 +93,9 @@ export class GmailElementReplacer implements WebmailElementReplacer { public reinsertReplyBox = (replyMsgId: string) => { const params: FactoryReplyParams = { replyMsgId }; - $('.reply_message_iframe_container:visible').last().append(this.factory.embeddedReply(params, false, true)); // xss-safe-value + $('.reply_message_iframe_container:visible') + .last() + .append(this.factory.embeddedReply(params, false, true)); // xss-safe-value }; public scrollToReplyBox = (replyMsgId: string) => { @@ -192,7 +194,7 @@ export class GmailElementReplacer implements WebmailElementReplacer { if (this.debug) { console.debug('replaceArmoredBlocks() for of emailsContainingPgpBlock -> emailContainer replaced'); } - await this.messageRenderer.startProcessingInlineBlocks(this.relayManager, this.factory, setMessageInfo, blocksInFrames).catch(Catch.reportErr); + this.messageRenderer.startProcessingInlineBlocks(this.relayManager, this.factory, setMessageInfo, blocksInFrames); } }; diff --git a/extension/js/content_scripts/webmail/setup-webmail-content-script.ts b/extension/js/content_scripts/webmail/setup-webmail-content-script.ts index 5f5ce571a51..f220e831448 100644 --- a/extension/js/content_scripts/webmail/setup-webmail-content-script.ts +++ b/extension/js/content_scripts/webmail/setup-webmail-content-script.ts @@ -104,7 +104,7 @@ export const contentScriptSetupIfVacant = async (webmailSpecific: WebmailSpecifi }; const initInternalVars = async (acctEmail: string) => { - const tabId = await BrowserMsg.requiredTabId(30, 1000); // keep trying for 30 seconds + const tabId = await BrowserMsg.requiredTabId(true, 30, 1000); // keep trying for 30 seconds const notifications = new Notifications(); const factory = new XssSafeFactory(acctEmail, tabId, win.reloadable_class, win.destroyable_class); const inject = new Injector(webmailSpecific.name, webmailSpecific.variant, factory); @@ -219,6 +219,12 @@ export const contentScriptSetupIfVacant = async (webmailSpecific: WebmailSpecifi BrowserMsg.addListener('add_pubkey_dialog', async ({ emails }: Bm.AddPubkeyDialog) => { await factory.showAddPubkeyDialog(emails); }); + BrowserMsg.addListener('pgp_block_ready', async ({ frameId, messageSender }: Bm.PgpBlockReady) => { + relayManager.associate(frameId, messageSender); + }); + BrowserMsg.addListener('pgp_block_retry', async ({ frameId, messageSender }: Bm.PgpBlockRetry) => { + relayManager.retry(frameId, messageSender); + }); BrowserMsg.addListener('notification_show', async ({ notification, callbacks, group }: Bm.NotificationShow) => { notifications.show(notification, callbacks, group); $('body').one( @@ -238,6 +244,7 @@ export const contentScriptSetupIfVacant = async (webmailSpecific: WebmailSpecifi relayManager.renderProgress(progress); }); BrowserMsg.listen(tabId); + BrowserMsg.listenForWindowMessages(); // listen for direct messages from child iframes }; const saveAcctEmailFullNameIfNeeded = async (acctEmail: string) => { @@ -420,7 +427,7 @@ export const contentScriptSetupIfVacant = async (webmailSpecific: WebmailSpecifi await showNotificationsAndWaitTilAcctSetUp(acctEmail, notifications); Catch.setHandledTimeout(() => updateClientConfiguration(acctEmail), 0); const ppEvent: { entered?: boolean } = {}; - const relayManager = new RelayManager(tabId); + const relayManager = new RelayManager(); browserMsgListen(acctEmail, tabId, inject, factory, notifications, relayManager, ppEvent); const clientConfiguration = await ClientConfiguration.newInstance(acctEmail); await startPullingKeysFromEkm( @@ -430,11 +437,6 @@ export const contentScriptSetupIfVacant = async (webmailSpecific: WebmailSpecifi ppEvent, Catch.try(() => notifyExpiringKeys(acctEmail, clientConfiguration, notifications)) ); - window.addEventListener('message', e => { - if (e.origin === Env.getExtensionOrigin()) { - relayManager.handleMessageFromFrame(e.data); - } - }); await webmailSpecific.start(acctEmail, clientConfiguration, inject, notifications, factory, notifyMurdered, relayManager); } catch (e) { if (e instanceof TabIdRequiredError) { diff --git a/test/source/browser/controllable.ts b/test/source/browser/controllable.ts index e575de2ac7d..85c0b532c33 100644 --- a/test/source/browser/controllable.ts +++ b/test/source/browser/controllable.ts @@ -503,7 +503,7 @@ abstract class ControllableBase { if (matchingFrames.length > 1) { throw Error(`More than one frame found: ${urlMatchables.join(',')}`); } else if (matchingFrames.length === 1) { - return new ControllableFrame(matchingFrames[0]); + return new ControllableFrame(matchingFrames[0], this.getPage()); } await Util.sleep(1); } @@ -518,7 +518,7 @@ abstract class ControllableBase { const resolvePromise: Promise = (async () => { const downloadPath = path.resolve(__dirname, 'download', Util.lousyRandom()); mkdirp.sync(downloadPath); - const page = 'page' in this.target ? this.target.page() : this.target; + const page = this.getPage().target; // eslint-disable-next-line @typescript-eslint/no-explicit-any, no-underscore-dangle await (page as any)._client().send('Page.setDownloadBehavior', { behavior: 'allow', downloadPath }); if (typeof selector === 'string') { @@ -669,6 +669,8 @@ abstract class ControllableBase { await Util.sleep(0.2); } }; + + public abstract getPage(): ControllablePage; } export class ControllableAlert { @@ -692,16 +694,23 @@ export class ControllableAlert { class ConsoleEvent { // eslint-disable-next-line no-empty-function - public constructor(public type: string, public text: string) {} + public constructor( + public type: string, + public text: string + ) {} } export class ControllablePage extends ControllableBase { + public target: Page; public consoleMsgs: (ConsoleMessage | ConsoleEvent)[] = []; public alerts: ControllableAlert[] = []; private preventclose = false; private acceptUnloadAlert = false; - public constructor(public t: AvaContext, public page: Page) { + public constructor( + public t: AvaContext, + public page: Page + ) { super(page); page.on('console', console => { this.consoleMsgs.push(console); @@ -863,6 +872,10 @@ export class ControllablePage extends ControllableBase { return result as Dict; }; + public getPage = () => { + return this; + }; + private dismissActiveAlerts = async (): Promise => { const activeAlerts = this.alerts.filter(a => a.active); for (const alert of activeAlerts) { @@ -879,12 +892,20 @@ export class ControllablePage extends ControllableBase { } export class ControllableFrame extends ControllableBase { + public target: Frame; public frame: Frame; - public constructor(frame: Frame) { + public constructor( + frame: Frame, + private page: ControllablePage + ) { super(frame); this.frame = frame; } + + public getPage = () => { + return this.page; + }; } export type Controllable = ControllableFrame | ControllablePage; diff --git a/test/source/tests/compose.ts b/test/source/tests/compose.ts index 3a6da930f8c..9182691da95 100644 --- a/test/source/tests/compose.ts +++ b/test/source/tests/compose.ts @@ -156,7 +156,7 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te const forgottenPassphrase = 'this passphrase is forgotten'; await SettingsPageRecipe.addKeyTest(t, browser, acctEmail, testConstants.testKeyMultipleSmimeCEA2D53BB9D24871, forgottenPassphrase, {}, false); const inboxPage = await browser.newExtensionInboxPage(t, acctEmail); - await InboxPageRecipe.finishSessionOnInboxPage(inboxPage); + await BrowserRecipe.finishSession(inboxPage); await inboxPage.close(); await composePage.waitAndType('@input-password', forgottenPassphrase); await composePage.waitAndClick('@action-send', { delay: 1 }); @@ -1254,7 +1254,7 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te false ); const inboxPage = await browser.newPage(t, t.context.urls?.extensionInbox(acctEmail) + '&labelId=DRAFT&debug=___cu_true___'); - await InboxPageRecipe.finishSessionOnInboxPage(inboxPage); + await BrowserRecipe.finishSession(inboxPage); const inboxTabId = await PageRecipe.getTabId(inboxPage); // send message from a different tab await PageRecipe.sendMessage(settingsPage, { @@ -2151,7 +2151,7 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te const passphrase = 'pa$$w0rd'; await SettingsPageRecipe.addKeyTest(t, browser, acctEmail, testConstants.testKeyMultipleSmimeCEA2D53BB9D24871, passphrase, {}, false); const inboxPage = await browser.newExtensionInboxPage(t, acctEmail); - await InboxPageRecipe.finishSessionOnInboxPage(inboxPage); + await BrowserRecipe.finishSession(inboxPage); const composeFrame = await InboxPageRecipe.openAndGetComposeFrame(inboxPage); await ComposePageRecipe.fillMsg( composeFrame, @@ -3043,7 +3043,10 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te await Promise.all( attachmentFrames .filter(f => f.url().includes('attachment.htm')) - .map(async frame => await PageRecipe.getElementPropertyJson(await new ControllableFrame(frame).waitAny('@attachment-name'), 'textContent')) + .map( + async frame => + await PageRecipe.getElementPropertyJson(await new ControllableFrame(frame, composePage).waitAny('@attachment-name'), 'textContent') + ) ) ).to.eql(['small.txt.pgp', 'small.pdf.pgp']); }) diff --git a/test/source/tests/decrypt.ts b/test/source/tests/decrypt.ts index e47171a5325..37483d0bcfb 100644 --- a/test/source/tests/decrypt.ts +++ b/test/source/tests/decrypt.ts @@ -214,7 +214,7 @@ export const defineDecryptTests = (testVariant: TestVariant, testWithBrowser: Te await InboxPageRecipe.checkDecryptMsg(t, browser, { acctEmail, threadId: '184a474fc1bd59b8', - expectedContent: 'This message contained the actual encrypted text inside a "message" attachment.', + content: ['This message contained the actual encrypted text inside a "message" attachment.'], }); }) ); @@ -247,7 +247,7 @@ export const defineDecryptTests = (testVariant: TestVariant, testWithBrowser: Te await InboxPageRecipe.checkDecryptMsg(t, browser, { acctEmail, threadId: '17dbdf2425ac0f29', - expectedContent: 'Documento anexo de prueba.docx', + content: ['Documento anexo de prueba.docx'], }); }) ); @@ -1049,27 +1049,27 @@ XZ8r4OC6sguP/yozWlkG+7dDxsgKQVBENeG6Lw== test( 'decrypt - by entering pass phrase + remember in session', testWithBrowser(async (t, browser) => { - const { acctEmail } = await BrowserRecipe.setupCommonAcctWithAttester(t, browser, 'compatibility'); + const { acctEmail, authHdr } = await BrowserRecipe.setupCommonAcctWithAttester(t, browser, 'compatibility'); const pp = Config.key('flowcrypt.compatibility.1pp1').passphrase; const threadId = '15f7f5630573be2d'; - const expectedContent = 'The International DUBLIN Literary Award is an international literary award'; + const content = ['The International DUBLIN Literary Award is an international literary award']; const settingsPage = await browser.newExtensionSettingsPage(t); await SettingsPageRecipe.forgetAllPassPhrasesInStorage(settingsPage, pp); + const enterPp = { + passphrase: Config.key('flowcrypt.compatibility.1pp1').passphrase, + isForgetPpChecked: true, + isForgetPpHidden: false, + }; + // 1. inbox page test // requires pp entry - await InboxPageRecipe.checkDecryptMsg(t, browser, { - acctEmail, - threadId, - expectedContent, - enterPp: { - passphrase: Config.key('flowcrypt.compatibility.1pp1').passphrase, - isForgetPpChecked: true, - isForgetPpHidden: false, - }, - }); + await InboxPageRecipe.checkDecryptMsg(t, browser, { enterPp, content, acctEmail, threadId }); + // now remembers pp in session + await InboxPageRecipe.checkDecryptMsg(t, browser, { acctEmail, threadId, content, finishSessionAfterTesting: true }); + // 2. gmail page test + // requires pp entry + await BrowserRecipe.pgpBlockVerifyDecryptedContent(t, browser, threadId, { enterPp, content }, authHdr); // now remembers pp in session - await InboxPageRecipe.checkDecryptMsg(t, browser, { acctEmail, threadId, expectedContent }); - // Finish session and check if it's finished - await InboxPageRecipe.checkFinishingSession(t, browser, acctEmail, threadId); + await BrowserRecipe.pgpBlockVerifyDecryptedContent(t, browser, threadId, { content, finishSessionAfterTesting: true }, authHdr); }) ); @@ -2021,6 +2021,7 @@ XZ8r4OC6sguP/yozWlkG+7dDxsgKQVBENeG6Lw== }) ); expect(Object.entries(downloadedFile1).length).to.equal(1); + expect(Object.keys(downloadedFile1)[0]).to.match(/demo.*\.bat/); await pgpBlockPage.waitAndClick('@preview-attachment'); const attachmentPreviewPage = await inboxPage.getFrame(['attachment_preview.htm']); await attachmentPreviewPage.waitAndClick('@attachment-preview-download'); @@ -2032,6 +2033,7 @@ XZ8r4OC6sguP/yozWlkG+7dDxsgKQVBENeG6Lw== }) ); expect(Object.entries(downloadedFile2).length).to.equal(1); + expect(Object.keys(downloadedFile2)[0]).to.match(/demo.*\.bat/); await inboxPage.close(); const inboxPage2 = await browser.newExtensionPage(t, `chrome/settings/inbox/inbox.htm?acctEmail=${acctEmail}&threadId=${threadId2}`); const pgpBlockPage2 = await inboxPage2.getFrame(['pgp_block.htm']); @@ -2044,6 +2046,7 @@ XZ8r4OC6sguP/yozWlkG+7dDxsgKQVBENeG6Lw== }) ); expect(Object.entries(downloadedFile3).length).to.equal(1); + expect(Object.keys(downloadedFile3)[0]).to.match(/demo.*\.bat/); await inboxPage2.close(); const gmailPage = await browser.newPage(t, `${t.context.urls?.mockGmailUrl()}/${threadId}`, undefined, authHdr); await gmailPage.waitAll('iframe'); @@ -2057,6 +2060,7 @@ XZ8r4OC6sguP/yozWlkG+7dDxsgKQVBENeG6Lw== }) ); expect(Object.entries(downloadedFile4).length).to.equal(1); + expect(Object.keys(downloadedFile4)[0]).to.match(/demo.*\.bat/); const attachmentFrame = await gmailPage.getFrame(['attachment.htm']); await attachmentFrame.waitAndClick('@attachment-container'); const attachmentPreviewPage2 = await gmailPage.getFrame(['attachment_preview.htm']); @@ -2069,6 +2073,7 @@ XZ8r4OC6sguP/yozWlkG+7dDxsgKQVBENeG6Lw== }) ); expect(Object.entries(downloadedFile5).length).to.equal(1); + expect(Object.keys(downloadedFile5)[0]).to.match(/demo.*\.bat/); await gmailPage.close(); const gmailPage2 = await browser.newPage(t, `${t.context.urls?.mockGmailUrl()}/${threadId2}`, undefined, authHdr); const pgpBlockPage4 = await gmailPage2.getFrame(['pgp_block.htm']); @@ -2081,6 +2086,7 @@ XZ8r4OC6sguP/yozWlkG+7dDxsgKQVBENeG6Lw== }) ); expect(Object.entries(downloadedFile6).length).to.equal(1); + expect(Object.keys(downloadedFile6)[0]).to.match(/demo.*\.bat/); await gmailPage2.close(); // check warning modal for regular unencrypted attachment on FlowCrypt web extension page const inboxPage3 = await browser.newExtensionPage(t, `chrome/settings/inbox/inbox.htm?acctEmail=${acctEmail}&threadId=${threadId3}`); @@ -2093,6 +2099,7 @@ XZ8r4OC6sguP/yozWlkG+7dDxsgKQVBENeG6Lw== }) ); expect(Object.entries(downloadedFile7).length).to.equal(1); + expect(Object.keys(downloadedFile7)[0]).to.match(/sample.*\.bat/); await attachmentFrame2.waitAndClick('@attachment-container'); const attachmentPreviewPage3 = await inboxPage3.getFrame(['attachment_preview.htm']); await attachmentPreviewPage3.waitAndClick('@attachment-preview-download'); @@ -2103,6 +2110,7 @@ XZ8r4OC6sguP/yozWlkG+7dDxsgKQVBENeG6Lw== }) ); expect(Object.entries(downloadedFile8).length).to.equal(1); + expect(Object.keys(downloadedFile8)[0]).to.match(/sample.*\.bat/); }) ); } diff --git a/test/source/tests/flaky.ts b/test/source/tests/flaky.ts index 79c7b6ee607..214d9aed6d7 100644 --- a/test/source/tests/flaky.ts +++ b/test/source/tests/flaky.ts @@ -412,7 +412,7 @@ export const defineFlakyTests = (testVariant: TestVariant, testWithBrowser: Test ); await settingsPage.notPresent('.swal2-container'); const inboxPage = await browser.newExtensionInboxPage(t, acctEmail); - await InboxPageRecipe.finishSessionOnInboxPage(inboxPage); + await BrowserRecipe.finishSession(inboxPage); const composeFrame = await InboxPageRecipe.openAndGetComposeFrame(inboxPage); await ComposePageRecipe.fillMsg(composeFrame, { to: 'human@flowcrypt.com' }, 'should not send as pass phrase is not known', undefined, { encrypt: false, @@ -607,11 +607,11 @@ export const defineFlakyTests = (testVariant: TestVariant, testWithBrowser: Test await SettingsPageRecipe.addKeyTest(t, browser, acctEmail, testConstants.testkey0389D3A7, passphrase, {}, false); await SettingsPageRecipe.addKeyTest(t, browser, acctEmail, testConstants.testKeyMultipleSmimeCEA2D53BB9D24871, passphrase, {}, false); const inboxPage = await browser.newExtensionInboxPage(t, acctEmail); - await InboxPageRecipe.finishSessionOnInboxPage(inboxPage); + await BrowserRecipe.finishSession(inboxPage); await InboxPageRecipe.checkDecryptMsg(t, browser, { acctEmail, threadId: '17c0e50966d7877c', - expectedContent: '1st key of of 2 keys with the same passphrase', + content: ['1st key of of 2 keys with the same passphrase'], enterPp: { passphrase, isForgetPpChecked: true, @@ -621,7 +621,7 @@ export const defineFlakyTests = (testVariant: TestVariant, testWithBrowser: Test await InboxPageRecipe.checkDecryptMsg(t, browser, { acctEmail, threadId: '17c0e55caaa4abb3', - expectedContent: '2nd key of of 2 keys with the same passphrase', + content: ['2nd key of of 2 keys with the same passphrase'], // passphrase for the 2nd key should not be needed because it's the same as for the 1st key }); // as decrypted s/mime messages are not rendered yet (#4070), let's test signing instead @@ -785,7 +785,7 @@ AfYUJUhqjgSuBctnpj0= while (frames.length !== 50) { frames = (inboxPage.target as Page).frames().filter(frame => frame.url().includes(frameName)); } - await Promise.all(frames.map(frame => new ControllableFrame(frame).waitForSelTestState('ready', 60))); + await Promise.all(frames.map(frame => new ControllableFrame(frame, inboxPage).waitForSelTestState('ready', 60))); const stop = new Date(); const diff = stop.getTime() - start.getTime(); expect(diff).to.be.lessThan(20000); diff --git a/test/source/tests/page-recipe/inbox-page-recipe.ts b/test/source/tests/page-recipe/inbox-page-recipe.ts index bebebc44b3e..9da579eded5 100644 --- a/test/source/tests/page-recipe/inbox-page-recipe.ts +++ b/test/source/tests/page-recipe/inbox-page-recipe.ts @@ -4,16 +4,14 @@ import { BrowserHandle, ControllableFrame, ControllablePage } from '../../browse import { AvaContext } from '../tooling/'; import { PageRecipe } from './abstract-page-recipe'; -import { Util } from '../../util'; -import { expect } from 'chai'; +import { TestMessageAndSession, Util } from '../../util'; +import { BrowserRecipe } from '../tooling/browser-recipe'; type CheckDecryptMsg$opt = { acctEmail: string; threadId: string; - expectedContent: string; - finishCurrentSession?: boolean; - enterPp?: { passphrase: string; isForgetPpHidden?: boolean; isForgetPpChecked?: boolean }; -}; +} & TestMessageAndSession; + type CheckSentMsg$opt = { acctEmail: string; subject: string; @@ -24,68 +22,12 @@ type CheckSentMsg$opt = { }; export class InboxPageRecipe extends PageRecipe { - public static checkDecryptMsg = async ( - t: AvaContext, - browser: BrowserHandle, - { acctEmail, threadId, enterPp, expectedContent, finishCurrentSession }: CheckDecryptMsg$opt - ) => { - const inboxPage = await browser.newExtensionPage(t, `chrome/settings/inbox/inbox.htm?acctEmail=${acctEmail}&threadId=${threadId}`); - await inboxPage.waitAll('iframe'); - if (finishCurrentSession) { - await InboxPageRecipe.finishSessionOnInboxPage(inboxPage); - await inboxPage.waitAll('iframe'); - } - const pgpBlockFrame = await inboxPage.getFrame(['pgp_block.htm']); - await pgpBlockFrame.waitAll('@pgp-block-content'); - await pgpBlockFrame.waitForSelTestState('ready'); - if (enterPp) { - await inboxPage.notPresent('@action-finish-session'); - const errBadgeContent = await pgpBlockFrame.read('@pgp-error'); - expect(errBadgeContent).to.equal('pass phrase needed'); - await pgpBlockFrame.notPresent('@action-print'); - await pgpBlockFrame.waitAndClick('@action-show-passphrase-dialog', { delay: 1 }); - await inboxPage.waitAll('@dialog-passphrase'); - const ppFrame = await inboxPage.getFrame(['passphrase.htm']); - await ppFrame.waitAndType('@input-pass-phrase', enterPp.passphrase); - if (enterPp.isForgetPpHidden !== undefined) { - expect(await ppFrame.hasClass('@forget-pass-phrase-label', 'hidden')).to.equal(enterPp.isForgetPpHidden); - } - if (enterPp.isForgetPpChecked !== undefined) { - expect(await ppFrame.isChecked('@forget-pass-phrase-checkbox')).to.equal(enterPp.isForgetPpChecked); - } - await ppFrame.waitAndClick('@action-confirm-pass-phrase-entry', { delay: 1 }); - await pgpBlockFrame.waitForSelTestState('ready'); - await inboxPage.waitAll('@action-finish-session'); - await Util.sleep(1); - } - await pgpBlockFrame.waitAll('@pgp-error', { visible: false }); - if (!(await pgpBlockFrame.isElementVisible('@action-print'))) { - throw new Error(`Print button is invisible`); - } - const content = await pgpBlockFrame.read('@pgp-block-content'); - if (!content?.includes(expectedContent)) { - throw new Error(`message did not decrypt`); - } + public static checkDecryptMsg = async (t: AvaContext, browser: BrowserHandle, m: CheckDecryptMsg$opt) => { + const inboxPage = await browser.newExtensionPage(t, `chrome/settings/inbox/inbox.htm?acctEmail=${m.acctEmail}&threadId=${m.threadId}`); + await BrowserRecipe.checkDecryptMsgOnPage(t, inboxPage, m); await inboxPage.close(); }; - public static finishSessionOnInboxPage = async (inboxPage: ControllablePage) => { - await inboxPage.waitAndClick('@action-finish-session'); - await inboxPage.waitTillGone('@action-finish-session'); - await Util.sleep(3); // give frames time to reload, else we will be manipulating them while reloading -> Error: waitForFunction failed: frame got detached. - }; - - public static checkFinishingSession = async (t: AvaContext, browser: BrowserHandle, acctEmail: string, threadId: string) => { - const inboxPage = await browser.newExtensionPage(t, `chrome/settings/inbox/inbox.htm?acctEmail=${acctEmail}&threadId=${threadId}`); - await InboxPageRecipe.finishSessionOnInboxPage(inboxPage); - await inboxPage.waitAll('iframe'); - const pgpBlockFrame = await inboxPage.getFrame(['pgp_block.htm']); - await pgpBlockFrame.waitAll('@pgp-block-content'); - await pgpBlockFrame.waitForSelTestState('ready'); - await pgpBlockFrame.waitAndClick('@action-show-passphrase-dialog', { delay: 1 }); - await inboxPage.waitAll('@dialog-passphrase'); - }; - public static checkSentMsg = async ( t: AvaContext, browser: BrowserHandle, diff --git a/test/source/tests/settings.ts b/test/source/tests/settings.ts index 5687d701bbf..63227f56f2a 100644 --- a/test/source/tests/settings.ts +++ b/test/source/tests/settings.ts @@ -561,7 +561,7 @@ export const defineSettingsTests = (testVariant: TestVariant, testWithBrowser: T await InboxPageRecipe.checkDecryptMsg(t, browser, { acctEmail, threadId: '16819bec18d4e011', - expectedContent: 'changed correctly if this can be decrypted', + content: ['changed correctly if this can be decrypted'], }); }) ); @@ -585,7 +585,7 @@ export const defineSettingsTests = (testVariant: TestVariant, testWithBrowser: T isForgetPpChecked: true, isForgetPpHidden: false, }, - expectedContent: 'changed correctly if this can be decrypted', + content: ['changed correctly if this can be decrypted'], }); // change pp - should not ask for pp because already in session await SettingsPageRecipe.changePassphrase(settingsPage, undefined, newPp); @@ -593,19 +593,19 @@ export const defineSettingsTests = (testVariant: TestVariant, testWithBrowser: T await InboxPageRecipe.checkDecryptMsg(t, browser, { acctEmail, threadId: '16819bec18d4e011', - expectedContent: 'changed correctly if this can be decrypted', + content: ['changed correctly if this can be decrypted'], }); // test decrypt - should ask for new pass phrase await InboxPageRecipe.checkDecryptMsg(t, browser, { acctEmail, threadId: '16819bec18d4e011', - finishCurrentSession: true, + finishSessionBeforeTesting: true, enterPp: { passphrase: newPp, isForgetPpChecked: true, isForgetPpHidden: false, }, - expectedContent: 'changed correctly if this can be decrypted', + content: ['changed correctly if this can be decrypted'], }); }) ); @@ -638,9 +638,9 @@ export const defineSettingsTests = (testVariant: TestVariant, testWithBrowser: T await InboxPageRecipe.checkDecryptMsg(t, browser, { acctEmail, threadId: '179f6feb575df213', - finishCurrentSession: true, + finishSessionBeforeTesting: true, enterPp: { passphrase, isForgetPpHidden: true, isForgetPpChecked: true }, - expectedContent: 'changed correctly if this can be decrypted', + content: ['changed correctly if this can be decrypted'], }); const { cryptup_userforbidstoringpassphraseclientconfigurationflowcrypttest_passphrase_B8F687BCDE14435A: savedPassphrase2 } = await settingsPage.getFromLocalStorage(['cryptup_userforbidstoringpassphraseclientconfigurationflowcrypttest_passphrase_B8F687BCDE14435A']); @@ -654,15 +654,15 @@ export const defineSettingsTests = (testVariant: TestVariant, testWithBrowser: T await InboxPageRecipe.checkDecryptMsg(t, browser, { acctEmail, threadId: '179f6feb575df213', - expectedContent: 'changed correctly if this can be decrypted', + content: ['changed correctly if this can be decrypted'], }); // test decrypt - should ask for new pass phrase await InboxPageRecipe.checkDecryptMsg(t, browser, { acctEmail, threadId: '179f6feb575df213', - finishCurrentSession: true, + finishSessionBeforeTesting: true, enterPp: { passphrase: newPp, isForgetPpHidden: true, isForgetPpChecked: true }, - expectedContent: 'changed correctly if this can be decrypted', + content: ['changed correctly if this can be decrypted'], }); }) ); @@ -683,14 +683,14 @@ export const defineSettingsTests = (testVariant: TestVariant, testWithBrowser: T await InboxPageRecipe.checkDecryptMsg(t, browser, { acctEmail, threadId: '16819bec18d4e011', - expectedContent: 'changed correctly if this can be decrypted', + content: ['changed correctly if this can be decrypted'], }); // test decrypt - should ask for new pass phrase await InboxPageRecipe.checkDecryptMsg(t, browser, { acctEmail, threadId: '16819bec18d4e011', - expectedContent: 'changed correctly if this can be decrypted', - finishCurrentSession: true, + content: ['changed correctly if this can be decrypted'], + finishSessionBeforeTesting: true, enterPp: { passphrase: newPp, isForgetPpChecked: true, isForgetPpHidden: false }, }); }) @@ -1207,7 +1207,7 @@ export const defineSettingsTests = (testVariant: TestVariant, testWithBrowser: T ); await settingsPage.close(); const inboxPage = await browser.newExtensionPage(t, `chrome/settings/inbox/inbox.htm?acctEmail=${acctEmail}`); - await InboxPageRecipe.finishSessionOnInboxPage(inboxPage); + await BrowserRecipe.finishSession(inboxPage); await SettingsPageRecipe.addKeyTest(t, browser, acctEmail, testConstants.testKeyMultiple98acfa1eadab5b92, '1234', { isSavePassphraseChecked: true, isSavePassphraseHidden: false, @@ -1264,7 +1264,7 @@ export const defineSettingsTests = (testVariant: TestVariant, testWithBrowser: T { isSavePassphraseChecked: false, isSavePassphraseHidden: false } ); const inboxPage = await browser.newExtensionPage(t, `chrome/settings/inbox/inbox.htm?acctEmail=${acctEmail}`); - await InboxPageRecipe.finishSessionOnInboxPage(inboxPage); + await BrowserRecipe.finishSession(inboxPage); await inboxPage.close(); const key98acfa1eadab5b92 = await KeyUtil.parse(testConstants.testKeyMultiple98acfa1eadab5b92); expect(await KeyUtil.decrypt(key98acfa1eadab5b92, '1234')).to.equal(true); @@ -1307,7 +1307,7 @@ export const defineSettingsTests = (testVariant: TestVariant, testWithBrowser: T { isSavePassphraseChecked: false, isSavePassphraseHidden: false } ); const inboxPage = await browser.newExtensionPage(t, `chrome/settings/inbox/inbox.htm?acctEmail=${acctEmail}`); - await InboxPageRecipe.finishSessionOnInboxPage(inboxPage); + await BrowserRecipe.finishSession(inboxPage); await inboxPage.close(); await settingsPage.waitAndClick('@action-open-backup-page'); const backupFrame = await settingsPage.getFrame(['backup.htm']); diff --git a/test/source/tests/setup.ts b/test/source/tests/setup.ts index fbd436c0580..53d7eabcf2e 100644 --- a/test/source/tests/setup.ts +++ b/test/source/tests/setup.ts @@ -12,7 +12,6 @@ import { Str, emailKeyIndex } from './../core/common'; import { BrowserRecipe } from './tooling/browser-recipe'; import { Key, KeyInfoWithIdentity, KeyUtil } from '../core/crypto/key'; import { testConstants } from './tooling/consts'; -import { InboxPageRecipe } from './page-recipe/inbox-page-recipe'; import { PageRecipe } from './page-recipe/abstract-page-recipe'; import { BrowserHandle, ControllablePage } from '../browser'; import { OauthPageRecipe } from './page-recipe/oauth-page-recipe'; @@ -1488,7 +1487,7 @@ AN8G3r5Htj8olot+jm9mIa5XLXWzMNUZgg== const set4 = await retrieveAndCheckKeys(settingsPage, acct, 1); expect(set4[0].lastModified).to.equal(set3[0].lastModified); // no update // 4. Forget the passphrase, EKM the same version of the existing key, no prompt - await InboxPageRecipe.finishSessionOnInboxPage(gmailPage); + await BrowserRecipe.finishSession(gmailPage); await gmailPage.close(); gmailPage = await browser.newMockGmailPage(t, extraAuthHeaders); await PageRecipe.noToastAppears(gmailPage); @@ -1552,7 +1551,7 @@ AN8G3r5Htj8olot+jm9mIa5XLXWzMNUZgg== // eslint-disable-next-line @typescript-eslint/no-non-null-assertion expect(mainKey10[0].lastModified!).to.be.greaterThan(mainKey9[0].lastModified!); // updated this key // 10. Forget the passphrase, EKM returns a third key, we enter a passphrase that doesn't match any of the existing keys, no update - await InboxPageRecipe.finishSessionOnInboxPage(gmailPage); + await BrowserRecipe.finishSession(gmailPage); await gmailPage.close(); t.context.mockApi!.configProvider.config.ekm!.keys = [testConstants.unprotectedPrvKey]; gmailPage = await browser.newMockGmailPage(t, extraAuthHeaders); @@ -1587,7 +1586,7 @@ AN8G3r5Htj8olot+jm9mIa5XLXWzMNUZgg== const mainKey12 = KeyUtil.filterKeysByIdentity(set12, [{ family: 'openpgp', id: '277D1ADA213881F4ABE0415395E783DC0289E2E2' }]); expect(mainKey12.length).to.equal(1); // 12. Forget the passphrase, EKM sends a broken key, no passphrase dialog, no updates - await InboxPageRecipe.finishSessionOnInboxPage(gmailPage); + await BrowserRecipe.finishSession(gmailPage); await gmailPage.close(); t.context.mockApi!.configProvider.config.ekm!.keys = [ await updateAndArmorKey(set2[0]), diff --git a/test/source/tests/tooling/browser-recipe.ts b/test/source/tests/tooling/browser-recipe.ts index c29075c3c8d..9008fa1bbc4 100644 --- a/test/source/tests/tooling/browser-recipe.ts +++ b/test/source/tests/tooling/browser-recipe.ts @@ -1,6 +1,6 @@ /* ©️ 2016 - present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com */ -import { Config, Util, TestMessage } from '../../util'; +import { Config, Util, TestMessage, TestMessageAndSession } from '../../util'; import { AvaContext } from '.'; import { BrowserHandle, Controllable, ControllableFrame, ControllablePage } from '../../browser'; @@ -255,34 +255,86 @@ export class BrowserRecipe { return { acctEmail, passphrase: key.passphrase, settingsPage }; }; + // todo: move to gmail-page-recipe public static pgpBlockVerifyDecryptedContent = async ( t: AvaContext, browser: BrowserHandle, msgId: string, - m: TestMessage, + m: TestMessageAndSession, extraHeaders: Record ) => { const gmailPage = await browser.newPage(t, `${t.context.urls?.mockGmailUrl()}/${msgId}`, undefined, extraHeaders); - await gmailPage.waitAll('iframe'); - await BrowserRecipe.pgpBlockCheck(t, await gmailPage.getFrame(['pgp_block.htm']), m); + await BrowserRecipe.checkDecryptMsgOnPage(t, gmailPage, m); await gmailPage.close(); }; - public static pgpBlockCheck = async (t: AvaContext, pgpBlockPage: ControllableFrame, m: TestMessage) => { + // todo: move some of these helpers somewhere to page-recipe/... + // gmail or inbox + public static checkDecryptMsgOnPage = async (t: AvaContext, page: ControllablePage, m: TestMessageAndSession) => { + await page.waitAll('iframe'); + if (m.finishSessionBeforeTesting) { + await BrowserRecipe.finishSession(page); + await page.waitAll('iframe'); + } + await BrowserRecipe.pgpBlockCheck(t, await page.getFrame(['pgp_block.htm']), m); + if (m.finishSessionAfterTesting) { + await BrowserRecipe.finishSession(page); + await page.waitAll('iframe'); + const pgpBlockFrame = await page.getFrame(['pgp_block.htm']); + await pgpBlockFrame.waitAll('@pgp-block-content'); + await pgpBlockFrame.waitForSelTestState('ready'); + await pgpBlockFrame.waitAndClick('@action-show-passphrase-dialog', { delay: 1 }); + await page.waitAll('@dialog-passphrase'); + } + }; + + // gmail or inbox + public static finishSession = async (page: ControllablePage) => { + await page.waitAndClick('@action-finish-session'); + await page.waitTillGone('@action-finish-session'); + await Util.sleep(3); // give frames time to reload, else we will be manipulating them while reloading -> Error: waitForFunction failed: frame got detached. + }; + + // todo: move to page-recipe/pgp-block-frame-recipe or frame-recipe/pgp-block-frame-recipe? + public static pgpBlockCheck = async (t: AvaContext, pgpBlockFrame: ControllableFrame, m: TestMessage) => { if (m.expectPercentageProgress) { - await pgpBlockPage.waitForContent('@pgp-block-content', /Retrieving message... \d+%/, 20, 10); + await pgpBlockFrame.waitForContent('@pgp-block-content', /Retrieving message... \d+%/, 20, 10); + } else { + await pgpBlockFrame.waitAll('@pgp-block-content'); } - await pgpBlockPage.waitForSelTestState('ready', 100); + await pgpBlockFrame.waitForSelTestState('ready', 100); await Util.sleep(1); + if (m.enterPp) { + const page = pgpBlockFrame.getPage(); + await page.notPresent('@action-finish-session'); + const errBadgeContent = await pgpBlockFrame.read('@pgp-error'); + expect(errBadgeContent).to.equal('pass phrase needed'); + await pgpBlockFrame.notPresent('@action-print'); + await pgpBlockFrame.waitAndClick('@action-show-passphrase-dialog', { delay: 1 }); + await page.waitAll('@dialog-passphrase'); + const ppFrame = await page.getFrame(['passphrase.htm']); + await ppFrame.waitAndType('@input-pass-phrase', m.enterPp.passphrase); + if (m.enterPp.isForgetPpHidden !== undefined) { + expect(await ppFrame.hasClass('@forget-pass-phrase-label', 'hidden')).to.equal(m.enterPp.isForgetPpHidden); + } + if (m.enterPp.isForgetPpChecked !== undefined) { + expect(await ppFrame.isChecked('@forget-pass-phrase-checkbox')).to.equal(m.enterPp.isForgetPpChecked); + } + await ppFrame.waitAndClick('@action-confirm-pass-phrase-entry', { delay: 1 }); + await pgpBlockFrame.waitForSelTestState('ready'); + await page.waitAll('@action-finish-session'); // todo: gmail + await Util.sleep(1); + } + if (m.quoted) { - await pgpBlockPage.waitAndClick('@action-show-quoted-content'); + await pgpBlockFrame.waitAndClick('@action-show-quoted-content'); await Util.sleep(1); } else { - if (await pgpBlockPage.isElementPresent('@action-show-quoted-content')) { + if (await pgpBlockFrame.isElementPresent('@action-show-quoted-content')) { throw new Error(`element: @action-show-quoted-content not expected in: ${t.title}`); } } - const content = await pgpBlockPage.read('@pgp-block-content'); + const content = await pgpBlockFrame.read('@pgp-block-content'); for (const expectedContent of m.content) { if (!content?.includes(expectedContent)) { throw new Error(`pgp_block_verify_decrypted_content:missing expected content: ${expectedContent}` + `\nactual content: ${content}`); @@ -296,8 +348,8 @@ export class BrowserRecipe { } } } - const sigBadgeContent = await pgpBlockPage.read('@pgp-signature'); - const encBadgeContent = await pgpBlockPage.read('@pgp-encryption'); + const sigBadgeContent = await pgpBlockFrame.read('@pgp-signature'); + const encBadgeContent = await pgpBlockFrame.read('@pgp-encryption'); if (m.signature) { // todo: check color, 'signed' should have 'green_label' class without 'red_label', others should have 'red_label' class if (sigBadgeContent !== m.signature) { @@ -320,15 +372,18 @@ export class BrowserRecipe { if (m.error) { expect(sigBadgeContent).to.be.empty; expect(encBadgeContent).to.be.empty; - await pgpBlockPage.notPresent('@action-print'); - const errBadgeContent = await pgpBlockPage.read('@pgp-error'); + await pgpBlockFrame.notPresent('@action-print'); + const errBadgeContent = await pgpBlockFrame.read('@pgp-error'); if (errBadgeContent !== m.error) { t.log(`found err content:${errBadgeContent}`); throw new Error(`pgp_block_verify_decrypted_content:missing expected error content:${m.error}`); } - } else if (m.content.length > 0) { - if (!(await pgpBlockPage.isElementVisible('@action-print'))) { - throw new Error(`Print button is invisible`); + } else { + await pgpBlockFrame.waitAll('@pgp-error', { visible: false }); + if (m.content.length > 0) { + if (!(await pgpBlockFrame.isElementVisible('@action-print'))) { + throw new Error(`Print button is invisible`); + } } } }; diff --git a/test/source/util/index.ts b/test/source/util/index.ts index dd61edefb1e..9c9657e4211 100644 --- a/test/source/util/index.ts +++ b/test/source/util/index.ts @@ -44,6 +44,12 @@ export type TestMessage = { signature?: string; encryption?: string; error?: string; + enterPp?: { passphrase: string; isForgetPpHidden?: boolean; isForgetPpChecked?: boolean }; +}; + +export type TestMessageAndSession = TestMessage & { + finishSessionBeforeTesting?: boolean; // finish session before testing pgp_block + finishSessionAfterTesting?: boolean; // finish session after testing pgp_block and test that pgp_block now requires a passphrase }; export type TestKeyInfo = {