Skip to content
11 changes: 10 additions & 1 deletion packages/hwk-adapter-core/src/types/connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'.
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions packages/hwk-adapter-core/src/types/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions packages/hwk-adapter-core/src/types/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } };
Expand Down
2 changes: 2 additions & 0 deletions packages/hwk-adapter-core/src/utils/errorMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
102 changes: 102 additions & 0 deletions packages/hwk-ledger-adapter/src/__tests__/LedgerAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)) {
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);
}
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
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<string>;
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');
Expand Down
Loading
Loading