feat: launch Piik with a unified interface and public website

Piik now has one public presentation and a coordinated App/Server release path. This phase unifies the room UI, rewrites the bilingual README and guides, adds a static website with an inline product film, and prepares matching GitHub/Gitee packages and update links.

The film uses the current homepage and shared product controls on one seekable timeline. Runtime fixes preserve presentation when the App opens a room, normalize Site origins, and keep capture and media-recovery status in the television indicator. New visits use Chinese for a Chinese system language and English otherwise; explicit language and visual-mode choices remain available. An optional site passphrase controls site entry, while room authorization remains enforced.

GitHub is the release authority. Gitee mirrors verified packages and provides update metadata when GitHub is unavailable. Reviewed PR release notes become the GitHub Release description. The separate public demo uses P2P only, with HTTPS and STUN verified.

Validation: 756 Web tests, TypeScript and production build; Go core tests, formatting, vet and available target builds; bounded Windows capture and App/Site interoperability; browser film checks, audible playback and narrow layouts; anonymous Gitee Windows-package download, checksum and launcher/update-link checks. Public demo acceptance covers authenticated WebSocket signaling, synthetic P2P video and room cleanup on one network. macOS/Linux physical capture and the wider device/network/endurance matrix remain explicitly deferred.

Publication builds every package from the merged source SHA. The repository is public, main protection is active, and Pages is configured for piik.tv. CI/publisher and Pages deployment records own publication results.

## Release notes

### 中文

Piik 是免费开源的屏幕共享工具。开个房间,发出邀请,最多 20 位朋友就能用浏览器加入观看。

- 使用浏览器或 Piik App 分享游戏、窗口和整个屏幕。
- 优先建立直连;自托管站点可开启媒体转发作为备用线路。
- Piik App 支持本地房间、临时公网邀请和已有站点。
- Piik Server 内置网页界面、房间管理和连接服务,方便自行部署。
- 提供中英文界面与使用指南,以及 GitHub、Gitee 两条下载线路。

[官网](https://piik.tv) · [在线体验](https://demo.piik.tv) · [使用指南](https://github.com/TNTcraftHIM/Piik/blob/main/docs/guide/getting-started.zh-CN.md) · [自行部署](https://github.com/TNTcraftHIM/Piik/blob/main/docs/operations/self-hosting.zh-CN.md) · [Gitee 下载](https://gitee.com/TNTcraftHIM/Piik/releases)

Windows App 和桌面浏览器是本次主要验证的平台。macOS 和 Linux 尚未经过实机测试,欢迎有设备的朋友测试并反馈。演示站和 App 公网邀请使用 P2P,受限网络下可能无法连接;自托管站点可开启媒体转发。

### English

Piik is free, open-source screen sharing for you and up to 20 viewers. Start a room, send an invitation, and your friends can join in a browser.

- Share games, windows or a whole screen from your browser or Piik App.
- Direct connections come first, with optional media forwarding on self-hosted sites.
- Piik App supports local rooms, temporary public invitations and existing sites.
- Piik Server includes the web interface, rooms and connection services for self-hosting.
- English and Chinese interfaces and guides are available, with GitHub and Gitee downloads.

[Website](https://piik.tv) · [Online demo](https://demo.piik.tv) · [User guide](https://github.com/TNTcraftHIM/Piik/blob/main/docs/guide/getting-started.md) · [Self-hosting](https://github.com/TNTcraftHIM/Piik/blob/main/docs/operations/self-hosting.md) · [Gitee downloads](https://gitee.com/TNTcraftHIM/Piik/releases)

Windows App and desktop browsers are the primary validated paths. macOS and Linux have not yet been tested on physical devices; testing and feedback are welcome. The demo and App public invitations rely on P2P and may not connect on restrictive networks. Self-hosted sites can enable media forwarding.
This commit is contained in:
AAA authored and GitHub committed 2026-09-12 22:09:12 +08:00
1 parent 0dd9f57153
commit f92162b3bb
261 files changed
+15305 -5328

No files matched your search

+13 -8
View File
@@ -6,8 +6,9 @@ PORT=8787
PUBLIC_BASE_URL=http://localhost:8787
# Include both the host browser origin and PUBLIC_BASE_URL when they differ.
ALLOWED_ORIGINS=http://localhost:8787
# Production-required site access secret. Local development/tests may leave it
# empty. A non-empty value must contain 8-128 visible ASCII bytes (0x21-0x7e).
# Optional site access password in every environment. Unset or empty allows
# entry without a site password; room ownership and invitations still apply.
# A non-empty value must contain 8-128 visible ASCII bytes (0x21-0x7e).
SITE_ACCESS_PASSWORD=
# Rooms persist in rooms.sqlite under the working directory by default (also
# when blank). Set :memory: explicitly for process-memory rooms. Deployments
@@ -23,7 +24,8 @@ ENDPOINT_MEDIA_COPY_CAPACITY=2
# PIIK_DEBUG=route
# Optional in-process automatic SFU fallback. Set SFU_UDP_PORT=7882 to enable;
# unset or empty keeps it disabled. Local Client modes never create this listener.
# unset or empty disables it and fixes Privacy mode on for every room.
# STUN/signaling/P2P remain available. Local App modes never create this listener.
# Ingress uses the 9000-room code capacity; egress uses that capacity times the
# configured per-room Viewer admission.
SFU_UDP_PORT=
@@ -38,11 +40,12 @@ SFU_PUBLIC_IP=
STUN_URLS=
# Hosted STUN is in-process. With STUN_URLS configured, bind this IPv4 address
# on UDP 3478 (also 3479/3480 below). It is independent of HTTP LISTEN_HOST.
# Local Client modes never create these listeners, even with public discovery.
# Local App modes never create these listeners, even with public discovery.
STUN_LISTEN_HOST=0.0.0.0
# Optional bounded NAT traversal support. When enabled, Hosts see a per-share
# switch that defaults on after all three local STUN listeners bind. Browser
# Optional bounded NAT traversal support. The per-share switch stays visible;
# it is locked off when disabled, and defaults on when enabled after all three
# local STUN listeners bind. Browser
# P2P derives UDP 3479/3480 from the first STUN_URLS endpoint on UDP 3478.
# Cloud security-group and host firewall rules must also allow these UDP ports.
NAT_PREDICTION_ENABLED=false
@@ -53,9 +56,11 @@ NAT_PREDICTION_ENABLED=false
# PIIK_ENV=development
# PORT=8788
# Minimal production example (use independent untracked secrets):
# Production example (keep any chosen password in an untracked environment):
# PIIK_ENV=production
# SITE_ACCESS_PASSWORD=replace-with-an-independent-access-key
# PUBLIC_BASE_URL=https://share.example.com
# ALLOWED_ORIGINS=https://share.example.com
# SITE_ACCESS_PASSWORD=
# STUN_URLS=stun:stun.example.com:3478
# NAT_PREDICTION_ENABLED=true
# Automatic SFU fallback (does not replace STUN above):
+1
View File
@@ -13,6 +13,7 @@
*.ico binary
*.jpg binary
*.jpeg binary
*.mp3 binary
*.png binary
*.webp binary
*.woff binary
+23
View File
@@ -0,0 +1,23 @@
---
name: Bug report / 问题反馈
about: Report a reproducible problem / 反馈可复现的问题
---
## Environment / 环境
- Piik version and build SHA / Piik 版本与构建 SHA:
- Browser, App, or Server / 使用端:
- OS and browser versions / 系统与浏览器版本:
## Steps to reproduce / 复现步骤
1.
## Expected and actual behavior / 预期与实际结果
- Expected / 预期:
- Actual / 实际:
## Optional evidence / 可选证据
<!-- Screenshots or a local Debug export may help. Review artifacts before attaching; remove credentials and private content. 可附截图或本地 Debug 导出;分享前移除凭据与隐私内容。 -->
+12
View File
@@ -0,0 +1,12 @@
---
name: Feature request / 功能建议
about: Describe a use case and desired outcome / 说明使用场景与期望结果
---
## Problem or use case / 问题或场景
<!-- Who needs this, and what cannot they do today? 谁在什么场景遇到了什么限制? -->
## Desired outcome / 期望结果
<!-- Describe the behavior that would solve the problem; alternatives are optional. 说明怎样的行为能解决问题,可补充替代方案。 -->
+10 -13
View File
@@ -1,20 +1,17 @@
## Summary
<!-- Title: type(scope): outcome. One accepted phase per squash PR. 标题遵循提交命名,一次 PR 完成一个阶段。 -->
Describe the problem and the outcome of this change.
## Problem and outcome / 问题与结果
## Verification
<!-- Describe the trigger and resulting behavior. Link a related issue or decision when useful. 说明问题、结果及相关依据。 -->
List automated tests, manual checks, environments, and meaningful measurements.
## Release notes
## Research And Decisions
<!-- Public copy, automatically published in GitHub Releases. Keep this exact heading; use ### for subsections. Describe user benefits, fixes and any required upgrade action in concise Chinese and English. Omit internal logs, audit prompts and unverified claims. 更新说明将直接公开:写清用户可感知的变化与升级事项,提供简洁的中英文文案。首次发布介绍当前产品,不罗列内部开发历史。 -->
Link the relevant product owner, research note, issue, and ADR, or explain why none is needed.
## Verification / 验证
## Checklist
<!-- List exact local checks and results, relevant environments, and material untested cases. 列出检查命令、结果与未验证项。 -->
- [ ] The branch contains one coherent change and no unrelated churn.
- [ ] Required local tests and repository hygiene checks pass, and their exact evidence is listed above.
- [ ] Security, privacy, compatibility, and licensing effects were considered.
- [ ] Product owners, operational docs, ADRs, project memory, and status were updated only where their facts changed.
- [ ] Stale or conflicting memory and documentation were removed rather than preserved as a diary.
- [ ] No credentials, personal data, machine-specific paths, or required untracked artifacts are included.
## Impact / 影响
<!-- Note relevant compatibility, release interruption/recovery, and updated owner docs. Explain any breaking change marked by ! in the title; write “None” if not applicable. 说明兼容、发布及文档影响,解释标题中 ! 标记的破坏性变更;不适用填“无”。 -->
+92 -4
View File
@@ -11,13 +11,25 @@ on:
required: false
default: false
type: boolean
candidate_version:
description: Optional candidate version (vMAJOR.MINOR.PATCH); never publishes
required: false
type: string
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: false
queue: max
jobs:
validate:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.release.outputs.version }}
publish: ${{ steps.release.outputs.publish }}
steps:
- name: Check out repository
uses: actions/checkout@v6
@@ -34,9 +46,26 @@ jobs:
PIIK_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
- name: Install dependencies
run: npm ci
- name: Determine release identity
id: release
env:
PIIK_BUILD_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.candidate_version || '' }}
run: >-
node scripts/release-version.mjs
${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.PIIK_RELEASES_ENABLED == 'true' && 'plan' || 'build' }}
--github-output
- name: Preview release notes
if: steps.release.outputs.publish == 'true' || inputs.candidate_version != ''
run: node scripts/release-notes.mjs "$PIIK_RELEASE_VERSION" "$GITHUB_SHA" --github-summary
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
env:
PIIK_BUILD_VERSION: ${{ steps.release.outputs.version }}
VITE_PIIK_VERSION: ${{ steps.release.outputs.version }}
VITE_PIIK_REVISION: ${{ github.sha }}
- name: Cache Go toolchain
uses: actions/cache@v4
with:
@@ -49,15 +78,22 @@ jobs:
cache-dependency-path: go.sum
- name: Check Go core
run: npm run check:client-core
- name: Type-check, test, and build
run: npm run check
- name: Type-check and test
run: npm run typecheck && npm test
env:
PIIK_BUILD_VERSION: ${{ steps.release.outputs.version }}
VITE_PIIK_VERSION: ${{ steps.release.outputs.version }}
VITE_PIIK_REVISION: ${{ github.sha }}
release-application:
needs: validate
if: >-
github.event_name == 'workflow_dispatch' && inputs.client_checks == true
needs.validate.outputs.publish == 'true' ||
(github.event_name == 'workflow_dispatch' && inputs.client_checks == true)
runs-on: ubuntu-latest
timeout-minutes: 15
env:
PIIK_BUILD_VERSION: ${{ needs.validate.outputs.version }}
steps:
- name: Check out repository
uses: actions/checkout@v6
@@ -93,7 +129,8 @@ jobs:
- validate
- release-application
if: >-
github.event_name == 'workflow_dispatch' && inputs.client_checks == true
needs.validate.outputs.publish == 'true' ||
(github.event_name == 'workflow_dispatch' && inputs.client_checks == true)
strategy:
fail-fast: false
matrix:
@@ -106,6 +143,8 @@ jobs:
runner: macos-15
runs-on: ${{ matrix.runner }}
timeout-minutes: 20
env:
PIIK_BUILD_VERSION: ${{ needs.validate.outputs.version }}
steps:
- name: Check out repository
uses: actions/checkout@v6
@@ -158,3 +197,52 @@ jobs:
if-no-files-found: error
compression-level: 0
retention-days: 14
publish:
needs:
- validate
- release-application
- release-clients
if: needs.validate.outputs.publish == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write
steps:
- name: Check out repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version-file: .node-version
cache: npm
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Download application release
uses: actions/download-artifact@v8
with:
name: piik-server-${{ github.sha }}
path: ${{ runner.temp }}/publish-release
- name: Download App release candidates
uses: actions/download-artifact@v8
with:
pattern: piik-app-*-${{ github.sha }}
path: ${{ runner.temp }}/publish-release
merge-multiple: true
- name: Publish verified release
run: >-
node scripts/publish-release.mjs
"$RUNNER_TEMP/publish-release" "$PIIK_RELEASE_VERSION" "$GITHUB_SHA"
env:
GH_TOKEN: ${{ github.token }}
PIIK_RELEASE_VERSION: ${{ needs.validate.outputs.version }}
- name: Mirror verified release to Gitee
run: >-
node scripts/mirror-release.mjs
"$RUNNER_TEMP/publish-release" "$PIIK_RELEASE_VERSION" "$GITHUB_SHA"
env:
GH_TOKEN: ${{ github.token }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
PIIK_RELEASE_VERSION: ${{ needs.validate.outputs.version }}
+39
View File
@@ -0,0 +1,39 @@
name: Website
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: github-pages
cancel-in-progress: false
jobs:
deploy:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .node-version
cache: npm
- run: npm ci
- run: npm run build:website
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v4
with:
path: build/site
retention-days: 1
- uses: actions/deploy-pages@v4
id: deployment
+19 -32
View File
@@ -23,7 +23,7 @@
## Product Contract
- Piik is private game screen sharing for one Host and up to 20 authenticated
- 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),
@@ -45,10 +45,9 @@
[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.
- Product modules own current behavior; ADRs own non-obvious decisions and
consequences; research owns evidence and license boundaries; deployment owns
operational reference. Status is the current execution/deployment index, TODO
is the only work ledger, and Git/PRs own completed history.
- Use the [documentation owner map](./docs/maintenance.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
snapshot materially changes. Ordinary UI detail, protocol field listings,
self-evident code, test inventories, routine validation, and agent process do
@@ -71,40 +70,28 @@
- Canonical root must be clean current `main` at audit/integration boundaries.
Create branches/worktrees from that exact commit; old branches and worktrees
are never truth sources.
- `main` is a low-frequency integration branch, never a workbench. Keep work on
branches/worktrees and merge only one complete, user-accepted phase per PR,
after its related implementation, tests, documentation, and acceptance are
complete. Use one squash commit and leave `main` unchanged between phases.
Do not merge intermediate checkpoints, release records, metadata-only edits,
or follow-up cleanup; fold them into the next phase. A P0/P1 emergency may
bypass batching only when the user explicitly authorizes it, still through
one coherent PR.
- `main` remains low-frequency: one complete, user-accepted phase per squash PR,
with no intermediate release-record or cleanup commits. Keep implementation
on worktrees; [CONTRIBUTING.md](./CONTRIBUTING.md#full-integration-and-release-workflow)
owns the full workflow and explicit emergency exception.
- Preserve user work. Branch, truth-checkpoint, PR, release, recovery, and
cleanup rules are owned by [CONTRIBUTING.md](./CONTRIBUTING.md); do not mirror
that workflow here.
## Engineering Defaults
- Prefer the simplest cohesive design that is easy to use and maintain. Reuse
mature components, keep ownership explicit, and remove superseded paths in
the same change instead of retaining speculative flexibility or technical debt.
- Apply good coding practice to every change: split modules around clear
responsibilities and state/lifecycle ownership, with cohesive internals and
narrow interfaces. Reuse proven shared behavior and abstract stable common
responsibilities; avoid both duplicate implementations and needless wrappers.
Review coupling and readability, not only file size or line count.
- Judge a change by evidenced user value against its full implementation,
maintenance, compatibility, and failure cost. A large measured gain may
justify broad or breaking work; a small or speculative gain does not justify
material complexity or risk.
- After completing a material module, run one ablation pass and remove every new
component, state, dependency, or branch that is not needed for the verified
result or an accepted contract. Its primary goal is lower total system
complexity, not perfect performance; marginal gains do not justify permanent
mechanisms or a materially larger maintenance surface.
- UI changes must follow the shared [visual language](./docs/design/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):
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,
storage and live generations, and owns the public compatibility design.
Its pending public-release policy does not replace today's private contract.
- Use primary sources for non-trivial design and bugs. Add focused tests in
proportion to risk; batch full browser/network/endurance checks at acceptance.
- Keep scripts deterministic, fast, cross-platform, and CI-runnable. Support
Windows, macOS, and Linux; use LF and ASCII filenames for new files.
- Keep scripts deterministic, fast, cross-platform and CI-runnable.
- Before finishing material work, inspect status, report untracked artifacts,
and stage requested files unless told otherwise.
-5
View File
@@ -1,5 +0,0 @@
@AGENTS.md
# Claude Code
This file intentionally imports the repository-wide agent instructions instead of duplicating them. Keep durable project guidance in `AGENTS.md` and the referenced bounded project documents.
+48 -12
View File
@@ -23,7 +23,7 @@ leave `main` alone until the next material phase. Only a user-authorized P0/P1
emergency may use a separate PR, and that PR must still contain one coherent
fix.
1. Create one short-lived branch/worktree from exact canonical `main`; use `feat/`, `fix/`, `docs/`, `refactor/`, `test/`, `chore/`, or `spike/` followed by a short description.
1. Create one short-lived branch/worktree from exact canonical `main`, following the [branch naming convention](#commit-pr-and-branch-names).
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.
@@ -37,20 +37,24 @@ the repository. Exact deployment identity belongs to the immutable release
descriptor, runtime `REVISION`, and deployment record. Preserve separate commits
only when they carry independently useful history.
Direct commits to `main` are reserved for an explicit user-approved exception. Never force-push shared branches or rewrite shared history without explicit approval.
Update `main` through PRs only; branch checkpoints and generated release metadata
do not bypass that boundary. Never force-push shared branches or rewrite shared
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.
Do not change `SIGNALING_PROTOCOL` for compatible internal, UI, routing, or
release work. A wire change makes existing pages stale and forces an active
Browser Host to reload, which ends its capture. Bump it only for an actually
incompatible message contract whose atomic cutover and capture interruption are
explicitly accepted.
Use the [version/compatibility policy](./docs/reference/versioning.md) to classify
release changes. In today's private contract an incompatible signaling cutover
forces old pages to reload and ends Browser capture; include that interruption
in the accepted cutover. Compatible UI/internal work does not require a wire bump.
Clean up worktrees and branches only after semantic review and integration are complete and the normal merged-head, open-reference, clean-tree, and non-following link checks pass. Never merge an old branch wholesale after a newer truth checkpoint.
## Research Standard
The [engineering reference](./docs/reference/engineering.md) owns module and
interface discipline, including the post-change ablation pass.
Follow the [naming convention](./docs/reference/naming.md) for product copy,
commands, packages and code ownership. Keep display names separate from stable
protocol identifiers; use normal language-specific identifier conventions.
@@ -65,6 +69,10 @@ protocol identifiers; use normal language-specific identifier conventions.
## Verification Entrypoints
Every retained test must be collected by a documented verification entry point.
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, three-platform
builds, and the Windows capture compile/probe when run on Windows. The server
@@ -80,16 +88,44 @@ protocol identifiers; use normal language-specific identifier conventions.
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.
- Server and three-platform Client candidates run only from an explicit manual
workflow dispatch with `client_checks=true`. Ordinary `main` pushes validate
source without running the expensive packaging matrix.
Publishing a tag or GitHub Release remains an explicit release decision rather
than a side effect of every merge.
- 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,
publication and activation state. Package the merged SHA without writing
version-record commits back to `main`.
## Commit, PR And Branch Names
Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/):
`type(scope): outcome`. Scope is optional. Use `feat` for new behavior, `fix`
for repairs, or `docs`, `refactor`, `test`, `build`, `ci`, and `chore` for their
named purpose. Keep the outcome short and concrete; English is the default,
and a Chinese explanation may follow in the body.
The PR title names the complete phase and becomes its single squash commit on
`main`, for example `fix(app): restore sharing after a source change`. Working
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
selection and publication behavior.
Name branches `<type>/<short-kebab-case-description>`, using the same types;
reserve `spike/` for experiments. No issue number is required. 中文说明:PR 标题
与最终 squash 提交使用上述格式;分支用英文短名,破坏性变更标记 `!` 并说明影响。
## Pull Request Scope
A pull request should explain the problem, the chosen design, verification performed, user-visible or operational effects, and remaining risks.
Before an integration PR is accepted, fill its exact `## Release notes` section
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)
owns aggregation and preview. Do not add a changelog file or release-note archive.
Durable semantic or current-snapshot changes must update their single owner in the same integration boundary. Ordinary implementation and bug fixes may be complete with code plus focused evidence.
## Local Hooks
-22
View File
@@ -1,22 +0,0 @@
# MIT 许可证中文参考译文
The authoritative license is the English [LICENSE](./LICENSE).
This translation is for convenience and adds no terms or restrictions.
本文件为方便阅读提供中文参考译文,不替代英文 [LICENSE](./LICENSE),
也不增加条件或限制。Piik 自有代码的 MIT 许可不替代
[第三方组件的许可与声明](./licenses/README.md)。
版权所有 (c) 2026 TNTcraft 和 Piik 贡献者
特此免费授予任何获得本软件及相关文档文件(以下简称“软件”)副本的人,
不受限制地处置本软件的权利,包括但不限于使用、复制、修改、合并、出版、
分发、再许可和/或销售本软件副本,并允许获得软件的人这样做,
但须遵守以下条件:
上述版权声明和本许可声明应包含在本软件的所有副本或实质部分中。
本软件按“原样”提供,不作任何明示或默示保证,包括但不限于适销性、
特定用途适用性和不侵权的保证。在任何情况下,无论依据合同、侵权或其他
法律理论,作者或版权持有人均不对因软件、软件的使用或其他处置而产生、
源自或与之有关的任何索赔、损害或其他责任负责。
+96 -70
View File
@@ -1,83 +1,109 @@
<img src="./public/favicon.svg" width="64" height="64" alt="Piik TV logo">
<p align="center"><img src="./public/favicon.svg" width="64" height="64" alt="Piik mascot"></p>
<h1 align="center">Piik</h1>
<p align="center"><strong>Share the good stuff.</strong><br>Private screen sharing for you and up to 20 friends.</p>
<p align="center">
<a href="https://piik.tv">Website</a> ·
<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">Try the demo</a> ·
<a href="./docs/README.md">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>
<img src="https://img.shields.io/badge/viewers-up_to_20-36564f?style=flat-square" alt="Up to 20 viewers">
<img src="https://img.shields.io/badge/media-P2P_first-36564f?style=flat-square" alt="P2P-first media">
</p>
<p align="center">English · <a href="./README.zh-CN.md">简体中文</a></p>
# Piik
Piik is a free, open-source screen sharing tool for games, movie nights,
drawings and photos. Choose what to share and send an invitation. Your friends
watch in their browsers.
English | [简体中文](./README.zh-CN.md)
<details open>
<summary>A little room · Show / hide animation</summary>
Share your screen. Bring your friends.
<p align="center"><img src="./site/assets/living-room.svg" width="720" height="472" alt="A host with a small gold crown shares an RPG, a drawing, photos and an animated film while friends watch from the sofa."></p>
Piik is private screen sharing for one Host and up to 20 invited friends.
Friends watch in their browsers, with no installation.
</details>
```mermaid
flowchart LR
Browser[Browser] --> Host[Host a room]
App[Piik App] --> Host
Host -->|Invite link| Friends[Friends watch in browsers]
[Features](#features) · [Get started](#get-started) · [Self-hosting](#self-hosting) · [Contributing](#contributing)
## Features
- **Watch without installing.** Friends join by invitation in a desktop or mobile browser.
- **Share from Piik App or an existing Piik site.** Capture a screen, window or browser tab; available sources and audio depend on the platform.
- **Direct connections first.** Media travels between participants where possible (P2P). A self-hosted server can provide automatic media forwarding (SFU) as a fallback.
- **Rooms you control.** Invitations, room codes and optional room passwords, for one host and up to 20 viewers.
- **Flexible viewing.** Light and dark themes, playback controls, picture-in-picture and a connection topology view.
- **One program to host a site.** Web UI, room management and optional media forwarding are packaged together.
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./docs/assets/room-en-dark.png">
<img src="./docs/assets/room-en-light.png" width="860" alt="Piik room: a shared game, playback controls and friends on a sofa. The host wears a small gold crown.">
</picture><br>
<sub>Interface preview · Sample room with a generated game scene</sub>
</p>
## Get started
**To watch:** open the invitation your friend sends you. Select **Play** if the
picture does not start automatically.
**To share:**
| Start here | What you need to do |
| --- | --- |
| [Try the online demo](https://demo.piik.tv) | Open it in a desktop browser and start sharing. |
| [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. |
<p align="center"><img src="./docs/assets/quickstart.svg" width="640" alt="Choose a screen, send an invitation, watch together."></p>
1. Select **Start sharing**, then choose the picture and audio to share.
2. Select **Copy invite link** and send it to your friends. Keep the sharing tab open.
Piik uses icon controls by default; select **EN** in the header to show labels.
The App offers a local room, a temporary public link, or a connection to your own
site. Keep the App running while sharing through it.
Download an archive beginning with **`piik-app`**. The filename also identifies the platform:
| Filename contains | Platform |
| --- | --- |
| `windows-amd64` | Windows x64 |
| `darwin-arm64` | Apple silicon; native capture requires macOS 13+ |
| `linux-amd64` | Linux x64 |
[**Full setup guide →**](./docs/guide/getting-started.md) · [**Open the demo →**](https://demo.piik.tv)
Browser capture requires HTTPS or `localhost`. Windows App and desktop browser
sharing are the primary tested paths. The macOS and Linux apps have not yet been
tested on physical devices; [test results and feedback are welcome](https://github.com/TNTcraftHIM/Piik/issues).
P2P-only modes, including the App's temporary public
link and the demo, may not connect on restrictive networks.
## Self-hosting
Piik Server is a standalone binary with the web interface built in.
Download the Linux x64 Server package, extract it, and run:
```sh
./piik-server
```
## Join a friend
Open `http://localhost:8787` to try it locally. For a public site, configure your
domain, HTTPS reverse proxy and STUN address. Room data is stored in SQLite.
1. Open the invitation your friend sent.
2. Press Play if your browser asks. Use the video's sound and fullscreen controls.
[**Deploy your own site →**](./docs/operations/self-hosting.md) · [Configuration](./docs/reference/configuration.md) · [Run from source](./docs/README.md#run-from-source)
Got a room code instead? Open the same Piik site and choose **Join a room**.
Site access and the room's password settings still apply.
## Contributing
## Share from your browser
1. Open the Piik site your group uses. Enter its site password if asked.
2. Choose **Start sharing**, pick a screen, window or tab, and check the sound switch.
3. Send the room's invitation link to your friends. Keep the sharing tab open.
Capture needs HTTPS or `localhost`; available sources and sound depend on the
browser and platform. English, Chinese and an illustrated visual mode share the
same controls, with light and dark themes.
## Start your own room with Piik App
With the Piik App package for your platform:
1. Extract the whole package, keeping `runtime` beside the executable.
2. Open `piik-app.exe` on Windows, `Piik App.app` on macOS, or
`./piik-app` on Linux.
3. Choose a mode, pick what to share, and send the invitation. Keep the app running.
| Mode | Use it for |
| --- | --- |
| **Local room** | Friends on the same local network. An optional site password controls access. |
| **Public invite** | A temporary Internet invitation. The link lasts for this App run; media remains P2P. |
| **Connect to Site** | An existing Piik site, with App capture and media capabilities available in the same browser UI. |
Piik App opens your system browser. Its package includes the Go application and
capture/link helpers; running it needs no Node.js, npm or Go installation.
Linux native capture uses the system Portal, PipeWire and GStreamer stack.
See the [App guide](./cmd/piik-app/README.md) for platform details.
Temporary public links use Cloudflare Quick Tunnel, which has no uptime guarantee.
Media needs a working UDP path; restrictive networks or browser/OS suspension
can interrupt sharing. Windows App and Browser are the current acceptance
focus; macOS/Linux physical capture checks remain separate. See
[current status](./docs/status.md) for candidate and release readiness.
## Host a site or contribute
The Server package is one Go executable with the Web UI embedded. It owns room
signaling, STUN and optional embedded SFU fallback. Follow the
[self-hosting guide](./docs/operations/self-hosting.md) to set up your own site.
For source builds, tests and architecture, start with the
[documentation map](./docs/README.md). [Contributing](./CONTRIBUTING.md) covers the workflow.
## Something went wrong?
Include your version, OS/browser, expected result and steps to reproduce.
For a local report, start Piik App with `--debug` and press `D`, or add `?debug=1`
before any `#` in the Browser page URL and use its header download button. Review reports before
sharing; see [diagnostics and export](./docs/reference/configuration.md#diagnostics).
Connection trouble? Try the [Chromium WebRTC FAQ](./cmd/piik-app/README.md#chromium-webrtc-connections).
Bug reports, documentation improvements and pull requests are welcome.
Start with [CONTRIBUTING.md](./CONTRIBUTING.md), or see the
[repository layout](./docs/reference/engineering.md#repository-layout) to find your way around.
## License
Piik-owned code is [MIT licensed](./LICENSE). Third-party components retain
their own licenses and notices: [licensing guide](./licenses/README.md).
Piik-owned code is [MIT licensed](./LICENSE). Dependencies retain their own
[licenses and notices](./licenses/README.md).
+91 -66
View File
@@ -1,79 +1,104 @@
<img src="./public/favicon.svg" width="64" height="64" alt="Piik 小电视标志">
<p align="center"><img src="./public/favicon.svg" width="64" height="64" alt="Piik 吉祥物"></p>
<h1 align="center">Piik</h1>
<p align="center"><strong>来,看点<br>好康的。</strong><br>私密屏幕共享,邀请最多 20 位朋友观看。</p>
<p align="center">
<a href="https://piik.tv/?lang=zh-CN">官网</a> ·
<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>
</p>
<p align="center">
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-36564f?style=flat-square" alt="MIT 许可"></a>
<img src="https://img.shields.io/badge/viewers-up_to_20-36564f?style=flat-square" alt="最多 20 位观众">
<img src="https://img.shields.io/badge/media-P2P_first-36564f?style=flat-square" alt="优先使用 P2P 连接">
</p>
<p align="center"><a href="./README.md">English</a> · 简体中文</p>
# Piik
Piik 是一个免费开源的屏幕共享工具,适合游戏围观、一起看电影、展示绘画或分享照片。
选择画面,把邀请链接发给朋友,对方用浏览器即可观看。
[English](./README.md) | 简体中文
<details open>
<summary>客厅小剧场 · 展开 / 收起动画</summary>
分享你的屏幕,叫上朋友一起看。
<p align="center"><img src="./site/assets/living-room.svg" width="720" height="472" alt="戴着小金冠的房主分享 RPG 游戏、绘画、照片和动画电影,朋友们坐在沙发上观看。"></p>
Piik 是一个私密屏幕分享工具:一位房主,最多 20 位受邀好友。
朋友用浏览器就能观看,无须安装。
</details>
```mermaid
flowchart LR
Browser[浏览器] --> Host[开房分享]
App[Piik App] --> Host
Host -->|邀请链接| Friends[好友用浏览器观看]
[功能](#功能) · [开始使用](#开始使用) · [自行部署](#自行部署) · [参与贡献](#参与贡献)
## 功能
- **观看无须安装。** 朋友通过邀请链接,用电脑或手机浏览器加入。
- **用客户端或已有 Piik 站点分享。** 支持屏幕、窗口和浏览器标签页;可选来源与音频能力取决于平台。
- **优先直接连接。** 画面优先在设备之间传输(P2P),自建站点可启用媒体转发(SFU)自动兜底。
- **房间由你管理。** 支持邀请链接、房间号和可选的房间密码,一位房主、最多 20 位观众。
- **灵活的观看方式。** 支持深浅主题、播放控制、画中画和连接拓扑查看。
- **一个程序就能建站。** 网页、房间管理和可选的媒体转发打包在同一服务端程序中。
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./docs/assets/room-zh-dark.png">
<img src="./docs/assets/room-zh-light.png" width="860" alt="Piik 房间:共享的游戏画面、播放控件和坐在沙发上的朋友,房主戴着小金冠。">
</picture><br>
<sub>界面预览 · 示例房间与生成的游戏画面</sub>
</p>
## 开始使用
**观看分享:** 打开朋友发来的邀请链接。画面没有自动播放时,点击**播放**即可。
**分享画面:**
| 从这里开始 | 需要做什么 |
| --- | --- |
| [体验在线 Demo](https://demo.piik.tv) | 在电脑浏览器打开,开始分享。 |
| [下载 Piik App](https://piik.tv/?lang=zh-CN#download) | 解压并启动客户端,选择**公网邀请**,点击**进入 Piik**,创建临时房间。 |
| 使用已有 Piik 站点 | 打开朋友或管理员提供的地址。[自行部署](./docs/operations/self-hosting.zh-CN.md)属于进阶操作。 |
<p align="center"><img src="./docs/assets/quickstart.svg" width="640" alt="选择画面,发送邀请,一起观看。"></p>
1. 点击**开始分享**,选择要分享的画面和声音。
2. 点击**复制邀请链接**,发给朋友。分享期间保持标签页打开。
Piik 默认使用图示按钮;点击顶部的**中**可显示文字。
客户端提供本地房间、临时公网邀请和连接已有站点三种模式,分享期间需保持 App 运行。
下载以 **`piik-app`** 开头的压缩包,再按文件名中的平台标识选择:
| 文件名包含 | 适用平台 |
| --- | --- |
| `windows-amd64` | Windows x64 |
| `darwin-arm64` | Apple 芯片;原生采集需 macOS 13 及以上 |
| `linux-amd64` | Linux x64 |
[**完整使用教程 →**](./docs/guide/getting-started.zh-CN.md) · [**打开在线演示 →**](https://demo.piik.tv)
浏览器采集需要 HTTPS 或 `localhost`。目前主要测试 Windows 客户端和桌面浏览器分享。
macOS 和 Linux 客户端尚未经过实机测试,欢迎有设备的朋友试用并[反馈问题](https://github.com/TNTcraftHIM/Piik/issues)。
客户端公网邀请和演示站使用纯 P2P 连接,
在受限网络下可能无法连通。
## 自行部署
Piik Server 是一个内置网页界面的独立程序。
下载 Linux x64 服务端程序包,解压后运行:
```sh
./piik-server
```
## 去朋友的房间
打开 `http://localhost:8787` 即可在本机试用。对外提供服务时,
再配置域名、HTTPS 反向代理和 STUN 地址。房间数据保存在 SQLite 中。
1. 打开朋友发来的邀请链接。
2. 如果浏览器提示,点击播放。声音和全屏由视频控件控制。
[**部署自己的站点 →**](./docs/operations/self-hosting.zh-CN.md) · [配置参考](./docs/reference/configuration.md) · [从源码运行](./docs/README.md#run-from-source)
只有房间号?打开同一个 Piik 站点,选择 **加入房间**。
站点访问权限和房间密码设置仍然适用。
## 参与贡献
## 用浏览器分享
1. 打开你们使用的 Piik 站点,按提示输入站点密码。
2. 点击 **开始分享**,选择屏幕、窗口或标签页,并检查声音开关。
3. 把房间邀请链接发给朋友,保持分享标签页打开。
浏览器采集需要 HTTPS 或 `localhost`;可选来源和声音支持取决于浏览器与平台。
界面提供中文、英文和图示模式,以及明暗主题。
## 用 App 自己开房
拿到对应平台的 App 程序包后:
1. 完整解压,保留可执行文件旁的 `runtime` 目录。
2. Windows 打开 `piik-app.exe`,macOS 打开 `Piik App.app`,
Linux 运行 `./piik-app`。
3. 选择模式和分享来源,发出邀请。分享期间保持 App 运行。
| 模式 | 适合什么情况 |
| --- | --- |
| **本地房间** | 与同一局域网内的好友分享,可设置站点访问密码。 |
| **公网邀请** | 生成本次 App 运行期间有效的临时邀请链接;媒体仍走 P2P。 |
| **连接站点** | 使用已有 Piik 站点,并在同一浏览器界面使用 App 的采集与媒体能力。 |
App 会打开系统浏览器。程序包包含 Go 应用和采集、临时链接辅助程序,
运行时无须安装 Node.js、npm 或 Go。Linux 原生采集使用系统的 Portal、PipeWire
和 GStreamer,平台细节见 [App 指南](./cmd/piik-app/README.md)。
临时公网链接使用 Cloudflare Quick Tunnel,不保证持续可用。
媒体需要可用的 UDP 路径;网络限制、浏览器或系统挂起都可能中断分享。
当前以 Windows App 和浏览器为验收重点,macOS/Linux 原生采集的实机验证另行进行。
候选版本与发布准备情况见 [当前状态](./docs/status.md)。
## 自建站点或参与开发
Server 是一个内嵌 Web 界面的 Go 可执行文件,负责房间信令、STUN 和可选的内置
SFU 后备路径。搭建自己的站点,请看 [自托管指南](./docs/operations/self-hosting.md)。
源码运行、测试和架构从 [文档地图](./docs/README.md) 开始;
[贡献指南](./CONTRIBUTING.md) 说明协作流程。
## 遇到问题?
反馈时附上版本、系统与浏览器、预期结果和复现步骤。
App 可用 `--debug` 启动后按 `D` 导出本地报告;浏览器网址加上 `?debug=1`
后(放在 `#` 之前),可点击顶部下载按钮。分享前请检查报告内容,详见
[诊断与导出](./docs/reference/configuration.md#diagnostics)。
连接不上时,也可以查看 [Chromium WebRTC 常见问题](./cmd/piik-app/README.md#chromium-webrtc-connections)。
欢迎提交问题、改进文档或参与开发。
修改前请阅读[贡献指南](./CONTRIBUTING.md),也可以从
[项目目录说明](./docs/reference/engineering.md#repository-layout)了解代码结构。
## 许可
Piik 自有代码采用 [MIT 许可](./LICENSE),提供 [中文参考译文](./LICENSE.zh-CN.md)。
第三方组件保留各自的许可与声明,详见 [许可说明](./licenses/README.md)。
Piik 自有代码采用 [MIT 许可](./LICENSE),依赖保留各自的[许可与声明](./licenses/README.md)。
+28 -24
View File
@@ -1,21 +1,20 @@
# Piik App
Piik App is the self-contained and native-capability runtime for the same
Browser application used by Hosted Piik. It does not implement another UI,
room store, signaling protocol, or route controller.
Piik App provides local rooms and native screen capture through the Piik web
interface in your system Browser.
## Run A Package
Extract the matching platform bundle in full and keep `runtime` beside the
App executable. Run `piik-app.exe` on Windows, `./piik-app` on
Linux, or `Piik App.app` on macOS. The launcher opens in the system Browser;
the App does not embed a browser UI. Packaged execution needs no Node.js, npm,
or Go installation. Linux native capture uses the system dependencies described
Linux, or `Piik App.app` on macOS. The launcher opens in the system Browser.
Linux native capture uses the system dependencies described
in the [Linux capture guide](../../native/capture/linux/README.md).
Windows App and Browser are this phase's acceptance targets. Other platform
builds do not establish physical capture/audio acceptance; see
[current status](../../docs/status.md). Package construction and public Release
Windows App and browser sharing are the primary tested paths. The macOS and
Linux apps have not yet been tested on physical devices; test results and
[feedback](https://github.com/TNTcraftHIM/Piik/issues) are welcome. macOS native
capture requires Apple silicon and macOS 13 or newer. Package construction and public Release
publication are separate steps in [deployment](../../docs/deployment.md).
## Modes
@@ -25,14 +24,12 @@ publication are separate steps in [deployment](../../docs/deployment.md).
Site, then enters the normal Host page.
- The launcher remembers the Site address but keeps the per-run room source
separate. The App RPC starts before this choice and accepts the saved Site
alongside the Local Host origin, so Local or Public invite does not disable
Site enhancement. An App-opened Site remembers the opt-in in that Browser
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.
- `--site`, `--local`, and `--link` remain deterministic automation inputs for
CI and development. They are not required for normal use.
- The default launcher checks the official GitHub Releases metadata after it
opens and shows a notice when a newer full-SHA release exists. The check is
best-effort and never installs or replaces the App.
- 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 links to an available release
without installing or replacing 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
@@ -72,9 +69,8 @@ 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 existing route
controller still owns persistent quality evidence and any route replacement;
there is no room-wide score or periodic rebalance. See
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.
@@ -89,7 +85,7 @@ The terminal shows the current mode, entry links and startup state; its language
follows the launcher and App-enabled pages.
Press `o` to reopen the Browser, or `q` / Ctrl+C to end Local
rooms and stop the local server and temporary public link. Plain-text output
uses Ctrl+C. A Site-loaded Browser tab does not own the App process.
uses Ctrl+C. The App keeps running after a Site tab closes.
For one-link Internet sharing, open the App launcher, choose **Public invite**,
create a room in the opened Browser, and send its normal invitation link. The
@@ -106,8 +102,7 @@ use a configured Site when persistent control availability or SFU fallback matte
Chromium-based Browsers can restrict WebRTC UDP through Browser settings,
extensions or managed policies. Disabling non-proxied UDP can prevent even the
local Browser-to-App media connection; successful capture or page loading
does not prove that this separate connection is available. This is not specific
to one Browser brand or to VPN use.
does not prove that this separate connection is available.
Check the Browser's WebRTC/IP-handling policy and any extension's WebRTC or
IP-leak protection setting. Restore a policy that permits WebRTC UDP, reload
@@ -170,12 +165,21 @@ 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; room authority
and remote signaling remain in the Browser. Viewer receive/relay remains
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.
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.
## Packaging
From a clean revision, build the application release and run the platform's
+33 -17
View File
@@ -1,10 +1,10 @@
// Command piik-server is the Hosted Piik entry point: it loads the
// environment, serves the embedded Browser UI together with the signaling and
// room API, and stops on SIGINT or SIGTERM. It replaces src/server/index.ts.
// room API, and stops on SIGINT or SIGTERM.
//
// Two maintenance modes exit without serving: --check-config validates the
// environment (the release wrapper runs it as the service user before cutover)
// and --check-release compares the deployed revision with the latest published
// and --check-release compares this binary with the latest published
// release (see release.go).
package main
@@ -37,17 +37,16 @@ const shutdownTimeout = 15 * time.Second
// release directory. Hosted keeps its real values in the service secret store.
const environmentFile = ".env"
// BuildRevision is the full Git revision the release packager links in with
// -ldflags. It matches the REVISION file shipped beside the binary and is
// reported at startup so one journal line identifies the running release.
var BuildRevision = "development"
// The packager injects the product version and full source revision together.
var (
BuildVersion = "development"
BuildRevision = "development"
)
func main() {
checkConfig := flag.Bool("check-config", false, "validate the environment and exit")
checkRelease := flag.Bool("check-release", false,
"report whether a newer published release exists and exit")
currentFile := flag.String("current-file", "",
"path to the deployed REVISION file read by --check-release")
apiURL := flag.String("api-url", defaultReleaseAPIURL,
"release metadata endpoint used by --check-release")
debug := flag.Bool("debug", false, "save opt-in server diagnostics to rotated files")
@@ -58,7 +57,7 @@ func main() {
}
switch {
case *checkRelease:
os.Exit(checkDeployedRelease(context.Background(), *currentFile, *apiURL, os.Stdout))
os.Exit(checkDeployedRelease(context.Background(), *apiURL, os.Stdout))
case *checkConfig:
if _, err := config.Load(environment()); err != nil {
fail(err)
@@ -98,14 +97,16 @@ func serve(debug bool) (returnedErr error) {
defer func() {
stopExport()
_ = dependencyLog.Close()
logger.Info("piik-server", "event", "stopped", "failed", returnedErr != nil, diagnostics.Error(returnedErr))
returnedErr = errors.Join(returnedErr, exportServerDiagnostics(recorder), recorder.Close())
slog.SetDefault(previous)
log.SetOutput(previousWriter)
log.SetFlags(previousFlags)
}()
logger.Info("piik-server", "event", "start", "revision", BuildRevision)
logger.Info("piik-server", "event", "start", "version", BuildVersion, "revision", BuildRevision)
}
defer func() {
logger.Info("piik-server", "event", "stopped", "failed", returnedErr != nil, diagnostics.Error(returnedErr))
}()
configuration, err := config.Load(environment())
if err != nil {
return err
@@ -133,11 +134,26 @@ func serve(debug bool) (returnedErr error) {
if err != nil {
return err
}
fmt.Printf("Piik %s is listening on %s:%d; public URL %s\n",
BuildRevision, configuration.ListenHost, port, config.Origin(configuration.PublicBaseURL))
logger.Info("Piik server is listening", "event", "ready", "version", BuildVersion, "revision", BuildRevision,
"host", configuration.ListenHost, "port", port, "publicUrl", config.Origin(configuration.PublicBaseURL),
"sqlite", configuration.RoomDatabasePath != "", "siteAccessProtected", configuration.SiteAccessPassword != "",
"sfu", configuration.SFU != nil, "natPrediction", configuration.NATPredictionEnabled)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// One bounded background lookup never delays serving. Join it before closing
// diagnostic outputs so shutdown cannot leave a late writer behind.
releaseChecked := make(chan struct{})
go func() {
defer close(releaseChecked)
result := checkRelease(ctx, BuildVersion, BuildRevision, defaultReleaseAPIURL, os.Getenv("GITHUB_TOKEN"), mirrorReleaseAPIURL)
if ctx.Err() == nil {
logReleaseNotice(logger, result)
}
}()
defer func() {
stop()
<-releaseChecked
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
@@ -185,9 +201,9 @@ func environment() map[string]string {
return values
}
// loadEnvironmentFile ports the src/server/index.ts loadEnvFile() call: an
// absent file is the ordinary case and every other read failure stops startup.
// Like Node, a name the process already carries is never overwritten, so a
// loadEnvironmentFile reads optional startup overrides. An absent file is the
// ordinary case and every other read failure stops startup. A name the process
// already carries is never overwritten, so a
// systemd EnvironmentFile still wins over a stale file in the release
// directory. The accepted syntax is DECISIONS D8: KEY=value lines, whole-line
// '#' comments, an optional 'export ' prefix, and a single- or double-quoted
+188 -144
View File
@@ -6,86 +6,66 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
"golang.org/x/mod/semver"
)
// defaultReleaseAPIURL is DEFAULT_RELEASE_API_URL: the same published release
// contract the Browser update notice uses (src/client/lib/release-update.ts).
// The Browser notice and operator checker read the same published release.
const defaultReleaseAPIURL = "https://api.github.com/repos/TNTcraftHIM/Piik/releases/latest"
const mirrorReleaseAPIURL = "https://gitee.com/api/v5/repos/TNTcraftHIM/Piik/releases"
// Exit codes are UPDATE_EXIT_CODES. deploy/check-release.sh forwards them, so a
// checker failure stays distinguishable from "an update exists".
// deploy/check-release.sh forwards these codes without installing anything.
const (
exitUpToDate = 0
exitUpdateAvailable = 10
exitUpdateUnavailable = 20
// exitUsage is the exit code parseArguments set when --current-file was
// missing. Without it a forgotten argument reads as an unavailable check.
exitUsage = 2
releaseTimeout = 5 * time.Second
releaseURLPrefix = "https://github.com/TNTcraftHIM/Piik/releases/tag/"
releaseAPIHost = "api.github.com"
mirrorReleasePrefix = "https://gitee.com/TNTcraftHIM/Piik/releases/tag/"
)
// releaseUsage is the usage string parseArguments threw, with this binary's
// name in place of the script's.
const releaseUsage = "Usage: piik-server --check-release --current-file <REVISION> [--api-url <URL>]"
const (
// releaseTimeout bounds the whole check; an operator runs it interactively.
releaseTimeout = 5 * time.Second
// releasePathPrefix is RELEASE_PATH_PREFIX: the only accepted release page.
releasePathPrefix = "/TNTcraftHIM/Piik/releases/tag/"
releaseHost = "github.com"
releaseAPIHost = "api.github.com"
)
// fullRevisionPattern is FULL_REVISION applied to an already lowercased value,
// which is why the JavaScript case-insensitive flag has no counterpart here.
var fullRevisionPattern = regexp.MustCompile(`^[0-9a-f]{40}$`)
var mirrorSourcePattern = regexp.MustCompile(`(?m)^<!-- piik-source: ([0-9a-f]{40}) -->\r?$`)
// Release status values are the "status" field of the printed JSON line, which
// deploy/check-release.sh reads; each maps to one exit code above.
const (
statusUpToDate = "up-to-date"
statusUpdateAvailable = "update-available"
statusDifferentBuild = "different-build"
statusOfficialRelease = "official-release"
statusUnavailable = "unavailable"
)
// releaseResult is the single JSON line the checker prints. Field order is the
// JSON.stringify order of the ported object, and the pointers are the nulls: a
// value the check could not establish is present and null, never omitted.
// Unknown identity fields are explicit nulls in the single printed JSON line.
type releaseResult struct {
Status string `json:"status"`
CurrentVersion *string `json:"currentVersion"`
LatestVersion *string `json:"latestVersion"`
CurrentRevision *string `json:"currentRevision"`
LatestRevision *string `json:"latestRevision"`
ReleaseURL *string `json:"releaseUrl"`
}
// checkDeployedRelease is the --check-release entry point. It writes one JSON
// line and returns the process exit code. An unreadable revision file is an
// unavailable check rather than an update, which is what the ported script's
// silently swallowed read failure meant; an absent --current-file is a usage
// error, which is what parseArguments made it.
func checkDeployedRelease(ctx context.Context, currentFile, apiURL string, out io.Writer) int {
if currentFile == "" {
fmt.Fprintln(os.Stderr, releaseUsage)
return exitUsage
func checkDeployedRelease(ctx context.Context, apiURL string, out io.Writer) int {
mirrorURL := ""
if apiURL == defaultReleaseAPIURL {
mirrorURL = mirrorReleaseAPIURL
}
current := ""
if content, err := os.ReadFile(currentFile); err == nil {
current = string(content)
}
result := checkRelease(ctx, current, apiURL, os.Getenv("GITHUB_TOKEN"))
result := checkRelease(ctx, BuildVersion, BuildRevision, apiURL, os.Getenv("GITHUB_TOKEN"), mirrorURL)
encoder := json.NewEncoder(out)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(result); err != nil {
return exitUpdateUnavailable
}
switch result.Status {
case statusUpdateAvailable:
case statusUpdateAvailable, statusDifferentBuild, statusOfficialRelease:
return exitUpdateAvailable
case statusUpToDate:
return exitUpToDate
@@ -94,149 +74,213 @@ func checkDeployedRelease(ctx context.Context, currentFile, apiURL string, out i
}
}
// checkRelease ports checkRelease. Every failure - an unusable current
// revision, an unusable endpoint, a redirect, a timeout, a non-2xx reply or
// metadata that fails validation - collapses to the same unavailable result:
// the checker never guesses that an update exists.
func checkRelease(ctx context.Context, currentRevision, apiURL, token string) releaseResult {
current := normalizeRevision(currentRevision)
if current == "" {
return unavailableResult(currentRevision)
func checkRelease(ctx context.Context, currentVersion, currentRevision, apiURL, token, mirrorURL string) releaseResult {
result := unavailableResult(currentVersion, currentRevision)
comparableVersion := result.CurrentVersion != nil && semver.IsValid(*result.CurrentVersion)
if !comparableVersion && result.CurrentRevision == nil {
return result
}
version, revision, releaseURL, ok := fetchLatestRelease(ctx, apiURL, token, false)
if !ok && mirrorURL != "" {
version, revision, releaseURL, ok = fetchLatestRelease(ctx, mirrorURL, "", true)
}
if !ok {
return result
}
result.LatestVersion = &version
result.LatestRevision = revisionOrNull(revision)
result.ReleaseURL = &releaseURL
if comparableVersion {
result.Status = statusUpToDate
order := semver.Compare(version, *result.CurrentVersion)
if order > 0 {
result.Status = statusUpdateAvailable
} else if order == 0 && result.CurrentRevision != nil && result.LatestRevision != nil &&
*result.CurrentRevision != *result.LatestRevision {
result.Status = statusDifferentBuild
}
} else if result.CurrentRevision != nil && result.LatestRevision != nil {
result.Status = statusOfficialRelease
if *result.CurrentRevision == *result.LatestRevision {
result.Status = statusUpToDate
}
}
return result
}
// Routine startup only announces actionable release choices. An unavailable
// provider is not a server failure, and never changes the running installation.
func logReleaseNotice(logger *slog.Logger, result releaseResult) {
var message string
switch result.Status {
case statusUpdateAvailable:
message = "A newer Piik release is available"
case statusDifferentBuild:
message = "This Piik build differs from the official release"
case statusOfficialRelease:
message = "An official Piik release is available"
default:
return
}
current := "development"
if result.CurrentVersion != nil {
current = *result.CurrentVersion
}
logger.Info(message, "event", "release-check", "status", result.Status,
"currentVersion", current, "latestVersion", *result.LatestVersion, "releaseUrl", *result.ReleaseURL)
}
func fetchLatestRelease(ctx context.Context, apiURL, token string, mirror bool) (version, revision, releaseURL string, ok bool) {
endpoint, err := url.Parse(apiURL)
if err != nil || (endpoint.Scheme != "https" && endpoint.Scheme != "http") {
return unavailableResult(current)
return "", "", "", false
}
ctx, cancel := context.WithTimeout(ctx, releaseTimeout)
defer cancel()
for page := 1; page <= 10; page++ {
if mirror {
query := endpoint.Query()
query.Set("per_page", "100")
query.Set("page", fmt.Sprint(page))
query.Set("direction", "desc")
endpoint.RawQuery = query.Encode()
}
body, err := readReleaseResponse(ctx, endpoint, token, mirror)
if err != nil {
return "", "", "", false
}
if !mirror {
return parseReleaseMetadata(body)
}
var releases []json.RawMessage
if err := json.Unmarshal(body, &releases); err != nil || releases == nil || len(releases) > 100 {
return "", "", "", false
}
for _, payload := range releases {
candidateVersion, candidateRevision, candidateURL, valid := parseMirrorReleaseMetadata(payload)
if valid && (!ok || semver.Compare(candidateVersion, version) > 0) {
version, revision, releaseURL, ok = candidateVersion, candidateRevision, candidateURL, true
}
}
if len(releases) < 100 {
return
}
}
// Incomplete pagination cannot establish the highest stable version.
return "", "", "", false
}
func readReleaseResponse(ctx context.Context, endpoint *url.URL, token string, mirror bool) ([]byte, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return unavailableResult(current)
return nil, err
}
request.Header.Set("Accept", "application/vnd.github+json")
request.Header.Set("X-GitHub-Api-Version", "2022-11-28")
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Piik-release-check")
if authorizesGitHub(endpoint, token) {
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token))
if !mirror {
request.Header.Set("Accept", "application/vnd.github+json")
request.Header.Set("X-GitHub-Api-Version", "2022-11-28")
if authorizesGitHub(endpoint, token) {
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token))
}
}
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
// The ported redirect: "error" policy. A moved release endpoint is not
// followed, so the Authorization header cannot travel to another origin.
return errors.New("release endpoint redirected")
}}
response, err := client.Do(request)
if err != nil {
return unavailableResult(current)
return nil, err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode > 299 {
return unavailableResult(current)
return nil, errors.New("release endpoint unavailable")
}
body, err := io.ReadAll(response.Body)
if err != nil {
return unavailableResult(current)
}
latest, releaseURL, ok := parseReleaseMetadata(body)
if !ok {
return unavailableResult(current)
}
status := statusUpdateAvailable
if latest == current {
status = statusUpToDate
}
return releaseResult{
Status: status,
CurrentRevision: &current,
LatestRevision: &latest,
ReleaseURL: &releaseURL,
const limit = 4 << 20
body, err := io.ReadAll(io.LimitReader(response.Body, limit+1))
if err != nil || len(body) > limit {
return nil, errors.New("release metadata unreadable or too large")
}
return body, nil
}
// authorizesGitHub reports whether the operator token may be sent to endpoint.
// A fixture, mirror or plaintext endpoint must never receive it, which is why
// the scheme and the host are both part of the rule.
// Only the HTTPS GitHub API receives an operator token, and redirects are refused.
func authorizesGitHub(endpoint *url.URL, token string) bool {
return endpoint.Scheme == "https" &&
strings.ToLower(endpoint.Hostname()) == releaseAPIHost &&
strings.TrimSpace(token) != ""
}
// unavailableResult is unavailableResult: the current revision is reported when
// it is well formed and null otherwise.
func unavailableResult(currentRevision string) releaseResult {
return releaseResult{Status: statusUnavailable, CurrentRevision: revisionOrNull(currentRevision)}
func unavailableResult(currentVersion, currentRevision string) releaseResult {
currentVersion = strings.TrimSpace(currentVersion)
if canonical := semver.Canonical(currentVersion); canonical != "" {
currentVersion = canonical
}
result := releaseResult{Status: statusUnavailable, CurrentRevision: revisionOrNull(currentRevision)}
if currentVersion != "" {
result.CurrentVersion = &currentVersion
}
return result
}
func revisionOrNull(value string) *string {
revision := normalizeRevision(value)
if revision == "" {
revision := strings.ToLower(strings.TrimSpace(value))
if !fullRevisionPattern.MatchString(revision) {
return nil
}
return &revision
}
// normalizeRevision ports normalizeRevision, returning "" where it returned
// null: only a full 40-hex commit identifies a release.
func normalizeRevision(value string) string {
revision := strings.ToLower(strings.TrimSpace(value))
if !fullRevisionPattern.MatchString(revision) {
return ""
}
return revision
}
// releaseMetadata is the subset of the GitHub release payload that is read.
// A tag_name or html_url of another type fails to decode, which is the
// rejection the JavaScript reached through typeof. Deviation: the JavaScript
// tested `draft === true`, so it read a draft of another type as "not a
// draft"; here such a payload fails to decode and the check reports
// unavailable. Only stricter, and a release payload the checker cannot
// understand must never be reported as an update.
type releaseMetadata struct {
TagName *string `json:"tag_name"`
HTMLURL *string `json:"html_url"`
Draft *bool `json:"draft"`
Prerelease *bool `json:"prerelease"`
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
TargetCommitish string `json:"target_commitish"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
}
// parseReleaseMetadata ports parseReleaseMetadata. It accepts one shape only: a
// published, non-draft release whose tag is a full commit revision and whose
// page is exactly https://github.com/TNTcraftHIM/Piik/releases/tag/<that
// revision> with no credentials, port, query or fragment. The returned URL is
// rebuilt from the validated parts so nothing unverified reaches the operator.
func parseReleaseMetadata(payload []byte) (revision string, releaseURL string, ok bool) {
// A branch-valued target_commitish is unknown provenance, never a source SHA.
func parseReleaseMetadata(payload []byte) (version, revision, releaseURL string, ok bool) {
var metadata releaseMetadata
if err := json.Unmarshal(payload, &metadata); err != nil {
return "", "", false
return "", "", "", false
}
if (metadata.Draft != nil && *metadata.Draft) ||
(metadata.Prerelease != nil && *metadata.Prerelease) {
return "", "", false
}
if metadata.TagName == nil || metadata.HTMLURL == nil {
return "", "", false
}
revision = normalizeRevision(*metadata.TagName)
if revision == "" {
return "", "", false
}
parsed, err := url.Parse(*metadata.HTMLURL)
if err != nil || parsed.Scheme != "https" ||
strings.ToLower(parsed.Hostname()) != releaseHost || parsed.User != nil ||
parsed.Port() != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", "", false
}
// The escaped path is compared, so an encoded separator such as %2F cannot
// smuggle another path past the prefix the way the decoded form would.
path := parsed.EscapedPath()
if !strings.HasPrefix(path, releasePathPrefix) {
return "", "", false
}
tag, err := url.PathUnescape(strings.TrimPrefix(path, releasePathPrefix))
if err != nil || normalizeRevision(tag) != revision {
return "", "", false
}
return revision, "https://" + releaseHost + path, true
return normalizeReleaseMetadata(metadata)
}
func normalizeReleaseMetadata(metadata releaseMetadata) (version, revision, releaseURL string, ok bool) {
if metadata.Draft || metadata.Prerelease {
return "", "", "", false
}
version = metadata.TagName
if version == "" || semver.Canonical(version) != version || semver.Prerelease(version) != "" ||
metadata.HTMLURL != releaseURLPrefix+version {
return "", "", "", false
}
if source := revisionOrNull(metadata.TargetCommitish); source != nil {
revision = *source
}
return version, revision, metadata.HTMLURL, true
}
func parseMirrorReleaseMetadata(payload []byte) (version, revision, releaseURL string, ok bool) {
var metadata struct {
TagName string `json:"tag_name"`
Body string `json:"body"`
Prerelease *bool `json:"prerelease"`
}
if err := json.Unmarshal(payload, &metadata); err != nil || metadata.Prerelease == nil || *metadata.Prerelease {
return "", "", "", false
}
sources := mirrorSourcePattern.FindAllStringSubmatch(metadata.Body, -1)
if len(sources) != 1 {
return "", "", "", false
}
// The mirror's target_commitish identifies its README, not the original source.
version, revision, _, ok = normalizeReleaseMetadata(releaseMetadata{
TagName: metadata.TagName, TargetCommitish: sources[0][1], HTMLURL: releaseURLPrefix + metadata.TagName,
})
if !ok {
return "", "", "", false
}
return version, revision, mirrorReleasePrefix + version, true
}
+225 -220
View File
@@ -4,202 +4,174 @@ import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
)
const (
deployedVersion = "v1.9.0"
latestVersion = "v1.10.0"
deployedRevision = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
latestRevision = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
latestReleaseURL = "https://github.com/TNTcraftHIM/Piik/releases/tag/" + latestRevision
latestRelease = `{"tag_name":"` + latestRevision + `","html_url":"` + latestReleaseURL + `"}`
latestReleaseURL = "https://github.com/TNTcraftHIM/Piik/releases/tag/" + latestVersion
latestRelease = `{"tag_name":"` + latestVersion + `","html_url":"` + latestReleaseURL + `","target_commitish":"` + latestRevision + `"}`
)
func TestReleaseNoticeUsesOperatorLogsOnlyForAvailableChoices(t *testing.T) {
for _, status := range []string{statusUpdateAvailable, statusDifferentBuild, statusOfficialRelease, statusUpToDate, statusUnavailable} {
t.Run(status, func(t *testing.T) {
var output bytes.Buffer
version, releaseURL := latestVersion, latestReleaseURL
logReleaseNotice(slog.New(slog.NewJSONHandler(&output, nil)), releaseResult{
Status: status, LatestVersion: &version, ReleaseURL: &releaseURL,
})
if status == statusUpToDate || status == statusUnavailable {
if output.Len() != 0 {
t.Fatalf("non-actionable check produced a notice: %s", output.String())
}
return
}
var entry map[string]any
if err := json.Unmarshal(output.Bytes(), &entry); err != nil {
t.Fatal(err)
}
if entry["status"] != status || entry["currentVersion"] != "development" ||
entry["latestVersion"] != latestVersion || entry["releaseUrl"] != latestReleaseURL || entry["level"] != "INFO" {
t.Fatalf("release notice lost its version or download action: %v", entry)
}
if status == statusDifferentBuild && strings.Contains(entry["msg"].(string), "newer") {
t.Fatal("SHA inequality was presented as release ordering")
}
})
}
}
func TestParseReleaseMetadataAcceptsOnlyTheStrictReleaseIdentity(t *testing.T) {
tagged := func(page string) string {
return `{"tag_name":"` + latestRevision + `","html_url":"` + page + `"}`
tagged := func(version, page string) string {
return `{"tag_name":"` + version + `","html_url":"` + page + `"}`
}
testCases := []struct {
name string
payload string
revision string
url string
name, payload, version, revision, url string
}{
{"published release", latestRelease, latestRevision, latestReleaseURL},
{
"uppercase tag normalizes",
`{"tag_name":"` + strings.ToUpper(latestRevision) + `","html_url":"` + latestReleaseURL + `"}`,
latestRevision, latestReleaseURL,
},
{"short tag", `{"tag_name":"short","html_url":"` + latestReleaseURL + `"}`, "", ""},
{"other host", tagged("https://evil.example"), "", ""},
{
"userinfo",
tagged("https://user@github.com/TNTcraftHIM/Piik/releases/tag/" + latestRevision),
"", "",
},
{
"port",
tagged("https://github.com:8443/TNTcraftHIM/Piik/releases/tag/" + latestRevision),
"", "",
},
{
"plaintext page",
tagged("http://github.com/TNTcraftHIM/Piik/releases/tag/" + latestRevision),
"", "",
},
{"query", tagged(latestReleaseURL + "?asset=1"), "", ""},
{"fragment", tagged(latestReleaseURL + "#assets"), "", ""},
{
"encoded path separator",
tagged("https://github.com/TNTcraftHIM%2FPiik/releases/tag/" + latestRevision),
"", "",
},
{
"page tag disagrees with the release tag",
tagged("https://github.com/TNTcraftHIM/Piik/releases/tag/" + deployedRevision),
"", "",
},
{
"draft",
`{"tag_name":"` + latestRevision + `","html_url":"` + latestReleaseURL + `","draft":true}`,
"", "",
},
{
"prerelease",
`{"tag_name":"` + latestRevision + `","html_url":"` + latestReleaseURL + `","prerelease":true}`,
"", "",
},
{"missing page", `{"tag_name":"` + latestRevision + `"}`, "", ""},
{"array payload", `[]`, "", ""},
{"not an object", `"released"`, "", ""},
{"not json", `nonsense`, "", ""},
{"published release", latestRelease, latestVersion, latestRevision, latestReleaseURL},
{"uppercase source normalizes", strings.Replace(latestRelease, latestRevision, strings.ToUpper(latestRevision), 1), latestVersion, latestRevision, latestReleaseURL},
{"branch source is unknown", strings.Replace(latestRelease, latestRevision, "main", 1), latestVersion, "", latestReleaseURL},
{"missing source is unknown", tagged(latestVersion, latestReleaseURL), latestVersion, "", latestReleaseURL},
{"short source is unknown", strings.Replace(latestRelease, latestRevision, "bbbbbbb", 1), latestVersion, "", latestReleaseURL},
{"old SHA tag", tagged(latestRevision, releaseURLPrefix+latestRevision), "", "", ""},
{"short version", tagged("v1", releaseURLPrefix+"v1"), "", "", ""},
{"missing v prefix", tagged("1.10.0", releaseURLPrefix+"1.10.0"), "", "", ""},
{"prerelease version", tagged("v1.10.0-rc.1", releaseURLPrefix+"v1.10.0-rc.1"), "", "", ""},
{"build suffix", tagged("v1.10.0+build", releaseURLPrefix+"v1.10.0+build"), "", "", ""},
{"other host", tagged(latestVersion, "https://evil.example"), "", "", ""},
{"userinfo", tagged(latestVersion, "https://user@github.com/TNTcraftHIM/Piik/releases/tag/"+latestVersion), "", "", ""},
{"port", tagged(latestVersion, "https://github.com:8443/TNTcraftHIM/Piik/releases/tag/"+latestVersion), "", "", ""},
{"plaintext page", tagged(latestVersion, "http://github.com/TNTcraftHIM/Piik/releases/tag/"+latestVersion), "", "", ""},
{"query", tagged(latestVersion, latestReleaseURL+"?asset=1"), "", "", ""},
{"fragment", tagged(latestVersion, latestReleaseURL+"#assets"), "", "", ""},
{"encoded path separator", tagged(latestVersion, "https://github.com/TNTcraftHIM%2FPiik/releases/tag/"+latestVersion), "", "", ""},
{"mismatched page tag", tagged(latestVersion, releaseURLPrefix+deployedVersion), "", "", ""},
{"draft", strings.TrimSuffix(latestRelease, "}") + `,"draft":true}`, "", "", ""},
{"prerelease", strings.TrimSuffix(latestRelease, "}") + `,"prerelease":true}`, "", "", ""},
{"missing page", `{"tag_name":"` + latestVersion + `"}`, "", "", ""},
{"array payload", `[]`, "", "", ""},
{"not an object", `"released"`, "", "", ""},
{"not json", `nonsense`, "", "", ""},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
revision, releaseURL, ok := parseReleaseMetadata([]byte(testCase.payload))
if ok != (testCase.revision != "") || revision != testCase.revision ||
releaseURL != testCase.url {
t.Fatalf("parseReleaseMetadata(%s) = %q, %q, %t; want %q, %q",
testCase.payload, revision, releaseURL, ok, testCase.revision, testCase.url)
version, revision, releaseURL, ok := parseReleaseMetadata([]byte(testCase.payload))
if ok != (testCase.version != "") || version != testCase.version || revision != testCase.revision || releaseURL != testCase.url {
t.Fatalf("parseReleaseMetadata = %q, %q, %q, %t; want %q, %q, %q",
version, revision, releaseURL, ok, testCase.version, testCase.revision, testCase.url)
}
})
}
}
func TestCheckReleaseReportsTheDeployedComparison(t *testing.T) {
branchRelease := strings.Replace(latestRelease, latestRevision, "main", 1)
testCases := []struct {
name string
current string
status int
body string
wantStatus string
wantCurrent string
wantLatest string
wantReleaseURL string
wantRequestSent bool
name, version, revision, body, wantStatus, wantLatest string
}{
{
name: "newer release", current: deployedRevision, status: http.StatusOK,
body: latestRelease, wantStatus: "update-available", wantCurrent: deployedRevision,
wantLatest: latestRevision, wantReleaseURL: latestReleaseURL, wantRequestSent: true,
},
{
name: "same release", current: latestRevision, status: http.StatusOK,
body: latestRelease, wantStatus: "up-to-date", wantCurrent: latestRevision,
wantLatest: latestRevision, wantReleaseURL: latestReleaseURL, wantRequestSent: true,
},
{
name: "endpoint unavailable", current: deployedRevision,
status: http.StatusServiceUnavailable, body: "offline",
wantStatus: "unavailable", wantCurrent: deployedRevision, wantRequestSent: true,
},
{
name: "unusable metadata", current: deployedRevision, status: http.StatusOK,
body: `{"tag_name":"v1","html_url":"https://evil.example"}`,
// The published page failed validation, so no update is claimed.
wantStatus: "unavailable", wantCurrent: deployedRevision, wantRequestSent: true,
},
{
name: "deployed revision is not a release", current: "development",
status: http.StatusOK, body: latestRelease,
// No request at all: the check cannot compare an unknown deployment.
wantStatus: "unavailable", wantCurrent: "", wantRequestSent: false,
},
{"newer numeric version", deployedVersion, deployedRevision, latestRelease, statusUpdateAvailable, latestRevision},
{"newer version of same source", deployedVersion, latestRevision, latestRelease, statusUpdateAvailable, latestRevision},
{"older version", "v2.0.0", deployedRevision, latestRelease, statusUpToDate, latestRevision},
{"same version and source", latestVersion, latestRevision, latestRelease, statusUpToDate, latestRevision},
{"same version with different source", latestVersion, deployedRevision, latestRelease, statusDifferentBuild, latestRevision},
{"development has an official choice", "development", deployedRevision, latestRelease, statusOfficialRelease, latestRevision},
{"development has same source", "development", latestRevision, latestRelease, statusUpToDate, latestRevision},
{"other unversioned build", "custom-build", deployedRevision, latestRelease, statusOfficialRelease, latestRevision},
{"local prerelease", "v1.10.0-rc.1", deployedRevision, latestRelease, statusUpdateAvailable, latestRevision},
{"newer version without known remote source", deployedVersion, deployedRevision, branchRelease, statusUpdateAvailable, ""},
{"same version without known remote source", latestVersion, deployedRevision, branchRelease, statusUpToDate, ""},
{"development without known remote source", "development", deployedRevision, branchRelease, statusUnavailable, ""},
{"newer version without known local source", deployedVersion, "", latestRelease, statusUpdateAvailable, latestRevision},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
requested := false
server := httptest.NewServer(http.HandlerFunc(
func(writer http.ResponseWriter, request *http.Request) {
requested = true
if got := request.Header.Get("Accept"); got != "application/vnd.github+json" {
t.Errorf("Accept = %q", got)
}
if got := request.Header.Get("X-GitHub-Api-Version"); got != "2022-11-28" {
t.Errorf("X-GitHub-Api-Version = %q", got)
}
if got := request.Header.Get("User-Agent"); got != "Piik-release-check" {
t.Errorf("User-Agent = %q", got)
}
writer.WriteHeader(testCase.status)
_, _ = writer.Write([]byte(testCase.body))
}))
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Header.Get("Accept") != "application/vnd.github+json" ||
request.Header.Get("X-GitHub-Api-Version") != "2022-11-28" || request.Header.Get("User-Agent") != "Piik-release-check" {
t.Error("release request headers changed")
}
_, _ = writer.Write([]byte(testCase.body))
}))
defer server.Close()
result := checkRelease(t.Context(), testCase.current, server.URL, "")
if requested != testCase.wantRequestSent {
t.Fatalf("endpoint requested = %t; want %t", requested, testCase.wantRequestSent)
}
assertResult(t, result, testCase.wantStatus, testCase.wantCurrent,
testCase.wantLatest, testCase.wantReleaseURL)
result := checkRelease(t.Context(), testCase.version, testCase.revision, server.URL, "", "")
assertResult(t, result, testCase.wantStatus, testCase.version, latestVersion, testCase.revision, testCase.wantLatest, latestReleaseURL)
})
}
}
func TestCheckReleaseFailsClosedWithoutAUsableEndpoint(t *testing.T) {
redirect := httptest.NewServer(http.HandlerFunc(
func(writer http.ResponseWriter, request *http.Request) {
http.Redirect(writer, request, "https://evil.example/releases", http.StatusFound)
}))
redirect := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
http.Redirect(writer, request, "https://evil.example/releases", http.StatusFound)
}))
defer redirect.Close()
reachable := httptest.NewServer(http.HandlerFunc(
func(writer http.ResponseWriter, _ *http.Request) {
_, _ = writer.Write([]byte(latestRelease))
}))
reachable := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
_, _ = writer.Write([]byte(latestRelease))
}))
defer reachable.Close()
unavailable := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.WriteHeader(http.StatusServiceUnavailable)
}))
defer unavailable.Close()
malformed := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
_, _ = writer.Write([]byte(`{"tag_name":"v1","html_url":"https://evil.example"}`))
}))
defer malformed.Close()
cancelled, cancel := context.WithCancel(t.Context())
cancel()
testCases := []struct {
name string
ctx context.Context
apiURL string
for _, testCase := range []struct {
name, apiURL string
ctx context.Context
}{
{"redirected endpoint", t.Context(), redirect.URL},
{"unsupported scheme", t.Context(), "file:///etc/passwd"},
{"relative endpoint", t.Context(), "releases/latest"},
{"cancelled check", cancelled, reachable.URL},
}
for _, testCase := range testCases {
{"redirected endpoint", redirect.URL, t.Context()},
{"unsupported scheme", "file:///etc/passwd", t.Context()},
{"relative endpoint", "releases/latest", t.Context()},
{"cancelled check", reachable.URL, cancelled},
{"server unavailable", unavailable.URL, t.Context()},
{"unusable metadata", malformed.URL, t.Context()},
} {
t.Run(testCase.name, func(t *testing.T) {
result := checkRelease(testCase.ctx, deployedRevision, testCase.apiURL, "")
assertResult(t, result, "unavailable", deployedRevision, "", "")
result := checkRelease(testCase.ctx, deployedVersion, deployedRevision, testCase.apiURL, "", "")
assertResult(t, result, statusUnavailable, deployedVersion, "", deployedRevision, "", "")
})
}
result := checkRelease(t.Context(), "development", "unknown", "", "", "")
assertResult(t, result, statusUnavailable, "development", "", "", "", "")
}
func TestOperatorTokenReachesGitHubOnly(t *testing.T) {
endpoints := []struct {
name string
url string
token string
authorized bool
for _, endpoint := range []struct {
name, url, token string
authorized bool
}{
{"github api", defaultReleaseAPIURL, "operator-token", true},
{"github api with padded token", defaultReleaseAPIURL, " operator-token ", true},
@@ -208,95 +180,143 @@ func TestOperatorTokenReachesGitHubOnly(t *testing.T) {
{"release host", "https://github.com/repos", "operator-token", false},
{"local fixture", "http://127.0.0.1:8787/releases/latest", "operator-token", false},
{"lookalike host", "https://api.github.com.evil.example/x", "operator-token", false},
}
for _, endpoint := range endpoints {
} {
t.Run(endpoint.name, func(t *testing.T) {
parsed, err := url.Parse(endpoint.url)
if err != nil {
t.Fatal(err)
}
if got := authorizesGitHub(parsed, endpoint.token); got != endpoint.authorized {
t.Fatalf("authorizesGitHub(%q) = %t; want %t",
endpoint.url, got, endpoint.authorized)
t.Fatalf("authorizesGitHub(%q) = %t; want %t", endpoint.url, got, endpoint.authorized)
}
})
}
var received http.Header
fixture := httptest.NewServer(http.HandlerFunc(
func(writer http.ResponseWriter, request *http.Request) {
received = request.Header.Clone()
_, _ = writer.Write([]byte(latestRelease))
}))
fixture := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
received = request.Header.Clone()
_, _ = writer.Write([]byte(latestRelease))
}))
defer fixture.Close()
checkRelease(t.Context(), deployedRevision, fixture.URL, "fixture-secret")
checkRelease(t.Context(), deployedVersion, deployedRevision, fixture.URL, "fixture-secret", "")
if authorization := received.Get("Authorization"); authorization != "" {
t.Fatalf("fixture endpoint received %q", authorization)
}
}
func TestCheckDeployedReleasePrintsOneLineAndTheOperatorExitCode(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(
func(writer http.ResponseWriter, _ *http.Request) {
_, _ = writer.Write([]byte(latestRelease))
}))
func TestCheckDeployedReleaseUsesTheBinaryIdentityAndOperatorExitCode(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
_, _ = writer.Write([]byte(latestRelease))
}))
defer server.Close()
testCases := []struct {
name string
revision string
wantCode int
wantJSON string
originalVersion, originalRevision := BuildVersion, BuildRevision
t.Cleanup(func() { BuildVersion, BuildRevision = originalVersion, originalRevision })
for _, testCase := range []struct {
name, version, revision, status string
code int
}{
{
"update available", deployedRevision + "\n", exitUpdateAvailable,
`{"status":"update-available","currentRevision":"` + deployedRevision +
`","latestRevision":"` + latestRevision +
`","releaseUrl":"` + latestReleaseURL + `"}`,
},
{
"up to date", latestRevision, exitUpToDate,
`{"status":"up-to-date","currentRevision":"` + latestRevision +
`","latestRevision":"` + latestRevision +
`","releaseUrl":"` + latestReleaseURL + `"}`,
},
{
"unreadable deployment", "", exitUpdateUnavailable,
`{"status":"unavailable","currentRevision":null,` +
`"latestRevision":null,"releaseUrl":null}`,
},
}
for _, testCase := range testCases {
{"newer release", deployedVersion, deployedRevision, statusUpdateAvailable, exitUpdateAvailable},
{"different build", latestVersion, deployedRevision, statusDifferentBuild, exitUpdateAvailable},
{"official release", "development", deployedRevision, statusOfficialRelease, exitUpdateAvailable},
{"up to date", latestVersion, latestRevision, statusUpToDate, exitUpToDate},
{"unknown build", "development", "development", statusUnavailable, exitUpdateUnavailable},
} {
t.Run(testCase.name, func(t *testing.T) {
currentFile := filepath.Join(t.TempDir(), "REVISION")
if testCase.revision != "" {
if err := os.WriteFile(currentFile, []byte(testCase.revision), 0o600); err != nil {
t.Fatal(err)
}
}
BuildVersion, BuildRevision = testCase.version, testCase.revision
var out bytes.Buffer
code := checkDeployedRelease(t.Context(), currentFile, server.URL, &out)
if code != testCase.wantCode || out.String() != testCase.wantJSON+"\n" {
t.Fatalf("checkDeployedRelease = %d, %q; want %d, %q",
code, out.String(), testCase.wantCode, testCase.wantJSON+"\n")
code := checkDeployedRelease(t.Context(), server.URL, &out)
if code != testCase.code || strings.Count(out.String(), "\n") != 1 {
t.Fatalf("checkDeployedRelease = %d, %q; want code %d and one line", code, out.String(), testCase.code)
}
var result releaseResult
if err := json.Unmarshal(out.Bytes(), &result); err != nil {
t.Fatal(err)
}
if testCase.status == statusUnavailable {
assertResult(t, result, testCase.status, testCase.version, "", "", "", "")
} else {
assertResult(t, result, testCase.status, testCase.version, latestVersion, testCase.revision, latestRevision, latestReleaseURL)
}
})
}
}
// assertResult compares the whole result through its JSON form so a value that
// must be null is distinguished from one that is merely empty.
func assertResult(t *testing.T, result releaseResult, status, current, latest, releaseURL string) {
func TestMirrorFallbackUsesOriginalSourceAndCompleteStablePagination(t *testing.T) {
payload, err := os.ReadFile("../../tests/fixtures/mirror-releases.json")
if err != nil {
t.Fatal(err)
}
var releases []json.RawMessage
if err := json.Unmarshal(payload, &releases); err != nil {
t.Fatal(err)
}
for i, raw := range releases {
_, revision, _, ok := parseMirrorReleaseMetadata(raw)
if ok != (i < 2) || (ok && revision != latestRevision) {
t.Fatalf("mirror fixture %d: accepted=%t revision=%s", i, ok, revision)
}
}
primaryAvailable, truncate := false, false
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if primaryAvailable {
_, _ = w.Write([]byte(latestRelease))
} else {
w.WriteHeader(http.StatusServiceUnavailable)
}
}))
defer primary.Close()
mirrorCalls := 0
mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mirrorCalls++
if r.Header.Get("Authorization") != "" || r.Header.Get("X-GitHub-Api-Version") != "" ||
r.Header.Get("Accept") != "application/json" {
t.Error("mirror received credentials or GitHub headers")
}
if r.URL.Query().Get("per_page") != "100" || r.URL.Query().Get("direction") != "desc" {
t.Error("mirror pagination missing")
}
if r.URL.Query().Get("page") == "1" || truncate {
page := make([]json.RawMessage, 100)
for i := range page {
page[i] = releases[0]
}
_ = json.NewEncoder(w).Encode(page)
} else {
_, _ = w.Write(payload)
}
}))
defer mirror.Close()
result := checkRelease(t.Context(), deployedVersion, deployedRevision, primary.URL, "operator-secret", mirror.URL)
assertResult(t, result, statusUpdateAvailable, deployedVersion, latestVersion, deployedRevision, latestRevision, mirrorReleasePrefix+latestVersion)
if mirrorCalls != 2 {
t.Fatalf("mirror calls = %d", mirrorCalls)
}
result = checkRelease(t.Context(), "v2.0.0", deployedRevision, primary.URL, "", mirror.URL)
if result.Status != statusUpToDate {
t.Fatal("a lagging mirror offered a downgrade")
}
truncate = true
result = checkRelease(t.Context(), deployedVersion, deployedRevision, primary.URL, "", mirror.URL)
if result.Status != statusUnavailable {
t.Fatal("an incomplete mirror list was accepted")
}
primaryAvailable = true
mirrorCalls = 0
result = checkRelease(t.Context(), latestVersion, latestRevision, primary.URL, "", mirror.URL)
if result.Status != statusUpToDate || mirrorCalls != 0 {
t.Fatal("a usable primary release consulted the mirror")
}
}
func assertResult(t *testing.T, result releaseResult, status, currentVersion, latestVersion, currentRevision, latestRevision, releaseURL string) {
t.Helper()
payload, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
expected, err := json.Marshal(releaseResult{
Status: status,
CurrentRevision: reported(current),
LatestRevision: reported(latest),
ReleaseURL: reported(releaseURL),
Status: status, CurrentVersion: reported(currentVersion), LatestVersion: reported(latestVersion),
CurrentRevision: reported(currentRevision), LatestRevision: reported(latestRevision), ReleaseURL: reported(releaseURL),
})
if err != nil {
t.Fatal(err)
@@ -306,24 +326,9 @@ func assertResult(t *testing.T, result releaseResult, status, current, latest, r
}
}
// reported turns the empty test expectation into the JSON null the result uses.
func reported(value string) *string {
if value == "" {
return nil
}
return &value
}
// release-update.mjs parseArguments threw the usage string and set
// process.exitCode = 2 when --current-file was absent, so the operator could
// tell a forgotten argument from a check that could not reach GitHub.
func TestCheckDeployedReleaseWithoutACurrentFileIsAUsageError(t *testing.T) {
var out bytes.Buffer
code := checkDeployedRelease(context.Background(), "", defaultReleaseAPIURL, &out)
if code != exitUsage {
t.Fatalf("exit code = %d, want %d", code, exitUsage)
}
if out.Len() != 0 {
t.Fatalf("a usage error wrote %q, want no result line", out.String())
}
}
+3 -5
View File
@@ -1,11 +1,9 @@
#!/bin/bash
set -Eeuo pipefail
if [ "$#" -gt 1 ]; then
printf 'usage: %s [current-revision-file]\n' "$0" >&2
if [ "$#" -ne 0 ]; then
printf 'usage: %s\n' "$0" >&2
exit 2
fi
current_file="${1:-/opt/piik/current/REVISION}"
exec /opt/piik/current/piik-server --check-release --current-file "$current_file"
exec /opt/piik/current/piik-server --check-release
@@ -8,7 +8,7 @@ limit_req_zone $binary_remote_addr zone=piik_site_access:10m rate=5r/m;
server {
listen 80;
listen [::]:80;
server_name share.bonfire.icu;
server_name share.example.com;
location ^~ /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
@@ -16,17 +16,17 @@ server {
}
location / {
return 301 https://share.bonfire.icu$request_uri;
return 301 https://share.example.com$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name share.bonfire.icu;
server_name share.example.com;
ssl_certificate /etc/letsencrypt/live/share.bonfire.icu/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/share.bonfire.icu/privkey.pem;
ssl_certificate /etc/letsencrypt/live/share.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/share.example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+6 -3
View File
@@ -26,7 +26,8 @@ descriptor_text() {
sed -n "s|^ \"$1\": \"\([A-Za-z0-9._/-]*\)\",\{0,1\}\$|\1|p" "$descriptor"
}
grep -qx ' "schema": 1,' "$descriptor"
grep -qx ' "schema": 2,' "$descriptor"
version="$(descriptor_text version)"
revision="$(descriptor_text revision)"
release_id="$(descriptor_text releaseId)"
artifact_name="$(descriptor_text artifact)"
@@ -37,6 +38,7 @@ file_count="$(sed -n 's|^ "fileCount": \([0-9]\{1,\}\),\{0,1\}$|\1|p' "$descrip
main_asset="$(descriptor_text mainAsset)"
[[ "$revision" =~ ^[0-9a-f]{40}$ ]]
[[ "$version" = development || "$version" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
[[ "$release_id" =~ ^[0-9a-f]{7}$ ]]
test "$release_id" = "${revision:0:7}"
test "$artifact_name" = "piik-${release_id}-runtime.tar.gz"
@@ -232,7 +234,8 @@ test -d "$release"
test ! -L "$release"
test "$(stat -c '%U:%G %a' "$release")" = 'root:root 755'
firewall_before="$(nft list table inet bonfire_filter | sha256sum | awk '{print $1}')"
# Compare rules without traffic counters; no operator-specific table name.
firewall_before="$(nft --stateless list ruleset | sha256sum | awk '{print $1}')"
nginx_restarts="$(systemctl show nginx.service -p NRestarts --value)"
cutover_since="$(date '+%Y-%m-%d %H:%M:%S')"
cutover_start="$(date +%s%3N)"
@@ -249,7 +252,7 @@ health_ready="$(date +%s%3N)"
test "$(systemctl show piik.service -p ActiveState --value)" = 'active'
test "$(systemctl show piik.service -p NRestarts --value)" = '0'
test "$(systemctl show nginx.service -p NRestarts --value)" = "$nginx_restarts"
test "$(nft list table inet bonfire_filter | sha256sum | awk '{print $1}')" = "$firewall_before"
test "$(nft --stateless list ruleset | sha256sum | awk '{print $1}')" = "$firewall_before"
test "$(readlink -f -- "$current")" = "$release"
pid="$(systemctl show piik.service -p MainPID --value)"
test "$pid" -gt 1
+65 -52
View File
@@ -1,32 +1,36 @@
# Documentation
# Piik Documentation
[English Quick Start](../README.md) · [中文快速开始](../README.zh-CN.md)
[About Piik](../README.md) · [中文介绍](../README.zh-CN.md) · [中文上手指南](./guide/getting-started.zh-CN.md)
## Use Piik
## Guides And References
| What you need | Start here |
| Task | Guide |
| --- | --- |
| Join a friend or share a screen | [Quick Start](../README.md) |
| Run App, choose a mode, or fix capture trouble | [App guide](../cmd/piik-app/README.md) |
| Collect a useful bug report | [Diagnostics and export](./reference/configuration.md#diagnostics) |
| Host your own site | [Self-hosting](./operations/self-hosting.md) |
| Configure ports, access or persistence | [Configuration reference](./reference/configuration.md) |
| Update an existing deployment | [Deployment and recovery](./deployment.md) |
| Check platform and release readiness | [Current status](./status.md) |
| Try the online demo | [Online demo](https://demo.piik.tv) · [Demo steps](./guide/getting-started.md#try-the-online-demo) |
| 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) |
| Run Piik App, check platform requirements, or build a package | [App guide](../cmd/piik-app/README.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) |
| 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) |
| Set passwords, ports, or room storage | [Configuration](./reference/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) |
| Check release and platform readiness | [Current status](./status.md) |
The public entry points have English and Chinese versions. Technical references
keep one owner rather than duplicate the full documentation tree.
Contributors should follow the [naming convention](./reference/naming.md) for
display names, commands, packages and App/Server/Browser code boundaries.
The introduction, getting-started and self-hosting guides are available in English and Chinese.
Technical references keep one shared version.
## Run from source
Use Node from [.node-version](../.node-version), npm from
[package.json](../package.json), and Go 1.26 as specified by [go.mod](../go.mod).
Node builds and serves the development UI; the application server runs in Go.
You can try the Browser application locally with Node from
[.node-version](../.node-version), npm from
[package.json](../package.json), and Go 1.26 from [go.mod](../go.mod).
Run these commands from the repository root.
Start the UI:
Start the web UI:
```sh
npm ci
@@ -49,49 +53,58 @@ go build -o build/dev/piik-server.exe ./cmd/piik-server
```
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).
Browser capture requires `localhost` or HTTPS.
Open `http://localhost:8787`. The development UI also listens on the LAN;
screen capture requires `localhost` or HTTPS. For another invitation origin,
follow the [configuration reference](./reference/configuration.md).
For the self-contained App, follow [App development](../cmd/piik-app/README.md#development)
and [packaging](../cmd/piik-app/README.md#packaging), including the platform's
native capture requirements.
The standard checks are:
## Work on Piik
[Contributing](../CONTRIBUTING.md) covers the workflow and choosing focused checks.
The main verification commands are:
```sh
npm run check
npm run check:client
```
The [contributing guide](../CONTRIBUTING.md#verification-entrypoints) explains
native build prerequisites, focused checks and separate physical gates.
Use the [naming and copy guide](./reference/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.
## Product and design
For the public website, see the [introduction design](./design/public-introduction.md)
and [website preview and publishing](./operations/website.md).
Current behavior belongs to four product modules:
## Understand the product
- [Rooms and access](./product/rooms-access.md)
- [Routing and transport](./product/routing-transport.md)
- [Capture, audio and media quality](./product/media-quality.md)
- [Presentation and lifecycle](./product/presentation-lifecycle.md)
| 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) |
| Why a design was chosen | [Architecture decisions](./adr/) |
| Measurements and platform limits | [Research](./research/) and [verification status](./verification-status.md) |
Key decisions cover [automatic routing](./adr/0005-automatic-hybrid-media-routing.md),
the [shared Go core](./adr/0012-shared-go-backend-core.md),
[embedded media and Native output groups](./adr/0013-embedded-node-local-media.md),
and the [Browser encoding-pool candidate](./adr/0014-browser-node-local-encoding-pool.md).
Browse [all ADRs](./adr/) for their context and consequences.
## Shared conventions
## Evidence and current work
| 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) |
| Main protection or automatic publishing | [GitHub operations](./operations/github.md) |
| Documentation or a duplicated rule | [Documentation ownership](./maintenance.md) |
| Checks, PRs, integration or cleanup | [Contributing](../CONTRIBUTING.md) |
- [Project memory](./project-memory.md): compact product map and invariants.
- [Status](./status.md): current source, production and acceptance boundaries.
- [TODO](./todo.md): the only work ledger.
- [Verification status](./verification-status.md): remaining physical evidence.
- [Native encoding research](./research/webrtc-encoder-pool.md) and
[Browser pooling research](./research/browser-local-encoding-pool.md): measured
results, failed controls and limits; neither promises universal performance.
- [Research directory](./research/): transport, capture, platform and backend evidence.
- [Maintenance](./maintenance.md): document ownership and repository lifecycle.
- [Licensing](../licenses/README.md): Piik's MIT scope and third-party notices.
Research distinguishes observations from assumptions. Product modules own
accepted behavior, source and tests own implementation detail, and Git/PRs own
completed history.
For ongoing work, use [TODO](./todo.md). [Project memory](./project-memory.md)
provides the compact product map; [status](./status.md) indexes current source
and deployment boundaries. Completed work lives in Git history and pull requests.
+1 -1
View File
@@ -25,7 +25,7 @@ browser-first product surface but did not establish a sustainable fanout,
fallback order, or server-resource policy.
The MVP explicitly excluded always-SFU conferencing, public-broadcast scaling,
MCU transcoding, and a custom Parsec-like transport. Those workloads either
MCU transcoding, and a custom low-latency media transport. Those workloads either
contradict the private-room cost model or duplicate mature WebRTC capabilities.
## Current Ownership
+2
View File
@@ -107,6 +107,8 @@ RTP packets to an independent Linux Pion Viewer over a selected direct
host-to-srflx pair. This proves the shared-socket candidate carries DTLS-SRTP;
the tested TUN network exposed only one distinct public mapping, so it does not
prove a predicted candidate win.
Survey inputs, platform limits and the measured boundaries live in
[NAT traversal research](../research/nat-traversal.md).
The current Native path maps selected Pion candidate foundations to the same
anonymous `ordinary | predicted | unknown` evidence as Browser ICE.
+9 -8
View File
@@ -56,18 +56,19 @@ the same Browser build.
stay synchronous inside the lock and keep the current fail-fast storage
policy: an unrecoverable authority write ends the process instead of
continuing on divergent state.
6. LiveKit access uses the standard library: HS256 participant and service tokens
plus Twirp JSON RoomService calls over `net/http`, with the current grants,
bounded per-request timeout, per-managed-room serialization, and not-found
rule. No LiveKit SDK is added.
6. Superseded by [ADR-0013](./0013-embedded-node-local-media.md): the port initially
used standard-library tokens and Twirp calls for external LiveKit. Current
App/Server media adapters share embedded Pion/LiveKit forwarding and the
authenticated room signaling path; that external service/token layer is gone.
7. Room persistence uses the pure-Go `modernc.org/sqlite` driver over the schema,
pragmas, single exclusive connection, and recovery accepted in
[ADR-0002](./0002-memory-resident-protected-rooms.md). Both storage modes,
their validation, and their failure policy are unchanged, and no build
requires a C toolchain.
8. The port changed no contract. Signaling stayed on its then-current v21 wire; the App
loopback stays v8, capture framing stays v4, and the HTTP API, cookies, error
codes, and close codes keep their exact shapes. No compatibility alias, dual
8. The port changed no contract. At that boundary signaling was v21, App loopback
v8 and the capture probe v4; the HTTP API, cookies, error codes and close codes
retained their shapes. [Status](../status.md#accepted-release-contract) owns
the current contract tuple. No compatibility alias, dual
reader, or migration is added for a change of implementation language.
## Consequences
@@ -91,7 +92,7 @@ the same Browser build.
production host need no Node; [deployment](../deployment.md) owns their exact
commands.
- Development runs two processes: the Vite dev server for the Browser bundle and
the Go application server behind it. The [README](../../README.md) owns those
the Go application server behind it. The [development guide](../README.md#run-from-source) owns those
commands.
- ADR-0010 items 1, 5, 7, 12 (its supervisor sentence), 13, 14, and its
two-internal-process consequence are superseded and amended in place. The rest
@@ -120,3 +120,4 @@ is introduced.
- [RTP switching and source projection, RFC 7667](https://www.rfc-editor.org/rfc/rfc7667)
- [LiveKit demand envelope](https://github.com/livekit/livekit/blob/v1.13.6/pkg/rtc/dynacast/dynacastmanagervideo.go)
- [Implementation proposal and reuse map](../research/node-local-media-design.md)
- [Embedded media evaluation](../research/embedded-media.md)
+63
View File
@@ -0,0 +1,63 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="192" viewBox="0 0 900 192" role="img" aria-labelledby="title desc">
<title id="title">Pick a screen, send an invitation, watch together</title>
<desc id="desc">Three illustrated steps: choose a window, send its room invitation, and join friends on the sofa. Each loop pauses to let the scene settle.</desc>
<style>
:root { --drawing:#22303e; --panel:#fafaf3; --line:#c6d6cc; --label:#256b47; --art-mint:#c4e3ce; --art-paper:#fffdf4; --art-blue:#a2c8e8; --art-yellow:#f5d37a; --art-orange:#ee8955; --art-pink:#e9b1bb; --couch:#edbb72; --couch-dark:#d99a56; }
@media (prefers-color-scheme:dark) { :root { --panel:#1b2d34; --line:#3b5155; --label:#9bdbb2; } }
text { fill:var(--label); font:600 13px system-ui,sans-serif; letter-spacing:2px; }
@media (prefers-reduced-motion:no-preference) {
g[class] { transform-box:fill-box; transform-origin:center; }
.screen-cursor { animation:choose-screen 6.4s ease-in-out infinite both; }
.chosen-window { animation:window-response 6.4s ease-in-out infinite both; }
.invite-plane { animation:send-invite 6.4s ease-in-out .2s infinite both; }
.invite-note { animation:note-response 6.4s ease-in-out .2s infinite both; }
.sofa-friend { animation:take-a-seat 6.4s ease-out .4s infinite both; }
.sofa-friend + .sofa-friend { animation-delay:.52s; }
}
@keyframes choose-screen {
0%, 8%, 48%, 100% { transform: none; }
22%, 34% { transform: translate(-62px, -19px); }
28% { transform: translate(-62px, -19px) scale(.85); }
}
@keyframes window-response { 0%, 22%, 46%, 100% { transform: none; } 30% { transform: scale(1.07); } 38% { transform: scale(.99); } }
@keyframes send-invite {
0%, 6%, 48%, 100% { transform: none; opacity: 1; }
12% { transform: translate(-4px, 3px) rotate(7deg); opacity: 1; }
32% { transform: translate(42px, -36px) rotate(-12deg); opacity: 0; }
33% { transform: translate(-8px, 8px); opacity: 0; }
}
@keyframes note-response { 0%, 10%, 42%, 100% { transform: none; } 20% { transform: translateY(1px) rotate(-4deg); } 32% { transform: rotate(1deg); } }
@keyframes take-a-seat { 0% { transform: translateY(-8px); } 22% { transform: translateY(2px); } 32%, 100% { transform: none; } }
</style>
<g transform="translate(0 2)">
<rect x="1" y="1" width="278" height="186" rx="18" fill="var(--panel)" stroke="var(--line)" stroke-width="1.5"/>
<text x="20" y="28">01</text>
<g transform="translate(15 40)"><g fill="none" stroke="var(--drawing)" stroke-width="3" stroke-linejoin="round">
<rect x="47" y="17" width="141" height="90" rx="12" fill="var(--art-mint)"/><path d="M105 108v14m-20 0h65"/>
<g class="chosen-window"><rect x="64" y="31" width="64" height="57" rx="7" fill="var(--art-paper)"/><path d="m79 76 12-15 9 7 12-19"/></g>
<rect x="137" y="31" width="34" height="23" rx="5" fill="var(--art-blue)"/><rect x="137" y="64" width="34" height="24" rx="5" fill="var(--art-yellow)"/>
<g class="screen-cursor"><path d="m172 82 3 33 9-8 8 13 9-5-8-13 11-2Z" fill="var(--art-orange)"/></g>
</g><path d="m33 35-8-4m12-8-4-8m169 22 9-5" stroke="var(--drawing)" stroke-width="3" stroke-linecap="round"/></g>
</g>
<g transform="translate(310 2)">
<rect x="1" y="1" width="278" height="186" rx="18" fill="var(--panel)" stroke="var(--line)" stroke-width="1.5"/>
<text x="20" y="28">02</text>
<g transform="translate(15 40)"><g stroke="var(--drawing)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<g class="invite-note"><rect x="44" y="31" width="143" height="73" rx="16" fill="var(--art-paper)" transform="rotate(-5 115 67)"/><path d="m80 77-6-6a13 13 0 0 1 0-18l10-10a13 13 0 0 1 18 0l6 6m-10 8 6 6a13 13 0 0 1 0 18L94 91a13 13 0 0 1-18 0l-6-6m11-10 16-16" fill="none"/><path d="M125 58h38m-38 15h24"/></g>
<circle cx="191" cy="96" r="25" fill="var(--art-blue)"/><g class="invite-plane"><path d="m174 94 34-12-12 33-7-14Z" fill="var(--art-paper)"/><path d="m189 101 8-9"/></g>
<path class="invite-spark" d="M190 24v10m-5-5h10M33 93v8m-4-4h8"/>
</g></g>
</g>
<g transform="translate(620 2)">
<rect x="1" y="1" width="278" height="186" rx="18" fill="var(--panel)" stroke="var(--line)" stroke-width="1.5"/>
<text x="20" y="28">03</text>
<g transform="translate(15 40)"><g stroke="var(--drawing)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M55 112v9m139-9v9"/><rect x="46" y="63" width="157" height="50" rx="17" fill="var(--couch)" stroke="none"/>
<g class="sofa-friend"><g fill="var(--art-blue)" stroke="none"><circle cx="99" cy="39" r="12"/><path d="M78 88c0-19.5 7.5-30 21-30s21 10.5 21 30q0 4.5-4.5 4.5h-33Q78 92.5 78 88Z"/></g><path d="M95 39v1m8-1v1"/></g>
<g class="sofa-friend"><g fill="var(--art-pink)" stroke="none"><circle cx="150" cy="37" r="12"/><path d="M129 88c0-19.5 7.5-30 21-30s21 10.5 21 30q0 4.5-4.5 4.5h-33Q129 92.5 129 88Z"/></g><path d="M146 37v1m8-1v1"/></g>
<path d="M125 99v10M46 73h18a10 10 0 0 1 10 10v26m129-36h-18a10 10 0 0 0-10 10v26" fill="none" stroke="var(--couch-dark)"/>
<g class="sofa-spark"><path d="m185 20 3 8 8 3-8 3-3 8-3-8-8-3 8-3Z" fill="var(--art-yellow)"/></g>
</g></g>
</g>
<path d="M288 102h15m-5-5 5 5-5 5m300-5h15m-5-5 5 5-5 5" fill="none" stroke="var(--label)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

+41 -64
View File
@@ -12,6 +12,9 @@ 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
bootstrap/updater; do not call this wrapper generic.
Its firewall check compares the nftables ruleset without traffic counters; it
does not depend on a particular table name. Coordinate other firewall writers
outside the cutover window. See [nft output options](https://netfilter.org/projects/nftables/manpage.html).
## Release Boundary
@@ -95,11 +98,12 @@ 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.
After `validate` succeeds on an explicit workflow dispatch with
`client_checks=true`, CI packages the Server application release and the
three-platform App candidates that consume it. The artifacts are retained
for 14 days. Ordinary `main` pushes do not run these packaging jobs. This
workflow does not create a tag, public GitHub Release, or deployment.
Manual `client_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
and PRs do not start cloud CI; no release-record commit is written back to main.
The workflow does not deploy into a running application service.
Do not build or run the full repository check on a constrained production host.
That host runs only the packaged binary and needs no Node, npm, or dependency
@@ -107,15 +111,19 @@ install.
## Update Check
The application, Server deployment, and platform App all use the full Git
revision as their release identity. A formal GitHub Release must use that exact
40-character revision as its tag and keep the corresponding release URL. The
current CI workflow produces short-lived candidates but does not publish a
GitHub Release; publication remains an explicit distribution decision.
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)
owns ordering and first-public-release readiness.
The default App launcher starts immediately, then performs one background
request to the official Piik GitHub Releases API. It shows a link only when
the latest release has a valid full revision different from the packaged one.
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
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
distinct; a different SHA alone is not called newer.
The request sends no current revision, credentials, room data, or media data;
network errors, private-repository responses, and missing releases are treated
as no notice. It never downloads, replaces, or interrupts a running share.
@@ -126,62 +134,26 @@ An operator can perform the corresponding read-only Server check:
bash deploy/check-release.sh
```
The script runs the deployed binary's release check, which reads
`/opt/piik/current/REVISION` and prints one JSON result.
Exit status `0` means the deployed revision is current, `10` means a newer
release is available, and `20` means the check could not establish a valid
release identity. For a private repository, inject a short-lived `GITHUB_TOKEN`
The Server also checks once in the background after startup and logs an available
release with its download URL. It does not delay startup, periodically poll,
notify room participants or install anything.
The script runs the deployed binary's `--check-release`, using that binary's
injected identity, and prints one JSON result with versions, available source
SHAs and the release URL. Exit `0` means no newer/different official build,
`10` means `update-available`, `different-build` or `official-release`, and `20`
means the check is unavailable. It does not accept a separate revision file.
For a private repository, inject a short-lived `GITHUB_TOKEN`
through the operator environment; never place it in the repository or command
line. The command does not mutate files, services, containers, or persistent
state. A different current-revision file may be supplied as its only argument.
## Permanent-Room Schema Cutover
The schema 2 / signaling v23 cutover required matching Web/App/Server builds
and an accepted active-session interruption. Native control stayed v9; that
schema change preserved Browser credential keys. It was not an app-only release.
The separate [brand cutover](./research/piik-rename-plan.md) must preserve this
database rather than repeat the schema change.
1. Verify the candidate; record the current release, environment, absolute DB
path and ownership. Stop ingress and the old application; take and retain a
SQLite backup. Require `application_id=1396920910`, `user_version=1`, expected
columns and `PRAGMA quick_check='ok'` before touching a separate protected copy.
2. On that copy, use SQLite with `DROP COLUMN` support and stop on SQL errors:
```sql
BEGIN EXCLUSIVE;
ALTER TABLE rooms DROP COLUMN lease_expires_at_ms;
PRAGMA user_version = 2;
COMMIT;
```
3. Verify integrity, exact columns/version and equal row counts. Compare retained
columns in both directions against the backup using `EXCEPT`; both must be
empty. Record only counts/pass/fail, not private verifiers. The candidate must
open this copy successfully; no room is dropped because of its former deadline.
4. Retain the absolute `ROOM_DATABASE_PATH`; remove `ROOM_LEASE_SECONDS`. Install
the verified copy and release while stopped, retaining permissions/ownership.
Start; check health and Host/Viewer reauthentication before reopening ingress.
5. On failure, stop and restore prior database, environment and release together.
Once new authority mutations are accepted, restoring the backup could revive
revoked grants: preserve those changes or make an explicit recovery decision.
The runtime reads only schema 2. SQLite's [column removal](https://www.sqlite.org/lang_altertable.html#altertabdropcol)
and [backup guidance](https://www.sqlite.org/backup.html) define this offline operation.
state.
## Atomic Cutover
### First embedded-media cutover prerequisite
The first move from external services to embedded STUN/SFU is a coordinated
infrastructure and protocol transaction, including Node-to-Go where still
needed. Complete the [self-hosting cutover procedure](./operations/self-hosting.md#coordinated-embedded-media-cutover)
with matching Web/Server signaling and native protocol v9 App builds,
accepted active-session interruption, released UDP ports, and exact
unit/environment/proxy/service recovery. The routine wrapper does not perform
that transaction; it requires an already running packaged Go release and cannot
restore the previous infrastructure.
The wrapper requires an already running packaged Go release and the service
layout described above. For a new installation, use the
[self-hosting guide](./operations/self-hosting.md). Infrastructure changes need
their own scoped recovery procedure under [CONTRIBUTING](../CONTRIBUTING.md).
Run the tracked server entry with the uploaded descriptor:
@@ -249,3 +221,8 @@ operator intervention; it must not silently report the new release as active.
Infrastructure or persistent-state recovery is separate and limited to the
surfaces changed by that task. Do not restore an entire server, old firewall, or
old secret set for an application-only failure.
If a task changes persistent room data, prepare and verify a separate
[SQLite backup](https://www.sqlite.org/backup.html) and recover matching data,
configuration and application together. Restoring old data can revive revoked
credentials; account for later authority changes before reopening ingress.
+113
View File
@@ -0,0 +1,113 @@
# Media Status
`src/client/ui/media-status.ts` owns the shared visual projection, demonstrated
by `/__status-preview`. It is not a routing or quality controller. The existing
presentation reducer continues to own playback facts; roster and quality
evidence retain their own authority. Overlay, indicators, notices and title are
consumers, not truth sources.
Frontend controls, names and details share `Tooltip`, following the
[visual language](./visual-language.md). Its native Popover top layer escapes scrolling
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);
`/__playback-preview` exercises the actual control bar with generated local media.
## Status Meaning
Keep three questions separate: is the media path available, is this picture
playing, and is this particular stream limited? An available path does not
prove excellent quality. One weak child does not make its parent's received
picture or a healthy sibling unhealthy.
The shared [visual language](./visual-language.md) owns colours, cast, shapes,
panel accents and motion. This document owns the evidence and scope that select
those meanings. Current `StatusDescriptor` tone and progress take precedence
over a reused comic's default; text and pure-visual tooltips retain that context.
## Projection Rules
- Overlay: appears when there is no usable picture, an intentional Host pause,
or a required user action. Ordinary quality warnings never cover playback.
Initial connection guidance is not an error even though it occupies the stage.
- Television: one media-status icon sits inside the lower television frame
(`lr-tv-chin`). Visual mode explains it with the existing comic tooltip;
text modes expose the specific localized status in the same paper tooltip.
Both reuse `Tooltip` for hover, keyboard focus, touch hold and viewport
placement; no native `title` competes with the custom status hint. There is no
permanent text bubble, additional round lamp, or repeated control-menu caption.
The Host television describes its local source, not an arbitrary outbound
child. A confirmed limitation may colour the relevant icon amber while the
page title still says watching.
- Host couch: each Viewer has one round lamp. Grey means waiting for the share,
blue means waiting for the media path, green means committed `mediaReady`, and
amber means a recent receive freeze. The Host's own pawn has no duplicate
lamp. `deriveParticipantStatus` supplies both the couch and Host overview;
neither stores another copy of the status.
- Viewer couch: pawns have no round lamps. While awaiting media readiness a
pawn's body breathes gently; it becomes still when ready. This expresses the
same roster fact without claiming to know other Viewers' quality.
- Header: the page's connection to the room server. Its control-link comics
show a Browser and server, not a failed video path. A media-route failure can
coexist with a healthy server connection, and vice versa.
- Connection notice: signaling recovery may coexist with healthy media. It
cannot convert proved playback into waiting.
- Notices: necessary coexisting issues and operation feedback use the existing
pills near the stage; they are not another primary media status. Raw
PeerConnection errors remain diagnostic observations, not persistent red
warnings after media recovers. The control menu does not repeat the television
status or aggregate the first weak outbound edge into a global quality warning.
- Title: keeps the primary activity, with a compact warning for an applicable
problem. Losing focus alone never means waiting for media.
- Recovery: every projection is recomputed from current facts. A warning requires
current evidence; an expired or mismatched sample becomes unknown, not healthy
or disconnected. A ready participant can therefore return to a green readiness
lamp without claiming that its quality has recovered. No warning latch or
additional timer is needed.
## Evidence And Integration Boundary
Use WebRTC `connectionState` for transport explanation, not first-frame proof.
`disconnected` may recover; `failed` is terminal for that connection. Closing a
retired connection is not an error in the replacement connection.
`qualityLimitationReason` describes an **outbound** encoder. A relay's sender
warning must never become a warning about its own inbound picture. Low FPS,
resolution or bitrate alone does not prove poor quality; a static screen or a
small source can legitimately need little data. Do not introduce UI thresholds
or a new quality score.
Current roster messages expose committed `mediaReady` to both roles, not a
complete remote quality/failure state. Missing readiness means waiting or
recovery, not confirmed disconnection; departed participants leave the roster.
Viewer receive evidence is shared with its parent and the Host, not with every
other Viewer. No additional remote-quality broadcast is needed.
The Host's amber participant lamp requires an active share, committed readiness,
fresh receive evidence for the current upstream, and a positive
`freezeCountDelta` or `freezeDurationMsDelta`. The existing evidence-expiry path
clears stale warnings. Hidden, paused and other diagnostic-only windows provide
no freeze proof; low FPS, resolution or bitrate is not a substitute.
All `*Delta` metrics describe one observation window and never inherit an older
non-null value. Non-window fields retain the existing details-display cache.
Participant status reads the current evidence directly; it adds no stored
status, timer, threshold or score. Detailed sender/receiver metrics
remain available in connection details and Debug with their original scope.
The preview labels supplied example quality facts for the displayed stream.
Examples do not authorize guessing unavailable production facts.
## References
- [W3C WebRTC connection states](https://www.w3.org/TR/webrtc/#dom-rtcpeerconnectionstate):
transport lifecycle is distinct from receiving and presenting media.
- [W3C WebRTC statistics](https://www.w3.org/TR/webrtc-stats/#dom-rtcoutboundrtpstreamstats-qualitylimitationreason):
native `none`, `bandwidth`, `cpu`, `other` describe outbound limitations.
- [LiveKit connection quality](https://docs.livekit.io/intro/basics/rooms-participants-tracks/webhooks-events/#connection-quality):
quality and connection events are separate; unknown is an explicit value.
- [LiveKit reconnection](https://docs.livekit.io/intro/basics/connect/#network-changes-and-reconnection):
transient signaling/media recovery does not always interrupt the picture.
We reuse these distinctions and the existing Piik owners, not a new SDK, scoring
algorithm or transport policy.
-239
View File
@@ -1,239 +0,0 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Piik · Brand study / 品牌预览</title>
<style>
:root { color-scheme: light; --wall:#e9f2ee; --wall2:#dcebe4; --ink:#22303e; --paper:#fff; --orange:#e4572e; --green:#1f7a4a; --spark:#d08a22; }
:root[data-theme="dark"] { color-scheme:dark; --wall:#141e29; --wall2:#0e1620; --ink:#dde8f3; --paper:#22303e; --green:#9de8bf; --spark:#f4c14d; }
* { box-sizing:border-box; }
body { margin:0; background:var(--wall); color:var(--ink); font:16px/1.55 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif; }
a { color:inherit; text-underline-offset:3px; }
main { max-width:1100px; margin:auto; padding:28px 28px 44px; }
header, .controls, .section-head, .card-heading, footer { display:flex; align-items:center; justify-content:space-between; gap:16px; }
header { padding-bottom:27px; border-bottom:1px solid color-mix(in srgb,var(--ink) 18%,transparent); }
.identity { display:flex; align-items:center; gap:10px; font-weight:750; letter-spacing:-.03em; }
.identity svg { width:32px; height:32px; }
.eyebrow, .number, .small-label { font-size:12px; font-weight:650; letter-spacing:.09em; text-transform:uppercase; }
.eyebrow { color:var(--green); }
h1,h2,h3,p { margin:0; }
h1 { font-size:clamp(32px,4.5vw,52px); line-height:1.1; letter-spacing:-.055em; max-width:520px; margin:12px 0 16px; }
h2 { font-size:25px; letter-spacing:-.035em; }
h3 { font-size:17px; letter-spacing:-.02em; }
.intro { display:grid; grid-template-columns:1fr 1fr; align-items:center; gap:32px; margin:44px 0 32px; }
.intro p { max-width:440px; }
.muted { opacity:.73; }
.hero { border:2px solid var(--ink); border-radius:22px; background:var(--paper); overflow:hidden; box-shadow:0 6px 0 var(--wall2); }
.stage { position:relative; padding:12px 16px 0; background:radial-gradient(ellipse at center,var(--wall2),transparent 70%); }
#morph { display:block; width:100%; height:auto; max-height:275px; overflow:visible; }
.caption { text-align:center; font-size:13px; padding:0 18px 17px; }
.controls { flex-wrap:wrap; justify-content:center; gap:8px; padding:15px; border-top:1px solid color-mix(in srgb,var(--ink) 15%,transparent); }
button { font:inherit; font-size:13px; font-weight:650; cursor:pointer; color:var(--ink); background:var(--paper); border:1.5px solid var(--ink); border-radius:9px; min-height:38px; padding:7px 12px; }
button:hover { background:var(--wall2); }
button:active { transform:translateY(1px); }
button:disabled { opacity:.45; cursor:default; }
button.primary { background:var(--ink); color:var(--paper); }
button:focus-visible,input:focus-visible { outline:3px solid var(--orange); outline-offset:3px; }
.motion-option { display:flex; align-items:center; justify-content:center; gap:7px; font-size:12px; padding:0 12px 13px; }
input { accent-color:var(--green); }
.section-head { margin:39px 0 15px; align-items:baseline; }
.section-head p { font-size:13px; }
.choices { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:16px; }
article { padding:20px; border:1px solid color-mix(in srgb,var(--ink) 24%,transparent); border-radius:15px; background:var(--paper); }
article.recommended { border:2px solid var(--ink); padding:19px; }
.tag { padding:3px 8px; border-radius:20px; font-size:11px; color:var(--green); background:var(--wall); white-space:nowrap; }
.word { display:block; width:100%; height:105px; margin:10px 0 8px; }
article p { font-size:13px; margin-top:7px; }
.detail { display:grid; grid-template-columns:1fr 1.2fr; gap:30px; align-items:center; padding:24px; margin-top:18px; background:var(--wall2); border-radius:15px; }
.detail p { font-size:14px; margin-top:10px; }
.samples { width:100%; border-collapse:collapse; table-layout:fixed; text-align:left; }
th,td { border-bottom:1px solid color-mix(in srgb,var(--ink) 15%,transparent); padding:9px 6px; vertical-align:middle; }
th { font-size:12px; font-weight:650; }
th:first-child { width:58px; }
td svg { display:block; max-width:100%; }
.size { font-size:12px; white-space:nowrap; opacity:.7; }
.aside { display:flex; align-items:center; gap:12px; margin-top:15px; padding-top:13px; border-top:1px solid color-mix(in srgb,var(--ink) 15%,transparent); }
.aside svg { width:32px; height:32px; flex:none; }
.aside span { font-size:12px; }
.zh { display:block; margin-top:6px; font-size:13px; font-weight:400; letter-spacing:0; line-height:1.6; opacity:.75; }
.application { margin-top:18px; border:2px solid var(--ink); border-radius:18px; overflow:hidden; background:var(--paper); }
.application header { padding:16px 24px; }
.lockup { display:flex; align-items:center; gap:12px; }
.lockup .tv { width:36px; height:36px; }
.lockup .word { width:68px; height:38px; margin:0; }
.sample-nav { display:flex; flex-wrap:wrap; gap:16px; font-size:12px; opacity:.75; }
.site-hero { display:grid; grid-template-columns:1fr auto; align-items:center; gap:24px; padding:36px 32px; }
.site-hero h3 { font-size:clamp(25px,3vw,38px); line-height:1.2; letter-spacing:-.04em; }
.site-hero p { max-width:430px; margin-top:12px; font-size:14px; }
.site-hero > .tv { width:128px; height:128px; color:var(--ink); }
.steps { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); border-top:1px solid color-mix(in srgb,var(--ink) 18%,transparent); }
.steps > div { padding:18px 24px; }
.steps > div + div { border-left:1px solid color-mix(in srgb,var(--ink) 18%,transparent); }
.steps p { font-size:13px; margin-top:6px; }
.implementation { display:flex; flex-wrap:wrap; gap:10px 24px; margin:14px 0 0; font-size:12px; }
code { font-family:ui-monospace,"Cascadia Mono",monospace; }
footer { margin-top:27px; padding-top:18px; border-top:1px solid color-mix(in srgb,var(--ink) 18%,transparent); font-size:12px; flex-wrap:wrap; }
.fallback { font-weight:750; font-size:42px; letter-spacing:-.045em; }
.js .fallback { display:none; }
.no-js { font-size:13px; }
@media(max-width:760px) { main { padding:20px 18px 32px; } .intro { grid-template-columns:1fr; gap:24px; margin-top:30px; } .intro p { max-width:560px; } .hero { max-width:550px; width:100%; } .detail { grid-template-columns:1fr; gap:15px; } }
@media(max-width:500px) { .choices { grid-template-columns:1fr; gap:10px; } article { display:grid; grid-template-columns:1fr 1.3fr; column-gap:14px; align-items:center; } .card-heading { grid-column:1/-1; } .word { height:90px; grid-row:2/4; } article h3 { margin-top:12px; } article p { margin:4px 0; } .section-head { align-items:start; flex-direction:column; gap:3px; } .detail { padding:18px; } .eyebrow { font-size:11px; } .application header { padding:14px; } .sample-nav { gap:9px; font-size:11px; } .site-hero { padding:24px 18px; gap:12px; } .site-hero > .tv { width:70px; height:70px; } .steps { grid-template-columns:1fr; } .steps > div + div { border-left:0; border-top:1px solid color-mix(in srgb,var(--ink) 18%,transparent); } }
@media(prefers-reduced-motion:reduce) { button:active { transform:none; } }
</style>
<main>
<header>
<div class="identity"><svg class="tv" aria-hidden="true" viewBox="0 0 32 32"></svg><span>Piik / 品牌预览</span></div>
<button id="theme" type="button" aria-label="Switch to dark theme">Dark room</button>
</header>
<section class="intro" aria-labelledby="intro-title">
<div>
<div class="eyebrow">01 / A name taking shape</div>
<h1 id="intro-title">A little peek.<br>A familiar face.</h1>
<p>From <strong>“Peek” in games</strong> to a little TV for watching together. Keep the wink. Give it a name.</p>
<p class="muted" style="font-size:14px;margin-top:14px" lang="zh-CN">探个头,看看朋友在玩什么。名字换了,小电视还是那个小电视。</p>
</div>
<div class="hero">
<div class="stage">
<span class="fallback">Piik → TV</span>
<svg id="morph" viewBox="70 20 330 210" role="img" aria-labelledby="morph-title morph-desc">
<title id="morph-title">Piik becomes the winking TV</title>
<desc id="morph-desc">The P bowl opens into the screen, with the outer letter stems joining its sides. The two i dots become eyes, their stems become feet, and the k arms open into the TV antennae.</desc>
</svg>
</div>
<p class="caption">Same strokes. Familiar wink.<span class="zh" lang="zh-CN">P 撑开屏幕,ii 变成眼睛和脚,k 的两笔转成电视天线。</span></p>
<div class="controls">
<button id="replay" class="primary" type="button">重播 / Replay</button>
<button id="pause" type="button">暂停 / Pause</button>
<button id="word-state" type="button">字标 / Wordmark</button>
<button id="tv-state" type="button">电视 / TV</button>
</div>
<label class="motion-option"><input id="reduce" type="checkbox"> 减少动态 / Reduced motion</label>
</div>
</section>
<noscript><p class="no-js">Motion is optional. Piik is the recommended spelling; PiiK and piik are the two alternatives below. The original TV remains the small icon.</p></noscript>
<div class="section-head"><h2>Three ways to write it<span class="zh" lang="zh-CN">三个名字写法,同一套笔画</span></h2><p class="muted">One drawing style. Only the casing changes.</p></div>
<section class="choices" aria-label="Casing comparison">
<article class="recommended">
<div class="card-heading"><span class="number">01</span><span class="tag">Recommended</span></div>
<span class="fallback">Piik</span><svg class="word" data-case="Piik" role="img" aria-label="Piik, with an uppercase P and lowercase i i k"></svg>
<h3>Piik</h3><p>A clear name, with two curious eyes.<span class="zh" lang="zh-CN">主方案。首字母清楚,两个 i 留一点好奇心。</span></p>
</article>
<article>
<div class="card-heading"><span class="number">02</span><span class="tag">More contrast</span></div>
<span class="fallback">PiiK</span><svg class="word" data-case="PiiK" role="img" aria-label="PiiK, with uppercase P and K"></svg>
<h3>PiiK</h3><p>A stronger K gives the name a sharper silhouette.<span class="zh" lang="zh-CN">两侧更高、更硬朗,也更抢眼。</span></p>
</article>
<article>
<div class="card-heading"><span class="number">03</span><span class="tag">Softer</span></div>
<span class="fallback">piik</span><svg class="word" data-case="piik" role="img" aria-label="piik, all lowercase"></svg>
<h3>piik</h3><p>Relaxed and compact, with a lower centre of gravity.<span class="zh" lang="zh-CN">更轻松。小写 p 会伸到基线下方。</span></p>
</article>
</section>
<section class="detail" aria-labelledby="detail-title">
<div>
<div class="eyebrow">02 / The two little details</div>
<h2 id="detail-title">Two dots. Two curious eyes.</h2>
<p>Round dots keep the <strong>ii</strong> easy to read, then become the familiar TV eyes.</p>
<p class="muted">The wordmark below is shown at everyday sizes.</p>
<p class="zh" lang="zh-CN">两个圆点保留 ii 的辨识度,再变成小电视的眼睛。下面按实际尺寸查看。</p>
<div class="aside"><svg class="tv" aria-hidden="true" viewBox="0 0 32 32"></svg><span>Keep the existing TV for the tiny icon.<br>No four-letter favicon puzzle.</span></div>
</div>
<table class="samples" aria-label="Piik at actual sizes">
<thead><tr><th>Height</th><th>Piik</th></tr></thead>
<tbody>
<tr><td class="size">64 px</td><td><svg data-size="64" role="img" aria-label="Piik at 64 pixels"></svg></td></tr>
<tr><td class="size">32 px</td><td><svg data-size="32" role="img" aria-label="Piik at 32 pixels"></svg></td></tr>
<tr><td class="size">24 px</td><td><svg data-size="24" role="img" aria-label="Piik at 24 pixels"></svg></td></tr>
</tbody>
</table>
</section>
<div class="section-head"><h2>At home on a page<span class="zh" lang="zh-CN">放到页面上:官网首屏的方向草图</span></h2><p class="muted">A static website. The sharing app stays a tool.</p></div>
<section class="application" aria-label="Static homepage composition, not a live sharing service">
<header>
<div class="lockup" role="img" aria-label="TV mascot and Piik wordmark"><svg class="tv" aria-hidden="true" viewBox="0 0 32 32"></svg><svg class="word" data-case="Piik" aria-hidden="true"></svg></div>
<div class="sample-nav"><span>Download</span><span>Guide</span><span>GitHub</span></div>
</header>
<div class="site-hero">
<div><h3>Your game.<br>Their front row.</h3><p>Share your screen. Bring your friends.<br>They watch right in their browsers.</p><p class="zh" lang="zh-CN">开一局,朋友坐前排。<br>发个邀请,打开浏览器就能看。</p></div>
<svg class="tv" aria-hidden="true" viewBox="0 0 32 32"></svg>
</div>
<div class="steps">
<div><span class="number">01 / Pick</span><p>A screen, a window, a game.<span class="zh" lang="zh-CN">选好想分享的画面。</span></p></div>
<div><span class="number">02 / Invite</span><p>Send a link to your friends.<span class="zh" lang="zh-CN">把邀请链接发给朋友。</span></p></div>
<div><span class="number">03 / Hang out</span><p>A little room for your people.<span class="zh" lang="zh-CN">人到齐,就一起看。</span></p></div>
</div>
</section>
<div class="implementation"><span>Display / 显示名称 <strong>Piik</strong></span><span>Technical / 技术名称 <code>piik-app</code></span><span>Website / 官网 <code>piik.tv</code></span><span>Page title / 页签 <code>Piik | 6020 · 📺</code></span></div>
<footer><span>Design study · 09 Sep 2026 · 待讨论,尚未替换产品</span><span>Layout references: <a href="https://localsend.org/">LocalSend</a> · <a href="https://rustdesk.com/">RustDesk</a></span></footer>
</main>
<script>
const $ = (id) => document.getElementById(id);
const ns = 'http://www.w3.org/2000/svg';
const tv = '<g fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m11 4 5 4 5-4"/><rect x="4" y="8" width="24" height="18" rx="4"/><circle cx="11.5" cy="16.5" r="1.25" fill="currentColor" stroke="none"/><path d="M19 17c1.5-1.8 3.2-1.8 4.7 0M10 29l2-3m10 3-2-3"/></g>';
document.querySelectorAll('.tv').forEach((svg) => svg.innerHTML = tv);
// The k diagonals become antennae; the outer stems join the screen edges.
const parts = [
['rect', {x:108,y:72,width:58,height:48,rx:20}, {x:172,y:76,width:136,height:102,rx:22.667}],
['line', {x1:108,y1:72,x2:108,y2:164}, {x1:172,y1:98.667,x2:172,y2:155.333}],
['line', {x1:194,y1:118,x2:194,y2:164}, {x1:217.333,y1:178,x2:206,y2:195}],
['line', {x1:236,y1:118,x2:236,y2:164}, {x1:262.667,y1:178,x2:274,y2:195}],
['line', {x1:282,y1:72,x2:282,y2:164}, {x1:308,y1:98.667,x2:308,y2:155.333}],
['line', {x1:284,y1:139,x2:318,y2:111}, {x1:240,y1:76,x2:268.333,y2:53.333}],
['line', {x1:294,y1:131,x2:326,y2:164}, {x1:211.667,y1:53.333,x2:240,y2:76}],
];
const attributes = (node, values) => Object.entries(values).forEach(([key,value]) => node.setAttribute(key,String(value)));
function element(parent, tag, values) { const node = document.createElementNS(ns,tag); attributes(node,values); parent.append(node); return node; }
function animate(node, name, from, to, begin=.65, dur=1.25) {
return element(node,'animate',{attributeName:name,from,to,begin:begin+'s',dur:dur+'s',fill:'freeze',calcMode:'spline',keyTimes:'0;1',keySplines:'.4 0 .2 1'});
}
function drawWord(svg, casing='Piik', moving=false) {
if (!moving) svg.setAttribute('viewBox','96 58 244 138');
const group = element(svg,'g',{fill:'none',stroke:'currentColor','stroke-width':10,'stroke-linecap':'round','stroke-linejoin':'round'});
parts.forEach(([tag,start,end],index) => {
const shape = {...start};
if (casing === 'piik') { if(index===0) {shape.y=112;shape.height=48;} if(index===1) {shape.y1=112;shape.y2=188;} }
if (casing === 'PiiK') { if(index===5) {shape.y1=122;shape.y2=72;} if(index===6) {shape.x1=286;shape.y1=120;} }
const node = element(group,tag,shape);
if(moving) Object.keys(end).forEach(key => animate(node,key,shape[key],end[key]));
});
[194,236].forEach((cx,index) => {
const eyeX = index?270.317:214.5;
const eye = element(group,'ellipse',{cx,cy:95,rx:6.5,ry:6.5,fill:'currentColor',stroke:'none'});
if(moving) { animate(eye,'cx',cx,eyeX); animate(eye,'cy',95,124.167); animate(eye,'rx',6.5,7.083); animate(eye,'ry',6.5,7.083); if(index) animate(eye,'ry',7.083,0,2.2,.16); }
});
if(moving) {
animate(group,'stroke-width',10,13.6);
const wink = element(group,'path',{d:'M257 127c8.5-10.2 18.133-10.2 26.633 0',pathLength:1,'stroke-dasharray':1,'stroke-dashoffset':1});
animate(wink,'stroke-dashoffset',1,0,2.3,.23);
const sparkles = element(group,'g',{stroke:'var(--spark)','stroke-width':7.933,opacity:0});
element(sparkles,'path',{d:'M291 95.8L295.5 106.6L306.3 111.1L295.5 115.7L291 126.4L286.5 115.7L275.7 111.1L286.5 106.6Z'});
element(sparkles,'path',{d:'M291 128.1L293.6 134.4L299.8 136.9L293.6 139.5L291 145.7L288.5 139.5L282.2 136.9L288.5 134.4Z'});
animate(sparkles,'opacity',0,1,2.4,.15);
animate(sparkles,'opacity',1,0,2.8,.4);
}
}
drawWord($('morph'),'Piik',true);
document.querySelectorAll('[data-case]').forEach(svg => drawWord(svg,svg.dataset.case));
document.querySelectorAll('[data-size]').forEach(svg => { const height=Number(svg.dataset.size); attributes(svg,{height,width:height*244/138}); drawWord(svg); });
const morph=$('morph'), reduce=$('reduce'), pause=$('pause'), motion=matchMedia('(prefers-reduced-motion: reduce)');
const duration=3.3;
let frame;
function still(at) { cancelAnimationFrame(frame); morph.pauseAnimations(); morph.setCurrentTime(at); pause.textContent='继续 / Resume'; pause.disabled=reduce.checked||at>=duration; }
function watch() { if(morph.getCurrentTime()>=duration) { still(duration); return; } frame=requestAnimationFrame(watch); }
function replay() { still(0); if(reduce.checked) { still(duration); return; } pause.textContent='暂停 / Pause'; pause.disabled=false; morph.unpauseAnimations(); watch(); }
$('replay').onclick=replay;
pause.onclick=() => { if(morph.animationsPaused()) { pause.textContent='暂停 / Pause'; morph.unpauseAnimations(); watch(); } else still(morph.getCurrentTime()); };
$('word-state').onclick=() => still(0);
$('tv-state').onclick=() => still(duration);
reduce.checked=motion.matches;
reduce.onchange=() => still(reduce.checked?duration:0);
motion.addEventListener('change',event => { reduce.checked=event.matches; reduce.onchange(); });
const dark=matchMedia('(prefers-color-scheme: dark)');
function theme(isDark) { document.documentElement.dataset.theme=isDark?'dark':'light'; $('theme').textContent=isDark?'Light room':'Dark room'; $('theme').setAttribute('aria-label',isDark?'Switch to light theme':'Switch to dark theme'); }
theme(dark.matches);
$('theme').onclick=() => theme(document.documentElement.dataset.theme!=='dark');
document.documentElement.classList.add('js');
if(reduce.checked) still(0); else replay();
</script>
</html>
+148
View File
@@ -0,0 +1,148 @@
# Public introduction
This file owns the website and README content structure. The
[visual language](./visual-language.md) owns their shared illustration and motion.
## 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
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 |
| 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
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.
## Presentation
Keep three presentation responsibilities distinct:
| Layer | Owns | Review question |
| --- | --- | --- |
| Shared identity | Mascot geometry and wink, gold sparkle, wordmark/orange dot, public-page brand control | Does this read as the same Piik at every size and entry? |
| Product demonstration | Actual components, labels, source selection, room controls and participants with sample data | Does the pictured action still match the current product? |
| Promotional composition | Original scenery, large typography, framing, transitions and optional music | Does the spectacle help a newcomer understand the product and find the next action? |
Use one public-page brand component for the homepage and film. The mascot's
curved wink and gold sparkle are recognizable features; a prop or a pictured
game does not replace its face with another expression. Original RPG scenery
can suggest exploration through terrain, ruins and small discoveries without
borrowing another product's characters, named places, logos or artwork.
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.
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)
owns technical disclosure; a dramatic heading does not exempt copy from it.
Review whole Chinese phrases, English meaning and adjacent number labels in
finished frames, not just the source string. Recheck the smallest viewport and
both languages when changing a title or its timing. Keep enough still time to
read it; do not solve overflow by shrinking an important caption beyond legibility.
Use the [shared visual language](./visual-language.md) for cast, objects,
accessible interaction and motion. This document owns the website's editorial
layout and introduction, not a separate product-status vocabulary.
Each section needs one reader question, evidence that answers it and a clear
next action. A scene should demonstrate one action or benefit before changing
the topic. Keep decorative words and transitions away from control labels and
the primary action; sound and motion must not carry essential instructions alone.
Gameplay reactions follow a visible result: establish the action, show its
success, then celebrate. Keep enough of the play visible to make the reaction
understandable; a genre label or a score burst does not replace the action.
Use mint walls, clear control edges, a warm orange/yellow sofa and the curious TV
mascot. The site is a welcoming room, not a monitoring dashboard. Keep one
primary action, native navigation and short instructions. English is the first
view with an explicit Chinese choice; theme follows the system unless changed.
Respect reduced motion and retain useful content without JavaScript.
The room illustration cycles through four distinct uses: an RPG, drawing,
travel photos and a movie. Treat coding and design as creative work rather than
proliferating overlapping examples. The host holds a gamepad during the game;
the role badge identifies the host independently of the activity.
Keep participant drawings consistent across the site, READMEs,
interface captures and tooltip/status comics.
Present Piik as an open-source project: plain explanations, room for tinkering,
and an invitation to contribute. Keep the friendly illustration and avoid
company-style pitches, repeated slogans or claims about a support team.
The short comics illustrate choosing, inviting and joining, following the
shared motion grammar and whole-control replay rule. The optional film starts
only after an explicit action and has native buttons and a seek slider. Open it
within the homepage, loading its single player on demand; closing it unloads
playback. The standalone page serves recording and direct links. Fullscreen
watching keeps touch controls available; hiding them is an explicit recording action.
Both READMEs share the friendly opening and room illustration. Their feature,
limitation and setup sections use the factual register in the copy guide.
Use a consistent README structure:
a distinct brand opening, bold headings and key phrases, regular explanatory
text, quiet captions and optional detail. Use images and short demonstrations
to explain different things rather than filling space. Keep essential setup and
availability in readable text, and distinguish generated demo content from a
real captured workload. GitHub-native Markdown/HTML must remain useful on mobile
and in both themes without custom CSS.
The product UI remains mascot-only. On the website, keep the header's TV
mascot before the Piik name. The wordmark and mascot do not transform
into one another. Match the product's
playful style through the mascot's wink and short comic interactions. An
original room illustration is shared by the site and READMEs. Interface previews
use generated scenes and sample participants. Keep private invitations,
user identities and personal media out of published assets; no external fonts
or tracking are needed.
## Small implementation
The website and film initially follow the browser's primary language: Chinese
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
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.
Rebuilding the site picks up component/style/copy changes; changes to the actual
workflow still require reviewing the sequence and camera framing. Do not replace
shared controls with a second hand-drawn UI or retain obsolete screenshot plates.
The existing App/Web
continues to own sharing. The homepage explains it rather than simulating a
second room authority. Public copies of advanced product/research documents
are not automatically included in the website artifact.
The [website operations guide](../operations/website.md) owns Pages publication,
public destinations and the separate P2P-only demo. The primary action leads
to the platform download section. Use permanent release-page links for downloads;
archive names include a revision. Verify destinations as part of publication.
Demo, App and existing-site walkthroughs
use real action names and explain the first sharing and viewing steps. Keep
platform requirements and connection limitations beside the relevant steps.
## Review boundary
Review desktop/mobile, English/Chinese, light/dark, keyboard navigation,
reduced motion, no JavaScript and a nested project URL. Check links, accurate
platform/connection claims and a single copy of each responsibility. Remove
decorative controls or code that do not improve understanding. This is a public
introduction overhaul, not an excuse to change configuration or media policy.
+210
View File
@@ -0,0 +1,210 @@
# Visual Language
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
and playback. Neither an illustration nor its colour creates product state.
Use one stroke language for single concepts, small panel comics for causes and
sequences, and literal scene objects for people, rooms and media. Glyphs do not
form sentences. The same meaning and visual hierarchy must work in Chinese,
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 [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.
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.
## Layout And Input
Present a small shared living room: the television is the media stage, the
couch and pawns make presence spatial, and the four-digit room code stays
prominent. Decoration must clarify product facts without creating another state
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.
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
essential value, overlap controls or change meaning.
Shared buttons and chips use pill shapes, a quiet lower edge and a short lift
on hover or keyboard focus, then compress on press. Inputs and option tiles
share softer corners. Keep a distinct focus ring and a persistent selected
state; disabled controls must not pretend to activate. Reduced motion keeps
the colour, outline and selected position without decorative displacement.
## Cast And Objects
The brand mascot keeps its own identity, separate from ordinary participants.
Participant characters should leave room for that distinctive presence.
| Meaning | Representation |
| --- | --- |
| The person using the pictured action | Green pawn (`YOU` / `--you`), in every panel |
| Somebody else | Blue pawn (`SKY`), or another distinct non-green illustration colour |
| Host role | Gold crown above the head: rounded three-point outline, dark gold edge and a quiet lower rim; the standalone absent-Host symbol uses the same outline, unfilled and dashed |
| Shared or watched media | Small television, including antenna, body and feet |
| Browser application | Browser chrome around content; not a second design of television |
| A captured window or display | Window title bar or display stand, with the shared media metaphor kept distinct |
| Server forwarding media | A server on the media path; opening a Site alone does not imply SFU |
These are **identity/object colours**, not status. A green pawn remains green
in a failed scene; a successful scene does not turn other people green. Real
roster identities retain the shared UUID colour function, not the illustrative
"you versus others" palette. Actor positions and identities remain stable from
the first panel to the result.
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,
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
participant identities. Keep the brand mascot distinct.
The couch has a back, seat, arms and feet. A full room reflows into rows of seats
without hiding people or requiring horizontal scrolling. Topology preserves
each participant and actual parent edge at a readable scale; reduce spare
spacing, then use a vertical outline when depth exceeds the available width.
Couch upholstery uses the fixed warm orange/yellow `--couch` / `--couch-dark`
palette. Never draw upholstery from the participant palette or randomize it by
room. Keep automatic participant colours outside the upholstery's amber/orange
range and the crown's gold; check all eight tokens against both objects in light
and dark themes. The crown retains a bright gold fill with a dark gold edge.
Figures and exterior upholstery use flat fills without outline strokes.
Use differences in tone, spacing and name tags to keep people readable; retain
only the few interior seams that clarify cushions. Apply this convention to
the website and README cast as well as the product UI.
## Semantic Colour And Shape
Use the existing theme tokens in `src/client/styles.css`. The shared
`comic-presentation.ts` maps meanings to those tokens for tooltip outlines,
carets, result panels and verdict marks. Do not choose independent hex colours
or animation rules inside an individual scene.
| Meaning | Token / code tone | Panel and symbol |
| --- | --- | --- |
| Explanation, preference, ordinary action, intentional closure or unknown state | Neutral ink / `off` | Neutral panels; illustrate the action/object without a success stamp |
| Confirmed completion or delivered media | Green `--live` / `live` | Neutral before, green result; check or completed object |
| Still usable with a limitation, unavailable optional capability, or intentional media pause | Amber `--warn` / `warn` | Neutral before, amber result; limit, pause, lock or unavailable mark on the affected part |
| Confirmed failed or blocked action | Red `--danger` / `bad` | Neutral before, red result; cross, broken path or other explicit failure mark |
| Work in progress or awaiting an action/frame | Blue `--action` / `busy` | Neutral before, blue current/result panel; connection, progress or play symbol |
Recovery retains amber because service is interrupted while work continues.
Waiting for a Host and unknown observations remain neutral. A control's
prospective action is not proof of success: opening details, switching theme,
adjusting volume or offering P2P stays neutral. Actual copy feedback can change
that same hint to green or red, using the existing operation result.
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.
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.
A missing optional feature is not a failed media path. For example, unavailable
NAT prediction marks only extra candidate paths; ordinary P2P remains a neutral
possibility. A rejected PiP request is failure; lack of PiP support is a limitation.
## Motion Grammar
| Presentation | Rule |
| --- | --- |
| Action demonstration (`demo`) | Play one meaningful transition when shown, then hold its result |
| Work actually continuing (`progress`) | Repeat a gentle connection/recovery beat while that state exists |
| Settled success, failure, limitation, pause or idle (`still`) | Show the informative final pose immediately; no repeated stamping, shaking, blinking or celebration |
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.
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 tooltip panel itself uses the same restrained entrance/exit for every tone.
Replay belongs to opening/hovering/focusing the whole control, not just its SVG.
Ordinary controls keep their icons visible during hover and keyboard focus;
surface colour, lift, focus and press feedback carry the interaction. Reserve
icon drawing/replay for primary actions such as starting a share or joining a
room, and for explicit state-entry feedback. The whole control owns the trigger.
Stroke and solid drawings share one total duration and easing; stroke count
must not lengthen the animation. Disabled controls and passive status icons
retain their informative pose; busy indicators retain their progress beat.
Do not add a per-scene timer or another interaction/state owner. Outside status
comics, the brand mascot and participant figures may blink or glance occasionally;
a pictured screen can guide the Host's gaze. Keep eye gestures and small body
leans brief, separated by long pauses and offset between people. A participant's
UUID seeds the cadence; align its phase to wall time in every view, including
later mounts and return from background. Names, roster order and the observer's
role do not reseed it. Device clock skew bounds cross-device precision; cosmetic
motion adds no signaling or media state. They express personality independently
of connection feedback. Follow the system's reduced-motion preference, keeping a
natural, open-eyed pose when enabled; participant gestures have no separate
product playback control. Status comics still follow the table above.
Introduction artwork is separate from product status. Website/README scenes may
repeat a short action with a generous pause. Keep broad movement to one or two
focal elements; small participant gestures follow the quiet cadence above.
Provide a pause or hide control for automatic loops, and a useful static pose
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. This decoration is hidden from assistive
technology. 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
precedence when space is tight.
## Ownership And Incremental Review
- Existing status/operation facts outrank the scene default. Reused `warning`
artwork can represent a warning or a confirmed failure; pass the actual tone.
- `comic-presentation.ts` owns defaults and semantic tokens; `Frame`, `RedX`,
`rmBlock`, `Comic` and `HintComic` apply the shared grammar. Scenes own only
their cast, causal illustration and final pose. `Tooltip` owns interaction
and placement. Do not duplicate these rules in each scene or page.
- Keep the semantic kind and tone in all languages; localized text changes
expression, not severity. Symbol-only controls retain accessible names.
- 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.
- Update this owner when accepting a new convention. Product documents link
here instead of copying the palette, cast or animation table.
## Interaction References
[Carbon status indicators](https://v10.carbondesignsystem.com/patterns/status-indicator-pattern/)
supports combining semantic colour with shape and symbols;
[Carbon motion](https://carbondesignsystem.com/elements/motion/overview/)
distinguishes functional feedback from occasional expressive movement.
[Web Animations start time](https://developer.mozilla.org/en-US/docs/Web/API/Animation/startTime)
provides the native timeline alignment used by participant gestures.
+133
View File
@@ -0,0 +1,133 @@
# Your first Piik room
English · [简体中文](./getting-started.zh-CN.md) · [Back to Piik](../../README.md)
Piik shares one person's screen with up to 20 invited viewers. This guide covers
watching, trying the online demo, using Piik App and sharing through an existing site.
[Join a room](#join-a-friends-room) · [Online demo](#try-the-online-demo) ·
[Piik App](#share-with-piik-app) · [Existing site](#share-from-your-browser) ·
[Troubleshooting](#when-something-gets-in-the-way)
Viewers need a browser and an invitation. To try sharing, use the online demo
or **Public invite** in Piik App.
If Piik shows icons only, choose **EN** in the header to show the labels
used below. **中** switches to Chinese; **✦** returns to the illustrations.
## Join a friend's room
1. Open the invitation link your friend sent in a desktop or mobile browser.
2. Wait for their screen to appear. If the browser blocks autoplay, press **Play**.
3. Move the pointer over the picture or tap it to show the playback bar.
The playback bar controls only your own picture and sound.
<details>
<summary>Playback controls, at a glance</summary>
| Control | What it does |
| --- | --- |
| **Theater mode** | Expands the picture inside the browser tab. |
| **Fullscreen** | Fills your screen. |
| **Picture in picture** | Opens a floating video where supported. |
| **Volume** | Goes up to 200% with audio boost, or 100% otherwise. Lower it if the sound distorts. |
</details>
With just a four-digit room code, open the same Piik site and choose
**Join a room**. You may need the site's passphrase and the room's password.
The host decides whether code entry is available; ask for an invitation link if
you cannot join by code.
If an old link no longer works, ask the host for the current invitation.
App Local rooms and temporary public invitations end when that App run stops.
## Try the online demo
1. Open [demo.piik.tv](https://demo.piik.tv) in a desktop browser that supports
screen sharing.
2. Select **Start sharing**, choose a screen, window or tab, and enable audio if
needed and offered by the browser.
3. Confirm the preview and select **Copy invite link**. Open that link on a
second device or send it to a friend.
4. Select **Stop sharing** when finished.
The project hosts this site for you. It uses direct media connections (P2P);
restrictive networks may load the page but block video.
## Share with Piik App
1. Download a **`piik-app`** archive for your platform from
[GitHub Releases](https://github.com/TNTcraftHIM/Piik/releases) or the
[Gitee mirror](https://gitee.com/TNTcraftHIM/Piik/releases). Extract the whole
archive and keep its directory structure intact.
2. Open `piik-app.exe` on Windows, `Piik App.app` on macOS, or `./piik-app` on Linux.
The launcher opens in your system browser.
3. Choose **Public invite** for friends outside your local network, then select
**Open Piik**. Other modes are explained below.
4. Choose **Start sharing**, then select **Browser**, **Apps / Windows**, or
**Screens** as available. Pick the actual source and sound option.
5. Copy and send the room invitation. Keep both the App and sharing tab open.
| Filename contains | Platform |
| --- | --- |
| `windows-amd64` | Windows x64 |
| `darwin-arm64` | Apple silicon; native capture requires macOS 13+ |
| `linux-amd64` | Linux x64 |
Windows App and browser sharing are the primary tested paths. The macOS and
Linux apps have not yet been tested on physical devices;
[test results and feedback are welcome](https://github.com/TNTcraftHIM/Piik/issues).
Linux native capture also needs [system components](../../native/capture/linux/README.md).
See the [App guide](../../cmd/piik-app/README.md) for package and runtime details.
### Choose an App mode
| Mode | When to choose it | What to keep in mind |
| --- | --- | --- |
| **Local room** | Friends are on the same local network. | Your computer must be reachable from theirs. A local site passphrase is optional. |
| **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. |
**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
You need an existing HTTPS site running Piik Server, provided by a friend or
administrator. If you do not have one, start with the [demo](#try-the-online-demo)
or [Piik App](#share-with-piik-app). Hosting a site yourself is an advanced option:
follow the [self-hosting guide](../operations/self-hosting.md) for server, HTTPS
and network setup.
1. Open your group's Piik site on your computer. Enter its site passphrase if asked.
2. Choose **Start sharing**. In the browser picker, select the screen, window or
tab you want friends to see, and enable audio if offered.
3. Check the preview, then choose **Copy invite link** and send it to your friends.
4. Keep the sharing tab open. Use **Pause sharing**, **Switch source**, or
**Stop sharing** when you need them.
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.
## 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. |
| 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 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.
Ready to host a site for your group? Follow [self-hosting](../operations/self-hosting.md).
For everything else, use the [documentation map](../README.md).
+122
View File
@@ -0,0 +1,122 @@
# 第一次用 Piik
[English](./getting-started.md) · 简体中文 · [回到 Piik](../../README.zh-CN.md)
Piik 支持一位房主分享屏幕,最多 20 位受邀观众通过浏览器观看。
本指南介绍加入房间、体验在线 Demo、使用客户端,以及通过已有站点分享的方法。
[加入房间](#加入朋友的房间) · [在线 Demo](#体验在线-demo) ·
[Piik App](#用-piik-app-分享) · [已有站点](#用浏览器分享) · [问题排查](#遇到问题了)
观众只需要浏览器和邀请链接。
初次体验分享,可以使用在线 Demo 或客户端的 **公网邀请**。
如果 Piik 只显示图示,点击顶部的 **中** 即可显示下文提到的按钮文字。
**EN** 切换到英文,**✦** 返回图示模式。
## 加入朋友的房间
1. 用电脑或手机浏览器打开朋友发来的邀请链接。
2. 等待画面出现。如果浏览器没有自动播放,点击 **播放**。
3. 将鼠标移到画面上,或轻点画面,显示播放控制栏。
播放控制栏只改变你自己看到的画面和听到的声音。
<details>
<summary>播放按钮分别做什么?</summary>
| 按钮 | 作用 |
| --- | --- |
| **剧场模式** | 在浏览器标签页内展开画面。 |
| **全屏观看** | 让画面铺满屏幕。 |
| **悬浮小窗** | 浏览器支持时,把视频放进独立小窗。 |
| **音量** | 支持增强时最高 200%,否则最高 100%;声音失真时调低一些。 |
</details>
只有四位房间号时,打开同一个 Piik 站点,选择 **加入房间**。
你可能需要站点口令和房间密码。能否通过房间号加入由房主决定;
无法加入时,请朋友发邀请链接。
旧链接失效了,就请房主重新发一份当前邀请。
App 的本地房间和临时公网邀请会随本次 App 退出而结束。
## 体验在线 Demo
1. 在支持屏幕共享的电脑浏览器中打开 [demo.piik.tv](https://demo.piik.tv)。
2. 点击 **开始分享**,选择屏幕、窗口或标签页;如需声音且浏览器提供该选项,启用音频。
3. 确认预览后,点击 **复制邀请链接**,用另一台设备打开,或发送给朋友。
4. 体验结束后,点击 **停止分享**。
演示站由项目提供,画面通过设备间直连(P2P)传输。
在受限网络下,可能出现页面能打开、画面无法连接的情况。
## 用 Piik App 分享
1. 从 [GitHub Releases](https://github.com/TNTcraftHIM/Piik/releases) 或
[Gitee 镜像](https://gitee.com/TNTcraftHIM/Piik/releases)下载以 **`piik-app`** 开头、
对应平台的压缩包,完整解压并保留目录结构。
2. Windows 打开 `piik-app.exe`,macOS 打开 `Piik App.app`,Linux 运行 `./piik-app`。
启动页会在系统浏览器里打开。
3. 邀请不在同一局域网的朋友时,选择 **公网邀请**,再点击 **进入 Piik**。
其他模式的用途见下表。
4. 点击 **开始分享**,按可用选项选择 **浏览器**、**程序 / 窗口** 或 **屏幕**,
然后选定要分享的画面与声音。
5. 复制房间邀请发给朋友。分享期间保持 App 和分享标签页打开。
| 文件名包含 | 适用平台 |
| --- | --- |
| `windows-amd64` | Windows x64 |
| `darwin-arm64` | Apple 芯片;原生采集需 macOS 13 及以上 |
| `linux-amd64` | Linux x64 |
目前主要测试 Windows 客户端和浏览器分享。macOS 和 Linux 客户端尚未经过实机测试,
欢迎有设备的朋友试用并[反馈问题](https://github.com/TNTcraftHIM/Piik/issues)。
Linux 原生采集还需要[系统组件](../../native/capture/linux/README.md)。
程序包与启动参数的细节见 [App 指南](../../cmd/piik-app/README.md)。
### 选择 App 模式
| 模式 | 什么时候用 | 需要知道 |
| --- | --- | --- |
| **本地房间** | 朋友和你在同一个局域网。 | 对方需要能连接到你的电脑,可以设置本地站点口令。 |
| **公网邀请** | 朋友在别处,想临时发个邀请。 | 需要互联网连接;公网地址只在本次 App 运行期间有效。 |
| **连接站点** | 你们已经有一个 Piik 站点。 | 输入站点地址,在原有分享页面使用 App 的采集能力。 |
**网页能打开,不代表视频一定能连通。** 公网邀请为房间提供临时网页地址,
画面和声音仍靠参与者之间的连接传输。此模式没有媒体服务器兜底,
临时地址也不保证持续可用。所有模式都需要可用的 UDP 媒体路径。
具体连接方式见 [App 指南](../../cmd/piik-app/README.md#modes)。
## 用浏览器分享
需要先有一个运行 Piik Server 的 HTTPS 站点,可以使用朋友或管理员提供的地址。
还没有站点时,请先体验[在线 Demo](#体验在线-demo) 或使用[客户端](#用-piik-app-分享)。
自建站点属于进阶操作,服务器、HTTPS 和网络端口的配置步骤见[部署指南](../operations/self-hosting.zh-CN.md)。
1. 在电脑上打开 Piik 站点。如果站点设置了口令,先输入口令。
2. 点击 **开始分享**,在浏览器弹窗里选择屏幕、窗口或标签页;
如需声音且浏览器提供该选项,启用音频。
3. 确认预览画面后,点击 **复制邀请链接** 发给朋友。
4. 保持分享标签页打开。需要时使用 **暂停分享**、**切换来源** 或 **停止分享**。
浏览器屏幕采集需要 HTTPS 站点或 `localhost`。
弹窗里有哪些画面和声音可选,取决于浏览器与操作系统。
拿到邀请链接的人可以进入这个房间,请把它发给你想邀请的人。
## 遇到问题了
| 你遇到的情况 | 可以先试什么 |
| --- | --- |
| 有画面,没声音 | 先取消视频静音。房主应选择支持分享声音的来源;如果开始时关闭了声音,请停止后重新开始并打开声音。通过 App 采集窗口或屏幕时,切换来源会保留原来的声音设置。 |
| 没有屏幕选择弹窗 | 按系统提示允许浏览器或 App 录制屏幕。浏览器采集需要 HTTPS 或 `localhost`,请在电脑上尝试分享。 |
| 本地邀请打不开 | 确认两台设备在同一网络且可以互相访问。访客 Wi-Fi 或防火墙规则可能阻止本地连接。 |
| 网页能打开,画面连不上 | 点击播放控制栏的 **重新连接**;仍然失败时,检查 [WebRTC 连接设置](../../cmd/piik-app/README.md#chromium-webrtc-connections),或尝试有媒体服务器兜底的站点。 |
| 休眠或挂起后分享中断 | 唤醒设备,回到分享标签页,必要时重新开始分享。浏览器或系统挂起可能中断采集和播放。 |
反馈问题时,请附上版本、系统与浏览器、预期结果和复现步骤。
[诊断与导出](../reference/configuration.md#diagnostics)
说明如何收集本地报告,以及分享前需要检查哪些内容。
想为朋友搭一个站点?从 [部署指南](../operations/self-hosting.zh-CN.md) 开始。
其他内容可在 [文档导航](../README.md) 找到。
+79 -52
View File
@@ -1,68 +1,95 @@
# 项目维护与上下文治理
# 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.
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).
仓库是跨 session、设备和 agent 的持久事实源。会影响后续工作的结论必须先进入其权威文档;聊天、agent 摘要、旧分支和临时计划都不能替代仓库真相。
## Owners
常驻上下文只保留每次工作都需要的边界和导航。细节按需读取,完成历史交给 Git。任务范围和交付遵循 `AGENTS.md`,最小正确结果遵循仓库内的 [`ponytail` skill](../.agents/skills/ponytail/SKILL.md);本文件只维护仓库特有的事实治理规则。
## 事实分层
| 内容 | 权威归属 | 维护方式 |
| Topic | Owner | Keep here |
| --- | --- | --- |
| 每次都必须遵守的硬约束和导航 | `AGENTS.md` | 保持短、单义、稳定;不复制流程手册或临时状态 |
| 分支、研究、验证、PR、合并和清理流程 | `CONTRIBUTING.md` | 维护可执行的日常流程 |
| 产品总览和领域导航 | `docs/project-memory.md` | 只保留每次 substantial task 都需要的摘要与链接 |
| 当前生产、里程碑、阻塞项和明细索引 | `docs/status.md` | 只保留 current/index;接近 120 行或 12 KB 时提示园艺审查 |
| 当前未决、已授权或待用户决策的工作 | `docs/todo.md` | 不是完成记录;不把 checkbox、实验或 agent 建议自动升级为 TODO |
| 当前产品合同 | `docs/product/` | 每个领域一个 owner;只写行为、边界和非显然不变量 |
| 架构取舍 | `docs/adr/` | 明确状态;新决定替代冲突旧决定 |
| 可复核证据和研究结论 | `docs/research/` | 标日期、来源、推断、适用边界和许可证 |
| 当前验证与部署边界 | `docs/verification-status.md`、`docs/deployment.md`、`docs/operations/`、`docs/reference/` | 按需读取,不保存发布流水账 |
| 已完成工作的时间线 | commit、PR、issue | 不复制进常驻 memory 或 status |
| 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 |
| 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 |
| 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 |
| 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 |
| Unresolved physical acceptance | `docs/verification-status.md` | Still-relevant evidence gaps across modules |
| Completed history | Git commits and PRs | Changes and outcomes; do not reproduce them in memory or TODO |
每个事实只有一个 owner。顶层文件提供链接和当前结论,不复制深层正文;`docs/README.md` 维护完整文档索引。
Source schemas and existing conformance fixtures own exact interface fields;
package comments own local lock/resource invariants. The engineering reference
links to them instead of copying every field. README and guides explain how to
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.
## 顶级指令卫生
## Updating A Rule
[官方调研](./research/agent-context-governance.md)的共同结论是:顶层指令应是短而稳定的地图,流程和专业细节按需加载,并定期删除冲突或陈旧规则。检查器把官方边界分成推荐线和硬顶:Codex 项目指令链在默认 32 KiB 上限的 80% 提示、到上限失败;Claude 的有效启动指令在官方建议的 200 行提示,并容许到 250 行的项目缓冲;Hermes 在动态下限 20,000 字符的 80% 提示、到下限失败。`CLAUDE.md` 导入的 `AGENTS.md` 计入有效行数;必读的 repo Ponytail skill 使用同一保守包络。
1. Trace the current product contract, actual callers and relevant evidence.
Search hits are leads, not proof of a defect. Separate an implementation
mismatch from a historical decision or a still-unaccepted proposal.
2. Update the existing owner first. Add a new owner only for a distinct topic
with real consumers; give it an entry in the documentation map. Delete
replaced normative text elsewhere and link to the owner.
3. Keep current owners consistent. Update memory/status only when their compact
snapshot materially changes; put unresolved work in TODO. Checkpoint and
integration timing follows CONTRIBUTING, not an automatic extra commit for
every small edit.
推荐线只触发园艺提醒,不是压缩目标。硬顶失败时应把目录专属规则迁到嵌套或 path-scoped context,把流程和参考资料迁到按需 skill/文档,简化重复措辞,并删除已完成历史或陈旧结论;不要为了过闸损失仍有消费者的约束。模块特有规则只有在对应代码存在且每次读取确有必要时,才放到最近目录的 `AGENTS.md`;流程说明进入 `CONTRIBUTING.md`,当前工作进入 `docs/todo.md`,证据与理由进入其 owner。
When semantics are disputed, record the decision needed and hold only dependent
implementation. At handoff or a material phase change, persist accepted rules,
remaining work and actual blockers before relying on a conversation summary.
Do not store tool transcripts, raw logs, temporary paths or repeated completed
checklists as product truth.
After an audit, put accepted constraints in their existing owners and unresolved
work in TODO, then remove the report and handoff bundle. Do not create dated
audit-summary directories or replacement archives of completed work.
## 真相更新顺序
Published copy describes Piik's behavior and design directly. Remove discarded
design labels, private operational details and conversational comparisons to
other products. Retain primary technical evidence and required attribution or
license notices; rewriting prose must not obscure the origin of reused work.
1. 按 [`AGENTS.md`](../AGENTS.md) 的 authority 和 scope 规则,对照完整产品模型、当前代码、测试和证据确定目标;语义未定时只冻结依赖项。
2. 就地更新受影响的 product、ADR 或 research owner;仅在快照确实变化时同步 memory/status,未决执行项进入 TODO。
3. 检查各 current owner 一致并形成 Git-tracked truth checkpoint,再开始依赖该结论的实现或派发。
## Consolidation And Ablation
语义仍未决定时,在 TODO 记录 hold,只冻结依赖项。具体 Git、worktree、checkpoint 和 integration 流程由 `CONTRIBUTING.md` 拥有。
Keep one owner per fact, with short summaries where navigation needs them.
Consolidation is not concatenating documents into one giant manual. Remove
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).
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.
机制减负遵循 [`ponytail`](../.agents/skills/ponytail/SKILL.md) 的最小正确结果原则,范围由 `AGENTS.md` 约束。本仓库的审查先只读清点机制服务的用户行为,量化移除后的真实退让,并与代码、配置、迁移、测试、运行时状态、部署和故障面的整体成本比较。文档也是需要同步和审查的机制:长期真相只保留产品合同、非显然不变量、算法/模型、关键取舍和外部证据,普通 UI 与实现细节由代码、测试和 PR 持有。若 owner 接受有界退让,先更新产品合同再删除失去必要性的完整表面;否则保留合同并寻找更小实现。审查不以行数为目标,也不授权无边界重构。
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 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.
TURN 的减负证据由 [ADR-0005](./adr/0005-automatic-hybrid-media-routing.md) 和相关 transport research 持有;轻量/SQLite 房间取舍由 [ADR-0002](./adr/0002-memory-resident-protected-rooms.md) 持有。这些案例只证明应按当前消费者重审完整成本,不能被推广为永远删除或永远保留某类机制的规则。
## Checks
## 检查点与恢复
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
owners for both hygiene entry points. No additional checker is needed for each
new convention.
在需求或优先级被接受、设计或研究结论改变、实质阶段切换、分支/PR/agent 交接、可能 compaction 或长暂停前更新事实 owner。不要为每个函数、工具调用或未形成结论的探索建立检查点。
检查点按 [`AGENTS.md`](../AGENTS.md) 的 durable-truth 规则,只保存以后仍需要的结论、当前状态、下一步和真实阻塞;不保存原始对话或工具输出、完整日志、临时路径、可廉价重查的事实和过程性 TODO 流水账。
恢复上下文时依次读取 `AGENTS.md`、project memory、status、TODO、相关 product/ADR/research,然后检查当前分支、`git status`、近期 commit 和 PR。摘要与仓库冲突时,以当前代码、测试和权威文档为准。
## 清理规则
- `project-memory.md` 和 `status.md` 接近预算时,先删除可从代码或 Git 恢复的完成/失效内容,再把仍需保留的细节迁到现有 owner;不要反复微压缩句子。
- `todo.md` 只保留真实未完工作、明确 hold 和待用户决策,不保留完成清单或一次性审计过程。
- `verification-status.md` 只保留仍影响验收的跨模块证据;成熟结论迁回 product、ADR、research 或 operations,过期结果删除。
- Product 合同不追加确认流水;研究失效时更新正文及最后验证日期;只有仍有解释价值的历史取舍进入 ADR。
- 每个重要 PR 检查 current 文档是否互相矛盾、链接是否有效、memory/status/TODO 是否仍代表现在。
## 验证与自动化
- 迭代中运行覆盖当前改动的最小定向检查;完整套件、真实浏览器矩阵、网络整形、跨设备、长时稳定性和部署检查在相关候选的验收边界批量运行。
- 文档改动默认运行本地 Markdown 链接、whitespace、diff 和 repository-hygiene 检查;不因无关文档变化重跑媒体基准。
- 断链、缺失必需 owner 和格式损坏是 hard failure。每次注入的有效 `AGENTS.md`/`CLAUDE.md` 上下文与必读的 repo Ponytail skill 使用“推荐线 warning、硬顶 failure”,避免把建议值误当死线,也避免超过工具加载边界。Product、ADR、research、operations 等按需文档的行数/字节阈值始终只是 warning:它提示检查是否应拆分二级 owner、删除历史或移出过程信息,不要求为通过门禁压缩合理正文,也不是交付质量指标。
- 昂贵证据记录 commit、环境、结果和适用边界。只有相关路径、协议、配置、依赖、环境或门槛改变,或旧证据失败/含糊时才重跑。
- `.githooks/pre-commit`、`scripts/check-project-state.sh` 和 PowerShell 等价入口共享 `scripts/required-project-paths.txt`。Hook 只做快速、确定、可复现的检查;工程判断留给评审和测试。
At a meaningful integration boundary check that current documents agree, links
resolve, and memory/status/TODO still describe what remains. Expensive evidence
retains its measured revision, environment and limits; rerun it when a relevant
contract, dependency, platform, workload or unexplained failure changes.
+94
View File
@@ -0,0 +1,94 @@
# GitHub Repository And Releases
[Versioning](../reference/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.
## Main And Pull Requests
Use squash merging only, with the PR title and body as the squash commit. These
repository settings are applied. Keep automatic branch deletion off: the normal
cleanup step checks local worktrees and remaining references first.
Protect `main` with PRs required, zero mandatory peer approvals for the solo
maintainer, administrator enforcement, linear history, and no force pushes or
deletions. Do not require cloud PR checks while branch/PR CI is intentionally
quiet; local validation and acceptance precede the squash merge. PR/issue
templates live in `.github/`; [naming](../../CONTRIBUTING.md#commit-pr-and-branch-names)
is shared with automatic version selection.
These protections are active, including conversation resolution. GitHub Pages
uses the **GitHub Actions** publishing source and the `piik.tv` custom domain;
the repository homepage points to `https://piik.tv`. The dedicated Website
workflow publishes the static site under [website operations](./website.md).
## Enable Automatic Publication Once
Before enabling, accept the release packages, compatibility baseline, public
copy and repository visibility; finish main protection. Then set repository
Actions variable `PIIK_RELEASES_ENABLED` to `true`. This is a repository setting,
not an environment variable for Piik App or Server.
Subsequent accepted PRs merged to main use the existing CI workflow: calculate
one version, validate, package Server and all App targets, and publish the same
artifacts. Runs queue rather than overlap or cancel one another. Branches/PRs
stay quiet; manual `client_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
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)
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
full revision to `scripts/publish-release.mjs --dry-run` (put the flag after
the three positional arguments). An omitted candidate version keeps the usual
development/exact-tag identity.
Enable GitHub's built-in release immutability for formal publication where
available. Upload all assets to the draft before publishing. A failed upload can
resume its same-source draft; a published version is never clobbered. If its
files are wrong or incomplete, publish a corrected new version. An older retry
must not replace a newer release's `latest` designation.
To stop future automatic publication, remove/set false the activation variable;
this does not alter existing releases or a currently running workflow. Inspect
the active run separately. No pipeline step changes visibility or installs an
update into a running Piik deployment.
## Gitee Download Mirror
[TNTcraftHIM/Piik](https://gitee.com/TNTcraftHIM/Piik) is a public README/Release
mirror. Source, documentation and issues stay on GitHub. The publishing job uses
the repository Actions secret `GITEE_TOKEN`, which is configured; rotate it there
when needed. Never put it in source, a remote URL or a distributed package.
GitHub publication continues to use the workflow's scoped token.
After GitHub publication, the same job runs `scripts/mirror-release.mjs` on the
original artifacts. Upload failure leaves the Gitee release marked as a preview.
Rerun the failed publishing job to verify existing files and upload only missing
ones. GitHub's published release remains unchanged. Both publishers reject
changing a published package; use a corrected new version if its bytes are wrong.
If the original Actions artifacts have expired, download that release's files
from GitHub into an external working directory, then run:
```sh
gh release download v1.0.0 --repo TNTcraftHIM/Piik --dir /tmp/piik-mirror
node scripts/mirror-release.mjs /tmp/piik-mirror v1.0.0 FULL_SOURCE_SHA
```
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)
owns provenance, selection and quota limits. Small-file upload/download acceptance
has passed; full-package mirror acceptance remains part of release readiness.
## Platform References
- [Protected branch availability](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches)
- [Workflow concurrency and queued runs](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#example-queueing-multiple-pending-runs)
- [Immutable release workflow](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases)
+68 -201
View File
@@ -1,226 +1,93 @@
# Self-Hosting Operations
# Deploy Piik Server
This runbook owns initial single-host service setup. Application releases use
[deployment](../deployment.md); accepted environment values and ports are in
[configuration reference](../reference/configuration.md).
English · [简体中文](./self-hosting.zh-CN.md) · [Documentation](../README.md)
## Embedded Media Configuration
Piik Server packages the web interface, room management and optional media
forwarding in **one server binary**. Extract and run it; room data is stored in SQLite.
The current source embeds STUN and SFU in the Piik process under
[ADR-0013](../adr/0013-embedded-node-local-media.md). Hosted `STUN_URLS`
advertises ordinary discovery and enables the UDP 3478 listener on
`STUN_LISTEN_HOST` (IPv4, default `0.0.0.0`). Enabling
`NAT_PREDICTION_ENABLED` also binds UDP 3479/3480 on that address. Local and
public-link App modes use discovery without creating media-service listeners.
## Try it locally
Set `SFU_UDP_PORT=7882` to enable automatic embedded SFU fallback. Its IPv4 bind
address is `SFU_LISTEN_HOST`, default `0.0.0.0`; `SFU_PUBLIC_IP` optionally
overrides the advertised IPv4 address when the host is behind NAT. Unset or
blank `SFU_UDP_PORT` keeps SFU disabled. SFU control uses authenticated Piik
signaling through the Web origin. No separate media-service executable, control
origin, or API credentials are needed by this source contract.
These server instructions use a Linux x64 machine.
These templates describe the embedded deployment. Its first cutover requires
the active-session check, matching artifacts and public media verification below.
## Single-Host Topology
```text
Browser -- HTTPS/WSS --> nginx :443 --> Piik :8787
Browser <---------- DTLS-SRTP P2P ----------> Browser
Browser -------- ordinary STUN/UDP --------> Piik :3478
Browser -------- optional STUN/UDP --------> Piik :3479/3480
Browser <--------- DTLS-SRTP/UDP ----------> Piik :7882
```
The public baseline uses one Piik Go binary, nginx, a valid Web TLS
certificate, and time synchronization. nginx terminates Web HTTPS/WSS and
proxies only to Piik's private application listener; media UDP reaches the
Go process directly. There is no separate SFU token endpoint or TCP 7880 service.
Web and STUN may use different DNS names on the same public IP. Another proxy or
firewall owner needs its own matching updater and recovery checks.
Expose only the public listeners in
[configuration reference](../reference/configuration.md). Piik TCP 8787 stays
private. STUN answers Binding requests only; TURN allocations remain unavailable.
## Templates
- [Piik systemd unit](../../deploy/systemd/piik.service.example)
- [nginx site](../../deploy/nginx/share.bonfire.icu.conf.example)
Copy templates outside the repository, inject independent secrets through an
access-restricted environment file or service manager, and adapt only addresses,
domains, certificates, users, and resource limits required by the host.
## Initial Setup
1. Configure DNS and TLS for the Web origin and STUN name.
2. Install nginx. The Piik release is one static binary and needs no
language runtime or separate media-service executable.
3. Create an unprivileged `piik` service account, `/opt/piik/releases`,
`/opt/piik/uploads`, and an access-restricted environment file.
4. Provision one verified initial immutable release and atomically point
`/opt/piik/current` to it before enabling the unit. The tracked release
wrapper is upgrade-only and deliberately refuses a missing prior release;
this repository does not provide a generic first-install transaction.
5. SQLite is the Hosted default. The service template selects
`/var/lib/piik/rooms.sqlite` in its service-owned StateDirectory; retain
that absolute path in the environment file and include it in the host's
backup/recovery policy. Use `ROOM_DATABASE_PATH=:memory:` only for intentional
process-only rooms. Do not leave an empty override in a service environment.
6. Install the tracked service and proxy templates. Bind HTTP to loopback on
bare metal; STUN and SFU retain their independent public IPv4 binds.
Containers may bind HTTP to `0.0.0.0` only when publishing/firewall rules
preserve the private application boundary.
7. Apply the public-port allowlist and confirm every retired TURN/TCP port
is closed.
8. Start Piik, then nginx. Piik binds its configured media listeners
before accepting signaling and closes them with the application; a listener
bind failure rolls back startup. Verify public UDP reachability separately.
## Optional Container
The [runtime-only Dockerfile](../../deploy/container/Dockerfile) consumes the
existing linux/amd64 Server release; it does not rebuild Go or Browser assets.
Its pinned [Distroless static non-root base](https://github.com/GoogleContainerTools/distroless)
includes CA certificates for HTTPS release checks and has no shell or package
manager. This is an optional recipe, not a published image or automatic installer.
On the local Linux Docker runtime, non-root/read-only startup, both persistence
modes, diagnostics export, STUN Binding and media listener cleanup pass. That
check covers the container lifecycle, not public-network SFU media or the host's
proxy/firewall configuration; verify those on the target deployment.
Verify the release archive against its descriptor and manifest using the
[release procedure](../deployment.md), then extract its four files into a new
build-context directory: `piik-server`, `LICENSE`, `THIRD-PARTY-NOTICES.txt`
and `REVISION`. Use that directory, not the repository or a secrets directory:
Download the **Linux x64 Server** archive from [GitHub Releases](https://github.com/TNTcraftHIM/Piik/releases)
or the [Gitee mirror](https://gitee.com/TNTcraftHIM/Piik/releases),
extract it, and run the following in its directory:
```sh
docker build --platform linux/amd64 \
-f /path/to/Piik/deploy/container/Dockerfile \
-t piik:<full-revision> /path/to/extracted-runtime
docker run --rm --env-file /path/to/piik.env \
piik:<full-revision> --check-config
./piik-server
```
Use the existing [production configuration](../reference/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
are not Internet-reachable. Advertised STUN names must resolve to the server's
public address, and UDP must reach the container directly.
Open `http://localhost:8787`. This is a local trial; use the configuration below
to let friends connect over the internet. To start a temporary room on your computer,
use the [Piik App guide](../guide/getting-started.md).
The following example publishes the standard listeners and keeps HTTP private
to a proxy running on the same host. Omit UDP publish options for disabled SFU
or auxiliary STUN listeners; use the matching port if configuration changes it.
## Put it online
You need a Linux x64 server and a domain pointing to its public IP.
The example uses `share.example.com`; replace it with your domain.
### 1. Configure and start Piik
Create a `.env` file next to the binary:
```dotenv
PIIK_ENV=production
LISTEN_HOST=127.0.0.1
PUBLIC_BASE_URL=https://share.example.com
STUN_URLS=stun:share.example.com:3478
MAX_VIEWERS_PER_ROOM=20
SITE_ACCESS_PASSWORD=
```
Run these commands from that directory as a regular user:
```sh
docker run -d --name piik --restart unless-stopped --stop-timeout 20 \
--read-only --cap-drop ALL --security-opt no-new-privileges \
--env-file /path/to/piik.env \
--mount type=volume,source=piik-data,target=/home/nonroot \
-e ROOM_DATABASE_PATH=/home/nonroot/rooms.sqlite \
-p 127.0.0.1:8787:8787/tcp \
-p 3478:3478/udp -p 3479:3479/udp -p 3480:3480/udp -p 7882:7882/udp \
piik:<full-revision>
./piik-server --check-config
./piik-server
```
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 the operator's responsibility. Do not mount application files writable.
With diagnostics enabled, `docker kill --signal=USR1 piik` requests a local
export without stopping the container; the ZIP remains in the mounted log directory.
The server reads `.env` automatically and stores rooms in `rooms.sqlite` in the
working directory. A blank `SITE_ACCESS_PASSWORD` allows
entry without a site passphrase; set 8–128 visible ASCII characters to require
one. Room invitations and access settings still apply.
Check `/healthz` from the host and public proxy, then verify configured STUN/SFU
UDP and room/media behavior using [operational verification](#operational-verification).
There is no shell-based healthcheck in the image. Retain the previous image,
environment and volume backup before replacement. Stop the old container before
reusing its listeners or SQLite volume, and recreate with the previous image and
environment if verification fails. The bare-metal `release-app.sh` wrapper does
not manage containers. The first move from external media services still follows
the coordinated cutover below; this recipe changes no proxy or firewall itself.
### 2. Enable HTTPS
## Coordinated Embedded-Media Cutover
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:
Replacing the external STUN/SFU services, including Node-to-Go where still
needed, is one explicitly authorized infrastructure and protocol transaction.
The routine application wrapper cannot perform or recover it.
```caddyfile
share.example.com {
reverse_proxy 127.0.0.1:8787
}
```
1. Prepare and verify matching Web/Server signaling and native protocol v9
App artifacts. Check active sessions and obtain the owner's acceptance of
share interruption before cutover; old pages and Apps must reload or
update together.
2. Retain the exact prior release path, Piik unit and drop-ins, environment,
nginx configuration, and external media-service units, configuration, secrets,
and enabled/active state for rollback. Keep this material access-restricted
outside the repository. Define recovery for any persistent-state or firewall
surface the transaction actually changes.
3. Stage the current unit with
`ExecStart=/opt/piik/current/piik-server` and
`PIIK_ENV=production`. Remove retired `NODE_ENV`, `PEER_ASSISTED_MEDIA`
and external `LIVEKIT_*` configuration; use the current
[environment contract](../reference/configuration.md), including
`SFU_UDP_PORT=7882` when fallback is enabled. Replace route `NODE_DEBUG` with
`PIIK_DEBUG=route` when needed. Validate the new binary's production
configuration as the service user before switching.
4. Stop Piik and the old coturn/LiveKit services. Disable those old media
services and remove the installed Piik LiveKit-readiness drop-in so they
cannot reclaim ports or delay startup. Verify that UDP 3478/3479/3480/7882
are released before the new Go process binds them. Install the current unit,
environment and nginx template, remove the old RTC proxy locations, validate
nginx, and reload systemd. Preserve unrelated proxy headers, limits, TLS,
asset handling, and firewall rules.
5. Atomically point the current release at the verified candidate, start
Piik, and reload nginx. Verify the actual unit command and environment,
local/public health, the matching Web asset, UDP listener ownership, public
STUN Binding and admitted SFU media, and the agreed active-session outcome.
6. On failure, stop the new Go process and confirm it released its UDP sockets.
Restore the exact prior application path, unit/drop-ins, environment, proxy
and changed infrastructure configuration; reload systemd and validate nginx.
Restore the previous external service states before starting the previous
application with its matching unit, then prove health and media. Restore
matching pages and Apps as part of protocol rollback. A symlink-only
rollback cannot recover this transaction.
Reload Caddy. It obtains and renews the certificate automatically when DNS points
to the server and TCP 80/443 are reachable. See [Caddy's HTTPS proxy guide](https://caddyserver.com/docs/quick-starts/reverse-proxy#https).
Retain the recovery material through the agreed acceptance window. This is a
single replacement contract with no permanent legacy aliases or migration mode.
Later application-only releases use the ordinary
[release wrapper](../deployment.md#atomic-cutover).
### 3. Open the ports and verify
## Optional NAT Prediction
Allow **TCP 80/443** for HTTPS and **UDP 3478** for STUN in the server firewall
and cloud security group. Keep TCP 8787 private. The STUN hostname must resolve
directly to the server; a CDN HTTP proxy does not forward its UDP traffic.
Allow UDP 3479 and 3480 in both the cloud security group and the existing
nftables input rule, validating the complete ruleset with
`nft -c -f /etc/nftables.conf`. Set `NAT_PREDICTION_ENABLED=true` and restart
Piik within an accepted service window. Verify `ss -lunp` shows all three
Piik STUN listeners and that a public Binding request succeeds on each.
Do not add auxiliary URLs to `STUN_URLS`; enabled connections derive them from
the ordinary STUN authority. Keep both firewall rules while the capability is
enabled. Set `NAT_PREDICTION_ENABLED=false` and restart Piik before removing
the auxiliary firewall rules. Avoid verbose STUN/ICE logging because those
requests contain client IP addresses.
Open `https://share.example.com/healthz`; it should return `{"status":"ok"}`.
Then open the site, share a screen and join from another device. This initial
configuration uses P2P media, so participants need a usable UDP path.
These same-IP auxiliary ports measure destination-port mapping behavior. They
are not a full RFC 5780 alternate-address deployment; that requires a second
public IPv4.
## Optional: media fallback
## Operational Verification
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.
- `/healthz` returns `{"status":"ok"}` locally and through the public origin.
- Piik and nginx are active with expected restart counts and bounded
logs/resources; Piik owns the configured STUN/SFU UDP listeners.
- The official Trickle ICE sample obtains an ordinary UDP `srflx` candidate from
the configured STUN service and no relay candidate.
- A direct room presents media on two networks when Peer ICE succeeds.
- A constrained route creates one managed Host publication and only admitted
subscriptions over embedded SFU UDP; Peer descendants remain ordinary P2P.
- Blocking all UDP reaches a clear bounded failure.
- Restarting Piik in stable mode retains room authority while Browser
sessions and media reconnect from fresh process state.
## Keep it running and update
Representative network, mobile, audio, 20-Viewer, and long-running evidence is
tracked in [verification status](../verification-status.md), not in this runbook.
For automatic startup, use the [systemd or container guide](./service-management.md).
Keep `.env` and `rooms.sqlite` across updates. Back up room data while the server is
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) ·
[Maintainer release tooling](../deployment.md)
+87
View File
@@ -0,0 +1,87 @@
# 部署 Piik Server
[English](./self-hosting.md) · 简体中文 · [文档导航](../README.md)
Piik Server 将网页、房间管理和可选的媒体转发打包在**一个服务端程序**中。
解压后即可运行,房间数据保存在 SQLite 中。
## 先在本机试用
以下服务端操作在 Linux x64 环境中执行。
从 [GitHub Releases](https://github.com/TNTcraftHIM/Piik/releases) 或
[Gitee 镜像](https://gitee.com/TNTcraftHIM/Piik/releases)下载 **Linux x64 服务端**程序包,
解压后,在该目录运行:
```sh
./piik-server
```
打开 `http://localhost:8787` 即可试用。需要让朋友通过互联网访问时,
继续完成下方配置。如果只想在自己的电脑上临时开房间,可直接使用 [Piik App](../guide/getting-started.zh-CN.md#用-piik-app-分享)。
## 对外提供服务
准备一台 Linux x64 服务器,以及一个指向服务器公网 IP 的域名。
下文以 `share.example.com` 为例,请替换成自己的域名。
### 1. 配置并启动 Piik
在程序旁新建 `.env` 文件:
```dotenv
PIIK_ENV=production
LISTEN_HOST=127.0.0.1
PUBLIC_BASE_URL=https://share.example.com
STUN_URLS=stun:share.example.com:3478
MAX_VIEWERS_PER_ROOM=20
SITE_ACCESS_PASSWORD=
```
使用普通用户,在该目录执行:
```sh
./piik-server --check-config
./piik-server
```
程序会自动读取 `.env`,并将房间数据保存在工作目录的 `rooms.sqlite` 中。
`SITE_ACCESS_PASSWORD` 留空时,进入站点无须口令;
如需设置口令,可填写 8–128 个可见 ASCII 字符。房间邀请与加入权限仍由房主管理。
### 2. 配置 HTTPS
如果已有 HTTPS 反向代理,将请求转发到 `127.0.0.1:8787`,并启用 WebSocket 支持。
如果还没有,可以[安装 Caddy](https://caddyserver.com/docs/install),在 Caddyfile 中加入:
```caddyfile
share.example.com {
reverse_proxy 127.0.0.1:8787
}
```
重新加载 Caddy。域名解析正确且 TCP 80/443 可访问时,它会自动申请和续期证书。
具体操作见 [Caddy 的 HTTPS 代理说明](https://caddyserver.com/docs/quick-starts/reverse-proxy#https)。
### 3. 放行端口并检查
在服务器防火墙和云平台安全组中放行 **TCP 80/443**(HTTPS)和 **UDP 3478**(STUN)。
TCP 8787 仅供本机反向代理访问。STUN 域名需要直接解析到服务器,不能只经过 CDN 的 HTTP 代理。
打开 `https://share.example.com/healthz`,应返回 `{"status":"ok"}`。
随后打开站点,分享一个画面,并用另一台设备加入验证。
上述基础配置使用 P2P 传输,参与者之间需要可用的 UDP 通路。
## 可选:启用媒体兜底
在 `.env` 中添加 `SFU_UDP_PORT=7882`,放行 UDP 7882,再重启 Piik,即可启用自动 SFU 兜底。
如果服务器处于 NAT 后方,还需将 `SFU_PUBLIC_IP` 设置为外部可达的公网 IPv4 地址。
这项功能由同一个服务端程序提供。
## 长期运行与更新
需要开机启动时,参阅 [systemd 与容器配置](./service-management.md)。
更新时保留 `.env` 和 `rooms.sqlite`:停止服务、备份房间数据、替换新版程序,再启动并检查健康状态和房间访问。
涉及数据格式变化的版本,请先阅读发布说明。
[全部配置与端口](../reference/configuration.md) · [维护者发版工具](../deployment.md)
+104
View File
@@ -0,0 +1,104 @@
# Service Management
For first installation, use the [short self-hosting guide](./self-hosting.md).
This reference covers a persistent Linux service and the optional container recipe.
## systemd
The [service template](../../deploy/systemd/piik.service.example) runs as the
`piik` user, reads `/etc/piik/piik.env`, and starts
`/opt/piik/current/piik-server`. Create the service account and place the extracted
release at that path, or point `current` at its release directory. Copy your
configuration to `/etc/piik/piik.env` and set
`ROOM_DATABASE_PATH=/var/lib/piik/rooms.sqlite`; the unit creates this state directory.
Keep the environment file readable only by its operator.
Install the template as `/etc/systemd/system/piik.service`, then run:
```sh
sudo systemctl daemon-reload
sudo systemctl enable --now piik
sudo systemctl status piik
```
Keep HTTP bound to loopback when the HTTPS proxy runs on the same host.
STUN and optional SFU use their own public UDP listeners. An existing nginx
installation can use the [proxy example](../../deploy/nginx/piik.conf.example)
after adapting the domain and certificate paths.
## Container
The [runtime-only Dockerfile](../../deploy/container/Dockerfile) consumes the
existing linux/amd64 Server release; it does not rebuild Go or Browser assets.
Its pinned [Distroless static non-root base](https://github.com/GoogleContainerTools/distroless)
includes CA certificates for HTTPS release checks and has no shell or package
manager. This is an optional recipe, not a published image or automatic installer.
Verify the release archive against its descriptor and manifest using the
[release procedure](../deployment.md), then extract its four files into a new
build-context directory: `piik-server`, `LICENSE`, `THIRD-PARTY-NOTICES.txt`
and `REVISION`. Use that directory, not the repository or a secrets directory:
```sh
docker build --platform linux/amd64 \
-f /path/to/Piik/deploy/container/Dockerfile \
-t piik:<full-revision> /path/to/extracted-runtime
docker run --rm --env-file /path/to/piik.env \
piik:<full-revision> --check-config
```
Use the existing [production configuration](../reference/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
are not Internet-reachable. Advertised STUN names must resolve to the server's
public address, and UDP must reach the container directly.
The following example publishes the standard listeners and keeps HTTP private
to a proxy running on the same host. Omit UDP publish options for disabled SFU
or auxiliary STUN listeners; use the matching port if configuration changes it.
```sh
docker run -d --name piik --restart unless-stopped --stop-timeout 20 \
--read-only --cap-drop ALL --security-opt no-new-privileges \
--env-file /path/to/piik.env \
--mount type=volume,source=piik-data,target=/home/nonroot \
-e ROOM_DATABASE_PATH=/home/nonroot/rooms.sqlite \
-p 127.0.0.1:8787:8787/tcp \
-p 3478:3478/udp -p 3479:3479/udp -p 3480:3480/udp -p 7882:7882/udp \
piik:<full-revision>
```
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 the operator's responsibility. Do not mount application files writable.
With diagnostics enabled, `docker kill --signal=USR1 piik` requests a local
export without stopping the container; the ZIP remains in the mounted log directory.
Check `/healthz` from the host and public proxy, then verify configured STUN/SFU
UDP and room/media behavior using [operational verification](#operational-verification).
There is no shell-based healthcheck in the image. Retain the previous image,
environment and volume backup before replacement. Stop the old container before
reusing its listeners or SQLite volume, and recreate with the previous image and
environment if verification fails. The bare-metal `release-app.sh` wrapper does
not manage containers. This recipe changes no proxy or firewall itself.
## Operational Verification
- `/healthz` returns `{"status":"ok"}` locally and through the public origin.
- Piik and the HTTPS proxy are active with expected restart counts and bounded
logs/resources; Piik owns the configured STUN/SFU UDP listeners.
- The official Trickle ICE sample obtains an ordinary UDP `srflx` candidate from
the configured STUN service and no relay candidate.
- A direct room presents media on two networks when Peer ICE succeeds.
- A constrained route creates one managed Host publication and only admitted
subscriptions over embedded SFU UDP; Peer descendants remain ordinary P2P.
- Blocking all UDP reaches a clear bounded failure.
- Restarting Piik in stable mode retains room authority while Browser
sessions and media reconnect from fresh process state.
Representative network, mobile, audio, 20-Viewer, and long-running evidence is
tracked in [verification status](../verification-status.md), not in this runbook.
+93
View File
@@ -0,0 +1,93 @@
# 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
its content and design boundary.
## Public Destinations
| Entry | Destination |
| --- | --- |
| Website | [piik.tv](https://piik.tv) |
| 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) |
The introduction is prepared with these links. DNS, deployment and release
publication must be verified when publishing.
GitHub Pages can serve the complete website: it hosts
[static HTML, CSS and JavaScript](https://docs.github.com/en/pages/getting-started-with-github-pages/what-is-github-pages).
The demo also needs the Go service for room authority, WebSocket signaling and
UDP STUN, so it runs on the separate US server rather than Pages.
## Public Demo Configuration
The chosen demo allows entry without a site password and provides P2P sharing
without SFU fallback. Use the [self-hosting runbook](./self-hosting.md) with:
```dotenv
PIIK_ENV=production
LISTEN_HOST=127.0.0.1
PORT=8787
PUBLIC_BASE_URL=https://demo.piik.tv
ALLOWED_ORIGINS=https://demo.piik.tv
SITE_ACCESS_PASSWORD=
ROOM_DATABASE_PATH=/var/lib/piik/rooms.sqlite
MAX_VIEWERS_PER_ROOM=20
STUN_URLS=stun:demo.piik.tv:3478
NAT_PREDICTION_ENABLED=true
SFU_UDP_PORT=
```
Point `demo.piik.tv` directly at the US server so UDP reaches its STUN listeners;
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)
owns each setting and its bounds.
For updates, verify the release descriptor and manifest before placing a new
release under `/opt/piik/releases/`. Stop Piik, back up its SQLite state, switch
`current` to the verified release, and restart. Check local/public health,
WebSocket room access and STUN. Keep the previous release and state backup until
acceptance; restore both with Piik stopped if rollback is needed. The nginx-specific
maintainer wrapper does not manage this Caddy installation.
## Preview
With the repository's development dependencies installed:
```sh
npm run build:website
npm run preview:website
```
Open `http://127.0.0.1:18890`. Rebuild after editing source. The build bundles the
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.
## Publish when approved
1. Verify the public copy and destinations above, publish the release artifacts
and open the source repository as part of the approved public launch.
2. In repository **Settings > Pages**, choose **GitHub Actions** as the source.
Use the dedicated Pages workflow, not publication of the repository root or
all of `docs/`, which includes developer research and operational reference.
3. Set the approved `piik.tv` custom domain in Pages settings and configure its
DNS using GitHub's [current domain instructions](https://docs.github.com/en/pages/configuring-a-custom-domain-for-your-github-pages-site/managing-a-custom-domain-for-your-github-pages-site).
Actions publishing does not need a `CNAME` file in the source tree.
4. Dispatch **Website** from `main` after the reviewed site is merged. The
workflow builds and uploads only `build/site/`; no App/Server packaging is run. Verify the
domain, HTTPS, both languages, relative asset paths and primary links.
There is no automatic deployment on branch pushes. The workflow is prepared,
but writing it does not enable Pages or change DNS. The private production
site and the US P2P-only demo remain separate deployments.
For rollback, dispatch the previously accepted site revision from a reviewed
revert on `main`; runtime App/Server binaries and databases are unaffected.
+2 -1
View File
@@ -4,7 +4,8 @@ This file owns the current Browser and App media contract. Detailed measurements
platform limits live in [realtime quality research](../research/realtime-quality-adaptation.md)
and [screen-audio research](../research/browser-screen-audio-quality.md).
[ADR-0007](../adr/0007-path-isolated-representation-quality.md) owns Browser
source intent and codec selection; [ADR-0013](../adr/0013-embedded-node-local-media.md)
source intent and codec selection, [ADR-0008](../adr/0008-window-scoped-audio-capture.md)
owns screen-audio scope, and [ADR-0013](../adr/0013-embedded-node-local-media.md)
owns the current candidate's shared Native/SFU output model. This file describes
that source contract. [Status](../status.md) separates it from the last known
production behavior and remaining acceptance.
+98 -43
View File
@@ -11,6 +11,13 @@ available, starting a share and joining a room are equally discoverable actions;
joining expands the four-digit room form in place, while `/join` remains a direct
entry route. Users never choose a media topology.
Host entry requires both site access and a validated capability description.
Initial entry, retry and password recovery load the same prerequisites; failed
authentication cannot leave a later successful entry using absent capability
state. A capability-load failure remains retryable and does not imply that a
service is available or disabled. Viewer/Join entry does not need this Host-only
description.
The Host workspace centers the capture preview, room code, invitation and common
share controls. The Viewer workspace centers one persistent 16:9 video stage,
room identity, current state, roster, and one manual media-reconnect action.
@@ -25,6 +32,13 @@ 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.
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;
disabled controls explain their reason in text and pure-visual modes. Real
workflow differences remain explicit: site authorization, opt-in diagnostics
and the capture/audio capabilities of the connected device are not alternate
versions of the product UI.
A remembered App activation permits discovery when choosing a source or
receiving media; it does not make App a prerequisite for sharing. Idle Host
pages hold no App control session. Ordinary Web entry opens the Browser picker
@@ -38,47 +52,69 @@ retire another operation's media. Optional native ingress uses an already
available connection without another scan. If it is not ready at Browser
startup, that share keeps Browser senders.
A reachable Piik App with an incompatible control protocol is distinct from an
absent App. Discovery continues looking for a compatible process before reporting
the mismatch; the source selector retains Browser operation and offers update/
refresh guidance. Discovery metadata may grow, while known identity and capability
types remain validated. Missing optional capabilities are unavailable; control
commands and responses retain their strict protocol contract.
## Visual Language
The interface presents one small shared living room rather than an operations
dashboard. The television is the media stage, the couch and pawns make presence
spatial, the shelf separates watching from controls, and the room display keeps
the four-digit code prominent. This metaphor must clarify the same product
state on Host and Viewer pages; decoration never creates a second state model.
Chinese, English, and pure-visual modes are three expressions of the same typed
state and command catalog. A fresh Browser starts in visual mode and persists
an explicit later choice. Text modes use concise copy. Visual mode uses one
consistent stroke language for single concepts, small panel comics for causes
and sequences, and literal scene objects for people, rooms, and media. Glyphs do
not form sentences. Universal digits, transport symbols, URLs, and measured
values remain literal when users need the data.
state and command catalog. On first use, the App and Server UI follow the
Browser's primary system language: Chinese uses Chinese text, and other
languages use English. Pure-visual mode remains an explicit choice. An explicit
later choice is persisted and takes precedence over that default. Text modes
use concise copy.
Control hints and state comics use the same television for shared or watched
media. Browser chrome identifies the application UI; a window title bar or a
display stand identifies the capture target. A server in a media path means
actual SFU relay, not merely opening a configured Site.
Motion explains entry, transition, progress, and control feedback. Operational
status stays still; ambient motion is limited to the App brand mark's
occasional idle wink.
All meaning remains available with reduced motion. Functional
controls remain native buttons or inputs with localized accessible names, and
hover, keyboard focus, and touch receive equivalent guidance.
The visual hierarchy, interaction ownership, and information order remain the
same across themes and viewport sizes. Responsive layout may reflow or scroll a
bounded visualization, but it must not hide a primary action, truncate an
essential value, overlap controls, or change product semantics.
The [visual language](../design/visual-language.md) is the single owner of
illustrative roles and objects, semantic colour, panel grammar, motion,
accessible input and responsive hierarchy.
## Playback Ownership
One native `<video>` element owns Viewer play/pause, volume, mute, and fullscreen.
Those actions are local and never change Host capture, room state, routing, or
other Viewers. The Host preview has no media controls; explicit Host actions own
authoritative share pause and stop.
One `<video>` element owns Viewer playback and frame proof. The shared playback
bar reflects that element and provides local play/pause, mute, volume, theater,
picture-in-picture, fullscreen and the existing reconnect action. It stays reachable while waiting
or disconnected. Theater and fullscreen are separate modes; fullscreen includes
the playback bar. Identity, connection details and topology stay in the deck.
Autoplay rejection exposes the native play action. A connection, track object,
The bar spans the screen's lower edge. Narrow screens separate audio controls
from window actions into two rows and retain 44px action targets. Playing video hides the bar
after two idle seconds, following [Media Chrome's default](https://github.com/muxinc/media-chrome/blob/main/docs/src/pages/docs/en/components/media-controller.md#autohide).
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 this behavior; it never changes playback or route state. Controls
follow the shared [motion grammar](../design/visual-language.md#motion-grammar).
Volume starts on native audio output. An explicit setting away from 100% activates
a local Web Audio stream source and gain, from silence to 200% amplitude. This also
avoids iPhone's system-owned `video.volume`. This output
exclusively owns sound while running, follows video pause and user mute, and
rebinds when the audio track changes. It never modifies the received track or
the stream relayed downstream. Unmount closes the graph. Browsers without gain
support retain 0–100%; video-only system fullscreen also hands audio back to the
native controls at at most 100%, rather than running two outputs.
This uses the standard [Web Audio stream-source and gain nodes](https://www.w3.org/TR/webaudio/).
The gain is amplitude scaling, so loud source peaks can clip above 100%.
Picture-in-picture uses the browser's native video window, including Safari's
presentation-mode API. Native enter/leave events own its displayed state; closing
the window or replacing the received stream does not create another playback
owner. Successful entry exits this video's other fullscreen mode, and unmount
closes only this video's window. Unavailable capabilities remain disabled with
an explanation. The native window contains video and system controls, not page
overlays or the custom bar. See the [PiP specification](https://w3c.github.io/picture-in-picture/)
and [Safari integration](https://developer.apple.com/documentation/webkitjs/adding_picture_in_picture_to_your_safari_media_controls).
Local playback actions never change Host capture or another Viewer. Reconnect
retains the existing route-operation owner. The Host preview remains muted and
has no media controls; explicit Host actions own authoritative pause and stop.
Autoplay rejection exposes the play action. A connection, track object,
or `playing` event alone is not proof that the current route is visible. The
Viewer removes its blocking presentation only after a current-generation frame
is composited, using `requestVideoFrameCallback()` where available and a decoded-
@@ -97,9 +133,12 @@ a replacement controller restarted revision numbering; ordinary updates remain
monotonic within that authority. A Viewer frame belongs to its local media generation and remains
current across unrelated graph revisions until that exact binding is replaced,
invalidated, or terminally failed. Pending candidates never own the primary
badge, status line, or overlay while committed media remains proved. Those
surfaces share the presentation reducer; raw transport snapshots provide route
labels, reconnect authority, and diagnostics only.
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)
records the visual vocabulary and the evidence needed for participant lamps.
Excluding time spent in the Browser's capture/play authorization UI, a Viewer
request targets a first visible frame within three seconds. Until then, the page
@@ -107,9 +146,17 @@ 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.
- Host roster connection state follows the server's committed physical media
path. A pending candidate or temporarily stale quality sample may annotate
optimization, but cannot downgrade an active Viewer to routing.
- The [status projection](../design/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
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
as terminal failure; roster readiness and quality evidence remain distinct.
- An overlay explains the absence of a usable picture, authoritative Host pause,
or a required user action. Ordinary quality warnings never cover playback.
- Host pause retains the current frame and waits for resume.
- Host signaling loss alone does not invalidate media that is still healthy.
- Exact media failure or terminal route failure invalidates current-frame proof
@@ -121,10 +168,11 @@ black screen, ICE-connected state, or unproved `playing` event.
state never forces the user out of fullscreen.
Visibility, page freeze, and pagehide suppress application decoded-stall
authority. Returning to the page rebaselines time and proves a current frame
again before clearing recovery. SFU media is retained independently of transient
room-signaling loss, but Browser or OS suspension and page reclamation remain
outside Web guarantees.
authority and invalidate quality observations, not already-proved playback.
Returning to the page rebaselines time and re-arms frame observation; an existing
recovery state still requires a fresh current frame to clear. SFU media is
retained independently of transient room-signaling loss, but Browser or OS
suspension and page reclamation remain outside Web guarantees.
Failure of the local Native media bridge disables Native reception for the
current Viewer session before route recovery runs. Future peers in that session
@@ -147,6 +195,13 @@ loss; deeper fields appear only when meaningful. Topology is a separate view.
Native bandwidth or CPU limitation warnings describe the exact outbound sender
that reported adaptation; they do not by themselves locate the physical
bottleneck or describe the Viewer's receive path.
The Host's participant freeze warning requires fresh receive evidence matching
the current upstream and a positive freeze count or duration for that window.
Window `*Delta` fields never inherit older values; other fields may retain their
existing details-display cache. The existing evidence expiry removes stale
warnings. No additional participant state, broadcast, timer or quality score is
introduced. Detailed per-edge observations remain available without a global
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
+7 -2
View File
@@ -35,8 +35,10 @@ the storage decision; implementation detail belongs in code and tests.
Three independent authorities exist:
1. **Site access.** Production requires an independent
`SITE_ACCESS_PASSWORD`. Successful entry creates a stateless, HttpOnly,
1. **Site access.** `SITE_ACCESS_PASSWORD` is optional in every environment,
including production. Unset or empty makes site access immediate without a
cookie; room ownership and Viewer admission still apply. When configured,
successful password entry creates a stateless, HttpOnly,
`SameSite=Strict` cookie with a 24-hour rolling idle lifetime. An already
authorized page renews it through the site-access check while it remains
active. It authorizes room creation, Host role, and code-only Viewer
@@ -90,6 +92,9 @@ code-only attempts and does not silently revoke invitations.
choice and optional Local site-access password, not rooms or media state. A
blank password leaves that local site open.
Persistence evidence, limits and the earlier cross-restart evaluation live in
[cross-restart recovery research](../research/cross-restart-room-recovery.md).
The App opens its own localhost Host page with the configured password, when
present, in a fragment. The page removes the fragment and uses the existing
SiteAccess endpoint; this does not create a fourth admission authority. LAN and
+5 -2
View File
@@ -17,14 +17,17 @@ owns ports and [self-hosting](../operations/self-hosting.md) owns service setup.
- A route ends in usable media, explicit bounded waiting, or clear failure.
Every deployment uses this controller. Leaving embedded SFU disabled keeps the
same bounded P2P graph without its SFU fallback; there is no separate Host-star mode.
same bounded P2P graph without its SFU fallback and fixes Privacy mode on for all
rooms, including App Local and public-link rooms. The server enforces that policy
for waiting and active participants; there is no separate Host-star mode.
## Per-Share Route Policy
The Host chooses route policy before sharing and it remains fixed for that
share generation:
- default hybrid mode keeps P2P first and permits the bounded SFU suffix;
- when SFU is available, default hybrid mode keeps P2P first and permits the
bounded SFU suffix;
- peer-only mode excludes SFU publication, subscription, bootstrap, and quality
candidates, while retaining the same bounded Peer graph and clear exhausted
failure; and
+4 -4
View File
@@ -1,8 +1,8 @@
# Project Memory
Last updated: 2026-09-10
Last updated: 2026-09-11
Piik is private, low-latency game screen sharing for one Host and up to 20
Piik is private, low-latency screen sharing for one Host and up to 20
authenticated friends. The current product surface is Web Host, Web Viewer, and
Browser relay; desktop and mobile Browsers are Viewer targets. It is not a
public broadcast service. Hosted and App run one shared Go core
@@ -94,8 +94,8 @@ implementation and routine UI detail.
## Current Snapshot
The current contract uses strict `piik-v23` Browser/server signaling,
Native control v9 and capture v7 together. Embedded SFU SDP/ICE and demand travel over the
The current contract tuple is owned by [status](./status.md#accepted-release-contract).
Embedded SFU SDP/ICE and demand travel over the
authenticated room WebSocket, without an external room service or media token.
Committed first-frame readiness, bounded candidate-relative progress, durable
room authority and the H.264/VP8 sender gate remain. [Status](./status.md)
+57 -4
View File
@@ -4,6 +4,18 @@ The tracked [`.env.example`](../../.env.example) is the executable schema
companion; `internal/server/config` is validation truth. Keep real values in the
service secret store or an untracked access-restricted environment file.
## Ownership
- Deployment configuration owns listeners, advertised origins, persistence,
admission, capacity and optional services. Change it before starting the process.
- The Host owns share settings and the available per-share route switches in the
shared Web UI. Server and App use the same controls, order and semantics;
configuration changes availability, not which controls exist. A fixed switch
remains visible and explains the reason in text or its pure-visual hint.
- Protocol versions, queue bounds and routing/adaptation constants stay in code.
They are not deployment tuning knobs. Debug and report destinations are
explicit diagnostic options, not normal share settings.
## Application Environment
| Variable | Contract |
@@ -13,19 +25,19 @@ service secret store or an untracked access-restricted environment file.
| `PORT` | Positive TCP port, default `8787`; the tracked release wrapper supports only that default. |
| `PUBLIC_BASE_URL` | Exact public HTTP(S) origin; production requires HTTPS. |
| `ALLOWED_ORIGINS` | Comma-separated exact HTTP(S) origins; wildcard is invalid. |
| `SITE_ACCESS_PASSWORD` | Production-required independent 8-128 visible-ASCII byte secret. |
| `SITE_ACCESS_PASSWORD` | Optional in every environment. Unset or empty allows entry without a site password. A non-empty value must contain 8-128 visible ASCII bytes; room ownership and Viewer admission remain independent. |
| `ROOM_DATABASE_PATH` | Hosted defaults to `rooms.sqlite` in its working directory when unset or blank. An explicit absolute file path selects another SQLite file; `:memory:` opts into process-memory room authority. App Local remains in memory. |
| `MAX_VIEWERS_PER_ROOM` | `1..20`, default `8`. |
| `ENDPOINT_MEDIA_COPY_CAPACITY` | Shared endpoint steady-copy cap `1..3`, default `2`. |
| `STUN_URLS` | Comma-separated advertised `stun:` discovery URLs; at least one is required in production. These are not local bind addresses and may use an unproxied DNS name separate from the Web origin. |
| `STUN_LISTEN_HOST` | Hosted IPv4 STUN bind address, default `0.0.0.0` when `STUN_URLS` is configured; independent of HTTP `LISTEN_HOST`. Local App construction creates no STUN listeners. |
| `NAT_PREDICTION_ENABLED` | Optional bounded NAT prediction capability, default `false`; requires an ordinary `STUN_URLS` endpoint on UDP 3478. Hosted startup binds UDP 3479/3480 before advertising the capability. Firewall reachability remains an operator requirement. |
| `NAT_PREDICTION_ENABLED` | Optional bounded NAT prediction capability, default `false`; requires an ordinary `STUN_URLS` endpoint on UDP 3478. Hosted startup binds UDP 3479/3480 before advertising the capability. When unavailable, the visible NAT switch is locked off; ordinary ICE remains. Firewall reachability remains an operator requirement. |
Automatic SFU fallback runs inside the Hosted process when `SFU_UDP_PORT` is set:
| Variable | Contract |
| --- | --- |
| `SFU_UDP_PORT` | Optional UDP media port `1..65535`; unset or blank disables SFU. Set `7882` for the standard public listener. |
| `SFU_UDP_PORT` | Optional UDP media port `1..65535`; unset or blank disables SFU and fixes Privacy mode on for every room. Set `7882` for the standard public listener and allow the Host to choose Privacy mode. |
| `SFU_LISTEN_HOST` | IPv4 bind address, default `0.0.0.0`; independent of HTTP `LISTEN_HOST`. Read only when SFU is enabled. |
| `SFU_PUBLIC_IP` | Optional explicit IPv4 advertised-address override for a host behind NAT. Read only when SFU is enabled. |
@@ -34,6 +46,11 @@ separate control origin or infrastructure credentials are configured. Local and
public-link App construction create no SFU listener. The relay does not
provide application E2EE.
For a P2P-only Server, leave `SFU_UDP_PORT` blank. Room authority, signaling,
configured STUN and peer relays remain; no media-server fallback is possible.
The server enforces this even if a Host requests hybrid mode. There is no
separate `SFU_ENABLED` flag to conflict with the listener configuration.
The SQLite parent directory must exist and be writable. The systemd template
sets `/var/lib/piik/rooms.sqlite` under its managed state directory; the
container image sets `/home/nonroot/rooms.sqlite` under its writable data
@@ -45,10 +62,41 @@ Removed access, room TTL/lease, endpoint-tier, room-rollout, and TURN variables
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.
dual configuration readers. [Versioning](./versioning.md) owns the planned public
upgrade promise and the work required before its first release.
## Piik App Configuration
Piik App stores `Piik/client.json` under the operating system's user configuration
directory (`%APPDATA%` on Windows, `~/Library/Application Support` on macOS,
and `$XDG_CONFIG_HOME` or `~/.config` on Linux). The App manages its `version`
schema field. The user settings are:
| Setting | Contract |
| --- | --- |
| `site` | Saved Piik Site origin; omit it for Local mode. The launcher or `--site` updates it. |
| `localAccessPassword` | Empty by default. Optional password for the App's Local room authority, using the same 8-128 visible ASCII bounds. It is separate from a hosted site's password. |
Command-line options select entry and local runtime behavior:
| Option | Purpose |
| --- | --- |
| `--site <origin>` / `--local` | Save and use a Site, or select the self-contained Local mode. Mutually exclusive. |
| `--link` | Create a public Viewer invitation for Local mode. |
| `--config <path>` | Select another App configuration file. |
| `--lan-address <IPv4>` / `--port <port>` | Override the Local invitation address or HTTP port (default `8787`). |
| `--capture-process <path>` / `--tunnel-process <path>` | Override packaged native capture or public tunnel helpers. Ordinary installations use the packaged paths. |
| `--debug` / `--log-dir <path>` | Enable diagnostics or choose their destination as described below. |
Share quality, room access policy, language, theme and motion are configured in
the shared Web UI, not through Server environment variables or App JSON.
## Diagnostics
Normal Server logs record startup identity, listener and enabled services,
unexpected failures, orderly shutdown and any available release found by the
single background startup check. Detailed room/ICE/media traces require Debug.
Diagnostics are local and opt-in. Enable them **before** reproducing the problem:
| Surface | Enable | Export |
@@ -57,6 +105,11 @@ Diagnostics are local and opt-in. Enable them **before** reproducing the problem
| Browser Host/Viewer | Add `?debug=1` to the page URL, before any invitation fragment | Use the download button beside language/theme controls |
| Hosted Server | Start with `--debug`, `PIIK_DEBUG=server` or `PIIK_DEBUG=route` | On Unix, `kill -USR1 <pid>`; also exported at orderly shutdown |
Server Debug is controlled by its startup environment or CLI, never by a remote
page or room role. Browser `?debug=1` only enables that page's local collection;
it cannot change Server logging or download Server reports. The Server exposes
no HTTP diagnostic export or pprof endpoint.
App/Server ZIP and Browser JSON reports are separate: when investigating
Browser/App cooperation, include both from the same reproduction. Neither
action stops an active share or uploads anything. Browser export also remains
+199
View File
@@ -0,0 +1,199 @@
# Engineering And Interface Boundaries
This is the shared engineering convention for Browser, App and Server work.
[AGENTS.md](../../AGENTS.md) owns task authority and hard product constraints;
[CONTRIBUTING.md](../../CONTRIBUTING.md) owns the delivery workflow. The required
[ponytail guidance](../../.agents/skills/ponytail/SKILL.md) selects the smallest
correct implementation. This file owns module responsibilities and interface
discipline, not a second copy of the product contracts or wire fields.
## Repository Layout
Keep conventional project entry points and tool-discovered configuration at the
root. Scope-specific configuration belongs with its owner: `src/tsconfig.json`
checks the Browser, `scripts/tsconfig.json` checks tooling/tests, and root
`tsconfig.json` only connects those projects.
| Location | What belongs here |
| --- | --- |
| Root README, contribution and agent files | Project introduction and shared working constraints; detailed documents live in `docs/` |
| Root manifests, lockfiles, Vite/Vitest config, `index.html` | Go/npm entry points and Browser build/test startup; root `LICENSE` is authoritative |
| `src/client`, `src/shared` | Browser UI/media orchestration and its shared wire types; the same UI serves Hosted and App modes |
| `public/` | Static assets shipped with that Browser application |
| `cmd/`, `internal/` | Go executable composition and private application, server, media and diagnostics packages |
| `native/capture`, `native/fixtures`, `native/probes` | Platform capture implementations, synthetic workloads and standalone capability probes |
| `site/` | Independently published static introduction; its assets do not implement product behavior |
| `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 |
| `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 |
`site/` introduces Piik; `src/client/` is Piik's interactive application.
`internal/app/nativecapture` adapts the capture contract in Go; `native/capture/`
implements it using platform APIs. `scripts/` prepares and verifies artifacts;
`deploy/` operates them. Move files with their actual consumers, and update paths
and checks together; a smaller root alone does not justify another abstraction.
## Module Map
| Owner | Responsibility and dependency boundary |
| --- | --- |
| `cmd/piik-server`, `cmd/piik-app` | Thin entry points: defaults, startup and shutdown; compose the same Go room service |
| `internal/server/app` | HTTP, site access, runtime capabilities, static assets and listener lifecycle; room mutations go through `signal` |
| `internal/server/signal` | Authenticated command/effect owner; serializes room, session and route mutations and executes controller decisions |
| `internal/server/room` | Room authority, credentials and persistence; caller holds the signaling lock, durable writes precede in-memory changes |
| `internal/server/route` | Synchronous room graph and single-operation decisions; returns resource effects to `signal`, owns no locks or timers, and calls its optional diagnostic sink synchronously |
| `internal/server/sfu`, `internal/app/mediaedge` | Server forwarding and native peer adapters over the shared `internal/media/forwarding`; Pion/LiveKit own transport and adaptation |
| `internal/app` | Local/public-link/Site composition, loopback service, native sessions and App lifecycle; never a second room backend |
| `internal/app/nativecapture`, `native/capture/*` | App-side capture contract and minimal platform adapters; platform-specific capture/encoding stays here |
| `src/client/pages`, `lib`, `media`, `native` | Browser orchestration and endpoint adapters; bind current media and retire owned resources |
| `src/client/media/viewer-presentation.ts`, `ui/media-status.ts` | Playback facts and their derived status projection; UI indicators do not create recovery or quality policy |
| `src/client/components`, `locales`, `ui` | Shared interaction, visual primitives and localized copy; same product interface for Server and App |
| `internal/server/webassets` | One built Browser bundle embedded by both binaries; Node/Vite are development tooling |
Names follow [naming](./naming.md). Architecture decisions explain why these
boundaries exist: [shared core](../adr/0012-shared-go-backend-core.md),
[embedded media](../adr/0013-embedded-node-local-media.md) and
[Browser pooling](../adr/0014-browser-node-local-encoding-pool.md).
Directory names are a map, not a requirement to introduce another layer.
## Contract Map
| 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) |
| 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) |
| Release and update metadata | Existing packaging scripts, `src/client/lib/release-update.ts`, `cmd/piik-server/release.go`; identity/compatibility in [versioning](./versioning.md), operations in [deployment](../deployment.md) |
The shared `tests/fixtures/wire-samples.json` is consumed by TypeScript and Go
fixture tests. It covers room requests and signaling; it is not a universal
schema for all HTTP, Native or capture traffic. Change the actual two ends and
the relevant existing fixture/check together. Do not create another handwritten
field list in documentation, parallel DTO hierarchy or schema generator without
a demonstrated reduction in ownership or drift.
## Runtime Lifecycles
Read each runtime as acquisition, the authoritative commit that makes its work
visible, and the retirement that ends it. Revalidate the acquiring identity
after every await or callback; presentation derives from the committed fact
instead of mirroring it.
| Runtime | Acquire | Commit | Retire |
| --- | --- | --- | --- |
| Browser Host share | `pages/HostPage.tsx` captures one source and creates its peers and route | current generation plus the owning identity: local-offer epoch, publisher slot revision, source-switch and quality-change tokens | `disposeResources` stops owned tracks, disposes peers and sends `stop-sharing`; a replaced source retires through `retiringStreamRef` |
| Browser Viewer route | `pages/ViewerPage.tsx` binds one parent peer (Browser or Native) and its relay children | parent, connection and message identity, then playback facts | a new viewer generation retires the old binding; relay children follow `activateChildren` revisions |
| Browser relay fanout | `webrtc/viewer-relay.ts` prepares a child before it is needed | `activateChildren(revision, childPeerIds)` commits the prepared set | replaced or emptied sets disconnect the child peers they own |
| Browser local encoding pool | `webrtc/host-peer.ts` attaches a pool carrier when capture and APIs support it ([ADR-0014](../adr/0014-browser-node-local-encoding-pool.md)) | the carrier binding owns its pooled producer while attached | source change or disposal releases the carrier; unsupported APIs keep ordinary senders |
| App native session | the loopback service admits native control sessions and the Web UI selects the native path | session identity plus the explicit media-path choice | session end, share end or process exit retires that edge and its capture sidecar |
| Server room authority | `signal` authenticates one Host or Viewer command | `room` writes durably before the in-memory commit; the signaling lock serializes mutations | explicit replacement or deletion, grant rotation or revocation, or process exit for memory-mode rooms |
| Server route and SFU | `signal` feeds `route` one serial child operation; `sfu/admission.go` accepts one bounded publication | one committed graph per controller and the forwarding publication | superseded assignments retire inside the same operation; deactivate retires the publication |
## Source Index
Start a change at the owning entry point, then follow the interfaces listed
above rather than searching the tree.
| Change | Start at |
| --- | --- |
| Host workflow, share lifecycle, notices | `src/client/pages/HostPage.tsx`, `src/client/pages/host-page-notices.ts` |
| Viewer workflow, playback, status projection | `src/client/pages/ViewerPage.tsx`, `src/client/media/viewer-presentation.ts`, `src/client/ui/media-status.ts` |
| Browser peer and route mechanics | `src/client/webrtc/host-peer.ts`, `viewer-peer.ts`, `viewer-relay.ts`, `src/client/media/host-sfu-route.ts`, `viewer-sfu-route.ts`, `route-transition.ts` |
| Capture, profiles and sender parameters | `src/client/media/quality.ts`, `src/client/media/browser-encoding-pool.ts`, `browser-encoding-output.ts` |
| SFU endpoints | `src/client/sfu/publisher.ts`, `subscriber.ts`, `peer.ts`, `internal/server/sfu` |
| Localized copy and failure facts | `src/client/locales`, `src/client/ui/copy.ts`, `src/client/ui/media-failure.ts` |
| Native bridge, capture and media edges | `src/client/native`, `internal/app/loopback`, `internal/app/nativecontrol`, `nativehost`, `nativeviewer`, `nativecapture` |
| Room authority, persistence and graph | `internal/server/signal`, `internal/server/room`, `internal/server/route` |
| Shared wire contract | `src/shared/protocol.ts`, `internal/server/protocol`, `tests/fixtures/wire-samples.json` |
| Diagnostics and logging | `src/client/lib/debug.ts`, `internal/diagnostics`, [configuration](./configuration.md#diagnostics) |
| Configuration and deployment | `internal/server/config`, `internal/app/config`, [configuration](./configuration.md), [deployment](../deployment.md) |
## Implementation Rules
- Reuse a current owner, standard API or mature dependency before adding a
mechanism. Extract around a responsibility shared by real callers; file size
alone does not justify wrappers, a generic manager or a framework.
Remove superseded paths in the same change; retain no unused legacy copy.
Keep test-only state seeding and setup in test harnesses.
Do not export mutable resources solely for tests; preserve the production
construction path when verifying defaults or restoration.
- Review dependency direction for source/type imports as well as runtime
imports. Shared types belong to a common owner. A type-only cycle is an
ownership concern; it does not by itself establish a runtime or performance
defect.
- State has one writer and a clear lifetime. Separate permission, capability,
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.
- The operation that acquires a connection, clone, listener or queue owns its
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.
- 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.
Injected callbacks still execute in their caller's context; include their
blocking I/O in the caller's lock and latency analysis.
- 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.
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.
Values with a defined canonical form, such as HTTP origins, use one shared
normalizer before storage and comparison. Verify the actual producer and
consumer together instead of weakening an authorization comparison.
- Deployment configuration describes available services; room preferences
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
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.
Support Windows, macOS and Linux; new text files use LF and new filenames use
ASCII, with format exceptions in [`.gitattributes`](../../.gitattributes).
Scripts use stable executable paths; physical confidence stays explicit in
[verification status](../verification-status.md).
- Retained tools declare their direct package dependencies. Documented TypeScript
tools join the normal type-check entry point; a transitive dependency or a
one-off manual check is not an owned toolchain contract.
Shipped templates use reserved example values. Checks provision isolated
resources or require explicit targets; they must not depend on a maintainer's
workstation, private deployment or machine-specific infrastructure names.
## Ablation And Review
Judge a change by verified user value against implementation, maintenance,
compatibility, runtime and failure cost. After a material module, remove any
new state, dependency, branch or abstraction that the accepted behavior does
not need. A small performance gain does not justify permanent complexity;
do not reduce viewing quality or requested functionality merely to save lines.
A concrete reduction in duplicated writers, cross-module dependencies or
repeated edit sites can justify structural refactoring without a reproduced
runtime failure. Name that gain and its regression risk. A completed audit is
bounded evidence, not a claim that every current module boundary is optimal.
Before changing production recovery or state, trace the real request producers
and their guards. A test that bypasses disabled controls or writes private state
does not alone establish a reachable product defect. Reference scans, including
hits from tests and previews, are leads rather than proof of product use or
absence. Follow the actual producer-to-consumer path and check the changed
boundary; isolated helper tests can miss a mismatch between the two ends.
Trace acquisition, use, commit and retirement, including replacement and failure.
For async ownership, check A starting, B replacing it, then A completing or
cleaning up. Where compatibility is promised or being established for public
release, check actual old/new readers as well as the happy path. Ordinary private
changes do not require support for stale private clients. Run proportionate checks through
[the standard entry points](../../CONTRIBUTING.md#verification-entrypoints).
This discipline does not authorize an unrelated repository rewrite or a new
test suite for every helper.
+129 -17
View File
@@ -1,8 +1,116 @@
# Naming
# Naming And Copy
This is the naming convention for new and changed Piik code, UI and packaging.
Use the brand for people and predictable lowercase identifiers for tools.
## Voice And Terminology
Use the same role and action vocabulary in the App, Browser, website and guides.
Role labels describe permission; a person's chosen nickname stays unchanged.
| Meaning | English | Chinese |
| --- | --- | --- |
| Person sharing and controlling the room | Host | 房主 |
| Person watching | Viewer | 观众 |
| Product capability | Screen sharing | 屏幕共享 |
| Start sending a chosen screen/window | Start sharing | 开始分享 |
| Enter another person's room | Join a room | 加入房间 |
| Site-wide access secret | Site passphrase | 站点口令 |
| Room-specific access secret | Room password | 房间密码 |
Write each language for its own audience. Chinese headings and welcomes may
sound like friends inviting each other: “叫朋友来一起看。” or “沙发给你留着呢。”
Do not translate English sentence structure or possessive screen metaphors
literally (“和朋友一起看你的屏幕”). Use familiar Chinese verbs and a light touch;
forced memes, baby talk and repeated slogans quickly become tiring.
The brand line is “Share the good stuff.” in English and “来,看点好康的。” in
Chinese. Display the Chinese headline on two lines: “来,看点” / “好康的。”
Keep metadata and ordinary prose as the continuous sentence.
Keep the public brand line consistent in the homepage, film ending and README
opening, beside a plain screen-sharing description. Scene titles should name
the action or benefit; do not turn every section into another slogan. Games, drawings, photos and
movie nights show the range of screen sharing without implying voice chat,
shared input or synchronized playback controls. Each locale must read naturally
as a complete sequence, without depending on a pun from the other language.
Set the register by the reader's task:
| Surface | Register |
| --- | --- |
| Website | Light, everyday language in introductions and illustration captions; keep the current restraint |
| README | A friendly introduction, followed by direct explanations of features, limits and first steps |
| Documentation index | Clear topic names and short descriptions that help readers choose a guide |
| Deployment, configuration and maintenance guides | Formal, precise instructions: prerequisites, actions, expected results and recovery steps |
Formal means clear and factual, not bureaucratic. Keep detailed procedures free
of jokes and promotional slogans; do not carry the website's playful headings
into technical instructions.
State the requirement, action or result directly. Omit negative comparisons that
answer an abandoned design or an internal discussion: “解压后运行” is enough;
listing the runtimes a packaged App does not require adds no useful next step.
Include an exclusion only when it resolves a real reader choice or limitation.
Write tutorials from an explicit starting point: what the reader needs, which
visible action to take, and what appears next. Distinguish watching an invitation,
creating an App room, using an existing site, and deploying a server. Say where
the site or download comes from; “follow the prompts” cannot stand in for a
missing prerequisite. Mark passwords and audio options as conditional when they
are optional. Keep App and Server package names distinguishable at download links.
Public platform notes say what has been tested and invite useful feedback;
internal phrases such as “this phase's acceptance target” belong in status.
Use progressive disclosure on the website and in the film:
| Information layer | Reader's question | Content |
| --- | --- | --- |
| First view: navigation, headings, feature cards, film titles | What does it do, how do I start, what does it cost? | Familiar actions and concrete benefits: “开箱即用”, “免费使用”, “朋友点开就能看” |
| Expanded details, supporting text and FAQ answers | Will this work for me, and why? | Platform/network conditions, explained technical terms and links to the relevant guide |
| README, wiki and operational guides | How do I configure, verify or troubleshoot it? | Exact action names, prerequisites, commands, configuration and evidence limits |
The layer follows the reader's task, not font size alone. Keep material costs,
availability and limitations beside the claim or action they qualify; do not
hide them behind a collapse. Never promote an implementation mechanism such as
encoding reuse or STUN as an unexplained headline. Explain technical terms on
first use even in detailed documentation. Plain wording must preserve the
original bounds: “开箱即用” does not promise offline use or guaranteed connection,
and reduced repeated work does not establish a universal CPU saving.
Lead performance copy with the user benefit (low latency, efficient sharing),
then explain the supporting mechanism and its conditions. “Lowest latency”,
“zero delay” and comparative resource savings require matching measurements;
an architectural preference alone does not establish a benchmark result.
Write Chinese and English headings independently. Break lines at complete
phrases, never inside a short action such as “画上两笔”. A decorative number must
have an adjacent meaning/unit (“使用费用 0 元”), and its label must remain visible
in both languages and at the smallest supported width. Check the actual rendered
headline after rotation and animation settle: all meaningful words need room
inside the frame. Oversized background lettering may crop; primary copy may not.
Download/availability labels, platform requirements, instructions, permission
prompts, status and errors stay plain and specific. Use the exact visible action
names in tutorials; a playful caption never replaces “Download”, “Join a room”
or a platform/connection limitation. Explain what happened, what the user can do
next, and any real limitation. Jokes belong
in the welcome or illustration caption. Do not soften a failure into a joke,
invent connection progress or promise guaranteed connectivity or absolute privacy.
Screen sharing includes games, creative work and showing a useful discovery.
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.
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.
## Product And Tool Names
| Surface | Convention | Example |
| --- | --- | --- |
| Product and repository | `Piik` | `TNTcraftHIM/Piik` |
@@ -18,11 +126,9 @@ Use the brand for people and predictable lowercase identifiers for tools.
| Environment variables | Uppercase snake case | `PIIK_DEBUG`, `PIIK_LOG_DIR` |
| Go module path | Exact repository spelling | `github.com/TNTcraftHIM/Piik` |
An `.exe` is lowercase on Windows too; `.app` is a user-facing bundle, not the
raw executable. There is no universal OS rule requiring lowercase binaries:
this project chooses one spelling across platforms for predictable commands.
Archive contents and directory layout are owned by the existing packager; a
name change does not change that layout.
Use the same lowercase binary names across platforms for predictable commands.
`Piik App.app` is the user-facing macOS bundle. The packager owns archive contents
and directory layout.
Keep platform/standard spellings such as `Info.plist`, `LICENSE`, `README.md`
and `REVISION`. Do not change third-party names or notices to fit this table.
Immutable measurement records keep the paths and hashes of their measured
@@ -30,25 +136,31 @@ revision; they are evidence rather than current command examples.
## Code ownership and vocabulary
- `internal/app` owns the standalone application and its native/launcher
components; `cmd/piik-app` is its thin command entry.
- `internal/server` owns the reusable room service and Hosted runtime;
`cmd/piik-server` is its command entry. The App runs that same service for
Local/public-link rooms, without a second backend implementation.
- `src/client` is the Browser frontend. HTTP/WebSocket clients, signaling
`clientId`, protocol clients and similar connection roles remain `client`.
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.
- 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.
Renaming the standalone product is not a protocol or persistence change.
Keep `piik-client-v9`, the discovery service name, Browser activation/storage
keys, the `Piik` configuration directory and diagnostic categories at their
current exact contracts. Change such identifiers only with a separate behavior
or contract decision; do not add aliases or dual readers for display renames.
Keep discovery, wire and stored identifiers at their current contracts. Their
change rules belong to [versioning](./versioning.md); do not add aliases or dual
readers merely for a display rename.
## Basis
Content layering reviewed 2026-09-12:
[NN/g's progressive disclosure](https://www.nngroup.com/articles/progressive-disclosure/)
defers secondary detail while preserving the primary task;
[GOV.UK's clear-language guidance](https://guidance.publishing.service.gov.uk/writing-to-gov-uk-standards/writing-guidelines/clear-language/)
keeps specialist content understandable. The layer assignments and Chinese
examples above are Piik's application of those principles.
[NN/g's microcopy guidance](https://www.nngroup.com/articles/3-cs-microcopy/)
puts clarity before concision and character; this also guides the balance
between Piik's playful introductions and factual instructions.
Reviewed 2026-09-10. Desktop standards explicitly separate display names from
executable identifiers; lowercase executable names are our portability convention.
+238
View File
@@ -0,0 +1,238 @@
# Versions And Compatibility
Reviewed 2026-09-12. Release identity, update comparisons and descriptive metadata
rules are implemented; the public compatibility promise starts with the first
declared public release. Use `v1.0.0` for it, with one product version for App,
Server and the embedded Web build, plus the full Git SHA for source provenance.
Automatic publication remains disabled until the readiness boundary below.
## Give Each Identifier One Job
| Identifier | Meaning and owner | Change rule |
| --- | --- | --- |
| `vMAJOR.MINOR.PATCH` | Public Git tag/product release | Patch: compatible fixes; minor: compatible features; major: incompatible public contract |
| Full Git SHA / `REVISION` | Exact source revision | Changes with the source commit; never use SHA inequality as release ordering or compatibility proof |
| Artifact SHA-256 | Exact packaged bytes in the release descriptor | Each artifact; one source SHA can produce different platform/toolchain binaries |
| Platform application version fields | Generated package metadata | Product version in the platform's numeric format; source SHA stays separate. XML/desktop-format versions remain specification values |
| Browser/Server signaling identifier | WS contract in `src/shared/protocol.ts` and Go protocol constants | Change for an incompatible wire contract, not UI edits or every release |
| Browser/App control identifier | `src/client/native/wire.ts` and App loopback/control | Independent compatibility boundary; installed App and Site can update separately |
| Capture probe and encoded-frame envelope | `internal/app/nativecapture` and platform adapters | Internal matched bundle; change only the affected format |
| SQLite schema, App config format, Browser storage-key format | Their storage/parser owners | Change with the actual stored representation; preserve accepted authority across a compatible upgrade |
| Release descriptor schema | Packager and descriptor readers | Describes metadata format, not the product release |
| Room authorization generation, share/route/publication/media generations, connection IDs | Current authority and resource owners | Runtime identity/freshness only; never reset or align them with release numbers |
Current numeric values are in source and [status](../status.md#accepted-release-contract).
Keep those internal values at the first public release: resetting schema or
authorization counters for visual consistency can invalidate data or stale-work
fences. A room number is a user-facing address, not a software version.
The private tooling-only npm package carries no independent product version.
## Public Compatibility Promise
The protected surface is documented App/Server startup and configuration, room
and invitation behavior, persisted authority, and the Browser/Server/App
interfaces needed for established sharing and viewing. Internal Go/TypeScript
symbols, capture sidecar details within a matched bundle, and undocumented
diagnostic fields are not separately supported public APIs.
- Within a public major, newer releases preserve established workflows and
accepted configuration/data for older public releases in that major. New features
require support at the receiving end; they are not sent speculatively.
- A Site serves its own Web build, but an already-open page or installed App
can be older. Compatibility is checked at their actual contracts, not by
requiring identical product versions or SHAs. New App with older Site keeps
established compatible functionality; newly added features may be unavailable.
- A patch/minor cannot remove a documented command, require a new field from an
old peer, change an existing field's meaning, revoke valid room authority, or
turn an incompatible HTTP response into a supposedly compatible WS release.
Deliberate public breakage needs a major and clear upgrade instructions.
- Same-major forward upgrades preserve existing durable authority. Arbitrary
binary/database downgrades, cross-major interoperability, process restart
without media interruption, and identical performance on every OS/browser
are not promised. A security repair may restrict previously unsafe behavior;
explain the impact rather than silently redefining compatibility.
This does not require retaining every historical implementation. Keep one stable
baseline contract; introduce a small optional capability only for a concrete
new feature. Do not add a version-range solver, general adapter framework,
parallel authority writers or blanket old-client fallback paths.
## Extending Interfaces
Command/event JSON objects still reject unknown keys and enums, and required
fields reject omissions. Therefore even adding a field can break the other side.
The former required `sfu` addition to strict `/api/capabilities` demonstrated
both directions of this problem; an unchanged signaling identifier does not
cover HTTP compatibility.
Runtime capabilities and App discovery now ignore unknown descriptive fields
while validating known types, bounds and required identity. An absent optional
capability means unavailable. Commands, credentials and authority checks remain
strict. A new
message type, enum value or behavior-changing field is emitted only after the
other side advertises support; accepting unknown metadata is not permission to
execute unknown commands. Verify both readers before calling a change additive.
Native discovery distinguishes an incompatible Piik App from an absent App and
reports protocol numbers to local diagnostics; the source picker offers update/
refresh guidance and Browser operation. Do not reload, reinstall or interrupt
an active share just because an unrelated product/build label changed.
## Release And Recovery
A single release plan computes the next tag and full source SHA. Packagers inject
both into binaries, embedded Web/debug metadata and the schema-2 release descriptors.
Local candidates use an exact release tag, `development` or an explicit candidate
version. An explicit version labels an unpublished test artifact; it does not
declare or freeze a public release. It cannot reuse a tag owned by another source
revision. Do not maintain independent hand-edited App/Server/Web version files.
Archive layout and checks stay in the
existing packagers; a new build never replaces an already-published package.
Update checks compare actual release precedence and link to the matching release.
Newer version wins even when the source SHA is unchanged. Same version with a
different known SHA reports a different official build, not a newer one. A
development build with a known differing SHA offers the official release as a
manual choice; it makes no ordering claim. Older releases never cause an update
notice. Unknown SHA is not inferred from a branch name in GitHub metadata.
The publisher records a full SHA in `target_commitish` and verifies actual tag
identity separately. Stable update notices ignore prereleases.
## Automatic Publication
After one-time activation, a complete squash PR merged to main runs the existing
CI and packaging pipeline. `scripts/release-version.mjs` examines unreleased
first-parent commit messages: `feat` selects minor, `!` or `BREAKING CHANGE`
selects major, and other accepted changes select patch. The first release is
`v1.0.0`. PR/commit naming is owned by [CONTRIBUTING](../../CONTRIBUTING.md#commit-pr-and-branch-names).
Code size or an internal refactor does not itself imply a breaking release.
No version/changelog commit is written back to main. The workflow queues main
runs, uses the same source SHA for Server and all App targets, and publishes only
after all required artifacts and checks pass. The publisher verifies matching
version, source SHA and checksums, then creates/uploads/publishes one GitHub draft.
Failed draft uploads can resume; published artifacts are never overwritten.
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.
Ordinary branches and PRs do not run cloud CI. [GitHub operations](../operations/github.md)
owns activation and main protection; no workflow changes repository visibility.
Updates remain explicit, outside an active share, using complete matching
packages. [Deployment](../deployment.md) owns artifact verification, cutover and
recovery. A changed storage format needs its own upgrade and rollback treatment;
restoring an old authority database after newer grants/revocations is not a safe
routine downgrade. No automatic updater or generic migration framework is
introduced by this policy.
## Release Notes
GitHub Releases owns the published changelog. Each accepted PR supplies reviewed
Chinese and English user-facing copy under the exact `## Release notes` heading
in its squash commit; `###` headings structure that copy. Explain benefits,
fixes and required upgrade actions, including breaking changes. Keep internal
work logs, audit handoffs and unverified claims outside this public section.
`scripts/release-notes.mjs` collects these sections from all unreleased
first-parent commits since the previous stable tag. The first release uses
only its launch commit's product introduction; private development history is
not a launch changelog. Missing, empty or duplicate sections fail validation
before packaging/publication. GitHub Actions shows the generated text in its
run summary. A manual candidate with an explicit version also previews it, so
prepare its commit text before dispatching a release rehearsal.
The publisher passes the same text through a temporary notes file to GitHub,
then removes that file. Gitee copies the GitHub description. Retrying the same
release preserves its draft/published description; 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 Sources
GitHub Releases is the primary publication; Gitee is an optional official mirror
for domestic access, with a minimal README pointing development and documentation
back to GitHub. The Gitee repository need not copy the source tree. Its README/tag
commit is not the Piik source revision; release metadata retains the original
GitHub SHA. Build each target once and copy the same archives, notices,
version, full source SHA and artifact hashes to both. A Git repository mirror
alone does not synchronize Release attachments. Keep one release description and
machine-readable metadata, generated by the existing packaging pipeline.
The two providers only adapt API fields and download locations into the same
release model. Share version comparison, channel filtering and package validation.
The normal background check tries GitHub, then Gitee if the primary is unavailable;
download links may expose both verified locations. A lagging mirror must not
cause a downgrade or a version/hash disagreement to be silently accepted.
Gitee's documented latest endpoint means last updated, so provider normalization
must select stable release precedence explicitly, not equate provider ordering.
Synchronize only an explicitly published release; branch pushes stay quiet.
Retrying a failed mirror upload reuses the original package. Advertise a mirrored
package as available only after its required attachments and matching metadata
are present. Tokens belong only to publishing, never the distributed Browser/App.
The public mirror is [TNTcraftHIM/Piik](https://gitee.com/TNTcraftHIM/Piik).
The publisher reuses the shared artifact validator, checks every local file
against GitHub's published asset digest, then uploads and verifies anonymous
Gitee downloads. Gitee has no draft API: an incomplete mirror remains a preview
release, and becomes stable only after every file passes. A source marker in
the release body (`<!-- piik-source: <full-SHA> -->`) preserves the GitHub revision;
the mirror's `target_commitish` identifies only its own README commit.
App notices and the operator check share their existing version comparison
across providers. Each provider has its own deadline; the mirror checks at most
ten pages of 100 releases and rejects an incomplete listing. An explicit operator
`--api-url` override checks that endpoint alone. A valid primary result, including
an up-to-date result, does not consult the mirror.
Gitee currently documents 100 MB per attachment and 1 GB total repository
attachments for ordinary projects. The mirror rejects files above 100,000,000
bytes before writing. Check aggregate capacity before each release; exhausted
storage leaves a pending preview rather than deleting older downloads.
Target authentication, uploads, anonymous metadata/CORS and a small download's
SHA-256 have been verified. Acceptance of the full matching release packages
remains pending with the first public release.
Use API metadata in the Browser; ordinary asset-link redirects are not guaranteed
to permit cross-origin fetch, even when a normal download works.
## First Public Release Readiness
The current private single-contract rule remains until this boundary is ready:
1. Accept real target packages and the queued GitHub publication path before
enabling automatic releases. Current local checks exercise planning, identity
injection, update decisions and publisher failure/retry paths without publishing.
2. Freeze the first public baseline and explicit feature-support rules after
checking the implemented discovery and descriptive-metadata behavior above.
3. Exercise the first supported old/new App-Site pair in both directions,
a stale open page, incompatible-contract recovery and retained room/config
authority. Bundled capture checks remain within their existing package gate.
4. Freeze the supported baseline and release notes, then explicitly declare
`v1.0.0`. Do not manufacture compatibility by merely renumbering private builds.
These implementation tasks are tracked in [TODO](../todo.md). Their absence is
a public-release gap, not evidence that the current matched private build fails.
## References
- [SemVer 2.0.0](https://semver.org/spec/v2.0.0.html): release meaning follows a
declared public API; build metadata does not determine precedence.
- [Syncthing releases](https://docs.syncthing.net/users/releases.html) and
[BEP](https://docs.syncthing.net/specs/bep-v1.html): product versions, peer
protocol and storage/upgrade behavior are distinct. Piik does not adopt its
minor-release REST-breaking exception for the protected interfaces above.
- [Go compatibility](https://go.dev/doc/go1compat): name the protected contract
and its limits instead of promising universal binary/runtime compatibility.
- [Debug Adapter Protocol evolution](https://microsoft.github.io/debug-adapter-protocol/overview.html):
stable baseline messages and explicit optional capabilities allow independent
endpoints to evolve. Piik uses that principle within its existing WebSocket/JSON
contracts; it does not replace them with DAP or a generic negotiation framework.
- [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) provides
the small release-intent vocabulary; standard SemVer libraries perform ordering.
- Apple's [bundle build version](https://developer.apple.com/documentation/bundleresources/information-property-list/cfbundleversion)
and [release version](https://developer.apple.com/documentation/bundleresources/information-property-list/cfbundleshortversionstring)
are numeric; package generation derives both from the release and keeps SHA separately.
- [GitHub Releases API](https://docs.github.com/en/rest/releases/releases) and
[Gitee's official API SDK reference](https://gitee.com/sdk/gitee5j/blob/main/docs/RepositoriesApi.md):
provider endpoints and metadata. [Gitee Release documentation](https://help.gitee.com/repository/release/intro)
owns its current attachment limits. Provider checks were made on 2026-09-12.
+2 -11
View File
@@ -18,12 +18,6 @@ cross-links, and mechanical freshness/structure checks. Codex concatenates the
applicable project instruction chain until `project_doc_max_bytes`, whose
default is 32 KiB.
Anthropic likewise recommends concise, specific project instructions and moves
multi-step procedures or path-specific rules to on-demand skills or scoped rule
files. Claude Code's stated target is under 200 lines per `CLAUDE.md`; imported
files still enter the startup context, so an import is organization rather than
context reduction.
Hermes distinguishes project conventions (`AGENTS.md`) from user preference,
personality, and learned memory. Its persistent memory is deliberately bounded
and curated; full session history remains searchable on demand rather than being
@@ -34,10 +28,8 @@ below that value and using nested context for genuinely distinct subtrees. Its
bounded-memory guidance starts consolidation at 80% rather than treating the
hard limit as the normal operating target.
Piik therefore warns at the published recommendation or 80% of a loader
limit, then fails only at a real loading boundary or a small explicit buffer:
32 KiB for the Codex chain, 20,000 characters for Hermes, and 250 effective
Claude lines above the 200-line recommendation. The same envelope applies to the
Piik therefore warns at 80% of a loader limit, then fails at the loading boundary:
32 KiB for the Codex chain and 20,000 characters for Hermes. The same envelope applies to the
repo Ponytail skill because `AGENTS.md` requires it for coding work. A failure tells the
maintainer to use nested/path-scoped context, on-demand skills or linked docs,
remove duplication and completed history, and delete stale conclusions; raising
@@ -47,7 +39,6 @@ Sources:
- [OpenAI Harness engineering](https://openai.com/index/harness-engineering/)
- [OpenAI Codex `AGENTS.md`](https://learn.chatgpt.com/docs/agent-configuration/agents-md)
- [Claude Code memory](https://code.claude.com/docs/en/memory)
- [Hermes file ownership](https://github.com/NousResearch/hermes-agent/blob/main/website/docs/user-guide/which-file-does-what.md)
- [Hermes context files](https://github.com/NousResearch/hermes-agent/blob/main/website/docs/user-guide/features/context-files.md)
- [Hermes persistent memory](https://github.com/NousResearch/hermes-agent/blob/main/website/docs/user-guide/features/memory.md)
-106
View File
@@ -1,106 +0,0 @@
# Backend Audit Reconciliation
- Reviewed: 2026-09-06
- External evidence baseline: `1b01048e558cfa69b2c8216e658374d06037bfe4`.
- Integration baseline: `d3232cfd2929eafc94a0e3f003cd2bcdcd47089c`.
- Scope: verified lifecycle fixes and removal of the optional Host-star route.
This is not the Go server rewrite or a new physical-media acceptance claim.
The external package contains 244 raw claims, not 244 confirmed defects.
Its handoff, source patch, and machine-readable findings remain external
evidence. This document records the independent reconciliation; the
[TODO ledger](../todo.md) alone owns remaining work.
## Accepted Corrections
- A quality candidate rejected before preparation consumes its existing
opportunity, preventing an identical prepare/deny loop.
- Disposing a Native media bridge settles its pending start. Native relay
callbacks bind the actual peer, including prepared children.
- A failed Native pause remains paused locally; a failed resume returns to the
paused intent. No new pause protocol or parallel state owner is introduced.
- Prepared Native receive deadlines follow the server operation. Viewer teardown
retires pending candidates and their recovery timers; terminal signaling
teardown uses its existing stop owner.
- Authentication is an authoritative route snapshot, even when a replacement
controller restarts revision numbering. Ordinary route updates retain their
monotonic fence; session and media identities still reject stale callbacks.
- Only explicit `committed: true` publishes a newly committed connection
identity. Stop-sharing presence is emitted after graph teardown.
- Room expiry and explicit abandonment share termination cleanup. Shutdown
closes Vite before its shared listener and attempts every SFU drain even when
one fails. Hosted shutdown reports failure explicitly.
- Native Receiver creation releases its unused GCC observer-map entry. Local
startup rejects an already occupied health port; this is a duplicate-instance
guard, not atomic socket handoff or cryptographic process identity.
- Native control keeps one reader, a bounded queue, and serial handling so socket
close can cancel an outstanding system-picker operation. Explicit share close
is a clean end; natural capture-process EOF still needs cause attribution.
- Gates use current loopback constants and explicit test STUN configuration.
Packagers reject in-repository output. Deployment templates specify production
mode and failed cutover cleanup removes its temporary links.
- Hygiene skips only GitHub's all-zero base SHA; a missing nonzero commit remains
an error. Uncalled exports and obsolete gate aliases are removed.
## Resolved Decisions
The owner removed `PEER_ASSISTED_MEDIA`. Hosted and Local now use the same
bounded graph controller; absent LiveKit configuration still means P2P-only
service. Room privacy and topology-optimization controls remain independent.
The old environment variable must be removed before the next deployment.
There is no Host-star compatibility path to port. Removing the unused
authentication fields required the coordinated private signaling v21 release;
rollback to the prior server must also restore its environment configuration.
[ADR-0009](../adr/0009-optional-nat-prediction.md) distinguishes server-owned
three-attempt NAT acquisition from active Browser edge recovery. Active recovery
first restarts ICE, then rebuilds the connection if needed. Uncommitted candidates
return failure to their route operation; the unreachable extra local restart
branch was removed. Neither kind of restart guarantees a new NAT mapping.
The App launch marker remains origin activation, not proof that the process
is alive. One failed discovery can mean contention or a permission delay, so
automatically deleting the marker would break the accepted saved-Site workflow.
Browser capture remains available in the existing picker.
## Claims Not Adopted Wholesale
- Catching every SQLite exception and continuing is not a complete recovery
strategy: failed disconnect persistence can leave an active room indefinitely.
Keep failure observable; design storage failure handling with its authority,
rather than adding scattered catch-and-continue guards.
- A Local public invitation URL does not identify the transport used by a LAN
visitor. Cookie issuance must support both HTTP LAN and HTTPS public access;
globally dropping Secure or trusting arbitrary forwarded headers is not an
acceptable shortcut.
- Audio capture can end silently while video continues. Ending healthy video is
not accepted as the fix; explicit audio availability/recovery needs a coherent
media contract. Native prepared-bridge fallback likewise needs a shared
session-level failure owner, not a per-candidate flag that is immediately lost.
- Registering Receivers with Engine, deduplicating media ledgers, splitting page
owners, and replacing full graph snapshots are refactor candidates, not proven
present-day leaks or measured performance wins.
- No replacement media pipeline, custom scoring, periodic retries, TURN, IDL
framework, compatibility readers, or new revision namespace was accepted.
## Refactor Direction
The [consolidation research](./server-consolidation.md) owns the shared Go module
layout that replaced the single TypeScript backend. Hosted and Local preserve
one graph/operation owner, with the Node runtime and superseded server packaging
removed in that same boundary.
Browser UI and native capture adapters are reuse boundaries, not rewrite targets.
Use the current behavioral tests as an oracle, not a requirement to preserve
private class structure. Extract cohesive packages without duplicating mutable
state; neither a file split nor one interface per old file is inherently an
improvement. Measure package size, startup, memory, and contract parity.
## Sources
- [WebRTC restartICE](https://www.w3.org/TR/webrtc/#dom-rtcpeerconnection-restartice)
defines ICE restart, not a promise of different network mappings.
- [coder/websocket](https://pkg.go.dev/github.com/coder/websocket)
documents the connection read and cancellation contract.
- [Go module layout](https://go.dev/doc/modules/layout)
supports multiple commands and shared internal packages.
@@ -71,6 +71,13 @@ 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`,
`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
uses an isolated App configuration and Browser profile, and rejects navigation
failures or redirects to another origin as acceptance evidence.
A self-contained LAN room uses a localhost Host URL and a selected LAN IPv4
Viewer URL backed by the same local App process. The App persists an
optional user-chosen access password and passes it only to its Host page in a
+2 -2
View File
@@ -83,8 +83,8 @@ For the permanent-room revision, an offline `DROP COLUMN` transaction on a
copy of the prior synthetic database preserved both active and formerly expired
rooms. All six remaining authority fields matched exactly; the source backup
was unchanged. The current reader accepted the converted schema without a
compatibility path. The [deployment procedure](../deployment.md#permanent-room-schema-cutover)
owns the operational steps and recovery boundary.
compatibility path. The completed cutover is retained in Git; current
[deployment recovery](../deployment.md#recovery) owns the operational boundary.
## Acceptance Matrix
-141
View File
@@ -1,141 +0,0 @@
# Piik rename
Status: accepted rename and coordinated cutover plan.
Last reviewed: 2026-09-10.
## Accepted identity
Display `Piik`; use `piik` for executable, package, environment and service
identifiers. GitHub is `TNTcraftHIM/Piik`; the Go module follows that exact case.
The [naming convention](../reference/naming.md) owns current App display and package names.
The product keeps its TV mascot. The wordmark and morph animation remain in the
[brand study](../design/piik-brand.html) for the future public website.
This is a private pre-release rename. It changes names and deployment identity,
not media policy, routing, capture behavior or SQLite room semantics.
## Recovery and local metadata
The external `Piik-recovery/rename-20260910` directory contains a verified
`repository.bundle` and a complete `git-metadata` copy. The latter preserves
reflogs and all ten stash entries, which a bundle of ordinary refs alone does
not capture. Earlier recovery bundles also remain outside the repository.
Backups keep their original names and contents so they remain identifiable.
The main directory is `Piik`; preparation worktrees are `Piik-appearance` and
`Piik-rename`, with `Piik-external-audit` retained separately. Their private Git
registration names, backlinks and shared remote are updated. The hook path is
relative `.githooks`. Retire integrated preparation worktrees after cutover.
The audit worktree's branch and source revision remain untouched. Integration
keeps one complete phase commit on main.
On Windows, a terminal can hold the repository directory open. Move the entries
into an empty verified destination and run `git worktree repair` from the new
main directory. Do not terminate the user's terminal to release a path.
Existing firewall rules name executable paths, so moved build binaries require
their corresponding path rules to be reviewed before network acceptance.
## Runtime and data boundary
`piik-v23` and `piik-client-v9` are new exact protocol labels. Their message
schemas retain versions 23 and 9; the full label, not only its numeric suffix,
is checked. Native capture remains v7. Release Web, Server, App and capture
artifacts together; old pages and installed Apps cannot interoperate across
the renamed control boundary. No alternate protocol reader is introduced.
SQLite schema 2, its application ID, room codes, token/grant digests and password
material remain unchanged. The four-byte database file identity is a format
constant, not display branding. Copy the database only while the old process is
stopped, verify integrity and retained columns, and preserve the original for
rollback. Do not recreate rooms or repeat the earlier schema-1 cutover.
The implementation renames Browser storage prefixes, the site-access cookie
and the App configuration/cache directory. The owner accepts a clean break:
copy directly compatible, accessible settings once during cutover; discard
unavailable or incompatible settings. The product reads only Piik names, with
no migration code, alternate reader or old-name alias.
The local App configuration is unchanged JSON version 1 and can be copied
byte-for-byte into the Piik directory without replacing an existing new config.
Browser credentials and settings are origin/session-owned. Transfer them only
through an available original Browser session; do not modify a live Browser's
storage database or introduce a product bridge to recover them. Otherwise the
new namespace starts empty, requiring login, room creation and preference setup.
Old room authority remains in SQLite, but an unavailable Host credential cannot
be reconstructed from its stored digest. Site-access cookies and remembered
App activation are renewed normally under the new contract.
## Git history
The owner authorized replacing the old name in Git history. Use the maintained
`git-filter-repo` tool in a fresh, separate bare clone, never in the working
repository. Scope this to commit/tag **messages**: historical source snapshots,
authorship, dates and topology remain factual. Current tracked prose and code
use the accepted identity; old snapshots remain recoverable at their mapped
commits and in the original archive.
The initial inventory found two matching commit bodies and no matching commit
subjects or tag names. A small text change still changes descendant SHAs and
invalidates their commit signatures. Preserve `commit-map` and `ref-map`; verify
equal commit counts, identical source trees, mapped parents, authors and dates,
no dropped commits and a clean repository integrity check. No replacement refs,
duplicate compatibility branches or history-rewriting tool enter the product.
The rehearsal verified all 546 reachable branch/tag commits: exactly two messages
changed, all file trees and mapped parent relationships match, and authors/dates
are preserved. Standard filtering rewrote 545 IDs and removed 374 signatures.
Those signatures remain verifiable only in the original archive; a rewritten
commit must not be presented as preserving its earlier GitHub verification.
Apply a reviewed message rewrite only with the coordinated brand integration,
using explicit ref targets and old-SHA leases. Do not use `push --mirror` from
the rehearsal: it could overwrite unrelated refs. Preserve the external audit
branch and stash history in the original repository. Old audit/deployment SHAs
remain evidence identifiers, interpreted through the archive and commit map.
## Operational cutover
The following old identifiers are retained here only as operator recovery
inputs, not as runtime aliases:
| Surface | Before | After |
| --- | --- | --- |
| Service, user and group | `screener` | `piik` |
| Installation root | `/opt/screener` | `/opt/piik` |
| Environment file | `/etc/screener/screener.env` | `/etc/piik/piik.env` |
| Branded environment prefix | `SCREENER_` | `PIIK_` |
| Browser storage prefix | `screener:` | `piik:` |
| App config directory | `Screener` | `Piik` |
Prepare the new immutable artifacts, service/environment/proxy configuration,
permissions and database path before stopping the old service. Record original
configuration and ownership for recovery. Do not run two room authorities on
the same database or two media services on the same UDP ports.
The routine `deploy/release-app.sh` assumes an existing **Piik** installation.
It is not the first brand migration tool: its old-release checks and recovery
target the Piik service/binary. Perform the first name cutover as one explicit
infrastructure operation, then use the ordinary wrapper for future releases.
On failure, stop the candidate and restore the old binary, service, environment,
proxy and data together before reopening ingress.
GitHub is already renamed and the remote works. There are no published releases
or Pages site to migrate. Keep repository visibility private; `piik.tv` DNS and
Pages publication remain a separate website task. Old GitHub deployment/PR
records describe earlier operations and are not rewritten as new releases.
## Validation and references
Acceptance covers the tracked-name inventory, Web type/build checks, relevant
contract/storage tests, native compilation, App/Server packaging and a
controlled local launch. Production release additionally requires the accepted
best-effort settings transfer, configuration/database recovery and postflight.
Primary references, checked 2026-09-10:
- [Git worktree repair](https://git-scm.com/docs/git-worktree): repair both
directions after moving the main repository or linked worktrees.
- [git-filter-repo](https://github.com/newren/git-filter-repo/blob/main/Documentation/git-filter-repo.txt):
message replacement, fresh clones and old/new commit maps.
- [GitHub repository rename](https://docs.github.com/en/repositories/creating-and-managing-repositories/renaming-a-repository):
ordinary URL redirects do not cover repository-hosted Actions or project Pages.
+2 -3
View File
@@ -32,9 +32,8 @@ remove.
and exact direct/peer-relayed evidence sources.
These are structural simplifications, not reproduced functional failures.
The [audit reconciliation](./backend-audit-1b01048.md) separates verified fixes
from disputed recommendations. Its external staged plan is not an instruction
to perform extractions for their own sake.
The [engineering reference](../reference/engineering.md#ablation-and-review)
requires a concrete ownership gain before further extraction.
## Product Choice
+31 -14
View File
@@ -1,6 +1,6 @@
# Current Status
Last updated: 2026-09-10
Last updated: 2026-09-12
This is the compact execution/deployment index. Product modules own behavior,
[verification status](./verification-status.md) owns unresolved physical limits,
@@ -8,9 +8,9 @@ and Git/PRs own completed history.
## Accepted Release Contract
- Browser/server v23, Native control v9 and capture v7 form one private contract.
The release requires matching Web/App/Server artifacts and a reload of old
active pages at cutover.
- Browser/server v23, Native control v9 and capture v7 form the current contract.
The coordinated brand/protocol cutover requires matching Web/App/Server
artifacts and a reload of incompatible active pages.
- Hosted Server defaults to SQLite schema 2. Room authority has no inactivity
expiry; explicit replacement/deletion or grant rotation/revocation ends the
corresponding authority. Explicit memory mode and App Local end rooms at
@@ -24,23 +24,40 @@ and Git/PRs own completed history.
## Deployment
Source, package identity, GitHub repository and local worktree paths use Piik.
The strict wire labels and stored keys move together at the coordinated
[brand cutover](./research/piik-rename-plan.md). Directly compatible, accessible
settings are transferred once; unavailable settings start fresh. Runtime code
keeps only the Piik contract.
Runtime code keeps only the Piik wire labels and stored keys. The coordinated
private cutover requires matching App/Server/Web packages; unavailable old
Browser credentials cannot be reconstructed from stored digests.
The immutable release descriptor, runtime `REVISION` and operator deployment
record own exact identity and postflight results. The
[schema cutover](./deployment.md#permanent-room-schema-cutover) preserved existing
room authority and Browser credential keys. Old active pages reload at the
signaling v23 boundary.
record own exact identity and postflight results. Old active pages reload at
the signaling v23 boundary.
The existing deployment uses one Go process for Web, room authority/signaling,
Binding-only STUN on UDP 3478/3479/3480 and optional SFU on UDP 7882. nginx owns
HTTPS; external LiveKit/coturn services remain disabled. Participants, routes and
media remain process-only. This private service is not a public demonstration.
[TODO](./todo.md) owns rename acceptance, distribution and ablation work.
The public website is a later phase.
The separate US [public demo](https://demo.piik.tv) is deployed with open site
entry, persistent room authority and P2P-only media. Caddy owns HTTPS, and the
standard systemd service runs the verified Server test candidate. Public DNS,
HTTPS, runtime assets, all three STUN listeners and a bounded browser P2P
sharing/stop check passed; the browser check used synthetic video on one network.
The operator record owns exact identity and postflight evidence.
[TODO](./todo.md) owns remaining publication work. The owner accepted the public
introduction and authorized launch on 2026-09-12. The source repository is public,
main protection is active, and GitHub Pages is configured for `piik.tv`. The
private service remains a separate deployment. Release descriptors and GitHub
deployment records identify published artifacts and website revisions.
The Windows test archive passed complete anonymous Gitee download, checksum,
App entry/presentation and update-link acceptance. Bounded two-build
interoperability also passed. The complete publisher rehearsal previously hit
the private repository's Actions artifact quota and must complete on the public
repository before package publication. GitHub/Gitee release comparison, mirror
publishing and PR-sourced release notes are implemented under
[versioning](./reference/versioning.md). [Verification status](./verification-status.md#candidate-evidence-boundary)
owns the remaining physical limits; shared lessons live in
[engineering](./reference/engineering.md).
## Media Evidence And Limits
+79 -111
View File
@@ -1,95 +1,48 @@
# Current TODO Ledger
Last reviewed: 2026-09-10
Last reviewed: 2026-09-12
Only **Now** is executable. Product modules own behavior, research owns evidence,
and Git/PRs own completed history. A parked idea is not implementation authority.
Only **Now** is executable. Product modules own behavior; Git/PRs own completed
history. A parked idea is not implementation authority.
## Now
After the accepted App naming release, draft the English/Chinese README,
onboarding and GitHub Pages website as one coherent public introduction.
Use the [naming convention](./reference/naming.md), the existing mascot and
the accepted website-only brand animation. Preview locally before publication;
keep private deployment details out of the public site. Preserve the rename
recovery archive and external-audit worktree. Release metadata owns deployed identity.
- [ ] **Release and repository readiness.** Review the prepared Windows test
candidate. Run native CI on the now-public repository, then
rehearse the publisher with genuine matching Server and App packages. Old cloud
artifacts have already been removed; if quota rejection persists, inspect
account billing and Packages usage with credentials that can read those scopes.
Run the final source's required regression checks, prepare one coherent squash
PR, accept the [first public release](./reference/versioning.md#first-public-release-readiness),
and finish [publishing activation](./operations/github.md).
Test-candidate checks do not accept the public release. Keep the owner's
[device/network deferrals](./verification-status.md#candidate-evidence-boundary).
- [ ] **Gitee package acceptance.** The public README mirror, publisher secret,
upload pipeline and App/Server update fallback are implemented. The complete
Windows test archive passed anonymous download, checksum and App entry
acceptance. Verify the complete matching Server and three-platform
App release, total attachment capacity and real update/download flow
under the [release model](./reference/versioning.md#release-sources) before launch.
- [ ] **Website launch.** Publish the accepted website through GitHub Pages
and verify piik.tv under
[website operations](./operations/website.md). Verify all public destinations,
including the deployed password-free, P2P-only demo.piik.tv;
the current private production service remains private.
## Next — Awaiting Owner Direction
Keep implementation on the existing phase branch. Main and the private service stay
unchanged until the related acceptance and delivery actions are complete.
The owner owns **piik.tv**.
Review deployment feature switches, starting with an explicit P2P-only mode:
the operator must be able to disable SFU in configuration while retaining STUN,
room authority/signaling and P2P viewing. Current `SFU_UDP_PORT` already controls
SFU availability; inspect that contract and the forced-private-mode UI before
adding a second boolean with potentially conflicting meaning. Review which
other options belong to deployment configuration, runtime parameters or fixed
implementation constants. Keep this separate from public-facing documentation drafting.
## Next: P2P Connection And Feedback Review
The owner proposes GitHub Pages for the public website and the dedicated US
test server for a separate P2P-only demonstration site. Plan this after naming
and configuration review; no public demo is deployed yet. The existing private
production site remains private. P2P-only has no SFU fallback when direct/peer
paths cannot connect; the demonstration must describe that actual capability.
Redesign the README, onboarding guides and Wiki/documentation navigation as one
coherent release-preparation task. The owner is dissatisfied with the current
structure; do not keep polishing it piecemeal. Until then, change only necessary
names, runnable commands and links. Keep the new public entry brief, visual and
easy to follow, with deeper technical detail in developer guides.
Design the GitHub Pages website around the accepted brand study. Keep one
current internal contract; third-party names/notices remain accurate. DNS/Pages
and public publication are separate from the repository rename. The private
production service is not a public demo.
Include the owner-requested frontend/backend ablation review in that phase.
Review duplicated behavior/state, module boundaries, interface clarity, lifecycle
ownership, cohesion and coupling. Reuse mature modules and stable shared behavior.
The primary goal is lower total complexity, not perfect performance: small gains
do not justify permanent mechanisms or larger maintenance cost. Preserve viewing
quality and product behavior; fewer lines alone do not justify a rewrite.
Use the App discovery and settings-ownership findings as leads for a
repository-wide lifecycle audit across Web UI, shared Go server, App and
media adapters. This is future work, not an expansion of the current entry fix.
Look for these recurring patterns:
- Permission, remembered activation, capability, connection readiness and live
operation ownership represented by the same flag or inferred from UI state.
- Cached promises retaining `null`, rejection or a closed resource indefinitely;
failure latches whose lifetime exceeds the evidence that justified them.
- Results after `await`, callbacks and `finally` writing shared state or closing
the current resource without checking the original operation/resource owner.
- Cancellation, failure and absent observation sharing a return value that a
caller interprets as success; review the complete caller chain.
- Connections, slots, listeners and queues acquired before actual demand, kept
after their consumer ends, or retired while another consumer still owns them.
- Draft, requested, applied and server-acknowledged settings overwriting one
another; duplicated writers or status models that can contradict real media.
Search results are leads, not defect evidence. Trace acquisition through use,
commit and retirement, including error paths and shared consumers. Reproduce
representative interleavings: A starts, is cancelled/replaced, B starts, then A
completes or fails. Check both obsolete writes and obsolete cleanup, plus
stop/restart and transient-failure recovery. Fix the owning boundary and remove
redundant state rather than add case-specific flags, timers or a generic manager.
Keep confirmed repairs separate from documented tradeoffs and unsupported
suspicions; retain only focused checks that demonstrate a meaningful failure.
English/Chinese Quick Start, documentation map and license guidance are prepared.
Keep the README short and friendly, with the logo and a small usage illustration;
detailed deployment/development guidance belongs in the linked guides. Draft a
static GitHub Pages homepage with tutorials and self-contained demonstrations
matching the living-room UI. Review final release assets, update/replacement
guidance and public distribution alongside the new brand; existing packagers and
Docker recipe are the baseline.
Keep expensive packaging manual and branch CI quiet. No broad media redesign is
part of naming or documentation work.
[Verification status](./verification-status.md) owns broader physical limits.
NAT and Auto are complete features; further statistical/hardware research is not
required to finish this phase. Run any new physical workload serially, with stable
executable paths and cleanup before the next.
After the current phase, measure connection success, time to first picture and
failure causes on representative networks, especially App and P2P-only sites.
Review user progress/failure feedback and useful privacy-conscious diagnostics.
Trace the existing ICE and P2P/SFU handoffs, including background P2P attempts
behind working SFU media; compare SFU-first startup with measured current behavior
before choosing changes. Preserve one graph and one operation under the
[routing contract](./product/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.
## Parked Product Work
@@ -101,41 +54,56 @@ reproduce them. Missing NAT attempt text alone does not prove skipped attempts.
If Native adaptation is implicated, compare actual VSE limitations with sender
quality evidence before changing policy. Do not add retry budgets, visibility
resets or resolution heuristics without proof. Sanitized aggregates and the
isolated reconnect reproduction remain locally in `build/room7534-investigation/`.
isolated reconnect reproductions are diagnostic evidence, not a recovery policy.
Game/background settings behavior also remains an unconfirmed incident lead;
reopen from actual control actions and matching requested/applied media evidence.
Clarify whether Viewer-local pause should survive replacement media before
adding playback-intent state; the current binding starts new media unless the
Host is paused. No preservation policy has been accepted for that transition.
1. **Public distribution and updates.** Candidate packagers, immutable descriptors,
notices, the runtime-only OCI recipe and release checks exist. Choose the
public asset set and release notes before publishing a full-SHA GitHub Release
or image. Do not add automatic installation, container self-update, Watchtower,
compatibility ranges or active-share interruption without distribution and
recovery evidence. Ordinary branch pushes must not run expensive CI packaging.
2. **Representative device/network acceptance.** Exercise public-network direct
and relay paths, two-room SFU, real-game A/V, twenty-Viewer endurance,
sustained loss/recovery and all-UDP-blocked bounded failure. Correlate freezes
with publisher/receiver evidence before changing representation policy.
Physical Android/iOS work includes autoplay, background/lock, rotation,
network migration and relay survival. Secondary desktop platform matrices
remain explicitly deferred; compilation does not prove capture.
3. **Broader quality work.** Reopen from measured benefit at acceptable complexity.
1. **Representative device/network acceptance.** Resume the remaining matrix in
[verification status](./verification-status.md#remaining-device-and-network-acceptance)
when directed, preserving the owner's platform deferrals. Correlate freezes
with publisher/receiver evidence before changing media policy. Run physical
workloads serially from stable executable paths with cleanup between runs.
2. **Broader quality work.** Reopen from measured benefit at acceptable complexity.
Preserve chosen profiles, bitrate ceilings, endpoint capacity and P2P-first
routing unless a new accepted decision supports changing them. No weighted
score, all-pairs probes, periodic rebalancing, parent-wide prediction or
room-wide minimum. Check whether a quality move merely shifts pressure to
another parent's siblings before widening policy.
4. **Control/resource fairness and input review.** Reassess the authenticated
3. **Control/resource fairness and input review.** Reassess the authenticated
WebSocket/SFU owner, HTTP/body/resource bounds, authorization and error/log
handling before adding a queue or limiter. No parallel security framework,
accounts, risk score or speculative policy layer.
5. **Reachable ownership/refactor work.** Extract page media-session owners only
alongside behavior changes; file size alone is not a rewrite reason. Reopen
C=3 structural-intent retention, SFU failure during unrelated prepare and
multi-child evidence ownership only with current-contract reproductions.
Measure signaling-lock contention and synchronous diagnostic I/O before
changing the lock or execution model; their cost remains a tradeoff.
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
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
reproductions.
No new revision namespace, failure-state mirror or topology queue by default.
6. **Storage fault recovery.** Ordinary transactions persist before memory
changes; failures must preserve authority. Message-handler panics must unwind
locks. Damaged-disk/COMMIT/ROLLBACK recovery is not established; choose its
policy explicitly rather than adding catch-and-continue or retries.
7. **Platform output.** System/tab mirroring needs no adapter. Remote Playback,
Cast and AirPlay do not provide a portable live MediaStream receiver.
Reopen for a registered receiver acting as an ordinary Viewer after the
[platform-output gate](./research/platform-output.md) passes.
5. **Storage fault recovery.** Choose and verify a damaged-disk/COMMIT/ROLLBACK
recovery policy before adding catch-and-continue or retries. This failure
boundary remains unestablished after ordinary persistence checks.
6. **Platform output.** Reopen for a registered receiver acting as an ordinary
Viewer only after the [platform-output gate](./research/platform-output.md)
passes.
7. **Full UI themes.** After the base version, research themes that can change
layout, composition and motion, including a restrained graphic/cinematic
direction with original Piik assets. Assess extension boundaries and cost
before scheduling any theme/plugin API. Design experiments remain in Git
history, outside the main source tree.
8. **Visual presentation and community translations.** In a later version,
explore bringing the pure-visual mode's illustrations and motion into the
Chinese and English interfaces. Evaluate whether a separate pure-visual mode
remains useful, and prepare the existing locale catalogs for community
translations. No removal or new translation framework is scheduled for this
release.
9. **Website illustration polish.** Match the Host's held prop to the four
existing hero activities: gamepad, paintbrush, camera and remote. Keep the
accepted bright cast, floating hands and shared activity timing.
+25 -3
View File
@@ -1,6 +1,6 @@
# Verification Status
Last updated: 2026-09-09
Last updated: 2026-09-11
This file owns physical evidence limits that change how the product may be
described. [Status](./status.md) owns the execution/deployment index.
@@ -11,8 +11,7 @@ implementation and obsolete package history.
Windows App and Browser are the owner's primary acceptance targets. Current
Go packages embed their Web assets and do not run a Node backend. The current
contract is Browser/server v22, Native control v9 and capture v7; pre-port
App/Node package checks do not prove this package.
contract is Browser/server v23, Native control v9 and capture v7.
Bounded current checks cover:
@@ -41,6 +40,29 @@ Neither a statistical NAT campaign nor exhaustive Auto hardware benchmarking
is required to close this phase. Investigate further only from a new failure
or a measured improvement worth its implementation and maintenance cost.
## Candidate Evidence Boundary
The 2026-09-11 functional test acceptance used two genuine Windows App packages
and matching Server source/Web builds under the same private contract. Both App/Site version
directions delivered decoded 1080p H264 from an isolated native test window.
Replacing the Server process preserved SQLite Host authority, private code-entry
policy, Viewer grant/password authorization and App configuration. Already-open
Host/Viewer pages reauthenticated without reloading and completed subsequent
sharing. Exact measured identity belongs to the package descriptors and local
structured acceptance result; Git retains the preparation history.
This is bounded single-machine Windows evidence. The local Server executables
were built for Windows from the matching source; this run did not execute the
Linux release archives. Process replacement used a forced stop on Windows and
does not promise uninterrupted media. It does not cover game/audio endurance,
storage faults, the wider device/network matrix or Go race detection.
The full publisher dry run with genuine matching target artifacts remains
unverified. The native CI candidate run passed validation and Server packaging
but hit the Actions artifact-storage quota before App jobs could start. Native
macOS tooling remains necessary. [TODO](./todo.md) owns the remaining publisher
rehearsal and owner acceptance; the broader platform deferrals below still apply.
## Remaining Device And Network Acceptance
The following are broader coverage and endurance tasks, not automatically new
+1
View File
@@ -28,6 +28,7 @@ require (
github.com/thesyncim/gopus v0.1.1
go.uber.org/zap v1.28.0
golang.org/x/crypto v0.54.0
golang.org/x/mod v0.38.0
golang.org/x/text v0.41.0
google.golang.org/protobuf v1.36.11
modernc.org/sqlite v1.58.0
+11 -5
View File
@@ -8,11 +8,17 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Piik</title>
<script>
try {
var theme = localStorage.getItem("piik:ui-theme") ||
(matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
document.documentElement.dataset.theme = theme;
} catch (e) {}
var systemTheme = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
var theme;
try { theme = localStorage.getItem("piik:ui-theme"); } catch (e) {}
var launch = new URLSearchParams(location.hash.slice(1));
var launchTheme = launch.get("piik-theme");
if (launch.get("piik-client") === "1" &&
(launchTheme === "light" || launchTheme === "dark" || launchTheme === "system")) {
theme = launchTheme === "system" ? systemTheme : launchTheme;
}
document.documentElement.dataset.theme =
theme === "light" || theme === "dark" ? theme : systemTheme;
</script>
</head>
<body>
+13 -1
View File
@@ -28,6 +28,7 @@ import (
serverapp "github.com/TNTcraftHIM/Piik/internal/server/app"
serverconfig "github.com/TNTcraftHIM/Piik/internal/server/config"
"github.com/TNTcraftHIM/Piik/internal/server/webassets"
"golang.org/x/mod/semver"
)
const (
@@ -41,8 +42,17 @@ const (
localShutdownTimeout = 5 * time.Second
)
var BuildVersion = "development"
var BuildRevision = "development"
func buildVersion() string {
version := strings.TrimSpace(BuildVersion)
if version != "" && semver.Canonical(version) == version && semver.Prerelease(version) == "" {
return version
}
return "development"
}
type Options struct {
Site string
SiteSet bool
@@ -87,6 +97,7 @@ func Run(ctx context.Context, options Options) (returnedErr error) {
if err != nil {
return fmt.Errorf("Piik App diagnostics are unavailable: %w", err)
}
recorder.Context("build", map[string]any{"version": buildVersion(), "revision": BuildRevision})
previous, previousWriter, previousFlags := slog.Default(), log.Writer(), log.Flags()
var dependencyLog *diagnostics.LineWriter
restoreLogger = func() {
@@ -102,7 +113,7 @@ func Run(ctx context.Context, options Options) (returnedErr error) {
log.SetFlags(previousFlags)
options.console.send(consoleDebug{logPath: recorder.LogPath(), export: recorder.Export})
}
slog.Debug("piik-client", "event", "start", "revision", BuildRevision)
slog.Debug("piik-client", "event", "start", "version", buildVersion(), "revision", BuildRevision)
options.console.show(consoleView{state: "starting"})
if err := validateMode(options); err != nil {
return err
@@ -192,6 +203,7 @@ func runLauncher(
ctx,
webassets.FS(),
config.Site,
buildVersion(),
BuildRevision,
config.LocalAccessPassword,
)
+55
View File
@@ -3,20 +3,74 @@ package app
import (
"bytes"
"errors"
"fmt"
"log"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
appconfig "github.com/TNTcraftHIM/Piik/internal/app/config"
"github.com/TNTcraftHIM/Piik/internal/app/loopback"
serverconfig "github.com/TNTcraftHIM/Piik/internal/server/config"
)
func TestSavedSiteAllowsBrowserOriginAtNativeControl(t *testing.T) {
for _, site := range []struct{ input, origin string }{
{"https://Share.Example:443", "https://share.example"},
{"http://share.example:80", "http://share.example"},
{"http://share.example:8787", "http://share.example:8787"},
} {
t.Run(site.input, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "client.json")
payload := fmt.Sprintf(`{"version":1,"localAccessPassword":"","site":%q}`, site.input)
if err := os.WriteFile(path, []byte(payload), 0o600); err != nil {
t.Fatal(err)
}
configuration, err := appconfig.LoadOrCreate(path)
if err != nil {
t.Fatal(err)
}
control, err := loopback.Start(t.Context(), loopback.Options{
AllowedOrigins: allowedOrigins(configuration.Site, 8787),
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = control.Close() })
client := &http.Client{Timeout: 3 * time.Second}
for _, probe := range []struct {
method, origin string
status int
}{
{http.MethodOptions, site.origin, http.StatusNoContent},
{http.MethodGet, site.origin, http.StatusOK},
{http.MethodOptions, "https://other.example", http.StatusForbidden},
} {
request, err := http.NewRequest(probe.method, control.Endpoint().URL+"/health", nil)
if err != nil {
t.Fatal(err)
}
request.Header.Set("Origin", probe.origin)
response, err := client.Do(request)
if err != nil {
t.Fatal(err)
}
response.Body.Close()
if response.StatusCode != probe.status {
t.Fatalf("%s health from %q = %d, want %d", probe.method, probe.origin, response.StatusCode, probe.status)
}
}
})
}
}
func TestOccupiedPortRejectsLinkBeforeStartingATunnel(t *testing.T) {
listener, err := net.ListenTCP("tcp4", &net.TCPAddr{IP: net.IPv4zero})
if err != nil {
@@ -83,6 +137,7 @@ func TestAppDiagnosticsUseFilesOnlyWhenEnabled(t *testing.T) {
continue
}
if readErr != nil || !strings.Contains(string(content), `"event":"start"`) ||
!strings.Contains(string(content), `"version":"`+buildVersion()+`"`) ||
!strings.Contains(string(content), `"revision":`) ||
!strings.Contains(string(content), `"event":"stopped","failed":true`) {
t.Fatalf("missing file lifecycle events: %s, %v", content, readErr)
+3 -1
View File
@@ -11,6 +11,8 @@ import (
"path/filepath"
"regexp"
"strings"
serverconfig "github.com/TNTcraftHIM/Piik/internal/server/config"
)
const (
@@ -101,7 +103,7 @@ func NormalizeSite(value string) (string, error) {
parsed.RawQuery != "" || parsed.Fragment != "" {
return "", errors.New("Piik Site must be an HTTP or HTTPS origin")
}
return parsed.Scheme + "://" + parsed.Host, nil
return serverconfig.Origin(parsed), nil
}
// NormalizeLocalAccessPassword keeps the local authority open by default while
+10 -5
View File
@@ -57,12 +57,17 @@ func TestSavePersistsOneNormalizedSiteChoice(t *testing.T) {
}
func TestNormalizeSiteAcceptsOnlyAPlainWebOrigin(t *testing.T) {
for _, value := range []string{
"https://share.example",
"http://192.168.1.4:8787/",
for value, want := range map[string]string{
"https://share.example": "https://share.example",
"https://Share.Example:443/": "https://share.example",
"http://Share.Example:80": "http://share.example",
"https://share.example:0443": "https://share.example",
"http://192.168.1.4:8787/": "http://192.168.1.4:8787",
"http://share.example:443": "http://share.example:443",
"https://[::1]:443": "https://[::1]",
} {
if _, err := NormalizeSite(value); err != nil {
t.Fatalf("NormalizeSite(%q) = %v", value, err)
if got, err := NormalizeSite(value); err != nil || got != want {
t.Fatalf("NormalizeSite(%q) = %q, %v; want %q", value, got, err, want)
}
}
for _, value := range []string{
+5 -1
View File
@@ -281,7 +281,11 @@ func (model consoleModel) content(styled bool) string {
revision = revision[:7]
}
var out strings.Builder
heading := "Piik\nApp\n" + muted.Render(revision)
version := buildVersion()
heading := "Piik\nApp\n" + muted.Render(version)
if revision != "" && revision != version {
heading = "Piik App\n" + muted.Render(version) + "\n" + muted.Render(revision)
}
animated := styled && model.animating()
mascot := accent.Render(consoleTV(model.frame, animated, compact || width < 34))
if styled && model.colors && width >= 36 && !compactDebug {
+21
View File
@@ -111,6 +111,27 @@ func TestConsolePresentationAndShutdown(t *testing.T) {
}
}
func TestConsoleBuildVersionAndRevision(t *testing.T) {
previousVersion, previousRevision := BuildVersion, BuildRevision
t.Cleanup(func() { BuildVersion, BuildRevision = previousVersion, previousRevision })
BuildRevision = strings.Repeat("a", 40)
for _, test := range []struct{ version, want string }{
{"v1.2.3", "v1.2.3"},
{"development", "development"},
{"", "development"},
{"v01.2.3", "development"},
{"v1.2", "development"},
{"v1.2.3-preview.1", "development"},
{"v1.2.3+build", "development"},
} {
BuildVersion = test.version
content := (consoleModel{width: 80, language: "en", view: consoleView{state: "ready"}}).content(false)
if !strings.Contains(content, test.want) || !strings.Contains(content, "aaaaaaa") || strings.Contains(content, BuildRevision) {
t.Fatalf("console identity for %q: %s", test.version, content)
}
}
}
func TestConsoleDiagnosticExportRunsOutsideUpdate(t *testing.T) {
called := 0
path := filepath.Join(t.TempDir(), "client-report.zip")
+4 -1
View File
@@ -48,6 +48,7 @@ type Server struct {
static http.Handler
site string
localAccessPassword string
version string
revision string
selection chan Selection
resultReady chan struct{}
@@ -68,7 +69,7 @@ type Server struct {
// Start serves the launcher page and its API on a loopback port. assets is the
// built Browser UI, which the App embeds; a nil file system means the binary
// carries no build and the launcher cannot run.
func Start(parent context.Context, assets fs.FS, site, revision, localAccessPassword string) (*Server, error) {
func Start(parent context.Context, assets fs.FS, site, version, revision, localAccessPassword string) (*Server, error) {
if parent == nil {
parent = context.Background()
}
@@ -100,6 +101,7 @@ func Start(parent context.Context, assets fs.FS, site, revision, localAccessPass
static: http.FileServer(http.FS(assets)),
site: site,
localAccessPassword: normalizedPassword,
version: version,
revision: strings.TrimSpace(revision),
selection: make(chan Selection, 1),
resultReady: make(chan struct{}),
@@ -201,6 +203,7 @@ func (server *Server) handleState(response http.ResponseWriter, request *http.Re
writeJSON(response, http.StatusOK, map[string]any{
"site": server.site,
"localAccessPassword": server.localAccessPassword,
"version": server.version,
"revision": server.revision,
"defaultMode": func() Mode {
if server.site != "" {
+16 -17
View File
@@ -31,11 +31,12 @@ func TestLauncherServesStateAndCompletesOneSelection(t *testing.T) {
Site string `json:"site"`
LocalAccessPassword string `json:"localAccessPassword"`
DefaultMode Mode `json:"defaultMode"`
Version string `json:"version"`
Revision string `json:"revision"`
}
if response.StatusCode != http.StatusOK || json.NewDecoder(response.Body).Decode(&state) != nil ||
state.Site != "https://share.example" || state.DefaultMode != ModeSite ||
state.Revision != "" || state.LocalAccessPassword != "" {
state.Version != "development" || state.Revision != "" || state.LocalAccessPassword != "" {
t.Fatalf("launcher state = %d, %+v", response.StatusCode, state)
}
@@ -80,9 +81,10 @@ func TestLauncherServesStateAndCompletesOneSelection(t *testing.T) {
}
}
func TestLauncherIncludesTheInjectedBuildRevision(t *testing.T) {
func TestLauncherIncludesTheInjectedBuildVersionAndRevision(t *testing.T) {
const version = "v1.2.3"
const revision = "0123456789abcdef0123456789abcdef01234567"
server := startFixtureWithRevision(t, "", revision)
server := startFixtureWithBuildAndPassword(t, "", version, revision, "")
origin := strings.TrimSuffix(server.URL(), "/client")
response, err := http.Get(origin + "/api/client-launcher")
if err != nil {
@@ -90,11 +92,12 @@ func TestLauncherIncludesTheInjectedBuildRevision(t *testing.T) {
}
defer response.Body.Close()
var state struct {
Version string `json:"version"`
Revision string `json:"revision"`
}
if response.StatusCode != http.StatusOK || json.NewDecoder(response.Body).Decode(&state) != nil ||
state.Revision != revision {
t.Fatalf("launcher revision = %d, %+v", response.StatusCode, state)
state.Version != version || state.Revision != revision {
t.Fatalf("launcher build = %d, %+v", response.StatusCode, state)
}
}
@@ -147,17 +150,17 @@ func TestLauncherCarriesAndNormalizesAnOptionalLocalPassword(t *testing.T) {
}
func TestLauncherRejectsAnInvalidSavedSite(t *testing.T) {
if _, err := Start(t.Context(), appAssets(), "https://example.test/path", "", ""); err == nil {
if _, err := Start(t.Context(), appAssets(), "https://example.test/path", "development", "", ""); err == nil {
t.Fatal("launcher accepted a Site path")
}
}
// A binary built without the Browser build has nothing to launch.
func TestLauncherRequiresTheEmbeddedBrowserBuild(t *testing.T) {
if _, err := Start(t.Context(), nil, "", "", ""); err == nil {
if _, err := Start(t.Context(), nil, "", "development", "", ""); err == nil {
t.Fatal("launcher started without embedded assets")
}
if _, err := Start(t.Context(), fstest.MapFS{}, "", "", ""); err == nil {
if _, err := Start(t.Context(), fstest.MapFS{}, "", "development", "", ""); err == nil {
t.Fatal("launcher started without an index document")
}
}
@@ -238,23 +241,19 @@ func TestLauncherRejectsCrossOriginControl(t *testing.T) {
}
func startFixture(t *testing.T, site string) *Server {
return startFixtureWithRevisionAndPassword(t, site, "", "")
}
func startFixtureWithRevision(t *testing.T, site, revision string) *Server {
return startFixtureWithRevisionAndPassword(t, site, revision, "")
return startFixtureWithBuildAndPassword(t, site, "development", "", "")
}
func startFixtureWithPassword(t *testing.T, site, password string) *Server {
return startFixtureWithRevisionAndPassword(t, site, "", password)
return startFixtureWithBuildAndPassword(t, site, "development", "", password)
}
func startFixtureWithRevisionAndPassword(
func startFixtureWithBuildAndPassword(
t *testing.T,
site, revision, password string,
site, version, revision, password string,
) *Server {
t.Helper()
server, err := Start(t.Context(), appAssets(), site, revision, password)
server, err := Start(t.Context(), appAssets(), site, version, revision, password)
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -17,7 +17,7 @@ import (
"github.com/coder/websocket/wsjson"
)
const testOrigin = "https://share.bonfire.icu"
const testOrigin = "https://share.example.com"
func TestStartServesHealthAndStrictControlHandshake(t *testing.T) {
expectedMedia := NativeMediaCapabilities{
+4 -12
View File
@@ -17,20 +17,17 @@ import (
"github.com/TNTcraftHIM/Piik/internal/server/protocol"
)
// Ported from src/server/access-session.ts.
const (
defaultSessionTTLSeconds = 24 * 60 * 60
cookieVersion = "v1"
)
var (
// TS: /^\d+$/ over the expiry segment.
accessExpiresPattern = regexp.MustCompile(`^[0-9]+$`)
// TS: /^[A-Za-z0-9_-]{43}$/ — base64url of a 32-byte HMAC, unpadded.
accessExpiresPattern = regexp.MustCompile(`^[0-9]+$`)
accessSignaturePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`)
)
// siteAccessOptions mirrors SiteAccessOptions in access-session.ts.
// siteAccessOptions configures cookie lifetime, signing and transport policy.
type siteAccessOptions struct {
// Password empty means site access is not required.
Password string
@@ -38,7 +35,7 @@ type siteAccessOptions struct {
Secure bool
// Now returns Unix milliseconds; nil uses the wall clock.
Now func() int64
// TTLSeconds of 0 stands for TS's absent `ttlSeconds`, i.e. 24 hours.
// TTLSeconds of 0 selects the default 24-hour lifetime.
TTLSeconds int
}
@@ -75,7 +72,6 @@ func newSiteAccess(options siteAccessOptions) (*siteAccessGate, error) {
if ttlSeconds == 0 {
ttlSeconds = defaultSessionTTLSeconds
}
// TS: !Number.isSafeInteger(ttlSeconds) || ttlSeconds <= 0.
if ttlSeconds < 0 || int64(ttlSeconds) > protocol.MaxSafeInteger {
return nil, errors.New("Access session TTL must be a positive integer")
}
@@ -111,9 +107,6 @@ func (a *siteAccessGate) isAuthenticated(cookieHeader string) bool {
if value == "" {
return false
}
// TS: const [version, expiresText, signature, ...extra] = value.split(".")
// Fewer than three segments fail the two regexes below (an absent segment
// is tested as ""), more than three fill `extra`.
segments := strings.Split(value, ".")
if len(segments) != 3 ||
segments[0] != cookieVersion ||
@@ -121,8 +114,7 @@ func (a *siteAccessGate) isAuthenticated(cookieHeader string) bool {
!accessSignaturePattern.MatchString(segments[2]) {
return false
}
// TS: Number(expiresText) then Number.isSafeInteger. The segment is all
// digits, so only an out-of-range value can fail here.
// Expiry is already decimal; parsing still enforces integer bounds.
expiresAt, err := strconv.ParseInt(segments[1], 10, 64)
if err != nil || expiresAt > protocol.MaxSafeInteger {
return false
+2 -10
View File
@@ -1,6 +1,5 @@
// Package app ports src/server/app.ts: the HTTP composition, the startup and
// shutdown lifecycle, and the request router. access.go ports
// src/server/access-session.ts and static.go the sirv frontend handler.
// Package app composes the shared HTTP service, site access, embedded assets,
// media listeners, and startup/shutdown lifecycle for Hosted Server and App.
//
// # Locking
//
@@ -208,13 +207,6 @@ func newRoomStore(options Options) (*room.Store, error) {
})
}
// Handler is the composed router, exposed so a host process can mount it.
func (s *Server) Handler() http.Handler { return s }
// Store is PiikServer.roomStore. The signaling server's mutex guards it, so
// callers outside that lock may only read it while no request is in flight.
func (s *Server) Store() *room.Store { return s.store }
// Listen binds application sockets before recovering room authority and accepting traffic.
func (s *Server) Listen(ctx context.Context) (int, error) {
s.mu.Lock()
+33 -17
View File
@@ -348,11 +348,11 @@ func expectGoroutinesSettled(t *testing.T, before int) {
// --- runtime capabilities -------------------------------------------------
func TestCapabilitiesKeepNATPredictionAbsentByDefault(t *testing.T) {
func TestCapabilitiesReportServicesDisabledByDefault(t *testing.T) {
server := start(t, Options{Config: testConfig(t)})
server.do(http.MethodGet, "/api/capabilities").
expect(http.StatusOK, `{"natPrediction":false}`)
expect(http.StatusOK, `{"sfu":false,"natPrediction":false}`)
}
func TestCapabilitiesReportOptionalNATPredictionWithoutExposingConfiguration(t *testing.T) {
@@ -363,7 +363,7 @@ func TestCapabilitiesReportOptionalNATPredictionWithoutExposingConfiguration(t *
server := start(t, Options{Config: configuration})
server.do(http.MethodGet, "/api/capabilities").
expect(http.StatusOK, `{"natPrediction":true}`).
expect(http.StatusOK, `{"sfu":false,"natPrediction":true}`).
expectHeader("Cache-Control", "no-store").
expectHeader("X-Content-Type-Options", "nosniff")
@@ -405,6 +405,8 @@ func TestSFUListenerFollowsApplicationCloseAndEnd(t *testing.T) {
if server.signaling.Load() == nil || server.media == nil {
t.Fatal("missing runtime owner")
}
server.do(http.MethodGet, "/api/capabilities").
expect(http.StatusOK, `{"sfu":true,"natPrediction":false}`)
var err error
if ending {
err = server.End(context.Background())
@@ -671,16 +673,21 @@ func TestSiteAccessRejectsAnExpiredOrModifiedCookie(t *testing.T) {
}
func TestSiteAccessIsImmediateWhenTheAccessPasswordIsEmpty(t *testing.T) {
configuration := testConfig(t)
configuration.SiteAccessPassword = ""
server := start(t, Options{Config: configuration})
for _, environment := range []config.Environment{config.EnvironmentDevelopment, config.EnvironmentProduction} {
t.Run(string(environment), func(t *testing.T) {
configuration := testConfig(t)
configuration.Env = environment
configuration.SiteAccessPassword = ""
server := start(t, Options{Config: configuration})
server.do(http.MethodGet, "/api/site-access").
expect(http.StatusOK, `{"required":false,"authenticated":true}`).
expectHeader("Set-Cookie", "")
server.do(http.MethodPost, "/api/site-access", withOrigin(allowedOrigin)).
expect(http.StatusOK, `{"required":false,"authenticated":true}`).
expectHeader("Set-Cookie", "")
server.do(http.MethodGet, "/api/site-access").
expect(http.StatusOK, `{"required":false,"authenticated":true}`).
expectHeader("Set-Cookie", "")
server.do(http.MethodPost, "/api/site-access", withOrigin(allowedOrigin)).
expect(http.StatusOK, `{"required":false,"authenticated":true}`).
expectHeader("Set-Cookie", "")
})
}
}
// --- room HTTP API --------------------------------------------------------
@@ -749,7 +756,7 @@ func TestRoomCreationPassesTheSharedViewerCeilingToRoomAdmission(t *testing.T) {
server := start(t, Options{Config: configuration})
server.createRoom(roomRequest{}).expectStatus(http.StatusCreated)
if got := server.Store().MaxViewersPerRoom(); got != protocol.MaxViewersPerRoomLimit {
if got := server.store.MaxViewersPerRoom(); got != protocol.MaxViewersPerRoomLimit {
t.Fatalf("maxViewersPerRoom = %d, want %d", got, protocol.MaxViewersPerRoomLimit)
}
}
@@ -774,8 +781,9 @@ func TestRoomCreationCreatesPrivateRoomsWithOptionalPasswordsAtomically(t *testi
}
}
func TestRoomCreationAllowsExplicitOpenCreationWithoutSiteAccess(t *testing.T) {
func TestProductionAllowsRoomCreationWithoutSiteAccessButRequiresRoomOwnership(t *testing.T) {
configuration := testConfig(t)
configuration.Env = config.EnvironmentProduction
configuration.SiteAccessPassword = ""
server := start(t, Options{Config: configuration})
@@ -787,6 +795,14 @@ func TestRoomCreationAllowsExplicitOpenCreationWithoutSiteAccess(t *testing.T) {
if !strings.Contains(created.InviteURL, "#v=") {
t.Fatalf("inviteUrl = %q", created.InviteURL)
}
server.updateRoomAccess(accessRequest{
roomID: created.RoomID, hostToken: "wrong-token",
body: `{"action":"set-code-entry-policy","policy":"private"}`,
}).expectStatus(http.StatusNotFound)
server.updateRoomAccess(accessRequest{
roomID: created.RoomID, hostToken: created.HostToken,
body: `{"action":"set-code-entry-policy","policy":"private"}`,
}).expectStatus(http.StatusOK)
}
func TestRoomCreationAllocatesUniqueFourDigitCodesConcurrently(t *testing.T) {
@@ -840,7 +856,7 @@ func TestRoomReplacementReplacesAuthorityAndClosesOldMembership(t *testing.T) {
password: &password, preferredRoomID: "4321",
}).expectStatus(http.StatusCreated).room()
store := server.Store()
store := server.store
host, err := store.ConnectParticipant(room.ConnectParticipantInput{
RoomID: original.RoomID, Role: protocol.RoleHost, Token: original.HostToken,
ClientID: "host-client", SessionID: "host-session",
@@ -999,7 +1015,7 @@ func TestRoomAccessManagesADormantRoomWithoutStartingSharing(t *testing.T) {
!strings.Contains(revoked.body, `"type":"viewer-grant-updated"`) {
t.Fatalf("revocation body = %s", revoked.body)
}
if _, connected := server.Store().GetConnectedHost(created.RoomID); connected {
if _, connected := server.store.GetConnectedHost(created.RoomID); connected {
t.Fatal("managing access started a sharing session")
}
@@ -1153,7 +1169,7 @@ func TestRestoresStableRoomAuthorityAcrossAnApplicationRestart(t *testing.T) {
now.Store(30 * 24 * 60 * 60 * 1_000)
second := start(t, Options{Config: configuration, Now: now.Load})
if size := second.Store().Size(); size != 1 {
if size := second.store.Size(); size != 1 {
t.Fatalf("restored room count = %d, want 1", size)
}
second.updateRoomAccess(accessRequest{
+3 -6
View File
@@ -10,10 +10,7 @@ import (
"strings"
)
// Request and response helpers ported from src/server/app.ts.
// maxJSONRequestBytes is the readJsonBody(request, 1_024) budget every JSON
// route shares.
// maxJSONRequestBytes bounds the body every JSON route accepts.
const maxJSONRequestBytes = 1024
// errorBody is the { error } shape every failing route answers with.
@@ -26,13 +23,13 @@ type healthBody struct {
Status string `json:"status"`
}
// siteAccessBody is siteAccessStatus(); the key order is the TS literal's.
// siteAccessBody reports whether site access is required and already granted.
type siteAccessBody struct {
Required bool `json:"required"`
Authenticated bool `json:"authenticated"`
}
// sendJSON always sets the exact Content-Length and the charset, as app.ts did.
// sendJSON sets the exact Content-Length and the JSON charset.
// Encoding one of this package's own values cannot fail, so a marshal error is
// a programming error and reaches the recover wrapper.
func sendJSON(writer http.ResponseWriter, status int, body any) {
+4 -4
View File
@@ -13,16 +13,15 @@ import (
"github.com/TNTcraftHIM/Piik/internal/server/signal"
)
// The request router of src/server/app.ts. Route order is a contract (map
// section 4.3, O34): the first match wins and the /api/ catch-all sits after
// every concrete route.
// The first matching route wins. The /api/ catch-all follows every concrete
// route so unknown API requests cannot fall through to the frontend.
var (
roomReplacementPath = regexp.MustCompile(`^/api/rooms/([1-9][0-9]{3})/replacement$`)
roomAccessPath = regexp.MustCompile(`^/api/rooms/([1-9][0-9]{3})/access$`)
)
// ServeHTTP is the createServer request listener of app.ts.
// ServeHTTP dispatches requests and contains handler panics at the HTTP boundary.
func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
recorder := &responseRecorder{ResponseWriter: writer}
defer func() {
@@ -77,6 +76,7 @@ func (s *Server) route(
return
}
sendJSON(writer, http.StatusOK, protocol.RuntimeCapabilities{
Sfu: s.config.SFU != nil,
NatPrediction: s.config.NATPredictionEnabled,
})
return
+8 -15
View File
@@ -10,11 +10,11 @@ import (
"github.com/TNTcraftHIM/Piik/internal/server/protocol"
)
// staticContentTypes freezes mrmime's answers for the extensions a Vite build
// of this repo can emit. Go's mime.TypeByExtension overlays the Windows
// staticContentTypes fixes the MIME types for this repository's Web assets.
// Go's mime.TypeByExtension overlays the Windows
// registry or /etc/mime.types, so the served Content-Type would otherwise
// depend on the machine; with X-Content-Type-Options: nosniff a wrong type on a
// font or a webmanifest is user visible. See evidence/runtime.md section 4.2.
// font or a webmanifest is user visible.
var staticContentTypes = map[string]string{
".html": "text/html;charset=utf-8", // sirv appends ";charset=utf-8" with no space
".htm": "text/html;charset=utf-8",
@@ -42,20 +42,16 @@ var staticContentTypes = map[string]string{
".ico": "", // mrmime has no .ico, so sirv sends an EMPTY Content-Type
}
// staticHandler reproduces `sirv(directory, { single: true, setHeaders })` as
// src/server/app.ts used it, over an fs.FS instead of a directory walk.
// notFound is the app's JSON 404, which sirv reached through its next()
// callback. Verified against sirv@3.0.2 in evidence/runtime.md section 4.4.
// staticHandler serves embedded assets and falls back to index.html for SPA
// routes. Missing files use the application's JSON 404 handler.
func staticHandler(assets fs.FS, notFound http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
// sirv never inspects req.method: app.ts routes /healthz and /api/**
// first, so everything else reaches the frontend handler as-is.
// API and health routes have already been handled by ServeHTTP.
cleaned := path.Clean("/" + request.URL.Path)
name := strings.TrimPrefix(cleaned, "/")
serve := func(candidate string) bool {
// path.Clean already dropped any "..", and totalist never indexed a
// dot-prefixed segment, so neither can be served.
// Clean traversal segments and exclude hidden assets before opening.
if candidate == "" || hasDotSegment(candidate) {
return false
}
@@ -72,15 +68,12 @@ func staticHandler(assets fs.FS, notFound http.Handler) http.Handler {
if !ok {
return false
}
// app.ts setHeaders.
writer.Header().Set("X-Content-Type-Options", "nosniff")
writer.Header().Set("Referrer-Policy", "no-referrer")
// Set Content-Type explicitly, possibly to "", so ServeContent
// never sniffs; the empty name keeps it from guessing either.
writer.Header()["Content-Type"] = []string{staticContentTypes[strings.ToLower(path.Ext(candidate))]}
// embed.FS reports a zero ModTime, so no Last-Modified is emitted
// and no conditional request is honoured. sirv sends
// Last-Modified but never answers 304 either; do not fabricate one.
// embed.FS reports a zero ModTime; do not fabricate Last-Modified.
http.ServeContent(writer, request, "", info.ModTime(), seeker)
return true
}
+20 -35
View File
@@ -1,7 +1,5 @@
// Package config ports src/server/config.ts, src/server/local-config.ts and
// src/server/ice.ts: the Hosted environment contract, the Client's local
// composition, and the ICE configuration derived from either. The caller
// supplies the environment as a map; loading opens no runtime resources.
// Package config owns Hosted and App Local configuration and ICE discovery.
// Callers provide configuration inputs; loading opens no runtime resources.
package config
import (
@@ -18,8 +16,8 @@ import (
"github.com/TNTcraftHIM/Piik/internal/server/protocol"
)
// Environment ports RuntimeEnvironment. DECISIONS D8 renames NODE_ENV to
// PIIK_ENV and drops "test".
// Environment selects development-only behavior. A stale unit file must fail
// closed rather than fall back to development.
type Environment string
// The two accepted PIIK_ENV values.
@@ -28,7 +26,7 @@ const (
EnvironmentProduction Environment = "production"
)
// Bounds and defaults from src/server/config.ts.
// Bounds and defaults for Hosted configuration.
const (
minSiteAccessPasswordBytes = 8
maxSiteAccessPasswordBytes = 128
@@ -42,9 +40,8 @@ var visibleASCIIPattern = regexp.MustCompile(`^[\x21-\x7e]+$`)
const removedTurnReason = "ordinary ICE accepts STUN_URLS only"
// removedEnvironmentVariables ports REMOVED_ENVIRONMENT_VARIABLES together with
// the two names the TypeScript checked separately, and NODE_ENV (DECISIONS D8:
// a stale unit file must not fail open). An empty reason takes the TURN default.
// removedEnvironmentVariables names settings with no current equivalent. An
// empty reason takes the TURN default.
// The order is the report order when several removed names are present.
var removedEnvironmentVariables = []struct{ name, reason string }{
{"TURN_URLS", ""},
@@ -74,17 +71,14 @@ type SFUConfig struct {
PublicIP string
}
// Config ports ServerConfig. Fields the TypeScript left `undefined` are the
// zero value here: an empty SiteAccessPassword means site access is open, an
// empty RoomDatabasePath means memory mode, and a nil SFU means no SFU
// fallback.
// Config describes runtime configuration. Zero values mean open site
// access, process-memory rooms, and no SFU fallback.
type Config struct {
Env Environment
Port int
ListenHost string
PublicBaseURL *url.URL
// AllowedOrigins is a membership test only; Go map order is not observable
// here (src/server/app.ts only ever calls `allowedOrigins.has(origin)`).
// AllowedOrigins is a membership test only; map order is not observable.
AllowedOrigins map[string]struct{}
SiteAccessPassword string
RoomDatabasePath string
@@ -96,15 +90,14 @@ type Config struct {
// discovery URLs do not make the Client an externally reachable STUN server.
STUNListenAddresses []string
NATPredictionEnabled bool
// NATPredictionSTUNURLs is nil where the TypeScript left it `undefined`,
// which IceConfig distinguishes from a configured empty list.
// NATPredictionSTUNURLs is nil when unset, which IceConfig distinguishes
// from a configured empty list.
NATPredictionSTUNURLs []string
}
// Load ports loadConfig. env is the whole process environment: cmd passes
// os.Environ() as a map, tests pass a literal. A key present with an empty
// value is `""` in TypeScript too, so presence is the only distinction that
// needs the two-value lookup.
// Load validates the whole process environment. Presence, not emptiness,
// distinguishes an explicit value from an unset value where that distinction
// is part of the contract.
func Load(env map[string]string) (Config, error) {
for _, removed := range removedEnvironmentVariables {
if _, present := env[removed.name]; present {
@@ -132,8 +125,8 @@ func Load(env map[string]string) (Config, error) {
listenHost = "0.0.0.0"
}
// TS: environment.PUBLIC_BASE_URL ?? `http://localhost:${port}` — only an
// absent variable takes the default.
// Only an absent variable takes the default; an explicit empty value is
// invalid below.
rawPublicBaseURL, present := env["PUBLIC_BASE_URL"]
if !present {
rawPublicBaseURL = fmt.Sprintf("http://localhost:%d", port)
@@ -200,9 +193,6 @@ func Load(env map[string]string) (Config, error) {
return Config{}, fmt.Errorf("SITE_ACCESS_PASSWORD must contain %d to %d visible ASCII bytes",
minSiteAccessPasswordBytes, maxSiteAccessPasswordBytes)
}
if environment == EnvironmentProduction && siteAccessPassword == "" {
return Config{}, errors.New("SITE_ACCESS_PASSWORD is required in production")
}
if environment == EnvironmentProduction && len(stunURLs) == 0 {
return Config{}, errors.New("STUN is required in production")
}
@@ -231,8 +221,6 @@ func Load(env map[string]string) (Config, error) {
// parseEnvironment ports parseEnvironment for PIIK_ENV.
func parseEnvironment(env map[string]string) (Environment, error) {
// TS: value ?? "development" — a present empty value is not nullish and so
// reaches the check below.
value, present := env["PIIK_ENV"]
if !present {
return EnvironmentDevelopment, nil
@@ -342,8 +330,7 @@ func parseURLList(value string, name string) ([]string, error) {
return list, nil
}
// parseStunURLList ports parseStunUrlList. The TypeScript `maximum` parameter
// was only ever the default.
// parseStunURLList validates the bounded ordinary-STUN list.
func parseStunURLList(value string, name string) ([]string, error) {
values, err := parseURLList(value, name)
if err != nil {
@@ -380,8 +367,7 @@ func parseOrigins(value string, fallback string) (map[string]struct{}, error) {
return set, nil
}
// toOrigin ports toOrigin. The TypeScript let an unparsable entry escape as a
// raw TypeError from `new URL`; it fails startup here with a message instead.
// toOrigin validates one allowed HTTP(S) origin.
func toOrigin(value string) (string, error) {
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
@@ -405,8 +391,7 @@ func parseOriginURL(value, name, scheme, alternative string) (*url.URL, error) {
if parsed.Scheme != scheme && parsed.Scheme != alternative {
return nil, fmt.Errorf("%s must use %s or %s", name, scheme, alternative)
}
// TS: url.username || url.password || url.pathname !== "/" || url.search ||
// url.hash. WHATWG reports "/" for an empty path, which net/url leaves "".
// WHATWG reports "/" for an empty path, while net/url leaves it "".
if hasUserinfo(parsed) || (parsed.Path != "" && parsed.Path != "/") ||
parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, fmt.Errorf(
+15 -4
View File
@@ -98,6 +98,21 @@ func TestLoadDevelopmentDefaults(t *testing.T) {
}
}
func TestLoadProductionAllowsAnUnsetOrEmptySiteAccessPassword(t *testing.T) {
for _, name := range []string{"unset", "empty"} {
t.Run(name, func(t *testing.T) {
environment := env(productionBase, map[string]string{"SITE_ACCESS_PASSWORD": ""})
if name == "unset" {
delete(environment, "SITE_ACCESS_PASSWORD")
}
configuration := mustLoad(t, environment)
if configuration.Env != EnvironmentProduction || configuration.SiteAccessPassword != "" {
t.Fatal("expected a production site without an access password")
}
})
}
}
func TestLoadRoomStorage(t *testing.T) {
directory := t.TempDir()
t.Chdir(directory)
@@ -377,10 +392,6 @@ func TestLoadRejects(t *testing.T) {
"PIIK_ENV": "production", "PUBLIC_BASE_URL": "https://share.test",
"SITE_ACCESS_PASSWORD": "host-password-12",
}, "STUN is required in production"},
{"production without a site password", map[string]string{
"PIIK_ENV": "production", "PUBLIC_BASE_URL": "https://share.test",
"STUN_URLS": "stun:stun.test:3478",
}, "SITE_ACCESS_PASSWORD is required in production"},
// Site access password bounds (UTF-8 bytes).
{"site password too short", map[string]string{"SITE_ACCESS_PASSWORD": strings.Repeat("x", 7)},
+4 -8
View File
@@ -10,12 +10,11 @@ import (
"github.com/TNTcraftHIM/Piik/internal/server/protocol"
)
// Ported from src/server/ice.ts.
const natPredictionBasePort = 3478
// natPredictionAuxiliaryPorts are NAT_PREDICTION_AUXILIARY_PORTS: the two
// listeners a NAT-prediction STUN deployment binds beside the base port, whose
// answers reveal the peer's port-allocation stride.
// natPredictionAuxiliaryPorts are the two listeners a NAT-prediction STUN
// deployment binds beside the base port; their answers reveal the peer's
// port-allocation stride.
var natPredictionAuxiliaryPorts = [...]int{natPredictionBasePort + 1, natPredictionBasePort + 2}
func stunListenAddresses(host string, prediction bool) ([]string, error) {
@@ -40,8 +39,6 @@ func stunListenAddresses(host string, prediction bool) ([]string, error) {
// listeners derived from the first ordinary STUN authority on UDP 3478.
func NATPredictionStunURLs(stunURLs []string) []string {
for _, stunURL := range stunURLs {
// TS: stunUrl.slice(stunUrl.indexOf(":") + 1) — a URL without a colon
// keeps its whole text, which then fails to yield a host below.
authorityText := stunURL[strings.IndexByte(stunURL, ':')+1:]
authority, err := url.Parse("http://" + authorityText)
if err != nil || authority.Hostname() == "" {
@@ -77,8 +74,7 @@ func NATPredictionStunURLs(stunURLs []string) []string {
// IceConfig ports createIceConfig.
func IceConfig(c Config) protocol.IceConfig {
// TS: options.natPredictionStunUrls ?? natPredictionStunUrls(stunUrls). A
// configured empty list (Local without prediction) is kept as-is; only an
// A configured empty list (Local without prediction) is kept as-is; only an
// absent one is derived, so nil and empty differ here.
prediction := c.NATPredictionSTUNURLs
if prediction == nil {
+6 -14
View File
@@ -12,18 +12,13 @@ import (
"github.com/TNTcraftHIM/Piik/internal/server/protocol"
)
// localPasswordPattern is VISIBLE_ASCII_PATTERN from local-config.ts. Because
// it admits one byte per character the TypeScript's extra Buffer.byteLength
// bounds are already implied by {8,128}.
// localPasswordPattern admits 8..128 one-byte visible ASCII characters.
var localPasswordPattern = regexp.MustCompile(`^[\x21-\x7e]{8,128}$`)
// LocalOptions ports LocalServerConfigOptions. loadLocalServerConfig, the
// environment form, went away with the Node child process, so Local never
// reads the process environment.
// LocalOptions describes App-selected Local composition. It never reads the
// process environment.
type LocalOptions struct {
// Port of 0 selects the default 8787, as TypeScript's absent `port` did.
// An explicit 0 is indistinguishable from absent here, where TypeScript
// rejected it; 0 is not a usable port either way.
// Port 0 selects the default 8787.
Port int
PublicAddress string
PublicOrigin string
@@ -65,8 +60,7 @@ func Local(options LocalOptions) (Config, error) {
}
}
// TS spreads `?? []`, so both lists are always arrays; keeping them non-nil
// is what tells IceConfig that NAT prediction URLs were configured.
// Copy non-nil lists so configured-empty prediction does not derive auxiliary URLs.
stunURLs := append([]string{}, options.STUNURLs...)
natPredictionSTUNURLs := append([]string{}, options.NATPredictionSTUNURLs...)
if len(stunURLs)+len(natPredictionSTUNURLs) > protocol.MaxIceServerURLs ||
@@ -78,9 +72,7 @@ func Local(options LocalOptions) (Config, error) {
return Config{}, errors.New("Local STUN URLs are invalid")
}
// TS builds these with a template string, so a non-default port is always
// spelled out and port 80 would produce "http://host:80", which is not the
// serialised origin. Kept as-is: Local never runs on 80.
// Local is loopback/LAN HTTP; its origin always spells out the port.
origin := func(host string) string { return fmt.Sprintf("http://%s:%d", host, port) }
publicBaseURL := &url.URL{Scheme: "http", Host: fmt.Sprintf("%s:%d", publicAddress, port), Path: "/"}
if options.PublicOrigin != "" {
+6 -7
View File
@@ -1,7 +1,6 @@
// Package ordered provides an insertion-ordered map with the iteration
// semantics of a JavaScript Map. The TypeScript server relied on those
// semantics wherever it iterated a Map or Set: wire-visible participant order,
// candidate ordering, round-robin state, and delete-during-iteration loops.
// Package ordered provides an insertion-ordered map with live iteration.
// Wire-visible participant order, candidate ordering, round-robin state and
// deletion during iteration depend on those semantics.
package ordered
import "iter"
@@ -13,9 +12,9 @@ type entry[K comparable, V any] struct {
}
// Map keeps keys in first-insertion order. Set on an existing key keeps its
// position; MoveToBack reproduces the JavaScript delete-then-set idiom.
// Iteration is live: an entry deleted before it is visited is skipped and an
// entry appended during iteration is visited, exactly like a JavaScript Map.
// position; MoveToBack removes and reinserts it at the end. Iteration is live:
// an entry deleted before it is visited is skipped and an entry appended
// during iteration is visited.
// The zero value is ready to use.
type Map[K comparable, V any] struct {
entries []entry[K, V]
+6 -7
View File
@@ -1,14 +1,13 @@
// Package protocol is the Go owner of the Piik wire contract. It mirrors
// src/shared/protocol.ts (zod schemas) plus the four shared scalar helpers
// (media-copy-accounting.ts, nat-candidate.ts, packet-loss.ts,
// video-codec-evidence.ts). The package performs no I/O and imports nothing
// from other internal packages.
// Package protocol is the Go owner of the Piik wire contract. Its shared
// fixtures and scalar helpers are kept semantics-compatible with the Browser
// implementation. The package performs no I/O and imports nothing from other
// internal packages.
package protocol
// MaxSafeInteger is JavaScript's Number.MAX_SAFE_INTEGER.
const MaxSafeInteger = 9007199254740991
// Limits and identifiers mirrored from src/shared/protocol.ts.
// Limits and identifiers in the Piik wire contract.
const (
MaxViewersPerRoomLimit = 20
MaxParticipantsPerRoomLimit = MaxViewersPerRoomLimit + 1
@@ -27,7 +26,7 @@ const (
MaxDisplayNameCodePoints = 24
DefaultViewerDisplayName = "观众"
DefaultHostDisplayNamePrefix = "分享者"
DefaultHostDisplayNamePrefix = "房主"
MinViewerPasswordLength = 1
MaxViewerPasswordLength = 64
+27
View File
@@ -8,6 +8,33 @@ import (
"testing"
)
func TestRuntimeCapabilities(t *testing.T) {
for _, test := range []struct {
data string
want RuntimeCapabilities
}{
{`{}`, RuntimeCapabilities{}},
{`{"sfu":true,"extra":{"enabled":true}}`, RuntimeCapabilities{Sfu: true}},
{`{"natPrediction":true,"SFU":true}`, RuntimeCapabilities{NatPrediction: true}},
{`{"sfu":true,"natPrediction":true}`, RuntimeCapabilities{Sfu: true, NatPrediction: true}},
{`{"sfu":"ignored duplicate","sfu":true}`, RuntimeCapabilities{Sfu: true}},
} {
got := RuntimeCapabilities{Sfu: true, NatPrediction: true}
if err := json.Unmarshal([]byte(test.data), &got); err != nil || got != test.want {
t.Errorf("decode %s = %+v, %v; want %+v", test.data, got, err, test.want)
}
}
for _, data := range []string{
`null`, `[]`, `true`, `{"sfu":null}`, `{"sfu":"true"}`,
`{"natPrediction":null}`, `{"natPrediction":1}`,
} {
var got RuntimeCapabilities
if err := json.Unmarshal([]byte(data), &got); err == nil {
t.Errorf("expected %s to be rejected", data)
}
}
}
// 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).
+1 -4
View File
@@ -11,10 +11,7 @@ type ServerMessage interface {
isServerMessage()
}
// AuthenticatedHostMessage is the host member of authenticatedMessageSchema.
// The field order is the emission order of the object literal in
// src/server/signaling.ts (`role` and `viewerPasswordEnabled` are appended
// after the shared base spread).
// AuthenticatedHostMessage returns the Host's current room and transport facts.
type AuthenticatedHostMessage struct {
Type string `json:"type"`
Protocol string `json:"protocol"`
+19 -4
View File
@@ -168,18 +168,33 @@ func (v *RoutePolicy) UnmarshalJSON(data []byte) error {
// RuntimeCapabilities mirrors runtimeCapabilitiesSchema.
type RuntimeCapabilities struct {
Sfu bool `json:"sfu"`
NatPrediction bool `json:"natPrediction"`
}
// UnmarshalJSON implements json.Unmarshaler.
func (v *RuntimeCapabilities) UnmarshalJSON(data []byte) error {
*v = RuntimeCapabilities{}
type raw RuntimeCapabilities
present, err := decodeObject(data, (*raw)(v))
if err != nil {
var present fields
if err := json.Unmarshal(data, &present); err != nil {
return err
}
return present.require("natPrediction")
if present == nil {
return errors.New("runtime capabilities must be an object")
}
if err := present.optional("sfu", "natPrediction"); err != nil {
return err
}
for key, target := range map[string]*bool{
"sfu": &v.Sfu, "natPrediction": &v.NatPrediction,
} {
if value, ok := present[key]; ok {
if err := json.Unmarshal(value, target); err != nil {
return err
}
}
}
return nil
}
// ---------------------------------------------------------------------------
+20 -80
View File
@@ -1,87 +1,27 @@
// Package room ports the room authority: src/server/room-store.ts (rooms,
// participants, room codes, viewer credentials, the four-digit code pool) and
// src/server/room-database.ts (the SQLite stable authority). Authority mutations
// write SQLite before memory and return the write error unchanged. Participants
// remain process-only.
// Package room owns room codes, Host/Viewer credentials, participant membership
// and persistent room authority. Durable mutations write SQLite before memory;
// participants and live connections remain process-only.
//
// # Locking
// # Locking And Password Work
//
// Store has no mutex. The signaling layer's single global lock guards it, which
// is what Node's single thread did: nothing interleaved except at an await. The
// caller must hold that lock for every method below except the two derive
// methods, which run the password KDF and must NOT be called under the lock.
// Store has no mutex. The caller holds the signaling server's global lock for
// store operations, except password derivation, which must run unlocked.
//
// The TypeScript methods that await the KDF are split into a locked "begin",
// an unlocked derive, and a locked "commit" so that the guards the TypeScript
// re-ran after its await are re-run here after the relock, in the same order.
// The commit takes the derive result as (material, derived error) because the
// TypeScript inspected the gate outcome (busy/cancelled) only after re-reading
// the room: forward both values unchanged and the commit reproduces the order.
// Password changes use a locked begin, unlocked derive, then locked commit.
// Pass both material and error from DeriveViewerPasswordMaterial unchanged;
// the commit revalidates room ownership/capacity before mapping gate outcomes.
// Viewer login instead returns derivation errors before committing credentials.
//
// POST /api/rooms (createRoom):
// A derivation admission predicate runs outside the signaling lock and must
// acquire that lock when consulting room/session state. ConnectViewerWithPassword
// also calls its predicate during the locked commit, so that call needs the
// already-locked form. Both must express the same current-session condition.
//
// mu.Lock()
// if err := store.BeginCreateRoom(); err != nil { mu.Unlock(); return err }
// var material []byte
// var derived error
// if password != "" {
// mu.Unlock()
// material, derived = store.DeriveViewerPasswordMaterial(password, nil)
// mu.Lock()
// }
// created, err := store.CreateRoom(policy, material, derived, preferredRoomID)
// mu.Unlock()
// HostManagedRoom and ViewerPasswordChallenge return a room reference for
// identity checks after unlocked work. A removed room is never reused as its
// replacement; a stale reference cannot authorize mutation of the new room.
// Do not inspect a retained room's mutable state without the lock.
//
// replaceRoom and set-viewer-password (the mayStart closure is the TypeScript
// `rooms.get(roomId) === current && verifyDigest(hostToken, ...)`; it runs on
// the derive goroutine, so it takes the lock itself):
//
// mu.Lock()
// current, err := store.HostManagedRoom(roomID, hostToken) // may fail INVALID_TOKEN
// if err != nil { mu.Unlock(); return err }
// var material []byte
// var derived error
// if password != "" {
// mu.Unlock()
// material, derived = store.DeriveViewerPasswordMaterial(password, func() bool {
// mu.Lock()
// defer mu.Unlock()
// return store.HostStillOwnsRoom(roomID, hostToken, current)
// })
// mu.Lock()
// }
// replaced, err := store.ReplaceRoom(roomID, hostToken, policy, material, derived, current)
// mu.Unlock()
//
// Viewer password login (signaling authenticate). mayConnect is the TypeScript
// closure `!closing && socketStates.get(socket) === state && !state.revoked &&
// !state.authenticated && socket.readyState === OPEN`. It is evaluated twice,
// once on the derive goroutine (gate admission) and once under the lock inside
// the commit, so the caller needs both a locking and a non-locking form of the
// same predicate:
//
// mu.Lock()
// salt, expected, ref, err := store.ViewerPasswordChallenge(input.RoomID)
// mu.Unlock()
// if err != nil { return err } // not initialized
// derivedKey, err := store.DeriveViewerPassword(input.Password, salt, func() bool {
// mu.Lock()
// defer mu.Unlock()
// return mayConnectLocked()
// })
// mu.Lock()
// if err != nil { mu.Unlock(); return err } // ROOM_BUSY or INVALID_TOKEN
// participant, err := store.ConnectViewerWithPassword(
// input, derivedKey, expected, ref, mayConnectLocked)
// mu.Unlock()
//
// The room reference handed back by HostManagedRoom and ViewerPasswordChallenge
// is only ever compared for pointer identity, reproducing the TypeScript
// `currentRoom !== room` checks that detect a room replaced while the KDF ran.
// Holding it across the unlock is safe: an abandoned room is dropped from the
// map, never mutated into another room.
//
// Options.Random is called both under the caller's lock and from the derive
// goroutine (the 16-byte salt), so it must be safe for concurrent use;
// crypto/rand is.
// Options.Random is used under the caller's lock and by derivation goroutines,
// so it must be concurrency-safe; crypto/rand satisfies this requirement.
package room
+1 -2
View File
@@ -19,8 +19,7 @@ const (
viewerPasswordScryptR = 8
viewerPasswordScryptP = 1
// AsyncGate bounds from src/server/room-store.ts (DECISIONS D11): scrypt at
// N=16384 costs ~16 MiB and tens of milliseconds, so two concurrent
// Scrypt at N=16384 costs ~16 MiB per derivation, so two concurrent
// derivations bound the memory and CPU a room burst can claim, and sixteen
// waiters bound the queue before the next caller is told ROOM_BUSY.
gateActiveLimit = 2
+6 -23
View File
@@ -229,20 +229,6 @@ type CommittedEdge struct {
Resource *Resource
}
// committedEdgeSeed is CommittedEdge without the session fields the
// controller derives at hydrateEdge. TS: CommittedEdgeSeed, which like
// hydrateEdge only ever had test callers.
type committedEdgeSeed struct {
Kind UpstreamKind
ParentPeerID string
PublicationGeneration string
Transport Transport
ConnectionID string
Usable bool
PhysicalActive bool
Resource *Resource
}
// HostPublication is the single live SFU ingress. TS: HostPublication.
type HostPublication struct {
Generation string
@@ -288,7 +274,6 @@ type OperationSnapshot struct {
// RouteSnapshot is the committed graph as the router reads it.
// UpstreamByViewer is a fresh insertion-ordered copy (child peerId -> edge);
// HostPublication is a shallow copy or nil; Operation is nil when idle.
// TS: RouteSnapshot.
type RouteSnapshot struct {
Revision int64
Paused bool
@@ -323,7 +308,6 @@ type SettleResult struct {
type SenderQualityEvidenceResult = SettleResult
// BeginResult is the outcome of the cursor-guarded candidate calls.
// TS: BeginResult.
type BeginResult struct {
Accepted bool
Operation *OperationSnapshot
@@ -351,7 +335,6 @@ type EdgeGuard struct {
}
// ParentEdgeGuard identifies a direct edge from the parent's side.
// TS: the inline input of invalidateDirectEdgeFromParent.
type ParentEdgeGuard struct {
ParentPeerID string
ParentSessionID string
@@ -410,7 +393,6 @@ type BeginInput struct {
}
// CandidateProof carries the Viewer's one-shot relative quality approval.
// TS: the proof parameter of candidateReady ({ relativeQualityApproved? }).
type CandidateProof struct {
RelativeQualityApproved bool
}
@@ -425,7 +407,6 @@ type QualityUpstream struct {
// RouteQualityEvidenceInput is one receiver-side quality window. Only
// FramesDecodedDelta, FreezeCountDelta, FreezeDurationMsDelta,
// PauseCountDelta and PauseDurationMsDelta of Metrics are read.
// TS: RouteQualityEvidenceInput.
type RouteQualityEvidenceInput struct {
ChildPeerID string
ChildSessionID string
@@ -440,7 +421,6 @@ type RouteQualityEvidenceInput struct {
// SenderQualityEvidenceInput is one sender-side health sample from a parent
// about one child. SenderIdentity and SampleTimestampMs are nullable.
// TS: SenderQualityEvidenceInput.
type SenderQualityEvidenceInput struct {
ParentPeerID string
ParentSessionID string
@@ -454,7 +434,6 @@ type SenderQualityEvidenceInput struct {
}
// SfuPublisherQualityEvidenceInput is one Host->SFU ingress health sample.
// TS: SfuPublisherQualityEvidenceInput.
type SfuPublisherQualityEvidenceInput struct {
HostPeerID string
HostSessionID string
@@ -468,8 +447,11 @@ type SfuPublisherQualityEvidenceInput struct {
// Options configures a Controller. DebugRoomID empty disables the sanitised
// route debug events. TS: ControllerOptions.
type Options struct {
HostPeerID string
DebugRoomID string
HostPeerID string
DebugRoomID string
// DebugLog receives sanitized route events. nil disables them; the effect
// layer owns environment, logger and retention policy.
DebugLog func(string, ...any)
EndpointMediaCopyCapacity int
OperationTimeoutMs int64
SfuEnabled bool
@@ -487,6 +469,7 @@ type Controller struct {
// options are copied once and never change.
hostPeerID string
debugRoomID string
debugLog func(string, ...any)
endpointMediaCopyCapacity int
operationTimeoutMs int64
sfuEnabled bool
+1 -62
View File
@@ -275,6 +275,7 @@ func New(options Options) *Controller {
c := &Controller{
hostPeerID: options.HostPeerID,
debugRoomID: options.DebugRoomID,
debugLog: options.DebugLog,
endpointMediaCopyCapacity: options.EndpointMediaCopyCapacity,
operationTimeoutMs: options.OperationTimeoutMs,
sfuEnabled: options.SfuEnabled,
@@ -642,68 +643,6 @@ func (c *Controller) TouchExternalFacts() {
c.touchFacts()
}
// hydrateEdge installs a committed edge for a connected viewer while no
// operation is active, without changing revision or fact version. Panics
// on an active operation, a non-viewer child or a child without a session.
func (c *Controller) hydrateEdge(childPeerID string, edge committedEdgeSeed) {
if c.operation != nil {
panic(errors.New("Cannot hydrate while an operation is active"))
}
c.assertViewer(childPeerID)
child, _ := c.participants.Get(childPeerID)
childSessionID := child.sessionID
if childSessionID == "" {
panic(errors.New("Route child has no current session"))
}
committed := &CommittedEdge{
Kind: edge.Kind,
ChildSessionID: childSessionID,
ParentPeerID: edge.ParentPeerID,
PublicationGeneration: edge.PublicationGeneration,
Transport: edge.Transport,
ConnectionID: edge.ConnectionID,
Usable: edge.Usable,
PhysicalActive: edge.PhysicalActive,
Resource: edge.Resource,
}
if edge.Kind == UpstreamPeer {
// TS 750: `this.participants.get(edge.parentPeerId)?.sessionId ?? ""`.
if parent, ok := c.participants.Get(edge.ParentPeerID); ok {
committed.ParentSessionID = parent.sessionID
}
}
// Map.set keeps the position of an existing child (§4.2 #15).
c.upstreamByViewer.Set(childPeerID, committed)
c.requireSenderQualityBaseline(childPeerID, false)
c.assertGraph()
}
// hydrateHostPublication installs a usable, physically active publication
// while no operation is active; connectionID empty selects the TS default
// "publication:<generation>". Panics on an active operation or a Host
// without a session.
func (c *Controller) hydrateHostPublication(generation string, resource *Resource, connectionID string) {
if c.operation != nil {
panic(errors.New("Cannot hydrate while an operation is active"))
}
host, _ := c.participants.Get(c.hostPeerID)
if host == nil || host.sessionID == "" {
panic(errors.New("Route Host has no current session"))
}
if connectionID == "" {
connectionID = "publication:" + generation
}
c.hostPublication = &HostPublication{
Generation: generation,
HostSessionID: host.sessionID,
ConnectionID: connectionID,
Usable: true,
PhysicalActive: true,
Resource: resource,
}
c.assertGraph()
}
// InvalidateEdge marks the exactly identified edge unusable and records an
// "edge-unavailable" demand; true when the guard matched (idempotent).
func (c *Controller) InvalidateEdge(guard EdgeGuard, nowMs *int64) bool {
+7 -21
View File
@@ -1,40 +1,26 @@
package route
import (
"context"
"log/slog"
"os"
"strconv"
"strings"
"github.com/TNTcraftHIM/Piik/internal/server/protocol"
)
func debugEnabled() bool {
return routeDebugEnabled(os.Getenv("PIIK_DEBUG")) ||
slog.Default().Enabled(context.Background(), slog.LevelDebug)
}
func routeDebugEnabled(value string) bool {
for _, section := range strings.Split(value, ",") {
if strings.TrimSpace(section) == "route" {
return true
}
}
return false
}
// debugCandidate is the `{route, transition}` item of "operation-started".
type debugCandidate struct {
Route string
Transition TransitionKind
}
func (c *Controller) debugEnabled() bool {
return c.debugLog != nil && c.debugRoomID != ""
}
// debug emits one sanitized event with a diagnostic room ID and current facts.
// Never pass raw peer IDs, session IDs or connection IDs; use debugPeer and
// debugTuple. The environment or diagnostic logger enables these Info events.
// debugTuple. The effect layer injects the sink and owns its enabling policy.
func (c *Controller) debug(event string, details ...any) {
if !debugEnabled() || c.debugRoomID == "" {
if !c.debugEnabled() {
return
}
args := make([]any, 0, 8+len(details))
@@ -44,7 +30,7 @@ func (c *Controller) debug(event string, details ...any) {
"revision", c.revision,
"factVersion", c.factVersion)
args = append(args, details...)
slog.Info("piik-route", args...)
c.debugLog("piik-route", args...)
}
// debugPeer ports 4895: "host", "viewer-<joinOrder>" or "viewer-unknown".
+19 -17
View File
@@ -1,24 +1,26 @@
package route
import (
"io"
"log/slog"
"testing"
)
import "testing"
func TestDebugUsesCurrentEnvironmentAndLogger(t *testing.T) {
previous := slog.Default()
t.Cleanup(func() { slog.SetDefault(previous) })
slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
for value, want := range map[string]bool{"": false, "client": false, "route": true, "client, route": true} {
t.Setenv("PIIK_DEBUG", value)
if got := debugEnabled(); got != want {
t.Fatalf("debugEnabled with %q = %v, want %v", value, got, want)
func TestControllerDebugRequiresInjectedSinkAndRoom(t *testing.T) {
var kinds, events []string
sink := func(kind string, args ...any) {
kinds = append(kinds, kind)
if len(args) >= 2 {
if event, ok := args[1].(string); ok {
events = append(events, event)
}
}
}
t.Setenv("PIIK_DEBUG", "")
slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug})))
if !debugEnabled() {
t.Fatal("the active diagnostic logger did not enable route events")
c := &Controller{debugRoomID: "1234", debugLog: sink}
c.debug("operation-started", "child", "viewer-1")
if len(kinds) != 1 || kinds[0] != "piik-route" ||
len(events) != 1 || events[0] != "operation-started" {
t.Fatalf("injected sink received kinds=%v events=%v", kinds, events)
}
(&Controller{debugRoomID: "1234"}).debug("no-sink")
(&Controller{debugLog: sink}).debug("no-room")
if len(kinds) != 1 {
t.Fatalf("disabled controller emitted %v", kinds)
}
}
+7 -8
View File
@@ -1,15 +1,14 @@
// Package route ports src/server/room-route-controller.ts: the pure,
// synchronous decision logic that owns one room's media route graph, its
// Package route owns synchronous decisions over one room's media route graph, its
// single in-flight route operation, the quality/opportunity ledgers and the
// privacy-safe diagnostic snapshot.
//
// The controller performs no I/O, starts no timers and never locks. Every
// method is a state transition over the nowMs the caller passes in; the
// The controller starts no timers, never locks and returns resource effects
// to its caller. Optional diagnostics invoke the injected sink synchronously.
// Every method is a state transition over the nowMs the caller passes in; the
// caller (signal.Server) holds the global mutex around every call and
// releases exactly the *Resource values a method returns, in order.
//
// Resources are compared by pointer identity, mirroring the JS Set semantics
// of the TypeScript source. Programming-error throws in the source
// (assertGraph, bad reservations, bad options) become panics with the same
// messages.
// Resources are compared by pointer identity. Invalid graphs, reservations or
// constructor options are programming errors and panic; ordinary unavailable
// routes remain decision outcomes.
package route
+63
View File
@@ -212,6 +212,69 @@ func sfuCreateOverlap(edge, publication, overlap string) CandidateReservation {
return reservation
}
type committedEdgeSeed struct {
Kind UpstreamKind
ParentPeerID string
PublicationGeneration string
Transport Transport
ConnectionID string
Usable bool
PhysicalActive bool
Resource *Resource
}
func (c *Controller) hydrateEdge(childPeerID string, edge committedEdgeSeed) {
if c.operation != nil {
panic("Cannot hydrate while an operation is active")
}
c.assertViewer(childPeerID)
child, _ := c.participants.Get(childPeerID)
if child.sessionID == "" {
panic("Route child has no current session")
}
committed := &CommittedEdge{
Kind: edge.Kind,
ChildSessionID: child.sessionID,
ParentPeerID: edge.ParentPeerID,
PublicationGeneration: edge.PublicationGeneration,
Transport: edge.Transport,
ConnectionID: edge.ConnectionID,
Usable: edge.Usable,
PhysicalActive: edge.PhysicalActive,
Resource: edge.Resource,
}
if edge.Kind == UpstreamPeer {
if parent, ok := c.participants.Get(edge.ParentPeerID); ok {
committed.ParentSessionID = parent.sessionID
}
}
c.upstreamByViewer.Set(childPeerID, committed)
c.requireSenderQualityBaseline(childPeerID, false)
c.assertGraph()
}
func (c *Controller) hydrateHostPublication(generation string, resource *Resource, connectionID string) {
if c.operation != nil {
panic("Cannot hydrate while an operation is active")
}
host, _ := c.participants.Get(c.hostPeerID)
if host == nil || host.sessionID == "" {
panic("Route Host has no current session")
}
if connectionID == "" {
connectionID = "publication:" + generation
}
c.hostPublication = &HostPublication{
Generation: generation,
HostSessionID: host.sessionID,
ConnectionID: connectionID,
Usable: true,
PhysicalActive: true,
Resource: resource,
}
c.assertGraph()
}
func peerEdge(parentPeerID, connectionID string) committedEdgeSeed {
return committedEdgeSeed{
Kind: UpstreamPeer,
+3 -3
View File
@@ -214,7 +214,7 @@ func (c *Controller) Reconcile(nowMs int64) ReconcileResult {
deferredParentPeerIDs: []string{},
qualitySource: qualitySource,
}
if debugEnabled() {
if c.debugEnabled() {
routes := make([]debugCandidate, 0, len(candidates))
for _, candidate := range candidates {
routes = append(routes, debugCandidate{Route: c.debugTuple(candidate.Tuple), Transition: candidate.EndpointTransition.Kind})
@@ -318,7 +318,7 @@ func (c *Controller) BeginCurrentCandidate(input BeginInput) BeginResult {
mediaReady: false,
connectionAttempt: c.startCandidateOpportunity(op, *plan),
}
if debugEnabled() {
if c.debugEnabled() {
details := []any{
"child", c.debugPeer(op.childPeerID),
"candidate", c.debugTuple(tuple),
@@ -689,7 +689,7 @@ func (c *Controller) CandidateFailed(guard CandidateGuard, nowMs int64) SettleRe
// OperationExpired applies head-start and deadline expiry at nowMs;
// Accepted reports whether something expired.
func (c *Controller) OperationExpired(nowMs int64) SettleResult {
if op := c.operation; op != nil && debugEnabled() {
if op := c.operation; op != nil && c.debugEnabled() {
current := op.current
noProgress := current != nil &&
current.tuple.Kind == UpstreamPeer &&
+2 -5
View File
@@ -1,10 +1,7 @@
package signal
// Viewer presence, host status, disconnect handling and room termination of
// src/server/signaling.ts (sendViewerPresence 1594, broadcastHostStatus
// 1683, handleDisconnect 1397, stopSharing 1466, closeRoom,
// revokeGrantViewers 1016, closeRevokedViewerSession 1697 and the
// viewer-keyed map helpers). Every function here runs with mu held.
// Viewer presence, Host status, disconnect handling and room termination run
// with Server.mu held, so each effect observes the current room membership.
import (
"fmt"
+12 -6
View File
@@ -1,13 +1,10 @@
package signal
// Ported from src/server/hybrid-media-router.ts (baseline b20fd88).
//
// The router owns no lock. signal.Server.mu (routerOptions.mu) is held by the
// caller of every method except close, hooks are called with it held, and the
// goroutines the router starts (pump driver, media prepares, drains and timer
// callbacks) take it themselves. Every I/O window is an
// unlock / I/O / lock window followed by exactly the guard the TS code ran
// after that await (map C:/tmp/piik-go/maps/effect-layer.md, section 2.2).
// callbacks) take it themselves. After unlocked I/O, revalidate the owning
// room, operation and resource before committing effects under the lock.
import (
"context"
@@ -46,6 +43,15 @@ func routeDebugFlag(value string) bool {
return false
}
// routeDebugSink is the injected route-diagnostic sink; nil disables route
// events. The signaling effect layer owns environment and logger policy.
func routeDebugSink() func(string, ...any) {
if !routeDebugEnabled() {
return nil
}
return slog.Info
}
// sfuFallback is SfuFallbackOptions. A zero timeout means the TS default.
type sfuFallback struct {
media sfu.Runtime
@@ -299,7 +305,6 @@ func (r *router) completeAuthentication(participant authenticatedRouteParticipan
return
}
r.broadcastActive(participant.roomID, rm)
// TS: void this.sendFreshSfuConfig(participant).catch(() => undefined)
r.sendFreshSfuConfig(participant)
r.requestPump(participant.roomID)
}
@@ -1002,6 +1007,7 @@ func (r *router) createController(roomID string, rm *roomRuntime, host authentic
rm.controller = route.New(route.Options{
HostPeerID: host.peerID,
DebugRoomID: roomID,
DebugLog: routeDebugSink(),
EndpointMediaCopyCapacity: r.capacity,
OperationTimeoutMs: operationTimeoutMs,
SfuEnabled: r.sfu != nil && !(routePolicy != nil && routePolicy.PeerOnly),
-1
View File
@@ -279,7 +279,6 @@ func (r *router) selectCandidate(
return pumpCandidate{}, true
}
if operation.Cursor < 0 || operation.Cursor >= len(operation.Candidates) {
// TS: `if (!plan)` -- note: no clearDeadline here.
r.resourceWaiters.Delete(roomID)
return pumpCandidate{}, true
}
-1
View File
@@ -185,7 +185,6 @@ func (r *router) scheduleSfuDrain(kind drainKind, fence sfu.SubscriptionFence) {
r.sfuDrainTasks.Set(key, task)
}
if task.operation == nil {
// TS: void this.runSfuDrain(key, task).catch(() => undefined)
r.runSfuDrain(key, task)
}
}
Loaded 100 of 261 files, more files were not shown because too many files have changed in this diff. Show more