diff --git a/.github/workflows/repository-hygiene.yml b/.github/workflows/repository-hygiene.yml
index 4182138..dc6f9f8 100644
--- a/.github/workflows/repository-hygiene.yml
+++ b/.github/workflows/repository-hygiene.yml
@@ -218,7 +218,9 @@ jobs:
pattern: piik-app-*-${{ github.sha }}
path: ${{ runner.temp }}/publish-release
merge-multiple: true
- - name: Publish verified release
+ # Local descriptors/manifests/checksums are validated before publishing.
+ # Release assets contain packages only; checksums appear in the release body.
+ - name: Publish packages with release notes and checksums
run: >-
node scripts/publish-release.mjs
"$RUNNER_TEMP/publish-release" "$PIIK_RELEASE_VERSION" "$GITHUB_SHA"
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 568dce0..b7a62a1 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,5 +1,26 @@
# Contributing
+## Ways To Help
+
+Corrections, translations, bug reports and code contributions are welcome.
+English and Chinese are both welcome in issues and PRs.
+欢迎用中文或英文参与讨论,从一处文案修正或一次问题反馈开始也很好。
+
+| Contribution / 参与方式 | Start here / 从这里开始 |
+| --- | --- |
+| Translate or improve wording / 翻译、校对文案 | [Translation guide](./docs/guide/translating.md) · [中文翻译教程](./docs/guide/translating.zh-CN.md) |
+| Report a bug / 反馈问题 | [Issue templates](https://github.com/TNTcraftHIM/Piik/issues/new/choose); include version, environment, steps and actual result / 附版本、环境、步骤与实际结果 |
+| Improve a guide / 改进教程 | [Documentation map](./docs/README.md); identify the confusing step and explain the expected result / 指出不清楚的步骤及预期结果 |
+| Suggest a feature / 功能建议 | Search [existing issues](https://github.com/TNTcraftHIM/Piik/issues) first, then describe the use case / 先搜索已有讨论,再说明使用场景 |
+| Contribute code / 参与开发 | [Run from source](./docs/README.md#run-from-source), then follow the workflow below / 从源码启动,按下文流程修改 |
+
+Keep feedback specific and respectful. Explain a wording or design disagreement
+with context and a suggested improvement. Review screenshots and diagnostics for
+private content before attaching them. If you cannot run a check, say so in the
+PR; maintainers can help identify what still needs verification.
+讨论时请结合实际场景提出建议,尊重不同意见;分享截图或日志前移除隐私内容。
+无法运行的检查请如实说明,便于维护者协助验证。
+
## Lean Workflow
This is the default for an ordinary scoped implementation or bug fix.
diff --git a/README.md b/README.md
index 77eafa8..115918a 100644
--- a/README.md
+++ b/README.md
@@ -63,7 +63,8 @@ picture does not start automatically.
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.
+Piik follows your system language: Chinese or English, with English as the fallback.
+Use **中 / EN / ✦** in the header to switch language or try the visual mode.
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.
@@ -99,8 +100,9 @@ domain, HTTPS reverse proxy and STUN address. Room data is stored in SQLite.
## Contributing
-Bug reports, documentation improvements and pull requests are welcome.
-Start with [CONTRIBUTING.md](./CONTRIBUTING.md), or see the
+Bug reports, translations, documentation improvements and pull requests are welcome.
+See [ways to contribute](./CONTRIBUTING.md#ways-to-help) or
+[help translate Piik](./docs/guide/translating.md). Use the
[repository layout](./docs/reference/engineering.md#repository-layout) to find your way around.
## License
diff --git a/README.zh-CN.md b/README.zh-CN.md
index e2f0f91..8078009 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -61,7 +61,8 @@ Piik 是一个免费开源的屏幕共享工具,适合游戏围观、一起看
1. 点击**开始分享**,选择要分享的画面和声音。
2. 点击**复制邀请链接**,发给朋友。分享期间保持标签页打开。
-Piik 默认使用图示按钮;点击顶部的**中**可显示文字。
+Piik 默认跟随系统语言,中文系统显示简体中文,其他语言显示英文。
+顶部的 **中 / EN / ✦** 可切换中英文或体验纯视觉模式。
客户端提供本地房间、临时公网邀请和连接已有站点三种模式,分享期间需保持 App 运行。
下载以 **`piik-app`** 开头的 ZIP 压缩包,再按文件名中的平台标识选择:
@@ -95,8 +96,9 @@ Piik Server 是一个内置网页界面的独立程序。
## 参与贡献
-欢迎提交问题、改进文档或参与开发。
-修改前请阅读[贡献指南](./CONTRIBUTING.md),也可以从
+欢迎反馈问题、参与翻译、改进文档或贡献代码。
+从[社区参与方式](./CONTRIBUTING.md#ways-to-help)或
+[翻译教程](./docs/guide/translating.zh-CN.md)开始,也可以从
[项目目录说明](./docs/reference/engineering.md#repository-layout)了解代码结构。
## 许可
diff --git a/cmd/piik-app/README.md b/cmd/piik-app/README.md
index 592111b..80e563a 100644
--- a/cmd/piik-app/README.md
+++ b/cmd/piik-app/README.md
@@ -28,8 +28,8 @@ publication are separate steps in [deployment](../../docs/deployment.md).
origin; later manually opened Host pages may reuse the running 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.
+ first and Gitee if GitHub is unavailable. It opens the matching platform ZIP
+ when available, falling back to the release page. It does not install or replace the App.
Local mode uses memory-only rooms, P2P relay, no SFU listener, and no public
discovery. Ordinary Local works on a reachable LAN. The **Public invite** mode
@@ -39,8 +39,14 @@ uses one ordinary public STUN destination plus two bounded public survey
destinations. Browser and Native edges share the same prediction rule; Native
STUN, ICE checks, and media use one Pion UDP mux. Media does not
travel through the HTTP tunnel, and a difficult media path still has no SFU or
-TURN fallback. The App chooses a sole private LAN IPv4 automatically. Use
-`--lan-address
` only when multiple real LAN interfaces are active.
+TURN fallback.
+
+For Local invitations, the App automatically selects a sole active address or
+sole private IPv4. If several addresses remain possible, choose an interface
+and IP in the launcher. Public invite and Site mode need no LAN selection.
+When bypassing the launcher with `--local`, use `--lan-address ` to
+resolve an ambiguous choice. The selected address is checked again at startup;
+it affects Local invitation links, while ICE selects media paths independently.
The system Browser remains the Host UI. An App-launched Host offers the
Browser's standard capture picker and a list of exact platform capture targets;
@@ -86,6 +92,9 @@ 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. The App keeps running after a Site tab closes.
+When launched in its own Windows console, a startup or runtime error leaves the
+error visible until Enter is pressed. Normal shutdown, existing terminals and
+redirected or automated runs exit directly.
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
@@ -118,11 +127,12 @@ and the [verified policy mechanism and field case](../../docs/research/native-cl
### Diagnostics
-Start the packaged executable with `--debug`, reproduce the problem, then press
-`D` in the interactive terminal to export a local ZIP. This does not stop the
-share or upload the archive. Browser diagnostics are separate: open the page with
-`?debug=1` and use its header download button. For cooperation failures, include
-both reports from the same reproduction. The
+Enable **Debug launch** in the mode selector before opening Piik, reproduce the
+problem, then press `D` in the interactive terminal to export a local ZIP.
+Use `--debug` for failures before the selector opens. Exporting does not stop
+the share or upload the archive. For Browser diagnostics, click the **Debug**
+chip icon after the theme control, confirm the reload, then use **Web report** to download.
+For cooperation failures, include both reports from the same reproduction. The
[diagnostic reference](../../docs/reference/configuration.md#diagnostics) owns
log locations, export commands, retention and privacy boundaries.
@@ -197,7 +207,7 @@ Supported targets are `windows-amd64`, `linux-amd64`, and `darwin-arm64`.
Use the matching target name in the command above. Darwin assembly requires a
native macOS runner with its SDK and enables cgo; Windows and Linux assembly keep
cgo disabled.
-The result is a `tar.gz` bundle and SHA-256 file. Manual sidecar assembly,
+The result is a ZIP bundle and SHA-256 file. Manual sidecar assembly,
explicit CI packaging, Release publication and updates are documented in
[deployment](../../docs/deployment.md); creating a candidate does not publish it.
diff --git a/cmd/piik-app/exit_other.go b/cmd/piik-app/exit_other.go
new file mode 100644
index 0000000..0e4c9a5
--- /dev/null
+++ b/cmd/piik-app/exit_other.go
@@ -0,0 +1,5 @@
+//go:build !windows
+
+package main
+
+func pauseOnError() {}
diff --git a/cmd/piik-app/exit_windows.go b/cmd/piik-app/exit_windows.go
new file mode 100644
index 0000000..b5fc7f6
--- /dev/null
+++ b/cmd/piik-app/exit_windows.go
@@ -0,0 +1,30 @@
+package main
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "syscall"
+ "unsafe"
+
+ "github.com/charmbracelet/x/term"
+)
+
+func pauseOnError() {
+ if os.Getenv("PIIK_CLIENT_GATE_NO_BROWSER") == "true" ||
+ !term.IsTerminal(os.Stdin.Fd()) || !term.IsTerminal(os.Stdout.Fd()) || !term.IsTerminal(os.Stderr.Fd()) {
+ return
+ }
+ // A shell sharing this console already preserves its output. Only keep a
+ // standalone window alive, after App resources and terminal mode are restored.
+ // https://learn.microsoft.com/en-us/windows/console/getconsoleprocesslist
+ var processID uint32
+ count, _, _ := syscall.NewLazyDLL("kernel32.dll").NewProc("GetConsoleProcessList").Call(
+ uintptr(unsafe.Pointer(&processID)), 1,
+ )
+ if count != 1 {
+ return
+ }
+ fmt.Fprintln(os.Stderr, "\nPress Enter to close this window. / 按回车关闭此窗口。")
+ _, _ = bufio.NewReader(os.Stdin).ReadString('\n')
+}
diff --git a/cmd/piik-app/exit_windows_test.go b/cmd/piik-app/exit_windows_test.go
new file mode 100644
index 0000000..37cf555
--- /dev/null
+++ b/cmd/piik-app/exit_windows_test.go
@@ -0,0 +1,87 @@
+package main
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "syscall"
+ "testing"
+ "time"
+ "unsafe"
+)
+
+func TestStandaloneConsoleErrorPause(t *testing.T) {
+ const helper = "PIIK_TEST_STANDALONE_CONSOLE"
+ if os.Getenv(helper) == "" {
+ ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
+ defer cancel()
+ command := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestStandaloneConsoleErrorPause$")
+ command.WaitDelay = time.Second
+ command.Env = append(os.Environ(), helper+"=true")
+ command.SysProcAttr = &syscall.SysProcAttr{CreationFlags: 0x10, HideWindow: true} // CREATE_NEW_CONSOLE
+ if output, err := command.CombinedOutput(); err != nil {
+ t.Fatalf("standalone console: %v\n%s", err, output)
+ }
+ return
+ }
+ // exec captures the test result; the real console handles model double-click
+ // launch without touching the parent terminal or a user's App configuration.
+ input, err := os.OpenFile("CONIN$", os.O_RDWR, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer input.Close()
+ output, err := os.OpenFile("CONOUT$", os.O_RDWR, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer output.Close()
+ stdin, stdout, stderr := os.Stdin, os.Stdout, os.Stderr
+ os.Stdin, os.Stdout, os.Stderr = input, output, output
+ defer func() { os.Stdin, os.Stdout, os.Stderr = stdin, stdout, stderr }()
+ t.Setenv("PIIK_CLIENT_GATE_NO_BROWSER", "false")
+ if os.Getenv(helper) == "shared" {
+ pauseOnError()
+ return
+ }
+ done := make(chan struct{})
+ go func() { pauseOnError(); close(done) }()
+ select {
+ case <-done:
+ t.Fatal("the standalone error window closed before confirmation")
+ case <-time.After(150 * time.Millisecond):
+ }
+ // INPUT_RECORD containing a key-down Enter event.
+ event := struct {
+ kind, padding uint16
+ down int32
+ repeat, key uint16
+ scan, char uint16
+ control uint32
+ }{kind: 1, down: 1, repeat: 1, key: 13, char: 13}
+ var written uint32
+ ok, _, callErr := syscall.NewLazyDLL("kernel32.dll").NewProc("WriteConsoleInputW").Call(
+ input.Fd(), uintptr(unsafe.Pointer(&event)), 1, uintptr(unsafe.Pointer(&written)),
+ )
+ if ok == 0 || written != 1 {
+ t.Fatalf("confirm close: %v", callErr)
+ }
+ select {
+ case <-done:
+ case <-time.After(2 * time.Second):
+ t.Fatal("Enter did not release the error window")
+ }
+ ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
+ defer cancel()
+ shared := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestStandaloneConsoleErrorPause$")
+ shared.Env = append(os.Environ(), helper+"=shared")
+ shared.WaitDelay = time.Second
+ if result, err := shared.CombinedOutput(); err != nil {
+ t.Fatalf("an existing terminal must exit directly: %v\n%s", err, result)
+ }
+ t.Setenv("PIIK_CLIENT_GATE_NO_BROWSER", "true")
+ pauseOnError() // Automation must not wait even when it owns a console.
+ os.Stdin, os.Stdout, os.Stderr = stdin, stdout, stderr
+ t.Setenv("PIIK_CLIENT_GATE_NO_BROWSER", "false")
+ pauseOnError() // Redirected output must not wait.
+}
diff --git a/cmd/piik-app/main.go b/cmd/piik-app/main.go
index e55bca0..0e782ae 100644
--- a/cmd/piik-app/main.go
+++ b/cmd/piik-app/main.go
@@ -17,22 +17,36 @@ import (
)
func main() {
+ code := run(os.Args[1:])
+ if code != 0 {
+ pauseOnError()
+ }
+ os.Exit(code)
+}
+
+func run(args []string) int {
var options app.Options
- flag.Func("site", "save and open a Piik Site origin", func(value string) error {
+ flags := flag.NewFlagSet("piik-app", flag.ContinueOnError)
+ flags.Func("site", "save and open a Piik Site origin", func(value string) error {
options.Site = value
options.SiteSet = true
return nil
})
- flag.BoolVar(&options.Local, "local", false, "use the self-contained Local room authority")
- flag.BoolVar(&options.Link, "link", false, "create one public Viewer invitation link")
- flag.BoolVar(&options.Debug, "debug", false, "save opt-in App diagnostics to rotated files")
- flag.StringVar(&options.LogDir, "log-dir", "", "diagnostic directory (overrides PIIK_LOG_DIR)")
- flag.StringVar(&options.CaptureProcess, "capture-process", "", "path to the platform native capture process")
- flag.StringVar(&options.TunnelProcess, "tunnel-process", "", "path to the packaged public tunnel process")
- flag.StringVar(&options.ConfigPath, "config", "", "path to the App configuration file")
- flag.StringVar(&options.LANAddress, "lan-address", "", "LAN IPv4 address used in Local invitations")
- flag.IntVar(&options.Port, "port", app.DefaultLocalPort, "Local Piik server port")
- flag.Parse()
+ flags.BoolVar(&options.Local, "local", false, "use the self-contained Local room authority")
+ flags.BoolVar(&options.Link, "link", false, "create one public Viewer invitation link")
+ flags.BoolVar(&options.Debug, "debug", false, "save opt-in App diagnostics to rotated files")
+ flags.StringVar(&options.LogDir, "log-dir", "", "diagnostic directory (overrides PIIK_LOG_DIR)")
+ flags.StringVar(&options.CaptureProcess, "capture-process", "", "path to the platform native capture process")
+ flags.StringVar(&options.TunnelProcess, "tunnel-process", "", "path to the packaged public tunnel process")
+ flags.StringVar(&options.ConfigPath, "config", "", "path to the App configuration file")
+ flags.StringVar(&options.LANAddress, "lan-address", "", "LAN IPv4 address used in Local invitations")
+ flags.IntVar(&options.Port, "port", app.DefaultLocalPort, "Local Piik server port")
+ if err := flags.Parse(args); err != nil {
+ if err == flag.ErrHelp {
+ return 0
+ }
+ return 2
+ }
options.DisableBrowser = os.Getenv("PIIK_CLIENT_GATE_NO_BROWSER") == "true"
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
@@ -49,6 +63,7 @@ func main() {
if options.DisableBrowser {
fmt.Fprintln(os.Stderr, err)
}
- os.Exit(1)
+ return 1
}
+ return 0
}
diff --git a/cmd/piik-app/main_test.go b/cmd/piik-app/main_test.go
new file mode 100644
index 0000000..c4f7162
--- /dev/null
+++ b/cmd/piik-app/main_test.go
@@ -0,0 +1,23 @@
+package main
+
+import "testing"
+
+func TestCommandExitStatus(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ args []string
+ want int
+ }{
+ {"help", []string{"--help"}, 0},
+ {"unknown flag", []string{"--not-a-piik-option"}, 2},
+ {"invalid port", []string{"--port", "invalid"}, 2},
+ {"startup failure", []string{"--local", "--link"}, 1},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ t.Setenv("PIIK_CLIENT_GATE_NO_BROWSER", "true")
+ if got := run(test.args); got != test.want {
+ t.Fatalf("exit status = %d, want %d", got, test.want)
+ }
+ })
+ }
+}
diff --git a/docs/README.md b/docs/README.md
index 1b7e90c..ef49dee 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -18,9 +18,10 @@
| 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) |
+| Translate Piik or improve wording | [Translation guide](./guide/translating.md) · [中文翻译教程](./guide/translating.zh-CN.md) |
| Check release and platform readiness | [Current status](./status.md) |
-The introduction, getting-started and self-hosting guides are available in English and Chinese.
+The introduction, getting-started, self-hosting and translation guides are available in English and Chinese.
Technical references keep one shared version.
## Run from source
diff --git a/docs/deployment.md b/docs/deployment.md
index 2e25fdb..5e9cabc 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -48,6 +48,12 @@ extracts its own artifact to verify it. The archive contains exactly
`piik-server`, `LICENSE`, `THIRD-PARTY-NOTICES.txt`, and `REVISION`. Upload
the archive, manifest, and descriptor together to `/opt/piik/uploads`.
+Public Release pages expose the runnable packages and put package checksums in
+the release body. Retain the original complete build output outside the checkout
+for later managed deployment or mirror retries: descriptors and manifests are
+available in CI artifacts for 14 days and are not public Release attachments.
+Do not rebuild an existing published version to replace missing metadata or bytes.
+
The same application descriptor is also the App assembly input. On each
target platform, provide that platform's Go toolchain:
diff --git a/docs/design/public-introduction.md b/docs/design/public-introduction.md
index 50025bb..b71b261 100644
--- a/docs/design/public-introduction.md
+++ b/docs/design/public-introduction.md
@@ -135,7 +135,8 @@ 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. Each supported platform has a direct ZIP link
using GitHub's native latest-release asset route. Build verified Gitee links from
-the matching release and source revision, showing the mirror's specific version.
+the matching release and source revision. Keep build-time versions out of
+download captions.
Keep release notes separate; when a mirror package is unverified, label its
fallback as a release-page link. Verify destinations as part of publication.
Demo, App and existing-site walkthroughs
diff --git a/docs/design/visual-language.md b/docs/design/visual-language.md
index c5c518c..31e000c 100644
--- a/docs/design/visual-language.md
+++ b/docs/design/visual-language.md
@@ -39,6 +39,17 @@ 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.
+Keep the application language control's segmented Chinese, English and visual
+shortcuts. When additional catalogs exist, insert one language menu
+after the visual shortcut; all extra languages share that slot. Show the
+selected language's short label there and native names inside the menu. Reuse
+the sliding selection, rounded paper panels and control motion. The native
+popover owns visibility, outside-click dismissal and Escape; the language
+control owns menu navigation, focus and viewport placement. Keep touch rows
+large and long lists scrollable. Locale labels belong to the shared registry.
+Use a globe and chevron for the unselected picker, and each language's own full
+name in the menu, following [W3C language-navigation guidance](https://www.w3.org/International/questions/qa-navigation-select).
+
Help-only indicators toggle their guidance on click or tap and dismiss on a
second activation, outside tap, Escape or focus leaving. Action controls keep
their direct click action and offer touch guidance on long-press. `Tooltip`
diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md
index 0e2a4ce..cd968dd 100644
--- a/docs/guide/getting-started.md
+++ b/docs/guide/getting-started.md
@@ -22,6 +22,9 @@ used below. **中** switches to Chinese; **✦** returns to the illustrations.
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.
+On a computer, click the picture to pause or resume; double-click to enter or
+leave fullscreen where supported. On a phone, tap the picture to show the bar
+and use its buttons. Safari may show system controls in video fullscreen.
Playback controls, at a glance
@@ -86,7 +89,7 @@ See the [App guide](../../cmd/piik-app/README.md) for package and runtime detail
| 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. |
+| **Local room** | Friends are on the same local network. | If several **Local invitation addresses** appear, choose the interface and IP on your friends' 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. |
@@ -121,6 +124,7 @@ Invitation links grant access to that room; share them with the people you want
| --- | --- |
| Picture but no sound | Unmute the video. The host should choose a source with shareable audio; if sound was disabled, stop and start sharing with it enabled. App window/screen capture keeps that setting when switching sources. |
| No screen picker | Allow the browser or App to record the screen when the OS asks. Browser capture needs HTTPS or `localhost`; try sharing from a desktop computer. |
+| App startup fails | Read the reason on the page and in the terminal. Reopen the App and enable the chip-shaped **Debug launch** control after the theme button before trying again. A failed startup then exports a report; its path appears in the terminal. |
| Local invitation will not open | Check that both devices are on the same network and can reach each other. Guest Wi-Fi or firewall rules can block local access. |
| Page opens but video will not connect | Choose **Reconnect** in the playback bar. If it still fails, check [WebRTC connection settings](../../cmd/piik-app/README.md#chromium-webrtc-connections) or try a site with media fallback. |
| Sharing stops after sleep or suspension | Wake the device and return to the sharing tab; start sharing again if needed. Browser and OS suspension can interrupt capture or playback. |
diff --git a/docs/guide/getting-started.zh-CN.md b/docs/guide/getting-started.zh-CN.md
index 5193627..5fa3278 100644
--- a/docs/guide/getting-started.zh-CN.md
+++ b/docs/guide/getting-started.zh-CN.md
@@ -21,6 +21,8 @@ Piik 支持一位房主分享屏幕,最多 20 位受邀观众通过浏览器
3. 将鼠标移到画面上,或轻点画面,显示播放控制栏。
播放控制栏只改变你自己看到的画面和听到的声音。
+电脑上单击画面可暂停或继续,双击可进入或退出全屏(浏览器支持时)。
+手机上轻点画面唤出控制栏,再使用其中的按钮。Safari 的视频全屏可能使用系统播控。
播放按钮分别做什么?
@@ -79,7 +81,7 @@ Linux 原生采集还需要[系统组件](../../native/capture/linux/README.md)
| 模式 | 什么时候用 | 需要知道 |
| --- | --- | --- |
-| **本地房间** | 朋友和你在同一个局域网。 | 对方需要能连接到你的电脑,可以设置本地站点口令。 |
+| **本地房间** | 朋友和你在同一个局域网。 | 出现多个「局域网邀请地址」时,选择与朋友同一网络的网卡和 IP。对方需要能连接到你的电脑,可以设置本地站点口令。 |
| **公网邀请** | 朋友在别处,想临时发个邀请。 | 需要互联网连接;公网地址只在本次 App 运行期间有效。 |
| **连接站点** | 你们已经有一个 Piik 站点。 | 输入站点地址,在原有分享页面使用 App 的采集能力。 |
@@ -110,6 +112,7 @@ Linux 原生采集还需要[系统组件](../../native/capture/linux/README.md)
| --- | --- |
| 有画面,没声音 | 先取消视频静音。房主应选择支持分享声音的来源;如果开始时关闭了声音,请停止后重新开始并打开声音。通过 App 采集窗口或屏幕时,切换来源会保留原来的声音设置。 |
| 没有屏幕选择弹窗 | 按系统提示允许浏览器或 App 录制屏幕。浏览器采集需要 HTTPS 或 `localhost`,请在电脑上尝试分享。 |
+| App 启动失败 | 先查看页面和黑窗口中的具体原因。重新打开 App,在主题按钮后的芯片图标开启「诊断启动」再重试。启动失败时会自动导出报告,保存位置见黑窗口。 |
| 本地邀请打不开 | 确认两台设备在同一网络且可以互相访问。访客 Wi-Fi 或防火墙规则可能阻止本地连接。 |
| 网页能打开,画面连不上 | 点击播放控制栏的 **重新连接**;仍然失败时,检查 [WebRTC 连接设置](../../cmd/piik-app/README.md#chromium-webrtc-connections),或尝试有媒体服务器兜底的站点。 |
| 休眠或挂起后分享中断 | 唤醒设备,回到分享标签页,必要时重新开始分享。浏览器或系统挂起可能中断采集和播放。 |
diff --git a/docs/guide/translating.md b/docs/guide/translating.md
new file mode 100644
index 0000000..2c0b0a2
--- /dev/null
+++ b/docs/guide/translating.md
@@ -0,0 +1,169 @@
+# Help translate Piik
+
+[中文](./translating.zh-CN.md) · [Contributing](../../CONTRIBUTING.md)
+
+Piik currently includes Simplified Chinese and English. You can improve a label,
+proofread a guide, or contribute another language. Translations are reviewed
+through GitHub issues and pull requests.
+
+For a wording suggestion, [open an issue](https://github.com/TNTcraftHIM/Piik/issues/new/choose)
+with the language, page or control, current text, proposed text and a brief reason.
+A screenshot or the steps to reach the text help establish context. Remove
+private invitations, passwords and personal content from screenshots. A wording
+suggestion does not require a development environment.
+
+## Make your first correction
+
+1. Try the affected screen and read the nearby controls. Check both existing
+ languages when helpful; the [terminology and voice guide](../reference/naming.md#voice-and-terminology)
+ owns names and writing style.
+2. Fork Piik and work on a branch from current `main`, following
+ [Contributing](../../CONTRIBUTING.md#lean-workflow). Small text edits can also
+ be proposed through GitHub's file editor.
+3. Find the text in the files below. In a locale file, change the string value
+ after the colon. Keep the key and its placeholders. Search the key in the
+ source to see where it appears; for example:
+
+ ```sh
+ rg -n -F 'host.title' src/client
+ ```
+
+4. Preview the affected screen and run the [relevant checks](#preview-and-check).
+ For a typo in one language, edit that language. If the underlying meaning
+ changes, update the corresponding messages in every registered language.
+5. Open a PR to `main`. Explain the wording problem, the language and surfaces
+ changed, and what you checked. Mention anything you could not preview;
+ maintainers can help with technical validation. PR format and publication
+ follow [Contributing](../../CONTRIBUTING.md#pull-request-scope).
+
+## Find the right text
+
+| Surface | Editable source |
+| --- | --- |
+| App launcher and shared Browser/App UI, including tooltips and accessible labels | [English](../../src/client/locales/en.ts), [Chinese](../../src/client/locales/zh.ts) |
+| Welcome lines and changing browser-tab titles | `welcome.*` messages and the title-frame catalog at the end of those same files |
+| Website | [Page markup](../../site/index.html) and [interactive labels](../../site/main.js); text is paired by `en` and `zh-CN` |
+| Introduction film | [Film sources](../../site/film/README.md); captions, artwork and playback labels have their own bilingual text |
+| App console window | `consoleCopy` in [console.go](../../internal/app/console.go); entries are ordered English, Chinese, visual; retain the third slot even when empty |
+| README and entry guides | [README pair](../../README.md), [getting started](./getting-started.md) and [self-hosting](../operations/self-hosting.md), each with a `.zh-CN.md` counterpart |
+
+The App and Server serve the same web UI. Edit source files; build output and
+release packages are generated. Translating an App message does not also
+translate a website caption. Update related instructions when an action name
+changes. Technical references keep one shared version; see
+[documentation ownership](../maintenance.md).
+
+## Preserve meaning and syntax
+
+Write naturally for the target audience. Use the shared glossary for role and
+password names. Introductions may be light; actions, errors and deployment steps
+must be clear. Preserve the conditions on performance, platform and connection
+claims. A cultural reference should still make sense to someone unfamiliar with it.
+Machine-assisted drafts need proofreading by someone who understands the language
+and the screen; raise unclear source text in the PR instead of guessing.
+
+Locale files are TypeScript objects. These are existing examples:
+
+```ts
+// en.ts
+"host.title": "{name}'s screen",
+// zh.ts
+"host.title": "{name} 的屏幕",
+```
+
+`host.title` is the lookup key; `{name}` is supplied by the application. Move the
+placeholder to fit the sentence, preserving its spelling, braces and number of
+occurrences. The current formatter substitutes each supplied variable once;
+repeating `{name}` would leave the second occurrence visible. It accepts plain
+strings, not HTML or ICU plural expressions. If a language needs plural forms
+or different sentence composition, describe the actual case so the caller and
+formatter can be changed together.
+
+Keep quotes, escapes and commas valid. Preserve commands, config keys, URLs,
+download filenames and format markers such as `%s` in Go console text. Translate
+link labels and image descriptions; repair internal anchors when translating
+Markdown headings. Keep legal license text and third-party notices intact.
+
+## Add a language
+
+Search existing issues and PRs for the language first. For a larger translation,
+start an issue with its language tag, intended coverage and any terminology or
+layout questions, so contributors can coordinate work and review.
+
+1. Copy `src/client/locales/en.ts` to a file such as `fr.ts`. Translate the
+ messages and title frames, rename the two exports, and retain the
+ `Record` and `TitleFrameCatalog` types imported from `zh.ts`.
+ Chinese currently defines the key set; either existing language can provide
+ context. Complete all keys rather than spreading an English catalog over
+ missing translations.
+2. Import the exports in [locales/index.ts](../../src/client/locales/index.ts)
+ and add one registry entry. For example, **after translating** the two exports:
+
+ ```ts
+ fr: { name: "Français", short: "FR", tag: "fr", copy: fr, titleFrames: frTitleFrames },
+ ```
+
+ Use an ASCII locale filename/key, the language's own name in `name`, a compact
+ button label in `short`, and a matching language tag in `tag`. Keep the short
+ label recognizable in one to three characters, such as `中`, `EN` or `FR`;
+ distinguish regional/script variants when they coexist. The registry supplies
+ the language controls, document language, saved selection and App launch handoff.
+ Chinese, English and visual mode retain their direct buttons; additional
+ languages share one dropdown slot after visual mode. The selected extra
+ language's short label appears on that slot, with its full name in the menu.
+ Initial selection matches a registered tag or key, then its base language;
+ unregistered languages use English. A saved choice takes precedence.
+ The shared [system-language policy](../product/presentation-lifecycle.md#visual-language)
+ also covers Chinese script and region variants across App, Server UI and website.
+3. Check complete screens and the launch flow. New UI languages use English in
+ the App console until its own translation is added. Console additions also
+ require updating the launcher and loopback accepted-language checks together;
+ use the [App module map](../reference/engineering.md#module-map).
+4. State coverage in the PR. Website, film and documentation translations can be
+ contributed separately; their existing language controls must be updated when
+ adding a language there. For right-to-left scripts, include text direction and
+ layout verification; a translated catalog alone does not establish RTL support.
+
+Pure-visual mode is an optional presentation, not another language to translate.
+The small visual scenes are shared with text modes; translate their labels and
+explanations in the normal catalog. Keep the visual option available.
+
+## Preview and check
+
+Use the Node/npm versions and local setup in [Run from source](../README.md#run-from-source).
+For UI catalog edits, run from the repository root:
+
+```sh
+npm ci
+npm run typecheck
+npm test -- tests/copy.test.ts
+```
+
+Type checking catches missing keys. The copy tests check registered catalogs for
+empty text and mismatched placeholders, plus language selection and handoff.
+Tests do not judge translation quality.
+
+Run the local UI and server as described in the source guide. Choose the language
+from the top-right controls, open the affected screen and inspect long names or counts
+in context. Check narrow windows, keyboard focus, tooltips and accessible labels.
+With extra languages registered, verify that the dropdown stays usable near
+viewport edges and exposes its expanded/collapsed state to assistive technology.
+For new languages, also check a reload, App launch, switching back to English,
+and the optional visual mode. Keep meaningful phrases together instead of adding
+spaces or hard line breaks to force one desktop layout.
+
+Website and film changes use the [website preview](../operations/website.md#preview).
+For Markdown changes, run `node scripts/check-docs.mjs` after staging new files,
+then review GitHub's rendered Markdown and translated links. Additional checks
+for code changes follow [Contributing](../../CONTRIBUTING.md#verification-entrypoints).
+
+## Further reading
+
+Reviewed 2026-09-13. These resources inform context-based proofreading and syntax
+preservation; Piik's contribution steps above use its own repository workflow.
+
+- [OBS translation guide](https://github.com/obsproject/obs-studio/wiki/How-To-Contribute-Translations-For-OBS): proofread in the running application and understand technical terms.
+- [Godot translation guide](https://contributing.godotengine.org/en/latest/other/translations.html): locate source context and coordinate terminology.
+- [Weblate translation guidance](https://docs.weblate.org/en/latest/user/translating.html#translating-special-text-safely): handle placeholders and markup carefully.
+- [W3C language-tag guidance](https://www.w3.org/International/questions/qa-choosing-language-tags): use BCP 47 tags to identify languages and their variants.
+- [Unicode CLDR language names](https://cldr.unicode.org/translation/displaynames/languagelocale-names): reference names for menus. Browser `Intl.DisplayNames` can help look up native names. Piik's one-to-three-character button labels are editorial choices, not standardized CLDR language names; keep them in the registry with each reviewed translation.
diff --git a/docs/guide/translating.zh-CN.md b/docs/guide/translating.zh-CN.md
new file mode 100644
index 0000000..5ea9cc9
--- /dev/null
+++ b/docs/guide/translating.zh-CN.md
@@ -0,0 +1,139 @@
+# 参与 Piik 翻译
+
+[English](./translating.md) · [贡献指南](../../CONTRIBUTING.md)
+
+Piik 目前提供简体中文和英文。欢迎改进按钮措辞、校对教程,或参与其他语言的翻译。
+翻译建议和修改通过 GitHub Issue 与 Pull Request(PR)讨论、审核。
+
+只想改一句话,可以直接[提交建议](https://github.com/TNTcraftHIM/Piik/issues/new/choose):
+写明语言、页面或控件、原文、建议译文及理由。附上截图或进入该页面的步骤,便于理解上下文;
+截图中请移除私人邀请链接、密码和个人内容。提交措辞建议无需搭建开发环境。
+
+## 从一处修改开始
+
+1. 先试用对应界面,看看附近有哪些操作。必要时对照中英文理解含义;
+ 角色名称、术语与文风以[命名和文案规范](../reference/naming.md#voice-and-terminology)为准。
+2. Fork 仓库,从当前 `main` 创建工作分支,按[贡献流程](../../CONTRIBUTING.md#lean-workflow)修改。
+ 少量文字也可以通过 GitHub 文件编辑器提出修改。
+3. 在下表对应的文件里查找原文。语言文件中只修改冒号后的译文,保留键名和占位符。
+ 找到键名后,可以搜索它在哪些界面使用,例如:
+
+ ```sh
+ rg -n -F 'host.title' src/client
+ ```
+
+4. 预览对应界面并运行[相关检查](#预览与检查)。仅修正某种语言的错字时,修改该语言即可;
+ 如果调整的是功能含义,需要同步所有已登记语言中的对应内容。
+5. 向 `main` 提交 PR,说明原文的问题、修改的语言和页面,以及检查结果。
+ 没有条件预览的部分请如实说明,维护者可以协助验证。
+ PR 格式及发布流程见[贡献指南](../../CONTRIBUTING.md#pull-request-scope)。
+
+## 文案在哪里
+
+| 内容 | 修改位置 |
+| --- | --- |
+| App 启动页、网页端与 App 共用的操作界面,含 tooltip 和无障碍标签 | [英文词库](../../src/client/locales/en.ts)、[中文词库](../../src/client/locales/zh.ts) |
+| 随机欢迎语、浏览器标签页的轮换标题 | 同一语言文件中的 `welcome.*` 及文件末尾的标题词库 |
+| 官网 | [页面正文](../../site/index.html)和[交互文案](../../site/main.js),按 `en`、`zh-CN` 成对组织 |
+| 宣传短片 | [短片源码说明](../../site/film/README.md),字幕、插画和播放控件有各自的中英文内容 |
+| App 黑窗口 | [console.go](../../internal/app/console.go) 中的 `consoleCopy`,每项按英文、中文、纯视觉排列;第三项即使为空也需保留 |
+| README 与入门教程 | [README](../../README.md)、[使用指南](./getting-started.md)、[部署指南](../operations/self-hosting.md),均有对应的 `.zh-CN.md` 文件 |
+
+App 与 Server 使用同一套网页操作界面。请修改源码,构建目录和发布包由工具生成。
+应用词库中的修改不会自动翻译官网文案;操作名称变化时,也要检查相关教程。
+技术参考文档保留一份公共版本,详见[文档职责](../maintenance.md)。
+
+## 翻译时保留什么
+
+按目标语言的习惯表达,角色和密码名称沿用公共术语表。介绍可以轻松,操作、错误和部署步骤要清楚。
+性能、平台、连接条件等限制应保留原意。梗和彩蛋应让不懂出处的人也能理解。
+机器辅助翻译需要由理解目标语言和使用场景的人校对;原文本身有歧义时,在 PR 中说明。
+
+语言文件使用 TypeScript 对象。下面是现有的一组内容:
+
+```ts
+// en.ts
+"host.title": "{name}'s screen",
+// zh.ts
+"host.title": "{name} 的屏幕",
+```
+
+`host.title` 是程序查找文案的键名,`{name}` 由程序填入。
+可以调整占位符的位置,但要保留拼写、花括号和出现次数。
+当前格式化函数对每个变量替换一次;重复写 `{name}` 会让第二处仍显示占位符。
+词库使用普通字符串,不解析 HTML 或 ICU 复数表达式。
+如果目标语言需要复数变化或不同的句子组合,请给出具体例子,便于连同调用处一起调整。
+
+保留代码的引号、转义和逗号。命令、配置项、URL、下载文件名,以及 Go 黑窗口文案中的 `%s`
+等格式标记保持原样。链接显示文字和图片说明可以翻译;Markdown 标题变化后,要修正对应的页内链接。
+许可证原文及第三方声明保持完整。
+
+## 新增一种语言
+
+先搜索是否已有相关 Issue 或 PR。准备翻译较多内容时,可以开一个 Issue,说明语言代码、
+翻译范围,以及术语或排版疑问,方便大家分工和校对。
+
+1. 复制 `src/client/locales/en.ts`,例如命名为 `fr.ts`。翻译正文和轮换标题,修改两个导出变量名,
+ 保留从 `zh.ts` 引入的 `Record`、`TitleFrameCatalog` 类型。
+ 中文词库目前定义键名集合,理解内容时可以参考任一现有语言。
+ 请补全全部键名,不要用展开英文词库的方式填充缺失译文。
+2. 在 [locales/index.ts](../../src/client/locales/index.ts) 中导入两个导出变量,并添加一项登记。
+ 例如,**完成这两个词库的翻译后**:
+
+ ```ts
+ fr: { name: "Français", short: "FR", tag: "fr", copy: fr, titleFrames: frTitleFrames },
+ ```
+
+ 文件名和键名使用 ASCII 语言代码,`name` 填写该语言自己的名称,`short` 填写按钮简称,
+ `tag` 填写对应语言标签。简称采用一至三个易识别的字符,例如 `中`、`EN`、`FR`;
+ 同时提供地区或文字变体时,简称也应能区分。
+ 此登记供语言控件、页面语言、偏好保存及 App 启动跳转共用。
+ 中文、英文和纯视觉保留直接切换按钮;有其他语言时,在纯视觉后增加一个共用的下拉入口。
+ 选中额外语言后,该格显示它的简称,菜单内显示全名。
+ 首次使用先匹配已登记的标签或键名,再匹配基础语言;没有对应翻译时使用英文。
+ 用户保存的选择优先。
+ App、Server 网页界面及官网对中文地区和文字变体的默认选择,遵循
+ [统一的系统语言规则](../product/presentation-lifecycle.md#visual-language)。
+3. 检查完整界面与启动流程。新增的界面语言在 App 黑窗口中暂用英文;
+ 翻译黑窗口时,还需同步启动服务和 loopback 对语言的校验,相关位置见
+ [App 模块地图](../reference/engineering.md#module-map)。
+4. 在 PR 中写清覆盖范围。官网、短片和文档可以分别翻译,新增语言时需同步各自的语言切换入口。
+ 从右向左书写的语言还需要验证文字方向与布局;仅有词库并不代表已支持 RTL 排版。
+
+纯视觉模式是可选的呈现方式,不是另一种待翻译语言。小漫画和文字模式共用,
+它们的文字标签与说明在普通词库中翻译,保留现有纯视觉选项即可。
+
+## 预览与检查
+
+Node/npm 版本及本地启动方法见[从源码运行](../README.md#run-from-source)。
+修改应用词库后,在仓库根目录执行:
+
+```sh
+npm ci
+npm run typecheck
+npm test -- tests/copy.test.ts
+```
+
+类型检查能发现缺少的键名;文案测试会检查已登记词库的空文案、占位符不一致,
+以及语言选择和启动跳转。测试不能代替语言质量的校对。
+
+按源码指南启动本地界面与服务端,通过右上角控件选择语言,进入被修改的页面。
+结合长昵称、数量等实际内容,检查窄窗口、键盘焦点、tooltip 和无障碍标签。
+登记额外语言后,检查下拉菜单在视口边缘仍可操作,并能向辅助技术提供展开与收起状态。
+新增语言还应检查刷新、App 启动、切回英文及纯视觉模式。
+按完整语义断句,避免用空格或强制换行去适配单一桌面尺寸。
+
+官网与短片使用[官网预览流程](../operations/website.md#preview)。
+Markdown 修改在暂存新文件后执行 `node scripts/check-docs.mjs`,并检查 GitHub 渲染结果与翻译后的链接。
+涉及代码时,额外检查按[贡献指南](../../CONTRIBUTING.md#verification-entrypoints)执行。
+
+## 延伸阅读
+
+核对日期:2026-09-13。以下资料提供上下文校对与语法保留的通用方法,
+Piik 的提交流程以本页和贡献指南为准。
+
+- [OBS 翻译指南](https://github.com/obsproject/obs-studio/wiki/How-To-Contribute-Translations-For-OBS):在运行中的界面校对,理解技术术语。
+- [Godot 翻译指南](https://contributing.godotengine.org/en/latest/other/translations.html):查找源码上下文,协作统一术语。
+- [Weblate 翻译说明](https://docs.weblate.org/en/latest/user/translating.html#translating-special-text-safely):处理占位符与标记语法。
+- [W3C 语言标签指南](https://www.w3.org/International/questions/qa-choosing-language-tags):用 BCP 47 标签区分语言及其变体。
+- [Unicode CLDR 语言名称](https://cldr.unicode.org/translation/displaynames/languagelocale-names):菜单名称的参考来源,也可以用浏览器的 `Intl.DisplayNames` 查找语言本名。Piik 按钮上的一至三个字符由项目选定,不是 CLDR 规定的标准简称;随经过校对的译文一起维护在语言登记表中。
diff --git a/docs/maintenance.md b/docs/maintenance.md
index 5942301..4992d9d 100644
--- a/docs/maintenance.md
+++ b/docs/maintenance.md
@@ -14,6 +14,7 @@ verification procedures stay in [CONTRIBUTING.md](../CONTRIBUTING.md).
| Branches, review, validation, PRs, releases and cleanup | `CONTRIBUTING.md` | Executable workflow |
| Module boundaries, interface discipline and ablation | [Engineering](./reference/engineering.md) | Current responsibility map and shared coding rules |
| Names, terminology and voice | [Naming and copy](./reference/naming.md) | Product/tool names, role labels and Chinese/English writing conventions |
+| Translation contributions | [Translation guide](./guide/translating.md) and its Chinese counterpart | Catalog editing, new-language registration and translation review |
| Illustration, palette, motion and accessible layout | [Visual language](./design/visual-language.md) | Shared Browser/App design grammar |
| Status projection | [Media status](./design/media-status.md) | Which facts each display surface may express |
| Release identity and compatibility | [Versioning](./reference/versioning.md) | Version roles, protected interfaces and public-readiness boundary |
diff --git a/docs/operations/github.md b/docs/operations/github.md
index 1528339..337861e 100644
--- a/docs/operations/github.md
+++ b/docs/operations/github.md
@@ -74,14 +74,24 @@ 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:
+For a local retry, use the original complete build output retained outside the
+checkout. While the publishing run's artifacts are available, download all four
+into one directory (replace `RUN_ID`, `FULL_SOURCE_SHA` and `VERSION`):
```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
+for artifact in piik-server piik-app-windows-amd64 piik-app-linux-amd64 piik-app-darwin-arm64; do
+ gh run download RUN_ID --repo TNTcraftHIM/Piik \
+ --name "$artifact-FULL_SOURCE_SHA" --dir /tmp/piik-mirror
+done
+node scripts/mirror-release.mjs /tmp/piik-mirror VERSION FULL_SOURCE_SHA
```
+Use a fresh directory. Public Release downloads contain only runtime archives;
+mirror verification also needs the original descriptors, manifest and checksum
+files. If CI artifacts have expired, recover that complete output from the
+operator's retained copy. Rebuilding a published version is not a metadata
+recovery procedure. See [artifact retention](../deployment.md#build-host).
+
Use the release's actual version and full `target_commitish` SHA, with
`GITEE_TOKEN` supplied through the publisher environment. `--dry-run` after the
three arguments checks local identity, checksums and attachment sizes without
diff --git a/docs/operations/website.md b/docs/operations/website.md
index 227c2eb..ca8ec19 100644
--- a/docs/operations/website.md
+++ b/docs/operations/website.md
@@ -81,10 +81,10 @@ a website rebuild. `PIIK_WEBSITE_RELEASE_DATA` points the build at temporary JSO
containing the published GitHub release and same-tag Gitee attachment metadata.
CI supplies this input; the builder validates source identity, uploaded ZIPs,
sizes and platform URLs. A missing/incomplete GitHub release fails deployment,
-preserving the current site. Verified Gitee links show their specific version
+preserving the current site. Verified Gitee links target the matched release
and refresh after publication. Without matching metadata, Gitee retains its
-labeled release-page link. The page makes no visitor API request or inferred
-version claim when metadata is absent.
+labeled release-page link. Download captions omit build-time version snapshots;
+the page makes no visitor API request.
## Publish
diff --git a/docs/product/presentation-lifecycle.md b/docs/product/presentation-lifecycle.md
index ad6eb08..2633853 100644
--- a/docs/product/presentation-lifecycle.md
+++ b/docs/product/presentation-lifecycle.md
@@ -62,11 +62,16 @@ commands and responses retain their strict protocol contract.
## Visual Language
Chinese, English, and pure-visual modes are three expressions of the same typed
-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.
+state and command catalog. On first use, the App and Server UI match the
+Browser's primary system language against registered locales, using English
+when none matches. Current catalogs cover Simplified Chinese and English.
+Both Simplified and Traditional Chinese system locales select the current
+Chinese catalog across App, Server UI and website; this includes region and
+script tags such as `zh-CN`, `zh-TW`, `zh-HK`, `zh-Hans` and `zh-Hant`.
+Pure-visual mode remains an explicit choice. An explicit later choice is
+persisted and takes precedence over that default, including App launch handoff.
+The App console uses its own supported presentation languages and falls back
+to English for additional UI languages. Text modes use concise copy.
The [visual language](../design/visual-language.md) is the single owner of
illustrative roles and objects, semantic colour, panel grammar, motion,
@@ -77,8 +82,10 @@ accessible input and responsive hierarchy.
One `` 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.
+or disconnected. Theater and fullscreen are separate modes. Page-element
+fullscreen includes the playback bar; Safari's video-only fullscreen uses
+system controls and native video events to reflect playback and exit.
+Identity, connection details and topology stay in the deck.
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
diff --git a/docs/product/routing-transport.md b/docs/product/routing-transport.md
index 784f992..be801cd 100644
--- a/docs/product/routing-transport.md
+++ b/docs/product/routing-transport.md
@@ -37,7 +37,8 @@ share generation:
- NAT traversal requires an exact three-destination STUN survey: a Site may use
self-hosted 3478/3479/3480, Public Link uses its bounded public survey, and
pure LAN supplies none. Its default-on Host switch augments Browser and Native
- P2P edges; Native also uses its media socket and best-effort gateway mapping.
+ P2P edges. Native uses one media socket for IPv4 and, where available, IPv6
+ direct connections; its STUN survey and best-effort gateway mapping use IPv4.
Availability and background direct acquisition share a bounded budget of
three actual connection attempts per eligible parent/session opportunity.
Retries use the same serial controller and normal operation deadlines;
@@ -97,6 +98,9 @@ not abandon its only possible route at the foreground boundary.
The pending Viewer transport reports an actual Browser failure but installs no
shorter initial or disconnected-state deadline. Viewer-owned reconnect timing
begins only after that exact candidate commits as the active route.
+Either preparing endpoint reports local rejection or connection-start failure
+to that same operation immediately; it does not wait out a silent candidate's
+deadline or start an independent recovery loop.
When NAT traversal is enabled, another operation may use the remaining
connection-attempt budget after rollback. The waiting display uses the actual
server-issued attempt ordinal; it never counts time as an attempted connection.
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index 5041435..8c96f8d 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -80,6 +80,13 @@ schema field. The user settings are:
The launcher preselects a saved Site when present; otherwise it preselects the
public invitation link. Selecting a mode does not start it: the user confirms
with the launch button. Local mode remains available for the same network.
+For Local mode, the launcher selects a sole active address or sole private IPv4
+address automatically. With several choices it shows interface names and IPs;
+an ambiguous choice must be selected before launch. `--lan-address` preselects
+an active address. Selection applies to this launch and is revalidated at startup.
+It sets the local invitation origin; HTTP still listens on the existing wildcard
+listener and ICE remains free to use available media interfaces. Public Link and
+Site mode require no local address selection.
Command-line options select entry and local runtime behavior:
@@ -92,8 +99,8 @@ Command-line options select entry and local runtime behavior:
| `--capture-process ` / `--tunnel-process ` | Override packaged native capture or public tunnel helpers. Ordinary installations use the packaged paths. |
| `--debug` / `--log-dir ` | 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.
+Share quality, room access policy, language and theme are configured in
+the shared Web UI. Decorative motion follows the system's reduced-motion preference.
## Diagnostics
@@ -105,26 +112,40 @@ Diagnostics are local and opt-in. Enable them **before** reproducing the problem
| Surface | Enable | Export |
| --- | --- | --- |
-| App | Start with `--debug` or `PIIK_DEBUG=client` | Press `D` in the terminal for a ZIP |
-| Browser Host/Viewer | Add `?debug=1` to the page URL, before any invitation fragment | Use the download button beside language/theme controls |
+| App | Enable **Debug launch** with the small chip icon after the theme control in the mode selector, or start with `--debug` / `PIIK_DEBUG=client` | Press `D` in the terminal for a ZIP |
+| Browser Host/Viewer | Click the **Debug** chip icon after the theme control and confirm the reload, or add `?debug=1` before any invitation fragment | The same control becomes a download arrow for the web report |
| Hosted Server | Start with `--debug`, `PIIK_DEBUG=server` or `PIIK_DEBUG=route` | On Unix, `kill -USR1 `; also exported at orderly shutdown |
+App **Debug launch** enables App and Browser collection for that run before
+starting the selected mode. It records Native capability results and subsequent
+capture, connection and local-server activity. Use `--debug` for failures before
+the mode selector opens. This choice does not change the saved App configuration.
+When bypassing the mode selector, `--debug` and `PIIK_DEBUG=client` enable backend
+collection; enable the Browser control separately for browser-side diagnosis.
+
+The Browser entry reloads the current page so collection includes connection
+startup. The opt-in follows App launch and room entry. Enabling it keeps the
+current URL parameters and any invitation fragment, but interrupts active
+sharing/viewing; enable it before reproducing the problem. Reports remain local
+until exported and shared by the user.
+
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.
+it does not enable App Native/capture or Server logging, or download their
+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
-available as `await window.__PIIK_DEBUG__.export()` in DevTools.
+Browser/App cooperation, include both from the same reproduction. Exporting
+either report does not stop an active share or upload anything. Browser export
+also remains available as `await window.__PIIK_DEBUG__.export()` in DevTools.
App logs go to `logs` beside the executable, falling back to `Piik/logs`
in the OS user-cache directory when that default is unwritable. The TUI shows
the actual path and the exported ZIP. `--log-dir` overrides
`PIIK_LOG_DIR`; an explicit directory must be writable. Choosing a
directory alone does not enable collection. Non-interactive Apps export at
-orderly shutdown. On App, `PIIK_DEBUG=route` alone retains console route
+orderly shutdown; a returned App error also exports before the terminal closes.
+On App, `PIIK_DEBUG=route` alone retains console route
tracing; use `--debug` for file collection and the `D` action.
Hosted logs use `PIIK_LOG_DIR`, otherwise systemd `LOGS_DIRECTORY`,
@@ -152,6 +173,10 @@ The report combines operation history and existing runtime evidence:
Pool observations distinguish native carrier reports, actual output and its
assigned producer. Export adds Browser/platform metadata; diagnostic collection
does not replace media APIs or control transport.
+ Each RTC sample includes a compact ICE summary ahead of the bounded raw stats:
+ reported pair states and selected-path type, prediction provenance, check
+ responses and RTT when available. Pair counts describe that sample, not route
+ attempts or a connection-success rate.
Each Go component keeps an 8 MiB current log and one 8 MiB backup. ZIPs contain
these retained logs, a report marker, selected startup context, build/module and
@@ -162,12 +187,14 @@ or C++ memory dumps. A failed optional collector leaves useful files available;
failure still reports an export failure. Rotation and preexisting history are
identified; retained logs do not claim a complete session history.
+Exported ZIPs remain until the user moves or deletes them; rotation manages only
+the current logs. Repeated exports and Debug-mode error exits accumulate archives,
+so manage that directory's disk usage separately.
+
Browser retains up to 8,192 events and 8 MiB of compact event data in the current
page. Reports identify retained sequence/time ranges, evicted/truncated events
and collector failures. Field/record limits are explicit in the report.
Browser reload/close loses that in-page history; export before closing it.
-Go ZIP exports remain until the user/operator removes them; rotation only
-manages the current logs.
Credentials, authorization/cookies, invitation secrets, ICE passwords/fragments
and private keys are filtered before persistence/export. Application media
diff --git a/docs/reference/engineering.md b/docs/reference/engineering.md
index 582c769..5395476 100644
--- a/docs/reference/engineering.md
+++ b/docs/reference/engineering.md
@@ -131,10 +131,15 @@ above rather than searching the tree.
connection readiness, requested settings, applied settings and observation.
Derive presentation from facts; do not synchronize parallel booleans or let
a cached failure outlive its evidence.
+ Cumulative observations need a valid baseline for the resource generation
+ they describe; missing measurements are not zero. Serialized recovery reads
+ current committed intent when it executes rather than replaying a queued snapshot.
- 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.
+ A caller deadline does not cancel a dependency that ignores it. Keep late
+ work bounded and serialize its cleanup; verify the dependency's actual lifetime.
- 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.
diff --git a/docs/reference/naming.md b/docs/reference/naming.md
index 067d45f..ba13ada 100644
--- a/docs/reference/naming.md
+++ b/docs/reference/naming.md
@@ -118,7 +118,7 @@ The [visual language](../design/visual-language.md) owns where these lines appea
| Hosted service in prose | `Piik Server` | Deployment instructions |
| Executables and command directories | Lowercase, hyphen-separated | `piik-app[.exe]`, `piik-server[.exe]`, `cmd/piik-app` |
| Native capture executable | Lowercase component name | `piik-capture[.exe]` |
-| Download archives | Lowercase, target and revision suffix | `piik-app-windows-amd64-.tar.gz` |
+| App download archives | Lowercase, fixed target name within each release | `piik-app-windows-amd64.zip` |
| Package/build directory names | Lowercase and target suffix | `piik-app-windows-amd64` |
| macOS application bundle | Display name, standard extension | `Piik App.app` |
| Linux desktop entry | Lowercase file/Exec, display Name | `piik-app.desktop`, `Exec=piik-app`, `Name=Piik App` |
diff --git a/docs/reference/versioning.md b/docs/reference/versioning.md
index 99a1a6e..527fc69 100644
--- a/docs/reference/versioning.md
+++ b/docs/reference/versioning.md
@@ -94,6 +94,15 @@ release descriptor. Fixed names allow GitHub's native latest-asset links;
[website operations](../operations/website.md#preview) owns download presentation
and verification of the matching Gitee mirror.
+Public Release attachments contain the three App ZIPs and the Server runtime
+archive. Package descriptors (`.release.json`), file manifests (`.manifest.tsv`)
+and standalone `.sha256` files remain build/deployment inputs in the local output
+and CI artifacts; publishers validate them but do not upload them to GitHub or
+Gitee Releases. The release body includes a collapsed full source SHA and
+SHA-256 listing generated from the verified packages. GitHub's automatic source
+archives are separate from Piik's uploaded packages. Retain original build
+metadata with operator deployment records as described in [deployment](../deployment.md).
+
Update checks compare actual release precedence. The App reports its compiled
OS/architecture and links to that platform's ZIP in the selected release metadata;
the release page remains the fallback when no matching attachment is available.
@@ -175,9 +184,12 @@ prepare its commit text before dispatching a release rehearsal. A manual
candidate with no product changes may still be built; its summary reports that
there are no product release notes, and publication remains disabled.
-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 publisher appends generated build identity and package checksums to the
+reviewed text, passes it through a temporary notes file to GitHub, then removes
+that file. Gitee copies the GitHub description. Retrying the same
+release preserves its reviewed prose; draft retries refresh only the marked
+generated build/checksum section to match the uploaded bytes. Published releases
+remain unchanged. An exact tag does not move
the changelog's starting point. Keep no `CHANGELOG.md`, versioned note directory
or generated history commits. PRs and Git retain editorial provenance.
@@ -189,8 +201,10 @@ back to GitHub. The Gitee repository need not copy the source tree. Its README/t
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.
+alone does not synchronize Release attachments. Keep one release description.
+Update detection reads each provider's API metadata; it does not parse the public
+changelog or download build descriptors. The existing pipeline owns local
+machine-readable build metadata.
The two providers only adapt API fields and download locations into the same
release model. Share version comparison, channel filtering and package validation.
@@ -205,7 +219,7 @@ Retrying a failed mirror upload reuses the original package. Advertise a mirrore
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
+The publisher reuses the shared artifact validator, checks every public package
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
diff --git a/docs/research/nat-traversal.md b/docs/research/nat-traversal.md
index 070a22a..f76dfc7 100644
--- a/docs/research/nat-traversal.md
+++ b/docs/research/nat-traversal.md
@@ -1,6 +1,6 @@
# Browser And Native NAT Traversal
-Last reviewed: 2026-09-02
+Last reviewed: 2026-09-13
This document owns evidence for improving direct ICE without adding a
new relay or a custom transport. The current product contract remains standard
@@ -75,8 +75,26 @@ the independently mapped-port candidate does not. The switch applies to Host,
Viewer upstream, and Viewer relay P2P connections. Its exact scope is recorded in
[ADR-0009](../adr/0009-optional-nat-prediction.md).
+Browser and Native candidate adapters use the same generation-scoped predictor;
+Native observations do not need a second Go predictor. Predictions can trickle
+as soon as the third qualifying survey observation arrives. Route scheduling
+continues to use the operation deadline in ADR-0005, independently of whether
+prediction produces candidates.
+On an ICE restart, discard the old prediction batch rather than emitting an
+unscoped end marker into the new generation. The real gathering owner supplies
+completion, following [Trickle ICE generation rules](https://www.rfc-editor.org/rfc/rfc8838.html#section-13).
+An earlier remote description is not readiness for a new generation's candidates.
+Native event delivery can precede its offer/answer response; the existing
+negotiation queue retains those candidates until the matching answer is applied.
+
### Native Shared-Socket Preflight
+Native requests one dual-stack wildcard UDP socket through Go and Pion's existing
+UDP mux. IPv4 remains usable on IPv4-only systems, and concrete IPv4 bindings
+remain IPv4-only. Usable IPv6 interfaces can supply direct ICE candidates on the
+same port; IPv4 discovery, prediction and gateway mapping retain their current
+owners. IPv6 does not remove firewall restrictions or prove a reachable peer.
+
On 2026-09-05, the unmodified Pion srflx gatherer contacted three public STUN
destinations from three different temporary local ports, despite the media
engine's ordinary UDP mux. This invalidated the prior assumption that Native
@@ -96,10 +114,45 @@ mapping for that same socket. The returned port is advertised as a
lower-priority candidate using a public address already observed by ordinary
STUN. This is additive and bounded; a VPN, double NAT, or absent mapping service
can make it unusable without delaying or replacing ordinary ICE.
+Gateway preparation runs alongside ordinary gathering. Each survey destination
+resolves and sends Binding independently within one shared deadline; an unhealthy
+DNS target cannot withhold a healthy target's result. End-of-candidates follows
+both the ordinary gatherer and the bounded supplemental work, and retirement
+discards that gathering generation's late output.
A local UDP-forwarding gate then withheld every ordinary Host candidate and
connected Pion ICE/DTLS in 1.26 seconds through the advertised `mp1` endpoint.
That proves the same-socket ICE mechanism, not rescue through a physical NAT.
+### Gateway And Survey Limits
+
+The pinned `go-nat` NAT-PMP adapter ignores context cancellation. Its underlying
+client can retry each proposed port for about 128 seconds, with several port
+proposals per call. Piik bounds the mapping caller's wait to three seconds;
+an abandoned worker may continue to the dependency's own retry limit. Failed
+attempts are not relaunched by later edges. Cleanup must not call Delete while
+that worker still owns the adapter's unsynchronized port bookkeeping.
+
+NAT-PMP Delete in this dependency clears only its local bookkeeping; it sends
+no router deletion. A successful mapping can therefore remain until its requested
+two-hour lease expires, including a late success after Piik stops waiting.
+Caller timeout and router lease expiry are distinct lifecycle boundaries.
+The same adapter discards the gateway's `MappedExternalPort` response and returns
+its requested port. A gateway may assign a different port under
+[RFC 6886](https://www.rfc-editor.org/rfc/rfc6886.html#section-3.3), making that
+optional mapped candidate incorrect. Ordinary ICE candidates remain available.
+Fix this inside the dependency so its combined NAT-PMP/PCPv6 adapter retains
+IPv6 pinholes; a second mapping writer would add another failure and lease owner.
+
+Binding all three STUN listeners proves local startup, not public reachability.
+A local self-probe also cannot prove traversal through an operator's firewall.
+Missing prediction may reflect unreachable survey destinations or insufficient
+distinct mappings. Library packet logs remain disabled because they expose
+remote addresses; future health summaries must retain that privacy boundary.
+Candidate provenance, selected paths and connection outcomes already have Debug
+owners. Count emitted candidates separately from successful connections; zero
+predicted selections alone does not establish a broken predictor or a success
+rate. Further observation work belongs to [TODO](../todo.md).
+
### Independent Observation And Attribution Preflight
On 2026-09-01, Chrome 151 on the flagship Windows network gathered one `srflx`
@@ -125,12 +178,11 @@ remained independent. The flagship deployment enables the bounded capability
to continue attributable field observation without claiming a demonstrated
reachability gain.
-After an SFU commit, the current route controller gives each deferred Peer
-parent one full direct-convergence attempt and then consumes that continuation.
-Further generations come from a new participant session, publication, or share,
-not a periodic retry loop. This bounds current field exposure. A fixed repeat
-budget remains unjustified until attributable runs show that fresh generations
-repeatedly recover a usable arithmetic shape.
+Background P2P acquisition behind SFU uses the same bounded opportunity budget
+as foreground acquisition under [ADR-0005](../adr/0005-automatic-hybrid-media-routing.md).
+Fresh connection attempts do not guarantee a new socket, mapping or predictable
+port sequence; field measurements must attribute the selected path to its actual
+connection generation.
The Browser-only alternatives do not yet have an accepted implementation:
@@ -210,4 +262,8 @@ a new Peer route.
- [coturn listener and auxiliary endpoint reference](https://github.com/coturn/coturn/blob/master/examples/etc/turnserver.conf)
- [Cloudflare Realtime STUN service](https://developers.cloudflare.com/realtime/turn/)
- [Pion Universal UDP mux](https://github.com/pion/ice/blob/main/udp_mux_universal.go)
+- [Pion dual-stack candidate enumeration](https://github.com/pion/ice/blob/v4.4.0/udp_mux.go)
+- [Go wildcard socket family selection](https://go.dev/src/net/ipsock_posix.go)
- [WebRTC selected candidate stats](https://www.w3.org/TR/webrtc-stats/#dom-rtcicecandidatestats-foundation)
+- [Pinned go-nat NAT-PMP adapter](https://github.com/netbirdio/go-nat/blob/6b2c8c5c74e8331ed41811cfd2fdc4c3dd8c3ff0/natpmp.go)
+- [NAT-PMP client timeout and mapping calls](https://github.com/jackpal/go-nat-pmp/blob/v1.0.2/natpmp.go)
diff --git a/docs/research/realtime-quality-adaptation.md b/docs/research/realtime-quality-adaptation.md
index 25bcda6..5f31cc5 100644
--- a/docs/research/realtime-quality-adaptation.md
+++ b/docs/research/realtime-quality-adaptation.md
@@ -220,9 +220,29 @@ one/two-second timers are unnecessary.
Current senders therefore start with the requested ceiling and temporary
`maintain-resolution`, then the existing stats path applies the user's desired
-preference once the exact sender has encoded five frames. The application does
+preference once the current source has proved five encoded frames. A fresh RTP
+stream starts at zero; source replacement establishes a baseline from its first
+valid current-track sample. Missing counters and unmatched old-source reports
+remain unknown. Queued restoration reads the current committed user profile
+when it executes. The application does
not add an SDP bitrate hint, periodic rewrite, or custom adaptation ladder.
+### Source Replacement Evidence
+
+On 2026-09-13, six isolated Chrome 152.0.7977.84 loopbacks used a 1920x1080@30
+canvas, `motion`, VP8 software encoding and a 5 Mbps ceiling. Protected startup
+initially delivered 1080p, dipped to 720p during ordinary bandwidth ramp-up,
+then recovered to 1080p around 14 seconds. After an established connection's
+`replaceTrack`, immediate `balanced`, a two-second delay and five-new-frame
+protection all retained 1080p with limitation `none` in their 15-second windows.
+Two 16x16-to-1080p replacements likewise reached 1080p with and without protection.
+
+The RTP stream's `framesEncoded` counter survived replacement. These runs did
+not reproduce a new startup restriction on an established connection; they do
+not justify removing protection for H.264, other browsers or cold recovery.
+The [WebRTC statistics model](https://www.w3.org/TR/webrtc-stats/#dom-rtcoutboundrtpstreamstats-framesencoded)
+defines this counter per RTP stream, independently of a capture-track lifetime.
+
## LiveKit Representation Evidence
Pinned LiveKit client 2.22.0, server 1.13.5, and Chrome 151/153 showed that
diff --git a/docs/todo.md b/docs/todo.md
index b5501bb..dab7ec4 100644
--- a/docs/todo.md
+++ b/docs/todo.md
@@ -16,14 +16,27 @@ history. A parked idea is not implementation authority.
Keep fixes on a maintenance branch until acceptance. The public release is the
compatibility baseline; private service deployment stays independent.
-## Next: P2P Connection And Feedback Review
+## Next: P2P Connection And Feedback Evidence
+
+**Next release: NAT-PMP dependency correctness.** Integrate an upstream fix or
+maintained dependency revision that uses the gateway-assigned external port,
+sends an actual deletion request and honors cancellation/deadlines. Preserve
+the existing PCPv6 combination and bounded mapping lifecycle. The current
+optional path can advertise a requested port that the router reassigned;
+ordinary ICE remains available. Keep this out of the current release. See the
+[dependency boundary](./research/nat-traversal.md#gateway-and-survey-limits).
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
+Use existing Debug provenance and selected-path events before adding runtime
+counters. Separate emitted candidates, actual connection attempts, successful
+paths and timeouts; keep observations scoped to connection generations and
+exclude raw endpoints. Establish survey response visibility without treating
+local listener binding as proof of public reachability. The
+[NAT evidence](./research/nat-traversal.md#gateway-and-survey-limits) owns the
+dependency and observation limits. Compare current P2P/SFU handoffs, including
+background P2P attempts behind working SFU media, before choosing changes.
+Preserve one graph and one operation under the
[routing contract](./product/routing-transport.md) and
[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.
@@ -82,17 +95,21 @@ Host is paused. No preservation policy has been accepted for that transition.
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. **Community translations.** Prepare the existing locale catalogs for
- community contributions when scheduled. No new translation framework is
- needed for the current Chinese, English and optional pure-visual modes.
-9. **First-share startup quality.** Verify that the existing five-frame startup
- protection for `motion + balanced` still takes effect on every active sender
- and shared-encoding path. Compare with the documented
- [startup regression](./research/realtime-quality-adaptation.md) and check
- whether later audits or refactors bypassed a necessary guard. Do not remove
- the workaround without evidence that first-share quality remains correct.
-10. **32-bit App packages.** Deferred until suitable native capture dependencies
+8. **Additional languages.** Review community catalogs and their rendered UI
+ following the [translation guide](./guide/translating.md). Add website,
+ documentation or App console translations as contributed; verify text
+ direction and layout when a language requires it. Before registering the
+ first extra UI language, fix the overflow menu's placement near the viewport
+ bottom: it currently permits zero content height. Check native popover
+ expanded/collapsed accessibility state with the rendered menu.
+9. **32-bit App packages.** Deferred until suitable native capture dependencies
are available. Windows x86 core compilation alone does not establish App
support: the pinned capture SDK currently has no Windows x86 package.
Complete native packaging and real launch/capture acceptance before
advertising a 32-bit target.
+10. **Ready-to-run Docker distribution.** Publish a maintained image and a short
+ deployment recipe. The existing runtime image already contains Web, room
+ signaling, STUN and optional embedded SFU in one process; the gap is a
+ published image and simple HTTPS, port and persistent-volume setup. Reuse
+ that server artifact and the existing release identity rather than adding
+ another service stack.
diff --git a/docs/verification-status.md b/docs/verification-status.md
index ac62cea..be97aaf 100644
--- a/docs/verification-status.md
+++ b/docs/verification-status.md
@@ -1,6 +1,6 @@
# Verification Status
-Last updated: 2026-09-12
+Last updated: 2026-09-13
This file owns physical evidence limits that change how the product may be
described. [Status](./status.md) owns the execution/deployment index.
@@ -68,6 +68,8 @@ The following are broader coverage and endurance tasks, not automatically new
implementation mechanisms:
- heterogeneous public-network P2P and multi-hop Browser/Native/SFU-fed relays;
+- Native IPv6 direct connections across separate networks with usable IPv6;
+ local dual-stack checks establish socket and candidate behavior only;
- Host/Viewer TUN/VPN combinations, migration and all-UDP-blocked bounded failure;
- two-room public SFU load, real-game audio/video synchronization and sustained
packet loss/recovery;
diff --git a/internal/app/app.go b/internal/app/app.go
index 255d1cd..bc11388 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -81,7 +81,7 @@ func Run(ctx context.Context, options Options) (returnedErr error) {
cancel()
if recorder != nil {
slog.Debug("piik-client", "event", "stopped", "failed", returnedErr != nil, diagnostics.Error(returnedErr))
- if options.console.program == nil {
+ if options.console.program == nil || returnedErr != nil {
path, err := recorder.Export()
options.console.send(consoleExportResult{path: path, err: err})
returnedErr = errors.Join(returnedErr, err)
@@ -91,7 +91,10 @@ func Run(ctx context.Context, options Options) (returnedErr error) {
}
returnedErr = errors.Join(returnedErr, options.console.finish(returnedErr))
}()
- if options.Debug {
+ enableDiagnostics := func() error {
+ if recorder != nil {
+ return nil
+ }
var err error
recorder, err = openDiagnostics(options.LogDir)
if err != nil {
@@ -112,6 +115,13 @@ func Run(ctx context.Context, options Options) (returnedErr error) {
log.SetOutput(dependencyLog)
log.SetFlags(previousFlags)
options.console.send(consoleDebug{logPath: recorder.LogPath(), export: recorder.Export})
+ slog.Debug("piik-client", "event", "diagnostics-enabled")
+ return nil
+ }
+ if options.Debug {
+ if err := enableDiagnostics(); err != nil {
+ return err
+ }
}
slog.Debug("piik-client", "event", "start", "version", buildVersion(), "revision", BuildRevision)
options.console.show(consoleView{state: "starting"})
@@ -126,12 +136,12 @@ func Run(ctx context.Context, options Options) (returnedErr error) {
var err error
configPath, err = appconfig.DefaultPath()
if err != nil {
- return errors.New("Piik App configuration is unavailable")
+ return fmt.Errorf("Piik App configuration is unavailable: %w", err)
}
}
config, err := appconfig.LoadOrCreate(configPath)
if err != nil {
- return errors.New("Piik App configuration is unavailable")
+ return fmt.Errorf("Piik App configuration is unavailable: %w", err)
}
config, err = applyMode(config, options)
if err != nil {
@@ -140,20 +150,25 @@ func Run(ctx context.Context, options Options) (returnedErr error) {
slog.Debug("piik-client", "event", "configuration", "siteConfigured", config.Site != "", "localAccessProtected", config.LocalAccessPassword != "")
if options.SiteSet || options.Local {
if err = appconfig.Save(configPath, config); err != nil {
- return errors.New("Piik App configuration is unavailable")
+ return fmt.Errorf("Piik App configuration is unavailable: %w", err)
}
}
nativeMedia := discoverNativeMedia(ctx, options.CaptureProcess)
- if recorder != nil {
- recorder.Context("configuration", map[string]any{"siteConfigured": config.Site != "", "local": options.Local,
- "link": options.Link, "port": options.Port, "localAccessProtected": config.LocalAccessPassword != ""})
+ recordContext := func(selected Options, config appconfig.Config) {
+ if recorder == nil {
+ return
+ }
+ recorder.Context("configuration", map[string]any{"siteConfigured": config.Site != "", "local": selected.Local,
+ "link": selected.Link, "port": selected.Port, "lanAddress": selected.LANAddress,
+ "localAccessProtected": config.LocalAccessPassword != ""})
recorder.Context("capture", nativeMedia.capture)
recorder.Binary("captureExecutable", nativeMedia.captureProcess)
+ slog.Debug("piik-client", "event", "native-capabilities",
+ "video", nativeMedia.capabilities.Video, "processAudio", nativeMedia.capabilities.ProcessAudio,
+ "systemAudio", nativeMedia.capabilities.SystemAudio, "hardwareH264", nativeMedia.capabilities.HardwareH264,
+ "softwareVP8", nativeMedia.capabilities.SoftwareVP8, diagnostics.Error(nativeMedia.discoveryErr))
}
- slog.Debug("piik-client", "event", "native-capabilities",
- "video", nativeMedia.capabilities.Video, "processAudio", nativeMedia.capabilities.ProcessAudio,
- "systemAudio", nativeMedia.capabilities.SystemAudio, "hardwareH264", nativeMedia.capabilities.HardwareH264,
- "softwareVP8", nativeMedia.capabilities.SoftwareVP8)
+ recordContext(options, config)
control, err := loopback.Start(ctx, loopback.Options{
AllowedOrigins: allowedOrigins(config.Site, options.Port),
NativeMedia: nativeMedia.capabilities,
@@ -161,7 +176,7 @@ func Run(ctx context.Context, options Options) (returnedErr error) {
Presentation: options.console.setLanguage,
})
if err != nil {
- return errors.New("Piik App could not start")
+ return fmt.Errorf("Piik App native control could not start: %w", err)
}
defer control.Close()
slog.Debug("piik-client", "event", "control-ready")
@@ -171,7 +186,16 @@ func Run(ctx context.Context, options Options) (returnedErr error) {
}
}
if !explicitMode(options) {
- return runLauncher(ctx, options, configPath, config, control)
+ return runLauncher(ctx, options, configPath, config, control, func(selected *Options, config appconfig.Config) error {
+ if selected.Debug {
+ if err := enableDiagnostics(); err != nil {
+ return err
+ }
+ selected.logger = recorder.Logger()
+ }
+ recordContext(*selected, config)
+ return nil
+ })
}
return runConfigured(ctx, options, config, control)
}
@@ -198,14 +222,16 @@ func runLauncher(
configPath string,
config appconfig.Config,
control *loopback.Server,
+ configureDiagnostics func(*Options, appconfig.Config) error,
) error {
launch, err := launcher.Start(
ctx,
webassets.FS(),
- config.Site,
- buildVersion(),
- BuildRevision,
- config.LocalAccessPassword,
+ launcher.Options{
+ Site: config.Site, Version: buildVersion(), Revision: BuildRevision,
+ LocalAccessPassword: config.LocalAccessPassword,
+ Debug: options.Debug, LANAddress: options.LANAddress,
+ },
)
if err != nil {
return err
@@ -221,7 +247,7 @@ func runLauncher(
))
options.console.show(consoleView{state: "setup", entry: launch.URL()})
if err = browser.Open(launch.URL()); err != nil {
- return errors.New("Piik App could not open its launcher")
+ return fmt.Errorf("Piik App could not open its launcher: %w", err)
}
var selection launcher.Selection
@@ -231,7 +257,7 @@ func runLauncher(
return nil
case err = <-launch.Done():
if err != nil {
- return errors.New("Piik App launcher stopped unexpectedly")
+ return fmt.Errorf("Piik App launcher stopped unexpectedly: %w", err)
}
return nil
}
@@ -241,15 +267,25 @@ func runLauncher(
} else {
config.LocalAccessPassword = selection.LocalAccessPassword
}
- if err = appconfig.Save(configPath, config); err != nil {
- launch.SetResult("", err)
- <-launch.Handled()
- return errors.New("Piik App configuration is unavailable")
- }
- control.SetAllowedOrigins(allowedOrigins(config.Site, options.Port))
options.SiteSet = false
options.Local = selection.Mode == launcher.ModeLocal
options.Link = selection.Mode == launcher.ModeLink
+ options.Debug = options.Debug || selection.Debug
+ if selection.LANAddress != nil {
+ options.LANAddress = *selection.LANAddress
+ }
+ if err = configureDiagnostics(&options, config); err != nil {
+ launch.SetResult("", err)
+ <-launch.Handled()
+ return err
+ }
+ if err = appconfig.Save(configPath, config); err != nil {
+ err = fmt.Errorf("Piik App configuration could not be saved: %w", err)
+ launch.SetResult("", err)
+ <-launch.Handled()
+ return err
+ }
+ control.SetAllowedOrigins(allowedOrigins(config.Site, options.Port))
options.DisableBrowser = true
ready := make(chan string, 1)
options.Ready = func(target string) { ready <- target }
@@ -361,7 +397,7 @@ func runSite(site string, options Options, control *loopback.Server) error {
}
if !options.DisableBrowser {
if err = browser.Open(targetURL); err != nil {
- return errors.New("Piik App could not open the Site")
+ return fmt.Errorf("Piik App could not open the Site: %w", err)
}
} else if options.Ready != nil {
options.Ready(targetURL)
@@ -370,7 +406,7 @@ func runSite(site string, options Options, control *loopback.Server) error {
slog.Debug("piik-client", "event", "site-ready")
options.console.show(view)
if err = <-control.Done(); err != nil {
- return errors.New("Piik App stopped unexpectedly")
+ return fmt.Errorf("Piik App native control stopped unexpectedly: %w", err)
}
return nil
}
@@ -393,13 +429,16 @@ func runLocal(ctx context.Context, options Options, config appconfig.Config,
_ = listener.Close()
}
}()
- addresses, err := lan.Addresses()
- if err != nil {
- return err
+ addresses, lanErr := lan.Addresses()
+ selectedAddress := ""
+ if lanErr == nil {
+ selectedAddress, lanErr = lan.Select(addresses, options.LANAddress)
}
- selectedAddress, err := lan.Select(addresses, options.LANAddress)
- if err != nil {
- return err
+ // A public invitation only needs the tunnel origin; keep any resolved LAN
+ // addresses as allowed local origins but never block link startup on LAN
+ // selection (multi-interface hosts may not have one obvious address).
+ if lanErr != nil && !options.Link {
+ return lanErr
}
var tunnel *publictunnel.Process
publicOrigin := ""
@@ -470,7 +509,7 @@ func runLocal(ctx context.Context, options Options, config appconfig.Config,
)
if !options.DisableBrowser {
if err = browser.Open(launchURL); err != nil {
- return errors.New("Piik App could not open the Local page")
+ return fmt.Errorf("Piik App could not open the Local page: %w", err)
}
} else if options.Ready != nil {
options.Ready(launchURL)
@@ -490,11 +529,11 @@ func runLocal(ctx context.Context, options Options, config appconfig.Config,
return endLocalServer(localServer)
case err = <-control.Done():
if err != nil {
- return errors.New("Piik App runtime stopped unexpectedly")
+ return fmt.Errorf("Piik App native control stopped unexpectedly: %w", err)
}
return nil
case <-tunnelDone:
- return errors.New("public invitation link stopped unexpectedly")
+ return errors.Join(errors.New("public invitation link stopped; reopen Piik to create a new link"), tunnel.Err())
}
}
@@ -534,6 +573,7 @@ type nativeRuntime struct {
capabilities loopback.NativeMediaCapabilities
capture nativecapture.Capabilities
captureProcess string
+ discoveryErr error
}
func (runtime nativeRuntime) controlFactory() func() loopback.ControlSession {
@@ -560,7 +600,7 @@ func discoverNativeMedia(ctx context.Context, configuredPath string) nativeRunti
}
capabilities, err := nativecapture.Discover(ctx, path)
if err != nil {
- return nativeRuntime{}
+ return nativeRuntime{captureProcess: path, discoveryErr: err}
}
summary := capabilities.Summary()
return nativeRuntime{
diff --git a/internal/app/app_test.go b/internal/app/app_test.go
index 6ce4526..2fa916a 100644
--- a/internal/app/app_test.go
+++ b/internal/app/app_test.go
@@ -17,6 +17,7 @@ import (
"time"
appconfig "github.com/TNTcraftHIM/Piik/internal/app/config"
+ "github.com/TNTcraftHIM/Piik/internal/app/lan"
"github.com/TNTcraftHIM/Piik/internal/app/loopback"
serverconfig "github.com/TNTcraftHIM/Piik/internal/server/config"
)
@@ -87,6 +88,23 @@ func TestOccupiedPortRejectsLinkBeforeStartingATunnel(t *testing.T) {
}
}
+func TestLocalLaunchRevalidatesTheChosenLANAddress(t *testing.T) {
+ addresses, err := lan.Addresses()
+ if err != nil {
+ t.Skipf("test requires an active LAN address: %v", err)
+ }
+ const inactive = "192.0.2.254"
+ if slices.Contains(addresses, inactive) {
+ t.Skip("documentation address is configured on this test machine")
+ }
+ err = runLocal(t.Context(), Options{
+ Local: true, LANAddress: inactive, console: &console{machine: true},
+ }, appconfig.Config{}, nil)
+ if err == nil || !strings.Contains(err.Error(), "selected LAN address is not active") {
+ t.Fatalf("Local launch accepted an unavailable invitation address: %v", err)
+ }
+}
+
func TestAppLaunchURLMarksThePageWithoutChangingOrigin(t *testing.T) {
value := launchURL("https://share.example/")
parsed, err := url.Parse(value)
@@ -142,6 +160,23 @@ func TestAppDiagnosticsUseFilesOnlyWhenEnabled(t *testing.T) {
!strings.Contains(string(content), `"event":"stopped","failed":true`) {
t.Fatalf("missing file lifecycle events: %s, %v", content, readErr)
}
+ reports, err := filepath.Glob(filepath.Join(directory, "*.zip"))
+ if err != nil || len(reports) != 1 {
+ t.Fatalf("failed App did not export its diagnostic report: %v, %v", reports, err)
+ }
+ }
+}
+
+func TestAppConfigurationFailurePreservesSystemCause(t *testing.T) {
+ t.Setenv("PIIK_DEBUG", "")
+ blocked := filepath.Join(t.TempDir(), "not-a-directory")
+ if err := os.WriteFile(blocked, nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ err := Run(t.Context(), Options{DisableBrowser: true, ConfigPath: filepath.Join(blocked, "client.json")})
+ var cause *os.PathError
+ if !errors.As(err, &cause) || !strings.Contains(err.Error(), "Piik App configuration is unavailable") {
+ t.Fatalf("configuration failure lost its path/system cause: %v", err)
}
}
@@ -186,6 +221,14 @@ func TestMissingCaptureKeepsViewerControlAvailable(t *testing.T) {
_ = control.Close()
}
+func TestFailedCaptureProbeRetainsItsCause(t *testing.T) {
+ path := t.TempDir() // A directory exists but cannot run as a capture sidecar.
+ native := discoverNativeMedia(t.Context(), path)
+ if native.discoveryErr == nil || native.captureProcess != path || native.capabilities.Video {
+ t.Fatalf("failed probe lost its diagnostic context: %+v", native)
+ }
+}
+
func TestLaunchURLPreservesLocalAccessInsideThePrivateFragment(t *testing.T) {
value := launchURL("http://localhost:8787/#client-access=secret")
parsed, err := url.Parse(value)
diff --git a/internal/app/console.go b/internal/app/console.go
index f10845b..6450f44 100644
--- a/internal/app/console.go
+++ b/internal/app/console.go
@@ -97,7 +97,7 @@ var consoleCopy = map[string][3]string{
func newConsole(cancel context.CancelFunc, machine bool) *console {
console := &console{
machine: machine,
- plain: consoleModel{view: consoleView{state: "starting"}, language: "vis", width: 76, cancel: cancel,
+ plain: consoleModel{view: consoleView{state: "starting"}, language: defaultConsoleLanguage(), width: 76, cancel: cancel,
colors: colorprofile.Detect(os.Stdout, os.Environ()) > colorprofile.ASCII},
}
if machine {
diff --git a/internal/app/console_language_other.go b/internal/app/console_language_other.go
new file mode 100644
index 0000000..ee09233
--- /dev/null
+++ b/internal/app/console_language_other.go
@@ -0,0 +1,31 @@
+//go:build !windows
+
+package app
+
+import (
+ "os"
+ "strings"
+)
+
+func defaultConsoleLanguage() string {
+ var locale string
+ for _, name := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} {
+ if locale = os.Getenv(name); locale != "" {
+ break
+ }
+ }
+ // GNU's message-language preference applies only outside the C/POSIX locale.
+ if locale == "" || locale == "C" || locale == "POSIX" || strings.HasPrefix(locale, "C.") {
+ return "en"
+ }
+ if preferred, _, _ := strings.Cut(os.Getenv("LANGUAGE"), ":"); preferred != "" {
+ locale = preferred
+ }
+ if end := strings.IndexAny(locale, "_-.@"); end >= 0 {
+ locale = locale[:end]
+ }
+ if strings.EqualFold(locale, "zh") {
+ return "zh"
+ }
+ return "en"
+}
diff --git a/internal/app/console_language_other_test.go b/internal/app/console_language_other_test.go
new file mode 100644
index 0000000..fe7f37b
--- /dev/null
+++ b/internal/app/console_language_other_test.go
@@ -0,0 +1,32 @@
+//go:build !windows
+
+package app
+
+import "testing"
+
+func TestConsoleLocaleEnvironment(t *testing.T) {
+ for _, test := range []struct {
+ all, messages, lang, preferred, want string
+ }{
+ {lang: "zh_CN.UTF-8", want: "zh"},
+ {lang: "zh_TW.UTF-8", want: "zh"},
+ {lang: "zh-Hant", want: "zh"},
+ {lang: "ja_JP.UTF-8", want: "en"},
+ {lang: "vis", want: "en"},
+ {messages: "zh_HK.UTF-8", lang: "en_US.UTF-8", want: "zh"},
+ {all: "en_US.UTF-8", messages: "zh_CN.UTF-8", want: "en"},
+ {lang: "en_US.UTF-8", preferred: "zh_TW:en", want: "zh"},
+ {lang: "C.UTF-8", preferred: "zh_TW:en", want: "en"},
+ {all: "POSIX", messages: "zh_CN.UTF-8", want: "en"},
+ {preferred: "zh_TW:en", want: "en"},
+ {want: "en"},
+ } {
+ t.Setenv("LC_ALL", test.all)
+ t.Setenv("LC_MESSAGES", test.messages)
+ t.Setenv("LANG", test.lang)
+ t.Setenv("LANGUAGE", test.preferred)
+ if got := defaultConsoleLanguage(); got != test.want {
+ t.Errorf("locale %+v = %q", test, got)
+ }
+ }
+}
diff --git a/internal/app/console_language_windows.go b/internal/app/console_language_windows.go
new file mode 100644
index 0000000..04367be
--- /dev/null
+++ b/internal/app/console_language_windows.go
@@ -0,0 +1,18 @@
+package app
+
+import "syscall"
+
+func defaultConsoleLanguage() string {
+ // Use the display language, not regional date/number formatting or a shell's LANG.
+ language, _, _ := syscall.NewLazyDLL("kernel32.dll").NewProc("GetUserDefaultUILanguage").Call()
+ return consoleLanguageFromWindowsUI(language)
+}
+
+func consoleLanguageFromWindowsUI(language uintptr) string {
+ // PRIMARYLANGID: every Chinese region/script has LANG_CHINESE (0x04).
+ // https://learn.microsoft.com/en-us/windows/win32/api/winnt/nf-winnt-primarylangid
+ if language&0x3ff == 0x04 {
+ return "zh"
+ }
+ return "en"
+}
diff --git a/internal/app/console_language_windows_test.go b/internal/app/console_language_windows_test.go
new file mode 100644
index 0000000..f667e7c
--- /dev/null
+++ b/internal/app/console_language_windows_test.go
@@ -0,0 +1,25 @@
+package app
+
+import "testing"
+
+func TestConsoleWindowsUILanguage(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ id uintptr
+ want string
+ }{
+ {"zh-CN", 0x0804, "zh"}, {"zh-TW", 0x0404, "zh"},
+ {"zh-Hant", 0x7c04, "zh"}, {"zh-HK", 0x0c04, "zh"},
+ {"zh-SG", 0x1004, "zh"}, {"en-US", 0x0409, "en"},
+ {"ja-JP", 0x0411, "en"}, {"unknown", 0, "en"},
+ } {
+ if got := consoleLanguageFromWindowsUI(test.id); got != test.want {
+ t.Errorf("%s = %q, want %q", test.name, got, test.want)
+ }
+ }
+ actual := defaultConsoleLanguage()
+ if actual != "zh" && actual != "en" {
+ t.Fatalf("Windows display language produced %q", actual)
+ }
+ t.Logf("Actual Windows console language: %s", actual)
+}
diff --git a/internal/app/console_test.go b/internal/app/console_test.go
index cd521e1..dc045c6 100644
--- a/internal/app/console_test.go
+++ b/internal/app/console_test.go
@@ -14,6 +14,17 @@ import (
"github.com/charmbracelet/x/ansi"
)
+func TestConsoleStartsWithSystemLanguageAndAcceptsVisualSelection(t *testing.T) {
+ console := newConsole(func() {}, true)
+ if console.plain.language != defaultConsoleLanguage() || console.plain.language == "vis" {
+ t.Fatalf("initial console language = %q", console.plain.language)
+ }
+ updated, _ := console.plain.Update(consoleLanguage("vis"))
+ if updated.(consoleModel).language != "vis" {
+ t.Fatal("explicit visual selection was ignored")
+ }
+}
+
func TestConsolePresentationAndShutdown(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
diff --git a/internal/app/lan/addresses.go b/internal/app/lan/addresses.go
index 1a24922..7a1e3c0 100644
--- a/internal/app/lan/addresses.go
+++ b/internal/app/lan/addresses.go
@@ -8,12 +8,19 @@ import (
"strings"
)
-func Addresses() ([]string, error) {
+type Address struct {
+ Address string `json:"address"`
+ Name string `json:"name"`
+}
+
+// List describes invitation addresses. It does not choose or constrain the
+// media interfaces, which remain owned by ICE and the operating system.
+func List() ([]Address, error) {
interfaces, err := net.Interfaces()
if err != nil {
return nil, fmt.Errorf("list network interfaces: %w", err)
}
- unique := make(map[string]struct{})
+ unique := make(map[string]Address)
for _, networkInterface := range interfaces {
if networkInterface.Flags&net.FlagUp == 0 || networkInterface.Flags&net.FlagLoopback != 0 {
continue
@@ -27,20 +34,35 @@ func Addresses() ([]string, error) {
if parseErr != nil || ip.To4() == nil || ip.IsLoopback() || ip.IsUnspecified() {
continue
}
- unique[ip.String()] = struct{}{}
+ value := ip.String()
+ if previous, exists := unique[value]; !exists || networkInterface.Name < previous.Name {
+ unique[value] = Address{Address: value, Name: networkInterface.Name}
+ }
}
}
- result := make([]string, 0, len(unique))
- for address := range unique {
+ result := make([]Address, 0, len(unique))
+ for _, address := range unique {
result = append(result, address)
}
- sort.Strings(result)
+ sort.Slice(result, func(left, right int) bool { return result[left].Address < result[right].Address })
if len(result) == 0 {
return nil, errors.New("no active LAN IPv4 address is available")
}
return result, nil
}
+func Addresses() ([]string, error) {
+ addresses, err := List()
+ if err != nil {
+ return nil, err
+ }
+ result := make([]string, 0, len(addresses))
+ for _, address := range addresses {
+ result = append(result, address.Address)
+ }
+ return result, nil
+}
+
func Select(addresses []string, preferred string) (string, error) {
preferred = strings.TrimSpace(preferred)
if preferred != "" {
@@ -51,6 +73,9 @@ func Select(addresses []string, preferred string) (string, error) {
}
return "", errors.New("selected LAN address is not active")
}
+ if len(addresses) == 0 {
+ return "", errors.New("no active LAN IPv4 address is available")
+ }
if len(addresses) == 1 {
return addresses[0], nil
}
@@ -63,5 +88,5 @@ func Select(addresses []string, preferred string) (string, error) {
if len(private) == 1 {
return private[0], nil
}
- return "", fmt.Errorf("multiple LAN addresses are active; select one of: %s", strings.Join(addresses, ", "))
+ return "", fmt.Errorf("multiple LAN addresses are active; restart with --local --lan-address , choosing from: %s; public-link mode does not require this selection", strings.Join(addresses, ", "))
}
diff --git a/internal/app/lan/addresses_test.go b/internal/app/lan/addresses_test.go
index 7c06bbc..4e3753d 100644
--- a/internal/app/lan/addresses_test.go
+++ b/internal/app/lan/addresses_test.go
@@ -1,6 +1,9 @@
package lan
-import "testing"
+import (
+ "strings"
+ "testing"
+)
func TestSelectUsesOneAddressOrAnExplicitActiveAddress(t *testing.T) {
if selected, err := Select([]string{"192.168.1.4"}, ""); err != nil || selected != "192.168.1.4" {
@@ -12,10 +15,18 @@ func TestSelectUsesOneAddressOrAnExplicitActiveAddress(t *testing.T) {
if selected, err := Select([]string{"192.168.1.4", "198.18.0.1"}, ""); err != nil || selected != "192.168.1.4" {
t.Fatalf("private LAN selection = %q, %v", selected, err)
}
- if _, err := Select([]string{"10.0.0.2", "192.168.1.4"}, ""); err == nil {
- t.Fatal("ambiguous selection was accepted")
+ if _, err := Select([]string{"10.0.0.2", "192.168.1.4"}, ""); err == nil ||
+ !strings.Contains(err.Error(), "--local --lan-address ") {
+ t.Fatalf("ambiguous selection must explain the actual CLI option: %v", err)
}
if _, err := Select([]string{"10.0.0.2"}, "192.168.1.4"); err == nil {
t.Fatal("inactive selection was accepted")
}
}
+
+func TestSelectReportsNoAvailableAddress(t *testing.T) {
+ selected, err := Select(nil, "")
+ if selected != "" || err == nil || err.Error() != "no active LAN IPv4 address is available" {
+ t.Fatalf("empty selection must explain the missing LAN address: %q, %v", selected, err)
+ }
+}
diff --git a/internal/app/launcher/server.go b/internal/app/launcher/server.go
index 8a1ed2d..6545760 100644
--- a/internal/app/launcher/server.go
+++ b/internal/app/launcher/server.go
@@ -4,8 +4,10 @@ import (
"context"
"encoding/json"
"errors"
+ "fmt"
"io"
"io/fs"
+ "log/slog"
"net"
"net/http"
"net/url"
@@ -15,6 +17,8 @@ import (
"time"
appconfig "github.com/TNTcraftHIM/Piik/internal/app/config"
+ "github.com/TNTcraftHIM/Piik/internal/app/lan"
+ "github.com/TNTcraftHIM/Piik/internal/diagnostics"
)
const (
@@ -34,12 +38,25 @@ const (
)
type Selection struct {
- Mode Mode `json:"mode"`
- Language string `json:"language"`
- Site string `json:"site,omitempty"`
- LocalAccessPassword string `json:"localAccessPassword"`
+ Mode Mode `json:"mode"`
+ Language string `json:"language"`
+ Site string `json:"site,omitempty"`
+ LocalAccessPassword string `json:"localAccessPassword"`
+ Debug bool `json:"debug,omitempty"`
+ LANAddress *string `json:"lanAddress,omitempty"`
}
+type Options struct {
+ Site string
+ Version string
+ Revision string
+ LocalAccessPassword string
+ Debug bool
+ LANAddress string
+}
+
+var listLANAddresses = lan.List
+
type Server struct {
ctx context.Context
cancel context.CancelFunc
@@ -51,6 +68,8 @@ type Server struct {
localAccessPassword string
version string
revision string
+ debug bool
+ lanAddress string
selection chan Selection
resultReady chan struct{}
handled chan struct{}
@@ -70,15 +89,14 @@ 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, version, revision, localAccessPassword string) (*Server, error) {
+func Start(parent context.Context, assets fs.FS, options Options) (*Server, error) {
if parent == nil {
parent = context.Background()
}
- normalizedSite, err := appconfig.NormalizeSite(site)
+ normalizedSite, err := appconfig.NormalizeSite(options.Site)
if err != nil {
- return nil, errors.New("App launcher Site is invalid")
+ return nil, fmt.Errorf("App launcher Site is invalid: %w", err)
}
- site = normalizedSite
if assets == nil {
return nil, errors.New("App launcher assets are unavailable")
}
@@ -87,7 +105,7 @@ func Start(parent context.Context, assets fs.FS, site, version, revision, localA
}
listener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
- return nil, errors.New("App launcher could not start")
+ return nil, fmt.Errorf("App launcher could not start: %w", err)
}
ctx, cancel := context.WithCancel(parent)
server := &Server{
@@ -96,10 +114,12 @@ func Start(parent context.Context, assets fs.FS, site, version, revision, localA
listener: listener,
assets: assets,
static: http.FileServer(http.FS(assets)),
- site: site,
- localAccessPassword: localAccessPassword,
- version: version,
- revision: strings.TrimSpace(revision),
+ site: normalizedSite,
+ localAccessPassword: options.LocalAccessPassword,
+ version: options.Version,
+ revision: strings.TrimSpace(options.Revision),
+ debug: options.Debug,
+ lanAddress: options.LANAddress,
selection: make(chan Selection, 1),
resultReady: make(chan struct{}),
handled: make(chan struct{}),
@@ -197,12 +217,26 @@ func (server *Server) handleState(response http.ResponseWriter, request *http.Re
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
return
}
+ addresses, err := listLANAddresses()
+ if err != nil {
+ slog.DebugContext(request.Context(), "launcher-lan-unavailable", diagnostics.Error(err))
+ }
+ if addresses == nil {
+ addresses = []lan.Address{}
+ }
+ values := make([]string, 0, len(addresses))
+ for _, address := range addresses {
+ values = append(values, address.Address)
+ }
+ selected, _ := lan.Select(values, server.lanAddress)
writeJSON(response, http.StatusOK, map[string]any{
"site": server.site,
"localAccessPassword": server.localAccessPassword,
"version": server.version,
"revision": server.revision,
"packageTarget": runtime.GOOS + "-" + runtime.GOARCH,
+ "debug": server.debug,
+ "lan": map[string]any{"addresses": addresses, "selected": selected},
"defaultMode": func() Mode {
if server.site != "" {
return ModeSite
@@ -221,13 +255,17 @@ func (server *Server) handleLaunch(response http.ResponseWriter, request *http.R
request.Body = http.MaxBytesReader(response, request.Body, maxRequestBytes)
selection, err := decodeSelection(request.Body)
if err != nil {
- http.Error(response, "invalid launch request", http.StatusBadRequest)
+ writeJSON(response, http.StatusBadRequest, map[string]string{
+ "error": "invalid launch request", "detail": errorDetail(err),
+ })
return
}
server.mu.Lock()
if server.selected {
server.mu.Unlock()
- http.Error(response, "launch already requested", http.StatusConflict)
+ writeJSON(response, http.StatusConflict, map[string]string{
+ "error": "launch already requested", "detail": "Close and reopen Piik before starting again.",
+ })
return
}
server.selected = true
@@ -256,7 +294,7 @@ func (server *Server) handleLaunch(response http.ResponseWriter, request *http.R
server.mu.Unlock()
if !resultSet || resultErr != nil {
writeJSON(response, http.StatusServiceUnavailable, map[string]string{
- "error": "Piik App could not start",
+ "error": "Piik App could not start", "detail": errorDetail(resultErr),
})
server.markHandled()
return
@@ -265,6 +303,18 @@ func (server *Server) handleLaunch(response http.ResponseWriter, request *http.R
server.markHandled()
}
+func errorDetail(err error) string {
+ if err == nil {
+ return ""
+ }
+ // Redact before truncation so a long credential cannot lose its delimiter.
+ detail := []rune(diagnostics.SafeText(err.Error()))
+ if len(detail) > 2048 {
+ return string(detail[:2048]) + "..."
+ }
+ return string(detail)
+}
+
func (server *Server) markHandled() {
server.handledOnce.Do(func() { close(server.handled) })
}
@@ -305,6 +355,16 @@ func decodeSelection(reader io.Reader) (Selection, error) {
if selection.Language != "zh" && selection.Language != "en" && selection.Language != "vis" {
return Selection{}, errors.New("invalid launch language")
}
+ if selection.LANAddress != nil {
+ if selection.Mode != ModeLocal {
+ return Selection{}, errors.New("only Local launch can select a LAN invitation address")
+ }
+ value := strings.TrimSpace(*selection.LANAddress)
+ if ip := net.ParseIP(value); ip == nil || ip.To4() == nil {
+ return Selection{}, errors.New("Local invitation address must be an active IPv4 address")
+ }
+ selection.LANAddress = &value
+ }
switch selection.Mode {
case ModeLocal, ModeLink:
if strings.TrimSpace(selection.Site) != "" {
diff --git a/internal/app/launcher/server_test.go b/internal/app/launcher/server_test.go
index f39433a..819c51d 100644
--- a/internal/app/launcher/server_test.go
+++ b/internal/app/launcher/server_test.go
@@ -3,6 +3,7 @@ package launcher
import (
"bytes"
"encoding/json"
+ "errors"
"io/fs"
"net/http"
"runtime"
@@ -10,6 +11,8 @@ import (
"testing"
"testing/fstest"
"time"
+
+ "github.com/TNTcraftHIM/Piik/internal/app/lan"
)
// appAssets stands in for the embedded Vite build the App ships.
@@ -34,10 +37,11 @@ func TestLauncherServesStateAndCompletesOneSelection(t *testing.T) {
DefaultMode Mode `json:"defaultMode"`
Version string `json:"version"`
Revision string `json:"revision"`
+ Debug *bool `json:"debug"`
}
if response.StatusCode != http.StatusOK || json.NewDecoder(response.Body).Decode(&state) != nil ||
state.Site != "https://share.example" || state.DefaultMode != ModeSite ||
- state.Version != "development" || state.Revision != "" || state.LocalAccessPassword != "" {
+ state.Version != "development" || state.Revision != "" || state.LocalAccessPassword != "" || state.Debug == nil || *state.Debug {
t.Fatalf("launcher state = %d, %+v", response.StatusCode, state)
}
@@ -47,7 +51,7 @@ func TestLauncherServesStateAndCompletesOneSelection(t *testing.T) {
response, err := http.Post(
origin+"/api/client-launcher/launch",
"application/json",
- bytes.NewBufferString(`{"mode":"link","language":"vis"}`),
+ bytes.NewBufferString(`{"mode":"link","language":"vis","debug":true}`),
)
if err != nil {
requestErr <- err
@@ -58,7 +62,7 @@ func TestLauncherServesStateAndCompletesOneSelection(t *testing.T) {
select {
case selection := <-server.Selection():
- if selection != (Selection{Mode: ModeLink, Language: "vis"}) {
+ if selection != (Selection{Mode: ModeLink, Language: "vis", Debug: true}) {
t.Fatalf("selection = %+v", selection)
}
server.SetResult("http://localhost:8787/#piik-client=1", nil)
@@ -105,6 +109,76 @@ func TestLauncherIncludesTheInjectedBuildVersionAndRevision(t *testing.T) {
}
}
+func TestLauncherLANChoicesUseCurrentAddressesAndTheExplicitPreference(t *testing.T) {
+ for _, test := range []struct {
+ name, preferred, selected string
+ addresses []lan.Address
+ }{
+ {name: "one", selected: "192.168.1.4", addresses: []lan.Address{{Address: "192.168.1.4", Name: "Wi-Fi"}}},
+ {name: "one-private", selected: "192.168.1.4", addresses: []lan.Address{{Address: "192.168.1.4", Name: "Wi-Fi"}, {Address: "198.18.0.1", Name: "Tunnel"}}},
+ {name: "ambiguous", addresses: []lan.Address{{Address: "10.0.0.2", Name: "Ethernet"}, {Address: "192.168.1.4", Name: "Wi-Fi"}}},
+ {name: "cli-preferred", preferred: "192.168.1.4", selected: "192.168.1.4", addresses: []lan.Address{{Address: "10.0.0.2", Name: "Ethernet"}, {Address: "192.168.1.4", Name: "Wi-Fi"}}},
+ {name: "stale-preference", preferred: "192.168.1.4", addresses: []lan.Address{{Address: "10.0.0.2", Name: "Ethernet"}}},
+ {name: "unavailable"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ original := listLANAddresses
+ t.Cleanup(func() { listLANAddresses = original })
+ listLANAddresses = func() ([]lan.Address, error) {
+ if test.addresses == nil {
+ return nil, errors.New("interfaces are unavailable")
+ }
+ return test.addresses, nil
+ }
+ server, err := Start(t.Context(), appAssets(), Options{Site: "https://share.example", LANAddress: test.preferred})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = server.Close() })
+ response, err := http.Get(strings.TrimSuffix(server.URL(), "/client") + "/api/client-launcher")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer response.Body.Close()
+ var state struct {
+ DefaultMode Mode
+ LAN *struct {
+ Addresses []lan.Address
+ Selected string
+ }
+ }
+ if err := json.NewDecoder(response.Body).Decode(&state); err != nil || response.StatusCode != http.StatusOK ||
+ state.DefaultMode != ModeSite || state.LAN == nil || state.LAN.Addresses == nil ||
+ state.LAN.Selected != test.selected || len(state.LAN.Addresses) != len(test.addresses) {
+ t.Fatalf("LAN choices = %+v, status %d, %v", state, response.StatusCode, err)
+ }
+ for index, address := range state.LAN.Addresses {
+ if address != test.addresses[index] {
+ t.Fatalf("LAN interface label lost: %+v", address)
+ }
+ }
+ })
+ }
+}
+
+func TestLauncherPassesTheChosenLANAddressToTheApp(t *testing.T) {
+ server := startFixture(t, "")
+ server.SetResult("http://localhost:8787/#piik-client=1", nil)
+ response, err := http.Post(strings.TrimSuffix(server.URL(), "/client")+"/api/client-launcher/launch",
+ "application/json", strings.NewReader(`{"mode":"local","language":"en","lanAddress":"192.168.1.4"}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer response.Body.Close()
+ if response.StatusCode != http.StatusOK {
+ t.Fatalf("selection status = %d", response.StatusCode)
+ }
+ selection := <-server.Selection()
+ if selection.LANAddress == nil || *selection.LANAddress != "192.168.1.4" {
+ t.Fatalf("selected invitation address was lost: %+v", selection)
+ }
+}
+
func TestLauncherPreservesAnOptionalLocalPassword(t *testing.T) {
for _, password := range []string{"", "x", "中文", " ", " 中文 +& ", strings.Repeat("x", 256)} {
t.Run(password, func(t *testing.T) {
@@ -162,17 +236,17 @@ func TestLauncherPreservesAnOptionalLocalPassword(t *testing.T) {
}
func TestLauncherRejectsAnInvalidSavedSite(t *testing.T) {
- if _, err := Start(t.Context(), appAssets(), "https://example.test/path", "development", "", ""); err == nil {
+ if _, err := Start(t.Context(), appAssets(), Options{Site: "https://example.test/path", Version: "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, "", "development", "", ""); err == nil {
+ if _, err := Start(t.Context(), nil, Options{Version: "development"}); err == nil {
t.Fatal("launcher started without embedded assets")
}
- if _, err := Start(t.Context(), fstest.MapFS{}, "", "development", "", ""); err == nil {
+ if _, err := Start(t.Context(), fstest.MapFS{}, Options{Version: "development"}); err == nil {
t.Fatal("launcher started without an index document")
}
}
@@ -186,6 +260,13 @@ func TestLauncherRejectsInvalidSelections(t *testing.T) {
"site-password": `{"mode":"site","language":"en","site":"https://share.example","localAccessPassword":"valid-pass"}`,
"unknown-field": `{"mode":"local","language":"en","extra":true}`,
"password-type": `{"mode":"local","language":"en","localAccessPassword":123}`,
+ "debug-type": `{"mode":"local","language":"en","debug":"true"}`,
+ "lan-type": `{"mode":"local","language":"en","lanAddress":123}`,
+ "lan-invalid": `{"mode":"local","language":"en","lanAddress":"bad-address"}`,
+ "lan-ipv6": `{"mode":"local","language":"en","lanAddress":"::1"}`,
+ "link-lan": `{"mode":"link","language":"en","lanAddress":"192.168.1.4"}`,
+ "link-lan-empty": `{"mode":"link","language":"en","lanAddress":""}`,
+ "site-lan": `{"mode":"site","language":"en","site":"https://share.example","lanAddress":"192.168.1.4"}`,
"missing-language": `{"mode":"local"}`,
"unknown-language": `{"mode":"local","language":"other"}`,
} {
@@ -204,10 +285,47 @@ func TestLauncherRejectsInvalidSelections(t *testing.T) {
if response.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d", response.StatusCode)
}
+ var failure struct{ Error, Detail string }
+ if err := json.NewDecoder(response.Body).Decode(&failure); err != nil || failure.Detail == "" {
+ t.Fatalf("invalid selection lost its reason: %+v, %v", failure, err)
+ }
})
}
}
+func TestLauncherReportsSafeBoundedFailureDetails(t *testing.T) {
+ server, err := Start(t.Context(), appAssets(), Options{Version: "development", Debug: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = server.Close() })
+ origin := strings.TrimSuffix(server.URL(), "/client")
+ response, err := http.Get(origin + "/api/client-launcher")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var state struct{ Debug bool }
+ if err := json.NewDecoder(response.Body).Decode(&state); err != nil || !state.Debug {
+ t.Fatalf("CLI debug was not advertised: %+v, %v", state, err)
+ }
+ _ = response.Body.Close()
+ server.SetResult("", errors.New("public tunnel failed: token=private-secret "+strings.Repeat("network unavailable ", 200)))
+ response, err = http.Post(origin+"/api/client-launcher/launch", "application/json",
+ strings.NewReader(`{"mode":"link","language":"en"}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer response.Body.Close()
+ var failure struct{ Error, Detail string }
+ if err := json.NewDecoder(response.Body).Decode(&failure); err != nil || response.StatusCode != http.StatusServiceUnavailable {
+ t.Fatalf("failure response = %d, %v", response.StatusCode, err)
+ }
+ if failure.Error != "Piik App could not start" || !strings.Contains(failure.Detail, "public tunnel failed") ||
+ strings.Contains(failure.Detail, "private-secret") || len([]rune(failure.Detail)) > 2051 {
+ t.Fatalf("unsafe or missing failure detail: %+v", failure)
+ }
+}
+
func TestLauncherAcceptsLocalhostAlias(t *testing.T) {
server := startFixture(t, "")
origin := strings.TrimSuffix(server.URL(), "/client")
@@ -265,7 +383,7 @@ func startFixtureWithBuildAndPassword(
site, version, revision, password string,
) *Server {
t.Helper()
- server, err := Start(t.Context(), appAssets(), site, version, revision, password)
+ server, err := Start(t.Context(), appAssets(), Options{Site: site, Version: version, Revision: revision, LocalAccessPassword: password})
if err != nil {
t.Fatal(err)
}
diff --git a/internal/app/mediaedge/candidate_gathering.go b/internal/app/mediaedge/candidate_gathering.go
index 1dae6e3..30dd405 100644
--- a/internal/app/mediaedge/candidate_gathering.go
+++ b/internal/app/mediaedge/candidate_gathering.go
@@ -14,17 +14,18 @@ const (
)
type localCandidateGathering struct {
- engine *Engine
- servers []webrtc.ICEServer
- mappedPort int
- emit func(*webrtc.ICECandidateInit)
- ctx context.Context
- cancel context.CancelFunc
- once sync.Once
+ engine *Engine
+ servers []webrtc.ICEServer
+ mappedPort int
+ prepareMapping func() int
+ emit func(*webrtc.ICECandidateInit)
+ ctx context.Context
+ cancel context.CancelFunc
+ once sync.Once
mu sync.Mutex
pionDone bool
- surveyDone bool
+ supplementalDone bool
endSent bool
closed bool
mappedCandidates map[string]struct{}
@@ -33,14 +34,14 @@ type localCandidateGathering struct {
func newLocalCandidateGathering(
engine *Engine,
servers []webrtc.ICEServer,
- mappedPort int,
+ prepareMapping func() int,
emit func(*webrtc.ICECandidateInit),
) *localCandidateGathering {
ctx, cancel := context.WithCancel(engine.ctx)
surveyServers := stunServers(servers)
return &localCandidateGathering{
- engine: engine, servers: surveyServers, mappedPort: mappedPort, emit: emit,
- ctx: ctx, cancel: cancel, surveyDone: len(surveyServers) == 0,
+ engine: engine, servers: surveyServers, prepareMapping: prepareMapping, emit: emit,
+ ctx: ctx, cancel: cancel, supplementalDone: len(surveyServers) == 0,
mappedCandidates: make(map[string]struct{}),
}
}
@@ -98,11 +99,42 @@ func (gathering *localCandidateGathering) start() {
}
gathering.once.Do(func() {
go func() {
+ // Gateway discovery and STUN are additive: neither delays the SDP or
+ // ordinary candidates. Keep end-of-candidates behind both owners.
+ var mapping <-chan int
+ if gathering.prepareMapping != nil && len(gathering.servers) > 0 {
+ result := make(chan int, 1)
+ mapping = result
+ go func() { result <- gathering.prepareMapping() }()
+ }
+ survey := make(chan mappedAddress)
+ go func() {
+ defer close(survey)
+ gathering.engine.surveySTUN(gathering.ctx, gathering.servers, func(mapped mappedAddress) {
+ select {
+ case survey <- mapped:
+ case <-gathering.ctx.Done():
+ }
+ })
+ }()
index := 0
- gathering.engine.surveySTUN(
- gathering.ctx,
- gathering.servers,
- func(mapped mappedAddress) {
+ var observed []mappedAddress
+ for survey != nil || mapping != nil {
+ select {
+ case <-gathering.ctx.Done():
+ return
+ case port := <-mapping:
+ gathering.mappedPort = port
+ mapping = nil
+ for _, mapped := range observed {
+ gathering.emitMappedCandidate(mapped)
+ }
+ observed = nil
+ case mapped, ok := <-survey:
+ if !ok {
+ survey = nil
+ continue
+ }
index++
mid := "0"
line := uint16(0)
@@ -114,11 +146,15 @@ func (gathering *localCandidateGathering) start() {
strconv.Itoa(gathering.engine.localPort),
SDPMid: &mid, SDPMLineIndex: &line,
})
- gathering.emitMappedCandidate(mapped)
- },
- )
+ if mapping != nil {
+ observed = append(observed, mapped)
+ } else {
+ gathering.emitMappedCandidate(mapped)
+ }
+ }
+ }
gathering.mu.Lock()
- gathering.surveyDone = true
+ gathering.supplementalDone = true
sendEnd := gathering.finishLocked()
gathering.mu.Unlock()
if sendEnd {
@@ -150,7 +186,7 @@ func (gathering *localCandidateGathering) emitCandidate(candidate *webrtc.ICECan
func (gathering *localCandidateGathering) finishLocked() bool {
if gathering.closed || gathering.endSent || !gathering.pionDone ||
- !gathering.surveyDone || gathering.emit == nil {
+ !gathering.supplementalDone || gathering.emit == nil {
return false
}
gathering.endSent = true
diff --git a/internal/app/mediaedge/candidate_gathering_test.go b/internal/app/mediaedge/candidate_gathering_test.go
new file mode 100644
index 0000000..afc516b
--- /dev/null
+++ b/internal/app/mediaedge/candidate_gathering_test.go
@@ -0,0 +1,168 @@
+package mediaedge
+
+import (
+ "context"
+ "net"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/TNTcraftHIM/Piik/internal/diagnostics"
+ "github.com/pion/turn/v5"
+ "github.com/pion/webrtc/v4"
+)
+
+func localSurveyServer(t *testing.T) string {
+ t.Helper()
+ listener, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = listener.Close() })
+ server, err := turn.NewServer(turn.ServerConfig{
+ PacketConnConfigs: []turn.PacketConnConfig{{PacketConn: listener}},
+ LoggerFactory: diagnostics.PionLoggerFactory(),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = server.Close() })
+ return "stun:" + listener.LocalAddr().String()
+}
+
+func TestCandidatesTrickleBeforeGatewayMappingCompletes(t *testing.T) {
+ for _, closeFirst := range []bool{false, true} {
+ name := "mapping-finishes"
+ if closeFirst {
+ name = "connection-retires"
+ }
+ t.Run(name, func(t *testing.T) {
+ engine, err := NewEngine(EngineOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = engine.Close() })
+ mapping := make(chan int, 1)
+ t.Cleanup(func() { close(mapping) })
+ started := make(chan struct{})
+ candidates := make(chan *webrtc.ICECandidateInit, 8)
+ gathering := newLocalCandidateGathering(engine,
+ []webrtc.ICEServer{{URLs: []string{localSurveyServer(t)}}},
+ func() int { close(started); return <-mapping },
+ func(candidate *webrtc.ICECandidateInit) { candidates <- candidate },
+ )
+ t.Cleanup(gathering.close)
+ gathering.start()
+ waitSignal(t, started, "mapping start")
+ gathering.addPion(&webrtc.ICECandidate{
+ Foundation: "host", Component: 1, Protocol: webrtc.ICEProtocolUDP,
+ Address: "127.0.0.1", Port: uint16(engine.localPort), Typ: webrtc.ICECandidateTypeHost,
+ })
+ gathering.addPion(nil)
+ for received := 0; received < 2; received++ {
+ select {
+ case candidate := <-candidates:
+ if candidate == nil || (!strings.Contains(candidate.Candidate, "candidate:host ") &&
+ !strings.Contains(candidate.Candidate, "candidate:ns1 ")) {
+ t.Fatalf("expected ordinary candidate before mapping: %+v", candidate)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("ordinary ICE waited for the gateway mapping")
+ }
+ }
+ select {
+ case candidate := <-candidates:
+ t.Fatalf("gathering ended before its mapping owner finished: %+v", candidate)
+ default:
+ }
+ if closeFirst {
+ gathering.close()
+ }
+ mapping <- engine.localPort%65535 + 1
+ if closeFirst {
+ select {
+ case candidate := <-candidates:
+ t.Fatalf("retired generation emitted a late candidate: %+v", candidate)
+ case <-time.After(50 * time.Millisecond):
+ }
+ return
+ }
+ for _, want := range []string{"candidate:mp1 ", "end"} {
+ select {
+ case candidate := <-candidates:
+ if want == "end" && candidate != nil || want != "end" &&
+ (candidate == nil || !strings.Contains(candidate.Candidate, want)) {
+ t.Fatalf("expected %s after mapping: %+v", want, candidate)
+ }
+ case <-time.After(time.Second):
+ t.Fatalf("missing %s after mapping", want)
+ }
+ }
+ gathering.addPion(nil)
+ select {
+ case <-candidates:
+ t.Fatal("duplicate end-of-candidates")
+ default:
+ }
+ })
+ }
+}
+
+func TestSurveyHealthySTUNDoesNotWaitForSlowDNS(t *testing.T) {
+ engine, err := NewEngine(EngineOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = engine.Close() })
+ original := net.DefaultResolver
+ t.Cleanup(func() { net.DefaultResolver = original })
+ net.DefaultResolver = &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ <-ctx.Done()
+ return nil, ctx.Err()
+ }}
+ observations := make(chan mappedAddress, 1)
+ done := make(chan struct{})
+ servers := []webrtc.ICEServer{{URLs: []string{"stun:unanswered.invalid.:3478", localSurveyServer(t)}}}
+ started := time.Now()
+ go func() {
+ defer close(done)
+ engine.surveySTUN(t.Context(), servers, func(mapped mappedAddress) { observations <- mapped })
+ }()
+ select {
+ case mapped := <-observations:
+ if mapped.port != engine.localPort {
+ t.Fatal("STUN did not use the shared media socket")
+ }
+ case <-time.After(time.Second):
+ t.Fatal("healthy STUN waited for the broken DNS target")
+ }
+ select {
+ case <-done:
+ if elapsed := time.Since(started); elapsed > stunSurveyTimeout+time.Second {
+ t.Fatalf("DNS exceeded the survey bound: %v", elapsed)
+ }
+ case <-time.After(stunSurveyTimeout + time.Second):
+ t.Fatal("DNS resolution ignored the survey deadline")
+ }
+}
+
+func TestSurveyReturnsWhenEveryDestinationAlreadyFailed(t *testing.T) {
+ probe, err := net.ListenUDP("udp6", &net.UDPAddr{IP: net.IPv6loopback})
+ if err != nil {
+ t.Skipf("IPv6 loopback UDP is unavailable: %v", err)
+ }
+ _ = probe.Close()
+ // A concrete IPv6 loopback socket cannot send an IPv4 Binding request.
+ // This is an immediate transport error, not a missing STUN response.
+ engine, err := NewEngine(EngineOptions{BindAddress: "[::1]:0"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = engine.Close() })
+ started := time.Now()
+ engine.surveySTUN(t.Context(), []webrtc.ICEServer{{URLs: []string{"stun:127.0.0.1:9"}}},
+ func(mappedAddress) { t.Error("failed transport emitted a candidate") })
+ if elapsed := time.Since(started); elapsed > time.Second {
+ t.Fatalf("completed failure retained its survey timer: %v", elapsed)
+ }
+}
diff --git a/internal/app/mediaedge/edge.go b/internal/app/mediaedge/edge.go
index 86b5b27..68fad76 100644
--- a/internal/app/mediaedge/edge.go
+++ b/internal/app/mediaedge/edge.go
@@ -60,9 +60,9 @@ func (engine *Engine) NewEdge(source *Source, options EdgeOptions) (*Edge, error
if options.Audio != nil && options.Audio.engine != engine {
return nil, errors.New("native audio edge source belongs to another engine")
}
- mappedPort := 0
+ var prepareMapping func() int
if engine.portMapping != nil && !options.Local && len(options.ICEServers) > 0 {
- mappedPort = engine.portMapping.Prepare()
+ prepareMapping = engine.portMapping.Prepare
}
if err := source.reserve(options.Local); err != nil {
return nil, err
@@ -131,7 +131,7 @@ func (engine *Engine) NewEdge(source *Source, options EdgeOptions) (*Edge, error
edge.localCandidates = newLocalCandidateGathering(
engine,
options.ICEServers,
- mappedPort,
+ prepareMapping,
options.Events.LocalCandidate,
)
connection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
diff --git a/internal/app/mediaedge/edge_test.go b/internal/app/mediaedge/edge_test.go
index 26030b1..8dba41f 100644
--- a/internal/app/mediaedge/edge_test.go
+++ b/internal/app/mediaedge/edge_test.go
@@ -749,14 +749,14 @@ func connectEdgeToReceiver(
}
func newReceiver(t *testing.T) *webrtc.PeerConnection {
- return newReceiverWithAudio(t, false)
+ return newReceiverWithAudio(t, false, "127.0.0.1:0")
}
func newAudioReceiver(t *testing.T) *webrtc.PeerConnection {
- return newReceiverWithAudio(t, true)
+ return newReceiverWithAudio(t, true, "127.0.0.1:0")
}
-func newReceiverWithAudio(t *testing.T, includeAudio bool) *webrtc.PeerConnection {
+func newReceiverWithAudio(t *testing.T, includeAudio bool, bindAddress string) *webrtc.PeerConnection {
t.Helper()
mediaEngine := &webrtc.MediaEngine{}
for _, codec := range []string{"h264", "vp8"} {
@@ -781,8 +781,11 @@ func newReceiverWithAudio(t *testing.T, includeAudio bool) *webrtc.PeerConnectio
}
settings := webrtc.SettingEngine{}
settings.SetIncludeLoopbackCandidate(true)
- settings.SetNetworkTypes([]webrtc.NetworkType{webrtc.NetworkTypeUDP4})
- udp, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
+ address, err := net.ResolveUDPAddr("udp", bindAddress)
+ if err != nil {
+ t.Fatal(err)
+ }
+ udp, err := net.ListenUDP("udp", address)
if err != nil {
t.Fatal(err)
}
diff --git a/internal/app/mediaedge/engine.go b/internal/app/mediaedge/engine.go
index 72dfde2..5e4f2a0 100644
--- a/internal/app/mediaedge/engine.go
+++ b/internal/app/mediaedge/engine.go
@@ -77,13 +77,15 @@ type Engine struct {
func NewEngine(options EngineOptions) (*Engine, error) {
bindAddress := options.BindAddress
if bindAddress == "" {
- bindAddress = "0.0.0.0:0"
+ bindAddress = ":0"
}
- address, err := net.ResolveUDPAddr("udp4", bindAddress)
+ // Go keeps wildcard UDP on one dual-stack socket where supported.
+ // Concrete IPv4 bindings remain IPv4-only.
+ address, err := net.ResolveUDPAddr("udp", bindAddress)
if err != nil {
return nil, errors.New("native media UDP bind address is invalid")
}
- connection, err := net.ListenUDP("udp4", address)
+ connection, err := net.ListenUDP("udp", address)
if err != nil {
return nil, errors.New("native media UDP socket is unavailable")
}
@@ -178,62 +180,59 @@ func (engine *Engine) surveySTUN(
) {
surveyContext, cancel := context.WithTimeout(ctx, stunSurveyTimeout)
defer cancel()
- type surveyTarget struct {
- address *net.UDPAddr
- }
- targets := make([]surveyTarget, 0)
+ results := make(chan mappedAddress)
+ var pending sync.WaitGroup
for _, server := range servers {
for _, rawURL := range server.URLs {
uri, err := stun.ParseURI(rawURL)
if err != nil || uri.Scheme != stun.SchemeTypeSTUN ||
- uri.Proto != stun.ProtoTypeUDP {
+ uri.Proto != stun.ProtoTypeUDP || uri.Port < 1 || uri.Port > 65_535 {
continue
}
- addresses, err := net.DefaultResolver.LookupNetIP(ctx, "ip4", uri.Host)
- if err != nil || len(addresses) == 0 {
- slog.DebugContext(ctx, "nat-survey", "event", "resolve-failed", "host", uri.Host, diagnostics.Error(err))
- continue
- }
- serverAddress, err := net.ResolveUDPAddr(
- "udp4",
- net.JoinHostPort(addresses[0].String(), strconv.Itoa(uri.Port)),
- )
- if err == nil {
- targets = append(targets, surveyTarget{address: serverAddress})
- }
+ pending.Add(1)
+ go func() {
+ defer pending.Done()
+ // Resolve each destination independently inside the same bound as
+ // its Binding request; one broken resolver must not stall healthy STUN.
+ addresses, err := net.DefaultResolver.LookupNetIP(surveyContext, "ip4", uri.Host)
+ if err != nil || len(addresses) == 0 {
+ slog.DebugContext(surveyContext, "nat-survey", "event", "resolve-failed", "host", uri.Host, diagnostics.Error(err))
+ return
+ }
+ address := net.UDPAddrFromAddrPort(netip.AddrPortFrom(addresses[0], uint16(uri.Port)))
+ started := time.Now()
+ mapped, err := engine.mux.GetXORMappedAddrContext(
+ surveyContext,
+ address,
+ stunSurveyTimeout,
+ )
+ if err != nil || mapped == nil || mapped.IP.To4() == nil ||
+ mapped.Port < 1 || mapped.Port > 65_535 {
+ slog.DebugContext(surveyContext, "nat-survey", "event", "binding-failed", "serverPort", address.Port,
+ "durationMs", time.Since(started).Milliseconds(), diagnostics.Error(err))
+ return
+ }
+ slog.DebugContext(surveyContext, "nat-survey", "event", "binding", "serverPort", address.Port,
+ "localPort", engine.localPort, "mappedAddress", diagnostics.ID(mapped.IP.String()), "mappedPort", mapped.Port,
+ "durationMs", time.Since(started).Milliseconds())
+ select {
+ case results <- mappedAddress{address: mapped.IP.String(), port: mapped.Port}:
+ case <-surveyContext.Done():
+ }
+ }()
}
}
- if len(targets) == 0 {
- return
- }
- results := make(chan mappedAddress, len(targets))
- for _, target := range targets {
- go func(address *net.UDPAddr) {
- started := time.Now()
- mapped, err := engine.mux.GetXORMappedAddrContext(
- surveyContext,
- address,
- stunSurveyTimeout,
- )
- if err != nil || mapped == nil || mapped.IP.To4() == nil ||
- mapped.Port < 1 || mapped.Port > 65_535 {
- slog.DebugContext(surveyContext, "nat-survey", "event", "binding-failed", "serverPort", address.Port,
- "durationMs", time.Since(started).Milliseconds(), diagnostics.Error(err))
+ go func() {
+ pending.Wait()
+ close(results)
+ }()
+ seen := map[string]struct{}{}
+ for {
+ select {
+ case value, ok := <-results:
+ if !ok {
return
}
- slog.DebugContext(surveyContext, "nat-survey", "event", "binding", "serverPort", address.Port,
- "localPort", engine.localPort, "mappedAddress", diagnostics.ID(mapped.IP.String()), "mappedPort", mapped.Port,
- "durationMs", time.Since(started).Milliseconds())
- select {
- case results <- mappedAddress{address: mapped.IP.String(), port: mapped.Port}:
- case <-surveyContext.Done():
- }
- }(target.address)
- }
- seen := map[string]struct{}{}
- for remaining := len(targets); remaining > 0; remaining-- {
- select {
- case value := <-results:
key := value.address + ":" + strconv.Itoa(value.port)
if _, found := seen[key]; found {
continue
diff --git a/internal/app/mediaedge/engine_test.go b/internal/app/mediaedge/engine_test.go
new file mode 100644
index 0000000..86ef10b
--- /dev/null
+++ b/internal/app/mediaedge/engine_test.go
@@ -0,0 +1,154 @@
+package mediaedge
+
+import (
+ "net"
+ "testing"
+ "time"
+
+ "github.com/TNTcraftHIM/Piik/internal/diagnostics"
+ "github.com/livekit/livekit-server/pkg/sfu"
+ "github.com/pion/rtp"
+ "github.com/pion/turn/v5"
+ "github.com/pion/webrtc/v4"
+)
+
+func TestEngineMediaAddressFamilies(t *testing.T) {
+ for _, test := range []struct {
+ name, bind, receiver string
+ ipv6 bool
+ }{
+ {name: "default-to-IPv4", receiver: "127.0.0.1:0"},
+ {name: "explicit-IPv4", bind: "127.0.0.1:0", receiver: "127.0.0.1:0"},
+ {name: "explicit-IPv6", bind: "[::1]:0", receiver: "[::1]:0", ipv6: true},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ if test.ipv6 {
+ probe, err := net.ListenUDP("udp6", &net.UDPAddr{IP: net.IPv6loopback})
+ if err != nil {
+ t.Skipf("IPv6 loopback UDP is unavailable: %v", err)
+ }
+ _ = probe.Close()
+ }
+ engine, err := NewEngine(EngineOptions{BindAddress: test.bind, IncludeLoopback: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = engine.Close() })
+ source, err := engine.NewSource("vp8", 1, 1, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = source.Close() })
+ edge, err := engine.NewEdge(source, EdgeOptions{ConnectionID: test.name})
+ if err != nil {
+ t.Fatal(err)
+ }
+ receiver := newReceiverWithAudio(t, false, test.receiver)
+ t.Cleanup(func() { _ = receiver.Close() })
+ packets := make(chan *rtp.Packet, 1)
+ receiver.OnTrack(func(track *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
+ if packet, _, readErr := track.ReadRTP(); readErr == nil {
+ packets <- packet
+ }
+ })
+ connectEdgeToReceiver(t, edge, receiver)
+ pair, err := edge.sender.Transport().ICETransport().GetSelectedCandidatePair()
+ if err != nil || pair == nil {
+ t.Fatalf("selected ICE pair is unavailable: %v", err)
+ }
+ if (net.ParseIP(pair.Local.Address).To4() == nil) != test.ipv6 ||
+ (net.ParseIP(pair.Remote.Address).To4() == nil) != test.ipv6 ||
+ int(pair.Local.Port) != engine.localPort {
+ t.Fatal("ICE selected a different address family or media socket")
+ }
+ for _, stats := range edge.connection.GetStats() {
+ if candidate, ok := stats.(webrtc.ICECandidateStats); ok && candidate.Type == webrtc.StatsTypeLocalCandidate &&
+ int(candidate.Port) != engine.localPort {
+ t.Fatal("ICE candidate did not use the shared media port")
+ }
+ }
+ if err = writeSourceFrame(source, sfu.VP8KeyFrame8x8, time.Second, time.Second/30); err != nil {
+ t.Fatal(err)
+ }
+ if packet := waitPacket(t, packets); len(packet.Payload) == 0 || !packet.Marker {
+ t.Fatal("direct ICE/DTLS transport did not deliver the video frame")
+ }
+ if err = engine.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if edge.State() != webrtc.PeerConnectionStateClosed {
+ t.Fatal("engine shutdown retained its media edge")
+ }
+ address, err := net.ResolveUDPAddr("udp", engine.ListenAddress())
+ if err != nil {
+ t.Fatal(err)
+ }
+ reopened, err := net.ListenUDP("udp", address)
+ if err != nil {
+ t.Fatal("engine shutdown retained its UDP listener")
+ }
+ _ = reopened.Close()
+ })
+ }
+}
+
+func TestDefaultEngineSTUNUsesOnePortForBothFamilies(t *testing.T) {
+ engine, err := NewEngine(EngineOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = engine.Close() })
+ for _, test := range []struct {
+ network string
+ ip net.IP
+ }{
+ {network: "udp4", ip: net.IPv4(127, 0, 0, 1)},
+ {network: "udp6", ip: net.IPv6loopback},
+ } {
+ t.Run(test.network, func(t *testing.T) {
+ bound, err := net.ResolveUDPAddr("udp", engine.ListenAddress())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if test.network == "udp6" && bound.IP.To4() != nil {
+ t.Skip("Go selected the supported IPv4-only wildcard socket")
+ }
+ listener, err := net.ListenUDP(test.network, &net.UDPAddr{IP: test.ip})
+ if err != nil {
+ if test.network == "udp6" {
+ t.Skipf("IPv6 loopback UDP is unavailable: %v", err)
+ }
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = listener.Close() })
+ server, err := turn.NewServer(turn.ServerConfig{
+ PacketConnConfigs: []turn.PacketConnConfig{{PacketConn: listener}},
+ LoggerFactory: diagnostics.PionLoggerFactory(),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = server.Close() })
+ mapped, err := engine.mux.GetXORMappedAddrContext(t.Context(), listener.LocalAddr(), time.Second)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if mapped.Port != engine.localPort || !mapped.IP.Equal(test.ip) {
+ t.Fatal("STUN observed a different socket or address family")
+ }
+ if test.network == "udp4" {
+ observations := 0
+ engine.surveySTUN(t.Context(), []webrtc.ICEServer{{URLs: []string{"stun:" + listener.LocalAddr().String()}}},
+ func(mapped mappedAddress) {
+ observations++
+ if mapped.port != engine.localPort || net.ParseIP(mapped.address).To4() == nil {
+ t.Error("Native survey did not retain its IPv4 media socket")
+ }
+ })
+ if observations != 1 {
+ t.Fatal("Native IPv4 survey did not emit its observation")
+ }
+ }
+ })
+ }
+}
diff --git a/internal/app/mediaedge/publication.go b/internal/app/mediaedge/publication.go
index bb34eed..14d19d4 100644
--- a/internal/app/mediaedge/publication.go
+++ b/internal/app/mediaedge/publication.go
@@ -73,7 +73,7 @@ func (engine *Engine) NewPublication(source *Source, options EdgeOptions) (*Publ
publication.transport = transport
transport.SetAudioBitrate(options.Audio.configuredBitrate())
publication.signaling.connection = transport.PC
- publication.signaling.localCandidates = newLocalCandidateGathering(engine, options.ICEServers, 0, options.Events.LocalCandidate)
+ publication.signaling.localCandidates = newLocalCandidateGathering(engine, options.ICEServers, nil, options.Events.LocalCandidate)
transport.PC.OnICECandidate(publication.signaling.localCandidates.addPion)
transport.PC.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
if state == webrtc.PeerConnectionStateConnected {
diff --git a/internal/app/mediaedge/receiver.go b/internal/app/mediaedge/receiver.go
index b456533..671b522 100644
--- a/internal/app/mediaedge/receiver.go
+++ b/internal/app/mediaedge/receiver.go
@@ -48,9 +48,9 @@ func (engine *Engine) NewReceiver(options ReceiverOptions) (*Receiver, webrtc.Se
if err != nil {
return nil, webrtc.SessionDescription{}, err
}
- mappedPort := 0
+ var prepareMapping func() int
if engine.portMapping != nil && len(options.ICEServers) > 0 {
- mappedPort = engine.portMapping.Prepare()
+ prepareMapping = engine.portMapping.Prepare
}
connection, err := engine.api.NewPeerConnection(webrtc.Configuration{})
if err != nil {
@@ -92,7 +92,7 @@ func (engine *Engine) NewReceiver(options ReceiverOptions) (*Receiver, webrtc.Se
receiver.localCandidates = newLocalCandidateGathering(
engine,
options.ICEServers,
- mappedPort,
+ prepareMapping,
options.Events.LocalCandidate,
)
connection.OnICECandidate(receiver.localCandidates.addPion)
diff --git a/internal/app/mediaedge/receiver_test.go b/internal/app/mediaedge/receiver_test.go
index 6c96135..005b253 100644
--- a/internal/app/mediaedge/receiver_test.go
+++ b/internal/app/mediaedge/receiver_test.go
@@ -281,7 +281,7 @@ func connectedReceiverForSources(
if err != nil {
return nil, nil, nil, err
}
- receiver := newReceiverWithAudio(t, audio != nil)
+ receiver := newReceiverWithAudio(t, audio != nil, "127.0.0.1:0")
t.Cleanup(func() { _ = receiver.Close() })
packets := make(chan *rtp.Packet, 4)
audioPackets := make(chan *rtp.Packet, 4)
diff --git a/internal/app/portmapping/mapping.go b/internal/app/portmapping/mapping.go
index 882fc65..def6c50 100644
--- a/internal/app/portmapping/mapping.go
+++ b/internal/app/portmapping/mapping.go
@@ -42,6 +42,7 @@ type Mapping struct {
mapped bool
externalPort int
deleteRequired bool
+ attemptDone chan struct{}
renewAfter time.Time
closed bool
}
@@ -88,6 +89,7 @@ func (mapping *Mapping) Close() {
mapping.closed = true
gateway := mapping.gateway
deleteRequired := mapping.deleteRequired
+ attemptDone := mapping.attemptDone
mapping.mapped = false
mapping.externalPort = 0
mapping.deleteRequired = false
@@ -95,6 +97,16 @@ func (mapping *Mapping) Close() {
if gateway == nil || !deleteRequired {
return
}
+ // A still-abandoned attempt may be retrying inside the dependency
+ // (NAT-PMP keeps unsynchronized state); give it a brief window to return
+ // rather than racing its bookkeeping with the Delete.
+ if attemptDone != nil {
+ select {
+ case <-attemptDone:
+ case <-time.After(deleteTimeout):
+ return
+ }
+ }
ctx, cancel := context.WithTimeout(context.Background(), deleteTimeout)
defer cancel()
err := gateway.DeletePortMapping(ctx, "udp", mapping.localPort)
@@ -122,9 +134,38 @@ func (mapping *Mapping) discover(parent context.Context) {
func (mapping *Mapping) mapPortLocked(ctx context.Context) {
mapping.attempted = true
- externalPort, err := mapping.gateway.AddPortMapping(
- ctx, "udp", mapping.localPort, "Piik", leaseDuration,
- )
+ // NAT-PMP ignores cancellation and retries each proposed port for ~128 s.
+ // Bound our wait; the dependency may continue until its own retry limit.
+ // The buffered result lets that worker finish after abandonment. Failed
+ // attempts are not retried by Prepare, and Close waits for attemptDone
+ // before touching the dependency's unsynchronized port bookkeeping.
+ type mappingResult struct {
+ externalPort int
+ err error
+ }
+ result := make(chan mappingResult, 1)
+ done := make(chan struct{})
+ mapping.attemptDone = done
+ go func() {
+ defer close(done)
+ externalPort, err := mapping.gateway.AddPortMapping(
+ ctx, "udp", mapping.localPort, "Piik", leaseDuration,
+ )
+ result <- mappingResult{externalPort, err}
+ }()
+ var externalPort int
+ var err error
+ select {
+ case outcome := <-result:
+ externalPort, err = outcome.externalPort, outcome.err
+ case <-ctx.Done():
+ select {
+ case outcome := <-result:
+ externalPort, err = outcome.externalPort, outcome.err
+ default:
+ externalPort, err = 0, ctx.Err()
+ }
+ }
slog.Debug("nat-mapping", "event", "mapping-result", "localPort", mapping.localPort,
"externalPort", externalPort, "leaseSeconds", leaseDuration.Seconds(), diagnostics.Error(err))
if err != nil || externalPort < 1 || externalPort > 65535 {
diff --git a/internal/app/portmapping/mapping_test.go b/internal/app/portmapping/mapping_test.go
index a12e1b2..d3396bb 100644
--- a/internal/app/portmapping/mapping_test.go
+++ b/internal/app/portmapping/mapping_test.go
@@ -13,6 +13,7 @@ type fakeGateway struct {
added []int
deleted []int
addErr error
+ block chan struct{}
}
func (gateway *fakeGateway) AddPortMapping(
@@ -23,10 +24,16 @@ func (gateway *fakeGateway) AddPortMapping(
_ time.Duration,
) (int, error) {
gateway.mu.Lock()
- defer gateway.mu.Unlock()
gateway.added = append(gateway.added, port)
- if gateway.addErr != nil {
- return 0, gateway.addErr
+ block := gateway.block
+ addErr := gateway.addErr
+ gateway.mu.Unlock()
+ if block != nil {
+ // Like the pinned NAT-PMP client: never observes the context.
+ <-block
+ }
+ if addErr != nil {
+ return 0, addErr
}
return port + 1, nil
}
@@ -101,6 +108,63 @@ func TestMappingFailureIsNotRetriedForEveryEdge(t *testing.T) {
}
}
+func TestMappingAttemptStaysBoundedWhenTheClientIgnoresContext(t *testing.T) {
+ original := discoverGateway
+ t.Cleanup(func() { discoverGateway = original })
+ fake := &fakeGateway{block: make(chan struct{})}
+ discoverGateway = func(context.Context) (gateway, error) {
+ return fake, nil
+ }
+ t.Cleanup(func() { close(fake.block) })
+
+ mapping := Start(43210)
+ started := time.Now()
+ if port := mapping.Prepare(); port != 0 {
+ t.Fatalf("mapped port = %d, want 0 for an unanswered attempt", port)
+ }
+ if elapsed := time.Since(started); elapsed > attemptTimeout+2*time.Second {
+ t.Fatalf("Prepare blocked %v, want within the attempt bound", elapsed)
+ }
+ mapping.Close()
+}
+
+func TestCloseSkipsDeleteWhileAnAttemptIsStillAbandoned(t *testing.T) {
+ original := discoverGateway
+ t.Cleanup(func() { discoverGateway = original })
+ fake := &fakeGateway{}
+ discoverGateway = func(context.Context) (gateway, error) {
+ return fake, nil
+ }
+
+ mapping := Start(43210)
+ if port := mapping.Prepare(); port != 43211 {
+ t.Fatalf("mapped port = %d, want 43211", port)
+ }
+ mapping.mu.Lock()
+ mapping.renewAfter = time.Now().Add(-time.Second)
+ mapping.mu.Unlock()
+ fake.mu.Lock()
+ fake.block = make(chan struct{})
+ fake.mu.Unlock()
+ t.Cleanup(func() { close(fake.block) })
+
+ // The renewal attempt is abandoned after attemptTimeout while the fake
+ // keeps ignoring the context. Close must stay bounded and must not race
+ // the dependency's still-running call with a Delete.
+ mapping.Prepare()
+ started := time.Now()
+ mapping.Close()
+ if elapsed := time.Since(started); elapsed > 2*deleteTimeout+time.Second {
+ t.Fatalf("Close blocked %v, want a brief bounded wait", elapsed)
+ }
+ fake.mu.Lock()
+ defer fake.mu.Unlock()
+ if len(fake.added) != 2 || len(fake.deleted) != 0 {
+ t.Fatalf("mapping calls = add %v delete %v, want the Delete skipped",
+ fake.added, fake.deleted)
+ }
+}
+
func TestRenewalFailureKeepsTheOriginalMappingOwnedUntilClose(t *testing.T) {
original := discoverGateway
t.Cleanup(func() { discoverGateway = original })
diff --git a/internal/app/publictunnel/process.go b/internal/app/publictunnel/process.go
index dceee05..ad1fe27 100644
--- a/internal/app/publictunnel/process.go
+++ b/internal/app/publictunnel/process.go
@@ -5,7 +5,9 @@ import (
"context"
"encoding/json"
"errors"
+ "fmt"
"io"
+ "log/slog"
"net"
"net/url"
"os"
@@ -17,6 +19,8 @@ import (
"strings"
"sync"
"time"
+
+ "github.com/TNTcraftHIM/Piik/internal/diagnostics"
)
const (
@@ -85,30 +89,44 @@ func Start(
if err != nil {
cancel()
_ = os.Remove(configPath)
- return nil, errors.New("public tunnel output is unavailable")
+ return nil, fmt.Errorf("public tunnel output is unavailable: %w", err)
}
stderr, err := child.StderrPipe()
if err != nil {
cancel()
_ = os.Remove(configPath)
- return nil, errors.New("public tunnel output is unavailable")
+ return nil, fmt.Errorf("public tunnel output is unavailable: %w", err)
}
child.WaitDelay = shutdownTimeout
if err = child.Start(); err != nil {
cancel()
_ = os.Remove(configPath)
- return nil, errors.New("public tunnel could not start")
+ return nil, fmt.Errorf("public tunnel could not start: %w", err)
}
process := &Process{cancel: cancel, done: make(chan struct{})}
discovered := make(chan string, 1)
connected := make(chan struct{}, 1)
var discoverOnce sync.Once
var connectOnce sync.Once
+ var outputDone sync.WaitGroup
+ outputDone.Add(2)
readOutput := func(reader io.Reader) {
+ defer outputDone.Done()
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 4096), maxLogLineBytes)
for scanner.Scan() {
line := scanner.Text()
+ var entry struct {
+ Level string `json:"level"`
+ Message string `json:"message"`
+ Error string `json:"error"`
+ }
+ if json.Unmarshal([]byte(line), &entry) != nil {
+ slog.Debug("public-tunnel", "event", "dependency-output", "message", diagnostics.SafeText(line))
+ } else if entry.Level == "error" || entry.Level == "warn" || entry.Level == "fatal" {
+ slog.Debug("public-tunnel", "event", "dependency-output", "level", entry.Level,
+ "message", diagnostics.SafeText(entry.Message), "cause", diagnostics.SafeText(entry.Error))
+ }
if origin, ok := originFromLogLine(line); ok {
discoverOnce.Do(func() { discovered <- origin })
}
@@ -116,11 +134,17 @@ func Start(
connectOnce.Do(func() { connected <- struct{}{} })
}
}
+ if err := scanner.Err(); err != nil {
+ slog.Debug("public-tunnel", "event", "output-read-failed", diagnostics.Error(err))
+ }
}
go readOutput(stdout)
go readOutput(stderr)
go func() {
waitErr := child.Wait()
+ // Wait closes the pipe readers; join them before App closes its recorder.
+ // Do not wait before Wait: that would bypass its bounded pipe shutdown.
+ outputDone.Wait()
_ = os.Remove(configPath)
if ctx.Err() != nil {
waitErr = nil
@@ -141,10 +165,10 @@ func Start(
registered = true
case <-process.done:
cancel()
- return nil, errors.New("public tunnel exited before it became available")
+ return nil, errors.Join(errors.New("public invitation service exited before connecting; reopen Piik to try again"), process.Err())
case <-timer.C:
_ = process.Close()
- return nil, errors.New("public tunnel startup timed out")
+ return nil, errors.New("public invitation service did not connect within 30 seconds; check your Internet connection and reopen Piik to try again")
case <-parent.Done():
_ = process.Close()
return nil, parent.Err()
@@ -156,17 +180,17 @@ func Start(
func writeTemporaryConfig() (string, error) {
file, err := os.CreateTemp("", "piik-cloudflared-*.yml")
if err != nil {
- return "", errors.New("public tunnel configuration is unavailable")
+ return "", fmt.Errorf("public tunnel temporary file could not be created: %w", err)
}
path := file.Name()
if _, err = file.WriteString("metrics: 127.0.0.1:0\n"); err != nil {
_ = file.Close()
_ = os.Remove(path)
- return "", errors.New("public tunnel configuration is unavailable")
+ return "", fmt.Errorf("public tunnel temporary file could not be written: %w", err)
}
if err = file.Close(); err != nil {
_ = os.Remove(path)
- return "", errors.New("public tunnel configuration is unavailable")
+ return "", fmt.Errorf("public tunnel temporary file could not be saved: %w", err)
}
return path, nil
}
@@ -227,8 +251,11 @@ func logMessage(line string) string {
func validateExecutable(path string) error {
path = strings.TrimSpace(path)
metadata, err := os.Stat(path)
- if err != nil || !metadata.Mode().IsRegular() {
- return errors.New("packaged public tunnel is unavailable")
+ if err != nil {
+ return fmt.Errorf("packaged public tunnel is unavailable; extract the complete Piik archive again: %w", err)
+ }
+ if !metadata.Mode().IsRegular() {
+ return errors.New("packaged public tunnel is not a file; extract the complete Piik archive again")
}
return nil
}
diff --git a/internal/app/publictunnel/process_test.go b/internal/app/publictunnel/process_test.go
index 5f1113e..7401c95 100644
--- a/internal/app/publictunnel/process_test.go
+++ b/internal/app/publictunnel/process_test.go
@@ -1,10 +1,21 @@
package publictunnel
import (
+ "errors"
"os"
+ "path/filepath"
+ "strings"
"testing"
)
+func TestMissingTunnelExplainsHowToRestoreThePackage(t *testing.T) {
+ _, err := Start(t.Context(), filepath.Join(t.TempDir(), "missing-tunnel"), "http://127.0.0.1:8787")
+ var cause *os.PathError
+ if !errors.As(err, &cause) || !strings.Contains(err.Error(), "extract the complete Piik archive") {
+ t.Fatalf("missing tunnel lost its cause or recovery instruction: %v", err)
+ }
+}
+
func TestOriginFromCloudflaredLog(t *testing.T) {
for _, line := range []string{
`{"level":"info","message":"| https://small-bright-room.trycloudflare.com |"}`,
diff --git a/internal/server/config/local.go b/internal/server/config/local.go
index 6207dfc..9120adb 100644
--- a/internal/server/config/local.go
+++ b/internal/server/config/local.go
@@ -33,14 +33,23 @@ func Local(options LocalOptions) (Config, error) {
if port < 1 || port > maxPort {
return Config{}, fmt.Errorf("Local server port must be an integer between 1 and %d", maxPort)
}
- publicAddress, err := localIPv4(options.PublicAddress, "public address")
- if err != nil {
- return Config{}, err
- }
- if publicAddress == "0.0.0.0" || strings.HasPrefix(publicAddress, "127.") {
+ publicAddress := ""
+ if options.PublicAddress != "" {
+ parsed, err := localIPv4(options.PublicAddress, "public address")
+ if err != nil {
+ return Config{}, err
+ }
+ if parsed == "0.0.0.0" || strings.HasPrefix(parsed, "127.") {
+ return Config{}, errors.New("Local server public address must be reachable from the LAN")
+ }
+ publicAddress = parsed
+ } else if options.PublicOrigin == "" {
return Config{}, errors.New("Local server public address must be reachable from the LAN")
}
- allowedAddresses := []string{publicAddress}
+ allowedAddresses := make([]string, 0, 1+len(options.AllowedAddresses))
+ if publicAddress != "" {
+ allowedAddresses = append(allowedAddresses, publicAddress)
+ }
for _, address := range options.AllowedAddresses {
normalized, err := localIPv4(address, "allowed address")
if err != nil {
@@ -65,12 +74,15 @@ func Local(options LocalOptions) (Config, error) {
// 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: "/"}
+ var publicBaseURL *url.URL
if options.PublicOrigin != "" {
- publicBaseURL, err = publicHTTPSOrigin(options.PublicOrigin)
+ parsed, err := publicHTTPSOrigin(options.PublicOrigin)
if err != nil {
return Config{}, err
}
+ publicBaseURL = parsed
+ } else {
+ publicBaseURL = &url.URL{Scheme: "http", Host: fmt.Sprintf("%s:%d", publicAddress, port), Path: "/"}
}
allowedOrigins := map[string]struct{}{
diff --git a/internal/server/config/local_test.go b/internal/server/config/local_test.go
index ac8f530..aff65c5 100644
--- a/internal/server/config/local_test.go
+++ b/internal/server/config/local_test.go
@@ -117,6 +117,29 @@ func TestLocalTunnelledClient(t *testing.T) {
}
}
+func TestLocalTunnelledClientWithoutLANAddress(t *testing.T) {
+ config := mustLocal(t, LocalOptions{
+ Port: 9235,
+ PublicOrigin: "https://quiet-fancy-room.trycloudflare.com",
+ AllowedAddresses: []string{"172.18.0.1", "192.168.1.10"},
+ SiteAccessPassword: "persistent-local-password",
+ })
+
+ if got := Origin(config.PublicBaseURL); got != "https://quiet-fancy-room.trycloudflare.com" {
+ t.Errorf("public origin = %q", got)
+ }
+ want := map[string]struct{}{
+ "http://localhost:9235": {},
+ "http://127.0.0.1:9235": {},
+ "http://172.18.0.1:9235": {},
+ "http://192.168.1.10:9235": {},
+ "https://quiet-fancy-room.trycloudflare.com": {},
+ }
+ if !reflect.DeepEqual(config.AllowedOrigins, want) {
+ t.Errorf("AllowedOrigins = %v, want %v", config.AllowedOrigins, want)
+ }
+}
+
func TestLocalDefaults(t *testing.T) {
config := mustLocal(t, LocalOptions{PublicAddress: "192.168.1.10"})
@@ -154,6 +177,9 @@ func TestLocalRejects(t *testing.T) {
{"hostname public address", LocalOptions{PublicAddress: "localhost",
SiteAccessPassword: "valid-password"},
"Local server public address must be an IPv4 address"},
+ {"missing public address without an origin", LocalOptions{
+ SiteAccessPassword: "valid-password"},
+ "Local server public address must be reachable from the LAN"},
{"IPv6 public address", LocalOptions{PublicAddress: "::ffff:192.168.1.10"},
"Local server public address must be an IPv4 address"},
{"loopback public address", LocalOptions{PublicAddress: "127.0.0.1",
diff --git a/internal/server/signal/router_sfu_signal.go b/internal/server/signal/router_sfu_signal.go
index 7eb1d59..ceff44b 100644
--- a/internal/server/signal/router_sfu_signal.go
+++ b/internal/server/signal/router_sfu_signal.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"time"
+ "github.com/TNTcraftHIM/Piik/internal/diagnostics"
"github.com/TNTcraftHIM/Piik/internal/media/forwarding"
"github.com/TNTcraftHIM/Piik/internal/server/protocol"
"github.com/TNTcraftHIM/Piik/internal/server/route"
@@ -117,6 +118,9 @@ func (r *router) handleSfuSignal(participant authenticatedRouteParticipant, mess
return
}
if err != nil {
+ r.debug(participant.roomID, "sfu-signal-failed",
+ "participant", r.debugPeer(participant.roomID, participant.peerID),
+ "role", participant.role, "kind", message.Kind, diagnostics.Error(err))
r.failSfuConnection(participant, message)
return
}
@@ -162,6 +166,8 @@ func (r *router) prepareSfuSubscriber(roomID string, operation *route.OperationS
return
}
if err != nil {
+ r.debug(roomID, "sfu-subscriber-prepare-failed",
+ "participant", r.debugPeer(roomID, participant.peerID), diagnostics.Error(err))
r.failSfuConnection(participant, message)
return
}
diff --git a/internal/server/signal/router_sfu_signal_test.go b/internal/server/signal/router_sfu_signal_test.go
index 1f12f82..cfa4f61 100644
--- a/internal/server/signal/router_sfu_signal_test.go
+++ b/internal/server/signal/router_sfu_signal_test.go
@@ -236,6 +236,7 @@ func TestEmbeddedSfuAuthenticatedWebSocketCarriesActualRTP(t *testing.T) {
}
func TestEmbeddedSfuRejectsBadPublisherOfferAtPendingRevision(t *testing.T) {
+ diagnostics := captureRouteDebug(t)
h := newRouterHarness(t, routerHarnessOptions{capacity: 1, withSfu: true})
service := sfu.NewMedia(sfu.MediaOptions{})
defer service.Close()
@@ -262,6 +263,11 @@ func TestEmbeddedSfuRejectsBadPublisherOfferAtPendingRevision(t *testing.T) {
})
})
h.waitUsage(sfu.Usage{})
+ if !diagnostics.calledWith(created.RoomID, "sfu-signal-failed", map[string]any{
+ "role": "host", "kind": "description",
+ }) {
+ t.Fatal("failed publisher preparation did not retain diagnostic context")
+ }
if _, ok := h.activeAfter(viewer.sessionID, int64(prepared.Revision)); !ok {
t.Fatal("failed pending media did not broadcast a newer rollback revision")
}
diff --git a/internal/server/signal/router_test.go b/internal/server/signal/router_test.go
index af7ee2e..54f99f9 100644
--- a/internal/server/signal/router_test.go
+++ b/internal/server/signal/router_test.go
@@ -1438,6 +1438,58 @@ func TestRouterWakesAtDerivedDirectBoundaryAndPreparesSfuAutomatically(t *testin
}
}
+func TestRouterRelayPreparationFailureAdvancesWithoutWaitingForDeadline(t *testing.T) {
+ h := newRouterHarness(t, routerHarnessOptions{capacity: 2, withSfu: true, fakeTimers: true})
+ created := h.createRoom()
+ host := h.connectHost(created, nil, "", "")
+ relay := h.connectViewer(created, "relay")
+ h.locked(func() {
+ h.doComplete(host)
+ h.doComplete(relay)
+ })
+ h.settle()
+ root, ok := h.preparedFor(relay.sessionID)
+ if !ok {
+ t.Fatal("relay must prepare its Host upstream")
+ }
+ h.locked(func() {
+ h.doReady(relay, int64(root.Revision))
+ h.doRelay(relay, 2)
+ })
+ child := h.connectViewer(created, "child")
+ h.complete(child)
+ h.settle()
+ direct, ok := h.preparedFor(child.sessionID)
+ if !ok || direct.Assignment.Upstream != protocol.PeerUpstream(host.peerID) {
+ t.Fatal("child must try the Host first")
+ }
+ h.routeFailed(host, routeFailedMessage(int64(direct.Revision), "prepare", direct.Candidate.ConnectionID))
+ h.settle()
+ prepared, ok := h.preparedFor(child.sessionID)
+ if !ok || prepared.Assignment.Upstream != protocol.PeerUpstream(relay.peerID) {
+ t.Fatal("child must next try the relay")
+ }
+ failure := routeFailedMessage(int64(prepared.Revision), "prepare", prepared.Candidate.ConnectionID)
+ h.routeFailed(relay, failure)
+ h.settle()
+ fallback, ok := h.preparedFor(child.sessionID)
+ if !ok || fallback.Candidate.Transport != "sfu" || fallback.Revision <= prepared.Revision {
+ t.Fatalf("relay rejection must prepare SFU immediately: %+v", fallback)
+ }
+ h.routeFailed(relay, failure)
+ h.settle()
+ current, _ := h.preparedFor(child.sessionID)
+ if current.Candidate.ConnectionID != fallback.Candidate.ConnectionID {
+ t.Fatal("duplicate relay failure disturbed the next candidate")
+ }
+ if h.clock.now() != 0 {
+ t.Fatal("preparation failure must advance without a timer wake")
+ }
+ if retained := h.mustActiveEdge(created.RoomID, relay.peerID); retained.connectionID != root.Candidate.ConnectionID {
+ t.Fatal("relay preparation failure disturbed its active upstream")
+ }
+}
+
func TestRouterKeepsExactTransportConnectedDirectCandidatePastBoundary(t *testing.T) {
h := newRouterHarness(t, routerHarnessOptions{capacity: 2, withSfu: true, prepareTimeoutMs: 300, fakeTimers: true})
created := h.createRoom()
diff --git a/scripts/build-website.mjs b/scripts/build-website.mjs
index 5e54803..3cc0fa9 100644
--- a/scripts/build-website.mjs
+++ b/scripts/build-website.mjs
@@ -19,7 +19,7 @@ await cp(source, output, {
!path.endsWith("README.md"),
});
await cp(new URL("../LICENSE", import.meta.url), new URL("LICENSE", output));
-const [{ WELCOME_LINES }, { catalogs }, { Glyph }] = await Promise.all([
+const [{ WELCOME_LINES }, { locales }, { Glyph }] = await Promise.all([
"../src/client/components/living/WelcomeLine.tsx",
"../src/client/locales/index.ts",
"../src/client/ui/icons.tsx",
@@ -30,8 +30,8 @@ const [{ WELCOME_LINES }, { catalogs }, { Glyph }] = await Promise.all([
// Reuse the product's paired lines as inert HTML, without a runtime catalog.
const welcomeLines = WELCOME_LINES.map(({ key }) => renderToStaticMarkup(
createElement("span", { "data-welcome-key": key },
- createElement("span", { lang: "en" }, `“${catalogs.en[key]}”`),
- createElement("span", { lang: "zh-CN" }, `“${catalogs.zh[key]}”`),
+ createElement("span", { lang: "en" }, `“${locales.en.copy[key]}”`),
+ createElement("span", { lang: "zh-CN" }, `“${locales.zh.copy[key]}”`),
),
));
let homepage = (await readFile(new URL("index.html", source), "utf8"))
@@ -45,8 +45,8 @@ if (process.env.PIIK_WEBSITE_RELEASE_DATA) {
const href = provider === 'github' ? item?.url : item?.mirrorURL;
if (!href) return original;
if (provider === 'gitee') content = content
- .replace(/[\s\S]*?<\/span\s*>/, `Gitee alternative · ${release.version} ZIP `)
- .replace(/[\s\S]*?<\/span\s*>/, `Gitee 备用 · ${release.version} ZIP `);
+ .replace(/[\s\S]*?<\/span\s*>/, 'Gitee alternative · ZIP ')
+ .replace(/[\s\S]*?<\/span\s*>/, 'Gitee 备用 · ZIP ');
return `${content} `;
});
}
diff --git a/scripts/client-link-gate.ts b/scripts/client-link-gate.ts
index 996f279..616d7a9 100644
--- a/scripts/client-link-gate.ts
+++ b/scripts/client-link-gate.ts
@@ -1,6 +1,6 @@
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
-import { networkInterfaces, tmpdir } from "node:os";
+import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { reservePort, withDeadline } from "./browser-gate-harness";
@@ -110,18 +110,6 @@ function transportArgs(key: string): string[] {
];
}
-function localLANAddress(): string {
- const addresses = Object.values(networkInterfaces())
- .flatMap((entries) => entries ?? [])
- .filter((entry) => entry.family === "IPv4" && !entry.internal)
- .map((entry) => entry.address);
- const address = addresses.find((value) =>
- /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(value),
- );
- if (!address) throw new Error("A private LAN IPv4 address is required");
- return address;
-}
-
async function waitForRemotePage(
ssh: string,
transport: string[],
@@ -244,7 +232,6 @@ async function main(): Promise {
"--link",
"--config", join(profile, "client.json"),
"--port", String(port),
- "--lan-address", localLANAddress(),
"--tunnel-process", tunnel,
], {
cwd: ROOT,
diff --git a/scripts/publish-release.mjs b/scripts/publish-release.mjs
index 69a30eb..83f5fc2 100644
--- a/scripts/publish-release.mjs
+++ b/scripts/publish-release.mjs
@@ -36,7 +36,7 @@ if (option === "--dry-run") {
}
verifyTag();
const releases = gh("api", `repos/${repository}/releases?per_page=100`, "--paginate", "--jq",
- ".[] | {tag_name,target_commitish,draft,prerelease,assets:[.assets[] | {name,state}]} | @json")
+ ".[] | {tag_name,target_commitish,draft,prerelease,body,assets:[.assets[] | {name,state}]} | @json")
.trim().split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line));
const existing = releases.find((release) => release.tag_name === version);
if (existing) {
@@ -50,16 +50,33 @@ if (option === "--dry-run") {
process.stdout.write(`Release ${version} is already published; left unchanged.\n`);
process.exit(0);
}
- } else {
- const body = releaseNotes(process.cwd(), version, revision, repository);
- const notes = join(tmpdir(), `piik-release-notes-${randomUUID()}.md`);
- writeFileSync(notes, body, { flag: "wx" });
- try {
+ }
+ const checksums = artifacts.files.map((file) => `${file.sha256} ${file.name}`).join("\n");
+ const start = "", end = "";
+ const generated = `${start}\n\n构建与校验 / Build and checksums \n\n` +
+ `[Source / 源码: ${revision}](https://github.com/${repository}/commit/${revision})\n\n` +
+ `SHA-256:\n\n\`\`\`text\n${checksums}\n\`\`\`\n\n \n${end}`;
+ const copy = existing ? existing.body ?? "" : releaseNotes(process.cwd(), version, revision, repository);
+ const begin = copy.indexOf(start), finish = copy.indexOf(end);
+ if ((begin < 0) !== (finish < 0) || finish < begin ||
+ (begin >= 0 && (copy.indexOf(start, begin + start.length) >= 0 || copy.indexOf(end, finish + end.length) >= 0))) {
+ throw new Error("Draft build/checksum section is ambiguous; repair its markers before retrying");
+ }
+ // A retried build may have different archive bytes at the same source SHA.
+ // Preserve reviewed prose while refreshing only this generated draft section.
+ const body = begin < 0 ? `${copy}\n\n${generated}\n`
+ : copy.slice(0, begin) + generated + copy.slice(finish + end.length);
+ const notes = join(tmpdir(), `piik-release-notes-${randomUUID()}.md`);
+ writeFileSync(notes, body, { flag: "wx" });
+ try {
+ if (!existing) {
gh("release", "create", version, "--repo", repository, "--target", revision,
"--draft", "--title", `Piik ${version}`, "--notes-file", notes);
- } finally {
- rmSync(notes);
+ } else {
+ gh("release", "edit", version, "--repo", repository, "--notes-file", notes);
}
+ } finally {
+ rmSync(notes);
}
// Clobber is limited to our same-revision draft; published versions are never changed.
gh("release", "upload", version, "--repo", repository, "--clobber", ...files);
diff --git a/scripts/release-artifacts.mjs b/scripts/release-artifacts.mjs
index 41cc8e1..ac62f8e 100644
--- a/scripts/release-artifacts.mjs
+++ b/scripts/release-artifacts.mjs
@@ -9,6 +9,7 @@ export function readReleaseArtifacts(directory, version, revision) {
}
const root = resolve(directory);
const files = new Map();
+ const packages = [];
const targets = new Set();
function asset(name) {
if (typeof name !== "string" || basename(name) !== name || !/^[a-zA-Z0-9._-]+$/.test(name)) {
@@ -29,9 +30,11 @@ export function readReleaseArtifacts(directory, version, revision) {
const target = descriptor.target ?? "server";
if (targets.has(target)) throw new Error(`Duplicate release target: ${target}`);
targets.add(target);
- if (asset(descriptor.artifact).sha256 !== descriptor.artifactSha256) {
+ const archive = asset(descriptor.artifact);
+ if (archive.sha256 !== descriptor.artifactSha256) {
throw new Error(`Release artifact checksum mismatch: ${name}`);
}
+ packages.push(archive);
if (target === "server") {
if (asset(descriptor.manifest).sha256 !== descriptor.manifestSha256) {
throw new Error("Server manifest checksum mismatch");
@@ -45,5 +48,6 @@ export function readReleaseArtifacts(directory, version, revision) {
if (targets.size !== expected.length || expected.some((target) => !targets.has(target))) {
throw new Error("Publication requires one matching Server and every App target");
}
- return { files: [...files.values()], targets: [...targets] };
+ // Sidecars prove the local build; only installable packages are public assets.
+ return { files: packages, targets: [...targets] };
}
diff --git a/site/downloads.ts b/site/downloads.ts
index d666983..ad28e9f 100644
--- a/site/downloads.ts
+++ b/site/downloads.ts
@@ -18,7 +18,6 @@ export function websiteDownloads(data: { github: unknown; mirror?: unknown }) {
const mirrorAssets = mirror?.version === release.version && mirror.revision === release.revision
? assets(data.mirror) : [];
return {
- version: release.version,
packages: targets.map(target => {
const name = `piik-app-${target}.zip`;
const publishedURL = `https://github.com/TNTcraftHIM/Piik/releases/download/${release.version}/${name}`;
diff --git a/site/film/README.md b/site/film/README.md
index 3a7f85e..564cdd5 100644
--- a/site/film/README.md
+++ b/site/film/README.md
@@ -1,8 +1,9 @@
# Piik film
The homepage expands this optional, 76-second introduction in an iframe loaded
-only on request. Closing it unloads playback; the standalone page remains useful
-for recording. Both entries use the same player. From the repository
+only on request. Closing it unloads playback; the adjacent **Open film page** link
+opens the same player on its own page and carries the current language and theme.
+Use `/film/?lang=en` for an English direct link. From the repository
root, run `npm run build:website` and `npm run preview:website`, then open
`http://127.0.0.1:18890/film/`. Publish the static `build/site/` output. A project
prefix such as `/Piik/film/` also works; GitHub Pages needs no application server.
diff --git a/site/film/ui/main.tsx b/site/film/ui/main.tsx
index 98ba5bb..6768af8 100644
--- a/site/film/ui/main.tsx
+++ b/site/film/ui/main.tsx
@@ -157,7 +157,6 @@ function Screen({ shot }: { shot: Shot }) {
onSiteChange={noop}
localAccessPassword=""
onLocalAccessPasswordChange={noop}
- error={false}
onSubmit={(event) => event.preventDefault()}
/>
diff --git a/site/index.html b/site/index.html
index e060419..351eaec 100644
--- a/site/index.html
+++ b/site/index.html
@@ -91,11 +91,16 @@
One host · Up to 20 friends · Nothing to install to watch 一位房主 · 最多 20 位朋友 · 观看无须安装
-
-
- A little film about Piik 用一支短片,认识 Piik
- 76s
-
+
Local room 本地房间
- Friends are on the same local network and can reach your computer. 朋友和你在同一局域网,且能访问你的电脑。
+ Friends are on the same local network and can reach your computer. If several local invitation addresses appear, choose the network your friends use. 朋友和你在同一局域网,且能访问你的电脑。出现多个局域网邀请地址时,选择与朋友相同的网络。
diff --git a/site/main.js b/site/main.js
index 80ba0e2..013a5a9 100644
--- a/site/main.js
+++ b/site/main.js
@@ -20,6 +20,7 @@ function syncFilmPreferences() {
target.searchParams.set('lang', root.lang);
target.searchParams.set('theme', theme.value);
filmLink.href = target.href;
+ document.querySelector('[data-film-page]').href = target.href;
filmFrame.contentWindow?.postMessage({type:'piik-film-preferences',lang:root.lang,theme:theme.value}, location.origin);
}
const copy = {
diff --git a/site/styles.css b/site/styles.css
index 5be1fd3..ad59c5a 100644
--- a/site/styles.css
+++ b/site/styles.css
@@ -75,7 +75,9 @@ h1 { font-size: clamp(49px, 5.7vw, 76px); line-height: 1.06; margin-block: 22px
.button:not(:disabled):is(:hover, :focus-visible) svg { transform: translateY(3px); }
.button:not(:disabled):active { transform: translateY(2px); box-shadow: 0 1px 0 color-mix(in srgb, var(--ink) 18%, transparent); }
.hero-note { color: var(--muted); font-size: 12px; line-height: 1.7; margin-top: 18px; }
-.film-link { display:inline-flex; align-items:center; gap:10px; min-height:44px; margin-top:18px; text-decoration:none; font-size:12px; font-weight:650; }
+.film-actions { display:flex; align-items:center; flex-wrap:wrap; gap:0 20px; margin-top:18px; }
+.film-link { display:inline-flex; align-items:center; gap:10px; min-height:44px; text-decoration:none; font-size:12px; font-weight:650; }
+.film-page-link { display:inline-flex; align-items:center; min-height:44px; color:var(--muted); font-size:12px; }
.film-play { display:grid; place-items:center; width:33px; height:33px; border-radius:50%; color:var(--orange); background:var(--paper); transition:transform .22s cubic-bezier(.2,.9,.3,1.4); }
.film-play svg { width:23px; height:23px; }
.film-link:is(:hover,:focus-visible) .film-play { transform:scale(1.12); }
diff --git a/src/client/App.tsx b/src/client/App.tsx
index 9bcf5f0..b283e48 100644
--- a/src/client/App.tsx
+++ b/src/client/App.tsx
@@ -26,8 +26,9 @@ import { Btn, Pill } from "./components/living/primitives";
import { Comic, type ComicKind } from "./components/living/Comic";
import { Glyph } from "./ui/icons";
import { setCopy, useCopy } from "./ui/copy";
+import { consoleLanguage } from "./locales";
import { initTheme } from "./ui/theme";
-import { installBrowserDebug } from "./lib/debug";
+import { installBrowserDebug, withBrowserDebug } from "./lib/debug";
const OverlayPreviewPage = import.meta.env.DEV
? lazy(() => import("./pages/OverlayPreviewPage").then((module) => ({ default: module.OverlayPreviewPage })))
@@ -103,7 +104,7 @@ export function App() {
const controller = new AbortController();
void import("./native/client")
.then(({ notifyNativePresentation }) =>
- notifyNativePresentation(vis ? "vis" : lang, controller.signal))
+ notifyNativePresentation(consoleLanguage(lang, vis), controller.signal))
.catch(() => undefined);
return () => controller.abort();
}, [lang, vis]);
@@ -245,7 +246,7 @@ function StaticRoute({
title="join.title"
cap="join.title"
hint="hint-join-go"
- onClick={() => window.location.assign("/join")}
+ onClick={() => window.location.assign(withBrowserDebug("/join"))}
/>
) : action === "reload" ? (
("idle");
- const option = (mode: Lang | "vis", label: string, tipKey: "mode.zh" | "mode.en" | "mode.vis") => {
- const active = mode === "vis" ? vis : !vis && lang === mode;
- return (
- (mode === "vis" ? setVis(true) : setLang(mode))}
- >
- {label}
-
- );
- };
const themeTitle = t(theme === "dark" ? "theme.light" : "theme.dark");
const themeButton = (
);
- const debugTitle = t(debugExport === "failed" ? "debug.exportFailed" : "debug.exportHint");
+ const debugTitle = t(!browserDebugEnabled ? "debug.startHint" :
+ debugExport === "failed" ? "debug.exportFailed" : "debug.exportHint");
const debugButton = (
{
if (event.detail !== 0) event.currentTarget.blur();
+ if (!browserDebugEnabled) {
+ // Start collection before connection owners attach their observers.
+ // Preserve the current route, access parameters and invitation fragment.
+ if (!window.confirm(t("debug.startConfirm"))) return;
+ window.location.assign(withBrowserDebug(window.location.href, true));
+ return;
+ }
setDebugExport("busy");
void downloadBrowserDebug().then(() => setDebugExport("idle")).catch((error) => {
debugError("export", "collector-failed", error, { collector: "download" });
@@ -101,26 +95,25 @@ export function HeaderControls() {
});
}}
>
-
- {vis ? null : {t(debugExport === "failed" ? "common.retry" : "debug.export")} }
+ {vis ? null : {t(!browserDebugEnabled ? "debug.start" :
+ debugExport === "failed" ? "common.retry" : "debug.export")} }
);
return (
- {browserDebugEnabled && (
-
- {debugButton}
-
- )}
-
- {option("zh", "中", "mode.zh")}
- {option("en", "EN", "mode.en")}
- {option("vis", "✦", "mode.vis")}
-
+
{themeButton}
+ {diagnosticControl !== null &&
+ {diagnosticControl === undefined ?
+ {debugButton}
+ : diagnosticControl}
+ }
);
}
@@ -128,23 +121,25 @@ export function HeaderControls() {
export function AppHeader({
led,
homeHref = "/",
+ diagnosticControl,
}: {
led?: React.ReactNode;
homeHref?: string;
+ diagnosticControl?: React.ReactNode;
}) {
const { t } = useCopy();
return (
);
diff --git a/src/client/components/living/LanguageControl.tsx b/src/client/components/living/LanguageControl.tsx
new file mode 100644
index 0000000..5c3c376
--- /dev/null
+++ b/src/client/components/living/LanguageControl.tsx
@@ -0,0 +1,139 @@
+import { useEffect, useId, useRef, type KeyboardEvent } from "react";
+import { locales, type Lang } from "../../locales";
+import { useCopy } from "../../ui/copy";
+import { Glyph } from "../../ui/icons";
+
+// The registry owns languages; the native popover owns visibility and dismissal.
+// This control adds Piik's presentation, viewport placement and menu navigation.
+export function LanguageControl() {
+ const { lang, vis, t, setLang, setVis } = useCopy();
+ const menuId = useId();
+ const triggerRef = useRef(null);
+ const menuRef = useRef(null);
+ const extraLanguages = (Object.keys(locales) as Lang[]).filter((key): boolean => key !== "zh" && key !== "en");
+ const selectedExtra = !vis && extraLanguages.includes(lang) ? locales[lang] : null;
+
+ useEffect(() => {
+ const menu = menuRef.current;
+ const trigger = triggerRef.current;
+ if (!menu || !trigger) return;
+ const place = () => {
+ if (!menu.matches(":popover-open")) return;
+ const rect = trigger.getBoundingClientRect();
+ if (rect.bottom <= 0 || rect.top >= window.innerHeight) {
+ menu.hidePopover();
+ return;
+ }
+ const top = Math.max(8, rect.bottom + 8);
+ menu.style.maxHeight = `${Math.max(0, Math.min(360, window.innerHeight - top - 8))}px`;
+ menu.style.left = `${Math.max(8, Math.min(rect.right - menu.offsetWidth, window.innerWidth - menu.offsetWidth - 8))}px`;
+ menu.style.top = `${top}px`;
+ };
+ const opened = () => {
+ if (!menu.matches(":popover-open")) return;
+ place();
+ const selected = menu.querySelector('[aria-checked="true"]');
+ (selected ?? menu.querySelector("button"))?.focus();
+ };
+ menu.addEventListener("toggle", opened);
+ window.addEventListener("resize", place);
+ window.addEventListener("scroll", place, true);
+ return () => {
+ menu.removeEventListener("toggle", opened);
+ window.removeEventListener("resize", place);
+ window.removeEventListener("scroll", place, true);
+ };
+ }, [extraLanguages.length]);
+
+ function navigate(event: KeyboardEvent) {
+ const menu = event.currentTarget;
+ if (event.key === "Tab") {
+ // Resume the page's normal Tab order from the menu's trigger.
+ triggerRef.current?.focus();
+ menu.hidePopover();
+ return;
+ }
+ if (event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing) return;
+ const items = Array.from(menu.querySelectorAll("button"));
+ const current = items.indexOf(document.activeElement as HTMLButtonElement);
+ let next: HTMLButtonElement | undefined;
+ if (event.key === "ArrowDown") next = items[(current + 1) % items.length];
+ else if (event.key === "ArrowUp") next = items[(current - 1 + items.length) % items.length];
+ else if (event.key === "Home") next = items[0];
+ else if (event.key === "End") next = items.at(-1);
+ else if (event.key.length === 1 && event.key !== " ") {
+ next = [...items.slice(current + 1), ...items.slice(0, current + 1)].find((item) =>
+ item.getAttribute("aria-label")?.toLocaleLowerCase().startsWith(event.key.toLocaleLowerCase()));
+ }
+ if (next) {
+ event.preventDefault();
+ next.focus();
+ }
+ }
+
+ return <>
+
+ {(["zh", "en"] as const).map((key) => (
+ setLang(key)}
+ >{locales[key].short}
+ ))}
+ setVis(true)}
+ >✦
+ {extraLanguages.length > 0 && (
+ {
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ event.currentTarget.click();
+ }
+ }}
+ >
+
+ {selectedExtra ? selectedExtra.short : }
+
+
+
+ )}
+
+ {extraLanguages.length > 0 && (
+
+ )}
+ >;
+}
diff --git a/src/client/components/living/LauncherForm.tsx b/src/client/components/living/LauncherForm.tsx
index c835fc4..d404e08 100644
--- a/src/client/components/living/LauncherForm.tsx
+++ b/src/client/components/living/LauncherForm.tsx
@@ -46,7 +46,7 @@ export function LauncherForm({
onSiteChange,
localAccessPassword,
onLocalAccessPasswordChange,
- error,
+ lan,
onSubmit,
children,
}: {
@@ -56,7 +56,11 @@ export function LauncherForm({
onSiteChange: (site: string) => void;
localAccessPassword: string;
onLocalAccessPasswordChange: (password: string) => void;
- error: boolean;
+ lan?: {
+ addresses: readonly { address: string; name: string }[];
+ selected: string;
+ onChange: (address: string) => void;
+ };
onSubmit: (event: FormEvent) => void;
children?: ReactNode;
}) {
@@ -126,6 +130,26 @@ export function LauncherForm({
})}
+ {mode === "local" && lan && (lan.addresses.length !== 1 || !lan.selected) ? (
+
+ {t("client.launch.lanAddress")}
+ {lan.addresses.length === 0 ? (
+
+ ) : <>
+ {vis ? null : {t("client.launch.lanHint")}
}
+
+ {lan.addresses.map(({ address, name }) => (
+
+ lan.onChange(address)} />
+ {name} {address}
+
+ ))}
+
+ >}
+
+ ) : null}
+
{mode === "site" ? (
@@ -153,15 +177,6 @@ export function LauncherForm({
) : null}
- {error ? (
-
- ) : null}
choice.mode === mode)?.comic}
- disabled={mode === "site" && !site.trim()}
+ disabled={(mode === "site" && !site.trim()) ||
+ (mode === "local" && lan !== undefined && !lan.selected)}
/>
);
diff --git a/src/client/components/living/PlaybackControls.tsx b/src/client/components/living/PlaybackControls.tsx
index 0b26b8c..638db38 100644
--- a/src/client/components/living/PlaybackControls.tsx
+++ b/src/client/components/living/PlaybackControls.tsx
@@ -1,17 +1,15 @@
import { useEffect, useRef, useState, type RefObject } from "react";
import { ViewerAudio, type ViewerAudioSnapshot } from "../../media/viewer-audio";
import { useCopy } from "../../ui/copy";
+import { debugError } from "../../lib/debug";
import { Btn } from "./primitives";
import { Tooltip } from "./Tooltip";
import { usePlaybackControlsVisibility } from "./use-playback-controls-visibility";
-import { leavePictureInPicture, usePictureInPicture } from "./use-picture-in-picture";
+import { usePictureInPicture } from "./use-picture-in-picture";
import { bindPlaybackGestures } from "./playback-gestures";
+import { toggleVideoFullscreen, videoFullscreenState, type FullscreenVideo } from "./video-fullscreen";
import "./playback-controls.css";
-type FullscreenVideo = HTMLVideoElement & {
- webkitEnterFullscreen?: () => void;
-};
-
// Reflect the existing media element; binding, autoplay recovery and room
// presentation retain their existing owners.
export function PlaybackControls({
@@ -36,8 +34,8 @@ export function PlaybackControls({
});
const [paused, setPaused] = useState(true);
const [hasAudio, setHasAudio] = useState(false);
- const [fullscreen, setFullscreen] = useState(false);
- const [canFullscreen, setCanFullscreen] = useState(false);
+ const [fullscreen, setFullscreen] = useState({ supported: false, ready: false, active: false });
+ const [fullscreenFailed, setFullscreenFailed] = useState(false);
const hidden = usePlaybackControlsVisibility(videoRef, controlsRef, paused || !canPlay);
const picture = usePictureInPicture(videoRef);
@@ -53,18 +51,14 @@ export function PlaybackControls({
}
};
const toggleFullscreen = () => {
- if (!canFullscreen || theaterMode) return;
+ if (theaterMode) return;
const video = videoRef.current as FullscreenVideo | null;
- if (fullscreen) {
- void document.exitFullscreen().catch(() => undefined);
- } else if (document.fullscreenEnabled && video?.parentElement) {
- void video.parentElement.requestFullscreen()
- .then(() => leavePictureInPicture(video))
- .catch(() => undefined);
- } else {
- audioRef.current?.useNativeControls();
- video?.webkitEnterFullscreen?.();
- }
+ if (!video) return;
+ setFullscreenFailed(false);
+ void toggleVideoFullscreen(video).catch((error: unknown) => {
+ debugError("playback", "fullscreen-failed", error);
+ if (videoRef.current === video) setFullscreenFailed(true);
+ });
};
const actionsRef = useRef({ togglePlayback, toggleFullscreen });
actionsRef.current = { togglePlayback, toggleFullscreen };
@@ -75,7 +69,13 @@ export function PlaybackControls({
const output = new ViewerAudio(video, setAudio);
audioRef.current = output;
const syncPlayback = () => setPaused(video.paused);
- const syncFullscreen = () => setFullscreen(document.fullscreenElement === video.parentElement);
+ const syncFullscreen = () => {
+ const state = videoFullscreenState(video);
+ setFullscreen(state);
+ setFullscreenFailed(false);
+ if (state.nativeActive) output.useNativeControls();
+ };
+ const fullscreenEvents = ["loadedmetadata", "loadeddata", "emptied", "webkitbeginfullscreen", "webkitendfullscreen", "webkitpresentationmodechanged"];
const onKey = (event: KeyboardEvent) => {
if (event.code !== "Space" || event.target !== video) return;
event.preventDefault();
@@ -83,8 +83,8 @@ export function PlaybackControls({
};
syncPlayback();
syncFullscreen();
- setCanFullscreen(Boolean(document.fullscreenEnabled || video.webkitEnterFullscreen));
for (const event of ["play", "pause", "ended", "emptied"]) video.addEventListener(event, syncPlayback);
+ for (const event of fullscreenEvents) video.addEventListener(event, syncFullscreen);
video.addEventListener("keydown", onKey);
document.addEventListener("fullscreenchange", syncFullscreen);
const unbindGestures = bindPlaybackGestures(video,
@@ -95,6 +95,7 @@ export function PlaybackControls({
output.dispose();
audioRef.current = null;
for (const event of ["play", "pause", "ended", "emptied"]) video.removeEventListener(event, syncPlayback);
+ for (const event of fullscreenEvents) video.removeEventListener(event, syncFullscreen);
video.removeEventListener("keydown", onKey);
document.removeEventListener("fullscreenchange", syncFullscreen);
};
@@ -113,7 +114,7 @@ export function PlaybackControls({
const percent = Math.round(audio.level * 100);
return (
-
+
{ void picture.toggle(); }} />
- {!fullscreen ? : null}
- {canFullscreen && !theaterMode ? (
-
) : null}
diff --git a/src/client/components/living/comic-presentation.ts b/src/client/components/living/comic-presentation.ts
index de15759..bfcc9bc 100644
--- a/src/client/components/living/comic-presentation.ts
+++ b/src/client/components/living/comic-presentation.ts
@@ -45,6 +45,7 @@ const PRESENTATIONS: Partial
> =
"hint-volume-basic": LIMITED,
"hint-no-audio": LIMITED,
"hint-pip-unavailable": LIMITED,
+ "hint-fullscreen-unavailable": LIMITED,
"hint-share-audio-fixed": LIMITED,
"hint-silent-share-fixed": LIMITED,
};
diff --git a/src/client/components/living/hints/index.tsx b/src/client/components/living/hints/index.tsx
index 15fd000..2d7b39b 100644
--- a/src/client/components/living/hints/index.tsx
+++ b/src/client/components/living/hints/index.tsx
@@ -76,6 +76,7 @@ export const HINT_KINDS: readonly HintKind[] = [
"hint-no-audio",
"hint-fullscreen",
"hint-fullscreen-exit",
+ "hint-fullscreen-unavailable",
"hint-pip",
"hint-pip-exit",
"hint-pip-unavailable",
diff --git a/src/client/components/living/hints/playback.tsx b/src/client/components/living/hints/playback.tsx
index 192be30..87b7e22 100644
--- a/src/client/components/living/hints/playback.tsx
+++ b/src/client/components/living/hints/playback.tsx
@@ -142,13 +142,17 @@ function PlaybackViewport({ x, fullscreen, animate = false }: { x: number; fulls
>;
}
-function FullscreenHint({ theme, exit }: Parameters[0] & { exit: boolean }) {
+function FullscreenHint({ theme, exit = false, unavailable = false }: Parameters[0] & {
+ exit?: boolean; unavailable?: boolean;
+}) {
return <>
-
+
+ {unavailable ? : null}
>;
}
@@ -186,6 +190,7 @@ export const PLAYBACK_SCENES: Record = {
"hint-no-audio": NoAudioHint,
"hint-fullscreen": (props) => ,
"hint-fullscreen-exit": (props) => ,
+ "hint-fullscreen-unavailable": (props) => ,
"hint-pip": (props) => ,
"hint-pip-exit": (props) => ,
"hint-pip-unavailable": (props) => ,
diff --git a/src/client/components/living/playback-controls.css b/src/client/components/living/playback-controls.css
index 0774ef0..5e9b670 100644
--- a/src/client/components/living/playback-controls.css
+++ b/src/client/components/living/playback-controls.css
@@ -13,6 +13,7 @@
transition: opacity 180ms ease, transform 220ms var(--spring);
}
.lr-playback.is-hidden { opacity: 0; transform: translateY(6px); pointer-events: none; }
+.lr-tv-screen:has(> .lr-playback[data-can-play="true"]) > video { cursor: pointer; }
.lr-tv-screen:has(> .lr-playback.is-hidden) > video { cursor: none; }
.lr-playback .lr-btn {
display: grid; place-items: center; flex: none;
diff --git a/src/client/components/living/video-fullscreen.ts b/src/client/components/living/video-fullscreen.ts
new file mode 100644
index 0000000..15fbade
--- /dev/null
+++ b/src/client/components/living/video-fullscreen.ts
@@ -0,0 +1,41 @@
+import { leavePictureInPicture } from "./use-picture-in-picture";
+
+export type FullscreenVideo = HTMLVideoElement & {
+ webkitEnterFullscreen?: () => void;
+ webkitExitFullscreen?: () => void;
+ webkitSupportsFullscreen?: boolean;
+ webkitDisplayingFullscreen?: boolean;
+ webkitPresentationMode?: string;
+};
+
+export function videoFullscreenState(video: FullscreenVideo) {
+ const screen = video.parentElement;
+ const pageSupported = Boolean(document.fullscreenEnabled && screen?.requestFullscreen);
+ const nativeSupported = typeof video.webkitEnterFullscreen === "function";
+ const nativeActive = Boolean(video.webkitDisplayingFullscreen || video.webkitPresentationMode === "fullscreen");
+ return {
+ supported: pageSupported || nativeSupported,
+ ready: pageSupported || (nativeSupported && video.readyState >= 1 && video.webkitSupportsFullscreen === true),
+ active: Boolean(screen && document.fullscreenElement === screen) || nativeActive,
+ nativeActive,
+ };
+}
+
+// Request in the original click stack: fullscreen consumes user activation.
+// Native events, rather than a successful method call, confirm Safari entry.
+export async function toggleVideoFullscreen(video: FullscreenVideo): Promise {
+ const screen = video.parentElement;
+ const state = videoFullscreenState(video);
+ if (screen && document.fullscreenElement === screen) {
+ await document.exitFullscreen();
+ } else if (state.nativeActive) {
+ video.webkitExitFullscreen?.();
+ } else if (state.ready) {
+ if (document.fullscreenEnabled && screen?.requestFullscreen) {
+ await screen.requestFullscreen();
+ await leavePictureInPicture(video).catch(() => undefined);
+ } else {
+ video.webkitEnterFullscreen?.();
+ }
+ }
+}
diff --git a/src/client/lib/debug-webrtc.ts b/src/client/lib/debug-webrtc.ts
index 3ff3386..7dcb49c 100644
--- a/src/client/lib/debug-webrtc.ts
+++ b/src/client/lib/debug-webrtc.ts
@@ -1,4 +1,5 @@
import { browserDebugEnabled, debugError, debugEvent } from "./debug";
+import { isPredictedCandidateFoundation } from "../../shared/nat-candidate";
const connections = new WeakMap();
const statsTypes = new Set([
@@ -43,7 +44,31 @@ export function debugRtcStats(connection: RTCPeerConnection, report: RTCStatsRep
if (!identity) return;
const stats: object[] = [];
report.forEach((record) => { if (statsTypes.has(record.type)) stats.push(record); });
- debugEvent("webrtc", "stats", { ...identity, stats });
+ // Preserve a small connectivity summary before the bounded raw array. With
+ // many interfaces, raw pairs can otherwise push the selected path past it.
+ const pairs = { total: 0, waiting: 0, "in-progress": 0, succeeded: 0, failed: 0, frozen: 0, unknown: 0 };
+ const selected: object[] = [];
+ report.forEach((record) => {
+ if (record.type === "candidate-pair") {
+ pairs.total++;
+ const state = record.state as keyof typeof pairs;
+ pairs[Object.hasOwn(pairs, state) && state !== "total" ? state : "unknown"]++;
+ }
+ if (record.type !== "transport" || typeof record.selectedCandidatePairId !== "string") return;
+ const pair = report.get(record.selectedCandidatePairId);
+ if (pair?.type !== "candidate-pair") return;
+ const local = report.get(pair.localCandidateId), remote = report.get(pair.remoteCandidateId);
+ selected.push({
+ state: pair.state ?? null, dtlsState: record.dtlsState ?? null,
+ localType: local?.candidateType ?? null, remoteType: remote?.candidateType ?? null,
+ protocol: local?.protocol ?? null,
+ natTraversalPath: typeof remote?.foundation !== "string" ? "unknown"
+ : isPredictedCandidateFoundation(remote.foundation) ? "predicted" : "ordinary",
+ requestsSent: pair.requestsSent ?? null, responsesReceived: pair.responsesReceived ?? null,
+ currentRoundTripTime: pair.currentRoundTripTime ?? null,
+ });
+ });
+ debugEvent("webrtc", "stats", { ...identity, ice: { pairs, selected }, stats });
}
export function debugRtcFailure(connection: RTCPeerConnection, error: unknown): void {
diff --git a/src/client/lib/debug.ts b/src/client/lib/debug.ts
index d7c1c4e..170b50f 100644
--- a/src/client/lib/debug.ts
+++ b/src/client/lib/debug.ts
@@ -14,6 +14,16 @@ interface BrowserDebugEvent {
export const browserDebugEnabled = typeof window !== "undefined" &&
new URLSearchParams(window.location.search).get("debug") === "1";
+
+// Keep the explicit opt-in through App launch and room entry without carrying
+// any other source-page parameters or credentials into the destination.
+export function withBrowserDebug(target: string, enabled = browserDebugEnabled): string {
+ if (!enabled) return target;
+ const url = new URL(target, window.location.href);
+ url.searchParams.set("debug", "1");
+ return url.href;
+}
+
const MAX_EVENTS = 8_192;
const MAX_BYTES = 8 * 1024 * 1024;
const events: Array<{ record: BrowserDebugEvent; bytes: number }> = [];
diff --git a/src/client/lib/session.ts b/src/client/lib/session.ts
index b77dc1f..0139b18 100644
--- a/src/client/lib/session.ts
+++ b/src/client/lib/session.ts
@@ -7,6 +7,8 @@ import {
} from "../../shared/protocol";
import { z } from "zod";
import { createOpaqueId } from "./opaque-id";
+import { isLang, type Lang } from "../locales";
+import { withBrowserDebug } from "./debug";
const CLIENT_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;
const CLIENT_LAUNCH_STORAGE_KEY = "piik:client-launch:v1";
@@ -71,7 +73,7 @@ export function roomRouteForExplicitEntry(value: string): string | null {
if (route) {
clearViewerGrant(value);
}
- return route;
+ return route ? withBrowserDebug(route) : null;
}
export function parseAppRoute(pathname: string): AppRoute {
@@ -101,19 +103,19 @@ export interface ClientLaunchBootstrap {
}
export interface ClientLaunchPresentation {
- lang: "zh" | "en";
+ lang: Lang;
vis: boolean;
theme: "light" | "dark" | null;
}
-export function clientLaunchURL(target: string, presentation: ClientLaunchPresentation): string {
+export function clientLaunchURL(target: string, presentation: ClientLaunchPresentation, debug?: boolean): string {
const url = new URL(target);
const params = new URLSearchParams(url.hash.slice(1));
params.set("piik-lang", presentation.lang);
params.set("piik-mode", presentation.vis ? "vis" : "text");
params.set("piik-theme", presentation.theme ?? "system");
url.hash = params.toString();
- return url.toString();
+ return withBrowserDebug(url.toString(), debug);
}
export function takeClientLaunchBootstrap(): ClientLaunchBootstrap {
@@ -145,7 +147,7 @@ export function takeClientLaunchBootstrap(): ClientLaunchBootstrap {
const result: ClientLaunchBootstrap = {
accessToken: accessValue || null,
launchedByClient,
- presentation: launchedFromFragment && (lang === "zh" || lang === "en") &&
+ presentation: launchedFromFragment && isLang(lang) &&
(mode === "vis" || mode === "text") &&
(theme === "light" || theme === "dark" || theme === "system")
? { lang, vis: mode === "vis", theme: theme === "system" ? null : theme }
@@ -451,7 +453,7 @@ export function readViewerRoute(): ViewerRoute | null {
} else {
clearViewerGrant(roomId);
}
- window.history.replaceState(window.history.state, "", `/r/${roomId}`);
+ window.history.replaceState(window.history.state, "", withBrowserDebug(`/r/${roomId}`));
return validGrant
? { roomId, viewerGrant: validGrant }
: { roomId, invalidGrant: true };
diff --git a/src/client/locales/en.ts b/src/client/locales/en.ts
index 091593e..5b5b5ff 100644
--- a/src/client/locales/en.ts
+++ b/src/client/locales/en.ts
@@ -11,14 +11,19 @@ export const en: Record = {
"playback.volume": "Adjust volume, from 0% to 200%",
"playback.volumeBasic": "Adjust volume, from 0% to 100%",
"playback.fullscreen": "Fullscreen",
+ "playback.fullscreenWaiting": "Fullscreen is available when the video is ready.",
+ "playback.fullscreenFailed": "Couldn’t switch fullscreen mode. Try again.",
"playback.exitFullscreen": "Exit fullscreen",
"playback.pip": "Picture in picture",
"playback.exitPip": "Return to the page",
"playback.pipUnavailable": "Picture in picture is unavailable in this browser",
"playback.pipFailed": "Could not switch picture in picture; try again",
"playback.pipWaiting": "Picture in picture is available once the video is ready",
- "debug.export": "Debug report",
- "debug.exportHint": "Download local diagnostics; may include network addresses and device details",
+ "debug.start": "Debug",
+ "debug.startHint": "Reload this page and record browser diagnostics",
+ "debug.startConfirm": "Start browser diagnostics and reload this page? Any active sharing or viewing will be interrupted.",
+ "debug.export": "Web report",
+ "debug.exportHint": "Download browser diagnostics; may include network addresses and device details",
"debug.exportFailed": "Could not download diagnostics; try again",
"brand.home": "Piik home",
@@ -74,11 +79,17 @@ export const en: Record = {
"client.launch.site": "Connect to Site",
"client.launch.siteHint": "Use an existing Piik Site",
"client.launch.siteAddress": "Piik Site address",
+ "client.launch.lanAddress": "Local invitation address",
+ "client.launch.lanHint": "Choose an address on the same network as your friends.",
+ "client.launch.lanUnavailable": "No local network address is available. Connect to a network and refresh this page.",
"client.launch.localAccess": "Site passphrase (optional)",
"client.launch.localAccessHint": "If set, visitors enter this passphrase before accessing your site.",
"client.launch.go": "Open Piik",
"client.launch.starting": "Preparing Piik",
- "client.launch.error": "Piik could not start; check the setting and retry",
+ "client.launch.error": "Piik could not start",
+ "client.launch.reopen": "Check the reason above and the terminal for details, then close and reopen Piik to try again. Enable Debug launch first if you need to record logs.",
+ "client.launch.debug": "Debug launch",
+ "client.launch.debugHint": "Record App and browser diagnostics for this launch. Press D in the terminal to export App logs.",
"client.launch.loadFailed": "Could not read the launcher settings; retry",
"client.update.available": "A newer Piik release is available",
"client.update.differentBuild": "This build differs from the official release",
@@ -349,8 +360,7 @@ export const en: Record = {
"viewer.error.relay": "Downstream media connection error",
"mode.language": "Interface language",
- "mode.zh": "中文",
- "mode.en": "English",
+ "mode.more": "More languages",
"mode.vis": "Visual only",
"theme.dark": "Switch to dark mode",
"theme.light": "Switch to light mode",
diff --git a/src/client/locales/index.ts b/src/client/locales/index.ts
index 8858284..17165b7 100644
--- a/src/client/locales/index.ts
+++ b/src/client/locales/index.ts
@@ -1,20 +1,31 @@
import { en, enTitleFrames } from "./en";
import { visualTitleFrames } from "./visual";
-import {
- zh,
- zhTitleFrames,
- type TitleFrameCatalog,
-} from "./zh";
+import { zh, zhTitleFrames } from "./zh";
export type { CopyKey, TitleFrameKey } from "./zh";
-// Register contributed locales here; UI consumers stay catalog-agnostic.
-export const catalogs = { zh, en };
-export type Lang = keyof typeof catalogs;
-
-export const titleFrameCatalogs: Record = {
- zh: zhTitleFrames,
- en: enTitleFrames,
+// One registration supplies the menu, browser-language matching and copy.
+export const locales = {
+ zh: { name: "简体中文", short: "中", tag: "zh-CN", copy: zh, titleFrames: zhTitleFrames },
+ en: { name: "English", short: "EN", tag: "en", copy: en, titleFrames: enTitleFrames },
};
+export type Lang = keyof typeof locales;
+
+export function isLang(value: unknown): value is Lang {
+ return typeof value === "string" && Object.hasOwn(locales, value);
+}
+
+export function resolveLang(language?: string): Lang {
+ const tag = language?.toLowerCase();
+ const exact = (Object.keys(locales) as Lang[]).find((key) =>
+ key.toLowerCase() === tag || locales[key].tag.toLowerCase() === tag);
+ const base = tag?.split("-")[0];
+ return exact ?? (isLang(base) ? base : "en");
+}
+
+// The App console currently translates these three presentations only.
+export function consoleLanguage(lang: Lang, visual: boolean): "zh" | "en" | "vis" {
+ return visual ? "vis" : lang === "zh" ? "zh" : "en";
+}
export { visualTitleFrames };
diff --git a/src/client/locales/zh.ts b/src/client/locales/zh.ts
index b063dc2..07b4f7a 100644
--- a/src/client/locales/zh.ts
+++ b/src/client/locales/zh.ts
@@ -9,14 +9,19 @@ export const zh = {
"playback.volume": "调节音量,支持 0%–200%",
"playback.volumeBasic": "调节音量,支持 0%–100%",
"playback.fullscreen": "全屏观看",
+ "playback.fullscreenWaiting": "画面准备好后可全屏",
+ "playback.fullscreenFailed": "未能切换全屏,请重试",
"playback.exitFullscreen": "退出全屏",
"playback.pip": "悬浮小窗",
"playback.exitPip": "返回页面观看",
"playback.pipUnavailable": "当前浏览器暂不支持悬浮小窗",
"playback.pipFailed": "小窗切换失败,请重试",
"playback.pipWaiting": "画面就绪后可开启悬浮小窗",
- "debug.export": "调试报告",
- "debug.exportHint": "下载本地诊断,可能包含网络地址和设备信息",
+ "debug.start": "诊断",
+ "debug.startHint": "重新加载页面并记录浏览器诊断",
+ "debug.startConfirm": "页面将重新加载,当前分享或观看会中断。是否开启浏览器诊断?",
+ "debug.export": "网页报告",
+ "debug.exportHint": "下载网页诊断,可能包含网络地址和设备信息",
"debug.exportFailed": "诊断下载失败,请重试",
"brand.home": "Piik 首页",
@@ -72,11 +77,17 @@ export const zh = {
"client.launch.site": "连接站点",
"client.launch.siteHint": "使用已有的 Piik 站点",
"client.launch.siteAddress": "Piik 站点地址",
+ "client.launch.lanAddress": "局域网邀请地址",
+ "client.launch.lanHint": "选择与朋友处于同一网络的地址。",
+ "client.launch.lanUnavailable": "没有可用的局域网地址,请连接网络后刷新页面",
"client.launch.localAccess": "站点口令(可选)",
"client.launch.localAccessHint": "设置后,访问此站点需要先输入口令。",
"client.launch.go": "进入 Piik",
"client.launch.starting": "正在准备 Piik",
- "client.launch.error": "暂时无法启动,请检查设置后重试",
+ "client.launch.error": "Piik 启动失败",
+ "client.launch.reopen": "请查看上方原因和黑窗口中的详细信息,关闭并重新打开 Piik 后重试。需要记录日志时,可先开启「诊断启动」。",
+ "client.launch.debug": "诊断启动",
+ "client.launch.debugHint": "本次启动记录 App 和网页诊断。App 日志可在黑窗口按 D 导出。",
"client.launch.loadFailed": "无法读取启动设置,请重试",
"client.update.available": "发现新的 Piik 版本",
"client.update.differentBuild": "当前构建与官方发行版不同",
@@ -347,8 +358,7 @@ export const zh = {
"viewer.error.relay": "下游媒体连接异常",
"mode.language": "界面语言",
- "mode.zh": "中文",
- "mode.en": "English",
+ "mode.more": "更多语言",
"mode.vis": "纯视觉",
"theme.dark": "切换到暗色模式",
"theme.light": "切换到浅色模式",
diff --git a/src/client/media/browser-encoding-producer.ts b/src/client/media/browser-encoding-producer.ts
index 5069ad9..07f61d4 100644
--- a/src/client/media/browser-encoding-producer.ts
+++ b/src/client/media/browser-encoding-producer.ts
@@ -139,7 +139,7 @@ export class BrowserEncodingProducer {
const report = await connection.getStats().catch((error) => { debugRtcFailure(connection, error); throw error; });
debugRtcStats(connection, report);
if (!this.disposed && this.startupPending &&
- maxEncodedVideoFrames(report, this.input!.id) >= STARTUP_VIDEO_ENCODED_FRAMES) {
+ (maxEncodedVideoFrames(report, this.input!.id) ?? 0) >= STARTUP_VIDEO_ENCODED_FRAMES) {
this.startupPending = false;
void this.serialize(() => this.applyCurrent()).catch(this.fail);
}
diff --git a/src/client/media/host-provisional-child.ts b/src/client/media/host-provisional-child.ts
index 864247b..1c440e7 100644
--- a/src/client/media/host-provisional-child.ts
+++ b/src/client/media/host-provisional-child.ts
@@ -46,6 +46,7 @@ interface HostProvisionalChildEvents {
revision: number,
) => void;
onPromotedUpdate?: (peer: HostMediaPeer, snapshot: PeerSnapshot) => void;
+ onPreparedChildFailed?: (revision: number, connectionId: string) => void;
}
export type HostPreparedChildActivation =
@@ -262,14 +263,19 @@ export class HostProvisionalChild {
}
private fail(peer: HostMediaPeer): void {
- if (this.prepared?.peer !== peer) {
+ const prepared = this.prepared;
+ if (prepared?.peer !== peer || prepared.failed) {
return;
}
- this.prepared.failed = true;
+ prepared.failed = true;
if (this.signalingPeer === peer) {
this.signalingPeer = null;
}
peer.dispose();
+ this.events.onPreparedChildFailed?.(
+ prepared.revision,
+ prepared.candidate.connectionId,
+ );
}
}
diff --git a/src/client/native/native-viewer-peer.ts b/src/client/native/native-viewer-peer.ts
index 766ab19..5fdba17 100644
--- a/src/client/native/native-viewer-peer.ts
+++ b/src/client/native/native-viewer-peer.ts
@@ -434,7 +434,20 @@ class NativeViewerPeer implements ViewerMediaPeer {
connectionId,
description: result.answer,
})) {
- throw new Error("Native Viewer answer could not be signaled");
+ // A room-signaling outage is not evidence the local Native capability is
+ // unavailable: close only this receiver and use the ordinary recovery
+ // path, like the Browser peer's "signal send rejected" handling.
+ this.sourceReady = false;
+ this.sourceCodec = null;
+ void this.client.closeReceiver(this.sessionId, connectionId).catch(
+ () => undefined,
+ );
+ if (this.recoveryOwner === "route") {
+ this.reportRecoveryExhausted();
+ } else if (!this.requestRecovery(true)) {
+ this.reportRecoveryExhausted();
+ }
+ return;
}
this.scheduleInitialConnectionDeadline(connectionId);
const bridge = new NativeMediaBridge(
diff --git a/src/client/pages/AppLauncherPage.tsx b/src/client/pages/AppLauncherPage.tsx
index 685b45f..db49210 100644
--- a/src/client/pages/AppLauncherPage.tsx
+++ b/src/client/pages/AppLauncherPage.tsx
@@ -8,8 +8,10 @@ import { LauncherForm, type AppMode } from "../components/living/LauncherForm";
import { Btn, Pill } from "../components/living/primitives";
import { Glyph } from "../ui/icons";
import { useCopy, type CopyKey } from "../ui/copy";
+import { consoleLanguage } from "../locales";
import { currentThemePreference } from "../ui/theme";
import { clientLaunchURL } from "../lib/session";
+import { browserDebugEnabled } from "../lib/debug";
import {
checkReleaseUpdate,
type ReleaseUpdateNotice,
@@ -22,8 +24,14 @@ const launcherStateSchema = z.object({
revision: z.string(),
version: z.string().default("development"),
packageTarget: z.string().optional(),
+ debug: z.boolean().optional(),
+ lan: z.object({
+ addresses: z.array(z.object({ address: z.string(), name: z.string() })),
+ selected: z.string(),
+ }).optional(),
});
const launcherResultSchema = z.object({ target: z.string().url() }).strict();
+const launcherErrorSchema = z.object({ detail: z.string() });
export function AppLauncherPage() {
const { lang, vis, t } = useCopy();
@@ -32,7 +40,10 @@ export function AppLauncherPage() {
const [mode, setMode] = useState("link");
const [site, setSite] = useState("");
const [localAccessPassword, setLocalAccessPassword] = useState("");
- const [error, setError] = useState(null);
+ const [error, setError] = useState<{ kind: "load" | "launch"; detail?: string } | null>(null);
+ const [appDebug, setAppDebug] = useState();
+ const [debug, setDebug] = useState(false);
+ const [lan, setLan] = useState["lan"]>();
const [update, setUpdate] = useState(null);
useEffect(() => {
@@ -47,6 +58,9 @@ export function AppLauncherPage() {
setMode(state.defaultMode);
setSite(state.site);
setLocalAccessPassword(state.localAccessPassword);
+ setAppDebug(state.debug);
+ setDebug(state.debug === true || browserDebugEnabled);
+ setLan(state.lan);
setLoading(false);
void checkReleaseUpdate({
version: state.version,
@@ -59,7 +73,7 @@ export function AppLauncherPage() {
})
.catch(() => {
if (!current) return;
- setError("load");
+ setError({ kind: "load" });
setLoading(false);
});
return () => {
@@ -69,7 +83,8 @@ export function AppLauncherPage() {
async function launch(event: FormEvent): Promise {
event.preventDefault();
- if (starting || (mode === "site" && !site.trim())) return;
+ if (starting || (mode === "site" && !site.trim()) ||
+ (mode === "local" && lan !== undefined && !lan.selected)) return;
setStarting(true);
setError(null);
const presentation = { lang, vis, theme: currentThemePreference() };
@@ -79,15 +94,22 @@ export function AppLauncherPage() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode,
- language: vis ? "vis" : lang,
+ language: consoleLanguage(lang, vis),
+ ...(appDebug !== undefined && debug ? { debug: true } : {}),
+ ...(mode === "local" && lan ? { lanAddress: lan.selected } : {}),
...(mode === "site" ? { site } : { localAccessPassword }),
}),
});
- if (!response.ok) throw new Error();
+ if (!response.ok) {
+ const failure = launcherErrorSchema.safeParse(await response.json().catch(() => null));
+ setError({ kind: "launch", detail: failure.success ? failure.data.detail : undefined });
+ setStarting(false);
+ return;
+ }
const result = launcherResultSchema.parse(await response.json());
- window.location.replace(clientLaunchURL(result.target, presentation));
+ window.location.replace(clientLaunchURL(result.target, presentation, debug));
} catch {
- setError("launch");
+ setError({ kind: "launch" });
setStarting(false);
}
}
@@ -115,7 +137,12 @@ export function AppLauncherPage() {
return (
-
+
setDebug((value) => !value)} />
+ )} />
{loading || starting ? (
)}
- ) : error === "load" ? (
+ ) : error ? (
-
+ {error.detail && {error.detail}
}
+ {t("client.launch.reopen")}
+ > : window.location.reload()}
- />
+ />}
) : (
{
- setMode(value);
- setError(null);
- }}
- onSiteChange={(value) => {
- setSite(value);
- setError(null);
- }}
- onLocalAccessPasswordChange={(value) => {
- setLocalAccessPassword(value);
- setError(null);
- }}
- error={error === "launch"}
+ onModeChange={setMode}
+ onSiteChange={setSite}
+ onLocalAccessPasswordChange={setLocalAccessPassword}
+ lan={lan && { ...lan, onChange: (selected) => setLan({ ...lan, selected }) }}
onSubmit={launch}
>
{updateLink &&
diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx
index b28f364..51eb8a0 100644
--- a/src/client/pages/HostPage.tsx
+++ b/src/client/pages/HostPage.tsx
@@ -1966,6 +1966,9 @@ export function HostPage({
reportSenderQuality(snapshot, revision);
}
},
+ onPreparedChildFailed: (revision, connectionId) => {
+ reportPreparedHostChildFailure(revision, connectionId, generation);
+ },
createPeer:
nativeClient && nativeShareGeneration
? (candidate, input, events) => {
@@ -2211,6 +2214,21 @@ export function HostPage({
);
}
+ function reportPreparedHostChildFailure(
+ revision: number,
+ connectionId: string,
+ generation: number,
+ ): void {
+ if (isCurrentGeneration(generation)) {
+ signalRef.current?.send({
+ type: "route-failed",
+ revision,
+ phase: "prepare",
+ connectionId,
+ });
+ }
+ }
+
function handleSignalMessage(
message: ServerMessage,
generation: number,
@@ -2324,13 +2342,22 @@ export function HostPage({
const accepted = route.accept(message);
if (accepted === "stale") return;
if (message.phase === "prepare") {
+ if (accepted === "duplicate") return;
if (message.candidate.transport === "direct") {
- prepareHostChild(
- message.revision,
- message.assignment,
- message.candidate,
- generation,
- );
+ if (
+ !prepareHostChild(
+ message.revision,
+ message.assignment,
+ message.candidate,
+ generation,
+ )
+ ) {
+ reportPreparedHostChildFailure(
+ message.revision,
+ message.candidate.connectionId,
+ generation,
+ );
+ }
} else {
discardPreparedHostChild();
}
diff --git a/src/client/pages/TooltipPreviewPage.tsx b/src/client/pages/TooltipPreviewPage.tsx
index 7882a81..d29e97e 100644
--- a/src/client/pages/TooltipPreviewPage.tsx
+++ b/src/client/pages/TooltipPreviewPage.tsx
@@ -12,6 +12,8 @@ const EXAMPLES: { label: string; kind: HintKind | ComicKind; tone: ComicTone; mo
{ label: "成功 / Success", kind: "hint-copy-code", tone: "live", motion: "still", text: "common.copied" },
{ label: "受限 / Limited", kind: "hint-nat-unavailable", tone: "warn", motion: "still", text: "host.advanced.route.natPredictionUnavailable" },
{ label: "App incompatible", kind: "warning", tone: "warn", motion: "still", text: "native.incompatible" },
+ { label: "Fullscreen waiting", kind: "hint-fullscreen-unavailable", tone: "warn", motion: "still", text: "playback.fullscreenWaiting" },
+ { label: "Fullscreen failed", kind: "hint-fullscreen-unavailable", tone: "bad", motion: "still", text: "playback.fullscreenFailed" },
{ label: "失败 / Failed", kind: "warning", tone: "bad", motion: "still", text: "common.copyFailed" },
{ label: "连接中 / Connecting", kind: "signal-connecting", tone: "busy", motion: "progress", text: "state.signal.connecting" },
{ label: "恢复中 / Recovering", kind: "recovering", tone: "warn", motion: "progress", text: "state.peer.reconnecting" },
diff --git a/src/client/pages/ViewerPage.tsx b/src/client/pages/ViewerPage.tsx
index eba9b71..f1ec424 100644
--- a/src/client/pages/ViewerPage.tsx
+++ b/src/client/pages/ViewerPage.tsx
@@ -921,6 +921,7 @@ export function ViewerPage({
signal.send(evidence);
}
},
+ onPreparedChildFailed: reportPreparedChildFailure,
},
endpointMediaCopyCapacity,
currentRoutePolicy.natPrediction,
@@ -931,6 +932,20 @@ export function ViewerPage({
return viewerRelay;
}
+ function reportPreparedChildFailure(
+ revision: number,
+ connectionId: string,
+ ): void {
+ if (active) {
+ signal.send({
+ type: "route-failed",
+ revision,
+ phase: "prepare",
+ connectionId,
+ });
+ }
+ }
+
function reconcileRelayChildren(
previousChildPeerIds: readonly string[],
activeRevision?: number,
@@ -1154,7 +1169,9 @@ export function ViewerPage({
return;
}
if (candidate && childPeerIds && revision !== undefined) {
- ensureViewerRelay()?.prepareChild(revision, candidate, childPeerIds);
+ if (!ensureViewerRelay()?.prepareChild(revision, candidate, childPeerIds)) {
+ reportPreparedChildFailure(revision, candidate.connectionId);
+ }
} else {
viewerRelay?.discardPreparedChild();
}
@@ -1965,7 +1982,7 @@ export function ViewerPage({
}
if (message.code === "ROOM_ACCESS_DENIED") {
if (viewerPasswordAttempt) {
- setViewerPasswordError("viewer.msg.denied");
+ setViewerPasswordError("join.passwordError");
setViewerPasswordExpanded(true);
}
return;
@@ -2173,10 +2190,9 @@ export function ViewerPage({
: failureCode === "ROOM_NOT_FOUND" ||
failureCode === "ROOM_CLOSED"
? "viewer.hint.notFound"
- : failureCode === "INVALID_TOKEN" ||
- // A grant that the room no longer accepts is not a retry case:
- // the recovery is a new invite, not another attempt.
- failureCode === "ROOM_ACCESS_DENIED"
+ : failureCode === "INVALID_TOKEN"
+ // A grant that the room no longer accepts is not a retry case:
+ // the recovery is a new invite, not another attempt.
? "viewer.hint.invite"
: "viewer.hint.generic";
return (
diff --git a/src/client/sfu/publisher.ts b/src/client/sfu/publisher.ts
index 93c3fac..093026f 100644
--- a/src/client/sfu/publisher.ts
+++ b/src/client/sfu/publisher.ts
@@ -1,5 +1,6 @@
import type { SfuMedia, SfuSignalMessage } from "../../shared/protocol";
import type { MediaFailure } from "../ui/media-failure";
+import { debugError } from "../lib/debug";
import { debugRtcFailure, debugRtcStats, debugTrack } from "../lib/debug-webrtc";
import {
audioSenderParameterWarning,
@@ -65,6 +66,9 @@ export class SfuPublisher {
private closed = false;
private readonly ownedTracks = new Set();
private startupPending = false;
+ // RTP frame counts can survive replaceTrack; 0 suits a fresh sender, while
+ // a replacement needs a valid current-track sample to establish its baseline.
+ private startupFramesBaseline: number | null = null;
private operationTail: Promise = Promise.resolve();
private failureStage: SfuPublisherFailureStage | null = null;
private senderParameters: VideoSenderParameterReadback | null = null;
@@ -138,6 +142,7 @@ export class SfuPublisher {
this.profile = profile;
this.codec = codec;
this.startupPending = needsStartupVideoProfile(profile);
+ this.startupFramesBaseline = 0;
let video: MediaStreamTrack | null = null;
try {
this.failureStage = "source";
@@ -195,6 +200,9 @@ export class SfuPublisher {
this.startStats();
return true;
} catch (error) {
+ debugError("webrtc", "sfu-activate-failed", error, {
+ stage: this.failureStage ?? "connect",
+ });
if (video && this.video !== video) this.releaseTrack(video);
if (this.peer === peer) this.fail(this.failureStage ?? "connect");
throw error;
@@ -239,6 +247,7 @@ export class SfuPublisher {
this.video = nextVideo;
this.audio = nextAudio;
this.startupPending = needsStartupVideoProfile(profile);
+ this.startupFramesBaseline = null;
await this.configure(profile);
if (this.peer !== peer) return false;
if (!peer.send({ kind: "media", media: this.media(profile) }))
@@ -248,7 +257,8 @@ export class SfuPublisher {
this.releaseTrack(previousAudio);
this.setPaused(this.paused);
return true;
- } catch {
+ } catch (error) {
+ debugError("webrtc", "sfu-source-failed", error);
if (this.peer !== peer) return false;
this.video = previousVideo;
this.audio = previousAudio;
@@ -272,40 +282,43 @@ export class SfuPublisher {
}
updateProfile(profile: QualityProfile): Promise {
- return this.enqueue(async () => {
- const peer = this.peer;
- const video = this.video;
- const previous = this.profile;
- if (!peer || !video || !previous) return false;
- if (!needsStartupVideoProfile(profile)) this.startupPending = false;
- const videoChanged = !videoQualitySettingsEqual(previous, profile);
- const configureVideo =
- videoChanged ||
- this.senderParameters?.applied.degradationPreference !==
- (this.startupPending ? startupVideoProfile(profile) : profile)
- .degradationPreference;
- if (configureVideo) this.resetStats();
+ return this.enqueue(() => this.applyProfile(profile));
+ }
+
+ private async applyProfile(profile: QualityProfile): Promise {
+ const peer = this.peer;
+ const video = this.video;
+ const previous = this.profile;
+ if (!peer || !video || !previous) return false;
+ if (!needsStartupVideoProfile(profile)) this.startupPending = false;
+ const videoChanged = !videoQualitySettingsEqual(previous, profile);
+ const configureVideo =
+ videoChanged ||
+ this.senderParameters?.applied.degradationPreference !==
+ (this.startupPending ? startupVideoProfile(profile) : profile)
+ .degradationPreference;
+ if (configureVideo) this.resetStats();
+ try {
+ if (videoChanged) await applyVideoCaptureProfile(video, profile);
+ const result = await this.configure(profile, configureVideo);
+ if (this.peer !== peer) return false;
+ if (!peer.send({ kind: "media", media: this.media(profile) }))
+ throw new Error("SFU signaling is unavailable");
+ this.profile = profile;
+ return result;
+ } catch (error) {
+ debugError("webrtc", "sfu-profile-failed", error, { requested: profile });
+ if (this.peer !== peer) return false;
try {
- if (videoChanged) await applyVideoCaptureProfile(video, profile);
- const result = await this.configure(profile, configureVideo);
- if (this.peer !== peer) return false;
- if (!peer.send({ kind: "media", media: this.media(profile) }))
- throw new Error("SFU signaling is unavailable");
- this.profile = profile;
- return result;
+ if (videoChanged) await applyVideoCaptureProfile(video, previous);
+ await this.configure(previous, configureVideo);
+ this.videoWarning = { key: "host.fail.sfuParams" };
+ return false;
} catch {
- if (this.peer !== peer) return false;
- try {
- if (videoChanged) await applyVideoCaptureProfile(video, previous);
- await this.configure(previous, configureVideo);
- this.videoWarning = { key: "host.fail.sfuParams" };
- return false;
- } catch {
- this.fail("sender-config");
- return false;
- }
+ this.fail("sender-config");
+ return false;
}
- });
+ }
}
getQualityWarning(): MediaFailure[] | null {
@@ -549,12 +562,23 @@ export class SfuPublisher {
intervalFramesEncoded: native.intervalFramesEncoded,
}),
});
- if (
- this.startupPending &&
- maxEncodedVideoFrames(report, video.id) >= STARTUP_VIDEO_ENCODED_FRAMES
- ) {
- this.startupPending = false;
- if (this.profile) void this.updateProfile(this.profile);
+ if (this.startupPending) {
+ const encodedFrames = maxEncodedVideoFrames(report, video.id);
+ if (encodedFrames === null) return;
+ if (this.startupFramesBaseline === null) {
+ this.startupFramesBaseline = encodedFrames;
+ } else if (
+ encodedFrames - this.startupFramesBaseline >=
+ STARTUP_VIDEO_ENCODED_FRAMES
+ ) {
+ this.startupPending = false;
+ // Re-read at execution: an in-flight user update commits this.profile
+ // first, so the recovery must not replay a stats-time snapshot.
+ void this.enqueue(async () => {
+ const profile = this.profile;
+ return profile ? this.applyProfile(profile) : false;
+ });
+ }
}
} catch (error) {
debugRtcFailure(peer.pc, error);
diff --git a/src/client/styles.css b/src/client/styles.css
index 647f6ef..0455bab 100644
--- a/src/client/styles.css
+++ b/src/client/styles.css
@@ -157,11 +157,10 @@ ul { margin: 0; padding: 0; list-style: none; }
@keyframes lr-chase { 0%, 100% { opacity: .25; } 50% { opacity: 1; } }
@keyframes lr-blink { 50% { opacity: .3; } }
-/* language + theme controls */
-.lr-lang {
- border: 2.5px solid var(--ink);
-}
-.lr-lang button {
+/* Keep the three familiar shortcuts; extra locales share one menu. */
+.lr-lang { --lang-count: 3; border: 2.5px solid var(--ink); }
+.lr-lang:has(.lr-lang-more) { --lang-count: 4; }
+.lr-lang > button {
position: relative;
display: inline-grid;
min-width: 38px;
@@ -177,10 +176,62 @@ ul { margin: 0; padding: 0; list-style: none; }
opacity: .68;
transition: opacity 120ms ease, color 140ms ease, background-color 140ms ease, transform 180ms var(--spring);
}
-.lr-lang button:is(:hover, :focus-visible) { opacity: 1; transform: translateY(-1px); }
-.lr-lang button:is(:hover, :focus-visible):not(.is-selected) { background: var(--wall-2); }
-.lr-lang button:active { transform: scale(.9); }
-.lr-lang button.is-selected { color: var(--paper); opacity: 1; }
+.lr-lang > button:is(:hover, :focus-visible) { opacity: 1; transform: translateY(-1px); }
+.lr-lang > button:is(:hover, :focus-visible):not(.is-selected) { background: var(--wall-2); }
+.lr-lang > button:active { transform: scale(.9); }
+.lr-lang > .is-selected { color: var(--paper); opacity: 1; }
+.lr-lang-more > span { display: inline-flex; align-items: center; gap: 2px; pointer-events: none; }
+.lr-lang-more .lr-glyph:last-child { transition: transform 180ms var(--spring); }
+.lr-header-controls:has(.lr-language-menu:popover-open) .lr-lang-more .lr-glyph:last-child { transform: rotate(180deg); }
+.lr-language-menu {
+ position: fixed;
+ inset: auto;
+ margin: 0;
+ width: min(252px, calc(100vw - 16px));
+ padding: 8px;
+ border: 2.5px solid var(--ink);
+ border-radius: var(--control-radius);
+ color: var(--ink);
+ background: var(--paper);
+ box-shadow: var(--control-shadow), 0 8px 24px rgb(16 26 44 / .16);
+ overflow: auto;
+ overscroll-behavior: contain;
+ font: 600 14px/1.4 var(--font);
+ transform-origin: top right;
+}
+.lr-language-menu:popover-open { animation: lr-language-open 180ms var(--spring); }
+@keyframes lr-language-open { from { opacity: 0; transform: translateY(-5px) scale(.97); } }
+.lr-language-menu button {
+ display: grid;
+ grid-template-columns: 34px minmax(0, 1fr) 16px;
+ align-items: center;
+ gap: 10px;
+ width: 100%;
+ min-height: 48px;
+ padding: 6px 10px;
+ border: 0;
+ border-radius: 12px;
+ background: transparent;
+ color: inherit;
+ text-align: start;
+ overflow-wrap: anywhere;
+ transition: background-color 140ms ease, color 140ms ease, transform 180ms var(--spring);
+}
+.lr-language-menu button + button { margin-top: 4px; }
+.lr-language-menu button:is(:hover, :focus-visible) { background: var(--wall-2); transform: translateX(2px); }
+.lr-language-menu button:focus-visible { outline-offset: -3px; }
+.lr-language-menu button:active { transform: scale(.98); }
+.lr-language-menu button[aria-checked="true"] { background: var(--ink); color: var(--paper); }
+.lr-language-mark {
+ display: grid;
+ place-items: center;
+ height: 32px;
+ border-radius: 9px;
+ background: color-mix(in srgb, currentColor 8%, transparent);
+ font-size: 12px;
+ font-weight: 800;
+}
+.lr-language-menu .is-unselected { visibility: hidden; }
/* ---------- room layout ---------- */
@@ -926,9 +977,14 @@ html[data-theme="dark"] .lr-invite-url { background: #1a2634; border-color: #334
.lr-btn.is-danger:is(:hover, :focus-visible):not(:disabled):not([aria-disabled="true"]) { background: color-mix(in srgb, var(--danger) 10%, var(--paper)); }
.lr-btn.is-on { border-color: var(--ink); background: var(--ink); color: var(--paper); }
.lr-btn .lr-cap { margin-left: 2px; font-size: 12.5px; font-weight: 600; }
-
.lr-cap { font-size: 12.5px; font-weight: 600; color: inherit; white-space: nowrap; }
+/* Diagnostics stay quiet until explicitly enabled; keep the shared input feedback. */
+.lr-header-diagnostic { display: inline-flex; }
+.lr-header-diagnostic .lr-btn { min-width: 40px; width: 40px; height: 40px; padding: 0; box-shadow: none; }
+.lr-header-diagnostic .lr-btn:not(.is-on) { background: transparent; border-color: transparent; color: var(--muted); }
+.lr-header-diagnostic .lr-cap { display: none; }
+
.lr-chip {
display: inline-flex;
align-items: center;
@@ -966,7 +1022,7 @@ html[data-theme="dark"] .lr-invite-url { background: #1a2634; border-color: #334
@keyframes lr-pop { 50% { transform: scale(1.06); } }
/* Segmented controls share a sliding thumb. Equal grid columns keep it aligned
- across captions; the selected button remains the only selection state. */
+ across captions; the selected control remains the only selection state. */
.lr-toggle, .lr-lang {
position: relative;
display: inline-grid;
@@ -987,10 +1043,11 @@ html[data-theme="dark"] .lr-invite-url { background: #1a2634; border-color: #334
pointer-events: none;
}
.lr-toggle::before { width: calc(50% - 4px); }
-.lr-lang::before { width: calc((100% - 8px) / 3); }
.lr-toggle[data-selected="private"]::before { transform: translateX(100%); }
-.lr-lang:has(button:nth-child(2).is-selected)::before { transform: translateX(100%); }
-.lr-lang:has(button:nth-child(3).is-selected)::before { transform: translateX(200%); }
+.lr-lang::before { width: calc((100% - 8px) / var(--lang-count)); }
+.lr-lang:has(> :nth-child(2).is-selected)::before { transform: translateX(100%); }
+.lr-lang:has(> :nth-child(3).is-selected)::before { transform: translateX(200%); }
+.lr-lang:has(> :nth-child(4).is-selected)::before { transform: translateX(300%); }
.lr-toggle button {
position: relative;
z-index: 1;
@@ -1391,6 +1448,7 @@ html[data-theme="dark"] .lr-door-glyph { color: #7d92a8; }
}
.lr-client-launch-title { font-size: 24px; text-align: center; }
.lr-client-launch-status { color: var(--ink); font-size: 14px; font-weight: 650; }
+.lr-client-launch-detail { max-width: 100%; overflow-wrap: anywhere; white-space: pre-wrap; font: 13px/1.6 var(--mono); }
.lr-client-update {
display: inline-flex;
align-items: center;
@@ -1450,6 +1508,16 @@ html[data-theme="dark"] .lr-door-glyph { color: #7d92a8; }
.lr-client-access input {
min-width: 0;
}
+.lr-client-lan { width: min(460px, 100%); min-width: 0; margin: 0; padding: 0; border: 0; }
+.lr-client-lan legend { padding: 0; font-size: 14px; font-weight: 750; }
+.lr-client-lan-hint { margin: 5px 0 10px; color: var(--muted); font-size: 12px; }
+.lr-client-lan-options { display: grid; gap: 8px; max-height: 192px; overflow-y: auto; padding: 3px; }
+.lr-client-lan-option { width: 100%; min-width: 0; justify-content: flex-start; border-radius: var(--control-radius); cursor: pointer; }
+.lr-client-lan-option input { flex: 0 0 auto; width: 18px; height: 18px; margin: 0; accent-color: var(--action); }
+.lr-client-lan-option span { display: grid; min-width: 0; gap: 3px; text-align: left; }
+.lr-client-lan-option strong { font-size: 13px; overflow-wrap: anywhere; }
+.lr-client-lan-option small { font: 12px var(--mono); }
+.lr-client-lan-option:has(:focus-visible) { outline: 3px solid var(--action); outline-offset: 1px; }
/* Only the wrapped access field spans the panel width; a wrapped pill or
button keeps its own size so the grid can center it. */
.lr-client-launch-panel > .lr-comic-tip-wrap:has(> .lr-client-access) {
@@ -1696,13 +1764,15 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); }
.lr-room.is-theater .lr-stage-notices > * { pointer-events: auto; }
@media (max-width: 360px) {
.lr-top { gap: 6px; padding-inline: 10px; }
+ .lr-brand { flex-shrink: 0; }
.lr-top-right { gap: 6px; }
+ .lr-top > .lr-top-right { min-width: 0; flex-wrap: wrap; justify-content: flex-end; }
.lr-leds { gap: 4px; padding: 6px; }
.lr-leds i { width: 8px; height: 8px; }
.lr-lang { padding: 2px; border-width: 2px; }
- .lr-lang::before { top: 2px; bottom: 2px; left: 2px; width: calc((100% - 4px) / 3); }
- .lr-lang button { min-width: 30px; height: 32px; padding-inline: 5px; }
- .lr-header-controls .lr-btn {
+ .lr-lang::before { top: 2px; bottom: 2px; left: 2px; width: calc((100% - 4px) / var(--lang-count)); }
+ .lr-lang > button { min-width: 30px; height: 32px; padding-inline: 5px; }
+ .lr-header-controls button.lr-btn {
min-width: 36px !important;
width: 36px;
height: 36px !important;
@@ -1865,7 +1935,7 @@ html[data-theme="dark"] .lr-switch::after { background: var(--ink); }
transition-duration: 0.01ms !important;
}
.lr-tv-static { animation: none; opacity: .35; }
- .lr-btn, .lr-chip, .lr-tile, .lr-client-mode, .lr-toggle button, .lr-lang button { transform: none !important; }
+ .lr-btn, .lr-chip, .lr-tile, .lr-client-mode, .lr-toggle button, .lr-lang > button, .lr-language-menu button { transform: none !important; }
.lr-route-edge.is-pending,
.lr-route-edge.is-recovering { animation: none; }
}
diff --git a/src/client/ui/copy.ts b/src/client/ui/copy.ts
index 3c0893d..e5338e4 100644
--- a/src/client/ui/copy.ts
+++ b/src/client/ui/copy.ts
@@ -1,8 +1,9 @@
// Locale selection and persistence. Catalog content lives in ../locales.
import { useSyncExternalStore } from "react";
import {
- catalogs,
- titleFrameCatalogs,
+ locales,
+ isLang,
+ resolveLang,
visualTitleFrames,
type CopyKey,
type Lang,
@@ -25,7 +26,7 @@ function readStored(): Partial {
const lang = window.localStorage.getItem(LANG_STORAGE_KEY);
const mode = window.localStorage.getItem(MODE_STORAGE_KEY);
return {
- ...(lang === "zh" || lang === "en" ? { lang } : {}),
+ ...(isLang(lang) ? { lang } : {}),
...(mode === "text" || mode === "vis" ? { vis: mode === "vis" } : {}),
};
} catch {
@@ -33,22 +34,15 @@ function readStored(): Partial {
}
}
-function detectLang(): Lang {
- const language = typeof navigator === "undefined"
- ? undefined
- : navigator.language?.split("-")[0]?.toLowerCase();
- return language === "zh" ? "zh" : "en";
-}
-
const state: CopyPrefs = {
- lang: detectLang(),
+ lang: resolveLang(typeof navigator === "undefined" ? undefined : navigator.language),
vis: false,
...readStored(),
};
function syncDocumentLanguage(): void {
if (typeof document !== "undefined") {
- document.documentElement.lang = state.lang === "zh" ? "zh-CN" : "en";
+ document.documentElement.lang = locales[state.lang].tag;
}
}
@@ -86,15 +80,15 @@ if (typeof window !== "undefined") {
});
}
-// Non-React setter (used by the mode pill and by tests).
+// Non-React setter for launch handoff and tests.
export const setCopy = commit;
export function isCopyKey(value: string): value is CopyKey {
- return Object.prototype.hasOwnProperty.call(catalogs.zh, value);
+ return Object.hasOwn(locales.zh.copy, value);
}
export function t(lang: Lang, key: CopyKey, vars?: Record): string {
- let value = catalogs[lang][key];
+ let value = locales[lang].copy[key];
if (vars) {
for (const [name, replacement] of Object.entries(vars)) {
value = value.replace(`{${name}}`, () => replacement);
@@ -108,7 +102,7 @@ export function getTitleFrames(
visual: boolean,
key: TitleFrameKey,
): readonly string[] {
- return visual ? visualTitleFrames[key] : titleFrameCatalogs[lang][key];
+ return visual ? visualTitleFrames[key] : locales[lang].titleFrames[key];
}
// Non-hook accessor for transient notices composed outside React render
diff --git a/src/client/ui/visual-kinds.ts b/src/client/ui/visual-kinds.ts
index 843bd91..6a54656 100644
--- a/src/client/ui/visual-kinds.ts
+++ b/src/client/ui/visual-kinds.ts
@@ -81,6 +81,7 @@ export type PlaybackHintKind =
| "hint-no-audio"
| "hint-fullscreen"
| "hint-fullscreen-exit"
+ | "hint-fullscreen-unavailable"
| "hint-pip"
| "hint-pip-exit"
| "hint-pip-unavailable";
diff --git a/src/client/webrtc/host-peer.ts b/src/client/webrtc/host-peer.ts
index 703ce0f..d7d6185 100644
--- a/src/client/webrtc/host-peer.ts
+++ b/src/client/webrtc/host-peer.ts
@@ -98,6 +98,9 @@ export class HostPeer {
private senderMutationTail: Promise = Promise.resolve();
private negotiationTail: Promise = Promise.resolve();
private startupVideoProfilePending: boolean;
+ // RTP frame counts can survive replaceTrack; 0 suits a fresh sender, while
+ // a replacement needs a valid current-track sample to establish its baseline.
+ private startupFramesBaseline: number | null = 0;
private connectedOnce = false;
private awaitingReconnect = false;
private paused = false;
@@ -280,6 +283,7 @@ export class HostPeer {
this.startupVideoProfilePending = needsStartupVideoProfile(
this.desiredProfile,
);
+ this.startupFramesBaseline = null;
this.snapshot = { ...this.snapshot, metrics: { ...EMPTY_METRICS } };
await this.configureSender(videoSender, audioSender, {
profile: startupVideoProfile(this.desiredProfile),
@@ -415,10 +419,19 @@ export class HostPeer {
return;
}
await this.enqueueNegotiation(() => this.acceptAnswer(payload));
- } else if (this.connection.remoteDescription) {
- await addRemoteIceCandidate(this.connection, payload.candidate);
- } else if (this.pendingCandidates.length < MAX_PENDING_CANDIDATES) {
- this.pendingCandidates.push(payload.candidate);
+ } else {
+ await this.enqueueNegotiation(async () => {
+ if (this.disposed) return;
+ // Native trickle events may precede a restart answer. The retained
+ // remote description still belongs to the previous ICE generation.
+ if (this.ordinaryAnswerEpoch !== null || !this.connection.remoteDescription) {
+ if (this.pendingCandidates.length < MAX_PENDING_CANDIDATES) {
+ this.pendingCandidates.push(payload.candidate);
+ }
+ } else {
+ await addRemoteIceCandidate(this.connection, payload.candidate);
+ }
+ });
}
} catch (error) {
this.setError(error, "host.err.createConnection");
@@ -751,12 +764,17 @@ export class HostPeer {
) {
return;
}
- if (
- this.startupVideoProfilePending &&
- (statsAccumulator.frames ?? 0) >= STARTUP_VIDEO_ENCODED_FRAMES
- ) {
- this.startupVideoProfilePending = false;
- void this.updateProfile(this.desiredProfile);
+ const encodedFrames = statsAccumulator.frames;
+ if (this.startupVideoProfilePending && encodedFrames !== null) {
+ if (this.startupFramesBaseline === null) {
+ this.startupFramesBaseline = encodedFrames;
+ } else if (
+ encodedFrames - this.startupFramesBaseline >=
+ STARTUP_VIDEO_ENCODED_FRAMES
+ ) {
+ this.startupVideoProfilePending = false;
+ void this.updateProfile(this.desiredProfile);
+ }
}
const deliveredMetrics = this.pooledVideo?.metrics(metrics) ?? metrics;
this.snapshot = { ...this.snapshot, metrics: deliveredMetrics };
diff --git a/src/client/webrtc/nat-prediction.ts b/src/client/webrtc/nat-prediction.ts
index b114324..c02beb1 100644
--- a/src/client/webrtc/nat-prediction.ts
+++ b/src/client/webrtc/nat-prediction.ts
@@ -386,7 +386,9 @@ export class NatPredictionCandidateEmitter {
next.usernameFragment &&
next.usernameFragment !== this.usernameFragment
) {
- this.completeBatch();
+ // An ICE restart retires old observations; an unscoped end marker here
+ // would end the new remote generation before its candidates arrive.
+ this.discard();
}
if (!this.batch) {
this.usernameFragment = next.usernameFragment ?? null;
diff --git a/src/client/webrtc/stats.ts b/src/client/webrtc/stats.ts
index ffd93fa..fb5f9b1 100644
--- a/src/client/webrtc/stats.ts
+++ b/src/client/webrtc/stats.ts
@@ -473,8 +473,9 @@ export function decodedVideoFrames(
export function maxEncodedVideoFrames(
report: RTCStatsReport,
trackIdentifier: string,
-): number {
- let frames = 0;
+): number | null {
+ // An absent current-track counter is unknown, not a fresh encoder at zero.
+ let frames: number | null = null;
report.forEach((raw) => {
const record = raw as StatsRecord;
if (
@@ -483,7 +484,10 @@ export function maxEncodedVideoFrames(
record.isRemote !== true &&
mediaTrackIdentifier(report, record, "send") === trackIdentifier
) {
- frames = Math.max(frames, numberValue(record, "framesEncoded") ?? 0);
+ const encodedFrames = numberValue(record, "framesEncoded");
+ if (encodedFrames !== null) {
+ frames = Math.max(frames ?? 0, encodedFrames);
+ }
}
});
return frames;
diff --git a/src/client/webrtc/viewer-relay.ts b/src/client/webrtc/viewer-relay.ts
index a190e7c..159488d 100644
--- a/src/client/webrtc/viewer-relay.ts
+++ b/src/client/webrtc/viewer-relay.ts
@@ -4,6 +4,7 @@ import type {
SignalPayload,
} from "../../shared/protocol";
import type { QualityProfile } from "../media/quality";
+import { debugEvent } from "../lib/debug";
import { BrowserEncodingPool } from "../media/browser-encoding-pool";
import type { PeerSnapshot } from "../types";
import {
@@ -23,6 +24,7 @@ export interface ViewerRelayEvents {
sendSignal: (peerId: string, payload: SignalPayload) => boolean;
onUpdate?: (snapshot: PeerSnapshot | null) => void;
onSenderUpdate?: (snapshot: PeerSnapshot, revision: number | null) => void;
+ onPreparedChildFailed?: (revision: number, connectionId: string) => void;
}
interface PreparedChild {
revision: number;
@@ -92,17 +94,22 @@ export class ViewerRelay {
): boolean {
const stream = this.stream;
const planned = [...new Set(plannedChildPeerIds)];
- if (
- this.disposed ||
- (!stream && (this.peerFactory?.requiresStream ?? true)) ||
- candidate.transport === "sfu" ||
- !planned.includes(candidate.childPeerId) ||
- planned.length !== plannedChildPeerIds.length ||
- planned.length < this.childPeerIds.length ||
- planned.length > this.childPeerIds.length + 1 ||
- planned.length > this.maxMediaEdges ||
- this.childPeerIds.some((peerId) => !planned.includes(peerId))
- ) {
+ let refusal: string | null = null;
+ if (this.disposed) refusal = "disposed";
+ else if (!stream && (this.peerFactory?.requiresStream ?? true)) refusal = "no-stream";
+ else if (candidate.transport === "sfu") refusal = "sfu-transport";
+ else if (!planned.includes(candidate.childPeerId)) refusal = "child-unplanned";
+ else if (planned.length !== plannedChildPeerIds.length) refusal = "plan-duplicate";
+ else if (planned.length < this.childPeerIds.length) refusal = "plan-shrinks";
+ else if (planned.length > this.childPeerIds.length + 1) refusal = "plan-skips";
+ else if (planned.length > this.maxMediaEdges) refusal = "over-capacity";
+ else if (this.childPeerIds.some((peerId) => !planned.includes(peerId))) refusal = "drops-current";
+ if (refusal !== null) {
+ debugEvent("webrtc", "relay-prepare-refused", {
+ revision,
+ childPeerId: candidate.childPeerId,
+ reason: refusal,
+ });
this.discardPreparedChild();
return false;
}
@@ -643,11 +650,15 @@ export class ViewerRelay {
private failPreparedChild(peer: HostMediaPeer): void {
const prepared = this.preparedChild;
- if (prepared?.peer !== peer) {
+ if (prepared?.peer !== peer || prepared.failed) {
return;
}
prepared.failed = true;
peer.dispose();
+ this.events.onPreparedChildFailed?.(
+ prepared.revision,
+ prepared.candidate.connectionId,
+ );
}
private disposePeers(): void {
diff --git a/tests/browser-debug.test.ts b/tests/browser-debug.test.ts
index ceac7a0..c6f0107 100644
--- a/tests/browser-debug.test.ts
+++ b/tests/browser-debug.test.ts
@@ -20,9 +20,38 @@ afterEach(() => {
});
describe("opt-in Browser diagnostics", () => {
+ it("keeps explicit diagnostics through room and App entry without copying source credentials", async () => {
+ const page = browser("?debug=1&token=source-secret");
+ const { withBrowserDebug } = await import("../src/client/lib/debug");
+ const { clientLaunchURL, roomRouteForExplicitEntry, readViewerRoute } = await import("../src/client/lib/session");
+ const launched = new URL(clientLaunchURL(
+ "https://site.example/?existing=1#client-access=target-capability&piik-client=1",
+ { lang: "en", vis: false, theme: "light" },
+ ));
+ expect(launched.origin).toBe("https://site.example");
+ expect(launched.search).toBe("?existing=1&debug=1");
+ expect(new URLSearchParams(launched.hash.slice(1)).get("client-access")).toBe("target-capability");
+ expect(launched.href).not.toContain("source-secret");
+ expect(new URL(clientLaunchURL("https://site.example/",
+ { lang: "en", vis: false, theme: "light" }, false)).search).toBe("");
+ expect(new URL(clientLaunchURL("https://site.example/",
+ { lang: "en", vis: false, theme: "light" }, true)).search).toBe("?debug=1");
+ expect(roomRouteForExplicitEntry("5678")).toBe("https://private.example/r/5678?debug=1");
+ expect(withBrowserDebug("/join")).toBe("https://private.example/join?debug=1");
+ const replaceState = vi.fn();
+ Object.assign(page, { history: { state: null, replaceState } });
+ const grant = `${"a".repeat(21)}A`;
+ page.location.hash = `#v=${grant}`;
+ expect(readViewerRoute()?.viewerGrant).toBe(grant);
+ // Consuming the invitation removes its credential and unrelated query
+ // parameters while keeping diagnostics enabled for a later reload.
+ expect(replaceState).toHaveBeenCalledWith(null, "", "https://private.example/r/1234?debug=1");
+ });
+
it("does no diagnostic collection without the explicit flag", async () => {
const page = browser("");
const debug = await import("../src/client/lib/debug");
+ expect(debug.withBrowserDebug("/join")).toBe("/join");
expect(debug.installBrowserDebug()).toBeUndefined();
debug.debugEvent("capture", "requested", { resolution: "1080p" });
debug.debugError("capture", "failed", new Error("private detail"));
@@ -127,6 +156,36 @@ describe("opt-in Browser diagnostics", () => {
expect(report).not.toContain("private-certificate");
});
+ it("keeps selected ICE evidence when many interface pairs exceed the raw report limit", async () => {
+ browser();
+ const debug = await import("../src/client/lib/debug");
+ const rtc = await import("../src/client/lib/debug-webrtc");
+ const connection = Object.assign(new EventTarget(), { getSenders: () => [], getReceivers: () => [] });
+ const peer = connection as unknown as RTCPeerConnection;
+ rtc.observeDebugConnection(peer, { connectionId: "many-interfaces", role: "receive" });
+ const records = new Map();
+ for (let index = 0; index < 70; index++) {
+ records.set(`pair-${index}`, { type: "candidate-pair", state: "failed" });
+ }
+ records.set("chosen", { type: "candidate-pair", state: "succeeded",
+ localCandidateId: "local", remoteCandidateId: "remote", requestsSent: 4, responsesReceived: 3 });
+ records.set("local", { type: "local-candidate", candidateType: "host", protocol: "udp", address: "192.0.2.1" });
+ records.set("remote", { type: "remote-candidate", candidateType: "srflx", foundation: "sp1", address: "192.0.2.2" });
+ records.set("transport", { type: "transport", selectedCandidatePairId: "chosen" });
+ rtc.debugRtcStats(peer, records as unknown as RTCStatsReport);
+ const report = JSON.parse(await debug.exportBrowserDebug());
+ const sample = report.events.find((entry: { event: string }) => entry.event === "stats");
+ expect(report.partial).toBe(true);
+ expect(sample.details.stats).toHaveLength(64);
+ expect(sample.details.ice.pairs).toMatchObject({ total: 71, failed: 70, succeeded: 1 });
+ expect(sample.details.ice.selected).toEqual([{
+ state: "succeeded", dtlsState: null,
+ localType: "host", remoteType: "srflx", protocol: "udp", natTraversalPath: "predicted",
+ requestsSent: 4, responsesReceived: 3, currentRoundTripTime: null,
+ }]);
+ expect(JSON.stringify(sample.details.ice)).not.toContain("192.0.2.");
+ });
+
it("correlates actual Native request and response using their existing wire identity", async () => {
const page = browser();
const token = "x".repeat(43);
diff --git a/tests/copy.test.ts b/tests/copy.test.ts
index 7915db0..e9c0a8f 100644
--- a/tests/copy.test.ts
+++ b/tests/copy.test.ts
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { initialLanguage, rememberLanguage } from "../site/assets/language.js";
+import { locales } from "../src/client/locales";
import {
getTitleFrames,
@@ -19,6 +20,11 @@ describe("copy catalog", () => {
it.each([
["zh-CN", "zh:false"],
["zh-TW", "zh:false"],
+ ["zh-HK", "zh:false"],
+ ["zh-SG", "zh:false"],
+ ["zh-Hans", "zh:false"],
+ ["zh-Hant", "zh:false"],
+ ["zh-Hant-HK", "zh:false"],
["en-US", "en:false"],
["ja-JP", "en:false"],
[undefined, "en:false"],
@@ -80,12 +86,62 @@ describe("copy catalog", () => {
expect(t("zh", "viewer.title", { name: "$&" })).toBe("$& 的屏幕");
});
- it("keeps zh and en key sets identical", () => {
- // Type-level parity is enforced by Record;
- // this guards runtime drift if the catalog shape changes.
- for (const key of ["host.start", "viewer.msg.playing", "stats.more"] as CopyKey[]) {
- expect(t("zh", key).length).toBeGreaterThan(0);
- expect(t("en", key).length).toBeGreaterThan(0);
+ it("keeps every registered translation complete with matching placeholders", () => {
+ const keys = Object.keys(locales.zh.copy) as CopyKey[];
+ const placeholders = (value: string) => (value.match(/\{\w+\}/g) ?? []).sort();
+ for (const [lang, locale] of Object.entries(locales)) {
+ expect(Object.keys(locale.copy).sort(), lang).toEqual([...keys].sort());
+ for (const key of keys) {
+ expect(locale.copy[key].trim(), `${lang}: ${key}`).not.toBe("");
+ expect(placeholders(locale.copy[key]), `${lang}: ${key}`)
+ .toEqual(placeholders(locales.zh.copy[key]));
+ }
+ for (const frames of Object.values(locale.titleFrames)) {
+ expect(frames.length, lang).toBeGreaterThan(0);
+ expect(frames.every((frame: string) => frame.trim().length > 0), lang).toBe(true);
+ }
+ }
+ });
+
+ it("carries a contributed locale through selection and App handoff", async () => {
+ vi.resetModules();
+ const registry = await import("../src/client/locales");
+ // Exercise registration without shipping an unreviewed translation.
+ Object.assign(registry.locales, {
+ fr: { ...registry.locales.en, name: "Français", short: "FR", tag: "fr" },
+ });
+ try {
+ const stored = new Map([["piik:ui-lang", "fr"]]);
+ vi.stubGlobal("navigator", { language: "en-US" });
+ vi.stubGlobal("document", { documentElement: { lang: "en" } });
+ vi.stubGlobal("window", {
+ localStorage: {
+ getItem: (key: string) => stored.get(key) ?? null,
+ setItem: (key: string, value: string) => stored.set(key, value),
+ },
+ addEventListener: () => {},
+ location: new URL("https://site.example/"),
+ history: { state: null, replaceState: vi.fn() },
+ });
+ const copy = await import("../src/client/ui/copy");
+ const { clientLaunchURL, takeClientLaunchBootstrap } = await import("../src/client/lib/session");
+
+ expect(registry.resolveLang("FR-ca")).toBe("fr");
+ expect(registry.resolveLang("zh-CN")).toBe("zh");
+ expect(registry.isLang("toString")).toBe(false);
+ expect(copy.currentLang()).toBe("fr");
+ expect(document.documentElement.lang).toBe("fr");
+ expect(copy.t(copy.currentLang(), "common.host")).toBe("Host");
+
+ const presentation = { lang: copy.currentLang(), vis: false, theme: null };
+ window.location.hash = new URL(clientLaunchURL("https://site.example/#piik-client=1", presentation)).hash;
+ expect(takeClientLaunchBootstrap().presentation).toEqual(presentation);
+ expect(registry.consoleLanguage(presentation.lang, false)).toBe("en");
+ expect(registry.consoleLanguage(presentation.lang, true)).toBe("vis");
+ expect(registry.consoleLanguage("zh", false)).toBe("zh");
+ } finally {
+ Reflect.deleteProperty(registry.locales, "fr");
+ vi.resetModules();
}
});
@@ -129,7 +185,13 @@ describe("website language", () => {
afterEach(() => vi.unstubAllGlobals());
it.each([
+ ["zh-CN", null, "", "zh-CN"],
["zh-TW", null, "", "zh-CN"],
+ ["zh-HK", null, "", "zh-CN"],
+ ["zh-SG", null, "", "zh-CN"],
+ ["zh-Hans", null, "", "zh-CN"],
+ ["zh-Hant", null, "", "zh-CN"],
+ ["zh-Hant-HK", null, "", "zh-CN"],
["en-US", null, "", "en"],
["ja-JP", null, "", "en"],
["ja-JP", "zh-CN", "", "zh-CN"],
diff --git a/tests/host-peer-tracks.test.ts b/tests/host-peer-tracks.test.ts
index ae41e01..11d9412 100644
--- a/tests/host-peer-tracks.test.ts
+++ b/tests/host-peer-tracks.test.ts
@@ -1083,6 +1083,40 @@ describe("HostPeer source replacement", () => {
);
});
+ it("retains restart candidates that arrive before the new answer", async () => {
+ const peer = createPeer(createStream(createTrack("video", "video"), null));
+ await peer.start();
+ const connection = FakePeerConnection.latest!;
+ await peer.acceptSignal({
+ kind: "description", connectionId: peer.connectionId,
+ description: { type: "answer", sdp: "previous-answer" },
+ });
+ await expect(peer.restartIce()).resolves.toBe(true);
+ const nextCandidate = {
+ candidate: "candidate:next 1 udp 2122260223 192.0.2.8 50001 typ host ufrag next",
+ usernameFragment: "next", sdpMid: "0", sdpMLineIndex: 0,
+ };
+ await peer.acceptSignal({
+ kind: "candidate", connectionId: peer.connectionId, candidate: nextCandidate,
+ });
+ // Native receiver events can precede the receive-offer RPC response.
+ expect(connection.addedIceCandidates).toEqual([]);
+ connection.deferRemoteDescriptionCall = 2;
+ const answer = peer.acceptSignal({
+ kind: "description", connectionId: peer.connectionId,
+ description: { type: "answer", sdp: "next-answer" },
+ });
+ await vi.waitFor(() => expect(connection.remoteDescriptionCallCount).toBe(2));
+ const end = peer.acceptSignal({
+ kind: "candidate", connectionId: peer.connectionId, candidate: null,
+ });
+ connection.releaseDeferredRemoteDescription();
+ await Promise.all([answer, end]);
+ expect(connection.remoteDescription?.sdp).toBe("next-answer");
+ expect(connection.addedIceCandidates).toEqual([nextCandidate, null]);
+ peer.dispose();
+ });
+
it("enables the selected balanced profile after startup frames", async () => {
const video = createTrack("video", "video");
const peer = createPeer(createStream(video, createTrack("audio", "audio")));
@@ -1131,6 +1165,68 @@ describe("HostPeer source replacement", () => {
expect(connection.senders[1]?.setParameters).toHaveBeenCalledOnce();
});
+ it.each([0, 250])("counts new source frames from a valid baseline of %s", async (baseline) => {
+ const video = createTrack("video", "video");
+ const peer = createPeer(createStream(video, createTrack("audio", "audio")));
+
+ await expect(peer.start()).resolves.toBe(true);
+ const connection = FakePeerConnection.latest!;
+ await acceptPeerAnswer(peer);
+ expect(
+ connection.senders[0]?.setParameters.mock.calls.at(-1)?.[0],
+ ).toMatchObject({ degradationPreference: "balanced" });
+
+ const nextVideo = createTrack("video", "next-video");
+ await expect(
+ peer.replaceStream(createStream(nextVideo, null)),
+ ).resolves.toBe(true);
+ expect(
+ connection.senders[0]?.setParameters.mock.calls.at(-1)?.[0],
+ ).toMatchObject({ degradationPreference: "maintain-resolution" });
+ const configureCalls =
+ connection.senders[0]!.setParameters.mock.calls.length;
+
+ const staleReport = sendStatsReport({
+ bytesSent: 10_000,
+ framesEncoded: 250,
+ timestamp: 1_000,
+ qualityLimitationReason: "none",
+ trackIdentifier: video.id,
+ });
+ const missingCount = sendStatsReport({
+ bytesSent: 10_000,
+ framesEncoded: 0,
+ timestamp: 1_000,
+ qualityLimitationReason: "none",
+ trackIdentifier: connection.senders[0]!.track!.id,
+ });
+ delete missingCount.get("outbound-video").framesEncoded;
+ // Missing/currently unmatched stats cannot seed the new baseline as zero.
+ for (const report of [emptyStatsReport(), staleReport, missingCount]) {
+ connection.statsReports.push(report);
+ statsCallbacks.at(-1)!();
+ await new Promise((resolve) => setImmediate(resolve));
+ }
+
+ // A valid zero is different from missing stats. An existing RTP stream may
+ // instead retain a large cumulative count across replaceTrack.
+ await completeVideoStartup(connection, baseline);
+ expect(connection.senders[0]!.setParameters.mock.calls.length).toBe(
+ configureCalls,
+ );
+ await completeVideoStartup(connection, baseline + 4);
+ expect(connection.senders[0]!.setParameters.mock.calls.length).toBe(
+ configureCalls,
+ );
+ await completeVideoStartup(connection, baseline + 5);
+ expect(connection.senders[0]!.setParameters.mock.calls.length).toBe(
+ configureCalls + 1,
+ );
+ expect(
+ connection.senders[0]?.setParameters.mock.calls.at(-1)?.[0],
+ ).toMatchObject({ degradationPreference: "balanced" });
+ });
+
it("applies the latest pre-answer profile once negotiation completes", async () => {
const peer = createPeer(
createStream(
@@ -1733,7 +1829,8 @@ describe("Host provisional child runtime ownership", () => {
});
it("disposes stale prepares on replacement, rollback, and auth reset", async () => {
- const owner = new HostProvisionalChild({ sendSignal: () => true });
+ const onPreparedChildFailed = vi.fn();
+ const owner = new HostProvisionalChild({ sendSignal: () => true, onPreparedChildFailed });
const stream = createStream(createTrack("video", "host-cleanup-video"), null);
expect(owner.prepare(hostProvisionalInput(7, ["first-probe"], stream))).toBe(true);
@@ -1755,6 +1852,27 @@ describe("Host provisional child runtime ownership", () => {
owner.discard();
expect(authProbe.connectionState).toBe("closed");
expect(FakePeerConnection.activeCount).toBe(0);
+ expect(onPreparedChildFailed).not.toHaveBeenCalled();
+ });
+
+ it("reports failed Host preparation once and fences retired attempts", async () => {
+ const onPreparedChildFailed = vi.fn();
+ const owner = new HostProvisionalChild({ sendSignal: () => true, onPreparedChildFailed });
+ const stream = createStream(createTrack("video", "host-failure-video"), null);
+ FakePeerConnection.offersFailing = 1;
+ owner.prepare(hostProvisionalInput(7, ["first"], stream));
+ await vi.waitFor(() => expect(onPreparedChildFailed).toHaveBeenCalledExactlyOnceWith(7, "candidate-connection-7"));
+
+ owner.prepare(hostProvisionalInput(8, ["second"], stream));
+ const connection = FakePeerConnection.latest!;
+ connection.connectionState = "failed";
+ connection.dispatchEvent(new Event("connectionstatechange"));
+ connection.dispatchEvent(new Event("connectionstatechange"));
+ expect(onPreparedChildFailed).toHaveBeenCalledTimes(2);
+ expect(onPreparedChildFailed).toHaveBeenLastCalledWith(8, "candidate-connection-8");
+ owner.discard();
+ connection.dispatchEvent(new Event("connectionstatechange"));
+ expect(onPreparedChildFailed).toHaveBeenCalledTimes(2);
});
it("updates the prepared stream before promotion", async () => {
@@ -2083,10 +2201,11 @@ describe("ViewerRelay downstream ownership", () => {
});
it("cleans prepared children on failure, replacement, rollback, session reset, and share stop", async () => {
+ const onPreparedChildFailed = vi.fn();
const relay = new ViewerRelay(
{ iceServers: [] },
QUALITY_PROFILES["720p30"],
- { sendSignal: () => true },
+ { sendSignal: () => true, onPreparedChildFailed },
);
relay.setStream(createStream(createTrack("video", "rollback-video"), null));
expect(relay.prepareChild(7, routeCandidate(7, "first-probe"), ["first-probe"])).toBe(true);
@@ -2097,6 +2216,7 @@ describe("ViewerRelay downstream ownership", () => {
expect(firstProbe.connectionState).toBe("closed");
relay.activateChildren(9, []);
expect(secondProbe.connectionState).toBe("closed");
+ expect(onPreparedChildFailed).not.toHaveBeenCalled();
FakePeerConnection.offersFailing = 1;
const instancesBeforeFailure = FakePeerConnection.instances.length;
@@ -2104,6 +2224,7 @@ describe("ViewerRelay downstream ownership", () => {
const failedProbe = FakePeerConnection.latest!;
await vi.waitFor(() => expect(failedProbe.connectionState).toBe("closed"));
expect(FakePeerConnection.instances).toHaveLength(instancesBeforeFailure + 1);
+ expect(onPreparedChildFailed).toHaveBeenCalledExactlyOnceWith(10, "relay-candidate-10");
expect(relay.prepareChild(11, routeCandidate(11, "session-probe"), ["session-probe"])).toBe(true);
const sessionProbe = FakePeerConnection.latest!;
@@ -2115,6 +2236,33 @@ describe("ViewerRelay downstream ownership", () => {
relay.stop();
expect(stoppedProbe.connectionState).toBe("closed");
relay.dispose();
+ expect(onPreparedChildFailed).toHaveBeenCalledTimes(1);
+ });
+
+ it("reports a failed prepared transport once without blaming retained children", async () => {
+ const onPreparedChildFailed = vi.fn();
+ const relay = new ViewerRelay(
+ { iceServers: [] },
+ QUALITY_PROFILES["720p30"],
+ { sendSignal: () => true, onPreparedChildFailed },
+ );
+ relay.setStream(createStream(createTrack("video", "failure-video"), null));
+ relay.prepareChild(1, routeCandidate(1, "retained"), ["retained"]);
+ await vi.waitFor(() => expect(FakePeerConnection.latest).not.toBeNull());
+ const retained = FakePeerConnection.latest!;
+ retained.connectionState = "connected";
+ relay.activateChildren(1, ["retained"]);
+ relay.prepareChild(2, routeCandidate(2, "candidate"), ["retained", "candidate"]);
+ const candidate = FakePeerConnection.latest!;
+ candidate.connectionState = "failed";
+ candidate.dispatchEvent(new Event("connectionstatechange"));
+ candidate.dispatchEvent(new Event("connectionstatechange"));
+ expect(onPreparedChildFailed).toHaveBeenCalledExactlyOnceWith(2, "relay-candidate-2");
+ expect(retained.connectionState).toBe("connected");
+ relay.activateChildren(3, ["retained"]);
+ candidate.dispatchEvent(new Event("connectionstatechange"));
+ expect(onPreparedChildFailed).toHaveBeenCalledTimes(1);
+ relay.dispose();
});
it("keeps the prepared connection when its stream replacement succeeds", async () => {
diff --git a/tests/nat-prediction.test.ts b/tests/nat-prediction.test.ts
index 0aa3d1e..758e861 100644
--- a/tests/nat-prediction.test.ts
+++ b/tests/nat-prediction.test.ts
@@ -296,4 +296,45 @@ describe("NAT prediction ICE adapter", () => {
).toBe(false);
expect(sent.filter((value) => value !== null)).toHaveLength(3);
});
+
+ it("retires an unfinished generation without ending the restarted ICE session", () => {
+ const sent: SignalCandidate[] = [];
+ const survey = [
+ "stun:share.example.test:3478",
+ "stun:survey-a.example.test:3478",
+ "stun:survey-b.example.test:3478",
+ ];
+ const emitter = new NatPredictionCandidateEmitter(
+ true,
+ (value) => sent.push(value),
+ new Set(survey),
+ );
+ const emit = (port: number, usernameFragment: string, url: string) => {
+ emitter.add({
+ ...candidate(port),
+ usernameFragment,
+ url,
+ } as unknown as RTCIceCandidate);
+ };
+
+ emit(40_000, "previous", survey[0]!);
+ emit(40_003, "previous", survey[1]!);
+ emit(40_006, "restarted", survey[0]!);
+ expect(sent).toHaveLength(3);
+ expect(sent.every((value) => value !== null)).toBe(true);
+
+ emit(40_009, "restarted", survey[1]!);
+ emit(40_012, "restarted", survey[2]!);
+ const predictions = sent.filter((value) =>
+ value?.candidate.startsWith("candidate:sp") ||
+ value?.candidate.startsWith("candidate:sm"),
+ );
+ expect(predictions).toHaveLength(MAX_NAT_PREDICTION_CANDIDATES);
+ expect(predictions.every((value) => value?.usernameFragment === "restarted")).toBe(true);
+
+ emitter.gatheringComplete();
+ emitter.add(null);
+ expect(sent.filter((value) => value === null)).toHaveLength(1);
+ expect(sent.at(-1)).toBeNull();
+ });
});
diff --git a/tests/native-viewer-peer.test.ts b/tests/native-viewer-peer.test.ts
index 6576743..9ef4994 100644
--- a/tests/native-viewer-peer.test.ts
+++ b/tests/native-viewer-peer.test.ts
@@ -70,6 +70,128 @@ it("does not create a browser backend after deferred native discovery is dispose
expect(peer.getConnectionIdentity()).toBeNull();
});
+it.each(["route", "viewer"] as const)("recovers an answer signaling failure without disabling native (%s)", async (recoveryOwner) => {
+ vi.stubGlobal("window", globalThis);
+ vi.stubGlobal("MediaStream", class { getTracks() { return []; } });
+ vi.stubGlobal("RTCPeerConnection", class { close() {} });
+ const receiveOffer = vi.fn(async () => ({
+ answer: { type: "answer", sdp: "v=0\r\n" }, audio: false, codec: "vp8",
+ }));
+ const closeReceiver = vi.fn(async () => undefined);
+ const client = {
+ receiveOffer,
+ onEvent: () => () => undefined,
+ onClose: () => () => undefined,
+ closeReceiver,
+ closeEdge: vi.fn(async () => undefined),
+ } as unknown as NativeClient;
+ let available = true;
+ const acquire = vi.fn(async () => available ? client : null);
+ const unavailable = vi.fn(() => { available = false; });
+ const restart = vi.fn(() => true);
+ const exhausted = vi.fn(() => true);
+ let signalOnline = false;
+ const peer = new NativeCapableViewerPeer(
+ { iceServers: [] },
+ {
+ sendSignal: () => signalOnline,
+ sendRestartRequest: restart,
+ onStream: () => undefined,
+ onUpdate: () => undefined,
+ onRecoveryExhausted: exhausted,
+ },
+ { recoveryOwner },
+ acquire,
+ unavailable,
+ "session",
+ 2,
+ );
+ const offer = (connectionId: string): SignalPayload => ({
+ kind: "description", connectionId,
+ description: { type: "offer", sdp: "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=sendonly\r\na=rtpmap:96 VP8/90000\r\n" },
+ });
+ try {
+ // A transient room-signaling outage at the answer must not disable
+ // Native reception for the session: close only this receiver and run
+ // the ordinary recovery path.
+ await peer.acceptSignal("parent", offer("first"));
+ expect(unavailable).not.toHaveBeenCalled();
+ expect(peer.nativeSource).toBeNull();
+ expect(closeReceiver).toHaveBeenCalledWith("session", "first");
+ if (recoveryOwner === "viewer") {
+ expect(restart).toHaveBeenCalledWith("parent", "first", true);
+ expect(exhausted).not.toHaveBeenCalled();
+ } else {
+ expect(exhausted).toHaveBeenCalledWith("parent", "first");
+ expect(restart).not.toHaveBeenCalled();
+ }
+
+ // With signaling back online, the next offer re-acquires the native path.
+ signalOnline = true;
+ vi.spyOn(NativeMediaBridge.prototype, "start").mockResolvedValue(
+ new (class {})() as never,
+ );
+ await peer.acceptSignal("parent", offer("second"));
+ expect(acquire).toHaveBeenCalledTimes(2);
+ expect(receiveOffer).toHaveBeenCalledTimes(2);
+ expect(unavailable).not.toHaveBeenCalled();
+ } finally {
+ peer.dispose();
+ }
+});
+
+it("still disables native on a genuine failure after a signaling failure", async () => {
+ vi.stubGlobal("window", globalThis);
+ vi.stubGlobal("MediaStream", class { getTracks() { return []; } });
+ vi.stubGlobal("RTCPeerConnection", class { close() {} });
+ let eventHandler: ((event: unknown) => void) | null = null;
+ const receiveOffer = vi.fn(async () => ({
+ answer: { type: "answer", sdp: "v=0\r\n" }, audio: false, codec: "vp8",
+ }));
+ const client = {
+ receiveOffer,
+ onEvent: (handler: (event: unknown) => void) => {
+ eventHandler = handler;
+ return () => undefined;
+ },
+ onClose: () => () => undefined,
+ closeReceiver: vi.fn(async () => undefined),
+ closeEdge: vi.fn(async () => undefined),
+ } as unknown as NativeClient;
+ const unavailable = vi.fn();
+ const peer = new NativeCapableViewerPeer(
+ { iceServers: [] },
+ {
+ sendSignal: () => false,
+ sendRestartRequest: () => true,
+ onStream: () => undefined,
+ onUpdate: () => undefined,
+ onRecoveryExhausted: () => true,
+ },
+ {},
+ async () => client,
+ unavailable,
+ "session",
+ 2,
+ );
+ try {
+ await peer.acceptSignal("parent", {
+ kind: "description",
+ connectionId: "first",
+ description: { type: "offer", sdp: "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=sendonly\r\na=rtpmap:96 VP8/90000\r\n" },
+ });
+ expect(unavailable).not.toHaveBeenCalled();
+
+ // A genuine native failure on the same connection must still disable
+ // Native for the session: the signaling path must not have latched the
+ // failure state.
+ eventHandler!({ shareId: "session", type: "share-ended", failed: true });
+ expect(unavailable).toHaveBeenCalledOnce();
+ } finally {
+ peer.dispose();
+ }
+});
+
it.each(["route", "viewer"] as const)("keeps %s bridge failure unavailable across replacement peers", async (recoveryOwner) => {
vi.stubGlobal("window", globalThis);
vi.stubGlobal("MediaStream", class { getTracks() { return []; } });
diff --git a/tests/publish-release.test.ts b/tests/publish-release.test.ts
index f86b6f3..e42c6b1 100644
--- a/tests/publish-release.test.ts
+++ b/tests/publish-release.test.ts
@@ -25,9 +25,10 @@ for (const target of ["server", "windows-amd64", "linux-amd64", "darwin-arm64"])
}
afterAll(() => rmSync(directory, { recursive: true, force: true }));
-const completeAssets = readdirSync(directory).map((name) => ({ name, state: "uploaded" }));
+const packageNames = readdirSync(directory).filter((name) => /\.(zip|tar\.gz)$/.test(name));
+const completeAssets = packageNames.map((name) => ({ name, state: "uploaded" }));
type Release = { tag_name: string; target_commitish: string; draft: boolean;
- prerelease: boolean; assets: typeof completeAssets };
+ prerelease: boolean; body?: string; assets: typeof completeAssets };
const release = (draft: boolean): Release => ({
tag_name: version, target_commitish: revision, draft, prerelease: false, assets: completeAssets,
});
@@ -36,7 +37,7 @@ describe("Gitee mirror publication", () => {
const marker = ``;
const api = "https://gitee.com/api/v5/repos/TNTcraftHIM/Piik";
const download = `https://gitee.com/TNTcraftHIM/Piik/releases/download/${version}/`;
- const sourceAssets = readdirSync(directory).map((name) => {
+ const sourceAssets = packageNames.map((name) => {
const bytes = readFileSync(join(directory, name));
return { name, size: bytes.length, digest: `sha256:${hash(bytes)}`, state: "uploaded" };
});
@@ -138,11 +139,11 @@ describe("Gitee mirror publication", () => {
});
async function publish(releases: Release[], tag: "absent" | "annotated" | "wrong" | "api-error",
- failureAt?: "notes" | "create") {
+ failureAt?: "notes" | "create" | "upload", inspectBody?: (body: string) => void) {
const mutations: string[][] = [];
const copy = "### 更新\n\n- Share `windows` and $literal text.\n";
let notesPath: string | undefined;
- notes.mockImplementation(() => {
+ notes.mockReset().mockImplementation(() => {
if (failureAt === "notes") throw new Error("Missing release notes");
return copy;
});
@@ -150,13 +151,26 @@ async function publish(releases: Release[], tag: "absent" | "annotated" | "wrong
command.mockImplementation((_program, args) => {
if (args[0] === "release") {
mutations.push(args);
- if (args[1] === "create") {
+ if (args.includes("--notes-file")) {
notesPath = args[args.indexOf("--notes-file") + 1];
- expect(readFileSync(notesPath, "utf8")).toBe(copy);
+ const body = readFileSync(notesPath, "utf8");
+ if (args[1] === "create") expect(body.startsWith(copy)).toBe(true);
+ expect(body).toContain(`构建与校验 / Build and checksums `);
+ expect(body).toContain(`/commit/${revision})`);
+ for (const name of packageNames) {
+ expect(body).toContain(`${hash(readFileSync(join(directory, name)))} ${name}\n`);
+ }
+ expect(body).not.toMatch(/\.release\.json|\.zip\.sha256|\.manifest\.tsv/);
expect(args).not.toContain("--generate-notes");
+ inspectBody?.(body);
if (failureAt === "create") throw new Error("GitHub create failed");
}
- if (args[1] === "edit") published = true;
+ if (args[1] === "edit" && args.includes("--draft=false")) published = true;
+ if (args[1] === "upload") {
+ expect(args.slice(args.indexOf("--clobber") + 1).sort())
+ .toEqual(packageNames.map((name) => join(directory, name)).sort());
+ if (failureAt === "upload") throw new Error("GitHub upload failed");
+ }
return "";
}
if (args[1].includes("/matching-refs/")) {
@@ -202,7 +216,43 @@ describe("publisher recovery", () => {
it("accepts annotated tags and preserves a newer stable release when resuming an older draft", async () => {
const result = await publish([release(true), { ...release(false), tag_name: "v1.1.0" }], "annotated");
expect(result.failure).toBeUndefined();
- expect(result.mutations.find((args) => args[1] === "edit")).toContain("--latest=false");
+ expect(result.mutations.find((args) => args.includes("--draft=false"))).toContain("--latest=false");
+ });
+
+ it("refreshes draft checksums after rebuilding the same SHA while retaining reviewed prose", async () => {
+ let draftBody = "";
+ const initial = await publish([], "absent", "upload", body => { draftBody = body; });
+ expect(initial.failure).toBe("GitHub upload failed");
+ const names = ["windows-amd64.zip", "windows-amd64.release.json", "windows-amd64.zip.sha256"];
+ const original = names.map(name => readFileSync(join(directory, name)));
+ const oldDigest = hash(original[0]!);
+ const newBytes = Buffer.from("rebuilt Windows archive at the same source SHA");
+ const newDigest = hash(newBytes);
+ try {
+ writeFileSync(join(directory, names[0]!), newBytes);
+ writeFileSync(join(directory, names[1]!), JSON.stringify({
+ ...JSON.parse(original[1]!.toString()), artifactSha256: newDigest,
+ }));
+ writeFileSync(join(directory, names[2]!), `${newDigest} ${names[0]}\n`);
+ const reviewed = `Owner-edited introduction\n\n${draftBody}\nOwner-edited final note`;
+ let updated = "";
+ const resumed = await publish([{ ...release(true), body: reviewed }], "annotated", undefined,
+ body => { updated = body; });
+ expect(resumed.failure).toBeUndefined();
+ expect(notes).not.toHaveBeenCalled();
+ expect(updated).toBe(reviewed.replace(oldDigest, newDigest));
+ expect(updated.match(/piik-build-checksums:start/g)).toHaveLength(1);
+ const published = await publish([{ ...release(false), body: reviewed }], "annotated");
+ expect(published).toEqual({ failure: undefined, mutations: [] });
+ } finally {
+ names.forEach((name, index) => writeFileSync(join(directory, name), original[index]!));
+ }
+ });
+
+ it("rejects ambiguous draft checksum sections before modifying a release", async () => {
+ const result = await publish([{ ...release(true), body: "Reviewed text\n" }], "annotated");
+ expect(result.failure).toContain("ambiguous");
+ expect(result.mutations).toEqual([]);
});
it("creates a new release without a pre-existing tag and verifies the resulting tag", async () => {
diff --git a/tests/release-automation.test.ts b/tests/release-automation.test.ts
index a022229..2f2395b 100644
--- a/tests/release-automation.test.ts
+++ b/tests/release-automation.test.ts
@@ -171,6 +171,12 @@ describe("release automation", () => {
const complete = run();
expect(complete.status, complete.stderr).toBe(0);
expect(JSON.parse(complete.stdout).targets).toHaveLength(4);
+ expect(JSON.parse(complete.stdout).files).toBe(4);
+ const checksum = join(root, "windows-amd64.zip.sha256");
+ const originalChecksum = readFileSync(checksum, "utf8");
+ writeFileSync(checksum, "wrong checksum");
+ expect(run().stderr).toContain("App checksum file mismatch");
+ writeFileSync(checksum, originalChecksum);
const path = join(root, "windows-amd64.release.json");
const original = readFileSync(path, "utf8");
writeFileSync(path, JSON.stringify({ ...JSON.parse(original), revision: "b".repeat(40) }));
diff --git a/tests/sfu-client.test.ts b/tests/sfu-client.test.ts
index 85c5803..1a9189c 100644
--- a/tests/sfu-client.test.ts
+++ b/tests/sfu-client.test.ts
@@ -216,6 +216,30 @@ async function publisher(audio = true) {
};
}
+function feedVideoStats(
+ pc: FakePc,
+ trackId: string,
+ framesEncoded: number,
+): void {
+ pc.report.clear();
+ pc.report.set("outbound-video", {
+ id: "outbound-video",
+ type: "outbound-rtp",
+ kind: "video",
+ timestamp: 1_000,
+ ssrc: 10,
+ framesEncoded,
+ mediaSourceId: "media-source-video",
+ });
+ pc.report.set("media-source-video", {
+ id: "media-source-video",
+ type: "media-source",
+ kind: "video",
+ timestamp: 1_000,
+ trackIdentifier: trackId,
+ });
+}
+
describe("embedded SFU browser transport", () => {
it("publishes one bounded simulcast PC, offers before ICE and admits only the selected codec", async () => {
const { publisher: host, pc, send, video } = await publisher();
@@ -321,6 +345,72 @@ describe("embedded SFU browser transport", () => {
expect(pc.close).toHaveBeenCalledOnce();
});
+ it.each([0, 250])("counts new publication frames from a valid baseline of %s", async (baseline) => {
+ const { publisher: host, pc, video } = await publisher();
+ const sender = pc.transceivers[0]!.sender;
+ expect(sender.parameters.degradationPreference).toBe(
+ "maintain-resolution",
+ );
+ feedVideoStats(pc, video.clones[0]!.id, 6);
+ await vi.advanceTimersByTimeAsync(2_000);
+ expect(sender.parameters.degradationPreference).toBe("balanced");
+
+ const nextVideo = new FakeTrack("video");
+ expect(await host.replaceStream(stream(nextVideo))).toBe(true);
+ expect(sender.parameters.degradationPreference).toBe(
+ "maintain-resolution",
+ );
+
+ // The old source, an empty report, and an absent counter are all unknown.
+ await vi.advanceTimersByTimeAsync(2_000);
+ pc.report.clear();
+ await vi.advanceTimersByTimeAsync(2_000);
+ feedVideoStats(pc, nextVideo.clones[0]!.id, 0);
+ delete pc.report.get("outbound-video").framesEncoded;
+ await vi.advanceTimersByTimeAsync(2_000);
+
+ feedVideoStats(pc, nextVideo.clones[0]!.id, baseline);
+ await vi.advanceTimersByTimeAsync(2_000);
+ expect(sender.parameters.degradationPreference).toBe(
+ "maintain-resolution",
+ );
+ feedVideoStats(pc, nextVideo.clones[0]!.id, baseline + 4);
+ await vi.advanceTimersByTimeAsync(2_000);
+ expect(sender.parameters.degradationPreference).toBe(
+ "maintain-resolution",
+ );
+ feedVideoStats(pc, nextVideo.clones[0]!.id, baseline + 5);
+ await vi.advanceTimersByTimeAsync(2_000);
+ expect(sender.parameters.degradationPreference).toBe("balanced");
+ });
+
+ it("applies the latest committed profile when startup recovery lands", async () => {
+ const { publisher: host, pc, video } = await publisher();
+ const sender = pc.transceivers[0]!.sender;
+ const clone = video.clones[0]!;
+ let releaseConstraints!: () => void;
+ clone.applyConstraints.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ releaseConstraints = () => resolve(undefined);
+ }),
+ );
+ const update = host.updateProfile(QUALITY_PROFILES["720p30"]);
+ await vi.advanceTimersByTimeAsync(0);
+ feedVideoStats(pc, clone.id, 6);
+ await vi.advanceTimersByTimeAsync(2_000);
+ releaseConstraints();
+ expect(await update).toBe(true);
+ await vi.advanceTimersByTimeAsync(0);
+
+ // The user's 720p30 change must survive the queued startup recovery; a
+ // stats-time snapshot must not re-apply the previous 1080p30 profile.
+ expect(sender.parameters.degradationPreference).toBe("balanced");
+ expect(sender.parameters.encodings[1]).toMatchObject({
+ maxBitrate: 3_000_000,
+ });
+ });
+
it("rolls back a failed source replacement and releases its unused clone", async () => {
const { publisher: host, pc, video } = await publisher();
pc.transceivers[0]!.sender.replaceTrack.mockRejectedValueOnce(
diff --git a/tests/video-fullscreen.test.ts b/tests/video-fullscreen.test.ts
new file mode 100644
index 0000000..61d6ac2
--- /dev/null
+++ b/tests/video-fullscreen.test.ts
@@ -0,0 +1,88 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { toggleVideoFullscreen, videoFullscreenState, type FullscreenVideo } from "../src/client/components/living/video-fullscreen";
+
+function player(native = false) {
+ const screen = { requestFullscreen: vi.fn<() => Promise>().mockResolvedValue() };
+ const video = {
+ parentElement: screen, readyState: 0,
+ ...(native ? {
+ webkitEnterFullscreen: vi.fn(), webkitExitFullscreen: vi.fn(),
+ webkitSupportsFullscreen: false, webkitDisplayingFullscreen: false,
+ webkitPresentationMode: "inline",
+ } : {}),
+ };
+ const page = {
+ fullscreenEnabled: !native, fullscreenElement: null as object | null,
+ exitFullscreen: vi.fn<() => Promise>().mockResolvedValue(),
+ pictureInPictureElement: null as object | null,
+ exitPictureInPicture: vi.fn<() => Promise>().mockResolvedValue(),
+ };
+ vi.stubGlobal("document", page);
+ return { screen, video, page, element: video as unknown as FullscreenVideo };
+}
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe("Video fullscreen", () => {
+ it("requests page fullscreen in the click stack before leaving PiP, and exits only its own stage", async () => {
+ const { screen, video, page, element } = player();
+ page.pictureInPictureElement = video;
+ const entering = toggleVideoFullscreen(element);
+ expect(screen.requestFullscreen).toHaveBeenCalledOnce();
+ expect(page.exitPictureInPicture).not.toHaveBeenCalled();
+ await entering;
+ expect(page.exitPictureInPicture).toHaveBeenCalledOnce();
+ expect(videoFullscreenState(element).active).toBe(false);
+
+ page.fullscreenElement = screen;
+ expect(videoFullscreenState(element).active).toBe(true);
+ await toggleVideoFullscreen(element);
+ expect(page.exitFullscreen).toHaveBeenCalledOnce();
+ page.fullscreenElement = {};
+ await toggleVideoFullscreen(element);
+ expect(screen.requestFullscreen).toHaveBeenCalledTimes(2);
+ expect(page.exitFullscreen).toHaveBeenCalledOnce();
+ });
+
+ it("waits for Safari metadata and actual native support without queuing a request outside user activation", async () => {
+ const { video, element } = player(true);
+ expect(videoFullscreenState(element)).toMatchObject({ supported: true, ready: false, active: false });
+ await toggleVideoFullscreen(element);
+ expect(video.webkitEnterFullscreen).not.toHaveBeenCalled();
+ video.readyState = 1;
+ await toggleVideoFullscreen(element);
+ expect(video.webkitEnterFullscreen).not.toHaveBeenCalled();
+ video.webkitSupportsFullscreen = true;
+ expect(videoFullscreenState(element).ready).toBe(true);
+ const entering = toggleVideoFullscreen(element);
+ expect(video.webkitEnterFullscreen).toHaveBeenCalledOnce();
+ await entering;
+ expect(videoFullscreenState(element).active).toBe(false);
+ video.webkitDisplayingFullscreen = true;
+ expect(videoFullscreenState(element)).toMatchObject({ active: true, nativeActive: true });
+ await toggleVideoFullscreen(element);
+ expect(video.webkitExitFullscreen).toHaveBeenCalledOnce();
+ video.webkitDisplayingFullscreen = false;
+ video.webkitPresentationMode = "fullscreen";
+ expect(videoFullscreenState(element).nativeActive).toBe(true);
+ video.webkitPresentationMode = "inline";
+ video.readyState = 0;
+ expect(videoFullscreenState(element)).toMatchObject({ active: false, ready: false });
+ });
+
+ it("retains native and standard rejection details and leaves PiP alone when fullscreen is rejected", async () => {
+ const native = player(true);
+ native.video.readyState = 1;
+ native.video.webkitSupportsFullscreen = true;
+ const rejected = new DOMException("The video is changing presentation mode", "InvalidStateError");
+ native.video.webkitEnterFullscreen!.mockImplementation(() => { throw rejected; });
+ await expect(toggleVideoFullscreen(native.element)).rejects.toBe(rejected);
+ expect(videoFullscreenState(native.element).active).toBe(false);
+
+ const standard = player();
+ standard.page.pictureInPictureElement = standard.video;
+ standard.screen.requestFullscreen.mockRejectedValue(rejected);
+ await expect(toggleVideoFullscreen(standard.element)).rejects.toBe(rejected);
+ expect(standard.page.exitPictureInPicture).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/website-downloads.test.ts b/tests/website-downloads.test.ts
index a4b61c7..5ef36b7 100644
--- a/tests/website-downloads.test.ts
+++ b/tests/website-downloads.test.ts
@@ -22,7 +22,6 @@ const mirror = {
describe("website package downloads", () => {
it("links each uploaded platform package and rejects an incomplete or mismatched GitHub release", () => {
const result = websiteDownloads({ github, mirror });
- expect(result.version).toBe(version);
expect(result.packages.map(item => item.url)).toEqual(github.assets.map(asset =>
`https://github.com/TNTcraftHIM/Piik/releases/latest/download/${asset.name}`));
expect(result.packages.map(item => item.mirrorURL)).toEqual(mirror.assets.map(asset => asset.browser_download_url));
diff --git a/vite.config.ts b/vite.config.ts
index b692618..dfdb008 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -18,6 +18,8 @@ export default defineConfig({
host: true,
port: 8787,
strictPort: true,
+ // Packaging and the embedded Go bundle are outputs, not live UI sources.
+ watch: { ignored: ["**/build/**", "**/internal/server/webassets/dist/**"] },
proxy: {
"/api": { target: server },
"/healthz": { target: server },