## 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.
414 lines
15 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|