新增desktop 新增修复速率
This commit is contained in:
parent
e4702b877f
commit
f1c9f018a4
@ -1,4 +1,5 @@
|
||||
<template>
|
||||
<DesktopView :peerId="sign2peerid(targetId)" />
|
||||
<div class="container">
|
||||
<!-- 连接状态 -->
|
||||
<div class="status-bar">
|
||||
@ -20,6 +21,14 @@
|
||||
</div>
|
||||
|
||||
<div class="connect-section">
|
||||
<div v-if="isConnected" class="connect-section">
|
||||
<div class="connect-item" @click="requestDesktop">
|
||||
<img src="/static/desktop.png" alt="桌面" />
|
||||
</div>
|
||||
<div class="connect-item" @click="requestCall">
|
||||
<img src="/static/phone.png" alt="通话" />
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
v-model="targetId"
|
||||
:placeholder="!myId ? '请稍后...' : '输入对方ID'"
|
||||
@ -79,11 +88,13 @@ import FileView from "./item/fileView.vue";
|
||||
import { notification } from "ant-design-vue";
|
||||
import { fileMgrInstance, type FileData } from "./utils/fileMgr";
|
||||
import { Button } from "ant-design-vue";
|
||||
import DesktopView from "./item/desptopView.vue";
|
||||
import {
|
||||
formatSize,
|
||||
getUrlParam,
|
||||
localCurrentFile,
|
||||
remoteCurrentFile,
|
||||
sign2peerid,
|
||||
} from "./utils/common";
|
||||
import FileTransferView from "./item/fileTranserView.vue";
|
||||
|
||||
@ -99,6 +110,13 @@ const selectedRemoteFiles = ref<string[]>([]);
|
||||
onMounted(() => {
|
||||
isPhone.value = document.body.clientWidth < 768;
|
||||
});
|
||||
|
||||
const requestDesktop = () => {
|
||||
peer.requestDesktop(targetId.value);
|
||||
};
|
||||
const requestCall = () => {
|
||||
peer.requestCall(targetId.value);
|
||||
};
|
||||
const shareUrl = async () => {
|
||||
if (myId.value) {
|
||||
const url =
|
||||
@ -252,6 +270,24 @@ onMounted(() => {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.connect-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: #dff1ff;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #82c7fb;
|
||||
}
|
||||
.connect-item:hover {
|
||||
background: #cae5f9;
|
||||
}
|
||||
.connect-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.connect-section {
|
||||
display: flex;
|
||||
|
||||
232
src/pages/file/item/desptopView.vue
Normal file
232
src/pages/file/item/desptopView.vue
Normal file
@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<div
|
||||
class="desktop-view-wrapper"
|
||||
:class="{ 'is-collapsed': isCollapsed, 'has-stream': !!stream }"
|
||||
>
|
||||
<div class="collapse-header" v-if="stream" @click="toggleCollapse">
|
||||
<span>远程桌面{{ isCollapsed ? "(已收起)" : "" }}</span>
|
||||
<a-button type="link">
|
||||
<template #icon>
|
||||
<UpOutlined v-if="!isCollapsed" />
|
||||
<DownOutlined v-else />
|
||||
</template>
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="desktop-view" :class="{ 'is-fullscreen': isFullscreen }">
|
||||
<div class="video-container" ref="videoContainer">
|
||||
<video ref="videoRef" autoplay playsinline></video>
|
||||
<div class="controls" v-if="stream">
|
||||
<a-button
|
||||
type="primary"
|
||||
ghost
|
||||
:icon="isFullscreen ? 'fullscreen-exit' : 'fullscreen'"
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
{{ isFullscreen ? "退出全屏" : "全屏" }}
|
||||
</a-button>
|
||||
</div>
|
||||
<div v-if="!stream" class="no-signal">
|
||||
<a-empty description="等待远程桌面共享..." />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import { peer } from "../utils/peer";
|
||||
import { message } from "ant-design-vue";
|
||||
import { UpOutlined, DownOutlined } from "@ant-design/icons-vue";
|
||||
|
||||
const props = defineProps<{
|
||||
peerId: string;
|
||||
}>();
|
||||
|
||||
interface StreamEvent extends CustomEvent {
|
||||
detail: {
|
||||
stream: MediaStream;
|
||||
type: "desktop" | "call";
|
||||
peerId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface MediaEndedEvent extends CustomEvent {
|
||||
detail: {
|
||||
type: "desktop" | "call";
|
||||
peerId: string;
|
||||
};
|
||||
}
|
||||
|
||||
const videoRef = ref<HTMLVideoElement | null>(null);
|
||||
const videoContainer = ref<HTMLElement | null>(null);
|
||||
const stream = ref<MediaStream | null>(null);
|
||||
const isFullscreen = ref(false);
|
||||
const isCollapsed = ref(false);
|
||||
|
||||
// 切换收起/展开状态
|
||||
const toggleCollapse = () => {
|
||||
isCollapsed.value = !isCollapsed.value;
|
||||
};
|
||||
|
||||
// 处理远程流
|
||||
const handleStream = (event: StreamEvent) => {
|
||||
const { stream: remoteStream, type } = event.detail;
|
||||
if (props.peerId !== event.detail.peerId) return;
|
||||
if (type === "desktop" && videoRef.value) {
|
||||
stream.value = remoteStream;
|
||||
videoRef.value.srcObject = remoteStream;
|
||||
isCollapsed.value = false; // 收到流时自动展开
|
||||
}
|
||||
};
|
||||
|
||||
// 处理媒体结束
|
||||
const handleMediaEnded = (event: MediaEndedEvent) => {
|
||||
const { type } = event.detail;
|
||||
if (props.peerId !== event.detail.peerId) return;
|
||||
if (type === "desktop") {
|
||||
stream.value = null;
|
||||
if (videoRef.value) {
|
||||
videoRef.value.srcObject = null;
|
||||
}
|
||||
message.info("远程桌面共享已结束");
|
||||
}
|
||||
};
|
||||
|
||||
// 切换全屏
|
||||
const toggleFullscreen = async () => {
|
||||
if (!videoContainer.value) return;
|
||||
|
||||
try {
|
||||
if (!isFullscreen.value) {
|
||||
if (videoContainer.value.requestFullscreen) {
|
||||
await videoContainer.value.requestFullscreen();
|
||||
}
|
||||
} else {
|
||||
if (document.exitFullscreen) {
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
message.error("切换全屏失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 监听全屏变化
|
||||
const handleFullscreenChange = () => {
|
||||
isFullscreen.value = !!document.fullscreenElement;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
peer.on("stream", handleStream as EventListener);
|
||||
peer.on("media-ended", handleMediaEnded as EventListener);
|
||||
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
peer.off("stream", handleStream as EventListener);
|
||||
peer.off("media-ended", handleMediaEnded as EventListener);
|
||||
document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
||||
|
||||
// 清理视频流
|
||||
if (stream.value) {
|
||||
stream.value.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.desktop-view-wrapper {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: #000;
|
||||
transition: all 0.3s ease;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.desktop-view-wrapper.has-stream {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.desktop-view-wrapper.is-collapsed {
|
||||
height: 40px !important;
|
||||
}
|
||||
|
||||
.collapse-header {
|
||||
height: 40px;
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.collapse-header:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.desktop-view {
|
||||
width: 100%;
|
||||
height: calc(100% - 40px);
|
||||
background: #000;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.video-container:hover .controls {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.no-signal {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.is-fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
:deep(.ant-empty-description) {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
:deep(.anticon) {
|
||||
vertical-align: 0;
|
||||
}
|
||||
</style>
|
||||
@ -1,6 +1,7 @@
|
||||
import { type Ref, ref } from "vue";
|
||||
import { FileInfo } from "./fileMgr";
|
||||
import { Permission } from "./peer";
|
||||
import { Modal } from "ant-design-vue";
|
||||
|
||||
export const formatSize = (size: number): string => {
|
||||
if (size < 1024) return `${size} B`;
|
||||
@ -18,6 +19,22 @@ export const base64ToString = (base64: string) => {
|
||||
//base64转汉字
|
||||
return decodeURIComponent(atob(base64))
|
||||
}
|
||||
export const confirmWin = (title: string, content: string, okText: string, cancelText: string): Promise<boolean> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
Modal.confirm({
|
||||
title,
|
||||
content,
|
||||
okText,
|
||||
cancelText,
|
||||
async onOk() {
|
||||
resolve(true)
|
||||
},
|
||||
onCancel() {
|
||||
reject(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
const commonCharsCN = '的一是在不了有和人这中大为上个国我以要他时来用们生到作地于出就分对成会可主发年动同工也能下过子说产种面而方后多点行学法所民得经十三之进着等部度家电力里如水化高自二理起小物现实加量都两体制机当西从广业本去把性好应开它合还因由其些然前外天政四日那社义事平形相全表间样与关各重新线内数正心反你明看原又么利比或但质气第向道命此变条只没结解问意建月公无系军很情者最立代想已通并提直题党程展五果料象员革位入常文总次品式活设及管特件长求老头基资边流路级少图山统接知较将组见计别她手角期根论运农指几九区强放决皮被干做必战先回则任取据处队南给色光门即保治北造百规热领七海口东导器压志世金增争济阶油思术极交受联什认六共权收证改清己美再采转更单风切打白教速花带安场身车例真务具万每目至达走积示议声报斗完类八离华名确才科张信马节话米整空元况今集温传土许步群广石记需段研界拉林律叫且究观越织装影算低持音众书布复容儿须际商非验连断深难近矿千周委素技备半办青省列习响约支般史感劳便团往酸历市克何除消构府称太准精值号率族维划选标写存候毛亲快效斯院查江型眼王按格养易置派层片始却专状育厂京识适属圆包火住调满县局照参红细引听该冯价严龙飞';
|
||||
const commonCharsEN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||
export const randomChars = (num: number = 3, chars: string = commonCharsEN) => {
|
||||
@ -51,6 +68,8 @@ export function getPermissionSet(): { [key in Permission]: boolean } {
|
||||
edit: false,
|
||||
view: true,
|
||||
download: true,
|
||||
desktop: true,
|
||||
call: false
|
||||
};
|
||||
}
|
||||
return permissionSet;
|
||||
|
||||
@ -23,12 +23,16 @@ export interface TransferProgress {
|
||||
}
|
||||
|
||||
// 分片配置
|
||||
const CHUNK_SIZE = 64 * 1024; // 64KB
|
||||
const MAX_CHUNK_SIZE = 200 * 1024; // 200KB
|
||||
const CHUNK_SIZE = 256 * 1024; // 256KB (从64KB提升,减少消息数量)
|
||||
const MAX_CHUNK_SIZE = 256 * 1024; // 256KB (WebRTC SCTP 最大安全消息大小)
|
||||
//多大的文件需要分片
|
||||
export const NEED_CHUNK_FILE_SIZE = 200 * 1024; // 200KB
|
||||
export const NEED_CHUNK_FILE_SIZE_PREVIEW = 50 * 1024 * 1024; // 50MB
|
||||
|
||||
// 流水线流控配置
|
||||
const MAX_BUFFERED_AMOUNT = 1024 * 1024; // 1MB - 触发流控的缓冲上限
|
||||
const DRAIN_THRESHOLD = 256 * 1024; // 256KB - 恢复发送的缓冲下限
|
||||
|
||||
export class FileTransfer {
|
||||
public conn: DataConnection;
|
||||
private file: FileInfo;
|
||||
@ -46,12 +50,16 @@ export class FileTransfer {
|
||||
public getFile(): FileInfo {
|
||||
return this.file;
|
||||
}
|
||||
constructor(conn: DataConnection, file: FileInfo, chunkSize: number = CHUNK_SIZE) {
|
||||
constructor(
|
||||
conn: DataConnection,
|
||||
file: FileInfo,
|
||||
chunkSize: number = CHUNK_SIZE,
|
||||
) {
|
||||
this.conn = conn;
|
||||
this.file = file;
|
||||
this.chunkSize = Math.min(chunkSize, MAX_CHUNK_SIZE);
|
||||
file.addTransfer(this)
|
||||
fileTransferMgrInstance.addFileTransfer(this)
|
||||
file.addTransfer(this);
|
||||
fileTransferMgrInstance.addFileTransfer(this);
|
||||
}
|
||||
public init(preView: boolean = false) {
|
||||
this.clear();
|
||||
@ -84,7 +92,10 @@ export class FileTransfer {
|
||||
private getProgress(): TransferProgress {
|
||||
const now = Date.now();
|
||||
const timeElapsed = (now - this.startTime) / 1000; // 转换为秒
|
||||
const speed = timeElapsed > 0 ? (this.transferredSize - this.lastTransferredSize) / timeElapsed : 0;
|
||||
const speed =
|
||||
timeElapsed > 0
|
||||
? (this.transferredSize - this.lastTransferredSize) / timeElapsed
|
||||
: 0;
|
||||
let totalSize = this.fileData?.chunkData?.totalSize || this.file.size;
|
||||
const progress: TransferProgress = {
|
||||
transferredSize: this.transferredSize,
|
||||
@ -93,7 +104,7 @@ export class FileTransfer {
|
||||
status: this.status,
|
||||
percent: (this.transferredSize / totalSize) * 100,
|
||||
costTime: timeElapsed,
|
||||
updateTime: now
|
||||
updateTime: now,
|
||||
};
|
||||
|
||||
// 更新上次传输大小和开始时间
|
||||
@ -101,27 +112,30 @@ export class FileTransfer {
|
||||
this.startTime = now;
|
||||
return progress;
|
||||
}
|
||||
public fileData: FileData
|
||||
public fileData: FileData;
|
||||
public offset: number = 0;
|
||||
public totalSize: number = 0;
|
||||
private fileBuffer: ArrayBuffer | null = null;
|
||||
// 发送文件
|
||||
public async sendFile(savePath: string = ''): Promise<boolean> {
|
||||
// 发送文件 (流水线模式 - 不再逐片等待确认)
|
||||
public async sendFile(savePath: string = ""): Promise<boolean> {
|
||||
try {
|
||||
if (this.status == TransferStatus.WAITING) {
|
||||
this.startTime = Date.now();
|
||||
this.fileData = await this.file.getFile() as FileData;
|
||||
this.fileData = (await this.file.getFile()) as FileData;
|
||||
this.totalSize = this.fileData.size;
|
||||
this.fileBuffer = this.fileData.buffer;
|
||||
this.fileData.preView = this.preView;
|
||||
this.fileData.savePath = savePath;
|
||||
this.fileData.buffer = null; // 移除完整buffer,只传分片
|
||||
this.addTask(new TransferTask(this.fileData, this.file));
|
||||
this.status = TransferStatus.SENDING;
|
||||
this.updateProgress();
|
||||
}
|
||||
|
||||
if (this.offset < this.totalSize && !this.aborted) {
|
||||
// 检查是否暂停
|
||||
const dc = this.conn.dataChannel;
|
||||
|
||||
// 流水线发送: 不再逐片等待确认,通过 bufferedAmount 控制背压
|
||||
while (this.offset < this.totalSize && !this.aborted) {
|
||||
if (this.pausePromise) {
|
||||
this.status = TransferStatus.PAUSED;
|
||||
this.updateProgress();
|
||||
@ -129,59 +143,70 @@ export class FileTransfer {
|
||||
this.status = TransferStatus.SENDING;
|
||||
}
|
||||
|
||||
// 发送分片
|
||||
const chunk = this.fileBuffer.slice(this.offset, this.offset + this.chunkSize);
|
||||
this.fileData.buffer = null;
|
||||
const end = Math.min(this.offset + this.chunkSize, this.totalSize);
|
||||
const chunk = this.fileBuffer.slice(this.offset, end);
|
||||
this.fileData.chunkData = {
|
||||
offset: this.offset,
|
||||
totalSize: this.totalSize,
|
||||
buffer: chunk
|
||||
}
|
||||
return this.sendFileChunk(this.fileData).then((): Promise<boolean> => {
|
||||
this.offset += chunk.byteLength;
|
||||
buffer: chunk,
|
||||
};
|
||||
|
||||
// 发送分片 (fire-and-forget,不等确认)
|
||||
peer.send(
|
||||
{
|
||||
type: MessageType.push_file_chunk,
|
||||
data: this.fileData,
|
||||
},
|
||||
this.conn,
|
||||
true,
|
||||
);
|
||||
|
||||
this.offset = end;
|
||||
this.transferredSize = this.offset;
|
||||
if (this.offset >= this.totalSize) {
|
||||
return this.sendFileComplete();
|
||||
this.updateProgress();
|
||||
|
||||
// 基于 bufferedAmount 的流控: 缓冲过大时等待排空
|
||||
if (dc && dc.bufferedAmount > MAX_BUFFERED_AMOUNT) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const checkBuffer = () => {
|
||||
if (dc.bufferedAmount < DRAIN_THRESHOLD || this.aborted) {
|
||||
resolve();
|
||||
} else {
|
||||
return this.sendFile();
|
||||
requestAnimationFrame(checkBuffer);
|
||||
}
|
||||
};
|
||||
checkBuffer();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.aborted) {
|
||||
// 仅等待最终完成确认
|
||||
await peer.send(
|
||||
{
|
||||
type: MessageType.push_file_complete,
|
||||
data: this.fileData,
|
||||
},
|
||||
this.conn,
|
||||
);
|
||||
this.status = TransferStatus.COMPLETED;
|
||||
this.updateProgress();
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
this.status = TransferStatus.ERROR;
|
||||
this.updateProgress();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
private sendFileChunk(chunk: FileData): Promise<void> {
|
||||
this.updateProgress()
|
||||
return peer.send({
|
||||
type: MessageType.push_file_chunk,
|
||||
data: chunk,
|
||||
}, this.conn);
|
||||
}
|
||||
private async sendFileComplete(): Promise<boolean> {
|
||||
await peer.send({
|
||||
type: MessageType.push_file_complete,
|
||||
data: this.fileData,
|
||||
}, this.conn);
|
||||
this.status = TransferStatus.COMPLETED;
|
||||
this.updateProgress();
|
||||
return true;
|
||||
}
|
||||
// 接收文件
|
||||
public async receiveFile(fData: FileData, preView: boolean = false): Promise<void> {
|
||||
public async receiveFile(
|
||||
fData: FileData,
|
||||
preView: boolean = false,
|
||||
): Promise<void> {
|
||||
try {
|
||||
let task = this.getTask(fData)
|
||||
let task = this.getTask(fData);
|
||||
if (!task) {
|
||||
this.addTask(new TransferTask(fData, this.file));
|
||||
} else {
|
||||
@ -191,21 +216,32 @@ export class FileTransfer {
|
||||
this.transferredSize = fData.chunkData.offset;
|
||||
this.startTime = Date.now();
|
||||
this.totalSize = fData.chunkData.totalSize;
|
||||
if (fData.chunkData.totalSize <= fData.chunkData.buffer.byteLength + fData.chunkData.offset) {
|
||||
if (
|
||||
fData.chunkData.totalSize <=
|
||||
fData.chunkData.buffer.byteLength + fData.chunkData.offset
|
||||
) {
|
||||
this.status = TransferStatus.COMPLETED;
|
||||
}
|
||||
if (preView) {
|
||||
await this.file.addPreviewCacheBuffer(fData.chunkData.buffer, fData.chunkData.offset, fData.chunkData.totalSize);
|
||||
await this.file.addPreviewCacheBuffer(
|
||||
fData.chunkData.buffer,
|
||||
fData.chunkData.offset,
|
||||
fData.chunkData.totalSize,
|
||||
);
|
||||
} else {
|
||||
const path = fData.savePath + '/' + fData.name;
|
||||
await this.file.createFile(path, fData.chunkData.buffer, fData.chunkData.offset);
|
||||
const path = fData.savePath + "/" + fData.name;
|
||||
await this.file.createFile(
|
||||
path,
|
||||
fData.chunkData.buffer,
|
||||
fData.chunkData.offset,
|
||||
);
|
||||
// if (this.status == TransferStatus.COMPLETED) {
|
||||
// await this.file.renameFile(path, fData.savePath + '/' + fData.name);
|
||||
// }
|
||||
}
|
||||
|
||||
this.updateProgress(fData);
|
||||
return
|
||||
return;
|
||||
} catch (error) {
|
||||
this.status = TransferStatus.ERROR;
|
||||
this.updateProgress(fData);
|
||||
@ -215,8 +251,11 @@ export class FileTransfer {
|
||||
|
||||
// 暂停传输
|
||||
public pause() {
|
||||
if (this.status === TransferStatus.SENDING || this.status === TransferStatus.RECEIVING) {
|
||||
this.pausePromise = new Promise(resolve => {
|
||||
if (
|
||||
this.status === TransferStatus.SENDING ||
|
||||
this.status === TransferStatus.RECEIVING
|
||||
) {
|
||||
this.pausePromise = new Promise((resolve) => {
|
||||
this.pauseResolve = resolve;
|
||||
});
|
||||
}
|
||||
@ -252,19 +291,21 @@ export class FileTransfer {
|
||||
return task;
|
||||
}
|
||||
public getTasks() {
|
||||
this.tasks.forEach(task => task.updateProgress());
|
||||
this.tasks.forEach((task) => task.updateProgress());
|
||||
return this.tasks;
|
||||
}
|
||||
public updateTask(fData: FileData) {
|
||||
const task = this.tasks.find(task => task.fileData.path === fData.path);
|
||||
const task = this.tasks.find((task) => task.fileData.path === fData.path);
|
||||
task.updateFileData(fData, this.status);
|
||||
}
|
||||
public getTask(fData: FileData) {
|
||||
return this.tasks.find(task => task.fileData.path === fData.path);
|
||||
return this.tasks.find((task) => task.fileData.path === fData.path);
|
||||
}
|
||||
//清除已完成任务
|
||||
public clearCompletedTasks() {
|
||||
this.tasks = this.tasks.filter(t => t.status !== TransferStatus.COMPLETED);
|
||||
this.tasks = this.tasks.filter(
|
||||
(t) => t.status !== TransferStatus.COMPLETED,
|
||||
);
|
||||
}
|
||||
}
|
||||
class FileTransferMgr {
|
||||
@ -272,7 +313,6 @@ class FileTransferMgr {
|
||||
public addFileTransfer(transfer: FileTransfer) {
|
||||
transfer.onProgress(this.notifyTransferChanged.bind(this));
|
||||
this.fileTransfers.set(transfer.getFile().path, transfer);
|
||||
|
||||
}
|
||||
public getFileTransfer(path: string) {
|
||||
return this.fileTransfers.get(path);
|
||||
@ -281,18 +321,20 @@ class FileTransferMgr {
|
||||
return Array.from(this.fileTransfers.values());
|
||||
}
|
||||
// 传输进度变化回调
|
||||
private onTransferChangedHandler: ((transfer: FileTransfer) => void)[] = []
|
||||
private onTransferChangedHandler: ((transfer: FileTransfer) => void)[] = [];
|
||||
public onTransferChanged(handler: (transfer: FileTransfer) => void) {
|
||||
this.onTransferChangedHandler.push(handler);
|
||||
}
|
||||
public removeTransferChangedHandler(handler: (transfer: FileTransfer) => void) {
|
||||
this.onTransferChangedHandler = this.onTransferChangedHandler.filter(h => h !== handler);
|
||||
public removeTransferChangedHandler(
|
||||
handler: (transfer: FileTransfer) => void,
|
||||
) {
|
||||
this.onTransferChangedHandler = this.onTransferChangedHandler.filter(
|
||||
(h) => h !== handler,
|
||||
);
|
||||
}
|
||||
public notifyTransferChanged(transfer: FileTransfer) {
|
||||
this.onTransferChangedHandler.forEach(handler => handler(transfer));
|
||||
this.onTransferChangedHandler.forEach((handler) => handler(transfer));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
export class TransferTask {
|
||||
//传输文件数据
|
||||
@ -311,14 +353,17 @@ export class TransferTask {
|
||||
status: TransferStatus.WAITING,
|
||||
percent: 0,
|
||||
costTime: 0,
|
||||
updateTime: 0
|
||||
updateTime: 0,
|
||||
};
|
||||
constructor(fileData: FileData, file: FileInfo) {
|
||||
this.fileData = fileData;
|
||||
this.file = file;
|
||||
this.startTime = Date.now();
|
||||
}
|
||||
public updateFileData(fileData: FileData, status: TransferStatus = TransferStatus.SENDING) {
|
||||
public updateFileData(
|
||||
fileData: FileData,
|
||||
status: TransferStatus = TransferStatus.SENDING,
|
||||
) {
|
||||
this.fileData = fileData;
|
||||
this.status = status;
|
||||
this.updateProgress();
|
||||
@ -329,10 +374,20 @@ export class TransferTask {
|
||||
return this.progress;
|
||||
}
|
||||
const totalSize = this.fileData.chunkData?.totalSize || this.fileData.size;
|
||||
const transferredSize = this.fileData.chunkData?.offset || this.fileData.size;
|
||||
const speed = this.fileData.chunkData ? (transferredSize + this.fileData.chunkData.buffer.byteLength) / (Date.now() - this.startTime) * 1000 : this.fileData.size / (Date.now() - this.startTime) * 1000;
|
||||
const percent = this.fileData.chunkData ? (transferredSize + this.fileData.chunkData.buffer.byteLength) / totalSize * 100 : transferredSize / totalSize * 100;
|
||||
this.status = transferredSize >= totalSize ? TransferStatus.COMPLETED : this.status;
|
||||
const transferredSize =
|
||||
this.fileData.chunkData?.offset || this.fileData.size;
|
||||
const speed = this.fileData.chunkData
|
||||
? ((transferredSize + this.fileData.chunkData.buffer.byteLength) /
|
||||
(Date.now() - this.startTime)) *
|
||||
1000
|
||||
: (this.fileData.size / (Date.now() - this.startTime)) * 1000;
|
||||
const percent = this.fileData.chunkData
|
||||
? ((transferredSize + this.fileData.chunkData.buffer.byteLength) /
|
||||
totalSize) *
|
||||
100
|
||||
: (transferredSize / totalSize) * 100;
|
||||
this.status =
|
||||
transferredSize >= totalSize ? TransferStatus.COMPLETED : this.status;
|
||||
this.progress = {
|
||||
transferredSize: transferredSize,
|
||||
totalSize: totalSize,
|
||||
@ -340,9 +395,9 @@ export class TransferTask {
|
||||
status: this.status,
|
||||
percent: percent,
|
||||
costTime: Date.now() - this.startTime,
|
||||
updateTime: Date.now()
|
||||
}
|
||||
updateTime: Date.now(),
|
||||
};
|
||||
return this.progress;
|
||||
}
|
||||
}
|
||||
export const fileTransferMgrInstance = new FileTransferMgr()
|
||||
export const fileTransferMgrInstance = new FileTransferMgr();
|
||||
|
||||
@ -1,128 +1,329 @@
|
||||
import { Peer as PeerJs } from 'peerjs'
|
||||
import type { DataConnection } 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 { notification } from 'ant-design-vue'
|
||||
import { randomChars, sign2peerid, getPermissionSet } from './common'
|
||||
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 = 10000;
|
||||
|
||||
class Peer extends EventTarget {
|
||||
peer: PeerJs
|
||||
peer: PeerJs;
|
||||
//被动连接
|
||||
connections: Map<string, DataConnection> = new Map()
|
||||
connections: Map<string, DataConnection> = new Map();
|
||||
//主动连接
|
||||
remoteConnection: DataConnection | null = null
|
||||
remoteConnection: DataConnection | null = null;
|
||||
//我的id
|
||||
id: string | null = null
|
||||
id: string | null = null;
|
||||
//自定义标识
|
||||
sign: 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
|
||||
super();
|
||||
this.sign = sign;
|
||||
}
|
||||
public init() {
|
||||
this.peer = new PeerJs(sign2peerid(this.sign))
|
||||
this.peer.on('open', (id) => {
|
||||
console.log('peer open', id)
|
||||
this.id = id
|
||||
this.dispatchEvent(new CustomEvent('open', { detail: this.sign }))
|
||||
})
|
||||
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("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("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("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("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
|
||||
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.callbackMap.get(typedData.id)?.(typedData);
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleMessage(typedData, conn)
|
||||
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 }
|
||||
}))
|
||||
})
|
||||
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
|
||||
const conn = this.peer.connect(sign2peerid(id));
|
||||
this.setupConnection(conn);
|
||||
this.remoteConnection = conn;
|
||||
this.checkConnection();
|
||||
return conn;
|
||||
}
|
||||
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()
|
||||
|
||||
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('连接不存在')
|
||||
message: "请创建连接",
|
||||
});
|
||||
return Promise.reject("连接不存在");
|
||||
}
|
||||
conn.send(data, true)
|
||||
conn.send(data, true);
|
||||
if (isHandleResponse) {
|
||||
return Promise.resolve(data.data)
|
||||
return Promise.resolve(data.data);
|
||||
} else {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.callbackMap.delete(data.id)
|
||||
this.callbackMap.delete(data.id);
|
||||
notification.error({
|
||||
message: '返回超时',
|
||||
description: '对方可能已离线或网络异常'
|
||||
})
|
||||
reject(new Error('返回超时'))
|
||||
}, SEND_TIMEOUT)
|
||||
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') {
|
||||
clearTimeout(timeoutId);
|
||||
if (
|
||||
data.type === MessageType.response_getFile &&
|
||||
data.data.sendType == "chunk"
|
||||
) {
|
||||
//分片传输 特殊处理 可以长时间等待进度
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (data.type === MessageType.error) {
|
||||
reject(data.data)
|
||||
reject(data.data);
|
||||
} else {
|
||||
resolve(data.data)
|
||||
resolve(data.data);
|
||||
}
|
||||
this.callbackMap.delete(data.id)
|
||||
})
|
||||
})
|
||||
this.callbackMap.delete(data.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
/**共交换的字节数 */
|
||||
@ -131,7 +332,7 @@ class Peer extends EventTarget {
|
||||
public transpackNum: number = 0;
|
||||
private checkConnection() {
|
||||
if (!this.remoteConnection) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
const rtcp: RTCPeerConnection = this.remoteConnection.peerConnection;
|
||||
rtcp.getStats().then((stats) => {
|
||||
@ -142,41 +343,105 @@ class Peer extends EventTarget {
|
||||
//包数
|
||||
this.transpackNum = stat.messagesReceived + stat.messagesSent;
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
setTimeout(() => {
|
||||
this.checkConnection()
|
||||
this.checkConnection();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// 便捷方法用于添加事件监听器
|
||||
on(event: string, callback: EventListener) {
|
||||
this.addEventListener(event, callback)
|
||||
this.addEventListener(event, callback);
|
||||
}
|
||||
|
||||
// 便捷方法用于移除事件监听器
|
||||
off(event: string, callback: EventListener) {
|
||||
this.removeEventListener(event, callback)
|
||||
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
|
||||
console.error("handleMessage recive error", data.data);
|
||||
return;
|
||||
}
|
||||
let resData: Message = {
|
||||
type: MessageType.error,
|
||||
data: null,
|
||||
id: data.id
|
||||
}
|
||||
id: data.id,
|
||||
};
|
||||
try {
|
||||
// 权限校验
|
||||
/** 如果通过权限校验,则permissionNo为null */
|
||||
let permissionNo: Permission | null = null;
|
||||
for (const [permission, allowed] of Object.entries(getPermissionSet())) {
|
||||
if (PermissionLimit[permission as Permission].includes(data.type)) {
|
||||
const limit = PermissionLimit[
|
||||
permission as Permission
|
||||
] as MessageType[];
|
||||
if (limit instanceof Array && limit.includes(data.type)) {
|
||||
if (allowed) {
|
||||
permissionNo = null;
|
||||
break;
|
||||
@ -186,183 +451,252 @@ class Peer extends EventTarget {
|
||||
}
|
||||
}
|
||||
if (permissionNo) {
|
||||
throw new Error(`没有权限执行该操作:${data.type},请检查权限${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 '没有粘贴板权限或窗口未聚焦,无法复制'
|
||||
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);
|
||||
file = (await fileMgrInstance.getRootFile()).getFileInfo(
|
||||
remoteD.path,
|
||||
);
|
||||
if (file) {
|
||||
await file.loadLocalDirectory()
|
||||
const json = file.toJson()
|
||||
resData.data = json
|
||||
await file.loadLocalDirectory();
|
||||
const json = file.toJson();
|
||||
resData.data = json;
|
||||
} else {
|
||||
resData.data = null
|
||||
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()
|
||||
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()
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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()
|
||||
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}设置`)
|
||||
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
|
||||
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({
|
||||
this.send(
|
||||
{
|
||||
type: MessageType.response_getFile,
|
||||
data: {
|
||||
sendType: 'chunk'
|
||||
sendType: "chunk",
|
||||
},
|
||||
id: data.id
|
||||
}, conn, true)
|
||||
id: data.id,
|
||||
},
|
||||
conn,
|
||||
true,
|
||||
);
|
||||
//分片传输
|
||||
if (remoteD.preView) {
|
||||
if (fileData.size < NEED_CHUNK_FILE_SIZE_PREVIEW) {
|
||||
await file.sendFileChunk(conn, remoteD.preView)
|
||||
await file.sendFileChunk(conn, remoteD.preView);
|
||||
}
|
||||
} else {
|
||||
await file.sendFileChunk(conn, false, remoteD.savePath)
|
||||
await file.sendFileChunk(conn, false, remoteD.savePath);
|
||||
}
|
||||
fileData.buffer = null;
|
||||
} else {
|
||||
//直接传输
|
||||
file.getTransfer(conn).addTask(new TransferTask(fileData, file)).updateFileData(fileData, TransferStatus.COMPLETED)
|
||||
file
|
||||
.getTransfer(conn)
|
||||
.addTask(new TransferTask(fileData, file))
|
||||
.updateFileData(fileData, TransferStatus.COMPLETED);
|
||||
}
|
||||
resData.data = fileData
|
||||
resData.data = fileData;
|
||||
break;
|
||||
case MessageType.push_file_chunk:
|
||||
resData.type = MessageType.response_push_file_chunk
|
||||
let fData = remoteD as FileData
|
||||
resData.type = MessageType.response_push_file_chunk;
|
||||
resData.data = "ok";
|
||||
// 先回复确认,再异步处理文件I/O,避免阻塞消息循环
|
||||
let fData = remoteD as FileData;
|
||||
if (fData.preView) {
|
||||
await fileMgrInstance.remoteRootFile.getFileInfo(fData.path).getTransfer(conn).receiveFile(fData, true)
|
||||
fileMgrInstance.remoteRootFile
|
||||
.getFileInfo(fData.path)
|
||||
.getTransfer(conn)
|
||||
.receiveFile(fData, true)
|
||||
.catch((err) => {
|
||||
console.error("receiveFile preview error", err);
|
||||
});
|
||||
} else {
|
||||
await (await fileMgrInstance.getRootFile()).getTransfer(conn).receiveFile(fData)
|
||||
(await fileMgrInstance.getRootFile())
|
||||
.getTransfer(conn)
|
||||
.receiveFile(fData)
|
||||
.catch((err) => {
|
||||
console.error("receiveFile error", err);
|
||||
});
|
||||
}
|
||||
resData.data = 'ok'
|
||||
break;
|
||||
case MessageType.push_file_complete:
|
||||
resData.type = MessageType.response_push_file_complete
|
||||
console.log('push_file_complete', remoteD)
|
||||
resData.type = MessageType.response_push_file_complete;
|
||||
console.log("push_file_complete", remoteD);
|
||||
if (remoteD.preView) {
|
||||
notification.success({
|
||||
message: '文件缓存建立完成',
|
||||
})
|
||||
message: "文件缓存建立完成",
|
||||
});
|
||||
} else {
|
||||
notification.success({
|
||||
message: '文件流式传输完成',
|
||||
description: remoteD.name
|
||||
})
|
||||
message: "文件流式传输完成",
|
||||
description: remoteD.name,
|
||||
});
|
||||
}
|
||||
resData.data = "ok";
|
||||
break;
|
||||
case MessageType.end_call:
|
||||
case MessageType.end_desktop:
|
||||
if (remoteD?.peerId) {
|
||||
this.endMediaConnection(remoteD.peerId);
|
||||
}
|
||||
resData.data = 'ok'
|
||||
break;
|
||||
default:
|
||||
resData.type = MessageType.error
|
||||
resData.data = '未知消息类型'
|
||||
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)
|
||||
console.error("handleMessage error", err);
|
||||
resData.type = MessageType.error;
|
||||
resData.data = err.message || JSON.stringify(err);
|
||||
}
|
||||
this.send(resData, conn, true)
|
||||
this.send(resData, conn, true);
|
||||
}
|
||||
}
|
||||
|
||||
export const peer = new Peer(randomChars(6))
|
||||
export const peer = new Peer(randomChars(6));
|
||||
export interface Message {
|
||||
type: MessageType
|
||||
data: any
|
||||
id?: string //uuid
|
||||
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',
|
||||
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',
|
||||
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.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 "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);
|
||||
});
|
||||
}
|
||||
BIN
static/desktop.png
Normal file
BIN
static/desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 517 B |
BIN
static/phone.png
Normal file
BIN
static/phone.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 876 B |
@ -4,6 +4,7 @@ import vue from '@vitejs/plugin-vue'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
build: {
|
||||
// 开发阶段启用源码映射:https://uniapp.dcloud.net.cn/tutorial/migration-to-vue3.html#需主动开启-sourcemap
|
||||
sourcemap: process.env.NODE_ENV === 'development',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user