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

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')
@@ -572,6 +637,7 @@ program
572
637
  .option('--se02 <path>', 'FW_MGMT_TARGET_SE02 binary path')
573
638
  .option('--se03 <path>', 'FW_MGMT_TARGET_SE03 binary path')
574
639
  .option('--se04 <path>', 'FW_MGMT_TARGET_SE04 binary path')
640
+ .option('--resource-archive <path>', 'Complete signed Protocol V2 resource ZIP path')
575
641
  .option('--forced-update-res', 'Force resource update')
576
642
  .option('--retries <count>', 'Retry count for transient Protocol V2 USB probe failures')
577
643
  .action(opts =>
@@ -727,7 +793,7 @@ const sessionCmd = program.command('session').description('Manage device passphr
727
793
 
728
794
  sessionCmd
729
795
  .command('connect')
730
- .description('Connect device and establish passphrase session (cached for subsequent commands)')
796
+ .description('Connect device and select a hidden wallet for this invocation')
731
797
  .action(() =>
732
798
  runCommand({}, async ({ sdk, globalOpts }) => {
733
799
  // 1. Search for device
@@ -739,7 +805,17 @@ sessionCmd
739
805
  });
740
806
  return;
741
807
  }
742
- 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
+ }
743
819
  const connectId = device.connectId || globalOpts.connectId;
744
820
 
745
821
  // 2. Unlock if locked — getPassphraseState below talks to a live
@@ -750,62 +826,35 @@ sessionCmd
750
826
  await unlockWithRetry(sdk, connectId);
751
827
  }
752
828
 
753
- // 3. Get passphraseState (triggers 1/2/3 selection)
754
- const psResult = await sdk.getPassphraseState(connectId, {
755
- initSession: true,
756
- 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',
757
832
  });
758
- if (!psResult.success) {
759
- outputResult(globalOpts, psResult);
833
+ if (!sessionResult.success) {
834
+ outputResult(globalOpts, sessionResult);
760
835
  return;
761
836
  }
762
- const { passphraseState, sessionId: passphraseSessionId } = extractPassphraseSession(
763
- psResult.payload
764
- );
765
- if (!passphraseState) {
837
+ if (sessionResult.payload.walletType !== 'hidden') {
766
838
  outputResult(globalOpts, {
767
839
  success: false,
768
- payload: { error: 'getPassphraseState did not return passphraseState' },
840
+ payload: { error: 'Hidden wallet selection did not return a hidden wallet session' },
769
841
  });
770
842
  return;
771
843
  }
844
+ const { deviceId, passphraseState } = sessionResult.payload;
772
845
 
773
846
  // 4. Get address to verify + extract deviceId
774
- const addrResult = await sdk.evmGetAddress(connectId, device.deviceId || '', {
847
+ const addrResult = await sdk.evmGetAddress(connectId, deviceId, {
775
848
  path: "m/44'/60'/0'/0/0",
776
849
  showOnOneKey: false,
777
850
  passphraseState,
778
851
  });
779
852
 
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
853
  outputResult(globalOpts, {
804
854
  success: true,
805
855
  payload: {
806
856
  passphraseState,
807
857
  deviceId,
808
- ...(sessionId ? { sessionId } : {}),
809
858
  ...(addrResult?.success ? { address: addrResult.payload.address } : {}),
810
859
  },
811
860
  });
@@ -818,8 +867,10 @@ sessionCmd
818
867
  .action(() =>
819
868
  runCommand({}, async ({ sdk, globalOpts }) => {
820
869
  const searchResult = await sdk.searchDevices();
821
- const device = // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
822
- (searchResult?.payload as any)?.[0];
870
+ const device = selectSearchDevice(
871
+ (searchResult?.payload as Array<SearchDevice & { features?: Features }>) ?? [],
872
+ globalOpts.connectId
873
+ );
823
874
  const deviceId = device?.deviceId || device?.features?.device_id;
824
875
  if (deviceId) {
825
876
  await clearSessionFromKeychain(deviceId);
@@ -914,12 +965,12 @@ async function unlockWithRetry(
914
965
  * Prepare passphrase session before SDK calls.
915
966
  *
916
967
  * 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
968
+ * 2. Try a legacy keychain entry → preloadSessionCache → use cached session
969
+ * 3. Keychain miss → openWalletSession (triggers 1/2/3 prompt)
919
970
  *
920
971
  * After this, globalOpts.passphraseState is set and getCommonParams will include it.
921
972
  */
922
- async function prepareSession(
973
+ export async function prepareSession(
923
974
  sdk: typeof import('@onekeyfe/hd-common-connect-sdk').default,
924
975
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
925
976
  globalOpts: Record<string, any>
@@ -930,7 +981,7 @@ async function prepareSession(
930
981
  }
931
982
 
932
983
  // Errors from the SDK calls below (PIN cancelled, transport broken,
933
- // getPassphraseState rejection) intentionally propagate to runCommand's
984
+ // openWalletSession rejection) intentionally propagate to runCommand's
934
985
  // catch block, which renders them as structured `{ success: false,
935
986
  // payload: { error, code } }` output instead of silently falling through
936
987
  // to a confusing downstream error 112 / 114.
@@ -945,9 +996,30 @@ async function prepareSession(
945
996
  return undefined;
946
997
  }
947
998
 
948
- 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 {
949
1020
  connectId?: string;
950
1021
  deviceId?: string;
1022
+ deviceType?: string;
951
1023
  features?: {
952
1024
  deviceId?: string | null;
953
1025
  deviceType?: string;
@@ -956,7 +1028,7 @@ async function prepareSession(
956
1028
  unlocked?: boolean | null;
957
1029
  };
958
1030
  };
959
- const connectId = device.connectId || globalOpts.connectId || '';
1031
+ const connectId = selectedDevice.connectId || globalOpts.connectId || '';
960
1032
  if (!globalOpts.connectId && connectId) {
961
1033
  globalOpts.connectId = connectId;
962
1034
  }
@@ -964,10 +1036,11 @@ async function prepareSession(
964
1036
  // ── Step 2: Get features if searchDevices didn't populate them ──
965
1037
  // getFeatures failures here are non-fatal — we fall through to Step 3
966
1038
  // 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;
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;
971
1044
 
972
1045
  if (!deviceId || unlocked == null || passphraseProtection == null) {
973
1046
  try {
@@ -1006,7 +1079,7 @@ async function prepareSession(
1006
1079
  return undefined;
1007
1080
  }
1008
1081
 
1009
- // ── Step 5: Try keychain session reuse ───────────────────────────
1082
+ // ── Step 5: Try legacy keychain session reuse ────────────────────
1010
1083
  // Only attempt if device was already unlocked — locking invalidates
1011
1084
  // all passphrase sessions, so cached session_id is useless after unlock.
1012
1085
  if (!wasLocked && deviceId) {
@@ -1017,40 +1090,19 @@ async function prepareSession(
1017
1090
  }
1018
1091
  }
1019
1092
 
1020
- // ── Step 6: Keychain miss → getPassphraseState (triggers 1/2/3 prompt) ──
1021
- const psResult = await sdk.getPassphraseState(connectId, {
1022
- initSession: true,
1023
- useEmptyPassphrase: false,
1093
+ // ── Step 6: Keychain miss → openWalletSession (triggers 1/2/3 prompt) ──
1094
+ const sessionResult = await sdk.openWalletSession(connectId, {
1095
+ mode: 'select-hidden',
1024
1096
  });
1025
1097
 
1026
- if (psResult.success && psResult.payload) {
1027
- const { passphraseState, sessionId: passphraseSessionId } = extractPassphraseSession(
1028
- psResult.payload
1029
- );
1030
- if (!passphraseState) {
1098
+ if (sessionResult.success && sessionResult.payload) {
1099
+ if (sessionResult.payload.walletType !== 'hidden') {
1031
1100
  return undefined;
1032
1101
  }
1102
+ const { deviceId: sessionDeviceId, passphraseState } = sessionResult.payload;
1103
+ globalOpts.deviceId = sessionDeviceId;
1033
1104
  globalOpts.passphraseState = passphraseState;
1034
1105
 
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
1106
  return passphraseState;
1055
1107
  }
1056
1108
  return undefined;
@@ -1067,8 +1119,8 @@ function outputResult(_globalOpts: Record<string, any>, result: unknown): void {
1067
1119
  ) {
1068
1120
  process.exitCode = 1;
1069
1121
  }
1070
- // No process.exit here — runCommand() below handles dispose + exit so SDK
1071
- // 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.
1072
1124
  }
1073
1125
 
1074
1126
  /**
@@ -1080,7 +1132,7 @@ function outputResult(_globalOpts: Record<string, any>, result: unknown): void {
1080
1132
  * 3. run the handler (which calls outputResult on success)
1081
1133
  * 4. report uncaught errors as a structured failure result
1082
1134
  * 5. dispose SDK
1083
- * 6. drain event loop and process.exit with the right code
1135
+ * 6. let Node exit naturally after all SDK resources are released
1084
1136
  *
1085
1137
  * This fixes three previous bugs:
1086
1138
  * - Most signing commands skipped prepareSession, so keychain sessions
@@ -1132,9 +1184,7 @@ async function runCommand(
1132
1184
  // promise reference. Idempotent, safe to call even if init failed.
1133
1185
  await disposeSDK();
1134
1186
  }
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));
1187
+ // disposeSDK awaits transport cleanup; let Node exit naturally so leaks remain visible.
1138
1188
  }
1139
1189
 
1140
1190
  /** For commands that don't touch the SDK at all (e.g. firmware-update stubs). */
@@ -1162,29 +1212,46 @@ function readBinaryParam(path: string): ArrayBuffer {
1162
1212
  return new Uint8Array(buffer).buffer;
1163
1213
  }
1164
1214
 
1165
- function parseResourceBundleParam(spec: string): { binary: ArrayBuffer; devicePath: string } {
1166
- const sep = spec.indexOf(':');
1167
- if (sep <= 0 || sep === spec.length - 1) {
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) {
1168
1234
  throw new Error(
1169
- `Invalid --resource-bundle value: "${spec}". Expected <localPath>:<devicePath>`
1235
+ normalizedName
1236
+ ? `BLE device not found by name: ${deviceName}`
1237
+ : 'No Classic/Pure BLE device found'
1170
1238
  );
1171
1239
  }
1172
- const localPath = spec.slice(0, sep);
1173
- const devicePath = spec.slice(sep + 1);
1174
- if (!devicePath.startsWith('vol')) {
1240
+ if (matches.length > 1) {
1175
1241
  throw new Error(
1176
- `Invalid --resource-bundle device path: "${devicePath}". Expected a vol*:/... path`
1242
+ normalizedName
1243
+ ? `Multiple BLE devices found by name: ${deviceName}`
1244
+ : 'Multiple Classic/Pure BLE devices found; specify --device-name'
1177
1245
  );
1178
1246
  }
1179
- return {
1180
- binary: readBinaryParam(localPath),
1181
- devicePath,
1182
- };
1247
+
1248
+ const [{ connectId, name }] = matches;
1249
+ if (!connectId) throw new Error(`BLE device has no connect ID: ${name}`);
1250
+ return connectId;
1183
1251
  }
1184
1252
 
1185
1253
  function getFirmwareUpdateV4TotalBytes(params: ReturnType<typeof buildFirmwareUpdateV4Params>) {
1186
1254
  return [
1187
- ...(params.resourceBundleFiles?.map(item => item.binary) ?? []),
1188
1255
  params.bootloaderBinary,
1189
1256
  params.applicationP1Binary,
1190
1257
  params.applicationP2Binary,
@@ -1234,6 +1301,30 @@ function formatFirmwareBytes(bytes: number) {
1234
1301
  return `${(bytes / 1024).toFixed(1)} KiB`;
1235
1302
  }
1236
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
+
1237
1328
  function maybePrintFirmwareProgress({
1238
1329
  progressType,
1239
1330
  progress,
@@ -1330,7 +1421,7 @@ function buildFirmwareUpdateV4Metrics({
1330
1421
  };
1331
1422
  }
1332
1423
 
1333
- async function runFirmwareUpdateV4WithRetry({
1424
+ export async function runFirmwareUpdateV4WithRetry({
1334
1425
  sdk,
1335
1426
  globalOpts,
1336
1427
  params,
@@ -1344,152 +1435,147 @@ async function runFirmwareUpdateV4WithRetry({
1344
1435
  const totalBytes = getFirmwareUpdateV4TotalBytes(params);
1345
1436
  const maxAttempts = Math.max((retries ?? 2) + 1, 1);
1346
1437
  let currentSdk = sdk;
1347
- let lastResult: unknown;
1348
1438
  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;
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;
1386
1452
  }
1387
1453
 
1388
- if (messageType !== UI_REQUEST.FIRMWARE_PROGRESS || !payload) return;
1389
- const progress = Number(payload.progress);
1390
- if (!Number.isFinite(progress)) return;
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
+ }
1391
1466
 
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;
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`);
1406
1488
  }
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();
1489
+ return;
1432
1490
  }
1433
1491
 
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
- };
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;
1462
1498
  }
1463
1499
 
1464
- if (isSuccessResult(lastResult)) {
1465
- return lastResult;
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;
1466
1518
  }
1467
1519
 
1468
- if (
1469
- attempt >= maxAttempts ||
1470
- globalOpts.transport !== 'usb' ||
1471
- !isProtocolV2UsbProbeTransientResult(lastResult)
1472
- ) {
1473
- return lastResult;
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
+ }
1474
1533
  }
1534
+ };
1475
1535
 
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);
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();
1485
1545
  }
1486
1546
 
1487
- return lastResult;
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;
1488
1575
  }
1489
1576
 
1490
1577
  function buildFirmwareUpdateV4Params(opts: {
1491
1578
  chunkSize?: string;
1492
- resourceBundle?: string[];
1493
1579
  romloader?: string;
1494
1580
  bootloader?: string;
1495
1581
  applicationP1?: string;
@@ -1499,6 +1585,7 @@ function buildFirmwareUpdateV4Params(opts: {
1499
1585
  se02?: string;
1500
1586
  se03?: string;
1501
1587
  se04?: string;
1588
+ resourceArchive?: string;
1502
1589
  forcedUpdateRes?: boolean;
1503
1590
  }) {
1504
1591
  const params = {
@@ -1506,7 +1593,6 @@ function buildFirmwareUpdateV4Params(opts: {
1506
1593
  connectProtocol: 'V2' as const,
1507
1594
  chunkSize: opts.chunkSize ? safeParseInt(opts.chunkSize, '--chunk-size') : undefined,
1508
1595
  forcedUpdateRes: opts.forcedUpdateRes,
1509
- resourceBundleFiles: opts.resourceBundle?.map(parseResourceBundleParam),
1510
1596
  romloaderBinary: opts.romloader ? readBinaryParam(opts.romloader) : undefined,
1511
1597
  bootloaderBinary: opts.bootloader ? readBinaryParam(opts.bootloader) : undefined,
1512
1598
  applicationP1Binary: opts.applicationP1 ? readBinaryParam(opts.applicationP1) : undefined,
@@ -1516,10 +1602,10 @@ function buildFirmwareUpdateV4Params(opts: {
1516
1602
  se02Binary: opts.se02 ? readBinaryParam(opts.se02) : undefined,
1517
1603
  se03Binary: opts.se03 ? readBinaryParam(opts.se03) : undefined,
1518
1604
  se04Binary: opts.se04 ? readBinaryParam(opts.se04) : undefined,
1605
+ resourceArchiveBinary: opts.resourceArchive ? readBinaryParam(opts.resourceArchive) : undefined,
1519
1606
  };
1520
1607
 
1521
1608
  const hasPayload = [
1522
- params.resourceBundleFiles,
1523
1609
  params.romloaderBinary,
1524
1610
  params.bootloaderBinary,
1525
1611
  params.applicationP1Binary,
@@ -1529,10 +1615,13 @@ function buildFirmwareUpdateV4Params(opts: {
1529
1615
  params.se02Binary,
1530
1616
  params.se03Binary,
1531
1617
  params.se04Binary,
1618
+ params.resourceArchiveBinary,
1532
1619
  ].some(Boolean);
1533
1620
 
1534
1621
  if (!hasPayload) {
1535
- const err = new Error('firmware-update-v4 requires at least one binary path');
1622
+ const err = new Error(
1623
+ 'firmware-update-v4 requires at least one firmware binary or resource archive path'
1624
+ );
1536
1625
  (err as Error & { code?: string }).code = 'MISSING_FIRMWARE_BINARY';
1537
1626
  throw err;
1538
1627
  }