import { Peer as PeerJs } from "peerjs"; import type { DataConnection, MediaConnection } from "peerjs"; import { fileMgrInstance, type FileData, type FileInfo } from "./fileMgr"; import { NEED_CHUNK_FILE_SIZE, NEED_CHUNK_FILE_SIZE_PREVIEW, TransferStatus, TransferTask, } from "./fileTransfer"; import { message, Modal, notification } from "ant-design-vue"; import { randomChars, sign2peerid, getPermissionSet, confirmWin, } from "./common"; // 发送超时时间(毫秒) const SEND_TIMEOUT = 30000; class Peer extends EventTarget { peer: PeerJs; //被动连接 connections: Map = new Map(); //主动连接 remoteConnection: DataConnection | null = null; //我的id id: string | null = null; //自定义标识 sign: string | null = null; // 媒体流相关 mediaConnections: Map< string, { connection: MediaConnection; stream: MediaStream; type: "desktop" | "call"; } > = new Map(); constructor(sign: string) { super(); this.sign = sign; } public init() { this.peer = new PeerJs(sign2peerid(this.sign), { config: { iceServers: [{ urls: "stun:8.134.35.244:3478" }], }, }); this.peer.on("open", (id) => { console.log("peer open", id); this.id = id; this.dispatchEvent(new CustomEvent("open", { detail: this.sign })); }); this.peer.on("connection", (conn) => { console.log("peer connection", conn); this.setupConnection(conn); }); this.peer.on("error", (err) => { console.log("peer error", err); this.dispatchEvent(new CustomEvent("error", { detail: err })); }); this.peer.on("close", () => { console.log("peer close"); this.dispatchEvent(new CustomEvent("close")); }); this.peer.on("disconnected", () => { console.log("peer disconnected"); this.dispatchEvent(new CustomEvent("disconnected")); }); // 处理媒体连接 this.peer.on("call", async (call) => { //弹出确认框 const type = call.metadata?.type === "desktop" ? "desktop" : "call"; const title = type === "desktop" ? "屏幕共享请求" : "语音通话请求"; const content = `${call.peer} 请求与您进行${type === "desktop" ? "屏幕共享" : "语音通话"},是否接受?`; const permission = type === "desktop" ? Permission.desktop : Permission.call; // 保存 this 引用 const self = this; const onOk = async () => { try { let stream: MediaStream; // 根据call.metadata判断是桌面共享还是音视频通话 if (type === "desktop") { stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false, }); } else { stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false, }); } call.answer(stream); self.setupMediaConnection(call, type); self.dispatchEvent( new CustomEvent( type === "desktop" ? "desktop-started" : "call-started", { detail: { peerId: call.peer, }, }, ), ); } catch (error) { console.error("获取媒体设备失败:", error); self.dispatchEvent( new CustomEvent("error", { detail: "无法访问" + (type === "desktop" ? "屏幕" : "麦克风"), }), ); message.error("无法访问" + (type === "desktop" ? "屏幕" : "麦克风")); } }; if (getPermissionSet()[permission]) { await onOk(); } else { confirmWin(title, content, "接受", "拒绝") .then(async () => { await onOk(); }) .catch(() => { call.close(); message.info( "已拒绝" + (type === "desktop" ? "屏幕共享" : "语音通话") + "请求", ); }); } }); } private setupConnection(conn: DataConnection) { this.connections.set(conn.peer, conn); conn.on("data", (data: unknown) => { const typedData = data as Message; if (this.callbackMap.has(typedData.id)) { this.callbackMap.get(typedData.id)?.(typedData); return; } this.handleMessage(typedData, conn); this.dispatchEvent( new CustomEvent(typedData.type, { detail: { peerId: conn.peer, data: typedData }, }), ); }); conn.on("open", () => { this.dispatchEvent( new CustomEvent("connection-open", { detail: { peer: conn.peer, conn: conn }, }), ); }); conn.on("close", () => { this.connections.delete(conn.peer); this.dispatchEvent( new CustomEvent("peer-disconnected", { detail: { peer: conn.peer, conn: conn }, }), ); }); } connect(id: string) { const conn = this.peer.connect(sign2peerid(id)); this.setupConnection(conn); this.remoteConnection = conn; this.checkConnection(); return conn; } async requestDesktop(id: string) { if (!this.remoteConnection) { notification.error({ message: "请创建连接", }); return; } try { // 创建一个静音的音频流作为占位 const emptyStream = new MediaStream(); // 将音频轨道静音 emptyStream.getAudioTracks().forEach((track) => { track.enabled = false; }); const call = this.peer.call(sign2peerid(id), emptyStream, { metadata: { type: "desktop" }, }); this.setupMediaConnection(call, "desktop"); this.dispatchEvent( new CustomEvent("desktop-started", { detail: { peerId: call.peer, }, }), ); } catch (error) { console.error("创建连接失败:", error); notification.error({ message: "创建连接失败", }); } } async requestCall(id: string) { if (!this.remoteConnection) { notification.error({ message: "请创建连接", }); return; } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false, }); const call = this.peer.call(sign2peerid(id), stream, { metadata: { type: "call" }, }); this.setupMediaConnection(call, "call"); this.dispatchEvent( new CustomEvent("call-started", { detail: { peerId: call.peer, }, }), ); } catch (error) { console.error("获取音频设备失败:", error); notification.error({ message: "无法访问麦克风", }); } } // 结束媒体连接 endMedia(peerId?: string) { if (peerId) { this.endMediaConnection(peerId); if (this.remoteConnection) { this.send( { type: MessageType.end_call, data: { peerId, }, }, this.remoteConnection, ); } } else { // 如果没有指定 peerId,结束所有连接 this.endAllMediaConnections(); } } private callbackMap: Map void> = new Map(); async send( data: Message, conn: DataConnection = this.remoteConnection, isHandleResponse: boolean = false, ): Promise { data.id = data.id || uuidv4(); if (!conn) { notification.error({ message: "请创建连接", }); return Promise.reject("连接不存在"); } if (!conn.open) { notification.error({ message: "连接未打开", description: "WebRTC 数据通道已关闭或尚未建立", }); return Promise.reject(new Error("连接未打开")); } try { const sendResult = conn.send(data); if (sendResult instanceof Promise) { await sendResult; } } catch (error) { notification.error({ message: "发送失败", description: error instanceof Error ? error.message : String(error), }); return Promise.reject(error); } if (isHandleResponse) { return Promise.resolve(data.data); } else { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { this.callbackMap.delete(data.id); notification.error({ message: "返回超时", description: "对方可能已离线或网络异常", }); reject(new Error("返回超时")); }, SEND_TIMEOUT); this.callbackMap.set(data.id, (data: Message) => { clearTimeout(timeoutId); if ( data.type === MessageType.response_getFile && data.data.sendType == "chunk" ) { //分片传输 特殊处理 可以长时间等待进度 return; } if (data.type === MessageType.error) { reject(data.data); } else { resolve(data.data); } this.callbackMap.delete(data.id); }); }); } } /**共交换的字节数 */ public transbytesNum: number = 0; /**共交换的包数 */ public transpackNum: number = 0; private checkConnection() { if (!this.remoteConnection || !this.remoteConnection.peerConnection) { return; } this.remoteConnection.peerConnection .getStats() .then((stats) => { stats.forEach((stat) => { if (stat.type == "data-channel") { //流量 this.transbytesNum = stat.bytesReceived + stat.bytesSent; //包数 this.transpackNum = stat.messagesReceived + stat.messagesSent; } }); }) .catch((err) => { console.warn("checkConnection getStats error:", err); }); setTimeout(() => { this.checkConnection(); }, 1000); } // 便捷方法用于添加事件监听器 on(event: string, callback: EventListener) { this.addEventListener(event, callback); } // 便捷方法用于移除事件监听器 off(event: string, callback: EventListener) { this.removeEventListener(event, callback); } private setupMediaConnection( call: MediaConnection, type: "desktop" | "call", ) { const peerId = call.peer; call.on("stream", (remoteStream: MediaStream) => { console.log("stream", remoteStream); // 只有当流中包含相应类型的轨道时才保存连接 const hasVideo = remoteStream.getVideoTracks().length > 0; const hasAudio = remoteStream.getAudioTracks().length > 0; if ((type === "desktop" && hasVideo) || (type === "call" && hasAudio)) { this.mediaConnections.set(peerId, { connection: call, stream: remoteStream, type, }); this.dispatchEvent( new CustomEvent("stream", { detail: { peerId, stream: remoteStream, type, }, }), ); } }); call.on("close", () => { this.endMediaConnection(peerId); }); } private endMediaConnection(peerId: string) { const mediaConn = this.mediaConnections.get(peerId); if (mediaConn) { mediaConn.connection.close(); mediaConn.stream.getTracks().forEach((track) => track.stop()); this.mediaConnections.delete(peerId); this.dispatchEvent( new CustomEvent("media-ended", { detail: { peerId, type: mediaConn.type, }, }), ); } } // 结束所有媒体连接 endAllMediaConnections() { for (const peerId of this.mediaConnections.keys()) { this.endMediaConnection(peerId); } } async handleMessage(data: Message, conn: DataConnection) { const remoteD = data.data; let file: FileInfo | null = null; if (data.type == MessageType.error) { console.error("handleMessage recive error", data.data); return; } let resData: Message = { type: MessageType.error, data: null, id: data.id, }; try { // 权限校验 /** 如果通过权限校验,则permissionNo为null */ let permissionNo: Permission | null = null; for (const [permission, allowed] of Object.entries(getPermissionSet())) { const limit = PermissionLimit[ permission as Permission ] as MessageType[]; if (limit instanceof Array && limit.includes(data.type)) { if (allowed) { permissionNo = null; break; } else { permissionNo = permission as Permission; } } } if (permissionNo) { throw new Error( `没有权限执行该操作:${data.type},请检查权限${permissionNo}设置`, ); } switch (data.type) { case MessageType.request_copyClipboard: resData.type = MessageType.response_copyClipboard; //读取粘贴板数据 resData.data = await navigator.clipboard .readText() .then((text) => { return text; }) .catch((err) => { return "没有粘贴板权限或窗口未聚焦,无法复制"; }); break; case MessageType.request_fileInfo: resData.type = MessageType.response_fileInfo; file = (await fileMgrInstance.getRootFile()).getFileInfo( remoteD.path, ); if (file) { await file.loadLocalDirectory(); const json = file.toJson(); resData.data = json; } else { resData.data = null; } break; case MessageType.request_fileSize: resData.type = MessageType.response_fileSize; file = (await fileMgrInstance.getRootFile()).getFileInfo( remoteD.path, ); resData.data = await file.getFileSize(); break; case MessageType.request_deleteFile: resData.type = MessageType.response_deleteFile; file = (await fileMgrInstance.getRootFile()).getFileInfo( remoteD.path, ); await file.deleteItself(); break; case MessageType.request_renameFile: resData.type = MessageType.response_renameFile; file = (await fileMgrInstance.getRootFile()).getFileInfo( remoteD.path, ); await file.renameFile(remoteD.newName); break; case MessageType.request_createDirectory: resData.type = MessageType.response_createDirectory; file = (await fileMgrInstance.getRootFile()).getFileInfo( remoteD.path, ); await file.createDirectory(remoteD.name); break; case MessageType.request_createFile: resData.type = MessageType.response_createFile; file = (await fileMgrInstance.getRootFile()).getFileInfo( remoteD.path, ); await file.createFile(remoteD.name, remoteD.buffer); break; case MessageType.request_saveFile: resData.type = MessageType.response_saveFile; file = (await fileMgrInstance.getRootFile()).getFileInfo( remoteD.path, ); await file.saveFile(remoteD.buffer); break; case MessageType.request_getFileLastModified: resData.type = MessageType.response_getFileLastModified; file = (await fileMgrInstance.getRootFile()).getFileInfo( remoteD.path, ); resData.data = await file.getFileLastModified(); break; case MessageType.request_getFile: if (!remoteD.preView) { //特殊处理下载权限 if (!getPermissionSet()[Permission.download]) { throw new Error( `没有权限执行该操作:${data.type},请检查权限${Permission.download}设置`, ); } } resData.type = MessageType.response_getFile; file = (await fileMgrInstance.getRootFile()).getFileInfo( remoteD.path, ); let fileData = (await file.getFile(remoteD.preView)) as FileData; if (fileData.size > NEED_CHUNK_FILE_SIZE) { this.send( { type: MessageType.response_getFile, data: { sendType: "chunk", }, id: data.id, }, conn, true, ); //分片传输 if (remoteD.preView) { if (fileData.size < NEED_CHUNK_FILE_SIZE_PREVIEW) { await file.sendFileChunk(conn, remoteD.preView); } } else { await file.sendFileChunk(conn, false, remoteD.savePath); } fileData.buffer = null; } else { //直接传输 file .getTransfer(conn) .addTask(new TransferTask(fileData, file)) .updateFileData(fileData, TransferStatus.COMPLETED); } resData.data = fileData; break; case MessageType.push_file_chunk: resData.type = MessageType.response_push_file_chunk; resData.data = "ok"; let fData = remoteD as FileData; if (fData.preView) { await fileMgrInstance.remoteRootFile .getFileInfo(fData.path) .getTransfer(conn) .receiveFile(fData, true); } else { await (await fileMgrInstance.getRootFile()) .getTransfer(conn) .receiveFile(fData); } break; case MessageType.push_file_complete: resData.type = MessageType.response_push_file_complete; console.log("push_file_complete", remoteD); if (remoteD.preView) { notification.success({ message: "文件缓存建立完成", }); } else { notification.success({ message: "文件流式传输完成", description: remoteD.name, }); } resData.data = "ok"; break; case MessageType.end_call: case MessageType.end_desktop: if (remoteD?.peerId) { this.endMediaConnection(remoteD.peerId); } break; default: resData.type = MessageType.error; resData.data = "未知消息类型"; break; } } catch (err: any) { console.error("handleMessage error", err); resData.type = MessageType.error; resData.data = err.message || JSON.stringify(err); } this.send(resData, conn, true); } } export const peer = new Peer(randomChars(6)); export interface Message { type: MessageType; data: any; id?: string; //uuid } export enum MessageType { request_copyClipboard = "request_copyClipboard", response_copyClipboard = "response_copyClipboard", request_getFile = "request_getFile", response_getFile = "response_getFile", request_getFileLastModified = "request_getFileLastModified", response_getFileLastModified = "response_getFileLastModified", request_saveFile = "request_saveFile", response_saveFile = "response_saveFile", request_createFile = "request_createFile", response_createFile = "response_createFile", request_createDirectory = "request_createDirectory", response_createDirectory = "response_createDirectory", request_renameFile = "request_renameFile", response_renameFile = "response_renameFile", request_deleteFile = "request_deleteFile", response_deleteFile = "response_deleteFile", error = "handle_error", request_fileSize = "request_fileSize", response_fileSize = "response_fileSize", response_fileInfo = "response_fileInfo", request_fileInfo = "request_fileInfo", push_file_chunk = "push_file_chunk", push_file_complete = "push_file_complete", response_push_file_chunk = "response_push_file_chunk", response_push_file_complete = "response_push_file_complete", end_call = "end_call", end_desktop = "end_desktop", } export enum Permission { edit = "edit", view = "view", download = "download", desktop = "desktop", call = "call", } export const PermissionLimit = { [Permission.edit]: [ MessageType.request_saveFile, MessageType.request_createFile, MessageType.request_createDirectory, MessageType.request_renameFile, MessageType.request_deleteFile, ], [Permission.view]: [ MessageType.request_copyClipboard, MessageType.request_getFile, MessageType.request_getFileLastModified, MessageType.request_fileInfo, MessageType.request_fileSize, ], [Permission.download]: [MessageType.request_getFile], [Permission.desktop]: false, [Permission.call]: false, }; export function uuidv4(): string { return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { var r = (Math.random() * 16) | 0, v = c == "x" ? r : (r & 0x3) | 0x8; return v.toString(16); }); }