新增desktop 新增修复速率

This commit is contained in:
kura 2026-05-07 17:10:16 +08:00
parent e4702b877f
commit f1c9f018a4
8 changed files with 1324 additions and 647 deletions

View File

@ -1,4 +1,5 @@
<template> <template>
<DesktopView :peerId="sign2peerid(targetId)" />
<div class="container"> <div class="container">
<!-- 连接状态 --> <!-- 连接状态 -->
<div class="status-bar"> <div class="status-bar">
@ -20,6 +21,14 @@
</div> </div>
<div class="connect-section"> <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 <input
v-model="targetId" v-model="targetId"
:placeholder="!myId ? '请稍后...' : '输入对方ID'" :placeholder="!myId ? '请稍后...' : '输入对方ID'"
@ -79,11 +88,13 @@ import FileView from "./item/fileView.vue";
import { notification } from "ant-design-vue"; import { notification } from "ant-design-vue";
import { fileMgrInstance, type FileData } from "./utils/fileMgr"; import { fileMgrInstance, type FileData } from "./utils/fileMgr";
import { Button } from "ant-design-vue"; import { Button } from "ant-design-vue";
import DesktopView from "./item/desptopView.vue";
import { import {
formatSize, formatSize,
getUrlParam, getUrlParam,
localCurrentFile, localCurrentFile,
remoteCurrentFile, remoteCurrentFile,
sign2peerid,
} from "./utils/common"; } from "./utils/common";
import FileTransferView from "./item/fileTranserView.vue"; import FileTransferView from "./item/fileTranserView.vue";
@ -99,6 +110,13 @@ const selectedRemoteFiles = ref<string[]>([]);
onMounted(() => { onMounted(() => {
isPhone.value = document.body.clientWidth < 768; isPhone.value = document.body.clientWidth < 768;
}); });
const requestDesktop = () => {
peer.requestDesktop(targetId.value);
};
const requestCall = () => {
peer.requestCall(targetId.value);
};
const shareUrl = async () => { const shareUrl = async () => {
if (myId.value) { if (myId.value) {
const url = const url =
@ -252,6 +270,24 @@ onMounted(() => {
align-items: center; align-items: center;
gap: 10px; 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 { .connect-section {
display: flex; display: flex;

View 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>

View File

@ -1,6 +1,7 @@
import { type Ref, ref } from "vue"; import { type Ref, ref } from "vue";
import { FileInfo } from "./fileMgr"; import { FileInfo } from "./fileMgr";
import { Permission } from "./peer"; import { Permission } from "./peer";
import { Modal } from "ant-design-vue";
export const formatSize = (size: number): string => { export const formatSize = (size: number): string => {
if (size < 1024) return `${size} B`; if (size < 1024) return `${size} B`;
@ -18,6 +19,22 @@ export const base64ToString = (base64: string) => {
//base64转汉字 //base64转汉字
return decodeURIComponent(atob(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 commonCharsCN = '的一是在不了有和人这中大为上个国我以要他时来用们生到作地于出就分对成会可主发年动同工也能下过子说产种面而方后多点行学法所民得经十三之进着等部度家电力里如水化高自二理起小物现实加量都两体制机当西从广业本去把性好应开它合还因由其些然前外天政四日那社义事平形相全表间样与关各重新线内数正心反你明看原又么利比或但质气第向道命此变条只没结解问意建月公无系军很情者最立代想已通并提直题党程展五果料象员革位入常文总次品式活设及管特件长求老头基资边流路级少图山统接知较将组见计别她手角期根论运农指几九区强放决皮被干做必战先回则任取据处队南给色光门即保治北造百规热领七海口东导器压志世金增争济阶油思术极交受联什认六共权收证改清己美再采转更单风切打白教速花带安场身车例真务具万每目至达走积示议声报斗完类八离华名确才科张信马节话米整空元况今集温传土许步群广石记需段研界拉林律叫且究观越织装影算低持音众书布复容儿须际商非验连断深难近矿千周委素技备半办青省列习响约支般史感劳便团往酸历市克何除消构府称太准精值号率族维划选标写存候毛亲快效斯院查江型眼王按格养易置派层片始却专状育厂京识适属圆包火住调满县局照参红细引听该冯价严龙飞';
const commonCharsEN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; const commonCharsEN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
export const randomChars = (num: number = 3, chars: string = commonCharsEN) => { export const randomChars = (num: number = 3, chars: string = commonCharsEN) => {
@ -51,6 +68,8 @@ export function getPermissionSet(): { [key in Permission]: boolean } {
edit: false, edit: false,
view: true, view: true,
download: true, download: true,
desktop: true,
call: false
}; };
} }
return permissionSet; return permissionSet;

View File

@ -23,12 +23,16 @@ export interface TransferProgress {
} }
// 分片配置 // 分片配置
const CHUNK_SIZE = 64 * 1024; // 64KB const CHUNK_SIZE = 256 * 1024; // 256KB (从64KB提升,减少消息数量)
const MAX_CHUNK_SIZE = 200 * 1024; // 200KB const MAX_CHUNK_SIZE = 256 * 1024; // 256KB (WebRTC SCTP 最大安全消息大小)
//多大的文件需要分片 //多大的文件需要分片
export const NEED_CHUNK_FILE_SIZE = 200 * 1024; // 200KB export const NEED_CHUNK_FILE_SIZE = 200 * 1024; // 200KB
export const NEED_CHUNK_FILE_SIZE_PREVIEW = 50 * 1024 * 1024; // 50MB 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 { export class FileTransfer {
public conn: DataConnection; public conn: DataConnection;
private file: FileInfo; private file: FileInfo;
@ -46,12 +50,16 @@ export class FileTransfer {
public getFile(): FileInfo { public getFile(): FileInfo {
return this.file; 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.conn = conn;
this.file = file; this.file = file;
this.chunkSize = Math.min(chunkSize, MAX_CHUNK_SIZE); this.chunkSize = Math.min(chunkSize, MAX_CHUNK_SIZE);
file.addTransfer(this) file.addTransfer(this);
fileTransferMgrInstance.addFileTransfer(this) fileTransferMgrInstance.addFileTransfer(this);
} }
public init(preView: boolean = false) { public init(preView: boolean = false) {
this.clear(); this.clear();
@ -84,7 +92,10 @@ export class FileTransfer {
private getProgress(): TransferProgress { private getProgress(): TransferProgress {
const now = Date.now(); const now = Date.now();
const timeElapsed = (now - this.startTime) / 1000; // 转换为秒 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; let totalSize = this.fileData?.chunkData?.totalSize || this.file.size;
const progress: TransferProgress = { const progress: TransferProgress = {
transferredSize: this.transferredSize, transferredSize: this.transferredSize,
@ -93,7 +104,7 @@ export class FileTransfer {
status: this.status, status: this.status,
percent: (this.transferredSize / totalSize) * 100, percent: (this.transferredSize / totalSize) * 100,
costTime: timeElapsed, costTime: timeElapsed,
updateTime: now updateTime: now,
}; };
// 更新上次传输大小和开始时间 // 更新上次传输大小和开始时间
@ -101,27 +112,30 @@ export class FileTransfer {
this.startTime = now; this.startTime = now;
return progress; return progress;
} }
public fileData: FileData public fileData: FileData;
public offset: number = 0; public offset: number = 0;
public totalSize: number = 0; public totalSize: number = 0;
private fileBuffer: ArrayBuffer | null = null; private fileBuffer: ArrayBuffer | null = null;
// 发送文件 // 发送文件 (流水线模式 - 不再逐片等待确认)
public async sendFile(savePath: string = ''): Promise<boolean> { public async sendFile(savePath: string = ""): Promise<boolean> {
try { try {
if (this.status == TransferStatus.WAITING) { if (this.status == TransferStatus.WAITING) {
this.startTime = Date.now(); 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.totalSize = this.fileData.size;
this.fileBuffer = this.fileData.buffer; this.fileBuffer = this.fileData.buffer;
this.fileData.preView = this.preView; this.fileData.preView = this.preView;
this.fileData.savePath = savePath; this.fileData.savePath = savePath;
this.fileData.buffer = null; // 移除完整buffer,只传分片
this.addTask(new TransferTask(this.fileData, this.file)); this.addTask(new TransferTask(this.fileData, this.file));
this.status = TransferStatus.SENDING; this.status = TransferStatus.SENDING;
this.updateProgress(); 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) { if (this.pausePromise) {
this.status = TransferStatus.PAUSED; this.status = TransferStatus.PAUSED;
this.updateProgress(); this.updateProgress();
@ -129,59 +143,70 @@ export class FileTransfer {
this.status = TransferStatus.SENDING; this.status = TransferStatus.SENDING;
} }
// 发送分片 const end = Math.min(this.offset + this.chunkSize, this.totalSize);
const chunk = this.fileBuffer.slice(this.offset, this.offset + this.chunkSize); const chunk = this.fileBuffer.slice(this.offset, end);
this.fileData.buffer = null;
this.fileData.chunkData = { this.fileData.chunkData = {
offset: this.offset, offset: this.offset,
totalSize: this.totalSize, totalSize: this.totalSize,
buffer: chunk buffer: chunk,
} };
return this.sendFileChunk(this.fileData).then((): Promise<boolean> => {
this.offset += chunk.byteLength; // 发送分片 (fire-and-forget,不等确认)
peer.send(
{
type: MessageType.push_file_chunk,
data: this.fileData,
},
this.conn,
true,
);
this.offset = end;
this.transferredSize = this.offset; this.transferredSize = this.offset;
if (this.offset >= this.totalSize) { this.updateProgress();
return this.sendFileComplete();
// 基于 bufferedAmount 的流控: 缓冲过大时等待排空
if (dc && dc.bufferedAmount > MAX_BUFFERED_AMOUNT) {
await new Promise<void>((resolve) => {
const checkBuffer = () => {
if (dc.bufferedAmount < DRAIN_THRESHOLD || this.aborted) {
resolve();
} else { } else {
return this.sendFile(); requestAnimationFrame(checkBuffer);
} }
};
checkBuffer();
}); });
} }
}
if (!this.aborted) { if (!this.aborted) {
// 仅等待最终完成确认
await peer.send(
{
type: MessageType.push_file_complete,
data: this.fileData,
},
this.conn,
);
this.status = TransferStatus.COMPLETED; this.status = TransferStatus.COMPLETED;
this.updateProgress(); this.updateProgress();
return true; return true;
} else {
return false;
} }
return false;
} catch (error) { } catch (error) {
this.status = TransferStatus.ERROR; this.status = TransferStatus.ERROR;
this.updateProgress(); this.updateProgress();
throw error; 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 { try {
let task = this.getTask(fData) let task = this.getTask(fData);
if (!task) { if (!task) {
this.addTask(new TransferTask(fData, this.file)); this.addTask(new TransferTask(fData, this.file));
} else { } else {
@ -191,21 +216,32 @@ export class FileTransfer {
this.transferredSize = fData.chunkData.offset; this.transferredSize = fData.chunkData.offset;
this.startTime = Date.now(); this.startTime = Date.now();
this.totalSize = fData.chunkData.totalSize; 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; this.status = TransferStatus.COMPLETED;
} }
if (preView) { 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 { } else {
const path = fData.savePath + '/' + fData.name; const path = fData.savePath + "/" + fData.name;
await this.file.createFile(path, fData.chunkData.buffer, fData.chunkData.offset); await this.file.createFile(
path,
fData.chunkData.buffer,
fData.chunkData.offset,
);
// if (this.status == TransferStatus.COMPLETED) { // if (this.status == TransferStatus.COMPLETED) {
// await this.file.renameFile(path, fData.savePath + '/' + fData.name); // await this.file.renameFile(path, fData.savePath + '/' + fData.name);
// } // }
} }
this.updateProgress(fData); this.updateProgress(fData);
return return;
} catch (error) { } catch (error) {
this.status = TransferStatus.ERROR; this.status = TransferStatus.ERROR;
this.updateProgress(fData); this.updateProgress(fData);
@ -215,8 +251,11 @@ export class FileTransfer {
// 暂停传输 // 暂停传输
public pause() { public pause() {
if (this.status === TransferStatus.SENDING || this.status === TransferStatus.RECEIVING) { if (
this.pausePromise = new Promise(resolve => { this.status === TransferStatus.SENDING ||
this.status === TransferStatus.RECEIVING
) {
this.pausePromise = new Promise((resolve) => {
this.pauseResolve = resolve; this.pauseResolve = resolve;
}); });
} }
@ -252,19 +291,21 @@ export class FileTransfer {
return task; return task;
} }
public getTasks() { public getTasks() {
this.tasks.forEach(task => task.updateProgress()); this.tasks.forEach((task) => task.updateProgress());
return this.tasks; return this.tasks;
} }
public updateTask(fData: FileData) { 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); task.updateFileData(fData, this.status);
} }
public getTask(fData: FileData) { 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() { public clearCompletedTasks() {
this.tasks = this.tasks.filter(t => t.status !== TransferStatus.COMPLETED); this.tasks = this.tasks.filter(
(t) => t.status !== TransferStatus.COMPLETED,
);
} }
} }
class FileTransferMgr { class FileTransferMgr {
@ -272,7 +313,6 @@ class FileTransferMgr {
public addFileTransfer(transfer: FileTransfer) { public addFileTransfer(transfer: FileTransfer) {
transfer.onProgress(this.notifyTransferChanged.bind(this)); transfer.onProgress(this.notifyTransferChanged.bind(this));
this.fileTransfers.set(transfer.getFile().path, transfer); this.fileTransfers.set(transfer.getFile().path, transfer);
} }
public getFileTransfer(path: string) { public getFileTransfer(path: string) {
return this.fileTransfers.get(path); return this.fileTransfers.get(path);
@ -281,18 +321,20 @@ class FileTransferMgr {
return Array.from(this.fileTransfers.values()); return Array.from(this.fileTransfers.values());
} }
// 传输进度变化回调 // 传输进度变化回调
private onTransferChangedHandler: ((transfer: FileTransfer) => void)[] = [] private onTransferChangedHandler: ((transfer: FileTransfer) => void)[] = [];
public onTransferChanged(handler: (transfer: FileTransfer) => void) { public onTransferChanged(handler: (transfer: FileTransfer) => void) {
this.onTransferChangedHandler.push(handler); this.onTransferChangedHandler.push(handler);
} }
public removeTransferChangedHandler(handler: (transfer: FileTransfer) => void) { public removeTransferChangedHandler(
this.onTransferChangedHandler = this.onTransferChangedHandler.filter(h => h !== handler); handler: (transfer: FileTransfer) => void,
) {
this.onTransferChangedHandler = this.onTransferChangedHandler.filter(
(h) => h !== handler,
);
} }
public notifyTransferChanged(transfer: FileTransfer) { public notifyTransferChanged(transfer: FileTransfer) {
this.onTransferChangedHandler.forEach(handler => handler(transfer)); this.onTransferChangedHandler.forEach((handler) => handler(transfer));
} }
} }
export class TransferTask { export class TransferTask {
//传输文件数据 //传输文件数据
@ -311,14 +353,17 @@ export class TransferTask {
status: TransferStatus.WAITING, status: TransferStatus.WAITING,
percent: 0, percent: 0,
costTime: 0, costTime: 0,
updateTime: 0 updateTime: 0,
}; };
constructor(fileData: FileData, file: FileInfo) { constructor(fileData: FileData, file: FileInfo) {
this.fileData = fileData; this.fileData = fileData;
this.file = file; this.file = file;
this.startTime = Date.now(); this.startTime = Date.now();
} }
public updateFileData(fileData: FileData, status: TransferStatus = TransferStatus.SENDING) { public updateFileData(
fileData: FileData,
status: TransferStatus = TransferStatus.SENDING,
) {
this.fileData = fileData; this.fileData = fileData;
this.status = status; this.status = status;
this.updateProgress(); this.updateProgress();
@ -329,10 +374,20 @@ export class TransferTask {
return this.progress; return this.progress;
} }
const totalSize = this.fileData.chunkData?.totalSize || this.fileData.size; const totalSize = this.fileData.chunkData?.totalSize || this.fileData.size;
const transferredSize = this.fileData.chunkData?.offset || this.fileData.size; const transferredSize =
const speed = this.fileData.chunkData ? (transferredSize + this.fileData.chunkData.buffer.byteLength) / (Date.now() - this.startTime) * 1000 : this.fileData.size / (Date.now() - this.startTime) * 1000; this.fileData.chunkData?.offset || this.fileData.size;
const percent = this.fileData.chunkData ? (transferredSize + this.fileData.chunkData.buffer.byteLength) / totalSize * 100 : transferredSize / totalSize * 100; const speed = this.fileData.chunkData
this.status = transferredSize >= totalSize ? TransferStatus.COMPLETED : this.status; ? ((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 = { this.progress = {
transferredSize: transferredSize, transferredSize: transferredSize,
totalSize: totalSize, totalSize: totalSize,
@ -340,9 +395,9 @@ export class TransferTask {
status: this.status, status: this.status,
percent: percent, percent: percent,
costTime: Date.now() - this.startTime, costTime: Date.now() - this.startTime,
updateTime: Date.now() updateTime: Date.now(),
} };
return this.progress; return this.progress;
} }
} }
export const fileTransferMgrInstance = new FileTransferMgr() export const fileTransferMgrInstance = new FileTransferMgr();

View File

@ -1,128 +1,329 @@
import { Peer as PeerJs } from 'peerjs' import { Peer as PeerJs } from "peerjs";
import type { DataConnection } from 'peerjs' import type { DataConnection, MediaConnection } from "peerjs";
import { fileMgrInstance, type FileData, type FileInfo } from './fileMgr' import { fileMgrInstance, type FileData, type FileInfo } from "./fileMgr";
import { NEED_CHUNK_FILE_SIZE, NEED_CHUNK_FILE_SIZE_PREVIEW, TransferStatus, TransferTask } from './fileTransfer' import {
import { notification } from 'ant-design-vue' NEED_CHUNK_FILE_SIZE,
import { randomChars, sign2peerid, getPermissionSet } from './common' 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; const SEND_TIMEOUT = 10000;
class Peer extends EventTarget { 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
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) { constructor(sign: string) {
super() super();
this.sign = sign this.sign = sign;
} }
public init() { public init() {
this.peer = new PeerJs(sign2peerid(this.sign)) this.peer = new PeerJs(sign2peerid(this.sign), {
this.peer.on('open', (id) => { config: {
console.log('peer open', id) iceServers: [{ urls: "stun:8.134.35.244:3478" }],
this.id = id },
this.dispatchEvent(new CustomEvent('open', { detail: 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.on('connection', (conn) => { this.peer.on("connection", (conn) => {
console.log('peer connection', conn) console.log("peer connection", conn);
this.setupConnection(conn) this.setupConnection(conn);
}) });
this.peer.on('error', (err) => { this.peer.on("error", (err) => {
console.log('peer error', err) console.log("peer error", err);
this.dispatchEvent(new CustomEvent('error', { detail: err })) this.dispatchEvent(new CustomEvent("error", { detail: err }));
}) });
this.peer.on('close', () => { this.peer.on("close", () => {
console.log('peer close') console.log("peer close");
this.dispatchEvent(new CustomEvent('close')) this.dispatchEvent(new CustomEvent("close"));
}) });
this.peer.on('disconnected', () => { this.peer.on("disconnected", () => {
console.log('peer disconnected') console.log("peer disconnected");
this.dispatchEvent(new CustomEvent('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) { private setupConnection(conn: DataConnection) {
this.connections.set(conn.peer, conn) this.connections.set(conn.peer, conn);
conn.on('data', (data: unknown) => {
const typedData = data as Message conn.on("data", (data: unknown) => {
const typedData = data as Message;
if (this.callbackMap.has(typedData.id)) { if (this.callbackMap.has(typedData.id)) {
this.callbackMap.get(typedData.id)?.(typedData) this.callbackMap.get(typedData.id)?.(typedData);
return return;
} }
this.handleMessage(typedData, conn) this.handleMessage(typedData, conn);
this.dispatchEvent(new CustomEvent(typedData.type, { this.dispatchEvent(
detail: { peerId: conn.peer, data: typedData } 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("open", () => {
}) this.dispatchEvent(
conn.on('close', () => { new CustomEvent("connection-open", {
this.connections.delete(conn.peer) detail: { peer: conn.peer, conn: conn },
this.dispatchEvent(new CustomEvent('peer-disconnected', { }),
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) { connect(id: string) {
const conn = this.peer.connect(sign2peerid(id)) const conn = this.peer.connect(sign2peerid(id));
this.setupConnection(conn) this.setupConnection(conn);
this.remoteConnection = conn this.remoteConnection = conn;
this.checkConnection() this.checkConnection();
return conn return conn;
} }
private callbackMap: Map<string, (data: any) => void> = new Map()
async send(data: Message, conn: DataConnection = this.remoteConnection, isHandleResponse: boolean = false): Promise<any> { async requestDesktop(id: string) {
data.id = data.id || uuidv4() 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) { if (!conn) {
notification.error({ notification.error({
message: '请创建连接', message: "请创建连接",
}) });
return Promise.reject('连接不存在') return Promise.reject("连接不存在");
} }
conn.send(data, true) conn.send(data, true);
if (isHandleResponse) { if (isHandleResponse) {
return Promise.resolve(data.data) return Promise.resolve(data.data);
} else { } else {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
this.callbackMap.delete(data.id) this.callbackMap.delete(data.id);
notification.error({ notification.error({
message: '返回超时', message: "返回超时",
description: '对方可能已离线或网络异常' description: "对方可能已离线或网络异常",
}) });
reject(new Error('返回超时')) reject(new Error("返回超时"));
}, SEND_TIMEOUT) }, SEND_TIMEOUT);
this.callbackMap.set(data.id, (data: Message) => { this.callbackMap.set(data.id, (data: Message) => {
clearTimeout(timeoutId) clearTimeout(timeoutId);
if (data.type === MessageType.response_getFile && data.data.sendType == 'chunk') { if (
data.type === MessageType.response_getFile &&
data.data.sendType == "chunk"
) {
//分片传输 特殊处理 可以长时间等待进度 //分片传输 特殊处理 可以长时间等待进度
return return;
} }
if (data.type === MessageType.error) { if (data.type === MessageType.error) {
reject(data.data) reject(data.data);
} else { } 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; public transpackNum: number = 0;
private checkConnection() { private checkConnection() {
if (!this.remoteConnection) { if (!this.remoteConnection) {
return return;
} }
const rtcp: RTCPeerConnection = this.remoteConnection.peerConnection; const rtcp: RTCPeerConnection = this.remoteConnection.peerConnection;
rtcp.getStats().then((stats) => { rtcp.getStats().then((stats) => {
@ -142,41 +343,105 @@ class Peer extends EventTarget {
//包数 //包数
this.transpackNum = stat.messagesReceived + stat.messagesSent; this.transpackNum = stat.messagesReceived + stat.messagesSent;
} }
}) });
}) });
setTimeout(() => { setTimeout(() => {
this.checkConnection() this.checkConnection();
}, 1000); }, 1000);
} }
// 便捷方法用于添加事件监听器 // 便捷方法用于添加事件监听器
on(event: string, callback: EventListener) { on(event: string, callback: EventListener) {
this.addEventListener(event, callback) this.addEventListener(event, callback);
} }
// 便捷方法用于移除事件监听器 // 便捷方法用于移除事件监听器
off(event: string, callback: EventListener) { 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) { async handleMessage(data: Message, conn: DataConnection) {
const remoteD = data.data; const remoteD = data.data;
let file: FileInfo | null = null; let file: FileInfo | null = null;
if (data.type == MessageType.error) { if (data.type == MessageType.error) {
console.error('handleMessage recive error', data.data) console.error("handleMessage recive error", data.data);
return return;
} }
let resData: Message = { let resData: Message = {
type: MessageType.error, type: MessageType.error,
data: null, data: null,
id: data.id id: data.id,
} };
try { try {
// 权限校验 // 权限校验
/** 如果通过权限校验则permissionNo为null */ /** 如果通过权限校验则permissionNo为null */
let permissionNo: Permission | null = null; let permissionNo: Permission | null = null;
for (const [permission, allowed] of Object.entries(getPermissionSet())) { 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) { if (allowed) {
permissionNo = null; permissionNo = null;
break; break;
@ -186,183 +451,252 @@ class Peer extends EventTarget {
} }
} }
if (permissionNo) { if (permissionNo) {
throw new Error(`没有权限执行该操作:${data.type},请检查权限${permissionNo}设置`) throw new Error(
`没有权限执行该操作:${data.type},请检查权限${permissionNo}设置`,
);
} }
switch (data.type) { switch (data.type) {
case MessageType.request_copyClipboard: case MessageType.request_copyClipboard:
resData.type = MessageType.response_copyClipboard; resData.type = MessageType.response_copyClipboard;
//读取粘贴板数据 //读取粘贴板数据
resData.data = await navigator.clipboard.readText().then(text => { resData.data = await navigator.clipboard
return text .readText()
}).catch(err => { .then((text) => {
return '没有粘贴板权限或窗口未聚焦,无法复制' return text;
})
.catch((err) => {
return "没有粘贴板权限或窗口未聚焦,无法复制";
}); });
break; break;
case MessageType.request_fileInfo: case MessageType.request_fileInfo:
resData.type = MessageType.response_fileInfo; resData.type = MessageType.response_fileInfo;
file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path); file = (await fileMgrInstance.getRootFile()).getFileInfo(
remoteD.path,
);
if (file) { if (file) {
await file.loadLocalDirectory() await file.loadLocalDirectory();
const json = file.toJson() const json = file.toJson();
resData.data = json resData.data = json;
} else { } else {
resData.data = null resData.data = null;
} }
break; break;
case MessageType.request_fileSize: case MessageType.request_fileSize:
resData.type = MessageType.response_fileSize resData.type = MessageType.response_fileSize;
file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path) file = (await fileMgrInstance.getRootFile()).getFileInfo(
resData.data = await file.getFileSize() remoteD.path,
);
resData.data = await file.getFileSize();
break; break;
case MessageType.request_deleteFile: case MessageType.request_deleteFile:
resData.type = MessageType.response_deleteFile resData.type = MessageType.response_deleteFile;
file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path) file = (await fileMgrInstance.getRootFile()).getFileInfo(
await file.deleteItself() remoteD.path,
);
await file.deleteItself();
break; break;
case MessageType.request_renameFile: case MessageType.request_renameFile:
resData.type = MessageType.response_renameFile resData.type = MessageType.response_renameFile;
file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path) file = (await fileMgrInstance.getRootFile()).getFileInfo(
await file.renameFile(remoteD.newName) remoteD.path,
);
await file.renameFile(remoteD.newName);
break; break;
case MessageType.request_createDirectory: case MessageType.request_createDirectory:
resData.type = MessageType.response_createDirectory resData.type = MessageType.response_createDirectory;
file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path) file = (await fileMgrInstance.getRootFile()).getFileInfo(
await file.createDirectory(remoteD.name) remoteD.path,
);
await file.createDirectory(remoteD.name);
break; break;
case MessageType.request_createFile: case MessageType.request_createFile:
resData.type = MessageType.response_createFile resData.type = MessageType.response_createFile;
file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path) file = (await fileMgrInstance.getRootFile()).getFileInfo(
await file.createFile(remoteD.name, remoteD.buffer) remoteD.path,
);
await file.createFile(remoteD.name, remoteD.buffer);
break; break;
case MessageType.request_saveFile: case MessageType.request_saveFile:
resData.type = MessageType.response_saveFile resData.type = MessageType.response_saveFile;
file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path) file = (await fileMgrInstance.getRootFile()).getFileInfo(
await file.saveFile(remoteD.buffer) remoteD.path,
);
await file.saveFile(remoteD.buffer);
break; break;
case MessageType.request_getFileLastModified: case MessageType.request_getFileLastModified:
resData.type = MessageType.response_getFileLastModified resData.type = MessageType.response_getFileLastModified;
file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path) file = (await fileMgrInstance.getRootFile()).getFileInfo(
resData.data = await file.getFileLastModified() remoteD.path,
);
resData.data = await file.getFileLastModified();
break; break;
case MessageType.request_getFile: case MessageType.request_getFile:
if (!remoteD.preView) { if (!remoteD.preView) {
//特殊处理下载权限 //特殊处理下载权限
if (!getPermissionSet()[Permission.download]) { if (!getPermissionSet()[Permission.download]) {
throw new Error(`没有权限执行该操作:${data.type},请检查权限${Permission.download}设置`) throw new Error(
`没有权限执行该操作:${data.type},请检查权限${Permission.download}设置`,
);
} }
} }
resData.type = MessageType.response_getFile resData.type = MessageType.response_getFile;
file = (await fileMgrInstance.getRootFile()).getFileInfo(remoteD.path) file = (await fileMgrInstance.getRootFile()).getFileInfo(
let fileData = await file.getFile(remoteD.preView) as FileData remoteD.path,
);
let fileData = (await file.getFile(remoteD.preView)) as FileData;
if (fileData.size > NEED_CHUNK_FILE_SIZE) { if (fileData.size > NEED_CHUNK_FILE_SIZE) {
this.send({ this.send(
{
type: MessageType.response_getFile, type: MessageType.response_getFile,
data: { data: {
sendType: 'chunk' sendType: "chunk",
}, },
id: data.id id: data.id,
}, conn, true) },
conn,
true,
);
//分片传输 //分片传输
if (remoteD.preView) { if (remoteD.preView) {
if (fileData.size < NEED_CHUNK_FILE_SIZE_PREVIEW) { if (fileData.size < NEED_CHUNK_FILE_SIZE_PREVIEW) {
await file.sendFileChunk(conn, remoteD.preView) await file.sendFileChunk(conn, remoteD.preView);
} }
} else { } else {
await file.sendFileChunk(conn, false, remoteD.savePath) await file.sendFileChunk(conn, false, remoteD.savePath);
} }
fileData.buffer = null; fileData.buffer = null;
} else { } 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; break;
case MessageType.push_file_chunk: case MessageType.push_file_chunk:
resData.type = MessageType.response_push_file_chunk resData.type = MessageType.response_push_file_chunk;
let fData = remoteD as FileData resData.data = "ok";
// 先回复确认,再异步处理文件I/O,避免阻塞消息循环
let fData = remoteD as FileData;
if (fData.preView) { 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 { } 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; break;
case MessageType.push_file_complete: case MessageType.push_file_complete:
resData.type = MessageType.response_push_file_complete resData.type = MessageType.response_push_file_complete;
console.log('push_file_complete', remoteD) console.log("push_file_complete", remoteD);
if (remoteD.preView) { if (remoteD.preView) {
notification.success({ notification.success({
message: '文件缓存建立完成', message: "文件缓存建立完成",
}) });
} else { } else {
notification.success({ notification.success({
message: '文件流式传输完成', message: "文件流式传输完成",
description: remoteD.name 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; break;
default: default:
resData.type = MessageType.error resData.type = MessageType.error;
resData.data = '未知消息类型' resData.data = "未知消息类型";
break; break;
} }
} catch (err: any) { } catch (err: any) {
console.error('handleMessage error', err) console.error("handleMessage error", err);
resData.type = MessageType.error resData.type = MessageType.error;
resData.data = err.message || JSON.stringify(err) 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 { export interface Message {
type: MessageType type: MessageType;
data: any data: any;
id?: string //uuid id?: string; //uuid
} }
export enum MessageType { export enum MessageType {
request_copyClipboard = 'request_copyClipboard', request_copyClipboard = "request_copyClipboard",
response_copyClipboard = 'response_copyClipboard', response_copyClipboard = "response_copyClipboard",
request_getFile = 'request_getFile', request_getFile = "request_getFile",
response_getFile = 'response_getFile', response_getFile = "response_getFile",
request_getFileLastModified = 'request_getFileLastModified', request_getFileLastModified = "request_getFileLastModified",
response_getFileLastModified = 'response_getFileLastModified', response_getFileLastModified = "response_getFileLastModified",
request_saveFile = 'request_saveFile', request_saveFile = "request_saveFile",
response_saveFile = 'response_saveFile', response_saveFile = "response_saveFile",
request_createFile = 'request_createFile', request_createFile = "request_createFile",
response_createFile = 'response_createFile', response_createFile = "response_createFile",
request_createDirectory = 'request_createDirectory', request_createDirectory = "request_createDirectory",
response_createDirectory = 'response_createDirectory', response_createDirectory = "response_createDirectory",
request_renameFile = 'request_renameFile', request_renameFile = "request_renameFile",
response_renameFile = 'response_renameFile', response_renameFile = "response_renameFile",
request_deleteFile = 'request_deleteFile', request_deleteFile = "request_deleteFile",
response_deleteFile = 'response_deleteFile', response_deleteFile = "response_deleteFile",
error = 'handle_error', error = "handle_error",
request_fileSize = 'request_fileSize', request_fileSize = "request_fileSize",
response_fileSize = 'response_fileSize', response_fileSize = "response_fileSize",
response_fileInfo = 'response_fileInfo', response_fileInfo = "response_fileInfo",
request_fileInfo = 'request_fileInfo', request_fileInfo = "request_fileInfo",
push_file_chunk = 'push_file_chunk', push_file_chunk = "push_file_chunk",
push_file_complete = 'push_file_complete', push_file_complete = "push_file_complete",
response_push_file_chunk = 'response_push_file_chunk', response_push_file_chunk = "response_push_file_chunk",
response_push_file_complete = 'response_push_file_complete', response_push_file_complete = "response_push_file_complete",
end_call = "end_call",
end_desktop = "end_desktop",
} }
export enum Permission { export enum Permission {
edit = 'edit', edit = "edit",
view = 'view', view = "view",
download = 'download', download = "download",
desktop = "desktop",
call = "call",
} }
export const PermissionLimit = { export const PermissionLimit = {
[Permission.edit]: [MessageType.request_saveFile, MessageType.request_createFile, MessageType.request_createDirectory, MessageType.request_renameFile, MessageType.request_deleteFile], [Permission.edit]: [
[Permission.view]: [MessageType.request_copyClipboard, MessageType.request_getFile, MessageType.request_getFileLastModified, MessageType.request_fileInfo, MessageType.request_fileSize], 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.download]: [MessageType.request_getFile],
} [Permission.desktop]: false,
[Permission.call]: false,
};
export function uuidv4(): string { export function uuidv4(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); var r = (Math.random() * 16) | 0,
v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16); return v.toString(16);
}); });
} }

BIN
static/desktop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 B

BIN
static/phone.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 876 B

View File

@ -4,6 +4,7 @@ import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
base: './',
build: { build: {
// 开发阶段启用源码映射https://uniapp.dcloud.net.cn/tutorial/migration-to-vue3.html#需主动开启-sourcemap // 开发阶段启用源码映射https://uniapp.dcloud.net.cn/tutorial/migration-to-vue3.html#需主动开启-sourcemap
sourcemap: process.env.NODE_ENV === 'development', sourcemap: process.env.NODE_ENV === 'development',