Files
Piik/internal/app/launcher/server.go
T
AAA 322e5d91b2 feat(ui): add illustrated hints and playback gestures
## Problem and outcome / 问题与结果

App startup rejected short or non-ASCII site passwords, viewers lacked picture-click playback gestures, and text-mode hints hid the existing illustrations. This phase accepts site passwords verbatim, combines shared illustrations with localized captions in tooltips and stage overlays, and adds desktop single-click play/pause and double-click fullscreen through the existing playback owner. Pure-visual mode remains available.

App ZIP names are now stable per platform, allowing the website to use GitHub's native latest-download URLs. The App update link selects the ZIP matching its compiled OS and architecture from the checked release metadata, falling back to the release page if the package is unavailable. Verified Gitee links retain their explicit release version. The Windows packaging workflow also stops restoring a redundant Go toolchain cache that caused setup-go to fail.

## Release notes

### 中文

- 中英文操作提示和画面状态覆盖层加入动态漫画,说明更直观;纯视觉模式继续保留。
- 观众可单击画面暂停或继续播放,双击进入或退出全屏;触屏仍通过单击显示或隐藏播放栏。
- 修复设置短密码、中文密码时 App 或 Server 无法启动的问题。站点密码仍可留空。
- 官网各系统的 GitHub 下载按钮直接获取最新 ZIP 包,并保留 Gitee 备用下载线路。
- App 内的更新入口直接下载对应系统和架构的 ZIP 包。

### English

- Chinese and English tooltips and picture status overlays now pair concise explanations with animated illustrations. Pure-visual mode remains available.
- Viewers can click the picture to pause or resume and double-click to enter or exit fullscreen. Touch taps continue to show or hide playback controls.
- Fixed App and Server startup rejecting short or non-ASCII site passwords. Site passwords remain optional.
- Each platform's GitHub download button now fetches the latest ZIP package, with Gitee retained as an alternative download source.
- App update links download the ZIP matching the installed platform and architecture.

## Verification / 验证

- `npm run check`: 59 test files / 774 tests, TypeScript and production build passed.
- `npm run check:client-core`: Go tests, formatting, vet and supported local target builds passed for the included runtime changes. Native macOS packaging remains on its macOS runner.
- Desktop and 390px touch playback checks passed, including double-click without an intermediate pause and control/keyboard interaction.
- Illustrated tooltip checks passed in Chinese, English and pure-visual modes, light/dark themes, 260/360/390px layouts, keyboard, touch hold and reduced motion. User accepted the resulting UI.
- Real stage overlay components passed 46 checks across the three modes and 260/390/1280px layouts, including keyboard play activation, reduced motion, and no overlay over healthy playback with a quality limitation.
- Bounded release review confirmed JSON password authentication retains old bodyless Bearer compatibility and ZIP names match packaging, publisher, mirror and website consumers.
- After checking both providers' real API shapes, 27 focused update tests and type-checking passed; anonymous GitHub and Gitee metadata each selected all three actual platform ZIPs without downloading them.
- Documentation, repository hygiene and diff checks passed.

## Impact / 影响

Compatible feature release. Signaling, Native control, capture protocol and persistent room schema are unchanged. The new latest-download filenames must be published before the matching website deploys; the existing CI-to-Website sequence provides that order. Production service updates use the immutable Server artifact and bounded rollback. Broader physical macOS/Linux, network and endurance acceptance stays under the existing documented deferrals.
2026-09-13 01:04:13 +08:00

345 lines
9.8 KiB
Go

package launcher
import (
"context"
"encoding/json"
"errors"
"io"
"io/fs"
"net"
"net/http"
"net/url"
"runtime"
"strings"
"sync"
"time"
appconfig "github.com/TNTcraftHIM/Piik/internal/app/config"
)
const (
maxRequestBytes = 2 << 10
shutdownTimeout = 2 * time.Second
// launcherIndex is the Browser entry document. /client serves it directly
// because that route belongs to the client-side router, not to the assets.
launcherIndex = "index.html"
)
type Mode string
const (
ModeLocal Mode = "local"
ModeLink Mode = "link"
ModeSite Mode = "site"
)
type Selection struct {
Mode Mode `json:"mode"`
Language string `json:"language"`
Site string `json:"site,omitempty"`
LocalAccessPassword string `json:"localAccessPassword"`
}
type Server struct {
ctx context.Context
cancel context.CancelFunc
listener net.Listener
httpServer *http.Server
assets fs.FS
static http.Handler
site string
localAccessPassword string
version string
revision string
selection chan Selection
resultReady chan struct{}
handled chan struct{}
done chan error
ready chan struct{}
mu sync.Mutex
selected bool
resultSet bool
target string
resultErr error
closeOnce sync.Once
resultOnce sync.Once
handledOnce sync.Once
}
// 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) {
if parent == nil {
parent = context.Background()
}
normalizedSite, err := appconfig.NormalizeSite(site)
if err != nil {
return nil, errors.New("App launcher Site is invalid")
}
site = normalizedSite
if assets == nil {
return nil, errors.New("App launcher assets are unavailable")
}
if metadata, err := fs.Stat(assets, launcherIndex); err != nil || !metadata.Mode().IsRegular() {
return nil, errors.New("App launcher assets are unavailable")
}
listener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
return nil, errors.New("App launcher could not start")
}
ctx, cancel := context.WithCancel(parent)
server := &Server{
ctx: ctx,
cancel: cancel,
listener: listener,
assets: assets,
static: http.FileServer(http.FS(assets)),
site: site,
localAccessPassword: localAccessPassword,
version: version,
revision: strings.TrimSpace(revision),
selection: make(chan Selection, 1),
resultReady: make(chan struct{}),
handled: make(chan struct{}),
done: make(chan error, 1),
ready: make(chan struct{}),
}
server.httpServer = &http.Server{
Handler: server,
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 30 * time.Second,
}
go server.serve()
<-server.ready
go func() {
<-ctx.Done()
_ = server.Close()
}()
return server, nil
}
func (server *Server) URL() string {
return "http://" + server.listener.Addr().String() + "/client"
}
func (server *Server) Selection() <-chan Selection {
return server.selection
}
func (server *Server) Handled() <-chan struct{} {
return server.handled
}
func (server *Server) Done() <-chan error {
return server.done
}
func (server *Server) SetResult(target string, resultErr error) {
server.resultOnce.Do(func() {
if resultErr == nil && !validTarget(target) {
resultErr = errors.New("App target is invalid")
}
server.mu.Lock()
server.resultSet = true
server.target = target
server.resultErr = resultErr
server.mu.Unlock()
close(server.resultReady)
})
}
func (server *Server) Close() error {
server.closeOnce.Do(server.cancel)
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
return server.httpServer.Shutdown(ctx)
}
func (server *Server) serve() {
close(server.ready)
err := server.httpServer.Serve(server.listener)
if errors.Is(err, http.ErrServerClosed) {
err = nil
}
server.done <- err
close(server.done)
}
func (server *Server) ServeHTTP(response http.ResponseWriter, request *http.Request) {
if !server.requestAllowed(request) {
http.NotFound(response, request)
return
}
response.Header().Set("X-Content-Type-Options", "nosniff")
switch request.URL.Path {
case "/api/client-launcher":
server.handleState(response, request)
case "/api/client-launcher/launch":
server.handleLaunch(response, request)
case "/client", "/client/":
if request.Method != http.MethodGet && request.Method != http.MethodHead {
response.Header().Set("Allow", "GET, HEAD")
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
return
}
response.Header().Set("Cache-Control", "no-store")
http.ServeFileFS(response, request, server.assets, launcherIndex)
default:
server.static.ServeHTTP(response, request)
}
}
func (server *Server) handleState(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet {
response.Header().Set("Allow", http.MethodGet)
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
return
}
writeJSON(response, http.StatusOK, map[string]any{
"site": server.site,
"localAccessPassword": server.localAccessPassword,
"version": server.version,
"revision": server.revision,
"packageTarget": runtime.GOOS + "-" + runtime.GOARCH,
"defaultMode": func() Mode {
if server.site != "" {
return ModeSite
}
return ModeLocal
}(),
})
}
func (server *Server) handleLaunch(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost {
response.Header().Set("Allow", http.MethodPost)
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
return
}
request.Body = http.MaxBytesReader(response, request.Body, maxRequestBytes)
selection, err := decodeSelection(request.Body)
if err != nil {
http.Error(response, "invalid launch request", http.StatusBadRequest)
return
}
server.mu.Lock()
if server.selected {
server.mu.Unlock()
http.Error(response, "launch already requested", http.StatusConflict)
return
}
server.selected = true
server.mu.Unlock()
select {
case server.selection <- selection:
case <-request.Context().Done():
server.markHandled()
return
case <-server.ctx.Done():
server.markHandled()
return
}
select {
case <-server.resultReady:
case <-request.Context().Done():
server.markHandled()
return
case <-server.ctx.Done():
server.markHandled()
return
}
server.mu.Lock()
target, resultErr, resultSet := server.target, server.resultErr, server.resultSet
server.mu.Unlock()
if !resultSet || resultErr != nil {
writeJSON(response, http.StatusServiceUnavailable, map[string]string{
"error": "Piik App could not start",
})
server.markHandled()
return
}
writeJSON(response, http.StatusOK, map[string]string{"target": target})
server.markHandled()
}
func (server *Server) markHandled() {
server.handledOnce.Do(func() { close(server.handled) })
}
func (server *Server) requestAllowed(request *http.Request) bool {
_, port, err := net.SplitHostPort(request.Host)
if err != nil {
return false
}
_, listenerPort, listenerErr := net.SplitHostPort(server.listener.Addr().String())
if listenerErr != nil || port != listenerPort {
return false
}
host := strings.TrimSuffix(strings.ToLower(request.Host), ":"+port)
if host != "127.0.0.1" && host != "localhost" {
return false
}
origin := strings.TrimSpace(request.Header.Get("Origin"))
return origin == "" || sameOrigin(origin, "http://"+request.Host)
}
func sameOrigin(actual, expected string) bool {
actualURL, actualErr := url.Parse(actual)
expectedURL, expectedErr := url.Parse(expected)
return actualErr == nil && expectedErr == nil &&
actualURL.Scheme == expectedURL.Scheme &&
strings.EqualFold(actualURL.Host, expectedURL.Host) &&
actualURL.Path == "" && actualURL.RawQuery == "" && actualURL.Fragment == ""
}
func decodeSelection(reader io.Reader) (Selection, error) {
var selection Selection
decoder := json.NewDecoder(reader)
decoder.DisallowUnknownFields()
if decoder.Decode(&selection) != nil || decoder.Decode(&struct{}{}) != io.EOF {
return Selection{}, errors.New("invalid launch request")
}
if selection.Language != "zh" && selection.Language != "en" && selection.Language != "vis" {
return Selection{}, errors.New("invalid launch language")
}
switch selection.Mode {
case ModeLocal, ModeLink:
if strings.TrimSpace(selection.Site) != "" {
return Selection{}, errors.New("local launch cannot include a Site")
}
selection.Site = ""
case ModeSite:
if selection.LocalAccessPassword != "" {
return Selection{}, errors.New("Site launch cannot include a local access password")
}
normalized, err := appconfig.NormalizeSite(selection.Site)
if err != nil || normalized == "" {
return Selection{}, errors.New("Site launch requires an HTTP or HTTPS origin")
}
selection.Site = normalized
default:
return Selection{}, errors.New("unknown launch mode")
}
return selection, nil
}
func validTarget(raw string) bool {
parsed, err := url.Parse(raw)
return err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != ""
}
func writeJSON(response http.ResponseWriter, status int, value any) {
payload, err := json.Marshal(value)
if err != nil {
http.Error(response, "response unavailable", http.StatusInternalServerError)
return
}
response.Header().Set("Cache-Control", "no-store")
response.Header().Set("Content-Type", "application/json; charset=utf-8")
response.WriteHeader(status)
_, _ = response.Write(payload)
}