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

627 lines
17 KiB
Go

package app
import (
"context"
"fmt"
"io"
"log/slog"
"math/rand/v2"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/TNTcraftHIM/Piik/internal/app/browser"
"github.com/TNTcraftHIM/Piik/internal/diagnostics"
"github.com/charmbracelet/colorprofile"
"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/term"
)
type consoleView struct {
mode, state, entry, invite string
protected bool
problem string
browserError error
}
type consoleLanguage string
type consoleFinished struct{ err error }
type consoleTick struct{}
type consoleIdleWink struct{}
type consoleOpenResult struct {
target string
err error
}
type consoleDiagnostic string
type consoleDebug struct {
logPath string
export func() (string, error)
}
type consoleExportResult struct {
path string
err error
}
type consoleResult struct {
model tea.Model
err error
}
type consoleModel struct {
view consoleView
language string
width int
height int
colors bool
frame int
tickPending bool
idlePending bool
idleWink bool
finished bool
cancel context.CancelFunc
debug consoleDebug
exporting bool
exportPath string
exportError bool
openBrowser func(string)
}
type console struct {
mu sync.Mutex
machine bool
finished bool
program *tea.Program
done chan consoleResult
plain consoleModel
opening bool
openURL func(string) error
}
var consoleCopy = map[string][3]string{
"mode": {"Mode", "模式", ""},
"local": {"Local network", "局域网", "□"},
"link": {"Public link", "公网链接", "↗"},
"site": {"Piik Site", "Piik 站点", "@"},
"setup": {"Choose a mode in the browser", "在浏览器中选择模式", "?"},
"starting": {"Starting", "正在启动", "…"},
"ready": {"Ready", "已就绪", "✓"},
"stopping": {"Stopping", "正在退出", "→"},
"stopped": {"Stopped", "已停止", "○"},
"failed": {"Could not continue", "运行失败", "!"},
"entry": {"Open in browser", "打开网页", "□"},
"invite": {"Site address", "站点地址", "↗"},
"access": {"Site access", "站点准入", ""},
"open": {"Open", "开放", "○"},
"password": {"Password protected", "已设置密码", "*"},
"error": {"Details", "详情", "!"},
"exit": {"quit", "退出", "↪"},
"openFailed": {"Could not open the browser; use the address above.", "无法打开浏览器,请使用上方地址。", "! ↗"},
"log": {"Log", "日志", ""},
"export": {"Export diagnostics", "导出诊断", ""},
"exporting": {"Saving diagnostics", "正在保存诊断", ""},
"exported": {"Saved", "已保存", ""},
"exportFailed": {"Could not save diagnostics", "无法保存诊断", ""},
}
func newConsole(cancel context.CancelFunc, machine bool) *console {
console := &console{
machine: machine,
openURL: browser.Open,
plain: consoleModel{view: consoleView{state: "starting"}, language: defaultConsoleLanguage(), width: 76, cancel: cancel,
colors: colorprofile.Detect(os.Stdout, os.Environ()) > colorprofile.ASCII},
}
console.plain.openBrowser = console.openBrowser
if machine {
return console
}
if term.IsTerminal(os.Stdin.Fd()) && term.IsTerminal(os.Stdout.Fd()) && os.Getenv("TERM") != "dumb" {
console.program = tea.NewProgram(console.plain, tea.WithoutSignalHandler())
console.done = make(chan consoleResult, 1)
go func() {
model, err := console.program.Run()
console.done <- consoleResult{model: model, err: err}
cancel()
}()
}
return console
}
func (console *console) send(message tea.Msg) {
console.mu.Lock()
defer console.mu.Unlock()
if console.machine || console.finished {
return
}
if _, finished := message.(consoleFinished); finished {
console.finished = true
}
if console.program != nil {
console.program.Send(message)
return
}
updated, _ := console.plain.Update(message)
console.plain = updated.(consoleModel)
fmt.Fprintln(os.Stdout, console.plain.content(false))
}
func (console *console) show(view consoleView) { console.send(view) }
func (console *console) setLanguage(language string) { console.send(consoleLanguage(language)) }
// One OS handoff may remain pending, but never blocks App readiness or exit.
// Do not kill the handler on a timer: it may itself own the user's browser.
func (console *console) openBrowser(target string) {
console.mu.Lock()
if console.finished || console.opening {
console.mu.Unlock()
return
}
console.opening = true
openURL := console.openURL
console.mu.Unlock()
go func() {
err := openURL(target)
if err != nil {
slog.Debug("Could not open the system browser", diagnostics.Error(err))
}
console.send(consoleOpenResult{target: target, err: err})
console.mu.Lock()
console.opening = false
console.mu.Unlock()
}()
}
func (console *console) stop(err error) error {
if console.program == nil {
return nil
}
console.send(consoleFinished{err})
result := <-console.done
if model, ok := result.model.(consoleModel); ok {
console.plain.language = model.language
}
return result.err
}
func (console *console) finish(err error, exportPath string) {
if console.program == nil {
console.send(consoleFinished{err})
return
}
if exportPath != "" {
fmt.Fprintln(os.Stderr, "Piik App:", console.plain.text("exported"), exportPath)
}
if err != nil {
fmt.Fprintln(os.Stderr, "Piik App:", err)
}
}
func (console *console) logWriter() io.Writer {
if console.program == nil {
return os.Stderr
}
return console
}
func (console *console) Write(data []byte) (int, error) {
if text := strings.TrimSpace(string(data)); text != "" {
console.send(consoleDiagnostic(ansi.Strip(text)))
}
return len(data), nil
}
func (model consoleModel) Init() tea.Cmd { return nil }
func (model consoleModel) Update(message tea.Msg) (tea.Model, tea.Cmd) {
switch value := message.(type) {
case consoleView:
if model.view.state != "stopping" {
model.view = value
}
case consoleLanguage:
model.language = string(value)
case consoleDiagnostic:
model.view.problem = string(value)
model.view.browserError = nil
case consoleDebug:
model.debug = value
case consoleExportResult:
model.exporting, model.exportError = false, value.err != nil
if value.err == nil {
model.exportPath = value.path
}
case tea.WindowSizeMsg:
model.width, model.height = value.Width, value.Height
case tea.ColorProfileMsg:
model.colors = value.Profile > colorprofile.ASCII
case consoleTick:
model.tickPending = false
model.frame++
if model.frame >= 16 {
model.idleWink = false
}
case consoleIdleWink:
model.idlePending = false
if model.view.state == "ready" || model.view.state == "setup" {
model.frame, model.idleWink = 8, true
}
case consoleOpenResult:
if value.target == model.view.entry && !model.finished && model.view.state != "stopping" {
model.view.browserError = value.err
}
case consoleFinished:
model.finished = true
model.view.entry, model.view.invite = "", ""
model.view.browserError = nil
model.view.state, model.view.problem = "stopped", ""
if value.err != nil {
model.view.state, model.view.problem = "failed", value.err.Error()
}
return model, tea.Quit
case tea.KeyPressMsg:
switch value.String() {
case "q", "ctrl+c":
model.view = consoleView{mode: model.view.mode, state: "stopping"}
model.cancel()
case "o", "O":
if target := model.view.entry; target != "" && model.openBrowser != nil {
return model, func() tea.Msg { model.openBrowser(target); return nil }
}
case "d", "D":
if model.debug.export != nil && !model.exporting && !model.finished && model.view.state != "stopping" {
model.exporting, model.exportError = true, false
return model, func() tea.Msg {
path, err := model.debug.export()
return consoleExportResult{path: path, err: err}
}
}
}
}
if model.animating() && !model.tickPending {
model.tickPending = true
return model, tea.Tick(200*time.Millisecond, func(time.Time) tea.Msg { return consoleTick{} })
}
if !model.finished && !model.animating() && !model.idlePending &&
(model.view.state == "ready" || model.view.state == "setup") {
model.idlePending = true
return model, tea.Tick(time.Duration(8+rand.IntN(13))*time.Second, func(time.Time) tea.Msg { return consoleIdleWink{} })
}
return model, nil
}
func (model consoleModel) animating() bool {
return !model.finished && model.view.state != "stopping" &&
(model.view.state == "starting" || model.frame < 8 || model.idleWink)
}
func (model consoleModel) text(key string) string {
index := 0
if model.language == "zh" {
index = 1
}
if model.language == "vis" {
return consoleVisualToken(key)
}
return consoleCopy[key][index]
}
func (model consoleModel) View() tea.View {
view := tea.NewView(model.content(true))
view.WindowTitle = "Piik App"
view.AltScreen = true
return view
}
func (model consoleModel) content(styled bool) string {
width := max(12, min(model.width-2, 74))
compact := model.height > 0 && model.height < 24
compactDebug := compact && model.debug.logPath != ""
visual := model.language == "vis"
accent, muted, link := lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle()
if styled {
accent = accent.Bold(true)
muted = muted.Faint(true)
link = link.Underline(true)
}
revision := BuildRevision
if len(revision) == 40 {
revision = revision[:7]
}
var out strings.Builder
version := buildVersion()
heading := "Piik\nApp\n" + muted.Render(version)
if revision != "" && revision != version {
heading = "Piik App\n" + muted.Render(version) + "\n" + muted.Render(revision)
}
animated := styled && model.animating()
mascot := accent.Render(consoleTV(model.frame, animated, compact || width < 34))
if styled && model.colors && width >= 36 && !compactDebug {
mascot = consoleBlockTV(model.frame, animated)
}
fmt.Fprintln(&out, lipgloss.JoinHorizontal(lipgloss.Center, mascot, heading))
fmt.Fprintln(&out)
state := model.text(model.view.state)
if model.view.state == "starting" && !visual {
dots := 3
if styled {
dots = model.frame%3 + 1
}
state += strings.Repeat(".", dots)
}
if visual {
stateStyle := accent
if styled && model.colors {
switch model.view.state {
case "ready":
stateStyle = stateStyle.Foreground(lipgloss.Color("#3fb97a"))
case "starting", "stopping":
stateStyle = stateStyle.Foreground(lipgloss.Color("#e8a33d"))
case "failed":
stateStyle = stateStyle.Foreground(lipgloss.Color("#e25a52"))
}
}
// Keep visual mode one line tall; multi-line scene art side by side
// collapses on narrow terminals and reads like missing characters.
fmt.Fprintln(&out, lipgloss.JoinHorizontal(lipgloss.Center,
accent.Render(consoleVisualToken(model.view.mode)), " · ",
stateStyle.Render(consoleVisualToken(model.view.state))))
} else if compactDebug && model.view.mode != "" {
fmt.Fprintf(&out, "%s %s\n", accent.Render(state), model.text(model.view.mode))
} else {
fmt.Fprintln(&out, accent.Render(state))
if model.view.mode != "" {
fmt.Fprintf(&out, "%s %s\n", muted.Render(model.text("mode")), model.text(model.view.mode))
}
}
for _, item := range [][2]string{{"entry", model.view.entry}, {"invite", model.view.invite}} {
if item[1] == "" || compact && item[0] == "invite" {
continue
}
address := consoleAddress(item[1])
if item[0] == "entry" {
address = launchURL(address)
}
style := link
if styled {
style = style.Hyperlink(address)
}
if compactDebug {
fmt.Fprintf(&out, "\n%s\n", style.Render(address))
} else if visual {
label := consoleVisualToken(item[0])
fmt.Fprintf(&out, "\n%s %s\n", label, style.Render(address))
} else {
fmt.Fprintf(&out, "\n%s\n%s\n", muted.Render(model.text(item[0])), style.Render(address))
}
}
accessPicture := ""
if !compact && model.view.entry != "" && (model.view.mode == "local" || model.view.mode == "link") {
access := "open"
if model.view.protected {
access = "password"
}
if visual {
accessPicture = consoleVisualToken(access) + " "
} else {
fmt.Fprintf(&out, "\n%s %s\n", muted.Render(model.text("access")), model.text(access))
}
}
problem := model.view.problem
if model.view.browserError != nil {
problem = model.text("openFailed") + "\n" + model.view.browserError.Error()
}
if problem != "" {
lines := strings.Split(ansi.Hardwrap(ansi.Strip(problem), width-4, true), "\n")
if len(lines) > 3 {
lines = append(lines[:2], "...")
}
fmt.Fprintf(&out, "\n%s\n%s\n", model.text("error"), strings.Join(lines, "\n"))
}
if model.debug.logPath != "" {
path, label := model.debug.logPath, "log"
if model.exportPath != "" {
path, label = model.exportPath, "exported"
}
separator := "\n"
if compactDebug {
separator = " "
}
fmt.Fprintf(&out, "\n%s%s%s\n", muted.Render(model.text(label)), separator, consoleFileLink(path, path, link, styled))
if model.exporting {
fmt.Fprintln(&out, model.text("exporting"))
} else if model.exportError {
fmt.Fprintln(&out, model.text("exportFailed"))
}
}
if !model.finished {
keys := "[q / Ctrl+C]"
if !styled {
keys = "Ctrl+C"
}
help := keys + " " + model.text("exit")
if visual {
if model.view.entry != "" && styled {
help = "[o] ↗ " + help
}
help = lipgloss.JoinHorizontal(lipgloss.Center, accessPicture, help)
} else if model.view.entry != "" && styled {
separator := " "
if compact && model.debug.export != nil {
separator = "\n"
}
help = "[o] " + model.text("entry") + separator + help
}
if model.debug.export != nil && styled {
help = "[d] " + model.text("export") + "\n" + help
}
if visual {
fmt.Fprintf(&out, "\n%s", accent.Render(help))
} else {
fmt.Fprintf(&out, "\n%s", muted.Render(help))
}
}
content := ansi.Hardwrap(out.String(), width-4, true)
if !styled {
return content
}
padding := 1
if model.height > 0 && lipgloss.Height(content)+4 > model.height {
content = strings.ReplaceAll(content, "\n\n", "\n")
padding = 0
}
return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).
Padding(padding, 1).Width(width).Render(content)
}
func consoleVisualToken(key string) string {
switch key {
case "local":
return "□"
case "link":
return "↗"
case "site":
return "@"
case "setup":
return "?"
case "starting":
return "…"
case "ready":
return "✓"
case "stopping":
return "→"
case "stopped":
return "○"
case "failed", "error":
return "!"
case "openFailed":
return "! ↗"
case "open":
return "○"
case "password":
return "*"
case "entry":
return "□"
case "invite":
return "↗"
case "exit":
return "↪"
case "log":
return "LOG"
case "export":
return "ZIP"
case "exporting":
return "ZIP ..."
case "exported":
return "ZIP +"
case "exportFailed":
return "ZIP !"
default:
return "?"
}
}
func consoleTV(frame int, animated, compact bool) string {
phase := 0
if animated {
phase = frame % 8
}
eye := "o"
if !animated || phase >= 4 {
eye = "-"
}
if compact {
return " \\ / \n [o " + eye + "] \n / \\ "
}
stars := [3]string{" ", " ", " "}
if phase == 5 {
stars[2] = "*"
}
if phase == 6 {
stars[1] = "+"
}
if phase == 7 {
stars[0] = "."
}
lines := []string{
" \\ / " + stars[0] + " ",
" .----v----. " + stars[1] + " ",
" | o " + eye + " | " + stars[2] + " ",
" '---------' ",
" / \\ ",
}
return strings.Join(lines, "\n")
}
func consoleBlockTV(frame int, animated bool) string {
phase := 0
if animated {
phase = frame % 8
}
shell := lipgloss.NewStyle().Reverse(true)
spark := lipgloss.NewStyle().Foreground(lipgloss.Color("#f4c14d")).Faint(phase == 7)
// Digits reveal the shell clockwise before the face; subsequent loops keep it drawn.
rows := []string{" 2 2 ", " 2 2 ", " 0000011 ", "3sssssss1", "3sessses1", "3sssssss1", " 3222222 ", " 3 3 "}
lines := make([]string, 0, len(rows))
for rowIndex, row := range rows {
var line strings.Builder
for column, pixel := range row {
if animated && (pixel >= '0' && pixel <= '3' && frame < int(pixel-'0') || (pixel == 's' || pixel == 'e') && frame < 3) {
pixel = ' '
}
switch pixel {
case '0', '1', '2', '3':
line.WriteString(shell.Render(" "))
case 's':
line.WriteString(" ")
case 'e':
if column == len(row)-3 && (!animated || phase >= 4) {
line.WriteString("\u2584\u2584")
} else {
line.WriteString(shell.Render(" "))
}
default:
line.WriteString(" ")
}
}
star := " "
starStyle := spark
switch {
case phase == 5 && rowIndex == 4:
star = "\u2584\u2588\u2584"
case phase == 5 && rowIndex == 5:
star, starStyle = "\u2584 \u2584", spark.Reverse(true)
case phase == 6 && rowIndex == 2:
star = " \u2584 "
case phase == 7 && rowIndex == 1:
star = " ."
}
lines = append(lines, line.String()+starStyle.Render(star))
}
return strings.Join(lines, "\n")
}
func consoleAddress(raw string) string {
parsed, err := url.Parse(raw)
if err != nil {
return ""
}
parsed.User, parsed.RawQuery, parsed.Fragment, parsed.RawFragment, parsed.ForceQuery = nil, "", "", "", false
return parsed.String()
}
func consoleFileLink(path, label string, style lipgloss.Style, styled bool) string {
if !styled {
return label
}
path = filepath.ToSlash(path)
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
address := url.URL{Scheme: "file", Path: path}
return style.Hyperlink(address.String()).Render(label)
}