Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ffa9f84c4a | |||
| 1e22481a00 | |||
| f0c4364dcd | |||
| 8ef43029d5 | |||
| 975f6e74ad | |||
| dec59a12ec | |||
| 8590eda2c2 | |||
| 1f4522eeb2 | |||
| 663082efe1 | |||
| 2bd09835b1 | |||
| 7809373f88 | |||
| 85baa97804 | |||
| 246eff196e | |||
| a498cc4799 | |||
| 200fc65617 | |||
| 2ee6961634 | |||
| cfcd60145a | |||
| 67b46d0b30 | |||
| 3f075c4a97 | |||
| 55f118be9a | |||
| 95331cb8e6 | |||
| d0ba2eb9c4 | |||
| 79089bed7e | |||
| 4dcdf0c3a0 | |||
| 327de90f52 | |||
| b5404cea72 | |||
| 33f2f041ac | |||
| 8627544946 | |||
| 158433bb0b | |||
| 6f8f4f65bb | |||
| 61e7c1db50 | |||
| 526e1b49c1 | |||
| 0747898f3c | |||
| 8ff2302c14 | |||
| 5ca911d1e1 | |||
| 230a06b3fb | |||
| 99c927f5c7 | |||
| 3f18002cf0 | |||
| e385389e1d | |||
| 81c2b204f3 | |||
| ec6a18dfc0 | |||
| a82ca50ee9 | |||
| 0bcd2c0f97 | |||
| 5af2e8db37 | |||
| 1aa738425f | |||
| 0562e8a3a8 | |||
| c0317211e7 | |||
| 7f33064109 | |||
| ad4a951525 | |||
| 4437c70257 | |||
| b38ef84bca | |||
| 9b6e6559fe | |||
| d2153d7630 |
@@ -0,0 +1,56 @@
|
||||
# Git相关
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# 环境变量文件
|
||||
.env*
|
||||
!.env.docker.example
|
||||
|
||||
# 日志文件
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# 依赖目录
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
backend/node_modules/
|
||||
|
||||
# 构建目录
|
||||
.next/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# 缓存目录
|
||||
.npm
|
||||
.pnpm-store
|
||||
|
||||
# 临时文件
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# 编辑器配置
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# 操作系统文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Docker相关
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose*.yml
|
||||
|
||||
# 文档文件
|
||||
*.md
|
||||
docs/
|
||||
|
||||
# 测试文件
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# 其他
|
||||
.eslintcache
|
||||
tsconfig.tsbuildinfo
|
||||
+7
-1
@@ -60,6 +60,12 @@ next-env.d.ts
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Generated docker assets
|
||||
docker/ssl/
|
||||
docker/nginx/
|
||||
docker/coturn/
|
||||
logs/
|
||||
|
||||
# Temporary files
|
||||
.temp/
|
||||
.tmp/
|
||||
.tmp/
|
||||
|
||||
@@ -35,17 +35,50 @@ We believe everyone should have control over their own data. PrivyDrop was creat
|
||||
- **Backend**: Node.js, Express.js, TypeScript
|
||||
- **Real-time Communication**: WebRTC, Socket.IO
|
||||
- **Data Storage**: Redis
|
||||
- **Deployment**: PM2, Nginx, Docker [WIP]
|
||||
- **Deployment**: PM2, Nginx, Docker
|
||||
|
||||
## 🐳 Docker One-Click Deployment (Recommended)
|
||||
|
||||
Deploy in minutes with zero manual configuration. Supports private/public networks and auto HTTPS (Let’s Encrypt).
|
||||
|
||||
```bash
|
||||
# Private LAN (no domain/public IP)
|
||||
bash ./deploy.sh --mode lan-http
|
||||
|
||||
# Private LAN + TURN (for complex NAT/LAN)
|
||||
bash ./deploy.sh --mode lan-http --with-turn
|
||||
|
||||
# LAN HTTPS (self-signed; dev/managed env; explicitly enable 8443)
|
||||
bash ./deploy.sh --mode lan-tls --enable-web-https --with-nginx
|
||||
|
||||
# Public IP without domain (with TURN; recommended with Nginx for same-origin)
|
||||
bash ./deploy.sh --mode public --with-turn --with-nginx
|
||||
|
||||
# Public domain (HTTPS + Nginx + TURN + SNI 443, auto-issue/renew)
|
||||
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
|
||||
```
|
||||
|
||||
See [Docker Deployment Guide](./docs/DEPLOYMENT_docker.md) (Modes Overview, LAN TLS limitations, Let’s Encrypt auto-issue/renew)
|
||||
|
||||
Heads-up (LAN TLS, self-signed)
|
||||
- Import the CA certificate into your browser (or system trust store) on first use: `docker/ssl/ca-cert.pem`. Otherwise the browser shows “certificate not valid/untrusted”.
|
||||
- Access endpoints (by default):
|
||||
- Nginx: `http://localhost`
|
||||
- HTTPS: `https://localhost:8443`, `https://<your LAN IP>:8443`
|
||||
- Frontend dev ports (optional): `http://localhost:3002`, `http://<your LAN IP>:3002`
|
||||
- When `--with-nginx` is enabled, the frontend and API are same-origin (`/api`, `/socket.io`) for stability; direct ports `:3002/:3001` are for debugging only and may cause CORS or 404.
|
||||
- With `--enable-web-https` and the CA trusted, same-origin HTTPS (8443) avoids CORS; common dev origins (`localhost`, `:3002`) are allowed by default.
|
||||
|
||||
## 🚀 Quick Start (Full-Stack Local Development)
|
||||
|
||||
Before you begin, ensure your development environment has [Node.js](https://nodejs.org/) (v18+), [npm](https://www.npmjs.com/), and a running [Redis](https://redis.io/) instance installed.
|
||||
|
||||
1. **Clone the Project**
|
||||
1. **Clone the Project & install redis**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/david-bai00/PrivyDrop.git
|
||||
cd PrivyDrop
|
||||
sudo apt-get install -y redis-server
|
||||
```
|
||||
|
||||
2. **Configure and Start the Backend Service**
|
||||
@@ -66,7 +99,7 @@ Before you begin, ensure your development environment has [Node.js](https://node
|
||||
cd frontend
|
||||
pnpm install
|
||||
|
||||
# Copy the development environment file, then modify .env.development as needed
|
||||
# Copy the development environment file, then modify .env.development as needed, Remove optional items
|
||||
cp .env_development_example .env.development
|
||||
|
||||
pnpm dev # Starts by default at http://localhost:3002
|
||||
|
||||
+49
-5
@@ -35,17 +35,61 @@ PrivyDrop (原 SecureShare) 是一个基于 WebRTC 的开源点对点(P2P)
|
||||
- **后端**: Node.js, Express.js, TypeScript
|
||||
- **实时通信**: WebRTC, Socket.IO
|
||||
- **数据存储**: Redis
|
||||
- **部署**: PM2, Nginx, Docker[暂未支持]
|
||||
- **部署**: PM2, Nginx, Docker
|
||||
|
||||
## 🚀 快速上手 (本地全栈开发)
|
||||
## 🚀 快速上手
|
||||
|
||||
### 🐳 Docker 一键部署 (推荐)
|
||||
|
||||
**零配置,一条命令完成部署!支持内网/公网/域名,自动签发/续期 HTTPS。**
|
||||
|
||||
```bash
|
||||
# 内网(无域名/无公网IP)
|
||||
bash ./deploy.sh --mode lan-http
|
||||
|
||||
# 内网 + TURN(推荐用于复杂内网/NAT)
|
||||
bash ./deploy.sh --mode lan-http --with-turn
|
||||
|
||||
# 内网 HTTPS(自签,开发/受管环境,显式开启 8443)
|
||||
bash ./deploy.sh --mode lan-tls --enable-web-https --with-nginx
|
||||
|
||||
# 公网IP(无域名),含 TURN(推荐同源经 Nginx)
|
||||
bash ./deploy.sh --mode public --with-turn --with-nginx
|
||||
|
||||
# 公网域名(HTTPS + Nginx + TURN + SNI 443 分流,自动申请/续期证书)
|
||||
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
|
||||
```
|
||||
|
||||
完整说明见: docs/DEPLOYMENT_docker.zh-CN.md(模式一览、LAN TLS、自签限制、Let’s Encrypt 自动签发/续期)
|
||||
|
||||
提示(lan-tls 自签 HTTPS)
|
||||
- 首次访问需导入 CA 证书:`docker/ssl/ca-cert.pem` 到浏览器(或系统信任),否则浏览器会提示“证书无效/不受信任”。
|
||||
- 访问方式(默认):
|
||||
- Nginx: `http://localhost`
|
||||
- HTTPS: `https://localhost:8443`、`https://<你的局域网IP>:8443`
|
||||
- 前端开发口(可选): `http://localhost:3002`、`http://<你的局域网IP>:3002`
|
||||
- 启用 `--with-nginx` 时,前端与 API 同源(/api、/socket.io)更稳定;直连 `:3002/:3001` 仅用于调试,可能导致 CORS 或 404。
|
||||
- 若启用了 `--enable-web-https` 并导入 CA,浏览器与 API 走同源 HTTPS(8443)可避免 CORS;我们已默认放开常见开发来源(`localhost`、`:3002` 等)。
|
||||
|
||||
**部署优势**:
|
||||
|
||||
- ✅ 部署时间: 60 分钟 → 5 分钟
|
||||
- ✅ 技术门槛: Linux 运维 → 会用 Docker 即可
|
||||
- ✅ 环境要求: 公网 IP → 内网即可使用
|
||||
- ✅ 成功率: 70% → 95%+
|
||||
|
||||
详见: [Docker 部署指南](./docs/DEPLOYMENT_docker.zh-CN.md)
|
||||
|
||||
### 💻 本地开发环境
|
||||
|
||||
在开始之前,请确保你的开发环境已安装 [Node.js](https://nodejs.org/) (v18+), [npm](https://www.npmjs.com/) 以及一个正在运行的 [Redis](https://redis.io/) 实例。
|
||||
|
||||
1. **克隆项目**
|
||||
1. **克隆项目 & 安装 redis**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/david-bai00/PrivyDrop.git
|
||||
cd privydrop
|
||||
cd PrivyDrop
|
||||
sudo apt-get install -y redis-server
|
||||
```
|
||||
|
||||
2. **配置并启动后端服务**
|
||||
@@ -66,7 +110,7 @@ PrivyDrop (原 SecureShare) 是一个基于 WebRTC 的开源点对点(P2P)
|
||||
cd frontend
|
||||
pnpm install
|
||||
|
||||
# 复制开发环境变量文件,然后根据需要修改 .env.development
|
||||
# 复制开发环境变量文件,然后根据需要修改 .env.development,删除可选项
|
||||
cp .env_development_example .env.development
|
||||
|
||||
pnpm dev # 默认启动于 http://localhost:3002
|
||||
|
||||
+11
-1
@@ -8,12 +8,22 @@ This roadmap is a living document. We welcome community feedback and contributio
|
||||
|
||||
## ✅ Completed
|
||||
|
||||
### Architecture optimization
|
||||
|
||||
- **Core Architecture Refactor (Q3 2025)**: Successfully refactored the entire frontend codebase to a modern, layered architecture.
|
||||
- Implemented a framework-agnostic **Service Layer** (`webrtcService`) to encapsulate all WebRTC and business logic.
|
||||
- Introduced **Zustand** for centralized, predictable state management (`fileTransferStore`).
|
||||
- Decoupled UI components from business logic, establishing a clear, unidirectional data flow.
|
||||
- **Resumable File Transfers (Q3 2025):** Implemented robust logic for resuming transfers from the point of interruption. This is enabled by setting a save directory, which allows the receiver to check for partially downloaded files and request only the missing chunks.
|
||||
|
||||
### Deployment and Operation
|
||||
|
||||
- Docker one-click deployment (Q4 20252)
|
||||
- Unified container health checks (node health-check.js)
|
||||
- Let’s Encrypt automation (webroot) with zero-downtime renewals and deploy-hook
|
||||
- TURN improvements (env port range; default 49152-49252)
|
||||
- SNI 443 multiplexing (turns:443 via Nginx stream; enabled by default in full+domain)
|
||||
|
||||
---
|
||||
|
||||
## Short-Term Goals (Next 1-3 Months)
|
||||
@@ -60,4 +70,4 @@ Your contributions are vital to making this roadmap a reality!
|
||||
2. **Start a Discussion:** If you're interested in a roadmap item, start a discussion to share your ideas.
|
||||
3. **Submit a PR:** Fork the repo, create a feature branch, and submit a Pull Request.
|
||||
|
||||
Thank you for being part of the PrivyDrop community! Let's build the future of private sharing, together.
|
||||
Thank you for being part of the PrivyDrop community! Let's build the future of private sharing, together.
|
||||
|
||||
+13
-3
@@ -8,11 +8,21 @@
|
||||
|
||||
## ✅ 已完成
|
||||
|
||||
- **核心架构重构 (2025年Q3)**: 成功地将整个前端代码库重构为现代化的分层架构。
|
||||
### 架构优化
|
||||
|
||||
- **核心架构重构 (2025 年 Q3)**: 成功地将整个前端代码库重构为现代化的分层架构。
|
||||
- 实现了一个与框架无关的**服务层** (`webrtcService`),用于封装所有 WebRTC 和业务逻辑。
|
||||
- 引入 **Zustand** (`fileTransferStore`) 进行中心化的、可预测的状态管理。
|
||||
- 将 UI 组件与业务逻辑解耦,建立了清晰的单向数据流。
|
||||
- **文件断点续传 (2025年Q3):** 实现了稳健的断点续传逻辑。通过设置保存目录,接收方能够检查已部分下载的文件,并仅请求缺失的数据块,极大地提升了大文件和不稳定网络下的传输成功率。
|
||||
- **文件断点续传 (2025 年 Q3):** 实现了稳健的断点续传逻辑。通过设置保存目录,接收方能够检查已部分下载的文件,并仅请求缺失的数据块,极大地提升了大文件和不稳定网络下的传输成功率。
|
||||
|
||||
### 部署与运维
|
||||
|
||||
- Docker 一键部署(2025 年 Q4)
|
||||
- 容器健康检查统一(node health-check.js)
|
||||
- Let’s Encrypt(webroot)自动化与续期 deploy-hook(无停机)
|
||||
- TURN 端口段变量化与默认缩小(49152-49252)
|
||||
- SNI 443 分流(Nginx stream;full+domain 默认开启)
|
||||
|
||||
---
|
||||
|
||||
@@ -61,4 +71,4 @@
|
||||
2. **发起讨论:** 如果你对路线图中某个项目感兴趣,欢迎发起一个讨论来分享你的想法。
|
||||
3. **提交代码:** Fork 仓库,创建你的功能分支,然后提交 Pull Request。
|
||||
|
||||
感谢你成为 PrivyDrop 社区的一员!让我们一起共创私人分享的未来。
|
||||
感谢你成为 PrivyDrop 社区的一员!让我们一起共创私人分享的未来。
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
.npm
|
||||
.env*
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
coverage
|
||||
.nyc_output
|
||||
logs
|
||||
*.log
|
||||
@@ -0,0 +1,41 @@
|
||||
# Build stage
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
ARG HTTP_PROXY
|
||||
ARG HTTPS_PROXY
|
||||
ARG NO_PROXY
|
||||
|
||||
ENV http_proxy ${HTTP_PROXY} \
|
||||
https_proxy ${HTTPS_PROXY} \
|
||||
no_proxy ${NO_PROXY}
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Run stage
|
||||
FROM node:18-alpine AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy prebuilt artifacts
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY health-check.js ./
|
||||
|
||||
# Create user and set permissions
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S backend -u 1001 -G nodejs && \
|
||||
chown -R backend:nodejs /app
|
||||
|
||||
USER backend
|
||||
EXPOSE 3001
|
||||
|
||||
# Use a Node.js script for health checks (instead of curl)
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD node health-check.js
|
||||
|
||||
CMD ["node", "dist/server.js"]
|
||||
@@ -88,7 +88,7 @@ server {
|
||||
}
|
||||
# Next.js Image Optimization Service (usually handled by the Next.js application)
|
||||
location /_next/image {
|
||||
proxy_pass http://localhost:3000; # Point to the Next.js application
|
||||
proxy_pass http://localhost:3002; # Point to the Next.js application
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
@@ -110,7 +110,7 @@ server {
|
||||
}
|
||||
# Named location, used to proxy requests to the Next.js application
|
||||
location @nextjs_app {
|
||||
proxy_pass http://localhost:3000; # Point to the Next.js application
|
||||
proxy_pass http://localhost:3002; # Point to the Next.js application
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const http = require('http');
|
||||
|
||||
const options = {
|
||||
host: 'localhost',
|
||||
port: process.env.BACKEND_PORT || 3001,
|
||||
path: '/health',
|
||||
timeout: 2000,
|
||||
method: 'GET'
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
console.log('Health check passed');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log(`Health check failed with status: ${res.statusCode}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
console.log(`Health check failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
console.log('Health check timeout');
|
||||
req.destroy();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
req.end();
|
||||
Generated
+156
-156
@@ -19,7 +19,7 @@ importers:
|
||||
version: 4.21.2
|
||||
ioredis:
|
||||
specifier: ^5.4.1
|
||||
version: 5.7.0
|
||||
version: 5.8.0
|
||||
socket.io:
|
||||
specifier: ^4.8.1
|
||||
version: 4.8.1
|
||||
@@ -32,7 +32,7 @@ importers:
|
||||
version: 5.0.3
|
||||
'@types/node':
|
||||
specifier: ^22.13.4
|
||||
version: 22.17.2
|
||||
version: 22.18.6
|
||||
cross-env:
|
||||
specifier: ^7.0.3
|
||||
version: 7.0.3
|
||||
@@ -41,10 +41,10 @@ importers:
|
||||
version: 6.0.1
|
||||
ts-node:
|
||||
specifier: ^10.9.2
|
||||
version: 10.9.2(@types/node@22.17.2)(typescript@5.9.2)
|
||||
version: 10.9.2(@types/node@22.18.6)(typescript@5.9.2)
|
||||
tsx:
|
||||
specifier: ^4.19.2
|
||||
version: 4.20.4
|
||||
version: 4.20.5
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.2
|
||||
@@ -55,164 +55,164 @@ packages:
|
||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.9':
|
||||
resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==}
|
||||
'@esbuild/aix-ppc64@0.25.10':
|
||||
resolution: {integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==}
|
||||
'@esbuild/android-arm64@0.25.10':
|
||||
resolution: {integrity: sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.25.9':
|
||||
resolution: {integrity: sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==}
|
||||
'@esbuild/android-arm@0.25.10':
|
||||
resolution: {integrity: sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.25.9':
|
||||
resolution: {integrity: sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==}
|
||||
'@esbuild/android-x64@0.25.10':
|
||||
resolution: {integrity: sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==}
|
||||
'@esbuild/darwin-arm64@0.25.10':
|
||||
resolution: {integrity: sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.25.9':
|
||||
resolution: {integrity: sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==}
|
||||
'@esbuild/darwin-x64@0.25.10':
|
||||
resolution: {integrity: sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==}
|
||||
'@esbuild/freebsd-arm64@0.25.10':
|
||||
resolution: {integrity: sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.9':
|
||||
resolution: {integrity: sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==}
|
||||
'@esbuild/freebsd-x64@0.25.10':
|
||||
resolution: {integrity: sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==}
|
||||
'@esbuild/linux-arm64@0.25.10':
|
||||
resolution: {integrity: sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.25.9':
|
||||
resolution: {integrity: sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==}
|
||||
'@esbuild/linux-arm@0.25.10':
|
||||
resolution: {integrity: sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.25.9':
|
||||
resolution: {integrity: sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==}
|
||||
'@esbuild/linux-ia32@0.25.10':
|
||||
resolution: {integrity: sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.25.9':
|
||||
resolution: {integrity: sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==}
|
||||
'@esbuild/linux-loong64@0.25.10':
|
||||
resolution: {integrity: sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.9':
|
||||
resolution: {integrity: sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==}
|
||||
'@esbuild/linux-mips64el@0.25.10':
|
||||
resolution: {integrity: sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.9':
|
||||
resolution: {integrity: sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==}
|
||||
'@esbuild/linux-ppc64@0.25.10':
|
||||
resolution: {integrity: sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.9':
|
||||
resolution: {integrity: sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==}
|
||||
'@esbuild/linux-riscv64@0.25.10':
|
||||
resolution: {integrity: sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.25.9':
|
||||
resolution: {integrity: sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==}
|
||||
'@esbuild/linux-s390x@0.25.10':
|
||||
resolution: {integrity: sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.25.9':
|
||||
resolution: {integrity: sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==}
|
||||
'@esbuild/linux-x64@0.25.10':
|
||||
resolution: {integrity: sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==}
|
||||
'@esbuild/netbsd-arm64@0.25.10':
|
||||
resolution: {integrity: sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.9':
|
||||
resolution: {integrity: sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==}
|
||||
'@esbuild/netbsd-x64@0.25.10':
|
||||
resolution: {integrity: sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==}
|
||||
'@esbuild/openbsd-arm64@0.25.10':
|
||||
resolution: {integrity: sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.9':
|
||||
resolution: {integrity: sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==}
|
||||
'@esbuild/openbsd-x64@0.25.10':
|
||||
resolution: {integrity: sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==}
|
||||
'@esbuild/openharmony-arm64@0.25.10':
|
||||
resolution: {integrity: sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.25.9':
|
||||
resolution: {integrity: sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==}
|
||||
'@esbuild/sunos-x64@0.25.10':
|
||||
resolution: {integrity: sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.25.9':
|
||||
resolution: {integrity: sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==}
|
||||
'@esbuild/win32-arm64@0.25.10':
|
||||
resolution: {integrity: sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.25.9':
|
||||
resolution: {integrity: sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==}
|
||||
'@esbuild/win32-ia32@0.25.10':
|
||||
resolution: {integrity: sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.25.9':
|
||||
resolution: {integrity: sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==}
|
||||
'@esbuild/win32-x64@0.25.10':
|
||||
resolution: {integrity: sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@ioredis/commands@1.3.0':
|
||||
resolution: {integrity: sha512-M/T6Zewn7sDaBQEqIZ8Rb+i9y8qfGmq+5SDFSf9sA2lUZTmdDLVdOiQaeDp+Q4wElZ9HG1GAX5KhDaidp6LQsQ==}
|
||||
'@ioredis/commands@1.4.0':
|
||||
resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==}
|
||||
|
||||
'@isaacs/balanced-match@4.0.1':
|
||||
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
|
||||
@@ -272,8 +272,8 @@ packages:
|
||||
'@types/mime@1.3.5':
|
||||
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
|
||||
|
||||
'@types/node@22.17.2':
|
||||
resolution: {integrity: sha512-gL6z5N9Jm9mhY+U2KXZpteb+09zyffliRkZyZOHODGATyC5B1Jt/7TzuuiLkFsSUMLbS1OLmlj/E+/3KF4Q/4w==}
|
||||
'@types/node@22.18.6':
|
||||
resolution: {integrity: sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==}
|
||||
|
||||
'@types/qs@6.14.0':
|
||||
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
|
||||
@@ -304,16 +304,16 @@ packages:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-regex@6.2.0:
|
||||
resolution: {integrity: sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==}
|
||||
ansi-regex@6.2.2:
|
||||
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-styles@6.2.1:
|
||||
resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==}
|
||||
ansi-styles@6.2.3:
|
||||
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
arg@4.1.3:
|
||||
@@ -405,8 +405,8 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@4.4.1:
|
||||
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
@@ -478,8 +478,8 @@ packages:
|
||||
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
esbuild@0.25.9:
|
||||
resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==}
|
||||
esbuild@0.25.10:
|
||||
resolution: {integrity: sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
@@ -557,8 +557,8 @@ packages:
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
ioredis@5.7.0:
|
||||
resolution: {integrity: sha512-NUcA93i1lukyXU+riqEyPtSEkyFq8tX90uL659J+qpCZ3rEdViB/APC58oAhIh3+bJln2hzdlZbBZsGNrlsR8g==}
|
||||
ioredis@5.8.0:
|
||||
resolution: {integrity: sha512-AUXbKn9gvo9hHKvk6LbZJQSKn/qIfkWXrnsyL9Yrf+oeXmla9Nmf6XEumOddyhM8neynpK5oAV6r9r99KBuwzA==}
|
||||
engines: {node: '>=12.22.0'}
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
@@ -582,8 +582,8 @@ packages:
|
||||
lodash.isarguments@3.1.0:
|
||||
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
|
||||
|
||||
lru-cache@11.1.0:
|
||||
resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==}
|
||||
lru-cache@11.2.1:
|
||||
resolution: {integrity: sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
make-error@1.3.6:
|
||||
@@ -772,8 +772,8 @@ packages:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strip-ansi@7.1.0:
|
||||
resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==}
|
||||
strip-ansi@7.1.2:
|
||||
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
toidentifier@1.0.1:
|
||||
@@ -794,8 +794,8 @@ packages:
|
||||
'@swc/wasm':
|
||||
optional: true
|
||||
|
||||
tsx@4.20.4:
|
||||
resolution: {integrity: sha512-yyxBKfORQ7LuRt/BQKBXrpcq59ZvSW0XxwfjAt3w2/8PmdxaFzijtMhTawprSHhpzeM5BgU2hXHG3lklIERZXg==}
|
||||
tsx@4.20.5:
|
||||
resolution: {integrity: sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -861,85 +861,85 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.9
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.9':
|
||||
'@esbuild/aix-ppc64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.25.9':
|
||||
'@esbuild/android-arm64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.25.9':
|
||||
'@esbuild/android-arm@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.25.9':
|
||||
'@esbuild/android-x64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.9':
|
||||
'@esbuild/darwin-arm64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.25.9':
|
||||
'@esbuild/darwin-x64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.9':
|
||||
'@esbuild/freebsd-arm64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.9':
|
||||
'@esbuild/freebsd-x64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.25.9':
|
||||
'@esbuild/linux-arm64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.25.9':
|
||||
'@esbuild/linux-arm@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.25.9':
|
||||
'@esbuild/linux-ia32@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.25.9':
|
||||
'@esbuild/linux-loong64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.9':
|
||||
'@esbuild/linux-mips64el@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.9':
|
||||
'@esbuild/linux-ppc64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.9':
|
||||
'@esbuild/linux-riscv64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.25.9':
|
||||
'@esbuild/linux-s390x@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.25.9':
|
||||
'@esbuild/linux-x64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.9':
|
||||
'@esbuild/netbsd-arm64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.9':
|
||||
'@esbuild/netbsd-x64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.9':
|
||||
'@esbuild/openbsd-arm64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.9':
|
||||
'@esbuild/openbsd-x64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.25.9':
|
||||
'@esbuild/openharmony-arm64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.25.9':
|
||||
'@esbuild/sunos-x64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.25.9':
|
||||
'@esbuild/win32-arm64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.25.9':
|
||||
'@esbuild/win32-ia32@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.25.9':
|
||||
'@esbuild/win32-x64@0.25.10':
|
||||
optional: true
|
||||
|
||||
'@ioredis/commands@1.3.0': {}
|
||||
'@ioredis/commands@1.4.0': {}
|
||||
|
||||
'@isaacs/balanced-match@4.0.1': {}
|
||||
|
||||
@@ -951,7 +951,7 @@ snapshots:
|
||||
dependencies:
|
||||
string-width: 5.1.2
|
||||
string-width-cjs: string-width@4.2.3
|
||||
strip-ansi: 7.1.0
|
||||
strip-ansi: 7.1.2
|
||||
strip-ansi-cjs: strip-ansi@6.0.1
|
||||
wrap-ansi: 8.1.0
|
||||
wrap-ansi-cjs: wrap-ansi@7.0.0
|
||||
@@ -978,19 +978,19 @@ snapshots:
|
||||
'@types/body-parser@1.19.6':
|
||||
dependencies:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/node': 22.17.2
|
||||
'@types/node': 22.18.6
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
dependencies:
|
||||
'@types/node': 22.17.2
|
||||
'@types/node': 22.18.6
|
||||
|
||||
'@types/cors@2.8.19':
|
||||
dependencies:
|
||||
'@types/node': 22.17.2
|
||||
'@types/node': 22.18.6
|
||||
|
||||
'@types/express-serve-static-core@5.0.7':
|
||||
dependencies:
|
||||
'@types/node': 22.17.2
|
||||
'@types/node': 22.18.6
|
||||
'@types/qs': 6.14.0
|
||||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 0.17.5
|
||||
@@ -1005,7 +1005,7 @@ snapshots:
|
||||
|
||||
'@types/mime@1.3.5': {}
|
||||
|
||||
'@types/node@22.17.2':
|
||||
'@types/node@22.18.6':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
@@ -1016,12 +1016,12 @@ snapshots:
|
||||
'@types/send@0.17.5':
|
||||
dependencies:
|
||||
'@types/mime': 1.3.5
|
||||
'@types/node': 22.17.2
|
||||
'@types/node': 22.18.6
|
||||
|
||||
'@types/serve-static@1.15.8':
|
||||
dependencies:
|
||||
'@types/http-errors': 2.0.5
|
||||
'@types/node': 22.17.2
|
||||
'@types/node': 22.18.6
|
||||
'@types/send': 0.17.5
|
||||
|
||||
accepts@1.3.8:
|
||||
@@ -1037,13 +1037,13 @@ snapshots:
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
|
||||
ansi-regex@6.2.0: {}
|
||||
ansi-regex@6.2.2: {}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
||||
ansi-styles@6.2.1: {}
|
||||
ansi-styles@6.2.3: {}
|
||||
|
||||
arg@4.1.3: {}
|
||||
|
||||
@@ -1125,7 +1125,7 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
debug@4.4.1:
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
@@ -1162,7 +1162,7 @@ snapshots:
|
||||
engine.io@6.6.4:
|
||||
dependencies:
|
||||
'@types/cors': 2.8.19
|
||||
'@types/node': 22.17.2
|
||||
'@types/node': 22.18.6
|
||||
accepts: 1.3.8
|
||||
base64id: 2.0.0
|
||||
cookie: 0.7.2
|
||||
@@ -1183,34 +1183,34 @@ snapshots:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
||||
esbuild@0.25.9:
|
||||
esbuild@0.25.10:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.25.9
|
||||
'@esbuild/android-arm': 0.25.9
|
||||
'@esbuild/android-arm64': 0.25.9
|
||||
'@esbuild/android-x64': 0.25.9
|
||||
'@esbuild/darwin-arm64': 0.25.9
|
||||
'@esbuild/darwin-x64': 0.25.9
|
||||
'@esbuild/freebsd-arm64': 0.25.9
|
||||
'@esbuild/freebsd-x64': 0.25.9
|
||||
'@esbuild/linux-arm': 0.25.9
|
||||
'@esbuild/linux-arm64': 0.25.9
|
||||
'@esbuild/linux-ia32': 0.25.9
|
||||
'@esbuild/linux-loong64': 0.25.9
|
||||
'@esbuild/linux-mips64el': 0.25.9
|
||||
'@esbuild/linux-ppc64': 0.25.9
|
||||
'@esbuild/linux-riscv64': 0.25.9
|
||||
'@esbuild/linux-s390x': 0.25.9
|
||||
'@esbuild/linux-x64': 0.25.9
|
||||
'@esbuild/netbsd-arm64': 0.25.9
|
||||
'@esbuild/netbsd-x64': 0.25.9
|
||||
'@esbuild/openbsd-arm64': 0.25.9
|
||||
'@esbuild/openbsd-x64': 0.25.9
|
||||
'@esbuild/openharmony-arm64': 0.25.9
|
||||
'@esbuild/sunos-x64': 0.25.9
|
||||
'@esbuild/win32-arm64': 0.25.9
|
||||
'@esbuild/win32-ia32': 0.25.9
|
||||
'@esbuild/win32-x64': 0.25.9
|
||||
'@esbuild/aix-ppc64': 0.25.10
|
||||
'@esbuild/android-arm': 0.25.10
|
||||
'@esbuild/android-arm64': 0.25.10
|
||||
'@esbuild/android-x64': 0.25.10
|
||||
'@esbuild/darwin-arm64': 0.25.10
|
||||
'@esbuild/darwin-x64': 0.25.10
|
||||
'@esbuild/freebsd-arm64': 0.25.10
|
||||
'@esbuild/freebsd-x64': 0.25.10
|
||||
'@esbuild/linux-arm': 0.25.10
|
||||
'@esbuild/linux-arm64': 0.25.10
|
||||
'@esbuild/linux-ia32': 0.25.10
|
||||
'@esbuild/linux-loong64': 0.25.10
|
||||
'@esbuild/linux-mips64el': 0.25.10
|
||||
'@esbuild/linux-ppc64': 0.25.10
|
||||
'@esbuild/linux-riscv64': 0.25.10
|
||||
'@esbuild/linux-s390x': 0.25.10
|
||||
'@esbuild/linux-x64': 0.25.10
|
||||
'@esbuild/netbsd-arm64': 0.25.10
|
||||
'@esbuild/netbsd-x64': 0.25.10
|
||||
'@esbuild/openbsd-arm64': 0.25.10
|
||||
'@esbuild/openbsd-x64': 0.25.10
|
||||
'@esbuild/openharmony-arm64': 0.25.10
|
||||
'@esbuild/sunos-x64': 0.25.10
|
||||
'@esbuild/win32-arm64': 0.25.10
|
||||
'@esbuild/win32-ia32': 0.25.10
|
||||
'@esbuild/win32-x64': 0.25.10
|
||||
|
||||
escape-html@1.0.3: {}
|
||||
|
||||
@@ -1331,11 +1331,11 @@ snapshots:
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
ioredis@5.7.0:
|
||||
ioredis@5.8.0:
|
||||
dependencies:
|
||||
'@ioredis/commands': 1.3.0
|
||||
'@ioredis/commands': 1.4.0
|
||||
cluster-key-slot: 1.1.2
|
||||
debug: 4.4.1
|
||||
debug: 4.4.3
|
||||
denque: 2.1.0
|
||||
lodash.defaults: 4.2.0
|
||||
lodash.isarguments: 3.1.0
|
||||
@@ -1359,7 +1359,7 @@ snapshots:
|
||||
|
||||
lodash.isarguments@3.1.0: {}
|
||||
|
||||
lru-cache@11.1.0: {}
|
||||
lru-cache@11.2.1: {}
|
||||
|
||||
make-error@1.3.6: {}
|
||||
|
||||
@@ -1407,7 +1407,7 @@ snapshots:
|
||||
|
||||
path-scurry@2.0.0:
|
||||
dependencies:
|
||||
lru-cache: 11.1.0
|
||||
lru-cache: 11.2.1
|
||||
minipass: 7.1.2
|
||||
|
||||
path-to-regexp@0.1.12: {}
|
||||
@@ -1556,26 +1556,26 @@ snapshots:
|
||||
dependencies:
|
||||
eastasianwidth: 0.2.0
|
||||
emoji-regex: 9.2.2
|
||||
strip-ansi: 7.1.0
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
|
||||
strip-ansi@7.1.0:
|
||||
strip-ansi@7.1.2:
|
||||
dependencies:
|
||||
ansi-regex: 6.2.0
|
||||
ansi-regex: 6.2.2
|
||||
|
||||
toidentifier@1.0.1: {}
|
||||
|
||||
ts-node@10.9.2(@types/node@22.17.2)(typescript@5.9.2):
|
||||
ts-node@10.9.2(@types/node@22.18.6)(typescript@5.9.2):
|
||||
dependencies:
|
||||
'@cspotcode/source-map-support': 0.8.1
|
||||
'@tsconfig/node10': 1.0.11
|
||||
'@tsconfig/node12': 1.0.11
|
||||
'@tsconfig/node14': 1.0.3
|
||||
'@tsconfig/node16': 1.0.4
|
||||
'@types/node': 22.17.2
|
||||
'@types/node': 22.18.6
|
||||
acorn: 8.15.0
|
||||
acorn-walk: 8.3.4
|
||||
arg: 4.1.3
|
||||
@@ -1586,9 +1586,9 @@ snapshots:
|
||||
v8-compile-cache-lib: 3.0.1
|
||||
yn: 3.1.1
|
||||
|
||||
tsx@4.20.4:
|
||||
tsx@4.20.5:
|
||||
dependencies:
|
||||
esbuild: 0.25.9
|
||||
esbuild: 0.25.10
|
||||
get-tsconfig: 4.10.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
@@ -1622,9 +1622,9 @@ snapshots:
|
||||
|
||||
wrap-ansi@8.1.0:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.1
|
||||
ansi-styles: 6.2.3
|
||||
string-width: 5.1.2
|
||||
strip-ansi: 7.1.0
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
ws@8.17.1: {}
|
||||
|
||||
|
||||
@@ -3,17 +3,30 @@ import { CONFIG } from "./env";
|
||||
|
||||
// Define the sources allowed in the development environment
|
||||
const DEV_ORIGINS = [
|
||||
CONFIG.CORS_ORIGIN, // http://localhost:3002
|
||||
'http://localhost:3000', // alternate port
|
||||
/^http:\/\/192\.168\.\d+\.\d+:3000$/, // LAN addresses
|
||||
/^http:\/\/192\.168\.\d+\.\d+:3002$/ // LAN addresses with new port
|
||||
CONFIG.CORS_ORIGIN, // http://localhost:3002
|
||||
"http://localhost:3002", // alternate port
|
||||
/^http:\/\/192\.168\.\d+\.\d+:3002$/, // LAN addresses
|
||||
/^http:\/\/192\.168\.\d+\.\d+:3002$/, // LAN addresses with new port
|
||||
];
|
||||
|
||||
// Parse multi-origin config in production (comma-separated)
|
||||
const parseProdOrigins = (): string | RegExp | (string | RegExp)[] => {
|
||||
const v = CONFIG.CORS_ORIGIN?.trim();
|
||||
if (!v) return DEV_ORIGINS; // Fallback to the development whitelist
|
||||
if (v.includes(",")) {
|
||||
return v
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return v;
|
||||
};
|
||||
|
||||
// Configure CORS
|
||||
export const corsOptions: CorsOptions =
|
||||
CONFIG.NODE_ENV === "production"
|
||||
? {
|
||||
origin: CONFIG.CORS_ORIGIN,
|
||||
origin: parseProdOrigins(),
|
||||
methods: ["GET", "POST", "OPTIONS"],
|
||||
credentials: true,
|
||||
allowedHeaders: ["Content-Type", "Authorization"],
|
||||
@@ -28,7 +41,7 @@ export const corsOptions: CorsOptions =
|
||||
export const corsWSOptions =
|
||||
CONFIG.NODE_ENV === "production"
|
||||
? {
|
||||
origin: CONFIG.CORS_ORIGIN, // Allowed origin, replace with your Next.js application's URL
|
||||
origin: parseProdOrigins(),
|
||||
methods: ["GET", "POST"],
|
||||
credentials: true,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { redis } from '../services/redis';
|
||||
import { CONFIG } from '../config/env';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 应用启动时间
|
||||
const startTime = Date.now();
|
||||
|
||||
// 基础健康检查
|
||||
router.get('/health', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const health = {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
service: 'privydrop-backend',
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
environment: CONFIG.NODE_ENV
|
||||
};
|
||||
|
||||
res.status(200).json(health);
|
||||
} catch (error) {
|
||||
console.error('Health check error:', error);
|
||||
res.status(503).json({
|
||||
status: 'unhealthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'privydrop-backend',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// API路径的健康检查 (兼容性)
|
||||
router.get('/api/health', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const health = {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
service: 'privydrop-backend',
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
environment: CONFIG.NODE_ENV
|
||||
};
|
||||
|
||||
res.status(200).json(health);
|
||||
} catch (error) {
|
||||
console.error('Health check error:', error);
|
||||
res.status(503).json({
|
||||
status: 'unhealthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'privydrop-backend',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 详细健康检查
|
||||
router.get('/health/detailed', async (req: Request, res: Response) => {
|
||||
const errors: string[] = [];
|
||||
let status = 'healthy';
|
||||
|
||||
try {
|
||||
// 基础信息
|
||||
const basicHealth = {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
service: 'privydrop-backend',
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
environment: CONFIG.NODE_ENV
|
||||
};
|
||||
|
||||
// 检查Redis连接
|
||||
const redisHealth = await checkRedisHealth();
|
||||
if (redisHealth.status !== 'connected') {
|
||||
errors.push('Redis connection failed');
|
||||
status = 'unhealthy';
|
||||
}
|
||||
|
||||
// 检查Socket.IO状态
|
||||
const io = req.app.get('io');
|
||||
const socketHealth = {
|
||||
status: io ? 'running' : 'not_initialized',
|
||||
connections: io ? io.engine.clientsCount : 0
|
||||
};
|
||||
|
||||
// 获取系统资源信息
|
||||
const systemInfo = getSystemInfo();
|
||||
|
||||
// 检查系统资源
|
||||
if (systemInfo.memory.percent > 90) {
|
||||
errors.push('High memory usage (>90%)');
|
||||
status = status === 'healthy' ? 'degraded' : status;
|
||||
}
|
||||
|
||||
if (systemInfo.cpu.percent > 80) {
|
||||
errors.push('High CPU usage (>80%)');
|
||||
status = status === 'healthy' ? 'degraded' : status;
|
||||
}
|
||||
|
||||
const detailedHealth = {
|
||||
...basicHealth,
|
||||
status,
|
||||
dependencies: {
|
||||
redis: redisHealth,
|
||||
socketio: socketHealth
|
||||
},
|
||||
system: systemInfo,
|
||||
...(errors.length > 0 && { errors })
|
||||
};
|
||||
|
||||
const httpStatus = status === 'healthy' ? 200 : 503;
|
||||
res.status(httpStatus).json(detailedHealth);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Detailed health check error:', error);
|
||||
res.status(503).json({
|
||||
status: 'unhealthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'privydrop-backend',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Redis健康检查函数
|
||||
async function checkRedisHealth() {
|
||||
try {
|
||||
const start = Date.now();
|
||||
await redis.ping();
|
||||
const responseTime = Date.now() - start;
|
||||
|
||||
return {
|
||||
status: 'connected',
|
||||
responseTime,
|
||||
host: CONFIG.REDIS.HOST,
|
||||
port: CONFIG.REDIS.PORT
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'disconnected',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
host: CONFIG.REDIS.HOST,
|
||||
port: CONFIG.REDIS.PORT
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 系统信息获取函数
|
||||
function getSystemInfo() {
|
||||
const memUsage = process.memoryUsage();
|
||||
const totalMem = memUsage.heapTotal;
|
||||
const usedMem = memUsage.heapUsed;
|
||||
const freeMem = totalMem - usedMem;
|
||||
|
||||
return {
|
||||
memory: {
|
||||
used: formatBytes(usedMem),
|
||||
free: formatBytes(freeMem),
|
||||
total: formatBytes(totalMem),
|
||||
percent: Math.round((usedMem / totalMem) * 100)
|
||||
},
|
||||
cpu: {
|
||||
percent: getCpuUsage()
|
||||
},
|
||||
uptime: process.uptime(),
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
nodeVersion: process.version
|
||||
};
|
||||
}
|
||||
|
||||
// 格式化字节数
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// 简化的CPU使用率 (基于进程CPU时间)
|
||||
function getCpuUsage(): number {
|
||||
const cpuUsage = process.cpuUsage();
|
||||
const totalCpuTime = (cpuUsage.user + cpuUsage.system) / 1000000; // 转换为秒
|
||||
const uptime = process.uptime();
|
||||
return Math.round((totalCpuTime / uptime) * 100);
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -5,6 +5,7 @@ import { Server } from "socket.io"; // socket.io: A library for real-time web ap
|
||||
import { CONFIG } from "./config/env";
|
||||
import { corsOptions, corsWSOptions } from "./config/server";
|
||||
import apiRouter from "./routes/api";
|
||||
import healthRouter from "./routes/health";
|
||||
import { setupSocketHandlers } from "./socket/handlers";
|
||||
|
||||
const app = express(); // Create an Express application
|
||||
@@ -19,6 +20,10 @@ setupSocketHandlers(io);
|
||||
// Make io instance available to routes
|
||||
app.set('io', io);
|
||||
|
||||
// Register health check routes first (for Docker health checks)
|
||||
app.use(healthRouter);
|
||||
|
||||
// Register API routes
|
||||
app.use(apiRouter);
|
||||
|
||||
server.listen(CONFIG.BACKEND_PORT, () => {
|
||||
|
||||
@@ -0,0 +1,762 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e # Exit immediately on error
|
||||
|
||||
# Color definitions
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Script directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DOCKER_SCRIPTS_DIR="$SCRIPT_DIR/docker/scripts"
|
||||
|
||||
# Logging helpers
|
||||
log_info() {
|
||||
echo -e "${BLUE}ℹ️ $1${NC}"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}✅ $1${NC}"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}❌ $1${NC}"
|
||||
}
|
||||
|
||||
# Show help
|
||||
show_help() {
|
||||
cat << EOF
|
||||
PrivyDrop Docker Deployment Script
|
||||
|
||||
Usage: $0 [options]
|
||||
|
||||
Options:
|
||||
--domain DOMAIN Specify domain (for HTTPS deployments)
|
||||
--mode MODE Deployment mode: lan-http|lan-tls|public|full
|
||||
lan-http: Intranet HTTP (fast start; no TLS)
|
||||
lan-tls: Intranet HTTPS (self-signed; dev/managed env only)
|
||||
public: Public HTTP + TURN server
|
||||
full: Domain + HTTPS (Let’s Encrypt) + TURN server
|
||||
--with-nginx Enable Nginx reverse proxy
|
||||
--with-turn Enable TURN server
|
||||
--with-sni443 Force enable 443 SNI routing (full; default enabled)
|
||||
--no-sni443 Disable 443 SNI routing (full; web listens directly on 443)
|
||||
--enable-web-https In lan-tls mode, enable self-signed HTTPS on 8443 (no HSTS)
|
||||
--le-email EMAIL Email for Let's Encrypt (recommended in full mode)
|
||||
--test-renewal Run 'certbot renew --dry-run' and reload services (verification)
|
||||
--clean Clean existing containers and data
|
||||
--help Show help
|
||||
|
||||
Examples:
|
||||
$0 --mode lan-http # LAN HTTP quick start
|
||||
$0 --mode lan-http --with-turn # LAN HTTP with TURN (NAT-friendly)
|
||||
$0 --mode lan-tls --enable-web-https # LAN HTTPS (self-signed) on 8443 (dev/managed)
|
||||
$0 --mode public --with-turn # Public deployment + TURN server (no domain)
|
||||
$0 --mode full --domain example.com \\
|
||||
--with-nginx --with-turn --le-email you@domain.com # Full HTTPS deployment (LE auto-issue/renew)
|
||||
$0 --clean # Clean deployment
|
||||
|
||||
Requirements:
|
||||
- Docker Engine and Docker Compose V2 (command `docker compose`)
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
# Parse command-line arguments
|
||||
parse_arguments() {
|
||||
DOMAIN_NAME=""
|
||||
DEPLOYMENT_MODE=""
|
||||
WITH_NGINX=false
|
||||
WITH_TURN=false
|
||||
CLEAN_MODE=false
|
||||
LE_EMAIL=""
|
||||
WITH_SNI443=false
|
||||
DISABLE_SNI443=false
|
||||
ENABLE_WEB_HTTPS=false
|
||||
TEST_RENEWAL=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--domain)
|
||||
DOMAIN_NAME="$2"
|
||||
shift 2
|
||||
;;
|
||||
--mode)
|
||||
DEPLOYMENT_MODE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--with-nginx)
|
||||
WITH_NGINX=true
|
||||
shift
|
||||
;;
|
||||
--with-turn)
|
||||
WITH_TURN=true
|
||||
shift
|
||||
;;
|
||||
--with-sni443)
|
||||
WITH_SNI443=true
|
||||
shift
|
||||
;;
|
||||
--no-sni443)
|
||||
DISABLE_SNI443=true
|
||||
shift
|
||||
;;
|
||||
--enable-web-https)
|
||||
ENABLE_WEB_HTTPS=true
|
||||
shift
|
||||
;;
|
||||
--le-email)
|
||||
LE_EMAIL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--test-renewal)
|
||||
TEST_RENEWAL=true
|
||||
shift
|
||||
;;
|
||||
--clean)
|
||||
CLEAN_MODE=true
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown argument: $1"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Export variables for other scripts
|
||||
export DOMAIN_NAME
|
||||
export DEPLOYMENT_MODE
|
||||
export WITH_NGINX
|
||||
export WITH_TURN
|
||||
}
|
||||
|
||||
# Check dependencies
|
||||
check_dependencies() {
|
||||
log_info "Checking dependencies..."
|
||||
|
||||
local missing_deps=()
|
||||
|
||||
if ! command -v docker &> /dev/null; then
|
||||
missing_deps+=("docker")
|
||||
fi
|
||||
|
||||
if ! docker compose version &> /dev/null; then
|
||||
missing_deps+=("docker compose (V2)")
|
||||
fi
|
||||
|
||||
if ! command -v curl &> /dev/null; then
|
||||
missing_deps+=("curl")
|
||||
fi
|
||||
|
||||
if ! command -v openssl &> /dev/null; then
|
||||
missing_deps+=("openssl")
|
||||
fi
|
||||
|
||||
if [[ ${#missing_deps[@]} -gt 0 ]]; then
|
||||
log_error "Missing dependencies: ${missing_deps[*]}"
|
||||
echo ""
|
||||
echo "Please install the missing dependencies:"
|
||||
for dep in "${missing_deps[@]}"; do
|
||||
case $dep in
|
||||
docker)
|
||||
echo " Docker: https://docs.docker.com/get-docker/"
|
||||
;;
|
||||
"docker compose (V2)")
|
||||
echo " Docker Compose V2 plugin: https://docs.docker.com/compose/install/"
|
||||
;;
|
||||
curl)
|
||||
echo " curl: sudo apt-get install curl (Ubuntu/Debian)"
|
||||
;;
|
||||
openssl)
|
||||
echo " openssl: sudo apt-get install openssl (Ubuntu/Debian)"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "Dependency checks passed"
|
||||
}
|
||||
|
||||
# Install and prepare Let's Encrypt (certbot)
|
||||
ensure_certbot() {
|
||||
if command -v certbot >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
log_info "Installing certbot (requires sudo)..."
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
sudo apt-get update -y && sudo apt-get install -y certbot
|
||||
else
|
||||
log_error "apt-get not found. Please install certbot manually or run on a supported system"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Write certbot deploy hook: copy certs and hot-reload services after renewal
|
||||
install_certbot_deploy_hook() {
|
||||
local repo_dir="$SCRIPT_DIR"
|
||||
local hook_dir="/etc/letsencrypt/renewal-hooks/deploy"
|
||||
local hook_file="$hook_dir/privydrop-reload.sh"
|
||||
local compose_file="$repo_dir/docker-compose.yml"
|
||||
|
||||
sudo mkdir -p "$hook_dir"
|
||||
sudo bash -c "cat > '$hook_file'" << EOF
|
||||
#!/bin/bash
|
||||
set -e
|
||||
REPO_DIR="$repo_dir"
|
||||
COMPOSE_FILE="$compose_file"
|
||||
|
||||
# RENEWED_LINEAGE is provided by certbot and points to live/<domain>
|
||||
if [[ -z "\$RENEWED_LINEAGE" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cp "\$RENEWED_LINEAGE/fullchain.pem" "\$REPO_DIR/docker/ssl/server-cert.pem"
|
||||
cp "\$RENEWED_LINEAGE/privkey.pem" "\$REPO_DIR/docker/ssl/server-key.pem"
|
||||
chmod 600 "\$REPO_DIR/docker/ssl/server-key.pem" || true
|
||||
|
||||
# Hot-reload nginx; restart if it fails
|
||||
docker compose -f "\$COMPOSE_FILE" exec -T nginx nginx -s reload 2>/dev/null || \
|
||||
docker compose -f "\$COMPOSE_FILE" restart nginx || true
|
||||
|
||||
# Prefer sending HUP to coturn; restart if needed (ignore if disabled)
|
||||
docker compose -f "\$COMPOSE_FILE" exec -T coturn sh -c 'kill -HUP 1' 2>/dev/null || \
|
||||
docker compose -f "\$COMPOSE_FILE" restart coturn || true
|
||||
EOF
|
||||
sudo chmod +x "$hook_file"
|
||||
|
||||
# Attempt to enable systemd timer
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
sudo systemctl enable --now certbot.timer 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Ensure renewal is scheduled daily: prefer systemd timer; fallback to cron
|
||||
ensure_renewal_scheduler() {
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
sudo systemctl enable --now certbot.timer 2>/dev/null || true
|
||||
return 0
|
||||
fi
|
||||
# Fallback: cron job every 12 hours
|
||||
if [ -w /etc/cron.d ] || sudo test -d /etc/cron.d; then
|
||||
sudo bash -c 'cat > /etc/cron.d/certbot' << 'EOF'
|
||||
# Auto-renew Let's Encrypt certificates for PrivyDrop
|
||||
SHELL=/bin/bash
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
|
||||
0 */12 * * * root certbot renew -q
|
||||
EOF
|
||||
fi
|
||||
}
|
||||
|
||||
# Dry-run renewal test
|
||||
test_renewal() {
|
||||
log_info "Running certbot dry-run renewal test..."
|
||||
if ! command -v certbot >/dev/null 2>&1; then
|
||||
log_error "certbot not installed. Run full mode issuance first or install certbot manually"
|
||||
return 1
|
||||
fi
|
||||
sudo certbot renew --dry-run || {
|
||||
log_error "certbot dry-run failed"
|
||||
return 1
|
||||
}
|
||||
# Attempt hot-reload of nginx and coturn similar to deploy-hook
|
||||
docker compose exec -T nginx nginx -s reload 2>/dev/null || docker compose restart nginx || true
|
||||
docker compose exec -T coturn sh -c 'kill -HUP 1' 2>/dev/null || docker compose restart coturn || true
|
||||
log_success "Dry-run renewal completed; services reloaded"
|
||||
}
|
||||
|
||||
# Issue via webroot and enable 443 config
|
||||
provision_letsencrypt_cert() {
|
||||
# Only in full mode with nginx enabled and domain set
|
||||
if [[ "$DEPLOYMENT_MODE" != "full" || "$WITH_NGINX" != "true" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ -z "$DOMAIN_NAME" ]]; then
|
||||
log_warning "Full mode without --domain; skipping Let's Encrypt"
|
||||
return 0
|
||||
fi
|
||||
if [[ -z "$LE_EMAIL" ]]; then
|
||||
log_warning "No --le-email specified; using --register-unsafely-without-email"
|
||||
fi
|
||||
|
||||
ensure_certbot
|
||||
install_certbot_deploy_hook
|
||||
ensure_renewal_scheduler
|
||||
|
||||
mkdir -p docker/letsencrypt-www docker/ssl
|
||||
|
||||
# If certificates already exist (including -0001 lineage), skip issuance
|
||||
if [[ -f "/etc/letsencrypt/live/$DOMAIN_NAME/fullchain.pem" ]] || ls -1d /etc/letsencrypt/live/${DOMAIN_NAME}* >/dev/null 2>&1; then
|
||||
log_info "Detected existing certificates/lineage; skipping initial issuance"
|
||||
else
|
||||
log_info "Issuing Let's Encrypt certificate via webroot..."
|
||||
local email_args="--email $LE_EMAIL"
|
||||
if [[ -z "$LE_EMAIL" ]]; then
|
||||
email_args="--register-unsafely-without-email"
|
||||
fi
|
||||
# Requires port 80 reachable and nginx running
|
||||
sudo certbot certonly --webroot -w "$(pwd)/docker/letsencrypt-www" \
|
||||
-d "$DOMAIN_NAME" -d "turn.$DOMAIN_NAME" \
|
||||
$email_args --agree-tos --non-interactive || {
|
||||
log_error "Certificate issuance failed; please check certbot output"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
|
||||
# Resolve lineage directory (supports -0001/-0002 suffixes) and copy to docker/ssl
|
||||
local lineage_dir
|
||||
lineage_dir=$(readlink -f "/etc/letsencrypt/live/$DOMAIN_NAME" 2>/dev/null || true)
|
||||
if [[ -z "$lineage_dir" || ! -d "$lineage_dir" ]]; then
|
||||
lineage_dir=$(ls -1d /etc/letsencrypt/live/${DOMAIN_NAME}* 2>/dev/null | sort | tail -1)
|
||||
fi
|
||||
if [[ -z "$lineage_dir" || ! -f "$lineage_dir/fullchain.pem" ]]; then
|
||||
log_error "No valid certificate lineage directory found. Check /etc/letsencrypt/live/${DOMAIN_NAME}*"
|
||||
return 1
|
||||
fi
|
||||
|
||||
sudo cp "$lineage_dir/fullchain.pem" docker/ssl/server-cert.pem
|
||||
sudo cp "$lineage_dir/privkey.pem" docker/ssl/server-key.pem
|
||||
sudo chmod 600 docker/ssl/server-key.pem || true
|
||||
|
||||
# Enable 443 config (certs ready): append only; pass SNI flag (enabled by default in full)
|
||||
local gen_args=(--mode full --domain "$DOMAIN_NAME" --no-clean --ssl-mode letsencrypt)
|
||||
[[ "$WITH_SNI443" == "true" ]] && gen_args+=(--enable-sni443)
|
||||
[[ "$DISABLE_SNI443" == "true" ]] && gen_args+=(--no-sni443)
|
||||
bash "$DOCKER_SCRIPTS_DIR/generate-config.sh" "${gen_args[@]}" || true
|
||||
|
||||
# Hot-reload nginx to enable 443
|
||||
docker compose exec -T nginx nginx -s reload || docker compose restart nginx
|
||||
}
|
||||
|
||||
# Clean existing deployment
|
||||
clean_deployment() {
|
||||
if [[ "$CLEAN_MODE" == "true" ]]; then
|
||||
log_warning "Cleaning existing deployment..."
|
||||
|
||||
# Stop and remove containers
|
||||
if [[ -f "docker-compose.yml" ]]; then
|
||||
docker compose down -v --remove-orphans 2>/dev/null || true
|
||||
fi
|
||||
# After graceful stop, force-clean named containers as fallback
|
||||
docker stop -t 10 privydrop-nginx privydrop-coturn 2>/dev/null || true
|
||||
docker rm -f privydrop-nginx privydrop-coturn 2>/dev/null || true
|
||||
# Fallback: remove project network (if present)
|
||||
docker network rm privydrop_privydrop-network 2>/dev/null || true
|
||||
|
||||
# Remove images
|
||||
docker images | grep privydrop | awk '{print $3}' | xargs -r docker rmi -f 2>/dev/null || true
|
||||
|
||||
# Clean configuration files
|
||||
rm -rf docker/nginx/conf.d/*.conf docker/ssl/* logs/* .env 2>/dev/null || true
|
||||
|
||||
log_success "Cleanup complete"
|
||||
|
||||
if [[ $# -eq 1 ]]; then # If only --clean parameter
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Ensure TURN service starts when requested (--with-turn)
|
||||
ensure_turn_running() {
|
||||
if [[ "$WITH_TURN" != "true" ]]; then
|
||||
return 0
|
||||
fi
|
||||
# If not running, start coturn via profile
|
||||
if ! docker compose ps | grep -q "privydrop-coturn"; then
|
||||
log_info "Starting TURN service (profile: turn)..."
|
||||
docker compose --profile turn up -d coturn || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Environment detection and configuration generation
|
||||
setup_environment() {
|
||||
log_info "Setting up environment..."
|
||||
|
||||
# Ensure scripts are executable
|
||||
chmod +x "$DOCKER_SCRIPTS_DIR"/*.sh 2>/dev/null || true
|
||||
|
||||
# Run environment detection
|
||||
local detect_args=""
|
||||
[[ -n "$DOMAIN_NAME" ]] && detect_args="--domain $DOMAIN_NAME"
|
||||
[[ -n "$DEPLOYMENT_MODE" ]] && detect_args="$detect_args --mode $DEPLOYMENT_MODE"
|
||||
[[ "$WITH_NGINX" == "true" ]] && detect_args="$detect_args --with-nginx"
|
||||
[[ "$WITH_SNI443" == "true" ]] && detect_args="$detect_args --enable-sni443"
|
||||
[[ "$DISABLE_SNI443" == "true" ]] && detect_args="$detect_args --no-sni443"
|
||||
[[ "$ENABLE_WEB_HTTPS" == "true" ]] && detect_args="$detect_args --enable-web-https"
|
||||
|
||||
if ! bash "$DOCKER_SCRIPTS_DIR/detect-environment.sh" $detect_args; then
|
||||
log_error "Environment detection failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Generate configuration files
|
||||
if ! bash "$DOCKER_SCRIPTS_DIR/generate-config.sh" $detect_args; then
|
||||
log_error "Configuration generation failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "Environment setup complete"
|
||||
}
|
||||
|
||||
# Build and start services
|
||||
deploy_services() {
|
||||
log_info "Building and starting services..."
|
||||
|
||||
# Ensure log directories exist and relax permissions so containers (coturn/nginx etc.) can write logs
|
||||
mkdir -p logs logs/nginx logs/backend logs/frontend logs/coturn 2>/dev/null || true
|
||||
chmod 777 -R logs 2>/dev/null || true
|
||||
log_info "Log directories prepared and permissions set: ./logs (mode 777)"
|
||||
|
||||
# Stop existing services
|
||||
if docker compose ps | grep -q "Up"; then
|
||||
log_info "Stopping existing services..."
|
||||
docker compose down
|
||||
fi
|
||||
|
||||
# Determine enabled services (Compose V2 requires --profile before the subcommand)
|
||||
local profiles=""
|
||||
if [[ "$WITH_NGINX" == "true" ]]; then
|
||||
profiles="$profiles --profile nginx"
|
||||
fi
|
||||
if [[ "$WITH_TURN" == "true" ]]; then
|
||||
profiles="$profiles --profile turn"
|
||||
fi
|
||||
|
||||
# Build images (parallel first, fall back to serial on failure)
|
||||
log_info "Building Docker images..."
|
||||
set +e
|
||||
docker compose build --parallel
|
||||
local build_status=$?
|
||||
set -e
|
||||
if [[ $build_status -ne 0 ]]; then
|
||||
log_warning "Parallel build failed; falling back to serial build..."
|
||||
docker compose build
|
||||
fi
|
||||
|
||||
# Start services (--profile must precede up)
|
||||
log_info "Starting services..."
|
||||
# shellcheck disable=SC2086
|
||||
docker compose $profiles up -d
|
||||
|
||||
log_success "Services started"
|
||||
}
|
||||
|
||||
# Wait for services to be ready
|
||||
wait_for_services() {
|
||||
log_info "Waiting for services to be ready..."
|
||||
|
||||
local max_attempts=60
|
||||
local attempt=0
|
||||
local services_ready=false
|
||||
|
||||
while [[ $attempt -lt $max_attempts ]]; do
|
||||
local backend_ready=false
|
||||
local frontend_ready=false
|
||||
|
||||
# Check backend health
|
||||
if curl -f http://localhost:3001/health &> /dev/null; then
|
||||
backend_ready=true
|
||||
fi
|
||||
|
||||
# Check frontend health
|
||||
if curl -f http://localhost:3002/api/health &> /dev/null; then
|
||||
frontend_ready=true
|
||||
fi
|
||||
|
||||
if [[ "$backend_ready" == "true" ]] && [[ "$frontend_ready" == "true" ]]; then
|
||||
services_ready=true
|
||||
break
|
||||
fi
|
||||
|
||||
attempt=$((attempt + 1))
|
||||
echo -n "."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
if [[ "$services_ready" == "true" ]]; then
|
||||
log_success "All services are ready"
|
||||
return 0
|
||||
else
|
||||
log_error "Service startup timed out"
|
||||
log_info "View service status: docker compose ps"
|
||||
log_info "View service logs: docker compose logs -f"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Run post-deployment checks
|
||||
post_deployment_checks() {
|
||||
log_info "Running post-deployment checks..."
|
||||
|
||||
# Check container status
|
||||
log_info "Checking container status..."
|
||||
docker compose ps
|
||||
|
||||
# In full+nginx, add HTTPS health check (if domain defined)
|
||||
if [[ -f ".env" ]]; then
|
||||
local dep_mode="$(grep "DEPLOYMENT_MODE=" .env | cut -d'=' -f2)"
|
||||
local dname="$(grep "DOMAIN_NAME=" .env | cut -d'=' -f2)"
|
||||
if [[ "$dep_mode" == "full" && -n "$dname" ]]; then
|
||||
log_info "Test: HTTPS health check https://$dname/api/health"
|
||||
if curl -fsS "https://$dname/api/health" >/dev/null; then
|
||||
log_success "HTTPS health check passed"
|
||||
else
|
||||
log_warning "HTTPS health check failed. If the certificate was just issued, wait a bit or run: bash docker/scripts/generate-config.sh --mode full --domain $dname --no-clean && docker compose exec -T nginx nginx -s reload"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run health-check tests
|
||||
if [[ -f "test-health-apis.sh" ]]; then
|
||||
log_info "Running health-check tests..."
|
||||
if bash test-health-apis.sh; then
|
||||
log_success "Health-check tests passed"
|
||||
else
|
||||
log_warning "Health-check tests failed, but services may still be working"
|
||||
fi
|
||||
fi
|
||||
|
||||
log_success "Post-deployment checks complete"
|
||||
}
|
||||
|
||||
# Show deployment results
|
||||
show_deployment_info() {
|
||||
echo ""
|
||||
echo -e "${GREEN}🎉 PrivyDrop deployment complete!${NC}"
|
||||
echo ""
|
||||
|
||||
# Read configuration
|
||||
local local_ip=""
|
||||
local public_ip=""
|
||||
local frontend_port=""
|
||||
local backend_port=""
|
||||
local https_port=""
|
||||
local deployment_mode=""
|
||||
local network_mode=""
|
||||
local domain_name=""
|
||||
local turn_enabled_env=""
|
||||
|
||||
if [[ -f ".env" ]]; then
|
||||
local_ip=$(grep "LOCAL_IP=" .env | cut -d'=' -f2)
|
||||
public_ip=$(grep "PUBLIC_IP=" .env | cut -d'=' -f2)
|
||||
frontend_port=$(grep "FRONTEND_PORT=" .env | cut -d'=' -f2)
|
||||
backend_port=$(grep "BACKEND_PORT=" .env | cut -d'=' -f2)
|
||||
https_port=$(grep "HTTPS_PORT=" .env | cut -d'=' -f2)
|
||||
deployment_mode=$(grep "DEPLOYMENT_MODE=" .env | cut -d'=' -f2)
|
||||
network_mode=$(grep "NETWORK_MODE=" .env | cut -d'=' -f2)
|
||||
domain_name=$(grep "DOMAIN_NAME=" .env | cut -d'=' -f2)
|
||||
turn_enabled_env=$(grep "TURN_ENABLED=" .env | cut -d'=' -f2)
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}📋 Access Info:${NC}"
|
||||
|
||||
# Determine if public scenario (public/full)
|
||||
local is_public="false"
|
||||
if [[ "$deployment_mode" == "public" || "$deployment_mode" == "full" || "$network_mode" == "public" ]]; then
|
||||
is_public="true"
|
||||
fi
|
||||
|
||||
if [[ "$is_public" == "true" ]]; then
|
||||
# For public scenarios, prefer domain, then public IP
|
||||
if [[ -n "$domain_name" ]]; then
|
||||
if [[ "$WITH_NGINX" == "true" || "$deployment_mode" == "full" ]]; then
|
||||
echo " Public access: https://$domain_name"
|
||||
echo " API: https://$domain_name"
|
||||
else
|
||||
echo " Public access: http://$domain_name:${frontend_port:-3002}"
|
||||
echo " API: http://$domain_name:${backend_port:-3001}"
|
||||
fi
|
||||
elif [[ -n "$public_ip" ]]; then
|
||||
if [[ "$WITH_NGINX" == "true" ]]; then
|
||||
echo " Public access: http://$public_ip"
|
||||
echo " API: http://$public_ip"
|
||||
else
|
||||
echo " Public access: http://$public_ip:${frontend_port:-3002}"
|
||||
echo " API: http://$public_ip:${backend_port:-3001}"
|
||||
fi
|
||||
else
|
||||
# Fallback: show LAN and localhost if public IP is unavailable
|
||||
echo " Frontend: http://localhost:${frontend_port:-3002}"
|
||||
echo " Backend API: http://localhost:${backend_port:-3001}"
|
||||
fi
|
||||
else
|
||||
# Private/basic: localhost + LAN
|
||||
if [[ "$WITH_NGINX" == "true" ]]; then
|
||||
# When Nginx is enabled and frontend uses same-origin API, prefer the gateway as the primary entry
|
||||
echo " Frontend: http://localhost"
|
||||
echo " API: http://localhost"
|
||||
else
|
||||
echo " Frontend: http://localhost:${frontend_port:-3002}"
|
||||
echo " Backend API: http://localhost:${backend_port:-3001}"
|
||||
fi
|
||||
if [[ -n "$local_ip" ]] && [[ "$local_ip" != "127.0.0.1" ]]; then
|
||||
echo ""
|
||||
echo -e "${BLUE}🌐 LAN Access:${NC}"
|
||||
if [[ "$WITH_NGINX" == "true" ]]; then
|
||||
echo " Frontend: http://$local_ip"
|
||||
echo " API: http://$local_ip"
|
||||
else
|
||||
echo " Frontend: http://$local_ip:${frontend_port:-3002}"
|
||||
echo " Backend API: http://$local_ip:${backend_port:-3001}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$WITH_NGINX" == "true" ]]; then
|
||||
echo ""
|
||||
echo -e "${BLUE}🔀 Nginx Proxy:${NC}"
|
||||
if [[ -n "$domain_name" ]]; then
|
||||
echo " HTTP: http://$domain_name"
|
||||
if [[ -f "docker/ssl/server-cert.pem" ]]; then
|
||||
echo " HTTPS: https://$domain_name"
|
||||
fi
|
||||
elif [[ -n "$public_ip" ]]; then
|
||||
echo " HTTP: http://$public_ip"
|
||||
if [[ -f "docker/ssl/server-cert.pem" ]]; then
|
||||
# In non-domain cases, show HTTPS with explicit port (e.g., lan-tls uses 8443)
|
||||
if [[ -n "$https_port" && "$https_port" != "443" ]]; then
|
||||
echo " HTTPS: https://$public_ip:$https_port"
|
||||
else
|
||||
echo " HTTPS: https://$public_ip"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo " HTTP: http://localhost"
|
||||
if [[ -f "docker/ssl/server-cert.pem" ]]; then
|
||||
# Show correct HTTPS endpoint based on configured port
|
||||
if [[ -n "$https_port" && "$https_port" != "443" ]]; then
|
||||
echo " HTTPS: https://localhost:$https_port"
|
||||
if [[ -n "$local_ip" && "$local_ip" != "127.0.0.1" ]]; then
|
||||
echo " HTTPS (LAN): https://$local_ip:$https_port"
|
||||
fi
|
||||
else
|
||||
echo " HTTPS: https://localhost"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}🔧 Management Commands:${NC}"
|
||||
echo " Status: docker compose ps"
|
||||
echo " Logs: docker compose logs -f [service]"
|
||||
echo " Restart: docker compose restart [service]"
|
||||
echo " Stop: docker compose down"
|
||||
echo " Full cleanup: $0 --clean"
|
||||
|
||||
if [[ -f "docker/ssl/ca-cert.pem" ]]; then
|
||||
echo ""
|
||||
echo -e "${BLUE}🔒 SSL Certificates:${NC}"
|
||||
echo " CA certificate: docker/ssl/ca-cert.pem"
|
||||
echo " To trust HTTPS, import the CA certificate into your browser"
|
||||
fi
|
||||
|
||||
if [[ "$WITH_TURN" == "true" || "$turn_enabled_env" == "true" ]]; then
|
||||
local turn_username=""
|
||||
local turn_realm=""
|
||||
if [[ -f ".env" ]]; then
|
||||
turn_username=$(grep "TURN_USERNAME=" .env | cut -d'=' -f2)
|
||||
turn_realm=$(grep "TURN_REALM=" .env | cut -d'=' -f2)
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${BLUE}🔄 TURN Server:${NC}"
|
||||
# Prefer domain for TURN info; otherwise show public IP
|
||||
if [[ -n "$domain_name" ]]; then
|
||||
echo " STUN: stun:${domain_name}:3478"
|
||||
echo " TURN (UDP): turn:${domain_name}:3478"
|
||||
echo " TURN (TLS): turns:turn.${domain_name}:443 (if 443 SNI split is configured)"
|
||||
elif [[ -n "$public_ip" ]]; then
|
||||
echo " STUN: stun:${public_ip}:3478"
|
||||
echo " TURN: turn:${public_ip}:3478"
|
||||
else
|
||||
echo " STUN: stun:${local_ip}:3478"
|
||||
echo " TURN: turn:${local_ip}:3478"
|
||||
fi
|
||||
echo " Username: ${turn_username:-privydrop}"
|
||||
echo " Password: (stored in .env)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}💡 Tips:${NC}"
|
||||
echo " - First run may take several minutes to download and build images"
|
||||
echo " - If issues occur, check logs: docker compose logs -f"
|
||||
echo " - More help: $0 --help"
|
||||
echo ""
|
||||
|
||||
# Public scenario: for domain + HTTPS setup steps, see docs
|
||||
if [[ "$is_public" == "true" && -z "$domain_name" ]]; then
|
||||
echo -e "${BLUE}🌍 Domain + HTTPS guide:${NC} see docs/DEPLOYMENT_docker.md or docs/DEPLOYMENT_docker.zh-CN.md"
|
||||
fi
|
||||
}
|
||||
|
||||
# Main function
|
||||
main() {
|
||||
echo -e "${BLUE}=== PrivyDrop Docker One-Click Deployment ===${NC}"
|
||||
echo ""
|
||||
|
||||
# Parse command-line arguments
|
||||
parse_arguments "$@"
|
||||
|
||||
# Check dependencies
|
||||
check_dependencies
|
||||
echo ""
|
||||
|
||||
# If only testing renewal, run and exit
|
||||
if [[ "$TEST_RENEWAL" == "true" ]]; then
|
||||
test_renewal && exit 0 || exit 1
|
||||
fi
|
||||
|
||||
# Clean mode
|
||||
clean_deployment
|
||||
# If only cleaning (no other args), exit early to skip env detection
|
||||
if [[ "$CLEAN_MODE" == "true" && -z "$DEPLOYMENT_MODE" && "$WITH_NGINX" == "false" && "$WITH_TURN" == "false" && -z "$DOMAIN_NAME" ]]; then
|
||||
log_success "Cleanup complete (clean-only mode). Exiting."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Environment setup
|
||||
setup_environment
|
||||
echo ""
|
||||
|
||||
# Deploy services
|
||||
deploy_services
|
||||
echo ""
|
||||
|
||||
# If full + nginx, automatically issue certs and enable 443
|
||||
provision_letsencrypt_cert || true
|
||||
# Ensure TURN is running (when requested with --with-turn)
|
||||
ensure_turn_running || true
|
||||
|
||||
# Wait for services to be ready
|
||||
if wait_for_services; then
|
||||
echo ""
|
||||
post_deployment_checks
|
||||
show_deployment_info
|
||||
else
|
||||
log_error "Deployment failed. Please check logs: docker compose logs"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Trap interrupt signals
|
||||
trap 'log_warning "Deployment interrupted"; exit 1' INT TERM
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
@@ -0,0 +1,144 @@
|
||||
services:
|
||||
# Redis cache service
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: privydrop-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- privydrop-network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
# Backend signaling service
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- HTTP_PROXY=${HTTP_PROXY}
|
||||
- HTTPS_PROXY=${HTTPS_PROXY}
|
||||
- NO_PROXY=${NO_PROXY}
|
||||
container_name: privydrop-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- BACKEND_PORT=3001
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost}
|
||||
ports:
|
||||
- "${BACKEND_PORT:-3001}:3001"
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- privydrop-network
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "health-check.js"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# Frontend app
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- HTTP_PROXY=${HTTP_PROXY}
|
||||
- HTTPS_PROXY=${HTTPS_PROXY}
|
||||
- NO_PROXY=${NO_PROXY}
|
||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
- NEXT_PUBLIC_TURN_HOST=${NEXT_PUBLIC_TURN_HOST}
|
||||
- NEXT_PUBLIC_TURN_USERNAME=${NEXT_PUBLIC_TURN_USERNAME}
|
||||
- NEXT_PUBLIC_TURN_PASSWORD=${NEXT_PUBLIC_TURN_PASSWORD}
|
||||
- NEXT_IMAGE_UNOPTIMIZED=${NEXT_IMAGE_UNOPTIMIZED:-true}
|
||||
container_name: privydrop-frontend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:3001}
|
||||
- BACKEND_INTERNAL_URL=${BACKEND_INTERNAL_URL:-http://backend:3001}
|
||||
- NEXT_IMAGE_UNOPTIMIZED=${NEXT_IMAGE_UNOPTIMIZED:-true}
|
||||
- PORT=3002
|
||||
- HOSTNAME=0.0.0.0
|
||||
- NODE_EXTRA_CA_CERTS=/opt/privydrop/ssl/ca-cert.pem
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3002}:3002"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- privydrop-network
|
||||
volumes:
|
||||
- ./docker/ssl:/opt/privydrop/ssl:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "health-check.js"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 120s
|
||||
|
||||
# Nginx reverse proxy
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: privydrop-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${HTTP_PORT:-80}:80"
|
||||
- "${HTTPS_PORT:-443}:${DOCKER_HTTPS_CONTAINER_PORT:-443}"
|
||||
volumes:
|
||||
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./docker/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
- ./docker/letsencrypt-www:/var/www/certbot:ro
|
||||
- ./docker/ssl:/etc/nginx/ssl:ro
|
||||
- ./logs/nginx:/var/log/nginx
|
||||
depends_on:
|
||||
- frontend
|
||||
- backend
|
||||
networks:
|
||||
- privydrop-network
|
||||
profiles:
|
||||
- nginx
|
||||
|
||||
# TURN/STUN server (optional, for NAT traversal)
|
||||
coturn:
|
||||
image: coturn/coturn:4.6.2
|
||||
container_name: privydrop-coturn
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3478:3478/tcp"
|
||||
- "3478:3478/udp"
|
||||
- "5349:5349/tcp"
|
||||
- "5349:5349/udp"
|
||||
- "${TURN_MIN_PORT:-49152}-${TURN_MAX_PORT:-49252}:${TURN_MIN_PORT:-49152}-${TURN_MAX_PORT:-49252}/udp"
|
||||
volumes:
|
||||
- ./docker/coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro
|
||||
- ./docker/ssl:/etc/ssl/certs:ro
|
||||
- ./logs/coturn:/var/log
|
||||
networks:
|
||||
- privydrop-network
|
||||
profiles:
|
||||
- turn
|
||||
command: ["-c", "/etc/coturn/turnserver.conf"]
|
||||
|
||||
networks:
|
||||
privydrop-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
driver: local
|
||||
Executable
+487
@@ -0,0 +1,487 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Color definitions
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Global variables
|
||||
NETWORK_MODE=""
|
||||
LOCAL_IP=""
|
||||
PUBLIC_IP=""
|
||||
DEPLOYMENT_MODE="basic"
|
||||
FORCED_MODE=""
|
||||
LOCAL_IP_OVERRIDE=""
|
||||
|
||||
declare -a IP_CANDIDATES=()
|
||||
declare -A __SEEN_IPS=()
|
||||
|
||||
add_ip_candidate() {
|
||||
local ip="$1"
|
||||
[[ -z "$ip" ]] && return
|
||||
[[ "$ip" == "127."* ]] && return
|
||||
[[ "$ip" == "0.0.0.0" ]] && return
|
||||
if [[ -z "${__SEEN_IPS[$ip]}" ]]; then
|
||||
IP_CANDIDATES+=("$ip")
|
||||
__SEEN_IPS[$ip]=1
|
||||
fi
|
||||
}
|
||||
|
||||
is_rfc1918_ip() {
|
||||
local ip="$1"
|
||||
case "$ip" in
|
||||
10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[0-1].*)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
is_cgnat_ip() {
|
||||
local ip="$1"
|
||||
case "$ip" in
|
||||
100.*)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
is_reserved_benchmark_ip() {
|
||||
local ip="$1"
|
||||
case "$ip" in
|
||||
198.18.*|198.19.*)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
is_link_local_ip() {
|
||||
local ip="$1"
|
||||
case "$ip" in
|
||||
169.254.*)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
is_routable_public_ip() {
|
||||
local ip="$1"
|
||||
if [[ -z "$ip" ]]; then
|
||||
return 1
|
||||
fi
|
||||
if is_rfc1918_ip "$ip"; then
|
||||
return 1
|
||||
fi
|
||||
if is_cgnat_ip "$ip"; then
|
||||
return 1
|
||||
fi
|
||||
if is_reserved_benchmark_ip "$ip"; then
|
||||
return 1
|
||||
fi
|
||||
case "$ip" in
|
||||
127.*|169.254.*)
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
collect_ip_candidates() {
|
||||
IP_CANDIDATES=()
|
||||
unset __SEEN_IPS
|
||||
declare -A __SEEN_IPS=()
|
||||
|
||||
if command -v hostname >/dev/null 2>&1; then
|
||||
local host_ips
|
||||
host_ips=$(hostname -I 2>/dev/null || true)
|
||||
for ip in $host_ips; do
|
||||
add_ip_candidate "$ip"
|
||||
done
|
||||
fi
|
||||
|
||||
if command -v ip >/dev/null 2>&1; then
|
||||
while IFS= read -r ip; do
|
||||
add_ip_candidate "$ip"
|
||||
done < <(ip -o -4 addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1)
|
||||
fi
|
||||
|
||||
if command -v ifconfig >/dev/null 2>&1; then
|
||||
while IFS= read -r ip; do
|
||||
add_ip_candidate "$ip"
|
||||
done < <(ifconfig 2>/dev/null | awk '/inet / {print $2}' | grep -E '^[0-9]+(\.[0-9]+){3}$')
|
||||
fi
|
||||
|
||||
if command -v ip >/dev/null 2>&1; then
|
||||
local route_ip
|
||||
route_ip=$(ip route get 1.1.1.1 2>/dev/null | awk '{for (i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}')
|
||||
add_ip_candidate "$route_ip"
|
||||
fi
|
||||
|
||||
if [[ ${#IP_CANDIDATES[@]} -eq 0 ]]; then
|
||||
local fallback
|
||||
fallback=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
add_ip_candidate "$fallback"
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_local_ip() {
|
||||
if [[ -n "$LOCAL_IP_OVERRIDE" ]]; then
|
||||
LOCAL_IP="$LOCAL_IP_OVERRIDE"
|
||||
return
|
||||
fi
|
||||
|
||||
collect_ip_candidates
|
||||
|
||||
if [[ ${#IP_CANDIDATES[@]} -eq 0 ]]; then
|
||||
LOCAL_IP=""
|
||||
return
|
||||
fi
|
||||
|
||||
local ip
|
||||
for ip in "${IP_CANDIDATES[@]}"; do
|
||||
if is_rfc1918_ip "$ip"; then
|
||||
LOCAL_IP="$ip"
|
||||
return
|
||||
fi
|
||||
done
|
||||
|
||||
for ip in "${IP_CANDIDATES[@]}"; do
|
||||
if is_cgnat_ip "$ip"; then
|
||||
LOCAL_IP="$ip"
|
||||
return
|
||||
fi
|
||||
done
|
||||
|
||||
for ip in "${IP_CANDIDATES[@]}"; do
|
||||
if is_reserved_benchmark_ip "$ip"; then
|
||||
continue
|
||||
fi
|
||||
if is_link_local_ip "$ip"; then
|
||||
continue
|
||||
fi
|
||||
LOCAL_IP="$ip"
|
||||
return
|
||||
done
|
||||
|
||||
LOCAL_IP="${IP_CANDIDATES[0]}"
|
||||
}
|
||||
|
||||
# Logging helpers
|
||||
log_info() {
|
||||
echo -e "${BLUE}ℹ️ $1${NC}"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}✅ $1${NC}"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}❌ $1${NC}"
|
||||
}
|
||||
|
||||
# Detect network environment
|
||||
detect_network_environment() {
|
||||
log_info "Detecting network environment..."
|
||||
|
||||
resolve_local_ip
|
||||
|
||||
if [[ -z "$LOCAL_IP" ]]; then
|
||||
LOCAL_IP="127.0.0.1"
|
||||
log_warning "Unable to detect host IP; using default: $LOCAL_IP"
|
||||
fi
|
||||
|
||||
if [[ "$FORCED_MODE" == "private" ]]; then
|
||||
NETWORK_MODE="private"
|
||||
PUBLIC_IP=""
|
||||
log_info "Network mode set via parameters: $NETWORK_MODE"
|
||||
echo " Local IP: $LOCAL_IP"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local mode_guess="private"
|
||||
local printed_prompt_info="false"
|
||||
PUBLIC_IP=""
|
||||
|
||||
if curl -s --connect-timeout 5 --max-time 10 ifconfig.me > /dev/null 2>&1; then
|
||||
PUBLIC_IP=$(curl -s --connect-timeout 5 --max-time 10 ifconfig.me 2>/dev/null || echo "")
|
||||
if [[ -n "$PUBLIC_IP" ]]; then
|
||||
if is_routable_public_ip "$PUBLIC_IP"; then
|
||||
mode_guess="public"
|
||||
else
|
||||
log_warning "Public IP is test/reserved range; treating as private"
|
||||
fi
|
||||
else
|
||||
log_warning "Public connectivity unstable; treating as private"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$FORCED_MODE" ]]; then
|
||||
if [[ "$mode_guess" == "public" ]]; then
|
||||
NETWORK_MODE="public"
|
||||
else
|
||||
NETWORK_MODE="private"
|
||||
fi
|
||||
else
|
||||
NETWORK_MODE="$FORCED_MODE"
|
||||
if [[ "$FORCED_MODE" == "public" && -z "$PUBLIC_IP" ]]; then
|
||||
log_warning "Could not detect public IP; continuing as public mode. Please verify network config"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$NETWORK_MODE" != "public" ]]; then
|
||||
PUBLIC_IP=""
|
||||
fi
|
||||
|
||||
if [[ "$FORCED_MODE" == "public" ]]; then
|
||||
log_info "Network mode set via parameters: $NETWORK_MODE"
|
||||
elif [[ "$NETWORK_MODE" == "public" ]]; then
|
||||
log_success "Public network detected"
|
||||
else
|
||||
log_success "Private network detected"
|
||||
fi
|
||||
|
||||
if [[ "$printed_prompt_info" == "false" ]]; then
|
||||
echo " Local IP: $LOCAL_IP"
|
||||
if [[ "$NETWORK_MODE" == "public" && -n "$PUBLIC_IP" ]]; then
|
||||
echo " Public IP: $PUBLIC_IP"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Check system resources
|
||||
check_system_resources() {
|
||||
log_info "Checking system resources..."
|
||||
|
||||
local warnings=0
|
||||
|
||||
# Check memory
|
||||
if command -v free >/dev/null 2>&1; then
|
||||
TOTAL_MEM=$(free -m | awk 'NR==2{print $2}')
|
||||
if [[ $TOTAL_MEM -lt 512 ]]; then
|
||||
log_error "Insufficient memory: ${TOTAL_MEM}MB (512MB+ recommended)"
|
||||
return 1
|
||||
elif [[ $TOTAL_MEM -lt 1024 ]]; then
|
||||
log_warning "Low memory: ${TOTAL_MEM}MB (1GB+ recommended)"
|
||||
warnings=$((warnings + 1))
|
||||
else
|
||||
log_success "Memory OK: ${TOTAL_MEM}MB"
|
||||
fi
|
||||
else
|
||||
log_warning "Unable to read memory usage"
|
||||
warnings=$((warnings + 1))
|
||||
fi
|
||||
|
||||
# Check disk usage
|
||||
DISK_USAGE=$(df -h / | awk 'NR==2{print $5}' | sed 's/%//')
|
||||
if [[ $DISK_USAGE -gt 95 ]]; then
|
||||
log_error "Insufficient disk space: ${DISK_USAGE}% used"
|
||||
return 1
|
||||
elif [[ $DISK_USAGE -gt 80 ]]; then
|
||||
log_warning "Disk space tight: ${DISK_USAGE}% used"
|
||||
warnings=$((warnings + 1))
|
||||
else
|
||||
log_success "Disk space OK: ${DISK_USAGE}% used"
|
||||
fi
|
||||
|
||||
# Check available disk space
|
||||
AVAILABLE_SPACE=$(df -BG / | awk 'NR==2{print $4}' | sed 's/G//')
|
||||
if [[ $AVAILABLE_SPACE -lt 2 ]]; then
|
||||
log_error "Not enough free disk space: ${AVAILABLE_SPACE}GB (2GB+ recommended)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ $warnings -gt 0 ]]; then
|
||||
log_warning "System resource check passed with $warnings warning(s)"
|
||||
else
|
||||
log_success "System resource check passed"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Validate Docker environment
|
||||
verify_docker_installation() {
|
||||
log_info "Checking Docker environment..."
|
||||
|
||||
if ! command -v docker &> /dev/null; then
|
||||
log_error "Docker is not installed"
|
||||
echo "Please install Docker: https://docs.docker.com/get-docker/"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
|
||||
log_error "Docker Compose is not installed"
|
||||
echo "Please install Docker Compose: https://docs.docker.com/compose/install/"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check Docker service status
|
||||
if ! docker info &> /dev/null; then
|
||||
log_error "Docker service is not running"
|
||||
echo "Please start the Docker service"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check Docker version
|
||||
DOCKER_VERSION=$(docker --version | grep -oE '[0-9]+\.[0-9]+' | head -1)
|
||||
log_success "Docker version: $DOCKER_VERSION"
|
||||
|
||||
# Check Docker Compose version
|
||||
if command -v docker-compose &> /dev/null; then
|
||||
COMPOSE_VERSION=$(docker-compose --version | grep -oE '[0-9]+\.[0-9]+' | head -1)
|
||||
log_success "Docker Compose version: $COMPOSE_VERSION"
|
||||
else
|
||||
COMPOSE_VERSION=$(docker compose version --short 2>/dev/null || echo "built-in")
|
||||
log_success "Docker Compose version: $COMPOSE_VERSION"
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Check port usage
|
||||
check_port_availability() {
|
||||
local ports="$1"
|
||||
log_info "Checking port usage..."
|
||||
|
||||
local occupied_ports=()
|
||||
|
||||
IFS=',' read -ra PORT_ARRAY <<< "$ports"
|
||||
for port in "${PORT_ARRAY[@]}"; do
|
||||
port=$(echo "$port" | xargs) # Trim spaces
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
if ss -tuln | grep -q ":$port "; then
|
||||
occupied_ports+=("$port")
|
||||
fi
|
||||
elif command -v netstat >/dev/null 2>&1; then
|
||||
if netstat -tuln 2>/dev/null | grep -q ":$port "; then
|
||||
occupied_ports+=("$port")
|
||||
fi
|
||||
else
|
||||
log_warning "Unable to check port usage (missing ss and netstat)"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#occupied_ports[@]} -gt 0 ]]; then
|
||||
log_warning "Ports in use: ${occupied_ports[*]}"
|
||||
log_info "Change ports in .env, or run './deploy.sh --clean' / 'docker-compose down' to clean old containers"
|
||||
else
|
||||
log_success "All ports available"
|
||||
fi
|
||||
}
|
||||
|
||||
# Detect deployment mode
|
||||
detect_deployment_mode() {
|
||||
log_info "Determining deployment mode..."
|
||||
|
||||
if [[ "$NETWORK_MODE" == "public" ]] && [[ -n "$DOMAIN_NAME" ]]; then
|
||||
DEPLOYMENT_MODE="full"
|
||||
log_success "Deployment mode: full (HTTPS + TURN server)"
|
||||
elif [[ "$NETWORK_MODE" == "public" ]]; then
|
||||
DEPLOYMENT_MODE="public"
|
||||
log_success "Deployment mode: public (HTTP + TURN)"
|
||||
else
|
||||
DEPLOYMENT_MODE="basic"
|
||||
log_success "Deployment mode: basic (intranet HTTP)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Main function
|
||||
main() {
|
||||
echo -e "${BLUE}=== PrivyDrop Docker Environment Check ===${NC}\n"
|
||||
|
||||
# Parse command-line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--domain)
|
||||
DOMAIN_NAME="$2"
|
||||
shift 2
|
||||
;;
|
||||
--mode)
|
||||
DEPLOYMENT_MODE="$2"
|
||||
case "$2" in
|
||||
private|basic)
|
||||
FORCED_MODE="private"
|
||||
;;
|
||||
public|full)
|
||||
FORCED_MODE="public"
|
||||
;;
|
||||
*)
|
||||
FORCED_MODE=""
|
||||
;;
|
||||
esac
|
||||
shift 2
|
||||
;;
|
||||
--local-ip)
|
||||
LOCAL_IP_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Run checks
|
||||
detect_network_environment
|
||||
echo ""
|
||||
|
||||
if ! check_system_resources; then
|
||||
log_error "System resource check failed; resolve resource issues and retry"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
if ! verify_docker_installation; then
|
||||
log_error "Docker environment check failed; please install and start Docker"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
check_port_availability "80,443,3002,3001,3478,5349,6379"
|
||||
echo ""
|
||||
|
||||
detect_deployment_mode
|
||||
echo ""
|
||||
|
||||
log_success "Environment check complete!"
|
||||
echo -e "${BLUE}Results:${NC}"
|
||||
echo " Network mode: $NETWORK_MODE"
|
||||
echo " Local IP: $LOCAL_IP"
|
||||
[[ -n "$PUBLIC_IP" ]] && echo " Public IP: $PUBLIC_IP"
|
||||
echo " Deployment mode: $DEPLOYMENT_MODE"
|
||||
|
||||
# Export env vars for other scripts
|
||||
export NETWORK_MODE
|
||||
export LOCAL_IP
|
||||
export PUBLIC_IP
|
||||
export DEPLOYMENT_MODE
|
||||
export DOMAIN_NAME
|
||||
export LOCAL_IP_OVERRIDE
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# If the script is executed directly
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
main "$@"
|
||||
fi
|
||||
Executable
+1086
File diff suppressed because it is too large
Load Diff
Executable
+420
@@ -0,0 +1,420 @@
|
||||
#!/bin/bash
|
||||
|
||||
# PrivyDrop Docker deployment test script
|
||||
# Validate deployment integrity and functionality
|
||||
|
||||
# Color definitions
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Test result counters
|
||||
TESTS_PASSED=0
|
||||
TESTS_FAILED=0
|
||||
TOTAL_TESTS=0
|
||||
|
||||
# Logging helpers
|
||||
log_info() {
|
||||
echo -e "${BLUE}ℹ️ $1${NC}"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}✅ $1${NC}"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}❌ $1${NC}"
|
||||
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||
}
|
||||
|
||||
# Test functions
|
||||
run_test() {
|
||||
local test_name="$1"
|
||||
local test_command="$2"
|
||||
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
log_info "Test: $test_name"
|
||||
|
||||
if eval "$test_command" >/dev/null 2>&1; then
|
||||
log_success "$test_name"
|
||||
return 0
|
||||
else
|
||||
log_error "$test_name"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Docker environment tests
|
||||
test_docker_environment() {
|
||||
echo -e "${BLUE}=== Docker Environment Tests ===${NC}"
|
||||
|
||||
run_test "Docker installed" "command -v docker"
|
||||
run_test "Docker daemon running" "docker info"
|
||||
run_test "Docker Compose available" "docker-compose --version || docker compose version"
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Container status tests
|
||||
test_container_status() {
|
||||
echo -e "${BLUE}=== Container Status Tests ===${NC}"
|
||||
|
||||
# Check if containers exist and are running
|
||||
local containers=("privydrop-redis" "privydrop-backend" "privydrop-frontend")
|
||||
|
||||
for container in "${containers[@]}"; do
|
||||
run_test "Container $container is running" "docker ps | grep -q $container"
|
||||
done
|
||||
|
||||
# Check container health
|
||||
for container in "${containers[@]}"; do
|
||||
if docker ps --format "table {{.Names}}\t{{.Status}}" | grep -q "$container.*healthy"; then
|
||||
log_success "Container $container health OK"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
log_warning "Container $container health unknown or unhealthy"
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
done
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Network connectivity tests
|
||||
test_network_connectivity() {
|
||||
echo -e "${BLUE}=== Network Connectivity Tests ===${NC}"
|
||||
|
||||
# Test port connectivity
|
||||
local ports=("3002:Frontend" "3001:Backend" "6379:Redis")
|
||||
|
||||
for port_info in "${ports[@]}"; do
|
||||
local port=$(echo "$port_info" | cut -d':' -f1)
|
||||
local service=$(echo "$port_info" | cut -d':' -f2)
|
||||
|
||||
run_test "$service port $port reachable" "nc -z localhost $port"
|
||||
done
|
||||
|
||||
# Test inter-container networking
|
||||
run_test "Backend can connect to Redis" "docker-compose exec -T backend sh -c 'nc -z redis 6379'"
|
||||
run_test "Frontend can reach backend" "curl -f http://localhost:3001/health"
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# API functionality tests
|
||||
test_api_functionality() {
|
||||
echo -e "${BLUE}=== API Functionality Tests ===${NC}"
|
||||
|
||||
# Health check APIs
|
||||
run_test "Backend health check API" "curl -f http://localhost:3001/health"
|
||||
run_test "Frontend health check API" "curl -f http://localhost:3002/api/health"
|
||||
|
||||
# Backend detailed health check
|
||||
if curl -f http://localhost:3001/health/detailed >/dev/null 2>&1; then
|
||||
local redis_status=$(curl -s http://localhost:3001/health/detailed | jq -r '.dependencies.redis.status' 2>/dev/null)
|
||||
if [[ "$redis_status" == "connected" ]]; then
|
||||
log_success "Redis connection OK"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
log_error "Redis connection issue"
|
||||
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||
fi
|
||||
else
|
||||
log_error "Detailed health check API unavailable"
|
||||
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
|
||||
# Application API tests
|
||||
run_test "Get room API" "curl -f http://localhost:3001/api/get_room"
|
||||
run_test "Create room API" "curl -f -X POST -H 'Content-Type: application/json' -d '{\"roomId\":\"test123\"}' http://localhost:3001/api/create_room"
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# WebRTC functionality tests
|
||||
test_webrtc_functionality() {
|
||||
echo -e "${BLUE}=== WebRTC Functionality Tests ===${NC}"
|
||||
|
||||
# Test frontend page load
|
||||
if curl -f http://localhost:3002 >/dev/null 2>&1; then
|
||||
log_success "Frontend page reachable"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
log_error "Frontend page not reachable"
|
||||
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
|
||||
# Test Socket.IO connection (basic)
|
||||
if curl -f http://localhost:3001/socket.io/socket.io.js >/dev/null 2>&1; then
|
||||
log_success "Socket.IO client script reachable"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
log_error "Socket.IO client script not reachable"
|
||||
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Performance tests
|
||||
test_performance() {
|
||||
echo -e "${BLUE}=== Performance Tests ===${NC}"
|
||||
|
||||
# Memory usage test
|
||||
local backend_memory=$(docker stats --no-stream --format "table {{.Container}}\t{{.MemUsage}}" | grep privydrop-backend | awk '{print $2}' | cut -d'/' -f1)
|
||||
local frontend_memory=$(docker stats --no-stream --format "table {{.Container}}\t{{.MemUsage}}" | grep privydrop-frontend | awk '{print $2}' | cut -d'/' -f1)
|
||||
|
||||
if [[ -n "$backend_memory" ]]; then
|
||||
log_info "Backend memory usage: $backend_memory"
|
||||
fi
|
||||
|
||||
if [[ -n "$frontend_memory" ]]; then
|
||||
log_info "Frontend memory usage: $frontend_memory"
|
||||
fi
|
||||
|
||||
# Response time test
|
||||
local response_time=$(curl -o /dev/null -s -w '%{time_total}' http://localhost:3001/health)
|
||||
if (( $(echo "$response_time < 1.0" | bc -l) )); then
|
||||
log_success "API response time OK: ${response_time}s"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
log_warning "API response time slow: ${response_time}s"
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Security tests
|
||||
test_security() {
|
||||
echo -e "${BLUE}=== Security Tests ===${NC}"
|
||||
|
||||
# Check container users
|
||||
local backend_user=$(docker-compose exec -T backend whoami 2>/dev/null || echo "unknown")
|
||||
local frontend_user=$(docker-compose exec -T frontend whoami 2>/dev/null || echo "unknown")
|
||||
|
||||
if [[ "$backend_user" != "root" ]]; then
|
||||
log_success "Backend container uses non-root user: $backend_user"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
log_warning "Backend container runs as root"
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
|
||||
if [[ "$frontend_user" != "root" ]]; then
|
||||
log_success "Frontend container uses non-root user: $frontend_user"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
log_warning "Frontend container runs as root"
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
|
||||
# Check for sensitive info leakage
|
||||
if curl -s http://localhost:3001/health/detailed | grep -q "password\|secret\|key" >/dev/null 2>&1; then
|
||||
log_warning "Health check API may leak sensitive info"
|
||||
else
|
||||
log_success "Health check API does not leak sensitive info"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Logging tests
|
||||
test_logging() {
|
||||
echo -e "${BLUE}=== Logging Tests ===${NC}"
|
||||
|
||||
# Check log directories
|
||||
if [[ -d "logs" ]]; then
|
||||
log_success "Log directory exists"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
log_warning "Log directory does not exist"
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
|
||||
# Check log files
|
||||
local log_files=("logs/backend" "logs/frontend")
|
||||
for log_dir in "${log_files[@]}"; do
|
||||
if [[ -d "$log_dir" ]]; then
|
||||
log_success "Log directory $log_dir exists"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
log_info "Log directory $log_dir not found (may be normal)"
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
done
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Configuration file tests
|
||||
test_configuration() {
|
||||
echo -e "${BLUE}=== Configuration File Tests ===${NC}"
|
||||
|
||||
# Check env file
|
||||
if [[ -f ".env" ]]; then
|
||||
log_success ".env file exists"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
|
||||
# Check key configuration entries
|
||||
local required_vars=("LOCAL_IP" "CORS_ORIGIN" "NEXT_PUBLIC_API_URL")
|
||||
for var in "${required_vars[@]}"; do
|
||||
if grep -q "^$var=" .env; then
|
||||
log_success "Config $var is set"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
log_error "Config $var is not set"
|
||||
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
done
|
||||
else
|
||||
log_error ".env file not found"
|
||||
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
|
||||
# Check Docker Compose file
|
||||
if [[ -f "docker-compose.yml" ]]; then
|
||||
log_success "docker-compose.yml exists"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
log_error "docker-compose.yml not found"
|
||||
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Cleanup tests
|
||||
test_cleanup() {
|
||||
echo -e "${BLUE}=== Cleanup Tests ===${NC}"
|
||||
|
||||
# Verify cleanup commands work
|
||||
if [[ -f "deploy.sh" ]]; then
|
||||
log_success "Deployment script exists"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
|
||||
# Test help command
|
||||
if bash deploy.sh --help >/dev/null 2>&1; then
|
||||
log_success "Deployment script help works"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
log_error "Deployment script help fails"
|
||||
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||
fi
|
||||
else
|
||||
log_error "Deployment script not found"
|
||||
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||
fi
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 2))
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Generate test report
|
||||
generate_report() {
|
||||
echo -e "${BLUE}=== Test Report ===${NC}"
|
||||
echo ""
|
||||
|
||||
echo "📊 Test stats:"
|
||||
echo " Total tests: $TOTAL_TESTS"
|
||||
echo -e " Passed: ${GREEN}$TESTS_PASSED${NC}"
|
||||
echo -e " Failed: ${RED}$TESTS_FAILED${NC}"
|
||||
|
||||
local success_rate=$((TESTS_PASSED * 100 / TOTAL_TESTS))
|
||||
echo " Success rate: $success_rate%"
|
||||
|
||||
echo ""
|
||||
echo "📋 System info:"
|
||||
echo " Docker version: $(docker --version)"
|
||||
echo " Docker Compose version: $(docker-compose --version 2>/dev/null || docker compose version 2>/dev/null || echo 'unknown')"
|
||||
echo " OS: $(uname -s) $(uname -r)"
|
||||
echo " Test time: $(date)"
|
||||
|
||||
echo ""
|
||||
if [[ $TESTS_FAILED -eq 0 ]]; then
|
||||
echo -e "${GREEN}🎉 All tests passed! PrivyDrop deployment successful!${NC}"
|
||||
echo ""
|
||||
echo "🔗 Access links:"
|
||||
echo " Frontend: http://localhost:3002"
|
||||
echo " Backend API: http://localhost:3001"
|
||||
|
||||
# Show LAN access addresses
|
||||
if [[ -f ".env" ]]; then
|
||||
local local_ip=$(grep "LOCAL_IP=" .env | cut -d'=' -f2)
|
||||
if [[ -n "$local_ip" && "$local_ip" != "127.0.0.1" ]]; then
|
||||
echo ""
|
||||
echo "🌐 LAN access:"
|
||||
echo " Frontend: http://$local_ip:3002"
|
||||
echo " Backend API: http://$local_ip:3001"
|
||||
fi
|
||||
fi
|
||||
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}❌ $TESTS_FAILED test(s) failed${NC}"
|
||||
echo ""
|
||||
echo "🔧 Troubleshooting tips:"
|
||||
echo " 1. View container status: docker-compose ps"
|
||||
echo " 2. View container logs: docker-compose logs -f"
|
||||
echo " 3. Redeploy: bash deploy.sh"
|
||||
echo " 4. Clean and redeploy: bash deploy.sh --clean"
|
||||
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Main function
|
||||
main() {
|
||||
echo -e "${BLUE}=== PrivyDrop Docker Deployment Tests ===${NC}"
|
||||
echo ""
|
||||
|
||||
# Check required tools
|
||||
local missing_tools=()
|
||||
for tool in curl jq bc nc; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
missing_tools+=("$tool")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#missing_tools[@]} -gt 0 ]]; then
|
||||
log_warning "Missing test tools: ${missing_tools[*]}"
|
||||
log_info "Suggested install: sudo apt-get install curl jq bc netcat"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Run all tests
|
||||
test_docker_environment
|
||||
test_container_status
|
||||
test_network_connectivity
|
||||
test_api_functionality
|
||||
test_webrtc_functionality
|
||||
test_performance
|
||||
test_security
|
||||
test_logging
|
||||
test_configuration
|
||||
test_cleanup
|
||||
|
||||
# Generate report
|
||||
generate_report
|
||||
}
|
||||
|
||||
# Trap interrupt signals
|
||||
trap 'echo -e "\n${YELLOW}Tests interrupted${NC}"; exit 1' INT TERM
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
+10
-1
@@ -1,4 +1,8 @@
|
||||
# PrivyDrop Deployment Guide
|
||||
# PrivyDrop Deployment Guide (Bare-Metal)
|
||||
|
||||
> Audience & Scope: This guide targets developers/operators who prefer a non-container (bare-metal) setup.
|
||||
>
|
||||
> Recommended: Prefer the one-click Docker deployment for simplicity and robustness, including auto HTTPS and TURN. See [Docker Deployment Guide](./DEPLOYMENT_docker.md).
|
||||
|
||||
This guide provides comprehensive instructions for deploying the full-stack PrivyDrop application, including setting up Redis, a TURN server, the backend service, the frontend application, and configuring Nginx as a reverse proxy.
|
||||
|
||||
@@ -30,6 +34,7 @@ sudo bash backend/docker/env_install.sh
|
||||
```
|
||||
|
||||
This script will automatically install:
|
||||
|
||||
- **Node.js v20** - Runtime environment
|
||||
- **Redis Server** - Used for room management and caching
|
||||
- **Coturn** - TURN/STUN server (optional, for NAT traversal)
|
||||
@@ -38,6 +43,7 @@ This script will automatically install:
|
||||
- **Certbot** - SSL certificate management
|
||||
|
||||
After installation, you can verify the services:
|
||||
|
||||
```bash
|
||||
# Verify Node.js version
|
||||
node -v
|
||||
@@ -53,11 +59,13 @@ sudo systemctl status coturn
|
||||
```
|
||||
|
||||
**Configuration Notes:**
|
||||
|
||||
- **Redis Configuration:** Default listening on `127.0.0.1:6379`, ensure your backend `.env` file includes correct `REDIS_HOST` and `REDIS_PORT`
|
||||
- **TURN Service:** Optional configuration, PrivyDrop uses public STUN servers by default, only needed for extremely high NAT traversal requirements
|
||||
- **Nginx:** Script installs official version and verifies stream module support
|
||||
|
||||
**TURN Server Firewall Configuration (if configuring TURN service):**
|
||||
|
||||
```bash
|
||||
# Enable the Coturn service
|
||||
sudo sed -i 's/#TURNSERVER_ENABLED=1/TURNSERVER_ENABLED=1/' /etc/default/coturn
|
||||
@@ -68,6 +76,7 @@ sudo ufw reload
|
||||
```
|
||||
|
||||
The ports seen via `sudo ufw app info Turnserver` are as follows:
|
||||
|
||||
- `3478,3479,5349,5350,49152:65535/tcp`
|
||||
- `3478,3479,5349,5350,49152:65535/udp`
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
# Privydrop 部署指南
|
||||
# Privydrop 部署指南(裸机部署)
|
||||
|
||||
> 说明与定位:本指南面向具备 Linux 运维能力的开发者,介绍“裸机(非容器)”部署方式。
|
||||
>
|
||||
> 推荐方案:优先使用“一键 Docker 部署”,更简单、更稳健,支持自动签发/续期证书与 TURN。详见 [Docker 部署指南](./DEPLOYMENT_docker.zh-CN.md)。
|
||||
|
||||
本指南提供部署 Privydrop 全栈应用的全面说明,包括设置 Redis、TURN 服务器、后端服务、前端应用以及配置 Nginx 作为反向代理。
|
||||
|
||||
@@ -30,14 +34,16 @@ sudo bash backend/docker/env_install.sh
|
||||
```
|
||||
|
||||
该脚本将自动安装:
|
||||
|
||||
- **Node.js v20** - 运行环境
|
||||
- **Redis Server** - 用于房间管理和缓存
|
||||
- **Coturn** - TURN/STUN 服务器(可选,用于NAT穿透)
|
||||
- **Redis Server** - 用于房间管理和缓存
|
||||
- **Coturn** - TURN/STUN 服务器(可选,用于 NAT 穿透)
|
||||
- **Nginx** - Web 服务器和反向代理(使用官方仓库)
|
||||
- **PM2** - Node.js 进程管理器
|
||||
- **Certbot** - SSL 证书管理
|
||||
|
||||
安装完成后,可以验证各服务状态:
|
||||
|
||||
```bash
|
||||
# 验证 Node.js 版本
|
||||
node -v
|
||||
@@ -53,11 +59,13 @@ sudo systemctl status coturn
|
||||
```
|
||||
|
||||
**注意事项:**
|
||||
- **Redis配置:** 默认监听 `127.0.0.1:6379`,请确保后端 `.env` 文件中包含正确的 `REDIS_HOST` 和 `REDIS_PORT`
|
||||
- **TURN服务:** 为可选配置,Privydrop 默认使用公共 STUN 服务器,只有对 NAT 穿透有极高要求时才需要配置
|
||||
|
||||
- **Redis 配置:** 默认监听 `127.0.0.1:6379`,请确保后端 `.env` 文件中包含正确的 `REDIS_HOST` 和 `REDIS_PORT`
|
||||
- **TURN 服务:** 为可选配置,Privydrop 默认使用公共 STUN 服务器,只有对 NAT 穿透有极高要求时才需要配置
|
||||
- **Nginx:** 脚本安装官方版本并验证 stream 模块支持
|
||||
|
||||
**TURN服务器防火墙配置(如果需要配置TURN服务):**
|
||||
**TURN 服务器防火墙配置(如果需要配置 TURN 服务):**
|
||||
|
||||
```bash
|
||||
# 启用 Coturn 服务
|
||||
sudo sed -i 's/#TURNSERVER_ENABLED=1/TURNSERVER_ENABLED=1/' /etc/default/coturn
|
||||
@@ -68,6 +76,7 @@ sudo ufw reload
|
||||
```
|
||||
|
||||
通过 `sudo ufw app info Turnserver` 看到的端口如下:
|
||||
|
||||
- `3478,3479,5349,5350,49152:65535/tcp`
|
||||
- `3478,3479,5349,5350,49152:65535/udp`
|
||||
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
# PrivyDrop Docker One-Click Deployment (Recommended)
|
||||
|
||||
This guide provides a one-click Docker deployment for PrivyDrop. It supports both private and public networks, automates config/build/start, and provisions HTTPS certificates.
|
||||
|
||||
## 🚀 Quick Start (Top)
|
||||
|
||||
```bash
|
||||
# Private LAN (no domain/public IP)
|
||||
bash ./deploy.sh --mode lan-http
|
||||
|
||||
# Private LAN + TURN (for complex NAT/LAN)
|
||||
bash ./deploy.sh --mode lan-http --with-turn
|
||||
|
||||
# LAN HTTPS (self-signed; dev/managed env; explicitly enable 8443)
|
||||
bash ./deploy.sh --mode lan-tls --enable-web-https --with-nginx
|
||||
|
||||
# Public IP without domain (with TURN)
|
||||
bash ./deploy.sh --mode public --with-turn
|
||||
|
||||
# Public domain (HTTPS + Nginx + TURN + SNI 443, auto-issue/renew certs)
|
||||
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
|
||||
```
|
||||
|
||||
- Requires Docker Compose v2 (command `docker compose`).
|
||||
- In full mode, Let’s Encrypt (webroot) is auto-issued and auto-renewed (no downtime); SNI 443 multiplexing is enabled by default (`turn.your-domain.com` → coturn:5349; others → web:8443).
|
||||
|
||||
## Modes Overview
|
||||
|
||||
- lan-http: Intranet HTTP; fastest to start; no TLS
|
||||
- lan-tls: Intranet HTTPS (self-signed; dev/managed env); 8443 disabled by default; enable via `--enable-web-https`; HSTS disabled; turns:443 not guaranteed
|
||||
- public: Public HTTP + TURN; works without a domain (no HTTPS/turns:443)
|
||||
- full: Domain + HTTPS (Let’s Encrypt auto-issue/renew) + TURN; SNI 443 split enabled by default (use `--no-sni443` to disable)
|
||||
|
||||
## 🎯 Deployment Advantages
|
||||
|
||||
Compared to traditional deployment methods, Docker deployment offers the following advantages:
|
||||
|
||||
| Comparison | Traditional Deployment | Docker Deployment |
|
||||
| ---------------------------- | ------------------------------- | ------------------------------ |
|
||||
| **Deploy Time** | 30-60 minutes | 5 minutes |
|
||||
| **Technical Requirements** | Linux ops experience | Basic Docker knowledge |
|
||||
| **Environment Requirements** | Public IP + Domain | Works on private networks |
|
||||
| **Configuration Complexity** | 10+ manual steps | One-click auto configuration |
|
||||
| **Success Rate** | ~70% | >95% |
|
||||
| **Maintenance Difficulty** | Manual multi-service management | Automatic container management |
|
||||
|
||||
## 📋 System Requirements
|
||||
|
||||
### Minimum Configuration
|
||||
|
||||
- **CPU**: 1 core
|
||||
- **Memory**: 512MB
|
||||
- **Disk**: 2GB available space
|
||||
- **Network**: Any network environment (private/public)
|
||||
|
||||
### Recommended Configuration
|
||||
|
||||
- **CPU**: 2+ cores
|
||||
- **Memory**: 1GB+
|
||||
- **Disk**: 5GB+ available space
|
||||
- **Network**: 100Mbps+
|
||||
|
||||
### Software Dependencies
|
||||
|
||||
- Docker 20.10+
|
||||
- Docker Compose 2.x (command `docker compose`)
|
||||
- curl (for health checks, optional)
|
||||
- openssl (cert tools; the script auto-installs certbot)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Get the Code
|
||||
|
||||
```bash
|
||||
# Clone the project
|
||||
git clone https://github.com/david-bai00/PrivyDrop.git
|
||||
cd PrivyDrop
|
||||
```
|
||||
|
||||
### 2. One-Click Deployment
|
||||
|
||||
```bash
|
||||
# Basic deployment (recommended for beginners)
|
||||
bash ./deploy.sh
|
||||
```
|
||||
|
||||
That's it! 🎉
|
||||
|
||||
## 📚 Deployment Modes
|
||||
|
||||
### LAN HTTP (lan-http)
|
||||
|
||||
**Use Case**: Private network file transfer, personal use, testing environment
|
||||
|
||||
```bash
|
||||
bash ./deploy.sh --mode lan-http
|
||||
```
|
||||
|
||||
**Features**:
|
||||
|
||||
- ✅ HTTP access
|
||||
- ✅ Private network P2P transfer
|
||||
- ✅ Uses public STUN servers
|
||||
- ✅ Zero configuration startup
|
||||
|
||||
### Public Mode
|
||||
|
||||
**Use Case**: Servers with public IP but no domain
|
||||
|
||||
```bash
|
||||
bash ./deploy.sh --mode public --with-turn --with-nginx
|
||||
```
|
||||
|
||||
**Features**:
|
||||
|
||||
- ✅ HTTP access
|
||||
- ✅ Built-in TURN server
|
||||
- ✅ Supports complex network environments
|
||||
- ✅ Automatic NAT traversal configuration
|
||||
|
||||
### Full Mode (full)
|
||||
|
||||
**Use Case**: Production environment, public servers with domain
|
||||
|
||||
```bash
|
||||
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
|
||||
```
|
||||
|
||||
**Features**:
|
||||
|
||||
- ✅ HTTPS secure access (Let’s Encrypt auto-issue/renew, zero downtime)
|
||||
- ✅ Nginx reverse proxy
|
||||
- ✅ Built-in TURN server (default port range 49152-49252/udp)
|
||||
- ✅ SNI 443 multiplexing (turn.<domain> → coturn:5349; others → web:8443)
|
||||
- ✅ Complete production setup
|
||||
|
||||
> Tip: The script no longer auto-detects the deployment mode; always pass `--mode lan-http|lan-tls|public|full`. If the detected LAN IP is not the one you expect, add `--local-ip 192.168.x.x` to override.
|
||||
|
||||
## 🔧 Advanced Configuration
|
||||
|
||||
### Custom Ports
|
||||
|
||||
```bash
|
||||
# Modify .env file
|
||||
FRONTEND_PORT=8080
|
||||
BACKEND_PORT=8081
|
||||
HTTP_PORT=8000
|
||||
```
|
||||
|
||||
### Build-Time Proxy (optional)
|
||||
|
||||
Set the following variables in `.env` (or export them before running `deploy.sh`) when the build needs to go through a proxy. The configuration generator now preserves these fields on subsequent runs.
|
||||
|
||||
```bash
|
||||
HTTP_PROXY=http://your-proxy:7890
|
||||
HTTPS_PROXY=http://your-proxy:7890
|
||||
NO_PROXY=localhost,127.0.0.1,backend,frontend,redis,coturn
|
||||
```
|
||||
|
||||
`docker compose` passes these values as build args; the Dockerfiles expose them as environment variables so `npm`/`pnpm` automatically reuse the proxy. Leave them blank if you don't need a proxy.
|
||||
|
||||
### Common Flags
|
||||
|
||||
```bash
|
||||
# Enable only Nginx reverse proxy
|
||||
bash ./deploy.sh --with-nginx
|
||||
|
||||
# Enable TURN (recommended in public/full)
|
||||
bash ./deploy.sh --with-turn
|
||||
|
||||
# Explicitly enable SNI 443 (auto-enabled in full+domain; use --no-sni443 to disable)
|
||||
bash ./deploy.sh --with-sni443
|
||||
|
||||
# Adjust TURN port range (default 49152-49252/udp)
|
||||
bash ./deploy.sh --mode full --with-turn --turn-port-range 55000-55100
|
||||
```
|
||||
|
||||
## 🌐 Access Methods
|
||||
|
||||
- With Nginx (recommended, same-origin gateway)
|
||||
- lan-http/public: `http://localhost` (or `http://<public IP>`)
|
||||
- lan-tls (with `--enable-web-https`): `https://localhost:8443` (or `https://<LAN IP>:8443`)
|
||||
- full (with domain): `https://<your-domain>` (443)
|
||||
- Health checks: `curl -fsS http://localhost/api/health` (lan-http/public), `curl -kfsS https://localhost:8443/api/health` (lan-tls+https), `curl -fsS https://<domain>/api/health` (full)
|
||||
|
||||
- Without Nginx (direct ports, for debugging only)
|
||||
- Frontend: `http://localhost:3002` (or `http://<LAN IP>:3002`)
|
||||
- API: `http://localhost:3001` (or `http://<LAN IP>:3001`)
|
||||
- Note: direct ports may cause CORS or 404 in production/public setups and are not recommended for public access.
|
||||
|
||||
### HTTPS Access (lan-tls/full)
|
||||
|
||||
- lan-tls: with `--enable-web-https`, access via `https://localhost:8443` (certs in `docker/ssl/`). Import `docker/ssl/ca-cert.pem` into your browser or trust store on first use.
|
||||
- full: after Let’s Encrypt issuance, access via `https://<your-domain>` (443). Certs auto-issue/renew; hot-reload is handled via deploy hook.
|
||||
|
||||
## 🔍 Management Commands
|
||||
|
||||
### View Service Status
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### View Service Logs
|
||||
|
||||
```bash
|
||||
# View all service logs
|
||||
docker compose logs -f
|
||||
|
||||
# View specific service logs
|
||||
docker compose logs -f backend
|
||||
docker compose logs -f frontend
|
||||
docker compose logs -f redis
|
||||
```
|
||||
|
||||
### Restart Services
|
||||
|
||||
```bash
|
||||
# Restart all services
|
||||
docker compose restart
|
||||
|
||||
# Restart specific service
|
||||
docker compose restart backend
|
||||
```
|
||||
|
||||
### Stop Services
|
||||
|
||||
```bash
|
||||
# Stop services but keep data
|
||||
docker compose stop
|
||||
|
||||
# Stop services and remove containers
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### Complete Cleanup
|
||||
|
||||
```bash
|
||||
# Clean all containers, images and data
|
||||
bash ./deploy.sh --clean
|
||||
```
|
||||
|
||||
## 🛠️ Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. Port Already in Use
|
||||
|
||||
**Symptom**: Deployment shows port occupation warning
|
||||
|
||||
```
|
||||
⚠️ The following ports are already in use: 3002, 3001
|
||||
```
|
||||
|
||||
**Solution**:
|
||||
|
||||
```bash
|
||||
# First try cleaning previous containers
|
||||
bash ./deploy.sh --clean # or docker compose down
|
||||
|
||||
# If the port is still occupied, locate the process
|
||||
sudo ss -tulpn | grep :3002
|
||||
sudo kill -9 <PID>
|
||||
|
||||
# Finally, adjust the exposed ports in .env if necessary
|
||||
vim .env # Update FRONTEND_PORT / BACKEND_PORT
|
||||
```
|
||||
|
||||
#### 2. Insufficient Memory
|
||||
|
||||
**Symptom**: Containers fail to start or restart frequently
|
||||
|
||||
**Solution**:
|
||||
|
||||
```bash
|
||||
# Check memory usage
|
||||
free -h
|
||||
|
||||
# Add swap space (temporary solution)
|
||||
sudo fallocate -l 1G /swapfile
|
||||
sudo chmod 600 /swapfile
|
||||
sudo mkswap /swapfile
|
||||
sudo swapon /swapfile
|
||||
```
|
||||
|
||||
#### 3. Docker Permission Issues
|
||||
|
||||
**Symptom**: Permission denied errors
|
||||
|
||||
**Solution**:
|
||||
|
||||
```bash
|
||||
# Add user to docker group
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
# Re-login or refresh group permissions
|
||||
newgrp docker
|
||||
```
|
||||
|
||||
#### 4. Service Inaccessible
|
||||
|
||||
**Symptom**: Browser cannot open pages
|
||||
|
||||
**Solution**:
|
||||
|
||||
```bash
|
||||
# 1. Check service status
|
||||
docker compose ps
|
||||
|
||||
# 2. Check health status
|
||||
curl http://localhost:3001/health
|
||||
curl http://localhost:3002/api/health
|
||||
|
||||
# 3. View detailed logs
|
||||
docker compose logs -f
|
||||
|
||||
# 4. Check firewall
|
||||
sudo ufw status
|
||||
```
|
||||
|
||||
#### 5. WebRTC Connection Failure
|
||||
|
||||
**Symptom**: Cannot establish P2P connections
|
||||
|
||||
**Solution**:
|
||||
|
||||
```bash
|
||||
# Enable TURN server
|
||||
bash ./deploy.sh --with-turn
|
||||
|
||||
# Check network connectivity
|
||||
curl -I http://localhost:3001/api/get_room
|
||||
```
|
||||
|
||||
### Health Checks
|
||||
|
||||
The project provides comprehensive health check functionality:
|
||||
|
||||
```bash
|
||||
# Run health check tests
|
||||
bash test-health-apis.sh
|
||||
|
||||
# Manual service checks
|
||||
curl http://localhost:3001/health # Backend basic check
|
||||
curl http://localhost:3001/health/detailed # Backend detailed check
|
||||
curl http://localhost:3002/api/health # Frontend check
|
||||
```
|
||||
|
||||
### Performance Monitoring
|
||||
|
||||
```bash
|
||||
# View container resource usage
|
||||
docker stats
|
||||
|
||||
# View disk usage
|
||||
docker system df
|
||||
|
||||
# Clean unused resources
|
||||
docker system prune -f
|
||||
```
|
||||
|
||||
## 📊 Performance Optimization
|
||||
|
||||
### Production Environment Optimization
|
||||
|
||||
1. **Enable Nginx Caching**:
|
||||
|
||||
```bash
|
||||
bash deploy.sh --with-nginx
|
||||
```
|
||||
|
||||
2. **Configure Resource Limits**:
|
||||
|
||||
```yaml
|
||||
# Add to docker-compose.yml
|
||||
services:
|
||||
backend:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
reservations:
|
||||
memory: 128M
|
||||
```
|
||||
|
||||
3. **Enable Log Rotation**:
|
||||
|
||||
```bash
|
||||
# Configure log size limits
|
||||
echo '{"log-driver":"json-file","log-opts":{"max-size":"10m","max-file":"3"}}' | sudo tee /etc/docker/daemon.json
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
### Network Optimization
|
||||
|
||||
1. **Use Dedicated Network**:
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
privydrop-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
```
|
||||
|
||||
2. **Enable HTTP/2**:
|
||||
|
||||
```bash
|
||||
# Auto-enabled (requires HTTPS)
|
||||
bash deploy.sh --mode full --with-nginx
|
||||
```
|
||||
|
||||
## 🔒 Security Configuration
|
||||
|
||||
### LAN HTTPS (lan-tls, self-signed, dev/managed env)
|
||||
|
||||
- 8443 is disabled by default; explicitly enable with:
|
||||
|
||||
```bash
|
||||
bash ./deploy.sh --mode lan-tls --enable-web-https --with-nginx
|
||||
```
|
||||
|
||||
- For development or managed devices only (internal CA trusted fleet-wide); HSTS disabled; `turns:443` not guaranteed. For restricted networks (443-only), use full (domain + trusted cert + SNI 443).
|
||||
|
||||
Usage (strongly recommended)
|
||||
|
||||
1) Import the self-signed CA (required)
|
||||
- Location: `docker/ssl/ca-cert.pem`
|
||||
- Browser import:
|
||||
- Chrome/Edge: Settings → Privacy & Security → Security → Manage certificates → “Trusted Root Certification Authorities” → Import `ca-cert.pem`
|
||||
- macOS: Keychain Access → System → Certificates → Import `ca-cert.pem` → set to “Always Trust”
|
||||
- Linux (system-wide):
|
||||
- `sudo cp docker/ssl/ca-cert.pem /usr/local/share/ca-certificates/privydrop-ca.crt`
|
||||
- `sudo update-ca-certificates`
|
||||
- Without trusting the CA, browser HTTPS will show untrusted cert warnings and API requests will fail.
|
||||
|
||||
2) Access endpoints (default ports and paths)
|
||||
- Nginx reverse proxy: `http://localhost`
|
||||
- HTTPS (Web): `https://localhost:8443`, `https://<LAN IP>:8443`
|
||||
- Frontend direct (optional): `http://localhost:3002`, `http://<LAN IP>:3002`
|
||||
- Note: In lan-tls, 443 is not open; HTTPS uses 8443.
|
||||
|
||||
3) CORS
|
||||
- For convenience, common dev origins are allowed by default: `https://<LAN IP>:8443`, `https://localhost:8443`, `http://localhost`, `http://<LAN IP>`, `http://localhost:3002`, `http://<LAN IP>:3002`.
|
||||
- To minimize allowed origins, edit `CORS_ORIGIN` in `.env` and then `docker compose restart backend`.
|
||||
|
||||
4) Health checks
|
||||
- `curl -kfsS https://localhost:8443/api/health` → 200
|
||||
- `bash ./test-health-apis.sh` → all tests should pass (frontend container trusts the self-signed CA).
|
||||
|
||||
5) Deployment hints
|
||||
- The script prints only reachable Nginx endpoints; in lan-tls it will show `https://localhost:8443` (and `https://<LAN IP>:8443` if available).
|
||||
|
||||
### Public Domain Deployment (HTTPS + Nginx) — Quick Test
|
||||
|
||||
1) Point your domain A record to the server IP (optional: also `turn.<your-domain>` to the same IP)
|
||||
|
||||
2) Run:
|
||||
|
||||
```bash
|
||||
./deploy.sh --mode full --domain <your-domain> --with-nginx --with-turn --le-email you@domain.com
|
||||
```
|
||||
|
||||
3) Open ports: `80`, `443`, `3478/udp`, `5349/tcp`, `5349/udp`
|
||||
|
||||
4) Verify: visit `https://<your-domain>`, `/api/health` returns 200; open `chrome://webrtc-internals` and check for `relay` candidates (TURN)
|
||||
|
||||
### SSL/TLS Automation (Let’s Encrypt)
|
||||
|
||||
In full mode, certificates are auto-issued and auto-renewed:
|
||||
|
||||
- Initial issuance: webroot (no downtime); system certs live under `/etc/letsencrypt/live/<domain>/`; copied to `docker/ssl/` and 443 is enabled.
|
||||
- Renewal: `certbot.timer` or `/etc/cron.d/certbot` runs daily; the deploy-hook copies new certs to `docker/ssl/` and hot-reloads Nginx/Coturn.
|
||||
- Lineage suffixes (-0001/-0002) are handled automatically.
|
||||
|
||||
### Network Security
|
||||
|
||||
1. **Firewall Configuration**:
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
sudo ufw allow 3478/udp # TURN server
|
||||
```
|
||||
|
||||
2. **Container Network Isolation**:
|
||||
- All services run in isolated networks
|
||||
- Only necessary ports exposed
|
||||
- Internal services communicate using container names
|
||||
|
||||
## 📈 Monitoring and Logging
|
||||
|
||||
### Log Management
|
||||
|
||||
All service logs are centrally stored in the `logs/` directory:
|
||||
|
||||
```
|
||||
logs/
|
||||
├── nginx/ # Nginx access and error logs
|
||||
├── backend/ # Backend application logs
|
||||
├── frontend/ # Frontend application logs
|
||||
└── coturn/ # TURN server logs
|
||||
```
|
||||
|
||||
## 🔄 Updates and Maintenance
|
||||
|
||||
### Update Application
|
||||
|
||||
```bash
|
||||
# Pull latest code
|
||||
git pull origin main
|
||||
|
||||
# Redeploy
|
||||
bash deploy.sh
|
||||
```
|
||||
|
||||
### Data Backup
|
||||
|
||||
```bash
|
||||
# Backup Redis data
|
||||
docker compose exec redis redis-cli BGSAVE
|
||||
|
||||
# Backup SSL certificates
|
||||
tar -czf ssl-backup.tar.gz docker/ssl/
|
||||
|
||||
# Backup configuration files
|
||||
cp .env .env.backup
|
||||
```
|
||||
|
||||
### Regular Maintenance
|
||||
|
||||
```bash
|
||||
# Clean unused images and containers
|
||||
docker system prune -f
|
||||
|
||||
# Update base images
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## 🆘 Getting Help
|
||||
|
||||
### Command Line Help
|
||||
|
||||
```bash
|
||||
bash ./deploy.sh --help
|
||||
|
||||
### Additional Notes
|
||||
|
||||
- In Docker environments, Next.js Image optimization is disabled by default (`NEXT_IMAGE_UNOPTIMIZED=true`) to avoid container loopback fetch failures on `/_next/image`. To enable it, set the variable to `false` and rebuild.
|
||||
- With `--with-nginx`, the frontend is built to use same-origin API (`/api`, `/socket.io/`). Use the gateway URLs printed by the script; direct ports `:3002/:3001` are not recommended in production.
|
||||
```
|
||||
|
||||
### Online Resources
|
||||
|
||||
- [Project Homepage](https://github.com/david-bai00/PrivyDrop)
|
||||
- [Live Demo](https://www.privydrop.app/)
|
||||
- [Issue Reporting](https://github.com/david-bai00/PrivyDrop/issues)
|
||||
|
||||
### Community Support
|
||||
|
||||
- GitHub Issues: Technical questions and bug reports
|
||||
@@ -0,0 +1,566 @@
|
||||
# PrivyDrop Docker 一键部署(推荐)
|
||||
|
||||
本指南提供 PrivyDrop 的 Docker 一键部署方案,支持内网与公网,一次命令完成配置、构建、启动与证书自动化。
|
||||
|
||||
## 🚀 快速开始(置顶)
|
||||
|
||||
```bash
|
||||
# 内网(无域名/无公网IP)
|
||||
bash ./deploy.sh --mode lan-http
|
||||
|
||||
# 内网 + TURN(推荐用于复杂内网/NAT)
|
||||
bash ./deploy.sh --mode lan-http --with-turn
|
||||
|
||||
# 内网 HTTPS(自签,开发/受管环境,需显式开启 8443)
|
||||
bash ./deploy.sh --mode lan-tls --enable-web-https --with-nginx
|
||||
|
||||
# 公网IP(无域名),含 TURN
|
||||
bash ./deploy.sh --mode public --with-turn
|
||||
|
||||
# 公网域名(HTTPS + Nginx + TURN + SNI 443 分流,自动申请/续期证书)
|
||||
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
|
||||
```
|
||||
|
||||
- 使用 Docker Compose V2(命令 `docker compose`)。
|
||||
- full 模式自动申请 Let’s Encrypt 证书(webroot,无停机)并自动续期;默认启用 SNI 443 分流(`turn.your-domain.com` → coturn:5349,其余 → web:8443)。
|
||||
|
||||
## 模式一览
|
||||
|
||||
- lan-http:内网 HTTP;最快上手,默认不启用 TLS
|
||||
- lan-tls:内网 HTTPS(自签,仅开发/受管环境);默认不启 8443,需 `--enable-web-https` 显式开启;禁用 HSTS;不保证 turns:443
|
||||
- public:公网 HTTP;开启 TURN;无域名也可使用(不提供 HTTPS/turns:443)
|
||||
- full:域名 + HTTPS(Let’s Encrypt 自动签发/续期)+ TURN;默认启用 SNI 443 分流(可 `--no-sni443` 关闭)
|
||||
|
||||
## 🎯 部署优势
|
||||
|
||||
相比传统部署方式,Docker 部署具有以下优势:
|
||||
|
||||
| 对比项目 | 传统部署 | Docker 部署 |
|
||||
| -------------- | -------------------- | ---------------- |
|
||||
| **部署时间** | 30-60 分钟 | 5 分钟 |
|
||||
| **技术要求** | Linux 运维经验 | 会用 Docker 即可 |
|
||||
| **环境要求** | 公网 IP + 域名 | 内网即可使用 |
|
||||
| **配置复杂度** | 10+个手动步骤 | 一键自动配置 |
|
||||
| **成功率** | ~70% | >95% |
|
||||
| **维护难度** | 需要手动管理多个服务 | 容器自动管理 |
|
||||
|
||||
## 📋 系统要求
|
||||
|
||||
### 最低配置
|
||||
|
||||
- **CPU**: 1 核
|
||||
- **内存**: 512MB
|
||||
- **磁盘**: 2GB 可用空间
|
||||
- **网络**: 任意网络环境(内网/公网均可)
|
||||
|
||||
### 推荐配置
|
||||
|
||||
- **CPU**: 2 核及以上
|
||||
- **内存**: 1GB 及以上
|
||||
- **磁盘**: 5GB 及以上可用空间
|
||||
- **网络**: 100Mbps 及以上
|
||||
|
||||
### 软件依赖
|
||||
|
||||
- Docker 20.10+
|
||||
- Docker Compose 2.x(命令 `docker compose`)
|
||||
- curl(用于健康检查,可选)
|
||||
- openssl(用于证书工具,脚本会自动安装 certbot)
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 获取代码
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/david-bai00/PrivyDrop.git
|
||||
cd PrivyDrop
|
||||
```
|
||||
|
||||
### 2. 一键部署(示例)
|
||||
|
||||
```bash
|
||||
# 示例:公网域名(HTTPS + Nginx + TURN)
|
||||
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
|
||||
```
|
||||
|
||||
## 📚 部署模式详解
|
||||
|
||||
### lan-http(内网 HTTP)
|
||||
|
||||
**适用场景**: 内网文件传输、个人使用、测试环境
|
||||
|
||||
```bash
|
||||
bash ./deploy.sh --mode lan-http
|
||||
```
|
||||
|
||||
**特性**:
|
||||
|
||||
- ✅ HTTP 访问
|
||||
- ✅ 内网 P2P 传输
|
||||
- ✅ 使用公共 STUN 服务器
|
||||
- ✅ 零配置启动
|
||||
|
||||
### 公网模式
|
||||
|
||||
**适用场景**: 有公网 IP 但无域名的服务器
|
||||
|
||||
```bash
|
||||
bash ./deploy.sh --mode public --with-turn --with-nginx
|
||||
```
|
||||
|
||||
**特性**:
|
||||
|
||||
- ✅ HTTP 访问
|
||||
- ✅ 内置 TURN 服务器
|
||||
- ✅ 支持复杂网络环境
|
||||
- ✅ 自动配置 NAT 穿透
|
||||
|
||||
### 完整模式(full)
|
||||
|
||||
**适用场景**: 生产环境、有域名的公网服务器
|
||||
|
||||
```bash
|
||||
bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn --le-email you@domain.com
|
||||
```
|
||||
|
||||
**特性**:
|
||||
|
||||
- ✅ HTTPS 安全访问(Let’s Encrypt 自动签发/续期,无停机)
|
||||
- ✅ Nginx 反向代理
|
||||
- ✅ 内置 TURN 服务器(默认端口段 49152-49252/udp,可覆盖)
|
||||
- ✅ SNI 443 分流(turn.<domain> → coturn:5349,其余 → web:8443)
|
||||
- ✅ 完整生产环境配置
|
||||
|
||||
> 提示:脚本不再自动判断部署模式,请显式传递 `--mode lan-http|lan-tls|public|full`。若自动检测到的局域网 IP 与预期不符,可使用 `--local-ip 192.168.x.x` 进行覆盖。
|
||||
|
||||
## 🔧 高级配置
|
||||
|
||||
### 自定义端口
|
||||
|
||||
```bash
|
||||
# 修改 .env 文件
|
||||
FRONTEND_PORT=8080
|
||||
BACKEND_PORT=8081
|
||||
HTTP_PORT=8000
|
||||
```
|
||||
|
||||
### 构建阶段代理(可选)
|
||||
|
||||
若需要在 Docker 构建时走网络代理,可在 `.env` 中设置以下变量,或者在执行 `deploy.sh` 之前通过环境变量导出。重新运行配置脚本时,这些字段会被保留:
|
||||
|
||||
```bash
|
||||
HTTP_PROXY=http://你的代理:7890
|
||||
HTTPS_PROXY=http://你的代理:7890
|
||||
NO_PROXY=localhost,127.0.0.1,backend,frontend,redis,coturn
|
||||
```
|
||||
|
||||
`docker compose` 会把这些变量作为 build args 传递给前后端镜像,Dockerfile 中会自动设置为环境变量,从而让 `npm`/`pnpm` 使用代理。若无需代理,保持为空即可。
|
||||
|
||||
### 常用开关
|
||||
|
||||
```bash
|
||||
# 仅启用 Nginx
|
||||
bash ./deploy.sh --with-nginx
|
||||
|
||||
# 启用 TURN(public/full 建议)
|
||||
bash ./deploy.sh --with-turn
|
||||
|
||||
# 显式启用 SNI 443(full+domain 默认开启,可用 --no-sni443 关闭)
|
||||
bash ./deploy.sh --with-sni443
|
||||
|
||||
# 调整 TURN 端口段(默认 49152-49252/udp)
|
||||
bash ./deploy.sh --mode full --with-turn --turn-port-range 55000-55100
|
||||
```
|
||||
|
||||
## 🌐 访问方式
|
||||
|
||||
- 启用 Nginx(推荐,同源网关)
|
||||
- lan-http/public:`http://localhost`(或 `http://<公网IP>`)
|
||||
- lan-tls(启用 `--enable-web-https`):`https://localhost:8443`(或 `https://<LAN IP>:8443`)
|
||||
- full(有域名):`https://<your-domain>`(443)
|
||||
- 健康检查:`curl -fsS http://localhost/api/health`(lan-http/public),`curl -kfsS https://localhost:8443/api/health`(lan-tls+https),`curl -fsS https://<domain>/api/health`(full)
|
||||
|
||||
- 未启用 Nginx(直连端口,仅调试)
|
||||
- 前端:`http://localhost:3002`(或 `http://<LAN IP>:3002`)
|
||||
- API:`http://localhost:3001`(或 `http://<LAN IP>:3001`)
|
||||
- 注意:直连端口在生产/公网可能导致 CORS 或 404,不推荐对外使用。
|
||||
|
||||
### HTTPS 访问(lan-tls/full)
|
||||
|
||||
- lan-tls:开启 `--enable-web-https` 后通过 `https://localhost:8443` 访问(证书在 `docker/ssl/`)。首次访问需导入 `docker/ssl/ca-cert.pem` 到浏览器或系统信任。
|
||||
- full:签发 Let’s Encrypt 后通过 `https://<your-domain>` 访问(443)。
|
||||
|
||||
## 🔍 管理命令
|
||||
|
||||
### 查看服务状态
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### 查看服务日志
|
||||
|
||||
```bash
|
||||
# 查看所有服务日志
|
||||
docker compose logs -f
|
||||
|
||||
# 查看特定服务日志
|
||||
docker compose logs -f backend
|
||||
docker compose logs -f frontend
|
||||
docker compose logs -f redis
|
||||
```
|
||||
|
||||
### 重启服务
|
||||
|
||||
```bash
|
||||
# 重启所有服务
|
||||
docker compose restart
|
||||
|
||||
# 重启特定服务
|
||||
docker compose restart backend
|
||||
```
|
||||
|
||||
### 停止服务
|
||||
|
||||
```bash
|
||||
# 停止服务但保留数据
|
||||
docker compose stop
|
||||
|
||||
# 停止服务并删除容器
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### 完全清理
|
||||
|
||||
```bash
|
||||
# 清理所有容器、镜像和数据
|
||||
bash ./deploy.sh --clean
|
||||
```
|
||||
|
||||
## 🛠️ 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
#### 1. 端口被占用
|
||||
|
||||
**现象**: 部署时提示端口已被占用
|
||||
|
||||
```
|
||||
⚠️ 以下端口已被占用: 3002, 3001
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 方法1: 清理旧容器
|
||||
bash ./deploy.sh --clean # 或 docker compose down
|
||||
|
||||
# 方法2: 查找并结束占用进程
|
||||
sudo ss -tulpn | grep :3002
|
||||
sudo kill -9 <PID>
|
||||
|
||||
# 方法3: 如仍冲突,再调整端口
|
||||
vim .env # 修改 FRONTEND_PORT / BACKEND_PORT
|
||||
```
|
||||
|
||||
#### 2. 内存不足
|
||||
|
||||
**现象**: 容器启动失败或频繁重启
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 检查内存使用
|
||||
free -h
|
||||
|
||||
# 添加交换空间 (临时解决)
|
||||
sudo fallocate -l 1G /swapfile
|
||||
sudo chmod 600 /swapfile
|
||||
sudo mkswap /swapfile
|
||||
sudo swapon /swapfile
|
||||
```
|
||||
|
||||
#### 3. Docker 权限问题
|
||||
|
||||
**现象**: 提示权限不足
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 将用户添加到docker组
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
# 重新登录或刷新组权限
|
||||
newgrp docker
|
||||
```
|
||||
|
||||
#### 4. 服务无法访问
|
||||
|
||||
**现象**: 浏览器无法打开页面
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 1. 检查服务状态
|
||||
docker compose ps
|
||||
|
||||
# 2. 检查健康状态
|
||||
curl http://localhost:3001/health
|
||||
curl http://localhost:3002/api/health
|
||||
|
||||
# 3. 查看详细日志
|
||||
docker compose logs -f
|
||||
|
||||
# 4. 检查防火墙
|
||||
sudo ufw status
|
||||
```
|
||||
|
||||
#### 5. WebRTC 连接失败
|
||||
|
||||
**现象**: 无法建立 P2P 连接
|
||||
|
||||
**解决方案**:
|
||||
|
||||
```bash
|
||||
# 启用TURN服务器
|
||||
bash ./deploy.sh --with-turn
|
||||
|
||||
# 检查网络连接
|
||||
curl -I http://localhost:3001/api/get_room
|
||||
```
|
||||
|
||||
### 健康检查
|
||||
|
||||
项目提供了完整的健康检查功能:
|
||||
|
||||
```bash
|
||||
# 运行健康检查测试
|
||||
bash ./test-health-apis.sh
|
||||
|
||||
# 手动检查各服务(直连端口)
|
||||
curl http://localhost:3001/health # 后端基础检查(直连)
|
||||
curl http://localhost:3001/health/detailed # 后端详细检查(直连)
|
||||
curl http://localhost:3002/api/health # 前端检查(直连)
|
||||
|
||||
# 同源网关(启用 Nginx)
|
||||
curl -fsS http://localhost/api/health # lan-http/public
|
||||
curl -kfsS https://localhost:8443/api/health # lan-tls (https)
|
||||
```
|
||||
|
||||
### 性能监控
|
||||
|
||||
```bash
|
||||
# 查看容器资源使用
|
||||
docker stats
|
||||
|
||||
# 查看磁盘使用
|
||||
docker system df
|
||||
|
||||
# 清理未使用的资源
|
||||
docker system prune -f
|
||||
```
|
||||
|
||||
## 📊 性能优化
|
||||
|
||||
### 生产环境优化
|
||||
|
||||
1. **启用 Nginx 缓存**:
|
||||
|
||||
```bash
|
||||
bash ./deploy.sh --with-nginx
|
||||
```
|
||||
|
||||
2. **配置资源限制**:
|
||||
|
||||
```yaml
|
||||
# 在 docker-compose.yml 中添加
|
||||
services:
|
||||
backend:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
reservations:
|
||||
memory: 128M
|
||||
```
|
||||
|
||||
3. **启用日志轮转**:
|
||||
|
||||
```bash
|
||||
# 配置日志大小限制
|
||||
echo '{"log-driver":"json-file","log-opts":{"max-size":"10m","max-file":"3"}}' | sudo tee /etc/docker/daemon.json
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
### 网络优化
|
||||
|
||||
1. **使用专用网络**:
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
privydrop-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
```
|
||||
|
||||
2. **启用 HTTP/2**:
|
||||
|
||||
```bash
|
||||
# 自动启用 (需要 HTTPS)
|
||||
bash ./deploy.sh --mode full --with-nginx
|
||||
```
|
||||
|
||||
## 🔒 HTTPS 与安全
|
||||
|
||||
### 内网 HTTPS(lan-tls,自签,开发/受管环境)
|
||||
|
||||
- 默认不启 8443;需 `--enable-web-https` 显式开启:
|
||||
|
||||
```bash
|
||||
bash ./deploy.sh --mode lan-tls --enable-web-https --with-nginx
|
||||
```
|
||||
|
||||
- 仅用于开发或受管终端(全员导入内部 CA);禁用 HSTS;不保证 `turns:443`;受限网络(仅 443 出口)应使用 full(域名 + 受信证书 + SNI 443)。
|
||||
|
||||
使用说明(强烈建议逐条完成)
|
||||
|
||||
1) 导入自签 CA 证书(必做)
|
||||
- 证书位置:`docker/ssl/ca-cert.pem`
|
||||
- 浏览器导入:
|
||||
- Chrome/Edge:设置 → 隐私与安全 → 安全 → 管理证书 → “受信任的根证书颁发机构” → 导入 `ca-cert.pem`
|
||||
- macOS:钥匙串访问 → System → 证书 → 导入 `ca-cert.pem` → 设置“始终信任”
|
||||
- Linux(系统层):
|
||||
- `sudo cp docker/ssl/ca-cert.pem /usr/local/share/ca-certificates/privydrop-ca.crt`
|
||||
- `sudo update-ca-certificates`
|
||||
- 未导入时,浏览器访问 HTTPS 会提示“证书无效/不受信任”,API 请求也会失败。
|
||||
|
||||
2) 访问方式(默认端口与路径)
|
||||
- Nginx 反代:`http://localhost`
|
||||
- HTTPS(Web):`https://localhost:8443`、`https://<局域网IP>:8443`
|
||||
- 前端直连(可选):`http://localhost:3002`、`http://<局域网IP>:3002`
|
||||
- 说明:lan-tls 下未开启 443;HTTPS 统一走 8443。
|
||||
|
||||
3) 跨域(CORS)说明
|
||||
- 为方便开发与调试,脚本已默认放开常见来源:`https://<局域网IP>:8443`、`https://localhost:8443`、`http://localhost`、`http://<局域网IP>`、`http://localhost:3002`、`http://<局域网IP>:3002`。
|
||||
- 若仍需最小化来源,请在 `.env` 中精准收敛 `CORS_ORIGIN`,并 `docker compose restart backend`。
|
||||
|
||||
4) 健康检查
|
||||
- `curl -kfsS https://localhost:8443/api/health` → 200
|
||||
- `bash ./test-health-apis.sh` → 所有测试应通过(前端 detailed 健康已在容器内信任自签 CA)。
|
||||
|
||||
5) 部署提示
|
||||
- 脚本会只显示可访问的 Nginx 入口;lan-tls 下将显示明确的 `https://localhost:8443`(如存在局域网 IP 也将显示 `https://<IP>:8443`)。
|
||||
|
||||
### 公网域名部署(HTTPS + Nginx)快速测试
|
||||
|
||||
1) 将域名 A 记录解析至服务器 IP(可选:`turn.<your-domain>` 指向相同 IP)
|
||||
|
||||
2) 运行:
|
||||
|
||||
```bash
|
||||
./deploy.sh --mode full --domain <your-domain> --with-nginx --with-turn --le-email you@domain.com
|
||||
```
|
||||
|
||||
3) 放行端口:`80`, `443`, `3478/udp`, `5349/tcp`, `5349/udp`
|
||||
|
||||
4) 验证:访问 `https://<your-domain>`,`/api/health` 返回 200;打开浏览器 `webrtc-internals` 观察是否出现 `relay` 候选(TURN)
|
||||
|
||||
### 证书自动化(Let’s Encrypt)
|
||||
|
||||
full 模式自动申请并续期证书:
|
||||
|
||||
- 首次签发:webroot 模式(无停机),系统证书在 `/etc/letsencrypt/live/<domain>/`,脚本复制到 `docker/ssl/` 并启用 443;
|
||||
- 续期:`certbot.timer` 或 `/etc/cron.d/certbot` 每日尝试 `certbot renew`;deploy-hook 自动复制新证书并热重载 Nginx/Coturn;
|
||||
- 证书谱系(-0001/-0002)已自动适配,无需手动处理。
|
||||
|
||||
### 网络安全
|
||||
|
||||
1. **防火墙配置**:
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
sudo ufw allow 3478/udp # TURN服务器
|
||||
```
|
||||
|
||||
2. **容器网络隔离**:
|
||||
- 所有服务运行在独立网络中
|
||||
- 仅暴露必要端口
|
||||
- 内部服务使用容器名通信
|
||||
|
||||
## 📈 监控和日志
|
||||
|
||||
### 日志管理
|
||||
|
||||
所有服务日志统一存储在 `logs/` 目录:
|
||||
|
||||
```
|
||||
logs/
|
||||
├── nginx/ # Nginx访问和错误日志
|
||||
├── backend/ # 后端应用日志
|
||||
├── frontend/ # 前端应用日志
|
||||
└── coturn/ # TURN服务器日志
|
||||
```
|
||||
|
||||
## 🔄 更新和维护
|
||||
|
||||
### 更新应用
|
||||
|
||||
```bash
|
||||
# 拉取最新代码
|
||||
git pull origin main
|
||||
|
||||
# 重新部署
|
||||
bash ./deploy.sh
|
||||
```
|
||||
|
||||
### 数据备份
|
||||
|
||||
```bash
|
||||
# 备份Redis数据
|
||||
docker compose exec redis redis-cli BGSAVE
|
||||
|
||||
# 备份SSL证书
|
||||
tar -czf ssl-backup.tar.gz docker/ssl/
|
||||
|
||||
# 备份配置文件
|
||||
cp .env .env.backup
|
||||
```
|
||||
|
||||
### 定期维护
|
||||
|
||||
```bash
|
||||
# 清理未使用的镜像和容器
|
||||
docker system prune -f
|
||||
|
||||
# 更新基础镜像
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## 🆘 获取帮助
|
||||
|
||||
### 命令行帮助
|
||||
|
||||
```bash
|
||||
bash ./deploy.sh --help
|
||||
```
|
||||
|
||||
### 在线资源
|
||||
|
||||
- [项目主页](https://github.com/david-bai00/PrivyDrop)
|
||||
- [在线演示](https://www.privydrop.app/)
|
||||
- [问题反馈](https://github.com/david-bai00/PrivyDrop/issues)
|
||||
|
||||
### 社区支持
|
||||
|
||||
- GitHub Issues: 技术问题和 bug 报告
|
||||
|
||||
### 其他提示
|
||||
|
||||
- Docker 环境默认关闭 Next.js Image 优化(`NEXT_IMAGE_UNOPTIMIZED=true`),以避免容器内对公网 IP 回环抓取导致 `/_next/image` 502;如需开启,将其设为 `false` 并重新构建。
|
||||
- 启用 `--with-nginx` 时,前端会构建为同源 API(相对路径 `/api`、`/socket.io/`);请优先使用脚本输出的网关入口,不要直接使用 `:3002/:3001` 对外访问,否则可能触发 CORS 或 404。
|
||||
@@ -0,0 +1,15 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.env*
|
||||
npm-debug.log*
|
||||
.npm
|
||||
coverage
|
||||
.nyc_output
|
||||
*.log
|
||||
public/sw.js
|
||||
public/workbox-*.js
|
||||
@@ -1,4 +1,4 @@
|
||||
NEXT_PUBLIC_API_URL=http://43.142.81.156:3001
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||
|
||||
# Option,Delete if not needed
|
||||
NEXT_PUBLIC_TURN_HOST=43.142.81.156
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Multi-stage build — build stage
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
ARG HTTP_PROXY
|
||||
ARG HTTPS_PROXY
|
||||
ARG NO_PROXY
|
||||
|
||||
ENV http_proxy ${HTTP_PROXY} \
|
||||
https_proxy ${HTTPS_PROXY} \
|
||||
no_proxy ${NO_PROXY}
|
||||
|
||||
WORKDIR /app
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY pnpm-lock.yaml ./
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm --no-audit --no-fund
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Declare and use build-time public vars after deps installation to avoid cache invalidation when only API/TURN change
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ARG NEXT_PUBLIC_TURN_HOST
|
||||
ARG NEXT_PUBLIC_TURN_USERNAME
|
||||
ARG NEXT_PUBLIC_TURN_PASSWORD
|
||||
ARG NEXT_IMAGE_UNOPTIMIZED
|
||||
|
||||
# Inject public env vars during frontend build (for client direct access to backend and TURN)
|
||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
ENV NEXT_PUBLIC_TURN_HOST=${NEXT_PUBLIC_TURN_HOST}
|
||||
ENV NEXT_PUBLIC_TURN_USERNAME=${NEXT_PUBLIC_TURN_USERNAME}
|
||||
ENV NEXT_PUBLIC_TURN_PASSWORD=${NEXT_PUBLIC_TURN_PASSWORD}
|
||||
ENV NEXT_IMAGE_UNOPTIMIZED=${NEXT_IMAGE_UNOPTIMIZED}
|
||||
|
||||
# Set environment variables
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NODE_ENV production
|
||||
|
||||
# Build the app
|
||||
RUN pnpm build
|
||||
|
||||
# Production stage
|
||||
FROM node:18-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create a non-root user
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nextjs -u 1001 -G nodejs
|
||||
|
||||
# Copy build artifacts
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY health-check.js ./
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV PORT 3002
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
USER nextjs
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 3002
|
||||
|
||||
# Use a Node.js script for health checks (instead of curl)
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD node health-check.js
|
||||
|
||||
# Start the app
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
# Keep public env vars at runtime (optional; helps SSR read them)
|
||||
# Re-declare ARGs in this stage so they can expand into ENV
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ARG NEXT_PUBLIC_TURN_HOST
|
||||
ARG NEXT_PUBLIC_TURN_USERNAME
|
||||
ARG NEXT_PUBLIC_TURN_PASSWORD
|
||||
ARG NEXT_IMAGE_UNOPTIMIZED
|
||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
ENV NEXT_PUBLIC_TURN_HOST=${NEXT_PUBLIC_TURN_HOST}
|
||||
ENV NEXT_PUBLIC_TURN_USERNAME=${NEXT_PUBLIC_TURN_USERNAME}
|
||||
ENV NEXT_PUBLIC_TURN_PASSWORD=${NEXT_PUBLIC_TURN_PASSWORD}
|
||||
ENV NEXT_IMAGE_UNOPTIMIZED=${NEXT_IMAGE_UNOPTIMIZED}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const errors: string[] = [];
|
||||
let status = 'healthy';
|
||||
|
||||
// 基础健康信息
|
||||
const basicHealth = {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
service: 'privydrop-frontend',
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
environment: process.env.NODE_ENV || 'development'
|
||||
};
|
||||
|
||||
// Check backend API connectivity
|
||||
const backendHealth = await checkBackendHealth();
|
||||
if (backendHealth.status !== 'connected') {
|
||||
errors.push('Backend API connection failed');
|
||||
status = 'degraded';
|
||||
}
|
||||
|
||||
// System info snapshot
|
||||
const systemInfo = {
|
||||
runtime: process.env.NEXT_RUNTIME || 'nodejs',
|
||||
nextjs: {
|
||||
version: process.version,
|
||||
platform: process.platform,
|
||||
arch: process.arch
|
||||
},
|
||||
memory: process.memoryUsage ? {
|
||||
used: formatBytes(process.memoryUsage().heapUsed),
|
||||
total: formatBytes(process.memoryUsage().heapTotal),
|
||||
percent: Math.round((process.memoryUsage().heapUsed / process.memoryUsage().heapTotal) * 100)
|
||||
} : null
|
||||
};
|
||||
|
||||
const detailedHealth = {
|
||||
...basicHealth,
|
||||
status,
|
||||
dependencies: {
|
||||
backend: backendHealth
|
||||
},
|
||||
system: systemInfo,
|
||||
...(errors.length > 0 && { errors })
|
||||
};
|
||||
|
||||
const httpStatus = status === 'healthy' ? 200 : 503;
|
||||
return NextResponse.json(detailedHealth, { status: httpStatus });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Detailed frontend health check error:', error);
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'unhealthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'privydrop-frontend',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}, { status: 503 });
|
||||
}
|
||||
}
|
||||
|
||||
// Check backend API health
|
||||
async function checkBackendHealth() {
|
||||
try {
|
||||
// Prefer container-internal URL, then public URL, then localhost fallback
|
||||
const backendUrl = process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
const start = Date.now();
|
||||
|
||||
const response = await fetch(`${backendUrl}/health`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// Timeout to avoid long-hanging connections in degraded networks
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
|
||||
const responseTime = Date.now() - start;
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return {
|
||||
status: 'connected',
|
||||
responseTime,
|
||||
backendUrl,
|
||||
backendService: data.service || 'unknown'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
status: 'error',
|
||||
responseTime,
|
||||
backendUrl,
|
||||
httpStatus: response.status,
|
||||
error: `HTTP ${response.status}`
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'disconnected',
|
||||
backendUrl: process.env.BACKEND_INTERNAL_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化字节数
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const health = {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
service: 'privydrop-frontend',
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
nextjs: {
|
||||
version: process.env.NEXT_RUNTIME || 'nodejs'
|
||||
}
|
||||
};
|
||||
|
||||
return NextResponse.json(health, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error('Frontend health check error:', error);
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'unhealthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'privydrop-frontend',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}, { status: 503 });
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ManagerOptions, SocketOptions } from "socket.io-client";
|
||||
|
||||
export const config = {
|
||||
API_URL: process.env.NEXT_PUBLIC_API_URL!,
|
||||
USE_HTTPS: process.env.NODE_ENV !== "development",
|
||||
@@ -11,43 +13,55 @@ export const config = {
|
||||
};
|
||||
|
||||
export const getIceServers = () => {
|
||||
// Default public STUN server
|
||||
const iceServers: RTCIceServer[] = [
|
||||
{
|
||||
const iceServers: RTCIceServer[] = [];
|
||||
|
||||
if (config.USE_HTTPS) {
|
||||
// Check if TURN server configuration is complete
|
||||
if (!config.TURN_HOST || !config.TURN_USERNAME || !config.TURN_CREDENTIAL) {
|
||||
console.warn(
|
||||
"TURN server configuration incomplete in HTTPS environment. " +
|
||||
"Please set NEXT_PUBLIC_TURN_HOST, NEXT_PUBLIC_TURN_USERNAME, and NEXT_PUBLIC_TURN_PASSWORD " +
|
||||
"environment variables for better connectivity. Falling back to Google STUN server."
|
||||
);
|
||||
|
||||
// Fallback to Google STUN server
|
||||
iceServers.push({
|
||||
urls: "stun:stun.l.google.com:19302",
|
||||
});
|
||||
} else {
|
||||
// Add self-hosted STUN and TURN servers
|
||||
iceServers.push(
|
||||
{
|
||||
urls: `stun:${config.TURN_HOST}:3478`,
|
||||
},
|
||||
{
|
||||
urls: `turns:${config.TURN_HOST}:443`,
|
||||
username: config.TURN_USERNAME,
|
||||
credential: config.TURN_CREDENTIAL,
|
||||
},
|
||||
{
|
||||
urls: `turn:${config.TURN_HOST}:3478`,
|
||||
username: config.TURN_USERNAME,
|
||||
credential: config.TURN_CREDENTIAL,
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Development environment uses Google's public STUN server
|
||||
iceServers.push({
|
||||
urls: "stun:stun.l.google.com:19302",
|
||||
},
|
||||
];
|
||||
|
||||
// Add self-hosted TURN/STUN server if configured through environment variables
|
||||
if (config.TURN_HOST && config.TURN_USERNAME && config.TURN_CREDENTIAL) {
|
||||
const turnUrls = config.USE_HTTPS
|
||||
? [`turns:${config.TURN_HOST}:443`, `turn:${config.TURN_HOST}:3478`]
|
||||
: [`turn:${config.TURN_HOST}:3478`];
|
||||
|
||||
// Add STUN from the self-hosted server
|
||||
iceServers.push({
|
||||
urls: `stun:${config.TURN_HOST}:3478`,
|
||||
});
|
||||
|
||||
// Add TURN from the self-hosted server
|
||||
iceServers.push({
|
||||
urls: turnUrls,
|
||||
username: config.TURN_USERNAME,
|
||||
credential: config.TURN_CREDENTIAL,
|
||||
});
|
||||
}
|
||||
|
||||
return iceServers;
|
||||
};
|
||||
|
||||
export const getSocketOptions = () => {
|
||||
return config.USE_HTTPS
|
||||
? {
|
||||
secure: true,
|
||||
path: "/socket.io/",
|
||||
transports: ["websocket"],
|
||||
}
|
||||
: undefined;
|
||||
export const getSocketOptions = (): Partial<ManagerOptions & SocketOptions> => {
|
||||
// Allow polling fallback; do not force "secure" here — protocol will be inferred
|
||||
return {
|
||||
path: "/socket.io/",
|
||||
transports: ["websocket", "polling"],
|
||||
};
|
||||
};
|
||||
|
||||
export const getFetchOptions = (options: RequestInit = {}): RequestInit => {
|
||||
|
||||
@@ -49,43 +49,18 @@ const ClipboardApp = () => {
|
||||
handleDownloadFile,
|
||||
} = useFileTransferHandler({ messages, putMessageInMs });
|
||||
|
||||
// 简化的 WebRTC 连接初始化
|
||||
// Simplified WebRTC connection initialization
|
||||
const {
|
||||
sharePeerCount,
|
||||
retrievePeerCount,
|
||||
broadcastDataToAllPeers,
|
||||
requestFile,
|
||||
requestFolder,
|
||||
setReceiverDirectoryHandle,
|
||||
getReceiverSaveType,
|
||||
senderDisconnected,
|
||||
resetReceiverConnection,
|
||||
resetSenderConnection,
|
||||
manualSafeSave,
|
||||
} = useWebRTCConnection({
|
||||
messages,
|
||||
putMessageInMs,
|
||||
});
|
||||
|
||||
const resetAppState = useCallback(async () => {
|
||||
try {
|
||||
// Reset file transfer state
|
||||
useFileTransferStore.getState().resetReceiverState();
|
||||
|
||||
// Reset WebRTC connection state
|
||||
await resetReceiverConnection();
|
||||
|
||||
// Reset room input
|
||||
setRetrieveRoomIdInput("");
|
||||
|
||||
console.log("Application state reset successfully");
|
||||
} catch (error) {
|
||||
console.error("Error during state reset:", error);
|
||||
window.location.reload();
|
||||
}
|
||||
}, [resetReceiverConnection, setRetrieveRoomIdInput]);
|
||||
|
||||
// 大大简化的房间管理 - 不再需要传递任何 WebRTC 依赖
|
||||
// Greatly simplified room management - No longer need to pass any WebRTC dependencies
|
||||
const {
|
||||
processRoomIdInput,
|
||||
joinRoom,
|
||||
@@ -243,7 +218,6 @@ const ClipboardApp = () => {
|
||||
getReceiverSaveType={getReceiverSaveType}
|
||||
retrieveMessage={retrieveMessage}
|
||||
handleLeaveRoom={handleLeaveReceiverRoom}
|
||||
manualSafeSave={manualSafeSave}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -11,6 +11,9 @@ import { getDictionary } from "@/lib/dictionary";
|
||||
import { useLocale } from "@/hooks/useLocale";
|
||||
import type { Messages } from "@/types/messages";
|
||||
import { useFileTransferStore } from "@/stores/fileTransferStore";
|
||||
import { supportsAutoDownload } from "@/lib/browserUtils";
|
||||
import { postLogToBackend } from "@/app/config/api";
|
||||
const developmentEnv = process.env.NODE_ENV;
|
||||
|
||||
function formatFolderDis(template: string, num: number, size: string) {
|
||||
return template.replace("{num}", num.toString()).replace("{size}", size);
|
||||
@@ -41,7 +44,6 @@ interface FileListDisplayProps {
|
||||
onRequest?: (item: FileMeta) => void; // Request file
|
||||
onDelete?: (item: FileMeta) => void;
|
||||
onLocationPick?: () => Promise<boolean>;
|
||||
onSafeSave?: () => void; // New prop for safe save functionality
|
||||
saveType?: { [fileId: string]: boolean }; // File stored on disk or in memory
|
||||
largeFileThreshold?: number;
|
||||
}
|
||||
@@ -63,14 +65,13 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
||||
onRequest,
|
||||
onDelete,
|
||||
onLocationPick,
|
||||
onSafeSave,
|
||||
saveType,
|
||||
largeFileThreshold = 500 * 1024 * 1024, // 500MB default
|
||||
}) => {
|
||||
const locale = useLocale();
|
||||
const [messages, setMessages] = useState<Messages | null>(null);
|
||||
|
||||
// 获取store的清理方法
|
||||
// Get the cleaning method of the store
|
||||
const { clearSendProgress, clearReceiveProgress } = useFileTransferStore();
|
||||
const [showFinished, setShowFinished] = useState<{
|
||||
[fileId: string]: boolean;
|
||||
@@ -78,6 +79,11 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
||||
// Add a ref to store the previous showFinished state
|
||||
const prevShowFinishedRef = useRef<{ [fileId: string]: boolean }>({});
|
||||
|
||||
// Add save pending status - Used for manual saving on non-Chrome browsers
|
||||
const [pendingSave, setPendingSave] = useState<{
|
||||
[fileId: string]: boolean;
|
||||
}>({});
|
||||
|
||||
const [pickedLocation, setPickedLocation] = useState<boolean>(false); // Whether a save directory has been selected
|
||||
const [needPickLocation, setNeedPickLocation] = useState<boolean>(false); // Whether a save directory needs to be selected -- for large files, folders, or user choice
|
||||
|
||||
@@ -92,6 +98,19 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
||||
[fileId: string]: number;
|
||||
}>({});
|
||||
|
||||
// Handling manual save - for non-Chrome browsers
|
||||
const handleManualSave = (item: FileMeta) => {
|
||||
if (onDownload) {
|
||||
onDownload(item);
|
||||
// Clear the pending save state to display UI as "Completed"
|
||||
setPendingSave((prev) => {
|
||||
const updated = { ...prev };
|
||||
delete updated[item.fileId];
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getDictionary(locale)
|
||||
.then((dict) => setMessages(dict))
|
||||
@@ -185,7 +204,7 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
||||
delete updated[fileId];
|
||||
return updated;
|
||||
});
|
||||
// 根据模式清理对应的progress数据
|
||||
// Clean the corresponding progress data according to the pattern
|
||||
if (mode === "sender") {
|
||||
clearSendProgress(fileId, activePeerId);
|
||||
} else {
|
||||
@@ -206,7 +225,7 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}, 3000);
|
||||
}, 3002);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -228,7 +247,6 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
||||
const prevShowFinished = prevShowFinishedRef.current[item.fileId];
|
||||
const isSaveToDisk = saveType ? saveType[item.fileId] : false;
|
||||
|
||||
// 添加详细调试信息
|
||||
const fileProgress = fileProgresses[item.fileId];
|
||||
const activePeerId = activeTransfers[item.fileId];
|
||||
const currentProgress = activePeerId
|
||||
@@ -237,10 +255,37 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
||||
// Detecting false -> true transitions
|
||||
if (!prevShowFinished && currentShowFinished) {
|
||||
if (!isSaveToDisk && onDownload) {
|
||||
onDownload(item);
|
||||
const isAutoDownloadSupported = supportsAutoDownload();
|
||||
|
||||
if (isAutoDownloadSupported) {
|
||||
// Browsers that support automatic downloads like Chrome: Download directly
|
||||
if (developmentEnv === "development") {
|
||||
postLogToBackend(
|
||||
`[Download Debug] Auto-downloading file: ${item.name}`
|
||||
);
|
||||
}
|
||||
onDownload(item);
|
||||
} else {
|
||||
// Non-Chrome browsers: Set to save status, wait for user manual click
|
||||
if (developmentEnv === "development") {
|
||||
postLogToBackend(
|
||||
`[Download Debug] Setting pendingSave for non-Chrome browser: ${item.name}`
|
||||
);
|
||||
}
|
||||
setPendingSave((prev) => ({
|
||||
...prev,
|
||||
[item.fileId]: true,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
if (developmentEnv === "development") {
|
||||
postLogToBackend(
|
||||
`Skipping download logic - isSaveToDisk: ${isSaveToDisk}, onDownload: ${!!onDownload}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Increase download count - 文件传输完成时增加下载次数 (只计算一次)
|
||||
// Increase download count - Increment download count upon completion of file transfer (counted only once)
|
||||
setDownloadCounts((prevCounts) => ({
|
||||
...prevCounts,
|
||||
[item.fileId]: (prevCounts[item.fileId] || 0) + 1,
|
||||
@@ -250,22 +295,17 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
||||
// Update the last status
|
||||
prevShowFinishedRef.current[item.fileId] = currentShowFinished;
|
||||
});
|
||||
}, [
|
||||
showFinished,
|
||||
singleFiles,
|
||||
folders,
|
||||
saveType,
|
||||
onDownload,
|
||||
downloadCounts,
|
||||
]);
|
||||
}, [showFinished, singleFiles, folders, saveType, onDownload]);
|
||||
|
||||
//Actions corresponding to each file - progress, download, delete
|
||||
const renderItemActions = (item: FileMeta) => {
|
||||
const fileProgress = fileProgresses[item.fileId];
|
||||
const activePeerId = activeTransfers[item.fileId];
|
||||
const progress = activePeerId ? fileProgress?.[activePeerId] : null;
|
||||
const showCompletion = showFinished[item.fileId];
|
||||
const showCompletion =
|
||||
showFinished[item.fileId] && !pendingSave[item.fileId]; // Only display completed when the transfer is finished and not in the save pending state
|
||||
const isSaveToDisk = saveType ? saveType[item.fileId] : false;
|
||||
const isPendingSave = pendingSave[item.fileId] || false;
|
||||
// Get download count
|
||||
const downloadCount = downloadCounts[item.fileId] || 0;
|
||||
|
||||
@@ -297,6 +337,7 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
||||
onDownload && ( //Request && Download
|
||||
<FileTransferButton
|
||||
onRequest={() => onRequest(item)}
|
||||
onSave={() => handleManualSave(item)}
|
||||
isCurrentFileTransferring={
|
||||
progress
|
||||
? progress.progress > 0 && progress.progress < 1
|
||||
@@ -304,6 +345,7 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
||||
}
|
||||
isOtherFileTransferring={isAnyFileTransferring && !progress}
|
||||
isSavedToDisk={saveType ? saveType[item.fileId] : false}
|
||||
isPendingSave={isPendingSave}
|
||||
/>
|
||||
)}
|
||||
{/* display download Num*/}
|
||||
@@ -415,29 +457,6 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
|
||||
{messages.text.FileListDisplay.chooseSavePath_dis}
|
||||
</Button>
|
||||
)}
|
||||
{/* Safe Save Button - only show when location is picked and files are saved to disk */}
|
||||
{onSafeSave &&
|
||||
pickedLocation &&
|
||||
(isAnyFileTransferring ||
|
||||
(saveType &&
|
||||
Object.values(saveType).some(
|
||||
(isSavedToDisk) => isSavedToDisk
|
||||
))) && (
|
||||
<Tooltip
|
||||
content={messages.text.FileListDisplay.safeSave_tooltip}
|
||||
>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onSafeSave();
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mr-2 text-green-600 border-green-600 hover:bg-green-50"
|
||||
>
|
||||
{messages.text.FileListDisplay.safeSave_dis}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -13,22 +13,28 @@ import type { Messages } from "@/types/messages";
|
||||
|
||||
interface FileTransferButtonProps {
|
||||
onRequest: () => void;
|
||||
onSave?: () => void; // 新增:处理手动保存
|
||||
isCurrentFileTransferring: boolean;
|
||||
isOtherFileTransferring: boolean;
|
||||
isSavedToDisk: boolean;
|
||||
isPendingSave?: boolean; // 新增:是否待保存状态
|
||||
}
|
||||
// Manage buttons for different download statuses
|
||||
const FileTransferButton = ({
|
||||
onRequest,
|
||||
onSave,
|
||||
isCurrentFileTransferring,
|
||||
isOtherFileTransferring,
|
||||
isSavedToDisk,
|
||||
isPendingSave = false,
|
||||
}: FileTransferButtonProps) => {
|
||||
const locale = useLocale();
|
||||
const [messages, setMessages] = useState<Messages | null>(null);
|
||||
// Button status judgment
|
||||
// Button status judgment - 待保存状态时按钮应该可点击
|
||||
const isDisabled =
|
||||
isCurrentFileTransferring || isSavedToDisk || isOtherFileTransferring;
|
||||
isCurrentFileTransferring ||
|
||||
isSavedToDisk ||
|
||||
(isOtherFileTransferring && !isPendingSave);
|
||||
|
||||
useEffect(() => {
|
||||
getDictionary(locale)
|
||||
@@ -41,6 +47,8 @@ const FileTransferButton = ({
|
||||
return messages!.text.FileTransferButton.SavedToDisk_tips;
|
||||
if (isCurrentFileTransferring)
|
||||
return messages!.text.FileTransferButton.CurrentFileTransferring_tips;
|
||||
if (isPendingSave)
|
||||
return messages!.text.FileTransferButton.PendingSave_tips;
|
||||
if (isOtherFileTransferring)
|
||||
return messages!.text.FileTransferButton.OtherFileTransferring_tips;
|
||||
return messages!.text.FileTransferButton.download_tips;
|
||||
@@ -60,6 +68,12 @@ const FileTransferButton = ({
|
||||
className: "mr-2 cursor-not-allowed",
|
||||
};
|
||||
}
|
||||
if (isPendingSave) {
|
||||
return {
|
||||
variant: "default" as const, // 使用更明显的样式
|
||||
className: "mr-2 bg-green-600 hover:bg-green-700 text-white",
|
||||
};
|
||||
}
|
||||
if (isOtherFileTransferring) {
|
||||
return {
|
||||
variant: "outline" as const,
|
||||
@@ -83,7 +97,7 @@ const FileTransferButton = ({
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-block">
|
||||
<Button
|
||||
onClick={onRequest}
|
||||
onClick={isPendingSave && onSave ? onSave : onRequest}
|
||||
variant={buttonStyles.variant}
|
||||
size="sm"
|
||||
className={buttonStyles.className}
|
||||
@@ -91,11 +105,13 @@ const FileTransferButton = ({
|
||||
>
|
||||
<Download
|
||||
className={`mr-2 h-4 w-4 ${
|
||||
isOtherFileTransferring ? "opacity-50" : ""
|
||||
isOtherFileTransferring && !isPendingSave ? "opacity-50" : ""
|
||||
}`}
|
||||
/>
|
||||
{isSavedToDisk
|
||||
? messages.text.FileTransferButton.Saved_dis
|
||||
: isPendingSave
|
||||
? messages.text.FileTransferButton.Save_dis
|
||||
: isOtherFileTransferring
|
||||
? messages.text.FileTransferButton.Waiting_dis
|
||||
: messages.text.FileTransferButton.Download_dis}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import RichTextEditor from "@/components/Editor/RichTextEditor";
|
||||
import {
|
||||
ReadClipboardButton,
|
||||
WriteClipboardButton,
|
||||
@@ -9,7 +8,6 @@ import {
|
||||
import FileListDisplay from "@/components/ClipboardApp/FileListDisplay";
|
||||
import type { Messages } from "@/types/messages";
|
||||
import type { FileMeta } from "@/types/webrtc";
|
||||
import type { ProgressState } from "@/hooks/useWebRTCConnection"; // Assuming this type is exported
|
||||
|
||||
import { useFileTransferStore } from "@/stores/fileTransferStore";
|
||||
|
||||
@@ -32,7 +30,6 @@ interface RetrieveTabPanelProps {
|
||||
directoryHandle: FileSystemDirectoryHandle
|
||||
) => Promise<void>;
|
||||
getReceiverSaveType: () => { [fileId: string]: boolean } | undefined;
|
||||
manualSafeSave: () => void; // Add manual safe save function
|
||||
retrieveMessage: string;
|
||||
handleLeaveRoom: () => void;
|
||||
}
|
||||
@@ -49,11 +46,10 @@ export function RetrieveTabPanel({
|
||||
requestFolder,
|
||||
setReceiverDirectoryHandle,
|
||||
getReceiverSaveType,
|
||||
manualSafeSave,
|
||||
retrieveMessage,
|
||||
handleLeaveRoom,
|
||||
}: RetrieveTabPanelProps) {
|
||||
// 从 store 中获取状态
|
||||
// Get the status from the store
|
||||
const {
|
||||
retrieveRoomStatusText,
|
||||
retrieveRoomIdInput,
|
||||
@@ -61,38 +57,25 @@ export function RetrieveTabPanel({
|
||||
retrievedFileMetas,
|
||||
receiveProgress,
|
||||
isAnyFileTransferring,
|
||||
senderDisconnected,
|
||||
isReceiverInRoom,
|
||||
} = useFileTransferStore();
|
||||
|
||||
const onLocationPick = useCallback(async (): Promise<boolean> => {
|
||||
if (!messages) return false; // Should not happen if panel is rendered
|
||||
if (!window.showDirectoryPicker) {
|
||||
putMessageInMs(
|
||||
// messages.text.ClipboardApp.pickSaveUnsupported ||
|
||||
"Directory picker not supported.",
|
||||
false
|
||||
);
|
||||
putMessageInMs(messages.text.ClipboardApp.pickSaveUnsupported, false);
|
||||
return false;
|
||||
}
|
||||
if (!window.confirm(messages.text.ClipboardApp.pickSaveMsg)) return false;
|
||||
try {
|
||||
const directoryHandle = await window.showDirectoryPicker();
|
||||
await setReceiverDirectoryHandle(directoryHandle);
|
||||
putMessageInMs(
|
||||
// messages.text.ClipboardApp.pickSaveSuccess ||
|
||||
"Save location set.",
|
||||
false
|
||||
);
|
||||
putMessageInMs(messages.text.ClipboardApp.pickSaveSuccess, false);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
if (err.name !== "AbortError") {
|
||||
console.error("Failed to set up folder receive:", err);
|
||||
putMessageInMs(
|
||||
// messages.text.ClipboardApp.pickSaveError ||
|
||||
"Could not set save location.",
|
||||
false
|
||||
);
|
||||
putMessageInMs(messages.text.ClipboardApp.pickSaveError, false);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -151,12 +134,14 @@ export function RetrieveTabPanel({
|
||||
{messages.text.ClipboardApp.html.joinRoom_dis}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant={isAnyFileTransferring ? "destructive" : "outline"}
|
||||
onClick={handleLeaveRoom}
|
||||
disabled={!isReceiverInRoom || isAnyFileTransferring}
|
||||
disabled={!isReceiverInRoom}
|
||||
className="w-full sm:w-auto px-4 order-2"
|
||||
>
|
||||
{messages.text.ClipboardApp.roomStatus.leaveRoomBtn}
|
||||
{isAnyFileTransferring
|
||||
? messages.text.ClipboardApp.roomStatus.leaveRoomBtn + " ⚠️"
|
||||
: messages.text.ClipboardApp.roomStatus.leaveRoomBtn}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,7 +166,6 @@ export function RetrieveTabPanel({
|
||||
onDownload={handleDownloadFile}
|
||||
onRequest={handleFileRequestFromPanel} // Use the panel's own handler
|
||||
onLocationPick={onLocationPick} // Use the panel's own handler
|
||||
onSafeSave={manualSafeSave} // Add safe save handler
|
||||
saveType={getReceiverSaveType()}
|
||||
/>
|
||||
{retrieveMessage && (
|
||||
|
||||
@@ -11,7 +11,6 @@ import FileListDisplay from "@/components/ClipboardApp/FileListDisplay";
|
||||
import AnimatedButton from "@/components/ui/AnimatedButton";
|
||||
import type { Messages } from "@/types/messages";
|
||||
import type { CustomFile, FileMeta } from "@/types/webrtc";
|
||||
import type { ProgressState } from "@/hooks/useWebRTCConnection";
|
||||
|
||||
import { useFileTransferStore } from "@/stores/fileTransferStore";
|
||||
|
||||
@@ -55,7 +54,7 @@ export function SendTabPanel({
|
||||
currentValidatedShareRoomId,
|
||||
handleLeaveSenderRoom,
|
||||
}: SendTabPanelProps) {
|
||||
// 从 store 中获取状态
|
||||
// Get the status from the store
|
||||
const {
|
||||
shareContent,
|
||||
sendFiles,
|
||||
@@ -68,6 +67,8 @@ export function SendTabPanel({
|
||||
const [inputFieldValue, setInputFieldValue] = useState<string>(
|
||||
currentValidatedShareRoomId
|
||||
);
|
||||
// State to track ID generation mode (false = will show simple next, true = will show random next)
|
||||
const [isSimpleIdMode, setIsSimpleIdMode] = useState<boolean>(true);
|
||||
|
||||
// When the validatedShareRoomId from the parent component changes (e.g., after initial fetch), synchronize the local input field's value
|
||||
useEffect(() => {
|
||||
@@ -92,6 +93,40 @@ export function SendTabPanel({
|
||||
[processRoomIdInput]
|
||||
);
|
||||
|
||||
// Handle ID generation toggle
|
||||
const handleIdGeneration = useCallback(async () => {
|
||||
if (isSimpleIdMode) {
|
||||
// Generate random UUID
|
||||
const randomId = crypto.randomUUID();
|
||||
processRoomIdInput(randomId);
|
||||
} else {
|
||||
// Generate simple 4-digit ID by calling backend API
|
||||
try {
|
||||
const { fetchRoom } = await import("@/app/config/api");
|
||||
const simpleRoomId = await fetchRoom();
|
||||
if (simpleRoomId) {
|
||||
// fetchRoom() already created the room, so set it as initial room ID
|
||||
// This prevents joinRoom() from trying to create it again
|
||||
setInputFieldValue(simpleRoomId);
|
||||
const { useFileTransferStore } = await import(
|
||||
"@/stores/fileTransferStore"
|
||||
);
|
||||
const store = useFileTransferStore.getState();
|
||||
store.setShareRoomId(simpleRoomId);
|
||||
// IMPORTANT: Set as initial room ID to prevent duplicate creation
|
||||
store.setInitShareRoomId(simpleRoomId);
|
||||
} else {
|
||||
processRoomIdInput(crypto.randomUUID());
|
||||
}
|
||||
} catch (error) {
|
||||
processRoomIdInput(crypto.randomUUID());
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle mode for next click
|
||||
setIsSimpleIdMode(!isSimpleIdMode);
|
||||
}, [isSimpleIdMode, processRoomIdInput, setInputFieldValue]);
|
||||
|
||||
return (
|
||||
<div id="send-panel" role="tabpanel" aria-labelledby="send-tab">
|
||||
<div className="mb-3 text-sm text-gray-600">
|
||||
@@ -141,10 +176,12 @@ export function SendTabPanel({
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto px-4"
|
||||
onClick={() => processRoomIdInput(crypto.randomUUID())}
|
||||
onClick={handleIdGeneration}
|
||||
disabled={isSenderInRoom}
|
||||
>
|
||||
{messages.text.ClipboardApp.html.generateRoomId_tips}
|
||||
{isSimpleIdMode
|
||||
? messages.text.ClipboardApp.html.generateRandomId_tips
|
||||
: messages.text.ClipboardApp.html.generateSimpleId_tips}
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full sm:w-auto px-4"
|
||||
@@ -174,12 +211,14 @@ export function SendTabPanel({
|
||||
{messages.text.ClipboardApp.html.SyncSending_dis}
|
||||
</AnimatedButton>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant={isAnyFileTransferring ? "destructive" : "outline"}
|
||||
onClick={handleLeaveSenderRoom}
|
||||
disabled={!isSenderInRoom || isAnyFileTransferring}
|
||||
disabled={!isSenderInRoom}
|
||||
className="w-full sm:w-auto px-4 order-2"
|
||||
>
|
||||
{messages.text.ClipboardApp.roomStatus.leaveRoomBtn}
|
||||
{isAnyFileTransferring
|
||||
? messages.text.ClipboardApp.roomStatus.leaveRoomBtn + " ⚠️"
|
||||
: messages.text.ClipboardApp.roomStatus.leaveRoomBtn}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -235,9 +235,11 @@ export const de: Messages = {
|
||||
OtherFileTransferring_tips:
|
||||
"Bitte warten Sie, bis die aktuelle Übertragung abgeschlossen ist",
|
||||
download_tips: "Klicken Sie, um die Datei herunterzuladen",
|
||||
PendingSave_tips: "Klicken Sie, um die Datei lokal zu speichern", // 新增
|
||||
Saved_dis: "Gespeichert",
|
||||
Waiting_dis: "Warten",
|
||||
Download_dis: "Herunterladen",
|
||||
Save_dis: "Speichern", // 新增
|
||||
},
|
||||
FileListDisplay: {
|
||||
sending_dis: "Senden",
|
||||
@@ -254,11 +256,6 @@ export const de: Messages = {
|
||||
chooseSavePath_tips:
|
||||
"Speichern Sie große Dateien oder Ordner direkt in einem ausgewählten Verzeichnis. 👉",
|
||||
chooseSavePath_dis: "Speicherort auswählen",
|
||||
safeSave_dis: "Sicheres Speichern",
|
||||
safeSave_tooltip:
|
||||
"Keine Angst vor Verbindungsunterbrechung, klicken Sie hier, um Dateien sicher zu speichern für die nächste Fortsetzung",
|
||||
safeSaveSuccessMsg:
|
||||
"Dateien wurden sicher auf der Festplatte gespeichert, sicher die Seite zu schließen, unterstützt Wiederaufnahme der Übertragung!",
|
||||
},
|
||||
RetrieveMethod: {
|
||||
P: "Glückwunsch 🎉 Freigegebene Inhalte warten darauf, abgerufen zu werden:",
|
||||
@@ -296,6 +293,9 @@ export const de: Messages = {
|
||||
failMsg: "Fehler beim Beitreten zum Raum:",
|
||||
},
|
||||
pickSaveMsg: "Direkt auf Festplatte speichern?",
|
||||
pickSaveUnsupported: "Verzeichnisauswahl nicht unterstützt.",
|
||||
pickSaveSuccess: "Speicherort festgelegt.",
|
||||
pickSaveError: "Speicherort konnte nicht festgelegt werden.",
|
||||
roomStatus: {
|
||||
senderEmptyMsg: "Raum ist leer",
|
||||
receiverEmptyMsg:
|
||||
@@ -311,6 +311,9 @@ export const de: Messages = {
|
||||
noFilesForFolderMsg: "Keine Dateien im Ordner '{folderName}' gefunden.",
|
||||
zipError: "Fehler beim Erstellen der ZIP-Datei.",
|
||||
fileNotFoundMsg: "Datei '{fileName}' zum Herunterladen nicht gefunden.",
|
||||
confirmLeaveWhileTransferring:
|
||||
"Dateien werden derzeit übertragen. Das Verlassen wird die Übertragung unterbrechen. Sind Sie sicher?",
|
||||
leaveWhileTransferringSuccess: "Raum verlassen, Übertragung unterbrochen",
|
||||
html: {
|
||||
senderTab: "Senden",
|
||||
retrieveTab: "Abrufen",
|
||||
@@ -321,7 +324,8 @@ export const de: Messages = {
|
||||
Copy_dis: "Kopieren",
|
||||
inputRoomIdprompt: "Ihre Raum-ID (bearbeitbar):",
|
||||
joinRoomBtn: "Raum beitreten",
|
||||
generateRoomId_tips: "Zufällige ID",
|
||||
generateSimpleId_tips: "Einfache ID",
|
||||
generateRandomId_tips: "Zufällige ID",
|
||||
readClipboardToRoomId: "Raum-ID einfügen",
|
||||
enterRoomID_placeholder: "Raum-ID eingeben",
|
||||
retrieveMethod: "Abrufmethode",
|
||||
|
||||
@@ -232,9 +232,11 @@ export const en: Messages = {
|
||||
OtherFileTransferring_tips:
|
||||
"Please wait for current transfer to complete",
|
||||
download_tips: "Click to download file",
|
||||
PendingSave_tips: "Click to save file locally",
|
||||
Saved_dis: "Saved",
|
||||
Waiting_dis: "Waiting",
|
||||
Download_dis: "Download",
|
||||
Save_dis: "Save",
|
||||
},
|
||||
FileListDisplay: {
|
||||
sending_dis: "Sending",
|
||||
@@ -251,11 +253,6 @@ export const en: Messages = {
|
||||
chooseSavePath_tips:
|
||||
"Save large files or folders directly to a selected directory. 👉",
|
||||
chooseSavePath_dis: "Choose save location",
|
||||
safeSave_dis: "Safe Save",
|
||||
safeSave_tooltip:
|
||||
"Don't worry about connection interruption, click here to safely save files for next resume",
|
||||
safeSaveSuccessMsg:
|
||||
"Files have been safely saved to disk, safe to close page, supports resume transfer!",
|
||||
},
|
||||
RetrieveMethod: {
|
||||
P: "Congrats 🎉 Share content is waiting to be retrieved:",
|
||||
@@ -289,6 +286,9 @@ export const en: Messages = {
|
||||
failMsg: "Failed to join room:",
|
||||
},
|
||||
pickSaveMsg: "Save Directly to Disk ?",
|
||||
pickSaveUnsupported: "Directory picker not supported.",
|
||||
pickSaveSuccess: "Save location set.",
|
||||
pickSaveError: "Could not set save location.",
|
||||
roomStatus: {
|
||||
senderEmptyMsg: "Room is empty",
|
||||
receiverEmptyMsg: "You can accept an invitation to join the room",
|
||||
@@ -303,6 +303,8 @@ export const en: Messages = {
|
||||
noFilesForFolderMsg: "No files found for folder '{folderName}'.",
|
||||
zipError: "Error creating ZIP.",
|
||||
fileNotFoundMsg: "File '{fileName}' not found for download.",
|
||||
confirmLeaveWhileTransferring: "Files are currently transferring. Leaving will interrupt the transfer. Are you sure?",
|
||||
leaveWhileTransferringSuccess: "Left room, transfer interrupted",
|
||||
html: {
|
||||
senderTab: "Send",
|
||||
retrieveTab: "Retrieve",
|
||||
@@ -313,7 +315,8 @@ export const en: Messages = {
|
||||
Copy_dis: "Copy",
|
||||
inputRoomIdprompt: "Your RoomID (Editable):",
|
||||
joinRoomBtn: "Join room",
|
||||
generateRoomId_tips: "Random ID",
|
||||
generateSimpleId_tips: "Simple ID",
|
||||
generateRandomId_tips: "Random ID",
|
||||
readClipboardToRoomId: "Paste RoomID",
|
||||
enterRoomID_placeholder: "enter RoomID",
|
||||
retrieveMethod: "Retrieve method",
|
||||
|
||||
@@ -233,9 +233,11 @@ export const es: Messages = {
|
||||
OtherFileTransferring_tips:
|
||||
"Por favor espera a que se complete la transferencia actual",
|
||||
download_tips: "Haz clic para descargar el archivo",
|
||||
PendingSave_tips: "Haz clic para guardar el archivo localmente", // 新增
|
||||
Saved_dis: "Guardado",
|
||||
Waiting_dis: "Esperando",
|
||||
Download_dis: "Descargar",
|
||||
Save_dis: "Guardar", // 新增
|
||||
},
|
||||
FileListDisplay: {
|
||||
sending_dis: "Enviando",
|
||||
@@ -252,11 +254,6 @@ export const es: Messages = {
|
||||
chooseSavePath_tips:
|
||||
"Guarda archivos grandes o carpetas directamente en un directorio seleccionado. 👉",
|
||||
chooseSavePath_dis: "Elegir ubicación de guardado",
|
||||
safeSave_dis: "Guardar Seguro",
|
||||
safeSave_tooltip:
|
||||
"No te preocupes por la interrupción de la conexión, haz clic aquí para guardar archivos de forma segura para la próxima reanudación",
|
||||
safeSaveSuccessMsg:
|
||||
"Los archivos se han guardado de forma segura en el disco, es seguro cerrar la página, ¡admite transferencia de reanudación!",
|
||||
},
|
||||
RetrieveMethod: {
|
||||
P: "¡Felicitaciones 🎉 El contenido compartido está esperando ser recuperado:",
|
||||
@@ -290,6 +287,9 @@ export const es: Messages = {
|
||||
failMsg: "Error al unirse a la sala:",
|
||||
},
|
||||
pickSaveMsg: "¿Guardar Directamente en Disco?",
|
||||
pickSaveUnsupported: "Selector de directorio no compatible.",
|
||||
pickSaveSuccess: "Ubicación de guardado establecida.",
|
||||
pickSaveError: "No se pudo establecer la ubicación de guardado.",
|
||||
roomStatus: {
|
||||
senderEmptyMsg: "La sala está vacía",
|
||||
receiverEmptyMsg: "Puedes aceptar una invitación para unirte a la sala",
|
||||
@@ -305,6 +305,10 @@ export const es: Messages = {
|
||||
"No se encontraron archivos en la carpeta '{folderName}'.",
|
||||
zipError: "Error al crear el archivo ZIP.",
|
||||
fileNotFoundMsg: "Archivo '{fileName}' no encontrado para descargar.",
|
||||
confirmLeaveWhileTransferring:
|
||||
"Los archivos se están transfiriendo actualmente. Salir interrumpirá la transferencia. ¿Estás seguro?",
|
||||
leaveWhileTransferringSuccess:
|
||||
"Saliste de la sala, transferencia interrumpida",
|
||||
html: {
|
||||
senderTab: "Enviar",
|
||||
retrieveTab: "Recuperar",
|
||||
@@ -315,7 +319,8 @@ export const es: Messages = {
|
||||
Copy_dis: "Copiar",
|
||||
inputRoomIdprompt: "Tu ID de Sala (Editable):",
|
||||
joinRoomBtn: "Unirse a sala",
|
||||
generateRoomId_tips: "ID Aleatorio",
|
||||
generateSimpleId_tips: "ID Simple",
|
||||
generateRandomId_tips: "ID Aleatorio",
|
||||
readClipboardToRoomId: "Pegar ID de Sala",
|
||||
enterRoomID_placeholder: "ingresa ID de Sala",
|
||||
retrieveMethod: "Método de recuperación",
|
||||
|
||||
@@ -236,9 +236,11 @@ export const fr: Messages = {
|
||||
OtherFileTransferring_tips:
|
||||
"Veuillez attendre que le transfert actuel soit terminé",
|
||||
download_tips: "Cliquez pour télécharger le fichier",
|
||||
PendingSave_tips: "Cliquez pour enregistrer le fichier localement", // 新增
|
||||
Saved_dis: "Enregistré",
|
||||
Waiting_dis: "En attente",
|
||||
Download_dis: "Télécharger",
|
||||
Save_dis: "Enregistrer", // 新增
|
||||
},
|
||||
FileListDisplay: {
|
||||
sending_dis: "Envoi",
|
||||
@@ -255,11 +257,6 @@ export const fr: Messages = {
|
||||
chooseSavePath_tips:
|
||||
"Enregistrez des fichiers volumineux ou des dossiers directement dans un répertoire sélectionné. 👉",
|
||||
chooseSavePath_dis: "Choisir l'emplacement de sauvegarde",
|
||||
safeSave_dis: "Sauvegarde Sécurisée",
|
||||
safeSave_tooltip:
|
||||
"N'ayez pas peur de l'interruption de connexion, cliquez ici pour sauvegarder les fichiers en toute sécurité pour la prochaine reprise",
|
||||
safeSaveSuccessMsg:
|
||||
"Les fichiers ont été sauvegardés en toute sécurité sur le disque, sûr de fermer la page, prend en charge la reprise du transfert !",
|
||||
},
|
||||
RetrieveMethod: {
|
||||
P: "Félicitations 🎉 Le contenu partagé attend d'être récupéré :",
|
||||
@@ -296,6 +293,9 @@ export const fr: Messages = {
|
||||
failMsg: "Échec de la connexion à la salle :",
|
||||
},
|
||||
pickSaveMsg: "Enregistrer directement sur le disque ?",
|
||||
pickSaveUnsupported: "Sélecteur de répertoire non pris en charge.",
|
||||
pickSaveSuccess: "Emplacement de sauvegarde défini.",
|
||||
pickSaveError: "Impossible de définir l'emplacement de sauvegarde.",
|
||||
roomStatus: {
|
||||
senderEmptyMsg: "La salle est vide",
|
||||
receiverEmptyMsg:
|
||||
@@ -313,6 +313,9 @@ export const fr: Messages = {
|
||||
zipError: "Erreur lors de la création du fichier ZIP.",
|
||||
fileNotFoundMsg:
|
||||
"Fichier '{fileName}' introuvable pour le téléchargement.",
|
||||
confirmLeaveWhileTransferring:
|
||||
"Des fichiers sont actuellement en cours de transfert. Quitter interrompra le transfert. Êtes-vous sûr?",
|
||||
leaveWhileTransferringSuccess: "Salle quittée, transfert interrompu",
|
||||
html: {
|
||||
senderTab: "Envoyer",
|
||||
retrieveTab: "Récupérer",
|
||||
@@ -323,7 +326,8 @@ export const fr: Messages = {
|
||||
Copy_dis: "Copier",
|
||||
inputRoomIdprompt: "Votre ID de salle (modifiable) :",
|
||||
joinRoomBtn: "Rejoindre la salle",
|
||||
generateRoomId_tips: "ID Aléatoire",
|
||||
generateSimpleId_tips: "ID Simple",
|
||||
generateRandomId_tips: "ID Aléatoire",
|
||||
readClipboardToRoomId: "Coller l'ID de salle",
|
||||
enterRoomID_placeholder: "entrez l'ID de salle",
|
||||
retrieveMethod: "Méthode de récupération",
|
||||
|
||||
@@ -229,9 +229,11 @@ export const ja: Messages = {
|
||||
CurrentFileTransferring_tips: "ファイルが転送中です",
|
||||
OtherFileTransferring_tips: "現在の転送が完了するまでお待ちください",
|
||||
download_tips: "クリックしてファイルをダウンロード",
|
||||
PendingSave_tips: "クリックしてファイルをローカルに保存", // 新增
|
||||
Saved_dis: "保存済み",
|
||||
Waiting_dis: "待機中",
|
||||
Download_dis: "ダウンロード",
|
||||
Save_dis: "保存", // 新增
|
||||
},
|
||||
FileListDisplay: {
|
||||
sending_dis: "送信中",
|
||||
@@ -247,9 +249,6 @@ export const ja: Messages = {
|
||||
chooseSavePath_tips:
|
||||
"大きなファイルやフォルダを選択したディレクトリに直接保存します。👉",
|
||||
chooseSavePath_dis: "保存場所を選択",
|
||||
safeSave_dis: "安全保存",
|
||||
safeSave_tooltip: "接続の中断を恐れる必要はありません。ここをクリックして、次回の再開のためにファイルを安全に保存してください",
|
||||
safeSaveSuccessMsg: "ファイルが安全にディスクに保存されました。ページを安全に閉じることができ、転送の再開をサポートします!",
|
||||
},
|
||||
RetrieveMethod: {
|
||||
P: "おめでとう 🎉 共有コンテンツが取得待ちです:",
|
||||
@@ -283,6 +282,9 @@ export const ja: Messages = {
|
||||
failMsg: "ルームへの参加に失敗しました:",
|
||||
},
|
||||
pickSaveMsg: "ディスクに直接保存しますか?",
|
||||
pickSaveUnsupported: "ディレクトリピッカーはサポートされていません。",
|
||||
pickSaveSuccess: "保存場所が設定されました。",
|
||||
pickSaveError: "保存場所を設定できませんでした。",
|
||||
roomStatus: {
|
||||
senderEmptyMsg: "ルームは空です",
|
||||
receiverEmptyMsg: "招待を受けてルームに参加できます",
|
||||
@@ -297,6 +299,8 @@ export const ja: Messages = {
|
||||
noFilesForFolderMsg: "フォルダ '{folderName}' にファイルが見つかりません。",
|
||||
zipError: "ZIP の作成中にエラーが発生しました。",
|
||||
fileNotFoundMsg: "ダウンロードするファイル '{fileName}' が見つかりません。",
|
||||
confirmLeaveWhileTransferring: "現在ファイルが転送中です。退出すると転送が中断されます。よろしいですか?",
|
||||
leaveWhileTransferringSuccess: "ルームを退出しました。転送が中断されました",
|
||||
html: {
|
||||
senderTab: "送信",
|
||||
retrieveTab: "取得",
|
||||
@@ -307,7 +311,8 @@ export const ja: Messages = {
|
||||
Copy_dis: "コピー",
|
||||
inputRoomIdprompt: "ルームID(編集可能):",
|
||||
joinRoomBtn: "ルームに参加",
|
||||
generateRoomId_tips: "ランダムID",
|
||||
generateSimpleId_tips: "シンプルID",
|
||||
generateRandomId_tips: "ランダムID",
|
||||
readClipboardToRoomId: "ルームIDを貼り付け",
|
||||
enterRoomID_placeholder: "ルームIDを入力",
|
||||
retrieveMethod: "取得方法",
|
||||
|
||||
@@ -227,9 +227,11 @@ export const ko: Messages = {
|
||||
CurrentFileTransferring_tips: "파일 전송 중",
|
||||
OtherFileTransferring_tips: "현재 전송이 완료될 때까지 기다려주세요",
|
||||
download_tips: "파일을 다운로드하려면 클릭하세요",
|
||||
PendingSave_tips: "로컬에 파일을 저장하려면 클릭하세요", // 新增
|
||||
Saved_dis: "저장됨",
|
||||
Waiting_dis: "대기 중",
|
||||
Download_dis: "다운로드",
|
||||
Save_dis: "저장", // 新增
|
||||
},
|
||||
FileListDisplay: {
|
||||
sending_dis: "전송 중",
|
||||
@@ -245,9 +247,6 @@ export const ko: Messages = {
|
||||
chooseSavePath_tips:
|
||||
"큰 파일이나 폴더를 선택한 디렉터리에 직접 저장합니다. 👉",
|
||||
chooseSavePath_dis: "저장 위치 선택",
|
||||
safeSave_dis: "안전 저장",
|
||||
safeSave_tooltip: "연결 중단을 두려워하지 마세요. 다음 재개를 위해 파일을 안전하게 저장하려면 여기를 클릭하세요",
|
||||
safeSaveSuccessMsg: "파일이 디스크에 안전하게 저장되었습니다. 페이지를 안전하게 닫을 수 있으며 전송 재개를 지원합니다!",
|
||||
},
|
||||
RetrieveMethod: {
|
||||
P: "축하합니다 🎉 공유된 콘텐츠가 검색을 기다리고 있습니다:",
|
||||
@@ -281,6 +280,9 @@ export const ko: Messages = {
|
||||
failMsg: "방 참여 실패:",
|
||||
},
|
||||
pickSaveMsg: "직접 디스크에 저장하시겠습니까?",
|
||||
pickSaveUnsupported: "디렉토리 선택기가 지원되지 않습니다.",
|
||||
pickSaveSuccess: "저장 위치가 설정되었습니다.",
|
||||
pickSaveError: "저장 위치를 설정할 수 없습니다.",
|
||||
roomStatus: {
|
||||
senderEmptyMsg: "방이 비어 있습니다",
|
||||
receiverEmptyMsg: "초대를 수락하여 방에 참여할 수 있습니다",
|
||||
@@ -295,6 +297,8 @@ export const ko: Messages = {
|
||||
noFilesForFolderMsg: "폴더 '{folderName}'에서 파일을 찾을 수 없습니다.",
|
||||
zipError: "ZIP 파일 생성 중 오류가 발생했습니다.",
|
||||
fileNotFoundMsg: "다운로드할 파일 '{fileName}'을(를) 찾을 수 없습니다.",
|
||||
confirmLeaveWhileTransferring: "현재 파일이 전송 중입니다. 나가면 전송이 중단됩니다. 확실합니까?",
|
||||
leaveWhileTransferringSuccess: "방을 나갔습니다. 전송이 중단되었습니다",
|
||||
html: {
|
||||
senderTab: "보내기",
|
||||
retrieveTab: "검색",
|
||||
@@ -305,7 +309,8 @@ export const ko: Messages = {
|
||||
Copy_dis: "복사",
|
||||
inputRoomIdprompt: "방 ID (편집 가능):",
|
||||
joinRoomBtn: "방 참여",
|
||||
generateRoomId_tips: "랜덤 ID",
|
||||
generateSimpleId_tips: "간단 ID",
|
||||
generateRandomId_tips: "랜덤 ID",
|
||||
readClipboardToRoomId: "방 ID 붙여넣기",
|
||||
enterRoomID_placeholder: "방 ID 입력",
|
||||
retrieveMethod: "검색 방법",
|
||||
|
||||
@@ -216,9 +216,11 @@ export const zh: Messages = {
|
||||
CurrentFileTransferring_tips: "文件正在传输中",
|
||||
OtherFileTransferring_tips: "请等待当前传输完成",
|
||||
download_tips: "点击下载文件",
|
||||
PendingSave_tips: "点击保存文件到本地", // 新增
|
||||
Saved_dis: "已保存",
|
||||
Waiting_dis: "等待中",
|
||||
Download_dis: "下载",
|
||||
Save_dis: "保存", // 新增
|
||||
},
|
||||
FileListDisplay: {
|
||||
sending_dis: "发送中",
|
||||
@@ -233,10 +235,6 @@ export const zh: Messages = {
|
||||
"我们建议选择一个保存目录来直接将文件保存到磁盘。这样可以更方便地传输大文件和同步文件夹。",
|
||||
chooseSavePath_tips: "大文件或文件夹可直接保存到指定目录 👉",
|
||||
chooseSavePath_dis: "选择保存位置",
|
||||
safeSave_dis: "安全保存",
|
||||
safeSave_tooltip: "连接中断不要怕,点击这里安全保存文件,方便下次续传",
|
||||
safeSaveSuccessMsg:
|
||||
"文件已安全保存到磁盘,可以安全关闭页面,支持断点续传!",
|
||||
},
|
||||
RetrieveMethod: {
|
||||
P: "恭喜 🎉 共享内容等待接收:",
|
||||
@@ -268,6 +266,9 @@ export const zh: Messages = {
|
||||
failMsg: "加入房间失败:",
|
||||
},
|
||||
pickSaveMsg: "直接保存到磁盘?",
|
||||
pickSaveUnsupported: "不支持目录选择器。",
|
||||
pickSaveSuccess: "保存位置已设置。",
|
||||
pickSaveError: "无法设置保存位置。",
|
||||
roomStatus: {
|
||||
senderEmptyMsg: "房间为空",
|
||||
receiverEmptyMsg: "您可以接受邀请加入房间",
|
||||
@@ -282,6 +283,8 @@ export const zh: Messages = {
|
||||
noFilesForFolderMsg: "在文件夹 '{folderName}' 中未找到文件。",
|
||||
zipError: "创建 ZIP 文件时出错。",
|
||||
fileNotFoundMsg: "未找到要下载的文件 '{fileName}'。",
|
||||
confirmLeaveWhileTransferring: "当前有文件正在传输,退出将中断传输。确定要退出吗?",
|
||||
leaveWhileTransferringSuccess: "已退出房间,传输已中断",
|
||||
html: {
|
||||
senderTab: "发送",
|
||||
retrieveTab: "接收",
|
||||
@@ -292,7 +295,8 @@ export const zh: Messages = {
|
||||
Copy_dis: "复制",
|
||||
inputRoomIdprompt: "您的房间ID(可编辑):",
|
||||
joinRoomBtn: "加入房间",
|
||||
generateRoomId_tips: "随机ID",
|
||||
generateSimpleId_tips: "简单ID",
|
||||
generateRandomId_tips: "随机ID",
|
||||
readClipboardToRoomId: "粘贴房间ID",
|
||||
enterRoomID_placeholder: "输入房间ID",
|
||||
retrieveMethod: "接收方式",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const http = require('http');
|
||||
|
||||
const options = {
|
||||
host: 'localhost',
|
||||
port: process.env.PORT || 3002,
|
||||
path: '/api/health',
|
||||
timeout: 2000,
|
||||
method: 'GET'
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
console.log('Frontend health check passed');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log(`Frontend health check failed with status: ${res.statusCode}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
console.log(`Frontend health check failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
console.log('Frontend health check timeout');
|
||||
req.destroy();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
req.end();
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { CustomFile, FileMeta, fileMetadata } from "@/types/webrtc";
|
||||
import { Messages } from "@/types/messages";
|
||||
import JSZip from "jszip";
|
||||
import { downloadAs } from "@/lib/fileUtils";
|
||||
import { useFileTransferStore } from "@/stores/fileTransferStore";
|
||||
import { postLogToBackend } from "@/app/config/api";
|
||||
|
||||
interface UseFileTransferHandlerProps {
|
||||
messages: Messages | null;
|
||||
@@ -18,7 +19,7 @@ export function useFileTransferHandler({
|
||||
messages,
|
||||
putMessageInMs,
|
||||
}: UseFileTransferHandlerProps) {
|
||||
// 从 store 中获取状态
|
||||
// Get state from store
|
||||
const {
|
||||
shareContent,
|
||||
sendFiles,
|
||||
@@ -26,7 +27,6 @@ export function useFileTransferHandler({
|
||||
retrievedFiles,
|
||||
retrievedFileMetas,
|
||||
setShareContent,
|
||||
setSendFiles,
|
||||
addSendFiles,
|
||||
removeSendFile,
|
||||
setRetrievedContent,
|
||||
@@ -68,7 +68,9 @@ export function useFileTransferHandler({
|
||||
|
||||
const handleDownloadFile = useCallback(
|
||||
async (meta: FileMeta) => {
|
||||
if (!messages) return;
|
||||
if (!messages) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (meta.folderName && meta.folderName !== "") {
|
||||
const { retrievedFiles: latestRetrievedFiles } =
|
||||
@@ -100,11 +102,11 @@ export function useFileTransferHandler({
|
||||
}
|
||||
} else {
|
||||
let retryCount = 0;
|
||||
const maxRetries = 3; // 重试次数
|
||||
const maxRetries = 3; // Retry count
|
||||
|
||||
const findAndDownload = async (): Promise<boolean> => {
|
||||
retryCount++;
|
||||
// 🔧 关键修复:使用最新的Store状态,而不是闭包中的旧状态
|
||||
// 🔧 Key fix: Use the latest Store state instead of the old state in the closure
|
||||
const { retrievedFiles: latestRetrievedFiles } =
|
||||
useFileTransferStore.getState();
|
||||
const fileToDownload = latestRetrievedFiles.find(
|
||||
@@ -112,27 +114,49 @@ export function useFileTransferHandler({
|
||||
);
|
||||
|
||||
if (fileToDownload) {
|
||||
// Check if file is empty
|
||||
if (fileToDownload.size === 0) {
|
||||
postLogToBackend(
|
||||
`ERROR: File has 0 size! This explains the 0-byte download.`
|
||||
);
|
||||
}
|
||||
|
||||
// Check if file is a valid Blob
|
||||
if (!(fileToDownload instanceof Blob)) {
|
||||
postLogToBackend(
|
||||
`WARNING: File is not a Blob object, type: ${typeof fileToDownload}`
|
||||
);
|
||||
}
|
||||
|
||||
downloadAs(fileToDownload, fileToDownload.name);
|
||||
return true;
|
||||
} else {
|
||||
// Debug log: Record the case where file is not found
|
||||
const availableFileNames = latestRetrievedFiles.map((f) => f.name);
|
||||
postLogToBackend(
|
||||
`File NOT found! Looking for: "${
|
||||
meta.name
|
||||
}", Available files: [${availableFileNames.join(", ")}]`
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// 首次尝试
|
||||
// First attempt
|
||||
const found = await findAndDownload();
|
||||
|
||||
if (!found) {
|
||||
// 如果没找到,启动重试机制
|
||||
// If not found, start retry mechanism
|
||||
const retryWithDelay = async (): Promise<void> => {
|
||||
while (retryCount < maxRetries) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50)); // 固定50ms延迟,因为现在状态应该很快同步
|
||||
await new Promise((resolve) => setTimeout(resolve, 50)); // Fixed 50ms delay, as the state should sync quickly now
|
||||
const foundInRetry = await findAndDownload();
|
||||
if (foundInRetry) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 所有重试都失败了
|
||||
// All retries failed
|
||||
putMessageInMs(
|
||||
messages.text.ClipboardApp.fileNotFoundMsg ||
|
||||
`File '${meta.name}' not found for download.`,
|
||||
@@ -140,12 +164,12 @@ export function useFileTransferHandler({
|
||||
);
|
||||
};
|
||||
|
||||
// 异步执行重试,不阻塞主线程
|
||||
// Execute retry asynchronously without blocking the main thread
|
||||
retryWithDelay().catch(console.error);
|
||||
}
|
||||
}
|
||||
},
|
||||
[messages, putMessageInMs] // 🔧 移除retrievedFiles依赖,因为我们现在直接从Store获取最新状态
|
||||
[messages, putMessageInMs] // 🔧 Remove retrievedFiles dependency as we now get the latest state directly from Store
|
||||
);
|
||||
|
||||
// Reset function specifically for receiver state (for leave room functionality)
|
||||
|
||||
@@ -9,7 +9,7 @@ function format_peopleMsg(template: string, peerCount: number) {
|
||||
return template.replace("{peerCount}", peerCount.toString());
|
||||
}
|
||||
|
||||
// 移除所有 WebRTC 相关的 props 依赖
|
||||
// Remove all WebRTC related props dependencies
|
||||
interface UseRoomManagerProps {
|
||||
messages: Messages | null;
|
||||
putMessageInMs: (
|
||||
@@ -23,7 +23,7 @@ export function useRoomManager({
|
||||
messages,
|
||||
putMessageInMs,
|
||||
}: UseRoomManagerProps) {
|
||||
// 从 store 获取状态
|
||||
// Get state from store
|
||||
const {
|
||||
shareRoomId,
|
||||
initShareRoomId,
|
||||
@@ -36,6 +36,7 @@ export function useRoomManager({
|
||||
senderDisconnected,
|
||||
isSenderInRoom,
|
||||
isReceiverInRoom,
|
||||
isAnyFileTransferring,
|
||||
setShareRoomId,
|
||||
setInitShareRoomId,
|
||||
setShareLink,
|
||||
@@ -45,13 +46,13 @@ export function useRoomManager({
|
||||
resetSenderApp,
|
||||
} = useFileTransferStore();
|
||||
|
||||
// 加入房间方法 - 直接使用 webrtcService
|
||||
// Join room method - directly use webrtcService
|
||||
const joinRoom = useCallback(
|
||||
async (isSenderSide: boolean, roomId: string) => {
|
||||
if (!messages) return;
|
||||
|
||||
try {
|
||||
// 如果是发送方且房间ID不是初始ID,需要先创建房间
|
||||
// If it's the sender side and the room ID is not the initial ID, need to create the room first
|
||||
if (
|
||||
isSenderSide &&
|
||||
activeTab === "send" &&
|
||||
@@ -77,7 +78,7 @@ export function useRoomManager({
|
||||
}
|
||||
}
|
||||
|
||||
// 确定实际要加入的房间ID
|
||||
// Determine the actual room ID to join
|
||||
const actualRoomId =
|
||||
isSenderSide && roomId !== initShareRoomId
|
||||
? roomId
|
||||
@@ -85,7 +86,7 @@ export function useRoomManager({
|
||||
? shareRoomId
|
||||
: roomId;
|
||||
|
||||
// 直接调用 service 方法,无需依赖注入
|
||||
// Directly call the service method without dependency injection
|
||||
await webrtcService.joinRoom(actualRoomId, isSenderSide);
|
||||
|
||||
putMessageInMs(
|
||||
@@ -94,7 +95,7 @@ export function useRoomManager({
|
||||
6000
|
||||
);
|
||||
|
||||
// 更新分享链接
|
||||
// Update share link
|
||||
if (isSenderSide) {
|
||||
const link = `${window.location.origin}${window.location.pathname}?roomId=${actualRoomId}`;
|
||||
setShareLink(link);
|
||||
@@ -103,7 +104,7 @@ export function useRoomManager({
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[RoomManager] 加入房间失败:", error);
|
||||
console.error("[RoomManager] Failed to join room:", error);
|
||||
let errorMsg = messages.text.ClipboardApp.joinRoom.failMsg;
|
||||
if (error instanceof Error) {
|
||||
errorMsg =
|
||||
@@ -125,7 +126,7 @@ export function useRoomManager({
|
||||
]
|
||||
);
|
||||
|
||||
// 生成分享链接并广播
|
||||
// Generate share link and broadcast
|
||||
const generateShareLinkAndBroadcast = useCallback(async () => {
|
||||
if (!messages || !shareRoomId) return;
|
||||
|
||||
@@ -133,25 +134,31 @@ export function useRoomManager({
|
||||
if (sharePeerCount === 0) {
|
||||
putMessageInMs(messages.text.ClipboardApp.waitting_tips, true);
|
||||
} else {
|
||||
// 直接调用 service 的广播方法
|
||||
// Directly call the service's broadcast method
|
||||
await webrtcService.broadcastDataToAllPeers();
|
||||
}
|
||||
|
||||
// 更新分享链接
|
||||
// Update share link
|
||||
const link = `${window.location.origin}${window.location.pathname}?roomId=${shareRoomId}`;
|
||||
setShareLink(link);
|
||||
} catch (error) {
|
||||
console.error("[RoomManager] 生成分享链接失败:", error);
|
||||
putMessageInMs("生成分享链接失败", true);
|
||||
console.error("[RoomManager] Failed to generate share link:", error);
|
||||
putMessageInMs("Failed to generate share link", true);
|
||||
}
|
||||
}, [messages, putMessageInMs, shareRoomId, sharePeerCount, setShareLink]);
|
||||
|
||||
// 接收方离开房间
|
||||
// Receiver leave room
|
||||
const handleLeaveReceiverRoom = useCallback(async () => {
|
||||
if (!messages) return;
|
||||
|
||||
// Check if files are transferring and show confirmation
|
||||
if (isAnyFileTransferring) {
|
||||
const confirmed = window.confirm(messages.text.ClipboardApp.confirmLeaveWhileTransferring);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 调用后端 API 离开房间
|
||||
// Call backend API to leave room
|
||||
if (webrtcService.receiver.roomId && webrtcService.receiver.peerId) {
|
||||
await leaveRoom(
|
||||
webrtcService.receiver.roomId,
|
||||
@@ -159,44 +166,53 @@ export function useRoomManager({
|
||||
);
|
||||
}
|
||||
|
||||
putMessageInMs(messages.text.ClipboardApp.roomStatus.leftRoomMsg, false);
|
||||
const message = isAnyFileTransferring
|
||||
? messages.text.ClipboardApp.leaveWhileTransferringSuccess
|
||||
: messages.text.ClipboardApp.roomStatus.leftRoomMsg;
|
||||
putMessageInMs(message, false);
|
||||
|
||||
// 重置接收方状态
|
||||
// Reset receiver state (clears all as per requirement)
|
||||
resetReceiverState();
|
||||
|
||||
// 清理 WebRTC 连接
|
||||
// Clean up WebRTC connection
|
||||
await webrtcService.leaveRoom(false);
|
||||
} catch (error) {
|
||||
console.error("[RoomManager] 接收方离开房间失败:", error);
|
||||
putMessageInMs("离开房间失败", true);
|
||||
console.error("[RoomManager] Receiver failed to leave room:", error);
|
||||
putMessageInMs("Failed to leave room", true);
|
||||
}
|
||||
}, [messages, putMessageInMs, resetReceiverState]);
|
||||
}, [messages, putMessageInMs, resetReceiverState, isAnyFileTransferring]);
|
||||
|
||||
// 发送方重置应用状态
|
||||
// Sender reset app state
|
||||
const resetSenderAppState = useCallback(async () => {
|
||||
try {
|
||||
// 1. 清理 WebRTC 连接
|
||||
// 1. Clean up WebRTC connection
|
||||
await webrtcService.leaveRoom(true);
|
||||
|
||||
// 2. 清除分享链接和进度
|
||||
// 2. Clear share link and progress
|
||||
resetSenderApp();
|
||||
|
||||
// 3. 从后端获取新的房间ID
|
||||
// 3. Fetch new room ID from backend
|
||||
const newRoomId = await fetchRoom();
|
||||
setShareRoomId(newRoomId || "");
|
||||
setInitShareRoomId(newRoomId || "");
|
||||
} catch (error) {
|
||||
console.error("[RoomManager] 重置发送方状态失败:", error);
|
||||
putMessageInMs("重置发送方状态失败", true);
|
||||
console.error("[RoomManager] Failed to reset sender state:", error);
|
||||
putMessageInMs("Failed to reset sender state", true);
|
||||
}
|
||||
}, [putMessageInMs, resetSenderApp, setShareRoomId, setInitShareRoomId]);
|
||||
|
||||
// 发送方离开房间
|
||||
// Sender leave room
|
||||
const handleLeaveSenderRoom = useCallback(async () => {
|
||||
if (!messages) return;
|
||||
|
||||
// Check if files are transferring and show confirmation
|
||||
if (isAnyFileTransferring) {
|
||||
const confirmed = window.confirm(messages.text.ClipboardApp.confirmLeaveWhileTransferring);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 调用后端 API 离开房间
|
||||
// Call backend API to leave room
|
||||
if (webrtcService.sender.roomId && webrtcService.sender.peerId) {
|
||||
await leaveRoom(
|
||||
webrtcService.sender.roomId,
|
||||
@@ -204,18 +220,21 @@ export function useRoomManager({
|
||||
);
|
||||
}
|
||||
|
||||
putMessageInMs(messages.text.ClipboardApp.roomStatus.leftRoomMsg, true);
|
||||
const message = isAnyFileTransferring
|
||||
? messages.text.ClipboardApp.leaveWhileTransferringSuccess
|
||||
: messages.text.ClipboardApp.roomStatus.leftRoomMsg;
|
||||
putMessageInMs(message, true);
|
||||
|
||||
// 重置发送方状态并获取新房间ID
|
||||
// Reset sender state and get new room ID (keeps files as per requirement)
|
||||
await resetSenderAppState();
|
||||
} catch (error) {
|
||||
console.error("[RoomManager] 发送方离开房间失败:", error);
|
||||
putMessageInMs("离开房间失败", true);
|
||||
console.error("[RoomManager] Sender failed to leave room:", error);
|
||||
putMessageInMs("Failed to leave room", true);
|
||||
}
|
||||
}, [messages, putMessageInMs, resetSenderAppState]);
|
||||
}, [messages, putMessageInMs, resetSenderAppState, isAnyFileTransferring]);
|
||||
|
||||
// 房间ID输入处理
|
||||
const processRoomIdInput = useCallback(
|
||||
// Room ID input processing
|
||||
const processRoomIdInput = useCallback(
|
||||
debounce(async (input: string) => {
|
||||
if (!input.trim() || !messages) return;
|
||||
|
||||
@@ -234,14 +253,14 @@ export function useRoomManager({
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[RoomManager] 验证房间失败:", error);
|
||||
putMessageInMs("验证房间失败", true);
|
||||
console.error("[RoomManager] Failed to validate room:", error);
|
||||
putMessageInMs("Failed to validate room", true);
|
||||
}
|
||||
}, 750),
|
||||
[messages, putMessageInMs, setShareRoomId]
|
||||
);
|
||||
|
||||
// 初始化发送方房间ID
|
||||
// Initialize sender room ID
|
||||
useEffect(() => {
|
||||
if (
|
||||
messages &&
|
||||
@@ -255,9 +274,10 @@ export function useRoomManager({
|
||||
setShareRoomId(newRoomId || "");
|
||||
setInitShareRoomId(newRoomId || "");
|
||||
} catch (err) {
|
||||
console.error("[RoomManager] 获取初始房间失败:", err);
|
||||
console.error("[RoomManager] Failed to fetch initial room:", err);
|
||||
const errorMsg =
|
||||
messages.text?.ClipboardApp?.fetchRoom_err || "获取房间ID失败";
|
||||
messages.text?.ClipboardApp?.fetchRoom_err ||
|
||||
"Failed to fetch room ID";
|
||||
putMessageInMs(errorMsg, true);
|
||||
}
|
||||
};
|
||||
@@ -272,7 +292,7 @@ export function useRoomManager({
|
||||
setInitShareRoomId,
|
||||
]);
|
||||
|
||||
// 房间状态文本更新
|
||||
// Room status text update
|
||||
useEffect(() => {
|
||||
if (!messages) {
|
||||
if (activeTab === "send") setShareRoomStatusText("");
|
||||
@@ -317,7 +337,7 @@ export function useRoomManager({
|
||||
]);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
// State
|
||||
shareRoomId,
|
||||
initShareRoomId,
|
||||
shareLink,
|
||||
@@ -329,7 +349,7 @@ export function useRoomManager({
|
||||
isSenderInRoom,
|
||||
isReceiverInRoom,
|
||||
|
||||
// 方法
|
||||
// Methods
|
||||
processRoomIdInput,
|
||||
joinRoom,
|
||||
generateShareLinkAndBroadcast,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { webrtcService } from "@/lib/webrtcService";
|
||||
import { useFileTransferStore } from "@/stores/fileTransferStore";
|
||||
import type { Messages } from "@/types/messages";
|
||||
|
||||
// 保留类型定义以保持兼容性
|
||||
// Retain type definitions for compatibility
|
||||
export type PeerProgressDetails = { progress: number; speed: number };
|
||||
export type FileProgressPeers = { [peerId: string]: PeerProgressDetails };
|
||||
export type ProgressState = { [fileId: string]: FileProgressPeers };
|
||||
@@ -18,10 +18,9 @@ interface UseWebRTCConnectionProps {
|
||||
}
|
||||
|
||||
export function useWebRTCConnection({
|
||||
messages,
|
||||
putMessageInMs,
|
||||
// Retaining interface compatibility but these are no longer used
|
||||
}: UseWebRTCConnectionProps) {
|
||||
// 从 store 获取状态
|
||||
// Get state from store
|
||||
const {
|
||||
sharePeerCount,
|
||||
retrievePeerCount,
|
||||
@@ -31,7 +30,7 @@ export function useWebRTCConnection({
|
||||
setIsAnyFileTransferring,
|
||||
} = useFileTransferStore();
|
||||
|
||||
// 计算是否有文件正在传输
|
||||
// Calculate if any file is being transferred
|
||||
const isAnyFileTransferring = useMemo(() => {
|
||||
const allProgress = [
|
||||
...Object.values(sendProgress),
|
||||
@@ -49,14 +48,14 @@ export function useWebRTCConnection({
|
||||
}, [isAnyFileTransferring, setIsAnyFileTransferring]);
|
||||
|
||||
return {
|
||||
// 状态从 store 获取
|
||||
// State obtained from store
|
||||
sharePeerCount,
|
||||
retrievePeerCount,
|
||||
senderDisconnected,
|
||||
sendProgress,
|
||||
receiveProgress,
|
||||
|
||||
// 方法直接从 service 暴露
|
||||
// Methods exposed directly from service
|
||||
broadcastDataToAllPeers:
|
||||
webrtcService.broadcastDataToAllPeers.bind(webrtcService),
|
||||
requestFile: webrtcService.requestFile.bind(webrtcService),
|
||||
@@ -64,13 +63,12 @@ export function useWebRTCConnection({
|
||||
setReceiverDirectoryHandle:
|
||||
webrtcService.setReceiverDirectoryHandle.bind(webrtcService),
|
||||
getReceiverSaveType: webrtcService.getReceiverSaveType.bind(webrtcService),
|
||||
manualSafeSave: webrtcService.manualSafeSave.bind(webrtcService),
|
||||
|
||||
// 重置连接方法
|
||||
// Reset connection methods
|
||||
resetSenderConnection: () => webrtcService.leaveRoom(true),
|
||||
resetReceiverConnection: () => webrtcService.leaveRoom(false),
|
||||
|
||||
// 为了兼容性,保留这些属性(但实际上不再需要)
|
||||
// For compatibility, retain these properties (but they are no longer needed)
|
||||
sender: webrtcService.sender,
|
||||
receiver: webrtcService.receiver,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Browser detection utility functions
|
||||
* Extended to support Firefox WebRTC compatibility handling
|
||||
*/
|
||||
|
||||
/**
|
||||
* Detect if the browser is Chrome
|
||||
* @returns {boolean} Returns true if it's Chrome, otherwise false
|
||||
*/
|
||||
export const isChrome = (): boolean => {
|
||||
// Detect Chrome browser, excluding Chromium-based Edge
|
||||
const userAgent = navigator.userAgent;
|
||||
|
||||
return (
|
||||
userAgent.includes("Chrome") && !userAgent.includes("Edg") // Exclude Edge
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect if programmatic download is supported
|
||||
* Chrome supports automatic download after long transfers, other browsers may have limitations
|
||||
* @returns {boolean} Returns true if automatic download is supported
|
||||
*/
|
||||
|
||||
export const supportsAutoDownload = (): boolean => {
|
||||
return isChrome();
|
||||
};
|
||||
+142
-563
@@ -1,83 +1,138 @@
|
||||
// Flow for receiving file(s)/folder(s): First, receive file metadata in batch, [decide if the user needs to select a save directory],
|
||||
// then click to request, receive the file content, and after receiving endMeta, send an ack to finish.
|
||||
// Flow for receiving a folder (same as above): Receive a batch file request.
|
||||
import { SpeedCalculator } from "@/lib/speedCalculator";
|
||||
// 🚀 Modernized FileReceiver using modular architecture
|
||||
// This file now serves as a compatibility layer for the new modular receive system
|
||||
|
||||
import WebRTC_Recipient from "./webrtc_Recipient";
|
||||
import {
|
||||
CustomFile,
|
||||
fileMetadata,
|
||||
WebRTCMessage,
|
||||
FolderProgress,
|
||||
CurrentString,
|
||||
StringMetadata,
|
||||
StringChunk,
|
||||
FileEnd,
|
||||
FileHandlers,
|
||||
FileMeta,
|
||||
FileRequest,
|
||||
FolderComplete,
|
||||
} from "@/types/webrtc";
|
||||
import { CustomFile, fileMetadata } from "@/types/webrtc";
|
||||
import { createFileReceiveService, FileReceiveOrchestrator } from "./receive";
|
||||
|
||||
/**
|
||||
* Manages the state of an active file reception.
|
||||
* 🚀 FileReceiver - Compatibility wrapper for the new modular architecture
|
||||
*
|
||||
* This class maintains backward compatibility while using the new modular receive system.
|
||||
* All heavy lifting is now done by the FileReceiveOrchestrator and its specialized modules.
|
||||
*/
|
||||
interface ActiveFileReception {
|
||||
meta: fileMetadata; // If meta is present, it means this file is currently being received; null means no file is being received.
|
||||
chunks: (ArrayBuffer | null)[]; // Received file chunks (stored in memory).
|
||||
receivedSize: number;
|
||||
initialOffset: number; // For resuming downloads
|
||||
fileHandle: FileSystemFileHandle | null; // Object related to writing to disk -- current file.
|
||||
writeStream: FileSystemWritableFileStream | null; // Object related to writing to disk.
|
||||
completionNotifier: {
|
||||
resolve: () => void;
|
||||
reject: (reason?: any) => void;
|
||||
};
|
||||
}
|
||||
|
||||
class FileReceiver {
|
||||
// region Private Properties
|
||||
private readonly webrtcConnection: WebRTC_Recipient;
|
||||
private readonly largeFileThreshold: number = 1 * 1024 * 1024 * 1024; // 1 GB, larger files will prompt the user to select a directory for direct disk saving.
|
||||
private readonly speedCalculator: SpeedCalculator;
|
||||
private fileHandlers: FileHandlers;
|
||||
private orchestrator: FileReceiveOrchestrator;
|
||||
|
||||
private peerId: string = "";
|
||||
private saveDirectory: FileSystemDirectoryHandle | null = null;
|
||||
// Public properties for backward compatibility
|
||||
public saveType: Record<string, boolean> = {};
|
||||
|
||||
// State Management
|
||||
private pendingFilesMeta = new Map<string, fileMetadata>(); // Stores file metadata, fileId: meta
|
||||
private folderProgresses: Record<string, FolderProgress> = {}; // Folder progress information, fileId: {totalSize: 0, receivedSize: 0, fileIds: []};
|
||||
public saveType: Record<string, boolean> = {}; // fileId or folderName -> isSavedToDisk
|
||||
// Callbacks - these are forwarded to the orchestrator
|
||||
public onFileMetaReceived: ((meta: fileMetadata) => void) | undefined =
|
||||
undefined;
|
||||
public onStringReceived: ((str: string) => void) | undefined = undefined;
|
||||
public onFileReceived: ((file: CustomFile) => Promise<void>) | undefined =
|
||||
undefined;
|
||||
|
||||
// Active transfer state
|
||||
private activeFileReception: ActiveFileReception | null = null;
|
||||
private activeStringReception: CurrentString | null = null;
|
||||
private currentFolderName: string | null = null; // The name of the folder currently being received, or null if not receiving a folder.
|
||||
constructor(webrtcRecipient: WebRTC_Recipient) {
|
||||
// Create the orchestrator using the factory function
|
||||
this.orchestrator = createFileReceiveService(webrtcRecipient);
|
||||
|
||||
// Callbacks
|
||||
public onFileMetaReceived: ((meta: fileMetadata) => void) | null = null;
|
||||
public onStringReceived: ((str: string) => void) | null = null;
|
||||
public onFileReceived: ((file: CustomFile) => Promise<void>) | null = null;
|
||||
private progressCallback:
|
||||
| ((id: string, progress: number, speed: number) => void)
|
||||
| null = null;
|
||||
// endregion
|
||||
// Set up callback forwarding
|
||||
this.setupCallbackForwarding();
|
||||
|
||||
constructor(WebRTC_recipient: WebRTC_Recipient) {
|
||||
this.webrtcConnection = WebRTC_recipient;
|
||||
this.speedCalculator = new SpeedCalculator();
|
||||
|
||||
this.fileHandlers = {
|
||||
string: this.handleReceivedStringChunk.bind(this),
|
||||
stringMetadata: this.handleStringMetadata.bind(this),
|
||||
fileMeta: this.handleFileMetadata.bind(this),
|
||||
fileEnd: this.handleFileEnd.bind(this),
|
||||
};
|
||||
|
||||
this.setupDataHandler();
|
||||
this.log("log", "FileReceiver initialized with modular architecture");
|
||||
}
|
||||
|
||||
// region Logging and Error Handling
|
||||
/**
|
||||
* Set up callback forwarding to the orchestrator
|
||||
*/
|
||||
private setupCallbackForwarding(): void {
|
||||
// Forward file metadata callback
|
||||
this.orchestrator.onFileMetaReceived = (meta: fileMetadata) => {
|
||||
// Update saveType for backward compatibility
|
||||
this.saveType = this.orchestrator.getSaveType();
|
||||
|
||||
if (this.onFileMetaReceived) {
|
||||
this.onFileMetaReceived(meta);
|
||||
}
|
||||
};
|
||||
|
||||
// Forward string received callback
|
||||
this.orchestrator.onStringReceived = (str: string) => {
|
||||
if (this.onStringReceived) {
|
||||
this.onStringReceived(str);
|
||||
}
|
||||
};
|
||||
|
||||
// Forward file received callback
|
||||
this.orchestrator.onFileReceived = async (file: CustomFile) => {
|
||||
if (this.onFileReceived) {
|
||||
await this.onFileReceived(file);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set progress callback
|
||||
*/
|
||||
public setProgressCallback(
|
||||
callback: (fileId: string, progress: number, speed: number) => void
|
||||
): void {
|
||||
this.orchestrator.setProgressCallback(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set save directory
|
||||
*/
|
||||
public setSaveDirectory(directory: FileSystemDirectoryHandle): Promise<void> {
|
||||
return this.orchestrator.setSaveDirectory(directory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a single file from the peer
|
||||
*/
|
||||
public async requestFile(fileId: string, singleFile = true): Promise<void> {
|
||||
return this.orchestrator.requestFile(fileId, singleFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request all files belonging to a folder from the peer
|
||||
*/
|
||||
public async requestFolder(folderName: string): Promise<void> {
|
||||
return this.orchestrator.requestFolder(folderName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful shutdown
|
||||
*/
|
||||
public gracefulShutdown(reason: string = "CONNECTION_LOST"): void {
|
||||
this.orchestrator.gracefulShutdown(reason);
|
||||
|
||||
// Update saveType for backward compatibility
|
||||
this.saveType = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Force reset all internal states
|
||||
*/
|
||||
public forceReset(): void {
|
||||
this.orchestrator.forceReset();
|
||||
|
||||
// Update saveType for backward compatibility
|
||||
this.saveType = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transfer statistics (for debugging and monitoring)
|
||||
*/
|
||||
public getTransferStats() {
|
||||
return this.orchestrator.getTransferStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all resources
|
||||
*/
|
||||
public cleanup(): void {
|
||||
this.orchestrator.cleanup();
|
||||
this.saveType = {};
|
||||
}
|
||||
|
||||
// ===== Private Methods =====
|
||||
|
||||
/**
|
||||
* Logging utility
|
||||
*/
|
||||
private log(
|
||||
level: "log" | "warn" | "error",
|
||||
message: string,
|
||||
@@ -87,512 +142,36 @@ class FileReceiver {
|
||||
console[level](prefix, message, context || "");
|
||||
}
|
||||
|
||||
private fireError(message: string, context?: Record<string, any>) {
|
||||
if (this.webrtcConnection.fireError) {
|
||||
// @ts-ignore
|
||||
this.webrtcConnection.fireError(message, {
|
||||
...context,
|
||||
component: "FileReceiver",
|
||||
});
|
||||
} else {
|
||||
this.log("error", message, context);
|
||||
}
|
||||
// ===== Backward Compatibility Getters =====
|
||||
|
||||
if (this.activeFileReception) {
|
||||
this.activeFileReception.completionNotifier.reject(new Error(message));
|
||||
this.activeFileReception = null;
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Setup and Public API
|
||||
private setupDataHandler(): void {
|
||||
this.webrtcConnection.onDataReceived = this.handleReceivedData.bind(this);
|
||||
}
|
||||
|
||||
public setProgressCallback(
|
||||
callback: (fileId: string, progress: number, speed: number) => void
|
||||
): void {
|
||||
this.progressCallback = callback;
|
||||
}
|
||||
|
||||
public setSaveDirectory(directory: FileSystemDirectoryHandle): Promise<void> {
|
||||
this.saveDirectory = directory;
|
||||
return Promise.resolve();
|
||||
/**
|
||||
* Get pending files metadata (for backward compatibility)
|
||||
*/
|
||||
public getPendingFilesMeta(): Map<string, fileMetadata> {
|
||||
return this.orchestrator.getPendingFilesMeta();
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a single file from the peer.
|
||||
* Get folder progresses (for backward compatibility)
|
||||
*/
|
||||
public async requestFile(fileId: string, singleFile = true): Promise<void> {
|
||||
if (this.activeFileReception) {
|
||||
this.log("warn", "Another file reception is already in progress.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (singleFile) this.currentFolderName = null;
|
||||
|
||||
const fileInfo = this.pendingFilesMeta.get(fileId);
|
||||
if (!fileInfo) {
|
||||
this.fireError("File info not found for the requested fileId", {
|
||||
fileId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldSaveToDisk =
|
||||
!!this.saveDirectory || fileInfo.size >= this.largeFileThreshold;
|
||||
|
||||
// Set saveType at the beginning of the request to prevent race conditions in the UI
|
||||
this.saveType[fileInfo.fileId] = shouldSaveToDisk;
|
||||
if (this.currentFolderName) {
|
||||
this.saveType[this.currentFolderName] = shouldSaveToDisk;
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
if (shouldSaveToDisk && this.saveDirectory) {
|
||||
try {
|
||||
const folderHandle = await this.createFolderStructure(
|
||||
fileInfo.fullName
|
||||
);
|
||||
const fileHandle = await folderHandle.getFileHandle(fileInfo.name, {
|
||||
create: false,
|
||||
});
|
||||
const file = await fileHandle.getFile();
|
||||
offset = file.size;
|
||||
|
||||
if (offset === fileInfo.size) {
|
||||
this.log("log", "File already fully downloaded.", { fileId });
|
||||
// Optionally, trigger a "completed" state in the UI directly
|
||||
this.progressCallback?.(fileId, 1, 0);
|
||||
return; // Skip the request
|
||||
}
|
||||
this.log("log", `Resuming file from offset: ${offset}`, { fileId });
|
||||
} catch (e) {
|
||||
// File does not exist, starting from scratch
|
||||
this.log("log", "Partial file not found, starting from scratch.", {
|
||||
fileId,
|
||||
});
|
||||
offset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const receptionPromise = new Promise<void>((resolve, reject) => {
|
||||
this.activeFileReception = {
|
||||
meta: fileInfo,
|
||||
chunks: [],
|
||||
receivedSize: 0,
|
||||
initialOffset: offset,
|
||||
fileHandle: null,
|
||||
writeStream: null,
|
||||
completionNotifier: { resolve, reject },
|
||||
};
|
||||
});
|
||||
|
||||
if (shouldSaveToDisk) {
|
||||
await this.createDiskWriteStream(fileInfo, offset);
|
||||
}
|
||||
|
||||
const request: FileRequest = { type: "fileRequest", fileId, offset };
|
||||
if (this.peerId) {
|
||||
this.webrtcConnection.sendData(JSON.stringify(request), this.peerId);
|
||||
this.log("log", "Sent fileRequest", { request });
|
||||
}
|
||||
|
||||
return receptionPromise;
|
||||
public getFolderProgresses(): Record<string, any> {
|
||||
return this.orchestrator.getFolderProgresses();
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests all files belonging to a folder from the peer.
|
||||
* Check if there's an active file reception
|
||||
*/
|
||||
public async requestFolder(folderName: string): Promise<void> {
|
||||
const folderProgress = this.folderProgresses[folderName];
|
||||
if (!folderProgress || folderProgress.fileIds.length === 0) {
|
||||
this.log("warn", "No files found for the requested folder.", {
|
||||
folderName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-calculate total size of already downloaded parts of the folder
|
||||
let initialFolderReceivedSize = 0;
|
||||
if (this.saveDirectory) {
|
||||
for (const fileId of folderProgress.fileIds) {
|
||||
const fileInfo = this.pendingFilesMeta.get(fileId);
|
||||
if (fileInfo) {
|
||||
try {
|
||||
const folderHandle = await this.createFolderStructure(
|
||||
fileInfo.fullName
|
||||
);
|
||||
const fileHandle = await folderHandle.getFileHandle(fileInfo.name, {
|
||||
create: false,
|
||||
});
|
||||
const file = await fileHandle.getFile();
|
||||
initialFolderReceivedSize += file.size;
|
||||
} catch (e) {
|
||||
// File doesn't exist, so its size is 0.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
folderProgress.receivedSize = initialFolderReceivedSize;
|
||||
this.log(
|
||||
"log",
|
||||
`Requesting to receive folder, initial received size: ${initialFolderReceivedSize}`,
|
||||
{ folderName }
|
||||
);
|
||||
|
||||
this.currentFolderName = folderName;
|
||||
for (const fileId of folderProgress.fileIds) {
|
||||
try {
|
||||
await this.requestFile(fileId, false);
|
||||
} catch (error) {
|
||||
this.fireError(
|
||||
`Failed to receive file ${fileId} in folder ${folderName}`,
|
||||
{ error }
|
||||
);
|
||||
// Stop receiving other files in the folder on error
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.currentFolderName = null;
|
||||
|
||||
// After the loop, the receiver has requested all necessary files.
|
||||
// Send a completion message to the sender to sync the final state.
|
||||
const folderComplete: FolderComplete = {
|
||||
type: "FolderComplete",
|
||||
folderName: folderName,
|
||||
};
|
||||
|
||||
if (this.peerId) {
|
||||
this.webrtcConnection.sendData(
|
||||
JSON.stringify(folderComplete),
|
||||
this.peerId
|
||||
);
|
||||
this.log(
|
||||
"log",
|
||||
`Sent folderComplete message for ${folderName} to peer ${this.peerId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region WebRTC Data Handlers
|
||||
private async handleReceivedData(
|
||||
data: string | ArrayBuffer,
|
||||
peerId: string
|
||||
): Promise<void> {
|
||||
this.peerId = peerId;
|
||||
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
const parsedData = JSON.parse(data) as WebRTCMessage;
|
||||
|
||||
const handler =
|
||||
this.fileHandlers[parsedData.type as keyof FileHandlers];
|
||||
if (handler) {
|
||||
await handler(parsedData, peerId);
|
||||
} else {
|
||||
console.warn(
|
||||
`[DEBUG] ⚠️ FileReceiver 未找到处理器: ${parsedData.type}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.fireError("Error parsing received JSON data", { error });
|
||||
}
|
||||
} else if (data instanceof ArrayBuffer) {
|
||||
if (!this.activeFileReception) {
|
||||
this.fireError(
|
||||
"Received a file chunk without an active file reception.",
|
||||
{ peerId }
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.updateProgress(data.byteLength);
|
||||
await this.handleFileChunk(data);
|
||||
}
|
||||
public hasActiveFileReception(): boolean {
|
||||
const stats = this.orchestrator.getTransferStats();
|
||||
return stats.stateManager.hasActiveFileReception;
|
||||
}
|
||||
|
||||
private handleFileMetadata(metadata: fileMetadata): void {
|
||||
if (this.pendingFilesMeta.has(metadata.fileId)) {
|
||||
console.log(
|
||||
`[DEBUG] 📥 FileReceiver 文件元数据已存在,忽略: ${metadata.fileId}`
|
||||
);
|
||||
return; // Ignore if already received.
|
||||
}
|
||||
|
||||
this.pendingFilesMeta.set(metadata.fileId, metadata);
|
||||
|
||||
if (this.onFileMetaReceived) {
|
||||
this.onFileMetaReceived(metadata);
|
||||
} else {
|
||||
console.error(`[DEBUG] ❌ FileReceiver onFileMetaReceived 回调不存在!`);
|
||||
}
|
||||
// Record the file size for folder progress calculation.
|
||||
if (metadata.folderName) {
|
||||
const folderId = metadata.folderName;
|
||||
if (!(folderId in this.folderProgresses)) {
|
||||
this.folderProgresses[folderId] = {
|
||||
totalSize: 0,
|
||||
receivedSize: 0,
|
||||
fileIds: [],
|
||||
};
|
||||
}
|
||||
const folderProgress = this.folderProgresses[folderId];
|
||||
if (!folderProgress.fileIds.includes(metadata.fileId)) {
|
||||
// Prevent duplicate calculation
|
||||
folderProgress.totalSize += metadata.size;
|
||||
folderProgress.fileIds.push(metadata.fileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleStringMetadata(metadata: StringMetadata): void {
|
||||
this.activeStringReception = {
|
||||
length: metadata.length,
|
||||
chunks: [],
|
||||
receivedChunks: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private handleReceivedStringChunk(data: StringChunk): void {
|
||||
if (!this.activeStringReception) return;
|
||||
|
||||
this.activeStringReception.chunks[data.index] = data.chunk;
|
||||
this.activeStringReception.receivedChunks++;
|
||||
|
||||
if (this.activeStringReception.receivedChunks === data.total) {
|
||||
const fullString = this.activeStringReception.chunks.join("");
|
||||
this.onStringReceived?.(fullString);
|
||||
this.activeStringReception = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleFileEnd(metadata: FileEnd): Promise<void> {
|
||||
this.log("log", "File transmission ended", { metadata });
|
||||
const reception = this.activeFileReception;
|
||||
if (!reception || reception.meta.fileId !== metadata.fileId) {
|
||||
this.log("warn", "Received fileEnd for unexpected file", { metadata });
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔧 关键修复:先完成文件处理,确保文件添加到Store
|
||||
await this.finalizeFileReceive();
|
||||
|
||||
// 🏗️ 架构重构:确保Store状态完全同步后再触发进度回调
|
||||
if (!this.currentFolderName) {
|
||||
// 🔧 优化的异步确保机制 - 确保Store状态完全同步
|
||||
await Promise.resolve(); // 确保当前执行栈完成
|
||||
await new Promise<void>((resolve) => {
|
||||
// 使用更长的延迟确保Store状态完全更新
|
||||
setTimeout(() => {
|
||||
this.progressCallback?.(reception.meta.fileId, 1, 0);
|
||||
resolve();
|
||||
}, 10); // 增加到10ms确保Store状态完全同步
|
||||
});
|
||||
}
|
||||
|
||||
this.sendFileAck(reception.meta.fileId);
|
||||
this.log("log", "Sent file-finish ack", { fileId: reception.meta.fileId });
|
||||
|
||||
reception.completionNotifier.resolve();
|
||||
this.activeFileReception = null;
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region File and Folder Processing
|
||||
private async handleFileChunk(chunk: ArrayBuffer): Promise<void> {
|
||||
if (!this.activeFileReception) return;
|
||||
|
||||
if (this.activeFileReception.writeStream) {
|
||||
await this.writeLargeFileChunk(chunk);
|
||||
} else {
|
||||
this.activeFileReception.chunks.push(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
private async finalizeFileReceive(): Promise<void> {
|
||||
if (!this.activeFileReception) return;
|
||||
|
||||
if (this.activeFileReception.writeStream) {
|
||||
await this.finalizeLargeFileReceive();
|
||||
} else {
|
||||
await this.finalizeMemoryFileReceive();
|
||||
}
|
||||
}
|
||||
|
||||
private updateProgress(byteLength: number): void {
|
||||
if (!this.peerId || !this.activeFileReception) return;
|
||||
|
||||
this.activeFileReception.receivedSize += byteLength;
|
||||
const reception = this.activeFileReception;
|
||||
const totalReceived = reception.initialOffset + reception.receivedSize;
|
||||
|
||||
if (this.currentFolderName) {
|
||||
const folderProgress = this.folderProgresses[this.currentFolderName];
|
||||
if (!folderProgress) return;
|
||||
// This is tricky: folder progress needs to sum up individual file progresses.
|
||||
// For simplicity, we'll estimate based on total received for the active file.
|
||||
// A more accurate implementation would track offsets for all files in the folder.
|
||||
folderProgress.receivedSize += byteLength; // This is an approximation
|
||||
|
||||
this.speedCalculator.updateSendSpeed(
|
||||
this.peerId,
|
||||
folderProgress.receivedSize
|
||||
);
|
||||
const speed = this.speedCalculator.getSendSpeed(this.peerId);
|
||||
const progress =
|
||||
folderProgress.totalSize > 0
|
||||
? folderProgress.receivedSize / folderProgress.totalSize
|
||||
: 0;
|
||||
this.progressCallback?.(this.currentFolderName, progress, speed);
|
||||
} else {
|
||||
this.speedCalculator.updateSendSpeed(this.peerId, totalReceived);
|
||||
const speed = this.speedCalculator.getSendSpeed(this.peerId);
|
||||
const progress =
|
||||
reception.meta.size > 0 ? totalReceived / reception.meta.size : 0;
|
||||
this.progressCallback?.(reception.meta.fileId, progress, speed);
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Disk Operations
|
||||
private async createDiskWriteStream(
|
||||
meta: FileMeta,
|
||||
offset: number
|
||||
): Promise<void> {
|
||||
if (!this.saveDirectory || !this.activeFileReception) {
|
||||
this.log("warn", "Save directory not set, falling back to in-memory.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const folderHandle = await this.createFolderStructure(meta.fullName);
|
||||
const fileHandle = await folderHandle.getFileHandle(meta.name, {
|
||||
create: true,
|
||||
});
|
||||
// Use keepExistingData: true to append
|
||||
const writeStream = await fileHandle.createWritable({
|
||||
keepExistingData: true,
|
||||
});
|
||||
// Seek to the offset to start writing from there
|
||||
await writeStream.seek(offset);
|
||||
|
||||
this.activeFileReception.fileHandle = fileHandle;
|
||||
this.activeFileReception.writeStream = writeStream;
|
||||
} catch (err) {
|
||||
this.fireError("Failed to create file on disk", {
|
||||
err,
|
||||
fileName: meta.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async createFolderStructure(
|
||||
fullName: string
|
||||
): Promise<FileSystemDirectoryHandle> {
|
||||
if (!this.saveDirectory) {
|
||||
throw new Error("Save directory not set");
|
||||
}
|
||||
|
||||
const parts = fullName.split("/");
|
||||
parts.pop(); // Remove filename
|
||||
|
||||
let currentDir = this.saveDirectory;
|
||||
for (const part of parts) {
|
||||
if (part) {
|
||||
currentDir = await currentDir.getDirectoryHandle(part, {
|
||||
create: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return currentDir;
|
||||
}
|
||||
|
||||
private async writeLargeFileChunk(chunk: ArrayBuffer): Promise<void> {
|
||||
const stream = this.activeFileReception?.writeStream;
|
||||
if (!stream) {
|
||||
// Fallback to memory if stream is not available for some reason
|
||||
this.activeFileReception?.chunks.push(chunk);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await stream.write(chunk);
|
||||
this.activeFileReception?.chunks.push(null); // Keep track of chunk count
|
||||
} catch (error) {
|
||||
this.fireError("Error writing chunk to disk", { error });
|
||||
}
|
||||
}
|
||||
|
||||
private async finalizeLargeFileReceive(): Promise<void> {
|
||||
const reception = this.activeFileReception;
|
||||
if (!reception?.writeStream || !reception.fileHandle) return;
|
||||
|
||||
try {
|
||||
await reception.writeStream.close();
|
||||
} catch (error) {
|
||||
this.fireError("Error closing write stream", { error });
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region In-Memory Operations
|
||||
private async finalizeMemoryFileReceive(): Promise<void> {
|
||||
const reception = this.activeFileReception;
|
||||
if (!reception) return;
|
||||
|
||||
const fileBlob = new Blob(reception.chunks as ArrayBuffer[], {
|
||||
type: reception.meta.fileType,
|
||||
});
|
||||
const file = new File([fileBlob], reception.meta.name, {
|
||||
type: reception.meta.fileType,
|
||||
});
|
||||
|
||||
const customFile = Object.assign(file, {
|
||||
fullName: reception.meta.fullName,
|
||||
folderName: this.currentFolderName,
|
||||
}) as CustomFile;
|
||||
|
||||
if (this.onFileReceived) {
|
||||
// 🔧 关键修复:确保 onFileReceived 回调完全同步执行完成
|
||||
await this.onFileReceived(customFile);
|
||||
// 🔧 多重确认机制:确保 Store 状态完全同步
|
||||
await Promise.resolve(); // 第一层确认
|
||||
await new Promise<void>((resolve) => setTimeout(() => resolve(), 0)); // 第二层确认
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Communication
|
||||
private sendFileAck(fileId: string): void {
|
||||
if (!this.peerId) return;
|
||||
const confirmation = JSON.stringify({ type: "fileAck", fileId });
|
||||
this.webrtcConnection.sendData(confirmation, this.peerId);
|
||||
}
|
||||
// endregion
|
||||
|
||||
public gracefulShutdown(): void {
|
||||
if (this.activeFileReception?.writeStream) {
|
||||
this.log(
|
||||
"log",
|
||||
"Attempting to gracefully close write stream on page unload."
|
||||
);
|
||||
// We don't await this, as beforeunload does not wait for promises.
|
||||
// This is a "best effort" attempt to flush the buffer to disk.
|
||||
this.activeFileReception.writeStream.close().catch((err) => {
|
||||
this.log("error", "Error closing stream during graceful shutdown", {
|
||||
err,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 🔧 清理所有内部状态,确保重新连接时能正确接收文件元数据
|
||||
this.pendingFilesMeta.clear();
|
||||
this.folderProgresses = {};
|
||||
this.saveType = {};
|
||||
this.activeFileReception = null;
|
||||
this.activeStringReception = null;
|
||||
this.currentFolderName = null;
|
||||
/**
|
||||
* Get current peer ID
|
||||
*/
|
||||
public getCurrentPeerId(): string {
|
||||
const stats = this.orchestrator.getTransferStats();
|
||||
return stats.stateManager.currentPeerId;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+30
-426
@@ -1,449 +1,53 @@
|
||||
// Flow for sending file(s)/folder(s): First, send file metadata, wait for the receiver's request, then send the file content.
|
||||
// After the file is sent, send an endMeta, wait for the receiver's ack, and finish.
|
||||
// Flow for sending a folder (same as above): Receive a batch file request.
|
||||
// Loop through and send the metadata for all files, then record the file size information for the folder part to calculate progress.
|
||||
// The receiving display end distinguishes between single files and folders.
|
||||
import { generateFileId } from "@/lib/fileUtils";
|
||||
import { SpeedCalculator } from "@/lib/speedCalculator";
|
||||
// 🚀 New process - Receiver-initiated file transfer
|
||||
// Refactored FileSender - Using modular architecture
|
||||
|
||||
import WebRTC_Initiator from "./webrtc_Initiator";
|
||||
import {
|
||||
CustomFile,
|
||||
fileMetadata,
|
||||
WebRTCMessage,
|
||||
PeerState,
|
||||
FolderMeta,
|
||||
FileAck,
|
||||
FileRequest,
|
||||
FolderComplete,
|
||||
} from "@/types/webrtc";
|
||||
import { CustomFile } from "@/types/webrtc";
|
||||
import { FileTransferOrchestrator } from "./transfer/FileTransferOrchestrator";
|
||||
|
||||
/**
|
||||
* 🚀 FileSender - Backward compatible wrapper layer
|
||||
*
|
||||
* Refactoring notes:
|
||||
* - Original 875-line monolithic class refactored into modular architecture
|
||||
* - Internally uses FileTransferOrchestrator for unified orchestration
|
||||
* - Maintains 100% backward compatible public API
|
||||
* - Gains advantages such as high-performance file reading and intelligent backpressure control
|
||||
*/
|
||||
class FileSender {
|
||||
private webrtcConnection: WebRTC_Initiator;
|
||||
private peerStates: Map<string, PeerState>;
|
||||
private readonly chunkSize: number;
|
||||
private readonly maxBufferSize: number;
|
||||
private pendingFiles: Map<string, CustomFile>;
|
||||
private pendingFolerMeta: Record<string, FolderMeta>;
|
||||
private speedCalculator: SpeedCalculator;
|
||||
private orchestrator: FileTransferOrchestrator;
|
||||
|
||||
constructor(WebRTC_initiator: WebRTC_Initiator) {
|
||||
this.webrtcConnection = WebRTC_initiator;
|
||||
|
||||
// Maintain independent sending states for each receiver
|
||||
this.peerStates = new Map(); // Map<peerId, PeerState>
|
||||
|
||||
this.chunkSize = 65536; // 64 KB chunks
|
||||
this.maxBufferSize = 10; // Number of chunks to pre-read
|
||||
this.pendingFiles = new Map(); // All files pending to be sent (by reference) {fileId: CustomFile}
|
||||
|
||||
this.pendingFolerMeta = {}; // Metadata for folders (total size, total file count), used for tracking transfer progress
|
||||
|
||||
// Create a SpeedCalculator instance
|
||||
this.speedCalculator = new SpeedCalculator();
|
||||
this.setupDataHandler();
|
||||
constructor(webrtcConnection: WebRTC_Initiator) {
|
||||
this.orchestrator = new FileTransferOrchestrator(webrtcConnection);
|
||||
console.log("[FileSender] ✅ Initialized with modular architecture");
|
||||
}
|
||||
|
||||
// region Logging and Error Handling
|
||||
private log(
|
||||
level: "log" | "warn" | "error",
|
||||
message: string,
|
||||
context?: Record<string, any>
|
||||
) {
|
||||
const prefix = `[FileSender]`;
|
||||
console[level](prefix, message, context || "");
|
||||
public sendFileMeta(files: CustomFile[], peerId?: string): void {
|
||||
return this.orchestrator.sendFileMeta(files, peerId);
|
||||
}
|
||||
|
||||
private fireError(message: string, context?: Record<string, any>) {
|
||||
this.webrtcConnection.fireError(message, {
|
||||
...context,
|
||||
component: "FileSender",
|
||||
});
|
||||
}
|
||||
// endregion
|
||||
// Initialize state for a new receiver
|
||||
private getPeerState(peerId: string): PeerState {
|
||||
if (!this.peerStates.has(peerId)) {
|
||||
this.peerStates.set(peerId, {
|
||||
isSending: false, // Used to determine if a file is successfully sent. True before sending, false after receiving ack.
|
||||
bufferQueue: [], // Pre-read buffer to improve sending efficiency.
|
||||
readOffset: 0, // Read position, used by the sending function.
|
||||
isReading: false, // Whether reading is in progress, used by the sending function to avoid duplicate reads.
|
||||
|
||||
currentFolderName: "", // If the current file belongs to a folder, assign the folder name here.
|
||||
totalBytesSent: {}, // Bytes sent for a file/folder, used for progress calculation; {fileId: 0}
|
||||
progressCallback: null, // Progress callback.
|
||||
});
|
||||
}
|
||||
return this.peerStates.get(peerId)!; // ! Non-Null Assertion Operator
|
||||
}
|
||||
|
||||
private setupDataHandler(): void {
|
||||
this.webrtcConnection.onDataReceived = (data, peerId) => {
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
const parsedData = JSON.parse(data) as WebRTCMessage;
|
||||
this.handleSignalingMessage(parsedData, peerId);
|
||||
} catch (error) {
|
||||
this.fireError("Error parsing received JSON data", { error, peerId });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private handleSignalingMessage(message: WebRTCMessage, peerId: string): void {
|
||||
const peerState = this.getPeerState(peerId);
|
||||
switch (message.type) {
|
||||
case "fileRequest":
|
||||
this.handleFileRequest(message as FileRequest, peerId);
|
||||
break;
|
||||
case "fileAck":
|
||||
peerState.isSending = false;
|
||||
this.log("log", `Received file-finish ack from peer ${peerId}`, {
|
||||
fileId: (message as FileAck).fileId,
|
||||
});
|
||||
break;
|
||||
case "folderComplete":
|
||||
const folderName = (message as FolderComplete).folderName;
|
||||
this.log(
|
||||
"log",
|
||||
`Received folderComplete message for ${folderName} from peer ${peerId}`
|
||||
);
|
||||
// The receiver has confirmed the folder is complete.
|
||||
// Force the progress to 100% for the sender's UI.
|
||||
if (this.pendingFolerMeta[folderName]) {
|
||||
peerState.progressCallback?.(folderName, 1, 0);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
this.log("warn", `Unknown signaling message type received`, {
|
||||
type: message.type,
|
||||
peerId,
|
||||
});
|
||||
}
|
||||
public async sendString(content: string, peerId: string): Promise<void> {
|
||||
return this.orchestrator.sendString(content, peerId);
|
||||
}
|
||||
|
||||
public setProgressCallback(
|
||||
callback: (fileId: string, progress: number, speed: number) => void,
|
||||
peerId: string
|
||||
): void {
|
||||
this.getPeerState(peerId).progressCallback = callback;
|
||||
}
|
||||
// Respond to a file request by sending the file
|
||||
private async handleFileRequest(
|
||||
request: FileRequest,
|
||||
peerId: string
|
||||
): Promise<void> {
|
||||
const file = this.pendingFiles.get(request.fileId);
|
||||
const offset = request.offset || 0;
|
||||
this.log(
|
||||
"log",
|
||||
`Handling file request for ${request.fileId} from ${peerId} with offset ${offset}`
|
||||
);
|
||||
if (file) {
|
||||
await this.sendSingleFile(file, peerId, offset);
|
||||
} else {
|
||||
this.fireError(`File not found for request`, {
|
||||
fileId: request.fileId,
|
||||
peerId,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Modify the sendString method to be asynchronous
|
||||
public async sendString(content: string, peerId: string): Promise<void> {
|
||||
const chunks: string[] = [];
|
||||
for (let i = 0; i < content.length; i += this.chunkSize) {
|
||||
chunks.push(content.slice(i, i + this.chunkSize));
|
||||
}
|
||||
|
||||
// First, send the metadata
|
||||
await this.sendWithBackpressure(
|
||||
JSON.stringify({
|
||||
type: "stringMetadata",
|
||||
length: content.length,
|
||||
}),
|
||||
peerId
|
||||
);
|
||||
|
||||
// Send each chunk sequentially, using backpressure control
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const data = JSON.stringify({
|
||||
type: "string",
|
||||
chunk: chunks[i],
|
||||
index: i,
|
||||
total: chunks.length,
|
||||
});
|
||||
await this.sendWithBackpressure(data, peerId);
|
||||
}
|
||||
return this.orchestrator.setProgressCallback(callback, peerId);
|
||||
}
|
||||
|
||||
public sendFileMeta(files: CustomFile[], peerId?: string): void {
|
||||
// Record the size of files belonging to a folder for progress calculation
|
||||
files.forEach((file) => {
|
||||
if (file.folderName) {
|
||||
const folderId = file.folderName;
|
||||
// folderName: {totalSize: 0, fileIds: []}
|
||||
if (!this.pendingFolerMeta[folderId]) {
|
||||
this.pendingFolerMeta[folderId] = { totalSize: 0, fileIds: [] };
|
||||
}
|
||||
const folderMeta = this.pendingFolerMeta[folderId];
|
||||
const fileId = generateFileId(file);
|
||||
if (!folderMeta.fileIds.includes(fileId)) {
|
||||
// If the file has not been added yet
|
||||
folderMeta.fileIds.push(fileId);
|
||||
folderMeta.totalSize += file.size;
|
||||
}
|
||||
}
|
||||
});
|
||||
// Loop through and send the metadata for all files
|
||||
const peers = peerId
|
||||
? [peerId]
|
||||
: Array.from(this.webrtcConnection.peerConnections.keys());
|
||||
peers.forEach((pId) => {
|
||||
files.forEach((file) => {
|
||||
const fileId = generateFileId(file);
|
||||
this.pendingFiles.set(fileId, file);
|
||||
const fileMeta = this.getFileMeta(file);
|
||||
const metaDataString = JSON.stringify(fileMeta);
|
||||
|
||||
const sendResult = this.webrtcConnection.sendData(metaDataString, pId);
|
||||
if (!sendResult) {
|
||||
this.fireError("Failed to send file metadata", {
|
||||
fileMeta,
|
||||
peerId: pId,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
public getTransferStats(peerId?: string) {
|
||||
return this.orchestrator.getTransferStats(peerId);
|
||||
}
|
||||
|
||||
// Send a single file
|
||||
private async sendSingleFile(
|
||||
file: CustomFile,
|
||||
peerId: string,
|
||||
offset: number = 0
|
||||
): Promise<void> {
|
||||
const fileId = generateFileId(file);
|
||||
const peerState = this.getPeerState(peerId);
|
||||
|
||||
if (peerState.isSending) {
|
||||
this.log(
|
||||
"warn",
|
||||
`Already sending a file to peer ${peerId}, request for ${file.name} ignored.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.log(
|
||||
"log",
|
||||
`Starting to send single file: ${file.name} to ${peerId} from offset ${offset}`
|
||||
);
|
||||
|
||||
// Reset state for the new transfer
|
||||
peerState.isSending = true;
|
||||
peerState.currentFolderName = file.folderName;
|
||||
peerState.readOffset = offset; // Start reading from the given offset
|
||||
peerState.bufferQueue = [];
|
||||
peerState.isReading = false;
|
||||
peerState.totalBytesSent[fileId] = offset; // Start counting sent bytes from the offset
|
||||
|
||||
try {
|
||||
await this.processSendQueue(file, peerId);
|
||||
this.finalizeSendFile(fileId, peerId);
|
||||
|
||||
await this.waitForTransferComplete(peerId); // Wait for transfer completion -- receiver confirmation
|
||||
} catch (error: any) {
|
||||
this.fireError(`Error sending file ${file.name}`, {
|
||||
error: error.message,
|
||||
fileId,
|
||||
peerId,
|
||||
});
|
||||
this.abortFileSend(fileId, peerId);
|
||||
}
|
||||
public handlePeerReconnection(peerId: string): void {
|
||||
this.orchestrator.handlePeerReconnection(peerId);
|
||||
console.log(`[FileSender] Handled peer reconnection for ${peerId}`);
|
||||
}
|
||||
|
||||
private async waitForTransferComplete(peerId: string): Promise<void> {
|
||||
const peerState = this.getPeerState(peerId);
|
||||
while (peerState?.isSending) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
private getFileMeta(file: CustomFile): fileMetadata {
|
||||
const fileId = generateFileId(file);
|
||||
return {
|
||||
type: "fileMeta",
|
||||
fileId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
fileType: file.type,
|
||||
fullName: file.fullName,
|
||||
folderName: file.folderName,
|
||||
};
|
||||
}
|
||||
|
||||
private async updateProgress(
|
||||
byteLength: number,
|
||||
fileId: string,
|
||||
fileSize: number,
|
||||
peerId: string
|
||||
): Promise<void> {
|
||||
const peerState = this.getPeerState(peerId);
|
||||
if (!peerState) return;
|
||||
|
||||
// Always update the individual file's progress first.
|
||||
if (!peerState.totalBytesSent[fileId]) {
|
||||
// This case should be handled by sendSingleFile's initialization
|
||||
peerState.totalBytesSent[fileId] = 0;
|
||||
}
|
||||
peerState.totalBytesSent[fileId] += byteLength;
|
||||
|
||||
let progressFileId = fileId;
|
||||
let currentBytes = peerState.totalBytesSent[fileId];
|
||||
let totalSize = fileSize;
|
||||
|
||||
// If the file is part of a folder, recalculate the folder's progress.
|
||||
if (peerState.currentFolderName) {
|
||||
const folderId = peerState.currentFolderName;
|
||||
const folderMeta = this.pendingFolerMeta[folderId];
|
||||
progressFileId = folderId;
|
||||
totalSize = folderMeta?.totalSize || 0;
|
||||
|
||||
// Recalculate folder progress from the sum of its files' progresses.
|
||||
// This is more robust and correct for resumed transfers.
|
||||
let folderTotalSent = 0;
|
||||
if (folderMeta) {
|
||||
folderMeta.fileIds.forEach((fId) => {
|
||||
folderTotalSent += peerState.totalBytesSent[fId] || 0;
|
||||
});
|
||||
}
|
||||
currentBytes = folderTotalSent;
|
||||
}
|
||||
|
||||
this.speedCalculator.updateSendSpeed(peerId, currentBytes);
|
||||
const speed = this.speedCalculator.getSendSpeed(peerId);
|
||||
const progress = totalSize > 0 ? currentBytes / totalSize : 0;
|
||||
|
||||
peerState.progressCallback?.(progressFileId, progress, speed);
|
||||
}
|
||||
|
||||
private async sendWithBackpressure(
|
||||
data: string | ArrayBuffer,
|
||||
peerId: string
|
||||
): Promise<void> {
|
||||
const dataChannel = this.webrtcConnection.dataChannels.get(peerId);
|
||||
if (!dataChannel) {
|
||||
throw new Error("Data channel not found");
|
||||
}
|
||||
|
||||
if (dataChannel.bufferedAmount > dataChannel.bufferedAmountLowThreshold) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const listener = () => {
|
||||
dataChannel.removeEventListener("bufferedamountlow", listener);
|
||||
resolve();
|
||||
};
|
||||
dataChannel.addEventListener("bufferedamountlow", listener);
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.webrtcConnection.sendData(data, peerId)) {
|
||||
throw new Error("sendData failed");
|
||||
}
|
||||
}
|
||||
//start sending file content
|
||||
private async processSendQueue(
|
||||
file: CustomFile,
|
||||
peerId: string
|
||||
): Promise<void> {
|
||||
const fileId = generateFileId(file);
|
||||
const peerState = this.getPeerState(peerId);
|
||||
const fileReader = new FileReader();
|
||||
|
||||
// The file object itself is the full file. Slicing happens here.
|
||||
const fileToSend = file.slice(peerState.readOffset);
|
||||
let relativeOffset = 0;
|
||||
|
||||
while (relativeOffset < fileToSend.size) {
|
||||
if (!peerState.isSending) {
|
||||
throw new Error("File sending was aborted.");
|
||||
}
|
||||
|
||||
// Read chunks into buffer if not already reading and buffer is not full
|
||||
if (
|
||||
!peerState.isReading &&
|
||||
peerState.bufferQueue.length < this.maxBufferSize
|
||||
) {
|
||||
peerState.isReading = true;
|
||||
const slice = fileToSend.slice(
|
||||
relativeOffset,
|
||||
relativeOffset + this.chunkSize
|
||||
);
|
||||
try {
|
||||
const chunk = await this.readChunkAsArrayBuffer(fileReader, slice);
|
||||
peerState.bufferQueue.push(chunk);
|
||||
relativeOffset += chunk.byteLength;
|
||||
peerState.readOffset += chunk.byteLength; // Also update the main offset
|
||||
} catch (error: any) {
|
||||
throw new Error(`File chunk reading failed: ${error.message}`);
|
||||
} finally {
|
||||
peerState.isReading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Send chunks from buffer
|
||||
if (peerState.bufferQueue.length > 0) {
|
||||
const chunk = peerState.bufferQueue.shift()!;
|
||||
await this.sendWithBackpressure(chunk, peerId);
|
||||
await this.updateProgress(chunk.byteLength, fileId, file.size, peerId);
|
||||
} else if (peerState.isReading) {
|
||||
// If buffer is empty but we are still reading, wait a bit
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
} else if (relativeOffset < fileToSend.size) {
|
||||
// Buffer is empty, not reading, but not done, so trigger a read
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Final progress update to 100%
|
||||
if (!peerState.currentFolderName) {
|
||||
this.getPeerState(peerId).progressCallback?.(fileId, 1, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private readChunkAsArrayBuffer(
|
||||
fileReader: FileReader,
|
||||
blob: Blob
|
||||
): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
fileReader.onload = (e) => {
|
||||
// Ensure e.target.result is an ArrayBuffer
|
||||
if (e.target?.result instanceof ArrayBuffer) {
|
||||
resolve(e.target.result);
|
||||
} else {
|
||||
reject(new Error("Failed to read blob as ArrayBuffer"));
|
||||
}
|
||||
};
|
||||
fileReader.onerror = () =>
|
||||
reject(fileReader.error || new Error("Unknown FileReader error"));
|
||||
fileReader.onabort = () => reject(new Error("File reading was aborted"));
|
||||
fileReader.readAsArrayBuffer(blob);
|
||||
});
|
||||
}
|
||||
//send fileEnd signal
|
||||
private finalizeSendFile(fileId: string, peerId: string): void {
|
||||
// this.log("log", `Finalizing file send for ${fileId} to ${peerId}`);
|
||||
const endMessage = JSON.stringify({ type: "fileEnd", fileId });
|
||||
if (!this.webrtcConnection.sendData(endMessage, peerId)) {
|
||||
this.log("warn", `Failed to send fileEnd message for ${fileId}`);
|
||||
}
|
||||
// The isSending flag will be set to false upon receiving fileAck
|
||||
}
|
||||
|
||||
private abortFileSend(fileId: string, peerId: string): void {
|
||||
this.log("warn", `Aborting file send for ${fileId} to ${peerId}`);
|
||||
const peerState = this.getPeerState(peerId);
|
||||
peerState.isSending = false;
|
||||
peerState.readOffset = 0;
|
||||
peerState.bufferQueue = [];
|
||||
peerState.isReading = false;
|
||||
// Optionally, send an abort message to the receiver
|
||||
public cleanup(): void {
|
||||
return this.orchestrator.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CustomFile } from "@/types/webrtc";
|
||||
import { postLogToBackend } from "@/app/config/api";
|
||||
|
||||
// Adaptively format the file size with units
|
||||
export const formatFileSize = (sizeInBytes: number): string => {
|
||||
@@ -25,15 +26,45 @@ export const generateFileId = (file: CustomFile): string => {
|
||||
export const downloadAs = async (
|
||||
file: Blob | File,
|
||||
saveName: string
|
||||
): Promise<void> => {
|
||||
// Check if file is empty
|
||||
if (file.size === 0) {
|
||||
postLogToBackend(
|
||||
`[Download Debug] CRITICAL ERROR: downloadAs received a file with 0 size! This is the root cause of the 0-byte download issue.`
|
||||
);
|
||||
throw new Error("Cannot download file with 0 size");
|
||||
}
|
||||
|
||||
try {
|
||||
return await standardDownload(file, saveName);
|
||||
} catch (error) {
|
||||
postLogToBackend(`[Download Debug] ERROR in downloadAs: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Standard download mechanism for all browsers
|
||||
*/
|
||||
const standardDownload = async (
|
||||
file: Blob | File,
|
||||
saveName: string
|
||||
): Promise<void> => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = saveName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
try {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = saveName;
|
||||
a.style.display = "none";
|
||||
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
} finally {
|
||||
// Clean up the object URL after a delay to ensure download starts
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
};
|
||||
|
||||
export const traverseFileTree = async (
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import { EmbeddedChunkMeta } from "@/types/webrtc";
|
||||
import { ReceptionConfig } from "./ReceptionConfig";
|
||||
import { postLogToBackend } from "@/app/config/api";
|
||||
|
||||
/**
|
||||
* 🚀 Chunk processing result interface
|
||||
*/
|
||||
export interface ChunkProcessingResult {
|
||||
chunkMeta: EmbeddedChunkMeta;
|
||||
chunkData: ArrayBuffer;
|
||||
absoluteChunkIndex: number;
|
||||
relativeChunkIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 Chunk processor
|
||||
* Handles all data chunk processing, format conversion, and parsing
|
||||
*/
|
||||
export class ChunkProcessor {
|
||||
/**
|
||||
* Convert various binary data formats to ArrayBuffer
|
||||
* Supports Blob, Uint8Array, and other formats for Firefox compatibility
|
||||
*/
|
||||
async convertToArrayBuffer(data: any): Promise<ArrayBuffer | null> {
|
||||
const originalType = Object.prototype.toString.call(data);
|
||||
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return data;
|
||||
} else if (data instanceof Blob) {
|
||||
try {
|
||||
const arrayBuffer = await data.arrayBuffer();
|
||||
if (data.size !== arrayBuffer.byteLength) {
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG] ⚠️ Blob size mismatch: ${data.size}→${arrayBuffer.byteLength}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return arrayBuffer;
|
||||
} catch (error) {
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(`[DEBUG] ❌ Blob conversion failed: ${error}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
} else if (data instanceof Uint8Array || ArrayBuffer.isView(data)) {
|
||||
try {
|
||||
const uint8Array =
|
||||
data instanceof Uint8Array
|
||||
? data
|
||||
: new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||
const newArrayBuffer = new ArrayBuffer(uint8Array.length);
|
||||
new Uint8Array(newArrayBuffer).set(uint8Array);
|
||||
return newArrayBuffer;
|
||||
} catch (error) {
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(`[DEBUG] ❌ TypedArray conversion failed: ${error}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG] ❌ Unknown data type: ${Object.prototype.toString.call(
|
||||
data
|
||||
)}`
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse embedded chunk packet
|
||||
* Format: [4 bytes length] + [JSON metadata] + [actual chunk data]
|
||||
*/
|
||||
parseEmbeddedChunkPacket(arrayBuffer: ArrayBuffer): {
|
||||
chunkMeta: EmbeddedChunkMeta;
|
||||
chunkData: ArrayBuffer;
|
||||
} | null {
|
||||
try {
|
||||
// 1. Check minimum packet length
|
||||
if (arrayBuffer.byteLength < ReceptionConfig.VALIDATION_CONFIG.MIN_PACKET_SIZE) {
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG] ❌ Invalid embedded packet - too small: ${arrayBuffer.byteLength}`
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. Read metadata length (4 bytes)
|
||||
const lengthView = new Uint32Array(arrayBuffer, 0, 1);
|
||||
const metaLength = lengthView[0];
|
||||
|
||||
// 3. Verify packet integrity
|
||||
const expectedTotalLength = 4 + metaLength;
|
||||
if (arrayBuffer.byteLength < expectedTotalLength) {
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG] ❌ Incomplete embedded packet - expected: ${expectedTotalLength}, got: ${arrayBuffer.byteLength}`
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 4. Extract metadata section
|
||||
const metaBytes = new Uint8Array(arrayBuffer, 4, metaLength);
|
||||
const metaJson = new TextDecoder().decode(metaBytes);
|
||||
const chunkMeta: EmbeddedChunkMeta = JSON.parse(metaJson);
|
||||
|
||||
// 5. Extract actual chunk data section
|
||||
const chunkDataStart = 4 + metaLength;
|
||||
const chunkData = arrayBuffer.slice(chunkDataStart);
|
||||
|
||||
// 6. Verify chunk data size
|
||||
if (chunkData.byteLength !== chunkMeta.chunkSize) {
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG] ⚠️ Chunk size mismatch - meta: ${chunkMeta.chunkSize}, actual: ${chunkData.byteLength}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { chunkMeta, chunkData };
|
||||
} catch (error) {
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG] ❌ Failed to parse embedded packet: ${error}`
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process received chunk and calculate indices
|
||||
*/
|
||||
processReceivedChunk(
|
||||
chunkMeta: EmbeddedChunkMeta,
|
||||
chunkData: ArrayBuffer,
|
||||
initialOffset: number
|
||||
): ChunkProcessingResult | null {
|
||||
// Calculate indices
|
||||
const absoluteChunkIndex = chunkMeta.chunkIndex; // Sender's absolute index
|
||||
const startChunkIndex = ReceptionConfig.getChunkIndexFromOffset(initialOffset); // Resume start index
|
||||
const relativeChunkIndex = absoluteChunkIndex - startChunkIndex; // Relative index in chunks array
|
||||
|
||||
// 🎯 Simplify debugging: Only record index mapping when boundary chunk
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING && (absoluteChunkIndex <= 2 || relativeChunkIndex <= 2)) {
|
||||
postLogToBackend(
|
||||
`[INDEX-MAP] abs:${absoluteChunkIndex}, start:${startChunkIndex}, rel:${relativeChunkIndex}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
chunkMeta,
|
||||
chunkData,
|
||||
absoluteChunkIndex,
|
||||
relativeChunkIndex,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate chunk against expected parameters
|
||||
*/
|
||||
validateChunk(
|
||||
chunkMeta: EmbeddedChunkMeta,
|
||||
expectedFileId: string,
|
||||
expectedChunksCount: number,
|
||||
initialOffset: number
|
||||
): {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Verify fileId match
|
||||
if (chunkMeta.fileId !== expectedFileId) {
|
||||
errors.push(`FileId mismatch - expected: ${expectedFileId}, got: ${chunkMeta.fileId}`);
|
||||
}
|
||||
|
||||
// Validate chunk size
|
||||
if (chunkMeta.chunkSize <= 0) {
|
||||
errors.push(`Invalid chunk size: ${chunkMeta.chunkSize}`);
|
||||
}
|
||||
|
||||
// Check if chunk index is reasonable
|
||||
if (chunkMeta.chunkIndex < 0) {
|
||||
errors.push(`Invalid chunk index: ${chunkMeta.chunkIndex}`);
|
||||
}
|
||||
|
||||
// Validate total chunks (with resume consideration)
|
||||
const startChunkIndex = ReceptionConfig.getChunkIndexFromOffset(initialOffset);
|
||||
const calculatedExpected = chunkMeta.totalChunks - startChunkIndex;
|
||||
|
||||
// 🎯 Simplify logging: Only record critical information when the number does not match
|
||||
if (chunkMeta.totalChunks !== expectedChunksCount && calculatedExpected !== expectedChunksCount) {
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[CHUNK-COUNT-MISMATCH] fileTotal:${chunkMeta.totalChunks}, expected:${expectedChunksCount}, calculated:${calculatedExpected}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if chunk index is within valid range
|
||||
*/
|
||||
isChunkIndexValid(
|
||||
relativeChunkIndex: number,
|
||||
expectedChunksCount: number
|
||||
): boolean {
|
||||
return relativeChunkIndex >= 0 && relativeChunkIndex < expectedChunksCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log chunk processing details (for debugging)
|
||||
*/
|
||||
logChunkDetails(
|
||||
result: ChunkProcessingResult,
|
||||
expectedChunksCount: number,
|
||||
writerExpectedIndex?: number
|
||||
): void {
|
||||
if (!ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🎯 Simplify logging: Only record boundary chunk and abnormal cases
|
||||
const { chunkMeta, absoluteChunkIndex, relativeChunkIndex } = result;
|
||||
const isFirstFew = absoluteChunkIndex <= 3;
|
||||
const isLastFew = relativeChunkIndex >= expectedChunksCount - 3;
|
||||
|
||||
// 🔧 Fix: SequencedWriter expects absolute index, not relative index
|
||||
const hasIndexMismatch = writerExpectedIndex !== undefined && absoluteChunkIndex !== writerExpectedIndex;
|
||||
|
||||
if (isFirstFew || isLastFew || hasIndexMismatch) {
|
||||
postLogToBackend(
|
||||
`[CHUNK-DETAIL] #${absoluteChunkIndex} rel:${relativeChunkIndex}${
|
||||
hasIndexMismatch ? ` MISMATCH(writer expects:${writerExpectedIndex})` : ''
|
||||
} size:${chunkMeta.chunkSize}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate completion statistics
|
||||
*/
|
||||
calculateCompletionStats(
|
||||
chunks: (ArrayBuffer | null)[],
|
||||
expectedChunksCount: number,
|
||||
expectedSize: number
|
||||
): {
|
||||
sequencedCount: number;
|
||||
currentTotalSize: number;
|
||||
isSequencedComplete: boolean;
|
||||
sizeComplete: boolean;
|
||||
isDataComplete: boolean;
|
||||
} {
|
||||
// Calculate current actual total received size
|
||||
const currentTotalSize = chunks.reduce((sum, chunk) => {
|
||||
return sum + (chunk instanceof ArrayBuffer ? chunk.byteLength : 0);
|
||||
}, 0);
|
||||
|
||||
// Count sequentially received chunks
|
||||
let sequencedCount = 0;
|
||||
for (let i = 0; i < expectedChunksCount; i++) {
|
||||
if (chunks[i] instanceof ArrayBuffer) {
|
||||
sequencedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const isSequencedComplete = sequencedCount === expectedChunksCount;
|
||||
const sizeComplete = currentTotalSize >= expectedSize;
|
||||
const isDataComplete = isSequencedComplete && sizeComplete;
|
||||
|
||||
return {
|
||||
sequencedCount,
|
||||
currentTotalSize,
|
||||
isSequencedComplete,
|
||||
sizeComplete,
|
||||
isDataComplete,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Log completion check details (for debugging)
|
||||
*/
|
||||
logCompletionCheck(
|
||||
fileName: string,
|
||||
stats: {
|
||||
sequencedCount: number;
|
||||
expectedChunksCount: number;
|
||||
currentTotalSize: number;
|
||||
expectedSize: number;
|
||||
isDataComplete: boolean;
|
||||
},
|
||||
chunks: (ArrayBuffer | null)[],
|
||||
initialOffset: number
|
||||
): void {
|
||||
if (!ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { sequencedCount, expectedChunksCount, currentTotalSize, expectedSize, isDataComplete } = stats;
|
||||
|
||||
// 🎯 Critical log 3: Only print final check results when complete
|
||||
if (isDataComplete) {
|
||||
const startChunkIndex = ReceptionConfig.getChunkIndexFromOffset(initialOffset);
|
||||
const missingChunks = [];
|
||||
|
||||
for (let i = 0; i < expectedChunksCount; i++) {
|
||||
if (!chunks[i]) {
|
||||
const absoluteIndex = startChunkIndex + i;
|
||||
missingChunks.push(absoluteIndex);
|
||||
}
|
||||
}
|
||||
|
||||
postLogToBackend(
|
||||
`[FINAL-CHECK] File: ${fileName}, received: ${sequencedCount}/${expectedChunksCount}, sizeDiff: ${expectedSize - currentTotalSize}, missing: [${missingChunks.join(',')}]`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { CustomFile, fileMetadata } from "@/types/webrtc";
|
||||
import { ReceptionConfig } from "./ReceptionConfig";
|
||||
import { postLogToBackend } from "@/app/config/api";
|
||||
|
||||
const developmentEnv = process.env.NODE_ENV;
|
||||
|
||||
/**
|
||||
* 🚀 File assembly result interface
|
||||
*/
|
||||
export interface FileAssemblyResult {
|
||||
file: CustomFile;
|
||||
totalChunkSize: number;
|
||||
validChunks: number;
|
||||
storeUpdated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 File assembler
|
||||
* Handles in-memory file assembly and validation
|
||||
*/
|
||||
export class FileAssembler {
|
||||
/**
|
||||
* Assemble file from chunks in memory
|
||||
*/
|
||||
async assembleFileFromChunks(
|
||||
chunks: (ArrayBuffer | null)[],
|
||||
meta: fileMetadata,
|
||||
currentFolderName: string | null,
|
||||
onFileReceived?: (file: CustomFile) => Promise<void>
|
||||
): Promise<FileAssemblyResult> {
|
||||
// Validate and count chunks
|
||||
let totalChunkSize = 0;
|
||||
let validChunks = 0;
|
||||
|
||||
chunks.forEach((chunk, index) => {
|
||||
if (chunk instanceof ArrayBuffer) {
|
||||
validChunks++;
|
||||
totalChunkSize += chunk.byteLength;
|
||||
}
|
||||
});
|
||||
|
||||
// Final verification
|
||||
const sizeDifference = meta.size - totalChunkSize;
|
||||
if (Math.abs(sizeDifference) > ReceptionConfig.VALIDATION_CONFIG.MAX_SIZE_DIFFERENCE_BYTES) {
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG] ❌ SIZE_MISMATCH - difference: ${sizeDifference} bytes (threshold: ${ReceptionConfig.VALIDATION_CONFIG.MAX_SIZE_DIFFERENCE_BYTES})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create file blob from valid chunks
|
||||
const validChunkBuffers = chunks.filter(
|
||||
(chunk) => chunk instanceof ArrayBuffer
|
||||
) as ArrayBuffer[];
|
||||
|
||||
const fileBlob = new Blob(validChunkBuffers, {
|
||||
type: meta.fileType,
|
||||
});
|
||||
|
||||
// Create File object
|
||||
const file = new File([fileBlob], meta.name, {
|
||||
type: meta.fileType,
|
||||
});
|
||||
|
||||
// Create CustomFile with additional properties
|
||||
const customFile = Object.assign(file, {
|
||||
fullName: meta.fullName,
|
||||
folderName: currentFolderName,
|
||||
}) as CustomFile;
|
||||
|
||||
// Store the file if callback is provided
|
||||
let storeUpdated = false;
|
||||
if (onFileReceived) {
|
||||
await onFileReceived(customFile);
|
||||
await Promise.resolve();
|
||||
await new Promise<void>((resolve) => setTimeout(() => resolve(), 0));
|
||||
storeUpdated = true;
|
||||
}
|
||||
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG] ✅ File assembled - ${meta.name}, chunks: ${validChunks}/${chunks.length}, size: ${totalChunkSize}/${meta.size}, stored: ${storeUpdated}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
file: customFile,
|
||||
totalChunkSize,
|
||||
validChunks,
|
||||
storeUpdated,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file assembly completeness
|
||||
*/
|
||||
validateAssembly(
|
||||
chunks: (ArrayBuffer | null)[],
|
||||
expectedSize: number,
|
||||
expectedChunksCount: number
|
||||
): {
|
||||
isComplete: boolean;
|
||||
validChunks: number;
|
||||
totalSize: number;
|
||||
missingChunks: number[];
|
||||
sizeDifference: number;
|
||||
} {
|
||||
let totalSize = 0;
|
||||
let validChunks = 0;
|
||||
const missingChunks: number[] = [];
|
||||
|
||||
chunks.forEach((chunk, index) => {
|
||||
if (chunk instanceof ArrayBuffer) {
|
||||
validChunks++;
|
||||
totalSize += chunk.byteLength;
|
||||
} else {
|
||||
missingChunks.push(index);
|
||||
}
|
||||
});
|
||||
|
||||
const sizeDifference = expectedSize - totalSize;
|
||||
const isComplete =
|
||||
validChunks === expectedChunksCount &&
|
||||
Math.abs(sizeDifference) <= ReceptionConfig.VALIDATION_CONFIG.MAX_SIZE_DIFFERENCE_BYTES;
|
||||
|
||||
return {
|
||||
isComplete,
|
||||
validChunks,
|
||||
totalSize,
|
||||
missingChunks,
|
||||
sizeDifference,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get assembly statistics for debugging
|
||||
*/
|
||||
getAssemblyStats(chunks: (ArrayBuffer | null)[]): {
|
||||
totalChunks: number;
|
||||
validChunks: number;
|
||||
nullChunks: number;
|
||||
totalSize: number;
|
||||
averageChunkSize: number;
|
||||
firstNullIndex: number | null;
|
||||
lastValidIndex: number | null;
|
||||
} {
|
||||
let validChunks = 0;
|
||||
let totalSize = 0;
|
||||
let firstNullIndex: number | null = null;
|
||||
let lastValidIndex: number | null = null;
|
||||
|
||||
chunks.forEach((chunk, index) => {
|
||||
if (chunk instanceof ArrayBuffer) {
|
||||
validChunks++;
|
||||
totalSize += chunk.byteLength;
|
||||
lastValidIndex = index;
|
||||
} else {
|
||||
if (firstNullIndex === null) {
|
||||
firstNullIndex = index;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const averageChunkSize = validChunks > 0 ? totalSize / validChunks : 0;
|
||||
|
||||
return {
|
||||
totalChunks: chunks.length,
|
||||
validChunks,
|
||||
nullChunks: chunks.length - validChunks,
|
||||
totalSize,
|
||||
averageChunkSize,
|
||||
firstNullIndex,
|
||||
lastValidIndex,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create file download URL for in-memory files
|
||||
*/
|
||||
createDownloadUrl(file: File): string {
|
||||
return URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke file download URL to free memory
|
||||
*/
|
||||
revokeDownloadUrl(url: string): void {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file type information
|
||||
*/
|
||||
getFileTypeInfo(file: File): {
|
||||
mimeType: string;
|
||||
extension: string;
|
||||
category: 'image' | 'video' | 'audio' | 'document' | 'archive' | 'other';
|
||||
} {
|
||||
const mimeType = file.type || 'application/octet-stream';
|
||||
const extension = file.name.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
let category: 'image' | 'video' | 'audio' | 'document' | 'archive' | 'other' = 'other';
|
||||
|
||||
if (mimeType.startsWith('image/')) {
|
||||
category = 'image';
|
||||
} else if (mimeType.startsWith('video/')) {
|
||||
category = 'video';
|
||||
} else if (mimeType.startsWith('audio/')) {
|
||||
category = 'audio';
|
||||
} else if (
|
||||
mimeType.includes('text/') ||
|
||||
mimeType.includes('application/pdf') ||
|
||||
mimeType.includes('application/msword') ||
|
||||
mimeType.includes('application/vnd.openxmlformats')
|
||||
) {
|
||||
category = 'document';
|
||||
} else if (
|
||||
mimeType.includes('zip') ||
|
||||
mimeType.includes('rar') ||
|
||||
mimeType.includes('tar') ||
|
||||
mimeType.includes('gzip')
|
||||
) {
|
||||
category = 'archive';
|
||||
}
|
||||
|
||||
return {
|
||||
mimeType,
|
||||
extension,
|
||||
category,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate memory usage for file assembly
|
||||
*/
|
||||
estimateMemoryUsage(chunks: (ArrayBuffer | null)[]): {
|
||||
chunkMemoryUsage: number;
|
||||
estimatedBlobMemory: number;
|
||||
totalEstimatedMemory: number;
|
||||
} {
|
||||
const chunkMemoryUsage = chunks.reduce((sum, chunk) => {
|
||||
return sum + (chunk instanceof ArrayBuffer ? chunk.byteLength : 0);
|
||||
}, 0);
|
||||
|
||||
// Blob creation might temporarily double memory usage
|
||||
const estimatedBlobMemory = chunkMemoryUsage;
|
||||
const totalEstimatedMemory = chunkMemoryUsage + estimatedBlobMemory;
|
||||
|
||||
return {
|
||||
chunkMemoryUsage,
|
||||
estimatedBlobMemory,
|
||||
totalEstimatedMemory,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file should be assembled in memory or streamed to disk
|
||||
*/
|
||||
shouldAssembleInMemory(
|
||||
fileSize: number,
|
||||
hasSaveDirectory: boolean,
|
||||
availableMemory?: number
|
||||
): boolean {
|
||||
// If we have a save directory and file is large, prefer disk
|
||||
if (hasSaveDirectory && fileSize >= ReceptionConfig.FILE_CONFIG.LARGE_FILE_THRESHOLD) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If available memory is provided, check if we have enough
|
||||
if (availableMemory !== undefined) {
|
||||
// Need roughly 2x file size for assembly process
|
||||
const requiredMemory = fileSize * 2;
|
||||
return availableMemory > requiredMemory;
|
||||
}
|
||||
|
||||
// Default: assemble in memory for smaller files
|
||||
return fileSize < ReceptionConfig.FILE_CONFIG.LARGE_FILE_THRESHOLD;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
import WebRTC_Recipient from "../webrtc_Recipient";
|
||||
import { CustomFile, fileMetadata } from "@/types/webrtc";
|
||||
import { ReceptionStateManager } from "./ReceptionStateManager";
|
||||
import { MessageProcessor, MessageProcessorDelegate } from "./MessageProcessor";
|
||||
import { ChunkProcessor } from "./ChunkProcessor";
|
||||
import {
|
||||
StreamingFileWriter
|
||||
} from "./StreamingFileWriter";
|
||||
import { FileAssembler } from "./FileAssembler";
|
||||
import { ProgressReporter, ProgressCallback } from "./ProgressReporter";
|
||||
import { ReceptionConfig } from "./ReceptionConfig";
|
||||
import { ChunkRangeCalculator } from "@/lib/utils/ChunkRangeCalculator";
|
||||
import { postLogToBackend } from "@/app/config/api";
|
||||
|
||||
/**
|
||||
* 🚀 File receive orchestrator
|
||||
* Main coordinator that integrates all reception modules
|
||||
*/
|
||||
export class FileReceiveOrchestrator implements MessageProcessorDelegate {
|
||||
private stateManager: ReceptionStateManager;
|
||||
private messageProcessor: MessageProcessor;
|
||||
private chunkProcessor: ChunkProcessor;
|
||||
private streamingFileWriter: StreamingFileWriter;
|
||||
private fileAssembler: FileAssembler;
|
||||
private progressReporter: ProgressReporter;
|
||||
|
||||
// Callbacks
|
||||
public onFileMetaReceived: ((meta: fileMetadata) => void) | undefined =
|
||||
undefined;
|
||||
public onStringReceived: ((str: string) => void) | undefined = undefined;
|
||||
public onFileReceived: ((file: CustomFile) => Promise<void>) | undefined =
|
||||
undefined;
|
||||
|
||||
constructor(private webrtcConnection: WebRTC_Recipient) {
|
||||
// Initialize all components
|
||||
this.stateManager = new ReceptionStateManager();
|
||||
this.chunkProcessor = new ChunkProcessor();
|
||||
this.streamingFileWriter = new StreamingFileWriter();
|
||||
this.fileAssembler = new FileAssembler();
|
||||
this.progressReporter = new ProgressReporter(this.stateManager);
|
||||
this.messageProcessor = new MessageProcessor(
|
||||
this.stateManager,
|
||||
webrtcConnection,
|
||||
{
|
||||
onFileMetaReceived: (meta: fileMetadata) => {
|
||||
if (this.onFileMetaReceived) {
|
||||
this.onFileMetaReceived(meta);
|
||||
}
|
||||
},
|
||||
onStringReceived: (str: string) => {
|
||||
if (this.onStringReceived) {
|
||||
this.onStringReceived(str);
|
||||
}
|
||||
},
|
||||
log: this.log.bind(this),
|
||||
}
|
||||
);
|
||||
|
||||
// Set up data handler
|
||||
this.setupDataHandler();
|
||||
|
||||
this.log("log", "FileReceiveOrchestrator initialized");
|
||||
}
|
||||
|
||||
// ===== Public API =====
|
||||
|
||||
/**
|
||||
* Set progress callback
|
||||
*/
|
||||
public setProgressCallback(callback: ProgressCallback): void {
|
||||
this.progressReporter.setProgressCallback(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set save directory
|
||||
*/
|
||||
public setSaveDirectory(directory: FileSystemDirectoryHandle): Promise<void> {
|
||||
this.stateManager.setSaveDirectory(directory);
|
||||
this.streamingFileWriter.setSaveDirectory(directory);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a single file from the peer
|
||||
*/
|
||||
public async requestFile(fileId: string, singleFile = true): Promise<void> {
|
||||
const activeReception = this.stateManager.getActiveFileReception();
|
||||
if (activeReception) {
|
||||
this.log("warn", "Another file reception is already in progress.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (singleFile) {
|
||||
this.stateManager.setCurrentFolderName(null);
|
||||
}
|
||||
|
||||
const fileInfo = this.stateManager.getFileMetadata(fileId);
|
||||
if (!fileInfo) {
|
||||
this.fireError("File info not found for the requested fileId", {
|
||||
fileId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldSaveToDisk = ReceptionConfig.shouldSaveToDisk(
|
||||
fileInfo.size,
|
||||
this.streamingFileWriter.hasSaveDirectory()
|
||||
);
|
||||
|
||||
// Set save type at the beginning to prevent race conditions
|
||||
this.stateManager.setSaveType(fileInfo.fileId, shouldSaveToDisk);
|
||||
const currentFolderName = this.stateManager.getCurrentFolderName();
|
||||
if (currentFolderName) {
|
||||
this.stateManager.setSaveType(currentFolderName, shouldSaveToDisk);
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
if (shouldSaveToDisk && this.streamingFileWriter.hasSaveDirectory()) {
|
||||
try {
|
||||
offset = await this.streamingFileWriter.getPartialFileSize(
|
||||
fileInfo.name,
|
||||
fileInfo.fullName
|
||||
);
|
||||
|
||||
if (offset === fileInfo.size) {
|
||||
this.log("log", "File already fully downloaded.", { fileId });
|
||||
this.progressReporter.reportFileComplete(fileId);
|
||||
return;
|
||||
}
|
||||
this.log("log", `Resuming file from offset: ${offset}`, { fileId });
|
||||
} catch (e) {
|
||||
this.log("log", "Partial file not found, starting from scratch.", {
|
||||
fileId,
|
||||
});
|
||||
offset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const expectedChunksCount = ReceptionConfig.calculateExpectedChunks(
|
||||
fileInfo.size,
|
||||
offset
|
||||
);
|
||||
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
// 🎯 Critical log 2: Summary information for receiver - using unified chunk range calculation logic
|
||||
const chunkRange = ChunkRangeCalculator.getChunkRange(
|
||||
fileInfo.size,
|
||||
offset,
|
||||
ReceptionConfig.FILE_CONFIG.CHUNK_SIZE
|
||||
);
|
||||
|
||||
postLogToBackend(
|
||||
`[RECV-SUMMARY] File: ${fileInfo.name}, expected: ${expectedChunksCount}, calculated: ${chunkRange.totalChunks}, startChunk: ${chunkRange.startChunk}, endChunk: ${chunkRange.endChunk}, absoluteTotal: ${chunkRange.absoluteTotalChunks}`
|
||||
);
|
||||
}
|
||||
|
||||
const receptionPromise = this.stateManager.startFileReception(
|
||||
fileInfo,
|
||||
expectedChunksCount,
|
||||
offset
|
||||
);
|
||||
|
||||
if (shouldSaveToDisk) {
|
||||
await this.createDiskWriteStream(fileInfo, offset);
|
||||
}
|
||||
|
||||
// Send file request
|
||||
const success = this.messageProcessor.sendFileRequest(fileId, offset);
|
||||
if (!success) {
|
||||
this.stateManager.failFileReception(
|
||||
new Error("Failed to send file request")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
return receptionPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request all files belonging to a folder from the peer
|
||||
*/
|
||||
public async requestFolder(folderName: string): Promise<void> {
|
||||
const folderProgress = this.stateManager.getFolderProgress(folderName);
|
||||
if (!folderProgress || folderProgress.fileIds.length === 0) {
|
||||
this.log("warn", "No files found for the requested folder.", {
|
||||
folderName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-calculate total size of already downloaded parts
|
||||
let initialFolderReceivedSize = 0;
|
||||
if (this.streamingFileWriter.hasSaveDirectory()) {
|
||||
for (const fileId of folderProgress.fileIds) {
|
||||
const fileInfo = this.stateManager.getFileMetadata(fileId);
|
||||
if (fileInfo) {
|
||||
try {
|
||||
const partialSize =
|
||||
await this.streamingFileWriter.getPartialFileSize(
|
||||
fileInfo.name,
|
||||
fileInfo.fullName
|
||||
);
|
||||
initialFolderReceivedSize += partialSize;
|
||||
} catch (e) {
|
||||
// File doesn't exist, so its size is 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.stateManager.setFolderReceivedSize(
|
||||
folderName,
|
||||
initialFolderReceivedSize
|
||||
);
|
||||
this.log(
|
||||
"log",
|
||||
`Requesting folder, initial received size: ${initialFolderReceivedSize}`,
|
||||
{ folderName }
|
||||
);
|
||||
|
||||
this.stateManager.setCurrentFolderName(folderName);
|
||||
|
||||
for (const fileId of folderProgress.fileIds) {
|
||||
try {
|
||||
await this.requestFile(fileId, false);
|
||||
} catch (error) {
|
||||
this.fireError(
|
||||
`Failed to receive file ${fileId} in folder ${folderName}`,
|
||||
{ error }
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.stateManager.setCurrentFolderName(null);
|
||||
|
||||
// Send folder completion message
|
||||
const completedFileIds = folderProgress.fileIds.filter(() => true); // Assume all succeeded
|
||||
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG] 📁 All files in folder completed - ${folderName}, files: ${completedFileIds.length}/${folderProgress.fileIds.length}`
|
||||
);
|
||||
}
|
||||
|
||||
this.messageProcessor.sendFolderReceiveComplete(
|
||||
folderName,
|
||||
completedFileIds,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// ===== MessageProcessorDelegate Implementation =====
|
||||
|
||||
// Note: These are implemented as properties, not methods, to avoid infinite recursion
|
||||
|
||||
public log(
|
||||
level: "log" | "warn" | "error",
|
||||
message: string,
|
||||
context?: Record<string, any>
|
||||
): void {
|
||||
const prefix = `[FileReceiveOrchestrator]`;
|
||||
console[level](prefix, message, context || "");
|
||||
}
|
||||
|
||||
// ===== Internal Methods =====
|
||||
|
||||
/**
|
||||
* Set up data handler
|
||||
*/
|
||||
private setupDataHandler(): void {
|
||||
this.webrtcConnection.onDataReceived = async (data, peerId) => {
|
||||
const binaryData = await this.messageProcessor.handleReceivedMessage(
|
||||
data,
|
||||
peerId
|
||||
);
|
||||
|
||||
if (binaryData) {
|
||||
// Handle binary chunk data
|
||||
await this.handleBinaryChunkData(binaryData);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle binary chunk data
|
||||
*/
|
||||
private async handleBinaryChunkData(data: any): Promise<void> {
|
||||
const activeReception = this.stateManager.getActiveFileReception();
|
||||
if (!activeReception) {
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG] ERROR: Received file chunk but no active file reception!`
|
||||
);
|
||||
}
|
||||
this.fireError("Received a file chunk without an active file reception.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to ArrayBuffer
|
||||
const arrayBuffer = await this.chunkProcessor.convertToArrayBuffer(data);
|
||||
if (!arrayBuffer) {
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG] ERROR: Failed to convert binary data to ArrayBuffer`
|
||||
);
|
||||
}
|
||||
this.fireError("Received unsupported binary data format", {
|
||||
dataType: Object.prototype.toString.call(data),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.handleEmbeddedChunkPacket(arrayBuffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle embedded chunk packet
|
||||
*/
|
||||
private async handleEmbeddedChunkPacket(
|
||||
arrayBuffer: ArrayBuffer
|
||||
): Promise<void> {
|
||||
const parsed = this.chunkProcessor.parseEmbeddedChunkPacket(arrayBuffer);
|
||||
if (!parsed) {
|
||||
this.fireError("Failed to parse embedded chunk packet");
|
||||
return;
|
||||
}
|
||||
|
||||
const { chunkMeta, chunkData } = parsed;
|
||||
const reception = this.stateManager.getActiveFileReception();
|
||||
if (!reception) {
|
||||
console.log(
|
||||
`[FileReceiveOrchestrator] Ignoring chunk ${chunkMeta.chunkIndex} - file reception already closed`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate chunk
|
||||
const validation = this.chunkProcessor.validateChunk(
|
||||
chunkMeta,
|
||||
reception.meta.fileId,
|
||||
reception.expectedChunksCount,
|
||||
reception.initialOffset
|
||||
);
|
||||
|
||||
if (!validation.isValid) {
|
||||
this.log("warn", "Chunk validation failed", {
|
||||
errors: validation.errors,
|
||||
chunkIndex: chunkMeta.chunkIndex,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Process chunk indices
|
||||
const result = this.chunkProcessor.processReceivedChunk(
|
||||
chunkMeta,
|
||||
chunkData,
|
||||
reception.initialOffset
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
this.fireError("Failed to process received chunk");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if chunk index is valid
|
||||
if (
|
||||
!this.chunkProcessor.isChunkIndexValid(
|
||||
result.relativeChunkIndex,
|
||||
reception.expectedChunksCount
|
||||
)
|
||||
) {
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG-CHUNKS] ❌ Invalid relative chunk index - absolute:${result.absoluteChunkIndex}, relative:${result.relativeChunkIndex}, arraySize:${reception.chunks.length}`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Store chunk
|
||||
reception.chunks[result.relativeChunkIndex] = result.chunkData;
|
||||
reception.chunkSequenceMap.set(result.absoluteChunkIndex, true);
|
||||
reception.receivedChunksCount++;
|
||||
|
||||
// Update progress
|
||||
this.progressReporter.updateFileProgress(
|
||||
result.chunkData.byteLength,
|
||||
reception.meta.fileId,
|
||||
reception.meta.size
|
||||
);
|
||||
|
||||
// Handle disk writing if needed
|
||||
if (reception.sequencedWriter) {
|
||||
// 🔧 Fix: SequencedWriter uses absolute index, ensuring correct index is passed
|
||||
this.chunkProcessor.logChunkDetails(
|
||||
result,
|
||||
reception.expectedChunksCount,
|
||||
reception.sequencedWriter.expectedIndex
|
||||
);
|
||||
|
||||
// ✅ Correctly use absolute index for disk writing
|
||||
await reception.sequencedWriter.writeChunk(
|
||||
result.absoluteChunkIndex,
|
||||
result.chunkData
|
||||
);
|
||||
}
|
||||
|
||||
await this.checkAndAutoFinalize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and auto-finalize file reception
|
||||
*/
|
||||
private async checkAndAutoFinalize(): Promise<void> {
|
||||
const reception = this.stateManager.getActiveFileReception();
|
||||
if (!reception || reception.isFinalized) return;
|
||||
|
||||
const expectedSize = reception.meta.size - reception.initialOffset;
|
||||
const stats = this.chunkProcessor.calculateCompletionStats(
|
||||
reception.chunks,
|
||||
reception.expectedChunksCount,
|
||||
expectedSize
|
||||
);
|
||||
|
||||
// Log completion check details
|
||||
this.chunkProcessor.logCompletionCheck(
|
||||
reception.meta.name,
|
||||
{
|
||||
sequencedCount: stats.sequencedCount,
|
||||
expectedChunksCount: reception.expectedChunksCount,
|
||||
currentTotalSize: stats.currentTotalSize,
|
||||
expectedSize,
|
||||
isDataComplete: stats.isDataComplete,
|
||||
},
|
||||
reception.chunks,
|
||||
reception.initialOffset
|
||||
);
|
||||
|
||||
if (stats.isDataComplete) {
|
||||
reception.isFinalized = true;
|
||||
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG-COMPLETE] ✅ Starting finalization - isDataComplete:${stats.isDataComplete}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.finalizeFileReceive();
|
||||
this.stateManager.completeFileReception();
|
||||
} catch (error) {
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(`[DEBUG] ❌ Auto-finalize ERROR: ${error}`);
|
||||
}
|
||||
this.stateManager.failFileReception(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize file reception
|
||||
*/
|
||||
private async finalizeFileReceive(): Promise<void> {
|
||||
const reception = this.stateManager.getActiveFileReception();
|
||||
if (!reception) return;
|
||||
|
||||
if (reception.writeStream) {
|
||||
await this.finalizeLargeFileReceive();
|
||||
} else {
|
||||
await this.finalizeMemoryFileReceive();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize large file reception (disk-based)
|
||||
*/
|
||||
private async finalizeLargeFileReceive(): Promise<void> {
|
||||
const reception = this.stateManager.getActiveFileReception();
|
||||
if (!reception?.writeStream || !reception.fileHandle) {
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG-FINALIZE] ❌ Cannot finalize - missing writeStream:${!!reception?.writeStream} or fileHandle:${!!reception?.fileHandle}`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG-FINALIZE] 🚀 Starting finalization for ${reception.meta.name}`
|
||||
);
|
||||
}
|
||||
|
||||
// Finalize using StreamingFileWriter
|
||||
if (reception.sequencedWriter && reception.writeStream) {
|
||||
await this.streamingFileWriter.finalizeWrite(
|
||||
reception.sequencedWriter,
|
||||
reception.writeStream,
|
||||
reception.meta.name
|
||||
);
|
||||
}
|
||||
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG-FINALIZE] ✅ LARGE_FILE finalized successfully - ${reception.meta.name}`
|
||||
);
|
||||
}
|
||||
|
||||
// 🆕 Send completion confirmation for large files
|
||||
const stats = this.chunkProcessor.calculateCompletionStats(
|
||||
reception.chunks,
|
||||
reception.expectedChunksCount,
|
||||
reception.meta.size - reception.initialOffset
|
||||
);
|
||||
|
||||
this.messageProcessor.sendFileReceiveComplete(
|
||||
reception.meta.fileId,
|
||||
stats.currentTotalSize,
|
||||
stats.sequencedCount,
|
||||
true
|
||||
);
|
||||
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG-FINALIZE] 📤 LARGE_FILE completion confirmation sent - ${reception.meta.fileId}, size: ${stats.currentTotalSize}, chunks: ${stats.sequencedCount}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG-FINALIZE] ❌ Error during finalization: ${error}`
|
||||
);
|
||||
}
|
||||
this.fireError("Error finalizing large file", { error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize memory file reception
|
||||
*/
|
||||
private async finalizeMemoryFileReceive(): Promise<void> {
|
||||
const reception = this.stateManager.getActiveFileReception();
|
||||
if (!reception) return;
|
||||
|
||||
const currentFolderName = this.stateManager.getCurrentFolderName();
|
||||
const result = await this.fileAssembler.assembleFileFromChunks(
|
||||
reception.chunks,
|
||||
reception.meta,
|
||||
currentFolderName,
|
||||
this.onFileReceived
|
||||
);
|
||||
|
||||
// Send completion confirmation
|
||||
this.messageProcessor.sendFileReceiveComplete(
|
||||
reception.meta.fileId,
|
||||
result.totalChunkSize,
|
||||
result.validChunks,
|
||||
result.storeUpdated
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create disk write stream
|
||||
*/
|
||||
private async createDiskWriteStream(
|
||||
meta: fileMetadata,
|
||||
offset: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { fileHandle, writeStream, sequencedWriter } =
|
||||
await this.streamingFileWriter.createWriteStream(
|
||||
meta.name,
|
||||
meta.fullName,
|
||||
offset
|
||||
);
|
||||
|
||||
this.stateManager.updateActiveFileReception({
|
||||
fileHandle,
|
||||
writeStream,
|
||||
sequencedWriter,
|
||||
});
|
||||
} catch (err) {
|
||||
this.fireError("Failed to create file on disk", {
|
||||
err,
|
||||
fileName: meta.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error handling
|
||||
*/
|
||||
private fireError(message: string, context?: Record<string, any>) {
|
||||
if (this.webrtcConnection.fireError) {
|
||||
// @ts-ignore
|
||||
this.webrtcConnection.fireError(message, {
|
||||
...context,
|
||||
component: "FileReceiveOrchestrator",
|
||||
});
|
||||
} else {
|
||||
this.log("error", message, context);
|
||||
}
|
||||
|
||||
const reception = this.stateManager.getActiveFileReception();
|
||||
if (reception) {
|
||||
// Clean up resources on error
|
||||
if (reception.sequencedWriter) {
|
||||
reception.sequencedWriter.close().catch((err: any) => {
|
||||
this.log(
|
||||
"error",
|
||||
"Error closing sequenced writer during error cleanup",
|
||||
{ err }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
this.stateManager.failFileReception(new Error(message));
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Lifecycle Management =====
|
||||
|
||||
/**
|
||||
* Graceful shutdown
|
||||
*/
|
||||
public gracefulShutdown(reason: string = "CONNECTION_LOST"): void {
|
||||
this.log("log", `Graceful shutdown initiated: ${reason}`);
|
||||
|
||||
const reception = this.stateManager.getActiveFileReception();
|
||||
if (reception?.sequencedWriter && reception?.writeStream) {
|
||||
this.log("log", "Attempting to gracefully close streams on shutdown.");
|
||||
|
||||
// Close sequenced writer and write stream
|
||||
reception.sequencedWriter.close().catch((err: any) => {
|
||||
this.log(
|
||||
"error",
|
||||
"Error closing sequenced writer during graceful shutdown",
|
||||
{ err }
|
||||
);
|
||||
});
|
||||
|
||||
reception.writeStream.close().catch((err: any) => {
|
||||
this.log("error", "Error closing stream during graceful shutdown", {
|
||||
err,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.stateManager.gracefulCleanup();
|
||||
this.log("log", "Graceful shutdown completed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Force reset all internal states
|
||||
*/
|
||||
public forceReset(): void {
|
||||
this.log("log", "Force resetting FileReceiveOrchestrator state");
|
||||
|
||||
const reception = this.stateManager.getActiveFileReception();
|
||||
if (reception?.sequencedWriter && reception?.writeStream) {
|
||||
reception.sequencedWriter.close().catch(console.error);
|
||||
reception.writeStream.close().catch(console.error);
|
||||
}
|
||||
|
||||
this.stateManager.forceReset();
|
||||
this.progressReporter.resetAllProgress();
|
||||
this.log("log", "FileReceiveOrchestrator state force reset completed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transfer statistics
|
||||
*/
|
||||
public getTransferStats() {
|
||||
return {
|
||||
stateManager: this.stateManager.getStateStats(),
|
||||
progressReporter: this.progressReporter.getProgressStats(),
|
||||
messageProcessor: this.messageProcessor.getMessageStats(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get save type information (for backward compatibility)
|
||||
*/
|
||||
public getSaveType(): Record<string, boolean> {
|
||||
return this.stateManager.saveType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending files metadata (for backward compatibility)
|
||||
*/
|
||||
public getPendingFilesMeta(): Map<string, fileMetadata> {
|
||||
return this.stateManager.getAllFileMetadata();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get folder progresses (for backward compatibility)
|
||||
*/
|
||||
public getFolderProgresses(): Record<string, any> {
|
||||
return this.stateManager.getAllFolderProgresses();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all resources
|
||||
*/
|
||||
public cleanup(): void {
|
||||
this.stateManager.gracefulCleanup();
|
||||
this.progressReporter.cleanup();
|
||||
this.messageProcessor.cleanup();
|
||||
this.log("log", "FileReceiveOrchestrator cleaned up");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import {
|
||||
WebRTCMessage,
|
||||
fileMetadata,
|
||||
StringMetadata,
|
||||
StringChunk,
|
||||
FileRequest,
|
||||
FileReceiveComplete,
|
||||
FolderReceiveComplete,
|
||||
FileHandlers,
|
||||
} from "@/types/webrtc";
|
||||
import { ReceptionStateManager } from "./ReceptionStateManager";
|
||||
import { ReceptionConfig } from "./ReceptionConfig";
|
||||
import { postLogToBackend } from "@/app/config/api";
|
||||
import WebRTC_Recipient from "../webrtc_Recipient";
|
||||
|
||||
const developmentEnv = process.env.NODE_ENV;
|
||||
|
||||
/**
|
||||
* 🚀 Message processor delegate interface
|
||||
*/
|
||||
export interface MessageProcessorDelegate {
|
||||
onFileMetaReceived?: (meta: fileMetadata) => void;
|
||||
onStringReceived?: (str: string) => void;
|
||||
log(level: "log" | "warn" | "error", message: string, context?: Record<string, any>): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 Message processor
|
||||
* Handles WebRTC message routing, processing, and communication
|
||||
*/
|
||||
export class MessageProcessor {
|
||||
private fileHandlers: FileHandlers;
|
||||
|
||||
constructor(
|
||||
private stateManager: ReceptionStateManager,
|
||||
private webrtcConnection: WebRTC_Recipient,
|
||||
private delegate: MessageProcessorDelegate
|
||||
) {
|
||||
this.fileHandlers = {
|
||||
string: this.handleReceivedStringChunk.bind(this),
|
||||
stringMetadata: this.handleStringMetadata.bind(this),
|
||||
fileMeta: this.handleFileMetadata.bind(this),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle received WebRTC message
|
||||
*/
|
||||
async handleReceivedMessage(
|
||||
data: string | ArrayBuffer | any,
|
||||
peerId: string
|
||||
): Promise<ArrayBuffer | null> {
|
||||
this.stateManager.setCurrentPeerId(peerId);
|
||||
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
const parsedData = JSON.parse(data) as WebRTCMessage;
|
||||
const handler = this.fileHandlers[parsedData.type as keyof FileHandlers];
|
||||
|
||||
if (handler) {
|
||||
await handler(parsedData as any, peerId);
|
||||
} else {
|
||||
this.delegate.log(
|
||||
"warn",
|
||||
`Handler not found for message type: ${parsedData.type}`,
|
||||
{ peerId }
|
||||
);
|
||||
}
|
||||
return null; // String messages don't return binary data
|
||||
} catch (error) {
|
||||
this.delegate.log("error", "Error parsing received JSON data", { error, peerId });
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// Return binary data for chunk processing
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file metadata message
|
||||
*/
|
||||
private handleFileMetadata(metadata: fileMetadata): void {
|
||||
const isNewMetadata = this.stateManager.addFileMetadata(metadata);
|
||||
|
||||
if (!isNewMetadata) {
|
||||
return; // Ignore if already received
|
||||
}
|
||||
|
||||
if (this.delegate.onFileMetaReceived) {
|
||||
this.delegate.onFileMetaReceived(metadata);
|
||||
} else {
|
||||
this.delegate.log(
|
||||
"error",
|
||||
"onFileMetaReceived callback not set",
|
||||
{ fileId: metadata.fileId }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle string metadata message
|
||||
*/
|
||||
private handleStringMetadata(metadata: StringMetadata): void {
|
||||
this.stateManager.startStringReception(metadata.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle received string chunk message
|
||||
*/
|
||||
private handleReceivedStringChunk(data: StringChunk): void {
|
||||
const activeStringReception = this.stateManager.getActiveStringReception();
|
||||
if (!activeStringReception) {
|
||||
this.delegate.log("warn", "Received string chunk without active reception");
|
||||
return;
|
||||
}
|
||||
|
||||
this.stateManager.updateStringReceptionChunk(data.index, data.chunk);
|
||||
|
||||
// Check if string reception is complete
|
||||
if (activeStringReception.receivedChunks === data.total) {
|
||||
const fullString = this.stateManager.completeStringReception();
|
||||
if (fullString && this.delegate.onStringReceived) {
|
||||
this.delegate.onStringReceived(fullString);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send file request message
|
||||
*/
|
||||
sendFileRequest(fileId: string, offset: number = 0): boolean {
|
||||
const peerId = this.stateManager.getCurrentPeerId();
|
||||
if (!peerId) {
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG] ERROR: Cannot send fileRequest - no peerId available!`
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const request: FileRequest = { type: "fileRequest", fileId, offset };
|
||||
const success = this.webrtcConnection.sendData(JSON.stringify(request), peerId);
|
||||
|
||||
if (success) {
|
||||
this.delegate.log("log", "Sent fileRequest", { request, peerId });
|
||||
} else {
|
||||
this.delegate.log("error", "Failed to send fileRequest", { request, peerId });
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send file receive complete message
|
||||
*/
|
||||
sendFileReceiveComplete(
|
||||
fileId: string,
|
||||
receivedSize: number,
|
||||
receivedChunks: number,
|
||||
storeUpdated: boolean
|
||||
): boolean {
|
||||
const peerId = this.stateManager.getCurrentPeerId();
|
||||
if (!peerId) {
|
||||
this.delegate.log("warn", "Cannot send file receive complete - no peer ID");
|
||||
return false;
|
||||
}
|
||||
|
||||
const completeMessage: FileReceiveComplete = {
|
||||
type: "fileReceiveComplete",
|
||||
fileId,
|
||||
receivedSize,
|
||||
receivedChunks,
|
||||
storeUpdated,
|
||||
};
|
||||
|
||||
const success = this.webrtcConnection.sendData(
|
||||
JSON.stringify(completeMessage),
|
||||
peerId
|
||||
);
|
||||
|
||||
if (success) {
|
||||
this.delegate.log("log", "Sent file receive complete", {
|
||||
fileId,
|
||||
receivedSize,
|
||||
receivedChunks,
|
||||
storeUpdated,
|
||||
});
|
||||
} else {
|
||||
this.delegate.log("error", "Failed to send file receive complete", {
|
||||
fileId,
|
||||
peerId,
|
||||
});
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send folder receive complete message
|
||||
*/
|
||||
sendFolderReceiveComplete(
|
||||
folderName: string,
|
||||
completedFileIds: string[],
|
||||
allStoreUpdated: boolean
|
||||
): boolean {
|
||||
const peerId = this.stateManager.getCurrentPeerId();
|
||||
if (!peerId) {
|
||||
this.delegate.log("warn", "Cannot send folder receive complete - no peer ID");
|
||||
return false;
|
||||
}
|
||||
|
||||
const completeMessage: FolderReceiveComplete = {
|
||||
type: "folderReceiveComplete",
|
||||
folderName,
|
||||
completedFileIds,
|
||||
allStoreUpdated,
|
||||
};
|
||||
|
||||
const success = this.webrtcConnection.sendData(
|
||||
JSON.stringify(completeMessage),
|
||||
peerId
|
||||
);
|
||||
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG] 📤 Sent folderReceiveComplete - folderName: ${folderName}, completedFiles: ${completedFileIds.length}, allStoreUpdated: ${allStoreUpdated}, success: ${success}`
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
this.delegate.log("log", "Sent folder receive complete", {
|
||||
folderName,
|
||||
completedFiles: completedFileIds.length,
|
||||
allStoreUpdated,
|
||||
});
|
||||
} else {
|
||||
this.delegate.log("error", "Failed to send folder receive complete", {
|
||||
folderName,
|
||||
peerId,
|
||||
});
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Firefox compatibility delay
|
||||
*/
|
||||
async addFirefoxDelay(): Promise<void> {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, ReceptionConfig.NETWORK_CONFIG.FIREFOX_COMPATIBILITY_DELAY)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get message processing statistics
|
||||
*/
|
||||
getMessageStats(): {
|
||||
handledMessages: number;
|
||||
lastMessageTime: number | null;
|
||||
currentPeerId: string;
|
||||
} {
|
||||
return {
|
||||
handledMessages: 0, // TODO: Implement message counting if needed
|
||||
lastMessageTime: null, // TODO: Record last message time if needed
|
||||
currentPeerId: this.stateManager.getCurrentPeerId(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connection is available
|
||||
*/
|
||||
isConnectionAvailable(): boolean {
|
||||
const peerId = this.stateManager.getCurrentPeerId();
|
||||
return !!peerId && !!this.webrtcConnection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current peer connection info
|
||||
*/
|
||||
getPeerConnectionInfo(): {
|
||||
peerId: string;
|
||||
isConnected: boolean;
|
||||
} {
|
||||
const peerId = this.stateManager.getCurrentPeerId();
|
||||
return {
|
||||
peerId,
|
||||
isConnected: this.isConnectionAvailable(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
cleanup(): void {
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend("[DEBUG] 🧹 MessageProcessor cleaned up");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { SpeedCalculator } from "@/lib/speedCalculator";
|
||||
import { ReceptionStateManager } from "./ReceptionStateManager";
|
||||
import { ReceptionConfig } from "./ReceptionConfig";
|
||||
import { postLogToBackend } from "@/app/config/api";
|
||||
|
||||
const developmentEnv = process.env.NODE_ENV;
|
||||
|
||||
/**
|
||||
* 🚀 Progress callback type
|
||||
*/
|
||||
export type ProgressCallback = (fileId: string, progress: number, speed: number) => void;
|
||||
|
||||
/**
|
||||
* 🚀 Progress statistics interface
|
||||
*/
|
||||
export interface ProgressStats {
|
||||
fileProgress: Record<string, number>;
|
||||
folderProgress: Record<string, number>;
|
||||
currentSpeed: number;
|
||||
averageSpeed: number;
|
||||
totalBytesReceived: number;
|
||||
estimatedTimeRemaining: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 Progress reporter
|
||||
* Handles progress calculation, speed tracking, and progress callback management
|
||||
*/
|
||||
export class ProgressReporter {
|
||||
private speedCalculator: SpeedCalculator;
|
||||
private progressCallback: ProgressCallback | null = null;
|
||||
|
||||
// Progress tracking
|
||||
private fileProgressMap = new Map<string, number>();
|
||||
private folderProgressMap = new Map<string, number>();
|
||||
private lastProgressUpdate = new Map<string, number>();
|
||||
|
||||
constructor(private stateManager: ReceptionStateManager) {
|
||||
this.speedCalculator = new SpeedCalculator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set progress callback
|
||||
*/
|
||||
setProgressCallback(callback: ProgressCallback): void {
|
||||
this.progressCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update file reception progress
|
||||
*/
|
||||
updateFileProgress(
|
||||
byteLength: number,
|
||||
fileId: string,
|
||||
fileSize: number
|
||||
): void {
|
||||
const peerId = this.stateManager.getCurrentPeerId();
|
||||
if (!peerId) return;
|
||||
|
||||
const activeReception = this.stateManager.getActiveFileReception();
|
||||
if (!activeReception) return;
|
||||
|
||||
// Update received size
|
||||
activeReception.receivedSize += byteLength;
|
||||
const totalReceived = activeReception.initialOffset + activeReception.receivedSize;
|
||||
|
||||
const currentFolderName = this.stateManager.getCurrentFolderName();
|
||||
|
||||
if (currentFolderName) {
|
||||
// Update folder progress
|
||||
this.updateFolderProgress(currentFolderName, byteLength, peerId);
|
||||
} else {
|
||||
// Update individual file progress
|
||||
this.speedCalculator.updateSendSpeed(peerId, totalReceived);
|
||||
const speed = this.speedCalculator.getSendSpeed(peerId);
|
||||
const progress = fileSize > 0 ? totalReceived / fileSize : 0;
|
||||
|
||||
// Store progress for statistics
|
||||
this.fileProgressMap.set(fileId, progress);
|
||||
|
||||
// Throttle progress callbacks to avoid overwhelming the UI
|
||||
const now = Date.now();
|
||||
const lastUpdate = this.lastProgressUpdate.get(fileId) || 0;
|
||||
const shouldUpdate = now - lastUpdate > 100; // Update at most every 100ms
|
||||
|
||||
if (shouldUpdate || progress >= 1) {
|
||||
this.progressCallback?.(fileId, progress, speed);
|
||||
this.lastProgressUpdate.set(fileId, now);
|
||||
}
|
||||
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_PROGRESS_LOGGING && progress >= 1) {
|
||||
postLogToBackend(
|
||||
`[DEBUG] 📈 File progress 100% - ${fileId}, speed: ${(speed / 1024 / 1024).toFixed(1)}MB/s`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update folder reception progress
|
||||
*/
|
||||
private updateFolderProgress(
|
||||
folderName: string,
|
||||
byteLength: number,
|
||||
peerId: string
|
||||
): void {
|
||||
// Update folder received size in state manager
|
||||
this.stateManager.updateFolderReceivedSize(folderName, byteLength);
|
||||
|
||||
const folderProgress = this.stateManager.getFolderProgress(folderName);
|
||||
if (!folderProgress) return;
|
||||
|
||||
this.speedCalculator.updateSendSpeed(peerId, folderProgress.receivedSize);
|
||||
const speed = this.speedCalculator.getSendSpeed(peerId);
|
||||
const progress = folderProgress.totalSize > 0
|
||||
? folderProgress.receivedSize / folderProgress.totalSize
|
||||
: 0;
|
||||
|
||||
// Store progress for statistics
|
||||
this.folderProgressMap.set(folderName, progress);
|
||||
|
||||
// Throttle folder progress callbacks
|
||||
const now = Date.now();
|
||||
const lastUpdate = this.lastProgressUpdate.get(folderName) || 0;
|
||||
const shouldUpdate = now - lastUpdate > 200; // Update folders less frequently
|
||||
|
||||
if (shouldUpdate || progress >= 1) {
|
||||
this.progressCallback?.(folderName, progress, speed);
|
||||
this.lastProgressUpdate.set(folderName, now);
|
||||
}
|
||||
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_PROGRESS_LOGGING && progress >= 1) {
|
||||
postLogToBackend(
|
||||
`[DEBUG] 📈 Folder progress 100% - ${folderName}, speed: ${(speed / 1024 / 1024).toFixed(1)}MB/s`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report file completion (100% progress)
|
||||
*/
|
||||
reportFileComplete(fileId: string): void {
|
||||
if (!this.progressCallback) return;
|
||||
|
||||
const peerId = this.stateManager.getCurrentPeerId();
|
||||
if (!peerId) return;
|
||||
|
||||
// Get final speed and report 100% progress
|
||||
const speed = this.speedCalculator.getSendSpeed(peerId);
|
||||
this.progressCallback(fileId, 1, speed);
|
||||
this.fileProgressMap.set(fileId, 1);
|
||||
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_PROGRESS_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG] ✅ File completion reported - ${fileId}, final speed: ${(speed / 1024 / 1024).toFixed(1)}MB/s`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report folder completion (100% progress)
|
||||
*/
|
||||
reportFolderComplete(folderName: string): void {
|
||||
if (!this.progressCallback) return;
|
||||
|
||||
const peerId = this.stateManager.getCurrentPeerId();
|
||||
if (!peerId) return;
|
||||
|
||||
// Get final speed and report 100% progress
|
||||
const speed = this.speedCalculator.getSendSpeed(peerId);
|
||||
this.progressCallback(folderName, 1, speed);
|
||||
this.folderProgressMap.set(folderName, 1);
|
||||
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_PROGRESS_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG] ✅ Folder completion reported - ${folderName}, final speed: ${(speed / 1024 / 1024).toFixed(1)}MB/s`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current progress for a file or folder
|
||||
*/
|
||||
getCurrentProgress(id: string): number {
|
||||
return this.fileProgressMap.get(id) || this.folderProgressMap.get(id) || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current speed for peer
|
||||
*/
|
||||
getCurrentSpeed(): number {
|
||||
const peerId = this.stateManager.getCurrentPeerId();
|
||||
return peerId ? this.speedCalculator.getSendSpeed(peerId) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed progress statistics
|
||||
*/
|
||||
getProgressStats(): ProgressStats {
|
||||
const peerId = this.stateManager.getCurrentPeerId();
|
||||
const currentSpeed = peerId ? this.speedCalculator.getSendSpeed(peerId) : 0;
|
||||
const averageSpeed = currentSpeed; // SpeedCalculator doesn't have getAverageSpeed method
|
||||
|
||||
// Calculate total bytes received
|
||||
let totalBytesReceived = 0;
|
||||
const activeReception = this.stateManager.getActiveFileReception();
|
||||
if (activeReception) {
|
||||
totalBytesReceived = activeReception.initialOffset + activeReception.receivedSize;
|
||||
}
|
||||
|
||||
// Estimate time remaining
|
||||
let estimatedTimeRemaining: number | null = null;
|
||||
if (activeReception && currentSpeed > 0) {
|
||||
const remainingBytes = activeReception.meta.size - totalBytesReceived;
|
||||
if (remainingBytes > 0) {
|
||||
estimatedTimeRemaining = remainingBytes / currentSpeed; // seconds
|
||||
}
|
||||
}
|
||||
|
||||
const fileProgress: Record<string, number> = {};
|
||||
this.fileProgressMap.forEach((progress, fileId) => {
|
||||
fileProgress[fileId] = progress;
|
||||
});
|
||||
|
||||
const folderProgress: Record<string, number> = {};
|
||||
this.folderProgressMap.forEach((progress, folderName) => {
|
||||
folderProgress[folderName] = progress;
|
||||
});
|
||||
|
||||
return {
|
||||
fileProgress,
|
||||
folderProgress,
|
||||
currentSpeed,
|
||||
averageSpeed,
|
||||
totalBytesReceived,
|
||||
estimatedTimeRemaining,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset progress for a specific file or folder
|
||||
*/
|
||||
resetProgress(id: string): void {
|
||||
this.fileProgressMap.delete(id);
|
||||
this.folderProgressMap.delete(id);
|
||||
this.lastProgressUpdate.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all progress data
|
||||
*/
|
||||
resetAllProgress(): void {
|
||||
this.fileProgressMap.clear();
|
||||
this.folderProgressMap.clear();
|
||||
this.lastProgressUpdate.clear();
|
||||
|
||||
// Reset speed calculator for current peer
|
||||
// Note: SpeedCalculator doesn't have resetSpeed method, so we create a new instance
|
||||
this.speedCalculator = new SpeedCalculator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress update frequency (for debugging)
|
||||
*/
|
||||
getUpdateFrequency(id: string): number {
|
||||
const lastUpdate = this.lastProgressUpdate.get(id);
|
||||
return lastUpdate ? Date.now() - lastUpdate : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if progress should be throttled
|
||||
*/
|
||||
shouldThrottleProgress(id: string, isFolder: boolean = false): boolean {
|
||||
const now = Date.now();
|
||||
const lastUpdate = this.lastProgressUpdate.get(id) || 0;
|
||||
const threshold = isFolder ? 200 : 100; // Folders update less frequently
|
||||
return now - lastUpdate < threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force progress update (bypass throttling)
|
||||
*/
|
||||
forceProgressUpdate(id: string, progress: number): void {
|
||||
if (!this.progressCallback) return;
|
||||
|
||||
const speed = this.getCurrentSpeed();
|
||||
this.progressCallback(id, progress, speed);
|
||||
this.lastProgressUpdate.set(id, Date.now());
|
||||
|
||||
// Update internal maps
|
||||
if (this.fileProgressMap.has(id)) {
|
||||
this.fileProgressMap.set(id, progress);
|
||||
} else if (this.folderProgressMap.has(id)) {
|
||||
this.folderProgressMap.set(id, progress);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.resetAllProgress();
|
||||
this.progressCallback = null;
|
||||
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_PROGRESS_LOGGING) {
|
||||
postLogToBackend("[DEBUG] 🧹 ProgressReporter cleaned up");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 🚀 Reception configuration management
|
||||
* Centralized configuration for file reception parameters
|
||||
*/
|
||||
|
||||
export class ReceptionConfig {
|
||||
// File size thresholds
|
||||
static readonly FILE_CONFIG = {
|
||||
LARGE_FILE_THRESHOLD: 1 * 1024 * 1024 * 1024, // 1GB - files larger than this will be saved to disk
|
||||
CHUNK_SIZE: 65536, // 64KB standard chunk size
|
||||
};
|
||||
|
||||
// Buffer management
|
||||
static readonly BUFFER_CONFIG = {
|
||||
MAX_BUFFER_SIZE: 100, // Buffer up to 100 chunks (approximately 6.4MB)
|
||||
SEQUENTIAL_FLUSH_THRESHOLD: 10, // Start flushing when this many sequential chunks are available
|
||||
};
|
||||
|
||||
// Performance and debugging
|
||||
static readonly DEBUG_CONFIG = {
|
||||
ENABLE_CHUNK_LOGGING: process.env.NODE_ENV === "development",
|
||||
ENABLE_PROGRESS_LOGGING: process.env.NODE_ENV === "development",
|
||||
PROGRESS_LOG_INTERVAL: 500, // Log progress every N chunks
|
||||
COMPLETION_CHECK_INTERVAL: 100, // Check completion every N ms
|
||||
};
|
||||
|
||||
// Network and timing
|
||||
static readonly NETWORK_CONFIG = {
|
||||
FIREFOX_COMPATIBILITY_DELAY: 10, // ms delay for Firefox compatibility
|
||||
FINALIZATION_TIMEOUT: 30020, // 30s timeout for file finalization
|
||||
GRACEFUL_SHUTDOWN_TIMEOUT: 5000, // 5s timeout for graceful shutdown
|
||||
};
|
||||
|
||||
// Validation thresholds
|
||||
static readonly VALIDATION_CONFIG = {
|
||||
MAX_SIZE_DIFFERENCE_BYTES: 1024, // Allow up to 1KB size difference for validation
|
||||
MIN_PACKET_SIZE: 4, // Minimum embedded packet size (4 bytes for length header)
|
||||
};
|
||||
|
||||
/**
|
||||
* Get chunk index from file offset
|
||||
*/
|
||||
static getChunkIndexFromOffset(offset: number): number {
|
||||
return Math.floor(offset / this.FILE_CONFIG.CHUNK_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file offset from chunk index
|
||||
*/
|
||||
static getOffsetFromChunkIndex(chunkIndex: number): number {
|
||||
return chunkIndex * this.FILE_CONFIG.CHUNK_SIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate expected chunks count for file size and offset
|
||||
*/
|
||||
static calculateExpectedChunks(
|
||||
fileSize: number,
|
||||
startOffset: number = 0
|
||||
): number {
|
||||
return Math.ceil((fileSize - startOffset) / this.FILE_CONFIG.CHUNK_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total chunks in file
|
||||
*/
|
||||
static calculateTotalChunks(fileSize: number): number {
|
||||
return Math.ceil(fileSize / this.FILE_CONFIG.CHUNK_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file should be saved to disk
|
||||
*/
|
||||
static shouldSaveToDisk(
|
||||
fileSize: number,
|
||||
hasSaveDirectory: boolean
|
||||
): boolean {
|
||||
return (
|
||||
hasSaveDirectory || fileSize >= this.FILE_CONFIG.LARGE_FILE_THRESHOLD
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import {
|
||||
fileMetadata,
|
||||
FolderProgress,
|
||||
CurrentString,
|
||||
CustomFile,
|
||||
} from "@/types/webrtc";
|
||||
|
||||
/**
|
||||
* 🚀 Active file reception state interface
|
||||
*/
|
||||
export interface ActiveFileReception {
|
||||
meta: fileMetadata;
|
||||
chunks: (ArrayBuffer | null)[];
|
||||
receivedSize: number;
|
||||
initialOffset: number;
|
||||
fileHandle: FileSystemFileHandle | null;
|
||||
writeStream: FileSystemWritableFileStream | null;
|
||||
sequencedWriter: any | null; // Will be typed properly when StreamingFileWriter is implemented
|
||||
completionNotifier: {
|
||||
resolve: () => void;
|
||||
reject: (reason?: any) => void;
|
||||
};
|
||||
receivedChunksCount: number;
|
||||
expectedChunksCount: number;
|
||||
chunkSequenceMap: Map<number, boolean>;
|
||||
isFinalized?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 Reception state management
|
||||
* Centrally manages all file reception state data
|
||||
*/
|
||||
export class ReceptionStateManager {
|
||||
// File metadata management
|
||||
private pendingFilesMeta = new Map<string, fileMetadata>();
|
||||
|
||||
// Folder progress tracking
|
||||
private folderProgresses: Record<string, FolderProgress> = {};
|
||||
|
||||
// Save type configuration (fileId/folderName -> isSavedToDisk)
|
||||
public saveType: Record<string, boolean> = {};
|
||||
|
||||
// Active transfer states
|
||||
private activeFileReception: ActiveFileReception | null = null;
|
||||
private activeStringReception: CurrentString | null = null;
|
||||
private currentFolderName: string | null = null;
|
||||
|
||||
// Peer information
|
||||
private currentPeerId: string = "";
|
||||
private saveDirectory: FileSystemDirectoryHandle | null = null;
|
||||
|
||||
// ===== File Metadata Management =====
|
||||
|
||||
/**
|
||||
* Add file metadata
|
||||
*/
|
||||
public addFileMetadata(metadata: fileMetadata): boolean {
|
||||
if (this.pendingFilesMeta.has(metadata.fileId)) {
|
||||
return false; // Already exists
|
||||
}
|
||||
|
||||
this.pendingFilesMeta.set(metadata.fileId, metadata);
|
||||
|
||||
// Update folder progress if this file belongs to a folder
|
||||
if (metadata.folderName) {
|
||||
this.addFileToFolder(metadata.folderName, metadata.fileId, metadata.size);
|
||||
}
|
||||
|
||||
return true; // New metadata added
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file metadata by ID
|
||||
*/
|
||||
public getFileMetadata(fileId: string): fileMetadata | undefined {
|
||||
return this.pendingFilesMeta.get(fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pending file metadata
|
||||
*/
|
||||
public getAllFileMetadata(): Map<string, fileMetadata> {
|
||||
return new Map(this.pendingFilesMeta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove file metadata
|
||||
*/
|
||||
public removeFileMetadata(fileId: string): void {
|
||||
this.pendingFilesMeta.delete(fileId);
|
||||
}
|
||||
|
||||
// ===== Folder Progress Management =====
|
||||
|
||||
/**
|
||||
* Add file to folder progress tracking
|
||||
*/
|
||||
private addFileToFolder(folderName: string, fileId: string, fileSize: number): void {
|
||||
if (!this.folderProgresses[folderName]) {
|
||||
this.folderProgresses[folderName] = {
|
||||
totalSize: 0,
|
||||
receivedSize: 0,
|
||||
fileIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const folderProgress = this.folderProgresses[folderName];
|
||||
if (!folderProgress.fileIds.includes(fileId)) {
|
||||
folderProgress.fileIds.push(fileId);
|
||||
folderProgress.totalSize += fileSize;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get folder progress
|
||||
*/
|
||||
public getFolderProgress(folderName: string): FolderProgress | undefined {
|
||||
return this.folderProgresses[folderName];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update folder received size
|
||||
*/
|
||||
public updateFolderReceivedSize(folderName: string, additionalBytes: number): void {
|
||||
const folderProgress = this.folderProgresses[folderName];
|
||||
if (folderProgress) {
|
||||
folderProgress.receivedSize += additionalBytes;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set folder received size (for resume scenarios)
|
||||
*/
|
||||
public setFolderReceivedSize(folderName: string, totalReceivedSize: number): void {
|
||||
const folderProgress = this.folderProgresses[folderName];
|
||||
if (folderProgress) {
|
||||
folderProgress.receivedSize = totalReceivedSize;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all folder progresses
|
||||
*/
|
||||
public getAllFolderProgresses(): Record<string, FolderProgress> {
|
||||
return { ...this.folderProgresses };
|
||||
}
|
||||
|
||||
// ===== Active File Reception Management =====
|
||||
|
||||
/**
|
||||
* Start active file reception
|
||||
*/
|
||||
public startFileReception(
|
||||
meta: fileMetadata,
|
||||
expectedChunksCount: number,
|
||||
initialOffset: number = 0
|
||||
): Promise<void> {
|
||||
if (this.activeFileReception) {
|
||||
throw new Error("Another file reception is already in progress");
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.activeFileReception = {
|
||||
meta,
|
||||
chunks: new Array(expectedChunksCount).fill(null),
|
||||
receivedSize: 0,
|
||||
initialOffset,
|
||||
fileHandle: null,
|
||||
writeStream: null,
|
||||
sequencedWriter: null,
|
||||
completionNotifier: { resolve, reject },
|
||||
receivedChunksCount: 0,
|
||||
expectedChunksCount,
|
||||
chunkSequenceMap: new Map<number, boolean>(),
|
||||
isFinalized: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active file reception
|
||||
*/
|
||||
public getActiveFileReception(): ActiveFileReception | null {
|
||||
return this.activeFileReception;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update active file reception
|
||||
*/
|
||||
public updateActiveFileReception(updates: Partial<ActiveFileReception>): void {
|
||||
if (this.activeFileReception) {
|
||||
Object.assign(this.activeFileReception, updates);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete active file reception
|
||||
*/
|
||||
public completeFileReception(): void {
|
||||
if (this.activeFileReception?.completionNotifier) {
|
||||
this.activeFileReception.completionNotifier.resolve();
|
||||
}
|
||||
this.activeFileReception = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail active file reception
|
||||
*/
|
||||
public failFileReception(reason: any): void {
|
||||
if (this.activeFileReception?.completionNotifier) {
|
||||
this.activeFileReception.completionNotifier.reject(reason);
|
||||
}
|
||||
this.activeFileReception = null;
|
||||
}
|
||||
|
||||
// ===== String Reception Management =====
|
||||
|
||||
/**
|
||||
* Start string reception
|
||||
*/
|
||||
public startStringReception(length: number): void {
|
||||
this.activeStringReception = {
|
||||
length,
|
||||
chunks: [],
|
||||
receivedChunks: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active string reception
|
||||
*/
|
||||
public getActiveStringReception(): CurrentString | null {
|
||||
return this.activeStringReception;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update string reception chunk
|
||||
*/
|
||||
public updateStringReceptionChunk(index: number, chunk: string): void {
|
||||
if (this.activeStringReception) {
|
||||
this.activeStringReception.chunks[index] = chunk;
|
||||
this.activeStringReception.receivedChunks++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete string reception
|
||||
*/
|
||||
public completeStringReception(): string | null {
|
||||
if (!this.activeStringReception) return null;
|
||||
|
||||
const fullString = this.activeStringReception.chunks.join("");
|
||||
this.activeStringReception = null;
|
||||
return fullString;
|
||||
}
|
||||
|
||||
// ===== Current Context Management =====
|
||||
|
||||
/**
|
||||
* Set current folder name
|
||||
*/
|
||||
public setCurrentFolderName(folderName: string | null): void {
|
||||
this.currentFolderName = folderName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current folder name
|
||||
*/
|
||||
public getCurrentFolderName(): string | null {
|
||||
return this.currentFolderName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set current peer ID
|
||||
*/
|
||||
public setCurrentPeerId(peerId: string): void {
|
||||
this.currentPeerId = peerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current peer ID
|
||||
*/
|
||||
public getCurrentPeerId(): string {
|
||||
return this.currentPeerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set save directory
|
||||
*/
|
||||
public setSaveDirectory(directory: FileSystemDirectoryHandle | null): void {
|
||||
this.saveDirectory = directory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get save directory
|
||||
*/
|
||||
public getSaveDirectory(): FileSystemDirectoryHandle | null {
|
||||
return this.saveDirectory;
|
||||
}
|
||||
|
||||
// ===== Save Type Management =====
|
||||
|
||||
/**
|
||||
* Set save type for file or folder
|
||||
*/
|
||||
public setSaveType(id: string, saveToDisk: boolean): void {
|
||||
this.saveType[id] = saveToDisk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get save type for file or folder
|
||||
*/
|
||||
public getSaveType(id: string): boolean {
|
||||
return this.saveType[id] || false;
|
||||
}
|
||||
|
||||
// ===== State Reset and Cleanup =====
|
||||
|
||||
/**
|
||||
* Force reset all states (for reconnection scenarios)
|
||||
*/
|
||||
public forceReset(): void {
|
||||
this.pendingFilesMeta.clear();
|
||||
this.folderProgresses = {};
|
||||
this.saveType = {};
|
||||
this.activeFileReception = null;
|
||||
this.activeStringReception = null;
|
||||
this.currentFolderName = null;
|
||||
this.currentPeerId = "";
|
||||
// Note: saveDirectory is preserved
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful cleanup (preserve some state for potential resume)
|
||||
*/
|
||||
public gracefulCleanup(): void {
|
||||
this.activeFileReception = null;
|
||||
this.activeStringReception = null;
|
||||
this.currentFolderName = null;
|
||||
// Note: preserve pendingFilesMeta, folderProgresses, saveType for potential resume
|
||||
}
|
||||
|
||||
/**
|
||||
* Get state statistics (for debugging)
|
||||
*/
|
||||
public getStateStats() {
|
||||
return {
|
||||
pendingFilesCount: this.pendingFilesMeta.size,
|
||||
folderCount: Object.keys(this.folderProgresses).length,
|
||||
hasActiveFileReception: !!this.activeFileReception,
|
||||
hasActiveStringReception: !!this.activeStringReception,
|
||||
currentFolderName: this.currentFolderName,
|
||||
currentPeerId: this.currentPeerId,
|
||||
hasSaveDirectory: !!this.saveDirectory,
|
||||
saveTypeCount: Object.keys(this.saveType).length,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
import { ReceptionConfig } from "./ReceptionConfig";
|
||||
import { postLogToBackend } from "@/app/config/api";
|
||||
|
||||
const developmentEnv = process.env.NODE_ENV;
|
||||
|
||||
/**
|
||||
* 🚀 Strict Sequential Buffering Writer - Optimizes large file disk I/O performance
|
||||
*/
|
||||
export class SequencedDiskWriter {
|
||||
private writeQueue = new Map<number, ArrayBuffer>();
|
||||
private nextWriteIndex = 0;
|
||||
private readonly maxBufferSize: number;
|
||||
private readonly stream: FileSystemWritableFileStream;
|
||||
private totalWritten = 0;
|
||||
|
||||
constructor(stream: FileSystemWritableFileStream, startIndex: number = 0) {
|
||||
this.stream = stream;
|
||||
this.nextWriteIndex = startIndex;
|
||||
this.maxBufferSize = ReceptionConfig.BUFFER_CONFIG.MAX_BUFFER_SIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a chunk, automatically managing order and buffering
|
||||
*/
|
||||
async writeChunk(chunkIndex: number, chunk: ArrayBuffer): Promise<void> {
|
||||
// Debug writeChunk calls
|
||||
if (
|
||||
ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING &&
|
||||
(chunkIndex <= 5 || chunkIndex === this.nextWriteIndex)
|
||||
) {
|
||||
postLogToBackend(
|
||||
`[DEBUG-RESUME] 🎯 WriteChunk called - received:${chunkIndex}, expected:${
|
||||
this.nextWriteIndex
|
||||
}, match:${chunkIndex === this.nextWriteIndex}`
|
||||
);
|
||||
}
|
||||
|
||||
// 1. If it is the expected next chunk, write immediately
|
||||
if (chunkIndex === this.nextWriteIndex) {
|
||||
await this.flushSequentialChunks(chunk);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. If it's a future chunk, buffer it
|
||||
if (chunkIndex > this.nextWriteIndex) {
|
||||
if (this.writeQueue.size < this.maxBufferSize) {
|
||||
this.writeQueue.set(chunkIndex, chunk);
|
||||
} else {
|
||||
// Buffer full, forcing processing of the earliest chunk to free up space
|
||||
await this.forceFlushOldest();
|
||||
this.writeQueue.set(chunkIndex, chunk);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. If the chunk is expired, log a warning but ignore (already written)
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG] ⚠️ DUPLICATE chunk #${chunkIndex} ignored (already written #${this.nextWriteIndex})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write current chunk and attempt to sequentially write subsequent chunks
|
||||
*/
|
||||
private async flushSequentialChunks(firstChunk: ArrayBuffer): Promise<void> {
|
||||
let flushCount = 0;
|
||||
|
||||
try {
|
||||
// Write current chunk
|
||||
await this.stream.write(firstChunk);
|
||||
this.totalWritten += firstChunk.byteLength;
|
||||
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG] ✓ DISK_WRITE chunk #${this.nextWriteIndex} - ${firstChunk.byteLength} bytes, total: ${this.totalWritten}`
|
||||
);
|
||||
}
|
||||
|
||||
this.nextWriteIndex++;
|
||||
|
||||
// Try to sequentially write chunks from buffer
|
||||
while (this.writeQueue.has(this.nextWriteIndex)) {
|
||||
const chunk = this.writeQueue.get(this.nextWriteIndex)!;
|
||||
await this.stream.write(chunk);
|
||||
this.totalWritten += chunk.byteLength;
|
||||
this.writeQueue.delete(this.nextWriteIndex);
|
||||
|
||||
flushCount++;
|
||||
this.nextWriteIndex++;
|
||||
}
|
||||
} catch (error) {
|
||||
// Defensive handling: If stream is closed, silently ignore
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
if (
|
||||
errorMessage.includes("closing writable stream") ||
|
||||
errorMessage.includes("stream is closed")
|
||||
) {
|
||||
console.log(
|
||||
`[SequencedDiskWriter] Stream closed during write - ignoring remaining chunks`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Re-throw other types of errors
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (flushCount > 0 && ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG] 🔥 SEQUENTIAL_FLUSH ${flushCount} chunks, now at #${this.nextWriteIndex}, queue: ${this.writeQueue.size}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next expected write index
|
||||
*/
|
||||
get expectedIndex(): number {
|
||||
return this.nextWriteIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force flush the earliest chunk to release buffer space
|
||||
*/
|
||||
private async forceFlushOldest(): Promise<void> {
|
||||
try {
|
||||
if (this.writeQueue.size === 0) return;
|
||||
|
||||
const oldestIndex = Math.min(...Array.from(this.writeQueue.keys()));
|
||||
const chunk = this.writeQueue.get(oldestIndex)!;
|
||||
|
||||
// Use seek to write at the correct position (fallback handling)
|
||||
const fileOffset = ReceptionConfig.getOffsetFromChunkIndex(oldestIndex);
|
||||
await this.stream.seek(fileOffset);
|
||||
await this.stream.write(chunk);
|
||||
this.writeQueue.delete(oldestIndex);
|
||||
|
||||
// Return to current position
|
||||
const currentOffset = ReceptionConfig.getOffsetFromChunkIndex(this.nextWriteIndex);
|
||||
await this.stream.seek(currentOffset);
|
||||
} catch (error) {
|
||||
// Defensive handling: If stream is closed, silently ignore
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
if (
|
||||
errorMessage.includes("closing writable stream") ||
|
||||
errorMessage.includes("stream is closed")
|
||||
) {
|
||||
console.log(
|
||||
`[SequencedDiskWriter] Stream closed during write - ignoring remaining chunks`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Re-throw other types of errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get buffer status
|
||||
*/
|
||||
getBufferStatus(): {
|
||||
queueSize: number;
|
||||
nextIndex: number;
|
||||
totalWritten: number;
|
||||
} {
|
||||
return {
|
||||
queueSize: this.writeQueue.size,
|
||||
nextIndex: this.nextWriteIndex,
|
||||
totalWritten: this.totalWritten,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Close and clean up resources
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
try {
|
||||
// 🔧 修复:确保以正确的WriteParams格式写入剩余chunks
|
||||
const remainingIndexes = Array.from(this.writeQueue.keys()).sort(
|
||||
(a, b) => a - b
|
||||
);
|
||||
|
||||
if (remainingIndexes.length > 0) {
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG-FINALIZE] 💾 Flushing ${remainingIndexes.length} remaining chunks: [${remainingIndexes.join(',')}]`
|
||||
);
|
||||
}
|
||||
|
||||
for (const chunkIndex of remainingIndexes) {
|
||||
const chunk = this.writeQueue.get(chunkIndex)!;
|
||||
const fileOffset = ReceptionConfig.getOffsetFromChunkIndex(chunkIndex);
|
||||
|
||||
// 🔧 修复:使用正确的WriteParams格式
|
||||
await this.stream.seek(fileOffset);
|
||||
|
||||
// 确保chunk是有效的ArrayBuffer
|
||||
if (!(chunk instanceof ArrayBuffer) || chunk.byteLength === 0) {
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG-FINALIZE] ⚠️ Skipping invalid chunk #${chunkIndex}: ${Object.prototype.toString.call(chunk)}, size: ${chunk.byteLength}`
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 使用标准WriteParams格式写入
|
||||
await this.stream.write({
|
||||
type: "write",
|
||||
data: chunk
|
||||
});
|
||||
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG-FINALIZE] ✅ FINAL_FLUSH chunk #${chunkIndex} (${chunk.byteLength} bytes)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Enhanced error handling with specific error types
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG-FINALIZE] ❌ Error during final flush: ${errorMessage}`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
errorMessage.includes("closing writable stream") ||
|
||||
errorMessage.includes("stream is closed") ||
|
||||
errorMessage.includes("The stream is not in a state that permits this operation")
|
||||
) {
|
||||
console.log(
|
||||
`[SequencedDiskWriter] Stream closed during final flush - completing gracefully`
|
||||
);
|
||||
} else {
|
||||
console.warn(`[SequencedDiskWriter] Unexpected error during final flush:`, errorMessage);
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
// 无论如何都要清理队列
|
||||
this.writeQueue.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 Streaming file writer
|
||||
* Manages disk file creation, directory structure, and streaming writes
|
||||
*/
|
||||
export class StreamingFileWriter {
|
||||
private saveDirectory: FileSystemDirectoryHandle | null = null;
|
||||
|
||||
constructor(saveDirectory?: FileSystemDirectoryHandle) {
|
||||
this.saveDirectory = saveDirectory || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set save directory
|
||||
*/
|
||||
setSaveDirectory(directory: FileSystemDirectoryHandle): void {
|
||||
this.saveDirectory = directory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create disk write stream for a file
|
||||
*/
|
||||
async createWriteStream(
|
||||
fileName: string,
|
||||
fullPath: string,
|
||||
offset: number = 0
|
||||
): Promise<{
|
||||
fileHandle: FileSystemFileHandle;
|
||||
writeStream: FileSystemWritableFileStream;
|
||||
sequencedWriter: SequencedDiskWriter;
|
||||
}> {
|
||||
if (!this.saveDirectory) {
|
||||
throw new Error("Save directory not set");
|
||||
}
|
||||
|
||||
try {
|
||||
const folderHandle = await this.createFolderStructure(fullPath);
|
||||
const fileHandle = await folderHandle.getFileHandle(fileName, {
|
||||
create: true,
|
||||
});
|
||||
|
||||
// Use keepExistingData: true to append
|
||||
const writeStream = await fileHandle.createWritable({
|
||||
keepExistingData: true,
|
||||
});
|
||||
|
||||
// Seek to the offset to start writing from there
|
||||
await writeStream.seek(offset);
|
||||
|
||||
// Create strictly sequential write manager
|
||||
const startChunkIndex = ReceptionConfig.getChunkIndexFromOffset(offset);
|
||||
const sequencedWriter = new SequencedDiskWriter(writeStream, startChunkIndex);
|
||||
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG] 📢 SEQUENCED_WRITER created - startIndex: ${startChunkIndex}, offset: ${offset}`
|
||||
);
|
||||
postLogToBackend(
|
||||
`[DEBUG-RESUME] 🎯 SequencedWriter expects - startIndex:${startChunkIndex}, offset:${offset}, calculatedFrom:${offset}/65536`
|
||||
);
|
||||
}
|
||||
|
||||
return { fileHandle, writeStream, sequencedWriter };
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to create file on disk: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if partial file exists and get its size
|
||||
*/
|
||||
async getPartialFileSize(fileName: string, fullPath: string): Promise<number> {
|
||||
if (!this.saveDirectory) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const folderHandle = await this.createFolderStructure(fullPath);
|
||||
const fileHandle = await folderHandle.getFileHandle(fileName, {
|
||||
create: false,
|
||||
});
|
||||
const file = await fileHandle.getFile();
|
||||
return file.size;
|
||||
} catch {
|
||||
// File does not exist
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create folder structure based on full path
|
||||
*/
|
||||
private async createFolderStructure(
|
||||
fullPath: string
|
||||
): Promise<FileSystemDirectoryHandle> {
|
||||
if (!this.saveDirectory) {
|
||||
throw new Error("Save directory not set");
|
||||
}
|
||||
|
||||
const parts = fullPath.split("/");
|
||||
parts.pop(); // Remove filename
|
||||
|
||||
let currentDir = this.saveDirectory;
|
||||
for (const part of parts) {
|
||||
if (part) {
|
||||
currentDir = await currentDir.getDirectoryHandle(part, {
|
||||
create: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return currentDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize file write and close streams
|
||||
*/
|
||||
async finalizeWrite(
|
||||
sequencedWriter: SequencedDiskWriter,
|
||||
writeStream: FileSystemWritableFileStream,
|
||||
fileName: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG-FINALIZE] 🚀 Starting finalization for ${fileName}`
|
||||
);
|
||||
}
|
||||
|
||||
// First close the strict sequential writing manager (flush all buffers)
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(`[DEBUG-FINALIZE] Closing SequencedWriter...`);
|
||||
}
|
||||
await sequencedWriter.close();
|
||||
const status = sequencedWriter.getBufferStatus();
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG-FINALIZE] 💾 SEQUENCED_WRITER closed - totalWritten: ${status.totalWritten}, finalQueue: ${status.queueSize}`
|
||||
);
|
||||
}
|
||||
|
||||
// Then close the file stream
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG-FINALIZE] About to close writeStream for ${fileName}`
|
||||
);
|
||||
}
|
||||
await writeStream.close();
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(`[DEBUG-FINALIZE] ✅ WriteStream closed successfully`);
|
||||
}
|
||||
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG-FINALIZE] ✅ LARGE_FILE finalized successfully - ${fileName}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (ReceptionConfig.DEBUG_CONFIG.ENABLE_CHUNK_LOGGING) {
|
||||
postLogToBackend(
|
||||
`[DEBUG-FINALIZE] ❌ Error during finalization: ${error}`
|
||||
);
|
||||
}
|
||||
throw new Error(`Error finalizing large file: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if save directory is available
|
||||
*/
|
||||
hasSaveDirectory(): boolean {
|
||||
return !!this.saveDirectory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get save directory
|
||||
*/
|
||||
getSaveDirectory(): FileSystemDirectoryHandle | null {
|
||||
return this.saveDirectory;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 🚀 File receive module unified export
|
||||
* Provides modular file reception services
|
||||
*/
|
||||
|
||||
// Configuration management
|
||||
export { ReceptionConfig } from "./ReceptionConfig";
|
||||
|
||||
// State management
|
||||
export { ReceptionStateManager } from "./ReceptionStateManager";
|
||||
export type { ActiveFileReception } from "./ReceptionStateManager";
|
||||
|
||||
// Data processing
|
||||
export { ChunkProcessor } from "./ChunkProcessor";
|
||||
export type { ChunkProcessingResult } from "./ChunkProcessor";
|
||||
|
||||
// File writing
|
||||
export { StreamingFileWriter, SequencedDiskWriter } from "./StreamingFileWriter";
|
||||
|
||||
// File assembly
|
||||
export { FileAssembler } from "./FileAssembler";
|
||||
export type { FileAssemblyResult } from "./FileAssembler";
|
||||
|
||||
// Message processing
|
||||
export { MessageProcessor } from "./MessageProcessor";
|
||||
export type { MessageProcessorDelegate } from "./MessageProcessor";
|
||||
|
||||
// Progress reporting
|
||||
export { ProgressReporter } from "./ProgressReporter";
|
||||
export type { ProgressCallback, ProgressStats } from "./ProgressReporter";
|
||||
|
||||
// Main orchestrator
|
||||
export { FileReceiveOrchestrator } from "./FileReceiveOrchestrator";
|
||||
|
||||
/**
|
||||
* 🎯 Convenience creation function - Quick initialization of file receive services
|
||||
*/
|
||||
import WebRTC_Recipient from "../webrtc_Recipient";
|
||||
import { FileReceiveOrchestrator } from "./FileReceiveOrchestrator";
|
||||
|
||||
export function createFileReceiveService(
|
||||
webrtcConnection: WebRTC_Recipient
|
||||
): FileReceiveOrchestrator {
|
||||
return new FileReceiveOrchestrator(webrtcConnection);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ export const trackReferrer = async () => {
|
||||
// Get URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
let ref = urlParams.get("ref");
|
||||
if (process.env.NEXT_PUBLIC_development === "false") {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
ref = urlParams.get("ref") || "noRef"; // Production environment, count daily active users, record as noRef if there is no ref
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
import { generateFileId } from "@/lib/fileUtils";
|
||||
import {
|
||||
CustomFile,
|
||||
fileMetadata,
|
||||
WebRTCMessage,
|
||||
FileRequest,
|
||||
EmbeddedChunkMeta,
|
||||
} from "@/types/webrtc";
|
||||
import { StateManager } from "./StateManager";
|
||||
import { MessageHandler, MessageHandlerDelegate } from "./MessageHandler";
|
||||
import { NetworkTransmitter } from "./NetworkTransmitter";
|
||||
import { ProgressTracker, ProgressCallback } from "./ProgressTracker";
|
||||
import { StreamingFileReader } from "./StreamingFileReader";
|
||||
import { TransferConfig } from "./TransferConfig";
|
||||
import WebRTC_Initiator from "../webrtc_Initiator";
|
||||
import { postLogToBackend } from "@/app/config/api";
|
||||
const developmentEnv = process.env.NODE_ENV;
|
||||
/**
|
||||
* 🚀 File transfer orchestrator
|
||||
* Integrates all components to provide unified file transfer services
|
||||
*/
|
||||
export class FileTransferOrchestrator implements MessageHandlerDelegate {
|
||||
private stateManager: StateManager;
|
||||
private messageHandler: MessageHandler;
|
||||
private networkTransmitter: NetworkTransmitter;
|
||||
private progressTracker: ProgressTracker;
|
||||
|
||||
constructor(private webrtcConnection: WebRTC_Initiator) {
|
||||
// Initialize all components
|
||||
this.stateManager = new StateManager();
|
||||
this.networkTransmitter = new NetworkTransmitter(
|
||||
webrtcConnection,
|
||||
this.stateManager
|
||||
);
|
||||
this.progressTracker = new ProgressTracker(this.stateManager);
|
||||
this.messageHandler = new MessageHandler(this.stateManager, this);
|
||||
|
||||
// Set up data handler
|
||||
this.setupDataHandler();
|
||||
|
||||
this.log("log", "FileTransferOrchestrator initialized");
|
||||
}
|
||||
/**
|
||||
* 🎯 Send file metadata
|
||||
*/
|
||||
public sendFileMeta(files: CustomFile[], peerId?: string): void {
|
||||
// Record file sizes belonging to folders for progress calculation
|
||||
files.forEach((file) => {
|
||||
if (file.folderName) {
|
||||
const fileId = generateFileId(file);
|
||||
this.stateManager.addFileToFolder(file.folderName, fileId, file.size);
|
||||
}
|
||||
});
|
||||
|
||||
// Loop to send metadata for all files
|
||||
const peers = peerId
|
||||
? [peerId]
|
||||
: Array.from(this.webrtcConnection.peerConnections.keys());
|
||||
|
||||
peers.forEach((pId) => {
|
||||
files.forEach((file) => {
|
||||
const fileId = generateFileId(file);
|
||||
this.stateManager.addPendingFile(fileId, file);
|
||||
|
||||
const fileMeta = this.getFileMeta(file);
|
||||
const metaDataString = JSON.stringify(fileMeta);
|
||||
|
||||
const sendResult = this.webrtcConnection.sendData(metaDataString, pId);
|
||||
if (!sendResult) {
|
||||
this.fireError("Failed to send file metadata", {
|
||||
fileMeta,
|
||||
peerId: pId,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 Send string content
|
||||
*/
|
||||
public async sendString(content: string, peerId: string): Promise<void> {
|
||||
const chunkSize = 65000;
|
||||
const chunks: string[] = [];
|
||||
|
||||
for (let i = 0; i < content.length; i += chunkSize) {
|
||||
chunks.push(content.slice(i, i + chunkSize));
|
||||
}
|
||||
|
||||
// First send metadata
|
||||
await this.networkTransmitter.sendWithBackpressure(
|
||||
JSON.stringify({
|
||||
type: "stringMetadata",
|
||||
length: content.length,
|
||||
}),
|
||||
peerId
|
||||
);
|
||||
|
||||
// Send chunks one by one using backpressure control
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const data = JSON.stringify({
|
||||
type: "string",
|
||||
chunk: chunks[i],
|
||||
index: i,
|
||||
total: chunks.length,
|
||||
});
|
||||
await this.networkTransmitter.sendWithBackpressure(data, peerId);
|
||||
}
|
||||
|
||||
this.log(
|
||||
"log",
|
||||
`String sent successfully - length: ${content.length}, chunks: ${chunks.length}`,
|
||||
{ peerId }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 Set progress callback
|
||||
*/
|
||||
public setProgressCallback(callback: ProgressCallback, peerId: string): void {
|
||||
this.progressTracker.setProgressCallback(callback, peerId);
|
||||
}
|
||||
|
||||
// ===== MessageHandlerDelegate Implementation =====
|
||||
|
||||
/**
|
||||
* 📄 Handle file request (delegated from MessageHandler)
|
||||
*/
|
||||
async handleFileRequest(request: FileRequest, peerId: string): Promise<void> {
|
||||
const file = this.stateManager.getPendingFile(request.fileId);
|
||||
const offset = request.offset || 0;
|
||||
|
||||
if (!file) {
|
||||
this.fireError(`File not found for request`, {
|
||||
fileId: request.fileId,
|
||||
peerId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendSingleFile(file, peerId, offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* 📝 Logging (delegated from MessageHandler)
|
||||
*/
|
||||
public log(
|
||||
level: "log" | "warn" | "error",
|
||||
message: string,
|
||||
context?: Record<string, any>
|
||||
): void {
|
||||
const prefix = `[FileTransferOrchestrator]`;
|
||||
console[level](prefix, message, context || "");
|
||||
}
|
||||
|
||||
// ===== Internal Orchestration Methods =====
|
||||
|
||||
/**
|
||||
* 🎯 Send single file
|
||||
*/
|
||||
private async sendSingleFile(
|
||||
file: CustomFile,
|
||||
peerId: string,
|
||||
offset: number = 0
|
||||
): Promise<void> {
|
||||
const fileId = generateFileId(file);
|
||||
const peerState = this.stateManager.getPeerState(peerId);
|
||||
if (peerState.isSending) {
|
||||
this.log("warn", `Already sending file to peer ${peerId}`, { fileId });
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize sending state
|
||||
this.stateManager.updatePeerState(peerId, {
|
||||
isSending: true,
|
||||
currentFolderName: file.folderName,
|
||||
readOffset: offset,
|
||||
bufferQueue: [],
|
||||
isReading: false,
|
||||
});
|
||||
// Initialize progress statistics
|
||||
const currentSent = this.stateManager.getFileBytesSent(peerId, fileId);
|
||||
this.stateManager.updateFileBytesSent(peerId, fileId, offset - currentSent);
|
||||
|
||||
try {
|
||||
await this.processSendQueue(file, peerId);
|
||||
await this.waitForTransferComplete(peerId);
|
||||
} catch (error: any) {
|
||||
this.fireError(`Error sending file ${file.name}: ${error.message}`, {
|
||||
fileId,
|
||||
peerId,
|
||||
});
|
||||
this.abortFileSend(fileId, peerId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 Process send queue - Using StreamingFileReader
|
||||
*/
|
||||
private async processSendQueue(
|
||||
file: CustomFile,
|
||||
peerId: string
|
||||
): Promise<void> {
|
||||
const fileId = generateFileId(file);
|
||||
const peerState = this.stateManager.getPeerState(peerId);
|
||||
const transferStartTime = performance.now();
|
||||
|
||||
// 🔧 Fix: Record initial offset at the start of transmission, used for subsequent statistics calculation
|
||||
const initialReadOffset = peerState.readOffset || 0;
|
||||
|
||||
// 1. Initialize streaming file reader
|
||||
const streamReader = new StreamingFileReader(file, initialReadOffset);
|
||||
|
||||
if (developmentEnv === "development") {
|
||||
postLogToBackend(
|
||||
`[DEBUG] 🚀 Starting transfer - file: ${file.name}, size: ${(
|
||||
file.size /
|
||||
1024 /
|
||||
1024
|
||||
).toFixed(1)}MB`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
let totalBytesSent = 0;
|
||||
let networkChunkIndex = 0;
|
||||
let totalReadTime = 0;
|
||||
let totalSendTime = 0;
|
||||
let totalProgressTime = 0;
|
||||
let lastProgressTime = performance.now();
|
||||
|
||||
// 2. Stream processing: Get 64KB network chunks one by one and send
|
||||
while (peerState.isSending) {
|
||||
// Get next network chunk
|
||||
const readStartTime = performance.now();
|
||||
const chunkInfo = await streamReader.getNextNetworkChunk();
|
||||
const readTime = performance.now() - readStartTime;
|
||||
totalReadTime += readTime;
|
||||
|
||||
// Check if completed
|
||||
if (chunkInfo.chunk === null) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Build embedded metadata
|
||||
const embeddedMeta: EmbeddedChunkMeta = {
|
||||
chunkIndex: chunkInfo.chunkIndex,
|
||||
totalChunks: chunkInfo.totalChunks,
|
||||
chunkSize: chunkInfo.chunk.byteLength,
|
||||
isLastChunk: chunkInfo.isLastChunk,
|
||||
fileOffset: chunkInfo.fileOffset,
|
||||
fileId,
|
||||
};
|
||||
|
||||
// Send network chunk with embedded metadata
|
||||
let sendSuccessful = false;
|
||||
const sendStartTime = performance.now();
|
||||
try {
|
||||
sendSuccessful = await this.networkTransmitter.sendEmbeddedChunk(
|
||||
chunkInfo.chunk,
|
||||
embeddedMeta,
|
||||
peerId
|
||||
);
|
||||
|
||||
if (sendSuccessful) {
|
||||
totalBytesSent += chunkInfo.chunk.byteLength;
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(
|
||||
"warn",
|
||||
`Chunk send failed #${chunkInfo.chunkIndex}: ${error}`
|
||||
);
|
||||
sendSuccessful = false;
|
||||
}
|
||||
const sendTime = performance.now() - sendStartTime;
|
||||
totalSendTime += sendTime;
|
||||
|
||||
// Update state and progress
|
||||
if (sendSuccessful) {
|
||||
this.stateManager.updatePeerState(peerId, {
|
||||
readOffset: chunkInfo.fileOffset + chunkInfo.chunk.byteLength,
|
||||
});
|
||||
|
||||
const progressStartTime = performance.now();
|
||||
await this.progressTracker.updateFileProgress(
|
||||
chunkInfo.chunk.byteLength,
|
||||
fileId,
|
||||
file.size,
|
||||
peerId,
|
||||
true
|
||||
);
|
||||
const progressTime = performance.now() - progressStartTime;
|
||||
totalProgressTime += progressTime;
|
||||
}
|
||||
|
||||
networkChunkIndex++;
|
||||
|
||||
// Check if it's the last chunk
|
||||
if (chunkInfo.isLastChunk) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (developmentEnv === "development") {
|
||||
const totalTime = performance.now() - transferStartTime;
|
||||
const avgSpeedMBps = totalBytesSent / 1024 / 1024 / (totalTime / 1000);
|
||||
|
||||
// 🔧 Fix: Use correct initial offset instead of current readOffset for log statistics
|
||||
const initialOffset = initialReadOffset || 0; // Initial offset at the start of transmission
|
||||
const expectedTotalChunks = Math.ceil(file.size / 65536);
|
||||
const startChunkIndex = Math.floor(initialOffset / 65536);
|
||||
const expectedChunksSent = expectedTotalChunks - startChunkIndex;
|
||||
|
||||
postLogToBackend(
|
||||
`[DEBUG-CHUNKS] ✅ Transfer complete - file: ${file.name}, time: ${(
|
||||
totalTime / 1000
|
||||
).toFixed(1)}s, speed: ${avgSpeedMBps.toFixed(1)}MB/s`
|
||||
);
|
||||
postLogToBackend(
|
||||
`[DEBUG-CHUNKS] Chunks sent: ${networkChunkIndex}, expected: ${expectedChunksSent}, startChunk: ${startChunkIndex}, totalFileChunks: ${expectedTotalChunks}, initialOffset: ${initialOffset}`
|
||||
);
|
||||
|
||||
if (networkChunkIndex !== expectedChunksSent) {
|
||||
postLogToBackend(
|
||||
`[DEBUG-CHUNKS] ⚠️ CHUNK MISMATCH: sent ${networkChunkIndex} but expected ${expectedChunksSent}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage = `Streaming send error: ${error.message}`;
|
||||
if (developmentEnv === "development") {
|
||||
postLogToBackend(`[DEBUG] ❌ Transfer error: ${errorMessage}`);
|
||||
}
|
||||
this.fireError(errorMessage, {
|
||||
fileId,
|
||||
peerId,
|
||||
offset: peerState.readOffset,
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
// Clean up resources
|
||||
streamReader.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⏳ Wait for transfer completion confirmation
|
||||
*/
|
||||
private async waitForTransferComplete(peerId: string): Promise<void> {
|
||||
while (true) {
|
||||
const currentPeerState = this.stateManager.getPeerState(peerId);
|
||||
|
||||
// Check if it has been cleaned up or does not exist
|
||||
if (!currentPeerState || !currentPeerState.isSending) {
|
||||
this.log("log", `Transfer completed or peer disconnected: ${peerId}`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if the WebRTC connection is still valid
|
||||
if (!this.webrtcConnection.peerConnections.has(peerId)) {
|
||||
this.log("log", `Peer connection lost: ${peerId}, stopping transfer`);
|
||||
this.stateManager.updatePeerState(peerId, { isSending: false });
|
||||
break;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 📋 Get file metadata
|
||||
*/
|
||||
private getFileMeta(file: CustomFile): fileMetadata {
|
||||
const fileId = generateFileId(file);
|
||||
return {
|
||||
type: "fileMeta",
|
||||
fileId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
fileType: file.type,
|
||||
fullName: file.fullName,
|
||||
folderName: file.folderName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ❌ Abort file sending
|
||||
*/
|
||||
private abortFileSend(fileId: string, peerId: string): void {
|
||||
this.log("warn", `Aborting file send for ${fileId} to ${peerId}`);
|
||||
this.stateManager.resetPeerState(peerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔧 Set up data handler
|
||||
*/
|
||||
private setupDataHandler(): void {
|
||||
this.webrtcConnection.onDataReceived = (data, peerId) => {
|
||||
if (typeof data === "string") {
|
||||
try {
|
||||
const parsedData = JSON.parse(data) as WebRTCMessage;
|
||||
this.messageHandler.handleSignalingMessage(parsedData, peerId);
|
||||
} catch (error) {
|
||||
this.fireError("Error parsing received JSON data", { error, peerId });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 Error handling
|
||||
*/
|
||||
private fireError(message: string, context?: Record<string, any>) {
|
||||
this.webrtcConnection.fireError(message, {
|
||||
...context,
|
||||
component: "FileTransferOrchestrator",
|
||||
});
|
||||
}
|
||||
|
||||
// ===== State Query and Debugging =====
|
||||
|
||||
/**
|
||||
* 📊 Get transfer statistics
|
||||
*/
|
||||
public getTransferStats(peerId?: string) {
|
||||
const stats = {
|
||||
stateManager: this.stateManager.getStateStats(),
|
||||
progressTracker: peerId
|
||||
? this.progressTracker.getProgressStats(peerId)
|
||||
: null,
|
||||
networkTransmitter: peerId
|
||||
? this.networkTransmitter.getTransmissionStats(peerId)
|
||||
: null,
|
||||
};
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔄 Handle peer reconnection
|
||||
*/
|
||||
public handlePeerReconnection(peerId: string): void {
|
||||
// Clear all transfer states for this peer
|
||||
this.stateManager.clearPeerState(peerId);
|
||||
if (developmentEnv === "development")
|
||||
this.log(
|
||||
"log",
|
||||
`Successfully reset transfer state for reconnected peer ${peerId}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 🧹 Clean up all resources
|
||||
*/
|
||||
public cleanup(): void {
|
||||
this.stateManager.cleanup();
|
||||
this.networkTransmitter.cleanup();
|
||||
this.progressTracker.cleanup();
|
||||
this.messageHandler.cleanup();
|
||||
if (developmentEnv === "development")
|
||||
this.log("log", "FileTransferOrchestrator cleaned up");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
WebRTCMessage,
|
||||
FileRequest,
|
||||
FileReceiveComplete,
|
||||
FolderReceiveComplete
|
||||
} from "@/types/webrtc";
|
||||
import { StateManager } from "./StateManager";
|
||||
import { postLogToBackend } from "@/app/config/api";
|
||||
const developmentEnv = process.env.NODE_ENV;
|
||||
/**
|
||||
* 🚀 Message handling interface - Communicate with main orchestrator
|
||||
*/
|
||||
export interface MessageHandlerDelegate {
|
||||
handleFileRequest(request: FileRequest, peerId: string): Promise<void>;
|
||||
log(
|
||||
level: "log" | "warn" | "error",
|
||||
message: string,
|
||||
context?: Record<string, any>
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 Message handler
|
||||
* Responsible for WebRTC message routing and processing logic
|
||||
*/
|
||||
export class MessageHandler {
|
||||
constructor(
|
||||
private stateManager: StateManager,
|
||||
private delegate: MessageHandlerDelegate
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 🎯 Handle received signaling message
|
||||
*/
|
||||
handleSignalingMessage(message: WebRTCMessage, peerId: string): void {
|
||||
// Delete frequent message reception logs
|
||||
|
||||
switch (message.type) {
|
||||
case "fileRequest":
|
||||
this.handleFileRequest(message as FileRequest, peerId);
|
||||
break;
|
||||
case "fileReceiveComplete":
|
||||
this.handleFileReceiveComplete(message as FileReceiveComplete, peerId);
|
||||
break;
|
||||
case "folderReceiveComplete":
|
||||
this.handleFolderReceiveComplete(
|
||||
message as FolderReceiveComplete,
|
||||
peerId
|
||||
);
|
||||
break;
|
||||
default:
|
||||
this.delegate.log("warn", `Unknown signaling message type received`, {
|
||||
type: message.type,
|
||||
peerId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 📄 Handle file request message
|
||||
*/
|
||||
private async handleFileRequest(
|
||||
request: FileRequest,
|
||||
peerId: string
|
||||
): Promise<void> {
|
||||
const offset = request.offset || 0;
|
||||
|
||||
this.delegate.log(
|
||||
"log",
|
||||
`Handling file request for ${request.fileId} from ${peerId} with offset ${offset}`
|
||||
);
|
||||
|
||||
// Firefox compatibility fix: Add slightly longer delay to ensure receiver is fully ready
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
// Delegate to main orchestrator for specific file transfer
|
||||
try {
|
||||
await this.delegate.handleFileRequest(request, peerId);
|
||||
} catch (error) {
|
||||
this.delegate.log("error", `Error handling file request`, {
|
||||
fileId: request.fileId,
|
||||
peerId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ Handle file receive completion confirmation message
|
||||
*/
|
||||
private handleFileReceiveComplete(
|
||||
message: FileReceiveComplete,
|
||||
peerId: string
|
||||
): void {
|
||||
// Clean up sending state
|
||||
this.stateManager.updatePeerState(peerId, { isSending: false });
|
||||
|
||||
// Get peer state to trigger progress callback
|
||||
const peerState = this.stateManager.getPeerState(peerId);
|
||||
|
||||
// Trigger single file 100% progress (only for non-folder cases)
|
||||
if (!peerState.currentFolderName) {
|
||||
// Delete frequent progress logs
|
||||
peerState.progressCallback?.(message.fileId, 1, 0);
|
||||
} else {
|
||||
// Delete frequent folder progress logs
|
||||
}
|
||||
|
||||
this.delegate.log("log", `File reception confirmed by peer ${peerId}`, {
|
||||
fileId: message.fileId,
|
||||
receivedSize: message.receivedSize,
|
||||
storeUpdated: message.storeUpdated,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 📁 Handle folder receive completion confirmation message
|
||||
*/
|
||||
private handleFolderReceiveComplete(
|
||||
message: FolderReceiveComplete,
|
||||
peerId: string
|
||||
): void {
|
||||
if (developmentEnv === "development") {
|
||||
postLogToBackend(
|
||||
`[DEBUG] 📥 Folder complete - folderName: ${message.folderName}, files: ${message.completedFileIds.length}`
|
||||
);
|
||||
}
|
||||
|
||||
// Get peer state to trigger progress callback
|
||||
const peerState = this.stateManager.getPeerState(peerId);
|
||||
|
||||
// Trigger folder 100% progress
|
||||
const folderMeta = this.stateManager.getFolderMeta(message.folderName);
|
||||
if (folderMeta) {
|
||||
postLogToBackend(
|
||||
`[DEBUG] 🎯 Setting folder progress to 100% - ${message.folderName}`
|
||||
);
|
||||
peerState.progressCallback?.(message.folderName, 1, 0);
|
||||
} else {
|
||||
this.delegate.log(
|
||||
"warn",
|
||||
`Folder metadata not found for completed folder`,
|
||||
{
|
||||
folderName: message.folderName,
|
||||
peerId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
this.delegate.log("log", `Folder reception confirmed by peer ${peerId}`, {
|
||||
folderName: message.folderName,
|
||||
completedFiles: message.completedFileIds.length,
|
||||
allStoreUpdated: message.allStoreUpdated,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 📊 Get message handling statistics
|
||||
*/
|
||||
public getMessageStats(): {
|
||||
handledMessages: number;
|
||||
lastMessageTime: number | null;
|
||||
} {
|
||||
// Message statistics logic can be added here if needed
|
||||
return {
|
||||
handledMessages: 0, // TODO: Implement message counting
|
||||
lastMessageTime: null, // TODO: Record last message time
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 🧹 Clean up resources
|
||||
*/
|
||||
public cleanup(): void {
|
||||
if (developmentEnv === "development")
|
||||
postLogToBackend("[DEBUG] 🧹 MessageHandler cleaned up");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { EmbeddedChunkMeta } from "@/types/webrtc";
|
||||
import { StateManager } from "./StateManager";
|
||||
import WebRTC_Initiator from "../webrtc_Initiator";
|
||||
import { postLogToBackend } from "@/app/config/api";
|
||||
const developmentEnv = process.env.NODE_ENV;
|
||||
/**
|
||||
* 🚀 Network transmitter - Simplified version
|
||||
* Uses WebRTC native bufferedAmountLowThreshold for backpressure control
|
||||
*/
|
||||
export class NetworkTransmitter {
|
||||
constructor(
|
||||
private webrtcConnection: WebRTC_Initiator,
|
||||
private stateManager: StateManager
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 🎯 Send embedded chunk packet with sequence number
|
||||
*/
|
||||
async sendEmbeddedChunk(
|
||||
chunkData: ArrayBuffer,
|
||||
metadata: EmbeddedChunkMeta,
|
||||
peerId: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// 1. Build fused data packet
|
||||
const embeddedPacket = this.createEmbeddedChunkPacket(
|
||||
chunkData,
|
||||
metadata
|
||||
);
|
||||
|
||||
// 2. Send complete fused data packet (no fragmentation)
|
||||
await this.sendSingleData(embeddedPacket, peerId);
|
||||
|
||||
// Key node logs (development environment only)
|
||||
|
||||
// if (
|
||||
// developmentEnv === "development" &&
|
||||
// (metadata.chunkIndex % 100 === 0 || metadata.isLastChunk)
|
||||
// ) {
|
||||
// postLogToBackend(
|
||||
// `[DEBUG] ✓ CHUNK #${metadata.chunkIndex}/${
|
||||
// metadata.totalChunks
|
||||
// } sent, size: ${(chunkData.byteLength / 1024).toFixed(
|
||||
// 1
|
||||
// )}KB, isLast: ${metadata.isLastChunk}`
|
||||
// );
|
||||
// }
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (developmentEnv === "development") {
|
||||
postLogToBackend(
|
||||
`[DEBUG] ❌ CHUNK #${metadata.chunkIndex} send failed: ${error}`
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 Build data packet with embedded metadata
|
||||
*/
|
||||
private createEmbeddedChunkPacket(
|
||||
chunkData: ArrayBuffer,
|
||||
chunkMeta: EmbeddedChunkMeta
|
||||
): ArrayBuffer {
|
||||
// 1. Serialize metadata to JSON
|
||||
const metaJson = JSON.stringify(chunkMeta);
|
||||
const metaBytes = new TextEncoder().encode(metaJson);
|
||||
|
||||
// 2. Metadata length (4 bytes)
|
||||
const metaLengthBuffer = new ArrayBuffer(4);
|
||||
const metaLengthView = new Uint32Array(metaLengthBuffer);
|
||||
metaLengthView[0] = metaBytes.length;
|
||||
|
||||
// 3. Build final fused packet
|
||||
const totalLength = 4 + metaBytes.length + chunkData.byteLength;
|
||||
const finalPacket = new Uint8Array(totalLength);
|
||||
|
||||
// Concatenate: [4-byte length] + [metadata] + [original chunk data]
|
||||
finalPacket.set(new Uint8Array(metaLengthBuffer), 0);
|
||||
finalPacket.set(metaBytes, 4);
|
||||
finalPacket.set(new Uint8Array(chunkData), 4 + metaBytes.length);
|
||||
|
||||
return finalPacket.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 Send single data packet (no fragmentation)
|
||||
*/
|
||||
private async sendSingleData(
|
||||
data: string | ArrayBuffer,
|
||||
peerId: string
|
||||
): Promise<void> {
|
||||
const dataChannel = this.webrtcConnection.dataChannels.get(peerId);
|
||||
if (!dataChannel) {
|
||||
throw new Error("Data channel not found");
|
||||
}
|
||||
|
||||
// Simplified backpressure control
|
||||
await this.simpleBufferControl(dataChannel, peerId);
|
||||
|
||||
// Send directly, no fragmentation
|
||||
const sendResult = this.webrtcConnection.sendData(data, peerId);
|
||||
|
||||
if (!sendResult) {
|
||||
const errorMessage = `sendData failed`;
|
||||
|
||||
if (developmentEnv === "development") {
|
||||
postLogToBackend(`[DEBUG] ❌ ${errorMessage}`);
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 Native backpressure control - Using WebRTC standard mechanism
|
||||
*/
|
||||
private async simpleBufferControl(
|
||||
dataChannel: RTCDataChannel,
|
||||
peerId: string
|
||||
): Promise<void> {
|
||||
const maxBuffer = 3 * 1024 * 1024; // 3MB maximum buffer
|
||||
const lowThreshold = 512 * 1024; // 512KB low threshold
|
||||
|
||||
// Set native low threshold
|
||||
if (dataChannel.bufferedAmountLowThreshold !== lowThreshold) {
|
||||
dataChannel.bufferedAmountLowThreshold = lowThreshold;
|
||||
}
|
||||
|
||||
// If buffer exceeds maximum, wait until it drops to low threshold
|
||||
if (dataChannel.bufferedAmount > maxBuffer) {
|
||||
const startTime = performance.now();
|
||||
const initialBuffered = dataChannel.bufferedAmount;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const onLow = () => {
|
||||
dataChannel.removeEventListener("bufferedamountlow", onLow);
|
||||
resolve();
|
||||
};
|
||||
dataChannel.addEventListener("bufferedamountlow", onLow);
|
||||
|
||||
// Add timeout protection to avoid infinite waiting
|
||||
setTimeout(() => {
|
||||
dataChannel.removeEventListener("bufferedamountlow", onLow);
|
||||
resolve();
|
||||
}, 5000); // 5 second timeout
|
||||
});
|
||||
|
||||
// Only output backpressure logs in development environment
|
||||
// if (developmentEnv === "development") {
|
||||
// const waitTime = performance.now() - startTime;
|
||||
// postLogToBackend(
|
||||
// `[DEBUG] 🚀 BACKPRESSURE - wait: ${waitTime.toFixed(
|
||||
// 1
|
||||
// )}ms, buffered: ${(initialBuffered / 1024).toFixed(0)}KB -> ${(
|
||||
// dataChannel.bufferedAmount / 1024
|
||||
// ).toFixed(0)}KB`
|
||||
// );
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 Send data with backpressure control
|
||||
*/
|
||||
async sendWithBackpressure(
|
||||
data: string | ArrayBuffer,
|
||||
peerId: string
|
||||
): Promise<void> {
|
||||
const dataChannel = this.webrtcConnection.dataChannels.get(peerId);
|
||||
if (!dataChannel) {
|
||||
throw new Error("Data channel not found");
|
||||
}
|
||||
|
||||
try {
|
||||
// For ArrayBuffer, if larger than 64KB, needs to be fragmented (fix sendData failed)
|
||||
if (data instanceof ArrayBuffer) {
|
||||
await this.sendLargeArrayBuffer(data, peerId);
|
||||
} else {
|
||||
await this.sendSingleData(data, peerId);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = `sendWithBackpressure failed: ${error}`;
|
||||
if (developmentEnv === "development") {
|
||||
postLogToBackend(`[DEBUG] ❌ ${errorMessage}`);
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 Send large ArrayBuffer (fragmentation processing)
|
||||
*/
|
||||
private async sendLargeArrayBuffer(
|
||||
data: ArrayBuffer,
|
||||
peerId: string
|
||||
): Promise<void> {
|
||||
const networkChunkSize = 65536; // 64KB
|
||||
const totalSize = data.byteLength;
|
||||
|
||||
// If data is less than 64KB, send directly
|
||||
if (totalSize <= networkChunkSize) {
|
||||
await this.sendSingleData(data, peerId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fragment large data for sending
|
||||
let offset = 0;
|
||||
let fragmentIndex = 0;
|
||||
|
||||
while (offset < totalSize) {
|
||||
const chunkSize = Math.min(networkChunkSize, totalSize - offset);
|
||||
const chunk = data.slice(offset, offset + chunkSize);
|
||||
|
||||
// Send fragment
|
||||
await this.sendSingleData(chunk, peerId);
|
||||
|
||||
offset += chunkSize;
|
||||
fragmentIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 📊 Get transmission statistics
|
||||
*/
|
||||
public getTransmissionStats(peerId: string) {
|
||||
const dataChannel = this.webrtcConnection.dataChannels.get(peerId);
|
||||
|
||||
return {
|
||||
peerId,
|
||||
currentBufferedAmount: dataChannel?.bufferedAmount || 0,
|
||||
bufferedAmountLowThreshold: dataChannel?.bufferedAmountLowThreshold || 0,
|
||||
channelState: dataChannel?.readyState || "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 🧹 Clean up resources
|
||||
*/
|
||||
public cleanup(): void {
|
||||
if (developmentEnv === "development") {
|
||||
postLogToBackend("[DEBUG] 🧹 NetworkTransmitter cleaned up");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { SpeedCalculator } from "@/lib/speedCalculator";
|
||||
import { StateManager } from "./StateManager";
|
||||
import { postLogToBackend } from "@/app/config/api";
|
||||
const developmentEnv = process.env.NODE_ENV;
|
||||
/**
|
||||
* 🚀 Progress callback type definition
|
||||
*/
|
||||
export type ProgressCallback = (
|
||||
fileId: string,
|
||||
progress: number,
|
||||
speed: number
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* 🚀 Progress tracker
|
||||
* Responsible for file and folder progress calculation, speed statistics, and callback triggering
|
||||
*/
|
||||
export class ProgressTracker {
|
||||
private speedCalculator = new SpeedCalculator();
|
||||
|
||||
constructor(private stateManager: StateManager) {}
|
||||
|
||||
/**
|
||||
* 🎯 Update file transfer progress
|
||||
*/
|
||||
async updateFileProgress(
|
||||
byteLength: number,
|
||||
fileId: string,
|
||||
fileSize: number,
|
||||
peerId: string,
|
||||
wasActuallySent: boolean = true
|
||||
): Promise<void> {
|
||||
const peerState = this.stateManager.getPeerState(peerId);
|
||||
if (!peerState) return;
|
||||
|
||||
// Important fix: Only update statistics for successfully sent data
|
||||
if (!wasActuallySent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update file sent bytes
|
||||
this.stateManager.updateFileBytesSent(peerId, fileId, byteLength);
|
||||
|
||||
// Calculate progress ID and statistics
|
||||
let progressFileId = fileId;
|
||||
let currentBytes = this.stateManager.getFileBytesSent(peerId, fileId);
|
||||
let totalSize = fileSize;
|
||||
|
||||
// If file belongs to a folder, recalculate folder progress
|
||||
if (peerState.currentFolderName) {
|
||||
const folderName = peerState.currentFolderName;
|
||||
const folderMeta = this.stateManager.getFolderMeta(folderName);
|
||||
|
||||
progressFileId = folderName;
|
||||
totalSize = folderMeta?.totalSize || 0;
|
||||
|
||||
// Recalculate folder progress (sum of progress from all its files)
|
||||
// This is more robust and correct for resume downloads
|
||||
currentBytes = this.stateManager.getFolderBytesSent(peerId, folderName);
|
||||
|
||||
// Delete frequent folder progress logs
|
||||
}
|
||||
|
||||
// Update speed calculator
|
||||
this.speedCalculator.updateSendSpeed(peerId, currentBytes);
|
||||
const speed = this.speedCalculator.getSendSpeed(peerId);
|
||||
const progress = totalSize > 0 ? currentBytes / totalSize : 0;
|
||||
|
||||
// Trigger progress callback
|
||||
this.triggerProgressCallback(peerId, progressFileId, progress, speed);
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 Update folder transfer progress
|
||||
*/
|
||||
async updateFolderProgress(
|
||||
folderName: string,
|
||||
fileProgress: Record<string, number>,
|
||||
peerId: string
|
||||
): Promise<void> {
|
||||
const folderMeta = this.stateManager.getFolderMeta(folderName);
|
||||
if (!folderMeta) {
|
||||
postLogToBackend(`[DEBUG] ⚠️ Folder metadata not found: ${folderName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate total folder progress
|
||||
let totalSentBytes = 0;
|
||||
folderMeta.fileIds.forEach((fileId) => {
|
||||
totalSentBytes += this.stateManager.getFileBytesSent(peerId, fileId);
|
||||
});
|
||||
|
||||
const progress =
|
||||
folderMeta.totalSize > 0 ? totalSentBytes / folderMeta.totalSize : 0;
|
||||
const speed = this.speedCalculator.getSendSpeed(peerId);
|
||||
|
||||
// Trigger folder progress callback
|
||||
this.triggerProgressCallback(peerId, folderName, progress, speed);
|
||||
|
||||
postLogToBackend(
|
||||
`[DEBUG] 📁 Folder progress - ${folderName}: ${(progress * 100).toFixed(
|
||||
2
|
||||
)}%, speed: ${speed.toFixed(2)} KB/s, bytes: ${totalSentBytes}/${
|
||||
folderMeta.totalSize
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 Set progress callback function
|
||||
*/
|
||||
setProgressCallback(callback: ProgressCallback, peerId: string): void {
|
||||
this.stateManager.updatePeerState(peerId, { progressCallback: callback });
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 Trigger progress callback
|
||||
*/
|
||||
private triggerProgressCallback(
|
||||
peerId: string,
|
||||
fileId: string,
|
||||
progress: number,
|
||||
speed: number
|
||||
): void {
|
||||
const peerState = this.stateManager.getPeerState(peerId);
|
||||
if (peerState.progressCallback) {
|
||||
try {
|
||||
peerState.progressCallback(fileId, progress, speed);
|
||||
} catch (error) {
|
||||
postLogToBackend(
|
||||
`[DEBUG] ❌ Progress callback error - fileId: ${fileId}, error: ${error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 Calculate current transfer speed
|
||||
*/
|
||||
getCurrentSpeed(peerId: string): number {
|
||||
return this.speedCalculator.getSendSpeed(peerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 Complete file transfer progress (set to 100%)
|
||||
*/
|
||||
completeFileProgress(fileId: string, peerId: string): void {
|
||||
this.triggerProgressCallback(peerId, fileId, 1.0, 0);
|
||||
|
||||
postLogToBackend(`[DEBUG] ✅ File progress completed: ${fileId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 Complete folder transfer progress (set to 100%)
|
||||
*/
|
||||
completeFolderProgress(folderName: string, peerId: string): void {
|
||||
this.triggerProgressCallback(peerId, folderName, 1.0, 0);
|
||||
|
||||
postLogToBackend(`[DEBUG] ✅ Folder progress completed: ${folderName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 📊 Get detailed progress statistics
|
||||
*/
|
||||
getProgressStats(peerId: string) {
|
||||
const peerState = this.stateManager.getPeerState(peerId);
|
||||
const currentSpeed = this.getCurrentSpeed(peerId);
|
||||
|
||||
// Calculate total sent bytes
|
||||
let totalBytesSent = 0;
|
||||
Object.values(peerState.totalBytesSent).forEach((bytes) => {
|
||||
totalBytesSent += bytes;
|
||||
});
|
||||
|
||||
return {
|
||||
peerId,
|
||||
currentSpeed,
|
||||
totalBytesSent,
|
||||
activeTransfers: Object.keys(peerState.totalBytesSent).length,
|
||||
currentFolderName: peerState.currentFolderName,
|
||||
isSending: peerState.isSending,
|
||||
hasProgressCallback: !!peerState.progressCallback,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 📊 Get detailed folder progress information
|
||||
*/
|
||||
getFolderProgressDetails(folderName: string, peerId: string) {
|
||||
const folderMeta = this.stateManager.getFolderMeta(folderName);
|
||||
if (!folderMeta) return null;
|
||||
|
||||
const fileProgresses: Record<
|
||||
string,
|
||||
{ sent: number; total: number; progress: number }
|
||||
> = {};
|
||||
let totalSent = 0;
|
||||
|
||||
folderMeta.fileIds.forEach((fileId) => {
|
||||
const sent = this.stateManager.getFileBytesSent(peerId, fileId);
|
||||
// Note: Need to get file size from pendingFiles, temporarily using 0
|
||||
const total = 0; // TODO: Need to get file size from StateManager
|
||||
totalSent += sent;
|
||||
|
||||
fileProgresses[fileId] = {
|
||||
sent,
|
||||
total,
|
||||
progress: total > 0 ? sent / total : 0,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
folderName,
|
||||
totalSize: folderMeta.totalSize,
|
||||
totalSent,
|
||||
overallProgress:
|
||||
folderMeta.totalSize > 0 ? totalSent / folderMeta.totalSize : 0,
|
||||
fileCount: folderMeta.fileIds.length,
|
||||
fileProgresses,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 🧹 Clean up progress tracking resources
|
||||
*/
|
||||
cleanup(): void {
|
||||
// SpeedCalculator internally automatically cleans up expired data
|
||||
if (developmentEnv === "development")
|
||||
postLogToBackend("[DEBUG] 🧹 ProgressTracker cleaned up");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { PeerState, CustomFile, FolderMeta } from "@/types/webrtc";
|
||||
// Simplified version no longer depends on TransferConfig's complex configuration
|
||||
|
||||
/**
|
||||
* 🚀 State management class
|
||||
* Centrally manages all transfer-related state data
|
||||
*/
|
||||
export class StateManager {
|
||||
private peerStates = new Map<string, PeerState>();
|
||||
private pendingFiles = new Map<string, CustomFile>();
|
||||
private pendingFolderMeta: Record<string, FolderMeta> = {};
|
||||
|
||||
// ===== Peer state management =====
|
||||
|
||||
/**
|
||||
* Get or create peer state
|
||||
*/
|
||||
public getPeerState(peerId: string): PeerState {
|
||||
if (!this.peerStates.has(peerId)) {
|
||||
this.peerStates.set(peerId, {
|
||||
isSending: false,
|
||||
bufferQueue: [],
|
||||
readOffset: 0,
|
||||
isReading: false,
|
||||
currentFolderName: "",
|
||||
totalBytesSent: {},
|
||||
progressCallback: null,
|
||||
});
|
||||
}
|
||||
return this.peerStates.get(peerId)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update peer state
|
||||
*/
|
||||
public updatePeerState(peerId: string, updates: Partial<PeerState>): void {
|
||||
const currentState = this.getPeerState(peerId);
|
||||
Object.assign(currentState, updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset peer state (when transfer is complete or error occurs)
|
||||
*/
|
||||
public resetPeerState(peerId: string): void {
|
||||
const peerState = this.getPeerState(peerId);
|
||||
peerState.isSending = false;
|
||||
peerState.readOffset = 0;
|
||||
peerState.bufferQueue = [];
|
||||
peerState.isReading = false;
|
||||
// Preserve currentFolderName, totalBytesSent, progressCallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear peer state immediately (for graceful disconnect)
|
||||
*/
|
||||
public clearPeerState(peerId: string): void {
|
||||
this.peerStates.delete(peerId);
|
||||
}
|
||||
|
||||
// ===== File management =====
|
||||
|
||||
/**
|
||||
* Add pending file to send
|
||||
*/
|
||||
public addPendingFile(fileId: string, file: CustomFile): void {
|
||||
this.pendingFiles.set(fileId, file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending file to send
|
||||
*/
|
||||
public getPendingFile(fileId: string): CustomFile | undefined {
|
||||
return this.pendingFiles.get(fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove pending file to send
|
||||
*/
|
||||
public removePendingFile(fileId: string): void {
|
||||
this.pendingFiles.delete(fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pending files to send
|
||||
*/
|
||||
public getAllPendingFiles(): Map<string, CustomFile> {
|
||||
return new Map(this.pendingFiles);
|
||||
}
|
||||
|
||||
// ===== Folder metadata management =====
|
||||
|
||||
/**
|
||||
* Add or update folder metadata
|
||||
*/
|
||||
public addFileToFolder(
|
||||
folderName: string,
|
||||
fileId: string,
|
||||
fileSize: number
|
||||
): void {
|
||||
if (!this.pendingFolderMeta[folderName]) {
|
||||
this.pendingFolderMeta[folderName] = { totalSize: 0, fileIds: [] };
|
||||
}
|
||||
|
||||
const folderMeta = this.pendingFolderMeta[folderName];
|
||||
if (!folderMeta.fileIds.includes(fileId)) {
|
||||
folderMeta.fileIds.push(fileId);
|
||||
folderMeta.totalSize += fileSize;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get folder metadata
|
||||
*/
|
||||
public getFolderMeta(folderName: string): FolderMeta | undefined {
|
||||
return this.pendingFolderMeta[folderName];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all folder metadata
|
||||
*/
|
||||
public getAllFolderMeta(): Record<string, FolderMeta> {
|
||||
return { ...this.pendingFolderMeta };
|
||||
}
|
||||
// ===== Progress tracking related state =====
|
||||
|
||||
/**
|
||||
* Update file sent bytes
|
||||
*/
|
||||
public updateFileBytesSent(
|
||||
peerId: string,
|
||||
fileId: string,
|
||||
bytes: number
|
||||
): void {
|
||||
const peerState = this.getPeerState(peerId);
|
||||
if (!peerState.totalBytesSent[fileId]) {
|
||||
peerState.totalBytesSent[fileId] = 0;
|
||||
}
|
||||
peerState.totalBytesSent[fileId] += bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file sent bytes
|
||||
*/
|
||||
public getFileBytesSent(peerId: string, fileId: string): number {
|
||||
const peerState = this.peerStates.get(peerId);
|
||||
return peerState?.totalBytesSent[fileId] || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate folder total sent bytes
|
||||
*/
|
||||
public getFolderBytesSent(peerId: string, folderName: string): number {
|
||||
const folderMeta = this.getFolderMeta(folderName);
|
||||
const peerState = this.peerStates.get(peerId);
|
||||
|
||||
if (!folderMeta || !peerState) return 0;
|
||||
|
||||
let totalSent = 0;
|
||||
folderMeta.fileIds.forEach((fileId) => {
|
||||
totalSent += peerState.totalBytesSent[fileId] || 0;
|
||||
});
|
||||
|
||||
return totalSent;
|
||||
}
|
||||
|
||||
// ===== Cleanup and reset =====
|
||||
|
||||
/**
|
||||
* Clean up all states (when system resets)
|
||||
*/
|
||||
public cleanup(): void {
|
||||
this.peerStates.clear();
|
||||
this.pendingFiles.clear();
|
||||
this.pendingFolderMeta = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get state statistics (for debugging)
|
||||
*/
|
||||
public getStateStats() {
|
||||
return {
|
||||
peerCount: this.peerStates.size,
|
||||
pendingFileCount: this.pendingFiles.size,
|
||||
folderCount: Object.keys(this.pendingFolderMeta).length,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { CustomFile } from "@/types/webrtc";
|
||||
import { TransferConfig } from "./TransferConfig";
|
||||
import { ChunkRangeCalculator } from "@/lib/utils/ChunkRangeCalculator";
|
||||
import { postLogToBackend } from "@/app/config/api";
|
||||
const developmentEnv = process.env.NODE_ENV;
|
||||
/**
|
||||
* 🚀 Network chunk interface
|
||||
*/
|
||||
export interface NetworkChunk {
|
||||
chunk: ArrayBuffer | null;
|
||||
chunkIndex: number;
|
||||
totalChunks: number;
|
||||
fileOffset: number;
|
||||
isLastChunk: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 High-performance streaming file reader
|
||||
* Uses a two-layer buffering architecture: large batch reading + small network chunk sending
|
||||
* Solves file reading performance bottleneck issues
|
||||
*/
|
||||
export class StreamingFileReader {
|
||||
// Configuration parameters
|
||||
private readonly BATCH_SIZE =
|
||||
TransferConfig.FILE_CONFIG.CHUNK_SIZE *
|
||||
TransferConfig.FILE_CONFIG.BATCH_SIZE; // 32MB batches
|
||||
private readonly NETWORK_CHUNK_SIZE =
|
||||
TransferConfig.FILE_CONFIG.NETWORK_CHUNK_SIZE; // 64KB network chunks
|
||||
private readonly CHUNKS_PER_BATCH = this.BATCH_SIZE / this.NETWORK_CHUNK_SIZE; // 512 chunks
|
||||
|
||||
// File state
|
||||
private file: File;
|
||||
private fileReader: FileReader;
|
||||
private totalFileSize: number;
|
||||
|
||||
// Batch buffering state
|
||||
private currentBatch: ArrayBuffer | null = null; // Current 32MB batch data
|
||||
private currentBatchStartOffset = 0; // Starting position of current batch in file
|
||||
private currentChunkIndexInBatch = 0; // Index of current network chunk in batch
|
||||
|
||||
// Global state
|
||||
private totalFileOffset = 0; // Current position in the entire file
|
||||
private startChunkIndex = 0; // 🔧 Record the chunk index at the start of transmission
|
||||
private isFinished = false;
|
||||
private isReading = false; // Prevent concurrent reading
|
||||
|
||||
constructor(file: CustomFile, startOffset: number = 0) {
|
||||
this.file = file;
|
||||
this.totalFileSize = file.size;
|
||||
this.totalFileOffset = startOffset;
|
||||
// 🔧 Fix: When resuming, currentBatchStartOffset should start from startOffset
|
||||
this.currentBatchStartOffset = startOffset;
|
||||
this.fileReader = new FileReader();
|
||||
|
||||
// 🔧 Record the starting chunk index of the transfer, used for boundary detection
|
||||
this.startChunkIndex = Math.floor(startOffset / this.NETWORK_CHUNK_SIZE);
|
||||
|
||||
if (developmentEnv === "development") {
|
||||
const chunkRange = ChunkRangeCalculator.getChunkRange(
|
||||
this.totalFileSize,
|
||||
startOffset,
|
||||
this.NETWORK_CHUNK_SIZE
|
||||
);
|
||||
postLogToBackend(
|
||||
`[SEND-SUMMARY] File: ${file.name}, offset: ${startOffset}, startChunk: ${chunkRange.startChunk}, endChunk: ${chunkRange.endChunk}, willSend: ${chunkRange.totalChunks}, absoluteTotal: ${chunkRange.absoluteTotalChunks}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 Core method: Get next 64KB network chunk
|
||||
*/
|
||||
async getNextNetworkChunk(): Promise<NetworkChunk> {
|
||||
// 1. Check if new batch needs to be loaded
|
||||
if (this.needsNewBatch()) {
|
||||
await this.loadNextBatch();
|
||||
}
|
||||
|
||||
// 2. Check if end of file has been reached
|
||||
if (this.isFinished || !this.currentBatch) {
|
||||
return {
|
||||
chunk: null,
|
||||
chunkIndex: this.calculateGlobalChunkIndex(),
|
||||
totalChunks: this.calculateTotalNetworkChunks(),
|
||||
fileOffset: this.totalFileOffset,
|
||||
isLastChunk: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Slice 64KB network chunk from current batch
|
||||
const networkChunk = this.sliceNetworkChunkFromBatch();
|
||||
const globalChunkIndex = this.calculateGlobalChunkIndex();
|
||||
const isLast = this.isLastNetworkChunk(networkChunk);
|
||||
|
||||
// 4. Update state
|
||||
this.updateChunkState(networkChunk);
|
||||
|
||||
// if (developmentEnv === "development") {
|
||||
// const totalChunks = this.calculateTotalNetworkChunks();
|
||||
|
||||
// const isFirst = globalChunkIndex === this.startChunkIndex;
|
||||
// const expectedLastChunk = Math.floor(
|
||||
// (this.totalFileSize - 1) / this.NETWORK_CHUNK_SIZE
|
||||
// );
|
||||
// const isRealLast = isLast && globalChunkIndex === expectedLastChunk;
|
||||
|
||||
// if (isFirst || isRealLast) {
|
||||
// postLogToBackend(
|
||||
// `[BOUNDARY] Chunk #${globalChunkIndex}/${totalChunks}, isFirst: ${isFirst}, isLast: ${isRealLast}, startIdx: ${this.startChunkIndex}, expectedLastIdx: ${expectedLastChunk}, size: ${networkChunk.byteLength}`
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
return {
|
||||
chunk: networkChunk,
|
||||
chunkIndex: globalChunkIndex,
|
||||
totalChunks: this.calculateTotalNetworkChunks(),
|
||||
fileOffset: this.totalFileOffset - networkChunk.byteLength,
|
||||
isLastChunk: isLast,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔍 Determine if new batch needs to be loaded
|
||||
*/
|
||||
private needsNewBatch(): boolean {
|
||||
return (
|
||||
this.currentBatch === null || // No batch loaded yet
|
||||
this.currentChunkIndexInBatch >= this.CHUNKS_PER_BATCH || // Current batch exhausted
|
||||
this.isCurrentBatchEmpty() // Current batch has no data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔍 Check if current batch is empty
|
||||
*/
|
||||
private isCurrentBatchEmpty(): boolean {
|
||||
if (!this.currentBatch) return true;
|
||||
|
||||
const usedBytes = this.currentChunkIndexInBatch * this.NETWORK_CHUNK_SIZE;
|
||||
return usedBytes >= this.currentBatch.byteLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* 📥 Load next 32MB batch into memory
|
||||
*/
|
||||
private async loadNextBatch(): Promise<void> {
|
||||
if (this.isReading) {
|
||||
// Prevent concurrent reading
|
||||
while (this.isReading) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.isReading = true;
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// 1. Clean up old batch memory
|
||||
this.currentBatch = null;
|
||||
|
||||
// 2. Calculate size to read this time
|
||||
const remainingFileSize = this.totalFileSize - this.totalFileOffset;
|
||||
const batchSize = Math.min(this.BATCH_SIZE, remainingFileSize);
|
||||
|
||||
if (batchSize <= 0) {
|
||||
this.isFinished = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Perform large chunk file reading
|
||||
const sliceStartTime = performance.now();
|
||||
const fileSlice = this.file.slice(
|
||||
this.totalFileOffset,
|
||||
this.totalFileOffset + batchSize
|
||||
);
|
||||
const sliceTime = performance.now() - sliceStartTime;
|
||||
|
||||
// 4. Asynchronously read file data
|
||||
const readStartTime = performance.now();
|
||||
this.currentBatch = await this.readFileSlice(fileSlice);
|
||||
const readTime = performance.now() - readStartTime;
|
||||
|
||||
const batchStartOffset = this.totalFileOffset;
|
||||
this.currentBatchStartOffset = batchStartOffset;
|
||||
|
||||
// 🔧 Fix: Simplify index calculation logic within batch
|
||||
// Since calculateGlobalChunkIndex now directly calculates based on totalFileOffset
|
||||
// Indexing within a batch only needs to be calculated based on the starting position of the current batch
|
||||
const chunkOffsetInBatch =
|
||||
batchStartOffset -
|
||||
Math.floor(batchStartOffset / this.BATCH_SIZE) * this.BATCH_SIZE;
|
||||
this.currentChunkIndexInBatch = Math.floor(
|
||||
chunkOffsetInBatch / this.NETWORK_CHUNK_SIZE
|
||||
);
|
||||
|
||||
// Only output essential batch reading logs in development environment
|
||||
if (developmentEnv === "development" && batchSize > this.BATCH_SIZE / 2) {
|
||||
const totalTime = performance.now() - startTime;
|
||||
const speedMBps = batchSize / 1024 / 1024 / (totalTime / 1000);
|
||||
postLogToBackend(
|
||||
`[BATCH-READ] 📖 size: ${(batchSize / 1024 / 1024).toFixed(
|
||||
1
|
||||
)}MB, speed: ${speedMBps.toFixed(1)}MB/s`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (developmentEnv === "development") {
|
||||
postLogToBackend(`[DEBUG] ❌ BATCH_READ failed: ${error}`);
|
||||
}
|
||||
throw new Error(`Failed to load file batch: ${error}`);
|
||||
} finally {
|
||||
this.isReading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 📄 Perform file reading operation
|
||||
*/
|
||||
private async readFileSlice(fileSlice: Blob): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.fileReader.onload = () => {
|
||||
const result = this.fileReader.result as ArrayBuffer;
|
||||
if (result) {
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(new Error("FileReader result is null"));
|
||||
}
|
||||
};
|
||||
|
||||
this.fileReader.onerror = () => {
|
||||
reject(
|
||||
new Error(
|
||||
`File reading failed: ${
|
||||
this.fileReader.error?.message || "Unknown error"
|
||||
}`
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
this.fileReader.readAsArrayBuffer(fileSlice);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ✂️ Slice 64KB network chunk from 32MB batch
|
||||
* 🆕 Fix: Calculate directly based on the position of offset in the batch, avoiding complex batch internal index calculations
|
||||
*/
|
||||
private sliceNetworkChunkFromBatch(): ArrayBuffer {
|
||||
if (!this.currentBatch) {
|
||||
throw new Error("No current batch available for slicing");
|
||||
}
|
||||
|
||||
// 🆕 Calculated directly based on the position of offset in the batch to avoid index calculation errors within the batch
|
||||
const offsetInBatch = this.totalFileOffset - this.currentBatchStartOffset;
|
||||
const remainingInBatch = this.currentBatch.byteLength - offsetInBatch;
|
||||
const chunkSize = Math.min(this.NETWORK_CHUNK_SIZE, remainingInBatch);
|
||||
|
||||
if (chunkSize <= 0) {
|
||||
throw new Error("Invalid chunk size calculated");
|
||||
}
|
||||
|
||||
const networkChunk = this.currentBatch.slice(
|
||||
offsetInBatch,
|
||||
offsetInBatch + chunkSize
|
||||
);
|
||||
|
||||
// Delete frequent slice logs, only output when needed
|
||||
return networkChunk;
|
||||
}
|
||||
|
||||
/**
|
||||
* 📊 Calculate global network chunk index
|
||||
* 🔧 Simplified logic: directly calculate based on file offset to avoid batch boundary errors
|
||||
*/
|
||||
private calculateGlobalChunkIndex(): number {
|
||||
// Calculate chunk index directly based on current file offset, avoiding complex batch calculations, consistent with receiver
|
||||
return Math.floor(this.totalFileOffset / this.NETWORK_CHUNK_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 📈 Calculate total network chunk count
|
||||
*/
|
||||
private calculateTotalNetworkChunks(): number {
|
||||
return Math.ceil(this.totalFileSize / this.NETWORK_CHUNK_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⏭️ Update current processing state
|
||||
*/
|
||||
private updateChunkState(chunk: ArrayBuffer): void {
|
||||
this.currentChunkIndexInBatch++;
|
||||
this.totalFileOffset += chunk.byteLength;
|
||||
|
||||
// Check if end of file has been reached
|
||||
if (this.totalFileOffset >= this.totalFileSize) {
|
||||
this.isFinished = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🏁 Check if this is the last network chunk
|
||||
*/
|
||||
private isLastNetworkChunk(chunk: ArrayBuffer): boolean {
|
||||
return this.totalFileOffset + chunk.byteLength >= this.totalFileSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* 📊 Get reading progress information
|
||||
*/
|
||||
public getProgress(): {
|
||||
readBytes: number;
|
||||
totalBytes: number;
|
||||
progressPercent: number;
|
||||
currentBatchInfo?: {
|
||||
batchStartOffset: number;
|
||||
batchSize: number;
|
||||
chunkIndex: number;
|
||||
totalChunks: number;
|
||||
};
|
||||
} {
|
||||
const progressPercent =
|
||||
this.totalFileSize > 0
|
||||
? (this.totalFileOffset / this.totalFileSize) * 100
|
||||
: 0;
|
||||
|
||||
const result = {
|
||||
readBytes: this.totalFileOffset,
|
||||
totalBytes: this.totalFileSize,
|
||||
progressPercent,
|
||||
} as any;
|
||||
|
||||
if (this.currentBatch) {
|
||||
result.currentBatchInfo = {
|
||||
batchStartOffset: this.currentBatchStartOffset,
|
||||
batchSize: this.currentBatch.byteLength,
|
||||
chunkIndex: this.currentChunkIndexInBatch,
|
||||
totalChunks: Math.ceil(
|
||||
this.currentBatch.byteLength / this.NETWORK_CHUNK_SIZE
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔄 Reset reader state (for restarting reading)
|
||||
*/
|
||||
public reset(startOffset: number = 0): void {
|
||||
this.totalFileOffset = startOffset;
|
||||
this.isFinished = false;
|
||||
this.isReading = false;
|
||||
this.currentBatch = null;
|
||||
// 🔧 Fix: Reset also needs to correctly set currentBatchStartOffset
|
||||
this.currentBatchStartOffset = startOffset;
|
||||
this.currentChunkIndexInBatch = 0; // Reset to 0, loadNextBatch will recalculate
|
||||
|
||||
if (developmentEnv === "development") {
|
||||
postLogToBackend(
|
||||
`[DEBUG] 🔄 StreamingFileReader reset - startOffset:${startOffset}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🧹 Cleanup and release resources
|
||||
*/
|
||||
public cleanup(): void {
|
||||
// Abort ongoing file reading
|
||||
if (this.isReading) {
|
||||
this.fileReader.abort();
|
||||
}
|
||||
|
||||
// Clean up memory
|
||||
this.currentBatch = null;
|
||||
this.isFinished = true;
|
||||
this.isReading = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔍 Get debug information
|
||||
*/
|
||||
public getDebugInfo() {
|
||||
return {
|
||||
fileName: this.file.name,
|
||||
fileSize: this.totalFileSize,
|
||||
currentOffset: this.totalFileOffset,
|
||||
isFinished: this.isFinished,
|
||||
isReading: this.isReading,
|
||||
hasBatch: !!this.currentBatch,
|
||||
batchOffset: this.currentBatchStartOffset,
|
||||
chunkInBatch: this.currentChunkIndexInBatch,
|
||||
globalChunkIndex: this.calculateGlobalChunkIndex(),
|
||||
totalChunks: this.calculateTotalNetworkChunks(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 🚀 Transfer configuration management class
|
||||
* Centrally manages all file transfer related configuration parameters
|
||||
*/
|
||||
export class TransferConfig {
|
||||
// File I/O related configuration
|
||||
static readonly FILE_CONFIG = {
|
||||
CHUNK_SIZE: 4194304, // 4MB - File reading chunk size, reduces FileReader calls
|
||||
BATCH_SIZE: 8, // 8 chunks batch processing - 32MB batch processing improves performance
|
||||
NETWORK_CHUNK_SIZE: 65536, // 64KB - WebRTC safe sending size, fixes sendData failed
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 🚀 File transfer module unified export
|
||||
* Provides modular file transfer services
|
||||
*/
|
||||
|
||||
// Configuration management
|
||||
export { TransferConfig } from "./TransferConfig";
|
||||
|
||||
// State management
|
||||
export { StateManager } from "./StateManager";
|
||||
|
||||
// High-performance file reading
|
||||
export { StreamingFileReader } from "./StreamingFileReader";
|
||||
export type { NetworkChunk } from "./StreamingFileReader";
|
||||
|
||||
// Network transmission
|
||||
export { NetworkTransmitter } from "./NetworkTransmitter";
|
||||
|
||||
// Message handling
|
||||
export { MessageHandler } from "./MessageHandler";
|
||||
export type { MessageHandlerDelegate } from "./MessageHandler";
|
||||
|
||||
// Progress tracking
|
||||
export { ProgressTracker } from "./ProgressTracker";
|
||||
export type { ProgressCallback } from "./ProgressTracker";
|
||||
|
||||
// Main orchestrator
|
||||
export { FileTransferOrchestrator } from "./FileTransferOrchestrator";
|
||||
|
||||
/**
|
||||
* 🎯 Convenience creation function - Quick initialization of file transfer services
|
||||
*/
|
||||
import WebRTC_Initiator from "../webrtc_Initiator";
|
||||
import { FileTransferOrchestrator } from "./FileTransferOrchestrator";
|
||||
|
||||
export function createFileTransferService(
|
||||
webrtcConnection: WebRTC_Initiator
|
||||
): FileTransferOrchestrator {
|
||||
return new FileTransferOrchestrator(webrtcConnection);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 🚀 Chunk range calculation utilities
|
||||
* Provides unified chunk calculation logic to ensure consistency between sender and receiver
|
||||
*/
|
||||
|
||||
export class ChunkRangeCalculator {
|
||||
/**
|
||||
* Calculate chunk range for a file with given parameters
|
||||
* This method ensures both sender and receiver use identical calculation logic
|
||||
*/
|
||||
static getChunkRange(fileSize: number, startOffset: number, chunkSize: number) {
|
||||
// Calculate starting chunk index
|
||||
const startChunk = Math.floor(startOffset / chunkSize);
|
||||
|
||||
// Calculate ending chunk index based on the last byte of the file
|
||||
const lastByteIndex = fileSize - 1;
|
||||
const endChunk = Math.floor(lastByteIndex / chunkSize);
|
||||
|
||||
// Calculate total chunks to be sent/received (from startChunk to endChunk inclusive)
|
||||
const totalChunks = endChunk - startChunk + 1;
|
||||
|
||||
// Calculate absolute total chunks in the entire file
|
||||
const absoluteTotalChunks = Math.ceil(fileSize / chunkSize);
|
||||
|
||||
return {
|
||||
startChunk, // First chunk index to process
|
||||
endChunk, // Last chunk index to process
|
||||
totalChunks, // Number of chunks to process (for resume transfers)
|
||||
absoluteTotalChunks // Total chunks in the entire file
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate expected chunks count for resume transfer
|
||||
* Identical to ReceptionConfig.calculateExpectedChunks()
|
||||
*/
|
||||
static calculateExpectedChunks(fileSize: number, startOffset: number, chunkSize: number): number {
|
||||
return Math.ceil((fileSize - startOffset) / chunkSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chunk index from file offset
|
||||
* Identical to ReceptionConfig.getChunkIndexFromOffset()
|
||||
*/
|
||||
static getChunkIndexFromOffset(offset: number, chunkSize: number): number {
|
||||
return Math.floor(offset / chunkSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file offset from chunk index
|
||||
* Identical to ReceptionConfig.getOffsetFromChunkIndex()
|
||||
*/
|
||||
static getOffsetFromChunkIndex(chunkIndex: number, chunkSize: number): number {
|
||||
return chunkIndex * chunkSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate chunk index within expected range
|
||||
*/
|
||||
static isChunkIndexValid(
|
||||
chunkIndex: number,
|
||||
startOffset: number,
|
||||
fileSize: number,
|
||||
chunkSize: number
|
||||
): boolean {
|
||||
const range = this.getChunkRange(fileSize, startOffset, chunkSize);
|
||||
return chunkIndex >= range.startChunk && chunkIndex <= range.endChunk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate relative chunk index from absolute chunk index
|
||||
* Used by receiver to map sender's absolute index to local array index
|
||||
*/
|
||||
static getRelativeChunkIndex(
|
||||
absoluteChunkIndex: number,
|
||||
startOffset: number,
|
||||
chunkSize: number
|
||||
): number {
|
||||
const startChunkIndex = this.getChunkIndexFromOffset(startOffset, chunkSize);
|
||||
return absoluteChunkIndex - startChunkIndex;
|
||||
}
|
||||
}
|
||||
+149
-59
@@ -8,7 +8,6 @@ import {
|
||||
config,
|
||||
} from "@/app/config/environment";
|
||||
import { useFileTransferStore } from "@/stores/fileTransferStore";
|
||||
import type { CustomFile } from "@/types/webrtc";
|
||||
|
||||
class WebRTCService {
|
||||
public sender: WebRTC_Initiator;
|
||||
@@ -19,10 +18,13 @@ class WebRTCService {
|
||||
private static instance: WebRTCService;
|
||||
|
||||
private constructor() {
|
||||
const apiUrl = (config.API_URL || "").trim();
|
||||
// Use same-origin when API_URL is empty string — socket.io accepts empty string for same-origin
|
||||
const signalingServer: string = apiUrl.length > 0 ? apiUrl : "";
|
||||
const webRTCConfig = {
|
||||
iceServers: getIceServers(),
|
||||
socketOptions: getSocketOptions() || {},
|
||||
signalingServer: config.API_URL,
|
||||
signalingServer,
|
||||
};
|
||||
|
||||
this.sender = new WebRTC_Initiator(webRTCConfig);
|
||||
@@ -41,72 +43,73 @@ class WebRTCService {
|
||||
}
|
||||
|
||||
private initializeEventHandlers(): void {
|
||||
// 发送方事件处理
|
||||
// Sender event handling
|
||||
this.sender.onConnectionStateChange = (state, peerId) => {
|
||||
console.log(`[WebRTC Service] Sender connection state: ${state} for peer ${peerId}`);
|
||||
|
||||
useFileTransferStore.getState().setShareConnectionState(state as any);
|
||||
useFileTransferStore
|
||||
.getState()
|
||||
.setSharePeerCount(this.sender.peerConnections.size);
|
||||
|
||||
if (state === "connected") {
|
||||
// update share peer count
|
||||
useFileTransferStore.getState().setSharePeerCount(this.sender.peerConnections.size);
|
||||
console.log(`[WebRTC Service] Sender connected, peer count: ${this.sender.peerConnections.size}`);
|
||||
|
||||
this.fileSender.setProgressCallback((fileId, progress, speed) => {
|
||||
useFileTransferStore
|
||||
.getState()
|
||||
.updateSendProgress(fileId, peerId, { progress, speed });
|
||||
}, peerId);
|
||||
} else if (state === "failed" || state === "closed") {
|
||||
this.handleConnectionDisconnect(peerId, true, `CONNECTION_${state.toUpperCase()}`);
|
||||
}
|
||||
};
|
||||
|
||||
this.sender.onDataChannelOpen = (peerId) => {
|
||||
this.sender.onDataChannelOpen = (_peerId) => {
|
||||
useFileTransferStore.getState().setIsSenderInRoom(true);
|
||||
// 自动广播当前内容
|
||||
// Automatically broadcast current content
|
||||
this.broadcastDataToAllPeers();
|
||||
};
|
||||
|
||||
this.sender.onPeerDisconnected = (peerId) => {
|
||||
setTimeout(() => {
|
||||
useFileTransferStore
|
||||
.getState()
|
||||
.setSharePeerCount(this.sender.peerConnections.size);
|
||||
}, 0);
|
||||
console.log(`[WebRTC Service] Sender peer disconnected: ${peerId}`);
|
||||
this.handleConnectionDisconnect(peerId, true, "PEER_DISCONNECTED");
|
||||
};
|
||||
|
||||
this.sender.onError = (error) => {
|
||||
console.error("[WebRTC Service] 发送方错误:", error.message);
|
||||
console.error("[WebRTC Service] Sender error:", error.message);
|
||||
// Clear all states on error
|
||||
this.clearAllTransferProgress();
|
||||
};
|
||||
|
||||
// 接收方事件处理
|
||||
// Receiver event handling
|
||||
this.receiver.onConnectionStateChange = (state, peerId) => {
|
||||
console.log(`[WebRTC Service] Receiver connection state: ${state} for peer ${peerId}`);
|
||||
|
||||
useFileTransferStore.getState().setRetrieveConnectionState(state as any);
|
||||
useFileTransferStore
|
||||
.getState()
|
||||
.setRetrievePeerCount(this.receiver.peerConnections.size);
|
||||
|
||||
if (state === "connected") {
|
||||
// update retrieve peer count
|
||||
useFileTransferStore.getState().setRetrievePeerCount(this.receiver.peerConnections.size);
|
||||
console.log(`[WebRTC Service] Receiver connected, peer count: ${this.receiver.peerConnections.size}`);
|
||||
|
||||
this.fileReceiver.setProgressCallback((fileId, progress, speed) => {
|
||||
useFileTransferStore
|
||||
.getState()
|
||||
.updateReceiveProgress(fileId, peerId, { progress, speed });
|
||||
});
|
||||
} else if (state === "failed" || state === "disconnected") {
|
||||
const { isAnyFileTransferring } = useFileTransferStore.getState();
|
||||
if (isAnyFileTransferring) {
|
||||
this.fileReceiver.gracefulShutdown();
|
||||
}
|
||||
} else if (state === "failed" || state === "closed") {
|
||||
this.handleConnectionDisconnect(peerId, false, `CONNECTION_${state.toUpperCase()}`);
|
||||
}
|
||||
};
|
||||
|
||||
this.receiver.onConnectionEstablished = (peerId) => {
|
||||
const store = useFileTransferStore.getState();
|
||||
this.fileSender.handlePeerReconnection(peerId);
|
||||
useFileTransferStore.getState().setSenderDisconnected(false);
|
||||
useFileTransferStore.getState().setIsReceiverInRoom(true);
|
||||
};
|
||||
|
||||
this.receiver.onPeerDisconnected = (peerId) => {
|
||||
const store = useFileTransferStore.getState();
|
||||
|
||||
useFileTransferStore.getState().setSenderDisconnected(true);
|
||||
useFileTransferStore.getState().setRetrievePeerCount(0);
|
||||
console.log(`[WebRTC Service] Receiver peer disconnected: ${peerId}`);
|
||||
this.handleConnectionDisconnect(peerId, false, "PEER_DISCONNECTED");
|
||||
};
|
||||
|
||||
this.fileReceiver.onStringReceived = (data) => {
|
||||
@@ -123,10 +126,10 @@ class WebRTCService {
|
||||
};
|
||||
|
||||
this.fileReceiver.onFileReceived = async (file) => {
|
||||
// 🔧 增强修复:确保Store状态更新完全同步,使用多重验证
|
||||
// 🔧 Enhanced fix: Ensure Store state updates are fully synchronized with multiple verifications
|
||||
const store = useFileTransferStore.getState();
|
||||
|
||||
// 检查文件是否已经存在,避免重复添加
|
||||
// Check if file already exists to avoid duplicates
|
||||
const existingFile = store.retrievedFiles.find(
|
||||
(f) => f.name === file.name && f.size === file.size
|
||||
);
|
||||
@@ -134,31 +137,17 @@ class WebRTCService {
|
||||
if (!existingFile) {
|
||||
store.addRetrievedFile(file);
|
||||
}
|
||||
|
||||
// 🔧 额外确保:立即验证状态更新是否成功,并重试机制
|
||||
let verificationAttempts = 0;
|
||||
const maxVerificationAttempts = 3;
|
||||
|
||||
const verifyFileAdded = () => {
|
||||
verificationAttempts++;
|
||||
const updatedStore = useFileTransferStore.getState();
|
||||
const fileExists = updatedStore.retrievedFiles.some(
|
||||
(f) => f.name === file.name && f.size === file.size
|
||||
);
|
||||
|
||||
if (!fileExists && verificationAttempts < maxVerificationAttempts) {
|
||||
updatedStore.addRetrievedFile(file);
|
||||
setTimeout(verifyFileAdded, 10);
|
||||
}
|
||||
};
|
||||
|
||||
// 立即进行第一次验证
|
||||
verifyFileAdded();
|
||||
};
|
||||
}
|
||||
|
||||
// 业务方法
|
||||
// Business methods
|
||||
public async joinRoom(roomId: string, isSender: boolean): Promise<void> {
|
||||
// Ensure clean state before joining
|
||||
if (!isSender) {
|
||||
// Force reset FileReceiver state to prevent "already in progress" errors
|
||||
this.fileReceiver.forceReset();
|
||||
}
|
||||
|
||||
const peer = isSender ? this.sender : this.receiver;
|
||||
await peer.joinRoom(roomId, isSender);
|
||||
|
||||
@@ -168,12 +157,17 @@ class WebRTCService {
|
||||
setInRoom(true);
|
||||
}
|
||||
|
||||
|
||||
public async leaveRoom(isSender: boolean): Promise<void> {
|
||||
if (isSender) {
|
||||
// Clean up sender
|
||||
this.fileSender.cleanup();
|
||||
await this.sender.leaveRoomAndCleanup();
|
||||
useFileTransferStore.getState().setIsSenderInRoom(false);
|
||||
useFileTransferStore.getState().setSharePeerCount(0);
|
||||
} else {
|
||||
// Clean up receiver - force reset to ensure complete cleanup
|
||||
this.fileReceiver.forceReset();
|
||||
await this.receiver.leaveRoomAndCleanup();
|
||||
useFileTransferStore.getState().setIsReceiverInRoom(false);
|
||||
useFileTransferStore.getState().setRetrievePeerCount(0);
|
||||
@@ -184,7 +178,7 @@ class WebRTCService {
|
||||
const { shareContent, sendFiles } = useFileTransferStore.getState();
|
||||
const peerIds = Array.from(this.sender.peerConnections.keys());
|
||||
if (peerIds.length === 0) {
|
||||
console.warn("[WebRTC Service] 没有连接的对等端进行广播");
|
||||
console.warn("[WebRTC Service] No connected peers to broadcast to");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -201,7 +195,7 @@ class WebRTCService {
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[WebRTC Service] 广播失败:", error);
|
||||
console.error("[WebRTC Service] Broadcast failed:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -224,20 +218,116 @@ class WebRTCService {
|
||||
return this.fileReceiver.saveType;
|
||||
}
|
||||
|
||||
public manualSafeSave(): void {
|
||||
this.fileReceiver.gracefulShutdown();
|
||||
private handleConnectionDisconnect(peerId: string, isSender: boolean, reason: string): void {
|
||||
console.log(`[WebRTC Service] Connection disconnect: ${reason}, peer: ${peerId}, sender: ${isSender}`);
|
||||
|
||||
// Immediately clean up the transfer status to avoid UI freezing
|
||||
this.immediateTransferCleanup(peerId, isSender, reason);
|
||||
|
||||
// update connection state
|
||||
this.updateConnectionState(peerId, isSender);
|
||||
}
|
||||
|
||||
// Immediately clean up the transfer status
|
||||
private immediateTransferCleanup(peerId: string, isSender: boolean, reason: string): void {
|
||||
const store = useFileTransferStore.getState();
|
||||
|
||||
if (isSender) {
|
||||
// Sender disconnected: clean up the sender related status
|
||||
this.clearPeerTransferProgress(peerId, true);
|
||||
} else {
|
||||
// Receiver side: sender disconnected, need to clean up the receiver status
|
||||
const { isAnyFileTransferring } = store;
|
||||
|
||||
if (isAnyFileTransferring) {
|
||||
console.log(`[WebRTC Service] Force cleaning receiver due to sender disconnect: ${reason}`);
|
||||
|
||||
// Catch the error that gracefulShutdown may throw
|
||||
try {
|
||||
this.fileReceiver.gracefulShutdown(`SENDER_${reason}`);
|
||||
} catch (error) {
|
||||
console.log(`[WebRTC Service] Expected error during graceful shutdown:`, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
this.clearPeerTransferProgress(peerId, false);
|
||||
}
|
||||
}
|
||||
|
||||
// update connection state
|
||||
private updateConnectionState(_peerId: string, isSender: boolean): void {
|
||||
const store = useFileTransferStore.getState();
|
||||
|
||||
if (isSender) {
|
||||
// Sender disconnected: clean up the sender related status
|
||||
const currentShareCount = store.sharePeerCount;
|
||||
store.setSharePeerCount(Math.max(0, currentShareCount - 1));
|
||||
console.log(`[WebRTC Service] Sender peer count: ${currentShareCount} → ${Math.max(0, currentShareCount - 1)}`);
|
||||
} else {
|
||||
// Receiver side: sender disconnected, need to clean up the receiver status
|
||||
store.setRetrievePeerCount(0);
|
||||
store.setSenderDisconnected(true);
|
||||
console.log(`[WebRTC Service] Receiver peer count set to 0`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all transfer progress
|
||||
private clearAllTransferProgress(): void {
|
||||
const store = useFileTransferStore.getState();
|
||||
store.setSendProgress({});
|
||||
store.setReceiveProgress({});
|
||||
store.setIsAnyFileTransferring(false);
|
||||
console.log(`[WebRTC Service] Cleared all transfer progress`);
|
||||
}
|
||||
|
||||
private clearPeerTransferProgress(peerId: string, isSender: boolean): void {
|
||||
const store = useFileTransferStore.getState();
|
||||
const progressState = isSender ? store.sendProgress : store.receiveProgress;
|
||||
|
||||
// Clear transfer progress for this peer
|
||||
const newProgress = { ...progressState };
|
||||
Object.keys(newProgress).forEach((fileId) => {
|
||||
if (newProgress[fileId][peerId]) {
|
||||
delete newProgress[fileId][peerId];
|
||||
// If no other peers are transferring this file, remove the file record
|
||||
if (Object.keys(newProgress[fileId]).length === 0) {
|
||||
delete newProgress[fileId];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (isSender) {
|
||||
store.setSendProgress(newProgress);
|
||||
} else {
|
||||
store.setReceiveProgress(newProgress);
|
||||
}
|
||||
|
||||
// Recalculate isAnyFileTransferring status
|
||||
const allProgress = [
|
||||
...Object.values(isSender ? newProgress : store.sendProgress),
|
||||
...Object.values(isSender ? store.receiveProgress : newProgress),
|
||||
];
|
||||
const hasActiveTransfers = allProgress.some((fileProgress: any) => {
|
||||
return Object.values(fileProgress).some((progress: any) => {
|
||||
return progress.progress > 0 && progress.progress < 1;
|
||||
});
|
||||
});
|
||||
|
||||
if (!hasActiveTransfers) {
|
||||
store.setIsAnyFileTransferring(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async cleanup(): Promise<void> {
|
||||
console.log("[WebRTC Service] 开始清理...");
|
||||
console.log("[WebRTC Service] Starting cleanup...");
|
||||
try {
|
||||
await Promise.all([
|
||||
this.sender.cleanUpBeforeExit(),
|
||||
this.receiver.cleanUpBeforeExit(),
|
||||
]);
|
||||
console.log("[WebRTC Service] 清理完成");
|
||||
console.log("[WebRTC Service] Cleanup completed");
|
||||
} catch (error) {
|
||||
console.error("[WebRTC Service] 清理过程中出错:", error);
|
||||
console.error("[WebRTC Service] Error during cleanup:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Initiator flow: Join room; receive 'ready' event (this event is triggered by the socket server after a new recipient enters) -> createPeerConnection + createDataChannel -> createAndSendOffer
|
||||
import BaseWebRTC, { WebRTCConfig } from "./webrtc_base";
|
||||
import { postLogToBackend } from "@/app/config/api";
|
||||
const developmentEnv = process.env.NEXT_PUBLIC_development!; // Development environment
|
||||
const developmentEnv = process.env.NODE_ENV; // Development environment
|
||||
|
||||
export default class WebRTC_Initiator extends BaseWebRTC {
|
||||
constructor(config: WebRTCConfig) {
|
||||
@@ -16,7 +16,7 @@ export default class WebRTC_Initiator extends BaseWebRTC {
|
||||
});
|
||||
// Add listener for recipient's response
|
||||
this.socket.on("recipient-ready", ({ peerId }) => {
|
||||
if (developmentEnv === "true")
|
||||
if (developmentEnv === "development")
|
||||
postLogToBackend(
|
||||
`[Initiator] Received recipient-ready from: ${peerId}`
|
||||
);
|
||||
@@ -32,7 +32,7 @@ export default class WebRTC_Initiator extends BaseWebRTC {
|
||||
private async handleReady({ peerId }: { peerId: string }): Promise<void> {
|
||||
// Recipient peerId
|
||||
// this.log('log',`Received ready signal from peer ${peerId}`);
|
||||
if (developmentEnv === "true")
|
||||
if (developmentEnv === "development")
|
||||
postLogToBackend(`Received ready signal from peer ${peerId}`);
|
||||
await this.createPeerConnection(peerId);
|
||||
await this.createDataChannel(peerId);
|
||||
@@ -48,7 +48,7 @@ export default class WebRTC_Initiator extends BaseWebRTC {
|
||||
from: string;
|
||||
}): Promise<void> {
|
||||
// this.log('log',`Handling answer from peer ${from}`);
|
||||
if (developmentEnv === "true")
|
||||
if (developmentEnv === "development")
|
||||
postLogToBackend(`Handling answer from peer ${from}`);
|
||||
const peerConnection = this.peerConnections.get(from);
|
||||
if (!peerConnection) {
|
||||
@@ -77,14 +77,16 @@ export default class WebRTC_Initiator extends BaseWebRTC {
|
||||
try {
|
||||
const dataChannel = peerConnection.createDataChannel("dataChannel", {
|
||||
ordered: true,
|
||||
// reliable: true
|
||||
});
|
||||
// this.log('log', `Created data channel for peer ${peerId}`);
|
||||
|
||||
dataChannel.bufferedAmountLowThreshold = 262144; //256 KB -- 可以根据需要调整
|
||||
dataChannel.bufferedAmountLowThreshold = 262144; // 256KB for others
|
||||
|
||||
this.setupDataChannel(dataChannel, peerId);
|
||||
this.dataChannels.set(peerId, dataChannel);
|
||||
} catch (error) {
|
||||
postLogToBackend(
|
||||
`Error creating DataChannel - peer: ${peerId}, error: ${error}`
|
||||
);
|
||||
this.fireError(`Error creating data channel for peer ${peerId}`, {
|
||||
error,
|
||||
peerId,
|
||||
@@ -94,7 +96,7 @@ export default class WebRTC_Initiator extends BaseWebRTC {
|
||||
// If it is the initiator, create and send an offer to the signaling server to negotiate a connection with the recipient.
|
||||
private async createAndSendOffer(peerId: string): Promise<void> {
|
||||
// this.log('log', `Creating and sending offer to ${peerId}`);
|
||||
if (developmentEnv === "true")
|
||||
if (developmentEnv === "development")
|
||||
postLogToBackend(`createAndSendOffer for peerId: ${peerId}`);
|
||||
const peerConnection = this.peerConnections.get(peerId);
|
||||
if (!peerConnection) {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// Recipient flow: Join room; receive 'offer' event -> createPeerConnection + createDataChannel -> send answer
|
||||
import BaseWebRTC, { WebRTCConfig } from "./webrtc_base";
|
||||
import { postLogToBackend } from "@/app/config/api";
|
||||
const developmentEnv = process.env.NEXT_PUBLIC_development!; // Development environment
|
||||
|
||||
interface AnswerPayload {
|
||||
answer: RTCSessionDescriptionInit;
|
||||
@@ -106,7 +104,6 @@ export default class WebRTC_Recipient extends BaseWebRTC {
|
||||
}
|
||||
|
||||
peerConnection.ondatachannel = (event) => {
|
||||
// this.log('log', `Received data channel from peer ${peerId}`);
|
||||
this.setupDataChannel(event.channel, peerId);
|
||||
this.dataChannels.set(peerId, event.channel);
|
||||
};
|
||||
|
||||
+126
-15
@@ -2,7 +2,7 @@
|
||||
import io, { Socket, ManagerOptions, SocketOptions } from "socket.io-client";
|
||||
import { WakeLockManager } from "./wakeLockManager";
|
||||
import { postLogToBackend } from "@/app/config/api";
|
||||
const developmentEnv = process.env.NEXT_PUBLIC_development!; // Development environment
|
||||
const developmentEnv = process.env.NODE_ENV; // Development environment
|
||||
|
||||
export class WebRTCError extends Error {
|
||||
constructor(message: string, public context?: Record<string, any>) {
|
||||
@@ -64,6 +64,8 @@ export default class BaseWebRTC {
|
||||
protected isPeerDisconnected: boolean; // Tracks P2P connection status
|
||||
protected reconnectionInProgress: boolean; // Prevents duplicate reconnections
|
||||
protected wakeLockManager: WakeLockManager;
|
||||
// Graceful disconnect tracking
|
||||
protected gracefullyDisconnectedPeers: Set<string>;
|
||||
|
||||
constructor(config: WebRTCConfig) {
|
||||
this.iceServers = config.iceServers;
|
||||
@@ -83,6 +85,7 @@ export default class BaseWebRTC {
|
||||
this.roomId = null;
|
||||
this.peerId = null; // Own ID
|
||||
this.isInRoom = false; // Whether the user has already joined a room
|
||||
this.gracefullyDisconnectedPeers = new Set(); // Track peers that disconnected gracefully
|
||||
this.setupCommonSocketListeners();
|
||||
|
||||
this.isInitiator = false;
|
||||
@@ -128,7 +131,7 @@ export default class BaseWebRTC {
|
||||
this.socket.on("disconnect", () => {
|
||||
this.isInRoom = false;
|
||||
this.isSocketDisconnected = true;
|
||||
if (developmentEnv === "true")
|
||||
if (developmentEnv === "development")
|
||||
postLogToBackend(
|
||||
`${this.peerId} disconnect on socket,isInitiator:${this.isInitiator},isInRoom:${this.isInRoom}`
|
||||
);
|
||||
@@ -150,7 +153,7 @@ export default class BaseWebRTC {
|
||||
if (this.isSocketDisconnected && this.isPeerDisconnected && this.roomId) {
|
||||
// Start reconnection only after both socket and P2P connections are disconnected
|
||||
this.reconnectionInProgress = true;
|
||||
if (developmentEnv === "true") {
|
||||
if (developmentEnv === "development") {
|
||||
postLogToBackend(
|
||||
`Starting reconnection, socket and peer both disconnected. isInitiator:${this.isInitiator}`
|
||||
);
|
||||
@@ -311,7 +314,7 @@ export default class BaseWebRTC {
|
||||
disconnected: async () => {
|
||||
await this.cleanupExistingConnection(peerId);
|
||||
this.isPeerDisconnected = true;
|
||||
if (developmentEnv === "true")
|
||||
if (developmentEnv === "development")
|
||||
postLogToBackend(`p2p disconnected, isInitiator:${this.isInitiator}`);
|
||||
// Attempt to reconnect
|
||||
this.attemptReconnection();
|
||||
@@ -352,11 +355,60 @@ export default class BaseWebRTC {
|
||||
};
|
||||
|
||||
dataChannel.onmessage = (event) => {
|
||||
this.onDataReceived?.(event.data, peerId);
|
||||
// Enhanced data type detection - supports multiple binary data formats in Firefox
|
||||
let dataType = "Unknown";
|
||||
let dataSize = 0;
|
||||
|
||||
if (typeof event.data === "string") {
|
||||
dataType = "String";
|
||||
dataSize = event.data.length;
|
||||
} else if (event.data instanceof ArrayBuffer) {
|
||||
dataType = "ArrayBuffer";
|
||||
dataSize = event.data.byteLength;
|
||||
} else if (event.data instanceof Blob) {
|
||||
dataType = "Blob";
|
||||
dataSize = event.data.size;
|
||||
} else if (event.data instanceof Uint8Array) {
|
||||
dataType = "Uint8Array";
|
||||
dataSize = event.data.byteLength;
|
||||
} else if (ArrayBuffer.isView(event.data)) {
|
||||
dataType = "TypedArray";
|
||||
dataSize = event.data.byteLength;
|
||||
} else {
|
||||
// Detailed unknown type debug information
|
||||
dataType = `Unknown(${Object.prototype.toString.call(event.data)})`;
|
||||
dataSize =
|
||||
event.data?.length || event.data?.size || event.data?.byteLength || 0;
|
||||
}
|
||||
|
||||
if (this.onDataReceived) {
|
||||
this.onDataReceived(event.data, peerId);
|
||||
}
|
||||
};
|
||||
|
||||
dataChannel.onclose = () =>
|
||||
dataChannel.onerror = (error) => {
|
||||
// Check if this is a user-initiated disconnect (not a real error)
|
||||
// The error parameter is an Event object, not an Error object
|
||||
const errorTarget = error.target as RTCDataChannel;
|
||||
const isUserDisconnect =
|
||||
errorTarget?.readyState === "closed" ||
|
||||
error.type === "error";
|
||||
|
||||
if (isUserDisconnect) {
|
||||
this.log("log", `Data channel closed by user for peer ${peerId}`, {
|
||||
error,
|
||||
});
|
||||
} else {
|
||||
this.log("error", `Data channel error for peer ${peerId}`, { error });
|
||||
}
|
||||
};
|
||||
|
||||
dataChannel.onclose = () => {
|
||||
if (developmentEnv === "development") {
|
||||
postLogToBackend(`DataChannel closed for peer: ${peerId}`);
|
||||
}
|
||||
this.log("log", `Data channel with ${peerId} closed.`);
|
||||
};
|
||||
}
|
||||
// Join a room. sendInitiatorOnline indicates whether to send "initiator online" message after joining.
|
||||
public async joinRoom(
|
||||
@@ -390,7 +442,7 @@ export default class BaseWebRTC {
|
||||
roomId: this.roomId,
|
||||
});
|
||||
}
|
||||
if (developmentEnv === "true")
|
||||
if (developmentEnv === "development")
|
||||
postLogToBackend(
|
||||
`peerId:${this.socket.id} Successfully joined room: ${response.roomId},isInitiator:${this.isInitiator},isInRoom:${this.isInRoom}`
|
||||
);
|
||||
@@ -398,7 +450,7 @@ export default class BaseWebRTC {
|
||||
} else {
|
||||
this.isInRoom = false;
|
||||
this.roomId = null;
|
||||
if (developmentEnv === "true")
|
||||
if (developmentEnv === "development")
|
||||
postLogToBackend(`Failed to join room,message:${response.message}`);
|
||||
this.fireError("Failed to join room", { message: response.message });
|
||||
reject(new Error(response.message));
|
||||
@@ -431,26 +483,75 @@ export default class BaseWebRTC {
|
||||
}
|
||||
}
|
||||
// Send to a specific peer
|
||||
protected sendToPeer(data: any, peerId: string): boolean {
|
||||
public sendToPeer(data: any, peerId: string): boolean {
|
||||
const dataChannel = this.dataChannels.get(peerId);
|
||||
if (dataChannel?.readyState === "open") {
|
||||
dataChannel.send(data);
|
||||
return true;
|
||||
try {
|
||||
// Firefox compatibility debugging: Log sending details
|
||||
const _dataType =
|
||||
typeof data === "string"
|
||||
? "string"
|
||||
: data instanceof ArrayBuffer
|
||||
? "ArrayBuffer"
|
||||
: typeof data;
|
||||
const _dataSize =
|
||||
typeof data === "string"
|
||||
? data.length
|
||||
: data instanceof ArrayBuffer
|
||||
? data.byteLength
|
||||
: 0;
|
||||
|
||||
// if (developmentEnv === "development")
|
||||
// postLogToBackend(
|
||||
// `sendToPeer - type: ${dataType}, size: ${dataSize}, bufferedAmount: ${dataChannel.bufferedAmount}`
|
||||
// );
|
||||
|
||||
dataChannel.send(data);
|
||||
return true;
|
||||
} catch (error) {
|
||||
postLogToBackend(`sendToPeer error: ${error}`);
|
||||
this.log("error", `Error sending data to peer ${peerId}`, { error });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
postLogToBackend(
|
||||
`DataChannel not ready - peerId: ${peerId}, state: ${
|
||||
dataChannel?.readyState || "undefined"
|
||||
}`
|
||||
);
|
||||
this.log("warn", `Data channel not ready for peer ${peerId}. Retrying...`);
|
||||
this.retryDataSend(data, peerId);
|
||||
return false;
|
||||
return this.retryDataSend(data, peerId);
|
||||
}
|
||||
|
||||
protected retryDataSend(data: any, peerId: string): void {
|
||||
protected retryDataSend(data: any, peerId: string): boolean {
|
||||
// Check if peer has gracefully disconnected - no need to retry
|
||||
if (this.gracefullyDisconnectedPeers.has(peerId)) {
|
||||
this.log(
|
||||
"log",
|
||||
`Peer ${peerId} has gracefully disconnected, skipping retry`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const maxRetries = 5;
|
||||
let retryCount = 0;
|
||||
let ret = false;
|
||||
|
||||
const attemptSend = () => {
|
||||
// Check again in case peer disconnected during retry
|
||||
if (this.gracefullyDisconnectedPeers.has(peerId)) {
|
||||
this.log(
|
||||
"log",
|
||||
`Peer ${peerId} gracefully disconnected during retry, stopping`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const dataChannel = this.dataChannels.get(peerId);
|
||||
if (dataChannel?.readyState === "open") {
|
||||
dataChannel.send(data);
|
||||
ret = true;
|
||||
} else if (retryCount < maxRetries) {
|
||||
retryCount++;
|
||||
this.log(
|
||||
@@ -466,6 +567,15 @@ export default class BaseWebRTC {
|
||||
};
|
||||
|
||||
setTimeout(attemptSend, 100);
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a peer as gracefully disconnected to prevent unnecessary retries
|
||||
*/
|
||||
public markPeerGracefullyDisconnected(peerId: string): void {
|
||||
this.gracefullyDisconnectedPeers.add(peerId);
|
||||
this.log("log", `Marked peer ${peerId} as gracefully disconnected`);
|
||||
}
|
||||
|
||||
protected async closeDataChannel(peerId: string): Promise<void> {
|
||||
@@ -512,6 +622,7 @@ export default class BaseWebRTC {
|
||||
this.isPeerDisconnected = false;
|
||||
this.isSocketDisconnected = false;
|
||||
this.reconnectionInProgress = false;
|
||||
this.gracefullyDisconnectedPeers.clear(); // Clear graceful disconnect tracking
|
||||
|
||||
this.log(
|
||||
"log",
|
||||
@@ -519,7 +630,7 @@ export default class BaseWebRTC {
|
||||
);
|
||||
}
|
||||
// Abstract method declaration
|
||||
protected createDataChannel(peerId: string) {
|
||||
protected createDataChannel(_peerId: string) {
|
||||
throw new Error("createDataChannel must be implemented by subclass");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ const withMDX = createMDX({
|
||||
const nextConfig = {
|
||||
pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'],
|
||||
images: {
|
||||
// Disable optimization inside Docker to avoid container loopback fetch failures (502)
|
||||
unoptimized: process.env.NEXT_IMAGE_UNOPTIMIZED === 'true',
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
@@ -20,7 +22,12 @@ const nextConfig = {
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
// 启用standalone输出模式,用于Docker部署
|
||||
output: 'standalone',
|
||||
// 禁用telemetry
|
||||
experimental: {
|
||||
instrumentationHook: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default withMDX(nextConfig);
|
||||
export default withMDX(nextConfig);
|
||||
|
||||
+10
-10
@@ -5,7 +5,7 @@
|
||||
"scripts": {
|
||||
"dev": "next dev -H 0.0.0.0 -p 3002",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000",
|
||||
"start": "next start -p 3002",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -24,8 +24,8 @@
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/negotiator": "^0.6.3",
|
||||
"@types/node": "^20",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/node": "^20.14.13",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/unist": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -39,8 +39,8 @@
|
||||
"next-mdx-remote": "^5.0.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"qrcode.react": "^4.0.1",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-intersection-observer": "^9.16.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"sharp": "^0.33.5",
|
||||
@@ -52,11 +52,11 @@
|
||||
"zustand": "^5.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.18",
|
||||
"eslint": "^8",
|
||||
"@types/react": "^18.3.22",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "14.2.5",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
"postcss": "^8.4.40",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
+13
-13
@@ -54,10 +54,10 @@ importers:
|
||||
specifier: ^0.6.3
|
||||
version: 0.6.3
|
||||
'@types/node':
|
||||
specifier: ^20
|
||||
specifier: ^20.14.13
|
||||
version: 20.14.13
|
||||
'@types/react-dom':
|
||||
specifier: ^18
|
||||
specifier: ^18.3.0
|
||||
version: 18.3.0
|
||||
'@types/unist':
|
||||
specifier: ^3.0.3
|
||||
@@ -99,10 +99,10 @@ importers:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1(react@18.3.1)
|
||||
react:
|
||||
specifier: ^18
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1
|
||||
react-dom:
|
||||
specifier: ^18
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1(react@18.3.1)
|
||||
react-intersection-observer:
|
||||
specifier: ^9.16.0
|
||||
@@ -133,22 +133,22 @@ importers:
|
||||
version: 5.0.7(@types/react@18.3.22)(react@18.3.1)
|
||||
devDependencies:
|
||||
'@types/react':
|
||||
specifier: ^18.3.18
|
||||
specifier: ^18.3.22
|
||||
version: 18.3.22
|
||||
eslint:
|
||||
specifier: ^8
|
||||
specifier: ^8.57.0
|
||||
version: 8.57.0
|
||||
eslint-config-next:
|
||||
specifier: 14.2.5
|
||||
version: 14.2.5(eslint@8.57.0)(typescript@5.5.4)
|
||||
postcss:
|
||||
specifier: ^8
|
||||
specifier: ^8.4.40
|
||||
version: 8.4.40
|
||||
tailwindcss:
|
||||
specifier: ^3.4.1
|
||||
specifier: ^3.4.7
|
||||
version: 3.4.7
|
||||
typescript:
|
||||
specifier: ^5
|
||||
specifier: ^5.5.4
|
||||
version: 5.5.4
|
||||
|
||||
packages:
|
||||
@@ -1367,8 +1367,8 @@ packages:
|
||||
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
caniuse-lite@1.0.30001735:
|
||||
resolution: {integrity: sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w==}
|
||||
caniuse-lite@1.0.30001745:
|
||||
resolution: {integrity: sha512-ywt6i8FzvdgrrrGbr1jZVObnVv6adj+0if2/omv9cmR2oiZs30zL4DIyaptKcbOrBdOIc74QTMoJvSE2QHh5UQ==}
|
||||
|
||||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
@@ -4730,7 +4730,7 @@ snapshots:
|
||||
|
||||
camelcase-css@2.0.1: {}
|
||||
|
||||
caniuse-lite@1.0.30001735: {}
|
||||
caniuse-lite@1.0.30001745: {}
|
||||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
@@ -6552,7 +6552,7 @@ snapshots:
|
||||
'@next/env': 14.2.5
|
||||
'@swc/helpers': 0.5.5
|
||||
busboy: 1.6.0
|
||||
caniuse-lite: 1.0.30001735
|
||||
caniuse-lite: 1.0.30001745
|
||||
graceful-fs: 4.2.11
|
||||
postcss: 8.4.31
|
||||
react: 18.3.1
|
||||
|
||||
@@ -2,14 +2,14 @@ import { create } from "zustand";
|
||||
import { CustomFile, FileMeta } from "@/types/webrtc";
|
||||
|
||||
interface FileTransferState {
|
||||
// 房间相关状态
|
||||
// Room-related state
|
||||
shareRoomId: string;
|
||||
initShareRoomId: string;
|
||||
shareLink: string;
|
||||
shareRoomStatusText: string;
|
||||
retrieveRoomStatusText: string;
|
||||
|
||||
// WebRTC 连接状态 - 发送方
|
||||
// WebRTC connection state - Sender
|
||||
shareConnectionState:
|
||||
| "idle"
|
||||
| "connecting"
|
||||
@@ -19,7 +19,7 @@ interface FileTransferState {
|
||||
isSenderInRoom: boolean;
|
||||
sharePeerCount: number;
|
||||
|
||||
// WebRTC 连接状态 - 接收方
|
||||
// WebRTC connection state - Receiver
|
||||
retrieveConnectionState:
|
||||
| "idle"
|
||||
| "connecting"
|
||||
@@ -30,36 +30,36 @@ interface FileTransferState {
|
||||
retrievePeerCount: number;
|
||||
senderDisconnected: boolean;
|
||||
|
||||
// 文件传输状态
|
||||
// File transfer state
|
||||
shareContent: string;
|
||||
sendFiles: CustomFile[];
|
||||
retrievedContent: string;
|
||||
retrievedFiles: CustomFile[];
|
||||
retrievedFileMetas: FileMeta[];
|
||||
|
||||
// 传输进度状态
|
||||
// Transfer progress state
|
||||
sendProgress: Record<string, any>;
|
||||
receiveProgress: Record<string, any>;
|
||||
isAnyFileTransferring: boolean;
|
||||
|
||||
// UI 状态
|
||||
// UI state
|
||||
activeTab: "send" | "retrieve";
|
||||
retrieveRoomIdInput: string;
|
||||
isDragging: boolean;
|
||||
|
||||
// 消息状态
|
||||
// Message state
|
||||
shareMessage: string;
|
||||
retrieveMessage: string;
|
||||
|
||||
// Actions
|
||||
// 房间相关 actions
|
||||
// Room-related actions
|
||||
setShareRoomId: (id: string) => void;
|
||||
setInitShareRoomId: (id: string) => void;
|
||||
setShareLink: (link: string) => void;
|
||||
setShareRoomStatusText: (text: string) => void;
|
||||
setRetrieveRoomStatusText: (text: string) => void;
|
||||
|
||||
// WebRTC 连接相关 actions
|
||||
// WebRTC connection-related actions
|
||||
setShareConnectionState: (
|
||||
state: "idle" | "connecting" | "connected" | "disconnected" | "failed"
|
||||
) => void;
|
||||
@@ -72,7 +72,7 @@ interface FileTransferState {
|
||||
setRetrievePeerCount: (count: number) => void;
|
||||
setSenderDisconnected: (disconnected: boolean) => void;
|
||||
|
||||
// 文件传输相关 actions
|
||||
// File transfer-related actions
|
||||
setShareContent: (content: string) => void;
|
||||
setSendFiles: (files: CustomFile[]) => void;
|
||||
addSendFiles: (files: CustomFile[]) => void;
|
||||
@@ -82,7 +82,7 @@ interface FileTransferState {
|
||||
setRetrievedFileMetas: (metas: FileMeta[]) => void;
|
||||
addRetrievedFile: (file: CustomFile) => void;
|
||||
|
||||
// 传输进度相关 actions
|
||||
// Transfer progress-related actions
|
||||
setSendProgress: (progress: Record<string, any>) => void;
|
||||
setReceiveProgress: (progress: Record<string, any>) => void;
|
||||
updateSendProgress: (
|
||||
@@ -99,23 +99,23 @@ interface FileTransferState {
|
||||
clearReceiveProgress: (fileId: string, peerId: string) => void;
|
||||
setIsAnyFileTransferring: (transferring: boolean) => void;
|
||||
|
||||
// UI 状态相关 actions
|
||||
// UI state-related actions
|
||||
setActiveTab: (tab: "send" | "retrieve") => void;
|
||||
setRetrieveRoomIdInput: (input: string) => void;
|
||||
setIsDragging: (dragging: boolean) => void;
|
||||
|
||||
// 消息相关 actions
|
||||
// Message-related actions
|
||||
setShareMessage: (message: string) => void;
|
||||
setRetrieveMessage: (message: string) => void;
|
||||
setRetrieveRoomId: (input: string) => void;
|
||||
|
||||
// 重置相关 actions
|
||||
// Reset-related actions
|
||||
resetReceiverState: () => void;
|
||||
resetSenderApp: () => void;
|
||||
}
|
||||
|
||||
export const useFileTransferStore = create<FileTransferState>()((set, get) => ({
|
||||
// 初始状态
|
||||
// Initial state
|
||||
shareRoomId: "",
|
||||
initShareRoomId: "",
|
||||
shareLink: "",
|
||||
@@ -142,14 +142,14 @@ export const useFileTransferStore = create<FileTransferState>()((set, get) => ({
|
||||
shareMessage: "",
|
||||
retrieveMessage: "",
|
||||
|
||||
// Actions 实现
|
||||
// Actions implementation
|
||||
setShareRoomId: (id) => set({ shareRoomId: id }),
|
||||
setInitShareRoomId: (id) => set({ initShareRoomId: id }),
|
||||
setShareLink: (link) => set({ shareLink: link }),
|
||||
setShareRoomStatusText: (text) => set({ shareRoomStatusText: text }),
|
||||
setRetrieveRoomStatusText: (text) => set({ retrieveRoomStatusText: text }),
|
||||
|
||||
// WebRTC 连接相关 actions
|
||||
// WebRTC connection-related actions
|
||||
setShareConnectionState: (state) => set({ shareConnectionState: state }),
|
||||
setIsSenderInRoom: (isInRoom) => set({ isSenderInRoom: isInRoom }),
|
||||
setSharePeerCount: (count) => set({ sharePeerCount: count }),
|
||||
@@ -238,18 +238,11 @@ export const useFileTransferStore = create<FileTransferState>()((set, get) => ({
|
||||
setRetrieveMessage: (message) => set({ retrieveMessage: message }),
|
||||
|
||||
resetReceiverState: () => {
|
||||
// 🔧 清理 FileReceiver 的内部状态(通过 Service 层)
|
||||
try {
|
||||
const { webrtcService } = require("@/lib/webrtcService");
|
||||
webrtcService.fileReceiver.gracefulShutdown();
|
||||
} catch (error) {
|
||||
console.warn(`[DEBUG] ⚠️ 清理 FileReceiver 状态失败:`, error);
|
||||
}
|
||||
|
||||
// 🔧 Only reset Store state - FileReceiver cleanup is handled by webrtcService.leaveRoom()
|
||||
set({
|
||||
retrievedContent: "",
|
||||
retrievedFiles: [],
|
||||
retrievedFileMetas: [], // 清空 Store 中的文件元数据
|
||||
retrievedFileMetas: [], // Clear file metadata in Store
|
||||
retrievePeerCount: 0,
|
||||
senderDisconnected: false,
|
||||
receiveProgress: {},
|
||||
|
||||
@@ -174,9 +174,11 @@ export type FileTransferButton = {
|
||||
CurrentFileTransferring_tips: string;
|
||||
OtherFileTransferring_tips: string;
|
||||
download_tips: string;
|
||||
PendingSave_tips: string;
|
||||
Saved_dis: string;
|
||||
Waiting_dis: string;
|
||||
Download_dis: string;
|
||||
Save_dis: string;
|
||||
};
|
||||
|
||||
export type FileListDisplay = {
|
||||
@@ -191,9 +193,6 @@ export type FileListDisplay = {
|
||||
PopupDialog_description: string;
|
||||
chooseSavePath_tips: string;
|
||||
chooseSavePath_dis: string;
|
||||
safeSave_dis: string;
|
||||
safeSave_tooltip: string;
|
||||
safeSaveSuccessMsg: string;
|
||||
};
|
||||
|
||||
export type RetrieveMethod = {
|
||||
@@ -243,7 +242,8 @@ export type ClipboardAppHtml = {
|
||||
Copy_dis: string;
|
||||
inputRoomIdprompt: string;
|
||||
joinRoomBtn: string;
|
||||
generateRoomId_tips: string;
|
||||
generateSimpleId_tips: string;
|
||||
generateRandomId_tips: string;
|
||||
readClipboardToRoomId: string;
|
||||
enterRoomID_placeholder: string;
|
||||
retrieveMethod: string;
|
||||
@@ -263,12 +263,17 @@ export type ClipboardApp = {
|
||||
waitting_tips: string;
|
||||
joinRoom: JoinRoom;
|
||||
pickSaveMsg: string;
|
||||
pickSaveUnsupported: string;
|
||||
pickSaveSuccess: string;
|
||||
pickSaveError: string;
|
||||
roomStatus: RoomStatus;
|
||||
html: ClipboardAppHtml;
|
||||
fileExistMsg?: string;
|
||||
noFilesForFolderMsg?: string;
|
||||
zipError?: string;
|
||||
fileNotFoundMsg?: string;
|
||||
confirmLeaveWhileTransferring: string;
|
||||
leaveWhileTransferringSuccess: string;
|
||||
};
|
||||
|
||||
export type Home = {
|
||||
@@ -305,4 +310,4 @@ export type Text = {
|
||||
export type Messages = {
|
||||
meta: Meta;
|
||||
text: Text;
|
||||
};
|
||||
};
|
||||
|
||||
+27
-13
@@ -29,11 +29,6 @@ export interface FileRequest {
|
||||
offset?: number; // Optional: byte offset to resume from
|
||||
}
|
||||
|
||||
export interface FileAck {
|
||||
type: "fileAck";
|
||||
fileId: string;
|
||||
}
|
||||
|
||||
export interface StringMetadata {
|
||||
type: "stringMetadata";
|
||||
length: number;
|
||||
@@ -46,24 +41,44 @@ export interface StringChunk {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface FileEnd {
|
||||
type: "fileEnd";
|
||||
// Receiver-initiated completion confirmation message
|
||||
export interface FileReceiveComplete {
|
||||
type: "fileReceiveComplete";
|
||||
fileId: string;
|
||||
receivedSize: number;
|
||||
receivedChunks: number;
|
||||
storeUpdated: boolean; // Confirm Store has been updated
|
||||
}
|
||||
|
||||
export interface FolderComplete {
|
||||
type: "FolderComplete";
|
||||
export interface FolderReceiveComplete {
|
||||
type: "folderReceiveComplete";
|
||||
folderName: string;
|
||||
completedFileIds: string[];
|
||||
allStoreUpdated: boolean; // Confirm all files have been added to Store
|
||||
}
|
||||
|
||||
// 🚀 New: Chunk metadata structure embedded in data packets
|
||||
export interface EmbeddedChunkMeta {
|
||||
chunkIndex: number; // Data chunk index, starting from 0
|
||||
totalChunks: number; // Total number of data chunks
|
||||
chunkSize: number; // Data chunk size (excluding metadata portion)
|
||||
isLastChunk: boolean; // Whether this is the last data chunk
|
||||
fileOffset: number; // Offset in the file
|
||||
fileId: string; // File ID, used for matching
|
||||
}
|
||||
// Note: EmbeddedChunkMeta is not in WebRTCMessage as it is embedded within binary data
|
||||
|
||||
// 🚀 Binary structure of fused packets:
|
||||
// [4 bytes: metadata length] + [JSON metadata] + [actual chunk data]
|
||||
// All file transfers use this format uniformly to completely solve Firefox out-of-order issues
|
||||
|
||||
export type WebRTCMessage =
|
||||
| fileMetadata
|
||||
| FileRequest
|
||||
| FileAck
|
||||
| StringMetadata
|
||||
| StringChunk
|
||||
| FileEnd
|
||||
| FolderComplete;
|
||||
| FileReceiveComplete
|
||||
| FolderReceiveComplete;
|
||||
|
||||
export interface FolderMeta {
|
||||
totalSize: number;
|
||||
@@ -96,5 +111,4 @@ export interface FileHandlers {
|
||||
string: (data: any, peerId: string) => void;
|
||||
stringMetadata: (data: any, peerId: string) => void;
|
||||
fileMeta: (data: any, peerId: string) => void;
|
||||
fileEnd: (data: any) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Color definitions
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Test result counters
|
||||
TESTS_PASSED=0
|
||||
TESTS_FAILED=0
|
||||
TOTAL_TESTS=0
|
||||
|
||||
# Logging helpers
|
||||
log_info() {
|
||||
echo -e "${BLUE}ℹ️ $1${NC}"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}✅ $1${NC}"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}❌ $1${NC}"
|
||||
TESTS_FAILED=$((TESTS_FAILED + 1))
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||
}
|
||||
|
||||
# Test functions
|
||||
test_api() {
|
||||
local url="$1"
|
||||
local description="$2"
|
||||
local expected_status="${3:-200}"
|
||||
|
||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
||||
echo ""
|
||||
log_info "Test: $description"
|
||||
log_info "URL: $url"
|
||||
|
||||
# Send request and capture response
|
||||
response=$(curl -s -w "\n%{http_code}" "$url" 2>/dev/null)
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
log_error "Request failed - unable to connect to service"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Split response body and status code
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
response_body=$(echo "$response" | head -n -1)
|
||||
|
||||
# Check HTTP status code
|
||||
if [ "$http_code" -eq "$expected_status" ]; then
|
||||
log_success "HTTP status code OK: $http_code"
|
||||
else
|
||||
log_error "HTTP status code mismatch: expected $expected_status, got $http_code"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Validate JSON format
|
||||
if echo "$response_body" | jq . >/dev/null 2>&1; then
|
||||
log_success "Response is valid JSON"
|
||||
|
||||
# Pretty-print JSON response
|
||||
echo -e "${BLUE}Response body:${NC}"
|
||||
echo "$response_body" | jq .
|
||||
|
||||
# Verify required fields
|
||||
status=$(echo "$response_body" | jq -r '.status // empty')
|
||||
service=$(echo "$response_body" | jq -r '.service // empty')
|
||||
timestamp=$(echo "$response_body" | jq -r '.timestamp // empty')
|
||||
|
||||
if [ -n "$status" ] && [ -n "$service" ] && [ -n "$timestamp" ]; then
|
||||
log_success "Contains required fields: status, service, timestamp"
|
||||
else
|
||||
log_error "Missing required fields"
|
||||
return 1
|
||||
fi
|
||||
|
||||
else
|
||||
log_error "Response is not valid JSON"
|
||||
echo "Response body: $response_body"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Check if service is running
|
||||
check_service() {
|
||||
local port="$1"
|
||||
local service_name="$2"
|
||||
|
||||
if nc -z localhost "$port" 2>/dev/null; then
|
||||
log_success "$service_name is running (port $port)"
|
||||
return 0
|
||||
else
|
||||
log_error "$service_name is not running (port $port)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Wait for service to start
|
||||
wait_for_service() {
|
||||
local port="$1"
|
||||
local service_name="$2"
|
||||
local max_attempts=30
|
||||
local attempt=0
|
||||
|
||||
log_info "Waiting for $service_name to start..."
|
||||
|
||||
while [ $attempt -lt $max_attempts ]; do
|
||||
if nc -z localhost "$port" 2>/dev/null; then
|
||||
log_success "$service_name started"
|
||||
return 0
|
||||
fi
|
||||
|
||||
attempt=$((attempt + 1))
|
||||
echo -n "."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
log_error "$service_name startup timed out"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Main test function
|
||||
main() {
|
||||
echo -e "${BLUE}=== PrivyDrop Health Check API Tests ===${NC}"
|
||||
echo ""
|
||||
|
||||
# Check required tools
|
||||
if ! command -v curl &> /dev/null; then
|
||||
log_error "curl is not installed; please install curl"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq &> /dev/null; then
|
||||
log_error "jq is not installed; please install jq for JSON parsing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v nc &> /dev/null; then
|
||||
log_error "netcat is not installed; please install nc for port checks"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check service status
|
||||
echo -e "${BLUE}=== Check Service Status ===${NC}"
|
||||
backend_running=false
|
||||
frontend_running=false
|
||||
|
||||
if check_service 3001 "Backend"; then
|
||||
backend_running=true
|
||||
fi
|
||||
|
||||
if check_service 3002 "Frontend"; then
|
||||
frontend_running=true
|
||||
fi
|
||||
|
||||
# Show startup hints if services are not running
|
||||
if [ "$backend_running" = false ]; then
|
||||
echo ""
|
||||
log_warning "Backend is not running; please start it:"
|
||||
echo " cd backend && npm run dev"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
if [ "$frontend_running" = false ]; then
|
||||
echo ""
|
||||
log_warning "Frontend is not running; please start it:"
|
||||
echo " cd frontend && pnpm dev"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Test backend health check APIs
|
||||
if [ "$backend_running" = true ]; then
|
||||
echo -e "${BLUE}=== Test Backend Health Check APIs ===${NC}"
|
||||
|
||||
test_api "http://localhost:3001/health" "Backend basic health check"
|
||||
test_api "http://localhost:3001/api/health" "Backend API path health check"
|
||||
test_api "http://localhost:3001/health/detailed" "Backend detailed health check"
|
||||
fi
|
||||
|
||||
# Test frontend health check APIs
|
||||
if [ "$frontend_running" = true ]; then
|
||||
echo -e "${BLUE}=== Test Frontend Health Check APIs ===${NC}"
|
||||
|
||||
test_api "http://localhost:3002/api/health" "Frontend basic health check"
|
||||
test_api "http://localhost:3002/api/health/detailed" "Frontend detailed health check"
|
||||
fi
|
||||
|
||||
# Test results summary
|
||||
echo ""
|
||||
echo -e "${BLUE}=== Test Results Summary ===${NC}"
|
||||
echo "Total tests: $TOTAL_TESTS"
|
||||
echo -e "Passed: ${GREEN}$TESTS_PASSED${NC}"
|
||||
echo -e "Failed: ${RED}$TESTS_FAILED${NC}"
|
||||
|
||||
if [ $TESTS_FAILED -eq 0 ]; then
|
||||
echo -e "${GREEN}🎉 All tests passed!${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}❌ $TESTS_FAILED test(s) failed${NC}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Trap interrupt signals
|
||||
trap 'echo -e "\n${YELLOW}Tests interrupted${NC}"; exit 1' INT TERM
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user