Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix/pairing secondary tab #824

Merged
merged 24 commits into from
Oct 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ jobs:
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Node 16
- name: Node 18
uses: actions/setup-node@v1
with:
node-version: 16.x
node-version: 18.x

- name: Prepare
run: npm ci
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/deploy-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ jobs:
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Node 16
- name: Node 18
uses: actions/setup-node@v1
with:
node-version: 16.x
node-version: 18.x

- name: Prepare
run: npm ci
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/dev-demo-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ jobs:
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Node 16
- name: Node 18
uses: actions/setup-node@v1
with:
node-version: 16.x
node-version: 18.x

- name: Prepare
run: npm ci
Expand Down
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"
}
}
174 changes: 54 additions & 120 deletions packages/beacon-core/src/utils/multi-tab-channel.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
import { Logger } from '@airgap/beacon-core'
import { BeaconMessageType } from '@airgap/beacon-types'
import { Logger } from './Logger'
import { ExposedPromise } from '@airgap/beacon-utils'
import { createLeaderElection, BroadcastChannel, LeaderElector } from 'broadcast-channel'

type BCMessageType =
| 'REQUEST_LEADERSHIP'
| 'LEADER_EXISTS'
| 'LEADER_UNLOAD'
| 'LEADER_STILL_ALIVE'
| 'IS_LEADER_ALIVE'
| 'CHILD_UNLOAD'
| 'CHILD_STILL_ALIVE'
| 'IS_CHILD_ALIVE'
| 'LEADER_DEAD'
| 'RESPONSE'
| 'DISCONNECT'
| BeaconMessageType
Expand All @@ -21,158 +16,97 @@ type BCMessage = {
data?: any
}

const timeout = 1000 // ms
const logger = new Logger('MultiTabChannel')

export class MultiTabChannel {
private id: string = String(Date.now())
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 leaderElectionTimeout: NodeJS.Timeout | undefined
private pendingACKs: Map<string, NodeJS.Timeout> = new Map()

isLeader: boolean = false

private messageHandlers: {
[key in BCMessageType]?: (data: BCMessage) => void
} = {
REQUEST_LEADERSHIP: this.handleRequestLeadership.bind(this),
LEADER_EXISTS: this.handleLeaderExists.bind(this),
CHILD_UNLOAD: this.handleChildUnload.bind(this),
CHILD_STILL_ALIVE: this.handleChildStillAlive.bind(this),
IS_CHILD_ALIVE: this.handleIsChildAlive.bind(this),
LEADER_UNLOAD: this.handleLeaderUnload.bind(this),
LEADER_STILL_ALIVE: this.handleLeaderStillAlive.bind(this),
IS_LEADER_ALIVE: this.handleIsLeaderAlive.bind(this)
}
private _isLeader: ExposedPromise<boolean> = new ExposedPromise()
// 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 init() {
this.postMessage({ type: 'REQUEST_LEADERSHIP' })
this.leaderElectionTimeout = setTimeout(() => {
this.isLeader = true
logger.log('The current tab is the leader.')
}, timeout)

window?.addEventListener('beforeunload', this.eventListeners[0])
this.channel.onmessage = this.eventListeners[1]
}
private async init() {
const isMobile = typeof window !== 'undefined' ? /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
) : false

private chooseNextLeader() {
return Math.floor(Math.random() * this.neighborhood.size)
}

private onBeforeUnloadHandler() {
// 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
if (this.isLeader) {
this.postMessage({
type: 'LEADER_UNLOAD',
recipient: Array.from(this.neighborhood)[this.chooseNextLeader()],
data: this.neighborhood
})
} else {
this.postMessage({ type: 'CHILD_UNLOAD' })
if (isMobile) {
throw new Error('BroadcastChannel is not fully supported on mobile.')
}

window?.removeEventListener('beforeunload', this.eventListeners[0])
this.channel.removeEventListener('message', this.eventListeners[1])
}
const hasLeader = await this.elector.hasLeader()

private onMessageHandler({ data }: { data: BCMessage }) {
const handler = this.messageHandlers[data.type]
if (handler) {
handler(data)
} else {
this.onBCMessageHandler(data)
if (!hasLeader) {
await this.elector.awaitLeadership()
this.wasLeader = this.elector.isLeader
this.wasLeader && logger.log('The current tab is the leader.')
}
}

private handleRequestLeadership(data: BCMessage) {
if (this.isLeader) {
this.postMessage({ type: 'LEADER_EXISTS', recipient: data.sender })
this.neighborhood.add(data.sender)
}
this._isLeader.resolve(this.wasLeader)
this.channel.onmessage = this.eventListeners[1]
window?.addEventListener('beforeunload', this.eventListeners[0])
}

private handleLeaderExists(data: BCMessage) {
if (data.recipient === this.id) {
clearTimeout(this.leaderElectionTimeout)
private async onBeforeUnloadHandler() {
if (this.wasLeader) {
await this.elector.die()
this.postMessage({ type: 'LEADER_DEAD' })
}
}

private handleChildUnload(data: BCMessage) {
if (this.isLeader) {
this.pendingACKs.set(
data.sender,
setTimeout(() => {
this.neighborhood.delete(data.sender)
this.pendingACKs.delete(data.sender)
}, timeout)
)

this.postMessage({ type: 'IS_CHILD_ALIVE', recipient: data.sender })
}
window?.removeEventListener('beforeunload', this.eventListeners[0])
this.channel.removeEventListener('message', this.eventListeners[1])
}

private handleChildStillAlive(data: BCMessage) {
if (this.isLeader) {
this.clearPendingACK(data.sender)
private async onMessageHandler(message: BCMessage) {
if (message.recipient && message.recipient !== this.id) {
return
}
}

private handleIsChildAlive(data: BCMessage) {
if (data.recipient === this.id) {
this.postMessage({ type: 'CHILD_STILL_ALIVE' })
}
}
if (message.type === 'LEADER_DEAD') {
await this.elector.awaitLeadership()

this.wasLeader = this.elector.isLeader
this._isLeader = new ExposedPromise()
this._isLeader.resolve(this.wasLeader)

private handleLeaderUnload(data: BCMessage) {
if (data.recipient === this.id) {
this.pendingACKs.set(
data.sender,
setTimeout(() => {
this.isLeader = true
this.neighborhood = data.data
this.neighborhood.delete(this.id)
this.onElectedLeaderHandler()
logger.log('The current tab is the leader.')
}, timeout)
)
if (this.wasLeader) {
this.onElectedLeaderHandler()
logger.log('The current tab is the leader.')
}
return
}
this.postMessage({ type: 'IS_LEADER_ALIVE', recipient: data.sender })

this.onBCMessageHandler(message)
}

private handleLeaderStillAlive(data: BCMessage) {
if (this.isLeader) {
this.clearPendingACK(data.sender)
}
isLeader(): Promise<boolean> {
return this._isLeader.promise
}

private handleIsLeaderAlive(data: BCMessage) {
if (data.recipient === this.id) {
this.postMessage({ type: 'LEADER_STILL_ALIVE' })
}
async getLeadership() {
return this.elector.awaitLeadership()
}

private clearPendingACK(sender: string) {
const timeout = this.pendingACKs.get(sender)
if (timeout) {
clearTimeout(timeout)
this.pendingACKs.delete(sender)
}
async hasLeader(): Promise<boolean> {
return this.elector.hasLeader()
}

postMessage(message: Omit<BCMessage, 'sender'>): void {
Expand Down
Loading
Loading