feat(app): improve capture controls and sharing feedback

## 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.
This commit is contained in:
AAA authored and GitHub committed 2026-09-17 08:29:15 +08:00
1 parent cdd503a98d
commit d76e94eb71
52 files changed
+1380 -298

No files matched your search

+11 -1
View File
@@ -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
+8 -1
View File
@@ -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,再确认没有其他扩展或管理策略覆盖该选择。
@@ -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
+67 -8
View File
@@ -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
+56 -7
View File
@@ -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 时,仍需换网络或由管理员放行。
+73 -2
View File
@@ -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
+7
View File
@@ -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
+9
View File
@@ -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
+25 -6
View File
@@ -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
+2 -2
View File
@@ -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
+21 -39
View File
@@ -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.
+6 -1
View File
@@ -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
+6 -5
View File
@@ -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,
},
}
}
+6 -5
View File
@@ -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 {
+20 -17
View File
@@ -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 {
+18 -1
View File
@@ -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")
}
}
}
+11 -7
View File
@@ -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
+10 -8
View File
@@ -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,
@@ -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) {
+21 -19
View File
@@ -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 {
+8 -7
View File
@@ -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:
- <https://libportal.org/libportal.html>
- <https://pipewire.pages.freedesktop.org/pipewire/page_portal.html>
- <https://gstreamer.freedesktop.org/documentation/>
- <https://gstreamer.freedesktop.org/documentation/gstreamer/gstghostpad.html>
- <https://gstreamer.freedesktop.org/documentation/coreelements/valve.html>
+76 -9
View File
@@ -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);
+137
View File
@@ -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;
}
+13
View File
@@ -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.
- <https://learn.microsoft.com/en-us/windows/uwp/audio-video-camera/screen-capture>
- <https://learn.microsoft.com/en-us/windows/win32/api/windows.graphics.capture.interop/nf-windows-graphics-capture-interop-igraphicscaptureiteminterop-createforwindow>
- <https://learn.microsoft.com/en-us/uwp/api/windows.graphics.capture.direct3d11captureframepool.createfreethreaded>
- <https://learn.microsoft.com/en-us/uwp/api/windows.graphics.capture.graphicscapturesession.isborderrequired>
- <https://github.com/microsoft/Windows.UI.Composition-Win32-Samples/issues/97#issuecomment-1015962518>
+73
View File
@@ -0,0 +1,73 @@
#pragma once
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Foundation.Metadata.h>
#include <winrt/Windows.Graphics.Capture.h>
#include <winrt/Windows.Security.Authorization.AppCapabilityAccess.h>
#include <iostream>
#include <syncstream>
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<int>(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<AccessStatus> access_{nullptr};
};
} // namespace piik::capture
@@ -15,6 +15,7 @@
#include <wrl/client.h>
#include "capture_target.h"
#include "capture_border.h"
#include "capture_geometry.h"
#include <algorithm>
@@ -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 (...) {
+13
View File
@@ -30,6 +30,7 @@
#include <winrt/base.h>
#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<VideoProfile> 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<UINT8>(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);
+2 -2
View File
@@ -834,8 +834,8 @@ async function main(): Promise<void> {
}>(
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,
),
+3 -2
View File
@@ -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}`);
}
+2 -1
View File
@@ -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");
+1 -1
View File
@@ -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/**",
@@ -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<HTMLDivElement>(null);
const [tab, setTab] = useState<SourceTab>(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 ? (
<div className="lr-source-picker-audio">
<span aria-hidden="true">
<Glyph name="speaker" size={19} />
</span>
{vis ? null : <span>{audioLabel}</span>}
<Tooltip
kind={!anyNativeAudio ? "no-audio" : audioLocked
? (shareAudio ? "hint-share-audio-fixed" : "hint-silent-share-fixed")
: shareAudio ? "hint-stop-audio" : "hint-share-audio"}
text={vis ? undefined : `${audioAction} · ${t("host.sourcePicker.audioHint")}`}
>
{audioSwitch}
</Tooltip>
(supportsCaptureBorder || sources.length > 0) ? (
<div className="lr-source-picker-options">
{supportsCaptureBorder ? (
<div className="lr-source-picker-option">
<span aria-hidden="true"><Glyph name="window" size={19} /></span>
{vis ? null : <span>{t("host.sourcePicker.showCaptureBorder")}</span>}
<Tooltip kind={showCaptureBorder ? "hint-hide-capture-border" : "hint-show-capture-border"}
text={vis ? undefined : t("host.sourcePicker.showCaptureBorderHint")} place="below">
<button
type="button"
className="lr-switch"
role="switch"
aria-checked={showCaptureBorder}
aria-label={t("host.sourcePicker.showCaptureBorder")}
aria-description={t("host.sourcePicker.showCaptureBorderHint")}
onClick={() => setShowCaptureBorder((current) => !current)}
/>
</Tooltip>
</div>
) : null}
{sources.length > 0 ? (
<div className="lr-source-picker-option">
<span aria-hidden="true">
<Glyph name="speaker" size={19} />
</span>
{vis ? null : <span>{audioLabel}</span>}
<Tooltip
kind={!anyNativeAudio ? "no-audio" : audioLocked
? (shareAudio ? "hint-share-audio-fixed" : "hint-silent-share-fixed")
: shareAudio ? "hint-stop-audio" : "hint-share-audio"}
text={vis ? undefined : `${audioAction} · ${t("host.sourcePicker.audioHint")}`}
place="below"
>
{audioSwitch}
</Tooltip>
</div>
) : null}
</div>
) : null}
{nativeSources.kind === "incompatible" ? (
<Pill icon="alert" label={t("native.incompatible")} comic="warning" />
{issueKey ? (
<Pill icon="alert" label={t(issueKey)} comic="warning" />
) : activeTab === "browser" ? null : nativeSources.kind === "loading" ? (
<span
className="lr-source-picker-status"
+1 -1
View File
@@ -164,7 +164,7 @@ export function MetricCell({ icon, label, value, glyphOnly, hint, tone = "off" }
<Tooltip toggleOnClick kind={hint ?? METRIC_PRESENTATION[label].hint} tone={tone} motion={tone === "warn" ? "still" : "demo"}
text={vis ? undefined : `${t(label)} · ${display}`}>
<button type="button" className="lr-meter-cell" aria-label={`${t(label)} · ${display}`}
style={{ border: 0, color: "inherit", font: "inherit", textAlign: "start", cursor: "help" }}>
style={{ border: 0, color: "inherit", font: "inherit", textAlign: "start" }}>
<Glyph name={icon ?? METRIC_PRESENTATION[label].icon} size={16} />
{vis ? (
<>
+72 -51
View File
@@ -1,6 +1,7 @@
// Shared tooltip: the same comic in every mode, with a localized caption in
// text modes. Truncated literal text needs only its full value. Hover and keyboard
// focus show guidance; help-only controls also toggle it on click/tap.
// focus show guidance; help-only controls also toggle it on click/tap. Hover
// waits for intent; clicking the panel dismisses it without activating below it.
// Action controls keep their click and use a 500ms touch hold for guidance,
// hiding 1.5s after release and suppressing the trailing synthetic click.
// Alignment: the align prop is a desktop-tuned preference; whenever a show
@@ -8,7 +9,7 @@
// start/center/end is re-picked so the panel never clips off-screen (rows
// wrap at narrow widths, so a static choice cannot hold). Player controls may
// use a complete left/right placement when the whole bar has empty space;
// otherwise they use the same above/below fallback as every other control.
// otherwise prefer below the television, then above the playback bar.
// The native top layer
// avoids clipping by scrolling lists; the caret points back to the trigger.
// Styling in styles.css under "comic tooltip" / "glyph draw-in". SSR-safe:
@@ -34,10 +35,11 @@ import { comicStyle, getComicPresentation, type ComicTone, type ComicMotion } fr
const LONG_PRESS_MS = 500;
const TOUCH_HIDE_MS = 1500;
const PANEL_EXIT_MS = 160;
const HOVER_ENTER_MS = 500;
// Minimum clearance the re-picked alignment keeps to each viewport edge.
const EDGE_MARGIN = 8;
const HOVER_EXIT_GRACE_MS = 160;
// Paper panel before it is measured: 240-wide comic strip plus its padding.
// Paper panel before its mounted content is measured.
const FALLBACK_PANEL_HEIGHT = 96;
type Align = "center" | "start" | "end";
@@ -63,7 +65,7 @@ export function Tooltip({
tone?: ComicTone;
motion?: ComicMotion;
className?: string;
/** below = for controls pinned to the viewport top (header). */
/** Preferred side; use below when the working content is above the control. */
place?: "above" | "below";
/** Preferred alignment; re-picked at open time if it would clip off-screen. */
align?: Align;
@@ -79,7 +81,7 @@ export function Tooltip({
const tipRef = useRef<HTMLSpanElement | null>(null);
const pressTimer = useRef<number | null>(null);
const hideTimer = useRef<number | null>(null);
const hoverExitTimer = useRef<number | null>(null);
const hoverTimer = useRef<number | null>(null);
const panelUnmountTimer = useRef<number | null>(null);
const longPressed = useRef(false);
const pressPoint = useRef<{ x: number; y: number } | null>(null);
@@ -137,15 +139,7 @@ export function Tooltip({
useEffect(() => {
if (enabled) return;
setHoverOpen(false);
setFocusOpen(false);
setPressOpen(false);
if (pressTimer.current !== null) window.clearTimeout(pressTimer.current);
pressTimer.current = null;
if (hideTimer.current !== null) window.clearTimeout(hideTimer.current);
hideTimer.current = null;
if (hoverExitTimer.current !== null) window.clearTimeout(hoverExitTimer.current);
hoverExitTimer.current = null;
dismissPanel();
pressPoint.current = null;
}, [enabled]);
@@ -171,9 +165,14 @@ export function Tooltip({
const vw = window.innerWidth;
if (!vw) return; // no layout (SSR/test): keep the prop alignment
const rect = wrap.getBoundingClientRect();
const playback = wrap.closest<HTMLElement>(".lr-playback")?.getBoundingClientRect();
const avoid = playback ?? rect;
// A wrapped control row remains one working area, including on narrow screens.
const controlBar = wrap.closest<HTMLElement>(".lr-playback, .lr-source-picker-options");
const playback = controlBar?.classList.contains("lr-playback");
const avoid = controlBar?.getBoundingClientRect() ?? rect;
const player = wrap.closest<HTMLElement>(".lr-tv-screen")?.getBoundingClientRect();
const below = playback
? wrap.closest<HTMLElement>(".lr-tv")?.getBoundingClientRect().bottom ?? avoid.bottom
: avoid.bottom;
const measured = panelMounted ? (tipRef.current?.offsetWidth ?? 0) : 0;
const width = Math.max(0, Math.min(measured || 320, vw - EDGE_MARGIN * 2));
// Same idea vertically: a control scrolled near the top has no room above,
@@ -182,7 +181,7 @@ export function Tooltip({
const panelHeight =
(panelMounted ? tipRef.current?.offsetHeight : 0) || FALLBACK_PANEL_HEIGHT;
const fitsAbove = avoid.top - panelHeight - EDGE_MARGIN >= EDGE_MARGIN;
const fitsBelow = !vh || avoid.bottom + panelHeight + EDGE_MARGIN <= vh - EDGE_MARGIN;
const fitsBelow = !vh || below + panelHeight + EDGE_MARGIN <= vh - EDGE_MARGIN;
const preferredPlace = wrap.closest(".lr-tv-chin") ? "below" : place;
let livePlace: Placement =
preferredPlace === "above"
@@ -205,8 +204,10 @@ export function Tooltip({
const nearRight = sideAvoid.right - rect.right <= rect.width;
const fitsLeft = nearLeft && sideAvoid.left - width - EDGE_MARGIN >= EDGE_MARGIN;
const fitsRight = nearRight && sideAvoid.right + width + EDGE_MARGIN <= vw - EDGE_MARGIN;
// Prefer space below the television (including its status strip) to
// covering the picture. Fullscreen falls back above the entire bar.
livePlace = fitsSideVertically && (fitsLeft || fitsRight) ? fitsLeft ? "left" : "right"
: fitsAbove || !fitsBelow ? "above" : "below";
: fitsBelow ? "below" : "above";
}
const center = rect.left + rect.width / 2;
const boxes: Record<Align, { left: number; right: number }> = {
@@ -224,7 +225,7 @@ export function Tooltip({
const fits = order.find((a) => clipped(boxes[a]) === 0);
const selected = fits ?? order.reduce((a, b) => (clipped(boxes[a]) <= clipped(boxes[b]) ? a : b));
let left = Math.max(EDGE_MARGIN, Math.min(boxes[selected].left, vw - width - EDGE_MARGIN));
let top = livePlace === "below" ? avoid.bottom + EDGE_MARGIN : avoid.top - panelHeight - EDGE_MARGIN;
let top = livePlace === "below" ? below + EDGE_MARGIN : avoid.top - panelHeight - EDGE_MARGIN;
let caret = Math.max(14, Math.min(center - left, width - 14));
if (livePlace === "left") {
left = (player ?? avoid).left - width - EDGE_MARGIN;
@@ -287,10 +288,7 @@ export function Tooltip({
// focused neighbour. Only visible guidance consumes the outer action.
const style = tipRef.current && getComputedStyle(tipRef.current);
if (style?.visibility === "visible" && style.pointerEvents !== "none") event.preventDefault();
cancelHoverExit();
setHoverOpen(false);
setPressOpen(false);
setFocusOpen(false);
dismissPanel();
};
document.addEventListener("keydown", dismiss);
return () => document.removeEventListener("keydown", dismiss);
@@ -314,7 +312,7 @@ export function Tooltip({
() => () => {
if (pressTimer.current !== null) window.clearTimeout(pressTimer.current);
if (hideTimer.current !== null) window.clearTimeout(hideTimer.current);
if (hoverExitTimer.current !== null) window.clearTimeout(hoverExitTimer.current);
if (hoverTimer.current !== null) window.clearTimeout(hoverTimer.current);
if (panelUnmountTimer.current !== null) {
window.clearTimeout(panelUnmountTimer.current);
}
@@ -322,20 +320,17 @@ export function Tooltip({
[],
);
// A click/touch-open hint stays until dismissed or focus leaves its trigger.
// Clicking elsewhere dismisses guidance regardless of how it was opened.
useEffect(() => {
if (!pressOpen) return;
if (!interactionOpen) return;
const dismiss = (event: PointerEvent) => {
if (!wrapRef.current?.contains(event.target as Node)) {
cancelHoverExit();
setPressOpen(false);
setHoverOpen(false);
setFocusOpen(false);
dismissPanel();
}
};
document.addEventListener("pointerdown", dismiss);
return () => document.removeEventListener("pointerdown", dismiss);
}, [pressOpen]);
}, [interactionOpen]);
const cancelPress = () => {
if (pressTimer.current !== null) {
@@ -344,37 +339,53 @@ export function Tooltip({
}
};
const cancelHoverExit = () => {
if (hoverExitTimer.current !== null) {
window.clearTimeout(hoverExitTimer.current);
hoverExitTimer.current = null;
const cancelHover = () => {
if (hoverTimer.current !== null) {
window.clearTimeout(hoverTimer.current);
hoverTimer.current = null;
}
};
const dismissPanel = () => {
cancelHover();
cancelPress();
if (hideTimer.current !== null) window.clearTimeout(hideTimer.current);
hideTimer.current = null;
setHoverOpen(false);
setPressOpen(false);
setFocusOpen(false);
};
const placeClass = position.placement === "above" ? "" : ` is-${position.placement}`;
return (
<span
ref={wrapRef}
className={`lr-comic-tip-wrap${className ? ` ${className}` : ""}${disabledTrigger ? " is-disabled-trigger" : ""}${hoverOpen ? " is-hover-open" : ""}${focusOpen ? " is-focus-open" : ""}${pressOpen ? " is-tip-open" : ""}`}
className={`lr-comic-tip-wrap${className ? ` ${className}` : ""}${toggleOnClick ? " is-help-only" : ""}${disabledTrigger ? " is-disabled-trigger" : ""}${hoverOpen ? " is-hover-open" : ""}${focusOpen ? " is-focus-open" : ""}${pressOpen ? " is-tip-open" : ""}`}
tabIndex={focusableWrap ? 0 : undefined}
aria-label={disabledTriggerLabel ?? (focusableWrap ? trigger?.props["aria-label"] ?? overflow?.text : undefined)}
aria-describedby={disabledTrigger && caption && interactionOpen ? tooltipId : undefined}
onPointerEnter={(event) => {
if (!enabled) return;
cancelHoverExit();
pickAlign();
if (event.pointerType !== "touch") {
mountPanel(!hoverOpen);
if (!enabled || event.pointerType === "touch" || event.buttons !== 0) return;
cancelHover();
if (interactionOpen) {
setHoverOpen(true);
return;
}
// No mounted panel or invisible hit area while passing over a control.
hoverTimer.current = window.setTimeout(() => {
hoverTimer.current = null;
pickAlignRef.current();
mountPanel(true);
setHoverOpen(true);
}, HOVER_ENTER_MS);
}}
onPointerLeave={(event) => {
if (event.pointerType === "touch") return;
cancelHoverExit();
cancelHover();
if (!hoverOpen) return;
// A two-row playback bar lies between some triggers and their hint.
// A short grace lets the pointer cross it without an invisible hit
// target covering neighbouring buttons.
hoverExitTimer.current = window.setTimeout(() => {
hoverExitTimer.current = null;
hoverTimer.current = window.setTimeout(() => {
hoverTimer.current = null;
setHoverOpen(false);
}, HOVER_EXIT_GRACE_MS);
}}
@@ -382,6 +393,7 @@ export function Tooltip({
if (!enabled) return;
pickAlign();
if ((event.target as HTMLElement).matches(":focus-visible")) {
cancelHover();
mountPanel(true);
setFocusOpen(true);
}
@@ -393,13 +405,16 @@ export function Tooltip({
}
}}
onPointerDown={(event) => {
cancelHover();
if (event.pointerType !== "touch" && !toggleOnClick && !tipRef.current?.contains(event.target as Node)) {
dismissPanel();
}
// A suppression token belongs only to the click synthesized for the
// completed long-press. A later touch starts a new, actionable gesture.
longPressed.current = false;
cancelPress();
touchGesture.current = event.pointerType === "touch";
if (!enabled || event.pointerType !== "touch") return;
if (toggleOnClick) return;
pressPoint.current = { x: event.clientX, y: event.clientY };
pressTimer.current = window.setTimeout(() => {
pressTimer.current = null;
@@ -461,15 +476,21 @@ export function Tooltip({
longPressed.current = false;
event.preventDefault();
event.stopPropagation();
} else if (tipRef.current?.contains(event.target as Node)) {
// This click only clears guidance; never pass it to a covered action.
event.stopPropagation();
// Dragging to select a caption or full nickname is still reading.
const selection = window.getSelection();
if (!selection?.isCollapsed && tipRef.current.contains(selection?.anchorNode ?? null)) return;
dismissPanel();
} else if (!toggleOnClick && event.detail !== 0) {
dismissPanel();
}
}}
onClick={enabled && toggleOnClick && !disabledTrigger ? (event) => {
if (tipRef.current?.contains(event.target as Node)) return;
if (pressOpen) {
cancelHoverExit();
setPressOpen(false);
setHoverOpen(false);
setFocusOpen(false);
dismissPanel();
} else {
mountPanel(true);
pickAlign();
@@ -495,8 +516,8 @@ export function Tooltip({
className={`lr-comic-tip${kind ? " has-comic" : ""}${caption !== undefined ? " is-text" : ""}${placeClass}`} role="tooltip" aria-hidden={!interactionOpen}>
{panelMounted ? <>
{kind ? isHintKind(kind)
? <HintComic kind={kind} size={240} tone={resolvedTone} motion={resolvedMotion} />
: <Comic kind={kind} theme="paper" size={240} tone={resolvedTone} motion={resolvedMotion} /> : null}
? <HintComic kind={kind} size={200} tone={resolvedTone} motion={resolvedMotion} />
: <Comic kind={kind} theme="paper" size={200} tone={resolvedTone} motion={resolvedMotion} /> : null}
{caption !== undefined ? <span className="lr-comic-tip-caption">{caption}</span> : null}
</> : null}
</span>
@@ -39,7 +39,7 @@ export function ViewerOverview({
finite(value ?? null) ? `${value!.toFixed(digits)}${unit}` : unknown;
const heading = (key: MetricLabel) => (
<Tooltip toggleOnClick kind={METRIC_PRESENTATION[key].hint} text={vis ? undefined : t(key)}>
<button type="button" aria-label={t(key)} style={{ border: 0, padding: 0, background: "none", color: "inherit", font: "inherit", cursor: "help" }}>
<button type="button" aria-label={t(key)} style={{ border: 0, padding: 0, background: "none", color: "inherit", font: "inherit" }}>
{vis ? <Glyph name={METRIC_PRESENTATION[key].icon} size={14} /> : t(key)}
</button>
</Tooltip>
@@ -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 <>
<style>{`
.vls-capture-border-show,.vls-capture-border-hide{animation:vlsCaptureBorderShow var(--comic-duration,3.2s) ease-out var(--comic-repeat,1) both}
.vls-capture-border-hide{animation-name:vlsCaptureBorderHide}
@keyframes vlsCaptureBorderShow{0%,10%{opacity:0}36%,100%{opacity:1}}
@keyframes vlsCaptureBorderHide{0%,10%{opacity:1}36%,100%{opacity:0}}
${rmBlock(["vls-capture-border-show", "vls-capture-border-hide"], [[".vls-capture-border-show", "opacity:1"], [".vls-capture-border-hide", "opacity:0"]])}
`}</style>
{[4, 164].map((x, index) => <g key={x}>
<Frame x={x} w={152} theme={theme} result={index === 1} />
<rect x={x + 30} y={25} width={92} height={56} rx={7} fill="none" stroke={STAR_GOLD} strokeWidth={3}
opacity={index ? (show ? 1 : 0) : (show ? 0 : 1)}
className={index ? (show ? "vls-capture-border-show" : "vls-capture-border-hide") : undefined} />
<rect x={x + 35} y={30} width={82} height={46} rx={3} fill="var(--paper)" stroke="var(--ink)" strokeWidth={2} />
<path d={`M${x + 35} 40h82 m-9 -7 4 4 m0-4-4 4`} fill="none" stroke="var(--ink)" strokeWidth={1.5} />
<path d={`M${x + 47} 67l17-19 15 12 11-9 14 16`} fill="none" stroke={SKY} strokeWidth={2.5} />
</g>)}
</>;
}
const RefreshSourcesHint: HintScene = ({ theme }) => <>
<style>{`
.vls-refresh-path{stroke-dasharray:1;animation:vlsRefreshPath var(--comic-duration,3.2s) ease-out var(--comic-repeat,1) both}
@@ -165,6 +187,8 @@ export const CONTROL_SCENES: Record<ControlHintKind, HintScene> = {
"hint-refresh-sources": RefreshSourcesHint,
"hint-source-picker": (props) => <SourceListHint {...props} />,
"hint-no-sources": (props) => <SourceListHint {...props} empty />,
"hint-show-capture-border": (props) => <CaptureBorderHint {...props} show />,
"hint-hide-capture-border": (props) => <CaptureBorderHint {...props} show={false} />,
"hint-password-show": (props) => <PasswordHint {...props} action="show" />,
"hint-password-hide": (props) => <PasswordHint {...props} action="hide" />,
"hint-password-remove": (props) => <PasswordHint {...props} action="clear" />,
@@ -29,6 +29,8 @@ export const HINT_KINDS: readonly HintKind[] = [
"hint-refresh-sources",
"hint-source-picker",
"hint-no-sources",
"hint-show-capture-border",
"hint-hide-capture-border",
"hint-reconnect",
"hint-copy-code",
"hint-shuffle-code",
+5
View File
@@ -107,7 +107,12 @@ export const en: Record<CopyKey, string> = {
"host.sourcePicker.tab.display": "Screens",
"host.sourcePicker.windowAudio": "Application sound",
"host.sourcePicker.systemAudio": "System sound",
"host.sourcePicker.showCaptureBorder": "Show capture border",
"host.sourcePicker.showCaptureBorderHint": "Turn off to hide the border when Windows allows it. Other captures may keep it visible.",
"host.sourcePicker.empty": "No sources available",
"host.sourcePicker.unavailable": "Cannot connect to Piik App. Keep the App running, open this site from the App, then refresh the list. Allow local-network access if asked.",
"host.sourcePicker.unsupported": "Piik App capture is currently unavailable. Check the encoding setting or the App diagnostics.",
"host.sourcePicker.failed": "Could not read the App's screen and window list. Refresh to try again; if it keeps failing, collect a Debug report.",
"native.incompatible": "Piik App and this page are incompatible. Update Piik App and reload this page, or use Browser.",
"native.fail.edge": "Piik App media connection failed",
"native.fail.signaling": "Piik App signaling failed",
+5
View File
@@ -107,7 +107,12 @@ export const zh = {
"host.sourcePicker.tab.display": "屏幕",
"host.sourcePicker.windowAudio": "应用声音",
"host.sourcePicker.systemAudio": "系统声音",
"host.sourcePicker.showCaptureBorder": "显示采集边框",
"host.sourcePicker.showCaptureBorderHint": "关闭后尝试隐藏黄框;Windows 或其他采集程序可能仍要求显示。",
"host.sourcePicker.empty": "暂无可用来源",
"host.sourcePicker.unavailable": "未能连接 Piik App。请保持 App 运行,从 App 打开当前站点后刷新列表;浏览器询问本地网络权限时,请允许访问。",
"host.sourcePicker.unsupported": "Piik App 采集功能当前不可用。请检查编码设置,或查看 App 诊断信息。",
"host.sourcePicker.failed": "未能读取 App 的窗口和屏幕列表。请刷新重试;若持续失败,请导出诊断报告。",
"native.incompatible": "Piik App 与当前页面不兼容。请更新 Piik App 并刷新页面,或使用浏览器。",
"native.fail.edge": "Piik App 媒体连接失败",
"native.fail.signaling": "Piik App 信令失败",
+76 -37
View File
@@ -41,9 +41,27 @@ import {
type NativeVideoCodec,
} from "./wire";
const DISCOVERY_TIMEOUT_MS = 400;
const REQUEST_TIMEOUT_MS = 8_000;
interface NativeDiscoveryOptions {
waitForPermission?: boolean;
}
async function ungrantedLocalPermission(): Promise<PermissionState | null> {
// App Local already runs on loopback. Its permission can say "prompt" even
// though loopback-to-loopback requests do not need consent.
if (["localhost", "127.0.0.1", "[::1]"].includes(window.location.hostname)) return null;
for (const name of ["loopback-network", "local-network-access"]) {
try {
const { state } = await navigator.permissions.query({ name: name as PermissionName });
return state === "granted" ? null : state;
} catch {
// Older Chromium uses the combined name; other browsers may expose neither.
}
}
return null;
}
interface PendingRequest<T = unknown> {
schema: z.ZodType<T>;
resolve: (value: T) => void;
@@ -55,6 +73,7 @@ export interface NativeShareInput {
shareId: string;
source: NativeCaptureTarget;
audio: boolean;
showCaptureBorder?: boolean;
adapterIndex: number;
encoderIndex: number;
edgeCapacity: number;
@@ -69,49 +88,61 @@ export class NativeCompatibilityError extends Error {
}
}
export async function discoverNativeHealth(): Promise<NativeHealth | null> {
let incompatible: NativeCompatibilityError | null = null;
for (
let port = NATIVE_CLIENT_PORT_START;
port <= NATIVE_CLIENT_PORT_END;
port += 1
) {
const controller = new AbortController();
const timer = window.setTimeout(
() => controller.abort(),
DISCOVERY_TIMEOUT_MS,
);
try {
export async function discoverNativeHealth(
{ waitForPermission = true }: NativeDiscoveryOptions = {},
): Promise<NativeHealth | null> {
if (!waitForPermission) {
const permission = await ungrantedLocalPermission();
if (permission) {
debugEvent("native", "unavailable", { stage: "permission", permission });
return null;
}
}
const controller = new AbortController();
// The first request may wait for browser permission. One shared deadline
// bounds the scan; a silent port must not hide an App on another port.
const timer = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
return await Promise.any(Array.from({
length: NATIVE_CLIENT_PORT_END - NATIVE_CLIENT_PORT_START + 1,
}, async (_, index) => {
const port = NATIVE_CLIENT_PORT_START + index;
const response = await fetch(`http://127.0.0.1:${port}/health`, {
cache: "no-store",
signal: controller.signal,
targetAddressSpace: "loopback",
} as RequestInit);
if (!response.ok) continue;
if (!response.ok) throw new Error("Piik App discovery was not accepted");
const body: unknown = await response.json();
const identity = nativeDiscoveryIdentitySchema.safeParse(body);
if (!identity.success || identity.data.port !== port) continue;
if (identity.data.protocol !== NATIVE_CLIENT_PROTOCOL) {
incompatible ??= new NativeCompatibilityError(identity.data.protocol);
continue;
if (!identity.success || identity.data.port !== port) {
throw new Error("Not a Piik App discovery response");
}
const health = nativeHealthSchema.safeParse(body);
if (health.success) return health.data;
} catch {
// An absent App and a denied local-network permission are both
// ordinary Browser-only operation.
} finally {
window.clearTimeout(timer);
if (identity.data.protocol !== NATIVE_CLIENT_PROTOCOL) {
throw new NativeCompatibilityError(identity.data.protocol);
}
const health = nativeHealthSchema.parse(body);
debugEvent("native", "discovered", { capabilities: health.nativeMedia });
return health;
}));
} catch (error) {
const incompatible = error instanceof AggregateError
? error.errors.find((failure) => failure instanceof NativeCompatibilityError)
: undefined;
if (incompatible) {
debugEvent("native", "incompatible", {
expectedProtocol: NATIVE_CLIENT_PROTOCOL,
actualProtocol: incompatible.actualProtocol,
});
throw incompatible;
}
// A failed fetch cannot distinguish an absent App from blocked local access.
debugEvent("native", "unavailable", { stage: "discovery", timedOut: controller.signal.aborted });
return null;
} finally {
window.clearTimeout(timer);
controller.abort();
}
if (incompatible) {
debugEvent("native", "incompatible", {
expectedProtocol: NATIVE_CLIENT_PROTOCOL,
actualProtocol: incompatible.actualProtocol,
});
throw incompatible;
}
return null;
}
export async function notifyNativePresentation(
@@ -147,8 +178,8 @@ export class NativeClient {
socket.addEventListener("error", () => this.handleClose());
}
static async connect(): Promise<NativeClient | null> {
const health = await discoverNativeHealth();
static async connect(options?: NativeDiscoveryOptions): Promise<NativeClient | null> {
const health = await discoverNativeHealth(options);
if (!health) return null;
const socket = new WebSocket(`ws://127.0.0.1:${health.port}/control`, [
`${NATIVE_CLIENT_SUBPROTOCOL}.${health.instanceToken}`,
@@ -179,6 +210,7 @@ export class NativeClient {
);
});
if (!opened) {
debugEvent("native", "unavailable", { stage: "control" });
socket.close();
return null;
}
@@ -246,9 +278,14 @@ export class NativeClient {
async startShare(
input: NativeShareInput,
): Promise<{ audio: boolean; codec: NativeVideoCodec }> {
// Older Apps reject unknown command fields.
const { showCaptureBorder = false, ...shareInput } = input;
const response = await this.request(
"start-share",
input,
{
...shareInput,
...(this.health.nativeMedia.captureBorderControl ? { showCaptureBorder } : {}),
},
shareStartedResponseSchema,
input.source.kind === "picker" ? null : REQUEST_TIMEOUT_MS,
);
@@ -275,6 +312,7 @@ export class NativeClient {
source: NativeCaptureTarget,
audio: boolean,
path: NativeCapturePath,
showCaptureBorder = false,
): Promise<void> {
const response = await this.request(
"replace-share-source",
@@ -284,6 +322,7 @@ export class NativeClient {
audio,
adapterIndex: path.adapterIndex,
encoderIndex: path.encoderIndex,
...(this.health.nativeMedia.captureBorderControl ? { showCaptureBorder } : {}),
},
shareSourceReplacedResponseSchema,
source.kind === "picker" ? null : REQUEST_TIMEOUT_MS,
+2
View File
@@ -35,6 +35,8 @@ export const nativeHealthSchema = nativeDiscoveryIdentitySchema.extend({
video: z.boolean().default(false),
processAudio: z.boolean().default(false),
systemAudio: z.boolean().default(false),
// API availability, not Windows approval or observed border visibility.
captureBorderControl: z.boolean().default(false),
hardwareH264: z.boolean().default(false),
softwareVP8: z.boolean().default(false),
})
+15 -7
View File
@@ -17,11 +17,11 @@ import { PeoplePreview } from "./PeoplePreview";
import "./controls-preview.css";
const POSTER = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360"><rect width="640" height="360" fill="#e8eee6"/><rect x="100" y="60" width="440" height="240" rx="20" fill="#fffdf6"/><path d="M100 100h440" stroke="#c9d7cf" stroke-width="2"/><circle cx="128" cy="81" r="6" fill="#ed9e65"/><circle cx="320" cy="190" r="46" fill="#65b099"/><path d="m310 191 9 9 16-22" fill="none" stroke="#fffdf6" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/></svg>')}`;
const SOURCES: NativeSourceList = { kind: "ready", processAudio: true, systemAudio: true, sources: [
const SOURCES = { kind: "ready", processAudio: true, systemAudio: true, captureBorderControl: true, sources: [
{ kind: "window", sourceId: "101", pid: 1, creationTime: "1", title: "Sketchbook" },
{ kind: "window", sourceId: "102", pid: 2, creationTime: "2", title: "A short film" },
{ kind: "display", sourceId: "103", title: "Display 1" },
] };
] } satisfies NativeSourceList;
const previewSource = async () => POSTER;
export function ControlsPreview() {
@@ -36,7 +36,9 @@ export function ControlsPreview() {
const [name, setName] = useState("Piik friend");
const [invalid, setInvalid] = useState(false);
const [sourceOpen, setSourceOpen] = useState(false);
const [sourceState, setSourceState] = useState<NativeSourceList["kind"]>("ready");
const [sourceState, setSourceState] = useState<NativeSourceList["kind"] | "empty">("ready");
const [captureBorderAvailable, setCaptureBorderAvailable] = useState(true);
const [showCaptureBorder, setShowCaptureBorder] = useState(false);
const [roomCode, setRoomCode] = useState("6020");
const [feedback, setFeedback] = useState("");
const [dial, setDial] = useState("60");
@@ -128,13 +130,19 @@ export function ControlsPreview() {
<section id="source-preview" className="cp-section">
<header className="cp-section-head"><span className="cp-number">06</span><div><h2>{en ? "Choose a picture" : "挑一块画面。"}</h2>
<p>{en ? "The actual source selector, with sample windows. Selection only updates this preview." : "正式的画面选择器,放入了几个示例窗口;选择只影响这张预览。"}</p></div></header>
<div className="cp-tools">{(["ready", "loading", "unavailable", "incompatible"] as const).map((value, index) => <Chip key={value}
<div className="cp-tools">{(["ready", "loading", "unavailable", "incompatible", "unsupported", "failed", "empty"] as const).map((value, index) => <Chip key={value}
title={value} selected={sourceState === value} onClick={() => { setSourceState(value); setSourceOpen(true); }}>
{en ? ["Available", "Loading", "App unavailable", "Update needed"][index] : ["正常", "读取中", "App 未连接", "需要更新"][index]}</Chip>)}</div>
{en ? ["Available", "Loading", "App unavailable", "Update needed", "Capture unavailable", "Read failed", "Empty list"][index] : ["正常", "读取中", "App 未连接", "需要更新", "无法采集", "读取失败", "空列表"][index]}</Chip>)}
<SwitchItem checked={captureBorderAvailable} onChange={setCaptureBorderAvailable}
label={en ? "Windows border control" : "Windows 边框控制"} />
</div>
<div className="cp-stage"><StageTv hasEntry label={t("host.sourcePicker.title")}>
<img className="cp-poster" src={POSTER} alt="" />
{sourceOpen ? <CaptureSourcePicker nativeSources={sourceState === "ready" ? SOURCES : { kind: sourceState }}
onBrowser={() => { setSourceOpen(false); notify(); }} onNative={() => { setSourceOpen(false); notify(); }}
{sourceOpen ? <CaptureSourcePicker nativeSources={sourceState === "empty" ? { ...SOURCES, sources: [], captureBorderControl: captureBorderAvailable }
: sourceState === "ready" ? { ...SOURCES, captureBorderControl: captureBorderAvailable } : { kind: sourceState }}
initialShowCaptureBorder={showCaptureBorder}
onBrowser={() => { setSourceOpen(false); notify(); }}
onNative={(_target, _audio, showBorder) => { setShowCaptureBorder(showBorder); setSourceOpen(false); notify(); }}
onPreview={previewSource} onRefresh={() => setSourceState("ready")} onCancel={() => setSourceOpen(false)} />
: <div className="cp-stage-action"><Btn icon="cast" tone="primary" title="host.start" cap="host.start" hint="hint-share-start" onClick={() => setSourceOpen(true)} /></div>}
</StageTv></div>
+25 -10
View File
@@ -337,6 +337,7 @@ type ShareSourceSelection =
client: NativeClient;
target: NativeCaptureTarget;
audio: boolean;
showCaptureBorder: boolean;
path: NativeCapturePath;
};
@@ -371,6 +372,7 @@ export function HostPage({
useState<SignalConnectionState>("offline");
const [stream, setStream] = useState<MediaStream | null>(null);
const [nativeActive, setNativeActive] = useState(false);
const [showCaptureBorder, setShowCaptureBorder] = useState(false);
const [nativeSources, setNativeSources] =
useState<NativeSourceList | null>(null);
const sourcePickerReturnRef = useRef<{ id: string; restore: boolean } | null>(null);
@@ -1123,7 +1125,7 @@ export function HostPage({
shareGeneration: string,
selection: Extract<ShareSourceSelection, { kind: "native" }>,
): Promise<MediaStream | null> {
const { client, target, audio, path } = selection;
const { client, target, audio, showCaptureBorder, path } = selection;
let bridge: NativeMediaBridge | null = null;
let shareStarted = false;
try {
@@ -1135,6 +1137,7 @@ export function HostPage({
shareId: shareGeneration,
source: target,
audio,
showCaptureBorder,
adapterIndex: path.adapterIndex,
encoderIndex: path.encoderIndex,
edgeCapacity: MAX_ENDPOINT_MEDIA_CHILDREN,
@@ -1306,13 +1309,16 @@ export function HostPage({
if (nativeSourceRequestRef.current !== request) {
return;
}
if (!client) {
setNativeSources({ kind: "unavailable" });
return;
}
if (
!client ||
!client.health.nativeMedia.video ||
(!client.health.nativeMedia.hardwareH264 &&
!client.health.nativeMedia.softwareVP8)
) {
setNativeSources({ kind: "unavailable" });
setNativeSources({ kind: "unsupported" });
return;
}
try {
@@ -1320,16 +1326,18 @@ export function HostPage({
client.captureOptions(),
client.sources(),
]);
const codec = nativeModeRef.current ? videoCodecRef.current.primary : videoCodecModeRef.current;
const path = defaultNativeCapturePath(
adapters,
nativeModeRef.current ? videoCodecRef.current.primary : videoCodecModeRef.current,
codec,
client.health.nativeMedia.softwareVP8,
);
if (nativeSourceRequestRef.current !== request || nativeClientRef.current !== client) {
return;
}
if (!path) {
setNativeSources({ kind: "unavailable" });
debugEvent("native", "capture-unavailable", { codec });
setNativeSources({ kind: "unsupported" });
return;
}
nativeSourcePathRef.current = path;
@@ -1338,13 +1346,15 @@ export function HostPage({
sources,
processAudio: client.health.nativeMedia.processAudio,
systemAudio: client.health.nativeMedia.systemAudio,
captureBorderControl: client.health.nativeMedia.captureBorderControl,
});
} catch {
} catch (error) {
if (nativeSourceRequestRef.current !== request || nativeClientRef.current !== client) return;
debugError("native", "source-list-failed", error);
if (!nativeShareGenerationRef.current) {
discardNativeClient(client);
}
setNativeSources({ kind: "unavailable" });
setNativeSources({ kind: "failed" });
}
}
@@ -1430,16 +1440,18 @@ export function HostPage({
function startNativeShareFromPicker(
target: NativeCaptureTarget,
audio: boolean,
showCaptureBorder: boolean,
): void {
if (nativeSources?.kind !== "ready") return;
const client = nativeClientRef.current;
const path = nativeSourcePathRef.current;
if (!client || !path) return;
setShowCaptureBorder(showCaptureBorder);
if (phase === "live" && nativeModeRef.current) {
void switchNativeSource(client, target, audio, path);
void switchNativeSource(client, target, audio, path, showCaptureBorder);
return;
}
void startSharing({ kind: "native", client, target, audio, path });
void startSharing({ kind: "native", client, target, audio, showCaptureBorder, path });
}
function disposeNativeShare(expectedShare = nativeShareGenerationRef.current): void {
@@ -2655,6 +2667,7 @@ export function HostPage({
target: NativeCaptureTarget,
audio: boolean,
path: NativeCapturePath,
showCaptureBorder: boolean,
): Promise<void> {
const generation = activeGenerationRef.current;
const shareGeneration = nativeShareGenerationRef.current;
@@ -2686,6 +2699,7 @@ export function HostPage({
target,
audio,
path,
showCaptureBorder,
);
if (
!isCurrentGeneration(generation) ||
@@ -3315,6 +3329,7 @@ export function HostPage({
: true
}
audioLocked={nativeActive}
initialShowCaptureBorder={showCaptureBorder}
/>
) : !stream &&
(phase === "idle" || phase === "ended" || phase === "error") ? (
@@ -3652,7 +3667,7 @@ export function HostPage({
text={vis ? undefined : t(details.hasAudio ? "host.capture.hasAudio" : "host.capture.noAudio")}
tone={details.hasAudio ? "off" : "warn"}>
<button type="button" className="lr-meter-cell"
style={{ border: 0, color: "inherit", font: "inherit", textAlign: "start", cursor: "help" }}
style={{ border: 0, color: "inherit", font: "inherit", textAlign: "start" }}
aria-label={t(details.hasAudio ? "host.capture.hasAudio" : "host.capture.noAudio")}>
<Glyph name={details.hasAudio ? "speaker" : "speakerOff"} size={16} />
{!vis && <b>{t(details.hasAudio ? "host.capture.hasAudio" : "host.capture.noAudio")}</b>}
+1 -1
View File
@@ -503,7 +503,7 @@ export function ViewerPage({
const acquireNativeClient = async (): Promise<NativeClient | null> => {
if (!active || !launchedByClient || !nativeViewerAvailable) return null;
if (!nativeClientPromise) {
const connecting: Promise<NativeClient | null> = NativeClient.connect().catch(() => null).then((client) => {
const connecting: Promise<NativeClient | null> = NativeClient.connect({ waitForPermission: false }).catch(() => null).then((client) => {
if (!client || !active) {
if (nativeClientPromise === connecting) nativeClientPromise = null;
client?.close();
+16 -9
View File
@@ -621,7 +621,14 @@ body.lr-theater-open .lr-top { visibility: hidden; }
gap: 8px;
text-align: center;
}
.lr-source-picker-audio {
.lr-source-picker-options {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px 24px;
}
.lr-source-picker-option {
display: flex;
min-height: 38px;
flex: 0 0 auto;
@@ -632,18 +639,18 @@ body.lr-theater-open .lr-top { visibility: hidden; }
font-size: 12px;
font-weight: 650;
}
.lr-source-picker-audio .lr-switch {
.lr-source-picker-option .lr-switch {
width: 50px;
height: 30px;
border-color: rgba(223, 232, 242, .72);
background: rgba(255, 255, 255, .06);
}
.lr-source-picker-audio .lr-switch::after {
.lr-source-picker-option .lr-switch::after {
width: 20px;
height: 20px;
background: #dfe8f2;
}
.lr-source-picker-audio .lr-switch[aria-checked="true"]::after {
.lr-source-picker-option .lr-switch[aria-checked="true"]::after {
transform: translateX(20px);
background: #fff;
}
@@ -1167,6 +1174,7 @@ html[data-theme="dark"] .lr-invite-url { background: #1a2634; border-color: #334
font-weight: 600;
animation: lr-slide-in 240ms var(--spring);
}
.lr-pill > .lr-glyph { flex-shrink: 0; }
.lr-pill.is-bad { background: rgba(214, 69, 65, .1); border-color: var(--danger); color: #a1332f; }
.lr-pill.is-good { background: #e2f3e9; border-color: var(--live); color: #1f7a4a; }
html[data-theme="dark"] .lr-pill { background: #2e2410; }
@@ -1796,7 +1804,7 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); }
/* Stateful hover/focus panel carrying a paper Comic above its trigger. The
trigger stays focusable; when it is natively disabled, the wrapper owns the
explanation focus instead. Enter is delayed ~120ms; hover exit gets a short
explanation focus instead. Tooltip delays hover until intent; exit gets a short
crossing grace without placing invisible targets over nearby controls. Under
prefers-reduced-motion the global block collapses the transition to instant
and the comic's own RM block pins its final frame. */
@@ -1830,6 +1838,7 @@ svg[data-comic-motion="still"] .lr-person-eyes > ellipse {
}
}
.lr-comic-tip-wrap { position: relative; display: inline-flex; }
.lr-comic-tip-wrap.is-help-only > button:not(:disabled) { cursor: default; }
.lr-slider-hint { flex: 1; min-width: 0; }
.lr-comic-tip-wrap.is-disabled-trigger > :disabled { pointer-events: none; }
.lr-comic-tip {
@@ -1861,7 +1870,7 @@ svg[data-comic-motion="still"] .lr-person-eyes > ellipse {
overflow-wrap: anywhere;
text-wrap: pretty;
}
.lr-comic-tip.has-comic.is-text { width: 272px; }
.lr-comic-tip.has-comic.is-text { width: 252px; }
.lr-comic-tip > svg {
display: block;
max-width: 100%;
@@ -1910,7 +1919,7 @@ svg[data-comic-motion="still"] .lr-person-eyes > ellipse {
/* channel 1: fine-pointer hover */
@media (hover: hover) and (pointer: fine) {
.lr-comic-tip-wrap.is-hover-open .lr-comic-tip { opacity: 1; transform: translate(var(--tooltip-offset-x, 0px), 0); transition-delay: .12s; }
.lr-comic-tip-wrap.is-hover-open .lr-comic-tip { opacity: 1; transform: translate(var(--tooltip-offset-x, 0px), 0); }
.lr-comic-tip-wrap.is-hover-open .lr-comic-tip * { animation-play-state: running !important; }
/* Panel mutex: keyboard focus (channel 2) can hold one wrap's panel open
@@ -1939,7 +1948,6 @@ svg[data-comic-motion="still"] .lr-person-eyes > ellipse {
.lr-comic-tip-wrap.is-tip-open .lr-comic-tip {
opacity: 1;
transform: translate(var(--tooltip-offset-x, 0px), 0);
transition-delay: .12s;
}
.lr-comic-tip-wrap.is-focus-open .lr-comic-tip *,
.lr-comic-tip-wrap.is-tip-open .lr-comic-tip * {
@@ -1966,7 +1974,6 @@ svg[data-comic-motion="still"] .lr-person-eyes > ellipse {
.lr-group-actions .lr-comic-tip-wrap { flex: 1 1 0; min-width: 0; }
.lr-group-actions .lr-comic-tip-wrap .lr-btn { width: 100%; }
.lr-comic-tip { max-width: min(320px, 78vw); }
.lr-comic-tip svg { width: 100% !important; height: auto; }
}
.lr-comic-tip-caption {
+2
View File
@@ -119,6 +119,8 @@ export type ControlHintKind =
| "hint-refresh-sources"
| "hint-source-picker"
| "hint-no-sources"
| "hint-show-capture-border"
| "hint-hide-capture-border"
| "hint-password-show"
| "hint-password-hide"
| "hint-password-remove"
+71 -1
View File
@@ -74,7 +74,7 @@ function fixture(launchedByClient = true) {
nativeClientCloseCleanupRef: ref<(() => void) | null>(null),
sourcePickerReturnRef: ref<{ id: string; restore: boolean } | null>(null),
nativeSourceRequestRef: ref<object | null>(null), nativeSourcePathRef: ref<unknown>(null),
setNativeSources: vi.fn(), defaultNativeCapturePath: () => ({ adapterIndex: 0, encoderIndex: 0 }),
setNativeSources: vi.fn(), setShowCaptureBorder: vi.fn(), defaultNativeCapturePath: () => ({ adapterIndex: 0, encoderIndex: 0 }),
roomMutationRef: ref<object | null>(null), setRoomMutation: vi.fn(),
generationRef: ref(0), shareGenerationRef: ref<string | null>("share"), createOpaqueId: () => "share",
setCopiedInviteUrl: vi.fn(), setPhase: vi.fn(), roomInitializationRef: ref(Promise.resolve()), roomRef: ref(null),
@@ -232,6 +232,29 @@ describe("Host invite copy feedback", () => {
});
describe("Host quality ownership", () => {
it.each([false, true])("remembers the border preference and passes it to native capture: replacing %s", async (replacing) => {
const current = fixture();
current.context.phase = replacing ? "live" : "idle";
current.nativeModeRef.current = replacing;
Object.assign(current.client.health.nativeMedia, { captureBorderControl: true });
await current.openPicker();
current.context.nativeSources = current.setNativeSources.mock.calls.at(-1)![0];
const target = { kind: "display", sourceId: "2", title: "Display 1" };
current.context.startNativeShareFromPicker(target, false, true);
expect(current.setShowCaptureBorder).toHaveBeenCalledWith(true);
if (replacing) {
await vi.waitFor(() => expect(current.client.replaceShareSource).toHaveBeenCalledWith(
"share", target, false, { adapterIndex: 0, encoderIndex: 0 }, true,
));
} else {
const selection = current.startSharing.mock.calls[0]![0];
expect(selection).toMatchObject({ kind: "native", target, audio: false, showCaptureBorder: true });
current.client.startShare.mockRejectedValue(new Error("capture unavailable"));
await expect(current.context.startNativeShare(1, "share", selection)).rejects.toThrow("capture unavailable");
expect(current.client.startShare).toHaveBeenCalledWith(expect.objectContaining({ showCaptureBorder: true }));
}
});
it.each(["browser", "native"] as const)("keeps the %s selection until room work permits capture", async (kind) => {
const current = fixture();
current.context.phase = "idle";
@@ -417,6 +440,53 @@ describe("Host quality ownership", () => {
current.closePicker();
});
it("distinguishes absent App, missing capture capability, incompatible codec and an empty source list", async () => {
const absent = fixture();
absent.nativeClientRef.current = null;
await absent.openPicker();
expect(absent.setNativeSources).toHaveBeenLastCalledWith({ kind: "unavailable" });
const unsupported = fixture();
unsupported.client.health.nativeMedia.video = false;
await unsupported.openPicker();
expect(unsupported.setNativeSources).toHaveBeenLastCalledWith({ kind: "unsupported" });
expect(unsupported.client.sources).not.toHaveBeenCalled();
const codec = fixture();
codec.context.defaultNativeCapturePath = () => null;
await codec.openPicker();
expect(codec.setNativeSources).toHaveBeenLastCalledWith({ kind: "unsupported" });
const empty = fixture();
await empty.openPicker();
expect(empty.setNativeSources).toHaveBeenLastCalledWith(expect.objectContaining({ kind: "ready", sources: [] }));
});
it("reports listing failure separately and reconnects on refresh", async () => {
const current = fixture();
current.nativeShareGenerationRef.current = null;
current.client.sources.mockRejectedValueOnce(new Error("capture helper exited"));
await current.openPicker();
expect(current.setNativeSources).toHaveBeenLastCalledWith({ kind: "failed" });
expect(current.client.close).toHaveBeenCalledOnce();
const replacement = { ...current.client, close: vi.fn() };
current.NativeClient.connect.mockResolvedValue(replacement);
await current.openPicker();
expect(current.setNativeSources).toHaveBeenLastCalledWith(expect.objectContaining({ kind: "ready" }));
current.closePicker();
expect(replacement.close).toHaveBeenCalledOnce();
});
it("keeps a live Native share when source enumeration fails", async () => {
const current = fixture();
current.client.sources.mockRejectedValueOnce(new Error("list failed"));
await current.openPicker();
expect(current.setNativeSources).toHaveBeenLastCalledWith({ kind: "failed" });
expect(current.client.close).not.toHaveBeenCalled();
expect(current.client.stopShare).not.toHaveBeenCalled();
expect(current.nativeClientRef.current).toBe(current.client);
});
it("ignores a cancelled discovery mismatch after another picker owns a compatible App", async () => {
const current = fixture();
current.nativeClientRef.current = null;
+40
View File
@@ -62,6 +62,25 @@ describe("native capture source selection", () => {
).toBe("display:2");
});
it.each(["unavailable", "unsupported", "failed"] as const)("does not describe %s as an empty list or block Browser capture", (kind) => {
for (const mode of ["en", "zh", "vis"] as const) {
const lang = mode === "zh" ? "zh" : "en";
setCopy({ lang, vis: mode === "vis" });
const props = {
nativeSources: { kind }, onBrowser: () => {}, onNative: () => {},
onPreview: async () => null, onRefresh: () => {}, onCancel: () => {},
};
const nativeTab = renderToStaticMarkup(createElement(CaptureSourcePicker, props));
expect(nativeTab.replaceAll("&#x27;", "'")).toContain(t(lang, `host.sourcePicker.${kind}`));
expect(nativeTab).not.toContain(t(lang, "host.sourcePicker.empty"));
expect(nativeTab).not.toMatch(/class="lr-source-picker-refresh"[^>]*disabled=""/);
const browserTab = renderToStaticMarkup(createElement(CaptureSourcePicker, { ...props, initialTab: "browser" }));
expect(browserTab).toContain(`aria-label="${t(lang, "host.sourcePicker.browser")}"`);
expect(browserTab).not.toContain(t(lang, `host.sourcePicker.${kind}`));
expect(browserTab).not.toMatch(/class="lr-source-option is-browser"[^>]*disabled=""/);
}
});
it.each(["browser", "window"] as const)("only adds waiting copy to the pending %s source list", initialTab => {
setCopy({ lang: "en", vis: false });
const html = renderToStaticMarkup(createElement(CaptureSourcePicker, {
@@ -126,6 +145,27 @@ describe("native capture source selection", () => {
expect(html).toMatch(/class="lr-source-option(?: is-browser)?"[^>]*disabled=""/);
expect(html).not.toMatch(/class="lr-source-picker-close"[^>]*disabled=""/);
});
it.each(["en", "zh", "vis"] as const)("offers the requested border preference only for supported native capture in %s", (mode) => {
const lang = mode === "zh" ? "zh" : "en";
setCopy({ lang, vis: mode === "vis" });
const render = (supported: boolean | undefined, initialTab: "window" | "display" | "browser", initialShowCaptureBorder?: boolean) =>
renderToStaticMarkup(createElement(CaptureSourcePicker, {
nativeSources: { kind: "ready", sources: [game], processAudio: true, systemAudio: true, captureBorderControl: supported },
initialTab, initialShowCaptureBorder,
onBrowser: () => {}, onNative: () => {}, onPreview: async () => null,
onRefresh: () => {}, onCancel: () => {},
}));
const label = t(lang, "host.sourcePicker.showCaptureBorder");
for (const tab of ["window", "display"] as const) {
expect(render(true, tab)).toContain(`aria-checked="false" aria-label="${label}"`);
expect(render(true, tab, true)).toContain(`aria-checked="true" aria-label="${label}"`);
expect(render(true, tab)).toContain(`aria-description="${t(lang, "host.sourcePicker.showCaptureBorderHint")}"`);
expect(render(false, tab, true)).not.toContain(label);
expect(render(undefined, tab, true)).not.toContain(label);
}
expect(render(true, "browser", true)).not.toContain(label);
});
});
describe("native adapter selection", () => {
+174 -5
View File
@@ -18,6 +18,7 @@ import {
} from "../src/client/native/wire";
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
@@ -30,6 +31,62 @@ describe("native App private wire", () => {
nativeMedia: { video: true, hardwareH264: true },
};
it.each(["prompt", "denied"] as const)("does not hold Viewer reception for %s permission and observes a later grant", async (state) => {
vi.stubGlobal("window", { setTimeout, clearTimeout, location: { hostname: "piik.example" } });
const query = vi.fn().mockResolvedValue({ state });
vi.stubGlobal("navigator", { permissions: { query } });
const fetcher = vi.fn(async () => new Response(JSON.stringify(health)));
vi.stubGlobal("fetch", fetcher);
const socket = vi.fn();
vi.stubGlobal("WebSocket", socket);
await expect(NativeClient.connect({ waitForPermission: false })).resolves.toBeNull();
expect(fetcher).not.toHaveBeenCalled();
expect(socket).not.toHaveBeenCalled();
query.mockResolvedValue({ state: "granted" });
await expect(discoverNativeHealth({ waitForPermission: false })).resolves.toMatchObject({ port: health.port });
});
it("still lets explicit source discovery ask for local permission", async () => {
vi.stubGlobal("window", { setTimeout, clearTimeout, location: { hostname: "piik.example" } });
const query = vi.fn().mockResolvedValue({ state: "prompt" });
vi.stubGlobal("navigator", { permissions: { query } });
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify(health))));
await expect(discoverNativeHealth()).resolves.toMatchObject({ port: health.port });
expect(query).not.toHaveBeenCalled();
});
it.each(["localhost", "127.0.0.1", "[::1]"])("keeps App Local reception on %s available without cross-address-space consent", async (hostname) => {
vi.stubGlobal("window", { setTimeout, clearTimeout, location: { hostname } });
const query = vi.fn().mockResolvedValue({ state: "prompt" });
vi.stubGlobal("navigator", { permissions: { query } });
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify(health))));
await expect(discoverNativeHealth({ waitForPermission: false })).resolves.toMatchObject({ port: health.port });
expect(query).not.toHaveBeenCalled();
});
it("uses the older combined permission when split permission is unsupported", async () => {
vi.stubGlobal("window", { setTimeout, clearTimeout, location: { hostname: "piik.example" } });
const query = vi.fn().mockRejectedValueOnce(new TypeError("Unsupported permission"))
.mockResolvedValue({ state: "prompt" });
vi.stubGlobal("navigator", { permissions: { query } });
const fetcher = vi.fn();
vi.stubGlobal("fetch", fetcher);
await expect(discoverNativeHealth({ waitForPermission: false })).resolves.toBeNull();
expect(query).toHaveBeenLastCalledWith({ name: "local-network-access" });
expect(fetcher).not.toHaveBeenCalled();
});
it.each([undefined, { query: vi.fn().mockRejectedValue(new TypeError("Unsupported permission")) }])(
"retains ordinary discovery when local permissions cannot be queried",
async (permissions) => {
vi.stubGlobal("window", { setTimeout, clearTimeout, location: { hostname: "piik.example" } });
vi.stubGlobal("navigator", { permissions });
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify(health))));
await expect(discoverNativeHealth({ waitForPermission: false })).resolves.toMatchObject({ port: health.port });
},
);
it.each([NATIVE_CLIENT_PROTOCOL - 1, NATIVE_CLIENT_PROTOCOL + 1])(
"reports observed Piik App protocol %s without opening control or presentation",
async (protocol) => {
@@ -63,7 +120,63 @@ describe("native App private wire", () => {
await expect(discoverNativeHealth()).resolves.toMatchObject({
protocol: NATIVE_CLIENT_PROTOCOL, port: NATIVE_CLIENT_PORT_START + 1,
});
expect(fetcher).toHaveBeenCalledTimes(2);
expect(fetcher).toHaveBeenCalledTimes(NATIVE_CLIENT_PORT_END - NATIVE_CLIENT_PORT_START + 1);
});
it("keeps discovery alive while local access takes longer than 400 ms", async () => {
vi.useFakeTimers();
vi.stubGlobal("window", { setTimeout, clearTimeout });
vi.stubGlobal("fetch", vi.fn(async (url: string, options: RequestInit) => {
if (new URL(url).port !== String(health.port)) throw new TypeError("No listener");
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, 1_500);
options.signal!.addEventListener("abort", () => {
clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
}, { once: true });
});
return new Response(JSON.stringify(health));
}));
const discovery = discoverNativeHealth();
await vi.advanceTimersByTimeAsync(1_500);
await expect(discovery).resolves.toMatchObject({ port: health.port });
expect(vi.getTimerCount()).toBe(0);
});
it("finds a later App without waiting for a silent port and aborts the unused probe", async () => {
vi.stubGlobal("window", { setTimeout, clearTimeout });
let silentSignal: AbortSignal | undefined;
vi.stubGlobal("fetch", vi.fn(async (url: string, options: RequestInit) => {
const port = Number(new URL(url).port);
if (port === health.port) {
silentSignal = options.signal!;
return new Promise<Response>((_resolve, reject) => {
silentSignal!.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
});
}
if (port !== health.port + 1) throw new TypeError("No listener");
return new Response(JSON.stringify({ ...health, port }));
}));
await expect(discoverNativeHealth()).resolves.toMatchObject({ port: health.port + 1 });
expect(silentSignal?.aborted).toBe(true);
});
it("bounds all silent discovery ports with one deadline", async () => {
vi.useFakeTimers();
vi.stubGlobal("window", { setTimeout, clearTimeout });
const signals: AbortSignal[] = [];
vi.stubGlobal("fetch", vi.fn(async (_url: string, options: RequestInit) => {
signals.push(options.signal!);
return new Promise<Response>((_resolve, reject) => {
options.signal!.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
});
}));
const discovery = discoverNativeHealth();
await vi.advanceTimersByTimeAsync(8_000);
await expect(discovery).resolves.toBeNull();
expect(signals).toHaveLength(NATIVE_CLIENT_PORT_END - NATIVE_CLIENT_PORT_START + 1);
expect(signals.every((signal) => signal.aborted)).toBe(true);
expect(vi.getTimerCount()).toBe(0);
});
it("identifies an incompatible App even when that protocol uses different token or media metadata", async () => {
@@ -113,7 +226,7 @@ describe("native App private wire", () => {
const staleNotification = notifyNativePresentation("zh", stale.signal);
stale.abort();
await staleNotification;
expect(fetcher).toHaveBeenCalledOnce();
expect(fetcher).toHaveBeenCalledTimes(NATIVE_CLIENT_PORT_END - NATIVE_CLIENT_PORT_START + 1);
expect(fetcher).toHaveBeenCalledWith("http://127.0.0.1:39721/health", expect.anything());
const current = new AbortController();
await notifyNativePresentation("vis", current.signal);
@@ -242,18 +355,74 @@ describe("native App private wire", () => {
...health, futureDescription: "ignored", nativeMedia: { video: true, futureFeature: true },
})).toEqual({
...health, nativeMedia: {
video: true, processAudio: false, systemAudio: false, hardwareH264: false, softwareVP8: false,
video: true, processAudio: false, systemAudio: false, captureBorderControl: false, hardwareH264: false, softwareVP8: false,
},
});
expect(nativeHealthSchema.parse({ ...health, nativeMedia: undefined }).nativeMedia)
.toEqual({ video: false, processAudio: false, systemAudio: false, hardwareH264: false, softwareVP8: false });
.toEqual({ video: false, processAudio: false, systemAudio: false, captureBorderControl: false, hardwareH264: false, softwareVP8: false });
for (const invalid of [
{ protocol: 0 }, { protocol: 9.5 }, { protocol: Number.MAX_SAFE_INTEGER + 1 },
{ service: "other" }, { port: NATIVE_CLIENT_PORT_END + 1 }, { instanceToken: "short" },
{ nativeMedia: { softwareVP8: "true" } }, { nativeMedia: null },
{ nativeMedia: { softwareVP8: "true" } }, { nativeMedia: { captureBorderControl: "true" } }, { nativeMedia: null },
]) expect(nativeHealthSchema.safeParse({ ...health, ...invalid }).success).toBe(false);
});
it.each([undefined, false, true])("gates capture-border commands on advertised support: %s", async (supported) => {
const requests: Record<string, unknown>[] = [];
class CaptureSocket extends EventTarget {
static readonly OPEN = 1;
static readonly CLOSING = 2;
readyState = CaptureSocket.OPEN;
protocol = `piik-client-v9.${health.instanceToken}`;
constructor() {
super();
queueMicrotask(() => this.dispatchEvent(new Event("open")));
}
close() { this.readyState = CaptureSocket.CLOSING; }
send(payload: string) {
const request = JSON.parse(payload) as Record<string, unknown>;
requests.push(request);
queueMicrotask(() => this.dispatchEvent(new MessageEvent("message", {
data: JSON.stringify({
version: NATIVE_CLIENT_PROTOCOL, id: request.id,
...(request.type === "hello" ? { type: "ready" } : {
type: request.type === "start-share" ? "share-started" : "share-source-replaced",
shareId: request.shareId,
...(request.type === "start-share" ? { audio: true, codec: "h264" } : {}),
}),
}),
})));
}
}
vi.stubGlobal("WebSocket", CaptureSocket);
vi.stubGlobal("window", { setTimeout, clearTimeout });
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({
...health, nativeMedia: { ...health.nativeMedia, captureBorderControl: supported },
}))));
const client = await NativeClient.connect();
expect(client).not.toBeNull();
expect(client!.health.nativeMedia.captureBorderControl).toBe(supported ?? false);
const input = {
shareId: "share_123456", source: { kind: "display" as const, sourceId: "2", title: "Display 1" },
audio: true, adapterIndex: 0, encoderIndex: 0, edgeCapacity: 2,
profile: DEFAULT_QUALITY_SETTINGS, codec: "auto" as const,
};
try {
for (const showCaptureBorder of [undefined, false, true]) {
await client!.startShare({ ...input, showCaptureBorder });
await client!.replaceShareSource(input.shareId, input.source, input.audio, input, showCaptureBorder);
const extension = supported ? { showCaptureBorder: showCaptureBorder ?? false } : {};
expect(requests.at(-2)).toEqual({
version: NATIVE_CLIENT_PROTOCOL, id: expect.any(String), type: "start-share", ...input, ...extension,
});
expect(requests.at(-1)).toEqual({
version: NATIVE_CLIENT_PROTOCOL, id: expect.any(String), type: "replace-share-source",
shareId: input.shareId, source: input.source, audio: true, adapterIndex: 0, encoderIndex: 0, ...extension,
});
}
} finally { client!.close(); }
});
it("keeps 64-bit Windows identities as exact decimal strings", () => {
expect(
nativeCaptureTargetSchema.safeParse({
+9 -2
View File
@@ -9,7 +9,7 @@ const repository = "TNTcraftHIM/Piik", image = `piik-candidate:${revision}`;
const tag = `ghcr.io/tntcrafthim/piik:${version}`;
type Scenario = { existing?: boolean; draft?: boolean; badArchive?: boolean;
wrongSource?: boolean; wrongImage?: boolean; remoteMismatch?: boolean;
registryError?: boolean; newerRelease?: boolean };
registryError?: boolean; newerRelease?: boolean; largeCommit?: boolean };
async function publish(options: Scenario = {}) {
vi.resetModules();
@@ -17,7 +17,10 @@ async function publish(options: Scenario = {}) {
command.mockReset().mockImplementation((program: string, args: string[]) => {
if (program === "gh") {
if (args[1].endsWith("/commits/" + version)) {
return JSON.stringify({ sha: options.wrongSource ? "c".repeat(40) : revision });
const sha = options.wrongSource ? "c".repeat(40) : revision;
if (args.includes("Accept: application/vnd.github.sha")) return sha;
if (options.largeCommit) throw Object.assign(new Error("spawnSync gh ENOBUFS"), { code: "ENOBUFS" });
return JSON.stringify({ sha });
}
if (args[1].endsWith("/releases/latest")) {
return JSON.stringify({ tag_name: options.newerRelease ? "v1.3.0" : version });
@@ -67,4 +70,8 @@ describe("container publication", () => {
await publish({ existing: true, newerRelease: true });
expect(pushes()).toEqual([]);
});
it("checks tag identity without buffering a large commit's file patches", async () => {
await publish({ largeCommit: true });
expect(pushes()).toEqual([tag, "ghcr.io/tntcrafthim/piik:latest"]);
});
});
+1
View File
@@ -91,6 +91,7 @@ describe("release automation", () => {
for (const path of ["site/film/art.js", "site/docs/package.json", "site/docs/package-lock.json",
"site/docs/.vitepress/config.mjs", "docs/guide/troubleshooting.md", "docs/deployment.md", "README.zh-CN.md",
"scripts/build-website.mjs", "scripts/check-docs.mjs", "scripts/markdown-slug.mjs", "scripts/release-notes.mjs",
"scripts/publish-container.mjs",
"scripts/app-gate-endpoint.ts", "scripts/client-gate-endpoint.ts",
"tests/room.test.ts", ".agents/skills/ponytail/SKILL.md", ".github/workflows/website.yml"]) {
mkdirSync(dirname(join(root, path)), { recursive: true });
@@ -56,6 +56,7 @@ it.each([false, true])("retries an absent or rejected App on the next offer: rej
if (reject) current.connect.mockRejectedValueOnce(new Error("App unavailable"));
await expect(current.acquire()).resolves.toBeNull();
expect(current.connect).toHaveBeenCalledOnce();
expect(current.connect).toHaveBeenCalledWith({ waitForPermission: false });
const client = control();
current.connect.mockResolvedValue(client);
await expect(current.acquire()).resolves.toBe(client);