- 添加API客户端、加密存储和provision/token激活逻辑 - WebSocket改用Bearer令牌认证,移除REGISTER请求 - 设备ID改为服务端下发,支持令牌刷新和强制下线处理 - 新增deviceSecret加密存储和accessToken自动刷新 - 更新设备ID获取方式为出厂SN,添加安全存储依赖
337 lines
10 KiB
JavaScript
337 lines
10 KiB
JavaScript
import { reactive, markRaw } from 'vue';
|
||
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();
|
||
|
||
// 默认 ICE 兜底(当服务端未返回 TURN 凭证时使用,仅 STUN,可能穿透失败)。
|
||
export const FALLBACK_ICE_SERVERS = [
|
||
{ urls: 'stun:175.178.213.60:3478' },
|
||
{ urls: 'stun:47.242.112.133:3478' },
|
||
];
|
||
|
||
export const store = reactive({
|
||
serverUrl: 'wss://www.ttstd.com/signal',
|
||
|
||
// 登录态
|
||
loggedIn: api.isLoggedIn(),
|
||
username: '',
|
||
loginError: '',
|
||
loginBusy: false,
|
||
|
||
// 本机设备 ID 由服务端 REGISTER_SUCCESS 下发,无需用户填写。
|
||
deviceId: '',
|
||
targetDeviceId: '',
|
||
iceServers: FALLBACK_ICE_SERVERS,
|
||
|
||
protoReady: false,
|
||
signalingConnected: false,
|
||
signalingConnecting: false,
|
||
registered: false,
|
||
rtcConnected: false,
|
||
dataChannelOpen: false,
|
||
statusText: '未连接',
|
||
error: '',
|
||
|
||
controlledDevices: [],
|
||
stats: null,
|
||
remoteStream: null,
|
||
currentResolution: null,
|
||
|
||
recording: false,
|
||
recordStatus: '',
|
||
});
|
||
|
||
let signaling = null;
|
||
let webrtc = null;
|
||
|
||
export async function initProto() {
|
||
await loadProto();
|
||
store.protoReady = true;
|
||
}
|
||
|
||
/* ---------------- 登录 / 登出 ---------------- */
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
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 = '';
|
||
if (!api.isLoggedIn()) { store.error = '请先登录'; return; }
|
||
|
||
store.signalingConnecting = true;
|
||
signaling = new SignalingClient({
|
||
serverUrl: store.serverUrl.trim(),
|
||
onConnected: async () => {
|
||
store.signalingConnected = true;
|
||
store.signalingConnecting = false;
|
||
store.statusText = '已连接信令服务器';
|
||
// 登录态下自动拉取一次 TURN 凭证与绑定设备列表。
|
||
await loadTurnCredentials();
|
||
await loadBindings();
|
||
},
|
||
onDisconnected: () => {
|
||
store.signalingConnected = false;
|
||
store.signalingConnecting = false;
|
||
store.registered = false;
|
||
store.statusText = '已断开信令连接';
|
||
},
|
||
onError: (e) => {
|
||
store.signalingConnecting = false;
|
||
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 : [];
|
||
break;
|
||
}
|
||
case 'ANSWER': {
|
||
store.statusText = '被控端已接受,正在建立连接...';
|
||
if (webrtc) {
|
||
webrtc.handleAnswer(parsePayload(msg.payload).sdp).catch((e) => {
|
||
store.error = '设置远端描述失败: ' + (e?.message || e);
|
||
});
|
||
}
|
||
break;
|
||
}
|
||
case 'ICE_CANDIDATE':
|
||
webrtc && webrtc.handleIceCandidate(parsePayload(msg.payload));
|
||
break;
|
||
case 'TARGET_OFFLINE':
|
||
store.error = msg.payload || '目标被控端不在线,请确认设备已开启并连接服务器';
|
||
store.statusText = '连接失败';
|
||
break;
|
||
case 'CONNECTION_REJECTED':
|
||
case 'REQUEST_ERROR':
|
||
case 'REQUEST_TIMEOUT':
|
||
store.error = msg.payload || '连接请求失败';
|
||
store.statusText = '连接被拒绝';
|
||
break;
|
||
}
|
||
}
|
||
|
||
export function refreshDevices() {
|
||
loadBindings();
|
||
}
|
||
|
||
export async function connectToDevice(targetId, authType = null, authValue = null) {
|
||
if (!signaling || !store.signalingConnected) { store.error = '请先连接信令服务器'; return; }
|
||
if (!targetId) { store.error = '请选择要控制的被控端设备'; return; }
|
||
|
||
store.targetDeviceId = targetId;
|
||
store.error = '';
|
||
store.statusText = '正在发起连接...';
|
||
store.recording = false;
|
||
store.recordStatus = '';
|
||
|
||
if (webrtc) {
|
||
if (videoRecorder.recording) await stopRecording();
|
||
await webrtc.close();
|
||
webrtc = null;
|
||
}
|
||
|
||
try {
|
||
webrtc = new WebRtcController({
|
||
iceServers: store.iceServers,
|
||
deviceId: store.deviceId,
|
||
targetDeviceId: targetId,
|
||
signaling,
|
||
onIceState: (s) => {
|
||
if (s === 'connected') { store.rtcConnected = true; store.statusText = '已连接,可远程控制'; }
|
||
else if (s === 'disconnected' || s === 'failed') { store.rtcConnected = false; store.statusText = '连接已断开'; }
|
||
},
|
||
onDataChannelState: (open) => { store.dataChannelOpen = open; },
|
||
onStream: (stream) => { store.remoteStream = markRaw(stream); },
|
||
onStats: (stats) => { store.stats = stats; },
|
||
onError: (msg) => { if (msg) store.error = msg; },
|
||
onResolutionReport: (res) => { store.currentResolution = res; },
|
||
});
|
||
await webrtc.createOffer(authType, authValue);
|
||
} catch (e) {
|
||
console.error('[connectToDevice] 创建 Offer 失败:', e);
|
||
store.error = '创建连接失败: ' + (e?.message || e);
|
||
store.statusText = '连接失败';
|
||
if (webrtc) { await webrtc.close(); webrtc = null; }
|
||
}
|
||
}
|
||
|
||
export async function disconnectDevice() {
|
||
if (videoRecorder.recording) {
|
||
await stopRecording();
|
||
}
|
||
if (webrtc) { await webrtc.close(); webrtc = null; }
|
||
store.rtcConnected = false;
|
||
store.dataChannelOpen = false;
|
||
store.remoteStream = null;
|
||
store.stats = null;
|
||
store.currentResolution = null;
|
||
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) {
|
||
store.error = '尚未接收到视频画面,无法录制';
|
||
return;
|
||
}
|
||
if (!videoRecorder.isSupported) {
|
||
store.error = '当前浏览器不支持 MediaRecorder 录制';
|
||
return;
|
||
}
|
||
try {
|
||
const ok = videoRecorder.start(store.remoteStream);
|
||
if (ok) {
|
||
store.recording = true;
|
||
store.recordStatus = '录制中…';
|
||
}
|
||
} catch (e) {
|
||
store.recordStatus = '';
|
||
store.error = '开始录制失败: ' + (e?.message || e);
|
||
}
|
||
}
|
||
|
||
export async function stopRecording() {
|
||
const result = await videoRecorder.stop();
|
||
store.recording = false;
|
||
if (result) {
|
||
const a = document.createElement('a');
|
||
a.href = result.url;
|
||
a.download = result.filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
store.recordStatus = '已保存:' + result.filename;
|
||
setTimeout(() => URL.revokeObjectURL(result.url), 30000);
|
||
} else {
|
||
store.recordStatus = '录制已停止';
|
||
}
|
||
return result;
|
||
}
|
||
|
||
export function disconnectSignaling() {
|
||
disconnectDevice();
|
||
if (signaling) { signaling.disconnect(); signaling = null; }
|
||
store.signalingConnected = false;
|
||
store.signalingConnecting = false;
|
||
store.registered = false;
|
||
store.controlledDevices = [];
|
||
}
|
||
|
||
// 控制指令转发(供 UI 组件调用)
|
||
export function sendTouch(x, y) { return webrtc && webrtc.sendTouch(x, y); }
|
||
export function sendSwipe(x1, y1, x2, y2, duration) { return webrtc && webrtc.sendSwipe(x1, y1, x2, y2, duration); }
|
||
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); }
|
||
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;
|
||
}
|