718 lines
21 KiB
TypeScript
718 lines
21 KiB
TypeScript
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);
|
||
});
|
||
}
|