feat(controlled): 实现设备激活与安全认证流程

- 添加API客户端、加密存储和provision/token激活逻辑
- WebSocket改用Bearer令牌认证,移除REGISTER请求
- 设备ID改为服务端下发,支持令牌刷新和强制下线处理
- 新增deviceSecret加密存储和accessToken自动刷新
- 更新设备ID获取方式为出厂SN,添加安全存储依赖
This commit is contained in:
2026-08-01 15:13:12 +08:00
parent 376a2c1217
commit 6eb2c7321a
124 changed files with 10535 additions and 862 deletions

View File

@@ -3,33 +3,30 @@ import { SignalingClient } from '../services/SignalingClient';
import { WebRtcController } from '../services/WebRtcController';
import { VideoRecorder } from '../services/VideoRecorder';
import { loadProto } from '../proto/controlMessage';
import * as api from '../services/ApiClient';
// 远程视频录制器(基于浏览器原生 MediaRecorder
const videoRecorder = new VideoRecorder();
// 与 Android/Flutter 端一致的 ICE 配置(请按需替换为自己的 TURN 凭据)。
export const DEFAULT_ICE_SERVERS = [
// 公共 TURNrelay 兜底UDP + TCP 两种传输TCP 用于 UDP 被防火墙拦截的网络。
// 默认 ICE 兜底(当服务端未返回 TURN 凭证时使用,仅 STUN可能穿透失败)。
export const FALLBACK_ICE_SERVERS = [
{ urls: 'stun:175.178.213.60:3478' },
{ urls: 'turn:175.178.213.60:3478', username: 'fanhuitong', credential: 'Fan19961207..' },
{ urls: 'turn:175.178.213.60:3478?transport=tcp', username: 'fanhuitong', credential: 'Fan19961207..' },
{ urls: 'stun:47.242.112.133:3478' },
{ urls: 'turn:47.242.112.133:3478', username: 'ttstd', credential: 'fanhuitong' },
// 内网 TURN与被控端同局域网时可用
{ urls: 'stun:192.168.5.224:3478' },
{ urls: 'turn:192.168.5.224:3478', username: 'tt', credential: 'fht' },
// { urls: 'turn:192.168.100.224:3478', username: 'tt', credential: 'fht' },
// { urls: 'turn:192.168.100.224:3478?transport=tcp', username: 'tt', credential: 'fht' },
// { urls: 'stun:stun.l.google.com:19302' },
// { urls: 'stun:stun1.l.google.com:19302' },
// { urls: 'stun:stun2.l.google.com:19302' },
];
export const store = reactive({
serverUrl: 'wss://www.ttstd.com/signal',
// 登录态
loggedIn: api.isLoggedIn(),
username: '',
loginError: '',
loginBusy: false,
// 本机设备 ID 由服务端 REGISTER_SUCCESS 下发,无需用户填写。
deviceId: '',
targetDeviceId: '',
iceServers: DEFAULT_ICE_SERVERS,
iceServers: FALLBACK_ICE_SERVERS,
protoReady: false,
signalingConnected: false,
@@ -43,10 +40,8 @@ export const store = reactive({
controlledDevices: [],
stats: null,
remoteStream: null,
// 被控端上报的当前实际采集分辨率(宽/高/帧率),用于与分辨率下拉框保持一致。
currentResolution: null,
// 远程视频录制状态
recording: false,
recordStatus: '',
});
@@ -59,28 +54,51 @@ export async function initProto() {
store.protoReady = true;
}
function parsePayload(payload) {
if (!payload) return {};
if (typeof payload === 'string') {
try { return JSON.parse(payload); } catch { return {}; }
/* ---------------- 登录 / 登出 ---------------- */
export async function login(username, password) {
store.loginBusy = true;
store.loginError = '';
try {
const data = await api.login(username, password);
store.loggedIn = true;
store.username = data.username || username;
return true;
} catch (e) {
store.loginError = e.message || '登录失败';
return false;
} finally {
store.loginBusy = false;
}
return payload;
}
export function connectSignaling() {
export async function register(username, password) {
return api.register({ username, password });
}
export async function logout() {
await api.logout().catch(() => {});
store.loggedIn = false;
store.username = '';
disconnectSignaling();
}
/* ---------------- 信令连接Bearer 由子协议携带) ---------------- */
export async function connectSignaling() {
store.error = '';
const deviceId = store.deviceId.trim();
if (!deviceId) { store.error = '请填写本机设备 ID'; return; }
if (!api.isLoggedIn()) { store.error = '请先登录'; return; }
store.signalingConnecting = true;
signaling = new SignalingClient({
serverUrl: store.serverUrl.trim(),
deviceId,
onConnected: () => {
onConnected: async () => {
store.signalingConnected = true;
store.signalingConnecting = false;
store.statusText = '已连接信令服务器';
refreshDevices();
// 登录态下自动拉取一次 TURN 凭证与绑定设备列表。
await loadTurnCredentials();
await loadBindings();
},
onDisconnected: () => {
store.signalingConnected = false;
@@ -93,20 +111,74 @@ export function connectSignaling() {
store.error = '信令错误: ' + e;
},
onMessage: handleSignalMessage,
// 4001尝试刷新令牌后重连。
onTokenExpired: async () => {
try {
await api.refresh();
reconnectSignaling();
} catch {
await forceLogout('令牌已失效,请重新登录');
}
},
// 4003强制下线。
onForceLogout: async (reason) => {
await forceLogout(reason || '账号已在其他位置登录');
},
});
signaling.connect();
}
// 刷新令牌后重连(保留已登录态)。
function reconnectSignaling() {
store.statusText = '令牌已刷新,正在重连...';
if (signaling) { signaling.disconnect(); signaling = null; }
connectSignaling();
}
async function forceLogout(reason) {
store.error = reason;
await api.logout().catch(() => {});
store.loggedIn = false;
store.username = '';
disconnectSignaling();
}
// 拉取 TURN 短期凭证(服务端开启时覆盖默认 ICE
export async function loadTurnCredentials() {
const data = await api.fetchTurnCredentials();
if (data && Array.isArray(data.iceServers) && data.iceServers.length) {
store.iceServers = data.iceServers;
} else {
store.iceServers = FALLBACK_ICE_SERVERS;
}
return store.iceServers;
}
// 拉取本机可连接的被控端(仅已绑定设备)。
export async function loadBindings() {
try {
const data = await api.listBindings();
const list = (data && data.bindings) || [];
// 列表元素可能为 {deviceUid, alias, online} 或纯字符串。
store.controlledDevices = list.map((b) =>
typeof b === 'string' ? b : (b.deviceUid || b.deviceId || '')
).filter(Boolean);
} catch {
store.controlledDevices = [];
}
}
function handleSignalMessage(msg) {
switch ((msg.type || '').toUpperCase()) {
case 'REGISTER_SUCCESS':
store.registered = true;
store.deviceId = msg.fromDeviceId || store.deviceId;
store.statusText = '注册成功 (CONTROLLER)';
break;
case 'DEVICE_LIST': {
// 兜底:若服务端仍推送 DEVICE_LIST兼容老逻辑
const list = msg.controlled || [];
store.controlledDevices = Array.isArray(list) ? list : [];
if (!store.controlledDevices.includes(store.targetDeviceId)) store.targetDeviceId = '';
break;
}
case 'ANSWER': {
@@ -135,7 +207,7 @@ function handleSignalMessage(msg) {
}
export function refreshDevices() {
signaling && signaling.requestDeviceList();
loadBindings();
}
export async function connectToDevice(targetId, authType = null, authValue = null) {
@@ -145,12 +217,10 @@ export async function connectToDevice(targetId, authType = null, authValue = nul
store.targetDeviceId = targetId;
store.error = '';
store.statusText = '正在发起连接...';
// 清空上一次连接的录制提示。
store.recording = false;
store.recordStatus = '';
if (webrtc) {
// 切换目标设备会结束当前控制,若正在录制则先自动保存。
if (videoRecorder.recording) await stopRecording();
await webrtc.close();
webrtc = null;
@@ -170,13 +240,10 @@ export async function connectToDevice(targetId, authType = null, authValue = nul
onStream: (stream) => { store.remoteStream = markRaw(stream); },
onStats: (stats) => { store.stats = stats; },
onError: (msg) => { if (msg) store.error = msg; },
// 被控端上报当前实际采集分辨率,同步到 store 供 UI 展示/匹配预设。
onResolutionReport: (res) => { store.currentResolution = res; },
});
await webrtc.createOffer(authType, authValue);
} catch (e) {
// 捕获 createOffer 阶段的所有异常,避免未处理的 Promise 拒绝导致浏览器中断脚本执行、
// 间接影响 WebSocket 信令连接的存活。
console.error('[connectToDevice] 创建 Offer 失败:', e);
store.error = '创建连接失败: ' + (e?.message || e);
store.statusText = '连接失败';
@@ -185,7 +252,6 @@ export async function connectToDevice(targetId, authType = null, authValue = nul
}
export async function disconnectDevice() {
// 结束控制时若正在录制,先停止并自动保存(触发浏览器下载),再关闭连接。
if (videoRecorder.recording) {
await stopRecording();
}
@@ -195,19 +261,15 @@ export async function disconnectDevice() {
store.remoteStream = null;
store.stats = null;
store.currentResolution = null;
// 注意:保留 recordStatus 的"已保存:文件名"提示,便于用户确认自动保存结果;
// 仅复位 recording 标志,下次连接开始时再清空提示。
store.recording = false;
store.statusText = store.signalingConnected ? '已断开设备连接' : '未连接';
}
// 切换远程视频录制:开始 / 停止。
export function toggleRecording() {
if (store.recording) return stopRecording();
return startRecording();
}
// 开始录制当前远端视频流。
export function startRecording() {
if (store.recording) return;
if (!store.remoteStream) {
@@ -230,12 +292,10 @@ export function startRecording() {
}
}
// 停止录制并触发浏览器下载。
export async function stopRecording() {
const result = await videoRecorder.stop();
store.recording = false;
if (result) {
// 通过 Blob URL 触发浏览器下载(纯前端唯一"保存"途径)。
const a = document.createElement('a');
a.href = result.url;
a.download = result.filename;
@@ -243,7 +303,6 @@ export async function stopRecording() {
a.click();
document.body.removeChild(a);
store.recordStatus = '已保存:' + result.filename;
// 下载完成后延迟释放 ObjectURL避免下载被浏览器中断。
setTimeout(() => URL.revokeObjectURL(result.url), 30000);
} else {
store.recordStatus = '录制已停止';
@@ -266,5 +325,12 @@ export function sendSwipe(x1, y1, x2, y2, duration) { return webrtc && webrtc.se
export function sendLongPress(x, y) { return webrtc && webrtc.sendLongPress(x, y); }
export function sendMotionEvent(action, x, y) { return webrtc && webrtc.sendMotionEvent(action, x, y); }
export function sendKey(keyCode) { return webrtc && webrtc.sendKey(keyCode); }
// 请求被控端切换屏幕采集分辨率width<=0 表示原生分辨率height<=0 时按 width 长边依宽高比缩放)
export function sendResolutionChange(width, height, fps) { return webrtc && webrtc.sendResolutionChange(width, height, fps); }
function parsePayload(payload) {
if (!payload) return {};
if (typeof payload === 'string') {
try { return JSON.parse(payload); } catch { return {}; }
}
return payload;
}