feat(controlled): 实现设备激活与安全认证流程
- 添加API客户端、加密存储和provision/token激活逻辑 - WebSocket改用Bearer令牌认证,移除REGISTER请求 - 设备ID改为服务端下发,支持令牌刷新和强制下线处理 - 新增deviceSecret加密存储和accessToken自动刷新 - 更新设备ID获取方式为出厂SN,添加安全存储依赖
This commit is contained in:
@@ -1,42 +1,54 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { store, initProto } from './store/controllerStore';
|
||||
import { store, initProto, logout } from './store/controllerStore';
|
||||
import LoginPanel from './components/LoginPanel.vue';
|
||||
import ConnectionPanel from './components/ConnectionPanel.vue';
|
||||
import RemoteScreen from './components/RemoteScreen.vue';
|
||||
import ControlBar from './components/ControlBar.vue';
|
||||
import StatsBar from './components/StatsBar.vue';
|
||||
|
||||
onMounted(async () => {
|
||||
if (!store.deviceId) store.deviceId = 'web-' + Math.random().toString(36).slice(2, 8);
|
||||
await initProto();
|
||||
});
|
||||
|
||||
async function onLogout() {
|
||||
await logout();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<div class="brand">WebRTC 网页远程控制</div>
|
||||
<div class="status" :class="{ ok: store.rtcConnected }">{{ store.statusText }}</div>
|
||||
<div class="badges">
|
||||
<span class="badge" :class="{ on: store.signalingConnected }">信令</span>
|
||||
<span class="badge" :class="{ on: store.registered }">注册</span>
|
||||
<span class="badge" :class="{ on: store.rtcConnected }">P2P</span>
|
||||
<span class="badge" :class="{ on: store.dataChannelOpen }">通道</span>
|
||||
</div>
|
||||
</header>
|
||||
<LoginPanel v-if="!store.loggedIn" />
|
||||
|
||||
<main class="layout">
|
||||
<aside class="sidebar">
|
||||
<ConnectionPanel />
|
||||
</aside>
|
||||
<section class="stage">
|
||||
<RemoteScreen />
|
||||
<ControlBar />
|
||||
</section>
|
||||
</main>
|
||||
<template v-else>
|
||||
<header class="topbar">
|
||||
<div class="brand">WebRTC 网页远程控制</div>
|
||||
<div class="user">
|
||||
<span class="user-name">{{ store.username || '已登录' }}</span>
|
||||
<button class="btn ghost" @click="onLogout">退出</button>
|
||||
</div>
|
||||
<div class="status" :class="{ ok: store.rtcConnected }">{{ store.statusText }}</div>
|
||||
<div class="badges">
|
||||
<span class="badge" :class="{ on: store.signalingConnected }">信令</span>
|
||||
<span class="badge" :class="{ on: store.registered }">注册</span>
|
||||
<span class="badge" :class="{ on: store.rtcConnected }">P2P</span>
|
||||
<span class="badge" :class="{ on: store.dataChannelOpen }">通道</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<footer class="footer">
|
||||
<StatsBar />
|
||||
</footer>
|
||||
<main class="layout">
|
||||
<aside class="sidebar">
|
||||
<ConnectionPanel />
|
||||
</aside>
|
||||
<section class="stage">
|
||||
<RemoteScreen />
|
||||
<ControlBar />
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<StatsBar />
|
||||
</footer>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -27,8 +27,8 @@ function onConnectDevice() {
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>本机设备 ID (CONTROLLER)</label>
|
||||
<input class="input" v-model="store.deviceId" placeholder="例如 web-controller-001" />
|
||||
<label>本机设备 ID(服务端下发)</label>
|
||||
<input class="input" :value="store.deviceId || '—'" disabled />
|
||||
</div>
|
||||
|
||||
<button class="btn danger" v-if="store.signalingConnected" @click="disconnectSignaling">断开信令服务器</button>
|
||||
@@ -42,7 +42,7 @@ function onConnectDevice() {
|
||||
</button>
|
||||
|
||||
<div v-if="store.controlledDevices.length === 0" class="hint">
|
||||
暂无在线被控端。请确认 Android 被控端 (WebRTCControlled) 已启动并连接到同一信令服务器。
|
||||
尚无已绑定设备。请在 Android 被控端内通过配对码完成绑定,绑定列表由服务端下发(仅显示你已绑定的设备)。
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
103
WebRTCControllerWeb/src/components/LoginPanel.vue
Normal file
103
WebRTCControllerWeb/src/components/LoginPanel.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { store, login, register, connectSignaling } from '../store/controllerStore';
|
||||
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
const showRegister = ref(false);
|
||||
const regUsername = ref('');
|
||||
const regPassword = ref('');
|
||||
const regBusy = ref(false);
|
||||
const regError = ref('');
|
||||
|
||||
async function onLogin() {
|
||||
const ok = await login(username.value.trim(), password.value);
|
||||
if (ok) {
|
||||
await connectSignaling();
|
||||
}
|
||||
}
|
||||
|
||||
async function onRegister() {
|
||||
regError.value = '';
|
||||
regBusy.value = true;
|
||||
try {
|
||||
await register({ username: regUsername.value.trim(), password: regPassword.value });
|
||||
// 注册成功后直接登录并连接。
|
||||
username.value = regUsername.value.trim();
|
||||
password.value = regPassword.value;
|
||||
showRegister.value = false;
|
||||
await onLogin();
|
||||
} catch (e) {
|
||||
regError.value = e.message || '注册失败';
|
||||
} finally {
|
||||
regBusy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-mask">
|
||||
<div class="login-card">
|
||||
<div class="login-brand">WebRTC 网页远程控制</div>
|
||||
<div class="login-sub">登录后才能连接信令服务器并发起远程控制</div>
|
||||
|
||||
<form v-if="!showRegister" class="login-form" @submit.prevent="onLogin">
|
||||
<label>用户名
|
||||
<input v-model="username" type="text" autocomplete="username" placeholder="请输入用户名" />
|
||||
</label>
|
||||
<label>密码
|
||||
<input v-model="password" type="password" autocomplete="current-password" placeholder="请输入密码" />
|
||||
</label>
|
||||
<p v-if="store.loginError" class="login-err">{{ store.loginError }}</p>
|
||||
<button class="btn primary" type="submit" :disabled="store.loginBusy">
|
||||
{{ store.loginBusy ? '登录中…' : '登录' }}
|
||||
</button>
|
||||
<button class="btn link" type="button" @click="showRegister = true">没有账号?注册</button>
|
||||
</form>
|
||||
|
||||
<form v-else class="login-form" @submit.prevent="onRegister">
|
||||
<label>用户名
|
||||
<input v-model="regUsername" type="text" autocomplete="username" placeholder="设置用户名" />
|
||||
</label>
|
||||
<label>密码
|
||||
<input v-model="regPassword" type="password" autocomplete="new-password" placeholder="设置密码(至少12位,含大小写/数字/特殊字符)" />
|
||||
</label>
|
||||
<p v-if="regError" class="login-err">{{ regError }}</p>
|
||||
<button class="btn primary" type="submit" :disabled="regBusy">
|
||||
{{ regBusy ? '注册中…' : '注册并登录' }}
|
||||
</button>
|
||||
<button class="btn link" type="button" @click="showRegister = false">返回登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-mask {
|
||||
position: fixed; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: radial-gradient(circle at 30% 20%, #1e293b, #0f172a 70%);
|
||||
}
|
||||
.login-card {
|
||||
width: 360px; padding: 32px;
|
||||
background: #1e293b; border: 1px solid #334155; border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.45); color: #e2e8f0;
|
||||
}
|
||||
.login-brand { font-size: 20px; font-weight: 700; color: #38bdf8; }
|
||||
.login-sub { margin: 8px 0 20px; font-size: 13px; color: #94a3b8; }
|
||||
.login-form { display: flex; flex-direction: column; gap: 14px; }
|
||||
.login-form label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: #cbd5e1; }
|
||||
.login-form input {
|
||||
padding: 10px 12px; border-radius: 8px; border: 1px solid #334155;
|
||||
background: #0f172a; color: #e2e8f0; font-size: 14px;
|
||||
}
|
||||
.login-form input:focus { outline: none; border-color: #38bdf8; }
|
||||
.login-err { color: #f87171; font-size: 13px; margin: 0; }
|
||||
.btn.primary {
|
||||
padding: 11px; border: none; border-radius: 8px; cursor: pointer;
|
||||
background: #38bdf8; color: #0f172a; font-weight: 600; font-size: 14px;
|
||||
}
|
||||
.btn.primary:disabled { opacity: .6; cursor: default; }
|
||||
.btn.link { background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 13px; }
|
||||
.btn.link:hover { color: #38bdf8; }
|
||||
</style>
|
||||
119
WebRTCControllerWeb/src/services/ApiClient.js
Normal file
119
WebRTCControllerWeb/src/services/ApiClient.js
Normal file
@@ -0,0 +1,119 @@
|
||||
// HTTP API 客户端:对接安全信令服务器的账号体系与自助接口。
|
||||
// 令牌存储策略(参考服务端实现约束):
|
||||
// - accessToken:内存中保存(掉线即失,需重新登录);
|
||||
// - refreshToken:sessionStorage(一次性、ses_ 前缀,页面关闭即清除,降低泄露面)。
|
||||
const API_BASE = (import.meta.env.VITE_API_BASE || 'https://www.ttstd.com').replace(/\/$/, '');
|
||||
|
||||
const ACCESS_KEY = 'ttstd.accessToken';
|
||||
const REFRESH_KEY = 'ttstd.refreshToken';
|
||||
|
||||
let accessToken = sessionStorage.getItem(ACCESS_KEY) || '';
|
||||
let refreshToken = sessionStorage.getItem(REFRESH_KEY) || '';
|
||||
// 刷新单飞:避免并发请求同时触发多次刷新。
|
||||
let refreshing = null;
|
||||
|
||||
export function getAccessToken() {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
export function isLoggedIn() {
|
||||
return !!accessToken;
|
||||
}
|
||||
|
||||
export function setTokens({ accessToken: at, refreshToken: rt }) {
|
||||
accessToken = at || '';
|
||||
if (rt) refreshToken = rt;
|
||||
if (accessToken) sessionStorage.setItem(ACCESS_KEY, accessToken);
|
||||
else sessionStorage.removeItem(ACCESS_KEY);
|
||||
if (refreshToken) sessionStorage.setItem(REFRESH_KEY, refreshToken);
|
||||
else sessionStorage.removeItem(REFRESH_KEY);
|
||||
}
|
||||
|
||||
export function clearTokens() {
|
||||
accessToken = '';
|
||||
refreshToken = '';
|
||||
sessionStorage.removeItem(ACCESS_KEY);
|
||||
sessionStorage.removeItem(REFRESH_KEY);
|
||||
}
|
||||
|
||||
async function request(path, { method = 'POST', body, auth = false } = {}) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (auth && accessToken) headers['Authorization'] = 'Bearer ' + accessToken;
|
||||
const res = await fetch(API_BASE + path, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
// 可能是 accessToken 失效,由调用方决定是否刷新。
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const err = new Error(data.error || 'UNAUTHORIZED');
|
||||
err.code = data.code || 'UNAUTHORIZED';
|
||||
err.status = 401;
|
||||
throw err;
|
||||
}
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const err = new Error(data.error || ('HTTP ' + res.status));
|
||||
err.code = data.code;
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function login(username, password) {
|
||||
const data = await request('/api/auth/login', { body: { username, password } });
|
||||
setTokens({ accessToken: data.accessToken, refreshToken: data.refreshToken });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function register(body) {
|
||||
return request('/api/auth/register', { body });
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
if (refreshToken) {
|
||||
try { await request('/api/auth/logout', { method: 'POST', body: { refreshToken } }); } catch { /* ignore */ }
|
||||
}
|
||||
clearTokens();
|
||||
}
|
||||
|
||||
// 刷新令牌:带单飞锁,并发调用共享同一次刷新结果。
|
||||
export async function refresh() {
|
||||
if (!refreshToken) {
|
||||
clearTokens();
|
||||
throw new Error('NO_REFRESH_TOKEN');
|
||||
}
|
||||
if (refreshing) return refreshing;
|
||||
refreshing = (async () => {
|
||||
try {
|
||||
const data = await request('/api/auth/refresh', { body: { refreshToken } });
|
||||
// 服务端可能轮换 refreshToken(一次性),若返回新的则覆盖。
|
||||
setTokens({ accessToken: data.accessToken, refreshToken: data.refreshToken });
|
||||
return data;
|
||||
} finally {
|
||||
refreshing = null;
|
||||
}
|
||||
})();
|
||||
return refreshing;
|
||||
}
|
||||
|
||||
// 令牌校验(可选,用于启动恢复时确认 accessToken 是否有效)。
|
||||
export async function verify() {
|
||||
return request('/api/client/verify', { method: 'GET', auth: true });
|
||||
}
|
||||
|
||||
// 取本机可连接的被控端列表(仅返回已绑定的设备)。
|
||||
export async function listBindings() {
|
||||
return request('/api/client/bindings', { method: 'GET', auth: true });
|
||||
}
|
||||
|
||||
// 拉取 TURN 短期凭证(服务端开启时返回 iceServers)。
|
||||
export async function fetchTurnCredentials() {
|
||||
try {
|
||||
return await request('/api/client/turn-credentials', { method: 'GET', auth: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,38 @@
|
||||
// 信令客户端:对应 Android 端 WebSocketClient / Flutter signaling_client.dart。
|
||||
// 连接成功后自动发送 REGISTER(deviceType=CONTROLLER),
|
||||
// 负责 OFFER / ICE_CANDIDATE 的发送,以及 ANSWER / ICE_CANDIDATE / 通知类的接收与转发。
|
||||
//
|
||||
// 鉴权方式:浏览器原生 WebSocket 无法在握手阶段自定义请求头,
|
||||
// 因此将 accessToken 通过子协议(Sec-WebSocket-Protocol)传递:
|
||||
// new WebSocket(url, ['signal.v1', 'auth.' + token])
|
||||
// 服务端从首子协议取出 auth.<token> 进行 Bearer 校验。
|
||||
//
|
||||
// 不再发送 REGISTER —— 连接由服务端根据令牌身份自动完成,并下发 REGISTER_SUCCESS。
|
||||
import { getAccessToken, refresh } from './ApiClient';
|
||||
|
||||
export class SignalingClient {
|
||||
constructor({ serverUrl, deviceId, onConnected, onDisconnected, onError, onMessage }) {
|
||||
constructor({ serverUrl, onConnected, onDisconnected, onError, onMessage, onTokenExpired, onForceLogout }) {
|
||||
this.serverUrl = serverUrl;
|
||||
this.deviceId = deviceId;
|
||||
this.onConnected = onConnected;
|
||||
this.onDisconnected = onDisconnected;
|
||||
this.onError = onError;
|
||||
this.onMessage = onMessage;
|
||||
// 令牌过期(4001):调用方应刷新令牌后重连。
|
||||
this.onTokenExpired = onTokenExpired;
|
||||
// 强制下线(4003):调用方应停止重连并跳回登录。
|
||||
this.onForceLogout = onForceLogout;
|
||||
this.ws = null;
|
||||
this.deviceId = '';
|
||||
}
|
||||
|
||||
connect() {
|
||||
try {
|
||||
this.ws = new WebSocket(this.serverUrl);
|
||||
const token = getAccessToken();
|
||||
// 子协议携带版本标识与 Bearer token。
|
||||
const protocols = ['signal.v1'];
|
||||
if (token) protocols.push('auth.' + token);
|
||||
this.ws = new WebSocket(this.serverUrl, protocols);
|
||||
this.ws.onopen = () => {
|
||||
this.register();
|
||||
this.onConnected && this.onConnected();
|
||||
// 启动心跳保活:每 25 秒发送 ping,防止中间代理或服务器因空闲超时断开连接
|
||||
this._startHeartbeat();
|
||||
this.onConnected && this.onConnected();
|
||||
};
|
||||
this.ws.onmessage = (ev) => {
|
||||
let msg;
|
||||
@@ -28,22 +41,26 @@ export class SignalingClient {
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// 记录服务端下发的本机 fromDeviceId(REGISTER_SUCCESS)。
|
||||
if (msg.type === 'REGISTER_SUCCESS' && msg.fromDeviceId) {
|
||||
this.deviceId = msg.fromDeviceId;
|
||||
}
|
||||
this.onMessage && this.onMessage(msg);
|
||||
};
|
||||
this.ws.onclose = (ev) => {
|
||||
// 记录关闭码和原因,便于诊断断连根因:
|
||||
// 1000 = 正常关闭, 1001 = 离开, 1006 = 异常断开(无 close frame)
|
||||
// 1009 = 消息过大, 1011 = 服务端异常
|
||||
let hint = '';
|
||||
if (ev.code === 1009) {
|
||||
// 关闭帧由服务端发出:服务端 WebSocket 单条消息缓冲上限过小,
|
||||
// 导致 SDP/ICE 等稍大的消息被拒绝。需在信令服务端调大消息大小上限
|
||||
// (Spring 原生 WebSocket 用 configureWebSocketTransport#setMessageSizeLimit)。
|
||||
hint = '(消息过大:请检查信令服务端 WebSocket 的 messageSizeLimit 配置是否过小)';
|
||||
}
|
||||
console.warn('[信令] WebSocket 已关闭 code=%d reason=%s wasClean=%s%s',
|
||||
ev.code, ev.reason || '(无)', ev.wasClean, hint);
|
||||
this._stopHeartbeat();
|
||||
console.warn('[信令] WebSocket 已关闭 code=%d reason=%s wasClean=%s',
|
||||
ev.code, ev.reason || '(无)', ev.wasClean);
|
||||
if (ev.code === 4001) {
|
||||
// 令牌失效:先尝试刷新,刷新成功由调用方重连;失败则跳登录。
|
||||
(this.onTokenExpired || this.onDisconnected)?.(ev.reason);
|
||||
return;
|
||||
}
|
||||
if (ev.code === 4003) {
|
||||
// 强制下线:停止重连,跳回登录。
|
||||
(this.onForceLogout || this.onDisconnected)?.(ev.reason);
|
||||
return;
|
||||
}
|
||||
this.onDisconnected && this.onDisconnected(ev.code, ev.reason);
|
||||
};
|
||||
this.ws.onerror = (e) => {
|
||||
@@ -57,9 +74,9 @@ export class SignalingClient {
|
||||
|
||||
_startHeartbeat() {
|
||||
this._stopHeartbeat();
|
||||
// 每 25 秒发送 PING,保持连接活跃(服务端忽略未知类型)。
|
||||
this._heartbeatTimer = setInterval(() => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
// 发送轻量级 ping 消息,保持连接活跃(服务端会忽略未知类型消息)
|
||||
try { this.ws.send(JSON.stringify({ type: 'PING' })); } catch { /* ignore */ }
|
||||
}
|
||||
}, 25000);
|
||||
@@ -69,22 +86,14 @@ export class SignalingClient {
|
||||
if (this._heartbeatTimer) { clearInterval(this._heartbeatTimer); this._heartbeatTimer = null; }
|
||||
}
|
||||
|
||||
register() {
|
||||
this.send({
|
||||
type: 'REGISTER',
|
||||
fromDeviceId: this.deviceId,
|
||||
deviceType: 'CONTROLLER',
|
||||
});
|
||||
}
|
||||
|
||||
requestDeviceList() {
|
||||
this.send({ type: 'DEVICE_LIST', fromDeviceId: this.deviceId });
|
||||
// 服务端不再接受 DEVICE_LIST 枚举;控制端改用 HTTP /api/client/bindings 拉取绑定设备。
|
||||
this.send({ type: 'DEVICE_LIST' });
|
||||
}
|
||||
|
||||
sendOffer(sdp, toDeviceId, authType = null, authValue = null) {
|
||||
const msg = {
|
||||
type: 'OFFER',
|
||||
fromDeviceId: this.deviceId,
|
||||
toDeviceId,
|
||||
deviceType: 'CONTROLLER',
|
||||
payload: JSON.stringify({ sdp }),
|
||||
@@ -102,7 +111,6 @@ export class SignalingClient {
|
||||
};
|
||||
this.send({
|
||||
type: 'ICE_CANDIDATE',
|
||||
fromDeviceId: this.deviceId,
|
||||
toDeviceId,
|
||||
deviceType: 'CONTROLLER',
|
||||
payload: JSON.stringify(payload),
|
||||
@@ -118,7 +126,7 @@ export class SignalingClient {
|
||||
disconnect() {
|
||||
this._stopHeartbeat();
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
try { this.ws.close(1000, 'Disconnecting'); } catch { /* ignore */ }
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
// 公共 TURN(relay 兜底):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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user