diff --git a/packages/hwk-adapter-core/src/types/connector.ts b/packages/hwk-adapter-core/src/types/connector.ts index c4f838c8e..21f758324 100644 --- a/packages/hwk-adapter-core/src/types/connector.ts +++ b/packages/hwk-adapter-core/src/types/connector.ts @@ -33,7 +33,12 @@ export interface ConnectorSession { deviceInfo: DeviceInfo; } -export type ConnectorEventType = 'device-connect' | 'device-disconnect' | 'ui-request' | 'ui-event'; +export type ConnectorEventType = + | 'device-connect' + | 'device-disconnect' + | 'ui-request' + | 'ui-event' + | 'app-install-progress'; /** * Interaction event types emitted via 'ui-event'. @@ -68,6 +73,10 @@ export interface ConnectorEventMap { 'device-disconnect': { connectId: string }; 'ui-request': { type: string; payload?: unknown }; 'ui-event': ConnectorUiEvent; + // OS-level Ledger app install progress. Emitted from inside the connector + // so the callback ref never has to cross the IHardwareBridge boundary. + // `progress` is a 0..1 fraction reported by DMK's InstallOrUpdateAppsDeviceAction. + 'app-install-progress': { sessionId: string; appName: string; progress: number }; } export interface IConnector { diff --git a/packages/hwk-adapter-core/src/types/errors.ts b/packages/hwk-adapter-core/src/types/errors.ts index 67cd5a0a2..ca751d27c 100644 --- a/packages/hwk-adapter-core/src/types/errors.ts +++ b/packages/hwk-adapter-core/src/types/errors.ts @@ -78,6 +78,8 @@ export enum HardwareErrorCode { WrongApp = 10501, /** 0x911c Command code not supported — app predates current SDK. */ AppTooOld = 10502, + /** Not enough free storage for install/update; user must uninstall apps first. */ + DeviceOutOfMemory = 10503, // --- 11000s EVM (Ledger Ethereum App) APDU-specific --- /** 0x6a80 Invalid data — observed on blindSignTransactionFallback when the diff --git a/packages/hwk-adapter-core/src/types/wallet.ts b/packages/hwk-adapter-core/src/types/wallet.ts index 5d0f166dc..110514a4f 100644 --- a/packages/hwk-adapter-core/src/types/wallet.ts +++ b/packages/hwk-adapter-core/src/types/wallet.ts @@ -83,6 +83,15 @@ export interface HardwareEventMap { // UnlockDevice / InteractionComplete. Subscribe with hw.on('ui-event', handler). 'ui-event': ConnectorUiEvent; + // OS-level Ledger app install progress (forwarded from IConnector + // 'app-install-progress'). The adapter re-emits with `connectId` instead + // of the connector-internal `sessionId`. Subscribe with + // hw.on('app-install-progress', handler). + 'app-install-progress': { + type: 'app-install-progress'; + payload: { connectId: string; appName: string; progress: number }; + }; + // Device events [DEVICE.CONNECT]: { type: typeof DEVICE.CONNECT; payload: DeviceInfo }; [DEVICE.DISCONNECT]: { type: typeof DEVICE.DISCONNECT; payload: { connectId: string } }; diff --git a/packages/hwk-adapter-core/src/utils/errorMessages.ts b/packages/hwk-adapter-core/src/utils/errorMessages.ts index 22d0a73cf..fc3bd0d05 100644 --- a/packages/hwk-adapter-core/src/utils/errorMessages.ts +++ b/packages/hwk-adapter-core/src/utils/errorMessages.ts @@ -22,6 +22,8 @@ export function enrichErrorMessage(code: HardwareErrorCode, originalMessage: str return `${originalMessage}. Please open the correct app on your device.`; case HardwareErrorCode.AppNotInstalled: return `${originalMessage}. The required app is not installed on the device.`; + case HardwareErrorCode.DeviceOutOfMemory: + return `${originalMessage}. Not enough free space on the device. Please uninstall some apps and try again.`; case HardwareErrorCode.TransportNotAvailable: return `${originalMessage}. Ensure the device bridge/transport is available and running.`; case HardwareErrorCode.FirmwareTooOld: diff --git a/packages/hwk-ledger-adapter/src/__tests__/LedgerAdapter.test.ts b/packages/hwk-ledger-adapter/src/__tests__/LedgerAdapter.test.ts index a42e0d3a0..1f87131e2 100644 --- a/packages/hwk-ledger-adapter/src/__tests__/LedgerAdapter.test.ts +++ b/packages/hwk-ledger-adapter/src/__tests__/LedgerAdapter.test.ts @@ -1545,4 +1545,106 @@ describe('LedgerAdapter', () => { } }); }); + + describe('app management', () => { + it('listInstalledApps routes through connector.call with listInstalledApps method', async () => { + connector.call.mockResolvedValueOnce([ + { + versionName: 'Bitcoin', + versionId: 1, + version: '2.4.1', + versionDisplayName: 'Bitcoin', + description: 'BTC app', + icon: null, + bytes: 12345, + currencyId: 'bitcoin', + isDevTools: false, + }, + ]); + await adapter.connectDevice('dev-1'); + const result = await adapter.listInstalledApps('dev-1'); + + expect(connector.call).toHaveBeenCalledWith('session-abc', 'listInstalledApps', {}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.payload[0].versionName).toBe('Bitcoin'); + } + }); + + it('listAvailableApps routes through connector.call with listAvailableApps method', async () => { + connector.call.mockResolvedValueOnce([]); + await adapter.connectDevice('dev-1'); + const result = await adapter.listAvailableApps('dev-1'); + + expect(connector.call).toHaveBeenCalledWith('session-abc', 'listAvailableApps', {}); + expect(result.success).toBe(true); + }); + + it('installApp passes appName through params (no function refs)', async () => { + connector.call.mockResolvedValueOnce(undefined); + await adapter.connectDevice('dev-1'); + const result = await adapter.installApp('dev-1', 'Cardano'); + + expect(result.success).toBe(true); + const [sessionId, method, params] = connector.call.mock.calls[0]; + expect(sessionId).toBe('session-abc'); + expect(method).toBe('installApp'); + expect(params).toEqual({ appName: 'Cardano' }); + // Params must be serializable — no function refs may cross the connector + // boundary (would be dropped by IHardwareBridge structured-clone / JSON). + for (const value of Object.values(params as Record)) { + expect(typeof value).not.toBe('function'); + } + }); + + it('forwards connector app-install-progress events with connectId re-keyed from sessionId', async () => { + connector.call.mockResolvedValueOnce(undefined); + const events: unknown[] = []; + adapter.on('app-install-progress', evt => events.push(evt)); + + await adapter.connectDevice('dev-1'); + // Simulate the connector emitting progress mid-install. + connector._emit('app-install-progress', { + sessionId: 'session-abc', + appName: 'Cardano', + progress: 0.5, + }); + await adapter.installApp('dev-1', 'Cardano'); + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + type: 'app-install-progress', + payload: { connectId: 'dev-1', appName: 'Cardano', progress: 0.5 }, + }); + }); + + it('drops app-install-progress events with no matching session', async () => { + const events: unknown[] = []; + adapter.on('app-install-progress', evt => events.push(evt)); + + // No connectDevice() called → _sessions is empty → forwarder drops. + connector._emit('app-install-progress', { + sessionId: 'stale-session', + appName: 'Cardano', + progress: 0.1, + }); + + expect(events).toHaveLength(0); + }); + + it('installApp surfaces connector errors as failure response', async () => { + connector.call.mockRejectedValueOnce( + Object.assign(new Error('Allow secure connection rejected'), { + code: HardwareErrorCode.UserAborted, + }) + ); + await adapter.connectDevice('dev-1'); + const result = await adapter.installApp('dev-1', 'Cardano'); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.payload.code).toBe(HardwareErrorCode.UserAborted); + } + }); + }); }); diff --git a/packages/hwk-ledger-adapter/src/__tests__/deviceActionToPromise.test.ts b/packages/hwk-ledger-adapter/src/__tests__/deviceActionToPromise.test.ts index 01cceb19f..1df7c8430 100644 --- a/packages/hwk-ledger-adapter/src/__tests__/deviceActionToPromise.test.ts +++ b/packages/hwk-ledger-adapter/src/__tests__/deviceActionToPromise.test.ts @@ -138,6 +138,152 @@ describe('deviceActionToPromise', () => { expect(onInteraction).toHaveBeenCalledWith('interaction-complete'); }); + it('should clear active interaction when DMK reports none after a prompt', async () => { + const onInteraction = jest.fn(); + let observer: + | { + next: (v: unknown) => void; + error?: (e: unknown) => void; + complete?: () => void; + } + | undefined; + const action = { + cancel: jest.fn(), + observable: { + subscribe(nextObserver: { + next: (v: unknown) => void; + error?: (e: unknown) => void; + complete?: () => void; + }) { + observer = nextObserver; + return { unsubscribe: jest.fn() }; + }, + }, + } as unknown as DeviceAction; + const promise = deviceActionToPromise(action, onInteraction, 0); + + observer?.next({ + status: 'pending', + intermediateValue: { + requiredUserInteraction: 'allow-secure-connection', + step: 'os.installOrUpdateApps.steps.updateDeviceMetadata', + }, + }); + observer?.next({ + status: 'pending', + intermediateValue: { + requiredUserInteraction: 'none', + step: 'os.installOrUpdateApps.steps.updateDeviceMetadata', + }, + }); + + expect(onInteraction).toHaveBeenNthCalledWith(1, 'allow-secure-connection'); + expect(onInteraction).toHaveBeenNthCalledWith(2, 'interaction-complete'); + + observer?.next({ status: 'completed', output: 'done' }); + + await expect(promise).resolves.toBe('done'); + expect(onInteraction).toHaveBeenNthCalledWith(3, 'interaction-complete'); + }); + + it('should not clear interaction repeatedly for consecutive none states', async () => { + const onInteraction = jest.fn(); + const action = createMockAction([ + { + status: 'pending', + intermediateValue: { + requiredUserInteraction: 'allow-secure-connection', + step: 'os.installOrUpdateApps.steps.updateDeviceMetadata', + }, + }, + { + status: 'pending', + intermediateValue: { + requiredUserInteraction: 'none', + step: 'os.installOrUpdateApps.steps.updateDeviceMetadata', + }, + }, + { + status: 'pending', + intermediateValue: { + requiredUserInteraction: 'none', + step: 'os.installOrUpdateApps.steps.updateDeviceMetadata', + }, + }, + { status: 'completed', output: 'done' }, + ]); + + await deviceActionToPromise(action, onInteraction); + + expect(onInteraction).toHaveBeenNthCalledWith(1, 'allow-secure-connection'); + expect(onInteraction).toHaveBeenNthCalledWith(2, 'interaction-complete'); + expect(onInteraction).toHaveBeenNthCalledWith(3, 'interaction-complete'); + expect(onInteraction).toHaveBeenCalledTimes(3); + }); + + it('should not emit duplicate events for repeated active interaction states', async () => { + const onInteraction = jest.fn(); + const action = createMockAction([ + { + status: 'pending', + intermediateValue: { requiredUserInteraction: 'confirm-on-device' }, + }, + { + status: 'pending', + intermediateValue: { requiredUserInteraction: 'confirm-on-device' }, + }, + { status: 'pending', intermediateValue: { requiredUserInteraction: 'none' } }, + { status: 'completed', output: 'done' }, + ]); + + await deviceActionToPromise(action, onInteraction); + + expect(onInteraction).toHaveBeenNthCalledWith(1, 'confirm-on-device'); + expect(onInteraction).toHaveBeenNthCalledWith(2, 'interaction-complete'); + expect(onInteraction).toHaveBeenNthCalledWith(3, 'interaction-complete'); + expect(onInteraction).toHaveBeenCalledTimes(3); + }); + + it('should clear active interaction when observable errors directly', async () => { + const onInteraction = jest.fn(); + let observer: + | { + next: (v: unknown) => void; + error?: (e: unknown) => void; + complete?: () => void; + } + | undefined; + const action = { + cancel: jest.fn(), + observable: { + subscribe(nextObserver: { + next: (v: unknown) => void; + error?: (e: unknown) => void; + complete?: () => void; + }) { + observer = nextObserver; + return { unsubscribe: jest.fn() }; + }, + }, + } as unknown as DeviceAction; + const error = new Error('transport failed'); + const promise = deviceActionToPromise(action, onInteraction, 0); + + observer?.next({ + status: 'pending', + intermediateValue: { + requiredUserInteraction: 'allow-secure-connection', + step: 'os.installOrUpdateApps.steps.updateDeviceMetadata', + }, + }); + observer?.error?.(error); + + await expect(promise).rejects.toThrow('transport failed'); + expect(onInteraction).toHaveBeenNthCalledWith(1, 'allow-secure-connection'); + expect(onInteraction).toHaveBeenNthCalledWith(2, 'interaction-complete'); + expect(onInteraction).toHaveBeenCalledTimes(2); + }); + it('should reject if observable completes without result', async () => { const action = createMockAction([{ status: 'pending' }]); await expect(deviceActionToPromise(action)).rejects.toThrow('completed without result'); diff --git a/packages/hwk-ledger-adapter/src/adapter/LedgerAdapter.ts b/packages/hwk-ledger-adapter/src/adapter/LedgerAdapter.ts index 250cb95cd..4d36fe560 100644 --- a/packages/hwk-ledger-adapter/src/adapter/LedgerAdapter.ts +++ b/packages/hwk-ledger-adapter/src/adapter/LedgerAdapter.ts @@ -27,6 +27,7 @@ import { import { isLedgerBleConnectionType } from '../utils/ledgerDmkTransport'; import { debugError, debugLog } from '../utils/debugLog'; +import type { AppMetadata, FirmwareVersion, LedgerDeviceInfo } from '../device-apps/DeviceApps'; import type { BtcAddress, BtcGetAddressParams, @@ -434,6 +435,60 @@ export class LedgerAdapter implements IHardwareWallet { return this.callChain(connectId, deviceId, 'tron', 'tronSignMessage', params); } + // --------------------------------------------------------------------------- + // App management — OS-level Ledger app install / list. Bypasses fingerprint + // and chain-handler dispatch; installApp progress is forwarded to the adapter + // emitter as 'app-install-progress' events. + // --------------------------------------------------------------------------- + + async installApp(connectId: string, appName: string): Promise> { + try { + // Progress is emitted from the connector via the 'app-install-progress' + // event (see appInstallProgressForwarder); no callback is passed here so + // installApp params stay fully serializable across IHardwareBridge. + await this.connectorCall(connectId, 'installApp', { appName }); + return success(undefined); + } catch (err) { + return this.errorToFailure(err); + } + } + + async listInstalledApps(connectId: string): Promise> { + try { + const result = await this.connectorCall(connectId, 'listInstalledApps', {}); + return success(result as AppMetadata[]); + } catch (err) { + return this.errorToFailure(err); + } + } + + async listAvailableApps(connectId: string): Promise> { + try { + const result = await this.connectorCall(connectId, 'listAvailableApps', {}); + return success(result as AppMetadata[]); + } catch (err) { + return this.errorToFailure(err); + } + } + + async getLedgerFirmwareVersion(connectId: string): Promise> { + try { + const result = await this.connectorCall(connectId, 'getFirmwareVersion', {}); + return success(result as FirmwareVersion); + } catch (err) { + return this.errorToFailure(err); + } + } + + async getLedgerDeviceInfo(connectId: string): Promise> { + try { + const result = await this.connectorCall(connectId, 'getDeviceInfo', {}); + return success(result as LedgerDeviceInfo); + } catch (err) { + return this.errorToFailure(err); + } + } + // --------------------------------------------------------------------------- // Events // --------------------------------------------------------------------------- @@ -1548,11 +1603,17 @@ export class LedgerAdapter implements IHardwareWallet { 'code' in err && typeof (err as { code: unknown }).code === 'number' ) { - const e = err as { code: number; message?: string; appName?: string; reason?: string }; + const e = err as { + code: number; + message?: string; + appName?: string; + reason?: string; + params?: Record; + }; const params = e.code === HardwareErrorCode.DevicePermissionDenied && e.reason ? { permissionDeniedReason: e.reason } - : undefined; + : e.params; return ledgerFailure(e.code, e.message ?? 'Unknown error', e.appName, tag, params); } @@ -1596,16 +1657,42 @@ export class LedgerAdapter implements IHardwareWallet { this.emitter.emit('ui-event', event); }; + // Forward 'app-install-progress' from the connector (carries sessionId) to the + // public hw.emitter as a connectId-keyed event. We translate sessionId → connectId + // via the live _sessions map; if no mapping exists (race during teardown) we drop. + private appInstallProgressForwarder = (data: { + sessionId: string; + appName: string; + progress: number; + }): void => { + let connectId: string | undefined; + for (const [cid, sid] of this._sessions) { + if (sid === data.sessionId) { + connectId = cid; + break; + } + } + if (!connectId) { + return; + } + this.emitter.emit('app-install-progress', { + type: 'app-install-progress', + payload: { connectId, appName: data.appName, progress: data.progress }, + }); + }; + private registerEventListeners(): void { this.connector.on('device-connect', this.deviceConnectHandler); this.connector.on('device-disconnect', this.deviceDisconnectHandler); this.connector.on('ui-event', this.uiEventForwarder); + this.connector.on('app-install-progress', this.appInstallProgressForwarder); } private unregisterEventListeners(): void { this.connector.off('device-connect', this.deviceConnectHandler); this.connector.off('device-disconnect', this.deviceDisconnectHandler); this.connector.off('ui-event', this.uiEventForwarder); + this.connector.off('app-install-progress', this.appInstallProgressForwarder); } // --------------------------------------------------------------------------- diff --git a/packages/hwk-ledger-adapter/src/connector/LedgerConnectorBase.ts b/packages/hwk-ledger-adapter/src/connector/LedgerConnectorBase.ts index 8941de5e4..503929367 100644 --- a/packages/hwk-ledger-adapter/src/connector/LedgerConnectorBase.ts +++ b/packages/hwk-ledger-adapter/src/connector/LedgerConnectorBase.ts @@ -29,10 +29,17 @@ import { tronSignMessage, tronSignTransaction, } from './chains'; +import { DeviceAppsManager } from '../device-apps/DeviceAppsManager'; +import { collapseSignerInteraction } from './chains/utils'; +import type { + DeviceApps, + InstallAppCallParams, + ListInstalledAppsCallParams, +} from '../device-apps/DeviceApps'; +import type { ConnectorContext } from './chains/types'; import type { WrapErrorOptions } from '../errors'; import type { CancelReason } from '../signer/deviceActionToPromise'; -import type { ConnectorContext } from './chains/types'; import type { DeviceManagementKit } from '@ledgerhq/device-management-kit'; import type { ConnectionType, @@ -133,6 +140,22 @@ async function defaultLedgerKitImporter(pkg: string): Promise { } } +// Mirrors _getEthSigner in chains/evm.ts. +async function _getDeviceApps(ctx: ConnectorContext, sessionId: string): Promise { + const manager = await ctx.getDeviceAppsManager(); + const apps = await manager.getOrCreate(sessionId); + apps.onInteraction = (interaction: string) => { + ctx.emit('ui-event', { + type: collapseSignerInteraction(interaction), + payload: { sessionId }, + }); + }; + apps.onRegisterCanceller = cancel => { + ctx.registerCanceller(sessionId, cancel); + }; + return apps; +} + // --------------------------------------------------------------------------- // LedgerConnectorBase // --------------------------------------------------------------------------- @@ -164,6 +187,8 @@ export class LedgerConnectorBase implements IConnector { private _signerManager: SignerManager | null = null; + private _deviceAppsManager: DeviceAppsManager | null = null; + private _dmk: DeviceManagementKit | null = null; private readonly _eventHandlers = new Map< @@ -248,6 +273,7 @@ export class LedgerConnectorBase implements IConnector { getOrCreateDmk: () => this._getOrCreateDmk(), getDeviceManager: () => this._getDeviceManager(), getSignerManager: () => this._getSignerManager(), + getDeviceAppsManager: () => this._getDeviceAppsManager(), clearAllSigners: () => this._signerManager?.clearAll(), replaceSession: (oldSid, newSid) => this._replaceSession(oldSid, newSid), registerCanceller: (sid, cancel) => this._cancellers.set(sid, cancel), @@ -694,6 +720,76 @@ export class LedgerConnectorBase implements IConnector { }; return tronSignMessage(ctx, sessionId, internalParams); } + // OS-level device management — symmetric to chain handlers. + // Each case uses try/catch/finally to mirror chain handlers: catch wraps + // raw DMK errors (so mapLedgerError / isOutOfMemoryError run) and + // invalidates the session (so a stale sid isn't reused by subsequent + // listInstalledApps / signTransaction calls). + case 'installApp': { + const p = params as InstallAppCallParams; + const apps = await _getDeviceApps(ctx, sessionId); + try { + // Progress callback is built here (not on params) so the function ref + // never crosses the IHardwareBridge boundary. Emits as a connector + // event; the adapter re-emits to its public typed emitter. + return await apps.install(p.appName, ({ progress }) => { + ctx.emit('app-install-progress', { + sessionId, + appName: p.appName, + progress, + }); + }); + } catch (err) { + ctx.invalidateSession(sessionId); + throw ctx.wrapError(err); + } finally { + ctx.clearCanceller(sessionId); + } + } + case 'listInstalledApps': { + const apps = await _getDeviceApps(ctx, sessionId); + try { + return await apps.listInstalled(params as ListInstalledAppsCallParams); + } catch (err) { + ctx.invalidateSession(sessionId); + throw ctx.wrapError(err); + } finally { + ctx.clearCanceller(sessionId); + } + } + case 'listAvailableApps': { + const apps = await _getDeviceApps(ctx, sessionId); + try { + return await apps.listAvailable(); + } catch (err) { + ctx.invalidateSession(sessionId); + throw ctx.wrapError(err); + } finally { + ctx.clearCanceller(sessionId); + } + } + case 'getFirmwareVersion': { + const apps = await _getDeviceApps(ctx, sessionId); + try { + return await apps.getFirmwareVersion(); + } catch (err) { + ctx.invalidateSession(sessionId); + throw ctx.wrapError(err); + } finally { + ctx.clearCanceller(sessionId); + } + } + case 'getDeviceInfo': { + const apps = await _getDeviceApps(ctx, sessionId); + try { + return await apps.getDeviceInfo(); + } catch (err) { + ctx.invalidateSession(sessionId); + throw ctx.wrapError(err); + } finally { + ctx.clearCanceller(sessionId); + } + } default: throw new Error(`LedgerConnector: unknown method "${method}"`); } @@ -796,6 +892,7 @@ export class LedgerConnectorBase implements IConnector { const mod = await importKit('@ledgerhq/device-signer-kit-ethereum'); return new mod.SignerEthBuilder(args); }); + this._deviceAppsManager = new DeviceAppsManager(dmk, importKit); } private async _getDeviceManager(): Promise { @@ -814,6 +911,14 @@ export class LedgerConnectorBase implements IConnector { return this._signerManager!; } + private async _getDeviceAppsManager(): Promise { + if (!this._deviceAppsManager) { + const dmk = await this._getOrCreateDmk(); + this._initManagers(dmk); + } + return this._deviceAppsManager!; + } + private _invalidateSession(sessionId: string): void { this._signerManager?.invalidate(sessionId); } @@ -856,6 +961,8 @@ export class LedgerConnectorBase implements IConnector { debugLog('[DMK] _resetSignersAndSessions called'); this._signerManager?.clearAll(); this._signerManager = null; + this._deviceAppsManager?.clearAll(); + this._deviceAppsManager = null; this._deviceManager?.disposeKeepingDmk(); this._deviceManager = null; } @@ -883,9 +990,11 @@ export class LedgerConnectorBase implements IConnector { } this._sessionStateSubs.clear(); this._signerManager?.clearAll(); + this._deviceAppsManager?.clearAll(); this._deviceManager?.dispose(); this._deviceManager = null; this._signerManager = null; + this._deviceAppsManager = null; this._dmk = null; } diff --git a/packages/hwk-ledger-adapter/src/connector/chains/types.ts b/packages/hwk-ledger-adapter/src/connector/chains/types.ts index d5dbebf21..578c08d66 100644 --- a/packages/hwk-ledger-adapter/src/connector/chains/types.ts +++ b/packages/hwk-ledger-adapter/src/connector/chains/types.ts @@ -1,5 +1,6 @@ import type { ConnectorEventMap, ConnectorEventType } from '@onekeyfe/hwk-adapter-core'; import type { DeviceManagementKit } from '@ledgerhq/device-management-kit'; +import type { DeviceAppsManager } from '../../device-apps/DeviceAppsManager'; import type { SignerManager } from '../../signer/SignerManager'; import type { LedgerDeviceManager } from '../../device/LedgerDeviceManager'; import type { WrapErrorOptions } from '../../errors'; @@ -16,6 +17,7 @@ export interface ConnectorContext { getOrCreateDmk(): Promise; getDeviceManager(): Promise; getSignerManager(): Promise; + getDeviceAppsManager(): Promise; clearAllSigners(): void; /** * Notify the connector that a session has been replaced (e.g. after app switch). diff --git a/packages/hwk-ledger-adapter/src/device-apps/DeviceApps.ts b/packages/hwk-ledger-adapter/src/device-apps/DeviceApps.ts new file mode 100644 index 000000000..c4bc7d992 --- /dev/null +++ b/packages/hwk-ledger-adapter/src/device-apps/DeviceApps.ts @@ -0,0 +1,260 @@ +import { deviceActionToPromise } from '../signer/deviceActionToPromise'; +import { debugLog } from '../utils/debugLog'; +import { GetOsVersionDeviceAction, ListAvailableAppsDeviceAction } from './customActions'; +import { AppNotFoundInCatalogError } from './errors'; + +import type { DmkApplication } from './customActions'; +import type { DeviceManagementKit, GetOsVersionResponse } from '@ledgerhq/device-management-kit'; +import type { CancelReason } from '../signer/deviceActionToPromise'; + +const INSTALL_TIMEOUT_MS = 5 * 60_000; + +export interface AppMetadata { + versionName: string; + versionId: number; + version: string; + versionDisplayName: string | null; + description: string | null; + icon: string | null; + bytes: number | null; + currencyId: string | null; + isDevTools: boolean; +} + +/** + * Progress fraction reported by DMK's InstallOrUpdateAppsDeviceAction. + * Note: DMK's raw `requiredUserInteraction` for install (e.g. allow-secure-connection, + * allow-manager) is intentionally NOT exposed here — those signals are routed through + * the public 'ui-event' channel (collapsed to EConnectorInteraction) and additionally + * captured via debugLog in the connector for diagnosis. + */ +export interface InstallProgress { + progress: number; +} + +export type InstallProgressCallback = (progress: InstallProgress) => void; + +export interface InstallAppCallParams { + appName: string; +} + +export interface ListInstalledAppsCallParams { + unlockTimeout?: number; +} + +export interface FirmwareVersion { + /** BOLOS version on the secure element — the user-facing firmware version. */ + seVersion: string; + /** MCU SEPH (SE–MCU link protocol) version. */ + mcuSephVersion: string; + /** MCU bootloader version. */ + mcuBootloaderVersion: string; + /** Hardware revision (e.g. "00" / "01" on Nano X). */ + hwVersion: string; +} + +/** Full GetOsVersionResponse projected to a plain serializable shape. */ +export interface LedgerDeviceInfo extends FirmwareVersion { + isBootloader: boolean; + isOsu: boolean; + targetId: number; + seTargetId?: number; + mcuTargetId?: number; + /** Secure element flags as hex string (raw bytes turned readable). */ + seFlagsHex: string; +} + +/** + * OS-level device management (install, list apps). Mirrors SignerEth: + * built fresh per call, consumer sets onInteraction / onRegisterCanceller. + */ +export class DeviceApps { + onInteraction?: (interaction: string) => void; + + onRegisterCanceller?: (cancel: (reason?: CancelReason) => void) => void; + + /* eslint-disable no-useless-constructor, no-empty-function -- TS parameter properties pattern */ + constructor( + private readonly _dmk: DeviceManagementKit, + private readonly _sessionId: string, + private readonly _ledgerKit: LedgerKitModule + ) {} + /* eslint-enable no-useless-constructor, no-empty-function */ + + async listInstalled(options?: { unlockTimeout?: number }): Promise { + const action = (this._dmk as unknown as DmkExecuteCapable).executeDeviceAction({ + sessionId: this._sessionId, + deviceAction: new this._ledgerKit.ListAppsWithMetadataDeviceAction({ + input: { unlockTimeout: options?.unlockTimeout }, + }), + }); + const result = await deviceActionToPromise>( + action, + this.onInteraction, + undefined, + this.onRegisterCanceller + ); + return result.filter((a): a is DmkApplication => a !== null).map(applicationToMetadata); + } + + // Catalog lookup via custom device action — DMK has no typed wrapper for this. + async listAvailable(): Promise { + const customAction = new ListAvailableAppsDeviceAction({ + GetOsVersionCommand: this._ledgerKit.GetOsVersionCommand, + isSuccessCommandResult: this._ledgerKit.isSuccessCommandResult, + }); + const action = (this._dmk as unknown as DmkExecuteCapable).executeDeviceAction({ + sessionId: this._sessionId, + deviceAction: customAction, + }); + const result = await deviceActionToPromise( + action, + this.onInteraction, + undefined, + this.onRegisterCanceller + ); + return result.map(applicationToMetadata); + } + + async getFirmwareVersion(): Promise { + const v = await this._fetchOsVersion(); + return { + seVersion: v.seVersion, + mcuSephVersion: v.mcuSephVersion, + mcuBootloaderVersion: v.mcuBootloaderVersion, + hwVersion: v.hwVersion, + }; + } + + async getDeviceInfo(): Promise { + const v = await this._fetchOsVersion(); + return { + isBootloader: v.isBootloader, + isOsu: v.isOsu, + targetId: v.targetId, + seTargetId: v.seTargetId, + mcuTargetId: v.mcuTargetId, + seVersion: v.seVersion, + seFlagsHex: bytesToHex(v.seFlags), + mcuSephVersion: v.mcuSephVersion, + mcuBootloaderVersion: v.mcuBootloaderVersion, + hwVersion: v.hwVersion, + }; + } + + // Sole sendCommand path — routed via executeDeviceAction so unlock-device + // interaction flows through onInteraction like the other methods. + private async _fetchOsVersion(): Promise { + const customAction = new GetOsVersionDeviceAction({ + GetOsVersionCommand: this._ledgerKit.GetOsVersionCommand, + isSuccessCommandResult: this._ledgerKit.isSuccessCommandResult, + }); + const action = (this._dmk as unknown as DmkExecuteCapable).executeDeviceAction({ + sessionId: this._sessionId, + deviceAction: customAction, + }); + return deviceActionToPromise( + action, + this.onInteraction, + undefined, + this.onRegisterCanceller + ); + } + + async install( + appName: string, + onProgress?: InstallProgressCallback, + options?: { unlockTimeout?: number } + ): Promise { + if (!appName) throw new Error('DeviceApps.install: appName is required'); + debugLog('[DeviceApps] install:', appName); + + // Use InstallOrUpdateAppsDeviceAction (not InstallAppDeviceAction): it runs + // UPDATE_DEVICE_METADATA → BUILD_INSTALL_PLAN → CHECK_IF_ENOUGH_MEMORY → + // INSTALL_APPLICATION, so OOM fails fast (zero bytes written) and the refreshed + // metadata feeds PredictOutOfMemoryTask accurate sizes — no client-side estimate. + const action = (this._dmk as unknown as DmkExecuteCapable).executeDeviceAction({ + sessionId: this._sessionId, + deviceAction: new this._ledgerKit.InstallOrUpdateAppsDeviceAction({ + input: { + applications: [{ name: appName }], + allowMissingApplication: false, + unlockTimeout: options?.unlockTimeout, + }, + }), + }); + + const result = await deviceActionToPromise( + action, + this.onInteraction, + INSTALL_TIMEOUT_MS, + this.onRegisterCanceller, + intermediateValue => { + const iv = intermediateValue as + | { + requiredUserInteraction?: string; + installPlan?: { currentProgress?: number } | null; + } + | undefined; + // Surface DMK's install-specific interaction string (e.g. allow-secure-connection, + // allow-manager, verify-app) into the debug log for post-hoc diagnosis. The + // public 'ui-event' channel still emits the collapsed EConnectorInteraction so + // existing UI consumers keep working. + if (iv?.requiredUserInteraction && iv.requiredUserInteraction !== 'none') { + debugLog('[DeviceApps] install interaction:', iv.requiredUserInteraction); + } + const progress = iv?.installPlan?.currentProgress; + if (onProgress && typeof progress === 'number') { + onProgress({ progress }); + } + } + ); + + // DMK can resolve Completed with `missingApplications` populated when the + // requested name is not in the catalog. allowMissingApplication=false should + // make DMK reject, but we double-check on this side so the adapter never + // returns success(undefined) for an install that didn't actually install. + if (result?.missingApplications?.length > 0) { + throw new AppNotFoundInCatalogError(result.missingApplications); + } + } +} + +function bytesToHex(bytes: Uint8Array | undefined): string { + if (!bytes) return ''; + return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); +} + +function applicationToMetadata(app: DmkApplication): AppMetadata { + return { + versionName: app.versionName, + versionId: app.versionId, + version: app.version, + versionDisplayName: app.versionDisplayName, + description: app.description, + icon: app.icon, + bytes: app.bytes, + currencyId: app.currencyId, + isDevTools: app.isDevTools, + }; +} + +// Loosened DMK surface (we receive the module via dynamic importLedgerKit). +export interface LedgerKitModule { + ListAppsWithMetadataDeviceAction: new (args: { input: unknown }) => unknown; + InstallOrUpdateAppsDeviceAction: new (args: { input: unknown }) => unknown; + GetOsVersionCommand: new () => unknown; + isSuccessCommandResult: (result: unknown) => result is { data: GetOsVersionResponse }; +} + +interface InstallOrUpdateAppsOutput { + successfullyInstalled: unknown[]; + alreadyInstalled: string[]; + missingApplications: string[]; +} + +interface DmkExecuteCapable { + // Loose return: deviceActionToPromise narrows the observable shape per-call. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + executeDeviceAction(args: { sessionId: string; deviceAction: unknown }): any; +} diff --git a/packages/hwk-ledger-adapter/src/device-apps/DeviceAppsManager.ts b/packages/hwk-ledger-adapter/src/device-apps/DeviceAppsManager.ts new file mode 100644 index 000000000..f8e14a8dc --- /dev/null +++ b/packages/hwk-ledger-adapter/src/device-apps/DeviceAppsManager.ts @@ -0,0 +1,29 @@ +import { DeviceApps } from './DeviceApps'; + +import type { LedgerKitModule } from './DeviceApps'; +import type { DeviceManagementKit } from '@ledgerhq/device-management-kit'; + +/** Per-call DeviceApps factory (mirrors SignerManager); DMK actions hold per-action state. */ +export class DeviceAppsManager { + private readonly _dmk: DeviceManagementKit; + + private readonly _importLedgerKit: (pkg: string) => Promise; + + constructor(dmk: DeviceManagementKit, importLedgerKit: (pkg: string) => Promise) { + this._dmk = dmk; + this._importLedgerKit = importLedgerKit; + } + + async getOrCreate(sessionId: string): Promise { + const ledgerKit = (await this._importLedgerKit( + '@ledgerhq/device-management-kit' + )) as LedgerKitModule; + return new DeviceApps(this._dmk, sessionId, ledgerKit); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars, class-methods-use-this + invalidate(_sessionId: string): void {} + + // eslint-disable-next-line class-methods-use-this + clearAll(): void {} +} diff --git a/packages/hwk-ledger-adapter/src/device-apps/customActions.ts b/packages/hwk-ledger-adapter/src/device-apps/customActions.ts new file mode 100644 index 000000000..6e381bdea --- /dev/null +++ b/packages/hwk-ledger-adapter/src/device-apps/customActions.ts @@ -0,0 +1,157 @@ +/* eslint-disable max-classes-per-file -- both classes are duck-typed DMK DeviceAction implementations sharing the same internal types (OsVersionDeps / InternalApiLike / AnyState); splitting would push those types into a third file with no payoff. */ +/** + * Duck-typed DMK DeviceAction implementations for the two flows DMK has no + * typed wrapper for: fetching OS version and listing available apps in the + * catalog. Both are routed through `executeDeviceAction` so the + * `unlock-device` interaction surfaces through the same onInteraction + * pipeline as every other call. + */ +import { DeviceActionStatus } from '@ledgerhq/device-management-kit'; +import { Subject } from 'rxjs'; + +import type { GetOsVersionResponse } from '@ledgerhq/device-management-kit'; +import type { Observable } from 'rxjs'; + +export interface DmkApplication { + versionName: string; + versionId: number; + version: string; + versionDisplayName: string | null; + description: string | null; + icon: string | null; + bytes: number | null; + currencyId: string | null; + isDevTools: boolean; +} + +export type OsVersionDeps = { + GetOsVersionCommand: new () => unknown; + isSuccessCommandResult: (result: unknown) => result is { data: GetOsVersionResponse }; +}; + +interface InternalApiLike { + sendCommand: (command: unknown) => Promise; + getManagerApiService: () => { + getAppList: (deviceInfo: GetOsVersionResponse) => { + run: () => Promise<{ isLeft: () => boolean; extract: () => unknown }>; + }; + }; +} + +type AnyState = + | { readonly status: DeviceActionStatus.NotStarted } + | { readonly status: DeviceActionStatus.Pending; readonly intermediateValue: unknown } + | { readonly status: DeviceActionStatus.Stopped } + | { readonly status: DeviceActionStatus.Completed; readonly output: T } + | { readonly status: DeviceActionStatus.Error; readonly error: unknown }; + +// Wrapped as a DeviceAction (not raw sendCommand) so unlock-device interaction +// flows through onInteraction like every other method. +export class GetOsVersionDeviceAction { + readonly input = undefined; + + // eslint-disable-next-line no-useless-constructor, no-empty-function + constructor(private readonly _deps: OsVersionDeps) {} + + _execute(internalApi: { sendCommand: (cmd: unknown) => Promise }): { + observable: Observable>; + cancel: () => void; + } { + const subject = new Subject>(); + let cancelled = false; + + (async () => { + try { + subject.next({ + status: DeviceActionStatus.Pending, + intermediateValue: { requiredUserInteraction: 'none' }, + }); + const result = await internalApi.sendCommand(new this._deps.GetOsVersionCommand()); + if (cancelled) return; + if (!this._deps.isSuccessCommandResult(result)) { + const errObj = (result as { error?: { message?: string } })?.error; + throw new Error(errObj?.message ?? 'GetOsVersionCommand failed'); + } + subject.next({ status: DeviceActionStatus.Completed, output: result.data }); + subject.complete(); + } catch (err) { + if (cancelled) return; + subject.next({ status: DeviceActionStatus.Error, error: err }); + subject.complete(); + } + })(); + + return { + observable: subject.asObservable(), + cancel: () => { + cancelled = true; + subject.next({ status: DeviceActionStatus.Stopped }); + subject.complete(); + }, + }; + } +} + +export class ListAvailableAppsDeviceAction { + readonly input = undefined; + + private readonly _GetOsVersionCommand: new () => unknown; + + private readonly _isSuccessCommandResult: ( + result: unknown + ) => result is { data: GetOsVersionResponse }; + + constructor(deps: OsVersionDeps) { + this._GetOsVersionCommand = deps.GetOsVersionCommand; + this._isSuccessCommandResult = deps.isSuccessCommandResult; + } + + _execute(internalApi: InternalApiLike): { + observable: Observable>; + cancel: () => void; + } { + const subject = new Subject>(); + let cancelled = false; + + (async () => { + try { + subject.next({ + status: DeviceActionStatus.Pending, + intermediateValue: { requiredUserInteraction: 'none' }, + }); + + const osVersionResult = await internalApi.sendCommand(new this._GetOsVersionCommand()); + if (cancelled) return; + if (!this._isSuccessCommandResult(osVersionResult)) { + const errObj = (osVersionResult as { error?: { message?: string } })?.error; + throw new Error(errObj?.message ?? 'GetOsVersionCommand failed'); + } + + const managerApi = internalApi.getManagerApiService(); + const either = await managerApi.getAppList(osVersionResult.data).run(); + if (cancelled) return; + if (either.isLeft()) { + const httpErr = either.extract() as { message?: string }; + throw new Error(httpErr?.message ?? 'Manager API getAppList failed'); + } + + const apps = either.extract() as DmkApplication[]; + subject.next({ status: DeviceActionStatus.Completed, output: apps }); + subject.complete(); + } catch (err) { + if (cancelled) return; + subject.next({ status: DeviceActionStatus.Error, error: err }); + subject.complete(); + } + })(); + + return { + observable: subject.asObservable(), + cancel: () => { + cancelled = true; + subject.next({ status: DeviceActionStatus.Stopped }); + subject.complete(); + }, + }; + } +} diff --git a/packages/hwk-ledger-adapter/src/device-apps/errors.ts b/packages/hwk-ledger-adapter/src/device-apps/errors.ts new file mode 100644 index 000000000..3f6adf180 --- /dev/null +++ b/packages/hwk-ledger-adapter/src/device-apps/errors.ts @@ -0,0 +1,10 @@ +/** Raised by DeviceApps.install when DMK reports the requested app is not in the catalog. */ +export class AppNotFoundInCatalogError extends Error { + readonly missingApplications: string[]; + + constructor(missing: string[]) { + super(`Ledger app not found in catalog: ${missing.join(', ')}`); + this.name = 'AppNotFoundInCatalogError'; + this.missingApplications = missing; + } +} diff --git a/packages/hwk-ledger-adapter/src/errors.ts b/packages/hwk-ledger-adapter/src/errors.ts index fccb4f03d..bf87c6172 100644 --- a/packages/hwk-ledger-adapter/src/errors.ts +++ b/packages/hwk-ledger-adapter/src/errors.ts @@ -407,6 +407,13 @@ export function isAppNotInstalledError(err: unknown): boolean { return false; } +/** DMK install ran out of space on the device. Identified by the DMK error tag. */ +export function isOutOfMemoryError(err: unknown): boolean { + if (!err || typeof err !== 'object') return false; + const e = err as Record; + return e._tag === 'OutOfMemoryDAError'; +} + /** Check for device disconnected errors. */ export function isDeviceDisconnectedError(err: unknown): boolean { if (!err || typeof err !== 'object') return false; @@ -512,6 +519,8 @@ export function mapLedgerError( code = HardwareErrorCode.WrongApp; } else if (isAppNotInstalledError(err)) { code = HardwareErrorCode.AppNotInstalled; + } else if (isOutOfMemoryError(err)) { + code = HardwareErrorCode.DeviceOutOfMemory; } else if (isDeviceDisconnectedError(err)) { code = HardwareErrorCode.DeviceDisconnected; } else if (isTimeoutError(err)) { diff --git a/packages/hwk-ledger-adapter/src/signer/deviceActionToPromise.ts b/packages/hwk-ledger-adapter/src/signer/deviceActionToPromise.ts index d4cc89cd0..1e65ec797 100644 --- a/packages/hwk-ledger-adapter/src/signer/deviceActionToPromise.ts +++ b/packages/hwk-ledger-adapter/src/signer/deviceActionToPromise.ts @@ -1,5 +1,7 @@ import { DeviceActionStatus } from '@ledgerhq/device-management-kit'; +import { debugLog } from '../utils/debugLog'; + import type { DeviceAction, DeviceActionState } from '../types'; /** @@ -38,11 +40,13 @@ export function deviceActionToPromise( action: DeviceAction, onInteraction?: (interaction: string) => void, timeoutMs: number = IDLE_WATCHDOG_MS, - onRegisterCanceller?: (cancel: (reason?: CancelReason) => void) => void + onRegisterCanceller?: (cancel: (reason?: CancelReason) => void) => void, + onIntermediate?: (intermediateValue: unknown) => void ): Promise { return new Promise((resolve, reject) => { let settled = false; let lastStep: string | undefined; + let activeInteraction: string | undefined; const observedSteps: string[] = []; // eslint-disable-next-line prefer-const -- assigned once after declaration, but must be declared before use in cleanup let sub: { unsubscribe: () => void }; @@ -62,12 +66,65 @@ export function deviceActionToPromise( } }; + const clearActiveInteraction = () => { + if (activeInteraction) { + debugLog( + '[DeviceAction] requiredUserInteraction', + 'interaction=none', + `prev=${activeInteraction}`, + `step=${lastStep ?? 'unknown'}` + ); + activeInteraction = undefined; + onInteraction?.('interaction-complete'); + return true; + } + return false; + }; + + const emitInteraction = (interaction: unknown) => { + if (!onInteraction) { + return; + } + + if (!interaction || interaction === 'none') { + clearActiveInteraction(); + return; + } + + const interactionName = String(interaction); + if (interactionName === activeInteraction) { + return; + } + + activeInteraction = interactionName; + debugLog( + '[DeviceAction] requiredUserInteraction', + `interaction=${interactionName}`, + `step=${lastStep ?? 'unknown'}` + ); + // unlock-device is DMK's own bounded poll. Other interaction + // states keep the caller-provided watchdog so a stuck observable + // cannot hold the queue slot forever. + if (interaction === 'unlock-device' && timer) { + clearTimeout(timer); + timer = null; + } + onInteraction(interactionName); + }; + + const completeInteraction = () => { + if (!clearActiveInteraction()) { + onInteraction?.('interaction-complete'); + } + }; + const armIdleWatchdog = () => { if (timer) clearTimeout(timer); if (timeoutMs > 0) { timer = setTimeout(() => { if (!settled) { settled = true; + completeInteraction(); cancelAction(); reject( new Error( @@ -89,6 +146,7 @@ export function deviceActionToPromise( } settled = true; if (timer) clearTimeout(timer); + completeInteraction(); cancelAction(); const err = new Error(reason?.message ?? 'Device action cancelled'); if (reason?.code !== undefined) { @@ -127,26 +185,26 @@ export function deviceActionToPromise( if (state.status === DeviceActionStatus.Completed) { settled = true; if (timer) clearTimeout(timer); - onInteraction?.('interaction-complete'); + completeInteraction(); sub?.unsubscribe(); resolve(state.output); } else if (state.status === DeviceActionStatus.Error) { settled = true; if (timer) clearTimeout(timer); - onInteraction?.('interaction-complete'); + completeInteraction(); sub?.unsubscribe(); rejectWithStepContext(state.error, lastStep, observedSteps, reject); - } else if (state.status === DeviceActionStatus.Pending && onInteraction) { - const interaction = state.intermediateValue?.requiredUserInteraction; - if (interaction && interaction !== 'none') { - // unlock-device is DMK's own bounded poll. Other interaction - // states keep the caller-provided watchdog so a stuck observable - // cannot hold the queue slot forever. - if (interaction === 'unlock-device' && timer) { - clearTimeout(timer); - timer = null; + } else if (state.status === DeviceActionStatus.Pending) { + if (onIntermediate) { + try { + onIntermediate(state.intermediateValue); + } catch { + // listener errors must not abort the device action } - onInteraction(String(interaction)); + } + if (onInteraction) { + const interaction = state.intermediateValue?.requiredUserInteraction; + emitInteraction(interaction); } } }, @@ -154,6 +212,7 @@ export function deviceActionToPromise( if (!settled) { settled = true; if (timer) clearTimeout(timer); + completeInteraction(); sub?.unsubscribe(); rejectWithStepContext(err, lastStep, observedSteps, reject); } @@ -162,6 +221,7 @@ export function deviceActionToPromise( if (!settled) { settled = true; if (timer) clearTimeout(timer); + completeInteraction(); reject(new Error('Device action completed without result')); } },