]> git.codecow.com Git - libnemo.git/commitdiff
Refactor Ledger polling. Working OK now, just need to verify fix for polling while...
authorChris Duncan <chris@zoso.dev>
Thu, 18 Sep 2025 21:37:25 +0000 (14:37 -0700)
committerChris Duncan <chris@zoso.dev>
Thu, 18 Sep 2025 21:37:25 +0000 (14:37 -0700)
index.html
src/lib/ledger.ts
src/lib/tools.ts
test/test.ledger.mjs

index 52ad248e1a33651534cc354907dd806fd21a85a3..cf9b2db9443f8e571a0b955937195bf3209cf6a2 100644 (file)
@@ -75,7 +75,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
                        }
                })()
        </script>
-       <script src="./dist/global.min.js"></script>
        <script src="./test/main.test.mjs" type="module"></script>
        <style>body{background:black;color:white;}</style>
 </head>
index 75d3a3d839b62932470cb771f831afefd1f1bcc9..123a42983db3470a00c368e13d67adc19e21805d 100644 (file)
@@ -41,9 +41,9 @@ interface LedgerSignResponse extends LedgerResponse {
 * https://github.com/roosmaa/ledger-app-nano/blob/master/doc/nano.md
 */
 export class Ledger {
+       static #isBusy: boolean = false
        static #listenTimeout: 30000 = 30000
        static #openTimeout: 3000 = 3000
-       static #polling: number | NodeJS.Timeout
        static #status: LedgerStatus = 'DISCONNECTED'
        static #transport: typeof TransportHID | typeof TransportBLE | typeof TransportUSB
        static #ADPU_CODES: { [key: string]: number } = Object.freeze({
@@ -80,6 +80,9 @@ export class Ledger {
        * transport type according to the following priorities: HID, Bluetooth, USB.
        */
        static get isUnsupported (): boolean {
+               if (this.#status === 'UNSUPPORTED') {
+                       return true
+               }
                if (this.#transport !== undefined) {
                        return false
                }
@@ -96,6 +99,7 @@ export class Ledger {
                        this.#transport ??= TransportUSB
                        return false
                }
+               this.#status = 'UNSUPPORTED'
                return true
        }
 
@@ -128,26 +132,31 @@ export class Ledger {
                const account = dec.toBytes(index + HARDENED_OFFSET, 4)
                const data = new Uint8Array([...this.#DERIVATION_PATH, ...account])
 
-               const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
-               const response = await transport
-                       .send(this.#ADPU_CODES.class, this.#ADPU_CODES.account, show ? 1 : 0, this.#ADPU_CODES.paramUnused, data as Buffer)
-                       .catch((err: any) => dec.toBytes(err.statusCode)) as Uint8Array
-               await transport.close()
-
-               const statusCode = bytes.toDec(response.slice(-2)) as number
-               const status = this.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'
-               if (status !== 'OK') {
-                       return { status, publicKey: null, address: null }
-               }
-
                try {
-                       const publicKey = bytes.toHex(response.slice(0, 32))
-                       const addressLength = response[32]
-                       const address = response.slice(33, 33 + addressLength).toString()
+                       this.#isBusy = true
+                       const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
+                       const response = await transport
+                               .send(this.#ADPU_CODES.class, this.#ADPU_CODES.account, show ? 1 : 0, this.#ADPU_CODES.paramUnused, data as Buffer)
+                               .catch((err: any) => dec.toBytes(err.statusCode)) as Uint8Array
+                       await transport.close()
+
+                       const statusCode = bytes.toDec(response.slice(-2)) as number
+                       const status = this.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'
+                       if (status !== 'OK') {
+                               return { status, publicKey: null, address: null }
+                       }
 
-                       return { status, publicKey, address }
-               } catch (err) {
-                       return { status: 'ERROR_PARSING_ACCOUNT', publicKey: null, address: null }
+                       try {
+                               const publicKey = bytes.toHex(response.slice(0, 32))
+                               const addressLength = response[32]
+                               const address = response.slice(33, 33 + addressLength).toString()
+
+                               return { status, publicKey, address }
+                       } catch (err) {
+                               return { status: 'ERROR_PARSING_ACCOUNT', publicKey: null, address: null }
+                       }
+               } finally {
+                       this.#isBusy = false
                }
        }
 
@@ -163,7 +172,7 @@ export class Ledger {
        * - CONNECTED: Nano app is open and listening
        */
        static async connect (api?: 'hid' | 'ble' | 'usb'): Promise<LedgerStatus> {
-               if (api !== undefined) {
+               if (api !== undefined || this.#status !== 'UNSUPPORTED') {
                        if (api === 'hid' && this.#transport !== TransportHID) {
                                this.#transport = TransportHID
                        }
@@ -175,11 +184,11 @@ export class Ledger {
                        }
                }
                try {
+                       this.#isBusy = true
                        const version = await this.#version()
                        if (version.status !== 'OK') {
                                this.#status = 'DISCONNECTED'
                        } else {
-                               this.#polling ??= setInterval(() => this.connect(), 1000)
                                if (version.name === 'Nano') {
                                        const { status } = await this.account()
                                        if (status === 'OK') {
@@ -195,17 +204,18 @@ export class Ledger {
                        }
                } catch (err) {
                        console.error(err)
-                       clearInterval(this.#polling)
                        this.#status = 'DISCONNECTED'
+               } finally {
+                       this.#isBusy = false
                }
-               return this.status
+               console.log(this.#status)
+               return this.#status
        }
 
        /**
        * Clears Ledger connections from all device interfaces.
        */
        static disconnect (): void {
-               clearInterval(this.#polling)
                setTimeout(async () => {
                        const hidDevices = await globalThis.navigator?.hid?.getDevices?.() ?? []
                        for (const device of hidDevices) {
@@ -215,7 +225,7 @@ export class Ledger {
                        }
                        const bleDevices = await globalThis.navigator?.bluetooth?.getDevices?.() ?? []
                        for (const device of bleDevices) {
-                               TransportBLE.disconnect(device.id)
+                               TransportBLE.disconnect(device.id).catch(() => { })
                        }
                        const usbDevices = await globalThis.navigator?.usb?.getDevices?.() ?? []
                        for (const device of usbDevices) {
@@ -371,14 +381,19 @@ export class Ledger {
                const signature = hex.toBytes(block.signature, 64)
                const data = new Uint8Array([this.#ADPU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account, ...previous, ...link, ...representative, ...balance, ...signature])
 
-               const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
-               const response = await transport
-                       .send(this.#ADPU_CODES.class, this.#ADPU_CODES.cacheBlock, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, data as Buffer)
-                       .then((res: Buffer) => bytes.toDec(res))
-                       .catch((err: any) => err.statusCode) as number
-               await transport.close()
-
-               return { status: this.#STATUS_CODES[response] }
+               try {
+                       this.#isBusy = true
+                       const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
+                       const response = await transport
+                               .send(this.#ADPU_CODES.class, this.#ADPU_CODES.cacheBlock, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, data as Buffer)
+                               .then((res: Buffer) => bytes.toDec(res))
+                               .catch((err: any) => err.statusCode) as number
+                       await transport.close()
+
+                       return { status: this.#STATUS_CODES[response] }
+               } finally {
+                       this.#isBusy = false
+               }
        }
 
        /**
@@ -395,12 +410,17 @@ export class Ledger {
        * @returns Status of command
        */
        static async #close (): Promise<LedgerResponse> {
-               const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
-               const response = await transport
-                       .send(0xb0, 0xa7, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused)
-                       .then((res: Buffer) => bytes.toDec(res))
-                       .catch((err: any) => err.statusCode) as number
-               return new Promise(r => setTimeout(r, 1000, { status: this.#STATUS_CODES[response] }))
+               try {
+                       this.#isBusy = true
+                       const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
+                       const response = await transport
+                               .send(0xb0, 0xa7, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused)
+                               .then((res: Buffer) => bytes.toDec(res))
+                               .catch((err: any) => err.statusCode) as number
+                       return new Promise(r => setTimeout(r, 1000, { status: this.#STATUS_CODES[response] }))
+               } finally {
+                       this.#isBusy = false
+               }
        }
 
        /**
@@ -417,13 +437,38 @@ export class Ledger {
        * @returns Status of command
        */
        static async #open (): Promise<LedgerResponse> {
-               const name = new TextEncoder().encode('Nano')
-               const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
-               const response = await transport
-                       .send(0xe0, 0xd8, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, name as Buffer)
-                       .then((res: Buffer) => bytes.toDec(res))
-                       .catch((err: any) => err.statusCode) as number
-               return new Promise(r => setTimeout(r, 1000, { status: this.#STATUS_CODES[response] }))
+               try {
+                       this.#isBusy = true
+                       const name = new TextEncoder().encode('Nano')
+                       const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
+                       const response = await transport
+                               .send(0xe0, 0xd8, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, name as Buffer)
+                               .then((res: Buffer) => bytes.toDec(res))
+                               .catch((err: any) => err.statusCode) as number
+                       return new Promise(r => setTimeout(r, 1000, { status: this.#STATUS_CODES[response] }))
+               } finally {
+                       this.#isBusy = false
+               }
+       }
+
+       static async #poll (): Promise<void> {
+               try {
+                       const hidDevices = await navigator.hid?.getDevices() ?? []
+                       const usbDevices = await navigator.usb?.getDevices() ?? []
+                       const isPaired = [...hidDevices, ...usbDevices]
+                               .some(device => device.vendorId === this.UsbVendorId)
+                       if (!this.#isBusy && isPaired && (this.#transport === TransportHID || this.#transport === TransportUSB)) {
+                               await this.connect().catch(() => { })
+                       } else {
+                               console.log('No Ledger USB devices paired')
+                               this.#status = 'DISCONNECTED'
+                       }
+               } catch {
+                       console.warn('Error polling Ledger device')
+                       this.#status = 'DISCONNECTED'
+               } finally {
+                       setTimeout(() => this.#poll(), 1000)
+               }
        }
 
        /**
@@ -451,25 +496,30 @@ export class Ledger {
                const balance = hex.toBytes(BigInt(block.balance).toString(16), 16)
                const data = new Uint8Array([...this.#DERIVATION_PATH, ...account, ...previous, ...link, ...representative, ...balance])
 
-               const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
-               const response = await transport
-                       .send(this.#ADPU_CODES.class, this.#ADPU_CODES.signBlock, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, data as Buffer)
-                       .catch((err: any) => dec.toBytes(err.statusCode)) as Uint8Array
-               await transport.close()
-
-               const statusCode = bytes.toDec(response.slice(-2)) as number
-               const status = this.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'
-
-               if (response.byteLength === 2) {
-                       return { status, signature: null }
-               }
-               if (response.byteLength === 98) {
-                       const hash = bytes.toHex(response.slice(0, 32))
-                       const signature = bytes.toHex(response.slice(32, 96))
-                       return { status, signature, hash }
+               try {
+                       this.#isBusy = true
+                       const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
+                       const response = await transport
+                               .send(this.#ADPU_CODES.class, this.#ADPU_CODES.signBlock, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, data as Buffer)
+                               .catch((err: any) => dec.toBytes(err.statusCode)) as Uint8Array
+                       await transport.close()
+
+                       const statusCode = bytes.toDec(response.slice(-2)) as number
+                       const status = this.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'
+
+                       if (response.byteLength === 2) {
+                               return { status, signature: null }
+                       }
+                       if (response.byteLength === 98) {
+                               const hash = bytes.toHex(response.slice(0, 32))
+                               const signature = bytes.toHex(response.slice(32, 96))
+                               return { status, signature, hash }
+                       } else {
+                               throw new Error('Unexpected byte length from device signature', { cause: response })
+                       }
+               } finally {
+                       this.#isBusy = false
                }
-
-               throw new Error('Unexpected byte length from device signature', { cause: response })
        }
 
        /**
@@ -492,24 +542,29 @@ export class Ledger {
                const derivationAccount = dec.toBytes(index + HARDENED_OFFSET, 4)
                const data = new Uint8Array([...this.#DERIVATION_PATH, ...derivationAccount, ...nonce])
 
-               const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
-               const response = await transport
-                       .send(this.#ADPU_CODES.class, this.#ADPU_CODES.signNonce, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, data as Buffer)
-                       .catch((err: any) => dec.toBytes(err.statusCode)) as Uint8Array
-               await transport.close()
-
-               const statusCode = bytes.toDec(response.slice(-2)) as number
-               const status = this.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'
-
-               if (response.byteLength === 2) {
-                       return { status, signature: null }
-               }
-               if (response.byteLength === 66) {
-                       const signature = bytes.toHex(response.slice(0, 64))
-                       return { status, signature }
+               try {
+                       this.#isBusy = true
+                       const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
+                       const response = await transport
+                               .send(this.#ADPU_CODES.class, this.#ADPU_CODES.signNonce, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, data as Buffer)
+                               .catch((err: any) => dec.toBytes(err.statusCode)) as Uint8Array
+                       await transport.close()
+
+                       const statusCode = bytes.toDec(response.slice(-2)) as number
+                       const status = this.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'
+
+                       if (response.byteLength === 2) {
+                               return { status, signature: null }
+                       }
+                       if (response.byteLength === 66) {
+                               const signature = bytes.toHex(response.slice(0, 64))
+                               return { status, signature }
+                       } else {
+                               throw new Error('Unexpected byte length from device signature', { cause: response })
+                       }
+               } finally {
+                       this.#isBusy = false
                }
-
-               throw new Error('Unexpected byte length from device signature', { cause: response })
        }
 
        /**
@@ -521,23 +576,35 @@ export class Ledger {
        * @returns Status, process name, and version
        */
        static async #version (): Promise<LedgerVersionResponse> {
-               const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
-               const response = await transport
-                       .send(0xb0, this.#ADPU_CODES.version, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused)
-                       .catch((err: any) => dec.toBytes(err.statusCode)) as Uint8Array
-               await transport.close()
-
-               const statusCode = bytes.toDec(response.slice(-2)) as number
-               const status = this.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'
-               if (status !== 'OK') {
-                       return { status, name: null, version: null }
-               }
+               try {
+                       this.#isBusy = true
+                       const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
+                       const response = await transport
+                               .send(0xb0, this.#ADPU_CODES.version, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused)
+                               .catch((err: any) => {
+                                       console.error(err)
+                                       dec.toBytes(err.statusCode)
+                               }) as Uint8Array
+                       await transport.close()
+
+                       const statusCode = bytes.toDec(response.slice(-2)) as number
+                       const status = this.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'
+                       if (status !== 'OK') {
+                               return { status, name: null, version: null }
+                       }
 
-               const nameLength = response[1]
-               const name = response.slice(2, 2 + nameLength).toString()
-               const versionLength = response[2 + nameLength]
-               const version = response.slice(2 + nameLength + 1, 2 + nameLength + 1 + versionLength).toString()
+                       const nameLength = response[1]
+                       const name = response.slice(2, 2 + nameLength).toString()
+                       const versionLength = response[2 + nameLength]
+                       const version = response.slice(2 + nameLength + 1, 2 + nameLength + 1 + versionLength).toString()
+
+                       return { status, name, version }
+               } finally {
+                       this.#isBusy = false
+               }
+       }
 
-               return { status, name, version }
+       static {
+               this.#poll().catch(() => { })
        }
 }
index 7f33bf8f3024c3ace64a1998cfa9b148da5f552c..99b36e137c9fffce20c64c1b12beb3f1770cebe9 100644 (file)
@@ -16,14 +16,6 @@ type SweepResult = {
 }
 
 export class Tools {
-       static #ledger: typeof import('./ledger').Ledger
-
-       static {
-               (async () => {
-                       this.#ledger = (await import('./ledger')).Ledger
-               })()
-       }
-
        /**
        * Converts a decimal amount of nano from one unit divider to another.
        *
@@ -137,15 +129,6 @@ export class Tools {
                        : hash.digest()
        }
 
-       /**
-       * Provides low-level access to Ledger hardware wallets.
-       *
-       * @returns `Ledger` class with collection of static methods for managing the device
-       */
-       static get Ledger (): typeof import('./ledger').Ledger {
-               return this.#ledger
-       }
-
        /**
        * Signs arbitrary strings with a private key using the Ed25519 signature scheme.
        * The strings are first hashed to a 32-byte value using BLAKE2b.
index 1ca0d6768276b0f63a49942b97f845200f500000..c32f2904b03a5c0a6f3dbb4b4e194fba8389f2b4 100644 (file)
@@ -131,13 +131,13 @@ await Promise.all([
                        })
 
                        await new Promise(async (resolve) => {
-                               console.log('Waiting 60 seconds...')
+                               console.log('Waiting 90 seconds...')
                                setTimeout(async () => {
                                        // should now be locked
                                        assert.equal(wallet.isLocked, true)
                                        assert.equal(Ledger.status, 'LOCKED')
                                        resolve(null)
-                               }, 60000)
+                               }, 900000)
                        })
 
                        await assert.resolves(async () => {