From 10269db781c8b6ded62a2a2967188535701a6596 Mon Sep 17 00:00:00 2001 From: Chen Date: Thu, 19 Mar 2026 12:56:45 +0800 Subject: [PATCH 01/34] =?UTF-8?q?docs(agents):=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=9D=83=E9=99=90=E5=BC=B9=E7=AA=97=E9=9A=94?= =?UTF-8?q?=E7=A6=BB=E7=BA=A6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增测试环境下隐私权限弹窗隔离规则 - 明确区分应用权限弹窗与测试基础设施授权 - 约束测试流程保持非交互避免人工盯屏授权 --- AGENTS.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 707021a..c670b16 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,13 @@ - high-risk runtime behavior (concurrency/persistence/network/security) - user explicitly requests full suite +## Test Permission Prompt Isolation +- Automated tests must not trigger app-driven macOS privacy prompts such as screen recording, microphone, or camera authorization dialogs. +- Test runs must remain non-interactive and must not depend on a human waiting for app permission prompts during execution. +- Any code path that may request app privacy permissions must switch to a test-specific provider, mock, stub, or equivalent isolation layer under test environments. +- macOS authorization required by the test harness itself, such as Automation, Accessibility, Input Monitoring, or related administrator approval for UI automation, is environment setup and should be handled separately from app permission flows. +- Reject any test change that can block local or CI execution by introducing new app-driven privacy authorization prompts. + ## UI Test Port Injection - Preferred port key is `SharingPortPreferenceKeys.preferredPort` (`sharing.preferredPort`). - For UI tests, inject with launch arguments: `-sharing.preferredPort `. From d9111ef8f81bc19413aee6dd422ba19e9c6bf69b Mon Sep 17 00:00:00 2001 From: Chen Date: Thu, 19 Mar 2026 19:21:49 +0800 Subject: [PATCH 02/34] =?UTF-8?q?docs(retrospective):=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E5=B1=8F=E5=B9=95=E7=9B=91=E5=90=AC=E4=B8=8E=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E5=A4=8D=E7=9B=98=E8=B5=84=E6=96=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增重构复盘文档并沉淀后续重构约束 - 补充预览黑边文档中的授权排查与额外排查信号 - 迁入主屏分享链接规则文档作为后续取舍参考 --- docs/capture_preview_black_bar_fix_notes.md | 94 +++++ .../capture_sharing_refactor_retrospective.md | 321 ++++++++++++++++++ docs/main_display_share_link_rules.md | 65 ++++ 3 files changed, 480 insertions(+) create mode 100644 docs/capture_sharing_refactor_retrospective.md create mode 100644 docs/main_display_share_link_rules.md diff --git a/docs/capture_preview_black_bar_fix_notes.md b/docs/capture_preview_black_bar_fix_notes.md index f6d1f66..c399c7d 100644 --- a/docs/capture_preview_black_bar_fix_notes.md +++ b/docs/capture_preview_black_bar_fix_notes.md @@ -259,6 +259,100 @@ window.frameRect(forContentRect: targetContentSize) - `contentAspectRatio` - 预览层所在视图实际 bounds +## UI 测试授权排查 + +预览诊断链路依赖 macOS 的自动化与截图能力。 + +如果在运行 [CapturePreviewDiagnosticsTests.swift](/Users/syc/Project/VoidDisplay/VoidDisplayUITests/Diagnostics/CapturePreviewDiagnosticsTests.swift) 或 `scripts/test/capture_preview_self_check.sh` 时,系统弹出与以下能力相关的授权窗口: + +- 屏幕录制 +- 辅助功能 +- 自动化控制 + +必须先完成授权,再判断是不是代码问题。 + +### 常见现象 + +如果授权弹窗出现但没有及时允许,常见现象包括: + +- `XCUIElement.screenshot()` 失败 +- 诊断矩阵只在截图步骤失败 +- 日志里出现 “Failed to create screenshot” 一类错误 +- UI 元素存在性检查正常,但 attachment 生成失败 + +### 排查顺序 + +遇到这类失败时,先按下面顺序检查: + +1. 是否有未处理的系统授权弹窗 +2. `Xcode`、测试 Runner、目标应用是否已经被授予需要的权限 +3. 重新运行同一条测试,确认失败是否可复现 +4. 权限与弹窗都确认无误后,再继续怀疑代码实现 + +### 结论 + +这类失败经常来自权限与弹窗环境,不应直接判定为代码回归。 + +## 额外排查信号 + +除了左右黑边和裁切问题,这条链路还积累过两类很容易混淆的现象。它们适合保留为排查信号,不适合把旧实现里的修法直接当成当前结论。 + +### 1. `适应` 正常,但 `1:1` 明显偏小 + +这类回归的识别信号通常是: + +- `适应` 铺满逻辑正常 +- 切到 `1:1` 后内容缩成居中的小画面 +- 工具栏、滚动宿主、窗口外观都正常 +- 预期应当能看到滚动条,实际没有出现 + +出现这种组合时,优先怀疑尺寸语义链路,不要先去改 `ScrollView`、`videoGravity` 或窗口约束。 + +建议按这个顺序查: + +1. `session.resolutionText` 是否表达原生像素尺寸 +2. `renderer.framePixelSize` 是否更接近逻辑尺寸 +3. 首帧 `CMVideoFormatDescriptionGetDimensions` 是否和前两者一致 +4. `SCStreamConfiguration.width/height` 是否已经在上游偏小 +5. 虚拟 HiDPI 场景下,`CGDisplayMode`、`CGDisplayPixelsWide`、`CGDisplayPixelsHigh` 是否互相矛盾 + +这类问题的核心不是视图层观感,关键在于“当前拿来喂给 `1:1` 的尺寸语义到底是什么”。 + +### 2. `1:1` 尺寸看起来对,但画面仍然发糊 + +这类问题要区分两件事: + +1. 预览窗口按多大尺寸显示一帧 +2. `SCStream` 实际交付的这一帧有多少有效像素 + +如果第 1 项正确、第 2 项偏低,最终效果仍然会糊。 + +建议按这个顺序查: + +1. `SCStreamConfiguration.width/height` +2. `SCContentFilter.pointPixelScale` +3. 首帧 `CMVideoFormatDescriptionGetDimensions` +4. 首帧 `SCStreamFrameInfo.scaleFactor` +5. 首帧 `SCStreamFrameInfo.contentScale` +6. 预览窗口的 `resolutionText` +7. 预览窗口的 `renderer.framePixelSize` + +如果看到下面这种组合: + +- `SCStreamConfiguration.width/height` 很大 +- `resolutionText` 也很大 +- 首帧 `dimensions` 仍然偏小 + +优先查采集链路本身。 + +如果看到下面这种组合: + +- 首帧 `dimensions` 已经接近原生像素尺寸 +- 预览尺寸也匹配 +- 视觉上仍然发糊 + +优先查被监听的源内容是否本身就没有以 HiDPI 方式渲染。 + ## 这轮新增经验 这轮又补了三个和预览窗口观感直接相关的问题: diff --git a/docs/capture_sharing_refactor_retrospective.md b/docs/capture_sharing_refactor_retrospective.md new file mode 100644 index 0000000..d09999f --- /dev/null +++ b/docs/capture_sharing_refactor_retrospective.md @@ -0,0 +1,321 @@ +# 屏幕监听与共享重构复盘 + +## 1. 背景与结论 + +这份复盘文档对应的对象是未合并分支 `codex/capture-cursor-config-serialize`。 +`codex/extract-capture-cursor-config-serialize` 当前与 `main` 几乎没有实现差异,不能代表那次失败重构的主体。 + +这次重构最终放弃合并,核心原因很直接:结构风险大于可保留收益。分支试图同时重写屏幕监听、屏幕共享、Web 服务生命周期、共享注册、主屏别名路由、窗口预览几何、测试隔离与回退机制,变更面过大,耦合面过密,后续只能靠连续补丁维持稳定。 + +可以直接作为证据的时间线如下: + +1. `7aed33c`:先把屏幕监听与屏幕共享结构一起收拢,并改写预览窗口支持。 +2. `42ee1d7`:进一步引入 `ScreenPipelineRuntime`,统一接管监听、共享、Web 生命周期与观看人数。 +3. `6e78b11`:开始修监听与共享状态一致性。 +4. `6226ceb`:开始修主屏别名与共享注册真值源。 +5. `7709f7a`:继续修并发收敛问题并补回归测试。 +6. `cb00555`:最后又修虚拟 HiDPI 屏幕监听回归。 + +从这个顺序可以看出,问题集中在重构后的结构本身。初始重构完成后,分支很快进入长时间的稳定性补丁阶段。 + +对照当前主线,风险更容易看清: + +1. 当前监听状态仍然由 [CaptureMonitoringService.swift](../VoidDisplay/Features/Capture/Services/CaptureMonitoringService.swift) 负责,职责边界单一。 +2. 当前共享注册与共享会话仍然由 [DisplaySharingCoordinator.swift](../VoidDisplay/Features/Sharing/Services/DisplaySharingCoordinator.swift) 负责,主屏解析和 shareID 分配也留在同一模块。 +3. 当前预览窗口几何问题已经沉淀为单独说明文档 [capture_preview_black_bar_fix_notes.md](capture_preview_black_bar_fix_notes.md),说明主线最终选择了分问题修复,不再继续沿用那套大一统 runtime。 + +## 2. 这次重构做错了什么 + +### 2.1 一次性把四类运行时职责压进同一个总入口 + +结论分类:不应该做 + +`42ee1d7` 引入 `VoidDisplay/Features/ScreenPipeline/ScreenPipelineRuntime.swift`,同次提交又删除 `CaptureMonitoringService.swift`、`SharingService.swift`、`DisplaySharingCoordinator.swift`。 +证据很明确:`ScreenPipelineRuntime` 同时定义监听状态、共享状态、Web 服务状态、viewer 统计、注册更新、命令等待、错误语义和快照分发,文件体量与职责密度都明显过高。 + +后续修补模式也很明显: + +1. `6e78b11` 修状态一致性。 +2. `b175bf6` 修共享注册冲突与失效回写。 +3. `6b66075` 修监听移除收敛与共享页拓扑误刷新。 +4. `7709f7a` 修并发收敛。 + +这说明重构后新增的复杂度没有在统一入口内自然收敛,后续只能继续向同一个入口追加状态规则。 + +### 2.2 让监听链路与共享链路互相污染状态 + +结论分类:不应该做 + +分支里的 `ScreenPipelineSnapshot` 把监听会话、共享状态、共享失败、主屏、Web 服务端口、viewer 数都揉进同一个快照模型。 +`CaptureController.swift` 与 `SharingController.swift` 都改成订阅同一个 runtime 快照,再从中拆出自己关心的状态。 + +证据: + +1. `42ee1d7` 的 `CaptureController.swift` 与 `SharingController.swift` 都新增 runtime 快照订阅。 +2. `6e78b11` 与 `a086cbf` 的提交信息已经直接指向“状态一致性”“链路状态污染”。 + +这种结构会让监听与共享在读模型上失去隔离。一个链路的补丁很容易波及另一个链路的呈现与收敛。 + +### 2.3 把共享目录注册和权限回退做成持续摆动的协调层 + +结论分类:不应该做 + +共享可注册显示器这件事,本来只需要明确“谁负责加载”“谁负责注册”“权限缺失时谁清空状态”。分支后来引入 `ShareableDisplayRegistrationCoordinator`,把权限检查、拓扑签名、回退轮询、恢复重试、同步串行化全部包进一个协调器。 + +证据: + +1. `6226ceb` 新增 `VoidDisplay/App/ShareableDisplayRegistrationCoordinator.swift`。 +2. 文件内同时存在 `fallbackPollingTask`、`recoveryRetryTask`、`pendingSync`、`lastAppliedTopologySignature`、`lastPermissionGranted`、`needsRetryAfterFailure`。 +3. `acca06c`、`962a5d5`、`a086cbf` 又继续围绕目录权限、目录状态、链路污染补丁。 + +这类结构会把“注册逻辑”演变成一个长期运行的补偿系统,后续任何权限、拓扑、加载失败都可能落进协调状态机。 + +### 2.4 过早抽象主屏别名路由 + +结论分类:不应该做 + +主屏分享链接规则在用户价值上很轻,技术风险却不轻。分支在 `6226ceb` 里新增 `docs/main_display_share_link_rules.md`,并把 `/display` 与 `/signal` 的主屏别名语义纳入注册真值源与路由代理。 + +证据: + +1. `6226ceb` 同时修改注册协调器、runtime、路由测试与文档。 +2. `ScreenPipelineRuntimeTests.swift` 在这一阶段新增了主屏别名、shareID 保持、并发注册保序等大量测试。 + +这类抽象提高了共享路由层的状态耦合,却没有为监听或共享稳定性带来基础收益。 + +### 2.5 在并发命令问题上建立过重的串行语义 + +结论分类:不应该做 + +分支后期的核心修补几乎都在处理命令排队、取消、等待注册槽位、停止中禁止启动、共享开始与停止 FIFO 次序、移除中的 in flight 命令回滚。 + +证据: + +1. `d3f81dd`、`7709f7a` 都集中修共享并发状态机与收敛。 +2. `ScreenPipelineRuntimeTests.swift` 中后期新增了大量 `concurrent`、`cancelled`、`waitingForRegistrationSlot`、`webServiceStopping` 相关测试。 + +这说明抽象层级已经高到需要专门证明“命令之间不会互相踩踏”。此时重构已经偏离了“让代码更容易推断”的目标。 + +## 3. 哪些属于过度设计 + +### 3.1 `ScreenPipelineRuntime` + +结论分类:过度设计 + +`ScreenPipelineRuntime` 在同一文件里承担了以下职责: + +1. 显示器描述注册。 +2. 监听会话生命周期。 +3. 共享会话生命周期。 +4. Web 服务生命周期。 +5. viewer 指标聚合。 +6. 路由代理回写。 +7. 错误语义定义。 +8. 快照流分发。 +9. 并发命令顺序控制。 + +证据:`42ee1d7` 的 `VoidDisplay/Features/ScreenPipeline/ScreenPipelineRuntime.swift`。 + +这类“总 runtime”看起来统一,实际把多个变化频率不同的问题绑到同一次改动里。监听和共享的修补无法独立演进。 + +### 3.2 `ShareableDisplayRegistrationCoordinator` + +结论分类:过度设计 + +显示器注册本质上是共享页的输入刷新逻辑。分支把它上升成跨权限、跨拓扑、跨失败恢复的协调器,包含轮询、恢复重试、状态签名缓存、同步去抖与串行化执行。 + +证据:`6226ceb` 的 `VoidDisplay/App/ShareableDisplayRegistrationCoordinator.swift`。 + +这会让一个本应短路径、短状态的输入同步问题,演变成新的长期运行状态机。 + +### 3.3 主屏别名规则 + +结论分类:过度设计 + +`/display` 和 `/signal` 的主屏别名本身是附加入口。分支围绕它新增了“当前主屏已存在于注册集时才可用”“主屏切换时别名跟随,具体地址不变”等规则,还同步修改路由解析与注册真值源。 + +证据:`6226ceb` 与 `docs/main_display_share_link_rules.md`。 + +这个规则复杂度与用户收益不对称,放在失败重构里只会继续放大状态耦合。 + +### 3.4 围绕状态收敛建立补偿路径 + +结论分类:过度设计 + +从 `6e78b11` 到 `7709f7a`,提交标题已经反复出现“收敛”“一致性”“回写”“误刷新”“并发收敛”。 +这说明重构后的结构需要靠补偿路径维持一致性。补偿路径一旦成为常态,系统的真实语义就会越来越难推断。 + +证据: + +1. `6e78b11` +2. `962a5d5` +3. `b175bf6` +4. `6b66075` +5. `7709f7a` + +同一主题连续出现,本身就是过度设计的信号。 + +## 4. `1:1` 预览失稳专项复盘 + +这次 `1:1` 失稳问题,不能只归结为某一行计算错误。更关键的问题在于,分支把“真实窗口承载区几何”和“采集元数据推断”缠在了一起,导致窗口大小计算失去了单一可信几何事实。 + +### 4.1 问题是怎样形成的 + +分支里的 `CaptureDisplayView.swift` 同时引入了两层推断: + +1. `nativeFrameSizeInPoints` 先走 `CapturePreviewNativeScaleResolver.resolve`,没有直接采用原始帧尺寸。 +2. `preferredAspect()` 也优先采用 `resolutionText` 解析结果,再回退到首帧像素尺寸。 + +证据: + +1. `codex/capture-cursor-config-serialize` 分支中的 `VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift` 第 41 行到第 56 行。 +2. 同文件第 243 行到第 257 行。 + +窗口计算又被抽到 `CapturePreviewWindowSupport.swift`,这里的 `CapturePreviewWindowMetrics` 同时接受 `aspect`、`framePixelSize`、`targetContentWidth`、`shouldLockAspect`。 +初始窗口大小在 `applyInitialWindowSizeIfNeeded()` 中只应用一次,后续还要继续参与 `snapWindowToAspect()` 与 resize 过程。 + +证据: + +1. 分支中的 `VoidDisplay/Features/Capture/Views/CapturePreviewWindowSupport.swift` 第 4 行到第 20 行。 +2. 同文件第 68 行到第 135 行。 +3. 同文件第 149 行到第 193 行。 + +### 4.2 为什么会失稳 + +`1:1` 模式真正需要的是一个稳定的几何基准:预览层承载区到底应该用哪组宽高。 +分支却把这个问题交给了“首帧像素尺寸”和“`resolutionText` 推断后的原生尺寸”共同决定。 + +这会带来三个后果: + +1. 当 `resolutionText` 与首帧尺寸处于 HiDPI、虚拟显示器、异常首帧、元数据延迟到达等组合场景时,`resolve` 的结果可能变化。 +2. `preferredAspect()` 与 `nativeFrameSizeInPoints` 的依据并不完全一致,一个偏向 `resolutionText`,一个偏向推断后的 native size。 +3. `applyInitialWindowSizeIfNeeded()` 只在第一次满足条件时落地窗口大小,首次采用的推断如果偏了,后续很难自动回到正确几何。 + +所以这次 bug 的本质不只是“公式写错”。更深一层的问题,是“窗口几何”和“采集元数据解释”被耦合到了同一个决策层。 + +### 4.3 主线后来做对了什么 + +主线修复预览问题时,重点放回到真实内容承载区几何。 +[capture_preview_black_bar_fix_notes.md](capture_preview_black_bar_fix_notes.md) 已经明确记录了两个关键点: + +1. 用 `contentRect` 与 `contentLayoutRect` 的差值计算真实布局 inset。 +2. 用真实预览承载区去反推窗口 frame。 + +这条思路更稳,因为它把问题收回到了窗口几何本身。 +几何由几何事实决定,采集元数据只负责提供可信的宽高比输入,不再参与多轮推断。 + +### 4.4 这类问题后续应该怎样处理 + +结论分类:应该做 + +后续如果再动 `1:1` 预览,只能遵守下面四条: + +1. 先固定几何真相,明确承载层的真实布局区域。 +2. 把采集尺寸解析单独封装为输入归一化,不得和窗口 frame 决策混写在一起。 +3. 当元数据存在多来源时,必须先定义单一真值源,再进入窗口 sizing。 +4. 必须保留预览诊断链路与像素级自验证,不能退回人工截图反馈。 + +证据: + +1. 主线的 [capture_preview_black_bar_fix_notes.md](capture_preview_black_bar_fix_notes.md)。 +2. `cb00555` 又一次修虚拟 HiDPI 回归,说明这块没有资格靠肉眼试错。 + +## 5. 后续重构应该怎么做 + +### 5.1 监听、共享、Web 服务、窗口 sizing 四块分治 + +结论分类:应该做 + +后续重构必须拆成四块独立问题: + +1. 监听内部状态与会话管理。 +2. 共享注册、shareID、共享会话管理。 +3. Web 服务启动停止与路由绑定。 +4. 预览窗口 sizing 与渲染诊断。 + +任何一个阶段都不能同时改这四块。 + +### 5.2 共享只抽共享,监听只抽监听 + +结论分类:应该做 + +监听和共享都依赖显示器输入,但它们的运行语义不同: + +1. 监听关心预览订阅、窗口、cursor、会话移除。 +2. 共享关心 shareID、路由、sessionHub、viewer 统计、服务启动状态。 + +公共层只应保留低层原语,例如显示器描述、底层采集句柄、可测试的 registry 能力。 +禁止再引入一个同时替代监听服务和共享服务的总 runtime。 + +### 5.3 目录注册逻辑保持短路径 + +结论分类:应该做 + +共享目录注册只保留三步: + +1. 读权限状态。 +2. 读取当前可共享显示器集合。 +3. 用单次结果刷新共享注册。 + +如果需要失败重试,重试逻辑必须留在调用层或测试层,不能升级为新的长期运行协调器。 + +### 5.4 主屏别名规则延后 + +结论分类:应该做 + +`/display`、`/signal` 这类主屏别名只在共享主链路稳定后才有资格进入。 +下一轮重构的第一批目标里不应包含这类语义扩展。 + +## 6. 重构重启门槛 + +### 6.1 分阶段顺序 + +下一轮重构执行顺序固定如下: + +1. 先收口监听内部实现,不动共享路由、不动 Web 服务、不动主屏别名。 +2. 再收口共享内部实现,不碰监听窗口几何。 +3. 再抽监听和共享都确实需要的低层原语。 +4. 最后才考虑跨模块统一状态接口。 + +这个顺序不能倒置。尤其不能一上来先做大一统 runtime。 + +### 6.2 每阶段验收项 + +每个阶段都要满足以下门槛后才能继续: + +1. 当前阶段改动只落在单条链路,另一条链路只允许适配型最小改动。 +2. 相关回归测试先补齐,再做结构调整。 +3. 编译零错误、零警告。 +4. 新增状态语义必须能用一句话描述清楚真值源和更新时机。 + +### 6.3 必须先补的回归测试 + +下次重构前,至少要保留并优先补齐以下测试能力: + +1. 监听会话添加、激活、移除、cursor 状态回写。 +2. 共享注册刷新、shareID 稳定、主屏切换、显示器移除。 +3. Web 服务启动、停止、端口冲突、停止中拒绝新共享。 +4. 共享并发场景,包括同屏重复启动、停止中启动、取消中的回滚。 +5. 虚拟 HiDPI 与 `1:1` 预览链路。 + +### 6.4 必须保留的本地自验证链路 + +以下链路不得删除: + +1. 预览诊断 runtime。 +2. 预览录制 sink。 +3. UI 诊断测试。 +4. `scripts/test/capture_preview_self_check.sh` +5. `scripts/test/capture_preview_analyze.swift` + +这套链路已经证明,屏幕预览问题需要可重复、可量化的本地验证。 + +### 6.5 最终约束清单 + +为了避免 `main` 的下一轮重构重蹈覆辙,执行前必须先确认下面三类结论: + +1. 不应该做:一次性统一监听、共享、Web 生命周期、共享注册、窗口几何。 +2. 应该做:按链路分治,先补测试,再做结构调整,公共层只保留稳定原语。 +3. 过度设计:总 runtime、长期运行注册协调器、主屏别名规则、围绕收敛建立的大量补偿路径。 + +只要重构方案重新出现这些特征,就应该立刻停下,重新拆分范围。 diff --git a/docs/main_display_share_link_rules.md b/docs/main_display_share_link_rules.md new file mode 100644 index 0000000..52275dd --- /dev/null +++ b/docs/main_display_share_link_rules.md @@ -0,0 +1,65 @@ +# 主屏分享链接规则 + +## 目标 + +这份文档定义屏幕共享链接的统一规则,尤其是主屏别名路由的语义边界。 + +## 统一规则 + +所有屏幕的真实共享目标都基于 `shareID`。 + +具体屏幕的标准地址格式: + +- `/display/{shareID}` +- `/signal/{shareID}` + +这条规则对主屏和非主屏完全一致。 + +## 主屏额外别名 + +主屏比其他屏幕多两个别名地址: + +- `/display` +- `/signal` + +这两个地址只表示“当前系统主屏”。 + +内部解析语义: + +- `/display` 映射到当前主屏对应的 `/display/{shareID}` +- `/signal` 映射到当前主屏对应的 `/signal/{shareID}` + +## 主屏切换语义 + +当系统当前主屏发生变化时: + +- `/display` 跟随新的主屏 +- `/signal` 跟随新的主屏 + +具体屏幕地址不会因为主屏切换而变化: + +- `/display/{shareID}` 继续指向对应那块屏幕 +- `/signal/{shareID}` 继续指向对应那块屏幕 + +## 可用性边界 + +主屏别名只有在“当前系统主屏已存在于当前共享注册集”时才可用。 + +如果当前系统主屏不在注册集内: + +- `/display` 不可用 +- `/signal` 不可用 + +此时具体屏幕地址是否可用,仍然只取决于对应 `shareID` 是否处于当前注册和路由状态。 + +## 前端展示规则 + +前端默认继续展示具体屏幕地址: + +- `/display/{shareID}` + +主屏别名属于额外入口,用于让用户输入 `/display` 时自动命中当前主屏,不替代具体地址展示。 + +## 一句话总结 + +主屏没有单独的底层共享标识规则,只有额外的别名路由规则。 From afa41ee4dea4c99478b0fbed2666b9cd412ddbef Mon Sep 17 00:00:00 2001 From: Chen Date: Thu, 19 Mar 2026 22:27:55 +0800 Subject: [PATCH 03/34] =?UTF-8?q?test(capture):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E6=A8=A1=E5=BC=8F=E4=B8=8E=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E9=9A=94=E7=A6=BB=E6=8A=A4=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 抽离可测预览几何逻辑并补充单元测试 - 增加 fit 和 native 诊断断言与截图自检 - 补充监听共享隔离测试与相关文档说明 --- .../Capture/Views/CaptureDisplayView.swift | 164 +++++--------- .../Views/CapturePreviewGeometry.swift | 139 ++++++++++++ .../CapturePreviewDiagnosticsRuntime.swift | 26 ++- .../App/CaptureSharingIsolationTests.swift | 120 +++++++++++ .../Views/CapturePreviewGeometryTests.swift | 111 ++++++++++ ...apturePreviewDiagnosticsRuntimeTests.swift | 14 +- .../CapturePreviewDiagnosticsTests.swift | 121 ++++++++++- docs/capture_preview_black_bar_fix_notes.md | 17 ++ .../capture_sharing_refactor_retrospective.md | 19 +- scripts/test/capture_preview_analyze.swift | 204 +++++++++++++++--- scripts/test/capture_preview_self_check.sh | 101 +++++---- 11 files changed, 844 insertions(+), 192 deletions(-) create mode 100644 VoidDisplay/Features/Capture/Views/CapturePreviewGeometry.swift create mode 100644 VoidDisplayTests/App/CaptureSharingIsolationTests.swift create mode 100644 VoidDisplayTests/Features/Capture/Views/CapturePreviewGeometryTests.swift diff --git a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift index 6c35dba..efa4e9d 100644 --- a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift +++ b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift @@ -43,14 +43,10 @@ struct CaptureDisplayView: View { } private var nativeFrameSizeInPoints: CGSize { - let pixelSize = renderer.framePixelSize - guard pixelSize.width > 0, pixelSize.height > 0 else { - let fallback = preferredAspect() - return CGSize(width: max(1, fallback.width), height: max(1, fallback.height)) - } - return CGSize( - width: max(1, pixelSize.width / currentScaleFactor), - height: max(1, pixelSize.height / currentScaleFactor) + CapturePreviewGeometry.nativeFrameSizeInPoints( + framePixelSize: renderer.framePixelSize, + scaleFactor: currentScaleFactor, + fallbackAspect: preferredAspect() ) } @@ -101,6 +97,7 @@ struct CaptureDisplayView: View { .controlSize(.small) .frame(width: 150) .accessibilityIdentifier("capture_preview_scale_mode_picker") + .accessibilityValue(Text(scaleMode == .fit ? "fit" : "native")) } ToolbarItem(placement: .automatic) { HStack(spacing: 6) { @@ -116,6 +113,9 @@ struct CaptureDisplayView: View { } .toolbarTitleDisplayMode(.inline) .onAppear { + if let diagnosticsScaleMode = initialPreviewScaleModeOverride { + scaleMode = diagnosticsScaleMode + } windowCoordinator.update(aspect: preferredAspect(), shouldLockAspect: scaleMode == .fit) capturesCursor = session?.capturesCursor ?? false } @@ -232,57 +232,33 @@ extension CaptureDisplayView { guard let window, aspect.width > 0, aspect.height > 0, !hasAppliedInitialSize else { return } window.backgroundColor = .windowBackgroundColor - let visibleFrame = window.screen?.visibleFrame ?? NSScreen.main?.visibleFrame let contentRect = window.contentRect(forFrameRect: window.frame) let layoutRect = window.contentLayoutRect - let chromeWidth = max(0, window.frame.width - contentRect.width) - let chromeHeight = max(0, window.frame.height - contentRect.height) - let layoutInsetWidth = max(0, contentRect.width - layoutRect.width) - let layoutInsetHeight = max(0, contentRect.height - layoutRect.height) - - let maxPreviewWidth = max( - 320, - (visibleFrame?.width ?? 1280) - chromeWidth - layoutInsetWidth - 16 - ) - let maxPreviewHeight = max( - 180, - (visibleFrame?.height ?? 800) - chromeHeight - layoutInsetHeight - 16 - ) - - let ratio = aspect.width / aspect.height - let scale = max(1, window.screen?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1) - let pixelSize = renderer.framePixelSize - let defaultPreviewWidth = max(320, maxPreviewWidth * 0.85) - let defaultPreviewHeight = defaultPreviewWidth / ratio - var previewWidth = defaultPreviewWidth - var previewHeight = defaultPreviewHeight - - if pixelSize.width > 0, pixelSize.height > 0 { - previewWidth = pixelSize.width / scale - previewHeight = pixelSize.height / scale - } - - if let overriddenWidth = CapturePreviewDiagnosticsRuntime.configuration()?.targetContentWidth { - previewWidth = min(max(320, overriddenWidth), maxPreviewWidth) - previewHeight = previewWidth / ratio - } - - if previewWidth > maxPreviewWidth { - previewWidth = maxPreviewWidth - previewHeight = previewWidth / ratio - } - if previewHeight > maxPreviewHeight { - previewHeight = maxPreviewHeight - previewWidth = previewHeight * ratio - } - - let targetContentSize = NSSize( - width: previewWidth + layoutInsetWidth, - height: previewHeight + layoutInsetHeight + let targetContentSize = CapturePreviewGeometry.initialContentSize( + input: .init( + aspect: aspect, + framePixelSize: renderer.framePixelSize, + targetContentWidth: CapturePreviewDiagnosticsRuntime.configuration()?.targetContentWidth, + visibleFrameSize: (window.screen?.visibleFrame ?? NSScreen.main?.visibleFrame)?.size + ?? CGSize(width: 1280, height: 800), + chromeSize: CGSize( + width: max(0, window.frame.width - contentRect.width), + height: max(0, window.frame.height - contentRect.height) + ), + layoutInsetSize: CGSize( + width: max(0, contentRect.width - layoutRect.width), + height: max(0, contentRect.height - layoutRect.height) + ), + scaleFactor: max(1, window.screen?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1) + ) ) + guard let targetContentSize else { return } let targetFrame = window.frameRect( - forContentRect: NSRect(origin: .zero, size: targetContentSize) + forContentRect: NSRect(origin: .zero, size: NSSize( + width: targetContentSize.width, + height: targetContentSize.height + )) ) var newFrame = window.frame newFrame.origin.x += (newFrame.width - targetFrame.width) / 2 @@ -297,23 +273,22 @@ extension CaptureDisplayView { /// resolution text (e.g. "2560 × 1440"), falling back to the /// pixel size reported by the renderer's first frame. private func preferredAspect() -> CGSize { - if let text = session?.resolutionText, - let size = Self.parseResolution(text) { - return size - } - return renderer.framePixelSize + CapturePreviewGeometry.preferredAspect( + resolutionText: session?.resolutionText, + framePixelSize: renderer.framePixelSize + ) } - private static func parseResolution(_ text: String) -> CGSize? { - let separators: [Character] = ["×", "x", "X", "*"] - guard let sep = separators.first(where: { text.contains($0) }) else { return nil } - let parts = text.split(separator: sep, maxSplits: 1) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - guard parts.count == 2, - let w = Double(parts[0]), w > 0, - let h = Double(parts[1]), h > 0 - else { return nil } - return CGSize(width: w, height: h) + private var initialPreviewScaleModeOverride: PreviewScaleMode? { + guard let override = CapturePreviewDiagnosticsRuntime.configuration()?.initialScaleMode else { + return nil + } + switch override { + case .fit: + return .fit + case .native: + return .native + } } } @@ -374,53 +349,22 @@ private final class CapturePreviewWindowCoordinator: NSObject { } private func aspectLockedContentSize(for window: NSWindow, proposedFrameSize: NSSize) -> NSSize? { - guard aspect.width > 0, aspect.height > 0 else { return nil } let currentContentRect = window.contentRect(forFrameRect: window.frame) let currentLayoutRect = window.contentLayoutRect - let layoutInsetWidth = max(0, currentContentRect.width - currentLayoutRect.width) - let layoutInsetHeight = max(0, currentContentRect.height - currentLayoutRect.height) let proposedContentRect = window.contentRect( forFrameRect: NSRect(origin: .zero, size: proposedFrameSize) ) - let scale = max(1, window.backingScaleFactor) - let insetWidthPixels = max(0, Int((layoutInsetWidth * scale).rounded())) - let insetHeightPixels = max(0, Int((layoutInsetHeight * scale).rounded())) - let proposedPreviewWidthPixels = max( - 1, - Int(((proposedContentRect.width - layoutInsetWidth) * scale).rounded(.down)) - ) - let proposedPreviewHeightPixels = max( - 1, - Int(((proposedContentRect.height - layoutInsetHeight) * scale).rounded(.down)) - ) - let aspectWidthPixels = max(1, Int(aspect.width.rounded())) - let aspectHeightPixels = max(1, Int(aspect.height.rounded())) - - let previewWidthPixels: Int - let previewHeightPixels: Int - - if proposedPreviewWidthPixels * aspectHeightPixels > proposedPreviewHeightPixels * aspectWidthPixels { - previewHeightPixels = proposedPreviewHeightPixels - previewWidthPixels = max( - 1, - Int((CGFloat(previewHeightPixels) * aspect.width / aspect.height).rounded(.down)) - ) - } else { - previewWidthPixels = proposedPreviewWidthPixels - previewHeightPixels = max( - 1, - Int((CGFloat(previewWidthPixels) * aspect.height / aspect.width).rounded(.down)) - ) - } - - let targetContentRect = NSRect( - origin: .zero, - size: NSSize( - width: CGFloat(previewWidthPixels + insetWidthPixels) / scale, - height: CGFloat(previewHeightPixels + insetHeightPixels) / scale - ) + let targetContentSize = CapturePreviewGeometry.aspectLockedContentSize( + aspect: aspect, + proposedContentSize: proposedContentRect.size, + layoutInsetSize: CGSize( + width: max(0, currentContentRect.width - currentLayoutRect.width), + height: max(0, currentContentRect.height - currentLayoutRect.height) + ), + scaleFactor: max(1, window.backingScaleFactor) ) - return targetContentRect.size + guard let targetContentSize else { return nil } + return NSSize(width: targetContentSize.width, height: targetContentSize.height) } private func restoreWindowDelegate() { diff --git a/VoidDisplay/Features/Capture/Views/CapturePreviewGeometry.swift b/VoidDisplay/Features/Capture/Views/CapturePreviewGeometry.swift new file mode 100644 index 0000000..1856022 --- /dev/null +++ b/VoidDisplay/Features/Capture/Views/CapturePreviewGeometry.swift @@ -0,0 +1,139 @@ +import CoreGraphics +import Foundation + +struct CapturePreviewGeometry { + struct InitialContentSizeInput { + let aspect: CGSize + let framePixelSize: CGSize + let targetContentWidth: CGFloat? + let visibleFrameSize: CGSize + let chromeSize: CGSize + let layoutInsetSize: CGSize + let scaleFactor: CGFloat + } + + nonisolated static func parseResolution(_ text: String?) -> CGSize? { + guard let text else { return nil } + let separators: [Character] = ["×", "x", "X", "*"] + guard let separator = separators.first(where: { text.contains($0) }) else { return nil } + let parts = text.split(separator: separator, maxSplits: 1) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard parts.count == 2, + let width = Double(parts[0]), width > 0, + let height = Double(parts[1]), height > 0 + else { return nil } + return CGSize(width: width, height: height) + } + + nonisolated static func preferredAspect( + resolutionText: String?, + framePixelSize: CGSize + ) -> CGSize { + parseResolution(resolutionText) ?? framePixelSize + } + + nonisolated static func nativeFrameSizeInPoints( + framePixelSize: CGSize, + scaleFactor: CGFloat, + fallbackAspect: CGSize + ) -> CGSize { + guard framePixelSize.width > 0, framePixelSize.height > 0 else { + return CGSize(width: max(1, fallbackAspect.width), height: max(1, fallbackAspect.height)) + } + let sanitizedScale = max(1, scaleFactor) + return CGSize( + width: max(1, framePixelSize.width / sanitizedScale), + height: max(1, framePixelSize.height / sanitizedScale) + ) + } + + nonisolated static func initialContentSize(input: InitialContentSizeInput) -> CGSize? { + guard input.aspect.width > 0, input.aspect.height > 0 else { return nil } + + let maxPreviewWidth = max( + 320, + input.visibleFrameSize.width - input.chromeSize.width - input.layoutInsetSize.width - 16 + ) + let maxPreviewHeight = max( + 180, + input.visibleFrameSize.height - input.chromeSize.height - input.layoutInsetSize.height - 16 + ) + + let ratio = input.aspect.width / input.aspect.height + let scale = max(1, input.scaleFactor) + let pixelSize = input.framePixelSize + let defaultPreviewWidth = max(320, maxPreviewWidth * 0.85) + let defaultPreviewHeight = defaultPreviewWidth / ratio + var previewWidth = defaultPreviewWidth + var previewHeight = defaultPreviewHeight + + if pixelSize.width > 0, pixelSize.height > 0 { + previewWidth = pixelSize.width / scale + previewHeight = pixelSize.height / scale + } + + if let overriddenWidth = input.targetContentWidth { + previewWidth = min(max(320, overriddenWidth), maxPreviewWidth) + previewHeight = previewWidth / ratio + } + + if previewWidth > maxPreviewWidth { + previewWidth = maxPreviewWidth + previewHeight = previewWidth / ratio + } + if previewHeight > maxPreviewHeight { + previewHeight = maxPreviewHeight + previewWidth = previewHeight * ratio + } + + return CGSize( + width: previewWidth + input.layoutInsetSize.width, + height: previewHeight + input.layoutInsetSize.height + ) + } + + nonisolated static func aspectLockedContentSize( + aspect: CGSize, + proposedContentSize: CGSize, + layoutInsetSize: CGSize, + scaleFactor: CGFloat + ) -> CGSize? { + guard aspect.width > 0, aspect.height > 0 else { return nil } + let scale = max(1, scaleFactor) + + let insetWidthPixels = max(0, Int((layoutInsetSize.width * scale).rounded())) + let insetHeightPixels = max(0, Int((layoutInsetSize.height * scale).rounded())) + let proposedPreviewWidthPixels = max( + 1, + Int(((proposedContentSize.width - layoutInsetSize.width) * scale).rounded(.down)) + ) + let proposedPreviewHeightPixels = max( + 1, + Int(((proposedContentSize.height - layoutInsetSize.height) * scale).rounded(.down)) + ) + let aspectWidthPixels = max(1, Int(aspect.width.rounded())) + let aspectHeightPixels = max(1, Int(aspect.height.rounded())) + + let previewWidthPixels: Int + let previewHeightPixels: Int + + if proposedPreviewWidthPixels * aspectHeightPixels > proposedPreviewHeightPixels * aspectWidthPixels { + previewHeightPixels = proposedPreviewHeightPixels + previewWidthPixels = max( + 1, + Int((CGFloat(previewHeightPixels) * aspect.width / aspect.height).rounded(.down)) + ) + } else { + previewWidthPixels = proposedPreviewWidthPixels + previewHeightPixels = max( + 1, + Int((CGFloat(previewWidthPixels) * aspect.height / aspect.width).rounded(.down)) + ) + } + + return CGSize( + width: CGFloat(previewWidthPixels + insetWidthPixels) / scale, + height: CGFloat(previewHeightPixels + insetHeightPixels) / scale + ) + } +} diff --git a/VoidDisplay/Shared/Testing/CapturePreviewDiagnosticsRuntime.swift b/VoidDisplay/Shared/Testing/CapturePreviewDiagnosticsRuntime.swift index d87163e..d9604dd 100644 --- a/VoidDisplay/Shared/Testing/CapturePreviewDiagnosticsRuntime.swift +++ b/VoidDisplay/Shared/Testing/CapturePreviewDiagnosticsRuntime.swift @@ -2,11 +2,17 @@ import AppKit import CoreGraphics import Foundation +enum CapturePreviewDiagnosticsScaleMode: String, Sendable { + case fit + case native +} + struct CapturePreviewDiagnosticsConfiguration: Sendable { let sourcePixelSize: CGSize let targetContentWidth: CGFloat? let replayImageURL: URL? let recordDirectoryURL: URL? + let initialScaleMode: CapturePreviewDiagnosticsScaleMode? } enum CapturePreviewDiagnosticsRuntime { @@ -14,6 +20,15 @@ enum CapturePreviewDiagnosticsRuntime { nonisolated static let targetContentWidthEnvironmentKey = "VOIDDISPLAY_CAPTURE_PREVIEW_TARGET_CONTENT_WIDTH" nonisolated static let replayImagePathEnvironmentKey = "VOIDDISPLAY_CAPTURE_PREVIEW_REPLAY_IMAGE_PATH" nonisolated static let recordDirectoryPathEnvironmentKey = "VOIDDISPLAY_CAPTURE_PREVIEW_RECORD_DIRECTORY" + /// Diagnostics-only scale mode override. + /// + /// Valid values: + /// - `fit`: Preview uses the adaptive fit mode. + /// - `native`: Preview uses the `1:1` mode. + /// + /// This key is intended for UI diagnostics and test scenarios. + /// Production runtime should not depend on it. + nonisolated static let scaleModeEnvironmentKey = "VOIDDISPLAY_CAPTURE_PREVIEW_SCALE_MODE" nonisolated static var isPreviewDiagnosticsScenario: Bool { UITestRuntime.isEnabled && UITestRuntime.scenario == .capturePreviewDiagnostics @@ -52,11 +67,20 @@ enum CapturePreviewDiagnosticsRuntime { recordDirectoryURL = nil } + let initialScaleMode: CapturePreviewDiagnosticsScaleMode? + if let rawMode = environment[scaleModeEnvironmentKey]?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawMode.isEmpty { + initialScaleMode = CapturePreviewDiagnosticsScaleMode(rawValue: rawMode.lowercased()) + } else { + initialScaleMode = nil + } + return CapturePreviewDiagnosticsConfiguration( sourcePixelSize: sourcePixelSize, targetContentWidth: targetContentWidth, replayImageURL: replayImageURL, - recordDirectoryURL: recordDirectoryURL + recordDirectoryURL: recordDirectoryURL, + initialScaleMode: initialScaleMode ) } diff --git a/VoidDisplayTests/App/CaptureSharingIsolationTests.swift b/VoidDisplayTests/App/CaptureSharingIsolationTests.swift new file mode 100644 index 0000000..542e023 --- /dev/null +++ b/VoidDisplayTests/App/CaptureSharingIsolationTests.swift @@ -0,0 +1,120 @@ +import CoreGraphics +import Foundation +import ScreenCaptureKit +import Testing +@testable import VoidDisplay + +private final class CaptureSharingIsolationDummySession: DisplayCaptureSessioning, @unchecked Sendable { + nonisolated let sessionHub = WebRTCSessionHub() + + nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { + _ = sink + } + + nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { + _ = sink + } + + nonisolated func stopSharing() {} + + nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { + _ = showsCursor + } + + nonisolated func retainShareCursorOverride() async throws {} + + nonisolated func releaseShareCursorOverride() async throws {} + + nonisolated func stop() async {} +} + +@MainActor +private final class IsolationPortPreferences: SharingPortPreferencesProtocol { + var preferredPort: UInt16 = 8081 + + func savePreferredPort(_ port: UInt16) { + preferredPort = port + } +} + +@Suite(.serialized) +@MainActor +struct CaptureSharingIsolationTests { + @Test func captureMutationsDoNotRewriteSharingSnapshot() async { + let sharingService = MockSharingService() + let sharedDisplay: CGDirectDisplayID = 901 + sharingService.activeSharingDisplayIDs = [sharedDisplay] + sharingService.activeStreamClientCount = 3 + sharingService.hasAnyActiveSharing = true + sharingService.startResult = .started( + WebServiceBinding(requestedPort: 8081, boundPort: 8081) + ) + let sharingController = SharingController( + sharingService: sharingService, + portPreferences: IsolationPortPreferences() + ) + _ = await sharingController.startWebService(requestedPort: 8081) + + let captureService = MockCaptureMonitoringService() + let captureSession = makeSession(id: UUID(), displayID: 777) + captureService.currentSessions = [captureSession] + let captureController = CaptureController(captureMonitoringService: captureService) + + captureController.markMonitoringSessionActive(id: captureSession.id) + captureController.setMonitoringSessionCapturesCursor(id: captureSession.id, capturesCursor: true) + + #expect(sharingController.isWebServiceRunning) + #expect(sharingController.isSharing) + #expect(sharingController.sharingClientCount == 3) + #expect(sharingController.activeSharingDisplayIDs == [sharedDisplay]) + #expect(sharingService.stopSharingCallCount == 0) + #expect(sharingService.stopAllSharingCallCount == 0) + } + + @Test func sharingMutationsDoNotRewriteCaptureSessions() async { + let captureService = MockCaptureMonitoringService() + let first = makeSession(id: UUID(), displayID: 1001) + let second = makeSession(id: UUID(), displayID: 1002) + captureService.currentSessions = [first, second] + let captureController = CaptureController(captureMonitoringService: captureService) + + let sharingService = MockSharingService() + sharingService.activeSharingDisplayIDs = [1001] + sharingService.activeStreamClientCount = 2 + sharingService.hasAnyActiveSharing = true + sharingService.startResult = .started( + WebServiceBinding(requestedPort: 8081, boundPort: 8081) + ) + let sharingController = SharingController( + sharingService: sharingService, + portPreferences: IsolationPortPreferences() + ) + _ = await sharingController.startWebService(requestedPort: 8081) + sharingController.stopAllSharing() + sharingController.stopWebService() + + #expect(captureController.screenCaptureSessions.map(\.id) == [first.id, second.id]) + #expect(captureController.monitoringSession(for: first.id)?.displayID == 1001) + #expect(captureController.monitoringSession(for: second.id)?.displayID == 1002) + #expect(captureService.removeCallCount == 0) + #expect(captureService.removeByDisplayCallCount == 0) + } + + private func makeSession(id: UUID, displayID: CGDirectDisplayID) -> ScreenMonitoringSession { + ScreenMonitoringSession( + id: id, + displayID: displayID, + displayName: "Display \(displayID)", + resolutionText: "1920 x 1080", + isVirtualDisplay: false, + previewSubscription: DisplayPreviewSubscription( + displayID: displayID, + resolutionText: "1920 x 1080", + session: CaptureSharingIsolationDummySession(), + cancelClosure: {} + ), + capturesCursor: false, + state: .starting + ) + } +} diff --git a/VoidDisplayTests/Features/Capture/Views/CapturePreviewGeometryTests.swift b/VoidDisplayTests/Features/Capture/Views/CapturePreviewGeometryTests.swift new file mode 100644 index 0000000..516d25a --- /dev/null +++ b/VoidDisplayTests/Features/Capture/Views/CapturePreviewGeometryTests.swift @@ -0,0 +1,111 @@ +import CoreGraphics +import Testing +@testable import VoidDisplay + +@Suite(.serialized) +struct CapturePreviewGeometryTests { + @Test func parseResolutionAcceptsCommonSeparators() { + #expect(CapturePreviewGeometry.parseResolution("2560 × 1600") == CGSize(width: 2560, height: 1600)) + #expect(CapturePreviewGeometry.parseResolution("1920x1080") == CGSize(width: 1920, height: 1080)) + #expect(CapturePreviewGeometry.parseResolution("3440*1440") == CGSize(width: 3440, height: 1440)) + } + + @Test func preferredAspectPrefersResolutionTextAndFallsBackToFrame() { + #expect( + CapturePreviewGeometry.preferredAspect( + resolutionText: "3008 x 1692", + framePixelSize: CGSize(width: 1920, height: 1080) + ) == CGSize(width: 3008, height: 1692) + ) + #expect( + CapturePreviewGeometry.preferredAspect( + resolutionText: "bad-value", + framePixelSize: CGSize(width: 1920, height: 1080) + ) == CGSize(width: 1920, height: 1080) + ) + } + + @Test func nativeFrameSizeInPointsUsesScaleFactor() { + let size = CapturePreviewGeometry.nativeFrameSizeInPoints( + framePixelSize: CGSize(width: 2560, height: 1600), + scaleFactor: 2, + fallbackAspect: CGSize(width: 16, height: 10) + ) + #expect(size == CGSize(width: 1280, height: 800)) + } + + @Test func nativeFrameSizeInPointsFallsBackToAspectWhenFrameMissing() { + let size = CapturePreviewGeometry.nativeFrameSizeInPoints( + framePixelSize: .zero, + scaleFactor: 2, + fallbackAspect: CGSize(width: 3008, height: 1692) + ) + #expect(size == CGSize(width: 3008, height: 1692)) + } + + @Test func initialContentSizeRespectsTargetWidthOverrideAndInsets() { + let size = CapturePreviewGeometry.initialContentSize( + input: .init( + aspect: CGSize(width: 2560, height: 1600), + framePixelSize: .zero, + targetContentWidth: 860, + visibleFrameSize: CGSize(width: 1600, height: 1000), + chromeSize: CGSize(width: 40, height: 60), + layoutInsetSize: CGSize(width: 12, height: 8), + scaleFactor: 2 + ) + ) + + #expect(size?.width == 872) + #expect(approximatelyEqual(size?.height, 545.5, tolerance: 0.01)) + } + + @Test func initialContentSizeClampsToVisibleBounds() { + let size = CapturePreviewGeometry.initialContentSize( + input: .init( + aspect: CGSize(width: 16, height: 9), + framePixelSize: .zero, + targetContentWidth: 1800, + visibleFrameSize: CGSize(width: 1000, height: 700), + chromeSize: CGSize(width: 40, height: 60), + layoutInsetSize: CGSize(width: 0, height: 0), + scaleFactor: 2 + ) + ) + + #expect(approximatelyEqual(size?.width, 944, tolerance: 0.01)) + #expect(approximatelyEqual(size?.height, 531, tolerance: 0.01)) + } + + @Test func aspectLockedContentSizeKeepsRatioAndInsets() { + let size = CapturePreviewGeometry.aspectLockedContentSize( + aspect: CGSize(width: 16, height: 10), + proposedContentSize: CGSize(width: 1000, height: 700), + layoutInsetSize: CGSize(width: 20, height: 10), + scaleFactor: 2 + ) + + #expect(approximatelyEqual(size?.width, 1000, tolerance: 0.01)) + #expect(approximatelyEqual(size?.height, 622.5, tolerance: 0.01)) + } + + @Test func aspectLockedContentSizeReturnsNilWhenAspectInvalid() { + #expect( + CapturePreviewGeometry.aspectLockedContentSize( + aspect: .zero, + proposedContentSize: CGSize(width: 1000, height: 700), + layoutInsetSize: CGSize(width: 20, height: 10), + scaleFactor: 2 + ) == nil + ) + } +} + +private func approximatelyEqual( + _ lhs: CGFloat?, + _ rhs: CGFloat, + tolerance: CGFloat +) -> Bool { + guard let lhs else { return false } + return abs(lhs - rhs) <= tolerance +} diff --git a/VoidDisplayTests/Shared/CapturePreviewDiagnosticsRuntimeTests.swift b/VoidDisplayTests/Shared/CapturePreviewDiagnosticsRuntimeTests.swift index fef3c07..208475f 100644 --- a/VoidDisplayTests/Shared/CapturePreviewDiagnosticsRuntimeTests.swift +++ b/VoidDisplayTests/Shared/CapturePreviewDiagnosticsRuntimeTests.swift @@ -8,13 +8,15 @@ struct CapturePreviewDiagnosticsRuntimeTests { let configuration = CapturePreviewDiagnosticsRuntime.configuration( environment: [ CapturePreviewDiagnosticsRuntime.sourceSizeEnvironmentKey: "3008x1692", - CapturePreviewDiagnosticsRuntime.targetContentWidthEnvironmentKey: "1180" + CapturePreviewDiagnosticsRuntime.targetContentWidthEnvironmentKey: "1180", + CapturePreviewDiagnosticsRuntime.scaleModeEnvironmentKey: "native" ] ) #expect(configuration?.sourcePixelSize == CGSize(width: 3008, height: 1692)) #expect(configuration?.targetContentWidth == 1180) #expect(configuration?.replayImageURL == nil) + #expect(configuration?.initialScaleMode == .native) } @Test @MainActor func parsedSizeAcceptsMultipleSeparators() { @@ -28,4 +30,14 @@ struct CapturePreviewDiagnosticsRuntimeTests { ) #expect(CapturePreviewDiagnosticsRuntime.parsedSize(from: "bad-value") == nil) } + + @Test @MainActor func configurationIgnoresInvalidScaleMode() { + let configuration = CapturePreviewDiagnosticsRuntime.configuration( + environment: [ + CapturePreviewDiagnosticsRuntime.scaleModeEnvironmentKey: "stretch" + ] + ) + + #expect(configuration?.initialScaleMode == nil) + } } diff --git a/VoidDisplayUITests/Diagnostics/CapturePreviewDiagnosticsTests.swift b/VoidDisplayUITests/Diagnostics/CapturePreviewDiagnosticsTests.swift index 7f35241..aa9f638 100644 --- a/VoidDisplayUITests/Diagnostics/CapturePreviewDiagnosticsTests.swift +++ b/VoidDisplayUITests/Diagnostics/CapturePreviewDiagnosticsTests.swift @@ -1,6 +1,31 @@ import XCTest final class CapturePreviewDiagnosticsTests: XCTestCase { + private enum PreviewScaleMode: String, CaseIterable { + case fit + case native + + var segmentLabel: String { + switch self { + case .fit: + "Fit" + case .native: + "1:1" + } + } + + static func fromAccessibilityValue(_ rawValue: String) -> Self? { + let normalized = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if normalized.contains("fit") { + return .fit + } + if normalized.contains("native") || normalized.contains("1:1") { + return .native + } + return nil + } + } + private struct DiagnosticCase { let id: String let sourceSize: String @@ -20,12 +45,25 @@ final class CapturePreviewDiagnosticsTests: XCTestCase { } @MainActor - func testCapturePreviewLayoutMatrix() throws { + func testCapturePreviewLayoutMatrixFit() throws { + runLayoutMatrix(scaleMode: .fit) + } + + @MainActor + func testCapturePreviewLayoutMatrixNative() throws { + runLayoutMatrix(scaleMode: .native) + } +} + +private extension CapturePreviewDiagnosticsTests { + @MainActor + private func runLayoutMatrix(scaleMode: PreviewScaleMode) { for testCase in diagnosticCases { - XCTContext.runActivity(named: testCase.id) { _ in + XCTContext.runActivity(named: "\(testCase.id)-\(scaleMode.rawValue)") { _ in let app = launchCapturePreviewDiagnosticsApp( sourceSize: testCase.sourceSize, - targetContentWidth: testCase.targetContentWidth + targetContentWidth: testCase.targetContentWidth, + scaleMode: scaleMode ) defer { app.terminate() } @@ -33,22 +71,25 @@ final class CapturePreviewDiagnosticsTests: XCTestCase { let scalePicker = smokeElement(app, identifier: "capture_preview_scale_mode_picker") XCTAssertTrue(scalePicker.waitForExistence(timeout: 4)) XCTAssertTrue(preview.waitForExistence(timeout: 4)) + assertScaleModeSelection( + scalePicker: scalePicker, + expectedMode: scaleMode + ) let screenshot = preview.screenshot() let attachment = XCTAttachment(screenshot: screenshot) - attachment.name = "capture-preview-\(testCase.id)" + attachment.name = "capture-preview-\(scaleMode.rawValue)-\(testCase.id)" attachment.lifetime = .keepAlways add(attachment) } } } -} -private extension CapturePreviewDiagnosticsTests { @MainActor - func launchCapturePreviewDiagnosticsApp( + private func launchCapturePreviewDiagnosticsApp( sourceSize: String, - targetContentWidth: Int + targetContentWidth: Int, + scaleMode: PreviewScaleMode ) -> XCUIApplication { let app = XCUIApplication() app.launchEnvironment["VOIDDISPLAY_UI_TEST_MODE"] = "1" @@ -56,7 +97,71 @@ private extension CapturePreviewDiagnosticsTests { app.launchEnvironment["VOIDDISPLAY_UI_TEST_SCENARIO"] = "capture_preview_diagnostics" app.launchEnvironment["VOIDDISPLAY_CAPTURE_PREVIEW_SOURCE_SIZE"] = sourceSize app.launchEnvironment["VOIDDISPLAY_CAPTURE_PREVIEW_TARGET_CONTENT_WIDTH"] = String(targetContentWidth) + app.launchEnvironment["VOIDDISPLAY_CAPTURE_PREVIEW_SCALE_MODE"] = scaleMode.rawValue app.launch() return app } + + @MainActor + private func assertScaleModeSelection( + scalePicker: XCUIElement, + expectedMode: PreviewScaleMode, + file: StaticString = #filePath, + line: UInt = #line + ) { + let actualMode = selectedScaleMode(from: scalePicker) + XCTAssertEqual( + actualMode, + expectedMode, + """ + Scale mode mismatch. expected=\(expectedMode.rawValue), actual=\(actualMode?.rawValue ?? "nil"), \ + pickerValue=\(String(describing: scalePicker.value)), picker=\(scalePicker.debugDescription) + """, + file: file, + line: line + ) + guard let actualMode else { return } + XCTContext.runActivity( + named: "Scale mode assertion passed: expected=\(expectedMode.rawValue), actual=\(actualMode.rawValue)" + ) { _ in } + } + + @MainActor + private func selectedScaleMode(from scalePicker: XCUIElement) -> PreviewScaleMode? { + if let value = scalePicker.value { + let text = String(describing: value) + if let mode = PreviewScaleMode.fromAccessibilityValue(text) { + return mode + } + } + + for mode in PreviewScaleMode.allCases { + let labeledElements = scalePicker + .descendants(matching: .any) + .matching(NSPredicate(format: "label == %@", mode.segmentLabel)) + .allElementsBoundByIndex + if labeledElements.contains(where: isAccessibilityElementSelected) { + return mode + } + } + return nil + } + + @MainActor + private func isAccessibilityElementSelected(_ element: XCUIElement) -> Bool { + if element.isSelected { + return true + } + guard let value = element.value else { return false } + let normalized = String(describing: value) + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + if normalized == "1" || normalized == "true" { + return true + } + if normalized.contains("selected") || normalized.contains("on") { + return true + } + return false + } } diff --git a/docs/capture_preview_black_bar_fix_notes.md b/docs/capture_preview_black_bar_fix_notes.md index c399c7d..9a6daf7 100644 --- a/docs/capture_preview_black_bar_fix_notes.md +++ b/docs/capture_preview_black_bar_fix_notes.md @@ -168,6 +168,23 @@ window.frameRect(forContentRect: targetContentSize) - [capture_preview_self_check.sh](/Users/syc/Project/VoidDisplay/scripts/test/capture_preview_self_check.sh) - [capture_preview_analyze.swift](/Users/syc/Project/VoidDisplay/scripts/test/capture_preview_analyze.swift) +### 诊断环境变量说明 + +以下环境变量仅用于预览诊断与 UI 测试场景: + +| 变量名 | 取值示例 | 含义 | +| --- | --- | --- | +| `VOIDDISPLAY_CAPTURE_PREVIEW_SOURCE_SIZE` | `3008x1692` | 注入的诊断画面像素尺寸。 | +| `VOIDDISPLAY_CAPTURE_PREVIEW_TARGET_CONTENT_WIDTH` | `1180` | 初始窗口目标内容宽度覆盖值(point)。 | +| `VOIDDISPLAY_CAPTURE_PREVIEW_REPLAY_IMAGE_PATH` | `/abs/path/frame.png` | 用指定图片替代内置诊断图。 | +| `VOIDDISPLAY_CAPTURE_PREVIEW_RECORD_DIRECTORY` | `/abs/path/recordings` | 预览录制输出目录。 | +| `VOIDDISPLAY_CAPTURE_PREVIEW_SCALE_MODE` | `fit` 或 `native` | 预览缩放模式。`fit` 表示适应模式,`native` 表示 `1:1` 模式。 | + +使用建议: + +1. 常规自检直接运行 `zsh scripts/test/capture_preview_self_check.sh`,脚本会自动跑 `fit` 与 `native` 两轮。 +2. 手动跑单轮 UI 诊断时,通过 app launch environment 设置 `VOIDDISPLAY_CAPTURE_PREVIEW_SCALE_MODE`。 + ### 自验证思路 1. UI test 场景下注入假的监听会话 diff --git a/docs/capture_sharing_refactor_retrospective.md b/docs/capture_sharing_refactor_retrospective.md index d09999f..343942c 100644 --- a/docs/capture_sharing_refactor_retrospective.md +++ b/docs/capture_sharing_refactor_retrospective.md @@ -3,9 +3,10 @@ ## 1. 背景与结论 这份复盘文档对应的对象是未合并分支 `codex/capture-cursor-config-serialize`。 -`codex/extract-capture-cursor-config-serialize` 当前与 `main` 几乎没有实现差异,不能代表那次失败重构的主体。 +截至 2026-03-19,`codex/extract-capture-cursor-config-serialize` 与 `main` 几乎没有实现差异,不能代表那次失败重构的主体。 -这次重构最终放弃合并,核心原因很直接:结构风险大于可保留收益。分支试图同时重写屏幕监听、屏幕共享、Web 服务生命周期、共享注册、主屏别名路由、窗口预览几何、测试隔离与回退机制,变更面过大,耦合面过密,后续只能靠连续补丁维持稳定。 +这次重构最终放弃合并,核心原因很直接:结构风险大于可保留收益。分支试图同时重写屏幕监听、屏幕共享、Web 服务生命周期、共享注册、主屏别名路由、窗口预览几何、测试隔离与回退机制,变更面过大,耦合面过密,后续只能靠连续补丁维持稳定。 +这份复盘的重点是给下一轮重构划清边界,明确不可为方向,同时保留可以复用的做法与资产。 可以直接作为证据的时间线如下: @@ -266,6 +267,17 @@ `/display`、`/signal` 这类主屏别名只在共享主链路稳定后才有资格进入。 下一轮重构的第一批目标里不应包含这类语义扩展。 +### 5.5 失败分支里可取且可复用的资产 + +结论分类:应该做 + +这次分支失败,仍有几类资产值得保留到下一轮重构: + +1. 并发语义测试样例。`ScreenPipelineRuntimeTests.swift` 中围绕 `concurrent`、`cancelled`、`waitingForRegistrationSlot`、`webServiceStopping` 的场景覆盖,适合迁移为监听链路和共享链路各自的回归基线。 +2. 预览自验证链路。`CapturePreviewDiagnosticsRuntime`、预览录制 sink、`scripts/test/capture_preview_self_check.sh`、`scripts/test/capture_preview_analyze.swift` 已经证明能量化几何与渲染问题,应该直接复用。 +3. 测试隔离约束。`ea383d5`、`a086cbf` 对“测试期间不触发录屏授权弹窗”的处理是有效约束,下一轮必须继续保持。 +4. 低层原语抽离方向。`DisplayCaptureRegistry`、`DisplayCaptureSession` 这类底层能力可以继续保留为公共层输入,前提是禁止把监听与共享的上层状态语义重新绑回同一运行时入口。 + ## 6. 重构重启门槛 ### 6.1 分阶段顺序 @@ -312,10 +324,11 @@ ### 6.5 最终约束清单 -为了避免 `main` 的下一轮重构重蹈覆辙,执行前必须先确认下面三类结论: +为了避免 `main` 的下一轮重构重蹈覆辙,执行前必须先确认下面四类结论: 1. 不应该做:一次性统一监听、共享、Web 生命周期、共享注册、窗口几何。 2. 应该做:按链路分治,先补测试,再做结构调整,公共层只保留稳定原语。 3. 过度设计:总 runtime、长期运行注册协调器、主屏别名规则、围绕收敛建立的大量补偿路径。 +4. 可取资产:并发回归样例、预览自验证链路、测试授权隔离、低层采集原语抽离。 只要重构方案重新出现这些特征,就应该立刻停下,重新拆分范围。 diff --git a/scripts/test/capture_preview_analyze.swift b/scripts/test/capture_preview_analyze.swift index 4076665..2b5ab35 100644 --- a/scripts/test/capture_preview_analyze.swift +++ b/scripts/test/capture_preview_analyze.swift @@ -20,15 +20,26 @@ struct RGBAColor { } } +enum AnalyzerMode: String { + case fit + case native +} + enum AnalyzerError: LocalizedError { case missingArgument + case invalidMode(String) + case invalidArgument(String) case imageLoadFailed(String) case bitmapUnavailable(String) var errorDescription: String? { switch self { case .missingArgument: - return "Usage: capture_preview_analyze.swift " + return "Usage: capture_preview_analyze.swift [--mode fit|native] " + case .invalidMode(let mode): + return "Unsupported mode: \(mode). Expected fit or native." + case .invalidArgument(let argument): + return "Invalid argument: \(argument)" case .imageLoadFailed(let path): return "Failed to load image at path: \(path)" case .bitmapUnavailable(let path): @@ -37,6 +48,22 @@ enum AnalyzerError: LocalizedError { } } +struct AnalyzerArguments { + let mode: AnalyzerMode + let imagePath: String +} + +struct EdgeObservation { + let name: String + let distance: Double + let actual: RGBAColor +} + +struct CornerObservation { + let name: String + let distance: Double +} + let expectedColors: [String: RGBAColor] = [ "left": .init(red: 0.92, green: 0.32, blue: 0.27), "right": .init(red: 0.20, green: 0.46, blue: 0.96), @@ -55,11 +82,9 @@ let circleColor = RGBAColor(red: 0.82, green: 0.16, blue: 0.66) let circleTolerance = 0.34 func main() throws { - guard CommandLine.arguments.count >= 2 else { - throw AnalyzerError.missingArgument - } + let arguments = try parseArguments() - let imagePath = URL(fileURLWithPath: CommandLine.arguments[1]).standardizedFileURL.path + let imagePath = URL(fileURLWithPath: arguments.imagePath).standardizedFileURL.path let bitmap = try loadBitmap(path: imagePath) let width = bitmap.pixelsWide let height = bitmap.pixelsHigh @@ -71,7 +96,7 @@ func main() throws { ("bottom", normalizedRect(0.10, 0.96, 0.80, 0.04, imageWidth: width, imageHeight: height)) ] - var failures: [String] = [] + var edgeObservations: [EdgeObservation] = [] for (name, rect) in edgeSearchRegions { let expected = expectedColors[name]! let (distance, actual) = nearestColorMatch( @@ -79,12 +104,7 @@ func main() throws { rect: rect, expected: expected ) - if distance > colorTolerance { - failures.append("\(name) expected close to diagnostic color, actual=(\(format(actual.red)), \(format(actual.green)), \(format(actual.blue)))") - } - if (name == "left" || name == "right") && actual.luminance < blackLuminanceThreshold { - failures.append("\(name) edge looks black, likely side letterboxing remains") - } + edgeObservations.append(.init(name: name, distance: distance, actual: actual)) } let cornerSearchRegions: [(String, CGRect)] = [ @@ -94,21 +114,82 @@ func main() throws { ("bottomRightCorner", normalizedRect(0.78, 0.78, 0.20, 0.20, imageWidth: width, imageHeight: height)) ] + var cornerObservations: [CornerObservation] = [] for (name, rect) in cornerSearchRegions { - let distance = nearestColorDistance( - bitmap: bitmap, - rect: rect, - expected: expectedColors[name]! - ) - if distance > cornerTolerance { - failures.append("\(name) marker not found in expected quadrant") - } + let distance = nearestColorDistance(bitmap: bitmap, rect: rect, expected: expectedColors[name]!) + cornerObservations.append(.init(name: name, distance: distance)) } let circleBounds = detectMagentaCircleBounds( bitmap: bitmap, searchRect: normalizedRect(0.25, 0.25, 0.50, 0.50, imageWidth: width, imageHeight: height) ) + let centerColor = averageColor(bitmap: bitmap, normalizedX: 0.5, normalizedY: 0.5, radius: 4) + + var leftBlackColumns = 0 + var rightBlackColumns = 0 + if arguments.mode == .fit { + leftBlackColumns = leadingBlackColumns(bitmap: bitmap, normalizedY: 0.5) + rightBlackColumns = trailingBlackColumns(bitmap: bitmap, normalizedY: 0.5) + } + + let failures = switch arguments.mode { + case .fit: + validateFit( + edgeObservations: edgeObservations, + cornerObservations: cornerObservations, + circleBounds: circleBounds, + leftBlackColumns: leftBlackColumns, + rightBlackColumns: rightBlackColumns, + imageWidth: width + ) + case .native: + validateNative( + edgeObservations: edgeObservations, + cornerObservations: cornerObservations, + circleBounds: circleBounds, + centerColor: centerColor + ) + } + + if failures.isEmpty { + print( + "PASS mode=\(arguments.mode.rawValue) \(imagePath) size=\(width)x\(height) leftBlack=\(leftBlackColumns) rightBlack=\(rightBlackColumns)" + ) + return + } + + print("FAIL mode=\(arguments.mode.rawValue) \(imagePath)") + for failure in failures { + print(" - \(failure)") + } + exit(1) +} + +func validateFit( + edgeObservations: [EdgeObservation], + cornerObservations: [CornerObservation], + circleBounds: CGRect?, + leftBlackColumns: Int, + rightBlackColumns: Int, + imageWidth: Int +) -> [String] { + var failures: [String] = [] + + for observation in edgeObservations where observation.distance > colorTolerance { + failures.append( + "\(observation.name) expected close to diagnostic color, actual=(\(format(observation.actual.red)), \(format(observation.actual.green)), \(format(observation.actual.blue)))" + ) + } + for observation in edgeObservations + where (observation.name == "left" || observation.name == "right") + && observation.actual.luminance < blackLuminanceThreshold { + failures.append("\(observation.name) edge looks black, likely side letterboxing remains") + } + for observation in cornerObservations where observation.distance > cornerTolerance { + failures.append("\(observation.name) marker not found in expected quadrant") + } + if let circleBounds { let ratio = Double(circleBounds.width) / Double(circleBounds.height) if abs(ratio - 1) > 0.12 { @@ -118,25 +199,86 @@ func main() throws { failures.append("failed to detect center circle") } - let leftBlackColumns = leadingBlackColumns(bitmap: bitmap, normalizedY: 0.5) - let rightBlackColumns = trailingBlackColumns(bitmap: bitmap, normalizedY: 0.5) - if leftBlackColumns > max(2, width / 200) { + if leftBlackColumns > max(2, imageWidth / 200) { failures.append("left black bar width=\(leftBlackColumns)px") } - if rightBlackColumns > max(2, width / 200) { + if rightBlackColumns > max(2, imageWidth / 200) { failures.append("right black bar width=\(rightBlackColumns)px") } + return failures +} - if failures.isEmpty { - print("PASS \(imagePath) size=\(width)x\(height) leftBlack=\(leftBlackColumns) rightBlack=\(rightBlackColumns)") - return +func validateNative( + edgeObservations: [EdgeObservation], + cornerObservations: [CornerObservation], + circleBounds: CGRect?, + centerColor: RGBAColor +) -> [String] { + var failures: [String] = [] + + let rightEdgeClipped = edgeObservations.first(where: { $0.name == "right" })?.distance ?? 0 > colorTolerance + let topRightMissing = cornerObservations.first(where: { $0.name == "topRightCorner" })?.distance ?? 0 > cornerTolerance + let bottomRightMissing = cornerObservations.first(where: { $0.name == "bottomRightCorner" })?.distance ?? 0 > cornerTolerance + let centerCircleClipped = circleBounds == nil + + let clippingSignals = [ + rightEdgeClipped, + topRightMissing, + bottomRightMissing, + centerCircleClipped + ] + let clippingSignalCount = clippingSignals.filter { $0 }.count + if clippingSignalCount < 3 { + failures.append( + "native mode clipping signature too weak: expected at least 3 clipped signals, actual=\(clippingSignalCount)" + ) } - print("FAIL \(imagePath)") - for failure in failures { - print(" - \(failure)") + if centerColor.luminance < blackLuminanceThreshold { + failures.append("native mode center region is unexpectedly dark") } - exit(1) + + return failures +} + +func parseArguments() throws -> AnalyzerArguments { + let rawArguments = Array(CommandLine.arguments.dropFirst()) + guard !rawArguments.isEmpty else { + throw AnalyzerError.missingArgument + } + + var mode: AnalyzerMode = .fit + var imagePath: String? + var index = 0 + + while index < rawArguments.count { + let argument = rawArguments[index] + if argument == "--mode" { + guard index + 1 < rawArguments.count else { + throw AnalyzerError.missingArgument + } + let rawMode = rawArguments[index + 1].lowercased() + guard let resolvedMode = AnalyzerMode(rawValue: rawMode) else { + throw AnalyzerError.invalidMode(rawMode) + } + mode = resolvedMode + index += 2 + continue + } + + if imagePath == nil { + imagePath = argument + index += 1 + continue + } + + throw AnalyzerError.invalidArgument(argument) + } + + guard let imagePath else { + throw AnalyzerError.missingArgument + } + return AnalyzerArguments(mode: mode, imagePath: imagePath) } func loadBitmap(path: String) throws -> NSBitmapImageRep { diff --git a/scripts/test/capture_preview_self_check.sh b/scripts/test/capture_preview_self_check.sh index 9307fb1..3ee7477 100644 --- a/scripts/test/capture_preview_self_check.sh +++ b/scripts/test/capture_preview_self_check.sh @@ -3,43 +3,68 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" TMP_DIR="$ROOT_DIR/.ai-tmp/capture-preview-check" -DERIVED_DATA_DIR="$ROOT_DIR/.ai-tmp/capture-preview-check/DerivedData" -BUILD_LOG="$ROOT_DIR/.ai-tmp/capture-preview-check/test.log" -ATTACHMENTS_DIR="$ROOT_DIR/.ai-tmp/capture-preview-check/attachments" mkdir -p "$TMP_DIR" -find "$TMP_DIR" -maxdepth 1 -name '*.png' -delete -rm -rf "$ATTACHMENTS_DIR" - -xcodebuild \ - -project "$ROOT_DIR/VoidDisplay.xcodeproj" \ - -scheme VoidDisplay \ - -configuration Debug \ - -derivedDataPath "$DERIVED_DATA_DIR" \ - -destination 'platform=macOS' \ - -only-testing:VoidDisplayUITests/CapturePreviewDiagnosticsTests/testCapturePreviewLayoutMatrix \ - test \ - > "$BUILD_LOG" 2>&1 - -RESULT_BUNDLE="$(find "$DERIVED_DATA_DIR/Logs/Test" -maxdepth 1 -name '*.xcresult' | sort | tail -n 1)" -if [[ -z "$RESULT_BUNDLE" ]]; then - echo "No xcresult bundle was generated. See $BUILD_LOG" >&2 - exit 1 -fi - -xcrun xcresulttool export attachments \ - --path "$RESULT_BUNDLE" \ - --output-path "$ATTACHMENTS_DIR" \ - > /dev/null 2>&1 - -typeset -a images -images=("$ATTACHMENTS_DIR"/*.png(N)) - -if (( ${#images[@]} == 0 )); then - echo "No capture preview diagnostic screenshots were generated. See $BUILD_LOG and $RESULT_BUNDLE" >&2 - exit 1 -fi - -for image in "${images[@]}"; do - swift "$ROOT_DIR/scripts/test/capture_preview_analyze.swift" "$image" -done + +# This self-check always runs both diagnostics scale modes: +# - fit: adaptive scaling +# - native: 1:1 scaling +# If you need a single mode for debugging, run `run_mode ` manually. + +run_mode() { + local mode="$1" + local test_name + if [[ "$mode" == "fit" ]]; then + test_name="testCapturePreviewLayoutMatrixFit" + elif [[ "$mode" == "native" ]]; then + test_name="testCapturePreviewLayoutMatrixNative" + else + echo "Unsupported mode: $mode" >&2 + exit 1 + fi + + local mode_dir="$TMP_DIR/$mode" + local derived_data_dir="$mode_dir/DerivedData" + local build_log="$mode_dir/test.log" + local attachments_dir="$mode_dir/attachments" + + rm -rf "$mode_dir" + mkdir -p "$mode_dir" + + xcodebuild \ + -project "$ROOT_DIR/VoidDisplay.xcodeproj" \ + -scheme VoidDisplay \ + -configuration Debug \ + -derivedDataPath "$derived_data_dir" \ + -destination 'platform=macOS' \ + -only-testing:VoidDisplayUITests/CapturePreviewDiagnosticsTests/$test_name \ + test \ + > "$build_log" 2>&1 + + local result_bundle + result_bundle="$(find "$derived_data_dir/Logs/Test" -maxdepth 1 -name '*.xcresult' | sort | tail -n 1)" + if [[ -z "$result_bundle" ]]; then + echo "No xcresult bundle for mode=$mode. See $build_log" >&2 + exit 1 + fi + + xcrun xcresulttool export attachments \ + --path "$result_bundle" \ + --output-path "$attachments_dir" \ + > /dev/null 2>&1 + + typeset -a images + images=("$attachments_dir"/*.png(N)) + + if (( ${#images[@]} == 0 )); then + echo "No capture preview screenshots for mode=$mode. See $build_log and $result_bundle" >&2 + exit 1 + fi + + for image in "${images[@]}"; do + swift "$ROOT_DIR/scripts/test/capture_preview_analyze.swift" --mode "$mode" "$image" + done +} + +run_mode fit +run_mode native From 4de1e5343f18adba97271b532d82e0ff894ed87e Mon Sep 17 00:00:00 2001 From: Chen Date: Thu, 19 Mar 2026 23:06:19 +0800 Subject: [PATCH 04/34] =?UTF-8?q?fix(capture):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E7=9B=91=E5=90=AC=E9=A2=84=E8=A7=88=E7=AA=97=E5=8F=A3=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=B0=BA=E5=AF=B8=E4=B8=8E=E5=85=89=E6=A0=87=E5=BC=80?= =?UTF-8?q?=E5=85=B3=E5=B1=95=E7=A4=BA=20-=20=E5=B0=86=E7=9B=91=E5=90=AC?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E7=AA=97=E5=8F=A3=E9=BB=98=E8=AE=A4=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=AE=BD=E5=BA=A6=E6=AF=94=E4=BE=8B=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E4=B8=BA=E5=8F=AF=E8=A7=81=E5=AE=BD=E5=BA=A6=E7=9A=84=2070%=20?= =?UTF-8?q?-=20=E4=BC=98=E5=8C=96=E6=A0=87=E9=A2=98=E6=A0=8F=E5=85=89?= =?UTF-8?q?=E6=A0=87=E5=BC=80=E5=85=B3=E7=9A=84=E6=96=87=E5=AD=97=E4=B8=8E?= =?UTF-8?q?=E9=97=B4=E8=B7=9D=E4=BB=A5=E6=94=B9=E5=96=84=E5=8F=AF=E8=AF=BB?= =?UTF-8?q?=E6=80=A7=20-=20=E5=8C=85=E5=90=AB=E6=9C=AC=E5=9C=B0=E5=8C=96?= =?UTF-8?q?=E5=AD=97=E7=AC=A6=E4=B8=B2=E6=96=87=E4=BB=B6=E7=9A=84=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Features/Capture/Views/CaptureDisplayView.swift | 11 ++++++++--- .../Capture/Views/CapturePreviewGeometry.swift | 2 +- VoidDisplay/Resources/Localizable.xcstrings | 8 +++++++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift index efa4e9d..d6b6657 100644 --- a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift +++ b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift @@ -100,15 +100,20 @@ struct CaptureDisplayView: View { .accessibilityValue(Text(scaleMode == .fit ? "fit" : "native")) } ToolbarItem(placement: .automatic) { - HStack(spacing: 6) { + HStack(spacing: AppUI.Spacing.small + 2) { Text(String(localized: "Cursor")) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + .fixedSize() Toggle("", isOn: cursorCaptureBinding) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) - .disabled(isUpdatingCursorCapture || isSharingDisplay) - .accessibilityIdentifier("capture_preview_cursor_toggle") + .accessibilityLabel(String(localized: "Cursor")) } + .padding(.horizontal, AppUI.Spacing.xSmall) + .disabled(isUpdatingCursorCapture || isSharingDisplay) + .accessibilityIdentifier("capture_preview_cursor_toggle") } } .toolbarTitleDisplayMode(.inline) diff --git a/VoidDisplay/Features/Capture/Views/CapturePreviewGeometry.swift b/VoidDisplay/Features/Capture/Views/CapturePreviewGeometry.swift index 1856022..b7d6db1 100644 --- a/VoidDisplay/Features/Capture/Views/CapturePreviewGeometry.swift +++ b/VoidDisplay/Features/Capture/Views/CapturePreviewGeometry.swift @@ -62,7 +62,7 @@ struct CapturePreviewGeometry { let ratio = input.aspect.width / input.aspect.height let scale = max(1, input.scaleFactor) let pixelSize = input.framePixelSize - let defaultPreviewWidth = max(320, maxPreviewWidth * 0.85) + let defaultPreviewWidth = max(320, maxPreviewWidth * 0.70) let defaultPreviewHeight = defaultPreviewWidth / ratio var previewWidth = defaultPreviewWidth var previewHeight = defaultPreviewHeight diff --git a/VoidDisplay/Resources/Localizable.xcstrings b/VoidDisplay/Resources/Localizable.xcstrings index c34a66a..0fbfd52 100644 --- a/VoidDisplay/Resources/Localizable.xcstrings +++ b/VoidDisplay/Resources/Localizable.xcstrings @@ -794,6 +794,9 @@ } } } + }, + "fit" : { + }, "Fit" : { "localizations" : { @@ -986,6 +989,9 @@ } } } + }, + "native" : { + }, "No available display can be monitored right now." : { "localizations" : { @@ -1994,4 +2000,4 @@ } }, "version" : "1.1" -} +} \ No newline at end of file From 6bd2ccace4ce5b443d59c5b13d81d06433087353 Mon Sep 17 00:00:00 2001 From: Chen Date: Thu, 19 Mar 2026 23:27:16 +0800 Subject: [PATCH 05/34] =?UTF-8?q?fix(capture):=20=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=E7=9B=91=E5=90=AC=E7=BC=A9=E6=94=BE=E6=A8=A1=E5=BC=8F=E6=96=87?= =?UTF-8?q?=E6=A1=88=E6=9D=A5=E6=BA=90=20-=20=E5=B0=86=E7=BC=A9=E6=94=BE?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E6=98=BE=E7=A4=BA=E6=96=87=E6=A1=88=E9=9B=86?= =?UTF-8?q?=E4=B8=AD=E5=88=B0=E6=9E=9A=E4=B8=BE=E6=9C=AC=E5=9C=B0=E5=8C=96?= =?UTF-8?q?=E8=B5=84=E6=BA=90=20-=20=E5=A4=8D=E7=94=A8=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E6=96=87=E6=A1=88=E9=A9=B1=E5=8A=A8=E5=88=86=E6=AE=B5=E6=8E=A7?= =?UTF-8?q?=E4=BB=B6=E4=B8=8E=E8=BE=85=E5=8A=A9=E5=8A=9F=E8=83=BD=E5=80=BC?= =?UTF-8?q?=20-=20=E6=B8=85=E7=90=86=E5=A4=9A=E4=BD=99=E7=9A=84=20fit=20na?= =?UTF-8?q?tive=20=E5=AD=97=E7=AC=A6=E4=B8=B2=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Capture/Views/CaptureDisplayView.swift | 19 +++++++++++++++---- VoidDisplay/Resources/Localizable.xcstrings | 8 +------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift index d6b6657..41da698 100644 --- a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift +++ b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift @@ -5,9 +5,18 @@ import SwiftUI // MARK: - Capture Display View struct CaptureDisplayView: View { - private enum PreviewScaleMode: Hashable { + private enum PreviewScaleMode: Hashable, CaseIterable { case fit case native + + var title: LocalizedStringResource { + switch self { + case .fit: + "Fit" + case .native: + "1:1" + } + } } let sessionId: UUID @@ -90,14 +99,16 @@ struct CaptureDisplayView: View { .toolbar { ToolbarItem(placement: .principal) { Picker("Scale Mode", selection: $scaleMode) { - Text("Fit").tag(PreviewScaleMode.fit) - Text("1:1").tag(PreviewScaleMode.native) + ForEach(PreviewScaleMode.allCases, id: \.self) { mode in + Text(mode.title) + .tag(mode) + } } .pickerStyle(.segmented) .controlSize(.small) .frame(width: 150) .accessibilityIdentifier("capture_preview_scale_mode_picker") - .accessibilityValue(Text(scaleMode == .fit ? "fit" : "native")) + .accessibilityValue(Text(scaleMode.title)) } ToolbarItem(placement: .automatic) { HStack(spacing: AppUI.Spacing.small + 2) { diff --git a/VoidDisplay/Resources/Localizable.xcstrings b/VoidDisplay/Resources/Localizable.xcstrings index 0fbfd52..c34a66a 100644 --- a/VoidDisplay/Resources/Localizable.xcstrings +++ b/VoidDisplay/Resources/Localizable.xcstrings @@ -794,9 +794,6 @@ } } } - }, - "fit" : { - }, "Fit" : { "localizations" : { @@ -989,9 +986,6 @@ } } } - }, - "native" : { - }, "No available display can be monitored right now." : { "localizations" : { @@ -2000,4 +1994,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} From b1008da730ba9e66417234111429539f415a7b35 Mon Sep 17 00:00:00 2001 From: Chen Date: Fri, 20 Mar 2026 00:19:22 +0800 Subject: [PATCH 06/34] =?UTF-8?q?refactor(capture):=20=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=E7=9B=91=E5=90=AC=E4=BC=9A=E8=AF=9D=E7=9C=9F=E5=80=BC=E6=BA=90?= =?UTF-8?q?=E4=B8=8E=E5=9B=9E=E5=BD=92=E6=8A=A4=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入监听会话 store 并固定状态迁移与删除语义 - 收紧控制器快照刷新路径并补齐 no-op 与顺序断言 - 修正预览诊断模式断言对本地化 fit 值的识别 --- .../Services/CaptureMonitoringService.swift | 28 ++--- .../CaptureMonitoringSessionStore.swift | 70 +++++++++++ .../App/CaptureControllerTests.swift | 82 ++++++++++++- .../CaptureMonitoringServiceTests.swift | 114 ++++++++++++++++++ .../CapturePreviewDiagnosticsTests.swift | 2 +- 5 files changed, 275 insertions(+), 21 deletions(-) create mode 100644 VoidDisplay/Features/Capture/Services/CaptureMonitoringSessionStore.swift diff --git a/VoidDisplay/Features/Capture/Services/CaptureMonitoringService.swift b/VoidDisplay/Features/Capture/Services/CaptureMonitoringService.swift index 12ec71c..a589834 100644 --- a/VoidDisplay/Features/Capture/Services/CaptureMonitoringService.swift +++ b/VoidDisplay/Features/Capture/Services/CaptureMonitoringService.swift @@ -20,53 +20,43 @@ protocol CaptureMonitoringServiceProtocol: AnyObject { @MainActor final class CaptureMonitoringService: CaptureMonitoringServiceProtocol { - private var sessions: [ScreenMonitoringSession] = [] + private let sessionStore: CaptureMonitoringSessionStore init(initialSessions: [ScreenMonitoringSession] = []) { - self.sessions = initialSessions + self.sessionStore = CaptureMonitoringSessionStore(initialSessions: initialSessions) } var currentSessions: [ScreenMonitoringSession] { - sessions + sessionStore.currentSessions } func monitoringSession(for id: UUID) -> ScreenMonitoringSession? { - sessions.first { $0.id == id } + sessionStore.session(for: id) } func addMonitoringSession(_ session: ScreenMonitoringSession) { - sessions.append(session) + sessionStore.add(session) } func updateMonitoringSessionState( id: UUID, state: ScreenMonitoringSession.State ) { - guard let index = sessions.firstIndex(where: { $0.id == id }) else { return } - sessions[index].state = state + sessionStore.updateState(id: id, state: state) } func updateMonitoringSessionCapturesCursor( id: UUID, capturesCursor: Bool ) { - guard let index = sessions.firstIndex(where: { $0.id == id }) else { return } - sessions[index].capturesCursor = capturesCursor + sessionStore.updateCapturesCursor(id: id, capturesCursor: capturesCursor) } func removeMonitoringSession(id: UUID) { - if let session = sessions.first(where: { $0.id == id }) { - session.previewSubscription.cancel() - } - sessions.removeAll { $0.id == id } + sessionStore.remove(id: id) } func removeMonitoringSessions(displayID: CGDirectDisplayID) { - let targetSessionIDs = sessions - .filter { $0.displayID == displayID } - .map(\.id) - for sessionID in targetSessionIDs { - removeMonitoringSession(id: sessionID) - } + sessionStore.remove(displayID: displayID) } } diff --git a/VoidDisplay/Features/Capture/Services/CaptureMonitoringSessionStore.swift b/VoidDisplay/Features/Capture/Services/CaptureMonitoringSessionStore.swift new file mode 100644 index 0000000..4a9d4ef --- /dev/null +++ b/VoidDisplay/Features/Capture/Services/CaptureMonitoringSessionStore.swift @@ -0,0 +1,70 @@ +import CoreGraphics +import Foundation + +@MainActor +final class CaptureMonitoringSessionStore { + private var sessions: [ScreenMonitoringSession] + + init(initialSessions: [ScreenMonitoringSession] = []) { + self.sessions = initialSessions + } + + var currentSessions: [ScreenMonitoringSession] { + sessions + } + + func session(for id: UUID) -> ScreenMonitoringSession? { + sessions.first { $0.id == id } + } + + func add(_ session: ScreenMonitoringSession) { + sessions.append(session) + } + + func updateState( + id: UUID, + state: ScreenMonitoringSession.State + ) { + guard let index = sessions.firstIndex(where: { $0.id == id }) else { return } + let currentState = sessions[index].state + guard shouldApplyStateTransition(from: currentState, to: state) else { return } + sessions[index].state = state + } + + func updateCapturesCursor( + id: UUID, + capturesCursor: Bool + ) { + guard let index = sessions.firstIndex(where: { $0.id == id }) else { return } + guard sessions[index].capturesCursor != capturesCursor else { return } + sessions[index].capturesCursor = capturesCursor + } + + func remove(id: UUID) { + guard let index = sessions.firstIndex(where: { $0.id == id }) else { return } + sessions[index].previewSubscription.cancel() + sessions.remove(at: index) + } + + func remove(displayID: CGDirectDisplayID) { + let removalIndexes = sessions.indices.filter { sessions[$0].displayID == displayID } + guard !removalIndexes.isEmpty else { return } + + for index in removalIndexes { + sessions[index].previewSubscription.cancel() + } + sessions.removeAll { $0.displayID == displayID } + } + + private func shouldApplyStateTransition( + from currentState: ScreenMonitoringSession.State, + to nextState: ScreenMonitoringSession.State + ) -> Bool { + switch (currentState, nextState) { + case (.starting, .active): + true + case (.starting, .starting), (.active, .active), (.active, .starting): + false + } + } +} diff --git a/VoidDisplayTests/App/CaptureControllerTests.swift b/VoidDisplayTests/App/CaptureControllerTests.swift index c9bba36..cec657b 100644 --- a/VoidDisplayTests/App/CaptureControllerTests.swift +++ b/VoidDisplayTests/App/CaptureControllerTests.swift @@ -30,6 +30,13 @@ private final class CaptureControllerDummySession: DisplayCaptureSessioning, @un @Suite(.serialized) @MainActor struct CaptureControllerTests { + private struct SessionSnapshot: Equatable { + let id: UUID + let displayID: CGDirectDisplayID + let capturesCursor: Bool + let state: String + } + @Test func initSynchronizesExistingSessionsFromService() { let service = MockCaptureMonitoringService() let existingSession = makeSession(id: UUID(), displayID: 66) @@ -46,12 +53,13 @@ struct CaptureControllerTests { let session = makeSession(id: UUID(), displayID: 77) controller.addMonitoringSession(session) - #expect(controller.screenCaptureSessions.map(\.id) == [session.id]) + assertSnapshotMatchesService(controller: controller, service: service) #expect(controller.monitoringSession(for: session.id)?.displayID == 77) controller.removeMonitoringSession(id: session.id) #expect(controller.screenCaptureSessions.isEmpty) #expect(service.removeCallCount == 1) + assertSnapshotMatchesService(controller: controller, service: service) } @Test func markMonitoringSessionActiveRefreshesSnapshot() { @@ -71,6 +79,7 @@ struct CaptureControllerTests { Issue.record("Expected controller session to be active.") } #expect(service.updateStateCallCount == 1) + assertSnapshotMatchesService(controller: controller, service: service) } @Test func setMonitoringSessionCapturesCursorRefreshesSnapshot() { @@ -83,6 +92,7 @@ struct CaptureControllerTests { #expect(controller.screenCaptureSessions.first?.capturesCursor == true) #expect(service.updateCapturesCursorCallCount == 1) + assertSnapshotMatchesService(controller: controller, service: service) } @Test func removeMonitoringSessionsFiltersByDisplayID() { @@ -97,6 +107,28 @@ struct CaptureControllerTests { #expect(controller.screenCaptureSessions.map(\.displayID) == [92]) #expect(service.removeByDisplayCallCount == 1) #expect(service.removedDisplayIDs == [91]) + assertSnapshotMatchesService(controller: controller, service: service) + } + + @Test func unknownMutationRequestsKeepControllerSnapshotStable() { + let service = MockCaptureMonitoringService() + let first = makeSession(id: UUID(), displayID: 101) + let second = makeSession(id: UUID(), displayID: 102) + service.currentSessions = [first, second] + let controller = CaptureController(captureMonitoringService: service) + let initialSignature = snapshotSignature(controller.screenCaptureSessions) + + controller.markMonitoringSessionActive(id: UUID()) + #expect(snapshotSignature(controller.screenCaptureSessions) == initialSignature) + assertSnapshotMatchesService(controller: controller, service: service) + + controller.setMonitoringSessionCapturesCursor(id: UUID(), capturesCursor: true) + #expect(snapshotSignature(controller.screenCaptureSessions) == initialSignature) + assertSnapshotMatchesService(controller: controller, service: service) + + controller.removeMonitoringSession(id: UUID()) + #expect(snapshotSignature(controller.screenCaptureSessions) == initialSignature) + assertSnapshotMatchesService(controller: controller, service: service) } @Test func stopDependentStreamsBeforeRebuildStopsSharingAndMonitoring() { @@ -120,6 +152,29 @@ struct CaptureControllerTests { #expect(sharingService.stopSharingCallCount == 1) #expect(service.removeByDisplayCallCount == 1) #expect(controller.screenCaptureSessions.isEmpty) + assertSnapshotMatchesService(controller: controller, service: service) + } + + @Test func stopDependentStreamsBeforeRebuildDoesNotStopSharingWhenDisplayIsNotShared() { + let service = MockCaptureMonitoringService() + let session = makeSession(id: UUID(), displayID: 124) + service.currentSessions = [session] + let sharingService = MockSharingService() + let sharingController = SharingController( + sharingService: sharingService, + portPreferences: SharingPortPreferences(defaults: UserDefaults(suiteName: "CaptureControllerTestsNoShare")!) + ) + let controller = CaptureController(captureMonitoringService: service) + + controller.stopDependentStreamsBeforeRebuild( + displayID: 124, + sharingController: sharingController + ) + + #expect(sharingService.stopSharingCallCount == 0) + #expect(service.removeByDisplayCallCount == 1) + #expect(controller.screenCaptureSessions.isEmpty) + assertSnapshotMatchesService(controller: controller, service: service) } private func makeSession(id: UUID, displayID: CGDirectDisplayID) -> ScreenMonitoringSession { @@ -139,4 +194,29 @@ struct CaptureControllerTests { state: .starting ) } + + private func assertSnapshotMatchesService( + controller: CaptureController, + service: MockCaptureMonitoringService + ) { + #expect(snapshotSignature(controller.screenCaptureSessions) == snapshotSignature(service.currentSessions)) + } + + private func snapshotSignature(_ sessions: [ScreenMonitoringSession]) -> [SessionSnapshot] { + sessions.map { session in + let stateLabel: String + switch session.state { + case .starting: + stateLabel = "starting" + case .active: + stateLabel = "active" + } + return SessionSnapshot( + id: session.id, + displayID: session.displayID, + capturesCursor: session.capturesCursor, + state: stateLabel + ) + } + } } diff --git a/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringServiceTests.swift b/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringServiceTests.swift index 2cce2e5..cb58f38 100644 --- a/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringServiceTests.swift +++ b/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringServiceTests.swift @@ -34,6 +34,13 @@ private final class CancellationCounter: @unchecked Sendable { @Suite(.serialized) @MainActor struct CaptureMonitoringServiceTests { + private struct SessionSnapshot: Equatable { + let id: UUID + let displayID: CGDirectDisplayID + let capturesCursor: Bool + let state: String + } + @Test func addAndLookupSessionTracksCurrentSessions() { let service = CaptureMonitoringService() let session = makeSession(id: UUID(), displayID: 7).session @@ -82,6 +89,37 @@ struct CaptureMonitoringServiceTests { #expect(cursorStates[second.id] == true) } + @Test func updateMonitoringSessionStateIgnoresUnknownSessionID() { + let service = CaptureMonitoringService() + let session = makeSession(id: UUID(), displayID: 15).session + service.addMonitoringSession(session) + + service.updateMonitoringSessionState(id: UUID(), state: .active) + + #expect(snapshotSignature(service.currentSessions) == [signature(for: session)]) + } + + @Test func updateMonitoringSessionStateDoesNotRevertActiveSessionToStarting() { + let service = CaptureMonitoringService() + var activeSession = makeSession(id: UUID(), displayID: 16).session + activeSession.state = .active + service.addMonitoringSession(activeSession) + + service.updateMonitoringSessionState(id: activeSession.id, state: .starting) + + #expect(service.currentSessions.first?.state == .active) + } + + @Test func updateMonitoringSessionCapturesCursorIgnoresUnknownSessionID() { + let service = CaptureMonitoringService() + let session = makeSession(id: UUID(), displayID: 17).session + service.addMonitoringSession(session) + + service.updateMonitoringSessionCapturesCursor(id: UUID(), capturesCursor: true) + + #expect(snapshotSignature(service.currentSessions) == [signature(for: session)]) + } + @Test func removeMonitoringSessionCancelsSubscription() { let service = CaptureMonitoringService() let (session, cancelCount) = makeSession(id: UUID(), displayID: 22) @@ -93,6 +131,17 @@ struct CaptureMonitoringServiceTests { #expect(cancelCount.value == 1) } + @Test func removeMonitoringSessionIgnoresUnknownSessionID() { + let service = CaptureMonitoringService() + let retained = makeSession(id: UUID(), displayID: 23) + service.addMonitoringSession(retained.session) + + service.removeMonitoringSession(id: UUID()) + + #expect(snapshotSignature(service.currentSessions) == [signature(for: retained.session)]) + #expect(retained.cancelCount.value == 0) + } + @Test func removeMonitoringSessionsCancelsAllMatchingDisplaySessions() { let service = CaptureMonitoringService() let first = makeSession(id: UUID(), displayID: 44) @@ -108,6 +157,51 @@ struct CaptureMonitoringServiceTests { #expect(first.cancelCount.value == 1) #expect(second.cancelCount.value == 1) #expect(third.cancelCount.value == 0) + #expect(first.cancelCount.value + second.cancelCount.value == 2) + } + + @Test func removeMonitoringSessionsIgnoresUnknownDisplayIDAndPreservesOrder() { + let service = CaptureMonitoringService() + let first = makeSession(id: UUID(), displayID: 50) + let second = makeSession(id: UUID(), displayID: 51) + let third = makeSession(id: UUID(), displayID: 52) + service.addMonitoringSession(first.session) + service.addMonitoringSession(second.session) + service.addMonitoringSession(third.session) + + service.removeMonitoringSessions(displayID: 99) + + #expect(snapshotSignature(service.currentSessions) == [ + signature(for: first.session), + signature(for: second.session), + signature(for: third.session) + ]) + #expect(first.cancelCount.value == 0) + #expect(second.cancelCount.value == 0) + #expect(third.cancelCount.value == 0) + } + + @Test func removeMonitoringSessionsKeepsRemainingOrderAfterCancellingMatches() { + let service = CaptureMonitoringService() + let first = makeSession(id: UUID(), displayID: 60) + let second = makeSession(id: UUID(), displayID: 61) + let third = makeSession(id: UUID(), displayID: 60) + let fourth = makeSession(id: UUID(), displayID: 62) + service.addMonitoringSession(first.session) + service.addMonitoringSession(second.session) + service.addMonitoringSession(third.session) + service.addMonitoringSession(fourth.session) + + service.removeMonitoringSessions(displayID: 60) + + #expect(snapshotSignature(service.currentSessions) == [ + signature(for: second.session), + signature(for: fourth.session) + ]) + #expect(first.cancelCount.value == 1) + #expect(third.cancelCount.value == 1) + #expect(second.cancelCount.value == 0) + #expect(fourth.cancelCount.value == 0) } private func makeSession( @@ -133,4 +227,24 @@ struct CaptureMonitoringServiceTests { ) return (session, cancelCount) } + + private func snapshotSignature(_ sessions: [ScreenMonitoringSession]) -> [SessionSnapshot] { + sessions.map(signature(for:)) + } + + private func signature(for session: ScreenMonitoringSession) -> SessionSnapshot { + let stateLabel: String + switch session.state { + case .starting: + stateLabel = "starting" + case .active: + stateLabel = "active" + } + return SessionSnapshot( + id: session.id, + displayID: session.displayID, + capturesCursor: session.capturesCursor, + state: stateLabel + ) + } } diff --git a/VoidDisplayUITests/Diagnostics/CapturePreviewDiagnosticsTests.swift b/VoidDisplayUITests/Diagnostics/CapturePreviewDiagnosticsTests.swift index aa9f638..c634c5c 100644 --- a/VoidDisplayUITests/Diagnostics/CapturePreviewDiagnosticsTests.swift +++ b/VoidDisplayUITests/Diagnostics/CapturePreviewDiagnosticsTests.swift @@ -16,7 +16,7 @@ final class CapturePreviewDiagnosticsTests: XCTestCase { static func fromAccessibilityValue(_ rawValue: String) -> Self? { let normalized = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if normalized.contains("fit") { + if normalized.contains("fit") || normalized.contains("适应") { return .fit } if normalized.contains("native") || normalized.contains("1:1") { From de616192e16371db0993014973cefe26150ed85d Mon Sep 17 00:00:00 2001 From: Chen Date: Fri, 20 Mar 2026 00:26:29 +0800 Subject: [PATCH 07/34] =?UTF-8?q?docs(workflow):=20=E6=98=8E=E7=A1=AE?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E9=98=B6=E6=AE=B5=E5=A4=8D=E7=94=A8=E6=9C=80?= =?UTF-8?q?=E8=BF=91=E9=AA=8C=E8=AF=81=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 规定提交指令在代码未变化时不得重复跑同组测试 - 将复用最近一次新鲜验证结果写入仓库测试策略 - 保持该规则只适用于 commit-only 场景 --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index c670b16..3ea3477 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,7 @@ ## Test Execution Policy - After every code change, explicitly check whether related tests need to be updated or added, and complete required test updates before handoff. - Default: run targeted tests related to changed module/feature. +- If related verification has already completed after the latest code change, and no repo-tracked file has changed since that verification, a later commit-only instruction must reuse the existing fresh verification result instead of rerunning the same tests. - For small, explicit, low-risk changes with tightly bounded impact, do not run the full `HomeSmokeTests` suite by default. Prefer build-only verification or a narrower targeted test that covers the changed control or flow. - Run full suite when changes are broad/high-risk or impact cannot be bounded: - shared/common code changes From ae7287275161723af915b16907198c3391ce6385 Mon Sep 17 00:00:00 2001 From: Chen Date: Fri, 20 Mar 2026 01:09:58 +0800 Subject: [PATCH 08/34] =?UTF-8?q?docs(ci):=20=E5=AF=B9=E9=BD=90=20CI=20?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E6=96=87=E6=A1=A3\n\n-=20=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E6=96=87=E6=A1=A3=E4=B8=AD=E7=9A=84=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=20Xcode=20=E7=89=88=E6=9C=AC=E4=B8=BA=2026.3\n-=20=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E6=96=87=E6=A1=A3=E4=B8=AD=E7=9A=84=20release-build-c?= =?UTF-8?q?heck=20=E6=9E=B6=E6=9E=84=E8=AF=B4=E6=98=8E=E4=B8=BA=E5=8F=8C?= =?UTF-8?q?=E7=9F=A9=E9=98=B5\n-=20=E4=BF=9D=E6=8C=81=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E6=8F=8F=E8=BF=B0=E4=B8=8E=E5=BD=93=E5=89=8D=20workflow=20?= =?UTF-8?q?=E5=92=8C=20action=20=E5=AE=9E=E7=8E=B0=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/testing/ci-workflows.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/testing/ci-workflows.md b/docs/testing/ci-workflows.md index 724253d..538af65 100644 --- a/docs/testing/ci-workflows.md +++ b/docs/testing/ci-workflows.md @@ -20,7 +20,7 @@ The repository does not use GitHub merge queue. `merge_group` is intentionally o Default Xcode selection is centralized in `.github/actions/xcode-select` and prefers: -- `/Applications/Xcode_26.2.app/Contents/Developer` +- `/Applications/Xcode_26.3.app/Contents/Developer` - fallback: `/Applications/Xcode.app/Contents/Developer` ## Branch Protection Gate @@ -45,7 +45,7 @@ UI smoke failure behavior: Release build check behavior: - `release-build-check` runs only on PRs targeting `main` with code-relevant changes -- It performs an unsigned `Release` build for `arm64` +- It performs unsigned `Release` builds for `arm64` and `x86_64` through a 2-job matrix - It does not package DMG and does not publish artifacts Unit coverage guard behavior: From 1354b49bd54cb24bb839cfb8a132d2c89d045a98 Mon Sep 17 00:00:00 2001 From: Chen Date: Fri, 20 Mar 2026 01:47:47 +0800 Subject: [PATCH 09/34] =?UTF-8?q?refactor(capture):=20=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=E7=9B=91=E5=90=AC=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B9=B6=E5=8F=91=E5=90=AF=E5=8A=A8=E5=8E=BB?= =?UTF-8?q?=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增监听生命周期 service 与 metadata,统一监听 session 的启动和关闭入口 - 将 controller、view model、view 的监听调用改为经由 lifecycle service - 补充并发同屏启动、失败重试与预览链路回归测试 --- VoidDisplay/App/CaptureController.swift | 47 +- .../CaptureMonitoringDisplayMetadata.swift | 7 + .../CaptureMonitoringLifecycleService.swift | 101 ++++ ...reMonitoringLifecycleServiceProtocol.swift | 17 + .../ViewModels/CaptureChooseViewModel.swift | 28 +- .../Capture/Views/CaptureChoose.swift | 4 +- .../Capture/Views/CaptureDisplayView.swift | 25 +- .../App/CaptureControllerTests.swift | 172 +++++-- .../App/CaptureSharingIsolationTests.swift | 9 +- ...ptureCatalogTopologyIntegrationTests.swift | 2 +- ...ptureMonitoringLifecycleServiceTests.swift | 480 ++++++++++++++++++ .../CaptureChooseViewModelTests.swift | 54 +- 12 files changed, 857 insertions(+), 89 deletions(-) create mode 100644 VoidDisplay/Features/Capture/Models/CaptureMonitoringDisplayMetadata.swift create mode 100644 VoidDisplay/Features/Capture/Services/CaptureMonitoringLifecycleService.swift create mode 100644 VoidDisplay/Features/Capture/Services/CaptureMonitoringLifecycleServiceProtocol.swift create mode 100644 VoidDisplayTests/Features/Capture/Services/CaptureMonitoringLifecycleServiceTests.swift diff --git a/VoidDisplay/App/CaptureController.swift b/VoidDisplay/App/CaptureController.swift index a770141..d28fd79 100644 --- a/VoidDisplay/App/CaptureController.swift +++ b/VoidDisplay/App/CaptureController.swift @@ -5,6 +5,7 @@ import Foundation import CoreGraphics +import ScreenCaptureKit import Observation @MainActor @@ -14,9 +15,15 @@ final class CaptureController { @ObservationIgnored let displayCatalogState = ScreenCaptureDisplayCatalogState() @ObservationIgnored private let captureMonitoringService: any CaptureMonitoringServiceProtocol + @ObservationIgnored private let captureMonitoringLifecycleService: any CaptureMonitoringLifecycleServiceProtocol - init(captureMonitoringService: any CaptureMonitoringServiceProtocol) { + init( + captureMonitoringService: any CaptureMonitoringServiceProtocol, + captureMonitoringLifecycleService: (any CaptureMonitoringLifecycleServiceProtocol)? = nil + ) { self.captureMonitoringService = captureMonitoringService + self.captureMonitoringLifecycleService = captureMonitoringLifecycleService + ?? CaptureMonitoringLifecycleService(captureMonitoringService: captureMonitoringService) self.screenCaptureSessions = captureMonitoringService.currentSessions } @@ -24,30 +31,45 @@ final class CaptureController { captureMonitoringService.monitoringSession(for: id) } - func addMonitoringSession(_ session: ScreenMonitoringSession) { - mutateAndSync { - captureMonitoringService.addMonitoringSession(session) + func startMonitoring( + display: SCDisplay, + metadata: CaptureMonitoringDisplayMetadata + ) async throws -> UUID { + try await mutateAndSyncAsync { + try await captureMonitoringLifecycleService.startMonitoring( + display: display, + metadata: metadata + ) } } - func markMonitoringSessionActive(id: UUID) { + func activateMonitoringSession(id: UUID) { mutateAndSync { - captureMonitoringService.updateMonitoringSessionState(id: id, state: .active) + captureMonitoringLifecycleService.activateMonitoringSession(id: id) } } - func setMonitoringSessionCapturesCursor(id: UUID, capturesCursor: Bool) { + func attachPreviewSink(_ sink: any DisplayPreviewSink, to id: UUID) { mutateAndSync { - captureMonitoringService.updateMonitoringSessionCapturesCursor( + captureMonitoringLifecycleService.attachPreviewSink(sink, to: id) + } + } + + func setMonitoringSessionCapturesCursor( + id: UUID, + capturesCursor: Bool + ) async throws { + try await mutateAndSyncAsync { + try await captureMonitoringLifecycleService.setMonitoringSessionCapturesCursor( id: id, capturesCursor: capturesCursor ) } } - func removeMonitoringSession(id: UUID) { + func closeMonitoringSession(id: UUID) { mutateAndSync { - captureMonitoringService.removeMonitoringSession(id: id) + captureMonitoringLifecycleService.closeMonitoringSession(id: id) } } @@ -75,4 +97,9 @@ final class CaptureController { mutation() syncCaptureMonitoringState() } + + private func mutateAndSyncAsync(_ mutation: () async throws -> T) async rethrows -> T { + defer { syncCaptureMonitoringState() } + return try await mutation() + } } diff --git a/VoidDisplay/Features/Capture/Models/CaptureMonitoringDisplayMetadata.swift b/VoidDisplay/Features/Capture/Models/CaptureMonitoringDisplayMetadata.swift new file mode 100644 index 0000000..d03ee38 --- /dev/null +++ b/VoidDisplay/Features/Capture/Models/CaptureMonitoringDisplayMetadata.swift @@ -0,0 +1,7 @@ +import Foundation + +struct CaptureMonitoringDisplayMetadata: Equatable, Sendable { + let displayName: String + let resolutionText: String + let isVirtualDisplay: Bool +} diff --git a/VoidDisplay/Features/Capture/Services/CaptureMonitoringLifecycleService.swift b/VoidDisplay/Features/Capture/Services/CaptureMonitoringLifecycleService.swift new file mode 100644 index 0000000..fc1bc56 --- /dev/null +++ b/VoidDisplay/Features/Capture/Services/CaptureMonitoringLifecycleService.swift @@ -0,0 +1,101 @@ +import Foundation +import ScreenCaptureKit + +@MainActor +final class CaptureMonitoringLifecycleService: CaptureMonitoringLifecycleServiceProtocol { + typealias AcquirePreview = @MainActor (SCDisplay) async throws -> DisplayPreviewSubscription + + private let captureMonitoringService: any CaptureMonitoringServiceProtocol + private let acquirePreview: AcquirePreview + private var inFlightStartTasksByDisplayID: [CGDirectDisplayID: Task] = [:] + + init( + captureMonitoringService: any CaptureMonitoringServiceProtocol, + acquirePreview: @escaping AcquirePreview = { display in + try await DisplayCaptureRegistry.shared.acquirePreview(display: SendableDisplay(display)) + } + ) { + self.captureMonitoringService = captureMonitoringService + self.acquirePreview = acquirePreview + } + + func startMonitoring( + display: SCDisplay, + metadata: CaptureMonitoringDisplayMetadata + ) async throws -> UUID { + if let existingSession = existingSession(for: display.displayID) { + return existingSession.id + } + if let existingTask = inFlightStartTasksByDisplayID[display.displayID] { + return try await existingTask.value + } + + let displayID = display.displayID + let task = Task { @MainActor [captureMonitoringService, acquirePreview] in + let clearInFlightTask: @MainActor () -> Void = { [weak self] in + self?.inFlightStartTasksByDisplayID[displayID] = nil + } + defer { clearInFlightTask() } + + if let existingSession = captureMonitoringService.currentSessions.first( + where: { $0.displayID == displayID } + ) { + return existingSession.id + } + + let previewSubscription = try await acquirePreview(display) + if let existingSession = captureMonitoringService.currentSessions.first( + where: { $0.displayID == displayID } + ) { + previewSubscription.cancel() + return existingSession.id + } + + let session = ScreenMonitoringSession( + id: UUID(), + displayID: displayID, + displayName: metadata.displayName, + resolutionText: metadata.resolutionText, + isVirtualDisplay: metadata.isVirtualDisplay, + previewSubscription: previewSubscription, + capturesCursor: false, + state: .starting + ) + captureMonitoringService.addMonitoringSession(session) + return session.id + } + inFlightStartTasksByDisplayID[displayID] = task + return try await task.value + } + + func activateMonitoringSession(id: UUID) { + guard captureMonitoringService.monitoringSession(for: id) != nil else { return } + captureMonitoringService.updateMonitoringSessionState(id: id, state: .active) + } + + func attachPreviewSink(_ sink: any DisplayPreviewSink, to id: UUID) { + guard let session = captureMonitoringService.monitoringSession(for: id) else { return } + session.previewSubscription.attachPreviewSink(sink) + } + + func setMonitoringSessionCapturesCursor( + id: UUID, + capturesCursor: Bool + ) async throws { + guard let session = captureMonitoringService.monitoringSession(for: id) else { return } + try await session.previewSubscription.setShowsCursor(capturesCursor) + captureMonitoringService.updateMonitoringSessionCapturesCursor( + id: id, + capturesCursor: capturesCursor + ) + } + + func closeMonitoringSession(id: UUID) { + guard captureMonitoringService.monitoringSession(for: id) != nil else { return } + captureMonitoringService.removeMonitoringSession(id: id) + } + + private func existingSession(for displayID: CGDirectDisplayID) -> ScreenMonitoringSession? { + captureMonitoringService.currentSessions.first(where: { $0.displayID == displayID }) + } +} diff --git a/VoidDisplay/Features/Capture/Services/CaptureMonitoringLifecycleServiceProtocol.swift b/VoidDisplay/Features/Capture/Services/CaptureMonitoringLifecycleServiceProtocol.swift new file mode 100644 index 0000000..46fcc23 --- /dev/null +++ b/VoidDisplay/Features/Capture/Services/CaptureMonitoringLifecycleServiceProtocol.swift @@ -0,0 +1,17 @@ +import Foundation +import ScreenCaptureKit + +@MainActor +protocol CaptureMonitoringLifecycleServiceProtocol: AnyObject { + func startMonitoring( + display: SCDisplay, + metadata: CaptureMonitoringDisplayMetadata + ) async throws -> UUID + func activateMonitoringSession(id: UUID) + func attachPreviewSink(_ sink: any DisplayPreviewSink, to id: UUID) + func setMonitoringSessionCapturesCursor( + id: UUID, + capturesCursor: Bool + ) async throws + func closeMonitoringSession(id: UUID) +} diff --git a/VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift b/VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift index 46172af..8eddd87 100644 --- a/VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift +++ b/VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift @@ -12,7 +12,10 @@ final class CaptureChooseViewModel { struct CaptureActions { var monitoringSessionForDisplayID: @MainActor (CGDirectDisplayID) -> ScreenMonitoringSession? - var addMonitoringSession: @MainActor (ScreenMonitoringSession) -> Void + var startMonitoring: @MainActor ( + SCDisplay, + CaptureMonitoringDisplayMetadata + ) async throws -> UUID } struct VirtualDisplayQueries { @@ -32,8 +35,8 @@ final class CaptureChooseViewModel { monitoringSessionForDisplayID: { displayID in capture.screenCaptureSessions.first(where: { $0.displayID == displayID }) }, - addMonitoringSession: { session in - capture.addMonitoringSession(session) + startMonitoring: { display, metadata in + try await capture.startMonitoring(display: display, metadata: metadata) } ), virtualDisplayQueries: .init( @@ -50,7 +53,6 @@ final class CaptureChooseViewModel { var startingDisplayIDs: Set = [] var userFacingAlert: UserFacingAlertState? - private let makePreviewSubscription: @MainActor (SCDisplay) async throws -> DisplayPreviewSubscription private let topologyCoordinator: ScreenCaptureCatalogTopologyCoordinator @ObservationIgnored private let dependencies: Dependencies @ObservationIgnored private let catalogLoader: ScreenCaptureDisplayCatalogLoader @@ -59,7 +61,6 @@ final class CaptureChooseViewModel { catalogState: ScreenCaptureDisplayCatalogState? = nil, permissionProvider: (any ScreenCapturePermissionProvider)? = nil, loadShareableDisplays: (@MainActor () async throws -> [SCDisplay])? = nil, - makePreviewSubscription: (@MainActor (SCDisplay) async throws -> DisplayPreviewSubscription)? = nil, activeDisplayIDsProvider: @escaping @MainActor () -> Set = { Set(NSScreen.screens.compactMap(\.cgDirectDisplayID)) }, @@ -67,9 +68,6 @@ final class CaptureChooseViewModel { ) { let catalog = catalogState ?? ScreenCaptureDisplayCatalogState() self.catalog = catalog - self.makePreviewSubscription = makePreviewSubscription ?? { display in - try await DisplayCaptureRegistry.shared.acquirePreview(display: SendableDisplay(display)) - } self.topologyCoordinator = ScreenCaptureCatalogTopologyCoordinator( state: catalog, activeDisplayIDsProvider: activeDisplayIDsProvider @@ -125,19 +123,13 @@ final class CaptureChooseViewModel { } do { - let previewSubscription = try await makePreviewSubscription(display) - let session = ScreenMonitoringSession( - id: UUID(), - displayID: display.displayID, + let metadata = CaptureMonitoringDisplayMetadata( displayName: displayName(for: display), resolutionText: resolutionText(for: display), - isVirtualDisplay: isVirtualDisplay(display), - previewSubscription: previewSubscription, - capturesCursor: false, - state: .starting + isVirtualDisplay: isVirtualDisplay(display) ) - dependencies.captureActions.addMonitoringSession(session) - openWindow(session.id) + let sessionID = try await dependencies.captureActions.startMonitoring(display, metadata) + openWindow(sessionID) } catch { AppErrorMapper.logFailure("Start monitoring", error: error, logger: AppLog.capture) userFacingAlert = UserFacingAlertState( diff --git a/VoidDisplay/Features/Capture/Views/CaptureChoose.swift b/VoidDisplay/Features/Capture/Views/CaptureChoose.swift index 6deb41e..bfc5dcc 100644 --- a/VoidDisplay/Features/Capture/Views/CaptureChoose.swift +++ b/VoidDisplay/Features/Capture/Views/CaptureChoose.swift @@ -176,7 +176,7 @@ struct IsCapturing: View { session: session, isSharing: sharing.isDisplaySharing(displayID: session.displayID) ) { - capture.removeMonitoringSession(id: session.id) + capture.closeMonitoringSession(id: session.id) } } } @@ -203,7 +203,7 @@ struct IsCapturing: View { isSharing: sharing.isDisplaySharing(displayID: display.displayID) ) { if isMonitoring, let session = monitoringSession { - capture.removeMonitoringSession(id: session.id) + capture.closeMonitoringSession(id: session.id) } else { Task { await viewModel.startMonitoring(display: display) { sessionId in diff --git a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift index 41da698..d0d7a3d 100644 --- a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift +++ b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift @@ -155,30 +155,24 @@ struct CaptureDisplayView: View { } .onAppear { if let session { - session.previewSubscription.attachPreviewSink(renderer) + capture.attachPreviewSink(renderer, to: sessionId) if let destinationDirectory = CapturePreviewDiagnosticsRuntime.configuration()?.recordDirectoryURL { let sink = CapturePreviewRecordingSink( destinationDirectory: destinationDirectory, session: session ) recordingSink = sink - session.previewSubscription.attachPreviewSink(sink) + capture.attachPreviewSink(sink, to: sessionId) } - capture.markMonitoringSessionActive(id: sessionId) + capture.activateMonitoringSession(id: sessionId) } else { dismiss() } } .onDisappear { - if let session { - if let recordingSink { - session.previewSubscription.detachPreviewSink(recordingSink) - } - session.previewSubscription.detachPreviewSink(renderer) - } + capture.closeMonitoringSession(id: sessionId) windowCoordinator.tearDown() renderer.flush() - capture.removeMonitoringSession(id: sessionId) } .onChange(of: renderer.framePixelSize) { _, _ in windowCoordinator.update(aspect: preferredAspect(), shouldLockAspect: scaleMode == .fit) @@ -212,16 +206,15 @@ extension CaptureDisplayView { let previousValue = capturesCursor capturesCursor = newValue - guard let session else { return } + guard session != nil else { return } isUpdatingCursorCapture = true Task { do { - try await session.previewSubscription.setShowsCursor(newValue) + try await capture.setMonitoringSessionCapturesCursor( + id: sessionId, + capturesCursor: newValue + ) await MainActor.run { - capture.setMonitoringSessionCapturesCursor( - id: sessionId, - capturesCursor: newValue - ) isUpdatingCursorCapture = false } } catch { diff --git a/VoidDisplayTests/App/CaptureControllerTests.swift b/VoidDisplayTests/App/CaptureControllerTests.swift index cec657b..2e8a6ac 100644 --- a/VoidDisplayTests/App/CaptureControllerTests.swift +++ b/VoidDisplayTests/App/CaptureControllerTests.swift @@ -1,4 +1,6 @@ import CoreGraphics +import CoreMedia +import Foundation import ScreenCaptureKit import Testing @testable import VoidDisplay @@ -6,18 +8,26 @@ import Testing private final class CaptureControllerDummySession: DisplayCaptureSessioning, @unchecked Sendable { nonisolated let sessionHub = WebRTCSessionHub() + nonisolated(unsafe) var attachedSinkCount = 0 + nonisolated(unsafe) var detachedSinkCount = 0 + nonisolated(unsafe) var cursorUpdateCount = 0 + nonisolated(unsafe) var lastShowsCursor: Bool? + nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { _ = sink + attachedSinkCount += 1 } nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { _ = sink + detachedSinkCount += 1 } nonisolated func stopSharing() {} nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { - _ = showsCursor + cursorUpdateCount += 1 + lastShowsCursor = showsCursor } nonisolated func retainShareCursorOverride() async throws {} @@ -27,6 +37,38 @@ private final class CaptureControllerDummySession: DisplayCaptureSessioning, @un nonisolated func stop() async {} } +private final class CaptureControllerPreviewSink: DisplayPreviewSink, @unchecked Sendable { + nonisolated func submitFrame(_ sampleBuffer: CMSampleBuffer) { + _ = sampleBuffer + } +} + +private final class CaptureControllerMockSCDisplayBox: NSObject { + @objc let displayID: CGDirectDisplayID + @objc let width: Int + @objc let height: Int + @objc let frame: CGRect + + init(displayID: CGDirectDisplayID, width: Int, height: Int) { + self.displayID = displayID + self.width = width + self.height = height + self.frame = CGRect(x: 0, y: 0, width: width, height: height) + super.init() + } +} + +private enum CaptureControllerMockSCDisplay { + static func make(displayID: CGDirectDisplayID, width: Int, height: Int) -> SCDisplay { + let box = CaptureControllerMockSCDisplayBox( + displayID: displayID, + width: width, + height: height + ) + return unsafeBitCast(box, to: SCDisplay.self) + } +} + @Suite(.serialized) @MainActor struct CaptureControllerTests { @@ -39,7 +81,7 @@ struct CaptureControllerTests { @Test func initSynchronizesExistingSessionsFromService() { let service = MockCaptureMonitoringService() - let existingSession = makeSession(id: UUID(), displayID: 66) + let existingSession = makeSession(id: UUID(), displayID: 66).session service.currentSessions = [existingSession] let controller = CaptureController(captureMonitoringService: service) @@ -47,28 +89,44 @@ struct CaptureControllerTests { #expect(controller.screenCaptureSessions.map(\.id) == [existingSession.id]) } - @Test func addAndRemoveSessionSyncsControllerState() { + @Test func startMonitoringRefreshesSnapshotFromLifecycleService() async throws { let service = MockCaptureMonitoringService() - let controller = CaptureController(captureMonitoringService: service) - let session = makeSession(id: UUID(), displayID: 77) + let subscriptionSession = CaptureControllerDummySession() + let subscription = DisplayPreviewSubscription( + displayID: 77, + resolutionText: "2560 × 1440", + session: subscriptionSession, + cancelClosure: {} + ) + let lifecycleService = CaptureMonitoringLifecycleService( + captureMonitoringService: service, + acquirePreview: { _ in subscription } + ) + let controller = CaptureController( + captureMonitoringService: service, + captureMonitoringLifecycleService: lifecycleService + ) + let display = CaptureControllerMockSCDisplay.make(displayID: 77, width: 2560, height: 1440) + let metadata = CaptureMonitoringDisplayMetadata( + displayName: "Display 77", + resolutionText: "2560 × 1440", + isVirtualDisplay: false + ) - controller.addMonitoringSession(session) - assertSnapshotMatchesService(controller: controller, service: service) - #expect(controller.monitoringSession(for: session.id)?.displayID == 77) + let sessionID = try await controller.startMonitoring(display: display, metadata: metadata) - controller.removeMonitoringSession(id: session.id) - #expect(controller.screenCaptureSessions.isEmpty) - #expect(service.removeCallCount == 1) + #expect(service.addCallCount == 1) + #expect(controller.monitoringSession(for: sessionID)?.displayName == "Display 77") assertSnapshotMatchesService(controller: controller, service: service) } - @Test func markMonitoringSessionActiveRefreshesSnapshot() { + @Test func activateMonitoringSessionRefreshesSnapshot() { let service = MockCaptureMonitoringService() - let session = makeSession(id: UUID(), displayID: 88) + let session = makeSession(id: UUID(), displayID: 88).session service.currentSessions = [session] let controller = CaptureController(captureMonitoringService: service) - controller.markMonitoringSessionActive(id: session.id) + controller.activateMonitoringSession(id: session.id) guard let updated = controller.screenCaptureSessions.first else { Issue.record("Expected active session.") @@ -82,58 +140,101 @@ struct CaptureControllerTests { assertSnapshotMatchesService(controller: controller, service: service) } - @Test func setMonitoringSessionCapturesCursorRefreshesSnapshot() { + @Test func attachPreviewSinkTargetsRequestedSessionAndKeepsSnapshotAligned() { let service = MockCaptureMonitoringService() - let session = makeSession(id: UUID(), displayID: 89) - service.currentSessions = [session] + let first = makeSession(id: UUID(), displayID: 89) + let second = makeSession(id: UUID(), displayID: 90) + service.currentSessions = [first.session, second.session] let controller = CaptureController(captureMonitoringService: service) + let sink = CaptureControllerPreviewSink() - controller.setMonitoringSessionCapturesCursor(id: session.id, capturesCursor: true) + controller.attachPreviewSink(sink, to: second.session.id) + + #expect(first.captureSession.attachedSinkCount == 0) + #expect(second.captureSession.attachedSinkCount == 1) + assertSnapshotMatchesService(controller: controller, service: service) + } + + @Test func setMonitoringSessionCapturesCursorRefreshesSnapshot() async throws { + let service = MockCaptureMonitoringService() + let sessionRecord = makeSession(id: UUID(), displayID: 91) + service.currentSessions = [sessionRecord.session] + let controller = CaptureController(captureMonitoringService: service) + + try await controller.setMonitoringSessionCapturesCursor( + id: sessionRecord.session.id, + capturesCursor: true + ) #expect(controller.screenCaptureSessions.first?.capturesCursor == true) #expect(service.updateCapturesCursorCallCount == 1) + #expect(sessionRecord.captureSession.cursorUpdateCount == 1) + #expect(sessionRecord.captureSession.lastShowsCursor == true) + assertSnapshotMatchesService(controller: controller, service: service) + } + + @Test func closeMonitoringSessionRefreshesSnapshot() { + let service = MockCaptureMonitoringService() + let session = makeSession(id: UUID(), displayID: 92).session + service.currentSessions = [session] + let controller = CaptureController(captureMonitoringService: service) + + controller.closeMonitoringSession(id: session.id) + + #expect(controller.screenCaptureSessions.isEmpty) + #expect(service.removeCallCount == 1) assertSnapshotMatchesService(controller: controller, service: service) } @Test func removeMonitoringSessionsFiltersByDisplayID() { let service = MockCaptureMonitoringService() - let first = makeSession(id: UUID(), displayID: 91) - let second = makeSession(id: UUID(), displayID: 92) + let first = makeSession(id: UUID(), displayID: 93).session + let second = makeSession(id: UUID(), displayID: 94).session service.currentSessions = [first, second] let controller = CaptureController(captureMonitoringService: service) - controller.removeMonitoringSessions(displayID: 91) + controller.removeMonitoringSessions(displayID: 93) - #expect(controller.screenCaptureSessions.map(\.displayID) == [92]) + #expect(controller.screenCaptureSessions.map(\.displayID) == [94]) #expect(service.removeByDisplayCallCount == 1) - #expect(service.removedDisplayIDs == [91]) + #expect(service.removedDisplayIDs == [93]) assertSnapshotMatchesService(controller: controller, service: service) } - @Test func unknownMutationRequestsKeepControllerSnapshotStable() { + @Test func unknownLifecycleMutationRequestsKeepControllerSnapshotStable() async throws { let service = MockCaptureMonitoringService() let first = makeSession(id: UUID(), displayID: 101) let second = makeSession(id: UUID(), displayID: 102) - service.currentSessions = [first, second] + service.currentSessions = [first.session, second.session] let controller = CaptureController(captureMonitoringService: service) let initialSignature = snapshotSignature(controller.screenCaptureSessions) + let sink = CaptureControllerPreviewSink() + + controller.activateMonitoringSession(id: UUID()) + #expect(snapshotSignature(controller.screenCaptureSessions) == initialSignature) + #expect(service.updateStateCallCount == 0) + assertSnapshotMatchesService(controller: controller, service: service) - controller.markMonitoringSessionActive(id: UUID()) + controller.attachPreviewSink(sink, to: UUID()) #expect(snapshotSignature(controller.screenCaptureSessions) == initialSignature) + #expect(first.captureSession.attachedSinkCount == 0) + #expect(second.captureSession.attachedSinkCount == 0) assertSnapshotMatchesService(controller: controller, service: service) - controller.setMonitoringSessionCapturesCursor(id: UUID(), capturesCursor: true) + try await controller.setMonitoringSessionCapturesCursor(id: UUID(), capturesCursor: true) #expect(snapshotSignature(controller.screenCaptureSessions) == initialSignature) + #expect(service.updateCapturesCursorCallCount == 0) assertSnapshotMatchesService(controller: controller, service: service) - controller.removeMonitoringSession(id: UUID()) + controller.closeMonitoringSession(id: UUID()) #expect(snapshotSignature(controller.screenCaptureSessions) == initialSignature) + #expect(service.removeCallCount == 0) assertSnapshotMatchesService(controller: controller, service: service) } @Test func stopDependentStreamsBeforeRebuildStopsSharingAndMonitoring() { let service = MockCaptureMonitoringService() - let session = makeSession(id: UUID(), displayID: 123) + let session = makeSession(id: UUID(), displayID: 123).session service.currentSessions = [session] let sharingService = MockSharingService() sharingService.activeSharingDisplayIDs = [123] @@ -157,7 +258,7 @@ struct CaptureControllerTests { @Test func stopDependentStreamsBeforeRebuildDoesNotStopSharingWhenDisplayIsNotShared() { let service = MockCaptureMonitoringService() - let session = makeSession(id: UUID(), displayID: 124) + let session = makeSession(id: UUID(), displayID: 124).session service.currentSessions = [session] let sharingService = MockSharingService() let sharingController = SharingController( @@ -177,8 +278,12 @@ struct CaptureControllerTests { assertSnapshotMatchesService(controller: controller, service: service) } - private func makeSession(id: UUID, displayID: CGDirectDisplayID) -> ScreenMonitoringSession { - ScreenMonitoringSession( + private func makeSession( + id: UUID, + displayID: CGDirectDisplayID + ) -> (session: ScreenMonitoringSession, captureSession: CaptureControllerDummySession) { + let captureSession = CaptureControllerDummySession() + let session = ScreenMonitoringSession( id: id, displayID: displayID, displayName: "Display \(displayID)", @@ -187,12 +292,13 @@ struct CaptureControllerTests { previewSubscription: DisplayPreviewSubscription( displayID: displayID, resolutionText: "1920 x 1080", - session: CaptureControllerDummySession(), + session: captureSession, cancelClosure: {} ), capturesCursor: false, state: .starting ) + return (session, captureSession) } private func assertSnapshotMatchesService( diff --git a/VoidDisplayTests/App/CaptureSharingIsolationTests.swift b/VoidDisplayTests/App/CaptureSharingIsolationTests.swift index 542e023..dae8f45 100644 --- a/VoidDisplayTests/App/CaptureSharingIsolationTests.swift +++ b/VoidDisplayTests/App/CaptureSharingIsolationTests.swift @@ -40,7 +40,7 @@ private final class IsolationPortPreferences: SharingPortPreferencesProtocol { @Suite(.serialized) @MainActor struct CaptureSharingIsolationTests { - @Test func captureMutationsDoNotRewriteSharingSnapshot() async { + @Test func captureMutationsDoNotRewriteSharingSnapshot() async throws { let sharingService = MockSharingService() let sharedDisplay: CGDirectDisplayID = 901 sharingService.activeSharingDisplayIDs = [sharedDisplay] @@ -60,8 +60,11 @@ struct CaptureSharingIsolationTests { captureService.currentSessions = [captureSession] let captureController = CaptureController(captureMonitoringService: captureService) - captureController.markMonitoringSessionActive(id: captureSession.id) - captureController.setMonitoringSessionCapturesCursor(id: captureSession.id, capturesCursor: true) + captureController.activateMonitoringSession(id: captureSession.id) + try await captureController.setMonitoringSessionCapturesCursor( + id: captureSession.id, + capturesCursor: true + ) #expect(sharingController.isWebServiceRunning) #expect(sharingController.isSharing) diff --git a/VoidDisplayTests/App/ScreenCaptureCatalogTopologyIntegrationTests.swift b/VoidDisplayTests/App/ScreenCaptureCatalogTopologyIntegrationTests.swift index 6611395..b96a724 100644 --- a/VoidDisplayTests/App/ScreenCaptureCatalogTopologyIntegrationTests.swift +++ b/VoidDisplayTests/App/ScreenCaptureCatalogTopologyIntegrationTests.swift @@ -234,7 +234,7 @@ struct ScreenCaptureCatalogTopologyIntegrationTests { .init( captureActions: .init( monitoringSessionForDisplayID: { _ in nil }, - addMonitoringSession: { _ in } + startMonitoring: { _, _ in UUID() } ), virtualDisplayQueries: .init( isManagedVirtualDisplay: { _ in false } diff --git a/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringLifecycleServiceTests.swift b/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringLifecycleServiceTests.swift new file mode 100644 index 0000000..0bfc807 --- /dev/null +++ b/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringLifecycleServiceTests.swift @@ -0,0 +1,480 @@ +import CoreGraphics +import CoreMedia +import Foundation +import ScreenCaptureKit +import Testing +@testable import VoidDisplay + +private final class CaptureMonitoringLifecycleDummySession: DisplayCaptureSessioning, @unchecked Sendable { + nonisolated let sessionHub = WebRTCSessionHub() + + nonisolated(unsafe) var attachedSinkCount = 0 + nonisolated(unsafe) var detachedSinkCount = 0 + nonisolated(unsafe) var cursorUpdateCount = 0 + nonisolated(unsafe) var lastShowsCursor: Bool? + nonisolated(unsafe) var cursorUpdateError: Error? + + nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { + _ = sink + attachedSinkCount += 1 + } + + nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { + _ = sink + detachedSinkCount += 1 + } + + nonisolated func stopSharing() {} + + nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { + cursorUpdateCount += 1 + lastShowsCursor = showsCursor + if let cursorUpdateError { + throw cursorUpdateError + } + } + + nonisolated func retainShareCursorOverride() async throws {} + + nonisolated func releaseShareCursorOverride() async throws {} + + nonisolated func stop() async {} +} + +private final class CaptureMonitoringLifecyclePreviewSink: DisplayPreviewSink, @unchecked Sendable { + nonisolated func submitFrame(_ sampleBuffer: CMSampleBuffer) { + _ = sampleBuffer + } +} + +private final class CaptureMonitoringLifecycleCancelCounter: @unchecked Sendable { + nonisolated(unsafe) var value = 0 +} + +private final class CaptureMonitoringLifecycleMockSCDisplayBox: NSObject { + @objc let displayID: CGDirectDisplayID + @objc let width: Int + @objc let height: Int + @objc let frame: CGRect + + init(displayID: CGDirectDisplayID, width: Int, height: Int) { + self.displayID = displayID + self.width = width + self.height = height + self.frame = CGRect(x: 0, y: 0, width: width, height: height) + super.init() + } +} + +private enum CaptureMonitoringLifecycleMockSCDisplay { + static func make(displayID: CGDirectDisplayID, width: Int, height: Int) -> SCDisplay { + let box = CaptureMonitoringLifecycleMockSCDisplayBox( + displayID: displayID, + width: width, + height: height + ) + return unsafeBitCast(box, to: SCDisplay.self) + } +} + +private struct CaptureMonitoringLifecycleControlledError: Error, Equatable {} + +private actor CaptureMonitoringLifecycleAcquirePreviewGate { + enum Outcome: Sendable { + case success(DisplayPreviewSubscription) + case failure(any Error & Sendable) + } + + private struct PendingCall { + let outcome: Outcome + let continuation: CheckedContinuation + } + + private let scriptedOutcomes: [Outcome] + private var callCount = 0 + private var pendingCalls: [Int: PendingCall] = [:] + + init(scriptedOutcomes: [Outcome]) { + self.scriptedOutcomes = scriptedOutcomes + } + + func nextOutcome() async -> Outcome { + callCount += 1 + let callIndex = callCount + let outcome = scriptedOutcomes.indices.contains(callIndex - 1) + ? scriptedOutcomes[callIndex - 1] + : scriptedOutcomes.last! + return await withCheckedContinuation { continuation in + pendingCalls[callIndex] = PendingCall( + outcome: outcome, + continuation: continuation + ) + } + } + + func release(call callIndex: Int) { + guard let pending = pendingCalls.removeValue(forKey: callIndex) else { return } + pending.continuation.resume(returning: pending.outcome) + } + + func currentCallCount() -> Int { + callCount + } +} + +@Suite(.serialized) +@MainActor +struct CaptureMonitoringLifecycleServiceTests { + @Test func startMonitoringCreatesSessionAndReturnsID() async throws { + let service = MockCaptureMonitoringService() + let previewRecord = makePreview(displayID: 701) + let lifecycle = CaptureMonitoringLifecycleService( + captureMonitoringService: service, + acquirePreview: { _ in previewRecord.subscription } + ) + let display = CaptureMonitoringLifecycleMockSCDisplay.make( + displayID: 701, + width: 1920, + height: 1080 + ) + let metadata = CaptureMonitoringDisplayMetadata( + displayName: "Studio Display", + resolutionText: "1920 × 1080", + isVirtualDisplay: false + ) + + let sessionID = try await lifecycle.startMonitoring(display: display, metadata: metadata) + + #expect(service.addCallCount == 1) + #expect(service.currentSessions.count == 1) + #expect(service.currentSessions.first?.id == sessionID) + #expect(service.currentSessions.first?.displayName == "Studio Display") + #expect(service.currentSessions.first?.resolutionText == "1920 × 1080") + #expect(service.currentSessions.first?.isVirtualDisplay == false) + } + + @Test func startMonitoringReusesExistingSessionIDForSameDisplay() async throws { + let service = MockCaptureMonitoringService() + let existing = makeSession(id: UUID(), displayID: 702) + service.currentSessions = [existing.session] + var acquirePreviewCallCount = 0 + let lifecycle = CaptureMonitoringLifecycleService( + captureMonitoringService: service, + acquirePreview: { _ in + acquirePreviewCallCount += 1 + return makePreview(displayID: 702).subscription + } + ) + let display = CaptureMonitoringLifecycleMockSCDisplay.make( + displayID: 702, + width: 1920, + height: 1080 + ) + + let sessionID = try await lifecycle.startMonitoring( + display: display, + metadata: .init( + displayName: "Ignored", + resolutionText: "1920 × 1080", + isVirtualDisplay: false + ) + ) + + #expect(sessionID == existing.session.id) + #expect(acquirePreviewCallCount == 0) + #expect(service.addCallCount == 0) + #expect(service.currentSessions.count == 1) + } + + @Test func startMonitoringUsesInjectedAcquirePreview() async throws { + let service = MockCaptureMonitoringService() + var usedInjectedAcquirePreview = false + let lifecycle = CaptureMonitoringLifecycleService( + captureMonitoringService: service, + acquirePreview: { _ in + usedInjectedAcquirePreview = true + return makePreview(displayID: 703).subscription + } + ) + let display = CaptureMonitoringLifecycleMockSCDisplay.make( + displayID: 703, + width: 2560, + height: 1440 + ) + + _ = try await lifecycle.startMonitoring( + display: display, + metadata: .init( + displayName: "Preview Source", + resolutionText: "2560 × 1440", + isVirtualDisplay: true + ) + ) + + #expect(usedInjectedAcquirePreview) + } + + @Test func concurrentStartMonitoringForSameDisplayCreatesOneSessionAndOnePreviewAcquire() async throws { + let service = MockCaptureMonitoringService() + let preview = makePreview(displayID: 712) + let gate = CaptureMonitoringLifecycleAcquirePreviewGate( + scriptedOutcomes: [.success(preview.subscription)] + ) + let lifecycle = CaptureMonitoringLifecycleService( + captureMonitoringService: service, + acquirePreview: { _ in + switch await gate.nextOutcome() { + case .success(let subscription): + return subscription + case .failure(let error): + throw error + } + } + ) + let display = CaptureMonitoringLifecycleMockSCDisplay.make( + displayID: 712, + width: 1920, + height: 1080 + ) + let metadata = CaptureMonitoringDisplayMetadata( + displayName: "Shared Display", + resolutionText: "1920 × 1080", + isVirtualDisplay: false + ) + + let firstTask = Task { @MainActor in + try await lifecycle.startMonitoring(display: display, metadata: metadata) + } + #expect(await waitForAcquirePreviewCall(gate, count: 1)) + + let secondTask = Task { @MainActor in + try await lifecycle.startMonitoring(display: display, metadata: metadata) + } + let stayedSingleAcquire = await staysTrue { + await gate.currentCallCount() == 1 + } + #expect(stayedSingleAcquire) + + await gate.release(call: 1) + let firstID = try await firstTask.value + let secondID = try await secondTask.value + + #expect(firstID == secondID) + #expect(service.addCallCount == 1) + #expect(service.currentSessions.count == 1) + #expect(service.currentSessions.first?.displayID == 712) + } + + @Test func attachPreviewSinkTargetsRequestedSessionOnly() { + let service = MockCaptureMonitoringService() + let first = makeSession(id: UUID(), displayID: 704) + let second = makeSession(id: UUID(), displayID: 705) + service.currentSessions = [first.session, second.session] + let lifecycle = CaptureMonitoringLifecycleService(captureMonitoringService: service) + let sink = CaptureMonitoringLifecyclePreviewSink() + + lifecycle.attachPreviewSink(sink, to: second.session.id) + + #expect(first.captureSession.attachedSinkCount == 0) + #expect(second.captureSession.attachedSinkCount == 1) + } + + @Test func activateMonitoringSessionPromotesOnlyRequestedSession() { + let service = MockCaptureMonitoringService() + let first = makeSession(id: UUID(), displayID: 706) + let second = makeSession(id: UUID(), displayID: 707) + service.currentSessions = [first.session, second.session] + let lifecycle = CaptureMonitoringLifecycleService(captureMonitoringService: service) + + lifecycle.activateMonitoringSession(id: second.session.id) + + #expect(service.currentSessions.first(where: { $0.id == first.session.id })?.state == .starting) + #expect(service.currentSessions.first(where: { $0.id == second.session.id })?.state == .active) + #expect(service.updateStateCallCount == 1) + } + + @Test func setMonitoringSessionCapturesCursorWritesBackOnlyAfterPreviewUpdateSucceeds() async throws { + let service = MockCaptureMonitoringService() + let record = makeSession(id: UUID(), displayID: 708) + service.currentSessions = [record.session] + let lifecycle = CaptureMonitoringLifecycleService(captureMonitoringService: service) + + try await lifecycle.setMonitoringSessionCapturesCursor( + id: record.session.id, + capturesCursor: true + ) + + #expect(record.captureSession.cursorUpdateCount == 1) + #expect(record.captureSession.lastShowsCursor == true) + #expect(service.updateCapturesCursorCallCount == 1) + #expect(service.currentSessions.first?.capturesCursor == true) + } + + @Test func setMonitoringSessionCapturesCursorDoesNotWriteBackWhenPreviewUpdateFails() async { + let service = MockCaptureMonitoringService() + let record = makeSession(id: UUID(), displayID: 709) + record.captureSession.cursorUpdateError = CaptureMonitoringLifecycleControlledError() + service.currentSessions = [record.session] + let lifecycle = CaptureMonitoringLifecycleService(captureMonitoringService: service) + + do { + try await lifecycle.setMonitoringSessionCapturesCursor( + id: record.session.id, + capturesCursor: true + ) + Issue.record("Expected cursor update failure.") + } catch is CaptureMonitoringLifecycleControlledError { + } catch { + Issue.record("Expected CaptureMonitoringLifecycleControlledError, got \(error)") + } + + #expect(record.captureSession.cursorUpdateCount == 1) + #expect(service.updateCapturesCursorCallCount == 0) + #expect(service.currentSessions.first?.capturesCursor == false) + } + + @Test func closeMonitoringSessionUsesRemovePathOnly() { + let service = MockCaptureMonitoringService() + let record = makeSession(id: UUID(), displayID: 710) + service.currentSessions = [record.session] + let lifecycle = CaptureMonitoringLifecycleService(captureMonitoringService: service) + + lifecycle.closeMonitoringSession(id: record.session.id) + + #expect(service.removeCallCount == 1) + #expect(record.cancelCounter.value == 0) + #expect(service.currentSessions.isEmpty) + } + + @Test func failedInFlightStartClearsMutualExclusionAndAllowsRetry() async { + let service = MockCaptureMonitoringService() + let secondPreview = makePreview(displayID: 713) + let gate = CaptureMonitoringLifecycleAcquirePreviewGate( + scriptedOutcomes: [ + .failure(CaptureMonitoringLifecycleControlledError()), + .success(secondPreview.subscription) + ] + ) + let lifecycle = CaptureMonitoringLifecycleService( + captureMonitoringService: service, + acquirePreview: { _ in + switch await gate.nextOutcome() { + case .success(let subscription): + return subscription + case .failure(let error): + throw error + } + } + ) + let display = CaptureMonitoringLifecycleMockSCDisplay.make( + displayID: 713, + width: 1920, + height: 1080 + ) + let metadata = CaptureMonitoringDisplayMetadata( + displayName: "Retry Display", + resolutionText: "1920 × 1080", + isVirtualDisplay: false + ) + + let firstTask = Task { @MainActor in + try await lifecycle.startMonitoring(display: display, metadata: metadata) + } + #expect(await waitForAcquirePreviewCall(gate, count: 1)) + await gate.release(call: 1) + + do { + _ = try await firstTask.value + Issue.record("Expected first start to fail.") + } catch is CaptureMonitoringLifecycleControlledError { + } catch { + Issue.record("Expected CaptureMonitoringLifecycleControlledError, got \(error)") + } + + let retryTask = Task { @MainActor in + try await lifecycle.startMonitoring(display: display, metadata: metadata) + } + #expect(await waitForAcquirePreviewCall(gate, count: 2)) + await gate.release(call: 2) + let retryID = try? await retryTask.value + + #expect(retryID != nil) + #expect(service.addCallCount == 1) + #expect(service.currentSessions.count == 1) + #expect(service.currentSessions.first?.displayID == 713) + } + + @Test func unknownSessionIDsAreNoOpForActivateAttachAndClose() async throws { + let service = MockCaptureMonitoringService() + let record = makeSession(id: UUID(), displayID: 711) + service.currentSessions = [record.session] + let lifecycle = CaptureMonitoringLifecycleService(captureMonitoringService: service) + let sink = CaptureMonitoringLifecyclePreviewSink() + + lifecycle.activateMonitoringSession(id: UUID()) + lifecycle.attachPreviewSink(sink, to: UUID()) + try await lifecycle.setMonitoringSessionCapturesCursor(id: UUID(), capturesCursor: true) + lifecycle.closeMonitoringSession(id: UUID()) + + #expect(service.updateStateCallCount == 0) + #expect(service.updateCapturesCursorCallCount == 0) + #expect(service.removeCallCount == 0) + #expect(record.captureSession.attachedSinkCount == 0) + #expect(record.captureSession.cursorUpdateCount == 0) + #expect(service.currentSessions.map(\.id) == [record.session.id]) + } + + private func makePreview( + displayID: CGDirectDisplayID + ) -> ( + subscription: DisplayPreviewSubscription, + captureSession: CaptureMonitoringLifecycleDummySession, + cancelCounter: CaptureMonitoringLifecycleCancelCounter + ) { + let captureSession = CaptureMonitoringLifecycleDummySession() + let cancelCounter = CaptureMonitoringLifecycleCancelCounter() + let subscription = DisplayPreviewSubscription( + displayID: displayID, + resolutionText: "1920 × 1080", + session: captureSession, + cancelClosure: { cancelCounter.value += 1 } + ) + return (subscription, captureSession, cancelCounter) + } + + private func makeSession( + id: UUID, + displayID: CGDirectDisplayID + ) -> ( + session: ScreenMonitoringSession, + captureSession: CaptureMonitoringLifecycleDummySession, + cancelCounter: CaptureMonitoringLifecycleCancelCounter + ) { + let preview = makePreview(displayID: displayID) + let session = ScreenMonitoringSession( + id: id, + displayID: displayID, + displayName: "Display \(displayID)", + resolutionText: "1920 × 1080", + isVirtualDisplay: false, + previewSubscription: preview.subscription, + capturesCursor: false, + state: .starting + ) + return (session, preview.captureSession, preview.cancelCounter) + } + + private func waitForAcquirePreviewCall( + _ gate: CaptureMonitoringLifecycleAcquirePreviewGate, + count: Int + ) async -> Bool { + let deadline = DispatchTime.now().uptimeNanoseconds + AsyncTestTimeouts.defaultAsyncAssertion + while DispatchTime.now().uptimeNanoseconds < deadline { + if await gate.currentCallCount() >= count { + return true + } + await Task.yield() + } + return await gate.currentCallCount() >= count + } +} diff --git a/VoidDisplayTests/Features/Capture/ViewModels/CaptureChooseViewModelTests.swift b/VoidDisplayTests/Features/Capture/ViewModels/CaptureChooseViewModelTests.swift index 43e1040..b0049dc 100644 --- a/VoidDisplayTests/Features/Capture/ViewModels/CaptureChooseViewModelTests.swift +++ b/VoidDisplayTests/Features/Capture/ViewModels/CaptureChooseViewModelTests.swift @@ -53,7 +53,7 @@ struct CaptureChooseViewModelTests { dependencies: .init( captureActions: .init( monitoringSessionForDisplayID: { _ in nil }, - addMonitoringSession: { _ in } + startMonitoring: { _, _ in UUID() } ), virtualDisplayQueries: .init( isManagedVirtualDisplay: { $0 == 1234 } @@ -159,10 +159,17 @@ struct CaptureChooseViewModelTests { } let sut = CaptureChooseViewModel( - makePreviewSubscription: { _ in - throw ControlledError() - }, - dependencies: makeNoopCaptureDependencies() + dependencies: .init( + captureActions: .init( + monitoringSessionForDisplayID: { _ in nil }, + startMonitoring: { _, _ in + throw ControlledError() + } + ), + virtualDisplayQueries: .init( + isManagedVirtualDisplay: { _ in false } + ) + ) ) let display = MockSCDisplay.make(displayID: 777, width: 1920, height: 1080) var openedSessionIDs: [UUID] = [] @@ -175,6 +182,41 @@ struct CaptureChooseViewModelTests { #expect(sut.startingDisplayIDs.isEmpty) } + @MainActor @Test func startMonitoringSuccessPassesMetadataToCaptureActions() async { + let expectedSessionID = UUID() + let display = MockSCDisplay.make(displayID: 778, width: 2560, height: 1440) + var receivedDisplayID: CGDirectDisplayID? + var receivedMetadata: CaptureMonitoringDisplayMetadata? + let sut = CaptureChooseViewModel( + dependencies: .init( + captureActions: .init( + monitoringSessionForDisplayID: { _ in nil }, + startMonitoring: { display, metadata in + receivedDisplayID = display.displayID + receivedMetadata = metadata + return expectedSessionID + } + ), + virtualDisplayQueries: .init( + isManagedVirtualDisplay: { $0 == 778 } + ) + ) + ) + var openedSessionIDs: [UUID] = [] + + await sut.startMonitoring(display: display) { openedSessionIDs.append($0) } + + #expect(receivedDisplayID == 778) + #expect(receivedMetadata == CaptureMonitoringDisplayMetadata( + displayName: String(localized: "Monitor"), + resolutionText: "2560 × 1440", + isVirtualDisplay: true + )) + #expect(openedSessionIDs == [expectedSessionID]) + #expect(sut.userFacingAlert == nil) + #expect(sut.startingDisplayIDs.isEmpty) + } + @MainActor @Test func requestPermissionDeniedClearsDisplayState() { let sut = CaptureChooseViewModel( permissionProvider: MockScreenCapturePermissionProvider( @@ -569,7 +611,7 @@ struct CaptureChooseViewModelTests { .init( captureActions: .init( monitoringSessionForDisplayID: { _ in nil }, - addMonitoringSession: { _ in } + startMonitoring: { _, _ in UUID() } ), virtualDisplayQueries: .init( isManagedVirtualDisplay: { _ in false } From cf14674d961896712fe0b1cd7c7afeb3db526747 Mon Sep 17 00:00:00 2001 From: Chen Date: Wed, 25 Mar 2026 19:43:35 +0800 Subject: [PATCH 10/34] =?UTF-8?q?fix(capture-sharing):=20=E6=94=B6?= =?UTF-8?q?=E6=95=9B=E6=98=BE=E7=A4=BA=E5=90=AF=E5=8A=A8=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E4=B8=8E=E5=A4=B1=E6=95=88=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入统一的显示启动协调器和失效上下文,复用捕获与共享启动流程 - 让控制器和视图模型暴露开始态,避免重复启动和过期结果污染界面 - 补充捕获、共享与权限相关测试,覆盖并发、失效和本地化交互路径 --- VoidDisplay/App/CaptureController.swift | 51 +- VoidDisplay/App/SharingController.swift | 50 +- .../CaptureMonitoringLifecycleService.swift | 101 ++-- ...reMonitoringLifecycleServiceProtocol.swift | 4 +- .../Services/ScreenCaptureFunction.swift | 309 ++++++++++ .../ViewModels/CaptureChooseViewModel.swift | 66 +-- .../Capture/Views/CaptureChoose.swift | 2 +- .../Services/DisplaySharingCoordinator.swift | 121 +++- .../Sharing/Services/SharingService.swift | 22 +- .../Sharing/ViewModels/ShareViewModel.swift | 86 ++- .../Sharing/Views/ShareDisplayList.swift | 31 +- VoidDisplay/Resources/Localizable.xcstrings | 23 +- .../ScreenCapturePermissionProvider.swift | 32 +- .../App/CaptureControllerTests.swift | 403 ++++++++++++- ...ptureCatalogTopologyIntegrationTests.swift | 9 +- .../App/SharingControllerTests.swift | 372 ++++++++++++ ...ptureMonitoringLifecycleServiceTests.swift | 224 ++++++- .../CaptureMonitoringSessionStoreTests.swift | 126 ++++ .../DisplayStartConcurrencyTests.swift | 184 ++++++ .../CaptureChooseViewModelTests.swift | 141 +++-- .../SharingEndToEndIntegrationTests.swift | 2 +- .../DisplaySharingCoordinatorTests.swift | 547 ++++++++++++++++++ .../Services/SharingServiceTests.swift | 227 +++++++- .../ViewModels/ShareViewModelTests.swift | 194 ++++--- .../Views/ShareViewBehaviorTests.swift | 14 +- ...ScreenCapturePermissionProviderTests.swift | 28 + .../TestSupport/TestServiceMocks.swift | 16 +- 27 files changed, 3055 insertions(+), 330 deletions(-) create mode 100644 VoidDisplayTests/Features/Capture/Services/CaptureMonitoringSessionStoreTests.swift create mode 100644 VoidDisplayTests/Features/Capture/Services/DisplayStartConcurrencyTests.swift create mode 100644 VoidDisplayTests/Shared/ScreenCapturePermissionProviderTests.swift diff --git a/VoidDisplay/App/CaptureController.swift b/VoidDisplay/App/CaptureController.swift index d28fd79..8660f33 100644 --- a/VoidDisplay/App/CaptureController.swift +++ b/VoidDisplay/App/CaptureController.swift @@ -12,10 +12,12 @@ import Observation @Observable final class CaptureController { var screenCaptureSessions: [ScreenMonitoringSession] = [] + var startingDisplayIDs: Set = [] @ObservationIgnored let displayCatalogState = ScreenCaptureDisplayCatalogState() @ObservationIgnored private let captureMonitoringService: any CaptureMonitoringServiceProtocol @ObservationIgnored private let captureMonitoringLifecycleService: any CaptureMonitoringLifecycleServiceProtocol + @ObservationIgnored private var observedStartTokensByDisplayID: [CGDirectDisplayID: Set] = [:] init( captureMonitoringService: any CaptureMonitoringServiceProtocol, @@ -31,16 +33,25 @@ final class CaptureController { captureMonitoringService.monitoringSession(for: id) } + func isStarting(displayID: CGDirectDisplayID) -> Bool { + startingDisplayIDs.contains(displayID) + } + func startMonitoring( display: SCDisplay, metadata: CaptureMonitoringDisplayMetadata - ) async throws -> UUID { - try await mutateAndSyncAsync { - try await captureMonitoringLifecycleService.startMonitoring( - display: display, - metadata: metadata - ) + ) async throws -> DisplayStartOutcome { + let displayID = display.displayID + let startToken = beginObservedStart(displayID: displayID) + defer { + endObservedStart(displayID: displayID, token: startToken) + syncCaptureMonitoringState() } + + return try await captureMonitoringLifecycleService.startMonitoring( + display: display, + metadata: metadata + ) } func activateMonitoringSession(id: UUID) { @@ -74,8 +85,9 @@ final class CaptureController { } func removeMonitoringSessions(displayID: CGDirectDisplayID) { + clearObservedStarts(displayID: displayID) mutateAndSync { - captureMonitoringService.removeMonitoringSessions(displayID: displayID) + captureMonitoringLifecycleService.removeMonitoringSessions(displayID: displayID) } } @@ -93,6 +105,31 @@ final class CaptureController { screenCaptureSessions = captureMonitoringService.currentSessions } + private func beginObservedStart(displayID: CGDirectDisplayID) -> UUID { + let token = UUID() + var tokens = observedStartTokensByDisplayID[displayID] ?? [] + tokens.insert(token) + observedStartTokensByDisplayID[displayID] = tokens + startingDisplayIDs.insert(displayID) + return token + } + + private func endObservedStart(displayID: CGDirectDisplayID, token: UUID) { + guard var tokens = observedStartTokensByDisplayID[displayID] else { return } + tokens.remove(token) + if tokens.isEmpty { + observedStartTokensByDisplayID.removeValue(forKey: displayID) + startingDisplayIDs.remove(displayID) + } else { + observedStartTokensByDisplayID[displayID] = tokens + } + } + + private func clearObservedStarts(displayID: CGDirectDisplayID) { + observedStartTokensByDisplayID.removeValue(forKey: displayID) + startingDisplayIDs.remove(displayID) + } + private func mutateAndSync(_ mutation: () -> Void) { mutation() syncCaptureMonitoringState() diff --git a/VoidDisplay/App/SharingController.swift b/VoidDisplay/App/SharingController.swift index 0709d47..cb9f64c 100644 --- a/VoidDisplay/App/SharingController.swift +++ b/VoidDisplay/App/SharingController.swift @@ -18,6 +18,7 @@ final class SharingController { } var activeSharingDisplayIDs: Set = [] + var startingDisplayIDs: Set = [] var sharingClientCount = 0 var sharingClientCounts: [CGDirectDisplayID: Int] = [:] var isSharing = false @@ -28,6 +29,7 @@ final class SharingController { @ObservationIgnored private(set) var webServer: WebServer? = nil @ObservationIgnored private let sharingService: any SharingServiceProtocol @ObservationIgnored private let portPreferences: any SharingPortPreferencesProtocol + @ObservationIgnored private var observedStartTokensByDisplayID: [CGDirectDisplayID: Set] = [:] init( sharingService: any SharingServiceProtocol, @@ -52,6 +54,7 @@ final class SharingController { } func stopWebService() { + clearAllObservedStarts() mutateAndSync { sharingService.stopWebService() } @@ -66,19 +69,26 @@ final class SharingController { } } - func beginSharing(display: SCDisplay) async throws { - try await mutateAndSync { - try await sharingService.startSharing(display: display) + func beginSharing(display: SCDisplay) async throws -> DisplayStartOutcome { + let displayID = display.displayID + let startToken = beginObservedStart(displayID: displayID) + defer { + endObservedStart(displayID: displayID, token: startToken) + syncSharingState() } + + return try await sharingService.startSharing(display: display) } func stopSharing(displayID: CGDirectDisplayID) { + clearObservedStarts(displayID: displayID) mutateAndSync { sharingService.stopSharing(displayID: displayID) } } func stopAllSharing() { + clearAllObservedStarts() mutateAndSync { sharingService.stopAllSharing() } @@ -105,6 +115,10 @@ final class SharingController { sharingService.isSharing(displayID: displayID) } + func isStarting(displayID: CGDirectDisplayID) -> Bool { + startingDisplayIDs.contains(displayID) + } + func sharePagePath(for displayID: CGDirectDisplayID) -> String? { guard let shareID = sharingService.shareID(for: displayID) else { return nil } return ShareTarget.id(shareID).displayPath @@ -146,6 +160,36 @@ final class SharingController { refreshSharingClientCounts() } + private func beginObservedStart(displayID: CGDirectDisplayID) -> UUID { + let token = UUID() + var tokens = observedStartTokensByDisplayID[displayID] ?? [] + tokens.insert(token) + observedStartTokensByDisplayID[displayID] = tokens + startingDisplayIDs.insert(displayID) + return token + } + + private func endObservedStart(displayID: CGDirectDisplayID, token: UUID) { + guard var tokens = observedStartTokensByDisplayID[displayID] else { return } + tokens.remove(token) + if tokens.isEmpty { + observedStartTokensByDisplayID.removeValue(forKey: displayID) + startingDisplayIDs.remove(displayID) + } else { + observedStartTokensByDisplayID[displayID] = tokens + } + } + + private func clearObservedStarts(displayID: CGDirectDisplayID) { + observedStartTokensByDisplayID.removeValue(forKey: displayID) + startingDisplayIDs.remove(displayID) + } + + private func clearAllObservedStarts() { + observedStartTokensByDisplayID.removeAll() + startingDisplayIDs.removeAll() + } + private func refreshSharingClientCounts() { guard isWebServiceRunning else { sharingClientCounts = [:] diff --git a/VoidDisplay/Features/Capture/Services/CaptureMonitoringLifecycleService.swift b/VoidDisplay/Features/Capture/Services/CaptureMonitoringLifecycleService.swift index fc1bc56..277aab0 100644 --- a/VoidDisplay/Features/Capture/Services/CaptureMonitoringLifecycleService.swift +++ b/VoidDisplay/Features/Capture/Services/CaptureMonitoringLifecycleService.swift @@ -1,71 +1,89 @@ import Foundation import ScreenCaptureKit +import CoreGraphics @MainActor final class CaptureMonitoringLifecycleService: CaptureMonitoringLifecycleServiceProtocol { - typealias AcquirePreview = @MainActor (SCDisplay) async throws -> DisplayPreviewSubscription + typealias AcquirePreview = @MainActor ( + SCDisplay, + DisplayStartInvalidationContext + ) async throws -> DisplayStartOutcome private let captureMonitoringService: any CaptureMonitoringServiceProtocol + private let startCoordinator: DisplayStreamStartCoordinator private let acquirePreview: AcquirePreview - private var inFlightStartTasksByDisplayID: [CGDirectDisplayID: Task] = [:] init( captureMonitoringService: any CaptureMonitoringServiceProtocol, - acquirePreview: @escaping AcquirePreview = { display in - try await DisplayCaptureRegistry.shared.acquirePreview(display: SendableDisplay(display)) + startCoordinator: DisplayStreamStartCoordinator = DisplayStreamStartCoordinator(), + acquirePreview: @escaping AcquirePreview = { display, invalidationContext in + try await DisplayCaptureRegistry.shared.acquirePreview( + display: SendableDisplay(display), + invalidationContext: invalidationContext + ) } ) { self.captureMonitoringService = captureMonitoringService + self.startCoordinator = startCoordinator self.acquirePreview = acquirePreview } + func isStarting(displayID: CGDirectDisplayID) -> Bool { + startCoordinator.isStarting(kind: .monitoring, displayID: displayID) + } + func startMonitoring( display: SCDisplay, metadata: CaptureMonitoringDisplayMetadata - ) async throws -> UUID { - if let existingSession = existingSession(for: display.displayID) { - return existingSession.id - } - if let existingTask = inFlightStartTasksByDisplayID[display.displayID] { - return try await existingTask.value - } - + ) async throws -> DisplayStartOutcome { let displayID = display.displayID - let task = Task { @MainActor [captureMonitoringService, acquirePreview] in - let clearInFlightTask: @MainActor () -> Void = { [weak self] in - self?.inFlightStartTasksByDisplayID[displayID] = nil - } - defer { clearInFlightTask() } + if let existingSession = existingSession(for: displayID) { + return .started(existingSession.id) + } + return try await startCoordinator.start( + kind: .monitoring, + displayID: displayID + ) { [captureMonitoringService, acquirePreview] invalidationContext in if let existingSession = captureMonitoringService.currentSessions.first( where: { $0.displayID == displayID } ) { - return existingSession.id + return .started(existingSession.id) } - let previewSubscription = try await acquirePreview(display) - if let existingSession = captureMonitoringService.currentSessions.first( - where: { $0.displayID == displayID } - ) { - previewSubscription.cancel() - return existingSession.id - } + switch try await acquirePreview(display, invalidationContext) { + case .invalidated: + return .invalidated + case .started(let previewSubscription): + if invalidationContext.isInvalidated() { + previewSubscription.cancel() + return .invalidated + } + if let existingSession = captureMonitoringService.currentSessions.first( + where: { $0.displayID == displayID } + ) { + previewSubscription.cancel() + return .started(existingSession.id) + } - let session = ScreenMonitoringSession( - id: UUID(), - displayID: displayID, - displayName: metadata.displayName, - resolutionText: metadata.resolutionText, - isVirtualDisplay: metadata.isVirtualDisplay, - previewSubscription: previewSubscription, - capturesCursor: false, - state: .starting - ) - captureMonitoringService.addMonitoringSession(session) - return session.id + let session = ScreenMonitoringSession( + id: UUID(), + displayID: displayID, + displayName: metadata.displayName, + resolutionText: metadata.resolutionText, + isVirtualDisplay: metadata.isVirtualDisplay, + previewSubscription: previewSubscription, + capturesCursor: false, + state: .starting + ) + if invalidationContext.isInvalidated() { + previewSubscription.cancel() + return .invalidated + } + captureMonitoringService.addMonitoringSession(session) + return .started(session.id) + } } - inFlightStartTasksByDisplayID[displayID] = task - return try await task.value } func activateMonitoringSession(id: UUID) { @@ -95,6 +113,11 @@ final class CaptureMonitoringLifecycleService: CaptureMonitoringLifecycleService captureMonitoringService.removeMonitoringSession(id: id) } + func removeMonitoringSessions(displayID: CGDirectDisplayID) { + startCoordinator.invalidate(kind: .monitoring, displayID: displayID) + captureMonitoringService.removeMonitoringSessions(displayID: displayID) + } + private func existingSession(for displayID: CGDirectDisplayID) -> ScreenMonitoringSession? { captureMonitoringService.currentSessions.first(where: { $0.displayID == displayID }) } diff --git a/VoidDisplay/Features/Capture/Services/CaptureMonitoringLifecycleServiceProtocol.swift b/VoidDisplay/Features/Capture/Services/CaptureMonitoringLifecycleServiceProtocol.swift index 46fcc23..8ce1ebc 100644 --- a/VoidDisplay/Features/Capture/Services/CaptureMonitoringLifecycleServiceProtocol.swift +++ b/VoidDisplay/Features/Capture/Services/CaptureMonitoringLifecycleServiceProtocol.swift @@ -3,10 +3,11 @@ import ScreenCaptureKit @MainActor protocol CaptureMonitoringLifecycleServiceProtocol: AnyObject { + func isStarting(displayID: CGDirectDisplayID) -> Bool func startMonitoring( display: SCDisplay, metadata: CaptureMonitoringDisplayMetadata - ) async throws -> UUID + ) async throws -> DisplayStartOutcome func activateMonitoringSession(id: UUID) func attachPreviewSink(_ sink: any DisplayPreviewSink, to id: UUID) func setMonitoringSessionCapturesCursor( @@ -14,4 +15,5 @@ protocol CaptureMonitoringLifecycleServiceProtocol: AnyObject { capturesCursor: Bool ) async throws func closeMonitoringSession(id: UUID) + func removeMonitoringSessions(displayID: CGDirectDisplayID) } diff --git a/VoidDisplay/Features/Capture/Services/ScreenCaptureFunction.swift b/VoidDisplay/Features/Capture/Services/ScreenCaptureFunction.swift index de52863..368855c 100644 --- a/VoidDisplay/Features/Capture/Services/ScreenCaptureFunction.swift +++ b/VoidDisplay/Features/Capture/Services/ScreenCaptureFunction.swift @@ -8,6 +8,16 @@ import Synchronization // MARK: - Public Protocols & Value Types +enum DisplayStartOutcome: Sendable { + case started(Value) + case invalidated +} + +enum DisplayStartKind: Hashable, Sendable { + case monitoring + case sharing +} + protocol DisplayPreviewSink: AnyObject, Sendable { nonisolated func submitFrame(_ sampleBuffer: CMSampleBuffer) } @@ -26,6 +36,240 @@ nonisolated struct SendableDisplay: @unchecked Sendable { } } +final class DisplayStartInvalidationContext: Sendable { + private struct State { + var isInvalidated = false + var waiters: [UUID: CheckedContinuation] = [:] + } + + private let state = Mutex(State()) + + nonisolated func invalidate() { + let pendingWaiters = state.withLock { state -> [CheckedContinuation] in + guard !state.isInvalidated else { return [] } + state.isInvalidated = true + let waiters = Array(state.waiters.values) + state.waiters.removeAll() + return waiters + } + for waiter in pendingWaiters { + waiter.resume() + } + } + + nonisolated func isInvalidated() -> Bool { + state.withLock { $0.isInvalidated } + } + + nonisolated func waitForInvalidation() async { + let waiterID = UUID() + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let shouldResumeImmediately = state.withLock { state -> Bool in + if state.isInvalidated || Task.isCancelled { + return true + } + state.waiters[waiterID] = continuation + return false + } + if shouldResumeImmediately { + continuation.resume() + return + } + + if Task.isCancelled { + let cancelledWaiter = state.withLock { state -> CheckedContinuation? in + state.waiters.removeValue(forKey: waiterID) + } + cancelledWaiter?.resume() + } + } + } onCancel: { + let cancelledWaiter = self.state.withLock { state -> CheckedContinuation? in + state.waiters.removeValue(forKey: waiterID) + } + cancelledWaiter?.resume() + } + } + + nonisolated func race( + _ operation: @escaping @Sendable () async throws -> T + ) async throws -> DisplayStartOutcome { + if isInvalidated() { + return .invalidated + } + + return try await withThrowingTaskGroup(of: DisplayStartOutcome.self) { group in + group.addTask { + .started(try await operation()) + } + group.addTask { + await self.waitForInvalidation() + try Task.checkCancellation() + return .invalidated + } + + do { + guard let firstResult = try await group.next() else { + throw CancellationError() + } + group.cancelAll() + while (try? await group.next()) != nil {} + return firstResult + } catch { + group.cancelAll() + while (try? await group.next()) != nil {} + throw error + } + } + } + + nonisolated var waiterCountForTesting: Int { + state.withLock { $0.waiters.count } + } +} + +@MainActor +final class DisplayStreamStartCoordinator { + private struct OperationKey: Hashable { + let kind: DisplayStartKind + let displayID: CGDirectDisplayID + } + + private enum OperationCompletion { + case finished(Any) + case invalidated + case failed(any Error) + } + + private final class OperationRecord { + let token = UUID() + let invalidationContext = DisplayStartInvalidationContext() + var waiters: [UUID: (OperationCompletion) -> Void] = [:] + var task: Task? + } + + private var operations: [OperationKey: OperationRecord] = [:] + + func isStarting( + kind: DisplayStartKind, + displayID: CGDirectDisplayID + ) -> Bool { + operations[OperationKey(kind: kind, displayID: displayID)] != nil + } + + func start( + kind: DisplayStartKind, + displayID: CGDirectDisplayID, + operation: @escaping @MainActor (DisplayStartInvalidationContext) async throws -> DisplayStartOutcome + ) async throws -> DisplayStartOutcome { + let key = OperationKey(kind: kind, displayID: displayID) + if let existing = operations[key] { + return try await awaitResult(from: existing) + } + + let record = OperationRecord() + operations[key] = record + let operationToken = record.token + record.task = Task { @MainActor [weak self] in + let completion: OperationCompletion + do { + let outcome = try await operation(record.invalidationContext) + switch outcome { + case .started(let value): + completion = .finished(value) + case .invalidated: + completion = .invalidated + } + } catch { + completion = .failed(error) + } + self?.complete( + key: key, + operationToken: operationToken, + completion: completion + ) + } + + return try await awaitResult(from: record) + } + + func invalidate( + kind: DisplayStartKind, + displayID: CGDirectDisplayID + ) { + invalidate(key: OperationKey(kind: kind, displayID: displayID)) + } + + func invalidateAll(displayID: CGDirectDisplayID) { + invalidate(kind: .monitoring, displayID: displayID) + invalidate(kind: .sharing, displayID: displayID) + } + + func invalidateAll(kind: DisplayStartKind) { + let keysToInvalidate = operations.keys.filter { $0.kind == kind } + for key in keysToInvalidate { + invalidate(key: key) + } + } + + func waiterCountForTesting( + kind: DisplayStartKind, + displayID: CGDirectDisplayID + ) -> Int { + operations[OperationKey(kind: kind, displayID: displayID)]?.waiters.count ?? 0 + } + + private func awaitResult( + from record: OperationRecord + ) async throws -> DisplayStartOutcome { + try await withCheckedThrowingContinuation { continuation in + let waiterID = UUID() + record.waiters[waiterID] = { completion in + switch completion { + case .finished(let value): + guard let typedValue = value as? Value else { + continuation.resume(throwing: StartCoordinatorTypeMismatchError()) + return + } + continuation.resume(returning: .started(typedValue)) + case .invalidated: + continuation.resume(returning: .invalidated) + case .failed(let error): + continuation.resume(throwing: error) + } + } + } + } + + private func complete( + key: OperationKey, + operationToken: UUID, + completion: OperationCompletion + ) { + guard let record = operations[key], record.token == operationToken else { return } + operations.removeValue(forKey: key) + let waiters = Array(record.waiters.values) + record.waiters.removeAll() + for waiter in waiters { + waiter(completion) + } + } + + private func invalidate(key: OperationKey) { + guard let record = operations.removeValue(forKey: key) else { return } + record.invalidationContext.invalidate() + record.task?.cancel() + let waiters = Array(record.waiters.values) + record.waiters.removeAll() + for waiter in waiters { + waiter(.invalidated) + } + } +} + +private struct StartCoordinatorTypeMismatchError: Error {} + protocol DisplayCaptureSessioning: AnyObject, Sendable { nonisolated var sessionHub: WebRTCSessionHub { get } nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) @@ -78,6 +322,7 @@ final class DisplayPreviewSubscription: Sendable { } nonisolated func cancel() { + let session = self.session let closure = cancelState.withLock { state -> (@Sendable () -> Void)? in let current = state state = nil @@ -115,6 +360,7 @@ final class DisplayShareSubscription: Sendable { private let session: any DisplayCaptureSessioning private let cancelState = Mutex<(@Sendable () -> Void)?>(nil) + private let prepareRetainTask = Mutex?>(nil) nonisolated init( displayID: CGDirectDisplayID, @@ -132,13 +378,58 @@ final class DisplayShareSubscription: Sendable { try await session.retainShareCursorOverride() } + nonisolated func prepareForSharing( + invalidationContext: DisplayStartInvalidationContext + ) async throws -> DisplayStartOutcome { + let retainTask = Task { + try await session.retainShareCursorOverride() + } + prepareRetainTask.withLock { state in + state = retainTask + } + do { + let outcome = try await invalidationContext.race { + try await retainTask.value + } + switch outcome { + case .started: + prepareRetainTask.withLock { state in + state = nil + } + case .invalidated: + cancel() + } + return outcome + } catch { + cancel() + throw error + } + } + nonisolated func cancel() { + let session = self.session + let pendingRetainTask = prepareRetainTask.withLock { state -> Task? in + let current = state + state = nil + return current + } let closure = cancelState.withLock { state -> (@Sendable () -> Void)? in let current = state state = nil return current } guard let closure else { return } + if let pendingRetainTask { + Task.detached { + do { + try await pendingRetainTask.value + } catch { + } + try? await session.releaseShareCursorOverride() + closure() + } + return + } Task { try? await session.releaseShareCursorOverride() closure() @@ -227,6 +518,15 @@ actor DisplayCaptureRegistry { ) } + func acquirePreview( + display: SendableDisplay, + invalidationContext: DisplayStartInvalidationContext + ) async throws -> DisplayStartOutcome { + try await invalidationContext.race { + try await self.acquirePreview(display: display) + } + } + func acquireShare(display: SendableDisplay) async throws -> DisplayShareSubscription { let token = try await acquireShareToken(display: display) guard let record = sessionsByDisplayID[token.displayID] else { @@ -243,6 +543,15 @@ actor DisplayCaptureRegistry { ) } + func acquireShare( + display: SendableDisplay, + invalidationContext: DisplayStartInvalidationContext + ) async throws -> DisplayStartOutcome { + try await invalidationContext.race { + try await self.acquireShare(display: display) + } + } + func acquirePreviewToken(display: SendableDisplay) async throws -> PreviewToken { let tokenID = try await acquireToken(display: display, kind: .preview) return PreviewToken(rawValue: tokenID, displayID: display.displayID) diff --git a/VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift b/VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift index 8eddd87..32f8f67 100644 --- a/VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift +++ b/VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift @@ -12,10 +12,11 @@ final class CaptureChooseViewModel { struct CaptureActions { var monitoringSessionForDisplayID: @MainActor (CGDirectDisplayID) -> ScreenMonitoringSession? + var isStartingDisplayID: @MainActor (CGDirectDisplayID) -> Bool var startMonitoring: @MainActor ( SCDisplay, CaptureMonitoringDisplayMetadata - ) async throws -> UUID + ) async throws -> DisplayStartOutcome } struct VirtualDisplayQueries { @@ -35,6 +36,9 @@ final class CaptureChooseViewModel { monitoringSessionForDisplayID: { displayID in capture.screenCaptureSessions.first(where: { $0.displayID == displayID }) }, + isStartingDisplayID: { displayID in + capture.isStarting(displayID: displayID) + }, startMonitoring: { display, metadata in try await capture.startMonitoring(display: display, metadata: metadata) } @@ -50,7 +54,6 @@ final class CaptureChooseViewModel { } let catalog: ScreenCaptureDisplayCatalogState - var startingDisplayIDs: Set = [] var userFacingAlert: UserFacingAlertState? private let topologyCoordinator: ScreenCaptureCatalogTopologyCoordinator @@ -100,46 +103,43 @@ final class CaptureChooseViewModel { topologyCoordinator.visibleDisplays(from: displays) } - @discardableResult - func withDisplayStartLock( - displayID: CGDirectDisplayID, - operation: () async -> Void - ) async -> Bool { - guard !startingDisplayIDs.contains(displayID) else { return false } - startingDisplayIDs.insert(displayID) - defer { startingDisplayIDs.remove(displayID) } - await operation() - return true + func isStarting(displayID: CGDirectDisplayID) -> Bool { + dependencies.captureActions.isStartingDisplayID(displayID) } func startMonitoring( display: SCDisplay, openWindow: @escaping (UUID) -> Void ) async { - _ = await withDisplayStartLock(displayID: display.displayID) { - if let existingSession = dependencies.captureActions.monitoringSessionForDisplayID(display.displayID) { - openWindow(existingSession.id) - return - } + if let existingSession = dependencies.captureActions.monitoringSessionForDisplayID(display.displayID) { + openWindow(existingSession.id) + return + } + guard !isStarting(displayID: display.displayID) else { return } - do { - let metadata = CaptureMonitoringDisplayMetadata( - displayName: displayName(for: display), - resolutionText: resolutionText(for: display), - isVirtualDisplay: isVirtualDisplay(display) - ) - let sessionID = try await dependencies.captureActions.startMonitoring(display, metadata) + do { + let metadata = CaptureMonitoringDisplayMetadata( + displayName: displayName(for: display), + resolutionText: resolutionText(for: display), + isVirtualDisplay: isVirtualDisplay(display) + ) + let outcome = try await dependencies.captureActions.startMonitoring(display, metadata) + switch outcome { + case .started(let sessionID): openWindow(sessionID) - } catch { - AppErrorMapper.logFailure("Start monitoring", error: error, logger: AppLog.capture) - userFacingAlert = UserFacingAlertState( - title: String(localized: "Start Monitoring Failed"), - message: AppErrorMapper.userMessage( - for: error, - fallback: String(localized: "Failed to start monitoring.") - ) - ) + case .invalidated: + break } + } catch is CancellationError { + } catch { + AppErrorMapper.logFailure("Start monitoring", error: error, logger: AppLog.capture) + userFacingAlert = UserFacingAlertState( + title: String(localized: "Start Monitoring Failed"), + message: AppErrorMapper.userMessage( + for: error, + fallback: String(localized: "Failed to start monitoring.") + ) + ) } } diff --git a/VoidDisplay/Features/Capture/Views/CaptureChoose.swift b/VoidDisplay/Features/Capture/Views/CaptureChoose.swift index bfc5dcc..e2b9f3b 100644 --- a/VoidDisplay/Features/Capture/Views/CaptureChoose.swift +++ b/VoidDisplay/Features/Capture/Views/CaptureChoose.swift @@ -190,7 +190,7 @@ struct IsCapturing: View { let isPrimaryDisplay = CGDisplayIsMain(display.displayID) != 0 let monitoringSession = capture.screenCaptureSessions.first(where: { $0.displayID == display.displayID }) let isMonitoring = monitoringSession?.state == .active - let isStarting = viewModel.startingDisplayIDs.contains(display.displayID) || monitoringSession?.state == .starting + let isStarting = capture.isStarting(displayID: display.displayID) || monitoringSession?.state == .starting return CaptureDisplayRow( display: display, diff --git a/VoidDisplay/Features/Sharing/Services/DisplaySharingCoordinator.swift b/VoidDisplay/Features/Sharing/Services/DisplaySharingCoordinator.swift index 388cbd8..1a92b7e 100644 --- a/VoidDisplay/Features/Sharing/Services/DisplaySharingCoordinator.swift +++ b/VoidDisplay/Features/Sharing/Services/DisplaySharingCoordinator.swift @@ -4,6 +4,11 @@ import ScreenCaptureKit @MainActor final class DisplaySharingCoordinator { + typealias AcquireShare = @MainActor ( + SCDisplay, + DisplayStartInvalidationContext + ) async throws -> DisplayStartOutcome + struct ShareableDisplayRegistrationInput { let displayID: CGDirectDisplayID let isMain: Bool @@ -26,14 +31,23 @@ final class DisplaySharingCoordinator { private var sessionsByDisplayID: [CGDirectDisplayID: SharingSession] = [:] private var mainDisplayID: CGDirectDisplayID? private let idStore: DisplayShareIDStore - private let captureRegistry: DisplayCaptureRegistry + private let startCoordinator: DisplayStreamStartCoordinator + private let acquireShare: AcquireShare init( idStore: DisplayShareIDStore, - captureRegistry: DisplayCaptureRegistry = .shared + startCoordinator: DisplayStreamStartCoordinator = DisplayStreamStartCoordinator(), + captureRegistry: DisplayCaptureRegistry = .shared, + acquireShare: AcquireShare? = nil ) { self.idStore = idStore - self.captureRegistry = captureRegistry + self.startCoordinator = startCoordinator + self.acquireShare = acquireShare ?? { display, invalidationContext in + try await captureRegistry.acquireShare( + display: SendableDisplay(display), + invalidationContext: invalidationContext + ) + } } var hasAnyActiveSharing: Bool { @@ -44,6 +58,10 @@ final class DisplaySharingCoordinator { Set(sessionsByDisplayID.keys) } + func isStarting(displayID: CGDirectDisplayID) -> Bool { + startCoordinator.isStarting(kind: .sharing, displayID: displayID) + } + func registerShareableDisplays( _ displays: [SCDisplay], virtualSerialResolver: (CGDirectDisplayID) -> UInt32? @@ -106,9 +124,16 @@ final class DisplaySharingCoordinator { nextDisplayIDsByShareID[shareID] = input.displayID } + let displayIDsToInvalidate = invalidatedDisplayIDs( + current: registrationsByDisplayID, + next: nextRegistrationsByDisplayID + ) registrationsByDisplayID = nextRegistrationsByDisplayID displayIDsByShareID = nextDisplayIDsByShareID mainDisplayID = resolvedMainDisplayID ?? mainDisplayID + for displayID in displayIDsToInvalidate { + startCoordinator.invalidate(kind: .sharing, displayID: displayID) + } let registeredDisplayIDs = Set(nextRegistrationsByDisplayID.keys) for displayID in Array(sessionsByDisplayID.keys) where !registeredDisplayIDs.contains(displayID) { @@ -116,35 +141,82 @@ final class DisplaySharingCoordinator { } } - func startSharing(display: SCDisplay) async throws { - stopSharing(displayID: display.displayID) - let subscription = try await captureRegistry.acquireShare(display: SendableDisplay(display)) - try await subscription.prepareForSharing() - sessionsByDisplayID[display.displayID] = SharingSession(display: display, subscription: subscription) - if CGDisplayIsMain(display.displayID) != 0 { - mainDisplayID = display.displayID + func startSharing(display: SCDisplay) async throws -> DisplayStartOutcome { + let displayID = display.displayID + guard registrationsByDisplayID[displayID] != nil else { + throw SharingStartError.displayNotRegistered(displayID) + } + + return try await startCoordinator.start( + kind: .sharing, + displayID: displayID + ) { [self, acquireShare] invalidationContext in + guard self.registrationsByDisplayID[displayID] != nil else { + return .invalidated + } + self.stopActiveSharingSession(displayID: displayID) + + let subscription: DisplayShareSubscription + switch try await acquireShare(display, invalidationContext) { + case .invalidated: + return .invalidated + case .started(let acquiredSubscription): + subscription = acquiredSubscription + } + + if invalidationContext.isInvalidated() || self.registrationsByDisplayID[displayID] == nil { + subscription.cancel() + return .invalidated + } + + switch try await subscription.prepareForSharing(invalidationContext: invalidationContext) { + case .invalidated: + return .invalidated + case .started: + break + } + + if invalidationContext.isInvalidated() || self.registrationsByDisplayID[displayID] == nil { + subscription.cancel() + return .invalidated + } + + if invalidationContext.isInvalidated() || self.registrationsByDisplayID[displayID] == nil { + subscription.cancel() + return .invalidated + } + self.sessionsByDisplayID[displayID] = SharingSession(display: display, subscription: subscription) + if CGDisplayIsMain(displayID) != 0 { + self.mainDisplayID = displayID + } + return .started(()) } } func stopSharing(displayID: CGDirectDisplayID) { + startCoordinator.invalidate(kind: .sharing, displayID: displayID) + stopActiveSharingSession(displayID: displayID) + } + + private func stopActiveSharingSession(displayID: CGDirectDisplayID) { guard let session = sessionsByDisplayID.removeValue(forKey: displayID) else { return } session.subscription.cancel() } func stopAllSharing() { + startCoordinator.invalidateAll(kind: .sharing) for displayID in Array(sessionsByDisplayID.keys) { - stopSharing(displayID: displayID) + stopActiveSharingSession(displayID: displayID) } } func state(for target: ShareTarget) -> ShareTargetState { switch target { case .main: - if let resolvedMainID = resolvedMainDisplayID(), - sessionsByDisplayID[resolvedMainID] != nil { - return .active + guard let resolvedMainID = resolvedMainDisplayID() else { + return .knownInactive } - return .knownInactive + return sessionsByDisplayID[resolvedMainID] != nil ? .active : .knownInactive case .id(let id): guard let displayID = displayIDsByShareID[id] else { return .unknown @@ -196,6 +268,25 @@ final class DisplaySharingCoordinator { return nil } + private func invalidatedDisplayIDs( + current: [CGDirectDisplayID: DisplayRegistration], + next: [CGDirectDisplayID: DisplayRegistration] + ) -> Set { + let allDisplayIDs = Set(current.keys).union(next.keys) + return Set( + allDisplayIDs.filter { displayID in + switch (current[displayID], next[displayID]) { + case (.none, .some), (.some, .none): + true + case (.some(let old), .some(let new)): + old.shareID != new.shareID || old.isMain != new.isMain + case (.none, .none): + false + } + } + ) + } + private func makeIdentityKey(for displayID: CGDirectDisplayID) -> String { if let cfUUID = CGDisplayCreateUUIDFromDisplayID(displayID)?.takeRetainedValue() { let uuidString = CFUUIDCreateString(nil, cfUUID) as String diff --git a/VoidDisplay/Features/Sharing/Services/SharingService.swift b/VoidDisplay/Features/Sharing/Services/SharingService.swift index 480c39f..2f4457e 100644 --- a/VoidDisplay/Features/Sharing/Services/SharingService.swift +++ b/VoidDisplay/Features/Sharing/Services/SharingService.swift @@ -3,6 +3,17 @@ import ScreenCaptureKit import OSLog import CoreGraphics +enum SharingStartError: LocalizedError, Equatable { + case displayNotRegistered(CGDirectDisplayID) + + var errorDescription: String? { + switch self { + case .displayNotRegistered: + String(localized: "Selected display is no longer available for sharing.") + } + } +} + @MainActor protocol SharingServiceProtocol: AnyObject { var webServicePortValue: UInt16 { get } @@ -14,6 +25,7 @@ protocol SharingServiceProtocol: AnyObject { var currentWebServer: WebServer? { get } var hasAnyActiveSharing: Bool { get } var activeSharingDisplayIDs: Set { get } + func isStarting(displayID: CGDirectDisplayID) -> Bool @discardableResult func startWebService(requestedPort: UInt16) async -> WebServiceStartResult @@ -22,7 +34,7 @@ protocol SharingServiceProtocol: AnyObject { _ displays: [SCDisplay], virtualSerialResolver: (CGDirectDisplayID) -> UInt32? ) - func startSharing(display: SCDisplay) async throws + func startSharing(display: SCDisplay) async throws -> DisplayStartOutcome func stopSharing(displayID: CGDirectDisplayID) func stopAllSharing() func isSharing(displayID: CGDirectDisplayID) -> Bool @@ -86,6 +98,10 @@ final class SharingService: SharingServiceProtocol { sharingCoordinator.activeSharingDisplayIDs } + func isStarting(displayID: CGDirectDisplayID) -> Bool { + sharingCoordinator.isStarting(displayID: displayID) + } + @discardableResult func startWebService(requestedPort: UInt16) async -> WebServiceStartResult { let result = await webServiceController.start( @@ -120,9 +136,9 @@ final class SharingService: SharingServiceProtocol { ) } - func startSharing(display: SCDisplay) async throws { + func startSharing(display: SCDisplay) async throws -> DisplayStartOutcome { AppLog.sharing.info("Begin sharing stream for display \(display.displayID, privacy: .public).") - try await sharingCoordinator.startSharing(display: display) + return try await sharingCoordinator.startSharing(display: display) } func stopSharing(displayID: CGDirectDisplayID) { diff --git a/VoidDisplay/Features/Sharing/ViewModels/ShareViewModel.swift b/VoidDisplay/Features/Sharing/ViewModels/ShareViewModel.swift index e3f9b37..551e325 100644 --- a/VoidDisplay/Features/Sharing/ViewModels/ShareViewModel.swift +++ b/VoidDisplay/Features/Sharing/ViewModels/ShareViewModel.swift @@ -12,6 +12,7 @@ final class ShareViewModel { struct SharingQueries { var isWebServiceRunning: @MainActor () -> Bool + var isStartingDisplayID: @MainActor (CGDirectDisplayID) -> Bool var sharePageAddress: @MainActor (CGDirectDisplayID) -> String? var preferredWebServicePort: @MainActor () -> UInt16 } @@ -20,7 +21,7 @@ final class ShareViewModel { var startWebService: @MainActor (UInt16) async -> WebServiceStartResult var stopWebService: @MainActor () -> Void var registerShareableDisplays: @MainActor ([SCDisplay], @escaping (CGDirectDisplayID) -> UInt32?) -> Void - var beginSharing: @MainActor (SCDisplay) async throws -> Void + var beginSharing: @MainActor (SCDisplay) async throws -> DisplayStartOutcome var stopSharing: @MainActor (CGDirectDisplayID) -> Void } @@ -40,6 +41,7 @@ final class ShareViewModel { .init( sharingQueries: .init( isWebServiceRunning: { sharing.isWebServiceRunning }, + isStartingDisplayID: { displayID in sharing.isStarting(displayID: displayID) }, sharePageAddress: { displayID in sharing.sharePageAddress(for: displayID) }, preferredWebServicePort: { sharing.preferredWebServicePort } ), @@ -81,7 +83,6 @@ final class ShareViewModel { } var portInputErrorMessage: String? var isStartingService = false - var startingDisplayIDs: Set = [] var userFacingAlert: UserFacingAlertState? private let topologyCoordinator: ScreenCaptureCatalogTopologyCoordinator @@ -238,57 +239,52 @@ final class ShareViewModel { topologyCoordinator.visibleDisplays(from: displays) } - @discardableResult - func withDisplayStartLock( - displayID: CGDirectDisplayID, - operation: () async -> Void - ) async -> Bool { - guard !startingDisplayIDs.contains(displayID) else { return false } - startingDisplayIDs.insert(displayID) - defer { startingDisplayIDs.remove(displayID) } - await operation() - return true + func isStarting(displayID: CGDirectDisplayID) -> Bool { + dependencies.sharingQueries.isStartingDisplayID(displayID) } func startSharing(display: SCDisplay) async { - _ = await withDisplayStartLock(displayID: display.displayID) { - let ready: Bool - if dependencies.sharingQueries.isWebServiceRunning() { - ready = true - } else { - let requestedPort: UInt16 - switch SharePortValidationError.parse(servicePortInput) { - case .success(let parsed): - requestedPort = parsed - case .failure(let validationError): - presentPortInputError(validationError.userMessage) - return - } - let result = await dependencies.sharingActions.startWebService(requestedPort) - if case .failed(let failure) = result { - presentPortInputError(failure.userMessage) - return - } - ready = true + guard !isStarting(displayID: display.displayID) else { return } + + let ready: Bool + if dependencies.sharingQueries.isWebServiceRunning() { + ready = true + } else { + let requestedPort: UInt16 + switch SharePortValidationError.parse(servicePortInput) { + case .success(let parsed): + requestedPort = parsed + case .failure(let validationError): + presentPortInputError(validationError.userMessage) + return } - guard ready else { - presentError( - title: String(localized: "Share Failed"), - message: String(localized: "Web service is not running.") - ) + let result = await dependencies.sharingActions.startWebService(requestedPort) + if case .failed(let failure) = result { + presentPortInputError(failure.userMessage) return } + ready = true + } + guard ready else { + presentError( + title: String(localized: "Share Failed"), + message: String(localized: "Web service is not running.") + ) + return + } - do { - try await dependencies.sharingActions.beginSharing(display) - } catch { - dependencies.sharingActions.stopSharing(display.displayID) - AppErrorMapper.logFailure("Start sharing", error: error, logger: AppLog.sharing) - presentError( - title: String(localized: "Share Failed"), - message: AppErrorMapper.userMessage(for: error, fallback: String(localized: "Failed to start sharing.")) - ) + do { + let outcome = try await dependencies.sharingActions.beginSharing(display) + if case .invalidated = outcome { + return } + } catch { + dependencies.sharingActions.stopSharing(display.displayID) + AppErrorMapper.logFailure("Start sharing", error: error, logger: AppLog.sharing) + presentError( + title: String(localized: "Share Failed"), + message: AppErrorMapper.userMessage(for: error, fallback: String(localized: "Failed to start sharing.")) + ) } } diff --git a/VoidDisplay/Features/Sharing/Views/ShareDisplayList.swift b/VoidDisplay/Features/Sharing/Views/ShareDisplayList.swift index 6d3acb0..d5c511d 100644 --- a/VoidDisplay/Features/Sharing/Views/ShareDisplayList.swift +++ b/VoidDisplay/Features/Sharing/Views/ShareDisplayList.swift @@ -62,6 +62,7 @@ struct ShareDisplayList: View { let displayURL = displayAddress.flatMap(URL.init(string:)) let displayClientCount = sharing.sharingClientCounts[display.displayID] ?? 0 let isPrimaryDisplay = CGDisplayIsMain(display.displayID) != 0 + let isStartingDisplay = sharing.isStarting(displayID: display.displayID) let isMonitoring = capture.screenCaptureSessions.contains { $0.displayID == display.displayID } @@ -103,7 +104,8 @@ struct ShareDisplayList: View { displayAddress: displayAddress, displayURL: displayURL, displayClientCount: displayClientCount, - isSharingDisplay: isSharingDisplay + isSharingDisplay: isSharingDisplay, + isStartingDisplay: isStartingDisplay ) } } @@ -114,7 +116,8 @@ struct ShareDisplayList: View { displayAddress: String?, displayURL: URL?, displayClientCount: Int, - isSharingDisplay: Bool + isSharingDisplay: Bool, + isStartingDisplay: Bool ) -> some View { ViewThatFits(in: .horizontal) { HStack(alignment: .center, spacing: AppUI.Spacing.medium) { @@ -127,7 +130,11 @@ struct ShareDisplayList: View { openURLAction: openURLAction ) - shareActionButton(display: display, isSharingDisplay: isSharingDisplay) + shareActionButton( + display: display, + isSharingDisplay: isSharingDisplay, + isStartingDisplay: isStartingDisplay + ) } VStack(alignment: .trailing, spacing: AppUI.Spacing.small) { @@ -140,14 +147,22 @@ struct ShareDisplayList: View { openURLAction: openURLAction ) - shareActionButton(display: display, isSharingDisplay: isSharingDisplay) + shareActionButton( + display: display, + isSharingDisplay: isSharingDisplay, + isStartingDisplay: isStartingDisplay + ) } } .frame(maxWidth: 560, alignment: .trailing) } @ViewBuilder - private func shareActionButton(display: SCDisplay, isSharingDisplay: Bool) -> some View { + private func shareActionButton( + display: SCDisplay, + isSharingDisplay: Bool, + isStartingDisplay: Bool + ) -> some View { Button { if isSharingDisplay { viewModel.stopSharing(displayID: display.displayID) @@ -159,19 +174,23 @@ struct ShareDisplayList: View { } label: { ZStack { Label(String(localized: "Share"), systemImage: "play.fill").hidden() + Label(String(localized: "Starting"), systemImage: "hourglass").hidden() Label(String(localized: "Stop"), systemImage: "stop.fill").hidden() if isSharingDisplay { Label(String(localized: "Stop"), systemImage: "stop.fill") + } else if isStartingDisplay { + Label(String(localized: "Starting"), systemImage: "hourglass") } else { Label(String(localized: "Share"), systemImage: "play.fill") } } } .appActionButtonStyle(variant: isSharingDisplay ? .danger : .primary) + .disabled(isStartingDisplay) .accessibilityIdentifier("share_action_button_\(display.displayID)") .accessibilityValue( - Text(verbatim: isSharingDisplay ? ShareAccessibilityState.sharing : ShareAccessibilityState.idle) + Text(verbatim: isSharingDisplay ? ShareAccessibilityState.sharing : (isStartingDisplay ? "starting" : ShareAccessibilityState.idle)) ) } } diff --git a/VoidDisplay/Resources/Localizable.xcstrings b/VoidDisplay/Resources/Localizable.xcstrings index c34a66a..12e6d82 100644 --- a/VoidDisplay/Resources/Localizable.xcstrings +++ b/VoidDisplay/Resources/Localizable.xcstrings @@ -554,6 +554,17 @@ } } }, + "Display sharing state changed. Try again." : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示共享状态已变化,请重试。" + } + } + } + }, "Display teardown timed out. Wait a moment and try rebuilding again." : { "localizations" : { "zh-Hans" : { @@ -1574,6 +1585,16 @@ } } }, + "Selected display is no longer available for sharing." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "所选显示器已不再可用于共享。" + } + } + } + }, "Serial Number" : { "localizations" : { "zh-Hans" : { @@ -1994,4 +2015,4 @@ } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/VoidDisplay/Shared/Testing/ScreenCapturePermissionProvider.swift b/VoidDisplay/Shared/Testing/ScreenCapturePermissionProvider.swift index d65b71e..c653317 100644 --- a/VoidDisplay/Shared/Testing/ScreenCapturePermissionProvider.swift +++ b/VoidDisplay/Shared/Testing/ScreenCapturePermissionProvider.swift @@ -42,11 +42,35 @@ struct UITestScreenCapturePermissionProvider: ScreenCapturePermissionProvider { } } +struct XCTestScreenCapturePermissionProvider: ScreenCapturePermissionProvider { + nonisolated func preflight() -> Bool { + false + } + + nonisolated func request() -> Bool { + false + } +} + enum ScreenCapturePermissionProviderFactory { - static func makeDefault() -> any ScreenCapturePermissionProvider { - guard UITestRuntime.isEnabled else { - return SystemScreenCapturePermissionProvider() + static func makeDefault( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> any ScreenCapturePermissionProvider { + if environment[UITestRuntime.modeEnvironmentKey] == "1" { + let scenario: UITestScenario + if let rawValue = environment[UITestRuntime.scenarioEnvironmentKey], + let resolvedScenario = UITestScenario(rawValue: rawValue) { + scenario = resolvedScenario + } else { + scenario = .baseline + } + return UITestScreenCapturePermissionProvider(scenario: scenario) } - return UITestScreenCapturePermissionProvider(scenario: UITestRuntime.scenario) + + if environment[PersistenceContext.xCTestConfigurationEnvironmentKey] != nil { + return XCTestScreenCapturePermissionProvider() + } + + return SystemScreenCapturePermissionProvider() } } diff --git a/VoidDisplayTests/App/CaptureControllerTests.swift b/VoidDisplayTests/App/CaptureControllerTests.swift index 2e8a6ac..2fddf70 100644 --- a/VoidDisplayTests/App/CaptureControllerTests.swift +++ b/VoidDisplayTests/App/CaptureControllerTests.swift @@ -69,6 +69,90 @@ private enum CaptureControllerMockSCDisplay { } } +private actor CaptureControllerAsyncGate { + private var waitCount = 0 + private var continuations: [CheckedContinuation] = [] + + func wait() async { + waitCount += 1 + await withCheckedContinuation { continuation in + continuations.append(continuation) + } + } + + func releaseOne() { + guard !continuations.isEmpty else { return } + continuations.removeFirst().resume() + } + + func currentWaitCount() -> Int { + waitCount + } +} + +@MainActor +private final class CaptureControllerLifecycleSpy: CaptureMonitoringLifecycleServiceProtocol { + typealias StartMonitoringHandler = @MainActor ( + SCDisplay, + CaptureMonitoringDisplayMetadata + ) async throws -> DisplayStartOutcome + + private let base: CaptureMonitoringLifecycleService + private let eventRecorder: ((String) -> Void)? + var startMonitoringHandler: StartMonitoringHandler? + + private(set) var removeByDisplayCallCount = 0 + private(set) var removedDisplayIDs: [CGDirectDisplayID] = [] + + init( + captureMonitoringService: any CaptureMonitoringServiceProtocol, + eventRecorder: ((String) -> Void)? = nil + ) { + self.base = CaptureMonitoringLifecycleService(captureMonitoringService: captureMonitoringService) + self.eventRecorder = eventRecorder + } + + func isStarting(displayID: CGDirectDisplayID) -> Bool { + base.isStarting(displayID: displayID) + } + + func startMonitoring( + display: SCDisplay, + metadata: CaptureMonitoringDisplayMetadata + ) async throws -> DisplayStartOutcome { + if let startMonitoringHandler { + return try await startMonitoringHandler(display, metadata) + } + return try await base.startMonitoring(display: display, metadata: metadata) + } + + func activateMonitoringSession(id: UUID) { + base.activateMonitoringSession(id: id) + } + + func attachPreviewSink(_ sink: any DisplayPreviewSink, to id: UUID) { + base.attachPreviewSink(sink, to: id) + } + + func setMonitoringSessionCapturesCursor( + id: UUID, + capturesCursor: Bool + ) async throws { + try await base.setMonitoringSessionCapturesCursor(id: id, capturesCursor: capturesCursor) + } + + func closeMonitoringSession(id: UUID) { + base.closeMonitoringSession(id: id) + } + + func removeMonitoringSessions(displayID: CGDirectDisplayID) { + removeByDisplayCallCount += 1 + removedDisplayIDs.append(displayID) + eventRecorder?("removeMonitoring:\(displayID)") + base.removeMonitoringSessions(displayID: displayID) + } +} + @Suite(.serialized) @MainActor struct CaptureControllerTests { @@ -100,7 +184,7 @@ struct CaptureControllerTests { ) let lifecycleService = CaptureMonitoringLifecycleService( captureMonitoringService: service, - acquirePreview: { _ in subscription } + acquirePreview: { _, _ in .started(subscription) } ) let controller = CaptureController( captureMonitoringService: service, @@ -113,11 +197,273 @@ struct CaptureControllerTests { isVirtualDisplay: false ) - let sessionID = try await controller.startMonitoring(display: display, metadata: metadata) + let result = try await controller.startMonitoring(display: display, metadata: metadata) + guard case .started(let sessionID) = result else { + Issue.record("Expected monitoring start to succeed.") + return + } #expect(service.addCallCount == 1) #expect(controller.monitoringSession(for: sessionID)?.displayName == "Display 77") assertSnapshotMatchesService(controller: controller, service: service) + #expect(controller.startingDisplayIDs.isEmpty) + } + + @Test func startMonitoringPublishesStartingDisplayIDWhileRequestIsInFlight() async throws { + let service = MockCaptureMonitoringService() + let gate = CaptureControllerAsyncGate() + let expectedSessionID = UUID() + let lifecycleService = CaptureControllerLifecycleSpy(captureMonitoringService: service) + lifecycleService.startMonitoringHandler = { _, _ in + await gate.wait() + return .started(expectedSessionID) + } + let controller = CaptureController( + captureMonitoringService: service, + captureMonitoringLifecycleService: lifecycleService + ) + let display = CaptureControllerMockSCDisplay.make(displayID: 781, width: 1920, height: 1080) + let metadata = CaptureMonitoringDisplayMetadata( + displayName: "Display 781", + resolutionText: "1920 × 1080", + isVirtualDisplay: false + ) + + let task = Task { @MainActor in + try await controller.startMonitoring(display: display, metadata: metadata) + } + + #expect(await waitForCaptureControllerGate(gate, count: 1)) + #expect(controller.startingDisplayIDs == [display.displayID]) + #expect(controller.isStarting(displayID: display.displayID)) + + await gate.releaseOne() + let outcome = try await task.value + guard case .started(let sessionID) = outcome else { + Issue.record("Expected monitoring start to succeed.") + return + } + + #expect(sessionID == expectedSessionID) + #expect(controller.startingDisplayIDs.isEmpty) + #expect(controller.isStarting(displayID: display.displayID) == false) + } + + @Test func duplicateStartMonitoringCallsShareSameUnderlyingStartOutcome() async throws { + let service = MockCaptureMonitoringService() + let gate = CaptureControllerAsyncGate() + let previewSession = CaptureControllerDummySession() + let previewSubscription = DisplayPreviewSubscription( + displayID: 785, + resolutionText: "1920 × 1080", + session: previewSession, + cancelClosure: {} + ) + let lifecycleService = CaptureMonitoringLifecycleService( + captureMonitoringService: service, + acquirePreview: { _, _ in + await gate.wait() + return .started(previewSubscription) + } + ) + let controller = CaptureController( + captureMonitoringService: service, + captureMonitoringLifecycleService: lifecycleService + ) + let display = CaptureControllerMockSCDisplay.make(displayID: 785, width: 1920, height: 1080) + let metadata = CaptureMonitoringDisplayMetadata( + displayName: "Display 785", + resolutionText: "1920 × 1080", + isVirtualDisplay: false + ) + + let firstTask = Task { @MainActor in + try await controller.startMonitoring(display: display, metadata: metadata) + } + #expect(await waitForCaptureControllerGate(gate, count: 1)) + + let secondTask = Task { @MainActor in + try await controller.startMonitoring(display: display, metadata: metadata) + } + + let deadline = DispatchTime.now().uptimeNanoseconds + AsyncTestTimeouts.shortStabilityWindow + var observedSecondAcquire = false + while DispatchTime.now().uptimeNanoseconds < deadline { + if await gate.currentWaitCount() > 1 { + observedSecondAcquire = true + break + } + await Task.yield() + } + + #expect(observedSecondAcquire == false) + #expect(controller.startingDisplayIDs == [display.displayID]) + + await gate.releaseOne() + + let firstOutcome = try await firstTask.value + let secondOutcome = try await secondTask.value + guard + case .started(let firstSessionID) = firstOutcome, + case .started(let secondSessionID) = secondOutcome + else { + Issue.record("Expected both monitoring starts to succeed.") + return + } + + #expect(firstSessionID == secondSessionID) + #expect(service.addCallCount == 1) + #expect(controller.startingDisplayIDs.isEmpty) + } + + @Test func cancellingOneWaitingStartMonitoringCallKeepsObservedStartingStateUntilLastWaiterFinishes() async throws { + let service = MockCaptureMonitoringService() + let gate = CaptureControllerAsyncGate() + let expectedSessionID = UUID() + let lifecycleService = CaptureControllerLifecycleSpy(captureMonitoringService: service) + lifecycleService.startMonitoringHandler = { _, _ in + await gate.wait() + try Task.checkCancellation() + return .started(expectedSessionID) + } + let controller = CaptureController( + captureMonitoringService: service, + captureMonitoringLifecycleService: lifecycleService + ) + let display = CaptureControllerMockSCDisplay.make(displayID: 786, width: 1920, height: 1080) + let metadata = CaptureMonitoringDisplayMetadata( + displayName: "Display 786", + resolutionText: "1920 × 1080", + isVirtualDisplay: false + ) + + let firstTask = Task { @MainActor in + try await controller.startMonitoring(display: display, metadata: metadata) + } + let secondTask = Task { @MainActor in + try await controller.startMonitoring(display: display, metadata: metadata) + } + + #expect(await waitForCaptureControllerGate(gate, count: 2)) + #expect(controller.startingDisplayIDs == [display.displayID]) + + firstTask.cancel() + await gate.releaseOne() + + do { + _ = try await firstTask.value + Issue.record("Expected first monitoring start to be cancelled.") + } catch is CancellationError { + } catch { + Issue.record("Expected CancellationError, got \(error)") + } + + #expect(controller.startingDisplayIDs == [display.displayID]) + #expect(controller.isStarting(displayID: display.displayID)) + + await gate.releaseOne() + let secondOutcome = try await secondTask.value + guard case .started(let sessionID) = secondOutcome else { + Issue.record("Expected second monitoring start to succeed.") + return + } + + #expect(sessionID == expectedSessionID) + #expect(controller.startingDisplayIDs.isEmpty) + } + + @Test func startMonitoringClearsStartingDisplayIDAfterFailure() async { + struct ControlledError: Error {} + + let service = MockCaptureMonitoringService() + let lifecycleService = CaptureControllerLifecycleSpy(captureMonitoringService: service) + lifecycleService.startMonitoringHandler = { _, _ in + throw ControlledError() + } + let controller = CaptureController( + captureMonitoringService: service, + captureMonitoringLifecycleService: lifecycleService + ) + let display = CaptureControllerMockSCDisplay.make(displayID: 782, width: 1920, height: 1080) + let metadata = CaptureMonitoringDisplayMetadata( + displayName: "Display 782", + resolutionText: "1920 × 1080", + isVirtualDisplay: false + ) + + do { + _ = try await controller.startMonitoring(display: display, metadata: metadata) + Issue.record("Expected monitoring start to fail.") + } catch { + } + + #expect(controller.startingDisplayIDs.isEmpty) + #expect(controller.isStarting(displayID: display.displayID) == false) + } + + @Test func startMonitoringClearsStartingDisplayIDAfterCancellation() async { + let service = MockCaptureMonitoringService() + let gate = CaptureControllerAsyncGate() + let lifecycleService = CaptureControllerLifecycleSpy(captureMonitoringService: service) + lifecycleService.startMonitoringHandler = { _, _ in + await gate.wait() + try Task.checkCancellation() + return .started(UUID()) + } + let controller = CaptureController( + captureMonitoringService: service, + captureMonitoringLifecycleService: lifecycleService + ) + let display = CaptureControllerMockSCDisplay.make(displayID: 783, width: 1920, height: 1080) + let metadata = CaptureMonitoringDisplayMetadata( + displayName: "Display 783", + resolutionText: "1920 × 1080", + isVirtualDisplay: false + ) + + let task = Task { @MainActor in + try await controller.startMonitoring(display: display, metadata: metadata) + } + + #expect(await waitForCaptureControllerGate(gate, count: 1)) + #expect(controller.startingDisplayIDs == [display.displayID]) + + task.cancel() + await gate.releaseOne() + + do { + _ = try await task.value + Issue.record("Expected monitoring start to be cancelled.") + } catch is CancellationError { + } catch { + Issue.record("Expected CancellationError, got \(error)") + } + + #expect(controller.startingDisplayIDs.isEmpty) + } + + @Test func startMonitoringClearsStartingDisplayIDAfterInvalidation() async throws { + let service = MockCaptureMonitoringService() + let lifecycleService = CaptureControllerLifecycleSpy(captureMonitoringService: service) + lifecycleService.startMonitoringHandler = { _, _ in .invalidated } + let controller = CaptureController( + captureMonitoringService: service, + captureMonitoringLifecycleService: lifecycleService + ) + let display = CaptureControllerMockSCDisplay.make(displayID: 784, width: 1920, height: 1080) + let metadata = CaptureMonitoringDisplayMetadata( + displayName: "Display 784", + resolutionText: "1920 × 1080", + isVirtualDisplay: false + ) + + let outcome = try await controller.startMonitoring(display: display, metadata: metadata) + + if case .invalidated = outcome { + } else { + Issue.record("Expected invalidated outcome.") + } + #expect(controller.startingDisplayIDs.isEmpty) } @Test func activateMonitoringSessionRefreshesSnapshot() { @@ -191,11 +537,19 @@ struct CaptureControllerTests { let first = makeSession(id: UUID(), displayID: 93).session let second = makeSession(id: UUID(), displayID: 94).session service.currentSessions = [first, second] - let controller = CaptureController(captureMonitoringService: service) + let lifecycleService = CaptureControllerLifecycleSpy(captureMonitoringService: service) + let controller = CaptureController( + captureMonitoringService: service, + captureMonitoringLifecycleService: lifecycleService + ) + controller.startingDisplayIDs = [93] controller.removeMonitoringSessions(displayID: 93) #expect(controller.screenCaptureSessions.map(\.displayID) == [94]) + #expect(controller.startingDisplayIDs.isEmpty) + #expect(lifecycleService.removeByDisplayCallCount == 1) + #expect(lifecycleService.removedDisplayIDs == [93]) #expect(service.removeByDisplayCallCount == 1) #expect(service.removedDisplayIDs == [93]) assertSnapshotMatchesService(controller: controller, service: service) @@ -236,21 +590,34 @@ struct CaptureControllerTests { let service = MockCaptureMonitoringService() let session = makeSession(id: UUID(), displayID: 123).session service.currentSessions = [session] + var eventLog: [String] = [] let sharingService = MockSharingService() sharingService.activeSharingDisplayIDs = [123] sharingService.hasAnyActiveSharing = true + sharingService.onStopSharing = { displayID in + eventLog.append("stopSharing:\(displayID)") + } let sharingController = SharingController( sharingService: sharingService, portPreferences: SharingPortPreferences(defaults: UserDefaults(suiteName: "CaptureControllerTests")!) ) - let controller = CaptureController(captureMonitoringService: service) + let lifecycleService = CaptureControllerLifecycleSpy( + captureMonitoringService: service, + eventRecorder: { eventLog.append($0) } + ) + let controller = CaptureController( + captureMonitoringService: service, + captureMonitoringLifecycleService: lifecycleService + ) controller.stopDependentStreamsBeforeRebuild( displayID: 123, sharingController: sharingController ) + #expect(eventLog == ["stopSharing:123", "removeMonitoring:123"]) #expect(sharingService.stopSharingCallCount == 1) + #expect(lifecycleService.removeByDisplayCallCount == 1) #expect(service.removeByDisplayCallCount == 1) #expect(controller.screenCaptureSessions.isEmpty) assertSnapshotMatchesService(controller: controller, service: service) @@ -260,19 +627,29 @@ struct CaptureControllerTests { let service = MockCaptureMonitoringService() let session = makeSession(id: UUID(), displayID: 124).session service.currentSessions = [session] + var eventLog: [String] = [] let sharingService = MockSharingService() let sharingController = SharingController( sharingService: sharingService, portPreferences: SharingPortPreferences(defaults: UserDefaults(suiteName: "CaptureControllerTestsNoShare")!) ) - let controller = CaptureController(captureMonitoringService: service) + let lifecycleService = CaptureControllerLifecycleSpy( + captureMonitoringService: service, + eventRecorder: { eventLog.append($0) } + ) + let controller = CaptureController( + captureMonitoringService: service, + captureMonitoringLifecycleService: lifecycleService + ) controller.stopDependentStreamsBeforeRebuild( displayID: 124, sharingController: sharingController ) + #expect(eventLog == ["removeMonitoring:124"]) #expect(sharingService.stopSharingCallCount == 0) + #expect(lifecycleService.removeByDisplayCallCount == 1) #expect(service.removeByDisplayCallCount == 1) #expect(controller.screenCaptureSessions.isEmpty) assertSnapshotMatchesService(controller: controller, service: service) @@ -326,3 +703,19 @@ struct CaptureControllerTests { } } } + +private func waitForCaptureControllerGate( + _ gate: CaptureControllerAsyncGate, + count: Int, + timeoutNanoseconds: UInt64 = AsyncTestTimeouts.defaultAsyncAssertion, + pollNanoseconds: UInt64 = 10_000_000 +) async -> Bool { + let deadline = DispatchTime.now().uptimeNanoseconds + timeoutNanoseconds + while DispatchTime.now().uptimeNanoseconds < deadline { + if await gate.currentWaitCount() >= count { + return true + } + try? await Task.sleep(for: .nanoseconds(pollNanoseconds)) + } + return await gate.currentWaitCount() >= count +} diff --git a/VoidDisplayTests/App/ScreenCaptureCatalogTopologyIntegrationTests.swift b/VoidDisplayTests/App/ScreenCaptureCatalogTopologyIntegrationTests.swift index b96a724..25e2ac1 100644 --- a/VoidDisplayTests/App/ScreenCaptureCatalogTopologyIntegrationTests.swift +++ b/VoidDisplayTests/App/ScreenCaptureCatalogTopologyIntegrationTests.swift @@ -141,6 +141,7 @@ struct ScreenCaptureCatalogTopologyIntegrationTests { dependencies: .init( sharingQueries: .init( isWebServiceRunning: { true }, + isStartingDisplayID: { _ in false }, sharePageAddress: { _ in nil }, preferredWebServicePort: { 8081 } ), @@ -150,7 +151,7 @@ struct ScreenCaptureCatalogTopologyIntegrationTests { registerShareableDisplays: { _, _ in registerCounter.value += 1 }, - beginSharing: { _ in }, + beginSharing: { _ in .started(()) }, stopSharing: { _ in } ), virtualDisplayQueries: .init( @@ -187,6 +188,7 @@ struct ScreenCaptureCatalogTopologyIntegrationTests { dependencies: .init( sharingQueries: .init( isWebServiceRunning: { true }, + isStartingDisplayID: { _ in false }, sharePageAddress: { _ in nil }, preferredWebServicePort: { 8081 } ), @@ -196,7 +198,7 @@ struct ScreenCaptureCatalogTopologyIntegrationTests { registerShareableDisplays: { _, _ in registerCounter.value += 1 }, - beginSharing: { _ in }, + beginSharing: { _ in .started(()) }, stopSharing: { _ in } ), virtualDisplayQueries: .init( @@ -234,7 +236,8 @@ struct ScreenCaptureCatalogTopologyIntegrationTests { .init( captureActions: .init( monitoringSessionForDisplayID: { _ in nil }, - startMonitoring: { _, _ in UUID() } + isStartingDisplayID: { _ in false }, + startMonitoring: { _, _ in .started(UUID()) } ), virtualDisplayQueries: .init( isManagedVirtualDisplay: { _ in false } diff --git a/VoidDisplayTests/App/SharingControllerTests.swift b/VoidDisplayTests/App/SharingControllerTests.swift index abdef4d..6810f18 100644 --- a/VoidDisplayTests/App/SharingControllerTests.swift +++ b/VoidDisplayTests/App/SharingControllerTests.swift @@ -1,7 +1,91 @@ +import Foundation import CoreGraphics +import ScreenCaptureKit import Testing @testable import VoidDisplay +private final class SharingControllerDummySession: DisplayCaptureSessioning, @unchecked Sendable { + nonisolated let sessionHub = WebRTCSessionHub() + + nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { + _ = sink + } + + nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { + _ = sink + } + + nonisolated func stopSharing() {} + + nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { + _ = showsCursor + } + + nonisolated func retainShareCursorOverride() async throws {} + + nonisolated func releaseShareCursorOverride() async throws {} + + nonisolated func stop() async {} +} + +private final class SharingControllerMockSCDisplayBox: NSObject { + @objc let displayID: CGDirectDisplayID + @objc let width: Int + @objc let height: Int + @objc let frame: CGRect + + init(displayID: CGDirectDisplayID, width: Int, height: Int) { + self.displayID = displayID + self.width = width + self.height = height + self.frame = CGRect(x: 0, y: 0, width: width, height: height) + super.init() + } +} + +private enum SharingControllerMockSCDisplay { + static func make(displayID: CGDirectDisplayID, width: Int, height: Int) -> SCDisplay { + let box = SharingControllerMockSCDisplayBox(displayID: displayID, width: width, height: height) + return unsafeBitCast(box, to: SCDisplay.self) + } +} + +private actor SharingControllerAsyncGate { + private var waitCount = 0 + private var continuations: [CheckedContinuation] = [] + + func wait() async { + waitCount += 1 + await withCheckedContinuation { continuation in + continuations.append(continuation) + } + } + + func releaseOne() { + guard !continuations.isEmpty else { return } + continuations.removeFirst().resume() + } + + func currentWaitCount() -> Int { + waitCount + } +} + +private actor SharingControllerOutcomeBox { + private var outcome: DisplayStartOutcome? + + func store(_ outcome: DisplayStartOutcome) { + self.outcome = outcome + } + + func isInvalidated() -> Bool { + if case .invalidated = outcome { + return true + } + return false + } +} + @MainActor struct SharingControllerTests { @Test func startWebServiceSyncsState() async { @@ -66,6 +150,236 @@ struct SharingControllerTests { #expect(service.stopAllSharingCallCount == 1) } + @Test func beginSharingPublishesStartingDisplayIDWhileRequestIsInFlight() async throws { + let service = MockSharingService() + let gate = SharingControllerAsyncGate() + let display = SharingControllerMockSCDisplay.make(displayID: 31, width: 1920, height: 1080) + service.startSharingHandler = { display in + await gate.wait() + return .started(()) + } + let sut = SharingController( + sharingService: service, + portPreferences: MockSharingPortPreferences() + ) + + let task = Task { @MainActor in + try await sut.beginSharing(display: display) + } + + #expect(await waitForSharingControllerGate(gate, count: 1)) + #expect(sut.startingDisplayIDs == [display.displayID]) + #expect(sut.isStarting(displayID: display.displayID)) + + await gate.releaseOne() + let outcome = try await task.value + + guard case .started = outcome else { + Issue.record("Expected sharing start to succeed.") + return + } + #expect(sut.startingDisplayIDs.isEmpty) + #expect(sut.isStarting(displayID: display.displayID) == false) + } + + @Test func duplicateBeginSharingCallsShareSameUnderlyingStartOutcome() async throws { + let gate = SharingControllerAsyncGate() + let displayID: CGDirectDisplayID = 35 + let display = SharingControllerMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let subscription = DisplayShareSubscription( + displayID: displayID, + sessionHub: WebRTCSessionHub(), + session: SharingControllerDummySession(), + cancelClosure: {} + ) + let sut = makeRealSharingController( + acquireShare: { _, _ in + await gate.wait() + return .started(subscription) + } + ) + sut.registerShareableDisplays([display], virtualSerialResolver: { _ in nil }) + + let firstTask = Task { @MainActor in + try await sut.beginSharing(display: display) + } + #expect(await waitForSharingControllerGate(gate, count: 1)) + + let secondTask = Task { @MainActor in + try await sut.beginSharing(display: display) + } + + let deadline = DispatchTime.now().uptimeNanoseconds + AsyncTestTimeouts.shortStabilityWindow + var observedSecondAcquire = false + while DispatchTime.now().uptimeNanoseconds < deadline { + if await gate.currentWaitCount() > 1 { + observedSecondAcquire = true + break + } + await Task.yield() + } + + #expect(observedSecondAcquire == false) + #expect(sut.startingDisplayIDs == [displayID]) + + await gate.releaseOne() + + let firstOutcome = try await firstTask.value + let secondOutcome = try await secondTask.value + if case .started = firstOutcome { + } else { + Issue.record("Expected first sharing start to succeed.") + } + if case .started = secondOutcome { + } else { + Issue.record("Expected second sharing start to reuse the in-flight start.") + } + + #expect(sut.startingDisplayIDs.isEmpty) + #expect(sut.isSharing(displayID: displayID)) + } + + @Test func cancellingOneWaitingBeginSharingCallKeepsObservedStartingStateUntilLastWaiterFinishes() async throws { + let service = MockSharingService() + let gate = SharingControllerAsyncGate() + let display = SharingControllerMockSCDisplay.make(displayID: 36, width: 1920, height: 1080) + service.startSharingHandler = { _ in + await gate.wait() + try Task.checkCancellation() + return .started(()) + } + let sut = SharingController( + sharingService: service, + portPreferences: MockSharingPortPreferences() + ) + + let firstTask = Task { @MainActor in + try await sut.beginSharing(display: display) + } + let secondTask = Task { @MainActor in + try await sut.beginSharing(display: display) + } + + #expect(await waitForSharingControllerGate(gate, count: 2)) + #expect(sut.startingDisplayIDs == [display.displayID]) + + firstTask.cancel() + await gate.releaseOne() + + do { + _ = try await firstTask.value + Issue.record("Expected first sharing start to be cancelled.") + } catch is CancellationError { + } catch { + Issue.record("Expected CancellationError, got \(error)") + } + + #expect(sut.startingDisplayIDs == [display.displayID]) + #expect(sut.isStarting(displayID: display.displayID)) + + await gate.releaseOne() + let secondOutcome = try await secondTask.value + if case .started = secondOutcome { + } else { + Issue.record("Expected second sharing start to succeed.") + } + + #expect(sut.startingDisplayIDs.isEmpty) + } + + @Test func beginSharingClearsStartingDisplayIDAfterFailure() async { + struct ControlledError: Error {} + + let service = MockSharingService() + let display = SharingControllerMockSCDisplay.make(displayID: 32, width: 1920, height: 1080) + service.startSharingHandler = { _ in + throw ControlledError() + } + let sut = SharingController( + sharingService: service, + portPreferences: MockSharingPortPreferences() + ) + + do { + _ = try await sut.beginSharing(display: display) + Issue.record("Expected sharing start to fail.") + } catch { + } + + #expect(sut.startingDisplayIDs.isEmpty) + } + + @Test func beginSharingClearsStartingDisplayIDAfterInvalidation() async throws { + let service = MockSharingService() + let display = SharingControllerMockSCDisplay.make(displayID: 33, width: 1920, height: 1080) + service.startSharingHandler = { _ in .invalidated } + let sut = SharingController( + sharingService: service, + portPreferences: MockSharingPortPreferences() + ) + + let outcome = try await sut.beginSharing(display: display) + + if case .invalidated = outcome { + } else { + Issue.record("Expected invalidated outcome.") + } + #expect(sut.startingDisplayIDs.isEmpty) + } + + @Test func stopSharingClearsStartingDisplayIDImmediately() { + let service = MockSharingService() + let displayID: CGDirectDisplayID = 34 + let sut = SharingController( + sharingService: service, + portPreferences: MockSharingPortPreferences() + ) + sut.startingDisplayIDs = [displayID] + + sut.stopSharing(displayID: displayID) + + #expect(sut.startingDisplayIDs.isEmpty) + #expect(service.stopSharingCallCount == 1) + } + + @Test func stopWebServiceClearsStartingDisplayIDImmediatelyAndInvalidatesInFlightStart() async throws { + let gate = SharingControllerAsyncGate() + let displayID: CGDirectDisplayID = 37 + let display = SharingControllerMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let subscription = DisplayShareSubscription( + displayID: displayID, + sessionHub: WebRTCSessionHub(), + session: SharingControllerDummySession(), + cancelClosure: {} + ) + let sut = makeRealSharingController( + acquireShare: { _, _ in + await gate.wait() + return .started(subscription) + } + ) + sut.registerShareableDisplays([display], virtualSerialResolver: { _ in nil }) + + let task = Task { @MainActor in + try await sut.beginSharing(display: display) + } + + #expect(await waitForSharingControllerGate(gate, count: 1)) + #expect(sut.startingDisplayIDs == [displayID]) + + sut.stopWebService() + + #expect(sut.startingDisplayIDs.isEmpty) + #expect(await waitForSharingControllerTaskInvalidation(task)) + + await gate.releaseOne() + let outcome = try await task.value + if case .invalidated = outcome { + } else { + Issue.record("Expected sharing start to be invalidated after stopping the web service.") + } + } + @Test func sharePageURLResolutionReturnsServiceNotRunningWhenStopped() { let service = MockSharingService() service.isWebServiceRunning = false @@ -80,6 +394,64 @@ struct SharingControllerTests { } } +private func waitForSharingControllerGate( + _ gate: SharingControllerAsyncGate, + count: Int, + timeoutNanoseconds: UInt64 = AsyncTestTimeouts.defaultAsyncAssertion, + pollNanoseconds: UInt64 = 10_000_000 +) async -> Bool { + let deadline = DispatchTime.now().uptimeNanoseconds + timeoutNanoseconds + while DispatchTime.now().uptimeNanoseconds < deadline { + if await gate.currentWaitCount() >= count { + return true + } + try? await Task.sleep(for: .nanoseconds(pollNanoseconds)) + } + return await gate.currentWaitCount() >= count +} + +private func waitForSharingControllerTaskInvalidation( + _ task: Task, Error>, + timeoutNanoseconds: UInt64 = AsyncTestTimeouts.defaultAsyncAssertion, + pollNanoseconds: UInt64 = 10_000_000 +) async -> Bool { + let box = SharingControllerOutcomeBox() + Task { + guard let outcome = try? await task.value else { return } + await box.store(outcome) + } + + let deadline = DispatchTime.now().uptimeNanoseconds + timeoutNanoseconds + while DispatchTime.now().uptimeNanoseconds < deadline { + if await box.isInvalidated() { + return true + } + try? await Task.sleep(for: .nanoseconds(pollNanoseconds)) + } + return await box.isInvalidated() +} + +@MainActor +private func makeRealSharingController( + acquireShare: DisplaySharingCoordinator.AcquireShare? = nil +) -> SharingController { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + .appendingPathComponent("display-share-id-mappings.json", isDirectory: false) + let coordinator = DisplaySharingCoordinator( + idStore: DisplayShareIDStore(storeURL: storeURL), + acquireShare: acquireShare + ) + let service = SharingService( + webServiceController: MockWebServiceController(), + sharingCoordinator: coordinator + ) + return SharingController( + sharingService: service, + portPreferences: MockSharingPortPreferences() + ) +} + @MainActor private final class MockSharingPortPreferences: SharingPortPreferencesProtocol { var preferredPort: UInt16 = 8081 diff --git a/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringLifecycleServiceTests.swift b/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringLifecycleServiceTests.swift index 0bfc807..90e35d4 100644 --- a/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringLifecycleServiceTests.swift +++ b/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringLifecycleServiceTests.swift @@ -130,7 +130,7 @@ struct CaptureMonitoringLifecycleServiceTests { let previewRecord = makePreview(displayID: 701) let lifecycle = CaptureMonitoringLifecycleService( captureMonitoringService: service, - acquirePreview: { _ in previewRecord.subscription } + acquirePreview: { _, _ in .started(previewRecord.subscription) } ) let display = CaptureMonitoringLifecycleMockSCDisplay.make( displayID: 701, @@ -143,7 +143,15 @@ struct CaptureMonitoringLifecycleServiceTests { isVirtualDisplay: false ) - let sessionID = try await lifecycle.startMonitoring(display: display, metadata: metadata) + let outcome = try await lifecycle.startMonitoring(display: display, metadata: metadata) + let sessionID: UUID + switch outcome { + case .started(let id): + sessionID = id + case .invalidated: + Issue.record("Expected monitoring start to succeed.") + return + } #expect(service.addCallCount == 1) #expect(service.currentSessions.count == 1) @@ -160,9 +168,9 @@ struct CaptureMonitoringLifecycleServiceTests { var acquirePreviewCallCount = 0 let lifecycle = CaptureMonitoringLifecycleService( captureMonitoringService: service, - acquirePreview: { _ in + acquirePreview: { _, _ in acquirePreviewCallCount += 1 - return makePreview(displayID: 702).subscription + return .started(makePreview(displayID: 702).subscription) } ) let display = CaptureMonitoringLifecycleMockSCDisplay.make( @@ -171,7 +179,7 @@ struct CaptureMonitoringLifecycleServiceTests { height: 1080 ) - let sessionID = try await lifecycle.startMonitoring( + let outcome = try await lifecycle.startMonitoring( display: display, metadata: .init( displayName: "Ignored", @@ -179,6 +187,14 @@ struct CaptureMonitoringLifecycleServiceTests { isVirtualDisplay: false ) ) + let sessionID: UUID + switch outcome { + case .started(let id): + sessionID = id + case .invalidated: + Issue.record("Expected monitoring start to reuse existing session.") + return + } #expect(sessionID == existing.session.id) #expect(acquirePreviewCallCount == 0) @@ -191,9 +207,9 @@ struct CaptureMonitoringLifecycleServiceTests { var usedInjectedAcquirePreview = false let lifecycle = CaptureMonitoringLifecycleService( captureMonitoringService: service, - acquirePreview: { _ in + acquirePreview: { _, _ in usedInjectedAcquirePreview = true - return makePreview(displayID: 703).subscription + return .started(makePreview(displayID: 703).subscription) } ) let display = CaptureMonitoringLifecycleMockSCDisplay.make( @@ -222,10 +238,10 @@ struct CaptureMonitoringLifecycleServiceTests { ) let lifecycle = CaptureMonitoringLifecycleService( captureMonitoringService: service, - acquirePreview: { _ in + acquirePreview: { _, _ in switch await gate.nextOutcome() { case .success(let subscription): - return subscription + return .started(subscription) case .failure(let error): throw error } @@ -256,8 +272,24 @@ struct CaptureMonitoringLifecycleServiceTests { #expect(stayedSingleAcquire) await gate.release(call: 1) - let firstID = try await firstTask.value - let secondID = try await secondTask.value + let firstOutcome = try await firstTask.value + let secondOutcome = try await secondTask.value + let firstID: UUID + let secondID: UUID + switch firstOutcome { + case .started(let id): + firstID = id + case .invalidated: + Issue.record("Expected first monitoring start to succeed.") + return + } + switch secondOutcome { + case .started(let id): + secondID = id + case .invalidated: + Issue.record("Expected second monitoring start to reuse the same start outcome.") + return + } #expect(firstID == secondID) #expect(service.addCallCount == 1) @@ -346,6 +378,49 @@ struct CaptureMonitoringLifecycleServiceTests { #expect(service.currentSessions.isEmpty) } + @Test func removeMonitoringSessionsRemovesSingleMatchingDisplaySession() { + let record = makeSession(id: UUID(), displayID: 714) + let service = CaptureMonitoringService(initialSessions: [record.session]) + let lifecycle = CaptureMonitoringLifecycleService(captureMonitoringService: service) + + lifecycle.removeMonitoringSessions(displayID: 714) + + #expect(service.currentSessions.isEmpty) + #expect(record.cancelCounter.value == 1) + } + + @Test func removeMonitoringSessionsRemovesAllMatchingSessionsAndPreservesOtherDisplays() { + let first = makeSession(id: UUID(), displayID: 715) + let second = makeSession(id: UUID(), displayID: 715) + let third = makeSession(id: UUID(), displayID: 716) + let service = CaptureMonitoringService(initialSessions: [ + first.session, + second.session, + third.session + ]) + let lifecycle = CaptureMonitoringLifecycleService(captureMonitoringService: service) + + lifecycle.removeMonitoringSessions(displayID: 715) + + #expect(service.currentSessions.map(\.displayID) == [716]) + #expect(first.cancelCounter.value == 1) + #expect(second.cancelCounter.value == 1) + #expect(third.cancelCounter.value == 0) + } + + @Test func removeMonitoringSessionsIgnoresUnknownDisplayID() { + let first = makeSession(id: UUID(), displayID: 717) + let second = makeSession(id: UUID(), displayID: 718) + let service = CaptureMonitoringService(initialSessions: [first.session, second.session]) + let lifecycle = CaptureMonitoringLifecycleService(captureMonitoringService: service) + + lifecycle.removeMonitoringSessions(displayID: 999) + + #expect(service.currentSessions.map(\.id) == [first.session.id, second.session.id]) + #expect(first.cancelCounter.value == 0) + #expect(second.cancelCounter.value == 0) + } + @Test func failedInFlightStartClearsMutualExclusionAndAllowsRetry() async { let service = MockCaptureMonitoringService() let secondPreview = makePreview(displayID: 713) @@ -357,10 +432,10 @@ struct CaptureMonitoringLifecycleServiceTests { ) let lifecycle = CaptureMonitoringLifecycleService( captureMonitoringService: service, - acquirePreview: { _ in + acquirePreview: { _, _ in switch await gate.nextOutcome() { case .success(let subscription): - return subscription + return .started(subscription) case .failure(let error): throw error } @@ -396,7 +471,13 @@ struct CaptureMonitoringLifecycleServiceTests { } #expect(await waitForAcquirePreviewCall(gate, count: 2)) await gate.release(call: 2) - let retryID = try? await retryTask.value + let retryOutcome = try? await retryTask.value + let retryID: UUID? + if case .some(.started(let id)) = retryOutcome { + retryID = id + } else { + retryID = nil + } #expect(retryID != nil) #expect(service.addCallCount == 1) @@ -404,6 +485,121 @@ struct CaptureMonitoringLifecycleServiceTests { #expect(service.currentSessions.first?.displayID == 713) } + @Test func removeMonitoringSessionsCancelsInFlightStartAndAllowsCleanRetry() async throws { + let service = CaptureMonitoringService() + let firstPreview = makePreview(displayID: 719) + let secondPreview = makePreview(displayID: 719) + let gate = CaptureMonitoringLifecycleAcquirePreviewGate( + scriptedOutcomes: [ + .success(firstPreview.subscription), + .success(secondPreview.subscription) + ] + ) + let lifecycle = CaptureMonitoringLifecycleService( + captureMonitoringService: service, + acquirePreview: { _, _ in + switch await gate.nextOutcome() { + case .success(let subscription): + return .started(subscription) + case .failure(let error): + throw error + } + } + ) + let display = CaptureMonitoringLifecycleMockSCDisplay.make( + displayID: 719, + width: 1920, + height: 1080 + ) + let metadata = CaptureMonitoringDisplayMetadata( + displayName: "Rebuild Display", + resolutionText: "1920 × 1080", + isVirtualDisplay: false + ) + + let firstTask = Task { @MainActor in + try await lifecycle.startMonitoring(display: display, metadata: metadata) + } + #expect(await waitForAcquirePreviewCall(gate, count: 1)) + + lifecycle.removeMonitoringSessions(displayID: 719) + await gate.release(call: 1) + + let firstOutcome = try await firstTask.value + if case .invalidated = firstOutcome { + } else { + Issue.record("Expected in-flight start to be invalidated by removeMonitoringSessions.") + } + + #expect(service.currentSessions.isEmpty) + #expect(await waitUntil { firstPreview.cancelCounter.value == 1 }) + + let retryTask = Task { @MainActor in + try await lifecycle.startMonitoring(display: display, metadata: metadata) + } + #expect(await waitForAcquirePreviewCall(gate, count: 2)) + await gate.release(call: 2) + let retryOutcome = try await retryTask.value + let retryID: UUID + switch retryOutcome { + case .started(let id): + retryID = id + case .invalidated: + Issue.record("Expected retry monitoring start to succeed.") + return + } + + #expect(retryID == service.currentSessions.first?.id) + #expect(service.currentSessions.count == 1) + #expect(secondPreview.cancelCounter.value == 0) + } + + @Test func removeMonitoringSessionsPreventsStaleSessionWriteWhenAcquireResumesAfterInvalidation() async throws { + let service = CaptureMonitoringService() + let preview = makePreview(displayID: 720) + let gate = CaptureMonitoringLifecycleAcquirePreviewGate( + scriptedOutcomes: [.success(preview.subscription)] + ) + let lifecycle = CaptureMonitoringLifecycleService( + captureMonitoringService: service, + acquirePreview: { _, _ in + switch await gate.nextOutcome() { + case .success(let subscription): + return .started(subscription) + case .failure(let error): + throw error + } + } + ) + let display = CaptureMonitoringLifecycleMockSCDisplay.make( + displayID: 720, + width: 1920, + height: 1080 + ) + let metadata = CaptureMonitoringDisplayMetadata( + displayName: "Cancelled Display", + resolutionText: "1920 × 1080", + isVirtualDisplay: false + ) + + let task = Task { @MainActor in + try await lifecycle.startMonitoring(display: display, metadata: metadata) + } + #expect(await waitForAcquirePreviewCall(gate, count: 1)) + + lifecycle.removeMonitoringSessions(displayID: 720) + await gate.release(call: 1) + + let outcome = try await task.value + if case .invalidated = outcome { + } else { + Issue.record("Expected invalidated outcome after removing monitoring sessions.") + } + + #expect(service.currentSessions.isEmpty) + #expect(await waitUntil { preview.cancelCounter.value == 1 }) + } + @Test func unknownSessionIDsAreNoOpForActivateAttachAndClose() async throws { let service = MockCaptureMonitoringService() let record = makeSession(id: UUID(), displayID: 711) diff --git a/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringSessionStoreTests.swift b/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringSessionStoreTests.swift new file mode 100644 index 0000000..ba9b8d2 --- /dev/null +++ b/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringSessionStoreTests.swift @@ -0,0 +1,126 @@ +import CoreGraphics +import ScreenCaptureKit +import Testing +@testable import VoidDisplay + +private final class CaptureMonitoringSessionStoreDummySession: DisplayCaptureSessioning, @unchecked Sendable { + nonisolated let sessionHub = WebRTCSessionHub() + + nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { + _ = sink + } + + nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { + _ = sink + } + + nonisolated func stopSharing() {} + + nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { + _ = showsCursor + } + + nonisolated func retainShareCursorOverride() async throws {} + + nonisolated func releaseShareCursorOverride() async throws {} + + nonisolated func stop() async {} +} + +private final class CaptureMonitoringSessionStoreCancellationCounter: @unchecked Sendable { + nonisolated(unsafe) var value = 0 +} + +@Suite(.serialized) +@MainActor +struct CaptureMonitoringSessionStoreTests { + @Test func addAndLookupSessionTracksCurrentSessions() { + let store = CaptureMonitoringSessionStore() + let record = makeSession(id: UUID(), displayID: 801) + + store.add(record.session) + + #expect(store.currentSessions.map(\.id) == [record.session.id]) + #expect(store.session(for: record.session.id)?.displayID == 801) + } + + @Test func updateStateOnlyAllowsStartingToActive() { + let starting = makeSession(id: UUID(), displayID: 802).session + var active = makeSession(id: UUID(), displayID: 803).session + active.state = .active + let store = CaptureMonitoringSessionStore(initialSessions: [starting, active]) + + store.updateState(id: starting.id, state: .active) + store.updateState(id: active.id, state: .starting) + + #expect(store.session(for: starting.id)?.state == .active) + #expect(store.session(for: active.id)?.state == .active) + } + + @Test func updateCapturesCursorOnlyTouchesMatchingSession() { + let first = makeSession(id: UUID(), displayID: 804).session + let second = makeSession(id: UUID(), displayID: 805).session + let store = CaptureMonitoringSessionStore(initialSessions: [first, second]) + + store.updateCapturesCursor(id: second.id, capturesCursor: true) + + #expect(store.session(for: first.id)?.capturesCursor == false) + #expect(store.session(for: second.id)?.capturesCursor == true) + } + + @Test func removeByIDCancelsSubscriptionAndDeletesSession() { + let first = makeSession(id: UUID(), displayID: 806) + let second = makeSession(id: UUID(), displayID: 807) + let store = CaptureMonitoringSessionStore(initialSessions: [first.session, second.session]) + + store.remove(id: first.session.id) + + #expect(store.currentSessions.map(\.id) == [second.session.id]) + #expect(first.cancelCounter.value == 1) + #expect(second.cancelCounter.value == 0) + } + + @Test func removeByDisplayIDCancelsAllMatchingSubscriptionsAndPreservesOthers() { + let first = makeSession(id: UUID(), displayID: 808) + let second = makeSession(id: UUID(), displayID: 808) + let third = makeSession(id: UUID(), displayID: 809) + let store = CaptureMonitoringSessionStore(initialSessions: [ + first.session, + second.session, + third.session + ]) + + store.remove(displayID: 808) + + #expect(store.currentSessions.map(\.displayID) == [809]) + #expect(first.cancelCounter.value == 1) + #expect(second.cancelCounter.value == 1) + #expect(third.cancelCounter.value == 0) + } + + private func makeSession( + id: UUID, + displayID: CGDirectDisplayID + ) -> ( + session: ScreenMonitoringSession, + cancelCounter: CaptureMonitoringSessionStoreCancellationCounter + ) { + let cancelCounter = CaptureMonitoringSessionStoreCancellationCounter() + let session = ScreenMonitoringSession( + id: id, + displayID: displayID, + displayName: "Display \(displayID)", + resolutionText: "1920 x 1080", + isVirtualDisplay: false, + previewSubscription: DisplayPreviewSubscription( + displayID: displayID, + resolutionText: "1920 x 1080", + session: CaptureMonitoringSessionStoreDummySession(), + cancelClosure: { cancelCounter.value += 1 } + ), + capturesCursor: false, + state: .starting + ) + return (session, cancelCounter) + } +} diff --git a/VoidDisplayTests/Features/Capture/Services/DisplayStartConcurrencyTests.swift b/VoidDisplayTests/Features/Capture/Services/DisplayStartConcurrencyTests.swift new file mode 100644 index 0000000..b8ec56f --- /dev/null +++ b/VoidDisplayTests/Features/Capture/Services/DisplayStartConcurrencyTests.swift @@ -0,0 +1,184 @@ +import CoreGraphics +import Foundation +import Testing +@testable import VoidDisplay + +private actor DisplayStartCoordinatorGate { + private var waitCount = 0 + private var continuations: [CheckedContinuation] = [] + + func wait() async { + waitCount += 1 + await withCheckedContinuation { continuation in + continuations.append(continuation) + } + } + + func releaseOne() { + guard !continuations.isEmpty else { return } + continuations.removeFirst().resume() + } + + func currentWaitCount() -> Int { + waitCount + } +} + +private func waitForDisplayStartCoordinatorGate( + _ gate: DisplayStartCoordinatorGate, + count: Int, + timeoutNanoseconds: UInt64 = AsyncTestTimeouts.defaultAsyncAssertion, + pollNanoseconds: UInt64 = 10_000_000 +) async -> Bool { + let deadline = DispatchTime.now().uptimeNanoseconds + timeoutNanoseconds + while DispatchTime.now().uptimeNanoseconds < deadline { + if await gate.currentWaitCount() >= count { + return true + } + try? await Task.sleep(for: .nanoseconds(pollNanoseconds)) + } + return await gate.currentWaitCount() >= count +} + +@Suite(.serialized) +struct DisplayStartInvalidationContextTests { + @Test func raceSuccessDoesNotRetainWaiters() async throws { + let context = DisplayStartInvalidationContext() + + let outcome = try await context.race { 42 } + + guard case .started(let value) = outcome else { + Issue.record("Expected race to return started outcome.") + return + } + + #expect(value == 42) + #expect(context.waiterCountForTesting == 0) + #expect(context.isInvalidated() == false) + } + + @Test func raceReturnsInvalidatedWhenInvalidationWins() async throws { + let context = DisplayStartInvalidationContext() + + let task = Task { + try await context.race { + try await Task.sleep(for: .seconds(30)) + return 7 + } + } + + context.invalidate() + let outcome = try await task.value + + if case .invalidated = outcome { + } else { + Issue.record("Expected invalidated outcome.") + } + #expect(context.waiterCountForTesting == 0) + } + + @Test func waitForInvalidationCancellationRemovesWaiter() async { + let context = DisplayStartInvalidationContext() + + let task = Task { + await context.waitForInvalidation() + } + + #expect(await waitUntil { context.waiterCountForTesting == 1 }) + + task.cancel() + _ = await task.value + + #expect(await waitUntil { context.waiterCountForTesting == 0 }) + } + + @Test func repeatedRaceCallsLeaveNoWaitersBehind() async throws { + let context = DisplayStartInvalidationContext() + + for expected in 0..<10 { + let outcome = try await context.race { expected } + guard case .started(let value) = outcome else { + Issue.record("Expected race iteration \(expected) to succeed.") + return + } + #expect(value == expected) + #expect(context.waiterCountForTesting == 0) + } + } +} + +@Suite(.serialized) +@MainActor +struct DisplayStreamStartCoordinatorTests { + @Test func invalidateReturnsInvalidatedBeforeBlockedOperationCompletes() async throws { + let coordinator = DisplayStreamStartCoordinator() + let gate = DisplayStartCoordinatorGate() + let displayID: CGDirectDisplayID = 901 + + let task = Task { @MainActor in + try await coordinator.start(kind: .sharing, displayID: displayID) { _ in + await gate.wait() + return .started(1) + } + } + + #expect(await waitForDisplayStartCoordinatorGate(gate, count: 1)) + + coordinator.invalidate(kind: .sharing, displayID: displayID) + let outcome = try await task.value + + if case .invalidated = outcome { + } else { + Issue.record("Expected invalidated outcome.") + } + + await gate.releaseOne() + #expect(coordinator.isStarting(kind: .sharing, displayID: displayID) == false) + } + + @Test func invalidatedOldOperationCannotCompleteNewOperationRecord() async throws { + let coordinator = DisplayStreamStartCoordinator() + let firstGate = DisplayStartCoordinatorGate() + let secondGate = DisplayStartCoordinatorGate() + let displayID: CGDirectDisplayID = 902 + + let firstTask = Task { @MainActor in + try await coordinator.start(kind: .sharing, displayID: displayID) { _ in + await firstGate.wait() + return .started(1) + } + } + #expect(await waitForDisplayStartCoordinatorGate(firstGate, count: 1)) + + coordinator.invalidate(kind: .sharing, displayID: displayID) + let firstOutcome = try await firstTask.value + if case .invalidated = firstOutcome { + } else { + Issue.record("Expected first outcome to be invalidated.") + } + + let secondTask = Task { @MainActor in + try await coordinator.start(kind: .sharing, displayID: displayID) { _ in + await secondGate.wait() + return .started(2) + } + } + #expect(await waitForDisplayStartCoordinatorGate(secondGate, count: 1)) + + await firstGate.releaseOne() + await Task.yield() + + #expect(coordinator.isStarting(kind: .sharing, displayID: displayID)) + + await secondGate.releaseOne() + let secondOutcome = try await secondTask.value + + guard case .started(let value) = secondOutcome else { + Issue.record("Expected second operation to succeed.") + return + } + + #expect(value == 2) + #expect(coordinator.isStarting(kind: .sharing, displayID: displayID) == false) + } +} diff --git a/VoidDisplayTests/Features/Capture/ViewModels/CaptureChooseViewModelTests.swift b/VoidDisplayTests/Features/Capture/ViewModels/CaptureChooseViewModelTests.swift index b0049dc..259353e 100644 --- a/VoidDisplayTests/Features/Capture/ViewModels/CaptureChooseViewModelTests.swift +++ b/VoidDisplayTests/Features/Capture/ViewModels/CaptureChooseViewModelTests.swift @@ -53,7 +53,8 @@ struct CaptureChooseViewModelTests { dependencies: .init( captureActions: .init( monitoringSessionForDisplayID: { _ in nil }, - startMonitoring: { _, _ in UUID() } + isStartingDisplayID: { _ in false }, + startMonitoring: { _, _ in .started(UUID()) } ), virtualDisplayQueries: .init( isManagedVirtualDisplay: { $0 == 1234 } @@ -70,6 +71,7 @@ struct CaptureChooseViewModelTests { @MainActor @Test func dependenciesLiveDelegatesToControllers() { let captureService = MockCaptureMonitoringService() let captureController = CaptureController(captureMonitoringService: captureService) + captureController.startingDisplayIDs = [777] let virtualDisplayController = VirtualDisplayController( virtualDisplayFacade: MockVirtualDisplayFacade(), appliedBadgeDisplayDuration: .nanoseconds(1), @@ -81,76 +83,26 @@ struct CaptureChooseViewModelTests { ) #expect(dependencies.captureActions.monitoringSessionForDisplayID(777) == nil) + #expect(dependencies.captureActions.isStartingDisplayID(777)) #expect(dependencies.virtualDisplayQueries.isManagedVirtualDisplay(777) == false) } - @MainActor @Test func withDisplayStartLockRejectsConcurrentStartForSameDisplay() async { - let sut = CaptureChooseViewModel(dependencies: makeNoopCaptureDependencies()) - let displayID = CGDirectDisplayID(301) - var enteredCount = 0 - var firstDidEnter = false - var allowFirstToFinish = false - - let firstTask = Task { @MainActor in - await sut.withDisplayStartLock(displayID: displayID) { - enteredCount += 1 - firstDidEnter = true - while !allowFirstToFinish { - await Task.yield() - } - } - } - - while !firstDidEnter { - await Task.yield() - } - - let secondStarted = await sut.withDisplayStartLock(displayID: displayID) { - enteredCount += 1 - } - - allowFirstToFinish = true - let firstStarted = await firstTask.value - - #expect(firstStarted) - #expect(secondStarted == false) - #expect(enteredCount == 1) - #expect(sut.startingDisplayIDs.isEmpty) - } - - @MainActor @Test func withDisplayStartLockAllowsConcurrentStartForDifferentDisplays() async { - let sut = CaptureChooseViewModel(dependencies: makeNoopCaptureDependencies()) - let firstDisplayID = CGDirectDisplayID(401) - let secondDisplayID = CGDirectDisplayID(402) - var enteredDisplayIDs: Set = [] - var firstDidEnter = false - var allowFirstToFinish = false - - let firstTask = Task { @MainActor in - await sut.withDisplayStartLock(displayID: firstDisplayID) { - enteredDisplayIDs.insert(firstDisplayID) - firstDidEnter = true - while !allowFirstToFinish { - await Task.yield() - } - } - } - - while !firstDidEnter { - await Task.yield() - } - - let secondStarted = await sut.withDisplayStartLock(displayID: secondDisplayID) { - enteredDisplayIDs.insert(secondDisplayID) - } - - allowFirstToFinish = true - let firstStarted = await firstTask.value + @MainActor @Test func isStartingDelegatesToCaptureActions() { + let sut = CaptureChooseViewModel( + dependencies: .init( + captureActions: .init( + monitoringSessionForDisplayID: { _ in nil }, + isStartingDisplayID: { $0 == 301 }, + startMonitoring: { _, _ in .started(UUID()) } + ), + virtualDisplayQueries: .init( + isManagedVirtualDisplay: { _ in false } + ) + ) + ) - #expect(firstStarted) - #expect(secondStarted) - #expect(enteredDisplayIDs == [firstDisplayID, secondDisplayID]) - #expect(sut.startingDisplayIDs.isEmpty) + #expect(sut.isStarting(displayID: 301)) + #expect(sut.isStarting(displayID: 302) == false) } @MainActor @Test func startMonitoringFailurePresentsUserFacingAlert() async { @@ -162,6 +114,7 @@ struct CaptureChooseViewModelTests { dependencies: .init( captureActions: .init( monitoringSessionForDisplayID: { _ in nil }, + isStartingDisplayID: { _ in false }, startMonitoring: { _, _ in throw ControlledError() } @@ -179,7 +132,30 @@ struct CaptureChooseViewModelTests { #expect(openedSessionIDs.isEmpty) #expect(sut.userFacingAlert?.title == String(localized: "Start Monitoring Failed")) #expect(sut.userFacingAlert?.message.isEmpty == false) - #expect(sut.startingDisplayIDs.isEmpty) + } + + @MainActor @Test func startMonitoringCancellationDoesNotPresentUserFacingAlert() async { + let sut = CaptureChooseViewModel( + dependencies: .init( + captureActions: .init( + monitoringSessionForDisplayID: { _ in nil }, + isStartingDisplayID: { _ in false }, + startMonitoring: { _, _ in + throw CancellationError() + } + ), + virtualDisplayQueries: .init( + isManagedVirtualDisplay: { _ in false } + ) + ) + ) + let display = MockSCDisplay.make(displayID: 779, width: 1920, height: 1080) + var openedSessionIDs: [UUID] = [] + + await sut.startMonitoring(display: display) { openedSessionIDs.append($0) } + + #expect(openedSessionIDs.isEmpty) + #expect(sut.userFacingAlert == nil) } @MainActor @Test func startMonitoringSuccessPassesMetadataToCaptureActions() async { @@ -191,10 +167,11 @@ struct CaptureChooseViewModelTests { dependencies: .init( captureActions: .init( monitoringSessionForDisplayID: { _ in nil }, + isStartingDisplayID: { _ in false }, startMonitoring: { display, metadata in receivedDisplayID = display.displayID receivedMetadata = metadata - return expectedSessionID + return .started(expectedSessionID) } ), virtualDisplayQueries: .init( @@ -214,7 +191,28 @@ struct CaptureChooseViewModelTests { )) #expect(openedSessionIDs == [expectedSessionID]) #expect(sut.userFacingAlert == nil) - #expect(sut.startingDisplayIDs.isEmpty) + } + + @MainActor @Test func startMonitoringInvalidationDoesNotPresentUserFacingAlert() async { + let sut = CaptureChooseViewModel( + dependencies: .init( + captureActions: .init( + monitoringSessionForDisplayID: { _ in nil }, + isStartingDisplayID: { _ in false }, + startMonitoring: { _, _ in .invalidated } + ), + virtualDisplayQueries: .init( + isManagedVirtualDisplay: { _ in false } + ) + ) + ) + let display = MockSCDisplay.make(displayID: 780, width: 1920, height: 1080) + var openedSessionIDs: [UUID] = [] + + await sut.startMonitoring(display: display) { openedSessionIDs.append($0) } + + #expect(openedSessionIDs.isEmpty) + #expect(sut.userFacingAlert == nil) } @MainActor @Test func requestPermissionDeniedClearsDisplayState() { @@ -611,7 +609,8 @@ struct CaptureChooseViewModelTests { .init( captureActions: .init( monitoringSessionForDisplayID: { _ in nil }, - startMonitoring: { _, _ in UUID() } + isStartingDisplayID: { _ in false }, + startMonitoring: { _, _ in .started(UUID()) } ), virtualDisplayQueries: .init( isManagedVirtualDisplay: { _ in false } diff --git a/VoidDisplayTests/Features/Sharing/Integration/SharingEndToEndIntegrationTests.swift b/VoidDisplayTests/Features/Sharing/Integration/SharingEndToEndIntegrationTests.swift index 9ff3813..615490e 100644 --- a/VoidDisplayTests/Features/Sharing/Integration/SharingEndToEndIntegrationTests.swift +++ b/VoidDisplayTests/Features/Sharing/Integration/SharingEndToEndIntegrationTests.swift @@ -92,7 +92,7 @@ struct SharingEndToEndIntegrationTests { } let boundPort = binding.boundPort - try await service.startSharing(display: display) + _ = try await service.startSharing(display: display) let shareID = try #require(service.shareID(for: displayID)) let displayPath = "/display/\(shareID)" diff --git a/VoidDisplayTests/Features/Sharing/Services/DisplaySharingCoordinatorTests.swift b/VoidDisplayTests/Features/Sharing/Services/DisplaySharingCoordinatorTests.swift index 35bdff8..44d41de 100644 --- a/VoidDisplayTests/Features/Sharing/Services/DisplaySharingCoordinatorTests.swift +++ b/VoidDisplayTests/Features/Sharing/Services/DisplaySharingCoordinatorTests.swift @@ -1,8 +1,119 @@ import CoreGraphics +import CoreMedia import Foundation +import ScreenCaptureKit import Testing @testable import VoidDisplay +private final class DisplaySharingCoordinatorDummySession: DisplayCaptureSessioning, @unchecked Sendable { + nonisolated let sessionHub = WebRTCSessionHub() + + private let retainGate: DisplaySharingCoordinatorAsyncGate? + private let releaseCounter: DisplaySharingCoordinatorCounter? + + init( + retainGate: DisplaySharingCoordinatorAsyncGate? = nil, + releaseCounter: DisplaySharingCoordinatorCounter? = nil + ) { + self.retainGate = retainGate + self.releaseCounter = releaseCounter + } + + nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { + _ = sink + } + + nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { + _ = sink + } + + nonisolated func stopSharing() {} + + nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { + _ = showsCursor + } + + nonisolated func retainShareCursorOverride() async throws { + await retainGate?.wait() + } + + nonisolated func releaseShareCursorOverride() async throws { + releaseCounter?.increment() + } + + nonisolated func stop() async {} +} + +private final class DisplaySharingCoordinatorCounter: @unchecked Sendable { + nonisolated(unsafe) var value = 0 + + nonisolated func increment() { + value += 1 + } +} + +private actor DisplaySharingCoordinatorAsyncGate { + private var waitCount = 0 + private var continuations: [CheckedContinuation] = [] + + func wait() async { + waitCount += 1 + await withCheckedContinuation { continuation in + continuations.append(continuation) + } + } + + func releaseOne() { + guard !continuations.isEmpty else { return } + continuations.removeFirst().resume() + } + + func currentWaitCount() -> Int { + waitCount + } +} + +private actor DisplaySharingCoordinatorOutcomeBox { + private var outcome: DisplayStartOutcome? + + func store(_ outcome: DisplayStartOutcome) { + self.outcome = outcome + } + + func isInvalidated() -> Bool { + if case .invalidated = outcome { + return true + } + return false + } +} + +private final class DisplaySharingCoordinatorMockSCDisplayBox: NSObject { + @objc let displayID: CGDirectDisplayID + @objc let width: Int + @objc let height: Int + @objc let frame: CGRect + + init(displayID: CGDirectDisplayID, width: Int, height: Int) { + self.displayID = displayID + self.width = width + self.height = height + self.frame = CGRect(x: 0, y: 0, width: width, height: height) + super.init() + } +} + +private enum DisplaySharingCoordinatorMockSCDisplay { + static func make(displayID: CGDirectDisplayID, width: Int, height: Int) -> SCDisplay { + let box = DisplaySharingCoordinatorMockSCDisplayBox( + displayID: displayID, + width: width, + height: height + ) + return unsafeBitCast(box, to: SCDisplay.self) + } +} + @Suite(.serialized) struct DisplaySharingCoordinatorTests { @MainActor @@ -31,9 +142,445 @@ struct DisplaySharingCoordinatorTests { #expect(!Set([UInt32(1), UInt32(3)]).contains(physicalShareID)) } + @MainActor + @Test func physicalDisplayShareIDStaysStableAcrossReorderedRegistration() throws { + let store = DisplayShareIDStore(storeURL: temporaryStoreURL()) + let coordinator = DisplaySharingCoordinator(idStore: store) + let firstMain: CGDirectDisplayID = 101 + let secondPhysical: CGDirectDisplayID = 102 + + coordinator.registerShareableDisplays([ + .init(displayID: firstMain, isMain: true, virtualSerial: nil), + .init(displayID: secondPhysical, isMain: false, virtualSerial: nil) + ]) + let initialMainID = try #require(coordinator.shareID(for: firstMain)) + let initialSecondaryID = try #require(coordinator.shareID(for: secondPhysical)) + + coordinator.registerShareableDisplays([ + .init(displayID: secondPhysical, isMain: false, virtualSerial: nil), + .init(displayID: firstMain, isMain: true, virtualSerial: nil) + ]) + + #expect(coordinator.shareID(for: firstMain) == initialMainID) + #expect(coordinator.shareID(for: secondPhysical) == initialSecondaryID) + } + + @MainActor + @Test func removingRegisteredDisplayStopsActiveSharingSession() async throws { + let displayID: CGDirectDisplayID = 103 + let display = DisplaySharingCoordinatorMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let subscription = makeSubscription(displayID: displayID) + let coordinator = DisplaySharingCoordinator( + idStore: DisplayShareIDStore(storeURL: temporaryStoreURL()), + acquireShare: { _, _ in .started(subscription.subscription) } + ) + coordinator.registerShareableDisplays([.init(displayID: displayID, isMain: true, virtualSerial: nil)]) + + let startOutcome = try await coordinator.startSharing(display: display) + guard case .started = startOutcome else { + Issue.record("Expected sharing start to succeed.") + return + } + coordinator.registerShareableDisplays([]) + + #expect(await waitUntil { subscription.cancelCounter.value == 1 }) + #expect(coordinator.isSharing(displayID: displayID) == false) + } + + @MainActor + @Test func startSharingFailsForUnregisteredDisplay() async { + let displayID: CGDirectDisplayID = 104 + let display = DisplaySharingCoordinatorMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let coordinator = DisplaySharingCoordinator(idStore: DisplayShareIDStore(storeURL: temporaryStoreURL())) + + do { + _ = try await coordinator.startSharing(display: display) + Issue.record("Expected displayNotRegistered error.") + } catch let error as SharingStartError { + #expect(error == .displayNotRegistered(displayID)) + } catch { + Issue.record("Expected SharingStartError.displayNotRegistered, got \(error)") + } + + #expect(coordinator.isSharing(displayID: displayID) == false) + } + + @MainActor + @Test func startSharingCancelsSubscriptionWhenRegistrationChangesDuringAcquire() async { + let displayID: CGDirectDisplayID = 105 + let display = DisplaySharingCoordinatorMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let acquireGate = DisplaySharingCoordinatorAsyncGate() + let subscription = makeSubscription(displayID: displayID) + let coordinator = DisplaySharingCoordinator( + idStore: DisplayShareIDStore(storeURL: temporaryStoreURL()), + acquireShare: { _, _ in + await acquireGate.wait() + return .started(subscription.subscription) + } + ) + coordinator.registerShareableDisplays([.init(displayID: displayID, isMain: true, virtualSerial: nil)]) + + let task = Task { @MainActor in + try await coordinator.startSharing(display: display) + } + #expect(await waitForGate(acquireGate, count: 1)) + + coordinator.registerShareableDisplays([]) + let outcome = try? await task.value + if case .some(.invalidated) = outcome { + } else { + Issue.record("Expected invalidated outcome.") + } + + await acquireGate.releaseOne() + + #expect(await waitUntil { subscription.cancelCounter.value == 1 }) + #expect(coordinator.isSharing(displayID: displayID) == false) + } + + @MainActor + @Test func startSharingInvalidatesImmediatelyWhenRegistrationChangesDuringAcquire() async { + let displayID: CGDirectDisplayID = 205 + let display = DisplaySharingCoordinatorMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let acquireGate = DisplaySharingCoordinatorAsyncGate() + let subscription = makeSubscription(displayID: displayID) + let coordinator = DisplaySharingCoordinator( + idStore: DisplayShareIDStore(storeURL: temporaryStoreURL()), + acquireShare: { _, _ in + await acquireGate.wait() + return .started(subscription.subscription) + } + ) + coordinator.registerShareableDisplays([.init(displayID: displayID, isMain: true, virtualSerial: nil)]) + + let task = Task { @MainActor in + try await coordinator.startSharing(display: display) + } + #expect(await waitForGate(acquireGate, count: 1)) + + coordinator.registerShareableDisplays([]) + let invalidatedBeforeGateRelease = await waitForTaskInvalidation(task) + #expect(invalidatedBeforeGateRelease) + + await acquireGate.releaseOne() + #expect(await waitUntil { subscription.cancelCounter.value == 1 }) + #expect(coordinator.isSharing(displayID: displayID) == false) + } + + @MainActor + @Test func startSharingCancelsSubscriptionWhenRegistrationChangesDuringPrepare() async { + let displayID: CGDirectDisplayID = 106 + let display = DisplaySharingCoordinatorMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let prepareGate = DisplaySharingCoordinatorAsyncGate() + let subscription = makeSubscription(displayID: displayID, retainGate: prepareGate) + let coordinator = DisplaySharingCoordinator( + idStore: DisplayShareIDStore(storeURL: temporaryStoreURL()), + acquireShare: { _, _ in .started(subscription.subscription) } + ) + coordinator.registerShareableDisplays([.init(displayID: displayID, isMain: true, virtualSerial: nil)]) + + let task = Task { @MainActor in + try await coordinator.startSharing(display: display) + } + #expect(await waitForGate(prepareGate, count: 1)) + + coordinator.registerShareableDisplays([]) + let outcome = try? await task.value + if case .some(.invalidated) = outcome { + } else { + Issue.record("Expected invalidated outcome.") + } + + await prepareGate.releaseOne() + #expect(await waitUntil { subscription.cancelCounter.value == 1 }) + #expect(coordinator.isSharing(displayID: displayID) == false) + } + + @MainActor + @Test func startSharingSucceedsWhenRegistrationRefreshKeepsSameDisplay() async throws { + let targetDisplayID: CGDirectDisplayID = 107 + let otherDisplayID: CGDirectDisplayID = 108 + let targetDisplay = DisplaySharingCoordinatorMockSCDisplay.make( + displayID: targetDisplayID, + width: 1920, + height: 1080 + ) + let acquireGate = DisplaySharingCoordinatorAsyncGate() + let subscription = makeSubscription(displayID: targetDisplayID) + let coordinator = DisplaySharingCoordinator( + idStore: DisplayShareIDStore(storeURL: temporaryStoreURL()), + acquireShare: { _, _ in + await acquireGate.wait() + return .started(subscription.subscription) + } + ) + coordinator.registerShareableDisplays([ + .init(displayID: targetDisplayID, isMain: true, virtualSerial: nil), + .init(displayID: otherDisplayID, isMain: false, virtualSerial: nil) + ]) + + let task = Task { @MainActor in + try await coordinator.startSharing(display: targetDisplay) + } + #expect(await waitForGate(acquireGate, count: 1)) + + coordinator.registerShareableDisplays([ + .init(displayID: otherDisplayID, isMain: false, virtualSerial: nil), + .init(displayID: targetDisplayID, isMain: true, virtualSerial: nil) + ]) + await acquireGate.releaseOne() + + let outcome = try await task.value + guard case .started = outcome else { + Issue.record("Expected sharing start to succeed after registration refresh.") + return + } + + #expect(coordinator.isSharing(displayID: targetDisplayID)) + #expect(subscription.cancelCounter.value == 0) + + coordinator.stopAllSharing() + #expect(await waitUntil { subscription.cancelCounter.value == 1 }) + } + + @MainActor + @Test func concurrentStartSharingForSameDisplayAcquiresShareOnlyOnce() async throws { + let displayID: CGDirectDisplayID = 111 + let display = DisplaySharingCoordinatorMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let acquireGate = DisplaySharingCoordinatorAsyncGate() + let subscription = makeSubscription(displayID: displayID) + let startCoordinator = DisplayStreamStartCoordinator() + let coordinator = DisplaySharingCoordinator( + idStore: DisplayShareIDStore(storeURL: temporaryStoreURL()), + startCoordinator: startCoordinator, + acquireShare: { _, _ in + await acquireGate.wait() + return .started(subscription.subscription) + } + ) + coordinator.registerShareableDisplays([.init(displayID: displayID, isMain: true, virtualSerial: nil)]) + + let firstTask = Task { @MainActor in + try await coordinator.startSharing(display: display) + } + #expect(await waitForGate(acquireGate, count: 1)) + + let secondTask = Task { @MainActor in + try await coordinator.startSharing(display: display) + } + #expect(await waitForCoordinatorWaiters( + startCoordinator, + kind: .sharing, + displayID: displayID, + count: 2 + )) + let deadline = DispatchTime.now().uptimeNanoseconds + AsyncTestTimeouts.shortStabilityWindow + var observedSecondAcquire = false + while DispatchTime.now().uptimeNanoseconds < deadline { + if await acquireGate.currentWaitCount() > 1 { + observedSecondAcquire = true + break + } + await Task.yield() + } + #expect(observedSecondAcquire == false) + + await acquireGate.releaseOne() + + let firstOutcome = try await firstTask.value + let secondOutcome = try await secondTask.value + if case .started = firstOutcome { + } else { + Issue.record("Expected first sharing start to succeed.") + } + if case .started = secondOutcome { + } else { + Issue.record("Expected second sharing start to reuse the in-flight start.") + } + + #expect(coordinator.isSharing(displayID: displayID)) + #expect(subscription.cancelCounter.value == 0) + + coordinator.stopAllSharing() + #expect(await waitUntil { subscription.cancelCounter.value == 1 }) + } + + @MainActor + @Test func stopAllSharingInvalidatesInFlightStartDuringAcquire() async throws { + let displayID: CGDirectDisplayID = 112 + let display = DisplaySharingCoordinatorMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let acquireGate = DisplaySharingCoordinatorAsyncGate() + let subscription = makeSubscription(displayID: displayID) + let coordinator = DisplaySharingCoordinator( + idStore: DisplayShareIDStore(storeURL: temporaryStoreURL()), + acquireShare: { _, _ in + await acquireGate.wait() + return .started(subscription.subscription) + } + ) + coordinator.registerShareableDisplays([.init(displayID: displayID, isMain: true, virtualSerial: nil)]) + + let task = Task { @MainActor in + try await coordinator.startSharing(display: display) + } + #expect(await waitForGate(acquireGate, count: 1)) + + coordinator.stopAllSharing() + + #expect(await waitForTaskInvalidation(task)) + + await acquireGate.releaseOne() + let outcome = try await task.value + if case .invalidated = outcome { + } else { + Issue.record("Expected in-flight sharing start to be invalidated by stopAllSharing.") + } + + #expect(await waitUntil { subscription.cancelCounter.value == 1 }) + #expect(coordinator.isSharing(displayID: displayID) == false) + } + + @MainActor + @Test func stopAllSharingPreventsStaleSessionWriteWhenPrepareResumesAfterInvalidation() async throws { + let displayID: CGDirectDisplayID = 113 + let display = DisplaySharingCoordinatorMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let prepareGate = DisplaySharingCoordinatorAsyncGate() + let subscription = makeSubscription(displayID: displayID, retainGate: prepareGate) + let coordinator = DisplaySharingCoordinator( + idStore: DisplayShareIDStore(storeURL: temporaryStoreURL()), + acquireShare: { _, _ in .started(subscription.subscription) } + ) + coordinator.registerShareableDisplays([.init(displayID: displayID, isMain: true, virtualSerial: nil)]) + + let task = Task { @MainActor in + try await coordinator.startSharing(display: display) + } + #expect(await waitForGate(prepareGate, count: 1)) + + coordinator.stopAllSharing() + #expect(await waitForTaskInvalidation(task)) + + await prepareGate.releaseOne() + let outcome = try await task.value + if case .invalidated = outcome { + } else { + Issue.record("Expected sharing start to stay invalidated after stopAllSharing.") + } + + #expect(await waitUntil { subscription.cancelCounter.value == 1 }) + #expect(coordinator.isSharing(displayID: displayID) == false) + } + + @MainActor + @Test func mainTargetContractIsActiveOrKnownInactiveAndHubIsNilWhenUnresolved() async throws { + let unresolvedCoordinator = DisplaySharingCoordinator(idStore: DisplayShareIDStore(storeURL: temporaryStoreURL())) + #expect(unresolvedCoordinator.state(for: ShareTarget.main) == .knownInactive) + #expect(unresolvedCoordinator.sessionHub(for: ShareTarget.main) == nil) + + let inactiveCoordinator = DisplaySharingCoordinator(idStore: DisplayShareIDStore(storeURL: temporaryStoreURL())) + inactiveCoordinator.registerShareableDisplays([.init(displayID: 109, isMain: true, virtualSerial: nil)]) + #expect(inactiveCoordinator.state(for: ShareTarget.main) == .knownInactive) + #expect(inactiveCoordinator.sessionHub(for: ShareTarget.main) == nil) + + let activeSubscription = makeSubscription(displayID: 110) + let activeCoordinator = DisplaySharingCoordinator( + idStore: DisplayShareIDStore(storeURL: temporaryStoreURL()), + acquireShare: { _, _ in .started(activeSubscription.subscription) } + ) + let display = DisplaySharingCoordinatorMockSCDisplay.make(displayID: 110, width: 1920, height: 1080) + activeCoordinator.registerShareableDisplays([.init(displayID: 110, isMain: true, virtualSerial: nil)]) + let outcome = try await activeCoordinator.startSharing(display: display) + guard case .started = outcome else { + Issue.record("Expected active sharing start to succeed.") + return + } + + #expect(activeCoordinator.state(for: ShareTarget.main) == .active) + let hub = try #require(activeCoordinator.sessionHub(for: ShareTarget.main)) + #expect(ObjectIdentifier(hub) == ObjectIdentifier(activeSubscription.session.sessionHub)) + + activeCoordinator.stopAllSharing() + #expect(await waitUntil { activeSubscription.cancelCounter.value == 1 }) + } + private func temporaryStoreURL() -> URL { let base = FileManager.default.temporaryDirectory .appendingPathComponent("display-sharing-coordinator-tests-\(UUID().uuidString)", isDirectory: true) return base.appendingPathComponent("display-share-id-mappings.json", isDirectory: false) } + + private func makeSubscription( + displayID: CGDirectDisplayID, + retainGate: DisplaySharingCoordinatorAsyncGate? = nil + ) -> ( + subscription: DisplayShareSubscription, + session: DisplaySharingCoordinatorDummySession, + cancelCounter: DisplaySharingCoordinatorCounter + ) { + let cancelCounter = DisplaySharingCoordinatorCounter() + let releaseCounter = DisplaySharingCoordinatorCounter() + let session = DisplaySharingCoordinatorDummySession( + retainGate: retainGate, + releaseCounter: releaseCounter + ) + let subscription = DisplayShareSubscription( + displayID: displayID, + sessionHub: session.sessionHub, + session: session, + cancelClosure: { cancelCounter.increment() } + ) + return (subscription, session, cancelCounter) + } + + private func waitForGate( + _ gate: DisplaySharingCoordinatorAsyncGate, + count: Int + ) async -> Bool { + let deadline = DispatchTime.now().uptimeNanoseconds + AsyncTestTimeouts.defaultAsyncAssertion + while DispatchTime.now().uptimeNanoseconds < deadline { + if await gate.currentWaitCount() >= count { + return true + } + await Task.yield() + } + return await gate.currentWaitCount() >= count + } + + private func waitForTaskInvalidation( + _ task: Task, Error> + ) async -> Bool { + let box = DisplaySharingCoordinatorOutcomeBox() + Task { + guard let outcome = try? await task.value else { return } + await box.store(outcome) + } + let deadline = DispatchTime.now().uptimeNanoseconds + AsyncTestTimeouts.defaultAsyncAssertion + while DispatchTime.now().uptimeNanoseconds < deadline { + if await box.isInvalidated() { + return true + } + await Task.yield() + } + return await box.isInvalidated() + } + + private func waitForCoordinatorWaiters( + _ coordinator: DisplayStreamStartCoordinator, + kind: DisplayStartKind, + displayID: CGDirectDisplayID, + count: Int + ) async -> Bool { + let deadline = DispatchTime.now().uptimeNanoseconds + AsyncTestTimeouts.defaultAsyncAssertion + while DispatchTime.now().uptimeNanoseconds < deadline { + if await MainActor.run(body: { + coordinator.waiterCountForTesting(kind: kind, displayID: displayID) >= count + }) { + return true + } + await Task.yield() + } + return await MainActor.run(body: { + coordinator.waiterCountForTesting(kind: kind, displayID: displayID) >= count + }) + } } diff --git a/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift b/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift index 4e100bb..9b90fc6 100644 --- a/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift +++ b/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift @@ -1,8 +1,99 @@ import Foundation import CoreGraphics +import ScreenCaptureKit import Testing @testable import VoidDisplay +private final class SharingServiceDummySession: DisplayCaptureSessioning, @unchecked Sendable { + nonisolated let sessionHub = WebRTCSessionHub() + + nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { + _ = sink + } + + nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { + _ = sink + } + + nonisolated func stopSharing() {} + + nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { + _ = showsCursor + } + + nonisolated func retainShareCursorOverride() async throws {} + + nonisolated func releaseShareCursorOverride() async throws {} + + nonisolated func stop() async {} +} + +private final class SharingServiceCounter: @unchecked Sendable { + nonisolated(unsafe) var value = 0 + + nonisolated func increment() { + value += 1 + } +} + +private actor SharingServiceAsyncGate { + private var waitCount = 0 + private var continuations: [CheckedContinuation] = [] + + func wait() async { + waitCount += 1 + await withCheckedContinuation { continuation in + continuations.append(continuation) + } + } + + func releaseOne() { + guard !continuations.isEmpty else { return } + continuations.removeFirst().resume() + } + + func currentWaitCount() -> Int { + waitCount + } +} + +private actor SharingServiceOutcomeBox { + private var outcome: DisplayStartOutcome? + + func store(_ outcome: DisplayStartOutcome) { + self.outcome = outcome + } + + func isInvalidated() -> Bool { + if case .invalidated = outcome { + return true + } + return false + } +} + +private final class SharingServiceMockSCDisplayBox: NSObject { + @objc let displayID: CGDirectDisplayID + @objc let width: Int + @objc let height: Int + @objc let frame: CGRect + + init(displayID: CGDirectDisplayID, width: Int, height: Int) { + self.displayID = displayID + self.width = width + self.height = height + self.frame = CGRect(x: 0, y: 0, width: width, height: height) + super.init() + } +} + +private enum SharingServiceMockSCDisplay { + static func make(displayID: CGDirectDisplayID, width: Int, height: Int) -> SCDisplay { + let box = SharingServiceMockSCDisplayBox(displayID: displayID, width: width, height: height) + return unsafeBitCast(box, to: SCDisplay.self) + } +} + struct SharingServiceTests { @MainActor @Test func startWebServiceDelegatesToControllerAndCapturesProviders() async { @@ -59,6 +150,95 @@ struct SharingServiceTests { #expect(sut.isWebServiceRunning == false) } + @MainActor @Test func stopWebServiceStopsActiveSharingSessionsBeforeStoppingController() async throws { + let mock = MockWebServiceController() + let displayID = CGDirectDisplayID(21) + let display = SharingServiceMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let cancelCounter = SharingServiceCounter() + let sut = makeService( + webServiceController: mock, + acquireShare: { _, _ in + .started(DisplayShareSubscription( + displayID: displayID, + sessionHub: WebRTCSessionHub(), + session: SharingServiceDummySession(), + cancelClosure: { cancelCounter.increment() } + )) + } + ) + sut.registerShareableDisplays([display], virtualSerialResolver: { _ in nil }) + let outcome = try await sut.startSharing(display: display) + guard case .started = outcome else { + Issue.record("Expected sharing start to succeed.") + return + } + + sut.stopWebService() + + #expect(await waitUntil { cancelCounter.value == 1 }) + #expect(sut.hasAnyActiveSharing == false) + #expect(mock.disconnectCallCount == 1) + #expect(mock.stopCallCount == 1) + } + + @MainActor @Test func stopWebServiceInvalidatesInFlightSharingStart() async throws { + let mock = MockWebServiceController() + let gate = SharingServiceAsyncGate() + let displayID = CGDirectDisplayID(23) + let display = SharingServiceMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let cancelCounter = SharingServiceCounter() + let sut = makeService( + webServiceController: mock, + acquireShare: { _, _ in + await gate.wait() + return .started(DisplayShareSubscription( + displayID: displayID, + sessionHub: WebRTCSessionHub(), + session: SharingServiceDummySession(), + cancelClosure: { cancelCounter.increment() } + )) + } + ) + sut.registerShareableDisplays([display], virtualSerialResolver: { _ in nil }) + + let task = Task { @MainActor in + try await sut.startSharing(display: display) + } + + #expect(await waitForSharingServiceGate(gate, count: 1)) + + sut.stopWebService() + + #expect(await waitForSharingServiceTaskInvalidation(task)) + + await gate.releaseOne() + let outcome = try await task.value + if case .invalidated = outcome { + } else { + Issue.record("Expected in-flight sharing start to be invalidated when the web service stops.") + } + + #expect(await waitUntil { cancelCounter.value == 1 }) + #expect(sut.hasAnyActiveSharing == false) + #expect(mock.disconnectCallCount == 1) + #expect(mock.stopCallCount == 1) + } + + @MainActor @Test func startSharingPropagatesDisplayNotRegisteredError() async { + let displayID = CGDirectDisplayID(22) + let display = SharingServiceMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let sut = makeService(webServiceController: MockWebServiceController()) + + do { + _ = try await sut.startSharing(display: display) + Issue.record("Expected displayNotRegistered error.") + } catch let error as SharingStartError { + #expect(error == .displayNotRegistered(displayID)) + } catch { + Issue.record("Expected SharingStartError.displayNotRegistered, got \(error)") + } + } + @MainActor @Test func activeStreamClientCountReflectsControllerValue() { let mock = MockWebServiceController() mock.activeStreamClientCount = 3 @@ -124,15 +304,58 @@ struct SharingServiceTests { } @MainActor - private func makeService(webServiceController: MockWebServiceController) -> SharingService { + private func makeService( + webServiceController: MockWebServiceController, + acquireShare: DisplaySharingCoordinator.AcquireShare? = nil + ) -> SharingService { let storeURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) .appendingPathComponent("display-share-id-mappings.json", isDirectory: false) let idStore = DisplayShareIDStore(storeURL: storeURL) - let coordinator = DisplaySharingCoordinator(idStore: idStore) + let coordinator = DisplaySharingCoordinator( + idStore: idStore, + acquireShare: acquireShare + ) return SharingService( webServiceController: webServiceController, sharingCoordinator: coordinator ) } } + +private func waitForSharingServiceGate( + _ gate: SharingServiceAsyncGate, + count: Int, + timeoutNanoseconds: UInt64 = AsyncTestTimeouts.defaultAsyncAssertion, + pollNanoseconds: UInt64 = 10_000_000 +) async -> Bool { + let deadline = DispatchTime.now().uptimeNanoseconds + timeoutNanoseconds + while DispatchTime.now().uptimeNanoseconds < deadline { + if await gate.currentWaitCount() >= count { + return true + } + try? await Task.sleep(for: .nanoseconds(pollNanoseconds)) + } + return await gate.currentWaitCount() >= count +} + +private func waitForSharingServiceTaskInvalidation( + _ task: Task, Error>, + timeoutNanoseconds: UInt64 = AsyncTestTimeouts.defaultAsyncAssertion, + pollNanoseconds: UInt64 = 10_000_000 +) async -> Bool { + let box = SharingServiceOutcomeBox() + Task { + guard let outcome = try? await task.value else { return } + await box.store(outcome) + } + + let deadline = DispatchTime.now().uptimeNanoseconds + timeoutNanoseconds + while DispatchTime.now().uptimeNanoseconds < deadline { + if await box.isInvalidated() { + return true + } + try? await Task.sleep(for: .nanoseconds(pollNanoseconds)) + } + return await box.isInvalidated() +} diff --git a/VoidDisplayTests/Features/Sharing/ViewModels/ShareViewModelTests.swift b/VoidDisplayTests/Features/Sharing/ViewModels/ShareViewModelTests.swift index 0bb9dc5..f3e432d 100644 --- a/VoidDisplayTests/Features/Sharing/ViewModels/ShareViewModelTests.swift +++ b/VoidDisplayTests/Features/Sharing/ViewModels/ShareViewModelTests.swift @@ -48,74 +48,78 @@ private actor SequencedShareDisplayLoaderGate { @Suite(.serialized) struct ShareViewModelTests { + @MainActor @Test func dependenciesLiveDelegatesToControllers() { + let sharingService = MockSharingService() + let sharingController = SharingController( + sharingService: sharingService, + portPreferences: SharingPortPreferences(defaults: UserDefaults(suiteName: "ShareViewModelTestsLive")!) + ) + sharingController.startingDisplayIDs = [501] + let virtualDisplayController = VirtualDisplayController( + virtualDisplayFacade: MockVirtualDisplayFacade(), + appliedBadgeDisplayDuration: .nanoseconds(1), + stopDependentStreamsBeforeRebuild: { _ in } + ) + let dependencies = ShareViewModel.Dependencies.live( + sharing: sharingController, + virtualDisplay: virtualDisplayController + ) - @MainActor @Test func withDisplayStartLockRejectsConcurrentStartForSameDisplay() async { - let sut = ShareViewModel(dependencies: makeNoopShareDependencies()) - let displayID = CGDirectDisplayID(101) - var enteredCount = 0 - var firstDidEnter = false - var allowFirstToFinish = false - - let firstTask = Task { @MainActor in - await sut.withDisplayStartLock(displayID: displayID) { - enteredCount += 1 - firstDidEnter = true - while !allowFirstToFinish { - await Task.yield() - } - } - } - - while !firstDidEnter { - await Task.yield() - } + #expect(dependencies.sharingQueries.isStartingDisplayID(501)) + #expect( + dependencies.sharingQueries.preferredWebServicePort() + == sharingController.preferredWebServicePort + ) + } - let secondStarted = await sut.withDisplayStartLock(displayID: displayID) { - enteredCount += 1 - } + @MainActor @Test func dependenciesLiveReflectsStopWebServiceClearingStartingState() { + let sharingService = MockSharingService() + let sharingController = SharingController( + sharingService: sharingService, + portPreferences: SharingPortPreferences(defaults: UserDefaults(suiteName: "ShareViewModelTestsStopWebService")!) + ) + sharingController.startingDisplayIDs = [502] + let virtualDisplayController = VirtualDisplayController( + virtualDisplayFacade: MockVirtualDisplayFacade(), + appliedBadgeDisplayDuration: .nanoseconds(1), + stopDependentStreamsBeforeRebuild: { _ in } + ) + let dependencies = ShareViewModel.Dependencies.live( + sharing: sharingController, + virtualDisplay: virtualDisplayController + ) - allowFirstToFinish = true - let firstStarted = await firstTask.value - - #expect(firstStarted) - #expect(secondStarted == false) - #expect(enteredCount == 1) - #expect(sut.startingDisplayIDs.isEmpty) - } - - @MainActor @Test func withDisplayStartLockAllowsConcurrentStartForDifferentDisplays() async { - let sut = ShareViewModel(dependencies: makeNoopShareDependencies()) - let firstDisplayID = CGDirectDisplayID(201) - let secondDisplayID = CGDirectDisplayID(202) - var enteredDisplayIDs: Set = [] - var firstDidEnter = false - var allowFirstToFinish = false - - let firstTask = Task { @MainActor in - await sut.withDisplayStartLock(displayID: firstDisplayID) { - enteredDisplayIDs.insert(firstDisplayID) - firstDidEnter = true - while !allowFirstToFinish { - await Task.yield() - } - } - } + #expect(dependencies.sharingQueries.isStartingDisplayID(502)) - while !firstDidEnter { - await Task.yield() - } + sharingController.stopWebService() - let secondStarted = await sut.withDisplayStartLock(displayID: secondDisplayID) { - enteredDisplayIDs.insert(secondDisplayID) - } + #expect(dependencies.sharingQueries.isStartingDisplayID(502) == false) + } - allowFirstToFinish = true - let firstStarted = await firstTask.value + @MainActor @Test func isStartingDelegatesToSharingQueries() { + let sut = ShareViewModel( + dependencies: .init( + sharingQueries: .init( + isWebServiceRunning: { false }, + isStartingDisplayID: { $0 == 101 }, + sharePageAddress: { _ in nil }, + preferredWebServicePort: { 8081 } + ), + sharingActions: .init( + startWebService: { _ in .failed(.timedOut(port: 8081)) }, + stopWebService: {}, + registerShareableDisplays: { _, _ in }, + beginSharing: { _ in .started(()) }, + stopSharing: { _ in } + ), + virtualDisplayQueries: .init( + virtualSerialForManagedDisplay: { _ in nil } + ) + ) + ) - #expect(firstStarted) - #expect(secondStarted) - #expect(enteredDisplayIDs == [firstDisplayID, secondDisplayID]) - #expect(sut.startingDisplayIDs.isEmpty) + #expect(sut.isStarting(displayID: 101)) + #expect(sut.isStarting(displayID: 102) == false) } @MainActor @Test func requestPermissionDeniedClearsDisplaysAndSetsErrorMessage() { @@ -173,6 +177,7 @@ struct ShareViewModelTests { dependencies: .init( sharingQueries: .init( isWebServiceRunning: { false }, + isStartingDisplayID: { _ in false }, sharePageAddress: { _ in nil }, preferredWebServicePort: { 8081 } ), @@ -180,7 +185,7 @@ struct ShareViewModelTests { startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, stopWebService: {}, registerShareableDisplays: { _, _ in }, - beginSharing: { _ in }, + beginSharing: { _ in .started(()) }, stopSharing: { _ in } ), virtualDisplayQueries: .init( @@ -219,6 +224,7 @@ struct ShareViewModelTests { dependencies: .init( sharingQueries: .init( isWebServiceRunning: { true }, + isStartingDisplayID: { _ in false }, sharePageAddress: { _ in nil }, preferredWebServicePort: { 8081 } ), @@ -228,7 +234,7 @@ struct ShareViewModelTests { registerShareableDisplays: { _, _ in registerShareableDisplaysCallCount += 1 }, - beginSharing: { _ in }, + beginSharing: { _ in .started(()) }, stopSharing: { _ in } ), virtualDisplayQueries: .init( @@ -344,6 +350,7 @@ struct ShareViewModelTests { dependencies: .init( sharingQueries: .init( isWebServiceRunning: { false }, + isStartingDisplayID: { _ in false }, sharePageAddress: { _ in nil }, preferredWebServicePort: { 8081 } ), @@ -351,7 +358,7 @@ struct ShareViewModelTests { startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, stopWebService: {}, registerShareableDisplays: { _, _ in }, - beginSharing: { _ in }, + beginSharing: { _ in .started(()) }, stopSharing: { _ in } ), virtualDisplayQueries: .init( @@ -374,6 +381,7 @@ struct ShareViewModelTests { dependencies: .init( sharingQueries: .init( isWebServiceRunning: { false }, + isStartingDisplayID: { _ in false }, sharePageAddress: { _ in nil }, preferredWebServicePort: { 8081 } ), @@ -381,7 +389,7 @@ struct ShareViewModelTests { startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, stopWebService: {}, registerShareableDisplays: { _, _ in }, - beginSharing: { _ in }, + beginSharing: { _ in .started(()) }, stopSharing: { _ in } ), virtualDisplayQueries: .init( @@ -588,6 +596,7 @@ struct ShareViewModelTests { dependencies: .init( sharingQueries: .init( isWebServiceRunning: { false }, + isStartingDisplayID: { _ in false }, sharePageAddress: { _ in nil }, preferredWebServicePort: { 9099 } ), @@ -595,7 +604,7 @@ struct ShareViewModelTests { startWebService: { _ in .failed(.timedOut(port: 9099)) }, stopWebService: {}, registerShareableDisplays: { _, _ in }, - beginSharing: { _ in }, + beginSharing: { _ in .started(()) }, stopSharing: { _ in } ), virtualDisplayQueries: .init( @@ -675,6 +684,7 @@ struct ShareViewModelTests { dependencies: .init( sharingQueries: .init( isWebServiceRunning: { false }, + isStartingDisplayID: { _ in false }, sharePageAddress: { _ in nil }, preferredWebServicePort: { 8081 } ), @@ -687,6 +697,7 @@ struct ShareViewModelTests { registerShareableDisplays: { _, _ in }, beginSharing: { _ in beginSharingCallCount += 1 + return .started(()) }, stopSharing: { _ in } ), @@ -712,6 +723,7 @@ struct ShareViewModelTests { dependencies: .init( sharingQueries: .init( isWebServiceRunning: { false }, + isStartingDisplayID: { _ in false }, sharePageAddress: { _ in nil }, preferredWebServicePort: { 8081 } ), @@ -721,6 +733,7 @@ struct ShareViewModelTests { registerShareableDisplays: { _, _ in }, beginSharing: { _ in beginSharingCallCount += 1 + return .started(()) }, stopSharing: { _ in } ), @@ -737,17 +750,14 @@ struct ShareViewModelTests { #expect(sut.userFacingAlert == nil) } - @MainActor @Test func startSharingFailureStopsShareAndPresentsAlert() async { - enum ShareStartFailure: Error { - case failed - } - + @MainActor @Test func startSharingFailureStopsShareAndPresentsLocalizedAlert() async { let display = MockSCDisplay.make(displayID: 7003, width: 1920, height: 1080) var stopSharingDisplayIDs: [CGDirectDisplayID] = [] let sut = ShareViewModel( dependencies: .init( sharingQueries: .init( isWebServiceRunning: { true }, + isStartingDisplayID: { _ in false }, sharePageAddress: { _ in nil }, preferredWebServicePort: { 8081 } ), @@ -756,7 +766,7 @@ struct ShareViewModelTests { stopWebService: {}, registerShareableDisplays: { _, _ in }, beginSharing: { _ in - throw ShareStartFailure.failed + throw SharingStartError.displayNotRegistered(display.displayID) }, stopSharing: { displayID in stopSharingDisplayIDs.append(displayID) @@ -772,6 +782,40 @@ struct ShareViewModelTests { #expect(stopSharingDisplayIDs == [display.displayID]) #expect(sut.userFacingAlert?.title == String(localized: "Share Failed")) + #expect(sut.userFacingAlert?.message == String(localized: "Selected display is no longer available for sharing.")) + #expect(sut.portInputErrorMessage == nil) + } + + @MainActor @Test func startSharingInvalidationEndsSilentlyWithoutStoppingShare() async { + let display = MockSCDisplay.make(displayID: 7004, width: 1920, height: 1080) + var stopSharingCallCount = 0 + let sut = ShareViewModel( + dependencies: .init( + sharingQueries: .init( + isWebServiceRunning: { true }, + isStartingDisplayID: { _ in false }, + sharePageAddress: { _ in nil }, + preferredWebServicePort: { 8081 } + ), + sharingActions: .init( + startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, + stopWebService: {}, + registerShareableDisplays: { _, _ in }, + beginSharing: { _ in .invalidated }, + stopSharing: { _ in + stopSharingCallCount += 1 + } + ), + virtualDisplayQueries: .init( + virtualSerialForManagedDisplay: { _ in nil } + ) + ) + ) + + await sut.startSharing(display: display) + + #expect(stopSharingCallCount == 0) + #expect(sut.userFacingAlert == nil) #expect(sut.portInputErrorMessage == nil) } @@ -909,6 +953,7 @@ struct ShareViewModelTests { .init( sharingQueries: .init( isWebServiceRunning: { false }, + isStartingDisplayID: { _ in false }, sharePageAddress: { _ in nil }, preferredWebServicePort: { 8081 } ), @@ -916,7 +961,7 @@ struct ShareViewModelTests { startWebService: { _ in .failed(.timedOut(port: 8081)) }, stopWebService: {}, registerShareableDisplays: { _, _ in }, - beginSharing: { _ in }, + beginSharing: { _ in .started(()) }, stopSharing: { _ in } ), virtualDisplayQueries: .init( @@ -930,6 +975,7 @@ struct ShareViewModelTests { .init( sharingQueries: .init( isWebServiceRunning: { true }, + isStartingDisplayID: { _ in false }, sharePageAddress: { _ in nil }, preferredWebServicePort: { 8081 } ), @@ -937,7 +983,7 @@ struct ShareViewModelTests { startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, stopWebService: {}, registerShareableDisplays: { _, _ in }, - beginSharing: { _ in }, + beginSharing: { _ in .started(()) }, stopSharing: { _ in } ), virtualDisplayQueries: .init( diff --git a/VoidDisplayTests/Features/Sharing/Views/ShareViewBehaviorTests.swift b/VoidDisplayTests/Features/Sharing/Views/ShareViewBehaviorTests.swift index 654433e..819f5ba 100644 --- a/VoidDisplayTests/Features/Sharing/Views/ShareViewBehaviorTests.swift +++ b/VoidDisplayTests/Features/Sharing/Views/ShareViewBehaviorTests.swift @@ -182,8 +182,19 @@ struct ShareViewBehaviorTests { #expect(monitor.stopCallCount == 1) } + @Test func viewModelSurfacesStartingStateFromSharingDependency() { + let viewModel = makeViewModel( + isWebServiceRunning: true, + isStartingDisplayID: { $0 == 404 } + ) + + #expect(viewModel.isStarting(displayID: 404)) + #expect(viewModel.isStarting(displayID: 405) == false) + } + private func makeViewModel( isWebServiceRunning: Bool, + isStartingDisplayID: @escaping @MainActor (CGDirectDisplayID) -> Bool = { _ in false }, loadShareableDisplays: (@MainActor () async throws -> [SCDisplay])? = nil, activeDisplayIDsProvider: @escaping @MainActor () -> Set = { [] } ) -> ShareViewModel { @@ -197,6 +208,7 @@ struct ShareViewBehaviorTests { dependencies: .init( sharingQueries: .init( isWebServiceRunning: { isWebServiceRunning }, + isStartingDisplayID: isStartingDisplayID, sharePageAddress: { _ in nil }, preferredWebServicePort: { 8081 } ), @@ -204,7 +216,7 @@ struct ShareViewBehaviorTests { startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, stopWebService: {}, registerShareableDisplays: { _, _ in }, - beginSharing: { _ in }, + beginSharing: { _ in .started(()) }, stopSharing: { _ in } ), virtualDisplayQueries: .init( diff --git a/VoidDisplayTests/Shared/ScreenCapturePermissionProviderTests.swift b/VoidDisplayTests/Shared/ScreenCapturePermissionProviderTests.swift new file mode 100644 index 0000000..872013b --- /dev/null +++ b/VoidDisplayTests/Shared/ScreenCapturePermissionProviderTests.swift @@ -0,0 +1,28 @@ +import Testing +@testable import VoidDisplay + +@MainActor +struct ScreenCapturePermissionProviderTests { + @Test func makeDefaultUsesUITestScenarioProviderWhenUITestModeIsEnabled() { + let provider = ScreenCapturePermissionProviderFactory.makeDefault( + environment: [ + UITestRuntime.modeEnvironmentKey: "1", + UITestRuntime.scenarioEnvironmentKey: UITestScenario.permissionDenied.rawValue + ] + ) + + #expect(provider.preflight() == false) + #expect(provider.request() == false) + } + + @Test func makeDefaultUsesNonInteractiveProviderWhenRunningUnderXCTest() { + let provider = ScreenCapturePermissionProviderFactory.makeDefault( + environment: [ + PersistenceContext.xCTestConfigurationEnvironmentKey: "/tmp/test.xctestconfiguration" + ] + ) + + #expect(provider.preflight() == false) + #expect(provider.request() == false) + } +} diff --git a/VoidDisplayTests/TestSupport/TestServiceMocks.swift b/VoidDisplayTests/TestSupport/TestServiceMocks.swift index ec45862..bc9ad6b 100644 --- a/VoidDisplayTests/TestSupport/TestServiceMocks.swift +++ b/VoidDisplayTests/TestSupport/TestServiceMocks.swift @@ -54,6 +54,8 @@ final class MockCaptureMonitoringService: CaptureMonitoringServiceProtocol { @MainActor final class MockSharingService: SharingServiceProtocol { + typealias StartSharingHandler = @MainActor (SCDisplay) async throws -> DisplayStartOutcome + var webServicePortValue: UInt16 = 8081 var onWebServiceRunningStateChanged: (@MainActor @Sendable (Bool) -> Void)? var onWebServiceLifecycleStateChanged: (@MainActor @Sendable (WebServiceLifecycleState) -> Void)? @@ -63,6 +65,7 @@ final class MockSharingService: SharingServiceProtocol { var currentWebServer: WebServer? var hasAnyActiveSharing = false var activeSharingDisplayIDs: Set = [] + var startingDisplayIDs: Set = [] var startResult: WebServiceStartResult = .started( WebServiceBinding(requestedPort: 8081, boundPort: 8081) @@ -77,6 +80,12 @@ final class MockSharingService: SharingServiceProtocol { var streamClientCountsByTarget: [ShareTarget: Int] = [:] var shareIDByDisplayID: [CGDirectDisplayID: UInt32] = [:] var shareTargetByDisplayID: [CGDirectDisplayID: ShareTarget] = [:] + var onStopSharing: (@MainActor @Sendable (CGDirectDisplayID) -> Void)? + var startSharingHandler: StartSharingHandler? + + func isStarting(displayID: CGDirectDisplayID) -> Bool { + startingDisplayIDs.contains(displayID) + } @discardableResult func startWebService(requestedPort: UInt16) async -> WebServiceStartResult { @@ -113,15 +122,20 @@ final class MockSharingService: SharingServiceProtocol { _ = virtualSerialResolver(CGDirectDisplayID(0)) } - func startSharing(display: SCDisplay) async throws { + func startSharing(display: SCDisplay) async throws -> DisplayStartOutcome { + if let startSharingHandler { + return try await startSharingHandler(display) + } hasAnyActiveSharing = true activeSharingDisplayIDs.insert(display.displayID) + return .started(()) } func stopSharing(displayID: CGDirectDisplayID) { stopSharingCallCount += 1 activeSharingDisplayIDs.remove(displayID) hasAnyActiveSharing = !activeSharingDisplayIDs.isEmpty + onStopSharing?(displayID) } func stopAllSharing() { From 66feadf551f3c9065926d6765324e0b45f8c473b Mon Sep 17 00:00:00 2001 From: Chen Date: Fri, 27 Mar 2026 11:35:42 +0800 Subject: [PATCH 11/34] =?UTF-8?q?docs(workflow):=20=E5=BC=BA=E5=8C=96?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E5=BC=80=E5=8F=91=E7=BA=A6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 明确拒绝临时方案、胶水代码和补丁式处理 - 明确默认不保留向后兼容 - 明确限制过渡适配层和一次性 workaround --- AGENTS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 3ea3477..30687e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,9 @@ ## Complexity and Size Guardrail - Default goal: solve problems without increasing code complexity and code size. - If that is not feasible, lower complexity first. +- Reject temporary fixes, glue code, and patch-style handling. Solve the root problem with a clean structural change. +- Do not add transitional adapters, one-off shims, or workaround layers unless the user explicitly requires them for a defined migration window. +- Do not preserve backward compatibility by default. Only keep it when explicitly required, and document the caller, removal condition, and validation impact in the handoff. - Prefer deleting duplicate branches and duplicate checks. - Keep equivalent validation at one convergence layer. Avoid multi-layer duplicate defense. - When adding defensive branches, prioritize deleting equivalent legacy branches in the same module. From 21f0680494f1e4671cab0de291bad682f8394cfa Mon Sep 17 00:00:00 2001 From: Chen Date: Fri, 27 Mar 2026 12:22:10 +0800 Subject: [PATCH 12/34] =?UTF-8?q?docs(agents):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E6=A8=A1=E5=BC=8F=E5=BB=BA=E8=AE=AE=E8=A7=84?= =?UTF-8?q?=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增执行模式建议章节 - 明确直接执行与开启计划模式的判定条件 - 要求后续实施类回复附带简短建议理由 --- AGENTS.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 30687e6..5e6b6df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,6 +66,13 @@ - If the user goal or instruction is ambiguous, do not guess. Ask for clarification promptly before continuing. - Clarification questions must include all reasonable current interpretations from the agent, so the user can confirm or correct them directly. +## Execution Mode Recommendation +- For any actionable request related to feature work, bug fixing, review follow-up, refactor, or implementation analysis, include a short execution mode recommendation in the response. +- Use `建议:直接执行` when scope is clear, affected area is bounded, validation path is clear, and there is no material decision gate before implementation. +- Use `建议:开启计划模式` when the task is ambiguous, cross-module, high-risk, multi-stage, blocked by unknowns, or depends on user choice between materially different options. +- Keep the recommendation to one or two sentences and state the concrete reason for the choice. +- If the user explicitly requests plan mode or explicitly requests immediate execution, follow that instruction and still state the recommendation briefly for visibility. + ## Code Review Output Policy - When review finds an issue, identify the root cause and provide a root-cause fix plan by default. - Always include a structural refactor assessment: whether it is needed, expected benefits, risks, and validation impact. From 152ec9028855fb1a6756f481e43c1ec85f6218a6 Mon Sep 17 00:00:00 2001 From: Chen Date: Sat, 28 Mar 2026 09:19:50 +0800 Subject: [PATCH 13/34] =?UTF-8?q?refactor(capture):=20=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E5=85=B1=E4=BA=AB=E6=98=BE=E7=A4=BA=E7=9B=AE=E5=BD=95=E7=8A=B6?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入 app 级 ScreenCaptureCatalogService - 统一 capture 与 sharing 的目录刷新语义 - 补齐共享目录相关回归测试 --- VoidDisplay/App/CaptureController.swift | 10 +- VoidDisplay/App/SharingController.swift | 10 +- VoidDisplay/App/VoidDisplayApp.swift | 9 +- .../ViewModels/CaptureChooseViewModel.swift | 67 +-- .../Capture/Views/CaptureChoose.swift | 2 +- .../Sharing/ViewModels/ShareViewModel.swift | 119 +++-- .../Features/Sharing/Views/ShareView.swift | 2 +- .../ScreenCaptureCatalogService.swift | 441 ++++++++++++++++++ .../ScreenCaptureDisplayCatalogState.swift | 14 +- .../CaptureChooseViewModelTests.swift | 30 +- .../SharingEndToEndIntegrationTests.swift | 2 +- .../ViewModels/ShareViewModelTests.swift | 50 +- .../ScreenCaptureCatalogServiceTests.swift | 336 +++++++++++++ 13 files changed, 951 insertions(+), 141 deletions(-) create mode 100644 VoidDisplay/Shared/ScreenCapture/ScreenCaptureCatalogService.swift create mode 100644 VoidDisplayTests/Shared/ScreenCaptureCatalogServiceTests.swift diff --git a/VoidDisplay/App/CaptureController.swift b/VoidDisplay/App/CaptureController.swift index 8660f33..d442c7f 100644 --- a/VoidDisplay/App/CaptureController.swift +++ b/VoidDisplay/App/CaptureController.swift @@ -13,7 +13,7 @@ import Observation final class CaptureController { var screenCaptureSessions: [ScreenMonitoringSession] = [] var startingDisplayIDs: Set = [] - @ObservationIgnored let displayCatalogState = ScreenCaptureDisplayCatalogState() + @ObservationIgnored let catalogService: ScreenCaptureCatalogService @ObservationIgnored private let captureMonitoringService: any CaptureMonitoringServiceProtocol @ObservationIgnored private let captureMonitoringLifecycleService: any CaptureMonitoringLifecycleServiceProtocol @@ -21,14 +21,20 @@ final class CaptureController { init( captureMonitoringService: any CaptureMonitoringServiceProtocol, - captureMonitoringLifecycleService: (any CaptureMonitoringLifecycleServiceProtocol)? = nil + captureMonitoringLifecycleService: (any CaptureMonitoringLifecycleServiceProtocol)? = nil, + catalogService: ScreenCaptureCatalogService? = nil ) { self.captureMonitoringService = captureMonitoringService self.captureMonitoringLifecycleService = captureMonitoringLifecycleService ?? CaptureMonitoringLifecycleService(captureMonitoringService: captureMonitoringService) + self.catalogService = catalogService ?? ScreenCaptureCatalogService() self.screenCaptureSessions = captureMonitoringService.currentSessions } + var displayCatalogState: ScreenCaptureDisplayCatalogState { + catalogService.store + } + func monitoringSession(for id: UUID) -> ScreenMonitoringSession? { captureMonitoringService.monitoringSession(for: id) } diff --git a/VoidDisplay/App/SharingController.swift b/VoidDisplay/App/SharingController.swift index cb9f64c..ab0ff53 100644 --- a/VoidDisplay/App/SharingController.swift +++ b/VoidDisplay/App/SharingController.swift @@ -24,7 +24,7 @@ final class SharingController { var isSharing = false var isWebServiceRunning = false var webServiceLifecycleState: WebServiceLifecycleState = .stopped - @ObservationIgnored let displayCatalogState = ScreenCaptureDisplayCatalogState() + @ObservationIgnored let catalogService: ScreenCaptureCatalogService @ObservationIgnored private(set) var webServer: WebServer? = nil @ObservationIgnored private let sharingService: any SharingServiceProtocol @@ -33,15 +33,21 @@ final class SharingController { init( sharingService: any SharingServiceProtocol, - portPreferences: any SharingPortPreferencesProtocol + portPreferences: any SharingPortPreferencesProtocol, + catalogService: ScreenCaptureCatalogService? = nil ) { self.sharingService = sharingService self.portPreferences = portPreferences + self.catalogService = catalogService ?? ScreenCaptureCatalogService() self.sharingService.onWebServiceLifecycleStateChanged = { [weak self] _ in self?.syncSharingState() } } + var displayCatalogState: ScreenCaptureDisplayCatalogState { + catalogService.store + } + @discardableResult func startWebService(requestedPort: UInt16) async -> WebServiceStartResult { await mutateAndSync { diff --git a/VoidDisplay/App/VoidDisplayApp.swift b/VoidDisplay/App/VoidDisplayApp.swift index d836e4e..4695de9 100644 --- a/VoidDisplay/App/VoidDisplayApp.swift +++ b/VoidDisplay/App/VoidDisplayApp.swift @@ -114,6 +114,7 @@ enum AppBootstrap { ?? (ProcessInfo.processInfo.environment[xCTestConfigurationEnvironmentKey] != nil) let resolvedStartupPlan = startupPlan ?? (isRunningUnderXCTest ? .skipAll : .standard) let resolvedCaptureMonitoringService = captureMonitoringService ?? CaptureMonitoringService() + let catalogService = ScreenCaptureCatalogService() var persistenceEnvironment = ProcessInfo.processInfo.environment if preview { @@ -142,10 +143,14 @@ enum AppBootstrap { resolvedVirtualDisplayFacade = VirtualDisplayOrchestrator(configRepository: configRepository) } - let capture = CaptureController(captureMonitoringService: resolvedCaptureMonitoringService) + let capture = CaptureController( + captureMonitoringService: resolvedCaptureMonitoringService, + catalogService: catalogService + ) let sharing = SharingController( sharingService: resolvedSharingService, - portPreferences: SharingPortPreferences(defaults: persistenceContext.userDefaults) + portPreferences: SharingPortPreferences(defaults: persistenceContext.userDefaults), + catalogService: catalogService ) let virtualDisplay = VirtualDisplayController( virtualDisplayFacade: resolvedVirtualDisplayFacade, diff --git a/VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift b/VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift index 32f8f67..2ed8201 100644 --- a/VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift +++ b/VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift @@ -9,6 +9,7 @@ import OSLog @Observable final class CaptureChooseViewModel { typealias LoadErrorInfo = ScreenCaptureDisplayCatalogLoadErrorInfo + typealias RefreshIntent = ScreenCaptureCatalogRefreshIntent struct CaptureActions { var monitoringSessionForDisplayID: @MainActor (CGDirectDisplayID) -> ScreenMonitoringSession? @@ -56,11 +57,12 @@ final class CaptureChooseViewModel { let catalog: ScreenCaptureDisplayCatalogState var userFacingAlert: UserFacingAlertState? - private let topologyCoordinator: ScreenCaptureCatalogTopologyCoordinator + private let catalogService: ScreenCaptureCatalogService + @ObservationIgnored private let refreshOwner = ScreenCaptureCatalogService.RefreshOwner() @ObservationIgnored private let dependencies: Dependencies - @ObservationIgnored private let catalogLoader: ScreenCaptureDisplayCatalogLoader init( + catalogService: ScreenCaptureCatalogService? = nil, catalogState: ScreenCaptureDisplayCatalogState? = nil, permissionProvider: (any ScreenCapturePermissionProvider)? = nil, loadShareableDisplays: (@MainActor () async throws -> [SCDisplay])? = nil, @@ -69,20 +71,17 @@ final class CaptureChooseViewModel { }, dependencies: Dependencies ) { - let catalog = catalogState ?? ScreenCaptureDisplayCatalogState() - self.catalog = catalog - self.topologyCoordinator = ScreenCaptureCatalogTopologyCoordinator( - state: catalog, - activeDisplayIDsProvider: activeDisplayIDsProvider - ) - self.dependencies = dependencies - self.catalogLoader = ScreenCaptureDisplayCatalogLoader( - state: catalog, + let resolvedCatalogService = catalogService ?? ScreenCaptureCatalogService( + store: catalogState, permissionProvider: permissionProvider, loadShareableDisplays: loadShareableDisplays, + activeDisplayIDsProvider: activeDisplayIDsProvider, logOperation: "Load shareable displays", logger: AppLog.capture ) + self.catalogService = resolvedCatalogService + self.catalog = resolvedCatalogService.store + self.dependencies = dependencies } func isVirtualDisplay(_ display: SCDisplay) -> Bool { @@ -100,7 +99,7 @@ final class CaptureChooseViewModel { } func visibleDisplays(from displays: [SCDisplay]) -> [SCDisplay] { - topologyCoordinator.visibleDisplays(from: displays) + catalogService.visibleDisplays(from: displays) } func isStarting(displayID: CGDirectDisplayID) -> Bool { @@ -148,64 +147,44 @@ final class CaptureChooseViewModel { } func openScreenCapturePrivacySettings(openURL: (URL) -> Void) { - catalogLoader.openScreenCapturePrivacySettings(openURL: openURL) + catalogService.openScreenCapturePrivacySettings(openURL: openURL) } func requestScreenCapturePermission() { - let granted = catalogLoader.requestPermission() + let granted = catalogService.requestPermission() if !granted { - catalogLoader.clearDisplaysAndCancel() + Task { await catalogService.clearSnapshotForDeniedPermission() } AppLog.capture.notice("Screen capture permission request denied.") return } - loadDisplays() + Task { await self.submitRefresh(.permissionChanged) } } func refreshPermissionAndMaybeLoad() { - let granted = catalogLoader.refreshPermission() + let granted = catalogService.refreshPermission() if !granted { - catalogLoader.cancelInFlightDisplayLoad() + Task { await catalogService.clearSnapshotForDeniedPermission() } AppLog.capture.notice("Screen capture permission preflight denied.") return } - guard topologyCoordinator.needsRefresh() else { return } - if catalog.displays == nil { - loadDisplaysIfNeeded() - return - } - loadDisplaysPreservingExisting() + Task { await self.submitRefresh(.permissionChanged) } } func loadDisplays() { - catalogLoader.loadDisplays { [weak self] _ in - self?.topologyCoordinator.commitLoadedTopologySignature() - } + Task { await self.submitRefresh(.userForcedRefresh) } } func refreshDisplaysBackgroundSafe() { guard catalog.hasScreenCapturePermission == true else { return } guard !catalog.isLoadingDisplays else { return } - guard topologyCoordinator.needsRefresh() else { return } - if catalog.displays == nil { - loadDisplaysIfNeeded() - return - } - loadDisplaysPreservingExisting() + Task { await self.submitRefresh(.topologyChanged) } } func cancelInFlightDisplayLoad() { - catalogLoader.cancelInFlightDisplayLoad() - } - - private func loadDisplaysIfNeeded() { - catalogLoader.loadDisplaysIfNeeded { [weak self] _ in - self?.topologyCoordinator.commitLoadedTopologySignature() - } + Task { await catalogService.cancelRefresh(owner: refreshOwner) } } - private func loadDisplaysPreservingExisting() { - catalogLoader.loadDisplays(preserveExistingDisplays: true) { [weak self] _ in - self?.topologyCoordinator.commitLoadedTopologySignature() - } + private func submitRefresh(_ intent: RefreshIntent) async { + _ = await catalogService.submitRefresh(intent: intent, owner: refreshOwner) } } diff --git a/VoidDisplay/Features/Capture/Views/CaptureChoose.swift b/VoidDisplay/Features/Capture/Views/CaptureChoose.swift index e2b9f3b..5c5e5eb 100644 --- a/VoidDisplay/Features/Capture/Views/CaptureChoose.swift +++ b/VoidDisplay/Features/Capture/Views/CaptureChoose.swift @@ -22,7 +22,7 @@ struct IsCapturing: View { _capture = Bindable(capture) _viewModel = State( initialValue: CaptureChooseViewModel( - catalogState: capture.displayCatalogState, + catalogService: capture.catalogService, dependencies: .live(capture: capture, virtualDisplay: virtualDisplay) ) ) diff --git a/VoidDisplay/Features/Sharing/ViewModels/ShareViewModel.swift b/VoidDisplay/Features/Sharing/ViewModels/ShareViewModel.swift index 551e325..e66e359 100644 --- a/VoidDisplay/Features/Sharing/ViewModels/ShareViewModel.swift +++ b/VoidDisplay/Features/Sharing/ViewModels/ShareViewModel.swift @@ -9,6 +9,7 @@ import OSLog @Observable final class ShareViewModel { typealias LoadErrorInfo = ScreenCaptureDisplayCatalogLoadErrorInfo + typealias RefreshIntent = ScreenCaptureCatalogRefreshIntent struct SharingQueries { var isWebServiceRunning: @MainActor () -> Bool @@ -85,11 +86,12 @@ final class ShareViewModel { var isStartingService = false var userFacingAlert: UserFacingAlertState? - private let topologyCoordinator: ScreenCaptureCatalogTopologyCoordinator + private let catalogService: ScreenCaptureCatalogService + @ObservationIgnored private let refreshOwner = ScreenCaptureCatalogService.RefreshOwner() @ObservationIgnored private let dependencies: Dependencies - @ObservationIgnored private let catalogLoader: ScreenCaptureDisplayCatalogLoader init( + catalogService: ScreenCaptureCatalogService? = nil, catalogState: ScreenCaptureDisplayCatalogState? = nil, permissionProvider: (any ScreenCapturePermissionProvider)? = nil, loadShareableDisplays: (@MainActor () async throws -> [SCDisplay])? = nil, @@ -98,21 +100,21 @@ final class ShareViewModel { }, dependencies: Dependencies ) { - let catalog = catalogState ?? ScreenCaptureDisplayCatalogState() - self.catalog = catalog - self.topologyCoordinator = ScreenCaptureCatalogTopologyCoordinator( - state: catalog, - activeDisplayIDsProvider: activeDisplayIDsProvider - ) - self.dependencies = dependencies - self.catalogLoader = ScreenCaptureDisplayCatalogLoader( - state: catalog, + let resolvedCatalogService = catalogService ?? ScreenCaptureCatalogService( + store: catalogState, permissionProvider: permissionProvider, loadShareableDisplays: loadShareableDisplays, + activeDisplayIDsProvider: activeDisplayIDsProvider, logOperation: "Load shareable displays (sharing)", logger: AppLog.capture ) + self.catalogService = resolvedCatalogService + self.catalog = resolvedCatalogService.store + self.dependencies = dependencies self.servicePortInput = String(dependencies.sharingQueries.preferredWebServicePort()) + if dependencies.sharingQueries.isWebServiceRunning() { + replayShareableDisplaysIfAvailable() + } } func syncForCurrentState( @@ -121,21 +123,18 @@ final class ShareViewModel { ) { guard catalog.hasScreenCapturePermission == true else { if clearDisplaysWhenPermissionDenied { - catalogLoader.clearDisplaysAndCancel() + Task { await self.submitRefresh(.permissionChanged, replayRegistration: false) } } else { - catalogLoader.cancelInFlightDisplayLoad() + Task { await self.catalogService.cancelRefresh(owner: self.refreshOwner) } } return } guard dependencies.sharingQueries.isWebServiceRunning() else { - if clearDisplaysWhenServiceStopped { - catalogLoader.clearDisplaysAndCancel() - } else { - catalogLoader.cancelInFlightDisplayLoad() - } + _ = clearDisplaysWhenServiceStopped + Task { await self.catalogService.cancelRefresh(owner: self.refreshOwner) } return } - refreshDisplaysForCurrentTopologyIfNeeded() + Task { await self.submitRefresh(.serviceBecameRunning) } } func startService() { @@ -163,30 +162,33 @@ final class ShareViewModel { } servicePortInput = String(requestedPort) portInputErrorMessage = nil - syncForCurrentState() + _ = await submitRefresh(.serviceBecameRunning) } } func stopService() { - catalogLoader.cancelInFlightDisplayLoad() + Task { await catalogService.cancelRefresh(owner: refreshOwner) } dependencies.sharingActions.stopWebService() syncForCurrentState() } func openScreenCapturePrivacySettings(openURL: (URL) -> Void) { - catalogLoader.openScreenCapturePrivacySettings(openURL: openURL) + catalogService.openScreenCapturePrivacySettings(openURL: openURL) } func requestScreenCapturePermission() { - let granted = catalogLoader.requestPermission() + let granted = catalogService.requestPermission() AppLog.capture.notice( "Screen capture permission request (sharing): requestResult=\((self.catalog.lastRequestPermission ?? false), privacy: .public), preflightResult=\(granted, privacy: .public)" ) if !granted { - catalogLoader.clearDisplaysAndCancel() - catalog.loadErrorMessage = String(localized: "Failed to load displays. Check permission and try again.") + Task { + await catalogService.clearSnapshotForDeniedPermission( + loadErrorMessage: String(localized: "Failed to load displays. Check permission and try again.") + ) + } AppLog.capture.notice("Screen capture permission request denied (sharing).") return } @@ -194,9 +196,9 @@ final class ShareViewModel { } func refreshPermissionAndMaybeLoad() { - let granted = catalogLoader.refreshPermission() + let granted = catalogService.refreshPermission() if !granted { - catalogLoader.clearDisplaysAndCancel() + Task { await self.submitRefresh(.permissionChanged, replayRegistration: false) } return } syncForCurrentState(clearDisplaysWhenServiceStopped: false) @@ -204,39 +206,26 @@ final class ShareViewModel { func loadDisplaysIfNeeded() { guard dependencies.sharingQueries.isWebServiceRunning() else { return } - catalogLoader.loadDisplaysIfNeeded { [weak self] displays in - self?.handleDisplaysLoaded(displays) - } + Task { await self.submitRefresh(.serviceBecameRunning) } } func loadDisplays() { - catalogLoader.loadDisplays { [weak self] displays in - self?.handleDisplaysLoaded(displays) - } + Task { await self.submitRefresh(.userForcedRefresh) } } func refreshDisplays() { guard dependencies.sharingQueries.isWebServiceRunning() else { return } - catalogLoader.loadDisplays(preserveExistingDisplays: true) { [weak self] displays in - self?.handleDisplaysLoaded(displays) - } + Task { await self.submitRefresh(.userForcedRefresh) } } func refreshDisplaysBackgroundSafe() { guard dependencies.sharingQueries.isWebServiceRunning() else { return } guard !catalog.isLoadingDisplays else { return } - guard topologyCoordinator.needsRefresh() else { return } - if catalog.displays == nil { - loadDisplaysIfNeeded() - return - } - catalogLoader.loadDisplays(preserveExistingDisplays: true) { [weak self] displays in - self?.handleDisplaysLoaded(displays) - } + Task { await self.submitRefresh(.topologyChanged) } } func visibleDisplays(from displays: [SCDisplay]) -> [SCDisplay] { - topologyCoordinator.visibleDisplays(from: displays) + catalogService.visibleDisplays(from: displays) } func isStarting(displayID: CGDirectDisplayID) -> Bool { @@ -301,23 +290,7 @@ final class ShareViewModel { } func cancelInFlightDisplayLoad() { - catalogLoader.cancelInFlightDisplayLoad() - } - - private func refreshDisplaysForCurrentTopologyIfNeeded() { - guard topologyCoordinator.needsRefresh() else { return } - if catalog.displays == nil { - loadDisplaysIfNeeded() - return - } - catalogLoader.loadDisplays(preserveExistingDisplays: true) { [weak self] displays in - self?.handleDisplaysLoaded(displays) - } - } - - private func handleDisplaysLoaded(_ displays: [SCDisplay]) { - topologyCoordinator.commitLoadedTopologySignature() - registerShareableDisplays(displays) + Task { await catalogService.cancelRefresh(owner: refreshOwner) } } private func registerShareableDisplays(_ displays: [SCDisplay]) { @@ -333,4 +306,26 @@ final class ShareViewModel { private func presentPortInputError(_ message: String) { portInputErrorMessage = message } + + @discardableResult + private func submitRefresh( + _ intent: RefreshIntent, + replayRegistration: Bool = true + ) async -> ScreenCaptureCatalogRefreshResult { + let result = await catalogService.submitRefresh(intent: intent, owner: refreshOwner) + if replayRegistration, dependencies.sharingQueries.isWebServiceRunning() { + switch result { + case .reloadedSnapshot, .reusedSnapshot: + replayShareableDisplaysIfAvailable() + case .clearedSnapshot, .failed: + break + } + } + return result + } + + private func replayShareableDisplaysIfAvailable() { + guard let displays = catalog.displays else { return } + registerShareableDisplays(displays) + } } diff --git a/VoidDisplay/Features/Sharing/Views/ShareView.swift b/VoidDisplay/Features/Sharing/Views/ShareView.swift index 0bf5701..49a9dcb 100644 --- a/VoidDisplay/Features/Sharing/Views/ShareView.swift +++ b/VoidDisplay/Features/Sharing/Views/ShareView.swift @@ -24,7 +24,7 @@ struct ShareView: View { _sharing = Bindable(sharing) _viewModel = State( initialValue: ShareViewModel( - catalogState: sharing.displayCatalogState, + catalogService: sharing.catalogService, dependencies: .live(sharing: sharing, virtualDisplay: virtualDisplay) ) ) diff --git a/VoidDisplay/Shared/ScreenCapture/ScreenCaptureCatalogService.swift b/VoidDisplay/Shared/ScreenCapture/ScreenCaptureCatalogService.swift new file mode 100644 index 0000000..7e56562 --- /dev/null +++ b/VoidDisplay/Shared/ScreenCapture/ScreenCaptureCatalogService.swift @@ -0,0 +1,441 @@ +import AppKit +import CoreGraphics +import Foundation +import Observation +import OSLog +@preconcurrency import ScreenCaptureKit + +enum ScreenCaptureCatalogRefreshIntent: Sendable, Equatable { + case permissionChanged + case topologyChanged + case serviceBecameRunning + case userForcedRefresh + +} + +enum ScreenCaptureCatalogRefreshResult: Sendable, Equatable { + case reloadedSnapshot + case reusedSnapshot + case clearedSnapshot + case failed +} + +@MainActor +@Observable +final class ScreenCaptureCatalogStore { + var displays: [SCDisplay]? + var hasScreenCapturePermission: Bool? + var lastPreflightPermission: Bool? + var lastRequestPermission: Bool? + var lastLoadedActiveDisplayTopologySignature: [CGDirectDisplayID]? + var isLoadingDisplays = false + var loadErrorMessage: String? + var lastLoadError: ScreenCaptureDisplayCatalogLoadErrorInfo? + var showDebugInfo = false + var lastRefreshResult: ScreenCaptureCatalogRefreshResult? +} + +@MainActor +final class ScreenCaptureCatalogService { + struct RefreshOwner: Hashable, Sendable { + fileprivate let id = UUID() + } + + typealias LoadShareableDisplays = @MainActor () async throws -> [SCDisplay] + typealias ActiveDisplayIDsProvider = @MainActor () -> Set + + struct Dependencies { + var permissionProvider: any ScreenCapturePermissionProvider + var loadShareableDisplays: LoadShareableDisplays + var activeDisplayIDsProvider: ActiveDisplayIDsProvider + var loadFailureMessage: String + var logOperation: String + var logger: Logger + var runtimeScenarioProbe: ScreenCaptureDisplayCatalogLoader.RuntimeScenarioProbe + + static func live( + loadFailureMessage: String = String(localized: "Failed to load displays. Check permission and try again."), + logOperation: String = "Load shareable displays", + logger: Logger = AppLog.capture + ) -> Self { + .init( + permissionProvider: ScreenCapturePermissionProviderFactory.makeDefault(), + loadShareableDisplays: { + let content = try await SCShareableContent.excludingDesktopWindows( + false, + onScreenWindowsOnly: false + ) + return content.displays + }, + activeDisplayIDsProvider: { + Set(NSScreen.screens.compactMap(\.cgDirectDisplayID)) + }, + loadFailureMessage: loadFailureMessage, + logOperation: logOperation, + logger: logger, + runtimeScenarioProbe: .live + ) + } + } + + let store: ScreenCaptureCatalogStore + + private let dependencies: Dependencies + private let coordinator: CatalogRefreshCoordinator + + init( + store: ScreenCaptureCatalogStore? = nil, + dependencies: Dependencies + ) { + let resolvedStore = store ?? ScreenCaptureCatalogStore() + self.store = resolvedStore + self.dependencies = dependencies + self.coordinator = CatalogRefreshCoordinator( + loadShareableDisplays: { + try await Task { @MainActor in + try await dependencies.loadShareableDisplays() + }.value + }, + runtimeScenarioProbe: dependencies.runtimeScenarioProbe + ) + } + + convenience init( + store: ScreenCaptureCatalogStore? = nil, + permissionProvider: (any ScreenCapturePermissionProvider)? = nil, + loadShareableDisplays: LoadShareableDisplays? = nil, + activeDisplayIDsProvider: ActiveDisplayIDsProvider? = nil, + loadFailureMessage: String = String(localized: "Failed to load displays. Check permission and try again."), + logOperation: String = "Load shareable displays", + logger: Logger = AppLog.capture, + runtimeScenarioProbe: ScreenCaptureDisplayCatalogLoader.RuntimeScenarioProbe = .live + ) { + self.init( + store: store, + dependencies: .init( + permissionProvider: permissionProvider ?? ScreenCapturePermissionProviderFactory.makeDefault(), + loadShareableDisplays: loadShareableDisplays ?? { + let content = try await SCShareableContent.excludingDesktopWindows( + false, + onScreenWindowsOnly: false + ) + return content.displays + }, + activeDisplayIDsProvider: activeDisplayIDsProvider ?? { + Set(NSScreen.screens.compactMap(\.cgDirectDisplayID)) + }, + loadFailureMessage: loadFailureMessage, + logOperation: logOperation, + logger: logger, + runtimeScenarioProbe: runtimeScenarioProbe + ) + ) + } + + func openScreenCapturePrivacySettings(openURL: (URL) -> Void) { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") { + openURL(url) + } else if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security") { + openURL(url) + } + } + + @discardableResult + func requestPermission() -> Bool { + let requestResult = dependencies.permissionProvider.request() + store.lastRequestPermission = requestResult + + let preflightResult = dependencies.permissionProvider.preflight() + store.hasScreenCapturePermission = preflightResult + store.lastPreflightPermission = preflightResult + return preflightResult + } + + @discardableResult + func refreshPermission() -> Bool { + let granted = dependencies.permissionProvider.preflight() + store.hasScreenCapturePermission = granted + store.lastPreflightPermission = granted + return granted + } + + func currentActiveDisplayTopologySignature() -> [CGDirectDisplayID] { + dependencies.activeDisplayIDsProvider().sorted() + } + + func visibleDisplays(from displays: [SCDisplay]) -> [SCDisplay] { + let activeDisplayIDs = dependencies.activeDisplayIDsProvider() + return displays.filter { activeDisplayIDs.contains($0.displayID) } + } + + func clearSnapshotForDeniedPermission(loadErrorMessage: String? = nil) async { + await coordinator.cancelActiveRefresh() + applyClearedSnapshot(signature: currentActiveDisplayTopologySignature()) + store.loadErrorMessage = loadErrorMessage + } + + func cancelRefresh(owner: RefreshOwner? = nil) async { + let cancellation = await coordinator.cancelActiveRefresh(ownedBy: owner?.id) + if cancellation != .ignoredOtherOwner { + store.isLoadingDisplays = false + } + } + + @discardableResult + func submitRefresh( + intent: ScreenCaptureCatalogRefreshIntent, + owner: RefreshOwner? = nil + ) async -> ScreenCaptureCatalogRefreshResult { + let permissionGranted = refreshPermission() + let currentSignature = currentActiveDisplayTopologySignature() + let request = CatalogRefreshCoordinator.Request( + intent: intent, + permissionGranted: permissionGranted, + currentTopologySignature: currentSignature, + cachedTopologySignature: store.lastLoadedActiveDisplayTopologySignature, + hasCachedDisplays: store.displays != nil, + ownerID: owner?.id + ) + + switch await coordinator.prepare(request: request) { + case .reusedSnapshot: + store.lastRefreshResult = .reusedSnapshot + return .reusedSnapshot + case .clearedSnapshot: + applyClearedSnapshot(signature: currentSignature) + return .clearedSnapshot + case .failed: + store.lastRefreshResult = .failed + return .failed + case .awaitInFlight(let loadID): + store.isLoadingDisplays = true + let execution = await coordinator.executeLoad(loadID: loadID) + return commit(execution: execution, signature: currentSignature) + case .execute(let loadID, let clearsSnapshotFirst): + store.isLoadingDisplays = true + store.loadErrorMessage = nil + store.lastLoadError = nil + if clearsSnapshotFirst { + store.displays = nil + } + + let execution = await coordinator.executeLoad(loadID: loadID) + return commit(execution: execution, signature: currentSignature) + } + } + + private func applyClearedSnapshot(signature: [CGDirectDisplayID]) { + store.displays = nil + store.hasScreenCapturePermission = false + store.lastPreflightPermission = false + store.lastLoadedActiveDisplayTopologySignature = signature + store.isLoadingDisplays = false + store.loadErrorMessage = nil + store.lastLoadError = nil + store.lastRefreshResult = .clearedSnapshot + } + + private func commit( + execution: CatalogRefreshCoordinator.ExecutionResult, + signature: [CGDirectDisplayID] + ) -> ScreenCaptureCatalogRefreshResult { + switch execution { + case .reloadedSnapshot(let displays): + store.displays = displays.map(\.value) + store.hasScreenCapturePermission = true + store.lastPreflightPermission = true + store.lastLoadedActiveDisplayTopologySignature = signature + store.isLoadingDisplays = false + store.loadErrorMessage = nil + store.lastLoadError = nil + store.lastRefreshResult = .reloadedSnapshot + return .reloadedSnapshot + case .failed(let error, let shouldClearDisplays): + let nsError = error as NSError + AppErrorMapper.logFailure( + dependencies.logOperation, + error: error, + logger: dependencies.logger + ) + store.isLoadingDisplays = false + store.loadErrorMessage = dependencies.loadFailureMessage + store.lastLoadError = .init( + domain: nsError.domain, + code: nsError.code, + description: nsError.localizedDescription, + failureReason: nsError.localizedFailureReason, + recoverySuggestion: nsError.localizedRecoverySuggestion + ) + if shouldClearDisplays { + store.displays = nil + } + store.lastRefreshResult = .failed + return .failed + case .clearedSnapshot: + applyClearedSnapshot(signature: signature) + return .clearedSnapshot + case .failedSuperseded: + return .failed + } + } +} + +actor CatalogRefreshCoordinator { + enum CancelResult: Sendable, Equatable { + case cancelledActiveRequest + case idle + case ignoredOtherOwner + } + + struct Request: Sendable { + let intent: ScreenCaptureCatalogRefreshIntent + let permissionGranted: Bool + let currentTopologySignature: [CGDirectDisplayID] + let cachedTopologySignature: [CGDirectDisplayID]? + let hasCachedDisplays: Bool + let ownerID: UUID? + } + + enum PrepareResult: Sendable, Equatable { + case execute(loadID: UInt64, clearsSnapshotFirst: Bool) + case awaitInFlight(loadID: UInt64) + case reusedSnapshot + case clearedSnapshot + case failed + } + + enum ExecutionResult: Sendable { + case reloadedSnapshot([SendableDisplay]) + case failed(error: any Error, shouldClearDisplays: Bool) + case clearedSnapshot + case failedSuperseded + } + + private let loadShareableDisplays: @Sendable () async throws -> [SCDisplay] + private let runtimeScenarioProbe: ScreenCaptureDisplayCatalogLoader.RuntimeScenarioProbe + private var nextLoadID: UInt64 = 0 + private var activeLoadID: UInt64? + private var activeOwnerID: UUID? + private var activeTask: Task<[SendableDisplay], Error>? + private var waiterCountsByLoadID: [UInt64: Int] = [:] + + init( + loadShareableDisplays: @escaping @Sendable () async throws -> [SCDisplay], + runtimeScenarioProbe: ScreenCaptureDisplayCatalogLoader.RuntimeScenarioProbe + ) { + self.loadShareableDisplays = loadShareableDisplays + self.runtimeScenarioProbe = runtimeScenarioProbe + } + + func prepare(request: Request) async -> PrepareResult { + if await MainActor.run(body: { runtimeScenarioProbe.shouldShortCircuitDisplayLoadAsPermissionDenied() }) { + cancelActiveRefresh() + return .clearedSnapshot + } + + guard request.permissionGranted else { + cancelActiveRefresh() + return .clearedSnapshot + } + + let isForcedRefresh: Bool + switch request.intent { + case .userForcedRefresh: + isForcedRefresh = true + case .permissionChanged, .topologyChanged, .serviceBecameRunning: + isForcedRefresh = false + } + + let canReuseSnapshot = + !isForcedRefresh && + request.hasCachedDisplays && + request.cachedTopologySignature == request.currentTopologySignature + + if canReuseSnapshot { + if let activeLoadID { + waiterCountsByLoadID[activeLoadID, default: 0] += 1 + return .awaitInFlight(loadID: activeLoadID) + } + return .reusedSnapshot + } + + activeTask?.cancel() + activeTask = nil + nextLoadID &+= 1 + let loadID = nextLoadID + activeLoadID = loadID + activeOwnerID = request.ownerID + waiterCountsByLoadID[loadID, default: 0] += 1 + return .execute(loadID: loadID, clearsSnapshotFirst: !request.hasCachedDisplays) + } + + func executeLoad(loadID: UInt64) async -> ExecutionResult { + guard waiterCountsByLoadID[loadID] != nil else { + return .failedSuperseded + } + defer { finishWaiting(on: loadID) } + + let task: Task<[SendableDisplay], Error> + if activeLoadID == loadID { + if let activeTask { + task = activeTask + } else { + let createdTask = Task<[SendableDisplay], Error> { [loadShareableDisplays, runtimeScenarioProbe] in + if await MainActor.run(body: { runtimeScenarioProbe.shouldDelayDisplayLoadForUITest() }) { + try await Task.sleep(for: .seconds(3)) + } + return try await loadShareableDisplays().map(SendableDisplay.init) + } + activeTask = createdTask + task = createdTask + } + } else { + return .failedSuperseded + } + + do { + let displays = try await task.value + guard activeLoadID == loadID, !Task.isCancelled else { + return .failedSuperseded + } + return .reloadedSnapshot(displays) + } catch is CancellationError { + return .failedSuperseded + } catch { + guard activeLoadID == loadID else { + return .failedSuperseded + } + return .failed(error: error, shouldClearDisplays: false) + } + } + + func cancelActiveRefresh() { + activeTask?.cancel() + activeTask = nil + activeLoadID = nil + activeOwnerID = nil + } + + func cancelActiveRefresh(ownedBy ownerID: UUID?) -> CancelResult { + guard activeLoadID != nil else { return .idle } + if let ownerID, activeOwnerID != ownerID { + return .ignoredOtherOwner + } + cancelActiveRefresh() + return .cancelledActiveRequest + } + + private func finishWaiting(on loadID: UInt64) { + guard let waiterCount = waiterCountsByLoadID[loadID] else { return } + if waiterCount > 1 { + waiterCountsByLoadID[loadID] = waiterCount - 1 + return + } + + waiterCountsByLoadID.removeValue(forKey: loadID) + guard activeLoadID == loadID else { return } + activeTask = nil + activeLoadID = nil + activeOwnerID = nil + } +} diff --git a/VoidDisplay/Shared/ScreenCapture/ScreenCaptureDisplayCatalogState.swift b/VoidDisplay/Shared/ScreenCapture/ScreenCaptureDisplayCatalogState.swift index adb48df..6d012d5 100644 --- a/VoidDisplay/Shared/ScreenCapture/ScreenCaptureDisplayCatalogState.swift +++ b/VoidDisplay/Shared/ScreenCapture/ScreenCaptureDisplayCatalogState.swift @@ -11,16 +11,4 @@ struct ScreenCaptureDisplayCatalogLoadErrorInfo: Equatable { var recoverySuggestion: String? } -@MainActor -@Observable -final class ScreenCaptureDisplayCatalogState { - var displays: [SCDisplay]? - var hasScreenCapturePermission: Bool? - var lastPreflightPermission: Bool? - var lastRequestPermission: Bool? - var lastLoadedActiveDisplayTopologySignature: [CGDirectDisplayID]? - var isLoadingDisplays = false - var loadErrorMessage: String? - var lastLoadError: ScreenCaptureDisplayCatalogLoadErrorInfo? - var showDebugInfo = false -} +typealias ScreenCaptureDisplayCatalogState = ScreenCaptureCatalogStore diff --git a/VoidDisplayTests/Features/Capture/ViewModels/CaptureChooseViewModelTests.swift b/VoidDisplayTests/Features/Capture/ViewModels/CaptureChooseViewModelTests.swift index 259353e..6a66ed0 100644 --- a/VoidDisplayTests/Features/Capture/ViewModels/CaptureChooseViewModelTests.swift +++ b/VoidDisplayTests/Features/Capture/ViewModels/CaptureChooseViewModelTests.swift @@ -215,7 +215,7 @@ struct CaptureChooseViewModelTests { #expect(sut.userFacingAlert == nil) } - @MainActor @Test func requestPermissionDeniedClearsDisplayState() { + @MainActor @Test func requestPermissionDeniedClearsDisplayState() async { let sut = CaptureChooseViewModel( permissionProvider: MockScreenCapturePermissionProvider( preflightResult: false, @@ -228,6 +228,15 @@ struct CaptureChooseViewModelTests { sut.requestScreenCapturePermission() + let cleared = await waitUntil { + sut.catalog.hasScreenCapturePermission == false && + sut.catalog.lastRequestPermission == false && + sut.catalog.lastPreflightPermission == false && + sut.catalog.displays == nil && + sut.catalog.isLoadingDisplays == false + } + + #expect(cleared) #expect(sut.catalog.hasScreenCapturePermission == false) #expect(sut.catalog.lastRequestPermission == false) #expect(sut.catalog.lastPreflightPermission == false) @@ -515,7 +524,7 @@ struct CaptureChooseViewModelTests { #expect(staleResultIgnored) } - @MainActor @Test func refreshPermissionDeniedCancelsInFlightDisplayLoad() async { + @MainActor @Test func refreshPermissionDeniedClearsStateWithoutStartingLoad() async { let gate = SequencedCaptureDisplayLoaderGate( scriptedOutcomes: [.success] ) @@ -536,12 +545,16 @@ struct CaptureChooseViewModelTests { ) sut.loadDisplays() - #expect(await waitForLoaderCall(gate, count: 1)) + #expect(await waitForLoaderCall(gate, count: 1) == false) sut.refreshPermissionAndMaybeLoad() - #expect(sut.catalog.hasScreenCapturePermission == false) - #expect(sut.catalog.isLoadingDisplays == false) - #expect(sut.catalog.displays == nil) + let cleared = await waitUntil { + sut.catalog.hasScreenCapturePermission == false && + sut.catalog.isLoadingDisplays == false && + sut.catalog.displays == nil + } + + #expect(cleared) await gate.release(call: 1) let lateWritePrevented = await waitUntil(timeoutNanoseconds: AsyncTestTimeouts.shortStabilityWindow) { @@ -571,7 +584,10 @@ struct CaptureChooseViewModelTests { sut.loadDisplays() #expect(await waitForLoaderCall(gate, count: 1)) sut.cancelInFlightDisplayLoad() - #expect(sut.catalog.isLoadingDisplays == false) + let cancelled = await waitUntil { + sut.catalog.isLoadingDisplays == false + } + #expect(cancelled) await gate.release(call: 1) let lateWritePrevented = await waitUntil(timeoutNanoseconds: AsyncTestTimeouts.shortStabilityWindow) { diff --git a/VoidDisplayTests/Features/Sharing/Integration/SharingEndToEndIntegrationTests.swift b/VoidDisplayTests/Features/Sharing/Integration/SharingEndToEndIntegrationTests.swift index 615490e..2efc37a 100644 --- a/VoidDisplayTests/Features/Sharing/Integration/SharingEndToEndIntegrationTests.swift +++ b/VoidDisplayTests/Features/Sharing/Integration/SharingEndToEndIntegrationTests.swift @@ -61,7 +61,7 @@ struct SharingEndToEndIntegrationTests { @Test func sharingLifecycleRoutesRemainConsistent() async throws { let storeURL = temporaryStoreURL() - let registry = DisplayCaptureRegistry(captureSessionFactory: { _ in + let registry = DisplayCaptureRegistry(captureSessionFactory: { _, _ in EndToEndFakeCaptureSession() }) let coordinator = DisplaySharingCoordinator( diff --git a/VoidDisplayTests/Features/Sharing/ViewModels/ShareViewModelTests.swift b/VoidDisplayTests/Features/Sharing/ViewModels/ShareViewModelTests.swift index f3e432d..18d0b66 100644 --- a/VoidDisplayTests/Features/Sharing/ViewModels/ShareViewModelTests.swift +++ b/VoidDisplayTests/Features/Sharing/ViewModels/ShareViewModelTests.swift @@ -122,7 +122,7 @@ struct ShareViewModelTests { #expect(sut.isStarting(displayID: 102) == false) } - @MainActor @Test func requestPermissionDeniedClearsDisplaysAndSetsErrorMessage() { + @MainActor @Test func requestPermissionDeniedClearsDisplaysAndSetsErrorMessage() async { let env = makeEnvironment() let sut = ShareViewModel( permissionProvider: MockScreenCapturePermissionProvider( @@ -136,6 +136,16 @@ struct ShareViewModelTests { sut.requestScreenCapturePermission() + let cleared = await waitUntil { + sut.catalog.hasScreenCapturePermission == false && + sut.catalog.lastRequestPermission == false && + sut.catalog.lastPreflightPermission == false && + sut.catalog.displays == nil && + sut.catalog.isLoadingDisplays == false && + sut.catalog.loadErrorMessage != nil + } + + #expect(cleared) #expect(sut.catalog.hasScreenCapturePermission == false) #expect(sut.catalog.lastRequestPermission == false) #expect(sut.catalog.lastPreflightPermission == false) @@ -146,14 +156,32 @@ struct ShareViewModelTests { @MainActor @Test func loadDisplaysRegistersDisplaysThroughControllers() async { let sharing = MockSharingService() - let env = makeEnvironment(sharing: sharing) let sut = ShareViewModel( permissionProvider: MockScreenCapturePermissionProvider( preflightResult: true, requestResult: true ), loadShareableDisplays: { [] }, - dependencies: .live(sharing: env.sharing, virtualDisplay: env.virtualDisplay) + dependencies: .init( + sharingQueries: .init( + isWebServiceRunning: { true }, + isStartingDisplayID: { _ in false }, + sharePageAddress: { _ in nil }, + preferredWebServicePort: { 8081 } + ), + sharingActions: .init( + startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, + stopWebService: {}, + registerShareableDisplays: { displays, resolver in + sharing.registerShareableDisplays(displays, virtualSerialResolver: resolver) + }, + beginSharing: { _ in .started(()) }, + stopSharing: { _ in } + ), + virtualDisplayQueries: .init( + virtualSerialForManagedDisplay: { _ in nil } + ) + ) ) sut.loadDisplays() @@ -344,7 +372,7 @@ struct ShareViewModelTests { #expect(secondFinished) } - @MainActor @Test func syncForCurrentStateClearsDisplaysWhenServiceIsStopped() { + @MainActor @Test func syncForCurrentStatePreservesDisplaysWhenServiceIsStopped() { let existingDisplay = MockSCDisplay.make(displayID: 9022, width: 1920, height: 1080) let sut = ShareViewModel( dependencies: .init( @@ -371,11 +399,11 @@ struct ShareViewModelTests { sut.syncForCurrentState() - #expect(sut.catalog.displays == nil) + #expect(sut.catalog.displays?.map(\.displayID) == [existingDisplay.displayID]) #expect(sut.catalog.isLoadingDisplays == false) } - @MainActor @Test func syncForCurrentStateCancelsLoadWithoutClearingDisplaysWhenServiceStoppedClearIsDisabled() { + @MainActor @Test func syncForCurrentStateCancelsLoadWithoutClearingDisplaysWhenServiceStoppedClearIsDisabled() async { let existingDisplay = MockSCDisplay.make(displayID: 9030, width: 1920, height: 1080) let sut = ShareViewModel( dependencies: .init( @@ -403,6 +431,11 @@ struct ShareViewModelTests { sut.syncForCurrentState(clearDisplaysWhenServiceStopped: false) + let cancelled = await waitUntil { + sut.catalog.isLoadingDisplays == false + } + + #expect(cancelled) #expect(sut.catalog.displays?.map(\.displayID) == [existingDisplay.displayID]) #expect(sut.catalog.isLoadingDisplays == false) } @@ -910,6 +943,11 @@ struct ShareViewModelTests { #expect(await waitForLoaderCall(gate, count: 1)) sut.stopService() + let cancelled = await waitUntil { + sut.catalog.isLoadingDisplays == false && sut.catalog.displays == nil + } + + #expect(cancelled) #expect(sut.catalog.isLoadingDisplays == false) #expect(sut.catalog.displays == nil) diff --git a/VoidDisplayTests/Shared/ScreenCaptureCatalogServiceTests.swift b/VoidDisplayTests/Shared/ScreenCaptureCatalogServiceTests.swift new file mode 100644 index 0000000..eedad5c --- /dev/null +++ b/VoidDisplayTests/Shared/ScreenCaptureCatalogServiceTests.swift @@ -0,0 +1,336 @@ +import CoreGraphics +import Foundation +import ScreenCaptureKit +import Testing +@testable import VoidDisplay + +private actor SequencedCatalogServiceLoadGate { + enum Outcome: Sendable { + case success + case failure(any Error) + } + + private struct PendingCall { + let outcome: Outcome + let continuation: CheckedContinuation + } + + private let scriptedOutcomes: [Outcome] + private var callCount = 0 + private var pendingCalls: [Int: PendingCall] = [:] + + init(scriptedOutcomes: [Outcome]) { + self.scriptedOutcomes = scriptedOutcomes + } + + func nextOutcome() async -> Outcome { + callCount += 1 + let callIndex = callCount + let outcome = scriptedOutcomes.indices.contains(callIndex - 1) + ? scriptedOutcomes[callIndex - 1] + : .success + return await withCheckedContinuation { continuation in + pendingCalls[callIndex] = PendingCall(outcome: outcome, continuation: continuation) + } + } + + func release(call callIndex: Int) { + guard let pending = pendingCalls.removeValue(forKey: callIndex) else { return } + pending.continuation.resume(returning: pending.outcome) + } + + func currentCallCount() -> Int { + callCount + } +} + +private struct CatalogServiceControlledFailure: Error, Sendable {} + +private final class CatalogServiceMockSCDisplayBox: NSObject { + @objc let displayID: CGDirectDisplayID + @objc let width: Int + @objc let height: Int + @objc let frame: CGRect + + init(displayID: CGDirectDisplayID, width: Int, height: Int) { + self.displayID = displayID + self.width = width + self.height = height + self.frame = CGRect(x: 0, y: 0, width: width, height: height) + super.init() + } +} + +private enum CatalogServiceMockSCDisplay { + static func make(displayID: CGDirectDisplayID, width: Int, height: Int) -> SCDisplay { + let box = CatalogServiceMockSCDisplayBox(displayID: displayID, width: width, height: height) + return unsafeBitCast(box, to: SCDisplay.self) + } +} + +@MainActor +@Suite(.serialized) +struct ScreenCaptureCatalogServiceTests { + @Test func unchangedTopologyReusesSnapshotWithoutReload() async { + let gate = SequencedCatalogServiceLoadGate(scriptedOutcomes: [.success]) + let sut = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider(preflightResult: true, requestResult: true), + loadShareableDisplays: { + switch await gate.nextOutcome() { + case .success: + return [] + case .failure(let error): + throw error + } + }, + activeDisplayIDsProvider: { [101] } + ) + sut.store.hasScreenCapturePermission = true + sut.store.displays = [] + sut.store.lastLoadedActiveDisplayTopologySignature = [101] + + let result = await sut.submitRefresh(intent: .permissionChanged) + + #expect(result == .reusedSnapshot) + #expect(sut.store.lastRefreshResult == .reusedSnapshot) + #expect(await gate.currentCallCount() == 0) + } + + @Test func permissionDeniedClearsSnapshot() async { + let sut = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider(preflightResult: false, requestResult: false), + loadShareableDisplays: { [] }, + activeDisplayIDsProvider: { [202] } + ) + sut.store.displays = [] + sut.store.hasScreenCapturePermission = true + _ = sut.refreshPermission() + + let result = await sut.submitRefresh(intent: .permissionChanged) + + #expect(result == .clearedSnapshot) + #expect(sut.store.displays == nil) + #expect(sut.store.hasScreenCapturePermission == false) + #expect(sut.store.lastRefreshResult == .clearedSnapshot) + } + + @Test func supersededRefreshDoesNotOverwriteLatestSnapshot() async { + let gate = SequencedCatalogServiceLoadGate( + scriptedOutcomes: [ + .failure(CatalogServiceControlledFailure()), + .success + ] + ) + let sut = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider(preflightResult: true, requestResult: true), + loadShareableDisplays: { + switch await gate.nextOutcome() { + case .success: + return [] + case .failure(let error): + throw error + } + }, + activeDisplayIDsProvider: { [303] } + ) + sut.store.hasScreenCapturePermission = true + + let firstRefresh = Task { await sut.submitRefresh(intent: .permissionChanged) } + #expect(await waitForLoaderCall(gate, count: 1)) + + let secondRefresh = Task { await sut.submitRefresh(intent: .userForcedRefresh) } + #expect(await waitForLoaderCall(gate, count: 2)) + + await gate.release(call: 2) + #expect(await secondRefresh.value == .reloadedSnapshot) + #expect(sut.store.displays?.isEmpty == true) + #expect(sut.store.lastLoadError == nil) + + await gate.release(call: 1) + #expect(await firstRefresh.value == .failed) + #expect(sut.store.displays?.isEmpty == true) + #expect(sut.store.lastLoadError == nil) + #expect(sut.store.lastRefreshResult == .reloadedSnapshot) + } + + @Test func cancelRefreshOnlyCancelsMatchingOwner() async { + let gate = SequencedCatalogServiceLoadGate(scriptedOutcomes: [.success]) + let sut = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider(preflightResult: true, requestResult: true), + loadShareableDisplays: { + switch await gate.nextOutcome() { + case .success: + return [] + case .failure(let error): + throw error + } + }, + activeDisplayIDsProvider: { [404] } + ) + let captureOwner = ScreenCaptureCatalogService.RefreshOwner() + let sharingOwner = ScreenCaptureCatalogService.RefreshOwner() + sut.store.hasScreenCapturePermission = true + + let refresh = Task { + await sut.submitRefresh(intent: .userForcedRefresh, owner: captureOwner) + } + #expect(await waitForLoaderCall(gate, count: 1)) + #expect(sut.store.isLoadingDisplays == true) + + await sut.cancelRefresh(owner: sharingOwner) + + #expect(sut.store.isLoadingDisplays == true) + + await gate.release(call: 1) + #expect(await refresh.value == .reloadedSnapshot) + #expect(sut.store.isLoadingDisplays == false) + #expect(sut.store.lastRefreshResult == .reloadedSnapshot) + } + + @Test func supersededRefreshDoesNotClearLoadingStateWhileReplacementRuns() async { + let gate = SequencedCatalogServiceLoadGate( + scriptedOutcomes: [ + .failure(CatalogServiceControlledFailure()), + .success + ] + ) + let sut = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider(preflightResult: true, requestResult: true), + loadShareableDisplays: { + switch await gate.nextOutcome() { + case .success: + return [] + case .failure(let error): + throw error + } + }, + activeDisplayIDsProvider: { [505] } + ) + sut.store.hasScreenCapturePermission = true + + let firstRefresh = Task { await sut.submitRefresh(intent: .permissionChanged) } + #expect(await waitForLoaderCall(gate, count: 1)) + + let secondRefresh = Task { await sut.submitRefresh(intent: .userForcedRefresh) } + #expect(await waitForLoaderCall(gate, count: 2)) + #expect(sut.store.isLoadingDisplays == true) + + await gate.release(call: 1) + + #expect(await firstRefresh.value == .failed) + #expect(sut.store.isLoadingDisplays == true) + #expect(sut.store.lastLoadError == nil) + #expect(sut.store.lastRefreshResult == nil) + + await gate.release(call: 2) + + #expect(await secondRefresh.value == .reloadedSnapshot) + #expect(sut.store.isLoadingDisplays == false) + #expect(sut.store.lastRefreshResult == .reloadedSnapshot) + } + + @Test func matchingCachedTopologyJoinsInFlightRefreshInsteadOfReusingStaleSnapshot() async { + let gate = SequencedCatalogServiceLoadGate(scriptedOutcomes: [.success]) + let staleDisplay = CatalogServiceMockSCDisplay.make(displayID: 707, width: 1280, height: 720) + let refreshedDisplay = CatalogServiceMockSCDisplay.make(displayID: 808, width: 1920, height: 1080) + let sut = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider(preflightResult: true, requestResult: true), + loadShareableDisplays: { + switch await gate.nextOutcome() { + case .success: + return [refreshedDisplay] + case .failure(let error): + throw error + } + }, + activeDisplayIDsProvider: { [707] } + ) + sut.store.hasScreenCapturePermission = true + sut.store.displays = [staleDisplay] + sut.store.lastLoadedActiveDisplayTopologySignature = [707] + + let firstRefresh = Task { + await sut.submitRefresh(intent: .userForcedRefresh) + } + #expect(await waitForLoaderCall(gate, count: 1)) + + let joinedRefresh = Task { + await sut.submitRefresh(intent: .serviceBecameRunning) + } + + let stayedSingleLoad = await staysTrue(timeoutNanoseconds: 100_000_000) { + await gate.currentCallCount() == 1 + } + #expect(stayedSingleLoad) + #expect(sut.store.isLoadingDisplays == true) + + await gate.release(call: 1) + + #expect(await firstRefresh.value == .reloadedSnapshot) + #expect(await joinedRefresh.value == .reloadedSnapshot) + #expect(sut.store.displays?.map(\.displayID) == [808]) + #expect(sut.store.lastRefreshResult == .reloadedSnapshot) + } + + @Test func deniedPermissionInvalidationCancelsInFlightRefreshBeforeClearingSnapshot() async { + let gate = SequencedCatalogServiceLoadGate(scriptedOutcomes: [.success]) + let sut = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider(preflightResult: true, requestResult: true), + loadShareableDisplays: { + switch await gate.nextOutcome() { + case .success: + return [] + case .failure(let error): + throw error + } + }, + activeDisplayIDsProvider: { [606] } + ) + sut.store.hasScreenCapturePermission = true + + let refresh = Task { await sut.submitRefresh(intent: .userForcedRefresh) } + #expect(await waitForLoaderCall(gate, count: 1)) + #expect(sut.store.isLoadingDisplays == true) + + await sut.clearSnapshotForDeniedPermission(loadErrorMessage: "permission denied") + #expect(sut.store.displays == nil) + #expect(sut.store.hasScreenCapturePermission == false) + #expect(sut.store.lastRefreshResult == .clearedSnapshot) + #expect(sut.store.loadErrorMessage == "permission denied") + + await gate.release(call: 1) + #expect(await refresh.value == .failed) + #expect(sut.store.displays == nil) + #expect(sut.store.hasScreenCapturePermission == false) + #expect(sut.store.lastRefreshResult == .clearedSnapshot) + #expect(sut.store.loadErrorMessage == "permission denied") + } + + private func waitForLoaderCall( + _ gate: SequencedCatalogServiceLoadGate, + count: Int + ) async -> Bool { + let deadline = DispatchTime.now().uptimeNanoseconds + 1_000_000_000 + while DispatchTime.now().uptimeNanoseconds < deadline { + if await gate.currentCallCount() >= count { + return true + } + await Task.yield() + } + return await gate.currentCallCount() >= count + } + + private func staysTrue( + timeoutNanoseconds: UInt64, + condition: @escaping @Sendable () async -> Bool + ) async -> Bool { + let deadline = DispatchTime.now().uptimeNanoseconds + timeoutNanoseconds + while DispatchTime.now().uptimeNanoseconds < deadline { + if await condition() == false { + return false + } + await Task.yield() + } + return await condition() + } +} From ae8c13b5d2d02838133817389c52213215011fb0 Mon Sep 17 00:00:00 2001 From: Chen Date: Sat, 28 Mar 2026 09:20:03 +0800 Subject: [PATCH 14/34] =?UTF-8?q?refactor(capture):=20=E6=8B=86=E5=88=86?= =?UTF-8?q?=E9=87=87=E9=9B=86=E6=9C=8D=E5=8A=A1=E5=B9=B6=E6=94=B6=E7=B4=A7?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拆分 ScreenCaptureFunction 单体实现 - 修复 releaseToken 重入覆盖与 preview sink 计数漂移 - 补齐 registry fanout renderer 回归测试 --- .../Services/DisplayCaptureRegistry.swift | 576 ++++++++++ .../Services/DisplayCaptureSession.swift | 473 ++++++++ .../Services/DisplayCaptureTypes.swift | 221 ++++ .../Services/DisplaySampleFanout.swift | 107 ++ .../Services/DisplayStartCoordinator.swift | 235 ++++ .../Services/ScreenCaptureFunction.swift | 1006 ----------------- .../Capture/Views/CaptureDisplayView.swift | 127 ++- ...splayCaptureProfileStateMachineTests.swift | 146 +++ .../DisplayCaptureRegistryTests.swift | 213 +++- .../DisplayPreviewSubscriptionTests.swift | 39 + .../Services/DisplaySampleFanoutTests.swift | 102 ++ .../Views/ZeroCopyPreviewRendererTests.swift | 109 ++ 12 files changed, 2333 insertions(+), 1021 deletions(-) create mode 100644 VoidDisplay/Features/Capture/Services/DisplayCaptureRegistry.swift create mode 100644 VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift create mode 100644 VoidDisplay/Features/Capture/Services/DisplayCaptureTypes.swift create mode 100644 VoidDisplay/Features/Capture/Services/DisplaySampleFanout.swift create mode 100644 VoidDisplay/Features/Capture/Services/DisplayStartCoordinator.swift delete mode 100644 VoidDisplay/Features/Capture/Services/ScreenCaptureFunction.swift create mode 100644 VoidDisplayTests/Features/Capture/Services/DisplayCaptureProfileStateMachineTests.swift create mode 100644 VoidDisplayTests/Features/Capture/Services/DisplaySampleFanoutTests.swift create mode 100644 VoidDisplayTests/Features/Capture/Views/ZeroCopyPreviewRendererTests.swift diff --git a/VoidDisplay/Features/Capture/Services/DisplayCaptureRegistry.swift b/VoidDisplay/Features/Capture/Services/DisplayCaptureRegistry.swift new file mode 100644 index 0000000..e3ece2b --- /dev/null +++ b/VoidDisplay/Features/Capture/Services/DisplayCaptureRegistry.swift @@ -0,0 +1,576 @@ +import CoreGraphics +import Foundation +import Synchronization + +final class DisplayPreviewSubscription: Sendable { + let displayID: CGDirectDisplayID + let resolutionText: String + + private let session: any DisplayCaptureSessioning + private let cancelState = Mutex<(@Sendable () -> Void)?>(nil) + private let attachedSinks = Mutex<[ObjectIdentifier: WeakSink]>([:]) + + private final class WeakSink: @unchecked Sendable { + nonisolated(unsafe) weak var value: (any DisplayPreviewSink)? + + nonisolated init(_ value: any DisplayPreviewSink) { + self.value = value + } + } + + nonisolated init( + displayID: CGDirectDisplayID, + resolutionText: String, + session: any DisplayCaptureSessioning, + cancelClosure: @escaping @Sendable () -> Void + ) { + self.displayID = displayID + self.resolutionText = resolutionText + self.session = session + cancelState.withLock { $0 = cancelClosure } + } + + nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { + let shouldAttach = attachedSinks.withLock { attachedSinks -> Bool in + let key = ObjectIdentifier(sink as AnyObject) + if let existing = attachedSinks[key], existing.value != nil { + return false + } + attachedSinks[key] = WeakSink(sink) + return true + } + guard shouldAttach else { return } + session.attachPreviewSink(sink) + } + + nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { + let shouldDetach = attachedSinks.withLock { attachedSinks -> Bool in + let key = ObjectIdentifier(sink as AnyObject) + guard let removed = attachedSinks.removeValue(forKey: key) else { + return false + } + return removed.value != nil + } + guard shouldDetach else { return } + session.detachPreviewSink(sink) + } + + nonisolated func cancel() { + let session = self.session + let closure = cancelState.withLock { state -> (@Sendable () -> Void)? in + let current = state + state = nil + return current + } + guard let closure else { return } + + let sinksToDetach: [any DisplayPreviewSink] = attachedSinks.withLock { dict in + let snapshot = dict.values.compactMap(\.value) + dict.removeAll(keepingCapacity: true) + return snapshot + } + for sink in sinksToDetach { + session.detachPreviewSink(sink) + } + + closure() + } + + nonisolated func setShowsCursor(_ showsCursor: Bool) async throws { + try await session.setPreviewShowsCursor(showsCursor) + } + + deinit { cancel() } +} + +final class DisplayShareSubscription: Sendable { + let displayID: CGDirectDisplayID + let sessionHub: WebRTCSessionHub + + private let session: any DisplayCaptureSessioning + private let cancelState = Mutex<(@Sendable () -> Void)?>(nil) + private let prepareRetainTask = Mutex?>(nil) + + nonisolated init( + displayID: CGDirectDisplayID, + sessionHub: WebRTCSessionHub, + session: any DisplayCaptureSessioning, + cancelClosure: @escaping @Sendable () -> Void + ) { + self.displayID = displayID + self.sessionHub = sessionHub + self.session = session + cancelState.withLock { $0 = cancelClosure } + } + + nonisolated func prepareForSharing() async throws { + try await session.retainShareCursorOverride() + } + + nonisolated func prepareForSharing( + invalidationContext: DisplayStartInvalidationContext + ) async throws -> DisplayStartOutcome { + let retainTask = Task { + try await session.retainShareCursorOverride() + } + prepareRetainTask.withLock { state in + state = retainTask + } + do { + let outcome = try await invalidationContext.race { + try await retainTask.value + } + switch outcome { + case .started: + prepareRetainTask.withLock { state in + state = nil + } + case .invalidated: + cancel() + } + return outcome + } catch { + cancel() + throw error + } + } + + nonisolated func cancel() { + let session = self.session + let pendingRetainTask = prepareRetainTask.withLock { state -> Task? in + let current = state + state = nil + return current + } + let closure = cancelState.withLock { state -> (@Sendable () -> Void)? in + let current = state + state = nil + return current + } + guard let closure else { return } + if let pendingRetainTask { + Task.detached { + do { + try await pendingRetainTask.value + } catch { + } + try? await session.releaseShareCursorOverride() + closure() + } + return + } + Task { + try? await session.releaseShareCursorOverride() + closure() + } + } + + deinit { cancel() } +} + +actor DisplayCaptureRegistry { + enum SessionResourceState: Equatable { + case initializing + case active + case draining + case stopped + } + + struct PreviewToken: Hashable, Sendable { + fileprivate let rawValue: UUID + let displayID: CGDirectDisplayID + } + + struct ShareToken: Hashable, Sendable { + fileprivate let rawValue: UUID + let displayID: CGDirectDisplayID + } + + private enum TokenKind: Sendable { + case preview + case share + } + + private struct TokenRecord: Sendable { + let kind: TokenKind + let displayID: CGDirectDisplayID + } + + private struct PendingCreationDemand: Sendable { + var previewCount = 0 + var shareCount = 0 + + mutating func record(_ kind: TokenKind, delta: Int) { + switch kind { + case .preview: + previewCount = max(0, previewCount + delta) + case .share: + shareCount = max(0, shareCount + delta) + } + } + + var initialProfile: DisplayCaptureProfile? { + DisplayCaptureProfileStateMachine.desiredProfile( + previewSinkCount: previewCount, + sharingActive: shareCount > 0 + ) + } + + var isEmpty: Bool { + previewCount == 0 && shareCount == 0 + } + } + + struct SessionRecord { + let session: any DisplayCaptureSessioning + let resolutionText: String + var state: SessionResourceState + var previewTokens: Set + var shareTokens: Set + } + + private enum RegistryError: Error { + case sessionUnavailable + } + + private struct ReleaseSideEffects { + let session: any DisplayCaptureSessioning + let setSharingActiveTo: Bool? + let stopSharing: Bool + } + + typealias CaptureSessionFactory = @Sendable ( + SendableDisplay, + DisplayCaptureProfile + ) async throws -> any DisplayCaptureSessioning + + static let shared = DisplayCaptureRegistry() + + private let captureSessionFactory: CaptureSessionFactory + private var sessionsByDisplayID: [CGDirectDisplayID: SessionRecord] = [:] + private var tokenOwnership: [UUID: TokenRecord] = [:] + private var sessionCreationTasks: [CGDirectDisplayID: Task] = [:] + private var pendingCreationDemandByDisplayID: [CGDirectDisplayID: PendingCreationDemand] = [:] + private var sessionDrainTasksByDisplayID: [CGDirectDisplayID: Task] = [:] + private var initializingDisplayIDs: Set = [] + + init( + captureSessionFactory: @escaping CaptureSessionFactory = { display, initialProfile in + try await DisplayCaptureSession(display: display.value, initialProfile: initialProfile) + } + ) { + self.captureSessionFactory = captureSessionFactory + } + + func acquirePreview(display: SendableDisplay) async throws -> DisplayPreviewSubscription { + let token = try await acquirePreviewToken(display: display) + guard let record = sessionsByDisplayID[token.displayID] else { + throw RegistryError.sessionUnavailable + } + return DisplayPreviewSubscription( + displayID: token.displayID, + resolutionText: record.resolutionText, + session: record.session, + cancelClosure: { [weak self] in + guard let self else { return } + Task { await self.release(token) } + } + ) + } + + func acquirePreview( + display: SendableDisplay, + invalidationContext: DisplayStartInvalidationContext + ) async throws -> DisplayStartOutcome { + try await invalidationContext.race { + try await self.acquirePreview(display: display) + } + } + + func acquireShare(display: SendableDisplay) async throws -> DisplayShareSubscription { + let token = try await acquireShareToken(display: display) + guard let record = sessionsByDisplayID[token.displayID] else { + throw RegistryError.sessionUnavailable + } + return DisplayShareSubscription( + displayID: token.displayID, + sessionHub: record.session.sessionHub, + session: record.session, + cancelClosure: { [weak self] in + guard let self else { return } + Task { await self.release(token) } + } + ) + } + + func acquireShare( + display: SendableDisplay, + invalidationContext: DisplayStartInvalidationContext + ) async throws -> DisplayStartOutcome { + try await invalidationContext.race { + try await self.acquireShare(display: display) + } + } + + func acquirePreviewToken(display: SendableDisplay) async throws -> PreviewToken { + let tokenID = try await acquireToken(display: display, kind: .preview) + return PreviewToken(rawValue: tokenID, displayID: display.displayID) + } + + func acquireShareToken(display: SendableDisplay) async throws -> ShareToken { + let tokenID = try await acquireToken(display: display, kind: .share) + return ShareToken(rawValue: tokenID, displayID: display.displayID) + } + + func release(_ token: PreviewToken) async { + await releaseToken(token.rawValue, expectedKind: .preview) + } + + func release(_ token: ShareToken) async { + await releaseToken(token.rawValue, expectedKind: .share) + } + + func sessionState(for displayID: CGDirectDisplayID) -> SessionResourceState { + if initializingDisplayIDs.contains(displayID) { + return .initializing + } + return sessionsByDisplayID[displayID]?.state ?? .stopped + } + + private func acquireToken( + display: SendableDisplay, + kind: TokenKind + ) async throws -> UUID { + recordPendingCreationDemand(for: display.displayID, kind: kind, delta: 1) + do { + try await ensureSessionExists(for: display, fallbackKind: kind) + recordPendingCreationDemand(for: display.displayID, kind: kind, delta: -1) + return try await registerToken(displayID: display.displayID, kind: kind) + } catch { + recordPendingCreationDemand(for: display.displayID, kind: kind, delta: -1) + throw error + } + } + +#if DEBUG + func installSessionForTesting( + displayID: CGDirectDisplayID, + resolutionText: String, + session: any DisplayCaptureSessioning + ) { + sessionDrainTasksByDisplayID[displayID]?.cancel() + sessionDrainTasksByDisplayID[displayID] = nil + initializingDisplayIDs.remove(displayID) + sessionsByDisplayID[displayID] = SessionRecord( + session: session, + resolutionText: resolutionText, + state: .active, + previewTokens: [], + shareTokens: [] + ) + } + + func acquirePreviewTokenForTesting(displayID: CGDirectDisplayID) throws -> PreviewToken { + let tokenID = try registerTokenForTesting(displayID: displayID, kind: .preview) + return PreviewToken(rawValue: tokenID, displayID: displayID) + } + + func acquireShareTokenForTesting(displayID: CGDirectDisplayID) throws -> ShareToken { + let tokenID = try registerTokenForTesting(displayID: displayID, kind: .share) + return ShareToken(rawValue: tokenID, displayID: displayID) + } +#endif + + private func registerToken(displayID: CGDirectDisplayID, kind: TokenKind) async throws -> UUID { + let tokenID = UUID() + guard var record = sessionsByDisplayID[displayID] else { + throw RegistryError.sessionUnavailable + } + guard record.state != .draining else { + throw RegistryError.sessionUnavailable + } + record.state = .active + switch kind { + case .preview: + record.previewTokens.insert(tokenID) + case .share: + record.shareTokens.insert(tokenID) + } + sessionsByDisplayID[displayID] = record + tokenOwnership[tokenID] = TokenRecord(kind: kind, displayID: displayID) + try? await record.session.setSharingActive(!record.shareTokens.isEmpty) + return tokenID + } + + private func registerTokenForTesting(displayID: CGDirectDisplayID, kind: TokenKind) throws -> UUID { + let tokenID = UUID() + guard var record = sessionsByDisplayID[displayID] else { + throw RegistryError.sessionUnavailable + } + guard record.state != .draining else { + throw RegistryError.sessionUnavailable + } + record.state = .active + switch kind { + case .preview: + record.previewTokens.insert(tokenID) + case .share: + record.shareTokens.insert(tokenID) + } + sessionsByDisplayID[displayID] = record + tokenOwnership[tokenID] = TokenRecord(kind: kind, displayID: displayID) + return tokenID + } + + private func ensureSessionExists( + for display: SendableDisplay, + fallbackKind: TokenKind + ) async throws { + let displayID = display.displayID + if let existing = sessionsByDisplayID[displayID] { + if existing.state != .draining { + return + } + await waitForDrainCompletion(for: displayID) + if let afterDrain = sessionsByDisplayID[displayID], afterDrain.state != .draining { + return + } + } + + if let existingTask = sessionCreationTasks[displayID] { + let record = try await existingTask.value + storeInitializedSessionIfAbsent(record, for: displayID) + return + } + + let task = Task { [captureSessionFactory] in + let initialProfile = await self.resolveInitialProfileForPendingCreation( + displayID: displayID, + fallbackKind: fallbackKind + ) + let session = try await captureSessionFactory(display, initialProfile) + return SessionRecord( + session: session, + resolutionText: "\(display.width) × \(display.height)", + state: .active, + previewTokens: [], + shareTokens: [] + ) + } + initializingDisplayIDs.insert(displayID) + sessionCreationTasks[displayID] = task + defer { sessionCreationTasks[displayID] = nil } + + do { + let record = try await task.value + storeInitializedSessionIfAbsent(record, for: displayID) + } catch { + initializingDisplayIDs.remove(displayID) + throw error + } + } + + private func storeInitializedSessionIfAbsent( + _ record: SessionRecord, + for displayID: CGDirectDisplayID + ) { + initializingDisplayIDs.remove(displayID) + guard sessionsByDisplayID[displayID] == nil else { return } + sessionsByDisplayID[displayID] = record + } + + private func releaseToken(_ tokenID: UUID, expectedKind: TokenKind) async { + let sideEffects: ReleaseSideEffects + guard let ownership = tokenOwnership.removeValue(forKey: tokenID), + ownership.kind == expectedKind else { + return + } + guard var record = sessionsByDisplayID[ownership.displayID] else { return } + + switch ownership.kind { + case .preview: + record.previewTokens.remove(tokenID) + case .share: + record.shareTokens.remove(tokenID) + } + + if record.previewTokens.isEmpty, record.shareTokens.isEmpty { + record.state = .draining + sessionsByDisplayID[ownership.displayID] = record + let session = record.session + sessionDrainTasksByDisplayID[ownership.displayID]?.cancel() + sessionDrainTasksByDisplayID[ownership.displayID] = Task { [session] in + await session.stop() + self.finishDrainingSession(displayID: ownership.displayID) + } + sideEffects = ReleaseSideEffects( + session: session, + setSharingActiveTo: nil, + stopSharing: ownership.kind == .share + ) + } else { + record.state = .active + sessionsByDisplayID[ownership.displayID] = record + sideEffects = ReleaseSideEffects( + session: record.session, + setSharingActiveTo: !record.shareTokens.isEmpty, + stopSharing: ownership.kind == .share && record.shareTokens.isEmpty + ) + } + + if sideEffects.stopSharing { + sideEffects.session.stopSharing() + } + if let isSharingActive = sideEffects.setSharingActiveTo { + try? await sideEffects.session.setSharingActive(isSharingActive) + } + } + + private func waitForDrainCompletion(for displayID: CGDirectDisplayID) async { + guard let drainTask = sessionDrainTasksByDisplayID[displayID] else { return } + await drainTask.value + } + + private func finishDrainingSession(displayID: CGDirectDisplayID) { + sessionDrainTasksByDisplayID[displayID] = nil + guard let record = sessionsByDisplayID[displayID] else { return } + guard record.state == .draining else { return } + guard record.previewTokens.isEmpty, record.shareTokens.isEmpty else { + var resumed = record + resumed.state = .active + sessionsByDisplayID[displayID] = resumed + return + } + sessionsByDisplayID.removeValue(forKey: displayID) + } + + private func recordPendingCreationDemand( + for displayID: CGDirectDisplayID, + kind: TokenKind, + delta: Int + ) { + var demand = pendingCreationDemandByDisplayID[displayID] ?? PendingCreationDemand() + demand.record(kind, delta: delta) + if demand.isEmpty { + pendingCreationDemandByDisplayID.removeValue(forKey: displayID) + } else { + pendingCreationDemandByDisplayID[displayID] = demand + } + } + + private func resolveInitialProfileForPendingCreation( + displayID: CGDirectDisplayID, + fallbackKind: TokenKind + ) async -> DisplayCaptureProfile { + await Task.yield() + if let profile = pendingCreationDemandByDisplayID[displayID]?.initialProfile { + return profile + } + switch fallbackKind { + case .preview: + return .previewOnly + case .share: + return .shareOnly + } + } +} diff --git a/VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift b/VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift new file mode 100644 index 0000000..fb0e5ef --- /dev/null +++ b/VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift @@ -0,0 +1,473 @@ +import AppKit +import CoreGraphics +import CoreMedia +import Foundation +import OSLog +import ScreenCaptureKit +import Synchronization + +private final class DisplayStreamOutput: NSObject, SCStreamOutput, SCStreamDelegate { + nonisolated(unsafe) weak var session: DisplayCaptureSession? + + nonisolated override init() { + super.init() + } + + nonisolated func stream( + _ stream: SCStream, + didOutputSampleBuffer sampleBuffer: CMSampleBuffer, + of type: SCStreamOutputType + ) { + session?.handle(sampleBuffer: sampleBuffer, type: type) + } + + nonisolated func stream(_ stream: SCStream, didStopWithError error: Error) { + Task { @MainActor in + AppErrorMapper.logFailure("Screen capture stream stopped", error: error, logger: AppLog.capture) + } + } +} + +private struct DisplayCaptureMetrics: Sendable { + var currentProfile: DisplayCaptureProfile? + var receivedFrameCount: UInt64 = 0 + var profileReconfigurationCount: UInt64 = 0 + var cursorOverrideReconfigurationCount: UInt64 = 0 + + nonisolated func snapshot() -> DisplayCaptureMetricsSnapshot { + .init( + currentProfile: currentProfile, + receivedFrameCount: receivedFrameCount, + profileReconfigurationCount: profileReconfigurationCount, + cursorOverrideReconfigurationCount: cursorOverrideReconfigurationCount + ) + } +} + +final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning { + private struct StreamConfigurationState: Sendable { + let width: Int + let height: Int + let maximumPreviewFramesPerSecond: Int + let queueDepth: Int + let capturesAudio: Bool + let pixelFormat: OSType + var profile: DisplayCaptureProfile + var previewShowsCursor: Bool + var shareCursorOverrideCount: Int + + nonisolated var minimumFrameInterval: CMTime { + let framesPerSecond: Int + switch profile { + case .previewOnly: + framesPerSecond = maximumPreviewFramesPerSecond + case .shareOnly, .mixed: + framesPerSecond = 30 + } + return CMTime(value: 1, timescale: CMTimeScale(max(1, Int32(framesPerSecond)))) + } + } + + private struct DemandState { + var profileCoordinator: DisplayCaptureProfileCoordinatorState + var taskLifetime = DisplayCaptureTaskLifetimeState() + var pendingTaskNonce: UInt64 = 0 + var pendingProfileTask: Task? + var activeApplyTask: Task? + } + + nonisolated private static let minimumProfileDwellNanoseconds: UInt64 = 5_000_000_000 + + nonisolated let displayID: CGDirectDisplayID + nonisolated let sessionHub: WebRTCSessionHub + + nonisolated(unsafe) private let stream: SCStream + private let output = DisplayStreamOutput() + nonisolated private let captureQueue: DispatchQueue + nonisolated private let fanout = DisplaySampleFanout() + nonisolated private let metrics = Mutex(DisplayCaptureMetrics()) + nonisolated private let configurationState: Mutex + nonisolated private let demandState: Mutex + + nonisolated init( + display: SCDisplay, + initialProfile: DisplayCaptureProfile = .previewOnly + ) async throws { + self.displayID = display.displayID + self.captureQueue = DispatchQueue( + label: "com.developerchen.voiddisplay.capture.\(display.displayID)", + qos: .userInitiated + ) + + let state = try await Self.makeStreamConfigurationState( + display: display, + showsCursor: false, + initialProfile: initialProfile + ) + let config = Self.makeStreamConfiguration(from: state) + let filter = try await Self.makeContentFilter(display: display) + self.stream = SCStream(filter: filter, configuration: config, delegate: output) + self.sessionHub = WebRTCSessionHub() + self.configurationState = Mutex(state) + self.demandState = Mutex( + DemandState( + profileCoordinator: DisplayCaptureProfileCoordinatorState( + committedProfile: initialProfile + ) + ) + ) + self.metrics.withLock { $0.currentProfile = state.profile } + + output.session = self + + try stream.addStreamOutput(output, type: .screen, sampleHandlerQueue: captureQueue) + try await stream.startCapture() + } + + nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { + fanout.attachPreviewSink(sink) + scheduleDemandUpdate { state in + state.profileCoordinator.previewSinkCount += 1 + } + } + + nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { + fanout.detachPreviewSink(sink) + scheduleDemandUpdate { state in + state.profileCoordinator.previewSinkCount = max( + 0, + state.profileCoordinator.previewSinkCount - 1 + ) + } + } + + nonisolated func stopSharing() { + sessionHub.stopSharing() + } + + nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { + let updatedState = configurationState.withLock { state -> StreamConfigurationState in + guard state.previewShowsCursor != showsCursor else { return state } + var copy = state + copy.previewShowsCursor = showsCursor + return copy + } + guard updatedState.previewShowsCursor == showsCursor else { return } + metrics.withLock { $0.cursorOverrideReconfigurationCount &+= 1 } + try await applyStreamConfiguration(updatedState) + } + + nonisolated func retainShareCursorOverride() async throws { + let updatedState = configurationState.withLock { state -> StreamConfigurationState in + var copy = state + copy.shareCursorOverrideCount += 1 + return copy + } + metrics.withLock { $0.cursorOverrideReconfigurationCount &+= 1 } + try await applyStreamConfiguration(updatedState) + } + + nonisolated func releaseShareCursorOverride() async throws { + let updatedState = configurationState.withLock { state -> StreamConfigurationState in + var copy = state + copy.shareCursorOverrideCount = max(0, copy.shareCursorOverrideCount - 1) + return copy + } + metrics.withLock { $0.cursorOverrideReconfigurationCount &+= 1 } + try await applyStreamConfiguration(updatedState) + } + + nonisolated func setSharingActive(_ isActive: Bool) async throws { + scheduleDemandUpdate { state in + state.profileCoordinator.sharingActive = isActive + } + } + + nonisolated func captureMetricsSnapshot() -> DisplayCaptureMetricsSnapshot { + metrics.withLock { $0.snapshot() } + } + + nonisolated private func applyStreamConfiguration(_ updatedState: StreamConfigurationState) async throws { + try await stream.updateConfiguration(Self.makeStreamConfiguration(from: updatedState)) + configurationState.withLock { state in + state.profile = updatedState.profile + state.previewShowsCursor = updatedState.previewShowsCursor + state.shareCursorOverrideCount = updatedState.shareCursorOverrideCount + } + } + + nonisolated func stop() async { + demandState.withLock { state in + _ = state.taskLifetime.invalidateAllTasks() + state.pendingProfileTask?.cancel() + state.pendingProfileTask = nil + state.activeApplyTask?.cancel() + state.activeApplyTask = nil + } + stopSharing() + try? await stream.stopCapture() + } + + nonisolated func handle(sampleBuffer: CMSampleBuffer, type: SCStreamOutputType) { + guard type == .screen, let pixelBuffer = sampleBuffer.imageBuffer else { return } + metrics.withLock { $0.receivedFrameCount &+= 1 } + + fanout.publishPreviewFrame(sampleBuffer) + + guard sessionHub.hasDemand else { return } + let ptsUs = Self.microseconds(from: CMSampleBufferGetPresentationTimeStamp(sampleBuffer)) + sessionHub.submitFrame(pixelBuffer: pixelBuffer, ptsUs: ptsUs) + } + + nonisolated private func scheduleDemandUpdate(_ mutation: (inout DemandState) -> Void) { + let decision = demandState.withLock { state -> (DisplayCaptureProfileDecision, UInt64) in + mutation(&state) + state.pendingProfileTask?.cancel() + state.pendingProfileTask = nil + state.pendingTaskNonce &+= 1 + let now = Self.currentTimeNanoseconds() + let decision = state.profileCoordinator.mutateDemand( + nowNs: now, + minimumDwellNanoseconds: Self.minimumProfileDwellNanoseconds + ) { _ in } + return (decision, state.pendingTaskNonce) + } + handleProfileDecision(decision.0, schedulingNonce: decision.1) + } + + nonisolated private func handleProfileDecision( + _ decision: DisplayCaptureProfileDecision, + schedulingNonce: UInt64 + ) { + switch decision { + case .noChange: + return + case .applyNow(let profile): + let executionGeneration = demandState.withLock { $0.taskLifetime.currentGeneration } + let task = Task { [weak self] in + guard let self else { return } + try? await self.applyDemandDrivenProfile( + profile: profile, + executionGeneration: executionGeneration + ) + } + demandState.withLock { state in + if state.taskLifetime.allowsExecution(for: executionGeneration) { + state.activeApplyTask = task + } else { + task.cancel() + } + } + case .applyAfter(_, let delayNanoseconds): + let task = Task { [weak self] in + try? await Task.sleep(nanoseconds: delayNanoseconds) + self?.resumeDemandDrivenProfileEvaluation(schedulingNonce: schedulingNonce) + } + demandState.withLock { state in + if state.pendingTaskNonce == schedulingNonce { + state.pendingProfileTask = task + } else { + task.cancel() + } + } + } + } + + nonisolated private func resumeDemandDrivenProfileEvaluation(schedulingNonce: UInt64) { + let decision = demandState.withLock { state -> (DisplayCaptureProfileDecision, UInt64)? in + guard state.pendingTaskNonce == schedulingNonce else { + return nil + } + state.pendingProfileTask = nil + state.pendingTaskNonce &+= 1 + let decision = state.profileCoordinator.resumeScheduledTransition( + nowNs: Self.currentTimeNanoseconds(), + minimumDwellNanoseconds: Self.minimumProfileDwellNanoseconds + ) + return (decision, state.pendingTaskNonce) + } + guard let decision else { return } + handleProfileDecision(decision.0, schedulingNonce: decision.1) + } + + nonisolated private func applyDemandDrivenProfile( + profile: DisplayCaptureProfile, + executionGeneration: UInt64 + ) async throws { + guard isExecutionAllowed(for: executionGeneration) else { return } + + let updatedState = configurationState.withLock { state -> StreamConfigurationState? in + guard state.profile != profile else { return nil } + var copy = state + copy.profile = profile + return copy + } + guard let updatedState else { + finishDemandDrivenProfileFailure(executionGeneration: executionGeneration) + return + } + + do { + try Task.checkCancellation() + guard isExecutionAllowed(for: executionGeneration) else { return } + + try await stream.updateConfiguration(Self.makeStreamConfiguration(from: updatedState)) + guard isExecutionAllowed(for: executionGeneration) else { return } + + configurationState.withLock { state in + state.profile = updatedState.profile + state.previewShowsCursor = updatedState.previewShowsCursor + state.shareCursorOverrideCount = updatedState.shareCursorOverrideCount + } + } catch is CancellationError { + finishDemandDrivenProfileFailure(executionGeneration: executionGeneration) + return + } catch { + finishDemandDrivenProfileFailure(executionGeneration: executionGeneration) + AppErrorMapper.logFailure("Update capture profile", error: error, logger: AppLog.capture) + return + } + + guard isExecutionAllowed(for: executionGeneration) else { return } + + metrics.withLock { metrics in + metrics.currentProfile = profile + metrics.profileReconfigurationCount &+= 1 + } + + let decision = demandState.withLock { state -> (DisplayCaptureProfileDecision, UInt64)? in + guard state.taskLifetime.allowsExecution(for: executionGeneration) else { + return nil + } + state.pendingProfileTask?.cancel() + state.pendingProfileTask = nil + state.activeApplyTask = nil + state.pendingTaskNonce &+= 1 + let decision = state.profileCoordinator.finishAppliedTransition( + at: Self.currentTimeNanoseconds(), + minimumDwellNanoseconds: Self.minimumProfileDwellNanoseconds + ) + return (decision, state.pendingTaskNonce) + } + guard let decision else { return } + handleProfileDecision(decision.0, schedulingNonce: decision.1) + } + + nonisolated private func isExecutionAllowed(for generation: UInt64) -> Bool { + demandState.withLock { state in + state.taskLifetime.allowsExecution(for: generation) + } + } + + nonisolated private func finishDemandDrivenProfileFailure(executionGeneration: UInt64) { + demandState.withLock { state in + guard state.taskLifetime.allowsExecution(for: executionGeneration) else { return } + state.activeApplyTask = nil + state.profileCoordinator.failAppliedTransition() + } + } +} + +extension DisplayCaptureSession { + nonisolated static func microseconds(from time: CMTime) -> UInt64 { + guard time.isValid, !time.isIndefinite, time.seconds.isFinite else { return 0 } + let scaled = CMTimeConvertScale(time, timescale: 1_000_000, method: .default) + return scaled.value > 0 ? UInt64(scaled.value) : 0 + } + + nonisolated private static func currentTimeNanoseconds() -> UInt64 { + DispatchTime.now().uptimeNanoseconds + } + + nonisolated static func clampedPreviewFramesPerSecond(for refreshRate: Double) -> Int { + let normalizedRefreshRate = refreshRate > 0 ? refreshRate : 60.0 + return max(1, Int(min(normalizedRefreshRate, 60.0).rounded())) + } + + nonisolated private static func makeStreamConfigurationState( + display: SCDisplay, + showsCursor: Bool, + initialProfile: DisplayCaptureProfile + ) async throws -> StreamConfigurationState { + let displayMode = CGDisplayCopyDisplayMode(display.displayID) + + let captureSize = preferredCaptureSize(display: display, displayMode: displayMode) + let previewFramesPerSecond = clampedPreviewFramesPerSecond(for: displayMode?.refreshRate ?? 60.0) + + let state = StreamConfigurationState( + width: captureSize.width, + height: captureSize.height, + maximumPreviewFramesPerSecond: previewFramesPerSecond, + queueDepth: 2, + capturesAudio: false, + pixelFormat: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, + profile: initialProfile, + previewShowsCursor: showsCursor, + shareCursorOverrideCount: 0 + ) + AppLog.capture.notice( + "Capture config display=\(display.displayID, privacy: .public) size=\(captureSize.width)x\(captureSize.height, privacy: .public)" + ) + return state + } + + nonisolated private static func makeStreamConfiguration( + from state: StreamConfigurationState + ) -> SCStreamConfiguration { + let config = SCStreamConfiguration() + config.width = state.width + config.height = state.height + config.minimumFrameInterval = state.minimumFrameInterval + config.queueDepth = state.queueDepth + config.showsCursor = state.shareCursorOverrideCount > 0 || state.previewShowsCursor + config.capturesAudio = state.capturesAudio + config.pixelFormat = state.pixelFormat + return config + } + + nonisolated private static func preferredCaptureSize( + display: SCDisplay, + displayMode: CGDisplayMode? + ) -> (width: Int, height: Int) { + let modePixelWidth = displayMode.map { Int($0.pixelWidth) } ?? display.width + let modePixelHeight = displayMode.map { Int($0.pixelHeight) } ?? display.height + let modeLogicalWidth = displayMode.map { $0.width } ?? modePixelWidth + let modeLogicalHeight = displayMode.map { $0.height } ?? modePixelHeight + let backingScale = screenBackingScaleFactor(for: display.displayID) + + let scaledLogicalWidth = max(1, Int((CGFloat(modeLogicalWidth) * backingScale).rounded())) + let scaledLogicalHeight = max(1, Int((CGFloat(modeLogicalHeight) * backingScale).rounded())) + + return ( + width: max(modePixelWidth, scaledLogicalWidth), + height: max(modePixelHeight, scaledLogicalHeight) + ) + } + + nonisolated private static func screenBackingScaleFactor(for displayID: CGDirectDisplayID) -> CGFloat { + let key = NSDeviceDescriptionKey("NSScreenNumber") + guard let screen = NSScreen.screens.first(where: { + guard let number = $0.deviceDescription[key] as? NSNumber else { return false } + return number.uint32Value == displayID + }) else { + return 1.0 + } + return max(1.0, screen.backingScaleFactor) + } + + nonisolated private static func makeContentFilter( + display: SCDisplay + ) async throws -> SCContentFilter { + let content = try await SCShareableContent.excludingDesktopWindows( + false, onScreenWindowsOnly: false + ) + let excludedApps = content.applications.filter { app in + Bundle.main.bundleIdentifier == app.bundleIdentifier + } + return SCContentFilter( + display: display, + excludingApplications: excludedApps, + exceptingWindows: [] + ) + } +} diff --git a/VoidDisplay/Features/Capture/Services/DisplayCaptureTypes.swift b/VoidDisplay/Features/Capture/Services/DisplayCaptureTypes.swift new file mode 100644 index 0000000..431adb0 --- /dev/null +++ b/VoidDisplay/Features/Capture/Services/DisplayCaptureTypes.swift @@ -0,0 +1,221 @@ +import CoreGraphics +import CoreMedia +import Foundation +import ScreenCaptureKit + +enum DisplayStartOutcome: Sendable { + case started(Value) + case invalidated +} + +enum DisplayStartKind: Hashable, Sendable { + case monitoring + case sharing +} + +nonisolated enum DisplayCaptureProfile: String, Sendable, Equatable { + case previewOnly + case shareOnly + case mixed +} + +nonisolated enum DisplayCaptureProfileDecision: Sendable, Equatable { + case noChange + case applyNow(DisplayCaptureProfile) + case applyAfter(DisplayCaptureProfile, delayNanoseconds: UInt64) +} + +nonisolated enum DisplayCaptureProfileStateMachine { + nonisolated static func desiredProfile( + previewSinkCount: Int, + sharingActive: Bool + ) -> DisplayCaptureProfile? { + switch (previewSinkCount > 0, sharingActive) { + case (true, false): + .previewOnly + case (false, true): + .shareOnly + case (true, true): + .mixed + case (false, false): + nil + } + } + + nonisolated static func decideTransition( + previewSinkCount: Int, + sharingActive: Bool, + currentProfile: DisplayCaptureProfile, + lastProfileSwitchTimeNs: UInt64?, + nowNs: UInt64, + minimumDwellNanoseconds: UInt64 + ) -> DisplayCaptureProfileDecision { + guard let desiredProfile = desiredProfile( + previewSinkCount: previewSinkCount, + sharingActive: sharingActive + ) else { + return .noChange + } + guard desiredProfile != currentProfile else { + return .noChange + } + guard let lastProfileSwitchTimeNs else { + return .applyNow(desiredProfile) + } + + let elapsed = nowNs &- lastProfileSwitchTimeNs + if elapsed >= minimumDwellNanoseconds { + return .applyNow(desiredProfile) + } + return .applyAfter( + desiredProfile, + delayNanoseconds: minimumDwellNanoseconds - elapsed + ) + } +} + +nonisolated struct DisplayCaptureProfileCoordinatorState: Sendable { + var previewSinkCount: Int = 0 + var sharingActive = false + var committedProfile: DisplayCaptureProfile + var inFlightProfile: DisplayCaptureProfile? + var lastProfileSwitchTimeNs: UInt64? + + nonisolated init( + committedProfile: DisplayCaptureProfile, + lastProfileSwitchTimeNs: UInt64? = nil + ) { + self.committedProfile = committedProfile + self.lastProfileSwitchTimeNs = lastProfileSwitchTimeNs + } + + nonisolated mutating func mutateDemand( + nowNs: UInt64, + minimumDwellNanoseconds: UInt64, + mutation: (inout DisplayCaptureProfileCoordinatorState) -> Void + ) -> DisplayCaptureProfileDecision { + mutation(&self) + return evaluateTransition( + nowNs: nowNs, + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + } + + nonisolated mutating func resumeScheduledTransition( + nowNs: UInt64, + minimumDwellNanoseconds: UInt64 + ) -> DisplayCaptureProfileDecision { + evaluateTransition( + nowNs: nowNs, + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + } + + nonisolated mutating func finishAppliedTransition( + at nowNs: UInt64, + minimumDwellNanoseconds: UInt64 + ) -> DisplayCaptureProfileDecision { + guard let inFlightProfile else { return .noChange } + committedProfile = inFlightProfile + self.inFlightProfile = nil + lastProfileSwitchTimeNs = nowNs + return evaluateTransition( + nowNs: nowNs, + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + } + + nonisolated mutating func failAppliedTransition() { + inFlightProfile = nil + } + + nonisolated private mutating func evaluateTransition( + nowNs: UInt64, + minimumDwellNanoseconds: UInt64 + ) -> DisplayCaptureProfileDecision { + guard inFlightProfile == nil else { + return .noChange + } + + let decision = DisplayCaptureProfileStateMachine.decideTransition( + previewSinkCount: previewSinkCount, + sharingActive: sharingActive, + currentProfile: committedProfile, + lastProfileSwitchTimeNs: lastProfileSwitchTimeNs, + nowNs: nowNs, + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + if case .applyNow(let profile) = decision { + inFlightProfile = profile + } + return decision + } +} + +nonisolated struct DisplayCaptureTaskLifetimeState: Sendable { + private(set) var currentGeneration: UInt64 = 0 + + nonisolated mutating func invalidateAllTasks() -> UInt64 { + currentGeneration &+= 1 + return currentGeneration + } + + nonisolated func allowsExecution(for generation: UInt64) -> Bool { + currentGeneration == generation + } +} + +protocol DisplayPreviewSink: AnyObject, Sendable { + nonisolated func submitFrame(_ sampleBuffer: CMSampleBuffer) +} + +nonisolated struct SendableDisplay: @unchecked Sendable { + nonisolated(unsafe) let value: SCDisplay + nonisolated let displayID: CGDirectDisplayID + nonisolated let width: Int + nonisolated let height: Int + + nonisolated init(_ value: SCDisplay) { + self.value = value + self.displayID = value.displayID + self.width = value.width + self.height = value.height + } +} + +struct DisplayCaptureMetricsSnapshot: Sendable { + var currentProfile: DisplayCaptureProfile? + var receivedFrameCount: UInt64 + var profileReconfigurationCount: UInt64 + var cursorOverrideReconfigurationCount: UInt64 +} + +protocol DisplayCaptureSessioning: AnyObject, Sendable { + nonisolated var sessionHub: WebRTCSessionHub { get } + nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) + nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) + nonisolated func stopSharing() + nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws + nonisolated func retainShareCursorOverride() async throws + nonisolated func releaseShareCursorOverride() async throws + nonisolated func setSharingActive(_ isActive: Bool) async throws + nonisolated func captureMetricsSnapshot() -> DisplayCaptureMetricsSnapshot + nonisolated func stop() async +} + +extension DisplayCaptureSessioning { + nonisolated func setSharingActive(_ isActive: Bool) async throws { + _ = isActive + } + + nonisolated func captureMetricsSnapshot() -> DisplayCaptureMetricsSnapshot { + .init( + currentProfile: nil, + receivedFrameCount: 0, + profileReconfigurationCount: 0, + cursorOverrideReconfigurationCount: 0 + ) + } +} + +struct StartCoordinatorTypeMismatchError: Error {} diff --git a/VoidDisplay/Features/Capture/Services/DisplaySampleFanout.swift b/VoidDisplay/Features/Capture/Services/DisplaySampleFanout.swift new file mode 100644 index 0000000..1d59f8b --- /dev/null +++ b/VoidDisplay/Features/Capture/Services/DisplaySampleFanout.swift @@ -0,0 +1,107 @@ +import CoreMedia +import Foundation +import Synchronization + +private struct SendableSampleBuffer: @unchecked Sendable { + nonisolated(unsafe) let value: CMSampleBuffer +} + +private final class PreviewSinkMailbox: @unchecked Sendable { + private struct State { + var latestFrame: SendableSampleBuffer? + var isDraining = false + var isActive = true + } + + private let sink: any DisplayPreviewSink + private let state = Mutex(State()) +#if DEBUG + nonisolated(unsafe) var willStartDrainForTesting: (@Sendable () -> Void)? +#endif + + nonisolated init(sink: any DisplayPreviewSink) { + self.sink = sink + } + + nonisolated func submit(_ sampleBuffer: CMSampleBuffer) { + let sample = SendableSampleBuffer(value: sampleBuffer) + let shouldStartDraining = state.withLock { state -> Bool in + guard state.isActive else { return false } + state.latestFrame = sample + guard !state.isDraining else { return false } + state.isDraining = true + return true + } + guard shouldStartDraining else { return } + +#if DEBUG + willStartDrainForTesting?() +#endif + Task.detached { [weak self] in + self?.drain() + } + } + + nonisolated func deactivate() { + state.withLock { state in + state.isActive = false + state.latestFrame = nil + if !state.isDraining { + state.isDraining = false + } + } + } + + nonisolated private func drain() { + while true { + let shouldContinue = state.withLock { state -> Bool in + guard state.isActive else { + state.latestFrame = nil + state.isDraining = false + return false + } + guard let latestFrame = state.latestFrame else { + state.isDraining = false + return false + } + state.latestFrame = nil + sink.submitFrame(latestFrame.value) + return true + } + guard shouldContinue else { return } + } + } +} + +final class DisplaySampleFanout: Sendable { + private let mailboxes = Mutex<[ObjectIdentifier: PreviewSinkMailbox]>([:]) +#if DEBUG + nonisolated(unsafe) var willStartDrainForTesting: (@Sendable () -> Void)? +#endif + + nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { + let key = ObjectIdentifier(sink as AnyObject) + mailboxes.withLock { mailboxes in + guard mailboxes[key] == nil else { return } + let mailbox = PreviewSinkMailbox(sink: sink) +#if DEBUG + mailbox.willStartDrainForTesting = willStartDrainForTesting +#endif + mailboxes[key] = mailbox + } + } + + nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { + let mailbox = mailboxes.withLock { + $0.removeValue(forKey: ObjectIdentifier(sink as AnyObject)) + } + mailbox?.deactivate() + } + + nonisolated func publishPreviewFrame(_ sampleBuffer: CMSampleBuffer) { + let snapshot = mailboxes.withLock { Array($0.values) } + for mailbox in snapshot { + mailbox.submit(sampleBuffer) + } + } +} diff --git a/VoidDisplay/Features/Capture/Services/DisplayStartCoordinator.swift b/VoidDisplay/Features/Capture/Services/DisplayStartCoordinator.swift new file mode 100644 index 0000000..b7997d4 --- /dev/null +++ b/VoidDisplay/Features/Capture/Services/DisplayStartCoordinator.swift @@ -0,0 +1,235 @@ +import CoreGraphics +import Foundation +import Synchronization + +final class DisplayStartInvalidationContext: Sendable { + private struct State { + var isInvalidated = false + var waiters: [UUID: CheckedContinuation] = [:] + } + + private let state = Mutex(State()) + + nonisolated func invalidate() { + let pendingWaiters = state.withLock { state -> [CheckedContinuation] in + guard !state.isInvalidated else { return [] } + state.isInvalidated = true + let waiters = Array(state.waiters.values) + state.waiters.removeAll() + return waiters + } + for waiter in pendingWaiters { + waiter.resume() + } + } + + nonisolated func isInvalidated() -> Bool { + state.withLock { $0.isInvalidated } + } + + nonisolated func waitForInvalidation() async { + let waiterID = UUID() + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let shouldResumeImmediately = state.withLock { state -> Bool in + if state.isInvalidated || Task.isCancelled { + return true + } + state.waiters[waiterID] = continuation + return false + } + if shouldResumeImmediately { + continuation.resume() + return + } + + if Task.isCancelled { + let cancelledWaiter = state.withLock { state -> CheckedContinuation? in + state.waiters.removeValue(forKey: waiterID) + } + cancelledWaiter?.resume() + } + } + } onCancel: { + let cancelledWaiter = self.state.withLock { state -> CheckedContinuation? in + state.waiters.removeValue(forKey: waiterID) + } + cancelledWaiter?.resume() + } + } + + nonisolated func race( + _ operation: @escaping @Sendable () async throws -> T + ) async throws -> DisplayStartOutcome { + if isInvalidated() { + return .invalidated + } + + return try await withThrowingTaskGroup(of: DisplayStartOutcome.self) { group in + group.addTask { + .started(try await operation()) + } + group.addTask { + await self.waitForInvalidation() + try Task.checkCancellation() + return .invalidated + } + + do { + guard let firstResult = try await group.next() else { + throw CancellationError() + } + group.cancelAll() + while (try? await group.next()) != nil {} + return firstResult + } catch { + group.cancelAll() + while (try? await group.next()) != nil {} + throw error + } + } + } + + nonisolated var waiterCountForTesting: Int { + state.withLock { $0.waiters.count } + } +} + +@MainActor +final class DisplayStreamStartCoordinator { + private struct OperationKey: Hashable { + let kind: DisplayStartKind + let displayID: CGDirectDisplayID + } + + private enum OperationCompletion { + case finished(Any) + case invalidated + case failed(any Error) + } + + private final class OperationRecord { + let token = UUID() + let invalidationContext = DisplayStartInvalidationContext() + var waiters: [UUID: (OperationCompletion) -> Void] = [:] + var task: Task? + } + + private var operations: [OperationKey: OperationRecord] = [:] + + func isStarting( + kind: DisplayStartKind, + displayID: CGDirectDisplayID + ) -> Bool { + operations[OperationKey(kind: kind, displayID: displayID)] != nil + } + + func start( + kind: DisplayStartKind, + displayID: CGDirectDisplayID, + operation: @escaping @MainActor (DisplayStartInvalidationContext) async throws -> DisplayStartOutcome + ) async throws -> DisplayStartOutcome { + let key = OperationKey(kind: kind, displayID: displayID) + if let existing = operations[key] { + return try await awaitResult(from: existing) + } + + let record = OperationRecord() + operations[key] = record + let operationToken = record.token + record.task = Task { @MainActor [weak self] in + let completion: OperationCompletion + do { + let outcome = try await operation(record.invalidationContext) + switch outcome { + case .started(let value): + completion = .finished(value) + case .invalidated: + completion = .invalidated + } + } catch { + completion = .failed(error) + } + self?.complete( + key: key, + operationToken: operationToken, + completion: completion + ) + } + + return try await awaitResult(from: record) + } + + func invalidate( + kind: DisplayStartKind, + displayID: CGDirectDisplayID + ) { + invalidate(key: OperationKey(kind: kind, displayID: displayID)) + } + + func invalidateAll(displayID: CGDirectDisplayID) { + invalidate(kind: .monitoring, displayID: displayID) + invalidate(kind: .sharing, displayID: displayID) + } + + func invalidateAll(kind: DisplayStartKind) { + let keysToInvalidate = operations.keys.filter { $0.kind == kind } + for key in keysToInvalidate { + invalidate(key: key) + } + } + + func waiterCountForTesting( + kind: DisplayStartKind, + displayID: CGDirectDisplayID + ) -> Int { + operations[OperationKey(kind: kind, displayID: displayID)]?.waiters.count ?? 0 + } + + private func awaitResult( + from record: OperationRecord + ) async throws -> DisplayStartOutcome { + try await withCheckedThrowingContinuation { continuation in + let waiterID = UUID() + record.waiters[waiterID] = { completion in + switch completion { + case .finished(let value): + guard let typedValue = value as? Value else { + continuation.resume(throwing: StartCoordinatorTypeMismatchError()) + return + } + continuation.resume(returning: .started(typedValue)) + case .invalidated: + continuation.resume(returning: .invalidated) + case .failed(let error): + continuation.resume(throwing: error) + } + } + } + } + + private func complete( + key: OperationKey, + operationToken: UUID, + completion: OperationCompletion + ) { + guard let record = operations[key], record.token == operationToken else { return } + operations.removeValue(forKey: key) + let waiters = Array(record.waiters.values) + record.waiters.removeAll() + for waiter in waiters { + waiter(completion) + } + } + + private func invalidate(key: OperationKey) { + guard let record = operations.removeValue(forKey: key) else { return } + record.invalidationContext.invalidate() + record.task?.cancel() + let waiters = Array(record.waiters.values) + record.waiters.removeAll() + for waiter in waiters { + waiter(.invalidated) + } + } +} diff --git a/VoidDisplay/Features/Capture/Services/ScreenCaptureFunction.swift b/VoidDisplay/Features/Capture/Services/ScreenCaptureFunction.swift deleted file mode 100644 index 368855c..0000000 --- a/VoidDisplay/Features/Capture/Services/ScreenCaptureFunction.swift +++ /dev/null @@ -1,1006 +0,0 @@ -import AppKit -import CoreGraphics -import CoreMedia -import Foundation -import OSLog -import ScreenCaptureKit -import Synchronization - -// MARK: - Public Protocols & Value Types - -enum DisplayStartOutcome: Sendable { - case started(Value) - case invalidated -} - -enum DisplayStartKind: Hashable, Sendable { - case monitoring - case sharing -} - -protocol DisplayPreviewSink: AnyObject, Sendable { - nonisolated func submitFrame(_ sampleBuffer: CMSampleBuffer) -} - -nonisolated struct SendableDisplay: @unchecked Sendable { - nonisolated(unsafe) let value: SCDisplay - nonisolated let displayID: CGDirectDisplayID - nonisolated let width: Int - nonisolated let height: Int - - nonisolated init(_ value: SCDisplay) { - self.value = value - self.displayID = value.displayID - self.width = value.width - self.height = value.height - } -} - -final class DisplayStartInvalidationContext: Sendable { - private struct State { - var isInvalidated = false - var waiters: [UUID: CheckedContinuation] = [:] - } - - private let state = Mutex(State()) - - nonisolated func invalidate() { - let pendingWaiters = state.withLock { state -> [CheckedContinuation] in - guard !state.isInvalidated else { return [] } - state.isInvalidated = true - let waiters = Array(state.waiters.values) - state.waiters.removeAll() - return waiters - } - for waiter in pendingWaiters { - waiter.resume() - } - } - - nonisolated func isInvalidated() -> Bool { - state.withLock { $0.isInvalidated } - } - - nonisolated func waitForInvalidation() async { - let waiterID = UUID() - await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - let shouldResumeImmediately = state.withLock { state -> Bool in - if state.isInvalidated || Task.isCancelled { - return true - } - state.waiters[waiterID] = continuation - return false - } - if shouldResumeImmediately { - continuation.resume() - return - } - - if Task.isCancelled { - let cancelledWaiter = state.withLock { state -> CheckedContinuation? in - state.waiters.removeValue(forKey: waiterID) - } - cancelledWaiter?.resume() - } - } - } onCancel: { - let cancelledWaiter = self.state.withLock { state -> CheckedContinuation? in - state.waiters.removeValue(forKey: waiterID) - } - cancelledWaiter?.resume() - } - } - - nonisolated func race( - _ operation: @escaping @Sendable () async throws -> T - ) async throws -> DisplayStartOutcome { - if isInvalidated() { - return .invalidated - } - - return try await withThrowingTaskGroup(of: DisplayStartOutcome.self) { group in - group.addTask { - .started(try await operation()) - } - group.addTask { - await self.waitForInvalidation() - try Task.checkCancellation() - return .invalidated - } - - do { - guard let firstResult = try await group.next() else { - throw CancellationError() - } - group.cancelAll() - while (try? await group.next()) != nil {} - return firstResult - } catch { - group.cancelAll() - while (try? await group.next()) != nil {} - throw error - } - } - } - - nonisolated var waiterCountForTesting: Int { - state.withLock { $0.waiters.count } - } -} - -@MainActor -final class DisplayStreamStartCoordinator { - private struct OperationKey: Hashable { - let kind: DisplayStartKind - let displayID: CGDirectDisplayID - } - - private enum OperationCompletion { - case finished(Any) - case invalidated - case failed(any Error) - } - - private final class OperationRecord { - let token = UUID() - let invalidationContext = DisplayStartInvalidationContext() - var waiters: [UUID: (OperationCompletion) -> Void] = [:] - var task: Task? - } - - private var operations: [OperationKey: OperationRecord] = [:] - - func isStarting( - kind: DisplayStartKind, - displayID: CGDirectDisplayID - ) -> Bool { - operations[OperationKey(kind: kind, displayID: displayID)] != nil - } - - func start( - kind: DisplayStartKind, - displayID: CGDirectDisplayID, - operation: @escaping @MainActor (DisplayStartInvalidationContext) async throws -> DisplayStartOutcome - ) async throws -> DisplayStartOutcome { - let key = OperationKey(kind: kind, displayID: displayID) - if let existing = operations[key] { - return try await awaitResult(from: existing) - } - - let record = OperationRecord() - operations[key] = record - let operationToken = record.token - record.task = Task { @MainActor [weak self] in - let completion: OperationCompletion - do { - let outcome = try await operation(record.invalidationContext) - switch outcome { - case .started(let value): - completion = .finished(value) - case .invalidated: - completion = .invalidated - } - } catch { - completion = .failed(error) - } - self?.complete( - key: key, - operationToken: operationToken, - completion: completion - ) - } - - return try await awaitResult(from: record) - } - - func invalidate( - kind: DisplayStartKind, - displayID: CGDirectDisplayID - ) { - invalidate(key: OperationKey(kind: kind, displayID: displayID)) - } - - func invalidateAll(displayID: CGDirectDisplayID) { - invalidate(kind: .monitoring, displayID: displayID) - invalidate(kind: .sharing, displayID: displayID) - } - - func invalidateAll(kind: DisplayStartKind) { - let keysToInvalidate = operations.keys.filter { $0.kind == kind } - for key in keysToInvalidate { - invalidate(key: key) - } - } - - func waiterCountForTesting( - kind: DisplayStartKind, - displayID: CGDirectDisplayID - ) -> Int { - operations[OperationKey(kind: kind, displayID: displayID)]?.waiters.count ?? 0 - } - - private func awaitResult( - from record: OperationRecord - ) async throws -> DisplayStartOutcome { - try await withCheckedThrowingContinuation { continuation in - let waiterID = UUID() - record.waiters[waiterID] = { completion in - switch completion { - case .finished(let value): - guard let typedValue = value as? Value else { - continuation.resume(throwing: StartCoordinatorTypeMismatchError()) - return - } - continuation.resume(returning: .started(typedValue)) - case .invalidated: - continuation.resume(returning: .invalidated) - case .failed(let error): - continuation.resume(throwing: error) - } - } - } - } - - private func complete( - key: OperationKey, - operationToken: UUID, - completion: OperationCompletion - ) { - guard let record = operations[key], record.token == operationToken else { return } - operations.removeValue(forKey: key) - let waiters = Array(record.waiters.values) - record.waiters.removeAll() - for waiter in waiters { - waiter(completion) - } - } - - private func invalidate(key: OperationKey) { - guard let record = operations.removeValue(forKey: key) else { return } - record.invalidationContext.invalidate() - record.task?.cancel() - let waiters = Array(record.waiters.values) - record.waiters.removeAll() - for waiter in waiters { - waiter(.invalidated) - } - } -} - -private struct StartCoordinatorTypeMismatchError: Error {} - -protocol DisplayCaptureSessioning: AnyObject, Sendable { - nonisolated var sessionHub: WebRTCSessionHub { get } - nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) - nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) - nonisolated func stopSharing() - nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws - nonisolated func retainShareCursorOverride() async throws - nonisolated func releaseShareCursorOverride() async throws - nonisolated func stop() async -} - -// MARK: - Preview Subscription - -final class DisplayPreviewSubscription: Sendable { - let displayID: CGDirectDisplayID - let resolutionText: String - - private let session: any DisplayCaptureSessioning - private let cancelState = Mutex<(@Sendable () -> Void)?>(nil) - private let attachedSinks = Mutex<[ObjectIdentifier: WeakSink]>([:]) - - private final class WeakSink: @unchecked Sendable { - nonisolated(unsafe) weak var value: (any DisplayPreviewSink)? - - nonisolated init(_ value: any DisplayPreviewSink) { - self.value = value - } - } - - nonisolated init( - displayID: CGDirectDisplayID, - resolutionText: String, - session: any DisplayCaptureSessioning, - cancelClosure: @escaping @Sendable () -> Void - ) { - self.displayID = displayID - self.resolutionText = resolutionText - self.session = session - cancelState.withLock { $0 = cancelClosure } - } - - nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { - attachedSinks.withLock { $0[ObjectIdentifier(sink as AnyObject)] = WeakSink(sink) } - session.attachPreviewSink(sink) - } - - nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { - attachedSinks.withLock { _ = $0.removeValue(forKey: ObjectIdentifier(sink as AnyObject)) } - session.detachPreviewSink(sink) - } - - nonisolated func cancel() { - let session = self.session - let closure = cancelState.withLock { state -> (@Sendable () -> Void)? in - let current = state - state = nil - return current - } - guard let closure else { return } - - // Detach all sinks previously attached via this subscription to avoid - // leaving fanout strongly holding onto closed-window renderers when the - // monitoring session is removed externally. - let sinksToDetach: [any DisplayPreviewSink] = attachedSinks.withLock { dict in - let snapshot = dict.values.compactMap(\.value) - dict.removeAll(keepingCapacity: true) - return snapshot - } - for sink in sinksToDetach { - session.detachPreviewSink(sink) - } - - closure() - } - - nonisolated func setShowsCursor(_ showsCursor: Bool) async throws { - try await session.setPreviewShowsCursor(showsCursor) - } - - deinit { cancel() } -} - -// MARK: - Share Subscription - -final class DisplayShareSubscription: Sendable { - let displayID: CGDirectDisplayID - let sessionHub: WebRTCSessionHub - - private let session: any DisplayCaptureSessioning - private let cancelState = Mutex<(@Sendable () -> Void)?>(nil) - private let prepareRetainTask = Mutex?>(nil) - - nonisolated init( - displayID: CGDirectDisplayID, - sessionHub: WebRTCSessionHub, - session: any DisplayCaptureSessioning, - cancelClosure: @escaping @Sendable () -> Void - ) { - self.displayID = displayID - self.sessionHub = sessionHub - self.session = session - cancelState.withLock { $0 = cancelClosure } - } - - nonisolated func prepareForSharing() async throws { - try await session.retainShareCursorOverride() - } - - nonisolated func prepareForSharing( - invalidationContext: DisplayStartInvalidationContext - ) async throws -> DisplayStartOutcome { - let retainTask = Task { - try await session.retainShareCursorOverride() - } - prepareRetainTask.withLock { state in - state = retainTask - } - do { - let outcome = try await invalidationContext.race { - try await retainTask.value - } - switch outcome { - case .started: - prepareRetainTask.withLock { state in - state = nil - } - case .invalidated: - cancel() - } - return outcome - } catch { - cancel() - throw error - } - } - - nonisolated func cancel() { - let session = self.session - let pendingRetainTask = prepareRetainTask.withLock { state -> Task? in - let current = state - state = nil - return current - } - let closure = cancelState.withLock { state -> (@Sendable () -> Void)? in - let current = state - state = nil - return current - } - guard let closure else { return } - if let pendingRetainTask { - Task.detached { - do { - try await pendingRetainTask.value - } catch { - } - try? await session.releaseShareCursorOverride() - closure() - } - return - } - Task { - try? await session.releaseShareCursorOverride() - closure() - } - } - - deinit { cancel() } -} - -// MARK: - Capture Registry - -actor DisplayCaptureRegistry { - enum SessionResourceState: Equatable { - case initializing - case active - case draining - case stopped - } - - struct PreviewToken: Hashable, Sendable { - fileprivate let rawValue: UUID - let displayID: CGDirectDisplayID - } - - struct ShareToken: Hashable, Sendable { - fileprivate let rawValue: UUID - let displayID: CGDirectDisplayID - } - - private enum TokenKind: Sendable { - case preview - case share - } - - private struct TokenRecord: Sendable { - let kind: TokenKind - let displayID: CGDirectDisplayID - } - - struct SessionRecord { - let session: any DisplayCaptureSessioning - let resolutionText: String - var state: SessionResourceState - var previewTokens: Set - var shareTokens: Set - } - - private enum RegistryError: Error { - case sessionUnavailable - } - - typealias CaptureSessionFactory = @Sendable (SendableDisplay) async throws -> any DisplayCaptureSessioning - - static let shared = DisplayCaptureRegistry() - - private let captureSessionFactory: CaptureSessionFactory - private var sessionsByDisplayID: [CGDirectDisplayID: SessionRecord] = [:] - private var tokenOwnership: [UUID: TokenRecord] = [:] - private var sessionCreationTasks: [CGDirectDisplayID: Task] = [:] - private var sessionDrainTasksByDisplayID: [CGDirectDisplayID: Task] = [:] - private var initializingDisplayIDs: Set = [] - - init( - captureSessionFactory: @escaping CaptureSessionFactory = { display in - try await DisplayCaptureSession(display: display.value) - } - ) { - self.captureSessionFactory = captureSessionFactory - } - - // MARK: Acquire / Release - - func acquirePreview(display: SendableDisplay) async throws -> DisplayPreviewSubscription { - let token = try await acquirePreviewToken(display: display) - guard let record = sessionsByDisplayID[token.displayID] else { - throw RegistryError.sessionUnavailable - } - return DisplayPreviewSubscription( - displayID: token.displayID, - resolutionText: record.resolutionText, - session: record.session, - cancelClosure: { [weak self] in - guard let self else { return } - Task { await self.release(token) } - } - ) - } - - func acquirePreview( - display: SendableDisplay, - invalidationContext: DisplayStartInvalidationContext - ) async throws -> DisplayStartOutcome { - try await invalidationContext.race { - try await self.acquirePreview(display: display) - } - } - - func acquireShare(display: SendableDisplay) async throws -> DisplayShareSubscription { - let token = try await acquireShareToken(display: display) - guard let record = sessionsByDisplayID[token.displayID] else { - throw RegistryError.sessionUnavailable - } - return DisplayShareSubscription( - displayID: token.displayID, - sessionHub: record.session.sessionHub, - session: record.session, - cancelClosure: { [weak self] in - guard let self else { return } - Task { await self.release(token) } - } - ) - } - - func acquireShare( - display: SendableDisplay, - invalidationContext: DisplayStartInvalidationContext - ) async throws -> DisplayStartOutcome { - try await invalidationContext.race { - try await self.acquireShare(display: display) - } - } - - func acquirePreviewToken(display: SendableDisplay) async throws -> PreviewToken { - let tokenID = try await acquireToken(display: display, kind: .preview) - return PreviewToken(rawValue: tokenID, displayID: display.displayID) - } - - func acquireShareToken(display: SendableDisplay) async throws -> ShareToken { - let tokenID = try await acquireToken(display: display, kind: .share) - return ShareToken(rawValue: tokenID, displayID: display.displayID) - } - - func release(_ token: PreviewToken) async { - await releaseToken(token.rawValue, expectedKind: .preview) - } - - func release(_ token: ShareToken) async { - await releaseToken(token.rawValue, expectedKind: .share) - } - - func sessionState(for displayID: CGDirectDisplayID) -> SessionResourceState { - if initializingDisplayIDs.contains(displayID) { - return .initializing - } - return sessionsByDisplayID[displayID]?.state ?? .stopped - } - - // MARK: Internal - - private func acquireToken( - display: SendableDisplay, - kind: TokenKind - ) async throws -> UUID { - try await ensureSessionExists(for: display) - return try registerToken(displayID: display.displayID, kind: kind) - } - -#if DEBUG - func installSessionForTesting( - displayID: CGDirectDisplayID, - resolutionText: String, - session: any DisplayCaptureSessioning - ) { - sessionDrainTasksByDisplayID[displayID]?.cancel() - sessionDrainTasksByDisplayID[displayID] = nil - initializingDisplayIDs.remove(displayID) - sessionsByDisplayID[displayID] = SessionRecord( - session: session, - resolutionText: resolutionText, - state: .active, - previewTokens: [], - shareTokens: [] - ) - } - - func acquirePreviewTokenForTesting(displayID: CGDirectDisplayID) throws -> PreviewToken { - let tokenID = try registerToken(displayID: displayID, kind: .preview) - return PreviewToken(rawValue: tokenID, displayID: displayID) - } - - func acquireShareTokenForTesting(displayID: CGDirectDisplayID) throws -> ShareToken { - let tokenID = try registerToken(displayID: displayID, kind: .share) - return ShareToken(rawValue: tokenID, displayID: displayID) - } -#endif - - private func registerToken(displayID: CGDirectDisplayID, kind: TokenKind) throws -> UUID { - let tokenID = UUID() - guard var record = sessionsByDisplayID[displayID] else { - throw RegistryError.sessionUnavailable - } - guard record.state != .draining else { - throw RegistryError.sessionUnavailable - } - record.state = .active - switch kind { - case .preview: - record.previewTokens.insert(tokenID) - case .share: - record.shareTokens.insert(tokenID) - } - sessionsByDisplayID[displayID] = record - tokenOwnership[tokenID] = TokenRecord(kind: kind, displayID: displayID) - return tokenID - } - - private func ensureSessionExists(for display: SendableDisplay) async throws { - let displayID = display.displayID - if let existing = sessionsByDisplayID[displayID] { - if existing.state != .draining { - return - } - await waitForDrainCompletion(for: displayID) - if let afterDrain = sessionsByDisplayID[displayID], afterDrain.state != .draining { - return - } - } - - if let existingTask = sessionCreationTasks[displayID] { - let record = try await existingTask.value - storeInitializedSessionIfAbsent(record, for: displayID) - return - } - - let task = Task { [captureSessionFactory] in - let session = try await captureSessionFactory(display) - return SessionRecord( - session: session, - resolutionText: "\(display.width) × \(display.height)", - state: .active, - previewTokens: [], - shareTokens: [] - ) - } - initializingDisplayIDs.insert(displayID) - sessionCreationTasks[displayID] = task - defer { sessionCreationTasks[displayID] = nil } - - do { - let record = try await task.value - storeInitializedSessionIfAbsent(record, for: displayID) - } catch { - initializingDisplayIDs.remove(displayID) - throw error - } - } - - private func storeInitializedSessionIfAbsent( - _ record: SessionRecord, - for displayID: CGDirectDisplayID - ) { - initializingDisplayIDs.remove(displayID) - guard sessionsByDisplayID[displayID] == nil else { return } - sessionsByDisplayID[displayID] = record - } - - private func releaseToken(_ tokenID: UUID, expectedKind: TokenKind) async { - guard let ownership = tokenOwnership.removeValue(forKey: tokenID), - ownership.kind == expectedKind else { - return - } - guard var record = sessionsByDisplayID[ownership.displayID] else { return } - - switch ownership.kind { - case .preview: - record.previewTokens.remove(tokenID) - case .share: - record.shareTokens.remove(tokenID) - } - - if ownership.kind == .share, record.shareTokens.isEmpty { - record.session.stopSharing() - } - - if record.previewTokens.isEmpty, record.shareTokens.isEmpty { - record.state = .draining - sessionsByDisplayID[ownership.displayID] = record - let session = record.session - sessionDrainTasksByDisplayID[ownership.displayID]?.cancel() - sessionDrainTasksByDisplayID[ownership.displayID] = Task { [session] in - await session.stop() - self.finishDrainingSession(displayID: ownership.displayID) - } - return - } - - record.state = .active - sessionsByDisplayID[ownership.displayID] = record - } - - private func waitForDrainCompletion(for displayID: CGDirectDisplayID) async { - guard let drainTask = sessionDrainTasksByDisplayID[displayID] else { return } - await drainTask.value - } - - private func finishDrainingSession(displayID: CGDirectDisplayID) { - sessionDrainTasksByDisplayID[displayID] = nil - guard let record = sessionsByDisplayID[displayID] else { return } - guard record.state == .draining else { return } - guard record.previewTokens.isEmpty, record.shareTokens.isEmpty else { - var resumed = record - resumed.state = .active - sessionsByDisplayID[displayID] = resumed - return - } - sessionsByDisplayID.removeValue(forKey: displayID) - } -} - -// MARK: - Stream Output Delegate - -private final class DisplayStreamOutput: NSObject, SCStreamOutput, SCStreamDelegate { - nonisolated(unsafe) weak var session: DisplayCaptureSession? - - nonisolated override init() { - super.init() - } - - nonisolated func stream( - _ stream: SCStream, - didOutputSampleBuffer sampleBuffer: CMSampleBuffer, - of type: SCStreamOutputType - ) { - session?.handle(sampleBuffer: sampleBuffer, type: type) - } - - nonisolated func stream(_ stream: SCStream, didStopWithError error: Error) { - Task { @MainActor in - AppErrorMapper.logFailure("Screen capture stream stopped", error: error, logger: AppLog.capture) - } - } -} - -// MARK: - Sample Fanout - -private final class DisplaySampleFanout: Sendable { - private let sinks = Mutex<[ObjectIdentifier: any DisplayPreviewSink]>([:]) - - nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { - sinks.withLock { $0[ObjectIdentifier(sink as AnyObject)] = sink } - } - - nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { - sinks.withLock { _ = $0.removeValue(forKey: ObjectIdentifier(sink as AnyObject)) } - } - - nonisolated func publishPreviewFrame(_ sampleBuffer: CMSampleBuffer) { - let snapshot = sinks.withLock { Array($0.values) } - for sink in snapshot { sink.submitFrame(sampleBuffer) } - } -} - -// MARK: - Display Capture Session - -final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning { - private struct StreamConfigurationState: Sendable { - let width: Int - let height: Int - let minimumFrameInterval: CMTime - let queueDepth: Int - let capturesAudio: Bool - let pixelFormat: OSType - var previewShowsCursor: Bool - var shareCursorOverrideCount: Int - } - - nonisolated let displayID: CGDirectDisplayID - nonisolated let sessionHub: WebRTCSessionHub - - nonisolated(unsafe) private let stream: SCStream - private let output = DisplayStreamOutput() - nonisolated private let captureQueue: DispatchQueue - nonisolated private let fanout = DisplaySampleFanout() - nonisolated private let metrics = Mutex(DisplayCaptureMetrics()) - nonisolated private let configurationState: Mutex - - // MARK: Lifecycle - - nonisolated init(display: SCDisplay) async throws { - self.displayID = display.displayID - self.captureQueue = DispatchQueue( - label: "com.developerchen.voiddisplay.capture.\(display.displayID)", - qos: .userInitiated - ) - - let state = try await Self.makeStreamConfigurationState(display: display, showsCursor: false) - let config = Self.makeStreamConfiguration(from: state) - let filter = try await Self.makeContentFilter(display: display) - self.stream = SCStream(filter: filter, configuration: config, delegate: output) - self.sessionHub = WebRTCSessionHub() - self.configurationState = Mutex(state) - - output.session = self - - try stream.addStreamOutput(output, type: .screen, sampleHandlerQueue: captureQueue) - try await stream.startCapture() - } - - // MARK: Preview Sinks - - nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { - fanout.attachPreviewSink(sink) - } - - nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { - fanout.detachPreviewSink(sink) - } - - // MARK: Sharing Control - - nonisolated func stopSharing() { - sessionHub.stopSharing() - } - - nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { - let updatedState = configurationState.withLock { state -> StreamConfigurationState in - if state.previewShowsCursor == showsCursor { - return state - } - var copy = state - copy.previewShowsCursor = showsCursor - return copy - } - guard updatedState.previewShowsCursor == showsCursor else { return } - try await applyStreamConfiguration(updatedState) - } - - nonisolated func retainShareCursorOverride() async throws { - let updatedState = configurationState.withLock { state -> StreamConfigurationState in - var copy = state - copy.shareCursorOverrideCount += 1 - return copy - } - try await applyStreamConfiguration(updatedState) - } - - nonisolated func releaseShareCursorOverride() async throws { - let updatedState = configurationState.withLock { state -> StreamConfigurationState in - var copy = state - copy.shareCursorOverrideCount = max(0, copy.shareCursorOverrideCount - 1) - return copy - } - try await applyStreamConfiguration(updatedState) - } - - nonisolated private func applyStreamConfiguration(_ updatedState: StreamConfigurationState) async throws { - try await stream.updateConfiguration(Self.makeStreamConfiguration(from: updatedState)) - configurationState.withLock { state in - state.previewShowsCursor = updatedState.previewShowsCursor - state.shareCursorOverrideCount = updatedState.shareCursorOverrideCount - } - } - - nonisolated func stop() async { - stopSharing() - try? await stream.stopCapture() - } - - // MARK: Frame Handling - - nonisolated func handle(sampleBuffer: CMSampleBuffer, type: SCStreamOutputType) { - guard type == .screen, let pixelBuffer = sampleBuffer.imageBuffer else { return } - metrics.withLock { $0.receivedFrameCount &+= 1 } - - fanout.publishPreviewFrame(sampleBuffer) - - guard sessionHub.hasDemand else { return } - let ptsUs = Self.microseconds(from: CMSampleBufferGetPresentationTimeStamp(sampleBuffer)) - sessionHub.submitFrame(pixelBuffer: pixelBuffer, ptsUs: ptsUs) - } -} - -// MARK: - DisplayCaptureSession Helpers - -extension DisplayCaptureSession { - - nonisolated static func microseconds(from time: CMTime) -> UInt64 { - guard time.isValid, !time.isIndefinite, time.seconds.isFinite else { return 0 } - let scaled = CMTimeConvertScale(time, timescale: 1_000_000, method: .default) - return scaled.value > 0 ? UInt64(scaled.value) : 0 - } - - nonisolated private static func makeStreamConfigurationState( - display: SCDisplay, - showsCursor: Bool - ) async throws -> StreamConfigurationState { - let displayMode = CGDisplayCopyDisplayMode(display.displayID) - - let captureSize = preferredCaptureSize(display: display, displayMode: displayMode) - let refreshRate = max(60.0, min(displayMode?.refreshRate ?? 60.0, 120.0)) - let timescale = CMTimeScale(max(1, Int32(refreshRate.rounded()))) - - let state = StreamConfigurationState( - width: captureSize.width, - height: captureSize.height, - minimumFrameInterval: CMTime(value: 1, timescale: timescale), - queueDepth: 2, - capturesAudio: false, - pixelFormat: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, - previewShowsCursor: showsCursor, - shareCursorOverrideCount: 0 - ) - AppLog.capture.notice( - "Capture config display=\(display.displayID, privacy: .public) size=\(captureSize.width)x\(captureSize.height, privacy: .public)" - ) - return state - } - - nonisolated private static func makeStreamConfiguration( - from state: StreamConfigurationState - ) -> SCStreamConfiguration { - let config = SCStreamConfiguration() - config.width = state.width - config.height = state.height - config.minimumFrameInterval = state.minimumFrameInterval - config.queueDepth = state.queueDepth - config.showsCursor = state.shareCursorOverrideCount > 0 || state.previewShowsCursor - config.capturesAudio = state.capturesAudio - config.pixelFormat = state.pixelFormat - return config - } - - nonisolated private static func preferredCaptureSize( - display: SCDisplay, - displayMode: CGDisplayMode? - ) -> (width: Int, height: Int) { - let modePixelWidth = displayMode.map { Int($0.pixelWidth) } ?? display.width - let modePixelHeight = displayMode.map { Int($0.pixelHeight) } ?? display.height - let modeLogicalWidth = displayMode.map { $0.width } ?? modePixelWidth - let modeLogicalHeight = displayMode.map { $0.height } ?? modePixelHeight - let backingScale = screenBackingScaleFactor(for: display.displayID) - - let scaledLogicalWidth = max(1, Int((CGFloat(modeLogicalWidth) * backingScale).rounded())) - let scaledLogicalHeight = max(1, Int((CGFloat(modeLogicalHeight) * backingScale).rounded())) - - return ( - width: max(modePixelWidth, scaledLogicalWidth), - height: max(modePixelHeight, scaledLogicalHeight) - ) - } - - nonisolated private static func screenBackingScaleFactor(for displayID: CGDirectDisplayID) -> CGFloat { - let key = NSDeviceDescriptionKey("NSScreenNumber") - guard let screen = NSScreen.screens.first(where: { - guard let number = $0.deviceDescription[key] as? NSNumber else { return false } - return number.uint32Value == displayID - }) else { - return 1.0 - } - return max(1.0, screen.backingScaleFactor) - } - - nonisolated private static func makeContentFilter( - display: SCDisplay - ) async throws -> SCContentFilter { - let content = try await SCShareableContent.excludingDesktopWindows( - false, onScreenWindowsOnly: false - ) - let excludedApps = content.applications.filter { app in - Bundle.main.bundleIdentifier == app.bundleIdentifier - } - return SCContentFilter( - display: display, - excludingApplications: excludedApps, - exceptingWindows: [] - ) - } -} - -// MARK: - Internal Metrics - -struct DisplayCaptureMetrics: Sendable { - var receivedFrameCount: UInt64 = 0 -} diff --git a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift index d0d7a3d..3675886 100644 --- a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift +++ b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift @@ -1,6 +1,7 @@ import AppKit import AVFoundation import SwiftUI +import Synchronization // MARK: - Capture Display View @@ -466,6 +467,32 @@ private struct TransparentScrollViewConfigurator: NSViewRepresentable { /// management entirely on the GPU. @Observable final class ZeroCopyPreviewRenderer: @unchecked Sendable, DisplayPreviewSink { + struct MetricsSnapshot: Sendable { + var receivedFrameCount: UInt64 + var renderedFrameCount: UInt64 + var droppedFrameCount: UInt64 + var latestRenderLatencyMilliseconds: Double? + var pendingSlotOccupied: Bool + } + + private struct PendingFrame { + let buffer: UncheckedSendableBuffer + let submittedAtNanoseconds: UInt64 + let generation: UInt64 + } + + private struct State { + var pendingFrame: PendingFrame? + var isDraining = false + var activeDrainToken: UInt64? + var nextDrainToken: UInt64 = 0 + var generation: UInt64 = 0 + var receivedFrameCount: UInt64 = 0 + var renderedFrameCount: UInt64 = 0 + var droppedFrameCount: UInt64 = 0 + var latestRenderLatencyMilliseconds: Double? + } + var framePixelSize: CGSize = .zero var hasReceivedFrame = false @@ -476,31 +503,105 @@ final class ZeroCopyPreviewRenderer: @unchecked Sendable, DisplayPreviewSink { return layer }() + nonisolated private let state = Mutex(State()) + @MainActor var willEnqueueFrameForTesting: (() -> Void)? + nonisolated func submitFrame(_ sampleBuffer: CMSampleBuffer) { let box = UncheckedSendableBuffer(sampleBuffer) + let drainToken = state.withLock { state -> UInt64? in + state.receivedFrameCount &+= 1 + if state.pendingFrame != nil { + state.droppedFrameCount &+= 1 + } + state.pendingFrame = PendingFrame( + buffer: box, + submittedAtNanoseconds: DispatchTime.now().uptimeNanoseconds, + generation: state.generation + ) + guard !state.isDraining else { return nil } + state.isDraining = true + state.nextDrainToken &+= 1 + state.activeDrainToken = state.nextDrainToken + return state.nextDrainToken + } + guard let drainToken else { return } + Task { @MainActor [weak self] in - guard let self else { return } - let renderer = self.displayLayer.sampleBufferRenderer + self?.drainLoop(drainToken: drainToken) + } + } + + func flush() { + state.withLock { state in + state.pendingFrame = nil + state.generation &+= 1 + state.activeDrainToken = nil + state.isDraining = false + } + displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true) + } - if renderer.status == .failed { renderer.flush() } - renderer.enqueue(box.buffer) + nonisolated func metricsSnapshot() -> MetricsSnapshot { + state.withLock { state in + .init( + receivedFrameCount: state.receivedFrameCount, + renderedFrameCount: state.renderedFrameCount, + droppedFrameCount: state.droppedFrameCount, + latestRenderLatencyMilliseconds: state.latestRenderLatencyMilliseconds, + pendingSlotOccupied: state.pendingFrame != nil + ) + } + } + + @MainActor + private func drainLoop(drainToken: UInt64) { + while true { + let nextFrame = state.withLock { state -> PendingFrame? in + guard state.activeDrainToken == drainToken else { + return nil + } + guard let pendingFrame = state.pendingFrame else { + state.activeDrainToken = nil + state.isDraining = false + return nil + } + state.pendingFrame = nil + return pendingFrame + } + guard let nextFrame else { return } + + willEnqueueFrameForTesting?() + let shouldRender = state.withLock { state in + state.activeDrainToken == drainToken && state.generation == nextFrame.generation + } + guard shouldRender else { continue } + + let renderer = displayLayer.sampleBufferRenderer + if renderer.status == .failed { + renderer.flush() + } + renderer.enqueue(nextFrame.buffer.buffer) - if !self.hasReceivedFrame { - self.hasReceivedFrame = true + if !hasReceivedFrame { + hasReceivedFrame = true } - if let desc = CMSampleBufferGetFormatDescription(box.buffer) { + if let desc = CMSampleBufferGetFormatDescription(nextFrame.buffer.buffer) { let dims = CMVideoFormatDescriptionGetDimensions(desc) let size = CGSize(width: CGFloat(dims.width), height: CGFloat(dims.height)) - if self.framePixelSize != size { - self.framePixelSize = size + if framePixelSize != size { + framePixelSize = size } } - } - } - func flush() { - displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true) + let latencyMilliseconds = Double( + DispatchTime.now().uptimeNanoseconds &- nextFrame.submittedAtNanoseconds + ) / 1_000_000 + state.withLock { state in + state.renderedFrameCount &+= 1 + state.latestRenderLatencyMilliseconds = latencyMilliseconds + } + } } } diff --git a/VoidDisplayTests/Features/Capture/Services/DisplayCaptureProfileStateMachineTests.swift b/VoidDisplayTests/Features/Capture/Services/DisplayCaptureProfileStateMachineTests.swift new file mode 100644 index 0000000..f3a175a --- /dev/null +++ b/VoidDisplayTests/Features/Capture/Services/DisplayCaptureProfileStateMachineTests.swift @@ -0,0 +1,146 @@ +import Testing +@testable import VoidDisplay + +struct DisplayCaptureProfileStateMachineTests { + @Test func desiredProfileMatchesPreviewAndSharingDemand() { + #expect( + DisplayCaptureProfileStateMachine.desiredProfile( + previewSinkCount: 1, + sharingActive: false + ) == .previewOnly + ) + #expect( + DisplayCaptureProfileStateMachine.desiredProfile( + previewSinkCount: 0, + sharingActive: true + ) == .shareOnly + ) + #expect( + DisplayCaptureProfileStateMachine.desiredProfile( + previewSinkCount: 2, + sharingActive: true + ) == .mixed + ) + #expect( + DisplayCaptureProfileStateMachine.desiredProfile( + previewSinkCount: 0, + sharingActive: false + ) == nil + ) + } + + @Test func firstTransitionAppliesImmediately() { + let decision = DisplayCaptureProfileStateMachine.decideTransition( + previewSinkCount: 1, + sharingActive: true, + currentProfile: .previewOnly, + lastProfileSwitchTimeNs: nil, + nowNs: 10, + minimumDwellNanoseconds: 5_000_000_000 + ) + + switch decision { + case .applyNow(.mixed): + break + default: + Issue.record("Expected immediate mixed profile transition, got \(String(describing: decision))") + } + } + + @Test func dwellWindowSchedulesDelayedTransition() { + let decision = DisplayCaptureProfileStateMachine.decideTransition( + previewSinkCount: 0, + sharingActive: true, + currentProfile: .previewOnly, + lastProfileSwitchTimeNs: 1_000, + nowNs: 2_000, + minimumDwellNanoseconds: 5_000 + ) + + switch decision { + case .applyAfter(.shareOnly, let delayNanoseconds): + #expect(delayNanoseconds == 4_000) + default: + Issue.record("Expected delayed shareOnly transition, got \(String(describing: decision))") + } + } + + @Test func elapsedDwellAppliesImmediately() { + let decision = DisplayCaptureProfileStateMachine.decideTransition( + previewSinkCount: 0, + sharingActive: true, + currentProfile: .previewOnly, + lastProfileSwitchTimeNs: 1_000, + nowNs: 10_000, + minimumDwellNanoseconds: 5_000 + ) + + switch decision { + case .applyNow(.shareOnly): + break + default: + Issue.record("Expected immediate shareOnly transition, got \(String(describing: decision))") + } + } + + @Test func previewFrameRateClampCapsHighRefreshAndPreservesLowerRefresh() { + #expect(DisplayCaptureSession.clampedPreviewFramesPerSecond(for: 144) == 60) + #expect(DisplayCaptureSession.clampedPreviewFramesPerSecond(for: 50) == 50) + #expect(DisplayCaptureSession.clampedPreviewFramesPerSecond(for: 0) == 60) + } + + @Test func committedTransitionUpdatesDwellBeforeReevaluatingPendingDemand() { + var coordinator = DisplayCaptureProfileCoordinatorState(committedProfile: .previewOnly) + + let initialDecision = coordinator.mutateDemand( + nowNs: 0, + minimumDwellNanoseconds: 5_000 + ) { state in + state.sharingActive = true + } + switch initialDecision { + case .applyNow(.mixed): + Issue.record("Expected shareOnly transition before preview demand arrives") + case .applyNow(.shareOnly): + break + default: + Issue.record("Expected immediate shareOnly transition, got \(String(describing: initialDecision))") + } + #expect(coordinator.inFlightProfile == .shareOnly) + + let inFlightDecision = coordinator.mutateDemand( + nowNs: 1_000, + minimumDwellNanoseconds: 5_000 + ) { state in + state.previewSinkCount = 1 + } + #expect(inFlightDecision == .noChange) + #expect(coordinator.inFlightProfile == .shareOnly) + + let followUpDecision = coordinator.finishAppliedTransition( + at: 1_000, + minimumDwellNanoseconds: 5_000 + ) + #expect(coordinator.committedProfile == .shareOnly) + #expect(coordinator.lastProfileSwitchTimeNs == 1_000) + + switch followUpDecision { + case .applyAfter(.mixed, let delayNanoseconds): + #expect(delayNanoseconds == 5_000) + default: + Issue.record("Expected delayed mixed transition after committed shareOnly apply, got \(String(describing: followUpDecision))") + } + } + + @Test func taskLifetimeInvalidationRejectsOldExecutionGeneration() { + var lifetime = DisplayCaptureTaskLifetimeState() + let initialGeneration = lifetime.currentGeneration + + #expect(lifetime.allowsExecution(for: initialGeneration)) + + _ = lifetime.invalidateAllTasks() + + #expect(lifetime.allowsExecution(for: initialGeneration) == false) + #expect(lifetime.allowsExecution(for: lifetime.currentGeneration)) + } +} diff --git a/VoidDisplayTests/Features/Capture/Services/DisplayCaptureRegistryTests.swift b/VoidDisplayTests/Features/Capture/Services/DisplayCaptureRegistryTests.swift index 764231c..5a6e93d 100644 --- a/VoidDisplayTests/Features/Capture/Services/DisplayCaptureRegistryTests.swift +++ b/VoidDisplayTests/Features/Capture/Services/DisplayCaptureRegistryTests.swift @@ -8,6 +8,7 @@ private final class FakeCaptureSession: DisplayCaptureSessioning, @unchecked Sen private struct Counters { var stopSharingCalls = 0 var stopCalls = 0 + var setSharingActiveCalls: [Bool] = [] } private let counters = Mutex(Counters()) @@ -33,6 +34,10 @@ private final class FakeCaptureSession: DisplayCaptureSessioning, @unchecked Sen nonisolated func releaseShareCursorOverride() async throws {} + nonisolated func setSharingActive(_ isActive: Bool) async throws { + counters.withLock { $0.setSharingActiveCalls.append(isActive) } + } + nonisolated func stop() async { counters.withLock { $0.stopCalls += 1 } } @@ -44,6 +49,10 @@ private final class FakeCaptureSession: DisplayCaptureSessioning, @unchecked Sen var stopCalls: Int { counters.withLock { $0.stopCalls } } + + var setSharingActiveCalls: [Bool] { + counters.withLock { $0.setSharingActiveCalls } + } } private actor SessionStopGate { @@ -97,12 +106,116 @@ private final class ControlledStopCaptureSession: DisplayCaptureSessioning, @unc nonisolated func releaseShareCursorOverride() async throws {} + nonisolated func setSharingActive(_ isActive: Bool) async throws { + _ = isActive + } + nonisolated func stop() async { counters.withLock { $0.stop += 1 } await stopGate.waitUntilOpen() } } +private actor SharingStateGate { + private var isOpen = false + private var enteredFalse = false + private var enteredWaiters: [CheckedContinuation] = [] + private var openWaiters: [CheckedContinuation] = [] + + func waitForFalseEntry() async { + guard !enteredFalse else { return } + await withCheckedContinuation { continuation in + enteredWaiters.append(continuation) + } + } + + func waitUntilOpen() async { + guard !isOpen else { return } + await withCheckedContinuation { continuation in + openWaiters.append(continuation) + } + } + + func markFalseEntered() { + guard !enteredFalse else { return } + enteredFalse = true + let waiters = enteredWaiters + enteredWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + } + + func open() { + guard !isOpen else { return } + isOpen = true + let waiters = openWaiters + openWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + } +} + +private final class BlockingSetSharingActiveSession: DisplayCaptureSessioning, @unchecked Sendable { + private struct Counters { + var stopSharingCalls = 0 + var stopCalls = 0 + var setSharingActiveCalls: [Bool] = [] + } + + nonisolated let sessionHub = WebRTCSessionHub() + private let counters = Mutex(Counters()) + private let gate: SharingStateGate + + init(gate: SharingStateGate) { + self.gate = gate + } + + nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { + _ = sink + } + + nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { + _ = sink + } + + nonisolated func stopSharing() { + counters.withLock { $0.stopSharingCalls += 1 } + } + + nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { + _ = showsCursor + } + + nonisolated func retainShareCursorOverride() async throws {} + + nonisolated func releaseShareCursorOverride() async throws {} + + nonisolated func setSharingActive(_ isActive: Bool) async throws { + counters.withLock { $0.setSharingActiveCalls.append(isActive) } + guard !isActive else { return } + await gate.markFalseEntered() + await gate.waitUntilOpen() + } + + nonisolated func stop() async { + counters.withLock { $0.stopCalls += 1 } + } + + var stopSharingCalls: Int { + counters.withLock { $0.stopSharingCalls } + } + + var stopCalls: Int { + counters.withLock { $0.stopCalls } + } + + var setSharingActiveCalls: [Bool] { + counters.withLock { $0.setSharingActiveCalls } + } +} + struct DisplayCaptureRegistryTests { @Test func releasingShareKeepsPreviewSessionAliveUntilLastToken() async throws { let registry = DisplayCaptureRegistry() @@ -119,6 +232,7 @@ struct DisplayCaptureRegistryTests { await registry.release(shareToken) #expect(fakeSession.stopSharingCalls == 1) + #expect(fakeSession.setSharingActiveCalls == [false]) #expect(fakeSession.stopCalls == 0) #expect(await registry.sessionState(for: displayID) == .active) @@ -159,7 +273,7 @@ struct DisplayCaptureRegistryTests { let fakeSession = FakeCaptureSession() let factoryCallCount = Mutex(0) - let registry = DisplayCaptureRegistry(captureSessionFactory: { _ in + let registry = DisplayCaptureRegistry(captureSessionFactory: { _, _ in factoryCallCount.withLock { $0 += 1 } await gate.waitUntilOpen() return fakeSession @@ -200,7 +314,7 @@ struct DisplayCaptureRegistryTests { let replacementSession = FakeCaptureSession() let factoryCallCount = Mutex(0) - let registry = DisplayCaptureRegistry(captureSessionFactory: { _ in + let registry = DisplayCaptureRegistry(captureSessionFactory: { _, _ in factoryCallCount.withLock { $0 += 1 } return replacementSession }) @@ -243,6 +357,101 @@ struct DisplayCaptureRegistryTests { #expect(drained) } + @Test func acquiringShareFirstUsesShareOnlyInitialProfile() async throws { + let displayID = CGDirectDisplayID(8080) + let display = MockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let sendableDisplay = SendableDisplay(display) + let fakeSession = FakeCaptureSession() + let initialProfiles = Mutex<[DisplayCaptureProfile]>([]) + let registry = DisplayCaptureRegistry(captureSessionFactory: { _, initialProfile in + initialProfiles.withLock { $0.append(initialProfile) } + return fakeSession + }) + + let subscription = try await registry.acquireShare(display: sendableDisplay) + + #expect(initialProfiles.withLock { $0.first } == .shareOnly) + subscription.cancel() + let drained = await waitUntil { + await registry.sessionState(for: displayID) == .stopped + } + #expect(drained) + } + + @Test func concurrentPreviewAndShareCreationUsesMixedInitialProfile() async throws { + let displayID = CGDirectDisplayID(9090) + let display = MockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let sendableDisplay = SendableDisplay(display) + let fakeSession = FakeCaptureSession() + let factoryGate = CaptureSessionFactoryGate() + let initialProfiles = Mutex<[DisplayCaptureProfile]>([]) + let registry = DisplayCaptureRegistry(captureSessionFactory: { _, initialProfile in + initialProfiles.withLock { $0.append(initialProfile) } + await factoryGate.waitUntilOpen() + return fakeSession + }) + + async let previewSubscription = registry.acquirePreview(display: sendableDisplay) + async let shareSubscription = registry.acquireShare(display: sendableDisplay) + + await factoryGate.open() + + let preview = try await previewSubscription + let share = try await shareSubscription + + #expect(initialProfiles.withLock { $0.first } == .mixed) + + preview.cancel() + share.cancel() + let drained = await waitUntil { + await registry.sessionState(for: displayID) == .stopped + } + #expect(drained) + } + + @Test func releaseWhileSetSharingActiveSuspendsDoesNotOverwriteConcurrentAcquire() async throws { + let displayID = CGDirectDisplayID(10010) + let display = MockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let sendableDisplay = SendableDisplay(display) + let gate = SharingStateGate() + let session = BlockingSetSharingActiveSession(gate: gate) + let registry = DisplayCaptureRegistry() + + await registry.installSessionForTesting( + displayID: displayID, + resolutionText: "1920 × 1080", + session: session + ) + + let previewToken = try await registry.acquirePreviewTokenForTesting(displayID: displayID) + let firstToken = try await registry.acquireShareToken(display: sendableDisplay) + let releaseTask = Task { + await registry.release(firstToken) + } + + await gate.waitForFalseEntry() + + let secondToken = try await registry.acquireShareToken(display: sendableDisplay) + #expect(await registry.sessionState(for: displayID) == .active) + + await gate.open() + await releaseTask.value + + #expect(await registry.sessionState(for: displayID) == .active) + #expect(session.stopCalls == 0) + #expect(session.stopSharingCalls == 1) + + await registry.release(secondToken) + #expect(await registry.sessionState(for: displayID) == .active) + + await registry.release(previewToken) + let drained = await waitUntil { + await registry.sessionState(for: displayID) == .stopped + } + #expect(drained) + #expect(session.stopCalls == 1) + } + private func waitUntil( timeout: Duration = .seconds(1), condition: @escaping @Sendable () async -> Bool diff --git a/VoidDisplayTests/Features/Capture/Services/DisplayPreviewSubscriptionTests.swift b/VoidDisplayTests/Features/Capture/Services/DisplayPreviewSubscriptionTests.swift index 263f7c9..e9d8516 100644 --- a/VoidDisplayTests/Features/Capture/Services/DisplayPreviewSubscriptionTests.swift +++ b/VoidDisplayTests/Features/Capture/Services/DisplayPreviewSubscriptionTests.swift @@ -100,4 +100,43 @@ struct DisplayPreviewSubscriptionTests { #expect(snap.detach == 1) #expect(cancelCalls.withLock { $0 } == 1) } + + @Test func duplicateAttachDoesNotForwardToSessionTwice() { + let session = MockDisplayCaptureSession() + let subscription = DisplayPreviewSubscription( + displayID: 1, + resolutionText: "100 × 100", + session: session, + cancelClosure: {} + ) + let sink = TestPreviewSink() + + subscription.attachPreviewSink(sink) + subscription.attachPreviewSink(sink) + + let snap = session.snapshot() + #expect(snap.attached == 1) + #expect(snap.attach == 1) + #expect(snap.detach == 0) + } + + @Test func extraDetachDoesNotForwardToSessionAgain() { + let session = MockDisplayCaptureSession() + let subscription = DisplayPreviewSubscription( + displayID: 1, + resolutionText: "100 × 100", + session: session, + cancelClosure: {} + ) + let sink = TestPreviewSink() + + subscription.attachPreviewSink(sink) + subscription.detachPreviewSink(sink) + subscription.detachPreviewSink(sink) + + let snap = session.snapshot() + #expect(snap.attached == 0) + #expect(snap.attach == 1) + #expect(snap.detach == 1) + } } diff --git a/VoidDisplayTests/Features/Capture/Services/DisplaySampleFanoutTests.swift b/VoidDisplayTests/Features/Capture/Services/DisplaySampleFanoutTests.swift new file mode 100644 index 0000000..9e6481c --- /dev/null +++ b/VoidDisplayTests/Features/Capture/Services/DisplaySampleFanoutTests.swift @@ -0,0 +1,102 @@ +import CoreGraphics +import CoreMedia +import Testing +import Synchronization +@testable import VoidDisplay + +private struct TestSendableSampleBuffer: @unchecked Sendable { + nonisolated(unsafe) let value: CMSampleBuffer +} + +private final class SampleBufferCaptureSink: @unchecked Sendable, DisplayPreviewSink { + private let latestBuffer = Mutex(nil) + + nonisolated func submitFrame(_ sampleBuffer: CMSampleBuffer) { + latestBuffer.withLock { $0 = TestSendableSampleBuffer(value: sampleBuffer) } + } + + nonisolated func snapshot() -> CMSampleBuffer? { + latestBuffer.withLock { $0?.value } + } +} + +private final class CountingPreviewSink: @unchecked Sendable, DisplayPreviewSink { + private let submissionCount = Mutex(0) + + nonisolated func submitFrame(_ sampleBuffer: CMSampleBuffer) { + _ = sampleBuffer + submissionCount.withLock { $0 += 1 } + } + + nonisolated func snapshot() -> Int { + submissionCount.withLock { $0 } + } +} + +@MainActor +@Suite(.serialized) +struct DisplaySampleFanoutTests { + @Test func detachPreventsQueuedFrameDelivery() async throws { + let sampleBuffer = try await makeSampleBuffer() + let fanout = DisplaySampleFanout() + let sink = CountingPreviewSink() + fanout.willStartDrainForTesting = { + fanout.detachPreviewSink(sink) + } + fanout.attachPreviewSink(sink) + + fanout.publishPreviewFrame(sampleBuffer) + + let noLateDelivery = await staysTrue(timeout: .milliseconds(100)) { + sink.snapshot() == 0 + } + #expect(noLateDelivery) + } + + private func makeSampleBuffer() async throws -> CMSampleBuffer { + let session = try UITestCapturePreviewSession( + configuration: .init( + sourcePixelSize: CGSize(width: 64, height: 64), + targetContentWidth: nil, + replayImageURL: nil, + recordDirectoryURL: nil, + initialScaleMode: nil + ) + ) + let sink = SampleBufferCaptureSink() + session.attachPreviewSink(sink) + let captured = await waitUntil { sink.snapshot() != nil } + #expect(captured) + return try #require(sink.snapshot()) + } + + private func waitUntil( + timeout: Duration = .seconds(1), + condition: @escaping @Sendable () async -> Bool + ) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now + timeout + while clock.now < deadline { + if await condition() { + return true + } + await Task.yield() + } + return await condition() + } + + private func staysTrue( + timeout: Duration, + condition: @escaping @Sendable () async -> Bool + ) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now + timeout + while clock.now < deadline { + if await condition() == false { + return false + } + await Task.yield() + } + return await condition() + } +} diff --git a/VoidDisplayTests/Features/Capture/Views/ZeroCopyPreviewRendererTests.swift b/VoidDisplayTests/Features/Capture/Views/ZeroCopyPreviewRendererTests.swift new file mode 100644 index 0000000..b610bde --- /dev/null +++ b/VoidDisplayTests/Features/Capture/Views/ZeroCopyPreviewRendererTests.swift @@ -0,0 +1,109 @@ +import CoreGraphics +import Testing +@testable import VoidDisplay + +@MainActor +@Suite(.serialized) +struct ZeroCopyPreviewRendererTests { + @Test func submitFrameRendersAndPublishesMetrics() async throws { + let renderer = ZeroCopyPreviewRenderer() + let session = try UITestCapturePreviewSession( + configuration: .init( + sourcePixelSize: CGSize(width: 640, height: 360), + targetContentWidth: nil, + replayImageURL: nil, + recordDirectoryURL: nil, + initialScaleMode: nil + ) + ) + + session.attachPreviewSink(renderer) + + let rendered = await waitUntil { + await MainActor.run { + renderer.metricsSnapshot().renderedFrameCount == 1 + } + } + + let metrics = renderer.metricsSnapshot() + #expect(rendered) + #expect(renderer.hasReceivedFrame) + #expect(renderer.framePixelSize == CGSize(width: 640, height: 360)) + #expect(metrics.receivedFrameCount == 1) + #expect(metrics.droppedFrameCount == 0) + #expect(metrics.pendingSlotOccupied == false) + } + + @Test func latestFrameSlotDropsSupersededFrames() async throws { + let renderer = ZeroCopyPreviewRenderer() + let session = try UITestCapturePreviewSession( + configuration: .init( + sourcePixelSize: CGSize(width: 800, height: 450), + targetContentWidth: nil, + replayImageURL: nil, + recordDirectoryURL: nil, + initialScaleMode: nil + ) + ) + + session.attachPreviewSink(renderer) + session.attachPreviewSink(renderer) + + let settled = await waitUntil { + await MainActor.run { + let metrics = renderer.metricsSnapshot() + return metrics.receivedFrameCount == 2 && metrics.renderedFrameCount >= 1 + } + } + + let metrics = renderer.metricsSnapshot() + #expect(settled) + #expect(metrics.droppedFrameCount >= 1) + } + + @Test func flushDropsFrameDequeuedBeforeEnqueue() async throws { + let renderer = ZeroCopyPreviewRenderer() + renderer.willEnqueueFrameForTesting = { + renderer.willEnqueueFrameForTesting = nil + renderer.flush() + } + let session = try UITestCapturePreviewSession( + configuration: .init( + sourcePixelSize: CGSize(width: 1024, height: 576), + targetContentWidth: nil, + replayImageURL: nil, + recordDirectoryURL: nil, + initialScaleMode: nil + ) + ) + + session.attachPreviewSink(renderer) + + let settled = await waitUntil { + await MainActor.run { + let metrics = renderer.metricsSnapshot() + return metrics.receivedFrameCount == 1 && metrics.pendingSlotOccupied == false + } + } + + let metrics = renderer.metricsSnapshot() + #expect(settled) + #expect(metrics.renderedFrameCount == 0) + #expect(renderer.hasReceivedFrame == false) + } + + private func waitUntil( + timeout: Duration = .seconds(1), + condition: @escaping @Sendable () async -> Bool + ) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now + timeout + while clock.now < deadline { + if await condition() { + return true + } + await Task.yield() + } + return await condition() + } +} From 3de3bca8ce8239dffb4ab81b41a0a3389b0f2997 Mon Sep 17 00:00:00 2001 From: Chen Date: Sat, 28 Mar 2026 09:20:13 +0800 Subject: [PATCH 15/34] =?UTF-8?q?docs(capture):=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E9=87=87=E9=9B=86=E6=9C=8D=E5=8A=A1=E6=8B=86=E5=88=86=E5=90=8E?= =?UTF-8?q?=E7=9A=84=E6=96=87=E4=BB=B6=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新 readme 中的调试入口文件 - 同步中文文档引用路径 - 修正文档里的旧单体文件名 --- Readme.md | 2 +- docs/Readme_cn-zh.md | 2 +- docs/capture_sharing_optimization_plan.md | 465 ++++++++++++++++++++++ 3 files changed, 467 insertions(+), 2 deletions(-) create mode 100644 docs/capture_sharing_optimization_plan.md diff --git a/Readme.md b/Readme.md index 9e286f0..361afdc 100644 --- a/Readme.md +++ b/Readme.md @@ -139,7 +139,7 @@ Key files for debugging: | Area | Files | |------|-------| | Virtual Display | `VirtualDisplayService.swift`, `CreateVirtualDisplayObjectView.swift`, `EditVirtualDisplayConfigView.swift` | -| Screen Capture | `CaptureChooseViewModel.swift`, `ScreenCaptureFunction.swift` | +| Screen Capture | `CaptureChooseViewModel.swift`, `DisplayCaptureRegistry.swift`, `DisplayCaptureSession.swift`, `DisplayStartCoordinator.swift` | | LAN Sharing | `ShareViewModel.swift`, `SharingService.swift`, `Features/Sharing/Web/WebServer.swift` | Unified logs (`Logger`, subsystem `com.developerchen.voiddisplay`): diff --git a/docs/Readme_cn-zh.md b/docs/Readme_cn-zh.md index 115917d..6dc0323 100644 --- a/docs/Readme_cn-zh.md +++ b/docs/Readme_cn-zh.md @@ -127,7 +127,7 @@ UI 入口:`HomeView` 包含四个标签页 — **显示器**、**虚拟显示 | 功能区域 | 文件 | |---------|------| | 虚拟显示器 | `VirtualDisplayService.swift`、`CreateVirtualDisplayObjectView.swift`、`EditVirtualDisplayConfigView.swift` | -| 屏幕采集 | `CaptureChooseViewModel.swift`、`ScreenCaptureFunction.swift` | +| 屏幕采集 | `CaptureChooseViewModel.swift`、`DisplayCaptureRegistry.swift`、`DisplayCaptureSession.swift`、`DisplayStartCoordinator.swift` | | 局域网共享 | `ShareViewModel.swift`、`SharingService.swift`、`Features/Sharing/Web/WebServer.swift` | 统一日志(`Logger`,subsystem `com.developerchen.voiddisplay`): diff --git a/docs/capture_sharing_optimization_plan.md b/docs/capture_sharing_optimization_plan.md new file mode 100644 index 0000000..00efbbb --- /dev/null +++ b/docs/capture_sharing_optimization_plan.md @@ -0,0 +1,465 @@ +# 屏幕监听与屏幕共享优化方案 + +> 记录日期:2026-03-25 +> 适用范围:`VoidDisplay/Features/Capture`、`VoidDisplay/Features/Sharing`、`VoidDisplay/App`、`VoidDisplay/Shared/ScreenCapture` +> 分析基线:当前工作区实现,包含本地未提交改动 + +## 1. 目标 + +这份文档解决两个问题: + +1. 当前屏幕监听与屏幕共享已经在底层采集层合流,上层状态与目录管理仍然分裂,导致重复加载、状态分叉、恢复路径复杂。 +2. 当前采集、预览、WebRTC 发送三段规格不一致,造成资源浪费与稳定性风险。 + +本轮优化目标如下: + +1. 收敛屏幕目录、权限、拓扑状态,建立单一真相源。 +2. 把采集配置变成按消费方动态决策的策略层。 +3. 给本地预览链路补齐背压与丢帧控制,避免主线程被高频帧淹没。 +4. 让共享连接状态改成事件驱动,去掉 UI 轮询。 +5. 修正网页端 WebRTC 配置缺口,贯通服务端与浏览器端的 ICE/TURN 配置。 +6. 补足监听与共享联合负载场景的测试覆盖。 +7. 给关键指标补量化验收门槛,并保证自动化测试保持非交互执行。 + +## 2. 当前问题归因 + +### 2.1 目录与权限状态分裂 + +`CaptureController` 和 `SharingController` 各自持有一份 `ScreenCaptureDisplayCatalogState`。 + +相关位置: + +1. `VoidDisplay/App/CaptureController.swift` +2. `VoidDisplay/App/SharingController.swift` +3. `VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift` +4. `VoidDisplay/Features/Sharing/ViewModels/ShareViewModel.swift` +5. `VoidDisplay/Shared/ScreenCapture/ScreenCaptureDisplayCatalogLoader.swift` + +后果: + +1. 监听页与共享页会分别触发 `SCShareableContent` 加载。 +2. 权限状态与最近一次加载错误会分叉。 +3. 页面切换后可能出现一边已刷新、一边仍持有旧目录的情况。 + +### 2.2 采集规格与发送规格失配 + +当前链路表现如下: + +1. `DisplayCaptureSession` 采集帧率按显示器刷新率设置,最高到 `120fps`。 +2. WebRTC media pipeline 适配输出格式时固定为 `60fps`。 +3. RTP sender 编码参数又把最高帧率压到 `30fps`。 + +相关位置: + +1. `VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift` +2. `VoidDisplay/Features/Capture/Services/DisplayCaptureRegistry.swift` +3. `VoidDisplay/Features/Sharing/Web/WebRTCSessionHub.swift` + +后果: + +1. 采集负载高于实际发送需要。 +2. 编码端和采集端之间存在长期浪费。 +3. 当监听和共享并存时,单一路径很难兼顾本地预览流畅度与网络稳定性。 + +### 2.3 本地预览链路没有背压 + +当前预览帧 fanout 方式是逐帧同步派发,`ZeroCopyPreviewRenderer` 对每一帧再创建一个 `Task` 切到 `MainActor`。 + +相关位置: + +1. `VoidDisplay/Features/Capture/Services/DisplaySampleFanout.swift` +2. `VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift` +3. `VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift` + +后果: + +1. 高频帧输入时,主线程任务可能堆积。 +2. 多个预览 sink 并存时,慢 sink 会拖累整条链路。 +3. 监听窗口压力会反向影响共享稳定性。 + +### 2.4 共享状态传播仍是轮询模型 + +共享页通过每秒定时器刷新观看人数和 target 计数。 + +相关位置: + +1. `VoidDisplay/Features/Sharing/Views/ShareView.swift` +2. `VoidDisplay/App/SharingController.swift` +3. `VoidDisplay/Features/Sharing/Web/WebServer.swift` +4. `VoidDisplay/Features/Sharing/Web/WebRTCSessionHub.swift` + +后果: + +1. UI 层存在固定频率的无效刷新。 +2. 状态变化不能即时反馈。 +3. target 客户端数与真实连接状态可能短时失真。 + +### 2.5 浏览器端 WebRTC 配置不完整 + +服务端已经预留 ICE server 配置扩展点,网页端仍然硬编码 `iceServers: []`。 + +相关位置: + +1. `VoidDisplay/Features/Sharing/Web/WebRTCSessionHub.swift` +2. `VoidDisplay/Features/Sharing/Web/displayPage.html` + +后果: + +1. 当前共享能力仍然依赖 host candidate 与局域网入口。 +2. 配置扩展只到达服务端,没有贯通到浏览器端。 + +### 2.6 拓扑刷新路径两套实现 + +共享页有 `ShareViewLifecycleController`、去抖回调注册与轮询回退;监听页仍然主要依赖 `onAppear` 和系统通知。 + +相关位置: + +1. `VoidDisplay/Features/Sharing/Views/ShareViewLifecycleController.swift` +2. `VoidDisplay/Features/Capture/Views/CaptureChoose.swift` +3. `VoidDisplay/Shared/Services/DebouncingDisplayReconfigurationMonitor.swift` + +后果: + +1. 同一类显示拓扑问题维护两套逻辑。 +2. 监听页的恢复与去抖策略弱于共享页。 + +## 3. 设计原则 + +后续重构必须遵守下面几条: + +1. 目录、权限、拓扑签名只保留一个 app 级真相源。 +2. 采集配置由消费需求决定,不能写死在单一场景。 +3. 预览链路必须允许丢帧,优先保留最新帧。 +4. 共享状态必须事件驱动,避免 UI 自行轮询补状态。 +5. 监听与共享可以共用底层采集,但上层读模型要继续隔离。 +6. 每轮结构调整都要补联合场景测试,不能只测单一模块。 +7. 自动化测试必须非交互,不能触发屏幕录制授权弹窗。 +8. 关键路径要同时定义观测指标、验收门槛和回退条件。 +9. 原始 display catalog snapshot 只表达权限、拓扑和显示器列表,不能承载页面 gating。 +10. 性能型重配与光标显隐这类正确性配置要分层处理。 +11. 共享统计必须明确口径,至少区分 signaling 连接数与稳定收流数。 + +## 4. 分阶段执行方案 + +### 阶段 1:收敛屏幕目录、权限、拓扑状态 + +新增 app 级 `ScreenCaptureCatalogService`,内部拆成 `CatalogStore` 与 `CatalogRefreshCoordinator`,统一管理: + +1. 权限状态 +2. `SCDisplay` 列表 +3. 最近一次加载错误 +4. 拓扑签名 +5. in-flight load task +6. 刷新去抖与取消 + +执行约束: + +1. service 使用 `actor` 串行化刷新请求。 +2. 刷新入口只接受明确意图,例如 `permissionChanged`、`topologyChanged`、`serviceBecameRunning`、`userForcedRefresh`。 +3. 主去重键只包含权限状态、当前拓扑签名和是否为强制刷新。 +4. 只有当前 request 才能提交 display snapshot 与 load error,过期结果直接丢弃。 +5. 新意图覆盖旧意图时取消旧 task;权限丢失时允许主动清空 snapshot;服务停止只取消共享页相关 refresh,不清空全局 snapshot。 +6. `refresh intent` 只用于优先级、取消策略和日志,不作为是否复用 snapshot 的判定条件。 +7. 每个 refresh intent 都必须产出一个结果事件,结果类型至少区分 `reloadedSnapshot`、`reusedSnapshot`、`clearedSnapshot`、`failed`。 + +调整方向: + +1. `CaptureChooseViewModel` 改为订阅 store,并提交监听页自己的 refresh intent。 +2. `ShareViewModel` 改为订阅 store,并只在服务可用时提交共享页自己的 refresh intent。 +3. 共享页的 `serviceStopped` 等页面状态继续由共享页派生状态决定,不通过清空全局 catalog 实现。 +4. 页面私有 lifecycle 只保留触发时机与 fallback 策略,不再持有独立 loader 状态。 +5. 让监听页和共享页共享同一份原始 display snapshot。 +6. 共享页在订阅建立、`serviceBecameRunning`、`reusedSnapshot` 命中时,都必须用当前 snapshot 重放一次 `registerShareableDisplays`。 + +预期收益: + +1. 减少重复 `SCShareableContent` 调用。 +2. 消除权限状态分叉。 +3. 简化目录刷新后的状态收敛。 + +### 阶段 2:建立采集 profile 策略层 + +在 `DisplayCaptureRegistry` 之上增加 profile 决策,至少区分: + +1. `previewOnly` +2. `shareOnly` +3. `mixed` + +profile 输入建议包含: + +1. preview sink 数量 +2. 当前 display 是否处于 `sharingActive`,定义为该 display 已建立 sharing session 且 session 尚未停止 +3. 显示器分辨率与刷新率上限 +4. 上一次稳定 profile 与最近一次切换时间 + +profile 输出建议包含: + +1. 采集尺寸 +2. 采集帧率 +3. `queueDepth` +4. 本次是否允许调用 `updateConfiguration` + +建议策略: + +1. `previewOnly` 优先本地流畅度与低切换成本。 +2. `shareOnly` 优先编码稳定性和带宽可控。 +3. `mixed` 在可接受清晰度下限制帧率,避免高刷浪费。 + +约束: + +1. profile 只允许在 `previewOnly`、`shareOnly`、`mixed` 三个稳定状态之间切换。 +2. 切换规则必须同时定义进入阈值、退出阈值、最小驻留时间。 +3. profile 型 `updateConfiguration` 最大重配频率限制为每 `5s` 不超过 `1` 次。 +4. 光标显隐走独立的 cursor override 通道,允许即时生效,不受 profile 驻留时间与重配频率门槛限制。 +5. `streamingPeers` 只作为观测指标,当前阶段不直接驱动 `updateConfiguration`。 +6. `signalingConnections`、协商失败、重连抖动都不能单独驱动 profile 切换。 + +### 阶段 3:给预览链路加背压与观测 + +优化方向: + +1. `DisplaySampleFanout` 为每个 sink 提供有界邮箱。 +2. 默认容量建议 `1` 或 `2`。 +3. 新帧到达时覆盖旧帧,保留最新帧。 +4. `ZeroCopyPreviewRenderer` 改为“最新帧槽位 + 单 drain loop”模型。 +5. 主线程只负责 dequeue 与 layer enqueue,不再为每一帧创建无界 `Task`。 + +新增指标: + +1. 收到帧数 +2. 渲染帧数 +3. 丢弃帧数 +4. 最近渲染延迟 +5. renderer 待处理帧槽位占用情况 + +预期收益: + +1. 高刷输入下不再无限堆积主线程任务。 +2. 慢预览 sink 不再拖垮整体链路。 + +### 阶段 4:共享状态改为事件驱动 + +优化方向: + +1. `WebServer` 只在 websocket upgrade 成功且连接通过容量校验后分配 `clientID`,并向服务层发出带 `target` 与 `clientID` 的 signaling 生命周期事件。 +2. `WebRTCSessionHub` 只消费 `WebServer` 分配的 `clientID`,并向服务层发出同一 `clientID` 的 peer phase 事件。 +3. `SharingService` 或 `WebServiceController` 暴露统一的共享统计快照订阅面。 +4. 快照至少包含 `signalingConnections`、`streamingPeers`、`signalingConnectionsByTarget`、`streamingPeersByTarget` 与时间戳。 +5. 原始事件载荷至少包含 `target`、`clientID`、`peerPhase`、事件来源、时间戳。 +6. peer phase 至少定义 `signalingConnected`、`offerReceived`、`peerConnected`、`peerDisconnected`、`peerFailed`、`closed`。 +7. 服务层必须维护以 `clientID` 为键的聚合状态,字段至少包含 `target`、`hasSignalingConnection`、`peerPhase`、最近更新时间戳。 +8. `signalingConnections` 与 `streamingPeers` 只能由聚合状态投影得到,不能直接按原始事件做加减。 +9. `streamingPeers` 只统计处于 `peerConnected` 的 client;进入 `peerDisconnected`、`peerFailed`、`closed` 时必须从聚合状态移出或降级。 +10. 同一 websocket 断开后再次连接时必须分配新的 `clientID`;重复断连、失败、关闭事件必须按 `clientID` 幂等处理。 +11. 被 `too_many_viewers` 或其他准入校验拒绝的连接不得分配 `clientID`,也不得计入 `signalingConnections`、`streamingPeers` 或其 per-target 计数;如需观测,单列 rejected attempt 指标。 +12. 订阅建立时立即返回当前聚合快照,随后再接收增量事件。 +13. `WebRTCSessionHub` 与 `WebServer` 只向服务层提供原始事件,不直接作为 UI 真相源。 +14. `SharingController` 直接订阅服务快照;状态面板使用全局 `streamingPeers`,每个 display 行使用 `streamingPeersByTarget[target]`,诊断视图按需读取 `signalingConnections` 与 `signalingConnectionsByTarget`。 +15. 去掉 `ShareView` 的每秒轮询定时器。 + +预期收益: + +1. UI 状态更新更及时。 +2. 共享统计口径更清楚,UI 可以区分信令连接与稳定收流。 +3. 界面刷新频率下降。 + +### 阶段 5:补齐浏览器端 WebRTC 配置 + +优化方向: + +1. 用同一份 bootstrap 配置同时驱动服务端与网页端的 ICE server。 +2. 网页端缺少该字段时默认回退到空数组,保持当前 host candidate 行为。 +3. 当前阶段只处理可配置 ICE/TURN;公网入口、TLS、认证另开子计划。 +4. 明确 `stopped`、`error`、`disconnected`、`failed` 的终态和重连边界。 +5. 保持现有 signaling message type,不在本轮引入协议版本分叉。 + +可选增强: + +1. 页面展示连接状态、分辨率、`signalingConnections` / `streamingPeers` 状态。 +2. 页面区分“服务已停止”和“网络暂时断开”。 + +### 阶段 6:统一拓扑监听机制并补联合压测 + +优化方向: + +1. 监听页复用共享页的拓扑监听与回退策略。 +2. 统一拓扑变化后的执行顺序: + 1. catalog 刷新 + 2. sharing registration 更新 + 3. 已失效 session 收敛 + 4. UI 读模型刷新 + +补测重点: + +1. 监听已开,再开启共享。 +2. 共享中增加多个 `streamingPeers`。 +3. 拓扑变化时 registry 仍保持一致。 +4. 服务重启后目录、sharing registration 与页面派生状态正确恢复,先前活跃 sharing session 保持停止态,不自动恢复。 + +## 5. 推荐实施顺序 + +推荐顺序如下: + +1. `ScreenCaptureCatalogService` +2. 采集 profile 策略层 +3. 预览背压 +4. 共享状态事件化 +5. ICE 与网页端策略 +6. 联合压测与观测 + +原因: + +1. 状态收敛是后续所有优化的基础。 +2. 采集规格决策和预览背压直接决定性能上限。 +3. 事件化与网页端调整建立在较稳定的服务层语义之上。 +4. 量化验收要建立在可观测的稳定实现之上。 + +## 6. 风险评估 + +### 6.1 结构风险 + +1. catalog service 收敛后,页面级测试会有一批需要改写。 +2. 采集 profile 动态切换会引入黑帧或短时抖动风险。 +3. 共享状态事件化后,部分 UI 依赖的轮询时序会发生变化。 +4. 如果把页面策略全部吞进全局 service,复杂度可能继续上升。 + +### 6.2 实现风险 + +1. `SCStream.updateConfiguration` 频繁调用可能带来额外不稳定性。 +2. preview sink 丢帧策略若设计不当,可能出现“窗口卡住但后台仍在刷新”的假象。 +3. ICE 配置下发涉及网页协议面,需防止旧页面行为退化。 +4. catalog 刷新并发语义若定义不清,可能出现过期结果覆盖新结果。 + +## 7. 验证矩阵 + +### 7.1 单元测试 + +新增或补强以下测试: + +1. catalog service 的去重、取消、过期结果丢弃测试 +2. 服务停止不会清空全局 catalog snapshot,且共享页仍能进入 `serviceStopped` 派生状态测试 +3. 监听页与共享页共享 snapshot 的状态收敛测试 +4. `reusedSnapshot` 结果事件会触发共享注册重放测试 +5. profile 状态机测试,覆盖进入阈值、退出阈值、最小驻留时间、最大重配频率 +6. `streamingPeers` 突发变化不会直接触发配置抖动测试 +7. signaling 已连通但协商失败、`streamingPeers = 0` 时不切换 profile 测试 +8. cursor override 不受 profile 节流限制且仍能即时生效测试 +9. preview 背压与 renderer 单消费者测试 +10. 共享统计订阅“先给当前快照,再给增量事件”测试 +11. 共享统计快照同时包含全局与 per-target 两组计数测试 +12. 同一 target 断开后重连会生成新的 `clientID`,重复断连事件仍保持幂等测试 +13. 超出 viewer 上限的连接会被拒绝,且不会生成 `clientID` 或进入共享统计快照测试 +14. sharing 原始事件载荷包含 `target`、`clientID`、`peerPhase` 测试 +15. ICE bootstrap 配置存在与缺失两种路径测试 + +### 7.2 集成测试 + +重点补以下组合场景: + +1. 监听窗口已开,再开启共享 +2. 共享中连接多个 `streamingPeers` +3. 共享中发生显示拓扑变化 +4. 停止共享、停止服务、重新启动服务 +5. 监听与共享共用同一 display catalog +6. 自动化测试必须使用测试权限 provider,执行期间不得触发系统屏幕录制授权弹窗 +7. 两个 target 同时共享时,`streamingPeersByTarget` 与 `signalingConnectionsByTarget` 不串扰 +8. 网页端收到 `stopped` 后进入终态且不再重连 +9. 网页端遇到 `disconnected` 或 `failed` 时进入退避重连 +10. 网页端收到 `error` 时 overlay 与连接清理行为符合文档约束 +11. viewer 超限时连接请求被拒绝,`signalingConnections` 不增加 + +### 7.3 基线采样 + +只在固定基准机上记录当前基线,再做优化后对比: + +环境约束: + +1. 固定一台代表机型,记录 CPU 型号、内存、macOS 版本、显示器规格。 +2. 使用 `Release` 构建与同一套网络环境。 +3. 每个场景预热 `10s` 后再采样 `60s`。 +4. 采样工具、日志路径与统计口径在实施前固定,后续对比沿用同一协议。 + +采样场景: + +1. `previewOnly`,单预览窗口 +2. `shareOnly`,单分享目标与单 `streamingPeer` +3. `mixed`,单预览窗口与两个 `streamingPeers` + +记录项: + +1. 进程 CPU 中位数 +2. `SCShareableContent` 加载次数 +3. `profileReconfigurationCount` +4. `cursorOverrideReconfigurationCount` +5. preview 渲染延迟 `p95` +6. preview 丢帧率 + +### 7.4 验收门槛 + +固定基准机上的通过条件建议写成硬门槛: + +1. 在权限状态与拓扑签名均未变化、且没有 `userForcedRefresh` 的前提下,`SCShareableContent` 加载次数不超过 `1` 次。 +2. 稳定状态下 `profileReconfigurationCount` 为 `0`;任意 `5s` 窗口内不超过 `1` 次。 +3. `streamingPeers` 在 `10s` 内从 `0 -> 3 -> 0` 波动时,`profileReconfigurationCount` 不超过 `1` 次。 +4. `previewOnly` 场景 preview 渲染延迟 `p95 <= 120ms`,`mixed` 场景 `p95 <= 180ms`。 +5. `previewOnly` 场景丢帧率不超过 `10%`,`mixed` 场景不超过 `20%`。 +6. `mixed` 场景进程 CPU 中位数不得高于基线 `5%`,目标下降 `10%`。 +7. `cursorOverrideReconfigurationCount` 单独记录,不计入 profile 频控门槛。 + +CI 与常规本地验证只检查稳定不变量: + +1. 去重、取消、过期结果丢弃语义正确。 +2. `profileReconfigurationCount` 频率限制与 `cursorOverrideReconfigurationCount` 例外规则正确。 +3. 共享统计快照包含 signaling 与 streaming 的全局计数和 per-target 计数。 +4. 自动化测试全程不触发屏幕录制授权弹窗。 + +### 7.5 运行时观测与回退 + +建议新增日志或指标: + +1. display catalog 加载次数 +2. catalog 复用次数 +3. `profileReconfigurationCount` +4. `cursorOverrideReconfigurationCount` +5. profile 切换次数 +6. preview 丢帧数 +7. 当前 `streamingPeers` +8. 当前 `signalingConnections` +9. 当前 session 采集规格 + +回退条件: + +1. `previewOnly` 或 `mixed` 连续 `3` 个观测窗口超出延迟门槛时,自动降到更保守的 fps 档位。 +2. 连续 `3` 个观测窗口超出丢帧率门槛时,记录告警并降级 profile。 +3. 若 CPU 无法满足门槛,保留观测日志并暂停默认启用更激进的 profile 规则。 + +## 8. 建议的交付拆分 + +为了控制回归范围,建议拆成三批提交: + +1. 状态收敛层 + - `ScreenCaptureCatalogService` + - `CatalogStore` / `CatalogRefreshCoordinator` + - 监听页与共享页目录状态统一 +2. 采集与预览性能层 + - profile 状态机 + - preview 背压、renderer drain loop 与量化门槛 +3. 共享协议与联合验证层 + - 快照驱动的共享状态传播 + - ICE bootstrap 配置下发与兼容 + - 联合测试、权限隔离与验收补齐 + +## 9. 当前结论 + +如果只选最值得优先落地的三项,建议先做: + +1. `ScreenCaptureCatalogService` +2. 采集 profile 状态机 +3. preview 背压与 renderer 单消费者模型 + +原因很直接: + +1. 这三项决定后续重构是否能在更低复杂度下推进。 +2. 它们能同时改善重复加载、资源浪费和界面稳定性。 +3. 它们对监听与共享两条链路都会立刻产生收益。 +4. 它们也是后续量化验收与回退策略的基础。 From a42cc95d445073dda9584e666d941e23d26bab42 Mon Sep 17 00:00:00 2001 From: Chen Date: Sat, 28 Mar 2026 12:36:44 +0800 Subject: [PATCH 16/34] =?UTF-8?q?fix(sharing):=20=E6=94=B6=E6=95=9B=20view?= =?UTF-8?q?er=20=E5=87=86=E5=85=A5=E4=B8=8E=E6=B5=8B=E8=AF=95=E5=91=BD?= =?UTF-8?q?=E4=B8=AD=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clientID 改为通过容量校验后分配并在拒绝路径保持无分配\n- 修复网页端 terminalStop 可变状态防止运行时异常\n- 新增 xcresult 命中数量守卫并接入 UI smoke 与预览自检 --- .../workflows/_reusable-ui-smoke-tests.yml | 7 + .../Sharing/Web/WebRTCSessionHub.swift | 166 ++++++++++++++++-- .../Features/Sharing/Web/WebServer.swift | 32 +++- .../Features/Sharing/Web/displayPage.html | 23 ++- .../Sharing/Web/WebRTCSessionHubTests.swift | 150 +++++++++++++++- scripts/test/capture_preview_self_check.sh | 4 + scripts/test/xcresult_test_count_guard.sh | 66 +++++++ 7 files changed, 418 insertions(+), 30 deletions(-) create mode 100755 scripts/test/xcresult_test_count_guard.sh diff --git a/.github/workflows/_reusable-ui-smoke-tests.yml b/.github/workflows/_reusable-ui-smoke-tests.yml index 2819ff5..7c17079 100644 --- a/.github/workflows/_reusable-ui-smoke-tests.yml +++ b/.github/workflows/_reusable-ui-smoke-tests.yml @@ -160,6 +160,13 @@ jobs: set -e if [ "$status" -eq 0 ]; then + if ! bash scripts/test/xcresult_test_count_guard.sh \ + --xcresult UISmokeTests.xcresult \ + --label "UI smoke attempt ${attempt}/${max_attempts}"; then + write_summary "failed" "selector_mismatch" "$attempt" "$log_file" + echo "UI smoke selector mismatch detected (0 tests executed)." + exit 1 + fi write_summary "passed" "none" "$attempt" "$log_file" exit 0 fi diff --git a/VoidDisplay/Features/Sharing/Web/WebRTCSessionHub.swift b/VoidDisplay/Features/Sharing/Web/WebRTCSessionHub.swift index 249df41..d4edcad 100644 --- a/VoidDisplay/Features/Sharing/Web/WebRTCSessionHub.swift +++ b/VoidDisplay/Features/Sharing/Web/WebRTCSessionHub.swift @@ -1,6 +1,7 @@ import CoreVideo import Foundation import Network +import OSLog import Synchronization #if canImport(WebRTC) @@ -73,13 +74,21 @@ nonisolated private struct SignalingOutboundMessage: Encodable { } final class WebRTCSessionHub: Sendable { + enum AddClientResult: Sendable, Equatable { + case accepted(clientID: String) + case rejected(reason: String) + } + nonisolated struct PeerCallbacks: Sendable { let onAnswer: @Sendable (String) -> Void let onLocalCandidate: @Sendable (_ sdp: String, _ sdpMid: String?, _ sdpMLineIndex: Int32) -> Void + let onConnected: @Sendable () -> Void let onFailure: @Sendable (String) -> Void let onDisconnected: @Sendable () -> Void } + typealias SharingEventSink = @Sendable (SharingSessionEvent) -> Void + typealias PeerFactory = @Sendable (PeerCallbacks) -> (any WebRTCPeerSessioning)? private nonisolated struct QueuedSignal: Sendable { @@ -89,6 +98,10 @@ final class WebRTCSessionHub: Sendable { private nonisolated struct ClientState { nonisolated(unsafe) let connection: any SignalSocketConnection + let clientID: String + let target: ShareTarget + let eventSink: SharingEventSink + var nextEventSequence: UInt64 = 0 var isSending = false var pendingSignals: [QueuedSignal] = [] var peer: (any WebRTCPeerSessioning)? @@ -124,6 +137,7 @@ final class WebRTCSessionHub: Sendable { Int32(candidate.sdpMLineIndex) ) }, + onConnected: callbacks.onConnected, onFailure: callbacks.onFailure, onDisconnected: callbacks.onDisconnected ) @@ -146,38 +160,63 @@ final class WebRTCSessionHub: Sendable { state.withLock { $0.onDemandChanged = onDemandChanged } } - nonisolated func addClient(_ connection: any SignalSocketConnection) { + nonisolated func addClient( + _ connection: any SignalSocketConnection, + target: ShareTarget, + makeClientID: @escaping @Sendable () -> String = { UUID().uuidString }, + eventSink: @escaping SharingEventSink + ) -> AddClientResult { let key = ObjectIdentifier(connection as AnyObject) - let (added, shouldSignalDemand, callback) = state.withLock { state -> (Bool, Bool, @Sendable (Bool) -> Void) in + let (result, acceptedClientID, shouldSignalDemand, callback) = state.withLock { + state -> (AddClientResult, String?, Bool, @Sendable (Bool) -> Void) in guard state.clients.count < maxClients else { - return (false, false, state.onDemandChanged) + return (.rejected(reason: "too_many_viewers"), nil, false, state.onDemandChanged) } let wasEmpty = state.clients.isEmpty - state.clients[key] = ClientState(connection: connection) - return (true, wasEmpty, state.onDemandChanged) + let clientID = makeClientID() + state.clients[key] = ClientState( + connection: connection, + clientID: clientID, + target: target, + eventSink: eventSink + ) + return (.accepted(clientID: clientID), clientID, wasEmpty, state.onDemandChanged) } - if !added { - send( - message: SignalingOutboundMessage(type: .error, reason: "too_many_viewers"), - to: connection, - completion: nil - ) - connection.cancelSocket() - return + guard case .accepted = result, let clientID = acceptedClientID else { + return result } if shouldSignalDemand { callback(true) } + emitEvent( + SharingSessionEvent( + target: target, + clientID: clientID, + sequence: nextEventSequence(for: key), + phase: .signalingConnected, + source: .webSocket + ), + for: key + ) send(message: SignalingOutboundMessage(type: .ready), to: connection, completion: nil) + return .accepted(clientID: clientID) } nonisolated func removeClient(_ connection: any SignalSocketConnection) { removeClient(for: ObjectIdentifier(connection as AnyObject), cancelConnection: false) } + nonisolated func sendRejection(reason: String, to connection: any SignalSocketConnection) { + send( + message: SignalingOutboundMessage(type: .error, reason: reason), + to: connection, + completion: nil + ) + } + nonisolated func disconnectAllClients() { let keys = state.withLock { Array($0.clients.keys) } for key in keys { @@ -237,6 +276,7 @@ final class WebRTCSessionHub: Sendable { send(message: SignalingOutboundMessage(type: .error, reason: "missing_offer_sdp"), to: key) return } + emitEvent(phase: .offerReceived, source: .peerConnection, for: key) ensurePeer(for: key)?.handleRemoteOffer(sdp: sdp) case .iceCandidate: guard let candidate = message.candidate else { @@ -280,11 +320,18 @@ final class WebRTCSessionHub: Sendable { to: key ) }, + onConnected: { [weak self] in + self?.emitEvent(phase: .peerConnected, source: .peerConnection, for: key) + }, onFailure: { [weak self] reason in - self?.send(message: SignalingOutboundMessage(type: .error, reason: reason), to: key) + self?.emitEvent(phase: .peerFailed, source: .peerConnection, for: key) + AppLog.web.warning( + "WebRTC peer failed; closing signaling socket to trigger reconnect (reason: \(reason, privacy: .public))." + ) self?.removeClient(for: key, cancelConnection: true) }, onDisconnected: { [weak self] in + self?.emitEvent(phase: .peerDisconnected, source: .peerConnection, for: key) self?.removeClient(for: key, cancelConnection: true) } ) @@ -440,6 +487,15 @@ final class WebRTCSessionHub: Sendable { } guard let removed else { return } + removed.eventSink( + SharingSessionEvent( + target: removed.target, + clientID: removed.clientID, + sequence: removed.nextEventSequence + 1, + phase: .closed, + source: .webSocket + ) + ) removed.peer?.close() if cancelConnection { removed.connection.cancelSocket() @@ -448,20 +504,83 @@ final class WebRTCSessionHub: Sendable { callback(false) } } -} -#if canImport(WebRTC) + nonisolated private func emitEvent( + _ event: SharingSessionEvent, + for key: ObjectIdentifier + ) { + let sink = state.withLock { $0.clients[key]?.eventSink } + sink?(event) + } + + nonisolated private func emitEvent( + phase: SharingPeerPhase, + source: SharingSessionEventSource, + for key: ObjectIdentifier + ) { + let payload = state.withLock { + state -> (target: ShareTarget, clientID: String, sequence: UInt64, sink: SharingEventSink)? in + guard var client = state.clients[key] else { return nil } + client.nextEventSequence += 1 + let sequence = client.nextEventSequence + state.clients[key] = client + return (client.target, client.clientID, sequence, client.eventSink) + } + guard let payload else { return } + payload.sink( + SharingSessionEvent( + target: payload.target, + clientID: payload.clientID, + sequence: payload.sequence, + phase: phase, + source: source + ) + ) + } -private enum WebRTCIceServerProvider { + nonisolated private func nextEventSequence(for key: ObjectIdentifier) -> UInt64 { + state.withLock { state -> UInt64 in + guard var client = state.clients[key] else { return 0 } + client.nextEventSequence += 1 + let sequence = client.nextEventSequence + state.clients[key] = client + return sequence + } + } +} + +enum WebRTCIceServerProvider { // Reserved extension point: defaults to host candidates only for LAN P2P. - nonisolated static func configuredServers() -> [RTCIceServer] { + nonisolated static func configuredURLStrings() -> [String] { guard let raw = ProcessInfo.processInfo.environment["VOIDDISPLAY_WEBRTC_ICE_SERVERS"] else { return [] } - let urls = raw + return raw .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } + } + + nonisolated static func browserBootstrapJSON() -> String { + let urls = configuredURLStrings() + let payload: [String: Any] = [ + "iceServers": urls.isEmpty ? [] : [["urls": urls]] + ] + guard JSONSerialization.isValidJSONObject(payload), + let data = try? JSONSerialization.data(withJSONObject: payload), + var json = String(data: data, encoding: .utf8) else { + return #"{"iceServers":[]}"# + } + json = json.replacingOccurrences(of: "", with: "<\\/script>") + return json + } +} + +#if canImport(WebRTC) + +private extension WebRTCIceServerProvider { + nonisolated static func configuredServers() -> [RTCIceServer] { + let urls = configuredURLStrings() guard !urls.isEmpty else { return [] } return [RTCIceServer(urlStrings: urls)] } @@ -545,6 +664,7 @@ private final class WebRTCPeerSession: NSObject, @unchecked Sendable, WebRTCPeer nonisolated(unsafe) private let peerConnection: RTCPeerConnection private let onAnswer: @Sendable (String) -> Void private let onLocalCandidate: @Sendable (RTCIceCandidate) -> Void + private let onConnected: @Sendable () -> Void private let onFailure: @Sendable (String) -> Void private let onDisconnected: @Sendable () -> Void @@ -552,6 +672,7 @@ private final class WebRTCPeerSession: NSObject, @unchecked Sendable, WebRTCPeer mediaPipeline: WebRTCMediaPipeline, onAnswer: @escaping @Sendable (String) -> Void, onLocalCandidate: @escaping @Sendable (RTCIceCandidate) -> Void, + onConnected: @escaping @Sendable () -> Void, onFailure: @escaping @Sendable (String) -> Void, onDisconnected: @escaping @Sendable () -> Void ) { @@ -559,6 +680,7 @@ private final class WebRTCPeerSession: NSObject, @unchecked Sendable, WebRTCPeer self.peerConnection = peerConnection self.onAnswer = onAnswer self.onLocalCandidate = onLocalCandidate + self.onConnected = onConnected self.onFailure = onFailure self.onDisconnected = onDisconnected super.init() @@ -636,12 +758,18 @@ extension WebRTCPeerSession: RTCPeerConnectionDelegate { nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) {} nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) {} nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCPeerConnectionState) { + if newState == .connected { + onConnected() + } if newState == .failed || newState == .closed || newState == .disconnected { onDisconnected() } } nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) { + if newState == .connected || newState == .completed { + onConnected() + } if newState == .failed || newState == .closed || newState == .disconnected { onDisconnected() } diff --git a/VoidDisplay/Features/Sharing/Web/WebServer.swift b/VoidDisplay/Features/Sharing/Web/WebServer.swift index 83b0ae7..a9994b0 100644 --- a/VoidDisplay/Features/Sharing/Web/WebServer.swift +++ b/VoidDisplay/Features/Sharing/Web/WebServer.swift @@ -94,6 +94,7 @@ final class WebServer { private struct ActiveConnection { let target: ShareTarget + let clientID: String let connection: NWConnection } @@ -104,6 +105,7 @@ final class WebServer { private var signalDecodersByConnectionKey: [ObjectIdentifier: WebSocketFrameDecoder] = [:] private let targetStateProvider: @MainActor @Sendable (ShareTarget) -> ShareTargetState private let sessionHubProvider: @MainActor @Sendable (ShareTarget) -> WebRTCSessionHub? + private let sharingEventSink: @Sendable (SharingSessionEvent) -> Void private let onListenerStopped: (@MainActor @Sendable (WebServiceServerStopReason) -> Void)? private var didNotifyListenerStopped = false private var startupWaiter: CheckedContinuation? @@ -117,10 +119,12 @@ final class WebServer { using port: NWEndpoint.Port = .http, targetStateProvider: @escaping @MainActor @Sendable (ShareTarget) -> ShareTargetState, sessionHubProvider: @escaping @MainActor @Sendable (ShareTarget) -> WebRTCSessionHub?, + sharingEventSink: @escaping @Sendable (SharingSessionEvent) -> Void, onListenerStopped: (@MainActor @Sendable (WebServiceServerStopReason) -> Void)? = nil ) throws { self.targetStateProvider = targetStateProvider self.sessionHubProvider = sessionHubProvider + self.sharingEventSink = sharingEventSink self.onListenerStopped = onListenerStopped displayPageTemplate = try Self.loadDisplayPageTemplate() @@ -307,6 +311,11 @@ final class WebServer { return displayPageTemplate .replacingOccurrences(of: "__PAGE_TITLE__", with: title) .replacingOccurrences(of: "__SIGNAL_PATH__", with: target.signalPath) + .replacingOccurrences(of: "__BOOTSTRAP_JSON__", with: makeDisplayPageBootstrapJSON()) + } + + private func makeDisplayPageBootstrapJSON() -> String { + WebRTCIceServerProvider.browserBootstrapJSON() } private func processRequest(_ content: Data?, on connection: NWConnection) { @@ -424,9 +433,28 @@ final class WebServer { return } AppLog.web.info("WebServer: [\(endpoint)] WebSocket upgrade succeeded.") - hub.addClient(connection) + let sharingEventSink = self.sharingEventSink + let addResult = hub.addClient( + connection, + target: target, + makeClientID: { UUID().uuidString }, + eventSink: { event in + sharingEventSink(event) + } + ) + guard case .accepted(let clientID) = addResult else { + if case .rejected(let reason) = addResult { + hub.sendRejection(reason: reason, to: connection) + } + connection.cancel() + return + } let key = self.connectionKey(for: connection) - self.activeConnections[key] = ActiveConnection(target: target, connection: connection) + self.activeConnections[key] = ActiveConnection( + target: target, + clientID: clientID, + connection: connection + ) self.signalDecodersByConnectionKey[key] = WebSocketFrameDecoder( maxFramePayloadBytes: Self.maxSignalBufferBytes, maxContinuationPayloadBytes: Self.maxSignalBufferBytes, diff --git a/VoidDisplay/Features/Sharing/Web/displayPage.html b/VoidDisplay/Features/Sharing/Web/displayPage.html index a584a71..eafa38a 100644 --- a/VoidDisplay/Features/Sharing/Web/displayPage.html +++ b/VoidDisplay/Features/Sharing/Web/displayPage.html @@ -196,8 +196,10 @@

Connecting…

Use `1:1` for original size and `Fullscreen` for immersive view.

+ @@ -204,6 +203,8 @@

Connecting…

const overlayEl = document.getElementById("overlay"); const messageTitleEl = document.getElementById("message-title"); const messageBodyEl = document.getElementById("message-body"); + const heroEyebrowEl = document.getElementById("hero-eyebrow"); + const footnoteEl = document.getElementById("footnote"); const player = document.getElementById("player"); const stage = document.querySelector(".stage"); const scaleModeBtn = document.getElementById("scale-mode-btn"); @@ -217,6 +218,118 @@

Connecting…

let state = "idle"; let originalScaleEnabled = false; const reconnectDelays = [250, 500, 1000, 2000, 4000]; + const messages = { + en: { + pageTitle: "Screen Share", + heroEyebrow: "VoidDisplay Live", + statusConnecting: "Connecting…", + statusSignalingConnected: "Signaling connected", + statusConnected: "Connected", + statusLive: "Live", + statusConnectionLost: "Connection lost", + statusUnsupportedBrowser: "Unsupported browser", + statusNegotiationFailed: "Negotiation failed", + statusStopped: "Stopped", + statusError: "Error", + statusConnectionError: "Connection error", + statusReconnecting: (delay) => `Reconnecting in ${delay}ms`, + scaleFit: "Fit", + scaleOriginal: "1:1", + fullscreenEnter: "Fullscreen", + fullscreenExit: "Exit Fullscreen", + overlayConnectingTitle: "Connecting…", + overlayConnectingBody: "Preparing the WebRTC signaling channel.", + overlayNegotiatingTitle: "Negotiating…", + overlayNegotiatingBody: "Exchanging offer/answer and ICE candidates.", + overlayLiveTitle: "Live stream", + overlayLiveBody: "Connected and receiving frames.", + overlayReconnectTitle: "Reconnecting…", + overlayReconnectBody: "The signaling channel dropped. Retrying automatically.", + overlayConnectionLostTitle: "Connection lost", + overlayConnectionLostBody: "Trying to reconnect to the stream.", + overlayWebSocketRequiredTitle: "WebSocket required", + overlayWebSocketRequiredBody: "This browser cannot open signaling transport.", + overlayWebRTCRequiredTitle: "WebRTC required", + overlayWebRTCRequiredBody: "This browser does not support RTCPeerConnection.", + overlayNegotiationFailedTitle: "Negotiation failed", + overlayNegotiationFailedFallback: "Failed to create WebRTC offer.", + overlaySharingStoppedTitle: "Sharing stopped", + overlaySharingStoppedBody: "The source stream is no longer available.", + overlayStreamErrorTitle: "Stream error", + overlayStreamErrorFallback: "Unknown signaling error.", + footnote: "Use `1:1` for original size and `Fullscreen` for immersive view." + }, + zhHans: { + pageTitle: "屏幕共享", + heroEyebrow: "VOIDDISPLAY 实时画面", + statusConnecting: "连接中…", + statusSignalingConnected: "信令已连接", + statusConnected: "已连接", + statusLive: "直播中", + statusConnectionLost: "连接已断开", + statusUnsupportedBrowser: "浏览器不受支持", + statusNegotiationFailed: "协商失败", + statusStopped: "已停止", + statusError: "发生错误", + statusConnectionError: "连接出错", + statusReconnecting: (delay) => `${delay}ms 后重连`, + scaleFit: "适应", + scaleOriginal: "1:1", + fullscreenEnter: "全屏", + fullscreenExit: "退出全屏", + overlayConnectingTitle: "连接中…", + overlayConnectingBody: "正在准备 WebRTC 信令通道。", + overlayNegotiatingTitle: "协商中…", + overlayNegotiatingBody: "正在交换 offer、answer 和 ICE 候选。", + overlayLiveTitle: "实时画面", + overlayLiveBody: "已连接并开始接收画面。", + overlayReconnectTitle: "正在重连…", + overlayReconnectBody: "信令通道已断开,正在自动重试。", + overlayConnectionLostTitle: "连接已断开", + overlayConnectionLostBody: "正在尝试重新连接画面流。", + overlayWebSocketRequiredTitle: "需要 WebSocket", + overlayWebSocketRequiredBody: "当前浏览器无法建立信令传输通道。", + overlayWebRTCRequiredTitle: "需要 WebRTC", + overlayWebRTCRequiredBody: "当前浏览器不支持 RTCPeerConnection。", + overlayNegotiationFailedTitle: "协商失败", + overlayNegotiationFailedFallback: "创建 WebRTC offer 失败。", + overlaySharingStoppedTitle: "共享已停止", + overlaySharingStoppedBody: "源端画面已不可用。", + overlayStreamErrorTitle: "画面错误", + overlayStreamErrorFallback: "发生未知信令错误。", + footnote: "使用“1:1”查看原始尺寸,使用“全屏”进入沉浸式观看。" + } + }; + + function resolveLocale() { + const preferredLocales = Array.isArray(navigator.languages) && navigator.languages.length > 0 + ? navigator.languages + : [navigator.language || "en"]; + for (const locale of preferredLocales) { + const normalized = String(locale || "").toLowerCase(); + if ( + normalized === "zh-hans" || + normalized === "zh-cn" || + normalized === "zh-sg" || + normalized.startsWith("zh") + ) { + return "zhHans"; + } + } + return "en"; + } + + const locale = resolveLocale(); + const currentMessages = messages[locale] || messages.en; + + function t(key, ...args) { + const value = currentMessages[key]; + if (typeof value === "function") { + return value(...args); + } + return value ?? messages.en[key] ?? ""; + } + const bootstrap = (() => { if (!bootstrapEl?.textContent) { return { iceServers: [] }; @@ -230,6 +343,16 @@

Connecting…

} })(); + function applyStaticCopy() { + document.title = t("pageTitle"); + if (heroEyebrowEl) { + heroEyebrowEl.textContent = t("heroEyebrow"); + } + if (footnoteEl) { + footnoteEl.textContent = t("footnote"); + } + } + function setStatus(text) { statusEl.textContent = text; } @@ -247,13 +370,13 @@

Connecting…

function applyScaleMode() { document.body.classList.toggle("mode-native", originalScaleEnabled); if (scaleModeBtn) { - scaleModeBtn.textContent = originalScaleEnabled ? "Fit" : "1:1"; + scaleModeBtn.textContent = originalScaleEnabled ? t("scaleFit") : t("scaleOriginal"); } } function syncFullscreenButtonLabel() { if (!fullscreenBtn) return; - fullscreenBtn.textContent = document.fullscreenElement ? "Exit Fullscreen" : "Fullscreen"; + fullscreenBtn.textContent = document.fullscreenElement ? t("fullscreenExit") : t("fullscreenEnter"); } async function toggleFullscreen() { @@ -279,6 +402,7 @@

Connecting…

}); document.addEventListener("fullscreenchange", syncFullscreenButtonLabel); + applyStaticCopy(); applyScaleMode(); syncFullscreenButtonLabel(); @@ -325,8 +449,8 @@

Connecting…

} const delay = reconnectDelays[Math.min(reconnectIndex, reconnectDelays.length - 1)]; reconnectIndex += 1; - setStatus(`Reconnecting in ${delay}ms`); - setOverlay("Reconnecting…", "The signaling channel dropped. Retrying automatically.", true); + setStatus(t("statusReconnecting", delay)); + setOverlay(t("overlayReconnectTitle"), t("overlayReconnectBody"), true); transition("handshaking"); reconnectTimer = window.setTimeout(() => { reconnectTimer = null; @@ -346,8 +470,8 @@

Connecting…

peer.ontrack = (event) => { if (event.streams && event.streams[0]) { player.srcObject = event.streams[0]; - setStatus("Live"); - setOverlay("Live stream", "Connected and receiving frames.", false); + setStatus(t("statusLive")); + setOverlay(t("overlayLiveTitle"), t("overlayLiveBody"), false); transition("streaming"); } }; @@ -367,8 +491,8 @@

Connecting…

peer.onconnectionstatechange = () => { if (peer.connectionState === "failed" || peer.connectionState === "disconnected") { - setStatus("Connection lost"); - setOverlay("Connection lost", "Trying to reconnect to the stream.", true); + setStatus(t("statusConnectionLost")); + setOverlay(t("overlayConnectionLostTitle"), t("overlayConnectionLostBody"), true); if (!terminalStop) { closeSocketAndClearReference(); scheduleReconnect(); @@ -393,14 +517,14 @@

Connecting…

return; } if (!window.WebSocket) { - setStatus("Unsupported browser"); - setOverlay("WebSocket required", "This browser cannot open signaling transport.", true); + setStatus(t("statusUnsupportedBrowser")); + setOverlay(t("overlayWebSocketRequiredTitle"), t("overlayWebSocketRequiredBody"), true); transition("closed"); return; } if (!window.RTCPeerConnection) { - setStatus("Unsupported browser"); - setOverlay("WebRTC required", "This browser does not support RTCPeerConnection.", true); + setStatus(t("statusUnsupportedBrowser")); + setOverlay(t("overlayWebRTCRequiredTitle"), t("overlayWebRTCRequiredBody"), true); transition("closed"); return; } @@ -410,22 +534,26 @@

Connecting…

const ws = new WebSocket(wsUrl); socket = ws; transition("handshaking"); - setStatus("Connecting…"); - setOverlay("Connecting…", "Preparing the WebRTC signaling channel.", true); + setStatus(t("statusConnecting")); + setOverlay(t("overlayConnectingTitle"), t("overlayConnectingBody"), true); ws.addEventListener("open", async () => { if (socket !== ws) return; reconnectIndex = 0; clearReconnectTimer(); transition("signalingReady"); - setStatus("Signaling connected"); + setStatus(t("statusSignalingConnected")); await sendSignal({ type: "viewer_ready" }); try { await startPeerConnection(); - setOverlay("Negotiating…", "Exchanging offer/answer and ICE candidates.", true); + setOverlay(t("overlayNegotiatingTitle"), t("overlayNegotiatingBody"), true); } catch (error) { - setStatus("Negotiation failed"); - setOverlay("Negotiation failed", error?.message || "Failed to create WebRTC offer.", true); + setStatus(t("statusNegotiationFailed")); + setOverlay( + t("overlayNegotiationFailedTitle"), + error?.message || t("overlayNegotiationFailedFallback"), + true + ); closeSocketAndClearReference(); scheduleReconnect(); } @@ -455,7 +583,7 @@

Connecting…

type: "answer", sdp: payload.sdp }); - setStatus("Connected"); + setStatus(t("statusConnected")); break; case "ice_candidate": if (!peer || typeof payload.candidate !== "string") return; @@ -468,8 +596,8 @@

Connecting…

case "stopped": terminalStop = true; transition("stopping"); - setStatus("Stopped"); - setOverlay("Sharing stopped", "The source stream is no longer available.", true); + setStatus(t("statusStopped")); + setOverlay(t("overlaySharingStoppedTitle"), t("overlaySharingStoppedBody"), true); closePeer(); clearReconnectTimer(); closeSocketAndClearReference(); @@ -477,8 +605,8 @@

Connecting…

break; case "error": terminalStop = true; - setStatus("Error"); - setOverlay("Stream error", payload.reason || "Unknown signaling error.", true); + setStatus(t("statusError")); + setOverlay(t("overlayStreamErrorTitle"), payload.reason || t("overlayStreamErrorFallback"), true); clearReconnectTimer(); closePeer(); closeSocketAndClearReference(); @@ -494,7 +622,7 @@

Connecting…

socket = null; closePeer(); if (terminalStop || state === "closed" || state === "stopping") { - setStatus("Stopped"); + setStatus(t("statusStopped")); transition("closed"); return; } @@ -503,7 +631,7 @@

Connecting…

ws.addEventListener("error", () => { if (socket !== ws) return; - setStatus("Connection error"); + setStatus(t("statusConnectionError")); }); } diff --git a/VoidDisplayTests/Features/Capture/Services/DisplayCaptureProfileStateMachineTests.swift b/VoidDisplayTests/Features/Capture/Services/DisplayCaptureProfileStateMachineTests.swift index f3a175a..79e36e6 100644 --- a/VoidDisplayTests/Features/Capture/Services/DisplayCaptureProfileStateMachineTests.swift +++ b/VoidDisplayTests/Features/Capture/Services/DisplayCaptureProfileStateMachineTests.swift @@ -89,6 +89,27 @@ struct DisplayCaptureProfileStateMachineTests { #expect(DisplayCaptureSession.clampedPreviewFramesPerSecond(for: 0) == 60) } + @Test func captureProfileFrameRatesMatchCurrentDefaults() { + #expect( + DisplayCaptureSession.captureFramesPerSecond( + for: .previewOnly, + maximumPreviewFramesPerSecond: 60 + ) == 60 + ) + #expect( + DisplayCaptureSession.captureFramesPerSecond( + for: .shareOnly, + maximumPreviewFramesPerSecond: 60 + ) == 60 + ) + #expect( + DisplayCaptureSession.captureFramesPerSecond( + for: .mixed, + maximumPreviewFramesPerSecond: 60 + ) == 45 + ) + } + @Test func committedTransitionUpdatesDwellBeforeReevaluatingPendingDemand() { var coordinator = DisplayCaptureProfileCoordinatorState(committedProfile: .previewOnly) diff --git a/VoidDisplayTests/Features/Sharing/Integration/WebServerSocketIntegrationTests.swift b/VoidDisplayTests/Features/Sharing/Integration/WebServerSocketIntegrationTests.swift index 6207ce1..11e0d60 100644 --- a/VoidDisplayTests/Features/Sharing/Integration/WebServerSocketIntegrationTests.swift +++ b/VoidDisplayTests/Features/Sharing/Integration/WebServerSocketIntegrationTests.swift @@ -85,8 +85,39 @@ struct WebServerSocketIntegrationTests { let responseText = try #require(String(data: responseData, encoding: .utf8)) #expect(responseText.contains("HTTP/1.1 200 OK")) + #expect(responseText.contains("Screen Share")) #expect(responseText.contains(#"id="voiddisplay-bootstrap""#)) #expect(responseText.contains(#""iceServers":[{"urls":["stun:127.0.0.1:3478","turn:127.0.0.1:3479"]}]"#)) + #expect(responseText.contains(#"const messages = {"#)) + #expect(responseText.contains(#"heroEyebrow: "VOIDDISPLAY 实时画面""#)) + #expect(responseText.contains(#"fullscreenEnter: "全屏""#)) + #expect(responseText.contains(#"pageTitle: "Screen Share""#)) + #expect(responseText.contains("hero-eyebrow")) + #expect(responseText.contains("__PAGE_TITLE__") == false) + #expect(responseText.contains("

") == false) + #expect(responseText.contains("Main Display") == false) + #expect(responseText.contains("Display 1") == false) + } + + @Test func secondaryDisplayRouteAlsoUsesGenericPageTitle() async throws { + let setup = try await startServerOnRandomPort( + targetStateProvider: { _ in .active }, + sessionHubProvider: { _ in WebRTCSessionHub() } + ) + let server = setup.server + let portValue = setup.port + defer { server.stopListener() } + + let request = Data("GET /display/7 HTTP/1.1\r\nHost: 127.0.0.1:\(portValue)\r\n\r\n".utf8) + let responseData = try await Task.detached { + try await sendRequestAndReadUntilClose(port: portValue, request: request) + }.value + + let responseText = try #require(String(data: responseData, encoding: .utf8)) + #expect(responseText.contains("HTTP/1.1 200 OK")) + #expect(responseText.contains("Screen Share")) + #expect(responseText.contains("/signal/7")) + #expect(responseText.contains("Display 7") == false) } @Test func oversizedIncompleteSignalFrameClosesConnection() async throws { From bc95202d18faac9dfa572fcd2ca63d53f3127e0c Mon Sep 17 00:00:00 2001 From: Chen Date: Thu, 2 Apr 2026 01:36:35 +0800 Subject: [PATCH 20/34] =?UTF-8?q?test(sharing):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=E5=85=AD=E5=85=B1=E4=BA=AB=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 覆盖服务重启后旧共享会话保持停止态 - 补充同目标与跨目标共享聚合统计校验 - 校准前端 bootstrap 页面断言并补拒绝 viewer 快照测试 --- .../SharingEndToEndIntegrationTests.swift | 99 ++++++++- .../Integration/SocketTestSupport.swift | 22 ++ .../WebServerSocketIntegrationTests.swift | 191 +++++++++++++++++- .../Sharing/Web/WebRTCSessionHubTests.swift | 45 +++++ 4 files changed, 355 insertions(+), 2 deletions(-) diff --git a/VoidDisplayTests/Features/Sharing/Integration/SharingEndToEndIntegrationTests.swift b/VoidDisplayTests/Features/Sharing/Integration/SharingEndToEndIntegrationTests.swift index 2efc37a..3949484 100644 --- a/VoidDisplayTests/Features/Sharing/Integration/SharingEndToEndIntegrationTests.swift +++ b/VoidDisplayTests/Features/Sharing/Integration/SharingEndToEndIntegrationTests.swift @@ -61,7 +61,7 @@ struct SharingEndToEndIntegrationTests { @Test func sharingLifecycleRoutesRemainConsistent() async throws { let storeURL = temporaryStoreURL() - let registry = DisplayCaptureRegistry(captureSessionFactory: { _, _ in + let registry = DisplayCaptureRegistry(captureSessionFactory: { _, _, _ in EndToEndFakeCaptureSession() }) let coordinator = DisplaySharingCoordinator( @@ -130,6 +130,80 @@ struct SharingEndToEndIntegrationTests { #expect(unreachableAfterStop) } + @MainActor + @Test + func restartingWebServiceKeepsPreviousSharingSessionStopped() async throws { + let storeURL = temporaryStoreURL() + let registry = DisplayCaptureRegistry(captureSessionFactory: { _, _, _ in + EndToEndFakeCaptureSession() + }) + let coordinator = DisplaySharingCoordinator( + idStore: DisplayShareIDStore(storeURL: storeURL), + captureRegistry: registry + ) + let webServiceController = WebServiceController() + let service = SharingService( + webServiceController: webServiceController, + sharingCoordinator: coordinator + ) + + let displayID = CGDirectDisplayID(9201) + let display = EndToEndMockSCDisplay.make(displayID: displayID, width: 2560, height: 1440) + service.registerShareableDisplays([display], virtualSerialResolver: { _ in nil }) + + let firstStart = await startWebServiceWithDynamicPorts(service: service) + let firstBinding = try requireBinding(firstStart) + _ = try await service.startSharing(display: display) + let shareID = try #require(service.shareID(for: displayID)) + let displayPath = "/display/\(shareID)" + let signalPath = "/signal/\(shareID)" + + service.stopWebService() + + let fullyStopped = await waitUntil(timeout: .seconds(2)) { + service.activeSharingDisplayIDs.isEmpty && + service.hasAnyActiveSharing == false && + service.isSharing(displayID: displayID) == false && + service.sharingStateSnapshot == .empty + } + #expect(fullyStopped) + + let firstPortClosed = await waitForConnectionFailure( + port: firstBinding.boundPort, + path: displayPath, + timeout: .seconds(3) + ) + #expect(firstPortClosed) + + let secondStart = await startWebServiceWithDynamicPorts(service: service) + let secondBinding = try requireBinding(secondStart) + defer { + service.stopWebService() + } + + #expect(service.activeSharingDisplayIDs.isEmpty) + #expect(service.hasAnyActiveSharing == false) + #expect(service.isSharing(displayID: displayID) == false) + + let displayResponse = try await Task.detached { + try await sendRequestAndReadUntilClose( + port: secondBinding.boundPort, + request: Data("GET \(displayPath) HTTP/1.1\r\nHost: 127.0.0.1:\(secondBinding.boundPort)\r\n\r\n".utf8) + ) + }.value + let displayText = try #require(String(data: displayResponse, encoding: .utf8)) + #expect(displayText.contains("HTTP/1.1 200 OK")) + + let signalResponse = try await Task.detached { + try await sendRequestAndReadUntilClose( + port: secondBinding.boundPort, + request: Data("GET \(signalPath) HTTP/1.1\r\nHost: 127.0.0.1:\(secondBinding.boundPort)\r\n\r\n".utf8) + ) + }.value + let signalText = try #require(String(data: signalResponse, encoding: .utf8)) + #expect(signalText.contains("503 Service Unavailable")) + } + private func waitForConnectionFailure( port: UInt16, path: String, @@ -154,6 +228,18 @@ struct SharingEndToEndIntegrationTests { } } + private func waitUntil(timeout: Duration, condition: @escaping @MainActor () -> Bool) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now + timeout + while clock.now < deadline { + if await condition() { + return true + } + await Task.yield() + } + return await condition() + } + private func temporaryStoreURL() -> URL { let base = FileManager.default.temporaryDirectory .appendingPathComponent("sharing-e2e-\(UUID().uuidString)", isDirectory: true) @@ -193,4 +279,15 @@ struct SharingEndToEndIntegrationTests { print("[SharingEndToEndIntegrationTests] \(message)") return .failure(.message(message)) } + + private func requireBinding( + _ result: Result + ) throws -> WebServiceBinding { + switch result { + case .success(let binding): + return binding + case .failure(let error): + throw error + } + } } diff --git a/VoidDisplayTests/Features/Sharing/Integration/SocketTestSupport.swift b/VoidDisplayTests/Features/Sharing/Integration/SocketTestSupport.swift index 7bd7836..6eb9e2c 100644 --- a/VoidDisplayTests/Features/Sharing/Integration/SocketTestSupport.swift +++ b/VoidDisplayTests/Features/Sharing/Integration/SocketTestSupport.swift @@ -271,6 +271,28 @@ func makeMaskedBinaryFrame(payload: Data) -> Data { return frame } +func makeMaskedTextFrame(_ text: String) -> Data { + let payload = Data(text.utf8) + let mask: [UInt8] = [0x10, 0x32, 0x54, 0x76] + var frame = Data([0x81]) + if payload.count <= 125 { + frame.append(0x80 | UInt8(payload.count)) + } else if payload.count <= Int(UInt16.max) { + frame.append(0x80 | 126) + var length = UInt16(payload.count).bigEndian + withUnsafeBytes(of: &length) { frame.append(contentsOf: $0) } + } else { + frame.append(0x80 | 127) + var length = UInt64(payload.count).bigEndian + withUnsafeBytes(of: &length) { frame.append(contentsOf: $0) } + } + frame.append(contentsOf: mask) + for (index, byte) in payload.enumerated() { + frame.append(byte ^ mask[index % 4]) + } + return frame +} + func websocketUpgradeRequest(path: String, port: UInt16) -> Data { let request = "GET \(path) HTTP/1.1\r\n" + diff --git a/VoidDisplayTests/Features/Sharing/Integration/WebServerSocketIntegrationTests.swift b/VoidDisplayTests/Features/Sharing/Integration/WebServerSocketIntegrationTests.swift index 11e0d60..8ebde85 100644 --- a/VoidDisplayTests/Features/Sharing/Integration/WebServerSocketIntegrationTests.swift +++ b/VoidDisplayTests/Features/Sharing/Integration/WebServerSocketIntegrationTests.swift @@ -3,6 +3,27 @@ import Darwin import Testing @testable import VoidDisplay +private final class IntegrationAutoConnectingPeer: @unchecked Sendable, WebRTCPeerSessioning { + private let onConnected: @Sendable () -> Void + + init(onConnected: @escaping @Sendable () -> Void) { + self.onConnected = onConnected + } + + nonisolated func handleRemoteOffer(sdp: String) { + _ = sdp + onConnected() + } + + nonisolated func addRemoteCandidate(sdp: String, sdpMid: String?, sdpMLineIndex: Int32) { + _ = sdp + _ = sdpMid + _ = sdpMLineIndex + } + + nonisolated func close() {} +} + @MainActor @Suite(.serialized) struct WebServerSocketIntegrationTests { @@ -89,12 +110,26 @@ struct WebServerSocketIntegrationTests { #expect(responseText.contains(#"id="voiddisplay-bootstrap""#)) #expect(responseText.contains(#""iceServers":[{"urls":["stun:127.0.0.1:3478","turn:127.0.0.1:3479"]}]"#)) #expect(responseText.contains(#"const messages = {"#)) + #expect(responseText.contains(#"function resolveLocale() {"#)) + #expect(responseText.contains(#"const locale = resolveLocale();"#)) + #expect(responseText.contains(#"document.title = t("pageTitle");"#)) + #expect(responseText.contains(#"function applyScaleMode() {"#)) + #expect(responseText.contains(#"function syncFullscreenButtonLabel() {"#)) + #expect(responseText.contains(#"const reconnectDelays = [250, 500, 1000, 2000, 4000];"#)) + #expect(responseText.contains(#"function scheduleReconnect() {"#)) + #expect(responseText.contains(#"peer = new RTCPeerConnection({ iceServers: bootstrap.iceServers ?? [] });"#)) + #expect(responseText.contains(#"setOverlay(t("overlayReconnectTitle"), t("overlayReconnectBody"), true);"#)) + #expect(responseText.contains(#"setOverlay(t("overlaySharingStoppedTitle"), t("overlaySharingStoppedBody"), true);"#)) + #expect(responseText.contains(#"case "stopped":"#)) + #expect(responseText.contains(#"case "error":"#)) + #expect(responseText.contains(#"connect();"#)) #expect(responseText.contains(#"heroEyebrow: "VOIDDISPLAY 实时画面""#)) #expect(responseText.contains(#"fullscreenEnter: "全屏""#)) #expect(responseText.contains(#"pageTitle: "Screen Share""#)) #expect(responseText.contains("hero-eyebrow")) + #expect(responseText.contains("footnote")) #expect(responseText.contains("__PAGE_TITLE__") == false) - #expect(responseText.contains("

") == false) + #expect(responseText.contains("__SIGNAL_PATH__") == false) #expect(responseText.contains("Main Display") == false) #expect(responseText.contains("Display 1") == false) } @@ -197,6 +232,126 @@ struct WebServerSocketIntegrationTests { #expect(clientCleared) } + @Test func sameTargetPeersAccumulateStreamingCounts() async throws { + let aggregator = SharingStateAggregator() + let sessionHub = WebRTCSessionHub( + peerFactory: { callbacks in + IntegrationAutoConnectingPeer(onConnected: callbacks.onConnected) + } + ) + let setup = try await startServerOnRandomPort( + targetStateProvider: { target in + target == .main ? .active : .unknown + }, + sessionHubProvider: { target in + target == .main ? sessionHub : nil + }, + sharingEventSink: { event in + Task { @MainActor in + aggregator.record(event) + } + } + ) + let server = setup.server + let portValue = setup.port + defer { server.stopListener() } + + let firstSocket = try await openWebSocket(path: "/signal", port: portValue) + let secondSocket = try await openWebSocket(path: "/signal", port: portValue) + defer { + close(firstSocket) + close(secondSocket) + } + + try sendAll(firstSocket, data: makeMaskedTextFrame(#"{"type":"offer","sdp":"v=0"}"#)) + try sendAll(secondSocket, data: makeMaskedTextFrame(#"{"type":"offer","sdp":"v=0"}"#)) + + let accumulated = await waitUntilAsync(timeout: .seconds(2)) { + let snapshot = aggregator.currentSnapshot + return snapshot.signalingConnections == 2 && + snapshot.streamingPeers == 2 && + snapshot.signalingConnectionsByTarget[.main] == 2 && + snapshot.streamingPeersByTarget[.main] == 2 + } + #expect(accumulated) + + try sendAll(firstSocket, data: makeMaskedCloseFrame()) + try sendAll(secondSocket, data: makeMaskedCloseFrame()) + _ = try await waitForSocketClose(firstSocket) + _ = try await waitForSocketClose(secondSocket) + + let cleared = await waitUntilAsync(timeout: .seconds(2)) { + aggregator.currentSnapshot.signalingConnections == 0 && + aggregator.currentSnapshot.streamingPeers == 0 + } + #expect(cleared) + } + + @Test func simultaneousTargetsKeepPerTargetSharingCountsIsolated() async throws { + let aggregator = SharingStateAggregator() + let mainHub = WebRTCSessionHub( + peerFactory: { callbacks in + IntegrationAutoConnectingPeer(onConnected: callbacks.onConnected) + } + ) + let secondaryHub = WebRTCSessionHub( + peerFactory: { callbacks in + IntegrationAutoConnectingPeer(onConnected: callbacks.onConnected) + } + ) + let setup = try await startServerOnRandomPort( + targetStateProvider: { target in + switch target { + case .main, .id(7): + .active + default: + .unknown + } + }, + sessionHubProvider: { target in + switch target { + case .main: + mainHub + case .id(7): + secondaryHub + default: + nil + } + }, + sharingEventSink: { event in + Task { @MainActor in + aggregator.record(event) + } + } + ) + let server = setup.server + let portValue = setup.port + defer { server.stopListener() } + + let mainSocket = try await openWebSocket(path: "/signal", port: portValue) + let secondarySocket = try await openWebSocket(path: "/signal/7", port: portValue) + defer { + close(mainSocket) + close(secondarySocket) + } + + try sendAll(mainSocket, data: makeMaskedTextFrame(#"{"type":"offer","sdp":"v=0"}"#)) + try sendAll(secondarySocket, data: makeMaskedTextFrame(#"{"type":"offer","sdp":"v=0"}"#)) + + let isolated = await waitUntilAsync(timeout: .seconds(2)) { + let snapshot = aggregator.currentSnapshot + return snapshot.signalingConnections == 2 && + snapshot.streamingPeers == 2 && + snapshot.signalingConnectionsByTarget[.main] == 1 && + snapshot.signalingConnectionsByTarget[.id(7)] == 1 && + snapshot.streamingPeersByTarget[.main] == 1 && + snapshot.streamingPeersByTarget[.id(7)] == 1 && + server.streamClientCount(for: .main) == 1 && + server.streamClientCount(for: .id(7)) == 1 + } + #expect(isolated) + } + @Test func binarySignalFrameClosesWithProtocolCodeAndRemovesActiveClient() async throws { let sessionHub = WebRTCSessionHub() let setup = try await startServerOnRandomPort( @@ -236,6 +391,40 @@ struct WebServerSocketIntegrationTests { } return condition() } + + private func openWebSocket(path: String, port: UInt16) async throws -> Int32 { + let result = try await Task.detached { + let socketFD = try await connectLoopbackSocket(port: port) + do { + try sendAll(socketFD, data: websocketUpgradeRequest(path: path, port: port)) + let handshake = try readUntilHeaderTerminator( + from: socketFD, + timeoutMilliseconds: 500, + deadlineSeconds: 8 + ) + let terminator = Data("\r\n\r\n".utf8) + guard let headerRange = handshake.range(of: terminator) else { + throw SocketIntegrationError.receiveFailed + } + let headerData = Data(handshake[.. Bool { + try await Task.detached { + try waitForCloseOrEOF(from: socketFD, deadlineSeconds: 5) + }.value + } } private extension String { diff --git a/VoidDisplayTests/Features/Sharing/Web/WebRTCSessionHubTests.swift b/VoidDisplayTests/Features/Sharing/Web/WebRTCSessionHubTests.swift index c5f8518..bebecae 100644 --- a/VoidDisplayTests/Features/Sharing/Web/WebRTCSessionHubTests.swift +++ b/VoidDisplayTests/Features/Sharing/Web/WebRTCSessionHubTests.swift @@ -83,6 +83,10 @@ private final class SharingEventRecorder: @unchecked Sendable { events.withLock { $0.append(event) } } + func currentEvents() -> [SharingSessionEvent] { + events.withLock { $0 } + } + func currentPhases() -> [SharingPeerPhase] { events.withLock { $0.map(\.recordedPhase) } } @@ -197,6 +201,47 @@ struct WebRTCSessionHubTests { #expect(rejectedClient.decodedTextPayloads().contains(where: { $0.contains(#""reason":"too_many_viewers""#) })) } + @MainActor @Test func rejectedViewerDoesNotEnterSharingSnapshot() { + let recorder = SharingEventRecorder() + let hub = WebRTCSessionHub() + var clients: [MockSignalSocketConnection] = [] + + for index in 0..<10 { + let client = MockSignalSocketConnection() + clients.append(client) + let result = hub.addClient( + client, + target: .main, + makeClientID: { "client-\(index)" }, + eventSink: { event in + recorder.record(event) + } + ) + #expect(isAccepted(result)) + } + + let rejectedClient = MockSignalSocketConnection() + let result = hub.addClient( + rejectedClient, + target: .main, + makeClientID: { "client-overflow" }, + eventSink: { event in + recorder.record(event) + } + ) + + let aggregator = SharingStateAggregator() + for event in recorder.currentEvents() { + aggregator.record(event) + } + + #expect(result == .rejected(reason: "too_many_viewers")) + #expect(aggregator.currentSnapshot.signalingConnections == 10) + #expect(aggregator.currentSnapshot.streamingPeers == 0) + #expect(aggregator.currentSnapshot.clientsByTarget[.main]?.count == 10) + #expect(aggregator.currentSnapshot.clientsByTarget[.main]?["client-overflow"] == nil) + } + @MainActor @Test func stopSharingBroadcastsStoppedAndDisconnectsClients() { let hub = WebRTCSessionHub() let client = MockSignalSocketConnection(autoCompleteSends: false) From e5e6ef4a6dfb7e10af6a44f17c20cf40f81250bb Mon Sep 17 00:00:00 2001 From: Chen Date: Thu, 2 Apr 2026 01:42:08 +0800 Subject: [PATCH 21/34] =?UTF-8?q?feat(capture):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E9=87=87=E9=9B=86=E6=80=A7=E8=83=BD=E6=A8=A1=E5=BC=8F=E4=B8=8E?= =?UTF-8?q?=E8=87=AA=E9=80=82=E5=BA=94=E5=B8=A7=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增采集性能偏好,并在设置页与共享页提供统一切换入口 - 让采集注册表和会话按性能模式调整混合监听共享帧率 - 补充状态机、偏好持久化与订阅转发相关测试 --- VoidDisplay/App/AppSettingsView.swift | 24 +- VoidDisplay/App/VoidDisplayApp.swift | 28 +- .../CaptureMonitoringLifecycleService.swift | 13 +- .../CapturePerformancePreferences.swift | 41 ++ .../Services/DisplayCaptureRegistry.swift | 29 +- .../Services/DisplayCaptureSession.swift | 30 +- .../Services/DisplayCaptureTypes.swift | 352 ++++++++++++++++++ .../Capture/Views/CaptureDisplayView.swift | 32 ++ .../Views/SharePerformanceModePicker.swift | 38 ++ .../Features/Sharing/Views/ShareView.swift | 45 ++- VoidDisplay/Resources/Localizable.xcstrings | 81 ++++ .../CapturePerformancePreferencesTests.swift | 32 ++ ...splayCaptureProfileStateMachineTests.swift | 237 ++++++++++++ .../DisplayCaptureRegistryTests.swift | 55 ++- .../DisplayPreviewSubscriptionTests.swift | 30 ++ 15 files changed, 1025 insertions(+), 42 deletions(-) create mode 100644 VoidDisplay/Features/Capture/Services/CapturePerformancePreferences.swift create mode 100644 VoidDisplay/Features/Sharing/Views/SharePerformanceModePicker.swift create mode 100644 VoidDisplayTests/Features/Capture/Services/CapturePerformancePreferencesTests.swift diff --git a/VoidDisplay/App/AppSettingsView.swift b/VoidDisplay/App/AppSettingsView.swift index e1f0b63..b248e4d 100644 --- a/VoidDisplay/App/AppSettingsView.swift +++ b/VoidDisplay/App/AppSettingsView.swift @@ -7,6 +7,7 @@ import SwiftUI struct AppSettingsView: View { @Environment(VirtualDisplayController.self) private var virtualDisplay + @Environment(CapturePerformancePreferences.self) private var capturePerformancePreferences @State private var showResetConfirmation = false @State private var resetCompleted = false @@ -14,6 +15,20 @@ struct AppSettingsView: View { @Bindable var bindableVirtualDisplay = virtualDisplay VStack(alignment: .leading, spacing: 12) { + Text("Capture Performance") + .font(.headline) + + Text("Choose how screen monitoring and sharing balance smoothness and resource usage.") + .font(.subheadline) + .foregroundStyle(.secondary) + + Picker("Capture Performance", selection: performanceModeBinding) { + Text("Automatic").tag(CapturePerformanceMode.automatic) + Text("Smooth").tag(CapturePerformanceMode.smooth) + Text("Power Efficient").tag(CapturePerformanceMode.powerEfficient) + } + .pickerStyle(.segmented) + Text("Virtual Displays") .font(.headline) @@ -35,7 +50,7 @@ struct AppSettingsView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding(16) - .frame(width: 420, height: 170, alignment: .topLeading) + .frame(width: 420, height: 270, alignment: .topLeading) .confirmationDialog( "Reset Virtual Display Configurations?", isPresented: $showResetConfirmation @@ -60,4 +75,11 @@ struct AppSettingsView: View { ) } } + + private var performanceModeBinding: Binding { + Binding( + get: { capturePerformancePreferences.mode }, + set: { capturePerformancePreferences.saveMode($0) } + ) + } } diff --git a/VoidDisplay/App/VoidDisplayApp.swift b/VoidDisplay/App/VoidDisplayApp.swift index 286327f..c659497 100644 --- a/VoidDisplay/App/VoidDisplayApp.swift +++ b/VoidDisplay/App/VoidDisplayApp.swift @@ -13,6 +13,7 @@ struct AppEnvironment { let sharing: SharingController let virtualDisplay: VirtualDisplayController let topology: DisplayTopologyChangeCoordinator + let capturePerformancePreferences: CapturePerformancePreferences } @main @@ -21,6 +22,7 @@ struct VoidDisplayApp: App { @State private var sharing: SharingController @State private var virtualDisplay: VirtualDisplayController @State private var topology: DisplayTopologyChangeCoordinator + @State private var capturePerformancePreferences: CapturePerformancePreferences init() { let env = AppBootstrap.makeEnvironment() @@ -28,6 +30,7 @@ struct VoidDisplayApp: App { _sharing = State(initialValue: env.sharing) _virtualDisplay = State(initialValue: env.virtualDisplay) _topology = State(initialValue: env.topology) + _capturePerformancePreferences = State(initialValue: env.capturePerformancePreferences) } var body: some Scene { @@ -36,6 +39,7 @@ struct VoidDisplayApp: App { .environment(capture) .environment(sharing) .environment(virtualDisplay) + .environment(capturePerformancePreferences) } .windowToolbarStyle(.unified(showsTitle: true)) @@ -52,6 +56,7 @@ struct VoidDisplayApp: App { .environment(capture) .environment(sharing) .environment(virtualDisplay) + .environment(capturePerformancePreferences) } } } @@ -124,13 +129,27 @@ enum AppBootstrap { persistenceEnvironment[PersistenceContext.uiTestModeEnvironmentKey] = "1" } let persistenceContext = PersistenceContext.resolve(environment: persistenceEnvironment) + let capturePerformancePreferences = CapturePerformancePreferences( + defaults: persistenceContext.userDefaults + ) + let captureRegistry = DisplayCaptureRegistry( + performanceMode: capturePerformancePreferences.mode + ) + capturePerformancePreferences.onModeChanged = { mode in + Task { + await captureRegistry.updatePerformanceMode(mode) + } + } let resolvedSharingService: any SharingServiceProtocol if let sharingService { resolvedSharingService = sharingService } else { let idStore = DisplayShareIDStore(storeURL: persistenceContext.displayShareIDMappingsURL) - let sharingCoordinator = DisplaySharingCoordinator(idStore: idStore) + let sharingCoordinator = DisplaySharingCoordinator( + idStore: idStore, + captureRegistry: captureRegistry + ) resolvedSharingService = SharingService(sharingCoordinator: sharingCoordinator) } @@ -148,6 +167,10 @@ enum AppBootstrap { let capture = CaptureController( captureMonitoringService: resolvedCaptureMonitoringService, + captureMonitoringLifecycleService: CaptureMonitoringLifecycleService( + captureMonitoringService: resolvedCaptureMonitoringService, + captureRegistry: captureRegistry + ), catalogService: catalogService ) let sharing = SharingController( @@ -175,7 +198,8 @@ enum AppBootstrap { sharing: sharing, virtualDisplay: virtualDisplay, catalogService: catalogService - ) + ), + capturePerformancePreferences: capturePerformancePreferences ) guard !preview else { return env } diff --git a/VoidDisplay/Features/Capture/Services/CaptureMonitoringLifecycleService.swift b/VoidDisplay/Features/Capture/Services/CaptureMonitoringLifecycleService.swift index 277aab0..4e109ff 100644 --- a/VoidDisplay/Features/Capture/Services/CaptureMonitoringLifecycleService.swift +++ b/VoidDisplay/Features/Capture/Services/CaptureMonitoringLifecycleService.swift @@ -16,16 +16,17 @@ final class CaptureMonitoringLifecycleService: CaptureMonitoringLifecycleService init( captureMonitoringService: any CaptureMonitoringServiceProtocol, startCoordinator: DisplayStreamStartCoordinator = DisplayStreamStartCoordinator(), - acquirePreview: @escaping AcquirePreview = { display, invalidationContext in - try await DisplayCaptureRegistry.shared.acquirePreview( + captureRegistry: DisplayCaptureRegistry = .shared, + acquirePreview: AcquirePreview? = nil + ) { + self.captureMonitoringService = captureMonitoringService + self.startCoordinator = startCoordinator + self.acquirePreview = acquirePreview ?? { display, invalidationContext in + try await captureRegistry.acquirePreview( display: SendableDisplay(display), invalidationContext: invalidationContext ) } - ) { - self.captureMonitoringService = captureMonitoringService - self.startCoordinator = startCoordinator - self.acquirePreview = acquirePreview } func isStarting(displayID: CGDirectDisplayID) -> Bool { diff --git a/VoidDisplay/Features/Capture/Services/CapturePerformancePreferences.swift b/VoidDisplay/Features/Capture/Services/CapturePerformancePreferences.swift new file mode 100644 index 0000000..da8f307 --- /dev/null +++ b/VoidDisplay/Features/Capture/Services/CapturePerformancePreferences.swift @@ -0,0 +1,41 @@ +import Foundation +import Observation + +public enum CapturePerformancePreferenceKeys { + public static let mode = "capture.performanceMode" +} + +enum CapturePerformanceMode: String, CaseIterable, Sendable { + case automatic + case smooth + case powerEfficient +} + +@MainActor +protocol CapturePerformancePreferencesProtocol: AnyObject { + var mode: CapturePerformanceMode { get } + var onModeChanged: (@MainActor @Sendable (CapturePerformanceMode) -> Void)? { get set } + func saveMode(_ mode: CapturePerformanceMode) +} + +@MainActor +@Observable +final class CapturePerformancePreferences: CapturePerformancePreferencesProtocol { + private let defaults: UserDefaults + var onModeChanged: (@MainActor @Sendable (CapturePerformanceMode) -> Void)? + + init(defaults: UserDefaults) { + self.defaults = defaults + } + + var mode: CapturePerformanceMode { + let rawValue = defaults.string(forKey: CapturePerformancePreferenceKeys.mode) + return rawValue.flatMap(CapturePerformanceMode.init(rawValue:)) ?? .automatic + } + + func saveMode(_ mode: CapturePerformanceMode) { + guard self.mode != mode else { return } + defaults.set(mode.rawValue, forKey: CapturePerformancePreferenceKeys.mode) + onModeChanged?(mode) + } +} diff --git a/VoidDisplay/Features/Capture/Services/DisplayCaptureRegistry.swift b/VoidDisplay/Features/Capture/Services/DisplayCaptureRegistry.swift index e3ece2b..c520473 100644 --- a/VoidDisplay/Features/Capture/Services/DisplayCaptureRegistry.swift +++ b/VoidDisplay/Features/Capture/Services/DisplayCaptureRegistry.swift @@ -76,6 +76,10 @@ final class DisplayPreviewSubscription: Sendable { closure() } + nonisolated func reportPerformanceSample(_ sample: DisplayPreviewPerformanceSample) { + session.reportPreviewPerformanceSample(sample) + } + nonisolated func setShowsCursor(_ showsCursor: Bool) async throws { try await session.setPreviewShowsCursor(showsCursor) } @@ -241,12 +245,14 @@ actor DisplayCaptureRegistry { typealias CaptureSessionFactory = @Sendable ( SendableDisplay, - DisplayCaptureProfile + DisplayCaptureProfile, + CapturePerformanceMode ) async throws -> any DisplayCaptureSessioning static let shared = DisplayCaptureRegistry() private let captureSessionFactory: CaptureSessionFactory + private var performanceMode: CapturePerformanceMode private var sessionsByDisplayID: [CGDirectDisplayID: SessionRecord] = [:] private var tokenOwnership: [UUID: TokenRecord] = [:] private var sessionCreationTasks: [CGDirectDisplayID: Task] = [:] @@ -255,13 +261,27 @@ actor DisplayCaptureRegistry { private var initializingDisplayIDs: Set = [] init( - captureSessionFactory: @escaping CaptureSessionFactory = { display, initialProfile in - try await DisplayCaptureSession(display: display.value, initialProfile: initialProfile) + performanceMode: CapturePerformanceMode = .automatic, + captureSessionFactory: @escaping CaptureSessionFactory = { display, initialProfile, initialPerformanceMode in + try await DisplayCaptureSession( + display: display.value, + initialProfile: initialProfile, + initialPerformanceMode: initialPerformanceMode + ) } ) { + self.performanceMode = performanceMode self.captureSessionFactory = captureSessionFactory } + func updatePerformanceMode(_ mode: CapturePerformanceMode) async { + performanceMode = mode + let sessions = sessionsByDisplayID.values.map(\.session) + for session in sessions { + try? await session.setPerformanceMode(mode) + } + } + func acquirePreview(display: SendableDisplay) async throws -> DisplayPreviewSubscription { let token = try await acquirePreviewToken(display: display) guard let record = sessionsByDisplayID[token.displayID] else { @@ -448,7 +468,8 @@ actor DisplayCaptureRegistry { displayID: displayID, fallbackKind: fallbackKind ) - let session = try await captureSessionFactory(display, initialProfile) + let performanceMode = self.performanceMode + let session = try await captureSessionFactory(display, initialProfile, performanceMode) return SessionRecord( session: session, resolutionText: "\(display.width) × \(display.height)", diff --git a/VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift b/VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift index f11b521..0955b85 100644 --- a/VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift +++ b/VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift @@ -30,6 +30,7 @@ private final class DisplayStreamOutput: NSObject, SCStreamOutput, SCStreamDeleg private struct DisplayCaptureMetrics: Sendable { var currentProfile: DisplayCaptureProfile? + var currentFrameRateTier: DisplayCaptureFrameRateTier? var receivedFrameCount: UInt64 = 0 var profileReconfigurationCount: UInt64 = 0 var cursorOverrideReconfigurationCount: UInt64 = 0 @@ -37,6 +38,7 @@ private struct DisplayCaptureMetrics: Sendable { nonisolated func snapshot() -> DisplayCaptureMetricsSnapshot { .init( currentProfile: currentProfile, + currentFrameRateTier: currentFrameRateTier, receivedFrameCount: receivedFrameCount, profileReconfigurationCount: profileReconfigurationCount, cursorOverrideReconfigurationCount: cursorOverrideReconfigurationCount @@ -53,12 +55,14 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning let capturesAudio: Bool let pixelFormat: OSType var profile: DisplayCaptureProfile + var frameRateTier: DisplayCaptureFrameRateTier var previewShowsCursor: Bool var shareCursorOverrideCount: Int nonisolated var minimumFrameInterval: CMTime { let framesPerSecond = DisplayCaptureSession.captureFramesPerSecond( for: profile, + frameRateTier: frameRateTier, maximumPreviewFramesPerSecond: maximumPreviewFramesPerSecond ) return CMTime(value: 1, timescale: CMTimeScale(max(1, Int32(framesPerSecond)))) @@ -88,7 +92,8 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning nonisolated init( display: SCDisplay, - initialProfile: DisplayCaptureProfile = .previewOnly + initialProfile: DisplayCaptureProfile = .previewOnly, + initialPerformanceMode: CapturePerformanceMode = .automatic ) async throws { self.displayID = display.displayID self.captureQueue = DispatchQueue( @@ -99,7 +104,8 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning let state = try await Self.makeStreamConfigurationState( display: display, showsCursor: false, - initialProfile: initialProfile + initialProfile: initialProfile, + initialPerformanceMode: initialPerformanceMode ) let config = Self.makeStreamConfiguration(from: state) let filter = try await Self.makeContentFilter(display: display) @@ -113,7 +119,10 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning ) ) ) - self.metrics.withLock { $0.currentProfile = state.profile } + self.metrics.withLock { + $0.currentProfile = state.profile + $0.currentFrameRateTier = state.frameRateTier + } output.session = self @@ -368,15 +377,16 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning extension DisplayCaptureSession { nonisolated static func captureFramesPerSecond( for profile: DisplayCaptureProfile, + frameRateTier: DisplayCaptureFrameRateTier, maximumPreviewFramesPerSecond: Int ) -> Int { switch profile { case .previewOnly: - return maximumPreviewFramesPerSecond + return min(maximumPreviewFramesPerSecond, frameRateTier.framesPerSecond) case .shareOnly: - return 60 + return frameRateTier.framesPerSecond case .mixed: - return 45 + return frameRateTier.framesPerSecond } } @@ -398,12 +408,17 @@ extension DisplayCaptureSession { nonisolated private static func makeStreamConfigurationState( display: SCDisplay, showsCursor: Bool, - initialProfile: DisplayCaptureProfile + initialProfile: DisplayCaptureProfile, + initialPerformanceMode: CapturePerformanceMode ) async throws -> StreamConfigurationState { let displayMode = CGDisplayCopyDisplayMode(display.displayID) let captureSize = preferredCaptureSize(display: display, displayMode: displayMode) let previewFramesPerSecond = clampedPreviewFramesPerSecond(for: displayMode?.refreshRate ?? 60.0) + let initialFrameRateTier = DisplayCaptureConfigurationStateMachine.defaultFrameRateTier( + for: initialProfile, + performanceMode: initialPerformanceMode + ) let state = StreamConfigurationState( width: captureSize.width, @@ -413,6 +428,7 @@ extension DisplayCaptureSession { capturesAudio: false, pixelFormat: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, profile: initialProfile, + frameRateTier: initialFrameRateTier, previewShowsCursor: showsCursor, shareCursorOverrideCount: 0 ) diff --git a/VoidDisplay/Features/Capture/Services/DisplayCaptureTypes.swift b/VoidDisplay/Features/Capture/Services/DisplayCaptureTypes.swift index 431adb0..bd1c27b 100644 --- a/VoidDisplay/Features/Capture/Services/DisplayCaptureTypes.swift +++ b/VoidDisplay/Features/Capture/Services/DisplayCaptureTypes.swift @@ -13,6 +13,38 @@ enum DisplayStartKind: Hashable, Sendable { case sharing } +nonisolated enum DisplayCaptureFrameRateTier: Int, CaseIterable, Sendable, Equatable { + case fps30 = 30 + case fps45 = 45 + case fps60 = 60 + + var framesPerSecond: Int { + rawValue + } + + var nextLowerTier: DisplayCaptureFrameRateTier? { + switch self { + case .fps60: + .fps45 + case .fps45: + .fps30 + case .fps30: + nil + } + } + + var nextHigherTier: DisplayCaptureFrameRateTier? { + switch self { + case .fps30: + .fps45 + case .fps45: + .fps60 + case .fps60: + nil + } + } +} + nonisolated enum DisplayCaptureProfile: String, Sendable, Equatable { case previewOnly case shareOnly @@ -165,6 +197,314 @@ nonisolated struct DisplayCaptureTaskLifetimeState: Sendable { } } +nonisolated struct DisplayPreviewPerformanceSample: Sendable, Equatable { + let renderedFrameCount: UInt64 + let droppedFrameCount: UInt64 + let latestRenderLatencyMilliseconds: Double + let pendingSlotOccupied: Bool + let capturedAt: UInt64 + + nonisolated var totalFrameCount: UInt64 { + renderedFrameCount &+ droppedFrameCount + } + + nonisolated var droppedFrameRatio: Double { + let total = max(1, totalFrameCount) + return Double(droppedFrameCount) / Double(total) + } +} + +nonisolated struct DisplayCaptureConfiguration: Sendable, Equatable { + let profile: DisplayCaptureProfile + let frameRateTier: DisplayCaptureFrameRateTier +} + +nonisolated enum DisplayCaptureConfigurationDecision: Sendable, Equatable { + case noChange + case applyNow(DisplayCaptureConfiguration) + case applyAfter(DisplayCaptureConfiguration, delayNanoseconds: UInt64) +} + +nonisolated struct DisplayCaptureAdaptivePolicyState: Sendable, Equatable { + private(set) var currentAutomaticMixedTier: DisplayCaptureFrameRateTier = .fps45 + private(set) var stableWindowCount = 0 + private(set) var pressureWindowCount = 0 + + mutating func resetToDefaultAutomaticMixedTier() { + currentAutomaticMixedTier = .fps45 + stableWindowCount = 0 + pressureWindowCount = 0 + } + + mutating func rebase( + desiredProfile: DisplayCaptureProfile?, + performanceMode: CapturePerformanceMode + ) { + guard desiredProfile == .mixed, performanceMode == .automatic else { + resetToDefaultAutomaticMixedTier() + return + } + } + + mutating func recordAutomaticMixedSample( + _ sample: DisplayPreviewPerformanceSample + ) -> DisplayCaptureFrameRateTier? { + guard sample.totalFrameCount > 0 else { + stableWindowCount = 0 + pressureWindowCount = 0 + return nil + } + let isPressureWindow = sample.droppedFrameRatio >= 0.08 + || sample.latestRenderLatencyMilliseconds >= 35 + || sample.pendingSlotOccupied + let isStableWindow = sample.droppedFrameRatio < 0.02 + && sample.latestRenderLatencyMilliseconds < 20 + && !sample.pendingSlotOccupied + + if isPressureWindow { + pressureWindowCount += 1 + stableWindowCount = 0 + if pressureWindowCount >= 2, let nextLowerTier = currentAutomaticMixedTier.nextLowerTier { + currentAutomaticMixedTier = nextLowerTier + stableWindowCount = 0 + pressureWindowCount = 0 + return nextLowerTier + } + return nil + } + + if isStableWindow { + stableWindowCount += 1 + pressureWindowCount = 0 + if stableWindowCount >= 4, let nextHigherTier = currentAutomaticMixedTier.nextHigherTier { + currentAutomaticMixedTier = nextHigherTier + stableWindowCount = 0 + pressureWindowCount = 0 + return nextHigherTier + } + return nil + } + + stableWindowCount = 0 + pressureWindowCount = 0 + return nil + } +} + +nonisolated enum DisplayCaptureConfigurationStateMachine { + nonisolated static func defaultFrameRateTier( + for profile: DisplayCaptureProfile, + performanceMode: CapturePerformanceMode, + adaptivePolicy: DisplayCaptureAdaptivePolicyState = .init() + ) -> DisplayCaptureFrameRateTier { + switch performanceMode { + case .automatic: + switch profile { + case .previewOnly: + .fps60 + case .shareOnly: + .fps60 + case .mixed: + adaptivePolicy.currentAutomaticMixedTier + } + case .smooth: + .fps60 + case .powerEfficient: + switch profile { + case .previewOnly: + .fps45 + case .shareOnly, .mixed: + .fps30 + } + } + } + + nonisolated static func desiredConfiguration( + previewSinkCount: Int, + sharingActive: Bool, + performanceMode: CapturePerformanceMode, + adaptivePolicy: DisplayCaptureAdaptivePolicyState + ) -> DisplayCaptureConfiguration? { + guard let profile = DisplayCaptureProfileStateMachine.desiredProfile( + previewSinkCount: previewSinkCount, + sharingActive: sharingActive + ) else { + return nil + } + return DisplayCaptureConfiguration( + profile: profile, + frameRateTier: defaultFrameRateTier( + for: profile, + performanceMode: performanceMode, + adaptivePolicy: adaptivePolicy + ) + ) + } + + nonisolated static func decideTransition( + desiredConfiguration: DisplayCaptureConfiguration?, + currentConfiguration: DisplayCaptureConfiguration, + lastConfigurationSwitchTimeNs: UInt64?, + nowNs: UInt64, + minimumDwellNanoseconds: UInt64 + ) -> DisplayCaptureConfigurationDecision { + guard let desiredConfiguration else { return .noChange } + guard desiredConfiguration != currentConfiguration else { return .noChange } + guard let lastConfigurationSwitchTimeNs else { + return .applyNow(desiredConfiguration) + } + + let elapsed = nowNs &- lastConfigurationSwitchTimeNs + if elapsed >= minimumDwellNanoseconds { + return .applyNow(desiredConfiguration) + } + return .applyAfter( + desiredConfiguration, + delayNanoseconds: minimumDwellNanoseconds - elapsed + ) + } +} + +nonisolated struct DisplayCaptureConfigurationCoordinatorState: Sendable, Equatable { + var previewSinkCount = 0 + var sharingActive = false + var performanceMode: CapturePerformanceMode + var adaptivePolicy = DisplayCaptureAdaptivePolicyState() + var committedConfiguration: DisplayCaptureConfiguration + var inFlightConfiguration: DisplayCaptureConfiguration? + var lastConfigurationSwitchTimeNs: UInt64? + + init( + committedConfiguration: DisplayCaptureConfiguration, + performanceMode: CapturePerformanceMode, + lastConfigurationSwitchTimeNs: UInt64? = nil + ) { + self.performanceMode = performanceMode + self.committedConfiguration = committedConfiguration + self.lastConfigurationSwitchTimeNs = lastConfigurationSwitchTimeNs + adaptivePolicy.rebase( + desiredProfile: committedConfiguration.profile, + performanceMode: performanceMode + ) + } + + mutating func mutateDemand( + nowNs: UInt64, + minimumDwellNanoseconds: UInt64, + mutation: (inout DisplayCaptureConfigurationCoordinatorState) -> Void + ) -> DisplayCaptureConfigurationDecision { + mutation(&self) + adaptivePolicy.rebase( + desiredProfile: currentDesiredProfile, + performanceMode: performanceMode + ) + return evaluateTransition( + nowNs: nowNs, + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + } + + mutating func updatePerformanceMode( + _ mode: CapturePerformanceMode, + nowNs: UInt64, + minimumDwellNanoseconds: UInt64 + ) -> DisplayCaptureConfigurationDecision { + performanceMode = mode + adaptivePolicy.rebase( + desiredProfile: currentDesiredProfile, + performanceMode: mode + ) + return evaluateTransition( + nowNs: nowNs, + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + } + + mutating func recordPreviewPerformanceSample( + _ sample: DisplayPreviewPerformanceSample, + nowNs: UInt64, + minimumDwellNanoseconds: UInt64 + ) -> DisplayCaptureConfigurationDecision { + let desiredProfile = currentDesiredProfile + adaptivePolicy.rebase(desiredProfile: desiredProfile, performanceMode: performanceMode) + guard desiredProfile == .mixed, performanceMode == .automatic else { + return evaluateTransition( + nowNs: nowNs, + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + } + _ = adaptivePolicy.recordAutomaticMixedSample(sample) + return evaluateTransition( + nowNs: nowNs, + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + } + + mutating func resumeScheduledTransition( + nowNs: UInt64, + minimumDwellNanoseconds: UInt64 + ) -> DisplayCaptureConfigurationDecision { + evaluateTransition( + nowNs: nowNs, + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + } + + mutating func finishAppliedTransition( + at nowNs: UInt64, + minimumDwellNanoseconds: UInt64 + ) -> DisplayCaptureConfigurationDecision { + guard let inFlightConfiguration else { return .noChange } + committedConfiguration = inFlightConfiguration + self.inFlightConfiguration = nil + lastConfigurationSwitchTimeNs = nowNs + adaptivePolicy.rebase( + desiredProfile: committedConfiguration.profile, + performanceMode: performanceMode + ) + return evaluateTransition( + nowNs: nowNs, + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + } + + mutating func failAppliedTransition() { + inFlightConfiguration = nil + } + + private var currentDesiredProfile: DisplayCaptureProfile? { + DisplayCaptureProfileStateMachine.desiredProfile( + previewSinkCount: previewSinkCount, + sharingActive: sharingActive + ) + } + + private mutating func evaluateTransition( + nowNs: UInt64, + minimumDwellNanoseconds: UInt64 + ) -> DisplayCaptureConfigurationDecision { + guard inFlightConfiguration == nil else { + return .noChange + } + let decision = DisplayCaptureConfigurationStateMachine.decideTransition( + desiredConfiguration: DisplayCaptureConfigurationStateMachine.desiredConfiguration( + previewSinkCount: previewSinkCount, + sharingActive: sharingActive, + performanceMode: performanceMode, + adaptivePolicy: adaptivePolicy + ), + currentConfiguration: committedConfiguration, + lastConfigurationSwitchTimeNs: lastConfigurationSwitchTimeNs, + nowNs: nowNs, + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + if case .applyNow(let configuration) = decision { + inFlightConfiguration = configuration + } + return decision + } +} + protocol DisplayPreviewSink: AnyObject, Sendable { nonisolated func submitFrame(_ sampleBuffer: CMSampleBuffer) } @@ -185,6 +525,7 @@ nonisolated struct SendableDisplay: @unchecked Sendable { struct DisplayCaptureMetricsSnapshot: Sendable { var currentProfile: DisplayCaptureProfile? + var currentFrameRateTier: DisplayCaptureFrameRateTier? var receivedFrameCount: UInt64 var profileReconfigurationCount: UInt64 var cursorOverrideReconfigurationCount: UInt64 @@ -194,23 +535,34 @@ protocol DisplayCaptureSessioning: AnyObject, Sendable { nonisolated var sessionHub: WebRTCSessionHub { get } nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) + nonisolated func reportPreviewPerformanceSample(_ sample: DisplayPreviewPerformanceSample) nonisolated func stopSharing() nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws nonisolated func retainShareCursorOverride() async throws nonisolated func releaseShareCursorOverride() async throws nonisolated func setSharingActive(_ isActive: Bool) async throws + nonisolated func setPerformanceMode(_ mode: CapturePerformanceMode) async throws nonisolated func captureMetricsSnapshot() -> DisplayCaptureMetricsSnapshot nonisolated func stop() async } extension DisplayCaptureSessioning { + nonisolated func reportPreviewPerformanceSample(_ sample: DisplayPreviewPerformanceSample) { + _ = sample + } + nonisolated func setSharingActive(_ isActive: Bool) async throws { _ = isActive } + nonisolated func setPerformanceMode(_ mode: CapturePerformanceMode) async throws { + _ = mode + } + nonisolated func captureMetricsSnapshot() -> DisplayCaptureMetricsSnapshot { .init( currentProfile: nil, + currentFrameRateTier: nil, receivedFrameCount: 0, profileReconfigurationCount: 0, cursorOverrideReconfigurationCount: 0 diff --git a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift index 3675886..160b026 100644 --- a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift +++ b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift @@ -34,6 +34,7 @@ struct CaptureDisplayView: View { @State private var scaleMode: PreviewScaleMode = .fit @State private var capturesCursor = false @State private var isUpdatingCursorCapture = false + @State private var lastReportedRendererMetrics: ZeroCopyPreviewRenderer.MetricsSnapshot? private var session: ScreenMonitoringSession? { capture.monitoringSession(for: sessionId) @@ -174,6 +175,10 @@ struct CaptureDisplayView: View { capture.closeMonitoringSession(id: sessionId) windowCoordinator.tearDown() renderer.flush() + lastReportedRendererMetrics = nil + } + .task(id: sessionId) { + await reportPreviewPerformanceLoop() } .onChange(of: renderer.framePixelSize) { _, _ in windowCoordinator.update(aspect: preferredAspect(), shouldLockAspect: scaleMode == .fit) @@ -199,6 +204,33 @@ struct CaptureDisplayView: View { // MARK: - Window Sizing extension CaptureDisplayView { + @MainActor + private func reportPreviewPerformanceLoop() async { + while !Task.isCancelled { + do { + try await Task.sleep(for: .seconds(5)) + } catch { + return + } + + guard let session else { continue } + let currentMetrics = renderer.metricsSnapshot() + let previousMetrics = lastReportedRendererMetrics + lastReportedRendererMetrics = currentMetrics + + let renderedDelta = currentMetrics.renderedFrameCount &- (previousMetrics?.renderedFrameCount ?? 0) + let droppedDelta = currentMetrics.droppedFrameCount &- (previousMetrics?.droppedFrameCount ?? 0) + let sample = DisplayPreviewPerformanceSample( + renderedFrameCount: renderedDelta, + droppedFrameCount: droppedDelta, + latestRenderLatencyMilliseconds: currentMetrics.latestRenderLatencyMilliseconds ?? 0, + pendingSlotOccupied: currentMetrics.pendingSlotOccupied, + capturedAt: DispatchTime.now().uptimeNanoseconds + ) + session.previewSubscription.reportPerformanceSample(sample) + } + } + private var cursorCaptureBinding: Binding { Binding( get: { effectiveCapturesCursor }, diff --git a/VoidDisplay/Features/Sharing/Views/SharePerformanceModePicker.swift b/VoidDisplay/Features/Sharing/Views/SharePerformanceModePicker.swift new file mode 100644 index 0000000..87bce98 --- /dev/null +++ b/VoidDisplay/Features/Sharing/Views/SharePerformanceModePicker.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct SharePerformanceModePicker: View { + @Environment(CapturePerformancePreferences.self) private var capturePerformancePreferences + + var body: some View { + VStack(spacing: AppUI.Spacing.small) { + HStack(alignment: .center, spacing: AppUI.Spacing.medium) { + Text("Share smoothness") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + + Picker("Capture Performance", selection: modeBinding) { + Text("Automatic").tag(CapturePerformanceMode.automatic) + Text("Smooth").tag(CapturePerformanceMode.smooth) + Text("Power Efficient").tag(CapturePerformanceMode.powerEfficient) + } + .labelsHidden() + .pickerStyle(.segmented) + .controlSize(.small) + } + .frame(maxWidth: .infinity, alignment: .center) + + Text("Automatic adapts frame rate for mixed preview and sharing.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .accessibilityIdentifier("share_capture_performance_picker") + } + + private var modeBinding: Binding { + Binding( + get: { capturePerformancePreferences.mode }, + set: { capturePerformancePreferences.saveMode($0) } + ) + } +} diff --git a/VoidDisplay/Features/Sharing/Views/ShareView.swift b/VoidDisplay/Features/Sharing/Views/ShareView.swift index e69ef71..6550ef2 100644 --- a/VoidDisplay/Features/Sharing/Views/ShareView.swift +++ b/VoidDisplay/Features/Sharing/Views/ShareView.swift @@ -124,6 +124,7 @@ struct ShareView: View { private var serviceStoppedState: some View { @Bindable var bindableViewModel = viewModel + let contentColumnWidth: CGFloat = 440 return stateContainer { VStack(spacing: AppUI.Spacing.medium + 2) { @@ -138,27 +139,33 @@ struct ShareView: View { .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) - .frame(maxWidth: 300) + .frame(width: contentColumnWidth) - VStack(spacing: 4) { - HStack(spacing: AppUI.Spacing.small) { - Text("Port") + VStack(spacing: AppUI.Spacing.medium) { + SharePerformanceModePicker() + .frame(width: contentColumnWidth) + + VStack(spacing: 4) { + HStack(spacing: AppUI.Spacing.small) { + Text("Port") + .font(.caption) + .foregroundStyle(.secondary) + TextField("8089", text: $bindableViewModel.servicePortInput) + .textFieldStyle(.roundedBorder) + .frame(width: 84) + .accessibilityIdentifier("share_port_input") + } + .frame(width: contentColumnWidth, alignment: .center) + + Text(viewModel.portInputErrorMessage ?? " ") .font(.caption) - .foregroundStyle(.secondary) - TextField("8089", text: $bindableViewModel.servicePortInput) - .textFieldStyle(.roundedBorder) - .frame(width: 84) - .accessibilityIdentifier("share_port_input") + .foregroundStyle(viewModel.portInputErrorMessage == nil ? .clear : .red) + .lineLimit(1) + .truncationMode(.tail) + .multilineTextAlignment(.center) + .frame(minWidth: contentColumnWidth, maxWidth: contentColumnWidth, minHeight: 14, maxHeight: 14, alignment: .center) + .accessibilityIdentifier("share_port_error_text") } - - Text(viewModel.portInputErrorMessage ?? " ") - .font(.caption) - .foregroundStyle(viewModel.portInputErrorMessage == nil ? .clear : .red) - .lineLimit(1) - .truncationMode(.tail) - .multilineTextAlignment(.center) - .frame(maxWidth: 360, minHeight: 14, maxHeight: 14, alignment: .center) - .accessibilityIdentifier("share_port_error_text") } Button("Start Service") { @@ -221,6 +228,8 @@ struct ShareView: View { stateContainer { VStack(spacing: AppUI.Spacing.medium) { Text("No screen to share") + SharePerformanceModePicker() + .frame(maxWidth: 360) Button("Refresh") { viewModel.refreshDisplays() } diff --git a/VoidDisplay/Resources/Localizable.xcstrings b/VoidDisplay/Resources/Localizable.xcstrings index 12e6d82..4dc7807 100644 --- a/VoidDisplay/Resources/Localizable.xcstrings +++ b/VoidDisplay/Resources/Localizable.xcstrings @@ -284,6 +284,26 @@ } } }, + "Automatic" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动" + } + } + } + }, + "Automatic adapts frame rate for mixed preview and sharing." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动模式会在监听和共享同时开启时动态调整帧率。" + } + } + } + }, "Basic Info" : { "localizations" : { "zh-Hans" : { @@ -324,6 +344,26 @@ } } }, + "Capture Performance" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "采集性能" + } + } + } + }, + "Choose how screen monitoring and sharing balance smoothness and resource usage." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择屏幕监听与共享在流畅度和资源占用之间的平衡方式。" + } + } + } + }, "Click the + button in the top right to create a virtual display." : { "localizations" : { "zh-Hans" : { @@ -1139,6 +1179,17 @@ } } }, + "Performance" : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "性能模式" + } + } + } + }, "Permission denied for port %u. Choose another port and try again." : { "localizations" : { "zh-Hans" : { @@ -1259,6 +1310,16 @@ } } }, + "Power Efficient" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "节能优先" + } + } + } + }, "Preflight Permission" : { "localizations" : { "zh-Hans" : { @@ -1681,6 +1742,16 @@ } } }, + "Share smoothness" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "共享流畅度" + } + } + } + }, "Sharing" : { "localizations" : { "zh-Hans" : { @@ -1701,6 +1772,16 @@ } } }, + "Smooth" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "流畅优先" + } + } + } + }, "Some changes require rebuild when the display is running." : { "localizations" : { "zh-Hans" : { diff --git a/VoidDisplayTests/Features/Capture/Services/CapturePerformancePreferencesTests.swift b/VoidDisplayTests/Features/Capture/Services/CapturePerformancePreferencesTests.swift new file mode 100644 index 0000000..7407935 --- /dev/null +++ b/VoidDisplayTests/Features/Capture/Services/CapturePerformancePreferencesTests.swift @@ -0,0 +1,32 @@ +import Foundation +import Testing +@testable import VoidDisplay + +@MainActor +struct CapturePerformancePreferencesTests { + @Test + func defaultsToAutomaticWhenNoPreferenceExists() { + let suiteName = "CapturePerformancePreferencesTests.defaults.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let sut = CapturePerformancePreferences(defaults: defaults) + + #expect(sut.mode == .automatic) + } + + @Test + func saveModePersistsAcrossNewInstance() { + let suiteName = "CapturePerformancePreferencesTests.persist.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let sut = CapturePerformancePreferences(defaults: defaults) + sut.saveMode(.powerEfficient) + + let reloaded = CapturePerformancePreferences(defaults: defaults) + #expect(reloaded.mode == .powerEfficient) + } +} diff --git a/VoidDisplayTests/Features/Capture/Services/DisplayCaptureProfileStateMachineTests.swift b/VoidDisplayTests/Features/Capture/Services/DisplayCaptureProfileStateMachineTests.swift index 79e36e6..8502d57 100644 --- a/VoidDisplayTests/Features/Capture/Services/DisplayCaptureProfileStateMachineTests.swift +++ b/VoidDisplayTests/Features/Capture/Services/DisplayCaptureProfileStateMachineTests.swift @@ -93,23 +93,260 @@ struct DisplayCaptureProfileStateMachineTests { #expect( DisplayCaptureSession.captureFramesPerSecond( for: .previewOnly, + frameRateTier: .fps60, maximumPreviewFramesPerSecond: 60 ) == 60 ) #expect( DisplayCaptureSession.captureFramesPerSecond( for: .shareOnly, + frameRateTier: .fps60, maximumPreviewFramesPerSecond: 60 ) == 60 ) #expect( DisplayCaptureSession.captureFramesPerSecond( for: .mixed, + frameRateTier: .fps45, maximumPreviewFramesPerSecond: 60 ) == 45 ) } + @Test func performanceModesMapToExpectedFrameRateTiers() { + #expect( + DisplayCaptureConfigurationStateMachine.defaultFrameRateTier( + for: .previewOnly, + performanceMode: .automatic + ) == .fps60 + ) + #expect( + DisplayCaptureConfigurationStateMachine.defaultFrameRateTier( + for: .shareOnly, + performanceMode: .automatic + ) == .fps60 + ) + #expect( + DisplayCaptureConfigurationStateMachine.defaultFrameRateTier( + for: .mixed, + performanceMode: .automatic + ) == .fps45 + ) + #expect( + DisplayCaptureConfigurationStateMachine.defaultFrameRateTier( + for: .mixed, + performanceMode: .smooth + ) == .fps60 + ) + #expect( + DisplayCaptureConfigurationStateMachine.defaultFrameRateTier( + for: .mixed, + performanceMode: .powerEfficient + ) == .fps30 + ) + } + + @Test func automaticMixedModeDropsTo30AfterTwoPressureWindows() { + var coordinator = DisplayCaptureConfigurationCoordinatorState( + committedConfiguration: .init(profile: .mixed, frameRateTier: .fps45), + performanceMode: .automatic + ) + coordinator.previewSinkCount = 1 + coordinator.sharingActive = true + + let firstDecision = coordinator.recordPreviewPerformanceSample( + .init( + renderedFrameCount: 90, + droppedFrameCount: 10, + latestRenderLatencyMilliseconds: 12, + pendingSlotOccupied: false, + capturedAt: 1 + ), + nowNs: 1, + minimumDwellNanoseconds: 0 + ) + #expect(firstDecision == .noChange) + + let secondDecision = coordinator.recordPreviewPerformanceSample( + .init( + renderedFrameCount: 85, + droppedFrameCount: 15, + latestRenderLatencyMilliseconds: 14, + pendingSlotOccupied: false, + capturedAt: 2 + ), + nowNs: 2, + minimumDwellNanoseconds: 0 + ) + switch secondDecision { + case .applyNow(let configuration): + #expect(configuration.profile == .mixed) + #expect(configuration.frameRateTier == .fps30) + default: + Issue.record("Expected automatic mixed mode to drop to 30fps, got \(String(describing: secondDecision))") + } + } + + @Test func automaticMixedModeRisesBackTo60AcrossStableWindows() { + var coordinator = DisplayCaptureConfigurationCoordinatorState( + committedConfiguration: .init(profile: .mixed, frameRateTier: .fps45), + performanceMode: .automatic + ) + coordinator.previewSinkCount = 1 + coordinator.sharingActive = true + + let pressureDecision = coordinator.recordPreviewPerformanceSample( + .init( + renderedFrameCount: 80, + droppedFrameCount: 20, + latestRenderLatencyMilliseconds: 10, + pendingSlotOccupied: false, + capturedAt: 1 + ), + nowNs: 1, + minimumDwellNanoseconds: 0 + ) + #expect(pressureDecision == .noChange) + let downgradeDecision = coordinator.recordPreviewPerformanceSample( + .init( + renderedFrameCount: 70, + droppedFrameCount: 30, + latestRenderLatencyMilliseconds: 10, + pendingSlotOccupied: false, + capturedAt: 2 + ), + nowNs: 2, + minimumDwellNanoseconds: 0 + ) + guard case .applyNow(let downgradedConfiguration) = downgradeDecision else { + Issue.record("Expected downgrade to 30fps before stable-window recovery.") + return + } + #expect(downgradedConfiguration.frameRateTier == .fps30) + + let postDowngradeDecision = coordinator.finishAppliedTransition( + at: 3, + minimumDwellNanoseconds: 0 + ) + #expect(postDowngradeDecision == .noChange) + + for index in 0..<3 { + let stableDecision = coordinator.recordPreviewPerformanceSample( + .init( + renderedFrameCount: 100, + droppedFrameCount: 0, + latestRenderLatencyMilliseconds: 10, + pendingSlotOccupied: false, + capturedAt: UInt64(10 + index) + ), + nowNs: UInt64(10 + index), + minimumDwellNanoseconds: 0 + ) + #expect(stableDecision == .noChange) + } + + let fourthStableDecision = coordinator.recordPreviewPerformanceSample( + .init( + renderedFrameCount: 100, + droppedFrameCount: 0, + latestRenderLatencyMilliseconds: 10, + pendingSlotOccupied: false, + capturedAt: 14 + ), + nowNs: 14, + minimumDwellNanoseconds: 0 + ) + switch fourthStableDecision { + case .applyNow(let configuration): + #expect(configuration.profile == .mixed) + #expect(configuration.frameRateTier == .fps45) + default: + Issue.record("Expected recovery to 45fps after four stable windows, got \(String(describing: fourthStableDecision))") + } + + let postRecoveryDecision = coordinator.finishAppliedTransition( + at: 15, + minimumDwellNanoseconds: 0 + ) + #expect(postRecoveryDecision == .noChange) + + for index in 0..<3 { + let stableDecision = coordinator.recordPreviewPerformanceSample( + .init( + renderedFrameCount: 100, + droppedFrameCount: 0, + latestRenderLatencyMilliseconds: 10, + pendingSlotOccupied: false, + capturedAt: UInt64(20 + index) + ), + nowNs: UInt64(20 + index), + minimumDwellNanoseconds: 0 + ) + #expect(stableDecision == .noChange) + } + + let eighthStableDecision = coordinator.recordPreviewPerformanceSample( + .init( + renderedFrameCount: 100, + droppedFrameCount: 0, + latestRenderLatencyMilliseconds: 10, + pendingSlotOccupied: false, + capturedAt: 24 + ), + nowNs: 24, + minimumDwellNanoseconds: 0 + ) + switch eighthStableDecision { + case .applyNow(let configuration): + #expect(configuration.profile == .mixed) + #expect(configuration.frameRateTier == .fps60) + default: + Issue.record("Expected recovery to 60fps after another four stable windows, got \(String(describing: eighthStableDecision))") + } + } + + @Test func smoothAndPowerEfficientModesIgnoreAutomaticPreviewPressureSamples() { + var smoothCoordinator = DisplayCaptureConfigurationCoordinatorState( + committedConfiguration: .init(profile: .mixed, frameRateTier: .fps60), + performanceMode: .smooth + ) + smoothCoordinator.previewSinkCount = 1 + smoothCoordinator.sharingActive = true + + let smoothDecision = smoothCoordinator.recordPreviewPerformanceSample( + .init( + renderedFrameCount: 50, + droppedFrameCount: 50, + latestRenderLatencyMilliseconds: 60, + pendingSlotOccupied: true, + capturedAt: 1 + ), + nowNs: 1, + minimumDwellNanoseconds: 0 + ) + #expect(smoothDecision == .noChange) + + var powerCoordinator = DisplayCaptureConfigurationCoordinatorState( + committedConfiguration: .init(profile: .mixed, frameRateTier: .fps30), + performanceMode: .powerEfficient + ) + powerCoordinator.previewSinkCount = 1 + powerCoordinator.sharingActive = true + + let powerDecision = powerCoordinator.recordPreviewPerformanceSample( + .init( + renderedFrameCount: 100, + droppedFrameCount: 0, + latestRenderLatencyMilliseconds: 5, + pendingSlotOccupied: false, + capturedAt: 1 + ), + nowNs: 1, + minimumDwellNanoseconds: 0 + ) + #expect(powerDecision == .noChange) + } + @Test func committedTransitionUpdatesDwellBeforeReevaluatingPendingDemand() { var coordinator = DisplayCaptureProfileCoordinatorState(committedProfile: .previewOnly) diff --git a/VoidDisplayTests/Features/Capture/Services/DisplayCaptureRegistryTests.swift b/VoidDisplayTests/Features/Capture/Services/DisplayCaptureRegistryTests.swift index 5a6e93d..2960929 100644 --- a/VoidDisplayTests/Features/Capture/Services/DisplayCaptureRegistryTests.swift +++ b/VoidDisplayTests/Features/Capture/Services/DisplayCaptureRegistryTests.swift @@ -9,6 +9,7 @@ private final class FakeCaptureSession: DisplayCaptureSessioning, @unchecked Sen var stopSharingCalls = 0 var stopCalls = 0 var setSharingActiveCalls: [Bool] = [] + var setPerformanceModeCalls: [CapturePerformanceMode] = [] } private let counters = Mutex(Counters()) @@ -38,6 +39,10 @@ private final class FakeCaptureSession: DisplayCaptureSessioning, @unchecked Sen counters.withLock { $0.setSharingActiveCalls.append(isActive) } } + nonisolated func setPerformanceMode(_ mode: CapturePerformanceMode) async throws { + counters.withLock { $0.setPerformanceModeCalls.append(mode) } + } + nonisolated func stop() async { counters.withLock { $0.stopCalls += 1 } } @@ -53,6 +58,10 @@ private final class FakeCaptureSession: DisplayCaptureSessioning, @unchecked Sen var setSharingActiveCalls: [Bool] { counters.withLock { $0.setSharingActiveCalls } } + + var setPerformanceModeCalls: [CapturePerformanceMode] { + counters.withLock { $0.setPerformanceModeCalls } + } } private actor SessionStopGate { @@ -273,7 +282,7 @@ struct DisplayCaptureRegistryTests { let fakeSession = FakeCaptureSession() let factoryCallCount = Mutex(0) - let registry = DisplayCaptureRegistry(captureSessionFactory: { _, _ in + let registry = DisplayCaptureRegistry(captureSessionFactory: { _, _, _ in factoryCallCount.withLock { $0 += 1 } await gate.waitUntilOpen() return fakeSession @@ -314,7 +323,7 @@ struct DisplayCaptureRegistryTests { let replacementSession = FakeCaptureSession() let factoryCallCount = Mutex(0) - let registry = DisplayCaptureRegistry(captureSessionFactory: { _, _ in + let registry = DisplayCaptureRegistry(captureSessionFactory: { _, _, _ in factoryCallCount.withLock { $0 += 1 } return replacementSession }) @@ -363,7 +372,7 @@ struct DisplayCaptureRegistryTests { let sendableDisplay = SendableDisplay(display) let fakeSession = FakeCaptureSession() let initialProfiles = Mutex<[DisplayCaptureProfile]>([]) - let registry = DisplayCaptureRegistry(captureSessionFactory: { _, initialProfile in + let registry = DisplayCaptureRegistry(captureSessionFactory: { _, initialProfile, _ in initialProfiles.withLock { $0.append(initialProfile) } return fakeSession }) @@ -385,7 +394,7 @@ struct DisplayCaptureRegistryTests { let fakeSession = FakeCaptureSession() let factoryGate = CaptureSessionFactoryGate() let initialProfiles = Mutex<[DisplayCaptureProfile]>([]) - let registry = DisplayCaptureRegistry(captureSessionFactory: { _, initialProfile in + let registry = DisplayCaptureRegistry(captureSessionFactory: { _, initialProfile, _ in initialProfiles.withLock { $0.append(initialProfile) } await factoryGate.waitUntilOpen() return fakeSession @@ -452,6 +461,44 @@ struct DisplayCaptureRegistryTests { #expect(session.stopCalls == 1) } + @Test func updatingPerformanceModePropagatesToExistingSessionsAndNewSessions() async throws { + let displayID = CGDirectDisplayID(11011) + let display = MockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let sendableDisplay = SendableDisplay(display) + let installedSession = FakeCaptureSession() + let createdSession = FakeCaptureSession() + let createdModes = Mutex<[CapturePerformanceMode]>([]) + let registry = DisplayCaptureRegistry( + performanceMode: .automatic, + captureSessionFactory: { _, _, mode in + createdModes.withLock { $0.append(mode) } + return createdSession + } + ) + + await registry.installSessionForTesting( + displayID: displayID, + resolutionText: "1920 × 1080", + session: installedSession + ) + + await registry.updatePerformanceMode(.powerEfficient) + #expect(installedSession.setPerformanceModeCalls == [.powerEfficient]) + + let subscription = try await registry.acquireShare(display: sendableDisplay) + + #expect(createdModes.withLock { $0.first } == nil) + #expect(installedSession.setSharingActiveCalls == [true]) + + subscription.cancel() + + let newDisplayID = CGDirectDisplayID(11012) + let newDisplay = MockSCDisplay.make(displayID: newDisplayID, width: 1280, height: 720) + let newSubscription = try await registry.acquireShare(display: SendableDisplay(newDisplay)) + #expect(createdModes.withLock { $0.first } == .powerEfficient) + newSubscription.cancel() + } + private func waitUntil( timeout: Duration = .seconds(1), condition: @escaping @Sendable () async -> Bool diff --git a/VoidDisplayTests/Features/Capture/Services/DisplayPreviewSubscriptionTests.swift b/VoidDisplayTests/Features/Capture/Services/DisplayPreviewSubscriptionTests.swift index e9d8516..297ebb0 100644 --- a/VoidDisplayTests/Features/Capture/Services/DisplayPreviewSubscriptionTests.swift +++ b/VoidDisplayTests/Features/Capture/Services/DisplayPreviewSubscriptionTests.swift @@ -15,6 +15,7 @@ private final class MockDisplayCaptureSession: @unchecked Sendable, DisplayCaptu var attachedSinks: Set = [] var attachCallCount = 0 var detachCallCount = 0 + var reportedSamples: [DisplayPreviewPerformanceSample] = [] var stopSharingCallCount = 0 var stopCallCount = 0 } @@ -38,6 +39,10 @@ private final class MockDisplayCaptureSession: @unchecked Sendable, DisplayCaptu } } + nonisolated func reportPreviewPerformanceSample(_ sample: DisplayPreviewPerformanceSample) { + state.withLock { $0.reportedSamples.append(sample) } + } + nonisolated func stopSharing() { state.withLock { $0.stopSharingCallCount += 1 } } @@ -57,6 +62,10 @@ private final class MockDisplayCaptureSession: @unchecked Sendable, DisplayCaptu func snapshot() -> (attached: Int, attach: Int, detach: Int) { state.withLock { ($0.attachedSinks.count, $0.attachCallCount, $0.detachCallCount) } } + + func reportedSamples() -> [DisplayPreviewPerformanceSample] { + state.withLock { $0.reportedSamples } + } } struct DisplayPreviewSubscriptionTests { @@ -139,4 +148,25 @@ struct DisplayPreviewSubscriptionTests { #expect(snap.attach == 1) #expect(snap.detach == 1) } + + @Test func performanceSampleForwardsToSession() { + let session = MockDisplayCaptureSession() + let subscription = DisplayPreviewSubscription( + displayID: 1, + resolutionText: "100 × 100", + session: session, + cancelClosure: {} + ) + let sample = DisplayPreviewPerformanceSample( + renderedFrameCount: 120, + droppedFrameCount: 4, + latestRenderLatencyMilliseconds: 18, + pendingSlotOccupied: false, + capturedAt: 99 + ) + + subscription.reportPerformanceSample(sample) + + #expect(session.reportedSamples() == [sample]) + } } From 1a242070d770d84c1a3b05f35caf1ed8827ff519 Mon Sep 17 00:00:00 2001 From: Chen Date: Thu, 2 Apr 2026 01:49:41 +0800 Subject: [PATCH 22/34] =?UTF-8?q?docs(agents):=20=E6=94=B6=E7=B4=A7?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E6=A8=A1=E5=BC=8F=E5=BB=BA=E8=AE=AE=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E6=97=B6=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 限制为执行前且存在真实分叉时才输出建议 - 排除完成汇报、状态更新和流程讨论等场景 - 用户已选模式或当前轮已开始执行时停止输出建议 --- AGENTS.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5e6b6df..d7ab029 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,11 +67,14 @@ - Clarification questions must include all reasonable current interpretations from the agent, so the user can confirm or correct them directly. ## Execution Mode Recommendation -- For any actionable request related to feature work, bug fixing, review follow-up, refactor, or implementation analysis, include a short execution mode recommendation in the response. -- Use `建议:直接执行` when scope is clear, affected area is bounded, validation path is clear, and there is no material decision gate before implementation. -- Use `建议:开启计划模式` when the task is ambiguous, cross-module, high-risk, multi-stage, blocked by unknowns, or depends on user choice between materially different options. -- Keep the recommendation to one or two sentences and state the concrete reason for the choice. -- If the user explicitly requests plan mode or explicitly requests immediate execution, follow that instruction and still state the recommendation briefly for visibility. +- Provide an execution mode recommendation only before starting work on an actionable request and only when there is a meaningful choice between immediate execution and plan-first handling. +- Do not provide this recommendation in completion handoff, status updates, commit summaries, verification summaries, review results, or meta discussions about process, prompts, or repository policy. +- Do not provide this recommendation for analysis-only or question-only requests. +- Do not provide this recommendation when the user has already explicitly chosen the mode for the current turn. +- Once execution has started in the current turn, stop emitting execution mode recommendations. +- Use `建议:直接执行` only when implementation has not started, scope is clear, affected area is bounded, validation path is clear, and there is no material decision gate. +- Use `建议:开启计划模式` only when implementation has not started and the task is ambiguous, cross-module, high-risk, multi-stage, blocked by unknowns, or depends on user choice between materially different options. +- Keep the recommendation to one sentence and state the concrete reason. ## Code Review Output Policy - When review finds an issue, identify the root cause and provide a root-cause fix plan by default. From b4d55a4150c4808cba724bee80a787aaab0f339e Mon Sep 17 00:00:00 2001 From: Chen Date: Thu, 2 Apr 2026 02:17:44 +0800 Subject: [PATCH 23/34] =?UTF-8?q?fix(capture):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=80=A7=E8=83=BD=E6=A8=A1=E5=BC=8F=E7=8A=B6=E6=80=81=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E4=B8=8E=E4=BC=9A=E8=AF=9D=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将采集性能偏好改为可观察存储状态 - 让运行中的采集会话接入配置状态机并响应模式切换 - 补充性能模式状态与持久化回归测试 --- .../CapturePerformancePreferences.swift | 11 +- .../Services/DisplayCaptureSession.swift | 151 ++++++++++++------ .../CapturePerformancePreferencesTests.swift | 13 ++ ...splayCaptureProfileStateMachineTests.swift | 41 +++++ 4 files changed, 158 insertions(+), 58 deletions(-) diff --git a/VoidDisplay/Features/Capture/Services/CapturePerformancePreferences.swift b/VoidDisplay/Features/Capture/Services/CapturePerformancePreferences.swift index da8f307..ddce436 100644 --- a/VoidDisplay/Features/Capture/Services/CapturePerformancePreferences.swift +++ b/VoidDisplay/Features/Capture/Services/CapturePerformancePreferences.swift @@ -21,20 +21,19 @@ protocol CapturePerformancePreferencesProtocol: AnyObject { @MainActor @Observable final class CapturePerformancePreferences: CapturePerformancePreferencesProtocol { - private let defaults: UserDefaults - var onModeChanged: (@MainActor @Sendable (CapturePerformanceMode) -> Void)? + @ObservationIgnored private let defaults: UserDefaults + @ObservationIgnored var onModeChanged: (@MainActor @Sendable (CapturePerformanceMode) -> Void)? + var mode: CapturePerformanceMode init(defaults: UserDefaults) { self.defaults = defaults - } - - var mode: CapturePerformanceMode { let rawValue = defaults.string(forKey: CapturePerformancePreferenceKeys.mode) - return rawValue.flatMap(CapturePerformanceMode.init(rawValue:)) ?? .automatic + self.mode = rawValue.flatMap(CapturePerformanceMode.init(rawValue:)) ?? .automatic } func saveMode(_ mode: CapturePerformanceMode) { guard self.mode != mode else { return } + self.mode = mode defaults.set(mode.rawValue, forKey: CapturePerformancePreferenceKeys.mode) onModeChanged?(mode) } diff --git a/VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift b/VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift index 0955b85..0111b88 100644 --- a/VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift +++ b/VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift @@ -70,14 +70,14 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning } private struct DemandState { - var profileCoordinator: DisplayCaptureProfileCoordinatorState + var configurationCoordinator: DisplayCaptureConfigurationCoordinatorState var taskLifetime = DisplayCaptureTaskLifetimeState() var pendingTaskNonce: UInt64 = 0 - var pendingProfileTask: Task? + var pendingConfigurationTask: Task? var activeApplyTask: Task? } - nonisolated private static let minimumProfileDwellNanoseconds: UInt64 = 5_000_000_000 + nonisolated private static let minimumConfigurationDwellNanoseconds: UInt64 = 5_000_000_000 nonisolated let displayID: CGDirectDisplayID nonisolated let sessionHub: WebRTCSessionHub @@ -114,8 +114,12 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning self.configurationState = Mutex(state) self.demandState = Mutex( DemandState( - profileCoordinator: DisplayCaptureProfileCoordinatorState( - committedProfile: initialProfile + configurationCoordinator: DisplayCaptureConfigurationCoordinatorState( + committedConfiguration: .init( + profile: state.profile, + frameRateTier: state.frameRateTier + ), + performanceMode: initialPerformanceMode ) ) ) @@ -133,16 +137,16 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { fanout.attachPreviewSink(sink) scheduleDemandUpdate { state in - state.profileCoordinator.previewSinkCount += 1 + state.previewSinkCount += 1 } } nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { fanout.detachPreviewSink(sink) scheduleDemandUpdate { state in - state.profileCoordinator.previewSinkCount = max( + state.previewSinkCount = max( 0, - state.profileCoordinator.previewSinkCount - 1 + state.previewSinkCount - 1 ) } } @@ -185,10 +189,18 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning nonisolated func setSharingActive(_ isActive: Bool) async throws { scheduleDemandUpdate { state in - state.profileCoordinator.sharingActive = isActive + state.sharingActive = isActive } } + nonisolated func setPerformanceMode(_ mode: CapturePerformanceMode) async throws { + schedulePerformanceModeUpdate(mode) + } + + nonisolated func reportPreviewPerformanceSample(_ sample: DisplayPreviewPerformanceSample) { + schedulePreviewPerformanceSample(sample) + } + nonisolated func captureMetricsSnapshot() -> DisplayCaptureMetricsSnapshot { metrics.withLock { $0.snapshot() } } @@ -197,6 +209,7 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning try await stream.updateConfiguration(Self.makeStreamConfiguration(from: updatedState)) configurationState.withLock { state in state.profile = updatedState.profile + state.frameRateTier = updatedState.frameRateTier state.previewShowsCursor = updatedState.previewShowsCursor state.shareCursorOverrideCount = updatedState.shareCursorOverrideCount } @@ -205,8 +218,8 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning nonisolated func stop() async { demandState.withLock { state in _ = state.taskLifetime.invalidateAllTasks() - state.pendingProfileTask?.cancel() - state.pendingProfileTask = nil + state.pendingConfigurationTask?.cancel() + state.pendingConfigurationTask = nil state.activeApplyTask?.cancel() state.activeApplyTask = nil } @@ -225,35 +238,64 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning sessionHub.submitFrame(pixelBuffer: pixelBuffer, ptsUs: ptsUs) } - nonisolated private func scheduleDemandUpdate(_ mutation: (inout DemandState) -> Void) { - let decision = demandState.withLock { state -> (DisplayCaptureProfileDecision, UInt64) in - mutation(&state) - state.pendingProfileTask?.cancel() - state.pendingProfileTask = nil + nonisolated private func scheduleDemandUpdate( + _ mutation: (inout DisplayCaptureConfigurationCoordinatorState) -> Void + ) { + scheduleConfigurationDecision { state in + state.configurationCoordinator.mutateDemand( + nowNs: Self.currentTimeNanoseconds(), + minimumDwellNanoseconds: Self.minimumConfigurationDwellNanoseconds, + mutation: mutation + ) + } + } + + nonisolated private func schedulePerformanceModeUpdate(_ mode: CapturePerformanceMode) { + scheduleConfigurationDecision { state in + state.configurationCoordinator.updatePerformanceMode( + mode, + nowNs: Self.currentTimeNanoseconds(), + minimumDwellNanoseconds: Self.minimumConfigurationDwellNanoseconds + ) + } + } + + nonisolated private func schedulePreviewPerformanceSample(_ sample: DisplayPreviewPerformanceSample) { + scheduleConfigurationDecision { state in + state.configurationCoordinator.recordPreviewPerformanceSample( + sample, + nowNs: Self.currentTimeNanoseconds(), + minimumDwellNanoseconds: Self.minimumConfigurationDwellNanoseconds + ) + } + } + + nonisolated private func scheduleConfigurationDecision( + _ decisionProvider: (inout DemandState) -> DisplayCaptureConfigurationDecision + ) { + let decision = demandState.withLock { state -> (DisplayCaptureConfigurationDecision, UInt64) in + state.pendingConfigurationTask?.cancel() + state.pendingConfigurationTask = nil state.pendingTaskNonce &+= 1 - let now = Self.currentTimeNanoseconds() - let decision = state.profileCoordinator.mutateDemand( - nowNs: now, - minimumDwellNanoseconds: Self.minimumProfileDwellNanoseconds - ) { _ in } + let decision = decisionProvider(&state) return (decision, state.pendingTaskNonce) } - handleProfileDecision(decision.0, schedulingNonce: decision.1) + handleConfigurationDecision(decision.0, schedulingNonce: decision.1) } - nonisolated private func handleProfileDecision( - _ decision: DisplayCaptureProfileDecision, + nonisolated private func handleConfigurationDecision( + _ decision: DisplayCaptureConfigurationDecision, schedulingNonce: UInt64 ) { switch decision { case .noChange: return - case .applyNow(let profile): + case .applyNow(let configuration): let executionGeneration = demandState.withLock { $0.taskLifetime.currentGeneration } let task = Task { [weak self] in guard let self else { return } - try? await self.applyDemandDrivenProfile( - profile: profile, + try? await self.applyDemandDrivenConfiguration( + configuration: configuration, executionGeneration: executionGeneration ) } @@ -267,11 +309,11 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning case .applyAfter(_, let delayNanoseconds): let task = Task { [weak self] in try? await Task.sleep(nanoseconds: delayNanoseconds) - self?.resumeDemandDrivenProfileEvaluation(schedulingNonce: schedulingNonce) + self?.resumeDemandDrivenConfigurationEvaluation(schedulingNonce: schedulingNonce) } demandState.withLock { state in if state.pendingTaskNonce == schedulingNonce { - state.pendingProfileTask = task + state.pendingConfigurationTask = task } else { task.cancel() } @@ -279,37 +321,40 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning } } - nonisolated private func resumeDemandDrivenProfileEvaluation(schedulingNonce: UInt64) { - let decision = demandState.withLock { state -> (DisplayCaptureProfileDecision, UInt64)? in + nonisolated private func resumeDemandDrivenConfigurationEvaluation(schedulingNonce: UInt64) { + let decision = demandState.withLock { state -> (DisplayCaptureConfigurationDecision, UInt64)? in guard state.pendingTaskNonce == schedulingNonce else { return nil } - state.pendingProfileTask = nil + state.pendingConfigurationTask = nil state.pendingTaskNonce &+= 1 - let decision = state.profileCoordinator.resumeScheduledTransition( + let decision = state.configurationCoordinator.resumeScheduledTransition( nowNs: Self.currentTimeNanoseconds(), - minimumDwellNanoseconds: Self.minimumProfileDwellNanoseconds + minimumDwellNanoseconds: Self.minimumConfigurationDwellNanoseconds ) return (decision, state.pendingTaskNonce) } guard let decision else { return } - handleProfileDecision(decision.0, schedulingNonce: decision.1) + handleConfigurationDecision(decision.0, schedulingNonce: decision.1) } - nonisolated private func applyDemandDrivenProfile( - profile: DisplayCaptureProfile, + nonisolated private func applyDemandDrivenConfiguration( + configuration: DisplayCaptureConfiguration, executionGeneration: UInt64 ) async throws { guard isExecutionAllowed(for: executionGeneration) else { return } let updatedState = configurationState.withLock { state -> StreamConfigurationState? in - guard state.profile != profile else { return nil } + guard state.profile != configuration.profile || state.frameRateTier != configuration.frameRateTier else { + return nil + } var copy = state - copy.profile = profile + copy.profile = configuration.profile + copy.frameRateTier = configuration.frameRateTier return copy } guard let updatedState else { - finishDemandDrivenProfileFailure(executionGeneration: executionGeneration) + finishDemandDrivenConfigurationFailure(executionGeneration: executionGeneration) return } @@ -322,41 +367,43 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning configurationState.withLock { state in state.profile = updatedState.profile + state.frameRateTier = updatedState.frameRateTier state.previewShowsCursor = updatedState.previewShowsCursor state.shareCursorOverrideCount = updatedState.shareCursorOverrideCount } } catch is CancellationError { - finishDemandDrivenProfileFailure(executionGeneration: executionGeneration) + finishDemandDrivenConfigurationFailure(executionGeneration: executionGeneration) return } catch { - finishDemandDrivenProfileFailure(executionGeneration: executionGeneration) - AppErrorMapper.logFailure("Update capture profile", error: error, logger: AppLog.capture) + finishDemandDrivenConfigurationFailure(executionGeneration: executionGeneration) + AppErrorMapper.logFailure("Update capture configuration", error: error, logger: AppLog.capture) return } guard isExecutionAllowed(for: executionGeneration) else { return } metrics.withLock { metrics in - metrics.currentProfile = profile + metrics.currentProfile = configuration.profile + metrics.currentFrameRateTier = configuration.frameRateTier metrics.profileReconfigurationCount &+= 1 } - let decision = demandState.withLock { state -> (DisplayCaptureProfileDecision, UInt64)? in + let decision = demandState.withLock { state -> (DisplayCaptureConfigurationDecision, UInt64)? in guard state.taskLifetime.allowsExecution(for: executionGeneration) else { return nil } - state.pendingProfileTask?.cancel() - state.pendingProfileTask = nil + state.pendingConfigurationTask?.cancel() + state.pendingConfigurationTask = nil state.activeApplyTask = nil state.pendingTaskNonce &+= 1 - let decision = state.profileCoordinator.finishAppliedTransition( + let decision = state.configurationCoordinator.finishAppliedTransition( at: Self.currentTimeNanoseconds(), - minimumDwellNanoseconds: Self.minimumProfileDwellNanoseconds + minimumDwellNanoseconds: Self.minimumConfigurationDwellNanoseconds ) return (decision, state.pendingTaskNonce) } guard let decision else { return } - handleProfileDecision(decision.0, schedulingNonce: decision.1) + handleConfigurationDecision(decision.0, schedulingNonce: decision.1) } nonisolated private func isExecutionAllowed(for generation: UInt64) -> Bool { @@ -365,11 +412,11 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning } } - nonisolated private func finishDemandDrivenProfileFailure(executionGeneration: UInt64) { + nonisolated private func finishDemandDrivenConfigurationFailure(executionGeneration: UInt64) { demandState.withLock { state in guard state.taskLifetime.allowsExecution(for: executionGeneration) else { return } state.activeApplyTask = nil - state.profileCoordinator.failAppliedTransition() + state.configurationCoordinator.failAppliedTransition() } } } diff --git a/VoidDisplayTests/Features/Capture/Services/CapturePerformancePreferencesTests.swift b/VoidDisplayTests/Features/Capture/Services/CapturePerformancePreferencesTests.swift index 7407935..7b357d3 100644 --- a/VoidDisplayTests/Features/Capture/Services/CapturePerformancePreferencesTests.swift +++ b/VoidDisplayTests/Features/Capture/Services/CapturePerformancePreferencesTests.swift @@ -29,4 +29,17 @@ struct CapturePerformancePreferencesTests { let reloaded = CapturePerformancePreferences(defaults: defaults) #expect(reloaded.mode == .powerEfficient) } + + @Test + func saveModeUpdatesInMemoryStateImmediately() { + let suiteName = "CapturePerformancePreferencesTests.immediate.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let sut = CapturePerformancePreferences(defaults: defaults) + sut.saveMode(.smooth) + + #expect(sut.mode == .smooth) + } } diff --git a/VoidDisplayTests/Features/Capture/Services/DisplayCaptureProfileStateMachineTests.swift b/VoidDisplayTests/Features/Capture/Services/DisplayCaptureProfileStateMachineTests.swift index 8502d57..c52dc57 100644 --- a/VoidDisplayTests/Features/Capture/Services/DisplayCaptureProfileStateMachineTests.swift +++ b/VoidDisplayTests/Features/Capture/Services/DisplayCaptureProfileStateMachineTests.swift @@ -347,6 +347,47 @@ struct DisplayCaptureProfileStateMachineTests { #expect(powerDecision == .noChange) } + @Test func performanceModeUpdateRecomputesCommittedMixedConfiguration() { + var coordinator = DisplayCaptureConfigurationCoordinatorState( + committedConfiguration: .init(profile: .mixed, frameRateTier: .fps45), + performanceMode: .automatic + ) + coordinator.previewSinkCount = 1 + coordinator.sharingActive = true + + let smoothDecision = coordinator.updatePerformanceMode( + .smooth, + nowNs: 1, + minimumDwellNanoseconds: 0 + ) + switch smoothDecision { + case .applyNow(let configuration): + #expect(configuration.profile == .mixed) + #expect(configuration.frameRateTier == .fps60) + default: + Issue.record("Expected smooth mode update to promote mixed configuration to 60fps, got \(String(describing: smoothDecision))") + } + + let followUpDecision = coordinator.finishAppliedTransition( + at: 2, + minimumDwellNanoseconds: 0 + ) + #expect(followUpDecision == .noChange) + + let powerDecision = coordinator.updatePerformanceMode( + .powerEfficient, + nowNs: 3, + minimumDwellNanoseconds: 0 + ) + switch powerDecision { + case .applyNow(let configuration): + #expect(configuration.profile == .mixed) + #expect(configuration.frameRateTier == .fps30) + default: + Issue.record("Expected power efficient mode update to reduce mixed configuration to 30fps, got \(String(describing: powerDecision))") + } + } + @Test func committedTransitionUpdatesDwellBeforeReevaluatingPendingDemand() { var coordinator = DisplayCaptureProfileCoordinatorState(committedProfile: .previewOnly) From 48ab20e673f10316c54434356b4bc7997dffea8a Mon Sep 17 00:00:00 2001 From: Chen Date: Thu, 2 Apr 2026 02:23:28 +0800 Subject: [PATCH 24/34] =?UTF-8?q?docs(agents):=20=E8=A1=A5=E5=85=85=20code?= =?UTF-8?q?=20review=20=E5=9C=BA=E6=99=AF=E7=9A=84=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E5=BB=BA=E8=AE=AE=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 调整执行模式建议的禁用场景表述 - 为 analysis 和 question 请求补充 code review 例外条件 - 要求有可执行 findings 时在总结后追加一条模式建议 --- AGENTS.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d7ab029..9dadb07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,8 +68,9 @@ ## Execution Mode Recommendation - Provide an execution mode recommendation only before starting work on an actionable request and only when there is a meaningful choice between immediate execution and plan-first handling. -- Do not provide this recommendation in completion handoff, status updates, commit summaries, verification summaries, review results, or meta discussions about process, prompts, or repository policy. -- Do not provide this recommendation for analysis-only or question-only requests. +- Do not provide this recommendation in completion handoff, commit summaries, verification summaries, or meta discussions about process, prompts, or repository policy. +- Do not provide this recommendation for analysis-only or question-only requests, unless the current turn is a code review that produced actionable findings requiring follow-up implementation. +- For code review requests with actionable findings, append exactly one execution mode recommendation after the findings summary. - Do not provide this recommendation when the user has already explicitly chosen the mode for the current turn. - Once execution has started in the current turn, stop emitting execution mode recommendations. - Use `建议:直接执行` only when implementation has not started, scope is clear, affected area is bounded, validation path is clear, and there is no material decision gate. From e49dce114009d212addaff7fff00960d07db7264 Mon Sep 17 00:00:00 2001 From: Chen Date: Thu, 2 Apr 2026 16:52:51 +0800 Subject: [PATCH 25/34] =?UTF-8?q?fix(capture):=20=E4=B8=B2=E8=A1=8C?= =?UTF-8?q?=E5=8C=96=E9=87=87=E9=9B=86=E6=B5=81=E9=85=8D=E7=BD=AE=E6=9B=B4?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 用领域专用 actor 收口 SCStream 配置提交流程 - 补充重叠更新与失败恢复回归测试 - 增加 display 页面脚本 smoke test --- .../Services/DisplayCaptureSession.swift | 368 +++++++++++++----- ...splayCaptureProfileStateMachineTests.swift | 159 ++++++++ .../WebServerSocketIntegrationTests.swift | 171 ++++++++ 3 files changed, 607 insertions(+), 91 deletions(-) diff --git a/VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift b/VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift index 0111b88..0ab18b1 100644 --- a/VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift +++ b/VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift @@ -46,29 +46,264 @@ private struct DisplayCaptureMetrics: Sendable { } } -final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning { - private struct StreamConfigurationState: Sendable { - let width: Int - let height: Int - let maximumPreviewFramesPerSecond: Int - let queueDepth: Int - let capturesAudio: Bool - let pixelFormat: OSType - var profile: DisplayCaptureProfile - var frameRateTier: DisplayCaptureFrameRateTier - var previewShowsCursor: Bool - var shareCursorOverrideCount: Int - - nonisolated var minimumFrameInterval: CMTime { - let framesPerSecond = DisplayCaptureSession.captureFramesPerSecond( - for: profile, - frameRateTier: frameRateTier, - maximumPreviewFramesPerSecond: maximumPreviewFramesPerSecond - ) - return CMTime(value: 1, timescale: CMTimeScale(max(1, Int32(framesPerSecond)))) +nonisolated struct DisplayCaptureStreamConfigurationState: Sendable, Equatable { + let width: Int + let height: Int + let maximumPreviewFramesPerSecond: Int + let queueDepth: Int + let capturesAudio: Bool + let pixelFormat: OSType + var profile: DisplayCaptureProfile + var frameRateTier: DisplayCaptureFrameRateTier + var previewShowsCursor: Bool + var shareCursorOverrideCount: Int + + nonisolated var minimumFrameInterval: CMTime { + let framesPerSecond = DisplayCaptureSession.captureFramesPerSecond( + for: profile, + frameRateTier: frameRateTier, + maximumPreviewFramesPerSecond: maximumPreviewFramesPerSecond + ) + return CMTime(value: 1, timescale: CMTimeScale(max(1, Int32(framesPerSecond)))) + } +} + +private nonisolated func makeDisplayCaptureStreamConfiguration( + from state: DisplayCaptureStreamConfigurationState +) -> SCStreamConfiguration { + let config = SCStreamConfiguration() + config.width = state.width + config.height = state.height + config.minimumFrameInterval = state.minimumFrameInterval + config.queueDepth = state.queueDepth + config.showsCursor = state.shareCursorOverrideCount > 0 || state.previewShowsCursor + config.capturesAudio = state.capturesAudio + config.pixelFormat = state.pixelFormat + return config +} + +actor DisplayCaptureStreamConfigurationCoordinator { + typealias TestApplier = @Sendable (DisplayCaptureStreamConfigurationState) async throws -> Void + + private struct Waiter { + let revision: UInt64 + let continuation: CheckedContinuation + } + + private let stream: SCStream? + private let testApplier: TestApplier? + private var committedState: DisplayCaptureStreamConfigurationState + private var desiredState: DisplayCaptureStreamConfigurationState + private var committedRevision: UInt64 = 0 + private var nextRevision: UInt64 = 0 + private var pendingRevision: UInt64? + private var failedThroughRevision: UInt64? + private var lastFailure: (any Error)? + private var flushTask: Task? + private var waiters: [UUID: Waiter] = [:] + + init( + stream: SCStream, + initialState: DisplayCaptureStreamConfigurationState + ) { + self.stream = stream + self.testApplier = nil + self.committedState = initialState + self.desiredState = initialState + } + + init( + initialState: DisplayCaptureStreamConfigurationState, + applier: @escaping TestApplier + ) { + self.stream = nil + self.testApplier = applier + self.committedState = initialState + self.desiredState = initialState + } + + func setPreviewShowsCursor(_ showsCursor: Bool) async throws -> Bool { + try await applyMutation { state in + state.previewShowsCursor = showsCursor + } + } + + func retainShareCursorOverride() async throws -> Bool { + try await applyMutation { state in + state.shareCursorOverrideCount += 1 + } + } + + func releaseShareCursorOverride() async throws -> Bool { + try await applyMutation { state in + state.shareCursorOverrideCount = max(0, state.shareCursorOverrideCount - 1) + } + } + + func applyDemandDrivenConfiguration(_ configuration: DisplayCaptureConfiguration) async throws -> Bool { + try await applyMutation { state in + state.profile = configuration.profile + state.frameRateTier = configuration.frameRateTier + } + } + + func committedStateSnapshot() -> DisplayCaptureStreamConfigurationState { + committedState + } + + func cancelPending(error: any Error = CancellationError()) { + flushTask?.cancel() + flushTask = nil + + guard let pendingRevision else { return } + desiredState = committedState + self.pendingRevision = nil + failedThroughRevision = pendingRevision + lastFailure = error + failWaiters( + upTo: pendingRevision, + error: error + ) + } + + private func applyMutation( + _ mutation: (inout DisplayCaptureStreamConfigurationState) -> Void + ) async throws -> Bool { + var nextState = desiredState + mutation(&nextState) + + guard nextState != desiredState else { + if let pendingRevision { + try await waitForResolution(of: pendingRevision) + } + return false + } + + desiredState = nextState + nextRevision &+= 1 + let targetRevision = nextRevision + pendingRevision = targetRevision + if flushTask == nil { + flushTask = Task { + await self.flushLoop() + } } + try await waitForResolution(of: targetRevision) + return true } + private func waitForResolution(of revision: UInt64) async throws { + if committedRevision >= revision { + return + } + if let failedThroughRevision, + failedThroughRevision >= revision, + let lastFailure { + throw lastFailure + } + + let waiterID = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + if committedRevision >= revision { + continuation.resume(returning: ()) + return + } + if let failedThroughRevision, + failedThroughRevision >= revision, + let lastFailure { + continuation.resume(throwing: lastFailure) + return + } + waiters[waiterID] = Waiter( + revision: revision, + continuation: continuation + ) + } + } onCancel: { + Task { + await self.cancelWaiter(id: waiterID) + } + } + } + + private func flushLoop() async { + while let pendingRevision { + let stateToApply = desiredState + let revisionToApply = pendingRevision + + do { + try Task.checkCancellation() + try await applyState(stateToApply) + } catch { + let failedThrough = self.pendingRevision ?? revisionToApply + desiredState = committedState + self.pendingRevision = nil + failedThroughRevision = failedThrough + lastFailure = error + flushTask = nil + failWaiters( + upTo: failedThrough, + error: error + ) + return + } + + committedState = stateToApply + committedRevision = revisionToApply + resumeWaiters(upTo: revisionToApply) + + if self.pendingRevision == revisionToApply { + desiredState = committedState + self.pendingRevision = nil + } + } + + flushTask = nil + } + + private func applyState(_ state: DisplayCaptureStreamConfigurationState) async throws { + if let stream { + try await stream.updateConfiguration(makeDisplayCaptureStreamConfiguration(from: state)) + return + } + if let testApplier { + try await testApplier(state) + return + } + preconditionFailure("DisplayCaptureStreamConfigurationCoordinator requires an applier") + } + + private func cancelWaiter(id: UUID) { + guard let waiter = waiters.removeValue(forKey: id) else { return } + waiter.continuation.resume(throwing: CancellationError()) + } + + private func resumeWaiters(upTo revision: UInt64) { + let matchingIDs = waiters.compactMap { id, waiter in + waiter.revision <= revision ? id : nil + } + for id in matchingIDs { + guard let waiter = waiters.removeValue(forKey: id) else { continue } + waiter.continuation.resume(returning: ()) + } + } + + private func failWaiters( + upTo revision: UInt64, + error: any Error + ) { + let matchingIDs = waiters.compactMap { id, waiter in + waiter.revision <= revision ? id : nil + } + for id in matchingIDs { + guard let waiter = waiters.removeValue(forKey: id) else { continue } + waiter.continuation.resume(throwing: error) + } + } +} + +final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning { private struct DemandState { var configurationCoordinator: DisplayCaptureConfigurationCoordinatorState var taskLifetime = DisplayCaptureTaskLifetimeState() @@ -87,7 +322,7 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning nonisolated private let captureQueue: DispatchQueue nonisolated private let fanout = DisplaySampleFanout() nonisolated private let metrics = Mutex(DisplayCaptureMetrics()) - nonisolated private let configurationState: Mutex + nonisolated private let streamConfigurationCoordinator: DisplayCaptureStreamConfigurationCoordinator nonisolated private let demandState: Mutex nonisolated init( @@ -107,11 +342,14 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning initialProfile: initialProfile, initialPerformanceMode: initialPerformanceMode ) - let config = Self.makeStreamConfiguration(from: state) + let config = makeDisplayCaptureStreamConfiguration(from: state) let filter = try await Self.makeContentFilter(display: display) self.stream = SCStream(filter: filter, configuration: config, delegate: output) self.sessionHub = WebRTCSessionHub() - self.configurationState = Mutex(state) + self.streamConfigurationCoordinator = DisplayCaptureStreamConfigurationCoordinator( + stream: self.stream, + initialState: state + ) self.demandState = Mutex( DemandState( configurationCoordinator: DisplayCaptureConfigurationCoordinatorState( @@ -156,35 +394,21 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning } nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { - let updatedState = configurationState.withLock { state -> StreamConfigurationState in - guard state.previewShowsCursor != showsCursor else { return state } - var copy = state - copy.previewShowsCursor = showsCursor - return copy - } - guard updatedState.previewShowsCursor == showsCursor else { return } + let changed = try await streamConfigurationCoordinator.setPreviewShowsCursor(showsCursor) + guard changed else { return } metrics.withLock { $0.cursorOverrideReconfigurationCount &+= 1 } - try await applyStreamConfiguration(updatedState) } nonisolated func retainShareCursorOverride() async throws { - let updatedState = configurationState.withLock { state -> StreamConfigurationState in - var copy = state - copy.shareCursorOverrideCount += 1 - return copy - } + let changed = try await streamConfigurationCoordinator.retainShareCursorOverride() + guard changed else { return } metrics.withLock { $0.cursorOverrideReconfigurationCount &+= 1 } - try await applyStreamConfiguration(updatedState) } nonisolated func releaseShareCursorOverride() async throws { - let updatedState = configurationState.withLock { state -> StreamConfigurationState in - var copy = state - copy.shareCursorOverrideCount = max(0, copy.shareCursorOverrideCount - 1) - return copy - } + let changed = try await streamConfigurationCoordinator.releaseShareCursorOverride() + guard changed else { return } metrics.withLock { $0.cursorOverrideReconfigurationCount &+= 1 } - try await applyStreamConfiguration(updatedState) } nonisolated func setSharingActive(_ isActive: Bool) async throws { @@ -205,16 +429,6 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning metrics.withLock { $0.snapshot() } } - nonisolated private func applyStreamConfiguration(_ updatedState: StreamConfigurationState) async throws { - try await stream.updateConfiguration(Self.makeStreamConfiguration(from: updatedState)) - configurationState.withLock { state in - state.profile = updatedState.profile - state.frameRateTier = updatedState.frameRateTier - state.previewShowsCursor = updatedState.previewShowsCursor - state.shareCursorOverrideCount = updatedState.shareCursorOverrideCount - } - } - nonisolated func stop() async { demandState.withLock { state in _ = state.taskLifetime.invalidateAllTasks() @@ -223,6 +437,7 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning state.activeApplyTask?.cancel() state.activeApplyTask = nil } + await streamConfigurationCoordinator.cancelPending() stopSharing() try? await stream.stopCapture() } @@ -344,33 +559,13 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning ) async throws { guard isExecutionAllowed(for: executionGeneration) else { return } - let updatedState = configurationState.withLock { state -> StreamConfigurationState? in - guard state.profile != configuration.profile || state.frameRateTier != configuration.frameRateTier else { - return nil - } - var copy = state - copy.profile = configuration.profile - copy.frameRateTier = configuration.frameRateTier - return copy - } - guard let updatedState else { - finishDemandDrivenConfigurationFailure(executionGeneration: executionGeneration) - return - } + let changed: Bool do { try Task.checkCancellation() guard isExecutionAllowed(for: executionGeneration) else { return } - try await stream.updateConfiguration(Self.makeStreamConfiguration(from: updatedState)) - guard isExecutionAllowed(for: executionGeneration) else { return } - - configurationState.withLock { state in - state.profile = updatedState.profile - state.frameRateTier = updatedState.frameRateTier - state.previewShowsCursor = updatedState.previewShowsCursor - state.shareCursorOverrideCount = updatedState.shareCursorOverrideCount - } + changed = try await streamConfigurationCoordinator.applyDemandDrivenConfiguration(configuration) } catch is CancellationError { finishDemandDrivenConfigurationFailure(executionGeneration: executionGeneration) return @@ -380,6 +575,11 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning return } + guard changed else { + finishDemandDrivenConfigurationFailure(executionGeneration: executionGeneration) + return + } + guard isExecutionAllowed(for: executionGeneration) else { return } metrics.withLock { metrics in @@ -457,7 +657,7 @@ extension DisplayCaptureSession { showsCursor: Bool, initialProfile: DisplayCaptureProfile, initialPerformanceMode: CapturePerformanceMode - ) async throws -> StreamConfigurationState { + ) async throws -> DisplayCaptureStreamConfigurationState { let displayMode = CGDisplayCopyDisplayMode(display.displayID) let captureSize = preferredCaptureSize(display: display, displayMode: displayMode) @@ -467,7 +667,7 @@ extension DisplayCaptureSession { performanceMode: initialPerformanceMode ) - let state = StreamConfigurationState( + let state = DisplayCaptureStreamConfigurationState( width: captureSize.width, height: captureSize.height, maximumPreviewFramesPerSecond: previewFramesPerSecond, @@ -485,20 +685,6 @@ extension DisplayCaptureSession { return state } - nonisolated private static func makeStreamConfiguration( - from state: StreamConfigurationState - ) -> SCStreamConfiguration { - let config = SCStreamConfiguration() - config.width = state.width - config.height = state.height - config.minimumFrameInterval = state.minimumFrameInterval - config.queueDepth = state.queueDepth - config.showsCursor = state.shareCursorOverrideCount > 0 || state.previewShowsCursor - config.capturesAudio = state.capturesAudio - config.pixelFormat = state.pixelFormat - return config - } - nonisolated private static func preferredCaptureSize( display: SCDisplay, displayMode: CGDisplayMode? diff --git a/VoidDisplayTests/Features/Capture/Services/DisplayCaptureProfileStateMachineTests.swift b/VoidDisplayTests/Features/Capture/Services/DisplayCaptureProfileStateMachineTests.swift index c52dc57..d0d0c0a 100644 --- a/VoidDisplayTests/Features/Capture/Services/DisplayCaptureProfileStateMachineTests.swift +++ b/VoidDisplayTests/Features/Capture/Services/DisplayCaptureProfileStateMachineTests.swift @@ -1,6 +1,97 @@ +import Foundation +import CoreVideo import Testing @testable import VoidDisplay +private func makeTestStreamConfigurationState( + profile: DisplayCaptureProfile = .mixed, + frameRateTier: DisplayCaptureFrameRateTier = .fps45, + previewShowsCursor: Bool = false, + shareCursorOverrideCount: Int = 0 +) -> DisplayCaptureStreamConfigurationState { + DisplayCaptureStreamConfigurationState( + width: 1920, + height: 1080, + maximumPreviewFramesPerSecond: 60, + queueDepth: 2, + capturesAudio: false, + pixelFormat: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, + profile: profile, + frameRateTier: frameRateTier, + previewShowsCursor: previewShowsCursor, + shareCursorOverrideCount: shareCursorOverrideCount + ) +} + +private actor StreamConfigurationApplyGate { + private var isOpen = false + private var enteredCount = 0 + private var entryWaiters: [CheckedContinuation] = [] + private var openWaiters: [CheckedContinuation] = [] + + func waitForFirstEntry() async { + guard enteredCount == 0 else { return } + await withCheckedContinuation { continuation in + entryWaiters.append(continuation) + } + } + + func enter() async { + enteredCount += 1 + let pendingEntryWaiters = entryWaiters + entryWaiters.removeAll() + for waiter in pendingEntryWaiters { + waiter.resume() + } + + guard !isOpen else { return } + await withCheckedContinuation { continuation in + openWaiters.append(continuation) + } + } + + func open() { + guard !isOpen else { return } + isOpen = true + let pendingOpenWaiters = openWaiters + openWaiters.removeAll() + for waiter in pendingOpenWaiters { + waiter.resume() + } + } +} + +private actor StreamConfigurationRecorder { + private var states: [DisplayCaptureStreamConfigurationState] = [] + + func record(_ state: DisplayCaptureStreamConfigurationState) { + states.append(state) + } + + func snapshot() -> [DisplayCaptureStreamConfigurationState] { + states + } +} + +private struct StreamConfigurationCoordinatorTestError: Error {} + +private actor StreamConfigurationFailureController { + private var shouldFailNext = true + private var appliedStates: [DisplayCaptureStreamConfigurationState] = [] + + func apply(_ state: DisplayCaptureStreamConfigurationState) throws { + if shouldFailNext { + shouldFailNext = false + throw StreamConfigurationCoordinatorTestError() + } + appliedStates.append(state) + } + + func snapshot() -> [DisplayCaptureStreamConfigurationState] { + appliedStates + } +} + struct DisplayCaptureProfileStateMachineTests { @Test func desiredProfileMatchesPreviewAndSharingDemand() { #expect( @@ -442,4 +533,72 @@ struct DisplayCaptureProfileStateMachineTests { #expect(lifetime.allowsExecution(for: initialGeneration) == false) #expect(lifetime.allowsExecution(for: lifetime.currentGeneration)) } + + @Test func streamConfigurationCoordinatorPreservesOverlappingChanges() async throws { + let gate = StreamConfigurationApplyGate() + let recorder = StreamConfigurationRecorder() + let coordinator = DisplayCaptureStreamConfigurationCoordinator( + initialState: makeTestStreamConfigurationState(), + applier: { state in + await recorder.record(state) + await gate.enter() + } + ) + + let firstTask = Task { + try await coordinator.setPreviewShowsCursor(true) + } + await gate.waitForFirstEntry() + + let secondTask = Task { + try await coordinator.applyDemandDrivenConfiguration( + .init(profile: .mixed, frameRateTier: .fps30) + ) + } + + await gate.open() + + let firstChanged = try await firstTask.value + let secondChanged = try await secondTask.value + let committedState = await coordinator.committedStateSnapshot() + + #expect(firstChanged) + #expect(secondChanged) + #expect(committedState == makeTestStreamConfigurationState(frameRateTier: .fps30, previewShowsCursor: true)) + #expect( + await recorder.snapshot() == [ + makeTestStreamConfigurationState(previewShowsCursor: true), + makeTestStreamConfigurationState(frameRateTier: .fps30, previewShowsCursor: true) + ] + ) + } + + @Test func streamConfigurationCoordinatorRecoversFromFailedApplyUsingCommittedState() async throws { + let failureController = StreamConfigurationFailureController() + let coordinator = DisplayCaptureStreamConfigurationCoordinator( + initialState: makeTestStreamConfigurationState(), + applier: { state in + try await failureController.apply(state) + } + ) + + do { + _ = try await coordinator.setPreviewShowsCursor(true) + Issue.record("Expected first coordinator apply to fail") + } catch { + } + + let retryChanged = try await coordinator.applyDemandDrivenConfiguration( + .init(profile: .mixed, frameRateTier: .fps30) + ) + let committedState = await coordinator.committedStateSnapshot() + + #expect(retryChanged) + #expect(committedState == makeTestStreamConfigurationState(frameRateTier: .fps30)) + #expect( + await failureController.snapshot() == [ + makeTestStreamConfigurationState(frameRateTier: .fps30) + ] + ) + } } diff --git a/VoidDisplayTests/Features/Sharing/Integration/WebServerSocketIntegrationTests.swift b/VoidDisplayTests/Features/Sharing/Integration/WebServerSocketIntegrationTests.swift index 8ebde85..df0d0b3 100644 --- a/VoidDisplayTests/Features/Sharing/Integration/WebServerSocketIntegrationTests.swift +++ b/VoidDisplayTests/Features/Sharing/Integration/WebServerSocketIntegrationTests.swift @@ -1,5 +1,6 @@ import Foundation import Darwin +import JavaScriptCore import Testing @testable import VoidDisplay @@ -155,6 +156,28 @@ struct WebServerSocketIntegrationTests { #expect(responseText.contains("Display 7") == false) } + @Test func displayRouteScriptBootstrapsAndHandlesBasicUIStateChanges() async throws { + let setup = try await startServerOnRandomPort( + targetStateProvider: { _ in .active }, + sessionHubProvider: { _ in WebRTCSessionHub() } + ) + let server = setup.server + let portValue = setup.port + defer { server.stopListener() } + + let request = Data("GET /display HTTP/1.1\r\nHost: 127.0.0.1:\(portValue)\r\n\r\n".utf8) + let responseData = try await Task.detached { + try await sendRequestAndReadUntilClose(port: portValue, request: request) + }.value + + let responseText = try #require(String(data: responseData, encoding: .utf8)) + let smokeResult = try evaluateDisplayPageRuntimeScript(in: responseText) + + #expect(smokeResult.documentTitle == "Screen Share") + #expect(smokeResult.scaleButtonText == "Fit") + #expect(smokeResult.toggleCallCount >= 2) + } + @Test func oversizedIncompleteSignalFrameClosesConnection() async throws { let sessionHub = WebRTCSessionHub() let setup = try await startServerOnRandomPort( @@ -427,6 +450,154 @@ struct WebServerSocketIntegrationTests { } } +private struct DisplayPageScriptSmokeResult: Equatable { + let documentTitle: String + let scaleButtonText: String + let toggleCallCount: Int +} + +private enum DisplayPageScriptSmokeError: Error { + case missingRuntimeScript + case evaluationFailed(String) +} + +private func evaluateDisplayPageRuntimeScript( + in responseText: String +) throws -> DisplayPageScriptSmokeResult { + guard let scriptOpenRange = responseText.range(of: "", + range: scriptOpenRange.upperBound.. String { guard let value = String(data: data, encoding: .utf8) else { From 7f39d488bb422fdace53526fac35e012158075ab Mon Sep 17 00:00:00 2001 From: Chen Date: Fri, 3 Apr 2026 01:16:35 +0800 Subject: [PATCH 26/34] =?UTF-8?q?fix(sharing):=20=E5=BD=92=E4=B8=80?= =?UTF-8?q?=E5=8C=96=E4=B8=BB=E5=B1=8F=E5=88=AB=E5=90=8D=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E4=B8=8E=E9=A2=84=E8=A7=88=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 Web 入口将 /display 与 /signal 主屏别名解析为具体 shareID - 固化连接目标并补充主屏切换后的清理与统计回归测试 - 为 ShareView 预览注入 CapturePerformancePreferences 环境 --- .../Services/DisplaySharingCoordinator.swift | 14 ++ .../Sharing/Services/SharingService.swift | 3 + .../Services/WebServiceController.swift | 8 + .../Features/Sharing/Views/ShareView.swift | 1 + .../Features/Sharing/Web/WebServer.swift | 29 ++- .../SharingWorkflowSmokeTests.swift | 1 + .../Integration/SocketTestSupport.swift | 2 + .../WebServerSocketIntegrationTests.swift | 219 ++++++++++++++++-- .../DisplaySharingCoordinatorTests.swift | 23 ++ .../Services/SharingServiceTests.swift | 2 + .../Services/WebServiceControllerTests.swift | 8 + .../MockWebServiceController.swift | 3 + 12 files changed, 297 insertions(+), 16 deletions(-) diff --git a/VoidDisplay/Features/Sharing/Services/DisplaySharingCoordinator.swift b/VoidDisplay/Features/Sharing/Services/DisplaySharingCoordinator.swift index 1a92b7e..6868eca 100644 --- a/VoidDisplay/Features/Sharing/Services/DisplaySharingCoordinator.swift +++ b/VoidDisplay/Features/Sharing/Services/DisplaySharingCoordinator.swift @@ -236,6 +236,20 @@ final class DisplaySharingCoordinator { } } + func resolveConcreteTarget(for target: ShareTarget) -> ShareTarget? { + switch target { + case .main: + guard let resolvedMainID = resolvedMainDisplayID(), + let shareID = registrationsByDisplayID[resolvedMainID]?.shareID else { + return nil + } + return .id(shareID) + case .id(let id): + guard displayIDsByShareID[id] != nil else { return nil } + return .id(id) + } + } + func shareID(for displayID: CGDirectDisplayID) -> UInt32? { registrationsByDisplayID[displayID]?.shareID } diff --git a/VoidDisplay/Features/Sharing/Services/SharingService.swift b/VoidDisplay/Features/Sharing/Services/SharingService.swift index 404d60c..989ccfb 100644 --- a/VoidDisplay/Features/Sharing/Services/SharingService.swift +++ b/VoidDisplay/Features/Sharing/Services/SharingService.swift @@ -130,6 +130,9 @@ final class SharingService: SharingServiceProtocol { targetStateProvider: { [weak self] target in self?.sharingCoordinator.state(for: target) ?? .unknown }, + concreteTargetResolver: { [weak self] target in + self?.sharingCoordinator.resolveConcreteTarget(for: target) + }, sessionHubProvider: { [weak self] target in self?.sharingCoordinator.sessionHub(for: target) }, diff --git a/VoidDisplay/Features/Sharing/Services/WebServiceController.swift b/VoidDisplay/Features/Sharing/Services/WebServiceController.swift index 1498dbf..c27ae32 100644 --- a/VoidDisplay/Features/Sharing/Services/WebServiceController.swift +++ b/VoidDisplay/Features/Sharing/Services/WebServiceController.swift @@ -35,6 +35,7 @@ protocol WebServiceControllerProtocol: AnyObject { func start( requestedPort: UInt16, targetStateProvider: @escaping @MainActor @Sendable (ShareTarget) -> ShareTargetState, + concreteTargetResolver: @escaping @MainActor @Sendable (ShareTarget) -> ShareTarget?, sessionHubProvider: @escaping @MainActor @Sendable (ShareTarget) -> WebRTCSessionHub?, sharingEventSink: @escaping @Sendable (SharingSessionEvent) -> Void ) async -> WebServiceStartResult @@ -70,6 +71,7 @@ final class WebServiceController: WebServiceControllerProtocol { typealias WebServiceServerFactory = @MainActor @Sendable ( _ port: NWEndpoint.Port, _ targetStateProvider: @escaping @MainActor @Sendable (ShareTarget) -> ShareTargetState, + _ concreteTargetResolver: @escaping @MainActor @Sendable (ShareTarget) -> ShareTarget?, _ sessionHubProvider: @escaping @MainActor @Sendable (ShareTarget) -> WebRTCSessionHub?, _ sharingEventSink: @escaping @Sendable (SharingSessionEvent) -> Void, _ onListenerStopped: (@MainActor @Sendable (WebServiceServerStopReason) -> Void)? @@ -93,12 +95,14 @@ final class WebServiceController: WebServiceControllerProtocol { webServiceServerFactory: @escaping WebServiceServerFactory = { port, targetStateProvider, + concreteTargetResolver, sessionHubProvider, sharingEventSink, onListenerStopped in try WebServer( using: port, targetStateProvider: targetStateProvider, + concreteTargetResolver: concreteTargetResolver, sessionHubProvider: sessionHubProvider, sharingEventSink: sharingEventSink, onListenerStopped: onListenerStopped @@ -143,6 +147,7 @@ final class WebServiceController: WebServiceControllerProtocol { func start( requestedPort: UInt16, targetStateProvider: @escaping @MainActor @Sendable (ShareTarget) -> ShareTargetState, + concreteTargetResolver: @escaping @MainActor @Sendable (ShareTarget) -> ShareTarget?, sessionHubProvider: @escaping @MainActor @Sendable (ShareTarget) -> WebRTCSessionHub?, sharingEventSink: @escaping @Sendable (SharingSessionEvent) -> Void ) async -> WebServiceStartResult { @@ -173,6 +178,7 @@ final class WebServiceController: WebServiceControllerProtocol { requestedPort: requestedPort, operationNonce: nonce, targetStateProvider: targetStateProvider, + concreteTargetResolver: concreteTargetResolver, sessionHubProvider: sessionHubProvider, sharingEventSink: sharingEventSink ) @@ -218,6 +224,7 @@ final class WebServiceController: WebServiceControllerProtocol { requestedPort: UInt16, operationNonce: UInt64, targetStateProvider: @escaping @MainActor @Sendable (ShareTarget) -> ShareTargetState, + concreteTargetResolver: @escaping @MainActor @Sendable (ShareTarget) -> ShareTarget?, sessionHubProvider: @escaping @MainActor @Sendable (ShareTarget) -> WebRTCSessionHub?, sharingEventSink: @escaping @Sendable (SharingSessionEvent) -> Void ) async -> WebServiceStartResult { @@ -251,6 +258,7 @@ final class WebServiceController: WebServiceControllerProtocol { let server = try webServiceServerFactory( port, targetStateProvider, + concreteTargetResolver, sessionHubProvider, sharingEventSink, { [weak self] reason in diff --git a/VoidDisplay/Features/Sharing/Views/ShareView.swift b/VoidDisplay/Features/Sharing/Views/ShareView.swift index 6550ef2..6788b15 100644 --- a/VoidDisplay/Features/Sharing/Views/ShareView.swift +++ b/VoidDisplay/Features/Sharing/Views/ShareView.swift @@ -289,6 +289,7 @@ struct ShareView: View { topologyCoordinator: env.topology ) .environment(env.capture) + .environment(env.capturePerformancePreferences) .environment(env.sharing) .environment(env.virtualDisplay) .environment(env.topology) diff --git a/VoidDisplay/Features/Sharing/Web/WebServer.swift b/VoidDisplay/Features/Sharing/Web/WebServer.swift index 8edebb8..c2d2def 100644 --- a/VoidDisplay/Features/Sharing/Web/WebServer.swift +++ b/VoidDisplay/Features/Sharing/Web/WebServer.swift @@ -104,6 +104,7 @@ final class WebServer { private var activeConnections: [ObjectIdentifier: ActiveConnection] = [:] private var signalDecodersByConnectionKey: [ObjectIdentifier: WebSocketFrameDecoder] = [:] private let targetStateProvider: @MainActor @Sendable (ShareTarget) -> ShareTargetState + private let concreteTargetResolver: @MainActor @Sendable (ShareTarget) -> ShareTarget? private let sessionHubProvider: @MainActor @Sendable (ShareTarget) -> WebRTCSessionHub? private let sharingEventSink: @Sendable (SharingSessionEvent) -> Void private let onListenerStopped: (@MainActor @Sendable (WebServiceServerStopReason) -> Void)? @@ -118,11 +119,13 @@ final class WebServer { init( using port: NWEndpoint.Port = .http, targetStateProvider: @escaping @MainActor @Sendable (ShareTarget) -> ShareTargetState, + concreteTargetResolver: @escaping @MainActor @Sendable (ShareTarget) -> ShareTarget?, sessionHubProvider: @escaping @MainActor @Sendable (ShareTarget) -> WebRTCSessionHub?, sharingEventSink: @escaping @Sendable (SharingSessionEvent) -> Void, onListenerStopped: (@MainActor @Sendable (WebServiceServerStopReason) -> Void)? = nil ) throws { self.targetStateProvider = targetStateProvider + self.concreteTargetResolver = concreteTargetResolver self.sessionHubProvider = sessionHubProvider self.sharingEventSink = sharingEventSink self.onListenerStopped = onListenerStopped @@ -300,6 +303,10 @@ final class WebServer { sessionHubProvider(target) } + private func concreteTarget(for target: ShareTarget) -> ShareTarget? { + concreteTargetResolver(target) + } + private func displayPage(for target: ShareTarget) -> String { _ = target let title = "Screen Share" @@ -348,15 +355,31 @@ final class WebServer { failureContext: "Send root page response" ) case .showDisplayPage(let target): + guard let concreteTarget = concreteTarget(for: target) else { + sendResponseAndClose( + requestHandler.responseData(for: .notFound), + on: connection, + failureContext: "Reject unresolved display target" + ) + return + } sendResponseAndClose( requestHandler.responseData( - for: decision, - htmlBody: displayPage(for: target) + for: .showDisplayPage(concreteTarget), + htmlBody: displayPage(for: concreteTarget) ), on: connection, failureContext: "Send display page response" ) case .openSignalSocket(let target): + guard let concreteTarget = concreteTarget(for: target) else { + sendResponseAndClose( + requestHandler.responseData(for: .notFound), + on: connection, + failureContext: "Reject unresolved websocket target" + ) + return + } guard isValidWebSocketUpgrade(request.headers) else { sendResponseAndClose( requestHandler.responseData(for: .badRequest), @@ -365,7 +388,7 @@ final class WebServer { ) return } - openSignalSocket(on: connection, target: target, headers: request.headers) + openSignalSocket(on: connection, target: concreteTarget, headers: request.headers) case .badRequest, .sharingUnavailable, .methodNotAllowed, .notFound: sendResponseAndClose( requestHandler.responseData(for: decision), diff --git a/VoidDisplayTests/Features/Sharing/Integration/SharingWorkflowSmokeTests.swift b/VoidDisplayTests/Features/Sharing/Integration/SharingWorkflowSmokeTests.swift index 9b221b5..b253b87 100644 --- a/VoidDisplayTests/Features/Sharing/Integration/SharingWorkflowSmokeTests.swift +++ b/VoidDisplayTests/Features/Sharing/Integration/SharingWorkflowSmokeTests.swift @@ -27,6 +27,7 @@ struct SharingWorkflowSmokeTests { #expect(service.isWebServiceRunning) #expect(controller.startCallCount == 1) #expect(controller.capturedTargetStateProvider?(.main) == .knownInactive) + #expect(controller.capturedConcreteTargetResolver?(.main) == nil) #expect(controller.capturedSessionHubProvider?(.main) == nil) // Stopping sharing with no active capture should still be safe and disconnect clients. diff --git a/VoidDisplayTests/Features/Sharing/Integration/SocketTestSupport.swift b/VoidDisplayTests/Features/Sharing/Integration/SocketTestSupport.swift index 6eb9e2c..b073b1f 100644 --- a/VoidDisplayTests/Features/Sharing/Integration/SocketTestSupport.swift +++ b/VoidDisplayTests/Features/Sharing/Integration/SocketTestSupport.swift @@ -313,6 +313,7 @@ private final class StaticLiveHubStore { @MainActor func startServerOnRandomPort( targetStateProvider: @escaping @MainActor @Sendable (ShareTarget) -> ShareTargetState, + concreteTargetResolver: @escaping @MainActor @Sendable (ShareTarget) -> ShareTarget? = { $0 }, sessionHubProvider: @escaping @MainActor @Sendable (ShareTarget) -> WebRTCSessionHub?, sharingEventSink: @escaping @Sendable (SharingSessionEvent) -> Void = { _ in } ) async throws -> (server: WebServer, port: UInt16) { @@ -325,6 +326,7 @@ func startServerOnRandomPort( let server = try WebServer( using: endpointPort, targetStateProvider: targetStateProvider, + concreteTargetResolver: concreteTargetResolver, sessionHubProvider: sessionHubProvider, sharingEventSink: sharingEventSink ) diff --git a/VoidDisplayTests/Features/Sharing/Integration/WebServerSocketIntegrationTests.swift b/VoidDisplayTests/Features/Sharing/Integration/WebServerSocketIntegrationTests.swift index df0d0b3..c35c48c 100644 --- a/VoidDisplayTests/Features/Sharing/Integration/WebServerSocketIntegrationTests.swift +++ b/VoidDisplayTests/Features/Sharing/Integration/WebServerSocketIntegrationTests.swift @@ -28,6 +28,8 @@ private final class IntegrationAutoConnectingPeer: @unchecked Sendable, WebRTCPe @MainActor @Suite(.serialized) struct WebServerSocketIntegrationTests { + private static let mainAliasShareID: UInt32 = 5 + private static let replacementMainAliasShareID: UInt32 = 6 @Test func rootRouteSupportsFragmentedSocketRequest() async throws { let setup = try await startServerOnRandomPort( @@ -54,8 +56,18 @@ struct WebServerSocketIntegrationTests { targetStateProvider: { target in target == .main ? .active : .unknown }, + concreteTargetResolver: { target in + switch target { + case .main: + .id(Self.mainAliasShareID) + case .id(let id) where id == Self.mainAliasShareID: + .id(id) + default: + nil + } + }, sessionHubProvider: { target in - target == .main ? sessionHub : nil + target == .id(Self.mainAliasShareID) ? sessionHub : nil } ) let server = setup.server @@ -94,6 +106,16 @@ struct WebServerSocketIntegrationTests { let setup = try await startServerOnRandomPort( targetStateProvider: { _ in .active }, + concreteTargetResolver: { target in + switch target { + case .main: + .id(Self.mainAliasShareID) + case .id(let id) where id == Self.mainAliasShareID: + .id(id) + default: + nil + } + }, sessionHubProvider: { _ in WebRTCSessionHub() } ) let server = setup.server @@ -131,6 +153,8 @@ struct WebServerSocketIntegrationTests { #expect(responseText.contains("footnote")) #expect(responseText.contains("__PAGE_TITLE__") == false) #expect(responseText.contains("__SIGNAL_PATH__") == false) + #expect(responseText.contains("/signal/\(Self.mainAliasShareID)")) + #expect(responseText.contains(#"new WebSocket((window.location.protocol === "https:" ? "wss://" : "ws://") + window.location.host + "/signal");"#) == false) #expect(responseText.contains("Main Display") == false) #expect(responseText.contains("Display 1") == false) } @@ -159,6 +183,16 @@ struct WebServerSocketIntegrationTests { @Test func displayRouteScriptBootstrapsAndHandlesBasicUIStateChanges() async throws { let setup = try await startServerOnRandomPort( targetStateProvider: { _ in .active }, + concreteTargetResolver: { target in + switch target { + case .main: + .id(Self.mainAliasShareID) + case .id(let id) where id == Self.mainAliasShareID: + .id(id) + default: + nil + } + }, sessionHubProvider: { _ in WebRTCSessionHub() } ) let server = setup.server @@ -171,6 +205,7 @@ struct WebServerSocketIntegrationTests { }.value let responseText = try #require(String(data: responseData, encoding: .utf8)) + #expect(responseText.contains("/signal/\(Self.mainAliasShareID)")) let smokeResult = try evaluateDisplayPageRuntimeScript(in: responseText) #expect(smokeResult.documentTitle == "Screen Share") @@ -184,8 +219,18 @@ struct WebServerSocketIntegrationTests { targetStateProvider: { target in target == .main ? .active : .unknown }, + concreteTargetResolver: { target in + switch target { + case .main: + .id(Self.mainAliasShareID) + case .id(let id) where id == Self.mainAliasShareID: + .id(id) + default: + nil + } + }, sessionHubProvider: { target in - target == .main ? sessionHub : nil + target == .id(Self.mainAliasShareID) ? sessionHub : nil } ) let server = setup.server @@ -203,8 +248,18 @@ struct WebServerSocketIntegrationTests { targetStateProvider: { target in target == .main ? .active : .unknown }, + concreteTargetResolver: { target in + switch target { + case .main: + .id(Self.mainAliasShareID) + case .id(let id) where id == Self.mainAliasShareID: + .id(id) + default: + nil + } + }, sessionHubProvider: { target in - target == .main ? sessionHub : nil + target == .id(Self.mainAliasShareID) ? sessionHub : nil } ) let server = setup.server @@ -228,8 +283,18 @@ struct WebServerSocketIntegrationTests { targetStateProvider: { target in target == .main ? .active : .unknown }, + concreteTargetResolver: { target in + switch target { + case .main: + .id(Self.mainAliasShareID) + case .id(let id) where id == Self.mainAliasShareID: + .id(id) + default: + nil + } + }, sessionHubProvider: { target in - target == .main ? sessionHub : nil + target == .id(Self.mainAliasShareID) ? sessionHub : nil }, sharingEventSink: { event in Task { @MainActor in @@ -250,7 +315,7 @@ struct WebServerSocketIntegrationTests { sessionHub.activeClientCount == 0 && aggregator.currentSnapshot.signalingConnections == 0 && aggregator.currentSnapshot.streamingPeers == 0 && - aggregator.currentSnapshot.clientsByTarget[.main]?.isEmpty ?? true + aggregator.currentSnapshot.clientsByTarget[.id(Self.mainAliasShareID)]?.isEmpty ?? true } #expect(clientCleared) } @@ -266,8 +331,18 @@ struct WebServerSocketIntegrationTests { targetStateProvider: { target in target == .main ? .active : .unknown }, + concreteTargetResolver: { target in + switch target { + case .main: + .id(Self.mainAliasShareID) + case .id(let id) where id == Self.mainAliasShareID: + .id(id) + default: + nil + } + }, sessionHubProvider: { target in - target == .main ? sessionHub : nil + target == .id(Self.mainAliasShareID) ? sessionHub : nil }, sharingEventSink: { event in Task { @MainActor in @@ -293,8 +368,8 @@ struct WebServerSocketIntegrationTests { let snapshot = aggregator.currentSnapshot return snapshot.signalingConnections == 2 && snapshot.streamingPeers == 2 && - snapshot.signalingConnectionsByTarget[.main] == 2 && - snapshot.streamingPeersByTarget[.main] == 2 + snapshot.signalingConnectionsByTarget[.id(Self.mainAliasShareID)] == 2 && + snapshot.streamingPeersByTarget[.id(Self.mainAliasShareID)] == 2 } #expect(accumulated) @@ -331,9 +406,19 @@ struct WebServerSocketIntegrationTests { .unknown } }, - sessionHubProvider: { target in + concreteTargetResolver: { target in switch target { case .main: + .id(Self.mainAliasShareID) + case .id(let id) where id == Self.mainAliasShareID || id == 7: + .id(id) + default: + nil + } + }, + sessionHubProvider: { target in + switch target { + case .id(let id) where id == Self.mainAliasShareID: mainHub case .id(7): secondaryHub @@ -365,24 +450,119 @@ struct WebServerSocketIntegrationTests { let snapshot = aggregator.currentSnapshot return snapshot.signalingConnections == 2 && snapshot.streamingPeers == 2 && - snapshot.signalingConnectionsByTarget[.main] == 1 && + snapshot.signalingConnectionsByTarget[.id(Self.mainAliasShareID)] == 1 && snapshot.signalingConnectionsByTarget[.id(7)] == 1 && - snapshot.streamingPeersByTarget[.main] == 1 && + snapshot.streamingPeersByTarget[.id(Self.mainAliasShareID)] == 1 && snapshot.streamingPeersByTarget[.id(7)] == 1 && - server.streamClientCount(for: .main) == 1 && + server.streamClientCount(for: .id(Self.mainAliasShareID)) == 1 && server.streamClientCount(for: .id(7)) == 1 } #expect(isolated) } + @Test func aliasConnectionCleanupStaysBoundToOriginalConcreteTargetAfterMainSwitch() async throws { + let aggregator = SharingStateAggregator() + let originalHub = WebRTCSessionHub( + peerFactory: { callbacks in + IntegrationAutoConnectingPeer(onConnected: callbacks.onConnected) + } + ) + let replacementHub = WebRTCSessionHub( + peerFactory: { callbacks in + IntegrationAutoConnectingPeer(onConnected: callbacks.onConnected) + } + ) + let mainShareIDBox = MutableShareIDBox(Self.mainAliasShareID) + let setup = try await startServerOnRandomPort( + targetStateProvider: { target in + switch target { + case .main: + .active + case .id(let id) where id == Self.mainAliasShareID || id == Self.replacementMainAliasShareID: + .active + default: + .unknown + } + }, + concreteTargetResolver: { target in + switch target { + case .main: + .id(mainShareIDBox.value) + case .id(let id) where id == Self.mainAliasShareID || id == Self.replacementMainAliasShareID: + .id(id) + default: + nil + } + }, + sessionHubProvider: { target in + switch target { + case .id(let id) where id == Self.mainAliasShareID: + originalHub + case .id(let id) where id == Self.replacementMainAliasShareID: + replacementHub + default: + nil + } + }, + sharingEventSink: { event in + Task { @MainActor in + aggregator.record(event) + } + } + ) + let server = setup.server + let portValue = setup.port + defer { server.stopListener() } + + let socket = try await openWebSocket(path: "/signal", port: portValue) + defer { close(socket) } + + try sendAll(socket, data: makeMaskedTextFrame(#"{"type":"offer","sdp":"v=0"}"#)) + + let connectedToOriginalTarget = await waitUntilAsync(timeout: .seconds(2)) { + let snapshot = aggregator.currentSnapshot + return snapshot.signalingConnectionsByTarget[.id(Self.mainAliasShareID)] == 1 && + snapshot.streamingPeersByTarget[.id(Self.mainAliasShareID)] == 1 && + originalHub.activeClientCount == 1 && + replacementHub.activeClientCount == 0 + } + #expect(connectedToOriginalTarget) + + mainShareIDBox.setValue(Self.replacementMainAliasShareID) + + try sendAll(socket, data: makeMaskedCloseFrame()) + _ = try await waitForSocketClose(socket) + + let clearedFromOriginalTarget = await waitUntilAsync(timeout: .seconds(2)) { + let snapshot = aggregator.currentSnapshot + return snapshot.signalingConnections == 0 && + snapshot.streamingPeers == 0 && + snapshot.signalingConnectionsByTarget[.id(Self.mainAliasShareID)] == nil && + snapshot.streamingPeersByTarget[.id(Self.mainAliasShareID)] == nil && + originalHub.activeClientCount == 0 && + replacementHub.activeClientCount == 0 + } + #expect(clearedFromOriginalTarget) + } + @Test func binarySignalFrameClosesWithProtocolCodeAndRemovesActiveClient() async throws { let sessionHub = WebRTCSessionHub() let setup = try await startServerOnRandomPort( targetStateProvider: { target in target == .main ? .active : .unknown }, + concreteTargetResolver: { target in + switch target { + case .main: + .id(Self.mainAliasShareID) + case .id(let id) where id == Self.mainAliasShareID: + .id(id) + default: + nil + } + }, sessionHubProvider: { target in - target == .main ? sessionHub : nil + target == .id(Self.mainAliasShareID) ? sessionHub : nil } ) let server = setup.server @@ -456,6 +636,19 @@ private struct DisplayPageScriptSmokeResult: Equatable { let toggleCallCount: Int } +@MainActor +private final class MutableShareIDBox { + var value: UInt32 + + init(_ value: UInt32) { + self.value = value + } + + func setValue(_ newValue: UInt32) { + value = newValue + } +} + private enum DisplayPageScriptSmokeError: Error { case missingRuntimeScript case evaluationFailed(String) diff --git a/VoidDisplayTests/Features/Sharing/Services/DisplaySharingCoordinatorTests.swift b/VoidDisplayTests/Features/Sharing/Services/DisplaySharingCoordinatorTests.swift index 44d41de..9e9f24d 100644 --- a/VoidDisplayTests/Features/Sharing/Services/DisplaySharingCoordinatorTests.swift +++ b/VoidDisplayTests/Features/Sharing/Services/DisplaySharingCoordinatorTests.swift @@ -503,6 +503,29 @@ struct DisplaySharingCoordinatorTests { #expect(await waitUntil { activeSubscription.cancelCounter.value == 1 }) } + @MainActor + @Test func resolveConcreteTargetMapsMainAliasAndRejectsUnknownTargets() { + let coordinator = DisplaySharingCoordinator(idStore: DisplayShareIDStore(storeURL: temporaryStoreURL())) + coordinator.registerShareableDisplays([ + .init(displayID: 201, isMain: true, virtualSerial: nil), + .init(displayID: 202, isMain: false, virtualSerial: nil) + ]) + + guard let mainShareID = coordinator.shareID(for: 201), + let secondaryShareID = coordinator.shareID(for: 202) else { + Issue.record("Expected registered displays to receive concrete share IDs.") + return + } + + #expect(coordinator.resolveConcreteTarget(for: .main) == .id(mainShareID)) + #expect(coordinator.resolveConcreteTarget(for: .id(mainShareID)) == .id(mainShareID)) + #expect(coordinator.resolveConcreteTarget(for: .id(secondaryShareID)) == .id(secondaryShareID)) + #expect(coordinator.resolveConcreteTarget(for: .id(999_999)) == nil) + + let unresolvedCoordinator = DisplaySharingCoordinator(idStore: DisplayShareIDStore(storeURL: temporaryStoreURL())) + #expect(unresolvedCoordinator.resolveConcreteTarget(for: .main) == nil) + } + private func temporaryStoreURL() -> URL { let base = FileManager.default.temporaryDirectory .appendingPathComponent("display-sharing-coordinator-tests-\(UUID().uuidString)", isDirectory: true) diff --git a/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift b/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift index 9973897..6fc7d38 100644 --- a/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift +++ b/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift @@ -112,6 +112,8 @@ struct SharingServiceTests { #expect(sut.isWebServiceRunning) #expect(mock.capturedTargetStateProvider?(.main) == .knownInactive) #expect(mock.capturedTargetStateProvider?(.id(123)) == .unknown) + #expect(mock.capturedConcreteTargetResolver?(.main) == nil) + #expect(mock.capturedConcreteTargetResolver?(.id(123)) == nil) #expect(mock.capturedSessionHubProvider?(.main) == nil) #expect(mock.capturedSharingEventSink != nil) } diff --git a/VoidDisplayTests/Features/Sharing/Services/WebServiceControllerTests.swift b/VoidDisplayTests/Features/Sharing/Services/WebServiceControllerTests.swift index a63b066..c4eaa30 100644 --- a/VoidDisplayTests/Features/Sharing/Services/WebServiceControllerTests.swift +++ b/VoidDisplayTests/Features/Sharing/Services/WebServiceControllerTests.swift @@ -33,6 +33,7 @@ struct WebServiceControllerTests { let result = await sut.start( requestedPort: 1000, targetStateProvider: { _ in .unknown }, + concreteTargetResolver: { $0 }, sessionHubProvider: { _ in nil }, sharingEventSink: { _ in } ) @@ -52,6 +53,7 @@ struct WebServiceControllerTests { _ = await sut.start( requestedPort: 999, targetStateProvider: { _ in .unknown }, + concreteTargetResolver: { $0 }, sessionHubProvider: { _ in nil }, sharingEventSink: { _ in } ) @@ -73,6 +75,7 @@ struct WebServiceControllerTests { _ = await sut.start( requestedPort: 999, targetStateProvider: { _ in .unknown }, + concreteTargetResolver: { $0 }, sessionHubProvider: { _ in nil }, sharingEventSink: { _ in } ) @@ -97,6 +100,7 @@ struct WebServiceControllerTests { _ = await sut.start( requestedPort: 999, targetStateProvider: { _ in .unknown }, + concreteTargetResolver: { $0 }, sessionHubProvider: { _ in nil }, sharingEventSink: { _ in } ) @@ -273,6 +277,7 @@ struct WebServiceControllerTests { sut: WebServiceController, requestedPort: UInt16, targetStateProvider: @escaping @MainActor @Sendable (ShareTarget) -> ShareTargetState = { _ in .unknown }, + concreteTargetResolver: @escaping @MainActor @Sendable (ShareTarget) -> ShareTarget? = { $0 }, sessionHubProvider: @escaping @MainActor @Sendable (ShareTarget) -> WebRTCSessionHub? = { _ in nil } ) async -> (startTask: Task, server: ControlledWebServiceServer)? { let existingServerCount = harness.createdServers.count @@ -280,6 +285,7 @@ struct WebServiceControllerTests { await sut.start( requestedPort: requestedPort, targetStateProvider: targetStateProvider, + concreteTargetResolver: concreteTargetResolver, sessionHubProvider: sessionHubProvider, sharingEventSink: { _ in } ) @@ -358,12 +364,14 @@ private final class WebServiceServerHarness { func makeServer( _ port: NWEndpoint.Port, _ targetStateProvider: @escaping @MainActor @Sendable (ShareTarget) -> ShareTargetState, + _ concreteTargetResolver: @escaping @MainActor @Sendable (ShareTarget) -> ShareTarget?, _ sessionHubProvider: @escaping @MainActor @Sendable (ShareTarget) -> WebRTCSessionHub?, _ sharingEventSink: @escaping @MainActor @Sendable (SharingSessionEvent) -> Void, _ onListenerStopped: (@MainActor @Sendable (WebServiceServerStopReason) -> Void)? ) throws -> any WebServiceServerProtocol { _ = port _ = targetStateProvider + _ = concreteTargetResolver _ = sessionHubProvider _ = sharingEventSink let server = ControlledWebServiceServer(onListenerStopped: onListenerStopped) diff --git a/VoidDisplayTests/Features/Sharing/TestDoubles/MockWebServiceController.swift b/VoidDisplayTests/Features/Sharing/TestDoubles/MockWebServiceController.swift index 8e5c13f..1553e01 100644 --- a/VoidDisplayTests/Features/Sharing/TestDoubles/MockWebServiceController.swift +++ b/VoidDisplayTests/Features/Sharing/TestDoubles/MockWebServiceController.swift @@ -20,18 +20,21 @@ final class MockWebServiceController: WebServiceControllerProtocol { var stopCallCount = 0 var disconnectCallCount = 0 var capturedTargetStateProvider: (@MainActor @Sendable (ShareTarget) -> ShareTargetState)? + var capturedConcreteTargetResolver: (@MainActor @Sendable (ShareTarget) -> ShareTarget?)? var capturedSessionHubProvider: (@MainActor @Sendable (ShareTarget) -> WebRTCSessionHub?)? var capturedSharingEventSink: (@Sendable (SharingSessionEvent) -> Void)? func start( requestedPort: UInt16, targetStateProvider: @escaping @MainActor @Sendable (ShareTarget) -> ShareTargetState, + concreteTargetResolver: @escaping @MainActor @Sendable (ShareTarget) -> ShareTarget?, sessionHubProvider: @escaping @MainActor @Sendable (ShareTarget) -> WebRTCSessionHub?, sharingEventSink: @escaping @Sendable (SharingSessionEvent) -> Void ) async -> WebServiceStartResult { startCallCount += 1 lastRequestedPort = requestedPort capturedTargetStateProvider = targetStateProvider + capturedConcreteTargetResolver = concreteTargetResolver capturedSessionHubProvider = sessionHubProvider capturedSharingEventSink = sharingEventSink switch startResult { From a85def4810c76cc04def6ebd7c82c53af2b78677 Mon Sep 17 00:00:00 2001 From: Chen Date: Fri, 3 Apr 2026 23:39:17 +0800 Subject: [PATCH 27/34] =?UTF-8?q?fix(capture):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E5=85=B1=E4=BA=AB=E6=B8=B8=E6=A0=87=E8=A6=86=E7=9B=96=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E9=87=8A=E6=94=BE=E6=97=B6=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 让 retain 任务返回实际持有结果,避免失败路径误释放 - 为订阅记录 cursor override 持有状态,取消时按需释放 - 补充成功、失败与并发订阅场景测试 --- .../Services/DisplayCaptureRegistry.swift | 29 ++- .../DisplayCaptureRegistryTests.swift | 167 ++++++++++++++++++ 2 files changed, 189 insertions(+), 7 deletions(-) diff --git a/VoidDisplay/Features/Capture/Services/DisplayCaptureRegistry.swift b/VoidDisplay/Features/Capture/Services/DisplayCaptureRegistry.swift index c520473..5635bb2 100644 --- a/VoidDisplay/Features/Capture/Services/DisplayCaptureRegistry.swift +++ b/VoidDisplay/Features/Capture/Services/DisplayCaptureRegistry.swift @@ -93,7 +93,8 @@ final class DisplayShareSubscription: Sendable { private let session: any DisplayCaptureSessioning private let cancelState = Mutex<(@Sendable () -> Void)?>(nil) - private let prepareRetainTask = Mutex?>(nil) + private let prepareRetainTask = Mutex?>(nil) + private let hasRetainedShareCursorOverride = Mutex(false) nonisolated init( displayID: CGDirectDisplayID, @@ -109,26 +110,29 @@ final class DisplayShareSubscription: Sendable { nonisolated func prepareForSharing() async throws { try await session.retainShareCursorOverride() + hasRetainedShareCursorOverride.withLock { $0 = true } } nonisolated func prepareForSharing( invalidationContext: DisplayStartInvalidationContext ) async throws -> DisplayStartOutcome { - let retainTask = Task { + let retainTask = Task { try await session.retainShareCursorOverride() + return true } prepareRetainTask.withLock { state in state = retainTask } do { let outcome = try await invalidationContext.race { - try await retainTask.value + _ = try await retainTask.value } switch outcome { case .started: prepareRetainTask.withLock { state in state = nil } + hasRetainedShareCursorOverride.withLock { $0 = true } case .invalidated: cancel() } @@ -141,11 +145,16 @@ final class DisplayShareSubscription: Sendable { nonisolated func cancel() { let session = self.session - let pendingRetainTask = prepareRetainTask.withLock { state -> Task? in + let pendingRetainTask = prepareRetainTask.withLock { state -> Task? in let current = state state = nil return current } + let hasRetained = hasRetainedShareCursorOverride.withLock { state -> Bool in + let current = state + state = false + return current + } let closure = cancelState.withLock { state -> (@Sendable () -> Void)? in let current = state state = nil @@ -154,17 +163,23 @@ final class DisplayShareSubscription: Sendable { guard let closure else { return } if let pendingRetainTask { Task.detached { + var needsRelease = hasRetained do { - try await pendingRetainTask.value + let didRetain = try await pendingRetainTask.value + needsRelease = needsRelease || didRetain } catch { } - try? await session.releaseShareCursorOverride() + if needsRelease { + try? await session.releaseShareCursorOverride() + } closure() } return } Task { - try? await session.releaseShareCursorOverride() + if hasRetained { + try? await session.releaseShareCursorOverride() + } closure() } } diff --git a/VoidDisplayTests/Features/Capture/Services/DisplayCaptureRegistryTests.swift b/VoidDisplayTests/Features/Capture/Services/DisplayCaptureRegistryTests.swift index 2960929..41733c8 100644 --- a/VoidDisplayTests/Features/Capture/Services/DisplayCaptureRegistryTests.swift +++ b/VoidDisplayTests/Features/Capture/Services/DisplayCaptureRegistryTests.swift @@ -225,7 +225,174 @@ private final class BlockingSetSharingActiveSession: DisplayCaptureSessioning, @ } } +private enum CursorOverrideTrackingError: Error { + case forcedRetainFailure +} + +private final class CursorOverrideTrackingSession: DisplayCaptureSessioning, @unchecked Sendable { + private struct State { + var retainCalls = 0 + var releaseCalls = 0 + var pendingRetainFailures: [Bool] + } + + nonisolated let sessionHub = WebRTCSessionHub() + private let state: Mutex + + init(retainFailures: [Bool] = []) { + self.state = Mutex(State(pendingRetainFailures: retainFailures)) + } + + nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { + _ = sink + } + + nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { + _ = sink + } + + nonisolated func stopSharing() {} + + nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { + _ = showsCursor + } + + nonisolated func retainShareCursorOverride() async throws { + let shouldFail = state.withLock { state -> Bool in + state.retainCalls += 1 + guard !state.pendingRetainFailures.isEmpty else { + return false + } + return state.pendingRetainFailures.removeFirst() + } + if shouldFail { + throw CursorOverrideTrackingError.forcedRetainFailure + } + } + + nonisolated func releaseShareCursorOverride() async throws { + state.withLock { $0.releaseCalls += 1 } + } + + nonisolated func stop() async {} + + var retainCalls: Int { + state.withLock { $0.retainCalls } + } + + var releaseCalls: Int { + state.withLock { $0.releaseCalls } + } +} + struct DisplayCaptureRegistryTests { + @Test func shareSubscriptionDoesNotReleaseCursorOverrideWhenRetainFails() async throws { + let session = CursorOverrideTrackingSession(retainFailures: [true]) + let cancelCount = Mutex(0) + let subscription = DisplayShareSubscription( + displayID: CGDirectDisplayID(12001), + sessionHub: session.sessionHub, + session: session, + cancelClosure: { + cancelCount.withLock { $0 += 1 } + } + ) + let invalidationContext = DisplayStartInvalidationContext() + + do { + _ = try await subscription.prepareForSharing(invalidationContext: invalidationContext) + Issue.record("Expected retainShareCursorOverride to fail.") + } catch { + } + + let settled = await waitUntil { + cancelCount.withLock { $0 } == 1 && + session.retainCalls == 1 && + session.releaseCalls == 0 + } + #expect(settled) + } + + @Test func shareSubscriptionReleasesCursorOverrideAfterSuccessfulRetain() async throws { + let session = CursorOverrideTrackingSession() + let cancelCount = Mutex(0) + let subscription = DisplayShareSubscription( + displayID: CGDirectDisplayID(12002), + sessionHub: session.sessionHub, + session: session, + cancelClosure: { + cancelCount.withLock { $0 += 1 } + } + ) + let invalidationContext = DisplayStartInvalidationContext() + + let outcome = try await subscription.prepareForSharing(invalidationContext: invalidationContext) + if case .invalidated = outcome { + Issue.record("Expected prepareForSharing to succeed.") + } + subscription.cancel() + + let settled = await waitUntil { + cancelCount.withLock { $0 } == 1 && + session.retainCalls == 1 && + session.releaseCalls == 1 + } + #expect(settled) + } + + @Test func failedShareSubscriptionDoesNotReleaseCursorOverrideHeldByAnotherSubscription() async throws { + let session = CursorOverrideTrackingSession(retainFailures: [false, true]) + let firstCancelCount = Mutex(0) + let secondCancelCount = Mutex(0) + let firstSubscription = DisplayShareSubscription( + displayID: CGDirectDisplayID(12003), + sessionHub: session.sessionHub, + session: session, + cancelClosure: { + firstCancelCount.withLock { $0 += 1 } + } + ) + let secondSubscription = DisplayShareSubscription( + displayID: CGDirectDisplayID(12003), + sessionHub: session.sessionHub, + session: session, + cancelClosure: { + secondCancelCount.withLock { $0 += 1 } + } + ) + + let firstOutcome = try await firstSubscription.prepareForSharing( + invalidationContext: DisplayStartInvalidationContext() + ) + if case .invalidated = firstOutcome { + Issue.record("Expected first retain to succeed.") + } + + do { + _ = try await secondSubscription.prepareForSharing( + invalidationContext: DisplayStartInvalidationContext() + ) + Issue.record("Expected second retain to fail.") + } catch { + } + + let secondFailureSettled = await waitUntil { + firstCancelCount.withLock { $0 } == 0 && + secondCancelCount.withLock { $0 } == 1 && + session.retainCalls == 2 && + session.releaseCalls == 0 + } + #expect(secondFailureSettled) + + firstSubscription.cancel() + + let firstCancelSettled = await waitUntil { + firstCancelCount.withLock { $0 } == 1 && + session.releaseCalls == 1 + } + #expect(firstCancelSettled) + } + @Test func releasingShareKeepsPreviewSessionAliveUntilLastToken() async throws { let registry = DisplayCaptureRegistry() let fakeSession = FakeCaptureSession() From f09cae38055f2efed195534257b3ff298b687cf2 Mon Sep 17 00:00:00 2001 From: Chen Date: Fri, 3 Apr 2026 23:39:44 +0800 Subject: [PATCH 28/34] =?UTF-8?q?fix(sharing):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E5=A4=B1=E6=95=88=E5=88=86=E4=BA=AB=E8=BF=9E=E6=8E=A5=E4=B8=8E?= =?UTF-8?q?=20WebRTC=20=E4=BF=A1=E4=BB=A4=E7=A7=AF=E5=8E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 shareID 变更时返回失效目标并主动断开旧流连接 - 为连接绑定创建时的 session hub,避免主目标重映射后错投递 - 为 WebRTC 待发送信令加入合并和溢出保护,补充服务层与集成测试 --- .../Services/DisplaySharingCoordinator.swift | 40 ++- .../Sharing/Services/SharingService.swift | 4 +- .../Services/WebServiceController.swift | 7 + .../Sharing/Web/WebRTCSessionHub.swift | 81 ++++-- .../Features/Sharing/Web/WebServer.swift | 58 +++-- .../Integration/SocketTestSupport.swift | 5 +- .../WebServerSocketIntegrationTests.swift | 243 ++++++++++++++++++ .../DisplaySharingCoordinatorTests.swift | 19 ++ .../Services/SharingServiceTests.swift | 17 ++ .../Services/WebServiceControllerTests.swift | 31 +++ .../MockWebServiceController.swift | 7 + .../Sharing/Web/WebRTCSessionHubTests.swift | 82 ++++-- 12 files changed, 525 insertions(+), 69 deletions(-) diff --git a/VoidDisplay/Features/Sharing/Services/DisplaySharingCoordinator.swift b/VoidDisplay/Features/Sharing/Services/DisplaySharingCoordinator.swift index 6868eca..fbc25c7 100644 --- a/VoidDisplay/Features/Sharing/Services/DisplaySharingCoordinator.swift +++ b/VoidDisplay/Features/Sharing/Services/DisplaySharingCoordinator.swift @@ -62,10 +62,11 @@ final class DisplaySharingCoordinator { startCoordinator.isStarting(kind: .sharing, displayID: displayID) } + @discardableResult func registerShareableDisplays( _ displays: [SCDisplay], virtualSerialResolver: (CGDirectDisplayID) -> UInt32? - ) { + ) -> Set { let inputs = displays.map { display in ShareableDisplayRegistrationInput( displayID: display.displayID, @@ -73,10 +74,11 @@ final class DisplaySharingCoordinator { virtualSerial: virtualSerialResolver(display.displayID) ) } - registerShareableDisplays(inputs) + return registerShareableDisplays(inputs) } - func registerShareableDisplays(_ inputs: [ShareableDisplayRegistrationInput]) { + @discardableResult + func registerShareableDisplays(_ inputs: [ShareableDisplayRegistrationInput]) -> Set { var nextRegistrationsByDisplayID: [CGDirectDisplayID: DisplayRegistration] = [:] var nextDisplayIDsByShareID: [UInt32: CGDirectDisplayID] = [:] var resolvedMainDisplayID: CGDirectDisplayID? @@ -124,8 +126,13 @@ final class DisplaySharingCoordinator { nextDisplayIDsByShareID[shareID] = input.displayID } + let currentRegistrationsByDisplayID = registrationsByDisplayID + let invalidatedTargets = invalidatedConcreteTargets( + current: currentRegistrationsByDisplayID, + next: nextRegistrationsByDisplayID + ) let displayIDsToInvalidate = invalidatedDisplayIDs( - current: registrationsByDisplayID, + current: currentRegistrationsByDisplayID, next: nextRegistrationsByDisplayID ) registrationsByDisplayID = nextRegistrationsByDisplayID @@ -139,6 +146,7 @@ final class DisplaySharingCoordinator { for displayID in Array(sessionsByDisplayID.keys) where !registeredDisplayIDs.contains(displayID) { stopSharing(displayID: displayID) } + return invalidatedTargets } func startSharing(display: SCDisplay) async throws -> DisplayStartOutcome { @@ -176,11 +184,6 @@ final class DisplaySharingCoordinator { break } - if invalidationContext.isInvalidated() || self.registrationsByDisplayID[displayID] == nil { - subscription.cancel() - return .invalidated - } - if invalidationContext.isInvalidated() || self.registrationsByDisplayID[displayID] == nil { subscription.cancel() return .invalidated @@ -301,6 +304,25 @@ final class DisplaySharingCoordinator { ) } + private func invalidatedConcreteTargets( + current: [CGDirectDisplayID: DisplayRegistration], + next: [CGDirectDisplayID: DisplayRegistration] + ) -> Set { + Set( + current.compactMap { displayID, registration in + switch next[displayID] { + case .none: + return .id(registration.shareID) + case .some(let nextRegistration): + guard registration.shareID != nextRegistration.shareID else { + return nil + } + return .id(registration.shareID) + } + } + ) + } + private func makeIdentityKey(for displayID: CGDirectDisplayID) -> String { if let cfUUID = CGDisplayCreateUUIDFromDisplayID(displayID)?.takeRetainedValue() { let uuidString = CFUUIDCreateString(nil, cfUUID) as String diff --git a/VoidDisplay/Features/Sharing/Services/SharingService.swift b/VoidDisplay/Features/Sharing/Services/SharingService.swift index 989ccfb..2af8659 100644 --- a/VoidDisplay/Features/Sharing/Services/SharingService.swift +++ b/VoidDisplay/Features/Sharing/Services/SharingService.swift @@ -161,10 +161,12 @@ final class SharingService: SharingServiceProtocol { _ displays: [SCDisplay], virtualSerialResolver: (CGDirectDisplayID) -> UInt32? ) { - sharingCoordinator.registerShareableDisplays( + let invalidatedTargets = sharingCoordinator.registerShareableDisplays( displays, virtualSerialResolver: virtualSerialResolver ) + guard !invalidatedTargets.isEmpty else { return } + webServiceController.disconnectStreamClients(for: invalidatedTargets) } func startSharing(display: SCDisplay) async throws -> DisplayStartOutcome { diff --git a/VoidDisplay/Features/Sharing/Services/WebServiceController.swift b/VoidDisplay/Features/Sharing/Services/WebServiceController.swift index c27ae32..bfd0224 100644 --- a/VoidDisplay/Features/Sharing/Services/WebServiceController.swift +++ b/VoidDisplay/Features/Sharing/Services/WebServiceController.swift @@ -41,6 +41,7 @@ protocol WebServiceControllerProtocol: AnyObject { ) async -> WebServiceStartResult func stop() func disconnectAllStreamClients() + func disconnectStreamClients(for targets: Set) } @MainActor @@ -48,6 +49,7 @@ protocol WebServiceServerProtocol: AnyObject { func startListener() async -> WebServer.ListenerStartResult func stopListener(reason: WebServiceServerStopReason) func disconnectAllStreamClients() + func disconnectStreamClients(for targets: Set) var activeStreamClientCount: Int { get } func streamClientCount(for target: ShareTarget) -> Int } @@ -220,6 +222,11 @@ final class WebServiceController: WebServiceControllerProtocol { activeServer?.disconnectAllStreamClients() } + func disconnectStreamClients(for targets: Set) { + guard !targets.isEmpty else { return } + activeServer?.disconnectStreamClients(for: targets) + } + private func startInternal( requestedPort: UInt16, operationNonce: UInt64, diff --git a/VoidDisplay/Features/Sharing/Web/WebRTCSessionHub.swift b/VoidDisplay/Features/Sharing/Web/WebRTCSessionHub.swift index df42675..d1ab943 100644 --- a/VoidDisplay/Features/Sharing/Web/WebRTCSessionHub.swift +++ b/VoidDisplay/Features/Sharing/Web/WebRTCSessionHub.swift @@ -94,6 +94,19 @@ final class WebRTCSessionHub: Sendable { private nonisolated struct QueuedSignal: Sendable { let text: String let disconnectAfterSend: Bool + let coalescingKey: CoalescingKey? + } + + private nonisolated enum CoalescingKey: Sendable, Equatable { + case answer + case stopped + } + + private nonisolated enum EnqueueDecision { + case sendNow(QueuedSignal) + case queued + case overflow + case dropped } private nonisolated struct ClientState { @@ -113,8 +126,8 @@ final class WebRTCSessionHub: Sendable { } nonisolated private let state: Mutex - nonisolated private let maxClients = 10 nonisolated private let peerFactory: PeerFactory + nonisolated private static let maxPendingSignalsPerClient = 256 #if canImport(WebRTC) nonisolated private let mediaPipeline = WebRTCMediaPipeline() @@ -169,9 +182,6 @@ final class WebRTCSessionHub: Sendable { let key = ObjectIdentifier(connection as AnyObject) let (result, acceptedClientID, shouldSignalDemand, callback) = state.withLock { state -> (AddClientResult, String?, Bool, @Sendable (Bool) -> Void) in - guard state.clients.count < maxClients else { - return (.rejected(reason: "too_many_viewers"), nil, false, state.onDemandChanged) - } let wasEmpty = state.clients.isEmpty let clientID = makeClientID() state.clients[key] = ClientState( @@ -400,6 +410,7 @@ final class WebRTCSessionHub: Sendable { } return } + let coalescingKey = coalescingKey(for: message) let connection = state.withLock { $0.clients[key]?.connection } guard let connection else { return } @@ -408,7 +419,8 @@ final class WebRTCSessionHub: Sendable { to: key, connection: connection, disconnectAfterSend: disconnectAfterSend, - replacePending: replacePending + replacePending: replacePending, + coalescingKey: coalescingKey ) } @@ -417,31 +429,70 @@ final class WebRTCSessionHub: Sendable { to key: ObjectIdentifier, connection: any SignalSocketConnection, disconnectAfterSend: Bool, - replacePending: Bool + replacePending: Bool, + coalescingKey: CoalescingKey? ) { let queuedSignal = QueuedSignal( text: text, - disconnectAfterSend: disconnectAfterSend + disconnectAfterSend: disconnectAfterSend, + coalescingKey: coalescingKey ) - let nextToSend = state.withLock { state -> QueuedSignal? in - guard var current = state.clients[key] else { return nil } + let decision = state.withLock { state -> EnqueueDecision in + guard var current = state.clients[key] else { return .dropped } if current.isSending { if replacePending { current.pendingSignals = [queuedSignal] - } else { - current.pendingSignals.append(queuedSignal) + state.clients[key] = current + return .queued + } + + if let coalescingKey { + if let index = current.pendingSignals.lastIndex(where: { $0.coalescingKey == coalescingKey }) { + current.pendingSignals[index] = queuedSignal + state.clients[key] = current + return .queued + } } + + if current.pendingSignals.count >= Self.maxPendingSignalsPerClient { + return .overflow + } + current.pendingSignals.append(queuedSignal) state.clients[key] = current - return nil + return .queued } current.isSending = true state.clients[key] = current - return queuedSignal + return .sendNow(queuedSignal) } - guard let nextToSend else { return } - send(signal: nextToSend, to: key, connection: connection) + switch decision { + case .sendNow(let nextToSend): + send(signal: nextToSend, to: key, connection: connection) + case .queued: + return + case .overflow: + AppLog.web.warning( + "WebRTC signaling backlog overflow; disconnecting client to prevent unbounded queue growth." + ) + removeClient(for: key, cancelConnection: true) + case .dropped: + return + } + } + + nonisolated private func coalescingKey( + for message: SignalingOutboundMessage + ) -> CoalescingKey? { + switch message.type { + case .answer: + return .answer + case .stopped: + return .stopped + default: + return nil + } } nonisolated private func send( diff --git a/VoidDisplay/Features/Sharing/Web/WebServer.swift b/VoidDisplay/Features/Sharing/Web/WebServer.swift index c2d2def..a72ba38 100644 --- a/VoidDisplay/Features/Sharing/Web/WebServer.swift +++ b/VoidDisplay/Features/Sharing/Web/WebServer.swift @@ -96,6 +96,7 @@ final class WebServer { let target: ShareTarget let clientID: String let connection: NWConnection + let sessionHub: WebRTCSessionHub } private var listener: NWListener? @@ -199,14 +200,9 @@ final class WebServer { } func disconnectAllStreamClients() { - for client in activeConnections.values { - if let hub = sessionHub(for: client.target) { - hub.removeClient(client.connection) - } - client.connection.cancel() + for key in Array(activeConnections.keys) { + disconnectActiveConnection(forKey: key, cancelConnection: true) } - activeConnections.removeAll() - signalDecodersByConnectionKey.removeAll() } var activeStreamClientCount: Int { @@ -217,6 +213,16 @@ final class WebServer { activeConnections.values.filter { $0.target == target }.count } + func disconnectStreamClients(for targets: Set) { + guard !targets.isEmpty else { return } + let keysToDisconnect = activeConnections.compactMap { key, connection in + targets.contains(connection.target) ? key : nil + } + for key in keysToDisconnect { + disconnectActiveConnection(forKey: key, cancelConnection: true) + } + } + func stopListener(reason: WebServiceServerStopReason = .requested) { notifyListenerStoppedIfNeeded(reason: reason) completeStartupWaiter(result: .failed(error: LifecycleError.listenerCancelled)) @@ -288,14 +294,21 @@ final class WebServer { private func removeSignalClient(_ connection: NWConnection, cancelConnection: Bool) { let key = connectionKey(for: connection) + disconnectActiveConnection(forKey: key, cancelConnection: cancelConnection, fallbackConnection: connection) + } + + private func disconnectActiveConnection( + forKey key: ObjectIdentifier, + cancelConnection: Bool, + fallbackConnection: NWConnection? = nil + ) { signalDecodersByConnectionKey.removeValue(forKey: key) + let connection = activeConnections[key]?.connection ?? fallbackConnection if let active = activeConnections.removeValue(forKey: key) { - if let hub = sessionHub(for: active.target) { - hub.removeClient(connection) - } + active.sessionHub.removeClient(active.connection) } if cancelConnection { - connection.cancel() + connection?.cancel() } } @@ -471,19 +484,28 @@ final class WebServer { self.activeConnections[key] = ActiveConnection( target: target, clientID: clientID, - connection: connection + connection: connection, + sessionHub: hub ) self.signalDecodersByConnectionKey[key] = WebSocketFrameDecoder( maxFramePayloadBytes: Self.maxSignalBufferBytes, maxContinuationPayloadBytes: Self.maxSignalBufferBytes, requiresMaskedFrames: true ) - self.startSignalReceiveLoop(on: connection, target: target) + self.startSignalReceiveLoop( + on: connection, + target: target, + sessionHub: hub + ) } }) } - nonisolated private func startSignalReceiveLoop(on connection: NWConnection, target: ShareTarget) { + nonisolated private func startSignalReceiveLoop( + on connection: NWConnection, + target: ShareTarget, + sessionHub: WebRTCSessionHub + ) { connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] content, _, isComplete, error in if let error = error { AppLog.web.warning("WebServer: WebSocket receive error: \(error)") @@ -526,7 +548,7 @@ final class WebServer { for frame in decoded.frames { switch frame { case .text(let text): - self.sessionHub(for: target)?.receiveSignalText(text, from: connection) + sessionHub.receiveSignalText(text, from: connection) case .ping(let payload): connection.send( content: encodeWebSocketPongFrame(payload), @@ -557,7 +579,11 @@ final class WebServer { guard self.activeConnections[key] != nil else { return } - self.startSignalReceiveLoop(on: connection, target: target) + self.startSignalReceiveLoop( + on: connection, + target: target, + sessionHub: sessionHub + ) } } } diff --git a/VoidDisplayTests/Features/Sharing/Integration/SocketTestSupport.swift b/VoidDisplayTests/Features/Sharing/Integration/SocketTestSupport.swift index b073b1f..0ecdb23 100644 --- a/VoidDisplayTests/Features/Sharing/Integration/SocketTestSupport.swift +++ b/VoidDisplayTests/Features/Sharing/Integration/SocketTestSupport.swift @@ -313,7 +313,10 @@ private final class StaticLiveHubStore { @MainActor func startServerOnRandomPort( targetStateProvider: @escaping @MainActor @Sendable (ShareTarget) -> ShareTargetState, - concreteTargetResolver: @escaping @MainActor @Sendable (ShareTarget) -> ShareTarget? = { $0 }, + concreteTargetResolver: @escaping @MainActor @Sendable (ShareTarget) -> ShareTarget? = { target in + guard case .id(let id) = target else { return nil } + return .id(id) + }, sessionHubProvider: @escaping @MainActor @Sendable (ShareTarget) -> WebRTCSessionHub?, sharingEventSink: @escaping @Sendable (SharingSessionEvent) -> Void = { _ in } ) async throws -> (server: WebServer, port: UInt16) { diff --git a/VoidDisplayTests/Features/Sharing/Integration/WebServerSocketIntegrationTests.swift b/VoidDisplayTests/Features/Sharing/Integration/WebServerSocketIntegrationTests.swift index c35c48c..1525b23 100644 --- a/VoidDisplayTests/Features/Sharing/Integration/WebServerSocketIntegrationTests.swift +++ b/VoidDisplayTests/Features/Sharing/Integration/WebServerSocketIntegrationTests.swift @@ -545,6 +545,249 @@ struct WebServerSocketIntegrationTests { #expect(clearedFromOriginalTarget) } + @Test func existingAliasConnectionKeepsBoundHubAfterMainMappingChanges() async throws { + let aggregator = SharingStateAggregator() + let sessionHub = WebRTCSessionHub( + peerFactory: { callbacks in + IntegrationAutoConnectingPeer(onConnected: callbacks.onConnected) + } + ) + let mainShareIDBox = MutableShareIDBox(Self.mainAliasShareID) + let setup = try await startServerOnRandomPort( + targetStateProvider: { target in + switch target { + case .main: + .active + case .id(let id) where id == Self.mainAliasShareID || id == Self.replacementMainAliasShareID: + .active + default: + .unknown + } + }, + concreteTargetResolver: { target in + switch target { + case .main: + .id(mainShareIDBox.value) + case .id(let id) where id == Self.mainAliasShareID || id == Self.replacementMainAliasShareID: + .id(id) + default: + nil + } + }, + sessionHubProvider: { target in + switch target { + case .id(let id) where id == mainShareIDBox.value: + sessionHub + default: + nil + } + }, + sharingEventSink: { event in + Task { @MainActor in + aggregator.record(event) + } + } + ) + let server = setup.server + let portValue = setup.port + defer { server.stopListener() } + + let socket = try await openWebSocket(path: "/signal", port: portValue) + defer { close(socket) } + + let connected = await waitUntilAsync(timeout: .seconds(2)) { + let snapshot = aggregator.currentSnapshot + return snapshot.signalingConnectionsByTarget[.id(Self.mainAliasShareID)] == 1 && + sessionHub.activeClientCount == 1 + } + #expect(connected) + + mainShareIDBox.setValue(Self.replacementMainAliasShareID) + + try sendAll(socket, data: makeMaskedTextFrame(#"{"type":"offer","sdp":"v=0"}"#)) + + let offerStillHandledByBoundHub = await waitUntilAsync(timeout: .seconds(2)) { + let snapshot = aggregator.currentSnapshot + return snapshot.streamingPeersByTarget[.id(Self.mainAliasShareID)] == 1 + } + #expect(offerStillHandledByBoundHub) + + try sendAll(socket, data: makeMaskedCloseFrame()) + _ = try await waitForSocketClose(socket) + + let cleared = await waitUntilAsync(timeout: .seconds(2)) { + let snapshot = aggregator.currentSnapshot + return snapshot.signalingConnections == 0 && + snapshot.streamingPeers == 0 && + sessionHub.activeClientCount == 0 + } + #expect(cleared) + } + + @Test func targetedDisconnectRemovesOnlyMatchingConnections() async throws { + let aggregator = SharingStateAggregator() + let mainHub = WebRTCSessionHub( + peerFactory: { callbacks in + IntegrationAutoConnectingPeer(onConnected: callbacks.onConnected) + } + ) + let secondaryHub = WebRTCSessionHub( + peerFactory: { callbacks in + IntegrationAutoConnectingPeer(onConnected: callbacks.onConnected) + } + ) + let setup = try await startServerOnRandomPort( + targetStateProvider: { target in + switch target { + case .main, .id(7): + .active + default: + .unknown + } + }, + concreteTargetResolver: { target in + switch target { + case .main: + .id(Self.mainAliasShareID) + case .id(let id) where id == Self.mainAliasShareID || id == 7: + .id(id) + default: + nil + } + }, + sessionHubProvider: { target in + switch target { + case .id(let id) where id == Self.mainAliasShareID: + mainHub + case .id(7): + secondaryHub + default: + nil + } + }, + sharingEventSink: { event in + Task { @MainActor in + aggregator.record(event) + } + } + ) + let server = setup.server + let portValue = setup.port + defer { server.stopListener() } + + let mainSocket = try await openWebSocket(path: "/signal", port: portValue) + let secondarySocket = try await openWebSocket(path: "/signal/7", port: portValue) + defer { + close(mainSocket) + close(secondarySocket) + } + + try sendAll(mainSocket, data: makeMaskedTextFrame(#"{"type":"offer","sdp":"v=0"}"#)) + try sendAll(secondarySocket, data: makeMaskedTextFrame(#"{"type":"offer","sdp":"v=0"}"#)) + + let connected = await waitUntilAsync(timeout: .seconds(2)) { + let snapshot = aggregator.currentSnapshot + return snapshot.signalingConnections == 2 && + snapshot.streamingPeers == 2 && + mainHub.activeClientCount == 1 && + secondaryHub.activeClientCount == 1 + } + #expect(connected) + + server.disconnectStreamClients(for: [.id(Self.mainAliasShareID)]) + + let mainClosed = try await waitForSocketClose(mainSocket) + #expect(mainClosed) + + let targetedDisconnectObserved = await waitUntilAsync(timeout: .seconds(2)) { + let snapshot = aggregator.currentSnapshot + return snapshot.signalingConnections == 1 && + snapshot.streamingPeers == 1 && + snapshot.signalingConnectionsByTarget[.id(Self.mainAliasShareID)] == nil && + snapshot.streamingPeersByTarget[.id(Self.mainAliasShareID)] == nil && + snapshot.signalingConnectionsByTarget[.id(7)] == 1 && + snapshot.streamingPeersByTarget[.id(7)] == 1 && + mainHub.activeClientCount == 0 && + secondaryHub.activeClientCount == 1 && + server.streamClientCount(for: .id(Self.mainAliasShareID)) == 0 && + server.streamClientCount(for: .id(7)) == 1 + } + #expect(targetedDisconnectObserved) + + try sendAll(secondarySocket, data: makeMaskedCloseFrame()) + _ = try await waitForSocketClose(secondarySocket) + } + + @Test func tenClientsOnSameTargetCanConnectAndDisconnectCleanly() async throws { + let sessionHub = WebRTCSessionHub( + peerFactory: { callbacks in + IntegrationAutoConnectingPeer(onConnected: callbacks.onConnected) + } + ) + let setup = try await startServerOnRandomPort( + targetStateProvider: { target in + target == .main ? .active : .unknown + }, + concreteTargetResolver: { target in + switch target { + case .main: + .id(Self.mainAliasShareID) + case .id(let id) where id == Self.mainAliasShareID: + .id(id) + default: + nil + } + }, + sessionHubProvider: { target in + target == .id(Self.mainAliasShareID) ? sessionHub : nil + } + ) + let server = setup.server + let portValue = setup.port + defer { server.stopListener() } + + let expectedClientCount = 10 + var sockets: [Int32] = [] + sockets.reserveCapacity(expectedClientCount) + do { + for _ in 0.. URL { let base = FileManager.default.temporaryDirectory .appendingPathComponent("display-sharing-coordinator-tests-\(UUID().uuidString)", isDirectory: true) diff --git a/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift b/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift index 6fc7d38..6e1a7d9 100644 --- a/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift +++ b/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift @@ -142,6 +142,23 @@ struct SharingServiceTests { #expect(sut.hasAnyActiveSharing == false) } + @MainActor @Test func registerShareableDisplaysDisconnectsConnectionsForRemappedTargets() throws { + let mock = MockWebServiceController() + let sut = makeService(webServiceController: mock) + let displayID = CGDirectDisplayID(24) + let display = SharingServiceMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + + sut.registerShareableDisplays([display], virtualSerialResolver: { _ in nil }) + let originalTarget = try #require(sut.shareTarget(for: displayID)) + #expect(mock.disconnectTargetCallCount == 0) + + sut.registerShareableDisplays([display], virtualSerialResolver: { _ in 77 }) + + #expect(mock.disconnectTargetCallCount == 1) + #expect(mock.disconnectedTargetsHistory == [Set([originalTarget])]) + #expect(sut.shareTarget(for: displayID) == .id(77)) + } + @MainActor @Test func stopWebServiceStopsControllerAndDisconnectsAllStreamClients() { let mock = MockWebServiceController() let sut = makeService(webServiceController: mock) diff --git a/VoidDisplayTests/Features/Sharing/Services/WebServiceControllerTests.swift b/VoidDisplayTests/Features/Sharing/Services/WebServiceControllerTests.swift index c4eaa30..5fbd462 100644 --- a/VoidDisplayTests/Features/Sharing/Services/WebServiceControllerTests.swift +++ b/VoidDisplayTests/Features/Sharing/Services/WebServiceControllerTests.swift @@ -272,6 +272,32 @@ struct WebServiceControllerTests { #expect(sut.lifecycleState == .running(.init(requestedPort: secondPort, boundPort: secondPort))) } + @Test + func disconnectStreamClientsForwardsTargetsToActiveServer() async { + let harness = WebServiceServerHarness() + let sut = WebServiceController(webServiceServerFactory: harness.makeServer) + + let requestedPort: UInt16 = 18086 + guard let startup = await beginControlledStartup( + harness: harness, + sut: sut, + requestedPort: requestedPort + ) else { + return + } + + startup.server.finishStart(with: .ready(boundPort: requestedPort)) + let result = await startup.startTask.value + guard case .started = result else { + Issue.record("Expected controlled startup to succeed before disconnecting clients.") + return + } + + sut.disconnectStreamClients(for: [.id(7)]) + + #expect(startup.server.disconnectedTargetsHistory == [Set([.id(7)])]) + } + private func beginControlledStartup( harness: WebServiceServerHarness, sut: WebServiceController, @@ -388,6 +414,7 @@ private final class ControlledWebServiceServer: WebServiceServerProtocol { private(set) var stopCallCount = 0 private(set) var stopReasons: [WebServiceServerStopReason] = [] private(set) var disconnectCallCount = 0 + private(set) var disconnectedTargetsHistory: [Set] = [] var activeStreamClientCount: Int = 0 init(onListenerStopped: (@MainActor @Sendable (WebServiceServerStopReason) -> Void)?) { @@ -427,6 +454,10 @@ private final class ControlledWebServiceServer: WebServiceServerProtocol { disconnectCallCount += 1 } + func disconnectStreamClients(for targets: Set) { + disconnectedTargetsHistory.append(targets) + } + func streamClientCount(for target: ShareTarget) -> Int { _ = target return 0 diff --git a/VoidDisplayTests/Features/Sharing/TestDoubles/MockWebServiceController.swift b/VoidDisplayTests/Features/Sharing/TestDoubles/MockWebServiceController.swift index 1553e01..e60818e 100644 --- a/VoidDisplayTests/Features/Sharing/TestDoubles/MockWebServiceController.swift +++ b/VoidDisplayTests/Features/Sharing/TestDoubles/MockWebServiceController.swift @@ -19,6 +19,8 @@ final class MockWebServiceController: WebServiceControllerProtocol { var startCallCount = 0 var stopCallCount = 0 var disconnectCallCount = 0 + var disconnectTargetCallCount = 0 + var disconnectedTargetsHistory: [Set] = [] var capturedTargetStateProvider: (@MainActor @Sendable (ShareTarget) -> ShareTargetState)? var capturedConcreteTargetResolver: (@MainActor @Sendable (ShareTarget) -> ShareTarget?)? var capturedSessionHubProvider: (@MainActor @Sendable (ShareTarget) -> WebRTCSessionHub?)? @@ -63,6 +65,11 @@ final class MockWebServiceController: WebServiceControllerProtocol { disconnectCallCount += 1 } + func disconnectStreamClients(for targets: Set) { + disconnectTargetCallCount += 1 + disconnectedTargetsHistory.append(targets) + } + func streamClientCount(for target: ShareTarget) -> Int { streamClientCountByTarget[target] ?? 0 } diff --git a/VoidDisplayTests/Features/Sharing/Web/WebRTCSessionHubTests.swift b/VoidDisplayTests/Features/Sharing/Web/WebRTCSessionHubTests.swift index bebecae..84da5e3 100644 --- a/VoidDisplayTests/Features/Sharing/Web/WebRTCSessionHubTests.swift +++ b/VoidDisplayTests/Features/Sharing/Web/WebRTCSessionHubTests.swift @@ -163,12 +163,14 @@ struct WebRTCSessionHubTests { #expect(client.decodedTextPayloads().contains(where: { $0.contains(#""reason":"invalid_signal_payload""#) })) } - @MainActor @Test func addClientRejectsViewerBeyondCapacity() { + @MainActor @Test func addClientAcceptsViewerBeyondFormerCapacity() { let hub = WebRTCSessionHub() var clients: [MockSignalSocketConnection] = [] let idGenerationCount = Counter() + let formerCapacity = 10 + let expectedClientCount = formerCapacity + 5 - for index in 0..<10 { + for index in 0.. 0 { + hub.receiveSignalText("not-a-json", from: client) + } + + #expect(hub.activeClientCount == 0) + #expect(client.cancelCallCount == 1) + } + @MainActor @Test func removedClient_offer_doesNotCreatePeer() { let peerCreateCalls = Counter() let peerCloseCalls = Counter() @@ -309,6 +311,32 @@ struct WebRTCSessionHubTests { } #if canImport(WebRTC) + @MainActor @Test func answerMessagesCoalesceToLatestUnderBackpressure() throws { + let callbacksBox = PeerCallbacksBox() + let hub = WebRTCSessionHub(peerFactory: { callbacks in + callbacksBox.callbacks = callbacks + return MockPeerSession(closeCalls: Counter()) + }) + let client = MockSignalSocketConnection(autoCompleteSends: false) + _ = hub.addClient(client, target: .main, eventSink: { _ in }) + + hub.receiveSignalText("not-a-json", from: client) + hub.receiveSignalText(#"{"type":"offer","sdp":"v=0"}"#, from: client) + callbacksBox.callbacks?.onAnswer("v=1") + callbacksBox.callbacks?.onAnswer("v=2") + + #expect(client.completeNextSend()) + #expect(client.completeNextSend()) + #expect(client.completeNextSend()) + + let payloads = client.decodedTextPayloads() + let answerPayloads = payloads.filter { $0.contains(#""type":"answer""#) } + #expect(answerPayloads.count == 1) + let finalAnswer = try #require(answerPayloads.first) + #expect(finalAnswer.contains(#""sdp":"v=2""#)) + #expect(finalAnswer.contains(#""sdp":"v=1""#) == false) + } + @MainActor @Test func clientRemovedDuringEnsurePeer_closesNewPeer() { let peerCloseCalls = Counter() let box = PeerFactoryBox(closeCalls: peerCloseCalls) From 11661660caf32a41339ae5d9bd7de615181e1d0e Mon Sep 17 00:00:00 2001 From: Chen Date: Sat, 4 Apr 2026 00:21:15 +0800 Subject: [PATCH 29/34] =?UTF-8?q?fix(capture):=20=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=8E=AF=E5=A2=83=E4=B8=8B=E7=9A=84=E5=B1=8F?= =?UTF-8?q?=E5=B9=95=E7=9B=AE=E5=BD=95=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 统一屏幕目录服务默认加载工厂 - 测试环境禁用真实 ScreenCaptureKit 目录请求 - 补充权限弹窗回归测试 --- .../ScreenCaptureCatalogService.swift | 26 ++--- .../ScreenCaptureDisplayCatalogLoader.swift | 9 +- .../UITestScreenCaptureCatalogFixture.swift | 101 ++++++++++++++++++ .../ScreenCaptureCatalogServiceTests.swift | 25 +++++ 4 files changed, 134 insertions(+), 27 deletions(-) create mode 100644 VoidDisplay/Shared/Testing/UITestScreenCaptureCatalogFixture.swift diff --git a/VoidDisplay/Shared/ScreenCapture/ScreenCaptureCatalogService.swift b/VoidDisplay/Shared/ScreenCapture/ScreenCaptureCatalogService.swift index 7e56562..d6055be 100644 --- a/VoidDisplay/Shared/ScreenCapture/ScreenCaptureCatalogService.swift +++ b/VoidDisplay/Shared/ScreenCapture/ScreenCaptureCatalogService.swift @@ -60,16 +60,8 @@ final class ScreenCaptureCatalogService { ) -> Self { .init( permissionProvider: ScreenCapturePermissionProviderFactory.makeDefault(), - loadShareableDisplays: { - let content = try await SCShareableContent.excludingDesktopWindows( - false, - onScreenWindowsOnly: false - ) - return content.displays - }, - activeDisplayIDsProvider: { - Set(NSScreen.screens.compactMap(\.cgDirectDisplayID)) - }, + loadShareableDisplays: ScreenCaptureShareableDisplayLoaderFactory.makeDefault(), + activeDisplayIDsProvider: ScreenCaptureActiveDisplayIDsProviderFactory.makeDefault(), loadFailureMessage: loadFailureMessage, logOperation: logOperation, logger: logger, @@ -114,16 +106,10 @@ final class ScreenCaptureCatalogService { store: store, dependencies: .init( permissionProvider: permissionProvider ?? ScreenCapturePermissionProviderFactory.makeDefault(), - loadShareableDisplays: loadShareableDisplays ?? { - let content = try await SCShareableContent.excludingDesktopWindows( - false, - onScreenWindowsOnly: false - ) - return content.displays - }, - activeDisplayIDsProvider: activeDisplayIDsProvider ?? { - Set(NSScreen.screens.compactMap(\.cgDirectDisplayID)) - }, + loadShareableDisplays: loadShareableDisplays + ?? ScreenCaptureShareableDisplayLoaderFactory.makeDefault(), + activeDisplayIDsProvider: activeDisplayIDsProvider + ?? ScreenCaptureActiveDisplayIDsProviderFactory.makeDefault(), loadFailureMessage: loadFailureMessage, logOperation: logOperation, logger: logger, diff --git a/VoidDisplay/Shared/ScreenCapture/ScreenCaptureDisplayCatalogLoader.swift b/VoidDisplay/Shared/ScreenCapture/ScreenCaptureDisplayCatalogLoader.swift index afe1c36..dc07af1 100644 --- a/VoidDisplay/Shared/ScreenCapture/ScreenCaptureDisplayCatalogLoader.swift +++ b/VoidDisplay/Shared/ScreenCapture/ScreenCaptureDisplayCatalogLoader.swift @@ -73,13 +73,8 @@ final class ScreenCaptureDisplayCatalogLoader { ) { self.state = state ?? ScreenCaptureDisplayCatalogState() self.permissionProvider = permissionProvider ?? ScreenCapturePermissionProviderFactory.makeDefault() - self.loadShareableDisplays = loadShareableDisplays ?? { - let content = try await SCShareableContent.excludingDesktopWindows( - false, - onScreenWindowsOnly: false - ) - return content.displays - } + self.loadShareableDisplays = loadShareableDisplays + ?? ScreenCaptureShareableDisplayLoaderFactory.makeDefault() self.loadFailureMessage = loadFailureMessage self.logOperation = logOperation self.logger = logger diff --git a/VoidDisplay/Shared/Testing/UITestScreenCaptureCatalogFixture.swift b/VoidDisplay/Shared/Testing/UITestScreenCaptureCatalogFixture.swift new file mode 100644 index 0000000..1698c52 --- /dev/null +++ b/VoidDisplay/Shared/Testing/UITestScreenCaptureCatalogFixture.swift @@ -0,0 +1,101 @@ +import AppKit +import CoreGraphics +import Foundation +import ScreenCaptureKit + +@MainActor +enum UITestScreenCaptureCatalogFixture { + private final class MockSCDisplayBox: NSObject { + @objc let displayID: CGDirectDisplayID + @objc let width: Int + @objc let height: Int + @objc let frame: CGRect + + init(displayID: CGDirectDisplayID, width: Int, height: Int) { + self.displayID = displayID + self.width = width + self.height = height + self.frame = CGRect(x: 0, y: 0, width: width, height: height) + super.init() + } + } + + private static let fallbackDisplayID = CGDirectDisplayID(9_001) + + static func makeShareableDisplays() -> [SCDisplay] { + let displays = NSScreen.screens.compactMap(makeDisplay(for:)) + if displays.isEmpty { + return [makeFallbackDisplay()] + } + return displays + } + + static func activeDisplayIDs() -> Set { + let ids = Set(NSScreen.screens.compactMap(\.cgDirectDisplayID)) + if ids.isEmpty { + return [fallbackDisplayID] + } + return ids + } + + private static func makeDisplay(for screen: NSScreen) -> SCDisplay? { + guard let displayID = screen.cgDirectDisplayID else { return nil } + let scale = max(screen.backingScaleFactor, 1) + let width = max(Int(screen.frame.width * scale), 1) + let height = max(Int(screen.frame.height * scale), 1) + return makeDisplay(displayID: displayID, width: width, height: height) + } + + private static func makeFallbackDisplay() -> SCDisplay { + makeDisplay(displayID: fallbackDisplayID, width: 1728, height: 1117) + } + + private static func makeDisplay( + displayID: CGDirectDisplayID, + width: Int, + height: Int + ) -> SCDisplay { + let box = MockSCDisplayBox(displayID: displayID, width: width, height: height) + return unsafeBitCast(box, to: SCDisplay.self) + } +} + +enum ScreenCaptureShareableDisplayLoaderFactory { + static func makeDefault( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> ScreenCaptureCatalogService.LoadShareableDisplays { + if environment[UITestRuntime.modeEnvironmentKey] == "1" { + return { + UITestScreenCaptureCatalogFixture.makeShareableDisplays() + } + } + + if environment[PersistenceContext.xCTestConfigurationEnvironmentKey] != nil { + return { [] } + } + + return { + let content = try await SCShareableContent.excludingDesktopWindows( + false, + onScreenWindowsOnly: false + ) + return content.displays + } + } +} + +enum ScreenCaptureActiveDisplayIDsProviderFactory { + static func makeDefault( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> ScreenCaptureCatalogService.ActiveDisplayIDsProvider { + if environment[UITestRuntime.modeEnvironmentKey] == "1" { + return { + UITestScreenCaptureCatalogFixture.activeDisplayIDs() + } + } + + return { + Set(NSScreen.screens.compactMap(\.cgDirectDisplayID)) + } + } +} diff --git a/VoidDisplayTests/Shared/ScreenCaptureCatalogServiceTests.swift b/VoidDisplayTests/Shared/ScreenCaptureCatalogServiceTests.swift index eedad5c..3341804 100644 --- a/VoidDisplayTests/Shared/ScreenCaptureCatalogServiceTests.swift +++ b/VoidDisplayTests/Shared/ScreenCaptureCatalogServiceTests.swift @@ -71,6 +71,31 @@ private enum CatalogServiceMockSCDisplay { @MainActor @Suite(.serialized) struct ScreenCaptureCatalogServiceTests { + @Test func defaultShareableDisplayLoaderReturnsEmptySnapshotUnderXCTestEnvironment() async throws { + let loader = ScreenCaptureShareableDisplayLoaderFactory.makeDefault( + environment: [ + PersistenceContext.xCTestConfigurationEnvironmentKey: "/tmp/VoidDisplayTests.xctest" + ] + ) + + let displays = try await loader() + + #expect(displays.isEmpty) + } + + @Test func defaultShareableDisplayLoaderUsesFixtureDisplaysUnderUITestMode() async throws { + let loader = ScreenCaptureShareableDisplayLoaderFactory.makeDefault( + environment: [ + UITestRuntime.modeEnvironmentKey: "1", + UITestRuntime.scenarioEnvironmentKey: UITestScenario.baseline.rawValue + ] + ) + + let displays = try await loader() + + #expect(!displays.isEmpty) + } + @Test func unchangedTopologyReusesSnapshotWithoutReload() async { let gate = SequencedCatalogServiceLoadGate(scriptedOutcomes: [.success]) let sut = ScreenCaptureCatalogService( From 1b97fdf14b04893da5ab7069367a6dec3366b7b0 Mon Sep 17 00:00:00 2001 From: Chen Date: Sat, 4 Apr 2026 00:57:36 +0800 Subject: [PATCH 30/34] =?UTF-8?q?fix(capture-sharing):=20=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E7=9B=AE=E5=BD=95=E6=8B=93=E6=89=91=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E4=B8=8E=E5=85=B1=E4=BA=AB=E7=8A=B6=E6=80=81=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 统一屏幕拓扑签名并修复无效 display 边界 - 调整预览 fanout 锁边界并补充慢 sink 回归测试 - 为共享状态 tombstone 增加有界清理并稳定集成验证 --- .../Services/DisplaySampleFanout.swift | 12 +- .../Sharing/Services/SharingState.swift | 28 ++++- ...ayTopologyRefreshLifecycleController.swift | 10 +- .../ScreenCaptureCatalogService.swift | 98 +++++++++++++-- ...eenCaptureCatalogTopologyCoordinator.swift | 6 +- ...ptureCatalogTopologyIntegrationTests.swift | 118 +++++++++++++++--- .../Services/DisplaySampleFanoutTests.swift | 102 +++++++++++++++ .../CaptureChooseViewModelTests.swift | 20 +-- .../Services/SharingServiceTests.swift | 30 +++++ .../ViewModels/ShareViewModelTests.swift | 22 ++-- .../Views/ShareViewBehaviorTests.swift | 79 ++++++++++-- .../ScreenCaptureCatalogServiceTests.swift | 64 +++++++++- .../TestSupport/TestServiceMocks.swift | 26 ++++ 13 files changed, 554 insertions(+), 61 deletions(-) diff --git a/VoidDisplay/Features/Capture/Services/DisplaySampleFanout.swift b/VoidDisplay/Features/Capture/Services/DisplaySampleFanout.swift index 1d59f8b..b143198 100644 --- a/VoidDisplay/Features/Capture/Services/DisplaySampleFanout.swift +++ b/VoidDisplay/Features/Capture/Services/DisplaySampleFanout.swift @@ -54,21 +54,21 @@ private final class PreviewSinkMailbox: @unchecked Sendable { nonisolated private func drain() { while true { - let shouldContinue = state.withLock { state -> Bool in + let nextFrame = state.withLock { state -> SendableSampleBuffer? in guard state.isActive else { state.latestFrame = nil state.isDraining = false - return false + return nil } guard let latestFrame = state.latestFrame else { state.isDraining = false - return false + return nil } state.latestFrame = nil - sink.submitFrame(latestFrame.value) - return true + return latestFrame } - guard shouldContinue else { return } + guard let nextFrame else { return } + sink.submitFrame(nextFrame.value) } } } diff --git a/VoidDisplay/Features/Sharing/Services/SharingState.swift b/VoidDisplay/Features/Sharing/Services/SharingState.swift index bf659dc..a1fb25f 100644 --- a/VoidDisplay/Features/Sharing/Services/SharingState.swift +++ b/VoidDisplay/Features/Sharing/Services/SharingState.swift @@ -118,9 +118,12 @@ final class SharingStateSubscription { @MainActor final class SharingStateAggregator { typealias Observer = @MainActor @Sendable (SharingStateSnapshot) -> Void + nonisolated static let closedClientTombstoneLimit = 512 private var clientStatesByID: [String: SharingClientState] = [:] private var lastAcceptedSequenceByClientID: [String: UInt64] = [:] + private var closedClientTombstoneSequenceByClientID: [String: UInt64] = [:] + private var closedClientTombstoneOrder: [(clientID: String, sequence: UInt64)] = [] private var observers: [UUID: Observer] = [:] private var snapshot = SharingStateSnapshot.empty @@ -128,15 +131,27 @@ final class SharingStateAggregator { snapshot } + var closedClientTombstoneCountForTesting: Int { + closedClientTombstoneSequenceByClientID.count + } + func record(_ event: SharingSessionEvent) { if let lastAcceptedSequence = lastAcceptedSequenceByClientID[event.clientID], event.sequence <= lastAcceptedSequence { return } - lastAcceptedSequenceByClientID[event.clientID] = event.sequence + if let closedClientSequence = closedClientTombstoneSequenceByClientID[event.clientID] { + guard event.sequence > closedClientSequence else { return } + closedClientTombstoneSequenceByClientID.removeValue(forKey: event.clientID) + } if event.phase == .closed { clientStatesByID.removeValue(forKey: event.clientID) + lastAcceptedSequenceByClientID.removeValue(forKey: event.clientID) + closedClientTombstoneSequenceByClientID[event.clientID] = event.sequence + closedClientTombstoneOrder.append((event.clientID, event.sequence)) + pruneClosedClientTombstonesIfNeeded() } else { + lastAcceptedSequenceByClientID[event.clientID] = event.sequence let nextState = SharingClientState( target: event.target, clientID: event.clientID, @@ -152,6 +167,8 @@ final class SharingStateAggregator { func reset() { clientStatesByID.removeAll() lastAcceptedSequenceByClientID.removeAll() + closedClientTombstoneSequenceByClientID.removeAll() + closedClientTombstoneOrder.removeAll() rebuildSnapshot(lastUpdatedAt: nil) } @@ -194,4 +211,13 @@ final class SharingStateAggregator { observer(snapshot) } } + + private func pruneClosedClientTombstonesIfNeeded() { + while closedClientTombstoneSequenceByClientID.count > Self.closedClientTombstoneLimit, + let oldest = closedClientTombstoneOrder.first { + closedClientTombstoneOrder.removeFirst() + guard closedClientTombstoneSequenceByClientID[oldest.clientID] == oldest.sequence else { continue } + closedClientTombstoneSequenceByClientID.removeValue(forKey: oldest.clientID) + } + } } diff --git a/VoidDisplay/Shared/ScreenCapture/DisplayTopologyRefreshLifecycleController.swift b/VoidDisplay/Shared/ScreenCapture/DisplayTopologyRefreshLifecycleController.swift index 92c4f9b..baeadfb 100644 --- a/VoidDisplay/Shared/ScreenCapture/DisplayTopologyRefreshLifecycleController.swift +++ b/VoidDisplay/Shared/ScreenCapture/DisplayTopologyRefreshLifecycleController.swift @@ -7,7 +7,7 @@ import OSLog @MainActor @Observable final class DisplayTopologyRefreshLifecycleController { - typealias DisplayTopologySignatureProvider = @MainActor () -> [CGDirectDisplayID] + typealias DisplayTopologySignatureProvider = @MainActor () -> ScreenCaptureDisplayTopologySignature typealias SleepOperation = @Sendable (Duration) async -> Void private(set) var showToolbarRefresh = false @@ -18,12 +18,16 @@ final class DisplayTopologyRefreshLifecycleController { @ObservationIgnored private let fallbackPollingInterval: Duration @ObservationIgnored private let recoveryAttemptInterval: Int @ObservationIgnored private var displayRefreshFallbackTask: Task? - @ObservationIgnored private var lastKnownDisplayTopologySignature: [CGDirectDisplayID] = [] + @ObservationIgnored private var lastKnownDisplayTopologySignature: ScreenCaptureDisplayTopologySignature = [] init( displayRefreshMonitor: any DisplayReconfigurationMonitoring = DebouncingDisplayReconfigurationMonitor(), displayTopologySignatureProvider: @escaping DisplayTopologySignatureProvider = { - NSScreen.screens.compactMap(\.cgDirectDisplayID).sorted() + ScreenCaptureDisplayTopologySignatureResolver.current( + activeDisplayIDsProvider: { + Set(NSScreen.screens.compactMap(\.cgDirectDisplayID)) + } + ) }, sleep: @escaping SleepOperation = { duration in try? await Task.sleep(for: duration) diff --git a/VoidDisplay/Shared/ScreenCapture/ScreenCaptureCatalogService.swift b/VoidDisplay/Shared/ScreenCapture/ScreenCaptureCatalogService.swift index d6055be..5b89e0d 100644 --- a/VoidDisplay/Shared/ScreenCapture/ScreenCaptureCatalogService.swift +++ b/VoidDisplay/Shared/ScreenCapture/ScreenCaptureCatalogService.swift @@ -5,6 +5,68 @@ import Observation import OSLog @preconcurrency import ScreenCaptureKit +typealias ScreenCaptureDisplayTopologySignature = [ScreenCaptureDisplayTopologySignatureEntry] + +struct ScreenCaptureDisplayTopologySignatureEntry: Sendable, Equatable, Hashable { + let displayID: CGDirectDisplayID + let isMain: Bool + let pixelWidth: Int + let pixelHeight: Int + let refreshRateMilliHertz: Int? + let mirrorsDisplayID: CGDirectDisplayID? + + init( + displayID: CGDirectDisplayID, + isMain: Bool = false, + pixelWidth: Int = 0, + pixelHeight: Int = 0, + refreshRateMilliHertz: Int? = nil, + mirrorsDisplayID: CGDirectDisplayID? = nil + ) { + self.displayID = displayID + self.isMain = isMain + self.pixelWidth = pixelWidth + self.pixelHeight = pixelHeight + self.refreshRateMilliHertz = refreshRateMilliHertz + self.mirrorsDisplayID = mirrorsDisplayID + } + + static func current(displayID: CGDirectDisplayID) -> Self { + let mode = CGDisplayCopyDisplayMode(displayID) + let refreshRateMilliHertz: Int? = { + guard let mode else { return nil } + let refreshRate = mode.refreshRate + guard refreshRate > 0 else { return nil } + return Int((refreshRate * 1_000).rounded()) + }() + let mirrorsDisplayID: CGDirectDisplayID? = { + let mirroredDisplayID = CGDisplayMirrorsDisplay(displayID) + guard mirroredDisplayID != kCGNullDirectDisplay else { return nil } + guard mirroredDisplayID != CGDirectDisplayID.max else { return nil } + return mirroredDisplayID + }() + return .init( + displayID: displayID, + isMain: CGDisplayIsMain(displayID) > 0, + pixelWidth: max(0, CGDisplayPixelsWide(displayID)), + pixelHeight: max(0, CGDisplayPixelsHigh(displayID)), + refreshRateMilliHertz: refreshRateMilliHertz, + mirrorsDisplayID: mirrorsDisplayID + ) + } +} + +enum ScreenCaptureDisplayTopologySignatureResolver { + @MainActor + static func current( + activeDisplayIDsProvider: () -> Set + ) -> ScreenCaptureDisplayTopologySignature { + activeDisplayIDsProvider() + .sorted() + .map(ScreenCaptureDisplayTopologySignatureEntry.current(displayID:)) + } +} + enum ScreenCaptureCatalogRefreshIntent: Sendable, Equatable { case permissionChanged case topologyChanged @@ -27,7 +89,7 @@ final class ScreenCaptureCatalogStore { var hasScreenCapturePermission: Bool? var lastPreflightPermission: Bool? var lastRequestPermission: Bool? - var lastLoadedActiveDisplayTopologySignature: [CGDirectDisplayID]? + var lastLoadedActiveDisplayTopologySignature: ScreenCaptureDisplayTopologySignature? var isLoadingDisplays = false var loadErrorMessage: String? var lastLoadError: ScreenCaptureDisplayCatalogLoadErrorInfo? @@ -48,6 +110,7 @@ final class ScreenCaptureCatalogService { var permissionProvider: any ScreenCapturePermissionProvider var loadShareableDisplays: LoadShareableDisplays var activeDisplayIDsProvider: ActiveDisplayIDsProvider + var displayTopologySignatureProvider: @MainActor () -> ScreenCaptureDisplayTopologySignature var loadFailureMessage: String var logOperation: String var logger: Logger @@ -58,10 +121,16 @@ final class ScreenCaptureCatalogService { logOperation: String = "Load shareable displays", logger: Logger = AppLog.capture ) -> Self { - .init( + let activeDisplayIDsProvider = ScreenCaptureActiveDisplayIDsProviderFactory.makeDefault() + return .init( permissionProvider: ScreenCapturePermissionProviderFactory.makeDefault(), loadShareableDisplays: ScreenCaptureShareableDisplayLoaderFactory.makeDefault(), - activeDisplayIDsProvider: ScreenCaptureActiveDisplayIDsProviderFactory.makeDefault(), + activeDisplayIDsProvider: activeDisplayIDsProvider, + displayTopologySignatureProvider: { + ScreenCaptureDisplayTopologySignatureResolver.current( + activeDisplayIDsProvider: activeDisplayIDsProvider + ) + }, loadFailureMessage: loadFailureMessage, logOperation: logOperation, logger: logger, @@ -97,19 +166,26 @@ final class ScreenCaptureCatalogService { permissionProvider: (any ScreenCapturePermissionProvider)? = nil, loadShareableDisplays: LoadShareableDisplays? = nil, activeDisplayIDsProvider: ActiveDisplayIDsProvider? = nil, + displayTopologySignatureProvider: (@MainActor () -> ScreenCaptureDisplayTopologySignature)? = nil, loadFailureMessage: String = String(localized: "Failed to load displays. Check permission and try again."), logOperation: String = "Load shareable displays", logger: Logger = AppLog.capture, runtimeScenarioProbe: ScreenCaptureDisplayCatalogLoader.RuntimeScenarioProbe = .live ) { + let resolvedActiveDisplayIDsProvider = activeDisplayIDsProvider + ?? ScreenCaptureActiveDisplayIDsProviderFactory.makeDefault() self.init( store: store, dependencies: .init( permissionProvider: permissionProvider ?? ScreenCapturePermissionProviderFactory.makeDefault(), loadShareableDisplays: loadShareableDisplays ?? ScreenCaptureShareableDisplayLoaderFactory.makeDefault(), - activeDisplayIDsProvider: activeDisplayIDsProvider - ?? ScreenCaptureActiveDisplayIDsProviderFactory.makeDefault(), + activeDisplayIDsProvider: resolvedActiveDisplayIDsProvider, + displayTopologySignatureProvider: displayTopologySignatureProvider ?? { + ScreenCaptureDisplayTopologySignatureResolver.current( + activeDisplayIDsProvider: resolvedActiveDisplayIDsProvider + ) + }, loadFailureMessage: loadFailureMessage, logOperation: logOperation, logger: logger, @@ -145,8 +221,8 @@ final class ScreenCaptureCatalogService { return granted } - func currentActiveDisplayTopologySignature() -> [CGDirectDisplayID] { - dependencies.activeDisplayIDsProvider().sorted() + func currentActiveDisplayTopologySignature() -> ScreenCaptureDisplayTopologySignature { + dependencies.displayTopologySignatureProvider() } func visibleDisplays(from displays: [SCDisplay]) -> [SCDisplay] { @@ -210,7 +286,7 @@ final class ScreenCaptureCatalogService { } } - private func applyClearedSnapshot(signature: [CGDirectDisplayID]) { + private func applyClearedSnapshot(signature: ScreenCaptureDisplayTopologySignature) { store.displays = nil store.hasScreenCapturePermission = false store.lastPreflightPermission = false @@ -223,7 +299,7 @@ final class ScreenCaptureCatalogService { private func commit( execution: CatalogRefreshCoordinator.ExecutionResult, - signature: [CGDirectDisplayID] + signature: ScreenCaptureDisplayTopologySignature ) -> ScreenCaptureCatalogRefreshResult { switch execution { case .reloadedSnapshot(let displays): @@ -276,8 +352,8 @@ actor CatalogRefreshCoordinator { struct Request: Sendable { let intent: ScreenCaptureCatalogRefreshIntent let permissionGranted: Bool - let currentTopologySignature: [CGDirectDisplayID] - let cachedTopologySignature: [CGDirectDisplayID]? + let currentTopologySignature: ScreenCaptureDisplayTopologySignature + let cachedTopologySignature: ScreenCaptureDisplayTopologySignature? let hasCachedDisplays: Bool let ownerID: UUID? } diff --git a/VoidDisplay/Shared/ScreenCapture/ScreenCaptureCatalogTopologyCoordinator.swift b/VoidDisplay/Shared/ScreenCapture/ScreenCaptureCatalogTopologyCoordinator.swift index 5342cfb..6bf0b22 100644 --- a/VoidDisplay/Shared/ScreenCapture/ScreenCaptureCatalogTopologyCoordinator.swift +++ b/VoidDisplay/Shared/ScreenCapture/ScreenCaptureCatalogTopologyCoordinator.swift @@ -34,7 +34,9 @@ struct ScreenCaptureCatalogTopologyCoordinator { state.lastLoadedActiveDisplayTopologySignature = currentActiveDisplayTopologySignature() } - func currentActiveDisplayTopologySignature() -> [CGDirectDisplayID] { - activeDisplayIDsProvider().sorted() + func currentActiveDisplayTopologySignature() -> ScreenCaptureDisplayTopologySignature { + ScreenCaptureDisplayTopologySignatureResolver.current( + activeDisplayIDsProvider: activeDisplayIDsProvider + ) } } diff --git a/VoidDisplayTests/App/ScreenCaptureCatalogTopologyIntegrationTests.swift b/VoidDisplayTests/App/ScreenCaptureCatalogTopologyIntegrationTests.swift index 1674c29..45d8620 100644 --- a/VoidDisplayTests/App/ScreenCaptureCatalogTopologyIntegrationTests.swift +++ b/VoidDisplayTests/App/ScreenCaptureCatalogTopologyIntegrationTests.swift @@ -60,11 +60,29 @@ struct ScreenCaptureCatalogTopologyIntegrationTests { let sharedCatalogState = ScreenCaptureDisplayCatalogState() sharedCatalogState.displays = [staleDisplay] sharedCatalogState.lastLoadedActiveDisplayTopologySignature = nil - + let permissionProvider = MockScreenCapturePermissionProvider( + preflightResult: true, + requestResult: true + ) + let activeDisplayIDsProvider: @MainActor () -> Set = { [3333] } let firstGate = IntegrationSequencedDisplayLoaderGate(scriptedOutcomes: [.success]) + let firstCatalogService = makeCatalogService( + store: sharedCatalogState, + permissionProvider: permissionProvider, + loadShareableDisplays: { + switch await firstGate.nextOutcome() { + case .success: + return [refreshedDisplay] + case .failure: + throw IntegrationControlledLoadFailure() + } + }, + activeDisplayIDsProvider: activeDisplayIDsProvider + ) let firstVM = CaptureChooseViewModel( + catalogService: firstCatalogService, catalogState: sharedCatalogState, - permissionProvider: MockScreenCapturePermissionProvider(preflightResult: true, requestResult: true), + permissionProvider: permissionProvider, loadShareableDisplays: { switch await firstGate.nextOutcome() { case .success: @@ -73,7 +91,7 @@ struct ScreenCaptureCatalogTopologyIntegrationTests { throw IntegrationControlledLoadFailure() } }, - activeDisplayIDsProvider: { Set([3333]) }, + activeDisplayIDsProvider: activeDisplayIDsProvider, dependencies: makeNoopCaptureDependencies() ) @@ -83,15 +101,30 @@ struct ScreenCaptureCatalogTopologyIntegrationTests { let firstRefreshFinished = await waitUntil { sharedCatalogState.isLoadingDisplays == false && - sharedCatalogState.displays?.map(\.displayID) == [3333] && - sharedCatalogState.lastLoadedActiveDisplayTopologySignature == [3333] + sharedCatalogState.displays?.map { $0.displayID } == [3333] && + sharedCatalogState.lastLoadedActiveDisplayTopologySignature + == makeTestDisplayTopologySignature([3333]) } #expect(firstRefreshFinished) let secondGate = IntegrationSequencedDisplayLoaderGate(scriptedOutcomes: [.success]) + let secondCatalogService = makeCatalogService( + store: sharedCatalogState, + permissionProvider: permissionProvider, + loadShareableDisplays: { + switch await secondGate.nextOutcome() { + case .success: + return [refreshedDisplay] + case .failure: + throw IntegrationControlledLoadFailure() + } + }, + activeDisplayIDsProvider: activeDisplayIDsProvider + ) let secondVM = CaptureChooseViewModel( + catalogService: secondCatalogService, catalogState: sharedCatalogState, - permissionProvider: MockScreenCapturePermissionProvider(preflightResult: true, requestResult: true), + permissionProvider: permissionProvider, loadShareableDisplays: { switch await secondGate.nextOutcome() { case .success: @@ -100,7 +133,7 @@ struct ScreenCaptureCatalogTopologyIntegrationTests { throw IntegrationControlledLoadFailure() } }, - activeDisplayIDsProvider: { Set([3333]) }, + activeDisplayIDsProvider: activeDisplayIDsProvider, dependencies: makeNoopCaptureDependencies() ) @@ -122,13 +155,32 @@ struct ScreenCaptureCatalogTopologyIntegrationTests { let refreshedDisplay = IntegrationMockSCDisplay.make(displayID: 5555, width: 2560, height: 1440) let sharedCatalogState = ScreenCaptureDisplayCatalogState() sharedCatalogState.displays = [staleDisplay] - sharedCatalogState.lastLoadedActiveDisplayTopologySignature = [4444] + sharedCatalogState.lastLoadedActiveDisplayTopologySignature = makeTestDisplayTopologySignature([4444]) let registerCounter = IntegrationRegisterCounter() + let permissionProvider = MockScreenCapturePermissionProvider( + preflightResult: true, + requestResult: true + ) + let activeDisplayIDsProvider: @MainActor () -> Set = { [5555] } let firstGate = IntegrationSequencedDisplayLoaderGate(scriptedOutcomes: [.success]) + let firstCatalogService = makeCatalogService( + store: sharedCatalogState, + permissionProvider: permissionProvider, + loadShareableDisplays: { + switch await firstGate.nextOutcome() { + case .success: + return [refreshedDisplay] + case .failure: + throw IntegrationControlledLoadFailure() + } + }, + activeDisplayIDsProvider: activeDisplayIDsProvider + ) let firstVM = ShareViewModel( + catalogService: firstCatalogService, catalogState: sharedCatalogState, - permissionProvider: MockScreenCapturePermissionProvider(preflightResult: true, requestResult: true), + permissionProvider: permissionProvider, loadShareableDisplays: { switch await firstGate.nextOutcome() { case .success: @@ -137,7 +189,7 @@ struct ScreenCaptureCatalogTopologyIntegrationTests { throw IntegrationControlledLoadFailure() } }, - activeDisplayIDsProvider: { Set([5555]) }, + activeDisplayIDsProvider: activeDisplayIDsProvider, dependencies: .init( sharingQueries: .init( isWebServiceRunning: { true }, @@ -166,16 +218,31 @@ struct ScreenCaptureCatalogTopologyIntegrationTests { let firstRefreshFinished = await waitUntil { sharedCatalogState.isLoadingDisplays == false && - sharedCatalogState.displays?.map(\.displayID) == [5555] && - sharedCatalogState.lastLoadedActiveDisplayTopologySignature == [5555] + sharedCatalogState.displays?.map { $0.displayID } == [5555] && + sharedCatalogState.lastLoadedActiveDisplayTopologySignature + == makeTestDisplayTopologySignature([5555]) } #expect(firstRefreshFinished) #expect(registerCounter.value >= 1) let secondGate = IntegrationSequencedDisplayLoaderGate(scriptedOutcomes: [.success]) + let secondCatalogService = makeCatalogService( + store: sharedCatalogState, + permissionProvider: permissionProvider, + loadShareableDisplays: { + switch await secondGate.nextOutcome() { + case .success: + return [refreshedDisplay] + case .failure: + throw IntegrationControlledLoadFailure() + } + }, + activeDisplayIDsProvider: activeDisplayIDsProvider + ) let secondVM = ShareViewModel( + catalogService: secondCatalogService, catalogState: sharedCatalogState, - permissionProvider: MockScreenCapturePermissionProvider(preflightResult: true, requestResult: true), + permissionProvider: permissionProvider, loadShareableDisplays: { switch await secondGate.nextOutcome() { case .success: @@ -184,7 +251,7 @@ struct ScreenCaptureCatalogTopologyIntegrationTests { throw IntegrationControlledLoadFailure() } }, - activeDisplayIDsProvider: { Set([5555]) }, + activeDisplayIDsProvider: activeDisplayIDsProvider, dependencies: .init( sharingQueries: .init( isWebServiceRunning: { true }, @@ -232,6 +299,29 @@ struct ScreenCaptureCatalogTopologyIntegrationTests { return await gate.currentCallCount() >= count } + private func makeCatalogService( + store: ScreenCaptureDisplayCatalogState, + permissionProvider: any ScreenCapturePermissionProvider, + loadShareableDisplays: @escaping @MainActor () async throws -> [SCDisplay], + activeDisplayIDsProvider: @escaping @MainActor () -> Set + ) -> ScreenCaptureCatalogService { + ScreenCaptureCatalogService( + store: store, + permissionProvider: permissionProvider, + loadShareableDisplays: loadShareableDisplays, + activeDisplayIDsProvider: activeDisplayIDsProvider, + displayTopologySignatureProvider: { + ScreenCaptureDisplayTopologySignatureResolver.current( + activeDisplayIDsProvider: activeDisplayIDsProvider + ) + }, + runtimeScenarioProbe: .init( + shouldShortCircuitDisplayLoadAsPermissionDenied: { false }, + shouldDelayDisplayLoadForUITest: { false } + ) + ) + } + private func makeNoopCaptureDependencies() -> CaptureChooseViewModel.Dependencies { .init( captureActions: .init( diff --git a/VoidDisplayTests/Features/Capture/Services/DisplaySampleFanoutTests.swift b/VoidDisplayTests/Features/Capture/Services/DisplaySampleFanoutTests.swift index 9e6481c..5b781a5 100644 --- a/VoidDisplayTests/Features/Capture/Services/DisplaySampleFanoutTests.swift +++ b/VoidDisplayTests/Features/Capture/Services/DisplaySampleFanoutTests.swift @@ -1,5 +1,6 @@ import CoreGraphics import CoreMedia +import Foundation import Testing import Synchronization @testable import VoidDisplay @@ -33,6 +34,69 @@ private final class CountingPreviewSink: @unchecked Sendable, DisplayPreviewSink } } +private actor BlockingPreviewSinkEntrySignal { + private var hasEntered = false + private var waiters: [CheckedContinuation] = [] + + func markEntered() { + guard !hasEntered else { return } + hasEntered = true + let pendingWaiters = waiters + waiters.removeAll() + for waiter in pendingWaiters { + waiter.resume() + } + } + + func waitForEntry() async { + guard !hasEntered else { return } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } +} + +private final class BlockingPreviewSink: @unchecked Sendable, DisplayPreviewSink { + private let entrySignal = BlockingPreviewSinkEntrySignal() + private let releaseSemaphore = DispatchSemaphore(value: 0) + private let hasEntered = Mutex(false) + + nonisolated func submitFrame(_ sampleBuffer: CMSampleBuffer) { + _ = sampleBuffer + let shouldSignal = hasEntered.withLock { entered -> Bool in + guard !entered else { return false } + entered = true + return true + } + if shouldSignal { + Task { + await entrySignal.markEntered() + } + } + releaseSemaphore.wait() + } + + nonisolated func waitForEntry() async { + await entrySignal.waitForEntry() + } + + nonisolated func release() { + releaseSemaphore.signal() + } +} + +private final class FanoutCompletionFlag: @unchecked Sendable { + private let isComplete = Mutex(false) + + nonisolated func markComplete() { + isComplete.withLock { $0 = true } + } + + nonisolated func snapshot() -> Bool { + isComplete.withLock { $0 } + } +} + @MainActor @Suite(.serialized) struct DisplaySampleFanoutTests { @@ -53,6 +117,44 @@ struct DisplaySampleFanoutTests { #expect(noLateDelivery) } + @Test func slowSinkDoesNotBlockPublishingNewFrame() async throws { + let first = try await makeSampleBuffer() + let second = try await makeSampleBuffer() + let fanout = DisplaySampleFanout() + let sink = BlockingPreviewSink() + let completionFlag = FanoutCompletionFlag() + let sendableSecond = TestSendableSampleBuffer(value: second) + fanout.attachPreviewSink(sink) + + fanout.publishPreviewFrame(first) + await sink.waitForEntry() + + let publishTask = publishDetachedFrame( + fanout: fanout, + sampleBuffer: sendableSecond, + completionFlag: completionFlag + ) + + let publishedWithoutBlocking = await waitUntil(timeout: .milliseconds(150)) { + completionFlag.snapshot() + } + #expect(publishedWithoutBlocking) + + sink.release() + _ = await publishTask.value + } + + nonisolated private func publishDetachedFrame( + fanout: DisplaySampleFanout, + sampleBuffer: TestSendableSampleBuffer, + completionFlag: FanoutCompletionFlag + ) -> Task { + Task.detached { + fanout.publishPreviewFrame(sampleBuffer.value) + completionFlag.markComplete() + } + } + private func makeSampleBuffer() async throws -> CMSampleBuffer { let session = try UITestCapturePreviewSession( configuration: .init( diff --git a/VoidDisplayTests/Features/Capture/ViewModels/CaptureChooseViewModelTests.swift b/VoidDisplayTests/Features/Capture/ViewModels/CaptureChooseViewModelTests.swift index 6a66ed0..b0d0ade 100644 --- a/VoidDisplayTests/Features/Capture/ViewModels/CaptureChooseViewModelTests.swift +++ b/VoidDisplayTests/Features/Capture/ViewModels/CaptureChooseViewModelTests.swift @@ -307,7 +307,7 @@ struct CaptureChooseViewModelTests { dependencies: makeNoopCaptureDependencies() ) sut.catalog.displays = [existingDisplay] - sut.catalog.lastLoadedActiveDisplayTopologySignature = [2222] + sut.catalog.lastLoadedActiveDisplayTopologySignature = makeTestDisplayTopologySignature([2222]) sut.refreshPermissionAndMaybeLoad() let deadline = DispatchTime.now().uptimeNanoseconds + AsyncTestTimeouts.shortStabilityWindow @@ -357,7 +357,8 @@ struct CaptureChooseViewModelTests { let finished = await waitUntil { sut.catalog.isLoadingDisplays == false && sut.catalog.displays?.map(\.displayID) == [3333] && - sut.catalog.lastLoadedActiveDisplayTopologySignature == [3333] + sut.catalog.lastLoadedActiveDisplayTopologySignature + == makeTestDisplayTopologySignature([3333]) } #expect(finished) } @@ -383,7 +384,7 @@ struct CaptureChooseViewModelTests { dependencies: makeNoopCaptureDependencies() ) sut.catalog.displays = [existingDisplay] - sut.catalog.lastLoadedActiveDisplayTopologySignature = [2222] + sut.catalog.lastLoadedActiveDisplayTopologySignature = makeTestDisplayTopologySignature([2222]) sut.refreshPermissionAndMaybeLoad() #expect(await waitForLoaderCall(gate, count: 1)) @@ -394,7 +395,8 @@ struct CaptureChooseViewModelTests { let finished = await waitUntil { sut.catalog.isLoadingDisplays == false && sut.catalog.displays?.map(\.displayID) == [3333] && - sut.catalog.lastLoadedActiveDisplayTopologySignature == [3333] + sut.catalog.lastLoadedActiveDisplayTopologySignature + == makeTestDisplayTopologySignature([3333]) } #expect(finished) } @@ -420,7 +422,7 @@ struct CaptureChooseViewModelTests { dependencies: makeNoopCaptureDependencies() ) sut.catalog.displays = [existingDisplay] - sut.catalog.lastLoadedActiveDisplayTopologySignature = [2222] + sut.catalog.lastLoadedActiveDisplayTopologySignature = makeTestDisplayTopologySignature([2222]) sut.refreshPermissionAndMaybeLoad() #expect(await waitForLoaderCall(gate, count: 1)) @@ -431,7 +433,10 @@ struct CaptureChooseViewModelTests { } #expect(firstFinished) #expect(sut.catalog.displays?.map(\.displayID) == [2222]) - #expect(sut.catalog.lastLoadedActiveDisplayTopologySignature == [2222]) + #expect( + sut.catalog.lastLoadedActiveDisplayTopologySignature + == makeTestDisplayTopologySignature([2222]) + ) sut.refreshPermissionAndMaybeLoad() #expect(await waitForLoaderCall(gate, count: 2)) @@ -441,7 +446,8 @@ struct CaptureChooseViewModelTests { sut.catalog.isLoadingDisplays == false && sut.catalog.lastLoadError == nil && sut.catalog.displays?.map(\.displayID) == [3333] && - sut.catalog.lastLoadedActiveDisplayTopologySignature == [3333] + sut.catalog.lastLoadedActiveDisplayTopologySignature + == makeTestDisplayTopologySignature([3333]) } #expect(secondFinished) } diff --git a/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift b/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift index 6e1a7d9..87d5e1d 100644 --- a/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift +++ b/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift @@ -440,6 +440,36 @@ struct SharingServiceTests { #expect(receivedStates == [true, false]) } + @MainActor @Test func closedClientTombstonesStayBounded() { + let aggregator = SharingStateAggregator() + let target = ShareTarget.id(88) + + for index in 0..<(SharingStateAggregator.closedClientTombstoneLimit + 5) { + let clientID = "client-\(index)" + aggregator.record( + SharingSessionEvent( + target: target, + clientID: clientID, + sequence: 1, + phase: .signalingConnected, + source: .webSocket + ) + ) + aggregator.record( + SharingSessionEvent( + target: target, + clientID: clientID, + sequence: 2, + phase: .closed, + source: .webSocket + ) + ) + } + + #expect(aggregator.currentSnapshot.signalingConnections == 0) + #expect(aggregator.closedClientTombstoneCountForTesting == SharingStateAggregator.closedClientTombstoneLimit) + } + @MainActor private func makeService( webServiceController: MockWebServiceController, diff --git a/VoidDisplayTests/Features/Sharing/ViewModels/ShareViewModelTests.swift b/VoidDisplayTests/Features/Sharing/ViewModels/ShareViewModelTests.swift index 18d0b66..c4fc16a 100644 --- a/VoidDisplayTests/Features/Sharing/ViewModels/ShareViewModelTests.swift +++ b/VoidDisplayTests/Features/Sharing/ViewModels/ShareViewModelTests.swift @@ -271,7 +271,7 @@ struct ShareViewModelTests { ) ) sut.catalog.displays = [existingDisplay] - sut.catalog.lastLoadedActiveDisplayTopologySignature = [4444] + sut.catalog.lastLoadedActiveDisplayTopologySignature = makeTestDisplayTopologySignature([4444]) sut.refreshPermissionAndMaybeLoad() #expect(await waitForLoaderCall(gate, count: 1)) @@ -282,7 +282,8 @@ struct ShareViewModelTests { let finished = await waitUntil { sut.catalog.isLoadingDisplays == false && sut.catalog.displays?.map(\.displayID) == [5555] && - sut.catalog.lastLoadedActiveDisplayTopologySignature == [5555] + sut.catalog.lastLoadedActiveDisplayTopologySignature + == makeTestDisplayTopologySignature([5555]) } #expect(finished) #expect(registerShareableDisplaysCallCount == 1) @@ -320,7 +321,8 @@ struct ShareViewModelTests { let finished = await waitUntil { sut.catalog.isLoadingDisplays == false && sut.catalog.displays?.map(\.displayID) == [5555] && - sut.catalog.lastLoadedActiveDisplayTopologySignature == [5555] + sut.catalog.lastLoadedActiveDisplayTopologySignature + == makeTestDisplayTopologySignature([5555]) } #expect(finished) } @@ -346,7 +348,7 @@ struct ShareViewModelTests { dependencies: makeAlwaysRunningShareDependencies() ) sut.catalog.displays = [existingDisplay] - sut.catalog.lastLoadedActiveDisplayTopologySignature = [4444] + sut.catalog.lastLoadedActiveDisplayTopologySignature = makeTestDisplayTopologySignature([4444]) sut.refreshPermissionAndMaybeLoad() #expect(await waitForLoaderCall(gate, count: 1)) @@ -357,7 +359,10 @@ struct ShareViewModelTests { } #expect(firstFinished) #expect(sut.catalog.displays?.map(\.displayID) == [4444]) - #expect(sut.catalog.lastLoadedActiveDisplayTopologySignature == [4444]) + #expect( + sut.catalog.lastLoadedActiveDisplayTopologySignature + == makeTestDisplayTopologySignature([4444]) + ) sut.refreshPermissionAndMaybeLoad() #expect(await waitForLoaderCall(gate, count: 2)) @@ -367,7 +372,8 @@ struct ShareViewModelTests { sut.catalog.isLoadingDisplays == false && sut.catalog.lastLoadError == nil && sut.catalog.displays?.map(\.displayID) == [5555] && - sut.catalog.lastLoadedActiveDisplayTopologySignature == [5555] + sut.catalog.lastLoadedActiveDisplayTopologySignature + == makeTestDisplayTopologySignature([5555]) } #expect(secondFinished) } @@ -563,7 +569,9 @@ struct ShareViewModelTests { dependencies: makeNoopShareDependencies() ) sut.catalog.displays = [existingDisplay] - sut.catalog.lastLoadedActiveDisplayTopologySignature = [existingDisplay.displayID] + sut.catalog.lastLoadedActiveDisplayTopologySignature = makeTestDisplayTopologySignature( + [existingDisplay.displayID] + ) sut.refreshDisplaysBackgroundSafe() diff --git a/VoidDisplayTests/Features/Sharing/Views/ShareViewBehaviorTests.swift b/VoidDisplayTests/Features/Sharing/Views/ShareViewBehaviorTests.swift index e1cefe5..c5a8cc4 100644 --- a/VoidDisplayTests/Features/Sharing/Views/ShareViewBehaviorTests.swift +++ b/VoidDisplayTests/Features/Sharing/Views/ShareViewBehaviorTests.swift @@ -1,5 +1,6 @@ import CoreGraphics import ScreenCaptureKit +import Synchronization import Testing @testable import VoidDisplay @@ -55,13 +56,25 @@ private final class ShareViewDisplayReconfigurationMonitor: DisplayReconfigurati } private final class ShareViewSignatureBox { - var value: [CGDirectDisplayID] + var value: ScreenCaptureDisplayTopologySignature - init(_ value: [CGDirectDisplayID]) { + init(_ value: ScreenCaptureDisplayTopologySignature) { self.value = value } } +private final class ShareViewTopologyChangeCounter: @unchecked Sendable { + private let count = Mutex(0) + + nonisolated func increment() { + count.withLock { $0 += 1 } + } + + nonisolated func snapshot() -> Int { + count.withLock { $0 } + } +} + private final class ShareViewMockSCDisplayBox: NSObject { @objc let displayID: CGDirectDisplayID @objc let width: Int @@ -118,19 +131,35 @@ struct ShareViewBehaviorTests { @Test func lifecycleFallbackRefreshesDisplaysAfterTopologyChange() async { let loaderGate = ShareViewLoaderGate() - let signatureBox = ShareViewSignatureBox([101]) + let signatureBox = ShareViewSignatureBox(makeTestDisplayTopologySignature([101])) let existingDisplay = ShareViewMockSCDisplay.make(displayID: 101, width: 1920, height: 1080) let refreshedDisplay = ShareViewMockSCDisplay.make(displayID: 202, width: 2560, height: 1440) + let catalogService = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider( + preflightResult: true, + requestResult: true + ), + loadShareableDisplays: { + await loaderGate.next() + return [refreshedDisplay] + }, + activeDisplayIDsProvider: { Set(signatureBox.value.map(\.displayID)) }, + displayTopologySignatureProvider: { signatureBox.value }, + runtimeScenarioProbe: .init( + shouldShortCircuitDisplayLoadAsPermissionDenied: { false }, + shouldDelayDisplayLoadForUITest: { false } + ) + ) let viewModel = makeViewModel( + catalogService: catalogService, isWebServiceRunning: true, loadShareableDisplays: { await loaderGate.next() return [refreshedDisplay] - }, - activeDisplayIDsProvider: { Set(signatureBox.value) } + } ) viewModel.catalog.displays = [existingDisplay] - viewModel.catalog.lastLoadedActiveDisplayTopologySignature = [101] + viewModel.catalog.lastLoadedActiveDisplayTopologySignature = makeTestDisplayTopologySignature([101]) let lifecycle = DisplayTopologyRefreshLifecycleController( displayRefreshMonitor: ShareViewDisplayReconfigurationMonitor(startResults: [false, false]), @@ -147,7 +176,7 @@ struct ShareViewBehaviorTests { await drainMainActorTasks() #expect(await loaderGate.currentCallCount() == 0) - signatureBox.value = [202] + signatureBox.value = makeTestDisplayTopologySignature([202]) let requestedReload = await waitUntilAsync { await loaderGate.currentCallCount() == 1 @@ -157,7 +186,7 @@ struct ShareViewBehaviorTests { await loaderGate.release() let finished = await waitUntil { viewModel.catalog.isLoadingDisplays == false && - viewModel.catalog.displays?.map(\.displayID) == [202] + viewModel.catalog.displays?.map { $0.displayID } == [202] } #expect(finished) } @@ -188,6 +217,38 @@ struct ShareViewBehaviorTests { #expect(monitor.stopCallCount == 1) } + @Test func lifecycleFallbackDetectsConfigurationChangeForSameDisplayID() async { + let displayID = CGDirectDisplayID(707) + let signatureBox = ShareViewSignatureBox([ + makeTestDisplayTopologySignatureEntry( + displayID: displayID, + pixelWidth: 1920, + pixelHeight: 1080 + ) + ]) + let lifecycle = DisplayTopologyRefreshLifecycleController( + displayRefreshMonitor: ShareViewDisplayReconfigurationMonitor(startResults: [false, false]), + displayTopologySignatureProvider: { signatureBox.value }, + fallbackPollingInterval: .milliseconds(20), + recoveryAttemptInterval: 99 + ) + let topologyChangeCount = ShareViewTopologyChangeCounter() + + lifecycle.handleAppear { + topologyChangeCount.increment() + } + signatureBox.value = [ + makeTestDisplayTopologySignatureEntry( + displayID: displayID, + pixelWidth: 2560, + pixelHeight: 1440 + ) + ] + + #expect(await waitUntilAsync { topologyChangeCount.snapshot() == 1 }) + lifecycle.handleDisappear() + } + @Test func viewModelSurfacesStartingStateFromSharingDependency() { let viewModel = makeViewModel( isWebServiceRunning: true, @@ -199,12 +260,14 @@ struct ShareViewBehaviorTests { } private func makeViewModel( + catalogService: ScreenCaptureCatalogService? = nil, isWebServiceRunning: Bool, isStartingDisplayID: @escaping @MainActor (CGDirectDisplayID) -> Bool = { _ in false }, loadShareableDisplays: (@MainActor () async throws -> [SCDisplay])? = nil, activeDisplayIDsProvider: @escaping @MainActor () -> Set = { [] } ) -> ShareViewModel { ShareViewModel( + catalogService: catalogService, permissionProvider: MockScreenCapturePermissionProvider( preflightResult: true, requestResult: true diff --git a/VoidDisplayTests/Shared/ScreenCaptureCatalogServiceTests.swift b/VoidDisplayTests/Shared/ScreenCaptureCatalogServiceTests.swift index 3341804..628f26a 100644 --- a/VoidDisplayTests/Shared/ScreenCaptureCatalogServiceTests.swift +++ b/VoidDisplayTests/Shared/ScreenCaptureCatalogServiceTests.swift @@ -46,6 +46,15 @@ private actor SequencedCatalogServiceLoadGate { private struct CatalogServiceControlledFailure: Error, Sendable {} +@MainActor +private final class CatalogServiceSignatureBox { + var value: ScreenCaptureDisplayTopologySignature + + init(_ value: ScreenCaptureDisplayTopologySignature) { + self.value = value + } +} + private final class CatalogServiceMockSCDisplayBox: NSObject { @objc let displayID: CGDirectDisplayID @objc let width: Int @@ -112,7 +121,7 @@ struct ScreenCaptureCatalogServiceTests { ) sut.store.hasScreenCapturePermission = true sut.store.displays = [] - sut.store.lastLoadedActiveDisplayTopologySignature = [101] + sut.store.lastLoadedActiveDisplayTopologySignature = makeTestDisplayTopologySignature([101]) let result = await sut.submitRefresh(intent: .permissionChanged) @@ -272,7 +281,7 @@ struct ScreenCaptureCatalogServiceTests { ) sut.store.hasScreenCapturePermission = true sut.store.displays = [staleDisplay] - sut.store.lastLoadedActiveDisplayTopologySignature = [707] + sut.store.lastLoadedActiveDisplayTopologySignature = makeTestDisplayTopologySignature([707]) let firstRefresh = Task { await sut.submitRefresh(intent: .userForcedRefresh) @@ -331,6 +340,57 @@ struct ScreenCaptureCatalogServiceTests { #expect(sut.store.loadErrorMessage == "permission denied") } + @Test func changedDisplayConfigurationReloadsEvenWhenDisplayIDIsUnchanged() async { + let gate = SequencedCatalogServiceLoadGate(scriptedOutcomes: [.success]) + let displayID = CGDirectDisplayID(909) + let signatureBox = CatalogServiceSignatureBox([ + makeTestDisplayTopologySignatureEntry( + displayID: displayID, + pixelWidth: 1920, + pixelHeight: 1080 + ) + ]) + let refreshedDisplay = CatalogServiceMockSCDisplay.make( + displayID: displayID, + width: 2560, + height: 1440 + ) + let sut = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider(preflightResult: true, requestResult: true), + loadShareableDisplays: { + switch await gate.nextOutcome() { + case .success: + return [refreshedDisplay] + case .failure(let error): + throw error + } + }, + activeDisplayIDsProvider: { [displayID] }, + displayTopologySignatureProvider: { signatureBox.value } + ) + sut.store.hasScreenCapturePermission = true + sut.store.displays = [ + CatalogServiceMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + ] + sut.store.lastLoadedActiveDisplayTopologySignature = signatureBox.value + + signatureBox.value = [ + makeTestDisplayTopologySignatureEntry( + displayID: displayID, + pixelWidth: 2560, + pixelHeight: 1440 + ) + ] + + let refresh = Task { await sut.submitRefresh(intent: .topologyChanged) } + #expect(await waitForLoaderCall(gate, count: 1)) + await gate.release(call: 1) + + #expect(await refresh.value == .reloadedSnapshot) + #expect(sut.store.displays?.map(\.displayID) == [displayID]) + #expect(sut.store.lastLoadedActiveDisplayTopologySignature == signatureBox.value) + } + private func waitForLoaderCall( _ gate: SequencedCatalogServiceLoadGate, count: Int diff --git a/VoidDisplayTests/TestSupport/TestServiceMocks.swift b/VoidDisplayTests/TestSupport/TestServiceMocks.swift index cb46c99..d97cbaa 100644 --- a/VoidDisplayTests/TestSupport/TestServiceMocks.swift +++ b/VoidDisplayTests/TestSupport/TestServiceMocks.swift @@ -3,6 +3,32 @@ import Foundation import ScreenCaptureKit @testable import VoidDisplay +@MainActor +func makeTestDisplayTopologySignature( + _ displayIDs: [CGDirectDisplayID] +) -> ScreenCaptureDisplayTopologySignature { + displayIDs.map { ScreenCaptureDisplayTopologySignatureEntry(displayID: $0) } +} + +@MainActor +func makeTestDisplayTopologySignatureEntry( + displayID: CGDirectDisplayID, + isMain: Bool = false, + pixelWidth: Int = 0, + pixelHeight: Int = 0, + refreshRateMilliHertz: Int? = nil, + mirrorsDisplayID: CGDirectDisplayID? = nil +) -> ScreenCaptureDisplayTopologySignatureEntry { + .init( + displayID: displayID, + isMain: isMain, + pixelWidth: pixelWidth, + pixelHeight: pixelHeight, + refreshRateMilliHertz: refreshRateMilliHertz, + mirrorsDisplayID: mirrorsDisplayID + ) +} + @MainActor final class MockCaptureMonitoringService: CaptureMonitoringServiceProtocol { var currentSessions: [ScreenMonitoringSession] = [] From 2809a88cf929c4ad604b961fc289db61e234a753 Mon Sep 17 00:00:00 2001 From: Chen Date: Sat, 4 Apr 2026 01:28:15 +0800 Subject: [PATCH 31/34] =?UTF-8?q?fix(sharing):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=85=B1=E4=BA=AB=E7=8A=B6=E6=80=81=E4=B8=B2=E6=89=B0=E4=B8=8E?= =?UTF-8?q?=E6=8B=93=E6=89=91=E5=88=B7=E6=96=B0=E9=94=99=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为共享连接事件引入 sessionEpoch 并按连接隔离聚合状态 - 为目录刷新增加提交前拓扑签名校验与最多三次内部重试 - 补充共享重连与拓扑漂移回归测试并通过本地验证 --- .../Sharing/Services/SharingState.swift | 56 ++++--- .../Sharing/Web/WebRTCSessionHub.swift | 12 +- .../ScreenCaptureCatalogService.swift | 133 +++++++++++------ .../Services/SharingServiceTests.swift | 90 ++++++++++++ .../Sharing/Web/WebRTCSessionHubTests.swift | 43 +++++- .../ScreenCaptureCatalogServiceTests.swift | 139 ++++++++++++++++++ 6 files changed, 406 insertions(+), 67 deletions(-) diff --git a/VoidDisplay/Features/Sharing/Services/SharingState.swift b/VoidDisplay/Features/Sharing/Services/SharingState.swift index a1fb25f..40345de 100644 --- a/VoidDisplay/Features/Sharing/Services/SharingState.swift +++ b/VoidDisplay/Features/Sharing/Services/SharingState.swift @@ -17,6 +17,7 @@ enum SharingPeerPhase: String, Sendable, Equatable, Codable { struct SharingSessionEvent: Sendable, Equatable { let target: ShareTarget let clientID: String + let sessionEpoch: UInt64 let sequence: UInt64 let phase: SharingPeerPhase let source: SharingSessionEventSource @@ -30,9 +31,14 @@ struct SharingSessionEvent: Sendable, Equatable { sequence } + nonisolated var recordedSessionEpoch: UInt64 { + sessionEpoch + } + nonisolated init( target: ShareTarget, clientID: String, + sessionEpoch: UInt64 = 0, sequence: UInt64 = 0, phase: SharingPeerPhase, source: SharingSessionEventSource, @@ -40,6 +46,7 @@ struct SharingSessionEvent: Sendable, Equatable { ) { self.target = target self.clientID = clientID + self.sessionEpoch = sessionEpoch self.sequence = sequence self.phase = phase self.source = source @@ -120,10 +127,15 @@ final class SharingStateAggregator { typealias Observer = @MainActor @Sendable (SharingStateSnapshot) -> Void nonisolated static let closedClientTombstoneLimit = 512 - private var clientStatesByID: [String: SharingClientState] = [:] - private var lastAcceptedSequenceByClientID: [String: UInt64] = [:] - private var closedClientTombstoneSequenceByClientID: [String: UInt64] = [:] - private var closedClientTombstoneOrder: [(clientID: String, sequence: UInt64)] = [] + private struct ConnectionKey: Hashable { + let clientID: String + let sessionEpoch: UInt64 + } + + private var clientStatesByConnectionKey: [ConnectionKey: SharingClientState] = [:] + private var lastAcceptedSequenceByConnectionKey: [ConnectionKey: UInt64] = [:] + private var closedClientTombstoneSequenceByConnectionKey: [ConnectionKey: UInt64] = [:] + private var closedClientTombstoneOrder: [(key: ConnectionKey, sequence: UInt64)] = [] private var observers: [UUID: Observer] = [:] private var snapshot = SharingStateSnapshot.empty @@ -132,26 +144,26 @@ final class SharingStateAggregator { } var closedClientTombstoneCountForTesting: Int { - closedClientTombstoneSequenceByClientID.count + closedClientTombstoneSequenceByConnectionKey.count } func record(_ event: SharingSessionEvent) { - if let lastAcceptedSequence = lastAcceptedSequenceByClientID[event.clientID], + let key = ConnectionKey(clientID: event.clientID, sessionEpoch: event.sessionEpoch) + if let lastAcceptedSequence = lastAcceptedSequenceByConnectionKey[key], event.sequence <= lastAcceptedSequence { return } - if let closedClientSequence = closedClientTombstoneSequenceByClientID[event.clientID] { - guard event.sequence > closedClientSequence else { return } - closedClientTombstoneSequenceByClientID.removeValue(forKey: event.clientID) + if closedClientTombstoneSequenceByConnectionKey[key] != nil { + return } if event.phase == .closed { - clientStatesByID.removeValue(forKey: event.clientID) - lastAcceptedSequenceByClientID.removeValue(forKey: event.clientID) - closedClientTombstoneSequenceByClientID[event.clientID] = event.sequence - closedClientTombstoneOrder.append((event.clientID, event.sequence)) + clientStatesByConnectionKey.removeValue(forKey: key) + lastAcceptedSequenceByConnectionKey.removeValue(forKey: key) + closedClientTombstoneSequenceByConnectionKey[key] = event.sequence + closedClientTombstoneOrder.append((key, event.sequence)) pruneClosedClientTombstonesIfNeeded() } else { - lastAcceptedSequenceByClientID[event.clientID] = event.sequence + lastAcceptedSequenceByConnectionKey[key] = event.sequence let nextState = SharingClientState( target: event.target, clientID: event.clientID, @@ -159,15 +171,15 @@ final class SharingStateAggregator { source: event.source, lastUpdatedAt: event.timestamp ) - clientStatesByID[event.clientID] = nextState + clientStatesByConnectionKey[key] = nextState } rebuildSnapshot(lastUpdatedAt: event.timestamp) } func reset() { - clientStatesByID.removeAll() - lastAcceptedSequenceByClientID.removeAll() - closedClientTombstoneSequenceByClientID.removeAll() + clientStatesByConnectionKey.removeAll() + lastAcceptedSequenceByConnectionKey.removeAll() + closedClientTombstoneSequenceByConnectionKey.removeAll() closedClientTombstoneOrder.removeAll() rebuildSnapshot(lastUpdatedAt: nil) } @@ -186,7 +198,7 @@ final class SharingStateAggregator { var signalingConnectionsByTarget: [ShareTarget: Int] = [:] var streamingPeersByTarget: [ShareTarget: Int] = [:] - for clientState in clientStatesByID.values { + for clientState in clientStatesByConnectionKey.values { clientsByTarget[clientState.target, default: [:]][clientState.clientID] = clientState if clientState.hasActiveSignalingConnection { signalingConnectionsByTarget[clientState.target, default: 0] += 1 @@ -213,11 +225,11 @@ final class SharingStateAggregator { } private func pruneClosedClientTombstonesIfNeeded() { - while closedClientTombstoneSequenceByClientID.count > Self.closedClientTombstoneLimit, + while closedClientTombstoneSequenceByConnectionKey.count > Self.closedClientTombstoneLimit, let oldest = closedClientTombstoneOrder.first { closedClientTombstoneOrder.removeFirst() - guard closedClientTombstoneSequenceByClientID[oldest.clientID] == oldest.sequence else { continue } - closedClientTombstoneSequenceByClientID.removeValue(forKey: oldest.clientID) + guard closedClientTombstoneSequenceByConnectionKey[oldest.key] == oldest.sequence else { continue } + closedClientTombstoneSequenceByConnectionKey.removeValue(forKey: oldest.key) } } } diff --git a/VoidDisplay/Features/Sharing/Web/WebRTCSessionHub.swift b/VoidDisplay/Features/Sharing/Web/WebRTCSessionHub.swift index d1ab943..5ea21b2 100644 --- a/VoidDisplay/Features/Sharing/Web/WebRTCSessionHub.swift +++ b/VoidDisplay/Features/Sharing/Web/WebRTCSessionHub.swift @@ -112,6 +112,7 @@ final class WebRTCSessionHub: Sendable { private nonisolated struct ClientState { nonisolated(unsafe) let connection: any SignalSocketConnection let clientID: String + let sessionEpoch: UInt64 let target: ShareTarget let eventSink: SharingEventSink var nextEventSequence: UInt64 = 0 @@ -122,6 +123,7 @@ final class WebRTCSessionHub: Sendable { private nonisolated struct State: ~Copyable { var clients: [ObjectIdentifier: ClientState] = [:] + var nextSessionEpoch: UInt64 = 0 var onDemandChanged: @Sendable (Bool) -> Void } @@ -184,9 +186,11 @@ final class WebRTCSessionHub: Sendable { state -> (AddClientResult, String?, Bool, @Sendable (Bool) -> Void) in let wasEmpty = state.clients.isEmpty let clientID = makeClientID() + state.nextSessionEpoch &+= 1 state.clients[key] = ClientState( connection: connection, clientID: clientID, + sessionEpoch: state.nextSessionEpoch, target: target, eventSink: eventSink ) @@ -201,10 +205,12 @@ final class WebRTCSessionHub: Sendable { callback(true) } + let sessionEpoch = state.withLock { $0.clients[key]?.sessionEpoch } ?? 0 emitEvent( SharingSessionEvent( target: target, clientID: clientID, + sessionEpoch: sessionEpoch, sequence: nextEventSequence(for: key), phase: .signalingConnected, source: .webSocket @@ -542,6 +548,7 @@ final class WebRTCSessionHub: Sendable { SharingSessionEvent( target: removed.target, clientID: removed.clientID, + sessionEpoch: removed.sessionEpoch, sequence: removed.nextEventSequence + 1, phase: .closed, source: .webSocket @@ -570,18 +577,19 @@ final class WebRTCSessionHub: Sendable { for key: ObjectIdentifier ) { let payload = state.withLock { - state -> (target: ShareTarget, clientID: String, sequence: UInt64, sink: SharingEventSink)? in + state -> (target: ShareTarget, clientID: String, sessionEpoch: UInt64, sequence: UInt64, sink: SharingEventSink)? in guard var client = state.clients[key] else { return nil } client.nextEventSequence += 1 let sequence = client.nextEventSequence state.clients[key] = client - return (client.target, client.clientID, sequence, client.eventSink) + return (client.target, client.clientID, client.sessionEpoch, sequence, client.eventSink) } guard let payload else { return } payload.sink( SharingSessionEvent( target: payload.target, clientID: payload.clientID, + sessionEpoch: payload.sessionEpoch, sequence: payload.sequence, phase: phase, source: source diff --git a/VoidDisplay/Shared/ScreenCapture/ScreenCaptureCatalogService.swift b/VoidDisplay/Shared/ScreenCapture/ScreenCaptureCatalogService.swift index 5b89e0d..309238a 100644 --- a/VoidDisplay/Shared/ScreenCapture/ScreenCaptureCatalogService.swift +++ b/VoidDisplay/Shared/ScreenCapture/ScreenCaptureCatalogService.swift @@ -99,6 +99,11 @@ final class ScreenCaptureCatalogStore { @MainActor final class ScreenCaptureCatalogService { + private enum CommitResolution { + case completed(ScreenCaptureCatalogRefreshResult) + case retry + } + struct RefreshOwner: Hashable, Sendable { fileprivate let id = UUID() } @@ -141,6 +146,8 @@ final class ScreenCaptureCatalogService { let store: ScreenCaptureCatalogStore + nonisolated private static let maxCommitSignatureRetryCount = 3 + private let dependencies: Dependencies private let coordinator: CatalogRefreshCoordinator @@ -248,41 +255,66 @@ final class ScreenCaptureCatalogService { intent: ScreenCaptureCatalogRefreshIntent, owner: RefreshOwner? = nil ) async -> ScreenCaptureCatalogRefreshResult { - let permissionGranted = refreshPermission() - let currentSignature = currentActiveDisplayTopologySignature() - let request = CatalogRefreshCoordinator.Request( - intent: intent, - permissionGranted: permissionGranted, - currentTopologySignature: currentSignature, - cachedTopologySignature: store.lastLoadedActiveDisplayTopologySignature, - hasCachedDisplays: store.displays != nil, - ownerID: owner?.id - ) + var staleCommitRetryCount = 0 + var clearedSnapshotForCurrentRequest = false + + while true { + let permissionGranted = refreshPermission() + let requestSignature = currentActiveDisplayTopologySignature() + let request = CatalogRefreshCoordinator.Request( + intent: intent, + permissionGranted: permissionGranted, + currentTopologySignature: requestSignature, + cachedTopologySignature: store.lastLoadedActiveDisplayTopologySignature, + hasCachedDisplays: store.displays != nil, + ownerID: owner?.id + ) - switch await coordinator.prepare(request: request) { - case .reusedSnapshot: - store.lastRefreshResult = .reusedSnapshot - return .reusedSnapshot - case .clearedSnapshot: - applyClearedSnapshot(signature: currentSignature) - return .clearedSnapshot - case .failed: - store.lastRefreshResult = .failed - return .failed - case .awaitInFlight(let loadID): - store.isLoadingDisplays = true - let execution = await coordinator.executeLoad(loadID: loadID) - return commit(execution: execution, signature: currentSignature) - case .execute(let loadID, let clearsSnapshotFirst): - store.isLoadingDisplays = true - store.loadErrorMessage = nil - store.lastLoadError = nil - if clearsSnapshotFirst { - store.displays = nil - } + switch await coordinator.prepare(request: request) { + case .reusedSnapshot: + store.lastRefreshResult = .reusedSnapshot + return .reusedSnapshot + case .clearedSnapshot: + applyClearedSnapshot(signature: requestSignature) + return .clearedSnapshot + case .failed: + store.isLoadingDisplays = false + store.lastRefreshResult = .failed + return .failed + case .awaitInFlight(let loadID): + store.isLoadingDisplays = true + let execution = await coordinator.executeLoad(loadID: loadID) + switch resolveCommit( + execution: execution, + requestSignature: requestSignature, + staleCommitRetryCount: &staleCommitRetryCount + ) { + case .completed(let result): + return result + case .retry: + continue + } + case .execute(let loadID, let clearsSnapshotFirst): + store.isLoadingDisplays = true + store.loadErrorMessage = nil + store.lastLoadError = nil + if clearsSnapshotFirst, !clearedSnapshotForCurrentRequest { + store.displays = nil + clearedSnapshotForCurrentRequest = true + } - let execution = await coordinator.executeLoad(loadID: loadID) - return commit(execution: execution, signature: currentSignature) + let execution = await coordinator.executeLoad(loadID: loadID) + switch resolveCommit( + execution: execution, + requestSignature: requestSignature, + staleCommitRetryCount: &staleCommitRetryCount + ) { + case .completed(let result): + return result + case .retry: + continue + } + } } } @@ -297,21 +329,38 @@ final class ScreenCaptureCatalogService { store.lastRefreshResult = .clearedSnapshot } - private func commit( + private func resolveCommit( execution: CatalogRefreshCoordinator.ExecutionResult, - signature: ScreenCaptureDisplayTopologySignature - ) -> ScreenCaptureCatalogRefreshResult { + requestSignature: ScreenCaptureDisplayTopologySignature, + staleCommitRetryCount: inout Int + ) -> CommitResolution { switch execution { case .reloadedSnapshot(let displays): + let commitSignature = currentActiveDisplayTopologySignature() + guard commitSignature == requestSignature else { + dependencies.logger.warning( + "Discarding stale display snapshot before commit because topology changed during refresh." + ) + guard staleCommitRetryCount < Self.maxCommitSignatureRetryCount else { + dependencies.logger.error( + "Abandoning display refresh after repeated topology changes prevented a stable commit." + ) + store.isLoadingDisplays = false + store.lastRefreshResult = .failed + return .completed(.failed) + } + staleCommitRetryCount += 1 + return .retry + } store.displays = displays.map(\.value) store.hasScreenCapturePermission = true store.lastPreflightPermission = true - store.lastLoadedActiveDisplayTopologySignature = signature + store.lastLoadedActiveDisplayTopologySignature = commitSignature store.isLoadingDisplays = false store.loadErrorMessage = nil store.lastLoadError = nil store.lastRefreshResult = .reloadedSnapshot - return .reloadedSnapshot + return .completed(.reloadedSnapshot) case .failed(let error, let shouldClearDisplays): let nsError = error as NSError AppErrorMapper.logFailure( @@ -332,12 +381,12 @@ final class ScreenCaptureCatalogService { store.displays = nil } store.lastRefreshResult = .failed - return .failed + return .completed(.failed) case .clearedSnapshot: - applyClearedSnapshot(signature: signature) - return .clearedSnapshot + applyClearedSnapshot(signature: requestSignature) + return .completed(.clearedSnapshot) case .failedSuperseded: - return .failed + return .completed(.failed) } } } diff --git a/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift b/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift index 87d5e1d..08eedd5 100644 --- a/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift +++ b/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift @@ -357,6 +357,96 @@ struct SharingServiceTests { #expect(snapshot.lastUpdatedAt != nil) } + @MainActor @Test func closedClientReconnectWithSameIDStartsFreshSession() { + let aggregator = SharingStateAggregator() + let target = ShareTarget.id(7) + aggregator.record( + SharingSessionEvent( + target: target, + clientID: "client-1", + sessionEpoch: 1, + sequence: 1, + phase: .peerConnected, + source: .peerConnection + ) + ) + aggregator.record( + SharingSessionEvent( + target: target, + clientID: "client-1", + sessionEpoch: 1, + sequence: 2, + phase: .closed, + source: .webSocket + ) + ) + aggregator.record( + SharingSessionEvent( + target: target, + clientID: "client-1", + sessionEpoch: 2, + sequence: 1, + phase: .signalingConnected, + source: .webSocket + ) + ) + + let snapshot = aggregator.currentSnapshot + #expect(snapshot.signalingConnections == 1) + #expect(snapshot.streamingPeers == 0) + #expect(snapshot.clientsByTarget[target]?["client-1"]?.phase == .signalingConnected) + } + + @MainActor @Test func lateEventFromClosedSessionDoesNotOverrideReconnectedClient() { + let aggregator = SharingStateAggregator() + let target = ShareTarget.id(7) + aggregator.record( + SharingSessionEvent( + target: target, + clientID: "client-1", + sessionEpoch: 1, + sequence: 1, + phase: .peerConnected, + source: .peerConnection + ) + ) + aggregator.record( + SharingSessionEvent( + target: target, + clientID: "client-1", + sessionEpoch: 1, + sequence: 2, + phase: .closed, + source: .webSocket + ) + ) + aggregator.record( + SharingSessionEvent( + target: target, + clientID: "client-1", + sessionEpoch: 2, + sequence: 1, + phase: .peerConnected, + source: .peerConnection + ) + ) + aggregator.record( + SharingSessionEvent( + target: target, + clientID: "client-1", + sessionEpoch: 1, + sequence: 3, + phase: .peerDisconnected, + source: .peerConnection + ) + ) + + let snapshot = aggregator.currentSnapshot + #expect(snapshot.signalingConnections == 1) + #expect(snapshot.streamingPeers == 1) + #expect(snapshot.clientsByTarget[target]?["client-1"]?.phase == .peerConnected) + } + @MainActor @Test func alreadyRunningStartPreservesCurrentSharingSnapshot() async { let requestedPort = TestPortAllocator.randomUnprivilegedPort() let mock = MockWebServiceController() diff --git a/VoidDisplayTests/Features/Sharing/Web/WebRTCSessionHubTests.swift b/VoidDisplayTests/Features/Sharing/Web/WebRTCSessionHubTests.swift index 84da5e3..a8bd3bd 100644 --- a/VoidDisplayTests/Features/Sharing/Web/WebRTCSessionHubTests.swift +++ b/VoidDisplayTests/Features/Sharing/Web/WebRTCSessionHubTests.swift @@ -94,6 +94,10 @@ private final class SharingEventRecorder: @unchecked Sendable { func currentSequences() -> [UInt64] { events.withLock { $0.map(\.recordedSequence) } } + + func currentSessionEpochs() -> [UInt64] { + events.withLock { $0.map(\.recordedSessionEpoch) } + } } private final class PeerCallbacksBox: @unchecked Sendable { @@ -373,7 +377,7 @@ struct WebRTCSessionHubTests { #expect(hub.activeClientCount == 0) } - @MainActor @Test func lifecycleEventsReflectOfferConnectAndClose() async { + @MainActor @Test func lifecycleEventsReflectOfferConnectAndClose() async throws { let eventRecorder = SharingEventRecorder() let callbacksBox = PeerCallbacksBox() let hub = WebRTCSessionHub(peerFactory: { callbacks in @@ -407,6 +411,43 @@ struct WebRTCSessionHubTests { ]) let sequences = eventRecorder.currentSequences() #expect(sequences == [1, 2, 3, 4, 5]) + let sessionEpochs = eventRecorder.currentSessionEpochs() + let firstEpoch = try #require(sessionEpochs.first) + #expect(sessionEpochs.allSatisfy { $0 == firstEpoch }) + } + + @MainActor @Test func reusedClientIDGetsNewSessionEpoch() { + let eventRecorder = SharingEventRecorder() + let hub = WebRTCSessionHub() + let firstClient = MockSignalSocketConnection() + let secondClient = MockSignalSocketConnection() + + let firstResult = hub.addClient( + firstClient, + target: .main, + makeClientID: { "client-1" }, + eventSink: { event in + eventRecorder.record(event) + } + ) + #expect(isAccepted(firstResult)) + hub.removeClient(firstClient) + + let secondResult = hub.addClient( + secondClient, + target: .main, + makeClientID: { "client-1" }, + eventSink: { event in + eventRecorder.record(event) + } + ) + #expect(isAccepted(secondResult)) + + let events = eventRecorder.currentEvents() + #expect(events.count == 3) + #expect(events[0].recordedSessionEpoch == events[1].recordedSessionEpoch) + #expect(events[2].recordedSessionEpoch > events[1].recordedSessionEpoch) + #expect(events[2].recordedSequence == 1) } #endif } diff --git a/VoidDisplayTests/Shared/ScreenCaptureCatalogServiceTests.swift b/VoidDisplayTests/Shared/ScreenCaptureCatalogServiceTests.swift index 628f26a..de4e6a6 100644 --- a/VoidDisplayTests/Shared/ScreenCaptureCatalogServiceTests.swift +++ b/VoidDisplayTests/Shared/ScreenCaptureCatalogServiceTests.swift @@ -391,6 +391,145 @@ struct ScreenCaptureCatalogServiceTests { #expect(sut.store.lastLoadedActiveDisplayTopologySignature == signatureBox.value) } + @Test func topologyMismatchBeforeCommitRetriesAndCommitsLatestSnapshot() async { + let gate = SequencedCatalogServiceLoadGate(scriptedOutcomes: [.success, .success]) + let initialSignature = makeTestDisplayTopologySignature([1001]) + let retriedSignature = makeTestDisplayTopologySignature([1002]) + let signatureBox = CatalogServiceSignatureBox(initialSignature) + let firstDisplay = CatalogServiceMockSCDisplay.make(displayID: 1001, width: 1280, height: 720) + let retriedDisplay = CatalogServiceMockSCDisplay.make(displayID: 1002, width: 1920, height: 1080) + var loadCallIndex = 0 + let sut = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider(preflightResult: true, requestResult: true), + loadShareableDisplays: { + loadCallIndex += 1 + switch await gate.nextOutcome() { + case .success: + return loadCallIndex == 1 ? [firstDisplay] : [retriedDisplay] + case .failure(let error): + throw error + } + }, + activeDisplayIDsProvider: { [1002] }, + displayTopologySignatureProvider: { signatureBox.value } + ) + sut.store.hasScreenCapturePermission = true + + let refresh = Task { await sut.submitRefresh(intent: .topologyChanged) } + #expect(await waitForLoaderCall(gate, count: 1)) + + signatureBox.value = retriedSignature + await gate.release(call: 1) + + #expect(await waitForLoaderCall(gate, count: 2)) + await gate.release(call: 2) + + #expect(await refresh.value == .reloadedSnapshot) + #expect(await gate.currentCallCount() == 2) + #expect(sut.store.displays?.map(\.displayID) == [1002]) + #expect(sut.store.lastLoadedActiveDisplayTopologySignature == retriedSignature) + } + + @Test func topologySignatureJitterRetriesUntilCommitMatchesLatestSample() async { + let gate = SequencedCatalogServiceLoadGate(scriptedOutcomes: [.success, .success, .success]) + let signatureA = makeTestDisplayTopologySignature([1101]) + let signatureB = makeTestDisplayTopologySignature([1102]) + let finalSignature = makeTestDisplayTopologySignature([1103]) + let signatureBox = CatalogServiceSignatureBox(signatureA) + let displays = [ + CatalogServiceMockSCDisplay.make(displayID: 1101, width: 1280, height: 720), + CatalogServiceMockSCDisplay.make(displayID: 1102, width: 1920, height: 1080), + CatalogServiceMockSCDisplay.make(displayID: 1103, width: 2560, height: 1440) + ] + var loadCallIndex = 0 + let sut = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider(preflightResult: true, requestResult: true), + loadShareableDisplays: { + loadCallIndex += 1 + switch await gate.nextOutcome() { + case .success: + return [displays[loadCallIndex - 1]] + case .failure(let error): + throw error + } + }, + activeDisplayIDsProvider: { [1103] }, + displayTopologySignatureProvider: { signatureBox.value } + ) + sut.store.hasScreenCapturePermission = true + + let refresh = Task { await sut.submitRefresh(intent: .topologyChanged) } + #expect(await waitForLoaderCall(gate, count: 1)) + + signatureBox.value = signatureB + await gate.release(call: 1) + + #expect(await waitForLoaderCall(gate, count: 2)) + signatureBox.value = finalSignature + await gate.release(call: 2) + + #expect(await waitForLoaderCall(gate, count: 3)) + await gate.release(call: 3) + + #expect(await refresh.value == .reloadedSnapshot) + #expect(await gate.currentCallCount() == 3) + #expect(sut.store.displays?.map(\.displayID) == [1103]) + #expect(sut.store.lastLoadedActiveDisplayTopologySignature == finalSignature) + } + + @Test func repeatedTopologyMismatchFailsWithoutOverwritingStableSnapshot() async { + let gate = SequencedCatalogServiceLoadGate(scriptedOutcomes: [.success, .success, .success, .success]) + let stableSignature = makeTestDisplayTopologySignature([1200]) + let signatureBox = CatalogServiceSignatureBox(makeTestDisplayTopologySignature([1201])) + let stableDisplay = CatalogServiceMockSCDisplay.make(displayID: 1200, width: 1440, height: 900) + var loadCallIndex = 0 + let retrySignatures = [ + makeTestDisplayTopologySignature([1202]), + makeTestDisplayTopologySignature([1203]), + makeTestDisplayTopologySignature([1204]), + makeTestDisplayTopologySignature([1205]) + ] + let refreshedDisplays = [ + CatalogServiceMockSCDisplay.make(displayID: 1201, width: 1280, height: 720), + CatalogServiceMockSCDisplay.make(displayID: 1202, width: 1600, height: 900), + CatalogServiceMockSCDisplay.make(displayID: 1203, width: 1920, height: 1080), + CatalogServiceMockSCDisplay.make(displayID: 1204, width: 2560, height: 1440) + ] + let sut = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider(preflightResult: true, requestResult: true), + loadShareableDisplays: { + loadCallIndex += 1 + switch await gate.nextOutcome() { + case .success: + return [refreshedDisplays[loadCallIndex - 1]] + case .failure(let error): + throw error + } + }, + activeDisplayIDsProvider: { [1205] }, + displayTopologySignatureProvider: { signatureBox.value } + ) + sut.store.hasScreenCapturePermission = true + sut.store.displays = [stableDisplay] + sut.store.lastLoadedActiveDisplayTopologySignature = stableSignature + sut.store.lastRefreshResult = .reusedSnapshot + + let refresh = Task { await sut.submitRefresh(intent: .topologyChanged) } + + for (index, retrySignature) in retrySignatures.enumerated() { + #expect(await waitForLoaderCall(gate, count: index + 1)) + signatureBox.value = retrySignature + await gate.release(call: index + 1) + } + + #expect(await refresh.value == .failed) + #expect(await gate.currentCallCount() == 4) + #expect(sut.store.displays?.map(\.displayID) == [1200]) + #expect(sut.store.lastLoadedActiveDisplayTopologySignature == stableSignature) + #expect(sut.store.lastRefreshResult == .failed) + #expect(sut.store.lastLoadError == nil) + } + private func waitForLoaderCall( _ gate: SequencedCatalogServiceLoadGate, count: Int From c549eebca7dc6ba932a0ab8d038fd14176abd644 Mon Sep 17 00:00:00 2001 From: Chen Date: Sat, 4 Apr 2026 13:57:18 +0800 Subject: [PATCH 32/34] =?UTF-8?q?refactor(capture-sharing):=20=E6=94=B6?= =?UTF-8?q?=E6=95=9B=E5=B1=8F=E5=B9=95=E7=9B=AE=E5=BD=95=E4=B8=8E=E9=87=87?= =?UTF-8?q?=E9=9B=86=E7=BC=96=E6=8E=92=E5=A4=8D=E6=9D=82=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ScreenCatalogOrchestrator 统一目录刷新、权限与拓扑收敛 - 收敛 display capture demand、session driver 与 controller 起停状态 - 拆分预览窗口渲染链路并补齐相关测试覆盖 --- VoidDisplay/App/CaptureController.swift | 64 +- .../DisplayStartTracker.swift | 42 + .../SnapshotMutationRunner.swift | 25 + .../DisplayTopologyChangeCoordinator.swift | 81 -- VoidDisplay/App/HomeView.swift | 12 +- .../App/ScreenCatalogOrchestrator.swift | 221 +++++ VoidDisplay/App/SharingController.swift | 80 +- VoidDisplay/App/VoidDisplayApp.swift | 14 +- .../Services/DisplayCaptureDemandDriver.swift | 218 +++++ .../Services/DisplayCaptureRegistry.swift | 724 +++++++++++----- .../Services/DisplayCaptureSession.swift | 303 ++----- .../Services/DisplayCaptureTypes.swift | 139 ++- .../ViewModels/CaptureChooseViewModel.swift | 66 +- .../Capture/Views/CaptureChoose.swift | 27 +- .../Capture/Views/CaptureDisplayView.swift | 334 -------- .../CapturePreviewWindowCoordinator.swift | 127 +++ .../Views/ZeroCopyPreviewRenderer.swift | 199 +++++ .../Sharing/ViewModels/ShareViewModel.swift | 134 +-- .../Features/Sharing/Views/ShareView.swift | 38 +- .../CapturePreviewDiagnosticsSession.swift | 8 +- .../App/CaptureControllerTests.swift | 30 +- .../App/CaptureSharingIsolationTests.swift | 8 +- .../App/DisplayStartTrackerTests.swift | 65 ++ ...isplayTopologyChangeCoordinatorTests.swift | 358 -------- ...ptureCatalogTopologyIntegrationTests.swift | 359 -------- .../App/ScreenCatalogOrchestratorTests.swift | 415 +++++++++ .../App/SharingControllerTests.swift | 12 +- ...ptureMonitoringLifecycleServiceTests.swift | 18 +- .../CaptureMonitoringServiceTests.swift | 8 +- .../CaptureMonitoringSessionStoreTests.swift | 8 +- .../DisplayCaptureDemandDriverTests.swift | 344 ++++++++ ...splayCaptureProfileStateMachineTests.swift | 122 ++- .../DisplayCaptureRegistryTests.swift | 447 ++++++++-- .../DisplayPreviewSubscriptionTests.swift | 8 +- .../CaptureChooseViewModelTests.swift | 499 +---------- .../SharingEndToEndIntegrationTests.swift | 8 +- .../DisplaySharingCoordinatorTests.swift | 37 +- .../Services/SharingServiceTests.swift | 10 +- .../ViewModels/ShareViewModelTests.swift | 807 +++--------------- .../Views/ShareViewBehaviorTests.swift | 166 +--- .../TestSupport/MockSCDisplay.swift | 25 + 41 files changed, 3057 insertions(+), 3553 deletions(-) create mode 100644 VoidDisplay/App/ControllerSupport/DisplayStartTracker.swift create mode 100644 VoidDisplay/App/ControllerSupport/SnapshotMutationRunner.swift delete mode 100644 VoidDisplay/App/DisplayTopologyChangeCoordinator.swift create mode 100644 VoidDisplay/App/ScreenCatalogOrchestrator.swift create mode 100644 VoidDisplay/Features/Capture/Services/DisplayCaptureDemandDriver.swift create mode 100644 VoidDisplay/Features/Capture/Views/CapturePreviewWindowCoordinator.swift create mode 100644 VoidDisplay/Features/Capture/Views/ZeroCopyPreviewRenderer.swift create mode 100644 VoidDisplayTests/App/DisplayStartTrackerTests.swift delete mode 100644 VoidDisplayTests/App/DisplayTopologyChangeCoordinatorTests.swift delete mode 100644 VoidDisplayTests/App/ScreenCaptureCatalogTopologyIntegrationTests.swift create mode 100644 VoidDisplayTests/App/ScreenCatalogOrchestratorTests.swift create mode 100644 VoidDisplayTests/Features/Capture/Services/DisplayCaptureDemandDriverTests.swift create mode 100644 VoidDisplayTests/TestSupport/MockSCDisplay.swift diff --git a/VoidDisplay/App/CaptureController.swift b/VoidDisplay/App/CaptureController.swift index d442c7f..b8b4784 100644 --- a/VoidDisplay/App/CaptureController.swift +++ b/VoidDisplay/App/CaptureController.swift @@ -12,12 +12,15 @@ import Observation @Observable final class CaptureController { var screenCaptureSessions: [ScreenMonitoringSession] = [] - var startingDisplayIDs: Set = [] + private(set) var startingDisplayIDs: Set = [] @ObservationIgnored let catalogService: ScreenCaptureCatalogService @ObservationIgnored private let captureMonitoringService: any CaptureMonitoringServiceProtocol @ObservationIgnored private let captureMonitoringLifecycleService: any CaptureMonitoringLifecycleServiceProtocol - @ObservationIgnored private var observedStartTokensByDisplayID: [CGDirectDisplayID: Set] = [:] + @ObservationIgnored private let startTracker = DisplayStartTracker() + @ObservationIgnored private lazy var mutationRunner = SnapshotMutationRunner { [weak self] in + self?.syncCaptureMonitoringState() + } init( captureMonitoringService: any CaptureMonitoringServiceProtocol, @@ -40,7 +43,7 @@ final class CaptureController { } func isStarting(displayID: CGDirectDisplayID) -> Bool { - startingDisplayIDs.contains(displayID) + startTracker.contains(displayID: displayID) } func startMonitoring( @@ -48,9 +51,10 @@ final class CaptureController { metadata: CaptureMonitoringDisplayMetadata ) async throws -> DisplayStartOutcome { let displayID = display.displayID - let startToken = beginObservedStart(displayID: displayID) + let startToken = startTracker.begin(displayID: displayID) + syncCaptureMonitoringState() defer { - endObservedStart(displayID: displayID, token: startToken) + startTracker.end(displayID: displayID, token: startToken) syncCaptureMonitoringState() } @@ -61,13 +65,13 @@ final class CaptureController { } func activateMonitoringSession(id: UUID) { - mutateAndSync { + mutationRunner.run { captureMonitoringLifecycleService.activateMonitoringSession(id: id) } } func attachPreviewSink(_ sink: any DisplayPreviewSink, to id: UUID) { - mutateAndSync { + mutationRunner.run { captureMonitoringLifecycleService.attachPreviewSink(sink, to: id) } } @@ -76,7 +80,7 @@ final class CaptureController { id: UUID, capturesCursor: Bool ) async throws { - try await mutateAndSyncAsync { + try await mutationRunner.run { try await captureMonitoringLifecycleService.setMonitoringSessionCapturesCursor( id: id, capturesCursor: capturesCursor @@ -85,14 +89,14 @@ final class CaptureController { } func closeMonitoringSession(id: UUID) { - mutateAndSync { + mutationRunner.run { captureMonitoringLifecycleService.closeMonitoringSession(id: id) } } func removeMonitoringSessions(displayID: CGDirectDisplayID) { - clearObservedStarts(displayID: displayID) - mutateAndSync { + startTracker.clear(displayID: displayID) + mutationRunner.run { captureMonitoringLifecycleService.removeMonitoringSessions(displayID: displayID) } } @@ -109,40 +113,16 @@ final class CaptureController { private func syncCaptureMonitoringState() { screenCaptureSessions = captureMonitoringService.currentSessions + startingDisplayIDs = startTracker.activeDisplayIDs } - private func beginObservedStart(displayID: CGDirectDisplayID) -> UUID { - let token = UUID() - var tokens = observedStartTokensByDisplayID[displayID] ?? [] - tokens.insert(token) - observedStartTokensByDisplayID[displayID] = tokens - startingDisplayIDs.insert(displayID) - return token - } - - private func endObservedStart(displayID: CGDirectDisplayID, token: UUID) { - guard var tokens = observedStartTokensByDisplayID[displayID] else { return } - tokens.remove(token) - if tokens.isEmpty { - observedStartTokensByDisplayID.removeValue(forKey: displayID) - startingDisplayIDs.remove(displayID) - } else { - observedStartTokensByDisplayID[displayID] = tokens +#if DEBUG + func installStartingDisplayIDsForTesting(_ displayIDs: Set) { + startTracker.clearAll() + for displayID in displayIDs { + _ = startTracker.begin(displayID: displayID) } - } - - private func clearObservedStarts(displayID: CGDirectDisplayID) { - observedStartTokensByDisplayID.removeValue(forKey: displayID) - startingDisplayIDs.remove(displayID) - } - - private func mutateAndSync(_ mutation: () -> Void) { - mutation() syncCaptureMonitoringState() } - - private func mutateAndSyncAsync(_ mutation: () async throws -> T) async rethrows -> T { - defer { syncCaptureMonitoringState() } - return try await mutation() - } +#endif } diff --git a/VoidDisplay/App/ControllerSupport/DisplayStartTracker.swift b/VoidDisplay/App/ControllerSupport/DisplayStartTracker.swift new file mode 100644 index 0000000..88bc39c --- /dev/null +++ b/VoidDisplay/App/ControllerSupport/DisplayStartTracker.swift @@ -0,0 +1,42 @@ +import CoreGraphics +import Foundation + +@MainActor +final class DisplayStartTracker { + private var tokensByDisplayID: [CGDirectDisplayID: Set] = [:] + + var activeDisplayIDs: Set { + Set(tokensByDisplayID.keys) + } + + func contains(displayID: CGDirectDisplayID) -> Bool { + tokensByDisplayID[displayID]?.isEmpty == false + } + + @discardableResult + func begin(displayID: CGDirectDisplayID) -> UUID { + let token = UUID() + var tokens = tokensByDisplayID[displayID] ?? [] + tokens.insert(token) + tokensByDisplayID[displayID] = tokens + return token + } + + func end(displayID: CGDirectDisplayID, token: UUID) { + guard var tokens = tokensByDisplayID[displayID] else { return } + tokens.remove(token) + if tokens.isEmpty { + tokensByDisplayID.removeValue(forKey: displayID) + } else { + tokensByDisplayID[displayID] = tokens + } + } + + func clear(displayID: CGDirectDisplayID) { + tokensByDisplayID.removeValue(forKey: displayID) + } + + func clearAll() { + tokensByDisplayID.removeAll() + } +} diff --git a/VoidDisplay/App/ControllerSupport/SnapshotMutationRunner.swift b/VoidDisplay/App/ControllerSupport/SnapshotMutationRunner.swift new file mode 100644 index 0000000..da63b1c --- /dev/null +++ b/VoidDisplay/App/ControllerSupport/SnapshotMutationRunner.swift @@ -0,0 +1,25 @@ +import Foundation + +@MainActor +final class SnapshotMutationRunner { + private let sync: @MainActor () -> Void + + init(sync: @escaping @MainActor () -> Void) { + self.sync = sync + } + + func run(_ mutation: () -> Void) { + mutation() + sync() + } + + func run(_ mutation: () async -> T) async -> T { + defer { sync() } + return await mutation() + } + + func run(_ mutation: () async throws -> T) async rethrows -> T { + defer { sync() } + return try await mutation() + } +} diff --git a/VoidDisplay/App/DisplayTopologyChangeCoordinator.swift b/VoidDisplay/App/DisplayTopologyChangeCoordinator.swift deleted file mode 100644 index de9aa3e..0000000 --- a/VoidDisplay/App/DisplayTopologyChangeCoordinator.swift +++ /dev/null @@ -1,81 +0,0 @@ -import AppKit -import CoreGraphics -import Foundation -import Observation -import ScreenCaptureKit - -@MainActor -@Observable -final class DisplayTopologyChangeCoordinator { - enum Source: Sendable, Equatable { - case captureView - case sharingView - } - - private let capture: CaptureController - private let sharing: SharingController - private let virtualDisplay: VirtualDisplayController - private let catalogService: ScreenCaptureCatalogService - private let refreshOwner = ScreenCaptureCatalogService.RefreshOwner() - private var inFlightTask: Task? - private var hasPendingTopologyChange = false - - init( - capture: CaptureController, - sharing: SharingController, - virtualDisplay: VirtualDisplayController, - catalogService: ScreenCaptureCatalogService - ) { - self.capture = capture - self.sharing = sharing - self.virtualDisplay = virtualDisplay - self.catalogService = catalogService - } - - func handleTopologyChange(source: Source) { - _ = source - hasPendingTopologyChange = true - guard inFlightTask == nil else { return } - inFlightTask = Task { @MainActor [weak self] in - defer { self?.inFlightTask = nil } - await self?.drainTopologyRefreshQueue() - } - } - - private func drainTopologyRefreshQueue() async { - while hasPendingTopologyChange { - hasPendingTopologyChange = false - await runTopologyRefreshSequence() - } - } - - private func runTopologyRefreshSequence() async { - guard catalogService.refreshPermission() else { - await catalogService.clearSnapshotForDeniedPermission() - convergeToVisibleDisplays([]) - return - } - - let result = await catalogService.submitRefresh(intent: .topologyChanged, owner: refreshOwner) - guard result != .failed else { return } - - let visibleDisplays = catalogService - .visibleDisplays(from: catalogService.store.displays ?? []) - convergeToVisibleDisplays(visibleDisplays) - } - - private func convergeToVisibleDisplays(_ visibleDisplays: [SCDisplay]) { - sharing.registerShareableDisplays(visibleDisplays) { [weak virtualDisplay] displayID in - virtualDisplay?.virtualSerialForManagedDisplay(displayID) - } - - let visibleDisplayIDs = Set(visibleDisplays.map(\.displayID)) - for displayID in sharing.activeSharingDisplayIDs where !visibleDisplayIDs.contains(displayID) { - sharing.stopSharing(displayID: displayID) - } - let monitoredDisplayIDs = Set(capture.screenCaptureSessions.map(\.displayID)) - for displayID in monitoredDisplayIDs where !visibleDisplayIDs.contains(displayID) { - capture.removeMonitoringSessions(displayID: displayID) - } - } -} diff --git a/VoidDisplay/App/HomeView.swift b/VoidDisplay/App/HomeView.swift index 2cdbc2f..743e4ad 100644 --- a/VoidDisplay/App/HomeView.swift +++ b/VoidDisplay/App/HomeView.swift @@ -11,7 +11,7 @@ struct HomeView: View { @Environment(SharingController.self) private var sharing @Environment(VirtualDisplayController.self) private var virtualDisplay @Environment(\.openWindow) private var openWindow - private let topologyCoordinator: DisplayTopologyChangeCoordinator + private let screenCatalogOrchestrator: ScreenCatalogOrchestrator private enum SidebarItem: Hashable { case screen @@ -23,8 +23,8 @@ struct HomeView: View { @State private var selection: SidebarItem? = .screen @State private var hasAutoOpenedCapturePreview = false - init(topologyCoordinator: DisplayTopologyChangeCoordinator) { - self.topologyCoordinator = topologyCoordinator + init(screenCatalogOrchestrator: ScreenCatalogOrchestrator) { + self.screenCatalogOrchestrator = screenCatalogOrchestrator } var body: some View { @@ -74,7 +74,7 @@ struct HomeView: View { IsCapturing( capture: capture, virtualDisplay: virtualDisplay, - topologyCoordinator: topologyCoordinator + screenCatalogOrchestrator: screenCatalogOrchestrator ) .navigationTitle("Screen Monitoring") .accessibilityIdentifier("detail_monitor_screen") @@ -82,7 +82,7 @@ struct HomeView: View { ShareView( sharing: sharing, virtualDisplay: virtualDisplay, - topologyCoordinator: topologyCoordinator + screenCatalogOrchestrator: screenCatalogOrchestrator ) .navigationTitle("Screen Sharing") .accessibilityIdentifier("detail_screen_sharing") @@ -111,7 +111,7 @@ struct HomeView: View { #Preview { let env = AppBootstrap.makeEnvironment(preview: true, isRunningUnderXCTestOverride: false) - HomeView(topologyCoordinator: env.topology) + HomeView(screenCatalogOrchestrator: env.screenCatalog) .environment(env.capture) .environment(env.sharing) .environment(env.virtualDisplay) diff --git a/VoidDisplay/App/ScreenCatalogOrchestrator.swift b/VoidDisplay/App/ScreenCatalogOrchestrator.swift new file mode 100644 index 0000000..f83bcec --- /dev/null +++ b/VoidDisplay/App/ScreenCatalogOrchestrator.swift @@ -0,0 +1,221 @@ +import CoreGraphics +import Foundation +import ScreenCaptureKit + +@MainActor +enum ScreenCatalogSource: Sendable, Equatable { + case capturePage + case sharingPage +} + +@MainActor +final class ScreenCatalogOrchestrator { + private let catalogService: ScreenCaptureCatalogService + private let capture: CaptureController + private let sharing: SharingController + private let virtualDisplay: VirtualDisplayController + private let captureRefreshOwner = ScreenCaptureCatalogService.RefreshOwner() + private let sharingRefreshOwner = ScreenCaptureCatalogService.RefreshOwner() + + private var topologyRefreshTask: Task? + private var hasPendingTopologyChange = false + + init( + catalogService: ScreenCaptureCatalogService, + capture: CaptureController, + sharing: SharingController, + virtualDisplay: VirtualDisplayController + ) { + self.catalogService = catalogService + self.capture = capture + self.sharing = sharing + self.virtualDisplay = virtualDisplay + } + + func handleAppear(source: ScreenCatalogSource) async { + await refreshPermissionIfNeeded(source: source) + } + + func handleDisappear(source: ScreenCatalogSource) async { + await cancelRefresh(for: source) + } + + func requestPermission(source: ScreenCatalogSource) async { + await requestPermissionIfNeeded(source: source) + } + + func refreshPermission(source: ScreenCatalogSource) async { + await refreshPermissionIfNeeded(source: source) + } + + func forceRefresh(source: ScreenCatalogSource) async { + await forceRefreshIfNeeded(source: source) + } + + func handleTopologyChanged() async { + hasPendingTopologyChange = true + if let topologyRefreshTask { + await topologyRefreshTask.value + return + } + let topologyRefreshTask = Task { @MainActor in + defer { self.topologyRefreshTask = nil } + await self.drainTopologyRefreshQueue() + } + self.topologyRefreshTask = topologyRefreshTask + await topologyRefreshTask.value + } + + func handleSharingServiceStateChanged(isRunning: Bool) async { + if isRunning { + await refreshSharingCatalogForRunningService() + } else { + await catalogService.cancelRefresh(owner: sharingRefreshOwner) + } + } + + func openScreenCapturePrivacySettings(openURL: (URL) -> Void) { + catalogService.openScreenCapturePrivacySettings(openURL: openURL) + } + + private func requestPermissionIfNeeded(source: ScreenCatalogSource) async { + let granted = catalogService.requestPermission() + guard granted else { + let loadErrorMessage = source == .sharingPage + ? String(localized: "Failed to load displays. Check permission and try again.") + : nil + await clearSnapshotForDeniedPermission(loadErrorMessage: loadErrorMessage) + return + } + await refreshAfterPermissionGranted(source: source) + } + + private func refreshPermissionIfNeeded(source: ScreenCatalogSource) async { + let granted = catalogService.refreshPermission() + guard granted else { + await clearSnapshotForDeniedPermission() + return + } + await refreshAfterPermissionGranted(source: source) + } + + private func forceRefreshIfNeeded(source: ScreenCatalogSource) async { + let granted = catalogService.refreshPermission() + guard granted else { + await clearSnapshotForDeniedPermission() + return + } + + switch source { + case .capturePage: + await refreshAndConverge( + intent: .userForcedRefresh, + owner: captureRefreshOwner + ) + case .sharingPage: + guard sharing.isWebServiceRunning else { + await catalogService.cancelRefresh(owner: sharingRefreshOwner) + return + } + await refreshAndConverge( + intent: .userForcedRefresh, + owner: sharingRefreshOwner + ) + } + } + + private func refreshAfterPermissionGranted(source: ScreenCatalogSource) async { + switch source { + case .capturePage: + await refreshAndConverge( + intent: .permissionChanged, + owner: captureRefreshOwner + ) + case .sharingPage: + if sharing.isWebServiceRunning { + await refreshSharingCatalogForRunningService() + } else { + await catalogService.cancelRefresh(owner: sharingRefreshOwner) + } + } + } + + private func refreshSharingCatalogForRunningService() async { + guard catalogService.refreshPermission() else { + await clearSnapshotForDeniedPermission() + return + } + guard sharing.isWebServiceRunning else { + await catalogService.cancelRefresh(owner: sharingRefreshOwner) + return + } + await refreshAndConverge( + intent: .serviceBecameRunning, + owner: sharingRefreshOwner + ) + } + + private func cancelRefresh(for source: ScreenCatalogSource) async { + switch source { + case .capturePage: + await catalogService.cancelRefresh(owner: captureRefreshOwner) + case .sharingPage: + await catalogService.cancelRefresh(owner: sharingRefreshOwner) + } + } + + private func clearSnapshotForDeniedPermission(loadErrorMessage: String? = nil) async { + await catalogService.clearSnapshotForDeniedPermission(loadErrorMessage: loadErrorMessage) + convergeToVisibleDisplays([]) + } + + private func drainTopologyRefreshQueue() async { + while hasPendingTopologyChange { + hasPendingTopologyChange = false + await runTopologyRefreshSequence() + } + } + + private func runTopologyRefreshSequence() async { + guard catalogService.refreshPermission() else { + await clearSnapshotForDeniedPermission() + return + } + + let result = await catalogService.submitRefresh(intent: .topologyChanged) + guard result != .failed else { return } + convergeToVisibleDisplaysFromCurrentSnapshot() + } + + private func refreshAndConverge( + intent: ScreenCaptureCatalogRefreshIntent, + owner: ScreenCaptureCatalogService.RefreshOwner? = nil + ) async { + let result = await catalogService.submitRefresh(intent: intent, owner: owner) + guard result != .failed else { return } + convergeToVisibleDisplaysFromCurrentSnapshot() + } + + private func convergeToVisibleDisplaysFromCurrentSnapshot() { + let visibleDisplays = catalogService.visibleDisplays(from: catalogService.store.displays ?? []) + convergeToVisibleDisplays(visibleDisplays) + } + + private func convergeToVisibleDisplays(_ visibleDisplays: [SCDisplay]) { + if sharing.isWebServiceRunning { + sharing.registerShareableDisplays(visibleDisplays) { [weak virtualDisplay] displayID in + virtualDisplay?.virtualSerialForManagedDisplay(displayID) + } + } + + let visibleDisplayIDs = Set(visibleDisplays.map(\.displayID)) + for displayID in sharing.activeSharingDisplayIDs where !visibleDisplayIDs.contains(displayID) { + sharing.stopSharing(displayID: displayID) + } + + let monitoredDisplayIDs = Set(capture.screenCaptureSessions.map(\.displayID)) + for displayID in monitoredDisplayIDs where !visibleDisplayIDs.contains(displayID) { + capture.removeMonitoringSessions(displayID: displayID) + } + } +} diff --git a/VoidDisplay/App/SharingController.swift b/VoidDisplay/App/SharingController.swift index 11001fc..96f6b10 100644 --- a/VoidDisplay/App/SharingController.swift +++ b/VoidDisplay/App/SharingController.swift @@ -18,7 +18,7 @@ final class SharingController { } var activeSharingDisplayIDs: Set = [] - var startingDisplayIDs: Set = [] + private(set) var startingDisplayIDs: Set = [] var sharingClientCount = 0 var sharingClientCounts: [CGDirectDisplayID: Int] = [:] var isSharing = false @@ -29,7 +29,10 @@ final class SharingController { @ObservationIgnored private(set) var webServer: WebServer? = nil @ObservationIgnored private let sharingService: any SharingServiceProtocol @ObservationIgnored private let portPreferences: any SharingPortPreferencesProtocol - @ObservationIgnored private var observedStartTokensByDisplayID: [CGDirectDisplayID: Set] = [:] + @ObservationIgnored private let startTracker = DisplayStartTracker() + @ObservationIgnored private lazy var mutationRunner = SnapshotMutationRunner { [weak self] in + self?.syncSharingState() + } @ObservationIgnored private var sharingStateSubscription: SharingStateSubscription? init( @@ -55,7 +58,7 @@ final class SharingController { @discardableResult func startWebService(requestedPort: UInt16) async -> WebServiceStartResult { - await mutateAndSync { + await mutationRunner.run { let result = await sharingService.startWebService(requestedPort: requestedPort) if let binding = result.binding { portPreferences.savePreferredPort(binding.requestedPort) @@ -65,8 +68,8 @@ final class SharingController { } func stopWebService() { - clearAllObservedStarts() - mutateAndSync { + startTracker.clearAll() + mutationRunner.run { sharingService.stopWebService() } } @@ -75,16 +78,17 @@ final class SharingController { _ displays: [SCDisplay], virtualSerialResolver: @escaping (CGDirectDisplayID) -> UInt32? ) { - mutateAndSync { + mutationRunner.run { sharingService.registerShareableDisplays(displays, virtualSerialResolver: virtualSerialResolver) } } func beginSharing(display: SCDisplay) async throws -> DisplayStartOutcome { let displayID = display.displayID - let startToken = beginObservedStart(displayID: displayID) + let startToken = startTracker.begin(displayID: displayID) + syncSharingState() defer { - endObservedStart(displayID: displayID, token: startToken) + startTracker.end(displayID: displayID, token: startToken) syncSharingState() } @@ -92,15 +96,15 @@ final class SharingController { } func stopSharing(displayID: CGDirectDisplayID) { - clearObservedStarts(displayID: displayID) - mutateAndSync { + startTracker.clear(displayID: displayID) + mutationRunner.run { sharingService.stopSharing(displayID: displayID) } } func stopAllSharing() { - clearAllObservedStarts() - mutateAndSync { + startTracker.clearAll() + mutationRunner.run { sharingService.stopAllSharing() } } @@ -122,7 +126,7 @@ final class SharingController { } func isStarting(displayID: CGDirectDisplayID) -> Bool { - startingDisplayIDs.contains(displayID) + startTracker.contains(displayID: displayID) } func sharePagePath(for displayID: CGDirectDisplayID) -> String? { @@ -162,39 +166,10 @@ final class SharingController { isSharing = sharingService.hasAnyActiveSharing isWebServiceRunning = sharingService.isWebServiceRunning webServiceLifecycleState = sharingService.webServiceLifecycleState + startingDisplayIDs = startTracker.activeDisplayIDs refreshSharingCountsFromSnapshot() } - private func beginObservedStart(displayID: CGDirectDisplayID) -> UUID { - let token = UUID() - var tokens = observedStartTokensByDisplayID[displayID] ?? [] - tokens.insert(token) - observedStartTokensByDisplayID[displayID] = tokens - startingDisplayIDs.insert(displayID) - return token - } - - private func endObservedStart(displayID: CGDirectDisplayID, token: UUID) { - guard var tokens = observedStartTokensByDisplayID[displayID] else { return } - tokens.remove(token) - if tokens.isEmpty { - observedStartTokensByDisplayID.removeValue(forKey: displayID) - startingDisplayIDs.remove(displayID) - } else { - observedStartTokensByDisplayID[displayID] = tokens - } - } - - private func clearObservedStarts(displayID: CGDirectDisplayID) { - observedStartTokensByDisplayID.removeValue(forKey: displayID) - startingDisplayIDs.remove(displayID) - } - - private func clearAllObservedStarts() { - observedStartTokensByDisplayID.removeAll() - startingDisplayIDs.removeAll() - } - private func refreshSharingCountsFromSnapshot() { let snapshot = sharingService.sharingStateSnapshot sharingClientCount = snapshot.streamingPeers @@ -211,18 +186,13 @@ final class SharingController { sharingClientCounts = counts } - private func mutateAndSync(_ mutation: () -> Void) { - mutation() +#if DEBUG + func installStartingDisplayIDsForTesting(_ displayIDs: Set) { + startTracker.clearAll() + for displayID in displayIDs { + _ = startTracker.begin(displayID: displayID) + } syncSharingState() } - - private func mutateAndSync(_ mutation: () async -> T) async -> T { - defer { syncSharingState() } - return await mutation() - } - - private func mutateAndSync(_ mutation: () async throws -> T) async rethrows -> T { - defer { syncSharingState() } - return try await mutation() - } +#endif } diff --git a/VoidDisplay/App/VoidDisplayApp.swift b/VoidDisplay/App/VoidDisplayApp.swift index c659497..b209632 100644 --- a/VoidDisplay/App/VoidDisplayApp.swift +++ b/VoidDisplay/App/VoidDisplayApp.swift @@ -12,7 +12,7 @@ struct AppEnvironment { let capture: CaptureController let sharing: SharingController let virtualDisplay: VirtualDisplayController - let topology: DisplayTopologyChangeCoordinator + let screenCatalog: ScreenCatalogOrchestrator let capturePerformancePreferences: CapturePerformancePreferences } @@ -21,7 +21,7 @@ struct VoidDisplayApp: App { @State private var capture: CaptureController @State private var sharing: SharingController @State private var virtualDisplay: VirtualDisplayController - @State private var topology: DisplayTopologyChangeCoordinator + @State private var screenCatalog: ScreenCatalogOrchestrator @State private var capturePerformancePreferences: CapturePerformancePreferences init() { @@ -29,13 +29,13 @@ struct VoidDisplayApp: App { _capture = State(initialValue: env.capture) _sharing = State(initialValue: env.sharing) _virtualDisplay = State(initialValue: env.virtualDisplay) - _topology = State(initialValue: env.topology) + _screenCatalog = State(initialValue: env.screenCatalog) _capturePerformancePreferences = State(initialValue: env.capturePerformancePreferences) } var body: some Scene { WindowGroup { - HomeView(topologyCoordinator: topology) + HomeView(screenCatalogOrchestrator: screenCatalog) .environment(capture) .environment(sharing) .environment(virtualDisplay) @@ -193,11 +193,11 @@ enum AppBootstrap { capture: capture, sharing: sharing, virtualDisplay: virtualDisplay, - topology: DisplayTopologyChangeCoordinator( + screenCatalog: ScreenCatalogOrchestrator( + catalogService: catalogService, capture: capture, sharing: sharing, - virtualDisplay: virtualDisplay, - catalogService: catalogService + virtualDisplay: virtualDisplay ), capturePerformancePreferences: capturePerformancePreferences ) diff --git a/VoidDisplay/Features/Capture/Services/DisplayCaptureDemandDriver.swift b/VoidDisplay/Features/Capture/Services/DisplayCaptureDemandDriver.swift new file mode 100644 index 0000000..e5614bc --- /dev/null +++ b/VoidDisplay/Features/Capture/Services/DisplayCaptureDemandDriver.swift @@ -0,0 +1,218 @@ +import Foundation +import Synchronization + +final class DisplayCaptureDemandDriver: @unchecked Sendable { + typealias ImmediateDemandApplier = @Sendable (DisplayCaptureDemandSnapshot) async throws -> Bool + typealias ConfigurationApplier = @Sendable (DisplayCaptureConfiguration) async throws -> Bool + typealias ConfigurationAppliedHandler = @Sendable (DisplayCaptureConfiguration) -> Void + typealias ConfigurationFailureHandler = @Sendable (any Error) -> Void + + private struct State { + var configurationCoordinator: DisplayCaptureConfigurationCoordinatorState + var taskLifetime = DisplayCaptureTaskLifetimeState() + var pendingTaskNonce: UInt64 = 0 + var pendingConfigurationTask: Task? + var activeApplyTask: Task? + } + + private let minimumDwellNanoseconds: UInt64 + private let currentTimeNanoseconds: @Sendable () -> UInt64 + private let applyImmediateDemandClosure: ImmediateDemandApplier + private let applyConfigurationClosure: ConfigurationApplier + private let onConfigurationApplied: ConfigurationAppliedHandler + private let onConfigurationFailure: ConfigurationFailureHandler? + private let state: Mutex + + nonisolated init( + initialConfiguration: DisplayCaptureConfiguration, + initialDemand: DisplayCaptureDemandSnapshot, + minimumDwellNanoseconds: UInt64, + currentTimeNanoseconds: @escaping @Sendable () -> UInt64 = { DispatchTime.now().uptimeNanoseconds }, + applyImmediateDemand: @escaping ImmediateDemandApplier, + applyConfiguration: @escaping ConfigurationApplier, + onConfigurationApplied: @escaping ConfigurationAppliedHandler = { _ in }, + onConfigurationFailure: ConfigurationFailureHandler? = nil + ) { + self.minimumDwellNanoseconds = minimumDwellNanoseconds + self.currentTimeNanoseconds = currentTimeNanoseconds + self.applyImmediateDemandClosure = applyImmediateDemand + self.applyConfigurationClosure = applyConfiguration + self.onConfigurationApplied = onConfigurationApplied + self.onConfigurationFailure = onConfigurationFailure + self.state = Mutex( + State( + configurationCoordinator: DisplayCaptureConfigurationCoordinatorState( + committedConfiguration: initialConfiguration, + demand: initialDemand + ) + ) + ) + } + + nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws { + _ = try await applyImmediateDemandClosure(demand) + scheduleConfigurationDecision { state, nowNs, minimumDwellNanoseconds in + state.configurationCoordinator.updateDemand( + demand, + nowNs: nowNs, + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + } + } + + nonisolated func recordPreviewPerformanceSample(_ sample: DisplayPreviewPerformanceSample) { + scheduleConfigurationDecision { state, nowNs, minimumDwellNanoseconds in + state.configurationCoordinator.recordPreviewPerformanceSample( + sample, + nowNs: nowNs, + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + } + } + + nonisolated func cancelAll() { + state.withLock { state in + _ = state.taskLifetime.invalidateAllTasks() + state.pendingConfigurationTask?.cancel() + state.pendingConfigurationTask = nil + state.activeApplyTask?.cancel() + state.activeApplyTask = nil + } + } + + nonisolated private func scheduleConfigurationDecision( + _ decisionProvider: ( + inout State, + UInt64, + UInt64 + ) -> DisplayCaptureConfigurationDecision + ) { + let decision = state.withLock { state -> (DisplayCaptureConfigurationDecision, UInt64) in + state.pendingConfigurationTask?.cancel() + state.pendingConfigurationTask = nil + state.pendingTaskNonce &+= 1 + let decision = decisionProvider( + &state, + currentTimeNanoseconds(), + minimumDwellNanoseconds + ) + return (decision, state.pendingTaskNonce) + } + handleConfigurationDecision(decision.0, schedulingNonce: decision.1) + } + + nonisolated private func handleConfigurationDecision( + _ decision: DisplayCaptureConfigurationDecision, + schedulingNonce: UInt64 + ) { + switch decision { + case .noChange: + return + case .applyNow(let configuration): + let executionGeneration = state.withLock { $0.taskLifetime.currentGeneration } + let task = Task { [weak self] in + guard let self else { return } + await self.applyConfiguration( + configuration: configuration, + executionGeneration: executionGeneration + ) + } + state.withLock { state in + if state.taskLifetime.allowsExecution(for: executionGeneration) { + state.activeApplyTask = task + } else { + task.cancel() + } + } + case .applyAfter(_, let delayNanoseconds): + let task = Task { [weak self] in + try? await Task.sleep(nanoseconds: delayNanoseconds) + self?.resumeDemandDrivenConfigurationEvaluation(schedulingNonce: schedulingNonce) + } + state.withLock { state in + if state.pendingTaskNonce == schedulingNonce { + state.pendingConfigurationTask = task + } else { + task.cancel() + } + } + } + } + + nonisolated private func resumeDemandDrivenConfigurationEvaluation(schedulingNonce: UInt64) { + let decision = state.withLock { state -> (DisplayCaptureConfigurationDecision, UInt64)? in + guard state.pendingTaskNonce == schedulingNonce else { + return nil + } + state.pendingConfigurationTask = nil + state.pendingTaskNonce &+= 1 + let decision = state.configurationCoordinator.resumeScheduledTransition( + nowNs: currentTimeNanoseconds(), + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + return (decision, state.pendingTaskNonce) + } + guard let decision else { return } + handleConfigurationDecision(decision.0, schedulingNonce: decision.1) + } + + nonisolated private func applyConfiguration( + configuration: DisplayCaptureConfiguration, + executionGeneration: UInt64 + ) async { + guard isExecutionAllowed(for: executionGeneration) else { return } + + let changed: Bool + do { + try Task.checkCancellation() + guard isExecutionAllowed(for: executionGeneration) else { return } + changed = try await applyConfigurationClosure(configuration) + } catch is CancellationError { + finishDiscardedConfigurationApply(executionGeneration: executionGeneration) + return + } catch { + finishDiscardedConfigurationApply(executionGeneration: executionGeneration) + onConfigurationFailure?(error) + return + } + + guard changed else { + finishDiscardedConfigurationApply(executionGeneration: executionGeneration) + return + } + + guard isExecutionAllowed(for: executionGeneration) else { return } + onConfigurationApplied(configuration) + + let decision = state.withLock { state -> (DisplayCaptureConfigurationDecision, UInt64)? in + guard state.taskLifetime.allowsExecution(for: executionGeneration) else { + return nil + } + state.pendingConfigurationTask?.cancel() + state.pendingConfigurationTask = nil + state.activeApplyTask = nil + state.pendingTaskNonce &+= 1 + let decision = state.configurationCoordinator.finishAppliedTransition( + at: currentTimeNanoseconds(), + minimumDwellNanoseconds: minimumDwellNanoseconds + ) + return (decision, state.pendingTaskNonce) + } + guard let decision else { return } + handleConfigurationDecision(decision.0, schedulingNonce: decision.1) + } + + nonisolated private func isExecutionAllowed(for generation: UInt64) -> Bool { + state.withLock { state in + state.taskLifetime.allowsExecution(for: generation) + } + } + + nonisolated private func finishDiscardedConfigurationApply(executionGeneration: UInt64) { + state.withLock { state in + guard state.taskLifetime.allowsExecution(for: executionGeneration) else { return } + state.activeApplyTask = nil + state.configurationCoordinator.failAppliedTransition() + } + } +} diff --git a/VoidDisplay/Features/Capture/Services/DisplayCaptureRegistry.swift b/VoidDisplay/Features/Capture/Services/DisplayCaptureRegistry.swift index 5635bb2..eb51d60 100644 --- a/VoidDisplay/Features/Capture/Services/DisplayCaptureRegistry.swift +++ b/VoidDisplay/Features/Capture/Services/DisplayCaptureRegistry.swift @@ -7,6 +7,8 @@ final class DisplayPreviewSubscription: Sendable { let resolutionText: String private let session: any DisplayCaptureSessioning + private let onAttachedPreviewSinkCountChanged: @Sendable (Int) -> Void + private let setShowsCursorClosure: @Sendable (Bool) async throws -> Void private let cancelState = Mutex<(@Sendable () -> Void)?>(nil) private let attachedSinks = Mutex<[ObjectIdentifier: WeakSink]>([:]) @@ -22,11 +24,15 @@ final class DisplayPreviewSubscription: Sendable { displayID: CGDirectDisplayID, resolutionText: String, session: any DisplayCaptureSessioning, - cancelClosure: @escaping @Sendable () -> Void + cancelClosure: @escaping @Sendable () -> Void, + onAttachedPreviewSinkCountChanged: @escaping @Sendable (Int) -> Void = { _ in }, + setShowsCursorClosure: @escaping @Sendable (Bool) async throws -> Void = { _ in } ) { self.displayID = displayID self.resolutionText = resolutionText self.session = session + self.onAttachedPreviewSinkCountChanged = onAttachedPreviewSinkCountChanged + self.setShowsCursorClosure = setShowsCursorClosure cancelState.withLock { $0 = cancelClosure } } @@ -41,6 +47,7 @@ final class DisplayPreviewSubscription: Sendable { } guard shouldAttach else { return } session.attachPreviewSink(sink) + onAttachedPreviewSinkCountChanged(1) } nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { @@ -53,6 +60,7 @@ final class DisplayPreviewSubscription: Sendable { } guard shouldDetach else { return } session.detachPreviewSink(sink) + onAttachedPreviewSinkCountChanged(-1) } nonisolated func cancel() { @@ -71,6 +79,7 @@ final class DisplayPreviewSubscription: Sendable { } for sink in sinksToDetach { session.detachPreviewSink(sink) + onAttachedPreviewSinkCountChanged(-1) } closure() @@ -81,7 +90,7 @@ final class DisplayPreviewSubscription: Sendable { } nonisolated func setShowsCursor(_ showsCursor: Bool) async throws { - try await session.setPreviewShowsCursor(showsCursor) + try await setShowsCursorClosure(showsCursor) } deinit { cancel() } @@ -91,7 +100,8 @@ final class DisplayShareSubscription: Sendable { let displayID: CGDirectDisplayID let sessionHub: WebRTCSessionHub - private let session: any DisplayCaptureSessioning + private let prepareForSharingClosure: @Sendable () async throws -> Void + private let releasePreparedShareClosure: @Sendable () async -> Void private let cancelState = Mutex<(@Sendable () -> Void)?>(nil) private let prepareRetainTask = Mutex?>(nil) private let hasRetainedShareCursorOverride = Mutex(false) @@ -99,17 +109,19 @@ final class DisplayShareSubscription: Sendable { nonisolated init( displayID: CGDirectDisplayID, sessionHub: WebRTCSessionHub, - session: any DisplayCaptureSessioning, - cancelClosure: @escaping @Sendable () -> Void + cancelClosure: @escaping @Sendable () -> Void, + prepareForSharingClosure: @escaping @Sendable () async throws -> Void = {}, + releasePreparedShareClosure: @escaping @Sendable () async -> Void = {} ) { self.displayID = displayID self.sessionHub = sessionHub - self.session = session + self.prepareForSharingClosure = prepareForSharingClosure + self.releasePreparedShareClosure = releasePreparedShareClosure cancelState.withLock { $0 = cancelClosure } } nonisolated func prepareForSharing() async throws { - try await session.retainShareCursorOverride() + try await prepareForSharingClosure() hasRetainedShareCursorOverride.withLock { $0 = true } } @@ -117,7 +129,7 @@ final class DisplayShareSubscription: Sendable { invalidationContext: DisplayStartInvalidationContext ) async throws -> DisplayStartOutcome { let retainTask = Task { - try await session.retainShareCursorOverride() + try await prepareForSharingClosure() return true } prepareRetainTask.withLock { state in @@ -144,7 +156,6 @@ final class DisplayShareSubscription: Sendable { } nonisolated func cancel() { - let session = self.session let pendingRetainTask = prepareRetainTask.withLock { state -> Task? in let current = state state = nil @@ -162,7 +173,7 @@ final class DisplayShareSubscription: Sendable { } guard let closure else { return } if let pendingRetainTask { - Task.detached { + Task.detached { [self] in var needsRelease = hasRetained do { let didRetain = try await pendingRetainTask.value @@ -170,7 +181,7 @@ final class DisplayShareSubscription: Sendable { } catch { } if needsRelease { - try? await session.releaseShareCursorOverride() + await releasePreparedShareClosure() } closure() } @@ -178,7 +189,7 @@ final class DisplayShareSubscription: Sendable { } Task { if hasRetained { - try? await session.releaseShareCursorOverride() + await releasePreparedShareClosure() } closure() } @@ -187,35 +198,179 @@ final class DisplayShareSubscription: Sendable { deinit { cancel() } } -actor DisplayCaptureRegistry { - enum SessionResourceState: Equatable { - case initializing - case active - case draining - case stopped +nonisolated final class DisplayCaptureSessionStore: @unchecked Sendable { + nonisolated struct Record { + let session: any DisplayCaptureSessioning + let resolutionText: String + var state: DisplayCaptureRegistry.SessionResourceState } - struct PreviewToken: Hashable, Sendable { - fileprivate let rawValue: UUID - let displayID: CGDirectDisplayID + private var recordsByDisplayID: [CGDirectDisplayID: Record] = [:] + private var sessionCreationTasks: [CGDirectDisplayID: Task] = [:] + private var sessionDrainTasksByDisplayID: [CGDirectDisplayID: Task] = [:] + private var initializingDisplayIDs: Set = [] + + nonisolated var activeDisplayIDs: [CGDirectDisplayID] { + recordsByDisplayID.compactMap { displayID, record in + record.state == .draining ? nil : displayID + } } - struct ShareToken: Hashable, Sendable { - fileprivate let rawValue: UUID - let displayID: CGDirectDisplayID + nonisolated func record(for displayID: CGDirectDisplayID) -> Record? { + recordsByDisplayID[displayID] + } + + nonisolated func sessionState( + for displayID: CGDirectDisplayID + ) -> DisplayCaptureRegistry.SessionResourceState { + if initializingDisplayIDs.contains(displayID) { + return .initializing + } + return recordsByDisplayID[displayID]?.state ?? .stopped + } + + nonisolated func installSessionForTesting( + displayID: CGDirectDisplayID, + resolutionText: String, + session: any DisplayCaptureSessioning + ) { + sessionDrainTasksByDisplayID[displayID]?.cancel() + sessionDrainTasksByDisplayID[displayID] = nil + initializingDisplayIDs.remove(displayID) + recordsByDisplayID[displayID] = Record( + session: session, + resolutionText: resolutionText, + state: .active + ) + } + + nonisolated func markActive(displayID: CGDirectDisplayID) { + guard var record = recordsByDisplayID[displayID] else { return } + record.state = .active + recordsByDisplayID[displayID] = record + } + + nonisolated func ensureSessionExists( + for display: SendableDisplay, + initialProfileProvider: @escaping @Sendable (CGDirectDisplayID) async -> DisplayCaptureProfile, + performanceMode: CapturePerformanceMode, + captureSessionFactory: @escaping DisplayCaptureRegistry.CaptureSessionFactory + ) async throws { + let displayID = display.displayID + if let existing = recordsByDisplayID[displayID] { + if existing.state != .draining { + return + } + await waitForDrainCompletion(for: displayID) + if let afterDrain = recordsByDisplayID[displayID], afterDrain.state != .draining { + return + } + } + + if let existingTask = sessionCreationTasks[displayID] { + let record = try await existingTask.value + storeInitializedSessionIfAbsent(record, for: displayID) + return + } + + let task = Task { [captureSessionFactory] in + await Task.yield() + let initialProfile = await initialProfileProvider(displayID) + let session = try await captureSessionFactory(display, initialProfile, performanceMode) + return Record( + session: session, + resolutionText: "\(display.width) × \(display.height)", + state: .active + ) + } + initializingDisplayIDs.insert(displayID) + sessionCreationTasks[displayID] = task + defer { sessionCreationTasks[displayID] = nil } + + do { + let record = try await task.value + storeInitializedSessionIfAbsent(record, for: displayID) + } catch { + initializingDisplayIDs.remove(displayID) + throw error + } + } + + nonisolated func beginDraining( + displayID: CGDirectDisplayID, + onStopCompleted: @escaping @Sendable (CGDirectDisplayID) async -> Void + ) { + guard var record = recordsByDisplayID[displayID] else { return } + record.state = .draining + recordsByDisplayID[displayID] = record + + let session = record.session + sessionDrainTasksByDisplayID[displayID]?.cancel() + sessionDrainTasksByDisplayID[displayID] = Task { [displayID] in + await session.stop() + await onStopCompleted(displayID) + } } - private enum TokenKind: Sendable { + nonisolated func finishDraining(displayID: CGDirectDisplayID, hasActiveTokens: Bool) { + sessionDrainTasksByDisplayID[displayID] = nil + guard let record = recordsByDisplayID[displayID] else { return } + guard record.state == .draining else { return } + + if hasActiveTokens { + var resumedRecord = record + resumedRecord.state = .active + recordsByDisplayID[displayID] = resumedRecord + return + } + + recordsByDisplayID.removeValue(forKey: displayID) + } + + private nonisolated func waitForDrainCompletion(for displayID: CGDirectDisplayID) async { + guard let drainTask = sessionDrainTasksByDisplayID[displayID] else { return } + await drainTask.value + } + + private nonisolated func storeInitializedSessionIfAbsent( + _ record: Record, + for displayID: CGDirectDisplayID + ) { + initializingDisplayIDs.remove(displayID) + guard recordsByDisplayID[displayID] == nil else { return } + recordsByDisplayID[displayID] = record + } +} + +nonisolated final class DisplayCaptureLeaseBook: @unchecked Sendable { + nonisolated enum TokenKind: Sendable { case preview case share } - private struct TokenRecord: Sendable { + nonisolated struct PreviewLeaseState: Sendable, Equatable { + var attachedSinkCount = 0 + var showsCursor = false + } + + nonisolated struct ReleaseResult: Sendable, Equatable { + let displayID: CGDirectDisplayID + let shouldStopSharing: Bool + let shouldApplyDemand: Bool + let shouldDrainSession: Bool + } + + nonisolated struct PreviewCursorMutation: Sendable, Equatable { + let displayID: CGDirectDisplayID + let previousValue: Bool + } + + private nonisolated struct TokenRecord: Sendable { let kind: TokenKind let displayID: CGDirectDisplayID } - private struct PendingCreationDemand: Sendable { + private nonisolated struct PendingCreationDemand: Sendable { var previewCount = 0 var shareCount = 0 @@ -229,10 +384,11 @@ actor DisplayCaptureRegistry { } var initialProfile: DisplayCaptureProfile? { - DisplayCaptureProfileStateMachine.desiredProfile( - previewSinkCount: previewCount, - sharingActive: shareCount > 0 - ) + DisplayCaptureDemandSnapshot( + attachedPreviewSinkCount: previewCount, + shareTokenCount: shareCount, + performanceMode: .automatic + ).desiredProfile } var isEmpty: Bool { @@ -240,22 +396,216 @@ actor DisplayCaptureRegistry { } } - struct SessionRecord { - let session: any DisplayCaptureSessioning - let resolutionText: String - var state: SessionResourceState - var previewTokens: Set - var shareTokens: Set + private nonisolated struct DisplayState { + var previewTokens: [UUID: PreviewLeaseState] = [:] + var shareTokens: Set = [] + var shareCursorOverrideTokens: Set = [] + + var hasActiveTokens: Bool { + previewTokens.isEmpty == false || shareTokens.isEmpty == false + } } - private enum RegistryError: Error { - case sessionUnavailable + private var statesByDisplayID: [CGDirectDisplayID: DisplayState] = [:] + private var tokenOwnership: [UUID: TokenRecord] = [:] + private var pendingCreationDemandByDisplayID: [CGDirectDisplayID: PendingCreationDemand] = [:] + + nonisolated func recordPendingCreationDemand( + for displayID: CGDirectDisplayID, + kind: TokenKind, + delta: Int + ) { + var demand = pendingCreationDemandByDisplayID[displayID] ?? PendingCreationDemand() + demand.record(kind, delta: delta) + if demand.isEmpty { + pendingCreationDemandByDisplayID.removeValue(forKey: displayID) + } else { + pendingCreationDemandByDisplayID[displayID] = demand + } } - private struct ReleaseSideEffects { - let session: any DisplayCaptureSessioning - let setSharingActiveTo: Bool? - let stopSharing: Bool + nonisolated func initialProfile( + for displayID: CGDirectDisplayID, + fallbackKind: TokenKind + ) -> DisplayCaptureProfile { + if let profile = pendingCreationDemandByDisplayID[displayID]?.initialProfile { + return profile + } + + switch fallbackKind { + case .preview: + return .previewOnly + case .share: + return .shareOnly + } + } + + nonisolated func registerToken(displayID: CGDirectDisplayID, kind: TokenKind) -> UUID { + let tokenID = UUID() + var state = statesByDisplayID[displayID] ?? DisplayState() + switch kind { + case .preview: + state.previewTokens[tokenID] = PreviewLeaseState() + case .share: + state.shareTokens.insert(tokenID) + } + statesByDisplayID[displayID] = state + tokenOwnership[tokenID] = TokenRecord(kind: kind, displayID: displayID) + return tokenID + } + + nonisolated func releaseToken(_ tokenID: UUID, expectedKind: TokenKind) -> ReleaseResult? { + guard let ownership = tokenOwnership.removeValue(forKey: tokenID), + ownership.kind == expectedKind else { + return nil + } + + var state = statesByDisplayID[ownership.displayID] ?? DisplayState() + switch ownership.kind { + case .preview: + state.previewTokens.removeValue(forKey: tokenID) + case .share: + state.shareTokens.remove(tokenID) + } + state.shareCursorOverrideTokens.remove(tokenID) + + let shouldDrainSession = state.hasActiveTokens == false + let shouldStopSharing = ownership.kind == .share && state.shareTokens.isEmpty + let shouldApplyDemand = shouldDrainSession == false + + if state.previewTokens.isEmpty && state.shareTokens.isEmpty && state.shareCursorOverrideTokens.isEmpty { + statesByDisplayID.removeValue(forKey: ownership.displayID) + } else { + statesByDisplayID[ownership.displayID] = state + } + + return ReleaseResult( + displayID: ownership.displayID, + shouldStopSharing: shouldStopSharing, + shouldApplyDemand: shouldApplyDemand, + shouldDrainSession: shouldDrainSession + ) + } + + nonisolated func recordAttachedPreviewSinkDelta(_ delta: Int, for tokenID: UUID) -> CGDirectDisplayID? { + guard let ownership = tokenOwnership[tokenID], + ownership.kind == .preview, + var state = statesByDisplayID[ownership.displayID], + var lease = state.previewTokens[tokenID] else { + return nil + } + lease.attachedSinkCount = max(0, lease.attachedSinkCount + delta) + state.previewTokens[tokenID] = lease + statesByDisplayID[ownership.displayID] = state + return ownership.displayID + } + + nonisolated func setPreviewShowsCursor( + _ showsCursor: Bool, + for tokenID: UUID + ) -> PreviewCursorMutation? { + guard let ownership = tokenOwnership[tokenID], + ownership.kind == .preview, + var state = statesByDisplayID[ownership.displayID], + var lease = state.previewTokens[tokenID] else { + return nil + } + let previousValue = lease.showsCursor + guard previousValue != showsCursor else { return nil } + + lease.showsCursor = showsCursor + state.previewTokens[tokenID] = lease + statesByDisplayID[ownership.displayID] = state + return PreviewCursorMutation(displayID: ownership.displayID, previousValue: previousValue) + } + + nonisolated func revertPreviewShowsCursor(for tokenID: UUID, previousValue: Bool) { + guard let ownership = tokenOwnership[tokenID], + ownership.kind == .preview, + var state = statesByDisplayID[ownership.displayID], + var lease = state.previewTokens[tokenID] else { + return + } + lease.showsCursor = previousValue + state.previewTokens[tokenID] = lease + statesByDisplayID[ownership.displayID] = state + } + + nonisolated func prepareShareForSharing(_ tokenID: UUID) -> CGDirectDisplayID? { + guard let ownership = tokenOwnership[tokenID], + ownership.kind == .share, + var state = statesByDisplayID[ownership.displayID] else { + return nil + } + guard state.shareCursorOverrideTokens.contains(tokenID) == false else { return nil } + + state.shareCursorOverrideTokens.insert(tokenID) + statesByDisplayID[ownership.displayID] = state + return ownership.displayID + } + + nonisolated func revertPreparedShare(_ tokenID: UUID) { + guard let ownership = tokenOwnership[tokenID], + ownership.kind == .share, + var state = statesByDisplayID[ownership.displayID] else { + return + } + guard state.shareCursorOverrideTokens.remove(tokenID) != nil else { return } + statesByDisplayID[ownership.displayID] = state + } + + nonisolated func releasePreparedShare(_ tokenID: UUID) -> CGDirectDisplayID? { + guard let ownership = tokenOwnership[tokenID], + ownership.kind == .share, + var state = statesByDisplayID[ownership.displayID] else { + return nil + } + guard state.shareCursorOverrideTokens.remove(tokenID) != nil else { return nil } + statesByDisplayID[ownership.displayID] = state + return ownership.displayID + } + + nonisolated func demandSnapshot( + for displayID: CGDirectDisplayID, + performanceMode: CapturePerformanceMode + ) -> DisplayCaptureDemandSnapshot { + let state = statesByDisplayID[displayID] ?? DisplayState() + return DisplayCaptureDemandSnapshot( + attachedPreviewSinkCount: state.previewTokens.values.reduce(0) { partialResult, lease in + partialResult + lease.attachedSinkCount + }, + shareTokenCount: state.shareTokens.count, + previewShowsCursor: state.previewTokens.values.contains { $0.showsCursor }, + shareCursorOverrideCount: state.shareCursorOverrideTokens.count, + performanceMode: performanceMode + ) + } + + nonisolated func hasActiveTokens(for displayID: CGDirectDisplayID) -> Bool { + statesByDisplayID[displayID]?.hasActiveTokens == true + } +} + +actor DisplayCaptureRegistry { + enum SessionResourceState: Equatable { + case initializing + case active + case draining + case stopped + } + + struct PreviewToken: Hashable, Sendable { + fileprivate let rawValue: UUID + let displayID: CGDirectDisplayID + } + + struct ShareToken: Hashable, Sendable { + fileprivate let rawValue: UUID + let displayID: CGDirectDisplayID + } + + private enum RegistryError: Error { + case sessionUnavailable } typealias CaptureSessionFactory = @Sendable ( @@ -268,12 +618,8 @@ actor DisplayCaptureRegistry { private let captureSessionFactory: CaptureSessionFactory private var performanceMode: CapturePerformanceMode - private var sessionsByDisplayID: [CGDirectDisplayID: SessionRecord] = [:] - private var tokenOwnership: [UUID: TokenRecord] = [:] - private var sessionCreationTasks: [CGDirectDisplayID: Task] = [:] - private var pendingCreationDemandByDisplayID: [CGDirectDisplayID: PendingCreationDemand] = [:] - private var sessionDrainTasksByDisplayID: [CGDirectDisplayID: Task] = [:] - private var initializingDisplayIDs: Set = [] + private let sessionStore = DisplayCaptureSessionStore() + private let leaseBook = DisplayCaptureLeaseBook() init( performanceMode: CapturePerformanceMode = .automatic, @@ -291,24 +637,27 @@ actor DisplayCaptureRegistry { func updatePerformanceMode(_ mode: CapturePerformanceMode) async { performanceMode = mode - let sessions = sessionsByDisplayID.values.map(\.session) - for session in sessions { - try? await session.setPerformanceMode(mode) + let displayIDs = sessionStore.activeDisplayIDs + for displayID in displayIDs { + try? await applyDemand(for: displayID) } } func acquirePreview(display: SendableDisplay) async throws -> DisplayPreviewSubscription { let token = try await acquirePreviewToken(display: display) - guard let record = sessionsByDisplayID[token.displayID] else { + guard let record = sessionStore.record(for: token.displayID) else { throw RegistryError.sessionUnavailable } return DisplayPreviewSubscription( displayID: token.displayID, resolutionText: record.resolutionText, session: record.session, - cancelClosure: { [weak self] in - guard let self else { return } - Task { await self.release(token) } + cancelClosure: { Task { await self.release(token) } }, + onAttachedPreviewSinkCountChanged: { [self] delta in + Task { await self.recordAttachedPreviewSinkDelta(delta, for: token.rawValue) } + }, + setShowsCursorClosure: { [self] showsCursor in + try await self.setPreviewShowsCursor(showsCursor, for: token.rawValue) } ) } @@ -324,16 +673,18 @@ actor DisplayCaptureRegistry { func acquireShare(display: SendableDisplay) async throws -> DisplayShareSubscription { let token = try await acquireShareToken(display: display) - guard let record = sessionsByDisplayID[token.displayID] else { + guard let record = sessionStore.record(for: token.displayID) else { throw RegistryError.sessionUnavailable } return DisplayShareSubscription( displayID: token.displayID, sessionHub: record.session.sessionHub, - session: record.session, - cancelClosure: { [weak self] in - guard let self else { return } - Task { await self.release(token) } + cancelClosure: { Task { await self.release(token) } }, + prepareForSharingClosure: { [self] in + try await self.prepareShareForSharing(token.rawValue) + }, + releasePreparedShareClosure: { [self] in + await self.releasePreparedShare(token.rawValue) } ) } @@ -366,23 +717,20 @@ actor DisplayCaptureRegistry { } func sessionState(for displayID: CGDirectDisplayID) -> SessionResourceState { - if initializingDisplayIDs.contains(displayID) { - return .initializing - } - return sessionsByDisplayID[displayID]?.state ?? .stopped + sessionStore.sessionState(for: displayID) } private func acquireToken( display: SendableDisplay, - kind: TokenKind + kind: DisplayCaptureLeaseBook.TokenKind ) async throws -> UUID { - recordPendingCreationDemand(for: display.displayID, kind: kind, delta: 1) + leaseBook.recordPendingCreationDemand(for: display.displayID, kind: kind, delta: 1) do { try await ensureSessionExists(for: display, fallbackKind: kind) - recordPendingCreationDemand(for: display.displayID, kind: kind, delta: -1) + leaseBook.recordPendingCreationDemand(for: display.displayID, kind: kind, delta: -1) return try await registerToken(displayID: display.displayID, kind: kind) } catch { - recordPendingCreationDemand(for: display.displayID, kind: kind, delta: -1) + leaseBook.recordPendingCreationDemand(for: display.displayID, kind: kind, delta: -1) throw error } } @@ -393,15 +741,10 @@ actor DisplayCaptureRegistry { resolutionText: String, session: any DisplayCaptureSessioning ) { - sessionDrainTasksByDisplayID[displayID]?.cancel() - sessionDrainTasksByDisplayID[displayID] = nil - initializingDisplayIDs.remove(displayID) - sessionsByDisplayID[displayID] = SessionRecord( - session: session, + sessionStore.installSessionForTesting( + displayID: displayID, resolutionText: resolutionText, - state: .active, - previewTokens: [], - shareTokens: [] + session: session ) } @@ -416,197 +759,132 @@ actor DisplayCaptureRegistry { } #endif - private func registerToken(displayID: CGDirectDisplayID, kind: TokenKind) async throws -> UUID { - let tokenID = UUID() - guard var record = sessionsByDisplayID[displayID] else { + private func registerToken( + displayID: CGDirectDisplayID, + kind: DisplayCaptureLeaseBook.TokenKind + ) async throws -> UUID { + guard let record = sessionStore.record(for: displayID) else { throw RegistryError.sessionUnavailable } guard record.state != .draining else { throw RegistryError.sessionUnavailable } - record.state = .active - switch kind { - case .preview: - record.previewTokens.insert(tokenID) - case .share: - record.shareTokens.insert(tokenID) - } - sessionsByDisplayID[displayID] = record - tokenOwnership[tokenID] = TokenRecord(kind: kind, displayID: displayID) - try? await record.session.setSharingActive(!record.shareTokens.isEmpty) + sessionStore.markActive(displayID: displayID) + let tokenID = leaseBook.registerToken(displayID: displayID, kind: kind) + try? await applyDemand(for: displayID) return tokenID } - private func registerTokenForTesting(displayID: CGDirectDisplayID, kind: TokenKind) throws -> UUID { - let tokenID = UUID() - guard var record = sessionsByDisplayID[displayID] else { + private func registerTokenForTesting( + displayID: CGDirectDisplayID, + kind: DisplayCaptureLeaseBook.TokenKind + ) throws -> UUID { + guard let record = sessionStore.record(for: displayID) else { throw RegistryError.sessionUnavailable } guard record.state != .draining else { throw RegistryError.sessionUnavailable } - record.state = .active - switch kind { - case .preview: - record.previewTokens.insert(tokenID) - case .share: - record.shareTokens.insert(tokenID) - } - sessionsByDisplayID[displayID] = record - tokenOwnership[tokenID] = TokenRecord(kind: kind, displayID: displayID) - return tokenID + sessionStore.markActive(displayID: displayID) + return leaseBook.registerToken(displayID: displayID, kind: kind) } private func ensureSessionExists( for display: SendableDisplay, - fallbackKind: TokenKind + fallbackKind: DisplayCaptureLeaseBook.TokenKind ) async throws { - let displayID = display.displayID - if let existing = sessionsByDisplayID[displayID] { - if existing.state != .draining { - return - } - await waitForDrainCompletion(for: displayID) - if let afterDrain = sessionsByDisplayID[displayID], afterDrain.state != .draining { - return - } - } + try await sessionStore.ensureSessionExists( + for: display, + initialProfileProvider: { [weak self] displayID in + guard let self else { return fallbackKind == .preview ? .previewOnly : .shareOnly } + return await self.initialProfile(for: displayID, fallbackKind: fallbackKind) + }, + performanceMode: performanceMode, + captureSessionFactory: captureSessionFactory + ) + } - if let existingTask = sessionCreationTasks[displayID] { - let record = try await existingTask.value - storeInitializedSessionIfAbsent(record, for: displayID) + private func releaseToken( + _ tokenID: UUID, + expectedKind: DisplayCaptureLeaseBook.TokenKind + ) async { + guard let result = leaseBook.releaseToken(tokenID, expectedKind: expectedKind) else { return } - let task = Task { [captureSessionFactory] in - let initialProfile = await self.resolveInitialProfileForPendingCreation( - displayID: displayID, - fallbackKind: fallbackKind - ) - let performanceMode = self.performanceMode - let session = try await captureSessionFactory(display, initialProfile, performanceMode) - return SessionRecord( - session: session, - resolutionText: "\(display.width) × \(display.height)", - state: .active, - previewTokens: [], - shareTokens: [] - ) - } - initializingDisplayIDs.insert(displayID) - sessionCreationTasks[displayID] = task - defer { sessionCreationTasks[displayID] = nil } + guard let record = sessionStore.record(for: result.displayID) else { return } - do { - let record = try await task.value - storeInitializedSessionIfAbsent(record, for: displayID) - } catch { - initializingDisplayIDs.remove(displayID) - throw error + if result.shouldStopSharing { + record.session.stopSharing() + } + if result.shouldDrainSession { + sessionStore.beginDraining(displayID: result.displayID) { [weak self] displayID in + await self?.finishDrainingSession(displayID: displayID) + } + } + if result.shouldApplyDemand { + try? await applyDemand(for: result.displayID) } } - private func storeInitializedSessionIfAbsent( - _ record: SessionRecord, - for displayID: CGDirectDisplayID - ) { - initializingDisplayIDs.remove(displayID) - guard sessionsByDisplayID[displayID] == nil else { return } - sessionsByDisplayID[displayID] = record + private func recordAttachedPreviewSinkDelta(_ delta: Int, for tokenID: UUID) async { + guard let displayID = leaseBook.recordAttachedPreviewSinkDelta(delta, for: tokenID) else { + return + } + try? await applyDemand(for: displayID) } - private func releaseToken(_ tokenID: UUID, expectedKind: TokenKind) async { - let sideEffects: ReleaseSideEffects - guard let ownership = tokenOwnership.removeValue(forKey: tokenID), - ownership.kind == expectedKind else { + private func setPreviewShowsCursor(_ showsCursor: Bool, for tokenID: UUID) async throws { + guard let mutation = leaseBook.setPreviewShowsCursor(showsCursor, for: tokenID) else { return } - guard var record = sessionsByDisplayID[ownership.displayID] else { return } - switch ownership.kind { - case .preview: - record.previewTokens.remove(tokenID) - case .share: - record.shareTokens.remove(tokenID) + do { + try await applyDemand(for: mutation.displayID) + } catch { + leaseBook.revertPreviewShowsCursor(for: tokenID, previousValue: mutation.previousValue) + try? await applyDemand(for: mutation.displayID) + throw error } + } - if record.previewTokens.isEmpty, record.shareTokens.isEmpty { - record.state = .draining - sessionsByDisplayID[ownership.displayID] = record - let session = record.session - sessionDrainTasksByDisplayID[ownership.displayID]?.cancel() - sessionDrainTasksByDisplayID[ownership.displayID] = Task { [session] in - await session.stop() - self.finishDrainingSession(displayID: ownership.displayID) - } - sideEffects = ReleaseSideEffects( - session: session, - setSharingActiveTo: nil, - stopSharing: ownership.kind == .share - ) - } else { - record.state = .active - sessionsByDisplayID[ownership.displayID] = record - sideEffects = ReleaseSideEffects( - session: record.session, - setSharingActiveTo: !record.shareTokens.isEmpty, - stopSharing: ownership.kind == .share && record.shareTokens.isEmpty - ) - } + private func prepareShareForSharing(_ tokenID: UUID) async throws { + guard let displayID = leaseBook.prepareShareForSharing(tokenID) else { return } - if sideEffects.stopSharing { - sideEffects.session.stopSharing() - } - if let isSharingActive = sideEffects.setSharingActiveTo { - try? await sideEffects.session.setSharingActive(isSharingActive) + do { + try await applyDemand(for: displayID) + } catch { + leaseBook.revertPreparedShare(tokenID) + try? await applyDemand(for: displayID) + throw error } } - private func waitForDrainCompletion(for displayID: CGDirectDisplayID) async { - guard let drainTask = sessionDrainTasksByDisplayID[displayID] else { return } - await drainTask.value + private func releasePreparedShare(_ tokenID: UUID) async { + guard let displayID = leaseBook.releasePreparedShare(tokenID) else { return } + try? await applyDemand(for: displayID) } - private func finishDrainingSession(displayID: CGDirectDisplayID) { - sessionDrainTasksByDisplayID[displayID] = nil - guard let record = sessionsByDisplayID[displayID] else { return } - guard record.state == .draining else { return } - guard record.previewTokens.isEmpty, record.shareTokens.isEmpty else { - var resumed = record - resumed.state = .active - sessionsByDisplayID[displayID] = resumed + private func applyDemand(for displayID: CGDirectDisplayID) async throws { + guard let record = sessionStore.record(for: displayID), record.state != .draining else { return } - sessionsByDisplayID.removeValue(forKey: displayID) + try await record.session.setDemand( + leaseBook.demandSnapshot(for: displayID, performanceMode: performanceMode) + ) } - private func recordPendingCreationDemand( - for displayID: CGDirectDisplayID, - kind: TokenKind, - delta: Int - ) { - var demand = pendingCreationDemandByDisplayID[displayID] ?? PendingCreationDemand() - demand.record(kind, delta: delta) - if demand.isEmpty { - pendingCreationDemandByDisplayID.removeValue(forKey: displayID) - } else { - pendingCreationDemandByDisplayID[displayID] = demand - } + private func finishDrainingSession(displayID: CGDirectDisplayID) { + sessionStore.finishDraining( + displayID: displayID, + hasActiveTokens: leaseBook.hasActiveTokens(for: displayID) + ) } - private func resolveInitialProfileForPendingCreation( - displayID: CGDirectDisplayID, - fallbackKind: TokenKind + private func initialProfile( + for displayID: CGDirectDisplayID, + fallbackKind: DisplayCaptureLeaseBook.TokenKind ) async -> DisplayCaptureProfile { - await Task.yield() - if let profile = pendingCreationDemandByDisplayID[displayID]?.initialProfile { - return profile - } - switch fallbackKind { - case .preview: - return .previewOnly - case .share: - return .shareOnly - } + leaseBook.initialProfile(for: displayID, fallbackKind: fallbackKind) } } diff --git a/VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift b/VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift index 0ab18b1..bb05dc5 100644 --- a/VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift +++ b/VoidDisplay/Features/Capture/Services/DisplayCaptureSession.swift @@ -46,6 +46,10 @@ private struct DisplayCaptureMetrics: Sendable { } } +private final class DisplayCaptureMetricsStore: Sendable { + let value = Mutex(DisplayCaptureMetrics()) +} + nonisolated struct DisplayCaptureStreamConfigurationState: Sendable, Equatable { let width: Int let height: Int @@ -122,21 +126,10 @@ actor DisplayCaptureStreamConfigurationCoordinator { self.desiredState = initialState } - func setPreviewShowsCursor(_ showsCursor: Bool) async throws -> Bool { - try await applyMutation { state in - state.previewShowsCursor = showsCursor - } - } - - func retainShareCursorOverride() async throws -> Bool { + func applyImmediateDemand(_ demand: DisplayCaptureDemandSnapshot) async throws -> Bool { try await applyMutation { state in - state.shareCursorOverrideCount += 1 - } - } - - func releaseShareCursorOverride() async throws -> Bool { - try await applyMutation { state in - state.shareCursorOverrideCount = max(0, state.shareCursorOverrideCount - 1) + state.previewShowsCursor = demand.previewShowsCursor + state.shareCursorOverrideCount = demand.shareCursorOverrideCount } } @@ -304,14 +297,6 @@ actor DisplayCaptureStreamConfigurationCoordinator { } final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning { - private struct DemandState { - var configurationCoordinator: DisplayCaptureConfigurationCoordinatorState - var taskLifetime = DisplayCaptureTaskLifetimeState() - var pendingTaskNonce: UInt64 = 0 - var pendingConfigurationTask: Task? - var activeApplyTask: Task? - } - nonisolated private static let minimumConfigurationDwellNanoseconds: UInt64 = 5_000_000_000 nonisolated let displayID: CGDirectDisplayID @@ -321,9 +306,9 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning private let output = DisplayStreamOutput() nonisolated private let captureQueue: DispatchQueue nonisolated private let fanout = DisplaySampleFanout() - nonisolated private let metrics = Mutex(DisplayCaptureMetrics()) + nonisolated private let metrics: DisplayCaptureMetricsStore nonisolated private let streamConfigurationCoordinator: DisplayCaptureStreamConfigurationCoordinator - nonisolated private let demandState: Mutex + nonisolated private let demandDriver: DisplayCaptureDemandDriver nonisolated init( display: SCDisplay, @@ -346,26 +331,50 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning let filter = try await Self.makeContentFilter(display: display) self.stream = SCStream(filter: filter, configuration: config, delegate: output) self.sessionHub = WebRTCSessionHub() - self.streamConfigurationCoordinator = DisplayCaptureStreamConfigurationCoordinator( + let metrics = DisplayCaptureMetricsStore() + self.metrics = metrics + let streamConfigurationCoordinator = DisplayCaptureStreamConfigurationCoordinator( stream: self.stream, initialState: state ) - self.demandState = Mutex( - DemandState( - configurationCoordinator: DisplayCaptureConfigurationCoordinatorState( - committedConfiguration: .init( - profile: state.profile, - frameRateTier: state.frameRateTier - ), - performanceMode: initialPerformanceMode + self.streamConfigurationCoordinator = streamConfigurationCoordinator + self.demandDriver = DisplayCaptureDemandDriver( + initialConfiguration: .init( + profile: state.profile, + frameRateTier: state.frameRateTier + ), + initialDemand: DisplayCaptureDemandSnapshot( + performanceMode: initialPerformanceMode + ), + minimumDwellNanoseconds: Self.minimumConfigurationDwellNanoseconds, + applyImmediateDemand: { demand in + let changed = try await streamConfigurationCoordinator.applyImmediateDemand(demand) + guard changed else { return false } + metrics.value.withLock { $0.cursorOverrideReconfigurationCount &+= 1 } + return true + }, + applyConfiguration: { configuration in + try await streamConfigurationCoordinator.applyDemandDrivenConfiguration(configuration) + }, + onConfigurationApplied: { configuration in + metrics.value.withLock { metrics in + metrics.currentProfile = configuration.profile + metrics.currentFrameRateTier = configuration.frameRateTier + metrics.profileReconfigurationCount &+= 1 + } + }, + onConfigurationFailure: { error in + AppErrorMapper.logFailure( + "Update capture configuration", + error: error, + logger: AppLog.capture ) - ) + } ) - self.metrics.withLock { + metrics.value.withLock { $0.currentProfile = state.profile $0.currentFrameRateTier = state.frameRateTier } - output.session = self try stream.addStreamOutput(output, type: .screen, sampleHandlerQueue: captureQueue) @@ -374,69 +383,30 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { fanout.attachPreviewSink(sink) - scheduleDemandUpdate { state in - state.previewSinkCount += 1 - } } nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { fanout.detachPreviewSink(sink) - scheduleDemandUpdate { state in - state.previewSinkCount = max( - 0, - state.previewSinkCount - 1 - ) - } } nonisolated func stopSharing() { sessionHub.stopSharing() } - nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { - let changed = try await streamConfigurationCoordinator.setPreviewShowsCursor(showsCursor) - guard changed else { return } - metrics.withLock { $0.cursorOverrideReconfigurationCount &+= 1 } - } - - nonisolated func retainShareCursorOverride() async throws { - let changed = try await streamConfigurationCoordinator.retainShareCursorOverride() - guard changed else { return } - metrics.withLock { $0.cursorOverrideReconfigurationCount &+= 1 } - } - - nonisolated func releaseShareCursorOverride() async throws { - let changed = try await streamConfigurationCoordinator.releaseShareCursorOverride() - guard changed else { return } - metrics.withLock { $0.cursorOverrideReconfigurationCount &+= 1 } - } - - nonisolated func setSharingActive(_ isActive: Bool) async throws { - scheduleDemandUpdate { state in - state.sharingActive = isActive - } - } - - nonisolated func setPerformanceMode(_ mode: CapturePerformanceMode) async throws { - schedulePerformanceModeUpdate(mode) + nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws { + try await demandDriver.setDemand(demand) } nonisolated func reportPreviewPerformanceSample(_ sample: DisplayPreviewPerformanceSample) { - schedulePreviewPerformanceSample(sample) + demandDriver.recordPreviewPerformanceSample(sample) } nonisolated func captureMetricsSnapshot() -> DisplayCaptureMetricsSnapshot { - metrics.withLock { $0.snapshot() } + metrics.value.withLock { $0.snapshot() } } nonisolated func stop() async { - demandState.withLock { state in - _ = state.taskLifetime.invalidateAllTasks() - state.pendingConfigurationTask?.cancel() - state.pendingConfigurationTask = nil - state.activeApplyTask?.cancel() - state.activeApplyTask = nil - } + demandDriver.cancelAll() await streamConfigurationCoordinator.cancelPending() stopSharing() try? await stream.stopCapture() @@ -444,7 +414,7 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning nonisolated func handle(sampleBuffer: CMSampleBuffer, type: SCStreamOutputType) { guard type == .screen, let pixelBuffer = sampleBuffer.imageBuffer else { return } - metrics.withLock { $0.receivedFrameCount &+= 1 } + metrics.value.withLock { $0.receivedFrameCount &+= 1 } fanout.publishPreviewFrame(sampleBuffer) @@ -452,173 +422,6 @@ final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSessioning let ptsUs = Self.microseconds(from: CMSampleBufferGetPresentationTimeStamp(sampleBuffer)) sessionHub.submitFrame(pixelBuffer: pixelBuffer, ptsUs: ptsUs) } - - nonisolated private func scheduleDemandUpdate( - _ mutation: (inout DisplayCaptureConfigurationCoordinatorState) -> Void - ) { - scheduleConfigurationDecision { state in - state.configurationCoordinator.mutateDemand( - nowNs: Self.currentTimeNanoseconds(), - minimumDwellNanoseconds: Self.minimumConfigurationDwellNanoseconds, - mutation: mutation - ) - } - } - - nonisolated private func schedulePerformanceModeUpdate(_ mode: CapturePerformanceMode) { - scheduleConfigurationDecision { state in - state.configurationCoordinator.updatePerformanceMode( - mode, - nowNs: Self.currentTimeNanoseconds(), - minimumDwellNanoseconds: Self.minimumConfigurationDwellNanoseconds - ) - } - } - - nonisolated private func schedulePreviewPerformanceSample(_ sample: DisplayPreviewPerformanceSample) { - scheduleConfigurationDecision { state in - state.configurationCoordinator.recordPreviewPerformanceSample( - sample, - nowNs: Self.currentTimeNanoseconds(), - minimumDwellNanoseconds: Self.minimumConfigurationDwellNanoseconds - ) - } - } - - nonisolated private func scheduleConfigurationDecision( - _ decisionProvider: (inout DemandState) -> DisplayCaptureConfigurationDecision - ) { - let decision = demandState.withLock { state -> (DisplayCaptureConfigurationDecision, UInt64) in - state.pendingConfigurationTask?.cancel() - state.pendingConfigurationTask = nil - state.pendingTaskNonce &+= 1 - let decision = decisionProvider(&state) - return (decision, state.pendingTaskNonce) - } - handleConfigurationDecision(decision.0, schedulingNonce: decision.1) - } - - nonisolated private func handleConfigurationDecision( - _ decision: DisplayCaptureConfigurationDecision, - schedulingNonce: UInt64 - ) { - switch decision { - case .noChange: - return - case .applyNow(let configuration): - let executionGeneration = demandState.withLock { $0.taskLifetime.currentGeneration } - let task = Task { [weak self] in - guard let self else { return } - try? await self.applyDemandDrivenConfiguration( - configuration: configuration, - executionGeneration: executionGeneration - ) - } - demandState.withLock { state in - if state.taskLifetime.allowsExecution(for: executionGeneration) { - state.activeApplyTask = task - } else { - task.cancel() - } - } - case .applyAfter(_, let delayNanoseconds): - let task = Task { [weak self] in - try? await Task.sleep(nanoseconds: delayNanoseconds) - self?.resumeDemandDrivenConfigurationEvaluation(schedulingNonce: schedulingNonce) - } - demandState.withLock { state in - if state.pendingTaskNonce == schedulingNonce { - state.pendingConfigurationTask = task - } else { - task.cancel() - } - } - } - } - - nonisolated private func resumeDemandDrivenConfigurationEvaluation(schedulingNonce: UInt64) { - let decision = demandState.withLock { state -> (DisplayCaptureConfigurationDecision, UInt64)? in - guard state.pendingTaskNonce == schedulingNonce else { - return nil - } - state.pendingConfigurationTask = nil - state.pendingTaskNonce &+= 1 - let decision = state.configurationCoordinator.resumeScheduledTransition( - nowNs: Self.currentTimeNanoseconds(), - minimumDwellNanoseconds: Self.minimumConfigurationDwellNanoseconds - ) - return (decision, state.pendingTaskNonce) - } - guard let decision else { return } - handleConfigurationDecision(decision.0, schedulingNonce: decision.1) - } - - nonisolated private func applyDemandDrivenConfiguration( - configuration: DisplayCaptureConfiguration, - executionGeneration: UInt64 - ) async throws { - guard isExecutionAllowed(for: executionGeneration) else { return } - - let changed: Bool - - do { - try Task.checkCancellation() - guard isExecutionAllowed(for: executionGeneration) else { return } - - changed = try await streamConfigurationCoordinator.applyDemandDrivenConfiguration(configuration) - } catch is CancellationError { - finishDemandDrivenConfigurationFailure(executionGeneration: executionGeneration) - return - } catch { - finishDemandDrivenConfigurationFailure(executionGeneration: executionGeneration) - AppErrorMapper.logFailure("Update capture configuration", error: error, logger: AppLog.capture) - return - } - - guard changed else { - finishDemandDrivenConfigurationFailure(executionGeneration: executionGeneration) - return - } - - guard isExecutionAllowed(for: executionGeneration) else { return } - - metrics.withLock { metrics in - metrics.currentProfile = configuration.profile - metrics.currentFrameRateTier = configuration.frameRateTier - metrics.profileReconfigurationCount &+= 1 - } - - let decision = demandState.withLock { state -> (DisplayCaptureConfigurationDecision, UInt64)? in - guard state.taskLifetime.allowsExecution(for: executionGeneration) else { - return nil - } - state.pendingConfigurationTask?.cancel() - state.pendingConfigurationTask = nil - state.activeApplyTask = nil - state.pendingTaskNonce &+= 1 - let decision = state.configurationCoordinator.finishAppliedTransition( - at: Self.currentTimeNanoseconds(), - minimumDwellNanoseconds: Self.minimumConfigurationDwellNanoseconds - ) - return (decision, state.pendingTaskNonce) - } - guard let decision else { return } - handleConfigurationDecision(decision.0, schedulingNonce: decision.1) - } - - nonisolated private func isExecutionAllowed(for generation: UInt64) -> Bool { - demandState.withLock { state in - state.taskLifetime.allowsExecution(for: generation) - } - } - - nonisolated private func finishDemandDrivenConfigurationFailure(executionGeneration: UInt64) { - demandState.withLock { state in - guard state.taskLifetime.allowsExecution(for: executionGeneration) else { return } - state.activeApplyTask = nil - state.configurationCoordinator.failAppliedTransition() - } - } } extension DisplayCaptureSession { @@ -643,10 +446,6 @@ extension DisplayCaptureSession { return scaled.value > 0 ? UInt64(scaled.value) : 0 } - nonisolated private static func currentTimeNanoseconds() -> UInt64 { - DispatchTime.now().uptimeNanoseconds - } - nonisolated static func clampedPreviewFramesPerSecond(for refreshRate: Double) -> Int { let normalizedRefreshRate = refreshRate > 0 ? refreshRate : 60.0 return max(1, Int(min(normalizedRefreshRate, 60.0).rounded())) diff --git a/VoidDisplay/Features/Capture/Services/DisplayCaptureTypes.swift b/VoidDisplay/Features/Capture/Services/DisplayCaptureTypes.swift index bd1c27b..37e3141 100644 --- a/VoidDisplay/Features/Capture/Services/DisplayCaptureTypes.swift +++ b/VoidDisplay/Features/Capture/Services/DisplayCaptureTypes.swift @@ -57,12 +57,43 @@ nonisolated enum DisplayCaptureProfileDecision: Sendable, Equatable { case applyAfter(DisplayCaptureProfile, delayNanoseconds: UInt64) } +nonisolated struct DisplayCaptureDemandSnapshot: Sendable, Equatable { + var attachedPreviewSinkCount: Int + var shareTokenCount: Int + var previewShowsCursor: Bool + var shareCursorOverrideCount: Int + var performanceMode: CapturePerformanceMode + + init( + attachedPreviewSinkCount: Int = 0, + shareTokenCount: Int = 0, + previewShowsCursor: Bool = false, + shareCursorOverrideCount: Int = 0, + performanceMode: CapturePerformanceMode + ) { + self.attachedPreviewSinkCount = max(0, attachedPreviewSinkCount) + self.shareTokenCount = max(0, shareTokenCount) + self.previewShowsCursor = previewShowsCursor + self.shareCursorOverrideCount = max(0, shareCursorOverrideCount) + self.performanceMode = performanceMode + } + + nonisolated var desiredProfile: DisplayCaptureProfile? { + DisplayCaptureProfileStateMachine.desiredProfile(for: self) + } + + nonisolated var showsCursor: Bool { + previewShowsCursor || shareCursorOverrideCount > 0 + } + + nonisolated var isEmpty: Bool { + attachedPreviewSinkCount == 0 && shareTokenCount == 0 && !showsCursor + } +} + nonisolated enum DisplayCaptureProfileStateMachine { - nonisolated static func desiredProfile( - previewSinkCount: Int, - sharingActive: Bool - ) -> DisplayCaptureProfile? { - switch (previewSinkCount > 0, sharingActive) { + nonisolated static func desiredProfile(for demand: DisplayCaptureDemandSnapshot) -> DisplayCaptureProfile? { + switch (demand.attachedPreviewSinkCount > 0, demand.shareTokenCount > 0) { case (true, false): .previewOnly case (false, true): @@ -75,17 +106,13 @@ nonisolated enum DisplayCaptureProfileStateMachine { } nonisolated static func decideTransition( - previewSinkCount: Int, - sharingActive: Bool, + demand: DisplayCaptureDemandSnapshot, currentProfile: DisplayCaptureProfile, lastProfileSwitchTimeNs: UInt64?, nowNs: UInt64, minimumDwellNanoseconds: UInt64 ) -> DisplayCaptureProfileDecision { - guard let desiredProfile = desiredProfile( - previewSinkCount: previewSinkCount, - sharingActive: sharingActive - ) else { + guard let desiredProfile = desiredProfile(for: demand) else { return .noChange } guard desiredProfile != currentProfile else { @@ -107,26 +134,27 @@ nonisolated enum DisplayCaptureProfileStateMachine { } nonisolated struct DisplayCaptureProfileCoordinatorState: Sendable { - var previewSinkCount: Int = 0 - var sharingActive = false + var demand: DisplayCaptureDemandSnapshot var committedProfile: DisplayCaptureProfile var inFlightProfile: DisplayCaptureProfile? var lastProfileSwitchTimeNs: UInt64? nonisolated init( committedProfile: DisplayCaptureProfile, + demand: DisplayCaptureDemandSnapshot, lastProfileSwitchTimeNs: UInt64? = nil ) { + self.demand = demand self.committedProfile = committedProfile self.lastProfileSwitchTimeNs = lastProfileSwitchTimeNs } - nonisolated mutating func mutateDemand( + nonisolated mutating func updateDemand( + _ demand: DisplayCaptureDemandSnapshot, nowNs: UInt64, - minimumDwellNanoseconds: UInt64, - mutation: (inout DisplayCaptureProfileCoordinatorState) -> Void + minimumDwellNanoseconds: UInt64 ) -> DisplayCaptureProfileDecision { - mutation(&self) + self.demand = demand return evaluateTransition( nowNs: nowNs, minimumDwellNanoseconds: minimumDwellNanoseconds @@ -170,8 +198,7 @@ nonisolated struct DisplayCaptureProfileCoordinatorState: Sendable { } let decision = DisplayCaptureProfileStateMachine.decideTransition( - previewSinkCount: previewSinkCount, - sharingActive: sharingActive, + demand: demand, currentProfile: committedProfile, lastProfileSwitchTimeNs: lastProfileSwitchTimeNs, nowNs: nowNs, @@ -320,22 +347,17 @@ nonisolated enum DisplayCaptureConfigurationStateMachine { } nonisolated static func desiredConfiguration( - previewSinkCount: Int, - sharingActive: Bool, - performanceMode: CapturePerformanceMode, + for demand: DisplayCaptureDemandSnapshot, adaptivePolicy: DisplayCaptureAdaptivePolicyState ) -> DisplayCaptureConfiguration? { - guard let profile = DisplayCaptureProfileStateMachine.desiredProfile( - previewSinkCount: previewSinkCount, - sharingActive: sharingActive - ) else { + guard let profile = demand.desiredProfile else { return nil } return DisplayCaptureConfiguration( profile: profile, frameRateTier: defaultFrameRateTier( for: profile, - performanceMode: performanceMode, + performanceMode: demand.performanceMode, adaptivePolicy: adaptivePolicy ) ) @@ -366,9 +388,7 @@ nonisolated enum DisplayCaptureConfigurationStateMachine { } nonisolated struct DisplayCaptureConfigurationCoordinatorState: Sendable, Equatable { - var previewSinkCount = 0 - var sharingActive = false - var performanceMode: CapturePerformanceMode + var demand: DisplayCaptureDemandSnapshot var adaptivePolicy = DisplayCaptureAdaptivePolicyState() var committedConfiguration: DisplayCaptureConfiguration var inFlightConfiguration: DisplayCaptureConfiguration? @@ -376,43 +396,27 @@ nonisolated struct DisplayCaptureConfigurationCoordinatorState: Sendable, Equata init( committedConfiguration: DisplayCaptureConfiguration, - performanceMode: CapturePerformanceMode, + demand: DisplayCaptureDemandSnapshot, lastConfigurationSwitchTimeNs: UInt64? = nil ) { - self.performanceMode = performanceMode + self.demand = demand self.committedConfiguration = committedConfiguration self.lastConfigurationSwitchTimeNs = lastConfigurationSwitchTimeNs adaptivePolicy.rebase( desiredProfile: committedConfiguration.profile, - performanceMode: performanceMode + performanceMode: demand.performanceMode ) } - mutating func mutateDemand( - nowNs: UInt64, - minimumDwellNanoseconds: UInt64, - mutation: (inout DisplayCaptureConfigurationCoordinatorState) -> Void - ) -> DisplayCaptureConfigurationDecision { - mutation(&self) - adaptivePolicy.rebase( - desiredProfile: currentDesiredProfile, - performanceMode: performanceMode - ) - return evaluateTransition( - nowNs: nowNs, - minimumDwellNanoseconds: minimumDwellNanoseconds - ) - } - - mutating func updatePerformanceMode( - _ mode: CapturePerformanceMode, + mutating func updateDemand( + _ demand: DisplayCaptureDemandSnapshot, nowNs: UInt64, minimumDwellNanoseconds: UInt64 ) -> DisplayCaptureConfigurationDecision { - performanceMode = mode + self.demand = demand adaptivePolicy.rebase( desiredProfile: currentDesiredProfile, - performanceMode: mode + performanceMode: demand.performanceMode ) return evaluateTransition( nowNs: nowNs, @@ -426,8 +430,8 @@ nonisolated struct DisplayCaptureConfigurationCoordinatorState: Sendable, Equata minimumDwellNanoseconds: UInt64 ) -> DisplayCaptureConfigurationDecision { let desiredProfile = currentDesiredProfile - adaptivePolicy.rebase(desiredProfile: desiredProfile, performanceMode: performanceMode) - guard desiredProfile == .mixed, performanceMode == .automatic else { + adaptivePolicy.rebase(desiredProfile: desiredProfile, performanceMode: demand.performanceMode) + guard desiredProfile == .mixed, demand.performanceMode == .automatic else { return evaluateTransition( nowNs: nowNs, minimumDwellNanoseconds: minimumDwellNanoseconds @@ -460,7 +464,7 @@ nonisolated struct DisplayCaptureConfigurationCoordinatorState: Sendable, Equata lastConfigurationSwitchTimeNs = nowNs adaptivePolicy.rebase( desiredProfile: committedConfiguration.profile, - performanceMode: performanceMode + performanceMode: demand.performanceMode ) return evaluateTransition( nowNs: nowNs, @@ -473,10 +477,7 @@ nonisolated struct DisplayCaptureConfigurationCoordinatorState: Sendable, Equata } private var currentDesiredProfile: DisplayCaptureProfile? { - DisplayCaptureProfileStateMachine.desiredProfile( - previewSinkCount: previewSinkCount, - sharingActive: sharingActive - ) + demand.desiredProfile } private mutating func evaluateTransition( @@ -488,9 +489,7 @@ nonisolated struct DisplayCaptureConfigurationCoordinatorState: Sendable, Equata } let decision = DisplayCaptureConfigurationStateMachine.decideTransition( desiredConfiguration: DisplayCaptureConfigurationStateMachine.desiredConfiguration( - previewSinkCount: previewSinkCount, - sharingActive: sharingActive, - performanceMode: performanceMode, + for: demand, adaptivePolicy: adaptivePolicy ), currentConfiguration: committedConfiguration, @@ -537,11 +536,7 @@ protocol DisplayCaptureSessioning: AnyObject, Sendable { nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) nonisolated func reportPreviewPerformanceSample(_ sample: DisplayPreviewPerformanceSample) nonisolated func stopSharing() - nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws - nonisolated func retainShareCursorOverride() async throws - nonisolated func releaseShareCursorOverride() async throws - nonisolated func setSharingActive(_ isActive: Bool) async throws - nonisolated func setPerformanceMode(_ mode: CapturePerformanceMode) async throws + nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws nonisolated func captureMetricsSnapshot() -> DisplayCaptureMetricsSnapshot nonisolated func stop() async } @@ -551,12 +546,8 @@ extension DisplayCaptureSessioning { _ = sample } - nonisolated func setSharingActive(_ isActive: Bool) async throws { - _ = isActive - } - - nonisolated func setPerformanceMode(_ mode: CapturePerformanceMode) async throws { - _ = mode + nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws { + _ = demand } nonisolated func captureMetricsSnapshot() -> DisplayCaptureMetricsSnapshot { diff --git a/VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift b/VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift index 2ed8201..5f4875e 100644 --- a/VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift +++ b/VoidDisplay/Features/Capture/ViewModels/CaptureChooseViewModel.swift @@ -8,9 +8,6 @@ import OSLog @MainActor @Observable final class CaptureChooseViewModel { - typealias LoadErrorInfo = ScreenCaptureDisplayCatalogLoadErrorInfo - typealias RefreshIntent = ScreenCaptureCatalogRefreshIntent - struct CaptureActions { var monitoringSessionForDisplayID: @MainActor (CGDirectDisplayID) -> ScreenMonitoringSession? var isStartingDisplayID: @MainActor (CGDirectDisplayID) -> Bool @@ -57,30 +54,18 @@ final class CaptureChooseViewModel { let catalog: ScreenCaptureDisplayCatalogState var userFacingAlert: UserFacingAlertState? - private let catalogService: ScreenCaptureCatalogService - @ObservationIgnored private let refreshOwner = ScreenCaptureCatalogService.RefreshOwner() + @ObservationIgnored private let activeDisplayIDsProvider: @MainActor () -> Set @ObservationIgnored private let dependencies: Dependencies init( - catalogService: ScreenCaptureCatalogService? = nil, catalogState: ScreenCaptureDisplayCatalogState? = nil, - permissionProvider: (any ScreenCapturePermissionProvider)? = nil, - loadShareableDisplays: (@MainActor () async throws -> [SCDisplay])? = nil, activeDisplayIDsProvider: @escaping @MainActor () -> Set = { Set(NSScreen.screens.compactMap(\.cgDirectDisplayID)) }, dependencies: Dependencies ) { - let resolvedCatalogService = catalogService ?? ScreenCaptureCatalogService( - store: catalogState, - permissionProvider: permissionProvider, - loadShareableDisplays: loadShareableDisplays, - activeDisplayIDsProvider: activeDisplayIDsProvider, - logOperation: "Load shareable displays", - logger: AppLog.capture - ) - self.catalogService = resolvedCatalogService - self.catalog = resolvedCatalogService.store + self.catalog = catalogState ?? ScreenCaptureDisplayCatalogState() + self.activeDisplayIDsProvider = activeDisplayIDsProvider self.dependencies = dependencies } @@ -99,7 +84,8 @@ final class CaptureChooseViewModel { } func visibleDisplays(from displays: [SCDisplay]) -> [SCDisplay] { - catalogService.visibleDisplays(from: displays) + let activeDisplayIDs = activeDisplayIDsProvider() + return displays.filter { activeDisplayIDs.contains($0.displayID) } } func isStarting(displayID: CGDirectDisplayID) -> Bool { @@ -145,46 +131,4 @@ final class CaptureChooseViewModel { func dismissAlert() { userFacingAlert = nil } - - func openScreenCapturePrivacySettings(openURL: (URL) -> Void) { - catalogService.openScreenCapturePrivacySettings(openURL: openURL) - } - - func requestScreenCapturePermission() { - let granted = catalogService.requestPermission() - if !granted { - Task { await catalogService.clearSnapshotForDeniedPermission() } - AppLog.capture.notice("Screen capture permission request denied.") - return - } - Task { await self.submitRefresh(.permissionChanged) } - } - - func refreshPermissionAndMaybeLoad() { - let granted = catalogService.refreshPermission() - if !granted { - Task { await catalogService.clearSnapshotForDeniedPermission() } - AppLog.capture.notice("Screen capture permission preflight denied.") - return - } - Task { await self.submitRefresh(.permissionChanged) } - } - - func loadDisplays() { - Task { await self.submitRefresh(.userForcedRefresh) } - } - - func refreshDisplaysBackgroundSafe() { - guard catalog.hasScreenCapturePermission == true else { return } - guard !catalog.isLoadingDisplays else { return } - Task { await self.submitRefresh(.topologyChanged) } - } - - func cancelInFlightDisplayLoad() { - Task { await catalogService.cancelRefresh(owner: refreshOwner) } - } - - private func submitRefresh(_ intent: RefreshIntent) async { - _ = await catalogService.submitRefresh(intent: intent, owner: refreshOwner) - } } diff --git a/VoidDisplay/Features/Capture/Views/CaptureChoose.swift b/VoidDisplay/Features/Capture/Views/CaptureChoose.swift index 3e78fc2..8b96144 100644 --- a/VoidDisplay/Features/Capture/Views/CaptureChoose.swift +++ b/VoidDisplay/Features/Capture/Views/CaptureChoose.swift @@ -15,23 +15,23 @@ struct IsCapturing: View { @Environment(SharingController.self) private var sharing @Environment(\.openWindow) var openWindow @Environment(\.openURL) private var openURL - private let topologyCoordinator: DisplayTopologyChangeCoordinator + private let screenCatalogOrchestrator: ScreenCatalogOrchestrator init( capture: CaptureController, virtualDisplay: VirtualDisplayController, - topologyCoordinator: DisplayTopologyChangeCoordinator, + screenCatalogOrchestrator: ScreenCatalogOrchestrator, lifecycle: DisplayTopologyRefreshLifecycleController = DisplayTopologyRefreshLifecycleController() ) { _capture = Bindable(capture) _viewModel = State( initialValue: CaptureChooseViewModel( - catalogService: capture.catalogService, + catalogState: capture.displayCatalogState, dependencies: .live(capture: capture, virtualDisplay: virtualDisplay) ) ) _lifecycle = State(initialValue: lifecycle) - self.topologyCoordinator = topologyCoordinator + self.screenCatalogOrchestrator = screenCatalogOrchestrator } private var shouldShowActiveSessionFallback: Bool { @@ -59,14 +59,14 @@ struct IsCapturing: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { - viewModel.refreshPermissionAndMaybeLoad() + Task { await screenCatalogOrchestrator.handleAppear(source: .capturePage) } lifecycle.handleAppear { guard viewModel.catalog.hasScreenCapturePermission == true else { return } - topologyCoordinator.handleTopologyChange(source: .captureView) + Task { await screenCatalogOrchestrator.handleTopologyChanged() } } } .onDisappear { - viewModel.cancelInFlightDisplayLoad() + Task { await screenCatalogOrchestrator.handleDisappear(source: .capturePage) } lifecycle.handleDisappear() } .accessibilityElement(children: .contain) @@ -137,7 +137,7 @@ struct IsCapturing: View { .textSelection(.enabled) } Button("Retry") { - viewModel.refreshPermissionAndMaybeLoad() + Task { await screenCatalogOrchestrator.refreshPermission(source: .capturePage) } } } } @@ -231,18 +231,18 @@ struct IsCapturing: View { ScreenCapturePermissionGuideView( loadErrorMessage: viewModel.catalog.loadErrorMessage, onOpenSettings: { - viewModel.openScreenCapturePrivacySettings { url in + screenCatalogOrchestrator.openScreenCapturePrivacySettings { url in openURL(url) } }, onRequestPermission: { - viewModel.requestScreenCapturePermission() + Task { await screenCatalogOrchestrator.requestPermission(source: .capturePage) } }, onRefresh: { - viewModel.refreshPermissionAndMaybeLoad() + Task { await screenCatalogOrchestrator.refreshPermission(source: .capturePage) } }, onRetry: (viewModel.catalog.loadErrorMessage != nil || viewModel.catalog.lastLoadError != nil) ? { - viewModel.loadDisplays() + Task { await screenCatalogOrchestrator.forceRefresh(source: .capturePage) } } : nil, isDebugInfoExpanded: $bindableCatalog.showDebugInfo, debugItems: capturePermissionDebugItems, @@ -304,10 +304,9 @@ struct IsCapturing: View { IsCapturing( capture: env.capture, virtualDisplay: env.virtualDisplay, - topologyCoordinator: env.topology + screenCatalogOrchestrator: env.screenCatalog ) .environment(env.capture) .environment(env.sharing) .environment(env.virtualDisplay) - .environment(env.topology) } diff --git a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift index 160b026..9dca79e 100644 --- a/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift +++ b/VoidDisplay/Features/Capture/Views/CaptureDisplayView.swift @@ -1,7 +1,5 @@ import AppKit -import AVFoundation import SwiftUI -import Synchronization // MARK: - Capture Display View @@ -334,134 +332,6 @@ extension CaptureDisplayView { } } -// MARK: - Window Coordination - -private final class CapturePreviewWindowCoordinator: NSObject { - private weak var window: NSWindow? - nonisolated(unsafe) private var forwardedDelegate: (any NSWindowDelegate)? - private var aspect = CGSize.zero - private var shouldLockAspect = true - - func attach(to window: NSWindow) { - guard self.window !== window else { return } - restoreWindowDelegate() - self.window = window - if let delegate = window.delegate, delegate !== self { - forwardedDelegate = delegate - } else { - forwardedDelegate = nil - } - window.delegate = self - } - - func update(aspect: CGSize, shouldLockAspect: Bool) { - self.aspect = aspect - self.shouldLockAspect = shouldLockAspect - } - - func snapWindowToAspect(_ window: NSWindow) { - guard shouldLockAspect, aspect.width > 0, aspect.height > 0 else { return } - let targetSize = aspectLockedFrameSize(for: window, proposedFrameSize: window.frame.size) - guard abs(targetSize.width - window.frame.width) > 0.5 - || abs(targetSize.height - window.frame.height) > 0.5 else { return } - - var newFrame = window.frame - newFrame.origin.x += (newFrame.width - targetSize.width) / 2 - newFrame.origin.y += (newFrame.height - targetSize.height) / 2 - newFrame.size = targetSize - window.setFrame(newFrame, display: true, animate: false) - } - - func tearDown() { - restoreWindowDelegate() - window = nil - forwardedDelegate = nil - } - - private func aspectLockedFrameSize(for window: NSWindow, proposedFrameSize: NSSize) -> NSSize { - guard let targetContentSize = aspectLockedContentSize( - for: window, - proposedFrameSize: proposedFrameSize - ) else { - return proposedFrameSize - } - - let targetContentRect = NSRect(origin: .zero, size: targetContentSize) - return window.frameRect(forContentRect: targetContentRect).size - } - - private func aspectLockedContentSize(for window: NSWindow, proposedFrameSize: NSSize) -> NSSize? { - let currentContentRect = window.contentRect(forFrameRect: window.frame) - let currentLayoutRect = window.contentLayoutRect - let proposedContentRect = window.contentRect( - forFrameRect: NSRect(origin: .zero, size: proposedFrameSize) - ) - let targetContentSize = CapturePreviewGeometry.aspectLockedContentSize( - aspect: aspect, - proposedContentSize: proposedContentRect.size, - layoutInsetSize: CGSize( - width: max(0, currentContentRect.width - currentLayoutRect.width), - height: max(0, currentContentRect.height - currentLayoutRect.height) - ), - scaleFactor: max(1, window.backingScaleFactor) - ) - guard let targetContentSize else { return nil } - return NSSize(width: targetContentSize.width, height: targetContentSize.height) - } - - private func restoreWindowDelegate() { - guard let window, window.delegate === self else { return } - window.delegate = forwardedDelegate - } -} - -extension CapturePreviewWindowCoordinator: NSWindowDelegate { - nonisolated override func responds(to aSelector: Selector!) -> Bool { - super.responds(to: aSelector) || (forwardedDelegate?.responds(to: aSelector) ?? false) - } - - nonisolated override func forwardingTarget(for aSelector: Selector!) -> Any? { - if forwardedDelegate?.responds(to: aSelector) == true { - return forwardedDelegate - } - return super.forwardingTarget(for: aSelector) - } - - func windowWillResize(_ sender: NSWindow, to frameSize: NSSize) -> NSSize { - let proposedFrameSize = forwardedDelegate?.windowWillResize?(sender, to: frameSize) ?? frameSize - guard shouldLockAspect, aspect.width > 0, aspect.height > 0 else { - return proposedFrameSize - } - return aspectLockedFrameSize(for: sender, proposedFrameSize: proposedFrameSize) - } - - func windowWillUseStandardFrame(_ window: NSWindow, defaultFrame newFrame: NSRect) -> NSRect { - let proposedFrame = forwardedDelegate?.windowWillUseStandardFrame?(window, defaultFrame: newFrame) - ?? newFrame - guard shouldLockAspect, aspect.width > 0, aspect.height > 0 else { - return proposedFrame - } - - let targetSize = aspectLockedFrameSize(for: window, proposedFrameSize: proposedFrame.size) - var adjustedFrame = proposedFrame - adjustedFrame.origin.x += (proposedFrame.width - targetSize.width) / 2 - adjustedFrame.origin.y += (proposedFrame.height - targetSize.height) / 2 - adjustedFrame.size = targetSize - return adjustedFrame - } - - func window( - _ window: NSWindow, - willUseFullScreenPresentationOptions proposedOptions: NSApplication.PresentationOptions - ) -> NSApplication.PresentationOptions { - let forwardedOptions = forwardedDelegate?.window?( - window, - willUseFullScreenPresentationOptions: proposedOptions - ) ?? proposedOptions - return forwardedOptions.union(.autoHideToolbar) - } -} - // MARK: - Scroll View Configuration private struct TransparentScrollViewConfigurator: NSViewRepresentable { @@ -491,210 +361,6 @@ private struct TransparentScrollViewConfigurator: NSViewRepresentable { } } -// MARK: - Zero-Copy Preview Renderer - -/// Renders captured frames via `AVSampleBufferDisplayLayer` with zero -/// pixel-data copies. The layer natively accepts `CMSampleBuffer` -/// backed by `IOSurface`, handling YUV→RGB conversion and colour -/// management entirely on the GPU. -@Observable -final class ZeroCopyPreviewRenderer: @unchecked Sendable, DisplayPreviewSink { - struct MetricsSnapshot: Sendable { - var receivedFrameCount: UInt64 - var renderedFrameCount: UInt64 - var droppedFrameCount: UInt64 - var latestRenderLatencyMilliseconds: Double? - var pendingSlotOccupied: Bool - } - - private struct PendingFrame { - let buffer: UncheckedSendableBuffer - let submittedAtNanoseconds: UInt64 - let generation: UInt64 - } - - private struct State { - var pendingFrame: PendingFrame? - var isDraining = false - var activeDrainToken: UInt64? - var nextDrainToken: UInt64 = 0 - var generation: UInt64 = 0 - var receivedFrameCount: UInt64 = 0 - var renderedFrameCount: UInt64 = 0 - var droppedFrameCount: UInt64 = 0 - var latestRenderLatencyMilliseconds: Double? - } - - var framePixelSize: CGSize = .zero - var hasReceivedFrame = false - - let displayLayer: AVSampleBufferDisplayLayer = { - let layer = AVSampleBufferDisplayLayer() - layer.videoGravity = .resizeAspect - layer.preventsDisplaySleepDuringVideoPlayback = false - return layer - }() - - nonisolated private let state = Mutex(State()) - @MainActor var willEnqueueFrameForTesting: (() -> Void)? - - nonisolated func submitFrame(_ sampleBuffer: CMSampleBuffer) { - let box = UncheckedSendableBuffer(sampleBuffer) - let drainToken = state.withLock { state -> UInt64? in - state.receivedFrameCount &+= 1 - if state.pendingFrame != nil { - state.droppedFrameCount &+= 1 - } - state.pendingFrame = PendingFrame( - buffer: box, - submittedAtNanoseconds: DispatchTime.now().uptimeNanoseconds, - generation: state.generation - ) - guard !state.isDraining else { return nil } - state.isDraining = true - state.nextDrainToken &+= 1 - state.activeDrainToken = state.nextDrainToken - return state.nextDrainToken - } - guard let drainToken else { return } - - Task { @MainActor [weak self] in - self?.drainLoop(drainToken: drainToken) - } - } - - func flush() { - state.withLock { state in - state.pendingFrame = nil - state.generation &+= 1 - state.activeDrainToken = nil - state.isDraining = false - } - displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true) - } - - nonisolated func metricsSnapshot() -> MetricsSnapshot { - state.withLock { state in - .init( - receivedFrameCount: state.receivedFrameCount, - renderedFrameCount: state.renderedFrameCount, - droppedFrameCount: state.droppedFrameCount, - latestRenderLatencyMilliseconds: state.latestRenderLatencyMilliseconds, - pendingSlotOccupied: state.pendingFrame != nil - ) - } - } - - @MainActor - private func drainLoop(drainToken: UInt64) { - while true { - let nextFrame = state.withLock { state -> PendingFrame? in - guard state.activeDrainToken == drainToken else { - return nil - } - guard let pendingFrame = state.pendingFrame else { - state.activeDrainToken = nil - state.isDraining = false - return nil - } - state.pendingFrame = nil - return pendingFrame - } - guard let nextFrame else { return } - - willEnqueueFrameForTesting?() - let shouldRender = state.withLock { state in - state.activeDrainToken == drainToken && state.generation == nextFrame.generation - } - guard shouldRender else { continue } - - let renderer = displayLayer.sampleBufferRenderer - if renderer.status == .failed { - renderer.flush() - } - renderer.enqueue(nextFrame.buffer.buffer) - - if !hasReceivedFrame { - hasReceivedFrame = true - } - - if let desc = CMSampleBufferGetFormatDescription(nextFrame.buffer.buffer) { - let dims = CMVideoFormatDescriptionGetDimensions(desc) - let size = CGSize(width: CGFloat(dims.width), height: CGFloat(dims.height)) - if framePixelSize != size { - framePixelSize = size - } - } - - let latencyMilliseconds = Double( - DispatchTime.now().uptimeNanoseconds &- nextFrame.submittedAtNanoseconds - ) / 1_000_000 - state.withLock { state in - state.renderedFrameCount &+= 1 - state.latestRenderLatencyMilliseconds = latencyMilliseconds - } - } - } -} - -/// Wraps `CMSampleBuffer` for safe cross-isolation transfer. -private struct UncheckedSendableBuffer: @unchecked Sendable { - nonisolated(unsafe) let buffer: CMSampleBuffer - nonisolated init(_ buffer: CMSampleBuffer) { self.buffer = buffer } -} - -// MARK: - Layer Host View - -private struct ZeroCopyPreviewLayerView: NSViewRepresentable { - let renderer: ZeroCopyPreviewRenderer - - func makeNSView(context: Context) -> ZeroCopyHostView { - let view = ZeroCopyHostView() - view.hostDisplayLayer(renderer.displayLayer) - return view - } - - func updateNSView(_: ZeroCopyHostView, context: Context) {} -} - -private final class ZeroCopyHostView: NSView { - private weak var displayLayer: AVSampleBufferDisplayLayer? - - func hostDisplayLayer(_ layer: AVSampleBufferDisplayLayer) { - wantsLayer = true - layerContentsRedrawPolicy = .duringViewResize - layer.frame = bounds - self.layer?.addSublayer(layer) - displayLayer = layer - syncLayerScale() - } - - override func layout() { - super.layout() - CATransaction.begin() - CATransaction.setDisableActions(true) - displayLayer?.frame = bounds - CATransaction.commit() - syncLayerScale() - } - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - syncLayerScale() - } - - override func viewDidChangeBackingProperties() { - super.viewDidChangeBackingProperties() - syncLayerScale() - } - - private func syncLayerScale() { - let scale = max(1, window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1) - layer?.contentsScale = scale - displayLayer?.contentsScale = scale - } -} - // MARK: - Window Accessor /// Invisible helper that resolves the hosting `NSWindow` reference diff --git a/VoidDisplay/Features/Capture/Views/CapturePreviewWindowCoordinator.swift b/VoidDisplay/Features/Capture/Views/CapturePreviewWindowCoordinator.swift new file mode 100644 index 0000000..6f7a380 --- /dev/null +++ b/VoidDisplay/Features/Capture/Views/CapturePreviewWindowCoordinator.swift @@ -0,0 +1,127 @@ +import AppKit + +final class CapturePreviewWindowCoordinator: NSObject { + private weak var window: NSWindow? + nonisolated(unsafe) private var forwardedDelegate: (any NSWindowDelegate)? + private var aspect = CGSize.zero + private var shouldLockAspect = true + + func attach(to window: NSWindow) { + guard self.window !== window else { return } + restoreWindowDelegate() + self.window = window + if let delegate = window.delegate, delegate !== self { + forwardedDelegate = delegate + } else { + forwardedDelegate = nil + } + window.delegate = self + } + + func update(aspect: CGSize, shouldLockAspect: Bool) { + self.aspect = aspect + self.shouldLockAspect = shouldLockAspect + } + + func snapWindowToAspect(_ window: NSWindow) { + guard shouldLockAspect, aspect.width > 0, aspect.height > 0 else { return } + let targetSize = aspectLockedFrameSize(for: window, proposedFrameSize: window.frame.size) + guard abs(targetSize.width - window.frame.width) > 0.5 + || abs(targetSize.height - window.frame.height) > 0.5 else { return } + + var newFrame = window.frame + newFrame.origin.x += (newFrame.width - targetSize.width) / 2 + newFrame.origin.y += (newFrame.height - targetSize.height) / 2 + newFrame.size = targetSize + window.setFrame(newFrame, display: true, animate: false) + } + + func tearDown() { + restoreWindowDelegate() + window = nil + forwardedDelegate = nil + } + + private func aspectLockedFrameSize(for window: NSWindow, proposedFrameSize: NSSize) -> NSSize { + guard let targetContentSize = aspectLockedContentSize( + for: window, + proposedFrameSize: proposedFrameSize + ) else { + return proposedFrameSize + } + + let targetContentRect = NSRect(origin: .zero, size: targetContentSize) + return window.frameRect(forContentRect: targetContentRect).size + } + + private func aspectLockedContentSize(for window: NSWindow, proposedFrameSize: NSSize) -> NSSize? { + let currentContentRect = window.contentRect(forFrameRect: window.frame) + let currentLayoutRect = window.contentLayoutRect + let proposedContentRect = window.contentRect( + forFrameRect: NSRect(origin: .zero, size: proposedFrameSize) + ) + let targetContentSize = CapturePreviewGeometry.aspectLockedContentSize( + aspect: aspect, + proposedContentSize: proposedContentRect.size, + layoutInsetSize: CGSize( + width: max(0, currentContentRect.width - currentLayoutRect.width), + height: max(0, currentContentRect.height - currentLayoutRect.height) + ), + scaleFactor: max(1, window.backingScaleFactor) + ) + guard let targetContentSize else { return nil } + return NSSize(width: targetContentSize.width, height: targetContentSize.height) + } + + private func restoreWindowDelegate() { + guard let window, window.delegate === self else { return } + window.delegate = forwardedDelegate + } +} + +extension CapturePreviewWindowCoordinator: NSWindowDelegate { + nonisolated override func responds(to aSelector: Selector!) -> Bool { + super.responds(to: aSelector) || (forwardedDelegate?.responds(to: aSelector) ?? false) + } + + nonisolated override func forwardingTarget(for aSelector: Selector!) -> Any? { + if forwardedDelegate?.responds(to: aSelector) == true { + return forwardedDelegate + } + return super.forwardingTarget(for: aSelector) + } + + func windowWillResize(_ sender: NSWindow, to frameSize: NSSize) -> NSSize { + let proposedFrameSize = forwardedDelegate?.windowWillResize?(sender, to: frameSize) ?? frameSize + guard shouldLockAspect, aspect.width > 0, aspect.height > 0 else { + return proposedFrameSize + } + return aspectLockedFrameSize(for: sender, proposedFrameSize: proposedFrameSize) + } + + func windowWillUseStandardFrame(_ window: NSWindow, defaultFrame newFrame: NSRect) -> NSRect { + let proposedFrame = forwardedDelegate?.windowWillUseStandardFrame?(window, defaultFrame: newFrame) + ?? newFrame + guard shouldLockAspect, aspect.width > 0, aspect.height > 0 else { + return proposedFrame + } + + let targetSize = aspectLockedFrameSize(for: window, proposedFrameSize: proposedFrame.size) + var adjustedFrame = proposedFrame + adjustedFrame.origin.x += (proposedFrame.width - targetSize.width) / 2 + adjustedFrame.origin.y += (proposedFrame.height - targetSize.height) / 2 + adjustedFrame.size = targetSize + return adjustedFrame + } + + func window( + _ window: NSWindow, + willUseFullScreenPresentationOptions proposedOptions: NSApplication.PresentationOptions + ) -> NSApplication.PresentationOptions { + let forwardedOptions = forwardedDelegate?.window?( + window, + willUseFullScreenPresentationOptions: proposedOptions + ) ?? proposedOptions + return forwardedOptions.union(.autoHideToolbar) + } +} diff --git a/VoidDisplay/Features/Capture/Views/ZeroCopyPreviewRenderer.swift b/VoidDisplay/Features/Capture/Views/ZeroCopyPreviewRenderer.swift new file mode 100644 index 0000000..76ea630 --- /dev/null +++ b/VoidDisplay/Features/Capture/Views/ZeroCopyPreviewRenderer.swift @@ -0,0 +1,199 @@ +import AppKit +import AVFoundation +import SwiftUI +import Synchronization + +@Observable +final class ZeroCopyPreviewRenderer: @unchecked Sendable, DisplayPreviewSink { + struct MetricsSnapshot: Sendable { + var receivedFrameCount: UInt64 + var renderedFrameCount: UInt64 + var droppedFrameCount: UInt64 + var latestRenderLatencyMilliseconds: Double? + var pendingSlotOccupied: Bool + } + + private struct PendingFrame { + let buffer: UncheckedSendableBuffer + let submittedAtNanoseconds: UInt64 + let generation: UInt64 + } + + private struct State { + var pendingFrame: PendingFrame? + var isDraining = false + var activeDrainToken: UInt64? + var nextDrainToken: UInt64 = 0 + var generation: UInt64 = 0 + var receivedFrameCount: UInt64 = 0 + var renderedFrameCount: UInt64 = 0 + var droppedFrameCount: UInt64 = 0 + var latestRenderLatencyMilliseconds: Double? + } + + var framePixelSize: CGSize = .zero + var hasReceivedFrame = false + + let displayLayer: AVSampleBufferDisplayLayer = { + let layer = AVSampleBufferDisplayLayer() + layer.videoGravity = .resizeAspect + layer.preventsDisplaySleepDuringVideoPlayback = false + return layer + }() + + nonisolated private let state = Mutex(State()) + @MainActor var willEnqueueFrameForTesting: (() -> Void)? + + nonisolated func submitFrame(_ sampleBuffer: CMSampleBuffer) { + let box = UncheckedSendableBuffer(sampleBuffer) + let drainToken = state.withLock { state -> UInt64? in + state.receivedFrameCount &+= 1 + if state.pendingFrame != nil { + state.droppedFrameCount &+= 1 + } + state.pendingFrame = PendingFrame( + buffer: box, + submittedAtNanoseconds: DispatchTime.now().uptimeNanoseconds, + generation: state.generation + ) + guard !state.isDraining else { return nil } + state.isDraining = true + state.nextDrainToken &+= 1 + state.activeDrainToken = state.nextDrainToken + return state.nextDrainToken + } + guard let drainToken else { return } + + Task { @MainActor [weak self] in + self?.drainLoop(drainToken: drainToken) + } + } + + func flush() { + state.withLock { state in + state.pendingFrame = nil + state.generation &+= 1 + state.activeDrainToken = nil + state.isDraining = false + } + displayLayer.sampleBufferRenderer.flush(removingDisplayedImage: true) + } + + nonisolated func metricsSnapshot() -> MetricsSnapshot { + state.withLock { state in + .init( + receivedFrameCount: state.receivedFrameCount, + renderedFrameCount: state.renderedFrameCount, + droppedFrameCount: state.droppedFrameCount, + latestRenderLatencyMilliseconds: state.latestRenderLatencyMilliseconds, + pendingSlotOccupied: state.pendingFrame != nil + ) + } + } + + @MainActor + private func drainLoop(drainToken: UInt64) { + while true { + let nextFrame = state.withLock { state -> PendingFrame? in + guard state.activeDrainToken == drainToken else { + return nil + } + guard let pendingFrame = state.pendingFrame else { + state.activeDrainToken = nil + state.isDraining = false + return nil + } + state.pendingFrame = nil + return pendingFrame + } + guard let nextFrame else { return } + + willEnqueueFrameForTesting?() + let shouldRender = state.withLock { state in + state.activeDrainToken == drainToken && state.generation == nextFrame.generation + } + guard shouldRender else { continue } + + let renderer = displayLayer.sampleBufferRenderer + if renderer.status == .failed { + renderer.flush() + } + renderer.enqueue(nextFrame.buffer.buffer) + + if !hasReceivedFrame { + hasReceivedFrame = true + } + + if let desc = CMSampleBufferGetFormatDescription(nextFrame.buffer.buffer) { + let dims = CMVideoFormatDescriptionGetDimensions(desc) + let size = CGSize(width: CGFloat(dims.width), height: CGFloat(dims.height)) + if framePixelSize != size { + framePixelSize = size + } + } + + let latencyMilliseconds = Double( + DispatchTime.now().uptimeNanoseconds &- nextFrame.submittedAtNanoseconds + ) / 1_000_000 + state.withLock { state in + state.renderedFrameCount &+= 1 + state.latestRenderLatencyMilliseconds = latencyMilliseconds + } + } + } +} + +struct UncheckedSendableBuffer: @unchecked Sendable { + nonisolated(unsafe) let buffer: CMSampleBuffer + nonisolated init(_ buffer: CMSampleBuffer) { self.buffer = buffer } +} + +struct ZeroCopyPreviewLayerView: NSViewRepresentable { + let renderer: ZeroCopyPreviewRenderer + + func makeNSView(context: Context) -> ZeroCopyHostView { + let view = ZeroCopyHostView() + view.hostDisplayLayer(renderer.displayLayer) + return view + } + + func updateNSView(_: ZeroCopyHostView, context: Context) {} +} + +final class ZeroCopyHostView: NSView { + private weak var displayLayer: AVSampleBufferDisplayLayer? + + func hostDisplayLayer(_ layer: AVSampleBufferDisplayLayer) { + wantsLayer = true + layerContentsRedrawPolicy = .duringViewResize + layer.frame = bounds + self.layer?.addSublayer(layer) + displayLayer = layer + syncLayerScale() + } + + override func layout() { + super.layout() + CATransaction.begin() + CATransaction.setDisableActions(true) + displayLayer?.frame = bounds + CATransaction.commit() + syncLayerScale() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + syncLayerScale() + } + + override func viewDidChangeBackingProperties() { + super.viewDidChangeBackingProperties() + syncLayerScale() + } + + private func syncLayerScale() { + let scale = max(1, window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1) + layer?.contentsScale = scale + displayLayer?.contentsScale = scale + } +} diff --git a/VoidDisplay/Features/Sharing/ViewModels/ShareViewModel.swift b/VoidDisplay/Features/Sharing/ViewModels/ShareViewModel.swift index e66e359..8c6bc8e 100644 --- a/VoidDisplay/Features/Sharing/ViewModels/ShareViewModel.swift +++ b/VoidDisplay/Features/Sharing/ViewModels/ShareViewModel.swift @@ -8,9 +8,6 @@ import OSLog @MainActor @Observable final class ShareViewModel { - typealias LoadErrorInfo = ScreenCaptureDisplayCatalogLoadErrorInfo - typealias RefreshIntent = ScreenCaptureCatalogRefreshIntent - struct SharingQueries { var isWebServiceRunning: @MainActor () -> Bool var isStartingDisplayID: @MainActor (CGDirectDisplayID) -> Bool @@ -86,55 +83,20 @@ final class ShareViewModel { var isStartingService = false var userFacingAlert: UserFacingAlertState? - private let catalogService: ScreenCaptureCatalogService - @ObservationIgnored private let refreshOwner = ScreenCaptureCatalogService.RefreshOwner() + @ObservationIgnored private let activeDisplayIDsProvider: @MainActor () -> Set @ObservationIgnored private let dependencies: Dependencies init( - catalogService: ScreenCaptureCatalogService? = nil, catalogState: ScreenCaptureDisplayCatalogState? = nil, - permissionProvider: (any ScreenCapturePermissionProvider)? = nil, - loadShareableDisplays: (@MainActor () async throws -> [SCDisplay])? = nil, activeDisplayIDsProvider: @escaping @MainActor () -> Set = { Set(NSScreen.screens.compactMap(\.cgDirectDisplayID)) }, dependencies: Dependencies ) { - let resolvedCatalogService = catalogService ?? ScreenCaptureCatalogService( - store: catalogState, - permissionProvider: permissionProvider, - loadShareableDisplays: loadShareableDisplays, - activeDisplayIDsProvider: activeDisplayIDsProvider, - logOperation: "Load shareable displays (sharing)", - logger: AppLog.capture - ) - self.catalogService = resolvedCatalogService - self.catalog = resolvedCatalogService.store + self.catalog = catalogState ?? ScreenCaptureDisplayCatalogState() + self.activeDisplayIDsProvider = activeDisplayIDsProvider self.dependencies = dependencies self.servicePortInput = String(dependencies.sharingQueries.preferredWebServicePort()) - if dependencies.sharingQueries.isWebServiceRunning() { - replayShareableDisplaysIfAvailable() - } - } - - func syncForCurrentState( - clearDisplaysWhenPermissionDenied: Bool = true, - clearDisplaysWhenServiceStopped: Bool = true - ) { - guard catalog.hasScreenCapturePermission == true else { - if clearDisplaysWhenPermissionDenied { - Task { await self.submitRefresh(.permissionChanged, replayRegistration: false) } - } else { - Task { await self.catalogService.cancelRefresh(owner: self.refreshOwner) } - } - return - } - guard dependencies.sharingQueries.isWebServiceRunning() else { - _ = clearDisplaysWhenServiceStopped - Task { await self.catalogService.cancelRefresh(owner: self.refreshOwner) } - return - } - Task { await self.submitRefresh(.serviceBecameRunning) } } func startService() { @@ -162,70 +124,16 @@ final class ShareViewModel { } servicePortInput = String(requestedPort) portInputErrorMessage = nil - _ = await submitRefresh(.serviceBecameRunning) } } func stopService() { - Task { await catalogService.cancelRefresh(owner: refreshOwner) } dependencies.sharingActions.stopWebService() - syncForCurrentState() - } - - func openScreenCapturePrivacySettings(openURL: (URL) -> Void) { - catalogService.openScreenCapturePrivacySettings(openURL: openURL) - } - - func requestScreenCapturePermission() { - let granted = catalogService.requestPermission() - - AppLog.capture.notice( - "Screen capture permission request (sharing): requestResult=\((self.catalog.lastRequestPermission ?? false), privacy: .public), preflightResult=\(granted, privacy: .public)" - ) - - if !granted { - Task { - await catalogService.clearSnapshotForDeniedPermission( - loadErrorMessage: String(localized: "Failed to load displays. Check permission and try again.") - ) - } - AppLog.capture.notice("Screen capture permission request denied (sharing).") - return - } - syncForCurrentState() - } - - func refreshPermissionAndMaybeLoad() { - let granted = catalogService.refreshPermission() - if !granted { - Task { await self.submitRefresh(.permissionChanged, replayRegistration: false) } - return - } - syncForCurrentState(clearDisplaysWhenServiceStopped: false) - } - - func loadDisplaysIfNeeded() { - guard dependencies.sharingQueries.isWebServiceRunning() else { return } - Task { await self.submitRefresh(.serviceBecameRunning) } - } - - func loadDisplays() { - Task { await self.submitRefresh(.userForcedRefresh) } - } - - func refreshDisplays() { - guard dependencies.sharingQueries.isWebServiceRunning() else { return } - Task { await self.submitRefresh(.userForcedRefresh) } - } - - func refreshDisplaysBackgroundSafe() { - guard dependencies.sharingQueries.isWebServiceRunning() else { return } - guard !catalog.isLoadingDisplays else { return } - Task { await self.submitRefresh(.topologyChanged) } } func visibleDisplays(from displays: [SCDisplay]) -> [SCDisplay] { - catalogService.visibleDisplays(from: displays) + let activeDisplayIDs = activeDisplayIDsProvider() + return displays.filter { activeDisplayIDs.contains($0.displayID) } } func isStarting(displayID: CGDirectDisplayID) -> Bool { @@ -289,16 +197,6 @@ final class ShareViewModel { userFacingAlert = nil } - func cancelInFlightDisplayLoad() { - Task { await catalogService.cancelRefresh(owner: refreshOwner) } - } - - private func registerShareableDisplays(_ displays: [SCDisplay]) { - dependencies.sharingActions.registerShareableDisplays(displays) { [weak self] displayID in - self?.dependencies.virtualDisplayQueries.virtualSerialForManagedDisplay(displayID) - } - } - private func presentError(title: String, message: String) { userFacingAlert = UserFacingAlertState(title: title, message: message) } @@ -306,26 +204,4 @@ final class ShareViewModel { private func presentPortInputError(_ message: String) { portInputErrorMessage = message } - - @discardableResult - private func submitRefresh( - _ intent: RefreshIntent, - replayRegistration: Bool = true - ) async -> ScreenCaptureCatalogRefreshResult { - let result = await catalogService.submitRefresh(intent: intent, owner: refreshOwner) - if replayRegistration, dependencies.sharingQueries.isWebServiceRunning() { - switch result { - case .reloadedSnapshot, .reusedSnapshot: - replayShareableDisplaysIfAvailable() - case .clearedSnapshot, .failed: - break - } - } - return result - } - - private func replayShareableDisplaysIfAvailable() { - guard let displays = catalog.displays else { return } - registerShareableDisplays(displays) - } } diff --git a/VoidDisplay/Features/Sharing/Views/ShareView.swift b/VoidDisplay/Features/Sharing/Views/ShareView.swift index 6788b15..96e9bd4 100644 --- a/VoidDisplay/Features/Sharing/Views/ShareView.swift +++ b/VoidDisplay/Features/Sharing/Views/ShareView.swift @@ -13,23 +13,23 @@ struct ShareView: View { @State private var viewModel: ShareViewModel @State private var lifecycle: DisplayTopologyRefreshLifecycleController @Environment(\.openURL) private var openURL - private let topologyCoordinator: DisplayTopologyChangeCoordinator + private let screenCatalogOrchestrator: ScreenCatalogOrchestrator init( sharing: SharingController, virtualDisplay: VirtualDisplayController, - topologyCoordinator: DisplayTopologyChangeCoordinator, + screenCatalogOrchestrator: ScreenCatalogOrchestrator, lifecycle: DisplayTopologyRefreshLifecycleController = DisplayTopologyRefreshLifecycleController() ) { _sharing = Bindable(sharing) _viewModel = State( initialValue: ShareViewModel( - catalogService: sharing.catalogService, + catalogState: sharing.displayCatalogState, dependencies: .live(sharing: sharing, virtualDisplay: virtualDisplay) ) ) _lifecycle = State(initialValue: lifecycle) - self.topologyCoordinator = topologyCoordinator + self.screenCatalogOrchestrator = screenCatalogOrchestrator } var body: some View { @@ -46,7 +46,7 @@ struct ShareView: View { if sharing.isWebServiceRunning { if lifecycle.showToolbarRefresh { Button("Refresh", systemImage: "arrow.clockwise") { - viewModel.refreshDisplays() + Task { await screenCatalogOrchestrator.forceRefresh(source: .sharingPage) } } } Button("Stop Service") { @@ -56,21 +56,18 @@ struct ShareView: View { } } .onAppear { - viewModel.refreshPermissionAndMaybeLoad() + Task { await screenCatalogOrchestrator.handleAppear(source: .sharingPage) } lifecycle.handleAppear { guard viewModel.catalog.hasScreenCapturePermission == true else { return } - topologyCoordinator.handleTopologyChange(source: .sharingView) + Task { await screenCatalogOrchestrator.handleTopologyChanged() } } } .onDisappear { - viewModel.cancelInFlightDisplayLoad() + Task { await screenCatalogOrchestrator.handleDisappear(source: .sharingPage) } lifecycle.handleDisappear() } - .onChange(of: sharing.isWebServiceRunning) { _, _ in - viewModel.syncForCurrentState() - } - .onChange(of: sharing.isSharing) { _, _ in - viewModel.syncForCurrentState() + .onChange(of: sharing.isWebServiceRunning) { _, isRunning in + Task { await screenCatalogOrchestrator.handleSharingServiceStateChanged(isRunning: isRunning) } } .alert(item: $bindableViewModel.userFacingAlert) { alert in Alert( @@ -194,20 +191,18 @@ struct ShareView: View { ScreenCapturePermissionGuideView( loadErrorMessage: viewModel.catalog.loadErrorMessage, onOpenSettings: { - viewModel.openScreenCapturePrivacySettings { url in + screenCatalogOrchestrator.openScreenCapturePrivacySettings { url in openURL(url) } }, onRequestPermission: { - viewModel.requestScreenCapturePermission() + Task { await screenCatalogOrchestrator.requestPermission(source: .sharingPage) } }, onRefresh: { - viewModel.refreshPermissionAndMaybeLoad() + Task { await screenCatalogOrchestrator.refreshPermission(source: .sharingPage) } }, onRetry: (viewModel.catalog.loadErrorMessage != nil || viewModel.catalog.lastLoadError != nil) ? { - // User-initiated retry: attempt to load the display list. - // If permission is still missing, macOS may prompt here (expected). - viewModel.loadDisplays() + Task { await screenCatalogOrchestrator.forceRefresh(source: .sharingPage) } } : nil, isDebugInfoExpanded: $bindableCatalog.showDebugInfo, debugItems: sharingPermissionDebugItems, @@ -231,7 +226,7 @@ struct ShareView: View { SharePerformanceModePicker() .frame(maxWidth: 360) Button("Refresh") { - viewModel.refreshDisplays() + Task { await screenCatalogOrchestrator.forceRefresh(source: .sharingPage) } } .appActionButtonStyle(variant: .default) .accessibilityIdentifier("share_empty_refresh_button") @@ -286,11 +281,10 @@ struct ShareView: View { ShareView( sharing: env.sharing, virtualDisplay: env.virtualDisplay, - topologyCoordinator: env.topology + screenCatalogOrchestrator: env.screenCatalog ) .environment(env.capture) .environment(env.capturePerformancePreferences) .environment(env.sharing) .environment(env.virtualDisplay) - .environment(env.topology) } diff --git a/VoidDisplay/Shared/Testing/CapturePreviewDiagnosticsSession.swift b/VoidDisplay/Shared/Testing/CapturePreviewDiagnosticsSession.swift index ca6a57c..268476b 100644 --- a/VoidDisplay/Shared/Testing/CapturePreviewDiagnosticsSession.swift +++ b/VoidDisplay/Shared/Testing/CapturePreviewDiagnosticsSession.swift @@ -53,11 +53,9 @@ final class UITestCapturePreviewSession: @unchecked Sendable, DisplayCaptureSess nonisolated func stopSharing() {} - nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws {} - - nonisolated func retainShareCursorOverride() async throws {} - - nonisolated func releaseShareCursorOverride() async throws {} + nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws { + _ = demand + } nonisolated func stop() async {} } diff --git a/VoidDisplayTests/App/CaptureControllerTests.swift b/VoidDisplayTests/App/CaptureControllerTests.swift index 2fddf70..8ceb666 100644 --- a/VoidDisplayTests/App/CaptureControllerTests.swift +++ b/VoidDisplayTests/App/CaptureControllerTests.swift @@ -25,15 +25,11 @@ private final class CaptureControllerDummySession: DisplayCaptureSessioning, @un nonisolated func stopSharing() {} - nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { + nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws { cursorUpdateCount += 1 - lastShowsCursor = showsCursor + lastShowsCursor = demand.previewShowsCursor } - nonisolated func retainShareCursorOverride() async throws {} - - nonisolated func releaseShareCursorOverride() async throws {} - nonisolated func stop() async {} } @@ -180,7 +176,15 @@ struct CaptureControllerTests { displayID: 77, resolutionText: "2560 × 1440", session: subscriptionSession, - cancelClosure: {} + cancelClosure: {}, + setShowsCursorClosure: { showsCursor in + try await subscriptionSession.setDemand( + DisplayCaptureDemandSnapshot( + previewShowsCursor: showsCursor, + performanceMode: .automatic + ) + ) + } ) let lifecycleService = CaptureMonitoringLifecycleService( captureMonitoringService: service, @@ -542,7 +546,7 @@ struct CaptureControllerTests { captureMonitoringService: service, captureMonitoringLifecycleService: lifecycleService ) - controller.startingDisplayIDs = [93] + controller.installStartingDisplayIDsForTesting([93]) controller.removeMonitoringSessions(displayID: 93) @@ -670,7 +674,15 @@ struct CaptureControllerTests { displayID: displayID, resolutionText: "1920 x 1080", session: captureSession, - cancelClosure: {} + cancelClosure: {}, + setShowsCursorClosure: { showsCursor in + try await captureSession.setDemand( + DisplayCaptureDemandSnapshot( + previewShowsCursor: showsCursor, + performanceMode: .automatic + ) + ) + } ), capturesCursor: false, state: .starting diff --git a/VoidDisplayTests/App/CaptureSharingIsolationTests.swift b/VoidDisplayTests/App/CaptureSharingIsolationTests.swift index dae8f45..42fc062 100644 --- a/VoidDisplayTests/App/CaptureSharingIsolationTests.swift +++ b/VoidDisplayTests/App/CaptureSharingIsolationTests.swift @@ -17,14 +17,10 @@ private final class CaptureSharingIsolationDummySession: DisplayCaptureSessionin nonisolated func stopSharing() {} - nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { - _ = showsCursor + nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws { + _ = demand } - nonisolated func retainShareCursorOverride() async throws {} - - nonisolated func releaseShareCursorOverride() async throws {} - nonisolated func stop() async {} } diff --git a/VoidDisplayTests/App/DisplayStartTrackerTests.swift b/VoidDisplayTests/App/DisplayStartTrackerTests.swift new file mode 100644 index 0000000..c38685c --- /dev/null +++ b/VoidDisplayTests/App/DisplayStartTrackerTests.swift @@ -0,0 +1,65 @@ +import CoreGraphics +import Foundation +import Testing +@testable import VoidDisplay + +@MainActor +struct DisplayStartTrackerTests { + @Test func beginAndEndTrackSingleDisplayLifecycle() { + let tracker = DisplayStartTracker() + let displayID = CGDirectDisplayID(101) + + let token = tracker.begin(displayID: displayID) + + #expect(tracker.contains(displayID: displayID)) + #expect(tracker.activeDisplayIDs == Set([displayID])) + + tracker.end(displayID: displayID, token: token) + + #expect(tracker.contains(displayID: displayID) == false) + #expect(tracker.activeDisplayIDs.isEmpty) + } + + @Test func endingOneTokenKeepsDisplayActiveUntilLastTokenLeaves() { + let tracker = DisplayStartTracker() + let displayID = CGDirectDisplayID(202) + + let firstToken = tracker.begin(displayID: displayID) + let secondToken = tracker.begin(displayID: displayID) + + tracker.end(displayID: displayID, token: firstToken) + #expect(tracker.contains(displayID: displayID)) + #expect(tracker.activeDisplayIDs == Set([displayID])) + + tracker.end(displayID: displayID, token: secondToken) + #expect(tracker.activeDisplayIDs.isEmpty) + } + + @Test func clearRemovesOnlySpecifiedDisplay() { + let tracker = DisplayStartTracker() + let firstDisplayID = CGDirectDisplayID(303) + let secondDisplayID = CGDirectDisplayID(304) + + _ = tracker.begin(displayID: firstDisplayID) + _ = tracker.begin(displayID: secondDisplayID) + + tracker.clear(displayID: firstDisplayID) + + #expect(tracker.contains(displayID: firstDisplayID) == false) + #expect(tracker.contains(displayID: secondDisplayID)) + #expect(tracker.activeDisplayIDs == Set([secondDisplayID])) + } + + @Test func clearAllRemovesEveryTrackedDisplay() { + let tracker = DisplayStartTracker() + + _ = tracker.begin(displayID: CGDirectDisplayID(401)) + _ = tracker.begin(displayID: CGDirectDisplayID(402)) + + tracker.clearAll() + + #expect(tracker.activeDisplayIDs.isEmpty) + #expect(tracker.contains(displayID: CGDirectDisplayID(401)) == false) + #expect(tracker.contains(displayID: CGDirectDisplayID(402)) == false) + } +} diff --git a/VoidDisplayTests/App/DisplayTopologyChangeCoordinatorTests.swift b/VoidDisplayTests/App/DisplayTopologyChangeCoordinatorTests.swift deleted file mode 100644 index 022602e..0000000 --- a/VoidDisplayTests/App/DisplayTopologyChangeCoordinatorTests.swift +++ /dev/null @@ -1,358 +0,0 @@ -import CoreGraphics -import Foundation -import ScreenCaptureKit -import Testing -@testable import VoidDisplay - -private final class DisplayTopologyChangeCoordinatorMockSCDisplayBox: NSObject { - @objc let displayID: CGDirectDisplayID - @objc let width: Int - @objc let height: Int - @objc let frame: CGRect - - init(displayID: CGDirectDisplayID, width: Int, height: Int) { - self.displayID = displayID - self.width = width - self.height = height - self.frame = CGRect(x: 0, y: 0, width: width, height: height) - super.init() - } -} - -private enum DisplayTopologyChangeCoordinatorMockSCDisplay { - static func make(displayID: CGDirectDisplayID, width: Int, height: Int) -> SCDisplay { - let box = DisplayTopologyChangeCoordinatorMockSCDisplayBox( - displayID: displayID, - width: width, - height: height - ) - return unsafeBitCast(box, to: SCDisplay.self) - } -} - -private actor DisplayTopologyChangeCoordinatorLoadGate { - private var displaysByCall: [[SendableDisplay]] - private var callCount = 0 - private var continuations: [Int: CheckedContinuation<[SendableDisplay], Never>] = [:] - - init(displaysByCall: [[SCDisplay]]) { - self.displaysByCall = displaysByCall.map { $0.map(SendableDisplay.init) } - } - - func waitForNextDisplays() async -> [SendableDisplay] { - callCount += 1 - let currentCall = callCount - return await withCheckedContinuation { continuation in - continuations[currentCall] = continuation - } - } - - func release(call: Int) { - guard let continuation = continuations.removeValue(forKey: call) else { return } - let displays = displaysByCall.indices.contains(call - 1) ? displaysByCall[call - 1] : [] - continuation.resume(returning: displays) - } - - func currentCallCount() -> Int { - callCount - } -} - -@MainActor -private final class DisplayTopologyChangeCoordinatorActiveDisplayIDsBox { - var ids: Set - - init(ids: Set) { - self.ids = ids - } -} - -@MainActor -struct DisplayTopologyChangeCoordinatorTests { - private final class TopologyChangePortPreferences: SharingPortPreferencesProtocol { - var preferredPort: UInt16 = 8081 - - func savePreferredPort(_ port: UInt16) { - preferredPort = port - } - } - - @Test func topologyRefreshRegistersVisibleDisplaysAndStopsInvalidSessions() async { - let removedDisplayID = CGDirectDisplayID(1001) - let keptDisplayID = CGDirectDisplayID(1002) - let removedDisplay = DisplayTopologyChangeCoordinatorMockSCDisplay.make( - displayID: removedDisplayID, - width: 1920, - height: 1080 - ) - let keptDisplay = DisplayTopologyChangeCoordinatorMockSCDisplay.make( - displayID: keptDisplayID, - width: 2560, - height: 1440 - ) - - let catalogService = ScreenCaptureCatalogService( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { - [removedDisplay, keptDisplay] - }, - activeDisplayIDsProvider: { - [keptDisplayID] - } - ) - - let sharingService = MockSharingService() - sharingService.startResult = .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) - sharingService.isWebServiceRunning = true - sharingService.webServiceLifecycleState = .running(.init(requestedPort: 8081, boundPort: 8081)) - sharingService.activeSharingDisplayIDs = [removedDisplayID, keptDisplayID] - sharingService.hasAnyActiveSharing = true - sharingService.shareTargetByDisplayID[removedDisplayID] = .id(1) - sharingService.shareTargetByDisplayID[keptDisplayID] = .id(2) - let sharingController = SharingController( - sharingService: sharingService, - portPreferences: TopologyChangePortPreferences(), - catalogService: catalogService - ) - - let captureService = MockCaptureMonitoringService() - captureService.currentSessions = [ - makeSession(id: UUID(), displayID: removedDisplayID), - makeSession(id: UUID(), displayID: keptDisplayID), - ] - let captureController = CaptureController( - captureMonitoringService: captureService, - catalogService: catalogService - ) - let virtualDisplay = VirtualDisplayController( - virtualDisplayFacade: MockVirtualDisplayFacade(), - appliedBadgeDisplayDuration: .seconds(1), - stopDependentStreamsBeforeRebuild: { _ in } - ) - let coordinator = DisplayTopologyChangeCoordinator( - capture: captureController, - sharing: sharingController, - virtualDisplay: virtualDisplay, - catalogService: catalogService - ) - - coordinator.handleTopologyChange(source: DisplayTopologyChangeCoordinator.Source.sharingView) - - let finished = await waitUntil { - catalogService.store.displays?.map(\.displayID) == [removedDisplayID, keptDisplayID] && - sharingService.registeredShareableDisplays.map(\.displayID) == [keptDisplayID] && - sharingService.stopSharingCallCount == 1 && - sharingService.activeSharingDisplayIDs == [keptDisplayID] && - captureService.removedDisplayIDs == [removedDisplayID] - } - - #expect(finished) - } - - @Test func coalescesInFlightTopologyChangesAndAppliesLatestVisibleDisplays() async { - let firstDisplayID = CGDirectDisplayID(2001) - let secondDisplayID = CGDirectDisplayID(2002) - let firstDisplay = DisplayTopologyChangeCoordinatorMockSCDisplay.make( - displayID: firstDisplayID, - width: 1920, - height: 1080 - ) - let secondDisplay = DisplayTopologyChangeCoordinatorMockSCDisplay.make( - displayID: secondDisplayID, - width: 2560, - height: 1440 - ) - let loadGate = DisplayTopologyChangeCoordinatorLoadGate( - displaysByCall: [[firstDisplay], [secondDisplay]] - ) - let activeDisplayIDs = DisplayTopologyChangeCoordinatorActiveDisplayIDsBox(ids: [firstDisplayID]) - let catalogService = ScreenCaptureCatalogService( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { - await loadGate.waitForNextDisplays().map(\.value) - }, - activeDisplayIDsProvider: { - activeDisplayIDs.ids - } - ) - - let sharingService = MockSharingService() - sharingService.isWebServiceRunning = true - sharingService.webServiceLifecycleState = .running(.init(requestedPort: 8081, boundPort: 8081)) - let sharingController = SharingController( - sharingService: sharingService, - portPreferences: TopologyChangePortPreferences(), - catalogService: catalogService - ) - let captureController = CaptureController( - captureMonitoringService: MockCaptureMonitoringService(), - catalogService: catalogService - ) - let virtualDisplay = VirtualDisplayController( - virtualDisplayFacade: MockVirtualDisplayFacade(), - appliedBadgeDisplayDuration: .seconds(1), - stopDependentStreamsBeforeRebuild: { _ in } - ) - let coordinator = DisplayTopologyChangeCoordinator( - capture: captureController, - sharing: sharingController, - virtualDisplay: virtualDisplay, - catalogService: catalogService - ) - - coordinator.handleTopologyChange(source: .sharingView) - #expect(await waitForCoordinatorLoaderCall(loadGate, count: 1)) - - activeDisplayIDs.ids = [secondDisplayID] - coordinator.handleTopologyChange(source: .captureView) - await loadGate.release(call: 1) - - #expect(await waitForCoordinatorLoaderCall(loadGate, count: 2)) - await loadGate.release(call: 2) - - let converged = await waitUntil { - sharingService.registerShareableDisplaysCallCount >= 2 && - sharingService.registeredShareableDisplays.map(\.displayID) == [secondDisplayID] && - catalogService.store.displays?.map(\.displayID) == [secondDisplayID] - } - #expect(converged) - } - - @Test func permissionDeniedConvergesToEmptyVisibleDisplays() async { - let removedDisplayID = CGDirectDisplayID(3001) - let catalogService = ScreenCaptureCatalogService( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: false, - requestResult: false - ), - loadShareableDisplays: { - Issue.record("Display load should not run when permission is denied.") - return [] - }, - activeDisplayIDsProvider: { - [removedDisplayID] - } - ) - - let sharingService = MockSharingService() - sharingService.isWebServiceRunning = true - sharingService.webServiceLifecycleState = .running(.init(requestedPort: 8081, boundPort: 8081)) - sharingService.activeSharingDisplayIDs = [removedDisplayID] - sharingService.hasAnyActiveSharing = true - let sharingController = SharingController( - sharingService: sharingService, - portPreferences: TopologyChangePortPreferences(), - catalogService: catalogService - ) - let captureService = MockCaptureMonitoringService() - captureService.currentSessions = [ - makeSession(id: UUID(), displayID: removedDisplayID) - ] - let captureController = CaptureController( - captureMonitoringService: captureService, - catalogService: catalogService - ) - let virtualDisplay = VirtualDisplayController( - virtualDisplayFacade: MockVirtualDisplayFacade(), - appliedBadgeDisplayDuration: .seconds(1), - stopDependentStreamsBeforeRebuild: { _ in } - ) - let coordinator = DisplayTopologyChangeCoordinator( - capture: captureController, - sharing: sharingController, - virtualDisplay: virtualDisplay, - catalogService: catalogService - ) - - coordinator.handleTopologyChange(source: .sharingView) - - let converged = await waitUntil { - catalogService.store.hasScreenCapturePermission == false && - sharingService.registerShareableDisplaysCallCount >= 1 && - sharingService.registeredShareableDisplays.isEmpty && - sharingService.stopSharingCallCount == 1 && - sharingService.activeSharingDisplayIDs.isEmpty && - captureService.removedDisplayIDs == [removedDisplayID] - } - #expect(converged) - } - - private func makeSession(id: UUID, displayID: CGDirectDisplayID) -> ScreenMonitoringSession { - ScreenMonitoringSession( - id: id, - displayID: displayID, - displayName: "Display \(displayID)", - resolutionText: "1920 x 1080", - isVirtualDisplay: false, - previewSubscription: DisplayPreviewSubscription( - displayID: displayID, - resolutionText: "1920 x 1080", - session: DisplayTopologyChangeCoordinatorDummySession(), - cancelClosure: {} - ), - capturesCursor: false, - state: .active - ) - } - - private func waitUntil( - timeoutNanoseconds: UInt64 = AsyncTestTimeouts.defaultAsyncAssertion, - pollNanoseconds: UInt64 = 10_000_000, - condition: @escaping @MainActor () -> Bool - ) async -> Bool { - let deadline = DispatchTime.now().uptimeNanoseconds + timeoutNanoseconds - while DispatchTime.now().uptimeNanoseconds < deadline { - if condition() { - return true - } - try? await Task.sleep(for: .nanoseconds(pollNanoseconds)) - } - return condition() - } -} - -private func waitForCoordinatorLoaderCall( - _ gate: DisplayTopologyChangeCoordinatorLoadGate, - count: Int, - timeoutNanoseconds: UInt64 = AsyncTestTimeouts.defaultAsyncAssertion, - pollNanoseconds: UInt64 = 10_000_000 -) async -> Bool { - let deadline = DispatchTime.now().uptimeNanoseconds + timeoutNanoseconds - while DispatchTime.now().uptimeNanoseconds < deadline { - if await gate.currentCallCount() >= count { - return true - } - try? await Task.sleep(for: .nanoseconds(pollNanoseconds)) - } - return await gate.currentCallCount() >= count -} - -private final class DisplayTopologyChangeCoordinatorDummySession: DisplayCaptureSessioning, @unchecked Sendable { - nonisolated let sessionHub = WebRTCSessionHub() - - nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { - _ = sink - } - - nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { - _ = sink - } - - nonisolated func stopSharing() {} - - nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { - _ = showsCursor - } - - nonisolated func retainShareCursorOverride() async throws {} - - nonisolated func releaseShareCursorOverride() async throws {} - - nonisolated func stop() async {} -} diff --git a/VoidDisplayTests/App/ScreenCaptureCatalogTopologyIntegrationTests.swift b/VoidDisplayTests/App/ScreenCaptureCatalogTopologyIntegrationTests.swift deleted file mode 100644 index 45d8620..0000000 --- a/VoidDisplayTests/App/ScreenCaptureCatalogTopologyIntegrationTests.swift +++ /dev/null @@ -1,359 +0,0 @@ -import CoreGraphics -import Foundation -import ScreenCaptureKit -import Testing -@testable import VoidDisplay - -private struct IntegrationControlledLoadFailure: Error, Sendable {} - -private actor IntegrationSequencedDisplayLoaderGate { - enum Outcome: Sendable { - case success - case failure - } - - private struct PendingCall { - let outcome: Outcome - let continuation: CheckedContinuation - } - - private let scriptedOutcomes: [Outcome] - private var callCount = 0 - private var pendingCalls: [Int: PendingCall] = [:] - - init(scriptedOutcomes: [Outcome]) { - self.scriptedOutcomes = scriptedOutcomes - } - - func nextOutcome() async -> Outcome { - callCount += 1 - let callIndex = callCount - let outcome = scriptedOutcomes.indices.contains(callIndex - 1) - ? scriptedOutcomes[callIndex - 1] - : .success - return await withCheckedContinuation { continuation in - pendingCalls[callIndex] = PendingCall(outcome: outcome, continuation: continuation) - } - } - - func release(call callIndex: Int) { - guard let pending = pendingCalls.removeValue(forKey: callIndex) else { return } - pending.continuation.resume(returning: pending.outcome) - } - - func currentCallCount() -> Int { - callCount - } -} - -@MainActor -private final class IntegrationRegisterCounter { - var value = 0 -} - -@Suite(.serialized) -@MainActor -struct ScreenCaptureCatalogTopologyIntegrationTests { - @Test func captureCatalogStateConvergesAcrossViewModelLifecycle() async { - let staleDisplay = IntegrationMockSCDisplay.make(displayID: 2222, width: 1280, height: 720) - let refreshedDisplay = IntegrationMockSCDisplay.make(displayID: 3333, width: 2560, height: 1440) - let sharedCatalogState = ScreenCaptureDisplayCatalogState() - sharedCatalogState.displays = [staleDisplay] - sharedCatalogState.lastLoadedActiveDisplayTopologySignature = nil - let permissionProvider = MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ) - let activeDisplayIDsProvider: @MainActor () -> Set = { [3333] } - let firstGate = IntegrationSequencedDisplayLoaderGate(scriptedOutcomes: [.success]) - let firstCatalogService = makeCatalogService( - store: sharedCatalogState, - permissionProvider: permissionProvider, - loadShareableDisplays: { - switch await firstGate.nextOutcome() { - case .success: - return [refreshedDisplay] - case .failure: - throw IntegrationControlledLoadFailure() - } - }, - activeDisplayIDsProvider: activeDisplayIDsProvider - ) - let firstVM = CaptureChooseViewModel( - catalogService: firstCatalogService, - catalogState: sharedCatalogState, - permissionProvider: permissionProvider, - loadShareableDisplays: { - switch await firstGate.nextOutcome() { - case .success: - return [refreshedDisplay] - case .failure: - throw IntegrationControlledLoadFailure() - } - }, - activeDisplayIDsProvider: activeDisplayIDsProvider, - dependencies: makeNoopCaptureDependencies() - ) - - firstVM.refreshPermissionAndMaybeLoad() - #expect(await waitForLoaderCall(firstGate, count: 1)) - await firstGate.release(call: 1) - - let firstRefreshFinished = await waitUntil { - sharedCatalogState.isLoadingDisplays == false && - sharedCatalogState.displays?.map { $0.displayID } == [3333] && - sharedCatalogState.lastLoadedActiveDisplayTopologySignature - == makeTestDisplayTopologySignature([3333]) - } - #expect(firstRefreshFinished) - - let secondGate = IntegrationSequencedDisplayLoaderGate(scriptedOutcomes: [.success]) - let secondCatalogService = makeCatalogService( - store: sharedCatalogState, - permissionProvider: permissionProvider, - loadShareableDisplays: { - switch await secondGate.nextOutcome() { - case .success: - return [refreshedDisplay] - case .failure: - throw IntegrationControlledLoadFailure() - } - }, - activeDisplayIDsProvider: activeDisplayIDsProvider - ) - let secondVM = CaptureChooseViewModel( - catalogService: secondCatalogService, - catalogState: sharedCatalogState, - permissionProvider: permissionProvider, - loadShareableDisplays: { - switch await secondGate.nextOutcome() { - case .success: - return [refreshedDisplay] - case .failure: - throw IntegrationControlledLoadFailure() - } - }, - activeDisplayIDsProvider: activeDisplayIDsProvider, - dependencies: makeNoopCaptureDependencies() - ) - - secondVM.refreshPermissionAndMaybeLoad() - let secondCheckDeadline = DispatchTime.now().uptimeNanoseconds + AsyncTestTimeouts.shortStabilityWindow - var observedUnexpectedLoad = false - while DispatchTime.now().uptimeNanoseconds < secondCheckDeadline { - if await secondGate.currentCallCount() > 0 { - observedUnexpectedLoad = true - break - } - await Task.yield() - } - #expect(observedUnexpectedLoad == false) - } - - @Test func shareCatalogStateConvergesAcrossViewModelLifecycle() async { - let staleDisplay = IntegrationMockSCDisplay.make(displayID: 4444, width: 1920, height: 1080) - let refreshedDisplay = IntegrationMockSCDisplay.make(displayID: 5555, width: 2560, height: 1440) - let sharedCatalogState = ScreenCaptureDisplayCatalogState() - sharedCatalogState.displays = [staleDisplay] - sharedCatalogState.lastLoadedActiveDisplayTopologySignature = makeTestDisplayTopologySignature([4444]) - let registerCounter = IntegrationRegisterCounter() - let permissionProvider = MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ) - let activeDisplayIDsProvider: @MainActor () -> Set = { [5555] } - - let firstGate = IntegrationSequencedDisplayLoaderGate(scriptedOutcomes: [.success]) - let firstCatalogService = makeCatalogService( - store: sharedCatalogState, - permissionProvider: permissionProvider, - loadShareableDisplays: { - switch await firstGate.nextOutcome() { - case .success: - return [refreshedDisplay] - case .failure: - throw IntegrationControlledLoadFailure() - } - }, - activeDisplayIDsProvider: activeDisplayIDsProvider - ) - let firstVM = ShareViewModel( - catalogService: firstCatalogService, - catalogState: sharedCatalogState, - permissionProvider: permissionProvider, - loadShareableDisplays: { - switch await firstGate.nextOutcome() { - case .success: - return [refreshedDisplay] - case .failure: - throw IntegrationControlledLoadFailure() - } - }, - activeDisplayIDsProvider: activeDisplayIDsProvider, - dependencies: .init( - sharingQueries: .init( - isWebServiceRunning: { true }, - isStartingDisplayID: { _ in false }, - sharePageAddress: { _ in nil }, - preferredWebServicePort: { 8081 } - ), - sharingActions: .init( - startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, - stopWebService: {}, - registerShareableDisplays: { _, _ in - registerCounter.value += 1 - }, - beginSharing: { _ in .started(()) }, - stopSharing: { _ in } - ), - virtualDisplayQueries: .init( - virtualSerialForManagedDisplay: { _ in nil } - ) - ) - ) - - firstVM.refreshPermissionAndMaybeLoad() - #expect(await waitForLoaderCall(firstGate, count: 1)) - await firstGate.release(call: 1) - - let firstRefreshFinished = await waitUntil { - sharedCatalogState.isLoadingDisplays == false && - sharedCatalogState.displays?.map { $0.displayID } == [5555] && - sharedCatalogState.lastLoadedActiveDisplayTopologySignature - == makeTestDisplayTopologySignature([5555]) - } - #expect(firstRefreshFinished) - #expect(registerCounter.value >= 1) - - let secondGate = IntegrationSequencedDisplayLoaderGate(scriptedOutcomes: [.success]) - let secondCatalogService = makeCatalogService( - store: sharedCatalogState, - permissionProvider: permissionProvider, - loadShareableDisplays: { - switch await secondGate.nextOutcome() { - case .success: - return [refreshedDisplay] - case .failure: - throw IntegrationControlledLoadFailure() - } - }, - activeDisplayIDsProvider: activeDisplayIDsProvider - ) - let secondVM = ShareViewModel( - catalogService: secondCatalogService, - catalogState: sharedCatalogState, - permissionProvider: permissionProvider, - loadShareableDisplays: { - switch await secondGate.nextOutcome() { - case .success: - return [refreshedDisplay] - case .failure: - throw IntegrationControlledLoadFailure() - } - }, - activeDisplayIDsProvider: activeDisplayIDsProvider, - dependencies: .init( - sharingQueries: .init( - isWebServiceRunning: { true }, - isStartingDisplayID: { _ in false }, - sharePageAddress: { _ in nil }, - preferredWebServicePort: { 8081 } - ), - sharingActions: .init( - startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, - stopWebService: {}, - registerShareableDisplays: { _, _ in - registerCounter.value += 1 - }, - beginSharing: { _ in .started(()) }, - stopSharing: { _ in } - ), - virtualDisplayQueries: .init( - virtualSerialForManagedDisplay: { _ in nil } - ) - ) - ) - - secondVM.refreshPermissionAndMaybeLoad() - let secondCheckDeadline = DispatchTime.now().uptimeNanoseconds + AsyncTestTimeouts.shortStabilityWindow - var observedUnexpectedLoad = false - while DispatchTime.now().uptimeNanoseconds < secondCheckDeadline { - if await secondGate.currentCallCount() > 0 { - observedUnexpectedLoad = true - break - } - await Task.yield() - } - #expect(observedUnexpectedLoad == false) - #expect(registerCounter.value >= 2) - } - - private func waitForLoaderCall(_ gate: IntegrationSequencedDisplayLoaderGate, count: Int) async -> Bool { - let deadline = DispatchTime.now().uptimeNanoseconds + AsyncTestTimeouts.defaultAsyncAssertion - while DispatchTime.now().uptimeNanoseconds < deadline { - if await gate.currentCallCount() >= count { - return true - } - await Task.yield() - } - return await gate.currentCallCount() >= count - } - - private func makeCatalogService( - store: ScreenCaptureDisplayCatalogState, - permissionProvider: any ScreenCapturePermissionProvider, - loadShareableDisplays: @escaping @MainActor () async throws -> [SCDisplay], - activeDisplayIDsProvider: @escaping @MainActor () -> Set - ) -> ScreenCaptureCatalogService { - ScreenCaptureCatalogService( - store: store, - permissionProvider: permissionProvider, - loadShareableDisplays: loadShareableDisplays, - activeDisplayIDsProvider: activeDisplayIDsProvider, - displayTopologySignatureProvider: { - ScreenCaptureDisplayTopologySignatureResolver.current( - activeDisplayIDsProvider: activeDisplayIDsProvider - ) - }, - runtimeScenarioProbe: .init( - shouldShortCircuitDisplayLoadAsPermissionDenied: { false }, - shouldDelayDisplayLoadForUITest: { false } - ) - ) - } - - private func makeNoopCaptureDependencies() -> CaptureChooseViewModel.Dependencies { - .init( - captureActions: .init( - monitoringSessionForDisplayID: { _ in nil }, - isStartingDisplayID: { _ in false }, - startMonitoring: { _, _ in .started(UUID()) } - ), - virtualDisplayQueries: .init( - isManagedVirtualDisplay: { _ in false } - ) - ) - } -} - -private final class IntegrationMockSCDisplayBox: NSObject { - @objc let displayID: CGDirectDisplayID - @objc let width: Int - @objc let height: Int - @objc let frame: CGRect - - init(displayID: CGDirectDisplayID, width: Int, height: Int) { - self.displayID = displayID - self.width = width - self.height = height - self.frame = CGRect(x: 0, y: 0, width: width, height: height) - super.init() - } -} - -private enum IntegrationMockSCDisplay { - static func make(displayID: CGDirectDisplayID, width: Int, height: Int) -> SCDisplay { - let box = IntegrationMockSCDisplayBox(displayID: displayID, width: width, height: height) - return unsafeBitCast(box, to: SCDisplay.self) - } -} diff --git a/VoidDisplayTests/App/ScreenCatalogOrchestratorTests.swift b/VoidDisplayTests/App/ScreenCatalogOrchestratorTests.swift new file mode 100644 index 0000000..74d8dcb --- /dev/null +++ b/VoidDisplayTests/App/ScreenCatalogOrchestratorTests.swift @@ -0,0 +1,415 @@ +import CoreGraphics +import Foundation +import ScreenCaptureKit +import Testing +@testable import VoidDisplay + +private final class ScreenCatalogOrchestratorMockSCDisplayBox: NSObject { + @objc let displayID: CGDirectDisplayID + @objc let width: Int + @objc let height: Int + @objc let frame: CGRect + + init(displayID: CGDirectDisplayID, width: Int, height: Int) { + self.displayID = displayID + self.width = width + self.height = height + self.frame = CGRect(x: 0, y: 0, width: width, height: height) + super.init() + } +} + +private enum ScreenCatalogOrchestratorMockSCDisplay { + static func make(displayID: CGDirectDisplayID, width: Int, height: Int) -> SCDisplay { + let box = ScreenCatalogOrchestratorMockSCDisplayBox( + displayID: displayID, + width: width, + height: height + ) + return unsafeBitCast(box, to: SCDisplay.self) + } +} + +private actor ScreenCatalogOrchestratorLoadGate { + private var displaysByCall: [[SendableDisplay]] + private var callCount = 0 + private var continuations: [Int: CheckedContinuation<[SendableDisplay], Never>] = [:] + + init(displaysByCall: [[SendableDisplay]]) { + self.displaysByCall = displaysByCall + } + + func waitForNextDisplays() async -> [SendableDisplay] { + callCount += 1 + let currentCall = callCount + return await withCheckedContinuation { continuation in + continuations[currentCall] = continuation + } + } + + func release(call: Int) { + guard let continuation = continuations.removeValue(forKey: call) else { return } + let displays = displaysByCall.indices.contains(call - 1) ? displaysByCall[call - 1] : [] + continuation.resume(returning: displays) + } + + func currentCallCount() -> Int { + callCount + } +} + +@MainActor +private final class ScreenCatalogOrchestratorActiveDisplayIDsBox { + var ids: Set + + init(ids: Set) { + self.ids = ids + } +} + +@Suite(.serialized) +@MainActor +struct ScreenCatalogOrchestratorTests { + private final class OrchestratorPortPreferences: SharingPortPreferencesProtocol { + var preferredPort: UInt16 = 8081 + + func savePreferredPort(_ port: UInt16) { + preferredPort = port + } + } + + @Test func captureAppearRefreshesAndConvergesVisibleDisplays() async { + let removedDisplayID = CGDirectDisplayID(1001) + let keptDisplayID = CGDirectDisplayID(1002) + let removedDisplay = ScreenCatalogOrchestratorMockSCDisplay.make( + displayID: removedDisplayID, + width: 1920, + height: 1080 + ) + let keptDisplay = ScreenCatalogOrchestratorMockSCDisplay.make( + displayID: keptDisplayID, + width: 2560, + height: 1440 + ) + + let catalogService = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider( + preflightResult: true, + requestResult: true + ), + loadShareableDisplays: { [removedDisplay, keptDisplay] }, + activeDisplayIDsProvider: { [keptDisplayID] } + ) + + let sharingService = MockSharingService() + sharingService.isWebServiceRunning = true + sharingService.webServiceLifecycleState = .running(.init(requestedPort: 8081, boundPort: 8081)) + sharingService.activeSharingDisplayIDs = [removedDisplayID, keptDisplayID] + sharingService.hasAnyActiveSharing = true + let sharingController = SharingController( + sharingService: sharingService, + portPreferences: OrchestratorPortPreferences(), + catalogService: catalogService + ) + + let captureService = MockCaptureMonitoringService() + captureService.currentSessions = [ + makeSession(id: UUID(), displayID: removedDisplayID), + makeSession(id: UUID(), displayID: keptDisplayID), + ] + let captureController = CaptureController( + captureMonitoringService: captureService, + catalogService: catalogService + ) + let virtualDisplay = makeVirtualDisplayController() + let orchestrator = ScreenCatalogOrchestrator( + catalogService: catalogService, + capture: captureController, + sharing: sharingController, + virtualDisplay: virtualDisplay + ) + + await orchestrator.handleAppear(source: .capturePage) + + #expect(catalogService.store.displays?.map(\.displayID) == [removedDisplayID, keptDisplayID]) + #expect(sharingService.registeredShareableDisplays.map(\.displayID) == [keptDisplayID]) + #expect(sharingService.stopSharingCallCount == 1) + #expect(sharingService.activeSharingDisplayIDs == [keptDisplayID]) + #expect(captureService.removedDisplayIDs == [removedDisplayID]) + } + + @Test func sharingAppearWithStoppedServiceCancelsRefreshWithoutClearingSnapshot() async { + let display = ScreenCatalogOrchestratorMockSCDisplay.make( + displayID: 2001, + width: 1920, + height: 1080 + ) + let loadGate = ScreenCatalogOrchestratorLoadGate(displaysByCall: [[SendableDisplay(display)]]) + let catalogService = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider( + preflightResult: true, + requestResult: true + ), + loadShareableDisplays: { + await loadGate.waitForNextDisplays().map(\.value) + }, + activeDisplayIDsProvider: { [display.displayID] } + ) + catalogService.store.hasScreenCapturePermission = true + catalogService.store.displays = [display] + catalogService.store.lastLoadedActiveDisplayTopologySignature = makeTestDisplayTopologySignature( + [display.displayID] + ) + + let sharingController = SharingController( + sharingService: MockSharingService(), + portPreferences: OrchestratorPortPreferences(), + catalogService: catalogService + ) + let orchestrator = ScreenCatalogOrchestrator( + catalogService: catalogService, + capture: CaptureController( + captureMonitoringService: MockCaptureMonitoringService(), + catalogService: catalogService + ), + sharing: sharingController, + virtualDisplay: makeVirtualDisplayController() + ) + + await orchestrator.handleAppear(source: .sharingPage) + + #expect(catalogService.store.isLoadingDisplays == false) + #expect(catalogService.store.displays?.map(\.displayID) == [display.displayID]) + #expect(await loadGate.currentCallCount() == 0) + } + + @Test func sharingServiceStartReplaysRegistrationWhenSnapshotIsReused() async { + let display = ScreenCatalogOrchestratorMockSCDisplay.make( + displayID: 3001, + width: 2560, + height: 1440 + ) + let catalogService = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider( + preflightResult: true, + requestResult: true + ), + loadShareableDisplays: { + Issue.record("Expected cached snapshot reuse without a fresh load.") + return [] + }, + activeDisplayIDsProvider: { [display.displayID] } + ) + catalogService.store.hasScreenCapturePermission = true + catalogService.store.displays = [display] + catalogService.store.lastLoadedActiveDisplayTopologySignature = makeTestDisplayTopologySignature( + [display.displayID] + ) + + let sharingService = MockSharingService() + sharingService.isWebServiceRunning = true + sharingService.webServiceLifecycleState = .running(.init(requestedPort: 8081, boundPort: 8081)) + let sharingController = SharingController( + sharingService: sharingService, + portPreferences: OrchestratorPortPreferences(), + catalogService: catalogService + ) + let orchestrator = ScreenCatalogOrchestrator( + catalogService: catalogService, + capture: CaptureController( + captureMonitoringService: MockCaptureMonitoringService(), + catalogService: catalogService + ), + sharing: sharingController, + virtualDisplay: makeVirtualDisplayController() + ) + + await orchestrator.handleSharingServiceStateChanged(isRunning: true) + + #expect(catalogService.store.lastRefreshResult == .reusedSnapshot) + #expect(sharingService.registerShareableDisplaysCallCount == 1) + #expect(sharingService.registeredShareableDisplays.map(\.displayID) == [display.displayID]) + } + + @Test func permissionDeniedClearsSnapshotAndStopsInvalidSessions() async { + let removedDisplayID = CGDirectDisplayID(4001) + let catalogService = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider( + preflightResult: false, + requestResult: false + ), + loadShareableDisplays: { + Issue.record("Display load should not run when permission is denied.") + return [] + }, + activeDisplayIDsProvider: { [removedDisplayID] } + ) + catalogService.store.displays = [ + ScreenCatalogOrchestratorMockSCDisplay.make(displayID: removedDisplayID, width: 1920, height: 1080) + ] + + let sharingService = MockSharingService() + sharingService.isWebServiceRunning = true + sharingService.webServiceLifecycleState = .running(.init(requestedPort: 8081, boundPort: 8081)) + sharingService.activeSharingDisplayIDs = [removedDisplayID] + sharingService.hasAnyActiveSharing = true + let sharingController = SharingController( + sharingService: sharingService, + portPreferences: OrchestratorPortPreferences(), + catalogService: catalogService + ) + let captureService = MockCaptureMonitoringService() + captureService.currentSessions = [ + makeSession(id: UUID(), displayID: removedDisplayID) + ] + let captureController = CaptureController( + captureMonitoringService: captureService, + catalogService: catalogService + ) + let orchestrator = ScreenCatalogOrchestrator( + catalogService: catalogService, + capture: captureController, + sharing: sharingController, + virtualDisplay: makeVirtualDisplayController() + ) + + await orchestrator.refreshPermission(source: .capturePage) + + #expect(catalogService.store.hasScreenCapturePermission == false) + #expect(sharingService.registerShareableDisplaysCallCount >= 1) + #expect(sharingService.registeredShareableDisplays.isEmpty) + #expect(sharingService.stopSharingCallCount == 1) + #expect(sharingService.activeSharingDisplayIDs.isEmpty) + #expect(captureService.removedDisplayIDs == [removedDisplayID]) + } + + @Test func topologyChangeCoalescesAndAppliesLatestVisibleDisplays() async { + let firstDisplayID = CGDirectDisplayID(5001) + let secondDisplayID = CGDirectDisplayID(5002) + let firstDisplay = ScreenCatalogOrchestratorMockSCDisplay.make( + displayID: firstDisplayID, + width: 1920, + height: 1080 + ) + let secondDisplay = ScreenCatalogOrchestratorMockSCDisplay.make( + displayID: secondDisplayID, + width: 2560, + height: 1440 + ) + let loadGate = ScreenCatalogOrchestratorLoadGate( + displaysByCall: [ + [SendableDisplay(firstDisplay)], + [SendableDisplay(secondDisplay)], + ] + ) + let activeDisplayIDs = ScreenCatalogOrchestratorActiveDisplayIDsBox(ids: [firstDisplayID]) + let catalogService = ScreenCaptureCatalogService( + permissionProvider: MockScreenCapturePermissionProvider( + preflightResult: true, + requestResult: true + ), + loadShareableDisplays: { + await loadGate.waitForNextDisplays().map(\.value) + }, + activeDisplayIDsProvider: { + activeDisplayIDs.ids + } + ) + + let sharingService = MockSharingService() + sharingService.isWebServiceRunning = true + sharingService.webServiceLifecycleState = .running(.init(requestedPort: 8081, boundPort: 8081)) + let orchestrator = ScreenCatalogOrchestrator( + catalogService: catalogService, + capture: CaptureController( + captureMonitoringService: MockCaptureMonitoringService(), + catalogService: catalogService + ), + sharing: SharingController( + sharingService: sharingService, + portPreferences: OrchestratorPortPreferences(), + catalogService: catalogService + ), + virtualDisplay: makeVirtualDisplayController() + ) + + let firstRefresh = Task { await orchestrator.handleTopologyChanged() } + #expect(await waitForLoaderCall(loadGate, count: 1)) + + activeDisplayIDs.ids = [secondDisplayID] + let secondRefresh = Task { await orchestrator.handleTopologyChanged() } + await loadGate.release(call: 1) + + #expect(await waitForLoaderCall(loadGate, count: 2)) + await loadGate.release(call: 2) + await firstRefresh.value + await secondRefresh.value + + #expect(sharingService.registerShareableDisplaysCallCount >= 2) + #expect(sharingService.registeredShareableDisplays.map(\.displayID) == [secondDisplayID]) + #expect(catalogService.store.displays?.map(\.displayID) == [secondDisplayID]) + } + + private func makeVirtualDisplayController() -> VirtualDisplayController { + VirtualDisplayController( + virtualDisplayFacade: MockVirtualDisplayFacade(), + appliedBadgeDisplayDuration: .seconds(1), + stopDependentStreamsBeforeRebuild: { _ in } + ) + } + + private func makeSession(id: UUID, displayID: CGDirectDisplayID) -> ScreenMonitoringSession { + ScreenMonitoringSession( + id: id, + displayID: displayID, + displayName: "Display \(displayID)", + resolutionText: "1920 x 1080", + isVirtualDisplay: false, + previewSubscription: DisplayPreviewSubscription( + displayID: displayID, + resolutionText: "1920 x 1080", + session: ScreenCatalogOrchestratorDummySession(), + cancelClosure: {} + ), + capturesCursor: false, + state: .active + ) + } + + private func waitForLoaderCall( + _ gate: ScreenCatalogOrchestratorLoadGate, + count: Int, + timeoutNanoseconds: UInt64 = AsyncTestTimeouts.defaultAsyncAssertion, + pollNanoseconds: UInt64 = 10_000_000 + ) async -> Bool { + let deadline = DispatchTime.now().uptimeNanoseconds + timeoutNanoseconds + while DispatchTime.now().uptimeNanoseconds < deadline { + if await gate.currentCallCount() >= count { + return true + } + try? await Task.sleep(for: .nanoseconds(pollNanoseconds)) + } + return await gate.currentCallCount() >= count + } + +} + +private final class ScreenCatalogOrchestratorDummySession: DisplayCaptureSessioning, @unchecked Sendable { + nonisolated let sessionHub = WebRTCSessionHub() + + nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { + _ = sink + } + + nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { + _ = sink + } + + nonisolated func stopSharing() {} + + nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws { + _ = demand + } + + nonisolated func stop() async {} +} diff --git a/VoidDisplayTests/App/SharingControllerTests.swift b/VoidDisplayTests/App/SharingControllerTests.swift index 6810f18..71dc818 100644 --- a/VoidDisplayTests/App/SharingControllerTests.swift +++ b/VoidDisplayTests/App/SharingControllerTests.swift @@ -17,14 +17,10 @@ private final class SharingControllerDummySession: DisplayCaptureSessioning, @un nonisolated func stopSharing() {} - nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { - _ = showsCursor + nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws { + _ = demand } - nonisolated func retainShareCursorOverride() async throws {} - - nonisolated func releaseShareCursorOverride() async throws {} - nonisolated func stop() async {} } @@ -189,7 +185,6 @@ struct SharingControllerTests { let subscription = DisplayShareSubscription( displayID: displayID, sessionHub: WebRTCSessionHub(), - session: SharingControllerDummySession(), cancelClosure: {} ) let sut = makeRealSharingController( @@ -334,7 +329,7 @@ struct SharingControllerTests { sharingService: service, portPreferences: MockSharingPortPreferences() ) - sut.startingDisplayIDs = [displayID] + sut.installStartingDisplayIDsForTesting([displayID]) sut.stopSharing(displayID: displayID) @@ -349,7 +344,6 @@ struct SharingControllerTests { let subscription = DisplayShareSubscription( displayID: displayID, sessionHub: WebRTCSessionHub(), - session: SharingControllerDummySession(), cancelClosure: {} ) let sut = makeRealSharingController( diff --git a/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringLifecycleServiceTests.swift b/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringLifecycleServiceTests.swift index 90e35d4..1d5334a 100644 --- a/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringLifecycleServiceTests.swift +++ b/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringLifecycleServiceTests.swift @@ -26,18 +26,14 @@ private final class CaptureMonitoringLifecycleDummySession: DisplayCaptureSessio nonisolated func stopSharing() {} - nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { + nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws { cursorUpdateCount += 1 - lastShowsCursor = showsCursor + lastShowsCursor = demand.previewShowsCursor if let cursorUpdateError { throw cursorUpdateError } } - nonisolated func retainShareCursorOverride() async throws {} - - nonisolated func releaseShareCursorOverride() async throws {} - nonisolated func stop() async {} } @@ -633,7 +629,15 @@ struct CaptureMonitoringLifecycleServiceTests { displayID: displayID, resolutionText: "1920 × 1080", session: captureSession, - cancelClosure: { cancelCounter.value += 1 } + cancelClosure: { cancelCounter.value += 1 }, + setShowsCursorClosure: { showsCursor in + try await captureSession.setDemand( + DisplayCaptureDemandSnapshot( + previewShowsCursor: showsCursor, + performanceMode: .automatic + ) + ) + } ) return (subscription, captureSession, cancelCounter) } diff --git a/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringServiceTests.swift b/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringServiceTests.swift index cb58f38..4a58954 100644 --- a/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringServiceTests.swift +++ b/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringServiceTests.swift @@ -16,14 +16,10 @@ private final class CaptureMonitoringDummySession: DisplayCaptureSessioning, @un nonisolated func stopSharing() {} - nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { - _ = showsCursor + nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws { + _ = demand } - nonisolated func retainShareCursorOverride() async throws {} - - nonisolated func releaseShareCursorOverride() async throws {} - nonisolated func stop() async {} } diff --git a/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringSessionStoreTests.swift b/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringSessionStoreTests.swift index ba9b8d2..26a5562 100644 --- a/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringSessionStoreTests.swift +++ b/VoidDisplayTests/Features/Capture/Services/CaptureMonitoringSessionStoreTests.swift @@ -16,14 +16,10 @@ private final class CaptureMonitoringSessionStoreDummySession: DisplayCaptureSes nonisolated func stopSharing() {} - nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { - _ = showsCursor + nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws { + _ = demand } - nonisolated func retainShareCursorOverride() async throws {} - - nonisolated func releaseShareCursorOverride() async throws {} - nonisolated func stop() async {} } diff --git a/VoidDisplayTests/Features/Capture/Services/DisplayCaptureDemandDriverTests.swift b/VoidDisplayTests/Features/Capture/Services/DisplayCaptureDemandDriverTests.swift new file mode 100644 index 0000000..1fbee50 --- /dev/null +++ b/VoidDisplayTests/Features/Capture/Services/DisplayCaptureDemandDriverTests.swift @@ -0,0 +1,344 @@ +import Foundation +import Synchronization +import Testing +@testable import VoidDisplay + +private func makeDriverDemand( + attachedPreviewSinkCount: Int = 0, + shareTokenCount: Int = 0, + previewShowsCursor: Bool = false, + shareCursorOverrideCount: Int = 0, + performanceMode: CapturePerformanceMode = .automatic +) -> DisplayCaptureDemandSnapshot { + DisplayCaptureDemandSnapshot( + attachedPreviewSinkCount: attachedPreviewSinkCount, + shareTokenCount: shareTokenCount, + previewShowsCursor: previewShowsCursor, + shareCursorOverrideCount: shareCursorOverrideCount, + performanceMode: performanceMode + ) +} + +private final class DemandDriverTimeSource: @unchecked Sendable { + private let value: Mutex + + nonisolated init(_ initialValue: UInt64) { + self.value = Mutex(initialValue) + } + + nonisolated func set(_ newValue: UInt64) { + value.withLock { $0 = newValue } + } + + nonisolated func now() -> UInt64 { + value.withLock { $0 } + } +} + +private final class DemandDriverRecorder: @unchecked Sendable { + private struct State { + var immediateDemands: [DisplayCaptureDemandSnapshot] = [] + var configurations: [DisplayCaptureConfiguration] = [] + var appliedConfigurations: [DisplayCaptureConfiguration] = [] + var failureCount = 0 + } + + private let state = Mutex(State()) + + nonisolated func recordImmediateDemand(_ demand: DisplayCaptureDemandSnapshot) { + state.withLock { $0.immediateDemands.append(demand) } + } + + nonisolated func recordConfiguration(_ configuration: DisplayCaptureConfiguration) { + state.withLock { $0.configurations.append(configuration) } + } + + nonisolated func recordAppliedConfiguration(_ configuration: DisplayCaptureConfiguration) { + state.withLock { $0.appliedConfigurations.append(configuration) } + } + + nonisolated func recordFailure() { + state.withLock { $0.failureCount += 1 } + } + + nonisolated var immediateDemands: [DisplayCaptureDemandSnapshot] { + state.withLock { $0.immediateDemands } + } + + nonisolated var configurations: [DisplayCaptureConfiguration] { + state.withLock { $0.configurations } + } + + nonisolated var appliedConfigurations: [DisplayCaptureConfiguration] { + state.withLock { $0.appliedConfigurations } + } + + nonisolated var failureCount: Int { + state.withLock { $0.failureCount } + } +} + +struct DisplayCaptureDemandDriverTests { + @Test func setDemandAppliesImmediateDemandAndMixedConfiguration() async throws { + let timeSource = DemandDriverTimeSource(1) + let recorder = DemandDriverRecorder() + let driver = DisplayCaptureDemandDriver( + initialConfiguration: .init(profile: .previewOnly, frameRateTier: .fps60), + initialDemand: makeDriverDemand(attachedPreviewSinkCount: 1), + minimumDwellNanoseconds: 0, + currentTimeNanoseconds: timeSource.now, + applyImmediateDemand: { demand in + recorder.recordImmediateDemand(demand) + return true + }, + applyConfiguration: { configuration in + recorder.recordConfiguration(configuration) + return true + }, + onConfigurationApplied: { configuration in + recorder.recordAppliedConfiguration(configuration) + }, + onConfigurationFailure: { _ in + recorder.recordFailure() + } + ) + + let demand = makeDriverDemand( + attachedPreviewSinkCount: 1, + shareTokenCount: 1, + previewShowsCursor: true + ) + try await driver.setDemand(demand) + + #expect(recorder.immediateDemands.last == demand) + #expect( + await waitUntil { + recorder.configurations == [.init(profile: .mixed, frameRateTier: .fps45)] && + recorder.appliedConfigurations == [.init(profile: .mixed, frameRateTier: .fps45)] + } + ) + #expect(recorder.failureCount == 0) + } + + @Test func delayedConfigurationAppliesAfterDwellWindow() async throws { + let timeSource = DemandDriverTimeSource(1) + let recorder = DemandDriverRecorder() + let driver = DisplayCaptureDemandDriver( + initialConfiguration: .init(profile: .previewOnly, frameRateTier: .fps60), + initialDemand: makeDriverDemand(attachedPreviewSinkCount: 1), + minimumDwellNanoseconds: 50_000_000, + currentTimeNanoseconds: timeSource.now, + applyImmediateDemand: { demand in + recorder.recordImmediateDemand(demand) + return true + }, + applyConfiguration: { configuration in + recorder.recordConfiguration(configuration) + return true + }, + onConfigurationApplied: { configuration in + recorder.recordAppliedConfiguration(configuration) + } + ) + + try await driver.setDemand(makeDriverDemand(shareTokenCount: 1)) + #expect( + await waitUntil { + recorder.configurations == [.init(profile: .shareOnly, frameRateTier: .fps60)] + } + ) + + timeSource.set(2) + try await driver.setDemand( + makeDriverDemand(attachedPreviewSinkCount: 1, shareTokenCount: 1) + ) + + #expect( + await staysTrue(timeoutNanoseconds: 10_000_000) { + recorder.configurations.count == 1 + } + ) + + timeSource.set(100_000_000) + #expect( + await waitUntil { + recorder.configurations == [ + .init(profile: .shareOnly, frameRateTier: .fps60), + .init(profile: .mixed, frameRateTier: .fps45) + ] + } + ) + } + + @Test func newerDemandCancelsObsoleteDelayedTransition() async throws { + let timeSource = DemandDriverTimeSource(1) + let recorder = DemandDriverRecorder() + let driver = DisplayCaptureDemandDriver( + initialConfiguration: .init(profile: .previewOnly, frameRateTier: .fps60), + initialDemand: makeDriverDemand(attachedPreviewSinkCount: 1), + minimumDwellNanoseconds: 50_000_000, + currentTimeNanoseconds: timeSource.now, + applyImmediateDemand: { demand in + recorder.recordImmediateDemand(demand) + return true + }, + applyConfiguration: { configuration in + recorder.recordConfiguration(configuration) + return true + } + ) + + try await driver.setDemand(makeDriverDemand(shareTokenCount: 1)) + #expect( + await waitUntil { + recorder.configurations == [.init(profile: .shareOnly, frameRateTier: .fps60)] + } + ) + + timeSource.set(2) + try await driver.setDemand( + makeDriverDemand(attachedPreviewSinkCount: 1, shareTokenCount: 1) + ) + timeSource.set(3) + try await driver.setDemand(makeDriverDemand(shareTokenCount: 1)) + timeSource.set(100_000_000) + + #expect( + await staysTrue(timeoutNanoseconds: 80_000_000) { + recorder.configurations == [.init(profile: .shareOnly, frameRateTier: .fps60)] + } + ) + } + + @Test func failedConfigurationApplyReportsFailureAndAllowsRetry() async throws { + let timeSource = DemandDriverTimeSource(1) + let recorder = DemandDriverRecorder() + let shouldFailNext = Mutex(true) + struct TestFailure: Error {} + + let driver = DisplayCaptureDemandDriver( + initialConfiguration: .init(profile: .previewOnly, frameRateTier: .fps60), + initialDemand: makeDriverDemand(attachedPreviewSinkCount: 1), + minimumDwellNanoseconds: 0, + currentTimeNanoseconds: timeSource.now, + applyImmediateDemand: { demand in + recorder.recordImmediateDemand(demand) + return demand.showsCursor + }, + applyConfiguration: { configuration in + let shouldFail = shouldFailNext.withLock { state -> Bool in + let current = state + state = false + return current + } + if shouldFail { + throw TestFailure() + } + recorder.recordConfiguration(configuration) + return true + }, + onConfigurationApplied: { configuration in + recorder.recordAppliedConfiguration(configuration) + }, + onConfigurationFailure: { _ in + recorder.recordFailure() + } + ) + + try await driver.setDemand(makeDriverDemand(shareTokenCount: 1)) + #expect(await waitUntil { recorder.failureCount == 1 }) + #expect(recorder.configurations.isEmpty) + + timeSource.set(2) + try await driver.setDemand(makeDriverDemand(shareTokenCount: 1)) + #expect( + await waitUntil { + recorder.configurations == [.init(profile: .shareOnly, frameRateTier: .fps60)] && + recorder.appliedConfigurations == [.init(profile: .shareOnly, frameRateTier: .fps60)] + } + ) + } + + @Test func previewPressureSamplesDriveAutomaticMixedDowngrade() async { + let timeSource = DemandDriverTimeSource(1) + let recorder = DemandDriverRecorder() + let driver = DisplayCaptureDemandDriver( + initialConfiguration: .init(profile: .mixed, frameRateTier: .fps45), + initialDemand: makeDriverDemand( + attachedPreviewSinkCount: 1, + shareTokenCount: 1 + ), + minimumDwellNanoseconds: 0, + currentTimeNanoseconds: timeSource.now, + applyImmediateDemand: { demand in + recorder.recordImmediateDemand(demand) + return demand.showsCursor + }, + applyConfiguration: { configuration in + recorder.recordConfiguration(configuration) + return true + }, + onConfigurationApplied: { configuration in + recorder.recordAppliedConfiguration(configuration) + } + ) + + driver.recordPreviewPerformanceSample( + .init( + renderedFrameCount: 90, + droppedFrameCount: 10, + latestRenderLatencyMilliseconds: 12, + pendingSlotOccupied: false, + capturedAt: 1 + ) + ) + timeSource.set(2) + driver.recordPreviewPerformanceSample( + .init( + renderedFrameCount: 80, + droppedFrameCount: 20, + latestRenderLatencyMilliseconds: 15, + pendingSlotOccupied: false, + capturedAt: 2 + ) + ) + + #expect( + await waitUntil { + recorder.configurations == [.init(profile: .mixed, frameRateTier: .fps30)] + } + ) + } + + @Test func cursorDemandAppliesImmediatelyWithoutWaitingForProfileDwell() async throws { + let timeSource = DemandDriverTimeSource(1) + let recorder = DemandDriverRecorder() + let driver = DisplayCaptureDemandDriver( + initialConfiguration: .init(profile: .shareOnly, frameRateTier: .fps60), + initialDemand: makeDriverDemand(shareTokenCount: 1), + minimumDwellNanoseconds: 50_000_000, + currentTimeNanoseconds: timeSource.now, + applyImmediateDemand: { demand in + recorder.recordImmediateDemand(demand) + return true + }, + applyConfiguration: { configuration in + recorder.recordConfiguration(configuration) + return true + } + ) + + let demand = makeDriverDemand( + shareTokenCount: 1, + shareCursorOverrideCount: 1 + ) + try await driver.setDemand(demand) + + #expect(recorder.immediateDemands.last == demand) + #expect( + await staysTrue(timeoutNanoseconds: 10_000_000) { + recorder.configurations.isEmpty + } + ) + } +} diff --git a/VoidDisplayTests/Features/Capture/Services/DisplayCaptureProfileStateMachineTests.swift b/VoidDisplayTests/Features/Capture/Services/DisplayCaptureProfileStateMachineTests.swift index d0d0c0a..3ad732b 100644 --- a/VoidDisplayTests/Features/Capture/Services/DisplayCaptureProfileStateMachineTests.swift +++ b/VoidDisplayTests/Features/Capture/Services/DisplayCaptureProfileStateMachineTests.swift @@ -23,6 +23,22 @@ private func makeTestStreamConfigurationState( ) } +private func makeDemand( + attachedPreviewSinkCount: Int = 0, + shareTokenCount: Int = 0, + previewShowsCursor: Bool = false, + shareCursorOverrideCount: Int = 0, + performanceMode: CapturePerformanceMode = .automatic +) -> DisplayCaptureDemandSnapshot { + DisplayCaptureDemandSnapshot( + attachedPreviewSinkCount: attachedPreviewSinkCount, + shareTokenCount: shareTokenCount, + previewShowsCursor: previewShowsCursor, + shareCursorOverrideCount: shareCursorOverrideCount, + performanceMode: performanceMode + ) +} + private actor StreamConfigurationApplyGate { private var isOpen = false private var enteredCount = 0 @@ -96,34 +112,43 @@ struct DisplayCaptureProfileStateMachineTests { @Test func desiredProfileMatchesPreviewAndSharingDemand() { #expect( DisplayCaptureProfileStateMachine.desiredProfile( - previewSinkCount: 1, - sharingActive: false + for: makeDemand(attachedPreviewSinkCount: 1) ) == .previewOnly ) #expect( DisplayCaptureProfileStateMachine.desiredProfile( - previewSinkCount: 0, - sharingActive: true + for: makeDemand(shareTokenCount: 1) ) == .shareOnly ) #expect( DisplayCaptureProfileStateMachine.desiredProfile( - previewSinkCount: 2, - sharingActive: true + for: makeDemand(attachedPreviewSinkCount: 2, shareTokenCount: 1) ) == .mixed ) #expect( DisplayCaptureProfileStateMachine.desiredProfile( - previewSinkCount: 0, - sharingActive: false + for: makeDemand() ) == nil ) } + @Test func demandSnapshotComputesDerivedState() { + let demand = makeDemand( + attachedPreviewSinkCount: 1, + shareTokenCount: 1, + previewShowsCursor: false, + shareCursorOverrideCount: 2, + performanceMode: .smooth + ) + + #expect(demand.desiredProfile == .mixed) + #expect(demand.showsCursor) + #expect(demand.isEmpty == false) + } + @Test func firstTransitionAppliesImmediately() { let decision = DisplayCaptureProfileStateMachine.decideTransition( - previewSinkCount: 1, - sharingActive: true, + demand: makeDemand(attachedPreviewSinkCount: 1, shareTokenCount: 1), currentProfile: .previewOnly, lastProfileSwitchTimeNs: nil, nowNs: 10, @@ -140,8 +165,7 @@ struct DisplayCaptureProfileStateMachineTests { @Test func dwellWindowSchedulesDelayedTransition() { let decision = DisplayCaptureProfileStateMachine.decideTransition( - previewSinkCount: 0, - sharingActive: true, + demand: makeDemand(shareTokenCount: 1), currentProfile: .previewOnly, lastProfileSwitchTimeNs: 1_000, nowNs: 2_000, @@ -158,8 +182,7 @@ struct DisplayCaptureProfileStateMachineTests { @Test func elapsedDwellAppliesImmediately() { let decision = DisplayCaptureProfileStateMachine.decideTransition( - previewSinkCount: 0, - sharingActive: true, + demand: makeDemand(shareTokenCount: 1), currentProfile: .previewOnly, lastProfileSwitchTimeNs: 1_000, nowNs: 10_000, @@ -240,10 +263,8 @@ struct DisplayCaptureProfileStateMachineTests { @Test func automaticMixedModeDropsTo30AfterTwoPressureWindows() { var coordinator = DisplayCaptureConfigurationCoordinatorState( committedConfiguration: .init(profile: .mixed, frameRateTier: .fps45), - performanceMode: .automatic + demand: makeDemand(attachedPreviewSinkCount: 1, shareTokenCount: 1) ) - coordinator.previewSinkCount = 1 - coordinator.sharingActive = true let firstDecision = coordinator.recordPreviewPerformanceSample( .init( @@ -281,10 +302,8 @@ struct DisplayCaptureProfileStateMachineTests { @Test func automaticMixedModeRisesBackTo60AcrossStableWindows() { var coordinator = DisplayCaptureConfigurationCoordinatorState( committedConfiguration: .init(profile: .mixed, frameRateTier: .fps45), - performanceMode: .automatic + demand: makeDemand(attachedPreviewSinkCount: 1, shareTokenCount: 1) ) - coordinator.previewSinkCount = 1 - coordinator.sharingActive = true let pressureDecision = coordinator.recordPreviewPerformanceSample( .init( @@ -399,10 +418,12 @@ struct DisplayCaptureProfileStateMachineTests { @Test func smoothAndPowerEfficientModesIgnoreAutomaticPreviewPressureSamples() { var smoothCoordinator = DisplayCaptureConfigurationCoordinatorState( committedConfiguration: .init(profile: .mixed, frameRateTier: .fps60), - performanceMode: .smooth + demand: makeDemand( + attachedPreviewSinkCount: 1, + shareTokenCount: 1, + performanceMode: .smooth + ) ) - smoothCoordinator.previewSinkCount = 1 - smoothCoordinator.sharingActive = true let smoothDecision = smoothCoordinator.recordPreviewPerformanceSample( .init( @@ -419,10 +440,12 @@ struct DisplayCaptureProfileStateMachineTests { var powerCoordinator = DisplayCaptureConfigurationCoordinatorState( committedConfiguration: .init(profile: .mixed, frameRateTier: .fps30), - performanceMode: .powerEfficient + demand: makeDemand( + attachedPreviewSinkCount: 1, + shareTokenCount: 1, + performanceMode: .powerEfficient + ) ) - powerCoordinator.previewSinkCount = 1 - powerCoordinator.sharingActive = true let powerDecision = powerCoordinator.recordPreviewPerformanceSample( .init( @@ -441,13 +464,15 @@ struct DisplayCaptureProfileStateMachineTests { @Test func performanceModeUpdateRecomputesCommittedMixedConfiguration() { var coordinator = DisplayCaptureConfigurationCoordinatorState( committedConfiguration: .init(profile: .mixed, frameRateTier: .fps45), - performanceMode: .automatic + demand: makeDemand(attachedPreviewSinkCount: 1, shareTokenCount: 1) ) - coordinator.previewSinkCount = 1 - coordinator.sharingActive = true - let smoothDecision = coordinator.updatePerformanceMode( - .smooth, + let smoothDecision = coordinator.updateDemand( + makeDemand( + attachedPreviewSinkCount: 1, + shareTokenCount: 1, + performanceMode: .smooth + ), nowNs: 1, minimumDwellNanoseconds: 0 ) @@ -465,8 +490,12 @@ struct DisplayCaptureProfileStateMachineTests { ) #expect(followUpDecision == .noChange) - let powerDecision = coordinator.updatePerformanceMode( - .powerEfficient, + let powerDecision = coordinator.updateDemand( + makeDemand( + attachedPreviewSinkCount: 1, + shareTokenCount: 1, + performanceMode: .powerEfficient + ), nowNs: 3, minimumDwellNanoseconds: 0 ) @@ -480,14 +509,16 @@ struct DisplayCaptureProfileStateMachineTests { } @Test func committedTransitionUpdatesDwellBeforeReevaluatingPendingDemand() { - var coordinator = DisplayCaptureProfileCoordinatorState(committedProfile: .previewOnly) + var coordinator = DisplayCaptureProfileCoordinatorState( + committedProfile: .previewOnly, + demand: makeDemand() + ) - let initialDecision = coordinator.mutateDemand( + let initialDecision = coordinator.updateDemand( + makeDemand(shareTokenCount: 1), nowNs: 0, minimumDwellNanoseconds: 5_000 - ) { state in - state.sharingActive = true - } + ) switch initialDecision { case .applyNow(.mixed): Issue.record("Expected shareOnly transition before preview demand arrives") @@ -498,12 +529,11 @@ struct DisplayCaptureProfileStateMachineTests { } #expect(coordinator.inFlightProfile == .shareOnly) - let inFlightDecision = coordinator.mutateDemand( + let inFlightDecision = coordinator.updateDemand( + makeDemand(attachedPreviewSinkCount: 1, shareTokenCount: 1), nowNs: 1_000, minimumDwellNanoseconds: 5_000 - ) { state in - state.previewSinkCount = 1 - } + ) #expect(inFlightDecision == .noChange) #expect(coordinator.inFlightProfile == .shareOnly) @@ -546,7 +576,9 @@ struct DisplayCaptureProfileStateMachineTests { ) let firstTask = Task { - try await coordinator.setPreviewShowsCursor(true) + try await coordinator.applyImmediateDemand( + makeDemand(previewShowsCursor: true) + ) } await gate.waitForFirstEntry() @@ -583,7 +615,9 @@ struct DisplayCaptureProfileStateMachineTests { ) do { - _ = try await coordinator.setPreviewShowsCursor(true) + _ = try await coordinator.applyImmediateDemand( + makeDemand(previewShowsCursor: true) + ) Issue.record("Expected first coordinator apply to fail") } catch { } diff --git a/VoidDisplayTests/Features/Capture/Services/DisplayCaptureRegistryTests.swift b/VoidDisplayTests/Features/Capture/Services/DisplayCaptureRegistryTests.swift index 41733c8..9bee466 100644 --- a/VoidDisplayTests/Features/Capture/Services/DisplayCaptureRegistryTests.swift +++ b/VoidDisplayTests/Features/Capture/Services/DisplayCaptureRegistryTests.swift @@ -8,8 +8,7 @@ private final class FakeCaptureSession: DisplayCaptureSessioning, @unchecked Sen private struct Counters { var stopSharingCalls = 0 var stopCalls = 0 - var setSharingActiveCalls: [Bool] = [] - var setPerformanceModeCalls: [CapturePerformanceMode] = [] + var setDemandCalls: [DisplayCaptureDemandSnapshot] = [] } private let counters = Mutex(Counters()) @@ -27,20 +26,8 @@ private final class FakeCaptureSession: DisplayCaptureSessioning, @unchecked Sen counters.withLock { $0.stopSharingCalls += 1 } } - nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { - _ = showsCursor - } - - nonisolated func retainShareCursorOverride() async throws {} - - nonisolated func releaseShareCursorOverride() async throws {} - - nonisolated func setSharingActive(_ isActive: Bool) async throws { - counters.withLock { $0.setSharingActiveCalls.append(isActive) } - } - - nonisolated func setPerformanceMode(_ mode: CapturePerformanceMode) async throws { - counters.withLock { $0.setPerformanceModeCalls.append(mode) } + nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws { + counters.withLock { $0.setDemandCalls.append(demand) } } nonisolated func stop() async { @@ -55,12 +42,8 @@ private final class FakeCaptureSession: DisplayCaptureSessioning, @unchecked Sen counters.withLock { $0.stopCalls } } - var setSharingActiveCalls: [Bool] { - counters.withLock { $0.setSharingActiveCalls } - } - - var setPerformanceModeCalls: [CapturePerformanceMode] { - counters.withLock { $0.setPerformanceModeCalls } + var setDemandCalls: [DisplayCaptureDemandSnapshot] { + counters.withLock { $0.setDemandCalls } } } @@ -107,16 +90,8 @@ private final class ControlledStopCaptureSession: DisplayCaptureSessioning, @unc counters.withLock { $0.stopSharing += 1 } } - nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { - _ = showsCursor - } - - nonisolated func retainShareCursorOverride() async throws {} - - nonisolated func releaseShareCursorOverride() async throws {} - - nonisolated func setSharingActive(_ isActive: Bool) async throws { - _ = isActive + nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws { + _ = demand } nonisolated func stop() async { @@ -170,7 +145,7 @@ private final class BlockingSetSharingActiveSession: DisplayCaptureSessioning, @ private struct Counters { var stopSharingCalls = 0 var stopCalls = 0 - var setSharingActiveCalls: [Bool] = [] + var setDemandCalls: [DisplayCaptureDemandSnapshot] = [] } nonisolated let sessionHub = WebRTCSessionHub() @@ -193,17 +168,9 @@ private final class BlockingSetSharingActiveSession: DisplayCaptureSessioning, @ counters.withLock { $0.stopSharingCalls += 1 } } - nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { - _ = showsCursor - } - - nonisolated func retainShareCursorOverride() async throws {} - - nonisolated func releaseShareCursorOverride() async throws {} - - nonisolated func setSharingActive(_ isActive: Bool) async throws { - counters.withLock { $0.setSharingActiveCalls.append(isActive) } - guard !isActive else { return } + nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws { + counters.withLock { $0.setDemandCalls.append(demand) } + guard demand.shareTokenCount == 0 else { return } await gate.markFalseEntered() await gate.waitUntilOpen() } @@ -220,8 +187,8 @@ private final class BlockingSetSharingActiveSession: DisplayCaptureSessioning, @ counters.withLock { $0.stopCalls } } - var setSharingActiveCalls: [Bool] { - counters.withLock { $0.setSharingActiveCalls } + var setDemandCalls: [DisplayCaptureDemandSnapshot] { + counters.withLock { $0.setDemandCalls } } } @@ -229,35 +196,22 @@ private enum CursorOverrideTrackingError: Error { case forcedRetainFailure } -private final class CursorOverrideTrackingSession: DisplayCaptureSessioning, @unchecked Sendable { +private final class CursorOverrideTrackingSession: @unchecked Sendable { private struct State { var retainCalls = 0 var releaseCalls = 0 var pendingRetainFailures: [Bool] } - nonisolated let sessionHub = WebRTCSessionHub() private let state: Mutex init(retainFailures: [Bool] = []) { self.state = Mutex(State(pendingRetainFailures: retainFailures)) } - nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { - _ = sink - } - - nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { - _ = sink - } - - nonisolated func stopSharing() {} - - nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { - _ = showsCursor - } + nonisolated let sessionHub = WebRTCSessionHub() - nonisolated func retainShareCursorOverride() async throws { + nonisolated func prepareForSharing() async throws { let shouldFail = state.withLock { state -> Bool in state.retainCalls += 1 guard !state.pendingRetainFailures.isEmpty else { @@ -270,12 +224,10 @@ private final class CursorOverrideTrackingSession: DisplayCaptureSessioning, @un } } - nonisolated func releaseShareCursorOverride() async throws { + nonisolated func releasePreparedShare() async { state.withLock { $0.releaseCalls += 1 } } - nonisolated func stop() async {} - var retainCalls: Int { state.withLock { $0.retainCalls } } @@ -292,16 +244,21 @@ struct DisplayCaptureRegistryTests { let subscription = DisplayShareSubscription( displayID: CGDirectDisplayID(12001), sessionHub: session.sessionHub, - session: session, cancelClosure: { cancelCount.withLock { $0 += 1 } + }, + prepareForSharingClosure: { + try await session.prepareForSharing() + }, + releasePreparedShareClosure: { + await session.releasePreparedShare() } ) let invalidationContext = DisplayStartInvalidationContext() do { _ = try await subscription.prepareForSharing(invalidationContext: invalidationContext) - Issue.record("Expected retainShareCursorOverride to fail.") + Issue.record("Expected prepareForSharing to fail.") } catch { } @@ -319,9 +276,14 @@ struct DisplayCaptureRegistryTests { let subscription = DisplayShareSubscription( displayID: CGDirectDisplayID(12002), sessionHub: session.sessionHub, - session: session, cancelClosure: { cancelCount.withLock { $0 += 1 } + }, + prepareForSharingClosure: { + try await session.prepareForSharing() + }, + releasePreparedShareClosure: { + await session.releasePreparedShare() } ) let invalidationContext = DisplayStartInvalidationContext() @@ -347,17 +309,27 @@ struct DisplayCaptureRegistryTests { let firstSubscription = DisplayShareSubscription( displayID: CGDirectDisplayID(12003), sessionHub: session.sessionHub, - session: session, cancelClosure: { firstCancelCount.withLock { $0 += 1 } + }, + prepareForSharingClosure: { + try await session.prepareForSharing() + }, + releasePreparedShareClosure: { + await session.releasePreparedShare() } ) let secondSubscription = DisplayShareSubscription( displayID: CGDirectDisplayID(12003), sessionHub: session.sessionHub, - session: session, cancelClosure: { secondCancelCount.withLock { $0 += 1 } + }, + prepareForSharingClosure: { + try await session.prepareForSharing() + }, + releasePreparedShareClosure: { + await session.releasePreparedShare() } ) @@ -408,7 +380,7 @@ struct DisplayCaptureRegistryTests { await registry.release(shareToken) #expect(fakeSession.stopSharingCalls == 1) - #expect(fakeSession.setSharingActiveCalls == [false]) + #expect(fakeSession.setDemandCalls.last?.shareTokenCount == 0) #expect(fakeSession.stopCalls == 0) #expect(await registry.sessionState(for: displayID) == .active) @@ -650,12 +622,12 @@ struct DisplayCaptureRegistryTests { ) await registry.updatePerformanceMode(.powerEfficient) - #expect(installedSession.setPerformanceModeCalls == [.powerEfficient]) + #expect(installedSession.setDemandCalls.last?.performanceMode == .powerEfficient) let subscription = try await registry.acquireShare(display: sendableDisplay) #expect(createdModes.withLock { $0.first } == nil) - #expect(installedSession.setSharingActiveCalls == [true]) + #expect(installedSession.setDemandCalls.last?.shareTokenCount == 1) subscription.cancel() @@ -682,6 +654,335 @@ struct DisplayCaptureRegistryTests { } } +private actor SessionStoreStopGate { + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + + func waitUntilOpen() async { + guard isOpen == false else { return } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func open() { + guard isOpen == false else { return } + isOpen = true + let pendingWaiters = waiters + waiters.removeAll() + for waiter in pendingWaiters { + waiter.resume() + } + } +} + +private actor SessionStoreDrainFinisher { + private let store: DisplayCaptureSessionStore + + init(store: DisplayCaptureSessionStore) { + self.store = store + } + + func finish(displayID: CGDirectDisplayID, hasActiveTokens: Bool) { + store.finishDraining(displayID: displayID, hasActiveTokens: hasActiveTokens) + } +} + +private final class SessionStoreFakeSession: DisplayCaptureSessioning, @unchecked Sendable { + nonisolated let sessionHub = WebRTCSessionHub() + private let stopCallCountValue = Mutex(0) + + nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { + _ = sink + } + + nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { + _ = sink + } + + nonisolated func stopSharing() {} + + nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws { + _ = demand + } + + nonisolated func stop() async { + stopCallCountValue.withLock { $0 += 1 } + } + + var stopCallCount: Int { + stopCallCountValue.withLock { $0 } + } +} + +private final class SessionStoreControlledStopSession: DisplayCaptureSessioning, @unchecked Sendable { + nonisolated let sessionHub = WebRTCSessionHub() + private let stopGate: SessionStoreStopGate + private let stopCallCountValue = Mutex(0) + + init(stopGate: SessionStoreStopGate) { + self.stopGate = stopGate + } + + nonisolated func attachPreviewSink(_ sink: any DisplayPreviewSink) { + _ = sink + } + + nonisolated func detachPreviewSink(_ sink: any DisplayPreviewSink) { + _ = sink + } + + nonisolated func stopSharing() {} + + nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws { + _ = demand + } + + nonisolated func stop() async { + stopCallCountValue.withLock { $0 += 1 } + await stopGate.waitUntilOpen() + } + + var stopCallCount: Int { + stopCallCountValue.withLock { $0 } + } +} + +struct DisplayCaptureLeaseBookTests { + @Test func initialProfileUsesPendingCreationDemand() { + let book = DisplayCaptureLeaseBook() + let displayID = CGDirectDisplayID(21001) + + book.recordPendingCreationDemand(for: displayID, kind: .preview, delta: 1) + #expect(book.initialProfile(for: displayID, fallbackKind: .preview) == .previewOnly) + + book.recordPendingCreationDemand(for: displayID, kind: .share, delta: 1) + #expect(book.initialProfile(for: displayID, fallbackKind: .preview) == .mixed) + + book.recordPendingCreationDemand(for: displayID, kind: .preview, delta: -1) + #expect(book.initialProfile(for: displayID, fallbackKind: .preview) == .shareOnly) + } + + @Test func demandSnapshotCombinesPreviewShareAndCursorState() { + let book = DisplayCaptureLeaseBook() + let displayID = CGDirectDisplayID(21002) + + let previewToken = book.registerToken(displayID: displayID, kind: .preview) + let shareToken = book.registerToken(displayID: displayID, kind: .share) + + _ = book.recordAttachedPreviewSinkDelta(2, for: previewToken) + let cursorMutation = book.setPreviewShowsCursor(true, for: previewToken) + #expect(cursorMutation?.previousValue == false) + #expect(book.prepareShareForSharing(shareToken) == displayID) + + let snapshot = book.demandSnapshot(for: displayID, performanceMode: .smooth) + + #expect(snapshot.attachedPreviewSinkCount == 2) + #expect(snapshot.shareTokenCount == 1) + #expect(snapshot.previewShowsCursor) + #expect(snapshot.shareCursorOverrideCount == 1) + #expect(snapshot.performanceMode == .smooth) + #expect(snapshot.showsCursor) + #expect(snapshot.desiredProfile == .mixed) + } + + @Test func preparedShareRollbackAndCancelClearCursorOverrideDemand() { + let book = DisplayCaptureLeaseBook() + let displayID = CGDirectDisplayID(21003) + let shareToken = book.registerToken(displayID: displayID, kind: .share) + + #expect(book.prepareShareForSharing(shareToken) == displayID) + #expect( + book.demandSnapshot(for: displayID, performanceMode: .automatic).shareCursorOverrideCount == 1 + ) + + book.revertPreparedShare(shareToken) + #expect( + book.demandSnapshot(for: displayID, performanceMode: .automatic).shareCursorOverrideCount == 0 + ) + + #expect(book.prepareShareForSharing(shareToken) == displayID) + #expect(book.releasePreparedShare(shareToken) == displayID) + #expect( + book.demandSnapshot(for: displayID, performanceMode: .automatic).shareCursorOverrideCount == 0 + ) + } + + @Test func releasingShareTokenWithPreviewRemainingStopsSharingWithoutDraining() { + let book = DisplayCaptureLeaseBook() + let displayID = CGDirectDisplayID(21004) + _ = book.registerToken(displayID: displayID, kind: .preview) + let shareToken = book.registerToken(displayID: displayID, kind: .share) + + let result = book.releaseToken(shareToken, expectedKind: .share) + + #expect(result?.displayID == displayID) + #expect(result?.shouldStopSharing == true) + #expect(result?.shouldApplyDemand == true) + #expect(result?.shouldDrainSession == false) + } + + @Test func releasingLastTokenRequestsDrain() { + let book = DisplayCaptureLeaseBook() + let displayID = CGDirectDisplayID(21005) + let previewToken = book.registerToken(displayID: displayID, kind: .preview) + + let result = book.releaseToken(previewToken, expectedKind: .preview) + + #expect(result?.displayID == displayID) + #expect(result?.shouldStopSharing == false) + #expect(result?.shouldApplyDemand == false) + #expect(result?.shouldDrainSession == true) + #expect(book.hasActiveTokens(for: displayID) == false) + } +} + +struct DisplayCaptureSessionStoreTests { + @Test func ensureSessionExistsReusesExistingActiveSession() async throws { + let store = DisplayCaptureSessionStore() + let displayID = CGDirectDisplayID(22001) + let display = SharedMockSCDisplay.make(displayID: displayID, width: 1920, height: 1080) + let sendableDisplay = SendableDisplay(display) + let existingSession = SessionStoreFakeSession() + let factoryCallCount = Mutex(0) + + store.installSessionForTesting( + displayID: displayID, + resolutionText: "1920 × 1080", + session: existingSession + ) + + try await store.ensureSessionExists( + for: sendableDisplay, + initialProfileProvider: { _ in .shareOnly }, + performanceMode: .automatic, + captureSessionFactory: { _, _, _ in + factoryCallCount.withLock { $0 += 1 } + return SessionStoreFakeSession() + } + ) + + #expect(factoryCallCount.withLock { $0 } == 0) + #expect( + ObjectIdentifier(store.record(for: displayID)?.session as AnyObject) + == ObjectIdentifier(existingSession) + ) + #expect(store.sessionState(for: displayID) == .active) + } + + @Test func ensureSessionExistsWaitsForDrainingSessionToFinishBeforeRecreating() async throws { + let store = DisplayCaptureSessionStore() + let displayID = CGDirectDisplayID(22002) + let display = SharedMockSCDisplay.make(displayID: displayID, width: 2560, height: 1440) + let sendableDisplay = SendableDisplay(display) + let stopGate = SessionStoreStopGate() + let drainingSession = SessionStoreControlledStopSession(stopGate: stopGate) + let replacementSession = SessionStoreFakeSession() + let factoryCallCount = Mutex(0) + let finisher = SessionStoreDrainFinisher(store: store) + + store.installSessionForTesting( + displayID: displayID, + resolutionText: "2560 × 1440", + session: drainingSession + ) + store.beginDraining(displayID: displayID) { displayID in + await finisher.finish(displayID: displayID, hasActiveTokens: false) + } + + let acquireTask = Task { + try await store.ensureSessionExists( + for: sendableDisplay, + initialProfileProvider: { _ in .previewOnly }, + performanceMode: .automatic, + captureSessionFactory: { _, _, _ in + factoryCallCount.withLock { $0 += 1 } + return replacementSession + } + ) + } + + #expect( + await staysTrue(timeoutNanoseconds: 50_000_000) { + factoryCallCount.withLock { $0 } == 0 + } + ) + + await stopGate.open() + try await acquireTask.value + + #expect(factoryCallCount.withLock { $0 } == 1) + #expect( + ObjectIdentifier(store.record(for: displayID)?.session as AnyObject) + == ObjectIdentifier(replacementSession) + ) + #expect(store.sessionState(for: displayID) == .active) + } + + @Test func finishDrainingRemovesSessionWhenNoTokensRemain() async { + let store = DisplayCaptureSessionStore() + let displayID = CGDirectDisplayID(22003) + let session = SessionStoreFakeSession() + let finisher = SessionStoreDrainFinisher(store: store) + + store.installSessionForTesting( + displayID: displayID, + resolutionText: "1280 × 720", + session: session + ) + store.beginDraining(displayID: displayID) { displayID in + await finisher.finish(displayID: displayID, hasActiveTokens: false) + } + + let settled = await waitUntil { + store.sessionState(for: displayID) == .stopped + } + + #expect(settled) + #expect(store.record(for: displayID) == nil) + #expect(session.stopCallCount == 1) + } + + @Test func finishDrainingRestoresActiveStateWhenTokensReappear() async { + let store = DisplayCaptureSessionStore() + let displayID = CGDirectDisplayID(22004) + let session = SessionStoreFakeSession() + let finisher = SessionStoreDrainFinisher(store: store) + + store.installSessionForTesting( + displayID: displayID, + resolutionText: "1600 × 900", + session: session + ) + store.beginDraining(displayID: displayID) { displayID in + await finisher.finish(displayID: displayID, hasActiveTokens: true) + } + + let settled = await waitUntil { + store.sessionState(for: displayID) == .active + } + + #expect(settled) + #expect(store.record(for: displayID) != nil) + #expect(session.stopCallCount == 1) + } + + private func waitUntil( + timeout: Duration = .seconds(1), + condition: @escaping () async -> Bool + ) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now + timeout + while clock.now < deadline { + if await condition() { + return true + } + await Task.yield() + } + return await condition() + } +} + private actor CaptureSessionFactoryGate { private var isOpen = false private var waiters: [CheckedContinuation] = [] diff --git a/VoidDisplayTests/Features/Capture/Services/DisplayPreviewSubscriptionTests.swift b/VoidDisplayTests/Features/Capture/Services/DisplayPreviewSubscriptionTests.swift index 297ebb0..c3117f3 100644 --- a/VoidDisplayTests/Features/Capture/Services/DisplayPreviewSubscriptionTests.swift +++ b/VoidDisplayTests/Features/Capture/Services/DisplayPreviewSubscriptionTests.swift @@ -47,14 +47,10 @@ private final class MockDisplayCaptureSession: @unchecked Sendable, DisplayCaptu state.withLock { $0.stopSharingCallCount += 1 } } - nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { - _ = showsCursor + nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws { + _ = demand } - nonisolated func retainShareCursorOverride() async throws {} - - nonisolated func releaseShareCursorOverride() async throws {} - nonisolated func stop() async { state.withLock { $0.stopCallCount += 1 } } diff --git a/VoidDisplayTests/Features/Capture/ViewModels/CaptureChooseViewModelTests.swift b/VoidDisplayTests/Features/Capture/ViewModels/CaptureChooseViewModelTests.swift index b0d0ade..7fadb75 100644 --- a/VoidDisplayTests/Features/Capture/ViewModels/CaptureChooseViewModelTests.swift +++ b/VoidDisplayTests/Features/Capture/ViewModels/CaptureChooseViewModelTests.swift @@ -1,54 +1,13 @@ -import Foundation import CoreGraphics +import Foundation import ScreenCaptureKit import Testing @testable import VoidDisplay -private struct ControlledCaptureLoadFailure: Error, Sendable {} - -private actor SequencedCaptureDisplayLoaderGate { - enum Outcome: Sendable { - case success - case failure - } - - private struct PendingCall { - let outcome: Outcome - let continuation: CheckedContinuation - } - - private let scriptedOutcomes: [Outcome] - private var callCount = 0 - private var pendingCalls: [Int: PendingCall] = [:] - - init(scriptedOutcomes: [Outcome]) { - self.scriptedOutcomes = scriptedOutcomes - } - - func nextOutcome() async -> Outcome { - callCount += 1 - let callIndex = callCount - let outcome = scriptedOutcomes.indices.contains(callIndex - 1) - ? scriptedOutcomes[callIndex - 1] - : .success - return await withCheckedContinuation { continuation in - pendingCalls[callIndex] = PendingCall(outcome: outcome, continuation: continuation) - } - } - - func release(call callIndex: Int) { - guard let pending = pendingCalls.removeValue(forKey: callIndex) else { return } - pending.continuation.resume(returning: pending.outcome) - } - - func currentCallCount() -> Int { - callCount - } -} - @Suite(.serialized) +@MainActor struct CaptureChooseViewModelTests { - @MainActor @Test func displayHelpersUseDisplayMetadataAndVirtualQuery() { + @Test func displayHelpersUseDisplayMetadataAndVirtualQuery() { let sut = CaptureChooseViewModel( dependencies: .init( captureActions: .init( @@ -61,17 +20,17 @@ struct CaptureChooseViewModelTests { ) ) ) - let display = MockSCDisplay.make(displayID: 1234, width: 1920, height: 1080) + let display = SharedMockSCDisplay.make(displayID: 1234, width: 1920, height: 1080) #expect(sut.isVirtualDisplay(display)) #expect(sut.resolutionText(for: display) == "1920 × 1080") #expect(sut.displayName(for: display) == String(localized: "Monitor")) } - @MainActor @Test func dependenciesLiveDelegatesToControllers() { + @Test func dependenciesLiveDelegatesToControllers() { let captureService = MockCaptureMonitoringService() let captureController = CaptureController(captureMonitoringService: captureService) - captureController.startingDisplayIDs = [777] + captureController.installStartingDisplayIDsForTesting([777]) let virtualDisplayController = VirtualDisplayController( virtualDisplayFacade: MockVirtualDisplayFacade(), appliedBadgeDisplayDuration: .nanoseconds(1), @@ -87,7 +46,7 @@ struct CaptureChooseViewModelTests { #expect(dependencies.virtualDisplayQueries.isManagedVirtualDisplay(777) == false) } - @MainActor @Test func isStartingDelegatesToCaptureActions() { + @Test func isStartingDelegatesToCaptureActions() { let sut = CaptureChooseViewModel( dependencies: .init( captureActions: .init( @@ -105,7 +64,7 @@ struct CaptureChooseViewModelTests { #expect(sut.isStarting(displayID: 302) == false) } - @MainActor @Test func startMonitoringFailurePresentsUserFacingAlert() async { + @Test func startMonitoringFailurePresentsUserFacingAlert() async { struct ControlledError: LocalizedError { var errorDescription: String? { "preview failed" } } @@ -124,7 +83,7 @@ struct CaptureChooseViewModelTests { ) ) ) - let display = MockSCDisplay.make(displayID: 777, width: 1920, height: 1080) + let display = SharedMockSCDisplay.make(displayID: 777, width: 1920, height: 1080) var openedSessionIDs: [UUID] = [] await sut.startMonitoring(display: display) { openedSessionIDs.append($0) } @@ -134,7 +93,7 @@ struct CaptureChooseViewModelTests { #expect(sut.userFacingAlert?.message.isEmpty == false) } - @MainActor @Test func startMonitoringCancellationDoesNotPresentUserFacingAlert() async { + @Test func startMonitoringCancellationDoesNotPresentUserFacingAlert() async { let sut = CaptureChooseViewModel( dependencies: .init( captureActions: .init( @@ -149,7 +108,7 @@ struct CaptureChooseViewModelTests { ) ) ) - let display = MockSCDisplay.make(displayID: 779, width: 1920, height: 1080) + let display = SharedMockSCDisplay.make(displayID: 779, width: 1920, height: 1080) var openedSessionIDs: [UUID] = [] await sut.startMonitoring(display: display) { openedSessionIDs.append($0) } @@ -158,9 +117,9 @@ struct CaptureChooseViewModelTests { #expect(sut.userFacingAlert == nil) } - @MainActor @Test func startMonitoringSuccessPassesMetadataToCaptureActions() async { + @Test func startMonitoringSuccessPassesMetadataToCaptureActions() async { let expectedSessionID = UUID() - let display = MockSCDisplay.make(displayID: 778, width: 2560, height: 1440) + let display = SharedMockSCDisplay.make(displayID: 778, width: 2560, height: 1440) var receivedDisplayID: CGDirectDisplayID? var receivedMetadata: CaptureMonitoringDisplayMetadata? let sut = CaptureChooseViewModel( @@ -193,7 +152,7 @@ struct CaptureChooseViewModelTests { #expect(sut.userFacingAlert == nil) } - @MainActor @Test func startMonitoringInvalidationDoesNotPresentUserFacingAlert() async { + @Test func startMonitoringInvalidationDoesNotPresentUserFacingAlert() async { let sut = CaptureChooseViewModel( dependencies: .init( captureActions: .init( @@ -206,7 +165,7 @@ struct CaptureChooseViewModelTests { ) ) ) - let display = MockSCDisplay.make(displayID: 780, width: 1920, height: 1080) + let display = SharedMockSCDisplay.make(displayID: 780, width: 1920, height: 1080) var openedSessionIDs: [UUID] = [] await sut.startMonitoring(display: display) { openedSessionIDs.append($0) } @@ -215,246 +174,9 @@ struct CaptureChooseViewModelTests { #expect(sut.userFacingAlert == nil) } - @MainActor @Test func requestPermissionDeniedClearsDisplayState() async { - let sut = CaptureChooseViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: false, - requestResult: false - ), - dependencies: makeNoopCaptureDependencies() - ) - sut.catalog.displays = [] - sut.catalog.isLoadingDisplays = true - - sut.requestScreenCapturePermission() - - let cleared = await waitUntil { - sut.catalog.hasScreenCapturePermission == false && - sut.catalog.lastRequestPermission == false && - sut.catalog.lastPreflightPermission == false && - sut.catalog.displays == nil && - sut.catalog.isLoadingDisplays == false - } - - #expect(cleared) - #expect(sut.catalog.hasScreenCapturePermission == false) - #expect(sut.catalog.lastRequestPermission == false) - #expect(sut.catalog.lastPreflightPermission == false) - #expect(sut.catalog.displays == nil) - #expect(sut.catalog.isLoadingDisplays == false) - } - - @MainActor @Test func requestPermissionGrantedLoadsDisplays() async { - let sut = CaptureChooseViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { [] }, - dependencies: makeNoopCaptureDependencies() - ) - - sut.requestScreenCapturePermission() - let loaded = await waitUntil { - sut.catalog.isLoadingDisplays == false && sut.catalog.displays != nil - } - - #expect(loaded) - #expect(sut.catalog.hasScreenCapturePermission == true) - #expect(sut.catalog.lastRequestPermission == true) - #expect(sut.catalog.displays?.isEmpty == true) - } - - @MainActor @Test func refreshPermissionGrantedLoadsDisplaysThroughInjectedLoader() async { - let sut = CaptureChooseViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { [] }, - dependencies: makeNoopCaptureDependencies() - ) - - sut.refreshPermissionAndMaybeLoad() - let loaded = await waitUntil { - sut.catalog.isLoadingDisplays == false && sut.catalog.displays != nil - } - - #expect(loaded) - #expect(sut.catalog.hasScreenCapturePermission == true) - #expect(sut.catalog.lastPreflightPermission == true) - #expect(sut.catalog.displays?.isEmpty == true) - #expect(sut.catalog.lastLoadedActiveDisplayTopologySignature != nil) - } - - @MainActor @Test func refreshPermissionSkipsReloadWhenDisplaysAreAlreadyCached() async { - let gate = SequencedCaptureDisplayLoaderGate(scriptedOutcomes: [.success]) - let existingDisplay = MockSCDisplay.make(displayID: 2222, width: 1280, height: 720) - let sut = CaptureChooseViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { - switch await gate.nextOutcome() { - case .success: - return [] - case .failure: - throw ControlledCaptureLoadFailure() - } - }, - activeDisplayIDsProvider: { Set([2222]) }, - dependencies: makeNoopCaptureDependencies() - ) - sut.catalog.displays = [existingDisplay] - sut.catalog.lastLoadedActiveDisplayTopologySignature = makeTestDisplayTopologySignature([2222]) - - sut.refreshPermissionAndMaybeLoad() - let deadline = DispatchTime.now().uptimeNanoseconds + AsyncTestTimeouts.shortStabilityWindow - var observedUnexpectedLoad = false - while DispatchTime.now().uptimeNanoseconds < deadline { - if await gate.currentCallCount() > 0 { - observedUnexpectedLoad = true - break - } - await Task.yield() - } - - #expect(observedUnexpectedLoad == false) - #expect(sut.catalog.isLoadingDisplays == false) - #expect(sut.catalog.displays?.count == 1) - } - - @MainActor @Test func refreshPermissionReloadsWhenCachedDisplaysExistButLoadedTopologySignatureIsMissing() async { - let gate = SequencedCaptureDisplayLoaderGate(scriptedOutcomes: [.success]) - let existingDisplay = MockSCDisplay.make(displayID: 2222, width: 1280, height: 720) - let rebuiltDisplay = MockSCDisplay.make(displayID: 3333, width: 2560, height: 1440) - let sut = CaptureChooseViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { - switch await gate.nextOutcome() { - case .success: - return [rebuiltDisplay] - case .failure: - throw ControlledCaptureLoadFailure() - } - }, - activeDisplayIDsProvider: { Set([3333]) }, - dependencies: makeNoopCaptureDependencies() - ) - sut.catalog.displays = [existingDisplay] - sut.catalog.lastLoadedActiveDisplayTopologySignature = nil - - sut.refreshPermissionAndMaybeLoad() - #expect(await waitForLoaderCall(gate, count: 1)) - #expect(sut.catalog.isLoadingDisplays == true) - #expect(sut.catalog.displays?.map(\.displayID) == [2222]) - - await gate.release(call: 1) - let finished = await waitUntil { - sut.catalog.isLoadingDisplays == false && - sut.catalog.displays?.map(\.displayID) == [3333] && - sut.catalog.lastLoadedActiveDisplayTopologySignature - == makeTestDisplayTopologySignature([3333]) - } - #expect(finished) - } - - @MainActor @Test func refreshPermissionReloadsWhenTopologyChangesWithCachedDisplays() async { - let gate = SequencedCaptureDisplayLoaderGate(scriptedOutcomes: [.success]) - let existingDisplay = MockSCDisplay.make(displayID: 2222, width: 1280, height: 720) - let rebuiltDisplay = MockSCDisplay.make(displayID: 3333, width: 2560, height: 1440) - let sut = CaptureChooseViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { - switch await gate.nextOutcome() { - case .success: - return [rebuiltDisplay] - case .failure: - throw ControlledCaptureLoadFailure() - } - }, - activeDisplayIDsProvider: { Set([3333]) }, - dependencies: makeNoopCaptureDependencies() - ) - sut.catalog.displays = [existingDisplay] - sut.catalog.lastLoadedActiveDisplayTopologySignature = makeTestDisplayTopologySignature([2222]) - - sut.refreshPermissionAndMaybeLoad() - #expect(await waitForLoaderCall(gate, count: 1)) - #expect(sut.catalog.isLoadingDisplays == true) - #expect(sut.catalog.displays?.map(\.displayID) == [2222]) - - await gate.release(call: 1) - let finished = await waitUntil { - sut.catalog.isLoadingDisplays == false && - sut.catalog.displays?.map(\.displayID) == [3333] && - sut.catalog.lastLoadedActiveDisplayTopologySignature - == makeTestDisplayTopologySignature([3333]) - } - #expect(finished) - } - - @MainActor @Test func refreshPermissionFailureDoesNotCommitLoadedTopologySignatureAndNextRefreshRetries() async { - let gate = SequencedCaptureDisplayLoaderGate(scriptedOutcomes: [.failure, .success]) - let existingDisplay = MockSCDisplay.make(displayID: 2222, width: 1280, height: 720) - let rebuiltDisplay = MockSCDisplay.make(displayID: 3333, width: 2560, height: 1440) - let sut = CaptureChooseViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { - switch await gate.nextOutcome() { - case .success: - return [rebuiltDisplay] - case .failure: - throw ControlledCaptureLoadFailure() - } - }, - activeDisplayIDsProvider: { Set([3333]) }, - dependencies: makeNoopCaptureDependencies() - ) - sut.catalog.displays = [existingDisplay] - sut.catalog.lastLoadedActiveDisplayTopologySignature = makeTestDisplayTopologySignature([2222]) - - sut.refreshPermissionAndMaybeLoad() - #expect(await waitForLoaderCall(gate, count: 1)) - - await gate.release(call: 1) - let firstFinished = await waitUntil { - sut.catalog.isLoadingDisplays == false && sut.catalog.lastLoadError != nil - } - #expect(firstFinished) - #expect(sut.catalog.displays?.map(\.displayID) == [2222]) - #expect( - sut.catalog.lastLoadedActiveDisplayTopologySignature - == makeTestDisplayTopologySignature([2222]) - ) - - sut.refreshPermissionAndMaybeLoad() - #expect(await waitForLoaderCall(gate, count: 2)) - - await gate.release(call: 2) - let secondFinished = await waitUntil { - sut.catalog.isLoadingDisplays == false && - sut.catalog.lastLoadError == nil && - sut.catalog.displays?.map(\.displayID) == [3333] && - sut.catalog.lastLoadedActiveDisplayTopologySignature - == makeTestDisplayTopologySignature([3333]) - } - #expect(secondFinished) - } - - @MainActor @Test func visibleDisplaysFiltersDisplaysMissingFromCurrentTopology() { - let displayA = MockSCDisplay.make(displayID: 1111, width: 1920, height: 1080) - let displayB = MockSCDisplay.make(displayID: 2222, width: 1920, height: 1080) + @Test func visibleDisplaysFiltersDisplaysMissingFromCurrentTopology() { + let displayA = SharedMockSCDisplay.make(displayID: 1111, width: 1920, height: 1080) + let displayB = SharedMockSCDisplay.make(displayID: 2222, width: 1920, height: 1080) let sut = CaptureChooseViewModel( activeDisplayIDsProvider: { Set([1111]) }, dependencies: makeNoopCaptureDependencies() @@ -464,169 +186,6 @@ struct CaptureChooseViewModelTests { #expect(visible.map(\.displayID) == [1111]) } - @MainActor @Test func loadDisplaysPersistsErrorDetailsWhenLoaderThrows() async { - let expected = NSError(domain: "CaptureTests", code: 99) - let sut = CaptureChooseViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { throw expected }, - dependencies: makeNoopCaptureDependencies() - ) - - sut.loadDisplays() - let finished = await waitUntil { - sut.catalog.isLoadingDisplays == false && sut.catalog.lastLoadError != nil - } - - #expect(finished) - #expect(sut.catalog.loadErrorMessage != nil) - #expect(sut.catalog.lastLoadError?.domain == expected.domain) - #expect(sut.catalog.lastLoadError?.code == expected.code) - #expect(sut.catalog.displays == nil) - } - - @MainActor @Test func loadDisplaysIgnoresLateResultFromSupersededRequest() async { - let gate = SequencedCaptureDisplayLoaderGate( - scriptedOutcomes: [.failure, .success] - ) - let sut = CaptureChooseViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { - switch await gate.nextOutcome() { - case .success: - return [] - case .failure: - throw ControlledCaptureLoadFailure() - } - }, - dependencies: makeNoopCaptureDependencies() - ) - - sut.loadDisplays() - #expect(await waitForLoaderCall(gate, count: 1)) - - sut.loadDisplays() - #expect(await waitForLoaderCall(gate, count: 2)) - - await gate.release(call: 2) - let secondFinished = await waitUntil { - sut.catalog.isLoadingDisplays == false && - sut.catalog.displays != nil && - sut.catalog.lastLoadError == nil - } - #expect(secondFinished) - - await gate.release(call: 1) - let staleResultIgnored = await waitUntil(timeoutNanoseconds: AsyncTestTimeouts.shortStabilityWindow) { - sut.catalog.isLoadingDisplays == false && - sut.catalog.displays?.isEmpty == true && - sut.catalog.lastLoadError == nil - } - #expect(staleResultIgnored) - } - - @MainActor @Test func refreshPermissionDeniedClearsStateWithoutStartingLoad() async { - let gate = SequencedCaptureDisplayLoaderGate( - scriptedOutcomes: [.success] - ) - let sut = CaptureChooseViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: false, - requestResult: false - ), - loadShareableDisplays: { - switch await gate.nextOutcome() { - case .success: - return [] - case .failure: - throw ControlledCaptureLoadFailure() - } - }, - dependencies: makeNoopCaptureDependencies() - ) - - sut.loadDisplays() - #expect(await waitForLoaderCall(gate, count: 1) == false) - - sut.refreshPermissionAndMaybeLoad() - let cleared = await waitUntil { - sut.catalog.hasScreenCapturePermission == false && - sut.catalog.isLoadingDisplays == false && - sut.catalog.displays == nil - } - - #expect(cleared) - - await gate.release(call: 1) - let lateWritePrevented = await waitUntil(timeoutNanoseconds: AsyncTestTimeouts.shortStabilityWindow) { - sut.catalog.isLoadingDisplays == false && sut.catalog.displays == nil - } - #expect(lateWritePrevented) - } - - @MainActor @Test func cancelInFlightDisplayLoadPreventsLateWrite() async { - let gate = SequencedCaptureDisplayLoaderGate(scriptedOutcomes: [.success]) - let sut = CaptureChooseViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { - switch await gate.nextOutcome() { - case .success: - return [] - case .failure: - throw ControlledCaptureLoadFailure() - } - }, - dependencies: makeNoopCaptureDependencies() - ) - - sut.loadDisplays() - #expect(await waitForLoaderCall(gate, count: 1)) - sut.cancelInFlightDisplayLoad() - let cancelled = await waitUntil { - sut.catalog.isLoadingDisplays == false - } - #expect(cancelled) - - await gate.release(call: 1) - let lateWritePrevented = await waitUntil(timeoutNanoseconds: AsyncTestTimeouts.shortStabilityWindow) { - sut.catalog.isLoadingDisplays == false && sut.catalog.displays == nil - } - #expect(lateWritePrevented) - } - - @MainActor @Test func openScreenCapturePrivacySettingsProvidesURL() { - let sut = CaptureChooseViewModel(dependencies: makeNoopCaptureDependencies()) - var openedURL: URL? - - sut.openScreenCapturePrivacySettings { url in - openedURL = url - } - - #expect(openedURL != nil) - #expect(openedURL?.scheme?.isEmpty == false) - } - - @MainActor - private func waitForLoaderCall(_ gate: SequencedCaptureDisplayLoaderGate, count: Int) async -> Bool { - let deadline = DispatchTime.now().uptimeNanoseconds + AsyncTestTimeouts.defaultAsyncAssertion - while DispatchTime.now().uptimeNanoseconds < deadline { - if await gate.currentCallCount() >= count { - return true - } - await Task.yield() - } - return await gate.currentCallCount() >= count - } - - @MainActor private func makeNoopCaptureDependencies() -> CaptureChooseViewModel.Dependencies { .init( captureActions: .init( @@ -640,25 +199,3 @@ struct CaptureChooseViewModelTests { ) } } - -private final class MockSCDisplayBox: NSObject { - @objc let displayID: CGDirectDisplayID - @objc let width: Int - @objc let height: Int - @objc let frame: CGRect - - init(displayID: CGDirectDisplayID, width: Int, height: Int) { - self.displayID = displayID - self.width = width - self.height = height - self.frame = CGRect(x: 0, y: 0, width: width, height: height) - super.init() - } -} - -private enum MockSCDisplay { - static func make(displayID: CGDirectDisplayID, width: Int, height: Int) -> SCDisplay { - let box = MockSCDisplayBox(displayID: displayID, width: width, height: height) - return unsafeBitCast(box, to: SCDisplay.self) - } -} diff --git a/VoidDisplayTests/Features/Sharing/Integration/SharingEndToEndIntegrationTests.swift b/VoidDisplayTests/Features/Sharing/Integration/SharingEndToEndIntegrationTests.swift index 3949484..02a78c8 100644 --- a/VoidDisplayTests/Features/Sharing/Integration/SharingEndToEndIntegrationTests.swift +++ b/VoidDisplayTests/Features/Sharing/Integration/SharingEndToEndIntegrationTests.swift @@ -17,14 +17,10 @@ private final class EndToEndFakeCaptureSession: DisplayCaptureSessioning, @unche nonisolated func stopSharing() {} - nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { - _ = showsCursor + nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws { + _ = demand } - nonisolated func retainShareCursorOverride() async throws {} - - nonisolated func releaseShareCursorOverride() async throws {} - nonisolated func stop() async {} } diff --git a/VoidDisplayTests/Features/Sharing/Services/DisplaySharingCoordinatorTests.swift b/VoidDisplayTests/Features/Sharing/Services/DisplaySharingCoordinatorTests.swift index 733529a..b1ffb68 100644 --- a/VoidDisplayTests/Features/Sharing/Services/DisplaySharingCoordinatorTests.swift +++ b/VoidDisplayTests/Features/Sharing/Services/DisplaySharingCoordinatorTests.swift @@ -29,16 +29,12 @@ private final class DisplaySharingCoordinatorDummySession: DisplayCaptureSession nonisolated func stopSharing() {} - nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { - _ = showsCursor - } - - nonisolated func retainShareCursorOverride() async throws { - await retainGate?.wait() - } - - nonisolated func releaseShareCursorOverride() async throws { - releaseCounter?.increment() + nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws { + if demand.shareCursorOverrideCount > 0 { + await retainGate?.wait() + } else { + releaseCounter?.increment() + } } nonisolated func stop() async {} @@ -568,8 +564,25 @@ struct DisplaySharingCoordinatorTests { let subscription = DisplayShareSubscription( displayID: displayID, sessionHub: session.sessionHub, - session: session, - cancelClosure: { cancelCounter.increment() } + cancelClosure: { cancelCounter.increment() }, + prepareForSharingClosure: { + try await session.setDemand( + DisplayCaptureDemandSnapshot( + shareTokenCount: 1, + shareCursorOverrideCount: 1, + performanceMode: .automatic + ) + ) + }, + releasePreparedShareClosure: { + try? await session.setDemand( + DisplayCaptureDemandSnapshot( + shareTokenCount: 1, + shareCursorOverrideCount: 0, + performanceMode: .automatic + ) + ) + } ) return (subscription, session, cancelCounter) } diff --git a/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift b/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift index 08eedd5..bd74edc 100644 --- a/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift +++ b/VoidDisplayTests/Features/Sharing/Services/SharingServiceTests.swift @@ -17,14 +17,10 @@ private final class SharingServiceDummySession: DisplayCaptureSessioning, @unche nonisolated func stopSharing() {} - nonisolated func setPreviewShowsCursor(_ showsCursor: Bool) async throws { - _ = showsCursor + nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws { + _ = demand } - nonisolated func retainShareCursorOverride() async throws {} - - nonisolated func releaseShareCursorOverride() async throws {} - nonisolated func stop() async {} } @@ -181,7 +177,6 @@ struct SharingServiceTests { .started(DisplayShareSubscription( displayID: displayID, sessionHub: WebRTCSessionHub(), - session: SharingServiceDummySession(), cancelClosure: { cancelCounter.increment() } )) } @@ -214,7 +209,6 @@ struct SharingServiceTests { return .started(DisplayShareSubscription( displayID: displayID, sessionHub: WebRTCSessionHub(), - session: SharingServiceDummySession(), cancelClosure: { cancelCounter.increment() } )) } diff --git a/VoidDisplayTests/Features/Sharing/ViewModels/ShareViewModelTests.swift b/VoidDisplayTests/Features/Sharing/ViewModels/ShareViewModelTests.swift index c4fc16a..5e836c2 100644 --- a/VoidDisplayTests/Features/Sharing/ViewModels/ShareViewModelTests.swift +++ b/VoidDisplayTests/Features/Sharing/ViewModels/ShareViewModelTests.swift @@ -1,60 +1,19 @@ -import Foundation import CoreGraphics +import Foundation import ScreenCaptureKit import Testing @testable import VoidDisplay -private struct ControlledLoadFailure: Error, Sendable {} - -private actor SequencedShareDisplayLoaderGate { - enum Outcome: Sendable { - case success - case failure - } - - private struct PendingCall { - let outcome: Outcome - let continuation: CheckedContinuation - } - - private let scriptedOutcomes: [Outcome] - private var callCount = 0 - private var pendingCalls: [Int: PendingCall] = [:] - - init(scriptedOutcomes: [Outcome]) { - self.scriptedOutcomes = scriptedOutcomes - } - - func nextOutcome() async -> Outcome { - callCount += 1 - let callIndex = callCount - let outcome = scriptedOutcomes.indices.contains(callIndex - 1) - ? scriptedOutcomes[callIndex - 1] - : .success - return await withCheckedContinuation { continuation in - pendingCalls[callIndex] = PendingCall(outcome: outcome, continuation: continuation) - } - } - - func release(call callIndex: Int) { - guard let pending = pendingCalls.removeValue(forKey: callIndex) else { return } - pending.continuation.resume(returning: pending.outcome) - } - - func currentCallCount() -> Int { - callCount - } -} - @Suite(.serialized) +@MainActor struct ShareViewModelTests { - @MainActor @Test func dependenciesLiveDelegatesToControllers() { + @Test func dependenciesLiveDelegatesToControllers() { let sharingService = MockSharingService() let sharingController = SharingController( sharingService: sharingService, portPreferences: SharingPortPreferences(defaults: UserDefaults(suiteName: "ShareViewModelTestsLive")!) ) - sharingController.startingDisplayIDs = [501] + sharingController.installStartingDisplayIDsForTesting([501]) let virtualDisplayController = VirtualDisplayController( virtualDisplayFacade: MockVirtualDisplayFacade(), appliedBadgeDisplayDuration: .nanoseconds(1), @@ -72,13 +31,13 @@ struct ShareViewModelTests { ) } - @MainActor @Test func dependenciesLiveReflectsStopWebServiceClearingStartingState() { + @Test func dependenciesLiveReflectsStopWebServiceClearingStartingState() { let sharingService = MockSharingService() let sharingController = SharingController( sharingService: sharingService, portPreferences: SharingPortPreferences(defaults: UserDefaults(suiteName: "ShareViewModelTestsStopWebService")!) ) - sharingController.startingDisplayIDs = [502] + sharingController.installStartingDisplayIDsForTesting([502]) let virtualDisplayController = VirtualDisplayController( virtualDisplayFacade: MockVirtualDisplayFacade(), appliedBadgeDisplayDuration: .nanoseconds(1), @@ -96,7 +55,7 @@ struct ShareViewModelTests { #expect(dependencies.sharingQueries.isStartingDisplayID(502) == false) } - @MainActor @Test func isStartingDelegatesToSharingQueries() { + @Test func isStartingDelegatesToSharingQueries() { let sut = ShareViewModel( dependencies: .init( sharingQueries: .init( @@ -122,384 +81,9 @@ struct ShareViewModelTests { #expect(sut.isStarting(displayID: 102) == false) } - @MainActor @Test func requestPermissionDeniedClearsDisplaysAndSetsErrorMessage() async { - let env = makeEnvironment() - let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: false, - requestResult: false - ), - dependencies: .live(sharing: env.sharing, virtualDisplay: env.virtualDisplay) - ) - sut.catalog.displays = [] - sut.catalog.isLoadingDisplays = true - - sut.requestScreenCapturePermission() - - let cleared = await waitUntil { - sut.catalog.hasScreenCapturePermission == false && - sut.catalog.lastRequestPermission == false && - sut.catalog.lastPreflightPermission == false && - sut.catalog.displays == nil && - sut.catalog.isLoadingDisplays == false && - sut.catalog.loadErrorMessage != nil - } - - #expect(cleared) - #expect(sut.catalog.hasScreenCapturePermission == false) - #expect(sut.catalog.lastRequestPermission == false) - #expect(sut.catalog.lastPreflightPermission == false) - #expect(sut.catalog.displays == nil) - #expect(sut.catalog.isLoadingDisplays == false) - #expect(sut.catalog.loadErrorMessage != nil) - } - - @MainActor @Test func loadDisplaysRegistersDisplaysThroughControllers() async { - let sharing = MockSharingService() - let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { [] }, - dependencies: .init( - sharingQueries: .init( - isWebServiceRunning: { true }, - isStartingDisplayID: { _ in false }, - sharePageAddress: { _ in nil }, - preferredWebServicePort: { 8081 } - ), - sharingActions: .init( - startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, - stopWebService: {}, - registerShareableDisplays: { displays, resolver in - sharing.registerShareableDisplays(displays, virtualSerialResolver: resolver) - }, - beginSharing: { _ in .started(()) }, - stopSharing: { _ in } - ), - virtualDisplayQueries: .init( - virtualSerialForManagedDisplay: { _ in nil } - ) - ) - ) - - sut.loadDisplays() - let finished = await waitUntil { - sut.catalog.isLoadingDisplays == false && sut.catalog.displays != nil - } - - #expect(finished) - #expect(sut.catalog.displays?.isEmpty == true) - #expect(sharing.registerShareableDisplaysCallCount == 1) - #expect(sut.catalog.lastLoadedActiveDisplayTopologySignature != nil) - } - - @MainActor @Test func refreshPermissionAndMaybeLoadKeepsCachedDisplaysWhenServiceAppearsStopped() { - let existingDisplay = MockSCDisplay.make(displayID: 9021, width: 1920, height: 1080) - let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - dependencies: .init( - sharingQueries: .init( - isWebServiceRunning: { false }, - isStartingDisplayID: { _ in false }, - sharePageAddress: { _ in nil }, - preferredWebServicePort: { 8081 } - ), - sharingActions: .init( - startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, - stopWebService: {}, - registerShareableDisplays: { _, _ in }, - beginSharing: { _ in .started(()) }, - stopSharing: { _ in } - ), - virtualDisplayQueries: .init( - virtualSerialForManagedDisplay: { _ in nil } - ) - ) - ) - sut.catalog.displays = [existingDisplay] - - sut.refreshPermissionAndMaybeLoad() - - #expect(sut.catalog.hasScreenCapturePermission == true) - #expect(sut.catalog.displays?.map(\.displayID) == [existingDisplay.displayID]) - #expect(sut.catalog.isLoadingDisplays == false) - } - - @MainActor @Test func refreshPermissionAndMaybeLoadReloadsWhenTopologyChangesWithCachedDisplays() async { - let gate = SequencedShareDisplayLoaderGate(scriptedOutcomes: [.success]) - let existingDisplay = MockSCDisplay.make(displayID: 4444, width: 1920, height: 1080) - let rebuiltDisplay = MockSCDisplay.make(displayID: 5555, width: 2560, height: 1440) - var registerShareableDisplaysCallCount = 0 - let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { - switch await gate.nextOutcome() { - case .success: - return [rebuiltDisplay] - case .failure: - throw ControlledLoadFailure() - } - }, - activeDisplayIDsProvider: { Set([5555]) }, - dependencies: .init( - sharingQueries: .init( - isWebServiceRunning: { true }, - isStartingDisplayID: { _ in false }, - sharePageAddress: { _ in nil }, - preferredWebServicePort: { 8081 } - ), - sharingActions: .init( - startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, - stopWebService: {}, - registerShareableDisplays: { _, _ in - registerShareableDisplaysCallCount += 1 - }, - beginSharing: { _ in .started(()) }, - stopSharing: { _ in } - ), - virtualDisplayQueries: .init( - virtualSerialForManagedDisplay: { _ in nil } - ) - ) - ) - sut.catalog.displays = [existingDisplay] - sut.catalog.lastLoadedActiveDisplayTopologySignature = makeTestDisplayTopologySignature([4444]) - - sut.refreshPermissionAndMaybeLoad() - #expect(await waitForLoaderCall(gate, count: 1)) - #expect(sut.catalog.isLoadingDisplays == true) - #expect(sut.catalog.displays?.map(\.displayID) == [4444]) - - await gate.release(call: 1) - let finished = await waitUntil { - sut.catalog.isLoadingDisplays == false && - sut.catalog.displays?.map(\.displayID) == [5555] && - sut.catalog.lastLoadedActiveDisplayTopologySignature - == makeTestDisplayTopologySignature([5555]) - } - #expect(finished) - #expect(registerShareableDisplaysCallCount == 1) - } - - @MainActor @Test func refreshPermissionAndMaybeLoadReloadsWhenCachedDisplaysExistButLoadedTopologySignatureIsMissing() async { - let gate = SequencedShareDisplayLoaderGate(scriptedOutcomes: [.success]) - let existingDisplay = MockSCDisplay.make(displayID: 4444, width: 1920, height: 1080) - let rebuiltDisplay = MockSCDisplay.make(displayID: 5555, width: 2560, height: 1440) - let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { - switch await gate.nextOutcome() { - case .success: - return [rebuiltDisplay] - case .failure: - throw ControlledLoadFailure() - } - }, - activeDisplayIDsProvider: { Set([5555]) }, - dependencies: makeAlwaysRunningShareDependencies() - ) - sut.catalog.displays = [existingDisplay] - sut.catalog.lastLoadedActiveDisplayTopologySignature = nil - - sut.refreshPermissionAndMaybeLoad() - #expect(await waitForLoaderCall(gate, count: 1)) - #expect(sut.catalog.isLoadingDisplays == true) - #expect(sut.catalog.displays?.map(\.displayID) == [4444]) - - await gate.release(call: 1) - let finished = await waitUntil { - sut.catalog.isLoadingDisplays == false && - sut.catalog.displays?.map(\.displayID) == [5555] && - sut.catalog.lastLoadedActiveDisplayTopologySignature - == makeTestDisplayTopologySignature([5555]) - } - #expect(finished) - } - - @MainActor @Test func refreshPermissionFailureDoesNotCommitLoadedTopologySignatureAndNextRefreshRetries() async { - let gate = SequencedShareDisplayLoaderGate(scriptedOutcomes: [.failure, .success]) - let existingDisplay = MockSCDisplay.make(displayID: 4444, width: 1920, height: 1080) - let rebuiltDisplay = MockSCDisplay.make(displayID: 5555, width: 2560, height: 1440) - let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { - switch await gate.nextOutcome() { - case .success: - return [rebuiltDisplay] - case .failure: - throw ControlledLoadFailure() - } - }, - activeDisplayIDsProvider: { Set([5555]) }, - dependencies: makeAlwaysRunningShareDependencies() - ) - sut.catalog.displays = [existingDisplay] - sut.catalog.lastLoadedActiveDisplayTopologySignature = makeTestDisplayTopologySignature([4444]) - - sut.refreshPermissionAndMaybeLoad() - #expect(await waitForLoaderCall(gate, count: 1)) - - await gate.release(call: 1) - let firstFinished = await waitUntil { - sut.catalog.isLoadingDisplays == false && sut.catalog.lastLoadError != nil - } - #expect(firstFinished) - #expect(sut.catalog.displays?.map(\.displayID) == [4444]) - #expect( - sut.catalog.lastLoadedActiveDisplayTopologySignature - == makeTestDisplayTopologySignature([4444]) - ) - - sut.refreshPermissionAndMaybeLoad() - #expect(await waitForLoaderCall(gate, count: 2)) - - await gate.release(call: 2) - let secondFinished = await waitUntil { - sut.catalog.isLoadingDisplays == false && - sut.catalog.lastLoadError == nil && - sut.catalog.displays?.map(\.displayID) == [5555] && - sut.catalog.lastLoadedActiveDisplayTopologySignature - == makeTestDisplayTopologySignature([5555]) - } - #expect(secondFinished) - } - - @MainActor @Test func syncForCurrentStatePreservesDisplaysWhenServiceIsStopped() { - let existingDisplay = MockSCDisplay.make(displayID: 9022, width: 1920, height: 1080) - let sut = ShareViewModel( - dependencies: .init( - sharingQueries: .init( - isWebServiceRunning: { false }, - isStartingDisplayID: { _ in false }, - sharePageAddress: { _ in nil }, - preferredWebServicePort: { 8081 } - ), - sharingActions: .init( - startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, - stopWebService: {}, - registerShareableDisplays: { _, _ in }, - beginSharing: { _ in .started(()) }, - stopSharing: { _ in } - ), - virtualDisplayQueries: .init( - virtualSerialForManagedDisplay: { _ in nil } - ) - ) - ) - sut.catalog.hasScreenCapturePermission = true - sut.catalog.displays = [existingDisplay] - - sut.syncForCurrentState() - - #expect(sut.catalog.displays?.map(\.displayID) == [existingDisplay.displayID]) - #expect(sut.catalog.isLoadingDisplays == false) - } - - @MainActor @Test func syncForCurrentStateCancelsLoadWithoutClearingDisplaysWhenServiceStoppedClearIsDisabled() async { - let existingDisplay = MockSCDisplay.make(displayID: 9030, width: 1920, height: 1080) - let sut = ShareViewModel( - dependencies: .init( - sharingQueries: .init( - isWebServiceRunning: { false }, - isStartingDisplayID: { _ in false }, - sharePageAddress: { _ in nil }, - preferredWebServicePort: { 8081 } - ), - sharingActions: .init( - startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, - stopWebService: {}, - registerShareableDisplays: { _, _ in }, - beginSharing: { _ in .started(()) }, - stopSharing: { _ in } - ), - virtualDisplayQueries: .init( - virtualSerialForManagedDisplay: { _ in nil } - ) - ) - ) - sut.catalog.hasScreenCapturePermission = true - sut.catalog.displays = [existingDisplay] - sut.catalog.isLoadingDisplays = true - - sut.syncForCurrentState(clearDisplaysWhenServiceStopped: false) - - let cancelled = await waitUntil { - sut.catalog.isLoadingDisplays == false - } - - #expect(cancelled) - #expect(sut.catalog.displays?.map(\.displayID) == [existingDisplay.displayID]) - #expect(sut.catalog.isLoadingDisplays == false) - } - - @MainActor @Test func refreshDisplaysBackgroundSafeStartsLoadWhenIdle() async { - let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { [] }, - dependencies: makeAlwaysRunningShareDependencies() - ) - - sut.refreshDisplaysBackgroundSafe() - let finished = await waitUntil { - sut.catalog.isLoadingDisplays == false && sut.catalog.displays != nil - } - - #expect(finished) - #expect(sut.catalog.displays?.isEmpty == true) - } - - @MainActor @Test func refreshDisplaysBackgroundSafePreserveModeKeepsExistingDisplaysWhileReloading() async { - let gate = SequencedShareDisplayLoaderGate(scriptedOutcomes: [.success]) - let existingDisplay = MockSCDisplay.make(displayID: 3333, width: 1920, height: 1080) - let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { - switch await gate.nextOutcome() { - case .success: - return [] - case .failure: - throw ControlledLoadFailure() - } - }, - dependencies: makeAlwaysRunningShareDependencies() - ) - sut.catalog.displays = [existingDisplay] - - sut.refreshDisplaysBackgroundSafe() - #expect(await waitForLoaderCall(gate, count: 1)) - #expect(sut.catalog.isLoadingDisplays == true) - #expect(sut.catalog.displays?.count == 1) - - await gate.release(call: 1) - let finished = await waitUntil { - sut.catalog.isLoadingDisplays == false && sut.catalog.displays != nil - } - #expect(finished) - } - - @MainActor @Test func visibleDisplaysFiltersDisplaysMissingFromCurrentTopology() { - let displayA = MockSCDisplay.make(displayID: 1234, width: 1920, height: 1080) - let displayB = MockSCDisplay.make(displayID: 5678, width: 1920, height: 1080) + @Test func visibleDisplaysFiltersDisplaysMissingFromCurrentTopology() { + let displayA = SharedMockSCDisplay.make(displayID: 1234, width: 1920, height: 1080) + let displayB = SharedMockSCDisplay.make(displayID: 5678, width: 1920, height: 1080) let sut = ShareViewModel( activeDisplayIDsProvider: { Set([1234]) }, dependencies: makeAlwaysRunningShareDependencies() @@ -509,112 +93,11 @@ struct ShareViewModelTests { #expect(visible.map(\.displayID) == [1234]) } - @MainActor @Test func refreshDisplaysBackgroundSafeSkipsWhenLoadInFlight() async { - let gate = SequencedShareDisplayLoaderGate(scriptedOutcomes: [.success]) - let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { - switch await gate.nextOutcome() { - case .success: - return [] - case .failure: - throw ControlledLoadFailure() - } - }, - dependencies: makeAlwaysRunningShareDependencies() - ) - - sut.loadDisplays() - #expect(await waitForLoaderCall(gate, count: 1)) - #expect(sut.catalog.isLoadingDisplays) - - sut.refreshDisplaysBackgroundSafe() - let deadline = DispatchTime.now().uptimeNanoseconds + AsyncTestTimeouts.shortStabilityWindow - var observedAdditionalCall = false - while DispatchTime.now().uptimeNanoseconds < deadline { - if await gate.currentCallCount() > 1 { - observedAdditionalCall = true - break - } - await Task.yield() - } - #expect(observedAdditionalCall == false) - - await gate.release(call: 1) - let finished = await waitUntil { - sut.catalog.isLoadingDisplays == false && sut.catalog.displays != nil - } - #expect(finished) - } - - @MainActor @Test func refreshDisplaysBackgroundSafeSkipsWhenServiceIsStopped() async { - let gate = SequencedShareDisplayLoaderGate(scriptedOutcomes: [.success]) - let existingDisplay = MockSCDisplay.make(displayID: 3344, width: 1920, height: 1080) - let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { - switch await gate.nextOutcome() { - case .success: - return [] - case .failure: - throw ControlledLoadFailure() - } - }, - dependencies: makeNoopShareDependencies() - ) - sut.catalog.displays = [existingDisplay] - sut.catalog.lastLoadedActiveDisplayTopologySignature = makeTestDisplayTopologySignature( - [existingDisplay.displayID] - ) - - sut.refreshDisplaysBackgroundSafe() - - let stayedIdle = await waitUntil(timeoutNanoseconds: AsyncTestTimeouts.shortStabilityWindow) { - sut.catalog.isLoadingDisplays == false && - sut.catalog.displays?.map(\.displayID) == [existingDisplay.displayID] - } - #expect(stayedIdle) - #expect(await gate.currentCallCount() == 0) - } - - @MainActor @Test func loadDisplaysRecordsDetailedErrorWhenLoaderFails() async { - let env = makeEnvironment() - let expected = NSError(domain: "ShareTests", code: 77) - let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { throw expected }, - dependencies: .live(sharing: env.sharing, virtualDisplay: env.virtualDisplay) - ) - - sut.loadDisplays() - let finished = await waitUntil { - sut.catalog.isLoadingDisplays == false && sut.catalog.lastLoadError != nil - } - - #expect(finished) - #expect(sut.catalog.loadErrorMessage != nil) - #expect(sut.catalog.lastLoadError?.domain == expected.domain) - #expect(sut.catalog.lastLoadError?.code == expected.code) - } - - @MainActor @Test func startServiceFailureShowsInlinePortError() async { + @Test func startServiceFailureShowsInlinePortError() async { let sharing = MockSharingService() sharing.startResult = .failed(.portInUse(port: 8081)) let env = makeEnvironment(sharing: sharing) let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), dependencies: .live(sharing: env.sharing, virtualDisplay: env.virtualDisplay) ) @@ -628,12 +111,8 @@ struct ShareViewModelTests { #expect(sut.userFacingAlert == nil) } - @MainActor @Test func initUsesPreferredPortAsInputDefault() { + @Test func initUsesPreferredPortAsInputDefault() { let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), dependencies: .init( sharingQueries: .init( isWebServiceRunning: { false }, @@ -657,13 +136,9 @@ struct ShareViewModelTests { #expect(sut.servicePortInput == "9099") } - @MainActor @Test func servicePortInputTruncatesToFiveCharacters() { + @Test func servicePortInputTruncatesToFiveCharacters() { let env = makeEnvironment() let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), dependencies: .live(sharing: env.sharing, virtualDisplay: env.virtualDisplay) ) @@ -672,14 +147,10 @@ struct ShareViewModelTests { #expect(sut.servicePortInput == "12345") } - @MainActor @Test func startServiceWithInvalidPortSkipsStartCallAndShowsValidationError() async { + @Test func startServiceWithInvalidPortSkipsStartCallAndShowsValidationError() async { let sharing = MockSharingService() let env = makeEnvironment(sharing: sharing) let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), dependencies: .live(sharing: env.sharing, virtualDisplay: env.virtualDisplay) ) sut.servicePortInput = "abc" @@ -694,16 +165,12 @@ struct ShareViewModelTests { #expect(sut.userFacingAlert == nil) } - @MainActor @Test func startServicePassesRequestedPortToSharingLayer() async { + @Test func startServicePassesRequestedPortToSharingLayer() async { let requestedPort = TestPortAllocator.randomUnprivilegedPort() let sharing = MockSharingService() sharing.startResult = .started(WebServiceBinding(requestedPort: requestedPort, boundPort: requestedPort)) let env = makeEnvironment(sharing: sharing) let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), dependencies: .live(sharing: env.sharing, virtualDisplay: env.virtualDisplay) ) sut.servicePortInput = String(requestedPort) @@ -717,8 +184,36 @@ struct ShareViewModelTests { #expect(sharing.lastStartRequestedPort == requestedPort) } - @MainActor @Test func startSharingWithInvalidPortSkipsServiceStartAndSharing() async { - let display = MockSCDisplay.make(displayID: 7001, width: 1920, height: 1080) + @Test func stopServiceDelegatesToSharingLayer() { + var stopCallCount = 0 + let sut = ShareViewModel( + dependencies: .init( + sharingQueries: .init( + isWebServiceRunning: { true }, + isStartingDisplayID: { _ in false }, + sharePageAddress: { _ in nil }, + preferredWebServicePort: { 8081 } + ), + sharingActions: .init( + startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, + stopWebService: { stopCallCount += 1 }, + registerShareableDisplays: { _, _ in }, + beginSharing: { _ in .started(()) }, + stopSharing: { _ in } + ), + virtualDisplayQueries: .init( + virtualSerialForManagedDisplay: { _ in nil } + ) + ) + ) + + sut.stopService() + + #expect(stopCallCount == 1) + } + + @Test func startSharingWithInvalidPortSkipsServiceStartAndSharing() async { + let display = SharedMockSCDisplay.make(displayID: 7001, width: 1920, height: 1080) var startCallCount = 0 var beginSharingCallCount = 0 let sut = ShareViewModel( @@ -757,8 +252,8 @@ struct ShareViewModelTests { #expect(sut.userFacingAlert == nil) } - @MainActor @Test func startSharingServiceStartFailureShowsInlineErrorAndSkipsSharing() async { - let display = MockSCDisplay.make(displayID: 7002, width: 1920, height: 1080) + @Test func startSharingServiceStartFailureShowsInlineErrorAndSkipsSharing() async { + let display = SharedMockSCDisplay.make(displayID: 7002, width: 1920, height: 1080) var beginSharingCallCount = 0 let sut = ShareViewModel( dependencies: .init( @@ -791,8 +286,8 @@ struct ShareViewModelTests { #expect(sut.userFacingAlert == nil) } - @MainActor @Test func startSharingFailureStopsShareAndPresentsLocalizedAlert() async { - let display = MockSCDisplay.make(displayID: 7003, width: 1920, height: 1080) + @Test func startSharingFailureStopsShareAndPresentsLocalizedAlert() async { + let display = SharedMockSCDisplay.make(displayID: 7003, width: 1920, height: 1080) var stopSharingDisplayIDs: [CGDirectDisplayID] = [] let sut = ShareViewModel( dependencies: .init( @@ -827,8 +322,8 @@ struct ShareViewModelTests { #expect(sut.portInputErrorMessage == nil) } - @MainActor @Test func startSharingInvalidationEndsSilentlyWithoutStoppingShare() async { - let display = MockSCDisplay.make(displayID: 7004, width: 1920, height: 1080) + @Test func startSharingInvalidationEndsSilentlyWithoutStoppingShare() async { + let display = SharedMockSCDisplay.make(displayID: 7004, width: 1920, height: 1080) var stopSharingCallCount = 0 let sut = ShareViewModel( dependencies: .init( @@ -860,14 +355,44 @@ struct ShareViewModelTests { #expect(sut.portInputErrorMessage == nil) } - @MainActor @Test func editingPortClearsInlineErrorMessage() async { + @Test func startSharingWithRunningServiceDelegatesToSharingLayer() async { + let display = SharedMockSCDisplay.make(displayID: 7005, width: 1920, height: 1080) + var beginSharingCallCount = 0 + let sut = ShareViewModel( + dependencies: .init( + sharingQueries: .init( + isWebServiceRunning: { true }, + isStartingDisplayID: { _ in false }, + sharePageAddress: { _ in nil }, + preferredWebServicePort: { 8081 } + ), + sharingActions: .init( + startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, + stopWebService: {}, + registerShareableDisplays: { _, _ in }, + beginSharing: { _ in + beginSharingCallCount += 1 + return .started(()) + }, + stopSharing: { _ in } + ), + virtualDisplayQueries: .init( + virtualSerialForManagedDisplay: { _ in nil } + ) + ) + ) + + await sut.startSharing(display: display) + + #expect(beginSharingCallCount == 1) + #expect(sut.userFacingAlert == nil) + #expect(sut.portInputErrorMessage == nil) + } + + @Test func editingPortClearsInlineErrorMessage() async { let sharing = MockSharingService() let env = makeEnvironment(sharing: sharing) let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), dependencies: .live(sharing: env.sharing, virtualDisplay: env.virtualDisplay) ) @@ -880,98 +405,35 @@ struct ShareViewModelTests { #expect(sut.portInputErrorMessage == nil) } - @MainActor @Test func loadDisplaysIgnoresLateResultFromSupersededRequest() async { - let gate = SequencedShareDisplayLoaderGate( - scriptedOutcomes: [.failure, .success] - ) - let env = makeEnvironment() + @Test func sharePageAddressDelegatesToSharingQueries() { let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { - switch await gate.nextOutcome() { - case .success: - return [] - case .failure: - throw ControlledLoadFailure() - } - }, - dependencies: .live(sharing: env.sharing, virtualDisplay: env.virtualDisplay) - ) - - sut.loadDisplays() - #expect(await waitForLoaderCall(gate, count: 1)) - - sut.loadDisplays() - #expect(await waitForLoaderCall(gate, count: 2)) - - await gate.release(call: 2) - let secondFinished = await waitUntil { - sut.catalog.isLoadingDisplays == false && - sut.catalog.displays != nil && - sut.catalog.lastLoadError == nil - } - #expect(secondFinished) - - await gate.release(call: 1) - let staleResultIgnored = await waitUntil(timeoutNanoseconds: AsyncTestTimeouts.shortStabilityWindow) { - sut.catalog.isLoadingDisplays == false && - sut.catalog.displays?.isEmpty == true && - sut.catalog.lastLoadError == nil - } - #expect(staleResultIgnored) - } - - @MainActor @Test func stopServiceCancelsInFlightDisplayLoadAndPreventsLateWrite() async { - let gate = SequencedShareDisplayLoaderGate( - scriptedOutcomes: [.success] - ) - let sharing = MockSharingService() - sharing.isWebServiceRunning = true - let env = makeEnvironment(sharing: sharing) - let sut = ShareViewModel( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { - switch await gate.nextOutcome() { - case .success: - return [] - case .failure: - throw ControlledLoadFailure() - } - }, - dependencies: .live(sharing: env.sharing, virtualDisplay: env.virtualDisplay) + dependencies: .init( + sharingQueries: .init( + isWebServiceRunning: { true }, + isStartingDisplayID: { _ in false }, + sharePageAddress: { _ in "http://127.0.0.1:8081/display/1" }, + preferredWebServicePort: { 8081 } + ), + sharingActions: .init( + startWebService: { _ in .started(WebServiceBinding(requestedPort: 8081, boundPort: 8081)) }, + stopWebService: {}, + registerShareableDisplays: { _, _ in }, + beginSharing: { _ in .started(()) }, + stopSharing: { _ in } + ), + virtualDisplayQueries: .init( + virtualSerialForManagedDisplay: { _ in nil } + ) + ) ) - sut.loadDisplays() - #expect(await waitForLoaderCall(gate, count: 1)) - - sut.stopService() - let cancelled = await waitUntil { - sut.catalog.isLoadingDisplays == false && sut.catalog.displays == nil - } - - #expect(cancelled) - #expect(sut.catalog.isLoadingDisplays == false) - #expect(sut.catalog.displays == nil) - - await gate.release(call: 1) - let lateWritePrevented = await waitUntil(timeoutNanoseconds: AsyncTestTimeouts.shortStabilityWindow) { - sut.catalog.isLoadingDisplays == false && sut.catalog.displays == nil - } - #expect(lateWritePrevented) + #expect(sut.sharePageAddress(for: 1) == "http://127.0.0.1:8081/display/1") } - @MainActor private func makeEnvironment() -> AppEnvironment { makeEnvironment(sharing: MockSharingService()) } - @MainActor private func makeEnvironment(sharing: MockSharingService) -> AppEnvironment { AppBootstrap.makeEnvironment( preview: true, @@ -982,41 +444,6 @@ struct ShareViewModelTests { ) } - @MainActor - private func waitForLoaderCall(_ gate: SequencedShareDisplayLoaderGate, count: Int) async -> Bool { - let deadline = DispatchTime.now().uptimeNanoseconds + AsyncTestTimeouts.defaultAsyncAssertion - while DispatchTime.now().uptimeNanoseconds < deadline { - if await gate.currentCallCount() >= count { - return true - } - await Task.yield() - } - return await gate.currentCallCount() >= count - } - - @MainActor - private func makeNoopShareDependencies() -> ShareViewModel.Dependencies { - .init( - sharingQueries: .init( - isWebServiceRunning: { false }, - isStartingDisplayID: { _ in false }, - sharePageAddress: { _ in nil }, - preferredWebServicePort: { 8081 } - ), - sharingActions: .init( - startWebService: { _ in .failed(.timedOut(port: 8081)) }, - stopWebService: {}, - registerShareableDisplays: { _, _ in }, - beginSharing: { _ in .started(()) }, - stopSharing: { _ in } - ), - virtualDisplayQueries: .init( - virtualSerialForManagedDisplay: { _ in nil } - ) - ) - } - - @MainActor private func makeAlwaysRunningShareDependencies() -> ShareViewModel.Dependencies { .init( sharingQueries: .init( @@ -1038,25 +465,3 @@ struct ShareViewModelTests { ) } } - -private final class MockSCDisplayBox: NSObject { - @objc let displayID: CGDirectDisplayID - @objc let width: Int - @objc let height: Int - @objc let frame: CGRect - - init(displayID: CGDirectDisplayID, width: Int, height: Int) { - self.displayID = displayID - self.width = width - self.height = height - self.frame = CGRect(x: 0, y: 0, width: width, height: height) - super.init() - } -} - -private enum MockSCDisplay { - static func make(displayID: CGDirectDisplayID, width: Int, height: Int) -> SCDisplay { - let box = MockSCDisplayBox(displayID: displayID, width: width, height: height) - return unsafeBitCast(box, to: SCDisplay.self) - } -} diff --git a/VoidDisplayTests/Features/Sharing/Views/ShareViewBehaviorTests.swift b/VoidDisplayTests/Features/Sharing/Views/ShareViewBehaviorTests.swift index c5a8cc4..76752d1 100644 --- a/VoidDisplayTests/Features/Sharing/Views/ShareViewBehaviorTests.swift +++ b/VoidDisplayTests/Features/Sharing/Views/ShareViewBehaviorTests.swift @@ -1,33 +1,8 @@ import CoreGraphics -import ScreenCaptureKit import Synchronization import Testing @testable import VoidDisplay -private actor ShareViewLoaderGate { - private var callCount = 0 - private var waiters: [CheckedContinuation] = [] - - func next() async { - callCount += 1 - await withCheckedContinuation { continuation in - waiters.append(continuation) - } - } - - func release() { - let pending = waiters - waiters.removeAll() - for waiter in pending { - waiter.resume() - } - } - - func currentCallCount() -> Int { - callCount - } -} - private final class ShareViewDisplayReconfigurationMonitor: DisplayReconfigurationMonitoring { private let startResults: [Bool] private(set) var startCallCount = 0 @@ -75,28 +50,6 @@ private final class ShareViewTopologyChangeCounter: @unchecked Sendable { } } -private final class ShareViewMockSCDisplayBox: NSObject { - @objc let displayID: CGDirectDisplayID - @objc let width: Int - @objc let height: Int - @objc let frame: CGRect - - init(displayID: CGDirectDisplayID, width: Int, height: Int) { - self.displayID = displayID - self.width = width - self.height = height - self.frame = CGRect(x: 0, y: 0, width: width, height: height) - super.init() - } -} - -private enum ShareViewMockSCDisplay { - static func make(displayID: CGDirectDisplayID, width: Int, height: Int) -> SCDisplay { - let box = ShareViewMockSCDisplayBox(displayID: displayID, width: width, height: height) - return unsafeBitCast(box, to: SCDisplay.self) - } -} - @Suite(.serialized) @MainActor struct ShareViewBehaviorTests { @@ -113,107 +66,26 @@ struct ShareViewBehaviorTests { #expect(state == .serviceStopped) } - @Test func lifecycleHandleAppearRefreshesPermissionAndEnablesToolbarFallback() { - let viewModel = makeViewModel(isWebServiceRunning: false) + @Test func lifecycleHandleAppearEnablesToolbarFallbackWhenMonitorRegistrationFails() { let monitor = ShareViewDisplayReconfigurationMonitor(startResults: [false]) let lifecycle = DisplayTopologyRefreshLifecycleController( displayRefreshMonitor: monitor, recoveryAttemptInterval: 99 ) - viewModel.refreshPermissionAndMaybeLoad() lifecycle.handleAppear {} - #expect(viewModel.catalog.lastPreflightPermission == true) #expect(lifecycle.showToolbarRefresh) #expect(monitor.startCallCount == 1) } - @Test func lifecycleFallbackRefreshesDisplaysAfterTopologyChange() async { - let loaderGate = ShareViewLoaderGate() - let signatureBox = ShareViewSignatureBox(makeTestDisplayTopologySignature([101])) - let existingDisplay = ShareViewMockSCDisplay.make(displayID: 101, width: 1920, height: 1080) - let refreshedDisplay = ShareViewMockSCDisplay.make(displayID: 202, width: 2560, height: 1440) - let catalogService = ScreenCaptureCatalogService( - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: { - await loaderGate.next() - return [refreshedDisplay] - }, - activeDisplayIDsProvider: { Set(signatureBox.value.map(\.displayID)) }, - displayTopologySignatureProvider: { signatureBox.value }, - runtimeScenarioProbe: .init( - shouldShortCircuitDisplayLoadAsPermissionDenied: { false }, - shouldDelayDisplayLoadForUITest: { false } - ) - ) - let viewModel = makeViewModel( - catalogService: catalogService, - isWebServiceRunning: true, - loadShareableDisplays: { - await loaderGate.next() - return [refreshedDisplay] - } - ) - viewModel.catalog.displays = [existingDisplay] - viewModel.catalog.lastLoadedActiveDisplayTopologySignature = makeTestDisplayTopologySignature([101]) - - let lifecycle = DisplayTopologyRefreshLifecycleController( - displayRefreshMonitor: ShareViewDisplayReconfigurationMonitor(startResults: [false, false]), - displayTopologySignatureProvider: { signatureBox.value }, - fallbackPollingInterval: .milliseconds(20), - recoveryAttemptInterval: 99 - ) - - viewModel.refreshPermissionAndMaybeLoad() - lifecycle.handleAppear { - guard viewModel.catalog.hasScreenCapturePermission == true else { return } - viewModel.refreshDisplaysBackgroundSafe() - } - await drainMainActorTasks() - #expect(await loaderGate.currentCallCount() == 0) - - signatureBox.value = makeTestDisplayTopologySignature([202]) - - let requestedReload = await waitUntilAsync { - await loaderGate.currentCallCount() == 1 - } - #expect(requestedReload) - - await loaderGate.release() - let finished = await waitUntil { - viewModel.catalog.isLoadingDisplays == false && - viewModel.catalog.displays?.map { $0.displayID } == [202] - } - #expect(finished) - } - - @Test func lifecycleHandleDisappearCancelsInFlightLoadAndStopsMonitor() async { - let loaderGate = ShareViewLoaderGate() + @Test func lifecycleHandleDisappearStopsMonitor() { let monitor = ShareViewDisplayReconfigurationMonitor(startResults: [true]) - let viewModel = makeViewModel( - isWebServiceRunning: true, - loadShareableDisplays: { - await loaderGate.next() - return [ShareViewMockSCDisplay.make(displayID: 303, width: 1280, height: 720)] - } - ) let lifecycle = DisplayTopologyRefreshLifecycleController(displayRefreshMonitor: monitor) - viewModel.loadDisplays() - #expect(await waitUntilAsync { await loaderGate.currentCallCount() == 1 }) - - viewModel.cancelInFlightDisplayLoad() + lifecycle.handleAppear {} lifecycle.handleDisappear() - await loaderGate.release() - let cancelled = await waitUntil { - viewModel.catalog.isLoadingDisplays == false && viewModel.catalog.displays == nil - } - #expect(cancelled) #expect(monitor.stopCallCount == 1) } @@ -250,34 +122,11 @@ struct ShareViewBehaviorTests { } @Test func viewModelSurfacesStartingStateFromSharingDependency() { - let viewModel = makeViewModel( - isWebServiceRunning: true, - isStartingDisplayID: { $0 == 404 } - ) - - #expect(viewModel.isStarting(displayID: 404)) - #expect(viewModel.isStarting(displayID: 405) == false) - } - - private func makeViewModel( - catalogService: ScreenCaptureCatalogService? = nil, - isWebServiceRunning: Bool, - isStartingDisplayID: @escaping @MainActor (CGDirectDisplayID) -> Bool = { _ in false }, - loadShareableDisplays: (@MainActor () async throws -> [SCDisplay])? = nil, - activeDisplayIDsProvider: @escaping @MainActor () -> Set = { [] } - ) -> ShareViewModel { - ShareViewModel( - catalogService: catalogService, - permissionProvider: MockScreenCapturePermissionProvider( - preflightResult: true, - requestResult: true - ), - loadShareableDisplays: loadShareableDisplays, - activeDisplayIDsProvider: activeDisplayIDsProvider, + let viewModel = ShareViewModel( dependencies: .init( sharingQueries: .init( - isWebServiceRunning: { isWebServiceRunning }, - isStartingDisplayID: isStartingDisplayID, + isWebServiceRunning: { true }, + isStartingDisplayID: { $0 == 404 }, sharePageAddress: { _ in nil }, preferredWebServicePort: { 8081 } ), @@ -293,6 +142,9 @@ struct ShareViewBehaviorTests { ) ) ) + + #expect(viewModel.isStarting(displayID: 404)) + #expect(viewModel.isStarting(displayID: 405) == false) } private func waitUntilAsync( diff --git a/VoidDisplayTests/TestSupport/MockSCDisplay.swift b/VoidDisplayTests/TestSupport/MockSCDisplay.swift new file mode 100644 index 0000000..557bbfd --- /dev/null +++ b/VoidDisplayTests/TestSupport/MockSCDisplay.swift @@ -0,0 +1,25 @@ +import CoreGraphics +import Foundation +import ScreenCaptureKit + +final class SharedMockSCDisplayBox: NSObject { + @objc let displayID: CGDirectDisplayID + @objc let width: Int + @objc let height: Int + @objc let frame: CGRect + + init(displayID: CGDirectDisplayID, width: Int, height: Int) { + self.displayID = displayID + self.width = width + self.height = height + self.frame = CGRect(x: 0, y: 0, width: width, height: height) + super.init() + } +} + +enum SharedMockSCDisplay { + static func make(displayID: CGDirectDisplayID, width: Int, height: Int) -> SCDisplay { + let box = SharedMockSCDisplayBox(displayID: displayID, width: width, height: height) + return unsafeBitCast(box, to: SCDisplay.self) + } +} From 856adc3d2ceec3f230b0011b2f2517241b7f8fb9 Mon Sep 17 00:00:00 2001 From: Chen Date: Sat, 4 Apr 2026 15:58:48 +0800 Subject: [PATCH 33/34] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E5=A4=8D=E5=B1=8F?= =?UTF-8?q?=E5=B9=95=E7=9B=91=E5=90=AC=E4=B8=8E=E9=A2=84=E8=A7=88=E8=AF=8A?= =?UTF-8?q?=E6=96=AD=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修正屏幕目录加载的执行器隔离,消除监控页崩溃 - 稳定预览诊断场景的主窗口启动与测试启动参数 - 加强 smoke 与 diagnostics UI 测试查询,消除失效元素引用 --- .../App/CaptureDisplayWindowRoot.swift | 31 ++- VoidDisplay/App/HomeView.swift | 21 +- VoidDisplay/App/VoidDisplayApp.swift | 17 +- .../Capture/Views/CaptureChoose.swift | 2 + .../ScreenCaptureCatalogService.swift | 8 +- .../CapturePreviewDiagnosticsTests.swift | 4 +- VoidDisplayUITests/Smoke/HomeSmokeTests.swift | 204 ++++++++++-------- .../Smoke/SmokeTestHelpers.swift | 56 +++-- 8 files changed, 213 insertions(+), 130 deletions(-) diff --git a/VoidDisplay/App/CaptureDisplayWindowRoot.swift b/VoidDisplay/App/CaptureDisplayWindowRoot.swift index 8e36399..948d095 100644 --- a/VoidDisplay/App/CaptureDisplayWindowRoot.swift +++ b/VoidDisplay/App/CaptureDisplayWindowRoot.swift @@ -8,14 +8,33 @@ import SwiftUI struct CaptureDisplayWindowRoot: View { @Environment(\.dismiss) private var dismiss let sessionId: UUID? + @State private var hasSeenSessionID = false var body: some View { - if let sessionId { - CaptureDisplayView(sessionId: sessionId) - .navigationTitle("Screen Monitoring") - } else { - Color.clear - .onAppear { dismiss() } + Group { + if let sessionId { + CaptureDisplayView(sessionId: sessionId) + .navigationTitle("Screen Monitoring") + } else { + Color.clear + } + } + .task(id: sessionId) { + if sessionId != nil { + hasSeenSessionID = true + return + } + + // Value-based windows can briefly render before their payload is + // attached. Give SwiftUI one turn to supply the session ID. + if !hasSeenSessionID { + try? await Task.sleep(for: .milliseconds(150)) + guard sessionId == nil, !hasSeenSessionID else { return } + dismiss() + return + } + + dismiss() } } } diff --git a/VoidDisplay/App/HomeView.swift b/VoidDisplay/App/HomeView.swift index 743e4ad..27573ea 100644 --- a/VoidDisplay/App/HomeView.swift +++ b/VoidDisplay/App/HomeView.swift @@ -31,20 +31,24 @@ struct HomeView: View { NavigationSplitView { List(selection: $selection) { Section("Display") { - Label("Displays", systemImage: "display") - .tag(SidebarItem.screen) + NavigationLink(value: SidebarItem.screen) { + Label("Displays", systemImage: "display") + } .accessibilityIdentifier("sidebar_screen") - Label("Virtual Displays", systemImage: "display.2") - .tag(SidebarItem.virtualDisplay) + NavigationLink(value: SidebarItem.virtualDisplay) { + Label("Virtual Displays", systemImage: "display.2") + } .accessibilityIdentifier("sidebar_virtual_display") - Label("Screen Monitoring", systemImage: "dot.scope.display") - .tag(SidebarItem.monitorScreen) + NavigationLink(value: SidebarItem.monitorScreen) { + Label("Screen Monitoring", systemImage: "dot.scope.display") + } .accessibilityIdentifier("sidebar_monitor_screen") } Section("Sharing") { - Label("Screen Sharing", systemImage: "display") - .tag(SidebarItem.screenSharing) + NavigationLink(value: SidebarItem.screenSharing) { + Label("Screen Sharing", systemImage: "display") + } .accessibilityIdentifier("sidebar_screen_sharing") } } @@ -88,6 +92,7 @@ struct HomeView: View { .accessibilityIdentifier("detail_screen_sharing") } } + .id(selection ?? .screen) } } .onAppear { diff --git a/VoidDisplay/App/VoidDisplayApp.swift b/VoidDisplay/App/VoidDisplayApp.swift index b209632..fec1861 100644 --- a/VoidDisplay/App/VoidDisplayApp.swift +++ b/VoidDisplay/App/VoidDisplayApp.swift @@ -35,11 +35,18 @@ struct VoidDisplayApp: App { var body: some Scene { WindowGroup { - HomeView(screenCatalogOrchestrator: screenCatalog) - .environment(capture) - .environment(sharing) - .environment(virtualDisplay) - .environment(capturePerformancePreferences) + Group { + if CapturePreviewDiagnosticsRuntime.shouldAutoOpenPreviewWindow, + let sessionID = capture.screenCaptureSessions.first?.id { + CaptureDisplayView(sessionId: sessionID) + } else { + HomeView(screenCatalogOrchestrator: screenCatalog) + } + } + .environment(capture) + .environment(sharing) + .environment(virtualDisplay) + .environment(capturePerformancePreferences) } .windowToolbarStyle(.unified(showsTitle: true)) diff --git a/VoidDisplay/Features/Capture/Views/CaptureChoose.swift b/VoidDisplay/Features/Capture/Views/CaptureChoose.swift index 8b96144..d6b138e 100644 --- a/VoidDisplay/Features/Capture/Views/CaptureChoose.swift +++ b/VoidDisplay/Features/Capture/Views/CaptureChoose.swift @@ -60,6 +60,7 @@ struct IsCapturing: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { Task { await screenCatalogOrchestrator.handleAppear(source: .capturePage) } + guard !UITestRuntime.isEnabled else { return } lifecycle.handleAppear { guard viewModel.catalog.hasScreenCapturePermission == true else { return } Task { await screenCatalogOrchestrator.handleTopologyChanged() } @@ -67,6 +68,7 @@ struct IsCapturing: View { } .onDisappear { Task { await screenCatalogOrchestrator.handleDisappear(source: .capturePage) } + guard !UITestRuntime.isEnabled else { return } lifecycle.handleDisappear() } .accessibilityElement(children: .contain) diff --git a/VoidDisplay/Shared/ScreenCapture/ScreenCaptureCatalogService.swift b/VoidDisplay/Shared/ScreenCapture/ScreenCaptureCatalogService.swift index 309238a..015ddc2 100644 --- a/VoidDisplay/Shared/ScreenCapture/ScreenCaptureCatalogService.swift +++ b/VoidDisplay/Shared/ScreenCapture/ScreenCaptureCatalogService.swift @@ -161,7 +161,7 @@ final class ScreenCaptureCatalogService { self.coordinator = CatalogRefreshCoordinator( loadShareableDisplays: { try await Task { @MainActor in - try await dependencies.loadShareableDisplays() + try await dependencies.loadShareableDisplays().map(SendableDisplay.init) }.value }, runtimeScenarioProbe: dependencies.runtimeScenarioProbe @@ -422,7 +422,7 @@ actor CatalogRefreshCoordinator { case failedSuperseded } - private let loadShareableDisplays: @Sendable () async throws -> [SCDisplay] + private let loadShareableDisplays: @Sendable () async throws -> [SendableDisplay] private let runtimeScenarioProbe: ScreenCaptureDisplayCatalogLoader.RuntimeScenarioProbe private var nextLoadID: UInt64 = 0 private var activeLoadID: UInt64? @@ -431,7 +431,7 @@ actor CatalogRefreshCoordinator { private var waiterCountsByLoadID: [UInt64: Int] = [:] init( - loadShareableDisplays: @escaping @Sendable () async throws -> [SCDisplay], + loadShareableDisplays: @escaping @Sendable () async throws -> [SendableDisplay], runtimeScenarioProbe: ScreenCaptureDisplayCatalogLoader.RuntimeScenarioProbe ) { self.loadShareableDisplays = loadShareableDisplays @@ -495,7 +495,7 @@ actor CatalogRefreshCoordinator { if await MainActor.run(body: { runtimeScenarioProbe.shouldDelayDisplayLoadForUITest() }) { try await Task.sleep(for: .seconds(3)) } - return try await loadShareableDisplays().map(SendableDisplay.init) + return try await loadShareableDisplays() } activeTask = createdTask task = createdTask diff --git a/VoidDisplayUITests/Diagnostics/CapturePreviewDiagnosticsTests.swift b/VoidDisplayUITests/Diagnostics/CapturePreviewDiagnosticsTests.swift index c634c5c..a5f6414 100644 --- a/VoidDisplayUITests/Diagnostics/CapturePreviewDiagnosticsTests.swift +++ b/VoidDisplayUITests/Diagnostics/CapturePreviewDiagnosticsTests.swift @@ -92,13 +92,13 @@ private extension CapturePreviewDiagnosticsTests { scaleMode: PreviewScaleMode ) -> XCUIApplication { let app = XCUIApplication() - app.launchEnvironment["VOIDDISPLAY_UI_TEST_MODE"] = "1" - app.launchEnvironment["VOIDDISPLAY_TEST_ISOLATION_ID"] = UUID().uuidString + configureAppForUITestLaunch(app) app.launchEnvironment["VOIDDISPLAY_UI_TEST_SCENARIO"] = "capture_preview_diagnostics" app.launchEnvironment["VOIDDISPLAY_CAPTURE_PREVIEW_SOURCE_SIZE"] = sourceSize app.launchEnvironment["VOIDDISPLAY_CAPTURE_PREVIEW_TARGET_CONTENT_WIDTH"] = String(targetContentWidth) app.launchEnvironment["VOIDDISPLAY_CAPTURE_PREVIEW_SCALE_MODE"] = scaleMode.rawValue app.launch() + app.activate() return app } diff --git a/VoidDisplayUITests/Smoke/HomeSmokeTests.swift b/VoidDisplayUITests/Smoke/HomeSmokeTests.swift index 2395123..60c3c92 100644 --- a/VoidDisplayUITests/Smoke/HomeSmokeTests.swift +++ b/VoidDisplayUITests/Smoke/HomeSmokeTests.swift @@ -28,9 +28,6 @@ final class HomeSmokeTests: XCTestCase { let virtualDisplaySidebar = smokeElement(app, identifier: "sidebar_virtual_display") let monitorSidebar = smokeElement(app, identifier: "sidebar_monitor_screen") let sharingSidebar = smokeElement(app, identifier: "sidebar_screen_sharing") - let monitorDetail = smokeElement(app, identifier: "detail_monitor_screen") - let sharingDetail = smokeElement(app, identifier: "detail_screen_sharing") - let virtualDisplayRibbon = smokeElement(app, identifier: "virtual_display_primary_ribbon") assertAllExist( app, @@ -43,7 +40,7 @@ final class HomeSmokeTests: XCTestCase { "detail_screen", "displays_open_system_settings" ], - timeout: 3 + timeout: 6 ) virtualDisplaySidebar.tap() @@ -58,25 +55,45 @@ final class HomeSmokeTests: XCTestCase { ) monitorSidebar.tap() - assertElementsExist([("detail_monitor_screen", monitorDetail)], timeout: 1.2) + let didShowMonitorDetail = waitForIdentifierByPolling( + app, + identifier: "detail_monitor_screen", + timeout: 1.2, + activateBeforePolling: true + ) + if !didShowMonitorDetail { + print("AX DEBUG START") + print(app.debugDescription) + print("AX DEBUG END") + } + XCTAssertTrue( + didShowMonitorDetail, + """ + detail_monitor_screen did not appear after tapping sidebar_monitor_screen. + detailStates=\(detailVisibilitySummary(in: app)) + """.trimmingCharacters(in: .whitespacesAndNewlines) + ) sharingSidebar.tap() - assertElementsExist([("detail_screen_sharing", sharingDetail)], timeout: 1.2) + assertAllExist( + app, + identifiers: ["detail_screen_sharing"], + timeout: 1.2 + ) virtualDisplaySidebar.tap() - assertElementsExist([("virtual_display_primary_ribbon", virtualDisplayRibbon)], timeout: 1.2) + assertAllExist( + app, + identifiers: ["virtual_display_primary_ribbon"], + timeout: 1.2 + ) } @MainActor func testVirtualDisplayEditSmoke_directSaveActionsWithoutConfirmationAlert() throws { let app = launchAppForSmoke(scenario: .baseline) let detail = openVirtualDisplayDetail(in: app) - let openEditButton = smokeElement(app, identifier: "virtual_display_open_edit_test_button") - let initialEditState = openVirtualDisplayEditForm( - in: app, - detail: detail, - openEditButton: openEditButton - ) + let initialEditState = openVirtualDisplayEditForm(in: app, detail: detail) let initialValue = boolValue(forToggle: initialEditState.toggle) let initialRebuildCount = rebuildRequestCount(in: detail) tapFast( @@ -97,11 +114,7 @@ final class HomeSmokeTests: XCTestCase { XCTAssertTrue(waitForDisappearance(of: initialEditState.form, timeout: 1.5)) XCTAssertEqual(rebuildRequestCount(in: detail), initialRebuildCount) - let saveOnlyPersistedState = reopenEditFormAndReadHiDPI( - in: app, - detail: detail, - openEditButton: openEditButton - ) + let saveOnlyPersistedState = reopenEditFormAndReadHiDPI(in: app, detail: detail) XCTAssertEqual(saveOnlyPersistedState.value, !initialValue) tapFast( saveOnlyPersistedState.toggle, @@ -124,11 +137,7 @@ final class HomeSmokeTests: XCTestCase { ) ) - let saveAndRebuildPersistedState = reopenEditFormAndReadHiDPI( - in: app, - detail: detail, - openEditButton: openEditButton - ) + let saveAndRebuildPersistedState = reopenEditFormAndReadHiDPI(in: app, detail: detail) XCTAssertEqual(saveAndRebuildPersistedState.value, initialValue) tapFast( saveAndRebuildPersistedState.cancelButton, @@ -144,16 +153,6 @@ final class HomeSmokeTests: XCTestCase { let app = launchAppForSmoke(scenario: .permissionDenied) let monitorSidebar = smokeElement(app, identifier: "sidebar_monitor_screen") let sharingSidebar = smokeElement(app, identifier: "sidebar_screen_sharing") - let monitorDetail = smokeElement(app, identifier: "detail_monitor_screen") - let captureGuide = smokeElement(app, identifier: "capture_permission_guide") - let captureOpenSettings = smokeElement(app, identifier: "capture_open_settings_button") - let captureRequest = smokeElement(app, identifier: "capture_request_permission_button") - let captureRefresh = smokeElement(app, identifier: "capture_refresh_button") - let sharingDetail = smokeElement(app, identifier: "detail_screen_sharing") - let shareGuide = smokeElement(app, identifier: "share_permission_guide") - let shareOpenSettings = smokeElement(app, identifier: "share_open_settings_button") - let shareRequest = smokeElement(app, identifier: "share_request_permission_button") - let shareRefresh = smokeElement(app, identifier: "share_refresh_button") assertAllExist( app, @@ -161,25 +160,27 @@ final class HomeSmokeTests: XCTestCase { timeout: 2 ) monitorSidebar.tap() - assertElementsExist( - [ - ("detail_monitor_screen", monitorDetail), - ("capture_permission_guide", captureGuide), - ("capture_open_settings_button", captureOpenSettings), - ("capture_request_permission_button", captureRequest), - ("capture_refresh_button", captureRefresh) + assertAllExist( + app, + identifiers: [ + "detail_monitor_screen", + "capture_permission_guide", + "capture_open_settings_button", + "capture_request_permission_button", + "capture_refresh_button" ], timeout: 1.2 ) sharingSidebar.tap() - assertElementsExist( - [ - ("detail_screen_sharing", sharingDetail), - ("share_permission_guide", shareGuide), - ("share_open_settings_button", shareOpenSettings), - ("share_request_permission_button", shareRequest), - ("share_refresh_button", shareRefresh) + assertAllExist( + app, + identifiers: [ + "detail_screen_sharing", + "share_permission_guide", + "share_open_settings_button", + "share_request_permission_button", + "share_refresh_button" ], timeout: 1.2 ) @@ -199,11 +200,7 @@ final class HomeSmokeTests: XCTestCase { ) let monitorSidebar = smokeElement(app, identifier: "sidebar_monitor_screen") let sharingSidebar = smokeElement(app, identifier: "sidebar_screen_sharing") - let monitorDetail = smokeElement(app, identifier: "detail_monitor_screen") - let captureLoading = smokeElement(app, identifier: "capture_loading_displays") - let sharingDetail = smokeElement(app, identifier: "detail_screen_sharing") let startServiceButton = smokeElement(app, identifier: "share_start_service_button") - let shareLoading = smokeElement(app, identifier: "share_loading_displays") assertAllExist( app, @@ -211,25 +208,31 @@ final class HomeSmokeTests: XCTestCase { timeout: 2 ) monitorSidebar.tap() - assertElementsExist( - [ - ("detail_monitor_screen", monitorDetail), - ("capture_loading_displays", captureLoading) + assertAllExist( + app, + identifiers: [ + "detail_monitor_screen", + "capture_loading_displays" ], timeout: 1.2 ) sharingSidebar.tap() - assertElementsExist( - [ - ("detail_screen_sharing", sharingDetail), - ("share_start_service_button", startServiceButton) + assertAllExist( + app, + identifiers: [ + "detail_screen_sharing", + "share_start_service_button" ], timeout: 1.2 ) startServiceButton.tap() - if waitForCondition(timeout: 1.0, condition: { shareLoading.exists }) { + if waitForIdentifierByPolling( + app, + identifier: "share_loading_displays", + timeout: 1.0 + ) { app.terminate() return } @@ -306,19 +309,24 @@ final class HomeSmokeTests: XCTestCase { @MainActor private func openVirtualDisplayDetail(in app: XCUIApplication) -> XCUIElement { - let virtualDisplaySidebar = smokeElement(app, identifier: "sidebar_virtual_display") - let detail = smokeElement(app, identifier: "detail_virtual_display") - assertElementsExist([("sidebar_virtual_display", virtualDisplaySidebar)], timeout: 1.2) - virtualDisplaySidebar.tap() - assertElementsExist([("detail_virtual_display", detail)], timeout: 1.2) - return detail + assertAllExist( + app, + identifiers: ["sidebar_virtual_display"], + timeout: 1.2 + ) + smokeElement(app, identifier: "sidebar_virtual_display").tap() + assertAllExist( + app, + identifiers: ["detail_virtual_display"], + timeout: 1.2 + ) + return smokeElement(app, identifier: "detail_virtual_display") } @MainActor private func openVirtualDisplayEditForm( in app: XCUIApplication, - detail: XCUIElement, - openEditButton: XCUIElement + detail: XCUIElement ) -> ( form: XCUIElement, toggle: XCUIElement, @@ -331,33 +339,48 @@ final class HomeSmokeTests: XCTestCase { let saveOnlyButton = smokeElement(app, identifier: "virtual_display_edit_save_only_button") let saveAndRebuildButton = smokeElement(app, identifier: "virtual_display_edit_save_and_rebuild_button") let cancelButton = smokeElement(app, identifier: "virtual_display_edit_cancel_button") - let formElements: [SmokeNamedElement] = [ - ("edit_virtual_display_form", form), - ("virtual_display_edit_mode_hidpi_toggle", toggle), - ("virtual_display_edit_save_only_button", saveOnlyButton), - ("virtual_display_edit_save_and_rebuild_button", saveAndRebuildButton), - ("virtual_display_edit_cancel_button", cancelButton) - ] assertAllExist( app, identifiers: ["detail_virtual_display", "virtual_display_open_edit_test_button"], timeout: 1.2 ) XCTAssertTrue(detail.exists, "Virtual display detail is unavailable.") + let openEditButton = smokeElement(app, identifier: "virtual_display_open_edit_test_button") tapByCoordinate( openEditButton, timeout: 1, requireExistenceCheck: false ) if waitForIdentifierByPolling(app, identifier: "edit_virtual_display_form", timeout: 0.9) { - assertElementsExist(formElements, timeout: 0.6) + assertAllExist( + app, + identifiers: [ + "edit_virtual_display_form", + "virtual_display_edit_mode_hidpi_toggle", + "virtual_display_edit_save_only_button", + "virtual_display_edit_save_and_rebuild_button", + "virtual_display_edit_cancel_button" + ], + timeout: 0.6 + ) } else { + let retryOpenEditButton = smokeElement(app, identifier: "virtual_display_open_edit_test_button") tapByCoordinate( - openEditButton, + retryOpenEditButton, timeout: 0.6, requireExistenceCheck: false ) - assertElementsExist(formElements, timeout: 1.5) + assertAllExist( + app, + identifiers: [ + "edit_virtual_display_form", + "virtual_display_edit_mode_hidpi_toggle", + "virtual_display_edit_save_only_button", + "virtual_display_edit_save_and_rebuild_button", + "virtual_display_edit_cancel_button" + ], + timeout: 1.5 + ) } return ( form: form, @@ -371,8 +394,7 @@ final class HomeSmokeTests: XCTestCase { @MainActor private func reopenEditFormAndReadHiDPI( in app: XCUIApplication, - detail: XCUIElement, - openEditButton: XCUIElement + detail: XCUIElement ) -> ( form: XCUIElement, toggle: XCUIElement, @@ -381,11 +403,7 @@ final class HomeSmokeTests: XCTestCase { cancelButton: XCUIElement, value: Bool ) { - let state = openVirtualDisplayEditForm( - in: app, - detail: detail, - openEditButton: openEditButton - ) + let state = openVirtualDisplayEditForm(in: app, detail: detail) return ( state.form, state.toggle, @@ -463,6 +481,22 @@ final class HomeSmokeTests: XCTestCase { return markers.contains { normalizedMessage.contains($0) } } + @MainActor + private func detailVisibilitySummary(in app: XCUIApplication) -> String { + [ + "detail_screen", + "detail_virtual_display", + "detail_monitor_screen", + "detail_screen_sharing", + "capture_choose_root", + "share_content_root" + ] + .map { identifier in + "\(identifier)=\(smokeElement(app, identifier: identifier).exists)" + } + .joined(separator: ", ") + } + @MainActor private func sharePageVisibleStates(_ app: XCUIApplication) -> [String] { let identifiers = [ diff --git a/VoidDisplayUITests/Smoke/SmokeTestHelpers.swift b/VoidDisplayUITests/Smoke/SmokeTestHelpers.swift index bd01a0e..2a7c276 100644 --- a/VoidDisplayUITests/Smoke/SmokeTestHelpers.swift +++ b/VoidDisplayUITests/Smoke/SmokeTestHelpers.swift @@ -12,6 +12,18 @@ enum SmokeScenario: String { } extension XCTestCase { + @MainActor + func configureAppForUITestLaunch(_ app: XCUIApplication) { + app.launchArguments = [ + "-ApplePersistenceIgnoreState", + "YES", + "-NSQuitAlwaysKeepsWindows", + "NO" + ] + app.launchEnvironment["VOIDDISPLAY_UI_TEST_MODE"] = "1" + app.launchEnvironment["VOIDDISPLAY_TEST_ISOLATION_ID"] = UUID().uuidString + } + @MainActor func smokeElement( _ app: XCUIApplication, @@ -28,16 +40,16 @@ extension XCTestCase { preferredPort: UInt16? = nil ) -> XCUIApplication { let app = XCUIApplication() - app.launchEnvironment["VOIDDISPLAY_UI_TEST_MODE"] = "1" - app.launchEnvironment["VOIDDISPLAY_TEST_ISOLATION_ID"] = UUID().uuidString + configureAppForUITestLaunch(app) app.launchEnvironment["VOIDDISPLAY_UI_TEST_SCENARIO"] = scenario.rawValue if let preferredPort { - app.launchArguments = [ + app.launchArguments.append(contentsOf: [ "-sharing.preferredPort", String(preferredPort) - ] + ]) } app.launch() + app.activate() return app } @@ -84,25 +96,26 @@ extension XCTestCase { file: StaticString = #filePath, line: UInt = #line ) { - let elements = identifiers.map { identifier in - app.descendants(matching: .any) - .matching(identifier: identifier) - .firstMatch - } let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { - let missing = zip(identifiers, elements) - .filter { !$0.1.exists } - .map(\.0) + let missing = identifiers.filter { identifier in + !app.descendants(matching: .any) + .matching(identifier: identifier) + .firstMatch + .exists + } if missing.isEmpty { return } RunLoop.current.run(until: Date().addingTimeInterval(pollInterval)) } - let missing = zip(identifiers, elements) - .filter { !$0.1.exists } - .map(\.0) + let missing = identifiers.filter { identifier in + !app.descendants(matching: .any) + .matching(identifier: identifier) + .firstMatch + .exists + } XCTAssertTrue(missing.isEmpty, "Missing identifiers: \(missing.joined(separator: ", "))", file: file, line: line) } @@ -217,20 +230,23 @@ extension XCTestCase { pollInterval: TimeInterval = 0.1, activateBeforePolling: Bool = false ) -> Bool { - let element = app.descendants(matching: .any) - .matching(identifier: identifier) - .firstMatch let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if activateBeforePolling { app.activate() } - if element.exists { + if app.descendants(matching: .any) + .matching(identifier: identifier) + .firstMatch + .exists { return true } RunLoop.current.run(until: Date().addingTimeInterval(pollInterval)) } - return element.exists + return app.descendants(matching: .any) + .matching(identifier: identifier) + .firstMatch + .exists } @MainActor From 414502dace38fa6e4d5f5dd89527dac5ade79f3b Mon Sep 17 00:00:00 2001 From: Chen Date: Sat, 4 Apr 2026 16:03:47 +0800 Subject: [PATCH 34/34] =?UTF-8?q?test(ui):=20=E8=A1=A5=E5=9B=9E=E9=87=8D?= =?UTF-8?q?=E5=BB=BA=E5=A4=B1=E8=B4=A5=20smoke=20=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 恢复 CI 使用的 rebuildFailed 独立 UI smoke 测试名 - 直接校验失败行展示重试按钮且无重建进度条 - 对齐 workflow 既有 only-testing 配置,消除远端假失败 --- VoidDisplayUITests/Smoke/HomeSmokeTests.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/VoidDisplayUITests/Smoke/HomeSmokeTests.swift b/VoidDisplayUITests/Smoke/HomeSmokeTests.swift index 60c3c92..db7ff77 100644 --- a/VoidDisplayUITests/Smoke/HomeSmokeTests.swift +++ b/VoidDisplayUITests/Smoke/HomeSmokeTests.swift @@ -289,6 +289,25 @@ final class HomeSmokeTests: XCTestCase { XCTAssertTrue(retryButton.isEnabled) } + @MainActor + func testVirtualDisplaySmoke_rebuildFailedRowShowsRetry() throws { + let app = launchAppForSmoke(scenario: .virtualDisplayRebuildFailed) + _ = openVirtualDisplayDetail(in: app) + + assertAllExist( + app, + identifiers: [ + "detail_virtual_display", + "virtual_display_rebuild_retry_button" + ], + timeout: 1.2 + ) + + let retryButton = smokeElement(app, identifier: "virtual_display_rebuild_retry_button") + XCTAssertTrue(retryButton.isEnabled) + XCTAssertFalse(smokeElement(app, identifier: "virtual_display_rebuild_progress").exists) + } + @MainActor private func boolValue(forToggle toggle: XCUIElement) -> Bool { if let numberValue = toggle.value as? NSNumber {