@onekeyfe/hardware-cli 1.2.0-alpha.13 → 1.2.0-alpha.130

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,112 @@ 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('--jpeg <path>', '604x1024 JPEG 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 jpegBase64 = readFileSync(opts.jpeg).toString('base64');
108
+
109
+ let transferStartedAt: number | undefined;
110
+ let transferEndedAt: number | undefined;
111
+ let lastProgress = -1;
112
+ let lastPrintedProgress = -10;
113
+ let progressTotalBytes = 0;
114
+ let transferredBytes = 0;
115
+ const totalStartedAt = Date.now();
116
+ const onUiEvent = (message: unknown) => {
117
+ if (!message || typeof message !== 'object') return;
118
+ const event = message as {
119
+ type?: string;
120
+ payload?: {
121
+ progress?: number;
122
+ transferredBytes?: number;
123
+ totalBytes?: number;
124
+ rateBytesPerSecond?: number;
125
+ };
126
+ };
127
+ if (event.type !== UI_REQUEST.DEVICE_PROGRESS || !event.payload) return;
128
+ const progress = Number(event.payload.progress);
129
+ if (!Number.isFinite(progress)) return;
130
+ transferStartedAt ??= Date.now();
131
+ lastProgress = Math.max(lastProgress, progress);
132
+ const totalBytes = Number(event.payload.totalBytes);
133
+ if (Number.isFinite(totalBytes) && totalBytes > 0) progressTotalBytes = totalBytes;
134
+ const confirmedBytes = Number(event.payload.transferredBytes);
135
+ if (Number.isFinite(confirmedBytes) && confirmedBytes >= 0) {
136
+ transferredBytes = Math.max(transferredBytes, confirmedBytes);
137
+ }
138
+ const printableProgress = Math.floor(progress / 10) * 10;
139
+ if (printableProgress > lastPrintedProgress || progress >= 100) {
140
+ const rate = Number(event.payload.rateBytesPerSecond);
141
+ const rateText =
142
+ Number.isFinite(rate) && rate > 0 ? ` ${(rate / 1024).toFixed(2)} KiB/s` : '';
143
+ process.stderr.write(
144
+ `[onekey-hw] Wallpaper transfer: ${Math.round(progress)}%${rateText}\n`
145
+ );
146
+ lastPrintedProgress = progress >= 100 ? 100 : printableProgress;
147
+ }
148
+ if (progress >= 100) transferEndedAt ??= Date.now();
149
+ };
150
+
151
+ sdk.on(UI_EVENT, onUiEvent);
152
+ let result: any;
153
+ try {
154
+ result = await sdk.deviceUploadWallpaper(globalOpts.connectId, {
155
+ ...params,
156
+ jpegBase64,
157
+ fileName: opts.fileName,
158
+ chunkSize: opts.chunkSize ? safeParseInt(opts.chunkSize, '--chunk-size') : undefined,
159
+ });
160
+ } finally {
161
+ sdk.off?.(UI_EVENT, onUiEvent);
162
+ }
163
+
164
+ const endedAt = transferEndedAt ?? Date.now();
165
+ const totalBytes = Number(result?.payload?.size) || progressTotalBytes;
166
+ outputResult(globalOpts, {
167
+ ...result,
168
+ metrics: buildWallpaperUploadMetrics({
169
+ totalBytes,
170
+ transferredBytes: result?.success ? totalBytes : transferredBytes,
171
+ startedAt: transferStartedAt ?? totalStartedAt,
172
+ endedAt,
173
+ lastProgress,
174
+ }),
175
+ });
176
+ })
177
+ );
178
+
150
179
  // ============================================================
151
180
  // Signing Commands
152
181
  // ============================================================
@@ -541,6 +570,38 @@ program
541
570
  })
542
571
  );
543
572
 
573
+ program
574
+ .command('firmware-update-legacy')
575
+ .description('Update Classic/Pure firmware through the legacy protocol')
576
+ .requiredOption('--binary <path>', 'Local firmware binary path')
577
+ .option('--device-name <name>', 'BLE advertising name, for example K1514')
578
+ .option('--update-type <type>', 'Firmware component: firmware or ble', 'firmware')
579
+ .option('--no-reboot', 'Do not reboot the device after a successful update')
580
+ .action(opts =>
581
+ runCommand({}, async ({ sdk, globalOpts }) => {
582
+ if (opts.updateType !== 'firmware' && opts.updateType !== 'ble') {
583
+ throw new Error(`Unsupported --update-type: ${opts.updateType}. Use "firmware" or "ble".`);
584
+ }
585
+
586
+ const connectId = await resolveLegacyFirmwareConnectId(
587
+ sdk,
588
+ globalOpts.connectId,
589
+ opts.deviceName
590
+ );
591
+ const result = await sdk.firmwareUpdate(connectId, {
592
+ binary: readBinaryParam(opts.binary),
593
+ updateType: opts.updateType,
594
+ rebootOnSuccess: opts.reboot,
595
+ timeout: getLegacyFirmwareConnectTimeout(globalOpts.transport),
596
+ });
597
+ outputResult(globalOpts, result);
598
+ })
599
+ );
600
+
601
+ export function getLegacyFirmwareConnectTimeout(transport: 'usb' | 'ble') {
602
+ return transport === 'usb' ? 90_000 : undefined;
603
+ }
604
+
544
605
  program
545
606
  .command('firmware-update-ble')
546
607
  .description('Run Protocol V2 firmware update over BLE')
@@ -559,10 +620,6 @@ program
559
620
  .command('firmware-update-v4')
560
621
  .description('Run Protocol V2 firmware update through sdk.firmwareUpdateV4')
561
622
  .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
623
  .option('--romloader <path>', 'FW_MGMT_TARGET_ROMLOADER binary path')
567
624
  .option('--bootloader <path>', 'FW_MGMT_TARGET_BOOTLOADER binary path')
568
625
  .option('--application-p1 <path>', 'FW_MGMT_TARGET_APPLICATION_P1 binary path')
@@ -572,6 +629,7 @@ program
572
629
  .option('--se02 <path>', 'FW_MGMT_TARGET_SE02 binary path')
573
630
  .option('--se03 <path>', 'FW_MGMT_TARGET_SE03 binary path')
574
631
  .option('--se04 <path>', 'FW_MGMT_TARGET_SE04 binary path')
632
+ .option('--resource-archive <path>', 'Complete signed Protocol V2 resource ZIP path')
575
633
  .option('--forced-update-res', 'Force resource update')
576
634
  .option('--retries <count>', 'Retry count for transient Protocol V2 USB probe failures')
577
635
  .action(opts =>
@@ -727,7 +785,7 @@ const sessionCmd = program.command('session').description('Manage device passphr
727
785
 
728
786
  sessionCmd
729
787
  .command('connect')
730
- .description('Connect device and establish passphrase session (cached for subsequent commands)')
788
+ .description('Connect device and select a hidden wallet for this invocation')
731
789
  .action(() =>
732
790
  runCommand({}, async ({ sdk, globalOpts }) => {
733
791
  // 1. Search for device
@@ -739,7 +797,17 @@ sessionCmd
739
797
  });
740
798
  return;
741
799
  }
742
- const device = searchResult.payload[0] as EnrichedSearchDevice;
800
+ const device = selectSearchDevice(
801
+ searchResult.payload as Array<SearchDevice & { features?: Features }>,
802
+ globalOpts.connectId
803
+ );
804
+ if (!device) {
805
+ outputResult(globalOpts, {
806
+ success: false,
807
+ payload: { error: 'No matching device found', code: 'NO_DEVICE' },
808
+ });
809
+ return;
810
+ }
743
811
  const connectId = device.connectId || globalOpts.connectId;
744
812
 
745
813
  // 2. Unlock if locked — getPassphraseState below talks to a live
@@ -750,62 +818,35 @@ sessionCmd
750
818
  await unlockWithRetry(sdk, connectId);
751
819
  }
752
820
 
753
- // 3. Get passphraseState (triggers 1/2/3 selection)
754
- const psResult = await sdk.getPassphraseState(connectId, {
755
- initSession: true,
756
- useEmptyPassphrase: false,
821
+ // 3. Open a hidden wallet session (triggers 1/2/3 selection).
822
+ const sessionResult = await sdk.openWalletSession(connectId, {
823
+ mode: 'select-hidden',
757
824
  });
758
- if (!psResult.success) {
759
- outputResult(globalOpts, psResult);
825
+ if (!sessionResult.success) {
826
+ outputResult(globalOpts, sessionResult);
760
827
  return;
761
828
  }
762
- const { passphraseState, sessionId: passphraseSessionId } = extractPassphraseSession(
763
- psResult.payload
764
- );
765
- if (!passphraseState) {
829
+ if (sessionResult.payload.walletType !== 'hidden') {
766
830
  outputResult(globalOpts, {
767
831
  success: false,
768
- payload: { error: 'getPassphraseState did not return passphraseState' },
832
+ payload: { error: 'Hidden wallet selection did not return a hidden wallet session' },
769
833
  });
770
834
  return;
771
835
  }
836
+ const { deviceId, passphraseState } = sessionResult.payload;
772
837
 
773
838
  // 4. Get address to verify + extract deviceId
774
- const addrResult = await sdk.evmGetAddress(connectId, device.deviceId || '', {
839
+ const addrResult = await sdk.evmGetAddress(connectId, deviceId, {
775
840
  path: "m/44'/60'/0'/0/0",
776
841
  showOnOneKey: false,
777
842
  passphraseState,
778
843
  });
779
844
 
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
845
  outputResult(globalOpts, {
804
846
  success: true,
805
847
  payload: {
806
848
  passphraseState,
807
849
  deviceId,
808
- ...(sessionId ? { sessionId } : {}),
809
850
  ...(addrResult?.success ? { address: addrResult.payload.address } : {}),
810
851
  },
811
852
  });
@@ -818,8 +859,10 @@ sessionCmd
818
859
  .action(() =>
819
860
  runCommand({}, async ({ sdk, globalOpts }) => {
820
861
  const searchResult = await sdk.searchDevices();
821
- const device = // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
822
- (searchResult?.payload as any)?.[0];
862
+ const device = selectSearchDevice(
863
+ (searchResult?.payload as Array<SearchDevice & { features?: Features }>) ?? [],
864
+ globalOpts.connectId
865
+ );
823
866
  const deviceId = device?.deviceId || device?.features?.device_id;
824
867
  if (deviceId) {
825
868
  await clearSessionFromKeychain(deviceId);
@@ -914,12 +957,12 @@ async function unlockWithRetry(
914
957
  * Prepare passphrase session before SDK calls.
915
958
  *
916
959
  * 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
960
+ * 2. Try a legacy keychain entry → preloadSessionCache → use cached session
961
+ * 3. Keychain miss → openWalletSession (triggers 1/2/3 prompt)
919
962
  *
920
963
  * After this, globalOpts.passphraseState is set and getCommonParams will include it.
921
964
  */
922
- async function prepareSession(
965
+ export async function prepareSession(
923
966
  sdk: typeof import('@onekeyfe/hd-common-connect-sdk').default,
924
967
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
925
968
  globalOpts: Record<string, any>
@@ -930,7 +973,7 @@ async function prepareSession(
930
973
  }
931
974
 
932
975
  // Errors from the SDK calls below (PIN cancelled, transport broken,
933
- // getPassphraseState rejection) intentionally propagate to runCommand's
976
+ // openWalletSession rejection) intentionally propagate to runCommand's
934
977
  // catch block, which renders them as structured `{ success: false,
935
978
  // payload: { error, code } }` output instead of silently falling through
936
979
  // to a confusing downstream error 112 / 114.
@@ -945,9 +988,30 @@ async function prepareSession(
945
988
  return undefined;
946
989
  }
947
990
 
948
- const device = searchResult.payload[0] as {
991
+ const device = selectSearchDevice(
992
+ searchResult.payload as Array<{
993
+ connectId?: string;
994
+ deviceId?: string;
995
+ deviceType?: string;
996
+ features?: {
997
+ deviceId?: string | null;
998
+ deviceType?: string;
999
+ sessionId?: string | null;
1000
+ passphraseProtection?: boolean | null;
1001
+ unlocked?: boolean | null;
1002
+ };
1003
+ }>,
1004
+ globalOpts.connectId
1005
+ );
1006
+
1007
+ if (!device) {
1008
+ throw new Error(`未找到指定的 BLE 设备: ${globalOpts.connectId}`);
1009
+ }
1010
+
1011
+ const selectedDevice = device as {
949
1012
  connectId?: string;
950
1013
  deviceId?: string;
1014
+ deviceType?: string;
951
1015
  features?: {
952
1016
  deviceId?: string | null;
953
1017
  deviceType?: string;
@@ -956,7 +1020,7 @@ async function prepareSession(
956
1020
  unlocked?: boolean | null;
957
1021
  };
958
1022
  };
959
- const connectId = device.connectId || globalOpts.connectId || '';
1023
+ const connectId = selectedDevice.connectId || globalOpts.connectId || '';
960
1024
  if (!globalOpts.connectId && connectId) {
961
1025
  globalOpts.connectId = connectId;
962
1026
  }
@@ -964,10 +1028,11 @@ async function prepareSession(
964
1028
  // ── Step 2: Get features if searchDevices didn't populate them ──
965
1029
  // getFeatures failures here are non-fatal — we fall through to Step 3
966
1030
  // 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;
1031
+ let deviceId = selectedDevice.features?.deviceId || selectedDevice.deviceId || '';
1032
+ let deviceType =
1033
+ selectedDevice.features?.deviceType ?? selectedDevice.deviceType ?? EDeviceType.Unknown;
1034
+ let unlocked = selectedDevice.features?.unlocked;
1035
+ let passphraseProtection = selectedDevice.features?.passphraseProtection;
971
1036
 
972
1037
  if (!deviceId || unlocked == null || passphraseProtection == null) {
973
1038
  try {
@@ -1006,7 +1071,7 @@ async function prepareSession(
1006
1071
  return undefined;
1007
1072
  }
1008
1073
 
1009
- // ── Step 5: Try keychain session reuse ───────────────────────────
1074
+ // ── Step 5: Try legacy keychain session reuse ────────────────────
1010
1075
  // Only attempt if device was already unlocked — locking invalidates
1011
1076
  // all passphrase sessions, so cached session_id is useless after unlock.
1012
1077
  if (!wasLocked && deviceId) {
@@ -1017,40 +1082,19 @@ async function prepareSession(
1017
1082
  }
1018
1083
  }
1019
1084
 
1020
- // ── Step 6: Keychain miss → getPassphraseState (triggers 1/2/3 prompt) ──
1021
- const psResult = await sdk.getPassphraseState(connectId, {
1022
- initSession: true,
1023
- useEmptyPassphrase: false,
1085
+ // ── Step 6: Keychain miss → openWalletSession (triggers 1/2/3 prompt) ──
1086
+ const sessionResult = await sdk.openWalletSession(connectId, {
1087
+ mode: 'select-hidden',
1024
1088
  });
1025
1089
 
1026
- if (psResult.success && psResult.payload) {
1027
- const { passphraseState, sessionId: passphraseSessionId } = extractPassphraseSession(
1028
- psResult.payload
1029
- );
1030
- if (!passphraseState) {
1090
+ if (sessionResult.success && sessionResult.payload) {
1091
+ if (sessionResult.payload.walletType !== 'hidden') {
1031
1092
  return undefined;
1032
1093
  }
1094
+ const { deviceId: sessionDeviceId, passphraseState } = sessionResult.payload;
1095
+ globalOpts.deviceId = sessionDeviceId;
1033
1096
  globalOpts.passphraseState = passphraseState;
1034
1097
 
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
1098
  return passphraseState;
1055
1099
  }
1056
1100
  return undefined;
@@ -1067,8 +1111,8 @@ function outputResult(_globalOpts: Record<string, any>, result: unknown): void {
1067
1111
  ) {
1068
1112
  process.exitCode = 1;
1069
1113
  }
1070
- // No process.exit here — runCommand() below handles dispose + exit so SDK
1071
- // async cleanup (USB release, event listener teardown) finishes first.
1114
+ // No process.exit here — runCommand() waits for SDK cleanup, then lets Node
1115
+ // exit naturally so leaked USB handles remain observable.
1072
1116
  }
1073
1117
 
1074
1118
  /**
@@ -1080,7 +1124,7 @@ function outputResult(_globalOpts: Record<string, any>, result: unknown): void {
1080
1124
  * 3. run the handler (which calls outputResult on success)
1081
1125
  * 4. report uncaught errors as a structured failure result
1082
1126
  * 5. dispose SDK
1083
- * 6. drain event loop and process.exit with the right code
1127
+ * 6. let Node exit naturally after all SDK resources are released
1084
1128
  *
1085
1129
  * This fixes three previous bugs:
1086
1130
  * - Most signing commands skipped prepareSession, so keychain sessions
@@ -1132,9 +1176,7 @@ async function runCommand(
1132
1176
  // promise reference. Idempotent, safe to call even if init failed.
1133
1177
  await disposeSDK();
1134
1178
  }
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));
1179
+ // disposeSDK awaits transport cleanup; let Node exit naturally so leaks remain visible.
1138
1180
  }
1139
1181
 
1140
1182
  /** For commands that don't touch the SDK at all (e.g. firmware-update stubs). */
@@ -1162,29 +1204,46 @@ function readBinaryParam(path: string): ArrayBuffer {
1162
1204
  return new Uint8Array(buffer).buffer;
1163
1205
  }
1164
1206
 
1165
- function parseResourceBundleParam(spec: string): { binary: ArrayBuffer; devicePath: string } {
1166
- const sep = spec.indexOf(':');
1167
- if (sep <= 0 || sep === spec.length - 1) {
1207
+ async function resolveLegacyFirmwareConnectId(
1208
+ sdk: AnySdk,
1209
+ explicitConnectId?: string,
1210
+ deviceName?: string
1211
+ ): Promise<string> {
1212
+ if (explicitConnectId && !deviceName) return explicitConnectId;
1213
+
1214
+ const searchResult = await sdk.searchDevices();
1215
+ if (!searchResult?.success || !Array.isArray(searchResult.payload)) {
1216
+ throw new Error('Unable to scan BLE devices');
1217
+ }
1218
+
1219
+ const devices = searchResult.payload as EnrichedSearchDevice[];
1220
+ const normalizedName = deviceName?.trim().toLowerCase();
1221
+ const matches = normalizedName
1222
+ ? devices.filter(device => device.name?.trim().toLowerCase() === normalizedName)
1223
+ : devices.filter(device => device.deviceType?.toLowerCase() === 'classic');
1224
+
1225
+ if (matches.length === 0) {
1168
1226
  throw new Error(
1169
- `Invalid --resource-bundle value: "${spec}". Expected <localPath>:<devicePath>`
1227
+ normalizedName
1228
+ ? `BLE device not found by name: ${deviceName}`
1229
+ : 'No Classic/Pure BLE device found'
1170
1230
  );
1171
1231
  }
1172
- const localPath = spec.slice(0, sep);
1173
- const devicePath = spec.slice(sep + 1);
1174
- if (!devicePath.startsWith('vol')) {
1232
+ if (matches.length > 1) {
1175
1233
  throw new Error(
1176
- `Invalid --resource-bundle device path: "${devicePath}". Expected a vol*:/... path`
1234
+ normalizedName
1235
+ ? `Multiple BLE devices found by name: ${deviceName}`
1236
+ : 'Multiple Classic/Pure BLE devices found; specify --device-name'
1177
1237
  );
1178
1238
  }
1179
- return {
1180
- binary: readBinaryParam(localPath),
1181
- devicePath,
1182
- };
1239
+
1240
+ const [{ connectId, name }] = matches;
1241
+ if (!connectId) throw new Error(`BLE device has no connect ID: ${name}`);
1242
+ return connectId;
1183
1243
  }
1184
1244
 
1185
1245
  function getFirmwareUpdateV4TotalBytes(params: ReturnType<typeof buildFirmwareUpdateV4Params>) {
1186
1246
  return [
1187
- ...(params.resourceBundleFiles?.map(item => item.binary) ?? []),
1188
1247
  params.bootloaderBinary,
1189
1248
  params.applicationP1Binary,
1190
1249
  params.applicationP2Binary,
@@ -1234,6 +1293,30 @@ function formatFirmwareBytes(bytes: number) {
1234
1293
  return `${(bytes / 1024).toFixed(1)} KiB`;
1235
1294
  }
1236
1295
 
1296
+ export function buildWallpaperUploadMetrics({
1297
+ totalBytes,
1298
+ transferredBytes,
1299
+ startedAt,
1300
+ endedAt,
1301
+ lastProgress,
1302
+ }: {
1303
+ totalBytes: number;
1304
+ transferredBytes: number;
1305
+ startedAt: number;
1306
+ endedAt: number;
1307
+ lastProgress: number;
1308
+ }) {
1309
+ const elapsedMs = Math.max(endedAt - startedAt, 0);
1310
+ return {
1311
+ totalBytes,
1312
+ transferredBytes,
1313
+ totalSeconds: Number((elapsedMs / 1000).toFixed(2)),
1314
+ transferKiBPerSecond:
1315
+ elapsedMs > 0 ? Number((transferredBytes / 1024 / (elapsedMs / 1000)).toFixed(2)) : null,
1316
+ lastProgress,
1317
+ };
1318
+ }
1319
+
1237
1320
  function maybePrintFirmwareProgress({
1238
1321
  progressType,
1239
1322
  progress,
@@ -1330,7 +1413,7 @@ function buildFirmwareUpdateV4Metrics({
1330
1413
  };
1331
1414
  }
1332
1415
 
1333
- async function runFirmwareUpdateV4WithRetry({
1416
+ export async function runFirmwareUpdateV4WithRetry({
1334
1417
  sdk,
1335
1418
  globalOpts,
1336
1419
  params,
@@ -1344,152 +1427,147 @@ async function runFirmwareUpdateV4WithRetry({
1344
1427
  const totalBytes = getFirmwareUpdateV4TotalBytes(params);
1345
1428
  const maxAttempts = Math.max((retries ?? 2) + 1, 1);
1346
1429
  let currentSdk = sdk;
1347
- let lastResult: unknown;
1348
1430
  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;
1431
+ let attempt = 1;
1432
+ let { connectId } = globalOpts;
1433
+
1434
+ if (globalOpts.transport === 'usb') {
1435
+ for (; attempt <= maxAttempts; attempt += 1) {
1436
+ const probeResult = await currentSdk.getDeviceState(connectId, {
1437
+ scope: 'runtime',
1438
+ connectProtocol: 'V2',
1439
+ retryCount: 0,
1440
+ });
1441
+ if (isSuccessResult(probeResult)) break;
1442
+ if (attempt >= maxAttempts || !isProtocolV2UsbProbeTransientResult(probeResult)) {
1443
+ return probeResult;
1386
1444
  }
1387
1445
 
1388
- if (messageType !== UI_REQUEST.FIRMWARE_PROGRESS || !payload) return;
1389
- const progress = Number(payload.progress);
1390
- if (!Number.isFinite(progress)) return;
1391
-
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;
1406
- }
1446
+ retried = true;
1447
+ process.stderr.write(
1448
+ `[onekey-hw] Protocol V2 USB probe was transient; retrying read-only probe (${attempt}/${maxAttempts})...\n`
1449
+ );
1450
+ await disposeSDK();
1451
+ await new Promise(resolve => {
1452
+ setTimeout(resolve, 3000);
1453
+ });
1454
+ currentSdk = await createSDK(globalOpts);
1455
+ if (globalOpts.connectId) connectId = undefined;
1456
+ }
1457
+ }
1407
1458
 
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
- }
1459
+ let progressEvents = 0;
1460
+ let lastProgress = -1;
1461
+ let transferStartedAt: number | undefined;
1462
+ let transferEndedAt: number | undefined;
1463
+ let installProgressEvents = 0;
1464
+ let lastInstallProgress = -1;
1465
+ let installStartedAt: number | undefined;
1466
+ let installEndedAt: number | undefined;
1467
+ let lastPrintedTransferProgress = -10;
1468
+ let lastPrintedInstallProgress = -10;
1469
+ const totalStartedAt = Date.now();
1470
+
1471
+ const onUiEvent = (message: unknown) => {
1472
+ if (!message || typeof message !== 'object') return;
1473
+ const messageType = (message as { type?: string }).type;
1474
+ const payload = getFirmwareUpdatePayload(message);
1475
+
1476
+ if (messageType === UI_REQUEST.FIRMWARE_TIP) {
1477
+ const tipMessage = (payload?.data as { message?: unknown } | undefined)?.message;
1478
+ if (typeof tipMessage === 'string') {
1479
+ process.stderr.write(`[onekey-hw] Firmware: ${tipMessage}\n`);
1421
1480
  }
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();
1481
+ return;
1432
1482
  }
1433
1483
 
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
- };
1484
+ if (messageType === UI_REQUEST.REQUEST_BUTTON) {
1485
+ const code = typeof payload?.code === 'string' ? ` (${payload.code})` : '';
1486
+ process.stderr.write(
1487
+ `[onekey-hw] Please confirm the firmware update on your device${code}.\n`
1488
+ );
1489
+ return;
1462
1490
  }
1463
1491
 
1464
- if (isSuccessResult(lastResult)) {
1465
- return lastResult;
1492
+ if (messageType !== UI_REQUEST.FIRMWARE_PROGRESS || !payload) return;
1493
+ const progress = Number(payload.progress);
1494
+ if (!Number.isFinite(progress)) return;
1495
+
1496
+ if (payload.progressType === 'transferData') {
1497
+ progressEvents += 1;
1498
+ lastProgress = Math.max(lastProgress, progress);
1499
+ transferStartedAt ??= Date.now();
1500
+ lastPrintedTransferProgress = maybePrintFirmwareProgress({
1501
+ progressType: 'transfer',
1502
+ progress,
1503
+ payload,
1504
+ lastPrintedProgress: lastPrintedTransferProgress,
1505
+ });
1506
+ if (progress >= 100) {
1507
+ transferEndedAt ??= Date.now();
1508
+ }
1509
+ return;
1466
1510
  }
1467
1511
 
1468
- if (
1469
- attempt >= maxAttempts ||
1470
- globalOpts.transport !== 'usb' ||
1471
- !isProtocolV2UsbProbeTransientResult(lastResult)
1472
- ) {
1473
- return lastResult;
1512
+ if (payload.progressType === 'installingFirmware') {
1513
+ installProgressEvents += 1;
1514
+ lastInstallProgress = Math.max(lastInstallProgress, progress);
1515
+ installStartedAt ??= Date.now();
1516
+ lastPrintedInstallProgress = maybePrintFirmwareProgress({
1517
+ progressType: 'install',
1518
+ progress,
1519
+ payload,
1520
+ lastPrintedProgress: lastPrintedInstallProgress,
1521
+ });
1522
+ if (progress >= 100) {
1523
+ installEndedAt ??= Date.now();
1524
+ }
1474
1525
  }
1526
+ };
1475
1527
 
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);
1528
+ currentSdk.on(UI_EVENT, onUiEvent);
1529
+ let result: unknown;
1530
+ try {
1531
+ result = await currentSdk.firmwareUpdateV4(connectId, params);
1532
+ } finally {
1533
+ currentSdk.off?.(UI_EVENT, onUiEvent);
1534
+ }
1535
+ if (installStartedAt !== undefined && installEndedAt === undefined) {
1536
+ installEndedAt = Date.now();
1537
+ }
1538
+
1539
+ const metrics = buildFirmwareUpdateV4Metrics({
1540
+ attempt,
1541
+ maxAttempts,
1542
+ totalBytes,
1543
+ totalStartedAt,
1544
+ transferStartedAt,
1545
+ transferEndedAt,
1546
+ installStartedAt,
1547
+ installEndedAt,
1548
+ progressEvents,
1549
+ lastProgress,
1550
+ installProgressEvents,
1551
+ lastInstallProgress,
1552
+ retried,
1553
+ });
1554
+
1555
+ if (result && typeof result === 'object') {
1556
+ const payload = ((result as { payload?: unknown }).payload ?? {}) as Record<string, unknown>;
1557
+ return {
1558
+ ...(result as Record<string, unknown>),
1559
+ payload: {
1560
+ ...payload,
1561
+ metrics,
1562
+ },
1563
+ };
1485
1564
  }
1486
1565
 
1487
- return lastResult;
1566
+ return result;
1488
1567
  }
1489
1568
 
1490
1569
  function buildFirmwareUpdateV4Params(opts: {
1491
1570
  chunkSize?: string;
1492
- resourceBundle?: string[];
1493
1571
  romloader?: string;
1494
1572
  bootloader?: string;
1495
1573
  applicationP1?: string;
@@ -1499,6 +1577,7 @@ function buildFirmwareUpdateV4Params(opts: {
1499
1577
  se02?: string;
1500
1578
  se03?: string;
1501
1579
  se04?: string;
1580
+ resourceArchive?: string;
1502
1581
  forcedUpdateRes?: boolean;
1503
1582
  }) {
1504
1583
  const params = {
@@ -1506,7 +1585,6 @@ function buildFirmwareUpdateV4Params(opts: {
1506
1585
  connectProtocol: 'V2' as const,
1507
1586
  chunkSize: opts.chunkSize ? safeParseInt(opts.chunkSize, '--chunk-size') : undefined,
1508
1587
  forcedUpdateRes: opts.forcedUpdateRes,
1509
- resourceBundleFiles: opts.resourceBundle?.map(parseResourceBundleParam),
1510
1588
  romloaderBinary: opts.romloader ? readBinaryParam(opts.romloader) : undefined,
1511
1589
  bootloaderBinary: opts.bootloader ? readBinaryParam(opts.bootloader) : undefined,
1512
1590
  applicationP1Binary: opts.applicationP1 ? readBinaryParam(opts.applicationP1) : undefined,
@@ -1516,10 +1594,10 @@ function buildFirmwareUpdateV4Params(opts: {
1516
1594
  se02Binary: opts.se02 ? readBinaryParam(opts.se02) : undefined,
1517
1595
  se03Binary: opts.se03 ? readBinaryParam(opts.se03) : undefined,
1518
1596
  se04Binary: opts.se04 ? readBinaryParam(opts.se04) : undefined,
1597
+ resourceArchiveBinary: opts.resourceArchive ? readBinaryParam(opts.resourceArchive) : undefined,
1519
1598
  };
1520
1599
 
1521
1600
  const hasPayload = [
1522
- params.resourceBundleFiles,
1523
1601
  params.romloaderBinary,
1524
1602
  params.bootloaderBinary,
1525
1603
  params.applicationP1Binary,
@@ -1529,10 +1607,13 @@ function buildFirmwareUpdateV4Params(opts: {
1529
1607
  params.se02Binary,
1530
1608
  params.se03Binary,
1531
1609
  params.se04Binary,
1610
+ params.resourceArchiveBinary,
1532
1611
  ].some(Boolean);
1533
1612
 
1534
1613
  if (!hasPayload) {
1535
- const err = new Error('firmware-update-v4 requires at least one binary path');
1614
+ const err = new Error(
1615
+ 'firmware-update-v4 requires at least one firmware binary or resource archive path'
1616
+ );
1536
1617
  (err as Error & { code?: string }).code = 'MISSING_FIRMWARE_BINARY';
1537
1618
  throw err;
1538
1619
  }