Files
Piik/internal/client/clientapp/app.go
T
AAA 1332473513 feat: rename product and runtime to Piik
Rename the active product, Go module, binaries, configuration, protocols and documentation to Piik while retaining the TV mascot and existing media/room behavior.

Use one current private contract without old-name aliases. Preserve SQLite authority and copy compatible local settings once; other settings start fresh. Git-message cleanup and the infrastructure name cutover are coordinated with this phase, with original history and deployment data archived externally.

Validated locally with Web and Go checks, Windows capture compilation, matched Client/Server builds, packaged Client control handshake and history-rewrite integrity checks. Skip duplicate hosted CI for this owner-authorized local release.

[skip ci]
2026-09-10 11:10:12 +08:00

612 lines
18 KiB
Go

package clientapp
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/TNTcraftHIM/Piik/internal/client/browser"
"github.com/TNTcraftHIM/Piik/internal/client/clientconfig"
"github.com/TNTcraftHIM/Piik/internal/client/lan"
"github.com/TNTcraftHIM/Piik/internal/client/launcher"
"github.com/TNTcraftHIM/Piik/internal/client/loopback"
"github.com/TNTcraftHIM/Piik/internal/client/nativecapture"
"github.com/TNTcraftHIM/Piik/internal/client/nativecontrol"
"github.com/TNTcraftHIM/Piik/internal/client/publictunnel"
"github.com/TNTcraftHIM/Piik/internal/diagnostics"
serverapp "github.com/TNTcraftHIM/Piik/internal/server/app"
serverconfig "github.com/TNTcraftHIM/Piik/internal/server/config"
"github.com/TNTcraftHIM/Piik/internal/server/webassets"
)
const (
DefaultLocalPort = 8787
publicSTUNURL = "stun:stun.cloudflare.com:3478"
publicNATPredictionSTUNURLA = "stun:stun.miwifi.com:3478"
publicNATPredictionSTUNURLB = "stun:stun.chat.bilibili.com:3478"
// localShutdownTimeout bounds the ordered Local stop. The packaged smoke
// allows 10 s between the stop request and the exit, so it stays well
// inside that budget.
localShutdownTimeout = 5 * time.Second
)
var BuildRevision = "development"
type Options struct {
Site string
SiteSet bool
Local bool
Link bool
ConfigPath string
LANAddress string
Port int
DisableBrowser bool
Debug bool
LogDir string
CaptureProcess string
TunnelProcess string
Ready func(string)
console *clientConsole
logger *slog.Logger
}
func Run(ctx context.Context, options Options) (returnedErr error) {
ctx, cancel := context.WithCancel(ctx)
options.Debug = options.Debug || clientDebugEnabled(os.Getenv("PIIK_DEBUG"))
options.console = newClientConsole(cancel, options.DisableBrowser)
var recorder *diagnostics.Recorder
var restoreLogger func()
defer func() {
cancel()
if recorder != nil {
slog.Debug("piik-client", "event", "stopped", "failed", returnedErr != nil, diagnostics.Error(returnedErr))
if options.console.program == nil {
path, err := recorder.Export()
options.console.send(consoleExportResult{path: path, err: err})
returnedErr = errors.Join(returnedErr, err)
}
restoreLogger()
returnedErr = errors.Join(returnedErr, recorder.Close())
}
returnedErr = errors.Join(returnedErr, options.console.finish(returnedErr))
}()
if options.Debug {
var err error
recorder, err = openClientDiagnostics(options.LogDir)
if err != nil {
return fmt.Errorf("Piik Client diagnostics are unavailable: %w", err)
}
previous, previousWriter, previousFlags := slog.Default(), log.Writer(), log.Flags()
var dependencyLog *diagnostics.LineWriter
restoreLogger = func() {
_ = dependencyLog.Close()
slog.SetDefault(previous)
log.SetOutput(previousWriter)
log.SetFlags(previousFlags)
}
options.logger = recorder.Logger()
slog.SetDefault(options.logger)
dependencyLog = diagnostics.Writer("stdlib")
log.SetOutput(dependencyLog)
log.SetFlags(previousFlags)
options.console.send(consoleDebug{logPath: recorder.LogPath(), export: recorder.Export})
}
slog.Debug("piik-client", "event", "start", "revision", BuildRevision)
options.console.show(consoleView{state: "starting"})
if err := validateMode(options); err != nil {
return err
}
if options.Port == 0 {
options.Port = DefaultLocalPort
}
configPath := strings.TrimSpace(options.ConfigPath)
if configPath == "" {
var err error
configPath, err = clientconfig.DefaultPath()
if err != nil {
return errors.New("Piik Client configuration is unavailable")
}
}
config, err := clientconfig.LoadOrCreate(configPath)
if err != nil {
return errors.New("Piik Client configuration is unavailable")
}
config, err = applyMode(config, options)
if err != nil {
return err
}
slog.Debug("piik-client", "event", "configuration", "siteConfigured", config.Site != "", "localAccessProtected", config.LocalAccessPassword != "")
if options.SiteSet || options.Local {
if err = clientconfig.Save(configPath, config); err != nil {
return errors.New("Piik Client configuration is unavailable")
}
}
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 != ""})
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)
client, err := loopback.Start(ctx, loopback.Options{
AllowedOrigins: clientOrigins(config.Site, options.Port),
NativeMedia: nativeMedia.capabilities,
NewControl: nativeMedia.controlFactory(),
Presentation: options.console.setLanguage,
})
if err != nil {
return errors.New("Piik Client could not start")
}
defer client.Close()
slog.Debug("piik-client", "event", "control-ready")
if options.console.machine {
if err = printEndpoint(client.Endpoint()); err != nil {
return err
}
}
if !explicitMode(options) {
return runLauncher(ctx, options, configPath, config, client)
}
return runConfigured(ctx, options, config, client)
}
func explicitMode(options Options) bool {
return options.SiteSet || options.Local || options.Link || options.DisableBrowser
}
func runConfigured(
ctx context.Context,
options Options,
config clientconfig.Config,
client *loopback.Server,
) error {
if config.Site != "" && !options.Local && !options.Link {
return runSite(config.Site, options, client)
}
return runLocal(ctx, options, config, client)
}
func runLauncher(
ctx context.Context,
options Options,
configPath string,
config clientconfig.Config,
client *loopback.Server,
) error {
launch, err := launcher.Start(
ctx,
webassets.FS(),
config.Site,
BuildRevision,
config.LocalAccessPassword,
)
if err != nil {
return err
}
defer launch.Close()
launchAddress, err := url.Parse(launch.URL())
if err != nil {
return err
}
client.SetAllowedOrigins(append(
clientOrigins(config.Site, options.Port),
launchAddress.Scheme+"://"+launchAddress.Host,
))
options.console.show(consoleView{state: "setup", entry: launch.URL()})
if err = browser.Open(launch.URL()); err != nil {
return errors.New("Piik Client could not open its launcher")
}
var selection launcher.Selection
select {
case selection = <-launch.Selection():
case <-ctx.Done():
return nil
case err = <-launch.Done():
if err != nil {
return errors.New("Piik Client launcher stopped unexpectedly")
}
return nil
}
options.console.setLanguage(selection.Language)
if selection.Mode == launcher.ModeSite {
config.Site = selection.Site
} else {
config.LocalAccessPassword = selection.LocalAccessPassword
}
if err = clientconfig.Save(configPath, config); err != nil {
launch.SetResult("", err)
<-launch.Handled()
return errors.New("Piik Client configuration is unavailable")
}
client.SetAllowedOrigins(clientOrigins(config.Site, options.Port))
options.SiteSet = false
options.Local = selection.Mode == launcher.ModeLocal
options.Link = selection.Mode == launcher.ModeLink
options.DisableBrowser = true
ready := make(chan string, 1)
options.Ready = func(target string) { ready <- target }
runtimeContext, cancelRuntime := context.WithCancel(ctx)
runtimeDone := make(chan struct{})
var runtimeErr error
go func() {
runtimeErr = runConfigured(runtimeContext, options, config, client)
close(runtimeDone)
}()
defer func() {
cancelRuntime()
<-runtimeDone
}()
select {
case target := <-ready:
launch.SetResult(target, nil)
case <-runtimeDone:
launch.SetResult("", runtimeErr)
<-launch.Handled()
return runtimeErr
case <-ctx.Done():
launch.SetResult("", ctx.Err())
return nil
}
select {
case <-launch.Handled():
_ = launch.Close()
case <-runtimeDone:
return runtimeErr
case <-ctx.Done():
return nil
}
select {
case <-runtimeDone:
return runtimeErr
case <-ctx.Done():
return nil
}
}
func applyMode(config clientconfig.Config, options Options) (clientconfig.Config, error) {
if options.SiteSet && options.Local {
return clientconfig.Config{}, errors.New("choose either --site or --local")
}
if options.SiteSet {
var err error
config.Site, err = clientconfig.NormalizeSite(options.Site)
if err != nil || config.Site == "" {
return clientconfig.Config{}, errors.New("Piik Site must be an HTTP or HTTPS origin")
}
} else if options.Local {
config.Site = ""
}
return config, nil
}
func validateMode(options Options) error {
if options.Link && (options.SiteSet || options.Local) {
return errors.New("--link is a self-contained Local mode")
}
return nil
}
func clientDebugEnabled(value string) bool {
for _, component := range strings.Split(value, ",") {
if strings.TrimSpace(component) == "client" {
return true
}
}
return false
}
func openClientDiagnostics(directory string) (*diagnostics.Recorder, error) {
if directory = strings.TrimSpace(directory); directory == "" {
directory = strings.TrimSpace(os.Getenv("PIIK_LOG_DIR"))
}
if directory != "" {
return diagnostics.Open(directory, "client", BuildRevision)
}
executable, err := os.Executable()
if err != nil {
return nil, err
}
recorder, err := diagnostics.Open(filepath.Join(filepath.Dir(executable), "logs"), "client", BuildRevision)
if err == nil || !errors.Is(err, os.ErrPermission) && !errors.Is(err, syscall.EROFS) {
return recorder, err
}
cache, cacheErr := os.UserCacheDir()
if cacheErr != nil {
return nil, errors.Join(err, cacheErr)
}
return diagnostics.Open(filepath.Join(cache, "Piik", "logs"), "client", BuildRevision)
}
// runSite opens the configured Piik Site in the Browser and waits for the
// loopback server, which is the only thing this mode owns. It takes no context:
// cancellation reaches it through client.Done().
func runSite(site string, options Options, client *loopback.Server) error {
var err error
slog.Debug("piik-client", "event", "mode", "mode", "site")
launchURL := clientLaunchURL(site)
view := consoleView{mode: "site", state: "starting", entry: launchURL}
options.console.show(view)
if options.console.machine {
fmt.Printf("Piik Site: %s\n", site)
}
if !options.DisableBrowser {
if err = browser.Open(launchURL); err != nil {
return errors.New("Piik Client could not open the Site")
}
} else if options.Ready != nil {
options.Ready(launchURL)
}
view.state = "ready"
slog.Debug("piik-client", "event", "site-ready")
options.console.show(view)
if err = <-client.Done(); err != nil {
return errors.New("Piik Client stopped unexpectedly")
}
return nil
}
func runLocal(ctx context.Context, options Options, config clientconfig.Config,
client *loopback.Server,
) error {
view := consoleView{mode: "local", state: "starting", protected: config.LocalAccessPassword != ""}
if options.Link {
view.mode = "link"
}
slog.Debug("piik-client", "event", "mode", "mode", view.mode)
options.console.show(view)
listener, err := net.ListenTCP("tcp4", &net.TCPAddr{IP: net.IPv4zero, Port: options.Port})
if err != nil {
return fmt.Errorf("local server port is unavailable: %w", err)
}
defer func() {
if listener != nil {
_ = listener.Close()
}
}()
addresses, err := lan.Addresses()
if err != nil {
return err
}
selectedAddress, err := lan.Select(addresses, options.LANAddress)
if err != nil {
return err
}
var tunnel *publictunnel.Process
publicOrigin := ""
if options.Link {
tunnel, err = publictunnel.Start(
ctx,
tunnelExecutable(options.TunnelProcess),
fmt.Sprintf("http://127.0.0.1:%d", options.Port),
)
if err != nil {
return err
}
defer tunnel.Close()
publicOrigin = tunnel.Origin()
slog.Debug("piik-client", "event", "public-link-ready")
}
stunURLs, natPredictionStunURLs := localSTUNURLs(options.Link)
localConfig, err := serverconfig.Local(serverconfig.LocalOptions{
Port: options.Port,
PublicAddress: selectedAddress,
PublicOrigin: publicOrigin,
AllowedAddresses: addresses,
SiteAccessPassword: config.LocalAccessPassword,
STUNURLs: stunURLs,
NATPredictionSTUNURLs: natPredictionStunURLs,
})
if err != nil {
return err
}
logger := options.logger
if logger == nil {
logger = slog.New(slog.NewTextHandler(options.console.logWriter(), nil))
}
localServer, err := serverapp.New(serverapp.Options{
Config: localConfig,
Listener: listener,
Assets: webassets.FS(),
Logger: logger,
})
if err != nil {
return err
}
listener = nil // Ownership is now in the application's startup/shutdown path.
defer func() { _ = endLocalServer(localServer) }()
if _, err = localServer.Listen(ctx); err != nil {
return err
}
slog.Debug("piik-client", "event", "local-server-ready", "port", options.Port, "publicLink", publicOrigin != "")
// The readiness lines follow the listener, which the packaged smoke and the
// public-link gate both read from stdout before they probe the port.
if options.console.machine {
if config.LocalAccessPassword == "" {
fmt.Println("Local access: open")
} else {
fmt.Printf("Local access password: %s\n", config.LocalAccessPassword)
}
if publicOrigin != "" {
fmt.Printf("Public invitation origin: %s\n", publicOrigin)
} else {
fmt.Printf("LAN invitation origin: http://%s:%d\n", selectedAddress, options.Port)
}
}
launchURL := clientLaunchURLWithLocalAccess(
fmt.Sprintf("http://127.0.0.1:%d/", options.Port),
config.LocalAccessPassword,
)
if !options.DisableBrowser {
if err = browser.Open(launchURL); err != nil {
return errors.New("Piik Client could not open the Local page")
}
} else if options.Ready != nil {
options.Ready(launchURL)
}
view.state, view.entry, view.invite = "ready", launchURL, publicOrigin
if view.invite == "" {
view.invite = fmt.Sprintf("http://%s:%d", selectedAddress, options.Port)
}
options.console.show(view)
var tunnelDone <-chan struct{}
if tunnel != nil {
tunnelDone = tunnel.Done()
}
select {
case <-ctx.Done():
return endLocalServer(localServer)
case err = <-client.Done():
if err != nil {
return errors.New("Piik Client runtime stopped unexpectedly")
}
return nil
case <-tunnelDone:
return errors.New("public invitation link stopped unexpectedly")
}
}
func clientLaunchURL(raw string) string {
parsed, err := url.Parse(raw)
if err != nil {
return raw
}
fragment, err := url.ParseQuery(parsed.Fragment)
if err != nil {
return raw
}
fragment.Set("piik-client", "1")
parsed.Fragment = fragment.Encode()
return parsed.String()
}
func clientLaunchURLWithLocalAccess(raw, password string) string {
parsed, err := url.Parse(raw)
if err != nil {
return raw
}
fragment, err := url.ParseQuery(parsed.Fragment)
if err != nil {
return clientLaunchURL(raw)
}
fragment.Del("client-access")
if password != "" {
fragment.Set("client-access", password)
}
fragment.Set("piik-client", "1")
parsed.Fragment = fragment.Encode()
return parsed.String()
}
type nativeRuntime struct {
capabilities loopback.NativeMediaCapabilities
capture nativecapture.Capabilities
captureProcess string
}
func (runtime nativeRuntime) controlFactory() func() loopback.ControlSession {
return func() loopback.ControlSession {
return nativecontrol.New(runtime.captureProcess, runtime.capture, true)
}
}
func clientOrigins(site string, localPort int) []string {
origins := []string{
fmt.Sprintf("http://localhost:%d", localPort),
fmt.Sprintf("http://127.0.0.1:%d", localPort),
}
if site = strings.TrimSpace(site); site != "" {
origins = append(origins, site)
}
return origins
}
func discoverNativeMedia(ctx context.Context, configuredPath string) nativeRuntime {
path := strings.TrimSpace(configuredPath)
if path == "" {
path = nativecapture.PackagedExecutable()
}
capabilities, err := nativecapture.Discover(ctx, path)
if err != nil {
return nativeRuntime{}
}
summary := capabilities.Summary()
return nativeRuntime{
captureProcess: path,
capture: capabilities,
capabilities: loopback.NativeMediaCapabilities{
Video: summary.Video,
ProcessAudio: summary.ProcessAudio,
SystemAudio: summary.SystemAudio,
HardwareH264: summary.HardwareH264,
SoftwareVP8: summary.SoftwareVP8,
},
}
}
// localSTUNURLs is the Local room authority's ICE configuration. Only --link
// has a public path, so only --link configures public STUN and the bounded NAT
// prediction survey. The lists are literals on purpose: a gate that sets
// STUN_URLS is configuring the Client's own Pion edge, and that value must not
// reach the room server (which is why serverconfig.Local reads no environment).
func localSTUNURLs(link bool) (stunURLs []string, natPredictionSTUNURLs []string) {
if !link {
return nil, nil
}
return []string{publicSTUNURL},
[]string{publicNATPredictionSTUNURLA, publicNATPredictionSTUNURLB}
}
// endLocalServer is step 1 of the ADR-0010 item 12 unwind: every room ends,
// signaling closes, the listener drains and the room store closes, all before
// the tunnel and the loopback server go away. serverapp.End is memoised, so the
// deferred call after an explicit stop is a no-op. A serve loop or a drain that
// was already stopping reports http.ErrServerClosed or context.Canceled;
// neither is a Client failure, and Run turns any error into exit status 1,
// which is exactly what the packaged smoke fails on.
func endLocalServer(server *serverapp.Server) error {
ctx, cancel := context.WithTimeout(context.Background(), localShutdownTimeout)
defer cancel()
err := server.End(ctx)
if errors.Is(err, http.ErrServerClosed) || errors.Is(err, context.Canceled) {
return nil
}
return err
}
func tunnelExecutable(configured string) string {
if path := strings.TrimSpace(configured); path != "" {
return filepath.Clean(path)
}
return publictunnel.PackagedExecutable()
}
func printEndpoint(endpoint loopback.Endpoint) error {
payload, err := json.Marshal(endpoint)
if err != nil {
return errors.New("Piik Client endpoint is unavailable")
}
fmt.Println(string(payload))
return nil
}