diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 48ae8e8..d8638e7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,14 +42,17 @@ jobs: shell: bash run: | set -euo pipefail - helper="native/macos/.build/lanextend-vdisplay" - test -f "$helper" - lipo "$helper" -verify_arch arm64 x86_64 + display_helper="native/macos/.build/lanextend-vdisplay" + input_helper="native/macos/.build/lanextend-input" + test -f "$display_helper" + test -f "$input_helper" + lipo "$display_helper" -verify_arch arm64 x86_64 + lipo "$input_helper" -verify_arch arm64 x86_64 app_binary="$(find release -type f -path '*/LanExtend.app/Contents/MacOS/LanExtend' -print -quit)" test -n "$app_binary" lipo "$app_binary" -verify_arch arm64 x86_64 - file "$helper" "$app_binary" + file "$display_helper" "$input_helper" "$app_binary" - name: Upload macOS packages uses: actions/upload-artifact@v4 diff --git a/README.md b/README.md index 4d8806e..015d2d7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # LanExtend -LanExtend 是一个面向可信局域网的双端扩展屏 MVP:macOS 主端创建一块虚拟显示器,捕获该显示器后通过 WebRTC 将视频发送到 Windows 子端 GUI。主端发起连接并拥有会话控制权;子端负责被发现、接收信令和全屏显示。 +LanExtend 是一个面向局域网的 Mac/Windows 双端协作工具,包含两种独立模式:把 Windows 变成 Mac 无线扩展屏,或在不传输画面的情况下,让一套 Mac 键盘鼠标无缝控制两台设备并双向同步剪贴板。 > 当前定位是可继续开发和双机验收的工程交付,不是已签名的正式产品。仓库内的自动化测试不等于真实 Mac/Windows、不同网卡、GPU 解码或长时间运行验证。 @@ -13,8 +13,9 @@ LanExtend 是一个面向可信局域网的双端扩展屏 MVP:macOS 主端创 | 拓扑 | 一台 Mac 主端连接一台 Windows 子端;子端同时只接受一个主端会话 | | 网络 | 同一可信 IPv4 局域网;自动 UDP 广播发现,也可填写私有 IPv4 地址 | | 画面 | 单路视频,默认建议 1920×1080、30 FPS、8 Mbps | -| 记忆 | 本机保存分辨率、帧率、码率、最近设备、已发现/手动添加设备和子端名称/端口 | -| 当前不支持 | 音频、键鼠/触控回传、HDR、多子端、IPv6、跨公网、账号/配对/认证 | +| 键鼠共享 | Mac 鼠标跨越屏幕边缘后控制 Windows;支持二维布局、按键/滚轮和纯文本剪贴板同步 | +| 记忆 | 本机保存画面参数、最近设备、二维设备布局、边缘停留时间和剪贴板开关 | +| 当前不支持 | 音频、触控、文件剪贴板、HDR、多子端、IPv6、跨公网、账号/配对/认证 | | 分发 | 不支持 Mac App Store;CI 产物未配置 Developer ID 签名、公证或 Windows 代码签名 | macOS 虚拟显示依赖未公开的 `CGVirtualDisplay` CoreGraphics API。它可能在系统更新后变化或失效,因此本项目把相关逻辑隔离在独立 helper 进程中,但无法消除兼容性风险。 @@ -27,6 +28,9 @@ macOS 虚拟显示依赖未公开的 `CGVirtualDisplay` CoreGraphics API。它 - Windows 子端通过 UDP 广播,Mac 主端监听并合并本机记忆列表。 - WebSocket 信令、协议版本/消息大小/字段校验、单主端占用保护。 - 主端主动连接、断开;子端可全屏并在会话期间阻止显示器休眠。 +- 独立的键鼠共享模式:不创建虚拟屏、不传输画面,使用可拖拽二维布局实现跨边缘切换。 +- Mac 全局键鼠捕获、Windows 本地输入注入、紧急返回快捷键 `⌃⌥⌘Esc`。 +- 双向纯文本剪贴板同步,远端内容写入后不会被立即回传形成循环。 - 子端画面信息条默认隐藏,鼠标移动、触摸或键盘聚焦时短暂显示,避免遮挡扩展桌面。 - JSON 设置持久化,最多记忆 32 台设备;离线设备仍可显示和删除。 - 启动时及侧边栏手动检查 GitHub Releases;发现新版本后打开官方发布页,由用户下载并安装。 @@ -65,7 +69,9 @@ npm run build:native npm run dev:host ``` -按系统提示授予“屏幕与系统音频录制”(不同 macOS 版本名称可能略有不同)和本地网络访问权限;修改权限后完全退出并重新启动 LanExtend。 +按系统提示授予“屏幕与系统音频录制”(不同 macOS 版本名称可能略有不同)和本地网络访问权限。安装包用户应只从 `/Applications/LanExtend.app` 启动;授权后返回应用会自动重新检测。源码目录或构建目录中的副本会被 macOS 当作不同的权限主体。 + +键鼠共享不需要屏幕录制,但首次启动时需要 macOS“辅助功能”权限;这是系统捕获全局键鼠事件的必要条件。 ### 4. 创建并投放扩展屏 @@ -76,6 +82,15 @@ npm run dev:host 5. 如果私有虚拟显示不可用,GUI 会切到“已有显示器”兼容模式;此时才需要手动选择捕获源。选择已有屏会发送该屏全部内容,请先清除敏感信息。 6. 使用主端“断开扩展屏”结束投放;由本会话自动创建的虚拟显示器会随断开清理。 +### 5. 启动键鼠和剪贴板共享 + +1. 在同一个设备列表选择 Windows 子端,然后滚动到“键鼠与剪贴板共享”。 +2. 在布局画布中拖动橙色 Windows 屏幕,使其贴到对应 Mac 显示器的左、右、上或下边缘;“自动排列”会把它放到最上方 Mac 屏幕的右侧。 +3. 根据需要开启“双向剪贴板同步”,并设置边缘停留时间。默认 `80 ms` 兼顾快速切换和减少误触。 +4. 点击“启动键鼠共享”。此模式不会创建扩展屏,也不会发送任何屏幕画面。 +5. 鼠标越过相邻边缘后,键盘、鼠标按键和滚轮会控制 Windows;从 Windows 对应边缘移回即可返回 Mac。 +6. 任意时候按 `Control + Option + Command + Esc` 可强制把控制权返回 Mac。 + 完整操作和未签名产物说明见[用户指南](docs/user-guide.md)。公开安装包可从 [GitHub Releases](https://github.com/Modole/LanExtend/releases) 获取。 ## 架构概览 @@ -84,12 +99,14 @@ npm run dev:host flowchart LR subgraph H["macOS 主端(最高控制权)"] GUIH["Electron GUI"] --> VD["CGVirtualDisplay helper"] + GUIH --> IH["全局键鼠 helper"] VD --> CAP["虚拟显示器 / 屏幕捕获"] GUIH --> MEMH["本机 settings.json"] CAP --> RTC1["WebRTC 发送端"] end subgraph R["Windows 子端"] GUIR["Electron GUI"] --> RTCR["WebRTC 接收端"] + GUIR --> IW["Windows 输入 helper"] GUIR --> MEMR["本机 settings.json"] GUIR --> ADV["UDP 广播"] GUIR --> SIG["WebSocket 信令服务"] @@ -97,6 +114,7 @@ flowchart LR ADV -- "UDP/47771" --> GUIH GUIH -- "ws://子端:47772" --> SIG RTC1 -- "WebRTC 加密媒体(单路视频)" --> RTCR + IH -- "WebSocket 键鼠 / 剪贴板" --> IW ``` 发现报文只用于定位子端;远端媒体不经过云服务。WebRTC 媒体本身使用其标准加密传输,但**设备身份、发现和 WebSocket 信令均未认证**,所以必须把整个二层/三层局域网视为信任边界。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 5b78489..4381714 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,6 +1,6 @@ # Third-Party Notices -This file describes direct runtime/build dependencies and implementation references for LanExtend 0.2.0. The authoritative resolved dependency graph is `package-lock.json`. A release maintainer must re-run license review whenever the lockfile or packaging inputs change. +This file describes direct runtime/build dependencies and implementation references for LanExtend 0.2.1. The authoritative resolved dependency graph is `package-lock.json`. A release maintainer must re-run license review whenever the lockfile or packaging inputs change. LanExtend source code is licensed under the repository's MIT License. Third-party components remain under their respective licenses. diff --git a/docs/acceptance.md b/docs/acceptance.md index 3779ad1..5e64d4e 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -6,8 +6,8 @@ - 主端:macOS 14+;至少一台 Apple Silicon Mac。若声明 Intel 支持,还需一台 Intel Mac 单独通过。 - 子端:Windows 10 22H2 64 位和 Windows 11 64 位应分别验收;如果只测其中一个,只能声明该系统已测。 -- MVP:一主一子、单路视频、可信 IPv4 LAN、无认证。 -- 明确排除:音频、键鼠/触控回传、HDR、多子端、IPv6、公网/NAT、App Store。 +- MVP:一主一子、单路视频或独立键鼠共享、纯文本剪贴板、可信 IPv4 LAN、无认证。 +- 明确排除:音频、触控、文件/图片剪贴板、HDR、多子端、IPv6、公网/NAT、App Store。 ## 2. 验收记录模板 @@ -41,6 +41,7 @@ Windows 型号 / CPU / GPU / Windows build: | A09 | 许可证 | 人工检查 | LICENSE、第三方声明、锁文件与打包依赖一致 | 执行时填写 | | A10 | 文档范围 | 人工检查 | 明确 14+、Win10/11、MVP 限制、无认证和无签名 | 执行时填写 | | A11 | 更新检查 | `tests/updates.test.js` + GUI | 语义版本比较正确,只接受本仓库 HTTPS Release 链接,失败可重试 | 执行时填写 | +| A12 | 输入助手探测 | `lanextend-input --probe` | Mac 返回 `trusted: true`;Windows 包含 PowerShell helper | **必须双机填写** | ## 4. 安装与首次启动(P0) @@ -93,7 +94,20 @@ Windows 型号 / CPU / GPU / Windows build: - [ ] R12 让 Mac/Windows 分别睡眠/唤醒,记录实际行为;若不能恢复,UI 至少允许干净断开重连。 - [ ] R13 子端视频出现后底部信息条默认隐藏;画面内移动鼠标/触摸/键盘聚焦后显示,无操作约 2.2 秒后隐藏;悬停按钮时不会在操作中消失。 -## 8. 画质、性能与稳定性(P0/P1) +## 8. 键鼠与剪贴板共享(P0) + +- [ ] K01 键鼠模式启动时不创建虚拟显示器、不请求屏幕录制、不建立 WebRTC 视频轨道。 +- [ ] K02 GUI 正确显示全部 Mac 屏幕;Windows 节点可拖动并吸附四个方向,完全重启后布局保持。 +- [ ] K03 从每条已配置相邻边缘进入 Windows,Windows 光标位置连续;从对应边缘返回正确 Mac 屏幕。 +- [ ] K04 左/右/中键、拖拽、滚轮、字母数字、方向键、退格、回车和常用组合键工作。 +- [ ] K05 `Command+C/V` 在 Windows 映射为 `Ctrl+C/V`;左右修饰键释放后没有粘键。 +- [ ] K06 `Control+Option+Command+Esc` 总能返回 Mac,并释放 Windows 上全部键鼠按钮。 +- [ ] K07 拔网、关闭 Windows、停止共享和 helper 异常退出后 Mac 本地输入立即恢复。 +- [ ] K08 两端复制纯文本可双向同步且不回环;空文本、中文、emoji、128 KiB 边界按定义工作。 +- [ ] K09 图片、文件和超过上限文本不造成崩溃,也不被误写为纯文本。 +- [ ] K10 连续跨越边缘 100 次,无明显延迟累积、粘键或残留 helper。 + +## 9. 画质、性能与稳定性(P0/P1) 本 MVP 没有承诺固定延迟/FPS;以下是建议验收基线,不应在未测时写入产品宣传。 @@ -109,7 +123,7 @@ Windows 型号 / CPU / GPU / Windows build: 如果要声明延迟数值,必须说明测量方法(相机/时间码)、网络、分辨率、帧率、编码路径、样本数和 P50/P95;主观“很低”不能作为证据。 -## 9. 安全负向验收(P0) +## 10. 安全负向验收(P0) - [ ] S01 README/GUI 明确“仅可信内网、无认证”,没有“安全连接/受信设备”等误导文案。 - [ ] S02 Windows 信令端口未映射公网,防火墙不对公用网络开放。 @@ -117,17 +131,17 @@ Windows 型号 / CPU / GPU / Windows build: - [ ] S04 超 256 KiB、未知类型、版本错误或畸形信令被拒绝并关闭。 - [ ] S05 任意 HTTPS 之外页面不能在 Electron 内导航,渲染层没有 Node 集成。 - [ ] S06 配置文件不保存屏幕帧、SDP/ICE、密码或长期密钥;日志默认不输出敏感信令。 -- [ ] S07 屏幕权限拒绝时失败关闭,不尝试 TCC 绕过;应用不申请辅助功能/麦克风。 +- [ ] S07 屏幕权限拒绝只影响扩展屏;辅助功能拒绝只影响键鼠共享;应用不尝试绕过 TCC。 - [ ] S08 明确记忆设备可伪造,不把 UUID/名称当认证。 - [ ] S09 未签名 artifacts 明确标记开发验收用途,没有建议全局关闭 Gatekeeper/SmartScreen。 - [ ] S10 第三方 GPL/AGPL 方案未复制进 MIT 实现;引用与许可证边界有记录。 -## 10. 当前明确不验收为“支持”的能力 +## 11. 当前明确不验收为“支持”的能力 -以下项目若意外“看似可用”也不应纳入 v0.2.0 支持声明: +以下项目若意外“看似可用”也不应纳入 v0.3.0 支持声明: - 音频播放/转发; -- Windows 到 Mac 的键盘、鼠标、触控、剪贴板或文件回传; +- 触控、图片/文件剪贴板或剪贴板历史同步; - HDR、广色域、色彩校准保证; - 一台 Mac 同时连接多个 Windows 子端或创建多块受管扩展屏; - IPv6、DNS 名称、跨公网、NAT、TURN、云中继; @@ -135,7 +149,7 @@ Windows 型号 / CPU / GPU / Windows build: - Mac App Store、后台下载/静默自动安装、正式签名/公证; - 无人值守服务和企业集中管理。 -## 11. 发布判定 +## 12. 发布判定 ### 可交付源码/开发预览 diff --git a/docs/architecture.md b/docs/architecture.md index d7d3ede..f92fca2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,9 +2,9 @@ ## 1. 目标与非目标 -LanExtend 的 MVP 目标是让一台 macOS 14+ 电脑把一块“真正的扩展桌面”显示到同一可信局域网中的一台 Windows 10/11 电脑。主端决定创建/销毁显示器、选择捕获源、连接/断开和画质参数;子端只提供发现、信令和显示窗口。 +LanExtend 提供两个独立目标:让 macOS 14+ 把一块“真正的扩展桌面”显示到 Windows 10/11,或在两台设备各自显示本地内容时,让 Mac 的键盘、鼠标和纯文本剪贴板无缝切换到 Windows。主端决定连接、布局和功能开关。 -当前非目标包括音频、远端键鼠/触控、HDR、跨公网、NAT 穿透、IPv6、多子端并发、后台服务、账号体系、设备配对和企业级策略管理。 +当前非目标包括音频、触控、文件/图片剪贴板、HDR、跨公网、NAT 穿透、IPv6、多子端并发、后台服务、账号体系和企业级策略管理。 ## 2. 组件与职责 @@ -14,6 +14,8 @@ LanExtend 的 MVP 目标是让一台 macOS 14+ 电脑把一块“真正的扩展 | 沙箱渲染进程 | 两端 | GUI、WebRTC 协商、主端捕获、子端播放和状态展示 | 当前会话中断,可重启应用 | | preload 桥 | 两端 | 暴露白名单 IPC,隔离 Node 能力 | GUI 无法调用系统能力 | | `lanextend-vdisplay` | macOS | 探测/调用私有 `CGVirtualDisplay` API,拥有一块虚拟显示器 | 虚拟显示器被系统移除 | +| `lanextend-input` | macOS | 监听屏幕边缘、接管全局键鼠并把事件写为 JSONL | 键鼠共享停止,Mac 本地输入恢复 | +| `lanextend-input.ps1` | Windows | 常驻读取 JSONL,调用 Win32 API 注入键鼠 | Windows 不再接收远端输入 | | UDP advertiser | Windows | 每 1.5 秒广播子端元数据 | 自动发现失效,手动地址仍可用 | | UDP listener | macOS | 监听并维护在线设备,6 秒未见即离线 | 自动发现失效 | | WebSocket server | Windows | 接受一个主端并转发 WebRTC 信令 | 无法建会话;已有媒体也可能终止 | @@ -53,6 +55,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` 消息;它不创建 WebRTC 或虚拟显示器。 - 子端拒绝第二个同时在线的主端,返回 WebSocket 关闭码 `1013`。 ### 媒体面 @@ -78,6 +81,17 @@ sequenceDiagram HiDPI 模式把 GUI 请求宽高视为**逻辑桌面尺寸**,物理帧缓冲宽高各为 2×。例如 1920×1080 HiDPI 对应 1920×1080 逻辑空间和 3840×2160 物理帧缓冲。像素数变为非 HiDPI 同逻辑尺寸的四倍,会显著增加 WindowServer、捕获和内存开销;发送端会尝试按所选逻辑分辨率约束/缩放 WebRTC,但最终编码尺寸必须以运行统计为准。 +### 键鼠共享生命周期 + +1. GUI 读取 Mac 当前显示器坐标,并把 Windows 屏幕作为可拖拽矩形保存到同一逻辑坐标系。 +2. 主端发出 `control/share-start`;Windows 启动常驻输入 helper 并返回主屏尺寸。 +3. Mac 启动 `lanextend-input`。未跨边缘时事件原样交给 macOS,只观察鼠标位置。 +4. 鼠标穿过与 Windows 矩形相邻的边缘后,helper 暂停向本机投递键鼠事件;主端把绝对鼠标位置、按键和滚轮通过 WebSocket 发送。 +5. Windows helper 使用 Win32 API 执行输入。返回相邻边缘、断线或停止时会先释放全部按键。 +6. `Control + Option + Command + Esc` 是本地紧急返回组合,不会发送给 Windows。 + +共享期间两端每 500 ms 检查纯文本剪贴板。启动共享时以 Mac 当前纯文本为初始值,之后任一端内容变化都会发送 `clipboard` 消息;应用远端内容时先更新本地去重值,避免形成回传循环。Windows 布局使用主显示器物理像素宽高,使高 DPI 缩放下的绝对光标坐标与 Win32 一致。 + ## 5. 配置与“记忆”模型 配置位于 Electron `app.getPath('userData')/settings.json`,写入时先创建同目录临时文件再原子重命名;类 Unix 平台创建文件时请求 `0600` 权限。数据模型版本当前为 `schemaVersion: 1`。 @@ -92,6 +106,9 @@ settings.json │ ├── id(首次运行生成并保持) │ ├── name / port │ └── autoFullscreen +├── inputSharing +│ ├── lastDeviceId / clipboard / autoReconnect / edgeDelayMs +│ └── layouts[](每个设备的 x/y/width/height) └── rememberedDevices[最多 32] └── id / name / host / port / lastSeen / lastConnected ``` @@ -104,7 +121,7 @@ settings.json | --- | --- | --- | --- | | 屏幕录制 | 必需 | 不需要 | 缺失时无法可靠列出/捕获扩展屏,常见表现是黑屏 | | 本地网络 | 必需 | Windows 防火墙放行 | 自动发现和局域网连接所需 | -| 辅助功能 | 不需要 | 不需要 | MVP 没有输入回传 | +| 辅助功能 | 键鼠共享必需 | 不需要 | macOS 全局事件 tap 的系统要求 | | 麦克风/系统音频 | 不需要 | 不需要 | MVP 没有音频 | | 管理员/root | 通常不需要 | 通常不需要 | 开放普通高位端口,不安装驱动/服务 | @@ -140,4 +157,4 @@ MVP 没有认证和信令机密性,因此安全性依赖网络隔离、端点 3. 增加明确的会话确认弹窗、设备指纹、撤销和连接审计。 4. 增加端到端统计、崩溃恢复、编码器能力探测和分辨率/码率自适应。 5. 完成 Apple Silicon/Intel、Windows 10/11、Wi-Fi/以太网组合的长期真机矩阵。 -6. 再评估音频、输入回传、多子端与 NAT 场景;每项都扩大权限和攻击面,应单独设计。 +6. 再评估音频、触控、文件剪贴板、多子端与 NAT 场景;每项都应单独设计和验收。 diff --git a/docs/development.md b/docs/development.md index 80d9b88..0d4f8db 100644 --- a/docs/development.md +++ b/docs/development.md @@ -34,7 +34,8 @@ LanExtend/ │ ├── preload.js 白名单 IPC 桥 │ ├── core/ 配置、网络、协议和 helper 管理 │ └── renderer/ 两端 GUI 与 WebRTC -├── native/macos/ CGVirtualDisplay helper 和说明 +├── native/macos/ CGVirtualDisplay 与键鼠捕获 helper +├── native/windows/ Windows 键鼠注入 PowerShell helper ├── scripts/ 原生构建、源码检查 ├── tests/ Node 单元测试 ├── docs/ 中文交付文档 @@ -50,7 +51,7 @@ LanExtend/ | `npm start` | 两端 | 启动 Electron;macOS 默认主端,Windows 默认子端 | | `npm run dev:host` | macOS | 强制主端并允许多实例;使用临时开发 userData | | `npm run dev:receiver` | Windows | 强制子端并允许多实例;使用临时开发 userData | -| `npm run build:native` | macOS | 构建虚拟显示 helper;其他平台明确跳过 | +| `npm run build:native` | macOS | 构建虚拟显示和键鼠捕获 helper;其他平台明确跳过 | | `npm test` | 两端 | 先做 JS/MJS 语法检查,再运行 Node 测试 | | `npm run verify` | 两端 | 构建/跳过 helper 后运行测试 | | `npm run dist:mac` | macOS | 构建 universal2 helper,再生成 Universal DMG 和 ZIP | @@ -68,6 +69,7 @@ LanExtend/ npm ci npm run build:native native/macos/.build/lanextend-vdisplay --probe +native/macos/.build/lanextend-input --probe npm run dev:host ``` @@ -129,6 +131,7 @@ npm test - 私有 IPv4、端口、发现报文和信令消息解析; - IPv4 广播地址计算和 WebSocket 单会话行为; - 虚拟显示参数验证、helper 路径与生命周期管理的可测试部分; +- 二维布局边缘进入/退出、Mac→Windows 键码和剪贴板去重; - JS/MJS 源码语法。 当前自动化**没有**覆盖: @@ -149,13 +152,13 @@ npm test npm run dist:mac ``` -`dist:mac` 会先用 `-arch arm64 -arch x86_64` 把 universal2 helper 编译到 `native/macos/.build/lanextend-vdisplay`,再调用 electron-builder `--universal` 合并两种 Electron 架构并生成 Universal DMG/ZIP。 +`dist:mac` 会先用 `-arch arm64 -arch x86_64` 构建 `lanextend-vdisplay` 与 `lanextend-input` 两个 universal2 helper,再调用 electron-builder `--universal` 合并两种 Electron 架构并生成 Universal DMG/ZIP。 注意: - 本地/CI 默认产物不具备正式 Developer ID 签名和 Apple 公证; - 当前项目使用私有 `CGVirtualDisplay` API,不支持 Mac App Store; -- 构建后用 `lipo -info native/macos/.build/lanextend-vdisplay` 并检查最终 app 主二进制,确认都含 `x86_64` 与 `arm64`;命令配置为 Universal 不等于产物已经正确合并。 +- 构建后对 `.build/lanextend-vdisplay` 和 `.build/lanextend-input` 分别执行 `lipo -info`,并检查最终 app 主二进制,确认都含 `x86_64` 与 `arm64`。 - 即使同一个 Universal 包包含两种 slices,也必须分别在 Intel 和 Apple Silicon 真机验收私有 API、权限、捕获和 WebRTC;当前没有自动真机矩阵可替代。 - 构建时设置 `CSC_IDENTITY_AUTO_DISCOVERY=false` 可避免 CI 意外使用 runner 上的签名身份;正式发布则应使用受保护 secrets 和独立签名工作流。 diff --git a/docs/protocol.md b/docs/protocol.md index 7e49fe4..b60cc29 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -1,6 +1,6 @@ -# LanExtend 协议 v1 +# LanExtend 协议 v2 -本文描述仓库当前 `PROTOCOL_VERSION = 1` 的线协议。它是内网 MVP 协议,不提供向后兼容承诺;修改字段语义或消息流程时应提升版本并同时更新双端。 +本文描述仓库当前 `PROTOCOL_VERSION = 2` 的线协议。v2 在原有扩展屏信令上加入键鼠控制和剪贴板消息,不与 v1 混用。 ## 1. 端口和传输 @@ -14,7 +14,7 @@ ## 2. 通用规则 -- 除 `welcome` 外,信令消息都包含整数 `protocol: 1` 和受支持的 `type`。 +- 除 `welcome` 外,信令消息都包含整数 `protocol: 2` 和受支持的 `type`。 - 接收方严格要求协议版本相等,不进行版本协商。 - JSON 顶层必须是普通对象,不能是数组、标量或带特殊原型的内部对象。 - 名称去除控制字符、前后空白,并截断到 64 个字符。 @@ -29,12 +29,13 @@ Windows 子端启动后立即广播,之后默认每 1500 ms 向 `255.255.255.2 ```json { "type": "lanextend.receiver", - "protocol": 1, + "protocol": 2, "id": "5dd9a8d2-f997-4f85-b1e5-f086d1164441", "name": "会议室 Windows", "port": 47772, "platform": "win32", - "capabilities": ["video", "fullscreen"] + "capabilities": ["video", "fullscreen", "input", "clipboard"], + "display": { "width": 1920, "height": 1080, "scaleFactor": 1 } } ``` @@ -43,12 +44,13 @@ Windows 子端启动后立即广播,之后默认每 1500 ms 向 `255.255.255.2 | 字段 | 要求 | | --- | --- | | `type` | 必须等于 `lanextend.receiver` | -| `protocol` | 必须等于 `1` | +| `protocol` | 必须等于 `2` | | `id` | 非空字符串,最长 128;正常实现首次运行生成 UUID 并持久化 | | `name` | 非空字符串,最长 64 | | `port` | 整数 `1–65535` | | `platform` | 当前实现发送 `win32`;其他值被归一为 `unknown` | | `capabilities` | 可选字符串数组;接收方最多保留前 8 项 | +| `display` | Windows 主显示器物理像素宽高和缩放,用于主端布局与绝对指针坐标 | Mac 端把 UDP 数据报的来源地址作为设备 `host`,不会采用报文中自报的 IP。设备超过 6 秒未再广播即从在线列表移除,但已记忆设备仍以离线状态保留。 @@ -61,11 +63,13 @@ Mac 端把 UDP 数据报的来源地址作为设备 `host`,不会采用报文 ```json { "type": "welcome", - "protocol": 1, + "protocol": 2, "receiver": { "id": "5dd9a8d2-f997-4f85-b1e5-f086d1164441", "name": "会议室 Windows", - "port": 47772 + "port": 47772, + "capabilities": ["video", "fullscreen", "input", "clipboard"], + "display": { "width": 1920, "height": 1080, "scaleFactor": 1 } } } ``` @@ -77,7 +81,7 @@ Mac 端把 UDP 数据报的来源地址作为设备 `host`,不会采用报文 ```json { "type": "hello", - "protocol": 1, + "protocol": 2, "hostId": "mac-host-id", "name": "设计部 Mac" } @@ -94,7 +98,7 @@ Mac 端把 UDP 数据报的来源地址作为设备 `host`,不会采用报文 ```json { "type": "offer", - "protocol": 1, + "protocol": 2, "sdp": { "type": "offer", "sdp": "v=0\r\n..." @@ -113,7 +117,7 @@ Mac 端把 UDP 数据报的来源地址作为设备 `host`,不会采用报文 ```json { "type": "ice", - "protocol": 1, + "protocol": 2, "candidate": { "candidate": "candidate:...", "sdpMid": "0", @@ -129,7 +133,7 @@ Mac 端把 UDP 数据报的来源地址作为设备 `host`,不会采用报文 用于应用层存活探测,时间戳必须是非负有限数。主端默认每 5 秒发送一次: ```json -{"type":"ping","protocol":1,"timestamp":1786320000000} +{"type":"ping","protocol":2,"timestamp":1786320000000} ``` 接收 `ping` 的一端使用相同时间戳回复 `pong`,主端据此展示应用层 RTT。连续约 15 秒没有 `pong` 时,主端把会话视为失联。它不是时钟同步,也不能证明对端身份。 @@ -139,23 +143,57 @@ Mac 端把 UDP 数据报的来源地址作为设备 `host`,不会采用报文 ```json { "type": "disconnect", - "protocol": 1, + "protocol": 2, "reason": "主端主动断开" } ``` `reason` 可省略;存在时必须是 UTF-8 编码后不超过 120 字节的字符串。子端发出 WebSocket close frame 时还会按 UTF-8 安全边界截断到协议允许的 123 字节,不会截断多字节字符。 -## 6. 标准时序 +## 6. 键鼠与剪贴板消息 + +输入模式在 `hello` 后由主端发送: + +```json +{ + "type": "control", + "protocol": 2, + "action": "share-start", + "clipboard": true, + "screen": { "width": 1920, "height": 1080 } +} +``` + +子端启动 Windows 输入 helper 后回复 `share-ready`,并返回实际主屏宽高。运行期间 `active`/`inactive` 表示控制权是否已跨到 Windows;`share-stop` 结束共享,`error` 携带最长 512 字符的错误说明。 + +鼠标、按键和释放事件使用 `input`: + +```json +{"type":"input","protocol":2,"event":{"kind":"pointer","x":960,"y":540}} +{"type":"input","protocol":2,"event":{"kind":"key","vk":65,"down":true}} +{"type":"input","protocol":2,"event":{"kind":"releaseAll"}} +``` + +支持的 `kind` 为 `pointer/button/wheel/key/releaseAll`。坐标是 Windows 主显示器内的逻辑像素;键盘使用 Windows virtual-key code。鼠标移动在 Mac 端合并为约 8 ms 一批,按键、点击或滚轮前会先刷新待发送位置。 + +纯文本剪贴板双向发送: + +```json +{"type":"clipboard","protocol":2,"text":"同步文本","revision":"mac-id:12"} +``` + +文本 UTF-8 上限为 128 KiB;`revision` 用于观测,实际回环抑制以本地最近内容为准。不支持文件、图片和富文本。 + +## 7. 标准时序 ```mermaid sequenceDiagram participant M as Mac 主端 participant W as Windows 子端 M->>W: 用户选择设备并点“扩展”,WebSocket connect - W-->>M: welcome(protocol=1) + W-->>M: welcome(protocol=2) M->>M: 核对子端真实 UUID并更新记忆 - M->>W: hello(protocol=1) + M->>W: hello(protocol=2) M->>M: 自动创建/定位虚拟显示捕获源 M->>W: offer(SDP) W-->>M: answer(SDP) @@ -175,9 +213,11 @@ sequenceDiagram 实际 ICE 与 SDP 消息可能交错;当前双端会缓存远端描述设置前到达的候选。`RTCPeerConnection` 使用空 `iceServers`,所以只有局域网 host candidates,没有 STUN/TURN/NAT 中继。主端使用 `sendonly` 视频 transceiver,优先 H.264、以 VP8 兜底,并把设置中的码率/FPS作为 sender 上限目标;协商/编码器最终值以运行 stats 为准。offer 发出后当前最终协商超时为 20 秒。 +键鼠模式用同一套 `welcome → hello → ping/pong → disconnect` 外壳,但以 `share-start/share-ready` 代替 SDP/ICE/WebRTC;它不发送视频。 + 自动重连属于客户端策略,不改变线协议:只有网络/异常断开才按约 1.6、3.2、6.4、12 秒退避(之后封顶 12 秒);用户主动断开、收到显式 `disconnect`,或 WebSocket 以 `1000`/`1008` 结束时不自动重连。 -## 7. 手动目标校验 +## 8. 手动目标校验 主端 GUI 的手动目标只接受以下 IPv4: @@ -189,7 +229,7 @@ sequenceDiagram 这只是减少误连公网的输入校验,不是访问控制。当前不接受 IPv6、DNS 主机名或公网 IPv4。 -## 8. 安全属性 +## 9. 安全属性 | 属性 | 当前状态 | | --- | --- | @@ -201,9 +241,9 @@ sequenceDiagram | 授权 | 仅“主端主动连接 + 子端单会话”的流程限制 | | DoS 缓解 | 消息大小、字段校验和单会话限制;不构成完整防护 | -因此协议 v1 只能部署在受控、隔离且参与设备都可信的局域网。上线认证环境前应设计协议 v2,而不是在 v1 上仅增加一个共享字符串;建议加入设备密钥、带外配对、证书固定、WSS、会话确认、撤销和速率限制。 +因此协议 v2 只适用于用户自己的受控局域网。当前产品选择不实现账号、配对或 TLS;如果未来扩大到共享网络,应另行提升协议并设计设备身份和加密。 -## 9. 兼容性变更规则 +## 10. 兼容性变更规则 以下变更必须提升 `PROTOCOL_VERSION`: @@ -212,4 +252,4 @@ sequenceDiagram - 增加必须被旧端理解的消息; - 改变端口/发现模型且没有兼容回退。 -仅增加可忽略的 GUI 元数据也应先确认当前解析器是否会保留;v1 解析器只验证已知关键字段,不提供通用扩展协商。 +仅增加可忽略的 GUI 元数据也应先确认当前解析器是否会保留;v2 解析器只验证已知关键字段,不提供通用扩展协商。 diff --git a/docs/release.md b/docs/release.md index b7a21f0..d9963de 100644 --- a/docs/release.md +++ b/docs/release.md @@ -124,7 +124,7 @@ npm run dist:win 建议直接使用以下摘要,不要删减为模糊营销文案: -> LanExtend 当前支持 macOS 14+ 主端到 Windows 10/11 64 位子端的一主一子视频扩展屏。仅用于可信 IPv4 局域网;当前无设备认证,禁止公网暴露。MVP 不支持音频、输入回传、HDR、多子端、IPv6 或跨网中继。macOS 虚拟显示依赖 Apple 未公开 API,系统更新可能导致失效,不支持 Mac App Store。 +> LanExtend 当前支持 macOS 14+ 主端到 Windows 10/11 64 位子端的一主一子视频扩展屏,以及独立的键鼠/纯文本剪贴板共享。仅用于可信 IPv4 局域网;当前无设备认证,禁止公网暴露。MVP 不支持音频、触控、文件剪贴板、HDR、多子端、IPv6 或跨网中继。macOS 虚拟显示依赖 Apple 未公开 API,系统更新可能导致失效,不支持 Mac App Store。 另外列出本次真实测试的 OS build、CPU 架构、网络和已知问题。未测平台必须写“未测”,不能仅写“理论支持”。 diff --git a/docs/security.md b/docs/security.md index a0ebc79..7896df4 100644 --- a/docs/security.md +++ b/docs/security.md @@ -2,7 +2,7 @@ ## 1. 安全结论 -LanExtend v0.2.0 是**无认证的可信内网 MVP**。只有在你控制网络、信任同网设备并能限制防火墙范围时才应运行。它不适合公网、端口转发、共享 VPN、访客 Wi‑Fi、酒店/机场网络、校园网或无法识别其他终端的办公网。 +LanExtend v0.3.0 是**无认证的可信内网 MVP**。只有在你控制网络、信任同网设备并能限制防火墙范围时才应运行。它不适合公网、端口转发、共享 VPN、访客 Wi‑Fi、酒店/机场网络、校园网或无法识别其他终端的办公网。 “Mac 主端权限最高”目前表示: @@ -90,7 +90,7 @@ LanExtend v0.2.0 是**无认证的可信内网 MVP**。只有在你控制网络 ### 不申请的权限 -MVP 不需要辅助功能、输入监控、麦克风、摄像头或系统扩展。若未来增加键鼠/触控或音频,必须重新做威胁模型、最小权限设计和用户可见开关。 +键鼠共享需要 macOS 辅助功能权限;扩展屏需要屏幕录制权限。MVP 不需要麦克风、摄像头或系统扩展。两种模式在 GUI 中分别启停。 ## 6. `CGVirtualDisplay` 私有 API diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 659bf4f..0379155 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -72,6 +72,15 @@ GUI 接受偶数逻辑宽 `800–7680`、偶数逻辑高 `600–4320`、`15–60 如果系统显示器列表也没有该显示器,回到 helper 排障,而不是继续排查 WebRTC。 +### 已授权但应用仍显示“需要屏幕录制权限” + +1. 完全退出所有 LanExtend 进程,确认没有从 DMG、下载目录、源码目录或 `release/` 运行另一份副本。 +2. 只保留并启动 `/Applications/LanExtend.app`;不要从构建输出双击应用。 +3. 在系统屏幕录制列表中关闭旧 LanExtend 项,再为当前 `/Applications` 副本开启;返回窗口后应用会自动重新检测。 +4. 如果替换了未使用 Developer ID 正式签名的开发包,macOS 仍可能要求重新授权。用于公开稳定分发的根本方案是 Developer ID 签名和公证。 + +开发包会对整个应用和原生 helper 做 ad-hoc 签名,以避免“应用包完全未签名”;这不等同于 Developer ID,不能提供发布者身份、跨版本稳定要求或公证。 + ## 4. Windows 子端未被自动发现 按顺序检查: @@ -107,7 +116,7 @@ MVP 只接受:`10/8`、`172.16/12`、`192.168/16`、`127/8`、`169.254/16` IPv ### 关闭码 1008 / 信令格式无效 -双端协议版本或消息实现不一致,或者同网有非 LanExtend 客户端连接。确认两端来自同一提交/版本,检查 `protocol: 1`,不要把完整 SDP/ICE 发布到公开 issue。 +双端协议版本或消息实现不一致,或者同网有非 LanExtend 客户端连接。确认两端来自同一提交/版本,当前版本应显示 `协议 v2`,不要把完整 SDP/ICE 发布到公开 issue。 ### `ECONNREFUSED` @@ -169,7 +178,32 @@ MVP 没有 STUN/TURN,只面向可直接互通的局域网。检查: - 若应用在断开后仍阻止显示休眠,正常退出子端并记录复现;会话关闭处理应停止 blocker。 - 若窗口在睡眠/唤醒后黑屏,断开并重新建立 WebRTC;MVP 尚未声明完整的睡眠恢复保证。 -## 11. 记忆设备错误或配置损坏 +## 11. 键鼠或剪贴板共享问题 + +### 鼠标到边缘后没有切换 + +1. 确认主端“键鼠与剪贴板共享”显示“共享运行中”。 +2. 在 macOS“隐私与安全性 → 辅助功能”允许当前 LanExtend 副本;源码、构建目录和 `/Applications` 中的副本可能被系统视为不同程序。 +3. 在布局画布确认橙色 Windows 矩形与目标 Mac 矩形真正贴边,没有缝隙或重叠;可先点“自动排列”。 +4. 把边缘停留临时调为 `0 ms`,持续向该边缘移动鼠标测试。 +5. 源码版执行 `native/macos/.build/lanextend-input --probe`,应返回 `trusted: true`。 + +### 控制权没有返回 Mac + +按 `Control + Option + Command + Esc`。该组合由 Mac helper 本地处理,会释放 Windows 上所有已按下的键并把鼠标送回进入时的 Mac 显示器。helper 进程退出后 macOS 也会恢复正常本地输入。 + +### Windows 能移动鼠标但快捷键不符合预期 + +默认按物理键映射:Mac `Command` 对应 Windows `Ctrl`、`Option` 对应 `Alt`、Mac `Control` 对应 Windows 键。当前版本没有自定义键位映射页面。 + +### 剪贴板没有同步 + +- 当前仅同步纯文本,不同步文件、图片、富文本或剪贴板历史。 +- 确认主端开关已启用,并且键鼠共享会话仍在线。 +- 超过 128 KiB 的文本会被忽略;缩短文本后重试。 +- 部分密码管理器或受保护应用会阻止读取系统剪贴板。 + +## 12. 记忆设备错误或配置损坏 ### IP 变更 @@ -183,11 +217,11 @@ MVP 没有 STUN/TURN,只面向可直接互通的局域网。检查: 完全退出应用,备份后重命名 Electron userData 目录中的 `settings.json`。不要直接删除整个 userData 目录,里面可能有 Chromium/Electron 其他状态。 -## 12. GitHub Actions 构建失败 +## 13. GitHub Actions 构建失败 - `npm ci` 失败:确认 `package.json` 与 `package-lock.json` 同步、registry 可用。 - Mac 原生构建失败:查看 `xcrun`/SDK/Clang 错误和 Objective-C `-Werror` 输出。 -- electron-builder 找不到 helper:确认 `npm run dist:mac` 先运行 native build,且 `.build/lanextend-vdisplay` 存在。 +- electron-builder 找不到 helper:确认 `npm run dist:mac` 先运行 native build,且 `.build/lanextend-vdisplay` 与 `.build/lanextend-input` 都存在。 - 上传提示无匹配文件:检查 `release/` 与 `artifactName`,不要用 `if-no-files-found: ignore` 隐藏打包失败。 - Windows 构建意外尝试 Mac helper:`build-native.mjs` 在非 macOS 应明确打印 skip 并成功退出。 diff --git a/docs/user-guide.md b/docs/user-guide.md index 5049d26..c01f1b4 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -68,7 +68,7 @@ npm run dev:receiver ### 屏幕录制 -主端必须能够捕获虚拟显示器。打开“系统设置 → 隐私与安全性 → 屏幕录制”(在部分版本中显示为“屏幕与系统音频录制”),启用 LanExtend。修改后应完全退出应用再重新打开。 +主端必须能够捕获虚拟显示器。把 LanExtend 安装到“应用程序”目录,并且只从该位置启动。打开“系统设置 → 隐私与安全性 → 屏幕录制”(在部分版本中显示为“屏幕与系统音频录制”),启用 LanExtend;返回应用后会自动重新检测。不要交替启动下载目录、DMG、源码或 `release/` 中的同名副本,否则 macOS 可能把它们当成不同的权限主体。 授权只用于视频画面;LanExtend MVP 不采集或发送音频。 @@ -76,9 +76,9 @@ npm run dev:receiver 若系统询问是否允许访问本地网络,请允许。拒绝后自动发现和连接可能失败。可以在“系统设置 → 隐私与安全性 → 本地网络”中复查。 -### 不需要的权限 +### 辅助功能 -当前版本没有输入回传,因此不需要辅助功能权限;也不需要麦克风、摄像头、管理员权限、内核扩展,且不应要求关闭 SIP。 +只有键鼠共享需要辅助功能权限。首次点击“启动键鼠共享”时允许 LanExtend,然后返回应用重试。该模式不需要屏幕录制权限。应用不需要麦克风、摄像头、管理员权限、内核扩展,也不应要求关闭 SIP。 ## 5. 配置扩展方式与画面参数 @@ -134,11 +134,26 @@ HiDPI 使同一逻辑尺寸的物理像素数变为四倍。发送端会尝试 若开启“网络中断后自动重连”,只有网络/异常断线会按约 1.6、3.2、6.4、12 秒退避重试,之后封顶 12 秒;等待期间可点击“取消自动重连”。用户主动断开、子端显式拒绝/断开,或 WebSocket 正常/策略关闭(1000/1008)不会重连。重新建链时默认模式会用真实子端 UUID 恢复稳定显示身份,并安全替换上一轮虚拟屏进程。 -## 8. 记忆功能 +## 8. 键鼠与剪贴板共享 + +键鼠共享与扩展屏互相独立,同一时间只运行一种会话。它不会创建虚拟屏,也不会把任何屏幕画面发送到 Windows。 + +1. 在设备列表选择 Windows 子端。 +2. 滚动到“键鼠与剪贴板共享”,布局画布会按 macOS 当前真实坐标显示全部 Mac 显示器。 +3. 拖动橙色 Windows 屏幕并贴到物理摆放对应的边缘。按题图布局,应把 Windows 放在上方 Mac 屏幕右侧、右下 Mac 屏幕上方。 +4. 选择是否双向同步纯文本剪贴板;设置边缘停留时间,默认 `80 ms`。 +5. 点击“启动键鼠共享”。鼠标从相邻边缘移出后,Windows 本机光标会接管位置,键盘随鼠标一起切换。 +6. 从 Windows 对应边缘移回即可返回 Mac;紧急情况下按 `Control + Option + Command + Esc`。 + +默认键位为 Mac `Command` → Windows `Ctrl`、`Option` → `Alt`、Mac `Control` → Windows 键。断线、停止或返回 Mac 时会发送 `releaseAll`,释放远端仍按下的按键和鼠标按钮。 + +剪贴板当前只同步 UTF-8 纯文本,上限 128 KiB;不包含图片、文件、富文本或剪贴板历史。刚启动共享时以 Mac 当前剪贴板为初始值,之后两端任一侧复制的新文本都会同步。 + +## 9. 记忆功能 双端把设置写到各自本机的 `settings.json`: -- 主端:画面参数、自动重连偏好、上次设备/捕获源标识、最多 32 个记忆设备; +- 主端:画面参数、自动重连偏好、上次设备/捕获源标识、键鼠布局、边缘停留和剪贴板开关; - 子端:稳定设备 UUID、名称、端口和自动全屏偏好; - 设备:名称、上次私有 IPv4、端口、最后发现和最后连接时间。 @@ -161,7 +176,7 @@ HiDPI 使同一逻辑尺寸的物理像素数变为四倍。发送端会尝试 - 全部设置:完全退出应用,先备份,再重命名 `settings.json`;下次启动会生成默认设置和新的子端 UUID。 - 不要在应用运行中编辑配置,退出时或设置更新时可能覆盖手工内容。 -## 9. 日常安全操作 +## 10. 日常安全操作 - 只在可信的家庭实验网、隔离测试 VLAN 或受控办公网使用。 - Windows 防火墙规则只选择“专用网络”,并尽可能限制到 Mac 所在子网。 diff --git a/native/macos/build.sh b/native/macos/build.sh index 17bebad..c2bf3cf 100755 --- a/native/macos/build.sh +++ b/native/macos/build.sh @@ -4,6 +4,7 @@ set -eu SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) OUTPUT_DIR="$SCRIPT_DIR/.build" OUTPUT_PATH="$OUTPUT_DIR/lanextend-vdisplay" +INPUT_OUTPUT_PATH="$OUTPUT_DIR/lanextend-input" SDK_PATH=$(xcrun --sdk macosx --show-sdk-path) CLANG_PATH=$(xcrun --sdk macosx --find clang) @@ -29,3 +30,25 @@ mkdir -p "$OUTPUT_DIR" -o "$OUTPUT_PATH" printf '%s\n' "Built $OUTPUT_PATH" + +"$CLANG_PATH" \ + -x objective-c \ + -std=gnu17 \ + -fobjc-arc \ + -fblocks \ + -O2 \ + -arch arm64 \ + -arch x86_64 \ + -Wall \ + -Wextra \ + -Werror \ + -mmacosx-version-min=14.0 \ + -isysroot "$SDK_PATH" \ + -framework AppKit \ + -framework ApplicationServices \ + -framework Foundation \ + -framework CoreGraphics \ + "$SCRIPT_DIR/lanextend_input.m" \ + -o "$INPUT_OUTPUT_PATH" + +printf '%s\n' "Built $INPUT_OUTPUT_PATH" diff --git a/native/macos/lanextend_input.m b/native/macos/lanextend_input.m new file mode 100644 index 0000000..4b2b87e --- /dev/null +++ b/native/macos/lanextend_input.m @@ -0,0 +1,206 @@ +#import +#import +#import +#include +#include +#include +#include +#include + +static atomic_bool gActive = false; +static CFMachPortRef gEventTap = NULL; +static CFRunLoopRef gRunLoop = NULL; +static pthread_mutex_t gOutputLock = PTHREAD_MUTEX_INITIALIZER; +static bool gModifierState[256] = { false }; + +static void WriteLine(NSString *line) { + pthread_mutex_lock(&gOutputLock); + fprintf(stdout, "%s\n", line.UTF8String); + fflush(stdout); + pthread_mutex_unlock(&gOutputLock); +} + +static void WriteReady(bool trusted) { + WriteLine([NSString stringWithFormat:@"{\"event\":\"ready\",\"trusted\":%@}", trusted ? @"true" : @"false"]); +} + +static NSString *MouseButtonName(CGMouseButton button) { + if (button == kCGMouseButtonLeft) return @"left"; + if (button == kCGMouseButtonRight) return @"right"; + if (button == kCGMouseButtonCenter) return @"middle"; + return @"other"; +} + +static bool IsMouseMove(CGEventType type) { + return type == kCGEventMouseMoved + || type == kCGEventLeftMouseDragged + || type == kCGEventRightMouseDragged + || type == kCGEventOtherMouseDragged; +} + +static bool IsMouseDown(CGEventType type) { + return type == kCGEventLeftMouseDown + || type == kCGEventRightMouseDown + || type == kCGEventOtherMouseDown; +} + +static bool IsMouseUp(CGEventType type) { + return type == kCGEventLeftMouseUp + || type == kCGEventRightMouseUp + || type == kCGEventOtherMouseUp; +} + +static CGEventRef EventCallback(CGEventTapProxy proxy, CGEventType type, CGEventRef event, void *context) { + (void)proxy; + (void)context; + @autoreleasepool { + if (type == kCGEventTapDisabledByTimeout || type == kCGEventTapDisabledByUserInput) { + if (gEventTap != NULL) CGEventTapEnable(gEventTap, true); + return event; + } + + const bool active = atomic_load(&gActive); + if (IsMouseMove(type)) { + CGPoint point = CGEventGetLocation(event); + int64_t deltaX = CGEventGetIntegerValueField(event, kCGMouseEventDeltaX); + int64_t deltaY = CGEventGetIntegerValueField(event, kCGMouseEventDeltaY); + WriteLine([NSString stringWithFormat: + @"{\"event\":\"move\",\"x\":%.2f,\"y\":%.2f,\"dx\":%lld,\"dy\":%lld}", + point.x, point.y, deltaX, deltaY]); + return active ? NULL : event; + } + + if (!active) return event; + + if (IsMouseDown(type) || IsMouseUp(type)) { + CGMouseButton button = (CGMouseButton)CGEventGetIntegerValueField(event, kCGMouseEventButtonNumber); + int64_t clicks = CGEventGetIntegerValueField(event, kCGMouseEventClickState); + WriteLine([NSString stringWithFormat: + @"{\"event\":\"button\",\"button\":\"%@\",\"down\":%@,\"clicks\":%lld}", + MouseButtonName(button), IsMouseDown(type) ? @"true" : @"false", MAX((int64_t)1, clicks)]); + return NULL; + } + + if (type == kCGEventScrollWheel) { + int64_t deltaY = CGEventGetIntegerValueField(event, kCGScrollWheelEventDeltaAxis1); + int64_t deltaX = CGEventGetIntegerValueField(event, kCGScrollWheelEventDeltaAxis2); + WriteLine([NSString stringWithFormat: + @"{\"event\":\"scroll\",\"deltaX\":%lld,\"deltaY\":%lld}", deltaX, deltaY]); + return NULL; + } + + if (type == kCGEventKeyDown || type == kCGEventKeyUp || type == kCGEventFlagsChanged) { + int64_t keyCode = CGEventGetIntegerValueField(event, kCGKeyboardEventKeycode); + CGEventFlags flags = CGEventGetFlags(event); + bool down = type == kCGEventKeyDown; + if (type == kCGEventFlagsChanged && keyCode >= 0 && keyCode < 256) { + down = !gModifierState[keyCode]; + gModifierState[keyCode] = down; + } else if (keyCode >= 0 && keyCode < 256) { + gModifierState[keyCode] = down; + } + const CGEventFlags escapeFlags = kCGEventFlagMaskCommand | kCGEventFlagMaskAlternate | kCGEventFlagMaskControl; + if (keyCode == 53 && down && (flags & escapeFlags) == escapeFlags) { + atomic_store(&gActive, false); + memset(gModifierState, 0, sizeof(gModifierState)); + WriteLine(@"{\"event\":\"release\"}"); + return NULL; + } + bool repeat = CGEventGetIntegerValueField(event, kCGKeyboardEventAutorepeat) != 0; + WriteLine([NSString stringWithFormat: + @"{\"event\":\"key\",\"keyCode\":%lld,\"down\":%@,\"repeat\":%@}", + keyCode, down ? @"true" : @"false", repeat ? @"true" : @"false"]); + return NULL; + } + + return active ? NULL : event; + } +} + +static void HandleCommand(NSDictionary *command) { + NSString *name = [command[@"command"] isKindOfClass:NSString.class] ? command[@"command"] : @""; + if ([name isEqualToString:@"activate"]) { + atomic_store(&gActive, true); + return; + } + if ([name isEqualToString:@"deactivate"]) { + atomic_store(&gActive, false); + memset(gModifierState, 0, sizeof(gModifierState)); + return; + } + if ([name isEqualToString:@"warp"]) { + NSNumber *x = [command[@"x"] isKindOfClass:NSNumber.class] ? command[@"x"] : @0; + NSNumber *y = [command[@"y"] isKindOfClass:NSNumber.class] ? command[@"y"] : @0; + CGWarpMouseCursorPosition(CGPointMake(x.doubleValue, y.doubleValue)); + CGAssociateMouseAndMouseCursorPosition(true); + return; + } + if ([name isEqualToString:@"quit"]) { + atomic_store(&gActive, false); + if (gRunLoop != NULL) CFRunLoopStop(gRunLoop); + } +} + +static void StartCommandReader(void) { + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + char *line = NULL; + size_t capacity = 0; + while (getline(&line, &capacity, stdin) >= 0) { + @autoreleasepool { + NSData *data = [NSData dataWithBytes:line length:strlen(line)]; + NSError *error = nil; + id object = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; + if ([object isKindOfClass:NSDictionary.class]) HandleCommand(object); + } + } + free(line); + if (gRunLoop != NULL) CFRunLoopStop(gRunLoop); + }); +} + +int main(int argc, const char *argv[]) { + @autoreleasepool { + bool probe = argc > 1 && strcmp(argv[1], "--probe") == 0; + NSDictionary *trustOptions = probe ? @{} : @{(__bridge NSString *)kAXTrustedCheckOptionPrompt: @YES}; + bool trusted = AXIsProcessTrustedWithOptions((__bridge CFDictionaryRef)trustOptions); + if (probe) { + WriteLine([NSString stringWithFormat:@"{\"event\":\"probe\",\"trusted\":%@}", trusted ? @"true" : @"false"]); + return trusted ? 0 : 2; + } + if (!trusted) { + WriteReady(false); + return 2; + } + + CGEventMask mask = CGEventMaskBit(kCGEventMouseMoved) + | CGEventMaskBit(kCGEventLeftMouseDown) | CGEventMaskBit(kCGEventLeftMouseUp) + | CGEventMaskBit(kCGEventRightMouseDown) | CGEventMaskBit(kCGEventRightMouseUp) + | CGEventMaskBit(kCGEventOtherMouseDown) | CGEventMaskBit(kCGEventOtherMouseUp) + | CGEventMaskBit(kCGEventLeftMouseDragged) | CGEventMaskBit(kCGEventRightMouseDragged) + | CGEventMaskBit(kCGEventOtherMouseDragged) | CGEventMaskBit(kCGEventScrollWheel) + | CGEventMaskBit(kCGEventKeyDown) | CGEventMaskBit(kCGEventKeyUp) + | CGEventMaskBit(kCGEventFlagsChanged); + gEventTap = CGEventTapCreate(kCGSessionEventTap, kCGHeadInsertEventTap, kCGEventTapOptionDefault, + mask, EventCallback, NULL); + if (gEventTap == NULL) { + WriteReady(false); + return 3; + } + CFRunLoopSourceRef source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, gEventTap, 0); + gRunLoop = CFRunLoopGetCurrent(); + CFRetain(gRunLoop); + CFRunLoopAddSource(gRunLoop, source, kCFRunLoopCommonModes); + CGEventTapEnable(gEventTap, true); + StartCommandReader(); + WriteReady(true); + CFRunLoopRun(); + + CFRunLoopRemoveSource(gRunLoop, source, kCFRunLoopCommonModes); + CFRelease(source); + CFRelease(gEventTap); + CFRelease(gRunLoop); + gEventTap = NULL; + gRunLoop = NULL; + return 0; + } +} diff --git a/native/windows/lanextend-input.ps1 b/native/windows/lanextend-input.ps1 new file mode 100644 index 0000000..0656592 --- /dev/null +++ b/native/windows/lanextend-input.ps1 @@ -0,0 +1,106 @@ +$ErrorActionPreference = 'Stop' + +Add-Type -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +public static class LanExtendInput { + [DllImport("user32.dll", SetLastError = true)] + public static extern bool SetCursorPos(int x, int y); + + [DllImport("user32.dll")] + private static extern void mouse_event(uint flags, uint dx, uint dy, int data, UIntPtr extraInfo); + + [DllImport("user32.dll")] + private static extern void keybd_event(byte virtualKey, byte scanCode, uint flags, UIntPtr extraInfo); + + [DllImport("user32.dll")] + private static extern bool SetProcessDpiAwarenessContext(IntPtr value); + + private const uint MOUSE_LEFT_DOWN = 0x0002; + private const uint MOUSE_LEFT_UP = 0x0004; + private const uint MOUSE_RIGHT_DOWN = 0x0008; + private const uint MOUSE_RIGHT_UP = 0x0010; + private const uint MOUSE_MIDDLE_DOWN = 0x0020; + private const uint MOUSE_MIDDLE_UP = 0x0040; + private const uint MOUSE_WHEEL = 0x0800; + private const uint MOUSE_HWHEEL = 0x1000; + private const uint KEY_UP = 0x0002; + private const uint KEY_EXTENDED = 0x0001; + + private static readonly HashSet PressedKeys = new HashSet(); + private static readonly HashSet PressedButtons = new HashSet(); + + private static bool IsExtended(int virtualKey) { + return (virtualKey >= 0x21 && virtualKey <= 0x2E) + || virtualKey == 0x5B || virtualKey == 0x5C || virtualKey == 0xA3 || virtualKey == 0xA5; + } + + public static void Initialize() { + try { SetProcessDpiAwarenessContext(new IntPtr(-4)); } catch { } + } + + public static void Pointer(int x, int y) { + SetCursorPos(x, y); + } + + public static void Button(string button, bool down) { + uint flag; + switch ((button ?? "left").ToLowerInvariant()) { + case "right": flag = down ? MOUSE_RIGHT_DOWN : MOUSE_RIGHT_UP; break; + case "middle": flag = down ? MOUSE_MIDDLE_DOWN : MOUSE_MIDDLE_UP; break; + default: flag = down ? MOUSE_LEFT_DOWN : MOUSE_LEFT_UP; button = "left"; break; + } + mouse_event(flag, 0, 0, 0, UIntPtr.Zero); + if (down) PressedButtons.Add(button); else PressedButtons.Remove(button); + } + + public static void Wheel(int deltaX, int deltaY) { + if (deltaY != 0) mouse_event(MOUSE_WHEEL, 0, 0, deltaY * 120, UIntPtr.Zero); + if (deltaX != 0) mouse_event(MOUSE_HWHEEL, 0, 0, deltaX * 120, UIntPtr.Zero); + } + + public static void Key(int virtualKey, bool down) { + if (virtualKey < 0 || virtualKey > 255) return; + byte key = (byte)virtualKey; + bool extended = IsExtended(virtualKey); + uint flags = (down ? 0u : KEY_UP) | (extended ? KEY_EXTENDED : 0u); + keybd_event(key, 0, flags, UIntPtr.Zero); + if (down) PressedKeys.Add(key); else PressedKeys.Remove(key); + } + + public static void ReleaseAll() { + foreach (byte key in new List(PressedKeys)) { + keybd_event(key, 0, KEY_UP | (IsExtended(key) ? KEY_EXTENDED : 0u), UIntPtr.Zero); + } + PressedKeys.Clear(); + foreach (string button in new List(PressedButtons)) Button(button, false); + PressedButtons.Clear(); + } +} +'@ + +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) +[LanExtendInput]::Initialize() +[Console]::Out.WriteLine('{"event":"ready","trusted":true}') +[Console]::Out.Flush() + +try { + while (($line = [Console]::In.ReadLine()) -ne $null) { + if ([string]::IsNullOrWhiteSpace($line)) { continue } + $message = $line | ConvertFrom-Json + if ($message.command -eq 'quit') { break } + if ($message.command -ne 'input' -or $null -eq $message.event) { continue } + $event = $message.event + switch ($event.kind) { + 'pointer' { [LanExtendInput]::Pointer([int]$event.x, [int]$event.y) } + 'button' { [LanExtendInput]::Button([string]$event.button, [bool]$event.down) } + 'wheel' { [LanExtendInput]::Wheel([int]$event.deltaX, [int]$event.deltaY) } + 'key' { [LanExtendInput]::Key([int]$event.vk, [bool]$event.down) } + 'releaseAll' { [LanExtendInput]::ReleaseAll() } + } + } +} finally { + [LanExtendInput]::ReleaseAll() +} diff --git a/package-lock.json b/package-lock.json index 7344591..0153fbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lanextend", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lanextend", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { "ws": "8.21.3" diff --git a/package.json b/package.json index dcbb1a5..65684b5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lanextend", - "version": "0.2.0", - "description": "LAN virtual extended display: macOS host to Windows receiver", + "version": "0.3.0", + "description": "LAN extended display and seamless keyboard, mouse and clipboard sharing", "main": "src/main.js", "author": "LanExtend contributors", "license": "MIT", @@ -48,8 +48,13 @@ "mac": { "category": "public.app-category.utilities", "minimumSystemVersion": "14.0", - "hardenedRuntime": true, + "identity": "-", + "hardenedRuntime": false, "gatekeeperAssess": false, + "binaries": [ + "Contents/Resources/native/lanextend-vdisplay", + "Contents/Resources/native/lanextend-input" + ], "extendInfo": { "NSScreenCaptureUsageDescription": "LanExtend 需要捕获虚拟扩展屏并将画面发送到您选择的局域网设备。", "NSLocalNetworkUsageDescription": "LanExtend 使用局域网发现并连接您的 Windows 子端。" @@ -58,6 +63,10 @@ { "from": "native/macos/.build/lanextend-vdisplay", "to": "native/lanextend-vdisplay" + }, + { + "from": "native/macos/.build/lanextend-input", + "to": "native/lanextend-input" } ] }, @@ -65,6 +74,12 @@ "target": [ "portable", "zip" + ], + "extraResources": [ + { + "from": "native/windows/lanextend-input.ps1", + "to": "native/lanextend-input.ps1" + } ] } } diff --git a/src/core/config-store.js b/src/core/config-store.js index f1504ad..8c6a9df 100644 --- a/src/core/config-store.js +++ b/src/core/config-store.js @@ -53,6 +53,49 @@ function sanitizeReceiver(input = {}, previous = DEFAULTS.receiver) { }; } +function sanitizeDisplay(input) { + if (!input || typeof input !== 'object') return null; + const width = clampInteger(input.width, 320, 7680, 1920); + const height = clampInteger(input.height, 240, 4320, 1080); + const scaleFactor = Number(input.scaleFactor); + return { + width, + height, + scaleFactor: Number.isFinite(scaleFactor) && scaleFactor >= 0.5 && scaleFactor <= 4 + ? scaleFactor + : 1 + }; +} + +function sanitizeInputLayout(input) { + if (!input || typeof input !== 'object' || typeof input.deviceId !== 'string') return null; + return { + deviceId: input.deviceId.slice(0, 128), + x: clampInteger(input.x, -32_768, 32_768, 1920), + y: clampInteger(input.y, -32_768, 32_768, 0), + width: clampInteger(input.width, 320, 7680, 1920), + height: clampInteger(input.height, 240, 4320, 1080) + }; +} + +function sanitizeInputSharing(input = {}, previous = DEFAULTS.inputSharing) { + const priorLayouts = Array.isArray(previous.layouts) ? previous.layouts : []; + const layouts = Array.isArray(input.layouts) + ? input.layouts.map(sanitizeInputLayout).filter(Boolean).slice(0, 32) + : priorLayouts; + return { + lastDeviceId: typeof input.lastDeviceId === 'string' + ? input.lastDeviceId.slice(0, 128) + : input.lastDeviceId === null ? null : previous.lastDeviceId, + clipboard: typeof input.clipboard === 'boolean' ? input.clipboard : previous.clipboard, + autoReconnect: typeof input.autoReconnect === 'boolean' + ? input.autoReconnect + : previous.autoReconnect, + edgeDelayMs: clampInteger(input.edgeDelayMs, 0, 1000, previous.edgeDelayMs), + layouts + }; +} + function sanitizeDevice(input) { if (!input || typeof input !== 'object') return null; const host = typeof input.host === 'string' ? input.host : ''; @@ -63,7 +106,8 @@ function sanitizeDevice(input) { host, port: input.port, lastSeen: Number.isFinite(input.lastSeen) ? input.lastSeen : Date.now(), - lastConnected: Number.isFinite(input.lastConnected) ? input.lastConnected : null + lastConnected: Number.isFinite(input.lastConnected) ? input.lastConnected : null, + display: sanitizeDisplay(input.display) }; } @@ -86,6 +130,7 @@ class ConfigStore { schemaVersion: 1, host: sanitizeHost(parsed.host, fallback.host), receiver: sanitizeReceiver(parsed.receiver, fallback.receiver), + inputSharing: sanitizeInputSharing(parsed.inputSharing, fallback.inputSharing), rememberedDevices: Array.isArray(parsed.rememberedDevices) ? parsed.rememberedDevices.map(sanitizeDevice).filter(Boolean).slice(0, 32) : [] @@ -103,6 +148,9 @@ class ConfigStore { updateSettings(patch = {}) { if (patch.host) this.state.host = sanitizeHost(patch.host, this.state.host); + if (patch.inputSharing) { + this.state.inputSharing = sanitizeInputSharing(patch.inputSharing, this.state.inputSharing); + } if (patch.receiver) { const next = sanitizeReceiver(patch.receiver, this.state.receiver); next.id = this.state.receiver.id; @@ -154,6 +202,9 @@ module.exports = { ConfigStore, defaultState, sanitizeDevice, + sanitizeDisplay, sanitizeHost, + sanitizeInputLayout, + sanitizeInputSharing, sanitizeReceiver }; diff --git a/src/core/constants.js b/src/core/constants.js index b772816..5d0f8b3 100644 --- a/src/core/constants.js +++ b/src/core/constants.js @@ -1,6 +1,6 @@ 'use strict'; -const PROTOCOL_VERSION = 1; +const PROTOCOL_VERSION = 2; const DISCOVERY_PORT = 47_771; const SIGNAL_PORT = 47_772; const MAX_SIGNAL_BYTES = 256 * 1024; @@ -24,6 +24,13 @@ const DEFAULTS = Object.freeze({ port: SIGNAL_PORT, autoFullscreen: true }, + inputSharing: { + lastDeviceId: null, + clipboard: true, + autoReconnect: true, + edgeDelayMs: 80, + layouts: [] + }, rememberedDevices: [] }); diff --git a/src/core/input-controller.js b/src/core/input-controller.js new file mode 100644 index 0000000..db250f3 --- /dev/null +++ b/src/core/input-controller.js @@ -0,0 +1,393 @@ +'use strict'; + +const { EventEmitter } = require('node:events'); +const fs = require('node:fs'); +const { spawn } = require('node:child_process'); +const { InputLayoutRouter, findEntry, hasAdjacentEdge } = require('./input-layout'); + +const CLIPBOARD_MAX_BYTES = 128 * 1024; + +const MAC_KEY_TO_WINDOWS_VK = new Map([ + [0, 0x41], [1, 0x53], [2, 0x44], [3, 0x46], [4, 0x48], [5, 0x47], [6, 0x5A], + [7, 0x58], [8, 0x43], [9, 0x56], [11, 0x42], [12, 0x51], [13, 0x57], [14, 0x45], + [15, 0x52], [16, 0x59], [17, 0x54], [18, 0x31], [19, 0x32], [20, 0x33], [21, 0x34], + [22, 0x36], [23, 0x35], [24, 0xBB], [25, 0x39], [26, 0x37], [27, 0xBD], [28, 0x38], + [29, 0x30], [30, 0xDD], [31, 0x4F], [32, 0x55], [33, 0xDB], [34, 0x49], [35, 0x50], + [36, 0x0D], [37, 0x4C], [38, 0x4A], [39, 0xDE], [40, 0x4B], [41, 0xBA], [42, 0xDC], + [43, 0xBC], [44, 0xBF], [45, 0x4E], [46, 0x4D], [47, 0xBE], [48, 0x09], [49, 0x20], + [50, 0xC0], [51, 0x08], [53, 0x1B], + // 在 Windows 上保留 Mac 的常用操作习惯:Command -> Ctrl,Option -> Alt,Control -> Win。 + [54, 0xA3], [55, 0xA2], [56, 0xA0], [57, 0x14], [58, 0xA4], [59, 0x5B], + [60, 0xA1], [61, 0xA5], [62, 0x5C], + [65, 0x6E], [67, 0x6A], [69, 0x6B], [71, 0x0C], [75, 0x6F], [76, 0x0D], + [78, 0x6D], [81, 0x6A], [82, 0x60], [83, 0x61], [84, 0x62], [85, 0x63], [86, 0x64], + [87, 0x65], [88, 0x66], [89, 0x67], [91, 0x68], [92, 0x69], + [96, 0x74], [97, 0x75], [98, 0x76], [99, 0x72], [100, 0x77], [101, 0x78], + [103, 0x7A], [105, 0x7C], [107, 0x7D], [109, 0x79], [111, 0x7B], + [114, 0x2D], [115, 0x24], [116, 0x21], [117, 0x2E], [118, 0x73], [119, 0x23], + [120, 0x71], [121, 0x22], [122, 0x70], [123, 0x25], [124, 0x27], [125, 0x28], [126, 0x26] +]); + +function macKeyCodeToWindowsVk(keyCode) { + return MAC_KEY_TO_WINDOWS_VK.get(Number(keyCode)) ?? null; +} + +class JsonLineProcess extends EventEmitter { + constructor(command, args, options = {}) { + super(); + this.command = command; + this.args = args; + this.options = options; + this.child = null; + this.buffer = ''; + this.shutdownTimer = null; + } + + async start(timeoutMs = 5000) { + if (this.child) return; + const child = spawn(this.command, this.args, { + cwd: this.options.cwd, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'] + }); + this.child = child; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk) => this.#consume(chunk)); + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk) => this.emit('warning', String(chunk).trim())); + child.on('error', (error) => this.emit('error', error)); + child.on('exit', (code, signal) => { + clearTimeout(this.shutdownTimer); + this.shutdownTimer = null; + if (this.child === child) this.child = null; + this.emit('exit', { code, signal }); + }); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('输入助手启动超时')), timeoutMs); + const ready = (message) => { + if (message?.event !== 'ready') return; + clearTimeout(timer); + this.off('message', ready); + if (message.trusted === false) reject(new Error('macOS 尚未授予辅助功能权限')); + else resolve(message); + }; + this.on('message', ready); + child.once('error', (error) => { + clearTimeout(timer); + this.off('message', ready); + reject(error); + }); + child.once('exit', (code) => { + clearTimeout(timer); + this.off('message', ready); + reject(new Error(`输入助手提前退出 (${code ?? 'unknown'})`)); + }); + }); + } + + #consume(chunk) { + this.buffer += chunk; + let newline; + while ((newline = this.buffer.indexOf('\n')) >= 0) { + const line = this.buffer.slice(0, newline).trim(); + this.buffer = this.buffer.slice(newline + 1); + if (!line) continue; + try { + this.emit('message', JSON.parse(line)); + } catch { + this.emit('warning', `输入助手返回了无法识别的内容:${line.slice(0, 160)}`); + } + } + } + + send(message) { + if (!this.child?.stdin?.writable) return false; + this.child.stdin.write(`${JSON.stringify(message)}\n`); + return true; + } + + stop() { + if (!this.child) return; + const child = this.child; + this.send({ command: 'quit' }); + clearTimeout(this.shutdownTimer); + this.shutdownTimer = setTimeout(() => { + if (this.child === child) child.kill('SIGTERM'); + }, 800); + this.shutdownTimer.unref?.(); + } +} + +class MacInputController extends EventEmitter { + constructor(options = {}) { + super(); + this.executable = options.executable; + this.platform = options.platform || process.platform; + this.process = null; + this.router = null; + this.running = false; + this.active = false; + this.ignoreEntryUntil = 0; + this.edgeDelayMs = 0; + this.entryCandidate = null; + this.pendingPointer = null; + this.pointerTimer = null; + } + + get supported() { + return this.platform === 'darwin' && Boolean(this.executable) && fs.existsSync(this.executable); + } + + status() { + return { supported: this.supported, running: this.running, active: this.active }; + } + + async start(layout) { + if (!this.supported) throw new Error('macOS 键鼠捕获助手不可用,请先构建原生组件'); + this.router = new InputLayoutRouter(layout); + if (!hasAdjacentEdge(this.router.locals, this.router.remote)) { + this.router = null; + throw new Error('Windows 布局必须与至少一台 Mac 显示器共享一段边缘'); + } + this.edgeDelayMs = Math.max(0, Math.min(1000, Number(layout?.edgeDelayMs) || 0)); + this.entryCandidate = null; + if (!this.process) { + const processWrapper = new JsonLineProcess(this.executable, []); + this.process = processWrapper; + processWrapper.on('message', (message) => this.#handle(message)); + processWrapper.on('warning', (message) => this.emit('warning', message)); + processWrapper.on('error', (error) => this.emit('error', error)); + processWrapper.on('exit', () => { + if (this.process === processWrapper) this.process = null; + const wasRunning = this.running; + this.running = false; + this.active = false; + if (wasRunning) this.emit('status', this.status()); + }); + try { + await processWrapper.start(); + } catch (error) { + processWrapper.stop(); + if (this.process === processWrapper) this.process = null; + throw error; + } + } + this.running = true; + this.active = false; + this.process.send({ command: 'deactivate' }); + this.emit('status', this.status()); + return this.status(); + } + + #handle(message) { + if (!this.running || !this.router) return; + if (message.event === 'move') { + if (!this.active) { + if (Date.now() < this.ignoreEntryUntil) return; + const point = { x: message.x, y: message.y }; + const delta = { x: message.dx, y: message.dy }; + const candidate = findEntry(this.router.locals, this.router.remote, point, delta); + if (!candidate) { + this.entryCandidate = null; + return; + } + const candidateKey = `${candidate.local.id}:${candidate.edge}`; + if (!this.entryCandidate || this.entryCandidate.key !== candidateKey) { + this.entryCandidate = { key: candidateKey, since: Date.now() }; + if (this.edgeDelayMs > 0) return; + } + if (Date.now() - this.entryCandidate.since < this.edgeDelayMs) return; + const entry = this.router.tryEnter(point, delta); + this.entryCandidate = null; + if (entry) this.#activate(entry.point); + return; + } + const result = this.router.move(message.dx, message.dy); + if (!result) return; + if (result.exited) this.#deactivate(result.localPoint); + else this.#queuePointer(result.point); + return; + } + if (!this.active) return; + if (message.event === 'button') { + this.#flushPointer(); + this.emit('outbound', { type: 'input', event: { + kind: 'button', button: message.button, down: Boolean(message.down), clicks: message.clicks || 1 + } }); + } else if (message.event === 'scroll') { + this.#flushPointer(); + this.emit('outbound', { type: 'input', event: { + kind: 'wheel', deltaX: Number(message.deltaX) || 0, deltaY: Number(message.deltaY) || 0 + } }); + } else if (message.event === 'key') { + this.#flushPointer(); + const vk = macKeyCodeToWindowsVk(message.keyCode); + if (vk !== null) this.emit('outbound', { type: 'input', event: { + kind: 'key', vk, down: Boolean(message.down), repeat: Boolean(message.repeat) + } }); + } else if (message.event === 'release') { + this.#deactivate(this.router.forceExit()); + } + } + + #activate(point) { + this.active = true; + this.process.send({ command: 'activate' }); + this.emit('outbound', { type: 'control', action: 'active' }); + this.emit('outbound', { type: 'input', event: { + kind: 'pointer', x: Math.round(point.x), y: Math.round(point.y) + } }); + this.emit('status', this.status()); + } + + #queuePointer(point) { + this.pendingPointer = { x: Math.round(point.x), y: Math.round(point.y) }; + if (this.pointerTimer) return; + this.pointerTimer = setTimeout(() => this.#flushPointer(), 8); + this.pointerTimer.unref?.(); + } + + #flushPointer() { + clearTimeout(this.pointerTimer); + this.pointerTimer = null; + if (!this.pendingPointer) return; + const point = this.pendingPointer; + this.pendingPointer = null; + this.emit('outbound', { type: 'input', event: { kind: 'pointer', ...point } }); + } + + #deactivate(localPoint) { + this.#flushPointer(); + this.active = false; + this.router.active = false; + this.ignoreEntryUntil = Date.now() + 450; + this.emit('outbound', { type: 'input', event: { kind: 'releaseAll' } }); + this.emit('outbound', { type: 'control', action: 'inactive' }); + this.process.send({ command: 'deactivate' }); + this.process.send({ command: 'warp', x: Math.round(localPoint.x), y: Math.round(localPoint.y) }); + this.emit('status', this.status()); + } + + stop() { + if (this.active) this.#deactivate(this.router?.forceExit() || { x: 20, y: 20 }); + this.running = false; + this.active = false; + this.router = null; + this.entryCandidate = null; + clearTimeout(this.pointerTimer); + this.pointerTimer = null; + this.pendingPointer = null; + this.process?.stop(); + this.process = null; + this.emit('status', this.status()); + } +} + +class WindowsInputController extends EventEmitter { + constructor(options = {}) { + super(); + this.script = options.script; + this.platform = options.platform || process.platform; + this.process = null; + } + + get supported() { + return this.platform === 'win32' && Boolean(this.script) && fs.existsSync(this.script); + } + + async start() { + if (!this.supported) throw new Error('Windows 键鼠注入助手不可用'); + if (this.process) return { supported: true, running: true }; + const wrapper = new JsonLineProcess('powershell.exe', [ + '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', this.script + ]); + this.process = wrapper; + wrapper.on('warning', (message) => this.emit('warning', message)); + wrapper.on('error', (error) => this.emit('error', error)); + wrapper.on('exit', () => { + if (this.process === wrapper) this.process = null; + this.emit('status', { supported: this.supported, running: false }); + }); + try { + await wrapper.start(8000); + } catch (error) { + wrapper.stop(); + if (this.process === wrapper) this.process = null; + throw error; + } + this.emit('status', { supported: true, running: true }); + return { supported: true, running: true }; + } + + send(event) { + return this.process?.send({ command: 'input', event }) || false; + } + + stop() { + this.process?.send({ command: 'input', event: { kind: 'releaseAll' } }); + this.process?.stop(); + this.process = null; + this.emit('status', { supported: this.supported, running: false }); + } +} + +class ClipboardSync extends EventEmitter { + constructor(options) { + super(); + this.readText = options.readText; + this.writeText = options.writeText; + this.send = options.send; + this.intervalMs = options.intervalMs || 500; + this.origin = options.origin || `clipboard-${Date.now()}`; + this.lastText = ''; + this.sequence = 0; + this.timer = null; + } + + start(sendInitial = true) { + if (this.timer) return; + this.lastText = String(this.readText() || ''); + if (sendInitial && Buffer.byteLength(this.lastText, 'utf8') <= CLIPBOARD_MAX_BYTES) this.#send(this.lastText); + this.timer = setInterval(() => this.poll(), this.intervalMs); + this.timer.unref?.(); + } + + poll() { + const text = String(this.readText() || ''); + if (text === this.lastText) return false; + this.lastText = text; + if (Buffer.byteLength(text, 'utf8') > CLIPBOARD_MAX_BYTES) { + this.emit('warning', '剪贴板文本过大,本次未同步'); + return false; + } + this.#send(text); + return true; + } + + #send(text) { + this.sequence += 1; + this.send({ type: 'clipboard', text, revision: `${this.origin}:${this.sequence}` }); + } + + applyRemote(message) { + if (typeof message?.text !== 'string') return false; + if (Buffer.byteLength(message.text, 'utf8') > CLIPBOARD_MAX_BYTES) return false; + if (message.text === this.lastText) return true; + this.lastText = message.text; + this.writeText(message.text); + return true; + } + + stop() { + clearInterval(this.timer); + this.timer = null; + } +} + +module.exports = { + CLIPBOARD_MAX_BYTES, + ClipboardSync, + MacInputController, + WindowsInputController, + macKeyCodeToWindowsVk +}; diff --git a/src/core/input-layout.js b/src/core/input-layout.js new file mode 100644 index 0000000..98ccd8f --- /dev/null +++ b/src/core/input-layout.js @@ -0,0 +1,240 @@ +'use strict'; + +const EDGE_TOLERANCE = 4; + +function finiteNumber(value, fallback = 0) { + const number = Number(value); + return Number.isFinite(number) ? number : fallback; +} + +function normalizeRect(value, fallback = {}) { + return { + id: String(value?.id ?? fallback.id ?? ''), + x: Math.round(finiteNumber(value?.x, fallback.x ?? 0)), + y: Math.round(finiteNumber(value?.y, fallback.y ?? 0)), + width: Math.max(1, Math.round(finiteNumber(value?.width, fallback.width ?? 1))), + height: Math.max(1, Math.round(finiteNumber(value?.height, fallback.height ?? 1))) + }; +} + +function rectRight(rect) { + return rect.x + rect.width; +} + +function rectBottom(rect) { + return rect.y + rect.height; +} + +function within(value, minimum, maximum, tolerance = 0) { + return value >= minimum - tolerance && value <= maximum + tolerance; +} + +function overlapPoint(value, firstStart, firstEnd, secondStart, secondEnd) { + return within(value, Math.max(firstStart, secondStart), Math.min(firstEnd, secondEnd)); +} + +function hasAdjacentEdge(localDisplays, remoteValue, tolerance = EDGE_TOLERANCE) { + const remote = normalizeRect(remoteValue); + const remoteRight = rectRight(remote); + const remoteBottom = rectBottom(remote); + return localDisplays.some((localValue) => { + const local = normalizeRect(localValue); + const localRight = rectRight(local); + const localBottom = rectBottom(local); + const verticalOverlap = Math.min(localBottom, remoteBottom) - Math.max(local.y, remote.y); + const horizontalOverlap = Math.min(localRight, remoteRight) - Math.max(local.x, remote.x); + return (verticalOverlap > 1 + && (Math.abs(localRight - remote.x) <= tolerance || Math.abs(local.x - remoteRight) <= tolerance)) + || (horizontalOverlap > 1 + && (Math.abs(localBottom - remote.y) <= tolerance || Math.abs(local.y - remoteBottom) <= tolerance)); + }); +} + +function defaultRemoteRect(localDisplays, width = 1920, height = 1080, id = 'remote') { + const locals = localDisplays.map((display) => normalizeRect(display)); + if (!locals.length) return normalizeRect({ id, x: 0, y: 0, width, height }); + const top = Math.min(...locals.map((display) => display.y)); + const topRow = locals.filter((display) => display.y === top); + const anchor = topRow.reduce((best, display) => ( + !best || rectRight(display) > rectRight(best) ? display : best + ), null); + return normalizeRect({ id, x: rectRight(anchor), y: anchor.y, width, height }); +} + +function findEntry(localDisplays, remoteValue, pointValue, deltaValue, tolerance = EDGE_TOLERANCE) { + const remote = normalizeRect(remoteValue); + const point = { x: finiteNumber(pointValue?.x), y: finiteNumber(pointValue?.y) }; + const delta = { x: finiteNumber(deltaValue?.x), y: finiteNumber(deltaValue?.y) }; + + for (const localValue of localDisplays) { + const local = normalizeRect(localValue); + const localRight = rectRight(local); + const localBottom = rectBottom(local); + const remoteRight = rectRight(remote); + const remoteBottom = rectBottom(remote); + + if (Math.abs(localRight - remote.x) <= tolerance + && delta.x > 0 + && point.x >= localRight - tolerance + && overlapPoint(point.y, local.y, localBottom, remote.y, remoteBottom)) { + return { + edge: 'right', + local, + remotePoint: { x: 1, y: Math.max(0, Math.min(remote.height - 1, point.y - remote.y)) } + }; + } + if (Math.abs(local.x - remoteRight) <= tolerance + && delta.x < 0 + && point.x <= local.x + tolerance + && overlapPoint(point.y, local.y, localBottom, remote.y, remoteBottom)) { + return { + edge: 'left', + local, + remotePoint: { x: remote.width - 2, y: Math.max(0, Math.min(remote.height - 1, point.y - remote.y)) } + }; + } + if (Math.abs(localBottom - remote.y) <= tolerance + && delta.y > 0 + && point.y >= localBottom - tolerance + && overlapPoint(point.x, local.x, localRight, remote.x, remoteRight)) { + return { + edge: 'bottom', + local, + remotePoint: { x: Math.max(0, Math.min(remote.width - 1, point.x - remote.x)), y: 1 } + }; + } + if (Math.abs(local.y - remoteBottom) <= tolerance + && delta.y < 0 + && point.y <= local.y + tolerance + && overlapPoint(point.x, local.x, localRight, remote.x, remoteRight)) { + return { + edge: 'top', + local, + remotePoint: { x: Math.max(0, Math.min(remote.width - 1, point.x - remote.x)), y: remote.height - 2 } + }; + } + } + return null; +} + +function findExit(localDisplays, remoteValue, pointValue) { + const remote = normalizeRect(remoteValue); + const point = { x: finiteNumber(pointValue?.x), y: finiteNumber(pointValue?.y) }; + const world = { x: remote.x + point.x, y: remote.y + point.y }; + + for (const localValue of localDisplays) { + const local = normalizeRect(localValue); + const localRight = rectRight(local); + const localBottom = rectBottom(local); + const remoteRight = rectRight(remote); + const remoteBottom = rectBottom(remote); + + if (point.x < 0 + && Math.abs(localRight - remote.x) <= EDGE_TOLERANCE + && overlapPoint(world.y, local.y, localBottom, remote.y, remoteBottom)) { + return { edge: 'left', local, localPoint: { x: localRight - 2, y: world.y } }; + } + if (point.x >= remote.width + && Math.abs(local.x - remoteRight) <= EDGE_TOLERANCE + && overlapPoint(world.y, local.y, localBottom, remote.y, remoteBottom)) { + return { edge: 'right', local, localPoint: { x: local.x + 1, y: world.y } }; + } + if (point.y < 0 + && Math.abs(localBottom - remote.y) <= EDGE_TOLERANCE + && overlapPoint(world.x, local.x, localRight, remote.x, remoteRight)) { + return { edge: 'top', local, localPoint: { x: world.x, y: localBottom - 2 } }; + } + if (point.y >= remote.height + && Math.abs(local.y - remoteBottom) <= EDGE_TOLERANCE + && overlapPoint(world.x, local.x, localRight, remote.x, remoteRight)) { + return { edge: 'bottom', local, localPoint: { x: world.x, y: local.y + 1 } }; + } + } + return null; +} + +function snapRemoteRect(remoteValue, localDisplays, threshold = 120) { + const remote = normalizeRect(remoteValue); + let best = { distance: Math.max(0, finiteNumber(threshold, 120)), x: remote.x, y: remote.y }; + for (const value of localDisplays) { + const local = normalizeRect(value); + const candidates = [ + { distance: Math.abs(remote.x - rectRight(local)), x: rectRight(local), y: remote.y }, + { distance: Math.abs(rectRight(remote) - local.x), x: local.x - remote.width, y: remote.y }, + { distance: Math.abs(remote.y - rectBottom(local)), x: remote.x, y: rectBottom(local) }, + { distance: Math.abs(rectBottom(remote) - local.y), x: remote.x, y: local.y - remote.height } + ]; + for (const candidate of candidates) { + if (candidate.distance < best.distance) best = candidate; + } + } + return { ...remote, x: Math.round(best.x), y: Math.round(best.y) }; +} + +class InputLayoutRouter { + constructor(layout) { + this.configure(layout); + } + + configure(layout) { + this.locals = (layout?.locals || []).map((display) => normalizeRect(display)); + this.remote = normalizeRect(layout?.remote, defaultRemoteRect(this.locals)); + this.remotePoint = { x: 0, y: 0 }; + this.entry = null; + this.active = false; + } + + tryEnter(point, delta) { + if (this.active) return null; + const entry = findEntry(this.locals, this.remote, point, delta); + if (!entry) return null; + this.entry = entry; + this.remotePoint = entry.remotePoint; + this.active = true; + return { ...entry, point: { ...this.remotePoint } }; + } + + move(deltaX, deltaY) { + if (!this.active) return null; + const candidate = { + x: this.remotePoint.x + finiteNumber(deltaX), + y: this.remotePoint.y + finiteNumber(deltaY) + }; + const exit = findExit(this.locals, this.remote, candidate); + if (exit) { + this.active = false; + this.remotePoint = { + x: Math.max(0, Math.min(this.remote.width - 1, candidate.x)), + y: Math.max(0, Math.min(this.remote.height - 1, candidate.y)) + }; + return { exited: true, ...exit }; + } + this.remotePoint = { + x: Math.max(0, Math.min(this.remote.width - 1, candidate.x)), + y: Math.max(0, Math.min(this.remote.height - 1, candidate.y)) + }; + return { exited: false, point: { ...this.remotePoint } }; + } + + forceExit() { + this.active = false; + const local = this.entry?.local || this.locals[0]; + if (!local) return { x: 0, y: 0 }; + if (this.entry?.edge === 'right') return { x: rectRight(local) - 8, y: local.y + local.height / 2 }; + if (this.entry?.edge === 'left') return { x: local.x + 8, y: local.y + local.height / 2 }; + if (this.entry?.edge === 'bottom') return { x: local.x + local.width / 2, y: rectBottom(local) - 8 }; + if (this.entry?.edge === 'top') return { x: local.x + local.width / 2, y: local.y + 8 }; + return { x: local.x + local.width / 2, y: local.y + local.height / 2 }; + } +} + +module.exports = { + EDGE_TOLERANCE, + InputLayoutRouter, + defaultRemoteRect, + findEntry, + findExit, + hasAdjacentEdge, + normalizeRect, + snapRemoteRect +}; diff --git a/src/core/network.js b/src/core/network.js index 42be8b9..c5eac13 100644 --- a/src/core/network.js +++ b/src/core/network.js @@ -230,7 +230,9 @@ class SignalServer extends EventEmitter { id: this.receiver.id, name: this.receiver.name, port: this.port, - authMode: 'none' + authMode: 'none', + capabilities: this.receiver.capabilities || ['video', 'fullscreen', 'input', 'clipboard'], + display: this.receiver.display || null }, transportSecurity: 'none' })); diff --git a/src/core/protocol.js b/src/core/protocol.js index 752397f..9306a94 100644 --- a/src/core/protocol.js +++ b/src/core/protocol.js @@ -12,9 +12,25 @@ const SIGNAL_TYPES = new Set([ 'ice', 'disconnect', 'ping', - 'pong' + 'pong', + 'control', + 'input', + 'clipboard' ]); +const CONTROL_ACTIONS = new Set([ + 'share-start', + 'share-ready', + 'share-stop', + 'active', + 'inactive', + 'error' +]); + +const INPUT_KINDS = new Set(['pointer', 'button', 'wheel', 'key', 'releaseAll']); +const MOUSE_BUTTONS = new Set(['left', 'right', 'middle', 'other']); +const MAX_CLIPBOARD_BYTES = 128 * 1024; + function isPlainObject(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; const proto = Object.getPrototypeOf(value); @@ -97,6 +113,57 @@ function parseSignalMessage(raw) { throw new Error('断开原因无效'); } + if (message.type === 'control') { + if (!CONTROL_ACTIONS.has(message.action)) throw new Error('控制消息无效'); + if (message.clipboard !== undefined && typeof message.clipboard !== 'boolean') { + throw new Error('控制消息的剪贴板设置无效'); + } + if (message.screen !== undefined) { + if (!isPlainObject(message.screen) + || !Number.isInteger(message.screen.width) + || !Number.isInteger(message.screen.height) + || message.screen.width < 320 || message.screen.width > 7680 + || message.screen.height < 240 || message.screen.height > 4320) { + throw new Error('控制消息的屏幕信息无效'); + } + } + if (message.message !== undefined && !isNonEmptyString(message.message, 512)) { + throw new Error('控制消息的说明无效'); + } + } + + if (message.type === 'input') { + const event = message.event; + if (!isPlainObject(event) || !INPUT_KINDS.has(event.kind)) throw new Error('输入事件无效'); + if (event.kind === 'pointer' + && (!Number.isInteger(event.x) || !Number.isInteger(event.y) + || event.x < -32_768 || event.x > 32_768 || event.y < -32_768 || event.y > 32_768)) { + throw new Error('鼠标坐标无效'); + } + if (event.kind === 'button' + && (!MOUSE_BUTTONS.has(event.button) || typeof event.down !== 'boolean')) { + throw new Error('鼠标按键事件无效'); + } + if (event.kind === 'wheel' + && (!Number.isFinite(event.deltaX) || !Number.isFinite(event.deltaY) + || Math.abs(event.deltaX) > 1000 || Math.abs(event.deltaY) > 1000)) { + throw new Error('滚轮事件无效'); + } + if (event.kind === 'key' + && (!Number.isInteger(event.vk) || event.vk < 0 || event.vk > 255 + || typeof event.down !== 'boolean')) { + throw new Error('键盘事件无效'); + } + } + + if (message.type === 'clipboard') { + if (typeof message.text !== 'string' + || Buffer.byteLength(message.text, 'utf8') > MAX_CLIPBOARD_BYTES + || !isNonEmptyString(message.revision, 160)) { + throw new Error('剪贴板消息无效'); + } + } + return message; } @@ -127,7 +194,8 @@ function makeBeacon(receiver) { port: receiver.port, platform: 'win32', authMode: 'none', - capabilities: ['video', 'fullscreen'] + capabilities: ['video', 'fullscreen', 'input', 'clipboard'], + display: receiver.display }; } @@ -154,12 +222,24 @@ function parseBeacon(raw, remoteAddress) { ? beacon.capabilities.filter((item) => typeof item === 'string').slice(0, 8) : [], authMode: 'none', + display: isPlainObject(beacon.display) + && Number.isInteger(beacon.display.width) + && Number.isInteger(beacon.display.height) + ? { + width: Math.max(320, Math.min(7680, beacon.display.width)), + height: Math.max(240, Math.min(4320, beacon.display.height)), + scaleFactor: Number.isFinite(beacon.display.scaleFactor) ? beacon.display.scaleFactor : 1 + } + : null, online: true, discoveredAt: Date.now() }; } module.exports = { + CONTROL_ACTIONS, + INPUT_KINDS, + MAX_CLIPBOARD_BYTES, MAX_DISCONNECT_REASON_BYTES, isPlainObject, isPrivateIPv4, diff --git a/src/main.js b/src/main.js index ce84afe..469e3be 100644 --- a/src/main.js +++ b/src/main.js @@ -5,6 +5,7 @@ const { pathToFileURL } = require('node:url'); const { app, BrowserWindow, + clipboard, desktopCapturer, ipcMain, Menu, @@ -17,8 +18,9 @@ const { } = require('electron'); const { ConfigStore } = require('./core/config-store'); const { PROTOCOL_VERSION } = require('./core/constants'); +const { ClipboardSync, MacInputController, WindowsInputController } = require('./core/input-controller'); const { DiscoveryAdvertiser, DiscoveryListener, SignalServer } = require('./core/network'); -const { isPrivateIPv4, isValidPort } = require('./core/protocol'); +const { isPrivateIPv4, isValidPort, makeSignal } = require('./core/protocol'); const { MAX_RELEASE_RESPONSE_BYTES, RELEASE_API_URL, @@ -51,6 +53,11 @@ let serviceError = null; let rendererReady = false; let pendingRendererEvents = []; let cleanupStarted = false; +let lastScreenPermission = null; +let hostInputController; +let receiverInputController; +let inputClipboard; +let receiverInputSessionId = null; function emitToRenderer(channel, payload) { if (!mainWindow || mainWindow.isDestroyed()) return; @@ -74,6 +81,66 @@ function mediaPermissionStatus() { } } +function publishScreenPermission() { + const status = mediaPermissionStatus(); + if (status !== lastScreenPermission) { + lastScreenPermission = status; + emitToRenderer('permission:screen-changed', status); + } + return status; +} + +function accessibilityPermissionStatus(prompt = false) { + if (process.platform !== 'darwin') return true; + try { + return systemPreferences.isTrustedAccessibilityClient(Boolean(prompt)); + } catch { + return false; + } +} + +function inputHelperPaths() { + if (app.isPackaged) { + return { + mac: path.join(process.resourcesPath, 'native', 'lanextend-input'), + windows: path.join(process.resourcesPath, 'native', 'lanextend-input.ps1') + }; + } + return { + mac: path.join(PROJECT_ROOT, 'native', 'macos', '.build', 'lanextend-input'), + windows: path.join(PROJECT_ROOT, 'native', 'windows', 'lanextend-input.ps1') + }; +} + +function receiverDisplayInfo() { + const primary = screen.getPrimaryDisplay(); + const scaleFactor = Number(primary.scaleFactor) || 1; + return { + // Electron reports Display.size in DIP. The DPI-aware Windows helper uses + // physical SetCursorPos coordinates, so advertise the matching pixel size. + width: Math.max(320, Math.round((primary.size?.width || primary.bounds.width || 1920) * scaleFactor)), + height: Math.max(240, Math.round((primary.size?.height || primary.bounds.height || 1080) * scaleFactor)), + scaleFactor + }; +} + +function stopClipboardSync() { + inputClipboard?.stop(); + inputClipboard = null; +} + +function startClipboardSync(send, origin, sendInitial = true) { + stopClipboardSync(); + inputClipboard = new ClipboardSync({ + readText: () => clipboard.readText(), + writeText: (text) => clipboard.writeText(text), + send, + origin + }); + inputClipboard.on('warning', (message) => emitToRenderer('input:warning', { message })); + inputClipboard.start(sendInitial); +} + async function checkReleasePageFallback(signal) { const response = await net.fetch(RELEASE_PAGE_URL, { method: 'HEAD', @@ -191,7 +258,8 @@ function createWindow() { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, - sandbox: true + sandbox: true, + backgroundThrottling: false } }); Menu.setApplicationMenu(null); @@ -199,6 +267,7 @@ function createWindow() { query: { role } }); mainWindow.once('ready-to-show', () => mainWindow.show()); + mainWindow.on('focus', () => publishScreenPermission()); mainWindow.webContents.setWindowOpenHandler(({ url }) => { if (url.startsWith('https://')) shell.openExternal(url); return { action: 'deny' }; @@ -233,6 +302,9 @@ async function startHostServices() { } async function stopReceiverServices() { + receiverInputController?.stop(); + receiverInputSessionId = null; + stopClipboardSync(); advertiser?.stop(); advertiser = null; await signalServer?.stop(); @@ -244,15 +316,33 @@ async function stopReceiverServices() { } async function startReceiverServices() { - const receiver = configStore.get().receiver; + const receiver = { + ...configStore.get().receiver, + capabilities: ['video', 'fullscreen', 'input', 'clipboard'], + display: receiverDisplayInfo() + }; try { signalServer = new SignalServer(receiver, { port: receiver.port }); signalServer.on('connected', (connection) => { if (sleepBlockerId === null) sleepBlockerId = powerSaveBlocker.start('prevent-display-sleep'); emitToRenderer('receiver:connected', connection); }); - signalServer.on('message', (event) => emitToRenderer('receiver:signal', event)); + signalServer.on('message', (event) => { + handleReceiverInputMessage(event).catch((error) => { + if (process.env.LANEXTEND_DEBUG) console.warn('[input]', error.message); + signalServer?.send(event.sessionId, makeSignal('control', { + action: 'error', message: error.message.slice(0, 512) + })); + emitToRenderer('input:receiver-status', { running: false, error: error.message }); + }); + }); signalServer.on('disconnected', (event) => { + if (receiverInputSessionId === event.sessionId) { + receiverInputController?.stop(); + receiverInputSessionId = null; + stopClipboardSync(); + emitToRenderer('input:receiver-status', { running: false, active: false }); + } if (sleepBlockerId !== null) { powerSaveBlocker.stop(sleepBlockerId); sleepBlockerId = null; @@ -295,6 +385,51 @@ async function startReceiverServices() { } } +async function handleReceiverInputMessage(event) { + const { sessionId, message } = event; + if (message.type === 'control' && message.action === 'share-start') { + await receiverInputController.start(); + receiverInputSessionId = sessionId; + const display = receiverDisplayInfo(); + signalServer.send(sessionId, makeSignal('control', { + action: 'share-ready', clipboard: Boolean(message.clipboard), screen: display + })); + if (message.clipboard) { + startClipboardSync( + (payload) => signalServer?.send(sessionId, makeSignal(payload.type, payload)), + `windows-${configStore.get().receiver.id}`, + false + ); + } else stopClipboardSync(); + emitToRenderer('input:receiver-status', { + running: true, active: false, clipboard: Boolean(message.clipboard), screen: display + }); + return; + } + if (message.type === 'control' && message.action === 'share-stop') { + receiverInputController?.stop(); + receiverInputSessionId = null; + stopClipboardSync(); + emitToRenderer('input:receiver-status', { running: false, active: false }); + return; + } + if (message.type === 'control' && (message.action === 'active' || message.action === 'inactive')) { + emitToRenderer('input:receiver-status', { + running: true, active: message.action === 'active', clipboard: Boolean(inputClipboard) + }); + return; + } + if (message.type === 'input') { + if (sessionId === receiverInputSessionId) receiverInputController?.send(message.event); + return; + } + if (message.type === 'clipboard') { + if (sessionId === receiverInputSessionId) inputClipboard?.applyRemote(message); + return; + } + emitToRenderer('receiver:signal', event); +} + function registerIpc() { ipcMain.handle('app:renderer-ready', () => { rendererReady = true; @@ -318,7 +453,7 @@ function registerIpc() { appVersion: app.getVersion(), settings: configStore.get(), devices: role === 'host' ? mergedDevices() : [], - permission: mediaPermissionStatus(), + permission: publishScreenPermission(), virtualDisplay: { ...displayStatus, capability }, serviceError, displays: screen.getAllDisplays().map((display) => ({ @@ -326,7 +461,14 @@ function registerIpc() { label: display.label, bounds: display.bounds, scaleFactor: display.scaleFactor - })) + })), + inputSharing: { + supported: role === 'host' ? hostInputController.supported : receiverInputController.supported, + accessibility: role === 'host' ? accessibilityPermissionStatus(false) : true, + status: role === 'host' + ? hostInputController.status() + : { supported: receiverInputController.supported, running: false, active: false } + } }; }); ipcMain.handle('updates:check', () => checkForUpdates()); @@ -407,6 +549,37 @@ function registerIpc() { return signalServer?.disconnect(String(sessionId), reason) || false; }); + ipcMain.handle('input:host-start', async (_event, layout, clipboardEnabled) => { + if (role !== 'host') throw new Error('只有 Mac 主端可以捕获本机键鼠'); + await hostInputController.start(layout); + if (clipboardEnabled) { + startClipboardSync( + (payload) => emitToRenderer('input:outbound', payload), + `mac-${configStore.get().receiver.id}` + ); + } else stopClipboardSync(); + return hostInputController.status(); + }); + ipcMain.handle('input:host-stop', () => { + hostInputController?.stop(); + stopClipboardSync(); + return hostInputController?.status() || { supported: false, running: false, active: false }; + }); + ipcMain.handle('input:apply-clipboard', (_event, message) => inputClipboard?.applyRemote(message) || false); + ipcMain.handle('input:get-status', () => ({ + supported: role === 'host' ? hostInputController.supported : receiverInputController.supported, + accessibility: role === 'host' ? accessibilityPermissionStatus(false) : true, + status: role === 'host' + ? hostInputController.status() + : { supported: receiverInputController.supported, running: Boolean(receiverInputSessionId) } + })); + ipcMain.handle('permission:request-accessibility', () => accessibilityPermissionStatus(true)); + ipcMain.handle('permission:open-accessibility-settings', async () => { + if (process.platform !== 'darwin') return false; + await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility'); + return true; + }); + ipcMain.handle('window:toggle-fullscreen', () => { mainWindow.setFullScreen(!mainWindow.isFullScreen()); return mainWindow.isFullScreen(); @@ -415,6 +588,7 @@ function registerIpc() { mainWindow.setFullScreen(Boolean(enabled)); return mainWindow.isFullScreen(); }); + ipcMain.handle('permission:get-screen-status', () => publishScreenPermission()); ipcMain.handle('permission:open-screen-settings', async () => { if (process.platform !== 'darwin') return false; await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'); @@ -426,6 +600,9 @@ function registerIpc() { } async function cleanup() { + hostInputController?.stop(); + receiverInputController?.stop(); + stopClipboardSync(); discoveryListener?.stop(); await stopReceiverServices(); await virtualDisplay?.destroy(); @@ -451,6 +628,18 @@ if (!allowMultipleInstances && !app.requestSingleInstanceLock()) { projectRoot: PROJECT_ROOT }) }); + const helperPaths = inputHelperPaths(); + hostInputController = new MacInputController({ executable: helperPaths.mac }); + receiverInputController = new WindowsInputController({ script: helperPaths.windows }); + hostInputController.on('outbound', (payload) => emitToRenderer('input:outbound', payload)); + hostInputController.on('status', (status) => emitToRenderer('input:host-status', status)); + hostInputController.on('warning', (message) => { + if (process.env.LANEXTEND_DEBUG) console.warn('[input-helper]', message); + }); + hostInputController.on('error', (error) => emitToRenderer('input:warning', { message: error.message })); + receiverInputController.on('warning', (message) => { + if (process.env.LANEXTEND_DEBUG) console.warn('[input-helper]', message); + }); virtualDisplay.on('stopped', () => emitToRenderer('display:changed', virtualDisplay.getStatus())); createWindow(); configureDisplayCapture(); diff --git a/src/preload.js b/src/preload.js index 85ef990..320f8c9 100644 --- a/src/preload.js +++ b/src/preload.js @@ -31,9 +31,18 @@ contextBridge.exposeInMainWorld('lanextend', Object.freeze({ disconnectReceiver: (sessionId, reason) => ipcRenderer.invoke( 'receiver:disconnect', sessionId, reason ), + startInputSharing: (layout, clipboardEnabled) => ipcRenderer.invoke( + 'input:host-start', layout, clipboardEnabled + ), + stopInputSharing: () => ipcRenderer.invoke('input:host-stop'), + getInputStatus: () => ipcRenderer.invoke('input:get-status'), + applyRemoteClipboard: (message) => ipcRenderer.invoke('input:apply-clipboard', message), toggleFullscreen: () => ipcRenderer.invoke('window:toggle-fullscreen'), setFullscreen: (enabled) => ipcRenderer.invoke('window:set-fullscreen', enabled), + getScreenPermission: () => ipcRenderer.invoke('permission:get-screen-status'), openScreenSettings: () => ipcRenderer.invoke('permission:open-screen-settings'), + requestAccessibility: () => ipcRenderer.invoke('permission:request-accessibility'), + openAccessibilitySettings: () => ipcRenderer.invoke('permission:open-accessibility-settings'), validateTarget: (host, port) => ipcRenderer.invoke('network:validate-target', host, port), onDevicesChanged: (callback) => subscribe('devices:changed', callback), onDisplayChanged: (callback) => subscribe('display:changed', callback), @@ -41,5 +50,10 @@ contextBridge.exposeInMainWorld('lanextend', Object.freeze({ onReceiverDisconnected: (callback) => subscribe('receiver:disconnected', callback), onReceiverSignal: (callback) => subscribe('receiver:signal', callback), onReceiverListening: (callback) => subscribe('receiver:listening', callback), + onScreenPermissionChanged: (callback) => subscribe('permission:screen-changed', callback), + onInputOutbound: (callback) => subscribe('input:outbound', callback), + onInputHostStatus: (callback) => subscribe('input:host-status', callback), + onInputReceiverStatus: (callback) => subscribe('input:receiver-status', callback), + onInputWarning: (callback) => subscribe('input:warning', callback), onServiceError: (callback) => subscribe('service:error', callback) })); diff --git a/src/renderer/app.js b/src/renderer/app.js index 2ae0ca9..f658e7a 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -1,8 +1,10 @@ (() => { 'use strict'; - const PROTOCOL_VERSION = 1; - const SIGNAL_TYPES = new Set(['hello', 'offer', 'answer', 'ice', 'disconnect', 'ping', 'pong']); + const PROTOCOL_VERSION = 2; + const SIGNAL_TYPES = new Set([ + 'hello', 'offer', 'answer', 'ice', 'disconnect', 'ping', 'pong', 'control', 'input', 'clipboard' + ]); const DEFAULT_SIGNAL_PORT = 47772; const api = window.lanextend; @@ -16,6 +18,7 @@ reconnectTimer: null, reconnectCountdownTimer: null, reconnectAttempt: 0, + reconnectMode: 'display', virtualDisplayRunning: false, receiverSession: null, receiverPeer: null, @@ -29,7 +32,14 @@ earlyReceiverSignals: [], listeningPort: null, updateInfo: null, - updateChecking: false + updateChecking: false, + screenPermission: 'unknown', + inputLayout: null, + inputAccessibility: false, + inputStatus: { supported: false, running: false, active: false }, + receiverInputStatus: { running: false, active: false, clipboard: false }, + pendingClipboard: null, + inputDrag: null }; const byId = (id) => document.getElementById(id); @@ -207,6 +217,24 @@ || new TextEncoder().encode(message.reason).byteLength > 120)) { throw new Error('收到的断开信令无效'); } + if (message.type === 'control') { + const actions = new Set(['share-start', 'share-ready', 'share-stop', 'active', 'inactive', 'error']); + if (!actions.has(message.action)) throw new Error('收到的键鼠控制信令无效'); + if (message.screen !== undefined + && (!message.screen || !Number.isInteger(message.screen.width) || !Number.isInteger(message.screen.height))) { + throw new Error('收到的屏幕信息无效'); + } + } + if (message.type === 'input') { + const kinds = new Set(['pointer', 'button', 'wheel', 'key', 'releaseAll']); + if (!message.event || !kinds.has(message.event.kind)) throw new Error('收到的输入事件无效'); + } + if (message.type === 'clipboard' + && (typeof message.text !== 'string' + || new TextEncoder().encode(message.text).byteLength > 128 * 1024 + || typeof message.revision !== 'string')) { + throw new Error('收到的剪贴板消息无效'); + } return message; } @@ -356,6 +384,20 @@ setSidebarStatus('error', '接收服务异常', errorText(event?.message)); } }); + api.onInputOutbound((payload) => { + const connection = state.hostConnection; + if (state.role !== 'host' || !connection || connection.intentional) return; + if (!payload?.type || !SIGNAL_TYPES.has(payload.type)) return; + sendHostSignal(connection, makeSignal(payload.type, payload)); + }); + api.onInputHostStatus((status) => { + state.inputStatus = { ...state.inputStatus, ...status }; + if (state.role === 'host') renderInputStatus(); + }); + api.onInputReceiverStatus((status) => { + if (state.role === 'receiver') renderReceiverInputStatus(status); + }); + api.onInputWarning((event) => toast('键鼠共享提示', errorText(event?.message), 'warning', 6200)); } function applyHostSettings(settings) { @@ -373,6 +415,12 @@ byId('resolution-preset').value = option ? preset : 'custom'; byId('custom-resolution').hidden = Boolean(option); state.selectedDeviceId = host.lastDeviceId || null; + const input = settings.inputSharing || {}; + byId('clipboard-sync').checked = input.clipboard !== false; + byId('input-auto-reconnect').checked = input.autoReconnect !== false; + byId('input-edge-delay').value = String(Number.isInteger(input.edgeDelayMs) ? input.edgeDelayMs : 80); + setText('input-edge-delay-output', `${byId('input-edge-delay').value} ms`); + if (!state.selectedDeviceId && input.lastDeviceId) state.selectedDeviceId = input.lastDeviceId; } function readHostOptions() { @@ -401,6 +449,166 @@ }; } + function localDisplayRects() { + return (state.bootstrap?.displays || []).map((display, index) => ({ + id: String(display.id), + label: display.label || `Mac 显示器 ${index + 1}`, + x: Math.round(display.bounds?.x || 0), + y: Math.round(display.bounds?.y || 0), + width: Math.max(1, Math.round(display.bounds?.width || 1920)), + height: Math.max(1, Math.round(display.bounds?.height || 1080)) + })); + } + + function defaultInputLayout(device) { + const locals = localDisplayRects(); + const top = Math.min(...locals.map((display) => display.y)); + const topRow = locals.filter((display) => display.y === top); + const anchor = topRow.reduce((best, display) => ( + !best || display.x + display.width > best.x + best.width ? display : best + ), null) || { x: 0, y: 0, width: 1920, height: 1080 }; + return { + deviceId: device?.id || '', + x: anchor.x + anchor.width, + y: anchor.y, + width: Math.round(device?.display?.width || 1920), + height: Math.round(device?.display?.height || 1080) + }; + } + + function ensureInputLayout(reset = false) { + const device = state.devices.find((item) => item.id === state.selectedDeviceId); + if (!device) { + state.inputLayout = null; + return null; + } + if (!reset && state.inputLayout?.deviceId === device.id) return state.inputLayout; + const saved = (state.bootstrap.settings.inputSharing?.layouts || []) + .find((layout) => layout.deviceId === device.id); + state.inputLayout = reset ? defaultInputLayout(device) : { ...(saved || defaultInputLayout(device)) }; + if (device.display) { + state.inputLayout.width = device.display.width; + state.inputLayout.height = device.display.height; + } + return state.inputLayout; + } + + function readInputOptions() { + const layout = ensureInputLayout(); + const previous = state.bootstrap.settings.inputSharing || {}; + const layouts = (previous.layouts || []).filter((item) => item.deviceId !== layout?.deviceId); + if (layout) layouts.push({ ...layout }); + return { + lastDeviceId: state.selectedDeviceId, + clipboard: byId('clipboard-sync').checked, + autoReconnect: byId('input-auto-reconnect').checked, + edgeDelayMs: Number(byId('input-edge-delay').value) || 0, + layouts + }; + } + + async function persistInputOptions() { + if (!state.bootstrap || !state.inputLayout) return; + state.bootstrap.settings = await api.updateSettings({ inputSharing: readInputOptions() }); + } + + function snapInputLayout(layout, threshold) { + const remote = { ...layout }; + let best = { distance: threshold, x: remote.x, y: remote.y }; + for (const local of localDisplayRects()) { + const candidates = [ + { distance: Math.abs(remote.x - (local.x + local.width)), x: local.x + local.width, y: remote.y }, + { distance: Math.abs((remote.x + remote.width) - local.x), x: local.x - remote.width, y: remote.y }, + { distance: Math.abs(remote.y - (local.y + local.height)), x: remote.x, y: local.y + local.height }, + { distance: Math.abs((remote.y + remote.height) - local.y), x: remote.x, y: local.y - remote.height } + ]; + for (const candidate of candidates) if (candidate.distance < best.distance) best = candidate; + } + return { ...remote, x: Math.round(best.x), y: Math.round(best.y) }; + } + + function renderInputLayout() { + const container = byId('layout-nodes'); + const canvas = byId('input-layout-canvas'); + if (!container || !canvas) return; + container.replaceChildren(); + const layout = ensureInputLayout(); + const locals = localDisplayRects(); + const device = state.devices.find((item) => item.id === state.selectedDeviceId); + setText('input-device-name', device?.name || '请先选择 Windows 子端'); + setText('input-device-resolution', layout ? `${layout.width} × ${layout.height}` : '—'); + if (!locals.length) return; + const rectangles = layout ? [...locals, layout] : locals; + const left = Math.min(...rectangles.map((rect) => rect.x)); + const top = Math.min(...rectangles.map((rect) => rect.y)); + const right = Math.max(...rectangles.map((rect) => rect.x + rect.width)); + const bottom = Math.max(...rectangles.map((rect) => rect.y + rect.height)); + const canvasWidth = Math.max(320, canvas.clientWidth || 640); + const canvasHeight = Math.max(220, canvas.clientHeight || 330); + const padding = 24; + const scale = Math.min((canvasWidth - padding * 2) / Math.max(1, right - left), (canvasHeight - padding * 2) / Math.max(1, bottom - top)); + const offsetX = (canvasWidth - (right - left) * scale) / 2 - left * scale; + const offsetY = (canvasHeight - (bottom - top) * scale) / 2 - top * scale; + const createNode = (rect, remote, index) => { + const node = document.createElement('div'); + node.className = `layout-node${remote ? ' is-remote' : ''}${state.hostConnection ? ' is-disabled' : ''}`; + node.style.left = `${offsetX + rect.x * scale}px`; + node.style.top = `${offsetY + rect.y * scale}px`; + node.style.width = `${Math.max(58, rect.width * scale)}px`; + node.style.height = `${Math.max(40, rect.height * scale)}px`; + const copy = document.createElement('span'); + const title = document.createElement('strong'); + title.textContent = remote ? (device?.name || 'Windows') : `Mac · ${index + 1}`; + const dimensions = document.createElement('small'); + dimensions.textContent = `${rect.width} × ${rect.height}`; + copy.append(title, dimensions); + node.append(copy); + if (remote) { + node.dataset.remote = 'true'; + node.addEventListener('pointerdown', (event) => beginInputLayoutDrag(event, scale)); + } + container.append(node); + }; + locals.forEach((rect, index) => createNode(rect, false, index)); + if (layout) createNode(layout, true, locals.length); + } + + function beginInputLayoutDrag(event, scale) { + if (state.hostConnection || !state.inputLayout) return; + try { event.currentTarget.setPointerCapture(event.pointerId); } catch { /* Window-level listeners keep the drag alive. */ } + event.currentTarget.classList.add('is-dragging'); + state.inputDrag = { + pointerId: event.pointerId, + node: event.currentTarget, + startX: event.clientX, + startY: event.clientY, + layout: { ...state.inputLayout }, + scale + }; + } + + function moveInputLayoutDrag(event) { + const drag = state.inputDrag; + if (!drag || event.pointerId !== drag.pointerId) return; + const candidate = { + ...drag.layout, + x: drag.layout.x + (event.clientX - drag.startX) / drag.scale, + y: drag.layout.y + (event.clientY - drag.startY) / drag.scale + }; + state.inputLayout = snapInputLayout(candidate, 85 / drag.scale); + renderInputLayout(); + const replacement = byId('layout-nodes').querySelector('[data-remote="true"]'); + replacement?.classList.add('is-dragging'); + state.inputDrag.node = replacement; + } + + function endInputLayoutDrag(event) { + if (!state.inputDrag || event.pointerId !== state.inputDrag.pointerId) return; + state.inputDrag.node?.classList.remove('is-dragging'); + state.inputDrag = null; + persistInputOptions().catch((error) => toast('无法保存设备布局', errorText(error), 'error')); + } + function updateBitrateRange() { const range = byId('display-bitrate'); const value = Number(range.value); @@ -433,7 +641,106 @@ byId('capture-source').addEventListener('change', () => updateConnectAvailability()); byId('connect-button').addEventListener('click', () => beginHostConnection(false)); byId('disconnect-button').addEventListener('click', () => disconnectHost('由主端断开')); - byId('open-screen-permission').addEventListener('click', () => api.openScreenSettings()); + byId('input-connect-button').addEventListener('click', () => beginHostConnection(false, 'input')); + byId('input-disconnect-button').addEventListener('click', () => disconnectHost('由主端停止键鼠共享')); + byId('reset-input-layout').addEventListener('click', () => { + ensureInputLayout(true); + renderInputLayout(); + persistInputOptions().catch((error) => toast('无法保存设备布局', errorText(error), 'error')); + }); + byId('input-edge-delay').addEventListener('input', () => { + setText('input-edge-delay-output', `${byId('input-edge-delay').value} ms`); + }); + byId('input-edge-delay').addEventListener('change', () => persistInputOptions().catch(() => {})); + byId('clipboard-sync').addEventListener('change', () => persistInputOptions().catch(() => {})); + byId('input-auto-reconnect').addEventListener('change', () => persistInputOptions().catch(() => {})); + window.addEventListener('pointermove', moveInputLayoutDrag); + window.addEventListener('pointerup', endInputLayoutDrag); + window.addEventListener('pointercancel', endInputLayoutDrag); + window.addEventListener('resize', () => renderInputLayout()); + byId('request-accessibility').addEventListener('click', async () => { + const granted = await api.requestAccessibility(); + if (!granted) { + await api.openAccessibilitySettings(); + toast('请开启辅助功能权限', '在列表中允许 LanExtend,然后返回并再次启动键鼠共享。', 'warning', 7200); + } + await refreshInputStatus(); + }); + byId('open-screen-permission').addEventListener('click', async () => { + await api.openScreenSettings(); + window.setTimeout(() => refreshScreenPermission(), 800); + }); + } + + async function refreshInputStatus() { + if (state.role !== 'host') return state.inputStatus; + try { + const result = await api.getInputStatus(); + state.inputAccessibility = Boolean(result?.accessibility); + state.inputStatus = { ...state.inputStatus, ...(result?.status || {}), supported: Boolean(result?.supported) }; + } catch { + state.inputAccessibility = false; + } + renderInputStatus(); + return state.inputStatus; + } + + function renderInputStatus() { + const badge = byId('input-sharing-badge'); + const live = byId('input-live-status'); + const button = byId('input-connect-button'); + if (!badge || !live || !button) return; + byId('accessibility-banner').hidden = state.inputAccessibility || !state.inputStatus.supported; + live.className = `input-live-status${state.inputStatus.active ? ' is-active' : state.inputStatus.running ? ' is-ready' : ''}`; + if (!state.inputStatus.supported) { + badge.textContent = '组件不可用'; + badge.className = 'capability-badge is-error'; + live.querySelector('strong').textContent = '键鼠助手不可用'; + live.querySelector('small').textContent = '请重新构建或安装完整版本'; + } else if (state.inputStatus.active) { + badge.textContent = '正在控制 Windows'; + badge.className = 'capability-badge is-success'; + live.querySelector('strong').textContent = '控制权在 Windows'; + live.querySelector('small').textContent = '将鼠标移回相邻边缘即可返回 Mac'; + } else if (state.inputStatus.running) { + badge.textContent = '共享运行中'; + badge.className = 'capability-badge is-success'; + live.querySelector('strong').textContent = '等待跨越屏幕边缘'; + live.querySelector('small').textContent = '当前键盘和鼠标仍由 Mac 控制'; + } else { + badge.textContent = state.inputAccessibility ? '可以启动' : '需要系统权限'; + badge.className = 'capability-badge'; + live.querySelector('strong').textContent = '尚未启动'; + live.querySelector('small').textContent = '鼠标仍由 Mac 控制'; + } + const hasDevice = Boolean(state.devices.find((item) => item.id === state.selectedDeviceId)); + button.disabled = Boolean(state.hostConnection || !hasDevice || !state.inputStatus.supported); + button.textContent = hasDevice ? '启动键鼠共享' : '请先选择 Windows 子端'; + renderInputLayout(); + } + + function renderScreenPermission(status) { + state.screenPermission = status || 'unknown'; + const granted = state.screenPermission === 'granted'; + const banner = byId('permission-banner'); + if (banner) banner.hidden = granted; + setText( + 'permission-message', + state.screenPermission === 'denied' + ? '系统当前未授权这个 LanExtend 副本,因此扩展屏不可用;键鼠共享不受影响。' + : '扩展屏功能需要此权限;键鼠共享不受影响。授权后返回此窗口会自动重新检测。' + ); + updateConnectAvailability(); + return granted; + } + + async function refreshScreenPermission() { + if (state.role !== 'host') return false; + try { + return renderScreenPermission(await api.getScreenPermission()); + } catch { + return renderScreenPermission('unknown'); + } } function renderCaptureMode() { @@ -524,11 +831,14 @@ list.append(card); } updateConnectAvailability(); + renderInputLayout(); + renderInputStatus(); } function selectDevice(id) { if (state.hostConnection) return; state.selectedDeviceId = id; + state.inputLayout = null; renderDevices(); setConnectionSteps('display'); const device = state.devices.find((item) => item.id === id); @@ -536,6 +846,8 @@ setText('connect-button-label', `扩展到 ${device.name}`); setText('connect-hint', `${formatAddress(device.host, device.port)} · 主端主动连接`); } + renderInputLayout(); + renderInputStatus(); } async function forgetDevice(id) { @@ -637,7 +949,8 @@ if (!button) return; const mode = document.querySelector('input[name="captureMode"]:checked')?.value || 'virtual'; const sourceReady = mode === 'virtual' || Boolean(byId('capture-source').value); - button.disabled = Boolean(state.hostConnection || state.reconnectTimer || !state.selectedDeviceId || !sourceReady); + const permissionReady = state.role !== 'host' || state.screenPermission === 'granted'; + button.disabled = Boolean(state.hostConnection || state.reconnectTimer || !state.selectedDeviceId || !sourceReady || !permissionReady); if (!state.selectedDeviceId) setText('connect-button-label', '选择设备后开始扩展'); else if (!state.hostConnection) { const device = state.devices.find((item) => item.id === state.selectedDeviceId); @@ -793,8 +1106,30 @@ return transceiver.sender; } - async function beginHostConnection(isReconnect) { + async function beginHostConnection(isReconnect, requestedMode = 'display') { if (state.hostConnection) return; + const mode = requestedMode === 'input' ? 'input' : 'display'; + if (mode === 'display' && !(await refreshScreenPermission())) { + toast('需要屏幕录制权限', '请在系统设置中授权当前安装在“应用程序”目录的 LanExtend,然后返回此窗口。', 'warning', 7200); + return; + } + if (mode === 'input') { + await refreshInputStatus(); + if (!state.inputStatus.supported) { + toast('键鼠共享组件不可用', '请安装包含原生输入助手的完整版本。', 'error', 7200); + return; + } + if (!state.inputAccessibility) { + const granted = await api.requestAccessibility(); + state.inputAccessibility = Boolean(granted); + renderInputStatus(); + if (!granted) { + await api.openAccessibilitySettings(); + toast('需要辅助功能权限', '开启 LanExtend 后返回此窗口,再次点击“启动键鼠共享”。', 'warning', 7200); + return; + } + } + } clearTimeout(state.reconnectTimer); state.reconnectTimer = null; const device = state.devices.find((item) => item.id === state.selectedDeviceId); @@ -804,10 +1139,11 @@ return; } - const options = readHostOptions(); + const options = mode === 'input' ? readInputOptions() : readHostOptions(); const connection = { id: `${Date.now()}-${Math.random()}`, device: { ...device }, + mode, options, stream: null, source: null, @@ -828,12 +1164,17 @@ lastPingRtt: null }; state.hostConnection = connection; - renderHostConnecting(device, isReconnect ? `正在第 ${state.reconnectAttempt} 次重连` : '正在准备扩展屏'); + state.reconnectMode = mode; + renderInputStatus(); + if (mode === 'input') renderInputConnecting(device, isReconnect); + else renderHostConnecting(device, isReconnect ? `正在第 ${state.reconnectAttempt} 次重连` : '正在准备扩展屏'); try { const target = await api.validateTarget(device.host, Number(device.port)); if (!target?.valid) throw new Error('子端不是有效的私有局域网地址'); - state.bootstrap.settings = await api.updateSettings({ host: options }); + state.bootstrap.settings = await api.updateSettings( + mode === 'input' ? { inputSharing: options } : { host: options } + ); await api.rememberDevice(device, false); if (state.hostConnection !== connection || connection.intentional) { cleanupHostConnection(connection); @@ -846,8 +1187,8 @@ cleanupHostConnection(connection); const message = errorText(error); renderHostIdle(); - toast(isReconnect ? '自动重连未成功' : '无法开始扩展', message, 'error', 7200); - if (isReconnect && options.autoReconnect) scheduleReconnect(message); + toast(isReconnect ? '自动重连未成功' : mode === 'input' ? '无法启动键鼠共享' : '无法开始扩展', message, 'error', 7200); + if (isReconnect && options.autoReconnect) scheduleReconnect(message, mode); else if (connection.virtualOwned) api.destroyVirtualDisplay().catch(() => {}); } } @@ -879,20 +1220,25 @@ } function openHostSocket(connection) { - setConnectionSteps('display'); - setHostActivity('正在连接 Windows 子端', formatAddress(connection.device.host, connection.device.port)); + if (connection.mode === 'display') setConnectionSteps('display'); + if (connection.mode === 'display') setHostActivity('正在连接 Windows 子端', formatAddress(connection.device.host, connection.device.port)); + else setInputActivity('正在连接 Windows 子端', formatAddress(connection.device.host, connection.device.port)); const socket = new WebSocket(`ws://${connection.device.host}:${connection.device.port}`); connection.ws = socket; socket.addEventListener('open', () => { if (state.hostConnection !== connection) return; - setHostActivity('子端已响应', '正在协商扩展画面'); + if (connection.mode === 'display') setHostActivity('子端已响应', '正在协商扩展画面'); + else setInputActivity('子端已响应', '正在启动键鼠接收器'); connection.welcomeTimer = window.setTimeout(() => handleHostLoss(connection, '子端握手超时'), 10_000); }); socket.addEventListener('message', (event) => { handleHostSocketMessage(connection, event.data).catch((error) => handleHostLoss(connection, errorText(error))); }); socket.addEventListener('error', () => { - if (state.hostConnection === connection && !connection.intentional) setHostActivity('无法连接子端', '请检查子端、防火墙与局域网'); + if (state.hostConnection === connection && !connection.intentional) { + if (connection.mode === 'display') setHostActivity('无法连接子端', '请检查子端、防火墙与局域网'); + else setInputActivity('无法连接子端', '请检查子端、防火墙与局域网'); + } }); socket.addEventListener('close', (event) => { if (state.hostConnection !== connection || connection.intentional) return; @@ -917,10 +1263,52 @@ startHostHeartbeat(connection); if (!connection.offered) { connection.offered = true; - await prepareHostMediaAndOffer(connection); + if (connection.mode === 'input') { + const layout = ensureInputLayout(); + sendHostSignal(connection, makeSignal('control', { + action: 'share-start', + clipboard: connection.options.clipboard, + screen: { width: layout.width, height: layout.height } + })); + clearTimeout(connection.negotiationTimer); + connection.negotiationTimer = window.setTimeout(() => { + if (state.hostConnection === connection && !connection.connected) { + handleHostLoss(connection, 'Windows 键鼠接收器启动超时'); + } + }, 15_000); + } else await prepareHostMediaAndOffer(connection); + } + return; + } + if (message.type === 'control') { + if (message.action === 'share-ready' && connection.mode === 'input') { + const layout = ensureInputLayout(); + if (message.screen) { + layout.width = message.screen.width; + layout.height = message.screen.height; + } + await persistInputOptions(); + await api.startInputSharing({ + locals: localDisplayRects(), + remote: { ...layout }, + edgeDelayMs: connection.options.edgeDelayMs + }, connection.options.clipboard); + if (state.pendingClipboard) { + await api.applyRemoteClipboard(state.pendingClipboard); + state.pendingClipboard = null; + } + markInputConnected(connection); + return; } + if (message.action === 'error') throw new Error(message.message || 'Windows 无法启动键鼠接收器'); + return; + } + if (message.type === 'clipboard') { + const applied = await api.applyRemoteClipboard(message); + if (!applied) state.pendingClipboard = message; return; } + if (message.type === 'input') return; if (message.type === 'answer') { await connection.pc.setRemoteDescription(message.sdp); for (const candidate of connection.pendingIce.splice(0)) await connection.pc.addIceCandidate(candidate); @@ -1009,11 +1397,20 @@ id: String(receiver.id).slice(0, 128), name: String(receiver.name).slice(0, 64), port: Number(receiver.port) || connection.device.port, + capabilities: Array.isArray(receiver.capabilities) ? receiver.capabilities : connection.device.capabilities, + display: receiver.display || connection.device.display || null, online: true, lastSeen: Date.now() }; connection.device = actual; connection.options.lastDeviceId = actual.id; + if (connection.mode === 'input' && state.inputLayout) { + state.inputLayout.deviceId = actual.id; + if (actual.display) { + state.inputLayout.width = actual.display.width; + state.inputLayout.height = actual.display.height; + } + } state.selectedDeviceId = actual.id; await api.rememberDevice(actual, true); state.bootstrap.settings.host.lastDeviceId = actual.id; @@ -1067,6 +1464,20 @@ toast('扩展屏已连接', `${connection.device.name} 正在显示 Mac 扩展画面`); } + function markInputConnected(connection) { + if (connection.connected || state.hostConnection !== connection) return; + connection.connected = true; + clearTimeout(connection.negotiationTimer); + state.reconnectAttempt = 0; + state.inputStatus = { ...state.inputStatus, running: true, active: false }; + byId('input-connect-button').hidden = true; + byId('input-disconnect-button').hidden = false; + byId('reset-input-layout').disabled = true; + setSidebarStatus('online', '键鼠共享已启动', connection.device.name); + renderInputStatus(); + toast('键鼠共享已启动', `将鼠标移向布局中与 ${connection.device.name} 相邻的边缘即可切换`); + } + function startHostStats(connection) { clearInterval(connection.statsTimer); const update = async () => { @@ -1108,6 +1519,21 @@ setText('connect-hint', detail); } + function setInputActivity(title, detail) { + const live = byId('input-live-status'); + if (live) { + live.className = 'input-live-status is-ready'; + live.querySelector('strong').textContent = title; + live.querySelector('small').textContent = detail; + } + const badge = byId('input-sharing-badge'); + if (badge) { + badge.textContent = '正在连接'; + badge.className = 'capability-badge'; + } + setSidebarStatus('busy', title, detail); + } + function renderHostConnecting(device, detail) { byId('connect-button').disabled = true; byId('connect-button').hidden = true; @@ -1119,6 +1545,26 @@ setText('stat-device', device.name); } + function renderInputConnecting(device, isReconnect) { + byId('input-connect-button').hidden = true; + byId('input-disconnect-button').hidden = false; + byId('input-disconnect-button').textContent = '取消连接'; + byId('reset-input-layout').disabled = true; + setInputActivity( + isReconnect ? `正在第 ${state.reconnectAttempt} 次重连` : `正在连接 ${device.name}`, + '正在准备键鼠与剪贴板通道' + ); + } + + function renderInputIdle() { + byId('input-connect-button').hidden = false; + byId('input-disconnect-button').hidden = true; + byId('input-disconnect-button').textContent = '停止键鼠共享'; + byId('reset-input-layout').disabled = false; + state.inputStatus = { ...state.inputStatus, running: false, active: false }; + renderInputStatus(); + } + function renderHostIdle() { byId('connect-button').hidden = false; byId('disconnect-button').hidden = true; @@ -1131,6 +1577,7 @@ setSidebarStatus('online', '正在发现子端', '局域网服务运行中'); setConnectionSteps(state.selectedDeviceId ? 'display' : 'device'); updateConnectAvailability(); + renderInputIdle(); } function cleanupHostConnection(connection) { @@ -1153,24 +1600,29 @@ try { connection.pc.close(); } catch { /* Already closed. */ } } connection.stream?.getTracks().forEach((track) => track.stop()); + if (connection.mode === 'input') { + state.pendingClipboard = null; + api.stopInputSharing().catch(() => {}); + } } function handleHostLoss(connection, reason, reconnectAllowed = true) { if (state.hostConnection !== connection || connection.failed || connection.intentional) return; connection.failed = true; - const reconnect = reconnectAllowed - && connection.options.autoReconnect - && byId('auto-reconnect').checked; + const reconnectToggle = connection.mode === 'input' ? byId('input-auto-reconnect') : byId('auto-reconnect'); + const reconnect = reconnectAllowed && connection.options.autoReconnect && reconnectToggle.checked; state.hostConnection = null; cleanupHostConnection(connection); renderHostIdle(); - toast('扩展屏连接已中断', reason, reconnect ? 'warning' : 'error', 6000); - if (reconnect) scheduleReconnect(reason); + toast(connection.mode === 'input' ? '键鼠共享已中断' : '扩展屏连接已中断', reason, reconnect ? 'warning' : 'error', 6000); + if (reconnect) scheduleReconnect(reason, connection.mode); else if (connection.virtualOwned) api.destroyVirtualDisplay().catch(() => {}); } - function scheduleReconnect(reason) { - if (state.reconnectTimer || state.hostConnection || !byId('auto-reconnect').checked) return; + function scheduleReconnect(reason, mode = state.reconnectMode) { + const reconnectToggle = mode === 'input' ? byId('input-auto-reconnect') : byId('auto-reconnect'); + if (state.reconnectTimer || state.hostConnection || !reconnectToggle.checked) return; + state.reconnectMode = mode; state.reconnectAttempt += 1; const delay = Math.min(12_000, 1600 * (2 ** Math.min(state.reconnectAttempt - 1, 3))); let seconds = Math.ceil(delay / 1000); @@ -1178,6 +1630,13 @@ byId('connect-button').hidden = true; byId('disconnect-button').hidden = false; setText('disconnect-button', '取消自动重连'); + if (mode === 'input') { + byId('connect-button').hidden = false; + byId('disconnect-button').hidden = true; + byId('input-connect-button').hidden = true; + byId('input-disconnect-button').hidden = false; + setText('input-disconnect-button', '取消自动重连'); + } clearInterval(state.reconnectCountdownTimer); state.reconnectCountdownTimer = window.setInterval(() => { seconds -= 1; @@ -1187,7 +1646,7 @@ window.clearInterval(state.reconnectCountdownTimer); state.reconnectCountdownTimer = null; state.reconnectTimer = null; - beginHostConnection(true); + beginHostConnection(true, mode); }, delay); } @@ -1199,6 +1658,7 @@ state.reconnectCountdownTimer = null; state.reconnectAttempt = 0; const connection = state.hostConnection; + const mode = connection?.mode || state.reconnectMode; state.hostConnection = null; if (connection) { connection.intentional = true; @@ -1207,10 +1667,14 @@ } cleanupHostConnection(connection); } - if (state.virtualDisplayRunning || connection?.virtualOwned) api.destroyVirtualDisplay().catch(() => {}); + if (mode === 'input') api.stopInputSharing().catch(() => {}); + if (mode === 'display' && (state.virtualDisplayRunning || connection?.virtualOwned)) api.destroyVirtualDisplay().catch(() => {}); renderHostIdle(); refreshDevices(); - toast(wasReconnecting || !connection?.connected ? '连接已取消' : '扩展屏已断开', wasReconnecting ? '已停止自动重连' : '子端已恢复等待状态'); + toast( + wasReconnecting || !connection?.connected ? '连接已取消' : mode === 'input' ? '键鼠共享已停止' : '扩展屏已断开', + wasReconnecting ? '已停止自动重连' : '子端已恢复等待状态' + ); } function applyReceiverSettings(settings) { @@ -1285,6 +1749,35 @@ } } + function renderReceiverInputStatus(status = {}) { + state.receiverInputStatus = { ...state.receiverInputStatus, ...status }; + const current = state.receiverInputStatus; + const badge = byId('receiver-input-badge'); + if (!badge) return; + if (current.active) { + badge.textContent = '正在控制'; + badge.className = 'capability-badge is-success'; + setText('receiver-input-state', 'Mac 正在控制此设备'); + setSidebarStatus('online', '键鼠控制中', state.receiverHost?.name || 'Mac 主端'); + setText('receiver-state-kicker', '键鼠共享'); + setText('receiver-state-title', '当前由 Mac 键盘和鼠标控制'); + setText('receiver-state-description', '鼠标移回相邻屏幕边缘后,控制权会自动返回 Mac。'); + } else if (current.running) { + badge.textContent = '共享已就绪'; + badge.className = 'capability-badge is-success'; + setText('receiver-input-state', '等待鼠标进入'); + setSidebarStatus('online', '键鼠共享已就绪', state.receiverHost?.name || 'Mac 主端'); + setText('receiver-state-kicker', '键鼠共享已就绪'); + setText('receiver-state-title', '等待 Mac 鼠标跨越屏幕边缘'); + setText('receiver-state-description', '当前不传输画面;两台设备继续显示各自的本地内容。'); + } else { + badge.textContent = status.error ? '启动失败' : '待机'; + badge.className = status.error ? 'capability-badge is-error' : 'capability-badge'; + setText('receiver-input-state', status.error || '等待 Mac'); + } + setText('receiver-clipboard-state', current.clipboard ? '双向同步中' : '未启动'); + } + async function saveReceiverSettings(event) { event.preventDefault(); const button = byId('save-receiver-settings'); @@ -1364,7 +1857,6 @@ state.receiverHost = { ...state.receiverHost, name: String(message.name || 'Mac 主端').slice(0, 64) }; setText('remote-host-name', state.receiverHost.name); setText('receiver-host-summary', state.receiverHost.name); - ensureReceiverPeer(); return; } if (message.type === 'offer') { @@ -1515,6 +2007,7 @@ if (state.receiverSession && event?.sessionId && state.receiverSession.id !== event.sessionId) return; const reason = event?.reason || (event?.code === 1000 ? '主端已正常断开' : '主端连接已结束'); cleanupReceiverPeer(true); + renderReceiverInputStatus({ running: false, active: false, clipboard: false }); if (state.receiverFullscreen) setReceiverFullscreen(false); renderReceiverIdle(reason); } @@ -1530,6 +2023,7 @@ setText('receiver-bitrate', '— Mbps'); setText('receiver-latency', '— ms'); state.receiverHost = null; + renderReceiverInputStatus({ running: false, active: false, clipboard: false }); renderListeningStatus(); } @@ -1543,8 +2037,14 @@ state.role = bootstrap.role; state.devices = Array.isArray(bootstrap.devices) ? bootstrap.devices : []; state.virtualDisplayRunning = Boolean(bootstrap.virtualDisplay?.running); + state.inputAccessibility = Boolean(bootstrap.inputSharing?.accessibility); + state.inputStatus = { + ...state.inputStatus, + ...(bootstrap.inputSharing?.status || {}), + supported: Boolean(bootstrap.inputSharing?.supported) + }; setRoleVisibility(state.role); - setText('app-version', `局域网扩展屏 · ${bootstrap.appVersion || '0.2.0'}`); + setText('app-version', `扩展屏与键鼠共享 · ${bootstrap.appVersion || '0.3.0'}`); setText('protocol-chip', `协议 v${bootstrap.protocolVersion}`); bindUpdateUi(); @@ -1558,21 +2058,35 @@ const fallbackMode = document.querySelector('input[name="captureMode"][value="existing"]'); if (fallbackMode) fallbackMode.checked = true; } - const permissionGranted = bootstrap.permission === 'granted'; - byId('permission-banner').hidden = permissionGranted; + renderScreenPermission(bootstrap.permission); renderDevices(); renderCaptureMode(); renderHostIdle(); + renderInputLayout(); + renderInputStatus(); } else { bindReceiverUi(); applyReceiverSettings(bootstrap.settings); renderReceiverIdle(); + renderReceiverInputStatus(); } await api.rendererReady(); if (state.role === 'host') { + api.onScreenPermissionChanged((status) => renderScreenPermission(status)); + window.addEventListener('focus', () => { + refreshScreenPermission(); + refreshInputStatus(); + }); + document.addEventListener('visibilitychange', () => { + if (!document.hidden) { + refreshScreenPermission(); + refreshInputStatus(); + } + }); await refreshSources(false); + await refreshInputStatus(); } else { if (state.earlyReceiverConnection) { onReceiverConnected(state.earlyReceiverConnection); diff --git a/src/renderer/index.html b/src/renderer/index.html index 7774914..9a5dfa6 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -52,6 +52,10 @@ 画面设置 + @@ -97,7 +105,7 @@

扩展工作台

- 协议 v1 + 协议 v2 @@ -112,7 +120,7 @@

扩展工作台

!
需要屏幕录制权限 -

macOS 需要授权 LanExtend 捕获扩展屏。授权后请重新启动应用。

+

扩展屏功能需要此权限;键鼠共享不受影响。授权后返回此窗口会自动重新检测。

@@ -265,6 +273,88 @@

配置扩展画面

子端无需确认,主端拥有最高连接权限。

+ +
+
+
+ 无缝跨设备控制 +

键鼠与剪贴板共享

+
+ 等待配置 +
+ + + +
+
+
+
+ 设备布局 + 拖动橙色 Windows 屏幕,让它贴到任意 Mac 屏幕边缘 +
+ +
+
+ +
+
+
+ 本机 Mac 显示器 + Windows 设备 + ⌃⌥⌘Esc 可立即返回 Mac +
+
+ +
+
+ W +
+ 目标设备 + 请先选择 Windows 子端 + +
+
+ +
+ + +
+ +
+
+ + 80 ms +
+ +

数值越低切换越快;提高它可以减少误触屏幕边缘。

+
+ +
+ +
尚未启动鼠标仍由 Mac 控制
+
+ + + +
+
+
+ +
+
+
+ 跨设备控制 +

键鼠与剪贴板

+
+ 待机 +
+
+ M + + W +
+
+
键鼠控制等待 Mac
+
剪贴板同步未启动
+
切换方式移动到屏幕边缘
+
+
diff --git a/src/renderer/styles.css b/src/renderer/styles.css index dc814ee..a15486f 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -292,6 +292,16 @@ select:focus-visible { content: ""; } +.icon-pointer::before { + position: absolute; + inset: 1px 4px 2px 3px; + border: 1.5px solid currentColor; + background: linear-gradient(135deg, currentColor 0 45%, transparent 46%); + clip-path: polygon(0 0, 100% 63%, 61% 70%, 43% 100%); + content: ""; + transform: rotate(-9deg); +} + .icon-sliders::before { position: absolute; inset: 2px 0; @@ -855,6 +865,149 @@ select:focus-visible { .capability-badge.is-error { border-color: rgba(255, 113, 136, 0.16); background: rgba(255, 113, 136, 0.06); color: #df8c99; } +.input-sharing-panel { + margin-top: 15px; + padding: 22px; +} + +.inline-permission { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin: -4px 0 16px; + padding: 11px 12px; + border: 1px solid rgba(255, 191, 105, 0.18); + border-radius: 12px; + background: rgba(255, 191, 105, 0.055); +} + +.inline-permission > div { display: grid; gap: 3px; } +.inline-permission strong { color: #e9c99f; font-size: 11px; } +.inline-permission span { color: #8f7d68; font-size: 9px; } + +.input-sharing-grid { + display: grid; + grid-template-columns: minmax(430px, 1.45fr) minmax(260px, 0.72fr); + gap: 18px; +} + +.layout-editor-wrap { + min-width: 0; + padding: 14px; + border: 1px solid rgba(147, 177, 212, 0.11); + border-radius: 15px; + background: rgba(3, 10, 19, 0.28); +} + +.layout-editor-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.layout-editor-head > div { display: grid; gap: 3px; } +.layout-editor-head strong { color: #cbd9e6; font-size: 11px; } +.layout-editor-head span { color: #64788d; font-size: 9px; } + +.layout-canvas { + position: relative; + height: 330px; + overflow: hidden; + border: 1px solid rgba(147, 177, 212, 0.13); + border-radius: 13px; + background: linear-gradient(145deg, rgba(11, 24, 40, 0.95), rgba(6, 15, 27, 0.95)); + user-select: none; + touch-action: none; +} + +.layout-grid-lines { + position: absolute; + inset: 0; + opacity: 0.24; + background-image: + linear-gradient(rgba(118, 153, 188, 0.12) 1px, transparent 1px), + linear-gradient(90deg, rgba(118, 153, 188, 0.12) 1px, transparent 1px); + background-size: 24px 24px; + pointer-events: none; +} + +.layout-nodes { position: absolute; inset: 0; } + +.layout-node { + position: absolute; + display: grid; + place-items: center; + min-width: 58px; + min-height: 40px; + overflow: hidden; + border: 1px solid rgba(120, 182, 255, 0.65); + border-radius: 8px; + background: linear-gradient(145deg, #306db8, #24518f); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.24), inset 0 1px rgba(255, 255, 255, 0.1); + color: #f2f8ff; + text-align: center; +} + +.layout-node > span { display: grid; gap: 2px; max-width: 92%; pointer-events: none; } +.layout-node strong { overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +.layout-node small { color: rgba(228, 241, 255, 0.64); font-size: 7px; } +.layout-node.is-remote { + border-color: rgba(255, 188, 122, 0.78); + background: linear-gradient(145deg, #d48446, #a85c2e); + cursor: grab; +} +.layout-node.is-remote:active { cursor: grabbing; } +.layout-node.is-remote.is-dragging { z-index: 4; box-shadow: 0 0 0 2px rgba(255, 188, 122, 0.3), 0 15px 34px rgba(0, 0, 0, 0.35); } +.layout-node.is-disabled { opacity: 0.45; cursor: not-allowed; } + +.layout-legend { + display: flex; + flex-wrap: wrap; + gap: 14px; + margin-top: 10px; + color: #667b90; + font-size: 8px; +} +.layout-legend span { display: inline-flex; align-items: center; gap: 5px; } +.layout-legend span:last-child { margin-left: auto; color: #778ca2; } +.layout-legend i { width: 8px; height: 8px; border-radius: 2px; background: #326eb8; } +.layout-legend i.is-windows { background: #c9773d; } + +.input-sharing-controls { display: flex; flex-direction: column; gap: 13px; min-width: 0; } +.input-device-summary { display: flex; align-items: center; gap: 11px; padding: 12px; border: 1px solid var(--line); border-radius: 13px; background: rgba(255, 255, 255, 0.02); } +.input-device-summary > div { display: grid; gap: 2px; min-width: 0; } +.input-device-summary small { color: #61758a; font-size: 8px; } +.input-device-summary strong { overflow: hidden; color: #c8d7e5; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.input-device-summary span:not(.input-device-icon) { color: #778b9f; font-size: 9px; } +.input-device-icon { display: grid; place-items: center; flex: 0 0 auto; width: 37px; height: 37px; border: 1px solid rgba(91, 185, 255, 0.25); border-radius: 10px; background: rgba(56, 116, 178, 0.16); color: #8bc9f5; font-size: 11px; font-weight: 750; } +.input-device-icon.is-windows { border-color: rgba(255, 184, 116, 0.28); background: rgba(198, 107, 52, 0.14); color: #efa972; } +.edge-delay-field { padding: 0 2px; } + +.input-live-status { + display: flex; + align-items: center; + gap: 10px; + min-height: 51px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.02); +} +.input-live-status > div { display: grid; gap: 2px; } +.input-live-status strong { color: #aebfce; font-size: 10px; } +.input-live-status small { color: #64798d; font-size: 8px; } +.input-live-status.is-ready .status-light { background: var(--blue); box-shadow: 0 0 9px rgba(91, 185, 255, 0.55); } +.input-live-status.is-active .status-light { background: var(--accent); box-shadow: 0 0 11px rgba(94, 231, 197, 0.7); } +.input-live-status.is-active { border-color: rgba(94, 231, 197, 0.17); background: rgba(94, 231, 197, 0.04); } + +.receiver-input-visual { display: flex; align-items: center; justify-content: center; gap: 10px; min-height: 76px; margin-bottom: 8px; } +.input-link-line { position: relative; width: 70px; height: 1px; background: rgba(91, 185, 255, 0.2); } +.input-link-line i { position: absolute; top: -2px; left: 0; width: 5px; height: 5px; border-radius: 50%; background: var(--blue); animation: input-link 1.8s linear infinite; } +@keyframes input-link { to { transform: translateX(65px); } } + .device-list { display: grid; gap: 8px; @@ -1361,6 +1514,8 @@ body.receiver-fullscreen .receiver-stage { width: 100%; height: 100%; min-height .workspace-grid { grid-template-columns: minmax(275px, 0.85fr) minmax(380px, 1.15fr); } .device-panel, .settings-panel { padding: 18px; } .stats-strip > div { padding-right: 10px; padding-left: 10px; } + .input-sharing-grid { grid-template-columns: minmax(390px, 1.25fr) minmax(235px, 0.75fr); } + .input-sharing-panel { padding: 18px; } } @media (max-height: 700px) { diff --git a/tests/config-store.test.js b/tests/config-store.test.js index 7ca7c43..d8b68b1 100644 --- a/tests/config-store.test.js +++ b/tests/config-store.test.js @@ -47,6 +47,24 @@ test('partial setting update preserves connection memory', (t) => { assert.equal(store.get().host.bitrateMbps, 20); }); +test('input sharing layout and clipboard settings are durable', (t) => { + const directory = temporaryDirectory(t); + const store = new ConfigStore(directory); + store.updateSettings({ inputSharing: { + lastDeviceId: 'win-input', + clipboard: true, + edgeDelayMs: 160, + layouts: [{ deviceId: 'win-input', x: 2560, y: 0, width: 1920, height: 1080 }] + } }); + const reloaded = new ConfigStore(directory).get().inputSharing; + assert.equal(reloaded.lastDeviceId, 'win-input'); + assert.equal(reloaded.clipboard, true); + assert.equal(reloaded.edgeDelayMs, 160); + assert.deepEqual(reloaded.layouts[0], { + deviceId: 'win-input', x: 2560, y: 0, width: 1920, height: 1080 + }); +}); + test('remembering, reconnecting and forgetting a device is durable', (t) => { const directory = temporaryDirectory(t); const store = new ConfigStore(directory); diff --git a/tests/input-controller.test.js b/tests/input-controller.test.js new file mode 100644 index 0000000..6eb9062 --- /dev/null +++ b/tests/input-controller.test.js @@ -0,0 +1,34 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { ClipboardSync, macKeyCodeToWindowsVk } = require('../src/core/input-controller'); + +test('Mac physical keys map to Windows shortcuts and navigation keys', () => { + assert.equal(macKeyCodeToWindowsVk(8), 0x43); + assert.equal(macKeyCodeToWindowsVk(55), 0xA2); + assert.equal(macKeyCodeToWindowsVk(123), 0x25); + assert.equal(macKeyCodeToWindowsVk(10_000), null); +}); + +test('clipboard synchronization sends changes and does not echo remote text', () => { + let local = 'first'; + const sent = []; + const sync = new ClipboardSync({ + readText: () => local, + writeText: (text) => { local = text; }, + send: (message) => sent.push(message), + origin: 'test', + intervalMs: 60_000 + }); + sync.start(true); + assert.equal(sent[0].text, 'first'); + local = 'second'; + assert.equal(sync.poll(), true); + assert.equal(sent[1].text, 'second'); + sync.applyRemote({ text: 'remote' }); + assert.equal(local, 'remote'); + assert.equal(sync.poll(), false); + assert.equal(sent.length, 2); + sync.stop(); +}); diff --git a/tests/input-layout.test.js b/tests/input-layout.test.js new file mode 100644 index 0000000..cf5f659 --- /dev/null +++ b/tests/input-layout.test.js @@ -0,0 +1,52 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + InputLayoutRouter, + defaultRemoteRect, + findEntry, + hasAdjacentEdge, + snapRemoteRect +} = require('../src/core/input-layout'); + +const locals = [ + { id: 'top-left', x: 0, y: 0, width: 2560, height: 1080 }, + { id: 'bottom-left', x: 0, y: 1080, width: 2560, height: 1080 }, + { id: 'bottom-right', x: 2560, y: 1080, width: 2560, height: 1080 } +]; +const remote = { id: 'windows', x: 2560, y: 0, width: 1920, height: 1080 }; + +test('default layout places Windows to the right of the top Mac display', () => { + assert.deepEqual(defaultRemoteRect(locals, 1920, 1080, 'windows'), remote); +}); + +test('cursor enters Windows only through a touching edge and matching direction', () => { + const entry = findEntry(locals, remote, { x: 2559, y: 400 }, { x: 8, y: 0 }); + assert.equal(entry.edge, 'right'); + assert.equal(entry.local.id, 'top-left'); + assert.deepEqual(entry.remotePoint, { x: 1, y: 400 }); + assert.equal(findEntry(locals, remote, { x: 2559, y: 400 }, { x: -8, y: 0 }), null); +}); + +test('router returns to the adjacent Mac display through the reciprocal edge', () => { + const router = new InputLayoutRouter({ locals, remote }); + assert.ok(router.tryEnter({ x: 2559, y: 400 }, { x: 4, y: 0 })); + const exited = router.move(-8, 0); + assert.equal(exited.exited, true); + assert.equal(exited.local.id, 'top-left'); + assert.equal(exited.localPoint.x, 2558); +}); + +test('dragged Windows layout snaps to a nearby Mac edge', () => { + const snapped = snapRemoteRect({ ...remote, x: 2608, y: 100 }, locals, 80); + assert.equal(snapped.x, 2560); + assert.equal(snapped.y, 100); +}); + +test('input sharing requires a real overlapping display edge', () => { + const single = [{ id: 'mac', x: 0, y: 0, width: 1920, height: 1080 }]; + assert.equal(hasAdjacentEdge(single, { x: 1920, y: 200, width: 1920, height: 1080 }), true); + assert.equal(hasAdjacentEdge(single, { x: 1920, y: 1080, width: 1920, height: 1080 }), false); + assert.equal(hasAdjacentEdge(single, { x: 2400, y: 0, width: 1920, height: 1080 }), false); +}); diff --git a/tests/protocol.test.js b/tests/protocol.test.js index 9769da9..c4534fa 100644 --- a/tests/protocol.test.js +++ b/tests/protocol.test.js @@ -47,10 +47,36 @@ test('signal validation accepts SDP and ICE messages', () => { test('signal validation rejects unknown type, wrong version and oversized name', () => { assert.throws(() => parseSignalMessage('{"type":"root","protocol":1}'), /不支持/); - assert.throws(() => parseSignalMessage('{"type":"ping","protocol":2,"timestamp":1}'), /版本/); - assert.throws(() => parseSignalMessage(JSON.stringify({ - type: 'hello', protocol: 1, hostId: 'x', name: 'a'.repeat(65) - })), /主端信息/); + assert.throws(() => parseSignalMessage('{"type":"ping","protocol":999,"timestamp":1}'), /版本/); + assert.throws(() => parseSignalMessage(JSON.stringify(makeSignal('hello', { + hostId: 'x', name: 'a'.repeat(65) + }))), /主端信息/); +}); + +test('keyboard, mouse and clipboard messages are validated', () => { + const pointer = makeSignal('input', { event: { kind: 'pointer', x: 1440, y: 720 } }); + assert.deepEqual(parseSignalMessage(JSON.stringify(pointer)), pointer); + const key = makeSignal('input', { event: { kind: 'key', vk: 0x41, down: true } }); + assert.deepEqual(parseSignalMessage(JSON.stringify(key)), key); + const clipboard = makeSignal('clipboard', { text: '跨设备文本', revision: 'mac:1' }); + assert.deepEqual(parseSignalMessage(JSON.stringify(clipboard)), clipboard); + assert.throws(() => parseSignalMessage(JSON.stringify(makeSignal('input', { + event: { kind: 'key', vk: 999, down: true } + }))), /键盘事件/); + assert.throws(() => parseSignalMessage(JSON.stringify(makeSignal('clipboard', { + text: 'x'.repeat(129 * 1024), revision: 'too-large' + }))), /剪贴板/); +}); + +test('input sharing capabilities and receiver display are advertised', () => { + const beacon = makeBeacon({ + id: 'receiver-input', name: 'Windows', port: 47772, + display: { width: 2560, height: 1440, scaleFactor: 1.5 } + }); + const parsed = parseBeacon(Buffer.from(JSON.stringify(beacon)), '192.168.1.20'); + assert.ok(parsed.capabilities.includes('input')); + assert.ok(parsed.capabilities.includes('clipboard')); + assert.deepEqual(parsed.display, { width: 2560, height: 1440, scaleFactor: 1.5 }); }); test('disconnect reasons use a bounded UTF-8 representation', () => {