@onekeyfe/hardware-cli 1.2.0-alpha.9 → 1.2.0-alpha.90

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,4 +1,5 @@
1
1
  import { readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
2
3
  import { Command } from 'commander';
3
4
  import { UI_EVENT, UI_REQUEST, getDeviceType } from '@onekeyfe/hd-core';
4
5
  import { EDeviceType } from '@onekeyfe/hd-shared';
@@ -10,65 +11,31 @@ import {
10
11
  resolveSignMessage,
11
12
  resolveSignTransaction,
12
13
  } from './chains';
14
+ import { selectSearchDevice } from './deviceSelection';
15
+ import { getCanonicalDeviceState, getCompatibleFeatures } from './deviceStateCommands';
13
16
  import { createSDK, disposeSDK } from './sdk';
14
- import {
15
- clearSessionFromKeychain,
16
- preloadSessionFromKeychain,
17
- saveSessionToKeychain,
18
- } from './session';
17
+ import { clearSessionFromKeychain, preloadSessionFromKeychain } from './session';
19
18
 
20
19
  import type {
20
+ DeviceStateScope,
21
21
  EthereumSignTypedDataMessage,
22
22
  EthereumSignTypedDataTypes,
23
23
  Features,
24
- IDeviceType,
25
24
  SearchDevice,
26
25
  } from '@onekeyfe/hd-core';
27
26
 
28
27
  /** SearchDevice enriched with features fetched after discovery */
29
28
  type EnrichedSearchDevice = SearchDevice & { features?: Features };
30
29
 
31
- function extractPassphraseSession(payload: unknown): {
32
- passphraseState?: string;
33
- sessionId?: string;
34
- } {
35
- if (typeof payload === 'string') {
36
- return { passphraseState: payload };
37
- }
38
- if (!payload || typeof payload !== 'object') {
39
- return {};
40
- }
41
-
42
- const statePayload = payload as {
43
- passphrase_state?: unknown;
44
- passphraseState?: unknown;
45
- session_id?: unknown;
46
- sessionId?: unknown;
47
- };
48
-
49
- let passphraseState: string | undefined;
50
- if (typeof statePayload.passphrase_state === 'string') {
51
- passphraseState = statePayload.passphrase_state;
52
- } else if (typeof statePayload.passphraseState === 'string') {
53
- passphraseState = statePayload.passphraseState;
54
- }
55
-
56
- let sessionId: string | undefined;
57
- if (typeof statePayload.session_id === 'string') {
58
- sessionId = statePayload.session_id;
59
- } else if (typeof statePayload.sessionId === 'string') {
60
- sessionId = statePayload.sessionId;
61
- }
62
-
63
- return { passphraseState, sessionId };
64
- }
65
-
66
30
  const program = new Command();
31
+ const { version: cliVersion } = JSON.parse(
32
+ readFileSync(resolve(__dirname, '../package.json'), 'utf8')
33
+ ) as { version: string };
67
34
 
68
35
  program
69
36
  .name('onekey-hw')
70
37
  .description('OneKey hardware wallet CLI for AI agent integration')
71
- .version('1.1.26-alpha.1');
38
+ .version(cliVersion);
72
39
 
73
40
  // ============================================================
74
41
  // Global Options
@@ -94,28 +61,6 @@ program
94
61
  .action(() =>
95
62
  runCommand({}, async ({ sdk, globalOpts }) => {
96
63
  const result = await sdk.searchDevices();
97
-
98
- // USB 下自动读取 features 成本低;BLE 搜索阶段只做枚举,避免批量连接导致超时。
99
- if (globalOpts.transport !== 'ble' && result?.success && Array.isArray(result.payload)) {
100
- for (const device of result.payload as EnrichedSearchDevice[]) {
101
- if (device.connectId) {
102
- try {
103
- const features = await sdk.getFeatures(device.connectId);
104
- if (features?.success && features.payload) {
105
- device.features = features.payload;
106
- device.name = features.payload.label || features.payload.bleName || device.name;
107
- const devType = features.payload.deviceType?.toLowerCase();
108
- if (devType) {
109
- device.deviceType = devType as IDeviceType;
110
- }
111
- }
112
- } catch {
113
- // Features fetch failed — device may need PIN, continue with basic info
114
- }
115
- }
116
- }
117
- }
118
-
119
64
  outputResult(globalOpts, result);
120
65
  })
121
66
  );
@@ -125,28 +70,120 @@ program
125
70
  .description('Get device features (firmware, unlock state, passphrase protection, etc.)')
126
71
  .action(() =>
127
72
  runCommand({}, async ({ sdk, globalOpts }) => {
128
- // Resolve connectId: explicit flag wins, else pick the first attached device
129
- let { connectId } = globalOpts as { connectId?: string };
130
- if (!connectId) {
131
- const searchResult = await sdk.searchDevices();
132
- if (
133
- !searchResult?.success ||
134
- !Array.isArray(searchResult.payload) ||
135
- searchResult.payload.length === 0
136
- ) {
137
- outputResult(globalOpts, {
138
- success: false,
139
- payload: { error: 'No device found', code: 'NO_DEVICE' },
140
- });
141
- return;
142
- }
143
- 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;
144
89
  }
145
- const result = await sdk.getFeatures(connectId || '');
90
+ const result = await getCanonicalDeviceState(
91
+ sdk,
92
+ globalOpts.connectId,
93
+ opts.scope as DeviceStateScope
94
+ );
146
95
  outputResult(globalOpts, result);
147
96
  })
148
97
  );
149
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
+
150
187
  // ============================================================
151
188
  // Signing Commands
152
189
  // ============================================================
@@ -541,6 +578,38 @@ program
541
578
  })
542
579
  );
543
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
+
544
613
  program
545
614
  .command('firmware-update-ble')
546
615
  .description('Run Protocol V2 firmware update over BLE')
@@ -559,10 +628,6 @@ program
559
628
  .command('firmware-update-v4')
560
629
  .description('Run Protocol V2 firmware update through sdk.firmwareUpdateV4')
561
630
  .option('--chunk-size <bytes>', 'Transfer chunk size in bytes')
562
- .option(
563
- '--resource-bundle <spec...>',
564
- 'RESC bundle direct-write spec: <localPath>:<devicePath>, e.g. wallpaper.okpkg:vol0:/bundles/images/wallpaper.okpkg'
565
- )
566
631
  .option('--romloader <path>', 'FW_MGMT_TARGET_ROMLOADER binary path')
567
632
  .option('--bootloader <path>', 'FW_MGMT_TARGET_BOOTLOADER binary path')
568
633
  .option('--application-p1 <path>', 'FW_MGMT_TARGET_APPLICATION_P1 binary path')
@@ -727,7 +792,7 @@ const sessionCmd = program.command('session').description('Manage device passphr
727
792
 
728
793
  sessionCmd
729
794
  .command('connect')
730
- .description('Connect device and establish passphrase session (cached for subsequent commands)')
795
+ .description('Connect device and select a hidden wallet for this invocation')
731
796
  .action(() =>
732
797
  runCommand({}, async ({ sdk, globalOpts }) => {
733
798
  // 1. Search for device
@@ -739,7 +804,17 @@ sessionCmd
739
804
  });
740
805
  return;
741
806
  }
742
- const device = searchResult.payload[0] as EnrichedSearchDevice;
807
+ const device = selectSearchDevice(
808
+ searchResult.payload as Array<SearchDevice & { features?: Features }>,
809
+ globalOpts.connectId
810
+ );
811
+ if (!device) {
812
+ outputResult(globalOpts, {
813
+ success: false,
814
+ payload: { error: 'No matching device found', code: 'NO_DEVICE' },
815
+ });
816
+ return;
817
+ }
743
818
  const connectId = device.connectId || globalOpts.connectId;
744
819
 
745
820
  // 2. Unlock if locked — getPassphraseState below talks to a live
@@ -750,62 +825,35 @@ sessionCmd
750
825
  await unlockWithRetry(sdk, connectId);
751
826
  }
752
827
 
753
- // 3. Get passphraseState (triggers 1/2/3 selection)
754
- const psResult = await sdk.getPassphraseState(connectId, {
755
- initSession: true,
756
- useEmptyPassphrase: false,
828
+ // 3. Open a hidden wallet session (triggers 1/2/3 selection).
829
+ const sessionResult = await sdk.openWalletSession(connectId, {
830
+ mode: 'select-hidden',
757
831
  });
758
- if (!psResult.success) {
759
- outputResult(globalOpts, psResult);
832
+ if (!sessionResult.success) {
833
+ outputResult(globalOpts, sessionResult);
760
834
  return;
761
835
  }
762
- const { passphraseState, sessionId: passphraseSessionId } = extractPassphraseSession(
763
- psResult.payload
764
- );
765
- if (!passphraseState) {
836
+ if (sessionResult.payload.walletType !== 'hidden') {
766
837
  outputResult(globalOpts, {
767
838
  success: false,
768
- payload: { error: 'getPassphraseState did not return passphraseState' },
839
+ payload: { error: 'Hidden wallet selection did not return a hidden wallet session' },
769
840
  });
770
841
  return;
771
842
  }
843
+ const { deviceId, passphraseState } = sessionResult.payload;
772
844
 
773
845
  // 4. Get address to verify + extract deviceId
774
- const addrResult = await sdk.evmGetAddress(connectId, device.deviceId || '', {
846
+ const addrResult = await sdk.evmGetAddress(connectId, deviceId, {
775
847
  path: "m/44'/60'/0'/0/0",
776
848
  showOnOneKey: false,
777
849
  passphraseState,
778
850
  });
779
851
 
780
- // 5. Fetch the now-active session_id via getFeatures.
781
- //
782
- // IMPORTANT: pass `passphraseState` here. Without it, the SDK's
783
- // connectStateChange guard (core/index.ts) would see the payload's
784
- // passphraseState flip from mnNy → undefined, clear the cached Device,
785
- // and call Initialize again with no passphrase_state / no session_id.
786
- // That Initialize resets the device to the standard wallet and returns
787
- // a *standard-wallet* session_id — which we'd then save in the keychain
788
- // paired with the hidden-wallet passphraseState. On the next CLI run
789
- // the mismatch would trigger PassphraseRequest (1/2/3 again).
790
- const featResult = await sdk.getFeatures(connectId, {
791
- passphraseState,
792
- skipPassphraseCheck: true,
793
- });
794
- const featPayload = featResult?.success ? featResult.payload : undefined;
795
- const deviceId = featPayload?.deviceId || device.deviceId || '';
796
- const sessionId = passphraseSessionId || featPayload?.sessionId || '';
797
-
798
- // 6. Save to keychain
799
- if (passphraseState && deviceId && sessionId) {
800
- await saveSessionToKeychain(deviceId, passphraseState, sessionId);
801
- }
802
-
803
852
  outputResult(globalOpts, {
804
853
  success: true,
805
854
  payload: {
806
855
  passphraseState,
807
856
  deviceId,
808
- ...(sessionId ? { sessionId } : {}),
809
857
  ...(addrResult?.success ? { address: addrResult.payload.address } : {}),
810
858
  },
811
859
  });
@@ -818,8 +866,10 @@ sessionCmd
818
866
  .action(() =>
819
867
  runCommand({}, async ({ sdk, globalOpts }) => {
820
868
  const searchResult = await sdk.searchDevices();
821
- const device = // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
822
- (searchResult?.payload as any)?.[0];
869
+ const device = selectSearchDevice(
870
+ (searchResult?.payload as Array<SearchDevice & { features?: Features }>) ?? [],
871
+ globalOpts.connectId
872
+ );
823
873
  const deviceId = device?.deviceId || device?.features?.device_id;
824
874
  if (deviceId) {
825
875
  await clearSessionFromKeychain(deviceId);
@@ -914,12 +964,12 @@ async function unlockWithRetry(
914
964
  * Prepare passphrase session before SDK calls.
915
965
  *
916
966
  * 1. If --use-empty-passphrase or --passphrase-state provided → use as-is
917
- * 2. Try keychain → preloadSessionCache → use cached session
918
- * 3. Keychain miss → getPassphraseState (triggers 1/2/3 prompt) → save to keychain
967
+ * 2. Try a legacy keychain entry → preloadSessionCache → use cached session
968
+ * 3. Keychain miss → openWalletSession (triggers 1/2/3 prompt)
919
969
  *
920
970
  * After this, globalOpts.passphraseState is set and getCommonParams will include it.
921
971
  */
922
- async function prepareSession(
972
+ export async function prepareSession(
923
973
  sdk: typeof import('@onekeyfe/hd-common-connect-sdk').default,
924
974
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
925
975
  globalOpts: Record<string, any>
@@ -930,7 +980,7 @@ async function prepareSession(
930
980
  }
931
981
 
932
982
  // Errors from the SDK calls below (PIN cancelled, transport broken,
933
- // getPassphraseState rejection) intentionally propagate to runCommand's
983
+ // openWalletSession rejection) intentionally propagate to runCommand's
934
984
  // catch block, which renders them as structured `{ success: false,
935
985
  // payload: { error, code } }` output instead of silently falling through
936
986
  // to a confusing downstream error 112 / 114.
@@ -945,9 +995,30 @@ async function prepareSession(
945
995
  return undefined;
946
996
  }
947
997
 
948
- const device = searchResult.payload[0] as {
998
+ const device = selectSearchDevice(
999
+ searchResult.payload as Array<{
1000
+ connectId?: string;
1001
+ deviceId?: string;
1002
+ deviceType?: string;
1003
+ features?: {
1004
+ deviceId?: string | null;
1005
+ deviceType?: string;
1006
+ sessionId?: string | null;
1007
+ passphraseProtection?: boolean | null;
1008
+ unlocked?: boolean | null;
1009
+ };
1010
+ }>,
1011
+ globalOpts.connectId
1012
+ );
1013
+
1014
+ if (!device) {
1015
+ throw new Error(`未找到指定的 BLE 设备: ${globalOpts.connectId}`);
1016
+ }
1017
+
1018
+ const selectedDevice = device as {
949
1019
  connectId?: string;
950
1020
  deviceId?: string;
1021
+ deviceType?: string;
951
1022
  features?: {
952
1023
  deviceId?: string | null;
953
1024
  deviceType?: string;
@@ -956,7 +1027,7 @@ async function prepareSession(
956
1027
  unlocked?: boolean | null;
957
1028
  };
958
1029
  };
959
- const connectId = device.connectId || globalOpts.connectId || '';
1030
+ const connectId = selectedDevice.connectId || globalOpts.connectId || '';
960
1031
  if (!globalOpts.connectId && connectId) {
961
1032
  globalOpts.connectId = connectId;
962
1033
  }
@@ -964,10 +1035,11 @@ async function prepareSession(
964
1035
  // ── Step 2: Get features if searchDevices didn't populate them ──
965
1036
  // getFeatures failures here are non-fatal — we fall through to Step 3
966
1037
  // which will fail with a clearer error if the device is truly unreachable.
967
- let deviceId = device.features?.deviceId || device.deviceId || '';
968
- let deviceType = getDeviceType(device.features as Features | undefined);
969
- let unlocked = device.features?.unlocked;
970
- let passphraseProtection = device.features?.passphraseProtection;
1038
+ let deviceId = selectedDevice.features?.deviceId || selectedDevice.deviceId || '';
1039
+ let deviceType =
1040
+ selectedDevice.features?.deviceType ?? selectedDevice.deviceType ?? EDeviceType.Unknown;
1041
+ let unlocked = selectedDevice.features?.unlocked;
1042
+ let passphraseProtection = selectedDevice.features?.passphraseProtection;
971
1043
 
972
1044
  if (!deviceId || unlocked == null || passphraseProtection == null) {
973
1045
  try {
@@ -1006,7 +1078,7 @@ async function prepareSession(
1006
1078
  return undefined;
1007
1079
  }
1008
1080
 
1009
- // ── Step 5: Try keychain session reuse ───────────────────────────
1081
+ // ── Step 5: Try legacy keychain session reuse ────────────────────
1010
1082
  // Only attempt if device was already unlocked — locking invalidates
1011
1083
  // all passphrase sessions, so cached session_id is useless after unlock.
1012
1084
  if (!wasLocked && deviceId) {
@@ -1017,40 +1089,19 @@ async function prepareSession(
1017
1089
  }
1018
1090
  }
1019
1091
 
1020
- // ── Step 6: Keychain miss → getPassphraseState (triggers 1/2/3 prompt) ──
1021
- const psResult = await sdk.getPassphraseState(connectId, {
1022
- initSession: true,
1023
- useEmptyPassphrase: false,
1092
+ // ── Step 6: Keychain miss → openWalletSession (triggers 1/2/3 prompt) ──
1093
+ const sessionResult = await sdk.openWalletSession(connectId, {
1094
+ mode: 'select-hidden',
1024
1095
  });
1025
1096
 
1026
- if (psResult.success && psResult.payload) {
1027
- const { passphraseState, sessionId: passphraseSessionId } = extractPassphraseSession(
1028
- psResult.payload
1029
- );
1030
- if (!passphraseState) {
1097
+ if (sessionResult.success && sessionResult.payload) {
1098
+ if (sessionResult.payload.walletType !== 'hidden') {
1031
1099
  return undefined;
1032
1100
  }
1101
+ const { deviceId: sessionDeviceId, passphraseState } = sessionResult.payload;
1102
+ globalOpts.deviceId = sessionDeviceId;
1033
1103
  globalOpts.passphraseState = passphraseState;
1034
1104
 
1035
- // Save session to keychain for next invocation.
1036
- //
1037
- // Pass passphraseState to keep connectStateChange=false — otherwise
1038
- // Initialize would be re-run without passphrase_state, resetting the
1039
- // device to the standard wallet and returning a mismatched session_id.
1040
- // See the matching comment in `session connect`.
1041
- if (deviceId) {
1042
- const featAfter = await sdk.getFeatures(connectId, {
1043
- passphraseState,
1044
- skipPassphraseCheck: true,
1045
- });
1046
- const sessionId =
1047
- passphraseSessionId || (featAfter?.success ? featAfter.payload?.sessionId : undefined);
1048
- if (sessionId) {
1049
- await saveSessionToKeychain(deviceId, passphraseState, sessionId);
1050
- await preloadSessionFromKeychain(deviceId);
1051
- }
1052
- }
1053
-
1054
1105
  return passphraseState;
1055
1106
  }
1056
1107
  return undefined;
@@ -1067,8 +1118,8 @@ function outputResult(_globalOpts: Record<string, any>, result: unknown): void {
1067
1118
  ) {
1068
1119
  process.exitCode = 1;
1069
1120
  }
1070
- // No process.exit here — runCommand() below handles dispose + exit so SDK
1071
- // async cleanup (USB release, event listener teardown) finishes first.
1121
+ // No process.exit here — runCommand() waits for SDK cleanup, then lets Node
1122
+ // exit naturally so leaked USB handles remain observable.
1072
1123
  }
1073
1124
 
1074
1125
  /**
@@ -1080,7 +1131,7 @@ function outputResult(_globalOpts: Record<string, any>, result: unknown): void {
1080
1131
  * 3. run the handler (which calls outputResult on success)
1081
1132
  * 4. report uncaught errors as a structured failure result
1082
1133
  * 5. dispose SDK
1083
- * 6. drain event loop and process.exit with the right code
1134
+ * 6. let Node exit naturally after all SDK resources are released
1084
1135
  *
1085
1136
  * This fixes three previous bugs:
1086
1137
  * - Most signing commands skipped prepareSession, so keychain sessions
@@ -1132,9 +1183,7 @@ async function runCommand(
1132
1183
  // promise reference. Idempotent, safe to call even if init failed.
1133
1184
  await disposeSDK();
1134
1185
  }
1135
- // SDK event listeners can keep the event loop alive after dispose.
1136
- // setImmediate lets any trailing stdout/stderr writes flush first.
1137
- setImmediate(() => process.exit(process.exitCode ?? 0));
1186
+ // disposeSDK awaits transport cleanup; let Node exit naturally so leaks remain visible.
1138
1187
  }
1139
1188
 
1140
1189
  /** For commands that don't touch the SDK at all (e.g. firmware-update stubs). */
@@ -1162,29 +1211,46 @@ function readBinaryParam(path: string): ArrayBuffer {
1162
1211
  return new Uint8Array(buffer).buffer;
1163
1212
  }
1164
1213
 
1165
- function parseResourceBundleParam(spec: string): { binary: ArrayBuffer; devicePath: string } {
1166
- const sep = spec.indexOf(':');
1167
- if (sep <= 0 || sep === spec.length - 1) {
1214
+ async function resolveLegacyFirmwareConnectId(
1215
+ sdk: AnySdk,
1216
+ explicitConnectId?: string,
1217
+ deviceName?: string
1218
+ ): Promise<string> {
1219
+ if (explicitConnectId && !deviceName) return explicitConnectId;
1220
+
1221
+ const searchResult = await sdk.searchDevices();
1222
+ if (!searchResult?.success || !Array.isArray(searchResult.payload)) {
1223
+ throw new Error('Unable to scan BLE devices');
1224
+ }
1225
+
1226
+ const devices = searchResult.payload as EnrichedSearchDevice[];
1227
+ const normalizedName = deviceName?.trim().toLowerCase();
1228
+ const matches = normalizedName
1229
+ ? devices.filter(device => device.name?.trim().toLowerCase() === normalizedName)
1230
+ : devices.filter(device => device.deviceType?.toLowerCase() === 'classic');
1231
+
1232
+ if (matches.length === 0) {
1168
1233
  throw new Error(
1169
- `Invalid --resource-bundle value: "${spec}". Expected <localPath>:<devicePath>`
1234
+ normalizedName
1235
+ ? `BLE device not found by name: ${deviceName}`
1236
+ : 'No Classic/Pure BLE device found'
1170
1237
  );
1171
1238
  }
1172
- const localPath = spec.slice(0, sep);
1173
- const devicePath = spec.slice(sep + 1);
1174
- if (!devicePath.startsWith('vol')) {
1239
+ if (matches.length > 1) {
1175
1240
  throw new Error(
1176
- `Invalid --resource-bundle device path: "${devicePath}". Expected a vol*:/... path`
1241
+ normalizedName
1242
+ ? `Multiple BLE devices found by name: ${deviceName}`
1243
+ : 'Multiple Classic/Pure BLE devices found; specify --device-name'
1177
1244
  );
1178
1245
  }
1179
- return {
1180
- binary: readBinaryParam(localPath),
1181
- devicePath,
1182
- };
1246
+
1247
+ const [{ connectId, name }] = matches;
1248
+ if (!connectId) throw new Error(`BLE device has no connect ID: ${name}`);
1249
+ return connectId;
1183
1250
  }
1184
1251
 
1185
1252
  function getFirmwareUpdateV4TotalBytes(params: ReturnType<typeof buildFirmwareUpdateV4Params>) {
1186
1253
  return [
1187
- ...(params.resourceBundleFiles?.map(item => item.binary) ?? []),
1188
1254
  params.bootloaderBinary,
1189
1255
  params.applicationP1Binary,
1190
1256
  params.applicationP2Binary,
@@ -1234,6 +1300,30 @@ function formatFirmwareBytes(bytes: number) {
1234
1300
  return `${(bytes / 1024).toFixed(1)} KiB`;
1235
1301
  }
1236
1302
 
1303
+ export function buildWallpaperUploadMetrics({
1304
+ totalBytes,
1305
+ transferredBytes,
1306
+ startedAt,
1307
+ endedAt,
1308
+ lastProgress,
1309
+ }: {
1310
+ totalBytes: number;
1311
+ transferredBytes: number;
1312
+ startedAt: number;
1313
+ endedAt: number;
1314
+ lastProgress: number;
1315
+ }) {
1316
+ const elapsedMs = Math.max(endedAt - startedAt, 0);
1317
+ return {
1318
+ totalBytes,
1319
+ transferredBytes,
1320
+ totalSeconds: Number((elapsedMs / 1000).toFixed(2)),
1321
+ transferKiBPerSecond:
1322
+ elapsedMs > 0 ? Number((transferredBytes / 1024 / (elapsedMs / 1000)).toFixed(2)) : null,
1323
+ lastProgress,
1324
+ };
1325
+ }
1326
+
1237
1327
  function maybePrintFirmwareProgress({
1238
1328
  progressType,
1239
1329
  progress,
@@ -1330,7 +1420,7 @@ function buildFirmwareUpdateV4Metrics({
1330
1420
  };
1331
1421
  }
1332
1422
 
1333
- async function runFirmwareUpdateV4WithRetry({
1423
+ export async function runFirmwareUpdateV4WithRetry({
1334
1424
  sdk,
1335
1425
  globalOpts,
1336
1426
  params,
@@ -1344,152 +1434,147 @@ async function runFirmwareUpdateV4WithRetry({
1344
1434
  const totalBytes = getFirmwareUpdateV4TotalBytes(params);
1345
1435
  const maxAttempts = Math.max((retries ?? 2) + 1, 1);
1346
1436
  let currentSdk = sdk;
1347
- let lastResult: unknown;
1348
1437
  let retried = false;
1349
-
1350
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
1351
- let progressEvents = 0;
1352
- let lastProgress = -1;
1353
- let transferStartedAt: number | undefined;
1354
- let transferEndedAt: number | undefined;
1355
- let installProgressEvents = 0;
1356
- let lastInstallProgress = -1;
1357
- let installStartedAt: number | undefined;
1358
- let installEndedAt: number | undefined;
1359
- let lastPrintedTransferProgress = -10;
1360
- let lastPrintedInstallProgress = -10;
1361
- const totalStartedAt = Date.now();
1362
- const connectId =
1363
- retried && globalOpts.transport === 'usb' && globalOpts.connectId
1364
- ? undefined
1365
- : globalOpts.connectId;
1366
-
1367
- const onUiEvent = (message: unknown) => {
1368
- if (!message || typeof message !== 'object') return;
1369
- const messageType = (message as { type?: string }).type;
1370
- const payload = getFirmwareUpdatePayload(message);
1371
-
1372
- if (messageType === UI_REQUEST.FIRMWARE_TIP) {
1373
- const tipMessage = (payload?.data as { message?: unknown } | undefined)?.message;
1374
- if (typeof tipMessage === 'string') {
1375
- process.stderr.write(`[onekey-hw] Firmware: ${tipMessage}\n`);
1376
- }
1377
- return;
1378
- }
1379
-
1380
- if (messageType === UI_REQUEST.REQUEST_BUTTON) {
1381
- const code = typeof payload?.code === 'string' ? ` (${payload.code})` : '';
1382
- process.stderr.write(
1383
- `[onekey-hw] Please confirm the firmware update on your device${code}.\n`
1384
- );
1385
- return;
1438
+ let attempt = 1;
1439
+ let { connectId } = globalOpts;
1440
+
1441
+ if (globalOpts.transport === 'usb') {
1442
+ for (; attempt <= maxAttempts; attempt += 1) {
1443
+ const probeResult = await currentSdk.getDeviceState(connectId, {
1444
+ scope: 'runtime',
1445
+ connectProtocol: 'V2',
1446
+ retryCount: 0,
1447
+ });
1448
+ if (isSuccessResult(probeResult)) break;
1449
+ if (attempt >= maxAttempts || !isProtocolV2UsbProbeTransientResult(probeResult)) {
1450
+ return probeResult;
1386
1451
  }
1387
1452
 
1388
- if (messageType !== UI_REQUEST.FIRMWARE_PROGRESS || !payload) return;
1389
- const progress = Number(payload.progress);
1390
- if (!Number.isFinite(progress)) return;
1453
+ retried = true;
1454
+ process.stderr.write(
1455
+ `[onekey-hw] Protocol V2 USB probe was transient; retrying read-only probe (${attempt}/${maxAttempts})...\n`
1456
+ );
1457
+ await disposeSDK();
1458
+ await new Promise(resolve => {
1459
+ setTimeout(resolve, 3000);
1460
+ });
1461
+ currentSdk = await createSDK(globalOpts);
1462
+ if (globalOpts.connectId) connectId = undefined;
1463
+ }
1464
+ }
1391
1465
 
1392
- if (payload.progressType === 'transferData') {
1393
- progressEvents += 1;
1394
- lastProgress = Math.max(lastProgress, progress);
1395
- transferStartedAt ??= Date.now();
1396
- lastPrintedTransferProgress = maybePrintFirmwareProgress({
1397
- progressType: 'transfer',
1398
- progress,
1399
- payload,
1400
- lastPrintedProgress: lastPrintedTransferProgress,
1401
- });
1402
- if (progress >= 100) {
1403
- transferEndedAt ??= Date.now();
1404
- }
1405
- return;
1466
+ let progressEvents = 0;
1467
+ let lastProgress = -1;
1468
+ let transferStartedAt: number | undefined;
1469
+ let transferEndedAt: number | undefined;
1470
+ let installProgressEvents = 0;
1471
+ let lastInstallProgress = -1;
1472
+ let installStartedAt: number | undefined;
1473
+ let installEndedAt: number | undefined;
1474
+ let lastPrintedTransferProgress = -10;
1475
+ let lastPrintedInstallProgress = -10;
1476
+ const totalStartedAt = Date.now();
1477
+
1478
+ const onUiEvent = (message: unknown) => {
1479
+ if (!message || typeof message !== 'object') return;
1480
+ const messageType = (message as { type?: string }).type;
1481
+ const payload = getFirmwareUpdatePayload(message);
1482
+
1483
+ if (messageType === UI_REQUEST.FIRMWARE_TIP) {
1484
+ const tipMessage = (payload?.data as { message?: unknown } | undefined)?.message;
1485
+ if (typeof tipMessage === 'string') {
1486
+ process.stderr.write(`[onekey-hw] Firmware: ${tipMessage}\n`);
1406
1487
  }
1407
-
1408
- if (payload.progressType === 'installingFirmware') {
1409
- installProgressEvents += 1;
1410
- lastInstallProgress = Math.max(lastInstallProgress, progress);
1411
- installStartedAt ??= Date.now();
1412
- lastPrintedInstallProgress = maybePrintFirmwareProgress({
1413
- progressType: 'install',
1414
- progress,
1415
- payload,
1416
- lastPrintedProgress: lastPrintedInstallProgress,
1417
- });
1418
- if (progress >= 100) {
1419
- installEndedAt ??= Date.now();
1420
- }
1421
- }
1422
- };
1423
-
1424
- currentSdk.on(UI_EVENT, onUiEvent);
1425
- try {
1426
- lastResult = await currentSdk.firmwareUpdateV4(connectId, params);
1427
- } finally {
1428
- currentSdk.off?.(UI_EVENT, onUiEvent);
1429
- }
1430
- if (installStartedAt !== undefined && installEndedAt === undefined) {
1431
- installEndedAt = Date.now();
1488
+ return;
1432
1489
  }
1433
1490
 
1434
- const metrics = buildFirmwareUpdateV4Metrics({
1435
- attempt,
1436
- maxAttempts,
1437
- totalBytes,
1438
- totalStartedAt,
1439
- transferStartedAt,
1440
- transferEndedAt,
1441
- installStartedAt,
1442
- installEndedAt,
1443
- progressEvents,
1444
- lastProgress,
1445
- installProgressEvents,
1446
- lastInstallProgress,
1447
- retried,
1448
- });
1449
-
1450
- if (lastResult && typeof lastResult === 'object') {
1451
- const payload = ((lastResult as { payload?: unknown }).payload ?? {}) as Record<
1452
- string,
1453
- unknown
1454
- >;
1455
- lastResult = {
1456
- ...(lastResult as Record<string, unknown>),
1457
- payload: {
1458
- ...payload,
1459
- metrics,
1460
- },
1461
- };
1491
+ if (messageType === UI_REQUEST.REQUEST_BUTTON) {
1492
+ const code = typeof payload?.code === 'string' ? ` (${payload.code})` : '';
1493
+ process.stderr.write(
1494
+ `[onekey-hw] Please confirm the firmware update on your device${code}.\n`
1495
+ );
1496
+ return;
1462
1497
  }
1463
1498
 
1464
- if (isSuccessResult(lastResult)) {
1465
- return lastResult;
1499
+ if (messageType !== UI_REQUEST.FIRMWARE_PROGRESS || !payload) return;
1500
+ const progress = Number(payload.progress);
1501
+ if (!Number.isFinite(progress)) return;
1502
+
1503
+ if (payload.progressType === 'transferData') {
1504
+ progressEvents += 1;
1505
+ lastProgress = Math.max(lastProgress, progress);
1506
+ transferStartedAt ??= Date.now();
1507
+ lastPrintedTransferProgress = maybePrintFirmwareProgress({
1508
+ progressType: 'transfer',
1509
+ progress,
1510
+ payload,
1511
+ lastPrintedProgress: lastPrintedTransferProgress,
1512
+ });
1513
+ if (progress >= 100) {
1514
+ transferEndedAt ??= Date.now();
1515
+ }
1516
+ return;
1466
1517
  }
1467
1518
 
1468
- if (
1469
- attempt >= maxAttempts ||
1470
- globalOpts.transport !== 'usb' ||
1471
- !isProtocolV2UsbProbeTransientResult(lastResult)
1472
- ) {
1473
- return lastResult;
1519
+ if (payload.progressType === 'installingFirmware') {
1520
+ installProgressEvents += 1;
1521
+ lastInstallProgress = Math.max(lastInstallProgress, progress);
1522
+ installStartedAt ??= Date.now();
1523
+ lastPrintedInstallProgress = maybePrintFirmwareProgress({
1524
+ progressType: 'install',
1525
+ progress,
1526
+ payload,
1527
+ lastPrintedProgress: lastPrintedInstallProgress,
1528
+ });
1529
+ if (progress >= 100) {
1530
+ installEndedAt ??= Date.now();
1531
+ }
1474
1532
  }
1533
+ };
1475
1534
 
1476
- retried = true;
1477
- process.stderr.write(
1478
- `[onekey-hw] Protocol V2 USB probe was transient; retrying firmwareUpdateV4 (${attempt}/${maxAttempts})...\n`
1479
- );
1480
- await disposeSDK();
1481
- await new Promise(resolve => {
1482
- setTimeout(resolve, 3000);
1483
- });
1484
- currentSdk = await createSDK(globalOpts);
1535
+ currentSdk.on(UI_EVENT, onUiEvent);
1536
+ let result: unknown;
1537
+ try {
1538
+ result = await currentSdk.firmwareUpdateV4(connectId, params);
1539
+ } finally {
1540
+ currentSdk.off?.(UI_EVENT, onUiEvent);
1541
+ }
1542
+ if (installStartedAt !== undefined && installEndedAt === undefined) {
1543
+ installEndedAt = Date.now();
1544
+ }
1545
+
1546
+ const metrics = buildFirmwareUpdateV4Metrics({
1547
+ attempt,
1548
+ maxAttempts,
1549
+ totalBytes,
1550
+ totalStartedAt,
1551
+ transferStartedAt,
1552
+ transferEndedAt,
1553
+ installStartedAt,
1554
+ installEndedAt,
1555
+ progressEvents,
1556
+ lastProgress,
1557
+ installProgressEvents,
1558
+ lastInstallProgress,
1559
+ retried,
1560
+ });
1561
+
1562
+ if (result && typeof result === 'object') {
1563
+ const payload = ((result as { payload?: unknown }).payload ?? {}) as Record<string, unknown>;
1564
+ return {
1565
+ ...(result as Record<string, unknown>),
1566
+ payload: {
1567
+ ...payload,
1568
+ metrics,
1569
+ },
1570
+ };
1485
1571
  }
1486
1572
 
1487
- return lastResult;
1573
+ return result;
1488
1574
  }
1489
1575
 
1490
1576
  function buildFirmwareUpdateV4Params(opts: {
1491
1577
  chunkSize?: string;
1492
- resourceBundle?: string[];
1493
1578
  romloader?: string;
1494
1579
  bootloader?: string;
1495
1580
  applicationP1?: string;
@@ -1506,7 +1591,6 @@ function buildFirmwareUpdateV4Params(opts: {
1506
1591
  connectProtocol: 'V2' as const,
1507
1592
  chunkSize: opts.chunkSize ? safeParseInt(opts.chunkSize, '--chunk-size') : undefined,
1508
1593
  forcedUpdateRes: opts.forcedUpdateRes,
1509
- resourceBundleFiles: opts.resourceBundle?.map(parseResourceBundleParam),
1510
1594
  romloaderBinary: opts.romloader ? readBinaryParam(opts.romloader) : undefined,
1511
1595
  bootloaderBinary: opts.bootloader ? readBinaryParam(opts.bootloader) : undefined,
1512
1596
  applicationP1Binary: opts.applicationP1 ? readBinaryParam(opts.applicationP1) : undefined,
@@ -1519,7 +1603,6 @@ function buildFirmwareUpdateV4Params(opts: {
1519
1603
  };
1520
1604
 
1521
1605
  const hasPayload = [
1522
- params.resourceBundleFiles,
1523
1606
  params.romloaderBinary,
1524
1607
  params.bootloaderBinary,
1525
1608
  params.applicationP1Binary,