@onekeyfe/hardware-cli 1.2.0-alpha.1 → 1.2.0-alpha.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -1,11 +1,9 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
1
3
  import { Command } from 'commander';
4
+ import { UI_EVENT, UI_REQUEST, getDeviceType } from '@onekeyfe/hd-core';
5
+ import { EDeviceType } from '@onekeyfe/hd-shared';
2
6
 
3
- import { createSDK, disposeSDK } from './sdk';
4
- import {
5
- clearSessionFromKeychain,
6
- preloadSessionFromKeychain,
7
- saveSessionToKeychain,
8
- } from './session';
9
7
  import {
10
8
  resolveBatchGetAddress,
11
9
  resolveGetAddress,
@@ -13,61 +11,31 @@ import {
13
11
  resolveSignMessage,
14
12
  resolveSignTransaction,
15
13
  } from './chains';
14
+ import { selectSearchDevice } from './deviceSelection';
15
+ import { getCanonicalDeviceState, getCompatibleFeatures } from './deviceStateCommands';
16
+ import { createSDK, disposeSDK } from './sdk';
17
+ import { clearSessionFromKeychain, preloadSessionFromKeychain } from './session';
16
18
 
17
- import { EDeviceType } from '@onekeyfe/hd-shared';
18
- import { getDeviceType } from '@onekeyfe/hd-core';
19
19
  import type {
20
+ DeviceStateScope,
20
21
  EthereumSignTypedDataMessage,
21
22
  EthereumSignTypedDataTypes,
22
23
  Features,
23
- IDeviceType,
24
24
  SearchDevice,
25
25
  } from '@onekeyfe/hd-core';
26
26
 
27
27
  /** SearchDevice enriched with features fetched after discovery */
28
28
  type EnrichedSearchDevice = SearchDevice & { features?: Features };
29
29
 
30
- function extractPassphraseSession(payload: unknown): {
31
- passphraseState?: string;
32
- sessionId?: string;
33
- } {
34
- if (typeof payload === 'string') {
35
- return { passphraseState: payload };
36
- }
37
- if (!payload || typeof payload !== 'object') {
38
- return {};
39
- }
40
-
41
- const statePayload = payload as {
42
- passphrase_state?: unknown;
43
- passphraseState?: unknown;
44
- session_id?: unknown;
45
- sessionId?: unknown;
46
- };
47
-
48
- let passphraseState: string | undefined;
49
- if (typeof statePayload.passphrase_state === 'string') {
50
- passphraseState = statePayload.passphrase_state;
51
- } else if (typeof statePayload.passphraseState === 'string') {
52
- passphraseState = statePayload.passphraseState;
53
- }
54
-
55
- let sessionId: string | undefined;
56
- if (typeof statePayload.session_id === 'string') {
57
- sessionId = statePayload.session_id;
58
- } else if (typeof statePayload.sessionId === 'string') {
59
- sessionId = statePayload.sessionId;
60
- }
61
-
62
- return { passphraseState, sessionId };
63
- }
64
-
65
30
  const program = new Command();
31
+ const { version: cliVersion } = JSON.parse(
32
+ readFileSync(resolve(__dirname, '../package.json'), 'utf8')
33
+ ) as { version: string };
66
34
 
67
35
  program
68
36
  .name('onekey-hw')
69
37
  .description('OneKey hardware wallet CLI for AI agent integration')
70
- .version('1.1.26-alpha.1');
38
+ .version(cliVersion);
71
39
 
72
40
  // ============================================================
73
41
  // Global Options
@@ -78,8 +46,10 @@ program.option(
78
46
  '--device-id <id>',
79
47
  'Persistent device ID from getFeatures (changes when seed changes)'
80
48
  );
49
+ program.option('--transport <transport>', 'Transport to use: usb or ble', 'usb');
81
50
  program.option('--passphrase-state <state>', 'Passphrase state for hidden wallet access');
82
51
  program.option('--use-empty-passphrase', 'Use standard wallet (skip passphrase prompt)');
52
+ program.option('--debug', 'Enable SDK debug logs');
83
53
 
84
54
  // ============================================================
85
55
  // Device Commands
@@ -91,28 +61,6 @@ program
91
61
  .action(() =>
92
62
  runCommand({}, async ({ sdk, globalOpts }) => {
93
63
  const result = await sdk.searchDevices();
94
-
95
- // Auto-fetch features for each discovered device (doesn't require PIN)
96
- if (result?.success && Array.isArray(result.payload)) {
97
- for (const device of result.payload as EnrichedSearchDevice[]) {
98
- if (device.connectId) {
99
- try {
100
- const features = await sdk.getFeatures(device.connectId);
101
- if (features?.success && features.payload) {
102
- device.features = features.payload;
103
- device.name = features.payload.label || features.payload.bleName || device.name;
104
- const devType = features.payload.deviceType?.toLowerCase();
105
- if (devType) {
106
- device.deviceType = devType as IDeviceType;
107
- }
108
- }
109
- } catch {
110
- // Features fetch failed — device may need PIN, continue with basic info
111
- }
112
- }
113
- }
114
- }
115
-
116
64
  outputResult(globalOpts, result);
117
65
  })
118
66
  );
@@ -122,28 +70,120 @@ program
122
70
  .description('Get device features (firmware, unlock state, passphrase protection, etc.)')
123
71
  .action(() =>
124
72
  runCommand({}, async ({ sdk, globalOpts }) => {
125
- // Resolve connectId: explicit flag wins, else pick the first attached device
126
- let { connectId } = globalOpts as { connectId?: string };
127
- if (!connectId) {
128
- const searchResult = await sdk.searchDevices();
129
- if (
130
- !searchResult?.success ||
131
- !Array.isArray(searchResult.payload) ||
132
- searchResult.payload.length === 0
133
- ) {
134
- outputResult(globalOpts, {
135
- success: false,
136
- payload: { error: 'No device found', code: 'NO_DEVICE' },
137
- });
138
- return;
139
- }
140
- connectId = (searchResult.payload[0] as EnrichedSearchDevice).connectId ?? undefined;
73
+ const result = await getCompatibleFeatures(sdk, globalOpts.connectId);
74
+ outputResult(globalOpts, result);
75
+ })
76
+ );
77
+
78
+ program
79
+ .command('get-state')
80
+ .description('Get canonical device state for Protocol V1 and Protocol V2 devices')
81
+ .option('--scope <scope>', 'State refresh scope: runtime, settings, or firmware', 'runtime')
82
+ .action((opts: { scope: string }) =>
83
+ runCommand({}, async ({ sdk, globalOpts }) => {
84
+ const supportedScopes: DeviceStateScope[] = ['runtime', 'settings', 'firmware'];
85
+ if (!supportedScopes.includes(opts.scope as DeviceStateScope)) {
86
+ const error = new Error(`Unsupported device state scope: ${opts.scope}`);
87
+ (error as Error & { code?: string }).code = 'INVALID_DEVICE_STATE_SCOPE';
88
+ throw error;
141
89
  }
142
- const result = await sdk.getFeatures(connectId || '');
90
+ const result = await getCanonicalDeviceState(
91
+ sdk,
92
+ globalOpts.connectId,
93
+ opts.scope as DeviceStateScope
94
+ );
143
95
  outputResult(globalOpts, result);
144
96
  })
145
97
  );
146
98
 
99
+ program
100
+ .command('upload-wallpaper')
101
+ .description('Upload and activate a Pro2 wallpaper')
102
+ .requiredOption('--rgba <path>', '604x1024 raw RGBA file')
103
+ .option('--file-name <name>', 'Device wallpaper file name', 'wallpaper-cli')
104
+ .option('--chunk-size <bytes>', 'Transfer chunk size in bytes')
105
+ .action(opts =>
106
+ runCommand({}, async ({ sdk, globalOpts, params }) => {
107
+ const rgba = readBinaryParam(opts.rgba);
108
+ const expectedBytes = 604 * 1024 * 4;
109
+ if (rgba.byteLength !== expectedBytes) {
110
+ throw new Error(
111
+ `Invalid RGBA size: expected ${expectedBytes} bytes, received ${rgba.byteLength}`
112
+ );
113
+ }
114
+
115
+ let transferStartedAt: number | undefined;
116
+ let transferEndedAt: number | undefined;
117
+ let lastProgress = -1;
118
+ let lastPrintedProgress = -10;
119
+ let progressTotalBytes = 0;
120
+ let transferredBytes = 0;
121
+ const totalStartedAt = Date.now();
122
+ const onUiEvent = (message: unknown) => {
123
+ if (!message || typeof message !== 'object') return;
124
+ const event = message as {
125
+ type?: string;
126
+ payload?: {
127
+ progress?: number;
128
+ transferredBytes?: number;
129
+ totalBytes?: number;
130
+ rateBytesPerSecond?: number;
131
+ };
132
+ };
133
+ if (event.type !== UI_REQUEST.DEVICE_PROGRESS || !event.payload) return;
134
+ const progress = Number(event.payload.progress);
135
+ if (!Number.isFinite(progress)) return;
136
+ transferStartedAt ??= Date.now();
137
+ lastProgress = Math.max(lastProgress, progress);
138
+ const totalBytes = Number(event.payload.totalBytes);
139
+ if (Number.isFinite(totalBytes) && totalBytes > 0) progressTotalBytes = totalBytes;
140
+ const confirmedBytes = Number(event.payload.transferredBytes);
141
+ if (Number.isFinite(confirmedBytes) && confirmedBytes >= 0) {
142
+ transferredBytes = Math.max(transferredBytes, confirmedBytes);
143
+ }
144
+ const printableProgress = Math.floor(progress / 10) * 10;
145
+ if (printableProgress > lastPrintedProgress || progress >= 100) {
146
+ const rate = Number(event.payload.rateBytesPerSecond);
147
+ const rateText =
148
+ Number.isFinite(rate) && rate > 0 ? ` ${(rate / 1024).toFixed(2)} KiB/s` : '';
149
+ process.stderr.write(
150
+ `[onekey-hw] Wallpaper transfer: ${Math.round(progress)}%${rateText}\n`
151
+ );
152
+ lastPrintedProgress = progress >= 100 ? 100 : printableProgress;
153
+ }
154
+ if (progress >= 100) transferEndedAt ??= Date.now();
155
+ };
156
+
157
+ sdk.on(UI_EVENT, onUiEvent);
158
+ let result: any;
159
+ try {
160
+ result = await sdk.deviceUploadWallpaper(globalOpts.connectId, {
161
+ ...params,
162
+ width: 604,
163
+ height: 1024,
164
+ rgba,
165
+ fileName: opts.fileName,
166
+ chunkSize: opts.chunkSize ? safeParseInt(opts.chunkSize, '--chunk-size') : undefined,
167
+ });
168
+ } finally {
169
+ sdk.off?.(UI_EVENT, onUiEvent);
170
+ }
171
+
172
+ const endedAt = transferEndedAt ?? Date.now();
173
+ const totalBytes = Number(result?.payload?.size) || progressTotalBytes;
174
+ outputResult(globalOpts, {
175
+ ...result,
176
+ metrics: buildWallpaperUploadMetrics({
177
+ totalBytes,
178
+ transferredBytes: result?.success ? totalBytes : transferredBytes,
179
+ startedAt: transferStartedAt ?? totalStartedAt,
180
+ endedAt,
181
+ lastProgress,
182
+ }),
183
+ });
184
+ })
185
+ );
186
+
147
187
  // ============================================================
148
188
  // Signing Commands
149
189
  // ============================================================
@@ -538,20 +578,81 @@ program
538
578
  })
539
579
  );
540
580
 
581
+ program
582
+ .command('firmware-update-legacy')
583
+ .description('Update Classic/Pure firmware through the legacy protocol')
584
+ .requiredOption('--binary <path>', 'Local firmware binary path')
585
+ .option('--device-name <name>', 'BLE advertising name, for example K1514')
586
+ .option('--update-type <type>', 'Firmware component: firmware or ble', 'firmware')
587
+ .option('--no-reboot', 'Do not reboot the device after a successful update')
588
+ .action(opts =>
589
+ runCommand({}, async ({ sdk, globalOpts }) => {
590
+ if (opts.updateType !== 'firmware' && opts.updateType !== 'ble') {
591
+ throw new Error(`Unsupported --update-type: ${opts.updateType}. Use "firmware" or "ble".`);
592
+ }
593
+
594
+ const connectId = await resolveLegacyFirmwareConnectId(
595
+ sdk,
596
+ globalOpts.connectId,
597
+ opts.deviceName
598
+ );
599
+ const result = await sdk.firmwareUpdate(connectId, {
600
+ binary: readBinaryParam(opts.binary),
601
+ updateType: opts.updateType,
602
+ rebootOnSuccess: opts.reboot,
603
+ timeout: getLegacyFirmwareConnectTimeout(globalOpts.transport),
604
+ });
605
+ outputResult(globalOpts, result);
606
+ })
607
+ );
608
+
609
+ export function getLegacyFirmwareConnectTimeout(transport: 'usb' | 'ble') {
610
+ return transport === 'usb' ? 90_000 : undefined;
611
+ }
612
+
541
613
  program
542
614
  .command('firmware-update-ble')
543
- .description('BLE firmware update is not supported via CLI')
615
+ .description('Run Protocol V2 firmware update over BLE')
544
616
  .action(() =>
545
617
  respondAndExit({
546
618
  success: false,
547
619
  payload: {
548
620
  error:
549
- 'BLE firmware update via CLI is not supported. Please use the OneKey App or https://firmware.onekey.so/ to update firmware.',
550
- code: 'FIRMWARE_UPDATE_NOT_SUPPORTED',
621
+ 'Use `onekey-hw --transport ble firmware-update-v4` for BLE Protocol V2 firmware updates.',
622
+ code: 'USE_FIRMWARE_UPDATE_V4',
551
623
  },
552
624
  })
553
625
  );
554
626
 
627
+ program
628
+ .command('firmware-update-v4')
629
+ .description('Run Protocol V2 firmware update through sdk.firmwareUpdateV4')
630
+ .option('--chunk-size <bytes>', 'Transfer chunk size in bytes')
631
+ .option('--romloader <path>', 'FW_MGMT_TARGET_ROMLOADER binary path')
632
+ .option('--bootloader <path>', 'FW_MGMT_TARGET_BOOTLOADER binary path')
633
+ .option('--application-p1 <path>', 'FW_MGMT_TARGET_APPLICATION_P1 binary path')
634
+ .option('--application-p2 <path>', 'FW_MGMT_TARGET_APPLICATION_P2 binary path')
635
+ .option('--coprocessor <path>', 'FW_MGMT_TARGET_COPROCESSOR binary path')
636
+ .option('--se01 <path>', 'FW_MGMT_TARGET_SE01 binary path')
637
+ .option('--se02 <path>', 'FW_MGMT_TARGET_SE02 binary path')
638
+ .option('--se03 <path>', 'FW_MGMT_TARGET_SE03 binary path')
639
+ .option('--se04 <path>', 'FW_MGMT_TARGET_SE04 binary path')
640
+ .option('--resource-archive <path>', 'Complete signed Protocol V2 resource ZIP path')
641
+ .option('--forced-update-res', 'Force resource update')
642
+ .option('--retries <count>', 'Retry count for transient Protocol V2 USB probe failures')
643
+ .action(opts =>
644
+ runCommand({}, async ({ sdk, globalOpts }) => {
645
+ const params = buildFirmwareUpdateV4Params(opts);
646
+ const result = await runFirmwareUpdateV4WithRetry({
647
+ sdk,
648
+ globalOpts,
649
+ params,
650
+ retries: opts.retries ? safeParseInt(opts.retries, '--retries') : undefined,
651
+ });
652
+ outputResult(globalOpts, result);
653
+ })
654
+ );
655
+
555
656
  program
556
657
  .command('bootloader-check')
557
658
  .description('Check bootloader version and status')
@@ -692,7 +793,7 @@ const sessionCmd = program.command('session').description('Manage device passphr
692
793
 
693
794
  sessionCmd
694
795
  .command('connect')
695
- .description('Connect device and establish passphrase session (cached for subsequent commands)')
796
+ .description('Connect device and select a hidden wallet for this invocation')
696
797
  .action(() =>
697
798
  runCommand({}, async ({ sdk, globalOpts }) => {
698
799
  // 1. Search for device
@@ -704,7 +805,17 @@ sessionCmd
704
805
  });
705
806
  return;
706
807
  }
707
- const device = searchResult.payload[0] as EnrichedSearchDevice;
808
+ const device = selectSearchDevice(
809
+ searchResult.payload as Array<SearchDevice & { features?: Features }>,
810
+ globalOpts.connectId
811
+ );
812
+ if (!device) {
813
+ outputResult(globalOpts, {
814
+ success: false,
815
+ payload: { error: 'No matching device found', code: 'NO_DEVICE' },
816
+ });
817
+ return;
818
+ }
708
819
  const connectId = device.connectId || globalOpts.connectId;
709
820
 
710
821
  // 2. Unlock if locked — getPassphraseState below talks to a live
@@ -715,62 +826,35 @@ sessionCmd
715
826
  await unlockWithRetry(sdk, connectId);
716
827
  }
717
828
 
718
- // 3. Get passphraseState (triggers 1/2/3 selection)
719
- const psResult = await sdk.getPassphraseState(connectId, {
720
- initSession: true,
721
- useEmptyPassphrase: false,
829
+ // 3. Open a hidden wallet session (triggers 1/2/3 selection).
830
+ const sessionResult = await sdk.openWalletSession(connectId, {
831
+ mode: 'select-hidden',
722
832
  });
723
- if (!psResult.success) {
724
- outputResult(globalOpts, psResult);
833
+ if (!sessionResult.success) {
834
+ outputResult(globalOpts, sessionResult);
725
835
  return;
726
836
  }
727
- const { passphraseState, sessionId: passphraseSessionId } = extractPassphraseSession(
728
- psResult.payload
729
- );
730
- if (!passphraseState) {
837
+ if (sessionResult.payload.walletType !== 'hidden') {
731
838
  outputResult(globalOpts, {
732
839
  success: false,
733
- payload: { error: 'getPassphraseState did not return passphraseState' },
840
+ payload: { error: 'Hidden wallet selection did not return a hidden wallet session' },
734
841
  });
735
842
  return;
736
843
  }
844
+ const { deviceId, passphraseState } = sessionResult.payload;
737
845
 
738
846
  // 4. Get address to verify + extract deviceId
739
- const addrResult = await sdk.evmGetAddress(connectId, device.deviceId || '', {
847
+ const addrResult = await sdk.evmGetAddress(connectId, deviceId, {
740
848
  path: "m/44'/60'/0'/0/0",
741
849
  showOnOneKey: false,
742
850
  passphraseState,
743
851
  });
744
852
 
745
- // 5. Fetch the now-active session_id via getFeatures.
746
- //
747
- // IMPORTANT: pass `passphraseState` here. Without it, the SDK's
748
- // connectStateChange guard (core/index.ts) would see the payload's
749
- // passphraseState flip from mnNy → undefined, clear the cached Device,
750
- // and call Initialize again with no passphrase_state / no session_id.
751
- // That Initialize resets the device to the standard wallet and returns
752
- // a *standard-wallet* session_id — which we'd then save in the keychain
753
- // paired with the hidden-wallet passphraseState. On the next CLI run
754
- // the mismatch would trigger PassphraseRequest (1/2/3 again).
755
- const featResult = await sdk.getFeatures(connectId, {
756
- passphraseState,
757
- skipPassphraseCheck: true,
758
- });
759
- const featPayload = featResult?.success ? featResult.payload : undefined;
760
- const deviceId = featPayload?.deviceId || device.deviceId || '';
761
- const sessionId = passphraseSessionId || featPayload?.sessionId || '';
762
-
763
- // 6. Save to keychain
764
- if (passphraseState && deviceId && sessionId) {
765
- await saveSessionToKeychain(deviceId, passphraseState, sessionId);
766
- }
767
-
768
853
  outputResult(globalOpts, {
769
854
  success: true,
770
855
  payload: {
771
856
  passphraseState,
772
857
  deviceId,
773
- ...(sessionId ? { sessionId } : {}),
774
858
  ...(addrResult?.success ? { address: addrResult.payload.address } : {}),
775
859
  },
776
860
  });
@@ -783,8 +867,10 @@ sessionCmd
783
867
  .action(() =>
784
868
  runCommand({}, async ({ sdk, globalOpts }) => {
785
869
  const searchResult = await sdk.searchDevices();
786
- const device = // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
787
- (searchResult?.payload as any)?.[0];
870
+ const device = selectSearchDevice(
871
+ (searchResult?.payload as Array<SearchDevice & { features?: Features }>) ?? [],
872
+ globalOpts.connectId
873
+ );
788
874
  const deviceId = device?.deviceId || device?.features?.device_id;
789
875
  if (deviceId) {
790
876
  await clearSessionFromKeychain(deviceId);
@@ -879,12 +965,12 @@ async function unlockWithRetry(
879
965
  * Prepare passphrase session before SDK calls.
880
966
  *
881
967
  * 1. If --use-empty-passphrase or --passphrase-state provided → use as-is
882
- * 2. Try keychain → preloadSessionCache → use cached session
883
- * 3. Keychain miss → getPassphraseState (triggers 1/2/3 prompt) → save to keychain
968
+ * 2. Try a legacy keychain entry → preloadSessionCache → use cached session
969
+ * 3. Keychain miss → openWalletSession (triggers 1/2/3 prompt)
884
970
  *
885
971
  * After this, globalOpts.passphraseState is set and getCommonParams will include it.
886
972
  */
887
- async function prepareSession(
973
+ export async function prepareSession(
888
974
  sdk: typeof import('@onekeyfe/hd-common-connect-sdk').default,
889
975
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
890
976
  globalOpts: Record<string, any>
@@ -895,7 +981,7 @@ async function prepareSession(
895
981
  }
896
982
 
897
983
  // Errors from the SDK calls below (PIN cancelled, transport broken,
898
- // getPassphraseState rejection) intentionally propagate to runCommand's
984
+ // openWalletSession rejection) intentionally propagate to runCommand's
899
985
  // catch block, which renders them as structured `{ success: false,
900
986
  // payload: { error, code } }` output instead of silently falling through
901
987
  // to a confusing downstream error 112 / 114.
@@ -910,9 +996,30 @@ async function prepareSession(
910
996
  return undefined;
911
997
  }
912
998
 
913
- const device = searchResult.payload[0] as {
999
+ const device = selectSearchDevice(
1000
+ searchResult.payload as Array<{
1001
+ connectId?: string;
1002
+ deviceId?: string;
1003
+ deviceType?: string;
1004
+ features?: {
1005
+ deviceId?: string | null;
1006
+ deviceType?: string;
1007
+ sessionId?: string | null;
1008
+ passphraseProtection?: boolean | null;
1009
+ unlocked?: boolean | null;
1010
+ };
1011
+ }>,
1012
+ globalOpts.connectId
1013
+ );
1014
+
1015
+ if (!device) {
1016
+ throw new Error(`未找到指定的 BLE 设备: ${globalOpts.connectId}`);
1017
+ }
1018
+
1019
+ const selectedDevice = device as {
914
1020
  connectId?: string;
915
1021
  deviceId?: string;
1022
+ deviceType?: string;
916
1023
  features?: {
917
1024
  deviceId?: string | null;
918
1025
  deviceType?: string;
@@ -921,7 +1028,7 @@ async function prepareSession(
921
1028
  unlocked?: boolean | null;
922
1029
  };
923
1030
  };
924
- const connectId = device.connectId || globalOpts.connectId || '';
1031
+ const connectId = selectedDevice.connectId || globalOpts.connectId || '';
925
1032
  if (!globalOpts.connectId && connectId) {
926
1033
  globalOpts.connectId = connectId;
927
1034
  }
@@ -929,10 +1036,11 @@ async function prepareSession(
929
1036
  // ── Step 2: Get features if searchDevices didn't populate them ──
930
1037
  // getFeatures failures here are non-fatal — we fall through to Step 3
931
1038
  // which will fail with a clearer error if the device is truly unreachable.
932
- let deviceId = device.features?.deviceId || device.deviceId || '';
933
- let deviceType = getDeviceType(device.features as Features | undefined);
934
- let unlocked = device.features?.unlocked;
935
- let passphraseProtection = device.features?.passphraseProtection;
1039
+ let deviceId = selectedDevice.features?.deviceId || selectedDevice.deviceId || '';
1040
+ let deviceType =
1041
+ selectedDevice.features?.deviceType ?? selectedDevice.deviceType ?? EDeviceType.Unknown;
1042
+ let unlocked = selectedDevice.features?.unlocked;
1043
+ let passphraseProtection = selectedDevice.features?.passphraseProtection;
936
1044
 
937
1045
  if (!deviceId || unlocked == null || passphraseProtection == null) {
938
1046
  try {
@@ -971,7 +1079,7 @@ async function prepareSession(
971
1079
  return undefined;
972
1080
  }
973
1081
 
974
- // ── Step 5: Try keychain session reuse ───────────────────────────
1082
+ // ── Step 5: Try legacy keychain session reuse ────────────────────
975
1083
  // Only attempt if device was already unlocked — locking invalidates
976
1084
  // all passphrase sessions, so cached session_id is useless after unlock.
977
1085
  if (!wasLocked && deviceId) {
@@ -982,40 +1090,19 @@ async function prepareSession(
982
1090
  }
983
1091
  }
984
1092
 
985
- // ── Step 6: Keychain miss → getPassphraseState (triggers 1/2/3 prompt) ──
986
- const psResult = await sdk.getPassphraseState(connectId, {
987
- initSession: true,
988
- useEmptyPassphrase: false,
1093
+ // ── Step 6: Keychain miss → openWalletSession (triggers 1/2/3 prompt) ──
1094
+ const sessionResult = await sdk.openWalletSession(connectId, {
1095
+ mode: 'select-hidden',
989
1096
  });
990
1097
 
991
- if (psResult.success && psResult.payload) {
992
- const { passphraseState, sessionId: passphraseSessionId } = extractPassphraseSession(
993
- psResult.payload
994
- );
995
- if (!passphraseState) {
1098
+ if (sessionResult.success && sessionResult.payload) {
1099
+ if (sessionResult.payload.walletType !== 'hidden') {
996
1100
  return undefined;
997
1101
  }
1102
+ const { deviceId: sessionDeviceId, passphraseState } = sessionResult.payload;
1103
+ globalOpts.deviceId = sessionDeviceId;
998
1104
  globalOpts.passphraseState = passphraseState;
999
1105
 
1000
- // Save session to keychain for next invocation.
1001
- //
1002
- // Pass passphraseState to keep connectStateChange=false — otherwise
1003
- // Initialize would be re-run without passphrase_state, resetting the
1004
- // device to the standard wallet and returning a mismatched session_id.
1005
- // See the matching comment in `session connect`.
1006
- if (deviceId) {
1007
- const featAfter = await sdk.getFeatures(connectId, {
1008
- passphraseState,
1009
- skipPassphraseCheck: true,
1010
- });
1011
- const sessionId =
1012
- passphraseSessionId || (featAfter?.success ? featAfter.payload?.sessionId : undefined);
1013
- if (sessionId) {
1014
- await saveSessionToKeychain(deviceId, passphraseState, sessionId);
1015
- await preloadSessionFromKeychain(deviceId);
1016
- }
1017
- }
1018
-
1019
1106
  return passphraseState;
1020
1107
  }
1021
1108
  return undefined;
@@ -1032,8 +1119,8 @@ function outputResult(_globalOpts: Record<string, any>, result: unknown): void {
1032
1119
  ) {
1033
1120
  process.exitCode = 1;
1034
1121
  }
1035
- // No process.exit here — runCommand() below handles dispose + exit so SDK
1036
- // async cleanup (USB release, event listener teardown) finishes first.
1122
+ // No process.exit here — runCommand() waits for SDK cleanup, then lets Node
1123
+ // exit naturally so leaked USB handles remain observable.
1037
1124
  }
1038
1125
 
1039
1126
  /**
@@ -1045,7 +1132,7 @@ function outputResult(_globalOpts: Record<string, any>, result: unknown): void {
1045
1132
  * 3. run the handler (which calls outputResult on success)
1046
1133
  * 4. report uncaught errors as a structured failure result
1047
1134
  * 5. dispose SDK
1048
- * 6. drain event loop and process.exit with the right code
1135
+ * 6. let Node exit naturally after all SDK resources are released
1049
1136
  *
1050
1137
  * This fixes three previous bugs:
1051
1138
  * - Most signing commands skipped prepareSession, so keychain sessions
@@ -1071,6 +1158,9 @@ async function runCommand(
1071
1158
  ): Promise<void> {
1072
1159
  const globalOpts = program.opts();
1073
1160
  try {
1161
+ if (globalOpts.transport !== 'usb' && globalOpts.transport !== 'ble') {
1162
+ throw new Error(`Unsupported transport: ${globalOpts.transport}. Use "usb" or "ble".`);
1163
+ }
1074
1164
  const sdk = await createSDK(globalOpts);
1075
1165
  if (options.needsSession) {
1076
1166
  await prepareSession(sdk, globalOpts);
@@ -1094,9 +1184,7 @@ async function runCommand(
1094
1184
  // promise reference. Idempotent, safe to call even if init failed.
1095
1185
  await disposeSDK();
1096
1186
  }
1097
- // SDK event listeners can keep the event loop alive after dispose.
1098
- // setImmediate lets any trailing stdout/stderr writes flush first.
1099
- setImmediate(() => process.exit(process.exitCode ?? 0));
1187
+ // disposeSDK awaits transport cleanup; let Node exit naturally so leaks remain visible.
1100
1188
  }
1101
1189
 
1102
1190
  /** For commands that don't touch the SDK at all (e.g. firmware-update stubs). */
@@ -1119,6 +1207,428 @@ function safeJsonParse(input: string, label: string): unknown {
1119
1207
  }
1120
1208
  }
1121
1209
 
1210
+ function readBinaryParam(path: string): ArrayBuffer {
1211
+ const buffer = readFileSync(path);
1212
+ return new Uint8Array(buffer).buffer;
1213
+ }
1214
+
1215
+ async function resolveLegacyFirmwareConnectId(
1216
+ sdk: AnySdk,
1217
+ explicitConnectId?: string,
1218
+ deviceName?: string
1219
+ ): Promise<string> {
1220
+ if (explicitConnectId && !deviceName) return explicitConnectId;
1221
+
1222
+ const searchResult = await sdk.searchDevices();
1223
+ if (!searchResult?.success || !Array.isArray(searchResult.payload)) {
1224
+ throw new Error('Unable to scan BLE devices');
1225
+ }
1226
+
1227
+ const devices = searchResult.payload as EnrichedSearchDevice[];
1228
+ const normalizedName = deviceName?.trim().toLowerCase();
1229
+ const matches = normalizedName
1230
+ ? devices.filter(device => device.name?.trim().toLowerCase() === normalizedName)
1231
+ : devices.filter(device => device.deviceType?.toLowerCase() === 'classic');
1232
+
1233
+ if (matches.length === 0) {
1234
+ throw new Error(
1235
+ normalizedName
1236
+ ? `BLE device not found by name: ${deviceName}`
1237
+ : 'No Classic/Pure BLE device found'
1238
+ );
1239
+ }
1240
+ if (matches.length > 1) {
1241
+ throw new Error(
1242
+ normalizedName
1243
+ ? `Multiple BLE devices found by name: ${deviceName}`
1244
+ : 'Multiple Classic/Pure BLE devices found; specify --device-name'
1245
+ );
1246
+ }
1247
+
1248
+ const [{ connectId, name }] = matches;
1249
+ if (!connectId) throw new Error(`BLE device has no connect ID: ${name}`);
1250
+ return connectId;
1251
+ }
1252
+
1253
+ function getFirmwareUpdateV4TotalBytes(params: ReturnType<typeof buildFirmwareUpdateV4Params>) {
1254
+ return [
1255
+ params.bootloaderBinary,
1256
+ params.applicationP1Binary,
1257
+ params.applicationP2Binary,
1258
+ params.coprocessorBinary,
1259
+ params.se01Binary,
1260
+ params.se02Binary,
1261
+ params.se03Binary,
1262
+ params.se04Binary,
1263
+ ].reduce((total, binary) => total + (binary?.byteLength ?? 0), 0);
1264
+ }
1265
+
1266
+ function getFirmwareUpdateV4ErrorText(result: unknown) {
1267
+ if (!result || typeof result !== 'object') return '';
1268
+ const { payload } = result as { payload?: unknown };
1269
+ if (!payload || typeof payload !== 'object') return '';
1270
+ const { error } = payload as { error?: unknown };
1271
+ return typeof error === 'string' ? error : '';
1272
+ }
1273
+
1274
+ function isProtocolV2UsbProbeTransientResult(result: unknown) {
1275
+ const error = getFirmwareUpdateV4ErrorText(result);
1276
+ return (
1277
+ error.includes('Device protocol mismatch') &&
1278
+ error.includes('expected V2') &&
1279
+ error.includes('did not respond to expected protocol')
1280
+ );
1281
+ }
1282
+
1283
+ function isSuccessResult(result: unknown) {
1284
+ return (
1285
+ !!result && typeof result === 'object' && (result as { success?: boolean }).success === true
1286
+ );
1287
+ }
1288
+
1289
+ function getFirmwareUpdatePayload(message: unknown) {
1290
+ if (!message || typeof message !== 'object') return undefined;
1291
+ return (message as { payload?: Record<string, unknown> }).payload;
1292
+ }
1293
+
1294
+ function formatFirmwareProgress(progress: number) {
1295
+ if (!Number.isFinite(progress)) return '0%';
1296
+ return `${Math.min(Math.max(Math.round(progress), 0), 100)}%`;
1297
+ }
1298
+
1299
+ function formatFirmwareBytes(bytes: number) {
1300
+ if (!Number.isFinite(bytes) || bytes <= 0) return '';
1301
+ return `${(bytes / 1024).toFixed(1)} KiB`;
1302
+ }
1303
+
1304
+ export function buildWallpaperUploadMetrics({
1305
+ totalBytes,
1306
+ transferredBytes,
1307
+ startedAt,
1308
+ endedAt,
1309
+ lastProgress,
1310
+ }: {
1311
+ totalBytes: number;
1312
+ transferredBytes: number;
1313
+ startedAt: number;
1314
+ endedAt: number;
1315
+ lastProgress: number;
1316
+ }) {
1317
+ const elapsedMs = Math.max(endedAt - startedAt, 0);
1318
+ return {
1319
+ totalBytes,
1320
+ transferredBytes,
1321
+ totalSeconds: Number((elapsedMs / 1000).toFixed(2)),
1322
+ transferKiBPerSecond:
1323
+ elapsedMs > 0 ? Number((transferredBytes / 1024 / (elapsedMs / 1000)).toFixed(2)) : null,
1324
+ lastProgress,
1325
+ };
1326
+ }
1327
+
1328
+ function maybePrintFirmwareProgress({
1329
+ progressType,
1330
+ progress,
1331
+ payload,
1332
+ lastPrintedProgress,
1333
+ }: {
1334
+ progressType: string;
1335
+ progress: number;
1336
+ payload: Record<string, unknown>;
1337
+ lastPrintedProgress: number;
1338
+ }) {
1339
+ const printableProgress = Math.floor(progress / 10) * 10;
1340
+ if (printableProgress <= lastPrintedProgress && progress < 100) {
1341
+ return lastPrintedProgress;
1342
+ }
1343
+
1344
+ const transferredBytes = Number(payload.transferredBytes);
1345
+ const totalBytes = Number(payload.totalBytes);
1346
+ const rateBytesPerSecond = Number(payload.rateBytesPerSecond);
1347
+ const sizeText =
1348
+ Number.isFinite(transferredBytes) && Number.isFinite(totalBytes) && totalBytes > 0
1349
+ ? ` ${formatFirmwareBytes(transferredBytes)}/${formatFirmwareBytes(totalBytes)}`
1350
+ : '';
1351
+ const speedText =
1352
+ Number.isFinite(rateBytesPerSecond) && rateBytesPerSecond > 0
1353
+ ? ` ${(rateBytesPerSecond / 1024).toFixed(2)} KiB/s`
1354
+ : '';
1355
+
1356
+ process.stderr.write(
1357
+ `[onekey-hw] Firmware ${progressType}: ${formatFirmwareProgress(
1358
+ progress
1359
+ )}${sizeText}${speedText}\n`
1360
+ );
1361
+ return progress >= 100 ? 100 : printableProgress;
1362
+ }
1363
+
1364
+ function buildFirmwareUpdateV4Metrics({
1365
+ attempt,
1366
+ maxAttempts,
1367
+ totalBytes,
1368
+ totalStartedAt,
1369
+ transferStartedAt,
1370
+ transferEndedAt,
1371
+ installStartedAt,
1372
+ installEndedAt,
1373
+ progressEvents,
1374
+ lastProgress,
1375
+ installProgressEvents,
1376
+ lastInstallProgress,
1377
+ retried,
1378
+ }: {
1379
+ attempt: number;
1380
+ maxAttempts: number;
1381
+ totalBytes: number;
1382
+ totalStartedAt: number;
1383
+ transferStartedAt?: number;
1384
+ transferEndedAt?: number;
1385
+ installStartedAt?: number;
1386
+ installEndedAt?: number;
1387
+ progressEvents: number;
1388
+ lastProgress: number;
1389
+ installProgressEvents: number;
1390
+ lastInstallProgress: number;
1391
+ retried: boolean;
1392
+ }) {
1393
+ const totalElapsedMs = Date.now() - totalStartedAt;
1394
+ const transferElapsedMs =
1395
+ transferStartedAt !== undefined && transferEndedAt !== undefined
1396
+ ? transferEndedAt - transferStartedAt
1397
+ : undefined;
1398
+ const installElapsedMs =
1399
+ installStartedAt !== undefined && installEndedAt !== undefined
1400
+ ? installEndedAt - installStartedAt
1401
+ : undefined;
1402
+
1403
+ return {
1404
+ attempt,
1405
+ maxAttempts,
1406
+ retried,
1407
+ totalBytes,
1408
+ progressEvents,
1409
+ lastProgress,
1410
+ installProgressEvents,
1411
+ lastInstallProgress,
1412
+ transferSeconds:
1413
+ transferElapsedMs !== undefined ? Number((transferElapsedMs / 1000).toFixed(2)) : null,
1414
+ transferKiBPerSecond:
1415
+ transferElapsedMs !== undefined && transferElapsedMs > 0
1416
+ ? Number((totalBytes / 1024 / (transferElapsedMs / 1000)).toFixed(2))
1417
+ : null,
1418
+ installSeconds:
1419
+ installElapsedMs !== undefined ? Number((installElapsedMs / 1000).toFixed(2)) : null,
1420
+ totalSeconds: Number((totalElapsedMs / 1000).toFixed(2)),
1421
+ };
1422
+ }
1423
+
1424
+ export async function runFirmwareUpdateV4WithRetry({
1425
+ sdk,
1426
+ globalOpts,
1427
+ params,
1428
+ retries,
1429
+ }: {
1430
+ sdk: AnySdk;
1431
+ globalOpts: Record<string, any>;
1432
+ params: ReturnType<typeof buildFirmwareUpdateV4Params>;
1433
+ retries?: number;
1434
+ }) {
1435
+ const totalBytes = getFirmwareUpdateV4TotalBytes(params);
1436
+ const maxAttempts = Math.max((retries ?? 2) + 1, 1);
1437
+ let currentSdk = sdk;
1438
+ let retried = false;
1439
+ let attempt = 1;
1440
+ let { connectId } = globalOpts;
1441
+
1442
+ if (globalOpts.transport === 'usb') {
1443
+ for (; attempt <= maxAttempts; attempt += 1) {
1444
+ const probeResult = await currentSdk.getDeviceState(connectId, {
1445
+ scope: 'runtime',
1446
+ connectProtocol: 'V2',
1447
+ retryCount: 0,
1448
+ });
1449
+ if (isSuccessResult(probeResult)) break;
1450
+ if (attempt >= maxAttempts || !isProtocolV2UsbProbeTransientResult(probeResult)) {
1451
+ return probeResult;
1452
+ }
1453
+
1454
+ retried = true;
1455
+ process.stderr.write(
1456
+ `[onekey-hw] Protocol V2 USB probe was transient; retrying read-only probe (${attempt}/${maxAttempts})...\n`
1457
+ );
1458
+ await disposeSDK();
1459
+ await new Promise(resolve => {
1460
+ setTimeout(resolve, 3000);
1461
+ });
1462
+ currentSdk = await createSDK(globalOpts);
1463
+ if (globalOpts.connectId) connectId = undefined;
1464
+ }
1465
+ }
1466
+
1467
+ let progressEvents = 0;
1468
+ let lastProgress = -1;
1469
+ let transferStartedAt: number | undefined;
1470
+ let transferEndedAt: number | undefined;
1471
+ let installProgressEvents = 0;
1472
+ let lastInstallProgress = -1;
1473
+ let installStartedAt: number | undefined;
1474
+ let installEndedAt: number | undefined;
1475
+ let lastPrintedTransferProgress = -10;
1476
+ let lastPrintedInstallProgress = -10;
1477
+ const totalStartedAt = Date.now();
1478
+
1479
+ const onUiEvent = (message: unknown) => {
1480
+ if (!message || typeof message !== 'object') return;
1481
+ const messageType = (message as { type?: string }).type;
1482
+ const payload = getFirmwareUpdatePayload(message);
1483
+
1484
+ if (messageType === UI_REQUEST.FIRMWARE_TIP) {
1485
+ const tipMessage = (payload?.data as { message?: unknown } | undefined)?.message;
1486
+ if (typeof tipMessage === 'string') {
1487
+ process.stderr.write(`[onekey-hw] Firmware: ${tipMessage}\n`);
1488
+ }
1489
+ return;
1490
+ }
1491
+
1492
+ if (messageType === UI_REQUEST.REQUEST_BUTTON) {
1493
+ const code = typeof payload?.code === 'string' ? ` (${payload.code})` : '';
1494
+ process.stderr.write(
1495
+ `[onekey-hw] Please confirm the firmware update on your device${code}.\n`
1496
+ );
1497
+ return;
1498
+ }
1499
+
1500
+ if (messageType !== UI_REQUEST.FIRMWARE_PROGRESS || !payload) return;
1501
+ const progress = Number(payload.progress);
1502
+ if (!Number.isFinite(progress)) return;
1503
+
1504
+ if (payload.progressType === 'transferData') {
1505
+ progressEvents += 1;
1506
+ lastProgress = Math.max(lastProgress, progress);
1507
+ transferStartedAt ??= Date.now();
1508
+ lastPrintedTransferProgress = maybePrintFirmwareProgress({
1509
+ progressType: 'transfer',
1510
+ progress,
1511
+ payload,
1512
+ lastPrintedProgress: lastPrintedTransferProgress,
1513
+ });
1514
+ if (progress >= 100) {
1515
+ transferEndedAt ??= Date.now();
1516
+ }
1517
+ return;
1518
+ }
1519
+
1520
+ if (payload.progressType === 'installingFirmware') {
1521
+ installProgressEvents += 1;
1522
+ lastInstallProgress = Math.max(lastInstallProgress, progress);
1523
+ installStartedAt ??= Date.now();
1524
+ lastPrintedInstallProgress = maybePrintFirmwareProgress({
1525
+ progressType: 'install',
1526
+ progress,
1527
+ payload,
1528
+ lastPrintedProgress: lastPrintedInstallProgress,
1529
+ });
1530
+ if (progress >= 100) {
1531
+ installEndedAt ??= Date.now();
1532
+ }
1533
+ }
1534
+ };
1535
+
1536
+ currentSdk.on(UI_EVENT, onUiEvent);
1537
+ let result: unknown;
1538
+ try {
1539
+ result = await currentSdk.firmwareUpdateV4(connectId, params);
1540
+ } finally {
1541
+ currentSdk.off?.(UI_EVENT, onUiEvent);
1542
+ }
1543
+ if (installStartedAt !== undefined && installEndedAt === undefined) {
1544
+ installEndedAt = Date.now();
1545
+ }
1546
+
1547
+ const metrics = buildFirmwareUpdateV4Metrics({
1548
+ attempt,
1549
+ maxAttempts,
1550
+ totalBytes,
1551
+ totalStartedAt,
1552
+ transferStartedAt,
1553
+ transferEndedAt,
1554
+ installStartedAt,
1555
+ installEndedAt,
1556
+ progressEvents,
1557
+ lastProgress,
1558
+ installProgressEvents,
1559
+ lastInstallProgress,
1560
+ retried,
1561
+ });
1562
+
1563
+ if (result && typeof result === 'object') {
1564
+ const payload = ((result as { payload?: unknown }).payload ?? {}) as Record<string, unknown>;
1565
+ return {
1566
+ ...(result as Record<string, unknown>),
1567
+ payload: {
1568
+ ...payload,
1569
+ metrics,
1570
+ },
1571
+ };
1572
+ }
1573
+
1574
+ return result;
1575
+ }
1576
+
1577
+ function buildFirmwareUpdateV4Params(opts: {
1578
+ chunkSize?: string;
1579
+ romloader?: string;
1580
+ bootloader?: string;
1581
+ applicationP1?: string;
1582
+ applicationP2?: string;
1583
+ coprocessor?: string;
1584
+ se01?: string;
1585
+ se02?: string;
1586
+ se03?: string;
1587
+ se04?: string;
1588
+ resourceArchive?: string;
1589
+ forcedUpdateRes?: boolean;
1590
+ }) {
1591
+ const params = {
1592
+ platform: 'desktop' as const,
1593
+ connectProtocol: 'V2' as const,
1594
+ chunkSize: opts.chunkSize ? safeParseInt(opts.chunkSize, '--chunk-size') : undefined,
1595
+ forcedUpdateRes: opts.forcedUpdateRes,
1596
+ romloaderBinary: opts.romloader ? readBinaryParam(opts.romloader) : undefined,
1597
+ bootloaderBinary: opts.bootloader ? readBinaryParam(opts.bootloader) : undefined,
1598
+ applicationP1Binary: opts.applicationP1 ? readBinaryParam(opts.applicationP1) : undefined,
1599
+ applicationP2Binary: opts.applicationP2 ? readBinaryParam(opts.applicationP2) : undefined,
1600
+ coprocessorBinary: opts.coprocessor ? readBinaryParam(opts.coprocessor) : undefined,
1601
+ se01Binary: opts.se01 ? readBinaryParam(opts.se01) : undefined,
1602
+ se02Binary: opts.se02 ? readBinaryParam(opts.se02) : undefined,
1603
+ se03Binary: opts.se03 ? readBinaryParam(opts.se03) : undefined,
1604
+ se04Binary: opts.se04 ? readBinaryParam(opts.se04) : undefined,
1605
+ resourceArchiveBinary: opts.resourceArchive ? readBinaryParam(opts.resourceArchive) : undefined,
1606
+ };
1607
+
1608
+ const hasPayload = [
1609
+ params.romloaderBinary,
1610
+ params.bootloaderBinary,
1611
+ params.applicationP1Binary,
1612
+ params.applicationP2Binary,
1613
+ params.coprocessorBinary,
1614
+ params.se01Binary,
1615
+ params.se02Binary,
1616
+ params.se03Binary,
1617
+ params.se04Binary,
1618
+ params.resourceArchiveBinary,
1619
+ ].some(Boolean);
1620
+
1621
+ if (!hasPayload) {
1622
+ const err = new Error(
1623
+ 'firmware-update-v4 requires at least one firmware binary or resource archive path'
1624
+ );
1625
+ (err as Error & { code?: string }).code = 'MISSING_FIRMWARE_BINARY';
1626
+ throw err;
1627
+ }
1628
+
1629
+ return params;
1630
+ }
1631
+
1122
1632
  /**
1123
1633
  * #9 FIX: Safe parseInt with NaN check
1124
1634
  */
@@ -1130,4 +1640,8 @@ function safeParseInt(input: string, label: string): number {
1130
1640
  return num;
1131
1641
  }
1132
1642
 
1133
- program.parse();
1643
+ export { program };
1644
+
1645
+ if (require.main === module) {
1646
+ program.parse();
1647
+ }