p2p-explorer-web/src/pages/file/utils/peer.ts

718 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, DataConnection> = 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<string, (data: any) => void> = new Map();
async send(
data: Message,
conn: DataConnection = this.remoteConnection,
isHandleResponse: boolean = false,
): Promise<any> {
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);
});
}