]> git.codecow.com Git - libnemo.git/commitdiff
Dispatch events when Ledger status changes and when wallet lock status changes.
authorChris Duncan <chris@zoso.dev>
Wed, 24 Sep 2025 20:50:11 +0000 (13:50 -0700)
committerChris Duncan <chris@zoso.dev>
Wed, 24 Sep 2025 20:50:11 +0000 (13:50 -0700)
src/lib/ledger.ts
src/lib/vault/index.ts
src/lib/wallet/index.ts
test/test.ledger.mjs
test/test.lock-unlock.mjs

index a5bcd9722501ef3e87b1fd9586ff71a1e09b0e79..86ce95e50b44a41924572e43806119d7a2fa0a0b 100644 (file)
@@ -77,6 +77,12 @@ export class Ledger {
                0x9000: 'OK'
        })
 
+       // Compose event emission for status changes
+       static #eventTarget = new EventTarget()
+       static addEventListener = this.#eventTarget.addEventListener.bind(this.#eventTarget)
+       static dispatchEvent = this.#eventTarget.dispatchEvent.bind(this.#eventTarget)
+       static removeEventListener = this.#eventTarget.removeEventListener.bind(this.#eventTarget)
+
        /**
        * Check which transport protocols are supported by the browser and return the
        * transport type according to the following priorities: HID, Bluetooth, USB.
@@ -101,7 +107,7 @@ export class Ledger {
                        this.#transport ??= TransportUSB
                        return false
                }
-               this.#status = 'UNSUPPORTED'
+               this.#setStatus('UNSUPPORTED')
                return true
        }
 
@@ -214,7 +220,7 @@ export class Ledger {
                                const devices = [...hidDevices, ...usbDevices]
                                        .filter(device => device.vendorId === this.ledgerVendorId)
                                await Promise.allSettled(devices.map(device => device.forget?.() ?? Promise.resolve()))
-                               this.#status = 'DISCONNECTED'
+                               this.#setStatus('DISCONNECTED')
                        } catch (err) {
                                console.warn('Ledger.disconnect()', err)
                        } finally {
@@ -426,26 +432,25 @@ export class Ledger {
                try {
                        const version = await this.#version()
                        if (version.status !== 'OK') {
-                               this.#status = 'DISCONNECTED'
+                               this.#setStatus('DISCONNECTED')
                        } else {
                                if (version.name === 'Nano') {
                                        const { status } = await this.account()
                                        if (status === 'OK') {
-                                               this.#status = 'CONNECTED'
+                                               this.#setStatus('CONNECTED')
                                        } else if (status === 'SECURITY_STATUS_NOT_SATISFIED') {
-                                               this.#status = 'LOCKED'
+                                               this.#setStatus('LOCKED')
                                        } else {
-                                               this.#status = 'DISCONNECTED'
+                                               this.#setStatus('DISCONNECTED')
                                        }
                                } else {
-                                       this.#status = 'BUSY'
+                                       this.#setStatus('BUSY')
                                }
                        }
                } catch (err) {
                        console.error('Ledger.#connect()', err)
-                       this.#status = 'DISCONNECTED'
+                       this.#setStatus('DISCONNECTED')
                }
-               console.log(this.#status)
                return this.#status
        }
 
@@ -514,14 +519,24 @@ export class Ledger {
                        } else if (this.#transport === TransportUSB && isUsbPaired) {
                                await this.#connect()
                        } else {
-                               console.log('No Ledger devices paired on USB')
-                               this.#status = 'DISCONNECTED'
+                               this.#setStatus('DISCONNECTED')
                        }
+                       this.#isPolling ? setTimeout(() => this.#poll(), 500) : void 0
                } catch {
                        console.warn('Error polling Ledger device')
-                       this.#status = 'DISCONNECTED'
-               } finally {
-                       this.#isPolling ? setTimeout(() => this.#poll(), 500) : void 0
+                       this.#setStatus('DISCONNECTED')
+               }
+       }
+
+       /**
+       * Sets the Ledger status and emits an event.
+       */
+       static #setStatus (value: LedgerStatus) {
+               if (this.#status !== value) {
+                       this.#status = value
+                       const event = new CustomEvent('ledgerstatuschanged', { detail: value })
+                       this.dispatchEvent(event)
+                       console.log(event)
                }
        }
 
index b321c8fdd9eabcdd193dd23e2e27a3e344a9765c..e9459bbac5173d5b65f3f5af193acbaead1cdf01 100644 (file)
@@ -17,6 +17,7 @@ type Task = {
 }
 
 export class Vault {
+       #eventTarget = new EventTarget()
        #job?: Task
        #isIdle: boolean
        #isLocked: boolean
@@ -25,6 +26,11 @@ export class Vault {
        #url: string
        #worker: Worker | NodeWorker
 
+       // Compose event emission for status changes
+       addEventListener = this.#eventTarget.addEventListener.bind(this.#eventTarget)
+       dispatchEvent = this.#eventTarget.dispatchEvent.bind(this.#eventTarget)
+       removeEventListener = this.#eventTarget.removeEventListener.bind(this.#eventTarget)
+
        constructor () {
                this.#isIdle = true
                this.#isLocked = true
@@ -45,6 +51,7 @@ export class Vault {
                })
        }
 
+
        get isLocked (): boolean { return this.#isLocked }
 
        request<T extends Data> (data: NamedData): Promise<NamedData<T>> {
@@ -91,7 +98,11 @@ export class Vault {
 
        #report (results: any): void {
                if (results === 'locked' || results === 'unlocked') {
-                       this.#isLocked = results === 'locked'
+                       const isLocked = results === 'locked'
+                       if (this.#isLocked !== isLocked) {
+                               this.#isLocked = isLocked
+                               this.dispatchEvent(new Event(results))
+                       }
                        return
                }
                if (this.#job == null) {
index 574f9c511a89c3d42572f711579d9415259af9c4..c042bdacd5762d0c8c7f0f48276c372fcd8f545b 100644 (file)
@@ -132,6 +132,15 @@ export class Wallet {
                return typeof id === 'string' ? wallets[0] : wallets\r
        }\r
 \r
+       #accounts: Map<number, Account> = new Map<number, Account>()\r
+       #eventTarget: EventTarget = new EventTarget()\r
+       #id: string = crypto.randomUUID()\r
+       #vault: Vault = new Vault()\r
+\r
+       #mnemonic?: ArrayBuffer\r
+       #seed?: ArrayBuffer\r
+       #type: WalletType\r
+\r
        constructor (type: WalletType, id?: string)\r
        constructor (type: unknown, id?: string) {\r
                if (!(this.constructor as typeof Wallet).isInternal) {\r
@@ -140,12 +149,17 @@ export class Wallet {
                if (type !== 'BIP-44' && type !== 'BLAKE2b' && type !== 'Ledger') {\r
                        throw new TypeError('Invalid wallet type', { cause: type })\r
                }\r
-               this.#accounts = new Map<number, Account>()\r
-               this.#id = id ?? crypto.randomUUID()\r
+               this.#id = id ?? this.#id\r
                this.#type = type\r
-               this.#vault = new Vault()\r
+               this.#vault.addEventListener('locked', () => this.dispatchEvent(new Event('locked')))\r
+               this.#vault.addEventListener('unlocked', () => this.dispatchEvent(new Event('unlocked')))\r
        }\r
 \r
+       // Compose event emission for status changes\r
+       addEventListener = this.#eventTarget.addEventListener.bind(this.#eventTarget)\r
+       dispatchEvent = this.#eventTarget.dispatchEvent.bind(this.#eventTarget)\r
+       removeEventListener = this.#eventTarget.removeEventListener.bind(this.#eventTarget)\r
+\r
        /**\r
        * @returns UUID of the wallet.\r
        */\r
@@ -403,11 +417,4 @@ export class Wallet {
        async verify (secret: string): Promise<boolean> {\r
                return await _verify(this.type, this.#vault, secret)\r
        }\r
-\r
-       #accounts: Map<number, Account>\r
-       #id: string\r
-       #mnemonic?: ArrayBuffer\r
-       #seed?: ArrayBuffer\r
-       #type: WalletType\r
-       #vault: Vault\r
 }\r
index 0a64da4233b110a08892053de383f53fd13b18c5..7047ce04754c8e54b72dff6c80cd3be1a5fb46a9 100644 (file)
@@ -39,6 +39,13 @@ await Promise.all([
                }
 
                await test('request permissions', { skip: true }, async () => {
+                       let status = Ledger.status
+
+                       Ledger.addEventListener('ledgerstatuschanged', (event) => {
+                               //@ts-expect-error
+                               status = event.detail ?? ''
+                       })
+
                        await click(
                                'Reset permissions, then click to continue',
                                async () => new Promise(r => setTimeout(r, 5000))
@@ -46,6 +53,7 @@ await Promise.all([
                        await assert.rejects(wallet.unlock())
                        assert.equal(wallet.isLocked, true)
                        assert.equal(Ledger.status, 'DISCONNECTED')
+                       assert.equal(status, 'DISCONNECTED')
 
                        await assert.rejects(async () => {
                                await click(
@@ -55,6 +63,7 @@ await Promise.all([
                        })
                        assert.equal(wallet.isLocked, true)
                        assert.equal(Ledger.status, 'BUSY')
+                       assert.equal(status, 'BUSY')
 
                        await new Promise(async (resolve, reject) => {
                                console.log('Waiting 6 seconds...')
@@ -63,6 +72,7 @@ await Promise.all([
                                                // should still be locked and busy
                                                assert.equal(wallet.isLocked, true)
                                                assert.equal(Ledger.status, 'BUSY')
+                                               assert.equal(status, 'BUSY')
                                                resolve(null)
                                        } catch (err) {
                                                reject(err)
@@ -86,6 +96,7 @@ await Promise.all([
                                                // should still be locked
                                                assert.equal(wallet.isLocked, true)
                                                assert.equal(Ledger.status, 'LOCKED')
+                                               assert.equal(status, 'LOCKED')
                                                resolve(null)
                                        } catch (err) {
                                                reject(err)
@@ -101,6 +112,7 @@ await Promise.all([
                        })
                        assert.equal(wallet.isLocked, false)
                        assert.equal(Ledger.status, 'CONNECTED')
+                       assert.equal(status, 'CONNECTED')
 
                        await new Promise(async (resolve, reject) => {
                                console.log('Waiting 6 seconds...')
@@ -109,6 +121,7 @@ await Promise.all([
                                                // should still be unlocked
                                                assert.equal(wallet.isLocked, false)
                                                assert.equal(Ledger.status, 'CONNECTED')
+                                               assert.equal(status, 'CONNECTED')
                                                resolve(null)
                                        } catch (err) {
                                                reject(err)
@@ -123,6 +136,7 @@ await Promise.all([
                                                // should now be locked
                                                assert.equal(wallet.isLocked, true)
                                                assert.equal(Ledger.status, 'LOCKED')
+                                               assert.equal(status, 'LOCKED')
                                                resolve(null)
                                        } catch (err) {
                                                reject(err)
@@ -138,6 +152,7 @@ await Promise.all([
                        })
                        assert.equal(wallet.isLocked, false)
                        assert.equal(Ledger.status, 'CONNECTED')
+                       assert.equal(status, 'CONNECTED')
                })
 
                await test('switch between interfaces', { skip: false || isNode || navigator?.usb == null }, async () => {
index b42edfadbb38b99545b2fa170ba9bfded78d9acf..ff694ced93d68db3a438da60db99a75bcd6f78ff 100644 (file)
@@ -10,7 +10,7 @@ import { NANO_TEST_VECTORS, TREZOR_TEST_VECTORS } from './VECTORS.mjs'
 await Promise.all([\r
        suite('Lock and unlock wallets', async () => {\r
 \r
-               await test('locking and unlocking a Bip44Wallet with a password', async () => {\r
+               await test('locking and unlocking a BIP-44 wallet with a password', async () => {\r
                        const wallet = await Wallet.load('BIP-44', NANO_TEST_VECTORS.PASSWORD, NANO_TEST_VECTORS.MNEMONIC, NANO_TEST_VECTORS.PASSWORD)\r
 \r
                        assert.ok('mnemonic' in wallet)\r
@@ -28,6 +28,9 @@ await Promise.all([
 \r
                await test('change the password on a BIP-44 wallet', async () => {\r
                        const wallet = await Wallet.load('BIP-44', '', NANO_TEST_VECTORS.MNEMONIC, NANO_TEST_VECTORS.PASSWORD)\r
+                       let isLocked = wallet.isLocked\r
+                       wallet.addEventListener('locked', () => isLocked = true)\r
+                       wallet.addEventListener('unlocked', () => isLocked = false)\r
                        await wallet.unlock('')\r
 \r
                        assert.ok('mnemonic' in wallet)\r
@@ -36,9 +39,12 @@ await Promise.all([
                        assert.ok(wallet.seed === undefined)\r
                        assert.ok(await wallet.verify(NANO_TEST_VECTORS.MNEMONIC))\r
                        assert.ok(await wallet.verify(NANO_TEST_VECTORS.BIP39_SEED))\r
+                       assert.equal(isLocked, false)\r
 \r
                        await wallet.update(NANO_TEST_VECTORS.PASSWORD)\r
                        wallet.lock()\r
+                       await new Promise(r => setTimeout(r, 1000))\r
+                       assert.equal(isLocked, true)\r
                        await wallet.unlock(NANO_TEST_VECTORS.PASSWORD)\r
 \r
                        assert.ok('mnemonic' in wallet)\r