Skip to content

Commit

Permalink
fix: broadcast channel only on desktop
Browse files Browse the repository at this point in the history
  • Loading branch information
IsaccoSordo committed Sep 24, 2024
1 parent 9932af0 commit c97d833
Show file tree
Hide file tree
Showing 7 changed files with 100 additions and 115 deletions.
51 changes: 44 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/beacon-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@stablelib/nacl": "^1.0.4",
"@stablelib/utf8": "^1.0.1",
"@stablelib/x25519-session": "^1.0.4",
"broadcast-channel": "^7.0.0",
"bs58check": "2.1.2"
}
}
148 changes: 49 additions & 99 deletions packages/beacon-core/src/utils/multi-tab-channel.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { BeaconMessageType, StorageKey } from '@airgap/beacon-types'
import { Logger } from './Logger'
import { LocalStorage } from '../storage/LocalStorage'
import { Logger } from '@airgap/beacon-core'
import { BeaconMessageType } from '@airgap/beacon-types'
import { createLeaderElection, BroadcastChannel, LeaderElector } from 'broadcast-channel'

type BCMessageType =
| 'HEARTBEAT'
| 'HEARTBEAT_ACK'
| 'LEADER_DEAD'
| 'RESPONSE'
| 'DISCONNECT'
| 'REQUEST_PAIRING'
Expand All @@ -21,141 +20,92 @@ type BCMessage = {
data?: any
}

const timeout = 1000 // ms
const logger = new Logger('MultiTabChannel')
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
)

export class MultiTabChannel {
private id: string = String(Date.now())
private leaderID: string = ''
private neighborhood: Set<string> = new Set()
private channel: BroadcastChannel
private elector: LeaderElector
private eventListeners = [
() => this.onBeforeUnloadHandler(),
(message: any) => this.onMessageHandler(message)
]
private onBCMessageHandler: Function
private onElectedLeaderHandler: Function
private pendingACKs: Map<string, NodeJS.Timeout> = new Map()
private storage: LocalStorage = new LocalStorage()

isLeader: boolean = false

private messageHandlers: {
[key in BCMessageType]?: (data: BCMessage) => void
} = {
HEARTBEAT: this.heartbeatHandler.bind(this),
HEARTBEAT_ACK: this.heartbeatACKHandler.bind(this)
}
// Auxiliary variable needed for handling beforeUnload.
// Closing a tab causes the elector to be killed immediately
private wasLeader: boolean = false

constructor(name: string, onBCMessageHandler: Function, onElectedLeaderHandler: Function) {
this.onBCMessageHandler = onBCMessageHandler
this.onElectedLeaderHandler = onElectedLeaderHandler
this.channel = new BroadcastChannel(name)
this.elector = createLeaderElection(this.channel)
this.init()
.then(() => logger.debug('MultiTabChannel', 'constructor', 'init', 'done'))
.catch((err) => logger.warn(err.message))
}

private async init() {
this.storage.subscribeToStorageChanged(async (event) => {
if (
event.eventType === 'entryModified' &&
event.key === this.storage.getPrefixedKey(StorageKey.BC_NEIGHBORHOOD)
) {
const newNeighborhood = !event.newValue ? this.neighborhood : JSON.parse(event.newValue)

if (newNeighborhood[0] !== this.leaderID) {
this.leaderElection()
} else {
clearTimeout(this.pendingACKs.get(this.leaderID))
}

this.neighborhood = newNeighborhood
}
})
await this.requestLeadership()
}
if (isMobile) {
throw new Error('BroadcastChannel is not fully supported on mobile.')
}

private async requestLeadership() {
const neighborhood = await this.storage.get(StorageKey.BC_NEIGHBORHOOD)
const hasLeader = await this.elector.hasLeader()

if (!neighborhood.length) {
this.isLeader = true
logger.log('The current tab is the leader.')
if (!hasLeader) {
await this.elector.awaitLeadership()
this.wasLeader = this.isLeader()
this.wasLeader && logger.log('The current tab is the leader.')
}

neighborhood.push(this.id)
this.leaderID = neighborhood[0]
this.neighborhood = new Set(neighborhood)
this.storage.set(StorageKey.BC_NEIGHBORHOOD, neighborhood)

window?.addEventListener('beforeunload', this.eventListeners[0])
this.channel.onmessage = this.eventListeners[1]

this.initHeartbeat()
window?.addEventListener('beforeunload', this.eventListeners[0])
}

private initHeartbeat() {
if (
this.isLeader ||
!/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
) {
return
private async onBeforeUnloadHandler() {
if (this.wasLeader) {
await this.elector.die()
this.postMessage({ type: 'LEADER_DEAD' })
}

setInterval(() => {
this.leaderElection(Array.from(this.neighborhood).filter((id) => id !== this.leaderID))
this.postMessage({ type: 'HEARTBEAT' })
}, timeout * 2)
window?.removeEventListener('beforeunload', this.eventListeners[0])
this.channel.removeEventListener('message', this.eventListeners[1])
}

private heartbeatHandler() {
if (!this.isLeader) {
private async onMessageHandler(message: BCMessage) {
if (message.recipient && message.recipient !== this.id) {
return
}
this.postMessage({ type: 'HEARTBEAT_ACK' })
}

private heartbeatACKHandler() {
this.pendingACKs.delete(this.leaderID)
}
if (message.type === 'LEADER_DEAD') {
await this.elector.awaitLeadership()

this.wasLeader = this.isLeader()

private leaderElection(neighborhood = Array.from(this.neighborhood)) {
this.pendingACKs.set(
this.leaderID,
setTimeout(() => {
this.leaderID = neighborhood[0]
if (neighborhood[0] !== this.id) {
return
}
this.isLeader = true
if (this.isLeader()) {
this.onElectedLeaderHandler()
logger.log('The current tab is the leader.')
}, timeout)
)
}
return
}

this.onBCMessageHandler(message)
}

private async onBeforeUnloadHandler() {
const oldNeighborhood = this.neighborhood
const newNeighborhood = new Set(this.neighborhood)
newNeighborhood.delete(this.id)

await this.storage.set(StorageKey.BC_NEIGHBORHOOD, Array.from(newNeighborhood))
this.neighborhood = newNeighborhood

// We can't immediately say that a child or the leader is dead
// beacause, on mobile a browser tab gets unloaded every time it no longer has focus
setTimeout(() => {
this.storage.set(StorageKey.BC_NEIGHBORHOOD, Array.from(oldNeighborhood))
this.neighborhood = oldNeighborhood
}, timeout / 2)
isLeader(): boolean {
return this.elector.isLeader
}

private onMessageHandler({ data }: { data: BCMessage }) {
const handler = this.messageHandlers[data.type]
if (handler) {
handler(data)
} else {
this.onBCMessageHandler(data)
}
async getLeadership() {
return this.elector.awaitLeadership()
}

async hasLeader(): Promise<boolean> {
return this.elector.hasLeader()
}

postMessage(message: Omit<BCMessage, 'sender'>): void {
Expand Down
12 changes: 6 additions & 6 deletions packages/beacon-dapp/src/dapp-client/DAppClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,7 @@ export class DAppClient extends Client {
}

private async handlePairingRequest(recipient: string) {
if (!this.multiTabChannel.isLeader) {
if (!this.multiTabChannel.isLeader()) {
return
}

Expand Down Expand Up @@ -571,7 +571,7 @@ export class DAppClient extends Client {
}

private async prepareRequest({ data }: any, isV3 = false) {
if (!this.multiTabChannel.isLeader) {
if (!this.multiTabChannel.isLeader()) {
return
}

Expand Down Expand Up @@ -655,7 +655,7 @@ export class DAppClient extends Client {
network: this.network.type,
opts: wcOptions
},
() => this.multiTabChannel.isLeader
() => this.multiTabChannel.isLeader()
)

this.initEvents()
Expand Down Expand Up @@ -730,7 +730,7 @@ export class DAppClient extends Client {
}

private async getPairingRequestInfo(transport: DappWalletConnectTransport) {
if (this.multiTabChannel.isLeader) {
if (this.multiTabChannel.isLeader()) {
return transport.getPairingRequestInfo()
}

Expand Down Expand Up @@ -960,7 +960,7 @@ export class DAppClient extends Client {
this.debounceSetActiveAccount = true
this._initPromise = undefined
this.postMessageTransport = this.p2pTransport = this.walletConnectTransport = undefined
if (this.multiTabChannel.isLeader) {
if (this.multiTabChannel.isLeader()) {
await transport.disconnect()
this.openRequestsOtherTabs.clear()
} else {
Expand Down Expand Up @@ -1149,7 +1149,7 @@ export class DAppClient extends Client {
private async checkMakeRequest() {
const isResolved = this._transport.isResolved()
const isWCInstance = isResolved && (await this.transport) instanceof WalletConnectTransport
const isLeader = this.multiTabChannel.isLeader
const isLeader = this.multiTabChannel.isLeader()

return !isResolved || !isWCInstance || isLeader
}
Expand Down
1 change: 0 additions & 1 deletion packages/beacon-types/src/types/storage/StorageKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ export enum StorageKey {
USER_ID = 'beacon:user-id',
ENABLE_METRICS = 'beacon:enable_metrics',
WC_INIT_ERROR = 'beacon:wc-init-error',
BC_NEIGHBORHOOD = 'beacon:bc-neighborhood',
WC_2_CORE_PAIRING = 'wc@2:core:0.3:pairing',
WC_2_CLIENT_SESSION = 'wc@2:client:0.3:session',
WC_2_CORE_KEYCHAIN = 'wc@2:core:0.3:keychain',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ export const defaultValues: StorageKeyReturnDefaults = {
[StorageKey.WC_2_CLIENT_SESSION]: undefined,
[StorageKey.USER_ID]: undefined,
[StorageKey.ENABLE_METRICS]: undefined,
[StorageKey.BC_NEIGHBORHOOD]: [],
[StorageKey.WC_INIT_ERROR]: undefined,
[StorageKey.WC_2_CORE_PAIRING]: undefined,
[StorageKey.WC_2_CORE_KEYCHAIN]: undefined,
Expand Down
Loading

0 comments on commit c97d833

Please sign in to comment.