21 Commits

Author SHA1 Message Date
david_bai 27375c1a4d refactor(theme): use design tokens and fix dark mode visuals
- Replace hardcoded Tailwind colors (bg-white, bg-gray-50/100, text-gray-, border-gray-, divide-gray-*, text-blue-600/800, bg-blue-50) with design tokens (bg-card, bg-muted, text-foreground, text-muted-foreground, border-
    border, text-primary, hover:bg-accent, bg-primary/10).
  - ClipboardApp: update RichTextEditor toolbar/editor, FileUploadHandler, ShareCard, FileListDisplay, SendTabPanel, RetrieveTabPanel, FileTransferButton.
  - Blog UI: unify styles in list page, tag page, post page, ArticleListItem, and TableOfContents.
  - MDX/prose: normalize pre/code/table/blockquote/lists and figure captions; switch rehype table divider to theme token.
  - Misc: adjust HomeClient and HowItWorks copy colors to tokens.
  - No functional changes; light mode parity; improved contrast and consistency in dark mode.
2025-11-25 21:52:45 +08:00
david_bai 17a43ec181 docs(blog): add “Cached ID reconnect” post in 7 languages with cover
- Add multi-language post “Cached ID reconnect: auto rejoin and resume”:
    zh, en, ja, ko, de, fr, es under: frontend/content/blog/cached-id-reconnect/*.mdx
  - Include cover asset: frontend/public/blog-assets/cached-id-reconnect.webp
  - Describe receiver auto-join, reconnect workflow, and resume behavior.
  - Tag with WebRTC/P2P/reconnect for discoverability.
2025-11-25 21:43:56 +08:00
david_bai 723a1ea086 feat(ux): cached roomId auto-join + theme toggle
- Receiver: auto-fill and join on Retrieve tab when input empty, not in room, no URL roomId, and cachedId exists (ClipboardApp + roomIdCache)
  - Sender: “Use cached ID” now immediately joins the room (add onUseCached + disabled to CachedIdActionButton; wire in SendTabPanel)
  - UI: add ThemeToggle and integrate into Header (desktop and mobile)
  - Styles: replace hardcoded white with design tokens in Retrieve panel (bg-card/text-card-foreground) for dark mode
  - Docs: update AI playbook flows and code-map
2025-11-25 12:24:28 +08:00
david_bai 10f236dc8d docs: document in-app navigation persistence (no transfer interruption)
- README.md / README.zh-CN.md:
      - Add feature bullet for in-app navigation persistence (same-tab SPA)
      - Note boundaries: refresh/tab close/new tab not covered
  - Frontend architecture (zh/en):
      - Add “State & Connection Lifecycle (In-App Navigation)” section
      - Explain Zustand store singleton and webrtcService singleton across routes
      - Guidance: avoid leaveRoom()/store reset on route-change side effects
  - System architecture (zh/en):
      - Add “Runtime Session Model (Frontend)” summary
  - AI Playbook:
      - flows.zh-CN.md: add UX optimization item for in-app navigation persistence and debugging notes
      - code-map.zh-CN.md: mark webrtcService and fileTransferStore as singletons (cross-route)
2025-11-24 15:06:10 +08:00
david_bai 89a38936b6 feat(blog-i18n): localize blog UI & SEO; add tag pages to sitemap
- Add Messages.meta.blog and text.blog (BlogTexts) to types/messages
  - Update all locales with blog UI strings and meta.blog
  - Localize blog list, tag pages, and article detail (titles, labels, dates)
  - Pass messages to ArticleListItem; TableOfContents supports localized title
  - Use dictionary-based metadata; alternates cover all supported locales
  - Sitemap: include /[lang]/blog/tag/{tag} and set blog list lastModified to newest post
  - JSON-LD: hardcode site URL in getSiteUrl() for consistency
2025-11-22 10:37:29 +08:00
david_bai 18f6703c6b fix(reconnect): auto rejoin on socket connect and widen reconnection triggers for mobile foreground resume
- Attempt reconnection on 'disconnected' | 'failed' | 'closed' states (BaseWebRTC)
  - Relax gating: rejoin when roomId exists and any of isPeerDisconnected, isSocketDisconnected, or socketId changed
  - Auto re-join room on socket 'connect' if lastJoinedSocketId differs or not in room; send initiator-online for initiators
  - Track lastJoinedSocketId after successful join and reset isInRoom when socketId changes to bypass early-return
  - Update flows to document mobile background/foreground reconnection and socketId-based rejoin
2025-11-21 20:23:47 +08:00
david_bai 415adfe638 chore(doc):Translate an existing blog into multiple languages 2025-11-12 00:01:32 +08:00
david_bai 0c4397bf46 chore(doc):add AI collaborative documents helps AI understand the project context. 2025-11-11 23:41:50 +08:00
david_bai 2840da2f34 feat(blog): restructure blog files into language-specific directories
- Move from flat file structure (privydrop-open-source-en.mdx) to nested structure (privydrop-open-source/en.mdx)
  - Update blog.ts to handle new directory-based file organization
2025-11-11 23:22:30 +08:00
david_bai 2f5ed92188 feat(frontend): send initiator-online after sender rejoins with cached/long room ID
- Add forceInitiatorOnline flag to webrtcService.joinRoom
  - Trigger initiator-online for sender when joining with >=8-char IDs
2025-10-31 12:47:46 +08:00
david_bai 3d222fd316 chore(doc):improve UX message clarity and add cache optimization
- Update duplicate room ID messages across all languages for better clarity
- Enhance cached ID tips with double-click save mode functionality
- Add image optimization cache configuration to Nginx
- Document production environment variable sync requirements
- Add NEXT_IMAGE_UNOPTIMIZED to production config
2025-10-29 23:12:09 +08:00
david_bai b636953770 build(standalone): use pnpm hoisted
- Add frontend/.npmrc (node-linker=hoisted; public-hoist-pattern for @next/env, styled-jsx, @swc/helpers)
  - Keep explicit deps: styled-jsx, @swc/helpers, @next/env
  - Verified standalone starts without MODULE_NOT_FOUND
2025-10-25 11:55:21 +08:00
david_bai 9d9b8036c4 feat(backend): make create_room idempotent for >=8-char room IDs to support cached-ID reconnects
- Return success=true when room exists and roomId length >= 8 (no TTL refresh)
  - Keep short IDs (<8) strict: duplicates still fail
  - No Redis schema changes; TTL refresh remains on successful socket join
  - Fix duplicate warning when sender rejoins with cached long ID
2025-10-25 00:08:11 +08:00
david_bai 30635864da feat(deploy): add build-and-deploy script; switch frontend to Next.js standalone; docs: add incremental update guide 2025-10-25 00:03:20 +08:00
david_bai 47beed3e7f chore(doc):update readme 2025-10-24 00:01:26 +08:00
david_bai b2aa493e2d chore(code):"Use Cache ID" button double-click to temporarily switch to "Save ID" function 2025-10-23 23:05:34 +08:00
david_bai 5ca89d71ad chore(code):Add cache room ID feature, no need to manually input room ID 2025-10-23 20:47:49 +08:00
david_bai 0d308515a7 SEO: refine WebApplication alternateName and commit docs
- Home WebApplication JSON-LD: add alternateName "Open-source web-based AirDrop alternative"
- Docs: add troubleshooting for missing .next production build in deployment guides
  - docs/DEPLOYMENT.md
  - docs/DEPLOYMENT.zh-CN.md
2025-10-14 23:50:40 +08:00
david_bai 0621fb27db SEO: add JSON-LD structured data
- Add generic JSON-LD injector component and builders
  - components/seo/JsonLd.tsx
  - lib/seo/jsonld.ts
- Inject Organization and WebSite JSON-LD globally in [lang]/layout
- Inject WebApplication JSON-LD on the localized home page
  - Localize description/url/inLanguage and set alternateName ["PrivyDrop", "PrivyDrop APP"]
- Inject FAQPage JSON-LD only on /[lang]/faq (not on home)
  - Build Q&A from messages.text.faqs
- Inject BlogPosting + BreadcrumbList on blog post pages
  - Use frontmatter.cover as image, localized breadcrumbs

Notes
- Use stable @id anchors (/#organization, /#website, /[lang]#app, /[lang]/blog/[slug]#post)
- Respect multi-language setup across en/zh/ja/es/de/fr/ko
- SameAs limited to GitHub and X as provided
- Site URL resolved via NEXT_PUBLIC_SITE_URL or defaults to https://www.privydrop.app
2025-10-13 21:19:07 +08:00
david_bai 99f264fcd0 chore(doc):update some docs
roadmap: rewrite CN/EN roadmap to focused P0/P1; remove non‑goals; add unified logging in P0
frontend: state management = Zustand + custom Hooks (CN/EN) with updated strategy
2025-10-13 00:03:44 +08:00
david_bai ad6fc85df1 chore(doc): Move how-it-works position up; the prompt for exiting a room during a transfer has been modified. 2025-10-12 14:41:29 +08:00
101 changed files with 6647 additions and 345 deletions
+2
View File
@@ -16,6 +16,8 @@ coverage/
.next/
# Ignore out/ directories in all subdirectories
out/
out.zip
deploy.config*
# production
# Ignore build/ directories in all subdirectories
+18
View File
@@ -0,0 +1,18 @@
# AGENTS — PrivyDrop Repository Rules(简版)
最重要的原则
- 用中文沟通:与项目负责人沟通一律使用中文(简体)。代码注释、命名、提交信息、PR 标题与描述统一使用英文。
- 方案与代码遵循最佳实践:优先选择与现有技术栈一致、被验证过的实现;小步迭代、易回滚。
- 计划先行:任何实现前须提交并获批变更计划(目标、范围/文件、方案、风险、验收、回滚、需更新文档、验证方式)。模板见 docs/ai-playbook/collab-rules.zh-CN.md。
- 单一主题:每次改动只解决一个明确目标,避免“顺手修复”无关问题,保持最小可回滚。
- 隐私与架构红线:后端仅做信令与房间管理;严禁任何形式的文件数据中转、存储或上报至第三方。
- 传输护栏:保持既定分片/背压/重试等关键参数与机制;任何破坏性或参数层变更需先获批。
- 依赖与基建:未经批准不得新增依赖/组件库/基础设施或进行大规模重构。
- 文档同步:涉及流程、接口或入口文件路径的改动,必须在同一 PR 内同步更新 docs/ai-playbook/flows.zh-CN.md 与 docs/ai-playbook/code-map.zh-CN.md。
- 验证要求:前端需构建通过(next build);列出关键手测用例与回归点。
优先级与冲突
- 显式用户指令优先于本文件;如有冲突需在计划中说明并征得同意。
- 更多细则、示例与校验清单以 docs/ai-playbook/collab-rules.zh-CN.md 为准(本文件仅保留最原则条款)。
+4
View File
@@ -20,6 +20,7 @@ We believe everyone should have control over their own data. PrivyDrop was creat
## ✨ Key Features
- 🔄 **Unlimited File Transfer** - Support files of any size through Chrome's direct-to-disk streaming (Need to set the save directory)
- 🔒 **End-to-End Encryption**: Leverages P2P direct connections via WebRTC. All files and text are transferred directly between browsers without passing through any central server.
- 📂 **File & Folder Transfer**: Supports transferring multiple files and entire folders.
- ⏸️ **Resume Transfer**: Resume file transfer from the point of interruption. Simply set the save directory to enable this feature, ensuring your large files are delivered safely even with unstable networks. If interrupted, you currently need to refresh both the sender and receiver web pages to restart the transfer.
@@ -28,6 +29,7 @@ We believe everyone should have control over their own data. PrivyDrop was creat
- 🔗 **Convenient Sharing**: Easily share a room and establish a connection via a link or QR code.
- 📱 **Multi-Device Support**: Responsive design supports both desktop and mobile browsers.
- 🌐 **Internationalization**: Supports multiple languages, including English and Chinese.
- 🧭 **In-App Navigation Persistence**: For in-app navigation within the same browser tab (Next.js App Router page switches), ongoing transfers are not interrupted, and selected-to-send items and already-displayed received content are preserved. Powered by a singleton app state (Zustand Store) and a singleton connection service (webrtcService).
## 🛠️ Tech Stack
@@ -61,6 +63,7 @@ bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn -
See [Docker Deployment Guide](./docs/DEPLOYMENT_docker.md) (Modes Overview, LAN TLS limitations, Lets 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`
@@ -122,6 +125,7 @@ We provide detailed documentation to help you dive deeper into the project's des
- [**Frontend Architecture Deep Dive**](./docs/FRONTEND_ARCHITECTURE.md): Explore the frontend's modern, layered architecture, state management with Zustand, and the decoupled service-based approach to WebRTC.
- [**Backend Architecture Deep Dive**](./docs/BACKEND_ARCHITECTURE.md): Dive into the backend's code structure, signaling flow, and Redis design.
- [**Deployment Guide**](./docs/DEPLOYMENT.md): Learn how to deploy the complete PrivyDrop application in a production environment.
- [AI Playbook (zh-CN)](./docs/ai-playbook/index.zh-CN.md) · [Collaboration Rules (zh-CN)](./docs/ai-playbook/collab-rules.zh-CN.md)
## 🤝 Contributing
+4
View File
@@ -20,6 +20,7 @@ PrivyDrop (原 SecureShare) 是一个基于 WebRTC 的开源点对点(P2P)
## ✨ 主要特性
- 🔄 **无限制文件传输** - 支持任意大小文件传输,通过 Chrome 的流式保存到磁盘功能实现(需设置保存目录)
- 🔒 **端到端加密**: 基于 WebRTC 的 P2P 直连技术,所有文件和文本在浏览器间直接传输,不经过任何中央服务器。
- 📂 **文件与文件夹传输**: 支持多文件和整个文件夹的传输。
- ⏸️ **断点续传**: 自动从中断处恢复文件传输。只需设置保存目录即可启用此功能,确保即使在网络不稳定的情况下,您的大文件也能安全送达。如果中断,目前需要同时刷新发送端和接收端网页,重新开始传输即可。
@@ -28,6 +29,7 @@ PrivyDrop (原 SecureShare) 是一个基于 WebRTC 的开源点对点(P2P)
- 🔗 **便捷分享**: 通过链接或二维码轻松分享房间,建立连接。
- 📱 **多端支持**: 响应式设计,支持桌面和移动端浏览器。
- 🌐 **国际化**: 支持中文、英文等多个语言。
- 🧭 **站内导航不中断/状态保持**: 在同一标签页的站内跳转(Next.js App Router 页面切换)时,进行中的传输不会中断,已选择的待发送内容与接收页已展示的文本/文件清单也不会丢失。该能力依赖前端的单例状态(Zustand Store)与单例连接服务(webrtcService)。
## 🛠️ 技术栈
@@ -63,6 +65,7 @@ bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn -
完整说明见: docs/DEPLOYMENT_docker.zh-CN.md(模式一览、LAN TLS、自签限制、Lets Encrypt 自动签发/续期)
提示(lan-tls 自签 HTTPS
- 首次访问需导入 CA 证书:`docker/ssl/ca-cert.pem` 到浏览器(或系统信任),否则浏览器会提示“证书无效/不受信任”。
- 访问方式(默认):
- Nginx: `http://localhost`
@@ -133,6 +136,7 @@ bash ./deploy.sh --mode full --domain your-domain.com --with-nginx --with-turn -
- [**前端架构详解**](./docs/FRONTEND_ARCHITECTURE.zh-CN.md): 深入探索前端的现代化分层架构、基于 Zustand 的状态管理,以及解耦的服务化 WebRTC 实现。
- [**后端架构详解**](./docs/BACKEND_ARCHITECTURE.zh-CN.md): 深入探索后端的代码结构、信令流程和 Redis 设计。
- [**部署指南**](./docs/DEPLOYMENT.zh-CN.md): 学习如何在生产环境部署完整的 PrivyDrop 应用。
- [AI Playbook 索引](./docs/ai-playbook/index.zh-CN.md) · [协作规则](./docs/ai-playbook/collab-rules.zh-CN.md)
## 🤝 参与贡献
+46 -52
View File
@@ -1,73 +1,67 @@
# PrivyDrop Project Roadmap
# PrivyDrop Roadmap
Welcome to the official roadmap for PrivyDrop! This document outlines our vision for the future, detailing the planned features and improvements that will shape the project. Our goal is to build the most secure, private, and user-friendly P2P file sharing solution.
## Overview
This roadmap is a living document. We welcome community feedback and contributions. If you have an idea or want to help build the future of PrivyDrop, please open an [Issue](https://github.com/david-bai00/PrivyDrop/issues) or a [Pull Request](https://github.com/david-bai00/PrivyDrop/pulls)!
- Vision: keep file/text transfer lightweight, smooth, reliable, and easy to selfhost.
- Current snapshot: resumable transfer, chunking + backpressure, Safari/Firefox support, Docker oneclick deploy.
---
## ✅ Completed
## Scope
### 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)
- Lets 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)
- Scope: file/text transfer only (onetomany), roombased sessions.
---
## Short-Term Goals (Next 1-3 Months)
## NearTerm Roadmap (by priority, no dates)
This phase focuses on perfecting the current feature set and enhancing reliability to build an even stronger foundation.
- P0 Code Optimization & Slimming
- **Enhanced Connection Stability:** The current implementation supports automatic reconnection for a short period (e.g., 15 minutes) in default 4-digit rooms. This will be extended to support custom-named rooms with a longer reconnection window (e.g., 1 hour).
- **Detailed Transfer Error-Handling:** Provide users with clearer, more specific feedback when a transfer fails (e.g., "Peer disconnected," "Browser storage full," "Network interrupted").
- Architecture convergence & clear boundaries: transport (send/receive), WebRTC wrapper, state, and UI separated; split oversized files; centralize shared types/constants.
- Redundancy cleanup: remove dead code/unused exports; merge duplicate utilities and logic (keep a single authority for packet encode/decode).
- Unified config & naming: chunk/batch/backpressure thresholds from a single source; unify naming; do not change behavior.
- State management coherence: Zustand as the single source of truth; custom hooks only subscribe/dispatch intent, no business logic.
- Async & error path simplification: unify Promise/event patterns and return values; centralize error types and boundaries.
- Logging & debug (key runtime item): unified logger with levels (error/warn/info/debug) and toggle; default lownoise in production; replace scattered console/postLog; consistent IDs by room/session/file.
- Type & build health: gradually tighten TS, reduce any/implicit any; keep lint/format consistent.
- P0 Minimal Test Set
- Unit tests: chunk read/slice, embedded packet parse, sequenced disk writer handling of outoforder/duplicate/tail chunks.
- Lightweight integration: headless/fake data channel to verify send→receive→persist, covering backpressure wait and resume path.
- Backend minimal tests: room and ratelimit core contracts.
- P1 Error UX & Readonly Network Check
- Clear, actionable errors with retry suggestions; visible send/receive states and failure summaries.
- Readonly panel: connection state, data channel state, send buffer, current/avg rate, recent errors. Display only; no complex probing.
- P1 Docs & Deployment Consistency
- Aligned quickstart and Docker selfhosting; FAQ and troubleshooting; consistent screenshots and terminology.
- Frontend architecture docs synced (Zustand + custom hooks).
---
## Mid-Term Goals (Next 3-9 Months)
## Definition of Done
This phase introduces powerful new features that expand PrivyDrop's use cases beyond one-to-one file transfers.
- P0 Code Optimization & Slimming
- **[Major Feature] P2P Group Chat:** While multiple peers can already join a room, this feature will add a simple, host-based group chat. The room creator will act as a hub to relay encrypted text and files to all other participants, enabling basic group collaboration.
- **Self-Destructing Messages & Files:** Allow users to send files or text messages that are automatically deleted from the recipient's view after being read or after a set time.
- **Clipboard Synchronization:** Add a dedicated mode to sync the clipboard content (text and images) in real-time between connected devices.
- **Official Docker Support:** Provide and maintain official `Dockerfile` and `docker-compose.yml` configurations for easy, one-command self-hosting of the entire stack.
- Clear module boundaries; unified directory/naming; duplicates merged; dead code removed.
- Single source for chunk/batch/backpressure config, with behavior unchanged.
- Zustand as the only state source; components free of business sideeffects; custom hooks roles are clear.
- Logger levels and toggle in place; production lownoise; no stray debug output.
- Build and lint pass; TypeScript warnings significantly reduced.
### Performance and deployment
- **Official Docker support:** Provide and maintain the official `Dockerfile` and `docker-compose.yml` configurations to achieve one-click deployment of the entire technology stack (frontend, backend, Redis, Coturn), which greatly facilitates self-hosted users.
- **Package size optimization:** Regularly use `@next/bundle-analyzer` to analyze the frontend package size, optimize through code splitting and other means, and keep the application lightweight.
### User Experience (UX)
- **To be defined**
- P0 Minimal Test Set
- Core edge cases covered by unit tests; at least one minimal integration path completes send→receive→persist.
---
## Future & Community-Driven Ideas
## Terminology
This section is for features that are not on the immediate roadmap but represent great opportunities for community contributions.
- **Comprehensive Testing:** The recent architectural refactor has made the codebase significantly more testable. We now plan to introduce a testing framework (like Vitest) to add unit tests for the core `webrtcService` and Zustand store, improving code quality and making community contributions safer. We welcome contributions in this area.
- **Your Ideas Here:** Have a great idea for a feature, like screen sharing or P2P media streaming? Open an issue and let's discuss it! We believe the best ideas can come from the community.
## How to Contribute
Your contributions are vital to making this roadmap a reality!
1. **Pick an Issue:** Look for issues tagged with `help wanted` or `good first issue`.
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.
- Sender/Receiver
- Room
- Chunk / Backpressure
- Resume
- DataChannel
- Persist to disk (OPFS/disk write)
+47 -52
View File
@@ -1,74 +1,69 @@
# PrivyDrop 项目发展路线图
# PrivyDrop 项目路线图
欢迎来到 PrivyDrop 的官方路线图!本文档将详细阐述我们对未来的愿景,以及计划中的功能和改进。我们的目标是构建最安全、最私密、最友好的 P2P 文件分享解决方案。
## 简介
这份路线图是一份动态文档,我们真诚地欢迎社区的反馈和贡献。如果你有任何想法,或希望帮助我们共创 PrivyDrop 的未来,请随时开启一个 [Issue](https://github.com/david-bai00/PrivyDrop/issues) 或提交一个 [Pull Request](https://github.com/david-bai00/PrivyDrop/pulls)
- 目标:把文件/文本传输这件事做得轻盈、顺滑、可靠,且易于自托管。
- 现状快照:断点续传、分块与背压、跨浏览器(含 Safari/Firefox)、Docker 一键部署。
---
## ✅ 已完成
## 范围说明
### 架构优化
- **核心架构重构 (2025 年 Q3)**: 成功地将整个前端代码库重构为现代化的分层架构。
- 实现了一个与框架无关的**服务层** (`webrtcService`),用于封装所有 WebRTC 和业务逻辑。
- 引入 **Zustand** (`fileTransferStore`) 进行中心化的、可预测的状态管理。
- 将 UI 组件与业务逻辑解耦,建立了清晰的单向数据流。
- **文件断点续传 (2025 年 Q3):** 实现了稳健的断点续传逻辑。通过设置保存目录,接收方能够检查已部分下载的文件,并仅请求缺失的数据块,极大地提升了大文件和不稳定网络下的传输成功率。
### 部署与运维
- Docker 一键部署(2025 年 Q4
- 容器健康检查统一(node health-check.js
- Lets Encryptwebroot)自动化与续期 deploy-hook(无停机)
- TURN 端口段变量化与默认缩小(49152-49252
- SNI 443 分流(Nginx streamfull+domain 默认开启)
- 范围:仅文件/文本传输(点对多),以房间为单位进行会话。
---
## 短期目标 (未来 1-3 个月)
## 路线图
本阶段将专注于完善现有功能、提升核心体验的可靠性,为项目打下更坚实的基础。
- P0 代码优化与瘦身
- **连接稳定性增强:** 目前,使用默认 4 位数字 ID 的房间已支持短时间(例如 15 分钟)内断线重连。未来将此特性扩展到自定义名称的房间,并提供更长的重连窗口(例如 1 小时)
- **精细化传输错误处理:** 当传输失败时,向用户提供更清晰、具体的反馈(例如:"对方已断开连接"、"浏览器存储空间不足"、"网络中断")。
- 架构收敛与边界清晰:传输(发送/接收)、WebRTC 封装、状态管理与 UI 分离;拆分过大文件,公共类型/常量下沉共享
- 冗余清理与精简:移除死代码与未用导出;合并重复工具与重复逻辑(封包/解包保留权威实现)。
- 配置与命名统一:分块大小/批大小/背压阈值来自单一配置源,仅统一来源,不改既有行为。
- 状态管理收敛:以 Zustand 为唯一状态源;自定义 hooks 负责订阅与意图触发,不承载业务逻辑。
- 异步与错误路径简化:统一 Promise/事件用法与返回值;集中错误类型与边界。
- 日志与调试(本批重点):统一 loggererror/warn/info/debug + 开关),生产默认低噪;替换散落的 console/postLog,并在房间/会话/文件维度埋点一致。
- 类型与构建健康度:收紧 TypeScript(小步),减少 any/隐式 any;保持 Lint/格式化一致。
- P0 最小测试集
- 单元测试:分块读取/切片、嵌包解析、顺序落盘器的乱序/重复/尾块处理等核心边界。
- 轻量集成:伪造数据通道验证“发送 → 接收 → 落盘”的最小闭环,覆盖背压等待与断点恢复路径。
- 后端最小单测:房间与速率限制的关键契约。
- P1 错误体验与只读网络体检
- 错误提示:用语清晰、可重试建议;显示发送/接收状态与失败简述。
- 只读体检:展示连接状态、数据通道状态、发送缓冲、当前/平均速率、最近错误;仅展示,不做复杂探测。
- P1 文档与部署一致性
- 快速上手与 Docker 自托管流程对齐;常见问题与排错路径补充;截图与术语口径统一。
- 前端架构文档与实现同步(Zustand + 自定义 Hooks)。
---
## 中期目标 (未来 3-9 个月)
## 完成定义(达成即止)
本阶段将引入强大的新功能,将 PrivyDrop 的应用场景从一对一的文件传输拓展到更广阔的领域。
- P0 代码优化与瘦身
- **[重大功能] P2P 群组聊天:** 虽然目前已支持多人加入同一房间,但此功能将增加一个简洁的、基于主持人的群聊系统。房间创建者将作为中心节点,负责将加密的文本和文件转发给所有其他参与者,实现基础的群组协作
- **阅后即焚的消息与文件:** 允许用户发送在被读取或设定时间后自动销毁的文件或文本消息
- **剪贴板同步:** 增加一个专门的模式,用于在连接的设备之间实时同步剪贴板内容(文本和图片)
- **官方 Docker 支持:** 提供并维护官方的 `Dockerfile``docker-compose.yml` 配置,实现一键部署整个技术栈(前端、后端、Redis、Coturn),极大地方便自托管用户
- **包体积优化:** 定期使用 `@next/bundle-analyzer` 分析前端打包体积,通过代码分割等手段进行优化,保持应用的轻量化
- 模块边界清晰、目录与命名统一;重复实现合并,死代码清理完毕
- 分块/批/背压等配置项有单一来源,保持现有行为不变
- Zustand 统一为状态源;组件无业务副作用;自定义 hooks 角色明确
- 日志体系可按等级与开关控制,生产默认低噪;无散落调试输出
- 构建与 Lint 通过;类型告警明显减少
### 性能与部署
- **官方 Docker 支持:** 提供并维护官方的 `Dockerfile``docker-compose.yml` 配置,实现一键部署整个技术栈(前端、后端、Redis、Coturn),极大地方便自托管用户。
- **包体积优化:** 定期使用 `@next/bundle-analyzer` 分析前端打包体积,通过代码分割等手段进行优化,保持应用的轻量化。
### 用户体验 (UX)
- to be define
- P0 最小测试集
- 关键模块单测覆盖核心边界;存在一个最小集成用例完成“发送 → 接收 → 落盘”。
---
## 未来探索与社区驱动
## 术语口径
本部分用于记录那些不在当前核心规划中,但对社区贡献开放的绝佳想法。
- 发送/接收(Sender/Receiver
- 房间(Room
- 分块(Chunk)与背压(Backpressure
- 断点续传(Resume
- 数据通道(DataChannel
- 落盘(OPFS/磁盘写入)
- **代码测试覆盖:** 近期的架构重构使得代码库的可测试性大大增强。我们现在计划引入测试框架(如 Vitest),为核心的 `webrtcService` 和 Zustand store 添加单元测试,以提升代码质量、保障社区贡献的安全性。我们非常欢迎社区在此方面做出贡献。
- **你的想法:** 你是否对浏览器扩展、屏幕共享、P2P 媒体流等高级功能有很棒的想法?欢迎通过 Issue 发起讨论!我们相信最好的创意来自社区。
## 如何贡献
你的贡献对于将这份路线图变为现实至关重要!
1. **认领任务:** 寻找被标记为 `help wanted``good first issue` 的 Issue。
2. **发起讨论:** 如果你对路线图中某个项目感兴趣,欢迎发起一个讨论来分享你的想法。
3. **提交代码:** Fork 仓库,创建你的功能分支,然后提交 Pull Request。
感谢你成为 PrivyDrop 社区的一员!让我们一起共创私人分享的未来。
感谢关注与贡献!
+6
View File
@@ -94,6 +94,12 @@ server {
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
# Add cache optimization for image optimization
proxy_cache_valid 200 1d;
add_header Cache-Control "public, max-age=31536000, immutable";
proxy_read_timeout 60s;
proxy_connect_timeout 5s;
}
# 2. Handle static files under the public directory and Next.js dynamic requests
# This location should be after specific proxies (like /api/, /socket.io/),
+12 -7
View File
@@ -47,16 +47,21 @@ const createRoomHandler: RequestHandler<{}, any, CreateRoomRequest> = async (
try {
const exists = await roomService.isRoomExist(roomId);
const response = {
success: !exists,
message: exists ? "roomId is already exists" : "create room success",
};
if (!exists) {
await roomService.createRoom(roomId);
// Idempotent behavior for long IDs (>= 8): allow reuse for reconnect scenarios.
// Short IDs keep strict uniqueness (prevent accidental collisions).
if (exists) {
if (roomId.length >= 8) {
// Do NOT refresh TTL here; actual join will refresh on success.
res.json({ success: true, message: "room exists (rejoin allowed)" });
return;
}
res.json({ success: false, message: "roomId is already exists" });
return;
}
res.json(response);
await roomService.createRoom(roomId);
res.json({ success: true, message: "create room success" });
} catch (error) {
console.error("Error checking room:", error);
res.status(500).json({ error: "Internal server error" });
+227
View File
@@ -0,0 +1,227 @@
#!/bin/bash
set -euo pipefail
# Check if a build package already exists
if [ -f "out.zip" ]; then
echo "📦 Detected existing build package: out.zip"
echo "📦 Package size: $(du -sh out.zip | cut -f1)"
echo "📝 Build info:"
if [ -f "out/deploy-info.txt" ]; then
cat out/deploy-info.txt
fi
echo ""
echo "⚠️ Choose an option:"
echo " 1. Deploy existing package"
echo " 2. Rebuild and deploy"
echo " 3. Exit"
echo ""
read -p "Select (1/2/3): " -n 1 -r
echo ""
case $REPLY in
1)
echo "🚀 Deploying existing package..."
DEPLOY_EXISTING=true
;;
2)
echo "🔄 Rebuilding..."
rm -rf out out.zip
;;
3)
echo "👋 Exit"
exit 0
;;
*)
echo "❌ Invalid option, aborting"
exit 1
;;
esac
fi
if [ "${DEPLOY_EXISTING:-}" != "true" ]; then
echo "🚀 Start local build..."
# Clean previous build outputs
echo "🧹 Cleaning previous build outputs..."
rm -rf frontend/.next
rm -rf backend/dist
rm -rf out
# Create output directory for packaging
mkdir -p out
# Build frontend
echo "📦 Building frontend..."
cd frontend
pnpm install
pnpm build
cd ..
# Build backend
echo "📦 Building backend..."
cd backend
pnpm install
pnpm build
cd ..
# Prepare deploy bundle
echo "📋 Preparing deploy bundle..."
mkdir -p out/frontend
mkdir -p out/backend
# Copy frontend artifacts
cp -r frontend/.next out/frontend/
cp frontend/package.json out/frontend/
cp -r frontend/public out/frontend/ 2>/dev/null || true
cp -r frontend/app out/frontend/ 2>/dev/null || true
cp -r frontend/components out/frontend/ 2>/dev/null || true
cp -r frontend/lib out/frontend/ 2>/dev/null || true
cp -r frontend/styles out/frontend/ 2>/dev/null || true
cp frontend/next.config.js out/frontend/ 2>/dev/null || true
cp frontend/tailwind.config.ts out/frontend/ 2>/dev/null || true
cp frontend/postcss.config.js out/frontend/ 2>/dev/null || true
cp -r frontend/content out/frontend/ 2>/dev/null || true
# Copy backend artifacts
cp -r backend/dist out/backend/
cp backend/package.json out/backend/
# Write deployment info
echo "📝 Writing deployment info..."
cat > out/deploy-info.txt << EOF
Build time: $(date)
Git commit: $(git rev-parse --short HEAD)
Git branch: $(git branch --show-current)
Frontend BUILD_ID: $(cat frontend/.next/BUILD_ID 2>/dev/null || echo "N/A")
EOF
# Archive deploy bundle
echo "📦 Archiving deploy bundle..."
cd out
zip -r ../out.zip .
cd ..
echo "✅ Local build and packaging completed!"
echo "📦 Package: out.zip"
echo "📦 Size: $(du -sh out.zip | cut -f1)"
fi
# Deploy logic
if [ -f "out.zip" ]; then
echo ""
echo "🚀 Detected out.zip, ready to deploy to server"
echo "⚠️ Deployment will:"
echo " 1. Upload out.zip to server"
echo " 2. Backup current version"
echo " 3. Unzip and replace files"
echo " 4. Restart PM2 apps"
echo ""
read -p "Proceed with deployment? (y/N): " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "🚀 Starting deployment..."
# Load deploy config file
if [ -f "deploy.config" ]; then
source deploy.config
fi
# Validate required environment variables
if [ -z "$DEPLOY_SERVER" ] || [ -z "$DEPLOY_USER" ] || [ -z "$DEPLOY_PATH" ]; then
echo "❌ Missing server configuration. Please configure one of the following:"
echo " 1. Copy deploy.config.example to deploy.config and edit values"
echo " 2. Or set environment variables:"
echo " export DEPLOY_SERVER=your-server-ip"
echo " export DEPLOY_USER=root"
echo " export DEPLOY_PATH=/root/PrivyDrop"
exit 1
fi
# Build SSH options (port/key)
SSH_OPTS=""
SCP_OPTS=""
if [ -n "${SSH_PORT:-}" ]; then
SSH_OPTS+=" -p $SSH_PORT"
SCP_OPTS+=" -P $SSH_PORT"
fi
if [ -n "${SSH_KEY_PATH:-}" ]; then
SSH_OPTS+=" -i $SSH_KEY_PATH"
SCP_OPTS+=" -i $SSH_KEY_PATH"
fi
# Upload build package to server
echo "📤 Uploading package to server..."
# shellcheck disable=SC2086
scp $SCP_OPTS out.zip $DEPLOY_USER@$DEPLOY_SERVER:/tmp/
# Run remote deployment (fix: ensure heredoc script actually executes)
echo "🔧 Executing remote deployment..."
# Inject DEPLOY_PATH and execute heredoc via 'bash -s' on remote host
# shellcheck disable=SC2086
ssh $SSH_OPTS $DEPLOY_USER@$DEPLOY_SERVER "DEPLOY_PATH='$DEPLOY_PATH' bash -s" << 'EOF'
set -euo pipefail
# Create structured backup directory
BACKUP_ROOT="/tmp/privydrop_backup"
BACKUP_DIR="$BACKUP_ROOT/$(date +%Y%m%d_%H%M%S)"
mkdir -p "$BACKUP_DIR/frontend" "$BACKUP_DIR/backend"
# Backup current artifacts if present
if [ -d "$DEPLOY_PATH/frontend/.next" ]; then
echo "📋 Backing up current frontend build..."
mv "$DEPLOY_PATH/frontend/.next" "$BACKUP_DIR/frontend/.next"
fi
if [ -d "$DEPLOY_PATH/backend/dist" ]; then
echo "📋 Backing up current backend build..."
mv "$DEPLOY_PATH/backend/dist" "$BACKUP_DIR/backend/dist"
fi
# Stop PM2 processes
echo "⏹️ Stopping PM2 apps..."
sudo pm2 stop all || true
sudo pm2 delete all || true
# Extract new version
echo "📂 Extracting new version..."
cd "$DEPLOY_PATH"
unzip -o /tmp/out.zip
rm -f /tmp/out.zip
# Fix ownership
sudo chown -R "$(id -un)":"$(id -gn)" "$DEPLOY_PATH/frontend/.next" 2>/dev/null || true
sudo chown -R "$(id -un)":"$(id -gn)" "$DEPLOY_PATH/backend/dist" 2>/dev/null || true
# Start PM2 apps
echo "▶️ Starting PM2 apps..."
sudo pm2 start ecosystem.config.js
# Wait for services to start
sleep 5
# Check PM2 status
echo "🔍 Checking PM2 status..."
sudo pm2 status
# Print version identifiers for verification
if [ -f "$DEPLOY_PATH/frontend/.next/BUILD_ID" ]; then
echo "📦 Frontend BUILD_ID: $(cat "$DEPLOY_PATH/frontend/.next/BUILD_ID")"
fi
if [ -f "$DEPLOY_PATH/deploy-info.txt" ]; then
echo "📝 Deploy info:"
cat "$DEPLOY_PATH/deploy-info.txt" || true
fi
echo "✅ Deployment completed!"
echo "📋 Backup saved at: $BACKUP_DIR"
EOF
echo "🎉 Deployment finished. Check PM2 status on server:"
echo " ssh $DEPLOY_USER@$DEPLOY_SERVER 'sudo pm2 status'"
else
echo "❌ Deployment canceled"
fi
else
echo "❌ out.zip not found"
exit 1
fi
+19
View File
@@ -0,0 +1,19 @@
# Deployment configuration
# Copy this file to 'deploy.config' and fill in your server details
# Server IP or domain
DEPLOY_SERVER="your-server-ip"
# Server username (default: root)
# Note: Using 'ssh root' is recommended here for simplicity. Ensure you understand the
# security implications and restrict access appropriately (keys, firewall, etc.).
DEPLOY_USER="root"
# Deploy path on the server (project root)
DEPLOY_PATH="/root/PrivyDrop"
# SSH port (optional, default 22)
# SSH_PORT="22"
# SSH private key path (optional)
# SSH_KEY_PATH="~/.ssh/id_rsa"
+6 -1
View File
@@ -76,4 +76,9 @@ graph TD
- **Privacy First**: Core file data is never uploaded to the server. The server only acts as an "introducer" or "matchmaker."
- **Frontend-Backend Separation**: Responsibilities are clearly separated. The frontend handles all user interaction and the complex logic of WebRTC; the backend provides lightweight, efficient signaling and room management services.
- **Horizontal Scalability**: The backend is stateless (with state managed in Redis), which theoretically allows it to be scaled horizontally by adding more Node.js instances to handle a large volume of concurrent signaling requests.
- **Horizontal Scalability**: The backend is stateless (with state managed in Redis), which theoretically allows it to be scaled horizontally by adding more Node.js instances to handle a large volume of concurrent signaling requests.
## 4. Runtime Session Model (Frontend)
- **SPA In-App Navigation Persistence**: The frontend is an SPA (App Router). Within the same browser tab, in-app navigation does not tear down the singleton app state (Zustand) nor the WebRTC connection service (webrtcService). Ongoing transfers continue, and selected/received content remains available.
- **Boundary**: Page refresh, closing the tab, or opening in a new tab are not covered. If changing layout/SSR strategy, avoid cleaning the connection during layout unmount.
+5
View File
@@ -77,3 +77,8 @@ graph TD
- **隐私优先**: 核心文件数据永不上传到服务器。服务器只承担“介绍人”的角色。
- **前后端分离**: 前后端职责清晰。前端负责所有与用户交互和 WebRTC 的复杂逻辑;后端则提供轻量、高效的信令和房间管理服务。
- **水平扩展**: 后端是无状态的(状态存储在 Redis 中),理论上可以通过增加 Node.js 实例来水平扩展,以应对大量并发信令请求。
## 四、运行时会话模型(前端)
- **SPA 站内导航保持**:前端为单页应用(App Router)。在同一标签页内进行站内跳转时,应用状态(Zustand 单例)与 WebRTC 连接服务(webrtcService 单例)不会被销毁,进行中的传输不中断,已选择/已接收内容保持。
- **边界说明**:页面刷新、关闭标签页或在新标签页打开页面,不属于保持范围;如调整布局/SSR 策略,需避免在布局卸载阶段清理连接。
+100
View File
@@ -287,6 +287,106 @@ PM2 is a powerful process manager for Node.js. We will use it to run both backen
- Restart services: `pm2 restart all` or specific service `pm2 restart signaling-server`
- Stop services: `pm2 stop all` or specific service `pm2 stop privydrop-frontend`
### 4.7. Daily Incremental Update (Local Build + Remote Replace)
This section describes how to build locally and deploy only the built artifacts to the server. It is optimized for day-to-day releases: fast, low resource usage on the server, and easy to verify.
- Assumes you have completed the first-time deployment and can access the app in production.
- The frontend runs in Next.js Standalone mode (configured in `ecosystem.config.js`), so the server does not need Next CLI or frontend dependencies installed.
0. Sync frontend production environment variables (important)
The local build reads variables from `frontend/.env.production` (e.g., `NEXT_PUBLIC_API_URL`, TURN settings, and build-time flags like `NEXT_IMAGE_UNOPTIMIZED`). To ensure the build matches production behavior, copy the production environment file from the server to your local machine before running `bash build-and-deploy.sh`.
- Example (sync from server to local):
```bash
# Assuming the server project root is /root/PrivyDrop
scp root@<server>:/root/PrivyDrop/frontend/.env.production ./frontend/.env.production
```
- If the file does not exist on the server yet, create it from the example and keep it consistent with production:
```bash
cp frontend/.env_production_example frontend/.env.production
# Fill in NEXT_PUBLIC_API_URL, TURN_*, NEXT_IMAGE_UNOPTIMIZED, etc.
```
- Note: `build-and-deploy.sh` does not auto-create or overwrite `frontend/.env.production`. Make sure it exists locally and matches production; otherwise, the built behavior may differ from the server (e.g., image optimization toggles).
1. Prepare deployment configuration
- From the project root:
```bash
cp deploy.config.example deploy.config
```
- Edit `deploy.config` with at least:
```bash
DEPLOY_SERVER="<your-server-ip-or-domain>"
DEPLOY_USER="root" # Recommended: use ssh root for simplicity
DEPLOY_PATH="/root/PrivyDrop" # Project root on the server
# Optional: SSH_PORT, SSH_KEY_PATH
```
- Security notes: Use SSH key authentication, restrict source IPs, and enforce firewall rules in production.
2. Build locally and deploy
- From the project root:
```bash
bash build-and-deploy.sh
```
- When an existing package (out.zip) is detected, the script lets you choose:
- 1. Deploy existing package
- 2. Rebuild and deploy
- Script flow (summary):
- Build frontend and backend locally
- Package artifacts into `out.zip`
- Upload to server path `/tmp/out.zip`
- Server-side backup to `/tmp/privydrop_backup/YYYYmmdd_HHMMSS/`
- Unzip and replace:
- Frontend: `frontend/.next` (includes `.next/standalone` and `.next/static`)
- Frontend static assets: `frontend/public`
- Frontend content: `frontend/content` (for blog file reads)
- Backend: `backend/dist`
- Restart using `pm2 start ecosystem.config.js`
3. Post-deployment verification
- Check process status on the server:
```bash
ssh root@<server> 'sudo pm2 status'
```
- Compare frontend BUILD_ID (optional):
```bash
ssh root@<server> 'cat /root/PrivyDrop/frontend/.next/BUILD_ID'
```
- Force refresh the browser or use an incognito window to confirm the new version.
4. Backups and manual rollback
- Each deployment creates a structured backup on the server at `/tmp/privydrop_backup/YYYYmmdd_HHMMSS/`:
- Frontend: `frontend/.next`
- Backend: `backend/dist`
- To rollback manually (example):
```bash
# Stop PM2
sudo pm2 stop all && sudo pm2 delete all
# Choose a backup directory, e.g. /tmp/privydrop_backup/20241024_235959
export DEPLOY_PATH=/root/PrivyDrop
export BACKUP=/tmp/privydrop_backup/20241024_235959
# Restore frontend and backend build artifacts
rm -rf "$DEPLOY_PATH/frontend/.next" "$DEPLOY_PATH/backend/dist"
cp -a "$BACKUP/frontend/.next" "$DEPLOY_PATH/frontend/.next"
cp -a "$BACKUP/backend/dist" "$DEPLOY_PATH/backend/dist"
# Restart PM2
sudo pm2 start ecosystem.config.js
```
5. Common issues
- Page still shows the old version: clear browser cache/force refresh; compare BUILD_ID; check Nginx/CDN caching.
- Blog posts not loading: ensure `frontend/content/blog` exists on the server and the PM2 frontend process `cwd` is `./frontend`.
- `out.zip not found`: choose “Rebuild and deploy” to create a new package.
## 5. Troubleshooting
- **Connection Issues:** Check firewall settings, Nginx proxy configurations, `CORS_ORIGIN` settings, and ensure all PM2 processes are running.
+101
View File
@@ -286,6 +286,107 @@ PM2 是一个强大的 Node.js 进程管理器,我们将用它来运行后端
- 重启服务: `pm2 restart all` 或指定服务 `pm2 restart signaling-server`
- 停止服务: `pm2 stop all` 或指定服务 `pm2 stop privydrop-frontend`
### 4.7. 日常增量更新(本地构建 + 远程替换)
本小节介绍如何在本地构建后,将前后端的生产产物一并打包上传到服务器,完成“增量更新”。该流程适合日常发布,速度快、资源占用低。
- 默认假设你已按“首次部署”完成环境配置(包括 PM2、Nginx/证书等),并能正常访问应用。
- 默认使用前端 Next.js Standalone 运行方式(ecosystem.config.js 已配置),服务器无需安装前端依赖和 next CLI。
0. 同步前端生产环境变量(重要)
本地构建会读取 `frontend/.env.production` 中的变量(例如 `NEXT_PUBLIC_API_URL`、TURN 配置、以及构建期开关 `NEXT_IMAGE_UNOPTIMIZED` 等)。
为确保构建产物与线上一致,请在执行 `bash build-and-deploy.sh` 之前,将“线上部署环境的 `frontend/.env.production`”拷贝到本地对应路径。
- 示例(从服务器同步到本地):
```bash
# 假设服务器项目根目录为 /root/PrivyDrop
scp root@<server>:/root/PrivyDrop/frontend/.env.production ./frontend/.env.production
```
- 如线上暂未建立该文件,可基于示例创建并与线上保持一致:
```bash
cp frontend/.env_production_example frontend/.env.production
# 按需填写 NEXT_PUBLIC_API_URL、TURN_*、NEXT_IMAGE_UNOPTIMIZED 等
```
- 说明:`build-and-deploy.sh` 不会自动生成/覆盖你的 `frontend/.env.production`,请确保本地文件存在且与线上一致,否则可能出现与线上不一致的行为(例如图片优化开关不同导致的差异)。
1. 准备部署配置
- 在项目根目录复制示例配置:
```bash
cp deploy.config.example deploy.config
```
- 编辑 `deploy.config`,至少设置:
```bash
DEPLOY_SERVER="<你的服务器IP或域名>"
DEPLOY_USER="root" # 推荐使用 ssh root 登录(简单直接)
DEPLOY_PATH="/root/PrivyDrop" # 你的服务器项目根目录
# 可选:SSH_PORT、SSH_KEY_PATH
```
- 安全建议:生产环境请启用密钥登录、限制来源 IP、开启防火墙(仅放行必要端口)。
2. 本地构建并部署
- 在项目根目录执行:
```bash
bash build-and-deploy.sh
```
- 当脚本检测到现有打包(out.zip)时,可选择:
- 1. 直接部署现有包
- 2. 重新构建并部署
- 脚本流程(简述):
- 本地构建前端与后端
- 将产物打包为 `out.zip`
- 上传至服务器 `/tmp/out.zip`
- 服务器侧备份当前版本到 `/tmp/privydrop_backup/YYYYmmdd_HHMMSS/`
- 解压替换:
- 前端:`frontend/.next`(包含 `.next/standalone` 与 `.next/static`
- 前端静态资源:`frontend/public`
- 前端内容:`frontend/content`(用于博客文件读取)
- 后端:`backend/dist`
- 使用 `pm2 start ecosystem.config.js` 重启应用
3. 发布校验
- 服务器上查看进程状态:
```bash
ssh root@<server> 'sudo pm2 status'
```
- 核对前端 BUILD_ID(可选):
```bash
ssh root@<server> 'cat /root/PrivyDrop/frontend/.next/BUILD_ID'
```
- 浏览器强制刷新或使用隐身模式,确认页面为新版本。
4. 备份和回退(手工)
- 每次部署会在服务器保存结构化备份:`/tmp/privydrop_backup/YYYYmmdd_HHMMSS/`
- 前端:`frontend/.next`
- 后端:`backend/dist`
- 如需回退,可手工执行(示例):
```bash
# 停止 PM2
sudo pm2 stop all && sudo pm2 delete all
# 假设选定备份目录为 /tmp/privydrop_backup/20241024_235959
export DEPLOY_PATH=/root/PrivyDrop
export BACKUP=/tmp/privydrop_backup/20241024_235959
# 恢复前端与后端构建产物
rm -rf "$DEPLOY_PATH/frontend/.next" "$DEPLOY_PATH/backend/dist"
cp -a "$BACKUP/frontend/.next" "$DEPLOY_PATH/frontend/.next"
cp -a "$BACKUP/backend/dist" "$DEPLOY_PATH/backend/dist"
# 重启 PM2
sudo pm2 start ecosystem.config.js
```
5. 常见问题
- 页面仍显示旧版本:清除浏览器缓存/强制刷新;核对 BUILD_ID;检查 Nginx/CDN 缓存。
- 前端博客文章为空:确认服务器目录存在 `frontend/content/blog`,并确保 PM2 前端进程的 `cwd` 为 `./frontend`。
- 部署脚本报错 `out.zip not found`:先选择“重新构建并部署”。
## 5. 故障排除
- **连接问题:** 检查防火墙、Nginx 代理设置、CORS_ORIGIN 配置,确保所有 PM2 进程都在运行。
+18 -5
View File
@@ -19,7 +19,7 @@ In a recent refactor, we established a design philosophy centered on "**Separati
- **Framework**: Next.js 14 (App Router)
- **Language**: TypeScript
- **UI**: React 18, Tailwind CSS, shadcn/ui (based on Radix UI)
- **State Management**: Modular state management centered on custom React Hooks
- **State Management**: Zustand + custom React Hooks (modular business logic with shared app state)
- **WebRTC Signaling**: Socket.IO Client
- **Data Fetching**: React Server Components (RSC), Fetch API
- **Internationalization**: `next/server` middleware + dynamic JSON dictionaries
@@ -118,11 +118,16 @@ This section details how the application's most critical P2P transfer feature is
### 3.2 State Management Strategy
The project uses **custom React Hooks as the core for modular state management**. We deliberately avoided introducing global state management libraries (like Redux or Zustand) for the following reasons:
The project adopts a combined approach of **Zustand + custom Hooks**:
- **Reduce Complexity**: For the current scale of the application, a global state would introduce unnecessary complexity.
- **Promote Cohesion**: Encapsulating related state and logic within the same Hook makes the code easier to understand and maintain.
- **Leverage React's Native Capabilities**: Passing state managed by Hooks through Context and Props is sufficient for all current needs.
- **Zustand (shared app state)**: Manages cross-page/component application-level state such as room/connection states, send/receive progress, and UI states. Implementation lives in `frontend/stores/fileTransferStore.ts`, offering minimal boilerplate and strong type support.
- **Custom Hooks (cohesive business logic)**: Complex flows (WebRTC connection, room management, file transfer orchestration) remain encapsulated within Hooks, preserving cohesion, testability, and reusability.
Benefits:
- **Clear boundaries**: Observable/shared data goes to Zustand; highly cohesive/transient state stays in each module/Hook.
- **Less boilerplate & maintainable**: Zustand remains lightweight while Hooks keep composability and testability.
- **Aligned with reality**: Keeps documentation consistent with the actual implementation.
### 3.3 Internationalization (i18n)
@@ -130,6 +135,14 @@ The project uses **custom React Hooks as the core for modular state management**
- **Automatic Detection**: `middleware.ts` intercepts requests and automatically redirects to the appropriate language path based on the `Accept-Language` header or a cookie.
- **Dynamic Loading**: The `getDictionary` function in `lib/dictionary.ts` asynchronously loads the corresponding `messages/*.json` file based on the `lang` parameter, enabling code splitting.
### 3.4 State & Connection Lifecycle (In-App Navigation)
- **Singleton Store (Zustand)**: `frontend/stores/fileTransferStore.ts` is a module-level singleton, preserving in-memory state across routes (e.g., share content, files to send, received files/meta, progress states).
- **Singleton Connection Service (webrtcService)**: `frontend/lib/webrtcService.ts` holds `RTCPeerConnection`/`RTCDataChannel` and FileSender/FileReceiver as a singleton. App Router page switches do not tear it down.
- **Effect**: In the same browser tab, in-app navigation (App Router page switches) does not interrupt ongoing transfers, and selected/received content remains intact.
- **Boundary**: Page refresh/closing the tab or opening in a new tab is not covered; when changing layout hierarchy/SSR behavior, avoid cleaning the connection in layout unmount.
- **Note**: Do not call `webrtcService.leaveRoom()` or reset the global Store inside route-change side effects; only do so on explicit user actions.
## 4. Summary and Outlook
The current frontend architecture successfully deconstructs a complex WebRTC application into a series of clean, maintainable modules through layered design and Hook-centric logic encapsulation. The boundaries between UI, business logic, and underlying libraries are clear, laying a solid foundation for future feature expansion and maintenance.
+18 -5
View File
@@ -19,7 +19,7 @@ Privydrop 是一个基于 WebRTC 的 P2P 文件/文本分享工具,旨在提
- **框架**: Next.js 14 (App Router)
- **语言**: TypeScript
- **UI**: React 18, Tailwind CSS, shadcn/ui (基于 Radix UI)
- **状态管理**: 自定义 React Hooks 为核心的模块化状态管理
- **状态管理**: Zustand + 自定义 React Hooks(模块化业务逻辑与全局共享状态结合)
- **WebRTC 信令**: Socket.IO Client
- **数据获取**: React Server Components (RSC), Fetch API
- **国际化**: `next/server` 中间件 + 动态 JSON 字典
@@ -118,11 +118,16 @@ graph TD
### 3.2 状态管理策略
项目**以自定义 React Hooks 为核心进行模块化状态管理**。我们刻意避免了引入全局状态管理库(如 Redux, Zustand),理由如下
当前项目采用“Zustand + 自定义 Hooks”的组合策略
- **降低复杂性**: 对于当前应用规模,全局状态会引入不必要的复杂性
- **促进内聚**: 将相关联的状态和逻辑封装在同一个 Hook 内,使得代码更易于理解和维护
- **利用 React 原生能力**: 通过 Context 和 Props 传递由 Hooks 管理的状态,足以满足当前所有需求。
- **Zustand(全局共享状态)**: 用于管理跨页面/跨组件的应用级状态,例如房间与连接状态、发送/接收进度、UI 活动 Tab 等。实现位于 `frontend/stores/fileTransferStore.ts`API 简洁、零样板、类型友好
- **自定义 Hooks(业务内聚**: 复杂的业务流程(如 WebRTC 连接、房间管理、文件传输编排)仍以 Hooks 为边界进行封装,保持“逻辑内聚、可测试、可复用”
这样做的收益:
- **边界清晰**: 全局可观察/可共享的数据进 Zustand,强业务内聚的瞬时/局部状态放在各自 Hook/模块内。
- **减样板与可维护**: Zustand 足够轻量,不引入冗长样板;同时保留 Hooks 的可组合性和可测试性。
- **更贴合现状**: 与代码实现保持一致,避免文档与实现脱节。
### 3.3 国际化 (i18n)
@@ -130,6 +135,14 @@ graph TD
- **自动检测**: `middleware.ts` 拦截请求,根据 `Accept-Language` 头或 Cookie 自动重定向到合适的语言路径。
- **动态加载**: `lib/dictionary.ts` 中的 `getDictionary` 函数根据 `lang` 参数异步加载对应的 `messages/*.json` 文件,实现了代码分割。
### 3.4 状态与连接生命周期(站内导航保持)
- **单例 StoreZustand**`frontend/stores/fileTransferStore.ts` 为模块级单例,跨路由保持内存状态(如分享内容、待发送文件、已接收文件/元信息、进度等)。
- **单例连接服务(webrtcService**`frontend/lib/webrtcService.ts` 单例持有 `RTCPeerConnection`/`RTCDataChannel` 与 FileSender/FileReceiver。App Router 的页面切换不会销毁该实例。
- **效果**:在同一标签页内的站内跳转(App Router 页面切换),进行中的传输不会中断,已选择/已接收的内容保持不丢失。
- **边界**:刷新/关闭标签页或新开标签页不在此保证范围内;SSR/布局层级调整时需确保不在布局卸载处做连接清理。
- **注意**:不要在路由切换副作用中调用 `webrtcService.leaveRoom()` 或重置全局 Store;离开房间应仅在用户显式操作时触发。
## 四、 总结与展望
当前的前端架构通过分层设计和以 Hooks 为中心的逻辑封装,成功地将一个复杂的 WebRTC 应用拆解为一系列清晰、可维护的模块。UI、业务逻辑和底层库之间的界限分明,为未来的功能扩展和维护奠定了坚实的基础。
+154
View File
@@ -0,0 +1,154 @@
# PrivyDrop AI Playbook — 代码地图(中文)
本地图以“快速定位”为目标,仅给出目录与关键入口文件的简要说明,不包含“常改动点/影响范围”。
## 前端(Next.jsTypeScript
- `frontend/app/` — App Router 路由与页面。
- `frontend/app/[lang]/page.tsx` — 主页入口,生成元数据和 SEO 结构化数据(JsonLd),支持多语言 canonical 链接。
- `frontend/app/[lang]/*/page.tsx` — 静态页面:features(功能特性)、about(关于)、faq(常见问题)、help(帮助)、terms(服务条款)、privacy(隐私政策),均包含多语言 SEO 元数据生成。
- `frontend/app/[lang]/blog/page.tsx` — 博客列表页,展示多语言文章列表。
- `frontend/app/[lang]/blog/[slug]/page.tsx` — 博客文章详情页,支持 MDX 渲染、目录生成、面包屑导航和 JSON-LD 结构化数据。
- `frontend/app/[lang]/blog/tag/[tag]/page.tsx` — 博客标签页,按标签分类展示文章。
- `frontend/app/[lang]/layout.tsx` — 全局布局与 Provider 注入,包含 ThemeProvider、Header/Footer,生成组织架构和网站结构化数据。
- `frontend/app/[lang]/HomeClient.tsx` — 主客户端组件,组织页面结构(Hero 区域、ClipboardApp、HowItWorks、视频演示、系统架构图、功能特性、FAQ),支持多平台视频链接(YouTube/B 站)。
- `frontend/app/api/health/route.ts` — 基础健康检查 API。
- `frontend/app/api/health/detailed/route.ts` — 详细健康检查 API。
- `frontend/app/sitemap.ts` — 站点地图生成,支持多语言 URL 和博客文章动态收录。
- `frontend/middleware.ts` — i18n 与路由中间件。
- `frontend/app/config/environment.ts` — 运行时/环境配置(ICE、端点等)。
- `frontend/app/config/api.ts` — 后端 API 交互封装。
- `frontend/components/` — UI,包括协调器与子组件。
- `frontend/components/ClipboardApp.tsx` — 顶层 UI 协调器,集成 5 个业务 hooksuseWebRTCConnection/useFileTransferHandler/useRoomManager/usePageSetup/useClipboardAppMessages),处理全局拖拽事件和双标签页(发送/接收)管理。
- 体验增强:切到接收端(retrieve)且满足“未在房间、URL 无 roomId、输入为空、存在缓存ID”时自动填充并加入房间(读取 `frontend/lib/roomIdCache.ts`)。
- `frontend/components/ClipboardApp/SendTabPanel.tsx` — 发送面板,集成富文本编辑器、文件上传、房间 ID 生成(支持 4 位数字/UUID 两种模式)、分享链接生成。
- 体验增强:点击“使用缓存ID”将立即触发加入房间(sender 侧),减少一次手动点击。
- `frontend/components/ClipboardApp/RetrieveTabPanel.tsx` — 接收面板,处理房间加入、文件接收、目录选择(File System Access API)、富文本内容显示。
- `frontend/components/ClipboardApp/FileListDisplay.tsx` — 文件列表显示组件,支持文件/文件夹分组显示、进度跟踪、多浏览器下载策略(Chrome 自动下载/其他浏览器手动保存)、下载计数统计。
- `frontend/components/ClipboardApp/FullScreenDropZone.tsx` — 全屏拖拽提示组件,文件拖拽时的视觉反馈。
- `frontend/components/ClipboardApp/*` — 其他子组件:FileUploadHandler(文件上传处理)、ShareCard(二维码分享)、TransferProgress(进度条)、CachedIdActionButton(缓存 ID 操作)、FileTransferButton(文件传输按钮)。
- `frontend/components/Editor/` — 富文本编辑器模块,包含 RichTextEditor 主编辑器、工具栏组件(BasicFormatTools/FontTools/AlignmentTools/InsertTools)、SelectMenu 下拉选择、类型定义和编辑器 hooks。
- `frontend/components/blog/` — 博客相关组件,包含 TableOfContents(支持中文目录生成和滚动跟踪)、Mermaid 图表渲染、MDXComponents、ArticleListItem 文章列表。
- `frontend/components/common/` — 通用组件,包含 clipboard_btn(读写剪贴板按钮)、AutoPopupDialog(自动弹出对话框)、LazyLoadWrapper(懒加载包装器)、YouTubePlayerYouTube 播放器)。
- `frontend/components/web/` — 网站页面组件,包含 Header(响应式导航和多语言支持)、Footer(版权和语言链接)、FAQSection(可配置 FAQ 展示)、HowItWorks(步骤说明和视频演示)、SystemDiagram(系统架构图)、KeyFeatures(功能特性展示)、theme-provider 主题提供者。
- `frontend/components/web/ThemeToggle.tsx` — 主题切换按钮(单按钮 Light/Dark 切换),集成于 Header(桌面与移动)。
- `frontend/components/seo/JsonLd.tsx` — SEO 结构化数据组件,支持多类型 JSON-LD 数据生成。
- `frontend/components/LanguageSwitcher.tsx` — 语言切换器。
- `frontend/components/ui/*` — 基础 UI 原子组件(基于 Radix UI 和 shadcn/ui),包含 Button(多变体按钮)、Accordion(手风琴)、Dialog(模态对话框)、Card(卡片)、Tooltip(工具提示)、Select、Input、Textarea、Checkbox、DropdownMenu、Toast 通知系统和 AnimatedButton 动画按钮。
- `frontend/hooks/` — 业务逻辑中枢(React Hooks)。
- `frontend/hooks/useWebRTCConnection.ts` — WebRTC 生命周期与编排 API。
- `frontend/hooks/useRoomManager.ts` — 房间创建/加入/校验与 UI 状态,支持缓存 ID 重连(≥8 字符自动发送 initiator-online)。
- `frontend/hooks/useFileTransferHandler.ts` — 文件/文本负载编排与回调,使用 getState() 修复闭包问题,支持 JSZip 文件夹下载。
- `frontend/hooks/useClipboardActions.ts` — 剪贴板操作与状态管理,支持现代 API 和 document.execCommand 降级,处理 HTML/富文本粘贴。
- `frontend/hooks/useClipboardAppMessages.ts` — 应用消息处理(shareMessage/retrieveMessage),4 秒自动消失机制。
- `frontend/hooks/useLocale.ts` — 国际化语言切换,基于 pathname 解析。
- `frontend/hooks/usePageSetup.ts` — 页面配置与 SEO 设置,处理 URL 参数 roomId 自动加入和引荐来源追踪。
- `frontend/hooks/useRichTextToPlainText.ts` — 富文本转纯文本工具,处理块级元素换行和文本节点包装。
- `frontend/lib/` — 核心库与工具。
- WebRTC 基础与角色
- `frontend/lib/webrtc_base.ts` — WebRTC 基础类,提供 Socket.IO 信令、RTCPeerConnection 管理、ICE 候选者队列、双重断开检测重连机制、唤醒锁管理、数据通道发送重试(5 次递增间隔)、优雅断开跟踪(gracefullyDisconnectedPeers Set)和多格式数据类型兼容性支持(ArrayBuffer/Blob/Uint8Array/TypedArray)。
- `frontend/lib/webrtc_Initiator.ts` — 发起方实现,处理`ready`/`recipient-ready`事件,创建 RTCPeerConnection 和主动式 DataChannel,发送 offer,处理 answer 响应,支持 256KB 缓冲阈值配置。
- `frontend/lib/webrtc_Recipient.ts` — 接收方实现,处理`offer`事件,创建 RTCPeerConnection 和响应式 DataChannelondatachannel),生成并发送 answer,处理`initiator-online`重连信号和现有连接清理。
- `frontend/lib/webrtcService.ts` — WebRTC 服务单例封装(跨路由常驻),管理 sender/receiver 实例,提供统一业务接口,处理连接状态变更、数据广播、文件请求和连接断开清理。
- 发送(sender
- `frontend/lib/fileSender.ts` — 发送端向后兼容包装层,内部使用 FileTransferOrchestrator 提供统一服务。
- `frontend/lib/transfer/FileTransferOrchestrator.ts` — 发送端主编排器,集成所有组件管理文件传输生命周期。
- `frontend/lib/transfer/StreamingFileReader.ts` — 高性能流式文件读取器,采用 32MB 批次+64KB 网络块的双层缓冲架构。
- `frontend/lib/transfer/NetworkTransmitter.ts` — 网络传输器,使用 WebRTC 原生背压控制,支持嵌入元数据分片发送。
- `frontend/lib/transfer/StateManager.ts` — 状态管理中心,跟踪 peer 状态、待发送文件、文件夹元数据。
- `frontend/lib/transfer/ProgressTracker.ts` — 进度跟踪器,处理文件/文件夹进度计算、速度统计和回调触发。
- `frontend/lib/transfer/MessageHandler.ts` — 消息处理器,负责 WebRTC 消息路由(fileRequest/fileReceiveComplete/folderReceiveComplete)。
- `frontend/lib/transfer/TransferConfig.ts` — 传输配置管理,定义文件读取 4MB 分片、32MB 批次、64KB 网络发送块。
- 接收(receiver
- `frontend/lib/fileReceiver.ts` — 接收端向后兼容包装层,内部使用 FileReceiveOrchestrator 提供统一服务。
- `frontend/lib/receive/FileReceiveOrchestrator.ts` — 接收端主编排器,集成所有组件管理文件接收生命周期,支持断点续传和磁盘流式写入。
- `frontend/lib/receive/ReceptionStateManager.ts` — 状态管理中心,管理文件元数据、活跃接收状态、文件夹进度、保存类型配置。
- `frontend/lib/receive/ChunkProcessor.ts` — 分片处理器,处理多种数据格式转换、嵌入元数据解析、分片验证和索引映射。
- `frontend/lib/receive/StreamingFileWriter.ts` — 流式文件写入器,包含 SequencedDiskWriter 严格顺序写入机制,支持大文件磁盘流式写入。
- `frontend/lib/receive/FileAssembler.ts` — 内存文件组装器,处理小块文件的内存重组、完整性校验和文件对象创建。
- `frontend/lib/receive/MessageProcessor.ts` — 消息处理器,负责 WebRTC 消息路由(fileMeta/stringMetadata/fileRequest/folderReceiveComplete)。
- `frontend/lib/receive/ProgressReporter.ts` — 进度报告器,处理文件/文件夹进度计算、速度统计和节流回调。
- `frontend/lib/receive/ReceptionConfig.ts` — 接收配置管理,定义大文件阈值 1GB、64KB 分片、缓冲区大小和调试开关。
- 工具与辅助
- `frontend/lib/fileReceiver.ts``frontend/lib/fileUtils.ts``frontend/lib/speedCalculator.ts``frontend/lib/utils.ts` — 基础工具。
- `frontend/lib/roomIdCache.ts` — 房间 ID 缓存管理。
- `frontend/lib/wakeLockManager.tsx` — 屏幕唤醒锁管理(移动端优化)。
- `frontend/lib/utils/ChunkRangeCalculator.ts` — 文件分片范围计算。
- `frontend/lib/browserUtils.ts` — 浏览器兼容性工具。
- `frontend/lib/tracking.ts` — 用户行为追踪。
- `frontend/lib/dictionary.ts``frontend/lib/mdx-config.ts``frontend/lib/blog.ts` — i18n/内容与 SEO 辅助。
- `frontend/stores/` — 共享应用状态(Zustand)。
- `frontend/stores/fileTransferStore.ts` — 传输进度/状态的唯一事实来源(Zustand 单例,跨路由保持)。
- `frontend/types/``frontend/constants/` — 类型定义与常量。
- `frontend/types/global.d.ts` — 全局类型定义(lodash 模块、FileSystemDirectoryHandle 接口)。
- `frontend/types/messages.ts` — 多语言消息与 UI 内容类型定义(Meta、Text、Messages 等国际化结构)。
- `frontend/types/webrtc.ts` — WebRTC 传输协议类型(文件元数据、分片结构、状态机接口)。
- `frontend/constants/messages/` — 多语言消息文件(7 种语言:en、zh、de、es、fr、ja、ko)。
- `frontend/constants/i18n-config.ts` — 国际化配置(默认语言、支持语言列表、显示名称映射)。
- `frontend/content/` — 内容资源。
- `frontend/content/blog/` — 博客文章(MDX 格式,多语言),包含开源发布、WebRTC 文件传输、断点续传等主题文章。
- `frontend/lib/blog.ts` — 博客工具函数,支持多语言文章读取、frontmatter 解析、标签提取和内容验证。
- **配置与构建**
- `frontend/package.json``frontend/tsconfig.json``frontend/tailwind.config.ts` — 项目配置。
- `frontend/next.config.mjs``frontend/postcss.config.mjs``frontend/components.json` — Next.js 与组件配置。
- `frontend/.eslintrc.json` — 代码检查配置。
- `frontend/Dockerfile``frontend/health-check.js` — Docker 部署与健康检查。
## 后端(ExpressSocket.IORedis
- `backend/src/server.ts` — 启动入口:Express + Socket.IO 初始化与监听。
- `backend/src/config/env.ts``backend/src/config/server.ts` — 环境与服务配置。
- `backend/src/config/env.ts` — 环境变量配置与验证,包含端口、CORS、Redis 连接设置,支持开发/生产环境自动加载对应.env 文件。
- `backend/src/config/server.ts` — CORS 配置,区分开发/生产环境,支持多域名配置和 LAN 地址正则匹配。
- `backend/src/routes/api.ts` — REST:房间创建/校验、追踪、调试日志。
- `backend/src/routes/health.ts` — 健康检查。
- `backend/src/socket/handlers.ts` — 信令事件:`join``initiator-online``recipient-ready``offer``answer``ice-candidate`
- `backend/src/services/redis.ts` — Redis 客户端。
- `backend/src/services/room.ts` — 房间/成员存储与辅助。
- `backend/src/services/rateLimit.ts` — 基于 Redis 有序集的 IP 限流。
- `backend/src/types/room.ts``backend/src/types/socket.ts` — 类型定义与接口。
- `backend/src/types/socket.ts` — Socket.IO 相关类型,包含 JoinData 房间加入数据、SignalingData WebRTC 信令数据(offer/answer/candidate)、InitiatorData 发起方数据、RecipientData 接收方数据。
- `backend/src/types/room.ts` — 房间相关类型,包含 RoomInfo 房间信息(创建时间)、ReferrerTrack 来源追踪数据、LogMessage 日志消息结构。
- **后端配置与脚本**
- `backend/package.json``backend/tsconfig.json` — 项目配置。
- `backend/Dockerfile``backend/.dockerignore` — Docker 配置。
- `backend/health-check.js` — 健康检查脚本。
- `backend/scripts/export-tracking-data.js` — 数据导出脚本。
- `backend/docker/` — Docker 相关配置与脚本(包含 Nginx、TURN 服务器配置)。
## 部署与运维
- **根目录配置**
- `docker-compose.yml``ecosystem.config.js` — Docker Compose 与 PM2 配置。
- `build-and-deploy.sh``deploy.sh` — 构建与部署脚本。
- `deploy.config_prod``deploy.config_test` — 生产与测试环境配置。
- **Docker 基础设施**
- `docker/nginx/` — Nginx 反向代理配置。
- `docker/scripts/` — 部署相关脚本(环境检测、配置生成、部署测试)。
- `docker/ssl/` — SSL 证书目录。
- `docker/coturn/` — TURN 服务器配置。
- `docker/letsencrypt-www/` — Let's Encrypt 配置。
- **构建与文档**
- `build/` — 请忽略这个临时目录。
- `test-health-apis.sh` — 健康 API 测试脚本。
- `README.md``README.zh-CN.md``ROADMAP.md``ROADMAP.zh-CN.md` — 项目文档。
+149
View File
@@ -0,0 +1,149 @@
# PrivyDrop AI Playbook — 协作规则(中文)
本规则面向“人类开发者 + AI 助手”的协作,确保在保持隐私立场与技术基线的前提下,高效而可控地演进代码。与目录索引(index.zh-CN.md)和流程地图(flows.zh-CN.md)互补:它约束“如何做”,不复述“做什么”。
- 适用范围:本仓库全部代码与文档
- 读者对象:人类开发者、AI 助手、评审者
- 变更原则:最佳实践优先、一次只解决一类问题、可回滚、可验证
## 一、协作原则
- Best Practices 优先:选用经过验证且与现有栈一致的方案,避免“自造轮子”。
- 单一主题:每个变更聚焦一个目标,避免“顺手修复”无关问题。
- 隐私立场:严禁引入服务器中转文件数据的实现或建议;后端仅做信令与房间协调。
- 小步快跑:小 PR、易回滚,优先最小可行改动。
- 可追溯:提交信息、PR 描述、代码注释清晰、可复现。
## 二、计划先行(强约束)
任何实现前,必须先提交“变更计划”,经同意后再实施。计划应包含:目标、影响范围与文件列表、方案概述、风险与缓解、验收标准、回滚策略、需更新的文档、验证方式。
推荐模板见“模板”章节。实施前需阅读并引用:
- docs/ai-playbook/index.zh-CN.md
- docs/ai-playbook/code-map.zh-CN.md
- docs/ai-playbook/flows.zh-CN.md
## 三、语言与注释
- 沟通语言:与项目负责人沟通一律使用中文(简体)。
- 代码注释、导出符号命名、提交信息、PR 标题/描述一律英文。
- 用户/市场文档可中英双语;本协作规则为中文。
- 导出函数、复杂流程、公共类型使用 TSDoc/JSDoc(英文),保证 API 可读性。
## 四、Next.js(前端)约定
- App Router 默认 Server Components;仅在确需交互时使用 "use client"。
- 复用现有 UITailwind + shadcn/ui + Radix);未经批准不引入新组件库。
- i18n:所有可见文案走字典与 `frontend/app/[lang]` 路由,不在组件内硬编码。
- 命名与文件:
- 组件:PascalCase 文件与导出(ExampleCard.tsx
- HookscamelCase 文件,导出以 use\* 开头(useSomething.ts
- 类型/常量集中维护,避免循环依赖
- SEO:使用 Next Metadata 与 `frontend/components/seo/JsonLd.tsx`;页面需补 canonical、多语言链接。
- 性能与可访问性:按需动态导入重组件;确保 aria/焦点管理基本可用。
## 五、TypeScript 与风格
- 类型严格,避免 any;必要时用 unknown 并显式收窄;导出函数显式返回类型。
- 遵循现有 ESLint/Prettier 与路径别名(`@/...`);不引入新格式化器。
- 函数小而清晰;复杂逻辑下沉到 service/util,组件只消费不变更状态。
- 不使用一字母变量名;避免魔法数,集中到常量。
## 六、WebRTC/传输“护栏”(不得突破)
- 保持既定策略:32MB 批次 + 64KB 网络块;DataChannel bufferedAmountLowThreshold 与 maxBuffer 策略不随意更改。
- 断点续传、严格顺序写入、多格式兼容是默认能力,禁止降级或移除。
- 信令与消息名(offer/answer/ice-candidate 等)保持兼容;如需破坏性变更,必须走“必须请示”流程。
- 重连与队列处理(ICE 候选缓存、背压、发送重试)策略保持一致,变更需风险评估与充分验证。
- 严禁将文件内容(任何形式)发往服务器或第三方服务。
## 七、后端约束(信令服务)
- 仅负责信令与房间管理;不落地用户文件数据;日志中不得包含敏感内容或原始 payload。
- 速率与滥用防护保留;如需扩展接口,必须保证向后兼容或提供迁移策略。
## 八、依赖与安全
- 新依赖需在计划中论证:体积(含 ESM/SSR 兼容性)、维护健康度、许可、替代方案、安全影响。
- 不引入遥测/埋点;不将敏感数据写入日志;最小权限原则。
- 配置通过环境变量注入;严禁在仓库中硬编码密钥或服务端点。
## 九、文档同步更新
- 代码改动若影响流程、接口或关键文件入口,须同步更新:
- docs/ai-playbook/flows.zh-CN.md
- docs/ai-playbook/code-map.zh-CN.md
- PR 必须列出“受影响文档”,避免 AI Playbook 过时;索引页(index.zh-CN.md)保持简洁,仅新增链接时更新。
## 十、验证与回归
- 前端:能构建通过(next build);关键路径手测说明(至少:创建/加入房间、单/多文件、文件夹、大文件、断点续传、双浏览器互传、i18n 路由与 SEO 元数据)。
- 后端:Socket.IO 基本流程可用。
- 回归清单:重连流程、下载计数与状态清理、Store 单一数据源约束、浏览器兼容(Chromium/Firefox)。
## 十一、必须请示(需先获批)
- 协议/消息名/公共 API/存储格式的破坏性变更。
- 影响隐私立场或跨边界的架构调整(如任何形式的中转或持久化)。
- 引入新依赖、新基础设施或大规模重构。
- 修改传输“护栏”参数(分片、背压、重试等)。
## 十二、常见误区
- 组件内直改全局状态(违背单向数据流)。
- 只改代码不更文档,导致 Playbook 过期。
- 使用 any 绕过类型与边界检查。
- 将 UI 文案硬编码在组件内,绕过字典/i18n。
- 擅自调整 WebRTC 关键参数,导致隐性性能回退或兼容性问题。
## 十三、模板
变更计划模板
```
Title: <简明标题>
Goals
- <预期达成的目标>
Scope / Files
- <将修改与新增的文件路径清单 + 原因>
Approach
- <实现思路与关键设计点>
Risks & Mitigations
- <主要风险> → <缓解策略>
Acceptance Criteria
- <可验证的验收项>
Rollback
- <如何快速回滚>
Docs to Update
- code-map.zh-CN.md / flows.zh-CN.md / README(.zh-CN).md / others?
Validation
- Build: next build / backend health
- Manual: <列出关键用例>
```
PR 校验清单
```
- [ ] 仅包含单一主题改动
- [ ] 代码注释与提交信息为英文
- [ ] 未引入未批准的依赖/组件库
- [ ] i18n 与 SEO 按约定接入(如适用)
- [ ] 传输护栏未被破坏(或已获批且有验证)
- [ ] flows / code-map 文档已同步
- [ ] 附带验证说明与回归清单
```
## 十四、引用与快速入口
- 索引与上下文:docs/ai-playbook/index.zh-CN.md
- 代码地图:docs/ai-playbook/code-map.zh-CN.md
- 关键流程:docs/ai-playbook/flows.zh-CN.md
+694
View File
@@ -0,0 +1,694 @@
# PrivyDrop AI Playbook — 流程(含微方案模板,中文)
本文汇总 P2P 传输与信令重连的关键流程与消息序列,并给出简明的调试要点与“微方案模板”。用于在改动前快速对齐阶段、事件与入口文件。
## 1)文件传输(单文件)
序列(通过 DataChannel,发送端 ↔ 接收端):
1. 发送端 → `fileMetadata`id、name、size、type、fullName、folderName)。
2. 接收端 → `fileRequest`(确认元信息;支持 offset 续传)。
3. 发送端 → 分片流(高性能双层缓冲架构):
- StreamingFileReader 使用 32MB 批次读取 + 64KB 网络块发送
- NetworkTransmitter 使用 WebRTC 原生背压控制(bufferedAmountLowThreshold
- 发送时嵌入元数据(chunkIndex、totalChunks、fileOffset、fileId
4. 接收端 → 完整性检查与组装(严格顺序写入或内存组装,支持断点续传)。
5. 接收端 → `fileReceiveComplete`(成功回执,包含 receivedSize)。
6. 发送端 → MessageHandler 触发 100% 进度回调,清理发送状态。
发送侧详细流程:
1. FileTransferOrchestrator.sendFileMeta() → StateManager 记录文件夹文件大小
2. 接收 fileRequest → FileTransferOrchestrator.handleFileRequest()
3. 初始化 StreamingFileReader(支持 startOffset 续传)
4. processSendQueue() 循环:
- getNextNetworkChunk() 获取 64KB 块(批次内高效切片)
- NetworkTransmitter.sendEmbeddedChunk() 背压控制发送
- ProgressTracker.updateFileProgress() 更新进度和速度
5. 等待 fileReceiveComplete 确认,清理 isSending 状态
入口:
- 发送侧:`frontend/lib/fileSender.ts`(兼容层)→ `frontend/lib/transfer/FileTransferOrchestrator.ts`(主编排器)
- 关键组件:StreamingFileReader(高性能读取)、NetworkTransmitter(背压发送)、StateManager(状态管理)、ProgressTracker(进度计算)
接收侧详细流程:
1. MessageProcessor.handleFileMetadata() → ReceptionStateManager 记录文件元数据
2. FileReceiveOrchestrator.requestFile() → 检查断点续传(getPartialFileSize
3. 初始化接收:计算期望分片数,根据文件大小选择存储方式(内存 vs 磁盘)
4. 发送 fileRequest(带 offset 参数)→ 等待发送端开始传输
5. handleBinaryChunkData() 循环:
- ChunkProcessor.convertToArrayBuffer() 处理多种数据格式(Blob/Uint8Array/ArrayBuffer
- ChunkProcessor.parseEmbeddedChunkPacket() 解析嵌入元数据包格式
- ChunkProcessor.validateChunk() 验证 fileId、chunkIndex、chunkSize
- 存储分片到 chunks 数组(或通过 SequencedDiskWriter 顺序写入磁盘)
- ProgressReporter.updateFileProgress() 节流更新进度(100ms 间隔)
6. 自动完成检测:checkAndAutoFinalize() 验证分片完整性
7. 根据存储方式选择最终化:
- 大文件/磁盘存储:StreamingFileWriter.finalizeWrite()
- 小文件/内存存储:FileAssembler.assembleFileFromChunks()
8. 发送 fileReceiveComplete 确认,包含 receivedSize 和 receivedChunks
入口:
- 发送侧:`frontend/lib/fileSender.ts`(兼容层)→ `frontend/lib/transfer/FileTransferOrchestrator.ts`(主编排器)
- 接收侧:`frontend/lib/fileReceiver.ts`(兼容层)→ `frontend/lib/receive/FileReceiveOrchestrator.ts`(主编排器)
- 关键组件:StreamingFileReader(高性能读取)、NetworkTransmitter(背压发送)、ChunkProcessor(格式处理)、StreamingFileWriter(磁盘写入)、FileAssembler(内存组装)
备注:
- **发送侧**:双层缓冲架构(32MB 批次+64KB 网络块),WebRTC 原生背压控制,支持断点续传
- **接收侧**:严格顺序写入机制(SequencedDiskWriter),支持多种数据格式转换,智能存储选择(≥1GB 文件自动磁盘存储)
- **兼容性处理**ChunkProcessor 支持 Blob/Uint8Array/ArrayBuffer 多种格式,解决 Firefox 兼容性问题
- **进度节流**ProgressReporter 使用不同频率更新(文件 100ms,文件夹 200ms),避免 UI 过载
- **断点续传**:通过 getPartialFileSize() 检查本地部分文件,fileRequest.offset 参数指定续传位置
- **调试支持**ReceptionConfig 提供详细的分片日志和进度日志,便于问题排查
## 2)文件传输(文件夹)
序列(对文件逐个进行单文件流程):
1. 发送端 → 发送文件夹内全部文件的 `fileMetadata`
2. 接收端 → `folderRequest`(确认开始批量传输)。
3. 每个文件:按“单文件流程”执行,但单个文件完成时不标记全局 100%。
4. 接收端 → 所有文件完成后发送 `folderReceiveComplete`
5. 发送端 → 将文件夹整体进度标记为 100%(触发最终回调)。
## 3)信令与重连(Socket.IO
高层序列:
1. 客户端 → REST:创建或获取 `roomId``backend/src/routes/api.ts`)。
2. 客户端 → Socket.IO`join` 房间(后端校验并绑定 socket 到房间)。
3. 在房间内进行在线状态与重连协作:
- 发起方 → `initiator-online`(上线/就绪,通知对端可重建连接)。
- 接收方 → `recipient-ready`(表示就绪;发起方可发起 offer)。
4. WebRTC 协商转发:
- `offer` → 后端 → 转发给对端。
- `answer` → 后端 → 转发给对端。
- `ice-candidate` → 后端 → 转发给对端。
重连机制细节(移动端网络切换支持):
- **双重断开检测**Socket.IO 断开触发 `disconnect` 事件 → 标记 `isSocketDisconnected = true`P2P 连接断开触发 `disconnected` 状态 → 标记 `isPeerDisconnected = true`,自动调用 `cleanupExistingConnection()` 清理资源
- **重连触发条件**:仅当 socket 和 P2P 都断开时才启动重连 → `attemptReconnection()`,防止重复重连;使用 `reconnectionInProgress` 标志防止并发重连
- **状态恢复机制**:重连时调用 `joinRoom(roomId, isInitiator, sendInitiatorOnline)` 恢复状态,发送方自动发送 `initiator-online` 信号,接收方响应 `recipient-ready`
- **ICE 候选者队列机制**:连接未就绪时缓存候选者到 `iceCandidatesQueue` Map,连接就绪后批量处理;支持候选者失效时的重新入队和连接状态验证
- **唤醒锁管理**:连接建立时通过 `WakeLockManager` 请求屏幕唤醒锁,连接断开时释放,优化移动端传输稳定性
- **优雅断开跟踪**`gracefullyDisconnectedPeers` Set 跟踪正常断开的 peer,发送重试时跳过这些 peer,避免不必要的重试
- **数据通道发送重试**:5 次重试机制,间隔从 100ms 递增到 1000ms,支持 `gracefullyDisconnectedPeers` 检测跳过重试
**后端信令与房间管理**
Socket.IO 事件处理流程:
1. **join 事件**:IP 限流检查 → 房间存在性验证 → socket-room 绑定 → 成功响应 → 广播 `ready` 通知新用户加入
2. **重连状态同步**:发送方重连时发送 `initiator-online` 信号,接收方响应 `recipient-ready` 确认就绪状态
3. **信令转发**offer/answer/ice-candidate 直接通过 `socket.to(peerId).emit()` 转发给目标客户端,包含 from 字段标识发送者
4. **断开清理**:广播 `peer-disconnected` → 解绑 socket-room 关系 → 空房间 15 分钟后删除
**房间管理机制**
- Redis 数据结构:
- `room:<roomId>` (Hash): 存储房间创建时间
- `room:<roomId>:sockets` (Set): 管理房间内 socket 连接
- `socket:<socketId>` (String): 存储 socket 对应的 roomId
- ID 生成策略:优先 4 位数字 ID,冲突时切换到 4 位字母数字 ID
- 幂等设计:长 ID(≥8 字符)支持重连时的房间复用
- TTL 管理:24 小时过期,活动时自动刷新
**限流保护**
- 基于 Redis Sorted Set 实现 IP 限流
- 5 秒时间窗口最多允许 2 次请求
- 使用 pipeline 确保原子性操作
入口:
- 前端:`frontend/hooks/useWebRTCConnection.ts``frontend/lib/webrtc_base.ts``frontend/lib/webrtc_Initiator.ts``frontend/lib/webrtc_Recipient.ts`
- 后端:`backend/src/socket/handlers.ts`(全部信令事件)、`backend/src/services/room.ts``backend/src/routes/api.ts`
## 4DataChannel 消息与约束(概览)
- 消息(示例命名):`fileMetadata``fileRequest``chunk``fileReceiveComplete``folderRequest``folderReceiveComplete`,以及可能的流控/保活。
- 核心字段:文件/文件夹 id、索引/范围、大小、名称、可选校验信息。
- 关键约束:
- 分片大小:按浏览器/网络选择安全范围;注意通道缓冲阈值。
- 背压:检查 `RTCDataChannel.bufferedAmount` 并按需节流。
- 完成:仅在收到 `fileReceiveComplete`/`folderReceiveComplete` 后标记 100%。
- 续传:`fileRequest` 可设计为带 offset/range 以支持续传。
## 5)调试要点(凝练自历史经验)
- 下载竞争/重复计数:以 `frontend/stores/fileTransferStore.ts` 为单一事实来源;在 Store 层提供清理 API(如 `clearSendProgress``clearReceiveProgress`),避免组件本地删除对象导致重复计数。
- 接收方重连与房间状态:正确的状态重置;UI 严格来源于 Store;离开/重进需清理相关状态;遵循 `initiator-online`/`recipient-ready` 的时序再发起 offer;重连后校验房间成员关系。
- 缓存 roomId 的重连:若存在缓存 `roomId`,确保依赖在线状态同步(`initiator-online`/`recipient-ready`)触发重新协商;后端需保证 socket↔room 映射在断开/重连路径上被正确清理与恢复。
- 多次传输计数:避免过度“去重”掩盖真实的二次下载;依赖正确的状态清理。
- 数据流原则:单向数据流(Store → Hooks → Components);Hooks 做适配,组件只消费不修改。
- **实用调试策略**
- 为连接状态变化与 Store 更新添加结构化日志;遇到时序/竞态可用 `setTimeout(..., 0)` 调整更新顺序
- DataChannel 发送重试机制:`sendToPeer()` 支持 5 次重试,间隔 100ms→1000ms 递增;优雅断开的 peer 跳过重试
- WebRTC 数据类型兼容性:支持 `ArrayBuffer`/`Blob`/`Uint8Array`/`TypedArray` 多种格式,解决 Firefox 兼容性问题
- 连接状态监控:`connectionState` 变化时触发相应的处理逻辑(connected/disconnected/failed/closed
- 背压控制:DataChannel 设置 `bufferedAmountLowThreshold = 256KB`,发送时检查 `bufferedAmount` 状态
## 6)前端组件系统与业务中枢协作流程
### 组件架构层级
```
App Router (page.tsx/layout.tsx)
HomeClient (页面布局与SEO)
ClipboardApp (顶层UI协调器)
SendTabPanel/RetrieveTabPanel (功能面板)
业务中枢 Hooks (状态管理与业务逻辑)
Core Services (webrtcService) + Store (fileTransferStore)
```
### ClipboardApp 顶层协调器模式
**核心职责**
- 集成 5 个关键业务 hooksuseWebRTCConnection、useFileTransferHandler、useRoomManager、usePageSetup、useClipboardAppMessages
- 全局拖拽事件处理:dragenter/dragleave/dragover/drop,支持多文件和文件夹树遍历
- 双标签页状态管理:发送/接收面板切换,通过 activeTab 控制
- 统一消息系统:shareMessage/retrieveMessage 4 秒自动消失机制
### Hook 层级与职责分离
**useWebRTCConnection**(状态桥梁):
- 计算全局传输状态(isAnyFileTransferring
- 暴露 webrtcService 方法(broadcastDataToAllPeers、requestFile、requestFolder
- 提供连接重置方法(resetSenderConnection、resetReceiverConnection
**useFileTransferHandler**(文件与内容管理):
- 文件操作:addFilesToSend(去重)、removeFileToSend
- 下载功能:handleDownloadFile(支持文件夹压缩下载)
- 关键修复:使用 `useFileTransferStore.getState()` 获取最新状态,避免闭包问题
- 重试机制:最大 3 次重试,50ms 间隔,详细错误日志
**useRoomManager**(房间生命周期管理):
- 房间操作:joinRoom(支持缓存 ID 重连)、processRoomIdInput750ms 防抖)
- 离开保护:传输中确认提示(isAnyFileTransferring 检查)
- 状态文本:动态更新房间状态文本
- 链接生成:自动生成分享链接
**usePageSetup**(页面初始化):
- 国际化消息加载与错误处理
- URL 参数处理:roomId 自动提取并触发加入房间(200ms 延迟确保 DOM 就绪)
- 引荐来源追踪(trackReferrer
**useClipboardAppMessages**(消息管理):
- 分离式消息状态:shareMessage(发送相关)和 retrieveMessage(接收相关)
- 统一消息显示接口:putMessageInMs(message, isShareEnd, displayTimeMs)
- 自动清理机制:4 秒后自动清空消息状态
### 面板组件特化设计
**SendTabPanel 发送面板**
- 房间 ID 双模式生成:4 位数字(后端 API 生成)和 UUID(前端 crypto API
- 富文本编辑器集成(动态导入,SSR 禁用)
- 文件上传处理和文件列表管理
- 分享链接生成与二维码显示
**RetrieveTabPanel 接收面板**
- File System Access API 集成:目录选择和直接保存到磁盘
- 富文本内容渲染(dangerouslySetInnerHTML
- 文件请求和下载状态管理
- 保存位置选择和大文件/文件夹提示
**FileListDisplay 文件列表**
- 智能文件/文件夹分组和统计显示
- 多浏览器下载策略:Chrome 自动下载,其他浏览器手动保存提示
- 下载计数统计和传输进度跟踪
- 断点续传和存储方式显示(内存/磁盘)
### 关键用户体验优化
1. **下载状态闭包修复**`useFileTransferHandler.ts:110` 使用 `useFileTransferStore.getState()` 获取最新状态
2. **房间 ID 输入防抖**`useRoomManager.ts:247` 使用 lodash debounce 750ms 延迟验证
3. **传输中离开保护**`useRoomManager.ts:164,218` 检查 `isAnyFileTransferring` 状态并显示确认对话框
4. **缓存 ID 重连**`useRoomManager.ts:91` 检测长 ID(≥8 字符)自动发送 `initiator-online`
5. **文件夹压缩下载**`useFileTransferHandler.ts:89` 使用 JSZip 动态创建 ZIP 文件
6. **全局拖拽优化**ClipboardApp 使用 dragCounter 防止拖拽状态误判,支持 webkitGetAsEntry 文件树遍历
7. **剪贴板兼容性**useClipboardActions 支持现代 navigator.clipboard API 和 document.execCommand 降级方案
8. **富文本安全处理**useRichTextToPlainText 服务端渲染安全,客户端 DOM 转换处理块级元素
9. **站内导航不中断(同一标签页)**:依赖 `frontend/stores/fileTransferStore.ts`Zustand 单例)与 `frontend/lib/webrtcService.ts`(服务单例)。App Router 页面切换不打断传输且保留已选择/已接收内容。注意不要在路由切换副作用中调用 `webrtcService.leaveRoom()` 或重置 Store;刷新/新标签不在保证范围内。
10. **切到接收端自动加入(缓存ID**:当用户切换到接收端、未在房间、URL 无 `roomId`、输入框为空且本地存在缓存 ID 时,自动填充并直接调用加入房间以提升体验。入口:`frontend/components/ClipboardApp.tsx`(监听 `activeTab` 变化,读取 `frontend/lib/roomIdCache.ts`)。
11. **发送端“使用缓存ID”即刻加入**:发送端在 `SendTabPanel` 点击“使用缓存ID”后会立即调用加入房间(而非仅填充输入框)。入口:`frontend/components/ClipboardApp/CachedIdActionButton.tsx``onUseCached` 回调)+ `frontend/components/ClipboardApp/SendTabPanel.tsx`
12. **深色主题切换**:提供单按钮 Light/Dark 切换,入口:`frontend/components/web/ThemeToggle.tsx`;集成位置:`frontend/components/web/Header.tsx`(桌面与移动);局部样式从硬编码颜色迁移为设计令牌(例如接收面板使用 `bg-card text-card-foreground`)。
### 前端组件架构特化
**富文本编辑器模块**
- **RichTextEditor**:主编辑器组件,支持 contentEditable、图片粘贴、格式化工具、SSR 禁用
- **工具栏组件分离**BasicFormatTools(粗体/斜体/下划线)、FontTools(字体/大小/颜色)、AlignmentTools(对齐)、InsertTools(链接/图片/代码块)
- **类型安全设计**:完整的 TypeScript 类型定义(FormatType、AlignmentType、FontStyleType、CustomClipboardEvent
- **编辑器 Hooks**useEditorCommands(命令执行)、useSelection(选择管理)、useStyleManagement(样式管理)
**网站页面组件设计**
- **Header 响应式导航**:桌面端水平导航+移动端汉堡菜单,集成 GitHub 链接和语言切换器
- **Footer 国际化**:动态版权年份、多语言支持链接显示,使用 languageDisplayNames 配置
- **FAQSection 灵活配置**:支持工具页面/独立页面切换、标题级别控制、自动 FAQ 数组生成
- **内容展示组件**:HowItWorks(步骤动画+视频)、SystemDiagram(架构图)、KeyFeatures(图标+特性说明)
**UI 组件库架构**
- **基于 Radix UI**ButtonCVA 多变体系统)、Accordion(手风琴)、Dialog(模态对话框)、Select、DropdownMenu
- **设计系统一致性**:统一的 cn 工具函数、主题色彩系统、动画过渡效果
- **组件组合模式**DialogHeader/DialogFooter/DialogTitle/DialogDescription 组合设计
- **懒加载优化**LazyLoadWrapper 使用 react-intersection-observer,支持 rootMargin 配置防止布局跳动
**通用组件工具化**
- **clipboard_btn**WriteClipboardButton/ReadClipboardButton 分离设计,集成 useClipboardActions hook,支持国际化消息
- **TableOfContents**:支持中文标题 ID 生成、滚动跟踪、层级缩进、IntersectionObserver 监听
- **JsonLd SEO**:多类型数据支持、suppressHydrationWarning、数组/单对象处理
- **AutoPopupDialog/YouTubePlayer**:业务场景封装,复用性设计
### 数据流模式
- **单向数据流**Store → Hooks → Components
- **状态管理集中化**:所有状态通过 `useFileTransferStore` 统一管理
- **错误处理标准化**:统一的消息提示机制(putMessageInMs
- **国际化集成**useLocale + getDictionary 提供多语言支持
## 7)背压与分片策略深度分析
### 发送侧双层缓冲架构
**设计原理**
- **文件读取层**4MB 分片减少 FileReader 调用,8 个分片组成 32MB 批次
- **网络传输层**64KB 小块适配 WebRTC DataChannel 限制,避免 sendData failed 错误
- **性能优化**:批次内高效切片,一次 FileReader.read()产生 512 个网络块
**配置参数**
```typescript
TransferConfig.FILE_CONFIG = {
CHUNK_SIZE: 4194304, // 4MB - 文件读取分片
BATCH_SIZE: 8, // 8个分片 = 32MB批次
NETWORK_CHUNK_SIZE: 65536, // 64KB - WebRTC安全发送大小
};
```
**背压控制机制**
- **DataChannel 阈值**`bufferedAmountLowThreshold = 256KB`Initiator)和`512KB`NetworkTransmitter
- **最大缓冲限制**`maxBuffer = 1MB`,超过时等待背压释放
- **异步等待策略**:监听`bufferedamountlow`事件,支持超时机制(10 秒)
**嵌入元数据包格式**
```
[4字节长度][JSON元数据][实际数据块]
```
- 每个网络块都包含:chunkIndex、totalChunks、fileOffset、fileId、isLastChunk
- 接收端可独立解析,无需依赖额外状态
### 接收侧智能存储策略
**存储选择逻辑**
```typescript
ReceptionConfig.shouldSaveToDisk(fileSize, hasSaveDirectory);
```
- **内存存储**:文件 < 1GB 且未指定保存目录
- **磁盘存储**:文件 ≥ 1GB 或用户选择了保存目录
- **缓冲管理**:最多缓存 100 个分片(约 6.4MB)
**分片验证机制**
- **格式兼容**:支持 ArrayBuffer/Blob/Uint8Array/TypedArray 多种格式
- **完整性检查**:验证 fileId、chunkIndex、chunkSize 一致性
- **Firefox 兼容**Blob size 检测和转换错误处理
**严格顺序写入**
- **SequencedDiskWriter**:确保分片按序写入磁盘,支持大文件流式处理
- **断点续传**:通过`getPartialFileSize()`检查本地部分文件
- **自动完成检测**`checkAndAutoFinalize()`验证分片完整性
### 性能优化细节
**发送侧优化**
- **批量读取**:32MB 批次减少 I/O 操作,提升大文件读取性能
- **网络适配**:64KB 块平衡传输效率与浏览器兼容性
- **背压响应**:利用 WebRTC 原生背压控制,避免数据丢失
**接收侧优化**
- **格式转换**ChunkProcessor 统一处理多种数据格式
- **进度节流**:文件 100ms、文件夹 200ms 间隔更新,避免 UI 过载
- **内存管理**:小文件内存组装,大文件直接写入磁盘
**错误处理**
- **发送重试**NetworkTransmitter 返回 boolean 状态,支持上层重试逻辑
- **转换容错**Blob conversion failed 时返回 null,不中断整体传输
- **超时保护**:文件完成 30 秒超时,优雅关闭 5 秒超时
### 调试与监控
**开发环境日志**
- **分片跟踪**:每 100 个分片或最后分片记录详细信息
- **背压监控**:缓冲区大小变化和等待时间统计
- **性能指标**:传输速度、批次处理时间、格式转换耗时
**生产环境优化**
- **条件日志**`ENABLE_CHUNK_LOGGING``ENABLE_PROGRESS_LOGGING`开关
- **错误上报**:关键错误通过`postLogToBackend`发送到后端
- **性能采样**:通过`performance.now()`精确测量耗时
## 9)断点续传深度分析
### 断点续传核心机制
**续传检测与状态恢复**
- **发送侧初始化**`StreamingFileReader constructor(file, startOffset)` 支持从任意偏移量开始
- **接收侧检测**`StreamingFileWriter.getPartialFileSize()` 通过 File System Access API 检查部分文件
- **状态同步**fileRequest 消息包含 offset 参数,通知发送方从指定位置继续传输
**分片索引计算**
```typescript
// 统一的分片计算逻辑
const startChunk = Math.floor(startOffset / chunkSize);
const expectedChunks = Math.ceil((fileSize - startOffset) / chunkSize);
```
### ChunkRangeCalculator 统一计算器
**设计目的**:确保发送端和接收端使用完全相同的分片计算逻辑
```typescript
getChunkRange(fileSize, startOffset, chunkSize) {
const startChunk = Math.floor(startOffset / chunkSize);
const endChunk = Math.floor((fileSize - 1) / chunkSize);
return { startChunk, endChunk, totalChunks: endChunk - startChunk + 1 };
}
```
**关键方法**
- `getRelativeChunkIndex()`:绝对索引转相对索引,用于接收端数组映射
- `isChunkIndexValid()`:验证分片索引是否在预期范围内
- `calculateExpectedChunks()`:计算预期分片数量,与 ReceptionConfig 保持一致
### 接收侧续传流程
**部分文件检测**
1. **目录准备**`createFolderStructure()` 确保目标目录存在
2. **文件查询**:通过 `getFileHandle(fileName, {create: false})` 检查文件是否存在
3. **大小获取**`file.getFile()` 获取当前文件大小作为续传起点
**续传决策逻辑**
```typescript
// FileReceiveOrchestrator.ts
const offset = await this.streamingFileWriter.getPartialFileSize(
fileInfo.name,
fileInfo.fullName
);
if (offset === fileInfo.size) {
// 文件已完整,跳过传输
return;
}
if (offset > 0) {
// 发现部分文件,准备续传
// 发送包含 offset 的 fileRequest
}
```
### 发送侧续传响应
**续传准备**
- **重置读取器**`StreamingFileReader.reset(startOffset)` 从新的偏移量开始
- **批次调整**`currentBatchStartOffset``totalFileOffset` 同步更新
- **分片索引**`startChunkIndex` 记录传输起始点,用于边界检测
**续传日志**
```typescript
const chunkRange = ChunkRangeCalculator.getChunkRange(
fileSize,
startOffset,
chunkSize
);
postLogToBackend(
`[SEND-SUMMARY] File: ${file.name}, offset: ${startOffset}, startChunk: ${chunkRange.startChunk}, endChunk: ${chunkRange.endChunk}`
);
```
### 续传的优势与限制
**优势**
- **带宽节省**:避免重新传输已接收的数据
- **时间效率**:大文件传输中断后可快速恢复
- **用户体验**:网络波动不会导致传输进度完全丢失
**限制与注意点**
- **文件一致性**:依赖文件内容未发生变化,续传前应验证文件大小/修改时间
- **存储位置**:仅在使用 File System Access API 选择保存目录时支持
- **浏览器兼容**File System Access API 主要支持 Chrome/Edge,其他浏览器降级为内存存储
**调试支持**
- **详细日志**:开发环境下记录续传起点、分片范围、预期传输量
- **错误处理**:文件访问失败时回退到从头开始传输
- **状态跟踪**:Store 层记录续传状态和实际接收大小
## 10)重连与状态一致性深度分析
### WebRTC 基础层重连机制
**双重断开检测架构**
```typescript
// webrtc_base.ts
private isSocketDisconnected = false; // Socket.IO 连接状态
private isPeerDisconnected = false; // P2P 连接状态
private gracefullyDisconnectedPeers = new Set(); // 优雅断开的 peer 列表
```
**重连触发条件**:仅当 Socket.IO 和 P2P 连接都断开时才启动重连:
```typescript
// 避免重复重连:socket 断开 ≠ P2P 断开
if (
this.isSocketDisconnected &&
this.isPeerDisconnected &&
!this.reconnectionInProgress
) {
this.attemptReconnection();
}
```
### ICE 候选者队列管理
**候选者缓存策略**
- **连接未就绪时**:候选者缓存到 `iceCandidatesQueue` Map,按 peerId 分组
- **连接就绪后**:批量处理缓存的候选者,按序添加到 RTCPeerConnection
- **失效处理**:候选者失效时重新入队,验证连接状态后重试
**实现细节**
```typescript
private iceCandidatesQueue = new Map<string, RTCIceCandidate[]>();
// 缓存候选项直到连接就绪
if (dataChannel?.readyState !== 'open') {
this.queueIceCandidate(candidate, peerId);
} else {
this.addIceCandidate(candidate, peerId);
}
```
### 数据通道发送重试机制
**5 次重试策略**
```typescript
async sendToPeer(data: string | ArrayBuffer, peerId: string): Promise<boolean> {
for (let attempt = 1; attempt <= 5; attempt++) {
try {
dataChannel.send(data);
return true;
} catch (error) {
if (this.gracefullyDisconnectedPeers.has(peerId)) {
return false; // 跳过已优雅断开的 peer
}
if (attempt === 5) throw error;
await new Promise(resolve => setTimeout(resolve, attempt * 100)); // 100ms→1000ms
}
}
}
```
**重试间隔递增**100ms → 200ms → 300ms → 400ms → 500ms,最大 5 次尝试
### 房间管理层的重连支持
**幂等性设计**
- **长 ID 重连**:≥8 字符的 roomId 支持断线重连时复用房间
- **短 ID 限制**:4 位数字 ID 断线后需重新生成房间,避免冲突
**缓存 ID 重连优化**
```typescript
// useRoomManager.ts
if (roomId.length >= 8) {
// 长ID自动发送 initiator-online 信号
this.sendInitiatorOnline();
}
```
**状态同步序列**
1. **发送方重连**`initiator-online` 信号通知接收方准备重建连接
2. **接收方响应**`recipient-ready` 确认就绪状态
3. **WebRTC 协商**:重新开始 offer/answer/ICE 候选者交换
4. **传输恢复**:在新的 DataChannel 上恢复文件传输
### 状态一致性保证机制
**Store 层单一事实来源**
```typescript
// fileTransferStore.ts
export const useFileTransferStore = create<TransferState>((set, get) => ({
sendProgress: new Map(),
receiveProgress: new Map(),
// 提供清理 API 避免重复计数
clearSendProgress: (fileId: string) =>
set((state) => {
const newProgress = new Map(state.sendProgress);
newProgress.delete(fileId);
return { sendProgress: newProgress };
}),
}));
```
**连接状态机**
```typescript
type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'failed' | 'closed';
// 状态变更时触发相应处理
connectionStateChangeHandler(status: ConnectionStatus) {
switch (status) {
case 'connected':
this.gracefullyDisconnectedPeers.clear(peerId);
this.resetReconnectionState();
break;
case 'disconnected':
case 'failed':
this.cleanupExistingConnection(peerId);
break;
}
}
```
### 移动端优化策略
**唤醒锁管理**
```typescript
// WakeLockManager
async requestWakeLock(): Promise<void> {
try {
this.wakeLock = await navigator.wakeLock.request('screen');
this.wakeLock.addEventListener('release', () => {
this.wakeLock = null;
});
} catch (error) {
console.warn('Wake lock request failed:', error);
}
}
```
**网络切换适应**
- **连接检测**:监听 `connectionstatechange` 事件检测网络质量变化
- **自动重连**`connectionState: 'disconnected' | 'failed' | 'closed'` 时均触发重连流程(统一走 attemptReconnection
- **状态恢复**:重连成功后恢复房间状态和传输进度
**移动端后台/前台切换补充策略**
- **socket 连接恢复自动入房**`socket.on('connect')` 时,若已持有 `roomId` 且(`lastJoinedSocketId !== socket.id``!isInRoom`),则强制重新 `joinRoom(roomId, isInitiator, isInitiator)`;发送端会自动广播 `initiator-online`,接收端回复 `recipient-ready`
- **身份追踪**:成功 `joinRoom` 后记录 `lastJoinedSocketId = socket.id`,用以检测“后台恢复时 socketId 更换”的情形。
- **门槛放宽**`attemptReconnection` 只要满足“`roomId` 存在,且满足任一:P2P 断开 / socket 断开 / socketId 改变”,即可发起重连;不再强依赖“socket 与 P2P 同时断开”。
### 重连调试要点
**关键日志点**
- **双重断开检测**:记录 Socket.IO 和 P2P 断开的具体时间戳
- **候选者队列**:统计缓存的 ICE 候选者数量和处理时间
- **发送重试**:记录重试次数、间隔和最终结果
- **状态恢复**:追踪 `initiator-online``recipient-ready` 的时序
**常见问题诊断**
- **重复重连**:检查 `reconnectionInProgress` 标志和 `gracefullyDisconnectedPeers` 集合
- **候选者失效**:验证 `iceConnectionState``iceGatheringState` 状态
- **状态不一致**:确认 Store 层的进度清理和连接状态同步
## 11)微方案模板(用于小改动前的对齐)
标题:<简述>
背景/问题
- 要解决的用户场景或缺陷是什么?
目标与非目标
- 本次改动包含与不包含的范围?
影响文件与消息
- 代码:列出关键文件(如 `frontend/lib/webrtc_base.ts``backend/src/socket/handlers.ts`)。
- 协议:列出将修改的 DataChannel 消息/字段。
状态机/流程变化
- 增删改的阶段;给出简要时序或步骤。
测试与回归清单
- 单测/集成(如适用)、手测场景、性能/边界、重连。
需要更新的文档
- `code-map.md`(如出现新的入口)
- `flows.md`(流程/消息/约束变化)
- 相关架构或部署文档(如涉及)
+47
View File
@@ -0,0 +1,47 @@
# PrivyDrop AI Playbook — 上下文与索引(中文)
本手册为 AI 与开发者提供一个高信噪比的入口,帮助快速定位到正确的代码位置。仅包含项目上下文与链接索引,不提供步骤化的任务指南。
## 项目快照
- 产品:基于 WebRTC 的 P2P 文件/文本分享,浏览器之间通过 RTCDataChannel 直接传输,端到端加密。
- 前端:Next.js 14App Router)、React 18、TypeScript、Tailwind、shadcn/ui。
- 后端:Node.js、Express、Socket.IO、Redis;可选 STUN/TURN 做 NAT 穿透。
- 隐私立场:服务器不承载文件数据中转;后端仅负责信令与房间协调。
## 文档索引
- AI Playbook
- 代码地图:`docs/ai-playbook/code-map.zh-CN.md`
- 流程(含微方案模板):`docs/ai-playbook/flows.zh-CN.md`
- 协作规则:`docs/ai-playbook/collab-rules.zh-CN.md`
- 系统与架构
- 系统架构:`docs/ARCHITECTURE.md` / `docs/ARCHITECTURE.zh-CN.md`
- 前端架构:`docs/FRONTEND_ARCHITECTURE.md` / `docs/FRONTEND_ARCHITECTURE.zh-CN.md`
- 后端架构:`docs/BACKEND_ARCHITECTURE.md` / `docs/BACKEND_ARCHITECTURE.zh-CN.md`
- 部署
- 部署指南:`docs/DEPLOYMENT.md` / `docs/DEPLOYMENT.zh-CN.md`
- Docker 部署:`docs/DEPLOYMENT_docker.md` / `docs/DEPLOYMENT_docker.zh-CN.md`
## 关键模块速览
- 前端核心
- Hooks`frontend/hooks/useWebRTCConnection.ts`(连接编排)、`useRoomManager.ts`(房间生命周期)、`useFileTransferHandler.ts`(负载编排)。
- WebRTC 基础:`frontend/lib/webrtc_base.ts`Socket.IO 信令、RTCPeerConnection、数据通道)。
- 角色:`frontend/lib/webrtc_Initiator.ts``frontend/lib/webrtc_Recipient.ts`(发起/接收角色行为)。
- 发送:`frontend/lib/transfer/*``frontend/lib/fileSender.ts`(元数据、分片、进度)。
- 接收:`frontend/lib/receive/*``frontend/lib/fileReceiver.ts`(组装、校验、持久化)。
- Store`frontend/stores/fileTransferStore.ts`(进度/状态的单一事实来源)。
- 后端核心
- Socket.IO`backend/src/socket/handlers.ts`join、initiator-online、recipient-ready、offer/answer/ice-candidate)。
- Services`backend/src/services/{room,redis,rateLimit}.ts`
- REST`backend/src/routes/api.ts`(房间、追踪、调试日志)。
## 维护
- 保持精简与事实,避免与系统级文档重复。
- 本文用于团队协作与快速理解。
+6 -4
View File
@@ -20,15 +20,17 @@ module.exports = {
{
name: "privydrop-frontend",
cwd: "./frontend",
script: "npm",
args: "run start",
script: "node",
args: ".next/standalone/server.js",
watch: false,
env: {
NODE_ENV: "production"
NODE_ENV: "production",
PORT: 3002,
HOSTNAME: "0.0.0.0"
},
log_date_format: "YYYY-MM-DD HH:mm:ss",
error_file: "/var/log/privydrop-frontend-error.log",
out_file: "/var/log/privydrop-frontend-out.log",
}
]
};
};
+3 -1
View File
@@ -2,4 +2,6 @@ NEXT_PUBLIC_API_URL=https://www.privydrop.app
NEXT_PUBLIC_TURN_HOST=turn.privydrop.app
NEXT_PUBLIC_TURN_USERNAME=[Username]
NEXT_PUBLIC_TURN_PASSWORD=[Password]
NEXT_PUBLIC_TURN_PASSWORD=[Password]
NEXT_IMAGE_UNOPTIMIZED=true
+5
View File
@@ -0,0 +1,5 @@
node-linker=hoisted
public-hoist-pattern[]=@next/env
public-hoist-pattern[]=styled-jsx
public-hoist-pattern[]=@swc/helpers
+10 -10
View File
@@ -38,19 +38,25 @@ export default function HomeClient({ messages, lang }: PageContentProps) {
<ClipboardApp />
</div>
</section>
{/* How It Works Section */}
<section aria-label="How It Works">
<LazyLoadWrapper>
<HowItWorks messages={messages} />
</LazyLoadWrapper>
</section>
{/* Demo Video Section */}
<section className="mb-12" aria-label="Product Demo">
<LazyLoadWrapper>
<h2 className="text-3xl font-bold mb-6 text-center">
{messages.text.home.h2_demo}
</h2>
<p className="text-center mb-6 text-gray-600">
<p className="text-center mb-6 text-muted-foreground">
{messages.text.home.h2P_demo}
</p>
<YouTubePlayer videoId={youtube_videoId} />
<div className="mt-4 text-center">
<p className="mb-3 text-gray-700">
<p className="mb-3 text-foreground">
{messages.text.home.watch_tips}
</p>
<a
@@ -72,12 +78,6 @@ export default function HomeClient({ messages, lang }: PageContentProps) {
</div>
</LazyLoadWrapper>
</section>
{/* How It Works Section */}
<section aria-label="How It Works">
<LazyLoadWrapper>
<HowItWorks messages={messages} />
</LazyLoadWrapper>
</section>
{/* System Architecture Section */}
<section aria-label="System Architecture">
<LazyLoadWrapper>
@@ -87,8 +87,8 @@ export default function HomeClient({ messages, lang }: PageContentProps) {
{/* Key Features */}
<section aria-label="Key Features">
<LazyLoadWrapper>
<KeyFeatures
messages={messages}
<KeyFeatures
messages={messages}
isInToolPage
titleClassName="text-2xl md:text-3xl"
/>
+10 -5
View File
@@ -2,6 +2,8 @@
import { Metadata } from "next";
import { getPostBySlug } from "@/lib/blog";
import { generateMetadata as generateBlogMetadata } from "../metadata";
import { getDictionary } from "@/lib/dictionary";
import { supportedLocales } from "@/constants/i18n-config";
export async function generateMetadata({
params,
@@ -16,8 +18,12 @@ export async function generateMetadata({
return generateBlogMetadata({ params: { lang: params.lang } });
}
const messages = await getDictionary(params.lang);
const blogWord = messages.text.Header.Blog_dis;
const blogCap = blogWord.charAt(0).toUpperCase() + blogWord.slice(1);
return {
title: `${post.frontmatter.title} | PrivyDrop Blog`,
title: `${post.frontmatter.title} | PrivyDrop ${blogCap}`,
description: post.frontmatter.description,
keywords: `${post.frontmatter.tags.join(
", "
@@ -25,10 +31,9 @@ export async function generateMetadata({
metadataBase: new URL("https://www.privydrop.app"),
alternates: {
canonical: `/${params.lang}/blog/${params.slug}`,
languages: {
en: `/en/blog/${params.slug}`,
zh: `/zh/blog/${params.slug}`,
},
languages: Object.fromEntries(
supportedLocales.map((l) => [l, `/${l}/blog/${params.slug}`])
),
},
openGraph: {
title: post.frontmatter.title,
+39 -7
View File
@@ -6,6 +6,14 @@ import { mdxOptions } from "@/lib/mdx-config";
import { mdxComponents } from "@/components/blog/MDXComponents";
import { TableOfContents } from "@/components/blog/TableOfContents";
import { generateMetadata } from "./metadata";
import JsonLd from "@/components/seo/JsonLd";
import {
absoluteUrl,
buildBlogPostingJsonLd,
buildBreadcrumbJsonLd,
getSiteUrl,
} from "@/lib/seo/jsonld";
import { getDictionary } from "@/lib/dictionary";
export { generateMetadata };
@@ -15,24 +23,48 @@ export default async function BlogPost({
params: { slug: string; lang: string };
}) {
const post = await getPostBySlug(params.slug, params.lang);
const messages = await getDictionary(params.lang);
if (!post) {
return <div>Post not found</div>;
return <div>{messages.text.blog.post_not_found}</div>;
}
const siteUrl = getSiteUrl();
const postUrl = `${siteUrl}/${params.lang}/blog/${params.slug}`;
const imageUrl = absoluteUrl(post.frontmatter.cover, siteUrl);
const postLd = buildBlogPostingJsonLd({
siteUrl,
url: postUrl,
title: post.frontmatter.title,
description: post.frontmatter.description,
datePublished: post.frontmatter.date,
dateModified: post.frontmatter.date,
authorName: post.frontmatter.author,
imageUrl,
inLanguage: params.lang,
});
const breadcrumbsLd = buildBreadcrumbJsonLd({
items: [
{ name: messages.text.Header.Home_dis, item: `${siteUrl}/${params.lang}` },
{ name: messages.text.Header.Blog_dis, item: `${siteUrl}/${params.lang}/blog` },
{ name: post.frontmatter.title, item: postUrl },
],
});
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
<JsonLd id="post-ld" data={[postLd, breadcrumbsLd]} />
{/* Use md: prefix to handle flex layout for medium screens and above */}
<div className="block md:flex md:gap-8">
{/* Article content area */}
<article className="w-full md:flex-1 max-w-4xl">
<header className="mb-8">
<h1 className="text-3xl sm:text-4xl font-bold mb-4 text-gray-900">
<h1 className="text-3xl sm:text-4xl font-bold mb-4 text-foreground">
{post.frontmatter.title}
</h1>
<div className="flex flex-wrap items-center text-gray-600 gap-2 sm:gap-4">
<div className="flex flex-wrap items-center text-muted-foreground gap-2 sm:gap-4">
<time className="text-sm">
{new Date(post.frontmatter.date).toLocaleDateString("en-US", {
{new Date(post.frontmatter.date).toLocaleDateString(params.lang, {
year: "numeric",
month: "long",
day: "numeric",
@@ -40,7 +72,7 @@ export default async function BlogPost({
</time>
<span className="hidden sm:inline">·</span>
<span className="text-sm">
by <span className="font-bold">{post.frontmatter.author}</span>
{messages.text.blog.by} <span className="font-bold">{post.frontmatter.author}</span>
</span>
</div>
</header>
@@ -51,7 +83,7 @@ export default async function BlogPost({
components={{
...mdxComponents,
wrapper: ({ children }) => (
<div className="space-y-4 text-gray-700 overflow-x-auto">
<div className="space-y-4 text-foreground overflow-x-auto">
{children}
</div>
),
@@ -60,7 +92,7 @@ export default async function BlogPost({
/>
</div>
</article>
<TableOfContents content={post.content} />
<TableOfContents content={post.content} title={messages.text.blog.toc_title} />
</div>
</div>
);
+11 -12
View File
@@ -1,29 +1,28 @@
import { supportedLocales } from "@/constants/i18n-config";
import { Metadata } from "next";
import { getDictionary } from "@/lib/dictionary";
export async function generateMetadata({
params,
}: {
params: { lang: string };
}): Promise<Metadata> {
const messages = await getDictionary(params.lang);
return {
title: "PrivyDrop Blog - Private P2P File Sharing & Collaboration",
description:
"Discover secure file sharing tips, privacy-focused collaboration strategies, and how to leverage P2P technology for safer data transfer. Learn about WebRTC, end-to-end encryption, and team collaboration.",
keywords:
"secure file sharing, p2p file transfer, private collaboration, webrtc, end-to-end encryption, team collaboration, privacy tools",
title: messages.meta.blog.title,
description: messages.meta.blog.description,
keywords: messages.meta.blog.keywords,
metadataBase: new URL("https://www.privydrop.app"),
alternates: {
canonical: `/${params.lang}/blog`,
languages: {
en: "/en/blog",
zh: "/zh/blog",
},
languages: Object.fromEntries(
supportedLocales.map((l) => [l, `/${l}/blog`])
),
},
openGraph: {
title: "PrivyDrop Blog - Private P2P File Sharing & Collaboration",
description:
"Explore secure file sharing, private collaboration tools, and data privacy best practices. Join our community of privacy-conscious professionals.",
title: messages.meta.blog.title,
description: messages.meta.blog.description,
url: `https://www.privydrop.app/${params.lang}/blog`,
siteName: "PrivyDrop",
locale: params.lang,
+13 -11
View File
@@ -3,6 +3,7 @@ import { ArticleListItem } from "@/components/blog/ArticleListItem";
import Link from "next/link";
import { slugifyTag } from "@/utils/tagUtils";
import { generateMetadata } from "./metadata";
import { getDictionary } from "@/lib/dictionary";
export { generateMetadata };
@@ -12,6 +13,7 @@ export default async function BlogPage({
params: { lang: string };
}) {
const posts = await getAllPosts(lang);
const messages = await getDictionary(lang);
return (
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-12">
@@ -19,14 +21,14 @@ export default async function BlogPage({
{/* Main Content */}
<main className="lg:col-span-8">
<div className="mb-12">
<h1 className="text-4xl font-bold mb-4">Blog</h1>
<p className="text-gray-600 text-lg">Latest articles and updates</p>
<h1 className="text-4xl font-bold mb-4">{messages.text.blog.list_title}</h1>
<p className="text-muted-foreground text-lg">{messages.text.blog.list_subtitle}</p>
</div>
{/* Articles List */}
<div className="space-y-12">
{posts.map((post) => (
<ArticleListItem key={post.slug} post={post} lang={lang} />
<ArticleListItem key={post.slug} post={post} lang={lang} messages={messages} />
))}
</div>
</main>
@@ -35,14 +37,14 @@ export default async function BlogPage({
<aside className="lg:col-span-4">
<div className="sticky top-8">
{/* Recent Posts */}
<div className="bg-white rounded-xl shadow-lg p-8 mb-8">
<h2 className="text-xl font-bold mb-6">Recent Posts</h2>
<div className="bg-card rounded-xl shadow-lg p-8 mb-8">
<h2 className="text-xl font-bold mb-6">{messages.text.blog.recent_posts}</h2>
<div className="space-y-4">
{posts.slice(0, 5).map((post) => (
<Link
key={post.slug}
href={`/${lang}/blog/${post.slug}`}
className="block hover:text-blue-600 text-base font-medium"
className="block hover:text-primary text-base font-medium"
>
{post.frontmatter.title}
</Link>
@@ -50,8 +52,8 @@ export default async function BlogPage({
</div>
</div>
{/* tags */}
<div className="bg-white rounded-xl shadow-lg p-8">
<h2 className="text-xl font-bold mb-6">Tags</h2>
<div className="bg-card rounded-xl shadow-lg p-8">
<h2 className="text-xl font-bold mb-6">{messages.text.blog.tags}</h2>
<div className="space-y-3">
{/* Get all tags and deduplicate */}
{Array.from(
@@ -60,10 +62,10 @@ export default async function BlogPage({
<Link
key={tag}
href={`/${lang}/blog/tag/${slugifyTag(tag)}`} // Jump to the tag filtering page
className="flex items-center justify-between hover:text-blue-600"
className="flex items-center justify-between hover:text-primary"
>
<span className="text-gray-700 font-medium">{tag}</span>
<span className="bg-gray-100 px-3 py-1 rounded-full text-sm text-gray-600">
<span className="text-foreground font-medium">{tag}</span>
<span className="bg-muted px-3 py-1 rounded-full text-sm text-muted-foreground">
{
posts.filter((p) => p.frontmatter.tags.includes(tag))
.length
+18 -17
View File
@@ -3,6 +3,7 @@ import { getPostsByTag } from "@/lib/blog";
import { ArticleListItem } from "@/components/blog/ArticleListItem";
import { supportedLocales } from "@/constants/i18n-config";
import { unslugifyTag } from "@/utils/tagUtils";
import { getDictionary } from "@/lib/dictionary";
export async function generateMetadata({
params: { tag, lang },
@@ -10,25 +11,24 @@ export async function generateMetadata({
params: { tag: string; lang: string };
}): Promise<Metadata> {
const decodedTag = unslugifyTag(tag);
const messages = await getDictionary(lang);
// Note: metadata text kept concise and localized
return {
title: `${decodedTag} - PrivyDrop Blog Articles`,
description: `Explore articles about ${decodedTag} - Learn about secure file sharing, private collaboration, and data privacy solutions related to ${decodedTag}`,
keywords: `${decodedTag}, secure file sharing, p2p file transfer, privacy, collaboration, webrtc`,
title: `${messages.text.blog.tag_title_prefix}: ${decodedTag} - PrivyDrop`,
description: messages.text.blog.tag_subtitle_template.replace("{tag}", decodedTag),
keywords: `${decodedTag}, blog, privydrop`,
metadataBase: new URL("https://www.privydrop.app"),
alternates: {
canonical: `/${lang}/blog/tag/${encodeURIComponent(tag)}`,
languages: {
en: `/en/blog/tag/${encodeURIComponent(tag)}`,
zh: `/zh/blog/tag/${encodeURIComponent(tag)}`,
},
languages: Object.fromEntries(
supportedLocales.map((l) => [l, `/${l}/blog/tag/${encodeURIComponent(tag)}`])
),
},
openGraph: {
title: `${decodedTag} - PrivyDrop Blog Articles`,
description: `Discover articles about ${decodedTag} - Expert insights on secure file sharing and private collaboration solutions`,
url: `https://www.privydrop.app/${lang}/blog/tag/${encodeURIComponent(
tag
)}`,
title: `${decodedTag} - PrivyDrop`,
description: `Articles tagged: ${decodedTag}`,
url: `https://www.privydrop.app/${lang}/blog/tag/${encodeURIComponent(tag)}`,
siteName: "PrivyDrop",
locale: lang,
type: "website",
@@ -42,6 +42,7 @@ export default async function TagPage({
}) {
const decodedTag = unslugifyTag(tag);
const posts = await getPostsByTag(decodedTag, lang);
const messages = await getDictionary(lang);
return (
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-12">
@@ -49,9 +50,9 @@ export default async function TagPage({
{/* Main Content */}
<main className="lg:col-span-8">
<div className="mb-12">
<h1 className="text-4xl font-bold mb-4">Tag: {decodedTag}</h1>
<p className="text-gray-600 text-lg">
Articles tagged with {decodedTag}
<h1 className="text-4xl font-bold mb-4">{messages.text.blog.tag_title_prefix}: {decodedTag}</h1>
<p className="text-muted-foreground text-lg">
{messages.text.blog.tag_subtitle_template.replace("{tag}", decodedTag)}
</p>
</div>
@@ -59,10 +60,10 @@ export default async function TagPage({
<div className="space-y-12">
{posts.length > 0 ? (
posts.map((post) => (
<ArticleListItem key={post.slug} post={post} lang={lang} />
<ArticleListItem key={post.slug} post={post} lang={lang} messages={messages} />
))
) : (
<p>No articles found for this decodedTag.</p>
<p>{messages.text.blog.tag_empty}</p>
)}
</div>
</main>
+23 -1
View File
@@ -2,6 +2,8 @@ import FAQSection from "@/components/web/FAQSection";
import type { Metadata } from "next";
import { getDictionary } from "@/lib/dictionary";
import { supportedLocales } from "@/constants/i18n-config";
import JsonLd from "@/components/seo/JsonLd";
import { buildFaqJsonLd } from "@/lib/seo/jsonld";
export async function generateMetadata({
params,
@@ -38,5 +40,25 @@ export default async function FAQ({
params: { lang: string };
}) {
const messages = await getDictionary(lang);
return <FAQSection messages={messages} />;
const faqsData = (messages as any).text.faqs as Record<string, string>;
const questionKeys = Object.keys(faqsData).filter((k) => k.startsWith("question_"));
const faqs = questionKeys
.map((qKey) => {
const idx = qKey.split("_")[1];
const aKey = `answer_${idx}`;
const q = faqsData[qKey];
const a = faqsData[aKey];
if (q && a) return { question: q, answer: a };
return null;
})
.filter(Boolean) as { question: string; answer: string }[];
const faqLd = buildFaqJsonLd({ inLanguage: lang, faqs });
return (
<>
<JsonLd id="faq-ld" data={faqLd} />
<FAQSection messages={messages} />
</>
);
}
+11 -10
View File
@@ -69,15 +69,16 @@
}
/* Custom prose styles */
.prose {
@apply text-gray-600;
@apply text-muted-foreground;
}
.prose h1,
.prose h2,
.prose h3,
.prose h4 {
@apply text-gray-900 font-bold mt-8 mb-4;
@apply text-foreground font-bold mt-8 mb-4;
}
.prose h1 {
@@ -106,31 +107,31 @@
}
.prose code {
@apply text-sm bg-gray-50 rounded px-1.5 py-0.5 text-gray-800 border border-gray-200;
@apply text-sm bg-muted rounded px-1.5 py-0.5 text-foreground border border-border;
}
.prose pre {
@apply my-6 p-4 bg-gray-50 rounded-lg overflow-x-auto border border-gray-200;
@apply my-6 p-4 bg-muted rounded-lg overflow-x-auto border border-border;
}
.prose pre code {
@apply bg-transparent text-gray-800 p-0 border-0;
@apply bg-transparent text-foreground p-0 border-0;
}
.prose blockquote {
@apply border-l-4 border-blue-500 pl-4 my-6 italic text-gray-600;
@apply border-l-4 border-primary pl-4 my-6 italic text-muted-foreground;
}
.prose table {
@apply min-w-full divide-y divide-gray-200 my-6;
@apply min-w-full divide-y divide-border my-6;
}
.prose th {
@apply px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider;
@apply px-6 py-3 bg-muted text-left text-xs font-medium text-muted-foreground uppercase tracking-wider;
}
.prose td {
@apply px-6 py-4 whitespace-nowrap text-sm text-gray-500;
@apply px-6 py-4 whitespace-nowrap text-sm text-muted-foreground;
}
.prose img {
@@ -138,7 +139,7 @@
}
.prose figure figcaption {
@apply text-center text-sm text-gray-600 mt-2 italic;
@apply text-center text-sm text-muted-foreground mt-2 italic;
}
/* Hide GitHub ribbon on small screens */
+23
View File
@@ -3,6 +3,13 @@ import Header from "@/components/web/Header";
import Footer from "@/components/web/Footer";
import { ThemeProvider } from "@/components/web/theme-provider";
import { getDictionary } from "@/lib/dictionary";
import JsonLd from "@/components/seo/JsonLd";
import {
absoluteUrl,
buildOrganizationJsonLd,
buildWebSiteJsonLd,
getSiteUrl,
} from "@/lib/seo/jsonld";
export default async function RootLayout({
children,
@@ -12,11 +19,27 @@ export default async function RootLayout({
params: { lang: string };
}>) {
const messages = await getDictionary(lang);
const siteUrl = getSiteUrl();
const logoUrl = absoluteUrl("/logo.png", siteUrl);
const orgJson = buildOrganizationJsonLd({
siteUrl,
logoUrl,
sameAs: [
"https://github.com/david-bai00/PrivyDrop",
"https://x.com/David_vision66",
],
});
const websiteJson = buildWebSiteJsonLd({
siteUrl,
name: "PrivyDrop",
inLanguage: lang,
});
return (
<html lang={lang} className="h-full" suppressHydrationWarning>
<head />
<body className="min-h-full flex flex-col">
<JsonLd id="global-ld" data={[orgJson, websiteJson]} />
<ThemeProvider
attribute="class"
defaultTheme="system"
+24 -1
View File
@@ -2,6 +2,8 @@ import HomeClient from "./HomeClient";
import { getDictionary } from "@/lib/dictionary";
import { Metadata } from "next";
import { supportedLocales } from "@/constants/i18n-config";
import JsonLd from "@/components/seo/JsonLd";
import { buildWebAppJsonLd, getSiteUrl, absoluteUrl } from "@/lib/seo/jsonld";
export async function generateMetadata({
params,
@@ -39,6 +41,27 @@ export default async function Home({
params: { lang: string };
}) {
const messages = await getDictionary(lang);
const siteUrl = getSiteUrl();
const webAppLd = buildWebAppJsonLd({
siteUrl,
path: `/${lang}`,
name: "PrivyDrop",
alternateName: [
"PrivyDrop",
"PrivyDrop APP",
"Open-source web-based AirDrop alternative",
],
description: messages.meta.home.description,
inLanguage: lang,
imageUrl: absoluteUrl("/logo.png", siteUrl),
applicationCategory: "UtilityApplication",
operatingSystem: "Web Browser",
});
return <HomeClient messages={messages} lang={lang} />;
return (
<>
<JsonLd id="home-ld" data={webAppLd} />
<HomeClient messages={messages} lang={lang} />
</>
);
}
+56 -15
View File
@@ -1,6 +1,7 @@
import { MetadataRoute } from "next";
import { supportedLocales } from "@/constants/i18n-config";
import { getAllPosts } from "@/lib/blog";
import { slugifyTag } from "@/utils/tagUtils";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = "https://www.privydrop.app";
@@ -26,23 +27,33 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
priority: 1,
});
// Add language specific URLs
languages.forEach((lang) => {
routes.forEach((route) => {
urls.push({
url: `${baseUrl}/${lang}${route}`,
lastModified: new Date(),
changeFrequency: "weekly",
priority: route === "" ? 1.0 : 0.8,
});
});
});
// Add blog posts for each language
// Add language specific URLs, blog posts and tag pages
for (const lang of languages) {
try {
const posts = await getAllPosts(lang);
// compute latest blog post date for this language
const latestDate = posts.length
? new Date(
Math.max(
...posts.map((p) => new Date(p.frontmatter.date).getTime())
)
)
: new Date();
// Add static routes per language (optimize blog list lastModified)
routes.forEach((route) => {
const isRoot = route === "";
const isBlogList = route === "/blog";
urls.push({
url: `${baseUrl}/${lang}${route}`,
lastModified: isBlogList ? latestDate : new Date(),
changeFrequency: isRoot ? "weekly" : isBlogList ? "weekly" : "weekly",
priority: isRoot ? 1.0 : 0.8,
});
});
// Add blog posts for this language
posts.forEach((post) => {
urls.push({
url: `${baseUrl}/${lang}/blog/${post.slug}`,
@@ -51,8 +62,38 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
priority: 0.7,
});
});
// Add tag pages for this language
const uniqueTags = Array.from(
new Set(posts.flatMap((p) => p.frontmatter.tags))
);
uniqueTags.forEach((tag) => {
const tagSlug = slugifyTag(tag);
const tagLatestDate = posts
.filter((p) => p.frontmatter.tags.includes(tag))
.map((p) => new Date(p.frontmatter.date).getTime());
const lastModified =
tagLatestDate.length > 0
? new Date(Math.max(...tagLatestDate))
: latestDate;
urls.push({
url: `${baseUrl}/${lang}/blog/tag/${tagSlug}`,
lastModified,
changeFrequency: "monthly",
priority: 0.6,
});
});
} catch (error) {
console.warn(`Failed to load blog posts for language ${lang}:`, error);
console.warn(`Failed to load blog data for language ${lang}:`, error);
// Fallback: keep at least the static routes
routes.forEach((route) => {
urls.push({
url: `${baseUrl}/${lang}${route}`,
lastModified: new Date(),
changeFrequency: "weekly",
priority: route === "" ? 1.0 : 0.8,
});
});
}
}
+31
View File
@@ -14,6 +14,7 @@ import { RetrieveTabPanel } from "./ClipboardApp/RetrieveTabPanel";
import FullScreenDropZone from "./ClipboardApp/FullScreenDropZone";
import { traverseFileTree } from "@/lib/fileUtils";
import { useFileTransferStore } from "@/stores/fileTransferStore";
import { getCachedId } from "@/lib/roomIdCache";
const ClipboardApp = () => {
const { shareMessage, retrieveMessage, putMessageInMs } =
@@ -37,6 +38,9 @@ const ClipboardApp = () => {
setIsDragging,
setRetrieveRoomIdInput,
setActiveTab,
// for auto-join on receiver side
isReceiverInRoom,
retrieveRoomIdInput,
} = useFileTransferStore();
const richTextToPlainText = useRichTextToPlainText();
@@ -144,6 +148,32 @@ const ClipboardApp = () => {
};
}, [activeTab, handleFileDrop, setIsDragging]);
// Auto-join on switching to receiver tab when cached ID exists
useEffect(() => {
if (activeTab !== "retrieve") return;
if (isReceiverInRoom) return;
// Do not auto-join if URL already specifies a roomId (URL 优先)
const params = new URLSearchParams(window.location.search);
if (params.get("roomId")) return;
// Do not override user's existing input
if ((retrieveRoomIdInput || "").trim().length > 0) return;
const cached = getCachedId();
if (!cached || cached.trim().length === 0) return;
// Fill input then join directly to improve UX
setRetrieveRoomIdInput(cached);
joinRoom(false, cached);
}, [
activeTab,
isReceiverInRoom,
retrieveRoomIdInput,
setRetrieveRoomIdInput,
joinRoom,
]);
if (isLoadingMessages || !messages) {
return (
<div className="container mx-auto px-4 py-8 w-full md:max-w-4xl">
@@ -202,6 +232,7 @@ const ClipboardApp = () => {
shareMessage={shareMessage}
currentValidatedShareRoomId={shareRoomId}
handleLeaveSenderRoom={handleLeaveSenderRoom}
putMessageInMs={putMessageInMs}
/>
) : (
<RetrieveTabPanel
@@ -0,0 +1,200 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import Tooltip from "@/components/Tooltip";
import type { Messages } from "@/types/messages";
import { getCachedId, setCachedId } from "@/lib/roomIdCache";
/**
* CachedIdActionButton
*
* A reusable action button that unifies the "Use cached ID" and "Save ID" behaviors
* across sender and receiver panels.
*
* UX
* - If a cached Room ID exists:
* - Single click (no second click within dblClickWindowMs, default 400ms):
* writes the cached ID into the target input without availability checks
* (matching the current Random ID UX).
* - Double click (two clicks within dblClickWindowMs): switches to a temporary
* "Save ID" mode for saveModeDurationMs (default 3000ms) without filling.
* - If no cached Room ID exists: the button shows "Save ID" by default; when the
* current input length >= 8, clicking saves it to localStorage and reports success
* via putMessageInMs, then the button returns to "Use cached ID".
* - In "Save ID" mode: clicking saves the current input (>= 8) and exits the mode;
* if the user does nothing, the mode auto-exits after saveModeDurationMs.
*
* Props
* - messages: i18n dictionary used for labels/tooltips.
* - getInputValue / setInputValue: provide read/write access to the room ID input.
* - putMessageInMs: message dispatcher; isShareEnd tells which side (sender/receiver)
* should display the toast.
* - Optional styling/timing overrides: className, variant, size, dblClickWindowMs,
* saveModeDurationMs — with sensible defaults for dropin usage.
*
* Implementation
* - Local state tracks if a cached ID exists and whether we are in temporary
* "save override" mode.
* - Single/double click detection uses a short timer + click counter refs;
* timers are cleaned up on unmount to avoid leaks.
* - localStorage reads/writes are abstracted via getCachedId/setCachedId.
* - No network calls, and no availability checks during "Use cached ID" to keep
* the interaction snappy and consistent with Random ID behavior.
*/
type Props = {
messages: Messages;
getInputValue: () => string;
setInputValue: (val: string) => void;
putMessageInMs: (
message: string,
isShareEnd?: boolean,
displayTimeMs?: number
) => void;
isShareEnd: boolean; // true for sender, false for receiver
className?: string;
variant?:
| "default"
| "destructive"
| "outline"
| "secondary"
| "ghost"
| "link";
size?: "default" | "sm" | "lg" | "icon";
dblClickWindowMs?: number; // default 400ms
saveModeDurationMs?: number; // default 3000ms
// Optional: called after a cached ID is applied (single-click "Use cached ID")
onUseCached?: (cachedId: string) => void;
// Optional: external disabled flag
disabled?: boolean;
};
export default function CachedIdActionButton({
messages,
getInputValue,
setInputValue,
putMessageInMs,
isShareEnd,
className = "w-full sm:w-auto px-4",
variant = "outline",
size = "default",
dblClickWindowMs = 400,
saveModeDurationMs = 3000,
onUseCached,
disabled = false,
}: Props) {
const [hasCachedId, setHasCachedId] = useState<boolean>(false);
const [showSaveOverride, setShowSaveOverride] = useState<boolean>(false);
const clickCountRef = useRef(0);
const singleTimerRef = useRef<number | null>(null);
const saveTimerRef = useRef<number | null>(null);
useEffect(() => {
setHasCachedId(!!getCachedId());
}, []);
useEffect(() => {
return () => {
if (singleTimerRef.current) {
clearTimeout(singleTimerRef.current);
singleTimerRef.current = null;
}
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
};
}, []);
const isSaveMode = showSaveOverride || !hasCachedId;
const inputVal = getInputValue() || "";
const isSaveEnabled = inputVal.trim().length >= 8;
const handleClick = useCallback(() => {
if (isSaveMode) {
const trimmed = (getInputValue() || "").trim();
if (trimmed.length >= 8) {
setCachedId(trimmed);
setHasCachedId(true);
setShowSaveOverride(false);
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
putMessageInMs(messages.text.ClipboardApp.saveId_success, isShareEnd);
}
return;
}
// Use cached with single/double click detection
clickCountRef.current += 1;
if (clickCountRef.current === 1) {
// Single click timer
singleTimerRef.current = window.setTimeout(() => {
if (clickCountRef.current === 1) {
const cached = getCachedId();
if (cached) {
setInputValue(cached);
// Notify caller after applying cached value
onUseCached?.(cached);
}
}
clickCountRef.current = 0;
if (singleTimerRef.current) {
clearTimeout(singleTimerRef.current);
singleTimerRef.current = null;
}
}, dblClickWindowMs);
} else if (clickCountRef.current === 2) {
// Double click => switch to save mode
if (singleTimerRef.current) {
clearTimeout(singleTimerRef.current);
singleTimerRef.current = null;
}
clickCountRef.current = 0;
setShowSaveOverride(true);
saveTimerRef.current = window.setTimeout(() => {
setShowSaveOverride(false);
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
}, saveModeDurationMs);
}
}, [
isSaveMode,
getInputValue,
setInputValue,
putMessageInMs,
messages.text.ClipboardApp.saveId_success,
isShareEnd,
dblClickWindowMs,
saveModeDurationMs,
onUseCached,
]);
return (
<Tooltip
content={
isSaveMode
? messages.text.ClipboardApp.html.saveId_tips
: messages.text.ClipboardApp.html.useCachedId_tips
}
>
<span className="inline-block">
<Button
className={className}
variant={variant}
size={size}
onClick={handleClick}
disabled={
disabled || (isSaveMode ? !isSaveEnabled : !hasCachedId)
}
>
{isSaveMode
? messages.text.ClipboardApp.html.saveId_dis
: messages.text.ClipboardApp.html.useCachedId_dis}
</Button>
</span>
</Tooltip>
);
}
@@ -394,7 +394,7 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
return (
<div
key={item.name}
className="flex flex-col sm:flex-row sm:items-center gap-2 mb-2 p-2 sm:p-3 border border-gray-100 rounded-lg"
className="flex flex-col sm:flex-row sm:items-center gap-2 mb-2 p-2 sm:p-3 border border-border rounded-lg"
>
<Tooltip content={tooltipContent}>
<div className="flex-1 min-w-0">
@@ -404,7 +404,7 @@ const FileListDisplay: React.FC<FileListDisplayProps> = ({
? `${item.name.slice(0, filenameDisplayLen - 3)}...`
: item.name}
</span>
<span className="text-xs sm:text-sm text-gray-500">
<span className="text-xs sm:text-sm text-muted-foreground">
{isFolder
? `${formatFolderDis(
messages!.text.FileListDisplay.folder_dis_template,
@@ -59,7 +59,7 @@ const FileTransferButton = ({
if (isSavedToDisk) {
return {
variant: "ghost" as const,
className: "mr-2 text-gray-500",
className: "mr-2 text-muted-foreground",
};
}
if (isCurrentFileTransferring) {
@@ -70,20 +70,19 @@ const FileTransferButton = ({
}
if (isPendingSave) {
return {
variant: "default" as const, // 使用更明显的样式
className: "mr-2 bg-green-600 hover:bg-green-700 text-white",
variant: "default" as const,
className: "mr-2",
};
}
if (isOtherFileTransferring) {
return {
variant: "outline" as const,
className:
"mr-2 cursor-not-allowed bg-gray-100 border-gray-300 text-gray-500",
className: "mr-2 cursor-not-allowed bg-muted text-muted-foreground",
};
}
return {
variant: "outline" as const,
className: "mr-2 hover:bg-blue-50",
className: "mr-2 hover:bg-accent",
};
};
@@ -118,10 +117,7 @@ const FileTransferButton = ({
</Button>
</span>
</TooltipTrigger>
<TooltipContent
side="top"
className="bg-gray-800 text-white px-3 py-2 rounded-md text-sm"
>
<TooltipContent side="top" className="px-3 py-2 rounded-md text-sm">
{getTooltipContent()}
</TooltipContent>
</Tooltip>
@@ -160,14 +160,14 @@ const FileUploadHandler: React.FC<FileUploadHandlerProps> = ({
return (
<>
<div
className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center cursor-pointer"
className="border-2 border-dashed border-border rounded-lg p-6 text-center cursor-pointer"
onClick={handleZoneClick}
>
<p className="text-sm text-gray-600 mb-4">
<p className="text-sm text-muted-foreground mb-4">
{messages.text.fileUploadHandler.chooseFileTips}
</p>
<Upload className="h-12 w-12 mx-auto mb-4 text-blue-500" />
<p className="text-sm text-gray-600">{fileText}</p>
<Upload className="h-12 w-12 mx-auto mb-4 text-primary" />
<p className="text-sm text-muted-foreground">{fileText}</p>
<Input
id="file-upload"
@@ -202,13 +202,13 @@ const FileUploadHandler: React.FC<FileUploadHandlerProps> = ({
<div className="flex justify-center gap-4 mt-6">
<button
onClick={handleSelectFile}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
className="px-4 py-2 rounded transition-colors bg-primary text-primary-foreground hover:bg-primary/90"
>
{messages.text.fileUploadHandler.SelectFile_dis}
</button>
<button
onClick={handleSelectFolder}
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 transition-colors"
className="px-4 py-2 rounded transition-colors bg-secondary text-secondary-foreground hover:bg-secondary/80"
>
{messages.text.fileUploadHandler.SelectFolder_dis}
</button>
@@ -1,10 +1,11 @@
import React, { useCallback } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
ReadClipboardButton,
WriteClipboardButton,
} from "@/components/common/clipboard_btn";
import CachedIdActionButton from "@/components/ClipboardApp/CachedIdActionButton";
import FileListDisplay from "@/components/ClipboardApp/FileListDisplay";
import type { Messages } from "@/types/messages";
import type { FileMeta } from "@/types/webrtc";
@@ -97,7 +98,7 @@ export function RetrieveTabPanel({
return (
<div id="retrieve-panel" role="tabpanel" aria-labelledby="retrieve-tab">
<div className="mb-3 text-sm text-gray-600">
<div className="mb-3 text-sm text-muted-foreground">
{retrieveRoomStatusText ||
(isReceiverInRoom
? messages.text.ClipboardApp.roomStatus.connected_dis
@@ -111,6 +112,14 @@ export function RetrieveTabPanel({
title={messages.text.ClipboardApp.html.readClipboard_dis}
onRead={setRetrieveRoomIdInput}
/>
{/* Save/Use Cached ID Button placed after Paste button */}
<CachedIdActionButton
messages={messages}
getInputValue={() => retrieveRoomIdInput}
setInputValue={setRetrieveRoomIdInput}
putMessageInMs={putMessageInMs}
isShareEnd={false}
/>
<Input
aria-label="Retrieve Room ID"
value={retrieveRoomIdInput}
@@ -147,7 +156,7 @@ export function RetrieveTabPanel({
</div>
{retrievedContent && (
<div className="my-3 p-3 border rounded-md">
<div className="bg-white p-3 rounded border border-gray-200 text-sm leading-relaxed">
<div className="bg-card text-card-foreground p-3 rounded border text-sm leading-relaxed">
<div dangerouslySetInnerHTML={{ __html: retrievedContent }} />
</div>
<div className="flex justify-start">
@@ -169,7 +178,7 @@ export function RetrieveTabPanel({
saveType={getReceiverSaveType()}
/>
{retrieveMessage && (
<p className="mt-3 text-sm text-blue-600">{retrieveMessage}</p>
<p className="mt-3 text-sm text-primary">{retrieveMessage}</p>
)}
</div>
);
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from "react";
import dynamic from "next/dynamic";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import CachedIdActionButton from "@/components/ClipboardApp/CachedIdActionButton";
import {
ReadClipboardButton,
WriteClipboardButton,
@@ -20,7 +21,7 @@ const RichTextEditor = dynamic(
{
ssr: false, // This component is client-side only
loading: () => (
<div className="p-4 border rounded-lg min-h-[200px] md:min-h-[400px] bg-gray-50 flex items-center justify-center">
<div className="p-4 border rounded-lg min-h-[200px] md:min-h-[400px] bg-muted flex items-center justify-center">
Loading Editor...
</div>
),
@@ -39,6 +40,11 @@ interface SendTabPanelProps {
shareMessage: string;
currentValidatedShareRoomId: string;
handleLeaveSenderRoom: () => void; // New prop for leaving room
putMessageInMs: (
message: string,
isShareEnd?: boolean,
displayTimeMs?: number
) => void;
}
export function SendTabPanel({
@@ -53,6 +59,7 @@ export function SendTabPanel({
shareMessage,
currentValidatedShareRoomId,
handleLeaveSenderRoom,
putMessageInMs,
}: SendTabPanelProps) {
// Get the status from the store
const {
@@ -129,7 +136,7 @@ export function SendTabPanel({
return (
<div id="send-panel" role="tabpanel" aria-labelledby="send-tab">
<div className="mb-3 text-sm text-gray-600">
<div className="mb-3 text-sm text-muted-foreground">
{shareRoomStatusText ||
(isSenderInRoom
? messages.text.ClipboardApp.roomStatus.onlyOneMsg
@@ -159,7 +166,7 @@ export function SendTabPanel({
<div className="space-y-3 mb-4">
{/* Room ID input section */}
<div className="space-y-2">
<p className="text-sm text-gray-600">
<p className="text-sm text-muted-foreground">
{messages.text.ClipboardApp.html.inputRoomId_tips}
</p>
<div className="flex flex-col sm:flex-row gap-2">
@@ -183,6 +190,19 @@ export function SendTabPanel({
? messages.text.ClipboardApp.html.generateRandomId_tips
: messages.text.ClipboardApp.html.generateSimpleId_tips}
</Button>
{/* Save/Use Cached ID Button in between */}
<CachedIdActionButton
messages={messages}
getInputValue={() => inputFieldValue}
setInputValue={setInputFieldValue}
putMessageInMs={putMessageInMs}
isShareEnd={true}
disabled={isSenderInRoom}
onUseCached={(id) => {
// Immediately join as sender after applying cached ID
joinRoom(true, id.trim());
}}
/>
<Button
className="w-full sm:w-auto px-4"
onClick={() => joinRoom(true, inputFieldValue.trim())}
@@ -223,7 +243,7 @@ export function SendTabPanel({
</div>
</div>
{shareMessage && (
<p className="mt-3 text-sm text-blue-600">{shareMessage}</p>
<p className="mt-3 text-sm text-primary">{shareMessage}</p>
)}
</div>
);
+11 -11
View File
@@ -120,21 +120,21 @@ const ShareCard: React.FC<ShareCardProps> = ({ RoomID, shareLink }) => {
return <div>Loading...</div>;
}
return (
<div className="bg-blue-50 p-2 sm:p-4 rounded-lg border border-blue-200">
<p className="text-blue-700 mb-3 sm:mb-4 text-sm sm:text-base">
<div className="bg-primary/10 p-2 sm:p-4 rounded-lg border border-primary/20">
<p className="text-primary mb-3 sm:mb-4 text-sm sm:text-base">
{messages.text.RetrieveMethod.P}
</p>
{/* Mobile-first responsive layout */}
<div className="space-y-3 sm:space-y-4">
{/* RoomID section */}
<div className="bg-white p-2 sm:p-3 rounded-lg border border-blue-100">
<div className="bg-card p-2 sm:p-3 rounded-lg border border-border">
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700">
<p className="text-sm font-medium text-foreground">
{messages.text.RetrieveMethod.RoomId_tips}
</p>
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
<code className="flex-1 bg-gray-100 px-2 py-1 rounded text-sm font-mono break-all">
<code className="flex-1 bg-muted px-2 py-1 rounded text-sm font-mono break-all">
{RoomID}
</code>
<WriteClipboardButton
@@ -146,12 +146,12 @@ const ShareCard: React.FC<ShareCardProps> = ({ RoomID, shareLink }) => {
</div>
{/* URL section */}
<div className="bg-white p-2 sm:p-3 rounded-lg border border-blue-100">
<div className="bg-card p-2 sm:p-3 rounded-lg border border-border">
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700">
<p className="text-sm font-medium text-foreground">
{messages.text.RetrieveMethod.url_tips}
</p>
<div className="bg-gray-100 px-2 py-2 rounded text-xs sm:text-sm break-all font-mono">
<div className="bg-muted px-2 py-2 rounded text-xs sm:text-sm break-all font-mono">
{shareLink}
</div>
<div className="flex justify-start">
@@ -164,15 +164,15 @@ const ShareCard: React.FC<ShareCardProps> = ({ RoomID, shareLink }) => {
</div>
{/* QR Code section */}
<div className="bg-white p-2 sm:p-3 rounded-lg border border-blue-100">
<div className="bg-card p-2 sm:p-3 rounded-lg border border-border">
<div className="space-y-3">
<p className="text-sm font-medium text-gray-700">
<p className="text-sm font-medium text-foreground">
{messages.text.RetrieveMethod.scanQR_tips}
</p>
{/* QR Code display area - moved up for better mobile UX */}
<div className="flex justify-center">
<div className="inline-block border-2 p-2 sm:p-4 bg-gray-50 rounded-lg">
<div className="inline-block border-2 p-2 sm:p-4 bg-muted rounded-lg">
<div ref={qrRef}>
<QRCodeSVG
value={shareLink}
@@ -9,21 +9,21 @@ export function AlignmentTools({ alignText }: AlignmentToolsProps) {
return (
<div className="flex flex-wrap gap-1">
<button
className="p-1.5 hover:bg-gray-200 rounded"
className="p-1.5 hover:bg-accent rounded"
onClick={() => alignText("left")}
title="Align left"
>
<AlignLeft className="w-3.5 h-3.5" />
</button>
<button
className="p-1.5 hover:bg-gray-200 rounded"
className="p-1.5 hover:bg-accent rounded"
onClick={() => alignText("center")}
title="Align center"
>
<AlignCenter className="w-3.5 h-3.5" />
</button>
<button
className="p-1.5 hover:bg-gray-200 rounded"
className="p-1.5 hover:bg-accent rounded"
onClick={() => alignText("right")}
title="Align right"
>
@@ -14,7 +14,7 @@ export function BasicFormatTools({
<div className="flex flex-wrap gap-1">
<button
className={`p-1.5 rounded ${
isStyleActive("bold") ? "bg-gray-200" : "hover:bg-gray-200"
isStyleActive("bold") ? "bg-accent" : "hover:bg-accent"
}`}
onClick={() => formatText("bold")}
title="Bold"
@@ -23,7 +23,7 @@ export function BasicFormatTools({
</button>
<button
className={`p-1.5 rounded ${
isStyleActive("italic") ? "bg-gray-200" : "hover:bg-gray-200"
isStyleActive("italic") ? "bg-accent" : "hover:bg-accent"
}`}
onClick={() => formatText("italic")}
title="Italic"
@@ -32,7 +32,7 @@ export function BasicFormatTools({
</button>
<button
className={`p-1.5 rounded ${
isStyleActive("underline") ? "bg-gray-200" : "hover:bg-gray-200"
isStyleActive("underline") ? "bg-accent" : "hover:bg-accent"
}`}
onClick={() => formatText("underline")}
title="Underline"
@@ -14,21 +14,21 @@ export function InsertTools({
return (
<div className="flex flex-wrap gap-1">
<button
className="p-1.5 hover:bg-gray-200 rounded"
className="p-1.5 hover:bg-accent rounded"
onClick={insertLink}
title="Insert url"
>
<Link2 className="w-3.5 h-3.5" />
</button>
<button
className="p-1.5 hover:bg-gray-200 rounded"
className="p-1.5 hover:bg-accent rounded"
onClick={insertImage}
title="Upload image"
>
<Image className="w-3.5 h-3.5" />
</button>
<button
className="p-1.5 hover:bg-gray-200 rounded"
className="p-1.5 hover:bg-accent rounded"
onClick={insertCodeBlock}
title="Insert code"
>
@@ -129,7 +129,7 @@ const RichTextEditor: React.FC<EditorProps> = ({ onChange, value = "" }) => {
<div className="w-full space-x-2 mb-4">
<div className="border rounded-lg shadow-sm overflow-hidden">
{/* Toolbar - Add light gray background and bottom border */}
<div className="flex flex-wrap gap-1 p-2 bg-gray-50 border-b">
<div className="flex flex-wrap gap-1 p-2 bg-muted border-b border-border">
{/* Basic format tool group */}
<BasicFormatTools
isStyleActive={isStyleActive}
@@ -158,10 +158,10 @@ const RichTextEditor: React.FC<EditorProps> = ({ onChange, value = "" }) => {
/>
</div>
{/* Editor area - Add pure white background and inner shadow effect */}
{/* Editor area - use theme tokens for background */}
<div
ref={editorRef}
className="p-4 min-h-[200px] md:min-h-[400px] focus:outline-none bg-white shadow-inner"
className="p-4 min-h-[200px] md:min-h-[400px] focus:outline-none bg-card shadow-inner"
contentEditable
onPaste={handlePaste}
onInput={handleChange}
@@ -10,7 +10,7 @@ export const SelectMenu: React.FC<SelectMenuProps> = ({
}) => (
<div className="relative inline-block">
<select
className={`appearance-none bg-transparent border rounded p-1.5 pr-6 hover:bg-gray-200 focus:outline-none ${className}`}
className={`appearance-none bg-transparent border border-border rounded p-1.5 pr-6 hover:bg-accent focus:outline-none ${className}`}
onChange={(e) => onChange(e.target.value)}
>
<option value="">{placeholder}</option>
+17 -11
View File
@@ -1,15 +1,17 @@
import Link from "next/link";
import Image from "next/image";
import { type BlogPost } from "@/lib/blog";
import { Messages } from "@/types/messages";
interface ArticleListItemProps {
post: BlogPost;
lang: string;
messages: Messages;
}
export function ArticleListItem({ post, lang }: ArticleListItemProps) {
export function ArticleListItem({ post, lang, messages }: ArticleListItemProps) {
return (
<article className="bg-white rounded-xl shadow-lg hover:shadow-xl transition-shadow overflow-hidden">
<article className="bg-card rounded-xl shadow-lg hover:shadow-xl transition-shadow overflow-hidden">
<div className="relative h-80 w-full">
<Image
src={post.frontmatter.cover}
@@ -22,16 +24,20 @@ export function ArticleListItem({ post, lang }: ArticleListItemProps) {
</div>
<div className="p-8">
<div className="flex items-center gap-4 text-sm text-gray-500 mb-4">
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-4">
<time className="font-medium">
{new Date(post.frontmatter.date).toLocaleDateString()}
{new Date(post.frontmatter.date).toLocaleDateString(lang, {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
<span>·</span>
<div className="flex gap-2 flex-wrap">
{post.frontmatter.tags.map((tag) => (
<span
key={tag}
className="bg-gray-100 px-3 py-1 rounded-full hover:bg-gray-200 transition-colors"
className="bg-muted px-3 py-1 rounded-full hover:bg-accent transition-colors"
>
{tag}
</span>
@@ -39,21 +45,21 @@ export function ArticleListItem({ post, lang }: ArticleListItemProps) {
</div>
</div>
<Link href={`/${lang}/blog/${post.slug}`}>
<h2 className="text-3xl font-bold mb-4 hover:text-blue-600 transition-colors leading-tight">
<h2 className="text-3xl font-bold mb-4 hover:text-primary transition-colors leading-tight">
{post.frontmatter.title}
</h2>
</Link>
<p className="text-gray-600 mb-6 text-lg leading-relaxed line-clamp-3">
<p className="text-muted-foreground mb-6 text-lg leading-relaxed line-clamp-3">
{post.frontmatter.description}
</p>
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
<div className="flex items-center justify-between pt-4 border-t border-border">
<Link
href={`/${lang}/blog/${post.slug}`}
className="text-blue-600 hover:text-blue-800 font-medium inline-flex items-center text-lg"
className="text-primary hover:text-primary/80 font-medium inline-flex items-center text-lg"
>
Read more
{messages.text.blog.read_more}
<svg
className="w-5 h-5 ml-2"
viewBox="0 0 24 24"
@@ -71,7 +77,7 @@ export function ArticleListItem({ post, lang }: ArticleListItemProps) {
<div className="flex items-center gap-3">
<span className="text-sm">
by <span className="font-bold">{post.frontmatter.author}</span>
{messages.text.blog.by} <span className="font-bold">{post.frontmatter.author}</span>
</span>
</div>
</div>
+14 -14
View File
@@ -71,7 +71,7 @@ export type MDXComponents = {
// Custom MDX components
export const mdxComponents: MDXComponents = {
p: ({ children, ...props }) => (
<div className="mb-6 leading-relaxed text-gray-700" {...props}>
<div className="mb-6 leading-relaxed text-foreground" {...props}>
{children}
</div>
),
@@ -92,7 +92,7 @@ export const mdxComponents: MDXComponents = {
alt={props.alt || ""}
/>
{props.alt && (
<div className="text-center text-sm text-gray-600 mt-2 italic">
<div className="text-center text-sm text-muted-foreground mt-2 italic">
{props.alt}
</div>
)}
@@ -101,7 +101,7 @@ export const mdxComponents: MDXComponents = {
},
pre: ({ children, ...props }) => (
<pre
className="relative my-6 rounded-lg bg-gray-50 border border-gray-200 p-4 overflow-x-auto"
className="relative my-6 rounded-lg bg-muted border border-border p-4 overflow-x-auto"
{...props}
>
{children}
@@ -111,13 +111,13 @@ export const mdxComponents: MDXComponents = {
const isInlineCode = !className;
return isInlineCode ? (
<code
className="bg-gray-50 rounded px-1.5 py-0.5 text-gray-800 border border-gray-200 text-sm"
className="bg-muted rounded px-1.5 py-0.5 text-foreground border border-border text-sm"
{...props}
>
{children}
</code>
) : (
<code className="block text-gray-800 text-sm" {...props}>
<code className="block text-foreground text-sm" {...props}>
{children}
</code>
);
@@ -125,7 +125,7 @@ export const mdxComponents: MDXComponents = {
table: ({ children, ...props }) => (
<div className="my-8 w-full overflow-x-auto">
<table
className="min-w-full divide-y divide-gray-300 border border-gray-300"
className="min-w-full divide-y divide-border border border-border"
{...props}
>
{children}
@@ -133,23 +133,23 @@ export const mdxComponents: MDXComponents = {
</div>
),
thead: ({ children, ...props }) => (
<thead className="bg-gray-50" {...props}>
<thead className="bg-muted" {...props}>
{children}
</thead>
),
tbody: ({ children, ...props }) => (
<tbody className="divide-y divide-gray-200 bg-white" {...props}>
<tbody className="divide-y divide-border bg-card" {...props}>
{children}
</tbody>
),
tr: ({ children, ...props }) => (
<tr className="hover:bg-gray-50" {...props}>
<tr className="hover:bg-accent" {...props}>
{children}
</tr>
),
th: ({ children, ...props }) => (
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border-r last:border-r-0"
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider border-r last:border-r-0"
{...props}
>
{children}
@@ -157,7 +157,7 @@ export const mdxComponents: MDXComponents = {
),
td: ({ children, ...props }) => (
<td
className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 border-r last:border-r-0"
className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground border-r last:border-r-0"
{...props}
>
{children}
@@ -165,7 +165,7 @@ export const mdxComponents: MDXComponents = {
),
blockquote: ({ children, ...props }) => (
<blockquote
className="border-l-4 border-blue-500 pl-4 my-4 italic text-gray-600 bg-gray-50 py-2 rounded-r-lg"
className="border-l-4 border-primary pl-4 my-4 italic text-muted-foreground bg-muted py-2 rounded-r-lg"
{...props}
>
{children}
@@ -173,7 +173,7 @@ export const mdxComponents: MDXComponents = {
),
ul: ({ children, ...props }) => (
<ul
className="list-disc list-outside ml-6 my-6 space-y-2 text-gray-700"
className="list-disc list-outside ml-6 my-6 space-y-2 text-foreground"
{...props}
>
{children}
@@ -181,7 +181,7 @@ export const mdxComponents: MDXComponents = {
),
ol: ({ children, ...props }) => (
<ol
className="list-decimal list-outside ml-6 my-6 space-y-2 text-gray-700"
className="list-decimal list-outside ml-6 my-6 space-y-2 text-foreground"
{...props}
>
{children}
+7 -5
View File
@@ -10,10 +10,12 @@ interface TocItem {
interface TableOfContentsProps {
content: string;
title?: string;
}
export const TableOfContents: React.FC<TableOfContentsProps> = ({
content,
title = "Table of contents",
}) => {
const [activeId, setActiveId] = useState<string>("");
const [toc, setToc] = useState<TocItem[]>([]);
@@ -109,8 +111,8 @@ export const TableOfContents: React.FC<TableOfContentsProps> = ({
if (toc.length === 0) return null;
return (
<nav className="hidden lg:block sticky top-8 p-6 bg-gray-50 rounded-lg max-h-[calc(100vh-4rem)] overflow-y-auto">
<h4 className="text-lg font-semibold mb-4">Table of contents</h4>
<nav className="hidden lg:block sticky top-8 p-6 bg-muted rounded-lg max-h-[calc(100vh-4rem)] overflow-y-auto">
<h4 className="text-lg font-semibold mb-4">{title}</h4>
<ul className="space-y-2">
{toc.map((item) => (
<li
@@ -123,10 +125,10 @@ export const TableOfContents: React.FC<TableOfContentsProps> = ({
<button
onClick={() => scrollToHeader(item.id)}
className={clsx(
"block w-full text-left py-1 text-sm hover:text-blue-600 transition-colors",
"block w-full text-left py-1 text-sm hover:text-primary transition-colors",
activeId === item.id
? "text-blue-600 font-medium"
: "text-gray-600"
? "text-primary font-medium"
: "text-muted-foreground"
)}
>
{item.text}
+23
View File
@@ -0,0 +1,23 @@
import React from "react";
type JsonLdProps = {
data: Record<string, any> | Record<string, any>[];
id?: string;
};
export default function JsonLd({ data, id }: JsonLdProps) {
const blocks = Array.isArray(data) ? data : [data];
return (
<>
{blocks.map((item, idx) => (
<script
key={id ? `${id}-${idx}` : idx}
type="application/ld+json"
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: JSON.stringify(item) }}
/>
))}
</>
);
}
+3
View File
@@ -8,6 +8,7 @@ import Image from "next/image";
import { Menu, X, Github } from "lucide-react";
import LanguageSwitcher from "@/components/LanguageSwitcher";
import { Messages } from "@/types/messages";
import ThemeToggle from "@/components/web/ThemeToggle";
/**
* Props interface for the Header component
@@ -91,6 +92,7 @@ const Header = ({ messages, lang }: HeaderProps) => {
<Github className="h-5 w-5" />
</Link>
</Button>
<ThemeToggle />
<LanguageSwitcher />
</div>
</div>
@@ -98,6 +100,7 @@ const Header = ({ messages, lang }: HeaderProps) => {
{/* Mobile menu controls */}
<div className="md:hidden flex items-center space-x-2">
<LanguageSwitcher />
<ThemeToggle />
<Button asChild variant="ghost" size="icon">
<Link
href={githubUrl}
+3 -3
View File
@@ -33,7 +33,7 @@ export default function HowItWorks({ messages }: PageContentProps) {
<h2 className="text-3xl md:text-4xl font-bold mb-6">
{messages.text.HowItWorks.h2}
</h2>
<p className="text-gray-600 mb-8">{messages.text.HowItWorks.h2_P}</p>
<p className="text-muted-foreground mb-8">{messages.text.HowItWorks.h2_P}</p>
<Button className="bg-gradient-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600 text-white rounded-full px-8 py-6 text-lg">
{messages.text.HowItWorks.btn_try}
</Button>
@@ -60,7 +60,7 @@ export default function HowItWorks({ messages }: PageContentProps) {
</div>
<div className="flex-1">
<h3 className="text-xl font-bold mb-2">{step.title}</h3>
<p className="text-gray-600">{step.description}</p>
<p className="text-muted-foreground">{step.description}</p>
</div>
</div>
))}
@@ -69,7 +69,7 @@ export default function HowItWorks({ messages }: PageContentProps) {
{/* Right Side - Demo Animation */}
<div className="w-full md:w-1/2">
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
<div className="bg-card rounded-lg shadow-lg overflow-hidden">
<video autoPlay loop muted playsInline width="1920" height="75">
<source src="/HowItWorks.webm" type="video/webm" />
</video>
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { Button } from "@/components/ui/button";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
export default function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const isDark = resolvedTheme === "dark";
const toggle = () => setTheme(isDark ? "light" : "dark");
return (
<Button
variant="ghost"
size="icon"
aria-label="Toggle theme"
onClick={toggle}
disabled={!mounted}
>
{isDark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
<span className="sr-only">Toggle theme</span>
</Button>
);
}
+30 -3
View File
@@ -45,6 +45,14 @@ export const de: Messages = {
description:
"Überprüfen Sie die Nutzungsbedingungen für PrivyDrop, einschließlich Informationen zur akzeptablen Nutzung des Dienstes, Datenschutz und -sicherheit sowie Haftungsbeschränkungen.",
},
blog: {
title:
"PrivyDrop Blog - Private P2P-Dateifreigabe & Zusammenarbeit",
description:
"Entdecken Sie Tipps für sicheres Dateifreigeben, datenschutzorientierte Zusammenarbeit und wie man P2P und WebRTC für sicherere Übertragungen nutzt.",
keywords:
"sichere Dateifreigabe,p2p Dateiübertragung,private Zusammenarbeit,webrtc,Ende-zu-Ende-Verschlüsselung,Teamzusammenarbeit,Datenschutz-Tools",
},
},
text: {
Header: {
@@ -63,6 +71,19 @@ export const de: Messages = {
Privacy_dis: "Datenschutzrichtlinie",
SupportedLanguages: "Unterstützte Sprachen",
},
blog: {
list_title: "Blog",
list_subtitle: "Neueste Artikel und Updates",
recent_posts: "Neueste Beiträge",
tags: "Schlagwörter",
read_more: "Weiterlesen",
by: "von",
post_not_found: "Beitrag nicht gefunden",
toc_title: "Inhaltsverzeichnis",
tag_title_prefix: "Schlagwort",
tag_subtitle_template: "Artikel mit dem Schlagwort {tag}",
tag_empty: "Keine Artikel für dieses Schlagwort gefunden.",
},
privacy: {
PrivacyPolicy_dis: "Datenschutzrichtlinie",
@@ -284,8 +305,7 @@ export const de: Messages = {
"Warten auf den Empfänger, der sich verbindet. Bitte lassen Sie diese Seite geöffnet, bis die Übertragung abgeschlossen ist. Auf dem Desktop können Sie den Browser minimieren oder zwischen Tabs wechseln. Auf mobilen Geräten sollte der Browser im Vordergrund bleiben.",
joinRoom: {
EmptyMsg: "Warnung, die Raum-ID ist leer",
DuplicateMsg:
"Die eingegebene Raum-ID ist doppelt. Bitte geben Sie sie erneut ein.",
DuplicateMsg: "Diese Raum-ID ist bereits vergeben. Bitte wählen Sie eine andere ID.",
successMsg:
"Raum erfolgreich betreten! Schließen Sie diese Seite nicht, bis die Übertragung abgeschlossen ist. (Am Desktop können Sie den Browser minimieren oder Tabs wechseln; auf mobilen Geräten bringen Sie den Browser nicht in den Hintergrund.)",
notExist:
@@ -312,7 +332,7 @@ export const de: Messages = {
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?",
"Übertragung wird unterbrochen. Bei Speicherverzeichnis kann fortgesetzt werden. Verlassen?",
leaveWhileTransferringSuccess: "Raum verlassen, Übertragung unterbrochen",
html: {
senderTab: "Senden",
@@ -336,7 +356,14 @@ export const de: Messages = {
readClipboard_dis: "Raum-ID einfügen",
retrieveRoomId_placeholder: "Raum-ID eingeben",
RetrieveMethodTitle: "Abrufmethode",
// New: cached ID utils
saveId_dis: "ID speichern",
useCachedId_dis: "Gespeicherte ID verwenden",
saveId_tips: "Aktuelle ID für spätere schnelle Nutzung speichern",
useCachedId_tips: "Gespeicherte ID schnell nutzen; Doppelklick zum Speichermodus wechseln",
},
// New: cache messages
saveId_success: "Erfolgreich im Cache gespeichert",
},
home: {
h1: "Kostenloses sicheres Online-Clipboard & Dateiübertragungstool",
+30 -2
View File
@@ -45,6 +45,14 @@ export const en: Messages = {
description:
"Review the terms of use for PrivyDrop, including information about the acceptable use of the service, data privacy and security, and limitations of liability.",
},
blog: {
title:
"PrivyDrop Blog - Private P2P File Sharing & Collaboration",
description:
"Discover secure file sharing tips, privacy-first collaboration strategies, and how to leverage P2P and WebRTC for safer data transfer.",
keywords:
"secure file sharing,p2p file transfer,private collaboration,webrtc,end-to-end encryption,team collaboration,privacy tools",
},
},
text: {
Header: {
@@ -63,6 +71,19 @@ export const en: Messages = {
Privacy_dis: "Privacy Policy",
SupportedLanguages: "Supported Languages",
},
blog: {
list_title: "Blog",
list_subtitle: "Latest articles and updates",
recent_posts: "Recent Posts",
tags: "Tags",
read_more: "Read more",
by: "by",
post_not_found: "Post not found",
toc_title: "Table of contents",
tag_title_prefix: "Tag",
tag_subtitle_template: "Articles tagged with {tag}",
tag_empty: "No articles found for this tag.",
},
privacy: {
PrivacyPolicy_dis: "Privacy Policy",
@@ -278,7 +299,7 @@ export const en: Messages = {
"Waiting for receiver to connect. Please keep this page open until the transfer is complete. On desktop, you can minimize the browser or switch tabs. On mobile, please keep the browser in the foreground.",
joinRoom: {
EmptyMsg: "Warning, the roomID is empty",
DuplicateMsg: "The room ID you entered is duplicate. Please re-enter.",
DuplicateMsg: "This room ID is already in use. Please choose another ID.",
successMsg:
"Successfully joined the room! Do not close this page until the transfer is complete. (On desktop, you can minimize the browser or switch tabs; on mobile, do not move the browser to the background.)",
notExist:
@@ -303,7 +324,7 @@ 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?",
confirmLeaveWhileTransferring: "Transfer will be interrupted. Can be resumed if save directory is set. Exit anyway?",
leaveWhileTransferringSuccess: "Left room, transfer interrupted",
html: {
senderTab: "Send",
@@ -327,7 +348,14 @@ export const en: Messages = {
readClipboard_dis: "Paste RoomID",
retrieveRoomId_placeholder: "Enter RoomID",
RetrieveMethodTitle: "Retrieve method",
// New: cached ID utils
saveId_dis: "Save ID",
useCachedId_dis: "Use cached ID",
saveId_tips: "Save current ID for quick reuse later",
useCachedId_tips: "Quick use saved ID; double-click to switch save mode",
},
// New: cache messages
saveId_success: "Saved to cache",
},
home: {
h1: "Free Secure Online Clipboard & File Transfer Tool",
+30 -3
View File
@@ -45,6 +45,14 @@ export const es: Messages = {
description:
"Revise los términos de uso de PrivyDrop, incluyendo información sobre el uso aceptable del servicio, privacidad y seguridad de datos, y limitaciones de responsabilidad.",
},
blog: {
title:
"Blog de PrivyDrop - Compartición de archivos P2P privada y colaboración",
description:
"Descubre consejos de compartición segura de archivos, estrategias de colaboración con enfoque en la privacidad y cómo aprovechar P2P y WebRTC para transferencias más seguras.",
keywords:
"compartición segura de archivos,transferencia de archivos p2p,colaboración privada,webrtc,cifrado de extremo a extremo,colaboración en equipo,herramientas de privacidad",
},
},
text: {
Header: {
@@ -63,6 +71,19 @@ export const es: Messages = {
Privacy_dis: "Política de Privacidad",
SupportedLanguages: "Idiomas soportados",
},
blog: {
list_title: "Blog",
list_subtitle: "Últimos artículos y actualizaciones",
recent_posts: "Entradas recientes",
tags: "Etiquetas",
read_more: "Leer más",
by: "por",
post_not_found: "Artículo no encontrado",
toc_title: "Tabla de contenidos",
tag_title_prefix: "Etiqueta",
tag_subtitle_template: "Artículos etiquetados con {tag}",
tag_empty: "No se encontraron artículos para esta etiqueta.",
},
privacy: {
PrivacyPolicy_dis: "Política de Privacidad",
h1: "Política de Privacidad de PrivyDrop",
@@ -278,8 +299,7 @@ export const es: Messages = {
"Esperando que el receptor se conecte. Por favor mantén esta página abierta hasta que se complete la transferencia. En escritorio, puedes minimizar el navegador o cambiar pestañas. En móvil, por favor mantén el navegador en primer plano.",
joinRoom: {
EmptyMsg: "Advertencia, el ID de sala está vacío",
DuplicateMsg:
"El ID de sala que ingresaste está duplicado. Por favor, vuelve a ingresar.",
DuplicateMsg: "Este ID de sala ya está en uso. Por favor, elige otro ID.",
successMsg:
"¡Ingreso exitoso al cuarto! No cierres esta página hasta que se complete la transferencia. (En escritorio, puedes minimizar el navegador o cambiar de pestaña; en móvil, no lleves el navegador al fondo.)",
notExist:
@@ -306,7 +326,7 @@ export const es: Messages = {
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?",
"Transferencia se interrumpirá. Se puede reanudar si hay directorio de guardado. ¿Salir de todos modos?",
leaveWhileTransferringSuccess:
"Saliste de la sala, transferencia interrumpida",
html: {
@@ -331,7 +351,14 @@ export const es: Messages = {
readClipboard_dis: "Pegar ID de Sala",
retrieveRoomId_placeholder: "Ingresa ID de Sala",
RetrieveMethodTitle: "Método de recuperación",
// New: cached ID utils
saveId_dis: "Guardar ID",
useCachedId_dis: "Usar ID en caché",
saveId_tips: "Guarda el ID actual para reutilizarlo rápidamente",
useCachedId_tips: "Usar ID guardado rápido; doble clic para cambiar modo guardar",
},
// New: cache messages
saveId_success: "Guardado en caché",
},
home: {
h1: "Herramienta Gratuita de Portapapeles y Transferencia de Archivos en Línea Segura",
+31 -3
View File
@@ -45,6 +45,14 @@ export const fr: Messages = {
description:
"Consultez les conditions d'utilisation de PrivyDrop, y compris des informations sur l'utilisation acceptable du service, la confidentialité et la sécurité des données, ainsi que les limitations de responsabilité.",
},
blog: {
title:
"Blog PrivyDrop - Partage de fichiers P2P privé et collaboration",
description:
"Découvrez des conseils pour un partage de fichiers sécurisé, des stratégies de collaboration axées sur la confidentialité et comment tirer parti de P2P et WebRTC pour des transferts plus sûrs.",
keywords:
"partage de fichiers sécurisé,transfert de fichiers p2p,collaboration privée,webrtc,chiffrement de bout en bout,collaboration d'équipe,outils de confidentialité",
},
},
text: {
Header: {
@@ -63,6 +71,19 @@ export const fr: Messages = {
Privacy_dis: "Politique de confidentialité",
SupportedLanguages: "Langues prises en charge",
},
blog: {
list_title: "Blog",
list_subtitle: "Derniers articles et mises à jour",
recent_posts: "Articles récents",
tags: "Étiquettes",
read_more: "En savoir plus",
by: "par",
post_not_found: "Article introuvable",
toc_title: "Table des matières",
tag_title_prefix: "Étiquette",
tag_subtitle_template: "Articles marqués avec {tag}",
tag_empty: "Aucun article trouvé pour cette étiquette.",
},
privacy: {
PrivacyPolicy_dis: "Politique de confidentialité",
@@ -284,8 +305,7 @@ export const fr: Messages = {
"En attente de la connexion du destinataire. Veuillez garder cette page ouverte jusqu'à la fin du transfert. Sur ordinateur, vous pouvez minimiser le navigateur ou changer d'onglet. Sur mobile, veuillez garder le navigateur au premier plan.",
joinRoom: {
EmptyMsg: "Avertissement, l'ID de salle est vide",
DuplicateMsg:
"L'ID de salle que vous avez entré est en double. Veuillez le réentrer.",
DuplicateMsg: "Cet ID de salle est déjà utilisé. Veuillez choisir un autre ID.",
successMsg:
"Rejoignez le salon avec succès ! Ne fermez pas cette page tant que le transfert n'est pas terminé. (Sur ordinateur, vous pouvez réduire le navigateur ou changer d'onglet ; sur mobile, ne mettez pas le navigateur en arrière-plan.)",
notExist:
@@ -314,7 +334,7 @@ export const fr: Messages = {
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?",
"Le transfert sera interrompu. Reprenez si un répertoire de sauvegarde est défini. Quitter quand même ?",
leaveWhileTransferringSuccess: "Salle quittée, transfert interrompu",
html: {
senderTab: "Envoyer",
@@ -338,7 +358,15 @@ export const fr: Messages = {
readClipboard_dis: "Coller l'ID de salle",
retrieveRoomId_placeholder: "Entrez l'ID de salle",
RetrieveMethodTitle: "Méthode de récupération",
// New: cached ID utils
saveId_dis: "Enregistrer lID",
useCachedId_dis: "Utiliser lID en cache",
saveId_tips:
"Enregistrez lID actuel pour une réutilisation rapide",
useCachedId_tips: "Utiliser ID enregistré rapide; double-clic pour changer mode sauvegarde",
},
// New: cache messages
saveId_success: "Enregistré dans le cache",
},
home: {
h1: "Outil gratuit de transfert de fichiers et de presse-papiers en ligne sécurisé",
+30 -2
View File
@@ -45,6 +45,14 @@ export const ja: Messages = {
description:
"PrivyDropの利用規約を確認しましょう。サービスの適切な使用、データプライバシーとセキュリティ、責任の制限に関する情報が含まれます。",
},
blog: {
title:
"PrivyDrop ブログ - プライベートなP2Pファイル共有とコラボレーション",
description:
"安全なファイル共有のヒント、プライバシー重視のコラボレーション戦略、そしてP2PとWebRTCを活用したより安全なデータ転送について学びましょう。",
keywords:
"安全なファイル共有,p2pファイル転送,プライベートコラボレーション,webrtc,エンドツーエンド暗号化,チームコラボレーション,プライバシーツール",
},
},
text: {
Header: {
@@ -63,6 +71,19 @@ export const ja: Messages = {
Privacy_dis: "プライバシーポリシー",
SupportedLanguages: "対応言語",
},
blog: {
list_title: "ブログ",
list_subtitle: "最新の記事と更新",
recent_posts: "最新の投稿",
tags: "タグ",
read_more: "続きを読む",
by: "著者",
post_not_found: "記事が見つかりません",
toc_title: "目次",
tag_title_prefix: "タグ",
tag_subtitle_template: "「{tag}」のタグが付いた記事",
tag_empty: "このタグの記事は見つかりません。",
},
privacy: {
PrivacyPolicy_dis: "プライバシーポリシー",
h1: "PrivyDropプライバシーポリシー",
@@ -274,7 +295,7 @@ export const ja: Messages = {
"受信者が接続するのを待っています。転送が完了するまでこのページを開いたままにしてください。デスクトップでは、ブラウザを最小化したり、タブを切り替えたりできます。モバイルでは、ブラウザをフォアグラウンドに保ってください。",
joinRoom: {
EmptyMsg: "警告、ルームIDが空です",
DuplicateMsg: "入力したルームIDが重複しています。再入力してください。",
DuplicateMsg: "このルームIDは既に使用されています。別のIDをご利用ください。",
successMsg:
"ルームに成功して参加しました!転送が完了するまでこのページを閉じないでください。(PCではブラウザを最小化したりタブを切り替えたりできます。モバイルではブラウザをバックグラウンドにしないでください。)",
notExist:
@@ -299,7 +320,7 @@ export const ja: Messages = {
noFilesForFolderMsg: "フォルダ '{folderName}' にファイルが見つかりません。",
zipError: "ZIP の作成中にエラーが発生しました。",
fileNotFoundMsg: "ダウンロードするファイル '{fileName}' が見つかりません。",
confirmLeaveWhileTransferring: "現在ファイルが転送中です。退出すると転送が中断されます。よろしいですか?",
confirmLeaveWhileTransferring: "転送が中断されます。保存先設定時は再開可能。退出しますか?",
leaveWhileTransferringSuccess: "ルームを退出しました。転送が中断されました",
html: {
senderTab: "送信",
@@ -323,7 +344,14 @@ export const ja: Messages = {
readClipboard_dis: "ルームIDを貼り付け",
retrieveRoomId_placeholder: "ルームIDを入力",
RetrieveMethodTitle: "取得方法",
// New: cached ID utils
saveId_dis: "ID を保存",
useCachedId_dis: "保存済みIDを使用",
saveId_tips: "現在のIDを保存して次回すぐに使えるようにします",
useCachedId_tips: "保存済みIDを即使用;ダブルクリックで保存モード切替",
},
// New: cache messages
saveId_success: "キャッシュに保存しました",
},
home: {
h1: "無料で安全なオンラインクリップボード&ファイル転送ツール",
+31 -2
View File
@@ -45,6 +45,14 @@ export const ko: Messages = {
description:
"PrivyDrop의 이용 약관을 검토하세요. 서비스의 허용 가능한 사용, 데이터 개인 정보 보호 및 보안, 책임 제한에 대한 정보를 포함합니다.",
},
blog: {
title:
"PrivyDrop 블로그 - 개인 P2P 파일 공유 및 협업",
description:
"안전한 파일 공유 팁, 개인 정보 중심의 협업 전략, 그리고 P2P와 WebRTC를 활용한 더 안전한 데이터 전송 방법을 알아보세요.",
keywords:
"안전한 파일 공유,p2p 파일 전송,개인 협업,webrtc,종단간 암호화,팀 협업,프라이버시 도구",
},
},
text: {
Header: {
@@ -63,6 +71,19 @@ export const ko: Messages = {
Privacy_dis: "개인정보 보호정책",
SupportedLanguages: "지원 언어",
},
blog: {
list_title: "블로그",
list_subtitle: "최신 글과 업데이트",
recent_posts: "최근 글",
tags: "태그",
read_more: "더 보기",
by: "작성자",
post_not_found: "게시글을 찾을 수 없습니다",
toc_title: "목차",
tag_title_prefix: "태그",
tag_subtitle_template: "{tag} 태그가 달린 글",
tag_empty: "해당 태그의 글이 없습니다.",
},
privacy: {
PrivacyPolicy_dis: "개인정보 보호정책",
@@ -272,7 +293,7 @@ export const ko: Messages = {
"수신자가 연결될 때까지 기다리는 중입니다. 전송이 완료될 때까지 이 페이지를 열어 두세요. 데스크톱에서는 브라우저를 최소화하거나 탭을 전환할 수 있습니다. 모바일에서는 브라우저를 포그라운드에 유지하세요.",
joinRoom: {
EmptyMsg: "경고, 방 ID가 비어 있습니다",
DuplicateMsg: "입력한 방 ID가 중복되었습니다. 다시 입력해주세요.",
DuplicateMsg: " 방 ID는 이미 사용 중입니다. 다른 ID를 선택해주세요.",
successMsg:
"방에 성공적으로 입장했습니다! 전송이 완료되기 전까지 현재 페이지를 닫지 마세요. (데스크톱에서는 브라우저를 최소화하거나 탭을 전환할 수 있으며, 모바일에서는 브라우저를 백그라운드로 이동하지 마세요.)",
notExist:
@@ -297,7 +318,7 @@ export const ko: Messages = {
noFilesForFolderMsg: "폴더 '{folderName}'에서 파일을 찾을 수 없습니다.",
zipError: "ZIP 파일 생성 중 오류가 발생했습니다.",
fileNotFoundMsg: "다운로드할 파일 '{fileName}'을(를) 찾을 수 없습니다.",
confirmLeaveWhileTransferring: "현재 파일이 전송 중니다. 나가면 전송이 중단됩니다. 확실합니까?",
confirmLeaveWhileTransferring: "전송단됩니다. 저장 경로 설정 시 재개 가능. 나가시겠습니까?",
leaveWhileTransferringSuccess: "방을 나갔습니다. 전송이 중단되었습니다",
html: {
senderTab: "보내기",
@@ -321,7 +342,15 @@ export const ko: Messages = {
readClipboard_dis: "방 ID 붙여넣기",
retrieveRoomId_placeholder: "방 ID 입력",
RetrieveMethodTitle: "검색 방법",
// New: cached ID utils
saveId_dis: "ID 저장",
useCachedId_dis: "저장된 ID 사용",
saveId_tips:
"현재 ID를 저장하여 다음에 빠르게 사용할 수 있어요",
useCachedId_tips: "저장된 ID 빠르게 사용;더블클릭으로 저장 모드 전환",
},
// New: cache messages
saveId_success: "캐시에 저장되었습니다",
},
home: {
h1: "무료 보안 온라인 클립보드 및 파일 전송 도구",
+31 -3
View File
@@ -42,6 +42,13 @@ export const zh: Messages = {
description:
"查看PrivyDrop使用条款,包括服务使用规范、数据隐私和安全性,以及责任限制等信息。",
},
blog: {
title: "PrivyDrop 博客 - 私密 P2P 文件分享与协作",
description:
"探索安全的文件分享方法、隐私优先的团队协作策略,以及如何利用 P2P 与 WebRTC 实现更安全的数据传输。",
keywords:
"安全文件分享,P2P文件传输,私密协作,WebRTC,端到端加密,团队协作,隐私工具",
},
},
text: {
Header: {
@@ -60,6 +67,19 @@ export const zh: Messages = {
Privacy_dis: "隐私政策",
SupportedLanguages: "支持的语言",
},
blog: {
list_title: "博客",
list_subtitle: "最新文章与更新",
recent_posts: "最新文章",
tags: "标签",
read_more: "阅读更多",
by: "作者",
post_not_found: "未找到文章",
toc_title: "目录",
tag_title_prefix: "标签",
tag_subtitle_template: "包含 {tag} 标签的文章",
tag_empty: "没有找到相关文章。",
},
privacy: {
PrivacyPolicy_dis: "隐私政策",
h1: "PrivyDrop隐私政策",
@@ -259,7 +279,7 @@ export const zh: Messages = {
"等待接收方连接。请保持此页面打开直到传输完成。在桌面端,您可以最小化浏览器或切换标签页。在移动端,请保持浏览器在前台。",
joinRoom: {
EmptyMsg: "警告,房间ID为空",
DuplicateMsg: "您输入的房间ID重复,请重新输入。",
DuplicateMsg: "该房间ID已被使用,请更换其他ID。",
successMsg:
"成功加入房间!在被接收之前不要关闭当前页(电脑端可以最小化浏览器或切换tab页,移动端不要将浏览器切到后台)。",
notExist: "您尝试加入的房间不存在。只有发送方可以创建房间。",
@@ -283,7 +303,8 @@ export const zh: Messages = {
noFilesForFolderMsg: "在文件夹 '{folderName}' 中未找到文件。",
zipError: "创建 ZIP 文件时出错。",
fileNotFoundMsg: "未找到要下载的文件 '{fileName}'。",
confirmLeaveWhileTransferring: "当前有文件正在传输,退出将中断传输。确定要退出吗?",
confirmLeaveWhileTransferring:
"传输将中断,已设置保存目录时可续传。确定退出?",
leaveWhileTransferringSuccess: "已退出房间,传输已中断",
html: {
senderTab: "发送",
@@ -296,7 +317,7 @@ export const zh: Messages = {
inputRoomIdprompt: "您的房间ID(可编辑):",
joinRoomBtn: "加入房间",
generateSimpleId_tips: "简单ID",
generateRandomId_tips: "随机ID",
generateRandomId_tips: "随机ID",
readClipboardToRoomId: "粘贴房间ID",
enterRoomID_placeholder: "输入房间ID",
retrieveMethod: "接收方式",
@@ -307,7 +328,14 @@ export const zh: Messages = {
readClipboard_dis: "粘贴房间ID",
retrieveRoomId_placeholder: "输入房间ID",
RetrieveMethodTitle: "接收方式",
// New: cached ID utils
saveId_dis: "保存ID",
useCachedId_dis: "使用缓存ID",
saveId_tips: "保存ID后,下次可以快捷使用该ID",
useCachedId_tips: "快捷使用已保存ID;双击可切换保存模式",
},
// New: cache messages
saveId_success: "缓存成功",
},
home: {
h1: "免费安全的在线剪贴板与文件传输工具",
@@ -0,0 +1,159 @@
---
title: "Ein Klick, wieder da Fels in der Brandung: CachedIDAutoJoin und robuste Wiederverbindung in PrivyDrop"
description: "Neu auf der Empfängerseite: AutoBeitritt per zwischengespeicherter ID und durchgängige Wiederverbindung für geschmeidige Abläufe automatischer Raumbeitritt, OneClickConnect, Doppelklick zum CacheUpdate und stabile Erholung in wackeligen Netzen."
date: "2025-11-25"
author: "david bai"
cover: "/blog-assets/cached-id-reconnect.webp"
tags: ["Neues Feature", "Automatische Wiederverbindung", "Gecachte ID", "WebRTC", "P2P"]
status: "published"
---
![](/blog-assets/cached-id-reconnect.webp)
## Einleitung: Warum „AutoJoin“ und „Wiederverbindung“ zählen
Neue PrivyDropNutzer stolpern häufig über zwei kleine Reibungen:
- Beim Wechsel von Senden zu Empfangen muss die RaumID erneut eingefügt werden.
- In Café‑WLANs oder im Mobilfunk erzwingt ein kurzer Haker eine manuelle Wiederverbindung.
Klein und doch im Alltag entscheidend dafür, ob sich etwas „mühelos“ anfühlt. Deshalb haben wir zwei Feinschliffe ausgeliefert, die den Fluss wirklich glattziehen:
- „CachedIDAutoJoin“ für Empfänger: Wenn die Bedingungen passen, füllen wir automatisch aus und treten sofort bei.
- Durchgängige, robuste Wiederverbindung: Fällt Socket oder P2P, erholen sich Aushandlung und Verbindung selbsttätig.
Das alles ohne unsere ArchitekturLeitplanke zu verletzen: Backend nur für Signalisierung und Räume; Dateien bleiben E2Everschlüsselt und gehen BrowserzuBrowser direkt.
---
## Funktion 1: AutoJoin mit zwischengespeicherter ID (Empfänger)
Beim Wechsel zum Empfangen füllen wir die letzte gespeicherte RaumID automatisch ein und treten sofort bei, wenn:
- Sie sich im EmpfangenTab befinden und noch keinem Raum beigetreten sind;
- die URL keinen `roomId`Parameter enthält (URL hat Vorrang kein Überschreiben);
- das Eingabefeld leer ist (kein Überschreiben der NutzerEingabe);
- eine gecachte ID in localStorage vorhanden ist.
Der Check läuft beim TabWechsel: Zuerst wird ausgefüllt, dann direkt die Beitrittslogik aufgerufen ein Einfügen/Klick weniger.
- CodeAnker:
- AutoJoin useEffect (Empfänger): https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp.tsx#L151
- CacheHelfer (localStorage): https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/roomIdCache.ts#L1
Wann greift es nicht?
- Sie sind bereits in einem Raum;
- die URL trägt explizit `roomId` (z.B. geteilter DeepLink);
- das Eingabefeld enthält bereits Text in Bearbeitung;
- keine gecachte ID gefunden.
---
## Funktion 2: „ID speichern/verwenden“ auf SenderSeite (Doppelklick zum Aktualisieren)
Auf der SenderSeite bekommt das IDFeld einen smarten „Wiederverwenden“‑Button mit zwei Zuständen:
- ID speichern: Ab Eingabelänge ≥ 8 wird der Button aktiv; Klick speichert die aktuelle Eingabe als CacheID.
- Gecachte ID verwenden: Existiert eine, schreibt ein Klick sie ins Feld und tritt sofort bei; Doppelklick schaltet ~3s auf „ID speichern“, um den Cache zu erneuern.
Implementierungsnotizen:
- Einfach/Doppelklick via 400msFenster und Timer, Cleanup beim Unmount;
- Nach „Gecachte ID verwenden“ tritt der Sender sofort dem Raum bei (kein zusätzlicher „Beitreten“‑Klick);
- IDs mit weniger als 8 Zeichen werden nicht gespeichert Schutz vor versehentlichen KurzIDs.
- CodeAnker:
- Einfach/Doppelklick inkl. TimerCleanup: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/CachedIdActionButton.tsx#L112
- Sofortiger Beitritt bei „Gecachte ID verwenden“ (Sender): https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/SendTabPanel.tsx#L193
---
## Wiederverbindung: Von der Erkennung bis zur Erholung
Wir beobachten drei Einstiegspunkte und stoßen die Wiederverbindung an:
- Socket getrennt: Nach Reconnect und geändertem `socketId` erfolgt der automatische RaumBeitritt;
- P2P getrennt/fehlgeschlagen/geschlossen: Status markieren und Verbindung neu aufbauen;
- Proaktiver `socketId`Check: Bei SocketRecovery erneut validieren.
- CodeAnker:
- AutoBeitritt nach SocketConnect: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L121
- Vereinheitlichter attemptReconnectionEinstieg: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L185
- `lastJoinedSocketId` verfolgen und bei Bedarf `initiator-online`: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L460
- Sender verarbeitet `recipient-ready` und startet Neuverhandlung: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L12
- Empfänger antwortet auf `initiator-online` mit `recipient-ready`: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Recipient.ts#L14
- BackendRelais:
- ready: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L63
- initiator-online: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L102
- recipient-ready: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L108
- peer-disconnected: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119
### Sequenz (Mermaid)
```mermaid
sequenceDiagram
participant S as Signaling Server
participant A as Sender (initiator)
participant B as Empfänger (recipient)
Note over A,B: Netzschwankungen trennen Socket/P2P
A->>A: attemptReconnection()
A->>S: join(roomId) / (ggf.) initiator-online
S-->>B: initiator-online
B->>S: recipient-ready(peerId)
S-->>A: recipient-ready(peerId)
A->>B: offer
B->>A: answer
A-->>B: ICE candidates
B-->>A: ICE candidates
Note over A,B: Verbindung wiederhergestellt, DataChannel neu aufgebaut
```
### Zuverlässigkeitsdetails
- ICEKandidatenQueue: Ist die RemoteDescription nicht bereit oder die Verbindung im Schließen, werden Kandidaten gepuffert und später geflusht; siehe https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L219-L256.
- DataChannelBackpressure & Chunking: SenderSchwelle `bufferedAmountLowThreshold=256KB` (https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L82); Netzsteuerung `maxBuffer≈3MB / lowThreshold≈512KB / 64KBChunks` (https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L66-L111, https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L160-L210).
- Mobile Wake Lock: Beim Verbinden anfordern, bei Trennung/Fehler freigeben reduziert Unterbrechungen im Hintergrund.
- Fehlerkapselung & Retries: seltene `sendData failed` werden gekapselt, angezeigt und erneut versucht (siehe `sendWithBackpressure`).
### Kurze vs. lange IDs: Wiederverwendungsstrategie
- Kurze IDs (4stellig) erhalten nach „leerem Raum + Trennung“ eine GnadenTTL von 15Min. (900s) schnelle Wiederverbindung im Fenster; siehe https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119-L125.
- StandardAblaufzeit für Räume: 24h; nur bei leerem Raum nach Trennung wird temporär auf 15Min. umgestellt; siehe https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/services/redis.ts#L6.
- Lange IDs (UUIDartig) eignen sich für Wiederverwendung über Sitzungen/Geräte hinweg am besten mit dem CacheButton kombinieren.
---
## Ausprobieren (Handson)
Schnelltest am Desktop:
1. Auf SenderSeite eine benutzerdefinierte ID (≥ 8 Zeichen) eingeben und „ID speichern“ klicken.
2. Zum Empfänger wechseln: Wenn die Bedingungen passen, wird automatisch ausgefüllt und beigetreten.
3. Ausfall simulieren (WLAN aus, Hotspot an, refresh & zurück) und die AutoWiederverbindung beobachten.
4. Auf SenderSeite „Gecachte ID verwenden“ doppelklicken, kurzzeitig auf „ID speichern“ umschalten und auf eine neue lange ID aktualisieren.
Mobil/Problemnetze:
- Hintergrund → Vordergrund; Wechsel WLAN ↔ Mobilfunk.
- Prüfen, ob der Empfänger autobeitritt und die Übertragung nahtlos fortsetzt.
---
## Schluss & Aufruf
Je geschmeidiger die Verbindung, desto größer der P2PWert. CachedIDAutoJoin und robuste Wiederverbindung machen PrivyDrop im echten Netz noch verlässlicher.
Wenn Ihnen das gefällt, freuen wir uns über einen Stern auf GitHub (<u>https://github.com/david-bai00/PrivyDrop</u>). Das hilft, entdeckt zu werden und treibt uns an, weiter zu feilen.
Jetzt online testen: <u>https://www.privydrop.app</u>. Feedback und Verbesserungsvorschläge gern als Issue helfen Sie uns, das „glatte Gefühl“ weiter auszubauen.
Zusätzlich sorgt Cloudflare CDN für Beschleunigung über Regionen hinweg schnellere, stabilere Zugriffe, weniger Ruckler.
Weiterführende Lektüre:
- [Warum ich PrivyDrop Open Source gestellt habe](/blog/privydrop-open-source)
- [Wie WebRTC BrowserDirekttransfer ermöglicht](/blog/webRTC-file-transfer)
- [Resumable Transfers: Schluss mit der GroßdateiAnxiety](/blog/resumable-transfers)
@@ -0,0 +1,159 @@
---
title: "OneClick Reconnect, RockSolid: A Deep Dive into CachedID AutoJoin and Resilient Reconnect in PrivyDrop"
description: "New on the receiver: cachedID autojoin and fullpath reconnect for smoother flows — auto room join, onetap direct connect, doubletap to update the cache, and steady recovery on flaky networks."
date: "2025-11-25"
author: "david bai"
cover: "/blog-assets/cached-id-reconnect.webp"
tags: ["New Feature", "Auto Reconnect", "Cached ID", "WebRTC", "P2P"]
status: "published"
---
![](/blog-assets/cached-id-reconnect.webp)
## Introduction: Why “AutoJoin” and “Reconnect” Matter
New users of PrivyDrop often run into two tiny frictions:
- When switching from Sender to Receiver, you have to paste the room ID again.
- On café WiFi or mobile data, a brief blip means a manual reconnect.
Tiny? Yes. Frequent in realworld networks? Absolutely. And they decide whether an app feels “effortless.” So we shipped two polishlevel upgrades that make the flow truly smooth:
- Receiver “CachedID AutoJoin”: when conditions match, we autofill and join the room for you.
- Endtoend “Resilient Reconnect”: whether Socket or P2P drops, negotiation and connection recover on their own.
Most importantly, none of this changes our redline architecture: the backend only handles signaling and room management; files are always endtoend encrypted and go directly browsertobrowser.
---
## Feature 1: Receiver CachedID AutoJoin
When you switch to the Receiver tab, if the following conditions are met, the last cached room ID will be autofilled and the app will immediately join the room:
- Youre on the Receiver tab and not already in a room;
- The URL has no explicit `roomId` param (URL wins — we dont override);
- The input is currently empty (we dont override your typing);
- A cached ID exists in localStorage.
This logic triggers on tab switch. If matched, we first fill the input, then immediately call the join routine—one less paste/click.
- Code anchors:
- Receiverside autojoin useEffect: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp.tsx#L151
- Cache helper (localStorage): https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/roomIdCache.ts#L1
When will it not trigger?
- Youre already in a room;
- The URL explicitly carries a `roomId` (e.g., a shared deep link);
- Theres already text in the input that youre editing;
- No cached ID is found.
---
## Feature 2: Sender “Save/Use Cached ID” (DoubleTap to Update)
On the Sender side, the room ID field gets a smart “Reuse” button that toggles between two states:
- Save ID: when the input length is ≥ 8, the button becomes active; clicking saves the current input as the cached ID.
- Use Cached ID: if a cached ID exists, a single tap writes it into the input and joins immediately; a doubletap flips the button to “Save ID” for about 3 seconds so you can refresh the cache.
Implementation notes:
- Single/double taps use a 400ms window with a timer thats cleaned up on unmount;
- After “Use Cached ID” is clicked, the Sender joins the room immediately (no extra “Join” click);
- We dont allow saving IDs shorter than 8 chars to avoid accidental short saves.
- Code anchors:
- Single/doubletap with timer cleanup: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/CachedIdActionButton.tsx#L112
- Autojoin immediately on “Use Cached ID” (Sender): https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/SendTabPanel.tsx#L193
---
## Reconnect: From Detection to Full Recovery
We watch for disconnects from three entry points and trigger reconnection:
- Socket disconnected: after reconnecting, if `socketId` changes, we auto rejoin the room;
- P2P disconnected/failed/closed: we flag state and attempt to rebuild the connection;
- Proactive `socketId` change check: on socket recovery, we validate once more.
- Code anchors:
- Auto rejoin after socket connects: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L121
- Unified attemptReconnection entry: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L185
- Track `lastJoinedSocketId` and trigger `initiator-online` when needed: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L460
- Sender handles `recipient-ready` and restarts negotiation: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L12
- Receiver responds to `initiator-online` with `recipient-ready`: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Recipient.ts#L14
- Backend signaling relay:
- ready: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L63
- initiator-online: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L102
- recipient-ready: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L108
- peer-disconnected: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119
### Sequence (Mermaid)
```mermaid
sequenceDiagram
participant S as Signaling Server
participant A as Sender (initiator)
participant B as Receiver (recipient)
Note over A,B: Network blips cause Socket/P2P disconnects
A->>A: attemptReconnection()
A->>S: join(roomId) / (maybe) initiator-online
S-->>B: initiator-online
B->>S: recipient-ready(peerId)
S-->>A: recipient-ready(peerId)
A->>B: offer
B->>A: answer
A-->>B: ICE candidates
B-->>A: ICE candidates
Note over A,B: Connection restored, DataChannel re-established
```
### Reliability Details
- ICE candidate queue: if the remote description isnt ready or the connection is closing/closed, candidates are queued and flushed later; see https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L219-L256.
- DataChannel backpressure & chunking: Sender threshold `bufferedAmountLowThreshold=256KB` (https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L82); network control `maxBuffer≈3MB / lowThreshold≈512KB / 64KB chunks` (https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L66-L111, https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L160-L210).
- Mobile wake lock: request Wake Lock when connected; release on disconnect/failure to reduce background interruptions.
- Error wrapping & retries: rare `sendData failed` paths are wrapped, surfaced, and retried (see `sendWithBackpressure`).
### Short vs Long IDs: Reuse Strategy
- Short IDs (4digit) get a 15minute (900s) grace TTL when a room becomes empty after a disconnect—allowing quick reconnection within the window; see https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119-L125.
- Default room expiry is 24 hours; only emptyroom disconnects switch to the temporary 15minute keepalive; see https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/services/redis.ts#L6.
- Long IDs (UUIDlike) are better for crosssession/crossdevice reuse; pair them with the cachedID button for best ergonomics.
---
## Try It (HandsOn)
Desktop quick try:
1. On the Sender, enter a custom ID with ≥ 8 characters and click “Save ID”.
2. Switch to the Receiver: if conditions match, it autofills and joins the room.
3. Simulate a dropout (turn WiFi off, switch to hotspot, refresh and return) and watch it reconnect automatically.
4. On the Sender, doubletap “Use Cached ID” to temporarily switch to “Save ID” and update to a new long ID.
Mobile/poor network scenarios:
- Background → foreground; switch WiFi ↔ cellular.
- Observe whether the Receiver autojoins, and whether transfer resumes automatically.
---
## WrapUp & Call to Action
Smoother connections amplify the value of P2P. CachedID autojoin on the receiver and resilient reconnect across the stack make PrivyDrop sturdier and more dependable in the real world.
If you find this useful, please star us on GitHub (<u>https://github.com/david-bai00/PrivyDrop</u>) so more people can discover it. Your star directly affects search and recommendation signals—and fuels our motivation to keep polishing.
Try it online: <u>https://www.privydrop.app</u>. We also welcome issues with your feedback and suggestions—help us make the “smooth experience” even smoother.
Additionally, our domain is accelerated via Cloudflare CDN (saintly cyber help), significantly improving crossregion speed and stability so more users can open the site without hiccups.
Further Reading:
- [Why I OpenSourced PrivyDrop](/blog/privydrop-open-source)
- [How WebRTC Enables BrowserDirect Transfer](/blog/webRTC-file-transfer)
- [Resumable Transfers: Say Goodbye to BigFile Anxiety](/blog/resumable-transfers)
@@ -0,0 +1,159 @@
---
title: "Un toque y de vuelta, sólido como roca: Autounión por ID en caché y reconexión resiliente en PrivyDrop"
description: "Novedad en el receptor: autoentrada mediante ID en caché y reconexión de extremo a extremo para un flujo más suave — entrada automática, conexión directa con un toque, doble toque para actualizar la caché y recuperación firme en redes inestables."
date: "2025-11-25"
author: "david bai"
cover: "/blog-assets/cached-id-reconnect.webp"
tags: ["Nueva función", "Reconexión automática", "ID en caché", "WebRTC", "P2P"]
status: "published"
---
![](/blog-assets/cached-id-reconnect.webp)
## Introducción: por qué importan el “autojoin” y la “reconexión”
Quien prueba PrivyDrop por primera vez tropieza con dos fricciones pequeñas pero frecuentes:
- Al pasar de Enviar a Recibir, hay que pegar de nuevo el ID de sala.
- En WiFi de cafetería o datos móviles, un microcorte obliga a reconectar manualmente.
Pequeñeces, sí. Pero en red real aparecen mucho y deciden si algo se siente “sin esfuerzo”. Por eso lanzamos dos mejoras de acabado que pulen el flujo hasta hacerlo realmente suave:
- “Autounión por ID en caché” en el receptor: si se cumplen las condiciones, rellenamos y entramos en la sala automáticamente.
- “Reconexion resiliente” de extremo a extremo: caiga Socket o P2P, la negociación y la conexión se recuperan solas.
Y lo más importante: no tocamos nuestra línea roja arquitectónica. El backend solo hace señalización y salas; los archivos siempre viajan de navegador a navegador con cifrado de extremo a extremo.
---
## Función 1: Autounión del receptor con ID en caché
Al cambiar a la pestaña de Receptor, si se cumplen estas condiciones, rellenamos el último ID de sala guardado y entramos al instante:
- Estás en la pestaña de Recibir y aún no estás en una sala;
- La URL no incluye `roomId` (la URL manda; no sobrescribimos);
- El campo de entrada está vacío (no pisamos lo que escribes);
- Existe un ID en caché en localStorage.
La lógica se dispara al cambiar de pestaña: primero rellenamos, luego llamamos directamente a unirse—un pegado/clic menos.
- Anclas de código:
- useEffect de autoentrada en el receptor: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp.tsx#L151
- Utilidad de caché (localStorage): https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/roomIdCache.ts#L1
¿Cuándo no se activa?
- Ya estás dentro de una sala;
- La URL trae `roomId` explícito (por ejemplo, enlace compartido con parámetro);
- El campo de entrada ya contiene texto en edición;
- No hay ID en caché.
---
## Función 2: “Guardar/Usar ID en caché” en el emisor (doble toque para actualizar)
En el lado Emisor, el campo del ID incorpora un botón inteligente de “Reusar” con dos estados:
- Guardar ID: con longitud ≥ 8, el botón se habilita; al pulsar, guarda el texto actual como ID en caché.
- Usar ID en caché: si existe, un toque lo escribe en el campo y se une de inmediato; con doble toque, el botón pasa durante ~3 s a “Guardar ID” para que puedas actualizar la caché.
Notas de implementación:
- El simple/doble toque se decide en una ventana de 400 ms con temporizador, limpiado al desmontar;
- Tras “Usar ID en caché”, el emisor entra a la sala inmediatamente (sin pulsar “Unirse”);
- No permitimos guardar IDs de menos de 8 caracteres para evitar guardados accidentales.
- Anclas de código:
- Simple/doble toque y limpieza del temporizador: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/CachedIdActionButton.tsx#L112
- Unión inmediata al “Usar ID en caché” (emisor): https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/SendTabPanel.tsx#L193
---
## Reconexión: de la detección a la recuperación completa
Observamos tres puntos para detectar cortes y disparar la reconexión:
- Socket desconectado: si al volver cambia el `socketId`, reentramos a la sala automáticamente;
- P2P desconectado/fallido/cerrado: marcamos estado e intentamos reconstruir la conexión;
- Comprobación proactiva de cambio de `socketId`: al recuperar el socket, validamos de nuevo.
- Anclas de código:
- Reentrada automática tras reconectar el socket: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L121
- Punto unificado de attemptReconnection: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L185
- Registro de `lastJoinedSocketId` y envío de `initiator-online` si procede: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L460
- El emisor recibe `recipient-ready` y reinicia la negociación: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L12
- El receptor responde a `initiator-online` con `recipient-ready`: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Recipient.ts#L14
- Relé de señalización en backend:
- ready: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L63
- initiator-online: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L102
- recipient-ready: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L108
- peer-disconnected: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119
### Secuencia (Mermaid)
```mermaid
sequenceDiagram
participant S as Signaling Server
participant A as Emisor (initiator)
participant B as Receptor (recipient)
Note over A,B: Microcortes provocan desconexión de Socket/P2P
A->>A: attemptReconnection()
A->>S: join(roomId) / (quizá) initiator-online
S-->>B: initiator-online
B->>S: recipient-ready(peerId)
S-->>A: recipient-ready(peerId)
A->>B: offer
B->>A: answer
A-->>B: ICE candidates
B-->>A: ICE candidates
Note over A,B: Conexión restaurada, DataChannel restablecido
```
### Detalles de fiabilidad
- Cola de candidatos ICE: si la descripción remota no está lista o la conexión se cierra, los candidatos se encolan y se envían después; ver https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L219-L256.
- Backpressure y troceado del DataChannel: umbral del emisor `bufferedAmountLowThreshold=256KB` (https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L82); control de red `maxBuffer≈3MB / lowThreshold≈512KB / trozos de 64KB` (https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L66-L111, https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L160-L210).
- Wake Lock móvil: se solicita al conectar y se libera al desconectar/fallar, para reducir interrupciones al pasar a segundo plano.
- Envoltorio de errores y reintentos: los raros `sendData failed` se capturan, se muestran y se reintentan (ver `sendWithBackpressure`).
### Estrategia de reutilización: IDs cortos vs largos
- IDs cortos (4 dígitos) reciben un TTL de 15 minutos (900s) cuando la sala queda vacía tras la desconexión; permite reconectar fácilmente en esa ventana; ver https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119-L125.
- La expiración por defecto de la sala es 24 horas; solo en “sala vacía + desconexión” pasamos a la retención temporal de 15 minutos; ver https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/services/redis.ts#L6.
- IDs largos (tipo UUID) son mejores para reutilización entre sesiones/dispositivos; combínalos con el botón de caché para la mejor ergonomía.
---
## Cómo probarlo (handson)
Prueba rápida en escritorio:
1. En el Emisor, escribe un ID personalizado de ≥ 8 caracteres y pulsa “Guardar ID”.
2. Cambia al Receptor: si se cumplen condiciones, se rellenará y entrará automáticamente.
3. Simula un corte (apaga el WiFi, cambia a hotspot, recarga y vuelve) y observa la reconexión automática.
4. En el Emisor, haz doble toque en “Usar ID en caché” para cambiar temporalmente a “Guardar ID” y actualizar a un ID largo nuevo.
Móvil/redes pobres:
- Segundo plano → primer plano; cambia entre WiFi ↔ datos.
- Observa si el receptor se autoune y si la transferencia se reanuda sola.
---
## Cierre y llamada a la acción
Cuanto más suave es la conexión, más crece el valor del P2P. El autojoin por ID en caché y la reconexión resiliente hacen que PrivyDrop sea más robusto y confiable en redes reales.
Si te resulta útil, déjanos una estrella en GitHub (<u>https://github.com/david-bai00/PrivyDrop</u>) para que más personas nos encuentren. Tu estrella impacta en búsqueda y recomendaciones—y alimenta nuestras ganas de seguir puliendo.
Pruébalo online: <u>https://www.privydrop.app</u>. También te invitamos a abrir issues con comentarios y sugerencias para seguir afinando esa “experiencia sin fricción”.
Además, el dominio está acelerado con Cloudflare CDN, mejorando notablemente velocidad y estabilidad entre regiones para una apertura sin tirones.
Lecturas recomendadas:
- [Por qué hice PrivyDrop de código abierto](/blog/privydrop-open-source)
- [Cómo WebRTC permite la transferencia directa entre navegadores](/blog/webRTC-file-transfer)
- [Transferencias reanudables: adiós a la ansiedad por los archivos grandes](/blog/resumable-transfers)
@@ -0,0 +1,158 @@
---
title: "Un clic et ça repart, solide comme un roc : Autojoin via ID en cache et reconnexion résiliente dans PrivyDrop"
description: "Nouveauté côté réception : autoentrée grâce à lID en cache et reconnexion de bout en bout pour un flux plus fluide — entrée automatique, connexion directe en un clic, doubleclic pour mettre à jour le cache, et reprise fiable sur réseau instable."
date: "2025-11-25"
author: "david bai"
cover: "/blog-assets/cached-id-reconnect.webp"
tags: ["Nouvelle fonctionnalité", "Reconnexion automatique", "ID en cache", "WebRTC", "P2P"]
status: "published"
---
![](/blog-assets/cached-id-reconnect.webp)
## Introduction : pourquoi « autojoin » et « reconnexion »
Les nouveaux utilisateurs de PrivyDrop rencontrent souvent deux petites frictions :
- En passant dEnvoyer à Recevoir, il faut recoller lID de salle ;
- Sur un WiFi de café ou en 4G, une microcoupure impose une reconnexion manuelle.
Des détails ? Oui. Mais très fréquents dans le monde réel — ils font la différence entre « ça marche » et « cest fluide ». Nous avons donc livré deux finitions qui rendent lexpérience vraiment soyeuse :
- « Autojoin via ID en cache » côté récepteur : si les conditions sont réunies, on pré‑remplit et on rejoint la salle automatiquement ;
- « Reconnexion résiliente » de bout en bout : que Socket ou P2P tombe, la négociation et la connexion se rétablissent seules.
Le tout sans toucher à notre ligne rouge architecturale : le backend ne fait que la signalisation et la gestion de salle ; les fichiers restent chiffrés de bout en bout, directement de navigateur à navigateur.
---
## Fonction 1 : Autojoin du récepteur avec ID en cache
Lorsque vous passez à longlet Récepteur, si les conditions suivantes sont réunies, le dernier ID de salle en cache est pré‑rempli et lentrée est immédiate :
- Vous êtes sur longlet Récepteur et pas encore dans une salle ;
- LURL ne contient pas `roomId` (lURL lemporte — pas d’écrasement) ;
- Le champ de saisie est vide (on ne remplace pas votre saisie) ;
- Un ID en cache existe dans le localStorage.
La logique se déclenche au changement donglet. Si cest bon, on remplit dabord, puis on appelle aussitôt la routine dentrée — un collage/clic de moins.
- Repères de code :
- useEffect dautoentrée côté récepteur : https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp.tsx#L151
- Utilitaire de cache (localStorage) : https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/roomIdCache.ts#L1
Quand cela ne sappliquetil pas ?
- Vous êtes déjà dans une salle ;
- LURL porte explicitement `roomId` (lien de partage avec paramètre) ;
- Le champ contient déjà un texte en cours de saisie ;
- Aucun ID en cache nest trouvé.
---
## Fonction 2 : « Enregistrer / Utiliser lID en cache » côté émetteur (doubleclic pour mettre à jour)
Sur l’émetteur, le champ dID accueille un bouton « Réutiliser » astucieux qui alterne entre deux états :
- Enregistrer lID : quand la longueur ≥ 8, le bouton sactive ; un clic enregistre la saisie courante comme ID en cache.
- Utiliser lID en cache : sil existe, un clic linsère et rejoint la salle immédiatement ; un doubleclic bascule ~3 s en « Enregistrer lID » pour actualiser le cache.
Notes dimplémentation :
- Simple/doubleclic via une fenêtre de 400 ms, timer nettoyé au démontage ;
- Après « Utiliser lID en cache », l’émetteur rejoint la salle immédiatement (pas de clic « Rejoindre » supplémentaire) ;
- Pas denregistrement dID de moins de 8 caractères pour éviter les « courts » accidentels.
- Repères de code :
- Simple/doubleclic et nettoyage du timer : https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/CachedIdActionButton.tsx#L112
- Rejoindre immédiatement après « Utiliser lID en cache » (émetteur) : https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/SendTabPanel.tsx#L193
---
## Reconnexion : de la détection au rétablissement complet
Nous surveillons trois points dentrée et déclenchons la reconnexion :
- Socket déconnecté : après reconnexion, si le `socketId` change, on ré‑entre automatiquement ;
- P2P déconnecté/échec/fermé : on marque l’état et on tente de reconstruire la connexion ;
- Vérification proactive de changement de `socketId` : à la reprise du socket, on revalide.
- Repères de code :
- Ré‑entrée auto après connexion du socket : https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L121
- Point dentrée unifié attemptReconnection : https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L185
- Suivi de `lastJoinedSocketId` et émission de `initiator-online` si nécessaire : https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L460
- Côté émetteur, réception de `recipient-ready` et reprise de la négociation : https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L12
- Côté récepteur, réponse `recipient-ready` à `initiator-online` : https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Recipient.ts#L14
- Relais côté backend :
- ready : https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L63
- initiator-online : https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L102
- recipient-ready : https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L108
- peer-disconnected : https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119
### Séquence (Mermaid)
```mermaid
sequenceDiagram
participant S as Signaling Server
participant A as Émetteur (initiator)
participant B as Récepteur (recipient)
Note over A,B: Les aléas réseau coupent Socket/P2P
A->>A: attemptReconnection()
A->>S: join(roomId) / (évent.) initiator-online
S-->>B: initiator-online
B->>S: recipient-ready(peerId)
S-->>A: recipient-ready(peerId)
A->>B: offer
B->>A: answer
A-->>B: ICE candidates
B-->>A: ICE candidates
Note over A,B: Connexion restaurée, DataChannel ré‑établi
```
### Détails de fiabilité
- File dattente des candidats ICE : si la description distante nest pas prête ou que la connexion se ferme, on met en file et on rejoue plus tard ; voir https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L219-L256.
- Rétropression et découpage DataChannel : seuil émetteur `bufferedAmountLowThreshold=256KB` (https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L82) ; contrôle réseau `maxBuffer≈3MB / lowThreshold≈512KB / chunks de 64KB` (https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L66-L111, https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L160-L210).
- Wake Lock mobile : demande à l’établissement de la connexion, libération à la déconnexion/échec — pour réduire les interruptions en arrièreplan.
- Encapsulation derreurs et retries : les rares `sendData failed` sont capturés, surfacés et réessayés (voir `sendWithBackpressure`).
### Stratégie de réutilisation : IDs courts vs longs
- IDs courts (4 chiffres) : en cas de « salle vide + déconnexion », TTL de grâce de 15 minutes (900s) — reconnexion rapide dans la fenêtre ; voir https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119-L125.
- Expiration par défaut : 24 h ; seul le cas « salle vide + déconnexion » passe en conservation temporaire de 15 min ; voir https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/services/redis.ts#L6.
- IDs longs (type UUID) : mieux pour la réutilisation intersessions/appareils ; les combiner avec le bouton dID en cache offre la meilleure ergonomie.
---
## Prise en main (handson)
Essai rapide sur desktop :
1. Côté Émetteur, entrez un ID personnalisé (≥ 8 caractères) et cliquez « Enregistrer lID ».
2. Passez au Récepteur : si les conditions sont réunies, autoremplissage et entrée immédiate.
3. Simulez une coupure (coupure WiFi, bascule hotspot, actualiser puis revenir) et observez la reconnexion automatique.
4. Côté Émetteur, doublecliquez « Utiliser lID en cache » pour basculer brièvement en « Enregistrer lID » et mettre à jour vers un nouvel ID long.
Mobile / réseaux difficiles :
- Arrièreplan → premier plan ; bascule WiFi ↔ cellulaire.
- Vérifiez lautoentrée du Récepteur et la reprise automatique du transfert.
---
## Conclusion & appel à laction
Plus la connexion est fluide, plus la valeur du P2P grandit. Lautojoin via ID en cache et la reconnexion résiliente renforcent la robustesse de PrivyDrop dans les réseaux réels.
Si vous aimez, metteznous une étoile sur GitHub (<u>https://github.com/david-bai00/PrivyDrop</u>) — cela accroît la visibilité et nourrit notre envie de peaufiner.
Essai en ligne : <u>https://www.privydrop.app</u>. Vos retours et idées sont bienvenus via les issues : continuons ensemble à polir « lexpérience soyeuse ».
Par ailleurs, notre domaine bénéficie de laccélération Cloudflare CDN, améliorant nettement vitesse et stabilité interrégions.
Pour aller plus loin :
- [Pourquoi jai opensourcé PrivyDrop](/blog/privydrop-open-source)
- [Comment WebRTC permet le transfert direct entre navigateurs](/blog/webRTC-file-transfer)
- [Transferts reprenables : adieu à lanxiété des gros fichiers](/blog/resumable-transfers)
@@ -0,0 +1,159 @@
---
title: "ワンタップで復帰、盤石の安定性:PrivyDrop のキャッシュID自動参加と切断時の再接続を徹底解説"
description: "受信側のキャッシュID自動入室と全経路の自動再接続で、体験はより滑らかに。自動入室、ワンタップ直結、ダブルタップでキャッシュ更新、そして不安定なネットでも粘り強く復帰します。"
date: "2025-11-25"
author: "david bai"
cover: "/blog-assets/cached-id-reconnect.webp"
tags: ["新機能", "自動再接続", "キャッシュID", "WebRTC", "P2P"]
status: "published"
---
![](/blog-assets/cached-id-reconnect.webp)
## はじめに:なぜ「自動入室」と「再接続」なのか
PrivyDrop を初めて使うと、よくある小さな引っかかりが二つあります。
- 送信から受信に切り替えるたび、部屋の ID をもう一度貼り付ける。
- カフェの Wi‑Fi やモバイル回線で一瞬切れると、手動でつなぎ直す。
小さなこと。でも現実のネットワークでは頻出で、使い心地を左右します。そこで私たちは、体験を“するり”と滑らかにする二つの磨き込みを加えました。
- 受信側「キャッシュIDの自動入室」:条件を満たせば、自動で入力&即入室。
-, エンドツーエンドの「粘り強い再接続」:Socket / P2P のどちらが落ちても、自動で再ネゴシエーション&復旧。
そして大切なのは、アーキテクチャのレッドラインは不変であること。バックエンドは信令とルーム管理のみ、ファイルは常に E2E 暗号化でブラウザ間を直送します。
---
## 機能1:受信側のキャッシュID自動入室
受信タブへ切り替えた際、以下の条件を満たすと、最後に保存した部屋 ID を自動入力し、すぐ入室します。
- 受信タブにいて、まだ入室していない;
- URL に `roomId` パラメータがない(URL が優先、上書きしない);
- 入力欄が空(ユーザーの入力は上書きしない);
- localStorage にキャッシュ ID が存在する。
この判定はタブ切り替え時に走ります。条件一致なら、入力欄を埋めてからそのまま入室ロジックを呼び出し、貼り付け/クリックを 1 回減らします。
- コード参照:
- 受信側の自動入室 useEffect: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp.tsx#L151
- キャッシュユーティリティ(localStorage: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/roomIdCache.ts#L1
発動しないとき:
- すでに入室している;
- URL が明示的に `roomId` を持つ(共有リンクなど);
- 入力欄に既に文字があり編集中;
- キャッシュ ID が存在しない。
---
## 機能2:送信側の「保存/使用」ボタン(ダブルタップで更新)
送信側の部屋 ID 入力欄に、賢い「再利用」ボタンを追加しました。状態は 2 つに切り替わります。
- ID を保存:入力長が 8 文字以上で有効化。クリックで現在の入力をキャッシュ ID として保存。
- キャッシュ ID を使用:キャッシュがあれば、ワンタップで入力欄に反映してそのまま入室。ダブルタップすると約 3 秒だけ「ID を保存」に切り替わり、キャッシュを更新できます。
実装メモ:
- シングル/ダブルタップは 400ms の判定窓+タイマーで実現し、アンマウント時にクリーンアップ;
- 「キャッシュ ID を使用」後は送信側が即入室(追加の「入室」操作は不要);
- 8 文字未満は保存不可にして、短い ID の誤保存を防止。
- コード参照:
- シングル/ダブルタップとタイマーのクリーンアップ: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/CachedIdActionButton.tsx#L112
- 「キャッシュ ID を使用」で即入室(送信側): https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/SendTabPanel.tsx#L193
---
## 再接続:検知から復旧までの流れ
私たちは 3 つの入口から「切断」を監視し、再接続を走らせます。
- Socket 切断:再接続後に `socketId` が変わっていれば自動再入室;
- P2P 切断/失敗/クローズ:状態をマーキングし、接続再構築を試行;
- `socketId` の変化を能動チェック:Socket 復旧時に再確認。
- コード参照:
- Socket 接続後の自動再入室: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L121
- 再接続の統一エントリ: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L185
- `lastJoinedSocketId` の記録と必要時の `initiator-online` 送出: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L460
- 送信側の `recipient-ready` 受信と再ネゴシエーション開始: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L12
- 受信側の `initiator-online` 受信と `recipient-ready` 応答: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Recipient.ts#L14
- バックエンドの信令リレー:
- ready: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L63
- initiator-online: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L102
- recipient-ready: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L108
- peer-disconnected: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119
### シーケンス(Mermaid
```mermaid
sequenceDiagram
participant S as Signaling Server
participant A as 送信側(initiator)
participant B as 受信側(recipient)
Note over A,B: ネットの揺らぎで Socket/P2P が切断
A->>A: attemptReconnection()
A->>S: join(roomId) / (場合により)initiator-online
S-->>B: initiator-online
B->>S: recipient-ready(peerId)
S-->>A: recipient-ready(peerId)
A->>B: offer
B->>A: answer
A-->>B: ICE candidates
B-->>A: ICE candidates
Note over A,B: 接続回復、DataChannel 再確立
```
### 信頼性ディテール
- ICE 候補キュー:リモート記述が未確立、または接続がクローズ系なら候補をキューイングし、後でまとめて反映;https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L219-L256。
- DataChannel の背圧と分割送信:送信側しきい値 `bufferedAmountLowThreshold=256KB`https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L82);ネットワーク制御は `maxBuffer≈3MB / lowThreshold≈512KB / 64KB チャンク`https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L66-L111, https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L160-L210)。
- モバイルの Wake Lock:接続時に取得、切断/失敗で解放。バックグラウンド遷移による中断を低減。
- エラー包みとリトライ:まれな `sendData failed` を捕捉し、表面化&再試行(`sendWithBackpressure` を参照)。
### 短い ID と長い ID の使い分け
- 短い ID(4 桁)は「空室で切断」時、バックエンドが 15 分(900s)の TTL に更新。猶予内は再接続が容易;https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119-L125。
- 既定の部屋期限は 24 時間。空室切断のときのみ 15 分の一時保持に切替;https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/services/redis.ts#L6。
- 長い ID(UUID 相当)はセッション横断・デバイス横断の再利用に向く。キャッシュ ID ボタンと組み合わせると最良。
---
## 触ってみる(クイックスタート)
デスクトップでの手早い体験:
1. 送信側で 8 文字以上の任意 ID を入力し、「ID を保存」をクリック。
2. 受信側へ切り替え:条件を満たせば自動入力&即入室。
3. 切断を再現(Wi‑Fi を切る、テザリングへ切替、リロード→戻る)して、自動復帰を観察。
4. 送信側で「キャッシュ ID を使用」をダブルタップし、一時的に「ID を保存」に切替→新しい長い ID へ更新。
モバイル/弱い回線の場面:
- バックグラウンド→フォアグラウンド、Wi‑Fi とセルラーの切替。
- 受信側の自動入室や、転送の自動再開を確認。
---
## 結びとお願い
“するり”とつながるほど、P2P の価値は増幅します。受信側のキャッシュ ID 自動入室と、スタック全体の再接続により、PrivyDrop は現実のネット環境でいっそう頑丈で頼れる存在になりました。
もし気に入っていただけたら、ぜひ GitHub で Star をお願いします(<u>https://github.com/david-bai00/PrivyDrop</u>)。見つけてもらいやすくなるだけでなく、私たちの磨き込みの原動力にもなります。
オンライン体験:<u>https://www.privydrop.app</u>。Issue から体験フィードバックや改善提案も歓迎します。“なめらかな体験”を、さらに厚くしていきましょう。
なお、ドメインは Cloudflare CDN による加速を有効化。地域間の速度と安定性が向上し、より多くのユーザーがストレスなくアクセスできます。
関連記事:
- [なぜ PrivyDrop をオープンソース化したのか](/blog/privydrop-open-source)
- [WebRTC はどうやってブラウザ直送を実現するのか](/blog/webRTC-file-transfer)
- [レジューム転送:大容量でも焦らない](/blog/resumable-transfers)
@@ -0,0 +1,159 @@
---
title: "원탭 재연결, 바위처럼 단단하게: PrivyDrop의 캐시 ID 자동 입장과 탄탄한 재연결 완전 해부"
description: "받는 쪽 캐시 ID 자동 입장과 전 구간 재연결로 흐름이 더 매끄럽게—자동 입장, 한 번 탭으로 즉시 연결, 두 번 탭으로 캐시 갱신, 불안정한 네트워크에서도 끈질긴 복구."
date: "2025-11-25"
author: "david bai"
cover: "/blog-assets/cached-id-reconnect.webp"
tags: ["신기능", "자동 재연결", "캐시된 ID", "WebRTC", "P2P"]
status: "published"
---
![](/blog-assets/cached-id-reconnect.webp)
## 소개: 왜 “자동 입장”과 “재연결”인가
PrivyDrop을 처음 쓰면 자주 마주치는 두 가지 작은 마찰이 있습니다.
- 발신 → 수신으로 바꿀 때마다 방 ID를 다시 붙여넣어야 함
- 카페 Wi‑Fi나 모바일 네트워크에서 잠깐 끊기면 직접 다시 연결해야 함
작지만, 현실 네트워크에서는 자주 일어납니다. 그리고 “손맛”을 좌우합니다. 그래서 흐름을 정말 부드럽게 만드는 두 가지 개선을 넣었습니다.
- 수신 측 “캐시 ID 자동 입장”: 조건이 맞으면 자동으로 입력하고 곧바로 입장
- 전체 경로 “탄탄한 재연결”: Socket/P2P 어느 쪽이 끊겨도 스스로 재협상/복구
무엇보다 우리의 아키텍처 레드라인은 그대로입니다. 백엔드는 신호와 방 관리만 담당하고, 파일은 E2E로 암호화된 상태로 브라우저끼리 직접 전송됩니다.
---
## 기능 1: 수신 측 캐시 ID 자동 입장
수신 탭으로 전환했을 때 아래 조건을 만족하면, 마지막으로 저장된 방 ID를 자동으로 채우고 즉시 입장합니다.
- 현재 수신 탭이고 아직 방에 들어가지 않았음
- URL에 `roomId` 파라미터가 없음(주소가 우선, 덮어쓰지 않음)
- 입력 칸이 비어 있음(사용자 입력을 덮어쓰지 않음)
- localStorage에 캐시 ID가 존재함
이 로직은 탭 전환 때 트리거됩니다. 조건이 맞으면 입력 칸을 채운 뒤 바로 입장 로직을 호출하여, 붙여넣기/클릭을 한 번 줄입니다.
- 코드 앵커:
- 수신 측 자동 입장 useEffect: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp.tsx#L151
- 캐시 유틸(localStorage): https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/roomIdCache.ts#L1
다음 상황에서는 동작하지 않습니다.
- 이미 방에 들어가 있음
- URL에 `roomId`가 명시됨(공유 링크 등)
- 입력 칸에 이미 텍스트가 있고 편집 중임
- 캐시 ID가 없음
---
## 기능 2: 발신 측 “저장/사용” 버튼(더블 탭으로 업데이트)
발신 측 방 ID 입력란에 똑똑한 “재사용” 버튼을 추가했습니다. 두 가지 상태를 오갑니다.
- ID 저장: 입력 길이가 8자 이상이면 활성화. 클릭 시 현재 입력을 캐시 ID로 저장
- 캐시 ID 사용: 캐시가 있으면 한 번 탭으로 입력란에 채우고 곧바로 입장. 두 번 탭하면 약 3초간 “ID 저장”으로 잠시 전환되어 캐시를 업데이트할 수 있음
구현 노트:
- 단/복 탭은 400ms 윈도우로 판별하며, 컴포넌트 언마운트 시 타이머를 정리
- “캐시 ID 사용” 후에는 발신 측이 즉시 입장(추가 “입장” 클릭 불필요)
- 8자 미만은 저장 불가로 하여, 짧은 ID 오저장을 방지
- 코드 앵커:
- 단/복 탭과 타이머 정리: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/CachedIdActionButton.tsx#L112
- “캐시 ID 사용” 시 즉시 입장(발신 측): https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/SendTabPanel.tsx#L193
---
## 재연결: 감지부터 완전 복구까지
세 가지 지점에서 끊김을 감지하고 재연결을 시도합니다.
- Socket 끊김: 재연결 후 `socketId`가 바뀌면 자동 재입장
- P2P 끊김/실패/종료: 상태를 표시하고 연결 재구성을 시도
- `socketId` 변경의 능동 확인: 소켓 복구 시 한 번 더 검증
- 코드 앵커:
- 소켓 연결 후 자동 재입장: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L121
- attemptReconnection 통합 진입점: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L185
- `lastJoinedSocketId` 기록 및 필요 시 `initiator-online` 트리거: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L460
- 발신 측의 `recipient-ready` 처리 및 재협상 시작: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L12
- 수신 측의 `initiator-online` 응답(`recipient-ready` 전송): https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Recipient.ts#L14
- 백엔드 신호 릴레이:
- ready: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L63
- initiator-online: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L102
- recipient-ready: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L108
- peer-disconnected: https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119
### 시퀀스(Mermaid)
```mermaid
sequenceDiagram
participant S as Signaling Server
participant A as 발신자(initiator)
participant B as 수신자(recipient)
Note over A,B: 네트워크 흔들림으로 Socket/P2P 끊김
A->>A: attemptReconnection()
A->>S: join(roomId) / (필요 시) initiator-online
S-->>B: initiator-online
B->>S: recipient-ready(peerId)
S-->>A: recipient-ready(peerId)
A->>B: offer
B->>A: answer
A-->>B: ICE candidates
B-->>A: ICE candidates
Note over A,B: 연결 복구, DataChannel 재수립
```
### 신뢰성 디테일
- ICE 후보 큐: 원격 설명이 준비되지 않았거나 연결이 종료 단계면 후보를 큐에 담아 두었다가 나중에 한 번에 반영; https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L219-L256.
- DataChannel 배압과 청킹: 발신 측 임계치 `bufferedAmountLowThreshold=256KB` (https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L82); 네트워크 제어 `maxBuffer≈3MB / lowThreshold≈512KB / 64KB 청크` (https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L66-L111, https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L160-L210).
- 모바일 Wake Lock: 연결 시 획득, 끊김/실패 시 해제 — 백그라운드 전환으로 인한 중단을 줄임.
- 에러 래핑과 재시도: 드물게 발생하는 `sendData failed` 경로를 포착/표면화/재시도(`sendWithBackpressure` 참고).
### 짧은 ID와 긴 ID 재사용 전략
- 짧은 ID(4자리)는 “빈 방 + 끊김” 시 백엔드가 TTL을 15분(900s)으로 갱신 — 그 창에서 빠르게 재연결 가능; https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119-L125.
- 기본 방 만료는 24시간; “빈 방 + 끊김”에 한해 일시적으로 15분 보존으로 전환; https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/services/redis.ts#L6.
- 긴 ID(UUID 유사)는 세션/디바이스를 넘어 재사용에 유리 — 캐시 ID 버튼과 함께 쓰면 가장 편함.
---
## 바로 써보기 (Handson)
데스크톱 빠른 체험:
1. 발신 측에서 8자 이상 사용자 지정 ID를 입력하고 “ID 저장” 클릭
2. 수신 측으로 전환: 조건이 맞으면 자동 채움 후 즉시 입장
3. 끊김 시나리오 시뮬레이션(Wi‑Fi 끄기, 핫스팟 전환, 새로고침 후 복귀) → 자동 재연결 관찰
4. 발신 측 “캐시 ID 사용” 더블 탭 → 잠시 “ID 저장”으로 전환 → 새 긴 ID로 갱신
모바일/열악한 네트워크:
- 백그라운드 ↔ 포그라운드 전환, Wi‑Fi ↔ 셀룰러 전환
- 수신 측 자동 입장과 전송 자동 복구 동작 확인
---
## 맺음말 & 액션
연결이 매끄러울수록 P2P의 가치는 커집니다. 캐시 ID 자동 입장과 전 구간 재연결로, PrivyDrop은 현실 네트워크에서 더 튼튼하고 믿을 만해졌습니다.
유용했다면 GitHub 별을 부탁드립니다(<u>https://github.com/david-bai00/PrivyDrop</u>). 더 많은 사람이 발견할 수 있고, 저희가 계속 다듬어 가는 동력이 됩니다.
온라인 체험: <u>https://www.privydrop.app</u>. Issue로 사용 소감과 제안을 남겨 주세요. “부드러운 경험”을 더 두텁게 만들어가겠습니다.
덧붙여, 도메인은 Cloudflare CDN 가속을 사용합니다. 지역 간 속도와 안정성이 크게 향상되어, 더 많은 지역에서 끊김 없이 접속할 수 있습니다.
더 읽기:
- [내가 PrivyDrop을 오픈 소스로 공개한 이유](/blog/privydrop-open-source)
- [WebRTC가 브라우저 직결 전송을 구현하는 방법](/blog/webRTC-file-transfer)
- [중단 후 재개 전송: 대용량 전송의 불안을 넘어](/blog/resumable-transfers)
@@ -0,0 +1,158 @@
---
title: "一键复连,稳如磐石:PrivyDrop 接收端缓存ID自动连接与断线重连全解析"
description: "新增接收端缓存ID自动连接与断线重连,让连接更顺滑:自动入房、单击直连、双击更新缓存、弱网稳复连。"
date: "2025-11-25"
author: "david bai"
cover: "/blog-assets/cached-id-reconnect.webp"
tags: [新功能, 断线重连, 缓存ID, WebRTC, P2P]
status: "published"
---
![](/blog-assets/cached-id-reconnect.webp)
## 引言:为什么我们要做“自动入房”和“断线重连”
很多第一次使用 PrivyDrop 的用户,都会经历这样两件“小事”:
- 从发送端切到接收端时,需要再粘贴一次房间 ID;
- 在咖啡店 Wi‑Fi 或移动网络下,短暂断网就要手动重连。
这两件“小事”,在真实世界里却非常高频,也直接决定了“顺不顺手”。为此,我们上线了两项把体验打磨到“顺滑”的改进:
- 接收端“缓存 ID 自动连接”:满足条件时,自动填充并直接入房;
- 全链路“断线重连”:Socket/P2P 任一断开,均自动恢复协商与连接。
更重要的是,这些改进不改变我们的架构红线:后端只做信令与房间管理,文件数据始终端到端加密,浏览器之间直传。
---
## 功能一:接收端缓存 ID 自动连接
当你切换到“接收”面板,如果满足以下条件,将自动填充上次保存的房间 ID 并直接入房:
- 当前处于接收面板,且尚未在房间内;
- URL 未携带 `roomId` 参数(URL 优先,不做覆盖);
- 输入框当前为空(不覆盖用户已有输入);
- 本地存在缓存 IDlocalStorage)。
上述逻辑在切换面板时触发,一旦命中,会先填充输入框,再立即调用加入逻辑,减少一次粘贴/点击。
- 代码锚点:
- 前端自动入房 useEffect(接收端):[frontend/components/ClipboardApp.tsx#L151](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp.tsx#L151)
- 缓存工具(localStorage):[frontend/lib/roomIdCache.ts#L1](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/roomIdCache.ts#L1)
何时不会触发?
- 你已在房间中;
- URL 显式携带了 `roomId`(例如分享链接带参直达);
- 输入框里已有你正在编辑的 ID
- 本地没有缓存 ID。
---
## 功能二:发送端“保存/使用缓存 ID”(支持双击更新)
发送端的房间 ID 输入区新增了一个“复用按钮”,在两种状态间智能切换:
- 保存 ID:当输入长度 ≥ 8 位时按钮可用,点击后将当前输入保存为缓存 ID;
- 使用缓存 ID:若存在缓存 ID,单击即将其写入输入框并立刻入房;双击会“短暂切换”为“保存 ID”,便于你替换更新缓存(约 3 秒后恢复)。
实现要点:
- 单/双击通过 400ms 窗口配合计时器实现,并在组件卸载时清理;
- “使用缓存 ID”单击后,发送端会立即加入房间(无需再点“加入”);
- 输入长度不足 8 位时不会允许保存,避免误存短 ID。
- 代码锚点:
- 单/双击与计时器清理:[frontend/components/ClipboardApp/CachedIdActionButton.tsx#L112](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/CachedIdActionButton.tsx#L112)
- 使用缓存 ID 后立刻直连(发送端):[frontend/components/ClipboardApp/SendTabPanel.tsx#L193](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/components/ClipboardApp/SendTabPanel.tsx#L193)
---
## 断线重连:从检测到恢复的全链路
我们从三个入口观测“断开”并触发重连:
- Socket 断开:重连后若 `socketId` 变化,将自动重新入房;
- P2P 断开/失败/关闭:标记状态并尝试重建连接;
- 主动判断 `socketId` 变化:在 socket 连接恢复时复核一次。
- 代码锚点:
- Socket 连接后自动重入房:[frontend/lib/webrtc_base.ts#L121](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L121)
- 尝试重连的统一入口:[frontend/lib/webrtc_base.ts#L185](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L185)
- 记录 `lastJoinedSocketId` 并在需要时触发 `initiator-online`[frontend/lib/webrtc_base.ts#L460](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L460)
- 发送端接收 `recipient-ready` 并重启协商:[frontend/lib/webrtc_Initiator.ts#L12](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L12)
- 接收端响应 `initiator-online` 并发送 `recipient-ready`[frontend/lib/webrtc_Recipient.ts#L14](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Recipient.ts#L14)
- 后端信令转发:
- ready[backend/src/socket/handlers.ts#L63](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L63)
- initiator-online[backend/src/socket/handlers.ts#L102](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L102)
- recipient-ready[backend/src/socket/handlers.ts#L108](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L108)
- peer-disconnected[backend/src/socket/handlers.ts#L119](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119)
### 时序(Mermaid
```mermaid
sequenceDiagram
participant S as Signaling Server
participant A as 发送端(initiator)
participant B as 接收端(recipient)
Note over A,B: 网络波动导致 Socket/P2P 断开
A->>A: attemptReconnection()
A->>S: join(roomId) / (可能) initiator-online
S-->>B: initiator-online
B->>S: recipient-ready(peerId)
S-->>A: recipient-ready(peerId)
A->>B: offer
B->>A: answer
A-->>B: ICE candidates
B-->>A: ICE candidates
Note over A,B: 连接恢复,DataChannel 重建
```
### 可靠性细节
- ICE 候选队列:若远端描述尚未建立或连接处于关闭态,候选会入队,待可用时批量补交;见 [frontend/lib/webrtc_base.ts#L219-L256](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_base.ts#L219-L256)。
- DataChannel 背压与分片:发送端阈值 `bufferedAmountLowThreshold=256KB`[frontend/lib/webrtc_Initiator.ts#L82](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/webrtc_Initiator.ts#L82));网络发送控制 `maxBuffer≈3MB / lowThreshold≈512KB / 64KB 分片`[frontend/lib/transfer/NetworkTransmitter.ts#L66-L111](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L66-L111)、[frontend/lib/transfer/NetworkTransmitter.ts#L160-L210](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/frontend/lib/transfer/NetworkTransmitter.ts#L160-L210))。
- 移动端唤醒锁:连接建立时申请 Wake Lock,断开/失败时释放,降低切后台导致的意外中断;
- 错误兜底与重试:小概率 `sendData failed` 会被包装、上报与重试(详见 `sendWithBackpressure` 相关逻辑)。
### 短 ID 与长 ID 的复用策略
- 短 ID(4 位)在“空房断开”后,会由后端将房间 TTL 刷新为 15 分钟(900s),窗口期内可直接重连,超时回收;见 [backend/src/socket/handlers.ts#L119-L125](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/socket/handlers.ts#L119-L125)。
- 默认房间过期时间为 24 小时,仅在空房断开发生临时 15 分钟保留;见 [backend/src/services/redis.ts#L6](https://github.com/david-bai00/PrivyDrop/blob/v1.1.1/backend/src/services/redis.ts#L6)。
- 长 ID(如 UUID 级别长度)更适合跨会话、跨设备的持续复用;与缓存 ID 按钮配合使用体验最佳。
---
## 如何体验(上手指南)
桌面端快速体验:
1. 在发送端输入一个 ≥8 位的自定义 ID,点击“保存 ID”;
2. 切换到接收端:若满足条件,将自动填充并直接入房;
3. 模拟断线(如:关闭 Wi‑Fi、切到手机热点、刷新页面再返回),观察自动重连;
4. 在发送端双击“使用缓存 ID”,短暂切换为“保存 ID”,更新为新的长 ID。
移动端/弱网场景:
- 切后台 → 回前台;Wi‑Fi ↔ 蜂窝之间切换;
- 关注接收端是否自动入房、传输是否自动恢复。
---
## 结语与行动号召
我们相信,越“顺手”的连接,越能放大 P2P 的价值。接收端缓存 ID 自动连接与断线重连,让 PrivyDrop 在真实网络环境下更加稳健、可依赖。
如果觉得好用,请到 GitHub 给我们一个 Star<u>https://github.com/david-bai00/PrivyDrop</u>),便于更多人发现与受益;你的 Star 也会直接影响搜索与推荐权重,是我们持续打磨产品的动力。
在线体验:<u>https://www.privydrop.app</u>。也欢迎在 Issue 中反馈你的使用体验与改进建议,让我们把“顺滑体验”继续做厚。
另外,域名已启用 Cloudflare CDN 加速(赛博菩萨),显著提升跨区域的访问速度与稳定性,让更多地区用户打开网站不再卡顿,整体体验更流畅。
延伸阅读:
- [我为什么开源了 PrivyDrop](/blog/privydrop-open-source)
- [WebRTC 如何实现浏览器直传](/blog/webRTC-file-transfer)
- [断点续传:让大文件传输告别焦虑](/blog/resumable-transfers)
@@ -0,0 +1,159 @@
---
title: "Warum ich PrivyDrop Open Source gestellt habe: Eine Geschichte über Privatsphäre, WebRTC und Gemeinschaftsaufbau"
description: "PrivyDrop ist jetzt Open Source! Dieser Artikel erzählt die Entwicklung von einem persönlichen Bedürfnis zu einem produktionsreifen privaten Dateiübertragungstool, taucht tief in seine Architektur ein und lädt Sie ein, die Zukunft gemeinsam zu gestalten."
date: "2025-07-07"
author: "david bai"
cover: "/blog-assets/privydrop-open-source.jpg"
tags: [Open Source, WebRTC, Privatsphäre, Sicherheit, Next.js, Node.js]
status: "published"
---
![](/blog-assets/privydrop-open-source.jpg)
## Einleitung
Heute bin ich unglaublich aufgeregt, ankündigen zu können, dass ein persönliches Projekt, in das ich mein Herz und meine Seele gesteckt habe, **PrivyDrop**, nun offiziell Open Source ist!
[**Jetzt Live testen »**](https://www.privydrop.app/) | [**GitHub Repository »**](https://github.com/david-bai00/PrivyDrop)
Dieses Projekt begann mit einem sehr einfachen persönlichen Bedürfnis: "Ich möchte nur Dinge sicher und einfach zwischen meinem Telefon und Computer versenden."
Wenn Sie, wie ich, jemals frustriert waren bei der Suche nach einem Datei-Freigabetool, das keine Registrierung erfordert, keine Geschwindigkeitsbegrenzungen hat und Ihre Privatsphäre wirklich respektiert, dann ist dieser Artikel für Sie. Er wird nicht nur die Geschichte des "Kratzens am eigenen Juckreiz" teilen, sondern Sie auch auf eine vollständige "Hinter-den-Kulissen"-Tour mitnehmen, um PrivyDrops Kernarchitektur und Designphilosophie zu erkunden. Und am wichtigsten ist es eine aufrichtige Einladung, Co-Autor des nächsten Kapitels zu werden.
## Teil 1: Die Geburt eines Werkzeugs: Von "Ich brauche es" zu "Alle können es nutzen"
### 1.1 Die Reise eines Entwicklers, seinen eigenen Juckreiz zu kratzen
Alles begann mit einem kleinen aber hartnäckigen Schmerzpunkt in meinem täglichen Arbeitsablauf.
Ich muss häufig Dateien, Screenshots oder Text-Schnipsel zwischen meinem Telefon und meinem Laptop schnell senden. Ich habe viele Werkzeuge ausprobiert, aber keine hat meine Anforderungen vollständig erfüllt:
- Einige Online-P2P-Werkzeuge waren mächtig, konnten aber nur Dateien senden und scheiterten an meinem Bedarf, leichtgewichtigen Text oder Links zu senden.
- Einige Online-Zwischenablagen konnten Text bequem synchronisieren, aber ich war zutiefst besorgt darüber, meine Zwischenablageninhalte auf einen unbekannten Server hochzuladen.
- Und die Mainstream-Cloud-Speicher- oder Social-Apps erforderten entweder eine Anmeldung oder hatten Größen- und Geschwindigkeitsbegrenzungen, was den gesamten Prozess umständlich und mühsam machte.
Nachdem ich versagt hatte, ein Werkzeug zu finden, das perfekt meinen drei Kernanforderungen entsprach—**schnell, privat und ohne Konto erforderlich**—entschied ich mich, eines für mich selbst zu bauen.
### 1.2 Von einem persönlichen Werkzeug zu einem öffentlichen Projekt
Ursprünglich war PrivyDrop nur ein kleines Werkzeug, um meine eigenen Bedürfnisse zu erfüllen. Aber als ich nach und nach seine Funktionen verbesserte, erkannte ich, dass mein Schmerzpunkt wahrscheinlich ein gemeinsamer war.
In einer Zeit, in der Daten und Privatsphäre immer wichtiger werden, verdienen wir eine bessere Wahl—ein Werkzeug, das uns nicht zwingt, eine schmerzhafte Kompromisseingenschaft zwischen "Bequemlichkeit" und "Privatsphäre" einzugehen. Diese Idee trieb mich an, PrivyDrop von einem persönlichen Projekt zu einem robusten und zuverlässigen öffentlichen Dienst zu polieren.
Unsere Kernvision ist einfach, wie ich im README des Projekts schrieb: **Wir glauben, dass jeder die Kontrolle über seine eigenen Daten haben sollte.**
### 1.3 Warum Open Source? Die einzige Antwort für Vertrauen
Für ein Werkzeug, das "Privatsphäre und Sicherheit" als Kernwert beansprucht, ist Closed Source ein Widerspruch in sich selbst. Wie können Benutzer Ihren Versprechen vertrauen?
Daher war Open Source die unvermeidliche Wahl und die einzige Antwort.
- **Um Vertrauen aufzubauen**: Code ist der beste Beweis. Wir machen all unseren Code öffentlich, um von der Welt geprüft zu werden, und bauen so unbestreitbares Vertrauen auf.
- **Die Kraft der Gemeinschaft**: Ich bin mir sehr bewusst, dass die Kraft eines Einzelnen begrenzt ist. Ich glaube, dass die kollektive Weisheit der Gemeinschaft helfen kann, Fehler zu finden, die ich übersehen habe, und Funktionen vorzuschlagen, an die ich nie gedacht habe, und PrivyDrop dabei helfen, weiterzukommen und robuster zu werden.
- **Um zurückzugeben und zu lernen**: Ich habe ungemein von der Open-Source-Gemeinschaft profitiert, und jetzt ist es meine Zeit zurückzugeben. Das Projekt Open Source zu stellen, ist sowohl eine Möglichkeit, von talentierten Entwicklern zu lernen, als auch eine Freude des Teilens.
## Teil 2: Ein tiefer Einblick in die Architektur: Eine "Produktionsreife" Praxis
PrivyDrop ist nicht nur ein Spielzeugprojekt. In seiner architektonischen Design verfolgten wir Einfachheit, Effizienz und Skalierbarkeit und strebten danach, Produktionsstandards zu erreichen.
### 2.1 Das große Ganze: Ein einfaches und effizientes System
Unser Kern-Designprinzip ist: **ein leichtgewichtiger Backend, ein intelligentes Frontend**. Das Backend agiert nur als "Verkehrspolizist" (für Signalisierung), während das Frontend alle "schweren Arbeiten" (Dateiverarbeitung und -übertragung) übernimmt.
```mermaid
graph TD
subgraph "Benutzer A (Sender)"
A[Frontend App]
end
subgraph "Benutzer B (Empfänger)"
B[Frontend App]
end
subgraph "Cloud"
C(Signalisierungsserver - Node.js)
D(Zustandsspeicher - Redis)
end
A -- "1.&nbsp;Anfrage zum Erstellen/Beitreten von Raum" --> C
B -- "2.&nbsp;Anfrage zum Beitreten zum gleichen Raum" --> C
C -- "3.&nbsp;WebRTC-Signale austauschen (SDP/ICE)" --> A
C -- "4.&nbsp;WebRTC-Signale austauschen (SDP/ICE)" --> B
A <-.-> B;
C <--> D
A <-. "5.&nbsp;P2P-Direktverbindung herstellen" .-> B
A -- "6.&nbsp;Dateien/Text direkt übertragen" --> B
style A fill:#D5E8D4,stroke:#82B366
style B fill:#D5E8D4,stroke:#82B366
```
### 2.2 Frontend-Architektur: Von der Trennung von Belangen zur logischen Kohäsion
Das Frontend ist mit Next.js 14 gebaut, und unsere Kern-Designphilosophie ist **benutzerdefinierte Hooks als Herz unserer Geschäftslogik zu verwenden**.
Sie könnten fragen, warum nicht Redux oder Zustand? Für PrivyDrop ist der meiste Zustand eng gekoppelt mit spezifischer, hochkohärenter Geschäftslogik. Wir kapselten diese Logik und den Zustand in eine Reihe von benutzerdefinierten Hooks (wie `useWebRTCConnection`, `useRoomManager`, `useFileTransferHandler`), was mehrere klare Vorteile brachte:
- **Logische Kohäsion**: Alle Zustände und Methoden im Zusammenhang mit der WebRTC-Verbindung sind in `useWebRTCConnection`, was es extrem einfach zu warten macht.
- **Reine Komponenten**: React-Komponenten werden von komplexer Geschäftslogik befreit und kehren zu ihrer wesentlichen Rolle des UI-Renderings zurück.
- **Klare Schichtung**: Dies schafft einen klaren Datenfluss und Abhängigkeitsbeziehung von `app` (Routing) -> `components` (UI) -> `hooks` (Logik) -> `lib` (Low-Level-Fähigkeiten), was die Wartbarkeit des Code-Basis erheblich verbessert.
### 2.3 Backend-Architektur: Die Kunst der Zustandslosigkeit und Effizienz
Das Backend, basierend auf Node.js und Express, folgt in seinem Design streng dem **zustandslosen (Stateless)** Prinzip.
Der Server selbst hält keinen Zustand im Zusammenhang mit Räumen oder Benutzern. Der gesamte Zustand wird an **Redis** delegiert. Dies ermöglicht es der Backend-Anwendung, leicht horizontal skaliert zu werden.
Wir nutzten auch clever verschiedene Redis-Datenstrukturen, um Geschäftsanforderungen zu erfüllen:
- **Hash**: Zum Speichern von Raum-Metadaten.
- **Set**: Zum Speichern der `socketId` aller Mitglieder in einem Raum, wodurch Eindeutigkeit sichergestellt wird.
- **String**: Um eine `socketId` rückwärts auf ihre `roomId` abzubilden, was eine schnelle Bereinigung bei Benutzertrennung erleichtert.
- **Sorted Set**: Zur Implementierung von IP-basierter Ratenbegrenzung, was schädliche Anriffe effektiv verhindert.
Alle Schlüssel sind mit einer angemessenen TTL (Time To Live) eingestellt, was eine automatische Ressourcenbereinigung sicherstellt und dem System ermöglicht, langfristig stabil zu laufen.
### 2.4 "Produktionsreife" Überlegungen: Von Bereitstellung bis Sicherheit
Wir bieten einen vollständigen Produktionsbereitstellungsplan, einschließlich:
- Verwendung von **Nginx** als Reverse-Proxy und für SSL-Terminierung.
- Verwendung von **PM2** für Node.js-Prozessmanagement.
- Verwendung von **Certbot** für automatische SSL-Zertifikatserwerbung und -erneuerung.
- Eine umfassende Anleitung zur Einrichtung eines **TURN/STUN**-Servers für Szenarien, die das Durchqueren komplexer NATs erfordern.
All dies zeigt, dass PrivyDrop ein ernstes Projekt ist, dem vertraut und das in einer Produktionsumgebung bereitgestellt werden kann.
## Teil 3: Mehr als Code: Eine Einladung, die Zukunft zu gestalten
Open Source ist nur der Anfang. Wir haben eine aufregende Zukunft für PrivyDrop geplant, und jetzt möchten wir Sie einladen, sich uns anzuschließen.
### 3.1 Projekt-Roadmap
Wir haben eine öffentliche [<u>**Projekt-Roadmap**</u>](https://github.com/david-bai00/PrivyDrop/blob/main/ROADMAP.md), die unsere zukünftigen Prioritäten umreißt. Wir planen, in Zukunft einige stark nachgefragte Funktionen hinzuzufügen, wie zum Beispiel:
- **Wiederaufnehmbare Übertragungen**: Um sehr große Dateien und instabile Netzwerkbedingungen zu bewältigen.
- **E2E-verschlüsselter Gruppenchat**: Um sichere P2P-Kommunikation auf Multi-Benutzer-Text-Chats zu erweitern.
- Andere unbestimmte Funktionen.
### 3.2 Wie kann man beitragen?
Wir begrüßen Beiträge jeglicher Form! Egal wer Sie sind, es gibt immer einen Weg zu helfen, PrivyDrop besser zu machen. Bitte lesen Sie unsere [<u>**Beitragsrichtlinien**</u>](https://github.com/david-bai00/PrivyDrop/blob/main/.github/CONTRIBUTING.md), um Ihre Reise zu beginnen.
- **Für Benutzer**: Verwenden Sie das Produkt, melden Sie Fehler und schlagen Sie Funktionen über [GitHub Issues](https://github.com/david-bai00/PrivyDrop/issues) vor.
- **Für Entwickler**: Übernehmen Sie einen Fehler, implementieren Sie eine neue Funktion oder refaktorisieren Sie ein Stück bestehenden Codes.
- **Für Dokumentatoren/Übersetzer**: Helfen Sie uns, die Dokumentation zu verbessern oder PrivyDrop in mehr Sprachen zu übersetzen.
### 3.3 Ein starker Aufruf zum Handeln
- **Für Benutzer**: Erleben Sie jetzt die ultimative Privatsphäre und Bequemlichkeit mit PrivyDrop!
[**➡️ Jetzt Live testen**](https://www.privydrop.app/)
- **Für Entwickler**: Wenn PrivyDrops Philosophie oder Technologie Sie begeistert, geben Sie unserem GitHub-Repository bitte einen Stern! Es ist die größte Anerkennung und Ermutigung für uns.
[**⭐️ Geben Sie uns einen Stern auf GitHub**](https://github.com/david-bai00/PrivyDrop)
- **Für alle**: Treten Sie unseren Gemeinschaftsdiskussionen bei und lassen Sie uns Ihre Stimme hören!
## Fazit
Vielen Dank erneut, dass Sie sich Zeit genommen haben, diese Geschichte zu lesen.
Die Geschichte von PrivyDrop begann mit dem Bedürfnis einer Person, und ich freue mich darauf, dass ihre Zukunft von einer Gemeinschaft geschrieben wird.
@@ -0,0 +1,159 @@
---
title: "Por qué Abierto PrivyDrop: Una Historia de Privacidad, WebRTC y Construcción Comunitaria"
description: "¡PrivyDrop ahora es código abierto! Este artículo narra la evolución desde una necesidad personal hasta una herramienta de transferencia de archivos privada de nivel producción, profundiza en su arquitectura y te invita a construir el futuro juntos."
date: "2025-07-07"
author: "david bai"
cover: "/blog-assets/privydrop-open-source.jpg"
tags: [Código Abierto, WebRTC, Privacidad, Seguridad, Next.js, Node.js]
status: "published"
---
![](/blog-assets/privydrop-open-source.jpg)
## Introducción
Hoy, estoy increíblemente emocionado de anunciar que un proyecto personal en el que he volcado mi corazón y alma, **PrivyDrop**, ahora es oficialmente código abierto.
[**Pruébalo en Vivo »**](https://www.privydrop.app/) | [**Repositorio GitHub »**](https://github.com/david-bai00/PrivyDrop)
Este proyecto comenzó con una necesidad personal muy simple: "Solo quiero enviar cosas entre mi teléfono y mi computadora, de forma segura y fácil."
Si tú, como yo, alguna vez te has frustrado buscando una herramienta para compartir archivos que no requiera registro, no tenga límites de velocidad y realmente respete tu privacidad, entonces este artículo es para ti. No solo compartirá la historia de "rascarme mi propia picazón", sino que también te llevará en un completo "detrás de cámaras" para explorar la arquitectura central y la filosofía de diseño de PrivyDrop. Y lo más importante, es una sincera invitación para que te conviertas en co-autor de su próximo capítulo.
## Parte 1: El Nacimiento de una Herramienta: Desde "Lo Necesito" hasta "Todos Pueden Usarlo"
### 1.1 El Viaje de un Desarrollador para Rascarse su Propia Picazón
Todo comenzó con un pequeño pero persistente punto de dolor en mi flujo de trabajo diario.
Frecuentemente necesito enviar rápidamente archivos, capturas de pantalla o fragmentos de texto entre mi teléfono y mi laptop. Probé muchas herramientas, pero ninguna cumplió completamente con mis requisitos:
- Algunas herramientas P2P en línea eran poderosas pero solo podían enviar archivos, fallando en mi necesidad de enviar texto ligero o enlaces.
- Algunos portapapeles en línea podían sincronizar texto convenientemente, pero estaba profundamente preocupado por subir el contenido de mi portapapeles a un servidor desconocido.
- Y las aplicaciones主流 de almacenamiento en la nube o sociales requerían iniciar sesión o tenían límites de tamaño y velocidad, haciendo que todo el proceso se sintiera torpe y engorroso.
Después de fallar en encontrar una herramienta que coincidiera perfectamente con mis tres requisitos centrales—**rápido, privado y sin necesidad de cuenta**—decidí construir una para mí mismo.
### 1.2 Desde una Utilidad Personal a un Proyecto Público
Inicialmente, PrivyDrop era solo una pequeña utilidad para satisfacer mis propias necesidades. Pero a medida que mejoré gradualmente sus características, me di cuenta de que mi punto de dolor probablemente era común.
En una era donde los datos y la privacidad son cada vez más importantes, merecemos una mejor opción—una herramienta que no nos obligue a hacer una dolorosa compensación entre "conveniencia" y "privacidad". Esta idea me impulsó a pulir PrivyDrop desde un proyecto personal hasta un servicio público robusto y confiable.
Nuestra visión central es simple, como escribí en el README del proyecto: **Creemos que todos deberían tener control sobre sus propios datos.**
### 1.3 ¿Por qué Código Abierto? La Única Respuesta para la Confianza
Para una herramienta que reclama "privacidad y seguridad" como su valor central, ser de código cerrado es una contradicción en sí misma. ¿Cómo pueden los usuarios confiar en tus promesas?
Por lo tanto, el código abierto fue la elección inevitable y la única respuesta.
- **Para Construir Confianza**: El código es la mejor prueba. Estamos haciendo público todo nuestro código para ser escrutado por el mundo, construyendo así una confianza innegable.
- **El Poder de la Comunidad**: Soy muy consciente de que el poder de un individuo es limitado. Creo que la sabiduría colectiva de la comunidad puede ayudar a encontrar defectos que he pasado por alto y sugerir características que nunca he pensado, ayudando a PrivyDrop a ir más lejos y volverse más robusto.
- **Para Devolver y Aprender**: He beneficiado inmensamente de la comunidad de código abierto, y ahora es mi momento de devolver. Abrir el código del proyecto es tanto una forma de aprender de desarrolladores talentosos como una alegría de compartir.
## Parte 2: Una Inmersión Profunda en la Arquitectura: Una Práctica de "Nivel Producción"
PrivyDrop no es solo un proyecto de juguete. En su diseño arquitectónico, buscamos simplicidad, eficiencia y escalabilidad, esforzándonos por cumplir con los estándares de nivel producción.
### 2.1 El Panorama General: Un Sistema Simple y Eficiente
Nuestro principio de diseño central es: **un backend ligero, un frontend inteligente**. El backend solo actúa como "agente de tráfico" (para señalización), mientras que el frontend maneja todo el "trabajo pesado" (procesamiento y transferencia de archivos).
```mermaid
graph TD
subgraph "Usuario A (Remitente)"
A[Aplicación Frontend]
end
subgraph "Usuario B (Receptor)"
B[Aplicación Frontend]
end
subgraph "Nube"
C(Servidor de Señalización - Node.js)
D(Almacenamiento de Estado - Redis)
end
A -- "1.&nbsp;Solicitar crear/unirse a sala" --> C
B -- "2.&nbsp;Solicitar unirse a misma sala" --> C
C -- "3.&nbsp;Intercambiar señales WebRTC (SDP/ICE)" --> A
C -- "4.&nbsp;Intercambiar señales WebRTC (SDP/ICE)" --> B
A <-.-> B;
C <--> D
A <-. "5.&nbsp;Establecer conexión P2P directa" .-> B
A -- "6.&nbsp;Transferir archivos/texto directamente" --> B
style A fill:#D5E8D4,stroke:#82B366
style B fill:#D5E8D4,stroke:#82B366
```
### 2.2 Arquitectura Frontend: Desde la Separación de Preocupaciones hasta la Cohesión Lógica
El frontend está construido con Next.js 14, y nuestra filosofía de diseño central es **usar Hooks personalizados como el corazón de nuestra lógica de negocio**.
Podrías preguntar, ¿por qué no Redux o Zustand? Para PrivyDrop, la mayor parte del estado está estrechamente acoplado con lógica de negocio específica y altamente cohesiva. Encapsulamos esta lógica y estado en una serie de Hooks personalizados (como `useWebRTCConnection`, `useRoomManager`, `useFileTransferHandler`), lo que trajo varios beneficios claros:
- **Cohesión Lógica**: Todo el estado y métodos relacionados con la conexión WebRTC están en `useWebRTCConnection`, haciéndolo extremadamente fácil de mantener.
- **Componentes Puros**: Los componentes de React se liberan de la compleja lógica de negocio, regresando a su rol esencial de renderizar UI.
- **Capas Claras**: Esto crea un claro flujo de datos y relación de dependencia desde `app` (enrutamiento) -> `components` (UI) -> `hooks` (lógica) -> `lib` (capacidades de bajo nivel), mejorando enormemente la capacidad de mantenimiento del código base.
### 2.3 Arquitectura Backend: El Arte de la Sin Estado y Eficiencia
El backend, basado en Node.js y Express, sigue estrictamente el principio **sin estado (stateless)** en su diseño.
El servidor en sí no mantiene ningún estado relacionado con salas o usuarios. Todo el estado es delegado a **Redis**. Esto permite que la aplicación backend sea escalada horizontalmente con facilidad.
También utilizamos clevermente diferentes estructuras de datos de Redis para satisfacer las necesidades del negocio:
- **Hash**: Para almacenar metadatos de sala.
- **Set**: Para almacenar el `socketId` de todos los miembros en una sala, asegurando unicidad.
- **String**: Para mapear inversamente un `socketId` a su `roomId`, facilitando una limpieza rápida cuando un usuario se desconecta.
- **Sorted Set**: Para implementar limitación de velocidad basada en IP, previniendo efectivamente ataques maliciosos.
Todas las claves están configuradas con un TTL (Time To Live) razonable, asegurando la limpieza automática de recursos y permitiendo que el sistema funcione de manera estable a largo plazo.
### 2.4 Consideraciones de "Nivel Producción": Desde Despliegue hasta Seguridad
Proporcionamos un plan completo de despliegue en producción, incluyendo:
- Usar **Nginx** como proxy inverso y para terminación SSL.
- Usar **PM2** para gestión de procesos Node.js.
- Usar **Certbot** para adquisición y renovación automática de certificados SSL.
- Una guía comprensiva para configurar un servidor **TURN/STUN** para escenarios que requieren atravesar NATs complejos.
Todo esto demuestra que PrivyDrop es un proyecto serio que puede ser confiado y desplegado a un entorno de producción.
## Parte 3: Más Allá del Código: Una Invitación a Construir el Futuro
Abrir el código es solo el comienzo. Tenemos un futuro emocionante planeado para PrivyDrop, y ahora, queremos invitarte a unirte a nosotros.
### 3.1 Hoja de Ruta del Proyecto
Tenemos una [<u>**Hoja de Ruta del Proyecto**</u>](https://github.com/david-bai00/PrivyDrop/blob/main/ROADMAP.md) pública que describe nuestras futuras prioridades. Planeamos agregar algunas características muy solicitadas en el futuro, tales como:
- **Transferencias Reanudables**: Para manejar archivos muy grandes y condiciones de red inestables.
- **Chat Grupal Cifrado E2E**: Para extender la comunicación P2P segura a chats de texto multi-usuario.
- Otras características por determinar.
### 3.2 ¿Cómo Contribuir?
¡Bienvenimos contribuciones de todas las formas! No importa quién seas, siempre hay una manera de ayudar a hacer PrivyDrop mejor. Por favor lee nuestras [<u>**Guías de Contribución**</u>](https://github.com/david-bai00/PrivyDrop/blob/main/.github/CONTRIBUTING.md) para comenzar tu viaje.
- **Para Usuarios**: Usa el producto, reporta bugs y sugiere características a través de [GitHub Issues](https://github.com/david-bai00/PrivyDrop/issues).
- **Para Desarrolladores**: Reclama un bug, implementa una nueva característica, o refactoriza una pieza de código existente.
- **Para Documentadores/Traductores**: Ayúdanos a mejorar la documentación o traduce PrivyDrop a más idiomas.
### 3.3 Una Fuerte Llamada a la Acción
- **Para Usuarios**: ¡Experimenta la máxima privacidad y conveniencia con PrivyDrop ahora!
[**➡️ Pruébalo en Vivo**](https://www.privydrop.app/)
- **Para Desarrolladores**: Si la filosofía o tecnología de PrivyDrop te emociona, por favor da una Estrella a nuestro repositorio GitHub. ¡Es el mayor reconocimiento y aliento para nosotros!
[**⭐️ Danos Estrella en GitHub**](https://github.com/david-bai00/PrivyDrop)
- **Para Todos**: ¡Únete a nuestras discusiones comunitarias y deja que escuchemos tu voz!
## Conclusión
Gracias nuevamente por tomar el tiempo de leer esta historia.
La historia de PrivyDrop comenzó con la necesidad de una persona, y espero que su futuro sea escrito por una comunidad.
@@ -0,0 +1,159 @@
---
title: "Pourquoi j'ai mis PrivyDrop en Open Source : Une histoire de confidentialité, WebRTC et construction communautaire"
description: "PrivyDrop est maintenant en open source ! Cet article raconte son évolution d'un besoin personnel à un outil de transfert de fichiers privé de qualité production, plonge en profondeur dans son architecture et vous invite à construire l'avenir ensemble."
date: "2025-07-07"
author: "david bai"
cover: "/blog-assets/privydrop-open-source.jpg"
tags: [Open Source, WebRTC, Confidentialité, Sécurité, Next.js, Node.js]
status: "published"
---
![](/blog-assets/privydrop-open-source.jpg)
## Introduction
Aujourd'hui, je suis incroyablement excité d'annoncer qu'un projet personnel auquel j'ai consacré mon cœur et mon âme, **PrivyDrop**, est maintenant officiellement open source !
[**Essayez-le en Direct »**](https://www.privydrop.app/) | [**Dépôt GitHub »**](https://github.com/david-bai00/PrivyDrop)
Ce projet a commencé avec un besoin personnel très simple : "Je veux juste envoyer des choses entre mon téléphone et mon ordinateur, de manière sécurisée et facile."
Si vous, comme moi, avez déjà été frustré en cherchant un outil de partage de fichiers qui ne nécessite aucune inscription, n'a pas de limites de vitesse et respecte vraiment votre confidentialité, alors cet article est pour vous. Il partagera non seulement l'histoire de "me gratter là où ça me démange", mais vous emmènera également dans une visite complète "coulisses" pour explorer l'architecture centrale et la philosophie de conception de PrivyDrop. Et plus important encore, c'est une invitation sincère à devenir co-auteur de son prochain chapitre.
## Partie 1 : La naissance d'un outil : De "J'en ai besoin" à "Tout le monde peut l'utiliser"
### 1.1 Le parcours d'un développeur pour se gratter là où ça lui démange
Tout a commencé par un petit point de douleur persistant dans mon flux de travail quotidien.
J'ai fréquemment besoin d'envoyer rapidement des fichiers, des captures d'écran ou des extraits de texte entre mon téléphone et mon ordinateur portable. J'ai essayé de nombreux outils, mais aucun n'a complètement satisfait mes exigences :
- Certains outils P2P en ligne étaient puissants mais ne pouvaient envoyer que des fichiers, échouant à mon besoin d'envoyer du texte léger ou des liens.
- Certains presse-papiers en ligne pouvaient synchroniser du texte commodément, mais j'étais profondément préoccupé par l'upload de mon contenu de presse-papiers sur un serveur inconnu.
- Et les applications主流 de stockage cloud ou sociales nécessitaient soit de se connecter, soit avaient des limites de taille et de vitesse, rendant tout le processus lourd et fastidieux.
Après avoir échoué à trouver un outil qui correspondait parfaitement à mes trois exigences centrales—**rapide, privé et sans compte nécessaire**—j'ai décidé d'en construire un pour moi-même.
### 1.2 D'un utilitaire personnel à un projet public
Initialement, PrivyDrop n'était qu'un petit utilitaire pour répondre à mes propres besoins. Mais à mesure que j'améliorais progressivement ses fonctionnalités, j'ai réalisé que mon point de douleur était probablement courant.
À une époque où les données et la confidentialité sont de plus en plus importantes, nous méritons un meilleur choix—un outil qui ne nous force pas à faire un compromis douloureux entre "commodité" et "confidentialité". Cette idée m'a poussé à polir PrivyDrop d'un projet personnel à un service public robuste et fiable.
Notre vision centrale est simple, comme je l'ai écrite dans le README du projet : **Nous croyons que tout le monde devrait avoir le contrôle sur ses propres données.**
### 1.3 Pourquoi l'Open Source ? La seule réponse pour la confiance
Pour un outil qui prétend "confidentialité et sécurité" comme valeur centrale, être en code fermé est une contradiction en soi. Comment les utilisateurs peuvent-ils faire confiance à vos promesses ?
Par conséquent, l'open source était le choix inévitable et la seule réponse.
- **Pour construire la confiance** : Le code est la meilleure preuve. Nous rendons tout notre code public pour être examiné par le monde, construisant ainsi une confiance incontestable.
- **Le pouvoir de la communauté** : Je suis bien conscient que le pouvoir d'un individu est limité. Je crois que la sagesse collective de la communauté peut aider à trouver des défauts que j'ai manqués et suggérer des fonctionnalités auxquelles je n'ai jamais pensé, aidant PrivyDrop à aller plus loin et à devenir plus robuste.
- **Pour donner en retour et apprendre** : J'ai énormément bénéficié de la communauté open source, et maintenant c'est mon tour de donner en retour. Mettre le projet en open source est à la fois une façon d'apprendre de développeurs talentueux et une joie de partager.
## Partie 2 : Une plongée en profondeur dans l'architecture : Une pratique "de qualité production"
PrivyDrop n'est pas juste un projet jouet. Dans sa conception architecturale, nous avons poursuivi la simplicité, l'efficacité et la scalabilité, s'efforçant d'atteindre les standards de qualité production.
### 2.1 La vue d'ensemble : Un système simple et efficace
Notre principe de conception central est : **un backend léger, un frontend intelligent**. Le backend n'agit que comme "agent de circulation" (pour la signalisation), tandis que le frontend gère tout le "travail lourd" (traitement et transfert de fichiers).
```mermaid
graph TD
subgraph "Utilisateur A (Expéditeur)"
A[Application Frontend]
end
subgraph "Utilisateur B (Destinataire)"
B[Application Frontend]
end
subgraph "Cloud"
C(Serveur de Signalisation - Node.js)
D(Stockage d'État - Redis)
end
A -- "1.&nbsp;Demander à créer/rejoindre une salle" --> C
B -- "2.&nbsp;Demander à rejoindre la même salle" --> C
C -- "3.&nbsp;Échanger des signaux WebRTC (SDP/ICE)" --> A
C -- "4.&nbsp;Échanger des signaux WebRTC (SDP/ICE)" --> B
A <-.-> B;
C <--> D
A <-. "5.&nbsp;Établir une connexion P2P directe" .-> B
A -- "6.&nbsp;Transférer fichiers/texte directement" --> B
style A fill:#D5E8D4,stroke:#82B366
style B fill:#D5E8D4,stroke:#82B366
```
### 2.2 Architecture Frontend : De la séparation des préoccupations à la cohésion logique
Le frontend est construit avec Next.js 14, et notre philosophie de conception centrale est **d'utiliser des Hooks personnalisés comme cœur de notre logique métier**.
Vous pourriez demander, pourquoi pas Redux ou Zustand ? Pour PrivyDrop, la plupart de l'état est étroitement couplé avec une logique métier spécifique et hautement cohésive. Nous avons encapsulé cette logique et cet état dans une série de Hooks personnalisés (comme `useWebRTCConnection`, `useRoomManager`, `useFileTransferHandler`), ce qui a apporté plusieurs avantages clairs :
- **Cohésion logique** : Tout l'état et les méthodes liés à la connexion WebRTC sont dans `useWebRTCConnection`, le rendant extrêmement facile à maintenir.
- **Composants purs** : Les composants React sont libérés de la logique métier complexe, retournant à leur rôle essentiel de rendu d'interface utilisateur.
- **Clarté des couches** : Cela crée un flux de données clair et une relation de dépendance de `app` (routage) -> `components` (interface utilisateur) -> `hooks` (logique) -> `lib` (capacités de bas niveau), améliorant grandement la maintenabilité de la base de code.
### 2.3 Architecture Backend : L'art de l'étatlessness et de l'efficacité
Le backend, basé sur Node.js et Express, suit strictement le principe **stateless (sans état)** dans sa conception.
Le serveur lui-même ne détient aucun état lié aux salles ou aux utilisateurs. Tout l'état est délégué à **Redis**. Cela permet à l'application backend d'être facilement mise à l'échelle horizontalement.
Nous avons également utilisé intelligemment différentes structures de données Redis pour répondre aux besoins métier :
- **Hash** : Pour stocker les métadonnées de salle.
- **Set** : Pour stocker le `socketId` de tous les membres dans une salle, assurant l'unicité.
- **String** : Pour mapper inversement un `socketId` à sa `roomId`, facilitant un nettoyage rapide lorsqu'un utilisateur se déconnecte.
- **Sorted Set** : Pour implémenter la limitation de débit basée sur IP, prévenant efficacement les attaques malveillantes.
Toutes les clés sont définies avec un TTL (Time To Live) raisonnable, assurant le nettoyage automatique des ressources et permettant au système de fonctionner de manière stable à long terme.
### 2.4 Considérations "de qualité production" : Du déploiement à la sécurité
Nous fournissons un plan complet de déploiement en production, incluant :
- Utilisation de **Nginx** comme proxy inverse et pour terminaison SSL.
- Utilisation de **PM2** pour la gestion des processus Node.js.
- Utilisation de **Certbot** pour l'acquisition et le renouvellement automatiques des certificats SSL.
- Un guide complet pour configurer un serveur **TURN/STUN** pour les scénarios nécessitant le traversal de NATs complexes.
Tout cela démontre que PrivyDrop est un projet sérieux qui peut être approuvé et déployé dans un environnement de production.
## Partie 3 : Plus que du code : Une invitation à construire l'avenir
L'open source n'est que le début. Nous avons planifié un avenir passionnant pour PrivyDrop, et maintenant, nous voulons vous inviter à nous rejoindre.
### 3.1 Feuille de route du projet
Nous avons une [<u>**Feuille de route du projet**</u>](https://github.com/david-bai00/PrivyDrop/blob/main/ROADMAP.md) publique qui décrit nos priorités futures. Nous prévoyons d'ajouter certaines fonctionnalités très demandées à l'avenir, telles que :
- **Transferts repriseables** : Pour gérer les très gros fichiers et les conditions réseau instables.
- **Chat de groupe chiffré E2E** : Pour étendre la communication P2P sécurisée aux chats texte multi-utilisateurs.
- Autres fonctionnalités à déterminer.
### 3.2 Comment contribuer ?
Nous accueillons les contributions de toutes formes ! Peu importe qui vous êtes, il y a toujours un moyen d'aider à rendre PrivyDrop meilleur. Veuillez lire nos [<u>**Directives de contribution**</u>](https://github.com/david-bai00/PrivyDrop/blob/main/.github/CONTRIBUTING.md) pour commencer votre voyage.
- **Pour les utilisateurs** : Utilisez le produit, signalez des bugs et suggérez des fonctionnalités via les [GitHub Issues](https://github.com/david-bai00/PrivyDrop/issues).
- **Pour les développeurs** : Réclamez un bug, implémentez une nouvelle fonctionnalité, ou refactorez un morceau de code existant.
- **Pour les documentaristes/traducteurs** : Aidez-nous à améliorer la documentation ou traduisez PrivyDrop en plus de langues.
### 3.3 Un fort appel à l'action
- **Pour les utilisateurs** : Expérimentez maintenant la confidentialité et la commodité ultimes avec PrivyDrop !
[**➡️ Essayez-le en Direct**](https://www.privydrop.app/)
- **Pour les développeurs** : Si la philosophie ou la technologie de PrivyDrop vous excite, veuillez donner une Étoile à notre dépôt GitHub ! C'est la plus grande reconnaissance et encouragement pour nous.
[**⭐️ Donnez-nous une Étoile sur GitHub**](https://github.com/david-bai00/PrivyDrop)
- **Pour tous** : Rejoignez nos discussions communautaires et laissez-nous entendre votre voix !
## Conclusion
Merci encore d'avoir pris le temps de lire cette histoire.
L'histoire de PrivyDrop a commencé avec le besoin d'une personne, et j'attends avec impatience que son avenir soit écrit par une communauté.
@@ -0,0 +1,159 @@
---
title: "PrivyDropをオープンソースにした理由:プライバシー、WebRTC、そしてコミュニティ育成の物語"
description: "PrivyDropが正式にオープンソースになりました!この記事では、個人的なニーズから生産レベルのプライベートファイル転送ツールへと進化した道のりを語り、そのアーキテクチャを深く掘り下げ、未来を共に築くための招待状です。"
date: "2025-07-07"
author: "david bai"
cover: "/blog-assets/privydrop-open-source.jpg"
tags: [オープンソース, WebRTC, プライバシー, セキュリティ, Next.js, Node.js]
status: "published"
---
![](/blog-assets/privydrop-open-source.jpg)
## はじめに
本日、心を込めて開発してきた個人プロジェクトである**PrivyDrop**が、正式にオープンソースとなりましたことを、心より誇りに思います!
[**今すぐ体験 »**](https://www.privydrop.app/) | [**GitHubリポジトリ »**](https://github.com/david-bai00/PrivyDrop)
このプロジェクトは、非常にシンプルな個人的なニーズから始まりました:「スマートフォンとコンピューターの間で、安全かつ簡単にファイルを送信したいだけ。」
もしあなたも私のように、登録不要、速度制限なし、真にプライバシーを尊重するファイル共有ツールを探して困った経験があるなら、この記事はあなたのためのものです。自分自身の「痒いところに手が届く」ツール開発の物語を共有するだけでなく、PrivyDropの中核的なアーキテクチャと設計哲学を探求する完全な「舞台裏」ツアーをご案内します。そして最も重要なこととして、あなたをこの物語の次の章の共同執筆者として招待します。
## 第1部:ツールの誕生:「私が必要」から「皆が使える」へ
### 1.1 開発者自身の痒いところに手を届ける物語
すべては、私の日々のワークフローにおける小さくも持続的な痛みから始まりました。
私は頻繁にスマートフォンとノートパソコンの間でファイル、スクリーンショット、テキストの断片を素早く送信する必要がありました。多くのツールを試しましたが、どれも私の要求を完全には満たしていませんでした:
- 一部のオンラインP2Pツールは強力でしたが、ファイルしか送信できず、軽量なテキストやリンクを送信するニーズには応えられませんでした。
- 一部のオンラインクリップボードはテキストを便利に同期できましたが、クリップボードの内容を未知のサーバーにアップロードすることに深い懸念を感じました。
- そして主流のクラウドストレージやソーシャルアプリは、ログインが必要か、サイズと速度の制限があり、プロセス全体を重くて面倒なものにしていました。
「**高速、プライベート、アカウント不要**」という3つの中核要件を完全に満たすツールが見つからなかった後、自分で作ることを決意しました。
### 1.2 個人用ツールから公開プロジェクトへ
当初、PrivyDropは自分自身のニーズを満たすための小さなユーティリティでした。しかし、機能を徐々に改善していくうちに、私の痛み点がおそらく多くの人々共通のものであることに気づきました。
データとプライバシーがますます重要になる時代において、私たちは「利便性」と「プライバシー」の間で苦痛な取引を強いられない、より良い選択に値します。この考えがPrivyDropを個人プロジェクトから堅牢で信頼性の高い公開サービスへと磨き上げる原動力となりました。
私たちの中核的なビジョンはシンプルで、プロジェクトのREADMEに記述した通りです:**すべての人が自分のデータをコントロールできるべきだと信じています。**
### 1.3 なぜオープンソースなのか?信頼の唯一の答え
「プライバシーとセキュリティ」を中核価値とするツールにとって、クローズドソースであること自体が矛盾です。ユーザーはどうしてあなたの約束を信頼できるのでしょうか?
したがって、オープンソースは必然的な選択であり、唯一の答えでした。
- **信頼の構築**:コードが最良の証明です。私たちはすべてのコードを公開し、世界からの監視を受けることで、議論の余地のない信頼を構築します。
- **コミュニティの力**:個人の力には限界があることをよく知っています。コミュニティの集合的知恵が、私が見逃した欠陥を見つけたり、私が思いもよらなかった機能を提案したりして、PrivyDropをさらに進化させ、より堅牢にしてくれると信じています。
- **還元と学習**:私はオープンソースコミュニティから多大な恩恵を受けてきました。今が還元するときです。プロジェクトをオープンソースにすることは、才能ある開発者たちから学ぶ機会であり、共有の喜びでもあります。
## 第2部:アーキテクチャ深掘り:「生産レベル」の実践
PrivyDropは単なるおもちゃプロジェクトではありません。アーキテクチャ設計において、私たちはシンプルさ、効率性、スケーラビリティを追求し、生産レベルの基準を満たすよう努めています。
### 2.1 全体像:シンプルで効率的なシステム
私たちの中核設計原則は:**軽量バックエンド、インテリジェントフロントエンド**です。バックエンドは「交通警察」(シグナリング用)としてのみ機能し、フロントエンドがすべて「重労働」(ファイル処理と転送)を担います。
```mermaid
graph TD
subgraph "ユーザーA (送信者)"
A[フロントエンドアプリ]
end
subgraph "ユーザーB (受信者)"
B[フロントエンドアプリ]
end
subgraph "クラウド"
C(シグナリングサーバー - Node.js)
D(状態ストレージ - Redis)
end
A -- "1.&nbsp;ルーム作成/参加リクエスト" --> C
B -- "2.&nbsp;同一ルーム参加リクエスト" --> C
C -- "3.&nbsp;WebRTCシグナル交換 (SDP/ICE)" --> A
C -- "4.&nbsp;WebRTCシグナル交換 (SDP/ICE)" --> B
A <-.-> B;
C <--> D
A <-. "5.&nbsp;P2P直接接続確立" .-> B
A -- "6.&nbsp;ファイル/テキスト直接転送" --> B
style A fill:#D5E8D4,stroke:#82B366
style B fill:#D5E8D4,stroke:#82B366
```
### 2.2 フロントエンドアーキテクチャ:関心の分離から論理的凝集へ
フロントエンドはNext.js 14で構築されており、私たちの最も中核的な設計哲学は**カスタムフックをビジネスロジックの中心とすること**です。
なぜReduxやZustandを使わないのかと尋ねるかもしれません。PrivyDropの場合、ほとんどの状態は特定の、高凝集なビジネスロジックと密接に関連しています。私たちは一連のカスタムフック(`useWebRTCConnection`、`useRoomManager`、`useFileTransferHandler`など)を使用してこれらのロジックと状態をカプル化し、いくつかの明らかな利点をもたらしました:
- **論理的凝集**:WebRTC接続に関連するすべての状態とメソッドが`useWebRTCConnection`にまとめられており、極めて保守しやすいです。
- **純粋なコンポーネント**:Reactコンポーネントは複雑なビジネスロジックから解放され、UIレンダリングという本質的な役割に戻ります。
- **明確な階層化**:`app`(ルーティング)→`components`UI)→`hooks`(ロジック)→`lib`(低レベル機能)の明確なデータフローと依存関係が形成され、コードベースの保守性が大幅に向上します。
### 2.3 バックエンドアーキテクチャ:ステートレスと効率性の芸術
Node.jsとExpressに基づくバックエンドは、設計において厳密に**ステートレス(Stateless)**原則に従っています。
サーバー自体はルームやユーザーに関連する状態を一切保持しません。すべての状態は**Redis**に委ねられます。これにより、バックエンドアプリケーションを非常に簡単に水平スケールできます。
私たちはまた、ビジネスニーズを満たすためにRedisの異なるデータ構造を巧みに活用しました:
- **Hash**: ルームのメタデータを格納する
- **Set**: ルーム内のすべてのメンバーの`socketId`を格納し、一意性を保証する
- **String**: `socketId`を`roomId`に逆マッピングし、ユーザー切断時の迅速なクリーンアップを容易にする
- **Sorted Set**: IPベースのレート制限を実装し、悪意のある攻撃を効果的に防ぐ
すべてのキーには合理的なTTL(有効期間)が設定されており、リソースの自動クリーンアップを保証し、システムを長期にわたり安定して実行できるようにしています。
### 2.4 「生産レベル」の考慮事項:デプロイメントからセキュリティまで
私たちは、包括的な本番環境デプロイメントプランを提供しています:
- **Nginx**をリバースプロキシおよびSSL終端として使用
- **PM2**によるNode.jsプロセス管理
- **Certbot**によるSSL証明書の自動取得と更新
- 複雑なNATのトラバーサルが必要なシナリオのための包括的な**TURN/STUN**サーバー設定ガイド
これらはすべて、PrivyDropが信頼でき、本番環境にデプロイ可能な深刻なプロジェクトであることを示しています。
## 第3部:コードを超えて:未来を共に築くための招待
オープンソースは始まりにすぎません。私たちはPrivyDropのためにエキサイティングな未来を計画しており、今、あなたに参加してほしいと願っています。
### 3.1 プロジェクトロードマップ
私たちは公開の[<u>**プロジェクトロードマップ**</u>](https://github.com/david-bai00/PrivyDrop/blob/main/ROADMAP.md)を持っており、未来の優先事項を概説しています。将来的に、以下のような要望の高い機能を追加する予定です:
- **レジューム可能な転送**:非常に大きなファイルと不安定なネットワーク状況に対応するため
- **E2E暗号化グループチャット**:安全なP2P通信をマルチユーザーテキストチャットに拡張する
- その他未定の機能
### 3.2 貢献するには?
私たちはあらゆる形式の貢献を歓迎します!あなたが誰であれ、PrivyDropをより良くする方法が必ずあります。[<u>**貢献ガイドライン**</u>](https://github.com/david-bai00/PrivyDrop/blob/main/.github/CONTRIBUTING.md)をお読みいただき、あなたの旅を始めてください。
- **ユーザーの皆さん**:製品を使用し、[GitHub Issues](https://github.com/david-bai00/PrivyDrop/issues)を通じてバグを報告し、機能を提案してください
- **開発者の皆さん**:バグを募集し、新機能を実装し、既存のコードをリファクタリングしてください
- **ドキュメンター/翻訳者の皆さん**:ドキュメントの改善やPrivyDropの多言語化を手伝ってください
### 3.3 力強い行動喚起
- **ユーザーの皆さん**:今すぐPrivyDropを体験し、究極のプライバシーと利便性を感じてください!
[**➡️ 今すぐ体験**](https://www.privydrop.app/)
- **開発者の皆さん**:PrivyDropの哲学や技術に目を輝かせたなら、私たちのGitHubリポジトリにStarを付けてください!それは私たちにとって最高の評価と励みになります。
[**⭐️ GitHubでStarを付ける**](https://github.com/david-bai00/PrivyDrop)
- **すべての皆さん**:コミュニティディスカッションに参加し、私たちにあなたの声を聞かせてください!
## 結論
この物語を読む時間を作ってくださり、再度感謝申し上げます。
PrivyDropの物語は一人の人間のニーズから始まりましたが、その未来はコミュニティによって書かれることを期待しています。
@@ -0,0 +1,159 @@
---
title: "PrivyDrop를 오픈소스로 만든 이유: 개인정보 보호, WebRTC, 그리고 커뮤니티 구축의 이야기"
description: "PrivyDrop이 이제 오픈소스가 되었습니다! 이 글은 개인적인 필요에서 시작하여 프로덕션급 개인 파일 전송 도구로 발전한 과정을 설명하고, 아키텍처를 깊이 파고들며, 함께 미래를 만들어갈 당신을 초대합니다."
date: "2025-07-07"
author: "david bai"
cover: "/blog-assets/privydrop-open-source.jpg"
tags: [오픈소스, WebRTC, 개인정보 보호, 보안, Next.js, Node.js]
status: "published"
---
![](/blog-assets/privydrop-open-source.jpg)
## 서론
오늘, 제가 마음과 영혼을 쏟아부은 개인 프로젝트인 **PrivyDrop**이 공식적으로 오픈소스가 되었음을 매우 기쁘게 알려드립니다!
[**지금 바로 사용해보세요 »**](https://www.privydrop.app/) | [**GitHub 저장소 »**](https://github.com/david-bai00/PrivyDrop)
이 프로젝트는 매우 간단한 개인적인 필요에서 시작되었습니다: "그냥 휴대폰과 컴퓨터 사이에 안전하고 쉽게 파일을 보내고 싶어요."
만약 당신도 저처럼, 등록이 필요 없고 속도 제한이 없으며, 진정으로 당신의 개인정보를 존중하는 파일 공유 도구를 찾다가 좌절했다면, 이 글은 당신을 위한 것입니다. 이 글은 제 '가려운 곳을 긁는' 이야기를 공유할 뿐만 아니라, PrivyDrop의 핵심 아키텍처와 디자인 철학을 탐구하는 완전한 '비하인드 스토리' 투어를 안내할 것입니다. 그리고 가장 중요한 것은, 당신을 다음 장의 공동 저자로 초대하는 진심 어린 초대장입니다.
## 1부: 도구의 탄생: "내가 필요해"에서 "모두가 사용할 수 있게"까지
### 1.1 개발자의 자기 필요 해소 여정
모든 것은 제 일상 워크플로우의 작지만 지속적인 불편함에서 시작되었습니다.
저는 자주 휴대폰과 노트북 사이에 파일, 스크린샷, 또는 텍스트 조각을 빠르게 보내야 합니다. 많은 도구를 시도했지만, 어느 것도 제 요구사항을 완전히 만족시키지 못했습니다:
- 일부 온라인 P2P 도구는 강력했지만 파일만 보낼 수 있어서 가벼운 텍스트나 링크를 보내는 필요에 부응하지 못했습니다.
- 일부 온라인 클립보드는 텍스트를 편리하게 동기화할 수 있었지만, 클립보드 내용을 알 수 없는 서버에 업로드하는 것에 대해 깊은 우려를 가졌습니다.
- 그리고 메인스트림 클라우드 저장소나 소셜 앱은 로그인이 필요하거나 크기와 속도 제한이 있어 전체 과정이 불편하고 번거롭게 느껴졌습니다.
제 세 가지 핵심 요구사항—**빠르고, 사적이며, 계정이 필요 없는**—에 완벽하게 맞는 도구를 찾지 못한 후, 스스로 하나를 만들기로 결정했습니다.
### 1.2 개인 유틸리티에서 공개 프로젝트로
처음에 PrivyDrop은 제 자신의 필요를 만족시키는 작은 유틸리티였습니다. 하지만 점차 기능을 개선하면서 제 불편함이 아마도 많은 사람들의 공통된 불편함일 것이라는 것을 깨달았습니다.
데이터와 개인정보 보호가 점점 더 중요해지는 시대에서, 우리는 "편리함"과 "개인정보 보호" 사이에서 고통스러운 타협을 강요받지 않는 더 나은 선택을 가치 있습니다. 이 아이디어는 PrivyDrop을 개인 프로젝트에서 견고하고 신뢰할 수 있는 공개 서비스로 다듬게 하는 원동력이 되었습니다.
저희의 핵심 비전은 간단합니다. 프로젝트 README에 쓴 것처럼: **우리는 모든 사람이 자신의 데이터를 통제할 수 있어야 한다고 믿습니다.**
### 1.3 왜 오픈소스인가? 신뢰를 위한 유일한 답
"개인정보 보호와 보안"을 핵심 가치로 주장하는 도구에 있어, 소스 코드를 닫는 것은 그 자체로 모순입니다. 사용자들이 어떻게 당신의 약속을 신뢰할 수 있을까요?
따라서 오픈소스는 필연적인 선택이자 유일한 답이었습니다.
- **신뢰 구축**: 코드가 최고의 증거입니다. 저희는 세계의 검토를 받을 수 있도록 모든 코드를 공개하여, 논쟁의 여지가 없는 신뢰를 구축하고 있습니다.
- **커뮤니티의 힘**: 개인의 힘은 제한적이라는 것을 잘 알고 있습니다. 커뮤니티의 집단 지성이 제가 놓친 결함을 발견하고 제가 상상하지 못했던 기능을 제안하여, PrivyDrop이 더 나아가고 더 견고해지도록 도울 수 있다고 믿습니다.
- **보답과 학습**: 저는 오픈소스 커뮤니티에서 엄청난 혜택을 받았고, 이제 제가 보답할 차례입니다. 프로젝트를 오픈소스로 만드는 것은 재능 있는 개발자들로부터 배우는 기회이자 공유의 기쁨입니다.
## 2부: 아키텍처 심층 분석: "프로덕션급" 실천
PrivyDrop은 단순한 장난감 프로젝트가 아닙니다. 아키텍처 설계에서 저희는 단순함, 효율성, 확장성을 추구하며 프로덕션급 표준을 만족시키기 위해 노력했습니다.
### 2.1 큰 그림: 단순하고 효율적인 시스템
저희의 핵심 설계 원칙은: **가벼운 백엔드, 지능적인 프론트엔드**입니다. 백엔드는 "교통 경찰"(시그널링용)으로만 작동하고, 프론트엔드는 모든 "무거운 작업"(파일 처리와 전송)을 처리합니다.
```mermaid
graph TD
subgraph "사용자 A (발신자)"
A[프론트엔드 앱]
end
subgraph "사용자 B (수신자)"
B[프론트엔드 앱]
end
subgraph "클라우드"
C(시그널링 서버 - Node.js)
D(상태 저장소 - Redis)
end
A -- "1.&nbsp;방 생성/참여 요청" --> C
B -- "2.&nbsp;동일 방 참여 요청" --> C
C -- "3.&nbsp;WebRTC 신호 교환 (SDP/ICE)" --> A
C -- "4.&nbsp;WebRTC 신호 교환 (SDP/ICE)" --> B
A <-.-> B;
C <--> D
A <-. "5.&nbsp;P2P 직접 연결 설정" .-> B
A -- "6.&nbsp;파일/텍스트 직접 전송" --> B
style A fill:#D5E8D4,stroke:#82B366
style B fill:#D5E8D4,stroke:#82B366
```
### 2.2 프론트엔드 아키텍처: 관심사 분리에서 논리적 응집까지
프론트엔드는 Next.js 14로 구축되었으며, 저희의 핵심 설계 철학은 **사용자 정의 Hooks를 비즈니스 로직의 핵심으로 사용**하는 것입니다.
왜 Redux나 Zustand를 사용하지 않았는지 물을 수 있습니다. PrivyDrop의 경우, 대부분의 상태가 특정하고 응집도가 높은 비즈니스 로직과 밀접하게 결합되어 있습니다. 저희는 일련의 사용자 정의 Hooks(`useWebRTCConnection`, `useRoomManager`, `useFileTransferHandler` 등)를 사용하여 이 로직과 상태를 캡슐화했으며, 이는 몇 가지 명확한 이점을 가져왔습니다:
- **논리적 응집**: WebRTC 연결과 관련된 모든 상태와 메서드가 `useWebRTCConnection`에 있어 유지보수가 극도로 쉽습니다.
- **순수 컴포넌트**: React 컴포넌트는 복잡한 비즈니스 로직에서 해방되어 UI 렌더링이라는 본질적인 역할로 돌아갑니다.
- **명확한 계층화**: 이것은 `app` (라우팅) -> `components` (UI) -> `hooks` (로직) -> `lib` (저수준 기능)의 명확한 데이터 흐름과 의존 관계를 만들어 코드베이스의 유지보수성을 크게 향상시킵니다.
### 2.3 백엔드 아키텍처: 무상태성과 효율성의 예술
Node.js와 Express 기반의 백엔드는 설계에서 엄격하게 **무상태(Stateless)** 원칙을 따릅니다.
서버 자체는 방이나 사용자와 관련된 상태를 유지하지 않습니다. 모든 상태는 **Redis**에 위임됩니다. 이를 통해 백엔드 애플리케이션을 매우 쉽게 수평적으로 확장할 수 있습니다.
저희는 또한 비즈니스 요구를 충족시키기 위해 Redis의 다양한 데이터 구조를 교묘하게 활용했습니다:
- **Hash**: 방 메타데이터를 저장하기 위해
- **Set**: 방 내 모든 멤버의 `socketId`를 저장하여 고유성 보장
- **String**: `socketId`를 `roomId`로 역매핑하여 사용자 연결 끊김 시 빠른 정리 용이화
- **Sorted Set**: IP 기반 속도 제한을 구현하여 악의적인 공격 효과적으로 방지
모든 키는 합리적인 TTL(Time To Live)로 설정되어 자동 리소스 정리를 보장하고 시스템이 장기적으로 안정적으로 실행되도록 합니다.
### 2.4 "프로덕션급" 고려사항: 배포에서 보안까지
저희는 포괄적인 프로덕션 배포 계획을 제공합니다:
- **Nginx**를 리버스 프록시 및 SSL 종료로 사용
- **PM2**를 통한 Node.js 프로세스 관리
- **Certbot**을 통한 SSL 인증서 자동 획득 및 갱신
- 복잡한 NAT 통과가 필요한 시나리오를 위한 포괄적인 **TURN/STUN** 서버 설정 가이드
이 모든 것은 PrivyDrop이 신뢰할 수 있고 프로덕션 환경에 배포할 수 있는 심각한 프로젝트임을 보여줍니다.
## 3부: 코드를 넘어서: 미래를 함께 만들기 위한 초대
오픈소스는 시작일 뿐입니다. 저희는 PrivyDrop을 위해 흥미로운 미래를 계획하고 있으며, 이제 당신이 저희와 함께하기를 원합니다.
### 3.1 프로젝트 로드맵
저희는 미래의 우선순위를 개요하는 공개 [<u>**프로젝트 로드맵**</u>](https://github.com/david-bai00/PrivyDrop/blob/main/ROADMAP.md)을 가지고 있습니다. 미래에 다음과 같이 많은 요청이 있었던 기능들을 추가할 계획입니다:
- **재개 가능한 전송**: 매우 큰 파일과 불안정한 네트워크 상황을 처리하기 위해
- **E2E 암호화 그룹 채팅**: 안전한 P2P 통신을 다중 사용자 텍스트 채팅으로 확장
- 기타 미정 기능
### 3.2 기여하는 방법?
저희는 모든 형태의 기여를 환영합니다! 당신이 누구든, PrivyDrop을 더 나게 만드는 방법이 항상 있습니다. 당신의 여정을 시작하기 위해 저희의 [<u>**기여 가이드라인**</u>](https://github.com/david-bai00/PrivyDrop/blob/main/.github/CONTRIBUTING.md)을 읽어주세요.
- **사용자**: 제품을 사용하고, [GitHub Issues](https://github.com/david-bai00/PrivyDrop/issues)를 통해 버그를 보고하고 기능을 제안하세요
- **개발자**: 버그를 담당하고, 새로운 기능을 구현하거나, 기존 코드의 일부를 리팩토링하세요
- **문서 작성자/번역가**: 문서 개선을 도와주시거나 PrivyDrop을 더 많은 언어로 번역해주세요
### 3.3 강력한 행동 촉구
- **사용자**: 지금 바로 PrivyDrop을 경험하고 궁극의 개인정보 보호와 편리함을 느껴보세요!
[**➡️ 지금 바로 사용해보세요**](https://www.privydrop.app/)
- **개발자**: PrivyDrop의 철학이나 기술에 감명받았다면, 저희 GitHub 저장소에 Star를 주세요! 이것은 저희에게 가장 큰 인정과 격려입니다.
[**⭐️ GitHub에서 Star 주기**](https://github.com/david-bai00/PrivyDrop)
- **모두**: 저희 커뮤니티 토론에 참여하여 당신의 목소리를 들려주세요!
## 결론
이 이야기를 읽는 시간을 내어주셔서 다시 한번 감사드립니다.
PrivyDrop의 이야기는 한 사람의 필요로 시작되었고, 그 미래가 커뮤니티에 의해 쓰여지기를 기대합니다.
@@ -0,0 +1,102 @@
---
title: "Neuer Meilenstein für PrivyDrop: Wiederaufnehmbare Übertragungen beenden die GroßdateiAngst"
description: "Großes Update: Mit wiederaufnehmbaren Übertragungen meisterst du Netzwerkabbrüche gelassen und sendest GigabyteDateien souverän. So funktionierts und so vereinen wir maximale Zuverlässigkeit mit Privatsphäre."
date: "2025-08-01"
author: "david bai"
cover: "/blog-assets/privydrop-resumable-transfer.jpg"
tags: ["Neue Funktion", "Wiederaufnehmbare Übertragungen", "Dateiübertragung", "Privatsphäre", "Open Source"]
status: "published"
---
![](/blog-assets/privydrop-resumable-transfer.jpg)
## Einleitung: der letzte Schritt von „nutzbar“ zu „liebgewonnen“
In früheren Artikeln haben wir [<u>**den OpenSourceGeist von PrivyDrop**</u>](/blog/privydrop-open-source) und die Grundlage [<u>**WebRTC**</u>](/blog/webrtc-file-transfer) vorgestellt. Herausgekommen ist ein sicheres, privates, P2Pbasiertes Werkzeug zum Dateien­teilen.
Doch Wert entsteht nicht nur dadurch, dass etwas „funktioniert“, sondern dadurch, wie es sich im Alltag anfühlt. Stell dir vor: Im Café mit wackeligem WLAN schickst du ein dringendes 4GBVideo. Die Leiste kriecht auf 95% dann ein Abbruch.
Dieses Gefühl, im letzten Moment zu scheitern, kennen wir alle.
Heute machen wir den Schritt zu Ende: Wir veröffentlichen **wiederaufnehmbare Übertragungen** und beenden die „TransferAngst“ endgültig.
## Das Kernstück: Wie funktioniert die smarte Wiederaufnahme?
Bevor wir zur Anwendung kommen: Wie „merkt“ sich PrivyDrop den Fortschritt?
### Das Prinzip: Wie entsteht diese „Erinnerung“?
Denke an ein riesiges digitales Puzzle:
1. **Den „Bauplan“ austauschen**: Vor dem Start sendet der Sender einen Bauplan (Metadaten: Name, Gesamtgröße, ChunkInfo). Der Empfänger sieht das große Ganze.
2. **Nummerierte Puzzleteile**: Die Datei wird in kleine, nummerierte Stücke zerlegt. Jedes Teil landet an der vorgesehenen Stelle.
3. **Schlauer Kassensturz nach Abbruch**: Bei Unterbrechung behält der Empfänger alle Teile. Nach dem Neuverbinden prüft er: „#1 bis #5000 habe ich bitte ab #5001 weiter.“
Dieses „Inventar und Anfordern“ ist der Kern: Kein Neustart von vorn, deutlich höhere Effizienz und Zuverlässigkeit.
### Praxisleitfaden: richtig reibungslos wiederaufnehmen
So setzt du es alltagstauglich ein.
**Schritt 1: „Sicherheitsmodus“ aktivieren (Empfänger)**
Vor dem Empfang klicke **„Speicherverzeichnis festlegen“**. Damit sagst du: „Gib der Datei ein sicheres Zuhause.“ Erst dann ist die Wiederaufnahme aktiv.
**Schritt 2: Wenn es unterbrochen wird**
Netzschwankungen, Tab versehentlich geschlossen, Rechner schläft ein … keine Panik. Dein Fortschritt bleibt bestehen.
**Schritt 3: Doppelte Absicherung**
PrivyDrop bietet **zwei Schutzmechanismen**:
**Aktiver Schutz: „Sicher speichern“‑Button**
Während der Übertragung siehst du den grünen **„Sicher speichern“**Knopf unser Rettungsboot für stürmische Netze.
- **Wann sichtbar**: Nach Wahl des Speicherorts, immer während aktiver Übertragung.
- **Wann nutzen**: Netz wird zäh? Droht ein Abbruch? Musst du kurz weg? Jederzeit klicken.
- **Wirkung**: Bereits empfangene Fragmente sofort auf die Platte schreiben bereit für den nächsten Anlauf.
**💡 Tipp**: Keine Zeit zum Klicken? Schließen oder Neuladen der Seite hat den gleichen Schutzeffekt der ExitSchutz speichert automatisch.
**Passiver Schutz: ExitSchutzmechanismus**
Zusätzlich greift der **ExitSchutzmechanismus** beim Schließen/Reload: **Schreibströme werden sauber beendet und empfangene Daten finalisiert**. Aus der temporären Datei wird ein stabiler „Speicherpunkt“.
Normalerweise wirft der Browser unfertige temporäre Dateien weg, wenn man eine aktive Seite schließt. PrivyDrop lässt deinen Einsatz nicht verpuffen.
**Schritt 4: Nahtlose Wiederaufnahme**
Betritt mit dem Sender erneut denselben Raum und starte die Übertragung. Die Leiste springt an die alte Position und läuft gelassen weiter. Voilà.
## Von der „Einzelaufgabe“ zur „laufenden Zusammenarbeit“: dein privater Datenkanal
Nach der Zuverlässigkeit kommt die Natürlichkeit. Statt „jedes Mal von vorn“ bietet PrivyDrop einen **persistenten privaten Datenkanal**.
Sobald ihr verbunden seid, wird der Raum zum gemeinsamen „Büro“. Inhalte teilt ihr fortlaufend, ohne den Zyklus „Raum erstellen, Link teilen“ ständig zu wiederholen.
1. Du sendest zunächst einen erklärenden Text aus der Zwischenablage.
2. Danach ziehst du ein Mockup (PNG) rüber.
3. Direkt im Anschluss eine Mappe mit allen Assets.
So fließend wie ein Gespräch am Schreibtisch nebenan. Aus einem „EinmalTool“ wird ein **leichtgewichtiger, hochprivater EchtzeitKollaborationsraum**.
## Wenn „Zuverlässigkeit“ auf „Privatsphäre“ trifft
Kostet die neue Stärke Privatsphäre?
**Nein.** Wir bauen Funktionen auf einem Fundament aus Privacy & Security.
- **Wiederaufnahme & EndezuEndeVerschlüsselung**: Auch Fragmente sind per DTLS EndezuEnde verschlüsselt Browser zu Browser. Der Server kann nichts einsehen, zusammensetzen oder speichern.
- **„Unbegrenzte Größe“ × „Wiederaufnahme“**: Unser GoldDuo. Größenlimit frei ist das Versprechen; Wiederaufnahme ist die Versicherung. Ganze Festplatten oder große Datasets? Nur zu ohne Angst, Fortschritt zu verlieren.
- **OpenSourceVertrauen**: Nicht nur Behauptung, sondern Einsicht in den Code: Alles liegt offen im [<u>**GitHubRepository**</u>](https://github.com/david-bai00/PrivyDrop).
## Fazit: Willkommen zur „furchtlosen“ Übertragung
PrivyDrop ist mehr als ein Werkzeug es wird dein verlässlicher Partner für persönliche und berufliche Daten.
Besuche [<u>**privydrop.app**</u>](https://www.privydrop.app), schnapp dir die größte Datei und **aktualisiere die Seite absichtlich während der Übertragung**. Spüre, wie beruhigend „Wiederfinden“ ist.
Und wenn dich robuste, verlässliche Tools begeistern, gib uns einen Stern auf [<u>**GitHub**</u>](https://github.com/david-bai00/PrivyDrop). Danke für deinen Rückenwind!
@@ -0,0 +1,102 @@
---
title: "Nuevo hito de PrivyDrop: transferencias reanudables para decir adiós a la ansiedad de los archivos gigantes"
description: "PrivyDrop se renueva: la nueva transferencia reanudable te permite afrontar cortes de red y mover archivos de varios gigas sin miedo. Descubre cómo funciona y cómo unimos fiabilidad extrema y privacidad."
date: "2025-08-01"
author: "david bai"
cover: "/blog-assets/privydrop-resumable-transfer.jpg"
tags: ["Nueva función", "Transferencias reanudables", "Transferencia de archivos", "Privacidad", "Código abierto"]
status: "published"
---
![](/blog-assets/privydrop-resumable-transfer.jpg)
## Introducción: el último tramo de “usable” a “disfrutable”
En artículos anteriores presentamos [<u>**el espíritu de código abierto de PrivyDrop**</u>](/blog/privydrop-open-source) y la tecnología que lo sostiene, [<u>**WebRTC**</u>](/blog/webrtc-file-transfer). Construimos una herramienta de intercambio de archivos segura y privada, basada en P2P.
Pero el valor real no está solo en que “funcione”, sino en cómo se siente en el mundo real. Imagina que, con el WiFi inestable de una cafetería, envías un vídeo de 4 GB urgente a un cliente. La barra sube… 95%… y de pronto, la red cae.
Esa punzada de frustración al final, la conocemos bien.
Hoy nos alegra anunciar que PrivyDrop ha recorrido ese último tramo. Lanzamos oficialmente las **transferencias reanudables**, para poner fin a tu “ansiedad de transferencia”.
## La pieza clave: ¿cómo funcionan las transferencias reanudables inteligentes?
Antes de usarlas, quizá te preguntes: ¿cómo “recuerda” PrivyDrop el progreso?
### El principio al desnudo: ¿cómo se logra esa “memoria”?
Nos gusta compararlo con montar un gran rompecabezas digital:
1. **Intercambiar el “plano”**: Antes de empezar, el emisor envía un “plano” del archivo (metadatos: nombre, tamaño total, información de fragmentos). El receptor entiende el cuadro completo.
2. **Piezas numeradas**: El archivo se divide en pequeños fragmentos numerados. El receptor coloca cada pieza en su lugar según llega.
3. **Inventario inteligente tras la interrupción**: Si se corta, el receptor conserva las piezas recibidas. Al reconectar, revisa el plano y le dice al emisor: “tengo la #1 a la #5000, empieza por la #5001”.
Este mecanismo de “inventario y petición” evita empezar de cero y eleva la eficiencia y fiabilidad en archivos grandes.
### Guía práctica: cómo dominar la reanudación
Veamos cómo aprovecharla de verdad.
**Paso 1: Activa el “modo seguro” (receptor)**
Antes de recibir, pulsa **“Establecer carpeta de guardado”**. Es crucial: le dices a PrivyDrop “prepara un hogar seguro para el archivo que llega”. Así se activa la reanudación.
**Paso 2: Cuando haya una interrupción**
Variaciones de red, cerrar la pestaña por error, suspensión del equipo… cuando pase, calma. Tu progreso está a salvo.
**Paso 3: Doble protección, tranquilidad real**
PrivyDrop ofrece **dos capas de protección**:
**Protección activa: botón "Guardado seguro"**
Mientras se transfiere, verás un botón verde **“Guardado seguro”**. Es el “bote salvavidas” para mares agitados.
- **Cuándo aparece**: Tras elegir la carpeta de guardado, estará ahí siempre que haya transferencia en curso.
- **Cuándo usarlo**: ¿La red va pesada? ¿Temes un corte? ¿Te ausentas un momento? Haz clic cuando quieras.
- **Qué hace**: Vuelca inmediatamente los fragmentos recibidos al disco y deja todo listo para reanudar.
**💡 Consejo**: Si no te da tiempo a pulsarlo, cerrar o recargar la página activa el mismo resguardo: el mecanismo de salida guarda el progreso automáticamente.
**Protección pasiva: Mecanismo de Protección al Salir**
Además, el **Mecanismo de Protección al Salir** intercepta el cierre/recarga y actúa como un archivero responsable: **cierra con elegancia la escritura en disco y consolida los datos ya recibidos**. El archivo temporal se convierte en un “punto de guardado” listo para la siguiente sesión.
Normalmente, si cierras una página con una descarga activa, el navegador puede descartar el temporal y perder datos. Con PrivyDrop, tu esfuerzo no se desperdicia.
**Paso 4: Reanudación sin costuras**
Vuelve con el emisor a la misma sala y reinicia la transferencia. Verás la barra saltar al punto exacto y seguir su camino. Eso es reanudar.
## De la “tarea única” a la “colaboración continua”: tu canal de datos privado
Resuelta la fiabilidad, queríamos que el intercambio fuera natural. Las herramientas tradicionales son “una y otra vez”. PrivyDrop ofrece un **canal privado persistente**.
Una vez conectados, la sala es vuestro “despacho” compartido. Compartid contenido sin repetir “crear sala, compartir enlace”.
1. Envías un texto por el portapapeles.
2. Al recibirlo, arrastras un mockup (PNG).
3. Al terminar, arrastras una carpeta con todos los recursos.
El flujo es conversacional, como con un colega al lado. Así, PrivyDrop deja de ser un “pasador de archivos” para ser un **espacio de colaboración ligero y privado en tiempo real**.
## Cuando la “fiabilidad” se da la mano con la “privacidad”
¿Todo esto sacrifica la privacidad?
**En absoluto**. Cada función se construye sobre privacidad y seguridad.
- **Reanudación + cifrado extremo a extremo**: Incluso los fragmentos para reanudar viajan cifrados (DTLS) y van de navegador a navegador. El servidor no puede inspeccionar, recomponer ni almacenar nada.
- **“Tamaño ilimitado” × “reanudación”**: Nuestra combinación dorada. Sin límite de tamaño es la promesa; la reanudación es el seguro que la cumple. Copias un disco entero o mueves datasets enormes sin miedo a perder avance.
- **Confianza de código abierto**: No solo lo decimos: lo mostramos. Cada línea está en [<u>**GitHub**</u>](https://github.com/david-bai00/PrivyDrop), abierta al escrutinio.
## Cierre: te invitamos a una transferencia “sin miedo”
PrivyDrop ya no es solo una herramienta: es un aliado para tus datos, inmune a los sobresaltos.
Entra en [<u>**privydrop.app**</u>](https://www.privydrop.app), elige tu archivo más grande y **recarga a propósito a mitad de camino**. Siente la calma de recuperar el terreno.
Si te entusiasma crear herramientas ultraseguras y fiables, déjanos una estrella en [<u>**GitHub**</u>](https://github.com/david-bai00/PrivyDrop). ¡Tu apoyo nos impulsa!
@@ -0,0 +1,102 @@
---
title: "Nouveau cap pour PrivyDrop : des transferts reprenables pour en finir avec langoisse des gros fichiers"
description: "PrivyDrop s’étoffe : la reprise de transfert permet daffronter les coupures réseau et denvoyer des fichiers de plusieurs Go sans crainte. Voici le fonctionnement et lalliance fiabilité/confidentialité."
date: "2025-08-01"
author: "david bai"
cover: "/blog-assets/privydrop-resumable-transfer.jpg"
tags: ["Nouvelle fonctionnalité", "Transferts reprenables", "Transfert de fichiers", "Confidentialité", "Open source"]
status: "published"
---
![](/blog-assets/privydrop-resumable-transfer.jpg)
## Introduction : le dernier pas de « utilisable » à « agréable »
Nous avons déjà présenté [<u>**lesprit opensource de PrivyDrop**</u>](/blog/privydrop-open-source) et la technologie au cœur du projet, [<u>**WebRTC**</u>](/blog/webrtc-file-transfer). Nous avons bâti un partage de fichiers sûr et privé, en P2P.
Mais la valeur dun outil ne tient pas qu’à « ça marche », elle tient à la sensation dusage. Imagine : au café, WiFi capricieux, tu envoies un rendu vidéo de 4Go. La barre grimpe à 95%… et la connexion lâche.
Cette défaite à la dernière seconde, on la tous vécue.
Aujourdhui, nous franchissons ce pas final : nous lançons les **transferts reprenables**, pour mettre un terme à langoisse des transferts.
## Larme maîtresse : comment fonctionne la reprise intelligente ?
Avant la prise en main : comment PrivyDrop « se souvientil » de lavancée ?
### Le principe : doù vient cette « mémoire » ?
Pense à un gigantesque puzzle numérique :
1. **Échanger le « plan »** : Avant denvoyer, lexpéditeur partage un plan (métadonnées : nom, taille, fragments). Le destinataire voit lensemble.
2. **Des pièces numérotées** : Le fichier est découpé en petits morceaux numérotés. Chaque pièce est posée à sa place.
3. **Inventaire malin après coupure**: À linterruption, le destinataire garde ses pièces. À la reconnexion, il vérifie et dit : « jai #1 à #5000, reprenons à #5001 ».
Ce mécanisme « inventaire + requête » évite le redémarrage et booste efficacité et fiabilité sur les gros volumes.
### Guide pratique : bien utiliser la reprise
Passons au concret.
**Étape 1 : activer le « mode sûr » (réception)**
Avant de recevoir, clique **« Définir le dossier de sauvegarde »**. Tu indiques à PrivyDrop : « prépare un foyer sûr ». La reprise sactive alors.
**Étape 2 : quand une coupure survient**
Réseau fluctuant, onglet fermé, mise en veille… garde ton calme. La progression est préservée.
**Étape 3 : double protection pour lesprit tranquille**
PrivyDrop offre **deux protections** :
**Protection active : bouton "Enregistrement sécurisé"**
Pendant lenvoi, un bouton vert **« Enregistrement sécurisé »** apparaît. Notre canot de sauvetage pour mers agitées.
- **Quand il saffiche** : après choix du dossier, tant quun transfert est en cours.
- **Quand cliquer** : réseau lent ? crainte dune coupure ? besoin de sabsenter ? à tout moment.
- **Effet** : écrit immédiatement les fragments reçus sur le disque, prêt pour la reprise.
**💡 Astuce** : Pas le temps de cliquer ? Fermer ou recharger la page déclenche la même protection : le mécanisme de sortie sauvegarde automatiquement.
**Protection passive : mécanisme de protection à la fermeture**
En plus, le **mécanisme de protection à la fermeture** intercepte la sortie : **flux d’écriture soigneusement fermés, données déjà reçues finalisées**. Le temporaire devient un « point de sauvegarde » solide.
En temps normal, fermer une page en téléchargement peut faire perdre le fichier temporaire. Avec PrivyDrop, rien nest perdu.
**Étape 4 : reprise sans couture**
Reviens avec lexpéditeur dans la même salle et relance. La barre saute au bon endroit et continue paisiblement. Cest la magie de la reprise.
## De la « tâche unique » à la « collaboration continue » : ton canal privé
Après la fiabilité, la fluidité. Plutôt que « recommencer à chaque fois », PrivyDrop propose un **canal de données privé persistant**.
Une fois connectés, la salle devient votre « bureau » partagé. Partage en continu, sans recréer/renvoyer le lien.
1. Tu envoies un texte par le pressepapiers.
2. Puis tu déposes une maquette (PNG).
3. Ensuite un dossier avec tous les assets.
Le tout se déroule comme une conversation. PrivyDrop passe dun « utilitaire ponctuel » à un **espace de collaboration léger, privé et temps réel**.
## Quand « fiabilité » rime avec « confidentialité »
Ces nouveautés coûtentelles la vie privée ?
**Absolument pas.** Tout repose sur la confidentialité et la sécurité.
- **Reprise + chiffrement de bout en bout** : Même fragmenté, tout transite chifré (DTLS) de navigateur à navigateur. Le serveur ne voit, ne recompose, ni ne stocke rien.
- **« Taille illimitée » × « reprise »** : Notre duo doré. Labsence de limite est la promesse ; la reprise, lassurance qui la tient. Disque entier ou dataset massif, sans peur de perdre le terrain.
- **Confiance opensource** : Nous montrons notre code. Tout est public dans [<u>**GitHub**</u>](https://github.com/david-bai00/PrivyDrop).
## Conclusion : on tinvite à un transfert « sans peur »
PrivyDrop nest plus un simple outil : cest lallié fiable de tes données.
Visite [<u>**privydrop.app**</u>](https://www.privydrop.app), choisis ton plus gros fichier et **actualise exprès en plein transfert**. Ressens la sérénité du « retrouvé ».
Si bâtir des outils ultrafiables te parle, offrenous une étoile sur [<u>**GitHub**</u>](https://github.com/david-bai00/PrivyDrop). Merci pour ton soutien !
@@ -0,0 +1,102 @@
---
title: "PrivyDropの新たな到達点——中断しても続く転送で、大容量の不安にさよなら"
description: "PrivyDropが大幅アップデート。中断後にそのまま再開できるレジューム転送で、ネット切断を恐れずギガ級ファイルを軽やかに。仕組みと、最高の信頼性とプライバシーをどう両立したかを解説。"
date: "2025-08-01"
author: "david bai"
cover: "/blog-assets/privydrop-resumable-transfer.jpg"
tags: ["新機能", "レジューム転送", "ファイル転送", "プライバシー", "オープンソース"]
status: "published"
---
![](/blog-assets/privydrop-resumable-transfer.jpg)
## はじめに:「使える」から「気持ちよく使える」へ、最後の1マイル
これまでの記事では、[<u>**PrivyDrop のオープンソース精神**</u>](/blog/privydrop-open-source) と、その中核にある [<u>**WebRTC**</u>](/blog/webrtc-file-transfer) を紹介してきました。私たちは、ピア・ツー・ピア(P2P)に基づく安全でプライベートなファイル共有ツールを形にしました。
けれど本当の価値は、「使える」ことの先にあります。たとえば、カフェの不安定な Wi‑Fi で、クライアントへ緊急の 4GB 動画を送っているとき。進捗が 95% を超えた瞬間……通信が途切れる——。
あの、最後の一押しで落ちる胸の底の冷たさ。誰もが一度は味わったはずです。
今日、PrivyDrop はその最後の1マイルを越えました。**レジューム転送(中断後の再開)**を正式に搭載し、「転送不安」と決別します。
## 切り札:賢いレジュームはどう動く?
使い方の前に少しだけ。PrivyDrop はどうやって転送の途中経過を「覚えて」いるのでしょう?
### 原理をひもとく:レジュームの「記憶」はこうして成り立つ
巨大なデジタルのジグソーパズルを組む、と想像してください。
1. **「設計図」を先に交換**: 転送開始前、送信側はファイルの「設計図」(メタデータ:名前・総サイズ・チャンク情報など)を受信側へ送ります。受信側は全体像を先に掴みます。
2. **番号付きの「ピース」を受け取る**: ファイルは小さなデータ片に分割され、番号順に送られます。受信側は受け取るたび、設計図の相応の位置にピースをはめ込みます。
3. **中断後は「スマート棚卸し」**: もし中断しても、受信側は集めたピースを保持。再接続時に設計図で在庫を確認し、「1〜5000 は持っているので、5001 からお願い」と送信側へ伝えます。
この「棚卸しと要求」の仕組みこそ、レジューム転送の核。最初からやり直す無駄を取り除き、大容量でも腰の据わった信頼性を実現します。
### 実践ガイド:レジューム転送を正しく使いこなす
ここからは、現場での活かし方です。
**ステップ 1:『セーフモード』を有効化(受信側)**
受信前に必ず **「保存先フォルダを設定」** をクリックしてください。これは「これから来るファイルの安全な居場所を用意してね」という合図。ここまでして初めて、レジューム転送が起動します。
**ステップ 2:中断が起きたら**
回線の揺らぎ、誤ってタブを閉じる、PC のスリープ……中断は起こるもの。慌てずに。進捗は守られています。
**ステップ 3:二つの保険でスマートに守る**
PrivyDrop は **二重の保護** を用意しています。
**能動的保護:「安全保存」ボタン**
送受信中、画面に緑色の **「安全保存」** ボタンが現れます。ネットが不安定なときのための「救命ボート」です。
- **いつ出るか**: 保存先フォルダを設定後、転送が走っている間は常に表示されます。
- **いつ使うか**: 回線が怪しい? これから離席? 不安を感じたらいつでも。
- **何が起きるか**: 受信済みの断片をすぐにディスクへ確定保存。次回の再開に備えます。
**💡 プロのコツ**: 「安全保存」を押す余裕がないときは、ページを閉じる/更新するだけでも同等の保護が働きます。退出時の保護が自動で進捗を確定します。
**受動的保護:退出保護メカニズム**
加えて、**退出保護メカニズム** を備えています。ページを閉じる/更新する瞬間をフックし、**ディスクへの書き込みストリームを丁寧に閉じ、受信済みデータをきちんと確定**。一時ファイルを、次回再開のための安定した「セーブポイント」に変換します。
通常ならアクティブなダウンロードを抱えたページを突然閉じると、ブラウザは未完の一時ファイルを捨てがち。PrivyDrop は、その努力を無駄にしません。
**ステップ 4:シームレスに再開**
同じ部屋にもう一度入り直して転送を開始するだけ。進捗バーが途中からスッと復帰して、あとは着実に伸び続けます。これがレジュームの魔法です。
## 「単発の作業」から「続くコラボ」へ:あなた専用のデータチャネル
大容量転送の信頼性を解決した私たちは、体験そのものも直感的にしました。多くのツールは「1件送ったらまた最初から」。PrivyDrop は **持続的なプライベート・データチャネル** を提供します。
一度つながれば、その部屋は二人だけの「小さなオフィス」。リンク共有を何度もやり直す必要はありません。
1. まずはテキストをクリップボードで送る。
2. 相手が受け取ったら、デザインモック(PNG)をドラッグ&ドロップ。
3. 送り終えたら、そのままアセット一式の入ったフォルダを投入。
まるで隣の同僚と会話するみたいに、流れるように。**一度きりのツール** から **軽やかでプライベートなリアルタイム協働空間** へ。PrivyDrop はその境界をまたぎます。
## 「信頼性」と「プライバシー」が握手するとき
強い機能は、プライバシーの犠牲で成り立つのか?
答えは **いいえ**。私たちの設計は常に、プライバシーとセキュリティを土台にしています。
- **レジューム転送 × エンドツーエンド暗号化**: 分割されたチャンクであっても、DTLS によるエンドツーエンド暗号化でブラウザ間を直接転送。サーバーが中身を覗く・つなぎ直す・保存する余地はありません。
- **「サイズ無制限」×「レジューム」**: 私たちの「黄金コンボ」。サイズ無制限という約束を、レジュームが現実のものにします。丸ごと HDD をバックアップする——そんな大胆さにも伴走します。
- **オープンソースの自信**: 口約束ではなく、実装で示す。レジュームや継続転送のコードはすべて [<u>**GitHub リポジトリ**</u>](https://github.com/david-bai00/PrivyDrop) で公開。世界中の開発者の目にさらされています。
## 結び:「恐れない転送」をあなたに
PrivyDrop は、ただのツールを越え、あなたの個人・仕事のデータを託せる相棒へ。
今すぐ [<u>**privydrop.app**</u>](https://www.privydrop.app) を開いて、PC の一番大きなファイルを選び、**あえて途中でページを更新** してみてください。「取り戻せる」安心が、きっと手触りになります。
もし私たちと同じ熱量で、強固で信頼できるツールづくりにワクワクしてくれるなら、[<u>**GitHub**</u>](https://github.com/david-bai00/PrivyDrop) に Star を。あなたの一票が、前進のエネルギーになります。
@@ -0,0 +1,103 @@
---
title: "PrivyDrop의 새로운 이정표: 중단 후 재개 전송으로 대용량 전송 불안 끝"
description: "네트워크 끊김에도 끄떡없는 재개 가능한 전송으로 기가바이트급 파일을 가볍게. 동작 원리와 극한의 신뢰성·프라이버시의 조화를 소개합니다."
date: "2025-08-01"
author: "david bai"
cover: "/blog-assets/privydrop-resumable-transfer.jpg"
tags: ["신규 기능", "재개 가능한 전송", "파일 전송", "프라이버시", "오픈 소스"]
status: "published"
---
![](/blog-assets/privydrop-resumable-transfer.jpg)
## 도입: “쓸 수 있음”에서 “기분 좋게 씀”까지, 마지막 한 걸음
이전 글에서 [<u>**PrivyDrop의 오픈 소스 정신**</u>](/blog/privydrop-open-source)과 그 근간이 되는 [<u>**WebRTC**</u>](/blog/webrtc-file-transfer)를 소개했습니다. 우리는 P2P 기반의 안전하고 프라이빗한 파일 공유 도구를 만들었습니다.
하지만 진짜 가치는 “된다”를 넘어서 “잘 된다”에 있습니다. 카페의 불안정한 Wi‑Fi로 4GB 긴급 영상을 보내는 상황을 떠올려 보세요. 진행률 95%… 그 순간 연결이 뚝 끊깁니다.
막판에 미끄러지는 그 허탈함, 잘 압니다.
오늘, PrivyDrop은 그 마지막 한 걸음을 채웠습니다. **중단 후 재개 전송**을 공식 제공하여, 전송 불안을 끝냅니다.
## 비밀 병기: 똑똑한 재개 전송은 어떻게 동작할까?
사용법으로 들어가기 전, PrivyDrop은 어떻게 진행 상황을 “기억”할까요?
### 원리: 재개의 “기억”은 이렇게 만들어진다
거대한 디지털 퍼즐을 맞춘다고 상상해 보세요.
1. **“설계도”를 먼저 교환**: 시작 전에, 보낸 쪽이 파일의 설계도(메타데이터: 이름, 전체 크기, 청크 정보 등)를 보냅니다. 받은 쪽은 전체 그림을 미리 파악합니다.
2. **번호 붙은 퍼즐 조각**: 파일은 잘게 나뉘어 번호가 매겨진 조각으로 전송됩니다. 받은 쪽은 조각을 설계도의 정확한 위치에 채워 넣습니다.
3. **중단 후의 “스마트 재고 확인”**: 끊기면 받은 쪽은 이미 받은 조각을 그대로 보관합니다. 재연결되면 설계도로 대조하고 “#1~#5000은 있으니 #5001부터 보내 주세요”라고 요청합니다.
이 “재고 확인 + 요청” 메커니즘이 재개 전송의 핵심입니다. 처음부터 다시 시작하는 낭비를 없애고, 대용량에서도 단단한 신뢰성을 보장합니다.
### 실전 가이드: 올바르게 재개를 누리는 법
이제 현장에서의 사용 팁입니다.
**1단계: ‘안전 모드’ 활성화(수신자)**
받기 전에 반드시 **“저장 디렉터리 설정”**을 클릭하세요. “들어올 파일의 안전한 집을 준비해”라는 신호입니다. 이 과정을 거쳐야 재개 기능이 활성화됩니다.
**2단계: 중단이 발생했을 때**
네트워크 흔들림, 실수로 탭을 닫음, 컴퓨터 절전… 일어나기 마련입니다. 침착하세요. 진행 상황은 안전합니다.
**3단계: 이중 보호로 더 든든하게**
PrivyDrop은 **이중 보호 장치**를 제공합니다.
**능동 보호: “안전 저장” 버튼**
전송 중에는 녹색 **“안전 저장”** 버튼이 보입니다. 불안정한 네트워크를 위한 우리의 구명보트입니다.
- **언제 보이나**: 저장 디렉터리를 설정한 뒤, 전송 중에는 항상 표시됩니다.
- **언제 누르나**: 네트워크가 버벅이나요? 끊길 것 같나요? 잠시 자리 비우나요? 언제든지.
- **무엇을 하나**: 수신된 조각을 즉시 디스크에 안전하게 기록하여 다음 재개를 준비합니다.
**💡 팁**: “안전 저장”을 누를 시간이 없어도, 페이지를 닫거나 새로고침하면 동일한 보호가 작동합니다. 종료 보호 메커니즘이 자동으로 저장합니다.
**수동 보호: 종료 보호 메커니즘**
또한 **종료 보호 메커니즘**이 있어, 페이지 종료/새로고침을 가로채고 **디스크 쓰기 스트림을 우아하게 닫아 수신 데이터를 확정**합니다. 임시 파일이 다음 재개를 위한 안정적인 “세이브 포인트”로 바뀝니다.
보통은 활성 다운로드 중 페이지를 닫으면 임시 파일이 버려질 수 있지만, PrivyDrop은 당신의 노력을 허공에 날리지 않습니다.
**4단계: 매끄러운 재개**
보내는 사람과 함께 같은 방에 다시 들어가 전송을 시작하세요. 진행률이 이전 지점으로 ‘툭’ 올라가고, 안정적으로 이어집니다. 이것이 재개의 마법입니다.
## “단발 작업”에서 “지속 협업”으로: 나만의 프라이빗 데이터 채널
신뢰성을 넘어, 흐름도 중요합니다. 많은 도구가 “하나 보내고 다시 처음부터”라면, PrivyDrop은 **지속되는 프라이빗 데이터 채널**을 제공합니다.
연결되면 방은 둘만의 “가상 오피스”가 됩니다. 매번 “방 만들기–링크 공유”를 반복할 필요가 없습니다.
1. 먼저 클립보드로 설명 텍스트를 보냅니다.
2. 수신 후, 디자인 목업(PNG)을 드래그 앤 드롭합니다.
3. 이어서 모든 에셋이 담긴 폴더를 바로 보냅니다.
바로 옆 동료와 대화하듯 매끈하게. 단발성 전송 도구를 넘어, **가볍고 고프라이버시의 실시간 협업 공간**으로 격상됩니다.
## “신뢰성”이 “프라이버시”와 만날 때
이 강력한 기능이 프라이버시를 희생할까요?
**전혀요.** 우리 설계의 토대는 언제나 프라이버시와 보안입니다.
- **재개 전송 × 종단 간 암호화**: 재개용 조각도 DTLS로 동일하게 암호화되어 브라우저 간 직접 전송됩니다. 서버는 내용을 볼 수도, 재조립할 수도, 저장할 수도 없습니다.
- **“크기 제한 없음” × “재개”**: 우리의 황금 조합. “무제한” 약속을 재개가 지켜 줍니다. 하드디스크 전체 백업이나 거대한 데이터셋 전송도, 진행분 유실 걱정 없이.
- **오픈 소스의 자신감**: 말이 아니라 코드로 증명합니다. 재개·연속 전송의 모든 코드는 [<u>**GitHub 저장소**</u>](https://github.com/david-bai00/PrivyDrop)에 공개되어 있습니다.
## 맺음말: “두렵지 않은 전송”으로의 초대
PrivyDrop은 도구를 넘어, 개인과 업무 데이터를 맡길 수 있는 믿음직한 동반자가 됩니다.
지금 [<u>**privydrop.app**</u>](https://www.privydrop.app)에 접속해, 가장 큰 파일을 골라 **일부러 전송 도중 새로고침**해 보세요. “되찾는 안도감”을 직접 느껴 보세요.
초안정·고신뢰 도구 만들기에 마음이 뜬다면, [<u>**GitHub**</u>](https://github.com/david-bai00/PrivyDrop)에서 Star로 응원해 주세요. 큰 힘이 됩니다!
@@ -0,0 +1,277 @@
---
title: "Browser-zu-Browser-Direktverbindung: Enthüllung der Kerntechnologie des datenschutzorientierten Dateitransfers basierend auf WebRTC"
description: "Dieser Artikel taucht tief in die Kerntechnologien eines datenschutzorientierten Dateitransferwerkzeugs basierend auf WebRTC ein, einschließlich P2P-Übertragung, E2EE-Verschlüsselung und Stream-Multiplexing. Geeignet sowohl für Technikbegeisterte als auch für allgemeine Benutzer."
date: "2025-02-09"
author: "david bai"
cover: "/blog-assets/webrtc-file-transfer.jpg"
tags: [WebRTC, P2P-Übertragung, Datenschutz-Sicherheit]
status: "published"
---
![](/blog-assets/webrtc-file-transfer.jpg)
## Einleitung
Traditionelle Dateitransfermethoden stützen sich weitgehend auf Cloud-Speicher oder zentralisierte Server, was Bedenken hinsichtlich des Datenschutzes aufwirft und gleichzeitig Beschränkungen bei Upload-Größen und Geschwindigkeitsengpässen gegenübersteht. Unser Werkzeug nutzt WebRTC-Technologie, um direkte Gerät-zu-Gerät-Übertragungen zu ermöglichen und diese Herausforderungen effektiv zu bewältigen.
Unser entwickeltes Werkzeug ([<u>**PrivyDrop**</u>](https://www.privydrop.app)) zeichnet sich durch mehrere bemerkenswerte Merkmale aus:
- Direkte Gerät-zu-Gerät-Übertragung mittels WebRTC-Technologie, ohne Zwischenserver
- Ende-zu-Ende-Verschlüsselung (E2EE) zur Gewährleistung sicherer Datenübertragung
- Keine Registrierung erforderlich, sofortige Nutzung, Unterstützung für mehrere gleichzeitige Empfänger
- Unterstützung für verschiedene Datentypen einschließlich Text, Bilder, Dateien und Ordner
- Übertragungsgeschwindigkeit und Dateigröße nur begrenzt durch Netzwerkbandbreite und Speicherplatz zwischen Geräten
In diesem Artikel erkunden wir die technische Architektur, die Funktionsweise und warum dieses Werkzeug eine so sichere und effiziente Dateitransfererfahrung bieten kann. Ob Sie ein Technikbegeisterter oder ein allgemeiner Benutzer sind, Sie werden Einblicke darin gewinnen, wie WebRTC-Technologie den Dateitransfer revolutioniert.
## I. Neubewertung des Dateitransfers: Die architektonische Revolution von WebRTC
WebRTC (Web Real-Time Communication) ist ein offener Standard, der Echtzeitkommunikation zwischen Browsern unterstützt. Unser auf WebRTC basierendes Dateitransferwerkzeug besteht aus mehreren Kernkomponenten:
1. **Signalisierungsserver**: Koordiniert Verbindungen zwischen Geräten, ohne an der eigentlichen Datenübertragung teilzunehmen.
2. **P2P-Verbindung**: Direkte Gerät-zu-Gerät-Verbindungen ohne Eingreifen von Drittanbieterservern.
3. **E2EE-Verschlüsselung**: Alle Daten werden während der Übertragung mittels DTLS-Protokoll ende-zu-ende verschlüsselt.
### 1.1 Traditioneller Ansatz vs. WebRTC-Ansatz
| Merkmal | Traditionelle HTTP-Übertragung | WebRTC P2P-Übertragung |
| --- | --- | --- |
| Übertragungspfad | Client → Server → Client | Direkte Gerät-zu-Gerät-Verbindung |
| Latenz | Begrenzt durch zentrale Serverbandbreite | Nur begrenzt durch physische Netzwerkbandbreite |
| Dateigrößenlimit | Üblicherweise beschränkt | Nur begrenzt durch Speicherplatz |
| Datenschutz | Abhängig von Sicherheitsmaßnahmen des Anbieters | Zwingende Verschlüsselung durch DTLS-Protokoll |
### 1.2 Prozess der P2P-Verbindungsherstellung
```mermaid
sequenceDiagram
participant UserA as Benutzer A (Sender)
participant SignalingServer as Signalisierungsserver
participant UserB as Benutzer B (Empfänger)
UserA->>SignalingServer: (1) Raum erstellen und beitreten
activate SignalingServer
UserB->>SignalingServer: (2) Raum beitreten
SignalingServer-->>UserA: Benutzer B beigetreten benachrichtigen
UserA->>SignalingServer: (3) WebRTC-Aushandlungsinformationen senden (SDP/ICE)
SignalingServer-->>UserB: WebRTC-Aushandlungsinformationen weiterleiten (SDP/ICE)
UserB->>SignalingServer: (4) Mit WebRTC-Aushandlungsinformationen antworten (SDP/ICE)
SignalingServer-->>UserA: Antwort-WebRTC-Aushandlungsinformationen weiterleiten (SDP/ICE)
UserA-->>UserB: (5) P2P-Verbindung herstellen (DataChannel)
UserA->>UserB: (6) Datei-Chunk-Übertragung über DataChannel
```
**Prozess:**
1. Benutzer A erstellt einen Raum und tritt bei ihm ein, verbindet sich mit dem Signalisierungsserver.
2. Benutzer B tritt dem Raum bei und verbindet sich mit dem Signalisierungsserver.
3. Benutzer A leitet die WebRTC-Aushandlung mit Benutzer B ein (einschließlich SDP- und ICE-Informationen).
4. Benutzer B antwortet mit WebRTC-Aushandlungsinformationen, vervollständigt die P2P-Verbindungsherstellung.
5. Schließlich werden Dateien über DataChannel auf der P2P-Verbindung übertragen.
### 1.3 Die Leistungsmagie von SCTP (over DTLS & UDP)
WebRTCs **DataChannel** basiert auf dem **Stream Control Transmission Protocol (SCTP)**, das über **DTLS** und **UDP** läuft, und bietet drei wesentliche Vorteile gegenüber herkömmlichem TCP:
1. **Stream-Multiplexing (derzeit nicht genutzt)**: Datei-Chunks können parallel übertragen werden, was die Übertragungseffizienz verbessert.
2. **Kein Head-of-Line-Blocking**: Der Verlust eines einzelnen Chunks beeinflusst nicht den Gesamtfortschritt und gewährleistet die Übertragungsstabilität.
3. **Automatische Überlastungssteuerung**: Dynamische Anpassung an Netzwerkschwankungen zur Optimierung der Übertragungsleistung.
**UDP-Vorteile:**
- **Niedrige Latenz**: UDP ist ein verbindungsloses Protokoll, das kein Three-Way-Handshake erfordert, ideal für Echtzeitkommunikation.
- **Flexible Zuverlässigkeit**: Während UDP selbst unzuverlässig ist, implementiert SCTP darüber zuverlässige Übertragungsmechanismen und kombiniert die Flexibilität von UDP mit der Zuverlässigkeit von TCP.
**SCTP-Multi-Stream-Übertragungsdiagramm**
```mermaid
graph TD
A[Sender] --> B[DataChannel 1]
A --> C[DataChannel 2]
A --> D[DataChannel 3]
B --> E[Empfänger]
C --> E
D --> E
```
## II. Browser-Direktübertragungs-Engine: Kerntechnologie entschlüsselt
### 2.1 Präzise Steuerung der Chunk-Übertragung
```typescript
// lib/fileSender.ts - 64KB-Festgrößen-Chunks
// Definiert Chunk-Größe als 65536 Bytes (64KB), um genau der Netzwerk-MTU (Maximum Transmission Unit) Größe zu entsprechen.
// Dies verhindert Netzwerküberlastung oder Fragmentierungsprobleme durch übergroße Pakete.
private readonly CHUNK_SIZE = 65536;
// Erstellt eine asynchrone Generatorfunktion zur Verarbeitung von Dateien in Festgrößen-Chunks.
// Jeder Generatoraufruf gibt Chunk-Daten vom Typ ArrayBuffer zurück.
private async *createChunkGenerator(file: File) {
let offset = 0; // Initialisiert Offset zur Markierung der aktuellen Dateileseposition
// Schleife durch Datei, bis alle Daten verarbeitet sind
while (offset < file.size) {
// Verwendet File.slice-Methode, um Datensegment aus [offset, offset + CHUNK_SIZE) zu extrahieren
const chunk = file.slice(offset, offset + this.CHUNK_SIZE);
// Konvertiert extrahierte Daten zu ArrayBuffer und gibt sie über yield zurück
yield await chunk.arrayBuffer();
// Aktualisiert Offset für nächsten Chunk
offset += this.CHUNK_SIZE;
}
}
// Back-Pressure-Steuerungsalgorithmus: Stellt sicher, dass Senden die DataChannel-Puffergrenzen nicht überschreitet.
// Wenn Puffer voll ist, warten, bis Pufferspeicherplatz verfügbar wird, bevor fortgefahren wird.
private async sendWithBackpressure(chunk: ArrayBuffer) {
// Senden pausieren, wenn DataChannel-Puffernutzung voreingestelltes Maximum überschreitet
while (this.dataChannel.bufferedAmount > this.MAX_BUFFER) {
// Promise verwenden, um auf bufferedamountlow-Ereignis zu warten, das Pufferplatzfreigabe anzeigt
await new Promise(r => this.dataChannel.bufferedamountlow = r);
}
// Aktuellen Chunk senden, wenn Puffer ausreichend Platz hat
this.dataChannel.send(chunk);
}
```
### 2.2 Zero-Copy-Speicherschreiben
Implementiert durch File System Access API:
```typescript
// lib/fileReceiver.ts
// Schreibt empfangene Chunk-Daten direkt auf die Festplatte, vermeidet zusätzliche Speicherkopien
private async writeToDisk(chunk: ArrayBuffer) {
// Initialisiert Dateischreiber, falls noch nicht erstellt
if (!this.writer) {
// Dateispeicher-Dialog anzeigen, damit Benutzer Speicherort auswählen kann
this.currentFileHandle = await window.showSaveFilePicker();
// Erstellt beschreibbaren Stream durch Dateihandle für nachfolgende Schreibvorgänge
this.writer = await this.currentFileHandle.createWritable();
}
// Konvertiert empfangenen ArrayBuffer zu Uint8Array und schreibt direkt auf Festplatte
// Dies umgeht Speicherpuffer, erreicht Zero-Copy-Schreiben zur Leistungsverbesserung
await this.writer.write(new Uint8Array(chunk));
}
```
## III. Verteiltes Raummanagementsystem
### 3.1 Vierstellige Kollisionserkennung:
```typescript
// server.ts
async function getAvailableRoomId() {
let roomId;
do {
roomId = Math.floor(1000 + Math.random() * 9000); // Vierstellige Zufallszahl generieren
} while (await redis.hexists(`room:${roomId}`, "created_at")); // Prüft ob existiert
return roomId;
}
```
Hinweis: Die 4-stellige Zahl ist eine systemgenerierte zufällige Raum-ID. Sie können jede beliebige Raum-ID angeben, die Sie bevorzugen.
### 3.2 Elegante Ablaufstrategie:
```typescript
// server.ts
await refreshRoom(roomId, 3600 * 24); // Aktive Räume 24 Stunden behalten
if (await isRoomEmpty(roomId)) {
// Raum freigeben wenn leer (sowohl Sender als auch Empfänger verlassen haben)
await deleteRoom(roomId);
}
```
### 3.3 Signalisierungsgetriebenes Wiederherstellungsprotokoll
Mobile Verbindungsunterbrechung-Wiederherstellungsfluss:
```mermaid
sequenceDiagram
participant Sender
participant Signaling
participant Recipient
Sender->>Signaling: initiator-online senden wenn Frontend sichtbar
Signaling->>Recipient: Online-Benachrichtigung weiterleiten
Recipient->>Signaling: recipient-ready antworten
Signaling->>Sender: Wiederherstellungsprozess auslösen
Sender->>Recipient: ICE-Verbindung wiederherstellen
```
Durch diesen Mechanismus kann das System Verbindungen schnell wiederherstellen, selbst wenn Benutzer auf mobilen Geräten Anwendungen wechseln oder in den Hintergrund gehen (Mobile umfasst auch Wakelock zur Schlaffverhinderung), was eine gute Benutzererfahrung gewährleistet.
## IV. Sicherheits- und Datenschutzverteidigungslinie
### 4.1 Verschlüsselungsprotokoll-Flywheel
```
Anwendungsschicht
DTLS 1.2+ → TLS_ECDHE_RSA_AES_128_GCM_SHA256
Betriebssystem-Level-Verschlüsselung
```
**Erklärung:**
1. **DTLS (Datagram Transport Layer Security)**:
- DTLS ist ein UDP-basiertes sicheres Transportprotokoll, das TLS-ähnliche Verschlüsselung bietet.
- In WebRTC werden alle Datenkanäle über DTLS ende-zu-ende verschlüsselt, was Abhören oder Manipulation während der Übertragung verhindert.
- Verwendet Verschlüsselungssuite **`TLS_ECDHE_RSA_AES_128_GCM_SHA256`** für hochsichere Sicherheit.
2. **Betriebssystem-Level-Verschlüsselung**:
- Moderne Browser bieten auf Betriebssystemebene zusätzlichen Schutz für sensible Daten im Speicher, um Zugriff durch bösartige Software zu verhindern.
**Zusammenfassung:**
Durch dualen Schutz von DTLS und Betriebssystem-Level-Verschlüsselung bietet WebRTC robusten Datenschutz und gewährleistet Datensicherheit während des Dateitransfers.
### 4.2 Angriffsfläche-Abwehrmatrix
| **Angriffstyp** | **Abwehrmaßnahme** | **Erklärung** |
| --- | --- | --- |
| **MITM** | **SDP-Fingerabdruck-Verifizierung** | **Generiert eindeutigen Fingerabdruck aus DTLS-öffentlichen Schlüssel-Hash, um Identität der Kommunikationspartner zu gewährleisten, verhindert Datenstrom-Fälschung oder Manipulation durch Mittelsmänner.** |
| **RaumID-Traversierungsangriff** | **Raumeintritts-Ratenbegrenzung** | **Begrenzt Raumeintrittsfrequenz pro IP-Adresse (z.B. max 2 joins pro 5 Sekunden), um zu verhindern, dass bösartige Benutzer Raumnummern durchlaufen, um auf Inhalte zuzugreifen.** |
**Erklärung:**
1. **MITM (Man-in-the-Middle-Angriff)**
- **Prinzip**: WebRTC verwendet SDP-Fingerabdrücke (basierend auf DTLS-öffentlichen Schlüssel-Hash), um Identität der Kommunikationspartner während des Handshakes zu verifizieren. Angreifer können gültige Fingerabdrücke nicht fälschen und können sich daher nicht als legitime Parteien ausgeben.
- **Wirkung**: Gewährleistet P2P-Verbindungssicherheit und Datenintegrität, verhindert Abhören oder Manipulation.
2. **RaumID-Traversierungsangriff**
- **Definition**: Bösartige Benutzer könnten verschiedene Raumnummern (z.B. vierstellige IDs) ausprobieren, um unerlaubte Räume zu betreten und auf geteilte Inhalte zuzugreifen.
- **Abwehrmaßnahmen**:
- **Ratenbegrenzung**: Begrenzt Raumeintrittsfrequenz pro IP-Adresse, z.B. max 2 Raumbeitritte pro 5 Sekunden.
- **Implementierung**: Redis verwenden, um IP-Anfragedatensätze zwischenzuspeichern für schnelle Erkennung und Blockierung abnormalen Verhaltens.
- **Wirkung**: Verhindert effektiv, dass bösartige Benutzer durch Raumnummern-Durchlauf auf sensible Inhalte zugreifen, schützt Benutzerdatenschutz.
## Schlussfolgerung: Vertrauenswürdige Übertragungsinfrastruktur aufbauen
Wir glauben, dass Technologie wesentliche menschliche Bedürfnisse dienen sollte, nicht neue Überwachungsabhängigkeiten schaffen. Erleben Sie jetzt dieses datenschutzsichere Dateitransferwerkzeug und fühlen Sie die revolutionären Veränderungen, die P2P-Technologie bringt! Klicken Sie auf [<u>**PrivyDrop-Portal**</u>](https://www.privydrop.app) zu beginnen.
**Verpflichtung zur Codetransparenz**: Code wird zukünftig Open Source sein. Wir sind verpflichtet, wirklich vertrauenswürdige Datenschutzwerkzeuge durch Community-Mitverwaltung zu etablieren.
## Häufig gestellte Fragen
- **Werden große Dateiübertragungen leicht unterbrochen?**
- Solche Fälle noch nicht beobachtet. P2P (Gerät-zu-Gerät) Verbindungen sind im Allgemeinen stabil. Wir können Resume-from-Breakpoint-Fähigkeit basierend auf zukünftigem Feedback hinzufügen.
- **Wären Raumpasswörter sicherer?**
- Theoretisch ja. Da Passworthinzufügung die Benutzerfreundlichkeit leicht beeinträchtigen würde, noch nicht implementiert. Für erhöhte Sicherheit können Sie jeden benutzerdefinierten String als RoomID verwenden und über Links und QR-Codes teilen. Außerdem begrenzt das System die Empfänger-Raumeintrittsfrequenz, was die Sicherheit weiter verbessert.
- **Können Sender die PrivyDrop-Seite jederzeit schließen?**
- Ja, vorzugsweise nachdem Inhalt empfangen wurde. Da es direkte Gerätekommunikation ist, ist Sharing nicht möglich, wenn Sender offline ist. Wenn Sie nicht mehr teilen möchten, können Sie die Seite sofort schließen.
Weitere Fragen? Klicken Sie auf [<u>**PrivyDrop FAQ**</u>](https://www.privydrop.app/faq) oder [<u>**PrivyDrop Hilfe**</u>](https://www.privydrop.app/help) Abschnitte für weitere Antworten und Hilfe.
**Entwicklerressourcen**
- [<u>**WebRTC Offizielle Dokumentation**</u>](https://webrtc.org/)
@@ -0,0 +1,277 @@
---
title: "Conexión Directa de Navegador a Navegador: Revelando la Tecnología Central de la Transferencia de Archivos Enfocada en la Privacidad Basada en WebRTC"
description: "Este artículo profundiza en las tecnologías centrales de una herramienta de transferencia de archivos enfocada en la privacidad basada en WebRTC, incluyendo transmisión P2P, cifrado E2EE y multiplexación de flujos. Adecuado tanto para entusiastas de la tecnología como para usuarios generales."
date: "2025-02-09"
author: "david bai"
cover: "/blog-assets/webrtc-file-transfer.jpg"
tags: [WebRTC, Transferencia P2P, Privacidad y Seguridad]
status: "published"
---
![](/blog-assets/webrtc-file-transfer.jpg)
## Introducción
Los métodos tradicionales de transferencia de archivos dependen en gran medida del almacenamiento en la nube o servidores centralizados, lo que genera preocupaciones sobre la privacidad de los datos y al mismo tiempo enfrenta limitaciones en el tamaño de carga y cuellos de botella de velocidad. Nuestra herramienta aprovecha la tecnología WebRTC para permitir transferencias directas de dispositivo a dispositivo, abordando efectivamente estos desafíos.
Nuestra herramienta desarrollada ([<u>**PrivyDrop**</u>](https://www.privydrop.app)) presenta varias características notables:
- Transferencia directa de dispositivo a dispositivo usando tecnología WebRTC, eliminando la necesidad de servidores intermedios
- Cifrado de extremo a extremo (E2EE) garantizando la transmisión segura de datos
- Sin registro requerido, uso instantáneo, soporte para múltiples receptores simultáneos
- Soporte para varios tipos de datos incluyendo texto, imágenes, archivos y carpetas
- Velocidad de transferencia y tamaño de archivo limitados solo por el ancho de banda de la red y el espacio en disco entre dispositivos
En este artículo, exploraremos la arquitectura técnica, los principios de funcionamiento y por qué esta herramienta puede proporcionar una experiencia de transferencia de archivos tan segura y eficiente. Ya sea que sea un entusiasta de la tecnología o un usuario general, obtendrá información sobre cómo la tecnología WebRTC está revolucionando la transferencia de archivos.
## I. Redefiniendo la Transferencia de Archivos: La Revolución Arquitectónica de WebRTC
WebRTC (Web Real-Time Communication) es un estándar abierto que admite comunicación en tiempo real entre navegadores. Nuestra herramienta de transferencia de archivos desarrollada basada en WebRTC comprende varios componentes centrales:
1. **Servidor de Señalización**: Coordina conexiones entre dispositivos sin participar en la transferencia real de datos.
2. **Conexión P2P**: Conexiones directas de dispositivo a dispositivo sin intervención de servidores de terceros.
3. **Cifrado E2EE**: Todos los datos se cifran de extremo a extremo usando el protocolo DTLS durante la transmisión.
### 1.1 Enfoque Tradicional vs Enfoque WebRTC
| Característica | Transferencia HTTP Tradicional | Transferencia P2P WebRTC |
| --- | --- | --- |
| Ruta de Transferencia | Cliente → Servidor → Cliente | Dispositivo a Dispositivo Directo |
| Latencia | Limitada por ancho de banda del servidor central | Limitada solo por ancho de banda de red física |
| Límite de Tamaño de Archivo | Usualmente restringido | Limitado solo por espacio en disco |
| Protección de Privacidad | Depende de seguridad del proveedor de servicios | Cifrado obligatorio vía protocolo DTLS |
### 1.2 Proceso de Establecimiento de Conexión P2P
```mermaid
sequenceDiagram
participant UserA as Usuario A (Remitente)
participant SignalingServer as Servidor de Señalización
participant UserB as Usuario B (Receptor)
UserA->>SignalingServer: (1) Crear y unirse a sala
activate SignalingServer
UserB->>SignalingServer: (2) Unirse a sala
SignalingServer-->>UserA: Notificar Usuario B se unió
UserA->>SignalingServer: (3) Enviar información de negociación WebRTC (SDP/ICE)
SignalingServer-->>UserB: Reenviar información de negociación WebRTC (SDP/ICE)
UserB->>SignalingServer: (4) Responder con información de negociación WebRTC (SDP/ICE)
SignalingServer-->>UserA: Reenviar información de negociación WebRTC de respuesta (SDP/ICE)
UserA-->>UserB: (5) Establecer conexión P2P (DataChannel)
UserA->>UserB: (6) Transferencia de fragmentos de archivo vía DataChannel
```
**Proceso:**
1. El Usuario A crea y se une a una sala, conectándose al servidor de señalización.
2. El Usuario B se une a la sala y se conecta al servidor de señalización.
3. El Usuario A inicia la negociación WebRTC con el Usuario B (incluyendo información SDP e ICE).
4. El Usuario B responde con información de negociación WebRTC, completando el establecimiento de la conexión P2P.
5. Finalmente, los archivos se transfieren a través de DataChannel en la conexión P2P.
### 1.3 La Magia de Rendimiento de SCTP (sobre DTLS & UDP)
El **DataChannel** de WebRTC se basa en el **Protocolo de Control de Transmisión de Flujo (SCTP)** que se ejecuta sobre **DTLS** y **UDP**, ofreciendo tres ventajas principales sobre el TCP tradicional:
1. **Multiplexación de Flujo (Actualmente No Utilizado)**: Los fragmentos de archivo pueden transmitirse en paralelo, mejorando la eficiencia de transferencia.
2. **Sin Bloqueo de Cabeza de Línea**: La pérdida de un solo fragmento no afecta el progreso general, asegurando estabilidad de transferencia.
3. **Control Automático de Congestión**: Se adapta dinámicamente a la fluctuación de la red, optimizando el rendimiento de transferencia.
**Ventajas de UDP:**
- **Baja Latencia**: UDP es un protocolo sin conexión que no requiere protocolo de enlace de tres vías, ideal para comunicación en tiempo real.
- **Fiabilidad Flexible**: Mientras que UDP en sí no es confiable, SCTP implementa mecanismos de transmisión confiables sobre él, combinando la flexibilidad de UDP con la fiabilidad de TCP.
**Diagrama de Transferencia Multi-Flujo SCTP**
```mermaid
graph TD
A[Remitente] --> B[DataChannel 1]
A --> C[DataChannel 2]
A --> D[DataChannel 3]
B --> E[Receptor]
C --> E
D --> E
```
## II. Motor de Transferencia Directa de Navegador: Tecnología Central Decodificada
### 2.1 Control Preciso de Transferencia de Fragmentos
```typescript
// lib/fileSender.ts - Fragmentos de 64KB de Tamaño Fijo
// Definir tamaño de fragmento como 65536 bytes (64KB) para coincidir precisamente con el tamaño MTU (Unidad Máxima de Transmisión) de red.
// Esto previene congestión de red o problemas de fragmentación causados por paquetes sobredimensionados.
private readonly CHUNK_SIZE = 65536;
// Crear una función generadora asíncrona para procesar archivos en fragmentos de tamaño fijo.
// Cada llamada al generador devuelve datos de fragmento de tipo ArrayBuffer.
private async *createChunkGenerator(file: File) {
let offset = 0; // Inicializar offset para marcar posición actual de lectura de archivo
// Bucle a través del archivo hasta que todos los datos sean procesados
while (offset < file.size) {
// Usar método File.slice para extraer segmento de datos de [offset, offset + CHUNK_SIZE)
const chunk = file.slice(offset, offset + this.CHUNK_SIZE);
// Convertir datos extraídos a ArrayBuffer y devolver vía yield
yield await chunk.arrayBuffer();
// Actualizar offset para siguiente fragmento
offset += this.CHUNK_SIZE;
}
}
// Algoritmo de control de contra-presión: Asegura que el envío no exceda los límites del búfer de DataChannel.
// Si el búfer está lleno, esperar hasta que el espacio del búfer esté disponible antes de continuar.
private async sendWithBackpressure(chunk: ArrayBuffer) {
// Pausar envío cuando el uso del búfer de DataChannel excede el máximo preestablecido
while (this.dataChannel.bufferedAmount > this.MAX_BUFFER) {
// Usar Promise para esperar evento bufferedamountlow indicando espacio de búfer liberado
await new Promise(r => this.dataChannel.bufferedamountlow = r);
}
// Enviar fragmento actual cuando el búfer tiene espacio suficiente
this.dataChannel.send(chunk);
}
```
### 2.2 Escritura de Memoria de Copia Cero
Implementado a través de File System Access API:
```typescript
// lib/fileReceiver.ts
// Escribir datos de fragmento recibidos directamente al disco, evitando copias de memoria adicionales
private async writeToDisk(chunk: ArrayBuffer) {
// Inicializar escritor de archivo si aún no está creado
if (!this.writer) {
// Mostrar diálogo de selector de guardado de archivo para que el usuario elija ubicación de guardado
this.currentFileHandle = await window.showSaveFilePicker();
// Crear flujo escribible a través del manejador de archivo para escrituras posteriores
this.writer = await this.currentFileHandle.createWritable();
}
// Convertir ArrayBuffer recibido a Uint8Array y escribir directamente al disco
// Esto evita el búfer de memoria, logrando escritura de copia cero para mejor rendimiento
await this.writer.write(new Uint8Array(chunk));
}
```
## III. Sistema de Gestión de Salas Distribuido
### 3.1 Detección de Colisión de Cuatro Dígitos:
```typescript
// server.ts
async function getAvailableRoomId() {
let roomId;
do {
roomId = Math.floor(1000 + Math.random() * 9000); // Generar número aleatorio de cuatro dígitos
} while (await redis.hexists(`room:${roomId}`, "created_at")); // Verificar si existe
return roomId;
}
```
Nota: El número de 4 dígitos es una ID de sala aleatoria generada por el sistema. Puede especificar cualquier ID de sala que prefiera.
### 3.2 Estrategia de Expiración Elegante:
```typescript
// server.ts
await refreshRoom(roomId, 3600 * 24); // Salas activas retenidas por 24 horas
if (await isRoomEmpty(roomId)) {
// Liberar sala si está vacía (tanto remitente como receptor se fueron)
await deleteRoom(roomId);
}
```
### 3.3 Protocolo de Recuperación Impulsado por Señalización
Flujo de Recuperación de Desconexión Móvil:
```mermaid
sequenceDiagram
participant Sender
participant Signaling
participant Recipient
Sender->>Signaling: Enviar initiator-online cuando frontend visible
Signaling->>Recipient: Reenviar notificación en línea
Recipient->>Signaling: Responder recipient-ready
Signaling->>Sender: Desencadenar proceso de reconexión
Sender->>Recipient: Reconstruir conexión ICE
```
A través de este mecanismo, el sistema puede restaurar rápidamente conexiones incluso cuando los usuarios cambian aplicaciones o entran en segundo plano en dispositivos móviles (móvil también incluye Wakelock para evitar dormir), asegurando una buena experiencia de usuario.
## IV. Línea de Defensa de Seguridad y Privacidad
### 4.1 Rueda de Protocolos de Cifrado
```
Capa de Aplicación
DTLS 1.2+ → TLS_ECDHE_RSA_AES_128_GCM_SHA256
Cifrado a Nivel de Sistema Operativo
```
**Explicación:**
1. **DTLS (Datagram Transport Layer Security)**:
- DTLS es un protocolo de transporte seguro basado en UDP que proporciona cifrado similar a TLS.
- En WebRTC, todos los canales de datos se cifran de extremo a extremo a través de DTLS, evitando escucha o manipulación durante la transmisión.
- Utiliza suite de cifrado **`TLS_ECDHE_RSA_AES_128_GCM_SHA256`** para alta seguridad.
2. **Cifrado a Nivel de Sistema Operativo**:
- Los navegadores modernos proporcionan protección adicional para datos sensibles en memoria a nivel de sistema operativo, evitando el acceso de software malicioso.
**Resumen:**
A través de la protección dual de DTLS y cifrado a nivel de sistema operativo, WebRTC proporciona robusta protección de privacidad asegurando la seguridad de datos durante la transferencia de archivos.
### 4.2 Matriz de Defensa de Superficie de Ataque
| **Tipo de Ataque** | **Medida de Defensa** | **Explicación** |
| --- | --- | --- |
| **MITM** | **Verificación de Huella SDP** | **Genera huella única desde hash de clave pública DTLS para asegurar identidad de partes de comunicación, evitando falsificación o manipulación de flujo de datos por intermediarios.** |
| **Ataque de Traversía de RoomID** | **Limitación de Tasa de Entrada a Sala** | **Limita frecuencia de entrada a sala por dirección IP (ej. máximo 2 joins por 5 segundos), evitando que usuarios maliciosos atraviesen números de sala para acceder a contenido.** |
**Explicación:**
1. **MITM (Ataque de Hombre en el Medio)**
- **Principio**: WebRTC usa huellas SDP (basadas en hash de clave pública DTLS) para verificar identidad de partes de comunicación durante el handshake. Los atacantes no pueden falsificar huellas válidas, por lo tanto no pueden hacerse pasar por partes legítimas.
- **Efecto**: Asegura seguridad de conexión P2P e integridad de datos, evitando escucha o manipulación.
2. **Ataque de Traversía de RoomID**
- **Definición**: Usuarios maliciosos podrían intentar diferentes números de sala (ej. IDs de cuatro dígitos) para entrar en salas no autorizadas y acceder a contenido compartido.
- **Medidas de Defensa**:
- **Limitación de Tasa**: Restringir frecuencia de entrada a sala por dirección IP, ej. máximo 2 entradas a sala por 5 segundos.
- **Implementación**: Usar Redis para almacenar en caché registros de solicitudes IP para rápida detección y bloqueo de comportamiento anormal.
- **Efecto**: Previene eficazmente que usuarios maliciosos accedan a contenido sensible a través de traversía de números de sala, protegiendo la privacidad del usuario.
## Conclusión: Construyendo Infraestructura de Transferencia Confiable
Creemos que la tecnología debería servir necesidades humanas esenciales en lugar de crear nuevas dependencias de vigilancia. ¡Experimente ahora esta herramienta de transferencia de archivos segura para la privacidad y sienta los cambios revolucionarios traídos por la tecnología P2P! Haga clic en [<u>**Portal PrivyDrop**</u>](https://www.privydrop.app) para comenzar.
**Compromiso de Transparencia de Código**: El código será de código abierto en el futuro. Estamos comprometidos en establecer herramientas de privacidad verdaderamente confiables a través de la cogobernanza comunitaria.
## Preguntas Frecuentes
- **¿Las transferencias de archivos grandes serán propensas a interrupciones?**
- Aún no se han observado tales casos. Las conexiones P2P (dispositivo a dispositivo) generalmente son estables. Podríamos agregar capacidad de reanudación desde punto de interrupción basado en retroalimentación futura.
- **¿Sería más seguro agregar contraseñas a las salas?**
- Teóricamente sí. Considerando que agregar contraseñas afectaría ligeramente la usabilidad, aún no está implementado. Para mayor seguridad, puede usar cualquier cadena personalizada como RoomID y compartir a través de enlaces y códigos QR. Además, el sistema limita la frecuencia de entrada a sala del receptor, mejorando aún más la seguridad.
- **¿Pueden los remitentes cerrar la página PrivyDrop en cualquier momento?**
- Sí, preferiblemente después de que el contenido sea recibido. Como es conexión directa de dispositivo, compartir no es posible si el remitente está fuera de línea. Si no desea seguir compartiendo, puede cerrar la página inmediatamente.
¿Más preguntas? Haga clic en [<u>**PrivyDrop FAQ**</u>](https://www.privydrop.app/faq) o [<u>**PrivyDrop Ayuda**</u>](https://www.privydrop.app/help) para más respuestas y ayuda.
**Recursos para Desarrolladores**
- [<u>**Documentación Oficial de WebRTC**</u>](https://webrtc.org/)
@@ -0,0 +1,277 @@
---
title: "Connexion Directe de Navigateur à Navigateur : Révélation de la Technologie Centrale du Transfert de Fichiers Axé sur la Confidentialité Basé sur WebRTC"
description: "Cet article explore en profondeur les technologies centrales d'un outil de transfert de fichiers axé sur la confidentialité basé sur WebRTC, incluant la transmission P2P, le chiffrement E2EE et le multiplexage de flux. Adapté autant pour les passionnés de technologie que pour les utilisateurs généraux."
date: "2025-02-09"
author: "david bai"
cover: "/blog-assets/webrtc-file-transfer.jpg"
tags: [WebRTC, Transfert P2P, Confidentialité Sécurité]
status: "published"
---
![](/blog-assets/webrtc-file-transfer.jpg)
## Introduction
Les méthodes traditionnelles de transfert de fichiers dépendent largement du stockage cloud ou de serveurs centralisés, ce qui soulève des préoccupations concernant la confidentialité des données tout en faisant face à des limitations de taille de téléchargement et des goulots d'étranglement de vitesse. Notre outil exploite la technologie WebRTC pour permettre des transferts directs d'appareil à appareil, adressant efficacement ces défis.
Notre outil développé ([<u>**PrivyDrop**</u>](https://www.privydrop.app)) présente plusieurs caractéristiques notables :
- Transfert direct d'appareil à appareil utilisant la technologie WebRTC, éliminant le besoin de serveurs intermédiaires
- Chiffrement de bout en bout (E2EE) garantissant une transmission sécurisée des données
- Aucune inscription requise, utilisation instantanée, support pour plusieurs récepteurs simultanés
- Support pour divers types de données incluant texte, images, fichiers et dossiers
- Vitesse de transfert et taille de fichier limitées seulement par la bande passante réseau et l'espace disque entre appareils
Dans cet article, nous explorerons l'architecture technique, les principes de fonctionnement et pourquoi cet outil peut offrir une expérience de transfert de fichiers si sécurisée et efficace. Que vous soyez un passionné de technologie ou un utilisateur général, vous obtiendrez des informations sur la manière dont la technologie WebRTC révolutionne le transfert de fichiers.
## I. Redéfinir le Transfert de Fichiers : La Révolution Architecturale de WebRTC
WebRTC (Web Real-Time Communication) est un standard ouvert supportant la communication en temps réel entre navigateurs. Notre outil de transfert de fichiers développé basé sur WebRTC comprend plusieurs composants centraux :
1. **Serveur de Signalisation** : Coordonne les connexions entre appareils sans participer au transfert réel de données.
2. **Connexion P2P** : Connexions directes d'appareil à appareil sans intervention de serveurs tiers.
3. **Chiffrement E2EE** : Toutes les données sont chiffrées de bout en bout utilisant le protocole DTLS pendant la transmission.
### 1.1 Approche Traditionnelle vs Approche WebRTC
| Caractéristique | Transfert HTTP Traditionnel | Transfert P2P WebRTC |
| --- | --- | --- |
| Chemin de Transfert | Client → Serveur → Client | Direct d'Appareil à Appareil |
| Latence | Limitée par la bande passante du serveur central | Limitée seulement par la bande passante réseau physique |
| Limite de Taille de Fichier | Généralement restreinte | Limitée seulement par l'espace disque |
| Protection de la Confidentialité | Dépend de la sécurité du fournisseur de services | Chiffrement obligatoire via protocole DTLS |
### 1.2 Processus d'Établissement de Connexion P2P
```mermaid
sequenceDiagram
participant UserA as Utilisateur A (Émetteur)
participant SignalingServer as Serveur de Signalisation
participant UserB as Utilisateur B (Récepteur)
UserA->>SignalingServer: (1) Créer et rejoindre une salle
activate SignalingServer
UserB->>SignalingServer: (2) Rejoindre la salle
SignalingServer-->>UserA: Notifier que l'Utilisateur B a rejoint
UserA->>SignalingServer: (3) Envoyer les informations de négociation WebRTC (SDP/ICE)
SignalingServer-->>UserB: Transférer les informations de négociation WebRTC (SDP/ICE)
UserB->>SignalingServer: (4) Répondre avec les informations de négociation WebRTC (SDP/ICE)
SignalingServer-->>UserA: Transférer les informations de négociation WebRTC de réponse (SDP/ICE)
UserA-->>UserB: (5) Établir la connexion P2P (DataChannel)
UserA->>UserB: (6) Transfert de fragments de fichier via DataChannel
```
**Processus :**
1. L'Utilisateur A crée et rejoint une salle, se connectant au serveur de signalisation.
2. L'Utilisateur B rejoint la salle et se connecte au serveur de signalisation.
3. L'Utilisateur A initie la négociation WebRTC avec l'Utilisateur B (incluant les informations SDP et ICE).
4. L'Utilisateur B répond avec les informations de négociation WebRTC, complétant l'établissement de la connexion P2P.
5. Finalement, les fichiers sont transférés via DataChannel sur la connexion P2P.
### 1.3 La Magie de Performance de SCTP (sur DTLS & UDP)
Le **DataChannel** de WebRTC est basé sur le **Stream Control Transmission Protocol (SCTP)** fonctionnant sur **DTLS** et **UDP**, offrant trois avantages majeurs par rapport au TCP traditionnel :
1. **Multiplexage de Flux (Pas Actuellement Utilisé)** : Les fragments de fichiers peuvent être transmis en parallèle, améliorant l'efficacité de transfert.
2. **Pas de Blocage de Tête de Ligne** : La perte d'un fragment unique n'affecte pas la progression globale, assurant la stabilité du transfert.
3. **Contrôle Automatique de Congestion** : S'adapte dynamiquement à la gigue réseau, optimisant les performances de transfert.
**Avantages UDP :**
- **Faible Latence** : UDP est un protocole sans connexion n'exigeant pas de handshake en trois temps, idéal pour la communication en temps réel.
- **Fiabilité Flexible** : Bien qu'UDP lui-même ne soit pas fiable, SCTP implémente des mécanismes de transmission fiables par-dessus, combinant la flexibilité d'UDP avec la fiabilité de TCP.
**Diagramme de Transfert Multi-Flux SCTP**
```mermaid
graph TD
A[Émetteur] --> B[DataChannel 1]
A --> C[DataChannel 2]
A --> D[DataChannel 3]
B --> E[Récepteur]
C --> E
D --> E
```
## II. Moteur de Transfert Direct Navigateur : Technologie Centrale Décryptée
### 2.1 Contrôle Précis du Transfert de Fragments
```typescript
// lib/fileSender.ts - Fragments de Taille Fixe 64KB
// Définir la taille de fragment comme 65536 octets (64KB) pour correspondre précisément à la taille MTU (Maximum Transmission Unit) réseau.
// Ceci prévient la congestion réseau ou les problèmes de fragmentation causés par des paquets surdimensionnés.
private readonly CHUNK_SIZE = 65536;
// Créer une fonction générateur asynchrone pour traiter les fichiers en fragments de taille fixe.
// Chaque appel au générateur retourne des données de fragment de type ArrayBuffer.
private async *createChunkGenerator(file: File) {
let offset = 0; // Initialiser l'offset pour marquer la position actuelle de lecture de fichier
// Boucler à travers le fichier jusqu'à ce que toutes les données soient traitées
while (offset < file.size) {
// Utiliser la méthode File.slice pour extraire un segment de données de [offset, offset + CHUNK_SIZE)
const chunk = file.slice(offset, offset + this.CHUNK_SIZE);
// Convertir les données extraites en ArrayBuffer et retourner via yield
yield await chunk.arrayBuffer();
// Mettre à jour l'offset pour le fragment suivant
offset += this.CHUNK_SIZE;
}
}
// Algorithme de contrôle de contre-pression : Assure que l'envoi n'excède pas les limites du tampon DataChannel.
// Si le tampon est plein, attendre que l'espace du tampon devienne disponible avant de continuer.
private async sendWithBackpressure(chunk: ArrayBuffer) {
// Mettre en pause l'envoi quand l'utilisation du tampon DataChannel dépasse le maximum préréglé
while (this.dataChannel.bufferedAmount > this.MAX_BUFFER) {
// Utiliser Promise pour attendre l'événement bufferedamountlow indiquant l'espace de tampon libéré
await new Promise(r => this.dataChannel.bufferedamountlow = r);
}
// Envoyer le fragment actuel quand le tampon a suffisamment d'espace
this.dataChannel.send(chunk);
}
```
### 2.2 Écriture Mémoire de Copie Zéro
Implémenté via File System Access API :
```typescript
// lib/fileReceiver.ts
// Écrire les données de fragment reçues directement sur le disque, évitant les copies mémoire supplémentaires
private async writeToDisk(chunk: ArrayBuffer) {
// Initialiser l'écrivain de fichier si pas encore créé
if (!this.writer) {
// Afficher le dialogue de sauvegarde de fichier pour que l'utilisateur choisisse l'emplacement de sauvegarde
this.currentFileHandle = await window.showSaveFilePicker();
// Créer un flux inscriptible via le handle de fichier pour les écritures ultérieures
this.writer = await this.currentFileHandle.createWritable();
}
// Convertir l'ArrayBuffer reçu en Uint8Array et écrire directement sur le disque
// Ceci contourne le tampon mémoire, réalisant une écriture de copie zéro pour une performance améliorée
await this.writer.write(new Uint8Array(chunk));
}
```
## III. Système de Gestion de Salle Distribué
### 3.1 Détection de Collision à Quatre Chiffres :
```typescript
// server.ts
async function getAvailableRoomId() {
let roomId;
do {
roomId = Math.floor(1000 + Math.random() * 9000); // Générer un nombre aléatoire à quatre chiffres
} while (await redis.hexists(`room:${roomId}`, "created_at")); // Vérifier si existe
return roomId;
}
```
Note : Le nombre à 4 chiffres est un ID de salle aléatoire généré par le système. Vous pouvez spécifier n'importe quel ID de salle que vous préférez.
### 3.2 Stratégie d'Expiration Élégante :
```typescript
// server.ts
await refreshRoom(roomId, 3600 * 24); // Salles actives retenues pendant 24 heures
if (await isRoomEmpty(roomId)) {
// Libérer la salle si vide (émetteur et récepteur sont partis)
await deleteRoom(roomId);
}
```
### 3.3 Protocole de Récupération Piloté par Signalisation
Flux de Récupération de Déconnexion Mobile :
```mermaid
sequenceDiagram
participant Sender
participant Signaling
participant Recipient
Sender->>Signaling: Envoyer initiator-online quand frontend visible
Signaling->>Recipient: Transférer notification en ligne
Recipient->>Signaling: Répondre recipient-ready
Signaling->>Sender: Déclencher le processus de reconnexion
Sender->>Recipient: Reconstruire la connexion ICE
```
À travers ce mécanisme, le système peut rapidement restaurer les connexions même lorsque les utilisateurs basculent d'application ou entrent en arrière-plan sur les appareils mobiles (mobile inclut aussi Wakelock pour empêcher le sommeil), assurant une bonne expérience utilisateur.
## IV. Ligne de Défense Sécurité et Confidentialité
### 4.1 Volant de Protocoles de Chiffrement
```
Couche Application
DTLS 1.2+ → TLS_ECDHE_RSA_AES_128_GCM_SHA256
Chiffrement au Niveau OS
```
**Explication :**
1. **DTLS (Datagram Transport Layer Security)** :
- DTLS est un protocole de transport sécurisé basé sur UDP fournissant un chiffrement similaire à TLS.
- Dans WebRTC, tous les canaux de données sont chiffrés de bout en bout via DTLS, empêchant l'écoute ou la manipulation pendant la transmission.
- Utilise la suite de chiffrement **`TLS_ECDHE_RSA_AES_128_GCM_SHA256`** pour une sécurité haute force.
2. **Chiffrement au Niveau OS** :
- Au niveau OS, les navigateurs modernes fournissent une protection supplémentaire pour les données sensibles en mémoire, empêchant l'accès par logiciel malveillant.
**Résumé :**
À travers la protection double de DTLS et du chiffrement au niveau OS, WebRTC fournit une robuste protection de confidentialité assurant la sécurité des données pendant le transfert de fichiers.
### 4.2 Matrice de Défense de Surface d'Attaque
| **Type d'Attaque** | **Mesure de Défense** | **Explication** |
| --- | --- | --- |
| **MITM** | **Vérification d'Empreinte SDP** | **Génère une empreinte unique depuis le hash de clé publique DTLS pour assurer l'identité des parties de communication, empêchant la falsification ou manipulation de flux de données par intermédiaires.** |
| **Attaque de Parcours RoomID** | **Limitation de Taux d'Entrée de Salle** | **Limite la fréquence d'entrée en salle par adresse IP (ex. max 2 joins par 5 secondes), empêchant les utilisateurs malveillants de parcourir les numéros de salle pour accéder au contenu.** |
**Explication :**
1. **MITM (Attaque de l'Homme du Milieu)**
- **Principe** : WebRTC utilise les empreintes SDP (basées sur le hash de clé publique DTLS) pour vérifier l'identité des parties de communication pendant le handshake. Les attaquants ne peuvent pas falsifier d'empreintes valides, donc ne peuvent pas se faire passer pour des parties légitimes.
- **Effet** : Assure la sécurité de connexion P2P et l'intégrité des données, empêchant l'écoute ou la manipulation.
2. **Attaque de Parcours RoomID**
- **Définition** : Les utilisateurs malveillants pourraient tenter différents numéros de salle (ex. IDs à quatre chiffres) pour entrer dans des salas non autorisées et accéder au contenu partagé.
- **Mesures de Défense** :
- **Limitation de Taux** : Restreindre la fréquence d'entrée en salle par adresse IP, ex. max 2 entrées de salle par 5 secondes.
- **Implémentation** : Utiliser Redis pour mettre en cache les enregistrements de requêtes IP pour détection rapide et blocage de comportement anormal.
- **Effet** : Empêche efficacement les utilisateurs malveillants d'accéder à du contenu sensible via le parcours de numéros de salle, protégeant la confidentialité utilisateur.
## Conclusion : Construire une Infrastructure de Transfert Fiable
Nous croyons que la technologie devrait servir les besoins humains essentiels plutôt que créer de nouvelles dépendances de surveillance. Expérimentez maintenant cet outil de transfert de fichiers sécurisé pour la confidentialité et sentez les changements révolutionnaires apportés par la technologie P2P ! Cliquez sur [<u>**Portail PrivyDrop**</u>](https://www.privydrop.app) pour commencer.
**Engagement de Transparence de Code** : Le code sera open source à l'avenir. Nous sommes engagés à établir de véritables outils de confidentialité fiables à travers la co-gouvernance communautaire.
## FAQ
- **Les transferts de gros fichiers seront-ils sujets à interruption ?**
- Pas encore observé de tels cas. Les connexions P2P (appareil à appareil) sont généralement stables. Nous pourrions ajouter la capacité de reprise depuis point d'arrêt basée sur les retours futurs.
- **Ajouter des mots de passe aux salles serait-il plus sécurisé ?**
- Théoriquement oui. Considérant que l'ajout de mots de passe impacterait légèrement l'utilisabilité, pas encore implémenté. Pour une sécurité accrue, vous pouvez utiliser n'importe quelle chaîne personnalisée comme RoomID et partager via liens et codes QR. De plus, le système limite la fréquence d'entrée en salle du récepteur, améliorant encore la sécurité.
- **Les émetteurs peuvent-ils fermer la page PrivyDrop à tout moment ?**
- Oui, de préférence après que le contenu soit reçu. Comme c'est une connexion directe d'appareil, le partage n'est pas possible si l'émetteur est hors ligne. Si vous voulez arrêter de partager, vous pouvez fermer la page immédiatement.
Plus de questions ? Cliquez sur [<u>**PrivyDrop FAQ**</u>](https://www.privydrop.app/faq) ou [<u>**PrivyDrop Aide**</u>](https://www.privydrop.app/help) pour plus de réponses et d'aide.
**Ressources Développeur**
- [<u>**Documentation Officielle WebRTC**</u>](https://webrtc.org/)
@@ -0,0 +1,277 @@
---
title: "ブラウザー間直接接続!WebRTCベースのプライベートファイル転送核心技術を解明"
description: "この記事では、WebRTCベースのプライベートファイル転送ツールの核心技術について深く掘り下げ、P2P転送、E2EE暗号化、ストリーム多重化などを含み、技術愛好家と一般ユーザーの両方に適しています。"
date: "2025-02-09"
author: "david bai"
cover: "/blog-assets/webrtc-file-transfer.jpg"
tags: [WebRTC, P2P転送, プライバシー・セキュリティ]
status: "published"
---
![](/blog-assets/webrtc-file-transfer.jpg)
## はじめに
従来のファイル転送方式のほとんどはクラウドストレージまたは中央集権型サーバーに依存しており、これによりデータプライバシーの懸念が生まれるだけでなく、アップロードサイズの制限や速度ボトルネックといった多くの問題に直面していました。私たちのツールはWebRTC技術を活用してデバイス間の直接転送を実現し、これらの問題を完全に解決しました。
私たちが開発したこのツール([<u>**PrivyDrop**</u>](https://www.privydrop.app))には、以下の顕著な特徴があります:
- WebRTC技術を活用したデバイス間直接転送、中間サーバー不要
- エンドツーエンド暗号化(E2EE)による安全なデータ転送保証
- 登録不要、即利用可能、複数人同時受信対応
- テキスト、画像、ファイル、フォルダなど多様なデータタイプをサポート
- 転送速度とファイルサイズはデバイス間のネットワーク帯域とディスク空間のみに制限
この記事では、このツールの技術アーキテクチャ、動作原理、そしてなぜこれほど安全で効率的なファイル転送体験を提供できるのかについて探求します。技術愛好家であっても一般ユーザーであっても、WebRTC技術がファイル転送分野にもたらす革命的変化を理解できるでしょう。
## 一、ファイル転送の再定義:WebRTCのアーキテクチャ革命
WebRTCWeb Real-Time Communication)は、ブラウザー間のリアルタイム通信をサポートするオープンスタンダードです。WebRTCベースで開発された私たちのファイル転送ツールは、主に以下のコアコンポーネントで構成されています:
1. **シグナリングサーバー**:デバイス間の接続を調整しますが、実際のデータ転送には関与しません。
2. **P2P接続**:デバイス間で直接接続を確立し、第三者サーバーを介さずにデータを転送します。
3. **E2EE暗号化**:すべてのデータが転送中にDTLSプロトコルでエンドツーエンド暗号化されます。
### 1.1 従来方式 vs WebRTC方式
| 特性 | 従来のHTTP転送 | WebRTC P2P転送 |
| --- | --- | --- |
| 転送パス | クライアント → サーバー → クライアント | デバイス間直接接続 |
| 遅延 | 中央サーバーの帯域に制限 | 物理ネットワーク帯域のみに制限 |
| ファイルサイズ制限 | 通常制限あり | ディスク容量のみに制限 |
| プライバシー保護 | サービスプロバイダーのセキュリティに依存 | DTLSプロトコルによる強制暗号化 |
### 1.2 P2P接続確立プロセス
```mermaid
sequenceDiagram
participant UserA as ユーザーA(送信者)
participant SignalingServer as シグナリングサーバー
participant UserB as ユーザーB(受信者)
UserA->>SignalingServer: (1) 部屋を作成して参加
activate SignalingServer
UserB->>SignalingServer: (2) 部屋に参加
SignalingServer-->>UserA: ユーザーBの参加を通知
UserA->>SignalingServer: (3) WebRTCネゴシエーション情報(SDP/ICE)を送信
SignalingServer-->>UserB: WebRTCネゴシエーション情報(SDP/ICE)を転送
UserB->>SignalingServer: (4) WebRTCネゴシエーション情報(SDP/ICE)で応答
SignalingServer-->>UserA: 応答WebRTCネゴシエーション情報(SDP/ICE)を転送
UserA-->>UserB: (5) P2P接続(DataChannel)を確立
UserA->>UserB: (6) DataChannel経由でファイルチャンク転送
```
**プロセス:**
1. ユーザーAが部屋を作成して参加し、シグナリングサーバーに接続します。
2. ユーザーBが部屋に参加し、シグナリングサーバーに接続します。
3. ユーザーAがユーザーBとのWebRTCネゴシエーション(SDPとICE情報を含む)を開始します。
4. ユーザーBがWebRTCネゴシエーション情報で応答し、P2P接続の確立を完了します。
5. 最後に、ファイルがP2P接続上のDataChannelを通じて転送されます。
### 1.3 SCTPover DTLS & UDP)の性能魔法
WebRTCの**DataChannel**は**DTLS**と**UDP**上で動作する**ストリーム制御伝送プロトコル(SCTP)**に基づいており、従来のTCPに対して3つの大きな利点があります:
1. **ストリーム多重化(現未採用)**:ファイルチャンクを並列転送でき、転送効率を向上させます。
2. **ヘッドオブラインブロッキングなし**:単一チャンクの損失が全体の進行に影響せず、転送安定性を保証します。
3. **自動輻輳制御**:ネットワークジッターに動的に適応し、転送性能を最適化します。
**UDPの利点:**
- **低遅延**:UDPはコネクションレスプロトコルで、3wayハンドシェイク不要で、リアルタイム通信に適しています。
- **柔軟な信頼性**:UDP自体は信頼性ありませんが、SCTPがその上に信頼性ある伝送メカニズムを実装し、UDPの柔軟性とTCPの信頼性を組み合わせています。
**SCTPマルチストリーム転送図**
```mermaid
graph TD
A[送信者] --> B[DataChannel 1]
A --> C[DataChannel 2]
A --> D[DataChannel 3]
B --> E[受信者]
C --> E
D --> E
```
## 二、ブラウザー直接転送エンジン:核心技術解読
### 2.1 チャンク転送の精密制御
```typescript
// lib/fileSender.ts - 64KB固定サイズチャンク
// 各チャンクサイズを65536バイト(64KB)と定義し、ネットワークMTU(最大伝送単位)サイズに正確に一致させます。
// これにより、過大なパケットによるネットワーク輻輳やフラグメンテーション問題を防ぎます。
private readonly CHUNK_SIZE = 65536;
// 固定サイズでファイルをチャンク処理するための非同期ジェネレーター関数を作成します。
// 各ジェネレーター呼び出しでArrayBuffer型のチャンクデータを返します。
private async *createChunkGenerator(file: File) {
let offset = 0; // 現在のファイル読み取り位置をマークするためにオフセットを初期化
// ファイル全体が処理されるまでループ
while (offset < file.size) {
// File.sliceメソッドを使用して[offset, offset + CHUNK_SIZE)範囲のデータセグメントを抽出
const chunk = file.slice(offset, offset + this.CHUNK_SIZE);
// 抽出したデータをArrayBufferに変換し、yield経由で返す
yield await chunk.arrayBuffer();
// 次のチャンク用にオフセットを更新
offset += this.CHUNK_SIZE;
}
}
// バックプレッシャー制御アルゴリズム:送信がDataChannelバッファー制限を超えないようにします。
// バッファーが満杯の場合、バッファー空間が利用可能になるまで待機します。
private async sendWithBackpressure(chunk: ArrayBuffer) {
// DataChannelバッファー使用量がプリセット最大値を超えた場合に送信を一時停止
while (this.dataChannel.bufferedAmount > this.MAX_BUFFER) {
// bufferedamountlowイベントを待機してバッファー空間が解放されたことを示すPromiseを使用
await new Promise(r => this.dataChannel.bufferedamountlow = r);
}
// バッファーに十分な空間がある場合に現在のチャンクを送信
this.dataChannel.send(chunk);
}
```
### 2.2 ゼロコピーメモリ書き込み
File System Access APIを通じて実装:
```typescript
// lib/fileReceiver.ts
// 受信したチャンクデータを直接ディスクに書き込み、余分なメモリコピーを回避します
private async writeToDisk(chunk: ArrayBuffer) {
// ファイルライターがまだ初期化されていない場合
if (!this.writer) {
// ファイル保存ピッカーダイアログを表示し、ユーザーが保存場所を選択
this.currentFileHandle = await window.showSaveFilePicker();
// ファイルハンドル経由で書き込み可能ストリームを作成し、後続の書き込み用に準備
this.writer = await this.currentFileHandle.createWritable();
}
// 受信したArrayBufferをUint8Arrayに変換し、直接ディスクに書き込み
// これによりメモリバッファーを回避し、ゼロコピー書き込みを実現して性能を向上
await this.writer.write(new Uint8Array(chunk));
}
```
## 三、分散部屋管理システム
### 3.1 四桁数字衝突検出:
```typescript
// server.ts
async function getAvailableRoomId() {
let roomId;
do {
roomId = Math.floor(1000 + Math.random() * 9000); // 四桁の乱数を生成
} while (await redis.hexists(`room:${roomId}`, "created_at")); // 存在するかチェック
return roomId;
}
```
注:4桁の数字はシステム生成のランダム部屋IDです。お好みの任意の部屋IDを指定できます。
### 3.2 優雅な有効期限戦略:
```typescript
// server.ts
await refreshRoom(roomId, 3600 * 24); // アクティブ部屋は24時間保持
if (await isRoomEmpty(roomId)) {
// 空き部屋の場合(送信者と受信者の両方が退出した場合)、部屋を解放
await deleteRoom(roomId);
}
```
### 3.3 シグナリング駆動の復旧プロトコル
モバイル切断回復フロー:
```mermaid
sequenceDiagram
participant Sender
participant Signaling
participant Recipient
Sender->>Signaling: フロントエンド可視時にinitiator-onlineを送信
Signaling->>Recipient: オンライン通知を転送
Recipient->>Signaling: recipient-readyで応答
Signaling->>Sender: 再接続プロセスをトリガー
Sender->>Recipient: ICE接続を再構築
```
このメカニズムにより、ユーザーがモバイルデバイスでアプリを切り替えたりバックグラウンドに入ったりしても、システムは迅速に接続を回復できます(モバイルでもWakelockでスリープを防止)、良好なユーザー体験を保証します。
## 四、セキュリティとプライバシー防御ライン
### 4.1 暗号化プロトコルフライホイール
```
アプリケーション層
DTLS 1.2+ → TLS_ECDHE_RSA_AES_128_GCM_SHA256
OSレベル暗号化
```
**説明:**
1. **DTLSDatagram Transport Layer Security**
- DTLSはUDPベースのセキュア伝送プロトコルで、TLS類似の暗号化機能を提供します。
- WebRTCでは、すべてのデータチャネルがDTLS経由でエンドツーエンド暗号化され、伝送中の盗聴や改ざんを防ぎます。
- 暗号化スイート**`TLS_ECDHE_RSA_AES_128_GCM_SHA256`**を使用し、高強度のセキュリティを提供します。
2. **OSレベル暗号化**
- OSレベルで、現代ブラウザーはメモリ内の機密データに追加保護を提供し、悪意のあるソフトウェアによるアクセスを防ぎます。
**要約:**
DTLSとOSレベル暗号化の二重保護により、WebRTCは強力なプライバシー保護能力を提供し、ファイル転送中のデータセキュリティを保証します。
### 4.2 攻撃面防御マトリックス
| **攻撃タイプ** | **防御措置** | **説明** |
| --- | --- | --- |
| **MITM** | **SDP指紋検証** | **DTLS公開鍵ハッシュ値から一意の指紋を生成し、通信相手の信頼性を確保し、中間者によるデータストリームの偽造や改ざんを防止します。** |
| **部屋ID巡回攻撃** | **部屋入室レート制限** | **各IPアドレスの部屋入室頻度を制限し(例:5秒以内に最大2回)、悪意のあるユーザーが部屋番号を巡回してコンテンツにアクセスするのを防ぎます。** |
**説明:**
1. **MITMMan-in-the-Middle Attack**
- **原理**WebRTCはSDP指紋(DTLS公開鍵ハッシュ値に基づく)を使用してハンドシェイク中に通信相手の身份を検証します。攻撃者は有効な指紋を偽造できないため、正当な通信者を装うことができません。
- **効果**:P2P接続のセキュリティとデータ完全性を保証し、盗聴や改ざんを防ぎます。
2. **部屋ID巡回攻撃**
- **定義**:悪意のあるユーザーが異なる部屋番号(例:4桁ID)を試すことで、未承認の部屋に入り共有コンテンツにアクセスしようとする可能性があります。
- **防御措置**
- **レート制限**:各IPアドレスの部屋入室頻度を制限します。例:5秒以内に最大2回の部屋入室を許可。
- **実装方法**:Redisを使用してIPリクエスト記録をキャッシュし、異常な動作の迅速な検出とブロッキングを行います。
- **効果**:悪意のあるユーザーが部屋番号巡回による機密コンテンツアクセスを効果的に防ぎ、ユーザープライバシーを保護します。
## 結論:信頼できる転送インフラの構築
私たちは、技術が本質的な人間のニーズに奉仕すべきであり、新しい監視依存を作るべきではないと固く信じています。今すぐこのプライバシーセキュアなファイル転送ツールを体験し、P2P技術がもたらす革命的変化を感じてください![<u>**PrivyDropポータル**</u>](https://www.privydrop.app)をクリックして開始してください。
**コード透明性コミットメント**:コードは将来的にオープンソース化されます。私たちは、社区共同ガバナンスを通じて真に信頼できるプライバシーツールを確立することに取り組んでいます。
## よくある質問
- **大きなファイル転送は中断しやすいですか?**
- まだそのようなケースは観察されていません。P2P(デバイス間)接続は一般的に安定しています。将来のフィードバックに基づき、レジューム機能を追加する可能性があります。
- **部屋にパスワードを追加するとより安全になりますか?**
- 理論的にははい。パスワード追加がわずかに使いやすさに影響することを考慮し、まだ実装されていません。セキュリティを向上させたい場合、任意のカスタム文字列をRoomIDとして使用し、リンクとQRコード経由で共有できます。さらに、システムは受信者の部屋入室頻度を制限し、さらなるセキュリティ向上を図っています。
- **送信者はいつでもPrivyDropページを閉じられますか?**
- はい、コンテンツが受信された後に閉じることが望ましいです。デバイス間直接接続のため、送信者がオフラインの場合、共有は不可能です。共有を停止したい場合、ページをすぐに閉じることができます。
その他の質問?[<u>**PrivyDrop FAQ**</u>](https://www.privydrop.app/faq)または[<u>**PrivyDrop Help**</u>](https://www.privydrop.app/help)セクションをクリックして、さらに回答とヘルプをご覧ください。
**開発者リソース**
- [<u>**WebRTC公式ドキュメント**</u>](https://webrtc.org/)
@@ -0,0 +1,277 @@
---
title: "브라우저 간 직접 연결! WebRTC 기반 프라이버시 보호 파일 전송 핵심 기술 공개"
description: "이 기사는 WebRTC 기반 프라이버시 보호 파일 전송 도구의 핵심 기술에 대해 깊이 탐구하며, P2P 전송, E2EE 암호화, 스트림 멀티플렉싱 등을 포함합니다. 기술 애호가와 일반 사용자 모두에게 적합합니다."
date: "2025-02-09"
author: "david bai"
cover: "/blog-assets/webrtc-file-transfer.jpg"
tags: [WebRTC, P2P 전송, 프라이버시 보안]
status: "published"
---
![](/blog-assets/webrtc-file-transfer.jpg)
## 서론
전통적인 파일 전송 방식은 대부분 클라우드 스토리지나 중앙 집중식 서버에 의존하여 데이터 프라이버시에 대한 우려를 제기할 뿐만 아니라 업로드 크기 제한, 속도 병목 현상 등 많은 문제에 직면했습니다. 우리의 도구는 WebRTC 기술을 활용하여 기기 간 직접 전송을 실현하여 이러한 문제들을 완전히 해결했습니다.
우리가 개발한 이 도구([<u>**PrivyDrop**</u>](https://www.privydrop.app))는 다음과 같은 두드러진 특징을 가지고 있습니다:
- WebRTC 기술을 사용한 기기 간 직접 전송, 중간 서버 불필요
- 종단 간 암호화(E2EE)로 안전한 데이터 전송 보장
- 등록 불필요, 즉시 사용 가능, 다중 동시 수신자 지원
- 텍스트, 이미지, 파일, 폴더 등 다양한 유형의 데이터 전송 지원
- 전송 속도와 파일 크기는 기기 간 네트워크 대역폭과 디스크 공간에만 제한
이 기사에서는 이 도구의 기술 아키텍처, 작동 원리, 그리고 왜 이렇게 안전하고 효율적인 파일 전송 경험을 제공할 수 있는지에 대해 탐구합니다. 기술 애호가이든 일반 사용자이든, WebRTC 기술이 파일 전송 분야에 가져온 혁명적인 변화를 이해할 수 있을 것입니다.
## 일. 파일 전송 재정의: WebRTC의 아키텍처 혁명
WebRTC(Web Real-Time Communication)는 브라우저 간 실시간 통신을 지원하는 개방형 표준입니다. WebRTC 기반으로 개발된 우리의 파일 전송 도구는 주로 다음과 같은 핵심 구성 요소를 포함합니다:
1. **시그널링 서버**: 기기 간 연결을 조정하지만 실제 데이터 전송에는 참여하지 않습니다.
2. **P2P 연결**: 제3자 서버 개입 없이 기기 간 직접 연결을 설정합니다.
3. **E2EE 암호화**: 모든 데이터가 전송 중 DTLS 프로토콜로 종단 간 암호화됩니다.
### 1.1 전통적인 방식 vs WebRTC 방식
| 특징 | 전통적 HTTP 전송 | WebRTC P2P 전송 |
| --- | --- | --- |
| 전송 경로 | 클라이언트 → 서버 → 클라이언트 | 기기 간 직접 연결 |
| 지연 시간 | 중앙 서버 대역폭에 제한됨 | 물리적 네트워크 대역폭에만 제한됨 |
| 파일 크기 제한 | 일반적으로 제한 있음 | 디스크 공간에만 제한됨 |
| 프라이버시 보호 | 서비스 제공업체 보안 조치에 의존 | DTLS 프로토콜 강제 암호화 |
### 1.2 P2P 연결 설정 프로세스
```mermaid
sequenceDiagram
participant UserA as 사용자 A(송신자)
participant SignalingServer as 시그널링 서버
participant UserB as 사용자 B(수신자)
UserA->>SignalingServer: (1) 방 생성 및 참여
activate SignalingServer
UserB->>SignalingServer: (2) 방 참여
SignalingServer-->>UserA: 사용자 B 참여 통지
UserA->>SignalingServer: (3) WebRTC 협상 정보(SDP/ICE) 전송
SignalingServer-->>UserB: WebRTC 협상 정보(SDP/ICE) 전달
UserB->>SignalingServer: (4) WebRTC 협상 정보(SDP/ICE) 응답
SignalingServer-->>UserA: 응답 WebRTC 협상 정보(SDP/ICE) 전달
UserA-->>UserB: (5) P2P 연결(DataChannel) 설정
UserA->>UserB: (6) DataChannel을 통한 파일 청크 전송
```
**프로세스:**
1. 사용자 A가 방을 생성하고 참여하여 시그널링 서버에 연결합니다.
2. 사용자 B가 방에 참여하여 시그널링 서버에 연결합니다.
3. 사용자 A가 사용자 B와 WebRTC 협상(SDP 및 ICE 정보 포함)을 시작합니다.
4. 사용자 B가 WebRTC 협상 정보로 응답하여 P2P 연결 설정을 완료합니다.
5. 최종적으로 파일이 P2P 연결 위의 DataChannel을 통해 전송됩니다.
### 1.3 SCTP(over DTLS & UDP)의 성능 마법
WebRTC의 **DataChannel**은 **DTLS**와 **UDP** 위에서 실행되는 **스트림 제어 전송 프로토콜(SCTP)**을 기반으로 하며, 전통적인 TCP에 비해 세 가지 큰 장점을 가집니다:
1. **스트림 멀티플렉싱(현재 미사용)**: 파일 청크를 병렬로 전송할 수 있어 전송 효율을 향상시킵니다.
2. **헤드 오브 라인 블로킹 없음**: 단일 청크 손실이 전체 진행에 영향을 주지 않아 전송 안정성을 보장합니다.
3. **자동 혼잡 제어**: 네트워크 지터에 동적으로 적응하여 전송 성능을 최적화합니다.
**UDP의 장점:**
- **낮은 지연 시간**: UDP는 3-way 핸드셰이크가 필요 없는 연결 없는 프로토콜로, 실시간 통신에 이상적입니다.
- **유연한 신뢰성**: UDP 자체는 신뢰성 없지만, SCTP가 그 위에 신뢰성 있는 전송 메커니즘을 구현하여 UDP의 유연성과 TCP의 신뢰성을 결합합니다.
**SCTP 다중 스트림 전송 다이어그램**
```mermaid
graph TD
A[송신자] --> B[DataChannel 1]
A --> C[DataChannel 2]
A --> D[DataChannel 3]
B --> E[수신자]
C --> E
D --> E
```
## 이. 브라우저 직접 전송 엔진: 핵심 기술 해독
### 2.1 청크 전송의 정밀 제어
```typescript
// lib/fileSender.ts - 64KB 고정 크기 청크
// 각 청크 크기를 65536 바이트(64KB)로 정의하여 네트워크 MTU(최대 전송 단위) 크기에 정확히 일치시킵니다.
// 이는 과대한 패킷으로 인한 네트워크 혼잡이나 단편화 문제를 방지합니다.
private readonly CHUNK_SIZE = 65536;
// 고정 크기로 파일을 청크 처리하기 위한 비동기 생성기 함수를 생성합니다.
// 각 생성기 호출 시 ArrayBuffer 유형의 청크 데이터를 반환합니다.
private async *createChunkGenerator(file: File) {
let offset = 0; // 현재 파일 읽기 위치를 표시하기 위해 오프셋 초기화
// 모든 데이터가 청크 처리될 때까지 파일 루프
while (offset < file.size) {
// File.slice 메서드를 사용하여 [offset, offset + CHUNK_SIZE) 범위의 데이터 세그먼트 추출
const chunk = file.slice(offset, offset + this.CHUNK_SIZE);
// 추출한 데이터를 ArrayBuffer로 변환하고 yield를 통해 반환
yield await chunk.arrayBuffer();
// 다음 청크용 오프셋 업데이트
offset += this.CHUNK_SIZE;
}
}
// 백 프레셔 제어 알고리즘: 전송이 DataChannel 버퍼 제한을 초과하지 않도록 합니다.
// 버퍼가 가득 찬 경우 버퍼 공간이 사용 가능해질 때까지 기다립니다.
private async sendWithBackpressure(chunk: ArrayBuffer) {
// DataChannel 버퍼 사용량이 사전 설정된 최대치를 초과할 때 전송 일시 중지
while (this.dataChannel.bufferedAmount > this.MAX_BUFFER) {
// 버퍼 공간 해제를 나타내는 bufferedamountlow 이벤트를 위해 Promise 사용
await new Promise(r => this.dataChannel.bufferedamountlow = r);
}
// 버퍼에 충분한 공간이 있을 때 현재 청크 전송
this.dataChannel.send(chunk);
}
```
### 2.2 제로 카피 메모리 쓰기
File System Access API를 통해 구현:
```typescript
// lib/fileReceiver.ts
// 수신된 청크 데이터를 디스크에 직접 쓰기, 추가 메모리 복사 방지
private async writeToDisk(chunk: ArrayBuffer) {
// 파일 작성기가 아직 초기화되지 않은 경우
if (!this.writer) {
// 파일 저장 선택 대화상자 표시, 사용자가 저장 위치 선택
this.currentFileHandle = await window.showSaveFilePicker();
// 파일 핸들을 통해 쓰기 가능 스트림(WritableStream) 생성, 후속 쓰기용 준비
this.writer = await this.currentFileHandle.createWritable();
}
// 수신된 ArrayBuffer 데이터를 Uint8Array로 변환하고 디스크에 직접 쓰기
// 이는 메모리 버퍼를 우회하여 제로 카피 쓰기를 구현하고 성능 향상
await this.writer.write(new Uint8Array(chunk));
}
```
## 삼. 분산 방 관리 시스템
### 3.1 네 자리 숫자 충돌 감지:
```typescript
// server.ts
async function getAvailableRoomId() {
let roomId;
do {
roomId = Math.floor(1000 + Math.random() * 9000); // 네 자리 난수 생성
} while (await redis.hexists(`room:${roomId}`, "created_at")); // 존재 여부 확인
return roomId;
}
```
참고: 4자리 숫자는 시스템 생성 난수 방 ID입니다. 원하는 모든 방 ID를 지정할 수 있습니다.
### 3.2 우아한 만료 전략:
```typescript
// server.ts
await refreshRoom(roomId, 3600 * 24); // 활성 방 24시간 유지
if (await isRoomEmpty(roomId)) {
// 방이 비어있는 경우(송신자, 수신자 모두 나간 경우), 방 해제
await deleteRoom(roomId);
}
```
### 3.3 시그널링 구동 재생 프로토콜
모바일 연결 끊김 복구 흐름:
```mermaid
sequenceDiagram
participant Sender
participant Signaling
participant Recipient
Sender->>Signaling: 프론트엔드 가시 시 initiator-online 전송
Signaling->>Recipient: 온라인 알림 전달
Recipient->>Signaling: recipient-ready 응답
Signaling->>Sender: 재연결 프로세스 트리거
Sender->>Recipient: ICE 연결 재구축
```
이 메커니즘을 통해 사용자가 모바일 기기에서 앱을 전환하거나 백그라운드로 들어가도 시스템은 빠르게 연결을 복구할 수 있습니다(모바일도 수면 방지를 위해 Wakelock 포함), 양호한 사용자 경험을 보장합니다.
## 사. 보안 및 프라이버시 방어선
### 4.1 암호화 프로토콜 플라이휠
```
애플리케이션 계층
DTLS 1.2+ → TLS_ECDHE_RSA_AES_128_GCM_SHA256
OS 레벨 암호화
```
**설명:**
1. **DTLS(Datagram Transport Layer Security)**:
- DTLS는 UDP 기반의 보안 전송 프로토콜로 TLS 유사 암호화 기능을 제공합니다.
- WebRTC에서 모든 데이터 채널(DataChannel)은 DTLS를 통해 종단 간 암호화되어 전송 중 도청이나 변조를 방지합니다.
- 암호화 스위트 **`TLS_ECDHE_RSA_AES_128_GCM_SHA256`**를 사용하여 고강도 보안을 제공합니다.
2. **OS 레벨 암호화**:
- OS 레벨에서 현대 브라우저는 메모리 내 민감 데이터에 대한 추가 보호를 제공하여 악성 소프트웨어 접근을 방지합니다.
**요약:**
DTLS와 OS 레벨 암호화의 이중 보호를 통해 WebRTC는 강력한 프라이버시 보호 능력을 제공하여 파일 전송 과정의 데이터 안전을 보장합니다.
### 4.2 공격 표면 방어 행렬
| **공격 유형** | **방어 조치** | **설명** |
| --- | --- | --- |
| **MITM** | **SDP 지문 검증** | **DTLS 공개 키 해시값에서 고유 지문 생성, 통신 당사자 신뢰성 보장, 중간자 데이터 스트림 위조나 변조 방지.** |
| **방 ID 순회 공격** | **방 입장 속도 제한** | **각 IP 주소의 방 입장 빈도 제한(예: 5초 내 최대 2회), 악의적 사용자가 방 번호 순회로 내용 접근 방지.** |
**설명:**
1. **MITM(Man-in-the-Middle Attack)**
- **원리**: WebRTC는 핸드셰이크 과정에서 SDP 지문(DTLS 공개 키 해시값 기반)을 사용하여 통신 당사자 신원을 검증합니다. 공격자는 유효한 지문을 위조할 수 없으므로 합법적 통신 당사자로 가장할 수 없습니다.
- **효과**: P2P 연결 보안과 데이터 무결성 보장, 도청이나 변조 방지.
2. **방 ID 순회 공격**
- **정의**: 악의적 사용자가 다른 방 번호(예: 네 자리 ID)를 시도하여 미승인 방에 들어가 공유 콘텐츠에 접근하려고 시도할 수 있습니다.
- **방어 조치**:
- **속도 제한**: 각 IP 주소의 방 입장 빈도 제한, 예: 5초 내 최대 2회 방 입장 허용.
- **구현 방식**: Redis를 사용하여 IP 요청 기록 캐시, 이상 동작 빠른 감지 및 차단.
- **효과**: 악의적 사용자가 방 번호 순회를 통한 민감 콘텐츠 접근을 효과적으로 방지, 사용자 프라이버시 보호.
## 결론: 신뢰할 수 있는 전송 인프라 구축
우리는 기술이 인간의 본질적인 요구를 봉사해야 하며, 새로운 감시 의존을 만들어서는 안 된다고 굳게 믿습니다. 지금 이 프라이버시 보안 파일 전송 도구를 경험하고 P2P 기술이 가져온 혁명적 변화를 느껴보세요! [<u>**PrivyDrop 포털**</u>](https://www.privydrop.app)을 클릭하여 시작하세요.
**코드 투명성 약속**: 코드는 미래에 오픈 소스가 될 것입니다. 우리는 커뮤니티 공동 거버넌스를 통해 진정으로 신뢰할 수 있는 프라이버시 도구를 확립하는 데 전념하고 있습니다.
## 자주 묻는 질문
- **큰 파일 전송이 중단되기 쉬운가요?**
- 아직 그런 경우를 관찰하지 않았습니다. P2P(기기 간) 연결은 일반적으로 안정적입니다. 향후 피드백에 따라 재시작 기능을 추가할 수 있습니다.
- **방에 비밀번호를 추가하면 더 안전한가요?**
- 이론적으로 그렇습니다. 비밀번호 추가가 사용성에 약간 영향을 줄 것을 고려하여 아직 구현되지 않았습니다. 보안을 높이고 싶다면 임의의 사용자 정의 문자열을 RoomID로 사용하고 링크와 QR 코드를 통해 공유할 수 있습니다. 또한 시스템은 수신자의 방 입장 빈도를 제한하여 추가로 보안을 향상시킵니다.
- **송신자는 언제든지 PrivyDrop 페이지를 닫을 수 있나요?**
- 네, 공유 콘텐츠가 수신된 후에 닫는 것이 좋습니다. 기기 간 직접 연결이므로 송신자가 오프라인이면 공유가 불가능합니다. 공유를 중단하고 싶다면 즉시 페이지를 닫을 수 있습니다.
더 궁금한 점이 있으신가요? [<u>**PrivyDrop FAQ**</u>](https://www.privydrop.app/faq) 또는 [<u>**PrivyDrop 도움말**</u>](https://www.privydrop.app/help) 섹션을 클릭하여 더 많은 답변과 도움을 확인하세요.
**개발자 리소스**
- [<u>**WebRTC 공식 문서**</u>](https://webrtc.org/)
+10 -1
View File
@@ -86,8 +86,17 @@ export function useRoomManager({
? shareRoomId
: roomId;
// If sender uses a long ID (e.g., cached UUID), proactively send
// "initiator-online" after join to trigger receivers' re-handshake.
const forceInitiatorOnline =
isSenderSide && typeof actualRoomId === "string" && actualRoomId.length >= 8;
// Directly call the service method without dependency injection
await webrtcService.joinRoom(actualRoomId, isSenderSide);
await webrtcService.joinRoom(
actualRoomId,
isSenderSide,
forceInitiatorOnline
);
putMessageInMs(
messages.text.ClipboardApp.joinRoom.successMsg,
+19 -19
View File
@@ -20,23 +20,21 @@ export interface BlogPost {
}
export async function getAllPosts(lang: string): Promise<BlogPost[]> {
const files = fs.readdirSync(POSTS_PATH);
const postsWithLang = files
.filter((file) => /\.mdx?$/.test(file))
.map((file) => {
const langFromFile = file.match(/-([a-z]{2})\.mdx?$/)?.[1];
return { file, langFromFile };
});
const lang_dst = lang === "zh" ? "zh" : "en";
const filteredFiles = postsWithLang.filter(
({ langFromFile }) => langFromFile === lang_dst
);
// Read all directories in the blog path
const directories = fs.readdirSync(POSTS_PATH).filter((file) => {
const fullPath = path.join(POSTS_PATH, file);
return fs.statSync(fullPath).isDirectory();
});
const posts = await Promise.all(
filteredFiles.map(async ({ file }) => {
const filePath = path.join(POSTS_PATH, file);
directories.map(async (directory) => {
const filePath = path.join(POSTS_PATH, directory, `${lang}.mdx`);
// Check if the language file exists
if (!fs.existsSync(filePath)) {
return null;
}
const source = fs.readFileSync(filePath, "utf8");
const { data, content } = matter(source);
@@ -52,7 +50,7 @@ export async function getAllPosts(lang: string): Promise<BlogPost[]> {
};
return {
slug: file.replace(/-[a-z]{2}\.mdx?$/, "").replace(/\.mdx?$/, ""),
slug: directory,
frontmatter,
content,
} as BlogPost;
@@ -60,7 +58,10 @@ export async function getAllPosts(lang: string): Promise<BlogPost[]> {
);
return posts
.filter((post) => post.frontmatter.status === "published")
.filter(
(post): post is BlogPost =>
post !== null && post.frontmatter.status === "published"
)
.sort(
(a, b) =>
new Date(b.frontmatter.date).getTime() -
@@ -73,8 +74,7 @@ export async function getPostBySlug(
lang: string
): Promise<BlogPost | null> {
try {
const lang_dst = lang === "zh" ? "zh" : "en";
const filePath = path.join(POSTS_PATH, `${slug}-${lang_dst}.mdx`);
const filePath = path.join(POSTS_PATH, slug, `${lang}.mdx`);
const source = fs.readFileSync(filePath, "utf8");
const { data, content } = matter(source);
+1 -1
View File
@@ -88,7 +88,7 @@ export const mdxOptions = {
if (node.tagName === "table") {
(node as ExtendedElement).properties = {
...((node as ExtendedElement).properties || {}),
className: "min-w-full divide-y divide-gray-300",
className: "min-w-full divide-y divide-border",
};
}
}) as BuildVisitor<Root, "element">);
+37
View File
@@ -0,0 +1,37 @@
// Utilities to cache a single room ID in browser localStorage
// Works on client only; no-ops on server.
const CACHED_KEY = "pd_cached_room_id_v1";
function isClient() {
return typeof window !== "undefined";
}
export function getCachedId(): string | null {
if (!isClient()) return null;
try {
const v = window.localStorage.getItem(CACHED_KEY);
return v && v.trim() ? v : null;
} catch (_) {
return null;
}
}
export function setCachedId(id: string): void {
if (!isClient()) return;
try {
window.localStorage.setItem(CACHED_KEY, id);
} catch (_) {
// ignore
}
}
export function clearCachedId(): void {
if (!isClient()) return;
try {
window.localStorage.removeItem(CACHED_KEY);
} catch (_) {
// ignore
}
}
+135
View File
@@ -0,0 +1,135 @@
// Use hardcoded site URL to keep consistent with sitemap and deployment
export const getSiteUrl = (): string => {
return "https://www.privydrop.app";
};
export const absoluteUrl = (path: string, siteUrl = getSiteUrl()): string => {
if (!path) return siteUrl;
if (/^https?:\/\//i.test(path)) return path;
return `${siteUrl}${path.startsWith("/") ? path : `/${path}`}`;
};
export function buildOrganizationJsonLd(params: {
siteUrl?: string;
name?: string;
logoUrl: string;
sameAs?: string[];
}) {
const siteUrl = params.siteUrl || getSiteUrl();
return {
"@context": "https://schema.org",
"@type": "Organization",
"@id": `${siteUrl}/#organization`,
name: params.name || "PrivyDrop",
url: `${siteUrl}/`,
logo: params.logoUrl,
sameAs: params.sameAs || [],
};
}
export function buildWebSiteJsonLd(params: {
siteUrl?: string;
name?: string;
inLanguage?: string;
}) {
const siteUrl = params.siteUrl || getSiteUrl();
return {
"@context": "https://schema.org",
"@type": "WebSite",
"@id": `${siteUrl}/#website`,
url: `${siteUrl}/`,
name: params.name || "PrivyDrop",
publisher: { "@id": `${siteUrl}/#organization` },
inLanguage: params.inLanguage,
};
}
export function buildWebAppJsonLd(params: {
siteUrl?: string;
path: string; // e.g. '/zh'
name: string;
description: string;
inLanguage?: string;
alternateName?: string[];
imageUrl?: string;
applicationCategory?: string; // default UtilityApplication
operatingSystem?: string; // default Web Browser
}) {
const siteUrl = params.siteUrl || getSiteUrl();
const url = absoluteUrl(params.path, siteUrl);
return {
"@context": "https://schema.org",
"@type": "WebApplication",
"@id": `${url}#app`,
name: params.name,
alternateName: params.alternateName?.length ? params.alternateName : undefined,
description: params.description,
applicationCategory: params.applicationCategory || "UtilityApplication",
operatingSystem: params.operatingSystem || "Web Browser",
isAccessibleForFree: true,
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
url,
image: params.imageUrl,
publisher: { "@id": `${siteUrl}/#organization` },
inLanguage: params.inLanguage,
};
}
export function buildFaqJsonLd(params: {
inLanguage?: string;
faqs: { question: string; answer: string }[];
}) {
return {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: params.faqs.map((f) => ({
"@type": "Question",
name: f.question,
acceptedAnswer: { "@type": "Answer", text: f.answer },
})),
inLanguage: params.inLanguage,
};
}
export function buildBlogPostingJsonLd(params: {
siteUrl?: string;
url: string; // absolute url
title: string;
description: string;
datePublished: string;
dateModified?: string;
authorName: string;
imageUrl?: string;
inLanguage?: string;
}) {
const siteUrl = params.siteUrl || getSiteUrl();
return {
"@context": "https://schema.org",
"@type": "BlogPosting",
"@id": `${params.url}#post`,
headline: params.title,
description: params.description,
datePublished: params.datePublished,
dateModified: params.dateModified || params.datePublished,
author: { "@type": "Person", name: params.authorName },
publisher: { "@id": `${siteUrl}/#organization` },
mainEntityOfPage: params.url,
image: params.imageUrl,
inLanguage: params.inLanguage,
};
}
export function buildBreadcrumbJsonLd(params: {
items: { name: string; item: string }[]; // absolute urls
}) {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: params.items.map((it, idx) => ({
"@type": "ListItem",
position: idx + 1,
name: it.name,
item: it.item,
})),
};
}
+9 -2
View File
@@ -141,7 +141,11 @@ class WebRTCService {
}
// Business methods
public async joinRoom(roomId: string, isSender: boolean): Promise<void> {
public async joinRoom(
roomId: string,
isSender: boolean,
forceInitiatorOnline: boolean = false
): Promise<void> {
// Ensure clean state before joining
if (!isSender) {
// Force reset FileReceiver state to prevent "already in progress" errors
@@ -149,7 +153,10 @@ class WebRTCService {
}
const peer = isSender ? this.sender : this.receiver;
await peer.joinRoom(roomId, isSender);
// If sender is performing a manual reconnect with a cached/long ID,
// optionally send an "initiator-online" after successful join so receivers
// can reply with "recipient-ready" to re-establish P2P.
await peer.joinRoom(roomId, isSender, isSender && !!forceInitiatorOnline);
const setInRoom = isSender
? useFileTransferStore.getState().setIsSenderInRoom
+53 -4
View File
@@ -66,6 +66,8 @@ export default class BaseWebRTC {
protected wakeLockManager: WakeLockManager;
// Graceful disconnect tracking
protected gracefullyDisconnectedPeers: Set<string>;
// Track last socket.id used to successfully join a room
protected lastJoinedSocketId: string | null;
constructor(config: WebRTCConfig) {
this.iceServers = config.iceServers;
@@ -94,6 +96,7 @@ export default class BaseWebRTC {
this.isPeerDisconnected = false;
this.reconnectionInProgress = false;
this.wakeLockManager = new WakeLockManager();
this.lastJoinedSocketId = null;
}
// region Logging and Error Handling
protected log(
@@ -113,9 +116,41 @@ export default class BaseWebRTC {
// endregion
// Sets up event listeners for the signaling server to handle various signaling messages (connection, ICE candidates, offer, answer, etc.).
setupCommonSocketListeners() {
this.socket.on("connect", () => {
this.socket.on("connect", async () => {
this.peerId = this.socket.id; // Save own ID
this.isSocketDisconnected = false;
this.log("log", `Connected to signaling server, peerId: ${this.peerId}`);
// Auto re-join if we previously joined a room but socket.id changed
const hasRoom = !!this.roomId;
const currentSocketId = this.socket.id ?? null;
const socketIdChanged =
this.lastJoinedSocketId !== null &&
this.lastJoinedSocketId !== currentSocketId;
if (hasRoom && (socketIdChanged || !this.isInRoom)) {
// Ensure joinRoom does not early-return
if (socketIdChanged) this.isInRoom = false;
if (!this.reconnectionInProgress) {
this.reconnectionInProgress = true;
try {
const sendInitiatorOnline = this.isInitiator;
await this.joinRoom(
this.roomId as string,
this.isInitiator,
sendInitiatorOnline
);
// Reset flags after successful auto rejoin
this.isSocketDisconnected = false;
this.isPeerDisconnected = false;
} catch (error) {
this.fireError("Auto rejoin on socket connect failed", { error });
} finally {
this.reconnectionInProgress = false;
}
}
}
});
this.socket.on("error", (error) => {
@@ -149,17 +184,25 @@ export default class BaseWebRTC {
}
protected async attemptReconnection(): Promise<void> {
if (this.reconnectionInProgress) return;
if (!this.roomId) return;
if (this.isSocketDisconnected && this.isPeerDisconnected && this.roomId) {
// Start reconnection only after both socket and P2P connections are disconnected
const currentSocketId = this.socket.id ?? null;
const socketIdChanged =
this.lastJoinedSocketId !== null &&
this.lastJoinedSocketId !== currentSocketId;
// Widen condition: if either side disconnected or socketId changed, try to rejoin
if (this.isPeerDisconnected || this.isSocketDisconnected || socketIdChanged) {
this.reconnectionInProgress = true;
if (developmentEnv === "development") {
postLogToBackend(
`Starting reconnection, socket and peer both disconnected. isInitiator:${this.isInitiator}`
`Starting reconnection. socketDisc:${this.isSocketDisconnected}, peerDisc:${this.isPeerDisconnected}, socketIdChanged:${socketIdChanged}, isInitiator:${this.isInitiator}`
);
}
try {
// Ensure joinRoom does not early-return
if (socketIdChanged) this.isInRoom = false;
const sendInitiatorOnline = this.isInitiator;
await this.joinRoom(this.roomId, this.isInitiator, sendInitiatorOnline);
@@ -323,11 +366,15 @@ export default class BaseWebRTC {
failed: async () => {
this.cleanupExistingConnection(peerId);
this.isPeerDisconnected = true;
// Attempt to reconnect as well when failed
this.attemptReconnection();
await this.wakeLockManager.releaseWakeLock();
},
closed: async () => {
this.cleanupExistingConnection(peerId);
this.isPeerDisconnected = true;
// Attempt to reconnect when closed
this.attemptReconnection();
await this.wakeLockManager.releaseWakeLock();
},
// The following must be added to prevent errors
@@ -437,6 +484,8 @@ export default class BaseWebRTC {
if (response.success) {
this.roomId = roomId;
this.isInRoom = true;
// Record the socket.id used for this successful join
this.lastJoinedSocketId = this.socket.id ?? null;
if (sendInitiatorOnline) {
this.socket.emit("initiator-online", {
roomId: this.roomId,
+1 -1
View File
@@ -22,7 +22,7 @@ const nextConfig = {
},
]
},
// 启用standalone输出模式,用于Docker部署
// Enable standalone output to run without dev deps on server
output: 'standalone',
// 禁用telemetry
experimental: {
+3
View File
@@ -44,6 +44,9 @@
"react-intersection-observer": "^9.16.0",
"remark-gfm": "^4.0.0",
"sharp": "^0.33.5",
"@swc/helpers": "^0.5.5",
"@next/env": "^14.2.5",
"styled-jsx": "^5.1.1",
"socket.io-client": "^4.7.5",
"tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7",
+9
View File
@@ -17,6 +17,9 @@ importers:
'@mdx-js/react':
specifier: ^3.1.0
version: 3.1.0(@types/react@18.3.22)(react@18.3.1)
'@next/env':
specifier: ^14.2.5
version: 14.2.5
'@next/mdx':
specifier: ^15.1.5
version: 15.1.5(@mdx-js/loader@3.1.0(acorn@8.12.1))(@mdx-js/react@3.1.0(@types/react@18.3.22)(react@18.3.1))
@@ -44,6 +47,9 @@ importers:
'@radix-ui/react-tooltip':
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.22)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@swc/helpers':
specifier: ^0.5.5
version: 0.5.5
'@types/hast':
specifier: ^3.0.4
version: 3.0.4
@@ -116,6 +122,9 @@ importers:
socket.io-client:
specifier: ^4.7.5
version: 4.7.5
styled-jsx:
specifier: ^5.1.1
version: 5.1.1(react@18.3.1)
tailwind-merge:
specifier: ^2.4.0
version: 2.4.0
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Some files were not shown because too many files have changed in this diff Show More