From d76e94eb715cdeb89e4bb742d9174bf776b2f3ae Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Thu, 17 Sep 2026 08:29:15 +0800 Subject: [PATCH] feat(app): improve capture controls and sharing feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem and outcome / 问题与结果 Windows App users can now choose whether to show the capture border. The same border owner handles source previews and active capture; unsupported or denied requests keep normal capture available. The source picker distinguishes an absent/incompatible App, unavailable capture capability, failed enumeration and a genuinely empty list. Chrome's pending local-access permission no longer holds optional Viewer reception. This phase also reduces tooltip obstruction and contains a failed Linux encoder output within its own branch. Browser/server v23, Native control v9 and capture v7 remain compatible. Closes #401. ## Release notes ### 中文 这是一次分享体验更新,重点改进 Windows 采集边框、App 选源反馈和提示浮窗,并修复 Linux 采集输出的故障隔离。 - **采集边框可选。** Windows App 选源器新增「显示采集边框」,位于声音开关左侧,默认关闭。支持的系统上,来源预览也会隐藏边框。 - **找不到窗口时,提示更明确。** 区分 App 未连接、版本不兼容、采集不可用和读取失败;授权后可以刷新列表重试。改善 Chrome 首次访问时的观看连接,避免本机授权等待拖慢入座。 - **提示浮窗更顺手。** 统一 500 毫秒悬停延迟,减少对画面和控件的遮挡,改善触屏长按和关闭操作;只用于说明的控件使用普通鼠标光标。 - **Linux 分享更稳。** 修复单路编码输出失败可能连带中断其他输出的问题。 - **排查文档更完整。** 补充 App 来源列表、Windows 10 黄框及浏览器 WebRTC 设置的排查说明。 使用新的采集边框开关,请更新 Windows App 并刷新已打开的网页。Windows 10 没有对应的系统接口,边框仍由系统显示;文档中心提供可选处理方法。系统权限或其他采集会话也可能要求保留边框。 ### English This sharing update improves Windows capture borders, App source selection and tooltips, and fixes failure isolation between Linux capture outputs. - **Choose whether to show the capture border.** The Windows App source picker adds a **Show capture border** switch to the left of audio, off by default. Source previews also hide the border on supported systems. - **Clearer source-selection feedback.** The picker distinguishes an unavailable or incompatible App, unsupported capture and a failed source list. Refresh after granting access to retry. Pending local-access permission in Chrome no longer delays Browser reception for Viewers. - **Less intrusive tooltips.** A consistent 500 ms hover delay, improved placement and touch-hold behavior make guidance easier to read and dismiss. Help-only controls use the ordinary cursor. - **More resilient Linux sharing.** A failed encoder output no longer interrupts healthy sibling outputs. - **More useful troubleshooting.** The documentation covers App source lists, Windows 10 capture borders and browser WebRTC settings. Update the Windows App and refresh open pages to use the capture-border switch. Windows 10 lacks the corresponding system API and retains its system border; an optional procedure is available in the documentation center. System permission or another capture session may also require the border to remain visible. ## Verification / 验证 - `npm run check`: TypeScript, 998 tests and production Web build passed. - `npm run check:go`: Go formatting/tests/vet/target builds and Windows native capture checks passed. macOS cgo builds require their native CI runner. - Documentation, repository hygiene and website build passed. - Real Chrome 152 profiles: first-use local permission, recovery after consent and source-list refresh; optional Viewer discovery before/after consent; App Local without cross-address-space consent. - Public invitation gate: link creation, remote page and WebSocket access, App exit and link retirement passed. - Windows 11 border on/off, source preview and concurrent-capture checks have physical evidence. Tooltip hover, keyboard, touch-hold, dismissal and narrow-layout checks passed. - [Candidate CI](https://github.com/TNTcraftHIM/Piik/actions/runs/35157993032) passed at `1d159831`: Server/container, Windows/Linux/macOS packages, Go race checks and frontend validation. Publication was skipped. - Local Windows ZIP passed packaged startup, capture probing, embedded Web, helper identity/notices and clean-shutdown checks. - Windows H.264 and VP8 Host gates passed live/paused settings, background recovery, source replacement, source-end/restart, decoded Viewer frames and complete resource cleanup. - Final `0b370e6a` differs from the CI candidate only in TODO/status closure. Full Web/Go/native checks and website build passed again. The access/privacy gate passed credential-leak and cleanup checks. ## Impact / 影响 Compatible feature update for **v1.5.0**. No wire or storage version change. Older Apps retain their existing capture behavior through capability negotiation. Publication and matching deployments follow the release workflow. The independent Windows x86 experiment is excluded. Physical 32-bit Windows, macOS/Linux capture, unsupported Windows border control, and actual border-permission denial remain outside this candidate's acceptance evidence. The original missing-window, public-link timeout and URL-handler crash reports still need affected-machine diagnostics. --- cmd/piik-app/README.md | 12 +- cmd/piik-app/README.zh-CN.md | 9 +- .../adr/0010-cross-platform-client-runtime.md | 9 +- docs/guide/troubleshooting.md | 75 +++++++- docs/guide/troubleshooting.zh-CN.md | 63 +++++- docs/research/native-client-lifecycle.md | 75 +++++++- docs/standards/media-quality.md | 7 + docs/standards/presentation-lifecycle.md | 9 + docs/standards/visual-language.md | 31 ++- docs/status.md | 4 +- docs/todo.md | 60 ++---- docs/verification-status.md | 7 +- internal/app/app.go | 11 +- internal/app/loopback/server.go | 11 +- internal/app/nativecapture/probe.go | 37 ++-- internal/app/nativecapture/probe_test.go | 19 +- internal/app/nativecapture/stream.go | 18 +- internal/app/nativecontrol/session.go | 18 +- internal/app/nativecontrol/session_test.go | 68 +++++++ internal/app/nativecontrol/wire.go | 40 ++-- native/capture/linux/README.md | 15 +- native/capture/linux/main.c | 85 ++++++++- native/capture/linux/main.test.c | 137 ++++++++++++++ native/capture/windows/README.md | 13 ++ native/capture/windows/capture_border.h | 73 +++++++ native/capture/windows/capture_target.cpp | 5 + native/capture/windows/main.cpp | 13 ++ scripts/app-native-host-gate.ts | 4 +- scripts/check-go.mjs | 5 +- scripts/publish-container.mjs | 3 +- scripts/release-version.mjs | 2 +- .../components/living/CaptureSourcePicker.tsx | 72 +++++-- src/client/components/living/Metrics.tsx | 2 +- src/client/components/living/Tooltip.tsx | 123 +++++++----- .../components/living/ViewerOverview.tsx | 2 +- .../components/living/hints/controls.tsx | 24 +++ src/client/components/living/hints/index.tsx | 2 + src/client/locales/en.ts | 5 + src/client/locales/zh.ts | 5 + src/client/native/client.ts | 113 +++++++---- src/client/native/wire.ts | 2 + src/client/pages/ControlsPreview.tsx | 22 ++- src/client/pages/HostPage.tsx | 35 +++- src/client/pages/ViewerPage.tsx | 2 +- src/client/styles.css | 25 ++- src/client/ui/visual-kinds.ts | 2 + tests/host-quality-ownership.test.ts | 72 ++++++- tests/native-capture-selection.test.ts | 40 ++++ tests/native-client.test.ts | 179 +++++++++++++++++- tests/publish-container.test.ts | 11 +- tests/release-automation.test.ts | 1 + tests/viewer-native-client-ownership.test.ts | 1 + 52 files changed, 1380 insertions(+), 298 deletions(-) create mode 100644 native/capture/windows/capture_border.h diff --git a/cmd/piik-app/README.md b/cmd/piik-app/README.md index 2bff446..a5c9bea 100644 --- a/cmd/piik-app/README.md +++ b/cmd/piik-app/README.md @@ -69,6 +69,13 @@ Native capture uses the Host's current quality settings. The live changes and encoding reuse; [verification status](../../docs/verification-status.md) keeps the measured limits. +On supported Windows versions, the **Apps / Windows** and **Screens** tabs offer +**Show capture border**, off by default. Change it when selecting or switching a +source; the Host page remembers your choice. Windows 10, system policy or another +active capture can keep the yellow border visible; sharing remains available. +See [capture-border troubleshooting](../../docs/guide/troubleshooting.md#yellow-capture-border-on-windows) +for an optional Windows 10 workaround. + The App configuration keeps an optional Local site passphrase. Leave it blank for an open Local site, or enter a password in the launcher before starting. Viewer invitations grant access to their room independently; see @@ -93,12 +100,15 @@ use a configured Site when persistent control availability or SFU fallback matte ### Chromium WebRTC Connections -**Why can screen capture succeed while the media connection fails?** +**Why can the page open, or screen capture succeed, while sharing or viewing fails?** Chromium-based Browsers can restrict WebRTC UDP through Browser settings, extensions or managed policies. Disabling non-proxied UDP can prevent even the local Browser-to-App media connection; successful capture or page loading does not prove that this separate connection is available. +The viewing browser is affected too: this can lead to **No media route available** +or another connection error. Hiding local IP addresses alone does not necessarily +disable WebRTC; the effective transport policy matters. Check the Browser's WebRTC/IP-handling policy and any extension's WebRTC or IP-leak protection setting. Restore a policy that permits WebRTC UDP, reload diff --git a/cmd/piik-app/README.zh-CN.md b/cmd/piik-app/README.zh-CN.md index 3662a8b..dd1ee6d 100644 --- a/cmd/piik-app/README.zh-CN.md +++ b/cmd/piik-app/README.zh-CN.md @@ -55,6 +55,11 @@ macOS 和 Linux 原生采集需要硬件 H264。可选来源与音频支持取 [媒体质量参考](../../docs/standards/media-quality.md)说明,实测边界见 [验证状态](../../docs/verification-status.md)。 +在支持的 Windows 版本上,选源器的 **程序 / 窗口** 和 **屏幕** 页提供 **显示采集边框** 开关,默认关闭。 +选取或切换来源时可以修改,本次房主页面会记住选择。 +Windows 10、系统策略或其他正在进行的采集可能让黄框保持可见,分享仍可正常进行。 +Windows 10 的可选处理方法见[采集边框问题排查](../../docs/guide/troubleshooting.zh-CN.md#windows-分享时出现黄框)。 + App 配置保存可选的本地站点口令。留空表示本地站点开放进入;需要口令时,在启动页填写后再启动。 观众邀请独立授予对应房间的访问权,详见[房间与访问](../../docs/standards/rooms-access.md)。 @@ -73,11 +78,13 @@ App 在独立 Windows 控制台中启动时,启动或运行错误会保留在 ### Chromium WebRTC 连接 -**为什么屏幕采集成功了,媒体连接却失败?** +**为什么网页能打开、屏幕也能采集,却无法分享或观看?** 基于 Chromium 的浏览器可能通过浏览器设置、扩展或管理策略限制 WebRTC UDP。 禁止未经代理的 UDP,甚至会阻止本机浏览器与 App 之间的媒体连接; 采集成功或页面能打开,并不能证明这条独立连接可用。 +观看端也会受影响,可能看到 **没有可用的媒体线路** 或其他连接失败提示。 +仅隐藏本机 IP 地址不一定会禁用 WebRTC,需要看实际生效的传输策略。 检查浏览器的 WebRTC/IP 处理策略,以及扩展中的 WebRTC 或 IP 泄露保护设置。 恢复允许 WebRTC UDP 的策略,重新加载 Piik,再确认没有其他扩展或管理策略覆盖该选择。 diff --git a/docs/adr/0010-cross-platform-client-runtime.md b/docs/adr/0010-cross-platform-client-runtime.md index a541a7d..2956e78 100644 --- a/docs/adr/0010-cross-platform-client-runtime.md +++ b/docs/adr/0010-cross-platform-client-runtime.md @@ -56,10 +56,11 @@ topologies and makes a saved Site unavailable while another room source runs. list local screen/window choices, request bounded previews, and own one share's generation-fenced SDP/ICE edges, including at most one loopback media bridge outside route-copy capacity; it carries no room password, Host token, Viewer grant, or route - policy. An activated participant tab opens this connection lazily on its first - native action, reuses it across successive media generations, and closes it - with the page; a picker, share, or room source does not own the socket. The - Browser forwards current signaling and remains the participant. + policy. The participant page owns this connection and opens it lazily for + native use. The [presentation lifecycle](../standards/presentation-lifecycle.md#product-surface) + owns acquisition and retirement, including releasing unused Host control. + Source replacement retains the connection; a new source or picker does not + create another participant. The Browser forwards current signaling. 5. A platform package contains one Go executable carrying the same core and embedded Browser assets used by Hosted Piik. It may also carry one process-isolated capture binary and the pinned `cloudflared` sidecar. Those diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md index 70ccde2..1ad6276 100644 --- a/docs/guide/troubleshooting.md +++ b/docs/guide/troubleshooting.md @@ -11,7 +11,8 @@ collect a diagnostic report if the problem persists. | --- | --- | | Picture but no sound | Unmute the video. The host should choose a source with shareable audio; if sound was disabled, stop and start sharing with it enabled. App window/screen capture keeps that setting when switching sources. | | No screen picker | Allow the browser or App to record the screen when the OS asks. Browser capture needs HTTPS or `localhost`; try sharing from a desktop computer. | -| No App windows or screens listed | Keep the App running and reopen its sharing page. Allow local-network access if asked, then refresh the source list. You can also choose **Browser** → **Browser picker** to use browser capture. If the problem persists, collect the Debug report described below. | +| No App windows or screens listed | Check the message in the source picker, then follow [App source troubleshooting](#app-windows-or-screens-are-missing). **Browser** → **Browser picker** also offers browser capture. | +| Yellow outline around the shared window or screen | This is Windows' capture indicator. See [capture borders](#yellow-capture-border-on-windows) for Windows 11 controls and an optional Windows 10 workaround. | | App startup fails | Read the reason on the page and in the terminal. Reopen the App and enable the chip-shaped **Debug launch** control after the theme button before trying again. A failed startup then exports a report; its path appears in the terminal. | | Local invitation will not open | Check that both devices are on the same network and can reach each other. Guest Wi-Fi or firewall rules can block local access. | | Page opens but video will not connect | Use **Reconnect** if available, or refresh the viewing page. See [connection troubleshooting](#when-video-will-not-connect) if it still fails. | @@ -21,6 +22,58 @@ For a bug report, include the version, OS/browser, what you expected, and how to reproduce it. [Diagnostics and export](../../cmd/piik-app/README.md#diagnostics) explains how to collect a local report and what to review before sharing it. +## App windows or screens are missing + +Keep Piik App running on the computer where you are sharing. Open the current +site from the App; for a hosted site, use the App's **Site** mode with that +site's address. If the browser asks for local-network access, allow it, then +refresh the source list. If access was previously blocked, review this site's +browser permissions first. + +The source picker distinguishes these outcomes: + +| Message | Next step | +| --- | --- | +| Cannot connect to Piik App | Confirm the App is running and opened this site. Close unused Piik tabs using the App, then refresh the list; up to two pages can use the App's native capture or viewing at once. | +| Piik App and this page are incompatible | Update Piik App and reload the page. | +| Piik App capture is currently unavailable | Check the App diagnostics for capture or encoder availability. Review your encoding setting or use browser capture. | +| Could not read the screen and window list | Refresh to retry. If it keeps failing, export both the App and browser Debug reports. | +| No sources available | The list was read successfully, but this tab has no selectable sources. Check the other source tabs. | + +Local-network permission lets the page contact the App. It is separate from +the WebRTC media settings described below. An unreachable App alone does not +prove that permission was denied. + +## Yellow capture border on Windows + +Windows draws this outline to identify the window or display being captured. +On supported Windows versions, the App source picker offers **Show capture border**, +off by default. Windows permissions or another active capture can still require +the border. Windows 10 does not provide this control for its capture API. + +### Optional Windows 10 workaround + +[DWM Custom Projection Border](https://windhawk.net/mods/dwm-custom-projection-border) +is a third-party Windhawk mod for x64 Windows that can hide the border while keeping the same +capture method. It changes Windows' desktop compositor and affects other apps' +capture indicators too. The author shows it working on Windows 10 21H2; this +procedure has not been tested with Piik on a Windows 10 device. + +1. Install [Windhawk](https://windhawk.net/) from its official website. +2. In Windhawk's global **Settings → Advanced settings → More advanced settings**, + append `dwm.exe` to **Process inclusion list** and save. Keep existing entries. +3. Find and install **DWM Custom Projection Border**. In the mod's settings, + turn on **Disable border** and save. +4. Stop and restart sharing in Piik, then check whether the border disappears. + +To undo this, disable the mod and restart sharing. Remove the `dwm.exe` entry +if you added it only for this mod. If desktop problems appear or a Windows +update breaks compatibility, disable the mod first. + +See the [mod author's instructions](https://github.com/ramensoftware/windhawk-mods/blob/main/mods/dwm-custom-projection-border.wh.cpp) +and [Windhawk's process settings](https://github.com/ramensoftware/windhawk/wiki/Injection-targets-and-critical-system-processes) +for current setup details. + ## When video will not connect **No media route available** means Piik has not found a working path to deliver @@ -38,23 +91,29 @@ Try these in order, stopping when the picture arrives: disabled or does not help, refresh the viewing page or reopen the invitation. Let each connection attempt finish. One or two fresh attempts can be worth trying; repeated refreshes cannot remove a network restriction. -2. **Try another network.** For example, test a phone hotspot. If you use a VPN, - proxy or WebRTC-blocking extension, check its UDP policy with the network - administrator. See [browser connection settings](../../cmd/piik-app/README.md#chromium-webrtc-connections). -3. **Enable usable IPv6.** Check that your provider, router and device support it. +2. **Check WebRTC settings in the viewing browser.** A browser setting or VPN/privacy + extension that disables WebRTC or non-proxied UDP can prevent viewing, even + while the page loads. Merely hiding local IP addresses does not necessarily + block WebRTC. Try the same invitation in a browser profile without those + extensions, or review the specific WebRTC setting, then reload Piik. + See [browser connection settings](../../cmd/piik-app/README.md#chromium-webrtc-connections) + for examples and the privacy tradeoff; keep unrelated protections enabled. +3. **Try another network.** For example, test a phone hotspot. If you use a VPN + or proxy, check its UDP policy; ask the administrator on a managed network. +4. **Enable usable IPv6.** Check that your provider, router and device support it. When both peers have working IPv6, Piik can try that direct path alongside IPv4. It does not bypass firewall rules; keep IPv4 enabled too. -4. **Check your own router, if you manage it.** On a trusted home network, +5. **Check your own router, if you manage it.** On a trusted home network, supported UPnP, PCP or NAT-PMP settings can let the App's native media path request a port mapping. Browser-only capture does not request these mappings. If a modem and router both perform NAT, follow your model's bridge/AP-mode instructions to remove the extra layer. Carrier-grade NAT is upstream of your router; ask your provider about available public IPv4 or IPv6 service. Back up settings before changing the router's operating mode. -5. **Invite a friend on another network.** A Viewer who can receive the picture +6. **Invite a friend on another network.** A Viewer who can receive the picture and has spare forwarding capacity may give Piik another path to you. Piik chooses this automatically; extra people help only when those connections work. -6. **Use a site with media fallback.** Its operator must enable SFU forwarding, +7. **Use a site with media fallback.** Its operator must enable SFU forwarding, and the Host must turn off **Privacy mode** before sharing. The public `demo.piik.tv` site and the App's **Public invite** mode do not provide SFU. [Self-hosting](../operations/self-hosting.md#optional-media-fallback) is an diff --git a/docs/guide/troubleshooting.zh-CN.md b/docs/guide/troubleshooting.zh-CN.md index 1e558c8..f782a6f 100644 --- a/docs/guide/troubleshooting.zh-CN.md +++ b/docs/guide/troubleshooting.zh-CN.md @@ -11,7 +11,8 @@ | --- | --- | | 有画面,没声音 | 先取消视频静音。房主应选择支持分享声音的来源;如果开始时关闭了声音,请停止后重新开始并打开声音。通过 App 采集窗口或屏幕时,切换来源会保留原来的声音设置。 | | 没有屏幕选择弹窗 | 按系统提示允许浏览器或 App 录制屏幕。浏览器采集需要 HTTPS 或 `localhost`,请在电脑上尝试分享。 | -| App 的窗口或屏幕列表为空 | 保持 App 运行,从 App 重新打开分享页面;浏览器询问本地网络权限时允许访问,再刷新来源列表。也可以选 **浏览器** → **浏览器选择器**,使用浏览器采集。仍然失败时,按下方说明收集诊断报告。 | +| App 的窗口或屏幕列表为空 | 先查看选源器中的提示,再按 [App 来源排查](#app-找不到窗口或屏幕) 操作;也可以选 **浏览器** → **浏览器选择器** 使用浏览器采集。 | +| 分享的窗口或屏幕周围出现黄框 | 这是 Windows 的采集提示。查看[采集边框说明](#windows-分享时出现黄框),了解 Windows 11 的开关和 Windows 10 的可选处理方法。 | | App 启动失败 | 先查看页面和终端中的具体原因。重新打开 App,在主题按钮后的芯片图标开启「诊断启动」再重试。启动失败时会自动导出报告,保存位置见终端。 | | 本地邀请打不开 | 确认两台设备在同一网络且可以互相访问。访客 Wi-Fi 或防火墙规则可能阻止本地连接。 | | 网页能打开,画面连不上 | **重新连接** 按钮可用时先点击,否则刷新观看页。仍然失败时,查看 [画面连接排查](#画面连接不上)。 | @@ -21,6 +22,51 @@ [诊断与导出](../../cmd/piik-app/README.zh-CN.md#诊断) 说明如何收集本地报告,以及分享前需要检查哪些内容。 +## App 找不到窗口或屏幕 + +在分享画面的这台电脑上保持 Piik App 运行,并从 App 打开当前站点。 +使用已部署的站点时,在 App 的 **站点** 模式中填写该站点地址。 +浏览器询问本地网络权限时允许访问,再刷新来源列表;如果之前拒绝过,先检查当前站点的浏览器权限。 + +选源器会区分以下情况: + +| 提示 | 处理方法 | +| --- | --- | +| 未能连接 Piik App | 确认 App 正在运行,且通过 App 打开了当前站点。关闭其他正在使用 App 的闲置 Piik 标签页后刷新列表;最多两个页面可以同时使用 App 的原生采集或收看功能。 | +| Piik App 与当前页面不兼容 | 更新 Piik App 并刷新页面。 | +| Piik App 采集功能当前不可用 | 查看 App 诊断信息中的采集和编码能力,检查编码设置,或使用浏览器采集。 | +| 未能读取 App 的窗口和屏幕列表 | 刷新重试;若持续失败,请导出同一次尝试的 App 和网页诊断报告。 | +| 暂无可用来源 | 已成功读取列表,但当前分类没有可选来源,可以查看其他分类。 | + +本地网络权限用于让网页连接 App,与下文的 WebRTC 媒体设置不同。 +仅凭 App 未连接,不能确定是权限被拒绝。 + +## Windows 分享时出现黄框 + +Windows 用黄色边框标出正在采集的窗口或屏幕。在支持的 Windows 版本上, +App 选源器提供 **显示采集边框** 开关,默认关闭;系统权限或其他正在进行的采集 +仍可能要求显示边框。Windows 10 的采集接口没有这个开关。 + +### Windows 10 可选处理方法 + +[DWM Custom Projection Border](https://windhawk.net/mods/dwm-custom-projection-border) +是适用于 x64(64 位)Windows 的 Windhawk 第三方模组,可以在保持原有采集方式的情况下隐藏黄框。 +它会修改 Windows 桌面合成器的行为,也会影响其他软件的采集提示。 +作者提供了 Windows 10 21H2 的效果示例;以下步骤尚未在 Windows 10 设备上配合 Piik 实测。 + +1. 从[官方网站](https://windhawk.net/)安装 Windhawk。 +2. 打开 Windhawk 全局的 **设置 → 高级设置 → 更多高级设置**, + 在 **包含的进程列表(Process inclusion list)** 中追加一行 `dwm.exe` 并保存,保留已有条目。 +3. 搜索并安装 **DWM Custom Projection Border**,在模组设置中开启 + **Disable border(关闭边框)** 并保存。 +4. 在 Piik 中停止后重新开始分享,检查黄框是否消失。 + +恢复原状时,禁用该模组并重新开始分享。如果 `dwm.exe` 这一项是专门为该模组添加的, +也可以移除。若桌面出现异常,或 Windows 更新后不再兼容,先禁用模组。 + +最新操作说明见[模组作者说明](https://github.com/ramensoftware/windhawk-mods/blob/main/mods/dwm-custom-projection-border.wh.cpp) +和 [Windhawk 进程设置文档](https://github.com/ramensoftware/windhawk/wiki/Injection-targets-and-critical-system-processes)(英文)。 + ## 画面连接不上 **没有可用的媒体线路** 表示 Piik 还没有找到能把画面送到你设备上的连接。 @@ -34,18 +80,21 @@ NAT(网络地址转换)让多台设备共用一个上网地址。不同路 1. **重新尝试观看连接。** 如果 **重新连接** 可用,先点击;按钮不可用或没有效果时, 刷新观看页或重新打开邀请链接。每次等连接尝试结束,重试一两次即可;反复刷新无法解除网络限制。 -2. **换个网络测试。** 例如试用手机热点。如果使用了 VPN、代理或限制 WebRTC 的扩展, - 检查其 UDP 策略,受管理的网络请联系管理员。详见 [浏览器连接设置](../../cmd/piik-app/README.zh-CN.md#chromium-webrtc-连接)。 -3. **开启可用的 IPv6。** 先确认宽带、路由器和设备都支持。连接双方都有可用 IPv6 时, +2. **检查观看端浏览器的 WebRTC 设置。** 浏览器设置、VPN 或隐私扩展如果禁用了 WebRTC, + 或禁止未经代理的 UDP,可能导致网页能打开、画面却连不上。仅隐藏本机 IP 地址不一定会阻止 WebRTC。 + 可以用未安装这类扩展的浏览器配置打开同一邀请进行对照,或检查对应的 WebRTC 设置后重新加载 Piik。 + 具体示例及隐私影响见 [浏览器连接设置](../../cmd/piik-app/README.zh-CN.md#chromium-webrtc-连接),保留其他无关保护。 +3. **换个网络测试。** 例如试用手机热点。如果使用了 VPN 或代理,检查其 UDP 策略;受管理的网络请联系管理员。 +4. **开启可用的 IPv6。** 先确认宽带、路由器和设备都支持。连接双方都有可用 IPv6 时, Piik 可以多尝试一条直连路径;它仍受防火墙规则约束,同时保留 IPv4 即可。 -4. **检查自己管理的路由器。** 在可信家庭网络中,路由器支持的 UPnP、PCP 或 NAT-PMP +5. **检查自己管理的路由器。** 在可信家庭网络中,路由器支持的 UPnP、PCP 或 NAT-PMP 可以让 App 的原生媒体连接申请端口映射;纯浏览器采集不会申请这些映射。 如果光猫和路由器都在做 NAT,可按对应型号的桥接或 AP 模式说明减少一层。 运营商共享公网地址(CGNAT)位于路由器上游,需要向运营商咨询公网 IPv4 或 IPv6 服务。 修改路由器工作模式前,先备份配置。 -5. **请另一网络的朋友加入。** 如果对方能正常看到画面,且有余力转发,Piik 可能经由这位观众 +6. **请另一网络的朋友加入。** 如果对方能正常看到画面,且有余力转发,Piik 可能经由这位观众 把画面送到你这里。线路由 Piik 自动选择,能否帮上忙取决于这些连接是否可用。 -6. **使用有媒体兜底的站点。** 管理员需要先启用 SFU 转发,房主在分享前关闭 **隐私模式**。 +7. **使用有媒体兜底的站点。** 管理员需要先启用 SFU 转发,房主在分享前关闭 **隐私模式**。 公开站点 `demo.piik.tv` 和 App 的 **公网邀请** 模式均没有 SFU 兜底。 有服务器的用户也可以按 [部署指南](../operations/self-hosting.zh-CN.md#可选启用媒体兜底) 配置,属于进阶操作。 目前 P2P 和 SFU 媒体都使用 UDP;网络完全禁止 UDP 时,仍需换网络或由管理员放行。 diff --git a/docs/research/native-client-lifecycle.md b/docs/research/native-client-lifecycle.md index bac8b85..86ffa4a 100644 --- a/docs/research/native-client-lifecycle.md +++ b/docs/research/native-client-lifecycle.md @@ -1,7 +1,7 @@ # Native App Lifecycle -- Reviewed: 2026-09-09 -- Scope: Windows capture idle semantics, native loopback input isolation, and +- Reviewed: 2026-09-17 +- Scope: Windows capture idle/border semantics, native loopback input isolation, and Browser visibility of an unexpected App disconnect. - Status: quiet-source, preview-queue and App-crash checks pass. The owner confirmed Windows 10 monitor sharing resolved; game-specific window replacement @@ -20,6 +20,23 @@ at nine seconds. The picker now sends one preview at a time, cancels unsent work when its source view is retired, and waits for the remaining preview before starting native media. Refresh and App replacement fence old results. +### Browser Local-Network Consent + +[Chromium local-network access](https://developer.chrome.com/blog/local-network-access) +can hold a public site's loopback fetch until the user answers a browser prompt. +Its [split-permission implementation](https://chromium.googlesource.com/chromium/src.git/+/f7eb223f51392d3eeb51a7d4b32db0762bf70d02%5E%21/) +exposes `loopback-network`, with the older `local-network-access` name retained +for browsers using a combined permission. An unsupported permission query is +unknown, not denial. A loopback page can report `prompt` even though its +loopback-to-loopback request needs no consent. + +Fresh-profile Windows/Chrome 152 checks confirmed a real prompt, successful +discovery after granting access, and source-list recovery by refreshing after +the discovery deadline. Optional Viewer discovery consults the permission before +waiting; later grants can be used by later connections without replacing healthy +Browser media. These checks do not identify the original missing-window +reporter's cause or establish other browsers' permission behavior. + ## Scope Decisions - Keep one strict loopback wire and one-share/one-session fence. @@ -229,6 +246,60 @@ Aspect references: [active display paths](https://learn.microsoft.com/en-us/wind [active signal size](https://learn.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-displayconfig_video_signal_info), and [independent panel fitting/MPO](https://learn.microsoft.com/en-us/windows/win32/direct3ddxgi/for-best-performance--use-dxgi-flip-model). +## Windows Capture Borders + +The accepted scope keeps WGC for Windows capture and previews. Piik does not +add DXGI or inject into system processes to hide the border. The reader-facing +[troubleshooting guide](../guide/troubleshooting.md#yellow-capture-border-on-windows) +owns the optional external-mod procedure; the evidence below records its limits +and the alternative's integration cost. + +Consumer Windows 10 does not expose the WGC border-control API: Microsoft's +[`IsBorderRequired`](https://learn.microsoft.com/en-us/uwp/api/windows.graphics.capture.graphicscapturesession.isborderrequired) +starts at build 20348, beyond consumer Windows 10 build 19045. The current +capture helper therefore retains the system border there. Browser capture +indicators remain Browser-owned. + +An unofficial WGC-preserving option exists: +[Windhawk's DWM Custom Projection Border](https://github.com/ramensoftware/windhawk-mods/blob/main/mods/dwm-custom-projection-border.wh.cpp) +documents disabling the border on Windows 10 21H2. Source inspection confirms +injection into `dwm.exe` and hooks of private `uDWM.dll` +`CProjectionBorderVisual` drawing methods. Its disable flag has no per-app +filter. This is a desktop-compositor modification, not a missing Piik session +option; compatibility with private Windows methods is outside WGC's contract. +It has not been locally verified on Windows 10 and is not a bundled dependency +or automatic workaround. The official +[border privacy policy](https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-privacy#letappsaccessgraphicscapturewithoutborder) +applies to Windows 11 onward, so registry/policy recipes do not establish a +supported Windows 10 solution. + +The official [Desktop Duplication API](https://learn.microsoft.com/en-us/windows/win32/direct3ddxgi/desktop-dup-api) +is a borderless alternative for a selected display. It is not window-isolated +capture: cropping the desktop can expose overlapping windows. Do not silently +substitute it for a selected window. [OBS's display capture implementation](https://github.com/obsproject/obs-studio/blob/master/plugins/win-capture/duplicator-monitor-capture.c) +also retains distinct DXGI/WGC paths and selects WGC for some multi-adapter +laptops; a mature implementation does not establish universal DXGI availability. +OBS is a design reference, not source to copy into Piik's MIT implementation. + +The pinned WebRTC SDK already contains `ScreenCapturerWinDirectx` and +`DesktopAndCursorComposer`. A bounded standalone probe on Windows 11 build 26200 +on 2026-09-16 linked that existing SDK and returned six 2560x1440 frames with +cursor composition, then exited. These frames expose CPU data and no GPU +texture. Piik's current capture/encoder boundary consumes D3D11 textures, so +this reuse would add a readback/upload path; the probe establishes neither +encoding throughput nor Windows 10 support. SDK build defines must match: +`RTC_ENABLE_WIN_WGC` changes `DesktopCaptureOptions` layout even when selecting +DXGI; omitting it caused heap corruption in the isolated probe. No product +capture path was changed by this experiment. + +If DXGI is reconsidered, compare this reuse against the current GPU path +under game motion and quiet scenes on Windows 10. Cover selected-output/GPU +identity, cursor and rotation, source previews, display/fullscreen changes, +and bounded cancellation. [DXGI requires the output's own adapter](https://learn.microsoft.com/en-us/windows/win32/api/dxgi1_2/nf-dxgi1_2-idxgioutput1-duplicateoutput); +[access loss requires recreating the duplication object](https://learn.microsoft.com/en-us/windows/win32/api/dxgi1_2/nf-dxgi1_2-idxgioutputduplication-acquirenextframe). +Preview capture must follow the same backend decision or it can still produce +a WGC border. Retain window scope and the existing encoder/output/stop owners. + ## Acceptance - Static Windows source remains a live share for a bounded physical run, and a diff --git a/docs/standards/media-quality.md b/docs/standards/media-quality.md index 51ad88b..0bbd9e3 100644 --- a/docs/standards/media-quality.md +++ b/docs/standards/media-quality.md @@ -23,6 +23,13 @@ production behavior and remaining acceptance. frame matches its desktop source dimensions. Other frames retain their own aspect ratio. This changes only the encoded presentation, never the game or display settings; vendor-private scaling is not inferred. +- Where Windows supports border control, the native source picker offers + **Show capture border**, off by default. The choice stays in the Host page and + follows native source and quality changes. Source previews request borderless + capture independently. Windows owns consent and final border visibility; + unsupported, pending, denied or failed access must leave ordinary capture and + stop controls usable. Other capture sessions can keep the border visible. + Browser indicators remain browser-owned. - Share and source-switch requests ask the Browser for available audio by default. Missing audio is reported clearly but does not block video-only sharing. Native screen capture can include system playback audio and native diff --git a/docs/standards/presentation-lifecycle.md b/docs/standards/presentation-lifecycle.md index 9999607..c13201b 100644 --- a/docs/standards/presentation-lifecycle.md +++ b/docs/standards/presentation-lifecycle.md @@ -63,6 +63,15 @@ the mismatch; the source selector retains Browser operation and offers update/ refresh guidance. Discovery metadata may grow, while known identity and capability types remain validated. Missing optional capabilities are unavailable; control commands and responses retain their strict protocol contract. +An empty source list means enumeration succeeded for the selected category. +Connection, capture-capability and enumeration failures keep their own feedback. +Discovery has one bounded deadline that allows time for browser local-network +consent; a failed fetch alone cannot establish that consent was denied. +Optional Viewer reception uses Browser media immediately when the browser +reports that local App access still needs consent or is denied. It rechecks +permission on a later connection attempt; a later grant does not replace healthy +Browser media. App Local needs no cross-address-space consent. Browsers without +a permission query retain bounded discovery. ## Visual Language diff --git a/docs/standards/visual-language.md b/docs/standards/visual-language.md index 8430f4e..b9228eb 100644 --- a/docs/standards/visual-language.md +++ b/docs/standards/visual-language.md @@ -41,9 +41,11 @@ model. Universal digits, transport symbols, URLs and measured values remain literal when the user needs the data. Functional controls use native buttons/inputs and localized accessible names. -Pointer cursors identify clickable controls and links; text inputs use text -cursors. A passive label, illustration or hover-only explanation keeps its -ordinary cursor. A help button that actually toggles guidance is interactive. +Pointer cursors identify actions, navigation and controls that change a setting; +text inputs use text cursors. Read-only status and metric indicators keep the +ordinary arrow even when tapping reveals an explanation. Keep these help-only +triggers as native buttons for keyboard and touch access. Passive labels and +illustrations also keep their ordinary cursor. Hover, keyboard focus and touch receive equivalent guidance. Keep information order and interaction ownership consistent across languages, themes and viewport sizes. Reflow or bounded scrolling must not hide a primary action, truncate an @@ -61,9 +63,23 @@ Use a globe and chevron for the unselected picker, and each language's own full name in the menu, following [W3C language-navigation guidance](https://www.w3.org/International/questions/qa-navigation-select). Help-only indicators toggle their guidance on click or tap and dismiss on a -second activation, outside tap, Escape or focus leaving. Action controls keep -their direct click action and offer touch guidance on long-press. `Tooltip` -owns both interactions; pages do not add their own open state or timers. +second activation, outside tap, Escape or focus leaving. Touch long-press opens +guidance after a 500 ms hold on both help indicators and action controls, +without triggering the action on release. Action controls keep their direct +short-tap action. `Tooltip` owns these interactions; pages do not add their own +open state or timers. + +Mouse hover opens after 500 ms of staying on the trigger; passing through or +operating a control cancels that pending hint. Keyboard focus and explicit help +activation do not wait. Keep the open panel hoverable for reading, with a short +crossing grace; Escape, clicking the panel or clicking elsewhere dismisses it. +A panel click only dismisses guidance and never activates an underlying control. +Keep the comic compact without reducing caption legibility. Player hints prefer +available space beside or below the television; when that does not fit, clear +the whole playback bar. A wrapped option row is also one area to avoid. Status, +headers and bottom-of-picker controls prefer below; other controls prefer above. +Flip to fit the viewport and keep the caret aimed at the trigger. Placement and +hover timing belong to the shared component. Add a tooltip when it explains an action, a limitation or a status that needs context. Plain names, decorative participants and self-explanatory text do not @@ -469,6 +485,9 @@ provides the native timeline alignment used by participant gestures. [Carbon tooltip guidance](https://carbondesignsystem.com/components/tooltip/usage/) supports concise, contextual help where it adds information; required instructions stay visible beside the action. +[W3C hover/focus guidance](https://www.w3.org/WAI/WCAG22/Understanding/content-on-hover-or-focus.html) +supports panels remaining reachable for reading and being dismissible without +moving the pointer away. [Carbon loading guidance](https://carbondesignsystem.com/components/loading/usage/) supports avoiding competing indicators and keeping required user actions distinct diff --git a/docs/status.md b/docs/status.md index 00f6221..26d7ef4 100644 --- a/docs/status.md +++ b/docs/status.md @@ -1,6 +1,6 @@ # Current Status -Last updated: 2026-09-16 +Last updated: 2026-09-17 This is the compact execution/deployment index. Product modules own behavior, [verification status](./verification-status.md) owns unresolved physical limits, @@ -48,7 +48,7 @@ HTTPS, runtime assets, all three STUN listeners and a bounded browser P2P sharing/stop check passed; the browser check used synthetic video on one network. The operator record owns exact identity and postflight evidence. -[TODO](./todo.md) owns the current maintenance phase. The owner declared the first +[TODO](./todo.md) owns remaining verification and follow-up work. The owner declared the first public release on 2026-09-12. Source is public, main protection is active, and `piik.tv` runs on GitHub Pages behind Cloudflare with HTTPS. The private service remains a separate deployment. Release descriptors and GitHub deployment records diff --git a/docs/todo.md b/docs/todo.md index 7fdfdbc..2816fee 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,40 +1,30 @@ # Current TODO Ledger -Last reviewed: 2026-09-16 +Last reviewed: 2026-09-17 Only **Now** is executable. Product modules own behavior; Git/PRs own completed history. A parked idea is not implementation authority. ## Now -- [ ] **Windows capture border preference.** After the current release, investigate - [#401](https://github.com/TNTcraftHIM/Piik/issues/401) with the packaged Native - helper. Verify Windows' permission-based `GraphicsCaptureAccess` / - `IsBorderRequired` flow and the available or denied cases before choosing a - setting. Keep ordinary capture working; Browser capture indicators remain - browser-owned. -- [ ] **App/Browser source discovery and feedback.** Investigate the report of - missing window sources; confirm the exact message, screen, OS/browser and App - version. The custom selector currently renders both App unavailability and a - successfully loaded empty list as "No sources available". Distinguish these - outcomes using facts from the existing discovery/capture owners. Check prior - site activation versus a fresh Browser profile, configured-origin matching, - local-network permission timing (including the 400 ms discovery deadline), - protocol mismatch, occupied control sessions, helper/encoder capabilities and - source-list failures. Compare startup-only probing with list refresh and App - restart recovery; keep Browser capture usable and local media-bridge failures - separate. A generic fetch - failure cannot prove a permission denial. Confirm the reporting machine's - cause before claiming resolution. -- [ ] **Linux output failure boundary.** Run the wired `build.sh --check` on a - supported Linux runner, including encoder admission and primary error checks. - Then verify and isolate output-local encoder failures while retaining fatal - shared source/engine errors. Windows checks do not establish this boundary. -- [ ] **Post-launch monitoring.** Collect App/Server feedback and verify the - public downloads, container pulls and deployed services after product releases. - Follow the [deployment runbook](./deployment.md) for the private service and keep public - demo deployment separate. Preserve the owner's - [device/network deferrals](./verification-status.md#candidate-evidence-boundary). +No additional implementation is scheduled. Routine release and postflight +follow the [deployment runbook](./deployment.md); public demo and private service +deployments remain separate. Physical limits are owned by +[verification status](./verification-status.md). + +## Awaiting Device Or Reporter Evidence + +- [ ] **App source-discovery field acceptance.** Retest the missing-window report + with the current App and page. Obtain OS/browser, version and paired Debug + reports to distinguish discovery, capture capability and enumeration failures. + Local permission and recovery checks do not establish the reporting machine's + cause. +- [ ] **Windows 32-bit candidate acceptance.** Verify the isolated + `spike/windows-x86-capture` candidate's launch, capture/audio, memory pressure, + source replacement and update links on a 32-bit Windows device. WOW64 + Host/media checks establish only that environment. Reconcile its scoped + SDK/toolchain and atomic-alignment changes with current main when accepted; + the experiment is not part of this maintenance release. - [ ] **Public invitation startup timeout.** The reporter in [#396](https://github.com/TNTcraftHIM/Piik/issues/396#issuecomment-5691465700) confirmed Local startup is fixed, but separately reported that the public @@ -48,9 +38,6 @@ history. A parked idea is not implementation authority. URL-handler crash itself still needs the affected build and process/dump evidence; local checks cannot establish its underlying cause. -Keep fixes on a maintenance branch until acceptance. The public release is the -compatibility baseline; private service deployment stays independent. - ## Next: P2P Connection And Feedback Evidence After the current phase, measure connection success, time to first picture and @@ -114,16 +101,11 @@ not establish better connection success or speed; this note adds no retry policy documentation or App console translations as contributed; verify text direction and layout when a language requires it. Check contributed language names and rendered menu navigation when registering a new catalog. -9. **32-bit App packages.** Deferred until suitable native capture dependencies - are available. Windows x86 core compilation alone does not establish App - support: the pinned capture SDK currently has no Windows x86 package. - Complete native packaging and real launch/capture acceptance before - advertising a 32-bit target. -10. **Windows code signing.** Revisit after enrollment in a trusted signing +9. **Windows code signing.** Revisit after enrollment in a trusted signing service. Sign Piik's executables before archive checksums are computed; signing improves publisher identity but does not guarantee that antivirus cloud scanning stops. Service selection and enrollment remain pending. -11. **Gitee download-source warning.** Paused by the owner. Keep GitHub primary +10. **Gitee download-source warning.** Paused by the owner. Keep GitHub primary and retain Gitee; do not add a self-hosted mirror. Chrome still blocks the Gitee attachment when Referer is removed. Reopen for new evidence or a provider review; the warning remains unresolved. diff --git a/docs/verification-status.md b/docs/verification-status.md index 962b430..470100a 100644 --- a/docs/verification-status.md +++ b/docs/verification-status.md @@ -1,6 +1,6 @@ # Verification Status -Last updated: 2026-09-13 +Last updated: 2026-09-17 This file owns physical evidence limits that change how the product may be described. [Status](./status.md) owns the execution/deployment index. @@ -32,6 +32,11 @@ The owner confirmed Windows 10 whole-display capture resolved. Chromium WebRTC/IP-handling policy explains the verified local-media failure; it is a Browser policy limitation, not a Vivaldi-specific transport workaround. +The Windows capture-border control and borderless previews have physical +Windows 11 evidence. Unsupported Windows and actual border-permission denial +remain device checks. The optional Windows 10 Windhawk procedure is documented +from its author and has not been tested with Piik on Windows 10. + NAT traversal and Auto codec selection are implemented features, not unfinished functionality. On 2026-09-09 the owner confirmed that the new NAT traversal is useful in actual use. That field confirmation is accepted; it does not claim a diff --git a/internal/app/app.go b/internal/app/app.go index f2bffa0..bd16239 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -620,11 +620,12 @@ func discoverNativeMedia(ctx context.Context, configuredPath string) nativeRunti captureProcess: path, capture: capabilities, capabilities: loopback.NativeMediaCapabilities{ - Video: summary.Video, - ProcessAudio: summary.ProcessAudio, - SystemAudio: summary.SystemAudio, - HardwareH264: summary.HardwareH264, - SoftwareVP8: summary.SoftwareVP8, + Video: summary.Video, + CaptureBorderControl: summary.CaptureBorderControl, + ProcessAudio: summary.ProcessAudio, + SystemAudio: summary.SystemAudio, + HardwareH264: summary.HardwareH264, + SoftwareVP8: summary.SoftwareVP8, }, } } diff --git a/internal/app/loopback/server.go b/internal/app/loopback/server.go index a49b66b..0e42bd2 100644 --- a/internal/app/loopback/server.go +++ b/internal/app/loopback/server.go @@ -62,11 +62,12 @@ type Health struct { } type NativeMediaCapabilities struct { - Video bool `json:"video"` - ProcessAudio bool `json:"processAudio"` - SystemAudio bool `json:"systemAudio"` - HardwareH264 bool `json:"hardwareH264"` - SoftwareVP8 bool `json:"softwareVP8"` + Video bool `json:"video"` + CaptureBorderControl bool `json:"captureBorderControl,omitempty"` + ProcessAudio bool `json:"processAudio"` + SystemAudio bool `json:"systemAudio"` + HardwareH264 bool `json:"hardwareH264"` + SoftwareVP8 bool `json:"softwareVP8"` } type Server struct { diff --git a/internal/app/nativecapture/probe.go b/internal/app/nativecapture/probe.go index 39ae6b4..d6bceb8 100644 --- a/internal/app/nativecapture/probe.go +++ b/internal/app/nativecapture/probe.go @@ -42,30 +42,33 @@ type Adapter struct { } type Capabilities struct { - Protocol int `json:"protocol"` - Platform string `json:"platform"` - PlatformBuild string `json:"platformBuild"` - VideoCapture bool `json:"videoCapture"` - ProcessAudio bool `json:"processAudio"` - SystemAudio bool `json:"systemAudio"` - SoftwareVP8 bool `json:"softwareVP8"` - Adapters []Adapter `json:"adapters"` + Protocol int `json:"protocol"` + Platform string `json:"platform"` + PlatformBuild string `json:"platformBuild"` + VideoCapture bool `json:"videoCapture"` + CaptureBorderControl bool `json:"captureBorderControl,omitempty"` + ProcessAudio bool `json:"processAudio"` + SystemAudio bool `json:"systemAudio"` + SoftwareVP8 bool `json:"softwareVP8"` + Adapters []Adapter `json:"adapters"` } type Summary struct { - Video bool - ProcessAudio bool - SystemAudio bool - HardwareH264 bool - SoftwareVP8 bool + Video bool + CaptureBorderControl bool + ProcessAudio bool + SystemAudio bool + HardwareH264 bool + SoftwareVP8 bool } func (capabilities Capabilities) Summary() Summary { summary := Summary{ - Video: capabilities.VideoCapture, - ProcessAudio: capabilities.ProcessAudio, - SystemAudio: capabilities.SystemAudio, - SoftwareVP8: capabilities.SoftwareVP8, + Video: capabilities.VideoCapture, + CaptureBorderControl: capabilities.VideoCapture && capabilities.CaptureBorderControl, + ProcessAudio: capabilities.ProcessAudio, + SystemAudio: capabilities.SystemAudio, + SoftwareVP8: capabilities.SoftwareVP8, } for _, adapter := range capabilities.Adapters { if len(adapter.HardwareH264) > 0 { diff --git a/internal/app/nativecapture/probe_test.go b/internal/app/nativecapture/probe_test.go index cf5aadd..6f2d1ef 100644 --- a/internal/app/nativecapture/probe_test.go +++ b/internal/app/nativecapture/probe_test.go @@ -42,7 +42,24 @@ func TestSummaryDoesNotConflateVideoAudioAndEncoder(t *testing.T) { } summary := capabilities.Summary() if !summary.Video || summary.ProcessAudio || !summary.SystemAudio || - summary.HardwareH264 { + summary.HardwareH264 || summary.CaptureBorderControl { t.Fatalf("summary = %+v", summary) } } + +func TestCaptureBorderCapabilityRequiresCaptureAndExplicitSupport(t *testing.T) { + for _, supported := range []bool{false, true} { + payload := validProbe + if supported { + payload = strings.Replace(payload, `"videoCapture":true`, `"videoCapture":true,"captureBorderControl":true`, 1) + } + capabilities, err := decodeProbe([]byte(payload)) + if err != nil || capabilities.Summary().CaptureBorderControl != supported { + t.Fatalf("capture border support = %v, error = %v", capabilities.Summary(), err) + } + capabilities.VideoCapture = false + if capabilities.Summary().CaptureBorderControl { + t.Fatal("border control was advertised without video capture") + } + } +} diff --git a/internal/app/nativecapture/stream.go b/internal/app/nativecapture/stream.go index bf62378..e8e5611 100644 --- a/internal/app/nativecapture/stream.go +++ b/internal/app/nativecapture/stream.go @@ -45,13 +45,14 @@ type CaptureTarget struct { } type VideoOptions struct { - Target CaptureTarget - Codec string - AdapterIndex uint32 - EncoderIndex uint32 - Profile VideoProfile - RestoreToken string - OutputGroups int + Target CaptureTarget + Codec string + AdapterIndex uint32 + EncoderIndex uint32 + Profile VideoProfile + RestoreToken string + OutputGroups int + ShowCaptureBorder bool } type EncodedVideoOptions struct { @@ -232,6 +233,9 @@ func StartVideo(parent context.Context, executable string, options VideoOptions) if err != nil { return nil, err } + if options.ShowCaptureBorder { + arguments = append(arguments, "--show-capture-border") + } stream, err := startStreamWithEnvironment(parent, executable, arguments, environment) if err != nil { return nil, err diff --git a/internal/app/nativecontrol/session.go b/internal/app/nativecontrol/session.go index 1727ae1..db3f462 100644 --- a/internal/app/nativecontrol/session.go +++ b/internal/app/nativecontrol/session.go @@ -214,9 +214,10 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any, "share", diagnostics.ID(request.ShareID), "sourceKind", request.Source.Kind, "audio", audio, "adapterIndex", request.AdapterIndex, "encoderIndex", request.EncoderIndex) err := host.ReplaceSource(ctx, nativecapture.VideoOptions{ - Target: request.Source, - AdapterIndex: request.AdapterIndex, - EncoderIndex: request.EncoderIndex, + Target: request.Source, + ShowCaptureBorder: request.ShowCaptureBorder && session.capabilities.CaptureBorderControl, + AdapterIndex: request.AdapterIndex, + EncoderIndex: request.EncoderIndex, }, audio) if err != nil { return operationFailure(envelope, err), nil @@ -468,11 +469,12 @@ func (session *Session) startShare( ShareID: request.ShareID, CaptureProcess: session.captureProcess, Video: nativecapture.VideoOptions{ - Target: request.Source, - Codec: request.Codec, - AdapterIndex: request.AdapterIndex, - EncoderIndex: request.EncoderIndex, - Profile: profile.Video, + Target: request.Source, + ShowCaptureBorder: request.ShowCaptureBorder && session.capabilities.CaptureBorderControl, + Codec: request.Codec, + AdapterIndex: request.AdapterIndex, + EncoderIndex: request.EncoderIndex, + Profile: profile.Video, }, Profile: profile, EdgeCapacity: request.EdgeCapacity, diff --git a/internal/app/nativecontrol/session_test.go b/internal/app/nativecontrol/session_test.go index b2410c2..849ecc9 100644 --- a/internal/app/nativecontrol/session_test.go +++ b/internal/app/nativecontrol/session_test.go @@ -9,6 +9,7 @@ import ( "log/slog" "os" "path/filepath" + "slices" "strconv" "strings" "testing" @@ -35,6 +36,10 @@ func TestMain(tests *testing.M) { } func runQuietCaptureFixture(directory string) { + arguments, _ := json.Marshal(os.Args[1:]) + if err := os.WriteFile(filepath.Join(directory, "arguments.json"), arguments, 0600); err != nil { + os.Exit(1) + } var state nativehost.CaptureState state.State, state.Codec = "starting", "vp8" var adapter uint32 @@ -100,6 +105,69 @@ func runQuietCaptureFixture(directory string) { <-closed } +func TestCaptureBorderPreferenceFollowsSourceAndProfile(t *testing.T) { + for _, supported := range []bool{false, true} { + t.Run(strconv.FormatBool(supported), func(t *testing.T) { + directory := t.TempDir() + t.Setenv("PIIK_QUIET_CAPTURE_FIXTURE", directory) + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + session := New(executable, nativecapture.Capabilities{VideoCapture: true, SoftwareVP8: true, CaptureBorderControl: supported}, false) + t.Cleanup(func() { _ = session.Close() }) + handle := func(request any) { + t.Helper() + payload, err := json.Marshal(request) + if err != nil { + t.Fatal(err) + } + value, err := session.Handle(t.Context(), payload) + if _, failed := value.(requestFailedResponse); err != nil || failed { + t.Fatalf("capture request failed: %v, %#v", err, value) + } + } + assertBorder := func(expected bool) { + t.Helper() + data, err := os.ReadFile(filepath.Join(directory, "arguments.json")) + var arguments []string + if err != nil || json.Unmarshal(data, &arguments) != nil { + t.Fatalf("capture arguments missing: %v", err) + } + if slices.Contains(arguments, "--show-capture-border") != expected { + t.Fatalf("capture border intent lost: %v", arguments) + } + } + start := startShareRequest{Version: 9, ID: "request_start", Type: "start-share", ShareID: "share_123456", + Source: nativecapture.CaptureTarget{Kind: "display", SourceID: "1", Title: "Fixture"}, + ShowCaptureBorder: true, EdgeCapacity: 1, Codec: "vp8", + Profile: qualitySettings{Resolution: "1080p", MaxFramerate: 30, MaxBitrate: 5_000_000, DegradationPreference: "balanced"}} + handle(start) + assertBorder(supported) + profile := start.Profile + profile.Resolution = "480p" + handle(updateShareRequest{Version: 9, ID: "request_update", Type: "update-share", ShareID: start.ShareID, Profile: profile}) + session.mu.Lock() + done := session.updateDone + session.mu.Unlock() + if done != nil { + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("profile update did not settle") + } + } + assertBorder(supported) + for _, show := range []bool{false, true} { + handle(replaceShareSourceRequest{Version: 9, ID: "request_replace", Type: "replace-share-source", ShareID: start.ShareID, + Source: nativecapture.CaptureTarget{Kind: "display", SourceID: "2", Title: "Other fixture"}, ShowCaptureBorder: show}) + assertBorder(show && supported) + } + handle(stopShareRequest{Version: 9, ID: "request_stop", Type: "stop-share", ShareID: start.ShareID}) + }) + } +} + func TestQuietHostUpdatePreservesControlAndCancelsCleanly(t *testing.T) { for _, outcome := range []string{"complete", "stop", "disconnect", "first-input-timeout", "backpressure-stop"} { t.Run(outcome, func(t *testing.T) { diff --git a/internal/app/nativecontrol/wire.go b/internal/app/nativecontrol/wire.go index ec9f362..5974863 100644 --- a/internal/app/nativecontrol/wire.go +++ b/internal/app/nativecontrol/wire.go @@ -42,17 +42,18 @@ type qualitySettings struct { } type startShareRequest struct { - Version int `json:"version"` - ID string `json:"id"` - Type string `json:"type"` - ShareID string `json:"shareId"` - Source nativecapture.CaptureTarget `json:"source"` - Audio bool `json:"audio"` - AdapterIndex uint32 `json:"adapterIndex"` - EncoderIndex uint32 `json:"encoderIndex"` - EdgeCapacity int `json:"edgeCapacity"` - Profile qualitySettings `json:"profile"` - Codec string `json:"codec"` + Version int `json:"version"` + ID string `json:"id"` + Type string `json:"type"` + ShareID string `json:"shareId"` + Source nativecapture.CaptureTarget `json:"source"` + Audio bool `json:"audio"` + ShowCaptureBorder bool `json:"showCaptureBorder,omitempty"` + AdapterIndex uint32 `json:"adapterIndex"` + EncoderIndex uint32 `json:"encoderIndex"` + EdgeCapacity int `json:"edgeCapacity"` + Profile qualitySettings `json:"profile"` + Codec string `json:"codec"` } type updateShareRequest struct { @@ -64,14 +65,15 @@ type updateShareRequest struct { } type replaceShareSourceRequest struct { - Version int `json:"version"` - ID string `json:"id"` - Type string `json:"type"` - ShareID string `json:"shareId"` - Source nativecapture.CaptureTarget `json:"source"` - Audio bool `json:"audio"` - AdapterIndex uint32 `json:"adapterIndex"` - EncoderIndex uint32 `json:"encoderIndex"` + Version int `json:"version"` + ID string `json:"id"` + Type string `json:"type"` + ShareID string `json:"shareId"` + Source nativecapture.CaptureTarget `json:"source"` + Audio bool `json:"audio"` + ShowCaptureBorder bool `json:"showCaptureBorder,omitempty"` + AdapterIndex uint32 `json:"adapterIndex"` + EncoderIndex uint32 `json:"encoderIndex"` } type stopShareRequest struct { diff --git a/native/capture/linux/README.md b/native/capture/linux/README.md index d0e19a2..79e648a 100644 --- a/native/capture/linux/README.md +++ b/native/capture/linux/README.md @@ -26,13 +26,12 @@ native build and regression alone, add `--check` to the command above. The test uses GStreamer's core synthetic elements and needs no display, Portal session, capture device or hardware encoder; physical capture remains a separate check. -Output retirement already closes a branch's valve before stopping its elements. -Encoder failures currently remain fatal to the capture run, including failures -reported for one output. Isolating those failures also requires containing -GStreamer queue/tee flow errors before they reach sibling branches; changing only -the asynchronous bus handler is insufficient. Keep state retirement off streaming -callbacks and verify sibling output, source failure and shutdown on Linux before -changing this boundary. [TODO](../../../docs/todo.md) tracks that remaining work. +Each output bin contains its downstream flow failures before they reach the +shared tee. The main-context bus handler reports that output as unavailable and +closes its valve before stopping the branch. Failed outputs remain retired for +the source generation; healthy siblings continue. Shared source, decoder and +protocol-output failures still end the capture run. Streaming callbacks report +errors through the bus and never stop their own branch. Runtime requirements are the desktop Portal/PipeWire services, a GStreamer PipeWire source, and an installed GStreamer hardware H.264 encoder. If any part @@ -48,3 +47,5 @@ Primary references: - - - +- +- diff --git a/native/capture/linux/main.c b/native/capture/linux/main.c index 0a9a61d..464b8f5 100644 --- a/native/capture/linux/main.c +++ b/native/capture/linux/main.c @@ -51,6 +51,7 @@ typedef struct { VideoProfile profile; guint layer; gboolean enabled; + gboolean failed; gboolean decodable; guint64 key_requested; guint64 key_sent; @@ -610,13 +611,51 @@ static void fail_run(CaptureRun *run, const char *message) { } static void fail_output(VideoOutput *output, const char *message) { - write_frame(6, 0, (guint8)output->layer, 0, 0, 0, 0, - (const guint8 *)message, MIN(strlen(message), 512)); - fail_run(output->run, message); + // Streaming callbacks report to the bus; only its main-context owner retires + // branches. Stopping an encoder from its own streaming thread can deadlock. + GError *error = g_error_new_literal(GST_STREAM_ERROR, GST_STREAM_ERROR_ENCODE, message); + gst_element_post_message(output->encoder, + gst_message_new_error(GST_OBJECT(output->encoder), error, NULL)); + g_error_free(error); +} + +static GstFlowReturn output_chain(GstPad *pad, GstObject *parent, GstBuffer *buffer) { + VideoOutput *output = g_object_get_data(G_OBJECT(pad), "piik-output"); + GstFlowReturn flow = gst_proxy_pad_chain_default(pad, parent, buffer); + if (flow >= GST_FLOW_OK) return flow; + // Bus errors arrive asynchronously. Contain the queue's failed flow here, + // before it reaches the shared tee, even when no encoder error was posted. + gboolean dropping = FALSE; + g_object_get(output->gate, "drop", &dropping, NULL); + g_object_set(output->gate, "drop", TRUE, NULL); + if (!dropping && !g_atomic_int_get(&output->run->stopping)) { + char *message = g_strdup_printf("GStreamer output stopped: %s", gst_flow_get_name(flow)); + fail_output(output, message); + g_free(message); + } + return GST_FLOW_OK; +} + +static gboolean isolate_output(VideoOutput *output) { + GstPad *source = gst_element_get_static_pad(output->gate, "src"); + GstPad *input = source == NULL ? NULL : gst_pad_get_peer(source); + if (source != NULL) gst_object_unref(source); + if (input == NULL) return FALSE; + if (!GST_IS_GHOST_PAD(input)) { + gst_object_unref(input); + return FALSE; + } + g_object_set_data(G_OBJECT(input), "piik-output", output); + gst_pad_set_chain_function(input, output_chain); + gst_object_unref(input); + g_object_set(output->gate, "drop", TRUE, NULL); + gst_element_set_locked_state(output->branch, TRUE); + return TRUE; } static gboolean set_output_active(VideoOutput *output, gboolean enabled) { CaptureRun *run = output->run; + if (enabled && output->failed) return TRUE; g_mutex_lock(&run->lock); if (output->enabled == enabled) { g_mutex_unlock(&run->lock); @@ -634,7 +673,12 @@ static gboolean set_output_active(VideoOutput *output, gboolean enabled) { gboolean applied = enabled ? gst_element_sync_state_with_parent(output->branch) : gst_element_set_state(output->branch, GST_STATE_NULL) != GST_STATE_CHANGE_FAILURE; - if (!applied) return FALSE; + if (!applied) { + gst_element_set_locked_state(output->branch, TRUE); + gst_element_set_state(output->branch, GST_STATE_NULL); + fail_output(output, "GStreamer output could not change state"); + return TRUE; + } g_mutex_lock(&run->lock); output->enabled = enabled; g_mutex_unlock(&run->lock); @@ -680,6 +724,7 @@ static gboolean apply_control(CaptureRun *run, char *line) { VideoOutput *output = &run->outputs[layer]; guint bitrate = 0; if (!parse_uint(values[2], 1000, output->profile.bit_rate, &bitrate)) return FALSE; + if (output->failed) return TRUE; GParamSpec *property = g_object_class_find_property( G_OBJECT_GET_CLASS(output->encoder), "bitrate"); if (property == NULL || !(property->flags & GST_PARAM_MUTABLE_PLAYING) || @@ -838,20 +883,33 @@ static gboolean bus_message(GstBus *bus, GstMessage *message, gpointer data) { GError *error = NULL; char *debug = NULL; gst_message_parse_error(message, &error, &debug); - // Keep the bus cause before an output summary reaches the first-failure latch. - fail_run(run, error == NULL ? "GStreamer pipeline failed" : error->message); + const char *cause = error == NULL ? "GStreamer pipeline failed" : error->message; if (error != NULL) { fprintf(stderr, "Piik GStreamer error: source=%s domain=%s code=%d detail=%s\n", GST_MESSAGE_SRC(message) == NULL ? "unknown" : GST_OBJECT_NAME(GST_MESSAGE_SRC(message)), g_quark_to_string(error->domain), error->code, error->message); if (debug != NULL) fprintf(stderr, "Piik GStreamer context: %s\n", debug); } + VideoOutput *failed = NULL; for (guint index = 0; index < run->output_count; ++index) { - if (GST_MESSAGE_SRC(message) == GST_OBJECT(run->outputs[index].encoder)) { - fail_output(&run->outputs[index], "hardware encoder output failed"); + VideoOutput *output = &run->outputs[index]; + if (output->branch != NULL && GST_MESSAGE_SRC(message) != NULL && + (GST_MESSAGE_SRC(message) == GST_OBJECT(output->branch) || + gst_object_has_as_ancestor(GST_MESSAGE_SRC(message), GST_OBJECT(output->branch)))) { + failed = output; break; } } + if (failed == NULL) { + fail_run(run, cause); + } else if (!failed->failed) { + failed->failed = TRUE; + set_output_active(failed, FALSE); + if (!write_frame(6, 0, (guint8)failed->layer, 0, 0, 0, 0, + (const guint8 *)cause, MIN(strlen(cause), 512))) { + fail_run(run, "native media output closed"); + } + } g_clear_error(&error); g_free(debug); } else if (GST_MESSAGE_TYPE(message) == GST_MESSAGE_EOS && @@ -998,9 +1056,18 @@ static int run_pipeline(CaptureRun *run, const char *starting_status) { fail_run(run, "GStreamer pipeline failed to start"); goto stopped; } + for (guint index = 0; index < run->output_count; ++index) { + VideoOutput *output = &run->outputs[index]; + gboolean enabled = output->enabled; + output->enabled = FALSE; + if (enabled) set_output_active(output, TRUE); + } g_main_loop_run(run->loop); stopped: g_source_remove(control_watch); + for (guint index = 0; index < run->output_count; ++index) { + set_output_active(&run->outputs[index], FALSE); + } gst_element_set_state(run->pipeline, GST_STATE_NULL); g_source_remove(bus_watch); g_byte_array_unref(run->input_payload); @@ -1118,7 +1185,7 @@ static int capture_video(int count, char **values) { output->gate = gst_bin_get_by_name(GST_BIN(pipeline), name); g_free(name); if (output->branch == NULL || output->branch == pipeline || output->gate == NULL) goto cleanup; - gst_element_set_locked_state(output->branch, !output->enabled); + if (!isolate_output(output)) goto cleanup; name = g_strdup_printf("output%u", index); GstElement *sink = gst_bin_get_by_name(GST_BIN(pipeline), name); g_free(name); diff --git a/native/capture/linux/main.test.c b/native/capture/linux/main.test.c index 32685e7..3d0de5d 100644 --- a/native/capture/linux/main.test.c +++ b/native/capture/linux/main.test.c @@ -200,11 +200,148 @@ static void check_slot_retirement(void) { g_mutex_clear(&run.lock); } +typedef struct { + CaptureRun run; + gint frames; + gint frames_at_failure; + guint checks; +} FailureFixture; + +static void count_healthy_frame(GstElement *sink, GstBuffer *buffer, + GstPad *pad, gpointer data) { + (void)sink; + (void)buffer; + (void)pad; + g_atomic_int_inc(&((FailureFixture *)data)->frames); +} + +static GstFlowReturn fail_without_bus(GstPad *pad, GstObject *parent, GstBuffer *buffer) { + (void)pad; + (void)parent; + gst_buffer_unref(buffer); + return GST_FLOW_ERROR; +} + +static gboolean verify_sibling_survives(gpointer data) { + FailureFixture *fixture = data; + CaptureRun *run = &fixture->run; + g_assert_cmpuint(++fixture->checks, <, 200); + g_assert_null(run->failure); + if (!run->outputs[0].failed) return G_SOURCE_CONTINUE; + g_assert_false(run->outputs[0].enabled); + g_assert_cmpint(branch_state(&run->outputs[0]), ==, GST_STATE_NULL); + gint frames = g_atomic_int_get(&fixture->frames); + if (fixture->frames_at_failure < 0) fixture->frames_at_failure = frames; + if (frames - fixture->frames_at_failure < 10) return G_SOURCE_CONTINUE; + // A failed slot cannot be revived by a later demand or bitrate update. + g_assert_true(apply_control(run, "A 0 1")); + g_assert_true(apply_control(run, "B 0 1000000")); + g_assert_false(run->outputs[0].enabled); + g_assert_cmpint(branch_state(&run->outputs[1]), ==, GST_STATE_PLAYING); + GstElement *source = gst_bin_get_by_name(GST_BIN(run->pipeline), "source"); + GError *error = g_error_new_literal(GST_RESOURCE_ERROR, GST_RESOURCE_ERROR_READ, + "shared source fixture failure"); + gst_element_post_message(source, gst_message_new_error(GST_OBJECT(source), error, NULL)); + g_error_free(error); + gst_object_unref(source); + return G_SOURCE_REMOVE; +} + +static void check_output_failure_isolation(gboolean post_error) { + FailureFixture fixture = {.run = {.output_count = 2}, .frames_at_failure = -1}; + CaptureRun *run = &fixture.run; + g_mutex_init(&run->lock); + run->loop = g_main_loop_new(NULL, FALSE); + char *description = g_strdup_printf( + "fakesrc name=source is-live=true sizetype=fixed sizemax=4 ! " + "identity sleep-time=1000 ! tee name=frames " + "frames. ! valve name=gate0 drop=true ! ( queue max-size-buffers=1 " + "max-size-bytes=0 max-size-time=0 leaky=downstream ! " + "identity name=encoder0 error-after=%d ! fakesink name=bad sync=false async=false ) " + "frames. ! valve name=gate1 drop=true ! ( queue max-size-buffers=1 " + "max-size-bytes=0 max-size-time=0 leaky=downstream ! " + "identity name=encoder1 ! fakesink name=good signal-handoffs=true sync=false async=false )", + post_error ? 3 : -1); + GError *error = NULL; + run->pipeline = gst_parse_launch(description, &error); + g_free(description); + g_assert_no_error(error); + for (guint index = 0; index < run->output_count; ++index) { + VideoOutput *output = &run->outputs[index]; + output->run = run; + output->layer = index; + output->profile.bit_rate = 2000000; + char *name = g_strdup_printf("encoder%u", index); + output->encoder = gst_bin_get_by_name(GST_BIN(run->pipeline), name); + g_free(name); + output->branch = GST_ELEMENT(gst_object_get_parent(GST_OBJECT(output->encoder))); + name = g_strdup_printf("gate%u", index); + output->gate = gst_bin_get_by_name(GST_BIN(run->pipeline), name); + g_free(name); + g_assert_true(isolate_output(output)); + } + GstElement *sink = gst_bin_get_by_name(GST_BIN(run->pipeline), post_error ? "good" : "bad"); + if (!post_error) { + GstPad *pad = gst_element_get_static_pad(sink, "sink"); + gst_pad_set_chain_function(pad, fail_without_bus); + gst_object_unref(pad); + gst_object_unref(sink); + sink = gst_bin_get_by_name(GST_BIN(run->pipeline), "good"); + } + g_signal_connect(sink, "handoff", G_CALLBACK(count_healthy_frame), &fixture); + gst_object_unref(sink); + GstBus *bus = gst_element_get_bus(run->pipeline); + guint watch = gst_bus_add_watch(bus, bus_message, run); + gst_object_unref(bus); + char *frame_path = NULL; + int frames = g_file_open_tmp("piik-output-test-XXXXXX", &frame_path, NULL); + int original_stdout = dup(STDOUT_FILENO); + g_assert_cmpint(frames, >=, 0); + g_assert_cmpint(original_stdout, >=, 0); + g_assert_cmpint(dup2(frames, STDOUT_FILENO), >=, 0); + g_assert_cmpint(gst_element_set_state(run->pipeline, GST_STATE_PLAYING), !=, GST_STATE_CHANGE_FAILURE); + g_assert_true(set_output_active(&run->outputs[0], TRUE)); + g_assert_true(set_output_active(&run->outputs[1], TRUE)); + g_timeout_add(10, verify_sibling_survives, &fixture); + g_main_loop_run(run->loop); + g_assert_cmpstr(run->failure, ==, "shared source fixture failure"); + for (guint index = 0; index < run->output_count; ++index) { + g_assert_true(set_output_active(&run->outputs[index], FALSE)); + } + gst_element_set_state(run->pipeline, GST_STATE_NULL); + g_source_remove(watch); + g_assert_cmpint(dup2(original_stdout, STDOUT_FILENO), >=, 0); + close(original_stdout); + close(frames); + char *bytes = NULL; + gsize size = 0; + g_assert_true(g_file_get_contents(frame_path, &bytes, &size, NULL)); + g_assert_cmpuint(size, >, 32); + g_assert_cmpint(bytes[5], ==, 6); + g_assert_cmpint(bytes[7], ==, 0); + g_assert_cmpuint(size, ==, 32 + read_be((guint8 *)bytes + 28, 4)); + if (post_error) g_assert_nonnull(strstr(bytes + 32, "Failed after iterations as requested")); + g_free(bytes); + unlink(frame_path); + g_free(frame_path); + for (guint index = 0; index < run->output_count; ++index) { + gst_object_unref(run->outputs[index].encoder); + gst_object_unref(run->outputs[index].branch); + gst_object_unref(run->outputs[index].gate); + } + gst_object_unref(run->pipeline); + g_free(run->failure); + g_main_loop_unref(run->loop); + g_mutex_clear(&run->lock); +} + int main(int argc, char **argv) { gst_init(&argc, &argv); check_encoder_admission_and_control(); check_bus_error_retains_primary_cause(); check_slot_profiles(); check_slot_retirement(); + check_output_failure_isolation(TRUE); + check_output_failure_isolation(FALSE); return 0; } diff --git a/native/capture/windows/README.md b/native/capture/windows/README.md index a6ca6de..59dc7e7 100644 --- a/native/capture/windows/README.md +++ b/native/capture/windows/README.md @@ -15,6 +15,17 @@ The video command receives the current product resolution, frame-rate, bitrate, and quality preference. A replacement process applies live changes while the Go session retains its Pion source and connections. +Sharing and source previews request borderless access by default when Windows +exposes `IsBorderRequired` and `GraphicsCaptureAccess` (build 20348+, including +Windows 11). The probe advertises API availability; the source-picker preference +passes `--show-capture-border` when the Host wants the sharing border visible. +One capture-local owner applies completed access without waiting +for consent, and cancels pending access on retirement. Unsupported APIs, denial +or failure retain ordinary capture; another capture session may still require a +border. The property value is a preference, not proof that the border is invisible. +Consumer Windows 10 through build 19045 lacks this API; the current WGC capture +path keeps its system border there. + Video and audio run as separate bounded child processes. A source whose audio loopback cannot be initialized keeps video available and reports audio unavailable instead of failing the whole source. Process loopback is probed by @@ -59,3 +70,5 @@ the package, not the SDK/toolchain or another runtime service. - - - +- +- diff --git a/native/capture/windows/capture_border.h b/native/capture/windows/capture_border.h new file mode 100644 index 0000000..433b30f --- /dev/null +++ b/native/capture/windows/capture_border.h @@ -0,0 +1,73 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +namespace piik::capture { + +// Sharing defaults and source previews request borderless capture. Windows owns +// consent and visibility; an optional access request must not delay frames/stop. +class CaptureBorder final { + public: + CaptureBorder() = default; + CaptureBorder(const CaptureBorder&) = delete; + CaptureBorder& operator=(const CaptureBorder&) = delete; + ~CaptureBorder() { Close(); } + + static bool Supported() noexcept { + using winrt::Windows::Foundation::Metadata::ApiInformation; + try { + return ApiInformation::IsPropertyPresent( + L"Windows.Graphics.Capture.GraphicsCaptureSession", L"IsBorderRequired") && + ApiInformation::IsMethodPresent( + L"Windows.Graphics.Capture.GraphicsCaptureAccess", L"RequestAccessAsync"); + } catch (const winrt::hresult_error&) { + return false; + } + } + + void Start(const winrt::Windows::Graphics::Capture::GraphicsCaptureSession& session) { + using namespace winrt::Windows::Graphics::Capture; + if (!Supported()) return; + try { + access_ = GraphicsCaptureAccess::RequestAccessAsync(GraphicsCaptureAccessKind::Borderless); + Apply(session); + } catch (const winrt::hresult_error& error) { + std::osyncstream(std::cerr) << "capture-border-access-failed: " << winrt::to_string(error.message()) << '\n'; + } + } + + void Apply(const winrt::Windows::Graphics::Capture::GraphicsCaptureSession& session) { + if (!access_) return; + try { + if (access_.Status() == winrt::Windows::Foundation::AsyncStatus::Started) return; + const auto access = access_.GetResults(); + if (access == AccessStatus::Allowed) session.IsBorderRequired(false); + std::osyncstream(std::cerr) << "capture-border-access=" + << (access == AccessStatus::Allowed ? "allowed" : "not-allowed") + << " status=" << static_cast(access) << '\n'; + } catch (const winrt::hresult_error& error) { + std::osyncstream(std::cerr) << "capture-border-access-failed: " << winrt::to_string(error.message()) << '\n'; + } + access_ = nullptr; + } + + void Close() noexcept { + try { + if (access_) access_.Cancel(); + } catch (...) { + } + access_ = nullptr; + } + + private: + using AccessStatus = winrt::Windows::Security::Authorization::AppCapabilityAccess::AppCapabilityAccessStatus; + winrt::Windows::Foundation::IAsyncOperation access_{nullptr}; +}; + +} // namespace piik::capture diff --git a/native/capture/windows/capture_target.cpp b/native/capture/windows/capture_target.cpp index c5a2a20..9fec32e 100644 --- a/native/capture/windows/capture_target.cpp +++ b/native/capture/windows/capture_target.cpp @@ -15,6 +15,7 @@ #include #include "capture_target.h" +#include "capture_border.h" #include "capture_geometry.h" #include @@ -480,6 +481,7 @@ HRESULT CaptureWithWgc(TargetKind kind, UINT64 source_id, GraphicsCaptureItem item{nullptr}; Direct3D11CaptureFramePool pool{nullptr}; GraphicsCaptureSession session{nullptr}; + CaptureBorder border; HANDLE frame_ready = CreateEventW(nullptr, FALSE, FALSE, nullptr); winrt::event_token frame_token{}; bool subscribed = false; @@ -502,8 +504,10 @@ HRESULT CaptureWithWgc(TargetKind kind, UINT64 source_id, if (frame_ready != nullptr) SetEvent(frame_ready); }); subscribed = true; + border.Start(session); session.StartCapture(); const DWORD wait = WaitForSingleObject(frame_ready, 1'500); + border.Apply(session); if (wait != WAIT_OBJECT_0) { result = wait == WAIT_TIMEOUT ? HRESULT_FROM_WIN32(ERROR_TIMEOUT) @@ -571,6 +575,7 @@ HRESULT CaptureWithWgc(TargetKind kind, UINT64 source_id, } catch (...) { } } + border.Close(); try { session.Close(); } catch (...) { diff --git a/native/capture/windows/main.cpp b/native/capture/windows/main.cpp index bc5f8b7..a37142c 100644 --- a/native/capture/windows/main.cpp +++ b/native/capture/windows/main.cpp @@ -30,6 +30,7 @@ #include #include "capture_target.h" +#include "capture_border.h" #include "capture_geometry.h" #include "process_audio.h" #include "h264_encoder.h" @@ -930,6 +931,7 @@ struct ProductArguments final { std::string codec = "auto"; VideoProfile profile; std::vector outputs; + bool show_capture_border = false; }; DegradationPreference ParseDegradationPreference(const wchar_t* value) { @@ -980,6 +982,11 @@ void ParseOutputProfiles(ProductArguments& arguments, int first, int count, wcha ProductArguments ParseProductArguments(int count, wchar_t** values) { ProductArguments arguments; + if (count > 2 && std::wstring(values[1]) == L"--capture-video" && + std::wstring(values[count - 1]) == L"--show-capture-border") { + arguments.show_capture_border = true; + --count; + } if (count == 2 && std::wstring(values[1]) == L"--list") return arguments; if (count == 2 && std::wstring(values[1]) == L"--probe") { arguments.mode = ProductArguments::Mode::probe; @@ -1150,6 +1157,8 @@ void WriteCapabilityProbe() { output << "{\"protocol\":7,\"platform\":\"windows\",\"platformBuild\":" << JSONString(std::to_string(build)) << ",\"videoCapture\":" << (window_capture ? "true" : "false") + << ",\"captureBorderControl\":" + << (window_capture && piik::capture::CaptureBorder::Supported() ? "true" : "false") << ",\"softwareVP8\":true" << ",\"processAudio\":" << (process_audio ? "true" : "false") @@ -1725,6 +1734,7 @@ void RunVideoCapture(const ProductArguments& arguments) { initial_size); GraphicsCaptureSession capture_session = pool.CreateCaptureSession(item); EnableFastCaptureUpdates(capture_session); + piik::capture::CaptureBorder border; UniqueHandle shutdown(CreateEventW(nullptr, TRUE, FALSE, nullptr)); UniqueHandle frame_ready(CreateEventW(nullptr, FALSE, FALSE, nullptr)); @@ -1767,6 +1777,7 @@ void RunVideoCapture(const ProductArguments& arguments) { item.Closed(closed_token); } catch (...) { } + border.Close(); try { capture_session.Close(); } catch (...) { @@ -1794,6 +1805,7 @@ void RunVideoCapture(const ProductArguments& arguments) { } if (FAILED(writer.WriteUnavailable(static_cast(layer), OutputFailureDetail(layer, error)))) fail_capture(error); }); + if (!arguments.show_capture_border) border.Start(capture_session); capture_session.StartCapture(); UINT64 previous_timestamp = 0; @@ -1826,6 +1838,7 @@ void RunVideoCapture(const ProductArguments& arguments) { ApplyOutputControl(control, workers); refresh_input = true; } + border.Apply(capture_session); DWORD wait = window_target ? WaitForMultipleObjects(3, window_waits, FALSE, control_wait_ms) : WaitForMultipleObjects(2, display_waits, FALSE, control_wait_ms); diff --git a/scripts/app-native-host-gate.ts b/scripts/app-native-host-gate.ts index dd4cff2..0cf1a83 100644 --- a/scripts/app-native-host-gate.ts +++ b/scripts/app-native-host-gate.ts @@ -834,8 +834,8 @@ async function main(): Promise { }>( cdp!, host, - "(() => ({invite: document.querySelector('.lr-invite-url')?.value || null, " + - "native: Boolean(document.querySelector('.lr-tv-overlay [aria-label]')), " + + "(async () => ({invite: document.querySelector('.lr-invite-url')?.value || null, " + + "native: (await window.__piikGateVideoCodec()) !== null, " + "body: document.body.innerText.slice(0, 500)}))()", deadline, ), diff --git a/scripts/check-go.mjs b/scripts/check-go.mjs index a1151b3..c3214b0 100644 --- a/scripts/check-go.mjs +++ b/scripts/check-go.mjs @@ -17,7 +17,8 @@ const WEB_INDEX = join("internal", "server", "webassets", "dist", "index.html"); const SERVER_TARGET = appPackageTarget("linux-amd64"); // Local dependency repairs retain upstream tests, including PCPv6 composition. // Nested modules need explicit test patterns; remove these with the replacements. -const GO_TEST_PACKAGES = ["./...", "github.com/netbirdio/go-nat/...", "github.com/jackpal/go-nat-pmp"]; +// Go source lives here; ./... would also scan downloaded SDKs and build probes. +const GO_TEST_PACKAGES = ["./cmd/...", "./internal/...", "github.com/netbirdio/go-nat/...", "github.com/jackpal/go-nat-pmp"]; const root = realpathSync(resolve(dirname(fileURLToPath(import.meta.url)), "..")); const captureRoot = join(root, "native", "capture"); @@ -59,7 +60,7 @@ function checkCore() { const goRoot = run(go, ["env", "GOROOT"], { capture: true }); const gofmt = process.env.PIIK_GOFMT?.trim() || join(goRoot, "bin", process.platform === "win32" ? "gofmt.exe" : "gofmt"); - const unformatted = run(gofmt, ["-l", "."], { capture: true }); + const unformatted = run(gofmt, ["-l", "cmd", "internal"], { capture: true }); if (unformatted) { throw new Error(`Go source is not formatted:\n${unformatted}`); } diff --git a/scripts/publish-container.mjs b/scripts/publish-container.mjs index 17d750e..de714a4 100644 --- a/scripts/publish-container.mjs +++ b/scripts/publish-container.mjs @@ -21,7 +21,8 @@ const release = gh(`releases/tags/${version}`); assert.equal(release.draft, false, "Container publication requires a published GitHub release"); assert.equal(release.prerelease, false); assert.equal(release.target_commitish, revision); -assert.equal(gh(`commits/${version}`).sha, revision, "Published source tag changed"); +assert.equal(run("gh", "api", `repos/${repository}/commits/${version}`, + "--header", "Accept: application/vnd.github.sha"), revision, "Published source tag changed"); assert.equal(release.assets.find((asset) => asset.name === server.name)?.digest, `sha256:${server.sha256}`, "Container must wrap the published Server archive"); diff --git a/scripts/release-version.mjs b/scripts/release-version.mjs index 115bb62..8a4a2a5 100644 --- a/scripts/release-version.mjs +++ b/scripts/release-version.mjs @@ -22,7 +22,7 @@ const nonProductPaths = [ "scripts/build-website.mjs", "scripts/update-website-hero.mjs", "scripts/check-website-film.js", "scripts/check-docs.mjs", "scripts/markdown-slug.mjs", "scripts/check-project-state.*", "scripts/install-hooks.*", "scripts/required-project-paths.txt", "scripts/tsconfig.json", - "scripts/release-*.mjs", "scripts/publish-release.mjs", "scripts/mirror-release.mjs", + "scripts/release-*.mjs", "scripts/publish-release.mjs", "scripts/publish-container.mjs", "scripts/mirror-release.mjs", "scripts/*-gate.*", "scripts/*-probe.*", "scripts/browser-*", "scripts/encoded-*", "scripts/*-gate-endpoint.ts", "scripts/embedded-sfu-page.ts", "scripts/peer-assisted-benchmark.ts", "cmd/piik-peer-gate/**", diff --git a/src/client/components/living/CaptureSourcePicker.tsx b/src/client/components/living/CaptureSourcePicker.tsx index 91b7833..ea133d7 100644 --- a/src/client/components/living/CaptureSourcePicker.tsx +++ b/src/client/components/living/CaptureSourcePicker.tsx @@ -21,11 +21,14 @@ export type NativeSourceList = | { kind: "loading" } | { kind: "unavailable" } | { kind: "incompatible" } + | { kind: "unsupported" } + | { kind: "failed" } | { kind: "ready"; sources: NativeCaptureTarget[]; processAudio: boolean; systemAudio: boolean; + captureBorderControl?: boolean; }; export function CaptureSourcePicker({ @@ -38,12 +41,13 @@ export function CaptureSourcePicker({ browserAvailable = true, initialTab = "window", initialAudio = true, + initialShowCaptureBorder = false, audioLocked = false, selectionDisabled = false, }: { nativeSources: NativeSourceList; onBrowser: () => void; - onNative: (target: NativeCaptureTarget, audio: boolean) => void; + onNative: (target: NativeCaptureTarget, audio: boolean, showCaptureBorder: boolean) => void; onPreview: ( target: NativeCaptureTarget, signal?: AbortSignal, @@ -53,6 +57,7 @@ export function CaptureSourcePicker({ browserAvailable?: boolean; initialTab?: SourceTab; initialAudio?: boolean; + initialShowCaptureBorder?: boolean; audioLocked?: boolean; selectionDisabled?: boolean; }) { @@ -61,7 +66,14 @@ export function CaptureSourcePicker({ const panelRef = useRef(null); const [tab, setTab] = useState(initialTab); const [shareAudio, setShareAudio] = useState(initialAudio); + const [showCaptureBorder, setShowCaptureBorder] = useState(initialShowCaptureBorder); const activeTab = tab === "browser" && !browserAvailable ? "window" : tab; + const supportsCaptureBorder = nativeSources.kind === "ready" && nativeSources.captureBorderControl === true; + const issueKey = nativeSources.kind === "incompatible" ? "native.incompatible" + : activeTab !== "browser" && (nativeSources.kind === "unavailable" || + nativeSources.kind === "unsupported" || nativeSources.kind === "failed") + ? `host.sourcePicker.${nativeSources.kind}` as const + : null; useEffect(() => { const cancelOnEscape = (event: KeyboardEvent) => { @@ -265,7 +277,8 @@ export function CaptureSourcePicker({ } onPreview={onPreview} onSelect={() => - onNative(target, shareAudio && supportsAudio(target)) + onNative(target, shareAudio && supportsAudio(target), + supportsCaptureBorder && showCaptureBorder) } /> )) @@ -274,25 +287,48 @@ export function CaptureSourcePicker({ {activeTab !== "browser" && nativeSources.kind === "ready" && - sources.length > 0 ? ( -
- - {vis ? null : {audioLabel}} - - {audioSwitch} - + (supportsCaptureBorder || sources.length > 0) ? ( +
+ {supportsCaptureBorder ? ( +
+ + {vis ? null : {t("host.sourcePicker.showCaptureBorder")}} + +
+ ) : null} + {sources.length > 0 ? ( +
+ + {vis ? null : {audioLabel}} + + {audioSwitch} + +
+ ) : null}
) : null} - {nativeSources.kind === "incompatible" ? ( - + {issueKey ? ( + ) : activeTab === "browser" ? null : nativeSources.kind === "loading" ? ( diff --git a/src/client/components/living/hints/controls.tsx b/src/client/components/living/hints/controls.tsx index 7fc3c42..274e4f6 100644 --- a/src/client/components/living/hints/controls.tsx +++ b/src/client/components/living/hints/controls.tsx @@ -109,6 +109,28 @@ ${rmBlock(["vls-share-audio-lock"], [[".vls-share-audio-lock", "transform:none"] ; } +// This shows the requested border change, not proof of Windows permission. +function CaptureBorderHint({ theme, show }: { theme: ComicTheme; show: boolean }) { + return <> + + {[4, 164].map((x, index) => + + + + + + )} + ; +} + const RefreshSourcesHint: HintScene = ({ theme }) => <>