WebUSB说明
这篇文档说明浏览器侧如何借助 @ukeyfe/hardware-common-connect-sdk 通过 WebUSB 直连 UKey Wallet。完成授权和连接后,BTC、EVM、Solana 等链相关接口都可以继续从同一个 SDK 入口调用。
这里聚焦 WebUSB;如果你要做 React Native BLE 或 Android/iOS 原生桥接,请跳到对应的传输章节。
第1步:前提
- 页面必须处在 HTTPS 或本地开发安全上下文里,普通 HTTP 无法调用 WebUSB。
- 浏览器要具备 WebUSB 能力,桌面版 Chrome 或 Edge 是更稳妥的选择。
- 电脑需要连接一台 UKey Wallet,并按流程提示完成解锁或确认。
第2步:部署
npm i @ukeyfe/hardware-common-connect-sdk @ukeyfe/hardware-shared @ukeyfe/hardware-core
第3步:启动事件
SDK 初始化完成后就尽快挂上事件监听。PIN、Passphrase、按钮确认、设备插拔都会走事件通道;监听太晚时,已经发出的请求可能会一直等不到用户响应。
import ukeySdk from '@ukeyfe/hardware-common-connect-sdk';
import { UI_EVENT, UI_REQUEST, UI_RESPONSE, DEVICE } from '@ukeyfe/hardware-core';
export async function initializeWebUsbSession() {
await ukeySdk.init({
env: 'webusb',
debug: process.env.NODE_ENV !== 'production',
fetchConfig: true,
});
bindDeviceEventHandlers();
}
function bindDeviceEventHandlers() {
ukeySdk.on(UI_EVENT, async (uiPacket) => {
switch (uiPacket.type) {
case UI_REQUEST.REQUEST_PIN: {
// 拉起 PIN 交互界面,然后调用 uiResponse 回传结果
const pinPromptState = await collectPinInput();
if (pinPromptState.mode === 'device') {
await ukeySdk.uiResponse({
type: UI_RESPONSE.RECEIVE_PIN,
payload: '@@UKEY_INPUT_PIN_IN_DEVICE',
});
} else {
await ukeySdk.uiResponse({
type: UI_RESPONSE.RECEIVE_PIN,
payload: pinPromptState.value,
});
}
break;
}
case UI_REQUEST.REQUEST_PASSPHRASE: {
const passphrasePromptState = await collectPassphraseInput();
if (passphrasePromptState.mode === 'device') {
await ukeySdk.uiResponse({
type: UI_RESPONSE.RECEIVE_PASSPHRASE,
payload: { passphraseOnDevice: true, value: '' },
});
} else {
await ukeySdk.uiResponse({
type: UI_RESPONSE.RECEIVE_PASSPHRASE,
payload: {
value: passphrasePromptState.value,
passphraseOnDevice: false,
save: passphrasePromptState.save,
},
});
}
break;
}
default:
break;
}
});
ukeySdk.on(DEVICE.CONNECT, (payload) => {
console.log('检测到设备已连接:', payload);
});
ukeySdk.on(DEVICE.DISCONNECT, (payload) => {
console.log('检测到设备已断开:', payload);
});
}
关键说明
- 第一时间订阅
UI_EVENT,不要让 PIN、Passphrase 或确认弹窗处于无人处理状态。参见 事件说明。 - PIN 建议直接在设备上完成(
@@UKEY_INPUT_PIN_IN_DEVICE);如果产品支持软件盲输,请按固定位置映射(7,8,9,4,5,6,1,2,3)转换。 - Passphrase 可以交给设备处理,也可以由软件界面收集;
save只适合当前会话缓存。 - 设备连接和断开事件建议同步到界面,用户拔线或重新连接时状态会更清晰。
第4步:授权选择器
WebUSB 权限需要从明确的用户操作开始,例如点击“连接”按钮;页面主动弹出 USB 选择器时,浏览器通常会直接拦截。
通过 UKEY_WEBUSB_FILTER 可以让选择器聚焦在 UKey Wallet 兼容设备上,减少误选其他 USB 硬件的机会。
import { UKEY_WEBUSB_FILTER } from '@ukeyfe/hardware-shared';
export function WebUsbPermissionButton() {
return (
<button
onClick={async () => {
await navigator.usb.requestDevice({ filters: UKEY_WEBUSB_FILTER });
// 浏览器放行设备权限后,再进入 SDK 的设备发现流程
}}
>
连接 UKey Wallet 设备
</button>
);
}
第5步:选设备
const deviceScanResponse = await ukeySdk.searchDevices();
if (!deviceScanResponse.success) throw new Error(deviceScanResponse.payload.error);
// 示例中先使用发现列表里的第一台设备
const { connectId, deviceId } = deviceScanResponse.payload[0] ?? {};
- WebUSB 枚举结果通常会直接包含
deviceId。 - 后续链 API 一般需要同时传入
connectId和deviceId,建议在当前会话中保存。
第6步:读特征
const featureSnapshot = await ukeySdk.getFeatures(connectId);
if (!featureSnapshot.success) throw new Error(featureSnapshot.payload.error);
const cachedDeviceId = featureSnapshot.payload.device_id;
第7步:首次调用
const btcAddressReply = await ukeySdk.btcGetAddress(connectId, cachedDeviceId, {
path: "m/84'/0'/3'/0/1",
coin: 'btc',
showOnUKey: false,
});
if (btcAddressReply.success) {
console.log('派生出的 BTC 地址:', btcAddressReply.payload.address);
} else {
console.error('执行报错:', btcAddressReply.payload.error, btcAddressReply.payload.code);
}
SDK方法表
连接成功后,可以从这些常用方法开始验证流程。
| 方法 | 说明 | 说明 |
|---|---|---|
evmGetAddress | 获取 EVM 地址 | path: "m/44'/60'/6'/0/2" |
evmSignMessage | 签名 EVM 消息 | messageHex: '...' |
evmSignTransaction | 签名 EVM 交易 | transaction: { ... } |
btcGetAddress | 获取 BTC 地址 | coin: 'btc', path: "m/84'/0'/2'/0/0" |
solanaGetAddress | 获取 Solana 地址 | path: "m/44'/501'/3'/0'" |
getFeatures | 获取设备详细特征 | connectId |
deviceSettings | 修改设备设置(如名称) | label: 'My UKey Wallet' |
附录:UI 助手
下面是极简版 collectPinInput 和 collectPassphraseInput,用于说明事件如何转成用户界面。生产环境请根据你的产品设计补充错误处理、取消流程和无障碍支持。
// 盲输键位表:按设备上的按键布局做位置对应
const PIN_SLOT_DIGITS = ['7', '8', '9', '4', '5', '6', '1', '2', '3'];
function collectPinInput() {
return new Promise(resolve => {
const backdrop = document.createElement('div');
backdrop.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999;';
const panel = document.createElement('div');
panel.style.cssText = 'background:white;padding:20px;border-radius:8px;max-width:400px;width:100%;';
panel.innerHTML = `
<h3 style="margin-top:0">填写 PIN</h3>
<p>更建议直接在设备上输入,也可以改用盲键盘录入。</p>
<div id="pin-display" style="font-size: 24px; margin: 10px 0; min-height: 1.2em; border-bottom: 1px solid #ccc;"></div>
<div id="keypad" style="display:grid; grid-template-columns: repeat(3, 1fr); gap: 10px;"></div>
<div style="margin-top: 20px; display:flex; gap:10px;">
<button id="use-device" style="flex:1">改在设备上输入</button>
<button id="clear-pin">清空输入</button>
<button id="confirm-pin" style="background:#007aff;color:white;border:none;padding:5px 15px;border-radius:4px;">确认</button>
</div>
`;
const enteredDigits = [];
const keypad = panel.querySelector('#keypad');
PIN_SLOT_DIGITS.forEach((mappedDigit, slotIndex) => {
const btn = document.createElement('button');
btn.textContent = slotIndex + 1;
btn.style.padding = '10px';
btn.onclick = () => {
enteredDigits.push(mappedDigit);
panel.querySelector('#pin-display').textContent = '•'.repeat(enteredDigits.length);
};
keypad.appendChild(btn);
});
panel.querySelector('#use-device').onclick = () => {
document.body.removeChild(backdrop);
resolve({ mode: 'device' });
};
panel.querySelector('#clear-pin').onclick = () => {
enteredDigits.length = 0;
panel.querySelector('#pin-display').textContent = '';
};
panel.querySelector('#confirm-pin').onclick = () => {
document.body.removeChild(backdrop);
resolve({ mode: 'software', value: enteredDigits.join('') });
};
backdrop.appendChild(panel);
document.body.appendChild(backdrop);
});
}
function collectPassphraseInput() {
return new Promise(resolve => {
const backdrop = document.createElement('div');
backdrop.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999;';
const panel = document.createElement('div');
panel.style.cssText = 'background:white;padding:20px;border-radius:8px;max-width:400px;width:100%;';
panel.innerHTML = `
<h3 style="margin-top:0">填写密码短语</h3>
<input type="password" id="passphrase-input" placeholder="留空则使用默认钱包" style="width:100%;padding:8px;margin-bottom:10px;box-sizing:border-box;" />
<slot style="display:block;margin-bottom:15px;"><input type="checkbox" id="save-passphrase" /> 仅在当前会话里保存</slot>
<div style="display:flex; gap:10px;">
<button id="use-device-pass" style="flex:1">改在设备上输入</button>
<button id="submit-pass" style="background:#007aff;color:white;border:none;padding:5px 15px;border-radius:4px;">确认提交</button>
</div>
`;
panel.querySelector('#use-device-pass').onclick = () => {
document.body.removeChild(backdrop);
resolve({ mode: 'device', value: '', save: false });
};
panel.querySelector('#submit-pass').onclick = () => {
const passphraseValue = panel.querySelector('#passphrase-input').value;
const rememberSession = panel.querySelector('#save-passphrase').checked;
document.body.removeChild(backdrop);
resolve({ mode: 'software', value: passphraseValue, save: rememberSession });
};
backdrop.appendChild(panel);
document.body.appendChild(backdrop);
});
}