diff --git a/README.md b/README.md index e033c07..1da75fe 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,9 @@ macOS 虚拟显示依赖未公开的 `CGVirtualDisplay` CoreGraphics API。它 让 Mac 与 Windows 位于同一可信 IPv4 局域网。优先按应用程序为 LanExtend 放行“专用网络”,因为 WebRTC 还会协商动态 UDP 端口。固定控制端口方向为: - Windows 子端**出站** UDP `47771`:广播发现;Mac 主端需要能接收入站发现报文; -- Windows 子端**入站** TCP `47772`:默认 WebSocket 信令端口,可在子端 GUI 修改; +- Windows 子端**入站** TCP `47772`:默认 WebSocket 信令端口,也承载 Windows→Mac 文件流,可在子端 GUI 修改; - WebRTC 动态 UDP:双向媒体/连通性检查,由系统防火墙按 LanExtend 应用放行更合适。 -- 文件剪贴板动态 TCP:复制文件时由发送端临时监听随机高位端口,传完即释放;需按 LanExtend 应用允许两端专用网络入站。 +- Mac→Windows 文件流:Mac 复制文件时临时监听随机高位 TCP 端口,传完即释放;Mac 端需按 LanExtend 应用允许专用网络入站。 不要把这些端口映射到公网。当前发现和信令没有认证,信令也没有 TLS。 @@ -110,7 +110,7 @@ flowchart LR subgraph R["Windows 子端"] GUIR["Electron GUI"] --> RTCR["WebRTC 接收端"] GUIR --> IW["Windows 输入 helper"] - GUIR --> FR["按需文件流服务"] + GUIR --> FR["文件流服务(复用信令端口)"] GUIR --> MEMR["本机 settings.json"] GUIR --> ADV["UDP 广播"] GUIR --> SIG["WebSocket 信令服务"] @@ -119,7 +119,8 @@ flowchart LR GUIH -- "ws://子端:47772" --> SIG RTC1 -- "WebRTC 加密媒体(单路视频)" --> RTCR IH -- "WebSocket 键鼠 / 剪贴板" --> IW - FH <-. "动态 TCP 文件流(双向)" .-> FR + FR -. "Windows→Mac TCP/47772" .-> FH + FH -. "Mac→Windows 临时 TCP" .-> FR ``` 发现报文只用于定位子端;远端媒体不经过云服务。WebRTC 媒体本身使用其标准加密传输,但**设备身份、发现和 WebSocket 信令均未认证**,所以必须把整个二层/三层局域网视为信任边界。 diff --git a/docs/acceptance.md b/docs/acceptance.md index f56aa54..5b66f1d 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -114,6 +114,7 @@ Windows 型号 / CPU / GPU / Windows build: - [ ] K14 修改原文件后重新复制同一路径会再次发送;远端写入不会立即反向回传。 - [ ] K15 已完成缓存 7 天清理、启动时 `.partial` 清理和磁盘空间行为符合文档。 - [ ] K16 连续跨越边缘 100 次,无明显延迟累积、粘键或残留 helper。 +- [ ] K17 Windows→Mac 文件复制使用与子端信令相同的配置端口;Windows 不额外监听随机文件端口,WebSocket 与 HTTP 文件流可同时工作。 ## 9. 画质、性能与稳定性(P0/P1) @@ -146,7 +147,7 @@ Windows 型号 / CPU / GPU / Windows build: ## 11. 当前明确不验收为“支持”的能力 -以下项目若意外“看似可用”也不应纳入 v0.4.1 支持声明: +以下项目若意外“看似可用”也不应纳入 v0.4.2 支持声明: - 音频播放/转发; - 触控、图片/富文本剪贴板、剪贴板历史或文件元数据完整保真; diff --git a/docs/architecture.md b/docs/architecture.md index 34f08d4..aa81db2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,7 +56,7 @@ sequenceDiagram - 主端必须先收到 `welcome` 并取得子端真实 UUID,再用该 UUID 生成稳定虚拟显示 serial、创建显示器和发起 offer;手动地址占位 ID 会在此时被真实 ID 替换。 - UDP 发现只携带定位和能力信息;收到报文时使用 UDP 数据包的来源 IPv4 作为子端地址,不信任报文自报地址。 - WebSocket 只传 `hello/offer/answer/ice/ping/pong/disconnect` 等 JSON 信令,不承载视频帧。 -- 键鼠模式复用同一 WebSocket,增加 `control/input/clipboard/file-offer/file-status` 消息;它不创建 WebRTC 或虚拟显示器。文件内容不塞入 JSON,而是从当前复制端的临时 TCP 服务流式传输。 +- 键鼠模式复用同一 WebSocket,增加 `control/input/clipboard/file-offer/file-status` 消息;它不创建 WebRTC 或虚拟显示器。文件内容不塞入 JSON。Windows→Mac 文件流复用子端信令 TCP 监听器,Mac→Windows 则从 Mac 的临时 TCP 服务流式传输。 - 子端拒绝第二个同时在线的主端,返回 WebSocket 关闭码 `1013`。 ### 媒体面 @@ -93,7 +93,7 @@ HiDPI 模式把 GUI 请求宽高视为**逻辑桌面尺寸**,物理帧缓冲 共享期间两端每 500 ms 检查纯文本剪贴板,并约每 650 ms 通过原生 helper 检查文件剪贴板。启动共享时以 Mac 当前内容为初始值,之后任一端变化都会同步;文本和文件格式互斥检测,避免把 Finder/资源管理器显示的文件名误发成文本,并用系统剪贴板 revision 抑制回环。Windows 布局使用主显示器物理像素宽高,使高 DPI 缩放下的绝对光标坐标与 Win32 一致。 -文件复制端递归生成不可变清单,最多 10000 个条目、20 GiB,跳过符号链接/特殊文件;真正复制时才按需监听随机 TCP 端口。接收端按条目流式落盘到 userData 缓存,每个普通文件校验 SHA-256,并核对清单条目数、总字节和顶层名称;全部成功后才原子提交并写入系统文件剪贴板。传输可以在 GUI 取消,未完成目录立即删除,已完成缓存默认 7 天后清理。 +文件复制端递归生成不可变清单,最多 10000 个条目、20 GiB,跳过符号链接/特殊文件。Windows 作为文件源时复用信令端口的 HTTP 路由,避免另开入站随机端口;Mac 作为文件源时才按需监听随机 TCP 端口。接收端按条目流式落盘到 userData 缓存,每个普通文件校验 SHA-256,并核对清单条目数、总字节和顶层名称;全部成功后才原子提交并写入系统文件剪贴板。传输可以在 GUI 取消,未完成目录立即删除,已完成缓存默认 7 天后清理。 ## 5. 配置与“记忆”模型 diff --git a/docs/development.md b/docs/development.md index b395bb1..416f91a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -92,7 +92,7 @@ npm ci npm run dev:receiver ``` -确认 Windows 网络配置文件为“专用”:允许子端程序向 UDP `47771` 发出广播、从可信 Mac/子网接收配置的 TCP 信令端口,并允许 WebRTC 动态 UDP。文件剪贴板还会在当前复制端按需监听随机 TCP 端口,因此双端应按 LanExtend 应用而不是固定端口放行专用网络。Mac 端需要接收入站发现广播。不要对公用网络放行。 +确认 Windows 网络配置文件为“专用”:允许子端程序向 UDP `47771` 发出广播、从可信 Mac/子网接收配置的 TCP 信令端口(同端口承载 Windows→Mac 文件流),并允许 WebRTC 动态 UDP。Mac→Windows 文件流会在 Mac 按需监听随机 TCP 端口,因此 Mac 仍应按 LanExtend 应用放行专用网络。Mac 端还需要接收入站发现广播。不要对公用网络放行。 ### 双机调试 diff --git a/docs/protocol.md b/docs/protocol.md index e9eea32..f4dc3f2 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -7,11 +7,11 @@ | 用途 | 方向 | 默认端口 | 传输 | 上限 | | --- | --- | --- | --- | --- | | 子端发现 | Windows 子端 → IPv4 广播 | UDP `47771` | 单个 UTF-8 JSON 对象 | 解析上限 4 KiB | -| WebRTC 信令 | Mac 主端 → Windows 子端 | TCP `47772` | 明文 WebSocket `ws://`、UTF-8 JSON | 单消息 256 KiB | +| WebRTC 信令及 Windows 文件源 | Mac 主端 → Windows 子端 | TCP `47772` | 明文 WebSocket `ws://`、UTF-8 JSON;按需 HTTP 文件流 | 信令单消息 256 KiB;文件 10000 条目、20 GiB | | 视频 | Mac 主端 → Windows 子端 | 动态 | WebRTC ICE/DTLS-SRTP | 由 WebRTC 决定 | -| 文件剪贴板 | 当前复制端 → 另一端 | 动态高位 TCP | 按需 HTTP 二进制流 | 10000 条目、20 GiB | +| Mac 文件源 | Windows 子端 → Mac 主端 | 动态高位 TCP | 按需 HTTP 二进制流 | 10000 条目、20 GiB | -文件复制时,发送端按需在 `0.0.0.0:0` 启动一次临时 HTTP 文件流服务,并在 `file-offer` 中公布实际端口;它不是通用管理 API。当前没有云服务、STUN 或 TURN。信令端口可以在子端设置中修改,发现端口是固定协议常量。 +Windows 复制文件时,HTTP 文件流复用子端已经监听的信令 TCP 端口,避免 Windows 防火墙拦截额外随机端口;WebSocket upgrade 与 `/v1/transfers//stream` 在同一监听器上分流。Mac 复制文件时仍按需在 `0.0.0.0:0` 启动临时文件流服务,并在 `file-offer` 中公布实际端口。文件端点不是通用管理 API。当前没有云服务、STUN 或 TURN。信令端口可以在子端设置中修改,发现端口是固定协议常量。 ## 2. 通用规则 diff --git a/docs/security.md b/docs/security.md index 5373807..cbb70e0 100644 --- a/docs/security.md +++ b/docs/security.md @@ -2,7 +2,7 @@ ## 1. 安全结论 -LanExtend v0.4.1 是**无认证的可信内网 MVP**。只有在你控制网络、信任同网设备并能限制防火墙范围时才应运行。它不适合公网、端口转发、共享 VPN、访客 Wi‑Fi、酒店/机场网络、校园网或无法识别其他终端的办公网。 +LanExtend v0.4.2 是**无认证的可信内网 MVP**。只有在你控制网络、信任同网设备并能限制防火墙范围时才应运行。它不适合公网、端口转发、共享 VPN、访客 Wi‑Fi、酒店/机场网络、校园网或无法识别其他终端的办公网。 “Mac 主端权限最高”目前表示: @@ -60,7 +60,7 @@ LanExtend v0.4.1 是**无认证的可信内网 MVP**。只有在你控制网络 ## 4. 推荐部署基线 1. 使用独立家庭实验网络或专用 VLAN;阻止不受信 VLAN 到 Windows 信令端口。 -2. Windows 网络配置文件设为“专用”。防火墙仅允许 LanExtend 在专用网络运行:子端出站 UDP `47771` 广播、从 Mac 固定 IP/子网接收入站 TCP 信令、双向 WebRTC 动态 UDP,以及复制文件时两端按需使用的动态 TCP。Mac 端只在可信网络接收发现广播。 +2. Windows 网络配置文件设为“专用”。防火墙仅允许 LanExtend 在专用网络运行:子端出站 UDP `47771` 广播、从 Mac 固定 IP/子网接收入站 TCP `47772` 信令及 Windows 文件流、双向 WebRTC 动态 UDP,以及 Mac 发送文件时按需使用的动态 TCP。Mac 端只在可信网络接收发现广播。 3. 不在路由器做 UDP/TCP 端口转发;不通过 frp、ngrok、Tailscale 共享网段或其他隧道暴露当前版本。 4. 在 Mac 与 Windows 实体旁完成首次配对式人工确认:核对主机名、IPv4 和画面测试内容。 5. 投放前关闭敏感通知,先把无敏感测试窗口移动到虚拟显示器,确认子端内容。 @@ -73,7 +73,8 @@ LanExtend v0.4.1 是**无认证的可信内网 MVP**。只有在你控制网络 允许 Mac_IP -> Windows_IP TCP/47772 允许 Windows_子网内广播 -> Mac UDP/47771(若需要自动发现) 允许 Mac_IP <-> Windows_IP WebRTC 动态 UDP(按应用/受控端点限制) -允许 Mac_IP <-> Windows_IP 文件剪贴板动态 TCP(按应用/受控端点限制) +允许 Mac_IP -> Windows_IP TCP/47772 文件流(与信令复用) +允许 Windows_IP -> Mac_IP 文件剪贴板动态 TCP(仅 Mac 发送文件时) 拒绝 其他 VLAN/公网 -> Windows TCP/47772 ``` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 960e6b7..5989871 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -208,7 +208,7 @@ MVP 没有 STUN/TURN,只面向可直接互通的局域网。检查: ### 文件或文件夹没有同步 1. 确认“文件与文件夹同步”开关已打开,并且双端都已升级到协议 v3。 -2. 复制后观察两端文件卡片。若始终停在“等待另一台设备接收”,检查发送端专用网络入站动态 TCP 是否被防火墙拦截;文件端口每次启动由系统随机分配,不是固定 `47772`。 +2. 复制后观察两端文件卡片。v0.4.2 起 Windows→Mac 文件流复用子端信令端口;若错误显示 Mac 正在连接 Windows 的另一个随机端口并出现 `EHOSTUNREACH`,说明仍有一端是旧版本,请同时升级。Mac→Windows 文件流仍由 Mac 临时分配端口,失败时检查 Mac 防火墙是否允许 LanExtend 接收入站连接。 3. 文件清单超过 10000 项、普通文件合计超过 20 GiB、顶层超过 16 项时会拒绝;拆分后重试。 4. 符号链接、socket、device 等特殊项目会跳过;请复制实际普通文件。macOS resource fork、Finder 标签、ACL 和 Windows NTFS 扩展属性不会保留。 5. 若显示“完整性校验失败”或“清单不一致”,原文件可能在发送期间被修改;等待写入完成后重新复制。 diff --git a/docs/user-guide.md b/docs/user-guide.md index 2a5d156..da4a929 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -6,7 +6,7 @@ - 主端为 macOS 14 或更高版本,子端为 Windows 10/11 64 位。 - 两台设备在同一个由你管理的可信 IPv4 局域网,最好是同一 VLAN/子网。 -- Windows 子端可发出 UDP `47771` 广播、接收入站 TCP `47772` 信令,并允许 LanExtend 在专用网络协商 WebRTC 动态 UDP;Mac 能接收入站发现报文。若使用文件剪贴板,两端还需允许 LanExtend 在复制时临时监听随机高位 TCP 端口。 +- Windows 子端可发出 UDP `47771` 广播、接收入站 TCP `47772` 信令/Windows 文件流,并允许 LanExtend 在专用网络协商 WebRTC 动态 UDP;Mac 能接收入站发现报文。Mac→Windows 文件复制还需要 Mac 允许 LanExtend 临时监听随机高位 TCP 端口。 - 网络未开启 AP/客户端隔离,且没有把相关端口映射到公网。 - 你接受当前 MVP 没有认证:同网攻击者可能伪装设备或干扰信令。 @@ -59,7 +59,7 @@ npm run dev:receiver 2. 设置便于辨认且不包含敏感信息的设备名称。 3. 保持默认信令端口 `47772`,除非端口冲突或网络策略要求修改。 4. 根据需要开启“连接后自动全屏”。 -5. 若 Windows Defender 防火墙提示,选择允许访问“专用网络”,不要为“公用网络”放行。固定方向是 Windows 出站 UDP `47771` 广播、Windows 入站 TCP 信令;WebRTC 使用动态 UDP,文件复制端使用动态 TCP,因此优先按 LanExtend 应用放行,而不是只开两个固定端口。 +5. 若 Windows Defender 防火墙提示,选择允许访问“专用网络”,不要为“公用网络”放行。固定方向是 Windows 出站 UDP `47771` 广播、Windows 入站 TCP `47772` 信令及 Windows→Mac 文件流;WebRTC 使用动态 UDP,Mac→Windows 文件流使用 Mac 临时 TCP,因此仍建议按 LanExtend 应用放行。 6. 确认 GUI 显示正在监听,然后再启动 Mac 主端。 子端会每 1.5 秒发送 UDP 广播。它只接受一个活动主端;已被占用时,第二台 Mac 会收到“子端当前正在使用”或连接关闭。 diff --git a/package-lock.json b/package-lock.json index c545d8c..ce54f4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lanextend", - "version": "0.4.1", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lanextend", - "version": "0.4.1", + "version": "0.4.2", "license": "MIT", "dependencies": { "ws": "8.21.3" diff --git a/package.json b/package.json index b2620d5..67d959e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lanextend", - "version": "0.4.1", + "version": "0.4.2", "description": "LAN extended display with seamless keyboard, mouse, text and file clipboard sharing", "main": "src/main.js", "author": "LanExtend contributors", diff --git a/src/core/file-transfer.js b/src/core/file-transfer.js index 0c1f2e8..395177a 100644 --- a/src/core/file-transfer.js +++ b/src/core/file-transfer.js @@ -146,6 +146,7 @@ class FileTransferServer extends EventEmitter { super(); this.host = options.host || '0.0.0.0'; this.port = options.port ?? 0; + this.sharedPort = isValidPort(options.sharedPort) ? options.sharedPort : null; this.offerTtlMs = options.offerTtlMs || DEFAULT_OFFER_TTL_MS; this.server = null; this.transfers = new Map(); @@ -157,9 +158,18 @@ class FileTransferServer extends EventEmitter { async start() { clearTimeout(this.idleTimer); this.idleTimer = null; + if (this.sharedPort) { + this.#startPruneTimer(); + return this.address(); + } if (this.server) return this.address(); const server = http.createServer((request, response) => { - this.#handle(request, response).catch((error) => { + this.handleRequest(request, response).then((handled) => { + if (!handled && !response.headersSent) { + response.writeHead(404); + response.end(); + } + }).catch((error) => { if (!response.headersSent) { response.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' }); response.end('transfer failed'); @@ -183,12 +193,25 @@ class FileTransferServer extends EventEmitter { server.close(); throw error; } + this.#startPruneTimer(); + return this.address(); + } + + #startPruneTimer() { + if (this.pruneTimer) return; this.pruneTimer = setInterval(() => this.prune(), 60_000); this.pruneTimer.unref?.(); + } + + useSharedPort(port) { + if (!isValidPort(port)) throw new Error('共享文件传输端口无效'); + if (this.server) throw new Error('独立文件传输服务已经启动'); + this.sharedPort = port; return this.address(); } address() { + if (this.sharedPort) return { host: this.host, port: this.sharedPort }; const address = this.server?.address(); return { host: this.host, port: typeof address === 'object' && address ? address.port : null }; } @@ -210,24 +233,27 @@ class FileTransferServer extends EventEmitter { }; } - async #handle(request, response) { + async handleRequest(request, response) { + const match = /^\/v1\/transfers\/([0-9a-f-]{36})\/stream$/.exec( + new URL(request.url, 'http://lanextend.local').pathname + ); + if (!match) return false; if (request.method !== 'GET') { response.writeHead(405, { allow: 'GET' }); response.end(); - return; + return true; } const remoteAddress = normalizedRemoteAddress(request.socket.remoteAddress); if (!isPrivateIPv4(remoteAddress)) { response.writeHead(403); response.end(); - return; + return true; } - const match = /^\/v1\/transfers\/([0-9a-f-]{36})\/stream$/.exec(new URL(request.url, 'http://lanextend.local').pathname); - const transfer = match ? this.transfers.get(match[1]) : null; + const transfer = this.transfers.get(match[1]); if (!transfer || transfer.expiresAt < Date.now()) { response.writeHead(404); response.end(); - return; + return true; } this.activeResponses.set(transfer.id, response); response.writeHead(200, { @@ -290,6 +316,7 @@ class FileTransferServer extends EventEmitter { this.activeResponses.delete(transfer.id); this.#scheduleIdleStop(); } + return true; } cancel(transferId) { @@ -316,6 +343,7 @@ class FileTransferServer extends EventEmitter { } #scheduleIdleStop() { + if (this.sharedPort) return; if (!this.server || this.transfers.size || this.activeResponses.size || this.idleTimer) return; this.idleTimer = setTimeout(() => { this.idleTimer = null; @@ -332,6 +360,7 @@ class FileTransferServer extends EventEmitter { for (const response of this.activeResponses.values()) response.destroy(); this.activeResponses.clear(); this.transfers.clear(); + this.sharedPort = null; if (!this.server) return; const server = this.server; this.server = null; @@ -551,6 +580,14 @@ class FileTransferManager extends EventEmitter { return this.server.start(); } + useSharedPort(port) { + return this.server.useSharedPort(port); + } + + handleRequest(request, response) { + return this.server.handleRequest(request, response); + } + createOffer(paths) { return this.server.register(paths); } diff --git a/src/core/network.js b/src/core/network.js index 0538e6e..6c42a92 100644 --- a/src/core/network.js +++ b/src/core/network.js @@ -2,6 +2,7 @@ const dgram = require('node:dgram'); const { EventEmitter } = require('node:events'); +const http = require('node:http'); const os = require('node:os'); const crypto = require('node:crypto'); const { WebSocketServer } = require('ws'); @@ -166,37 +167,56 @@ class SignalServer extends EventEmitter { this.receiver = receiver; this.host = options.host || '0.0.0.0'; this.port = options.port ?? receiver.port ?? SIGNAL_PORT; + this.requestHandler = typeof options.requestHandler === 'function' ? options.requestHandler : null; + this.httpServer = null; this.server = null; this.sessions = new Map(); } async start() { if (this.server) return; - this.server = new WebSocketServer({ - host: this.host, - port: this.port, + const httpServer = http.createServer((request, response) => { + this.#handleHttpRequest(request, response).catch((error) => { + this.emit('warning', error); + if (!response.headersSent) response.writeHead(500); + if (!response.writableEnded) response.end(); + }); + }); + const webSocketServer = new WebSocketServer({ + server: httpServer, maxPayload: MAX_SIGNAL_BYTES, perMessageDeflate: false }); - this.server.on('connection', (socket, request) => this.#accept(socket, request)); - this.server.on('error', (error) => this.emit('error', error)); + this.httpServer = httpServer; + this.server = webSocketServer; + webSocketServer.on('connection', (socket, request) => this.#accept(socket, request)); + webSocketServer.on('error', (error) => this.emit('error', error)); + httpServer.on('error', (error) => this.emit('error', error)); try { await new Promise((resolve, reject) => { const onError = (error) => reject(error); - this.server.once('error', onError); - this.server.once('listening', () => { - this.server.off('error', onError); + httpServer.once('error', onError); + httpServer.listen(this.port, this.host, () => { + httpServer.off('error', onError); resolve(); }); }); } catch (error) { - const failedServer = this.server; + const failedWebSocketServer = this.server; this.server = null; - failedServer?.close(); + this.httpServer = null; + failedWebSocketServer?.close(); + httpServer.close(); throw error; } } + async #handleHttpRequest(request, response) { + const handled = this.requestHandler ? await this.requestHandler(request, response) : false; + if (!handled && !response.headersSent) response.writeHead(404); + if (!handled && !response.writableEnded) response.end(); + } + #accept(socket, request) { const origin = request.headers.origin; if (origin && origin !== 'file://' && origin !== 'null') { @@ -299,8 +319,11 @@ class SignalServer extends EventEmitter { for (const session of this.sessions.values()) session.socket.terminate(); this.sessions.clear(); const server = this.server; + const httpServer = this.httpServer; this.server = null; + this.httpServer = null; await new Promise((resolve) => server.close(resolve)); + if (httpServer) await new Promise((resolve) => httpServer.close(resolve)); } } diff --git a/src/main.js b/src/main.js index 419db00..f0612f8 100644 --- a/src/main.js +++ b/src/main.js @@ -461,7 +461,11 @@ async function startReceiverServices() { display: receiverDisplayInfo() }; try { - signalServer = new SignalServer(receiver, { port: receiver.port }); + fileTransferManager.useSharedPort(receiver.port); + signalServer = new SignalServer(receiver, { + port: receiver.port, + requestHandler: (request, response) => fileTransferManager.handleRequest(request, response) + }); signalServer.on('connected', (connection) => { if (sleepBlockerId === null) sleepBlockerId = powerSaveBlocker.start('prevent-display-sleep'); emitToRenderer('receiver:connected', connection); diff --git a/src/renderer/app.js b/src/renderer/app.js index 166cc39..c325b3a 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -2155,7 +2155,7 @@ supported: Boolean(bootstrap.inputSharing?.supported) }; setRoleVisibility(state.role); - setText('app-version', `扩展屏与键鼠共享 · ${bootstrap.appVersion || '0.4.1'}`); + setText('app-version', `扩展屏与键鼠共享 · ${bootstrap.appVersion || '0.4.2'}`); setText('protocol-chip', `协议 v${bootstrap.protocolVersion}`); bindUpdateUi(); diff --git a/tests/file-transfer.test.js b/tests/file-transfer.test.js index 23a6c93..c38c9c1 100644 --- a/tests/file-transfer.test.js +++ b/tests/file-transfer.test.js @@ -4,8 +4,10 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const fsp = require('node:fs/promises'); +const net = require('node:net'); const os = require('node:os'); const path = require('node:path'); +const { WebSocket } = require('ws'); const { FileClipboardSync, FileTransferClient, @@ -15,6 +17,16 @@ const { portableSegment, safeDestination } = require('../src/core/file-transfer'); +const { SignalServer } = require('../src/core/network'); +const { makeSignal } = require('../src/core/protocol'); + +async function unusedPort() { + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + await new Promise((resolve) => server.close(resolve)); + return port; +} async function fixture(t) { const directory = await fsp.mkdtemp(path.join(os.tmpdir(), 'lanextend-files-')); @@ -64,6 +76,36 @@ test('streaming transfer round-trips multiple files with SHA-256 verification', assert.equal(progress.at(-1), offer.totalBytes); }); +test('Windows sender serves files on the existing signal port', async (t) => { + const { directory, source } = await fixture(t); + const port = await unusedPort(); + const transferServer = new FileTransferServer({ host: '127.0.0.1', sharedPort: port }); + const signalServer = new SignalServer({ id: 'receiver-files', name: 'Windows', port }, { + host: '127.0.0.1', + port, + requestHandler: (request, response) => transferServer.handleRequest(request, response) + }); + await signalServer.start(); + const socket = new WebSocket(`ws://127.0.0.1:${port}`); + const welcome = await new Promise((resolve, reject) => { + socket.once('message', (message) => resolve(JSON.parse(message.toString()))); + socket.once('error', reject); + }); + assert.equal(welcome.type, 'welcome'); + socket.send(JSON.stringify(makeSignal('hello', { hostId: 'mac-files', name: 'Mac' }))); + t.after(async () => { + socket.close(); + await transferServer.stop(); + await signalServer.stop(); + }); + + const offer = await transferServer.register([path.join(source, '单独.txt')]); + assert.equal(offer.port, port); + const client = new FileTransferClient({ cacheDirectory: path.join(directory, 'shared-port-cache') }); + const result = await client.receive(offer, '127.0.0.1'); + assert.equal(await fsp.readFile(result.paths[0], 'utf8'), 'standalone'); +}); + test('file clipboard offers changes once and suppresses a remote echo', async () => { let localPaths = ['/tmp/source.txt']; let written = null;