99 GetCapabilitiesRequest ,
1010 RevokePermissionsRequest ,
1111} from 'src/app/features/dappRequests/types/DappRequestTypes'
12- import { focusOrCreateOnboardingTab } from 'src/app/navigation/utils'
12+ import { focusOrCreateDappRequestWindow , focusOrCreateOnboardingTab } from 'src/app/navigation/utils'
1313import {
1414 DappBackgroundPortChannel ,
1515 contentScriptToBackgroundMessageChannel ,
@@ -22,7 +22,6 @@ import {
2222 ContentScriptUtilityMessageType ,
2323 DappRequestMessage ,
2424} from 'src/background/messagePassing/types/requests'
25- import { openSidePanel } from 'src/background/utils/chromeSidePanelUtils'
2625import { checkAreMigrationsPending , readReduxStateFromStorage } from 'src/background/utils/persistedStateUtils'
2726import { getFeatureFlaggedChainIds } from 'uniswap/src/features/chains/hooks/useFeatureFlaggedChainIds'
2827import { getEnabledChains , hexadecimalStringToInt , toSupportedChainId } from 'uniswap/src/features/chains/utils'
@@ -37,6 +36,20 @@ import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring'
3736import { walletContextValue } from 'wallet/src/features/wallet/context'
3837import { selectHasSmartWalletConsent } from 'wallet/src/features/wallet/selectors'
3938
39+ // Request classification constants for determining which requests need user interaction
40+ const REQUEST_CLASSIFICATION = {
41+ interactive : new Set ( [
42+ DappRequestType . RequestAccount ,
43+ DappRequestType . SendTransaction ,
44+ DappRequestType . SignMessage ,
45+ DappRequestType . SignTypedData ,
46+ DappRequestType . UniswapOpenSidebar ,
47+ DappRequestType . RequestPermissions ,
48+ DappRequestType . SendCalls ,
49+ ] ) ,
50+ silent : new Set ( [ DappRequestType . ChangeChain , DappRequestType . RevokePermissions , DappRequestType . GetCapabilities ] ) ,
51+ } as const
52+
4053const INACTIVITY_ALARM_NAME = 'inactivity'
4154// TODO(EXT-546): add a setting to turn off the auto-lock setting
4255const INACTIVITY_TIMEOUT_MINUTES = 60 * 24 // 1 day
@@ -93,39 +106,79 @@ export function initMessageBridge(): void {
93106 return
94107 }
95108
96- contentScriptToBackgroundMessageChannel . addAllMessageListener ( async ( message , sender ) => {
97- // The side panel needs to be opened here because it has to be in response to a user action.
98- // Further down in the chain it will be opened in response to a message from the background script.
109+ contentScriptToBackgroundMessageChannel . addAllMessageListener ( ( message , sender ) => {
110+ // CRITICAL: This listener must NOT be async to preserve user gesture context.
111+ // Chrome's sidePanel.open() API requires execution within ~1ms of a user gesture.
112+ // Using async/await here breaks the gesture context and causes the error:
113+ // "sidePanel.open() may only be called in response to a user gesture"
99114
100- if ( sender ?. tab ?. id === undefined || sender . tab . url === undefined ) {
115+ // Validate sender has required information
116+ if ( ! isValidSender ( sender ) ) {
101117 logger . error ( new Error ( 'sender.tab id or url is not defined' ) , {
102118 tags : {
103- file : 'background/background .ts' ,
119+ file : 'backgroundDappRequests .ts' ,
104120 function : 'dappMessageListener' ,
105121 } ,
106122 } )
107123 return
108124 }
109125
110- const senderTabInfo = {
111- id : sender . tab . id ,
112- url : sender . tab . url ,
113- favIconUrl : sender . tab . favIconUrl ,
114- }
115-
116- const isSidebarActive = Boolean ( windowIdToSidebarPortMap . get ( sender . tab . windowId . toString ( ) ) )
117- if ( ! isSidebarActive ) {
118- const handled = await handleSilentBackgroundRequest ( message , senderTabInfo )
119- if ( handled ) {
120- return
121- }
126+ const requestType = message . type
127+ const windowId = sender . tab . windowId
128+ const windowIdString = windowId . toString ( )
129+ const isSidebarActive = Boolean ( windowIdToSidebarPortMap . get ( windowIdString ) )
130+
131+ // CRITICAL: Open side panel synchronously to preserve user gesture context.
132+ // This must happen immediately, before any async operations.
133+ if ( requiresSidePanel ( requestType ) && ! isSidebarActive ) {
134+ openSidePanelSync ( {
135+ tabId : sender . tab . id ,
136+ windowId,
137+ onSuccess : ( ) => {
138+ // Process request after panel opens (async operations safe here)
139+ handleRequestAsync ( { message, sender } )
140+ } ,
141+ onError : ( error , fallbackOpened ) => {
142+ // Panel failed to open, but fallback might have succeeded
143+ logger . error ( error , {
144+ tags : {
145+ file : 'backgroundDappRequests.ts' ,
146+ function : 'initMessageBridge' ,
147+ } ,
148+ extra : {
149+ action : 'openSidePanel' ,
150+ fallbackOpened,
151+ } ,
152+ } )
153+
154+ // Revalidate sender in error callback context
155+ if ( ! isValidSender ( sender ) ) {
156+ logger . error ( new Error ( 'Sender tab info unexpectedly invalid in error callback' ) , {
157+ tags : {
158+ file : 'backgroundDappRequests.ts' ,
159+ function : 'initMessageBridge' ,
160+ } ,
161+ } )
162+ return
163+ }
164+
165+ // Queue the message for when panel/popup eventually connects
166+ // This works for both side panel and popup window
167+ queueMessageForPanel ( {
168+ windowId,
169+ message,
170+ senderTabInfo : {
171+ id : sender . tab . id ,
172+ url : sender . tab . url ,
173+ favIconUrl : sender . tab . favIconUrl ,
174+ } ,
175+ } )
176+ } ,
177+ } )
178+ } else {
179+ // Non-interactive request or panel already open - async handling is safe
180+ handleRequestAsync ( { message, sender } )
122181 }
123-
124- await handleSidebarRequest ( {
125- request : message ,
126- windowId : sender . tab . windowId ,
127- senderTabInfo,
128- } )
129182 } )
130183
131184 contentScriptUtilityMessageChannel . addMessageListener ( ContentScriptUtilityMessageType . ErrorLog , async ( message ) => {
@@ -315,10 +368,58 @@ async function handleGetCapabilities({
315368 }
316369}
317370
318- class ExpectedNoPortError extends Error {
319- constructor ( ) {
320- super ( 'No port in storage to post message to' )
371+ /**
372+ * Handles dapp requests asynchronously after the side panel has been opened (if needed).
373+ * This function contains the original async logic that was previously in the message listener.
374+ * Moving it here allows us to open the side panel synchronously while preserving all existing behavior.
375+ */
376+ async function handleRequestAsync ( {
377+ message,
378+ sender,
379+ } : {
380+ message : DappRequest
381+ sender : chrome . runtime . MessageSender
382+ } ) : Promise < void > {
383+ // Revalidate sender
384+ if ( ! isValidSender ( sender ) ) {
385+ logger . error ( new Error ( 'Invalid sender tab info in handleRequestAsync' ) , {
386+ tags : {
387+ file : 'backgroundDappRequests.ts' ,
388+ function : 'handleRequestAsync' ,
389+ } ,
390+ extra : {
391+ hasTab : ! ! sender . tab ,
392+ hasId : sender . tab ?. id !== undefined ,
393+ hasUrl : ! ! sender . tab ?. url ,
394+ } ,
395+ } )
396+ return
397+ }
398+
399+ const senderTabInfo : SenderTabInfo = {
400+ id : sender . tab . id ,
401+ url : sender . tab . url ,
402+ favIconUrl : sender . tab . favIconUrl ,
403+ }
404+
405+ const windowId = sender . tab . windowId
406+ const windowIdString = windowId . toString ( )
407+ const isSidebarActive = Boolean ( windowIdToSidebarPortMap . get ( windowIdString ) )
408+
409+ // Try to handle silently if sidebar is not active
410+ if ( ! isSidebarActive ) {
411+ const handled = await handleSilentBackgroundRequest ( message , senderTabInfo )
412+ if ( handled ) {
413+ return
414+ }
321415 }
416+
417+ // Handle via sidebar (queue message for processing)
418+ await handleSidebarRequest ( {
419+ request : message ,
420+ windowId,
421+ senderTabInfo,
422+ } )
322423}
323424
324425async function handleSidebarRequest ( {
@@ -339,25 +440,111 @@ async function handleSidebarRequest({
339440 isSidebarClosed : ! portChannel ,
340441 }
341442
342- try {
343- if ( ! portChannel ) {
344- throw new ExpectedNoPortError ( )
345- }
346-
347- await portChannel . sendMessage ( message )
348- } catch ( error ) {
349- await openSidePanel ( senderTabInfo . id , windowId )
350-
351- windowIdToPendingRequestsMap . set ( windowIdString , windowIdToPendingRequestsMap . get ( windowIdString ) ?? [ ] )
352- windowIdToPendingRequestsMap . get ( windowIdString ) ?. push ( message )
353-
354- if ( ! ( error instanceof ExpectedNoPortError ) ) {
443+ if ( portChannel ) {
444+ // Port exists, send message directly
445+ try {
446+ await portChannel . sendMessage ( message )
447+ } catch ( error ) {
355448 logger . error ( error , {
356449 tags : {
357450 file : 'backgroundDappRequests.ts' ,
358451 function : 'handleSidebarRequest' ,
359452 } ,
360453 } )
454+ // Queue message if send fails
455+ queueMessageForPanel ( { windowId, message : request , senderTabInfo } )
361456 }
457+ } else {
458+ // IMPORTANT: No port channel means the panel is opening or about to open.
459+ // We do NOT call openSidePanel here because it was already opened synchronously
460+ // in the message listener to preserve the user gesture context.
461+ // Just queue the message - it will be processed when the panel connects.
462+ queueMessageForPanel ( { windowId, message : request , senderTabInfo } )
463+ }
464+ }
465+
466+ /**
467+ * Determines if a request requires the side panel to be opened for user interaction
468+ */
469+ function requiresSidePanel ( requestType : DappRequestType ) : boolean {
470+ return REQUEST_CLASSIFICATION . interactive . has ( requestType )
471+ }
472+
473+ /**
474+ * Validates that the sender has all required tab information
475+ */
476+ function isValidSender ( sender ?: chrome . runtime . MessageSender ) : sender is chrome . runtime . MessageSender & {
477+ tab : chrome . tabs . Tab & { id : number ; url : string }
478+ } {
479+ return sender ?. tab ?. id !== undefined && sender . tab . url !== undefined
480+ }
481+
482+ /**
483+ * Opens the side panel synchronously to preserve user gesture context.
484+ * Must be called within ~1ms of user gesture.
485+ * Falls back to opening a popup window if side panel fails.
486+ */
487+ function openSidePanelSync ( {
488+ tabId,
489+ windowId,
490+ onSuccess,
491+ onError,
492+ } : {
493+ tabId : number
494+ windowId : number
495+ onSuccess : ( ) => void
496+ onError : ( error : chrome . runtime . LastError , fallbackOpened : boolean ) => void
497+ } ) : void {
498+ chrome . sidePanel . open ( { tabId } , ( ) => {
499+ const lastError = chrome . runtime . lastError
500+ if ( lastError ) {
501+ // Try fallback to popup window - still in sync callback to preserve gesture
502+ focusOrCreateDappRequestWindow ( tabId , windowId )
503+ . then ( ( ) => {
504+ // Fallback succeeded - notify that we opened a window instead
505+ onError ( lastError , true )
506+ } )
507+ . catch ( ( fallbackError ) => {
508+ // Even fallback failed
509+ logger . error ( fallbackError , {
510+ tags : {
511+ file : 'backgroundDappRequests.ts' ,
512+ function : 'openSidePanelSync' ,
513+ } ,
514+ extra : { action : 'fallbackToPopupWindow' } ,
515+ } )
516+ onError ( lastError , false )
517+ } )
518+ } else {
519+ onSuccess ( )
520+ }
521+ } )
522+ }
523+
524+ /**
525+ * Queues a message for processing when the side panel connects
526+ */
527+ function queueMessageForPanel ( {
528+ windowId,
529+ message,
530+ senderTabInfo,
531+ } : {
532+ windowId : number
533+ message : DappRequest
534+ senderTabInfo : SenderTabInfo
535+ } ) : void {
536+ const windowIdString = windowId . toString ( )
537+
538+ if ( ! windowIdToPendingRequestsMap . has ( windowIdString ) ) {
539+ windowIdToPendingRequestsMap . set ( windowIdString , [ ] )
362540 }
541+
542+ const queuedMessage : DappRequestMessage = {
543+ type : BackgroundToSidePanelRequestType . DappRequestReceived ,
544+ dappRequest : message ,
545+ senderTabInfo,
546+ isSidebarClosed : true ,
547+ }
548+
549+ windowIdToPendingRequestsMap . get ( windowIdString ) ?. push ( queuedMessage )
363550}
0 commit comments