Files
AAA 22864cf311 fix(app,ui): polish handoff links and mobile playback controls
## Problem and outcome

The App console could show a launch address that still contained private activation data when copied, while the terminal hyperlink opened a cleaned address. On narrow phones, playback controls could wrap into a second row and cover too much of the video. The handoff now presents the same safe, activation-preserving address everywhere, and the playback bar keeps one compact row with a shorter flexible volume control.

## Changes

- Sanitize the App activation URL before rendering both copyable console output and terminal hyperlinks, preserving App handoff without exposing credentials.
- Keep the mobile playback bar on one row; let the volume slider shrink with the available width and hide the nonessential percentage readout at the narrowest sizes.
- Add bilingual macOS browser/App audio-capture troubleshooting guidance and record the physical-device boundary.

## Verification

- `npm run check`: TypeScript, 73 test files / 1008 tests, and the Web production build passed.
- `npm run check:go-core`: Go tests, formatting, vet and supported core target builds passed.
- `node scripts/check-docs.mjs` and `scripts/check-project-state.ps1` passed.
- Narrow-layout browser checks covered 320px, 360px, 375px, 390px, 430px, 768px and 1280px widths without wrapping or horizontal overflow.
- The interaction prototype remains local and uncommitted for later acceptance; it is not part of this PR.

## Release notes

### 中文

这是一个维护版本,改善 App 启动链接的安全提示和手机上的播放控制体验。

#### 修复

- 修复 App 控制台复制链接时可能带出私有激活参数的问题。复制、终端链接和手动打开地址现在保持一致,并继续支持自动唤起 App。
- 修复窄屏手机上的播控挤成两行、遮挡画面的问题。控件保持单行,音量滑条会按可用空间缩短,过窄时隐藏非关键的百分比读数。
- 补充 macOS 浏览器与 App 采集声音选项的排查说明,明确不同浏览器和系统版本的能力边界。

#### 兼容性

本版本不改变通信协议、配置格式或存储格式。App 公网邀请、本地模式和已部署站点按原方式更新即可;已打开的页面刷新后使用新的播控布局。

### English

This maintenance release improves App launch links and playback controls on phones.

#### Fixes

- Fixed the App console sometimes exposing private activation parameters in a copied launch address. The copied address, terminal hyperlink and manual URL now match while still supporting App handoff.
- Fixed narrow phone layouts wrapping playback controls into a second row and covering the video. Controls stay on one row, the volume slider shrinks with the available space, and the nonessential percentage readout hides when space is tight.
- Added macOS troubleshooting guidance for browser and App audio capture, including the current browser and OS support boundaries.

#### Compatibility

This release changes no wire protocol, configuration format or storage format. Update the App for public invitations or Local mode, or update the deployed site as usual; refresh an open page to use the new playback layout.
2026-09-18 20:20:53 +08:00

414 lines
15 KiB
Go

package app
import (
"archive/zip"
"bytes"
"context"
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/TNTcraftHIM/Piik/internal/diagnostics"
"github.com/charmbracelet/x/ansi"
)
func TestConsoleRetainsTheSavedDiagnosticPathAfterRestoration(t *testing.T) {
for _, scenario := range []string{"normal", "terminal-ended-before-export", "terminal-read-failed", "manual-result-after-terminal", "failed-export", "failed-export-keeps-path"} {
t.Run(scenario, func(t *testing.T) {
output, err := os.CreateTemp(t.TempDir(), "restored-terminal-")
if err != nil {
t.Fatal(err)
}
defer output.Close()
previous := os.Stderr
os.Stderr = output
defer func() { os.Stderr = previous }()
recorder, err := diagnostics.Open(t.TempDir(), "client", "test")
if err != nil {
t.Fatal(err)
}
defer recorder.Close()
previousLogger := slog.Default()
slog.SetDefault(recorder.Logger())
defer slog.SetDefault(previousLogger)
model := consoleModel{language: "en", width: 80, cancel: func() {}, view: consoleView{state: "ready"}}
exported, release, commandDone := make(chan struct{}), make(chan struct{}), make(chan struct{})
if scenario == "manual-result-after-terminal" {
model.debug = consoleDebug{export: func() (string, error) {
path, err := recorder.Export()
close(exported)
<-release
close(commandDone)
return path, err
}}
}
var input io.Reader
var failInput func()
readFailure := errors.New("fixture terminal read failed")
if scenario == "terminal-read-failed" {
reader, writer := io.Pipe()
defer reader.Close()
defer writer.Close()
input = reader
failInput = func() { _ = writer.CloseWithError(readFailure) }
}
var screen bytes.Buffer
console := &console{
program: tea.NewProgram(model, tea.WithInput(input), tea.WithOutput(&screen), tea.WithoutSignalHandler(), tea.WithoutRenderer()),
done: make(chan consoleResult, 1),
}
terminalEnded := make(chan struct{})
go func() {
model, err := console.program.Run()
console.done <- consoleResult{model: model, err: err}
close(terminalEnded)
}()
defer console.program.Kill()
if scenario == "manual-result-after-terminal" {
console.send(tea.KeyPressMsg{Code: 'd'})
select {
case <-exported:
case <-time.After(5 * time.Second):
close(release)
t.Fatal("manual export did not start")
}
}
if scenario != "normal" {
if failInput != nil {
failInput()
} else {
console.program.Quit()
}
select {
case <-terminalEnded:
case <-time.After(time.Second):
close(release)
t.Fatal("terminal did not stop")
}
}
var failure error
if scenario == "terminal-ended-before-export" || strings.HasPrefix(scenario, "failed-export") {
failure = errors.New("local service failed")
}
if scenario == "normal" || scenario == "failed-export-keeps-path" {
path, err := recorder.Export()
if err != nil {
t.Fatal(err)
}
console.send(consoleExportResult{path: path})
}
if strings.HasPrefix(scenario, "failed-export") {
if err := recorder.Close(); err != nil {
t.Fatal(err)
}
}
if scenario == "manual-result-after-terminal" {
close(release)
<-commandDone
}
failure = finishRun(console, recorder, func() { slog.SetDefault(previousLogger) }, failure)
path := recorder.LastExportPath()
if (path == "") != (scenario == "failed-export") {
t.Fatalf("saved path %q does not match export outcome", path)
}
if scenario == "terminal-read-failed" {
if !errors.Is(failure, readFailure) {
t.Fatalf("terminal read failure was not retained: %v", failure)
}
archive, err := zip.OpenReader(path)
if err != nil {
t.Fatalf("terminal failure did not produce a complete diagnostic report: %v", err)
}
_ = archive.Close()
log, err := os.ReadFile(recorder.LogPath())
if err != nil || !strings.Contains(string(log), `"event":"stopped","failed":true`) ||
!strings.Contains(string(log), readFailure.Error()) {
t.Fatalf("final diagnostics missed the terminal error: %s, %v", log, err)
}
}
content, err := os.ReadFile(output.Name())
if err != nil || path != "" && strings.Count(string(content), path) != 1 ||
path == "" && strings.Contains(string(content), "Saved") ||
failure != nil && !strings.Contains(string(content), failure.Error()) {
t.Fatalf("restored terminal lost the saved path or App error: %s, %v", content, err)
}
})
}
}
func TestConsoleStartsWithSystemLanguageAndAcceptsVisualSelection(t *testing.T) {
console := newConsole(func() {}, true)
if console.plain.language != defaultConsoleLanguage() || console.plain.language == "vis" {
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 TestBrowserOpenFeedbackBelongsToTheCurrentEntry(t *testing.T) {
target := "http://127.0.0.1:8787/#client-access=private-secret"
failure := consoleOpenResult{target: target, err: errors.New("exit status 0xc0000005")}
model := consoleModel{width: 80, language: "en", view: consoleView{state: "ready", entry: target}}
updated, _ := model.Update(failure)
model = updated.(consoleModel)
if model.finished || model.view.state != "ready" || model.view.entry != target {
t.Fatal("browser failure retired a ready App")
}
for _, language := range []string{"en", "zh", "vis"} {
updated, _ = model.Update(consoleLanguage(language))
model = updated.(consoleModel)
content := model.content(false)
if !strings.Contains(content, model.text("openFailed")) || !strings.Contains(content, "0xc0000005") ||
strings.Contains(content, "private-secret") {
t.Fatalf("browser feedback lost its current language/error or exposed a credential: %s", content)
}
}
updated, _ = model.Update(consoleOpenResult{target: target})
model = updated.(consoleModel)
if model.view.browserError != nil {
t.Fatal("successful retry retained the browser failure")
}
for _, next := range []tea.Msg{
consoleView{state: "setup", entry: "http://127.0.0.1:12345/client"},
consoleView{state: "stopping"},
consoleFinished{err: errors.New("service failed")},
} {
updated, _ = model.Update(next)
updated, _ = updated.(consoleModel).Update(failure)
if current := updated.(consoleModel); current.view.browserError != nil {
t.Fatal("late browser result overwrote the current App state")
}
}
}
func TestConsoleEntryKeepsAppActivationWithoutExposingCredentials(t *testing.T) {
model := consoleModel{width: 80, language: "en", view: consoleView{
mode: "link", state: "ready",
entry: "http://user:private-secret@localhost:8787/?token=private-secret#client-access=private-secret&piik-client=1",
invite: "https://room.example/?token=private-secret#v=private-secret",
}}
want := []string{"http://localhost:8787/#piik-client=1", "https://room.example/"}
for _, styled := range []bool{false, true} {
content := model.content(styled)
var addresses []string
for _, field := range strings.Fields(ansi.Strip(content)) {
if strings.HasPrefix(field, "http") {
addresses = append(addresses, field)
}
}
if len(addresses) != len(want) {
t.Fatalf("styled=%v: copyable addresses = %q", styled, addresses)
}
for i, address := range want {
if addresses[i] != address {
t.Fatalf("styled=%v: copyable address = %q, want %q", styled, addresses[i], address)
}
if styled && !strings.Contains(content, ansi.SetHyperlink(address)) {
t.Fatalf("terminal hyperlink differs from copyable address %q", address)
}
}
if strings.Contains(content, "private-secret") || strings.Contains(content, "client-access") {
t.Fatal("console exposed an entry or invitation credential")
}
}
}
func TestConsolePresentationAndShutdown(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
model := consoleModel{cancel: cancel, view: consoleView{
mode: "link", state: "ready", protected: true,
entry: "http://localhost:8787/#client-access=private-secret",
invite: "https://a-long-public-link-name.example.test/",
}}
for _, language := range []string{"zh", "en", "vis"} {
for _, width := range []int{30, 52, 90} {
model.language, model.width, model.height = language, width, 18
for _, styled := range []bool{true, false} {
content := model.content(styled)
if styled && strings.Count(content, "\n")+1 > model.height {
t.Fatalf("%s width %d height overflow: %d", language, width, strings.Count(content, "\n")+1)
}
if strings.Contains(content, "private-secret") || strings.Contains(content, "client-access") {
t.Fatal("console exposed the entry credential")
}
for _, line := range strings.Split(content, "\n") {
if ansi.StringWidth(line) > width {
t.Fatalf("%s width %d overflow: %q", language, width, line)
}
}
}
}
}
model.colors = true
for _, compact := range []bool{false, true} {
model.width, model.height = 80, 32
if compact {
model.width, model.height = 40, 18
}
for _, frame := range []int{0, 3, 5, 8} {
mascot := consoleBlockTV(frame, true)
lines := strings.Split(mascot, "\n")
if len(lines) != len(strings.Split(consoleBlockTV(0, false), "\n")) {
t.Fatal("mascot animation moved the layout")
}
for _, line := range lines {
if ansi.StringWidth(line) != ansi.StringWidth(lines[0]) {
t.Fatal("mascot frame changed width")
}
}
model.frame = frame
content := model.content(true)
if lipgloss.Width(content) > model.width || lipgloss.Height(content) > model.height {
t.Fatal("color console overflowed")
}
}
}
if !strings.Contains(consoleTV(5, true, false), "- | *") ||
!strings.Contains(consoleTV(0, false, false), "o -") ||
!strings.Contains(consoleBlockTV(0, false), "\u2584\u2584") ||
consoleTV(8, false, false) != consoleTV(0, false, false) {
t.Fatal("mascot wink or static state changed")
}
model.frame = 16
updated, cmd := model.Update(consoleIdleWink{})
waking := updated.(consoleModel)
if !waking.animating() || waking.frame != 8 || cmd == nil {
t.Fatal("idle wink did not resume without redrawing the shell")
}
for range 8 {
updated, _ = waking.Update(consoleTick{})
waking = updated.(consoleModel)
}
if waking.animating() || !waking.idlePending {
t.Fatal("idle wink did not return to rest")
}
var output bytes.Buffer
program := tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(&output), tea.WithoutSignalHandler(), tea.WithoutRenderer())
done := make(chan error, 1)
go func() { _, err := program.Run(); done <- err }()
program.Send(consoleLanguage("zh"))
program.Send(tea.KeyPressMsg{Code: 'q'})
select {
case <-ctx.Done():
case <-time.After(time.Second):
t.Fatal("quit did not request application shutdown")
}
program.Send(consoleFinished{})
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
program.Kill()
t.Fatal("console did not finish after application cleanup")
}
updated, _ = model.Update(consoleFinished{})
if text := updated.(consoleModel).content(false); strings.Contains(text, "http") {
t.Fatal("stopped console retained a live entry")
}
}
func TestConsoleBuildVersionAndRevision(t *testing.T) {
previousVersion, previousRevision := BuildVersion, BuildRevision
t.Cleanup(func() { BuildVersion, BuildRevision = previousVersion, previousRevision })
BuildRevision = strings.Repeat("a", 40)
for _, test := range []struct{ version, want string }{
{"v1.2.3", "v1.2.3"},
{"development", "development"},
{"", "development"},
{"v01.2.3", "development"},
{"v1.2", "development"},
{"v1.2.3-preview.1", "development"},
{"v1.2.3+build", "development"},
} {
BuildVersion = test.version
content := (consoleModel{width: 80, language: "en", view: consoleView{state: "ready"}}).content(false)
if !strings.Contains(content, test.want) || !strings.Contains(content, "aaaaaaa") || strings.Contains(content, BuildRevision) {
t.Fatalf("console identity for %q: %s", test.version, content)
}
}
}
func TestConsoleDiagnosticExportRunsOutsideUpdate(t *testing.T) {
called := 0
path := filepath.Join(t.TempDir(), "client-report.zip")
model := consoleModel{
view: consoleView{state: "ready"}, frame: 16, idlePending: true,
debug: consoleDebug{logPath: filepath.Join(filepath.Dir(path), "client.log"), export: func() (string, error) {
called++
return path, nil
}},
}
updated, command := model.Update(tea.KeyPressMsg{Code: 'd'})
model = updated.(consoleModel)
if called != 0 || command == nil || !model.exporting {
t.Fatal("diagnostic export blocked the console update")
}
_, repeated := model.Update(tea.KeyPressMsg{Code: 'd'})
if repeated != nil {
t.Fatal("repeated key queued another diagnostic export")
}
updated, _ = model.Update(command())
model = updated.(consoleModel)
if called != 1 || model.exporting || model.exportPath != path || model.exportError {
t.Fatalf("diagnostic result = %+v", model)
}
updated, _ = model.Update(consoleExportResult{err: errors.New("disk full")})
model = updated.(consoleModel)
if !model.exportError || model.exportPath != path {
t.Fatal("failed export discarded the previously saved bundle")
}
for _, language := range []string{"en", "zh", "vis"} {
for _, width := range []int{30, 52, 90} {
model.language, model.width, model.height = language, width, 40
for _, styled := range []bool{false, true} {
content := model.content(styled)
for _, line := range strings.Split(content, "\n") {
if ansi.StringWidth(line) > width {
t.Fatalf("%s diagnostic console width %d overflowed: %q", language, width, line)
}
}
if styled && !strings.Contains(content, "[d]") || !styled && strings.Contains(content, "[d]") {
t.Fatalf("export key hint does not match terminal input: %q", content)
}
}
}
}
link := consoleFileLink(path, path, lipgloss.NewStyle(), true)
if !strings.Contains(link, "file:///") || strings.Contains(link, "file:///C:\\") {
t.Fatalf("local report hyperlink = %q", link)
}
}
func TestConsoleDiagnosticsFitCompactTerminal(t *testing.T) {
model := consoleModel{
view: consoleView{mode: "local", state: "ready", entry: "http://localhost:8787/"},
width: 30, height: 18, colors: true,
debug: consoleDebug{logPath: "C:/Piik/logs/client.log", export: func() (string, error) { return "", nil }},
}
for _, language := range []string{"en", "zh", "vis"} {
model.language = language
for _, path := range []string{"", "C:/Piik/logs/client-20260908T120000Z.zip", "C:/Users/Example/Documents/Workspaces/Piik-embedded-media/build/client-check/logs/client-diagnostics-20260908T120000Z-123456789.zip"} {
model.exportPath = path
content := model.content(true)
if lipgloss.Width(content) > model.width || lipgloss.Height(content) > model.height {
t.Fatalf("%s diagnostic console overflowed: %s", language, content)
}
if !strings.Contains(content, "[d]") || !strings.Contains(content, "[o]") || !strings.Contains(content, "[q") {
t.Fatalf("compact diagnostics hid a terminal action: %s", content)
}
}
}
}