feat: improve feedback and App lifecycle reliability (#404)

Waiting feedback and tooltips now share consistent presentation lifecycles, while App shutdown and native-media operation failures stay within their owning session or output. This phase also consolidates the project standards and publishes searchable English/Chinese reader documentation alongside the homepage.

Validation: repository hygiene and Markdown links; TypeScript, 971 Web tests and production build; website/documentation build; Go tests, vet and Windows/Linux builds with Windows capture checks; desktop/mobile UI and documentation review. Linux race, container and three-platform packaging checks run in the release pipeline. Physical macOS/Linux capture and broader network coverage remain outside this acceptance.

The owner approved v1.4.0 publication. Existing empty-source, Windows URL-handler and public-invitation timeout reports remain tracked; this release makes no claim to resolve those device/network reports. The Windows capture-border request is next-phase work.

## Release notes

### 中文

这是一次以稳定性和交互一致性为重点的功能更新。等待与提示更有反馈,App 启动、原生分享和连接恢复的边界处理得到改善,使用与部署指南也有了统一的文档中心。

#### 新增与改进

- 欢迎语、等待短句和六类可轮换的标签页标题共用一致的显示规则。等待短句从一开始就出现,并在停留时轮换;明确的错误与操作提示保持固定。
- 房间准入等漫画采用更克制的小人物与循环动效。改进 tooltip 的定位与指向,减少对播放器的遮挡;状态、图标和文案保持相同含义。
- 新增中英文文档中心,提供搜索、手机目录、使用教程、问题排查与自部署指南。官网 FAQ 聚焦开始使用前的问题,详细排查统一进入文档中心。

#### 修复

- 使用 `--local` 启动不再清空已保存的 Piik 站点地址。
- 打开系统浏览器不再阻塞 App 的退出流程;改进临时公网邀请、终端和诊断导出在关闭时的清理及错误反馈。
- 单条原生媒体连接的操作失败不再被误判为整个会话的协议错误;重复停止分享保持幂等。
- 修复连接替换、转发停止和异常退出时的部分资源清理问题,避免迟到结果影响当前连接。
- Windows 原生采集中,单个输出的编码错误只结束对应输出;共享采集源本身的错误仍会结束分享。

#### 升级与已知边界

本次保持现有协议版本与配置格式。更新 App 或自托管 Server 后,重新打开页面即可使用对应的新界面。

个别设备的空窗口列表、Windows 浏览器启动异常,以及 Linux 编码输出的故障隔离仍在跟进。macOS/Linux 的物理采集和更广泛的公网网络场景仍需实机验证;本次不宣称这些问题已经解决,也不承诺所有网络都能直连。

### English

This feature update focuses on stability and consistent feedback. Waiting states are easier to follow, App startup and native sharing handle several failure cases more reliably, and the new documentation center brings usage and deployment guides together.

#### New and improved

- Welcome lines, waiting captions and six groups of rotating tab titles follow one presentation lifecycle. Waiting captions appear immediately and rotate while visible; actionable errors and instructions remain fixed.
- Room-admission comics use restrained characters and looping motion. Tooltips point more accurately to their controls and interfere less with the player, with consistent meanings across illustrations, icons and copy.
- A new English/Chinese documentation center includes search, mobile navigation, usage guides, troubleshooting and self-hosting. The homepage FAQ covers questions before getting started and links to the documentation for detailed help.

#### Fixes

- Starting with `--local` no longer clears a saved Piik site address.
- Opening the system browser no longer blocks App shutdown. Temporary public invitations, the terminal and diagnostic exports handle cleanup and exit errors more consistently.
- An operation failure on one native media connection no longer becomes a protocol failure for the entire session. Repeated stop-sharing requests are safe.
- Fixed cleanup cases during connection replacement, relay shutdown and unexpected exits, preventing late results from affecting the current connection.
- On Windows, an encoder failure for one native output stops that output only. A failure of the shared capture source still ends sharing.

#### Updating and known limits

Protocol versions and configuration formats are unchanged. After updating the App or your self-hosted Server, reopen the page to use its updated interface.

Empty source lists on some devices, Windows browser-launch failures and Linux output-failure isolation remain under investigation. Physical macOS/Linux capture and broader public-network coverage still need device testing; this update does not claim to resolve those cases or guarantee a direct connection on every network.
This commit is contained in:
AAA authored and GitHub committed 2026-09-16 13:13:06 +08:00
1 parent 799d6efb4a
commit cdd503a98d
236 files changed
+8834 -2449

No files matched your search

@@ -0,0 +1,84 @@
---
name: context-consistency
description: >
Review repeated meanings across Piik code, UI, copy, lifecycle and documentation.
Use for cross-surface or cross-layer consistency work, recurring fixes in sibling
paths, and requested repository-wide standardization audits. Locate common
ownership gaps while preserving differences justified by context.
---
# Context Consistency
Apply the project's [contextual contract](../../../docs/standards/engineering.md#contextual-consistency).
[AGENTS.md](../../../AGENTS.md) owns authority and
[CONTRIBUTING.md](../../../CONTRIBUTING.md) owns delivery. This skill is a review
method, not a second product specification or permission to change behavior.
## Scope And Sources
Read the user's latest accepted decisions and the relevant entries in the
[owner map](../../../docs/standards/documentation.md#owners). Follow their product, design,
engineering or operational owners; do not load every reference for a scoped fix.
Treat desired rules, current implementation and verified behavior separately.
An undocumented preference or an old audit is not an accepted contract.
For a scoped change, follow the affected semantic family and its actual callers.
For a requested repository-wide audit, first inventory standards and reachable
surfaces across Browser/App/Server, shared interfaces, website/demos, tooling
and guides. Record examined and unexamined areas so a large finding count cannot
stand in for coverage. Review-only work must not alter product sources.
## Trace Before Comparing
Follow a concrete producer through state/normalization, effects, consumers and
cleanup. Record the subject, asserted fact or requested outcome, authority,
scope/generation, lifetime and user intent. Use a small comparison table only
when there are multiple variants to compare.
Ask whether an apparent inconsistency is:
- one meaning implemented or documented differently;
- different meanings accidentally using one label, flag or representation;
- a missing, conflicting or bypassed shared rule;
- an intentional adaptation supported by capability, authority or user context.
Compare timing as well as steady appearance: initial state, update, replacement,
failure/recovery and retirement. For async work, check A replaced by B before
A's completion or cleanup. For presentation, check actual state against copy,
icon, comic, mascot, motion and announcement, including simultaneous surfaces.
Do not infer progress from decoration, availability from configured capability,
or media readiness from signaling alone.
## Correct The Owning Boundary
When fixes are authorized, choose the smallest coherent correction at the
existing owner and update its reachable consumers. Share implementation where
the semantics and lifetime match; use narrow adapters for real runtime or
presentation differences. A similar name or repeated syntax is not sufficient
reason to merge states or introduce a generic manager.
If the intended semantics are unresolved, put that decision in
[TODO](../../../docs/todo.md) and continue independent checks. Do not turn a
proposal into a mandatory rule merely by adding it to a table.
Verify the changed producer-to-consumer boundary with existing previews, tests
or bounded runtime checks. Check that the correction preserves justified
differences and removes obsolete writers, mappings or timers. Stop broadening
verification after relevant checks pass unless new evidence warrants it.
## Deliver Useful Findings
Group occurrences by their root ownership or contract issue. Give each confirmed
finding its intended rule, reachable trigger, evidence, affected scope, smallest
correction and regression risk. Label unverified suspicions and design choices
separately. Do not claim a repository-wide pass from static searches or one view.
Use the existing owner map as the index of standards. Consolidating files must
preserve ownership, inbound links, tool-discovered paths and contributor entry
points; test those references after moves. Keep architectural rationale, user
guides and temporary audit evidence distinct from normative rules.
Absorb accepted lessons into their existing owner, keep remaining work in TODO,
and leave completed history in Git/PRs. Raw inventories, screenshots and audit
reports remain ignored working artifacts. Do not add a parallel permanent audit
ledger, scoring system, new dependency or extra approval flow.
+2
View File
@@ -8,6 +8,8 @@
<!-- Required for product-changing PRs; omit this section for standalone website/docs/maintenance work. Public copy, automatically published in GitHub Releases. Keep this exact heading; use ### for subsections. Describe user benefits, fixes and required upgrade actions in concise Chinese and English. Omit internal logs, audit prompts and unverified claims. 产品改动需填写公开的中英文更新说明;仅官网、文档或外围维护可删去此节。 -->
<!-- Start each language with a short introduction: what kind of update this is, its emphasis and who benefits. Then group relevant highlights/fixes/upgrade notes; omit empty headings. Use ### for languages and #### for categories. An internal refactor is not automatically a breaking change. Review the final release as one coherent description, including any earlier unpublished changes. 中英文分别先介绍本次更新的性质和重点,再按需分组写新功能、修复与升级事项;别用提交日志代替版本介绍。 -->
## Verification / 验证
<!-- List exact local checks and results, relevant environments, and material untested cases. 列出检查命令、结果与未验证项。 -->
+25 -25
View File
@@ -6,8 +6,8 @@ on:
- main
workflow_dispatch:
inputs:
client_checks:
description: Run the optional Piik App checks
app_checks:
description: Build and verify App and Server candidates
required: false
default: false
type: boolean
@@ -60,8 +60,8 @@ jobs:
env:
PIIK_RELEASE_VERSION: ${{ steps.release.outputs.version }}
# The Go build embeds internal/server/webassets/dist, so Vite runs first.
- name: Build the Browser client
run: npm run build:client
- name: Build the Web UI
run: npm run build:web
env:
PIIK_BUILD_VERSION: ${{ steps.release.outputs.version }}
VITE_PIIK_VERSION: ${{ steps.release.outputs.version }}
@@ -72,9 +72,9 @@ jobs:
go-version: 1.26.6
cache-dependency-path: go.sum
- name: Check Go core
run: npm run check:client-core
run: npm run check:go-core
- name: Check NAT and media ownership with the race detector
run: npm run check:client-race
run: npm run check:go-race
- name: Type-check and test
run: npm run typecheck && npm test
env:
@@ -82,11 +82,11 @@ jobs:
VITE_PIIK_VERSION: ${{ steps.release.outputs.version }}
VITE_PIIK_REVISION: ${{ github.sha }}
release-application:
release-server:
needs: validate
if: >-
needs.validate.outputs.publish == 'true' ||
(github.event_name == 'workflow_dispatch' && inputs.client_checks == true)
(github.event_name == 'workflow_dispatch' && inputs.app_checks == true)
runs-on: ubuntu-latest
timeout-minutes: 15
env:
@@ -106,9 +106,9 @@ jobs:
cache-dependency-path: go.sum
- name: Install dependencies
run: npm ci
- name: Package application release
- name: Package Server release
run: >-
node scripts/package-app-release.mjs "$RUNNER_TEMP/app-release"
node scripts/package-server-release.mjs "$RUNNER_TEMP/server-release"
--container-image "piik-candidate:$GITHUB_SHA"
- name: Check the container and deployment recipe
run: npm run check:container -- "piik-candidate:$GITHUB_SHA" "$GITHUB_SHA"
@@ -121,21 +121,21 @@ jobs:
path: ${{ runner.temp }}/piik-container.tar
if-no-files-found: error
retention-days: 14
- name: Upload application release
- name: Upload Server release
uses: actions/upload-artifact@v7
with:
name: piik-server-${{ github.sha }}
path: ${{ runner.temp }}/app-release/*
path: ${{ runner.temp }}/server-release/*
if-no-files-found: error
retention-days: 14
release-clients:
release-apps:
needs:
- validate
- release-application
- release-server
if: >-
needs.validate.outputs.publish == 'true' ||
(github.event_name == 'workflow_dispatch' && inputs.client_checks == true)
(github.event_name == 'workflow_dispatch' && inputs.app_checks == true)
strategy:
fail-fast: false
matrix:
@@ -163,7 +163,7 @@ jobs:
with:
go-version: 1.26.6
cache-dependency-path: go.sum
# scripts/assemble-client.mjs runs `npm run build:client` for the Web
# scripts/assemble-app.mjs runs `npm run build:web` for the Web
# assets the App binary embeds, so Vite and the license inputs must be
# installed here too.
- name: Install dependencies
@@ -180,22 +180,22 @@ jobs:
gstreamer1.0-pipewire
gstreamer1.0-plugins-bad
gstreamer1.0-pulseaudio
- name: Download application release
- name: Download Server release
uses: actions/download-artifact@v8
with:
name: piik-server-${{ github.sha }}
path: ${{ runner.temp }}/app-release
path: ${{ runner.temp }}/server-release
- name: Build and verify App candidate
run: >-
node scripts/package-client-candidate.mjs
"${{ runner.temp }}/app-release"
node scripts/package-app-candidate.mjs
"${{ runner.temp }}/server-release"
"${{ matrix.target }}"
"${{ runner.temp }}/client-release"
"${{ runner.temp }}/app-release"
- name: Upload App release candidate
uses: actions/upload-artifact@v7
with:
name: piik-app-${{ matrix.target }}-${{ github.sha }}
path: ${{ runner.temp }}/client-release/*
path: ${{ runner.temp }}/app-release/*
if-no-files-found: error
compression-level: 0
retention-days: 14
@@ -203,8 +203,8 @@ jobs:
publish:
needs:
- validate
- release-application
- release-clients
- release-server
- release-apps
if: needs.validate.outputs.publish == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
@@ -223,7 +223,7 @@ jobs:
cache: npm
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Download application release
- name: Download Server release
uses: actions/download-artifact@v8
with:
name: piik-server-${{ github.sha }}
+4
View File
@@ -38,7 +38,11 @@ jobs:
with:
node-version-file: .node-version
cache: npm
cache-dependency-path: |
package-lock.json
site/docs/package-lock.json
- run: npm ci
- run: npm ci --prefix site/docs
- run: npm run build:website
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v4
+15 -8
View File
@@ -26,10 +26,10 @@
- Piik is private screen sharing for one Host and up to 20 authenticated
Viewers, with Web Host/Viewer/relay as the current product surface.
- Current product truth is split by owner:
[rooms/access](./docs/product/rooms-access.md),
[routing/transport](./docs/product/routing-transport.md),
[media quality](./docs/product/media-quality.md), and
[presentation/lifecycle](./docs/product/presentation-lifecycle.md).
[rooms/access](./docs/standards/rooms-access.md),
[routing/transport](./docs/standards/routing-transport.md),
[media quality](./docs/standards/media-quality.md), and
[presentation/lifecycle](./docs/standards/presentation-lifecycle.md).
- Media stays automatic and P2P-first; central services provide room authority,
signaling, STUN, observability, and bounded embedded SFU/UDP fallback using
mature LiveKit media components under [ADR-0013](./docs/adr/0013-embedded-node-local-media.md). Route
@@ -45,7 +45,8 @@
[status](./docs/status.md), [TODO](./docs/todo.md), and only the relevant
product module, ADR, research, or operations document. Then inspect branch,
HEAD, status, staged diff, and worktrees.
- Use the [documentation owner map](./docs/maintenance.md#owners): product
- Use the [standards index](./docs/standards/README.md) and
[documentation owner map](./docs/standards/documentation.md#owners): product
behavior, engineering, visual language, versions, evidence and operations each
have one owner. TODO is the only work ledger; Git/PRs own completed history.
- Give every durable fact one owner. Update memory or status only when its compact
@@ -80,14 +81,20 @@
## Engineering Defaults
- UI changes must follow the shared [visual language](./docs/design/visual-language.md):
- Repeated meanings across code, UI, copy, configuration and lifecycle follow
one owned [contextual contract](./docs/standards/engineering.md#contextual-consistency).
Check related producers and consumers before adding another local rule;
preserve differences justified by authority, scope or user intent.
Use the [consistency review skill](./.agents/skills/context-consistency/SKILL.md)
for cross-cutting consistency work; ordinary changes check the affected family.
- UI changes must follow the shared [visual language](./docs/standards/visual-language.md):
cast, semantic colours, result panels and motion have one owner. Extend the
existing preview when adding a new meaning; do not invent scene-local rules.
- Follow [engineering and interface boundaries](./docs/reference/engineering.md):
- Follow [engineering and interface boundaries](./docs/standards/engineering.md):
simple cohesive modules, clear state/resource owners, mature reuse and narrow
contracts. After a material module, remove additions that the accepted result
does not need; total complexity matters more than marginal performance gains.
- [Versioning](./docs/reference/versioning.md) separates build identity, wire,
- [Versioning](./docs/standards/versioning.md) separates build identity, wire,
storage and live generations, and owns the public compatibility design.
The declared public release is the compatibility baseline for subsequent work.
- Use primary sources for non-trivial design and bugs. Add focused tests in
+17 -12
View File
@@ -48,7 +48,7 @@ fix.
2. Update only the single durable owner for changed semantics and any materially changed current snapshot. If semantics remain disputed, record a hold and stop dependent work.
3. Implement and run repository hygiene, `npm run check`, the relevant browser/network gates, and review in proportion to the whole acceptance boundary.
4. Keep accepted truth checkpoints and dependent candidates on branches until the phase is complete. Rebase or rebuild a retained candidate from exact `main` only when starting a new phase, preserving main's owning truth on conflicts and transplanting only approved scoped code, tests, and new facts.
5. Open one pull request for the complete phase, resolve required review/checks, meet the [release approval boundary](./docs/reference/versioning.md#automatic-publication) before a publishing merge, squash-merge it once, deploy that merged revision when authorized, perform scoped postflight, then audit references and reparse safety before cleanup.
5. Open one pull request for the complete phase, resolve required review/checks, meet the [release approval boundary](./docs/standards/versioning.md#automatic-publication) before a publishing merge, squash-merge it once, deploy that merged revision when authorized, perform scoped postflight, then audit references and reparse safety before cleanup.
One coherent phase should leave one meaningful squash commit on `main`. Include
its source, tests, owned semantic documentation, and materially changed status
@@ -64,7 +64,7 @@ history without explicit approval.
For deployment work, a routine application-only release verifies a new immutable artifact, switches to it atomically, and guarantees the pre-cutover application release only through bounded health and postflight checks; it has no retention contract afterward and is not a maintained backup. Define recovery only for the infrastructure, configuration, secrets, persistent state, or irreversible surfaces the task actually touches, before changing them.
Use the [version/compatibility policy](./docs/reference/versioning.md) to classify
Use the [version/compatibility policy](./docs/standards/versioning.md) to classify
release changes. An incompatible signaling cutover forces old pages to reload
and ends Browser capture; follow the public compatibility promise and include
that interruption in the accepted cutover. Compatible UI/internal work does not
@@ -74,10 +74,10 @@ Clean up worktrees and branches only after semantic review and integration are c
## Research Standard
The [engineering reference](./docs/reference/engineering.md) owns module and
The [engineering reference](./docs/standards/engineering.md) owns module and
interface discipline, including the post-change ablation pass.
Follow the [naming convention](./docs/reference/naming.md) for product copy,
Follow the [naming convention](./docs/standards/naming.md) for product copy,
commands, packages and code ownership. Keep display names separate from stable
protocol identifiers; use normal language-specific identifier conventions.
@@ -96,14 +96,18 @@ Confirm runner inclusion when adding or moving a test; a file's presence alone
does not establish coverage.
- `npm run check` owns deterministic Web type-check, unit, and build acceptance.
- `npm run check:client` owns Go formatting, unit tests, vet, Windows/Linux
- `npm run check:go` owns Go formatting, unit tests, vet, Windows/Linux
builds, Darwin builds on macOS, and the current platform's capture compile/probe.
The server core is Go, so its acceptance runs here. It builds the Vite client first when
The server core is Go, so its acceptance runs here. It builds the Web UI first when
that output is missing, because both binaries embed it. CI invokes these same
package commands rather than rebuilding their steps in YAML.
- `npm run check:client-race` checks port mapping, media-edge ownership and their
- `npm run check:go-race` checks port mapping, media-edge ownership and their
scoped NAT dependencies with Go's race detector. It requires a supported cgo
toolchain; CI runs it on Linux.
- `npm run check:native` runs only the current platform's capture compile/probe
and deterministic native regressions. The Linux App packaging job includes
its headless GStreamer output-profile and retirement test; this does not
establish physical screen/audio capture on that runner.
- `npm run check:container -- <local-image> <full-SHA>` checks the Compose recipe
against a packaged linux/amd64 image: embedded Web, P2P/STUN, optional SFU
startup, room persistence across recreation, diagnostics and clean shutdown.
@@ -112,15 +116,16 @@ does not establish coverage.
- `gate:*` commands are explicit physical or network acceptance. They must use
isolated profiles, bounded deadlines, shared cleanup helpers, and a structured
result. A manual diagnostic may locate a failure, but is not retained as pass
evidence.
- On Windows, execute Client/Server and Go test binaries only from stable
evidence. Existing `PIIK_CLIENT_*` gate environment names retain their current
contract; renaming the tools does not change those overrides.
- On Windows, execute App/Server and Go test binaries only from stable
project build paths. Go's temporary compiler/cache files are not firewall
identities; do not run network tests with a changing temporary executable
path. Keep browser profiles isolated and consider the active firewall when
diagnosing connection failures rather than changing global firewall rules.
- CI, versioning and release automation belong to complete, accepted squash
merges into `main`; ordinary branch pushes and PRs stay quiet. The
[versioning policy](./docs/reference/versioning.md) owns version selection,
[versioning policy](./docs/standards/versioning.md) owns version selection,
publication and activation state. Package the merged SHA without writing
version-record commits back to `main`.
@@ -138,7 +143,7 @@ commits should follow the same form where useful; no history rewrite is needed
just to rename commits that will be squashed. Add `!` before the colon for a
breaking change, such as `feat(protocol)!: replace the join handshake`, and
explain its effect in the body. The final squash title and body supply release
intent; the [versioning policy](./docs/reference/versioning.md) owns version
intent; the [versioning policy](./docs/standards/versioning.md) owns version
selection and publication behavior.
Name branches `<type>/<short-kebab-case-description>`, using the same types;
@@ -153,7 +158,7 @@ Before a product-changing integration PR is accepted, fill its exact `## Release
with concise public copy in Chinese and English: user-visible changes, fixes and
required upgrade actions. Use `###` for subsections. Review this text as product
copy; it is published automatically from the squash commit, without PR discussion
or verification logs. The [release-note policy](./docs/reference/versioning.md#release-notes)
or verification logs. The [release-note policy](./docs/standards/versioning.md#release-notes)
owns aggregation and preview. Do not add a changelog file or release-note archive.
Standalone website, documentation and maintenance phases do not need product
release notes; their public description stays in the PR.
+4 -4
View File
@@ -6,7 +6,7 @@
<a href="https://github.com/TNTcraftHIM/Piik/releases">Download</a> ·
<a href="https://gitee.com/TNTcraftHIM/Piik/releases">Gitee mirror</a> ·
<a href="https://demo.piik.tv">Use online</a> ·
<a href="./docs/README.md">Documentation</a>
<a href="https://piik.tv/docs/?lang=en">Documentation</a>
</p>
<p align="center">
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-36564f?style=flat-square" alt="License: MIT"></a>
@@ -57,7 +57,7 @@ you can also create a room in the online version.
| --- | --- |
| [Use Piik online](https://demo.piik.tv) | Open it in a desktop browser to create a room and share. |
| [Download Piik App](https://piik.tv/#download) | Extract and open the App, choose **Public invite**, then **Open Piik** to create a temporary room. |
| Use an existing Piik site | Open the address your friend or administrator provides. [Hosting your own](./docs/operations/self-hosting.md) is an advanced option. |
| [Host your own site](./docs/operations/self-hosting.md) | Advanced: deploy Piik Server on a Linux x64 server with your own domain, then connect through Piik App or a browser. |
<p align="center"><img src="./docs/assets/quickstart.svg" width="640" alt="Choose a screen, send an invitation, watch together."></p>
@@ -99,14 +99,14 @@ Open `http://localhost:8787` to try it locally. For a public site, configure you
domain, HTTPS reverse proxy and STUN address. Room data is stored in SQLite.
For containers, use the [Docker Compose setup](./docs/operations/self-hosting.md#docker-compose).
[**Deploy your own site →**](./docs/operations/self-hosting.md) · [Configuration](./docs/reference/configuration.md) · [Run from source](./docs/README.md#run-from-source)
[**Deploy your own site →**](./docs/operations/self-hosting.md) · [Configuration](./docs/standards/configuration.md) · [Run from source](./docs/README.md#run-from-source)
## Contributing
Bug reports, translations, documentation improvements and pull requests are welcome.
See [ways to contribute](./CONTRIBUTING.md#ways-to-help) or
[help translate Piik](./docs/guide/translating.md). Use the
[repository layout](./docs/reference/engineering.md#repository-layout) to find your way around.
[repository layout](./docs/standards/engineering.md#repository-layout) to find your way around.
## License
+10 -10
View File
@@ -6,7 +6,7 @@
<a href="https://github.com/TNTcraftHIM/Piik/releases">下载</a> ·
<a href="https://gitee.com/TNTcraftHIM/Piik/releases">Gitee 镜像</a> ·
<a href="https://demo.piik.tv">在线版</a> ·
<a href="./docs/README.md">文档</a>
<a href="https://piik.tv/docs/zh/">文档中心</a>
</p>
<p align="center">
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-36564f?style=flat-square" alt="MIT 许可"></a>
@@ -30,7 +30,7 @@ Piik 是一个免费开源的屏幕共享工具,适合游戏围观、一起看
## 功能
- **观看无须安装。** 朋友通过邀请链接,用电脑或手机浏览器加入。
- **用客户端或已有 Piik 站点分享。** 支持屏幕、窗口和浏览器标签页;可选来源与音频能力取决于平台。
- **用 Piik App 或已有 Piik 站点分享。** 支持屏幕、窗口和浏览器标签页;可选来源与音频能力取决于平台。
- **优先直接连接。** 画面优先在设备之间传输(P2P),自建站点可启用媒体转发(SFU)自动兜底。
- **房间由你管理。** 支持邀请链接、房间号和可选的房间密码,一位房主、最多 20 位观众。
- **灵活的观看方式。** 支持深浅主题、播放控制、画中画和连接拓扑查看。
@@ -53,8 +53,8 @@ Piik 是一个免费开源的屏幕共享工具,适合游戏围观、一起看
| 从这里开始 | 需要做什么 |
| --- | --- |
| [使用在线版](https://demo.piik.tv) | 在电脑浏览器打开,直接创建房间并分享。 |
| [下载 Piik App](https://piik.tv/?lang=zh-CN#download) | 解压并启动客户端,选择**公网邀请**,点击**进入 Piik**,创建临时房间。 |
| 使用已有 Piik 站点 | 打开朋友或管理员提供的地址。[自行部署](./docs/operations/self-hosting.zh-CN.md)属于进阶操作。 |
| [下载 Piik App](https://piik.tv/?lang=zh-CN#download) | 解压并启动 Piik App,选择**公网邀请**,点击**进入 Piik**,创建临时房间。 |
| [部署自己的站点](./docs/operations/self-hosting.zh-CN.md) | 进阶使用:在 Linux x64 服务器上部署 Piik Server、配置自己的域名,再用 Piik App 或浏览器连接。 |
<p align="center"><img src="./docs/assets/quickstart.svg" width="640" alt="选择画面,发送邀请,一起观看。"></p>
@@ -63,7 +63,7 @@ Piik 是一个免费开源的屏幕共享工具,适合游戏围观、一起看
Piik 默认跟随系统语言,中文系统显示简体中文,其他语言显示英文。
顶部的 **中 / EN / ✦** 可切换中英文或体验纯视觉模式。
客户端提供本地房间、临时公网邀请和连接已有站点三种模式,分享期间需保持 App 运行。
Piik App 提供本地房间、临时公网邀请和连接已有站点三种模式,分享期间需保持 App 运行。
下载以 **`piik-app`** 开头的 ZIP 压缩包,再按文件名中的平台标识选择:
@@ -75,9 +75,9 @@ Piik 默认跟随系统语言,中文系统显示简体中文,其他语言显
[**完整使用教程 →**](./docs/guide/getting-started.zh-CN.md) · [**打开在线版 →**](https://demo.piik.tv)
浏览器采集需要 HTTPS 或 `localhost`。目前主要测试 Windows 客户端和桌面浏览器分享。
macOS 和 Linux 客户端尚未经过实机测试,欢迎有设备的朋友试用并[反馈问题](https://github.com/TNTcraftHIM/Piik/issues)。
客户端公网邀请和在线版使用纯 P2P 连接,
浏览器采集需要 HTTPS 或 `localhost`。目前主要测试 Windows 版 Piik App 和桌面浏览器分享。
macOS 和 Linux 版 Piik App 尚未经过实机测试,欢迎有设备的朋友试用并[反馈问题](https://github.com/TNTcraftHIM/Piik/issues)。
Piik App 的公网邀请和在线版使用纯 P2P 连接,
在受限网络下可能无法连通。
## 自行部署
@@ -93,14 +93,14 @@ Piik Server 是一个内置网页界面的独立程序。
再配置域名、HTTPS 反向代理和 STUN 地址。房间数据保存在 SQLite 中。
也可以使用 [Docker Compose 部署](./docs/operations/self-hosting.zh-CN.md#使用-docker-compose)。
[**部署自己的站点 →**](./docs/operations/self-hosting.zh-CN.md) · [配置参考](./docs/reference/configuration.md) · [从源码运行](./docs/README.md#run-from-source)
[**部署自己的站点 →**](./docs/operations/self-hosting.zh-CN.md) · [配置参考](./docs/standards/configuration.md) · [从源码运行](./docs/README.md#run-from-source)
## 参与贡献
欢迎反馈问题、参与翻译、改进文档或贡献代码。
从[社区参与方式](./CONTRIBUTING.md#ways-to-help)或
[翻译教程](./docs/guide/translating.zh-CN.md)开始,也可以从
[项目目录说明](./docs/reference/engineering.md#repository-layout)了解代码结构。
[项目目录说明](./docs/standards/engineering.md#repository-layout)了解代码结构。
## 许可
+52 -86
View File
@@ -30,24 +30,20 @@ publication are separate steps in [deployment](../../docs/deployment.md).
- The launcher remembers the last mode confirmed with **Open Piik** and the saved
Site address. Without a saved mode, it selects Site when an address is saved,
otherwise Public invite; it waits for confirmation before starting.
[App configuration](../../docs/reference/configuration.md#piik-app-configuration)
owns the saved settings. The App RPC starts before this choice and accepts the saved Site
alongside the Local Host origin. An App-opened Site remembers the opt-in in that Browser
origin; later manually opened Host pages may reuse the running App.
The mode preference is stored beside the settings in `client.json.mode`;
[App configuration](../../docs/standards/configuration.md#piik-app-configuration)
covers saved preferences and custom paths.
- The `--site`, `--local`, and `--link` flags select a mode for CI and development.
- The default launcher checks official releases after it opens, using GitHub
first and Gitee if GitHub is unavailable. It opens the matching platform ZIP
when available, falling back to the release page. It does not install or replace the App.
Local mode uses memory-only rooms, P2P relay, no SFU listener, and no public
discovery. Ordinary Local works on a reachable LAN. The **Public invite** mode
runs the packaged Cloudflare Tunnel sidecar for the existing HTTP/WebSocket
control surface and
uses one ordinary public STUN destination plus two bounded public survey
destinations. Browser and Native edges share the same prediction rule; Native
STUN, ICE checks, and media use one Pion UDP mux. Media does not
travel through the HTTP tunnel, and a difficult media path still has no SFU or
TURN fallback.
Local rooms last for this App run and work on a reachable LAN. **Public invite**
uses the packaged Cloudflare Tunnel helper to expose that same room service at a
temporary HTTPS address. Picture and sound travel between participants (P2P),
outside the tunnel. Both modes need a usable UDP media path and provide no
media-server forwarding (SFU) or TURN relay fallback. The
[routing reference](../../docs/standards/routing-transport.md) explains connection setup.
For Local invitations, the App automatically selects a sole active address or
sole private IPv4. If several addresses remain possible, choose an interface
@@ -56,44 +52,27 @@ When bypassing the launcher with `--local`, use `--lan-address <address>` to
resolve an ambiguous choice. The selected address is checked again at startup;
it affects Local invitation links, while ICE selects media paths independently.
The system Browser remains the Host UI. An App-launched Host offers the
Browser's standard capture picker and a list of exact platform capture targets;
the user selects one explicitly. Windows offers the same VP8/Auto/H264 selector:
VP8 uses the pinned WebRTC/libvpx encoder and H264 uses hardware Media Foundation
inside WebRTC's output pipeline. Auto measures target-profile encoding work
before choosing one codec for the share. Windows uses
Graphics Capture and WASAPI; macOS
uses ScreenCaptureKit, VideoToolbox, and AudioToolbox; Linux delegates selection
to the ScreenCast Portal and uses the system PipeWire/GStreamer hardware path.
Video and audio share the same room route and PeerConnection. Native capture
starts with the Host's
current resolution, frame-rate, video/audio bitrate, and quality preference;
live changes replace only the capture/encoder generation behind those stable
connections. A Native edge with public STUN also attempts one bounded PCP,
UPnP, or NAT-PMP mapping for its Pion UDP socket; pure LAN does not. Routers
without a mapping service continue with ordinary ICE/STUN. The mapping does not
create a relay or carry media through the App control link. A configured Site
may receive a direct Native publication from the shared encoded source;
Local and one-link modes remain P2P-only. An ordinary Web Host keeps the
Browser capture path without probing the App.
Site mode uses the configured Site's rooms and any enabled SFU fallback.
An App-opened Site remembers native activation, so later pages there may reuse
the running App. Browser capture remains available without the App; see the
[entry workflow](../../docs/standards/presentation-lifecycle.md#product-surface).
Native P2P edges and embedded SFU use one shared Pion/LiveKit media adapter for
feedback, forwarding allocation, bounded pacing and recovery. Native parents
reuse suitable H.264/VP8 outputs and derive a missing lower output only for
direct-child demand. Compatible children share that output; each edge receives
only its selected representation. SFU publication combines its requested output
prefix with one aggregate upstream budget. A lower-output constraint does not
replace the original input or higher sibling outputs. The route controller owns
persistent quality evidence and route replacement. See
[media quality](../../docs/product/media-quality.md) for the implemented behavior
and [status](../../docs/status.md) for its acceptance limits.
The Host page offers Browser capture and available native windows or screens;
select the source explicitly. Windows native capture offers VP8/Auto/H264;
Auto chooses one codec for the share. macOS and Linux native capture require
hardware H264. Source and audio support vary by platform; their setup is covered
in the [Windows](../../native/capture/windows/README.md),
[macOS](../../native/capture/darwin/README.md) and
[Linux](../../native/capture/linux/README.md) capture guides.
Native capture uses the Host's current quality settings. The
[media-quality reference](../../docs/standards/media-quality.md) owns codec selection,
live changes and encoding reuse; [verification status](../../docs/verification-status.md)
keeps the measured limits.
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. When present, the App passes it to its own
Host page in a URL fragment; the page uses the existing SiteAccess endpoint and
removes the fragment before continuing. Viewer invitations keep using the
existing room-scoped grant.
starting. Viewer invitations grant access to their room independently; see
[rooms and access](../../docs/standards/rooms-access.md).
The terminal shows the current mode, entry links and startup state; its language
follows the launcher and App-enabled pages.
@@ -141,7 +120,7 @@ Use `--debug` for failures before the selector opens. Exporting does not stop
the share or upload the archive. For Browser diagnostics, click the **Debug**
chip icon after the theme control, confirm the reload, then use **Web report** to download.
For cooperation failures, include both reports from the same reproduction. The
[diagnostic reference](../../docs/reference/configuration.md#diagnostics) owns
[diagnostic reference](../../docs/standards/configuration.md#diagnostics) owns
log locations, export commands, retention and privacy boundaries.
## Development
@@ -151,7 +130,7 @@ The binary embeds the Browser assets, so build them from the repository root fir
```sh
npm ci
npm run build:client
npm run build:web
```
On Linux or macOS:
@@ -175,11 +154,11 @@ use `--capture-process` / `--tunnel-process` to select built helpers, or follow
The repository-level entry used locally and by CI is:
```sh
npm run check:client
npm run check:go
```
On Windows, its generated App, capture, and media-test executables are
written to the ignored repository `build/client-check` directory and reused on
written to the ignored repository `build/go-check` directory and reused on
the next run. This keeps the executable identity stable for the system firewall;
the files are local build output and are never packaged or committed.
@@ -194,26 +173,17 @@ IDR; Linux probes the Portal/PipeWire/GStreamer adapter. Real capture, GPU
attribution, Browser decode, and public-network paths remain explicit physical
gates rather than environment-dependent unit tests.
The loopback service binds IPv4 loopback on the first available port from
`39721` through `39730`. `/health` discovers the current process; `/control`
admits at most two independent strict v9 control sessions. After `hello`, each
session may list local capture choices, own one generation-fenced Host share,
or receive one native Viewer source and its bounded encoded child edges. Closing
one session retires only its resources. Its public `instanceToken`
distinguishes the discovered process but is not authentication. The shared Go
service owns room authority; the Browser owns the room protocol client and
orchestrates native media through this control session. Viewer receive/relay remains
available even when this machine has no accepted native capture encoder.
Capture sidecars must match the App's current probe/encoded-output contract;
the package-candidate wrapper validates that version before accepting its artifact.
App and Hosted share the Go room service. The Browser UI coordinates native
capture and Viewer receive/relay through the App's local control service;
Viewer receive/relay remains available without a supported native capture encoder.
Use the [module map](../../docs/standards/engineering.md#module-map) and
[contract map](../../docs/standards/engineering.md#contract-map) to find the source owners.
The candidate packager checks that capture helpers match the App's contract.
Packaged builds report the product version and source SHA in the terminal and
diagnostic context. The launcher uses that identity to distinguish a newer
version, a different build of the same version, and an official release offered
to a development build; the link never installs or interrupts a share.
[Versioning](../../docs/reference/versioning.md)
owns these meanings and [GitHub operations](../../docs/operations/github.md)
owns automatic publication.
diagnostics. [Versioning](../../docs/standards/versioning.md) explains build identity
and update notices; [GitHub operations](../../docs/operations/github.md)
describes automatic publication.
## Packaging
@@ -222,8 +192,8 @@ candidate wrapper on its native operating system. The wrapper builds capture,
verifies the pinned public-link helper, and assembles and checks the App:
```sh
node scripts/package-app-release.mjs /outside/repository/app-release
node scripts/package-client-candidate.mjs /outside/repository/app-release windows-amd64 /outside/repository/client-candidate
node scripts/package-server-release.mjs /outside/repository/app-release
node scripts/package-app-candidate.mjs /outside/repository/app-release windows-amd64 /outside/repository/app-candidate
```
Supported targets are `windows-amd64`, `linux-amd64`, and `darwin-arm64`.
@@ -248,7 +218,7 @@ runtime/tunnel/cloudflared[.exe] # packages that support --link
The executable embeds the Browser assets of the consumed application release and
carries that same full Git revision as the package `REVISION`. App/Site
interoperability follows the
[public compatibility contract](../../docs/reference/versioning.md#public-compatibility-promise).
[public compatibility contract](../../docs/standards/versioning.md#public-compatibility-promise).
The Windows App embeds the shared Piik mark through the
`cmd/piik-app/piik_windows_amd64.syso` resource; the platform
@@ -258,11 +228,8 @@ hicolor icon. macOS packages include a thin `Piik App.app` launcher
with an ICNS resource; the raw Go executable remains available beside it.
For a native Host smoke run, launch the App and select a window in the Host
page. The App never guesses among multiple targets. The page still creates
the room and sends the current SDP/ICE through the selected authority. A
configured Site supplies its normal Internet routing and any enabled SFU fallback.
Without a Site, the **Public invite** choice exposes the Local control surface while
media remains P2P-only.
page, then open its invitation on another device. Choose the mode for that
network as described [above](#modes).
## Gates
@@ -277,22 +244,22 @@ PIIK_CLIENT_LOCAL_GATE=true \
CHROME_PATH=/path/to/chrome \
PIIK_CLIENT_EXE=/path/to/piik-app \
PIIK_CLIENT_GATE_LAN_ADDRESS=192.168.1.10 \
npm run gate:client-local
npm run gate:app-local
PIIK_CLIENT_LOOPBACK_GATE=true \
CHROME_PATH=/path/to/chrome \
PIIK_CLIENT_EXE=/path/to/piik-app \
npm run probe:client-loopback
npm run probe:app-loopback
PIIK_CLIENT_MEDIA_GATE=true \
CHROME_PATH=/path/to/chrome \
PIIK_CLIENT_GATE_STUN_URLS=stun:<stun-host>:3478 \
npm run gate:client-media
npm run gate:app-media
PIIK_CLIENT_NATIVE_HOST_GATE=true \
CHROME_PATH=/path/to/chrome \
PIIK_GO=/path/to/go \
npm run gate:client-native-host
npm run gate:app-native-host
PIIK_CLIENT_NATIVE_HOST_GATE=true \
PIIK_CLIENT_CROSS_NAT_GATE=true \
@@ -302,7 +269,7 @@ PIIK_REMOTE_HOST=<public-test-host> \
PIIK_REMOTE_USER=<ssh-user> \
PIIK_REMOTE_SSH_KEY=/path/to/key \
PIIK_CLIENT_GATE_STUN_URLS=stun:<stun-host>:3478 \
npm run gate:client-native-host
npm run gate:app-native-host
PIIK_CLIENT_NATIVE_HOST_GATE=true \
PIIK_CLIENT_LINK_MEDIA_GATE=true \
@@ -312,7 +279,7 @@ PIIK_CLOUDFLARED=/path/to/cloudflared \
PIIK_REMOTE_HOST=<public-test-host> \
PIIK_REMOTE_USER=<ssh-user> \
PIIK_REMOTE_SSH_KEY=/path/to/key \
npm run gate:client-native-host
npm run gate:app-native-host
PIIK_CLIENT_LINK_GATE=true \
PIIK_GO=/path/to/go \
@@ -320,11 +287,10 @@ PIIK_CLOUDFLARED=/path/to/cloudflared \
PIIK_REMOTE_HOST=<public-test-host> \
PIIK_REMOTE_USER=<ssh-user> \
PIIK_REMOTE_SSH_KEY=/path/to/key \
npm run gate:client-link
npm run gate:app-link
```
The loopback health response reports video, process-audio, system-audio, and
hardware H.264 availability separately. The Windows media gate proves one hardware-H.264
The Windows media gate proves one hardware-H.264
capture generation, shared Pion source, Browser decode, PLI recovery, and STUN
candidate gathering. The native Host gate proves room creation and native video
delivery through the current route; native audio is included when the capability
+43 -63
View File
@@ -14,7 +14,7 @@ Windows 运行 `piik-app.exe`,Linux 运行 `./piik-app`,macOS 打开 `Piik A
网页没有自动打开时,可手动打开终端中显示的地址,或在终端按 **O** 重试。
使用网页期间,请保持 App 运行。
目前主要测试 Windows 客户端和浏览器分享。macOS 与 Linux 客户端尚未经过实机测试,
目前主要测试 Windows 版 Piik App 和浏览器分享。macOS 与 Linux 版 Piik App 尚未经过实机测试,
欢迎试用并[反馈结果](https://github.com/TNTcraftHIM/Piik/issues)。macOS 原生采集需要
Apple 芯片及 macOS 13 或更新版本。构建程序包与公开发布 Release 是两个独立步骤,
详见[部署与发布](../../docs/deployment.md)。
@@ -25,51 +25,38 @@ Apple 芯片及 macOS 13 或更新版本。构建程序包与公开发布 Releas
或已保存的站点后,进入通常的房主页面。
- 启动页会记住上次点击 **进入 Piik** 确认的模式,以及保存的站点地址。
没有已保存的模式时,有站点地址就默认选中站点,否则选中公网邀请;确认后才启动。
保存项的规则见 [App 配置](../../docs/reference/configuration.md#piik-app-configuration)。
App RPC 在选择前就已启动,同时接受已保存站点和本地房主来源的访问。
通过 App 打开的站点会在该浏览器来源下记住启用 App 的选择,之后手动打开的房主页面
可以继续使用正在运行的 App。
模式偏好保存在配置旁的 `client.json.mode` 中;保存项与自定义路径见
[App 配置](../../docs/standards/configuration.md#piik-app-configuration)。
- `--site`、`--local` 和 `--link` 用于在 CI 或开发时直接选择模式。
- 默认启动页打开后会检查官方发布,优先使用 GitHub,GitHub 不可用时使用 Gitee。
有对应平台的 ZIP 时直接打开下载地址,否则打开发布页。App 不会自行安装或替换程序。
本地模式的房间只保存在内存中,使用 P2P 中继,不启动 SFU 监听器,也不使用公网发现服务。
普通本地模式适用于设备间可互相访问的局域网。**公网邀请** 模式运行程序包内的
Cloudflare Tunnel 辅助进程,仅暴露已有的 HTTP/WebSocket 控制服务;同时使用一个普通公网
STUN 地址和两个用于有限范围 NAT 探测的公网地址。浏览器与原生连接使用相同的预测规则;
原生 STUN、ICE 检查和媒体共用一个 Pion UDP mux。媒体不会经过 HTTP 隧道,
难以连通的媒体路径也没有 SFU 或 TURN 兜底。
本地房间仅在本次 App 运行期间有效,适用于设备间可互相访问的局域网。
**公网邀请** 通过程序包中的 Cloudflare Tunnel 辅助程序,为同一个房间服务提供临时 HTTPS 地址。
画面和声音在参与者之间传输(P2P),不经过隧道。这两种模式都需要可用的 UDP 媒体路径,
没有媒体服务器转发(SFU)或 TURN 中继兜底。连接机制见
[路由参考](../../docs/standards/routing-transport.md)。
本地邀请会自动选择唯一的活动地址或唯一的私有 IPv4 地址。仍有多个可能地址时,
请在启动页选择网卡和 IP。公网邀请和站点模式无需选择局域网地址。
通过 `--local` 跳过启动页时,可用 `--lan-address <address>` 指定地址来解决多地址歧义。
App 会在启动时再次检查所选地址;它只影响本地邀请链接,媒体路径仍由 ICE 独立选择。
系统浏览器始终是房主操作界面。通过 App 启动的房主页面同时提供浏览器标准采集弹窗,
以及平台上的具体采集目标列表,由用户明确选择。Windows 同样提供 VP8/Auto/H264 选择:
VP8 使用固定版本的 WebRTC/libvpx 编码器,H264 在 WebRTC 输出管线内使用硬件
Media Foundation。Auto 会测量目标画质配置下的编码工作量,再为本次分享选定一种编码。
Windows 使用 Graphics Capture 与 WASAPI;macOS 使用 ScreenCaptureKit、VideoToolbox
和 AudioToolbox;Linux 通过 ScreenCast Portal 选择来源,使用系统 PipeWire/GStreamer
硬件路径。
站点模式使用所配置站点的房间,以及该站点已启用的 SFU 兜底。
通过 App 打开的站点会记住原生功能的启用选择,之后打开该站点的页面可继续使用正在运行的 App。
没有 App 时仍可使用浏览器采集,详见[进入流程](../../docs/standards/presentation-lifecycle.md#product-surface)。
视频与音频共用房间路由和 PeerConnection。原生采集按房主当前的分辨率、帧率、
视频与音频码率及画质偏好启动;分享中修改设置只替换这些稳定连接后的采集与编码代次。
使用公网 STUN 的原生连接还会为其 Pion UDP socket 尝试一次有界的 PCP、UPnP 或 NAT-PMP
端口映射,纯局域网模式不会执行。路由器没有映射服务时,继续使用普通 ICE/STUN。
映射不会建立中继,也不会让媒体经过 App 控制连接。配置的站点可以从共享编码源直接接收
原生发布;本地与公网邀请模式仍只使用 P2P。普通网页房主保留浏览器采集路径,不主动探测 App。
原生 P2P 连接与内嵌 SFU 共用一套 Pion/LiveKit 媒体适配层,负责反馈、转发分配、
有界发送节奏和恢复。原生父节点复用合适的 H.264/VP8 输出,仅在直接子节点需要时生成
缺少的较低输出。兼容的子节点共用该输出,每条连接只接收为它选定的表示。
SFU 发布将其请求的输出前缀与一个聚合上行预算结合使用。较低输出的限制不会替换原始输入,
也不会替换其他同级连接使用的较高输出。路由控制器负责持续质量证据与路由替换。
具体行为见[媒体质量](../../docs/product/media-quality.md),验收边界见[当前状态](../../docs/status.md)。
房主页面提供浏览器采集及可用的原生窗口或屏幕,请明确选择来源。
Windows 原生采集提供 VP8/Auto/H264,Auto 会为本次分享选定一种编码。
macOS 和 Linux 原生采集需要硬件 H264。可选来源与音频支持取决于平台,设置要求见
[Windows](../../native/capture/windows/README.md)、[macOS](../../native/capture/darwin/README.md)
和 [Linux](../../native/capture/linux/README.md) 采集指南。
原生采集使用房主当前的画质设置;编码选择、分享中修改设置与编码复用由
[媒体质量参考](../../docs/standards/media-quality.md)说明,实测边界见
[验证状态](../../docs/verification-status.md)。
App 配置保存可选的本地站点口令。留空表示本地站点开放进入;需要口令时,在启动页填写后再启动。
设置口令后,App 通过 URL fragment 将它交给自己的房主页面,页面调用已有的 SiteAccess
接口,并在继续前移除 fragment。观众邀请仍使用既有的房间级授权。
观众邀请独立授予对应房间的访问权,详见[房间与访问](../../docs/standards/rooms-access.md)。
终端显示当前模式、入口链接与启动状态,语言跟随启动页和已启用 App 的页面。
按 `o` 重新打开浏览器,按 `q` 或 Ctrl+C 结束本地房间,停止本地服务与临时公网链接。
@@ -108,7 +95,7 @@ App 在独立 Windows 控制台中启动时,启动或运行错误会保留在
启动页出现前就失败时,使用 `--debug`。导出不会停止分享或上传文件。
浏览器诊断通过主题按钮后的 **诊断** 芯片图标开启:确认重新加载后,使用 **网页报告** 下载。
调查浏览器与 App 配合的问题时,请提供同一次复现的两份报告。
[诊断参考](../../docs/reference/configuration.md#diagnostics)说明日志位置、导出命令、保留规则与隐私边界。
[诊断参考](../../docs/standards/configuration.md#diagnostics)说明日志位置、导出命令、保留规则与隐私边界。
## 开发
@@ -117,7 +104,7 @@ Node/npm/Go 版本见[从源码运行](../../docs/README.md#run-from-source)。
```sh
npm ci
npm run build:client
npm run build:web
```
Linux 或 macOS:
@@ -140,10 +127,10 @@ go build -o build/dev/piik-app.exe ./cmd/piik-app
本地与 CI 共用的仓库级检查入口是:
```sh
npm run check:client
npm run check:go
```
在 Windows 上,该入口将 App、采集及媒体测试程序写入已忽略的仓库 `build/client-check`
在 Windows 上,该入口将 App、采集及媒体测试程序写入已忽略的仓库 `build/go-check`
目录,并在下次运行时复用路径,保持系统防火墙识别的可执行文件身份稳定。
这些文件是本地构建输出,不会打包或提交。
@@ -155,19 +142,14 @@ Darwin App 和 peer gate 仅在安装 SDK 的 macOS 上启用 cgo 构建;其
IDR;Linux 会探测 Portal/PipeWire/GStreamer 适配层。真实采集、GPU 归因、浏览器解码
和公网路径由明确的实机验证负责,不作为依赖环境的单元测试。
loopback 服务在 IPv4 回环地址的 `39721` 到 `39730` 中绑定第一个可用端口。
`/health` 用于发现当前进程;`/control` 最多接纳两个相互独立、使用严格 v9 协议的控制会话。
完成 `hello` 后,每个会话可以列出本地采集选项、管理一个按代次隔离的房主分享,
或接收一个原生观众来源及其数量受限的编码子连接。关闭会话只回收该会话的资源。
公开的 `instanceToken` 用于区分发现的进程,不是身份认证凭据。
共享 Go 服务负责房间权威状态,浏览器负责房间协议客户端,并通过控制会话协调原生媒体。
即使本机没有可用的原生采集编码器,观众接收与中继仍可使用。
采集辅助程序必须匹配 App 当前的探测与编码输出协议;候选打包脚本会先验证版本,再接受产物。
App 与托管站点共用 Go 房间服务。浏览器界面通过 App 的本地控制服务协调原生采集,
以及观众接收与中继;即使本机没有可用的原生采集编码器,观众接收与中继仍可使用。
源码职责见[模块地图](../../docs/standards/engineering.md#module-map)和
[接口地图](../../docs/standards/engineering.md#contract-map)。候选打包脚本会检查采集辅助程序
是否匹配 App 的协议。
打包版本会在终端和诊断上下文中报告产品版本与源码 SHA。启动页用它们区分更新版本、
同版本的不同构建,以及向开发构建提供的正式发布;更新链接不会安装程序或打断分享。
这些含义由[版本规则](../../docs/reference/versioning.md)管理,自动发布由
[GitHub 运维](../../docs/operations/github.md)说明。
打包版本会在终端和诊断中报告产品版本与源码 SHA。[版本规则](../../docs/standards/versioning.md)
说明构建身份与更新提示,[GitHub 运维](../../docs/operations/github.md)说明自动发布。
## 打包
@@ -175,8 +157,8 @@ loopback 服务在 IPv4 回环地址的 `39721` 到 `39730` 中绑定第一个
脚本会构建采集程序、验证固定版本的公网链接辅助程序,并组装和检查 App:
```sh
node scripts/package-app-release.mjs /outside/repository/app-release
node scripts/package-client-candidate.mjs /outside/repository/app-release windows-amd64 /outside/repository/client-candidate
node scripts/package-server-release.mjs /outside/repository/app-release
node scripts/package-app-candidate.mjs /outside/repository/app-release windows-amd64 /outside/repository/app-candidate
```
支持的目标为 `windows-amd64`、`linux-amd64` 和 `darwin-arm64`,请替换命令中的目标名称。
@@ -196,16 +178,15 @@ runtime/tunnel/cloudflared[.exe] # 支持 --link 的程序包
```
App 内嵌所用应用发布产物的浏览器资源,程序内的完整 Git 版本与包内 `REVISION` 一致。
App 与站点之间的互操作遵循[公开兼容规则](../../docs/reference/versioning.md#public-compatibility-promise)。
App 与站点之间的互操作遵循[公开兼容规则](../../docs/standards/versioning.md#public-compatibility-promise)。
Windows App 通过 `cmd/piik-app/piik_windows_amd64.syso` 资源内嵌共享的 Piik 标识;
平台后缀使该 Windows 资源不参与 Linux 和 macOS 构建。
Linux 程序包包含标准的 `share/applications` 桌面入口与 hicolor 图标。
macOS 程序包包含一个带 ICNS 资源的轻量 `Piik App.app` 启动器,原始 Go 程序仍保留在它旁边。
执行原生房主冒烟检查时,启动 App 并在房主页面选择一个窗口;有多个目标时,App 不会代替用户猜选。
页面仍通过选定的房间权威服务创建房间、发送当前 SDP/ICE。配置好的站点提供通常的公网路由,
以及已启用的 SFU 兜底。没有站点时,**公网邀请** 会暴露本地控制服务,媒体仍只使用 P2P。
执行原生房主冒烟检查时,启动 App,在房主页面选择一个窗口,再用另一台设备打开邀请。
按[运行模式](#运行模式)说明选择适合该网络的模式。
## 验证入口
@@ -219,22 +200,22 @@ PIIK_CLIENT_LOCAL_GATE=true \
CHROME_PATH=/path/to/chrome \
PIIK_CLIENT_EXE=/path/to/piik-app \
PIIK_CLIENT_GATE_LAN_ADDRESS=192.168.1.10 \
npm run gate:client-local
npm run gate:app-local
PIIK_CLIENT_LOOPBACK_GATE=true \
CHROME_PATH=/path/to/chrome \
PIIK_CLIENT_EXE=/path/to/piik-app \
npm run probe:client-loopback
npm run probe:app-loopback
PIIK_CLIENT_MEDIA_GATE=true \
CHROME_PATH=/path/to/chrome \
PIIK_CLIENT_GATE_STUN_URLS=stun:<stun-host>:3478 \
npm run gate:client-media
npm run gate:app-media
PIIK_CLIENT_NATIVE_HOST_GATE=true \
CHROME_PATH=/path/to/chrome \
PIIK_GO=/path/to/go \
npm run gate:client-native-host
npm run gate:app-native-host
PIIK_CLIENT_NATIVE_HOST_GATE=true \
PIIK_CLIENT_CROSS_NAT_GATE=true \
@@ -244,7 +225,7 @@ PIIK_REMOTE_HOST=<public-test-host> \
PIIK_REMOTE_USER=<ssh-user> \
PIIK_REMOTE_SSH_KEY=/path/to/key \
PIIK_CLIENT_GATE_STUN_URLS=stun:<stun-host>:3478 \
npm run gate:client-native-host
npm run gate:app-native-host
PIIK_CLIENT_NATIVE_HOST_GATE=true \
PIIK_CLIENT_LINK_MEDIA_GATE=true \
@@ -254,7 +235,7 @@ PIIK_CLOUDFLARED=/path/to/cloudflared \
PIIK_REMOTE_HOST=<public-test-host> \
PIIK_REMOTE_USER=<ssh-user> \
PIIK_REMOTE_SSH_KEY=/path/to/key \
npm run gate:client-native-host
npm run gate:app-native-host
PIIK_CLIENT_LINK_GATE=true \
PIIK_GO=/path/to/go \
@@ -262,10 +243,9 @@ PIIK_CLOUDFLARED=/path/to/cloudflared \
PIIK_REMOTE_HOST=<public-test-host> \
PIIK_REMOTE_USER=<ssh-user> \
PIIK_REMOTE_SSH_KEY=/path/to/key \
npm run gate:client-link
npm run gate:app-link
```
loopback 健康响应分别报告视频、进程音频、系统音频和硬件 H.264 是否可用。
Windows media gate 验证一个硬件 H.264 采集代次、共享 Pion 来源、浏览器解码、PLI 恢复和
STUN 候选收集。native Host gate 验证房间创建及当前路由上的原生视频传输;
能力探测与目标系统支持时,也包含原生音频。
File renamed without changes.
+23 -17
View File
@@ -2,6 +2,10 @@
[About Piik](../README.md) · [中文介绍](../README.zh-CN.md) · [中文上手指南](./guide/getting-started.zh-CN.md)
For using Piik, troubleshooting or self-hosting, start with the
[reader documentation](./guide/README.md) · [中文文档中心](./guide/README.zh-CN.md).
This page also indexes developer and maintainer references.
## Guides And References
| Task | Guide |
@@ -9,15 +13,17 @@
| Use Piik online | [Open the online site](https://demo.piik.tv) · [Online sharing guide](./guide/getting-started.md#use-piik-online) |
| Watch a friend or share my first screen | [Getting started](./guide/getting-started.md) |
| Choose an App mode or fix sound and connection trouble | [First-use help](./guide/getting-started.md#choose-an-app-mode) |
| Page opens but video will not connect / 网页能打开,画面连不上 | [Connection troubleshooting](./guide/getting-started.md#when-video-will-not-connect) · [中文排查](./guide/getting-started.zh-CN.md#画面连接不上) |
| Run Piik App, check platform requirements, or build a package | [App guide](../cmd/piik-app/README.md) · [中文 App 指南](../cmd/piik-app/README.zh-CN.md) |
| Try or change the source code | [Run from source](#run-from-source) |
| Find a directory or the module responsible for a behavior | [Repository layout and module map](./reference/engineering.md#repository-layout) |
| Find a directory or the module responsible for a behavior | [Repository layout and module map](./standards/engineering.md#repository-layout) |
| Find the shared product, design and lifecycle rules | [Standards index](./standards/README.md) |
| Host a site for my group | [Self-hosting](./operations/self-hosting.md) · [中文部署](./operations/self-hosting.zh-CN.md) |
| Keep a server running with systemd or Docker | [Service management](./operations/service-management.md) · [中文服务管理](./operations/service-management.zh-CN.md) |
| Set passwords, ports, or room storage | [Configuration](./reference/configuration.md) |
| Set passwords, ports, or room storage | [Configuration](./standards/configuration.md) |
| Update my server | [Server updates](./operations/self-hosting.md#keep-it-running-and-update) |
| Build, publish or recover a maintained release | [Maintainer release tooling](./deployment.md) |
| Send a useful bug report | [Diagnostics and export](./reference/configuration.md#diagnostics) |
| Send a useful bug report | [Diagnostics and export](./standards/configuration.md#diagnostics) |
| Translate Piik or improve wording | [Translation guide](./guide/translating.md) · [中文翻译教程](./guide/translating.zh-CN.md) |
| Check release and platform readiness | [Current status](./status.md) |
@@ -57,7 +63,7 @@ The fixed Windows executable path avoids repeated firewall prompts from `go run`
Open [localhost:8787](http://localhost:8787), start sharing, and open the invitation
in another browser tab to try watching. This localhost invitation stays on your
computer; remote invitations need a reachable origin as described in
[configuration](./reference/configuration.md).
[configuration](./standards/configuration.md).
Browser capture requires `localhost` or HTTPS.
For the self-contained App, follow [App development](../cmd/piik-app/README.md#development)
@@ -71,25 +77,25 @@ The main verification commands are:
```sh
npm run check
npm run check:client
npm run check:go
```
Use the [naming and copy guide](./reference/naming.md) for product names,
Use the [naming and copy guide](./standards/naming.md) for product names,
commands, role labels and Chinese/English voice.
Piik-owned code uses [MIT](../LICENSE); see the
[licensing guide](../licenses/README.md) for third-party components.
For the public website, see the [introduction design](./design/public-introduction.md)
For the public website, see the [introduction design](./standards/public-introduction.md)
and [website preview and publishing](./operations/website.md).
## Understand the product
| Topic | Its reference |
| --- | --- |
| Invitations, room codes and passwords | [Rooms and access](./product/rooms-access.md) |
| How viewers connect | [Routing and transport](./product/routing-transport.md) |
| Screen capture, quality controls and sound | [Media quality](./product/media-quality.md) |
| Host and viewer behavior | [Presentation and lifecycle](./product/presentation-lifecycle.md) |
| Invitations, room codes and passwords | [Rooms and access](./standards/rooms-access.md) |
| How viewers connect | [Routing and transport](./standards/routing-transport.md) |
| Screen capture, quality controls and sound | [Media quality](./standards/media-quality.md) |
| Host and viewer behavior | [Presentation and lifecycle](./standards/presentation-lifecycle.md) |
| Why a design was chosen | [Architecture decisions](./adr/) |
| Measurements and platform limits | [Research](./research/) and [verification status](./verification-status.md) |
@@ -97,13 +103,13 @@ and [website preview and publishing](./operations/website.md).
| When changing… | Follow this owner |
| --- | --- |
| Module responsibilities or interfaces | [Engineering and contract map](./reference/engineering.md) |
| Product/tool names, role labels or copy | [Naming and copy](./reference/naming.md) |
| UI colour, illustration, motion or layout | [Visual language](./design/visual-language.md) |
| Overlays, titles or state indicators | [Media status](./design/media-status.md) |
| Releases, protocols or stored formats | [Versions and compatibility](./reference/versioning.md) |
| Module responsibilities or interfaces | [Engineering and contract map](./standards/engineering.md) |
| Product/tool names, role labels or copy | [Naming and copy](./standards/naming.md) |
| UI colour, illustration, motion or layout | [Visual language](./standards/visual-language.md) |
| Overlays, titles or state indicators | [Media status](./standards/media-status.md) |
| Releases, protocols or stored formats | [Versions and compatibility](./standards/versioning.md) |
| Main protection or automatic publishing | [GitHub operations](./operations/github.md) |
| Documentation or a duplicated rule | [Documentation ownership](./maintenance.md) |
| Documentation or a duplicated rule | [Documentation ownership](./standards/documentation.md) |
| Checks, PRs, integration or cleanup | [Contributing](../CONTRIBUTING.md) |
For ongoing work, use [TODO](./todo.md). [Project memory](./project-memory.md)
@@ -19,7 +19,7 @@ bounded server fallback without exposing topology choices to users or turning
every room into an SFU conference.
This decision owns the route controller and resource model. The observable
product contract is [routing and transport](../product/routing-transport.md);
product contract is [routing and transport](../standards/routing-transport.md);
physical evidence is in the routing research documents.
## Decision
@@ -8,7 +8,7 @@
Dynacast-service and single-output Native assumptions for the current candidate.
Browser source intent, codec selection and framework-owned adaptation remain.
Historical evidence below is not acceptance of the shared Native/embedded-SFU
implementation; [media quality](../product/media-quality.md) owns current behavior.
implementation; [media quality](../standards/media-quality.md) owns current behavior.
## Context
+1 -1
View File
@@ -26,7 +26,7 @@ platform probe and physical gate report it.
Track presence does not prove its source or isolation.
3. Returned screen-audio tracks use `contentHint = "music"`. Current bounded
sender ceilings and narrow Opus stereo answer normalization are owned by
[media quality](../product/media-quality.md). There is no Web Audio mixer,
[media quality](../standards/media-quality.md). There is no Web Audio mixer,
resampler, second representation, or independent audio clock.
4. Browser source switching replaces the current screen-audio track in the same
persistent media stream. Local Viewer play, mute, and volume stay inside the
+18 -18
View File
@@ -1,13 +1,13 @@
# Application Release And Recovery
# Server Release And Recovery
This file owns routine immutable application releases. Initial service setup is
This file owns routine immutable Server releases. Initial service setup is
in [self-hosting operations](./operations/self-hosting.md); environment and ports
are in [configuration reference](./reference/configuration.md). Exact production
are in [configuration reference](./standards/configuration.md). Exact production
identity is owned by the immutable release descriptor, runtime `REVISION`, and
deployment record. [Status](./status.md) owns only the compact current product
and operational snapshot; it is not a per-release ledger.
The tracked `release-app.sh` updates an existing Go deployment with embedded
The tracked `release-server.sh` updates an existing Go deployment with embedded
media, bare-metal nginx, nftables, and application port 8787. It requires an
existing running Go release. Another proxy, firewall owner, application port,
first installation, or initial embedded-media cutover needs its own scoped
@@ -18,16 +18,16 @@ outside the cutover window. See [nft output options](https://netfilter.org/proje
## Release Boundary
A routine application release changes only the built Piik server binary and
A routine Server release changes only the built Piik server binary and
the Browser assets it embeds. It does not change infrastructure,
service units, proxy/firewall rules, secrets, media listener configuration, or
persistent room state.
Any task that touches one of those excluded surfaces must first record its exact
pre-change value and a recovery procedure for that surface. Do not complicate the
routine application wrapper with one-off infrastructure branches.
routine Server wrapper with one-off infrastructure branches.
The pre-cutover application release is guaranteed only through bounded health
The pre-cutover Server release is guaranteed only through bounded health
and postflight. It has no retention contract afterward and is not a maintained
backup; Git and immutable artifacts own history.
@@ -38,7 +38,7 @@ Start from a clean exact revision with Node.js 24, npm 11, and Go 1.26:
```sh
npm ci
npm run check
node scripts/package-app-release.mjs <output-directory-outside-repository>
node scripts/package-server-release.mjs <output-directory-outside-repository>
```
The packager refuses a dirty tree, builds the Browser assets and the
@@ -54,12 +54,12 @@ for later managed deployment or mirror retries: descriptors and manifests are
available in CI artifacts for 14 days and are not public Release attachments.
Do not rebuild an existing published version to replace missing metadata or bytes.
The same application descriptor is also the App assembly input. On each
The same Server descriptor is also the App assembly input. On each
target platform, provide that platform's Go toolchain:
```sh
PIIK_GO=/path/to/go node scripts/assemble-client.mjs \
/outside/repository/app-release/piik-<revision>.release.json \
PIIK_GO=/path/to/go node scripts/assemble-app.mjs \
/outside/repository/server-release/piik-<revision>.release.json \
/outside/repository/piik-app \
--target windows-amd64 \
--capture /path/to/platform-capture \
@@ -90,10 +90,10 @@ CI and local release-candidate builds use the same wrapper on the target's
native operating system:
```sh
node scripts/package-client-candidate.mjs \
/outside/repository/app-release \
node scripts/package-app-candidate.mjs \
/outside/repository/server-release \
windows-amd64 \
/outside/repository/client-candidate
/outside/repository/app-candidate
```
The wrapper downloads the pinned public-link sidecar, verifies its digest,
@@ -104,7 +104,7 @@ Retain the descriptor and successful deployment output as release metadata. Do
not create a follow-up source commit solely to duplicate their revision, asset,
or hashes.
Manual `client_checks=true` dispatch packages Server and three-platform App
Manual `app_checks=true` dispatch packages Server and three-platform App
candidates after validation. Once [automatic publication](./operations/github.md)
is explicitly enabled, accepted main merges run that same pipeline and publish
the verified artifacts. Candidate artifacts are retained for 14 days. Branches
@@ -120,12 +120,12 @@ install.
Builds carry a product version plus full source revision. Packagers use one
release plan or exact Git tag and inject its identity into Server, App and Web;
untagged local candidates use `development`. Schema-2 package descriptors carry
the same pair and artifact hashes. The [version policy](./reference/versioning.md)
the same pair and artifact hashes. The [version policy](./standards/versioning.md)
owns ordering and first-public-release readiness.
The default App launcher starts immediately, then checks GitHub Releases in the
background, falling back to the Gitee mirror if GitHub is unavailable. The
[release-source policy](./reference/versioning.md#release-sources) owns selection
[release-source policy](./standards/versioning.md#release-sources) owns selection
and provenance checks. It shows a link only when
the latest stable release is newer, the same version has a known different source
SHA, or a development build can choose the official release. These notices are
@@ -166,7 +166,7 @@ Run the tracked server entry with the uploaded descriptor:
```sh
sudo env PIIK_PUBLIC_ORIGIN=https://share.example.com \
bash /path/to/repository/deploy/release-app.sh \
bash /path/to/repository/deploy/release-server.sh \
/opt/piik/uploads/piik-<revision>.release.json
```
+39
View File
@@ -0,0 +1,39 @@
# Piik documentation
English · [简体中文](./README.zh-CN.md) · [Website](https://piik.tv/?lang=en)
Share your screen with friends, or open their invitation to watch in your browser.
Choose a guide for what you want to do.
## Start using Piik
| I want to… | Start here |
| --- | --- |
| Watch a friend | [Open an invitation](./getting-started.md#join-a-friends-room); no installation needed. |
| Share my screen | [Download Piik App](https://piik.tv/?lang=en#download), then follow [the App steps](./getting-started.md#share-with-piik-app). |
| Use Piik without installing | [Open the online site](https://demo.piik.tv) and follow [the online guide](./getting-started.md#use-piik-online). |
| Use my group's Piik site | [Connect the App or share in a browser](./getting-started.md#share-from-an-existing-site). |
| Understand App options | [Modes, platform requirements and diagnostics](../../cmd/piik-app/README.md). |
## Get help
[Troubleshooting](./troubleshooting.md) covers sound, screen sources, startup and playback.
If the page opens but the picture will not connect, start with
[connection troubleshooting](./troubleshooting.md#when-video-will-not-connect).
Still stuck? [Report a problem](https://github.com/TNTcraftHIM/Piik/issues/new/choose)
with your version, system, browser and reproduction steps. The
[diagnostic guide](../../cmd/piik-app/README.md#diagnostics) explains how to collect useful reports.
## Run a site for your group
Self-hosting is an advanced option requiring a server, domain and network configuration.
- [Deploy Piik Server](../operations/self-hosting.md) with Docker Compose or the server package.
- [Keep it running and update](../operations/service-management.md).
- [Look up configuration settings](../standards/configuration.md).
## Help improve Piik
[Translate Piik](./translating.md), [contribute](../../CONTRIBUTING.md), or explore the
[developer and maintainer documentation](../README.md).
+38
View File
@@ -0,0 +1,38 @@
# Piik 文档中心
[English](./README.md) · 简体中文 · [官网](https://piik.tv/?lang=zh-CN)
分享自己的屏幕,或者打开朋友的邀请链接,在浏览器里一起看。
按你想做的事,选择下面的指南。
## 开始使用
| 我想…… | 从这里开始 |
| --- | --- |
| 看朋友的分享 | [打开邀请链接](./getting-started.zh-CN.md#加入朋友的房间),无须安装。 |
| 分享自己的屏幕 | [下载 Piik App](https://piik.tv/?lang=zh-CN#download),再按 [App 使用步骤](./getting-started.zh-CN.md#用-piik-app-分享)操作。 |
| 免安装使用 | 打开[在线版](https://demo.piik.tv),参阅[在线版教程](./getting-started.zh-CN.md#使用在线版)。 |
| 使用已有的 Piik 站点 | [配合 App 使用,或直接在浏览器中分享](./getting-started.zh-CN.md#使用已有站点分享)。 |
| 了解 App 的选项 | [运行模式、平台要求与诊断](../../cmd/piik-app/README.zh-CN.md)。 |
## 遇到问题
[问题排查](./troubleshooting.zh-CN.md)包含声音、画面来源、启动与播放的常见问题。
如果网页能打开,画面却连不上,先看[画面连接排查](./troubleshooting.zh-CN.md#画面连接不上)。
仍然没解决?可以[反馈问题](https://github.com/TNTcraftHIM/Piik/issues/new/choose),
附上版本、系统、浏览器和复现步骤。需要收集报告时,参阅
[诊断说明](../../cmd/piik-app/README.zh-CN.md#诊断)。
## 为朋友搭建站点
自部署属于进阶操作,需要服务器、域名和网络配置。
- [部署 Piik Server](../operations/self-hosting.zh-CN.md):使用 Docker Compose 或服务端程序包。
- [长期运行与更新](../operations/service-management.zh-CN.md)。
- [完整配置参考(英文)](../standards/configuration.md)。
## 一起改进 Piik
可以从[参与翻译](./translating.zh-CN.md)、[贡献指南](../../CONTRIBUTING.md),
或[开发与维护文档](../README.md)开始。
+20 -15
View File
@@ -6,7 +6,7 @@ Piik shares one person's screen with up to 20 invited viewers. This guide covers
watching, using Piik online, using Piik App and sharing through an existing site.
[Join a room](#join-a-friends-room) · [Use online](#use-piik-online) ·
[Piik App](#share-with-piik-app) · [Existing site](#share-from-your-browser) ·
[Piik App](#share-with-piik-app) · [Existing site](#share-from-an-existing-site) ·
[Troubleshooting](#when-something-gets-in-the-way)
Viewers need a browser and an invitation. To start sharing, download Piik App
@@ -91,13 +91,22 @@ See the [App guide](../../cmd/piik-app/README.md) for package and runtime detail
| **Public invite** | Friends are elsewhere and you want a temporary invitation. | Needs Internet access. The public address lasts for this App run. |
| **Connect to Site** | Your group already has a Piik site. | Enter its address; the App adds capture capabilities to the site's sharing page. |
For **Connect to Site**, enter the full site address, such as
`https://demo.piik.tv`, add its passphrase if required, then choose **Open Piik**.
After opening the site through the App once, the same browser remembers that
entry. Keep the App running with that site configured and you can visit the
address directly. **Start sharing** opens the selector; choose **Apps / Windows**
or **Screens** for App capture. Allow local-network access if the browser asks.
In another browser, in a private window, or after clearing site data, enter through
the App again. For a different site, update the address in the App first.
**A page that opens does not guarantee a video connection.** Public invite gives
your room a temporary web address; picture and sound still travel between
participants. This mode has no media-server fallback, and the temporary address
has no uptime guarantee. All modes need a working UDP media path.
The [App guide](../../cmd/piik-app/README.md#modes) explains the connection setup.
## Share from your browser
## Share from an existing site
You need an existing HTTPS site running Piik Server, provided by a friend or
administrator. You can also use [Piik online](#use-piik-online)
@@ -116,20 +125,16 @@ Screen capture requires an HTTPS site or `localhost`. The sources and audio
offered by the picker depend on your browser and operating system.
Invitation links grant access to that room; share them with the people you want there.
To use App capture on that same site, follow [Connect to Site](#choose-an-app-mode).
The site manages rooms and invitations; the App supplies native capture.
If its operator has enabled SFU forwarding, turn off **Privacy mode** before
sharing to allow that fallback. The project's online site uses P2P only.
## When something gets in the way
| What you see | Try this |
| --- | --- |
| 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. |
| 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 | Choose **Reconnect** in the playback bar. If it still fails, check [WebRTC connection settings](../../cmd/piik-app/README.md#chromium-webrtc-connections) or try a site with media fallback. |
| Sharing stops after sleep or suspension | Wake the device and return to the sharing tab; start sharing again if needed. Browser and OS suspension can interrupt capture or playback. |
For sound, capture, startup or playback issues, see [Troubleshooting](./troubleshooting.md).
For a bug report, include the version, OS/browser, what you expected, and how
to reproduce it. [Diagnostics and export](../reference/configuration.md#diagnostics)
explains how to collect a local report and what to review before sharing it.
### When video will not connect
Ready to host a site for your group? Follow [self-hosting](../operations/self-hosting.md).
For everything else, use the [documentation map](../README.md).
See [connection troubleshooting](./troubleshooting.md#when-video-will-not-connect)
for “No media route available” and similar connection failures.
+20 -18
View File
@@ -3,10 +3,10 @@
[English](./getting-started.md) · 简体中文 · [回到 Piik](../../README.zh-CN.md)
Piik 支持一位房主分享屏幕,最多 20 位受邀观众通过浏览器观看。
本指南介绍加入房间、使用在线版、使用客户端,以及通过已有站点分享的方法。
本指南介绍加入房间、使用在线版、使用 Piik App,以及通过已有站点分享的方法。
[加入房间](#加入朋友的房间) · [在线版](#使用在线版) ·
[Piik App](#用-piik-app-分享) · [已有站点](#用浏览器分享) · [问题排查](#遇到问题了)
[Piik App](#用-piik-app-分享) · [已有站点](#使用已有站点分享) · [问题排查](#遇到问题了)
观众只需要浏览器和邀请链接。
分享画面推荐下载 Piik App,选择 **公网邀请**。
@@ -70,7 +70,7 @@ App 的本地房间和临时公网邀请会随本次 App 退出而结束。
| `darwin-arm64` | Apple 芯片;原生采集需 macOS 13 及以上 |
| `linux-amd64` | Linux x64 |
目前主要测试 Windows 客户端和浏览器分享。macOS 和 Linux 客户端尚未经过实机测试,
目前主要测试 Windows 版 Piik App 和浏览器分享。macOS 和 Linux 版 Piik App 尚未经过实机测试,
欢迎有设备的朋友试用并[反馈问题](https://github.com/TNTcraftHIM/Piik/issues)。
Linux 原生采集还需要[系统组件](../../native/capture/linux/README.md)。
程序包与启动参数的细节见 [App 指南](../../cmd/piik-app/README.zh-CN.md)。
@@ -83,15 +83,22 @@ Linux 原生采集还需要[系统组件](../../native/capture/linux/README.md)
| **公网邀请** | 朋友在别处,想临时发个邀请。 | 需要互联网连接;公网地址只在本次 App 运行期间有效。 |
| **连接站点** | 你们已经有一个 Piik 站点。 | 输入站点地址,在原有分享页面使用 App 的采集能力。 |
使用 **连接站点** 时,填写完整地址,例如 `https://demo.piik.tv`;如果站点有访问口令,
也一并填写,然后点击 **进入 Piik**。通过 App 打开过一次后,同一浏览器会记住这个入口。
保持 App 运行并使用该站点配置,之后就能直接访问网址。点击 **开始分享**,
在选择器里选 **程序 / 窗口** 或 **屏幕**,即可使用 App 采集。
浏览器询问本地网络权限时,请允许访问。换浏览器、使用无痕窗口或清除站点数据后,
需要重新从 App 进入一次;换用其他站点时,先在 App 中更新地址。
**网页能打开,不代表视频一定能连通。** 公网邀请为房间提供临时网页地址,
画面和声音仍靠参与者之间的连接传输。此模式没有媒体服务器兜底,
临时地址也不保证持续可用。所有模式都需要可用的 UDP 媒体路径。
具体连接方式见 [App 指南](../../cmd/piik-app/README.zh-CN.md#运行模式)。
## 用浏览器分享
## 使用已有站点分享
需要先有一个运行 Piik Server 的 HTTPS 站点,可以使用朋友或管理员提供的地址。
也可以使用项目提供的[在线版](#使用在线版)或[客户端](#用-piik-app-分享)。
也可以使用项目提供的[在线版](#使用在线版)或 [Piik App](#用-piik-app-分享)。
自建站点属于进阶操作,服务器、HTTPS 和网络端口的配置步骤见[部署指南](../operations/self-hosting.zh-CN.md)。
1. 在电脑上打开 Piik 站点。如果站点设置了口令,先输入口令。
@@ -104,20 +111,15 @@ Linux 原生采集还需要[系统组件](../../native/capture/linux/README.md)
弹窗里有哪些画面和声音可选,取决于浏览器与操作系统。
拿到邀请链接的人可以进入这个房间,请把它发给你想邀请的人。
想在同一站点使用 App 采集,可按[连接站点](#选择-app-模式)的步骤操作。
站点负责房间与邀请,App 提供原生采集。
如果管理员已启用 SFU 转发,房主可在开始分享前关闭 **隐私模式**,允许使用这条兜底线路。
项目提供的在线版仅使用 P2P。
## 遇到问题了
| 你遇到的情况 | 可以先试什么 |
| --- | --- |
| 有画面,没声音 | 先取消视频静音。房主应选择支持分享声音的来源;如果开始时关闭了声音,请停止后重新开始并打开声音。通过 App 采集窗口或屏幕时,切换来源会保留原来的声音设置。 |
| 没有屏幕选择弹窗 | 按系统提示允许浏览器或 App 录制屏幕。浏览器采集需要 HTTPS 或 `localhost`,请在电脑上尝试分享。 |
| App 启动失败 | 先查看页面和终端中的具体原因。重新打开 App,在主题按钮后的芯片图标开启「诊断启动」再重试。启动失败时会自动导出报告,保存位置见终端。 |
| 本地邀请打不开 | 确认两台设备在同一网络且可以互相访问。访客 Wi-Fi 或防火墙规则可能阻止本地连接。 |
| 网页能打开,画面连不上 | 点击播放控制栏的 **重新连接**;仍然失败时,检查 [WebRTC 连接设置](../../cmd/piik-app/README.zh-CN.md#chromium-webrtc-连接),或尝试有媒体服务器兜底的站点。 |
| 休眠或挂起后分享中断 | 唤醒设备,回到分享标签页,必要时重新开始分享。浏览器或系统挂起可能中断采集和播放。 |
声音、采集、启动或播放遇到问题时,先看[问题排查](./troubleshooting.zh-CN.md)。
反馈问题时,请附上版本、系统与浏览器、预期结果和复现步骤。
[诊断与导出](../reference/configuration.md#diagnostics)
说明如何收集本地报告,以及分享前需要检查哪些内容。
### 画面连接不上
想为朋友搭一个站点?从 [部署指南](../operations/self-hosting.zh-CN.md) 开始。
其他内容可在 [文档导航](../README.md) 找到。
出现「没有可用的媒体线路」等提示时,参阅[画面连接排查](./troubleshooting.zh-CN.md#画面连接不上)。
+51 -10
View File
@@ -15,7 +15,7 @@ suggestion does not require a development environment.
## Make your first correction
1. Try the affected screen and read the nearby controls. Check both existing
languages when helpful; the [terminology and voice guide](../reference/naming.md#voice-and-terminology)
languages when helpful; the [terminology and voice guide](../standards/naming.md#voice-and-terminology)
owns names and writing style.
2. Fork Piik and work on a branch from current `main`, following
[Contributing](../../CONTRIBUTING.md#lean-workflow). Small text edits can also
@@ -41,17 +41,28 @@ suggestion does not require a development environment.
| Surface | Editable source |
| --- | --- |
| App launcher and shared Browser/App UI, including tooltips and accessible labels | [English](../../src/client/locales/en.ts), [Chinese](../../src/client/locales/zh.ts) |
| Welcome lines and changing browser-tab titles | `welcome.*` messages and the title-frame catalog at the end of those same files |
| Welcome lines, waiting captions and changing browser-tab titles | The `enPlayful` / `zhPlayful` pools and title-frame catalogs in those same files; website welcome lines reuse these pools |
| Website | [Page markup](../../site/index.html) and [interactive labels](../../site/main.js); text is paired by `en` and `zh-CN` |
| Introduction film | [Film sources](../../site/film/README.md); captions, artwork and playback labels have their own bilingual text |
| App console window | `consoleCopy` in [console.go](../../internal/app/console.go); entries are ordered English, Chinese, visual; retain the third slot even when empty |
| README and entry guides | [README pair](../../README.md), [getting started](./getting-started.md) and [self-hosting](../operations/self-hosting.md), each with a `.zh-CN.md` counterpart |
| README and entry guides | [README pair](../../README.md), [documentation home](./README.md), [getting started](./getting-started.md), [troubleshooting](./troubleshooting.md) and [self-hosting](../operations/self-hosting.md), each with a `.zh-CN.md` counterpart |
The App and Server serve the same web UI. Edit source files; build output and
release packages are generated. Translating an App message does not also
translate a website caption. Update related instructions when an action name
changes. Technical references keep one shared version; see
[documentation ownership](../maintenance.md).
[documentation ownership](../standards/documentation.md).
Language catalogs are bundled with the web assets; a new language also needs
the registry import described below. Rebuild and publish the App or Server that
serves the affected UI after an edit. Installed packages do not load external
language files. When the App connects to a remote site, that site serves its UI,
so updating the site's web translations does not require reinstalling the App.
The website has its own build and deployment workflow.
The documentation website renders those same Markdown sources; editing a guide
updates its web page on the next website publication. Its navigation and search
labels live in `site/docs/`. Use the [website preview](../operations/website.md#preview)
to check the generated page as well as GitHub's Markdown view.
## Preserve meaning and syntax
@@ -91,16 +102,18 @@ start an issue with its language tag, intended coverage and any terminology or
layout questions, so contributors can coordinate work and review.
1. Copy `src/client/locales/en.ts` to a file such as `fr.ts`. Translate the
messages and title frames, rename the two exports, and retain the
`Record<CopyKey, string>` and `TitleFrameCatalog` types imported from `zh.ts`.
messages and title labels, write natural [playful entries](#playful-copy), and
rename the three exports. Retain `Record<CopyKey, string>`, `TitleFrameCatalog`
and `PlayfulCatalog` from `zh.ts`.
Chinese currently defines the key set; either existing language can provide
context. Complete all keys rather than spreading an English catalog over
missing translations.
2. Import the exports in [locales/index.ts](../../src/client/locales/index.ts)
and add one registry entry. For example, **after translating** the two exports:
and add one registry entry. For example, **after translating** the catalogs:
```ts
fr: { name: "Français", short: "FR", tag: "fr", copy: fr, titleFrames: frTitleFrames },
fr: { name: "Français", short: "FR", tag: "fr", copy: fr,
titleFrames: frTitleFrames, playful: frPlayful },
```
Use an ASCII locale filename/key, the language's own name in `name`, a compact
@@ -113,12 +126,12 @@ layout questions, so contributors can coordinate work and review.
language's short label appears on that slot, with its full name in the menu.
Initial selection matches a registered tag or key, then its base language;
unregistered languages use English. A saved choice takes precedence.
The shared [system-language policy](../product/presentation-lifecycle.md#visual-language)
The shared [system-language policy](../standards/presentation-lifecycle.md#visual-language)
also covers Chinese script and region variants across App, Server UI and website.
3. Check complete screens and the launch flow. New UI languages use English in
the App console until its own translation is added. Console additions also
require updating the launcher and loopback accepted-language checks together;
use the [App module map](../reference/engineering.md#module-map).
use the [App module map](../standards/engineering.md#module-map).
4. State coverage in the PR. Website, film and documentation translations can be
contributed separately; their existing language controls must be updated when
adding a language there. For right-to-left scripts, include text direction and
@@ -128,6 +141,34 @@ Pure-visual mode is an optional presentation, not another language to translate.
The small visual scenes are shared with text modes; translate their labels and
explanations in the normal catalog. Keep the visual option available.
## Playful copy
Each language owns its welcome and waiting arrays, and each playful title has a
fixed `label` plus a `variations` array. Add or remove entries in that language
without matching another language's count, order or references. An empty array
omits the decoration; one entry stays static. Operational keys and title labels
still require complete translations. Errors, pauses, endings, required actions
and slogans stay fixed.
Welcome entries carry `text` and two `symbols`, chosen from the existing
[icon vocabulary](../../src/client/ui/icons.tsx) to express that sentence in the
visual cipher. For example:
```ts
{ text: "Your seat is right here.", symbols: ["couch", "heart"] },
```
Waiting entries are plain strings such as `"Pull up a comfy chair."`. Keep them
brief and natural in context. Avoid temporary trends, literal borrowed dialogue
and statements that invent progress, success or remaining time.
All these surfaces show a line immediately and share the
[playful-copy lifecycle](../standards/visual-language.md#playful-copy-lifecycle).
Keep timing in that owner. Preview welcome text and its cipher at
`/__tooltip-preview`; use `/__status-preview` for waiting captions and titles.
Check both narrow windows and language changes. The website's build picks up
welcome edits automatically; its other copy remains separate.
## Preview and check
Use the Node/npm versions and local setup in [Run from source](../README.md#run-from-source).
+44 -11
View File
@@ -12,7 +12,7 @@ Piik 目前提供简体中文和英文。欢迎改进按钮措辞、校对教程
## 从一处修改开始
1. 先试用对应界面,看看附近有哪些操作。必要时对照中英文理解含义;
角色名称、术语与文风以[命名和文案规范](../reference/naming.md#voice-and-terminology)为准。
角色名称、术语与文风以[命名和文案规范](../standards/naming.md#voice-and-terminology)为准。
2. Fork 仓库,从当前 `main` 创建工作分支,按[贡献流程](../../CONTRIBUTING.md#lean-workflow)修改。
少量文字也可以通过 GitHub 文件编辑器提出修改。
3. 在下表对应的文件里查找原文。语言文件中只修改冒号后的译文,保留键名和占位符。
@@ -33,15 +33,20 @@ Piik 目前提供简体中文和英文。欢迎改进按钮措辞、校对教程
| 内容 | 修改位置 |
| --- | --- |
| App 启动页、网页端与 App 共用的操作界面,含 tooltip 和无障碍标签 | [英文词库](../../src/client/locales/en.ts)、[中文词库](../../src/client/locales/zh.ts) |
| 随机欢迎语、浏览器标签页的轮换标题 | 同一语言文件中的 `welcome.*` 及文件末尾的标题词库 |
| 欢迎语、等待短句、浏览器标签页的轮换标题 | 同一语言文件中的 `zhPlayful` / `enPlayful` 及标题词库;官网欢迎语共用这些词库 |
| 官网 | [页面正文](../../site/index.html)和[交互文案](../../site/main.js),按 `en`、`zh-CN` 成对组织 |
| 宣传短片 | [短片源码说明](../../site/film/README.md),字幕、插画和播放控件有各自的中英文内容 |
| App 终端 | [console.go](../../internal/app/console.go) 中的 `consoleCopy`,每项按英文、中文、纯视觉排列;第三项即使为空也需保留 |
| README 与入门教程 | [README](../../README.md)、[使用指南](./getting-started.md)、[部署指南](../operations/self-hosting.md),均有对应的 `.zh-CN.md` 文件 |
| README 与读者文档 | [README](../../README.md)、[文档首页](./README.md)、[使用指南](./getting-started.md)、[问题排查](./troubleshooting.md)、[部署指南](../operations/self-hosting.md),均有对应的 `.zh-CN.md` 文件 |
App 与 Server 使用同一套网页操作界面。请修改源码,构建目录和发布包由工具生成。
应用词库中的修改不会自动翻译官网文案;操作名称变化时,也要检查相关教程。
技术参考文档保留一份公共版本,详见[文档职责](../maintenance.md)。
技术参考文档保留一份公共版本,详见[文档职责](../standards/documentation.md)。
语言词库随网页资源一起构建,新语言还需在下文的登记表中导入。修改后重新构建并发布
提供该界面的 App 或 Server;安装目录不支持直接放入语言文件热加载。
App 连接远程站点时使用站点提供的界面,因此该站点的网页译文更新不要求重新安装 App。
官网有自己的构建和部署流程。
## 翻译时保留什么
@@ -73,15 +78,17 @@ App 与 Server 使用同一套网页操作界面。请修改源码,构建目
先搜索是否已有相关 Issue 或 PR。准备翻译较多内容时,可以开一个 Issue,说明语言代码、
翻译范围,以及术语或排版疑问,方便大家分工和校对。
1. 复制 `src/client/locales/en.ts`,例如命名为 `fr.ts`。翻译正文和轮换标题,修改两个导出变量名,
保留从 `zh.ts` 引入的 `Record<CopyKey, string>`、`TitleFrameCatalog` 类型。
1. 复制 `src/client/locales/en.ts`,例如命名为 `fr.ts`。翻译正文与标题标签,按语境编写
[俏皮话](#俏皮话),修改三个导出变量名。
保留从 `zh.ts` 引入的 `Record<CopyKey, string>`、`TitleFrameCatalog`、`PlayfulCatalog` 类型。
中文词库目前定义键名集合,理解内容时可以参考任一现有语言。
请补全全部键名,不要用展开英文词库的方式填充缺失译文。
2. 在 [locales/index.ts](../../src/client/locales/index.ts) 中导入两个导出变量,并添加一项登记。
例如,**完成这两个词库的翻译后**:
2. 在 [locales/index.ts](../../src/client/locales/index.ts) 中导入三个导出变量,并添加一项登记。
例如,**完成词库翻译后**:
```ts
fr: { name: "Français", short: "FR", tag: "fr", copy: fr, titleFrames: frTitleFrames },
fr: { name: "Français", short: "FR", tag: "fr", copy: fr,
titleFrames: frTitleFrames, playful: frPlayful },
```
文件名和键名使用 ASCII 语言代码,`name` 填写该语言自己的名称,`short` 填写按钮简称,
@@ -93,18 +100,44 @@ App 与 Server 使用同一套网页操作界面。请修改源码,构建目
首次使用先匹配已登记的标签或键名,再匹配基础语言;没有对应翻译时使用英文。
用户保存的选择优先。
App、Server 网页界面及官网对中文地区和文字变体的默认选择,遵循
[统一的系统语言规则](../product/presentation-lifecycle.md#visual-language)。
[统一的系统语言规则](../standards/presentation-lifecycle.md#visual-language)。
3. 检查完整界面与启动流程。新增的界面语言在 App 终端中暂用英文;
翻译终端时,还需同步启动服务和 loopback 对语言的校验,相关位置见
[App 模块地图](../reference/engineering.md#module-map)。
[App 模块地图](../standards/engineering.md#module-map)。
4. 在 PR 中写清覆盖范围。官网、短片和文档可以分别翻译,新增语言时需同步各自的语言切换入口。
从右向左书写的语言还需要验证文字方向与布局;仅有词库并不代表已支持 RTL 排版。
纯视觉模式是可选的呈现方式,不是另一种待翻译语言。小漫画和文字模式共用,
它们的文字标签与说明在普通词库中翻译,保留现有纯视觉选项即可。
## 俏皮话
各语言独立维护欢迎语、等待短句数组;可轮换标题由固定的 `label` 和 `variations` 数组组成。
可以单独增删某种语言的条目,不要求与其他语言的数量、顺序或梗一一对应。
空数组不显示装饰文案;只有一条时保持静止。操作键名和标题标签仍须完整翻译,
错误、暂停、结束、必需操作提示及 slogan 保持固定。
欢迎语每项包含 `text` 和两个 `symbols`,从现有的
[图标词汇](../../src/client/ui/icons.tsx)中选择能表达这句话的图形,用于纯视觉暗号。例如:
```ts
{ text: "沙发给你留着呢。", symbols: ["couch", "heart"] },
```
等待短句直接写成字符串,例如 `"先搬个小板凳。"`。短句应简洁、自然、符合所在场景;
避免时效热点、直接照搬台词,以及虚构进度、成功状态或剩余时间的说法。
这些位置都会立即显示第一句,并遵循统一的
[俏皮话生命周期](../standards/visual-language.md#playful-copy-lifecycle),不要在词库或某个页面单独设置轮换时间。
欢迎语及其暗号在 `/__tooltip-preview` 预览;等待短句与标题在 `/__status-preview` 预览。
检查窄窗口和切换语言的效果。官网构建会自动使用更新后的欢迎语,其他官网文案单独维护。
## 预览与检查
文档网站直接生成自仓库中的 Markdown。修改指南后,下次官网发布会同步更新对应网页。
导航和搜索按钮的文字位于 `site/docs/`;正文仍只修改原始 Markdown 文件。
使用[官网预览流程](../operations/website.md#preview)检查生成的网页,并核对 GitHub 上的 Markdown 展示。
Node/npm 版本及本地启动方法见[从源码运行](../README.md#run-from-source)。
修改应用词库后,在仓库根目录执行:
+76
View File
@@ -0,0 +1,76 @@
# Troubleshooting
English · [简体中文](./troubleshooting.zh-CN.md) · [Documentation](./README.md)
Find the symptom below and try the suggested checks. Stop when it works;
collect a diagnostic report if the problem persists.
## Common problems
| What you see | Try this |
| --- | --- |
| 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. |
| 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. |
| Sharing stops after sleep or suspension | Wake the device and return to the sharing tab; start sharing again if needed. Browser and OS suspension can interrupt capture or playback. |
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.
## When video will not connect
**No media route available** means Piik has not found a working path to deliver
the picture to your device. Loading the page and receiving media use different
connections, so one can work while the other fails.
NAT (Network Address Translation) lets several devices share an Internet address.
Routers and providers differ in how they map addresses and admit incoming traffic;
some combinations make direct connections difficult. Firewalls and browser
policies can also block media. The message alone does not identify the cause.
Try these in order, stopping when the picture arrives:
1. **Retry the viewing page.** Choose **Reconnect** if it is available. If it is
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.
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,
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
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,
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
advanced option. Both P2P and SFU media currently use UDP; a network that blocks
UDP entirely still needs a different network or an administrator's help.
Router menus vary by model. These manufacturer guides explain
[IPv6 prerequisites](https://support.google.com/googlehome/answer/6361450),
[UPnP](https://support.google.com/googlehome/answer/6274337), and
[double NAT with a modem and router](https://www.tp-link.com/us/support/faq/3113/).
The [WebRTC connection guide](https://webrtc.org/getting-started/peer-connections)
explains discovery and connection checks in more technical detail.
If these steps do not help, collect a [Debug report](../../cmd/piik-app/README.md#diagnostics)
from the affected Viewer and, if possible, the Host for the same attempt. Include
the time, Piik version and network type when reporting the problem.
Ready to host a site for your group? Follow [self-hosting](../operations/self-hosting.md).
For other guides, return to [the documentation home](./README.md).
+63
View File
@@ -0,0 +1,63 @@
# 问题排查
[English](./troubleshooting.md) · 简体中文 · [文档中心](./README.zh-CN.md)
按遇到的现象查找下方说明,恢复正常后就不必继续。
如果问题仍然存在,可以收集诊断报告反馈。
## 常见问题
| 你遇到的情况 | 可以先试什么 |
| --- | --- |
| 有画面,没声音 | 先取消视频静音。房主应选择支持分享声音的来源;如果开始时关闭了声音,请停止后重新开始并打开声音。通过 App 采集窗口或屏幕时,切换来源会保留原来的声音设置。 |
| 没有屏幕选择弹窗 | 按系统提示允许浏览器或 App 录制屏幕。浏览器采集需要 HTTPS 或 `localhost`,请在电脑上尝试分享。 |
| App 的窗口或屏幕列表为空 | 保持 App 运行,从 App 重新打开分享页面;浏览器询问本地网络权限时允许访问,再刷新来源列表。也可以选 **浏览器** → **浏览器选择器**,使用浏览器采集。仍然失败时,按下方说明收集诊断报告。 |
| App 启动失败 | 先查看页面和终端中的具体原因。重新打开 App,在主题按钮后的芯片图标开启「诊断启动」再重试。启动失败时会自动导出报告,保存位置见终端。 |
| 本地邀请打不开 | 确认两台设备在同一网络且可以互相访问。访客 Wi-Fi 或防火墙规则可能阻止本地连接。 |
| 网页能打开,画面连不上 | **重新连接** 按钮可用时先点击,否则刷新观看页。仍然失败时,查看 [画面连接排查](#画面连接不上)。 |
| 休眠或挂起后分享中断 | 唤醒设备,回到分享标签页,必要时重新开始分享。浏览器或系统挂起可能中断采集和播放。 |
反馈问题时,请附上版本、系统与浏览器、预期结果和复现步骤。
[诊断与导出](../../cmd/piik-app/README.zh-CN.md#诊断)
说明如何收集本地报告,以及分享前需要检查哪些内容。
## 画面连接不上
**没有可用的媒体线路** 表示 Piik 还没有找到能把画面送到你设备上的连接。
网页和媒体使用不同的连接,所以可能出现网页能打开、画面却连不上的情况。
NAT(网络地址转换)让多台设备共用一个上网地址。不同路由器和运营商对地址转换、
外部数据进入的规则不同,有些组合会让设备难以直连。防火墙和浏览器策略也可能阻止媒体连接,
仅凭这条提示还不能确定具体原因。
可以依次尝试,画面恢复后就不必继续:
1. **重新尝试观看连接。** 如果 **重新连接** 可用,先点击;按钮不可用或没有效果时,
刷新观看页或重新打开邀请链接。每次等连接尝试结束,重试一两次即可;反复刷新无法解除网络限制。
2. **换个网络测试。** 例如试用手机热点。如果使用了 VPN、代理或限制 WebRTC 的扩展,
检查其 UDP 策略,受管理的网络请联系管理员。详见 [浏览器连接设置](../../cmd/piik-app/README.zh-CN.md#chromium-webrtc-连接)。
3. **开启可用的 IPv6。** 先确认宽带、路由器和设备都支持。连接双方都有可用 IPv6 时,
Piik 可以多尝试一条直连路径;它仍受防火墙规则约束,同时保留 IPv4 即可。
4. **检查自己管理的路由器。** 在可信家庭网络中,路由器支持的 UPnP、PCP 或 NAT-PMP
可以让 App 的原生媒体连接申请端口映射;纯浏览器采集不会申请这些映射。
如果光猫和路由器都在做 NAT,可按对应型号的桥接或 AP 模式说明减少一层。
运营商共享公网地址(CGNAT)位于路由器上游,需要向运营商咨询公网 IPv4 或 IPv6 服务。
修改路由器工作模式前,先备份配置。
5. **请另一网络的朋友加入。** 如果对方能正常看到画面,且有余力转发,Piik 可能经由这位观众
把画面送到你这里。线路由 Piik 自动选择,能否帮上忙取决于这些连接是否可用。
6. **使用有媒体兜底的站点。** 管理员需要先启用 SFU 转发,房主在分享前关闭 **隐私模式**。
公开站点 `demo.piik.tv` 和 App 的 **公网邀请** 模式均没有 SFU 兜底。
有服务器的用户也可以按 [部署指南](../operations/self-hosting.zh-CN.md#可选启用媒体兜底) 配置,属于进阶操作。
目前 P2P 和 SFU 媒体都使用 UDP;网络完全禁止 UDP 时,仍需换网络或由管理员放行。
不同型号的路由器菜单会有差异,可参考厂商的
[IPv6 条件说明](https://support.google.com/googlehome/answer/6361450?hl=zh-Hans)、
[UPnP 说明](https://support.google.com/googlehome/answer/6274337?hl=zh-Hans) 和
[双重 NAT 调整示例](https://www.tp-link.com/us/support/faq/3113/)(英文)。
想了解连接机制,可以阅读 [WebRTC 官方说明](https://webrtc.org/getting-started/peer-connections)(英文)。
仍然失败时,收集出现问题的观众端与房主端同一次尝试的
[诊断报告](../../cmd/piik-app/README.zh-CN.md#诊断),反馈时附上发生时间、Piik 版本和所用网络类型。
想为朋友搭一个站点?从 [部署指南](../operations/self-hosting.zh-CN.md) 开始。
其他指南可在 [文档中心](./README.zh-CN.md) 找到。
+7 -7
View File
@@ -1,6 +1,6 @@
# GitHub Repository And Releases
[Versioning](../reference/versioning.md) owns release meaning and compatibility;
[Versioning](../standards/versioning.md) owns release meaning and compatibility;
[CONTRIBUTING](../../CONTRIBUTING.md) owns review and local validation. This file
owns the one-time GitHub settings. The source repository is public; automatic
package publication has its own activation gate below.
@@ -33,15 +33,15 @@ not an environment variable for Piik App or Server.
Subsequent accepted PRs merged to main use the existing CI workflow. Product
changes calculate one version, validate, package Server and all App targets,
and publish the same artifacts. Standalone website/docs changes validate without
packaging or releasing; [versioning](../reference/versioning.md#automatic-publication)
packaging or releasing; [versioning](../standards/versioning.md#automatic-publication)
owns that boundary. Runs queue rather than overlap or cancel one another. Branches/PRs
stay quiet; manual `client_checks` dispatch remains available for candidates.
stay quiet; manual `app_checks` dispatch remains available for candidates.
Writing a new version back to main is unnecessary and prohibited.
For a complete publisher rehearsal, dispatch with `client_checks: true` and an
For a complete publisher rehearsal, dispatch with `app_checks: true` and an
explicit `candidate_version` such as `v1.0.0`. This builds genuine Server and
native App artifacts under one revision and version without tagging or
publishing. Prepare its [release-note section](../reference/versioning.md#release-notes)
publishing. Prepare its [release-note section](../standards/versioning.md#release-notes)
and inspect the generated preview in the run summary. For a local preview, run
`node scripts/release-notes.mjs v1.0.0 FULL_SOURCE_SHA` from the checkout with
full history and tags. Download that run's artifacts and pass their directory, version and
@@ -73,7 +73,7 @@ in its GitHub package settings; repository visibility alone does not do this.
Verify both its version tag and `latest` with an anonymous pull before announcing
the Docker download. Keep repository Actions access enabled for later releases.
Image checks, version identity and retries follow
[container distribution](../reference/versioning.md#container-distribution).
[container distribution](../standards/versioning.md#container-distribution).
## Gitee Download Mirror
@@ -110,7 +110,7 @@ recovery procedure. See [artifact retention](../deployment.md#build-host).
Use the release's actual version and full `target_commitish` SHA, with
`GITEE_TOKEN` supplied through the publisher environment. `--dry-run` after the
three arguments checks local identity, checksums and attachment sizes without
publishing. The [release-source policy](../reference/versioning.md#release-sources)
publishing. The [release-source policy](../standards/versioning.md#release-sources)
owns provenance, selection and quota limits. The first public release passed
complete matching Server and three-platform App mirror download acceptance.
+5 -3
View File
@@ -78,7 +78,7 @@ Room invitations and access settings still apply.
`MAX_VIEWERS_PER_ROOM` sets the room's Viewer limit, excluding the Host. Choose
`1..20` and restart to apply it. More Viewers can require more network and relay
resources. See [room capacity](../reference/configuration.md#room-capacity)
resources. See [room capacity](../standards/configuration.md#room-capacity)
for defaults and the difference from App rooms.
### 2. Enable HTTPS
@@ -87,7 +87,7 @@ Use your existing HTTPS reverse proxy to forward to `127.0.0.1:8787` with
WebSocket support. If you do not have one, [install Caddy](https://caddyserver.com/docs/install)
and add this to its Caddyfile:
```caddyfile
```text
share.example.com {
reverse_proxy 127.0.0.1:8787
}
@@ -111,6 +111,8 @@ configuration uses P2P media, so participants need a usable UDP path.
Add `SFU_UDP_PORT=7882` to `.env`, allow UDP 7882, and restart Piik to enable
automatic SFU fallback. When the server is behind NAT, also set `SFU_PUBLIC_IP`
to its reachable public IPv4 address. The same binary provides the fallback.
The Host must turn off **Privacy mode** before sharing to allow this route.
Both direct and SFU media need a usable UDP connection.
## Keep it running and update
@@ -119,5 +121,5 @@ Keep `.env` and `rooms.sqlite` across updates. Back up room data while the serve
stopped, replace the executable with the new release, then restart and check
health and room access. Read release notes before an upgrade that changes data formats.
[All settings and ports](../reference/configuration.md) ·
[All settings and ports](../standards/configuration.md) ·
[Maintainer release tooling](../deployment.md)
+5 -3
View File
@@ -73,14 +73,14 @@ SITE_ACCESS_PASSWORD=
`MAX_VIEWERS_PER_ROOM` 可设置每房观众上限,不含房主,支持 `1..20`,修改后重启生效。
人数越多,对网络和转发资源的需求也可能增加。默认值及 App 房间的区别见
[人数限制](../reference/configuration.md#room-capacity)。
[人数限制](../standards/configuration.md#room-capacity)。
### 2. 配置 HTTPS
如果已有 HTTPS 反向代理,将请求转发到 `127.0.0.1:8787`,并启用 WebSocket 支持。
如果还没有,可以[安装 Caddy](https://caddyserver.com/docs/install),在 Caddyfile 中加入:
```caddyfile
```text
share.example.com {
reverse_proxy 127.0.0.1:8787
}
@@ -103,6 +103,8 @@ TCP 8787 仅供本机反向代理访问。STUN 域名需要直接解析到服务
在 `.env` 中添加 `SFU_UDP_PORT=7882`,放行 UDP 7882,再重启 Piik,即可启用自动 SFU 兜底。
如果服务器处于 NAT 后方,还需将 `SFU_PUBLIC_IP` 设置为外部可达的公网 IPv4 地址。
这项功能由同一个服务端程序提供。
房主需要在开始分享前关闭 **隐私模式**,才会允许使用这条线路。
直连和 SFU 媒体都需要可用的 UDP 通路。
## 长期运行与更新
@@ -110,4 +112,4 @@ TCP 8787 仅供本机反向代理访问。STUN 域名需要直接解析到服务
更新时保留 `.env` 和 `rooms.sqlite`:停止服务、备份房间数据、替换新版程序,再启动并检查健康状态和房间访问。
涉及数据格式变化的版本,请先阅读发布说明。
[全部配置与端口](../reference/configuration.md) · [维护者发版工具](../deployment.md)
[全部配置与端口](../standards/configuration.md) · [维护者发版工具](../deployment.md)
+4 -4
View File
@@ -40,10 +40,10 @@ The [runtime Dockerfile](../../deploy/container/Dockerfile) wraps the verified
Server archive, including its Web UI, license notices and `REVISION`.
Its pinned [Distroless non-root base](https://github.com/GoogleContainerTools/distroless)
includes CA certificates and has no shell or package manager. The
[release workflow](../reference/versioning.md#container-distribution) owns
[release workflow](../standards/versioning.md#container-distribution) owns
building, checking and publishing the image.
Use the existing [production configuration](../reference/configuration.md).
Use the existing [production configuration](../standards/configuration.md).
Keep `LISTEN_HOST=0.0.0.0` inside the container and retain the host's existing
HTTPS/WebSocket proxy. With Docker port mapping, set `SFU_PUBLIC_IP` to the
reachable server IPv4 address when SFU is enabled; container-private candidates
@@ -61,7 +61,7 @@ The image's `/home/nonroot` is owned by UID/GID `65532:65532` with mode `0700`.
A new named volume inherits that directory; an existing volume or bind mount
must already be writable by that identity. Keep this volume when replacing the
container. Set `ROOM_DATABASE_PATH=:memory:` for memory-only room authority. Diagnostics
remain opt-in and use `/home/nonroot/logs`; [export and retention](../reference/configuration.md#diagnostics)
remain opt-in and use `/home/nonroot/logs`; [export and retention](../standards/configuration.md#diagnostics)
remain the operator's responsibility. Do not mount application files writable.
With diagnostics enabled, `docker compose kill --signal=SIGUSR1 piik` requests a local
export without stopping the container; the ZIP remains in the mounted log directory.
@@ -85,7 +85,7 @@ backups as room credentials. Keep the same Compose project/directory so the name
volume is reused; `docker compose down --volumes` deletes that data. To roll back,
restore the previous image setting and compatible configuration, then recreate
the service. Read release notes before crossing a storage-format boundary.
The bare-metal `release-app.sh` wrapper does not manage containers.
The bare-metal `release-server.sh` wrapper does not manage containers.
## Operational Verification
+4 -4
View File
@@ -35,9 +35,9 @@ STUN 和可选的 SFU 使用各自的公网 UDP 监听器。已有 nginx 时,
包括网页界面、许可证声明和 `REVISION`。固定版本的
[Distroless 非 root 基础镜像](https://github.com/GoogleContainerTools/distroless)
包含 CA 证书,没有 shell 或包管理器。镜像的构建、检查和发布由
[发布流程](../reference/versioning.md#container-distribution)管理。
[发布流程](../standards/versioning.md#container-distribution)管理。
使用既有的[生产配置](../reference/configuration.md)。容器内保留 `LISTEN_HOST=0.0.0.0`,
使用既有的[生产配置](../standards/configuration.md)。容器内保留 `LISTEN_HOST=0.0.0.0`,
并继续使用主机已有的 HTTPS/WebSocket 代理。使用 Docker 端口映射且启用 SFU 时,
将 `SFU_PUBLIC_IP` 设为外部可达的服务器 IPv4 地址;容器私网候选无法从互联网访问。
公布的 STUN 域名必须解析到服务器公网地址,UDP 必须能直接到达容器。
@@ -51,7 +51,7 @@ STUN 和可选的 SFU 使用各自的公网 UDP 监听器。已有 nginx 时,
镜像中的 `/home/nonroot` 属于 UID/GID `65532:65532`,权限为 `0700`。
新的命名数据卷继承该目录;已有数据卷或绑定挂载需要事先允许该身份写入。
替换容器时保留这个数据卷。设置 `ROOM_DATABASE_PATH=:memory:` 可让房间权威状态只保存在内存中。
诊断保持手动开启,使用 `/home/nonroot/logs`;[导出与保留](../reference/configuration.md#diagnostics)
诊断保持手动开启,使用 `/home/nonroot/logs`;[导出与保留](../standards/configuration.md#diagnostics)
由运维人员负责。不要将应用文件挂载为可写。
启用诊断后,`docker compose kill --signal=SIGUSR1 piik` 会请求本地导出,不会停止容器;
ZIP 保存在挂载的日志目录中。
@@ -73,7 +73,7 @@ docker compose logs --tail=50 piik
保留同一个 Compose 项目与目录,以便继续使用原命名数据卷;
`docker compose down --volumes` 会删除这些数据。
回滚时,恢复之前的镜像设置与兼容配置,再重新创建服务。
跨越存储格式边界前,先阅读发布说明。裸机部署脚本 `release-app.sh` 不管理容器。
跨越存储格式边界前,先阅读发布说明。裸机部署脚本 `release-server.sh` 不管理容器。
## 运行验证
+40 -6
View File
@@ -1,7 +1,7 @@
# Public website
The website source is `site/`. It explains Piik; it does not run rooms, signaling
or media. The [public introduction guide](../design/public-introduction.md) owns
or media. The [public introduction guide](../standards/public-introduction.md) owns
its content and design boundary.
## Public Destinations
@@ -12,9 +12,9 @@ its content and design boundary.
| P2P-only demo on the separate US server | [demo.piik.tv](https://demo.piik.tv) |
| App downloads and release notes | [GitHub Releases](https://github.com/TNTcraftHIM/Piik/releases) |
| Download mirror for domestic access | [Gitee Releases](https://gitee.com/TNTcraftHIM/Piik/releases) |
| Documentation index | [Repository documentation](../README.md) |
| Documentation source | [Reader guides](../guide/README.md), with a separate [developer map](../README.md) |
These public destinations are live. GitHub Pages serves `piik.tv` behind
GitHub Pages serves `piik.tv` behind
Cloudflare; both the GitHub origin and Cloudflare edge use HTTPS. Website DNS is
proxied with strict origin TLS verification and origin cache headers respected.
Demo DNS remains unproxied so its UDP listeners stay reachable.
@@ -48,7 +48,7 @@ allow UDP 3478/3479/3480 and terminate HTTPS/WSS at Caddy. The demo uses the
standard [systemd service](./service-management.md#systemd), with its release
under `/opt/piik/current` and configuration at `/etc/piik/piik.env`. The service
unit provides the SQLite state directory. Room ownership, invitations and private-room
admission still apply. The [configuration reference](../reference/configuration.md)
admission still apply. The [configuration reference](../standards/configuration.md)
owns each setting and its bounds.
For updates, follow the [application release boundary](../deployment.md#release-boundary)
@@ -62,6 +62,7 @@ does not manage it.
With the repository's development dependencies installed:
```sh
npm ci --prefix site/docs
npm run build:website
npm run preview:website
```
@@ -70,7 +71,40 @@ Open `http://127.0.0.1:18890`. Rebuild after editing source. The build bundles t
film's real product components into an isolated demonstration frame; publishing
`build/site/` needs no Node runtime or backend. Vite preview supplies HTTP byte
ranges for seeking the soundtrack. Keep asset links relative so both a custom
domain and GitHub's `/Piik/` project path work.
domain and GitHub's `/Piik/` project path work. For a nested deployment, set
`PIIK_DOCS_BASE=/Piik/docs/` when building; ordinary custom-domain builds use `/docs/`.
## Documentation Build
`site/docs/` configures VitePress; its isolated package and lockfile keep document
tooling out of the App/Server dependency tree. The pinned Vite override follows the
[VitePress maintainer's guidance](https://github.com/vuejs/vitepress/discussions/5072)
to use the security-maintained Vite 6 line with stable VitePress 1. Remove the
override when a stable upstream release supplies a maintained version directly.
The explicit list in `site/docs/pages.mjs` maps original Markdown files to public
routes and sidebar labels. `build.mjs` stages only those files under ignored
`build/docs-source/`, then emits `build/site/docs/` as part of `build:website`.
Edit the original Markdown, never a staged copy. Relative links between published
pages become local web links; references outside the list continue to GitHub.
Heading IDs reuse the repository checker's GitHub-style slug rule, so section
links work in both views without a second set of anchors.
The page edit link targets its original source. Developer context, TODO, research
and operational release records are not automatically published as reader pages.
The docs keep paired English/Chinese guides, shared site language preference,
system/explicit appearance and the existing mascot. Search uses a local index
and native word segmentation for Chinese and English, with no hosted search service.
Technical configuration keeps its existing English owner on GitHub.
Both homepage and documentation are uploaded in the same Pages artifact. Do not
run a second Pages deployment that would overwrite the homepage. Website/docs
sources and the nested package remain outside product-release paths; shared
product code and App/Server build inputs still follow the release boundary.
Check links, language switching, search, keyboard/mobile use and the static
HTML fallback when changing the public documentation.
## Downloads
The Windows x64, macOS Apple silicon and Linux x64 cards use fixed filenames and
each provider's native latest-attachment route:
@@ -84,7 +118,7 @@ Both links follow provider publication without a website rebuild or a release
API request from the build or visitor. Platform buttons say “Download from GitHub” /
“GitHub 下载”; the secondary link says “Download from Gitee” /
“Gitee 国内镜像下载”. Keep versions and archive formats out of these labels.
[Versioning](../reference/versioning.md#release-sources) owns package verification
[Versioning](../standards/versioning.md#release-sources) owns package verification
and completion of mirror publication. GitHub remains the primary source; the
Gitee download can temporarily lag while a mirror publication completes.
Verify the latest redirects and package checksums after releases.
+5 -5
View File
@@ -40,13 +40,13 @@ blank keeps the self-contained site open.
## Product Map
- [Rooms and access](./product/rooms-access.md) owns room codes,
- [Rooms and access](./standards/rooms-access.md) owns room codes,
invitations, code entry, credentials, and lightweight/SQLite persistence.
- [Routing and transport](./product/routing-transport.md) owns P2P-first
- [Routing and transport](./standards/routing-transport.md) owns P2P-first
distribution, endpoint capacity, SFU fallback, recovery, and privacy limits.
- [Capture and media quality](./product/media-quality.md) owns Browser capture,
- [Capture and media quality](./standards/media-quality.md) owns Browser capture,
profiles, codec selection, audio, and framework adaptation.
- [Presentation and lifecycle](./product/presentation-lifecycle.md) owns Host and
- [Presentation and lifecycle](./standards/presentation-lifecycle.md) owns Host and
Viewer workflows, playback authority, roster, state, and Browser lifecycle.
Non-obvious decisions live in [ADRs](./adr/); reproducible evidence and platform
@@ -104,7 +104,7 @@ matching Browser and App artifacts, not only an application symlink.
Current work is owned by the [TODO ledger](./todo.md). Open physical evidence is
owned by [verification status](./verification-status.md). Environment and initial
services are owned by [configuration](./reference/configuration.md) and
services are owned by [configuration](./standards/configuration.md) and
[self-hosting](./operations/self-hosting.md); application release is owned by
[deployment](./deployment.md), with exact deployed identity retained in the
release descriptor and deployment record.
+2 -2
View File
@@ -3,7 +3,7 @@
- Reviewed: 2026-08-27
- Scope: repository context, durable memory, documentation types, and decisions
- Status: evidence; repository policy is owned by
[maintenance](../maintenance.md)
[maintenance](../standards/documentation.md)
## Findings
@@ -72,4 +72,4 @@ map, parallel agent-context format, or separate RFC repository would add more
ownership than it removes. The useful practices are bounded always-on context,
one owner per fact, domain-shaped reference, evidence on demand, short ADRs,
link checks, and Git-backed history. Exact repository policy remains in
[maintenance](../maintenance.md), not in this research note.
[maintenance](../standards/documentation.md), not in this research note.
+3 -2
View File
@@ -2,8 +2,9 @@
Reviewed and executed 2026-09-09 on Windows, Chrome 152.0.7977.82.
[ADR-0014](../adr/0014-browser-node-local-encoding-pool.md) owns the selected
design. [TODO](../todo.md) owns combined Debug, package and release acceptance.
This branch has not been deployed.
design. The observations below belong to that measured baseline;
[status](../status.md) owns current adoption and release state, and
[verification status](../verification-status.md) owns remaining physical limits.
## Result And Scope
@@ -3,7 +3,7 @@
- Reviewed: 2026-08-27
- Scope: Browser display audio over direct, relay, and LiveKit paths
- Status: evidence; current controls are owned by
[media quality](../product/media-quality.md)
[media quality](../standards/media-quality.md)
## Findings
@@ -71,7 +71,7 @@ A local page completed discovery and the full control exchange in Chromium
its `loopback-network` permission; without that permission the cross-origin
request was blocked. The gate checks both expected outcomes.
Run `npm run probe:client-loopback` with `PIIK_CLIENT_LOOPBACK_GATE=true`,
Run `npm run probe:app-loopback` with `PIIK_CLIENT_LOOPBACK_GATE=true`,
`CHROME_PATH`, `PIIK_CLIENT_EXE`, and `PIIK_CLIENT_GATE_SITE_URL` pointing to
the reachable HTTPS Site being tested. Use `PIIK_CLIENT_GATE_GRANT_LOOPBACK=true`
for the permission-granted run; omit it for the permission-blocked run. The gate
+1 -1
View File
@@ -2,7 +2,7 @@
- Research date: 2026-08-26; permanent-room revision reviewed 2026-09-10
- Status: SQLite authority verified; current behavior is owned by
[rooms/access](../product/rooms-access.md)
[rooms/access](../standards/rooms-access.md)
## Current Conclusion
+1 -1
View File
@@ -1,6 +1,6 @@
# Diagnostic Feedback
Reviewed 2026-09-09. [Configuration](../reference/configuration.md#diagnostics)
Reviewed 2026-09-09. [Configuration](../standards/configuration.md#diagnostics)
owns current activation, contents, bounds and disclosure. This note records the
references and integration decisions; it is not another logging API contract.
+4 -3
View File
@@ -76,9 +76,10 @@ because its example compiles. Keep test executables under the worktree's stable
defaults. A demand-driven publisher contract equivalent to our current behavior
has not yet been established. README feature lists alone do not close this gap.
Reproduction uses `go test -c -o build/embedded-media/<name>.test.exe` for
`./internal/server/stun` and `./internal/server/sfuprobe`, then executes each
binary with `-test.v -test.timeout=15s`. Quote the dotted flags in PowerShell.
Those results came from the evaluation revision; its `sfuprobe` package is not
retained in current source. Do not run that
old package path against current source. Current STUN and SFU validation uses
the [maintained verification entry points](../../CONTRIBUTING.md#verification-entrypoints).
## Transport Direction
+1 -1
View File
@@ -39,7 +39,7 @@ LiveKit Viewer received the default 1080p source, then 15 consecutive 854x480
frames after one live Native/SFU profile change. The direct native P2P path,
Browser, App, LiveKit process, ports, and profiles all cleaned up.
The opt-in `gate:client-media` run proved, in order:
The opt-in `gate:app-media` run proved, in order:
- WGC capture and adapter-bound Media Foundation H.264 became active;
- 30+ encoded 1280x720 frames crossed the bounded process protocol;
+1 -1
View File
@@ -4,7 +4,7 @@
- Scope: Windows capture, one hardware H.264 encoder, bounded WebRTC fanout
- Status: historical evidence; obsolete sender implementation deleted
Current product scope is owned by [media quality](../product/media-quality.md).
Current product scope is owned by [media quality](../standards/media-quality.md).
[ADR-0006](../adr/0006-fixed-high-native-sender-canary.md) owns the stop line.
## Conclusion
+1 -1
View File
@@ -4,7 +4,7 @@
- Status: retained Browser relay capability and measurement evidence
Current product behavior is owned by
[Routing and transport](../product/routing-transport.md). Controller and
[Routing and transport](../standards/routing-transport.md). Controller and
capacity decisions are owned by
[ADR-0005](../adr/0005-automatic-hybrid-media-routing.md). This document does
not define route selection, wire messages, UI, or current deployment state.
+1 -1
View File
@@ -3,7 +3,7 @@
- Reviewed: 2026-08-30
- Scope: Browser game capture, codecs, startup adaptation, relay, and LiveKit
- Status: current evidence; product behavior is owned by
[media quality](../product/media-quality.md) and
[media quality](../standards/media-quality.md) and
[ADR-0007](../adr/0007-path-isolated-representation-quality.md)
## Findings
+1 -1
View File
@@ -32,7 +32,7 @@ remove.
and exact direct/peer-relayed evidence sources.
These are structural simplifications, not reproduced functional failures.
The [engineering reference](../reference/engineering.md#ablation-and-review)
The [engineering reference](../standards/engineering.md#ablation-and-review)
requires a concrete ownership gain before further extraction.
## Product Choice
+2 -2
View File
@@ -2,8 +2,8 @@
Reviewed: 2026-09-08. Source-backed review and synthetic VP8/H264 feasibility probes,
not an accepted engine replacement. [ADR-0013](../adr/0013-embedded-node-local-media.md)
still owns the direct-child/shared-output model; [TODO](../todo.md) owns work and
the dependent release hold. The question is whether retaining mature encoder
owns the direct-child/shared-output model. [Status](../status.md) owns current
adoption; this investigation asks whether retaining mature encoder
control while sharing compatible encoding work is smaller and more complete
than extending the current Native fixed-output candidate.
+31
View File
@@ -0,0 +1,31 @@
# Piik standards
Current product and engineering rules live here. Start with the affected subject;
the [ownership map](./documentation.md#owners) explains where each fact belongs.
[AGENTS.md](../../AGENTS.md) owns task authority and
[Contributing](../../CONTRIBUTING.md) owns delivery.
| Subject | Standard |
| --- | --- |
| Module responsibility, resources, interfaces and contextual consistency | [Engineering](./engineering.md) |
| Product names, terminology, Chinese/English voice and content layers | [Naming and copy](./naming.md) |
| Room authority, invitation/password access and persistence | [Rooms and access](./rooms-access.md) |
| Automatic routes, capacity, P2P/SFU transitions and route operations | [Routing and transport](./routing-transport.md) |
| Capture, encoders, media adaptation and audio | [Media quality](./media-quality.md) |
| Workflow, playback, presentation and Browser/App lifecycle | [Presentation and lifecycle](./presentation-lifecycle.md) |
| Evidence behind each status, indicator, overlay and notice | [Media status](./media-status.md) |
| Cast, symbols, colour, animation, tooltips and playful copy | [Visual language](./visual-language.md) |
| Website, README and introduction-film composition | [Public introduction](./public-introduction.md) |
| Runtime settings, defaults, bounds and diagnostics | [Configuration](./configuration.md) |
| Compatibility, build identity, publication and release notes | [Versioning](./versioning.md) |
| Documentation responsibilities and updating a rule | [Documentation ownership](./documentation.md) |
Before another implementation of a familiar concept, identify its fact, owner,
scope and lifetime. Apply the existing rule, or explain the contextual difference
at that rule's owner. Source schemas and focused tests own exact implementation
details; standards need not repeat every field or function.
[ADRs](../adr/) preserve decisions and supersession. [Research](../research/)
preserves dated observations and limits. [TODO](../todo.md) owns work that remains,
including accepted targets not yet implemented. A recorded target is not a claim
of completed verification. User guides remain in the [documentation index](../README.md).
@@ -77,9 +77,8 @@ Room authority has no idle expiry; the separate site-access cookie keeps its
Removed access, room TTL/lease, endpoint-tier, room-rollout, and TURN variables fail
startup even when blank. A present `NODE_ENV` fails the same way, so a stale
environment file cannot silently drop a deployment out of production. The
private deployment is upgraded atomically; there are no compatibility aliases or
dual configuration readers. [Versioning](./versioning.md) owns the planned public
upgrade promise and the work required before its first release.
private deployment uses its scoped atomic updater. Supported public upgrades and
any compatibility handling follow the active [versioning contract](./versioning.md).
## Piik App Configuration
@@ -100,6 +99,8 @@ changing saved settings. Older App versions ignore this file. Without a valid
preference, a saved Site is selected when present; otherwise the public
invitation link is selected. Choosing a mode does not start it until confirmed.
Local mode remains available for the same network.
Selecting Local or Public Link, including through the CLI, preserves the saved
Site and its allowed App origin; only an explicit Site edit replaces it.
For Local mode, the launcher selects a sole active address or sole private IPv4
address automatically. With several choices it shows interface names and IPs;
an ambiguous choice must be selected before launch. `--lan-address` preselects
@@ -1,10 +1,10 @@
# Documentation Ownership
The repository is the durable source across sessions, devices and agents.
Use [the documentation map](./README.md) to find a topic, then update its owner.
Use [the documentation map](../README.md) to find a topic, then update its owner.
Chat, agent summaries and an old branch do not replace current code, contracts
and evidence. Task authority stays in [AGENTS.md](../AGENTS.md); delivery and
verification procedures stay in [CONTRIBUTING.md](../CONTRIBUTING.md).
and evidence. Task authority stays in [AGENTS.md](../../AGENTS.md); delivery and
verification procedures stay in [CONTRIBUTING.md](../../CONTRIBUTING.md).
## Owners
@@ -12,16 +12,16 @@ verification procedures stay in [CONTRIBUTING.md](../CONTRIBUTING.md).
| --- | --- | --- |
| Universal constraints and navigation | `AGENTS.md` | Short mandatory boundaries and links; no procedure manual |
| Branches, review, validation, PRs, releases and cleanup | `CONTRIBUTING.md` | Executable workflow |
| Module boundaries, interface discipline and ablation | [Engineering](./reference/engineering.md) | Current responsibility map and shared coding rules |
| Names, terminology and voice | [Naming and copy](./reference/naming.md) | Product/tool names, role labels and Chinese/English writing conventions |
| Translation contributions | [Translation guide](./guide/translating.md) and its Chinese counterpart | Catalog editing, new-language registration and translation review |
| Illustration, palette, motion and accessible layout | [Visual language](./design/visual-language.md) | Shared Browser/App design grammar |
| Status projection | [Media status](./design/media-status.md) | Which facts each display surface may express |
| Release identity and compatibility | [Versioning](./reference/versioning.md) | Version roles, protected interfaces and public-readiness boundary |
| Current product behavior | `docs/product/` | One owner per domain; promises, boundaries and non-obvious invariants |
| Module boundaries, interface discipline and ablation | [Engineering](./engineering.md) | Current responsibility map and shared coding rules |
| Names, terminology and voice | [Naming and copy](./naming.md) | Product/tool names, role labels and Chinese/English writing conventions |
| Translation contributions | [Translation guide](../guide/translating.md) and its Chinese counterpart | Catalog editing, new-language registration and translation review |
| Illustration, palette, motion and accessible layout | [Visual language](./visual-language.md) | Shared Browser/App design grammar |
| Status projection | [Media status](./media-status.md) | Which facts each display surface may express |
| Release identity and compatibility | [Versioning](./versioning.md) | Version roles, protected interfaces and public-readiness boundary |
| Current product behavior | [Rooms](./rooms-access.md), [routing](./routing-transport.md), [media](./media-quality.md), [lifecycle](./presentation-lifecycle.md) | One owner per domain; promises, boundaries and non-obvious invariants |
| Architecture decisions | `docs/adr/` | Decision status, rationale and consequences; explicitly supersede changed clauses |
| Measurements and external evidence | `docs/research/` | Date, sources, observations, inference, limits and license scope |
| Deployment and configuration | `docs/deployment.md`, `docs/operations/`, `docs/reference/configuration.md` | Runnable operator reference; no per-release diary |
| Deployment and configuration | `docs/deployment.md`, `docs/operations/`, `docs/standards/configuration.md` | Runnable operator reference; no per-release diary |
| Compact product map | `docs/project-memory.md` | Stable summaries and domain navigation, not implementation inventories |
| Current execution/deployment index | `docs/status.md` | Current boundaries and pointers; exact artifact identity stays in release metadata |
| Remaining work and decisions | `docs/todo.md` | One task ledger, with executable work separated from parked ideas |
@@ -35,6 +35,13 @@ use the product; technical details link to the relevant owner. English/Chinese
entry guides are intentional translations and must change together; technical
references keep one version.
The public documentation website renders selected repository Markdown without
owning another copy. Its [reader index](../guide/README.md) serves usage and support;
the repository map remains the developer/maintainer entry. The
[website runbook](../operations/website.md#documentation-build) owns generation and
publication. Keep all troubleshooting procedures in their guide, with short links
from introductions rather than duplicate answers.
## Updating A Rule
1. Trace the current product contract, actual callers and relevant evidence.
@@ -70,14 +77,14 @@ stale procedures and duplicated prescriptions; preserve still-consumed behavior,
failure boundaries, reasoning and reproducible evidence. An ADR can retain a
historical decision when its superseded status is clear.
Document cost is part of [engineering ablation](./reference/engineering.md#ablation-and-review).
Document cost is part of [engineering ablation](./engineering.md#ablation-and-review).
Do not delete a product promise solely to shrink a file. Archive completed work
in Git, keep candidate ideas out of active instructions, and never promote an
agent suggestion to accepted policy simply by writing it in a checklist.
The top-level context stays a map. Directory-specific instructions belong near
the code only when that module exists and the rule is needed every time.
The [context-governance research](./research/agent-context-governance.md) owns
The [context-governance research](../research/agent-context-governance.md) owns
the external rationale; the existing hygiene scripts own executable limits.
Their gardening warnings invite review, not arbitrary compression; hard failures
for broken links, required owners and instruction-loading limits remain binding.
@@ -86,7 +93,7 @@ for broken links, required owners and instruction-loading limits remain binding.
For documentation work use the existing link, whitespace and repository-hygiene
checks; do not rerun unrelated media benchmarks. The shared
[`required-project-paths.txt`](../scripts/required-project-paths.txt) lists durable
[`required-project-paths.txt`](../../scripts/required-project-paths.txt) lists durable
owners for both hygiene entry points. No additional checker is needed for each
new convention.
@@ -27,7 +27,7 @@ checks the Browser, `scripts/tsconfig.json` checks tooling/tests, and root
| `scripts/` | Development checks, browser/media gates, dependency assembly and release packaging/publishing tools |
| `tests/` | TypeScript/tooling tests and cross-language fixtures; Go tests stay beside their packages |
| `deploy/` | Operator scripts and container, proxy, service and certificate templates |
| `docs/` | User guides, contracts, decisions and operations; [documentation ownership](../maintenance.md) defines each owner |
| `docs/` | User guides, contracts, decisions and operations; [documentation ownership](./documentation.md) defines each owner |
| `licenses/` | Third-party license texts, pinned notice sources and their redistribution reference |
| `.github/`, `.githooks/`, `.agents/`, `.codex/` | Platform automation and scoped agent/tool integration |
| `build/`, `node_modules/`, `coverage/` | Ignored local output and dependencies; never current product truth |
@@ -66,7 +66,7 @@ Directory names are a map, not a requirement to introduce another layer.
| Boundary | Schema/validation owners |
| --- | --- |
| Room HTTP and Browser/Server signaling | `src/shared/protocol.ts` and `internal/server/protocol`; HTTP dispatch in `internal/server/app`, WS dispatch in `signal` |
| Site access HTTP | `src/client/lib/api.ts` and `internal/server/app/json.go`; cookies and access behavior belong to [rooms/access](../product/rooms-access.md) |
| Site access HTTP | `src/client/lib/api.ts` and `internal/server/app/json.go`; cookies and access behavior belong to [rooms/access](./rooms-access.md) |
| Browser/App discovery and control | `src/client/native/wire.ts`, `internal/app/loopback/protocol.go`, `internal/app/nativecontrol/wire.go` |
| App/capture sidecar | `internal/app/nativecapture` and each platform capture adapter; probe/commands and encoded-frame envelope are distinct formats |
| Configuration and persistence | `internal/server/config`, `internal/app/config`, `internal/server/room/database.go`; operator semantics in [configuration](./configuration.md) |
@@ -133,6 +133,8 @@ above rather than searching the tree.
connection readiness, requested settings, applied settings and observation.
Derive presentation from facts; do not synchronize parallel booleans or let
a cached failure outlive its evidence.
Selecting a runtime mode is not an edit to saved preferences; compare CLI,
launcher and page write paths against the same explicit user intent.
Cumulative observations need a valid baseline for the resource generation
they describe; missing measurements are not zero. Serialized recovery reads
current committed intent when it executes rather than replaying a queued snapshot.
@@ -140,8 +142,12 @@ above rather than searching the tree.
retirement. After an await/callback, and inside cleanup, validate the original
operation/resource identity before changing current state. Cancellation,
failure, missing observation and success must retain distinct meanings.
Later cancellation must not erase a failure already observed before cleanup.
A caller deadline does not cancel a dependency that ignores it. Keep late
work bounded and serialize its cleanup; verify the dependency's actual lifetime.
Ordered shutdown may require a ready auxiliary transport to outlive the stop
request; its owner closes it after dependants retire. External convenience
actions must not block lifecycle handling, and repeated attempts stay bounded.
- Keep decisions separate from effects. Respect documented lock ordering and
revalidate after unlocked work. Local native capability never grants room
authority; the shared Go service remains authoritative.
@@ -150,6 +156,9 @@ above rather than searching the tree.
- Validate input at the boundary, keep domain error meanings stable, and map
them into shared localized UI. [Versioning](./versioning.md) determines which
extensions are compatible; an optional field is not automatically compatible.
Dispatch separates protocol rejection, per-request operation failure and an
already-satisfied teardown. Late teardown is idempotent and cannot retire a
replacement resource; retain strict validation before looking up its target.
Retained media state carries failure facts; the presentation layer resolves
localized copy during render. Keep raw exceptions in diagnostics. Transient
event notices may resolve once when the event occurs.
@@ -160,8 +169,8 @@ above rather than searching the tree.
request allowed behavior. The server enforces the policy even when a client
requests more. The UI retains the same controls and explains locked choices.
See [configuration](./configuration.md); avoid a second flag with the same job.
- Browser/App/Server share [visual language](../design/visual-language.md) and
[status projection](../design/media-status.md). A new page, locale or deployment
- Browser/App/Server share [visual language](./visual-language.md) and
[status projection](./media-status.md). A new page, locale or deployment
does not invent a new palette, status model or user-role metaphor.
- Keep package comments about current responsibilities and invariants. Historical
migration paths belong in ADR/research unless they explain a surviving constraint.
@@ -176,6 +185,43 @@ above rather than searching the tree.
resources or require explicit targets; they must not depend on a maintainer's
workstation, private deployment or machine-specific infrastructure names.
## Contextual Consistency
Repeated meaning has one owned contract across code, interaction, wording,
illustration, diagnostics and documentation. Shared components can still receive
conflicting caller policies. Locate related producers, consumers and the existing
[owner](./documentation.md#owners), then make its relevant contract explicit:
- **Meaning and evidence:** the subject, fact or action; who may assert it;
its scope and generation; how unknown differs from failure or success.
- **Context and priority:** where it applies, where it does not, which user
action it supports, and which information takes precedence.
- **Lifetime:** first appearance or acquisition, updates, replacement,
cancellation, recovery and retirement; visibility or persistence when relevant.
- **Representations:** permitted copy, icons, motion and feedback surfaces;
truthful runtime/platform adapters; shared implementation and validation owners.
Use the current owner. New shared rules need repeated callers or a public
contract, a reason, and proportionate evidence. Retire replaced rules and
consumers together.
Equal wording or appearance does not prove equal semantics. Compare authority,
subject, scope, evidence, lifetime and intended outcome before merging paths.
Signaling readiness and visible media, requested and applied settings, and a
retired operation versus its replacement must remain distinguishable. Runtime
and layout adaptations may differ while preserving the same contract. Explain
such differences at their owner; do not hide them in caller-specific flags or
force different authorities into a universal state manager.
The [consistency skill](../../.agents/skills/context-consistency/SKILL.md) owns the
review method; ordinary changes check their affected consumers.
These boundaries align with [W3C consistent identification](https://www.w3.org/WAI/WCAG21/Understanding/consistent-identification.html),
[GOV.UK contribution criteria](https://design-system.service.gov.uk/community/contribution-criteria/)
and [bounded contexts](https://martinfowler.com/bliki/BoundedContext.html): keep
repeated functions recognizable, justify shared patterns, and preserve real
domain distinctions.
## Ablation And Review
Judge a change by verified user value against implementation, maintenance,
File renamed without changes.
@@ -11,7 +11,7 @@ Frontend controls, names and details share `Tooltip`, following the
containers, with viewport-relative placement. Hover, keyboard focus and touch
hold share one visibility owner; Escape dismisses even a mouse-only hint.
`/__tooltip-preview` shows the catalogue. Playback actions and their local audio
ownership are defined in [presentation lifecycle](../product/presentation-lifecycle.md#playback-ownership);
ownership are defined in [presentation lifecycle](./presentation-lifecycle.md#playback-ownership);
`/__playback-preview` exercises the actual control bar with generated local media.
## Status Meaning
@@ -106,12 +106,19 @@ Operational pages omit introductory sentences that repeat their controls.
Helper text should explain a meaningful choice or prevent an error.
Use short, contextual references in opening lines and illustration reactions.
Waiting captions may invite a stretch or joke about the living room; they do not
narrate fictional loading steps or replace the current operation's status.
Prefer lasting expressions and genre conventions over current events or
short-lived viral phrases; title decorations follow the same rule.
A familiar expression may work when its ordinary meaning still makes sense to
someone who does not recognize it. Keep each language independent and avoid
long dialogue, borrowed brand slogans or a stream of unrelated memes. Match a
celebration to a visible successful action; it must not precede the result.
Keep the small bilingual set local, without a quotation service.
The [visual language](../design/visual-language.md) owns where these lines appear.
Keep reviewed pools local, without a quotation service.
Playful pools may differ in content and length between languages. Their context
and display lifecycle follow the [shared policy](./visual-language.md#playful-copy-lifecycle);
operational messages retain corresponding meanings and placeholders.
The [visual language](./visual-language.md) owns where these lines appear.
## Product And Tool Names
@@ -144,6 +151,13 @@ The [engineering module map](./engineering.md#module-map) owns directory
responsibilities. HTTP/WebSocket clients, signaling `clientId`, protocol clients
and similar connection roles remain `client`; they do not name the standalone App.
Build commands, workflow jobs, packaging helpers, messages and local variables
that mean the standalone executable use `App`; hosted packages use `Server`,
and browser assets use `Web` or `Browser UI`. Follow the subject through its
callers before renaming an ambiguous `client` or `application`. Public storage,
wire and environment identifiers retain their compatibility contract; list
those exceptions at their owner instead of globally replacing the word.
- Follow each language's semantics: lowercase Go packages, exported `PascalCase`
and unexported `camelCase`; existing TypeScript component/type conventions.
Brand casing must not change visibility or create unnecessary name prefixes.
@@ -32,9 +32,10 @@ Browser picker and any exact App-owned native windows. It never chooses a
window automatically. A reproduced Browser-window, capture, or background
failure is required before introducing an embedded Web runtime.
Failure to open the system Browser does not retire a ready App service. The
console keeps its entry address and retry action; service failure and explicit
exit still own shutdown. Browser-opening errors retain their underlying cause.
Opening the system Browser is an asynchronous convenience action. One pending
OS handoff is allowed; a slow or failed handler cannot block readiness or App
exit. The console retains the entry address and reports the underlying error.
The App does not terminate the user's browser to enforce a handoff deadline.
Server and App share one control layout and vocabulary. Deployment capabilities
may fix a control on or off, but do not remove its place in the interface;
@@ -80,7 +81,7 @@ preserve their saved preferences when receiving those unmarked hints.
The App console uses its own supported presentation languages and falls back
to English for additional UI languages. Text modes use concise copy.
The [visual language](../design/visual-language.md) is the single owner of
The [visual language](./visual-language.md) is the single owner of
illustrative roles and objects, semantic colour, panel grammar, motion,
accessible input and responsive hierarchy.
@@ -107,7 +108,7 @@ Pause or unavailable playback keeps it visible. Control hover, keyboard focus,
dragging and an open tooltip hold it open; mouse movement reveals it, and a
touch tap on the picture toggles only visibility. One presentation-only idle
timer owns visibility; it never changes playback or route state. Controls
follow the shared [motion grammar](../design/visual-language.md#motion-grammar).
follow the shared [motion grammar](./visual-language.md#motion-grammar).
A desktop primary click on the picture toggles local playback after a brief
double-click window; double-click toggles the available fullscreen action without
@@ -162,7 +163,7 @@ badge, status line, or overlay while committed media remains proved. A shared
visual projection maps those facts to title, media status, notices and
overlays. None of these views owns state for another. Connection availability,
actual playback and quality limitation remain separate; raw transport snapshots
do not prove playback or quality. The [status preview](../design/media-status.md)
do not prove playback or quality. The [status preview](./media-status.md)
records the visual vocabulary and the evidence needed for participant lamps.
Excluding time spent in the Browser's capture/play authorization UI, a Viewer
@@ -171,11 +172,11 @@ must continuously show a truthful accessible connection stage rather than a
black screen, ICE-connected state, or unproved `playing` event.
- A proved current frame remains visible behind non-terminal recovery state.
- The [status projection](../design/media-status.md#projection-rules) owns the
- The [status projection](./media-status.md#projection-rules) owns the
television, Header, notices, participant lamps and title. These consumers
cannot turn an unproved frame into playback or ordinary quality into failure.
- The Host's couch and Viewer overview share the
[participant projection](../design/media-status.md#projection-rules). The
[participant projection](./media-status.md#projection-rules). The
Host's own pawn has no additional lamp. Committed `mediaReady` owns readiness;
transport diagnostics and quality samples cannot override it in either direction.
- Viewers do not infer another Viewer's quality or diagnose missing readiness
@@ -230,7 +231,7 @@ warning selected from the first limited child.
Locally exposed selected-candidate addresses may be shown only on the Browser
that owns that PeerConnection and are never uploaded, persisted, or used for
identity or route selection. Explicit Debug mode can export a bounded diagnostic
report through the separate [diagnostic owner](../reference/configuration.md#diagnostics);
report through the separate [diagnostic owner](./configuration.md#diagnostics);
ordinary connection details do not collect or expose that report.
Current scope includes Web Host, Web Viewer, Browser relay, and optional Native
@@ -6,22 +6,33 @@ This file owns the website and README content structure. The
## One story, three readers
Lead with friends sharing something worth seeing: a game, a drawing, a film or
travel photos. The [copy guide](../reference/naming.md#voice-and-terminology) owns
travel photos. The [copy guide](./naming.md#voice-and-terminology) owns
Chinese/English voice and role labels. A visitor should
understand what Piik does, choose an entry and complete the first useful action.
| Surface | Reader's question | Content |
| --- | --- | --- |
| README, English and Chinese | What is this, and how do I start? | Brand introduction and interface preview, features, getting started, self-hosting, contribution and license |
| `site/`, for GitHub Pages | What does using it feel like, and how do I start? | Room illustration and primary download link, illustrated steps, demo/App/existing-site walkthroughs, FAQ, platform downloads, documentation and source |
| `site/`, for GitHub Pages | What does using it feel like, and how do I start? | Room illustration and primary download link, illustrated steps, online/App/existing-site walkthroughs, FAQ, platform downloads, self-hosting and documentation |
| Getting-started guides | Which button or mode do I use? | Browser invitation, sharing, App setup and three mode choices |
| Self-hosting guides | How do I put my own site online? | Run the binary, set the domain and HTTPS proxy, open ports and verify; service management remains a separate reference |
| Developer and operations docs | How do I build or host it? | Existing configuration, deployment, architecture and diagnostic owners |
The [documentation map](../README.md) is the Wiki-style entry. Link it from the
The [reader documentation](../guide/README.md) is the public help entry. Link it from the
website navigation and footer, with direct self-hosting, configuration and
update/recovery links in the documentation section. Keep the guides in this
repository; do not maintain a second GitHub Wiki copy of the same instructions.
The website build renders the selected guides at `/docs/`, with language navigation
and local search. The repository's [developer map](../README.md), standards,
research and work ledgers remain separate from that reader navigation.
Homepage FAQ answers questions before first use: installation, supported
platforms, audience size, sound and sharing across networks. Order them along
the visitor's decisions: device and installation requirements, joining friends,
shared media, then advanced hosting. Keep related questions together and place
material limitations beside their answers. Specific errors and recovery procedures
belong in the usage guides, reached through a short documentation link after
the FAQ. Do not duplicate troubleshooting steps on the homepage.
## Presentation
@@ -43,13 +54,20 @@ Homepage sections follow the visitor's decisions: understand Piik and find the
primary download action, see the sharing steps, choose a short tutorial, resolve
questions, then find platform downloads, detailed documentation and source.
The primary download link lets returning visitors skip the tutorials. Guide
choices run from online demo to App to an existing site, with only the demo
expanded initially. Watching requires no installation. Explain where an existing
site comes from; mark self-hosting as advanced and link its complete guide.
choices run from the online site to App to an existing Piik site, with only the
online entry expanded initially. The existing-site guide explains connecting the
App to a site run by the reader or their group, and direct browser use. The App
tutorial's mode descriptions link to that guide. When using the public site as an example,
keep its P2P-only limitation beside the explanation of optional SFU fallback.
Watching requires no installation. Self-hosting belongs in the bottom
documentation section: mark it as advanced, name its server/domain requirements
and link its complete guide. The existing-site guide links there for visitors
who want to run their own site. Explain what App and site each provide; optional SFU
fallback requires the site to enable it and is not a connectivity guarantee.
Platform download cards share one App tutorial entry. Keep section navigation
available on phones rather than hiding the only route to documentation.
The film can use bolder composition, but must show real operations and concrete
benefits before its closing action. The [copy guide](../reference/naming.md#voice-and-terminology)
benefits before its closing action. The [copy guide](./naming.md#voice-and-terminology)
owns technical disclosure; a dramatic heading does not exempt copy from it.
Review whole Chinese phrases, English meaning and adjacent number labels in
@@ -119,8 +137,11 @@ uses Chinese copy, and other languages use English. An explicit language choice
is shared between these two pages and remembered; linked language choices take
precedence when following a link.
Use static HTML, CSS, SVG and small scripts for the public pages; no router,
CMS, translation dependency or new application backend. The film's isolated
Use static HTML, CSS, SVG and small scripts for the homepage and film; no router,
CMS, translation dependency or new application backend. The documentation section
uses VitePress's standard navigation and search with Piik's colours and mascot.
It compiles existing Markdown; [website operations](../operations/website.md#documentation-build)
owns its publication list and build boundary. The film's isolated
demonstration frame bundles the existing React product components and styles,
using staged inputs. It cannot access storage, run capture or contact the room
service. The film clock owns its actions and decorative CSS animation positions.
@@ -137,7 +158,7 @@ public destinations and the separate P2P-only demo. The primary action leads
to the platform download section. The website operations guide owns each
platform's latest download routes and provider labels. Link release notes
separately and verify destinations as part of publication.
Demo, App and existing-site walkthroughs
Online, App and self-hosting walkthroughs
use real action names and explain the first sharing and viewing steps. Keep
platform requirements and connection limitations beside the relevant steps.
@@ -88,9 +88,9 @@ code-only attempts and does not silently revoke invitations.
- **Lightweight mode:** explicit `ROOM_DATABASE_PATH=:memory:` keeps room authority
in process memory and all rooms disappear on restart.
- **App Local mode:** the packaged App composes the same memory RoomStore
and ends every room when its local authority exits. It persists only its Site
choice and optional Local site-access password, not rooms or media state. A
blank password leaves that local site open.
and ends every room when its local authority exits. Saved Site/password and
last-confirmed mode preferences follow [App configuration](./configuration.md#piik-app-configuration);
rooms and media state remain process-only. A blank password leaves that site open.
Persistence evidence, limits and the earlier cross-restart evaluation live in
[cross-restart recovery research](../research/cross-restart-room-recovery.md).
@@ -2,7 +2,7 @@
This file owns the user-visible routing and transport contract.
[ADR-0005](../adr/0005-automatic-hybrid-media-routing.md) owns the controller
algorithm and resource invariants; [configuration](../reference/configuration.md)
algorithm and resource invariants; [configuration](./configuration.md)
owns ports and [self-hosting](../operations/self-hosting.md) owns service setup.
## Topology
@@ -154,7 +154,7 @@ An older draft retried after a newer release cannot take over `latest`.
`PIIK_RELEASES_ENABLED` is a GitHub repository activation variable, not a product
setting. Until the first release is explicitly enabled,
main runs validation and manual `client_checks` dispatches create candidates only.
main runs validation and manual `app_checks` dispatches create candidates only.
Ordinary branches and PRs do not run cloud CI. [GitHub operations](../operations/github.md)
owns activation and main protection; no workflow changes repository visibility.
@@ -206,6 +206,31 @@ remain unchanged. An exact tag does not move
the changelog's starting point. Keep no `CHANGELOG.md`, versioned note directory
or generated history commits. PRs and Git retain editorial provenance.
Release notes introduce the release before listing changes. In each language,
open with one or two sentences explaining its actual emphasis and who benefits:
a maintenance update, a feature addition, or a compatibility-changing release.
Match that description to the user-visible impact; an internal refactor alone
does not justify calling a release breaking or major.
Then group concrete changes under useful headings such as highlights, fixes,
upgrade instructions or known limitations. Omit empty categories and routine
implementation/test inventories. A small fix may need only its introduction and
a few bullets. Required upgrade steps or interruptions must be easy to find.
Chinese and English cover the same release facts with natural phrasing. Use
`###` for language sections and `####` for categories when both are needed.
The approved final description must read as one release when several unpublished
commits are included. Preview the rendered aggregate; do not let repeated
introductions or raw commit titles become its narrative. The existing pipeline
preserves authored Markdown and appends identity/checksums; it does not infer an
editorial summary from a version number. Keep provenance and the full changelog
link after the user-facing explanation.
Editorial references: [CC Switch releases](https://github.com/farion1231/cc-switch/releases)
and [OBS Studio releases](https://github.com/obsproject/obs-studio/releases) show
release introductions and grouped changes; Piik's exact scope and upgrade
instructions must come from its own accepted changes.
## Release Sources
GitHub Releases is the primary publication; Gitee is an optional official mirror
@@ -4,7 +4,7 @@ This is the single design requirement for Piik's shared Web/App interface:
illustration, semantic colour, panel grammar, motion and tooltip presentation.
New UI work follows this file and the local `/__tooltip-preview` catalogue.
[Media status](./media-status.md) owns which facts justify each status;
[presentation lifecycle](../product/presentation-lifecycle.md) owns interaction
[presentation lifecycle](./presentation-lifecycle.md) owns interaction
and playback. Neither an illustration nor its colour creates product state.
Use one stroke language for single concepts, small panel comics for causes and
@@ -15,7 +15,7 @@ English and pure-visual modes, in both themes and at narrow widths.
Piik's living room uses simple silhouettes, generous space, rounded controls
and a small responsive bounce. Clear hierarchy and legible interaction keep
the playful details easy to use.
The [copy guide](../reference/naming.md#voice-and-terminology) owns its voice.
The [copy guide](./naming.md#voice-and-terminology) owns its voice.
The [public introduction guide](./public-introduction.md) owns website and film
hierarchy: shared identity, current product demonstrations and promotional
composition. It applies the copy guide's audience and technical-detail layers.
@@ -24,6 +24,13 @@ Keep playfulness restrained: clean silhouettes, balanced proportions, quiet
expressions and brief gestures. Keep the interface approachable; avoid
pushing ordinary characters toward infant proportions, exaggerated
grins, dense cute decoration or children's picture-book styling.
This balance applies to all comic hints, status overlays and entry/error scenes.
Consistency governs meaning, cast and interaction, not identical compositions.
Give each explanation a recognisable little scene: a welcoming hop, a curious
peek or a character leaning into an action. Anticipation, a soft landing and
staggered responses make movement expressive. Reuse drawing primitives and motion
ownership; do not reduce distinct situations to one layout with a swapped symbol.
## Layout And Input
@@ -34,6 +41,9 @@ 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.
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
@@ -116,9 +126,16 @@ Room pawns, tooltip/status comics and introduction participants use
a round head, half-oval body and two restrained eyes. Reuse the shared
figure and face geometry in product illustrations so expressions stay inside
the head as it scales. Larger scenes can add a small lean; small status panels
may omit facial detail to keep the action clear. When a gesture needs hands,
use small floating round shapes. A held gamepad is a scene prop; a separate role
crown identifies the host and moves with the body. Its gold is an identity colour,
may omit facial detail to keep the action clear. Compact comic pawns stay small
and handless: use gaze, body lean and short hops to express their action. Keep
their scale consistent with neighbouring scenes in the same family. The relevant
door, credential or media object carries the explanation; people provide scale
and a response. Leave space around them so the action is legible. Use the
room-admission examples in `/__tooltip-preview` as a reference for compact
proportions and detail, while giving other subjects their own appropriate scene.
Large introduction illustrations may use small floating round hands to hold a prop.
A held gamepad is a scene prop; a separate role crown identifies the host and
moves with the body. Its gold is an identity colour,
not a warning or achievement. The share control retains its
cast icon. The cast represents friends sharing games, drawings
and other screen content. Exploratory figures are not alternate
@@ -211,7 +228,89 @@ Text-only surfaces retain the same meanings:
| Unknown values | `—` means unavailable/unknown, never zero or a healthy result. Keep exact values and units whenever known. |
| Capability values | `✓` / `✗` answer the adjacent boolean capability, not whether an entire route or share succeeded. |
| Shortened text | `…` means truncation. Preserve the full nickname/URL for reading and copying; never turn an exact value into a decorative code. |
| Welcome cipher | [WelcomeLine](../../src/client/components/living/WelcomeLine.tsx) owns the paired symbols and pixel words, tied to the same selected line. They are decoration, not hidden connection state. |
| Welcome cipher | The locale entry owns its two meaningful symbols; [WelcomeLine](../../src/client/components/living/WelcomeLine.tsx) renders their pixel words. They are decoration, not hidden connection state. |
### Feedback Composition
Choose the illustration from the operation's scope and the caption from its
actual state. Each feedback region has one principal illustration; a comic does
not need a second mascot or decorative spinner. The header's brand mark remains
navigation identity. Keep an actionable play symbol or an exact progress value
when it helps the user proceed or understand the operation.
| Context | Illustration and literal content | Playful caption |
| --- | --- | --- |
| Page loading, site access check, App preparation | The shared wink mascot with the actual loading operation; no room or media-path claim | The current language's waiting pool |
| Source listing/start/switch, room joining, media setup/recovery, waiting for the Host | The corresponding comic with the current status; only the existing state owner can declare waiting | The same waiting pool while the wait exists |
| Error, limitation, pause, ended room or required user action | The corresponding comic and clear reason/action; settled movement keeps that verdict readable | None |
| Small controls, tooltips and inline results | Existing action/status icon and contextual guidance when needed; retain direct actions | None |
The shared `LoadingStatus`, `StageOverlay` and `WaitingCaption` apply this
composition; source selection reuses the caption beside its existing comic.
Do not infer waiting from a chosen drawing, mascot visibility or animation flag.
Healthy playback stays visible during control reconnection; this rule does not
create another overlay. An idle room awaiting a new share differs from a closed
room. In pure-visual mode keep the same illustration and accessible operation
name, with no waiting text. Welcome ciphers retain their separate role.
At compact player sizes, omit the optional caption before shrinking the comic
or the actual status into illegibility. The existing visibility policy suspends
hidden captions; viewport size does not become product state.
### Playful Copy Lifecycle
The shared [rotation owner](../../src/client/ui/text-rotation.ts) supplies the
clock and visibility policy; its React adapter and the static website consume
the same rule. Locale files own the independent pools.
Website/App welcome lines, waiting captions and playful title variations share
one lifecycle. Show an entry immediately, then change it every eight seconds
while visible. Draw without replacement until the current pool is exhausted.
An ordinary rerender or metric update does not restart the cycle. A new semantic
context or language starts from that context's current-language pool; never
reuse a cross-language array index. Hidden surfaces stop advancing. On return,
keep the current line for a full interval; reduced motion retains a static line.
Unmount or an ended context retires timers and listeners. Empty pools omit the
decoration; one-entry pools stay static. Real completion never waits for copy
or an animation to finish. This cadence is a product choice, not measured progress.
Each language maintains independent contextual pools without equal counts,
paired translation keys or a fixed total. Contributors can add a locally natural
entry without inventing equivalents in other languages. Keep operational labels
in the ordinary translation contract. Welcome ciphers attach meaningful symbols
to the selected entry, rather than depending on a global numeric correspondence.
Pure-visual waiting keeps the shared animated scene.
Actual status, error reasons, paused/ended states, required actions and slogans
remain literal. A title retains its primary activity and any applicable warning.
Waiting decorations never invent steps, remaining time or success; they are
excluded from live status announcements. Small controls retain concise labels
and their existing motion. Graphic composition still follows its own contextual
review; this copy policy does not require a mascot or a new tooltip everywhere.
| Existing surface | Copy coverage |
| --- | --- |
| Website welcome, App launcher, sharing entry | The current language's welcome pool; the App's visual mode renders that entry's cipher. |
| Loading, source selection and media waiting | A secondary waiting caption according to [feedback composition](#feedback-composition). |
| Browser tab | Sharing, watching, starting, ready-to-share, not-started and waiting states may append their own variations; room identity, literal activity and warnings remain visible. |
| Error, pause, ended state, required action, tooltip, small control | Keep the existing literal message and contextual graphic; no rotating caption is added. |
## Panel Composition
Choose panels by what the explanation asks the reader to compare:
| Composition | Use |
| --- | --- |
| Two panels | Default for action → effect or condition → outcome. The left supplies the action, credential or context; the right explains its consequence. Examples: copying an invitation, pausing playback, entering with a room code/password/invitation. |
| One wide panel | One ongoing or settled scene without a before/after comparison, or a continuous spatial relationship that a divider would break. Examples: waiting for the Host, an unavailable source, a network round trip. Width alone is not a reason to use it. |
Use the same composition and panel roles within a semantic family. Room-entry
credential hints all use two panels: accepted credential on the left, access to
the room on the right. The door scene explains admission; it does not claim that the user is
changing privacy or has already joined. Vary the small gestures and useful props,
not the reading order. Keep full-scene states and metric diagrams in one panel
when splitting them would only duplicate the scene or interrupt its relationship.
More than two panels require distinct steps of a real sequence that cannot be
explained clearly in two; do not invent stages to fill extra panels.
## Semantic Colour And Shape
@@ -235,16 +334,17 @@ adjusting volume or offering P2P stays neutral. Actual copy feedback can change
that same hint to green or red, using the existing operation result.
Review meaning at the call site. A current admission badge shows the credential
accepted by [room access](../product/rooms-access.md), not the act of switching
privacy. Password input is not proof of admission; copying writes to the local
clipboard and does not send to a friend. An audio track's presence does not
accepted by [room access](./rooms-access.md). The private-mode control instead
shows restricted code-only entry with a closed door; its meaning must not change
to an invitation action merely because no room password is set. This room-entry
rule is separate from media-route privacy. Password input is not proof of
admission; copying writes to the local clipboard and does not send to a friend. An audio track's presence does not
establish a locked control or delivery to another device.
Two-panel comics always read before/context on the left and result/current
condition on the right. Only the result carries the semantic accent; a single
panel carries the current condition. Tooltip outline and caret use that same
tone in Chinese, English and visual modes. Neutral uses theme ink, not literal
white. Keep paper fills quiet; do not tint the whole scene green/yellow/red.
Only the result carries the semantic accent; a single panel carries the current
condition. Tooltip outline and caret use that same tone in Chinese, English and
visual modes. Neutral uses theme ink, not literal white. Keep paper fills quiet;
do not tint the whole scene green/yellow/red.
Meaning must also survive without colour through objects, symbols and accessible
localized names. Never show celebration or a completed live path as the verdict
of a failed or unavailable operation.
@@ -257,21 +357,34 @@ possibility. A rejected PiP request is failure; lack of PiP support is a limitat
| Presentation | Rule |
| --- | --- |
| Action demonstration (`demo`) | Play one meaningful transition when shown, then hold its result |
| Action demonstration (`demo`) | Explain an action with a short gesture and a readable hold; repeat while its tooltip is displayed |
| Work actually continuing (`progress`) | Repeat a gentle connection/recovery beat while that state exists |
| Settled success, failure, limitation, pause or idle (`still`) | Keep the result readable while a related subject moves briefly, then holds; do not repeat the operation, retry or celebrate |
| Settled success, failure, limitation, pause or idle (`still`) | Keep the result readable while a related character or object moves; tooltip gestures loop without re-running the actual operation or changing the verdict |
Demonstrations and progress share the existing 3.2-second comic beat. The main
action belongs in its first half, followed by a readable hold; small internal
staggering is allowed. At most two independent movers per panel. An explanatory
slider describes purpose and available range, not its live numeric value.
staggering is allowed. Keep one focal gesture at a time; other characters can
answer it with a delayed reaction. A small action and a readable pause can carry
the whole explanation; extra movement needs a purpose beyond making the scene
busier. An explanatory slider describes purpose and available range, not its
live numeric value.
Decorative sparkle must support the pictured action, never fake current success.
Reduced motion and settled states reuse each scene's **same explicit final
pose**. Disabling animation alone is insufficient if the base SVG hides the
result or shows a misleading starting pose. Static styling is scoped to its SVG;
one still tooltip must not freeze another progress scene. New keyframes must end
in the documented result rather than reset for an obsolete loop.
The shared tooltip owns repetition for hover, keyboard focus and click/tap;
scene animations inherit its repeat count instead of hard-coding one play.
Closing guidance pauses and unmounts its scene. The panel entrance plays once;
its characters and objects keep moving while shown. Standalone settled scenes
retain their brief gesture, while real progress repeats for its state's lifetime.
Reduced motion uses each scene's **explicit informative static pose**. Disabling
animation alone is insufficient if the base SVG hides the result or shows a
misleading starting pose. Settled facts stay visible during decorative movement.
Static styling is scoped to its SVG; one still tooltip must not freeze another
progress scene. Action demonstrations play forward, hold their result, then
replay; a direct cut back to the opening pose is acceptable. Seamless loops are
optional. Do not add a reverse action, extra bounce or fade just to match the
first and last frames. Natural blinking, hopping and peeking can return to rest
as part of the gesture. Keep movement brief and give the explanation quiet time.
Every comic surface, including overlays and error pages, needs visible motion
in its subject: a paused scene's steam rises, a loose cable sways, or packets
@@ -318,9 +431,10 @@ for reduced motion. Product-state comics still follow the table above.
Entry screens may carry one quiet, original welcome line in Chinese or English
text mode. Pure-visual mode pairs familiar pictograms with static pixel
pseudo-lettering tied to the same line. Hide that visual cipher from assistive
technology; the ordinary text sentence remains readable. The selected line stays stable during that visit, including mode
changes, and never replaces an action, error, loading message or connection
progress. Keep it on one line, apart from the actions; primary controls take
technology; the ordinary text sentence remains readable. Switching between text
and visual mode preserves the selected welcome entry; rotation follows the
[shared lifecycle](#playful-copy-lifecycle). The line never replaces an action,
error, loading message or connection progress. Keep it on one line, apart from the actions; primary controls take
precedence when space is tight.
## Ownership And Incremental Review
@@ -336,7 +450,9 @@ precedence when space is tight.
- For each changed scene inspect its start, transition and settled pose in
both themes, reduced motion and narrow layouts. Compare success, failure,
limitation and neutral neighbours in the catalogue. Code-level tests do not
establish visual clarity.
establish visual clarity. Judge the actual tooltip size, not only an enlarged
SVG. Improve staging, gaze and timing before enlarging figures or adding props
and effects; remove additions that do not help the meaning or the gesture.
- Update this owner when accepting a new convention. Product documents link
here instead of copying the palette, cast or animation table.
@@ -353,3 +469,8 @@ 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.
[Carbon loading guidance](https://carbondesignsystem.com/components/loading/usage/)
supports avoiding competing indicators and keeping required user actions distinct
from passive loading. Piik's immediate captions and eight-second cadence are its
own presentation choice.
+13 -5
View File
@@ -1,6 +1,6 @@
# Current Status
Last updated: 2026-09-15
Last updated: 2026-09-16
This is the compact execution/deployment index. Product modules own behavior,
[verification status](./verification-status.md) owns unresolved physical limits,
@@ -9,7 +9,7 @@ and Git/PRs own completed history.
## Accepted Release Contract
- Browser/server v23, Native control v9 and capture v7 form the current contract.
[Versioning](./reference/versioning.md#public-compatibility-promise) owns
[Versioning](./standards/versioning.md#public-compatibility-promise) owns
compatibility between published Web/App/Server versions.
- Hosted Server defaults to SQLite schema 2. Room authority has no inactivity
expiry; explicit replacement/deletion or grant rotation/revocation ends the
@@ -18,8 +18,16 @@ and Git/PRs own completed history.
- Browser/App entry lifecycle and current-edge reconnect ownership repairs
are implemented. Browser node-local pooling and detailed local Debug export
remain part of the accepted media surface. Product modules own their behavior;
[configuration](./reference/configuration.md#diagnostics) owns Debug activation,
[configuration](./standards/configuration.md#diagnostics) owns Debug activation,
report contents and disclosure.
- Shared feedback composition is implemented: generic loading uses the wink
mascot, concrete media waits use their owning comic, and waiting captions are
secondary to literal status. Website reduced-motion preference and player
tooltip placement follow the same presentation owners.
Website delivery includes a bilingual reader documentation center generated
from the repository's guides alongside the homepage, with search and detailed
troubleshooting separate from the homepage FAQ.
## Deployment
@@ -53,9 +61,9 @@ and bounded two-build interoperability have acceptance evidence. Accepted
product changes publish automatically; standalone website and documentation
changes validate and deploy the website without creating App/Server versions.
GitHub/Gitee release comparison, mirror publishing and PR-sourced notes follow
[versioning](./reference/versioning.md). [Verification status](./verification-status.md#candidate-evidence-boundary)
[versioning](./standards/versioning.md). [Verification status](./verification-status.md#candidate-evidence-boundary)
owns the remaining physical limits; shared lessons live in
[engineering](./reference/engineering.md).
[engineering](./standards/engineering.md).
## Media Evidence And Limits
+34 -29
View File
@@ -1,39 +1,52 @@
# Current TODO Ledger
Last reviewed: 2026-09-15
Last reviewed: 2026-09-16
Only **Now** is executable. Product modules own behavior; Git/PRs own completed
history. A parked idea is not implementation authority.
## Now
- [ ] **Introduction motion and presentation boundaries.** Resolve the automatic
hero-loop control requirement against the accepted design without restoring
discarded gesture controls or silently removing animation. Review only the
remaining concrete layout/accessibility choices; distinguish decorative content
from functional symbols. Keep independent correctness repairs moving.
- [ ] **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).
- [ ] **App startup feedback.** Verify [#396](https://github.com/TNTcraftHIM/Piik/issues/396)
against the current release. Its older build predates the LAN selector,
detailed launcher errors and Windows error-exit pause; those repairs do not
establish resolution on the reporting machine. Use a current Debug report if
startup still fails.
- [ ] **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
invitation service did not connect within 30 seconds. Trace tunnel startup,
network reachability and the existing timeout/error feedback using a current
Debug report; the Local fix does not establish this separate failure's cause.
- [ ] **Windows launcher exit after opening the page.** A user reports that the
mode-selection page opens, then the App console reports
`Piik App could not open its launcher: exit status 0xc0000005`.
Recheck on the reporting machine after the browser-handoff repair. The Windows
URL-handler crash itself still needs the affected build and process/dump
evidence; local checks cannot establish its underlying cause.
- [ ] **Tooltip placement around video.** After the current motion/meaning
acceptance, review placement that keeps the picture visible, including
side placement and restrained translucency. Keep neighbouring actions
reachable when a help popup remains open. Current behavior prefers above
(below for header controls) and flips for viewport space. Discuss the design
before changing placement rules.
Keep fixes on a maintenance branch until acceptance. The public release is the
compatibility baseline; private service deployment stays independent.
@@ -51,7 +64,7 @@ local listener binding as proof of public reachability. The
dependency and observation limits. Compare current P2P/SFU handoffs, including
background P2P attempts behind working SFU media, before choosing changes.
Preserve one graph and one operation under the
[routing contract](./product/routing-transport.md) and
[routing contract](./standards/routing-transport.md) and
[ADR-0005](./adr/0005-automatic-hybrid-media-routing.md). Prior ownership audits do
not establish better connection success or speed; this note adds no retry policy.
@@ -77,7 +90,7 @@ not establish better connection success or speed; this note adds no retry policy
4. **Reachable ownership/refactor work.** Retain Host/Viewer page media-session
extraction as a candidate alongside related behavior changes. Evaluate clear
resource owners, fewer shared writers and a smaller change surface under
[engineering review](./reference/engineering.md#ablation-and-review); file
[engineering review](./standards/engineering.md#ablation-and-review); file
size alone does not justify a split. The completed audits do not close this
candidate. Reopen C=3 structural-intent retention, SFU failure during unrelated
prepare and multi-child evidence ownership only with current-contract
@@ -99,10 +112,8 @@ not establish better connection success or speed; this note adds no retry policy
8. **Additional languages.** Review community catalogs and their rendered UI
following the [translation guide](./guide/translating.md). Add website,
documentation or App console translations as contributed; verify text
direction and layout when a language requires it. Before registering the
first extra UI language, fix the overflow menu's placement near the viewport
bottom: it currently permits zero content height. Check native popover
expanded/collapsed accessibility state with the rendered menu.
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.
@@ -116,9 +127,3 @@ not establish better connection success or speed; this note adds no retry policy
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.
12. **Windows capture border preference.** [#401](https://github.com/TNTcraftHIM/Piik/issues/401)
requests an optional borderless capture mode. First verify Windows'
permission-based `GraphicsCaptureAccess` / `IsBorderRequired` flow with the
packaged Native helper; keep ordinary capture working when unavailable or
denied. Browser capture indicators remain browser-owned. No new setting is
accepted yet.
+4 -4
View File
@@ -26,10 +26,10 @@ require (
github.com/pion/rtcp v1.2.17
github.com/pion/rtp v1.10.5
github.com/pion/sdp/v3 v3.0.19
github.com/pion/stun/v3 v3.1.6
github.com/pion/transport/v4 v4.1.0
github.com/pion/turn/v5 v5.0.12
github.com/pion/webrtc/v4 v4.2.18
github.com/pion/stun/v3 v3.1.7
github.com/pion/transport/v4 v4.1.1
github.com/pion/turn/v5 v5.0.13
github.com/pion/webrtc/v4 v4.2.19
github.com/thesyncim/gopus v0.1.1
go.uber.org/zap v1.28.0
golang.org/x/crypto v0.54.0
+10 -10
View File
@@ -257,16 +257,16 @@ github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ=
github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU=
github.com/pion/srtp/v3 v3.0.13 h1:FmQaqgNbN1vUtMhEsmj8trldc3lNZr1xmN7nl8CyX+Q=
github.com/pion/srtp/v3 v3.0.13/go.mod h1:7qR3L69t8RX0EPVQwGNwCa1Gy9keKKNDpWwQzZbeXDY=
github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8=
github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
github.com/pion/stun/v3 v3.1.7 h1:uRXMTlGLf89WgItGNyZ6aR5jMTX0NBbybXADpQCzn+E=
github.com/pion/stun/v3 v3.1.7/go.mod h1:Nq77RW4aRrSNrltf2ksUJLjxWeipj4lnlgdsYIxC8g8=
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pion/transport/v4 v4.1.0 h1:8S+nF2reM2cJuqC6g78OVy2BBgmbdns+acx3jA97BvQ=
github.com/pion/transport/v4 v4.1.0/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
github.com/pion/turn/v5 v5.0.12 h1:6+b69ivQQXSlyfkp2AKripqD2k3W32qXK8QzCzpJWPI=
github.com/pion/turn/v5 v5.0.12/go.mod h1:CQACsRDJtjQ+6RSrGHrS2PCIerLwbW3uqXRqOvtjAFg=
github.com/pion/webrtc/v4 v4.2.18 h1:smA/3g6Gy4RohM0VIZ5KKY/12TQbxv3XFgpUMyb2EUI=
github.com/pion/webrtc/v4 v4.2.18/go.mod h1:vmzi6s+rvhoIuT94DPqivB+0xJXs9rG4QRD+4MgBtlY=
github.com/pion/transport/v4 v4.1.1 h1:kEBtbp1sRad0tnkREDNJ3fmop/CR8p/YxXnpFbn9tqo=
github.com/pion/transport/v4 v4.1.1/go.mod h1:Oxo5oODQvjFF7NkHBBz3f4fZXLB7x03uLVuzfYeA5pE=
github.com/pion/turn/v5 v5.0.13 h1:erHOsJyxuV6QK54+PjWJhe8u1O7BM3a/US0zYJJsnx4=
github.com/pion/turn/v5 v5.0.13/go.mod h1:btdOovUYdYc8iBnvt87JHN4Pa1XV5UiLaCYe4ay3o9A=
github.com/pion/webrtc/v4 v4.2.19 h1:2usG6s7eXMF08tqqoP3A4CX5XHArZsi1qeXDIIvXMeE=
github.com/pion/webrtc/v4 v4.2.19/go.mod h1:jWMn3KOGxyYFYJohzIHhBc4VrJiyS+94A1OfErYzN34=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
@@ -292,8 +292,8 @@ github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI=
github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/thesyncim/gopus v0.1.1 h1:SM58S7VZGnEZ3ZR0pX5AoC33//LBxJhKkvm15MbBmik=
github.com/thesyncim/gopus v0.1.1/go.mod h1:orRqwrGs5gqYRRnhqwI0Y3liqQTeDkreUpra+Kv9bQc=
github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU=
+44 -30
View File
@@ -78,17 +78,7 @@ func Run(ctx context.Context, options Options) (returnedErr error) {
var restoreLogger func()
defer func() {
cancel()
if recorder != nil {
slog.Debug("piik-client", "event", "stopped", "failed", returnedErr != nil, diagnostics.Error(returnedErr))
if options.console.program == nil || returnedErr != nil {
path, err := recorder.Export()
options.console.send(consoleExportResult{path: path, err: err})
returnedErr = errors.Join(returnedErr, err)
}
restoreLogger()
returnedErr = errors.Join(returnedErr, recorder.Close())
}
returnedErr = errors.Join(returnedErr, options.console.finish(returnedErr))
returnedErr = finishRun(options.console, recorder, restoreLogger, returnedErr)
}()
enableDiagnostics := func() error {
if recorder != nil {
@@ -147,7 +137,7 @@ func Run(ctx context.Context, options Options) (returnedErr error) {
return err
}
slog.Debug("piik-client", "event", "configuration", "siteConfigured", config.Site != "", "localAccessProtected", config.LocalAccessPassword != "")
if options.SiteSet || options.Local {
if options.SiteSet {
if err = appconfig.Save(configPath, config); err != nil {
return fmt.Errorf("Piik App configuration is unavailable: %w", err)
}
@@ -177,7 +167,7 @@ func Run(ctx context.Context, options Options) (returnedErr error) {
if err != nil {
return fmt.Errorf("Piik App native control could not start: %w", err)
}
defer control.Close()
defer func() { returnedErr = errors.Join(returnedErr, control.Close()) }()
slog.Debug("piik-client", "event", "control-ready")
if options.console.machine {
if err = printEndpoint(control.Endpoint()); err != nil {
@@ -199,6 +189,24 @@ func Run(ctx context.Context, options Options) (returnedErr error) {
return runConfigured(ctx, options, config, control)
}
func finishRun(console *console, recorder *diagnostics.Recorder, restoreLogger func(), err error) error {
err = errors.Join(err, console.stop(err))
exportPath := ""
if recorder != nil {
slog.Debug("piik-client", "event", "stopped", "failed", err != nil, diagnostics.Error(err))
if console.program == nil || err != nil {
path, exportErr := recorder.Export()
console.send(consoleExportResult{path: path, err: exportErr})
err = errors.Join(err, exportErr)
}
restoreLogger()
err = errors.Join(err, recorder.Close())
exportPath = recorder.LastExportPath()
}
console.finish(err, exportPath)
return err
}
func explicitMode(options Options) bool {
return options.SiteSet || options.Local || options.Link || options.DisableBrowser
}
@@ -222,10 +230,10 @@ func runLauncher(
config appconfig.Config,
control *loopback.Server,
configureDiagnostics func(*Options, appconfig.Config) error,
) error {
) (returnedErr error) {
mode, err := launcher.LoadMode(configPath)
if err != nil {
slog.Warn("Could not restore the last launcher mode", diagnostics.Error(err))
slog.Debug("Could not restore the last launcher mode", diagnostics.Error(err))
}
launch, err := launcher.Start(
ctx,
@@ -250,7 +258,7 @@ func runLauncher(
launchAddress.Scheme+"://"+launchAddress.Host,
))
options.console.show(consoleView{state: "setup", entry: launch.URL()})
options.console.send(openBrowser(launch.URL()))
options.console.openBrowser(launch.URL())
var selection launcher.Selection
select {
@@ -265,14 +273,13 @@ func runLauncher(
}
options.console.setLanguage(selection.Language)
if err := launcher.SaveMode(configPath, selection.Mode); err != nil {
slog.Warn("Could not save the launcher mode", diagnostics.Error(err))
slog.Debug("Could not save the launcher mode", diagnostics.Error(err))
}
if selection.Mode == launcher.ModeSite {
config.Site = selection.Site
} else {
config.LocalAccessPassword = selection.LocalAccessPassword
}
options.SiteSet = false
options.Local = selection.Mode == launcher.ModeLocal
options.Link = selection.Mode == launcher.ModeLink
options.Debug = options.Debug || selection.Debug
@@ -304,6 +311,7 @@ func runLauncher(
defer func() {
cancelRuntime()
<-runtimeDone
returnedErr = errors.Join(returnedErr, runtimeErr)
}()
select {
@@ -312,7 +320,7 @@ func runLauncher(
case <-runtimeDone:
launch.SetResult("", runtimeErr)
<-launch.Handled()
return runtimeErr
return nil
case <-ctx.Done():
launch.SetResult("", ctx.Err())
return nil
@@ -322,13 +330,13 @@ func runLauncher(
case <-launch.Handled():
_ = launch.Close()
case <-runtimeDone:
return runtimeErr
return nil
case <-ctx.Done():
return nil
}
select {
case <-runtimeDone:
return runtimeErr
return nil
case <-ctx.Done():
return nil
}
@@ -344,8 +352,6 @@ func applyMode(config appconfig.Config, options Options) (appconfig.Config, erro
if err != nil || config.Site == "" {
return appconfig.Config{}, errors.New("Piik Site must be an HTTP or HTTPS origin")
}
} else if options.Local {
config.Site = ""
}
return config, nil
}
@@ -401,7 +407,7 @@ func runSite(site string, options Options, control *loopback.Server) error {
fmt.Printf("Piik Site: %s\n", site)
}
if !options.DisableBrowser {
options.console.send(openBrowser(targetURL))
options.console.openBrowser(targetURL)
} else if options.Ready != nil {
options.Ready(targetURL)
}
@@ -414,7 +420,7 @@ func runSite(site string, options Options, control *loopback.Server) error {
func runLocal(ctx context.Context, options Options, config appconfig.Config,
control *loopback.Server,
) error {
) (returnedErr error) {
view := consoleView{mode: "local", state: "starting", protected: config.LocalAccessPassword != ""}
if options.Link {
view.mode = "link"
@@ -450,9 +456,14 @@ func runLocal(ctx context.Context, options Options, config appconfig.Config,
fmt.Sprintf("http://127.0.0.1:%d", options.Port),
)
if err != nil {
// Only the caller's clean cancellation is a normal stopped outcome.
// A startup or retirement failure racing cancellation remains an error.
if err == context.Canceled && ctx.Err() == context.Canceled {
return nil
}
return err
}
defer tunnel.Close()
defer func() { returnedErr = errors.Join(returnedErr, tunnel.Close()) }()
publicOrigin = tunnel.Origin()
slog.Debug("piik-client", "event", "public-link-ready")
}
@@ -484,7 +495,7 @@ func runLocal(ctx context.Context, options Options, config appconfig.Config,
return err
}
listener = nil // Ownership is now in the application's startup/shutdown path.
defer func() { _ = endLocalServer(localServer) }()
defer func() { returnedErr = errors.Join(returnedErr, endLocalServer(localServer)) }()
if _, err = localServer.Listen(ctx); err != nil {
return err
}
@@ -514,7 +525,7 @@ func runLocal(ctx context.Context, options Options, config appconfig.Config,
}
options.console.show(view)
if !options.DisableBrowser {
options.console.send(openBrowser(launchURL))
options.console.openBrowser(launchURL)
} else if options.Ready != nil {
options.Ready(launchURL)
}
@@ -525,14 +536,17 @@ func runLocal(ctx context.Context, options Options, config appconfig.Config,
select {
case <-ctx.Done():
return endLocalServer(localServer)
return nil
case err = <-control.Done():
if err != nil {
return fmt.Errorf("Piik App native control stopped unexpectedly: %w", err)
}
return nil
case <-tunnelDone:
return errors.Join(errors.New("public invitation link stopped; reopen Piik to create a new link"), tunnel.Err())
if ctx.Err() != nil {
return nil
}
return errors.New("public invitation link stopped; reopen Piik to create a new link")
}
}
+321 -5
View File
@@ -15,9 +15,11 @@ import (
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
tea "charm.land/bubbletea/v2"
appconfig "github.com/TNTcraftHIM/Piik/internal/app/config"
"github.com/TNTcraftHIM/Piik/internal/app/lan"
"github.com/TNTcraftHIM/Piik/internal/app/launcher"
@@ -25,6 +27,318 @@ import (
serverconfig "github.com/TNTcraftHIM/Piik/internal/server/config"
)
func TestMain(tests *testing.M) {
if mode := os.Getenv("PIIK_APP_TUNNEL_FIXTURE"); mode != "" {
if err := os.WriteFile(os.Getenv("PIIK_APP_TUNNEL_STARTED"), nil, 0600); err != nil {
os.Exit(1)
}
if mode == "failed" {
os.Exit(7)
}
if mode == "ready" || mode == "ready-failed" {
fmt.Println(`{"message":"https://test-room.trycloudflare.com"}`)
fmt.Println(`{"message":"Registered tunnel connection"}`)
}
if mode == "ready-failed" {
for {
if _, err := os.Stat(os.Getenv("PIIK_APP_TUNNEL_EXIT")); err == nil {
os.Exit(7)
}
time.Sleep(time.Millisecond)
}
}
time.Sleep(10 * time.Second)
os.Exit(0)
}
os.Exit(tests.Run())
}
func TestPublicLinkStartupDistinguishesCancellationFromFailure(t *testing.T) {
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
t.Setenv("PIIK_DEBUG", "")
for _, mode := range []string{"starting", "ready", "failed", "ready-failed"} {
t.Run(mode, func(t *testing.T) {
directory := t.TempDir()
marker := filepath.Join(directory, "tunnel-started")
exit := filepath.Join(directory, "tunnel-exit")
t.Setenv("PIIK_APP_TUNNEL_FIXTURE", mode)
t.Setenv("PIIK_APP_TUNNEL_STARTED", marker)
t.Setenv("PIIK_APP_TUNNEL_EXIT", exit)
listener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := listener.Addr().(*net.TCPAddr).Port
if err = listener.Close(); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
ready := false
done := make(chan error, 1)
joined := false
defer func() {
cancel()
if !joined {
select {
case <-done:
case <-time.After(5 * time.Second):
t.Error("App tunnel fixture did not retire")
}
}
}()
go func() {
done <- Run(ctx, Options{
Link: true, DisableBrowser: true, Debug: true, LogDir: filepath.Join(directory, "logs"),
ConfigPath: filepath.Join(directory, "client.json"), Port: port,
CaptureProcess: filepath.Join(directory, "missing-capture"), TunnelProcess: executable,
Ready: func(string) {
ready = true
if mode == "ready-failed" {
if err := os.WriteFile(exit, nil, 0600); err != nil {
t.Error(err)
cancel()
}
} else {
cancel()
}
},
})
}()
if mode == "starting" {
deadline := time.Now().Add(3 * time.Second)
for {
if _, err = os.Stat(marker); err == nil {
break
}
if time.Now().After(deadline) {
t.Fatal("public link startup did not reach its tunnel")
}
time.Sleep(10 * time.Millisecond)
}
cancel()
}
select {
case err = <-done:
joined = true
if mode == "failed" {
if err == nil || !strings.Contains(err.Error(), "service exited before connecting") {
t.Fatalf("startup failure was lost: %v", err)
}
} else if mode == "ready-failed" {
if err == nil || !strings.Contains(err.Error(), "public invitation link stopped") ||
strings.Count(err.Error(), "exit status 7") != 1 {
t.Fatalf("ready tunnel failure must be retained once: %v", err)
}
} else if err != nil {
t.Fatalf("intentional %s stop became an App failure: %v", mode, err)
}
if ready != (mode == "ready" || mode == "ready-failed") {
t.Fatalf("startup readiness = %v for %s", ready, mode)
}
case <-time.After(5 * time.Second):
t.Fatal("App did not retire its local authority and tunnel")
}
content, err := os.ReadFile(filepath.Join(directory, "logs", "client.log"))
if err != nil || !strings.Contains(string(content), fmt.Sprintf(`"event":"stopped","failed":%v`, mode == "failed" || mode == "ready-failed")) {
t.Fatalf("diagnostics misclassified the App outcome: %s, %v", content, err)
}
})
}
}
type launcherRuntimeLog struct {
started chan struct{}
release <-chan struct{}
}
func (writer launcherRuntimeLog) Write(data []byte) (int, error) {
if bytes.Contains(data, []byte(`"event":"mode"`)) {
close(writer.started)
<-writer.release
}
return len(data), nil
}
func TestLauncherRetainsItsRuntimeFailureDuringCancellation(t *testing.T) {
for _, cancelBeforeFailure := range []bool{true, false} {
t.Run(fmt.Sprintf("cancel=%v", cancelBeforeFailure), func(t *testing.T) {
listener, err := net.ListenTCP("tcp4", &net.TCPAddr{IP: net.IPv4zero})
if err != nil {
t.Fatal(err)
}
defer listener.Close()
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
control, err := loopback.Start(ctx, loopback.Options{})
if err != nil {
t.Fatal(err)
}
defer control.Close()
configPath := filepath.Join(t.TempDir(), "client.json")
config, err := appconfig.LoadOrCreate(configPath)
if err != nil {
t.Fatal(err)
}
started, release := make(chan struct{}), make(chan struct{})
resume := sync.OnceFunc(func() { close(release) })
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(launcherRuntimeLog{started, release},
&slog.HandlerOptions{Level: slog.LevelDebug})))
defer slog.SetDefault(previous)
defer resume()
opened := make(chan string, 1)
console := newConsole(cancel, true)
console.openURL = func(target string) error { opened <- target; return nil }
done := make(chan error, 1)
joined := false
go func() {
done <- runLauncher(ctx, Options{Port: listener.Addr().(*net.TCPAddr).Port, console: console},
configPath, config, control, func(*Options, appconfig.Config) error { return nil })
}()
defer func() {
cancel()
resume()
if !joined {
select {
case <-done:
case <-time.After(3 * time.Second):
t.Error("launcher runtime did not retire")
}
}
}()
var target string
select {
case target = <-opened:
case <-time.After(2 * time.Second):
t.Fatal("launcher did not open")
}
requestDone := make(chan struct{})
go func() {
defer close(requestDone)
client := &http.Client{Timeout: 3 * time.Second}
response, err := client.Post(strings.TrimSuffix(target, "/client")+"/api/client-launcher/launch",
"application/json", strings.NewReader(`{"mode":"link","language":"en"}`))
if err != nil {
if !cancelBeforeFailure {
t.Error(err)
}
return
}
defer response.Body.Close()
if !cancelBeforeFailure && response.StatusCode != http.StatusServiceUnavailable {
t.Errorf("launcher did not report the runtime failure: %d", response.StatusCode)
}
}()
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("launcher selection did not start its runtime")
}
if cancelBeforeFailure {
cancel()
select {
case <-requestDone:
case <-time.After(2 * time.Second):
t.Fatal("launcher did not retire its pending selection")
}
// Hold the real port failure while the launcher handles cancellation.
select {
case err := <-done:
joined = true
t.Fatalf("launcher returned before its runtime: %v", err)
case <-time.After(25 * time.Millisecond):
}
}
resume()
select {
case err := <-done:
joined = true
var bindError *net.OpError
if !errors.As(err, &bindError) || bindError.Op != "listen" ||
strings.Count(err.Error(), "local server port is unavailable") != 1 {
t.Fatalf("launcher must retain its runtime failure once: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("launcher did not join its failed runtime")
}
select {
case <-requestDone:
case <-time.After(2 * time.Second):
t.Fatal("launcher selection did not finish")
}
})
}
}
func TestHungBrowserHandoffDoesNotBlockLauncherOrSiteExit(t *testing.T) {
t.Setenv("TERM", "dumb")
for _, mode := range []string{"launcher", "site"} {
t.Run(mode, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
control, err := loopback.Start(ctx, loopback.Options{})
if err != nil {
t.Fatal(err)
}
defer control.Close()
console := newConsole(cancel, false)
started, release := make(chan struct{}, 10), make(chan struct{})
defer close(release)
console.openURL = func(string) error { started <- struct{}{}; <-release; return errors.New("late browser result") }
options := Options{Port: 8787, console: console}
configPath := filepath.Join(t.TempDir(), "client.json")
config, err := appconfig.LoadOrCreate(configPath)
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() {
if mode == "site" {
done <- runSite("https://share.example", options, control)
} else {
done <- runLauncher(ctx, options, configPath, config, control, func(*Options, appconfig.Config) error { return nil })
}
}()
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("browser handoff was not started")
}
console.mu.Lock()
model := console.plain
console.mu.Unlock()
if model.view.entry == "" {
t.Fatal("manual entry was not available while browser opening hung")
}
for range 5 {
_, command := model.Update(tea.KeyPressMsg{Code: 'o'})
if command != nil {
command()
}
}
select {
case <-started:
t.Fatal("repeated open queued another hung handler")
case <-time.After(50 * time.Millisecond):
}
model.Update(tea.KeyPressMsg{Code: 'q'})
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("App exit waited for the browser handler")
}
console.finish(nil, "")
})
}
}
func TestBrowserOpenFailureKeepsTheAppAvailable(t *testing.T) {
// An empty launcher search path makes the real browser.Open fail on every
// supported platform without changing the user's default browser or opening it.
@@ -365,14 +679,16 @@ func TestLinkModeKeepsOneLocalAuthority(t *testing.T) {
LocalAccessPassword: "abcdefghijklmnopqrstuvwxyzABCDEF",
Site: "https://example.test",
}
selected, err := applyMode(config, Options{Link: true})
if err != nil || selected.Site != config.Site {
t.Fatalf("link mode = %+v, %v", selected, err)
for _, options := range []Options{{Link: true}, {Local: true}} {
selected, err := applyMode(config, options)
if err != nil || selected != config {
t.Fatalf("mode selection changed saved settings: %+v, %v", selected, err)
}
}
if err = validateMode(Options{Link: true, SiteSet: true}); err == nil {
if err := validateMode(Options{Link: true, SiteSet: true}); err == nil {
t.Fatal("link mode accepted a separate Site")
}
if err = validateMode(Options{Link: true, Local: true}); err == nil {
if err := validateMode(Options{Link: true, Local: true}); err == nil {
t.Fatal("link mode accepted a second Local selector")
}
}
+58 -25
View File
@@ -47,6 +47,11 @@ type consoleExportResult struct {
err error
}
type consoleResult struct {
model tea.Model
err error
}
type consoleModel struct {
view consoleView
language string
@@ -63,6 +68,7 @@ type consoleModel struct {
exporting bool
exportPath string
exportError bool
openBrowser func(string)
}
type console struct {
@@ -70,8 +76,10 @@ type console struct {
machine bool
finished bool
program *tea.Program
done chan error
done chan consoleResult
plain consoleModel
opening bool
openURL func(string) error
}
var consoleCopy = map[string][3]string{
@@ -103,18 +111,20 @@ var consoleCopy = map[string][3]string{
func newConsole(cancel context.CancelFunc, machine bool) *console {
console := &console{
machine: machine,
openURL: browser.Open,
plain: consoleModel{view: consoleView{state: "starting"}, language: defaultConsoleLanguage(), width: 76, cancel: cancel,
colors: colorprofile.Detect(os.Stdout, os.Environ()) > colorprofile.ASCII},
}
console.plain.openBrowser = console.openBrowser
if machine {
return console
}
if term.IsTerminal(os.Stdin.Fd()) && term.IsTerminal(os.Stdout.Fd()) && os.Getenv("TERM") != "dumb" {
console.program = tea.NewProgram(console.plain, tea.WithoutSignalHandler())
console.done = make(chan error, 1)
console.done = make(chan consoleResult, 1)
go func() {
_, err := console.program.Run()
console.done <- err
model, err := console.program.Run()
console.done <- consoleResult{model: model, err: err}
cancel()
}()
}
@@ -142,29 +152,52 @@ func (console *console) send(message tea.Msg) {
func (console *console) show(view consoleView) { console.send(view) }
func (console *console) setLanguage(language string) { console.send(consoleLanguage(language)) }
// Opening a page is a convenience action; the launcher and room services own
// readiness and shutdown even when the system URL handler fails.
func openBrowser(target string) consoleOpenResult {
err := browser.Open(target)
if err != nil {
slog.Warn("Could not open the system browser", diagnostics.Error(err))
// One OS handoff may remain pending, but never blocks App readiness or exit.
// Do not kill the handler on a timer: it may itself own the user's browser.
func (console *console) openBrowser(target string) {
console.mu.Lock()
if console.finished || console.opening {
console.mu.Unlock()
return
}
return consoleOpenResult{target: target, err: err}
console.opening = true
openURL := console.openURL
console.mu.Unlock()
go func() {
err := openURL(target)
if err != nil {
slog.Debug("Could not open the system browser", diagnostics.Error(err))
}
console.send(consoleOpenResult{target: target, err: err})
console.mu.Lock()
console.opening = false
console.mu.Unlock()
}()
}
func (console *console) finish(err error) error {
console.send(consoleFinished{err})
if console.program != nil {
terminalErr := <-console.done
if err != nil {
fmt.Fprintln(os.Stderr, "Piik App:", err)
}
if terminalErr != nil {
fmt.Fprintln(os.Stderr, "Piik App:", terminalErr)
}
return terminalErr
func (console *console) stop(err error) error {
if console.program == nil {
return nil
}
console.send(consoleFinished{err})
result := <-console.done
if model, ok := result.model.(consoleModel); ok {
console.plain.language = model.language
}
return result.err
}
func (console *console) finish(err error, exportPath string) {
if console.program == nil {
console.send(consoleFinished{err})
return
}
if exportPath != "" {
fmt.Fprintln(os.Stderr, "Piik App:", console.plain.text("exported"), exportPath)
}
if err != nil {
fmt.Fprintln(os.Stderr, "Piik App:", err)
}
return nil
}
func (console *console) logWriter() io.Writer {
@@ -235,8 +268,8 @@ func (model consoleModel) Update(message tea.Msg) (tea.Model, tea.Cmd) {
model.view = consoleView{mode: model.view.mode, state: "stopping"}
model.cancel()
case "o", "O":
if target := model.view.entry; target != "" {
return model, func() tea.Msg { return openBrowser(target) }
if target := model.view.entry; target != "" && model.openBrowser != nil {
return model, func() tea.Msg { model.openBrowser(target); return nil }
}
case "d", "D":
if model.debug.export != nil && !model.exporting && !model.finished && model.view.state != "stopping" {
+129
View File
@@ -1,9 +1,13 @@
package app
import (
"archive/zip"
"bytes"
"context"
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
@@ -11,9 +15,134 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/TNTcraftHIM/Piik/internal/diagnostics"
"github.com/charmbracelet/x/ansi"
)
func TestConsoleRetainsTheSavedDiagnosticPathAfterRestoration(t *testing.T) {
for _, scenario := range []string{"normal", "terminal-ended-before-export", "terminal-read-failed", "manual-result-after-terminal", "failed-export", "failed-export-keeps-path"} {
t.Run(scenario, func(t *testing.T) {
output, err := os.CreateTemp(t.TempDir(), "restored-terminal-")
if err != nil {
t.Fatal(err)
}
defer output.Close()
previous := os.Stderr
os.Stderr = output
defer func() { os.Stderr = previous }()
recorder, err := diagnostics.Open(t.TempDir(), "client", "test")
if err != nil {
t.Fatal(err)
}
defer recorder.Close()
previousLogger := slog.Default()
slog.SetDefault(recorder.Logger())
defer slog.SetDefault(previousLogger)
model := consoleModel{language: "en", width: 80, cancel: func() {}, view: consoleView{state: "ready"}}
exported, release, commandDone := make(chan struct{}), make(chan struct{}), make(chan struct{})
if scenario == "manual-result-after-terminal" {
model.debug = consoleDebug{export: func() (string, error) {
path, err := recorder.Export()
close(exported)
<-release
close(commandDone)
return path, err
}}
}
var input io.Reader
var failInput func()
readFailure := errors.New("fixture terminal read failed")
if scenario == "terminal-read-failed" {
reader, writer := io.Pipe()
defer reader.Close()
defer writer.Close()
input = reader
failInput = func() { _ = writer.CloseWithError(readFailure) }
}
var screen bytes.Buffer
console := &console{
program: tea.NewProgram(model, tea.WithInput(input), tea.WithOutput(&screen), tea.WithoutSignalHandler(), tea.WithoutRenderer()),
done: make(chan consoleResult, 1),
}
terminalEnded := make(chan struct{})
go func() {
model, err := console.program.Run()
console.done <- consoleResult{model: model, err: err}
close(terminalEnded)
}()
defer console.program.Kill()
if scenario == "manual-result-after-terminal" {
console.send(tea.KeyPressMsg{Code: 'd'})
select {
case <-exported:
case <-time.After(5 * time.Second):
close(release)
t.Fatal("manual export did not start")
}
}
if scenario != "normal" {
if failInput != nil {
failInput()
} else {
console.program.Quit()
}
select {
case <-terminalEnded:
case <-time.After(time.Second):
close(release)
t.Fatal("terminal did not stop")
}
}
var failure error
if scenario == "terminal-ended-before-export" || strings.HasPrefix(scenario, "failed-export") {
failure = errors.New("local service failed")
}
if scenario == "normal" || scenario == "failed-export-keeps-path" {
path, err := recorder.Export()
if err != nil {
t.Fatal(err)
}
console.send(consoleExportResult{path: path})
}
if strings.HasPrefix(scenario, "failed-export") {
if err := recorder.Close(); err != nil {
t.Fatal(err)
}
}
if scenario == "manual-result-after-terminal" {
close(release)
<-commandDone
}
failure = finishRun(console, recorder, func() { slog.SetDefault(previousLogger) }, failure)
path := recorder.LastExportPath()
if (path == "") != (scenario == "failed-export") {
t.Fatalf("saved path %q does not match export outcome", path)
}
if scenario == "terminal-read-failed" {
if !errors.Is(failure, readFailure) {
t.Fatalf("terminal read failure was not retained: %v", failure)
}
archive, err := zip.OpenReader(path)
if err != nil {
t.Fatalf("terminal failure did not produce a complete diagnostic report: %v", err)
}
_ = archive.Close()
log, err := os.ReadFile(recorder.LogPath())
if err != nil || !strings.Contains(string(log), `"event":"stopped","failed":true`) ||
!strings.Contains(string(log), readFailure.Error()) {
t.Fatalf("final diagnostics missed the terminal error: %s, %v", log, err)
}
}
content, err := os.ReadFile(output.Name())
if err != nil || path != "" && strings.Count(string(content), path) != 1 ||
path == "" && strings.Contains(string(content), "Saved") ||
failure != nil && !strings.Contains(string(content), failure.Error()) {
t.Fatalf("restored terminal lost the saved path or App error: %s, %v", content, err)
}
})
}
}
func TestConsoleStartsWithSystemLanguageAndAcceptsVisualSelection(t *testing.T) {
console := newConsole(func() {}, true)
if console.plain.language != defaultConsoleLanguage() || console.plain.language == "vis" {
+2
View File
@@ -38,6 +38,8 @@ type Options struct {
}
type ControlSession interface {
// Errors reject protocol violations and close this control connection.
// Valid operation failures return a response instead of ending the session.
// A nil response with no error is completed later through Events, using the request ID.
Handle(context.Context, []byte) (any, error)
Events() <-chan any
@@ -0,0 +1,224 @@
package nativecontrol
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/TNTcraftHIM/Piik/internal/app/loopback"
"github.com/TNTcraftHIM/Piik/internal/app/nativecapture"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
)
type observedProbeSession struct {
*Session
closed chan struct{}
}
func (session *observedProbeSession) Close() error {
err := session.Session.Close()
close(session.closed)
return err
}
func TestSourceProbesRetireWithTheirControlConnection(t *testing.T) {
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
for _, request := range []struct{ kind, fields string }{
{"list-sources", ""},
{"source-preview", `,"source":{"kind":"display","sourceId":"1","title":"Fixture"}`},
} {
for _, shutdown := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/shutdown=%v", request.kind, shutdown), func(t *testing.T) {
marker := filepath.Join(t.TempDir(), "probe-started")
t.Setenv("PIIK_SOURCE_PROBE_FIXTURE", marker)
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
closed := make(chan struct{})
const origin = "https://site.example"
server, err := loopback.Start(ctx, loopback.Options{AllowedOrigins: []string{origin},
NewControl: func() loopback.ControlSession {
return &observedProbeSession{Session: New(executable, nativecapture.Capabilities{}, false), closed: closed}
}})
if err != nil {
t.Fatal(err)
}
defer server.Close()
endpoint := server.Endpoint()
connection, _, err := websocket.Dial(ctx, strings.Replace(endpoint.URL, "http:", "ws:", 1)+"/control",
&websocket.DialOptions{HTTPHeader: http.Header{"Origin": {origin}},
Subprotocols: []string{loopback.ControlSubprotocol + "." + endpoint.InstanceToken}})
if err != nil {
t.Fatal(err)
}
defer connection.CloseNow()
if err = wsjson.Write(ctx, connection, json.RawMessage(`{"version":9,"id":"request_hello","type":"hello"}`)); err != nil {
t.Fatal(err)
}
var ready struct{ Type string }
if err = wsjson.Read(ctx, connection, &ready); err != nil || ready.Type != "ready" {
t.Fatalf("hello: %+v, %v", ready, err)
}
defer func() {
_ = connection.CloseNow()
_ = server.Close()
select {
case <-closed:
case <-time.After(5 * time.Second):
t.Error("native probe fixture did not retire")
}
}()
payload := fmt.Sprintf(`{"version":9,"id":"request_probe","type":%q%s}`, request.kind, request.fields)
if err = wsjson.Write(ctx, connection, json.RawMessage(payload)); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(2 * time.Second)
for {
if _, err = os.Stat(marker); err == nil {
break
}
if time.Now().After(deadline) {
t.Fatal("source probe did not start")
}
time.Sleep(10 * time.Millisecond)
}
if shutdown {
if err = server.Close(); err != nil {
t.Fatalf("source probe exceeded ordered shutdown: %v", err)
}
} else {
_ = connection.CloseNow()
}
select {
case <-closed:
case <-time.After(2 * time.Second):
t.Fatal("source probe retained its native session after control closed")
}
})
}
}
}
func assertOperationFailure(t *testing.T, session *Session, payload string) {
t.Helper()
value, err := session.Handle(t.Context(), []byte(payload))
failed, ok := value.(requestFailedResponse)
if err != nil || !ok || failed.Type != "request-failed" || failed.Code != "operation-failed" {
t.Fatalf("operation failure escaped its request: %#v, %v", value, err)
}
}
func TestOperationalFailureAndIdempotentStopKeepTheRealControlConnection(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
const origin = "https://site.example"
server, err := loopback.Start(ctx, loopback.Options{AllowedOrigins: []string{origin},
NewControl: func() loopback.ControlSession { return New("missing-capture", nativecapture.Capabilities{}, false) }})
if err != nil {
t.Fatal(err)
}
defer server.Close()
endpoint := server.Endpoint()
connection, _, err := websocket.Dial(ctx, strings.Replace(endpoint.URL, "http:", "ws:", 1)+"/control",
&websocket.DialOptions{HTTPHeader: http.Header{"Origin": {origin}},
Subprotocols: []string{loopback.ControlSubprotocol + "." + endpoint.InstanceToken}})
if err != nil {
t.Fatal(err)
}
defer connection.CloseNow()
for index, command := range []struct{ kind, fields, expected string }{
{"hello", "", "ready"},
{"start-share", `,"shareId":"missing_share","source":{"kind":"display","sourceId":"1","title":"Fixture"},"edgeCapacity":1,"codec":"vp8","profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced"}`, "request-failed"},
{"prepare-edge", `,"shareId":"missing_share","connectionId":"edge_123456","iceServers":[]`, "request-failed"},
{"edge-answer", `,"shareId":"missing_share","connectionId":"edge_123456","sdp":"v=0"`, "request-failed"},
{"receive-candidate", `,"shareId":"missing_share","connectionId":"edge_123456","candidate":null`, "request-failed"},
{"pause-share", `,"shareId":"missing_share","paused":true`, "request-failed"},
{"publication-media", `,"shareId":"missing_share","publicationGeneration":"publication_123","connectionId":"edge_123456"`, "request-failed"},
{"stop-share", `,"shareId":"missing_share"`, "share-stopped"},
{"stop-share", `,"shareId":"missing_share"`, "share-stopped"},
{"close-publication", `,"shareId":"missing_share","publicationGeneration":"publication_123","connectionId":"edge_123456"`, "publication-closed"},
{"ping", "", "pong"},
} {
id := fmt.Sprintf("request_%d", index)
payload := fmt.Sprintf(`{"version":9,"id":%q,"type":%q%s}`, id, command.kind, command.fields)
if err := wsjson.Write(ctx, connection, json.RawMessage(payload)); err != nil {
t.Fatal(err)
}
var received struct{ ID, Type, Code string }
if err := wsjson.Read(ctx, connection, &received); err != nil {
t.Fatalf("%s closed control: %v", command.kind, err)
}
if received.ID != id || received.Type != command.expected ||
(command.expected == "request-failed" && received.Code != "operation-failed") {
t.Fatalf("%s response: %+v", command.kind, received)
}
}
// A gone target must not bypass strict publication validation.
if err := wsjson.Write(ctx, connection, json.RawMessage(`{"version":9,"id":"request_invalid","type":"close-publication","shareId":"missing_share","publicationGeneration":"publication_123","connectionId":"edge_123456","extra":true}`)); err != nil {
t.Fatal(err)
}
if _, _, err := connection.Read(ctx); websocket.CloseStatus(err) != websocket.StatusPolicyViolation {
t.Fatalf("malformed command did not close as a protocol violation: %v", err)
}
}
func TestFailedEdgeAndLateStopDoNotRetireAnotherShare(t *testing.T) {
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
t.Setenv("PIIK_QUIET_CAPTURE_FIXTURE", t.TempDir())
session := New(executable, nativecapture.Capabilities{SoftwareVP8: true}, false)
defer session.Close()
const start = `{"version":9,"id":"request_start","type":"start-share","shareId":"share_123456","source":{"kind":"display","sourceId":"1","title":"Fixture"},"audio":false,"edgeCapacity":1,"codec":"vp8","profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced"}}`
if value, err := session.Handle(t.Context(), []byte(start)); err != nil {
t.Fatal(err)
} else if _, ok := value.(shareStartedResponse); !ok {
t.Fatalf("start failed: %#v", value)
}
host := session.current("share_123456")
if host == nil {
t.Fatal("share was not installed")
}
const edge = `{"version":9,"id":"request_edge","type":"prepare-local-edge","shareId":"share_123456","connectionId":"edge_123456"}`
if value, err := session.Handle(t.Context(), []byte(edge)); err != nil {
t.Fatal(err)
} else if _, ok := value.(edgeOfferResponse); !ok {
t.Fatalf("edge preparation failed: %#v", value)
}
assertOperationFailure(t, session, edge)
assertOperationFailure(t, session, `{"version":9,"id":"request_edge","type":"prepare-edge","shareId":"stale_share","connectionId":"edge_123456","iceServers":[]}`)
if session.current("share_123456") != host {
t.Fatal("failed edge retired the current share")
}
// The underlying owner can finish before the Browser's stop command arrives.
_ = host.Close()
deadline := time.Now().Add(time.Second)
for session.current("share_123456") != nil {
if time.Now().After(deadline) {
t.Fatal("watchHost did not retire the source")
}
time.Sleep(time.Millisecond)
}
if _, err := session.Handle(t.Context(), []byte(strings.ReplaceAll(start, "share_123456", "share_654321"))); err != nil {
t.Fatal(err)
}
for range 2 {
value, err := session.Handle(t.Context(), []byte(`{"version":9,"id":"request_stop","type":"stop-share","shareId":"share_123456"}`))
if err != nil || value != response(requestEnvelope{Version: 9, ID: "request_stop"}, "share-stopped") {
t.Fatalf("late stop: %#v, %v", value, err)
}
}
if session.current("share_654321") == nil {
t.Fatal("late stop retired the replacement share")
}
}
+46 -37
View File
@@ -11,73 +11,82 @@ func (session *Session) handlePublication(envelope requestEnvelope, payload []by
var identity publicationIdentity
if err := json.Unmarshal(payload, &identity); err != nil ||
!validIdentities(identity.ShareID, identity.PublicationGeneration, identity.ConnectionID) {
return nil, errors.New("native publication identity is invalid")
return nil, protocolViolation("native publication identity is invalid")
}
// Validate the command before looking up a possibly retired target.
var servers []webrtc.ICEServer
var answer publicationAnswerRequest
var candidate publicationCandidateRequest
var layers publicationLayersRequest
switch envelope.Type {
case "prepare-publication":
var request preparePublicationRequest
if err := decodeStrict(payload, &request); err != nil {
return nil, err
}
var err error
servers, err = pionICEServers(request.ICEServers)
if err != nil {
return nil, err
}
case "close-publication", "publication-media":
if err := decodeStrict(payload, &identity); err != nil {
return nil, err
}
case "publication-answer":
if err := decodeStrict(payload, &answer); err != nil || len(answer.SDP) == 0 || len(answer.SDP) > maxSDPBytes {
return nil, protocolViolation("native publication answer is invalid")
}
case "publication-candidate":
if err := decodeStrict(payload, &candidate); err != nil || !validCandidate(candidate.Candidate) {
return nil, protocolViolation("native publication candidate is invalid")
}
case "publication-layers":
if err := decodeStrict(payload, &layers); err != nil || layers.ActiveCount == nil || *layers.ActiveCount < 0 || *layers.ActiveCount > 3 {
return nil, protocolViolation("native publication layer demand is invalid")
}
}
host := session.current(identity.ShareID)
if envelope.Type == "close-publication" {
if host != nil {
host.ClosePublication(identity.PublicationGeneration, identity.ConnectionID)
}
return response(envelope, "publication-closed"), nil
}
if host == nil {
return nil, errors.New("native share does not exist")
}
result := publicationResponse{ShareID: identity.ShareID,
PublicationGeneration: identity.PublicationGeneration, ConnectionID: identity.ConnectionID}
if envelope.Type == "prepare-publication" {
var request preparePublicationRequest
if err := decodeStrict(payload, &request); err != nil {
return nil, err
}
servers, err := pionICEServers(request.ICEServers)
if err != nil {
return nil, err
}
offer, media, err := host.PreparePublication(identity.PublicationGeneration, identity.ConnectionID, servers)
if err != nil {
return operationFailure(envelope, err), nil
return nil, err
}
result.responseEnvelope, result.SDP, result.Media = response(envelope, "publication-offer"), offer.SDP, media
return result, nil
}
if envelope.Type == "close-publication" {
if err := decodeStrict(payload, &identity); err != nil {
return nil, err
}
host.ClosePublication(identity.PublicationGeneration, identity.ConnectionID)
return response(envelope, "publication-closed"), nil
}
publication, err := host.Publication(identity.PublicationGeneration, identity.ConnectionID)
if err != nil {
return operationFailure(envelope, err), nil
return nil, err
}
accepted := ""
switch envelope.Type {
case "publication-media":
if err = decodeStrict(payload, &identity); err != nil {
return nil, err
}
result.responseEnvelope, result.Media = response(envelope, "publication-media"), publication.Media()
return result, nil
case "publication-answer":
var request publicationAnswerRequest
if err = decodeStrict(payload, &request); err != nil || len(request.SDP) == 0 || len(request.SDP) > maxSDPBytes {
return nil, errors.New("native publication answer is invalid")
}
err = publication.SetAnswer(webrtc.SessionDescription{Type: webrtc.SDPTypeAnswer, SDP: request.SDP})
err = publication.SetAnswer(webrtc.SessionDescription{Type: webrtc.SDPTypeAnswer, SDP: answer.SDP})
accepted = "publication-answer-accepted"
case "publication-candidate":
var request publicationCandidateRequest
if err = decodeStrict(payload, &request); err != nil || !validCandidate(request.Candidate) {
return nil, errors.New("native publication candidate is invalid")
}
err = publication.AddRemoteCandidate(request.Candidate)
err = publication.AddRemoteCandidate(candidate.Candidate)
accepted = "publication-candidate-accepted"
case "publication-layers":
var request publicationLayersRequest
if err = decodeStrict(payload, &request); err != nil || request.ActiveCount == nil || *request.ActiveCount < 0 || *request.ActiveCount > 3 {
return nil, errors.New("native publication layer demand is invalid")
}
err = publication.SetActiveCount(*request.ActiveCount)
err = publication.SetActiveCount(*layers.ActiveCount)
accepted = "publication-layers-accepted"
}
if err != nil {
return operationFailure(envelope, err), nil
return nil, err
}
return response(envelope, accepted), nil
}
+41 -27
View File
@@ -83,6 +83,12 @@ func (session *Session) Events() <-chan any {
return session.events
}
type protocolViolation string
func (err protocolViolation) Error() string { return string(err) }
// Malformed commands close the control connection. Valid commands that cannot
// complete fail only their request; an already-retired teardown target is ACKed.
func (session *Session) Handle(ctx context.Context, payload []byte) (result any, returnedErr error) {
var envelope requestEnvelope
if err := decodeEnvelope(payload, &envelope); err != nil {
@@ -91,6 +97,11 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
complete := session.traceRequest(envelope, payload)
asynchronous := false
defer func() {
var invalid protocolViolation
if returnedErr != nil && !errors.As(returnedErr, &invalid) {
result = operationFailure(envelope, returnedErr)
returnedErr = nil
}
if !asynchronous {
complete(result, returnedErr)
}
@@ -101,7 +112,7 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
case "capture-options":
var request captureOptionsRequest
if err := decodeStrict(payload, &request); err != nil || request.Type != envelope.Type {
return nil, errors.New("native capture-options request is invalid")
return nil, protocolViolation("native capture-options request is invalid")
}
return captureOptionsResponse{
responseEnvelope: response(envelope, "capture-options"),
@@ -110,9 +121,9 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
case "list-sources":
var request listSourcesRequest
if err := decodeStrict(payload, &request); err != nil || request.Type != envelope.Type {
return nil, errors.New("native list-sources request is invalid")
return nil, protocolViolation("native list-sources request is invalid")
}
targets, err := nativecapture.ListSources(session.ctx, session.captureProcess)
targets, err := nativecapture.ListSources(ctx, session.captureProcess)
if err != nil {
return operationFailure(envelope, err), nil
}
@@ -123,10 +134,10 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
case "source-preview":
var request sourcePreviewRequest
if err := decodeStrict(payload, &request); err != nil || request.Type != envelope.Type {
return nil, errors.New("native source-preview request is invalid")
return nil, protocolViolation("native source-preview request is invalid")
}
preview, err := nativecapture.PreviewSource(
session.ctx,
ctx,
session.captureProcess,
request.Source,
)
@@ -147,11 +158,11 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
if err := decodeStrict(payload, &request); err != nil ||
request.Type != envelope.Type ||
!identityPattern.MatchString(request.ShareID) ||
!request.Source.Valid() ||
request.EdgeCapacity < 1 || request.EdgeCapacity > maxEdgeCapacity ||
(request.Codec != "auto" && request.Codec != "h264" && request.Codec != "vp8") ||
(request.Codec == "vp8" && !session.capabilities.SoftwareVP8) ||
!validQualitySettings(request.Profile) {
return nil, errors.New("native start-share request is invalid")
return nil, protocolViolation("native start-share request is invalid")
}
slog.Debug("piik-client", "event", "native-profile-requested", "requestId", envelope.ID,
"share", diagnostics.ID(request.ShareID), "profile", nativeQualityProfile(request.Profile), "codec", request.Codec, "audio", request.Audio,
@@ -163,7 +174,7 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
request.Type != envelope.Type ||
!validIdentities(request.ShareID) ||
!validQualitySettings(request.Profile) {
return nil, errors.New("native update-share request is invalid")
return nil, protocolViolation("native update-share request is invalid")
}
profile := nativeQualityProfile(request.Profile)
slog.Debug("piik-client", "event", "native-profile-requested", "requestId", envelope.ID,
@@ -184,7 +195,7 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
if err := decodeStrict(payload, &request); err != nil ||
request.Type != envelope.Type ||
!validIdentities(request.ShareID) || !request.Source.Valid() {
return nil, errors.New("native replace-share-source request is invalid")
return nil, protocolViolation("native replace-share-source request is invalid")
}
host := session.current(request.ShareID)
if host == nil {
@@ -221,7 +232,7 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
if err := decodeStrict(payload, &request); err != nil ||
request.Type != envelope.Type ||
!validIdentities(request.ShareID, request.ConnectionID) {
return nil, errors.New("native prepare-edge request is invalid")
return nil, protocolViolation("native prepare-edge request is invalid")
}
return session.prepareEdge(envelope, request)
case "prepare-local-edge":
@@ -229,7 +240,7 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
if err := decodeStrict(payload, &request); err != nil ||
request.Type != envelope.Type ||
!validIdentities(request.ShareID, request.ConnectionID) {
return nil, errors.New("native prepare-local-edge request is invalid")
return nil, protocolViolation("native prepare-local-edge request is invalid")
}
return session.prepareLocalEdge(envelope, request)
case "receive-offer":
@@ -239,7 +250,7 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
!validIdentities(request.ShareID, request.ConnectionID) ||
request.EdgeCapacity < 1 || request.EdgeCapacity > maxEdgeCapacity ||
len(request.SDP) == 0 || len(request.SDP) > maxSDPBytes {
return nil, errors.New("native receive-offer request is invalid")
return nil, protocolViolation("native receive-offer request is invalid")
}
return session.receiveOffer(envelope, request)
case "receive-candidate":
@@ -248,7 +259,7 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
request.Type != envelope.Type ||
!validIdentities(request.ShareID, request.ConnectionID) ||
!validCandidate(request.Candidate) {
return nil, errors.New("native receive-candidate request is invalid")
return nil, protocolViolation("native receive-candidate request is invalid")
}
viewer := session.currentViewer(request.ShareID)
if viewer == nil {
@@ -263,7 +274,7 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
if err := decodeStrict(payload, &request); err != nil ||
request.Type != envelope.Type ||
!validIdentities(request.ShareID, request.ConnectionID) {
return nil, errors.New("native close-receiver request is invalid")
return nil, protocolViolation("native close-receiver request is invalid")
}
if viewer := session.currentViewer(request.ShareID); viewer != nil {
viewer.CloseReceiver(request.ConnectionID)
@@ -273,7 +284,7 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
var request stopReceiveRequest
if err := decodeStrict(payload, &request); err != nil ||
request.Type != envelope.Type || !validIdentities(request.ShareID) {
return nil, errors.New("native stop-receive request is invalid")
return nil, protocolViolation("native stop-receive request is invalid")
}
session.stopViewer(request.ShareID)
return response(envelope, "receive-stopped"), nil
@@ -283,7 +294,7 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
request.Type != envelope.Type ||
!validIdentities(request.ShareID, request.ConnectionID) ||
len(request.SDP) == 0 || len(request.SDP) > maxSDPBytes {
return nil, errors.New("native edge-answer request is invalid")
return nil, protocolViolation("native edge-answer request is invalid")
}
media := session.currentMedia(request.ShareID)
if media == nil {
@@ -302,7 +313,7 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
request.Type != envelope.Type ||
!validIdentities(request.ShareID, request.ConnectionID) ||
!validCandidate(request.Candidate) {
return nil, errors.New("native edge-candidate request is invalid")
return nil, protocolViolation("native edge-candidate request is invalid")
}
media := session.currentMedia(request.ShareID)
if media == nil {
@@ -317,7 +328,7 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
if err := decodeStrict(payload, &request); err != nil ||
request.Type != envelope.Type ||
!validIdentities(request.ShareID, request.ConnectionID) {
return nil, errors.New("native close-edge request is invalid")
return nil, protocolViolation("native close-edge request is invalid")
}
if media := session.currentMedia(request.ShareID); media != nil {
media.CloseEdge(request.ConnectionID)
@@ -328,7 +339,7 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
if err := decodeStrict(payload, &request); err != nil ||
request.Type != envelope.Type ||
!identityPattern.MatchString(request.ShareID) {
return nil, errors.New("native stop-share request is invalid")
return nil, protocolViolation("native stop-share request is invalid")
}
err := session.stopShare(request.ShareID)
if err != nil {
@@ -340,7 +351,7 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
if err := decodeStrict(payload, &request); err != nil ||
request.Type != envelope.Type ||
!validIdentities(request.ShareID) {
return nil, errors.New("native pause-share request is invalid")
return nil, protocolViolation("native pause-share request is invalid")
}
host := session.current(request.ShareID)
if host == nil {
@@ -351,7 +362,7 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (result any,
"share", diagnostics.ID(request.ShareID), "paused", request.Paused)
return response(envelope, "share-paused"), nil
default:
return nil, errors.New("native control message is unsupported")
return nil, protocolViolation("native control message is unsupported")
}
}
@@ -440,6 +451,9 @@ func (session *Session) startShare(
request startShareRequest,
) (any, error) {
profile := nativeQualityProfile(request.Profile)
if request.Codec == "vp8" && !session.capabilities.SoftwareVP8 {
return nil, errors.New("native VP8 encoding is unavailable")
}
session.mu.Lock()
if session.updateDone != nil {
session.mu.Unlock()
@@ -623,7 +637,7 @@ func (session *Session) stopShare(shareID string) error {
host := session.host
if host == nil || host.ShareID() != shareID {
session.mu.Unlock()
return errors.New("native share does not exist")
return nil
}
session.host = nil
updateDone := session.updateDone
@@ -841,17 +855,17 @@ func eventMessage(event nativehost.Event) any {
func pionICEServers(values []iceServer) ([]webrtc.ICEServer, error) {
if len(values) > maxICEServers {
return nil, errors.New("native ICE server list is too large")
return nil, protocolViolation("native ICE server list is too large")
}
result := make([]webrtc.ICEServer, 0, len(values))
for _, server := range values {
if len(server.URLs) == 0 || len(server.URLs) > maxURLsPerServer {
return nil, errors.New("native ICE server is invalid")
return nil, protocolViolation("native ICE server is invalid")
}
urls := make([]string, 0, len(server.URLs))
for _, value := range server.URLs {
if !validSTUNURL(value) {
return nil, errors.New("native STUN URL is invalid")
return nil, protocolViolation("native STUN URL is invalid")
}
urls = append(urls, value)
}
@@ -916,7 +930,7 @@ func decodeEnvelope(payload []byte, envelope *requestEnvelope) error {
if err := decoder.Decode(envelope); err != nil || decoder.Decode(&struct{}{}) != io.EOF ||
envelope.Version != loopback.ProtocolVersion ||
!identityPattern.MatchString(envelope.ID) || envelope.Type == "" {
return errors.New("native control envelope is invalid")
return protocolViolation("native control envelope is invalid")
}
return nil
}
@@ -925,7 +939,7 @@ func decodeStrict(payload []byte, value any) error {
decoder := json.NewDecoder(bytes.NewReader(payload))
decoder.DisallowUnknownFields()
if err := decoder.Decode(value); err != nil || decoder.Decode(&struct{}{}) != io.EOF {
return errors.New("native control message is invalid")
return protocolViolation("native control message is invalid")
}
return nil
}
+10 -11
View File
@@ -20,6 +20,13 @@ import (
)
func TestMain(tests *testing.M) {
if marker := os.Getenv("PIIK_SOURCE_PROBE_FIXTURE"); marker != "" {
if err := os.WriteFile(marker, nil, 0600); err != nil {
os.Exit(1)
}
time.Sleep(10 * time.Second)
os.Exit(0)
}
if directory := os.Getenv("PIIK_QUIET_CAPTURE_FIXTURE"); directory != "" {
runQuietCaptureFixture(directory)
os.Exit(0)
@@ -333,9 +340,7 @@ func TestPrepareLocalEdgeOwnsOneStrictRequestShape(t *testing.T) {
session := New("missing-capture-process", nativecapture.Capabilities{}, false)
t.Cleanup(func() { _ = session.Close() })
valid := `{"version":9,"id":"request_local_edge","type":"prepare-local-edge","shareId":"share_123456","connectionId":"edge_1234567"}`
if _, err := session.Handle(t.Context(), []byte(valid)); err == nil || err.Error() != "native media source does not exist" {
t.Fatalf("valid local-edge request stopped at wrong boundary: %v", err)
}
assertOperationFailure(t, session, valid)
invalid := `{"version":9,"id":"request_local_edge","type":"prepare-local-edge","shareId":"share_123456","connectionId":"edge_1234567","iceServers":[]}`
if _, err := session.Handle(t.Context(), []byte(invalid)); err == nil || err.Error() != "native prepare-local-edge request is invalid" {
t.Fatalf("extended local-edge request was accepted: %v", err)
@@ -399,10 +404,7 @@ func TestUpdateShareRequiresTheCurrentStrictProfile(t *testing.T) {
session := New("missing-capture-process", nativecapture.Capabilities{}, false)
t.Cleanup(func() { _ = session.Close() })
valid := `{"version":9,"id":"request_update","type":"update-share","shareId":"share_123456","profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music"}}`
if _, err := session.Handle(t.Context(), []byte(valid)); err == nil ||
err.Error() != "native share does not exist" {
t.Fatalf("valid update stopped at wrong boundary: %v", err)
}
assertOperationFailure(t, session, valid)
invalid := `{"version":9,"id":"request_update","type":"update-share","shareId":"share_123456","profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music","extra":true}}`
if _, err := session.Handle(t.Context(), []byte(invalid)); err == nil ||
err.Error() != "native update-share request is invalid" {
@@ -414,10 +416,7 @@ func TestReplaceShareSourceKeepsOneStrictTargetShape(t *testing.T) {
session := New("missing-capture-process", nativecapture.Capabilities{}, false)
t.Cleanup(func() { _ = session.Close() })
valid := `{"version":9,"id":"request_source","type":"replace-share-source","shareId":"share_123456","source":{"kind":"picker","sourceId":"1","title":"Portal"},"audio":false,"adapterIndex":0,"encoderIndex":0}`
if _, err := session.Handle(t.Context(), []byte(valid)); err == nil ||
err.Error() != "native share does not exist" {
t.Fatalf("valid source replacement stopped at wrong boundary: %v", err)
}
assertOperationFailure(t, session, valid)
invalid := `{"version":9,"id":"request_source","type":"replace-share-source","shareId":"share_123456","source":{"kind":"picker","sourceId":"1","title":"Portal"},"audio":false,"adapterIndex":0,"encoderIndex":0,"extra":true}`
if _, err := session.Handle(t.Context(), []byte(invalid)); err == nil ||
err.Error() != "native replace-share-source request is invalid" {
+20 -30
View File
@@ -559,11 +559,20 @@ func (session *Session) Close() error {
return nil
}
session.closed = true
source := session.source
stream := session.stream
audioStream := session.audioStream
session.edges = make(map[string]*mediaedge.Edge)
session.mu.Unlock()
session.retire()
_, _ = <-session.done
return nil
}
// Both explicit Close and source termination retire these idempotent owners.
// Do not wait for run here: Close uses this to unblock run's readers.
func (session *Session) retire() {
session.mu.Lock()
source, stream := session.source, session.stream
audioSource, audioStream := session.audioSource, session.audioStream
session.mu.Unlock()
if source != nil {
_ = source.Close()
}
@@ -571,12 +580,10 @@ func (session *Session) Close() error {
if audioStream != nil {
_ = audioStream.Close()
}
if session.audioSource != nil {
_ = session.audioSource.Close()
if audioSource != nil {
_ = audioSource.Close()
}
_ = session.engine.Close()
_, _ = <-session.done
return nil
}
func (session *Session) edge(connectionID string) *mediaedge.Edge {
@@ -625,29 +632,12 @@ func (session *Session) run() {
}
<-audioDone
<-qualityDone
defer func() {
if session.source != nil {
_ = session.source.Close()
}
if session.audioSource != nil {
_ = session.audioSource.Close()
}
_ = session.engine.Close()
session.mu.Lock()
stream := session.stream
audioStream := session.audioStream
session.mu.Unlock()
_ = stream.Close()
if audioStream != nil {
_ = audioStream.Close()
}
if slog.Default().Enabled(session.ctx, slog.LevelDebug) {
slog.Debug("piik-client", "event", "share-ended", "share", diagnostics.ID(session.shareID), "failed", result != nil, diagnostics.Error(result))
}
session.done <- result
close(session.done)
}()
return
session.retire()
if slog.Default().Enabled(session.ctx, slog.LevelDebug) {
slog.Debug("piik-client", "event", "share-ended", "share", diagnostics.ID(session.shareID), "failed", result != nil, diagnostics.Error(result))
}
session.done <- result
close(session.done)
}
func (session *Session) runVideo() error {
+17 -8
View File
@@ -63,6 +63,9 @@ func Start(
if parent == nil {
parent = context.Background()
}
if err := parent.Err(); err != nil {
return nil, err
}
if err := validateExecutable(executable); err != nil {
return nil, err
}
@@ -73,7 +76,10 @@ func Start(
if err != nil {
return nil, err
}
ctx, cancel := context.WithCancel(parent)
// The caller cancels startup, then explicitly closes a ready tunnel after
// sending room-closed and draining its server. Root cancellation cannot
// kill that transport ahead of the ordered shutdown.
ctx, cancel := context.WithCancel(context.WithoutCancel(parent))
child := exec.CommandContext(
ctx,
executable,
@@ -142,16 +148,18 @@ func Start(
go readOutput(stderr)
go func() {
waitErr := child.Wait()
// Wait closes the pipe readers; join them before App closes its recorder.
// Do not wait before Wait: that would bypass its bounded pipe shutdown.
outputDone.Wait()
_ = os.Remove(configPath)
// Classify the observed exit before draining logs: later owner cleanup
// cannot turn a dependency failure into a successful cancellation.
if ctx.Err() != nil {
waitErr = nil
}
process.mu.Lock()
process.err = waitErr
process.mu.Unlock()
// Wait closes the pipe readers; join them before App closes its recorder.
// Do not wait before Wait: that would bypass its bounded pipe shutdown.
outputDone.Wait()
_ = os.Remove(configPath)
close(process.done)
}()
@@ -167,10 +175,11 @@ func Start(
cancel()
return nil, errors.Join(errors.New("public invitation service exited before connecting; reopen Piik to try again"), process.Err())
case <-timer.C:
_ = process.Close()
return nil, errors.New("public invitation service did not connect within 30 seconds; check your Internet connection and reopen Piik to try again")
return nil, errors.Join(errors.New("public invitation service did not connect within 30 seconds; check your Internet connection and reopen Piik to try again"), process.Close())
case <-parent.Done():
_ = process.Close()
if err := process.Close(); err != nil {
return nil, errors.Join(parent.Err(), err)
}
return nil, parent.Err()
}
}
+159
View File
@@ -1,13 +1,172 @@
package publictunnel
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func TestMain(tests *testing.M) {
if mode := os.Getenv("PIIK_TUNNEL_FIXTURE"); mode != "" {
if mode == "ready" || mode == "fail-after-ready" {
fmt.Println(`{"message":"https://test-room.trycloudflare.com"}`)
fmt.Println(`{"message":"Registered tunnel connection"}`)
}
if mode == "fail-after-ready" {
fmt.Println(`{"level":"fatal","message":"fixture exit failure"}`)
for {
if _, err := os.Stat(os.Getenv("PIIK_TUNNEL_FIXTURE_EXIT")); err == nil {
os.Exit(7)
}
time.Sleep(time.Millisecond)
}
}
for {
time.Sleep(time.Hour)
}
}
os.Exit(tests.Run())
}
type blockedTunnelLog struct {
slog.Handler
entered chan struct{}
release <-chan struct{}
}
func (handler blockedTunnelLog) Handle(ctx context.Context, record slog.Record) error {
blocked := false
record.Attrs(func(attr slog.Attr) bool {
blocked = blocked || attr.Key == "message" && attr.Value.String() == "fixture exit failure"
return true
})
if blocked {
close(handler.entered)
<-handler.release
}
return handler.Handler.Handle(ctx, record)
}
func TestCloseKeepsAnObservedFailureWhileLogsDrain(t *testing.T) {
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
exit := filepath.Join(t.TempDir(), "exit")
t.Setenv("PIIK_TUNNEL_FIXTURE", "fail-after-ready")
t.Setenv("PIIK_TUNNEL_FIXTURE_EXIT", exit)
entered, release := make(chan struct{}), make(chan struct{})
resume := sync.OnceFunc(func() { close(release) })
previous := slog.Default()
slog.SetDefault(slog.New(blockedTunnelLog{
Handler: slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}),
entered: entered, release: release,
}))
defer slog.SetDefault(previous)
defer resume()
process, err := Start(t.Context(), executable, "http://127.0.0.1:8787")
if err != nil {
t.Fatal(err)
}
defer func() { resume(); _ = process.Close() }()
select {
case <-entered:
case <-time.After(2 * time.Second):
t.Fatal("fixture did not enter its log writer")
}
if err := os.WriteFile(exit, nil, 0600); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(2 * time.Second)
for {
process.mu.Lock()
observed := process.err
process.mu.Unlock()
if observed != nil {
break
}
if time.Now().After(deadline) {
t.Fatal("dependency exit was not recorded before draining its logs")
}
time.Sleep(time.Millisecond)
}
canceled := make(chan struct{})
cancelProcess := process.cancel
process.cancel = func() { cancelProcess(); close(canceled) }
closed := make(chan error, 1)
go func() { closed <- process.Close() }()
<-canceled
resume()
select {
case err := <-closed:
var failure *exec.ExitError
if !errors.As(err, &failure) || failure.ExitCode() != 7 {
t.Fatalf("late owner cancellation lost the observed dependency failure: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("tunnel did not retire after its logs drained")
}
}
func TestReadyTunnelWaitsForItsExplicitOrderedClose(t *testing.T) {
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
t.Setenv("PIIK_TUNNEL_FIXTURE", "ready")
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
process, err := Start(ctx, executable, "http://127.0.0.1:8787")
if err != nil {
t.Fatal(err)
}
defer process.Close()
cancel()
select {
case <-process.Done():
t.Fatal("parent cancellation killed the ready tunnel before its owner closed it")
case <-time.After(150 * time.Millisecond):
}
if err := process.Close(); err != nil {
t.Fatal(err)
}
select {
case <-process.Done():
default:
t.Fatal("Close returned with the tunnel still running")
}
if err := process.Close(); err != nil {
t.Fatalf("repeat Close: %v", err)
}
}
func TestStartupCancellationStillRetiresTheTunnel(t *testing.T) {
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
t.Setenv("PIIK_TUNNEL_FIXTURE", "starting")
ctx, cancel := context.WithTimeout(t.Context(), 150*time.Millisecond)
defer cancel()
started := time.Now()
process, err := Start(ctx, executable, "http://127.0.0.1:8787")
if process != nil || !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("cancelled startup: %v, %v", process, err)
}
if time.Since(started) > 2*time.Second {
t.Fatal("startup cancellation did not join its child promptly")
}
}
func TestMissingTunnelExplainsHowToRestoreThePackage(t *testing.T) {
_, err := Start(t.Context(), filepath.Join(t.TempDir(), "missing-tunnel"), "http://127.0.0.1:8787")
var cause *os.PathError
+18 -8
View File
@@ -22,14 +22,15 @@ import (
const maxFileBytes = 8 << 20
type Recorder struct {
exportMu sync.Mutex
log *rotatingLog
logger *slog.Logger
component string
revision string
started time.Time
context map[string]any
closed bool
exportMu sync.Mutex
log *rotatingLog
logger *slog.Logger
component string
revision string
started time.Time
context map[string]any
closed bool
exportPath string
}
// Open appends to the component's current log, retaining one rotated backup.
@@ -73,6 +74,13 @@ func Open(directory, component, revision string) (*Recorder, error) {
func (r *Recorder) Logger() *slog.Logger { return r.logger }
func (r *Recorder) LogPath() string { return r.log.path }
// LastExportPath returns the latest successfully closed archive, including after Close.
func (r *Recorder) LastExportPath() string {
r.exportMu.Lock()
defer r.exportMu.Unlock()
return r.exportPath
}
// Context snapshots explicitly selected startup facts; it never reads environment files.
func (r *Recorder) Context(name string, value any) {
r.exportMu.Lock()
@@ -133,6 +141,8 @@ func (r *Recorder) Export() (path string, returnedErr error) {
if returnedErr != nil {
returnedErr = errors.Join(returnedErr, os.Remove(file.Name()))
path = ""
} else {
r.exportPath = path
}
}()
files := []string{}
+88 -1
View File
@@ -17,6 +17,7 @@ import (
"sync"
"syscall"
"testing"
"time"
)
func TestRecorderRotationAndExport(t *testing.T) {
@@ -115,7 +116,7 @@ func TestRecorderRotationAndExport(t *testing.T) {
}
}
second, err := recorder.Export()
if err != nil || second == archivePath {
if err != nil || second == archivePath || recorder.LastExportPath() != second {
t.Fatalf("export must have a unique path: %q, %v", second, err)
}
_ = recorder.Close()
@@ -129,6 +130,92 @@ func TestRecorderRotationAndExport(t *testing.T) {
}
}
func TestRecorderKeepsTheLastSuccessfulExportAfterFailureAndClose(t *testing.T) {
recorder, err := Open(t.TempDir(), "client", "test")
if err != nil {
t.Fatal(err)
}
defer recorder.Close()
if path := recorder.LastExportPath(); path != "" {
t.Fatalf("unexpected initial report: %q", path)
}
saved, err := recorder.Export()
if err != nil {
t.Fatal(err)
}
original := recorder.log.path
// A regular file in the directory position makes archive creation fail on every OS.
recorder.log.path = filepath.Join(original, "unavailable", "client.log")
path, exportErr := recorder.Export()
recorder.log.path = original
if path != "" || exportErr == nil || recorder.LastExportPath() != saved {
t.Fatalf("failed export replaced the saved report: %q, %v", path, exportErr)
}
if err := recorder.Close(); err != nil {
t.Fatal(err)
}
if path, err := recorder.Export(); path != "" || !errors.Is(err, os.ErrClosed) || recorder.LastExportPath() != saved {
t.Fatalf("closed recorder lost the saved report: %q, %v", path, err)
}
archive, err := zip.OpenReader(saved)
if err != nil {
t.Fatalf("saved report is unavailable or incomplete: %v", err)
}
_ = archive.Close()
}
type exportContext func() ([]byte, error)
func (value exportContext) MarshalJSON() ([]byte, error) { return value() }
func TestRecorderCloseWaitsForAnExportAlreadyInProgress(t *testing.T) {
recorder, err := Open(t.TempDir(), "client", "test")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = recorder.Close() })
started, release := make(chan struct{}), make(chan struct{})
resume := sync.OnceFunc(func() { close(release) })
defer resume()
recorder.context = map[string]any{"barrier": exportContext(func() ([]byte, error) {
close(started)
<-release
return []byte("null"), nil
})}
type result struct {
path string
err error
}
exported := make(chan result, 1)
go func() {
path, err := recorder.Export()
exported <- result{path, err}
}()
select {
case <-started:
case <-time.After(5 * time.Second):
t.Fatal("export did not reach the archive writer")
}
closing, closed := make(chan struct{}), make(chan error, 1)
go func() { close(closing); closed <- recorder.Close() }()
<-closing
select {
case err := <-closed:
t.Fatalf("Close returned before the archive finished: %v", err)
case <-time.After(25 * time.Millisecond):
}
resume()
completed := <-exported
if err := <-closed; err != nil || completed.err != nil || completed.path == "" || recorder.LastExportPath() != completed.path {
t.Fatalf("Close lost the completed report: %v, %v, %q", err, completed.err, completed.path)
}
archive, err := zip.OpenReader(completed.path)
if err != nil {
t.Fatalf("Close exposed an unfinished archive: %v", err)
}
_ = archive.Close()
}
func TestRecorderCloseAndFailures(t *testing.T) {
if _, err := Open(t.TempDir(), "../client", "test"); err == nil {
t.Fatal("accepted a component path")
+15 -20
View File
@@ -39,7 +39,7 @@ import (
"github.com/pion/webrtc/v4"
)
// Node HTTP server settings of app.ts, mapped in map section 3.3 / D11.
// HTTP request, header and keep-alive deadlines.
const (
requestTimeout = 10 * time.Second // httpServer.requestTimeout
headersTimeout = 15 * time.Second // httpServer.headersTimeout
@@ -55,7 +55,7 @@ type Options struct {
Listener *net.TCPListener
// Assets is the built Browser UI. A nil FS serves the API only, which is
// the TS frontend mode "none"; webassets.FS() returns nil the same way
// when no build was embedded (D3).
// when no build was embedded.
Assets fs.FS
// Now returns Unix milliseconds; nil uses the wall clock.
Now func() int64
@@ -69,7 +69,7 @@ type Options struct {
MaxUnauthenticatedSignalConnections int
SiteAccessTTLSeconds int
// AfterFunc is the signaling timer factory (D5); nil uses time.AfterFunc.
// AfterFunc is the signaling timer factory; nil uses time.AfterFunc.
AfterFunc func(time.Duration, func()) func() bool
// RoomStore replaces the store this package would build from Config.
RoomStore *room.Store
@@ -92,9 +92,8 @@ type Server struct {
// STUN shares the same startup/shutdown owner, never the advertised ICE URLs.
stunServer *stun.Server
// acceptingTraffic and signaling are the two closure variables app.ts
// flipped together; both are read by request goroutines without a lock and
// written under mu (hazard 13).
// acceptingTraffic and signaling are read by request goroutines without
// a lock and written together under mu.
acceptingTraffic atomic.Bool
signaling atomic.Pointer[signal.Server]
@@ -102,7 +101,7 @@ type Server struct {
closing bool
startupRequested bool
// startupDone is closed when listen settles, which is what shutdown awaits
// so the listener stays owned until an in-flight startup finishes (A6).
// so the listener stays owned until an in-flight startup finishes.
startupDone chan struct{}
shutdownOnce sync.Once
@@ -221,9 +220,8 @@ func (s *Server) Listen(ctx context.Context) (int, error) {
s.startupRequested = true
s.mu.Unlock()
// Deferred: a rejected startupOperation still settled, so shutdown's wait
// on it ended (app.ts:219). A panic escaping start() must likewise release
// the latch, or every later Close blocks on it for good.
// Release the startup latch even when start panics, so a later Close
// cannot wait forever for startup to finish.
defer close(s.startupDone)
return s.start(ctx)
}
@@ -273,11 +271,8 @@ func (s *Server) start(ctx context.Context) (int, error) {
})}
}
go func() {
// bindHttpServer removed its "error" listener once the server was
// listening (app.ts:315-333), so a later listener failure ended the
// Node process. Serve only returns something other than
// ErrServerClosed on a permanent accept failure, which nothing here
// causes; report it rather than serving nothing in silence.
// Report permanent accept failures so an unexpectedly stopped HTTP
// listener is visible to the operator.
if err := s.httpServer.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
s.logger.Error("Piik HTTP server stopped unexpectedly", "errorType", fmt.Sprintf("%T", err))
s.logger.Debug("http-listener-failed", diagnostics.Error(err))
@@ -285,8 +280,8 @@ func (s *Server) start(ctx context.Context) (int, error) {
}()
port := listener.Addr().(*net.TCPAddr).Port
if err := s.reconcile(ctx); err != nil {
// A13: give the database and the listener back before reporting.
if err := s.reconcile(); err != nil {
// Give the database and the listener back before reporting.
var cleanup []error
if closeErr := s.store.Close(); closeErr != nil {
cleanup = append(cleanup, closeErr)
@@ -330,7 +325,7 @@ func (s *Server) stopServing(ctx context.Context) error {
}
// reconcile restores room authority and enables traffic unless shutdown has begun.
func (s *Server) reconcile(ctx context.Context) error {
func (s *Server) reconcile() error {
if err := s.store.Initialize(); err != nil {
return err
}
@@ -371,14 +366,14 @@ func (s *Server) shutdown(ctx context.Context, endRooms bool) error {
}
func (s *Server) runShutdown(ctx context.Context, endRooms bool) error {
// O35: stop traffic, mark closing, then wait for an in-flight startup.
// Stop traffic, mark closing, then wait for an in-flight startup.
s.mu.Lock()
s.acceptingTraffic.Store(false)
s.closing = true
awaitStartup := s.startupRequested
s.mu.Unlock()
if awaitStartup {
// A6: the listen caller owns the startup error; shutdown only waits.
// The listen caller owns the startup error; shutdown only waits.
<-s.startupDone
}
+5 -7
View File
@@ -35,7 +35,7 @@ type siteAccessBody struct {
func sendJSON(writer http.ResponseWriter, status int, body any) {
var buffer bytes.Buffer
encoder := json.NewEncoder(&buffer)
// D9: JSON.stringify does not escape HTML either.
// JSON.stringify does not escape HTML either.
encoder.SetEscapeHTML(false)
if err := encoder.Encode(body); err != nil {
panic(err)
@@ -48,10 +48,8 @@ func sendJSON(writer http.ResponseWriter, status int, body any) {
_, _ = writer.Write(encoded)
}
// readJSONBody ports readJsonBody: the content type must be exactly
// application/json once its parameters are cut, the body must be at most 1024
// bytes and must not be empty. Every failure collapses to the route's 400, so
// the messages only exist to keep the TS reasons readable.
// readJSONBody requires application/json after removing parameters and a
// nonempty body of at most 1024 bytes. Every failure maps to the route's 400.
func readJSONBody(request *http.Request) ([]byte, error) {
contentType, _, _ := strings.Cut(request.Header.Get("Content-Type"), ";")
if strings.TrimSpace(contentType) != "application/json" {
@@ -70,7 +68,7 @@ func readJSONBody(request *http.Request) ([]byte, error) {
return body, nil
}
// hasRequestBody ports hasRequestBody. net/http parses Content-Length and moves
// net/http parses Content-Length and moves
// Transfer-Encoding out of Header, so a chunked body is ContentLength -1 and an
// unparseable length never reaches a handler at all.
func hasRequestBody(request *http.Request) bool {
@@ -96,7 +94,7 @@ func cookieHeader(request *http.Request) string {
return strings.Join(request.Header.Values("Cookie"), "; ")
}
// bearerToken ports readBearerToken: the prefix is case sensitive and an empty
// bearerToken requires a case-sensitive prefix; an empty
// token counts as absent.
func bearerToken(request *http.Request) string {
authorization := request.Header.Get("Authorization")
+7 -10
View File
@@ -38,8 +38,7 @@ func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
}()
if !s.acceptingTraffic.Load() {
// Hazard 13: no traffic at all until the SignalingServer exists, so
// /healthz answers 503 while LiveKit reconciliation runs.
// No traffic is accepted until startup completes; /healthz also returns 503.
recorder.Header().Set("Cache-Control", "no-store")
recorder.Header().Set("Retry-After", "1")
sendJSON(recorder, http.StatusServiceUnavailable, errorBody{"Service starting"})
@@ -152,7 +151,7 @@ func (s *Server) handleSiteAccess(writer http.ResponseWriter, request *http.Requ
}
if status.Required && status.Authenticated {
// The Set-Cookie value is written verbatim, in the TS attribute
// order (D11).
// order.
writer.Header().Set("Set-Cookie", access.createCookie())
}
sendJSON(writer, http.StatusOK, status)
@@ -292,8 +291,8 @@ func (s *Server) handleRoomCreation(
sendJSON(writer, http.StatusForbidden, errorBody{"Forbidden"})
return
}
// Hazard 12: the cookie is the only room-creation credential. A
// `Bearer <site password>` header must still be 401.
// When site access requires a password, room creation uses its cookie;
// a `Bearer <site password>` header does not authorize it.
if !s.roomCreationAuthorized(request) {
sendJSON(writer, http.StatusUnauthorized, errorBody{"Unauthorized"})
return
@@ -353,9 +352,8 @@ func (s *Server) authorizedRoomHostToken(
return hostToken, true
}
// allowedRequestOrigin ports isStrictlyAllowedRequestOrigin: the header must be
// present, must parse, must be exactly its own serialised origin (so
// "http://allowed.test/path" is refused) and must be configured.
// allowedRequestOrigin requires a present, parseable header equal to its own
// serialised origin (so "http://allowed.test/path" is refused) and configured.
func (s *Server) allowedRequestOrigin(request *http.Request) bool {
origin := request.Header.Get("Origin")
if origin == "" {
@@ -369,12 +367,11 @@ func (s *Server) allowedRequestOrigin(request *http.Request) bool {
return allowed
}
// roomCreationAuthorized ports isRoomCreationAuthorized.
func (s *Server) roomCreationAuthorized(request *http.Request) bool {
return s.siteAccessForRequest(request).isAuthenticated(cookieHeader(request))
}
// createRoomResponse of app.ts. signal.InviteURL is the one owner of the invite
// signal.InviteURL is the one owner of the invite
// URL shape; the rotated-grant notification on the signaling side uses it too.
func (s *Server) createRoomResponse(created room.CreatedRoom) protocol.CreateRoomResponse {
return protocol.CreateRoomResponse{
+2 -6
View File
@@ -205,7 +205,6 @@ func Load(env map[string]string) (Config, error) {
}, nil
}
// parseEnvironment ports parseEnvironment for PIIK_ENV.
func parseEnvironment(env map[string]string) (Environment, error) {
value, present := env["PIIK_ENV"]
if !present {
@@ -218,7 +217,7 @@ func parseEnvironment(env map[string]string) (Environment, error) {
return "", errors.New("PIIK_ENV must be development or production")
}
// parseBoolean ports parseBoolean; every caller used a false fallback.
// parseBoolean defaults an absent value to false.
func parseBoolean(value string, name string) (bool, error) {
switch value {
case "", "false":
@@ -229,7 +228,7 @@ func parseBoolean(value string, name string) (bool, error) {
return false, fmt.Errorf("%s must be true or false", name)
}
// parsePositiveInteger ports parsePositiveInteger, including Number.isSafeInteger.
// parsePositiveInteger preserves Number.isSafeInteger's bounds.
func parsePositiveInteger(value string, fallback int64, name string) (int64, error) {
if value == "" {
return fallback, nil
@@ -241,7 +240,6 @@ func parsePositiveInteger(value string, fallback int64, name string) (int64, err
return int64(number), nil
}
// parseBoundedInteger ports parseBoundedInteger.
func parseBoundedInteger(value string, fallback int64, name string, minimum, maximum int64) (int64, error) {
parsed, err := parsePositiveInteger(value, fallback, name)
if err != nil {
@@ -299,7 +297,6 @@ func parseSFU(env map[string]string) (*SFUConfig, error) {
}, nil
}
// parseURLList ports parseUrlList.
func parseURLList(value string, name string) ([]string, error) {
if strings.TrimFunc(value, protocol.IsJSWhitespace) == "" {
return nil, nil
@@ -333,7 +330,6 @@ func parseStunURLList(value string, name string) ([]string, error) {
return values, nil
}
// parseOrigins ports parseOrigins.
func parseOrigins(value string, fallback string) (map[string]struct{}, error) {
origins, err := parseURLList(value, "ALLOWED_ORIGINS")
if err != nil {
+9
View File
@@ -275,6 +275,12 @@ func TestLoadAccepts(t *testing.T) {
t.Errorf("STUNURLs = %v", c.STUNURLs)
}
}},
{"ordinary STUN keeps an explicit non-default port", map[string]string{"STUN_URLS": "stun:stun.test:80"},
func(t *testing.T, c Config) {
if !slices.Equal(c.STUNURLs, []string{"stun:stun.test:80"}) || len(c.NATPredictionSTUNURLs) != 0 {
t.Fatalf("unexpected ICE configuration: %+v", IceConfig(c))
}
}},
{"NAT prediction with a base listener", map[string]string{
"STUN_URLS": "stun:share.test:3478",
"NAT_PREDICTION_ENABLED": "true",
@@ -440,6 +446,9 @@ func TestLoadRejects(t *testing.T) {
// NAT prediction.
{"NAT prediction without STUN", map[string]string{"NAT_PREDICTION_ENABLED": "true"},
"NAT_PREDICTION_ENABLED requires a STUN_URLS entry on UDP 3478"},
{"NAT prediction cannot reinterpret explicit port 80", map[string]string{
"STUN_URLS": "stun:share.test:80", "NAT_PREDICTION_ENABLED": "true",
}, "NAT_PREDICTION_ENABLED requires a STUN_URLS entry on UDP 3478"},
{"NAT prediction off the base port", map[string]string{
"STUN_URLS": "stun:share.test:5349", "NAT_PREDICTION_ENABLED": "true",
}, "NAT_PREDICTION_ENABLED requires a STUN_URLS entry on UDP 3478"},
+1 -8
View File
@@ -35,7 +35,7 @@ func stunListenAddresses(host string, prediction bool) ([]string, error) {
return listeners, nil
}
// NATPredictionStunURLs ports natPredictionStunUrls: the two auxiliary
// NATPredictionStunURLs returns the two auxiliary
// listeners derived from the first ordinary STUN authority on UDP 3478.
func NATPredictionStunURLs(stunURLs []string) []string {
for _, stunURL := range stunURLs {
@@ -45,12 +45,6 @@ func NATPredictionStunURLs(stunURLs []string) []string {
continue
}
port := authority.Port()
// The WHATWG parser drops a port equal to the scheme default, so
// "stun:host:80" reaches the TypeScript's `: NAT_PREDICTION_BASE_PORT`
// fallback and counts as a base listener.
if port == defaultSchemePort("http") {
port = ""
}
if port != "" {
number, err := strconv.Atoi(port)
if err != nil || number != natPredictionBasePort {
@@ -72,7 +66,6 @@ func NATPredictionStunURLs(stunURLs []string) []string {
return nil
}
// IceConfig ports createIceConfig.
func IceConfig(c Config) protocol.IceConfig {
// A configured empty list (Local without prediction) is kept as-is; only an
// absent one is derived, so nil and empty differ here.
+3 -5
View File
@@ -104,16 +104,14 @@ func TestNATPredictionStunURLs(t *testing.T) {
[]string{"stun:share.test:3479", "stun:share.test:3480"}},
{"leading zeros in the port", []string{"stun:share.test:03478"},
[]string{"stun:share.test:3479", "stun:share.test:3480"}},
// WHATWG drops a port equal to the scheme default, so "http://host:80"
// reports no port and the TS fallback treats it as 3478.
{"HTTP default port reads as the base port", []string{"stun:share.test:80"},
[]string{"stun:share.test:3479", "stun:share.test:3480"}},
{"explicit port 80 is not the base port", []string{"stun:share.test:80"}, nil},
{"leading zero port 80 is not the base port", []string{"stun:share.test:00080"}, nil},
{"uppercase scheme and host", []string{"STUN:Share.TEST:3478"},
[]string{"stun:share.test:3479", "stun:share.test:3480"}},
{"IPv6 authority", []string{"stun:[2001:DB8::1]:3478"},
[]string{"stun:[2001:db8::1]:3479", "stun:[2001:db8::1]:3480"}},
{"first base listener wins",
[]string{"stun:a.test:5349", "stun:b.test:3478", "stun:c.test:3478"},
[]string{"stun:a.test:80", "stun:b.test:3478", "stun:c.test:3478"},
[]string{"stun:b.test:3479", "stun:b.test:3480"}},
{"no base listener", []string{"stun:a.test:5349", "stun:b.test:19302"}, nil},
// TS: indexOf(":") is -1, so the whole text becomes the authority.
+2 -3
View File
@@ -24,7 +24,6 @@ type LocalOptions struct {
NATPredictionSTUNURLs []string
}
// Local ports createLocalServerConfig.
func Local(options LocalOptions) (Config, error) {
port := options.Port
if port == 0 {
@@ -118,7 +117,7 @@ func allValidStunURLs(values []string) bool {
return true
}
// publicHTTPSOrigin ports publicHTTPSOrigin: every failure shares one message.
// publicHTTPSOrigin uses one error message for all invalid origins.
func publicHTTPSOrigin(value string) (*url.URL, error) {
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" ||
@@ -129,7 +128,7 @@ func publicHTTPSOrigin(value string) (*url.URL, error) {
return &url.URL{Scheme: "https", Host: normalizedHost(parsed), Path: "/"}, nil
}
// localIPv4 ports localIPv4. Node's isIP(value) === 4 accepts only a dotted
// Node's isIP(value) === 4 accepts only a dotted
// quad, so an IPv4-mapped IPv6 literal such as "::ffff:192.168.1.10" — which
// net.ParseIP(...).To4() does resolve — is rejected here as well.
func localIPv4(value string, name string) (string, error) {
+2 -3
View File
@@ -197,9 +197,8 @@ func (SetSharingPausedMessage) isClientMessage() {}
func (StopSharingMessage) isClientMessage() {}
func (AbandonRoomMessage) isClientMessage() {}
// DecodeClientMessage ports decodeClientMessage: JSON.parse followed by
// clientMessageSchema.parse. Every member of the union carries a distinct
// `type` literal, so the union is resolved by that key.
// DecodeClientMessage parses JSON and validates the message schema.
// Every member carries a distinct `type` literal, so that key resolves the union.
func DecodeClientMessage(data []byte) (ClientMessage, error) {
messageType, err := typeOf(data, "type")
if err != nil {
+18
View File
@@ -6,6 +6,8 @@ import (
"errors"
"fmt"
"math"
"reflect"
"strings"
)
// fields holds the raw members of one JSON object. Validation reads it to tell
@@ -21,6 +23,22 @@ func decodeObject(data []byte, target any) (fields, error) {
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
// These wire structs declare every member with a JSON tag. Go's struct
// decoder also accepts case-folded names; reject those before they can
// disagree with the exact presence/null checks below.
targetType := reflect.TypeOf(target).Elem()
known := make(map[string]struct{}, targetType.NumField())
for index := range targetType.NumField() {
name, _, _ := strings.Cut(targetType.Field(index).Tag.Get("json"), ",")
if name != "" && name != "-" {
known[name] = struct{}{}
}
}
for name := range raw {
if _, ok := known[name]; !ok {
return nil, fmt.Errorf("unknown field %q", name)
}
}
// JSON.parse keeps only the last occurrence of a duplicate key, so the
// earlier ones must never reach the strict decode. Re-encoding the member
// map drops them.
+21
View File
@@ -35,6 +35,27 @@ func TestRuntimeCapabilities(t *testing.T) {
}
}
func TestStrictMemberNamesCannotPopulateForbiddenOrOptionalFields(t *testing.T) {
for _, sample := range []struct {
name string
data string
target any
}{
{"none upstream with folded empty peer", `{"kind":"none","peerid":""}`, &MediaRouteUpstream{}},
{"host diagnostic with folded zero ordinal", `{"kind":"host","Ordinal":0}`, &RouteDiagnosticParent{}},
{"host presence with folded false ready", `{"role":"host","peerId":"host_12345678","displayName":"Host","upstream":{"kind":"none"},"mediaready":false}`, &ParticipantPresenceEntry{}},
{"viewer presence with folded false ready", `{"role":"viewer","peerId":"viewer_12345678","displayName":"Viewer","upstream":{"kind":"none"},"mediaready":false}`, &ParticipantPresenceEntry{}},
} {
t.Run(sample.name, func(t *testing.T) {
if err := json.Unmarshal([]byte(sample.data), sample.target); err == nil {
t.Fatal("a noncanonical member must not populate a schema field")
}
})
}
assertServerMessage(t, "folded route policy cannot be ignored by its default",
object(with(drop(authenticatedHost(), "routePolicy"), member{"routepolicy", testRoutePolicy})), false)
}
// member is one JSON object key with its raw encoded value, so the tests can
// reproduce the exact wire shapes from tests/protocol.test.ts (including key
// presence, which a Go map cannot express).
+7 -13
View File
@@ -24,8 +24,7 @@ const (
var ErrEndpointMediaCopyCapacity = errors.New(
"Endpoint media copy capacity must be 1, 2, or 3")
// EndpointMediaCopyLimit ports endpointMediaCopyLimit. It panics on an invalid
// capacity, which is where the TypeScript throws.
// EndpointMediaCopyLimit panics on an invalid capacity, where TypeScript throws.
func EndpointMediaCopyLimit(capacity int, phase EndpointMediaCopyPhase) int {
if err := AssertEndpointMediaCopyCapacity(capacity); err != nil {
panic(err)
@@ -36,20 +35,17 @@ func EndpointMediaCopyLimit(capacity int, phase EndpointMediaCopyPhase) int {
return min(capacity+1, MaxEndpointMediaCopyCapacity)
}
// EndpointMediaCopyCountFits ports endpointMediaCopyCountFits.
func EndpointMediaCopyCountFits(count, capacity int, phase EndpointMediaCopyPhase) bool {
return count >= 0 &&
IsEndpointMediaCopyCapacity(capacity) &&
count <= EndpointMediaCopyLimit(capacity, phase)
}
// IsEndpointMediaCopyCapacity ports isEndpointMediaCopyCapacity. The
// TypeScript Number.isSafeInteger guard is implicit in Go's int.
// Go's int makes the fractional-value part of Number.isSafeInteger implicit.
func IsEndpointMediaCopyCapacity(capacity int) bool {
return capacity >= 1 && capacity <= MaxEndpointMediaCopyCapacity
}
// AssertEndpointMediaCopyCapacity ports assertEndpointMediaCopyCapacity.
func AssertEndpointMediaCopyCapacity(capacity int) error {
if !IsEndpointMediaCopyCapacity(capacity) {
return ErrEndpointMediaCopyCapacity
@@ -76,8 +72,7 @@ var predictedCandidatePattern = regexp.MustCompile(
`(?i)^candidate:s[pm][0-9]+[\t\n\v\f\r \x{00a0}\x{1680}\x{2000}-\x{200a}` +
`\x{2028}\x{2029}\x{202f}\x{205f}\x{3000}\x{feff}]`)
// CandidateSignalOriginOf ports candidateSignalOrigin. An empty string stands
// in for the TypeScript null (both yield "end").
// CandidateSignalOriginOf treats an empty string as TypeScript null (both yield "end").
func CandidateSignalOriginOf(candidate string) CandidateSignalOrigin {
if strings.TrimFunc(candidate, IsJSWhitespace) == "" {
return CandidateOriginEnd
@@ -92,8 +87,8 @@ func CandidateSignalOriginOf(candidate string) CandidateSignalOrigin {
// src/shared/packet-loss.ts
// ---------------------------------------------------------------------------
// PacketLossPercentFromDeltas ports packetLossPercentFromDeltas. A nil
// argument is the TypeScript null; ok is false where it returns null.
// PacketLossPercentFromDeltas maps nil arguments to TypeScript null;
// ok is false where the shared calculation returns null.
func PacketLossPercentFromDeltas(packetsReceivedDelta, packetsLostDelta *float64) (float64, bool) {
if packetsReceivedDelta == nil || packetsLostDelta == nil ||
*packetsReceivedDelta < 0 || *packetsLostDelta < 0 {
@@ -156,9 +151,8 @@ var videoCodecParameterRules = map[string]codecParameterRule{
var videoMimeTypePattern = regexp.MustCompile(`(?i)^video/[A-Za-z0-9.+-]{1,32}$`)
// IsCanonicalVideoCodecEvidence ports isCanonicalVideoCodecEvidence. Empty
// strings are the TypeScript nulls; the schema rejects a genuinely empty codec
// string before this refine runs.
// IsCanonicalVideoCodecEvidence uses empty strings for TypeScript nulls;
// the schema rejects an empty codec string before this refinement runs.
func IsCanonicalVideoCodecEvidence(codec, codecProfile, codecParameters string) bool {
rule, ok := videoCodecParameterRules[strings.ToLower(codec)]
if codec == "" || !ok {
+9 -3
View File
@@ -83,8 +83,7 @@ var forbiddenDisplayNameCharacters = regexp.MustCompile(
`[\p{Cc}\p{Zl}\p{Zp}\x{061c}\x{200b}\x{200e}\x{200f}\x{202a}-\x{202e}` +
`\x{2060}\x{2066}-\x{2069}\x{feff}]`)
// NormalizeDisplayName ports normalizeDisplayName. The second result is false
// where the TypeScript returns null.
// NormalizeDisplayName returns false where the shared TypeScript normalizer returns null.
func NormalizeDisplayName(value string) (string, bool) {
if forbiddenDisplayNameCharacters.MatchString(value) {
return "", false
@@ -169,7 +168,12 @@ func ValidDisplayName(value string) bool {
func hasUnpairedSurrogateEscape(token []byte) bool {
text := string(token)
for index := 0; index+6 <= len(text); index++ {
if text[index] != '\\' || text[index+1] != 'u' {
if text[index] != '\\' {
continue
}
if text[index+1] != 'u' {
// Consume the whole escape, including \\, before looking for \u.
index++
continue
}
unit, err := strconv.ParseUint(text[index+2:index+6], 16, 32)
@@ -188,6 +192,8 @@ func hasUnpairedSurrogateEscape(token []byte) bool {
index += 11
case unit >= 0xdc00 && unit <= 0xdfff:
return true
default:
index += 5
}
}
return false
+2
View File
@@ -136,6 +136,8 @@ func TestValidStunURL(t *testing.T) {
{"STUN:[2001:db8::1]:3478", true},
{"stun:stun.test", true},
{"stun:stun.test/path", false},
{"stun:stun.test:80/", false},
{"stun:stun.test:3478/", false},
{"stun:stun.test?transport=udp", false},
{"stun:stun.test#fragment", false},
{"stun:", false},
+2 -3
View File
@@ -222,9 +222,8 @@ func EncodeServerMessage(message ServerMessage) ([]byte, error) {
return marshalJSON(message)
}
// DecodeServerMessage ports decodeServerMessage. The server never reads its
// own messages in production; this exists so the shared wire fixture can be
// replayed from Go.
// DecodeServerMessage supports replaying the shared wire fixture from Go.
// The server never reads its own messages in production.
func DecodeServerMessage(data []byte) (ServerMessage, error) {
messageType, err := typeOf(data, "type")
if err != nil {
+2 -2
View File
@@ -9,7 +9,7 @@ import (
"path/filepath"
"strings"
// The pure-Go driver: the port must build with CGO_ENABLED=0 (D2).
// The pure-Go driver supports builds with CGO_ENABLED=0.
_ "modernc.org/sqlite"
"github.com/TNTcraftHIM/Piik/internal/server/protocol"
@@ -81,7 +81,7 @@ type StoredRoomAuthority struct {
// Database is RoomDatabase: the SQLite stable authority. It holds one
// connection for its lifetime, because `PRAGMA locking_mode = EXCLUSIVE` is a
// connection property that a recycled pool connection would silently drop (D2).
// connection property that a recycled pool connection would silently drop.
type Database struct {
path string
db *sql.DB
+2 -2
View File
@@ -188,7 +188,7 @@ type Store struct {
maxRooms int
maxViewersPerRoom int
// gate is per store; the TypeScript module-level gate was shared by every
// RoomStore in a process, of which there was one (D11).
// RoomStore in a process, of which there was one.
gate gate
}
@@ -916,7 +916,7 @@ func (s *Store) takeRoomCode(preferredRoomID string) (string, error) {
// uniformIndex draws a uniform index below count from 8 random bytes, rejecting
// the values above the largest multiple of count. The TypeScript computed the
// limit in BigInt as 2^64 - 2^64 % count; (MaxUint64 % count + 1) % count is
// the same remainder without the uint64 wrap at a power-of-two count (D11).
// the same remainder without the uint64 wrap at a power-of-two count.
func uniformIndex(count int, random func(size int) []byte) (int, error) {
span := uint64(count)
remainder := (math.MaxUint64%span + 1) % span
+3 -6
View File
@@ -459,13 +459,10 @@ type Options struct {
NatPredictionEnabled bool
}
// Controller ports RoomRouteController. It never locks: the caller holds
// signal.Server.mu around every call. The public methods live in the files
// named after the TS helper clusters (controller.go, operation.go,
// quality.go, ...); every signature below the types is unchanged.
// Controller never locks: the caller holds signal.Server.mu around every call.
type Controller struct {
// hostPeerID is its own mutable field because RebindHostIdentity
// reassigns it (TS: options.hostPeerId = peerId at line 660); the other
// reassigns it; the other
// options are copied once and never change.
hostPeerID string
debugRoomID string
@@ -477,7 +474,7 @@ type Controller struct {
natPredictionEnabled bool
// Insertion order is load-bearing for participants, upstreamByViewer and
// directContinuations (map §4.1-4.3); the opportunity ledgers and the
// directContinuations; the opportunity ledgers and the
// retiring set are ordered for the same delete-while-iterating semantics.
participants ordered.Map[string, *participant]
upstreamByViewer ordered.Map[string, *CommittedEdge]
+24 -35
View File
@@ -8,7 +8,7 @@ import (
"github.com/TNTcraftHIM/Piik/internal/server/protocol"
)
// buildCandidates ports 2767: the full plan list filtered by the
// buildCandidates filters the full plan list by the
// availability opportunity ledger.
func (c *Controller) buildCandidates(childPeerID string, sfuOnly bool) []CandidatePlan {
plans := c.buildCandidatePlans(childPeerID, sfuOnly, true)
@@ -21,10 +21,10 @@ func (c *Controller) buildCandidates(childPeerID string, sfuOnly bool) []Candida
return out
}
// buildCandidatePlans ports 2777: eligible parents sorted by (untried
// buildCandidatePlans sorts eligible parents by (untried
// attempts when preferUntried) then depth, remaining capacity descending,
// stablePairRank, compareParticipant; the SFU tuple is spliced into index 1
// (§4.4 #28) and the list is deduped by tupleKey (§4.4 #29).
// and the list is deduped by tupleKey.
func (c *Controller) buildCandidatePlans(childPeerID string, sfuOnly, preferUntried bool) []CandidatePlan {
descendants := c.descendantsOf(childPeerID)
parents := []*participant{}
@@ -98,11 +98,11 @@ func (c *Controller) buildCandidatePlans(childPeerID string, sfuOnly, preferUntr
return plans
}
// planCandidate ports 3947: decides none / overlap / bounded-gap from the
// planCandidate decides none / overlap / bounded-gap from the
// producer's copy budget; nil when the producer is gone or over budget.
func (c *Controller) planCandidate(childPeerID string, tuple CandidateTuple) *CandidatePlan {
if tuple.Kind == UpstreamSfu && tuple.Publication == PublicationReuse {
// No producer on sfu/reuse (3949); endpointTransitionEquals compares it.
// No producer on sfu/reuse; endpointTransitionEquals compares it.
return &CandidatePlan{Tuple: tuple, EndpointTransition: EndpointTransition{Kind: TransitionNone}}
}
producerPeerID := c.hostPeerID
@@ -128,7 +128,6 @@ func (c *Controller) planCandidate(childPeerID string, tuple CandidateTuple) *Ca
return &CandidatePlan{Tuple: tuple, EndpointTransition: EndpointTransition{Kind: TransitionBoundedGap, ProducerPeerID: &producer0, Retire: retire}}
}
// retirementFor ports 3969.
func (c *Controller) retirementFor(childPeerID, producerPeerID string, tuple CandidateTuple) *EndpointRetirement {
old, _ := c.upstreamByViewer.Get(childPeerID)
if old != nil && old.Kind == UpstreamPeer && old.ParentPeerID == producerPeerID && old.PhysicalActive {
@@ -159,7 +158,7 @@ func (c *Controller) retirementFor(childPeerID, producerPeerID string, tuple Can
}
}
// candidateValid ports 3903; att is the live attempt when validating it.
// candidateValid receives att when validating a live attempt.
func (c *Controller) candidateValid(childPeerID string, plan CandidatePlan, att *attempt) bool {
tuple := plan.Tuple
child, _ := c.participants.Get(childPeerID)
@@ -203,7 +202,6 @@ func (c *Controller) candidateValid(childPeerID string, plan CandidatePlan, att
return currentPlan != nil && endpointTransitionEquals(currentPlan.EndpointTransition, plan.EndpointTransition)
}
// operationCandidateValid ports 3934.
func (c *Controller) operationCandidateValid(op *operation, plan CandidatePlan, att *attempt) bool {
return c.candidateValid(op.childPeerID, plan, att) &&
((att != nil && att.connectionAttempt != nil) ||
@@ -211,7 +209,6 @@ func (c *Controller) operationCandidateValid(op *operation, plan CandidatePlan,
c.candidateOpportunityAvailable(op.childPeerID, plan))
}
// physicalCopies ports 3999.
func (c *Controller) physicalCopies(peerID string) int {
copies := 0
for _, edge := range c.upstreamByViewer.Values() {
@@ -225,7 +222,7 @@ func (c *Controller) physicalCopies(peerID string) int {
return copies
}
// assertReservation ports 4006: panics on a reservation kind that does not
// assertReservation panics on a reservation kind that does not
// match the tuple or a missing overlap for an overlap transition.
func assertReservation(tuple CandidateTuple, reservation CandidateReservation, overlap bool) {
expected := ReservationSfuCreate
@@ -243,7 +240,7 @@ func assertReservation(tuple CandidateTuple, reservation CandidateReservation, o
}
}
// targetFits ports 4380; the parent must exist.
// targetFits requires an existing parent.
func (c *Controller) targetFits(parentPeerID, childPeerID string) bool {
parent, _ := c.participants.Get(parentPeerID)
edge, _ := c.upstreamByViewer.Get(childPeerID)
@@ -260,7 +257,6 @@ func (c *Controller) targetFits(parentPeerID, childPeerID string) bool {
return slots <= parent.effectiveDownstreamCapacity
}
// publicationFits ports 4390.
func (c *Controller) publicationFits(childPeerID string, replacing bool) bool {
host, _ := c.participants.Get(c.hostPeerID)
edge, _ := c.upstreamByViewer.Get(childPeerID)
@@ -276,7 +272,6 @@ func (c *Controller) publicationFits(childPeerID string, replacing bool) bool {
return host != nil && host.sessionID != "" && !host.departureConfirmed && nextSlots <= host.effectiveDownstreamCapacity
}
// hostHasPublicationSlot ports 4400.
func (c *Controller) hostHasPublicationSlot() bool {
host, _ := c.participants.Get(c.hostPeerID)
return host != nil && c.usedSlots(c.hostPeerID)+1 <= host.effectiveDownstreamCapacity
@@ -286,7 +281,7 @@ func (c *Controller) usedSlots(peerID string) int {
return c.physicalCopies(peerID)
}
// remaining ports 4409 and may be negative; the parent must exist.
// remaining may be negative; the parent must exist.
func (c *Controller) remaining(parentPeerID, childPeerID string) int64 {
parent, _ := c.participants.Get(parentPeerID)
edge, _ := c.upstreamByViewer.Get(childPeerID)
@@ -298,8 +293,8 @@ func (c *Controller) remaining(parentPeerID, childPeerID string) int64 {
return value
}
// depth ports 4416; a cycle yields MAX_SAFE_INTEGER (not MaxInt64) so the
// comparator subtraction cannot overflow.
// depth yields MAX_SAFE_INTEGER on a cycle, avoiding MaxInt64 so comparator
// subtraction cannot overflow.
func (c *Controller) depth(peerID string) int64 {
if peerID == c.hostPeerID {
return 1
@@ -323,8 +318,8 @@ func (c *Controller) depth(peerID string) int64 {
}
}
// childrenOf ports 4352: direct peer children in upstreamByViewer order
// (§4.2 #17), the base order for every children-derived list.
// childrenOf returns direct peer children in upstreamByViewer order,
// the base order for every children-derived list.
func (c *Controller) childrenOf(parentPeerID string) []string {
children := []string{}
for childPeerID, edge := range c.upstreamByViewer.All() {
@@ -335,8 +330,7 @@ func (c *Controller) childrenOf(parentPeerID string) []string {
return children
}
// descendantsOf ports 4356: a DFS (the TS queue is popped from the end,
// §4.5 #49) returning the visit order without duplicates.
// descendantsOf walks depth-first, returning visit order without duplicates.
func (c *Controller) descendantsOf(peerID string) []string {
descendants := []string{}
seen := make(map[string]struct{})
@@ -357,7 +351,7 @@ func (c *Controller) descendantsOf(peerID string) []string {
}
// withSelf ports `this.descendantsOf(peerId).add(peerId)`: the peer itself
// is appended last unless the walk already reached it (§4.5 #50).
// is appended last unless the walk already reached it.
func withSelf(descendants []string, peerID string) []string {
if slices.Contains(descendants, peerID) {
return descendants
@@ -365,7 +359,7 @@ func withSelf(descendants []string, peerID string) []string {
return append(descendants, peerID)
}
// sourceUsable ports 4365: the whole upstream path is usable and active.
// sourceUsable checks that the whole upstream path is usable and active.
func (c *Controller) sourceUsable(peerID string) bool {
if peerID == c.hostPeerID {
return true
@@ -391,12 +385,11 @@ func (c *Controller) sourceUsable(peerID string) bool {
}
}
// participantBlocked ports 3862.
func (c *Controller) participantBlocked(current *participant) bool {
return current.availabilityExhausted && len(c.buildCandidates(current.peerID, false)) == 0
}
// availableViewers ports 3869: connected, non-departed viewers sorted by
// availableViewers returns connected, non-departed viewers sorted by
// compareParticipant (a total order, so the map order does not leak).
func (c *Controller) availableViewers(includeBlocked bool) []*participant {
viewers := []*participant{}
@@ -410,8 +403,8 @@ func (c *Controller) availableViewers(includeBlocked bool) []*participant {
return viewers
}
// overflowChildren ports 3876: per parent in participants order, the
// overflowing children newest-first (§4.1 #3).
// overflowChildren visits parents in participant order and returns each
// parent's overflowing children newest first.
func (c *Controller) overflowChildren() []string {
result := []string{}
for _, parent := range c.participants.Values() {
@@ -428,8 +421,8 @@ func (c *Controller) overflowChildren() []string {
return result
}
// overflowPeerChildren ports 3889: the physically active direct children
// beyond the parent's capacity, oldest first kept.
// overflowPeerChildren keeps the oldest physically active direct children
// within the parent's capacity and returns the overflow.
func (c *Controller) overflowPeerChildren(parentPeerID string) []string {
parent, _ := c.participants.Get(parentPeerID)
if parent == nil {
@@ -457,7 +450,6 @@ func (c *Controller) overflowPeerChildren(parentPeerID string) []string {
return children[capacity:]
}
// tupleKey ports 4979.
func tupleKey(tuple CandidateTuple) string {
if tuple.Kind == UpstreamPeer {
key := "peer:" + tuple.ParentPeerID
@@ -469,7 +461,6 @@ func tupleKey(tuple CandidateTuple) string {
return "sfu:" + string(tuple.Publication)
}
// edgeTupleKey ports 4999.
func edgeTupleKey(edge *CommittedEdge) string {
if edge.Kind == UpstreamPeer {
return "peer:" + edge.ParentPeerID
@@ -477,7 +468,6 @@ func edgeTupleKey(edge *CommittedEdge) string {
return "sfu:reuse"
}
// endpointTransitionRank ports 5003.
func endpointTransitionRank(transition EndpointTransition) int {
switch transition.Kind {
case TransitionNone:
@@ -489,7 +479,7 @@ func endpointTransitionRank(transition EndpointTransition) int {
}
}
// cloneCandidatePlan ports 5020 (deep copy of the retirement).
// cloneCandidatePlan copies the plan and deeply copies its retirement data.
func cloneCandidatePlan(plan CandidatePlan) CandidatePlan {
clone := plan
if plan.EndpointTransition.ProducerPeerID != nil {
@@ -503,7 +493,6 @@ func cloneCandidatePlan(plan CandidatePlan) CandidatePlan {
return clone
}
// candidatePlanEquals ports 5029.
func candidatePlanEquals(left, right CandidatePlan) bool {
return tupleKey(left.Tuple) == tupleKey(right.Tuple) &&
endpointTransitionEquals(left.EndpointTransition, right.EndpointTransition)
@@ -516,7 +505,7 @@ func stringPtrEquals(left, right *string) bool {
return *left == *right
}
// endpointTransitionEquals ports 5034; `undefined !== HOST` matters for the
// In endpointTransitionEquals, `undefined !== HOST` matters for the
// none producer, hence the pointer comparison.
func endpointTransitionEquals(left, right EndpointTransition) bool {
if left.Kind != right.Kind {
@@ -545,7 +534,7 @@ func endpointTransitionEquals(left, right EndpointTransition) bool {
return false
}
// reservationResources ports 5060 in order: edge (never for a borrowed
// reservationResources returns resources in order: edge (never for a borrowed
// sfu-reuse edge, which belongs to another holder), publication, overlap.
func reservationResources(reservation CandidateReservation) []*Resource {
resources := []*Resource{}
+18 -21
View File
@@ -11,7 +11,6 @@ import (
"github.com/TNTcraftHIM/Piik/internal/server/protocol"
)
// File-local constants of room-route-controller.ts (lines 112-114).
const (
maxDirectHeadStartMs int64 = 5_000
maxNatConnectionAttempts = 3
@@ -118,7 +117,7 @@ func (o *operation) planAt(index int) *CandidatePlan {
}
// planAtOrLast ports `operation.candidates[Math.min(cursor, length - 1)]`,
// which is undefined (nil) for an empty array (map R8).
// which is undefined (nil) for an empty array.
func (o *operation) planAtOrLast() *CandidatePlan {
return o.planAt(min(o.cursor, len(o.candidates)-1))
}
@@ -323,7 +322,7 @@ func (c *Controller) Publication() *HostPublication {
// Snapshot returns a copy of the committed graph, the publication and the
// operation snapshot; it mutates nothing.
func (c *Controller) Snapshot() RouteSnapshot {
// Ordering site: the router iterates this map to build assignments (§4.2 #9).
// The router iterates this map to build assignments.
upstream := &ordered.Map[string, CommittedEdge]{}
for id, edge := range c.upstreamByViewer.All() {
upstream.Set(id, *edge)
@@ -513,7 +512,7 @@ func (c *Controller) RebindHostIdentity(peerID, sessionID string, nowMs *int64)
panic(errors.New("Route Host identity is unavailable"))
}
// Ordering site §4.5 #38: aborted reservation, then SFU edges in map
// Aborted reservation, then SFU edges in map
// order, then the publication, deduplicated.
released := &resourceSet{}
if c.operation != nil {
@@ -541,7 +540,7 @@ func (c *Controller) RebindHostIdentity(peerID, sessionID string, nowMs *int64)
c.sfuBootstrapIntent = nil
c.consumedSfuBootstrapOpportunities.Clear()
c.rootConvergenceRootPeerID = ""
// Ordering site §4.1 #2: demand is re-recorded in participants order.
// Demand is re-recorded in participants order.
for _, current := range c.participants.Values() {
current.availabilityExhausted = false
current.bootstrapFailureReported = false
@@ -552,7 +551,7 @@ func (c *Controller) RebindHostIdentity(peerID, sessionID string, nowMs *int64)
}
}
// Ordering site §4.1 #1: delete + set moves the Host to the tail of the
// Delete + set moves the Host to the tail of the
// insertion order while it keeps its joinOrder.
c.participants.Delete(previousPeerID)
host.peerID = peerID
@@ -672,7 +671,7 @@ func (c *Controller) InvalidateEdge(guard EdgeGuard, nowMs *int64) bool {
// InvalidateDirectEdgeFromParent invalidates the first committed direct edge
// (insertion order) matching the parent-side guard.
func (c *Controller) InvalidateDirectEdgeFromParent(input ParentEdgeGuard, nowMs *int64) bool {
// Ordering site §4.2 #10: first match in insertion order wins.
// First match in insertion order wins.
for childPeerID, edge := range c.upstreamByViewer.All() {
if edge.Kind == UpstreamPeer &&
edge.ParentPeerID == input.ParentPeerID &&
@@ -768,7 +767,7 @@ func (c *Controller) RetireHostPublication(guard PublicationGuard) []*Resource {
publication.ConnectionID != guard.ConnectionID {
return []*Resource{}
}
// Ordering site §4.2 #11: subscriptions in map order, then the publication.
// Subscriptions in map order, then the publication.
released := []*Resource{}
for viewerPeerID, edge := range c.upstreamByViewer.All() {
if edge.Kind == UpstreamSfu && edge.PublicationGeneration == publication.Generation {
@@ -814,7 +813,7 @@ func (c *Controller) SetPaused(paused bool, nowMs *int64) []*Resource {
// Dispose clears all state, pauses the controller and returns the live
// reservation's resources then every committed resource, deduplicated.
func (c *Controller) Dispose() []*Resource {
// Ordering site §4.5 #39: reservation first, then committed resources.
// Reservation first, then committed resources.
resources := &resourceSet{}
if c.operation != nil && c.operation.current != nil {
resources.add(reservationResources(c.operation.current.reservation)...)
@@ -836,14 +835,13 @@ func (c *Controller) Dispose() []*Resource {
c.sfuBootstrapIntent = nil
c.consumedSfuBootstrapOpportunities.Clear()
c.rootConvergenceRootPeerID = ""
// nextJoinOrder, revision, latestRevision and factVersion survive (TS 2314-2339).
// nextJoinOrder, revision, latestRevision and factVersion survive.
c.paused = true
return resources.resources()
}
// abortOperation ports the private abortOperation (4050): finishes the
// demand timing when nowMs is given, releases the live reservation and
// clears the operation.
// abortOperation finishes demand timing when nowMs is given, releases the live
// reservation and clears the operation.
func (c *Controller) abortOperation(nowMs *int64, bucket RejectionBucket) []*Resource {
if c.operation == nil {
return []*Resource{}
@@ -861,7 +859,6 @@ func (c *Controller) abortOperation(nowMs *int64, bucket RejectionBucket) []*Res
return released
}
// operationUsesParticipantSession ports 4072.
func (c *Controller) operationUsesParticipantSession(peerID string) bool {
op := c.operation
if op == nil {
@@ -877,7 +874,7 @@ func (c *Controller) operationUsesParticipantSession(peerID string) bool {
return peerID == c.hostPeerID
}
// effectiveCapacity ports 4925: panics "Effective capacity is invalid" for
// effectiveCapacity panics "Effective capacity is invalid" for
// a negative or unsafe value and clamps to the endpoint capacity.
func (c *Controller) effectiveCapacity(value int) int {
if !isSafeInteger(int64(value)) || value < 0 {
@@ -888,7 +885,7 @@ func (c *Controller) effectiveCapacity(value int) int {
func (c *Controller) touchFacts() { c.factVersion++ }
// allocateRevision ports 4932; the revision space is MAX_SAFE_INTEGER.
// allocateRevision bounds revisions by MAX_SAFE_INTEGER.
func (c *Controller) allocateRevision() int64 {
if c.latestRevision >= protocol.MaxMediaRouteRevision {
panic(errors.New("Media route revision space exhausted"))
@@ -902,8 +899,8 @@ func isSafeInteger(value int64) bool {
return value >= -maxSafeInteger && value <= maxSafeInteger
}
// compareParticipant ports 4939. joinOrder is allocated once per
// participant (573-577) and preserved across rebindHostIdentity, so two live
// joinOrder is allocated once per participant and preserved across
// rebindHostIdentity, so two live
// participants never share one and the TS localeCompare (ICU) tiebreak is
// unreachable; strings.Compare stands in for it.
func compareParticipant(left, right *participant) int {
@@ -916,7 +913,7 @@ func compareParticipant(left, right *participant) int {
return strings.Compare(left.peerID, right.peerID)
}
// stablePairRank ports 4955: the first 6 bytes, big-endian, of
// stablePairRank returns the first 6 bytes, big-endian, of
// sha256(child + "\0" + parent). Byte-exact: it breaks parent ties.
func stablePairRank(childPeerID, parentPeerID string) int64 {
sum := sha256.Sum256([]byte(childPeerID + "\x00" + parentPeerID))
@@ -927,8 +924,8 @@ func stablePairRank(childPeerID, parentPeerID string) int64 {
return rank
}
// safeAdd ports 4969. JS Math.round is half-up while math.Round is
// half-away-from-zero; they differ only for negative halves, which the
// JS Math.round is half-up while math.Round is half-away-from-zero;
// they differ only for negative halves, which the
// `rounded < 0` branch drops either way. NaN and +/-Inf drop the window.
func safeAdd(total int64, delta float64) int64 {
rounded := math.Round(delta)
+102 -51
View File
@@ -664,29 +664,77 @@ func TestAbandonsPendingQualityMoveWhenIncumbentRecovers(t *testing.T) {
eq(t, edge.ConnectionID, "a_from_host")
}
// TS 2086-2095: when candidateReady abandons a quality move because the
// incumbent is no longer degraded, activeRevision is the revision that was
// active before the abort (the TS literal reads this.revision before
// abortOperation advances it), even though the abort itself advances it.
func TestReportsPreAbortRevisionWhenCandidateReadyAbandonsQualityMove(t *testing.T) {
routes := newController(2, Options{QualityConvergenceEnabled: true})
addViewer(routes, A, 2, nil)
addViewer(routes, B, 2, nil)
routes.hydrateEdge(A, peerEdge(HOST, "a_from_host"))
routes.hydrateEdge(B, peerEdge(HOST, "b_from_host"))
observePersistentDegraded(t, routes, A, "a_from_host", 99)
routes.Reconcile(103)
begin := beginCandidate(t, routes, BeginInput{NowMs: 104, ConnectionID: "a_from_b", Reservation: direct()})
current := must(t, begin.Operation).Current
before := routes.Snapshot().Revision
eq(t, routes.ObserveSenderQualityEvidence(unknownSample(HOST, A, before, "a_from_host", 105), nil).Accepted, true)
settled := routes.CandidateReady(guardFor(A, current.Revision, "a_from_b"), 106, nil, CandidateProof{})
eq(t, settled.Accepted, true)
eq(t, settled.Committed, false)
noOperation(t, routes.Snapshot().Operation)
eq(t, settled.ActiveRevision, before)
if routes.Snapshot().Revision <= before {
t.Fatalf("expected the abort to advance the revision past %d, got %d", before, routes.Snapshot().Revision)
func TestQualityReadinessKeepsInconclusiveIncumbentWithinTheSameOperation(t *testing.T) {
for _, unknownFirst := range []bool{true, false} {
t.Run(fmt.Sprintf("unknown before readiness=%v", unknownFirst), func(t *testing.T) {
routes := newController(2, Options{QualityConvergenceEnabled: true})
addViewer(routes, A, 2, nil)
addViewer(routes, B, 2, nil)
routes.hydrateEdge(A, peerEdge(HOST, "a_from_host"))
routes.hydrateEdge(B, peerEdge(HOST, "b_from_host"))
observePersistentDegraded(t, routes, A, "a_from_host", 99)
operation := must(t, routes.Reconcile(103).Operation)
begin := beginOperation(t, routes, BeginInput{NowMs: 104, ConnectionID: "a_from_b", Reservation: direct()})
guard := guardFor(A, begin.Current.Revision, "a_from_b")
unknown := func(at int64) {
eq(t, routes.ObserveSenderQualityEvidence(unknownSample(HOST, A, routes.Snapshot().Revision, "a_from_host", at), nil).Accepted, true)
}
ready := func(at int64) {
settled := routes.CandidateReady(guard, at, nil, CandidateProof{RelativeQualityApproved: true})
eq(t, settled.Accepted, true)
eq(t, settled.Committed, false)
}
if unknownFirst {
unknown(105)
ready(106)
} else {
ready(105)
unknown(106)
}
pending := must(t, routes.Snapshot().Operation)
eq(t, pending.Current.Revision, guard.Revision)
eq(t, pending.DeadlineAtMs, operation.DeadlineAtMs)
eq(t, edgeOf(t, routes, A).ConnectionID, "a_from_host")
settled := routes.ObserveSenderQualityEvidence(senderSample(
B, A, guard.Revision, guard.ConnectionID, "candidate-rtp\x00track", SenderQualityHealthy, 107), nil)
eq(t, settled.Committed, true)
noOperation(t, routes.Snapshot().Operation)
eq(t, edgeOf(t, routes, A).ConnectionID, "a_from_b")
})
}
}
func TestInconclusiveQualityOperationStillRequiresCurrentAuthority(t *testing.T) {
for _, cause := range []string{"healthy", "reset", "sender-replaced", "pause", "deadline"} {
t.Run(cause, func(t *testing.T) {
routes := newController(2, Options{QualityConvergenceEnabled: true})
addViewer(routes, A, 2, nil)
addViewer(routes, B, 2, nil)
routes.hydrateEdge(A, peerEdge(HOST, "a_from_host"))
routes.hydrateEdge(B, peerEdge(HOST, "b_from_host"))
observePersistentDegraded(t, routes, A, "a_from_host", 99)
operation := must(t, routes.Reconcile(103).Operation)
begin := beginOperation(t, routes, BeginInput{NowMs: 104, ConnectionID: "a_from_b", Reservation: direct()})
guard := guardFor(A, begin.Current.Revision, "a_from_b")
eq(t, routes.ObserveSenderQualityEvidence(unknownSample(HOST, A, routes.Snapshot().Revision, "a_from_host", 105), nil).Accepted, true)
eq(t, routes.CandidateReady(guard, 106, nil, CandidateProof{RelativeQualityApproved: true}).Accepted, true)
at := int64(107)
switch cause {
case "healthy":
observeSenderState(routes, senderState{childPeerID: A, connectionID: "a_from_host", state: SenderQualityHealthy, acceptedAtMs: at})
case "reset":
routes.ResetSenderQuality(HOST, "host_session", at)
case "sender-replaced":
observeSenderState(routes, senderState{childPeerID: A, connectionID: "a_from_host", senderIdentity: "replacement-rtp\x00track", state: SenderQualityDegraded, acceptedAtMs: at})
case "pause":
routes.SetPaused(true, &at)
case "deadline":
at = operation.DeadlineAtMs
}
eq(t, routes.CandidateReady(guard, at, nil, CandidateProof{RelativeQualityApproved: true}).Accepted, false)
noOperation(t, routes.Snapshot().Operation)
eq(t, edgeOf(t, routes, A).ConnectionID, "a_from_host")
})
}
}
@@ -1015,34 +1063,37 @@ func TestRejectsSfuQualityCandidateOnDegradedPublisherEvidence(t *testing.T) {
eq(t, edge.ParentPeerID, HOST)
}
// TS 2297: abandons Host relief when another Host root recovers during the canary
func TestAbandonsHostReliefWhenAnotherHostRootRecoversDuringCanary(t *testing.T) {
routes := newController(2, Options{SfuEnabled: true, QualityConvergenceEnabled: true})
addViewer(routes, A, 1, nil)
addViewer(routes, B, 0, nil)
routes.hydrateEdge(A, peerEdge(HOST, "a_from_host"))
routes.hydrateEdge(B, peerEdge(HOST, "b_from_host"))
observePersistentDegraded(t, routes, A, "a_from_host", 99)
observePersistentDegraded(t, routes, B, "b_from_host", 99)
routes.Reconcile(103)
begin := beginCandidate(t, routes, BeginInput{
NowMs: 104,
ConnectionID: "a_from_sfu",
Reservation: sfuCreateOverlap("sfu-edge", "sfu-publication", "host-overlap"),
PublicationGeneration: "publication_generation_12345678",
PublicationConnectionID: "publication_connection_12345678",
HostSessionID: "host_session",
})
current := must(t, begin.Operation).Current
eq(t, observeSenderState(routes, senderState{
childPeerID: B, connectionID: "b_from_host", state: SenderQualityHealthy, acceptedAtMs: 105,
}).Accepted, true)
eq(t, routes.CandidateReady(guardFor(A, current.Revision, "a_from_sfu"), 106, nil, CandidateProof{}).Committed, false)
noOperation(t, routes.Snapshot().Operation)
edge := edgeOf(t, routes, A)
eq(t, edge.Kind, UpstreamPeer)
eq(t, edge.ParentPeerID, HOST)
eq(t, edge.ConnectionID, "a_from_host")
func TestAbandonsHostReliefWhenAnotherHostRootIsNoLongerDegraded(t *testing.T) {
for _, state := range []SenderQualityState{SenderQualityHealthy, SenderQualityUnknown} {
t.Run(string(state), func(t *testing.T) {
routes := newController(2, Options{SfuEnabled: true, QualityConvergenceEnabled: true})
addViewer(routes, A, 1, nil)
addViewer(routes, B, 0, nil)
routes.hydrateEdge(A, peerEdge(HOST, "a_from_host"))
routes.hydrateEdge(B, peerEdge(HOST, "b_from_host"))
observePersistentDegraded(t, routes, A, "a_from_host", 99)
observePersistentDegraded(t, routes, B, "b_from_host", 99)
routes.Reconcile(103)
begin := beginCandidate(t, routes, BeginInput{
NowMs: 104,
ConnectionID: "a_from_sfu",
Reservation: sfuCreateOverlap("sfu-edge", "sfu-publication", "host-overlap"),
PublicationGeneration: "publication_generation_12345678",
PublicationConnectionID: "publication_connection_12345678",
HostSessionID: "host_session",
})
current := must(t, begin.Operation).Current
eq(t, observeSenderState(routes, senderState{
childPeerID: B, connectionID: "b_from_host", state: state, acceptedAtMs: 105,
}).Accepted, true)
eq(t, routes.CandidateReady(guardFor(A, current.Revision, "a_from_sfu"), 106, nil, CandidateProof{}).Committed, false)
noOperation(t, routes.Snapshot().Operation)
edge := edgeOf(t, routes, A)
eq(t, edge.Kind, UpstreamPeer)
eq(t, edge.ParentPeerID, HOST)
eq(t, edge.ConnectionID, "a_from_host")
})
}
}
// TS 2350: uses SFU reuse only for multi-edge Host relief with healthy ingress
+2 -2
View File
@@ -33,7 +33,7 @@ func (c *Controller) debug(event string, details ...any) {
c.debugLog("piik-route", args...)
}
// debugPeer ports 4895: "host", "viewer-<joinOrder>" or "viewer-unknown".
// debugPeer returns "host", "viewer-<joinOrder>" or "viewer-unknown".
func (c *Controller) debugPeer(peerID string) string {
current, _ := c.participants.Get(peerID)
if peerID == c.hostPeerID || (current != nil && current.role == protocol.RoleHost) {
@@ -45,7 +45,7 @@ func (c *Controller) debugPeer(peerID string) string {
return "viewer-" + strconv.FormatInt(current.joinOrder, 10)
}
// debugTuple ports 4903: "p2p:<label>[:regenerate]" or "sfu:<publication>".
// debugTuple returns "p2p:<label>[:regenerate]" or "sfu:<publication>".
func (c *Controller) debugTuple(tuple CandidateTuple) string {
if tuple.Kind == UpstreamPeer {
label := "p2p:" + c.debugPeer(tuple.ParentPeerID)
+9 -19
View File
@@ -6,8 +6,8 @@ import (
"github.com/TNTcraftHIM/Piik/internal/server/protocol"
)
// pruneDepartedLeaves ports 4015: removes departed viewers without
// children to a fixpoint, in participants order (§4.1 #4), and drops an
// pruneDepartedLeaves removes departed viewers without
// children to a fixpoint, in participants order, and drops an
// orphaned publication.
func (c *Controller) pruneDepartedLeaves(released *[]*Resource) []string {
removed := []string{}
@@ -49,7 +49,6 @@ func (c *Controller) pruneDepartedLeaves(released *[]*Resource) []string {
return removed
}
// retireInvalidOperationEdge ports 4133.
func (c *Controller) retireInvalidOperationEdge(childPeerID string, released *[]*Resource) bool {
edge, _ := c.upstreamByViewer.Get(childPeerID)
if edge == nil || !c.edgeRequiresMove(childPeerID, edge) {
@@ -74,7 +73,6 @@ func (c *Controller) retireInvalidOperationEdge(childPeerID string, released *[]
return true
}
// edgeRequiresMove ports 4153.
func (c *Controller) edgeRequiresMove(childPeerID string, edge *CommittedEdge) bool {
if !edge.Usable || !edge.PhysicalActive {
return true
@@ -95,11 +93,8 @@ func (c *Controller) edgeRequiresMove(childPeerID string, edge *CommittedEdge) b
return false
}
// rebindCommittedSession ports 4747: the reconnecting peer keeps its edges
// and publication, which take its new session id. The TS `{released,
// retired}` result is dropped because it was dead: nothing ever joined the
// released set and retired was the constant false, so the caller's
// `rebound.retired &&` branch was unreachable (map R12).
// rebindCommittedSession preserves the reconnecting peer's edges and
// publication, updating them to its new session ID.
func (c *Controller) rebindCommittedSession(peerID, sessionID string) {
if ownEdge, ok := c.upstreamByViewer.Get(peerID); ok {
ownEdge.ChildSessionID = sessionID
@@ -115,7 +110,6 @@ func (c *Controller) rebindCommittedSession(peerID, sessionID string) {
}
}
// removePublicationGeneration ports 4766 (§4.2 #12).
func (c *Controller) removePublicationGeneration(generation string) []*Resource {
released := []*Resource{}
for viewerPeerID, edge := range c.upstreamByViewer.All() {
@@ -140,7 +134,7 @@ func (c *Controller) retainsAnchor(viewerPeerID string) bool {
return len(c.childrenOf(viewerPeerID)) > 0
}
// retireSfuEdge ports 4780: releases the subscription only while it is
// retireSfuEdge releases the subscription only while it is
// still physically active, then keeps the edge as an anchor for attached
// peer descendants or deletes it.
func (c *Controller) retireSfuEdge(viewerPeerID string, edge *CommittedEdge, released *[]*Resource, retainAnchor bool) {
@@ -168,7 +162,6 @@ func (c *Controller) hasRetiringAnchor(generation string) bool {
return false
}
// pruneRetiringSfuAnchors ports 4797 (§4.2 #18, §4.5 #46).
func (c *Controller) pruneRetiringSfuAnchors() {
removed := true
for removed {
@@ -190,8 +183,8 @@ func (c *Controller) pruneRetiringSfuAnchors() {
}
}
// assertGraph ports 4827: programming-error panics with the TS messages
// (§4.2 #20, §4.1 #6 decide which violation is reported first).
// assertGraph panics on programming errors. Edge insertion order and
// participant order determine which violation is reported first.
func (c *Controller) assertGraph() {
host, _ := c.participants.Get(c.hostPeerID)
if host == nil || host.role != protocol.RoleHost {
@@ -248,19 +241,16 @@ func (c *Controller) assertGraph() {
}
}
// assertViewer ports 4891.
func (c *Controller) assertViewer(peerID string) {
if current, ok := c.participants.Get(peerID); !ok || current.role != protocol.RoleViewer {
panic(errors.New("Route child must be a Viewer"))
}
}
// hasSfuSubscribers ports 4431.
func (c *Controller) hasSfuSubscribers() bool {
return c.sfuSubscriberCount() > 0
}
// sfuSubscriberCount ports 4435.
func (c *Controller) sfuSubscriberCount() int {
count := 0
for _, edge := range c.upstreamByViewer.Values() {
@@ -271,8 +261,8 @@ func (c *Controller) sfuSubscriberCount() int {
return count
}
// committedResources ports 4439: SFU subscriptions in map order, then the
// publication (§4.2 #13).
// committedResources returns SFU subscriptions in map order, then the
// publication.
func (c *Controller) committedResources() []*Resource {
resources := []*Resource{}
for _, edge := range c.upstreamByViewer.Values() {
+25 -50
View File
@@ -6,8 +6,7 @@ import (
"sort"
)
// validation is the private validateOrAdvance result. The TS `exhausted`
// flag was written (2454, 2588) but never read, so it is not ported.
// validation is the private validateOrAdvance result.
type validation struct {
released []*Resource
exhaustedChildPeerID string // "" = undefined
@@ -15,7 +14,7 @@ type validation struct {
consumedGuard bool
}
// failedPeerIDsFrom ports 4943: dedupe preserving argument order.
// failedPeerIDsFrom deduplicates while preserving argument order.
func failedPeerIDsFrom(results ...validation) []string {
ids := make([]string, 0, len(results))
for _, result := range results {
@@ -36,7 +35,7 @@ func isAvailabilityOperation(reason DemandReason) bool {
return reason != DemandSfuBootstrap && !isBackgroundConvergence(reason)
}
// operationRequiresNativeCandidateProof ports 3554 (nil-safe).
// operationRequiresNativeCandidateProof returns false for a nil operation.
func operationRequiresNativeCandidateProof(op *operation) bool {
if op == nil {
return false
@@ -45,7 +44,7 @@ func operationRequiresNativeCandidateProof(op *operation) bool {
(op.reason == DemandQualityConvergence && op.current != nil && op.current.tuple.Kind == UpstreamSfu)
}
// directHeadStartMs ports 3535: min(5000, max(1, floor(timeout / 2))).
// directHeadStartMs bounds half the operation timeout to 1..5000 ms.
func (c *Controller) directHeadStartMs() int64 {
return min(maxDirectHeadStartMs, max(1, c.operationTimeoutMs/2))
}
@@ -75,7 +74,7 @@ func (c *Controller) Reconcile(nowMs int64) ReconcileResult {
}
removedPeerIDs := c.pruneDepartedLeaves(&released)
// The loop bound is a count, not an order (§4.1 #8).
// The loop bound is a count, not an order.
for remaining := c.participants.Len(); remaining > 0; remaining-- {
bootstrap := c.takeSfuBootstrapCarrier(nowMs, &failedPeerIDs)
routeChildPeerID := ""
@@ -141,7 +140,7 @@ func (c *Controller) Reconcile(nowMs int64) ReconcileResult {
candidates = c.buildCandidates(childPeerID, bootstrap != nil)
}
if bootstrap == nil && continuation == nil && demand.sfuFirstAtNextRoute {
// Ordering site §4.4 #30: the SFU candidate moves to the front.
// The SFU candidate moves to the front.
sfuIndex := slices.IndexFunc(candidates, func(candidate CandidatePlan) bool {
return candidate.Tuple.Kind == UpstreamSfu
})
@@ -154,7 +153,7 @@ func (c *Controller) Reconcile(nowMs int64) ReconcileResult {
}
}
if reason == DemandSfuBootstrap {
// Ordering site §4.4 #31: bootstrap sets drop bounded-gap candidates.
// Bootstrap sets drop bounded-gap candidates.
filtered := make([]CandidatePlan, 0, len(candidates))
for _, candidate := range candidates {
if candidate.EndpointTransition.Kind != TransitionBoundedGap {
@@ -509,22 +508,8 @@ func (c *Controller) CandidateReady(guard CandidateGuard, nowMs int64, commitRes
}
nativeCandidateProof := operationRequiresNativeCandidateProof(op)
if relativeP2pApproved || relativeP2pPending || nativeCandidateProof {
// validateOrAdvance already owns the ongoing operation's eligibility.
att.mediaReady = true
currentEdge, _ := c.upstreamByViewer.Get(op.childPeerID)
if (op.reason == DemandQualityConvergence &&
(currentEdge == nil || c.senderQualityState(op.childPeerID, currentEdge, nowMs) != SenderQualityDegraded)) ||
(op.reason == DemandRootConvergence && !c.rootConvergenceOperationStillEligible(op)) {
// TS 2086-2095: the object literal reads `activeRevision: this.revision`
// before `released: [...this.abortOperation(...)]` advances it.
activeRevision := c.revision
return SettleResult{
Accepted: true,
Committed: false,
FailedPeerIDs: failedPeerIDsFrom(validation),
ActiveRevision: activeRevision,
Released: concatResources(validation.released, c.abortOperation(&nowMs, RejectionAborted)),
}
}
}
if relativeP2pExpired {
// A one-shot client proof cannot be renewed by a late sender sample.
@@ -612,7 +597,7 @@ func (c *Controller) CandidateReady(guard CandidateGuard, nowMs int64, commitRes
"child", c.debugPeer(op.childPeerID),
"candidate", c.debugTuple(att.tuple),
"revision", att.revision)
// Ordering site §4.2 #16: displaced SFU children in map order.
// Displaced SFU children in map order.
displacedSfuChildren := []string{}
if att.tuple.Kind == UpstreamSfu && att.tuple.Publication == PublicationReplace {
for childPeerID, edge := range c.upstreamByViewer.All() {
@@ -720,7 +705,7 @@ func (c *Controller) OperationExpired(nowMs int64) SettleResult {
}
}
// validateOrAdvance ports 2342: the single place that expires, replans and
// validateOrAdvance is the single place that expires, replans and
// clears the operation. failedGuard names an attempt the caller failed.
func (c *Controller) validateOrAdvance(nowMs int64, failedGuard *CandidateGuard) validation {
activeRevisionAtStart := c.revision
@@ -910,7 +895,7 @@ func (c *Controller) validateOrAdvance(nowMs int64, failedGuard *CandidateGuard)
}
// expiredFinalRoute is the finalRoute expression shared by both
// validateOrAdvance terminal branches (2434-2438, 2573-2577).
// validateOrAdvance terminal branches.
func (c *Controller) expiredFinalRoute(op *operation, backgroundConvergence, exhausted bool) FinalRoute {
if backgroundConvergence {
return c.currentFinalRoute(op.demandPeerID)
@@ -921,9 +906,9 @@ func (c *Controller) expiredFinalRoute(op *operation, backgroundConvergence, exh
return FinalRouteWaiting
}
// commitAttempt ports 2600: installs the attempt as the child's committed
// commitAttempt installs the attempt as the child's committed
// edge and returns the resources committed before but not after, plus the
// overlap (§4.2 #14).
// overlap.
func (c *Controller) commitAttempt(op *operation, att *attempt) []*Resource {
before := &resourceSet{}
before.add(c.committedResources()...)
@@ -940,7 +925,7 @@ func (c *Controller) commitAttempt(op *operation, att *attempt) []*Resource {
c.clearQualityForParticipant(op.childPeerID)
if att.tuple.Kind == UpstreamSfu && att.tuple.Publication == PublicationReplace {
// The return value is discarded on purpose (2616): the before/after
// The return value is discarded on purpose: the before/after
// diff below picks those resources up.
c.removePublicationGeneration(c.hostPublication.Generation)
}
@@ -950,7 +935,7 @@ func (c *Controller) commitAttempt(op *operation, att *attempt) []*Resource {
if parent == nil || parent.sessionID == "" {
panic(errors.New("Candidate parent session is unavailable"))
}
// Map.set keeps an existing child's position (§4.2 #15).
// Map.set keeps an existing child's position.
c.upstreamByViewer.Set(op.childPeerID, &CommittedEdge{
Kind: UpstreamPeer,
ChildSessionID: op.childSessionID,
@@ -1013,7 +998,7 @@ func (c *Controller) commitAttempt(op *operation, att *attempt) []*Resource {
remainingParentPeerIDs = append(remainingParentPeerIDs, op.deferredParentPeerIDs...)
}
if len(remainingParentPeerIDs) > 0 {
// Ordering site §4.3 #27: dedupe preserving first occurrence.
// Dedupe preserving first occurrence.
c.directContinuations.Set(op.childPeerID, &directContinuation{
childSessionID: op.childSessionID,
sfuConnectionID: att.connectionID,
@@ -1100,7 +1085,6 @@ func (c *Controller) commitAttempt(op *operation, att *attempt) []*Resource {
return released
}
// advanceActiveRevision ports 4088.
func (c *Controller) advanceActiveRevision(op *operation) {
if op.reason == DemandQualityConvergence && op.current != nil {
producerPeerID := c.hostPeerID
@@ -1123,7 +1107,6 @@ func (c *Controller) advanceActiveRevision(op *operation) {
op.baseRevision = c.revision
}
// blockAndClear ports 4112.
func (c *Controller) blockAndClear(op *operation, block bool, released *[]*Resource, revisionAdvanced bool) {
child, _ := c.participants.Get(op.childPeerID)
c.operation = nil
@@ -1143,10 +1126,9 @@ func (c *Controller) blockAndClear(op *operation, block bool, released *[]*Resou
child.availabilityExhausted = true
}
// replanRemaining ports 4164: re-plans the suffix from the cursor, forcing
// replanRemaining replans the suffix from the cursor, forcing
// firstTuple into the first slot and appending restoreTuple when absent.
func (c *Controller) replanRemaining(op *operation, firstTuple, restoreTuple *CandidateTuple) {
// Ordering site §4.4 #34.
cursor := min(op.cursor, len(op.candidates))
prefix := append([]CandidatePlan{}, op.candidates[:cursor]...)
tuples := make([]CandidateTuple, 0, len(op.candidates)-cursor+2)
@@ -1177,7 +1159,7 @@ func (c *Controller) replanRemaining(op *operation, firstTuple, restoreTuple *Ca
op.builtAtFactVersion = c.factVersion
}
// operationSnapshot ports 4190: a deep copy plus the derived wakeAtMs;
// operationSnapshot returns a deep copy plus the derived wakeAtMs;
// Current omits ConnectionAttempt when undefined.
func (c *Controller) operationSnapshot() *OperationSnapshot {
op := c.operation
@@ -1215,7 +1197,6 @@ func (c *Controller) operationSnapshot() *OperationSnapshot {
return snapshot
}
// advanceExpiredDirectHeadStart ports 4208.
func (c *Controller) advanceExpiredDirectHeadStart(op *operation, nowMs int64, released *[]*Resource) bool {
currentTuple := op.currentTuple()
sfuIndex := c.foregroundSfuIndex(op)
@@ -1238,7 +1219,7 @@ func (c *Controller) advanceExpiredDirectHeadStart(op *operation, nowMs int64, r
op.current = nil
c.clearCandidateTiming(op.demandPeerID)
}
// Ordering site §4.4 #33: deferred parents keep candidate order.
// Deferred parents keep candidate order.
for _, candidate := range op.candidates[op.cursor:min(sfuIndex, len(op.candidates))] {
if candidate.Tuple.Kind == UpstreamPeer {
op.deferredParentPeerIDs = append(op.deferredParentPeerIDs, candidate.Tuple.ParentPeerID)
@@ -1254,7 +1235,6 @@ func (c *Controller) advanceExpiredDirectHeadStart(op *operation, nowMs int64, r
return true
}
// operationWakeAt ports 4258.
func (c *Controller) operationWakeAt(op *operation) int64 {
currentTuple := op.currentTuple()
if currentTuple != nil && currentTuple.Kind == UpstreamPeer &&
@@ -1270,17 +1250,15 @@ func (c *Controller) operationWakeAt(op *operation) int64 {
return op.deadlineAtMs
}
// peerCandidateHasSuccessor ports 4281.
func (c *Controller) peerCandidateHasSuccessor(op *operation) bool {
return op.cursor+1 < len(op.candidates) || c.foregroundSfuIndex(op) > op.cursor
}
// directHeadStartDeadlineAt ports 4290.
func (c *Controller) directHeadStartDeadlineAt(op *operation) int64 {
return op.deadlineAtMs - c.operationTimeoutMs + c.directHeadStartMs()
}
// foregroundSfuIndex ports 4299: the index of the first SFU candidate at or
// foregroundSfuIndex returns the index of the first SFU candidate at or
// after the cursor, the candidate count when a bootstrap carrier exists, -1
// otherwise (and always -1 for background convergence).
func (c *Controller) foregroundSfuIndex(op *operation) int {
@@ -1298,7 +1276,6 @@ func (c *Controller) foregroundSfuIndex(op *operation) int {
return -1
}
// promoteNextDirectWithinHeadStart ports 4314 (§4.4 #32).
func (c *Controller) promoteNextDirectWithinHeadStart(op *operation, nowMs int64) {
sfuIndex := c.foregroundSfuIndex(op)
if sfuIndex != op.cursor+1 || nowMs >= c.directHeadStartDeadlineAt(op) {
@@ -1319,13 +1296,11 @@ func (c *Controller) promoteNextDirectWithinHeadStart(op *operation, nowMs int64
op.candidates = slices.Insert(op.candidates, sfuIndex, nextDirect)
}
// guardMatches ports 4338.
func (c *Controller) guardMatches(guard CandidateGuard, op *operation, att *attempt) bool {
return guard.ChildPeerID == op.childPeerID && guard.ChildSessionID == op.childSessionID &&
guard.Revision == att.revision && guard.ConnectionID == att.connectionID
}
// cursorGuardMatches ports 4343.
func (c *Controller) cursorGuardMatches(guard CandidateCursorGuard, op *operation) bool {
plan := op.planAt(op.cursor)
return plan != nil &&
@@ -1335,8 +1310,8 @@ func (c *Controller) cursorGuardMatches(guard CandidateCursorGuard, op *operatio
candidatePlanEquals(guard.Plan, *plan)
}
// selectNextChild ports 3451: five priority buckets, first-occurrence
// dedupe, then a STABLE sort by started attempts (§4.5 #42).
// selectNextChild uses five priority buckets, first-occurrence deduplication,
// then a stable sort by started attempts.
func (c *Controller) selectNextChild() string {
viewers := c.availableViewers(false)
departedParents := []string{}
@@ -1378,7 +1353,7 @@ type directContinuationPick struct {
plan CandidatePlan
}
// selectDirectContinuation ports 3477 (§4.3 #23-25): a stable sort over
// selectDirectContinuation uses a stable sort over
// the round-robin map order, then the first valid parent per child.
func (c *Controller) selectDirectContinuation() *directContinuationPick {
type entry struct {
@@ -1432,9 +1407,9 @@ func (c *Controller) selectDirectContinuation() *directContinuationPick {
return nil
}
// consumeDirectContinuationCandidate ports 3564: removes the tried parent
// consumeDirectContinuationCandidate removes the tried parent
// from the retry order (re-appending it while NAT attempts remain) and
// moves the continuation to the tail of the round-robin (§4.3 #24, #26).
// moves the continuation to the tail of the round-robin.
func (c *Controller) consumeDirectContinuationCandidate(op *operation) {
tuple := op.currentTuple()
if op.reason != DemandDirectConvergence || tuple == nil || tuple.Kind != UpstreamPeer {
+8 -19
View File
@@ -7,7 +7,7 @@ import (
"github.com/TNTcraftHIM/Piik/internal/server/protocol"
)
// candidateOpportunityBase ports 2900: the NUL-joined ledger key. A session
// candidateOpportunityBase returns the NUL-joined ledger key. A session
// change erases the key because the session is part of it.
func (c *Controller) candidateOpportunityBase(childPeerID string, plan CandidatePlan) string {
childSessionID := ""
@@ -28,7 +28,6 @@ func (c *Controller) candidateOpportunityBase(childPeerID string, plan Candidate
return sfuOpportunityPrefix + childSessionID + "\x00" + hostSessionID
}
// candidateOpportunityAvailable ports 2823.
func (c *Controller) candidateOpportunityAvailable(childPeerID string, plan CandidatePlan) bool {
child, _ := c.participants.Get(childPeerID)
if child == nil {
@@ -43,7 +42,7 @@ func (c *Controller) candidateOpportunityAvailable(childPeerID string, plan Cand
consumed.startedAttempts < maxNatConnectionAttempts)
}
// startCandidateOpportunity ports 2842: counts one NAT attempt for a peer
// startCandidateOpportunity counts one NAT attempt for a peer
// candidate of an availability or direct-convergence operation.
func (c *Controller) startCandidateOpportunity(op *operation, plan CandidatePlan) *ConnectionAttemptProgress {
if !c.natPredictionEnabled ||
@@ -64,13 +63,11 @@ func (c *Controller) startCandidateOpportunity(op *operation, plan CandidatePlan
if ok {
newRank = min(previous.rank, rank)
}
// The new entry has no `exhausted` (2859-2862).
opportunities.Set(key, candidateOpportunity{rank: newRank, startedAttempts: startedAttempts})
op.peerAttemptStarted = true
return &ConnectionAttemptProgress{Current: startedAttempts, Total: maxNatConnectionAttempts}
}
// peerAttemptsStarted ports 2867.
func (c *Controller) peerAttemptsStarted(childPeerID, parentPeerID string) int {
if !c.natPredictionEnabled {
return 0
@@ -90,7 +87,7 @@ func (c *Controller) peerAttemptsStarted(childPeerID, parentPeerID string) int {
return 0
}
// viewerAttemptsStarted ports 2880: the sum over every peer key.
// viewerAttemptsStarted sums the attempts over every peer key.
func (c *Controller) viewerAttemptsStarted(peerID string) int {
if !c.natPredictionEnabled {
return 0
@@ -108,7 +105,6 @@ func (c *Controller) viewerAttemptsStarted(peerID string) int {
return started
}
// hasRemainingNatOpportunity ports 2890.
func (c *Controller) hasRemainingNatOpportunity(op *operation) bool {
if !isAvailabilityOperation(op.reason) || !op.peerAttemptStarted {
return false
@@ -121,7 +117,7 @@ func (c *Controller) hasRemainingNatOpportunity(op *operation) bool {
return false
}
// consumeCandidateOpportunity ports 2916: exhausts the key at its best rank.
// consumeCandidateOpportunity exhausts the key at its best rank.
func (c *Controller) consumeCandidateOpportunity(childPeerID string, plan CandidatePlan) {
child, _ := c.participants.Get(childPeerID)
if child == nil {
@@ -138,7 +134,6 @@ func (c *Controller) consumeCandidateOpportunity(childPeerID string, plan Candid
child.consumedCandidateOpportunities.Set(key, entry)
}
// qualityCandidateOpportunityAvailable ports 2934.
func (c *Controller) qualityCandidateOpportunityAvailable(childPeerID string, plan CandidatePlan) bool {
observation := c.senderQualityObservations[childPeerID]
if observation == nil {
@@ -148,7 +143,6 @@ func (c *Controller) qualityCandidateOpportunityAvailable(childPeerID string, pl
return !ok || endpointTransitionRank(plan.EndpointTransition) < consumed
}
// consumeQualityCandidateOpportunity ports 2949.
func (c *Controller) consumeQualityCandidateOpportunity(op *operation, plan *CandidatePlan) {
if op.reason != DemandQualityConvergence || plan == nil {
return
@@ -162,7 +156,6 @@ func (c *Controller) consumeQualityCandidateOpportunity(op *operation, plan *Can
endpointTransitionRank(plan.EndpointTransition))
}
// consumeQualityOperationCandidates ports 2967.
func (c *Controller) consumeQualityOperationCandidates(op *operation) {
for index := range op.candidates {
candidate := op.candidates[index]
@@ -170,8 +163,7 @@ func (c *Controller) consumeQualityOperationCandidates(op *operation) {
}
}
// consumeActiveEdgeOpportunity ports 2975: an invalidated active edge
// exhausts its exact tuple at rank 0.
// consumeActiveEdgeOpportunity exhausts the invalidated edge's exact tuple at rank 0.
func (c *Controller) consumeActiveEdgeOpportunity(child *participant, edge *CommittedEdge) {
if edge.Kind == UpstreamPeer {
key := "peer:" + edge.ParentPeerID + "\x00" + child.sessionID + "\x00" + edge.ParentSessionID
@@ -189,7 +181,6 @@ func (c *Controller) consumeActiveEdgeOpportunity(child *participant, edge *Comm
})
}
// consumeAvailabilityOperation ports 2993.
func (c *Controller) consumeAvailabilityOperation(op *operation) {
if !isAvailabilityOperation(op.reason) {
return
@@ -202,7 +193,6 @@ func (c *Controller) consumeAvailabilityOperation(op *operation) {
}
}
// consumeCurrentAvailabilityOpportunity ports 3003.
func (c *Controller) consumeCurrentAvailabilityOpportunity(op *operation) {
if !isAvailabilityOperation(op.reason) {
return
@@ -216,8 +206,8 @@ func (c *Controller) consumeCurrentAvailabilityOpportunity(op *operation) {
}
}
// clearOpportunitiesForSessionChange ports 4561 (§4.1 #5, §4.5 #47): keys
// are snapshotted before deletion, which JS defines as safe in place.
// clearOpportunitiesForSessionChange snapshots keys before deletion,
// preserving JavaScript's safe deletion during iteration.
func (c *Controller) clearOpportunitiesForSessionChange(peerID string, role protocol.Role) {
if current, ok := c.participants.Get(peerID); ok {
current.consumedCandidateOpportunities.Clear()
@@ -248,7 +238,6 @@ func (c *Controller) clearOpportunitiesForSessionChange(peerID string, role prot
}
}
// clearSfuCandidateOpportunities ports 4597.
func (c *Controller) clearSfuCandidateOpportunities() {
for _, current := range c.participants.Values() {
for _, key := range current.consumedCandidateOpportunities.Keys() {
@@ -261,7 +250,7 @@ func (c *Controller) clearSfuCandidateOpportunities() {
c.consumedSfuBootstrapOpportunities.Clear()
}
// consumeOpportunity ports 5011: keeps the best (lowest) rank.
// consumeOpportunity keeps the best (lowest) rank.
func consumeOpportunity(consumed *ordered.Map[string, int], base string, rank int) {
previous, ok := consumed.Get(base)
if !ok || rank < previous {
Loaded 100 of 236 files, more files were not shown because too many files have changed in this diff. Show more